diff --git a/.betterer.results b/.betterer.results
index f540cfeba20..235ffb75cae 100644
--- a/.betterer.results
+++ b/.betterer.results
@@ -8,21 +8,9 @@ exports[`no enzyme tests`] = {
"packages/grafana-ui/src/components/BarGauge/BarGauge.test.tsx:2315765050": [
[1, 19, 13, "RegExp match", "2409514259"]
],
- "packages/grafana-ui/src/components/BigValue/BigValue.test.tsx:261212211": [
- [1, 19, 13, "RegExp match", "2409514259"]
- ],
- "packages/grafana-ui/src/components/CallToActionCard/CallToActionCard.test.tsx:2771878691": [
- [1, 18, 13, "RegExp match", "2409514259"]
- ],
"packages/grafana-ui/src/components/ColorPicker/NamedColorsPalette.test.tsx:1355456933": [
[1, 31, 13, "RegExp match", "2409514259"]
],
- "packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.test.tsx:3194817479": [
- [2, 33, 13, "RegExp match", "2409514259"]
- ],
- "packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.test.tsx:3838344574": [
- [1, 17, 13, "RegExp match", "2409514259"]
- ],
"packages/grafana-ui/src/components/FileUpload/FileUpload.test.tsx:3475964456": [
[1, 19, 13, "RegExp match", "2409514259"]
],
@@ -116,7 +104,7 @@ exports[`no enzyme tests`] = {
"packages/jaeger-ui-components/src/TraceTimelineViewer/ReferencesButton.test.js:2288177454": [
[15, 19, 13, "RegExp match", "2409514259"]
],
- "packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBar.test.js:2545268168": [
+ "packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBar.test.js:2127169675": [
[15, 17, 13, "RegExp match", "2409514259"]
],
"packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBarRow.test.js:2454947085": [
@@ -197,9 +185,6 @@ exports[`no enzyme tests`] = {
"public/app/core/components/Select/MetricSelect.test.tsx:3409251428": [
[1, 19, 13, "RegExp match", "2409514259"]
],
- "public/app/core/specs/OrgSwitcher.test.tsx:848670248": [
- [2, 19, 13, "RegExp match", "2409514259"]
- ],
"public/app/features/alerting/AlertRuleList.test.tsx:1800339390": [
[1, 19, 13, "RegExp match", "2409514259"]
],
@@ -218,7 +203,7 @@ exports[`no enzyme tests`] = {
"public/app/features/dashboard/components/SaveDashboard/forms/SaveDashboardForm.test.tsx:4134073823": [
[1, 17, 13, "RegExp match", "2409514259"]
],
- "public/app/features/dashboard/components/ShareModal/ShareLink.test.tsx:1141305288": [
+ "public/app/features/dashboard/components/ShareModal/ShareLink.test.tsx:1044891955": [
[1, 35, 13, "RegExp match", "2409514259"]
],
"public/app/features/dashboard/dashgrid/DashboardGrid.test.tsx:1798654441": [
@@ -290,6 +275,9 @@ exports[`no enzyme tests`] = {
"public/app/features/folders/FolderSettingsPage.test.tsx:1751147194": [
[2, 19, 13, "RegExp match", "2409514259"]
],
+ "public/app/features/invites/InviteesTable.test.tsx:3077684439": [
+ [1, 19, 13, "RegExp match", "2409514259"]
+ ],
"public/app/features/org/OrgDetailsPage.test.tsx:2540662821": [
[1, 19, 13, "RegExp match", "2409514259"]
],
@@ -317,13 +305,10 @@ exports[`no enzyme tests`] = {
"public/app/features/teams/TeamSettings.test.tsx:2628968507": [
[1, 19, 13, "RegExp match", "2409514259"]
],
- "public/app/features/users/InviteesTable.test.tsx:2271264692": [
- [1, 19, 13, "RegExp match", "2409514259"]
- ],
"public/app/features/users/UsersActionBar.test.tsx:4031641375": [
[1, 19, 13, "RegExp match", "2409514259"]
],
- "public/app/features/users/UsersListPage.test.tsx:2523261097": [
+ "public/app/features/users/UsersListPage.test.tsx:2626906707": [
[1, 19, 13, "RegExp match", "2409514259"]
],
"public/app/features/users/UsersTable.test.tsx:3051231816": [
diff --git a/.drone.star b/.drone.star
index f025f8df415..d2723f88be8 100644
--- a/.drone.star
+++ b/.drone.star
@@ -6,6 +6,7 @@
load('scripts/drone/pipelines/pr.star', 'pr_pipelines')
load('scripts/drone/pipelines/main.star', 'main_pipelines')
+load('scripts/drone/pipelines/docs.star', 'docs_pipelines')
load('scripts/drone/pipelines/release.star', 'release_pipelines', 'publish_image_pipelines', 'publish_artifacts_pipelines', 'publish_npm_pipelines', 'publish_packages_pipeline')
load('scripts/drone/version.star', 'version_branch_pipelines')
load('scripts/drone/pipelines/cron.star', 'cronjobs')
@@ -13,7 +14,7 @@ load('scripts/drone/vault.star', 'secrets')
def main(ctx):
edition = 'oss'
- return pr_pipelines(edition=edition) + main_pipelines(edition=edition) + release_pipelines() + \
+ return pr_pipelines(edition=edition) + docs_pipelines(edition=edition) + main_pipelines(edition=edition) + release_pipelines() + \
publish_image_pipelines('public') + publish_image_pipelines('security') + \
publish_artifacts_pipelines('security') + publish_artifacts_pipelines('public') + \
publish_npm_pipelines('public') + publish_packages_pipeline() + \
diff --git a/.drone.yml b/.drone.yml
index 21d0852c426..a3ec9b7f565 100644
--- a/.drone.yml
+++ b/.drone.yml
@@ -11,7 +11,7 @@ services: []
steps:
- commands:
- mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
@@ -93,6 +93,9 @@ steps:
trigger:
event:
- pull_request
+ paths:
+ exclude:
+ - docs/**
type: docker
volumes:
- host:
@@ -111,7 +114,7 @@ services: []
steps:
- commands:
- mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
@@ -288,20 +291,6 @@ steps:
failure: always
image: grafana/docker-puppeteer:1.0.0
name: test-a11y-frontend
-- commands:
- - ./scripts/ci-reference-docs-lint.sh ci
- depends_on:
- - build-frontend
- image: grafana/build-container:1.4.9
- name: build-frontend-docs
-- commands:
- - mkdir -p /hugo/content/docs/grafana
- - cp -r docs/sources/* /hugo/content/docs/grafana/latest/
- - cd /hugo && make prod
- depends_on:
- - build-frontend-docs
- image: grafana/docs-base:latest
- name: build-docs-website
- commands:
- ls dist/*.tar.gz*
- cp dist/*.tar.gz* packaging/docker/
@@ -324,6 +313,9 @@ steps:
trigger:
event:
- pull_request
+ paths:
+ exclude:
+ - docs/**
type: docker
volumes:
- host:
@@ -362,7 +354,7 @@ services:
steps:
- commands:
- mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
@@ -399,6 +391,9 @@ steps:
trigger:
event:
- pull_request
+ paths:
+ exclude:
+ - docs/**
type: docker
volumes:
- host:
@@ -413,6 +408,68 @@ volumes:
---
depends_on: []
kind: pipeline
+name: pr-docs
+node:
+ type: no-parallel
+platform:
+ arch: amd64
+ os: linux
+services: []
+steps:
+- commands:
+ - mkdir -p bin
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl
+ - chmod +x bin/grabpl
+ image: byrnedo/alpine-curl:0.1.8
+ name: grabpl
+- commands:
+ - echo $DRONE_RUNNER_NAME
+ image: alpine:3.15
+ name: identify-runner
+- commands:
+ - make gen-go
+ - ./bin/grabpl gen-version --build-id ${DRONE_BUILD_NUMBER}
+ - yarn install --immutable
+ image: grafana/build-container:1.4.9
+ name: initialize
+- commands:
+ - ./bin/grabpl build-frontend --jobs 8 --no-install-deps --edition oss --build-id
+ ${DRONE_BUILD_NUMBER} --no-pull-enterprise
+ depends_on:
+ - initialize
+ environment:
+ NODE_OPTIONS: --max_old_space_size=8192
+ image: grafana/build-container:1.4.9
+ name: build-frontend
+- commands:
+ - ./scripts/ci-reference-docs-lint.sh ci
+ depends_on:
+ - build-frontend
+ image: grafana/build-container:1.4.9
+ name: build-frontend-docs
+- commands:
+ - mkdir -p /hugo/content/docs/grafana
+ - cp -r docs/sources/* /hugo/content/docs/grafana/latest/
+ - cd /hugo && make prod
+ depends_on:
+ - build-frontend-docs
+ image: grafana/docs-base:latest
+ name: build-docs-website
+trigger:
+ event:
+ - pull_request
+ paths:
+ include:
+ - docs/**
+ - packages/**
+type: docker
+volumes:
+- host:
+ path: /var/run/docker.sock
+ name: docker
+---
+depends_on: []
+kind: pipeline
name: main-test
node:
type: no-parallel
@@ -423,7 +480,7 @@ services: []
steps:
- commands:
- mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
@@ -524,7 +581,7 @@ services: []
steps:
- commands:
- mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
@@ -909,7 +966,7 @@ services:
steps:
- commands:
- mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
@@ -977,7 +1034,7 @@ steps:
name: identify-runner
- commands:
- $$ProgressPreference = "SilentlyContinue"
- - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/windows/grabpl.exe
+ - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/windows/grabpl.exe
-OutFile grabpl.exe
image: grafana/ci-wix:0.1.1
name: initialize
@@ -1060,7 +1117,7 @@ services: []
steps:
- commands:
- mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
@@ -1142,7 +1199,7 @@ services: []
steps:
- commands:
- mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
@@ -1446,7 +1503,7 @@ services: []
steps:
- commands:
- mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
@@ -1567,7 +1624,7 @@ services:
steps:
- commands:
- mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
@@ -1651,7 +1708,7 @@ steps:
name: identify-runner
- commands:
- $$ProgressPreference = "SilentlyContinue"
- - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/windows/grabpl.exe
+ - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/windows/grabpl.exe
-OutFile grabpl.exe
image: grafana/ci-wix:0.1.1
name: initialize
@@ -1710,7 +1767,7 @@ services: []
steps:
- commands:
- mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
@@ -2050,7 +2107,7 @@ services: []
steps:
- commands:
- mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
@@ -2223,7 +2280,7 @@ services:
steps:
- commands:
- mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
@@ -2351,7 +2408,7 @@ steps:
name: identify-runner
- commands:
- $$ProgressPreference = "SilentlyContinue"
- - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/windows/grabpl.exe
+ - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/windows/grabpl.exe
-OutFile grabpl.exe
- git clone "https://$$env:GITHUB_TOKEN@github.com/grafana/grafana-enterprise.git"
- cd grafana-enterprise
@@ -2426,7 +2483,7 @@ services: []
steps:
- commands:
- mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
@@ -2504,7 +2561,7 @@ services: []
steps:
- commands:
- mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
@@ -2565,7 +2622,7 @@ services: []
steps:
- commands:
- mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
@@ -2644,7 +2701,7 @@ services: []
steps:
- commands:
- mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
@@ -2706,7 +2763,7 @@ services: []
steps:
- commands:
- mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
@@ -2742,7 +2799,7 @@ services: []
steps:
- commands:
- mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
@@ -2789,7 +2846,7 @@ steps:
name: initialize
- commands:
- mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
@@ -2837,7 +2894,7 @@ services: []
steps:
- commands:
- mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
@@ -2900,7 +2957,7 @@ services: []
steps:
- commands:
- mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
@@ -3159,7 +3216,7 @@ services: []
steps:
- commands:
- mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
@@ -3273,7 +3330,7 @@ services:
steps:
- commands:
- mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
@@ -3350,7 +3407,7 @@ steps:
name: identify-runner
- commands:
- $$ProgressPreference = "SilentlyContinue"
- - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/windows/grabpl.exe
+ - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/windows/grabpl.exe
-OutFile grabpl.exe
image: grafana/ci-wix:0.1.1
name: initialize
@@ -3398,7 +3455,7 @@ services: []
steps:
- commands:
- mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
@@ -3731,7 +3788,7 @@ services: []
steps:
- commands:
- mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
@@ -3894,7 +3951,7 @@ services:
steps:
- commands:
- mkdir -p bin
- - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/grabpl
+ - curl -fL -o bin/grabpl https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/grabpl
- chmod +x bin/grabpl
image: byrnedo/alpine-curl:0.1.8
name: grabpl
@@ -4012,7 +4069,7 @@ steps:
name: identify-runner
- commands:
- $$ProgressPreference = "SilentlyContinue"
- - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.0/windows/grabpl.exe
+ - Invoke-WebRequest https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.3/windows/grabpl.exe
-OutFile grabpl.exe
- git clone "https://$$env:GITHUB_TOKEN@github.com/grafana/grafana-enterprise.git"
- cd grafana-enterprise
@@ -4209,6 +4266,6 @@ kind: secret
name: gcp_upload_artifacts_key
---
kind: signature
-hmac: 559c02d03809e345c15ec6badc083947ac12d79dc851596a317a5bd83ad75941
+hmac: 703b9a44a073ab768a774029cee2c3fc7c5fe7b7e01495e18f048bc7d1f007b0
...
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 88f575d5b9d..495abdd9d94 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -104,7 +104,7 @@ go.sum @grafana/backend-platform
/public/app/features/live/ @grafana/grafana-edge-squad
/public/app/features/explore/ @grafana/observability-experience-squad
/public/app/features/plugins @grafana/plugins-platform-frontend
-/public/app/core/components/TransformersUI/spatial @grafana/grafana-edge-squad
+/public/app/features/transformers/spatial @grafana/grafana-edge-squad
/public/app/plugins/panel/alertlist @grafana/alerting-squad
/public/app/plugins/panel/barchart @grafana/grafana-bi-squad
/public/app/plugins/panel/heatmap @grafana/grafana-bi-squad
diff --git a/.github/workflows/detect-breaking-changes-build.yml b/.github/workflows/detect-breaking-changes-build.yml
index 41d3a05aa39..87096eaf159 100644
--- a/.github/workflows/detect-breaking-changes-build.yml
+++ b/.github/workflows/detect-breaking-changes-build.yml
@@ -112,7 +112,7 @@ jobs:
- name: Get link for the Github Action job
id: job
- uses: actions/github-script@v5
+ uses: actions/github-script@v6
with:
script: |
const script = require('./.github/workflows/scripts/pr-get-job-link.js')
diff --git a/.github/workflows/detect-breaking-changes-report.yml b/.github/workflows/detect-breaking-changes-report.yml
index f7a096a288e..fe5f2f1f490 100644
--- a/.github/workflows/detect-breaking-changes-report.yml
+++ b/.github/workflows/detect-breaking-changes-report.yml
@@ -17,7 +17,7 @@ jobs:
- uses: actions/checkout@v2
- name: 'Download artifact'
- uses: actions/github-script@v5
+ uses: actions/github-script@v6
env:
RUN_ID: ${{ github.event.workflow_run.id }}
with:
@@ -53,7 +53,7 @@ jobs:
run: unzip "${ARTIFACT_FOLDER}/${ARTIFACT_NAME}.zip" -d "${ARTIFACT_FOLDER}"
- name: Parsing levitate result
- uses: actions/github-script@v5
+ uses: actions/github-script@v6
id: levitate-run
with:
script: |
@@ -63,7 +63,7 @@ jobs:
- name: Check if "breaking change" label exists
id: does-label-exist
- uses: actions/github-script@v5
+ uses: actions/github-script@v6
env:
PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }}
with:
@@ -91,7 +91,7 @@ jobs:
${{ steps.levitate-run.outputs.message }}
[Console output](${{ steps.levitate-run.outputs.job_link }})
- [Read our guideline](../../contribute/breaking-changes-guide.md)
+ [Read our guideline](https://github.com/grafana/grafana/blob/main/contribute/breaking-changes-guide.md)
- name: Remove comment on PR
if: ${{ steps.levitate-run.outputs.exit_code == 0 }}
@@ -119,7 +119,7 @@ jobs:
- name: Add "breaking change" label
if: ${{ steps.levitate-run.outputs.exit_code == 1 && steps.does-label-exist.outputs.result == 0 }}
- uses: actions/github-script@v5
+ uses: actions/github-script@v6
env:
PR_NUMBER: ${{ steps.levitate-run.outputs.pr_number }}
with:
@@ -134,7 +134,7 @@ jobs:
- name: Remove "breaking change" label
if: ${{ steps.levitate-run.outputs.exit_code == 0 && steps.does-label-exist.outputs.result == 1 }}
- uses: actions/github-script@v5
+ uses: actions/github-script@v6
env:
PR_NUMBER: ${{ steps.levitate-run.outputs.pr_number }}
with:
@@ -151,7 +151,7 @@ jobs:
# Related issue: https://github.com/renovatebot/renovate/issues/1908
- name: Add "grafana/plugins-platform-frontend" as a reviewer
if: ${{ steps.levitate-run.outputs.exit_code == 1 }}
- uses: actions/github-script@v5
+ uses: actions/github-script@v6
env:
PR_NUMBER: ${{ steps.levitate-run.outputs.pr_number }}
with:
@@ -167,7 +167,7 @@ jobs:
- name: Remove "grafana/plugins-platform-frontend" from the list of reviewers
if: ${{ steps.levitate-run.outputs.exit_code == 0 }}
- uses: actions/github-script@v5
+ uses: actions/github-script@v6
env:
PR_NUMBER: ${{ steps.levitate-run.outputs.pr_number }}
with:
diff --git a/.vscode/launch.json b/.vscode/launch.json
index 5eaae3ff47b..112f73498bc 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -15,7 +15,8 @@
"name": "Debug Jest test",
"type": "node",
"request": "launch",
- "runtimeArgs": ["--inspect-brk", "${workspaceRoot}/node_modules/.bin/jest", "--runInBand", "${file}"],
+ "runtimeExecutable": "yarn",
+ "runtimeArgs": ["run", "jest", "--runInBand", "${file}"],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"port": 9229
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 18d57c1e256..30bd5c155a3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,73 @@
+
+
+# 8.4.1 (2022-02-18)
+
+### Features and enhancements
+
+- **Cloudwatch:** Add support for AWS/PrivateLink\* metrics and dimensions. [#45515](https://github.com/grafana/grafana/pull/45515), [@szymonpk](https://github.com/szymonpk)
+- **Configuration:** Add ability to customize okta login button name and icon. [#44079](https://github.com/grafana/grafana/pull/44079), [@DanCech](https://github.com/DanCech)
+- **Tempo:** Switch out Select with AsyncSelect component to get loading state in Tempo Search. [#45110](https://github.com/grafana/grafana/pull/45110), [@CatPerry](https://github.com/CatPerry)
+
+### Bug fixes
+
+- **Alerting:** Fix migrations by making send_alerts_to field nullable. [#45572](https://github.com/grafana/grafana/pull/45572), [@santihernandezc](https://github.com/santihernandezc)
+
+
+
+
+# 8.4.0 (2022-02-16)
+
+### Features and enhancements
+
+- **API:** Extract OpenAPI specification from source code using go-swagger. [#40528](https://github.com/grafana/grafana/pull/40528), [@papagian](https://github.com/papagian)
+- **AccessControl:** Disable user remove and user update roles when they do not have the permissions. [#43429](https://github.com/grafana/grafana/pull/43429), [@Jguer](https://github.com/Jguer)
+- **AccessControl:** Provisioning for teams. [#43767](https://github.com/grafana/grafana/pull/43767), [@gamab](https://github.com/gamab)
+- **API:** Add usage stats preview endpoint. [#43899](https://github.com/grafana/grafana/pull/43899), [@Jguer](https://github.com/Jguer)
+- **Alerting:** Move slow queries in the scheduler to another goroutine. [#44423](https://github.com/grafana/grafana/pull/44423), [@grobinson-grafana](https://github.com/grobinson-grafana)
+- **Alerting:** Use time.Ticker instead of alerting.Ticker in ngalert. [#44395](https://github.com/grafana/grafana/pull/44395), [@grobinson-grafana](https://github.com/grobinson-grafana)
+- **Alerting:** add custom grouping to Alert Panel. [#44559](https://github.com/grafana/grafana/pull/44559), [@gillesdemey](https://github.com/gillesdemey)
+- **Analytics:** Add user id tracking to google analytics. [#42763](https://github.com/grafana/grafana/pull/42763), [@autoric](https://github.com/autoric)
+- **Angular:** Add AngularJS plugin support deprecation plan to docs site. [#45149](https://github.com/grafana/grafana/pull/45149), [@torkelo](https://github.com/torkelo)
+- **Auth:** implement auto_sign_up for auth.jwt. [#43502](https://github.com/grafana/grafana/pull/43502), [@sakjur](https://github.com/sakjur)
+- **Azure Monitor Logs:** Order subscriptions in resource picker by name. [#45228](https://github.com/grafana/grafana/pull/45228), [@sunker](https://github.com/sunker)
+- **Azure monitor Logs:** Optimize data fetching in resource picker. [#44549](https://github.com/grafana/grafana/pull/44549), [@sunker](https://github.com/sunker)
+- **AzureMonitor:** Filter list of resources by resourceType. [#43522](https://github.com/grafana/grafana/pull/43522), [@andresmgot](https://github.com/andresmgot)
+- **BarChart:** color by field, x time field, bar radius, label skipping. [#43257](https://github.com/grafana/grafana/pull/43257), [@leeoniya](https://github.com/leeoniya)
+- **Chore:** Implement OpenTelemetry in Grafana. [#42674](https://github.com/grafana/grafana/pull/42674), [@idafurjes](https://github.com/idafurjes)
+- **Cloud Monitoring:** Adds metric type to Metric drop down options. [#43268](https://github.com/grafana/grafana/pull/43268), [@tw1nk](https://github.com/tw1nk)
+- **CloudWatch:** Add Data Lifecycle Manager metrics and dimension. [#43310](https://github.com/grafana/grafana/pull/43310), [@ilyastoli](https://github.com/ilyastoli)
+- **CloudWatch:** Add Missing Elasticache Host-level metrics. [#43455](https://github.com/grafana/grafana/pull/43455), [@dhendo](https://github.com/dhendo)
+- **CloudWatch:** Add all ElastiCache Redis Metrics. [#43336](https://github.com/grafana/grafana/pull/43336), [@siavashs](https://github.com/siavashs)
+- **CloudWatch:** Add new AWS/ES metrics. [#43034](https://github.com/grafana/grafana/pull/43034), [@sunker](https://github.com/sunker)
+- **Cloudwatch:** Add syntax highlighting and autocomplete for "Metric Search". [#43985](https://github.com/grafana/grafana/pull/43985), [@sarahzinger](https://github.com/sarahzinger)
+- **Explore:** Support custom display label for exemplar links for Prometheus datasource. [#42732](https://github.com/grafana/grafana/pull/42732), [@JokerQueue](https://github.com/JokerQueue)
+- **Hotkeys:** Make time range absolute/permanent. [#43802](https://github.com/grafana/grafana/pull/43802), [@davkal](https://github.com/davkal)
+- **Playlists:** Enable sharing direct links to playlists. [#44161](https://github.com/grafana/grafana/pull/44161), [@ashharrison90](https://github.com/ashharrison90)
+- **SQLStore:** Prevent concurrent migrations. [#44101](https://github.com/grafana/grafana/pull/44101), [@papagian](https://github.com/papagian)
+- **SSE:** Add Mode to drop NaN/Inf/Null in Reduction operations. [#43583](https://github.com/grafana/grafana/pull/43583), [@kylebrandt](https://github.com/kylebrandt)
+- **Setting:** Support configuring feature toggles with bools instead of just passing an array. [#43326](https://github.com/grafana/grafana/pull/43326), [@bergquist](https://github.com/bergquist)
+- **TimeSeries:** Add support for negative Y and constant transform. [#44774](https://github.com/grafana/grafana/pull/44774), [@dprokop](https://github.com/dprokop)
+- **Transformations:** Add 'JSON' field type to ConvertFieldTypeTransformer. [#42624](https://github.com/grafana/grafana/pull/42624), [@sd2k](https://github.com/sd2k)
+
+### Bug fixes
+
+- **Auth:** Guarantee consistency of signed SigV4 headers. [#45054](https://github.com/grafana/grafana/pull/45054), [@wbrowne](https://github.com/wbrowne)
+- **CloudWatch:** Fix MetricName resetting on Namespace change. [#44165](https://github.com/grafana/grafana/pull/44165), [@yaelleC](https://github.com/yaelleC)
+- **Cloudwatch :** Fixed resetting metric name when changing namespace in Metric Query. [#44612](https://github.com/grafana/grafana/pull/44612), [@yaelleC](https://github.com/yaelleC)
+- **Explore:** Avoid locking timepicker when range is inverted. [#44790](https://github.com/grafana/grafana/pull/44790), [@Elfo404](https://github.com/Elfo404)
+- **Instrumentation:** Fix HTTP request instrumentation of authentication failures. [#44234](https://github.com/grafana/grafana/pull/44234), [@marefr](https://github.com/marefr)
+- **LibraryPanels:** Prevent long descriptions and names from obscuring the delete button. [#45190](https://github.com/grafana/grafana/pull/45190), [@zuchka](https://github.com/zuchka)
+- **OAuth:** Fix parsing of ID token if header contains non-string value. [#44159](https://github.com/grafana/grafana/pull/44159), [@marefr](https://github.com/marefr)
+- **Panel Edit:** Visualization search now works correctly with special characters. [#45137](https://github.com/grafana/grafana/pull/45137), [@ashharrison90](https://github.com/ashharrison90)
+- **Provisioning:** Fix duplicate validation when multiple organizations have been configured. [#44151](https://github.com/grafana/grafana/pull/44151), [@marefr](https://github.com/marefr)
+- **QueryField:** Fix issue with undo history when suggestion is inserted (#28656). [#39114](https://github.com/grafana/grafana/pull/39114), [@glintik](https://github.com/glintik)
+- **TablePanel:** Do not prefix columns with frame name if multiple frames and override active. [#45174](https://github.com/grafana/grafana/pull/45174), [@mdvictor](https://github.com/mdvictor)
+
+### Deprecations
+
+AngularJS plugin support is now in a deprecated state, meaning it will be removed in a future release. Currently, that is planned for version 10 (in 2023). The documentation site has an [article](https://grafana.com/docs/grafana/next/developers/angular_deprecation/) with more details on why, when, and how. Issue [#45149](https://github.com/grafana/grafana/issues/45149)
+
+
# 8.4.0-beta1 (2022-02-02)
diff --git a/conf/defaults.ini b/conf/defaults.ini
index a852dee4e75..738b33889ac 100644
--- a/conf/defaults.ini
+++ b/conf/defaults.ini
@@ -292,6 +292,9 @@ content_security_policy = false
# $ROOT_PATH is server.root_url without the protocol.
content_security_policy_template = """script-src 'self' 'unsafe-eval' 'unsafe-inline' 'strict-dynamic' $NONCE;object-src 'none';font-src 'self';style-src 'self' 'unsafe-inline' blob:;img-src * data:;base-uri 'self';connect-src 'self' grafana.com ws://$ROOT_PATH wss://$ROOT_PATH;manifest-src 'self';media-src 'none';form-action 'self';"""
+# Controls if old angular plugins are supported or not. This will be disabled by default in Grafana v9.
+angular_support_enabled = true
+
#################################### Snapshots ###########################
[snapshots]
# snapshot sharing options
@@ -497,6 +500,7 @@ allowed_groups =
#################################### Okta OAuth #######################
[auth.okta]
name = Okta
+icon = okta
enabled = false
allow_sign_up = true
client_id = some_id
@@ -513,6 +517,7 @@ role_attribute_strict = false
#################################### Generic OAuth #######################
[auth.generic_oauth]
name = OAuth
+icon = signin
enabled = false
allow_sign_up = true
client_id = some_id
diff --git a/conf/sample.ini b/conf/sample.ini
index 710a8ea4810..759fd56af35 100644
--- a/conf/sample.ini
+++ b/conf/sample.ini
@@ -292,6 +292,9 @@
# $ROOT_PATH is server.root_url without the protocol.
;content_security_policy_template = """script-src 'self' 'unsafe-eval' 'unsafe-inline' 'strict-dynamic' $NONCE;object-src 'none';font-src 'self';style-src 'self' 'unsafe-inline' blob:;img-src * data:;base-uri 'self';connect-src 'self' grafana.com ws://$ROOT_PATH wss://$ROOT_PATH;manifest-src 'self';media-src 'none';form-action 'self';"""
+# Controls if old angular plugins are supported or not. This will be disabled by default in Grafana v9.
+;angular_support_enabled = true
+
#################################### Snapshots ###########################
[snapshots]
# snapshot sharing options
diff --git a/devenv/docker/blocks/saml-enterprise b/devenv/docker/blocks/saml-enterprise
deleted file mode 120000
index c3916961dee..00000000000
--- a/devenv/docker/blocks/saml-enterprise
+++ /dev/null
@@ -1 +0,0 @@
-/Users/mitchel/Workspace/grafana-enterprise/src/devenv/blocks/saml-enterprise
\ No newline at end of file
diff --git a/docs/README.md b/docs/README.md
index 9d6abafad4d..d15a584b9c7 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -10,7 +10,7 @@ Yarn >= 1.22.4
## Build the doc site
1. On the command line, first change to the docs folder: `cd docs`.
-1. Run `make docs-quick`. This launches a preview of the website with the current grafana docs at `http://localhost:3002/docs/grafana/next/` which will refresh automatically when changes are made to content in the `sources` directory.
+1. Run `make docs`. This launches a preview of the website with the current grafana docs at `http://localhost:3002/docs/grafana/next/` which will refresh automatically when changes are made to content in the `sources` directory.
If you have the grafana/website repo checked out in the same directory as the grafana repo, then you can run `make docs-local-static` to use local assets (such as images).
diff --git a/docs/sources/administration/configuration.md b/docs/sources/administration/configuration.md
index ecdc40b5386..1600c0420b5 100644
--- a/docs/sources/administration/configuration.md
+++ b/docs/sources/administration/configuration.md
@@ -582,6 +582,21 @@ Set Content Security Policy template used when adding the Content-Security-Polic
+### angular_support_enabled
+
+This currently defaults to `true` but will in Grafana v9 default to `false`. When set to false the angular framework and support components will not be loaded. This means that
+all plugins and core features that depend on angular support will stop working.
+
+Current core features that will stop working:
+
+- Heatmap panel
+- Old graph panel
+- Old table panel
+- Postgres, MySQL and MSSQL data source query editors
+- Legacy alerting edit rule UI
+
+Before we disable angular support by default we plan to migrate these remaining areas to React.
+
## [snapshots]
### external_enabled
diff --git a/docs/sources/auth/generic-oauth.md b/docs/sources/auth/generic-oauth.md
index 444e92f68c1..053758777fb 100755
--- a/docs/sources/auth/generic-oauth.md
+++ b/docs/sources/auth/generic-oauth.md
@@ -27,6 +27,8 @@ Example config:
```bash
[auth.generic_oauth]
+name = OAuth
+icon = signin
enabled = true
client_id = YOUR_APP_CLIENT_ID
client_secret = YOUR_APP_CLIENT_SECRET
diff --git a/docs/sources/auth/okta.md b/docs/sources/auth/okta.md
index c1751e134fb..a1d41babe58 100644
--- a/docs/sources/auth/okta.md
+++ b/docs/sources/auth/okta.md
@@ -38,6 +38,7 @@ Before you can sign a user in, you need to create an Okta application from the O
```ini
[auth.okta]
name = Okta
+icon = okta
enabled = true
allow_sign_up = true
client_id = some_id
diff --git a/docs/sources/enterprise/query-caching.md b/docs/sources/enterprise/query-caching.md
index 02d25a57510..b58e5c351a8 100644
--- a/docs/sources/enterprise/query-caching.md
+++ b/docs/sources/enterprise/query-caching.md
@@ -55,11 +55,11 @@ By default, data source queries are not cached. To enable query caching for a si
1. In the data source list, click the data source that you want to turn on caching for.
1. Open the Cache tab.
1. Press the Enable button.
-1. (Optional) Choose a custom TTL for that data source. If you skip this step, then Grafana uses the default TTL.
+1. (Optional) Choose custom TTLs for the data source's queries and resources caching. If you skip this step, then Grafana uses the default TTL.
> **Note:** If query caching is enabled and the Cache tab is not visible in a data source's settings, then query caching is not available for that data source.
-To configure global settings for query caching, refer the the [Query caching section of Enterprise Configuration]({{< relref "./enterprise-configuration.md#caching" >}}).
+To configure global settings for query caching, refer to the [Query caching section of Enterprise Configuration]({{< relref "./enterprise-configuration.md#caching" >}}).
## Disable query caching
@@ -71,6 +71,19 @@ To disable query caching for a single data source:
To disable query caching for an entire Grafana instance, set the `enabled` flag to `false` in the [Query caching section of Enterprise Configuration]({{< relref "./enterprise-configuration.md#caching" >}}). You will no longer see the Cache tab on any data sources, and no data source queries will be cached.
+## Clear cache
+
+If you experience performance issues or repeated queries become slower to execute, consider clearing your cache.
+
+> **Note:** This action impacts all cache-enabled data sources. If you are using Memcached, the system clears all data from the Memcached instance.
+
+**To clear the cache**:
+
+1. Sign in to Grafana and click **Settings > Data Sources**.
+1. Select a data source.
+1. Click the **Cache** tab.
+1. Click **Clear cache**.
+
## Sending a request without cache
If a data source query request contains an `X-Cache-Skip` header, then Grafana skips the caching middleware, and does not search the cache for a response. This can be particularly useful when debugging data source queries using cURL.
diff --git a/docs/sources/explore/logs-integration.md b/docs/sources/explore/logs-integration.md
index 50f394afe38..51b33a7d632 100644
--- a/docs/sources/explore/logs-integration.md
+++ b/docs/sources/explore/logs-integration.md
@@ -29,7 +29,9 @@ If the data source does not support loading full range log volume histogram, the
#### Log level
-For logs where a level label is specified, we use the value of the label to determine the log level and update color accordingly. If the log doesn't have a level label specified, we try to parse the log using logfmt and JSON parsers to find out if its content matches any of the supported expressions (see below for more information). The log level is always determined by the first match. In case Grafana is not able to determine a log level, it will be visualized with an unknown log level.
+For logs where a level label is specified, we use the value of the label to determine the log level and update color accordingly. If the log doesn't have a level label specified, we try to find out if its content matches any of the supported expressions (see below for more information). The log level is always determined by the first match. In case Grafana is not able to determine a log level, it will be visualized with an unknown log level.
+
+> **Tip:** If you use Loki data source and the "level" is in you log content, try to use parsers (JSON, logfmt, regex,..) to extract level information into level label that is used to determine log level.
**Supported log levels and mapping of log level abbreviation and expressions:**
diff --git a/docs/sources/panels/query-a-data-source/use-expressions-to-manipulate-data/about-expressions.md b/docs/sources/panels/query-a-data-source/use-expressions-to-manipulate-data/about-expressions.md
index 3563b22e542..9085d2a400e 100644
--- a/docs/sources/panels/query-a-data-source/use-expressions-to-manipulate-data/about-expressions.md
+++ b/docs/sources/panels/query-a-data-source/use-expressions-to-manipulate-data/about-expressions.md
@@ -16,8 +16,6 @@ Expressions are primarily used by the new [Grafana 8 alerts]({{< relref "../../.
> **Note:** Expressions do not work with legacy dashboard alerts.
-> **Note:** Expressions do not work with dashboard variables.
-
Expressions are meant to augment data sources by enabling queries from different data sources to be combined or by providing operations unavailable in a data source.
> **Note:** When possible, you should do data processing inside the data source. Copying data from storage to the Grafana server for processing is inefficient, so expressions are targeted at lightweight data processing.
diff --git a/docs/sources/release-notes/_index.md b/docs/sources/release-notes/_index.md
index 5b9b46103f7..cb08b39c58e 100644
--- a/docs/sources/release-notes/_index.md
+++ b/docs/sources/release-notes/_index.md
@@ -8,6 +8,7 @@ weight = 10000
Here you can find detailed release notes that list everything that is included in every release as well as notices
about deprecations, breaking changes as well as changes that relate to plugin development.
+- [Release notes for 8.4.1]({{< relref "release-notes-8-4-1" >}})
- [Release notes for 8.4.0-beta1]({{< relref "release-notes-8-4-0-beta1" >}})
- [Release notes for 8.3.6]({{< relref "release-notes-8-3-6" >}})
- [Release notes for 8.3.5]({{< relref "release-notes-8-3-5" >}})
diff --git a/docs/sources/release-notes/release-notes-8-4-0.md b/docs/sources/release-notes/release-notes-8-4-0.md
new file mode 100644
index 00000000000..0576173e6e8
--- /dev/null
+++ b/docs/sources/release-notes/release-notes-8-4-0.md
@@ -0,0 +1,58 @@
++++
+title = "Release notes for Grafana 8.4.0"
+hide_menu = true
++++
+
+
+
+# Release notes for Grafana 8.4.0
+
+### Features and enhancements
+
+- **API:** Extract OpenAPI specification from source code using go-swagger. [#40528](https://github.com/grafana/grafana/pull/40528), [@papagian](https://github.com/papagian)
+- **AccessControl:** Disable user remove and user update roles when they do not have the permissions. [#43429](https://github.com/grafana/grafana/pull/43429), [@Jguer](https://github.com/Jguer)
+- **AccessControl:** Provisioning for teams. [#43767](https://github.com/grafana/grafana/pull/43767), [@gamab](https://github.com/gamab)
+- **API:** Add usage stats preview endpoint. [#43899](https://github.com/grafana/grafana/pull/43899), [@Jguer](https://github.com/Jguer)
+- **Alerting:** Move slow queries in the scheduler to another goroutine. [#44423](https://github.com/grafana/grafana/pull/44423), [@grobinson-grafana](https://github.com/grobinson-grafana)
+- **Alerting:** Use time.Ticker instead of alerting.Ticker in ngalert. [#44395](https://github.com/grafana/grafana/pull/44395), [@grobinson-grafana](https://github.com/grobinson-grafana)
+- **Alerting:** add custom grouping to Alert Panel. [#44559](https://github.com/grafana/grafana/pull/44559), [@gillesdemey](https://github.com/gillesdemey)
+- **Analytics:** Add user id tracking to google analytics. [#42763](https://github.com/grafana/grafana/pull/42763), [@autoric](https://github.com/autoric)
+- **Angular:** Add AngularJS plugin support deprecation plan to docs site. [#45149](https://github.com/grafana/grafana/pull/45149), [@torkelo](https://github.com/torkelo)
+- **Auth:** implement auto_sign_up for auth.jwt. [#43502](https://github.com/grafana/grafana/pull/43502), [@sakjur](https://github.com/sakjur)
+- **Azure Monitor Logs:** Order subscriptions in resource picker by name. [#45228](https://github.com/grafana/grafana/pull/45228), [@sunker](https://github.com/sunker)
+- **Azure monitor Logs:** Optimize data fetching in resource picker. [#44549](https://github.com/grafana/grafana/pull/44549), [@sunker](https://github.com/sunker)
+- **AzureMonitor:** Filter list of resources by resourceType. [#43522](https://github.com/grafana/grafana/pull/43522), [@andresmgot](https://github.com/andresmgot)
+- **BarChart:** color by field, x time field, bar radius, label skipping. [#43257](https://github.com/grafana/grafana/pull/43257), [@leeoniya](https://github.com/leeoniya)
+- **Chore:** Implement OpenTelemetry in Grafana. [#42674](https://github.com/grafana/grafana/pull/42674), [@idafurjes](https://github.com/idafurjes)
+- **Cloud Monitoring:** Adds metric type to Metric drop down options. [#43268](https://github.com/grafana/grafana/pull/43268), [@tw1nk](https://github.com/tw1nk)
+- **CloudWatch:** Add Data Lifecycle Manager metrics and dimension. [#43310](https://github.com/grafana/grafana/pull/43310), [@ilyastoli](https://github.com/ilyastoli)
+- **CloudWatch:** Add Missing Elasticache Host-level metrics. [#43455](https://github.com/grafana/grafana/pull/43455), [@dhendo](https://github.com/dhendo)
+- **CloudWatch:** Add all ElastiCache Redis Metrics. [#43336](https://github.com/grafana/grafana/pull/43336), [@siavashs](https://github.com/siavashs)
+- **CloudWatch:** Add new AWS/ES metrics. [#43034](https://github.com/grafana/grafana/pull/43034), [@sunker](https://github.com/sunker)
+- **Cloudwatch:** Add syntax highlighting and autocomplete for "Metric Search". [#43985](https://github.com/grafana/grafana/pull/43985), [@sarahzinger](https://github.com/sarahzinger)
+- **Explore:** Support custom display label for exemplar links for Prometheus datasource. [#42732](https://github.com/grafana/grafana/pull/42732), [@JokerQueue](https://github.com/JokerQueue)
+- **Hotkeys:** Make time range absolute/permanent. [#43802](https://github.com/grafana/grafana/pull/43802), [@davkal](https://github.com/davkal)
+- **Playlists:** Enable sharing direct links to playlists. [#44161](https://github.com/grafana/grafana/pull/44161), [@ashharrison90](https://github.com/ashharrison90)
+- **SQLStore:** Prevent concurrent migrations. [#44101](https://github.com/grafana/grafana/pull/44101), [@papagian](https://github.com/papagian)
+- **SSE:** Add Mode to drop NaN/Inf/Null in Reduction operations. [#43583](https://github.com/grafana/grafana/pull/43583), [@kylebrandt](https://github.com/kylebrandt)
+- **Setting:** Support configuring feature toggles with bools instead of just passing an array. [#43326](https://github.com/grafana/grafana/pull/43326), [@bergquist](https://github.com/bergquist)
+- **TimeSeries:** Add support for negative Y and constant transform. [#44774](https://github.com/grafana/grafana/pull/44774), [@dprokop](https://github.com/dprokop)
+- **Transformations:** Add 'JSON' field type to ConvertFieldTypeTransformer. [#42624](https://github.com/grafana/grafana/pull/42624), [@sd2k](https://github.com/sd2k)
+
+### Bug fixes
+
+- **Auth:** Guarantee consistency of signed SigV4 headers. [#45054](https://github.com/grafana/grafana/pull/45054), [@wbrowne](https://github.com/wbrowne)
+- **CloudWatch:** Fix MetricName resetting on Namespace change. [#44165](https://github.com/grafana/grafana/pull/44165), [@yaelleC](https://github.com/yaelleC)
+- **Cloudwatch :** Fixed resetting metric name when changing namespace in Metric Query. [#44612](https://github.com/grafana/grafana/pull/44612), [@yaelleC](https://github.com/yaelleC)
+- **Explore:** Avoid locking timepicker when range is inverted. [#44790](https://github.com/grafana/grafana/pull/44790), [@Elfo404](https://github.com/Elfo404)
+- **Instrumentation:** Fix HTTP request instrumentation of authentication failures. [#44234](https://github.com/grafana/grafana/pull/44234), [@marefr](https://github.com/marefr)
+- **LibraryPanels:** Prevent long descriptions and names from obscuring the delete button. [#45190](https://github.com/grafana/grafana/pull/45190), [@zuchka](https://github.com/zuchka)
+- **OAuth:** Fix parsing of ID token if header contains non-string value. [#44159](https://github.com/grafana/grafana/pull/44159), [@marefr](https://github.com/marefr)
+- **Panel Edit:** Visualization search now works correctly with special characters. [#45137](https://github.com/grafana/grafana/pull/45137), [@ashharrison90](https://github.com/ashharrison90)
+- **Provisioning:** Fix duplicate validation when multiple organizations have been configured. [#44151](https://github.com/grafana/grafana/pull/44151), [@marefr](https://github.com/marefr)
+- **QueryField:** Fix issue with undo history when suggestion is inserted (#28656). [#39114](https://github.com/grafana/grafana/pull/39114), [@glintik](https://github.com/glintik)
+- **TablePanel:** Do not prefix columns with frame name if multiple frames and override active. [#45174](https://github.com/grafana/grafana/pull/45174), [@mdvictor](https://github.com/mdvictor)
+
+### Deprecations
+
+AngularJS plugin support is now in a deprecated state, meaning it will be removed in a future release. Currently, that is planned for version 10 (in 2023). The documentation site has an [article](https://grafana.com/docs/grafana/next/developers/angular_deprecation/) with more details on why, when, and how. Issue [#45149](https://github.com/grafana/grafana/issues/45149)
diff --git a/docs/sources/release-notes/release-notes-8-4-1.md b/docs/sources/release-notes/release-notes-8-4-1.md
new file mode 100644
index 00000000000..6b95dae3adf
--- /dev/null
+++ b/docs/sources/release-notes/release-notes-8-4-1.md
@@ -0,0 +1,18 @@
++++
+title = "Release notes for Grafana 8.4.1"
+hide_menu = true
++++
+
+
+
+# Release notes for Grafana 8.4.1
+
+### Features and enhancements
+
+- **Cloudwatch:** Add support for AWS/PrivateLink\* metrics and dimensions. [#45515](https://github.com/grafana/grafana/pull/45515), [@szymonpk](https://github.com/szymonpk)
+- **Configuration:** Add ability to customize okta login button name and icon. [#44079](https://github.com/grafana/grafana/pull/44079), [@DanCech](https://github.com/DanCech)
+- **Tempo:** Switch out Select with AsyncSelect component to get loading state in Tempo Search. [#45110](https://github.com/grafana/grafana/pull/45110), [@CatPerry](https://github.com/CatPerry)
+
+### Bug fixes
+
+- **Alerting:** Fix migrations by making send_alerts_to field nullable. [#45572](https://github.com/grafana/grafana/pull/45572), [@santihernandezc](https://github.com/santihernandezc)
diff --git a/docs/sources/visualizations/logs-panel.md b/docs/sources/visualizations/logs-panel.md
index 179a25e8fbb..e16afd9d692 100644
--- a/docs/sources/visualizations/logs-panel.md
+++ b/docs/sources/visualizations/logs-panel.md
@@ -17,7 +17,7 @@ To limit the number of lines rendered, you can use the **Max data points** setti
## Log level
-For logs where a **level** label is specified, we use the value of the label to determine the log level and update color accordingly. If the log doesn't have a level label specified, we parse the log to find out if its content matches any of the supported expressions (see below for more information). The log level is always determined by the first match. In case Grafana is not able to determine a log level, it will be visualized with **unknown** log level. See [supported log levels and mappings of log level abbreviation and expressions]({{< relref "../explore/_index.md#log-level" >}}).
+For logs where a **level** label is specified, we use the value of the label to determine the log level and update color accordingly. If the log doesn't have a level label specified, we try to find out if its content matches any of the supported expressions (see below for more information). The log level is always determined by the first match. In case Grafana is not able to determine a log level, it will be visualized with **unknown** log level. See [supported log levels and mappings of log level abbreviation and expressions]({{< relref "../explore/_index.md#log-level" >}}).
## Log details
diff --git a/docs/sources/whatsnew/whats-new-in-v8-4.md b/docs/sources/whatsnew/whats-new-in-v8-4.md
index 4a86ffcd536..c9d86fb8ac6 100644
--- a/docs/sources/whatsnew/whats-new-in-v8-4.md
+++ b/docs/sources/whatsnew/whats-new-in-v8-4.md
@@ -24,9 +24,9 @@ You can now share links to your playlists the same way as with dashboards, to ea
{{< figure src="/static/img/docs/dashboards/share-playlists-8-4.png" max-width="1200px" caption="Share playlist" >}}
-### $**interval and $**interval_ms in panel title
+### `$__interval` and `$__interval_ms` in panel title
-We’ve expanded the functionality of this existing and popular feature. You can now use $**interval and $**interval_ms in panel titles. This new function displays the interval that’s used in a specific panel without requiring edit mode.
+We’ve expanded the functionality of this existing and popular feature. You can now use `$__interval` and `$__interval_ms` in panel titles. This new function displays the interval that’s used in a specific panel without requiring edit mode.
{{< figure src="/static/img/docs/panels/interval-8-4.png" max-width="1200px" caption="Time intervals" >}}
diff --git a/e2e/various-suite/explore.spec.ts b/e2e/various-suite/explore.spec.ts
index 4e089a27b79..57dab64ceec 100644
--- a/e2e/various-suite/explore.spec.ts
+++ b/e2e/various-suite/explore.spec.ts
@@ -19,6 +19,28 @@ e2e.scenario({
cy.contains('CSV Metric Values').scrollIntoView().should('be.visible').click();
+ cy.location().then((loc) => {
+ const params = new URLSearchParams(loc.search);
+ const leftJSON = JSON.parse(params.get('left'));
+ expect(leftJSON.range.to).to.equal('now');
+ expect(leftJSON.range.from).to.equal('now-1h');
+
+ cy.get('body').click();
+ cy.get('body').type('t{leftarrow}');
+
+ cy.location().then((locPostKeypress) => {
+ const params = new URLSearchParams(locPostKeypress.search);
+ const leftJSON = JSON.parse(params.get('left'));
+ // be sure the keypress affected the time window
+ expect(leftJSON.range.to).to.not.equal('now');
+ expect(leftJSON.range.from).to.not.equal('now-1h');
+ // be sure the url does not contain dashboard range values
+ // eslint wants this to be a function, so we use this instead of to.be.false
+ expect(params.has('to')).to.equal(false);
+ expect(params.has('from')).to.equal(false);
+ });
+ });
+
const canvases = e2e().get('canvas');
canvases.should('have.length', 1);
},
diff --git a/jest.config.js b/jest.config.js
index 8218dcb3be6..418b115f570 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -13,7 +13,7 @@ module.exports = {
'node_modules/(?!(ol)/)', // <- exclude the open layers library
],
moduleDirectories: ['node_modules', 'public'],
- roots: ['/public/app', '/public/test', '/packages', '/scripts'],
+ roots: ['/public/app', '/public/test', '/packages'],
testRegex: '(\\.|/)(test)\\.(jsx?|tsx?)$',
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
setupFiles: ['jest-canvas-mock', './public/test/jest-shim.ts', './public/test/jest-setup.ts'],
diff --git a/latest.json b/latest.json
index d325bb2abf8..2351b122bce 100644
--- a/latest.json
+++ b/latest.json
@@ -1,4 +1,4 @@
{
- "stable": "8.3.6",
- "testing": "8.4.0-beta1"
+ "stable": "8.4.1",
+ "testing": "8.4.1"
}
diff --git a/log b/log
deleted file mode 100644
index e69de29bb2d..00000000000
diff --git a/package.json b/package.json
index 08ac67a5423..3a96b5bac55 100644
--- a/package.json
+++ b/package.json
@@ -71,7 +71,7 @@
"devDependencies": {
"@babel/core": "7.17.0",
"@babel/plugin-proposal-nullish-coalescing-operator": "7.16.7",
- "@babel/plugin-proposal-object-rest-spread": "7.16.7",
+ "@babel/plugin-proposal-object-rest-spread": "7.17.3",
"@babel/plugin-proposal-optional-chaining": "7.16.7",
"@babel/plugin-syntax-dynamic-import": "7.8.3",
"@babel/plugin-transform-react-constant-elements": "7.16.7",
@@ -93,9 +93,9 @@
"@microsoft/api-extractor": "7.19.4",
"@pmmmwh/react-refresh-webpack-plugin": "0.5.4",
"@react-types/button": "3.4.1",
- "@react-types/menu": "3.4.1",
+ "@react-types/menu": "3.5.1",
"@react-types/overlays": "3.5.1",
- "@react-types/shared": "3.10.1",
+ "@react-types/shared": "3.11.1",
"@rtsao/plugin-proposal-class-properties": "7.0.1-patch.1",
"@swc/core": "1.2.136",
"@swc/helpers": "0.3.2",
@@ -126,7 +126,6 @@
"@types/lingui__macro": "^3",
"@types/lodash": "4.14.149",
"@types/logfmt": "^1.2.1",
- "@types/lru-cache": "^5.1.0",
"@types/mousetrap": "1.6.3",
"@types/node": "16.11.22",
"@types/papaparse": "5.3.2",
@@ -158,7 +157,7 @@
"@typescript-eslint/parser": "5.11.0",
"@wojtekmaj/enzyme-adapter-react-17": "0.6.6",
"autoprefixer": "10.4.2",
- "axios": "0.25.0",
+ "axios": "0.26.0",
"babel-jest": "27.5.0",
"babel-loader": "8.2.3",
"babel-plugin-angularjs-annotate": "0.10.0",
@@ -169,11 +168,11 @@
"cypress": "9.3.1",
"enzyme": "3.11.0",
"enzyme-to-json": "3.6.2",
- "eslint": "8.8.0",
- "eslint-config-prettier": "8.3.0",
+ "eslint": "8.9.0",
+ "eslint-config-prettier": "8.4.0",
"eslint-plugin-jest": "^26.1.0",
- "eslint-plugin-jsdoc": "37.8.0",
- "eslint-plugin-lodash": "^7.2.0",
+ "eslint-plugin-jsdoc": "37.9.1",
+ "eslint-plugin-lodash": "7.4.0",
"eslint-plugin-prettier": "4.0.0",
"eslint-plugin-react": "7.28.0",
"eslint-plugin-react-hooks": "4.3.0",
@@ -213,7 +212,7 @@
"redux-mock-store": "1.5.4",
"rimraf": "3.0.2",
"sass": "1.49.7",
- "sass-loader": "12.4.0",
+ "sass-loader": "12.6.0",
"sinon": "13.0.1",
"style-loader": "3.3.1",
"stylelint": "14.5.0",
@@ -234,7 +233,7 @@
},
"dependencies": {
"@emotion/css": "11.7.1",
- "@emotion/react": "11.7.1",
+ "@emotion/react": "11.8.1",
"@grafana/aws-sdk": "0.0.33",
"@grafana/data": "workspace:*",
"@grafana/e2e-selectors": "workspace:*",
@@ -255,7 +254,7 @@
"@react-aria/button": "3.3.4",
"@react-aria/dialog": "3.1.4",
"@react-aria/focus": "3.5.0",
- "@react-aria/interactions": "3.7.0",
+ "@react-aria/interactions": "3.8.1",
"@react-aria/menu": "3.3.0",
"@react-aria/overlays": "3.7.3",
"@react-aria/utils": "3.11.0",
@@ -309,7 +308,7 @@
"lezer-tree": "0.13.2",
"lodash": "4.17.21",
"logfmt": "^1.3.2",
- "lru-cache": "6.0.0",
+ "lru-cache": "7.3.1",
"memoize-one": "6.0.0",
"moment": "2.29.1",
"moment-timezone": "0.5.34",
@@ -335,7 +334,7 @@
"react-draggable": "4.4.4",
"react-grid-layout": "1.3.3",
"react-highlight-words": "0.17.0",
- "react-hook-form": "7.5.3",
+ "react-hook-form": "7.27.0",
"react-inlinesvg": "2.3.0",
"react-loadable": "5.5.0",
"react-moveable": "0.30.3",
diff --git a/packages/grafana-data/src/transformations/transformers/histogram.ts b/packages/grafana-data/src/transformations/transformers/histogram.ts
index 0e8b2c1da88..73a4a39c323 100644
--- a/packages/grafana-data/src/transformations/transformers/histogram.ts
+++ b/packages/grafana-data/src/transformations/transformers/histogram.ts
@@ -285,15 +285,24 @@ export function buildHistogram(frames: DataFrame[], options?: HistogramTransform
};
}
-// function incrRound(num: number, incr: number) {
-// return Math.round(num / incr) * incr;
-// }
+/**
+ * @internal
+ */
+export function incrRound(num: number, incr: number) {
+ return Math.round(num / incr) * incr;
+}
-// function incrRoundUp(num: number, incr: number) {
-// return Math.ceil(num / incr) * incr;
-// }
+/**
+ * @internal
+ */
+export function incrRoundUp(num: number, incr: number) {
+ return Math.ceil(num / incr) * incr;
+}
-function incrRoundDn(num: number, incr: number) {
+/**
+ * @internal
+ */
+export function incrRoundDn(num: number, incr: number) {
return Math.floor(num / incr) * incr;
}
diff --git a/packages/grafana-data/src/transformations/transformers/ids.ts b/packages/grafana-data/src/transformations/transformers/ids.ts
index ed2066063e6..e8254b1949a 100644
--- a/packages/grafana-data/src/transformations/transformers/ids.ts
+++ b/packages/grafana-data/src/transformations/transformers/ids.ts
@@ -28,6 +28,7 @@ export enum DataTransformerID {
prepareTimeSeries = 'prepareTimeSeries',
convertFieldType = 'convertFieldType',
fieldLookup = 'fieldLookup',
+ heatmap = 'heatmap',
spatial = 'spatial',
extractFields = 'extractFields',
}
diff --git a/packages/grafana-data/src/types/config.ts b/packages/grafana-data/src/types/config.ts
index c07e212e9cd..cca149159ca 100644
--- a/packages/grafana-data/src/types/config.ts
+++ b/packages/grafana-data/src/types/config.ts
@@ -83,7 +83,7 @@ export type OAuth =
*
* @public
*/
-export type OAuthSettings = Partial>;
+export type OAuthSettings = Partial>;
/**
* Describes all the different Grafana configuration values available for an instance.
@@ -138,4 +138,5 @@ export interface GrafanaConfig {
geomapDefaultBaseLayer?: MapLayerOptions;
geomapDisableCustomBaseLayer?: boolean;
unifiedAlertingEnabled: boolean;
+ angularSupportEnabled: boolean;
}
diff --git a/packages/grafana-data/src/types/dataFrameTypes.ts b/packages/grafana-data/src/types/dataFrameTypes.ts
index 9add0f27abf..f4faa9fda88 100644
--- a/packages/grafana-data/src/types/dataFrameTypes.ts
+++ b/packages/grafana-data/src/types/dataFrameTypes.ts
@@ -8,4 +8,17 @@ export enum DataFrameType {
TimeSeriesWide = 'timeseries-wide',
TimeSeriesLong = 'timeseries-long',
TimeSeriesMany = 'timeseries-many',
+
+ /**
+ * First field is X, the rest are bucket values
+ */
+ HeatmapBuckets = 'heatmap-buckets',
+
+ /**
+ * Explicit fields for:
+ * xMin, yMin, count, ...
+ *
+ * All values in the grid exist and have regular spacing
+ */
+ HeatmapScanlines = 'heatmap-scanlines',
}
diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts
index a5f13a7de0f..95bb511c0e9 100644
--- a/packages/grafana-data/src/types/featureToggles.gen.ts
+++ b/packages/grafana-data/src/types/featureToggles.gen.ts
@@ -23,6 +23,7 @@ export interface FeatureToggles {
database_metrics?: boolean;
dashboardPreviews?: boolean;
dashboardPreviewsScheduler?: boolean;
+ dashboardPreviewsAdmin?: boolean;
['live-config']?: boolean;
['live-pipeline']?: boolean;
['live-service-web-worker']?: boolean;
diff --git a/packages/grafana-data/src/types/navModel.ts b/packages/grafana-data/src/types/navModel.ts
index d4c885f11a5..781a6610270 100644
--- a/packages/grafana-data/src/types/navModel.ts
+++ b/packages/grafana-data/src/types/navModel.ts
@@ -20,6 +20,7 @@ export interface NavModelItem {
onClick?: () => void;
menuItemType?: NavMenuItemType;
highlightText?: string;
+ highlightId?: string;
tabSuffix?: ComponentType<{ className?: string }>;
}
diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts
index d9258c84c71..99c63fd66a3 100644
--- a/packages/grafana-runtime/src/config.ts
+++ b/packages/grafana-runtime/src/config.ts
@@ -41,6 +41,7 @@ export class GrafanaBootConfig implements GrafanaConfig {
alertingErrorOrTimeout = '';
alertingNoDataOrNullValues = '';
alertingMinInterval = 1;
+ angularSupportEnabled = false;
authProxyEnabled = false;
exploreEnabled = false;
ldapEnabled = false;
@@ -65,6 +66,13 @@ export class GrafanaBootConfig implements GrafanaConfig {
featureToggles: FeatureToggles = {};
licenseInfo: LicenseInfo = {} as LicenseInfo;
rendererAvailable = false;
+ dashboardPreviews: {
+ systemRequirements: {
+ met: boolean;
+ requiredImageRendererPluginVersion: string;
+ };
+ thumbnailsExist: boolean;
+ } = { systemRequirements: { met: false, requiredImageRendererPluginVersion: '' }, thumbnailsExist: false };
rendererVersion = '';
http2Enabled = false;
dateFormats?: SystemDateFormatSettings;
diff --git a/packages/grafana-schema/src/scuemata/dashboard/dist/family.cue b/packages/grafana-schema/src/scuemata/dashboard/dist/family.cue
index 4f97a4230d0..2f8f169ed7e 100644
--- a/packages/grafana-schema/src/scuemata/dashboard/dist/family.cue
+++ b/packages/grafana-schema/src/scuemata/dashboard/dist/family.cue
@@ -16,6 +16,7 @@ import (
ptable "github.com/grafana/grafana/public/app/plugins/panel/table:grafanaschema"
ptext "github.com/grafana/grafana/public/app/plugins/panel/text:grafanaschema"
ptimeseries "github.com/grafana/grafana/public/app/plugins/panel/timeseries:grafanaschema"
+ pheatmap_new "github.com/grafana/grafana/public/app/plugins/panel/heatmap-new:grafanaschema"
)
// Family composes the base dashboard scuemata family with all Grafana core plugins -
@@ -40,5 +41,6 @@ Family: dashboard.Family & {
text: ptext.Panel
table: ptable.Panel
timeseries: ptimeseries.Panel
+ "heatmap-new": pheatmap_new.Panel
}
}
\ No newline at end of file
diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json
index 149fac977ac..7d6935c56cf 100644
--- a/packages/grafana-ui/package.json
+++ b/packages/grafana-ui/package.json
@@ -31,7 +31,7 @@
],
"dependencies": {
"@emotion/css": "11.7.1",
- "@emotion/react": "11.7.1",
+ "@emotion/react": "11.8.1",
"@grafana/aws-sdk": "0.0.33",
"@grafana/data": "8.5.0-pre",
"@grafana/e2e-selectors": "8.5.0-pre",
@@ -74,7 +74,7 @@
"react-dom": "17.0.2",
"react-dropzone": "11.5.1",
"react-highlight-words": "0.17.0",
- "react-hook-form": "7.5.3",
+ "react-hook-form": "7.27.0",
"react-inlinesvg": "2.3.0",
"react-popper": "2.2.5",
"react-popper-tooltip": "^4.3.1",
@@ -169,7 +169,7 @@
"rollup": "2.67.1",
"rollup-plugin-sourcemaps": "0.6.3",
"rollup-plugin-terser": "7.0.2",
- "sass-loader": "12.4.0",
+ "sass-loader": "12.6.0",
"storybook-dark-mode": "1.0.8",
"style-loader": "3.3.1",
"terser-webpack-plugin": "5.3.1",
diff --git a/packages/grafana-ui/src/components/Alert/Alert.tsx b/packages/grafana-ui/src/components/Alert/Alert.tsx
index b25ecc025f8..c6e73b7fe2b 100644
--- a/packages/grafana-ui/src/components/Alert/Alert.tsx
+++ b/packages/grafana-ui/src/components/Alert/Alert.tsx
@@ -19,6 +19,7 @@ export interface Props extends HTMLAttributes {
elevated?: boolean;
buttonContent?: React.ReactNode | string;
bottomSpacing?: number;
+ topSpacing?: number;
}
function getIconFromSeverity(severity: AlertVariant): string {
@@ -37,11 +38,22 @@ function getIconFromSeverity(severity: AlertVariant): string {
export const Alert = React.forwardRef(
(
- { title, onRemove, children, buttonContent, elevated, bottomSpacing, className, severity = 'error', ...restProps },
+ {
+ title,
+ onRemove,
+ children,
+ buttonContent,
+ elevated,
+ bottomSpacing,
+ topSpacing,
+ className,
+ severity = 'error',
+ ...restProps
+ },
ref
) => {
const theme = useTheme2();
- const styles = getStyles(theme, severity, elevated, bottomSpacing);
+ const styles = getStyles(theme, severity, elevated, bottomSpacing, topSpacing);
return (
(
Alert.displayName = 'Alert';
-const getStyles = (theme: GrafanaTheme2, severity: AlertVariant, elevated?: boolean, bottomSpacing?: number) => {
+const getStyles = (
+ theme: GrafanaTheme2,
+ severity: AlertVariant,
+ elevated?: boolean,
+ bottomSpacing?: number,
+ topSpacing?: number
+) => {
const color = theme.colors[severity];
const borderRadius = theme.shape.borderRadius();
@@ -92,6 +110,7 @@ const getStyles = (theme: GrafanaTheme2, severity: AlertVariant, elevated?: bool
background: ${theme.colors.background.secondary};
box-shadow: ${elevated ? theme.shadows.z3 : theme.shadows.z1};
margin-bottom: ${theme.spacing(bottomSpacing ?? 2)};
+ margin-top: ${theme.spacing(topSpacing ?? 0)};
&:before {
content: '';
diff --git a/packages/grafana-ui/src/components/BigValue/BigValue.test.tsx b/packages/grafana-ui/src/components/BigValue/BigValue.test.tsx
index 94b995ccc99..16efc2b43c3 100644
--- a/packages/grafana-ui/src/components/BigValue/BigValue.test.tsx
+++ b/packages/grafana-ui/src/components/BigValue/BigValue.test.tsx
@@ -1,6 +1,6 @@
import React from 'react';
-import { shallow } from 'enzyme';
-import { BigValue, Props, BigValueColorMode, BigValueGraphMode } from './BigValue';
+import { render, screen } from '@testing-library/react';
+import { BigValue, BigValueColorMode, BigValueGraphMode, Props } from './BigValue';
import { createTheme } from '@grafana/data';
function getProps(propOverrides?: Partial
): Props {
@@ -21,22 +21,12 @@ function getProps(propOverrides?: Partial): Props {
return props;
}
-const setup = (propOverrides?: object) => {
- const props = getProps(propOverrides);
- const wrapper = shallow( );
- const instance = wrapper.instance() as BigValue;
-
- return {
- instance,
- wrapper,
- };
-};
-
describe('BigValue', () => {
describe('Render with basic options', () => {
it('should render', () => {
- const { wrapper } = setup();
- expect(wrapper).toMatchSnapshot();
+ render( );
+
+ expect(screen.getByText('25')).toBeInTheDocument();
});
});
});
diff --git a/packages/grafana-ui/src/components/BigValue/BigValue.tsx b/packages/grafana-ui/src/components/BigValue/BigValue.tsx
index ff222b5d60e..864c2547199 100644
--- a/packages/grafana-ui/src/components/BigValue/BigValue.tsx
+++ b/packages/grafana-ui/src/components/BigValue/BigValue.tsx
@@ -1,8 +1,6 @@
-// Library
import React, { PureComponent } from 'react';
import { DisplayValue, DisplayValueAlignmentFactors, FieldSparkline, TextDisplayOptions } from '@grafana/data';
-// Types
import { Themeable2 } from '../../types';
import { buildLayout } from './BigValueLayout';
import { FormattedValueDisplay } from '../FormattedValueDisplay/FormattedValueDisplay';
diff --git a/packages/grafana-ui/src/components/BigValue/__snapshots__/BigValue.test.tsx.snap b/packages/grafana-ui/src/components/BigValue/__snapshots__/BigValue.test.tsx.snap
deleted file mode 100644
index 5acf66fb54e..00000000000
--- a/packages/grafana-ui/src/components/BigValue/__snapshots__/BigValue.test.tsx.snap
+++ /dev/null
@@ -1,54 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`BigValue Render with basic options should render 1`] = `
-
-`;
diff --git a/packages/grafana-ui/src/components/CallToActionCard/CallToActionCard.test.tsx b/packages/grafana-ui/src/components/CallToActionCard/CallToActionCard.test.tsx
index 633902d32df..1e2a72d8f81 100644
--- a/packages/grafana-ui/src/components/CallToActionCard/CallToActionCard.test.tsx
+++ b/packages/grafana-ui/src/components/CallToActionCard/CallToActionCard.test.tsx
@@ -1,33 +1,39 @@
import React from 'react';
-import { render } from 'enzyme';
+import { render, screen } from '@testing-library/react';
import { CallToActionCard } from './CallToActionCard';
+import { expect } from '../../../../../public/test/lib/common';
describe('CallToActionCard', () => {
describe('rendering', () => {
- it('when no message and footer provided', () => {
- const tree = render(Click me} />);
- expect(tree).toMatchSnapshot();
+ it('should render callToActionElement', () => {
+ render(Click me} />);
+ expect(screen.getByRole('link', { name: 'Click me' })).toBeInTheDocument();
});
- it('when message and no footer provided', () => {
- const tree = render(
- Click me}
- />
+ it('should render message when provided', () => {
+ render(
+ Click me} />
);
- expect(tree).toMatchSnapshot();
+ expect(screen.getByText('Click button below')).toBeInTheDocument();
});
- it('when message and footer provided', () => {
- const tree = render(
+ it('should render footer when provided', () => {
+ render(
+ Click me} />
+ );
+ expect(screen.getByText('footer content')).toBeInTheDocument();
+ });
+
+ it('should render both message and footer when provided', () => {
+ render(
Click me}
/>
);
- expect(tree).toMatchSnapshot();
+ expect(screen.getByText('Click button below')).toBeInTheDocument();
+ expect(screen.getByText('footer content')).toBeInTheDocument();
});
});
});
diff --git a/packages/grafana-ui/src/components/CallToActionCard/__snapshots__/CallToActionCard.test.tsx.snap b/packages/grafana-ui/src/components/CallToActionCard/__snapshots__/CallToActionCard.test.tsx.snap
deleted file mode 100644
index 511fd516da3..00000000000
--- a/packages/grafana-ui/src/components/CallToActionCard/__snapshots__/CallToActionCard.test.tsx.snap
+++ /dev/null
@@ -1,52 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`CallToActionCard rendering when message and footer provided 1`] = `
-
-
- Click button bellow
-
-
- Click me
-
-
- footer content
-
-
-`;
-
-exports[`CallToActionCard rendering when message and no footer provided 1`] = `
-
-`;
-
-exports[`CallToActionCard rendering when no message and footer provided 1`] = `
-
-`;
diff --git a/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.test.tsx b/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.test.tsx
index e3117ec9336..c9e4d9cd4f3 100644
--- a/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.test.tsx
+++ b/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.test.tsx
@@ -1,34 +1,72 @@
import React from 'react';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
import { ConfirmButton } from './ConfirmButton';
-import { mount, ShallowWrapper } from 'enzyme';
-import { Button } from '../Button';
+import { expect } from '../../../../../public/test/lib/common';
describe('ConfirmButton', () => {
- let wrapper: any;
- let deleted: any;
-
- beforeAll(() => {
- deleted = false;
-
- function deleteItem() {
- deleted = true;
- }
-
- wrapper = mount(
- deleteItem()}>
+ it('should show confirm delete when clicked', () => {
+ const onConfirm = jest.fn();
+ render(
+
Delete
);
+
+ // Confirm button should not be visible before clicking the Delete button
+ expect(screen.queryByRole('button', { name: 'Confirm delete' })).not.toBeInTheDocument();
+
+ userEvent.click(screen.getByRole('button', { name: 'Delete' }));
+
+ // Confirm button should now be visible
+ expect(screen.getByRole('button', { name: 'Confirm delete' })).toBeInTheDocument();
+ userEvent.click(screen.getByRole('button', { name: 'Confirm delete' }));
+ expect(onConfirm).toHaveBeenCalled();
+
+ // Confirm button should be visible if closeOnConfirm is false
+ expect(screen.queryByRole('button', { name: 'Confirm delete' })).toBeInTheDocument();
});
- it('should show confirm delete when clicked', () => {
- expect(deleted).toBe(false);
- wrapper
- .find(Button)
- .findWhere((n: ShallowWrapper) => {
- return n.text() === 'Confirm delete' && n.type() === Button;
- })
- .simulate('click');
- expect(deleted).toBe(true);
+ it('should hide confirm delete when closeOnConfirm is true', () => {
+ render(
+ {}} closeOnConfirm={true}>
+ Delete
+
+ );
+
+ // Confirm button should not be visible before clicking the Delete button
+ expect(screen.queryByRole('button', { name: 'Confirm delete' })).not.toBeInTheDocument();
+
+ userEvent.click(screen.getByRole('button', { name: 'Delete' }));
+
+ // Confirm button should now be visible
+ expect(screen.getByRole('button', { name: 'Confirm delete' })).toBeInTheDocument();
+ userEvent.click(screen.getByRole('button', { name: 'Confirm delete' }));
+
+ // Confirm button should not be visible if closeOnConfirm is true
+ expect(screen.queryByRole('button', { name: 'Confirm delete' })).not.toBeInTheDocument();
+ });
+
+ it('should show cancel when clicked', () => {
+ const onCancel = jest.fn();
+ render(
+ {}}>
+ Delete
+
+ );
+
+ // Cancel button should not be visible before clicking the Delete button
+ expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument();
+
+ userEvent.click(screen.getByRole('button', { name: 'Delete' }));
+
+ // Cancel button should now be visible
+ expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
+ userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
+ expect(onCancel).toHaveBeenCalled();
+
+ // Cancel button should not be visible after click
+ expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument();
});
});
diff --git a/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.test.tsx b/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.test.tsx
index e5c720b7b0d..50a9482c9af 100644
--- a/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.test.tsx
+++ b/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.test.tsx
@@ -1,23 +1,34 @@
import React from 'react';
-import { mount } from 'enzyme';
+import { render, screen, within } from '@testing-library/react';
+
import { ConfirmModal } from './ConfirmModal';
describe('ConfirmModal', () => {
- it('renders without error', () => {
- mount(
+ it('should render correct title, body, dismiss-, alternative- and confirm-text', () => {
+ render(
{}}
onDismiss={() => {}}
+ onAlternative={() => {}}
/>
);
+
+ expect(screen.getByRole('heading', { name: 'Some Title' })).toBeInTheDocument();
+ expect(screen.getByText('Some Body')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Dismiss Text' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Alternative Text' })).toBeInTheDocument();
+ const button = screen.getByRole('button', { name: 'Confirm Modal Danger Button' });
+ expect(within(button).getByText('Please Confirm')).toBeInTheDocument();
});
- it('renders nothing by default or when isOpen is false', () => {
- const wrapper = mount(
+ it('should render nothing when isOpen is false', () => {
+ render(
{
onDismiss={() => {}}
/>
);
- expect(wrapper.html()).toBe('');
- wrapper.setProps({ ...wrapper.props(), isOpen: false });
- expect(wrapper.html()).toBe('');
- });
-
- it('renders correct contents', () => {
- const wrapper = mount(
- {}}
- onDismiss={() => {}}
- />
- );
-
- expect(wrapper.contains('Some Title')).toBeTruthy();
- expect(wrapper.contains('Content')).toBeTruthy();
- expect(wrapper.contains('Confirm')).toBeTruthy();
+ expect(screen.queryByRole('heading', { name: 'Some Title' })).not.toBeInTheDocument();
+ expect(screen.queryByText('Some Body')).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Dismiss Text' })).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Alternative Text' })).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Confirm Modal Danger Button' })).not.toBeInTheDocument();
});
});
diff --git a/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.tsx b/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.tsx
index d2c64cd404e..99d5bf676c6 100644
--- a/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.tsx
+++ b/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.tsx
@@ -1,12 +1,13 @@
import React, { useEffect, useRef, useState } from 'react';
import { css } from '@emotion/css';
+import { GrafanaTheme2 } from '@grafana/data';
+import { selectors } from '@grafana/e2e-selectors';
+
import { Modal } from '../Modal/Modal';
import { IconName } from '../../types/icon';
import { Button } from '../Button';
import { useStyles2 } from '../../themes';
-import { GrafanaTheme2 } from '@grafana/data';
import { HorizontalGroup, Input } from '..';
-import { selectors } from '@grafana/e2e-selectors';
export interface ConfirmModalProps {
/** Toggle modal's open/closed state */
diff --git a/packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButtonGroup.tsx b/packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButtonGroup.tsx
index dacd001a8a1..17134a109e7 100644
--- a/packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButtonGroup.tsx
+++ b/packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButtonGroup.tsx
@@ -9,6 +9,7 @@ import { useStyles2 } from '../../../themes';
export interface RadioButtonGroupProps {
value?: T;
+ id?: string;
disabled?: boolean;
disabledOptions?: T[];
options: Array>;
@@ -28,6 +29,7 @@ export function RadioButtonGroup({
disabled,
disabledOptions,
size = 'md',
+ id,
className,
fullWidth = false,
autoFocus = false,
@@ -52,8 +54,9 @@ export function RadioButtonGroup({
},
[onClick]
);
- const id = uniqueId('radiogroup-');
- const groupName = useRef(id);
+
+ const internalId = id ?? uniqueId('radiogroup-');
+ const groupName = useRef(internalId);
const styles = useStyles2(getStyles);
const activeButtonRef = useRef(null);
@@ -76,7 +79,7 @@ export function RadioButtonGroup({
aria-label={o.ariaLabel}
onChange={handleOnChange(o)}
onClick={handleOnClick(o)}
- id={`option-${o.value}-${id}`}
+ id={`option-${o.value}-${internalId}`}
name={groupName.current}
description={o.description}
fullWidth={fullWidth}
diff --git a/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx b/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx
index 053539acbe1..ba5cd2b2b9f 100644
--- a/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx
+++ b/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx
@@ -69,6 +69,7 @@ function getStyles(theme: GrafanaTheme2) {
transition: opacity 0.3s;
z-index: ${theme.zIndex.tooltip};
max-width: 400px;
+ overflow-wrap: break-word;
&[data-popper-interactive='false'] {
pointer-events: none;
diff --git a/packages/grafana-ui/src/components/VizTooltip/VizTooltipContainer.tsx b/packages/grafana-ui/src/components/VizTooltip/VizTooltipContainer.tsx
index 48dad2789eb..96e6a506413 100644
--- a/packages/grafana-ui/src/components/VizTooltip/VizTooltipContainer.tsx
+++ b/packages/grafana-ui/src/components/VizTooltip/VizTooltipContainer.tsx
@@ -23,7 +23,7 @@ export const VizTooltipContainer: React.FC = ({
position: { x: positionX, y: positionY },
offset: { x: offsetX, y: offsetY },
children,
- allowPointerEvents,
+ allowPointerEvents = false,
className,
...otherProps
}) => {
diff --git a/packages/jaeger-ui-components/package.json b/packages/jaeger-ui-components/package.json
index ce1308cecb6..c68c7169f87 100644
--- a/packages/jaeger-ui-components/package.json
+++ b/packages/jaeger-ui-components/package.json
@@ -18,7 +18,6 @@
"@types/prop-types": "^15",
"@types/react": "17.0.39",
"@types/react-icons": "2.2.7",
- "@types/recompose": "0.30.10",
"@types/reselect": "2.2.0",
"@types/tinycolor2": "1.4.3",
"enzyme": "3.11.0",
@@ -46,7 +45,6 @@
"react": "17.0.2",
"react-dom": "17.0.2",
"react-icons": "2.2.7",
- "recompose": "0.30.0",
"reselect": "4.1.5",
"tinycolor2": "1.4.2",
"tslib": "2.3.1",
diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBar.test.js b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBar.test.js
index 09146b8e459..70cc7b87420 100644
--- a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBar.test.js
+++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBar.test.js
@@ -14,6 +14,7 @@
import React from 'react';
import { mount } from 'enzyme';
+import { act } from 'react-dom/test-utils';
import { Popover } from '../common/Popover';
import SpanBar from './SpanBar';
@@ -79,9 +80,13 @@ describe('', () => {
const { onMouseOver, onMouseLeave } = wrapper.find('[data-test-id="SpanBar--wrapper"]').props();
const labelElm = wrapper.find('[data-test-id="SpanBar--label"]');
expect(labelElm.text()).toBe(shortLabel);
- onMouseOver();
+ act(() => {
+ onMouseOver();
+ });
expect(labelElm.text()).toBe(longLabel);
- onMouseLeave();
+ act(() => {
+ onMouseLeave();
+ });
expect(labelElm.text()).toBe(shortLabel);
});
diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBar.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBar.tsx
index c78db4b23ae..097bd362bda 100644
--- a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBar.tsx
+++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBar.tsx
@@ -15,8 +15,7 @@
import cx from 'classnames';
import { css } from '@emotion/css';
import { groupBy as _groupBy } from 'lodash';
-import React from 'react';
-import { compose, onlyUpdateForKeys, withProps, withState } from 'recompose';
+import React, { useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { autoColor } from '../Theme';
@@ -90,7 +89,7 @@ const getStyles = (theme: GrafanaTheme2) => {
};
};
-type TCommonProps = {
+type Props = {
color: string;
onClick?: (evt: React.MouseEvent) => void;
viewEnd: number;
@@ -107,39 +106,32 @@ type TCommonProps = {
span: TraceSpan;
className?: string;
labelClassName?: string;
-};
-
-type TInnerProps = {
- label: string;
- setLongLabel: () => void;
- setShortLabel: () => void;
-} & TCommonProps;
-
-type TOuterProps = {
longLabel: string;
shortLabel: string;
-} & TCommonProps;
+};
function toPercent(value: number) {
return `${(value * 100).toFixed(1)}%`;
}
-function SpanBar(props: TInnerProps) {
- const {
- viewEnd,
- viewStart,
- getViewedBounds,
- color,
- label,
- onClick,
- setLongLabel,
- setShortLabel,
- rpc,
- traceStartTime,
- span,
- className,
- labelClassName,
- } = props;
+function SpanBar({
+ viewEnd,
+ viewStart,
+ getViewedBounds,
+ color,
+ shortLabel,
+ longLabel,
+ onClick,
+ rpc,
+ traceStartTime,
+ span,
+ className,
+ labelClassName,
+}: Props) {
+ const [label, setLabel] = useState(shortLabel);
+ const setShortLabel = () => setLabel(shortLabel);
+ const setLongLabel = () => setLabel(longLabel);
+
// group logs based on timestamps
const logGroups = _groupBy(span.logs, (log) => {
const posPercent = getViewedBounds(log.timestamp, log.timestamp).start;
@@ -196,21 +188,4 @@ function SpanBar(props: TInnerProps) {
);
}
-export default compose(
- withState('label', 'setLabel', (props: { shortLabel: string }) => props.shortLabel),
- withProps(
- ({
- setLabel,
- shortLabel,
- longLabel,
- }: {
- setLabel: (label: string) => void;
- shortLabel: string;
- longLabel: string;
- }) => ({
- setLongLabel: () => setLabel(longLabel),
- setShortLabel: () => setLabel(shortLabel),
- })
- ),
- onlyUpdateForKeys(['label', 'rpc', 'viewStart', 'viewEnd'])
-)(SpanBar);
+export default React.memo(SpanBar);
diff --git a/packages/jaeger-ui-components/src/index.ts b/packages/jaeger-ui-components/src/index.ts
index 64ad6a2b383..f5737eff1f5 100644
--- a/packages/jaeger-ui-components/src/index.ts
+++ b/packages/jaeger-ui-components/src/index.ts
@@ -6,9 +6,3 @@ export { default as DetailState } from './TraceTimelineViewer/SpanDetail/DetailS
export { default as transformTraceData } from './model/transform-trace-data';
export { default as filterSpans } from './utils/filter-spans';
export * from './Theme';
-
-import { onlyUpdateForKeys } from 'recompose';
-
-export default {
- onlyUpdateForKeys,
-} as any;
diff --git a/pkg/api/roles.go b/pkg/api/accesscontrol.go
similarity index 55%
rename from pkg/api/roles.go
rename to pkg/api/accesscontrol.go
index 8ec62c68ea7..5215869edf8 100644
--- a/pkg/api/roles.go
+++ b/pkg/api/accesscontrol.go
@@ -1,8 +1,10 @@
package api
import (
+ "fmt"
+
"github.com/grafana/grafana/pkg/models"
- "github.com/grafana/grafana/pkg/services/accesscontrol"
+ ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/setting"
)
@@ -29,49 +31,49 @@ const (
// API related scopes
var (
- ScopeProvisionersAll = accesscontrol.Scope("provisioners", "*")
- ScopeProvisionersDashboards = accesscontrol.Scope("provisioners", "dashboards")
- ScopeProvisionersPlugins = accesscontrol.Scope("provisioners", "plugins")
- ScopeProvisionersDatasources = accesscontrol.Scope("provisioners", "datasources")
- ScopeProvisionersNotifications = accesscontrol.Scope("provisioners", "notifications")
+ ScopeProvisionersAll = ac.Scope("provisioners", "*")
+ ScopeProvisionersDashboards = ac.Scope("provisioners", "dashboards")
+ ScopeProvisionersPlugins = ac.Scope("provisioners", "plugins")
+ ScopeProvisionersDatasources = ac.Scope("provisioners", "datasources")
+ ScopeProvisionersNotifications = ac.Scope("provisioners", "notifications")
- ScopeDatasourcesAll = accesscontrol.Scope("datasources", "*")
- ScopeDatasourceID = accesscontrol.Scope("datasources", "id", accesscontrol.Parameter(":id"))
- ScopeDatasourceUID = accesscontrol.Scope("datasources", "uid", accesscontrol.Parameter(":uid"))
- ScopeDatasourceName = accesscontrol.Scope("datasources", "name", accesscontrol.Parameter(":name"))
+ ScopeDatasourcesAll = ac.Scope("datasources", "*")
+ ScopeDatasourceID = ac.Scope("datasources", "id", ac.Parameter(":id"))
+ ScopeDatasourceUID = ac.Scope("datasources", "uid", ac.Parameter(":uid"))
+ ScopeDatasourceName = ac.Scope("datasources", "name", ac.Parameter(":name"))
)
// declareFixedRoles declares to the AccessControl service fixed roles and their
// grants to organization roles ("Viewer", "Editor", "Admin") or "Grafana Admin"
// that HTTPServer needs
func (hs *HTTPServer) declareFixedRoles() error {
- provisioningWriterRole := accesscontrol.RoleRegistration{
- Role: accesscontrol.RoleDTO{
+ provisioningWriterRole := ac.RoleRegistration{
+ Role: ac.RoleDTO{
Version: 3,
Name: "fixed:provisioning:writer",
DisplayName: "Provisioning writer",
Description: "Reload provisioning.",
Group: "Provisioning",
- Permissions: []accesscontrol.Permission{
+ Permissions: []ac.Permission{
{
Action: ActionProvisioningReload,
Scope: ScopeProvisionersAll,
},
},
},
- Grants: []string{accesscontrol.RoleGrafanaAdmin},
+ Grants: []string{ac.RoleGrafanaAdmin},
}
- datasourcesExplorerRole := accesscontrol.RoleRegistration{
- Role: accesscontrol.RoleDTO{
+ datasourcesExplorerRole := ac.RoleRegistration{
+ Role: ac.RoleDTO{
Version: 4,
Name: "fixed:datasources:explorer",
DisplayName: "Data source explorer",
Description: "Enable the Explore feature. Data source permissions still apply; you can only query data sources for which you have query permissions.",
Group: "Data sources",
- Permissions: []accesscontrol.Permission{
+ Permissions: []ac.Permission{
{
- Action: accesscontrol.ActionDatasourcesExplore,
+ Action: ac.ActionDatasourcesExplore,
},
},
},
@@ -82,14 +84,14 @@ func (hs *HTTPServer) declareFixedRoles() error {
datasourcesExplorerRole.Grants = append(datasourcesExplorerRole.Grants, string(models.ROLE_VIEWER))
}
- datasourcesReaderRole := accesscontrol.RoleRegistration{
- Role: accesscontrol.RoleDTO{
+ datasourcesReaderRole := ac.RoleRegistration{
+ Role: ac.RoleDTO{
Version: 3,
Name: "fixed:datasources:reader",
DisplayName: "Data source reader",
Description: "Read and query all data sources.",
Group: "Data sources",
- Permissions: []accesscontrol.Permission{
+ Permissions: []ac.Permission{
{
Action: ActionDatasourcesRead,
Scope: ScopeDatasourcesAll,
@@ -103,14 +105,14 @@ func (hs *HTTPServer) declareFixedRoles() error {
Grants: []string{string(models.ROLE_ADMIN)},
}
- datasourcesWriterRole := accesscontrol.RoleRegistration{
- Role: accesscontrol.RoleDTO{
+ datasourcesWriterRole := ac.RoleRegistration{
+ Role: ac.RoleDTO{
Version: 3,
Name: "fixed:datasources:writer",
DisplayName: "Data source writer",
Description: "Create, update, delete, read, or query data sources.",
Group: "Data sources",
- Permissions: accesscontrol.ConcatPermissions(datasourcesReaderRole.Role.Permissions, []accesscontrol.Permission{
+ Permissions: ac.ConcatPermissions(datasourcesReaderRole.Role.Permissions, []ac.Permission{
{
Action: ActionDatasourcesWrite,
Scope: ScopeDatasourcesAll,
@@ -127,14 +129,14 @@ func (hs *HTTPServer) declareFixedRoles() error {
Grants: []string{string(models.ROLE_ADMIN)},
}
- datasourcesIdReaderRole := accesscontrol.RoleRegistration{
- Role: accesscontrol.RoleDTO{
+ datasourcesIdReaderRole := ac.RoleRegistration{
+ Role: ac.RoleDTO{
Version: 4,
Name: "fixed:datasources.id:reader",
DisplayName: "Data source ID reader",
Description: "Read the ID of a data source based on its name.",
Group: "Infrequently used",
- Permissions: []accesscontrol.Permission{
+ Permissions: []ac.Permission{
{
Action: ActionDatasourcesIDRead,
Scope: ScopeDatasourcesAll,
@@ -144,14 +146,14 @@ func (hs *HTTPServer) declareFixedRoles() error {
Grants: []string{string(models.ROLE_VIEWER)},
}
- datasourcesCompatibilityReaderRole := accesscontrol.RoleRegistration{
- Role: accesscontrol.RoleDTO{
+ datasourcesCompatibilityReaderRole := ac.RoleRegistration{
+ Role: ac.RoleDTO{
Version: 3,
Name: "fixed:datasources:compatibility:querier",
DisplayName: "Data source compatibility querier",
Description: "Only used for open source compatibility. Query data sources.",
Group: "Infrequently used",
- Permissions: []accesscontrol.Permission{
+ Permissions: []ac.Permission{
{Action: ActionDatasourcesQuery},
{Action: ActionDatasourcesRead},
},
@@ -159,29 +161,29 @@ func (hs *HTTPServer) declareFixedRoles() error {
Grants: []string{string(models.ROLE_VIEWER)},
}
- orgReaderRole := accesscontrol.RoleRegistration{
- Role: accesscontrol.RoleDTO{
+ orgReaderRole := ac.RoleRegistration{
+ Role: ac.RoleDTO{
Version: 5,
Name: "fixed:organization:reader",
DisplayName: "Organization reader",
Description: "Read an organization, such as its ID, name, address, or quotas.",
Group: "Organizations",
- Permissions: []accesscontrol.Permission{
+ Permissions: []ac.Permission{
{Action: ActionOrgsRead},
{Action: ActionOrgsQuotasRead},
},
},
- Grants: []string{string(models.ROLE_VIEWER), accesscontrol.RoleGrafanaAdmin},
+ Grants: []string{string(models.ROLE_VIEWER), ac.RoleGrafanaAdmin},
}
- orgWriterRole := accesscontrol.RoleRegistration{
- Role: accesscontrol.RoleDTO{
+ orgWriterRole := ac.RoleRegistration{
+ Role: ac.RoleDTO{
Version: 5,
Name: "fixed:organization:writer",
DisplayName: "Organization writer",
Description: "Read an organization, its quotas, or its preferences. Update organization properties, or its preferences.",
Group: "Organizations",
- Permissions: accesscontrol.ConcatPermissions(orgReaderRole.Role.Permissions, []accesscontrol.Permission{
+ Permissions: ac.ConcatPermissions(orgReaderRole.Role.Permissions, []ac.Permission{
{Action: ActionOrgsPreferencesRead},
{Action: ActionOrgsWrite},
{Action: ActionOrgsPreferencesWrite},
@@ -190,71 +192,71 @@ func (hs *HTTPServer) declareFixedRoles() error {
Grants: []string{string(models.ROLE_ADMIN)},
}
- orgMaintainerRole := accesscontrol.RoleRegistration{
- Role: accesscontrol.RoleDTO{
+ orgMaintainerRole := ac.RoleRegistration{
+ Role: ac.RoleDTO{
Version: 5,
Name: "fixed:organization:maintainer",
DisplayName: "Organization maintainer",
Description: "Create, read, write, or delete an organization. Read or write an organization's quotas. Needs to be assigned globally.",
Group: "Organizations",
- Permissions: accesscontrol.ConcatPermissions(orgReaderRole.Role.Permissions, []accesscontrol.Permission{
+ Permissions: ac.ConcatPermissions(orgReaderRole.Role.Permissions, []ac.Permission{
{Action: ActionOrgsCreate},
{Action: ActionOrgsWrite},
{Action: ActionOrgsDelete},
{Action: ActionOrgsQuotasWrite},
}),
},
- Grants: []string{string(accesscontrol.RoleGrafanaAdmin)},
+ Grants: []string{string(ac.RoleGrafanaAdmin)},
}
teamCreatorGrants := []string{string(models.ROLE_ADMIN)}
if hs.Cfg.EditorsCanAdmin {
teamCreatorGrants = append(teamCreatorGrants, string(models.ROLE_EDITOR))
}
- teamsCreatorRole := accesscontrol.RoleRegistration{
- Role: accesscontrol.RoleDTO{
+ teamsCreatorRole := ac.RoleRegistration{
+ Role: ac.RoleDTO{
Name: "fixed:teams:creator",
DisplayName: "Team creator",
Description: "Create teams and read organisation users (required to manage the created teams).",
Group: "Teams",
Version: 2,
- Permissions: []accesscontrol.Permission{
- {Action: accesscontrol.ActionTeamsCreate},
- {Action: accesscontrol.ActionOrgUsersRead, Scope: accesscontrol.ScopeUsersAll},
+ Permissions: []ac.Permission{
+ {Action: ac.ActionTeamsCreate},
+ {Action: ac.ActionOrgUsersRead, Scope: ac.ScopeUsersAll},
},
},
Grants: teamCreatorGrants,
}
- teamsWriterRole := accesscontrol.RoleRegistration{
- Role: accesscontrol.RoleDTO{
+ teamsWriterRole := ac.RoleRegistration{
+ Role: ac.RoleDTO{
Name: "fixed:teams:writer",
DisplayName: "Team writer",
Description: "Create, read, write, or delete a team as well as controlling team memberships.",
Group: "Teams",
Version: 2,
- Permissions: []accesscontrol.Permission{
- {Action: accesscontrol.ActionTeamsCreate},
- {Action: accesscontrol.ActionTeamsDelete, Scope: accesscontrol.ScopeTeamsAll},
- {Action: accesscontrol.ActionTeamsPermissionsRead, Scope: accesscontrol.ScopeTeamsAll},
- {Action: accesscontrol.ActionTeamsPermissionsWrite, Scope: accesscontrol.ScopeTeamsAll},
- {Action: accesscontrol.ActionTeamsRead, Scope: accesscontrol.ScopeTeamsAll},
- {Action: accesscontrol.ActionTeamsWrite, Scope: accesscontrol.ScopeTeamsAll},
+ Permissions: []ac.Permission{
+ {Action: ac.ActionTeamsCreate},
+ {Action: ac.ActionTeamsDelete, Scope: ac.ScopeTeamsAll},
+ {Action: ac.ActionTeamsPermissionsRead, Scope: ac.ScopeTeamsAll},
+ {Action: ac.ActionTeamsPermissionsWrite, Scope: ac.ScopeTeamsAll},
+ {Action: ac.ActionTeamsRead, Scope: ac.ScopeTeamsAll},
+ {Action: ac.ActionTeamsWrite, Scope: ac.ScopeTeamsAll},
},
},
Grants: []string{string(models.ROLE_ADMIN)},
}
- annotationsReaderRole := accesscontrol.RoleRegistration{
- Role: accesscontrol.RoleDTO{
+ annotationsReaderRole := ac.RoleRegistration{
+ Role: ac.RoleDTO{
Name: "fixed:annotations:reader",
DisplayName: "Annotation reader",
Description: "Read annotations and tags",
Group: "Annotations",
Version: 1,
- Permissions: []accesscontrol.Permission{
- {Action: accesscontrol.ActionAnnotationsRead, Scope: accesscontrol.ScopeAnnotationsAll},
- {Action: accesscontrol.ActionAnnotationsTagsRead, Scope: accesscontrol.ScopeAnnotationsTagsAll},
+ Permissions: []ac.Permission{
+ {Action: ac.ActionAnnotationsRead, Scope: ac.ScopeAnnotationsAll},
+ {Action: ac.ActionAnnotationsTagsRead, Scope: ac.ScopeAnnotationsTagsAll},
},
},
Grants: []string{string(models.ROLE_VIEWER)},
@@ -271,69 +273,96 @@ func (hs *HTTPServer) declareFixedRoles() error {
// here is the list of complex evaluators we use in this package
// dataSourcesConfigurationAccessEvaluator is used to protect the "Configure > Data sources" tab access
-var dataSourcesConfigurationAccessEvaluator = accesscontrol.EvalAll(
- accesscontrol.EvalPermission(ActionDatasourcesRead),
- accesscontrol.EvalAny(
- accesscontrol.EvalPermission(ActionDatasourcesCreate),
- accesscontrol.EvalPermission(ActionDatasourcesDelete),
- accesscontrol.EvalPermission(ActionDatasourcesWrite),
+var dataSourcesConfigurationAccessEvaluator = ac.EvalAll(
+ ac.EvalPermission(ActionDatasourcesRead),
+ ac.EvalAny(
+ ac.EvalPermission(ActionDatasourcesCreate),
+ ac.EvalPermission(ActionDatasourcesDelete),
+ ac.EvalPermission(ActionDatasourcesWrite),
),
)
// dataSourcesNewAccessEvaluator is used to protect the "Configure > Data sources > New" page access
-var dataSourcesNewAccessEvaluator = accesscontrol.EvalAll(
- accesscontrol.EvalPermission(ActionDatasourcesRead),
- accesscontrol.EvalPermission(ActionDatasourcesCreate),
- accesscontrol.EvalPermission(ActionDatasourcesWrite),
+var dataSourcesNewAccessEvaluator = ac.EvalAll(
+ ac.EvalPermission(ActionDatasourcesRead),
+ ac.EvalPermission(ActionDatasourcesCreate),
+ ac.EvalPermission(ActionDatasourcesWrite),
)
// dataSourcesEditAccessEvaluator is used to protect the "Configure > Data sources > Edit" page access
-var dataSourcesEditAccessEvaluator = accesscontrol.EvalAll(
- accesscontrol.EvalPermission(ActionDatasourcesRead),
- accesscontrol.EvalPermission(ActionDatasourcesWrite),
+var dataSourcesEditAccessEvaluator = ac.EvalAll(
+ ac.EvalPermission(ActionDatasourcesRead),
+ ac.EvalPermission(ActionDatasourcesWrite),
)
// orgPreferencesAccessEvaluator is used to protect the "Configure > Preferences" page access
-var orgPreferencesAccessEvaluator = accesscontrol.EvalAny(
- accesscontrol.EvalAll(
- accesscontrol.EvalPermission(ActionOrgsRead),
- accesscontrol.EvalPermission(ActionOrgsWrite),
+var orgPreferencesAccessEvaluator = ac.EvalAny(
+ ac.EvalAll(
+ ac.EvalPermission(ActionOrgsRead),
+ ac.EvalPermission(ActionOrgsWrite),
),
- accesscontrol.EvalAll(
- accesscontrol.EvalPermission(ActionOrgsPreferencesRead),
- accesscontrol.EvalPermission(ActionOrgsPreferencesWrite),
+ ac.EvalAll(
+ ac.EvalPermission(ActionOrgsPreferencesRead),
+ ac.EvalPermission(ActionOrgsPreferencesWrite),
),
)
// orgsAccessEvaluator is used to protect the "Server Admin > Orgs" page access
// (you need to have read access to update or delete orgs; read is the minimum)
-var orgsAccessEvaluator = accesscontrol.EvalPermission(ActionOrgsRead)
+var orgsAccessEvaluator = ac.EvalPermission(ActionOrgsRead)
// orgsCreateAccessEvaluator is used to protect the "Server Admin > Orgs > New Org" page access
-var orgsCreateAccessEvaluator = accesscontrol.EvalAll(
- accesscontrol.EvalPermission(ActionOrgsRead),
- accesscontrol.EvalPermission(ActionOrgsCreate),
+var orgsCreateAccessEvaluator = ac.EvalAll(
+ ac.EvalPermission(ActionOrgsRead),
+ ac.EvalPermission(ActionOrgsCreate),
)
// teamsAccessEvaluator is used to protect the "Configuration > Teams" page access
// grants access to a user when they can either create teams or can read and update a team
-var teamsAccessEvaluator = accesscontrol.EvalAny(
- accesscontrol.EvalPermission(accesscontrol.ActionTeamsCreate),
- accesscontrol.EvalAll(
- accesscontrol.EvalPermission(accesscontrol.ActionTeamsRead),
- accesscontrol.EvalAny(
- accesscontrol.EvalPermission(accesscontrol.ActionTeamsWrite),
- accesscontrol.EvalPermission(accesscontrol.ActionTeamsPermissionsWrite),
+var teamsAccessEvaluator = ac.EvalAny(
+ ac.EvalPermission(ac.ActionTeamsCreate),
+ ac.EvalAll(
+ ac.EvalPermission(ac.ActionTeamsRead),
+ ac.EvalAny(
+ ac.EvalPermission(ac.ActionTeamsWrite),
+ ac.EvalPermission(ac.ActionTeamsPermissionsWrite),
),
),
)
// teamsEditAccessEvaluator is used to protect the "Configuration > Teams > edit" page access
-var teamsEditAccessEvaluator = accesscontrol.EvalAll(
- accesscontrol.EvalPermission(accesscontrol.ActionTeamsRead),
- accesscontrol.EvalAny(
- accesscontrol.EvalPermission(accesscontrol.ActionTeamsCreate),
- accesscontrol.EvalPermission(accesscontrol.ActionTeamsWrite),
- accesscontrol.EvalPermission(accesscontrol.ActionTeamsPermissionsWrite),
+var teamsEditAccessEvaluator = ac.EvalAll(
+ ac.EvalPermission(ac.ActionTeamsRead),
+ ac.EvalAny(
+ ac.EvalPermission(ac.ActionTeamsCreate),
+ ac.EvalPermission(ac.ActionTeamsWrite),
+ ac.EvalPermission(ac.ActionTeamsPermissionsWrite),
),
)
+
+// Metadata helpers
+// getAccessControlMetadata returns the accesscontrol metadata associated with a given resource
+func (hs *HTTPServer) getAccessControlMetadata(c *models.ReqContext, resource string, id int64) ac.Metadata {
+ key := fmt.Sprintf("%d", id)
+ ids := map[string]bool{key: true}
+
+ return hs.getMultiAccessControlMetadata(c, resource, ids)[key]
+}
+
+// getMultiAccessControlMetadata returns the accesscontrol metadata associated with a given set of resources
+func (hs *HTTPServer) getMultiAccessControlMetadata(c *models.ReqContext, resource string, ids map[string]bool) map[string]ac.Metadata {
+ if hs.AccessControl.IsDisabled() || !c.QueryBool("accesscontrol") {
+ return map[string]ac.Metadata{}
+ }
+
+ if c.SignedInUser.Permissions == nil {
+ return map[string]ac.Metadata{}
+ }
+
+ permissions, ok := c.SignedInUser.Permissions[c.OrgId]
+ if !ok {
+ return map[string]ac.Metadata{}
+ }
+
+ return ac.GetResourcesMetadata(c.Req.Context(), permissions, resource, ids)
+}
diff --git a/pkg/api/acl.go b/pkg/api/acl.go
deleted file mode 100644
index eae0cf21aeb..00000000000
--- a/pkg/api/acl.go
+++ /dev/null
@@ -1,15 +0,0 @@
-package api
-
-import (
- "context"
-
- "github.com/grafana/grafana/pkg/dashboards"
- "github.com/grafana/grafana/pkg/models"
-)
-
-// updateDashboardACL updates a dashboard's ACL items.
-//
-// Stubbable by tests.
-var updateDashboardACL = func(ctx context.Context, s dashboards.Store, dashID int64, items []*models.DashboardAcl) error {
- return s.UpdateDashboardACLCtx(ctx, dashID, items)
-}
diff --git a/pkg/api/alerting.go b/pkg/api/alerting.go
index 23efe48f686..15414b6269d 100644
--- a/pkg/api/alerting.go
+++ b/pkg/api/alerting.go
@@ -239,7 +239,7 @@ func (hs *HTTPServer) GetAlertNotifications(c *models.ReqContext) response.Respo
func (hs *HTTPServer) getAlertNotificationsInternal(c *models.ReqContext) ([]*models.AlertNotification, error) {
query := &models.GetAllAlertNotificationsQuery{OrgId: c.OrgId}
- if err := hs.SQLStore.GetAllAlertNotifications(c.Req.Context(), query); err != nil {
+ if err := hs.AlertNotificationService.GetAllAlertNotifications(c.Req.Context(), query); err != nil {
return nil, err
}
@@ -260,7 +260,7 @@ func (hs *HTTPServer) GetAlertNotificationByID(c *models.ReqContext) response.Re
return response.Error(404, "Alert notification not found", nil)
}
- if err := hs.SQLStore.GetAlertNotifications(c.Req.Context(), query); err != nil {
+ if err := hs.AlertNotificationService.GetAlertNotifications(c.Req.Context(), query); err != nil {
return response.Error(500, "Failed to get alert notifications", err)
}
@@ -281,7 +281,7 @@ func (hs *HTTPServer) GetAlertNotificationByUID(c *models.ReqContext) response.R
return response.Error(404, "Alert notification not found", nil)
}
- if err := hs.SQLStore.GetAlertNotificationsWithUid(c.Req.Context(), query); err != nil {
+ if err := hs.AlertNotificationService.GetAlertNotificationsWithUid(c.Req.Context(), query); err != nil {
return response.Error(500, "Failed to get alert notifications", err)
}
@@ -299,7 +299,7 @@ func (hs *HTTPServer) CreateAlertNotification(c *models.ReqContext) response.Res
}
cmd.OrgId = c.OrgId
- if err := hs.SQLStore.CreateAlertNotificationCommand(c.Req.Context(), &cmd); err != nil {
+ if err := hs.AlertNotificationService.CreateAlertNotificationCommand(c.Req.Context(), &cmd); err != nil {
if errors.Is(err, models.ErrAlertNotificationWithSameNameExists) || errors.Is(err, models.ErrAlertNotificationWithSameUIDExists) {
return response.Error(409, "Failed to create alert notification", err)
}
@@ -325,7 +325,7 @@ func (hs *HTTPServer) UpdateAlertNotification(c *models.ReqContext) response.Res
return response.Error(500, "Failed to update alert notification", err)
}
- if err := hs.SQLStore.UpdateAlertNotification(c.Req.Context(), &cmd); err != nil {
+ if err := hs.AlertNotificationService.UpdateAlertNotification(c.Req.Context(), &cmd); err != nil {
if errors.Is(err, models.ErrAlertNotificationNotFound) {
return response.Error(404, err.Error(), err)
}
@@ -341,7 +341,7 @@ func (hs *HTTPServer) UpdateAlertNotification(c *models.ReqContext) response.Res
Id: cmd.Id,
}
- if err := hs.SQLStore.GetAlertNotifications(c.Req.Context(), &query); err != nil {
+ if err := hs.AlertNotificationService.GetAlertNotifications(c.Req.Context(), &query); err != nil {
return response.Error(500, "Failed to get alert notification", err)
}
@@ -361,7 +361,7 @@ func (hs *HTTPServer) UpdateAlertNotificationByUID(c *models.ReqContext) respons
return response.Error(500, "Failed to update alert notification", err)
}
- if err := hs.SQLStore.UpdateAlertNotificationWithUid(c.Req.Context(), &cmd); err != nil {
+ if err := hs.AlertNotificationService.UpdateAlertNotificationWithUid(c.Req.Context(), &cmd); err != nil {
if errors.Is(err, models.ErrAlertNotificationNotFound) {
return response.Error(404, err.Error(), nil)
}
@@ -373,7 +373,7 @@ func (hs *HTTPServer) UpdateAlertNotificationByUID(c *models.ReqContext) respons
Uid: cmd.Uid,
}
- if err := hs.SQLStore.GetAlertNotificationsWithUid(c.Req.Context(), &query); err != nil {
+ if err := hs.AlertNotificationService.GetAlertNotificationsWithUid(c.Req.Context(), &query); err != nil {
return response.Error(500, "Failed to get alert notification", err)
}
@@ -390,7 +390,7 @@ func (hs *HTTPServer) fillWithSecureSettingsData(ctx context.Context, cmd *model
Id: cmd.Id,
}
- if err := hs.SQLStore.GetAlertNotifications(ctx, query); err != nil {
+ if err := hs.AlertNotificationService.GetAlertNotifications(ctx, query); err != nil {
return err
}
@@ -418,7 +418,7 @@ func (hs *HTTPServer) fillWithSecureSettingsDataByUID(ctx context.Context, cmd *
Uid: cmd.Uid,
}
- if err := hs.SQLStore.GetAlertNotificationsWithUid(ctx, query); err != nil {
+ if err := hs.AlertNotificationService.GetAlertNotificationsWithUid(ctx, query); err != nil {
return err
}
@@ -447,7 +447,7 @@ func (hs *HTTPServer) DeleteAlertNotification(c *models.ReqContext) response.Res
Id: notificationId,
}
- if err := hs.SQLStore.DeleteAlertNotification(c.Req.Context(), &cmd); err != nil {
+ if err := hs.AlertNotificationService.DeleteAlertNotification(c.Req.Context(), &cmd); err != nil {
if errors.Is(err, models.ErrAlertNotificationNotFound) {
return response.Error(404, err.Error(), nil)
}
@@ -463,7 +463,7 @@ func (hs *HTTPServer) DeleteAlertNotificationByUID(c *models.ReqContext) respons
Uid: web.Params(c.Req)[":uid"],
}
- if err := hs.SQLStore.DeleteAlertNotificationWithUid(c.Req.Context(), &cmd); err != nil {
+ if err := hs.AlertNotificationService.DeleteAlertNotificationWithUid(c.Req.Context(), &cmd); err != nil {
if errors.Is(err, models.ErrAlertNotificationNotFound) {
return response.Error(404, err.Error(), nil)
}
diff --git a/pkg/api/api.go b/pkg/api/api.go
index 6da0aa3488c..535cca95ab7 100644
--- a/pkg/api/api.go
+++ b/pkg/api/api.go
@@ -26,7 +26,7 @@ func (hs *HTTPServer) registerRoutes() {
reqGrafanaAdmin := middleware.ReqGrafanaAdmin
reqEditorRole := middleware.ReqEditorRole
reqOrgAdmin := middleware.ReqOrgAdmin
- reqOrgAdminFolderAdminOrTeamAdmin := middleware.OrgAdminFolderAdminOrTeamAdmin
+ reqOrgAdminFolderAdminOrTeamAdmin := middleware.OrgAdminFolderAdminOrTeamAdmin(hs.SQLStore)
reqCanAccessTeams := middleware.AdminOrEditorAndFeatureEnabled(hs.Cfg.EditorsCanAdmin)
reqSnapshotPublicModeOrSignedIn := middleware.SnapshotPublicModeOrSignedIn(hs.Cfg)
redirectFromLegacyPanelEditURL := middleware.RedirectFromLegacyPanelEditURL(hs.Cfg)
@@ -336,8 +336,11 @@ func (hs *HTTPServer) registerRoutes() {
if hs.ThumbService != nil {
dashboardRoute.Get("/uid/:uid/img/:kind/:theme", hs.ThumbService.GetImage)
- dashboardRoute.Post("/uid/:uid/img/:kind/:theme", hs.ThumbService.SetImage)
- dashboardRoute.Put("/uid/:uid/img/:kind/:theme", hs.ThumbService.UpdateThumbnailState)
+
+ if hs.Features.IsEnabled(featuremgmt.FlagDashboardPreviewsAdmin) {
+ dashboardRoute.Post("/uid/:uid/img/:kind/:theme", reqGrafanaAdmin, hs.ThumbService.SetImage)
+ dashboardRoute.Put("/uid/:uid/img/:kind/:theme", reqGrafanaAdmin, hs.ThumbService.UpdateThumbnailState)
+ }
}
dashboardRoute.Post("/calculate-diff", routing.Wrap(hs.CalculateDashboardDiff))
@@ -470,7 +473,7 @@ func (hs *HTTPServer) registerRoutes() {
adminRoute.Get("/stats", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionServerStatsRead)), routing.Wrap(hs.AdminGetStats))
adminRoute.Post("/pause-all-alerts", reqGrafanaAdmin, routing.Wrap(hs.PauseAllAlerts))
- if hs.ThumbService != nil {
+ if hs.ThumbService != nil && hs.Features.IsEnabled(featuremgmt.FlagDashboardPreviewsAdmin) {
adminRoute.Post("/crawler/start", reqGrafanaAdmin, routing.Wrap(hs.ThumbService.StartCrawler))
adminRoute.Post("/crawler/stop", reqGrafanaAdmin, routing.Wrap(hs.ThumbService.StopCrawler))
adminRoute.Get("/crawler/status", reqGrafanaAdmin, routing.Wrap(hs.ThumbService.CrawlerStatus))
diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go
index ce94ba3e77e..f03eb0eb304 100644
--- a/pkg/api/common_test.go
+++ b/pkg/api/common_test.go
@@ -25,9 +25,11 @@ import (
acmiddleware "github.com/grafana/grafana/pkg/services/accesscontrol/middleware"
accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
"github.com/grafana/grafana/pkg/services/accesscontrol/ossaccesscontrol"
- "github.com/grafana/grafana/pkg/services/accesscontrol/resourceservices"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/contexthandler"
+ "github.com/grafana/grafana/pkg/services/dashboards"
+ dashboardsstore "github.com/grafana/grafana/pkg/services/dashboards/database"
+ dashboardservice "github.com/grafana/grafana/pkg/services/dashboards/manager"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/ldap"
"github.com/grafana/grafana/pkg/services/quota"
@@ -276,6 +278,8 @@ type accessControlScenarioContext struct {
// cfg is the setting provider
cfg *setting.Cfg
+
+ dashboardsStore dashboards.Store
}
func setAccessControlPermissions(acmock *accesscontrolmock.Mock, perms []*accesscontrol.Permission, org int64) {
@@ -347,6 +351,8 @@ func setupHTTPServerWithCfg(t *testing.T, useFakeAccessControl, enableAccessCont
bus := bus.GetBus()
+ dashboardsStore := dashboardsstore.ProvideDashboardStore(db)
+
routeRegister := routing.NewRouteRegister()
// Create minimal HTTP Server
hs := &HTTPServer{
@@ -358,6 +364,7 @@ func setupHTTPServerWithCfg(t *testing.T, useFakeAccessControl, enableAccessCont
RouteRegister: routeRegister,
SQLStore: db,
searchUsersService: searchusers.ProvideUsersService(bus, filters.ProvideOSSSearchUserFilter()),
+ dashboardService: dashboardservice.ProvideDashboardService(dashboardsStore),
}
// Defining the accesscontrol service has to be done before registering routes
@@ -367,9 +374,9 @@ func setupHTTPServerWithCfg(t *testing.T, useFakeAccessControl, enableAccessCont
acmock = acmock.WithDisabled()
}
hs.AccessControl = acmock
- teamPermissionService, err := resourceservices.ProvideTeamPermissions(routeRegister, db, acmock, database.ProvideService(db))
+ teamPermissionService, err := ossaccesscontrol.ProvideTeamPermissions(routeRegister, db, acmock, database.ProvideService(db))
require.NoError(t, err)
- hs.TeamPermissionsService = teamPermissionService
+ hs.teamPermissionsService = teamPermissionService
} else {
ac := ossaccesscontrol.ProvideService(hs.Features, &usagestats.UsageStatsMock{T: t},
database.ProvideService(db), routing.NewRouteRegister())
@@ -379,9 +386,9 @@ func setupHTTPServerWithCfg(t *testing.T, useFakeAccessControl, enableAccessCont
require.NoError(t, err)
err = ac.RegisterFixedRoles()
require.NoError(t, err)
- teamPermissionService, err := resourceservices.ProvideTeamPermissions(routeRegister, db, ac, database.ProvideService(db))
+ teamPermissionService, err := ossaccesscontrol.ProvideTeamPermissions(routeRegister, db, ac, database.ProvideService(db))
require.NoError(t, err)
- hs.TeamPermissionsService = teamPermissionService
+ hs.teamPermissionsService = teamPermissionService
}
// Instantiate a new Server
@@ -402,12 +409,13 @@ func setupHTTPServerWithCfg(t *testing.T, useFakeAccessControl, enableAccessCont
hs.RouteRegister.Register(m.Router)
return accessControlScenarioContext{
- server: m,
- initCtx: initCtx,
- hs: hs,
- acmock: acmock,
- db: db,
- cfg: cfg,
+ server: m,
+ initCtx: initCtx,
+ hs: hs,
+ acmock: acmock,
+ db: db,
+ cfg: cfg,
+ dashboardsStore: dashboardsStore,
}
}
diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go
index 45a5b577ba8..8568a8b4615 100644
--- a/pkg/api/dashboard.go
+++ b/pkg/api/dashboard.go
@@ -148,8 +148,7 @@ func (hs *HTTPServer) GetDashboard(c *models.ReqContext) response.Response {
meta.FolderUrl = query.Result.GetUrl()
}
- svc := dashboards.NewProvisioningService(hs.SQLStore)
- provisioningData, err := svc.GetProvisionedDashboardDataByDashboardID(dash.Id)
+ provisioningData, err := hs.dashboardProvisioningService.GetProvisionedDashboardDataByDashboardID(dash.Id)
if err != nil {
return response.Error(500, "Error while checking if dashboard is provisioned", err)
}
@@ -233,8 +232,8 @@ func (hs *HTTPServer) deleteDashboard(c *models.ReqContext) response.Response {
if err != nil {
hs.log.Error("Failed to disconnect library elements", "dashboard", dash.Id, "user", c.SignedInUser.UserId, "error", err)
}
- svc := dashboards.NewService(hs.SQLStore)
- err = svc.DeleteDashboard(c.Req.Context(), dash.Id, c.OrgId)
+
+ err = hs.dashboardService.DeleteDashboard(c.Req.Context(), dash.Id, c.OrgId)
if err != nil {
var dashboardErr models.DashboardErr
if ok := errors.As(err, &dashboardErr); ok {
@@ -271,8 +270,7 @@ func (hs *HTTPServer) postDashboard(c *models.ReqContext, cmd models.SaveDashboa
cmd.OrgId = c.OrgId
cmd.UserId = c.UserId
if cmd.FolderUid != "" {
- folders := dashboards.NewFolderService(c.OrgId, c.SignedInUser, hs.SQLStore)
- folder, err := folders.GetFolderByUID(ctx, cmd.FolderUid)
+ folder, err := hs.folderService.GetFolderByUID(ctx, c.SignedInUser, c.OrgId, cmd.FolderUid)
if err != nil {
if errors.Is(err, models.ErrFolderNotFound) {
return response.Error(400, "Folder not found", err)
@@ -294,18 +292,17 @@ func (hs *HTTPServer) postDashboard(c *models.ReqContext, cmd models.SaveDashboa
}
}
- svc := dashboards.NewProvisioningService(hs.SQLStore)
var provisioningData *models.DashboardProvisioning
if dash.Id != 0 {
- data, err := svc.GetProvisionedDashboardDataByDashboardID(dash.Id)
+ data, err := hs.dashboardProvisioningService.GetProvisionedDashboardDataByDashboardID(dash.Id)
if err != nil {
return response.Error(500, "Error while checking if dashboard is provisioned using ID", err)
}
provisioningData = data
} else if dash.Uid != "" {
- data, err := svc.GetProvisionedDashboardDataByDashboardUID(dash.OrgId, dash.Uid)
- if err != nil && (!errors.Is(err, models.ErrProvisionedDashboardNotFound) && !errors.Is(err, models.ErrDashboardNotFound)) {
- return response.Error(500, "Error while checking if dashboard is provisioned using UID", err)
+ data, err := hs.dashboardProvisioningService.GetProvisionedDashboardDataByDashboardUID(dash.OrgId, dash.Uid)
+ if err != nil && !errors.Is(err, models.ErrProvisionedDashboardNotFound) && !errors.Is(err, models.ErrDashboardNotFound) {
+ return response.Error(500, "Error while checking if dashboard is provisioned", err)
}
provisioningData = data
}
@@ -329,8 +326,7 @@ func (hs *HTTPServer) postDashboard(c *models.ReqContext, cmd models.SaveDashboa
Overwrite: cmd.Overwrite,
}
- dashSvc := dashboards.NewService(hs.SQLStore)
- dashboard, err := dashSvc.SaveDashboard(alerting.WithUAEnabled(ctx, hs.Cfg.UnifiedAlerting.IsEnabled()), dashItem, allowUiUpdate)
+ dashboard, err := hs.dashboardService.SaveDashboard(alerting.WithUAEnabled(ctx, hs.Cfg.UnifiedAlerting.IsEnabled()), dashItem, allowUiUpdate)
if hs.Live != nil {
// Tell everyone listening that the dashboard changed
@@ -362,7 +358,7 @@ func (hs *HTTPServer) postDashboard(c *models.ReqContext, cmd models.SaveDashboa
if hs.Cfg.EditorsCanAdmin && newDashboard {
inFolder := cmd.FolderId > 0
- err := dashSvc.MakeUserAdmin(ctx, cmd.OrgId, cmd.UserId, dashboard.Id, !inFolder)
+ err := hs.dashboardService.MakeUserAdmin(ctx, cmd.OrgId, cmd.UserId, dashboard.Id, !inFolder)
if err != nil {
hs.log.Error("Could not make user admin", "dashboard", dashboard.Title, "user", c.SignedInUser.UserId, "error", err)
}
diff --git a/pkg/api/dashboard_permission.go b/pkg/api/dashboard_permission.go
index 275acc82d13..da6833dc395 100644
--- a/pkg/api/dashboard_permission.go
+++ b/pkg/api/dashboard_permission.go
@@ -112,7 +112,7 @@ func (hs *HTTPServer) UpdateDashboardPermissions(c *models.ReqContext) response.
return response.Error(403, "Cannot remove own admin permission for a folder", nil)
}
- if err := updateDashboardACL(c.Req.Context(), hs.SQLStore, dashID, items); err != nil {
+ if err := hs.dashboardService.UpdateDashboardACL(c.Req.Context(), dashID, items); err != nil {
if errors.Is(err, models.ErrDashboardAclInfoMissing) ||
errors.Is(err, models.ErrDashboardPermissionDashboardEmpty) {
return response.Error(409, err.Error(), err)
diff --git a/pkg/api/dashboard_permission_test.go b/pkg/api/dashboard_permission_test.go
index f5dd0f490b0..a64ef0b8da3 100644
--- a/pkg/api/dashboard_permission_test.go
+++ b/pkg/api/dashboard_permission_test.go
@@ -1,59 +1,38 @@
package api
import (
- "context"
"encoding/json"
"fmt"
"testing"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
-
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/api/routing"
- "github.com/grafana/grafana/pkg/dashboards"
"github.com/grafana/grafana/pkg/models"
+ "github.com/grafana/grafana/pkg/services/dashboards/database"
+ dashboardservice "github.com/grafana/grafana/pkg/services/dashboards/manager"
"github.com/grafana/grafana/pkg/services/guardian"
"github.com/grafana/grafana/pkg/services/sqlstore/mockstore"
"github.com/grafana/grafana/pkg/setting"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+ "github.com/stretchr/testify/require"
)
func TestDashboardPermissionAPIEndpoint(t *testing.T) {
t.Run("Dashboard permissions test", func(t *testing.T) {
settings := setting.NewCfg()
+ dashboardStore := &database.FakeDashboardStore{}
+ defer dashboardStore.AssertExpectations(t)
+
mockSQLStore := mockstore.NewSQLStoreMock()
+
hs := &HTTPServer{
- Cfg: settings,
- SQLStore: mockSQLStore,
+ Cfg: settings,
+ dashboardService: dashboardservice.ProvideDashboardService(dashboardStore),
+ SQLStore: mockSQLStore,
}
- t.Run("Given dashboard not exists", func(t *testing.T) {
- mockSQLStore.ExpectedError = models.ErrDashboardNotFound
- loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/id/1/permissions",
- "/api/dashboards/id/:dashboardId/permissions", models.ROLE_EDITOR, func(sc *scenarioContext) {
- callGetDashboardPermissions(sc, hs)
- assert.Equal(t, 404, sc.resp.Code)
- }, mockSQLStore)
-
- cmd := dtos.UpdateDashboardAclCommand{
- Items: []dtos.DashboardAclUpdateItem{
- {UserID: 1000, Permission: models.PERMISSION_ADMIN},
- },
- }
-
- updateDashboardPermissionScenario(t, updatePermissionContext{
- desc: "When calling POST on",
- url: "/api/dashboards/id/1/permissions",
- routePattern: "/api/dashboards/id/:dashboardId/permissions",
- cmd: cmd,
- fn: func(sc *scenarioContext) {
- callUpdateDashboardPermissions(t, sc)
- assert.Equal(t, 404, sc.resp.Code)
- },
- }, hs)
- })
-
t.Run("Given user has no admin permissions", func(t *testing.T) {
origNewGuardian := guardian.New
t.Cleanup(func() {
@@ -63,7 +42,6 @@ func TestDashboardPermissionAPIEndpoint(t *testing.T) {
guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanAdminValue: false})
getDashboardQueryResult := models.NewDashboard("Dash")
-
mockSQLStore := mockstore.NewSQLStoreMock()
mockSQLStore.ExpectedDashboard = getDashboardQueryResult
mockSQLStore.ExpectedError = nil
@@ -80,6 +58,7 @@ func TestDashboardPermissionAPIEndpoint(t *testing.T) {
},
}
+ dashboardStore.On("UpdateDashboardACL", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once()
updateDashboardPermissionScenario(t, updatePermissionContext{
desc: "When calling POST on",
url: "/api/dashboards/id/1/permissions",
@@ -324,26 +303,20 @@ func TestDashboardPermissionAPIEndpoint(t *testing.T) {
}
assert.Len(t, cmd.Items, 3)
+ var numOfItems []*models.DashboardAcl
+ dashboardStore.On("UpdateDashboardACL", mock.Anything, mock.Anything, mock.Anything).Run(func(args mock.Arguments) {
+ items := args.Get(2).([]*models.DashboardAcl)
+ numOfItems = items
+ }).Return(nil).Once()
updateDashboardPermissionScenario(t, updatePermissionContext{
desc: "When calling POST on",
url: "/api/dashboards/id/1/permissions",
routePattern: "/api/dashboards/id/:dashboardId/permissions",
cmd: cmd,
fn: func(sc *scenarioContext) {
- // TODO: Replace this fake with a fake SQLStore instead (once we can use an interface in its stead)
- origUpdateDashboardACL := updateDashboardACL
- t.Cleanup(func() {
- updateDashboardACL = origUpdateDashboardACL
- })
- var gotItems []*models.DashboardAcl
- updateDashboardACL = func(_ context.Context, _ dashboards.Store, folderID int64, items []*models.DashboardAcl) error {
- gotItems = items
- return nil
- }
-
sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
assert.Equal(t, 200, sc.resp.Code)
- assert.Len(t, gotItems, 4)
+ assert.Len(t, numOfItems, 4)
},
}, hs)
})
@@ -357,15 +330,6 @@ func callGetDashboardPermissions(sc *scenarioContext, hs *HTTPServer) {
func callUpdateDashboardPermissions(t *testing.T, sc *scenarioContext) {
t.Helper()
-
- origUpdateDashboardACL := updateDashboardACL
- t.Cleanup(func() {
- updateDashboardACL = origUpdateDashboardACL
- })
- updateDashboardACL = func(_ context.Context, _ dashboards.Store, dashID int64, items []*models.DashboardAcl) error {
- return nil
- }
-
sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
}
diff --git a/pkg/api/dashboard_snapshot.go b/pkg/api/dashboard_snapshot.go
index 5179dc0094f..1d5c45cc5f7 100644
--- a/pkg/api/dashboard_snapshot.go
+++ b/pkg/api/dashboard_snapshot.go
@@ -133,7 +133,7 @@ func (hs *HTTPServer) CreateDashboardSnapshot(c *models.ReqContext) response.Res
metrics.MApiDashboardSnapshotCreate.Inc()
}
- if err := hs.SQLStore.CreateDashboardSnapshot(c.Req.Context(), &cmd); err != nil {
+ if err := hs.DashboardsnapshotsService.CreateDashboardSnapshot(c.Req.Context(), &cmd); err != nil {
c.JsonApiErr(500, "Failed to create snapshot", err)
return nil
}
@@ -157,7 +157,7 @@ func (hs *HTTPServer) GetDashboardSnapshot(c *models.ReqContext) response.Respon
query := &models.GetDashboardSnapshotQuery{Key: key}
- err := hs.SQLStore.GetDashboardSnapshot(query)
+ err := hs.DashboardsnapshotsService.GetDashboardSnapshot(c.Req.Context(), query)
if err != nil {
return response.Error(500, "Failed to get dashboard snapshot", err)
}
@@ -224,8 +224,7 @@ func (hs *HTTPServer) DeleteDashboardSnapshotByDeleteKey(c *models.ReqContext) r
}
query := &models.GetDashboardSnapshotQuery{DeleteKey: key}
-
- err := hs.SQLStore.GetDashboardSnapshot(query)
+ err := hs.DashboardsnapshotsService.GetDashboardSnapshot(c.Req.Context(), query)
if err != nil {
return response.Error(500, "Failed to get dashboard snapshot", err)
}
@@ -239,7 +238,7 @@ func (hs *HTTPServer) DeleteDashboardSnapshotByDeleteKey(c *models.ReqContext) r
cmd := &models.DeleteDashboardSnapshotCommand{DeleteKey: query.Result.DeleteKey}
- if err := hs.SQLStore.DeleteDashboardSnapshot(c.Req.Context(), cmd); err != nil {
+ if err := hs.DashboardsnapshotsService.DeleteDashboardSnapshot(c.Req.Context(), cmd); err != nil {
return response.Error(500, "Failed to delete dashboard snapshot", err)
}
@@ -258,7 +257,7 @@ func (hs *HTTPServer) DeleteDashboardSnapshot(c *models.ReqContext) response.Res
query := &models.GetDashboardSnapshotQuery{Key: key}
- err := hs.SQLStore.GetDashboardSnapshot(query)
+ err := hs.DashboardsnapshotsService.GetDashboardSnapshot(c.Req.Context(), query)
if err != nil {
return response.Error(500, "Failed to get dashboard snapshot", err)
}
@@ -287,7 +286,7 @@ func (hs *HTTPServer) DeleteDashboardSnapshot(c *models.ReqContext) response.Res
cmd := &models.DeleteDashboardSnapshotCommand{DeleteKey: query.Result.DeleteKey}
- if err := hs.SQLStore.DeleteDashboardSnapshot(c.Req.Context(), cmd); err != nil {
+ if err := hs.DashboardsnapshotsService.DeleteDashboardSnapshot(c.Req.Context(), cmd); err != nil {
return response.Error(500, "Failed to delete dashboard snapshot", err)
}
@@ -313,7 +312,7 @@ func (hs *HTTPServer) SearchDashboardSnapshots(c *models.ReqContext) response.Re
SignedInUser: c.SignedInUser,
}
- err := hs.SQLStore.SearchDashboardSnapshots(&searchQuery)
+ err := hs.DashboardsnapshotsService.SearchDashboardSnapshots(c.Req.Context(), &searchQuery)
if err != nil {
return response.Error(500, "Search failed", err)
}
diff --git a/pkg/api/dashboard_snapshot_test.go b/pkg/api/dashboard_snapshot_test.go
index 5bf2ae90c4b..434714ba131 100644
--- a/pkg/api/dashboard_snapshot_test.go
+++ b/pkg/api/dashboard_snapshot_test.go
@@ -12,6 +12,7 @@ import (
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/models"
+ "github.com/grafana/grafana/pkg/services/dashboardsnapshots"
"github.com/grafana/grafana/pkg/services/sqlstore/mockstore"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -33,7 +34,7 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) {
editorRole := models.ROLE_EDITOR
sqlmock := mockstore.NewSQLStoreMock()
aclMockResp := []*models.DashboardAclInfoDTO{}
- hs := &HTTPServer{SQLStore: sqlmock}
+ hs := &HTTPServer{DashboardsnapshotsService: &dashboardsnapshots.Service{SQLStore: sqlmock}}
setUpSnapshotTest := func(t *testing.T) *models.DashboardSnapshot {
t.Helper()
diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go
index 3c4f21ec618..01f87d54b0a 100644
--- a/pkg/api/dashboard_test.go
+++ b/pkg/api/dashboard_test.go
@@ -7,7 +7,6 @@ import (
"fmt"
"io/ioutil"
"net/http"
- "path/filepath"
"testing"
"github.com/grafana/grafana/pkg/api/dtos"
@@ -15,11 +14,12 @@ import (
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson"
- dboards "github.com/grafana/grafana/pkg/dashboards"
"github.com/grafana/grafana/pkg/infra/usagestats"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/alerting"
"github.com/grafana/grafana/pkg/services/dashboards"
+ "github.com/grafana/grafana/pkg/services/dashboards/database"
+ service "github.com/grafana/grafana/pkg/services/dashboards/manager"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/libraryelements"
"github.com/grafana/grafana/pkg/services/live"
@@ -30,6 +30,7 @@ import (
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/web"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
@@ -145,7 +146,7 @@ func TestDashboardAPIEndpoint(t *testing.T) {
"/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
setUp()
sc.sqlStore = mockSQLStore
- dash := getDashboardShouldReturn200(sc)
+ dash := getDashboardShouldReturn200(t, sc)
assert.False(t, dash.Meta.CanEdit)
assert.False(t, dash.Meta.CanSave)
@@ -177,7 +178,7 @@ func TestDashboardAPIEndpoint(t *testing.T) {
"/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
setUp()
sc.sqlStore = mockSQLStore
- dash := getDashboardShouldReturn200(sc)
+ dash := getDashboardShouldReturn200(t, sc)
assert.True(t, dash.Meta.CanEdit)
assert.True(t, dash.Meta.CanSave)
@@ -212,11 +213,13 @@ func TestDashboardAPIEndpoint(t *testing.T) {
mockSQLStore := mockstore.NewSQLStoreMock()
mockSQLStore.ExpectedDashboard = fakeDash
+ dashboardStore := database.ProvideDashboardStore(sqlstore.InitTestDB(t))
hs := &HTTPServer{
Cfg: setting.NewCfg(),
Live: newTestLive(t),
LibraryPanelService: &mockLibraryPanelService{},
LibraryElementService: &mockLibraryElementService{},
+ dashboardService: service.ProvideDashboardService(dashboardStore),
SQLStore: mockSQLStore,
}
hs.SQLStore = mockSQLStore
@@ -345,7 +348,7 @@ func TestDashboardAPIEndpoint(t *testing.T) {
"/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
setUpInner()
sc.sqlStore = mockSQLStore
- dash := getDashboardShouldReturn200(sc)
+ dash := getDashboardShouldReturn200(t, sc)
assert.True(t, dash.Meta.CanEdit)
assert.True(t, dash.Meta.CanSave)
@@ -412,7 +415,7 @@ func TestDashboardAPIEndpoint(t *testing.T) {
require.True(t, setting.ViewersCanEdit)
sc.sqlStore = mockSQLStore
- dash := getDashboardShouldReturn200(sc)
+ dash := getDashboardShouldReturn200(t, sc)
assert.True(t, dash.Meta.CanEdit)
assert.False(t, dash.Meta.CanSave)
@@ -445,7 +448,7 @@ func TestDashboardAPIEndpoint(t *testing.T) {
loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
setUpInner()
sc.sqlStore = mockSQLStore
- dash := getDashboardShouldReturn200(sc)
+ dash := getDashboardShouldReturn200(t, sc)
assert.True(t, dash.Meta.CanEdit)
assert.True(t, dash.Meta.CanSave)
@@ -493,7 +496,7 @@ func TestDashboardAPIEndpoint(t *testing.T) {
loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
setUpInner()
sc.sqlStore = mockSQLStore
- dash := getDashboardShouldReturn200(sc)
+ dash := getDashboardShouldReturn200(t, sc)
assert.False(t, dash.Meta.CanEdit)
assert.False(t, dash.Meta.CanSave)
@@ -535,6 +538,8 @@ func TestDashboardAPIEndpoint(t *testing.T) {
})
t.Run("Post dashboard response tests", func(t *testing.T) {
+ dashboardStore := &database.FakeDashboardStore{}
+ defer dashboardStore.AssertExpectations(t)
// This tests that a valid request returns correct response
t.Run("Given a correct request for creating a dashboard", func(t *testing.T) {
const folderID int64 = 3
@@ -562,7 +567,7 @@ func TestDashboardAPIEndpoint(t *testing.T) {
},
}
- postDashboardScenario(t, "When calling POST on", "/api/dashboards", "/api/dashboards", mock, nil, cmd, func(sc *scenarioContext) {
+ postDashboardScenario(t, "When calling POST on", "/api/dashboards", "/api/dashboards", cmd, mock, nil, func(sc *scenarioContext) {
callPostDashboardShouldReturnSuccess(sc)
dto := mock.SavedDashboards[0]
@@ -612,7 +617,7 @@ func TestDashboardAPIEndpoint(t *testing.T) {
GetFolderByUIDResult: &models.Folder{Id: 1, Uid: "folderUID", Title: "Folder"},
}
- postDashboardScenario(t, "When calling POST on", "/api/dashboards", "/api/dashboards", mock, mockFolder, cmd, func(sc *scenarioContext) {
+ postDashboardScenario(t, "When calling POST on", "/api/dashboards", "/api/dashboards", cmd, mock, mockFolder, func(sc *scenarioContext) {
callPostDashboardShouldReturnSuccess(sc)
dto := mock.SavedDashboards[0]
@@ -661,7 +666,7 @@ func TestDashboardAPIEndpoint(t *testing.T) {
GetFolderByUIDError: errors.New("Error while searching Folder ID"),
}
- postDashboardScenario(t, "When calling POST on", "/api/dashboards", "/api/dashboards", mock, mockFolder, cmd, func(sc *scenarioContext) {
+ postDashboardScenario(t, "When calling POST on", "/api/dashboards", "/api/dashboards", cmd, mock, mockFolder, func(sc *scenarioContext) {
callPostDashboard(sc)
assert.Equal(t, 500, sc.resp.Code)
})
@@ -706,7 +711,7 @@ func TestDashboardAPIEndpoint(t *testing.T) {
}
postDashboardScenario(t, fmt.Sprintf("Expect '%s' error when calling POST on", tc.SaveError.Error()),
- "/api/dashboards", "/api/dashboards", mock, nil, cmd, func(sc *scenarioContext) {
+ "/api/dashboards", "/api/dashboards", cmd, mock, nil, func(sc *scenarioContext) {
callPostDashboard(sc)
assert.Equal(t, tc.ExpectedStatusCode, sc.resp.Code)
})
@@ -852,14 +857,6 @@ func TestDashboardAPIEndpoint(t *testing.T) {
t.Run("Given provisioned dashboard", func(t *testing.T) {
setUp := func() {
- origGetProvisionedData := dashboards.GetProvisionedData
- t.Cleanup(func() {
- dashboards.GetProvisionedData = origGetProvisionedData
- })
- dashboards.GetProvisionedData = func(dboards.Store, int64) (*models.DashboardProvisioning, error) {
- return &models.DashboardProvisioning{ExternalId: "/tmp/grafana/dashboards/test/dashboard1.json"}, nil
- }
-
bus.AddHandler("test", func(ctx context.Context, query *models.GetDashboardAclInfoListQuery) error {
query.Result = []*models.DashboardAclInfoDTO{
{OrgId: testOrgID, DashboardId: 1, UserId: testUserID, Permission: models.PERMISSION_EDIT},
@@ -867,6 +864,7 @@ func TestDashboardAPIEndpoint(t *testing.T) {
return nil
})
}
+
mockSQLStore := mockstore.NewSQLStoreMock()
dataValue, err := simplejson.NewJson([]byte(`{"id": 1, "editable": true, "style": "dark"}`))
require.NoError(t, err)
@@ -874,37 +872,39 @@ func TestDashboardAPIEndpoint(t *testing.T) {
loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/uid/dash", "/api/dashboards/uid/:uid", models.ROLE_EDITOR, func(sc *scenarioContext) {
setUp()
- dataValue, err := simplejson.NewJson([]byte(`{"id": 1, "editable": true, "style": "dark"}`))
- require.NoError(t, err)
- mockSQLStore.ExpectedDashboard = &models.Dashboard{Id: 1, Data: dataValue}
- sc.sqlStore = mockSQLStore
- mock := provisioning.NewProvisioningServiceMock(context.Background())
- mock.GetDashboardProvisionerResolvedPathFunc = func(name string) string {
+ fakeProvisioningService := provisioning.NewProvisioningServiceMock(context.Background())
+ fakeProvisioningService.GetDashboardProvisionerResolvedPathFunc = func(name string) string {
return "/tmp/grafana/dashboards"
}
- dash := getDashboardShouldReturn200WithConfig(sc, mock)
+ dashboardStore := &database.FakeDashboardStore{}
+ defer dashboardStore.AssertExpectations(t)
- assert.Equal(t, filepath.Join("test", "dashboard1.json"), dash.Meta.ProvisionedExternalId)
+ dashboardStore.On("GetProvisionedDataByDashboardID", mock.Anything).Return(&models.DashboardProvisioning{ExternalId: "/dashboard1.json"}, nil).Once()
+
+ dash := getDashboardShouldReturn200WithConfig(t, sc, fakeProvisioningService, dashboardStore)
+
+ assert.Equal(t, "../../../dashboard1.json", dash.Meta.ProvisionedExternalId, mockSQLStore)
}, mockSQLStore)
loggedInUserScenarioWithRole(t, "When allowUiUpdates is true and calling GET on", "GET", "/api/dashboards/uid/dash", "/api/dashboards/uid/:uid", models.ROLE_EDITOR, func(sc *scenarioContext) {
setUp()
-
- mock := provisioning.NewProvisioningServiceMock(context.Background())
- mock.GetDashboardProvisionerResolvedPathFunc = func(name string) string {
+ fakeProvisioningService := provisioning.NewProvisioningServiceMock(context.Background())
+ fakeProvisioningService.GetDashboardProvisionerResolvedPathFunc = func(name string) string {
return "/tmp/grafana/dashboards"
}
- mock.GetAllowUIUpdatesFromConfigFunc = func(name string) bool {
+
+ fakeProvisioningService.GetAllowUIUpdatesFromConfigFunc = func(name string) bool {
return true
}
hs := &HTTPServer{
- Cfg: setting.NewCfg(),
- ProvisioningService: mock,
- LibraryPanelService: &mockLibraryPanelService{},
- LibraryElementService: &mockLibraryElementService{},
- SQLStore: mockSQLStore,
+ Cfg: setting.NewCfg(),
+ ProvisioningService: fakeProvisioningService,
+ LibraryPanelService: &mockLibraryPanelService{},
+ LibraryElementService: &mockLibraryElementService{},
+ dashboardProvisioningService: mockDashboardProvisioningService{},
+ SQLStore: mockSQLStore,
}
hs.callGetDashboard(sc)
@@ -919,21 +919,28 @@ func TestDashboardAPIEndpoint(t *testing.T) {
})
}
-func getDashboardShouldReturn200WithConfig(sc *scenarioContext, provisioningService provisioning.ProvisioningService) dtos.
- DashboardFullWithMeta {
+func getDashboardShouldReturn200WithConfig(t *testing.T, sc *scenarioContext, provisioningService provisioning.ProvisioningService, dashboardStore dashboards.Store) dtos.DashboardFullWithMeta {
+ t.Helper()
+
if provisioningService == nil {
provisioningService = provisioning.NewProvisioningServiceMock(context.Background())
}
+ if dashboardStore == nil {
+ sql := sqlstore.InitTestDB(t)
+ dashboardStore = database.ProvideDashboardStore(sql)
+ }
+
libraryPanelsService := mockLibraryPanelService{}
libraryElementsService := mockLibraryElementService{}
hs := &HTTPServer{
- Cfg: setting.NewCfg(),
- LibraryPanelService: &libraryPanelsService,
- LibraryElementService: &libraryElementsService,
- ProvisioningService: provisioningService,
- SQLStore: sc.sqlStore,
+ Cfg: setting.NewCfg(),
+ LibraryPanelService: &libraryPanelsService,
+ LibraryElementService: &libraryElementsService,
+ ProvisioningService: provisioningService,
+ dashboardProvisioningService: service.ProvideDashboardService(dashboardStore),
+ SQLStore: sc.sqlStore,
}
hs.callGetDashboard(sc)
@@ -947,8 +954,8 @@ func getDashboardShouldReturn200WithConfig(sc *scenarioContext, provisioningServ
return dash
}
-func getDashboardShouldReturn200(sc *scenarioContext) dtos.DashboardFullWithMeta {
- return getDashboardShouldReturn200WithConfig(sc, nil)
+func getDashboardShouldReturn200(t *testing.T, sc *scenarioContext) dtos.DashboardFullWithMeta {
+ return getDashboardShouldReturn200WithConfig(t, sc, nil, nil)
}
func (hs *HTTPServer) callGetDashboard(sc *scenarioContext) {
@@ -976,17 +983,13 @@ func (hs *HTTPServer) callGetDashboardVersions(sc *scenarioContext) {
sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
}
-func (hs *HTTPServer) callDeleteDashboardByUID(t *testing.T, sc *scenarioContext, mockDashboard *dashboards.FakeDashboardService) {
+func (hs *HTTPServer) callDeleteDashboardByUID(t *testing.T,
+ sc *scenarioContext, mockDashboard *dashboards.FakeDashboardService) {
bus.AddHandler("test", func(ctx context.Context, cmd *models.DeleteDashboardCommand) error {
return nil
})
- origNewDashboardService := dashboards.NewService
- t.Cleanup(func() {
- dashboards.NewService = origNewDashboardService
- })
- dashboards.MockDashboardService(mockDashboard)
-
+ hs.dashboardService = mockDashboard
sc.handlerFunc = hs.DeleteDashboardByUID
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
}
@@ -1005,9 +1008,7 @@ func callPostDashboardShouldReturnSuccess(sc *scenarioContext) {
assert.Equal(sc.t, 200, sc.resp.Code)
}
-func postDashboardScenario(t *testing.T, desc string, url string, routePattern string,
- mock *dashboards.FakeDashboardService, mockFolder *fakeFolderService, cmd models.SaveDashboardCommand,
- fn scenarioFunc) {
+func postDashboardScenario(t *testing.T, desc string, url string, routePattern string, cmd models.SaveDashboardCommand, dashboardService dashboards.DashboardService, folderService dashboards.FolderService, fn scenarioFunc) {
t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) {
t.Cleanup(bus.ClearBusHandlers)
@@ -1023,6 +1024,8 @@ func postDashboardScenario(t *testing.T, desc string, url string, routePattern s
pluginStore: &fakePluginStore{},
LibraryPanelService: &mockLibraryPanelService{},
LibraryElementService: &mockLibraryElementService{},
+ dashboardService: dashboardService,
+ folderService: folderService,
}
sc := setupScenarioContext(t, url)
@@ -1035,20 +1038,6 @@ func postDashboardScenario(t *testing.T, desc string, url string, routePattern s
return hs.PostDashboard(c)
})
- origNewDashboardService := dashboards.NewService
- origProvisioningService := dashboards.NewProvisioningService
- origNewFolderService := dashboards.NewFolderService
- t.Cleanup(func() {
- dashboards.NewService = origNewDashboardService
- dashboards.NewProvisioningService = origProvisioningService
- dashboards.NewFolderService = origNewFolderService
- })
- dashboards.MockDashboardService(mock)
- dashboards.NewProvisioningService = func(dboards.Store) dashboards.DashboardProvisioningService {
- return mockDashboardProvisioningService{}
- }
- mockFolderService(mockFolder)
-
sc.m.Post(routePattern, sc.defaultHandler)
fn(sc)
@@ -1091,9 +1080,7 @@ func postDiffScenario(t *testing.T, desc string, url string, routePattern string
})
}
-func restoreDashboardVersionScenario(t *testing.T, desc string, url string, routePattern string,
- mock *dashboards.FakeDashboardService, cmd dtos.RestoreDashboardVersionCommand, fn scenarioFunc,
- sqlStore sqlstore.Store) {
+func restoreDashboardVersionScenario(t *testing.T, desc string, url string, routePattern string, mock *dashboards.FakeDashboardService, cmd dtos.RestoreDashboardVersionCommand, fn scenarioFunc, sqlStore sqlstore.Store) {
t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) {
defer bus.ClearBusHandlers()
@@ -1107,6 +1094,7 @@ func restoreDashboardVersionScenario(t *testing.T, desc string, url string, rout
QuotaService: "a.QuotaService{Cfg: cfg},
LibraryPanelService: &mockLibraryPanelService{},
LibraryElementService: &mockLibraryElementService{},
+ dashboardService: mock,
SQLStore: sqlStore,
}
@@ -1125,17 +1113,6 @@ func restoreDashboardVersionScenario(t *testing.T, desc string, url string, rout
return hs.RestoreDashboardVersion(c)
})
- origProvisioningService := dashboards.NewProvisioningService
- origNewDashboardService := dashboards.NewService
- t.Cleanup(func() {
- dashboards.NewService = origNewDashboardService
- dashboards.NewProvisioningService = origProvisioningService
- })
- dashboards.NewProvisioningService = func(dboards.Store) dashboards.DashboardProvisioningService {
- return mockDashboardProvisioningService{}
- }
- dashboards.MockDashboardService(mock)
-
sc.m.Post(routePattern, sc.defaultHandler)
fn(sc)
diff --git a/pkg/api/datasources.go b/pkg/api/datasources.go
index 138635024c6..9af7d1c247b 100644
--- a/pkg/api/datasources.go
+++ b/pkg/api/datasources.go
@@ -17,7 +17,6 @@ import (
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins/adapters"
- "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/util"
"github.com/grafana/grafana/pkg/web"
)
@@ -71,23 +70,6 @@ func (hs *HTTPServer) GetDataSources(c *models.ReqContext) response.Response {
return response.JSON(200, &result)
}
-func (hs *HTTPServer) getDataSourceAccessControlMetadata(c *models.ReqContext, dsID int64) (accesscontrol.Metadata, error) {
- if hs.AccessControl.IsDisabled() || !c.QueryBool("accesscontrol") {
- return nil, nil
- }
-
- userPermissions, err := hs.AccessControl.GetUserPermissions(c.Req.Context(), c.SignedInUser,
- accesscontrol.Options{ReloadCache: false})
- if err != nil || len(userPermissions) == 0 {
- return nil, err
- }
-
- key := fmt.Sprintf("%d", dsID)
- dsIDs := map[string]bool{key: true}
-
- return accesscontrol.GetResourcesMetadata(c.Req.Context(), userPermissions, "datasources", dsIDs)[key], nil
-}
-
// GET /api/datasources/:id
func (hs *HTTPServer) GetDataSourceById(c *models.ReqContext) response.Response {
id, err := strconv.ParseInt(web.Params(c.Req)[":id"], 10, 64)
@@ -115,12 +97,9 @@ func (hs *HTTPServer) GetDataSourceById(c *models.ReqContext) response.Response
}
dto := convertModelToDtos(filtered[0])
+
// Add accesscontrol metadata
- metadata, err := hs.getDataSourceAccessControlMetadata(c, dto.Id)
- if err != nil {
- return response.Error(http.StatusInternalServerError, "Failed to query metadata", err)
- }
- dto.AccessControl = metadata
+ dto.AccessControl = hs.getAccessControlMetadata(c, "datasources", dto.Id)
return response.JSON(200, &dto)
}
@@ -179,11 +158,7 @@ func (hs *HTTPServer) GetDataSourceByUID(c *models.ReqContext) response.Response
dto := convertModelToDtos(filtered[0])
// Add accesscontrol metadata
- metadata, err := hs.getDataSourceAccessControlMetadata(c, dto.Id)
- if err != nil {
- return response.Error(http.StatusInternalServerError, "Failed to query metadata", err)
- }
- dto.AccessControl = metadata
+ dto.AccessControl = hs.getAccessControlMetadata(c, "datasources", dto.Id)
return response.JSON(200, &dto)
}
diff --git a/pkg/api/dtos/index.go b/pkg/api/dtos/index.go
index 5afb1e62064..64e2d5b531f 100644
--- a/pkg/api/dtos/index.go
+++ b/pkg/api/dtos/index.go
@@ -71,6 +71,7 @@ type NavLink struct {
HideFromTabs bool `json:"hideFromTabs,omitempty"`
Children []*NavLink `json:"children,omitempty"`
HighlightText string `json:"highlightText,omitempty"`
+ HighlightID string `json:"highlightId,omitempty"`
}
// NavIDCfg is the id for org configuration navigation node
diff --git a/pkg/api/folder.go b/pkg/api/folder.go
index 3fcf762ef89..1c08a0403da 100644
--- a/pkg/api/folder.go
+++ b/pkg/api/folder.go
@@ -11,7 +11,6 @@ import (
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/models"
- "github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/guardian"
"github.com/grafana/grafana/pkg/services/libraryelements"
"github.com/grafana/grafana/pkg/util"
@@ -19,8 +18,7 @@ import (
)
func (hs *HTTPServer) GetFolders(c *models.ReqContext) response.Response {
- s := dashboards.NewFolderService(c.OrgId, c.SignedInUser, hs.SQLStore)
- folders, err := s.GetFolders(c.Req.Context(), c.QueryInt64("limit"), c.QueryInt64("page"))
+ folders, err := hs.folderService.GetFolders(c.Req.Context(), c.SignedInUser, c.OrgId, c.QueryInt64("limit"), c.QueryInt64("page"))
if err != nil {
return apierrors.ToFolderErrorResponse(err)
@@ -40,8 +38,7 @@ func (hs *HTTPServer) GetFolders(c *models.ReqContext) response.Response {
}
func (hs *HTTPServer) GetFolderByUID(c *models.ReqContext) response.Response {
- s := dashboards.NewFolderService(c.OrgId, c.SignedInUser, hs.SQLStore)
- folder, err := s.GetFolderByUID(c.Req.Context(), web.Params(c.Req)[":uid"])
+ folder, err := hs.folderService.GetFolderByUID(c.Req.Context(), c.SignedInUser, c.OrgId, web.Params(c.Req)[":uid"])
if err != nil {
return apierrors.ToFolderErrorResponse(err)
}
@@ -51,14 +48,11 @@ func (hs *HTTPServer) GetFolderByUID(c *models.ReqContext) response.Response {
}
func (hs *HTTPServer) GetFolderByID(c *models.ReqContext) response.Response {
- s := dashboards.NewFolderService(c.OrgId, c.SignedInUser, hs.SQLStore)
-
id, err := strconv.ParseInt(web.Params(c.Req)[":id"], 10, 64)
if err != nil {
return response.Error(http.StatusBadRequest, "id is invalid", err)
}
-
- folder, err := s.GetFolderByID(c.Req.Context(), id)
+ folder, err := hs.folderService.GetFolderByID(c.Req.Context(), c.SignedInUser, c.OrgId, id)
if err != nil {
return apierrors.ToFolderErrorResponse(err)
}
@@ -72,14 +66,13 @@ func (hs *HTTPServer) CreateFolder(c *models.ReqContext) response.Response {
if err := web.Bind(c.Req, &cmd); err != nil {
return response.Error(http.StatusBadRequest, "bad request data", err)
}
- s := dashboards.NewFolderService(c.OrgId, c.SignedInUser, hs.SQLStore)
- folder, err := s.CreateFolder(c.Req.Context(), cmd.Title, cmd.Uid)
+ folder, err := hs.folderService.CreateFolder(c.Req.Context(), c.SignedInUser, c.OrgId, cmd.Title, cmd.Uid)
if err != nil {
return apierrors.ToFolderErrorResponse(err)
}
if hs.Cfg.EditorsCanAdmin {
- if err := s.MakeUserAdmin(c.Req.Context(), c.OrgId, c.SignedInUser.UserId, folder.Id, true); err != nil {
+ if err := hs.folderService.MakeUserAdmin(c.Req.Context(), c.OrgId, c.SignedInUser.UserId, folder.Id, true); err != nil {
hs.log.Error("Could not make user admin", "folder", folder.Title, "user",
c.SignedInUser.UserId, "error", err)
}
@@ -94,8 +87,7 @@ func (hs *HTTPServer) UpdateFolder(c *models.ReqContext) response.Response {
if err := web.Bind(c.Req, &cmd); err != nil {
return response.Error(http.StatusBadRequest, "bad request data", err)
}
- s := dashboards.NewFolderService(c.OrgId, c.SignedInUser, hs.SQLStore)
- err := s.UpdateFolder(c.Req.Context(), web.Params(c.Req)[":uid"], &cmd)
+ err := hs.folderService.UpdateFolder(c.Req.Context(), c.SignedInUser, c.OrgId, web.Params(c.Req)[":uid"], &cmd)
if err != nil {
return apierrors.ToFolderErrorResponse(err)
}
@@ -105,7 +97,6 @@ func (hs *HTTPServer) UpdateFolder(c *models.ReqContext) response.Response {
}
func (hs *HTTPServer) DeleteFolder(c *models.ReqContext) response.Response { // temporarily adding this function to HTTPServer, will be removed from HTTPServer when librarypanels featuretoggle is removed
- s := dashboards.NewFolderService(c.OrgId, c.SignedInUser, hs.SQLStore)
err := hs.LibraryElementService.DeleteLibraryElementsInFolder(c.Req.Context(), c.SignedInUser, web.Params(c.Req)[":uid"])
if err != nil {
if errors.Is(err, libraryelements.ErrFolderHasConnectedLibraryElements) {
@@ -114,7 +105,7 @@ func (hs *HTTPServer) DeleteFolder(c *models.ReqContext) response.Response { //
return apierrors.ToFolderErrorResponse(err)
}
- f, err := s.DeleteFolder(c.Req.Context(), web.Params(c.Req)[":uid"], c.QueryBool("forceDeleteRules"))
+ f, err := hs.folderService.DeleteFolder(c.Req.Context(), c.SignedInUser, c.OrgId, web.Params(c.Req)[":uid"], c.QueryBool("forceDeleteRules"))
if err != nil {
return apierrors.ToFolderErrorResponse(err)
}
diff --git a/pkg/api/folder_permission.go b/pkg/api/folder_permission.go
index 3ffd756b3f0..ca03d2497e8 100644
--- a/pkg/api/folder_permission.go
+++ b/pkg/api/folder_permission.go
@@ -9,15 +9,13 @@ import (
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/models"
- "github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/guardian"
"github.com/grafana/grafana/pkg/util"
"github.com/grafana/grafana/pkg/web"
)
func (hs *HTTPServer) GetFolderPermissionList(c *models.ReqContext) response.Response {
- s := dashboards.NewFolderService(c.OrgId, c.SignedInUser, hs.SQLStore)
- folder, err := s.GetFolderByUID(c.Req.Context(), web.Params(c.Req)[":uid"])
+ folder, err := hs.folderService.GetFolderByUID(c.Req.Context(), c.SignedInUser, c.OrgId, web.Params(c.Req)[":uid"])
if err != nil {
return apierrors.ToFolderErrorResponse(err)
@@ -68,8 +66,7 @@ func (hs *HTTPServer) UpdateFolderPermissions(c *models.ReqContext) response.Res
return response.Error(400, err.Error(), err)
}
- s := dashboards.NewFolderService(c.OrgId, c.SignedInUser, hs.SQLStore)
- folder, err := s.GetFolderByUID(c.Req.Context(), web.Params(c.Req)[":uid"])
+ folder, err := hs.folderService.GetFolderByUID(c.Req.Context(), c.SignedInUser, c.OrgId, web.Params(c.Req)[":uid"])
if err != nil {
return apierrors.ToFolderErrorResponse(err)
}
@@ -117,7 +114,7 @@ func (hs *HTTPServer) UpdateFolderPermissions(c *models.ReqContext) response.Res
return response.Error(403, "Cannot remove own admin permission for a folder", nil)
}
- if err := updateDashboardACL(c.Req.Context(), hs.SQLStore, folder.Id, items); err != nil {
+ if err := hs.dashboardService.UpdateDashboardACL(c.Req.Context(), folder.Id, items); err != nil {
if errors.Is(err, models.ErrDashboardAclInfoMissing) {
err = models.ErrFolderAclInfoMissing
}
diff --git a/pkg/api/folder_permission_test.go b/pkg/api/folder_permission_test.go
index 395e4a58f2a..46df1f74192 100644
--- a/pkg/api/folder_permission_test.go
+++ b/pkg/api/folder_permission_test.go
@@ -1,7 +1,6 @@
package api
import (
- "context"
"encoding/json"
"fmt"
"testing"
@@ -13,28 +12,28 @@ import (
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/bus"
- dashboardifaces "github.com/grafana/grafana/pkg/dashboards"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/dashboards"
+ "github.com/grafana/grafana/pkg/services/dashboards/database"
+ service "github.com/grafana/grafana/pkg/services/dashboards/manager"
"github.com/grafana/grafana/pkg/services/guardian"
"github.com/grafana/grafana/pkg/services/sqlstore/mockstore"
"github.com/grafana/grafana/pkg/setting"
+ "github.com/stretchr/testify/mock"
)
func TestFolderPermissionAPIEndpoint(t *testing.T) {
settings := setting.NewCfg()
- hs := &HTTPServer{Cfg: settings}
+ folderService := &dashboards.FakeFolderService{}
+ defer folderService.AssertExpectations(t)
+
+ dashboardStore := &database.FakeDashboardStore{}
+ defer dashboardStore.AssertExpectations(t)
+
+ hs := &HTTPServer{Cfg: settings, folderService: folderService, dashboardService: service.ProvideDashboardService(dashboardStore)}
t.Run("Given folder not exists", func(t *testing.T) {
- mock := &fakeFolderService{
- GetFolderByUIDError: models.ErrFolderNotFound,
- }
-
- origNewFolderService := dashboards.NewFolderService
- t.Cleanup(func() {
- dashboards.NewFolderService = origNewFolderService
- })
- mockFolderService(mock)
+ folderService.On("GetFolderByUID", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, models.ErrFolderNotFound).Twice()
mockSQLStore := mockstore.NewSQLStoreMock()
loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/folders/uid/permissions", "/api/folders/:uid/permissions", models.ROLE_EDITOR, func(sc *scenarioContext) {
callGetFolderPermissions(sc, hs)
@@ -61,24 +60,14 @@ func TestFolderPermissionAPIEndpoint(t *testing.T) {
t.Run("Given user has no admin permissions", func(t *testing.T) {
origNewGuardian := guardian.New
- origNewFolderService := dashboards.NewFolderService
t.Cleanup(func() {
guardian.New = origNewGuardian
- dashboards.NewFolderService = origNewFolderService
})
guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanAdminValue: false})
-
- mock := &fakeFolderService{
- GetFolderByUIDResult: &models.Folder{
- Id: 1,
- Uid: "uid",
- Title: "Folder",
- },
- }
-
- mockFolderService(mock)
+ folderService.On("GetFolderByUID", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, models.ErrFolderAccessDenied).Twice()
mockSQLStore := mockstore.NewSQLStoreMock()
+
loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/folders/uid/permissions", "/api/folders/:uid/permissions", models.ROLE_EDITOR, func(sc *scenarioContext) {
callGetFolderPermissions(sc, hs)
assert.Equal(t, 403, sc.resp.Code)
@@ -104,10 +93,8 @@ func TestFolderPermissionAPIEndpoint(t *testing.T) {
t.Run("Given user has admin permissions and permissions to update", func(t *testing.T) {
origNewGuardian := guardian.New
- origNewFolderService := dashboards.NewFolderService
t.Cleanup(func() {
guardian.New = origNewGuardian
- dashboards.NewFolderService = origNewFolderService
})
guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{
@@ -122,16 +109,11 @@ func TestFolderPermissionAPIEndpoint(t *testing.T) {
},
})
- mock := &fakeFolderService{
- GetFolderByUIDResult: &models.Folder{
- Id: 1,
- Uid: "uid",
- Title: "Folder",
- },
- }
-
- mockFolderService(mock)
+ folderResponse := &models.Folder{Id: 1, Uid: "uid", Title: "Folder"}
+ folderService.On("GetFolderByUID", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(folderResponse, nil).Twice()
+ dashboardStore.On("UpdateDashboardACL", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once()
mockSQLStore := mockstore.NewSQLStoreMock()
+
loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/folders/uid/permissions", "/api/folders/:uid/permissions", models.ROLE_ADMIN, func(sc *scenarioContext) {
callGetFolderPermissions(sc, hs)
assert.Equal(t, 200, sc.resp.Code)
@@ -175,10 +157,8 @@ func TestFolderPermissionAPIEndpoint(t *testing.T) {
t.Run("When trying to update permissions with duplicate permissions", func(t *testing.T) {
origNewGuardian := guardian.New
- origNewFolderService := dashboards.NewFolderService
t.Cleanup(func() {
guardian.New = origNewGuardian
- dashboards.NewFolderService = origNewFolderService
})
guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{
@@ -187,15 +167,8 @@ func TestFolderPermissionAPIEndpoint(t *testing.T) {
CheckPermissionBeforeUpdateError: guardian.ErrGuardianPermissionExists,
})
- mock := &fakeFolderService{
- GetFolderByUIDResult: &models.Folder{
- Id: 1,
- Uid: "uid",
- Title: "Folder",
- },
- }
-
- mockFolderService(mock)
+ folderResponse := &models.Folder{Id: 1, Uid: "uid", Title: "Folder"}
+ folderService.On("GetFolderByUID", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(folderResponse, nil).Once()
cmd := dtos.UpdateDashboardAclCommand{
Items: []dtos.DashboardAclUpdateItem{
@@ -249,10 +222,8 @@ func TestFolderPermissionAPIEndpoint(t *testing.T) {
t.Run("When trying to override inherited permissions with lower precedence", func(t *testing.T) {
origNewGuardian := guardian.New
- origNewFolderService := dashboards.NewFolderService
t.Cleanup(func() {
guardian.New = origNewGuardian
- dashboards.NewFolderService = origNewFolderService
})
guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{
@@ -261,15 +232,8 @@ func TestFolderPermissionAPIEndpoint(t *testing.T) {
CheckPermissionBeforeUpdateError: guardian.ErrGuardianOverride},
)
- mock := &fakeFolderService{
- GetFolderByUIDResult: &models.Folder{
- Id: 1,
- Uid: "uid",
- Title: "Folder",
- },
- }
-
- mockFolderService(mock)
+ folderResponse := &models.Folder{Id: 1, Uid: "uid", Title: "Folder"}
+ folderService.On("GetFolderByUID", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(folderResponse, nil).Once()
cmd := dtos.UpdateDashboardAclCommand{
Items: []dtos.DashboardAclUpdateItem{
@@ -291,14 +255,12 @@ func TestFolderPermissionAPIEndpoint(t *testing.T) {
t.Run("Getting and updating folder permissions with hidden users", func(t *testing.T) {
origNewGuardian := guardian.New
- origNewFolderService := dashboards.NewFolderService
settings.HiddenUsers = map[string]struct{}{
"hiddenUser": {},
testUserLogin: {},
}
t.Cleanup(func() {
guardian.New = origNewGuardian
- dashboards.NewFolderService = origNewFolderService
settings.HiddenUsers = make(map[string]struct{})
})
@@ -315,15 +277,13 @@ func TestFolderPermissionAPIEndpoint(t *testing.T) {
},
})
- mock := &fakeFolderService{
- GetFolderByUIDResult: &models.Folder{
- Id: 1,
- Uid: "uid",
- Title: "Folder",
- },
- }
+ var gotItems []*models.DashboardAcl
- mockFolderService(mock)
+ folderResponse := &models.Folder{Id: 1, Uid: "uid", Title: "Folder"}
+ folderService.On("GetFolderByUID", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(folderResponse, nil).Twice()
+ dashboardStore.On("UpdateDashboardACL", mock.Anything, mock.Anything, mock.Anything).Run(func(args mock.Arguments) {
+ gotItems = args.Get(2).([]*models.DashboardAcl)
+ }).Return(nil).Once()
var resp []*models.DashboardAclInfoDTO
mockSQLStore := mockstore.NewSQLStoreMock()
@@ -360,16 +320,6 @@ func TestFolderPermissionAPIEndpoint(t *testing.T) {
routePattern: "/api/folders/:uid/permissions",
cmd: cmd,
fn: func(sc *scenarioContext) {
- origUpdateDashboardACL := updateDashboardACL
- t.Cleanup(func() {
- updateDashboardACL = origUpdateDashboardACL
- })
- var gotItems []*models.DashboardAcl
- updateDashboardACL = func(_ context.Context, _ dashboardifaces.Store, _ int64, items []*models.DashboardAcl) error {
- gotItems = items
- return nil
- }
-
sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
assert.Equal(t, 200, sc.resp.Code)
assert.Len(t, gotItems, 4)
@@ -385,21 +335,12 @@ func callGetFolderPermissions(sc *scenarioContext, hs *HTTPServer) {
func callUpdateFolderPermissions(t *testing.T, sc *scenarioContext) {
t.Helper()
-
- origUpdateDashboardACL := updateDashboardACL
- t.Cleanup(func() {
- updateDashboardACL = origUpdateDashboardACL
- })
- updateDashboardACL = func(_ context.Context, _ dashboardifaces.Store, dashID int64, items []*models.DashboardAcl) error {
- return nil
- }
-
sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
}
func updateFolderPermissionScenario(t *testing.T, ctx updatePermissionContext, hs *HTTPServer) {
t.Run(fmt.Sprintf("%s %s", ctx.desc, ctx.url), func(t *testing.T) {
- defer bus.ClearBusHandlers()
+ t.Cleanup(bus.ClearBusHandlers)
sc := setupScenarioContext(t, ctx.url)
diff --git a/pkg/api/folder_test.go b/pkg/api/folder_test.go
index 08ae3853a3b..cf10dc46952 100644
--- a/pkg/api/folder_test.go
+++ b/pkg/api/folder_test.go
@@ -10,26 +10,28 @@ import (
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/bus"
- dboards "github.com/grafana/grafana/pkg/dashboards"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/setting"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
func TestFoldersAPIEndpoint(t *testing.T) {
+ folderService := &dashboards.FakeFolderService{}
+ defer folderService.AssertExpectations(t)
+
t.Run("Given a correct request for creating a folder", func(t *testing.T) {
cmd := models.CreateFolderCommand{
Uid: "uid",
Title: "Folder",
}
- mock := &fakeFolderService{
- CreateFolderResult: &models.Folder{Id: 1, Uid: "uid", Title: "Folder"},
- }
+ folderResult := &models.Folder{Id: 1, Uid: "uid", Title: "Folder"}
+ folderService.On("CreateFolder", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(folderResult, nil).Once()
- createFolderScenario(t, "When calling POST on", "/api/folders", "/api/folders", mock, cmd,
+ createFolderScenario(t, "When calling POST on", "/api/folders", "/api/folders", folderService, cmd,
func(sc *scenarioContext) {
callCreateFolder(sc)
@@ -64,12 +66,10 @@ func TestFoldersAPIEndpoint(t *testing.T) {
}
for _, tc := range testCases {
- mock := &fakeFolderService{
- CreateFolderError: tc.Error,
- }
+ folderService.On("CreateFolder", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, tc.Error).Once()
createFolderScenario(t, fmt.Sprintf("Expect '%s' error when calling POST on", tc.Error.Error()),
- "/api/folders", "/api/folders", mock, cmd, func(sc *scenarioContext) {
+ "/api/folders", "/api/folders", folderService, cmd, func(sc *scenarioContext) {
callCreateFolder(sc)
assert.Equalf(t, tc.ExpectedStatusCode, sc.resp.Code, "Wrong status code for error %s", tc.Error)
})
@@ -81,11 +81,12 @@ func TestFoldersAPIEndpoint(t *testing.T) {
Title: "Folder upd",
}
- mock := &fakeFolderService{
- UpdateFolderResult: &models.Folder{Id: 1, Uid: "uid", Title: "Folder upd"},
- }
+ folderService.On("UpdateFolder", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Run(func(args mock.Arguments) {
+ cmd := args.Get(4).(*models.UpdateFolderCommand)
+ cmd.Result = &models.Folder{Id: 1, Uid: "uid", Title: "Folder upd"}
+ }).Return(nil).Once()
- updateFolderScenario(t, "When calling PUT on", "/api/folders/uid", "/api/folders/:uid", mock, cmd,
+ updateFolderScenario(t, "When calling PUT on", "/api/folders/uid", "/api/folders/:uid", folderService, cmd,
func(sc *scenarioContext) {
callUpdateFolder(sc)
@@ -119,12 +120,9 @@ func TestFoldersAPIEndpoint(t *testing.T) {
}
for _, tc := range testCases {
- mock := &fakeFolderService{
- UpdateFolderError: tc.Error,
- }
-
+ folderService.On("UpdateFolder", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(tc.Error).Once()
updateFolderScenario(t, fmt.Sprintf("Expect '%s' error when calling PUT on", tc.Error.Error()),
- "/api/folders/uid", "/api/folders/:uid", mock, cmd, func(sc *scenarioContext) {
+ "/api/folders/uid", "/api/folders/:uid", folderService, cmd, func(sc *scenarioContext) {
callUpdateFolder(sc)
assert.Equalf(t, tc.ExpectedStatusCode, sc.resp.Code, "Wrong status code for %s", tc.Error)
})
@@ -136,14 +134,15 @@ func callCreateFolder(sc *scenarioContext) {
sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
}
-func createFolderScenario(t *testing.T, desc string, url string, routePattern string, mock *fakeFolderService,
+func createFolderScenario(t *testing.T, desc string, url string, routePattern string, folderService dashboards.FolderService,
cmd models.CreateFolderCommand, fn scenarioFunc) {
t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) {
t.Cleanup(bus.ClearBusHandlers)
hs := HTTPServer{
- Bus: bus.GetBus(),
- Cfg: setting.NewCfg(),
+ Bus: bus.GetBus(),
+ Cfg: setting.NewCfg(),
+ folderService: folderService,
}
sc := setupScenarioContext(t, url)
@@ -156,15 +155,8 @@ func createFolderScenario(t *testing.T, desc string, url string, routePattern st
return hs.CreateFolder(c)
})
- origNewFolderService := dashboards.NewFolderService
- mockFolderService(mock)
-
sc.m.Post(routePattern, sc.defaultHandler)
- defer func() {
- dashboards.NewFolderService = origNewFolderService
- }()
-
fn(sc)
})
}
@@ -173,13 +165,14 @@ func callUpdateFolder(sc *scenarioContext) {
sc.fakeReqWithParams("PUT", sc.url, map[string]string{}).exec()
}
-func updateFolderScenario(t *testing.T, desc string, url string, routePattern string, mock *fakeFolderService,
+func updateFolderScenario(t *testing.T, desc string, url string, routePattern string, folderService dashboards.FolderService,
cmd models.UpdateFolderCommand, fn scenarioFunc) {
t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) {
defer bus.ClearBusHandlers()
hs := HTTPServer{
- Cfg: setting.NewCfg(),
+ Cfg: setting.NewCfg(),
+ folderService: folderService,
}
sc := setupScenarioContext(t, url)
@@ -192,12 +185,6 @@ func updateFolderScenario(t *testing.T, desc string, url string, routePattern st
return hs.UpdateFolder(c)
})
- origNewFolderService := dashboards.NewFolderService
- t.Cleanup(func() {
- dashboards.NewFolderService = origNewFolderService
- })
- mockFolderService(mock)
-
sc.m.Put(routePattern, sc.defaultHandler)
fn(sc)
@@ -222,35 +209,28 @@ type fakeFolderService struct {
DeletedFolderUids []string
}
-func (s *fakeFolderService) GetFolders(ctx context.Context, limit int64, page int64) ([]*models.Folder, error) {
+func (s *fakeFolderService) GetFolders(ctx context.Context, user *models.SignedInUser, orgID int64, limit int64, page int64) ([]*models.Folder, error) {
return s.GetFoldersResult, s.GetFoldersError
}
-func (s *fakeFolderService) GetFolderByID(ctx context.Context, id int64) (*models.Folder, error) {
+func (s *fakeFolderService) GetFolderByID(ctx context.Context, user *models.SignedInUser, id int64, orgID int64) (*models.Folder, error) {
return s.GetFolderByIDResult, s.GetFolderByIDError
}
-func (s *fakeFolderService) GetFolderByUID(ctx context.Context, uid string) (*models.Folder, error) {
+func (s *fakeFolderService) GetFolderByUID(ctx context.Context, user *models.SignedInUser, orgID int64, uid string) (*models.Folder, error) {
return s.GetFolderByUIDResult, s.GetFolderByUIDError
}
-func (s *fakeFolderService) CreateFolder(ctx context.Context, title, uid string) (*models.Folder, error) {
+func (s *fakeFolderService) CreateFolder(ctx context.Context, user *models.SignedInUser, orgID int64, title, uid string) (*models.Folder, error) {
return s.CreateFolderResult, s.CreateFolderError
}
-func (s *fakeFolderService) UpdateFolder(ctx context.Context, existingUID string, cmd *models.UpdateFolderCommand) error {
+func (s *fakeFolderService) UpdateFolder(ctx context.Context, user *models.SignedInUser, orgID int64, existingUid string, cmd *models.UpdateFolderCommand) error {
cmd.Result = s.UpdateFolderResult
return s.UpdateFolderError
}
-func (s *fakeFolderService) DeleteFolder(ctx context.Context, uid string, forceDeleteRules bool) (*models.Folder, error) {
+func (s *fakeFolderService) DeleteFolder(ctx context.Context, user *models.SignedInUser, orgID int64, uid string, forceDeleteRules bool) (*models.Folder, error) {
s.DeletedFolderUids = append(s.DeletedFolderUids, uid)
return s.DeleteFolderResult, s.DeleteFolderError
}
-
-func mockFolderService(mock *fakeFolderService) {
- dashboards.NewFolderService = func(orgId int64, user *models.SignedInUser,
- dashboardStore dboards.Store) dashboards.FolderService {
- return mock
- }
-}
diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go
index f1ea4edaef1..5416cde6b24 100644
--- a/pkg/api/frontendsettings.go
+++ b/pkg/api/frontendsettings.go
@@ -229,6 +229,7 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *models.ReqContext) (map[string]i
"externalUserMngLinkUrl": setting.ExternalUserMngLinkUrl,
"externalUserMngLinkName": setting.ExternalUserMngLinkName,
"viewersCanEdit": setting.ViewersCanEdit,
+ "angularSupportEnabled": hs.Cfg.AngularSupportEnabled,
"editorsCanAdmin": hs.Cfg.EditorsCanAdmin,
"disableSanitizeHtml": hs.Cfg.DisableSanitizeHtml,
"pluginsToPreload": pluginsToPreload,
@@ -274,6 +275,10 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *models.ReqContext) (map[string]i
"unifiedAlertingEnabled": hs.Cfg.UnifiedAlerting.Enabled,
}
+ if hs.ThumbService != nil {
+ jsonObj["dashboardPreviews"] = hs.ThumbService.GetDashboardPreviewsSetupSettings(c)
+ }
+
if hs.Cfg.GeomapDefaultBaseLayerConfig != nil {
jsonObj["geomapDefaultBaseLayerConfig"] = hs.Cfg.GeomapDefaultBaseLayerConfig
}
diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go
index af58dbc2744..729bf865099 100644
--- a/pkg/api/http_server.go
+++ b/pkg/api/http_server.go
@@ -28,11 +28,11 @@ import (
"github.com/grafana/grafana/pkg/plugins/plugincontext"
"github.com/grafana/grafana/pkg/services/accesscontrol"
acmiddleware "github.com/grafana/grafana/pkg/services/accesscontrol/middleware"
- "github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions"
- "github.com/grafana/grafana/pkg/services/accesscontrol/resourceservices"
"github.com/grafana/grafana/pkg/services/alerting"
"github.com/grafana/grafana/pkg/services/cleanup"
"github.com/grafana/grafana/pkg/services/contexthandler"
+ "github.com/grafana/grafana/pkg/services/dashboards"
+ "github.com/grafana/grafana/pkg/services/dashboardsnapshots"
"github.com/grafana/grafana/pkg/services/datasourceproxy"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/encryption"
@@ -128,9 +128,14 @@ type HTTPServer struct {
queryDataService *query.Service
serviceAccountsService serviceaccounts.Service
authInfoService login.AuthInfoService
- TeamPermissionsService *resourcepermissions.Service
+ teamPermissionsService accesscontrol.PermissionsService
NotificationService *notifications.NotificationService
+ dashboardService dashboards.DashboardService
+ dashboardProvisioningService dashboards.DashboardProvisioningService
+ folderService dashboards.FolderService
DatasourcePermissionsService DatasourcePermissionsService
+ AlertNotificationService *alerting.AlertNotificationService
+ DashboardsnapshotsService *dashboardsnapshots.Service
}
type ServerOptions struct {
@@ -157,8 +162,12 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
pluginsUpdateChecker *updatechecker.PluginsService, searchUsersService searchusers.Service,
dataSourcesService datasources.DataSourceService, secretsService secrets.Service, queryDataService *query.Service,
ldapGroups ldap.Groups, teamGuardian teamguardian.TeamGuardian, serviceaccountsService serviceaccounts.Service,
- authInfoService login.AuthInfoService, resourcePermissionServices *resourceservices.ResourceServices,
- notificationService *notifications.NotificationService, datasourcePermissionsService DatasourcePermissionsService) (*HTTPServer, error) {
+ authInfoService login.AuthInfoService, permissionsServices accesscontrol.PermissionsServices,
+ notificationService *notifications.NotificationService, dashboardService dashboards.DashboardService,
+ dashboardProvisioningService dashboards.DashboardProvisioningService, folderService dashboards.FolderService,
+ datasourcePermissionsService DatasourcePermissionsService, alertNotificationService *alerting.AlertNotificationService,
+ dashboardsnapshotsService *dashboardsnapshots.Service,
+) (*HTTPServer, error) {
web.Env = cfg.Env
m := web.New()
@@ -217,9 +226,14 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
queryDataService: queryDataService,
serviceAccountsService: serviceaccountsService,
authInfoService: authInfoService,
- TeamPermissionsService: resourcePermissionServices.GetTeamService(),
NotificationService: notificationService,
+ dashboardService: dashboardService,
+ dashboardProvisioningService: dashboardProvisioningService,
+ folderService: folderService,
DatasourcePermissionsService: datasourcePermissionsService,
+ teamPermissionsService: permissionsServices.GetTeamService(),
+ AlertNotificationService: alertNotificationService,
+ DashboardsnapshotsService: dashboardsnapshotsService,
}
if hs.Listener != nil {
hs.log.Debug("Using provided listener")
diff --git a/pkg/api/login.go b/pkg/api/login.go
index 06688c97297..bad639e6d1d 100644
--- a/pkg/api/login.go
+++ b/pkg/api/login.go
@@ -92,7 +92,10 @@ func (hs *HTTPServer) LoginView(c *models.ReqContext) {
enabledOAuths := make(map[string]interface{})
providers := hs.SocialService.GetOAuthInfoProviders()
for key, oauth := range providers {
- enabledOAuths[key] = map[string]string{"name": oauth.Name}
+ enabledOAuths[key] = map[string]string{
+ "name": oauth.Name,
+ "icon": oauth.Icon,
+ }
}
viewData.Settings["oauth"] = enabledOAuths
diff --git a/pkg/api/org_users.go b/pkg/api/org_users.go
index dfc70933943..cad6b3715f6 100644
--- a/pkg/api/org_users.go
+++ b/pkg/api/org_users.go
@@ -10,7 +10,6 @@ import (
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/models"
- "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/util"
"github.com/grafana/grafana/pkg/web"
)
@@ -113,19 +112,6 @@ func (hs *HTTPServer) GetOrgUsersForCurrentOrgLookup(c *models.ReqContext) respo
return response.JSON(200, result)
}
-func (hs *HTTPServer) getUserAccessControlMetadata(c *models.ReqContext, resourceIDs map[string]bool) (map[string]accesscontrol.Metadata, error) {
- if hs.AccessControl == nil || hs.AccessControl.IsDisabled() || !c.QueryBool("accesscontrol") {
- return nil, nil
- }
-
- userPermissions, err := hs.AccessControl.GetUserPermissions(c.Req.Context(), c.SignedInUser, accesscontrol.Options{ReloadCache: false})
- if err != nil || len(userPermissions) == 0 {
- return nil, err
- }
-
- return accesscontrol.GetResourcesMetadata(c.Req.Context(), userPermissions, "users", resourceIDs), nil
-}
-
// GET /api/orgs/:orgId/users
func (hs *HTTPServer) GetOrgUsers(c *models.ReqContext) response.Response {
orgId, err := strconv.ParseInt(web.Params(c.Req)[":orgId"], 10, 64)
@@ -164,17 +150,11 @@ func (hs *HTTPServer) getOrgUsersHelper(c *models.ReqContext, query *models.GetO
filteredUsers = append(filteredUsers, user)
}
- accessControlMetadata, errAC := hs.getUserAccessControlMetadata(c, userIDs)
- if errAC != nil {
- hs.log.Error("Failed to get access control metadata", "error", errAC)
-
- return filteredUsers, nil
- } else if accessControlMetadata == nil {
- return filteredUsers, nil
- }
-
- for i := range filteredUsers {
- filteredUsers[i].AccessControl = accessControlMetadata[fmt.Sprint(filteredUsers[i].UserId)]
+ accessControlMetadata := hs.getMultiAccessControlMetadata(c, "users", userIDs)
+ if len(accessControlMetadata) > 0 {
+ for i := range filteredUsers {
+ filteredUsers[i].AccessControl = accessControlMetadata[fmt.Sprint(filteredUsers[i].UserId)]
+ }
}
return filteredUsers, nil
diff --git a/pkg/api/org_users_test.go b/pkg/api/org_users_test.go
index ee5dedb51c7..a098d163932 100644
--- a/pkg/api/org_users_test.go
+++ b/pkg/api/org_users_test.go
@@ -150,7 +150,7 @@ func TestOrgUsersAPIEndpoint_LegacyAccessControl_FolderAdmin(t *testing.T) {
"tags": "prod",
}),
}
- folder, err := sc.db.SaveDashboard(cmd)
+ folder, err := sc.dashboardsStore.SaveDashboard(cmd)
require.NoError(t, err)
require.NotNil(t, folder)
@@ -165,7 +165,7 @@ func TestOrgUsersAPIEndpoint_LegacyAccessControl_FolderAdmin(t *testing.T) {
Updated: time.Now(),
},
}
- err = sc.db.UpdateDashboardACL(context.Background(), folder.Id, acls)
+ err = sc.dashboardsStore.UpdateDashboardACL(context.Background(), folder.Id, acls)
require.NoError(t, err)
response := callAPI(sc.server, http.MethodGet, "/api/org/users/lookup", nil, t)
diff --git a/pkg/api/pluginproxy/ds_proxy_test.go b/pkg/api/pluginproxy/ds_proxy_test.go
index bd1808aed6b..fb23163678d 100644
--- a/pkg/api/pluginproxy/ds_proxy_test.go
+++ b/pkg/api/pluginproxy/ds_proxy_test.go
@@ -21,6 +21,7 @@ import (
"github.com/grafana/grafana/pkg/plugins"
acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
datasourceservice "github.com/grafana/grafana/pkg/services/datasources/service"
+ "github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/oauthtoken"
"github.com/grafana/grafana/pkg/services/secrets"
"github.com/grafana/grafana/pkg/services/secrets/fakes"
@@ -129,7 +130,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) {
t.Run("When matching route path", func(t *testing.T) {
ctx, req := setUp()
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{})
+ dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock())
proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/v4/some/method", cfg, httpClientProvider,
&oauthtoken.Service{}, dsService, tracer, secretsService)
require.NoError(t, err)
@@ -142,7 +143,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) {
t.Run("When matching route path and has dynamic url", func(t *testing.T) {
ctx, req := setUp()
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{})
+ dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock())
proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/common/some/method", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService)
require.NoError(t, err)
proxy.matchedRoute = routes[3]
@@ -154,7 +155,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) {
t.Run("When matching route path with no url", func(t *testing.T) {
ctx, req := setUp()
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{})
+ dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock())
proxy, err := NewDataSourceProxy(ds, routes, ctx, "", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService)
require.NoError(t, err)
proxy.matchedRoute = routes[4]
@@ -165,7 +166,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) {
t.Run("When matching route path and has dynamic body", func(t *testing.T) {
ctx, req := setUp()
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{})
+ dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock())
proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/body", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService)
require.NoError(t, err)
proxy.matchedRoute = routes[5]
@@ -179,7 +180,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) {
t.Run("Validating request", func(t *testing.T) {
t.Run("plugin route with valid role", func(t *testing.T) {
ctx, _ := setUp()
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{})
+ dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock())
proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/v4/some/method", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService)
require.NoError(t, err)
err = proxy.validateRequest()
@@ -188,7 +189,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) {
t.Run("plugin route with admin role and user is editor", func(t *testing.T) {
ctx, _ := setUp()
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{})
+ dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock())
proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/admin", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService)
require.NoError(t, err)
err = proxy.validateRequest()
@@ -198,7 +199,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) {
t.Run("plugin route with admin role and user is admin", func(t *testing.T) {
ctx, _ := setUp()
ctx.SignedInUser.OrgRole = models.ROLE_ADMIN
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{})
+ dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock())
proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/admin", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService)
require.NoError(t, err)
err = proxy.validateRequest()
@@ -289,7 +290,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) {
},
}
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{})
+ dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock())
proxy, err := NewDataSourceProxy(ds, routes, ctx, "pathwithtoken1", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService)
require.NoError(t, err)
ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, routes[0], dsInfo, cfg)
@@ -305,7 +306,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) {
req, err := http.NewRequest("GET", "http://localhost/asd", nil)
require.NoError(t, err)
client = newFakeHTTPClient(t, json2)
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{})
+ dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock())
proxy, err := NewDataSourceProxy(ds, routes, ctx, "pathwithtoken2", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService)
require.NoError(t, err)
ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, routes[1], dsInfo, cfg)
@@ -322,7 +323,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) {
require.NoError(t, err)
client = newFakeHTTPClient(t, []byte{})
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{})
+ dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock())
proxy, err := NewDataSourceProxy(ds, routes, ctx, "pathwithtoken1", cfg, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService)
require.NoError(t, err)
ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, routes[0], dsInfo, cfg)
@@ -344,7 +345,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) {
ctx := &models.ReqContext{}
secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{})
+ dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock())
proxy, err := NewDataSourceProxy(ds, routes, ctx, "/render", &setting.Cfg{BuildVersion: "5.3.0"}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService)
require.NoError(t, err)
req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil)
@@ -370,7 +371,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) {
ctx := &models.ReqContext{}
var routes []*plugins.Route
secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{})
+ dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock())
proxy, err := NewDataSourceProxy(ds, routes, ctx, "", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService)
require.NoError(t, err)
@@ -394,7 +395,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) {
ctx := &models.ReqContext{}
var routes []*plugins.Route
secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{})
+ dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock())
proxy, err := NewDataSourceProxy(ds, routes, ctx, "", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService)
require.NoError(t, err)
@@ -422,7 +423,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) {
ctx := &models.ReqContext{}
var pluginRoutes []*plugins.Route
secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{})
+ dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock())
proxy, err := NewDataSourceProxy(ds, pluginRoutes, ctx, "", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService)
require.NoError(t, err)
@@ -445,7 +446,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) {
ctx := &models.ReqContext{}
var routes []*plugins.Route
secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{})
+ dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock())
proxy, err := NewDataSourceProxy(ds, routes, ctx, "/path/to/folder/", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService)
require.NoError(t, err)
req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil)
@@ -509,7 +510,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) {
var routes []*plugins.Route
secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{})
+ dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock())
proxy, err := NewDataSourceProxy(ds, routes, ctx, "/path/to/folder/", &setting.Cfg{}, httpClientProvider, &mockAuthToken, dsService, tracer, secretsService)
require.NoError(t, err)
req, err = http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil)
@@ -642,7 +643,7 @@ func TestDataSourceProxy_requestHandling(t *testing.T) {
ctx, ds := setUp(t)
var routes []*plugins.Route
secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{})
+ dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock())
proxy, err := NewDataSourceProxy(ds, routes, ctx, "/render", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService)
require.NoError(t, err)
@@ -660,7 +661,7 @@ func TestDataSourceProxy_requestHandling(t *testing.T) {
})
var routes []*plugins.Route
secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{})
+ dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock())
proxy, err := NewDataSourceProxy(ds, routes, ctx, "/render", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService)
require.NoError(t, err)
@@ -674,7 +675,7 @@ func TestDataSourceProxy_requestHandling(t *testing.T) {
ctx, ds := setUp(t)
var routes []*plugins.Route
secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{})
+ dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock())
proxy, err := NewDataSourceProxy(ds, routes, ctx, "/render", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService)
require.NoError(t, err)
@@ -696,7 +697,7 @@ func TestDataSourceProxy_requestHandling(t *testing.T) {
})
var routes []*plugins.Route
secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{})
+ dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock())
proxy, err := NewDataSourceProxy(ds, routes, ctx, "/render", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService)
require.NoError(t, err)
@@ -721,7 +722,7 @@ func TestDataSourceProxy_requestHandling(t *testing.T) {
ctx.Req = httptest.NewRequest("GET", "/api/datasources/proxy/1/path/%2Ftest%2Ftest%2F?query=%2Ftest%2Ftest%2F", nil)
var routes []*plugins.Route
secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{})
+ dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock())
proxy, err := NewDataSourceProxy(ds, routes, ctx, "/path/%2Ftest%2Ftest%2F", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService)
require.NoError(t, err)
@@ -745,7 +746,7 @@ func TestDataSourceProxy_requestHandling(t *testing.T) {
ctx.Req = httptest.NewRequest("GET", "/api/datasources/proxy/1/path/%2Ftest%2Ftest%2F?query=%2Ftest%2Ftest%2F", nil)
var routes []*plugins.Route
secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{})
+ dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock())
proxy, err := NewDataSourceProxy(ds, routes, ctx, "/path/%2Ftest%2Ftest%2F", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService, tracer, secretsService)
require.NoError(t, err)
@@ -770,7 +771,7 @@ func TestNewDataSourceProxy_InvalidURL(t *testing.T) {
require.NoError(t, err)
var routes []*plugins.Route
secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{})
+ dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock())
_, err = NewDataSourceProxy(&ds, routes, &ctx, "api/method", &cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer, secretsService)
require.Error(t, err)
assert.True(t, strings.HasPrefix(err.Error(), `validation of data source URL "://host/root" failed`))
@@ -791,7 +792,7 @@ func TestNewDataSourceProxy_ProtocolLessURL(t *testing.T) {
var routes []*plugins.Route
secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{})
+ dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock())
_, err = NewDataSourceProxy(&ds, routes, &ctx, "api/method", &cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer, secretsService)
require.NoError(t, err)
@@ -834,7 +835,7 @@ func TestNewDataSourceProxy_MSSQL(t *testing.T) {
var routes []*plugins.Route
secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{})
+ dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock())
p, err := NewDataSourceProxy(&ds, routes, &ctx, "api/method", &cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer, secretsService)
if tc.err == nil {
require.NoError(t, err)
@@ -861,7 +862,7 @@ func getDatasourceProxiedRequest(t *testing.T, ctx *models.ReqContext, cfg *sett
var routes []*plugins.Route
secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{})
+ dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock())
proxy, err := NewDataSourceProxy(ds, routes, ctx, "", cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer, secretsService)
require.NoError(t, err)
req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil)
@@ -985,7 +986,7 @@ func runDatasourceAuthTest(t *testing.T, secretsService secrets.Service, test *t
require.NoError(t, err)
var routes []*plugins.Route
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{})
+ dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock())
proxy, err := NewDataSourceProxy(test.datasource, routes, ctx, "", &setting.Cfg{}, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer, secretsService)
require.NoError(t, err)
@@ -1027,7 +1028,7 @@ func Test_PathCheck(t *testing.T) {
}
ctx, _ := setUp()
secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{})
+ dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock())
proxy, err := NewDataSourceProxy(&models.DataSource{}, routes, ctx, "b", &setting.Cfg{}, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer, secretsService)
require.NoError(t, err)
diff --git a/pkg/api/team.go b/pkg/api/team.go
index be12efe992a..2b256959e9c 100644
--- a/pkg/api/team.go
+++ b/pkg/api/team.go
@@ -2,14 +2,12 @@ package api
import (
"errors"
- "fmt"
"net/http"
"strconv"
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/models"
- "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/util"
"github.com/grafana/grafana/pkg/web"
@@ -39,7 +37,7 @@ func (hs *HTTPServer) CreateTeam(c *models.ReqContext) response.Response {
// the SignedInUser is an empty struct therefore
// an additional check whether it is an actual user is required
if c.SignedInUser.IsRealUser() {
- if err := addOrUpdateTeamMember(c.Req.Context(), hs.TeamPermissionsService, c.SignedInUser.UserId, c.OrgId, team.Id, models.PERMISSION_ADMIN.String()); err != nil {
+ if err := addOrUpdateTeamMember(c.Req.Context(), hs.teamPermissionsService, c.SignedInUser.UserId, c.OrgId, team.Id, models.PERMISSION_ADMIN.String()); err != nil {
c.Logger.Error("Could not add creator to team", "error", err)
}
} else {
@@ -105,20 +103,6 @@ func (hs *HTTPServer) DeleteTeamByID(c *models.ReqContext) response.Response {
return response.Success("Team deleted")
}
-func (hs *HTTPServer) getTeamsAccessControlMetadata(c *models.ReqContext, teamIDs map[string]bool) (map[string]accesscontrol.Metadata, error) {
- if hs.AccessControl.IsDisabled() || !c.QueryBool("accesscontrol") {
- return nil, nil
- }
-
- userPermissions, err := hs.AccessControl.GetUserPermissions(c.Req.Context(), c.SignedInUser, accesscontrol.Options{ReloadCache: false})
- if err != nil || len(userPermissions) == 0 {
- hs.log.Warn("could not fetch accesscontrol metadata for teams", "error", err)
- return nil, err
- }
-
- return accesscontrol.GetResourcesMetadata(c.Req.Context(), userPermissions, "teams", teamIDs), nil
-}
-
// GET /api/teams/search
func (hs *HTTPServer) SearchTeams(c *models.ReqContext) response.Response {
perPage := c.QueryInt("perpage")
@@ -157,8 +141,8 @@ func (hs *HTTPServer) SearchTeams(c *models.ReqContext) response.Response {
teamIDs[strconv.FormatInt(team.Id, 10)] = true
}
- metadata, err := hs.getTeamsAccessControlMetadata(c, teamIDs)
- if err == nil && len(metadata) != 0 {
+ metadata := hs.getMultiAccessControlMetadata(c, "teams", teamIDs)
+ if len(metadata) > 0 {
for _, team := range query.Result.Teams {
team.AccessControl = metadata[strconv.FormatInt(team.Id, 10)]
}
@@ -170,23 +154,6 @@ func (hs *HTTPServer) SearchTeams(c *models.ReqContext) response.Response {
return response.JSON(200, query.Result)
}
-func (hs *HTTPServer) getTeamAccessControlMetadata(c *models.ReqContext, teamID int64) (accesscontrol.Metadata, error) {
- if hs.AccessControl.IsDisabled() || !c.QueryBool("accesscontrol") {
- return nil, nil
- }
-
- userPermissions, err := hs.AccessControl.GetUserPermissions(c.Req.Context(), c.SignedInUser, accesscontrol.Options{ReloadCache: false})
- if err != nil || len(userPermissions) == 0 {
- hs.log.Warn("could not fetch accesscontrol metadata", "team", teamID, "error", err)
- return nil, err
- }
-
- key := fmt.Sprintf("%d", teamID)
- teamIDs := map[string]bool{key: true}
-
- return accesscontrol.GetResourcesMetadata(c.Req.Context(), userPermissions, "teams", teamIDs)[key], nil
-}
-
// UserFilter returns the user ID used in a filter when querying a team
// 1. If the user is a viewer or editor, this will return the user's ID.
// 2. If the user is an admin, this will return models.FilterIgnoreUser (0)
@@ -227,8 +194,8 @@ func (hs *HTTPServer) GetTeamByID(c *models.ReqContext) response.Response {
return response.Error(500, "Failed to get Team", err)
}
- metadata, _ := hs.getTeamAccessControlMetadata(c, query.Result.Id)
- query.Result.AccessControl = metadata
+ // Add accesscontrol metadata
+ query.Result.AccessControl = hs.getAccessControlMetadata(c, "teams", query.Result.Id)
query.Result.AvatarUrl = dtos.GetGravatarUrlWithDefault(query.Result.Email, query.Result.Name)
return response.JSON(200, &query.Result)
diff --git a/pkg/api/team_members.go b/pkg/api/team_members.go
index e03170099a6..e39028872e8 100644
--- a/pkg/api/team_members.go
+++ b/pkg/api/team_members.go
@@ -11,7 +11,6 @@ import (
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/accesscontrol"
- "github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/util"
"github.com/grafana/grafana/pkg/web"
@@ -85,7 +84,7 @@ func (hs *HTTPServer) AddTeamMember(c *models.ReqContext) response.Response {
return response.Error(400, "User is already added to this team", nil)
}
- err = addOrUpdateTeamMember(c.Req.Context(), hs.TeamPermissionsService, cmd.UserId, cmd.OrgId, cmd.TeamId, getPermissionName(cmd.Permission))
+ err = addOrUpdateTeamMember(c.Req.Context(), hs.teamPermissionsService, cmd.UserId, cmd.OrgId, cmd.TeamId, getPermissionName(cmd.Permission))
if err != nil {
return response.Error(500, "Failed to add Member to Team", err)
}
@@ -125,7 +124,7 @@ func (hs *HTTPServer) UpdateTeamMember(c *models.ReqContext) response.Response {
return response.Error(404, "Team member not found.", nil)
}
- err = addOrUpdateTeamMember(c.Req.Context(), hs.TeamPermissionsService, userId, orgId, teamId, getPermissionName(cmd.Permission))
+ err = addOrUpdateTeamMember(c.Req.Context(), hs.teamPermissionsService, userId, orgId, teamId, getPermissionName(cmd.Permission))
if err != nil {
return response.Error(500, "Failed to update team member.", err)
}
@@ -161,7 +160,7 @@ func (hs *HTTPServer) RemoveTeamMember(c *models.ReqContext) response.Response {
}
teamIDString := strconv.FormatInt(teamId, 10)
- if _, err := hs.TeamPermissionsService.SetUserPermission(c.Req.Context(), orgId, accesscontrol.User{ID: userId}, teamIDString, ""); err != nil {
+ if _, err := hs.teamPermissionsService.SetUserPermission(c.Req.Context(), orgId, accesscontrol.User{ID: userId}, teamIDString, ""); err != nil {
if errors.Is(err, models.ErrTeamNotFound) {
return response.Error(404, "Team not found", nil)
}
@@ -178,7 +177,7 @@ func (hs *HTTPServer) RemoveTeamMember(c *models.ReqContext) response.Response {
// addOrUpdateTeamMember adds or updates a team member.
//
// Stubbable by tests.
-var addOrUpdateTeamMember = func(ctx context.Context, resourcePermissionService *resourcepermissions.Service, userID, orgID, teamID int64, permission string) error {
+var addOrUpdateTeamMember = func(ctx context.Context, resourcePermissionService accesscontrol.PermissionsService, userID, orgID, teamID int64, permission string) error {
teamIDString := strconv.FormatInt(teamID, 10)
if _, err := resourcePermissionService.SetUserPermission(ctx, orgID, accesscontrol.User{ID: userID}, teamIDString, permission); err != nil {
return fmt.Errorf("failed setting permissions for user %d in team %d: %w", userID, teamID, err)
diff --git a/pkg/api/team_test.go b/pkg/api/team_test.go
index 4246cafa1e9..d2995504d5f 100644
--- a/pkg/api/team_test.go
+++ b/pkg/api/team_test.go
@@ -11,7 +11,6 @@ import (
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/accesscontrol"
- "github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/services/sqlstore/mockstore"
"github.com/grafana/grafana/pkg/setting"
@@ -112,7 +111,7 @@ func TestTeamAPIEndpoint(t *testing.T) {
teamName := "team foo"
addTeamMemberCalled := 0
- addOrUpdateTeamMember = func(ctx context.Context, resourcePermissionService *resourcepermissions.Service, userID, orgID, teamID int64,
+ addOrUpdateTeamMember = func(ctx context.Context, resourcePermissionService accesscontrol.PermissionsService, userID, orgID, teamID int64,
permission string) error {
addTeamMemberCalled++
return nil
diff --git a/pkg/api/user.go b/pkg/api/user.go
index 64f609fff43..20babbf91cc 100644
--- a/pkg/api/user.go
+++ b/pkg/api/user.go
@@ -3,14 +3,12 @@ package api
import (
"context"
"errors"
- "fmt"
"net/http"
"strconv"
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/models"
- "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
"github.com/grafana/grafana/pkg/web"
@@ -48,33 +46,12 @@ func (hs *HTTPServer) getUserUserProfile(c *models.ReqContext, userID int64) res
query.Result.IsExternal = true
}
- accessControlMetadata, errAC := hs.getGlobalUserAccessControlMetadata(c, userID)
- if errAC != nil {
- hs.log.Error("Failed to get access control metadata", "error", errAC)
- }
-
- query.Result.AccessControl = accessControlMetadata
+ query.Result.AccessControl = hs.getAccessControlMetadata(c, "global:users", userID)
query.Result.AvatarUrl = dtos.GetGravatarUrl(query.Result.Email)
return response.JSON(200, query.Result)
}
-func (hs *HTTPServer) getGlobalUserAccessControlMetadata(c *models.ReqContext, userID int64) (accesscontrol.Metadata, error) {
- if hs.AccessControl == nil || hs.AccessControl.IsDisabled() || !c.QueryBool("accesscontrol") {
- return nil, nil
- }
-
- userPermissions, err := hs.AccessControl.GetUserPermissions(c.Req.Context(), c.SignedInUser, accesscontrol.Options{ReloadCache: false})
- if err != nil || len(userPermissions) == 0 {
- return nil, err
- }
-
- key := fmt.Sprintf("%d", userID)
- userIDs := map[string]bool{key: true}
-
- return accesscontrol.GetResourcesMetadata(c.Req.Context(), userPermissions, "global:users", userIDs)[key], nil
-}
-
// GET /api/users/lookup
func (hs *HTTPServer) GetUserByLoginOrEmail(c *models.ReqContext) response.Response {
query := models.GetUserByLoginQuery{LoginOrEmail: c.Query("loginOrEmail")}
diff --git a/pkg/api/user_test.go b/pkg/api/user_test.go
index 2841d5bd5d2..e52fcf09778 100644
--- a/pkg/api/user_test.go
+++ b/pkg/api/user_test.go
@@ -8,32 +8,33 @@ import (
"testing"
"time"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "golang.org/x/oauth2"
+
"github.com/grafana/grafana/pkg/api/dtos"
+ "github.com/grafana/grafana/pkg/bus"
+ "github.com/grafana/grafana/pkg/components/simplejson"
+ "github.com/grafana/grafana/pkg/models"
+ acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
"github.com/grafana/grafana/pkg/services/login/authinfoservice"
authinfostore "github.com/grafana/grafana/pkg/services/login/authinfoservice/database"
+ "github.com/grafana/grafana/pkg/services/searchusers"
"github.com/grafana/grafana/pkg/services/searchusers/filters"
"github.com/grafana/grafana/pkg/services/secrets/database"
secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/services/sqlstore/mockstore"
"github.com/grafana/grafana/pkg/setting"
- "golang.org/x/oauth2"
-
- "github.com/grafana/grafana/pkg/services/searchusers"
-
- "github.com/grafana/grafana/pkg/bus"
- "github.com/grafana/grafana/pkg/components/simplejson"
- "github.com/grafana/grafana/pkg/models"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
func TestUserAPIEndpoint_userLoggedIn(t *testing.T) {
settings := setting.NewCfg()
sqlStore := sqlstore.InitTestDB(t)
hs := &HTTPServer{
- Cfg: settings,
- SQLStore: sqlStore,
+ Cfg: settings,
+ SQLStore: sqlStore,
+ AccessControl: &acmock.Mock{},
}
mockResult := models.SearchUserQueryResult{
diff --git a/pkg/cmd/grafana-cli/commands/commands.go b/pkg/cmd/grafana-cli/commands/commands.go
index 82b1bff1ef1..809dd35adf3 100644
--- a/pkg/cmd/grafana-cli/commands/commands.go
+++ b/pkg/cmd/grafana-cli/commands/commands.go
@@ -12,9 +12,6 @@ import (
"github.com/grafana/grafana/pkg/cmd/grafana-cli/services"
"github.com/grafana/grafana/pkg/cmd/grafana-cli/utils"
"github.com/grafana/grafana/pkg/infra/tracing"
- "github.com/grafana/grafana/pkg/services/featuremgmt"
- "github.com/grafana/grafana/pkg/services/hooks"
- "github.com/grafana/grafana/pkg/services/licensing"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/services/sqlstore/migrations"
"github.com/grafana/grafana/pkg/setting"
@@ -59,14 +56,7 @@ func runDbCommand(command func(commandLine utils.CommandLine, sqlStore *sqlstore
return errutil.Wrap("failed to initialize tracer service", err)
}
- hooksService := hooks.ProvideService()
- ossLicensingService := licensing.ProvideService(cfg, hooksService)
- featureManager, err := featuremgmt.ProvideManagerService(cfg, ossLicensingService)
- if err != nil {
- return errutil.Wrap("failed to initialize feature manager service", err)
- }
-
- sqlStore, err := sqlstore.ProvideService(cfg, nil, bus.GetBus(), &migrations.OSSMigrations{}, tracer, featureManager)
+ sqlStore, err := sqlstore.ProvideService(cfg, nil, bus.GetBus(), &migrations.OSSMigrations{}, tracer)
if err != nil {
return errutil.Wrap("failed to initialize SQL store", err)
}
diff --git a/pkg/cmd/grafana-cli/commands/cuetsify_command.go b/pkg/cmd/grafana-cli/commands/cuetsify_command.go
index 73ae0d098c7..d71335f2431 100644
--- a/pkg/cmd/grafana-cli/commands/cuetsify_command.go
+++ b/pkg/cmd/grafana-cli/commands/cuetsify_command.go
@@ -43,6 +43,7 @@ var skipPaths = []string{
"public/app/plugins/panel/dashlist/models.cue",
"public/app/plugins/panel/gauge/models.cue",
"public/app/plugins/panel/histogram/models.cue",
+ "public/app/plugins/panel/heatmap-new/models.cue",
"public/app/plugins/panel/stat/models.cue",
"public/app/plugins/panel/candlestick/models.cue",
"public/app/plugins/panel/state-timeline/models.cue",
diff --git a/pkg/dashboards/ifaces.go b/pkg/dashboards/ifaces.go
deleted file mode 100644
index 93fe150e583..00000000000
--- a/pkg/dashboards/ifaces.go
+++ /dev/null
@@ -1,23 +0,0 @@
-package dashboards
-
-import (
- "context"
-
- "github.com/grafana/grafana/pkg/models"
-)
-
-// Store is a dashboard store.
-type Store interface {
- // ValidateDashboardBeforeSave validates a dashboard before save.
- ValidateDashboardBeforeSave(dashboard *models.Dashboard, overwrite bool) (bool, error)
- // GetFolderByTitle retrieves a dashboard by its title and is used by unified alerting
- GetFolderByTitle(orgID int64, title string) (*models.Dashboard, error)
- GetProvisionedDataByDashboardID(dashboardID int64) (*models.DashboardProvisioning, error)
- GetProvisionedDataByDashboardUID(orgID int64, dashboardUID string) (*models.DashboardProvisioning, error)
- GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error)
- SaveProvisionedDashboard(cmd models.SaveDashboardCommand, provisioning *models.DashboardProvisioning) (*models.Dashboard, error)
- SaveDashboard(cmd models.SaveDashboardCommand) (*models.Dashboard, error)
- UpdateDashboardACLCtx(ctx context.Context, uid int64, items []*models.DashboardAcl) error
- // SaveAlerts saves dashboard alerts.
- SaveAlerts(ctx context.Context, dashID int64, alerts []*models.Alert) error
-}
diff --git a/pkg/login/social/social.go b/pkg/login/social/social.go
index 339f4d6b2bb..aad424e3900 100644
--- a/pkg/login/social/social.go
+++ b/pkg/login/social/social.go
@@ -45,6 +45,7 @@ type OAuthInfo struct {
TeamsUrl string
AllowSignup bool
Name string
+ Icon string
TlsClientCert string
TlsClientKey string
TlsClientCa string
@@ -81,6 +82,7 @@ func ProvideService(cfg *setting.Cfg) *SocialService {
HostedDomain: sec.Key("hosted_domain").String(),
AllowSignup: sec.Key("allow_sign_up").MustBool(),
Name: sec.Key("name").MustString(name),
+ Icon: sec.Key("icon").String(),
TlsClientCert: sec.Key("tls_client_cert").String(),
TlsClientKey: sec.Key("tls_client_key").String(),
TlsClientCa: sec.Key("tls_client_ca").String(),
diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go
index a3ff1276639..a3b2a2f2e89 100644
--- a/pkg/middleware/auth.go
+++ b/pkg/middleware/auth.go
@@ -191,28 +191,30 @@ func shouldForceLogin(c *models.ReqContext) bool {
return forceLogin
}
-func OrgAdminFolderAdminOrTeamAdmin(c *models.ReqContext) {
- if c.OrgRole == models.ROLE_ADMIN {
- return
- }
+func OrgAdminFolderAdminOrTeamAdmin(ss sqlstore.Store) func(c *models.ReqContext) {
+ return func(c *models.ReqContext) {
+ if c.OrgRole == models.ROLE_ADMIN {
+ return
+ }
- hasAdminPermissionInFoldersQuery := models.HasAdminPermissionInFoldersQuery{SignedInUser: c.SignedInUser}
- if err := sqlstore.HasAdminPermissionInFolders(c.Req.Context(), &hasAdminPermissionInFoldersQuery); err != nil {
- c.JsonApiErr(500, "Failed to check if user is a folder admin", err)
- }
+ hasAdminPermissionInFoldersQuery := models.HasAdminPermissionInFoldersQuery{SignedInUser: c.SignedInUser}
+ if err := ss.HasAdminPermissionInFolders(c.Req.Context(), &hasAdminPermissionInFoldersQuery); err != nil {
+ c.JsonApiErr(500, "Failed to check if user is a folder admin", err)
+ }
- if hasAdminPermissionInFoldersQuery.Result {
- return
- }
+ if hasAdminPermissionInFoldersQuery.Result {
+ return
+ }
- isAdminOfTeamsQuery := models.IsAdminOfTeamsQuery{SignedInUser: c.SignedInUser}
- if err := sqlstore.IsAdminOfTeams(c.Req.Context(), &isAdminOfTeamsQuery); err != nil {
- c.JsonApiErr(500, "Failed to check if user is a team admin", err)
- }
+ isAdminOfTeamsQuery := models.IsAdminOfTeamsQuery{SignedInUser: c.SignedInUser}
+ if err := sqlstore.IsAdminOfTeams(c.Req.Context(), &isAdminOfTeamsQuery); err != nil {
+ c.JsonApiErr(500, "Failed to check if user is a team admin", err)
+ }
- if isAdminOfTeamsQuery.Result {
- return
- }
+ if isAdminOfTeamsQuery.Result {
+ return
+ }
- accessForbidden(c)
+ accessForbidden(c)
+ }
}
diff --git a/pkg/models/dashboard_thumbs.go b/pkg/models/dashboard_thumbs.go
index 954a07b1a42..0935d0c16e0 100644
--- a/pkg/models/dashboard_thumbs.go
+++ b/pkg/models/dashboard_thumbs.go
@@ -117,6 +117,10 @@ type DashboardWithStaleThumbnail struct {
Slug string
}
+type FindDashboardThumbnailCountCommand struct {
+ Result int64
+}
+
type FindDashboardsWithStaleThumbnailsCommand struct {
IncludeManuallyUploadedThumbnails bool
Theme Theme
diff --git a/pkg/models/dashboards.go b/pkg/models/dashboards.go
index ff11aa463b3..358f0ad1e8b 100644
--- a/pkg/models/dashboards.go
+++ b/pkg/models/dashboards.go
@@ -456,7 +456,3 @@ type GetDashboardRefByIdQuery struct {
Id int64
Result *DashboardRef
}
-
-type UnprovisionDashboardCommand struct {
- Id int64
-}
diff --git a/pkg/plugins/manager/dashboards_test.go b/pkg/plugins/manager/dashboards_test.go
index 55397f0bff2..d801c65e78b 100644
--- a/pkg/plugins/manager/dashboards_test.go
+++ b/pkg/plugins/manager/dashboards_test.go
@@ -11,6 +11,9 @@ import (
"github.com/grafana/grafana/pkg/plugins/backendplugin/provider"
"github.com/grafana/grafana/pkg/plugins/manager/loader"
"github.com/grafana/grafana/pkg/plugins/manager/signature"
+ "github.com/grafana/grafana/pkg/services/dashboards/database"
+ service "github.com/grafana/grafana/pkg/services/dashboards/manager"
+ "github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/setting"
"github.com/stretchr/testify/require"
)
@@ -24,8 +27,9 @@ func TestGetPluginDashboards(t *testing.T) {
},
}
pmCfg := plugins.FromGrafanaCfg(cfg)
+ dashboardService := service.ProvideDashboardService(database.ProvideDashboardStore(&sqlstore.SQLStore{}))
pm, err := ProvideService(cfg, loader.New(pmCfg, nil,
- signature.NewUnsignedAuthorizer(pmCfg), &provider.Service{}))
+ signature.NewUnsignedAuthorizer(pmCfg), &provider.Service{}), dashboardService)
require.NoError(t, err)
bus.AddHandler("test", func(ctx context.Context, query *models.GetDashboardQuery) error {
diff --git a/pkg/plugins/manager/loader/loader.go b/pkg/plugins/manager/loader/loader.go
index f906a9572f2..03c0819d90b 100644
--- a/pkg/plugins/manager/loader/loader.go
+++ b/pkg/plugins/manager/loader/loader.go
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
+ "fmt"
"net/url"
"os"
"path"
@@ -240,7 +241,7 @@ func createPluginBase(pluginJSON plugins.JSONData, class plugins.Class, pluginDi
Class: class,
}
- plugin.SetLogger(logger.New("pluginID", plugin.ID))
+ plugin.SetLogger(log.New(fmt.Sprintf("plugin.%s", plugin.ID)))
setImages(plugin)
return plugin
diff --git a/pkg/plugins/manager/manager.go b/pkg/plugins/manager/manager.go
index 616f862d97b..d74bad4f744 100644
--- a/pkg/plugins/manager/manager.go
+++ b/pkg/plugins/manager/manager.go
@@ -9,12 +9,12 @@ import (
"time"
"github.com/grafana/grafana-plugin-sdk-go/backend"
-
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/backendplugin"
"github.com/grafana/grafana/pkg/plugins/backendplugin/instrumentation"
"github.com/grafana/grafana/pkg/plugins/manager/installer"
+ "github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util/errutil"
)
@@ -30,35 +30,38 @@ var _ plugins.StaticRouteResolver = (*PluginManager)(nil)
var _ plugins.RendererManager = (*PluginManager)(nil)
type PluginManager struct {
- cfg *plugins.Cfg
- store map[string]*plugins.Plugin
- pluginInstaller plugins.Installer
- pluginLoader plugins.Loader
- pluginsMu sync.RWMutex
- pluginPaths map[plugins.Class][]string
- log log.Logger
+ cfg *plugins.Cfg
+ store map[string]*plugins.Plugin
+ pluginInstaller plugins.Installer
+ pluginLoader plugins.Loader
+ pluginsMu sync.RWMutex
+ pluginPaths map[plugins.Class][]string
+ dashboardService dashboards.DashboardService
+ log log.Logger
}
-func ProvideService(grafanaCfg *setting.Cfg, pluginLoader plugins.Loader) (*PluginManager, error) {
+func ProvideService(grafanaCfg *setting.Cfg, pluginLoader plugins.Loader, dashboardService dashboards.DashboardService) (*PluginManager, error) {
pm := New(plugins.FromGrafanaCfg(grafanaCfg), map[plugins.Class][]string{
plugins.Core: corePluginPaths(grafanaCfg),
plugins.Bundled: {grafanaCfg.BundledPluginsPath},
plugins.External: append([]string{grafanaCfg.PluginsPath}, pluginSettingPaths(grafanaCfg)...),
- }, pluginLoader)
+ }, pluginLoader, dashboardService)
if err := pm.Init(); err != nil {
return nil, err
}
return pm, nil
}
-func New(cfg *plugins.Cfg, pluginPaths map[plugins.Class][]string, pluginLoader plugins.Loader) *PluginManager {
+func New(cfg *plugins.Cfg, pluginPaths map[plugins.Class][]string, pluginLoader plugins.Loader,
+ dashboardService dashboards.DashboardService) *PluginManager {
return &PluginManager{
- cfg: cfg,
- pluginLoader: pluginLoader,
- pluginPaths: pluginPaths,
- store: make(map[string]*plugins.Plugin),
- log: log.New("plugin.manager"),
- pluginInstaller: installer.New(false, cfg.BuildVersion, newInstallerLogger("plugin.installer", true)),
+ cfg: cfg,
+ pluginLoader: pluginLoader,
+ pluginPaths: pluginPaths,
+ store: make(map[string]*plugins.Plugin),
+ log: log.New("plugin.manager"),
+ pluginInstaller: installer.New(false, cfg.BuildVersion, newInstallerLogger("plugin.installer", true)),
+ dashboardService: dashboardService,
}
}
diff --git a/pkg/plugins/manager/manager_integration_test.go b/pkg/plugins/manager/manager_integration_test.go
index beb81cb7a96..e0c871f0428 100644
--- a/pkg/plugins/manager/manager_integration_test.go
+++ b/pkg/plugins/manager/manager_integration_test.go
@@ -7,16 +7,14 @@ import (
"strings"
"testing"
- "github.com/grafana/grafana/pkg/infra/tracing"
- "go.opentelemetry.io/otel/trace"
-
"github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
-
+ "github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/backendplugin/coreplugin"
"github.com/grafana/grafana/pkg/plugins/backendplugin/provider"
"github.com/grafana/grafana/pkg/plugins/manager/loader"
"github.com/grafana/grafana/pkg/plugins/manager/signature"
+ service "github.com/grafana/grafana/pkg/services/dashboards/manager"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/licensing"
"github.com/grafana/grafana/pkg/services/searchV2"
@@ -37,6 +35,7 @@ import (
"github.com/grafana/grafana/pkg/tsdb/prometheus"
"github.com/grafana/grafana/pkg/tsdb/tempo"
"github.com/grafana/grafana/pkg/tsdb/testdatasource"
+ "go.opentelemetry.io/otel/trace"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -95,7 +94,7 @@ func TestPluginManager_int_init(t *testing.T) {
pmCfg := plugins.FromGrafanaCfg(cfg)
pm, err := ProvideService(cfg, loader.New(pmCfg, license, signature.NewUnsignedAuthorizer(pmCfg),
- provider.ProvideService(coreRegistry)))
+ provider.ProvideService(coreRegistry)), &service.DashboardServiceImpl{})
require.NoError(t, err)
verifyCorePluginCatalogue(t, pm)
@@ -120,6 +119,7 @@ func verifyCorePluginCatalogue(t *testing.T, pm *PluginManager) {
"gettingstarted": {},
"graph": {},
"heatmap": {},
+ "heatmap-new": {},
"histogram": {},
"icon": {},
"live": {},
diff --git a/pkg/plugins/manager/manager_test.go b/pkg/plugins/manager/manager_test.go
index d0b5d9dc813..6a35bd463d0 100644
--- a/pkg/plugins/manager/manager_test.go
+++ b/pkg/plugins/manager/manager_test.go
@@ -12,6 +12,9 @@ import (
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/backendplugin"
+ "github.com/grafana/grafana/pkg/services/dashboards/database"
+ service "github.com/grafana/grafana/pkg/services/dashboards/manager"
+ "github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -466,7 +469,8 @@ func TestPluginManager_lifecycle_unmanaged(t *testing.T) {
func createManager(t *testing.T, cbs ...func(*PluginManager)) *PluginManager {
t.Helper()
- pm := New(&plugins.Cfg{}, nil, &fakeLoader{})
+ dashboardService := service.ProvideDashboardService(database.ProvideDashboardStore(&sqlstore.SQLStore{}))
+ pm := New(&plugins.Cfg{}, nil, &fakeLoader{}, dashboardService)
for _, cb := range cbs {
cb(pm)
@@ -520,7 +524,8 @@ func newScenario(t *testing.T, managed bool, fn func(t *testing.T, ctx *managerS
cfg.Azure.ManagedIdentityClientId = "client-id"
loader := &fakeLoader{}
- manager := New(cfg, nil, loader)
+ dashboardService := service.ProvideDashboardService(database.ProvideDashboardStore(&sqlstore.SQLStore{}))
+ manager := New(cfg, nil, loader, dashboardService)
manager.pluginLoader = loader
ctx := &managerScenarioCtx{
manager: manager,
diff --git a/pkg/server/wire.go b/pkg/server/wire.go
index 3e6f8dbb1e0..8f57ceb3a29 100644
--- a/pkg/server/wire.go
+++ b/pkg/server/wire.go
@@ -27,13 +27,15 @@ import (
"github.com/grafana/grafana/pkg/plugins/manager"
"github.com/grafana/grafana/pkg/plugins/manager/loader"
"github.com/grafana/grafana/pkg/plugins/plugincontext"
- "github.com/grafana/grafana/pkg/services/accesscontrol/resourceservices"
"github.com/grafana/grafana/pkg/services/alerting"
"github.com/grafana/grafana/pkg/services/auth/jwt"
"github.com/grafana/grafana/pkg/services/cleanup"
"github.com/grafana/grafana/pkg/services/contexthandler"
"github.com/grafana/grafana/pkg/services/dashboardimport"
dashboardimportservice "github.com/grafana/grafana/pkg/services/dashboardimport/service"
+ "github.com/grafana/grafana/pkg/services/dashboards"
+ dashboardstore "github.com/grafana/grafana/pkg/services/dashboards/database"
+ dashboardservice "github.com/grafana/grafana/pkg/services/dashboards/manager"
"github.com/grafana/grafana/pkg/services/dashboardsnapshots"
"github.com/grafana/grafana/pkg/services/datasourceproxy"
"github.com/grafana/grafana/pkg/services/datasources"
@@ -199,7 +201,13 @@ var wireBasicSet = wire.NewSet(
wire.Bind(new(teamguardian.TeamGuardian), new(*teamguardianManager.Service)),
featuremgmt.ProvideManagerService,
featuremgmt.ProvideToggles,
- resourceservices.ProvideResourceServices,
+ dashboardservice.ProvideDashboardService,
+ dashboardservice.ProvideFolderService,
+ dashboardstore.ProvideDashboardStore,
+ wire.Bind(new(dashboards.DashboardService), new(*dashboardservice.DashboardServiceImpl)),
+ wire.Bind(new(dashboards.DashboardProvisioningService), new(*dashboardservice.DashboardServiceImpl)),
+ wire.Bind(new(dashboards.FolderService), new(*dashboardservice.FolderServiceImpl)),
+ wire.Bind(new(dashboards.Store), new(*dashboardstore.DashboardStore)),
dashboardimportservice.ProvideService,
wire.Bind(new(dashboardimport.Service), new(*dashboardimportservice.ImportDashboardService)),
plugindashboards.ProvideService,
diff --git a/pkg/server/wireexts_oss.go b/pkg/server/wireexts_oss.go
index 6c694411593..292b666c3f5 100644
--- a/pkg/server/wireexts_oss.go
+++ b/pkg/server/wireexts_oss.go
@@ -77,6 +77,8 @@ var wireExtsBasicSet = wire.NewSet(
wire.Bind(new(ldap.Groups), new(*ldap.OSSGroups)),
api.ProvideDatasourcePermissionsService,
wire.Bind(new(api.DatasourcePermissionsService), new(*api.OSSDatasourcePermissionsService)),
+ ossaccesscontrol.ProvidePermissionsServices,
+ wire.Bind(new(accesscontrol.PermissionsServices), new(*ossaccesscontrol.PermissionsService)),
)
var wireExtsSet = wire.NewSet(
diff --git a/pkg/services/accesscontrol/accesscontrol.go b/pkg/services/accesscontrol/accesscontrol.go
index c05280f5693..03ae32a23a2 100644
--- a/pkg/services/accesscontrol/accesscontrol.go
+++ b/pkg/services/accesscontrol/accesscontrol.go
@@ -37,7 +37,12 @@ type PermissionsProvider interface {
GetUserPermissions(ctx context.Context, query GetUserPermissionsQuery) ([]*Permission, error)
}
-type ResourcePermissionsService interface {
+type PermissionsServices interface {
+ GetTeamService() PermissionsService
+ GetDataSourceService() PermissionsService
+}
+
+type PermissionsService interface {
// GetPermissions returns all permissions for given resourceID
GetPermissions(ctx context.Context, orgID int64, resourceID string) ([]ResourcePermission, error)
// SetUserPermission sets permission on resource for a user
@@ -146,7 +151,7 @@ func addActionToMetadata(allMetadata map[string]Metadata, action, id string) map
}
// GetResourcesMetadata returns a map of accesscontrol metadata, listing for each resource, users available actions
-func GetResourcesMetadata(ctx context.Context, permissions []*Permission, resource string, resourceIDs map[string]bool) map[string]Metadata {
+func GetResourcesMetadata(ctx context.Context, permissions map[string][]string, resource string, ids map[string]bool) map[string]Metadata {
allScope := GetResourceAllScope(resource)
allIDScope := GetResourceAllIDScope(resource)
@@ -157,16 +162,18 @@ func GetResourcesMetadata(ctx context.Context, permissions []*Permission, resour
// Loop through permissions once
result := map[string]Metadata{}
- for _, p := range permissions {
- if p.Scope == "*" || p.Scope == allScope || p.Scope == allIDScope {
- // Add global action to all resources
- for id := range resourceIDs {
- result = addActionToMetadata(result, p.Action, id)
- }
- } else {
- if len(p.Scope) > idIndex && strings.HasPrefix(p.Scope, idPrefix) && resourceIDs[p.Scope[idIndex:]] {
- // Add action to a specific resource
- result = addActionToMetadata(result, p.Action, p.Scope[idIndex:])
+ for action, scopes := range permissions {
+ for _, scope := range scopes {
+ if scope == "*" || scope == allScope || scope == allIDScope {
+ // Add global action to all resources
+ for id := range ids {
+ result = addActionToMetadata(result, action, id)
+ }
+ } else {
+ if len(scope) > idIndex && strings.HasPrefix(scope, idPrefix) && ids[scope[idIndex:]] {
+ // Add action to a specific resource
+ result = addActionToMetadata(result, action, scope[idIndex:])
+ }
}
}
}
diff --git a/pkg/services/accesscontrol/accesscontrol_bench_test.go b/pkg/services/accesscontrol/accesscontrol_bench_test.go
index 2259508f67e..b6942e664d3 100644
--- a/pkg/services/accesscontrol/accesscontrol_bench_test.go
+++ b/pkg/services/accesscontrol/accesscontrol_bench_test.go
@@ -9,17 +9,17 @@ import (
"github.com/stretchr/testify/assert"
)
-func setupTestEnv(b *testing.B, resourceCount, permissionPerResource int) ([]*Permission, map[string]bool) {
- res := make([]*Permission, resourceCount*permissionPerResource)
+func setupTestEnv(b *testing.B, resourceCount, permissionPerResource int) (map[string][]string, map[string]bool) {
+ res := map[string][]string{}
ids := make(map[string]bool, resourceCount)
- for r := 0; r < resourceCount; r++ {
- for p := 0; p < permissionPerResource; p++ {
- perm := Permission{Action: fmt.Sprintf("resources:action%v", p), Scope: fmt.Sprintf("resources:id:%v", r)}
- id := r*permissionPerResource + p
- res[id] = &perm
+ for p := 0; p < permissionPerResource; p++ {
+ action := fmt.Sprintf("resources:action%v", p)
+ for r := 0; r < resourceCount; r++ {
+ scope := fmt.Sprintf("resources:id:%v", r)
+ res[action] = append(res[action], scope)
+ ids[fmt.Sprintf("%d", r)] = true
}
- ids[fmt.Sprintf("%d", r)] = true
}
return res, ids
@@ -40,23 +40,23 @@ func benchGetMetadata(b *testing.B, resourceCount, permissionPerResource int) {
}
// Lots of permissions
-func BenchmarkGetResourcesMetadata_10_1000(b *testing.B) { benchGetMetadata(b, 10, 1000) } // ~0.0017s/op
-func BenchmarkGetResourcesMetadata_10_10000(b *testing.B) { benchGetMetadata(b, 10, 10000) } // ~0.016s/op
-func BenchmarkGetResourcesMetadata_10_100000(b *testing.B) { benchGetMetadata(b, 10, 100000) } // ~0.17s/op
+func BenchmarkGetResourcesMetadata_10_1000(b *testing.B) { benchGetMetadata(b, 10, 1000) } // ~0.0022s/op
+func BenchmarkGetResourcesMetadata_10_10000(b *testing.B) { benchGetMetadata(b, 10, 10000) } // ~0.019s/op
+func BenchmarkGetResourcesMetadata_10_100000(b *testing.B) { benchGetMetadata(b, 10, 100000) } // ~0.25s/op
func BenchmarkGetResourcesMetadata_10_1000000(b *testing.B) {
if testing.Short() {
b.Skip("Skipping benchmark in short mode")
}
benchGetMetadata(b, 10, 1000000)
-} // ~3.89s/op
+} // ~5.8s/op
// Lots of resources
func BenchmarkGetResourcesMetadata_1000_10(b *testing.B) { benchGetMetadata(b, 1000, 10) } // ~0,0023s/op
-func BenchmarkGetResourcesMetadata_10000_10(b *testing.B) { benchGetMetadata(b, 10000, 10) } // ~0.021s/op
-func BenchmarkGetResourcesMetadata_100000_10(b *testing.B) { benchGetMetadata(b, 100000, 10) } // ~0.22s/op
+func BenchmarkGetResourcesMetadata_10000_10(b *testing.B) { benchGetMetadata(b, 10000, 10) } // ~0.022s/op
+func BenchmarkGetResourcesMetadata_100000_10(b *testing.B) { benchGetMetadata(b, 100000, 10) } // ~0.26s/op
func BenchmarkGetResourcesMetadata_1000000_10(b *testing.B) {
if testing.Short() {
b.Skip("Skipping benchmark in short mode")
}
benchGetMetadata(b, 1000000, 10)
-} // ~2.8s/op
+} // ~4.1s/op
diff --git a/pkg/services/accesscontrol/accesscontrol_test.go b/pkg/services/accesscontrol/accesscontrol_test.go
index e373b08d172..eef8e0b1e11 100644
--- a/pkg/services/accesscontrol/accesscontrol_test.go
+++ b/pkg/services/accesscontrol/accesscontrol_test.go
@@ -12,7 +12,7 @@ func TestGetResourcesMetadata(t *testing.T) {
desc string
resource string
resourcesIDs map[string]bool
- permissions []*Permission
+ permissions map[string][]string
expected map[string]Metadata
}{
{
@@ -24,10 +24,10 @@ func TestGetResourcesMetadata(t *testing.T) {
{
desc: "Should return no permission for resources 1,2,3 given the user has permissions for 4 only",
resource: "resources",
- permissions: []*Permission{
- {Action: "resources:action1", Scope: Scope("resources", "id", "4")},
- {Action: "resources:action2", Scope: Scope("resources", "id", "4")},
- {Action: "resources:action3", Scope: Scope("resources", "id", "4")},
+ permissions: map[string][]string{
+ "resources:action1": {Scope("resources", "id", "4")},
+ "resources:action2": {Scope("resources", "id", "4")},
+ "resources:action3": {Scope("resources", "id", "4")},
},
resourcesIDs: map[string]bool{"1": true, "2": true, "3": true},
expected: map[string]Metadata{},
@@ -35,10 +35,10 @@ func TestGetResourcesMetadata(t *testing.T) {
{
desc: "Should only return permissions for resources 1 and 2, given the user has no permissions for 3",
resource: "resources",
- permissions: []*Permission{
- {Action: "resources:action1", Scope: Scope("resources", "id", "1")},
- {Action: "resources:action2", Scope: Scope("resources", "id", "2")},
- {Action: "resources:action3", Scope: Scope("resources", "id", "2")},
+ permissions: map[string][]string{
+ "resources:action1": {Scope("resources", "id", "1")},
+ "resources:action2": {Scope("resources", "id", "2")},
+ "resources:action3": {Scope("resources", "id", "2")},
},
resourcesIDs: map[string]bool{"1": true, "2": true, "3": true},
expected: map[string]Metadata{
@@ -49,13 +49,13 @@ func TestGetResourcesMetadata(t *testing.T) {
{
desc: "Should return permissions with global scopes for resources 1,2,3",
resource: "resources",
- permissions: []*Permission{
- {Action: "resources:action4", Scope: Scope("resources", "id", "*")},
- {Action: "resources:action5", Scope: Scope("resources", "*")},
- {Action: "resources:action6", Scope: "*"},
- {Action: "resources:action1", Scope: Scope("resources", "id", "1")},
- {Action: "resources:action2", Scope: Scope("resources", "id", "2")},
- {Action: "resources:action3", Scope: Scope("resources", "id", "2")},
+ permissions: map[string][]string{
+ "resources:action1": {Scope("resources", "id", "1")},
+ "resources:action2": {Scope("resources", "id", "2")},
+ "resources:action3": {Scope("resources", "id", "2")},
+ "resources:action4": {Scope("resources", "id", "*")},
+ "resources:action5": {Scope("resources", "*")},
+ "resources:action6": {"*"},
},
resourcesIDs: map[string]bool{"1": true, "2": true, "3": true},
expected: map[string]Metadata{
@@ -67,11 +67,10 @@ func TestGetResourcesMetadata(t *testing.T) {
{
desc: "Should correctly filter out irrelevant permissions for resources 1,2,3",
resource: "resources",
- permissions: []*Permission{
- {Action: "resources:action1", Scope: Scope("resources", "id", "1")},
- {Action: "otherresources:action1", Scope: Scope("resources", "id", "1")},
- {Action: "resources:action2", Scope: Scope("otherresources", "id", "*")},
- {Action: "otherresources:action1", Scope: Scope("otherresources", "id", "*")},
+ permissions: map[string][]string{
+ "resources:action1": {Scope("resources", "id", "1")},
+ "resources:action2": {Scope("otherresources", "id", "*")},
+ "otherresources:action1": {Scope("resources", "id", "1"), Scope("otherresources", "id", "*")},
},
resourcesIDs: map[string]bool{"1": true, "2": true, "3": true},
expected: map[string]Metadata{
@@ -81,9 +80,8 @@ func TestGetResourcesMetadata(t *testing.T) {
{
desc: "Should correctly handle permissions with multilayer scope",
resource: "resources:sub",
- permissions: []*Permission{
- {Action: "resources:action1", Scope: Scope("resources", "sub", "id", "1")},
- {Action: "resources:action1", Scope: Scope("resources", "sub", "id", "123")},
+ permissions: map[string][]string{
+ "resources:action1": {Scope("resources", "sub", "id", "1"), Scope("resources", "sub", "id", "123")},
},
resourcesIDs: map[string]bool{"1": true, "123": true},
expected: map[string]Metadata{
diff --git a/pkg/services/accesscontrol/mock/permissions_services_mock.go b/pkg/services/accesscontrol/mock/permissions_services_mock.go
new file mode 100644
index 00000000000..b5d084bdce9
--- /dev/null
+++ b/pkg/services/accesscontrol/mock/permissions_services_mock.go
@@ -0,0 +1,27 @@
+package mock
+
+import (
+ "github.com/grafana/grafana/pkg/services/accesscontrol"
+)
+
+var _ accesscontrol.PermissionsServices = new(PermissionsServicesMock)
+
+func NewPermissionsServicesMock() *PermissionsServicesMock {
+ return &PermissionsServicesMock{
+ teams: &MockPermissionsService{},
+ datasources: &MockPermissionsService{},
+ }
+}
+
+type PermissionsServicesMock struct {
+ teams *MockPermissionsService
+ datasources *MockPermissionsService
+}
+
+func (p PermissionsServicesMock) GetTeamService() accesscontrol.PermissionsService {
+ return p.teams
+}
+
+func (p PermissionsServicesMock) GetDataSourceService() accesscontrol.PermissionsService {
+ return p.datasources
+}
diff --git a/pkg/services/accesscontrol/mock/service_mock.go b/pkg/services/accesscontrol/mock/service_mock.go
new file mode 100644
index 00000000000..c5f57caf056
--- /dev/null
+++ b/pkg/services/accesscontrol/mock/service_mock.go
@@ -0,0 +1,40 @@
+package mock
+
+import (
+ "context"
+
+ "github.com/stretchr/testify/mock"
+
+ "github.com/grafana/grafana/pkg/services/accesscontrol"
+)
+
+var _ accesscontrol.PermissionsService = new(MockPermissionsService)
+
+type MockPermissionsService struct {
+ mock.Mock
+}
+
+func (m *MockPermissionsService) GetPermissions(ctx context.Context, orgID int64, resourceID string) ([]accesscontrol.ResourcePermission, error) {
+ mockedArgs := m.Called(ctx, orgID, resourceID)
+ return mockedArgs.Get(0).([]accesscontrol.ResourcePermission), mockedArgs.Error(1)
+}
+
+func (m *MockPermissionsService) SetUserPermission(ctx context.Context, orgID int64, user accesscontrol.User, resourceID, permission string) (*accesscontrol.ResourcePermission, error) {
+ mockedArgs := m.Called(ctx, orgID, user, resourceID, permission)
+ return mockedArgs.Get(0).(*accesscontrol.ResourcePermission), mockedArgs.Error(1)
+}
+
+func (m *MockPermissionsService) SetTeamPermission(ctx context.Context, orgID, teamID int64, resourceID, permission string) (*accesscontrol.ResourcePermission, error) {
+ mockedArgs := m.Called(ctx, orgID, teamID, resourceID, permission)
+ return mockedArgs.Get(0).(*accesscontrol.ResourcePermission), mockedArgs.Error(1)
+}
+
+func (m *MockPermissionsService) SetBuiltInRolePermission(ctx context.Context, orgID int64, builtInRole, resourceID, permission string) (*accesscontrol.ResourcePermission, error) {
+ mockedArgs := m.Called(ctx, orgID, builtInRole, resourceID, permission)
+ return mockedArgs.Get(0).(*accesscontrol.ResourcePermission), mockedArgs.Error(1)
+}
+
+func (m *MockPermissionsService) SetPermissions(ctx context.Context, orgID int64, resourceID string, commands ...accesscontrol.SetResourcePermissionCommand) ([]accesscontrol.ResourcePermission, error) {
+ mockedArgs := m.Called(ctx, orgID, resourceID, commands)
+ return mockedArgs.Get(0).([]accesscontrol.ResourcePermission), mockedArgs.Error(1)
+}
diff --git a/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol.go b/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol.go
index efe6dc98118..51a928495c7 100644
--- a/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol.go
+++ b/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol.go
@@ -11,7 +11,6 @@ import (
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/accesscontrol/api"
- "github.com/grafana/grafana/pkg/services/accesscontrol/resourceservices"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/prometheus/client_golang/prometheus"
)
@@ -116,7 +115,7 @@ func (ac *OSSAccessControlService) GetUserPermissions(ctx context.Context, user
OrgID: user.OrgId,
UserID: user.UserId,
Roles: ac.GetUserBuiltInRoles(user),
- Actions: resourceservices.TeamAdminActions,
+ Actions: TeamAdminActions,
})
if err != nil {
return nil, err
diff --git a/pkg/services/accesscontrol/resourceservices/resource_services.go b/pkg/services/accesscontrol/ossaccesscontrol/permissions_services.go
similarity index 57%
rename from pkg/services/accesscontrol/resourceservices/resource_services.go
rename to pkg/services/accesscontrol/ossaccesscontrol/permissions_services.go
index 3cf2adfb95f..578819c5cfa 100644
--- a/pkg/services/accesscontrol/resourceservices/resource_services.go
+++ b/pkg/services/accesscontrol/ossaccesscontrol/permissions_services.go
@@ -1,4 +1,4 @@
-package resourceservices
+package ossaccesscontrol
import (
"context"
@@ -12,23 +12,26 @@ import (
"github.com/grafana/grafana/pkg/services/sqlstore"
)
-func ProvideResourceServices(router routing.RouteRegister, sql *sqlstore.SQLStore, ac accesscontrol.AccessControl, store resourcepermissions.Store) (*ResourceServices, error) {
+func ProvidePermissionsServices(router routing.RouteRegister, sql *sqlstore.SQLStore, ac accesscontrol.AccessControl, store resourcepermissions.Store) (*PermissionsService, error) {
teamPermissions, err := ProvideTeamPermissions(router, sql, ac, store)
if err != nil {
return nil, err
}
- return &ResourceServices{services: map[string]*resourcepermissions.Service{
- "teams": teamPermissions,
- }}, nil
+ return &PermissionsService{teams: teamPermissions, datasources: provideEmptyPermissionsService()}, nil
}
-type ResourceServices struct {
- services map[string]*resourcepermissions.Service
+type PermissionsService struct {
+ teams accesscontrol.PermissionsService
+ datasources accesscontrol.PermissionsService
}
-func (s *ResourceServices) GetTeamService() *resourcepermissions.Service {
- return s.services["teams"]
+func (s *PermissionsService) GetTeamService() accesscontrol.PermissionsService {
+ return s.teams
+}
+
+func (s *PermissionsService) GetDataSourceService() accesscontrol.PermissionsService {
+ return s.datasources
}
var (
@@ -101,3 +104,31 @@ func ProvideTeamPermissions(router routing.RouteRegister, sql *sqlstore.SQLStore
return resourcepermissions.New(options, router, ac, store, sql)
}
+
+func provideEmptyPermissionsService() accesscontrol.PermissionsService {
+ return &emptyPermissionsService{}
+}
+
+var _ accesscontrol.PermissionsService = new(emptyPermissionsService)
+
+type emptyPermissionsService struct{}
+
+func (e emptyPermissionsService) GetPermissions(ctx context.Context, orgID int64, resourceID string) ([]accesscontrol.ResourcePermission, error) {
+ return nil, nil
+}
+
+func (e emptyPermissionsService) SetUserPermission(ctx context.Context, orgID int64, user accesscontrol.User, resourceID, permission string) (*accesscontrol.ResourcePermission, error) {
+ return nil, nil
+}
+
+func (e emptyPermissionsService) SetTeamPermission(ctx context.Context, orgID, teamID int64, resourceID, permission string) (*accesscontrol.ResourcePermission, error) {
+ return nil, nil
+}
+
+func (e emptyPermissionsService) SetBuiltInRolePermission(ctx context.Context, orgID int64, builtInRole string, resourceID string, permission string) (*accesscontrol.ResourcePermission, error) {
+ return nil, nil
+}
+
+func (e emptyPermissionsService) SetPermissions(ctx context.Context, orgID int64, resourceID string, commands ...accesscontrol.SetResourcePermissionCommand) ([]accesscontrol.ResourcePermission, error) {
+ return nil, nil
+}
diff --git a/pkg/services/accesscontrol/resourcepermissions/service_mock.go b/pkg/services/accesscontrol/resourcepermissions/service_mock.go
deleted file mode 100644
index 41f78f4efcb..00000000000
--- a/pkg/services/accesscontrol/resourcepermissions/service_mock.go
+++ /dev/null
@@ -1,40 +0,0 @@
-package resourcepermissions
-
-import (
- "context"
-
- "github.com/stretchr/testify/mock"
-
- "github.com/grafana/grafana/pkg/services/accesscontrol"
-)
-
-var _ accesscontrol.ResourcePermissionsService = new(MockService)
-
-type MockService struct {
- mock.Mock
-}
-
-func (m *MockService) GetPermissions(ctx context.Context, orgID int64, resourceID string) ([]accesscontrol.ResourcePermission, error) {
- mockedArgs := m.Called(ctx, orgID, resourceID)
- return mockedArgs.Get(0).([]accesscontrol.ResourcePermission), mockedArgs.Error(1)
-}
-
-func (m *MockService) SetUserPermission(ctx context.Context, orgID int64, user accesscontrol.User, resourceID, permission string) (*accesscontrol.ResourcePermission, error) {
- mockedArgs := m.Called(ctx, orgID, user, resourceID, permission)
- return mockedArgs.Get(0).(*accesscontrol.ResourcePermission), mockedArgs.Error(1)
-}
-
-func (m *MockService) SetTeamPermission(ctx context.Context, orgID, teamID int64, resourceID, permission string) (*accesscontrol.ResourcePermission, error) {
- mockedArgs := m.Called(ctx, orgID, teamID, resourceID, permission)
- return mockedArgs.Get(0).(*accesscontrol.ResourcePermission), mockedArgs.Error(1)
-}
-
-func (m *MockService) SetBuiltInRolePermission(ctx context.Context, orgID int64, builtInRole, resourceID, permission string) (*accesscontrol.ResourcePermission, error) {
- mockedArgs := m.Called(ctx, orgID, builtInRole, resourceID, permission)
- return mockedArgs.Get(0).(*accesscontrol.ResourcePermission), mockedArgs.Error(1)
-}
-
-func (m *MockService) SetPermissions(ctx context.Context, orgID int64, resourceID string, commands ...accesscontrol.SetResourcePermissionCommand) ([]accesscontrol.ResourcePermission, error) {
- mockedArgs := m.Called(ctx, orgID, resourceID, commands)
- return mockedArgs.Get(0).([]accesscontrol.ResourcePermission), mockedArgs.Error(1)
-}
diff --git a/pkg/services/dashboardimport/service/service.go b/pkg/services/dashboardimport/service/service.go
index 1fcfeb3d836..eead5a7b046 100644
--- a/pkg/services/dashboardimport/service/service.go
+++ b/pkg/services/dashboardimport/service/service.go
@@ -13,16 +13,15 @@ import (
"github.com/grafana/grafana/pkg/services/librarypanels"
"github.com/grafana/grafana/pkg/services/quota"
"github.com/grafana/grafana/pkg/services/schemaloader"
- "github.com/grafana/grafana/pkg/services/sqlstore"
)
-func ProvideService(sqlStore *sqlstore.SQLStore, routeRegister routing.RouteRegister,
+func ProvideService(routeRegister routing.RouteRegister,
quotaService *quota.QuotaService, schemaLoaderService *schemaloader.SchemaLoaderService,
pluginDashboardManager plugins.PluginDashboardManager, pluginStore plugins.Store,
- libraryPanelService librarypanels.Service) *ImportDashboardService {
+ libraryPanelService librarypanels.Service, dashboardService dashboards.DashboardService) *ImportDashboardService {
s := &ImportDashboardService{
pluginDashboardManager: pluginDashboardManager,
- dashboardService: dashboards.NewService(sqlStore),
+ dashboardService: dashboardService,
libraryPanelService: libraryPanelService,
}
diff --git a/pkg/services/dashboards/acl_service.go b/pkg/services/dashboards/acl_service.go
deleted file mode 100644
index 0a9b7b4f024..00000000000
--- a/pkg/services/dashboards/acl_service.go
+++ /dev/null
@@ -1,51 +0,0 @@
-package dashboards
-
-import (
- "context"
- "time"
-
- "github.com/grafana/grafana/pkg/models"
-)
-
-func (dr *dashboardServiceImpl) MakeUserAdmin(ctx context.Context, orgID int64, userID int64, dashboardID int64, setViewAndEditPermissions bool) error {
- rtEditor := models.ROLE_EDITOR
- rtViewer := models.ROLE_VIEWER
-
- items := []*models.DashboardAcl{
- {
- OrgID: orgID,
- DashboardID: dashboardID,
- UserID: userID,
- Permission: models.PERMISSION_ADMIN,
- Created: time.Now(),
- Updated: time.Now(),
- },
- }
-
- if setViewAndEditPermissions {
- items = append(items,
- &models.DashboardAcl{
- OrgID: orgID,
- DashboardID: dashboardID,
- Role: &rtEditor,
- Permission: models.PERMISSION_EDIT,
- Created: time.Now(),
- Updated: time.Now(),
- },
- &models.DashboardAcl{
- OrgID: orgID,
- DashboardID: dashboardID,
- Role: &rtViewer,
- Permission: models.PERMISSION_VIEW,
- Created: time.Now(),
- Updated: time.Now(),
- },
- )
- }
-
- if err := dr.dashboardStore.UpdateDashboardACLCtx(ctx, dashboardID, items); err != nil {
- return err
- }
-
- return nil
-}
diff --git a/pkg/services/dashboards/dashboard.go b/pkg/services/dashboards/dashboard.go
new file mode 100644
index 00000000000..f3cca22bfe4
--- /dev/null
+++ b/pkg/services/dashboards/dashboard.go
@@ -0,0 +1,47 @@
+package dashboards
+
+import (
+ "context"
+
+ "github.com/grafana/grafana/pkg/models"
+)
+
+// DashboardService is a service for operating on dashboards.
+type DashboardService interface {
+ SaveDashboard(ctx context.Context, dto *SaveDashboardDTO, allowUiUpdate bool) (*models.Dashboard, error)
+ ImportDashboard(ctx context.Context, dto *SaveDashboardDTO) (*models.Dashboard, error)
+ DeleteDashboard(ctx context.Context, dashboardId int64, orgId int64) error
+ MakeUserAdmin(ctx context.Context, orgID int64, userID, dashboardID int64, setViewAndEditPermissions bool) error
+ BuildSaveDashboardCommand(ctx context.Context, dto *SaveDashboardDTO, shouldValidateAlerts bool, validateProvisionedDashboard bool) (*models.SaveDashboardCommand, error)
+ UpdateDashboardACL(ctx context.Context, uid int64, items []*models.DashboardAcl) error
+}
+
+//go:generate mockery --name DashboardProvisioningService --structname FakeDashboardProvisioning --inpackage --filename dashboard_provisioning_mock.go
+// DashboardProvisioningService is a service for operating on provisioned dashboards.
+type DashboardProvisioningService interface {
+ SaveProvisionedDashboard(ctx context.Context, dto *SaveDashboardDTO, provisioning *models.DashboardProvisioning) (*models.Dashboard, error)
+ SaveFolderForProvisionedDashboards(context.Context, *SaveDashboardDTO) (*models.Dashboard, error)
+ GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error)
+ GetProvisionedDashboardDataByDashboardUID(orgID int64, dashboardUID string) (*models.DashboardProvisioning, error)
+ GetProvisionedDashboardDataByDashboardID(dashboardID int64) (*models.DashboardProvisioning, error)
+ UnprovisionDashboard(ctx context.Context, dashboardID int64) error
+ DeleteProvisionedDashboard(ctx context.Context, dashboardID int64, orgID int64) error
+}
+
+//go:generate mockery --name Store --structname FakeDashboardStore --output database --outpkg database --filename database_mock.go
+// Store is a dashboard store.
+type Store interface {
+ // ValidateDashboardBeforeSave validates a dashboard before save.
+ ValidateDashboardBeforeSave(dashboard *models.Dashboard, overwrite bool) (bool, error)
+ // GetFolderByTitle retrieves a dashboard by its title and is used by unified alerting
+ GetFolderByTitle(orgID int64, title string) (*models.Dashboard, error)
+ GetProvisionedDataByDashboardID(dashboardID int64) (*models.DashboardProvisioning, error)
+ GetProvisionedDataByDashboardUID(orgID int64, dashboardUID string) (*models.DashboardProvisioning, error)
+ GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error)
+ SaveProvisionedDashboard(cmd models.SaveDashboardCommand, provisioning *models.DashboardProvisioning) (*models.Dashboard, error)
+ SaveDashboard(cmd models.SaveDashboardCommand) (*models.Dashboard, error)
+ UpdateDashboardACL(ctx context.Context, uid int64, items []*models.DashboardAcl) error
+ // SaveAlerts saves dashboard alerts.
+ SaveAlerts(ctx context.Context, dashID int64, alerts []*models.Alert) error
+ UnprovisionDashboard(ctx context.Context, id int64) error
+}
diff --git a/pkg/services/dashboards/dashboard_provisioning_mock.go b/pkg/services/dashboards/dashboard_provisioning_mock.go
new file mode 100644
index 00000000000..46ae5a09745
--- /dev/null
+++ b/pkg/services/dashboards/dashboard_provisioning_mock.go
@@ -0,0 +1,158 @@
+// Code generated by mockery v2.10.0. DO NOT EDIT.
+
+package dashboards
+
+import (
+ context "context"
+
+ models "github.com/grafana/grafana/pkg/models"
+ mock "github.com/stretchr/testify/mock"
+)
+
+// FakeDashboardProvisioning is an autogenerated mock type for the DashboardProvisioningService type
+type FakeDashboardProvisioning struct {
+ mock.Mock
+}
+
+// DeleteProvisionedDashboard provides a mock function with given fields: ctx, dashboardID, orgID
+func (_m *FakeDashboardProvisioning) DeleteProvisionedDashboard(ctx context.Context, dashboardID int64, orgID int64) error {
+ ret := _m.Called(ctx, dashboardID, orgID)
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(context.Context, int64, int64) error); ok {
+ r0 = rf(ctx, dashboardID, orgID)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// GetProvisionedDashboardData provides a mock function with given fields: name
+func (_m *FakeDashboardProvisioning) GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error) {
+ ret := _m.Called(name)
+
+ var r0 []*models.DashboardProvisioning
+ if rf, ok := ret.Get(0).(func(string) []*models.DashboardProvisioning); ok {
+ r0 = rf(name)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).([]*models.DashboardProvisioning)
+ }
+ }
+
+ var r1 error
+ if rf, ok := ret.Get(1).(func(string) error); ok {
+ r1 = rf(name)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// GetProvisionedDashboardDataByDashboardID provides a mock function with given fields: dashboardID
+func (_m *FakeDashboardProvisioning) GetProvisionedDashboardDataByDashboardID(dashboardID int64) (*models.DashboardProvisioning, error) {
+ ret := _m.Called(dashboardID)
+
+ var r0 *models.DashboardProvisioning
+ if rf, ok := ret.Get(0).(func(int64) *models.DashboardProvisioning); ok {
+ r0 = rf(dashboardID)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(*models.DashboardProvisioning)
+ }
+ }
+
+ var r1 error
+ if rf, ok := ret.Get(1).(func(int64) error); ok {
+ r1 = rf(dashboardID)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// GetProvisionedDashboardDataByDashboardUID provides a mock function with given fields: orgID, dashboardUID
+func (_m *FakeDashboardProvisioning) GetProvisionedDashboardDataByDashboardUID(orgID int64, dashboardUID string) (*models.DashboardProvisioning, error) {
+ ret := _m.Called(orgID, dashboardUID)
+
+ var r0 *models.DashboardProvisioning
+ if rf, ok := ret.Get(0).(func(int64, string) *models.DashboardProvisioning); ok {
+ r0 = rf(orgID, dashboardUID)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(*models.DashboardProvisioning)
+ }
+ }
+
+ var r1 error
+ if rf, ok := ret.Get(1).(func(int64, string) error); ok {
+ r1 = rf(orgID, dashboardUID)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// SaveFolderForProvisionedDashboards provides a mock function with given fields: _a0, _a1
+func (_m *FakeDashboardProvisioning) SaveFolderForProvisionedDashboards(_a0 context.Context, _a1 *SaveDashboardDTO) (*models.Dashboard, error) {
+ ret := _m.Called(_a0, _a1)
+
+ var r0 *models.Dashboard
+ if rf, ok := ret.Get(0).(func(context.Context, *SaveDashboardDTO) *models.Dashboard); ok {
+ r0 = rf(_a0, _a1)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(*models.Dashboard)
+ }
+ }
+
+ var r1 error
+ if rf, ok := ret.Get(1).(func(context.Context, *SaveDashboardDTO) error); ok {
+ r1 = rf(_a0, _a1)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// SaveProvisionedDashboard provides a mock function with given fields: ctx, dto, provisioning
+func (_m *FakeDashboardProvisioning) SaveProvisionedDashboard(ctx context.Context, dto *SaveDashboardDTO, provisioning *models.DashboardProvisioning) (*models.Dashboard, error) {
+ ret := _m.Called(ctx, dto, provisioning)
+
+ var r0 *models.Dashboard
+ if rf, ok := ret.Get(0).(func(context.Context, *SaveDashboardDTO, *models.DashboardProvisioning) *models.Dashboard); ok {
+ r0 = rf(ctx, dto, provisioning)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(*models.Dashboard)
+ }
+ }
+
+ var r1 error
+ if rf, ok := ret.Get(1).(func(context.Context, *SaveDashboardDTO, *models.DashboardProvisioning) error); ok {
+ r1 = rf(ctx, dto, provisioning)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// UnprovisionDashboard provides a mock function with given fields: ctx, dashboardID
+func (_m *FakeDashboardProvisioning) UnprovisionDashboard(ctx context.Context, dashboardID int64) error {
+ ret := _m.Called(ctx, dashboardID)
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(context.Context, int64) error); ok {
+ r0 = rf(ctx, dashboardID)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
diff --git a/pkg/services/dashboards/dashboard_service_mock.go b/pkg/services/dashboards/dashboard_service_mock.go
new file mode 100644
index 00000000000..9d6331879cb
--- /dev/null
+++ b/pkg/services/dashboards/dashboard_service_mock.go
@@ -0,0 +1,44 @@
+package dashboards
+
+import (
+ "context"
+
+ "github.com/grafana/grafana/pkg/models"
+)
+
+type FakeDashboardService struct {
+ DashboardService
+
+ SaveDashboardResult *models.Dashboard
+ SaveDashboardError error
+ SavedDashboards []*SaveDashboardDTO
+ ProvisionedDashData *models.DashboardProvisioning
+}
+
+func (s *FakeDashboardService) SaveDashboard(ctx context.Context, dto *SaveDashboardDTO, allowUiUpdate bool) (*models.Dashboard, error) {
+ s.SavedDashboards = append(s.SavedDashboards, dto)
+
+ if s.SaveDashboardResult == nil && s.SaveDashboardError == nil {
+ s.SaveDashboardResult = dto.Dashboard
+ }
+
+ return s.SaveDashboardResult, s.SaveDashboardError
+}
+
+func (s *FakeDashboardService) ImportDashboard(ctx context.Context, dto *SaveDashboardDTO) (*models.Dashboard, error) {
+ return s.SaveDashboard(ctx, dto, true)
+}
+
+func (s *FakeDashboardService) DeleteDashboard(ctx context.Context, dashboardId int64, orgId int64) error {
+ for index, dash := range s.SavedDashboards {
+ if dash.Dashboard.Id == dashboardId && dash.OrgId == orgId {
+ s.SavedDashboards = append(s.SavedDashboards[:index], s.SavedDashboards[index+1:]...)
+ break
+ }
+ }
+ return nil
+}
+
+func (s *FakeDashboardService) GetProvisionedDashboardDataByDashboardID(id int64) (*models.DashboardProvisioning, error) {
+ return s.ProvisionedDashData, nil
+}
diff --git a/pkg/services/dashboards/database/database.go b/pkg/services/dashboards/database/database.go
new file mode 100644
index 00000000000..60b0b44d5f1
--- /dev/null
+++ b/pkg/services/dashboards/database/database.go
@@ -0,0 +1,611 @@
+package database
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/grafana/grafana/pkg/infra/log"
+ "github.com/grafana/grafana/pkg/infra/metrics"
+ "github.com/grafana/grafana/pkg/models"
+ "github.com/grafana/grafana/pkg/services/sqlstore"
+ "github.com/grafana/grafana/pkg/services/sqlstore/migrator"
+ "github.com/grafana/grafana/pkg/util"
+)
+
+type DashboardStore struct {
+ sqlStore *sqlstore.SQLStore
+ log log.Logger
+}
+
+func ProvideDashboardStore(sqlStore *sqlstore.SQLStore) *DashboardStore {
+ return &DashboardStore{sqlStore: sqlStore, log: log.New("dashboard-store")}
+}
+
+func (d *DashboardStore) ValidateDashboardBeforeSave(dashboard *models.Dashboard, overwrite bool) (bool, error) {
+ isParentFolderChanged := false
+ err := d.sqlStore.WithTransactionalDbSession(context.Background(), func(sess *sqlstore.DBSession) error {
+ var err error
+ isParentFolderChanged, err = getExistingDashboardByIdOrUidForUpdate(sess, dashboard, d.sqlStore.Dialect, overwrite)
+ if err != nil {
+ return err
+ }
+
+ isParentFolderChanged, err = getExistingDashboardByTitleAndFolder(sess, dashboard, d.sqlStore.Dialect, overwrite,
+ isParentFolderChanged)
+ if err != nil {
+ return err
+ }
+
+ return nil
+ })
+ if err != nil {
+ return false, err
+ }
+
+ return isParentFolderChanged, nil
+}
+
+func (d *DashboardStore) GetFolderByTitle(orgID int64, title string) (*models.Dashboard, error) {
+ if title == "" {
+ return nil, models.ErrDashboardIdentifierNotSet
+ }
+
+ // there is a unique constraint on org_id, folder_id, title
+ // there are no nested folders so the parent folder id is always 0
+ dashboard := models.Dashboard{OrgId: orgID, FolderId: 0, Title: title}
+ err := d.sqlStore.WithTransactionalDbSession(context.Background(), func(sess *sqlstore.DBSession) error {
+ has, err := sess.Table(&models.Dashboard{}).Where("is_folder = " + d.sqlStore.Dialect.BooleanStr(true)).Where("folder_id=0").Get(&dashboard)
+ if err != nil {
+ return err
+ }
+ if !has {
+ return models.ErrDashboardNotFound
+ }
+ dashboard.SetId(dashboard.Id)
+ dashboard.SetUid(dashboard.Uid)
+ return nil
+ })
+ return &dashboard, err
+}
+
+func (d *DashboardStore) GetProvisionedDataByDashboardID(dashboardID int64) (*models.DashboardProvisioning, error) {
+ var data models.DashboardProvisioning
+ err := d.sqlStore.WithTransactionalDbSession(context.Background(), func(sess *sqlstore.DBSession) error {
+ _, err := sess.Where("dashboard_id = ?", dashboardID).Get(&data)
+ return err
+ })
+
+ if data.DashboardId == 0 {
+ return nil, nil
+ }
+ return &data, err
+}
+
+func (d *DashboardStore) GetProvisionedDataByDashboardUID(orgID int64, dashboardUID string) (*models.DashboardProvisioning, error) {
+ var provisionedDashboard models.DashboardProvisioning
+ err := d.sqlStore.WithTransactionalDbSession(context.Background(), func(sess *sqlstore.DBSession) error {
+ var dashboard models.Dashboard
+ exists, err := sess.Where("org_id = ? AND uid = ?", orgID, dashboardUID).Get(&dashboard)
+ if err != nil {
+ return err
+ }
+ if !exists {
+ return models.
+ ErrDashboardNotFound
+ }
+ exists, err = sess.Where("dashboard_id = ?", dashboard.Id).Get(&provisionedDashboard)
+ if err != nil {
+ return err
+ }
+ if !exists {
+ return models.ErrProvisionedDashboardNotFound
+ }
+ return nil
+ })
+ return &provisionedDashboard, err
+}
+
+func (d *DashboardStore) GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error) {
+ var result []*models.DashboardProvisioning
+ err := d.sqlStore.WithTransactionalDbSession(context.Background(), func(sess *sqlstore.DBSession) error {
+ return sess.Where("name = ?", name).Find(&result)
+ })
+ return result, err
+}
+
+func (d *DashboardStore) SaveProvisionedDashboard(cmd models.SaveDashboardCommand, provisioning *models.DashboardProvisioning) (*models.Dashboard, error) {
+ err := d.sqlStore.WithTransactionalDbSession(context.Background(), func(sess *sqlstore.DBSession) error {
+ if err := saveDashboard(sess, &cmd); err != nil {
+ return err
+ }
+
+ if provisioning.Updated == 0 {
+ provisioning.Updated = cmd.Result.Updated.Unix()
+ }
+
+ return saveProvisionedData(sess, provisioning, cmd.Result)
+ })
+
+ return cmd.Result, err
+}
+
+func (d *DashboardStore) SaveDashboard(cmd models.SaveDashboardCommand) (*models.Dashboard, error) {
+ err := d.sqlStore.WithTransactionalDbSession(context.Background(), func(sess *sqlstore.DBSession) error {
+ return saveDashboard(sess, &cmd)
+ })
+ return cmd.Result, err
+}
+
+func (d *DashboardStore) UpdateDashboardACL(ctx context.Context, dashboardID int64, items []*models.DashboardAcl) error {
+ return d.sqlStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error {
+ // delete existing items
+ _, 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 {
+ 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
+ })
+}
+
+func (d *DashboardStore) SaveAlerts(ctx context.Context, dashID int64, alerts []*models.Alert) error {
+ return d.sqlStore.WithTransactionalDbSession(context.Background(), func(sess *sqlstore.DBSession) error {
+ existingAlerts, err := GetAlertsByDashboardId2(dashID, sess)
+ if err != nil {
+ return err
+ }
+
+ if err := updateAlerts(existingAlerts, alerts, sess, d.log); err != nil {
+ return err
+ }
+
+ if err := deleteMissingAlerts(existingAlerts, alerts, sess, d.log); err != nil {
+ return err
+ }
+
+ return nil
+ })
+}
+
+// UnprovisionDashboard removes row in dashboard_provisioning for the dashboard making it seem as if manually created.
+// The dashboard will still have `created_by = -1` to see it was not created by any particular user.
+func (d *DashboardStore) UnprovisionDashboard(ctx context.Context, id int64) error {
+ return d.sqlStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error {
+ _, err := sess.Where("dashboard_id = ?", id).Delete(&models.DashboardProvisioning{})
+ return err
+ })
+}
+
+func getExistingDashboardByIdOrUidForUpdate(sess *sqlstore.DBSession, dash *models.Dashboard, dialect migrator.Dialect, overwrite bool) (bool, error) {
+ dashWithIdExists := false
+ isParentFolderChanged := false
+ var existingById models.Dashboard
+
+ if dash.Id > 0 {
+ var err error
+ dashWithIdExists, err = sess.Where("id=? AND org_id=?", dash.Id, dash.OrgId).Get(&existingById)
+ if err != nil {
+ return false, fmt.Errorf("SQL query for existing dashboard by ID failed: %w", err)
+ }
+
+ if !dashWithIdExists {
+ return false, models.ErrDashboardNotFound
+ }
+
+ if dash.Uid == "" {
+ dash.SetUid(existingById.Uid)
+ }
+ }
+
+ dashWithUidExists := false
+ var existingByUid models.Dashboard
+
+ if dash.Uid != "" {
+ var err error
+ dashWithUidExists, err = sess.Where("org_id=? AND uid=?", dash.OrgId, dash.Uid).Get(&existingByUid)
+ if err != nil {
+ return false, fmt.Errorf("SQL query for existing dashboard by UID failed: %w", err)
+ }
+ }
+
+ if dash.FolderId > 0 {
+ var existingFolder models.Dashboard
+ folderExists, err := sess.Where("org_id=? AND id=? AND is_folder=?", dash.OrgId, dash.FolderId,
+ dialect.BooleanStr(true)).Get(&existingFolder)
+ if err != nil {
+ return false, fmt.Errorf("SQL query for folder failed: %w", err)
+ }
+
+ if !folderExists {
+ return false, models.ErrDashboardFolderNotFound
+ }
+ }
+
+ if !dashWithIdExists && !dashWithUidExists {
+ return false, nil
+ }
+
+ if dashWithIdExists && dashWithUidExists && existingById.Id != existingByUid.Id {
+ return false, models.ErrDashboardWithSameUIDExists
+ }
+
+ existing := existingById
+
+ if !dashWithIdExists && dashWithUidExists {
+ dash.SetId(existingByUid.Id)
+ dash.SetUid(existingByUid.Uid)
+ existing = existingByUid
+
+ if !dash.IsFolder {
+ isParentFolderChanged = true
+ }
+ }
+
+ if (existing.IsFolder && !dash.IsFolder) ||
+ (!existing.IsFolder && dash.IsFolder) {
+ return isParentFolderChanged, models.ErrDashboardTypeMismatch
+ }
+
+ if !dash.IsFolder && dash.FolderId != existing.FolderId {
+ isParentFolderChanged = true
+ }
+
+ // check for is someone else has written in between
+ if dash.Version != existing.Version {
+ if overwrite {
+ dash.SetVersion(existing.Version)
+ } else {
+ return isParentFolderChanged, models.ErrDashboardVersionMismatch
+ }
+ }
+
+ // do not allow plugin dashboard updates without overwrite flag
+ if existing.PluginId != "" && !overwrite {
+ return isParentFolderChanged, models.UpdatePluginDashboardError{PluginId: existing.PluginId}
+ }
+
+ return isParentFolderChanged, nil
+}
+
+func getExistingDashboardByTitleAndFolder(sess *sqlstore.DBSession, dash *models.Dashboard, dialect migrator.Dialect, overwrite,
+ isParentFolderChanged bool) (bool, error) {
+ var existing models.Dashboard
+ exists, err := sess.Where("org_id=? AND slug=? AND (is_folder=? OR folder_id=?)", dash.OrgId, dash.Slug,
+ dialect.BooleanStr(true), dash.FolderId).Get(&existing)
+ if err != nil {
+ return isParentFolderChanged, fmt.Errorf("SQL query for existing dashboard by org ID or folder ID failed: %w", err)
+ }
+
+ if exists && dash.Id != existing.Id {
+ if existing.IsFolder && !dash.IsFolder {
+ return isParentFolderChanged, models.ErrDashboardWithSameNameAsFolder
+ }
+
+ if !existing.IsFolder && dash.IsFolder {
+ return isParentFolderChanged, models.ErrDashboardFolderWithSameNameAsDashboard
+ }
+
+ if !dash.IsFolder && (dash.FolderId != existing.FolderId || dash.Id == 0) {
+ isParentFolderChanged = true
+ }
+
+ if overwrite {
+ dash.SetId(existing.Id)
+ dash.SetUid(existing.Uid)
+ dash.SetVersion(existing.Version)
+ } else {
+ return isParentFolderChanged, models.ErrDashboardWithSameNameInFolderExists
+ }
+ }
+
+ return isParentFolderChanged, nil
+}
+
+func saveDashboard(sess *sqlstore.DBSession, cmd *models.SaveDashboardCommand) error {
+ dash := cmd.GetDashboardModel()
+
+ userId := cmd.UserId
+
+ if userId == 0 {
+ userId = -1
+ }
+
+ if dash.Id > 0 {
+ var existing models.Dashboard
+ dashWithIdExists, err := sess.Where("id=? AND org_id=?", dash.Id, dash.OrgId).Get(&existing)
+ if err != nil {
+ return err
+ }
+ if !dashWithIdExists {
+ return models.ErrDashboardNotFound
+ }
+
+ // check for is someone else has written in between
+ if dash.Version != existing.Version {
+ if cmd.Overwrite {
+ dash.SetVersion(existing.Version)
+ } else {
+ return models.ErrDashboardVersionMismatch
+ }
+ }
+
+ // do not allow plugin dashboard updates without overwrite flag
+ if existing.PluginId != "" && !cmd.Overwrite {
+ return models.UpdatePluginDashboardError{PluginId: existing.PluginId}
+ }
+ }
+
+ if dash.Uid == "" {
+ uid, err := generateNewDashboardUid(sess, dash.OrgId)
+ if err != nil {
+ return err
+ }
+ dash.SetUid(uid)
+ }
+
+ parentVersion := dash.Version
+ var affectedRows int64
+ var err error
+
+ if dash.Id == 0 {
+ dash.SetVersion(1)
+ dash.Created = time.Now()
+ dash.CreatedBy = userId
+ dash.Updated = time.Now()
+ dash.UpdatedBy = userId
+ metrics.MApiDashboardInsert.Inc()
+ affectedRows, err = sess.Insert(dash)
+ } else {
+ dash.SetVersion(dash.Version + 1)
+
+ if !cmd.UpdatedAt.IsZero() {
+ dash.Updated = cmd.UpdatedAt
+ } else {
+ dash.Updated = time.Now()
+ }
+
+ dash.UpdatedBy = userId
+
+ affectedRows, err = sess.MustCols("folder_id").ID(dash.Id).Update(dash)
+ }
+
+ if err != nil {
+ return err
+ }
+
+ if affectedRows == 0 {
+ return models.ErrDashboardNotFound
+ }
+
+ dashVersion := &models.DashboardVersion{
+ DashboardId: dash.Id,
+ ParentVersion: parentVersion,
+ RestoredFrom: cmd.RestoredFrom,
+ Version: dash.Version,
+ Created: time.Now(),
+ CreatedBy: dash.UpdatedBy,
+ Message: cmd.Message,
+ Data: dash.Data,
+ }
+
+ // insert version entry
+ if affectedRows, err = sess.Insert(dashVersion); err != nil {
+ return err
+ } else if affectedRows == 0 {
+ return models.ErrDashboardNotFound
+ }
+
+ // delete existing tags
+ _, err = sess.Exec("DELETE FROM dashboard_tag WHERE dashboard_id=?", dash.Id)
+ if err != nil {
+ return err
+ }
+
+ // insert new tags
+ tags := dash.GetTags()
+ if len(tags) > 0 {
+ for _, tag := range tags {
+ if _, err := sess.Insert(&sqlstore.DashboardTag{DashboardId: dash.Id, Term: tag}); err != nil {
+ return err
+ }
+ }
+ }
+
+ cmd.Result = dash
+
+ return nil
+}
+
+func generateNewDashboardUid(sess *sqlstore.DBSession, orgId int64) (string, error) {
+ for i := 0; i < 3; i++ {
+ uid := util.GenerateShortUID()
+
+ exists, err := sess.Where("org_id=? AND uid=?", orgId, uid).Get(&models.Dashboard{})
+ if err != nil {
+ return "", err
+ }
+
+ if !exists {
+ return uid, nil
+ }
+ }
+
+ return "", models.ErrDashboardFailedGenerateUniqueUid
+}
+
+func saveProvisionedData(sess *sqlstore.DBSession, provisioning *models.DashboardProvisioning, dashboard *models.Dashboard) error {
+ result := &models.DashboardProvisioning{}
+
+ exist, err := sess.Where("dashboard_id=? AND name = ?", dashboard.Id, provisioning.Name).Get(result)
+ if err != nil {
+ return err
+ }
+
+ provisioning.Id = result.Id
+ provisioning.DashboardId = dashboard.Id
+
+ if exist {
+ _, err = sess.ID(result.Id).Update(provisioning)
+ } else {
+ _, err = sess.Insert(provisioning)
+ }
+
+ return err
+}
+
+func GetAlertsByDashboardId2(dashboardId int64, sess *sqlstore.DBSession) ([]*models.Alert, error) {
+ alerts := make([]*models.Alert, 0)
+ err := sess.Where("dashboard_id = ?", dashboardId).Find(&alerts)
+
+ if err != nil {
+ return []*models.Alert{}, err
+ }
+
+ return alerts, nil
+}
+
+func updateAlerts(existingAlerts []*models.Alert, alerts []*models.Alert, sess *sqlstore.DBSession, log log.Logger) error {
+ for _, alert := range alerts {
+ update := false
+ var alertToUpdate *models.Alert
+
+ for _, k := range existingAlerts {
+ if alert.PanelId == k.PanelId {
+ update = true
+ alert.Id = k.Id
+ alertToUpdate = k
+ break
+ }
+ }
+
+ if update {
+ if alertToUpdate.ContainsUpdates(alert) {
+ alert.Updated = time.Now()
+ alert.State = alertToUpdate.State
+ sess.MustCols("message", "for")
+
+ _, err := sess.ID(alert.Id).Update(alert)
+ if err != nil {
+ return err
+ }
+
+ log.Debug("Alert updated", "name", alert.Name, "id", alert.Id)
+ }
+ } else {
+ alert.Updated = time.Now()
+ alert.Created = time.Now()
+ alert.State = models.AlertStateUnknown
+ alert.NewStateDate = time.Now()
+
+ _, err := sess.Insert(alert)
+ if err != nil {
+ return err
+ }
+
+ log.Debug("Alert inserted", "name", alert.Name, "id", alert.Id)
+ }
+ tags := alert.GetTagsFromSettings()
+ if _, err := sess.Exec("DELETE FROM alert_rule_tag WHERE alert_id = ?", alert.Id); err != nil {
+ return err
+ }
+ if tags != nil {
+ tags, err := EnsureTagsExist(sess, tags)
+ if err != nil {
+ return err
+ }
+ for _, tag := range tags {
+ if _, err := sess.Exec("INSERT INTO alert_rule_tag (alert_id, tag_id) VALUES(?,?)", alert.Id, tag.Id); err != nil {
+ return err
+ }
+ }
+ }
+ }
+
+ return nil
+}
+
+func deleteMissingAlerts(alerts []*models.Alert, existingAlerts []*models.Alert, sess *sqlstore.DBSession, log log.Logger) error {
+ for _, missingAlert := range alerts {
+ missing := true
+
+ for _, k := range existingAlerts {
+ if missingAlert.PanelId == k.PanelId {
+ missing = false
+ break
+ }
+ }
+
+ if missing {
+ if err := deleteAlertByIdInternal(missingAlert.Id, "Removed from dashboard", sess, log); err != nil {
+ // No use trying to delete more, since we're in a transaction and it will be
+ // rolled back on error.
+ return err
+ }
+ }
+ }
+
+ return nil
+}
+
+func deleteAlertByIdInternal(alertId int64, reason string, sess *sqlstore.DBSession, log log.Logger) error {
+ log.Debug("Deleting alert", "id", alertId, "reason", reason)
+
+ if _, err := sess.Exec("DELETE FROM alert WHERE id = ?", alertId); err != nil {
+ return err
+ }
+
+ if _, err := sess.Exec("DELETE FROM annotation WHERE alert_id = ?", alertId); err != nil {
+ return err
+ }
+
+ if _, err := sess.Exec("DELETE FROM alert_notification_state WHERE alert_id = ?", alertId); err != nil {
+ return err
+ }
+
+ if _, err := sess.Exec("DELETE FROM alert_rule_tag WHERE alert_id = ?", alertId); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func EnsureTagsExist(sess *sqlstore.DBSession, tags []*models.Tag) ([]*models.Tag, error) {
+ for _, tag := range tags {
+ var existingTag models.Tag
+
+ // check if it exists
+ exists, err := sess.Table("tag").Where("`key`=? AND `value`=?", tag.Key, tag.Value).Get(&existingTag)
+ if err != nil {
+ return nil, err
+ }
+ if exists {
+ tag.Id = existingTag.Id
+ } else {
+ _, err := sess.Table("tag").Insert(tag)
+ if err != nil {
+ return nil, err
+ }
+ }
+ }
+
+ return tags, nil
+}
diff --git a/pkg/services/sqlstore/dashboard_test.go b/pkg/services/dashboards/database/database_dashboard_test.go
similarity index 84%
rename from pkg/services/sqlstore/dashboard_test.go
rename to pkg/services/dashboards/database/database_dashboard_test.go
index 7c8745d74ee..f02745b4512 100644
--- a/pkg/services/sqlstore/dashboard_test.go
+++ b/pkg/services/dashboards/database/database_dashboard_test.go
@@ -1,7 +1,7 @@
//go:build integration
// +build integration
-package sqlstore
+package database
import (
"context"
@@ -14,24 +14,25 @@ import (
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/search"
+ "github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/services/sqlstore/searchstore"
"github.com/grafana/grafana/pkg/setting"
- "github.com/grafana/grafana/pkg/util"
-
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDashboardDataAccess(t *testing.T) {
- var sqlStore *SQLStore
+ var sqlStore *sqlstore.SQLStore
var savedFolder, savedDash, savedDash2 *models.Dashboard
+ var dashboardStore *DashboardStore
setup := func() {
- sqlStore = InitTestDB(t)
- savedFolder = insertTestDashboard(t, sqlStore, "1 test dash folder", 1, 0, true, "prod", "webapp")
- savedDash = insertTestDashboard(t, sqlStore, "test dash 23", 1, savedFolder.Id, false, "prod", "webapp")
- insertTestDashboard(t, sqlStore, "test dash 45", 1, savedFolder.Id, false, "prod")
- savedDash2 = insertTestDashboard(t, sqlStore, "test dash 67", 1, 0, false, "prod")
+ sqlStore = sqlstore.InitTestDB(t)
+ dashboardStore = ProvideDashboardStore(sqlStore)
+ savedFolder = insertTestDashboard(t, dashboardStore, "1 test dash folder", 1, 0, true, "prod", "webapp")
+ savedDash = insertTestDashboard(t, dashboardStore, "test dash 23", 1, savedFolder.Id, false, "prod", "webapp")
+ insertTestDashboard(t, dashboardStore, "test dash 45", 1, savedFolder.Id, false, "prod")
+ savedDash2 = insertTestDashboard(t, dashboardStore, "test dash 67", 1, 0, false, "prod")
insertTestRule(t, sqlStore, savedFolder.OrgId, savedFolder.Uid)
}
@@ -115,7 +116,7 @@ func TestDashboardDataAccess(t *testing.T) {
t.Run("Should be able to delete dashboard", func(t *testing.T) {
setup()
- dash := insertTestDashboard(t, sqlStore, "delete me", 1, 0, false, "delete this")
+ dash := insertTestDashboard(t, dashboardStore, "delete me", 1, 0, false, "delete this")
err := sqlStore.DeleteDashboard(context.Background(), &models.DeleteDashboardCommand{
Id: dash.Id,
@@ -124,29 +125,6 @@ func TestDashboardDataAccess(t *testing.T) {
require.NoError(t, err)
})
- t.Run("Should retry generation of uid once if it fails.", func(t *testing.T) {
- setup()
- timesCalled := 0
- generateNewUid = func() string {
- timesCalled += 1
- if timesCalled <= 2 {
- return savedDash.Uid
- }
- return util.GenerateShortUID()
- }
- cmd := models.SaveDashboardCommand{
- OrgId: 1,
- Dashboard: simplejson.NewFromAny(map[string]interface{}{
- "title": "new dash 12334",
- "tags": []interface{}{},
- }),
- }
- _, err := sqlStore.SaveDashboard(cmd)
- require.NoError(t, err)
-
- generateNewUid = util.GenerateShortUID
- })
-
t.Run("Should be able to create dashboard", func(t *testing.T) {
setup()
cmd := models.SaveDashboardCommand{
@@ -157,13 +135,14 @@ func TestDashboardDataAccess(t *testing.T) {
}),
UserId: 100,
}
- dashboard, err := sqlStore.SaveDashboard(cmd)
+ dashboard, err := dashboardStore.SaveDashboard(cmd)
require.NoError(t, err)
require.EqualValues(t, dashboard.CreatedBy, 100)
require.False(t, dashboard.Created.IsZero())
require.EqualValues(t, dashboard.UpdatedBy, 100)
require.False(t, dashboard.Updated.IsZero())
})
+
t.Run("Should be able to update dashboard by id and remove folderId", func(t *testing.T) {
setup()
cmd := models.SaveDashboardCommand{
@@ -177,7 +156,7 @@ func TestDashboardDataAccess(t *testing.T) {
FolderId: 2,
UserId: 100,
}
- dash, err := sqlStore.SaveDashboard(cmd)
+ dash, err := dashboardStore.SaveDashboard(cmd)
require.NoError(t, err)
require.EqualValues(t, dash.FolderId, 2)
@@ -192,7 +171,7 @@ func TestDashboardDataAccess(t *testing.T) {
Overwrite: true,
UserId: 100,
}
- _, err = sqlStore.SaveDashboard(cmd)
+ _, err = dashboardStore.SaveDashboard(cmd)
require.NoError(t, err)
query := models.GetDashboardQuery{
@@ -211,7 +190,7 @@ func TestDashboardDataAccess(t *testing.T) {
t.Run("Should be able to delete empty folder", func(t *testing.T) {
setup()
- emptyFolder := insertTestDashboard(t, sqlStore, "2 test dash folder", 1, 0, true, "prod", "webapp")
+ emptyFolder := insertTestDashboard(t, dashboardStore, "2 test dash folder", 1, 0, true, "prod", "webapp")
deleteCmd := &models.DeleteDashboardCommand{Id: emptyFolder.Id}
err := sqlStore.DeleteDashboard(context.Background(), deleteCmd)
@@ -242,7 +221,7 @@ func TestDashboardDataAccess(t *testing.T) {
require.Equal(t, len(query.Result), 0)
- sqlStore.WithDbSession(context.Background(), func(sess *DBSession) error {
+ sqlStore.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error {
var existingRuleID int64
exists, err := sess.Table("alert_rule").Where("namespace_uid = (SELECT uid FROM dashboard WHERE id = ?)", savedFolder.Id).Cols("id").Get(&existingRuleID)
require.NoError(t, err)
@@ -258,7 +237,6 @@ func TestDashboardDataAccess(t *testing.T) {
})
t.Run("Should return error if no dashboard is found for update when dashboard id is greater than zero", func(t *testing.T) {
- setup()
cmd := models.SaveDashboardCommand{
OrgId: 1,
Overwrite: true,
@@ -269,12 +247,11 @@ func TestDashboardDataAccess(t *testing.T) {
}),
}
- _, err := sqlStore.SaveDashboard(cmd)
+ _, err := dashboardStore.SaveDashboard(cmd)
require.Equal(t, err, models.ErrDashboardNotFound)
})
t.Run("Should not return error if no dashboard is found for update when dashboard id is zero", func(t *testing.T) {
- setup()
cmd := models.SaveDashboardCommand{
OrgId: 1,
Overwrite: true,
@@ -284,7 +261,7 @@ func TestDashboardDataAccess(t *testing.T) {
"tags": []interface{}{},
}),
}
- _, err := sqlStore.SaveDashboard(cmd)
+ _, err := dashboardStore.SaveDashboard(cmd)
require.NoError(t, err)
})
@@ -405,7 +382,7 @@ func TestDashboardDataAccess(t *testing.T) {
t.Run("Should be able to search for starred dashboards", func(t *testing.T) {
setup()
- starredDash := insertTestDashboard(t, sqlStore, "starred dash", 1, 0, false)
+ starredDash := insertTestDashboard(t, dashboardStore, "starred dash", 1, 0, false)
err := sqlStore.StarDashboard(context.Background(), &models.StarDashboardCommand{
DashboardId: starredDash.Id,
UserId: 10,
@@ -431,29 +408,32 @@ func TestDashboardDataAccess(t *testing.T) {
}
func TestDashboardDataAccessGivenPluginWithImportedDashboards(t *testing.T) {
- sqlStore := InitTestDB(t)
+ sqlStore := sqlstore.InitTestDB(t)
+ dashboardStore := ProvideDashboardStore(sqlStore)
pluginId := "test-app"
- appFolder := insertTestDashboardForPlugin(t, sqlStore, "app-test", 1, 0, true, pluginId)
- insertTestDashboardForPlugin(t, sqlStore, "app-dash1", 1, appFolder.Id, false, pluginId)
- insertTestDashboardForPlugin(t, sqlStore, "app-dash2", 1, appFolder.Id, false, pluginId)
+ appFolder := insertTestDashboardForPlugin(t, dashboardStore, "app-test", 1, 0, true, pluginId)
+ insertTestDashboardForPlugin(t, dashboardStore, "app-dash1", 1, appFolder.Id, false, pluginId)
+ insertTestDashboardForPlugin(t, dashboardStore, "app-dash2", 1, appFolder.Id, false, pluginId)
query := models.GetDashboardsByPluginIdQuery{
PluginId: pluginId,
OrgId: 1,
}
- err := GetDashboardsByPluginId(context.Background(), &query)
+ err := sqlStore.GetDashboardsByPluginId(context.Background(), &query)
require.NoError(t, err)
require.Equal(t, len(query.Result), 2)
}
func TestDashboard_SortingOptions(t *testing.T) {
+ sqlStore := sqlstore.InitTestDB(t)
+ dashboardStore := ProvideDashboardStore(sqlStore)
// insertTestDashboard uses GoConvey's assertions. Workaround.
t.Run("test with multiple sorting options", func(t *testing.T) {
- sqlStore := InitTestDB(t)
- dashB := insertTestDashboard(t, sqlStore, "Beta", 1, 0, false)
- dashA := insertTestDashboard(t, sqlStore, "Alfa", 1, 0, false)
+ sqlStore := sqlstore.InitTestDB(t)
+ dashB := insertTestDashboard(t, dashboardStore, "Beta", 1, 0, false)
+ dashA := insertTestDashboard(t, dashboardStore, "Alfa", 1, 0, false)
assert.NotZero(t, dashA.Id)
assert.Less(t, dashB.Id, dashA.Id)
q := &search.FindPersistedDashboardsQuery{
@@ -464,35 +444,16 @@ func TestDashboard_SortingOptions(t *testing.T) {
searchstore.TitleSorter{Descending: true},
},
}
- dashboards, err := sqlStore.findDashboards(context.Background(), q)
+ dashboards, err := sqlStore.FindDashboards(context.Background(), q)
require.NoError(t, err)
require.Len(t, dashboards, 2)
assert.Equal(t, dashA.Id, dashboards[0].ID)
assert.Equal(t, dashB.Id, dashboards[1].ID)
})
}
-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,
- }),
- }
- dash, err := sqlStore.SaveDashboard(cmd)
- require.NoError(t, err)
- require.NotNil(t, dash)
- dash.Data.Set("id", dash.Id)
- dash.Data.Set("uid", dash.Uid)
- return dash
-}
-func insertTestRule(t *testing.T, sqlStore *SQLStore, foderOrgID int64, folderUID string) {
- sqlStore.WithDbSession(context.Background(), func(sess *DBSession) error {
+
+func insertTestRule(t *testing.T, sqlStore *sqlstore.SQLStore, foderOrgID int64, folderUID string) {
+ sqlStore.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error {
type alertQuery struct {
RefID string
DatasourceUID string
@@ -562,26 +523,8 @@ func insertTestRule(t *testing.T, sqlStore *SQLStore, foderOrgID int64, folderUI
return err
})
}
-func insertTestDashboardForPlugin(t *testing.T, sqlStore *SQLStore, title string, orgId int64,
- folderId int64, isFolder bool, pluginId string) *models.Dashboard {
- t.Helper()
- cmd := models.SaveDashboardCommand{
- OrgId: orgId,
- FolderId: folderId,
- IsFolder: isFolder,
- Dashboard: simplejson.NewFromAny(map[string]interface{}{
- "id": nil,
- "title": title,
- }),
- PluginId: pluginId,
- }
- dash, err := sqlStore.SaveDashboard(cmd)
- require.NoError(t, err)
-
- return dash
-}
-func createUser(t *testing.T, sqlStore *SQLStore, name string, role string, isAdmin bool) models.User {
+func CreateUser(t *testing.T, sqlStore *sqlstore.SQLStore, name string, role string, isAdmin bool) models.User {
t.Helper()
setting.AutoAssignOrg = true
setting.AutoAssignOrgId = 1
@@ -595,3 +538,59 @@ func createUser(t *testing.T, sqlStore *SQLStore, name string, role string, isAd
require.Equal(t, models.RoleType(role), q1.Result[0].Role)
return *currentUser
}
+
+func insertTestDashboard(t *testing.T, dashboardStore *DashboardStore, 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,
+ }),
+ }
+ dash, err := dashboardStore.SaveDashboard(cmd)
+ require.NoError(t, err)
+ require.NotNil(t, dash)
+ dash.Data.Set("id", dash.Id)
+ dash.Data.Set("uid", dash.Uid)
+ return dash
+}
+
+func insertTestDashboardForPlugin(t *testing.T, dashboardStore *DashboardStore, title string, orgId int64,
+ folderId int64, isFolder bool, pluginId string) *models.Dashboard {
+ t.Helper()
+ cmd := models.SaveDashboardCommand{
+ OrgId: orgId,
+ FolderId: folderId,
+ IsFolder: isFolder,
+ Dashboard: simplejson.NewFromAny(map[string]interface{}{
+ "id": nil,
+ "title": title,
+ }),
+ PluginId: pluginId,
+ }
+
+ dash, err := dashboardStore.SaveDashboard(cmd)
+ require.NoError(t, err)
+
+ return dash
+}
+
+func updateDashboardAcl(t *testing.T, dashboardStore *DashboardStore, dashboardID int64,
+ items ...models.DashboardAcl) error {
+ t.Helper()
+
+ var itemPtrs []*models.DashboardAcl
+ for _, it := range items {
+ item := it
+ item.Created = time.Now()
+ item.Updated = time.Now()
+ itemPtrs = append(itemPtrs, &item)
+ }
+
+ return dashboardStore.UpdateDashboardACL(context.Background(), dashboardID, itemPtrs)
+}
diff --git a/pkg/services/sqlstore/dashboard_folder_test.go b/pkg/services/dashboards/database/database_folder_test.go
similarity index 81%
rename from pkg/services/sqlstore/dashboard_folder_test.go
rename to pkg/services/dashboards/database/database_folder_test.go
index 3b7007594f0..4718c41a7fb 100644
--- a/pkg/services/sqlstore/dashboard_folder_test.go
+++ b/pkg/services/dashboards/database/database_folder_test.go
@@ -1,32 +1,34 @@
//go:build integration
// +build integration
-package sqlstore
+package database
import (
"context"
"testing"
- "github.com/stretchr/testify/require"
-
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/search"
+ "github.com/grafana/grafana/pkg/services/sqlstore"
+ "github.com/stretchr/testify/require"
)
func TestDashboardFolderDataAccess(t *testing.T) {
t.Run("Testing DB", func(t *testing.T) {
- var sqlStore *SQLStore
+ var sqlStore *sqlstore.SQLStore
var folder, dashInRoot, childDash *models.Dashboard
var currentUser models.User
+ var dashboardStore *DashboardStore
setup := func() {
- sqlStore = InitTestDB(t)
- folder = insertTestDashboard(t, sqlStore, "1 test dash folder", 1, 0, true, "prod", "webapp")
- dashInRoot = insertTestDashboard(t, sqlStore, "test dash 67", 1, 0, false, "prod", "webapp")
- childDash = insertTestDashboard(t, sqlStore, "test dash 23", 1, folder.Id, false, "prod", "webapp")
- insertTestDashboard(t, sqlStore, "test dash 45", 1, folder.Id, false, "prod")
- currentUser = createUser(t, sqlStore, "viewer", "Viewer", false)
+ sqlStore = sqlstore.InitTestDB(t)
+ dashboardStore = ProvideDashboardStore(sqlStore)
+ folder = insertTestDashboard(t, dashboardStore, "1 test dash folder", 1, 0, true, "prod", "webapp")
+ dashInRoot = insertTestDashboard(t, dashboardStore, "test dash 67", 1, 0, false, "prod", "webapp")
+ childDash = insertTestDashboard(t, dashboardStore, "test dash 23", 1, folder.Id, false, "prod", "webapp")
+ insertTestDashboard(t, dashboardStore, "test dash 45", 1, folder.Id, false, "prod")
+ currentUser = CreateUser(t, sqlStore, "viewer", "Viewer", false)
}
t.Run("Given one dashboard folder with two dashboards and one dashboard in the root folder", func(t *testing.T) {
@@ -49,7 +51,7 @@ func TestDashboardFolderDataAccess(t *testing.T) {
t.Run("and acl is set for dashboard folder", func(t *testing.T) {
var otherUser int64 = 999
- err := testHelperUpdateDashboardAcl(t, sqlStore, folder.Id, models.DashboardAcl{
+ err := updateDashboardAcl(t, dashboardStore, folder.Id, models.DashboardAcl{
DashboardID: folder.Id,
OrgID: 1,
UserID: otherUser,
@@ -70,7 +72,7 @@ func TestDashboardFolderDataAccess(t *testing.T) {
})
t.Run("when the user is given permission", func(t *testing.T) {
- err := testHelperUpdateDashboardAcl(t, sqlStore, folder.Id, models.DashboardAcl{
+ err := updateDashboardAcl(t, dashboardStore, folder.Id, models.DashboardAcl{
DashboardID: folder.Id, OrgID: 1, UserID: currentUser.Id, Permission: models.PERMISSION_EDIT,
})
require.NoError(t, err)
@@ -111,9 +113,9 @@ func TestDashboardFolderDataAccess(t *testing.T) {
t.Run("and acl is set for dashboard child and folder has all permissions removed", func(t *testing.T) {
var otherUser int64 = 999
- err := testHelperUpdateDashboardAcl(t, sqlStore, folder.Id)
+ err := updateDashboardAcl(t, dashboardStore, folder.Id)
require.NoError(t, err)
- err = testHelperUpdateDashboardAcl(t, sqlStore, childDash.Id, models.DashboardAcl{
+ err = updateDashboardAcl(t, dashboardStore, childDash.Id, models.DashboardAcl{
DashboardID: folder.Id, OrgID: 1, UserID: otherUser, Permission: models.PERMISSION_EDIT,
})
require.NoError(t, err)
@@ -129,7 +131,7 @@ func TestDashboardFolderDataAccess(t *testing.T) {
})
t.Run("when the user is given permission to child", func(t *testing.T) {
- err := testHelperUpdateDashboardAcl(t, sqlStore, childDash.Id, models.DashboardAcl{
+ err := updateDashboardAcl(t, dashboardStore, childDash.Id, models.DashboardAcl{
DashboardID: childDash.Id, OrgID: 1, UserID: currentUser.Id, Permission: models.PERMISSION_EDIT,
})
require.NoError(t, err)
@@ -167,20 +169,21 @@ func TestDashboardFolderDataAccess(t *testing.T) {
})
t.Run("Given two dashboard folders with one dashboard each and one dashboard in the root folder", func(t *testing.T) {
- var sqlStore *SQLStore
+ var sqlStore *sqlstore.SQLStore
var folder1, folder2, dashInRoot, childDash1, childDash2 *models.Dashboard
var currentUser models.User
var rootFolderId int64 = 0
setup2 := func() {
- sqlStore = InitTestDB(t)
- folder1 = insertTestDashboard(t, sqlStore, "1 test dash folder", 1, 0, true, "prod")
- folder2 = insertTestDashboard(t, sqlStore, "2 test dash folder", 1, 0, true, "prod")
- dashInRoot = insertTestDashboard(t, sqlStore, "test dash 67", 1, 0, false, "prod")
- childDash1 = insertTestDashboard(t, sqlStore, "child dash 1", 1, folder1.Id, false, "prod")
- childDash2 = insertTestDashboard(t, sqlStore, "child dash 2", 1, folder2.Id, false, "prod")
+ sqlStore = sqlstore.InitTestDB(t)
+ dashboardStore := ProvideDashboardStore(sqlStore)
+ folder1 = insertTestDashboard(t, dashboardStore, "1 test dash folder", 1, 0, true, "prod")
+ folder2 = insertTestDashboard(t, dashboardStore, "2 test dash folder", 1, 0, true, "prod")
+ dashInRoot = insertTestDashboard(t, dashboardStore, "test dash 67", 1, 0, false, "prod")
+ childDash1 = insertTestDashboard(t, dashboardStore, "child dash 1", 1, folder1.Id, false, "prod")
+ childDash2 = insertTestDashboard(t, dashboardStore, "child dash 2", 1, folder2.Id, false, "prod")
- currentUser = createUser(t, sqlStore, "viewer", "Viewer", false)
+ currentUser = CreateUser(t, sqlStore, "viewer", "Viewer", false)
}
setup2()
@@ -205,13 +208,13 @@ func TestDashboardFolderDataAccess(t *testing.T) {
t.Run("and acl is set for one dashboard folder", func(t *testing.T) {
const otherUser int64 = 999
- err := testHelperUpdateDashboardAcl(t, sqlStore, folder1.Id, models.DashboardAcl{
+ err := updateDashboardAcl(t, dashboardStore, folder1.Id, models.DashboardAcl{
DashboardID: folder1.Id, OrgID: 1, UserID: otherUser, Permission: models.PERMISSION_EDIT,
})
require.NoError(t, err)
t.Run("and a dashboard is moved from folder without acl to the folder with an acl", func(t *testing.T) {
- moveDashboard(t, sqlStore, 1, childDash2.Data, folder1.Id)
+ moveDashboard(t, dashboardStore, 1, childDash2.Data, folder1.Id)
t.Run("should not return folder with acl or its children", func(t *testing.T) {
query := &search.FindPersistedDashboardsQuery{
@@ -227,7 +230,7 @@ func TestDashboardFolderDataAccess(t *testing.T) {
})
t.Run("and a dashboard is moved from folder with acl to the folder without an acl", func(t *testing.T) {
setup2()
- moveDashboard(t, sqlStore, 1, childDash1.Data, folder2.Id)
+ moveDashboard(t, dashboardStore, 1, childDash1.Data, folder2.Id)
t.Run("should return folder without acl and its children", func(t *testing.T) {
query := &search.FindPersistedDashboardsQuery{
@@ -246,12 +249,12 @@ func TestDashboardFolderDataAccess(t *testing.T) {
})
t.Run("and a dashboard with an acl is moved to the folder without an acl", func(t *testing.T) {
- err := testHelperUpdateDashboardAcl(t, sqlStore, childDash1.Id, models.DashboardAcl{
+ err := updateDashboardAcl(t, dashboardStore, childDash1.Id, models.DashboardAcl{
DashboardID: childDash1.Id, OrgID: 1, UserID: otherUser, Permission: models.PERMISSION_EDIT,
})
require.NoError(t, err)
- moveDashboard(t, sqlStore, 1, childDash1.Data, folder2.Id)
+ moveDashboard(t, dashboardStore, 1, childDash1.Data, folder2.Id)
t.Run("should return folder without acl but not the dashboard with acl", func(t *testing.T) {
query := &search.FindPersistedDashboardsQuery{
@@ -272,19 +275,20 @@ func TestDashboardFolderDataAccess(t *testing.T) {
})
t.Run("Given two dashboard folders", func(t *testing.T) {
- var sqlStore *SQLStore
+ var sqlStore *sqlstore.SQLStore
var folder1, folder2 *models.Dashboard
var adminUser, editorUser, viewerUser models.User
setup3 := func() {
- sqlStore = InitTestDB(t)
- folder1 = insertTestDashboard(t, sqlStore, "1 test dash folder", 1, 0, true, "prod")
- folder2 = insertTestDashboard(t, sqlStore, "2 test dash folder", 1, 0, true, "prod")
- insertTestDashboard(t, sqlStore, "folder in another org", 2, 0, true, "prod")
+ sqlStore = sqlstore.InitTestDB(t)
+ dashboardStore := ProvideDashboardStore(sqlStore)
+ folder1 = insertTestDashboard(t, dashboardStore, "1 test dash folder", 1, 0, true, "prod")
+ folder2 = insertTestDashboard(t, dashboardStore, "2 test dash folder", 1, 0, true, "prod")
+ insertTestDashboard(t, dashboardStore, "folder in another org", 2, 0, true, "prod")
- adminUser = createUser(t, sqlStore, "admin", "Admin", true)
- editorUser = createUser(t, sqlStore, "editor", "Editor", false)
- viewerUser = createUser(t, sqlStore, "viewer", "Viewer", false)
+ adminUser = CreateUser(t, sqlStore, "admin", "Admin", true)
+ editorUser = CreateUser(t, sqlStore, "editor", "Editor", false)
+ viewerUser = CreateUser(t, sqlStore, "viewer", "Viewer", false)
}
setup3()
@@ -313,7 +317,7 @@ func TestDashboardFolderDataAccess(t *testing.T) {
OrgRole: models.ROLE_ADMIN,
}
- err := GetDashboardPermissionsForUser(context.Background(), &query)
+ err := sqlStore.GetDashboardPermissionsForUser(context.Background(), &query)
require.NoError(t, err)
require.Equal(t, len(query.Result), 2)
@@ -336,7 +340,7 @@ func TestDashboardFolderDataAccess(t *testing.T) {
query := &models.HasAdminPermissionInFoldersQuery{
SignedInUser: &models.SignedInUser{UserId: adminUser.Id, OrgId: 1, OrgRole: models.ROLE_ADMIN},
}
- err := HasAdminPermissionInFolders(context.Background(), query)
+ err := sqlStore.HasAdminPermissionInFolders(context.Background(), query)
require.NoError(t, err)
require.True(t, query.Result)
})
@@ -366,7 +370,7 @@ func TestDashboardFolderDataAccess(t *testing.T) {
OrgRole: models.ROLE_EDITOR,
}
- err := GetDashboardPermissionsForUser(context.Background(), &query)
+ err := sqlStore.GetDashboardPermissionsForUser(context.Background(), &query)
require.NoError(t, err)
require.Equal(t, len(query.Result), 2)
@@ -377,7 +381,7 @@ func TestDashboardFolderDataAccess(t *testing.T) {
})
t.Run("Should have write access to one dashboard folder if default role changed to view for one folder", func(t *testing.T) {
- err := testHelperUpdateDashboardAcl(t, sqlStore, folder1.Id, models.DashboardAcl{
+ err := updateDashboardAcl(t, dashboardStore, folder1.Id, models.DashboardAcl{
DashboardID: folder1.Id, OrgID: 1, UserID: editorUser.Id, Permission: models.PERMISSION_VIEW,
})
require.NoError(t, err)
@@ -394,7 +398,7 @@ func TestDashboardFolderDataAccess(t *testing.T) {
SignedInUser: &models.SignedInUser{UserId: editorUser.Id, OrgId: 1, OrgRole: models.ROLE_EDITOR},
}
err := sqlStore.HasEditPermissionInFolders(context.Background(), query)
- require.NoError(t, err)
+ go require.NoError(t, err)
require.True(t, query.Result)
})
@@ -402,7 +406,7 @@ func TestDashboardFolderDataAccess(t *testing.T) {
query := &models.HasAdminPermissionInFoldersQuery{
SignedInUser: &models.SignedInUser{UserId: adminUser.Id, OrgId: 1, OrgRole: models.ROLE_EDITOR},
}
- err := HasAdminPermissionInFolders(context.Background(), query)
+ err := sqlStore.HasAdminPermissionInFolders(context.Background(), query)
require.NoError(t, err)
require.False(t, query.Result)
})
@@ -432,7 +436,7 @@ func TestDashboardFolderDataAccess(t *testing.T) {
OrgRole: models.ROLE_VIEWER,
}
- err := GetDashboardPermissionsForUser(context.Background(), &query)
+ err := sqlStore.GetDashboardPermissionsForUser(context.Background(), &query)
require.NoError(t, err)
require.Equal(t, len(query.Result), 2)
@@ -443,7 +447,7 @@ func TestDashboardFolderDataAccess(t *testing.T) {
})
t.Run("Should be able to get one dashboard folder if default role changed to edit for one folder", func(t *testing.T) {
- err := testHelperUpdateDashboardAcl(t, sqlStore, folder1.Id, models.DashboardAcl{
+ err := updateDashboardAcl(t, dashboardStore, folder1.Id, models.DashboardAcl{
DashboardID: folder1.Id, OrgID: 1, UserID: viewerUser.Id, Permission: models.PERMISSION_EDIT,
})
require.NoError(t, err)
@@ -462,7 +466,7 @@ func TestDashboardFolderDataAccess(t *testing.T) {
SignedInUser: &models.SignedInUser{UserId: viewerUser.Id, OrgId: 1, OrgRole: models.ROLE_VIEWER},
}
err := sqlStore.HasEditPermissionInFolders(context.Background(), query)
- require.NoError(t, err)
+ go require.NoError(t, err)
require.False(t, query.Result)
})
@@ -470,13 +474,13 @@ func TestDashboardFolderDataAccess(t *testing.T) {
query := &models.HasAdminPermissionInFoldersQuery{
SignedInUser: &models.SignedInUser{UserId: adminUser.Id, OrgId: 1, OrgRole: models.ROLE_VIEWER},
}
- err := HasAdminPermissionInFolders(context.Background(), query)
+ err := sqlStore.HasAdminPermissionInFolders(context.Background(), query)
require.NoError(t, err)
require.False(t, query.Result)
})
t.Run("and admin permission is given for user with org role viewer in one dashboard folder", func(t *testing.T) {
- err := testHelperUpdateDashboardAcl(t, sqlStore, folder1.Id, models.DashboardAcl{
+ err := updateDashboardAcl(t, dashboardStore, folder1.Id, models.DashboardAcl{
DashboardID: folder1.Id, OrgID: 1, UserID: viewerUser.Id, Permission: models.PERMISSION_ADMIN,
})
require.NoError(t, err)
@@ -486,13 +490,13 @@ func TestDashboardFolderDataAccess(t *testing.T) {
SignedInUser: &models.SignedInUser{UserId: viewerUser.Id, OrgId: 1, OrgRole: models.ROLE_VIEWER},
}
err := sqlStore.HasEditPermissionInFolders(context.Background(), query)
- require.NoError(t, err)
+ go require.NoError(t, err)
require.True(t, query.Result)
})
})
t.Run("and edit permission is given for user with org role viewer in one dashboard folder", func(t *testing.T) {
- err := testHelperUpdateDashboardAcl(t, sqlStore, folder1.Id, models.DashboardAcl{
+ err := updateDashboardAcl(t, dashboardStore, folder1.Id, models.DashboardAcl{
DashboardID: folder1.Id, OrgID: 1, UserID: viewerUser.Id, Permission: models.PERMISSION_EDIT,
})
require.NoError(t, err)
@@ -502,7 +506,7 @@ func TestDashboardFolderDataAccess(t *testing.T) {
SignedInUser: &models.SignedInUser{UserId: viewerUser.Id, OrgId: 1, OrgRole: models.ROLE_VIEWER},
}
err := sqlStore.HasEditPermissionInFolders(context.Background(), query)
- require.NoError(t, err)
+ go require.NoError(t, err)
require.True(t, query.Result)
})
})
@@ -512,15 +516,16 @@ func TestDashboardFolderDataAccess(t *testing.T) {
t.Run("Given dashboard and folder with the same title", func(t *testing.T) {
var orgId int64 = 1
title := "Very Unique Name"
- var sqlStore *SQLStore
+ var sqlStore *sqlstore.SQLStore
var folder1, folder2 *models.Dashboard
- sqlStore = InitTestDB(t)
- folder2 = insertTestDashboard(t, sqlStore, "TEST", orgId, 0, true, "prod")
- _ = insertTestDashboard(t, sqlStore, title, orgId, folder2.Id, false, "prod")
- folder1 = insertTestDashboard(t, sqlStore, title, orgId, 0, true, "prod")
+ sqlStore = sqlstore.InitTestDB(t)
+ dashboardStore := ProvideDashboardStore(sqlStore)
+ folder2 = insertTestDashboard(t, dashboardStore, "TEST", orgId, 0, true, "prod")
+ _ = insertTestDashboard(t, dashboardStore, title, orgId, folder2.Id, false, "prod")
+ folder1 = insertTestDashboard(t, dashboardStore, title, orgId, 0, true, "prod")
t.Run("GetFolderByTitle should find the folder", func(t *testing.T) {
- result, err := sqlStore.GetFolderByTitle(orgId, title)
+ result, err := dashboardStore.GetFolderByTitle(orgId, title)
require.NoError(t, err)
require.Equal(t, folder1.Id, result.Id)
})
@@ -528,7 +533,7 @@ func TestDashboardFolderDataAccess(t *testing.T) {
})
}
-func moveDashboard(t *testing.T, sqlStore *SQLStore, orgId int64, dashboard *simplejson.Json,
+func moveDashboard(t *testing.T, dashboardStore *DashboardStore, orgId int64, dashboard *simplejson.Json,
newFolderId int64) *models.Dashboard {
t.Helper()
@@ -538,7 +543,7 @@ func moveDashboard(t *testing.T, sqlStore *SQLStore, orgId int64, dashboard *sim
Dashboard: dashboard,
Overwrite: true,
}
- dash, err := sqlStore.SaveDashboard(cmd)
+ dash, err := dashboardStore.SaveDashboard(cmd)
require.NoError(t, err)
return dash
diff --git a/pkg/services/dashboards/database/database_mock.go b/pkg/services/dashboards/database/database_mock.go
new file mode 100644
index 00000000000..c8b154f970f
--- /dev/null
+++ b/pkg/services/dashboards/database/database_mock.go
@@ -0,0 +1,217 @@
+// Code generated by mockery v2.10.0. DO NOT EDIT.
+
+package database
+
+import (
+ context "context"
+
+ mock "github.com/stretchr/testify/mock"
+
+ models "github.com/grafana/grafana/pkg/models"
+)
+
+// FakeDashboardStore is an autogenerated mock type for the Store type
+type FakeDashboardStore struct {
+ mock.Mock
+}
+
+// GetFolderByTitle provides a mock function with given fields: orgID, title
+func (_m *FakeDashboardStore) GetFolderByTitle(orgID int64, title string) (*models.Dashboard, error) {
+ ret := _m.Called(orgID, title)
+
+ var r0 *models.Dashboard
+ if rf, ok := ret.Get(0).(func(int64, string) *models.Dashboard); ok {
+ r0 = rf(orgID, title)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(*models.Dashboard)
+ }
+ }
+
+ var r1 error
+ if rf, ok := ret.Get(1).(func(int64, string) error); ok {
+ r1 = rf(orgID, title)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// GetProvisionedDashboardData provides a mock function with given fields: name
+func (_m *FakeDashboardStore) GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error) {
+ ret := _m.Called(name)
+
+ var r0 []*models.DashboardProvisioning
+ if rf, ok := ret.Get(0).(func(string) []*models.DashboardProvisioning); ok {
+ r0 = rf(name)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).([]*models.DashboardProvisioning)
+ }
+ }
+
+ var r1 error
+ if rf, ok := ret.Get(1).(func(string) error); ok {
+ r1 = rf(name)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// GetProvisionedDataByDashboardID provides a mock function with given fields: dashboardID
+func (_m *FakeDashboardStore) GetProvisionedDataByDashboardID(dashboardID int64) (*models.DashboardProvisioning, error) {
+ ret := _m.Called(dashboardID)
+
+ var r0 *models.DashboardProvisioning
+ if rf, ok := ret.Get(0).(func(int64) *models.DashboardProvisioning); ok {
+ r0 = rf(dashboardID)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(*models.DashboardProvisioning)
+ }
+ }
+
+ var r1 error
+ if rf, ok := ret.Get(1).(func(int64) error); ok {
+ r1 = rf(dashboardID)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// GetProvisionedDataByDashboardUID provides a mock function with given fields: orgID, dashboardUID
+func (_m *FakeDashboardStore) GetProvisionedDataByDashboardUID(orgID int64, dashboardUID string) (*models.DashboardProvisioning, error) {
+ ret := _m.Called(orgID, dashboardUID)
+
+ var r0 *models.DashboardProvisioning
+ if rf, ok := ret.Get(0).(func(int64, string) *models.DashboardProvisioning); ok {
+ r0 = rf(orgID, dashboardUID)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(*models.DashboardProvisioning)
+ }
+ }
+
+ var r1 error
+ if rf, ok := ret.Get(1).(func(int64, string) error); ok {
+ r1 = rf(orgID, dashboardUID)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// SaveAlerts provides a mock function with given fields: ctx, dashID, alerts
+func (_m *FakeDashboardStore) SaveAlerts(ctx context.Context, dashID int64, alerts []*models.Alert) error {
+ ret := _m.Called(ctx, dashID, alerts)
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(context.Context, int64, []*models.Alert) error); ok {
+ r0 = rf(ctx, dashID, alerts)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// SaveDashboard provides a mock function with given fields: cmd
+func (_m *FakeDashboardStore) SaveDashboard(cmd models.SaveDashboardCommand) (*models.Dashboard, error) {
+ ret := _m.Called(cmd)
+
+ var r0 *models.Dashboard
+ if rf, ok := ret.Get(0).(func(models.SaveDashboardCommand) *models.Dashboard); ok {
+ r0 = rf(cmd)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(*models.Dashboard)
+ }
+ }
+
+ var r1 error
+ if rf, ok := ret.Get(1).(func(models.SaveDashboardCommand) error); ok {
+ r1 = rf(cmd)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// SaveProvisionedDashboard provides a mock function with given fields: cmd, provisioning
+func (_m *FakeDashboardStore) SaveProvisionedDashboard(cmd models.SaveDashboardCommand, provisioning *models.DashboardProvisioning) (*models.Dashboard, error) {
+ ret := _m.Called(cmd, provisioning)
+
+ var r0 *models.Dashboard
+ if rf, ok := ret.Get(0).(func(models.SaveDashboardCommand, *models.DashboardProvisioning) *models.Dashboard); ok {
+ r0 = rf(cmd, provisioning)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(*models.Dashboard)
+ }
+ }
+
+ var r1 error
+ if rf, ok := ret.Get(1).(func(models.SaveDashboardCommand, *models.DashboardProvisioning) error); ok {
+ r1 = rf(cmd, provisioning)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// UnprovisionDashboard provides a mock function with given fields: ctx, id
+func (_m *FakeDashboardStore) UnprovisionDashboard(ctx context.Context, id int64) error {
+ ret := _m.Called(ctx, id)
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(context.Context, int64) error); ok {
+ r0 = rf(ctx, id)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// UpdateDashboardACL provides a mock function with given fields: ctx, uid, items
+func (_m *FakeDashboardStore) UpdateDashboardACL(ctx context.Context, uid int64, items []*models.DashboardAcl) error {
+ ret := _m.Called(ctx, uid, items)
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(context.Context, int64, []*models.DashboardAcl) error); ok {
+ r0 = rf(ctx, uid, items)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// ValidateDashboardBeforeSave provides a mock function with given fields: dashboard, overwrite
+func (_m *FakeDashboardStore) ValidateDashboardBeforeSave(dashboard *models.Dashboard, overwrite bool) (bool, error) {
+ ret := _m.Called(dashboard, overwrite)
+
+ var r0 bool
+ if rf, ok := ret.Get(0).(func(*models.Dashboard, bool) bool); ok {
+ r0 = rf(dashboard, overwrite)
+ } else {
+ r0 = ret.Get(0).(bool)
+ }
+
+ var r1 error
+ if rf, ok := ret.Get(1).(func(*models.Dashboard, bool) error); ok {
+ r1 = rf(dashboard, overwrite)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
diff --git a/pkg/services/sqlstore/dashboard_provisioning_test.go b/pkg/services/dashboards/database/database_provisioning_test.go
similarity index 79%
rename from pkg/services/sqlstore/dashboard_provisioning_test.go
rename to pkg/services/dashboards/database/database_provisioning_test.go
index f74800d8851..bec37866108 100644
--- a/pkg/services/sqlstore/dashboard_provisioning_test.go
+++ b/pkg/services/dashboards/database/database_provisioning_test.go
@@ -1,10 +1,11 @@
//go:build integration
// +build integration
-package sqlstore
+package database
import (
"context"
+ "github.com/grafana/grafana/pkg/services/sqlstore"
"testing"
"time"
@@ -15,7 +16,8 @@ import (
)
func TestDashboardProvisioningTest(t *testing.T) {
- sqlStore := InitTestDB(t)
+ sqlStore := sqlstore.InitTestDB(t)
+ dashboardStore := ProvideDashboardStore(sqlStore)
folderCmd := models.SaveDashboardCommand{
OrgId: 1,
@@ -27,7 +29,7 @@ func TestDashboardProvisioningTest(t *testing.T) {
}),
}
- dash, err := sqlStore.SaveDashboard(folderCmd)
+ dash, err := dashboardStore.SaveDashboard(folderCmd)
require.Nil(t, err)
saveDashboardCmd := models.SaveDashboardCommand{
@@ -49,7 +51,7 @@ func TestDashboardProvisioningTest(t *testing.T) {
Updated: now.Unix(),
}
- dash, err := sqlStore.SaveProvisionedDashboard(saveDashboardCmd, provisioning)
+ dash, err := dashboardStore.SaveProvisionedDashboard(saveDashboardCmd, provisioning)
require.Nil(t, err)
require.NotNil(t, dash)
require.NotEqual(t, 0, dash.Id)
@@ -71,7 +73,7 @@ func TestDashboardProvisioningTest(t *testing.T) {
Updated: now.Unix(),
}
- anotherDash, err := sqlStore.SaveProvisionedDashboard(saveCmd, provisioning)
+ anotherDash, err := dashboardStore.SaveProvisionedDashboard(saveCmd, provisioning)
require.Nil(t, err)
query := &models.GetDashboardsQuery{DashboardIds: []int64{anotherDash.Id}}
@@ -91,7 +93,7 @@ func TestDashboardProvisioningTest(t *testing.T) {
})
t.Run("Can query for provisioned dashboards", func(t *testing.T) {
- rslt, err := sqlStore.GetProvisionedDashboardData("default")
+ rslt, err := dashboardStore.GetProvisionedDashboardData("default")
require.Nil(t, err)
require.Equal(t, 1, len(rslt))
@@ -100,13 +102,13 @@ func TestDashboardProvisioningTest(t *testing.T) {
})
t.Run("Can query for one provisioned dashboard", func(t *testing.T) {
- data, err := sqlStore.GetProvisionedDataByDashboardID(dash.Id)
+ data, err := dashboardStore.GetProvisionedDataByDashboardID(dash.Id)
require.Nil(t, err)
require.NotNil(t, data)
})
t.Run("Can query for none provisioned dashboard", func(t *testing.T) {
- data, err := sqlStore.GetProvisionedDataByDashboardID(3000)
+ data, err := dashboardStore.GetProvisionedDataByDashboardID(3000)
require.Nil(t, err)
require.Nil(t, data)
})
@@ -119,19 +121,15 @@ func TestDashboardProvisioningTest(t *testing.T) {
require.Nil(t, sqlStore.DeleteDashboard(context.Background(), deleteCmd))
- data, err := sqlStore.GetProvisionedDataByDashboardID(dash.Id)
+ data, err := dashboardStore.GetProvisionedDataByDashboardID(dash.Id)
require.Nil(t, err)
require.Nil(t, data)
})
t.Run("UnprovisionDashboard should delete provisioning metadata", func(t *testing.T) {
- unprovisionCmd := &models.UnprovisionDashboardCommand{
- Id: dashId,
- }
+ require.Nil(t, dashboardStore.UnprovisionDashboard(context.Background(), dashId))
- require.Nil(t, UnprovisionDashboard(context.Background(), unprovisionCmd))
-
- data, err := sqlStore.GetProvisionedDataByDashboardID(dashId)
+ data, err := dashboardStore.GetProvisionedDataByDashboardID(dashId)
require.Nil(t, err)
require.Nil(t, data)
})
diff --git a/pkg/services/sqlstore/dashboard_acl_test.go b/pkg/services/dashboards/database/permissions/database_acl_test.go
similarity index 72%
rename from pkg/services/sqlstore/dashboard_acl_test.go
rename to pkg/services/dashboards/database/permissions/database_acl_test.go
index 2a29fade832..f0d74fc4719 100644
--- a/pkg/services/sqlstore/dashboard_acl_test.go
+++ b/pkg/services/dashboards/database/permissions/database_acl_test.go
@@ -1,11 +1,16 @@
//go:build integration
// +build integration
-package sqlstore
+package permissions
import (
"context"
+ "github.com/grafana/grafana/pkg/components/simplejson"
+ "github.com/grafana/grafana/pkg/services/dashboards/database"
+ "github.com/grafana/grafana/pkg/services/sqlstore"
+ "github.com/grafana/grafana/pkg/setting"
"testing"
+ "time"
"github.com/grafana/grafana/pkg/models"
@@ -13,20 +18,22 @@ import (
)
func TestDashboardAclDataAccess(t *testing.T) {
- var sqlStore *SQLStore
+ var sqlStore *sqlstore.SQLStore
var currentUser models.User
var savedFolder, childDash *models.Dashboard
+ var dashboardStore *database.DashboardStore
setup := func(t *testing.T) {
- sqlStore = InitTestDB(t)
+ sqlStore = sqlstore.InitTestDB(t)
+ dashboardStore = database.ProvideDashboardStore(sqlStore)
currentUser = createUser(t, sqlStore, "viewer", "Viewer", false)
- savedFolder = insertTestDashboard(t, sqlStore, "1 test dash folder", 1, 0, true, "prod", "webapp")
- childDash = insertTestDashboard(t, sqlStore, "2 test dash", 1, savedFolder.Id, false, "prod", "webapp")
+ savedFolder = insertTestDashboard(t, dashboardStore, "1 test dash folder", 1, 0, true, "prod", "webapp")
+ childDash = insertTestDashboard(t, dashboardStore, "2 test dash", 1, savedFolder.Id, false, "prod", "webapp")
}
t.Run("Dashboard permission with userId and teamId set to 0", func(t *testing.T) {
setup(t)
- err := testHelperUpdateDashboardAcl(t, sqlStore, savedFolder.Id, models.DashboardAcl{
+ err := updateDashboardAcl(t, dashboardStore, savedFolder.Id, models.DashboardAcl{
OrgID: 1,
DashboardID: savedFolder.Id,
Permission: models.PERMISSION_EDIT,
@@ -70,7 +77,7 @@ func TestDashboardAclDataAccess(t *testing.T) {
t.Run("Folder with removed default permissions returns no acl items", func(t *testing.T) {
setup(t)
- err := sqlStore.UpdateDashboardACL(context.Background(), savedFolder.Id, nil)
+ err := dashboardStore.UpdateDashboardACL(context.Background(), savedFolder.Id, nil)
require.Nil(t, err)
query := models.GetDashboardAclInfoListQuery{DashboardID: childDash.Id, OrgID: 1}
@@ -84,7 +91,7 @@ func TestDashboardAclDataAccess(t *testing.T) {
t.Run("Given dashboard folder permission", func(t *testing.T) {
setup(t)
- err := testHelperUpdateDashboardAcl(t, sqlStore, savedFolder.Id, models.DashboardAcl{
+ err := updateDashboardAcl(t, dashboardStore, savedFolder.Id, models.DashboardAcl{
OrgID: 1,
UserID: currentUser.Id,
DashboardID: savedFolder.Id,
@@ -103,7 +110,7 @@ func TestDashboardAclDataAccess(t *testing.T) {
})
t.Run("Given child dashboard permission", func(t *testing.T) {
- err := testHelperUpdateDashboardAcl(t, sqlStore, childDash.Id, models.DashboardAcl{
+ err := updateDashboardAcl(t, dashboardStore, childDash.Id, models.DashboardAcl{
OrgID: 1,
UserID: currentUser.Id,
DashboardID: childDash.Id,
@@ -128,7 +135,7 @@ func TestDashboardAclDataAccess(t *testing.T) {
t.Run("Reading dashboard acl should include default acl for parent folder and the child acl", func(t *testing.T) {
setup(t)
- err := testHelperUpdateDashboardAcl(t, sqlStore, childDash.Id, models.DashboardAcl{
+ err := updateDashboardAcl(t, dashboardStore, childDash.Id, models.DashboardAcl{
OrgID: 1,
UserID: currentUser.Id,
DashboardID: childDash.Id,
@@ -155,7 +162,7 @@ func TestDashboardAclDataAccess(t *testing.T) {
t.Run("Add and delete dashboard permission", func(t *testing.T) {
setup(t)
- err := testHelperUpdateDashboardAcl(t, sqlStore, savedFolder.Id, models.DashboardAcl{
+ err := updateDashboardAcl(t, dashboardStore, savedFolder.Id, models.DashboardAcl{
OrgID: 1,
UserID: currentUser.Id,
DashboardID: savedFolder.Id,
@@ -174,7 +181,7 @@ func TestDashboardAclDataAccess(t *testing.T) {
require.Equal(t, currentUser.Login, q1.Result[0].UserLogin)
require.Equal(t, currentUser.Email, q1.Result[0].UserEmail)
- err = testHelperUpdateDashboardAcl(t, sqlStore, savedFolder.Id)
+ err = updateDashboardAcl(t, dashboardStore, savedFolder.Id)
require.Nil(t, err)
q3 := &models.GetDashboardAclInfoListQuery{DashboardID: savedFolder.Id, OrgID: 1}
@@ -188,7 +195,7 @@ func TestDashboardAclDataAccess(t *testing.T) {
team1, err := sqlStore.CreateTeam("group1 name", "", 1)
require.Nil(t, err)
- err = testHelperUpdateDashboardAcl(t, sqlStore, savedFolder.Id, models.DashboardAcl{
+ err = updateDashboardAcl(t, dashboardStore, savedFolder.Id, models.DashboardAcl{
OrgID: 1,
TeamID: team1.Id,
DashboardID: savedFolder.Id,
@@ -208,7 +215,7 @@ func TestDashboardAclDataAccess(t *testing.T) {
setup(t)
team1, err := sqlStore.CreateTeam("group1 name", "", 1)
require.Nil(t, err)
- err = testHelperUpdateDashboardAcl(t, sqlStore, savedFolder.Id, models.DashboardAcl{
+ err = updateDashboardAcl(t, dashboardStore, savedFolder.Id, models.DashboardAcl{
OrgID: 1,
TeamID: team1.Id,
DashboardID: savedFolder.Id,
@@ -229,7 +236,7 @@ func TestDashboardAclDataAccess(t *testing.T) {
t.Run("Default permissions for root folder dashboards", func(t *testing.T) {
setup(t)
var rootFolderId int64 = 0
- sqlStore := InitTestDB(t)
+ sqlStore := sqlstore.InitTestDB(t)
query := models.GetDashboardAclInfoListQuery{DashboardID: rootFolderId, OrgID: 1}
@@ -246,3 +253,54 @@ func TestDashboardAclDataAccess(t *testing.T) {
require.False(t, query.Result[1].Inherited)
})
}
+
+func createUser(t *testing.T, sqlStore *sqlstore.SQLStore, name string, role string, isAdmin bool) models.User {
+ t.Helper()
+ setting.AutoAssignOrg = true
+ setting.AutoAssignOrgId = 1
+ setting.AutoAssignOrgRole = role
+ currentUserCmd := models.CreateUserCommand{Login: name, Email: name + "@test.com", Name: "a " + name, IsAdmin: isAdmin}
+ currentUser, err := sqlStore.CreateUser(context.Background(), currentUserCmd)
+ require.NoError(t, err)
+ q1 := models.GetUserOrgListQuery{UserId: currentUser.Id}
+ err = sqlStore.GetUserOrgList(context.Background(), &q1)
+ require.NoError(t, err)
+ require.Equal(t, models.RoleType(role), q1.Result[0].Role)
+ return *currentUser
+}
+
+func insertTestDashboard(t *testing.T, dashboardStore *database.DashboardStore, 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,
+ }),
+ }
+ dash, err := dashboardStore.SaveDashboard(cmd)
+ require.NoError(t, err)
+ require.NotNil(t, dash)
+ dash.Data.Set("id", dash.Id)
+ dash.Data.Set("uid", dash.Uid)
+ return dash
+}
+
+func updateDashboardAcl(t *testing.T, dashboardStore *database.DashboardStore, dashboardID int64,
+ items ...models.DashboardAcl) error {
+ t.Helper()
+
+ var itemPtrs []*models.DashboardAcl
+ for _, it := range items {
+ item := it
+ item.Created = time.Now()
+ item.Updated = time.Now()
+ itemPtrs = append(itemPtrs, &item)
+ }
+
+ return dashboardStore.UpdateDashboardACL(context.Background(), dashboardID, itemPtrs)
+}
diff --git a/pkg/services/dashboards/folder.go b/pkg/services/dashboards/folder.go
new file mode 100644
index 00000000000..42660db3f76
--- /dev/null
+++ b/pkg/services/dashboards/folder.go
@@ -0,0 +1,20 @@
+package dashboards
+
+import (
+ "context"
+
+ "github.com/grafana/grafana/pkg/models"
+)
+
+//go:generate mockery --name FolderService --structname FakeFolderService --inpackage --filename folder_service_mock.go
+// FolderService is a service for operating on folders.
+type FolderService interface {
+ GetFolders(ctx context.Context, user *models.SignedInUser, orgID int64, limit int64, page int64) ([]*models.Folder, error)
+ GetFolderByID(ctx context.Context, user *models.SignedInUser, id int64, orgID int64) (*models.Folder, error)
+ GetFolderByUID(ctx context.Context, user *models.SignedInUser, orgID int64, uid string) (*models.Folder, error)
+ GetFolderByTitle(ctx context.Context, user *models.SignedInUser, orgID int64, title string) (*models.Folder, error)
+ CreateFolder(ctx context.Context, user *models.SignedInUser, orgID int64, title, uid string) (*models.Folder, error)
+ UpdateFolder(ctx context.Context, user *models.SignedInUser, orgID int64, existingUid string, cmd *models.UpdateFolderCommand) error
+ DeleteFolder(ctx context.Context, user *models.SignedInUser, orgID int64, uid string, forceDeleteRules bool) (*models.Folder, error)
+ MakeUserAdmin(ctx context.Context, orgID int64, userID, folderID int64, setViewAndEditPermissions bool) error
+}
diff --git a/pkg/services/dashboards/folder_service_mock.go b/pkg/services/dashboards/folder_service_mock.go
new file mode 100644
index 00000000000..3fcc72601b2
--- /dev/null
+++ b/pkg/services/dashboards/folder_service_mock.go
@@ -0,0 +1,181 @@
+// Code generated by mockery v2.10.0. DO NOT EDIT.
+
+package dashboards
+
+import (
+ context "context"
+
+ models "github.com/grafana/grafana/pkg/models"
+ mock "github.com/stretchr/testify/mock"
+)
+
+// FakeFolderService is an autogenerated mock type for the FolderService type
+type FakeFolderService struct {
+ mock.Mock
+}
+
+// CreateFolder provides a mock function with given fields: ctx, user, orgID, title, uid
+func (_m *FakeFolderService) CreateFolder(ctx context.Context, user *models.SignedInUser, orgID int64, title string, uid string) (*models.Folder, error) {
+ ret := _m.Called(ctx, user, orgID, title, uid)
+
+ var r0 *models.Folder
+ if rf, ok := ret.Get(0).(func(context.Context, *models.SignedInUser, int64, string, string) *models.Folder); ok {
+ r0 = rf(ctx, user, orgID, title, uid)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(*models.Folder)
+ }
+ }
+
+ var r1 error
+ if rf, ok := ret.Get(1).(func(context.Context, *models.SignedInUser, int64, string, string) error); ok {
+ r1 = rf(ctx, user, orgID, title, uid)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// DeleteFolder provides a mock function with given fields: ctx, user, orgID, uid, forceDeleteRules
+func (_m *FakeFolderService) DeleteFolder(ctx context.Context, user *models.SignedInUser, orgID int64, uid string, forceDeleteRules bool) (*models.Folder, error) {
+ ret := _m.Called(ctx, user, orgID, uid, forceDeleteRules)
+
+ var r0 *models.Folder
+ if rf, ok := ret.Get(0).(func(context.Context, *models.SignedInUser, int64, string, bool) *models.Folder); ok {
+ r0 = rf(ctx, user, orgID, uid, forceDeleteRules)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(*models.Folder)
+ }
+ }
+
+ var r1 error
+ if rf, ok := ret.Get(1).(func(context.Context, *models.SignedInUser, int64, string, bool) error); ok {
+ r1 = rf(ctx, user, orgID, uid, forceDeleteRules)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// GetFolderByID provides a mock function with given fields: ctx, user, id, orgID
+func (_m *FakeFolderService) GetFolderByID(ctx context.Context, user *models.SignedInUser, id int64, orgID int64) (*models.Folder, error) {
+ ret := _m.Called(ctx, user, id, orgID)
+
+ var r0 *models.Folder
+ if rf, ok := ret.Get(0).(func(context.Context, *models.SignedInUser, int64, int64) *models.Folder); ok {
+ r0 = rf(ctx, user, id, orgID)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(*models.Folder)
+ }
+ }
+
+ var r1 error
+ if rf, ok := ret.Get(1).(func(context.Context, *models.SignedInUser, int64, int64) error); ok {
+ r1 = rf(ctx, user, id, orgID)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// GetFolderByTitle provides a mock function with given fields: ctx, user, orgID, title
+func (_m *FakeFolderService) GetFolderByTitle(ctx context.Context, user *models.SignedInUser, orgID int64, title string) (*models.Folder, error) {
+ ret := _m.Called(ctx, user, orgID, title)
+
+ var r0 *models.Folder
+ if rf, ok := ret.Get(0).(func(context.Context, *models.SignedInUser, int64, string) *models.Folder); ok {
+ r0 = rf(ctx, user, orgID, title)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(*models.Folder)
+ }
+ }
+
+ var r1 error
+ if rf, ok := ret.Get(1).(func(context.Context, *models.SignedInUser, int64, string) error); ok {
+ r1 = rf(ctx, user, orgID, title)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// GetFolderByUID provides a mock function with given fields: ctx, user, orgID, uid
+func (_m *FakeFolderService) GetFolderByUID(ctx context.Context, user *models.SignedInUser, orgID int64, uid string) (*models.Folder, error) {
+ ret := _m.Called(ctx, user, orgID, uid)
+
+ var r0 *models.Folder
+ if rf, ok := ret.Get(0).(func(context.Context, *models.SignedInUser, int64, string) *models.Folder); ok {
+ r0 = rf(ctx, user, orgID, uid)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(*models.Folder)
+ }
+ }
+
+ var r1 error
+ if rf, ok := ret.Get(1).(func(context.Context, *models.SignedInUser, int64, string) error); ok {
+ r1 = rf(ctx, user, orgID, uid)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// GetFolders provides a mock function with given fields: ctx, user, orgID, limit, page
+func (_m *FakeFolderService) GetFolders(ctx context.Context, user *models.SignedInUser, orgID int64, limit int64, page int64) ([]*models.Folder, error) {
+ ret := _m.Called(ctx, user, orgID, limit, page)
+
+ var r0 []*models.Folder
+ if rf, ok := ret.Get(0).(func(context.Context, *models.SignedInUser, int64, int64, int64) []*models.Folder); ok {
+ r0 = rf(ctx, user, orgID, limit, page)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).([]*models.Folder)
+ }
+ }
+
+ var r1 error
+ if rf, ok := ret.Get(1).(func(context.Context, *models.SignedInUser, int64, int64, int64) error); ok {
+ r1 = rf(ctx, user, orgID, limit, page)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MakeUserAdmin provides a mock function with given fields: ctx, orgID, userID, folderID, setViewAndEditPermissions
+func (_m *FakeFolderService) MakeUserAdmin(ctx context.Context, orgID int64, userID int64, folderID int64, setViewAndEditPermissions bool) error {
+ ret := _m.Called(ctx, orgID, userID, folderID, setViewAndEditPermissions)
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(context.Context, int64, int64, int64, bool) error); ok {
+ r0 = rf(ctx, orgID, userID, folderID, setViewAndEditPermissions)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// UpdateFolder provides a mock function with given fields: ctx, user, orgID, existingUid, cmd
+func (_m *FakeFolderService) UpdateFolder(ctx context.Context, user *models.SignedInUser, orgID int64, existingUid string, cmd *models.UpdateFolderCommand) error {
+ ret := _m.Called(ctx, user, orgID, existingUid, cmd)
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(context.Context, *models.SignedInUser, int64, string, *models.UpdateFolderCommand) error); ok {
+ r0 = rf(ctx, user, orgID, existingUid, cmd)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
diff --git a/pkg/services/dashboards/dashboard_service.go b/pkg/services/dashboards/manager/dashboard_service.go
similarity index 60%
rename from pkg/services/dashboards/dashboard_service.go
rename to pkg/services/dashboards/manager/dashboard_service.go
index 1c0035d8737..759582dfcdd 100644
--- a/pkg/services/dashboards/dashboard_service.go
+++ b/pkg/services/dashboards/manager/dashboard_service.go
@@ -1,4 +1,4 @@
-package dashboards
+package service
import (
"context"
@@ -7,86 +7,42 @@ import (
"time"
"github.com/grafana/grafana-plugin-sdk-go/backend/gtime"
- "github.com/grafana/grafana/pkg/dashboards"
- "github.com/grafana/grafana/pkg/services/alerting"
- "github.com/grafana/grafana/pkg/setting"
-
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
+ "github.com/grafana/grafana/pkg/services/alerting"
+ m "github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/guardian"
+ "github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
"github.com/grafana/grafana/pkg/util/errutil"
)
-// DashboardService is a service for operating on dashboards.
-type DashboardService interface {
- SaveDashboard(ctx context.Context, dto *SaveDashboardDTO, allowUiUpdate bool) (*models.Dashboard, error)
- ImportDashboard(ctx context.Context, dto *SaveDashboardDTO) (*models.Dashboard, error)
- DeleteDashboard(ctx context.Context, dashboardId int64, orgId int64) error
- MakeUserAdmin(ctx context.Context, orgID int64, userID, dashboardID int64, setViewAndEditPermissions bool) error
+type DashboardServiceImpl struct {
+ dashboardStore m.Store
+ log log.Logger
}
-// DashboardProvisioningService is a service for operating on provisioned dashboards.
-type DashboardProvisioningService interface {
- SaveProvisionedDashboard(ctx context.Context, dto *SaveDashboardDTO, provisioning *models.DashboardProvisioning) (*models.Dashboard, error)
- SaveFolderForProvisionedDashboards(context.Context, *SaveDashboardDTO) (*models.Dashboard, error)
- GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error)
- GetProvisionedDashboardDataByDashboardUID(orgID int64, dashboardUID string) (*models.DashboardProvisioning, error)
- GetProvisionedDashboardDataByDashboardID(dashboardID int64) (*models.DashboardProvisioning, error)
- UnprovisionDashboard(ctx context.Context, dashboardID int64) error
- DeleteProvisionedDashboard(ctx context.Context, dashboardID int64, orgID int64) error
-}
-
-// NewService is a factory for creating a new dashboard service.
-var NewService = func(store dashboards.Store) DashboardService {
- return &dashboardServiceImpl{
+func ProvideDashboardService(store m.Store) *DashboardServiceImpl {
+ return &DashboardServiceImpl{
dashboardStore: store,
log: log.New("dashboard-service"),
}
}
-// NewProvisioningService is a factory for creating a new dashboard provisioning service.
-var NewProvisioningService = func(store dashboards.Store) DashboardProvisioningService {
- return NewService(store).(*dashboardServiceImpl)
-}
-
-type SaveDashboardDTO struct {
- OrgId int64
- UpdatedAt time.Time
- User *models.SignedInUser
- Message string
- Overwrite bool
- Dashboard *models.Dashboard
-}
-
-type dashboardServiceImpl struct {
- dashboardStore dashboards.Store
- orgId int64
- user *models.SignedInUser
- log log.Logger
-}
-
-func (dr *dashboardServiceImpl) GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error) {
+func (dr *DashboardServiceImpl) GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error) {
return dr.dashboardStore.GetProvisionedDashboardData(name)
}
-// GetProvisionedData gets provisioned dashboard data.
-//
-// Stubbable by tests.
-var GetProvisionedData = func(store dashboards.Store, dashboardID int64) (*models.DashboardProvisioning, error) {
- return store.GetProvisionedDataByDashboardID(dashboardID)
+func (dr *DashboardServiceImpl) GetProvisionedDashboardDataByDashboardID(dashboardID int64) (*models.DashboardProvisioning, error) {
+ return dr.dashboardStore.GetProvisionedDataByDashboardID(dashboardID)
}
-func (dr *dashboardServiceImpl) GetProvisionedDashboardDataByDashboardID(dashboardID int64) (*models.DashboardProvisioning, error) {
- return GetProvisionedData(dr.dashboardStore, dashboardID)
-}
-
-func (dr *dashboardServiceImpl) GetProvisionedDashboardDataByDashboardUID(orgID int64, dashboardUID string) (*models.DashboardProvisioning, error) {
+func (dr *DashboardServiceImpl) GetProvisionedDashboardDataByDashboardUID(orgID int64, dashboardUID string) (*models.DashboardProvisioning, error) {
return dr.dashboardStore.GetProvisionedDataByDashboardUID(orgID, dashboardUID)
}
-func (dr *dashboardServiceImpl) buildSaveDashboardCommand(ctx context.Context, dto *SaveDashboardDTO, shouldValidateAlerts bool,
+func (dr *DashboardServiceImpl) BuildSaveDashboardCommand(ctx context.Context, dto *m.SaveDashboardDTO, shouldValidateAlerts bool,
validateProvisionedDashboard bool) (*models.SaveDashboardCommand, error) {
dash := dto.Dashboard
@@ -175,6 +131,10 @@ func (dr *dashboardServiceImpl) buildSaveDashboardCommand(ctx context.Context, d
return cmd, nil
}
+func (dr *DashboardServiceImpl) UpdateDashboardACL(ctx context.Context, uid int64, items []*models.DashboardAcl) error {
+ return dr.dashboardStore.UpdateDashboardACL(ctx, uid, items)
+}
+
var validateAlerts = func(ctx context.Context, dash *models.Dashboard, user *models.SignedInUser) error {
extractor := alerting.NewDashAlertExtractor(dash, dash.OrgId, user)
return extractor.ValidateAlerts(ctx)
@@ -210,7 +170,7 @@ func validateDashboardRefreshInterval(dash *models.Dashboard) error {
// UpdateAlerting updates alerting.
//
// Stubbable by tests.
-var UpdateAlerting = func(ctx context.Context, store dashboards.Store, orgID int64, dashboard *models.Dashboard, user *models.SignedInUser) error {
+var UpdateAlerting = func(ctx context.Context, store m.Store, orgID int64, dashboard *models.Dashboard, user *models.SignedInUser) error {
extractor := alerting.NewDashAlertExtractor(dashboard, orgID, user)
alerts, err := extractor.GetAlerts(ctx)
if err != nil {
@@ -220,7 +180,7 @@ var UpdateAlerting = func(ctx context.Context, store dashboards.Store, orgID int
return store.SaveAlerts(ctx, dashboard.Id, alerts)
}
-func (dr *dashboardServiceImpl) SaveProvisionedDashboard(ctx context.Context, dto *SaveDashboardDTO,
+func (dr *DashboardServiceImpl) SaveProvisionedDashboard(ctx context.Context, dto *m.SaveDashboardDTO,
provisioning *models.DashboardProvisioning) (*models.Dashboard, error) {
if err := validateDashboardRefreshInterval(dto.Dashboard); err != nil {
dr.log.Warn("Changing refresh interval for provisioned dashboard to minimum refresh interval", "dashboardUid",
@@ -234,7 +194,7 @@ func (dr *dashboardServiceImpl) SaveProvisionedDashboard(ctx context.Context, dt
OrgId: dto.OrgId,
}
- cmd, err := dr.buildSaveDashboardCommand(ctx, dto, true, false)
+ cmd, err := dr.BuildSaveDashboardCommand(ctx, dto, true, false)
if err != nil {
return nil, err
}
@@ -253,12 +213,12 @@ func (dr *dashboardServiceImpl) SaveProvisionedDashboard(ctx context.Context, dt
return dash, nil
}
-func (dr *dashboardServiceImpl) SaveFolderForProvisionedDashboards(ctx context.Context, dto *SaveDashboardDTO) (*models.Dashboard, error) {
+func (dr *DashboardServiceImpl) SaveFolderForProvisionedDashboards(ctx context.Context, dto *m.SaveDashboardDTO) (*models.Dashboard, error) {
dto.User = &models.SignedInUser{
UserId: 0,
OrgRole: models.ROLE_ADMIN,
}
- cmd, err := dr.buildSaveDashboardCommand(ctx, dto, false, false)
+ cmd, err := dr.BuildSaveDashboardCommand(ctx, dto, false, false)
if err != nil {
return nil, err
}
@@ -275,7 +235,7 @@ func (dr *dashboardServiceImpl) SaveFolderForProvisionedDashboards(ctx context.C
return dash, nil
}
-func (dr *dashboardServiceImpl) SaveDashboard(ctx context.Context, dto *SaveDashboardDTO,
+func (dr *DashboardServiceImpl) SaveDashboard(ctx context.Context, dto *m.SaveDashboardDTO,
allowUiUpdate bool) (*models.Dashboard, error) {
if err := validateDashboardRefreshInterval(dto.Dashboard); err != nil {
dr.log.Warn("Changing refresh interval for imported dashboard to minimum refresh interval",
@@ -284,7 +244,7 @@ func (dr *dashboardServiceImpl) SaveDashboard(ctx context.Context, dto *SaveDash
dto.Dashboard.Data.Set("refresh", setting.MinRefreshInterval)
}
- cmd, err := dr.buildSaveDashboardCommand(ctx, dto, true, !allowUiUpdate)
+ cmd, err := dr.BuildSaveDashboardCommand(ctx, dto, true, !allowUiUpdate)
if err != nil {
return nil, err
}
@@ -303,16 +263,59 @@ func (dr *dashboardServiceImpl) SaveDashboard(ctx context.Context, dto *SaveDash
// DeleteDashboard removes dashboard from the DB. Errors out if the dashboard was provisioned. Should be used for
// operations by the user where we want to make sure user does not delete provisioned dashboard.
-func (dr *dashboardServiceImpl) DeleteDashboard(ctx context.Context, dashboardId int64, orgId int64) error {
+func (dr *DashboardServiceImpl) DeleteDashboard(ctx context.Context, dashboardId int64, orgId int64) error {
return dr.deleteDashboard(ctx, dashboardId, orgId, true)
}
+func (dr *DashboardServiceImpl) MakeUserAdmin(ctx context.Context, orgID int64, userID int64, dashboardID int64, setViewAndEditPermissions bool) error {
+ rtEditor := models.ROLE_EDITOR
+ rtViewer := models.ROLE_VIEWER
+
+ items := []*models.DashboardAcl{
+ {
+ OrgID: orgID,
+ DashboardID: dashboardID,
+ UserID: userID,
+ Permission: models.PERMISSION_ADMIN,
+ Created: time.Now(),
+ Updated: time.Now(),
+ },
+ }
+
+ if setViewAndEditPermissions {
+ items = append(items,
+ &models.DashboardAcl{
+ OrgID: orgID,
+ DashboardID: dashboardID,
+ Role: &rtEditor,
+ Permission: models.PERMISSION_EDIT,
+ Created: time.Now(),
+ Updated: time.Now(),
+ },
+ &models.DashboardAcl{
+ OrgID: orgID,
+ DashboardID: dashboardID,
+ Role: &rtViewer,
+ Permission: models.PERMISSION_VIEW,
+ Created: time.Now(),
+ Updated: time.Now(),
+ },
+ )
+ }
+
+ if err := dr.dashboardStore.UpdateDashboardACL(ctx, dashboardID, items); err != nil {
+ return err
+ }
+
+ return nil
+}
+
// DeleteProvisionedDashboard removes dashboard from the DB even if it is provisioned.
-func (dr *dashboardServiceImpl) DeleteProvisionedDashboard(ctx context.Context, dashboardId int64, orgId int64) error {
+func (dr *DashboardServiceImpl) DeleteProvisionedDashboard(ctx context.Context, dashboardId int64, orgId int64) error {
return dr.deleteDashboard(ctx, dashboardId, orgId, false)
}
-func (dr *dashboardServiceImpl) deleteDashboard(ctx context.Context, dashboardId int64, orgId int64, validateProvisionedDashboard bool) error {
+func (dr *DashboardServiceImpl) deleteDashboard(ctx context.Context, dashboardId int64, orgId int64, validateProvisionedDashboard bool) error {
if validateProvisionedDashboard {
provisionedData, err := dr.GetProvisionedDashboardDataByDashboardID(dashboardId)
if err != nil {
@@ -327,7 +330,7 @@ func (dr *dashboardServiceImpl) deleteDashboard(ctx context.Context, dashboardId
return bus.Dispatch(ctx, cmd)
}
-func (dr *dashboardServiceImpl) ImportDashboard(ctx context.Context, dto *SaveDashboardDTO) (
+func (dr *DashboardServiceImpl) ImportDashboard(ctx context.Context, dto *m.SaveDashboardDTO) (
*models.Dashboard, error) {
if err := validateDashboardRefreshInterval(dto.Dashboard); err != nil {
dr.log.Warn("Changing refresh interval for imported dashboard to minimum refresh interval",
@@ -336,7 +339,7 @@ func (dr *dashboardServiceImpl) ImportDashboard(ctx context.Context, dto *SaveDa
dto.Dashboard.Data.Set("refresh", setting.MinRefreshInterval)
}
- cmd, err := dr.buildSaveDashboardCommand(ctx, dto, false, true)
+ cmd, err := dr.BuildSaveDashboardCommand(ctx, dto, false, true)
if err != nil {
return nil, err
}
@@ -351,50 +354,6 @@ func (dr *dashboardServiceImpl) ImportDashboard(ctx context.Context, dto *SaveDa
// UnprovisionDashboard removes info about dashboard being provisioned. Used after provisioning configs are changed
// and provisioned dashboards are left behind but not deleted.
-func (dr *dashboardServiceImpl) UnprovisionDashboard(ctx context.Context, dashboardId int64) error {
- cmd := &models.UnprovisionDashboardCommand{Id: dashboardId}
- return bus.Dispatch(ctx, cmd)
-}
-
-type FakeDashboardService struct {
- DashboardService
-
- SaveDashboardResult *models.Dashboard
- SaveDashboardError error
- SavedDashboards []*SaveDashboardDTO
- ProvisionedDashData *models.DashboardProvisioning
-}
-
-func (s *FakeDashboardService) SaveDashboard(ctx context.Context, dto *SaveDashboardDTO, allowUiUpdate bool) (*models.Dashboard, error) {
- s.SavedDashboards = append(s.SavedDashboards, dto)
-
- if s.SaveDashboardResult == nil && s.SaveDashboardError == nil {
- s.SaveDashboardResult = dto.Dashboard
- }
-
- return s.SaveDashboardResult, s.SaveDashboardError
-}
-
-func (s *FakeDashboardService) ImportDashboard(ctx context.Context, dto *SaveDashboardDTO) (*models.Dashboard, error) {
- return s.SaveDashboard(ctx, dto, true)
-}
-
-func (s *FakeDashboardService) DeleteDashboard(ctx context.Context, dashboardId int64, orgId int64) error {
- for index, dash := range s.SavedDashboards {
- if dash.Dashboard.Id == dashboardId && dash.OrgId == orgId {
- s.SavedDashboards = append(s.SavedDashboards[:index], s.SavedDashboards[index+1:]...)
- break
- }
- }
- return nil
-}
-
-func (s *FakeDashboardService) GetProvisionedDashboardDataByDashboardID(id int64) (*models.DashboardProvisioning, error) {
- return s.ProvisionedDashData, nil
-}
-
-func MockDashboardService(mock *FakeDashboardService) {
- NewService = func(dashboards.Store) DashboardService {
- return mock
- }
+func (dr *DashboardServiceImpl) UnprovisionDashboard(ctx context.Context, dashboardId int64) error {
+ return dr.dashboardStore.UnprovisionDashboard(ctx, dashboardId)
}
diff --git a/pkg/services/dashboards/dashboard_service_integration_test.go b/pkg/services/dashboards/manager/dashboard_service_integration_test.go
similarity index 96%
rename from pkg/services/dashboards/dashboard_service_integration_test.go
rename to pkg/services/dashboards/manager/dashboard_service_integration_test.go
index 8f6fba2d402..00bc7ca305b 100644
--- a/pkg/services/dashboards/dashboard_service_integration_test.go
+++ b/pkg/services/dashboards/manager/dashboard_service_integration_test.go
@@ -1,20 +1,20 @@
//go:build integration
// +build integration
-package dashboards
+package service
import (
"context"
"testing"
"github.com/grafana/grafana/pkg/components/simplejson"
- "github.com/grafana/grafana/pkg/dashboards"
+ "github.com/grafana/grafana/pkg/models"
+ dashbboardservice "github.com/grafana/grafana/pkg/services/dashboards"
+ "github.com/grafana/grafana/pkg/services/dashboards/database"
"github.com/grafana/grafana/pkg/services/guardian"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
-
- "github.com/grafana/grafana/pkg/models"
)
const testOrgID int64 = 1
@@ -25,7 +25,7 @@ func TestIntegratedDashboardService(t *testing.T) {
t.Cleanup(func() {
UpdateAlerting = origUpdateAlerting
})
- UpdateAlerting = func(ctx context.Context, store dashboards.Store, orgID int64, dashboard *models.Dashboard, user *models.SignedInUser) error {
+ UpdateAlerting = func(ctx context.Context, store dashbboardservice.Store, orgID int64, dashboard *models.Dashboard, user *models.SignedInUser) error {
return nil
}
@@ -860,7 +860,8 @@ func callSaveWithResult(t *testing.T, cmd models.SaveDashboardCommand, sqlStore
t.Helper()
dto := toSaveDashboardDto(cmd)
- res, err := NewService(sqlStore).SaveDashboard(context.Background(), &dto, false)
+ dashboardStore := database.ProvideDashboardStore(sqlStore)
+ res, err := ProvideDashboardService(dashboardStore).SaveDashboard(context.Background(), &dto, false)
require.NoError(t, err)
return res
@@ -868,7 +869,8 @@ func callSaveWithResult(t *testing.T, cmd models.SaveDashboardCommand, sqlStore
func callSaveWithError(cmd models.SaveDashboardCommand, sqlStore *sqlstore.SQLStore) error {
dto := toSaveDashboardDto(cmd)
- _, err := NewService(sqlStore).SaveDashboard(context.Background(), &dto, false)
+ dashboardStore := database.ProvideDashboardStore(sqlStore)
+ _, err := ProvideDashboardService(dashboardStore).SaveDashboard(context.Background(), &dto, false)
return err
}
@@ -885,7 +887,7 @@ func saveTestDashboard(t *testing.T, title string, orgID, folderID int64, sqlSto
}),
}
- dto := SaveDashboardDTO{
+ dto := dashbboardservice.SaveDashboardDTO{
OrgId: orgID,
Dashboard: cmd.GetDashboardModel(),
User: &models.SignedInUser{
@@ -894,7 +896,8 @@ func saveTestDashboard(t *testing.T, title string, orgID, folderID int64, sqlSto
},
}
- res, err := NewService(sqlStore).SaveDashboard(context.Background(), &dto, false)
+ dashboardStore := database.ProvideDashboardStore(sqlStore)
+ res, err := ProvideDashboardService(dashboardStore).SaveDashboard(context.Background(), &dto, false)
require.NoError(t, err)
return res
@@ -912,7 +915,7 @@ func saveTestFolder(t *testing.T, title string, orgID int64, sqlStore *sqlstore.
}),
}
- dto := SaveDashboardDTO{
+ dto := dashbboardservice.SaveDashboardDTO{
OrgId: orgID,
Dashboard: cmd.GetDashboardModel(),
User: &models.SignedInUser{
@@ -921,16 +924,17 @@ func saveTestFolder(t *testing.T, title string, orgID int64, sqlStore *sqlstore.
},
}
- res, err := NewService(sqlStore).SaveDashboard(context.Background(), &dto, false)
+ dashboardStore := database.ProvideDashboardStore(sqlStore)
+ res, err := ProvideDashboardService(dashboardStore).SaveDashboard(context.Background(), &dto, false)
require.NoError(t, err)
return res
}
-func toSaveDashboardDto(cmd models.SaveDashboardCommand) SaveDashboardDTO {
+func toSaveDashboardDto(cmd models.SaveDashboardCommand) dashbboardservice.SaveDashboardDTO {
dash := (&cmd).GetDashboardModel()
- return SaveDashboardDTO{
+ return dashbboardservice.SaveDashboardDTO{
Dashboard: dash,
Message: cmd.Message,
OrgId: cmd.OrgId,
diff --git a/pkg/services/dashboards/dashboard_service_test.go b/pkg/services/dashboards/manager/dashboard_service_test.go
similarity index 59%
rename from pkg/services/dashboards/dashboard_service_test.go
rename to pkg/services/dashboards/manager/dashboard_service_test.go
index ec92ab0f6b7..1fb721e2e9f 100644
--- a/pkg/services/dashboards/dashboard_service_test.go
+++ b/pkg/services/dashboards/manager/dashboard_service_test.go
@@ -1,18 +1,22 @@
-package dashboards
+//go:build integration
+// +build integration
+
+package service
import (
"context"
- "fmt"
+ "errors"
"testing"
- "github.com/grafana/grafana/pkg/dashboards"
- "github.com/grafana/grafana/pkg/infra/log"
- "github.com/grafana/grafana/pkg/setting"
-
"github.com/grafana/grafana/pkg/bus"
+ "github.com/grafana/grafana/pkg/components/simplejson"
+ "github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
+ m "github.com/grafana/grafana/pkg/services/dashboards"
+ "github.com/grafana/grafana/pkg/services/dashboards/database"
"github.com/grafana/grafana/pkg/services/guardian"
-
+ "github.com/grafana/grafana/pkg/setting"
+ "github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
@@ -20,8 +24,9 @@ func TestDashboardService(t *testing.T) {
t.Run("Dashboard service tests", func(t *testing.T) {
bus.ClearBusHandlers()
- fakeStore := fakeDashboardStore{}
- service := &dashboardServiceImpl{
+ fakeStore := database.FakeDashboardStore{}
+ defer fakeStore.AssertExpectations(t)
+ service := &DashboardServiceImpl{
log: log.New("test.logger"),
dashboardStore: &fakeStore,
}
@@ -31,7 +36,7 @@ func TestDashboardService(t *testing.T) {
guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: true})
t.Run("Save dashboard validation", func(t *testing.T) {
- dto := &SaveDashboardDTO{}
+ dto := &m.SaveDashboardDTO{}
t.Run("When saving a dashboard with empty title it should return error", func(t *testing.T) {
titles := []string{"", " ", " \t "}
@@ -57,14 +62,6 @@ func TestDashboardService(t *testing.T) {
})
t.Run("When saving a dashboard should validate uid", func(t *testing.T) {
- origValidateAlerts := validateAlerts
- t.Cleanup(func() {
- validateAlerts = origValidateAlerts
- })
- validateAlerts = func(ctx context.Context, dash *models.Dashboard, user *models.SignedInUser) error {
- return nil
- }
-
testCases := []struct {
Uid string
Error error
@@ -83,24 +80,17 @@ func TestDashboardService(t *testing.T) {
dto.Dashboard.SetUid(tc.Uid)
dto.User = &models.SignedInUser{}
- _, err := service.buildSaveDashboardCommand(context.Background(), dto, true, false)
+ if tc.Error == nil {
+ fakeStore.On("ValidateDashboardBeforeSave", mock.Anything, mock.Anything).Return(true, nil).Once()
+ }
+ _, err := service.BuildSaveDashboardCommand(context.Background(), dto, true, false)
require.Equal(t, err, tc.Error)
}
})
t.Run("Should return validation error if dashboard is provisioned", func(t *testing.T) {
- t.Cleanup(func() {
- fakeStore.provisionedData = nil
- })
- fakeStore.provisionedData = &models.DashboardProvisioning{}
-
- origValidateAlerts := validateAlerts
- t.Cleanup(func() {
- validateAlerts = origValidateAlerts
- })
- validateAlerts = func(ctx context.Context, dash *models.Dashboard, user *models.SignedInUser) error {
- return nil
- }
+ fakeStore.On("ValidateDashboardBeforeSave", mock.Anything, mock.Anything).Return(true, nil).Once()
+ fakeStore.On("GetProvisionedDataByDashboardID", mock.Anything).Return(&models.DashboardProvisioning{}, nil).Once()
dto.Dashboard = models.NewDashboard("Dash")
dto.Dashboard.SetId(3)
@@ -110,13 +100,9 @@ func TestDashboardService(t *testing.T) {
})
t.Run("Should not return validation error if dashboard is provisioned but UI updates allowed", func(t *testing.T) {
- origValidateAlerts := validateAlerts
- t.Cleanup(func() {
- validateAlerts = origValidateAlerts
- })
- validateAlerts = func(ctx context.Context, dash *models.Dashboard, user *models.SignedInUser) error {
- return nil
- }
+ fakeStore.On("ValidateDashboardBeforeSave", mock.Anything, mock.Anything).Return(true, nil).Once()
+ fakeStore.On("SaveDashboard", mock.Anything).Return(&models.Dashboard{Data: simplejson.New()}, nil).Once()
+ fakeStore.On("SaveAlerts", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once()
dto.Dashboard = models.NewDashboard("Dash")
dto.Dashboard.SetId(3)
@@ -126,40 +112,25 @@ func TestDashboardService(t *testing.T) {
})
t.Run("Should return validation error if alert data is invalid", func(t *testing.T) {
- origValidateAlerts := validateAlerts
- t.Cleanup(func() {
- validateAlerts = origValidateAlerts
- })
- validateAlerts = func(ctx context.Context, dash *models.Dashboard, user *models.SignedInUser) error {
- return fmt.Errorf("alert validation error")
- }
+ fakeStore.On("ValidateDashboardBeforeSave", mock.Anything, mock.Anything).Return(true, nil).Once()
+ fakeStore.On("GetProvisionedDataByDashboardID", mock.Anything).Return(nil, nil).Once()
+ fakeStore.On("SaveDashboard", mock.Anything).Return(&models.Dashboard{Data: simplejson.New()}, nil).Once()
+ fakeStore.On("SaveAlerts", mock.Anything, mock.Anything, mock.Anything).Return(errors.New("alert validation error")).Once()
dto.Dashboard = models.NewDashboard("Dash")
+ dto.User = &models.SignedInUser{UserId: 1}
_, err := service.SaveDashboard(context.Background(), dto, false)
require.Equal(t, err.Error(), "alert validation error")
})
})
t.Run("Save provisioned dashboard validation", func(t *testing.T) {
- dto := &SaveDashboardDTO{}
+ dto := &m.SaveDashboardDTO{}
t.Run("Should not return validation error if dashboard is provisioned", func(t *testing.T) {
- origUpdateAlerting := UpdateAlerting
- t.Cleanup(func() {
- UpdateAlerting = origUpdateAlerting
- })
- UpdateAlerting = func(ctx context.Context, store dashboards.Store, orgID int64, dashboard *models.Dashboard,
- user *models.SignedInUser) error {
- return nil
- }
-
- origValidateAlerts := validateAlerts
- t.Cleanup(func() {
- validateAlerts = origValidateAlerts
- })
- validateAlerts = func(ctx context.Context, dash *models.Dashboard, user *models.SignedInUser) error {
- return nil
- }
+ fakeStore.On("ValidateDashboardBeforeSave", mock.Anything, mock.Anything).Return(true, nil).Once()
+ fakeStore.On("SaveProvisionedDashboard", mock.Anything, mock.Anything).Return(&models.Dashboard{Data: simplejson.New()}, nil).Once()
+ fakeStore.On("SaveAlerts", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once()
dto.Dashboard = models.NewDashboard("Dash")
dto.Dashboard.SetId(3)
@@ -169,27 +140,14 @@ func TestDashboardService(t *testing.T) {
})
t.Run("Should override invalid refresh interval if dashboard is provisioned", func(t *testing.T) {
+ fakeStore.On("ValidateDashboardBeforeSave", mock.Anything, mock.Anything).Return(true, nil).Once()
+ fakeStore.On("SaveProvisionedDashboard", mock.Anything, mock.Anything).Return(&models.Dashboard{Data: simplejson.New()}, nil).Once()
+ fakeStore.On("SaveAlerts", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once()
+
oldRefreshInterval := setting.MinRefreshInterval
setting.MinRefreshInterval = "5m"
defer func() { setting.MinRefreshInterval = oldRefreshInterval }()
- origValidateAlerts := validateAlerts
- t.Cleanup(func() {
- validateAlerts = origValidateAlerts
- })
- validateAlerts = func(ctx context.Context, dash *models.Dashboard, user *models.SignedInUser) error {
- return nil
- }
-
- origUpdateAlerting := UpdateAlerting
- t.Cleanup(func() {
- UpdateAlerting = origUpdateAlerting
- })
- UpdateAlerting = func(ctx context.Context, store dashboards.Store, orgID int64, dashboard *models.Dashboard,
- user *models.SignedInUser) error {
- return nil
- }
-
dto.Dashboard = models.NewDashboard("Dash")
dto.Dashboard.SetId(3)
dto.User = &models.SignedInUser{UserId: 1}
@@ -201,30 +159,11 @@ func TestDashboardService(t *testing.T) {
})
t.Run("Import dashboard validation", func(t *testing.T) {
- dto := &SaveDashboardDTO{}
+ dto := &m.SaveDashboardDTO{}
t.Run("Should return validation error if dashboard is provisioned", func(t *testing.T) {
- t.Cleanup(func() {
- fakeStore.provisionedData = nil
- })
- fakeStore.provisionedData = &models.DashboardProvisioning{}
-
- origValidateAlerts := validateAlerts
- t.Cleanup(func() {
- validateAlerts = origValidateAlerts
- })
- validateAlerts = func(ctx context.Context, dash *models.Dashboard, user *models.SignedInUser) error {
- return nil
- }
-
- origUpdateAlerting := UpdateAlerting
- t.Cleanup(func() {
- UpdateAlerting = origUpdateAlerting
- })
- UpdateAlerting = func(ctx context.Context, store dashboards.Store, orgID int64, dashboard *models.Dashboard,
- user *models.SignedInUser) error {
- return nil
- }
+ fakeStore.On("ValidateDashboardBeforeSave", mock.Anything, mock.Anything).Return(true, nil).Once()
+ fakeStore.On("GetProvisionedDataByDashboardID", mock.Anything).Return(&models.DashboardProvisioning{}, nil).Once()
dto.Dashboard = models.NewDashboard("Dash")
dto.Dashboard.SetId(3)
@@ -236,14 +175,15 @@ func TestDashboardService(t *testing.T) {
t.Run("Given provisioned dashboard", func(t *testing.T) {
t.Run("DeleteProvisionedDashboard should delete it", func(t *testing.T) {
- result := setupDeleteHandlers(t, &fakeStore, true)
+ result := setupDeleteHandlers(t)
err := service.DeleteProvisionedDashboard(context.Background(), 1, 1)
require.NoError(t, err)
require.True(t, result.deleteWasCalled)
})
t.Run("DeleteDashboard should fail to delete it", func(t *testing.T) {
- result := setupDeleteHandlers(t, &fakeStore, true)
+ fakeStore.On("GetProvisionedDataByDashboardID", mock.Anything).Return(&models.DashboardProvisioning{}, nil).Once()
+ result := setupDeleteHandlers(t)
err := service.DeleteDashboard(context.Background(), 1, 1)
require.Equal(t, err, models.ErrDashboardCannotDeleteProvisionedDashboard)
require.False(t, result.deleteWasCalled)
@@ -251,7 +191,7 @@ func TestDashboardService(t *testing.T) {
})
t.Run("Given non provisioned dashboard", func(t *testing.T) {
- result := setupDeleteHandlers(t, &fakeStore, false)
+ result := setupDeleteHandlers(t)
t.Run("DeleteProvisionedDashboard should delete it", func(t *testing.T) {
err := service.DeleteProvisionedDashboard(context.Background(), 1, 1)
@@ -260,6 +200,7 @@ func TestDashboardService(t *testing.T) {
})
t.Run("DeleteDashboard should delete it", func(t *testing.T) {
+ fakeStore.On("GetProvisionedDataByDashboardID", mock.Anything).Return(nil, nil).Once()
err := service.DeleteDashboard(context.Background(), 1, 1)
require.NoError(t, err)
require.True(t, result.deleteWasCalled)
@@ -272,16 +213,9 @@ type Result struct {
deleteWasCalled bool
}
-func setupDeleteHandlers(t *testing.T, fakeStore *fakeDashboardStore, provisioned bool) *Result {
+func setupDeleteHandlers(t *testing.T) *Result {
t.Helper()
- t.Cleanup(func() {
- fakeStore.provisionedData = nil
- })
- if provisioned {
- fakeStore.provisionedData = &models.DashboardProvisioning{}
- }
-
result := &Result{}
bus.AddHandler("test", func(ctx context.Context, cmd *models.DeleteDashboardCommand) error {
require.Equal(t, cmd.Id, int64(1))
@@ -292,32 +226,3 @@ func setupDeleteHandlers(t *testing.T, fakeStore *fakeDashboardStore, provisione
return result
}
-
-type fakeDashboardStore struct {
- dashboards.Store
-
- validationError error
- provisionedData *models.DashboardProvisioning
-}
-
-func (s *fakeDashboardStore) ValidateDashboardBeforeSave(dashboard *models.Dashboard, overwrite bool) (
- bool, error) {
- return false, s.validationError
-}
-
-func (s *fakeDashboardStore) GetProvisionedDataByDashboardID(int64) (*models.DashboardProvisioning, error) {
- return s.provisionedData, nil
-}
-
-func (s *fakeDashboardStore) SaveProvisionedDashboard(models.SaveDashboardCommand,
- *models.DashboardProvisioning) (*models.Dashboard, error) {
- return nil, nil
-}
-
-func (s *fakeDashboardStore) SaveDashboard(cmd models.SaveDashboardCommand) (*models.Dashboard, error) {
- return cmd.GetDashboardModel(), nil
-}
-
-func (s *fakeDashboardStore) SaveAlerts(ctx context.Context, dashID int64, alerts []*models.Alert) error {
- return nil
-}
diff --git a/pkg/services/dashboards/folder_service.go b/pkg/services/dashboards/manager/folder_service.go
similarity index 55%
rename from pkg/services/dashboards/folder_service.go
rename to pkg/services/dashboards/manager/folder_service.go
index b6f05b1e661..2876496bb12 100644
--- a/pkg/services/dashboards/folder_service.go
+++ b/pkg/services/dashboards/manager/folder_service.go
@@ -1,4 +1,4 @@
-package dashboards
+package service
import (
"context"
@@ -6,46 +6,42 @@ import (
"strings"
"github.com/grafana/grafana/pkg/bus"
- "github.com/grafana/grafana/pkg/dashboards"
+ "github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
+ "github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/guardian"
"github.com/grafana/grafana/pkg/services/search"
)
-// FolderService is a service for operating on folders.
-type FolderService interface {
- GetFolders(ctx context.Context, limit int64, page int64) ([]*models.Folder, error)
- GetFolderByID(ctx context.Context, id int64) (*models.Folder, error)
- GetFolderByUID(ctx context.Context, uid string) (*models.Folder, error)
- GetFolderByTitle(ctx context.Context, title string) (*models.Folder, error)
- CreateFolder(ctx context.Context, title, uid string) (*models.Folder, error)
- UpdateFolder(ctx context.Context, uid string, cmd *models.UpdateFolderCommand) error
- DeleteFolder(ctx context.Context, uid string, forceDeleteRules bool) (*models.Folder, error)
- MakeUserAdmin(ctx context.Context, orgID int64, userID, folderID int64, setViewAndEditPermissions bool) error
+type FolderServiceImpl struct {
+ dashboardService dashboards.DashboardService
+ dashboardStore dashboards.Store
+ searchService *search.SearchService
+ log log.Logger
}
-// NewFolderService is a factory for creating a new folder service.
-var NewFolderService = func(orgID int64, user *models.SignedInUser, store dashboards.Store) FolderService {
- return &dashboardServiceImpl{
- orgId: orgID,
- user: user,
- dashboardStore: store,
+func ProvideFolderService(dashboardService dashboards.DashboardService, dashboardStore dashboards.Store, searchService *search.SearchService) *FolderServiceImpl {
+ return &FolderServiceImpl{
+ dashboardService: dashboardService,
+ dashboardStore: dashboardStore,
+ searchService: searchService,
+ log: log.New("folder-service"),
}
}
-func (dr *dashboardServiceImpl) GetFolders(ctx context.Context, limit int64, page int64) ([]*models.Folder, error) {
+func (f *FolderServiceImpl) GetFolders(ctx context.Context, user *models.SignedInUser, orgID int64, limit int64, page int64) ([]*models.Folder, error) {
searchQuery := search.Query{
- SignedInUser: dr.user,
+ SignedInUser: user,
DashboardIds: make([]int64, 0),
FolderIds: make([]int64, 0),
Limit: limit,
- OrgId: dr.orgId,
+ OrgId: orgID,
Type: "dash-folder",
Permission: models.PERMISSION_VIEW,
Page: page,
}
- if err := bus.Dispatch(ctx, &searchQuery); err != nil {
+ if err := f.searchService.SearchHandler(ctx, &searchQuery); err != nil {
return nil, err
}
@@ -62,17 +58,17 @@ func (dr *dashboardServiceImpl) GetFolders(ctx context.Context, limit int64, pag
return folders, nil
}
-func (dr *dashboardServiceImpl) GetFolderByID(ctx context.Context, id int64) (*models.Folder, error) {
+func (f *FolderServiceImpl) GetFolderByID(ctx context.Context, user *models.SignedInUser, id int64, orgID int64) (*models.Folder, error) {
if id == 0 {
return &models.Folder{Id: id, Title: "General"}, nil
}
- query := models.GetDashboardQuery{OrgId: dr.orgId, Id: id}
+ query := models.GetDashboardQuery{OrgId: orgID, Id: id}
dashFolder, err := getFolder(ctx, query)
if err != nil {
return nil, toFolderError(err)
}
- g := guardian.New(ctx, dashFolder.Id, dr.orgId, dr.user)
+ g := guardian.New(ctx, dashFolder.Id, orgID, user)
if canView, err := g.CanView(); err != nil || !canView {
if err != nil {
return nil, toFolderError(err)
@@ -83,15 +79,15 @@ func (dr *dashboardServiceImpl) GetFolderByID(ctx context.Context, id int64) (*m
return dashToFolder(dashFolder), nil
}
-func (dr *dashboardServiceImpl) GetFolderByUID(ctx context.Context, uid string) (*models.Folder, error) {
- query := models.GetDashboardQuery{OrgId: dr.orgId, Uid: uid}
+func (f *FolderServiceImpl) GetFolderByUID(ctx context.Context, user *models.SignedInUser, orgID int64, uid string) (*models.Folder, error) {
+ query := models.GetDashboardQuery{OrgId: orgID, Uid: uid}
dashFolder, err := getFolder(ctx, query)
if err != nil {
return nil, toFolderError(err)
}
- g := guardian.New(ctx, dashFolder.Id, dr.orgId, dr.user)
+ g := guardian.New(ctx, dashFolder.Id, orgID, user)
if canView, err := g.CanView(); err != nil || !canView {
if err != nil {
return nil, toFolderError(err)
@@ -102,13 +98,13 @@ func (dr *dashboardServiceImpl) GetFolderByUID(ctx context.Context, uid string)
return dashToFolder(dashFolder), nil
}
-func (dr *dashboardServiceImpl) GetFolderByTitle(ctx context.Context, title string) (*models.Folder, error) {
- dashFolder, err := dr.dashboardStore.GetFolderByTitle(dr.orgId, title)
+func (f *FolderServiceImpl) GetFolderByTitle(ctx context.Context, user *models.SignedInUser, orgID int64, title string) (*models.Folder, error) {
+ dashFolder, err := f.dashboardStore.GetFolderByTitle(orgID, title)
if err != nil {
return nil, toFolderError(err)
}
- g := guardian.New(ctx, dashFolder.Id, dr.orgId, dr.user)
+ g := guardian.New(ctx, dashFolder.Id, orgID, user)
if canView, err := g.CanView(); err != nil || !canView {
if err != nil {
return nil, toFolderError(err)
@@ -119,11 +115,11 @@ func (dr *dashboardServiceImpl) GetFolderByTitle(ctx context.Context, title stri
return dashToFolder(dashFolder), nil
}
-func (dr *dashboardServiceImpl) CreateFolder(ctx context.Context, title, uid string) (*models.Folder, error) {
+func (f *FolderServiceImpl) CreateFolder(ctx context.Context, user *models.SignedInUser, orgID int64, title, uid string) (*models.Folder, error) {
dashFolder := models.NewDashboardFolder(title)
- dashFolder.OrgId = dr.orgId
+ dashFolder.OrgId = orgID
dashFolder.SetUid(strings.TrimSpace(uid))
- userID := dr.user.UserId
+ userID := user.UserId
if userID == 0 {
userID = -1
}
@@ -131,23 +127,23 @@ func (dr *dashboardServiceImpl) CreateFolder(ctx context.Context, title, uid str
dashFolder.UpdatedBy = userID
dashFolder.UpdateSlug()
- dto := &SaveDashboardDTO{
+ dto := &dashboards.SaveDashboardDTO{
Dashboard: dashFolder,
- OrgId: dr.orgId,
- User: dr.user,
+ OrgId: orgID,
+ User: user,
}
- saveDashboardCmd, err := dr.buildSaveDashboardCommand(ctx, dto, false, false)
+ saveDashboardCmd, err := f.dashboardService.BuildSaveDashboardCommand(ctx, dto, false, false)
if err != nil {
return nil, toFolderError(err)
}
- dash, err := dr.dashboardStore.SaveDashboard(*saveDashboardCmd)
+ dash, err := f.dashboardStore.SaveDashboard(*saveDashboardCmd)
if err != nil {
return nil, toFolderError(err)
}
- query := models.GetDashboardQuery{OrgId: dr.orgId, Id: dash.Id}
+ query := models.GetDashboardQuery{OrgId: orgID, Id: dash.Id}
dashFolder, err = getFolder(ctx, query)
if err != nil {
return nil, toFolderError(err)
@@ -156,33 +152,33 @@ func (dr *dashboardServiceImpl) CreateFolder(ctx context.Context, title, uid str
return dashToFolder(dashFolder), nil
}
-func (dr *dashboardServiceImpl) UpdateFolder(ctx context.Context, existingUid string, cmd *models.UpdateFolderCommand) error {
- query := models.GetDashboardQuery{OrgId: dr.orgId, Uid: existingUid}
+func (f *FolderServiceImpl) UpdateFolder(ctx context.Context, user *models.SignedInUser, orgID int64, existingUid string, cmd *models.UpdateFolderCommand) error {
+ query := models.GetDashboardQuery{OrgId: orgID, Uid: existingUid}
dashFolder, err := getFolder(ctx, query)
if err != nil {
return toFolderError(err)
}
- cmd.UpdateDashboardModel(dashFolder, dr.orgId, dr.user.UserId)
+ cmd.UpdateDashboardModel(dashFolder, orgID, user.UserId)
- dto := &SaveDashboardDTO{
+ dto := &dashboards.SaveDashboardDTO{
Dashboard: dashFolder,
- OrgId: dr.orgId,
- User: dr.user,
+ OrgId: orgID,
+ User: user,
Overwrite: cmd.Overwrite,
}
- saveDashboardCmd, err := dr.buildSaveDashboardCommand(ctx, dto, false, false)
+ saveDashboardCmd, err := f.dashboardService.BuildSaveDashboardCommand(ctx, dto, false, false)
if err != nil {
return toFolderError(err)
}
- dash, err := dr.dashboardStore.SaveDashboard(*saveDashboardCmd)
+ dash, err := f.dashboardStore.SaveDashboard(*saveDashboardCmd)
if err != nil {
return toFolderError(err)
}
- query = models.GetDashboardQuery{OrgId: dr.orgId, Id: dash.Id}
+ query = models.GetDashboardQuery{OrgId: orgID, Id: dash.Id}
dashFolder, err = getFolder(ctx, query)
if err != nil {
return toFolderError(err)
@@ -193,14 +189,14 @@ func (dr *dashboardServiceImpl) UpdateFolder(ctx context.Context, existingUid st
return nil
}
-func (dr *dashboardServiceImpl) DeleteFolder(ctx context.Context, uid string, forceDeleteRules bool) (*models.Folder, error) {
- query := models.GetDashboardQuery{OrgId: dr.orgId, Uid: uid}
+func (f *FolderServiceImpl) DeleteFolder(ctx context.Context, user *models.SignedInUser, orgID int64, uid string, forceDeleteRules bool) (*models.Folder, error) {
+ query := models.GetDashboardQuery{OrgId: orgID, Uid: uid}
dashFolder, err := getFolder(ctx, query)
if err != nil {
return nil, toFolderError(err)
}
- guardian := guardian.New(ctx, dashFolder.Id, dr.orgId, dr.user)
+ guardian := guardian.New(ctx, dashFolder.Id, orgID, user)
if canSave, err := guardian.CanSave(); err != nil || !canSave {
if err != nil {
return nil, toFolderError(err)
@@ -208,7 +204,7 @@ func (dr *dashboardServiceImpl) DeleteFolder(ctx context.Context, uid string, fo
return nil, models.ErrFolderAccessDenied
}
- deleteCmd := models.DeleteDashboardCommand{OrgId: dr.orgId, Id: dashFolder.Id, ForceDeleteFolderRules: forceDeleteRules}
+ deleteCmd := models.DeleteDashboardCommand{OrgId: orgID, Id: dashFolder.Id, ForceDeleteFolderRules: forceDeleteRules}
if err := bus.Dispatch(ctx, &deleteCmd); err != nil {
return nil, toFolderError(err)
}
@@ -216,6 +212,10 @@ func (dr *dashboardServiceImpl) DeleteFolder(ctx context.Context, uid string, fo
return dashToFolder(dashFolder), nil
}
+func (f *FolderServiceImpl) MakeUserAdmin(ctx context.Context, orgID int64, userID, folderID int64, setViewAndEditPermissions bool) error {
+ return f.dashboardService.MakeUserAdmin(ctx, orgID, userID, folderID, setViewAndEditPermissions)
+}
+
func getFolder(ctx context.Context, query models.GetDashboardQuery) (*models.Dashboard, error) {
if err := bus.Dispatch(ctx, &query); err != nil {
return nil, toFolderError(err)
diff --git a/pkg/services/dashboards/folder_service_test.go b/pkg/services/dashboards/manager/folder_service_test.go
similarity index 73%
rename from pkg/services/dashboards/folder_service_test.go
rename to pkg/services/dashboards/manager/folder_service_test.go
index 7d08b6a6bf7..962e0fe88de 100644
--- a/pkg/services/dashboards/folder_service_test.go
+++ b/pkg/services/dashboards/manager/folder_service_test.go
@@ -1,25 +1,34 @@
-package dashboards
+//go:build integration
+// +build integration
+
+package service
import (
"context"
"testing"
"github.com/grafana/grafana/pkg/bus"
- "github.com/grafana/grafana/pkg/dashboards"
"github.com/grafana/grafana/pkg/models"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
-
+ "github.com/grafana/grafana/pkg/services/dashboards"
+ "github.com/grafana/grafana/pkg/services/dashboards/database"
"github.com/grafana/grafana/pkg/services/guardian"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+ "github.com/stretchr/testify/require"
)
+var orgID = int64(1)
+var user = &models.SignedInUser{UserId: 1}
+
func TestFolderService(t *testing.T) {
t.Run("Folder service tests", func(t *testing.T) {
- service := dashboardServiceImpl{
- orgId: 1,
- user: &models.SignedInUser{UserId: 1},
- dashboardStore: &fakeDashboardStore{},
- }
+ store := &database.FakeDashboardStore{}
+ defer store.AssertExpectations(t)
+ service := ProvideFolderService(
+ &dashboards.FakeDashboardService{DashboardService: ProvideDashboardService(store)},
+ store,
+ nil,
+ )
t.Run("Given user has no permissions", func(t *testing.T) {
origNewGuardian := guardian.New
@@ -30,37 +39,30 @@ func TestFolderService(t *testing.T) {
return nil
})
- origStore := service.dashboardStore
- t.Cleanup(func() {
- service.dashboardStore = origStore
- })
- service.dashboardStore = &fakeDashboardStore{
- validationError: models.ErrDashboardUpdateAccessDenied,
- }
-
t.Run("When get folder by id should return access denied error", func(t *testing.T) {
- _, err := service.GetFolderByID(context.Background(), 1)
+ _, err := service.GetFolderByID(context.Background(), user, 1, orgID)
require.Equal(t, err, models.ErrFolderAccessDenied)
})
t.Run("When get folder by id, with id = 0 should return default folder", func(t *testing.T) {
- folder, err := service.GetFolderByID(context.Background(), 0)
+ folder, err := service.GetFolderByID(context.Background(), user, 0, orgID)
require.NoError(t, err)
require.Equal(t, folder, &models.Folder{Id: 0, Title: "General"})
})
t.Run("When get folder by uid should return access denied error", func(t *testing.T) {
- _, err := service.GetFolderByUID(context.Background(), "uid")
+ _, err := service.GetFolderByUID(context.Background(), user, orgID, "uid")
require.Equal(t, err, models.ErrFolderAccessDenied)
})
t.Run("When creating folder should return access denied error", func(t *testing.T) {
- _, err := service.CreateFolder(context.Background(), "Folder", "")
+ store.On("ValidateDashboardBeforeSave", mock.Anything, mock.Anything).Return(true, nil).Times(2)
+ _, err := service.CreateFolder(context.Background(), user, orgID, "Folder", "")
require.Equal(t, err, models.ErrFolderAccessDenied)
})
t.Run("When updating folder should return access denied error", func(t *testing.T) {
- err := service.UpdateFolder(context.Background(), "uid", &models.UpdateFolderCommand{
+ err := service.UpdateFolder(context.Background(), user, orgID, "uid", &models.UpdateFolderCommand{
Uid: "uid",
Title: "Folder",
})
@@ -68,7 +70,7 @@ func TestFolderService(t *testing.T) {
})
t.Run("When deleting folder by uid should return access denied error", func(t *testing.T) {
- _, err := service.DeleteFolder(context.Background(), "uid", false)
+ _, err := service.DeleteFolder(context.Background(), user, orgID, "uid", false)
require.Error(t, err)
require.Equal(t, err, models.ErrFolderAccessDenied)
})
@@ -90,15 +92,6 @@ func TestFolderService(t *testing.T) {
return nil
})
- origUpdateAlerting := UpdateAlerting
- t.Cleanup(func() {
- UpdateAlerting = origUpdateAlerting
- })
- UpdateAlerting = func(ctx context.Context, store dashboards.Store, orgID int64, dashboard *models.Dashboard,
- user *models.SignedInUser) error {
- return nil
- }
-
bus.AddHandler("test", func(ctx context.Context, cmd *models.SaveDashboardCommand) error {
cmd.Result = dash
return nil
@@ -109,12 +102,15 @@ func TestFolderService(t *testing.T) {
})
t.Run("When creating folder should not return access denied error", func(t *testing.T) {
- _, err := service.CreateFolder(context.Background(), "Folder", "")
+ store.On("ValidateDashboardBeforeSave", mock.Anything, mock.Anything).Return(true, nil).Times(2)
+ store.On("SaveDashboard", mock.Anything).Return(&models.Dashboard{Id: 1}, nil).Once()
+ _, err := service.CreateFolder(context.Background(), user, orgID, "Folder", "")
require.NoError(t, err)
})
t.Run("When updating folder should not return access denied error", func(t *testing.T) {
- err := service.UpdateFolder(context.Background(), "uid", &models.UpdateFolderCommand{
+ store.On("SaveDashboard", mock.Anything).Return(&models.Dashboard{Id: 1}, nil).Once()
+ err := service.UpdateFolder(context.Background(), user, orgID, "uid", &models.UpdateFolderCommand{
Uid: "uid",
Title: "Folder",
})
@@ -122,7 +118,7 @@ func TestFolderService(t *testing.T) {
})
t.Run("When deleting folder by uid should not return access denied error", func(t *testing.T) {
- _, err := service.DeleteFolder(context.Background(), "uid", false)
+ _, err := service.DeleteFolder(context.Background(), user, orgID, "uid", false)
require.NoError(t, err)
})
@@ -145,14 +141,14 @@ func TestFolderService(t *testing.T) {
})
t.Run("When get folder by id should return folder", func(t *testing.T) {
- f, _ := service.GetFolderByID(context.Background(), 1)
+ f, _ := service.GetFolderByID(context.Background(), user, orgID, 1)
require.Equal(t, f.Id, dashFolder.Id)
require.Equal(t, f.Uid, dashFolder.Uid)
require.Equal(t, f.Title, dashFolder.Title)
})
t.Run("When get folder by uid should return folder", func(t *testing.T) {
- f, _ := service.GetFolderByUID(context.Background(), "uid")
+ f, _ := service.GetFolderByUID(context.Background(), user, orgID, "uid")
require.Equal(t, f.Id, dashFolder.Id)
require.Equal(t, f.Uid, dashFolder.Uid)
require.Equal(t, f.Title, dashFolder.Title)
diff --git a/pkg/services/dashboards/models.go b/pkg/services/dashboards/models.go
new file mode 100644
index 00000000000..eea9e9c9b44
--- /dev/null
+++ b/pkg/services/dashboards/models.go
@@ -0,0 +1,16 @@
+package dashboards
+
+import (
+ "time"
+
+ "github.com/grafana/grafana/pkg/models"
+)
+
+type SaveDashboardDTO struct {
+ OrgId int64
+ UpdatedAt time.Time
+ User *models.SignedInUser
+ Message string
+ Overwrite bool
+ Dashboard *models.Dashboard
+}
diff --git a/pkg/services/dashboardsnapshots/dashboardsnapshots.go b/pkg/services/dashboardsnapshots/dashboardsnapshots.go
index 48dfb5ab2f1..990e60bb818 100644
--- a/pkg/services/dashboardsnapshots/dashboardsnapshots.go
+++ b/pkg/services/dashboardsnapshots/dashboardsnapshots.go
@@ -12,11 +12,11 @@ import (
type Service struct {
Bus bus.Bus
- SQLStore *sqlstore.SQLStore
+ SQLStore sqlstore.Store
SecretsService secrets.Service
}
-func ProvideService(bus bus.Bus, store *sqlstore.SQLStore, secretsService secrets.Service) *Service {
+func ProvideService(bus bus.Bus, store sqlstore.Store, secretsService secrets.Service) *Service {
s := &Service{
Bus: bus,
SQLStore: store,
diff --git a/pkg/services/datasources/service/datasource_service.go b/pkg/services/datasources/service/datasource_service.go
index f25fb98ebe4..9f5e1cd273b 100644
--- a/pkg/services/datasources/service/datasource_service.go
+++ b/pkg/services/datasources/service/datasource_service.go
@@ -16,6 +16,7 @@ import (
"github.com/grafana/grafana/pkg/infra/httpclient"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/accesscontrol"
+ "github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/secrets"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/setting"
@@ -23,9 +24,11 @@ import (
)
type Service struct {
- Bus bus.Bus
- SQLStore *sqlstore.SQLStore
- SecretsService secrets.Service
+ Bus bus.Bus
+ SQLStore *sqlstore.SQLStore
+ SecretsService secrets.Service
+ features featuremgmt.FeatureToggles
+ permissionsService accesscontrol.PermissionsService
ptc proxyTransportCache
dsDecryptionCache secureJSONDecryptionCache
@@ -51,7 +54,10 @@ type cachedDecryptedJSON struct {
json map[string]string
}
-func ProvideService(bus bus.Bus, store *sqlstore.SQLStore, secretsService secrets.Service, ac accesscontrol.AccessControl) *Service {
+func ProvideService(
+ bus bus.Bus, store *sqlstore.SQLStore, secretsService secrets.Service, features featuremgmt.FeatureToggles,
+ ac accesscontrol.AccessControl, permissionsServices accesscontrol.PermissionsServices,
+) *Service {
s := &Service{
Bus: bus,
SQLStore: store,
@@ -62,6 +68,8 @@ func ProvideService(bus bus.Bus, store *sqlstore.SQLStore, secretsService secret
dsDecryptionCache: secureJSONDecryptionCache{
cache: make(map[int64]cachedDecryptedJSON),
},
+ features: features,
+ permissionsService: permissionsServices.GetDataSourceService(),
}
s.Bus.AddHandler(s.GetDataSources)
@@ -128,7 +136,23 @@ func (s *Service) AddDataSource(ctx context.Context, cmd *models.AddDataSourceCo
return err
}
- return s.SQLStore.AddDataSource(ctx, cmd)
+ if err := s.SQLStore.AddDataSource(ctx, cmd); err != nil {
+ return err
+ }
+
+ if s.features.IsEnabled(featuremgmt.FlagAccesscontrol) {
+ if _, err := s.permissionsService.SetPermissions(ctx, cmd.OrgId, strconv.FormatInt(cmd.Result.Id, 10), accesscontrol.SetResourcePermissionCommand{
+ BuiltinRole: "Viewer",
+ Permission: "Query",
+ }, accesscontrol.SetResourcePermissionCommand{
+ BuiltinRole: "Editor",
+ Permission: "Query",
+ }); err != nil {
+ return err
+ }
+ }
+
+ return nil
}
func (s *Service) DeleteDataSource(ctx context.Context, cmd *models.DeleteDataSourceCommand) error {
diff --git a/pkg/services/datasources/service/datasource_service_test.go b/pkg/services/datasources/service/datasource_service_test.go
index 99ddcbf8c8a..ef9159dbd5d 100644
--- a/pkg/services/datasources/service/datasource_service_test.go
+++ b/pkg/services/datasources/service/datasource_service_test.go
@@ -15,6 +15,7 @@ import (
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/accesscontrol"
acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
+ "github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/secrets"
"github.com/grafana/grafana/pkg/services/secrets/database"
"github.com/grafana/grafana/pkg/services/secrets/fakes"
@@ -36,7 +37,7 @@ func TestService(t *testing.T) {
})
secretsService := secretsManager.SetupTestService(t, database.ProvideSecretsStore(sqlStore))
- s := ProvideService(bus.New(), sqlStore, secretsService, &acmock.Mock{})
+ s := ProvideService(bus.New(), sqlStore, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock())
var ds *models.DataSource
@@ -151,7 +152,7 @@ func TestService_GetHttpTransport(t *testing.T) {
}
secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())
- dsService := ProvideService(bus.New(), nil, secretsService, &acmock.Mock{})
+ dsService := ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock())
rt1, err := dsService.GetHTTPTransport(&ds, provider)
require.NoError(t, err)
@@ -184,7 +185,7 @@ func TestService_GetHttpTransport(t *testing.T) {
json.Set("tlsAuthWithCACert", true)
secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())
- dsService := ProvideService(bus.New(), nil, secretsService, &acmock.Mock{})
+ dsService := ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock())
tlsCaCert, err := secretsService.Encrypt(context.Background(), []byte(caCert), secrets.WithoutScope())
require.NoError(t, err)
@@ -234,7 +235,7 @@ func TestService_GetHttpTransport(t *testing.T) {
json.Set("tlsAuth", true)
secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())
- dsService := ProvideService(bus.New(), nil, secretsService, &acmock.Mock{})
+ dsService := ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock())
tlsClientCert, err := secretsService.Encrypt(context.Background(), []byte(clientCert), secrets.WithoutScope())
require.NoError(t, err)
@@ -277,7 +278,7 @@ func TestService_GetHttpTransport(t *testing.T) {
json.Set("serverName", "server-name")
secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())
- dsService := ProvideService(bus.New(), nil, secretsService, &acmock.Mock{})
+ dsService := ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock())
tlsCaCert, err := secretsService.Encrypt(context.Background(), []byte(caCert), secrets.WithoutScope())
require.NoError(t, err)
@@ -314,7 +315,7 @@ func TestService_GetHttpTransport(t *testing.T) {
json.Set("tlsSkipVerify", true)
secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())
- dsService := ProvideService(bus.New(), nil, secretsService, &acmock.Mock{})
+ dsService := ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock())
ds := models.DataSource{
Id: 1,
@@ -345,7 +346,7 @@ func TestService_GetHttpTransport(t *testing.T) {
})
secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())
- dsService := ProvideService(bus.New(), nil, secretsService, &acmock.Mock{})
+ dsService := ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock())
encryptedData, err := secretsService.Encrypt(context.Background(), []byte(`Bearer xf5yhfkpsnmgo`), secrets.WithoutScope())
require.NoError(t, err)
@@ -404,7 +405,7 @@ func TestService_GetHttpTransport(t *testing.T) {
})
secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())
- dsService := ProvideService(bus.New(), nil, secretsService, &acmock.Mock{})
+ dsService := ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock())
ds := models.DataSource{
Id: 1,
@@ -437,7 +438,7 @@ func TestService_GetHttpTransport(t *testing.T) {
require.NoError(t, err)
secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())
- dsService := ProvideService(bus.New(), nil, secretsService, &acmock.Mock{})
+ dsService := ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock())
ds := models.DataSource{
Type: models.DS_ES,
@@ -471,7 +472,7 @@ func TestService_getTimeout(t *testing.T) {
}
secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())
- dsService := ProvideService(bus.New(), nil, secretsService, &acmock.Mock{})
+ dsService := ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock())
for _, tc := range testCases {
ds := &models.DataSource{
@@ -484,7 +485,7 @@ func TestService_getTimeout(t *testing.T) {
func TestService_DecryptedValue(t *testing.T) {
t.Run("When datasource hasn't been updated, encrypted JSON should be fetched from cache", func(t *testing.T) {
secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())
- dsService := ProvideService(bus.New(), nil, secretsService, &acmock.Mock{})
+ dsService := ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock())
encryptedJsonData, err := secretsService.EncryptJsonData(
context.Background(),
@@ -538,7 +539,7 @@ func TestService_DecryptedValue(t *testing.T) {
SecureJsonData: encryptedJsonData,
}
- dsService := ProvideService(bus.New(), nil, secretsService, &acmock.Mock{})
+ dsService := ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock())
// Populate cache
password, ok := dsService.DecryptedValue(&ds, "password")
@@ -574,7 +575,7 @@ func TestService_HTTPClientOptions(t *testing.T) {
t.Cleanup(func() { ds.JsonData = emptyJsonData; ds.SecureJsonData = emptySecureJsonData })
secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())
- dsService := ProvideService(bus.New(), nil, secretsService, &acmock.Mock{})
+ dsService := ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock())
opts, err := dsService.httpClientOptions(&ds)
require.NoError(t, err)
@@ -592,7 +593,7 @@ func TestService_HTTPClientOptions(t *testing.T) {
})
secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())
- dsService := ProvideService(bus.New(), nil, secretsService, &acmock.Mock{})
+ dsService := ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock())
opts, err := dsService.httpClientOptions(&ds)
require.NoError(t, err)
@@ -611,7 +612,7 @@ func TestService_HTTPClientOptions(t *testing.T) {
})
secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())
- dsService := ProvideService(bus.New(), nil, secretsService, &acmock.Mock{})
+ dsService := ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock())
_, err := dsService.httpClientOptions(&ds)
assert.Error(t, err)
@@ -625,7 +626,7 @@ func TestService_HTTPClientOptions(t *testing.T) {
})
secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())
- dsService := ProvideService(bus.New(), nil, secretsService, &acmock.Mock{})
+ dsService := ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock())
opts, err := dsService.httpClientOptions(&ds)
require.NoError(t, err)
diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go
index bd715f9457a..d95d6b385c1 100644
--- a/pkg/services/featuremgmt/registry.go
+++ b/pkg/services/featuremgmt/registry.go
@@ -38,6 +38,11 @@ var (
Description: "Schedule automatic updates to dashboard previews",
State: FeatureStateAlpha,
},
+ {
+ Name: "dashboardPreviewsAdmin",
+ Description: "Manage the dashboard previews crawler process from the UI",
+ State: FeatureStateAlpha,
+ },
{
Name: "live-config",
Description: "Save grafana live configuration in SQL tables",
diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go
index 960a7b0f511..7c54af1412e 100644
--- a/pkg/services/featuremgmt/toggles_gen.go
+++ b/pkg/services/featuremgmt/toggles_gen.go
@@ -31,6 +31,10 @@ const (
// Schedule automatic updates to dashboard previews
FlagDashboardPreviewsScheduler = "dashboardPreviewsScheduler"
+ // FlagDashboardPreviewsAdmin
+ // Manage the dashboard previews crawler process from the UI
+ FlagDashboardPreviewsAdmin = "dashboardPreviewsAdmin"
+
// FlagLiveConfig
// Save grafana live configuration in SQL tables
FlagLiveConfig = "live-config"
diff --git a/pkg/services/libraryelements/guard.go b/pkg/services/libraryelements/guard.go
index 8d483488140..7265e3acc3d 100644
--- a/pkg/services/libraryelements/guard.go
+++ b/pkg/services/libraryelements/guard.go
@@ -4,7 +4,6 @@ import (
"context"
"github.com/grafana/grafana/pkg/models"
- "github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/guardian"
)
@@ -32,9 +31,7 @@ func (l *LibraryElementService) requirePermissionsOnFolder(ctx context.Context,
if isGeneralFolder(folderID) && user.HasRole(models.ROLE_VIEWER) {
return models.ErrFolderAccessDenied
}
-
- s := dashboards.NewFolderService(user.OrgId, user, l.SQLStore)
- folder, err := s.GetFolderByID(ctx, folderID)
+ folder, err := l.folderService.GetFolderByID(ctx, user, folderID, user.OrgId)
if err != nil {
return err
}
diff --git a/pkg/services/libraryelements/libraryelements.go b/pkg/services/libraryelements/libraryelements.go
index 302b31a1b59..50229a71b68 100644
--- a/pkg/services/libraryelements/libraryelements.go
+++ b/pkg/services/libraryelements/libraryelements.go
@@ -6,15 +6,17 @@ import (
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
+ "github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/setting"
)
-func ProvideService(cfg *setting.Cfg, sqlStore *sqlstore.SQLStore, routeRegister routing.RouteRegister) *LibraryElementService {
+func ProvideService(cfg *setting.Cfg, sqlStore *sqlstore.SQLStore, routeRegister routing.RouteRegister, folderService dashboards.FolderService) *LibraryElementService {
l := &LibraryElementService{
Cfg: cfg,
SQLStore: sqlStore,
RouteRegister: routeRegister,
+ folderService: folderService,
log: log.New("library-elements"),
}
l.registerAPIEndpoints()
@@ -36,6 +38,7 @@ type LibraryElementService struct {
Cfg *setting.Cfg
SQLStore *sqlstore.SQLStore
RouteRegister routing.RouteRegister
+ folderService dashboards.FolderService
log log.Logger
}
diff --git a/pkg/services/libraryelements/libraryelements_test.go b/pkg/services/libraryelements/libraryelements_test.go
index 5744f9c8b5e..8c1df027581 100644
--- a/pkg/services/libraryelements/libraryelements_test.go
+++ b/pkg/services/libraryelements/libraryelements_test.go
@@ -9,14 +9,13 @@ import (
"testing"
"time"
- "github.com/grafana/grafana/pkg/components/simplejson"
-
- dboards "github.com/grafana/grafana/pkg/dashboards"
-
"github.com/google/go-cmp/cmp"
"github.com/grafana/grafana/pkg/api/response"
+ "github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/dashboards"
+ "github.com/grafana/grafana/pkg/services/dashboards/database"
+ dashboardservice "github.com/grafana/grafana/pkg/services/dashboards/manager"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/web"
@@ -194,16 +193,9 @@ func createDashboard(t *testing.T, sqlStore *sqlstore.SQLStore, user models.Sign
User: &user,
Overwrite: false,
}
- origUpdateAlerting := dashboards.UpdateAlerting
- t.Cleanup(func() {
- dashboards.UpdateAlerting = origUpdateAlerting
- })
- dashboards.UpdateAlerting = func(ctx context.Context, store dboards.Store, orgID int64, dashboard *models.Dashboard,
- user *models.SignedInUser) error {
- return nil
- }
- dashboard, err := dashboards.NewService(sqlStore).SaveDashboard(context.Background(), dashItem, true)
+ dashboardStore := database.ProvideDashboardStore(sqlStore)
+ dashboard, err := dashboardservice.ProvideDashboardService(dashboardStore).SaveDashboard(context.Background(), dashItem, true)
require.NoError(t, err)
return dashboard
@@ -213,17 +205,19 @@ func createFolderWithACL(t *testing.T, sqlStore *sqlstore.SQLStore, title string
items []folderACLItem) *models.Folder {
t.Helper()
- s := dashboards.NewFolderService(user.OrgId, &user, sqlStore)
+ dashboardStore := database.ProvideDashboardStore(sqlStore)
+ d := dashboardservice.ProvideDashboardService(dashboardStore)
+ s := dashboardservice.ProvideFolderService(d, dashboardStore, nil)
t.Logf("Creating folder with title and UID %q", title)
- folder, err := s.CreateFolder(context.Background(), title, title)
+ folder, err := s.CreateFolder(context.Background(), &user, user.OrgId, title, title)
require.NoError(t, err)
- updateFolderACL(t, sqlStore, folder.Id, items)
+ updateFolderACL(t, dashboardStore, folder.Id, items)
return folder
}
-func updateFolderACL(t *testing.T, sqlStore *sqlstore.SQLStore, folderID int64, items []folderACLItem) {
+func updateFolderACL(t *testing.T, dashboardStore *database.DashboardStore, folderID int64, items []folderACLItem) {
t.Helper()
if len(items) == 0 {
@@ -243,7 +237,7 @@ func updateFolderACL(t *testing.T, sqlStore *sqlstore.SQLStore, folderID int64,
})
}
- err := sqlStore.UpdateDashboardACL(context.Background(), folderID, aclItems)
+ err := dashboardStore.UpdateDashboardACL(context.Background(), folderID, aclItems)
require.NoError(t, err)
}
@@ -297,9 +291,12 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo
orgID := int64(1)
role := models.ROLE_ADMIN
sqlStore := sqlstore.InitTestDB(t)
+ dashboardStore := database.ProvideDashboardStore(sqlStore)
+ dashboardService := dashboardservice.ProvideDashboardService(dashboardStore)
service := LibraryElementService{
- Cfg: setting.NewCfg(),
- SQLStore: sqlStore,
+ Cfg: setting.NewCfg(),
+ SQLStore: sqlStore,
+ folderService: dashboardservice.ProvideFolderService(dashboardService, dashboardStore, nil),
}
user := models.SignedInUser{
diff --git a/pkg/services/librarypanels/librarypanels_test.go b/pkg/services/librarypanels/librarypanels_test.go
index 2a73e01e632..7a101269709 100644
--- a/pkg/services/librarypanels/librarypanels_test.go
+++ b/pkg/services/librarypanels/librarypanels_test.go
@@ -8,15 +8,16 @@ import (
"time"
"github.com/google/go-cmp/cmp"
- "github.com/stretchr/testify/require"
-
+ "github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/components/simplejson"
- dboards "github.com/grafana/grafana/pkg/dashboards"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/dashboards"
+ "github.com/grafana/grafana/pkg/services/dashboards/database"
+ dashboardservice "github.com/grafana/grafana/pkg/services/dashboards/manager"
"github.com/grafana/grafana/pkg/services/libraryelements"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/setting"
+ "github.com/stretchr/testify/require"
)
const userInDbName = "user_in_db"
@@ -1413,16 +1414,9 @@ func createDashboard(t *testing.T, sqlStore *sqlstore.SQLStore, user *models.Sig
User: user,
Overwrite: false,
}
- origUpdateAlerting := dashboards.UpdateAlerting
- t.Cleanup(func() {
- dashboards.UpdateAlerting = origUpdateAlerting
- })
- dashboards.UpdateAlerting = func(ctx context.Context, store dboards.Store, orgID int64, dashboard *models.Dashboard,
- user *models.SignedInUser) error {
- return nil
- }
- dashboard, err := dashboards.NewService(sqlStore).SaveDashboard(context.Background(), dashItem, true)
+ dashboadStore := database.ProvideDashboardStore(sqlStore)
+ dashboard, err := dashboardservice.ProvideDashboardService(dashboadStore).SaveDashboard(context.Background(), dashItem, true)
require.NoError(t, err)
return dashboard
@@ -1432,17 +1426,19 @@ func createFolderWithACL(t *testing.T, sqlStore *sqlstore.SQLStore, title string
items []folderACLItem) *models.Folder {
t.Helper()
- s := dashboards.NewFolderService(user.OrgId, user, sqlStore)
+ dashboardStore := database.ProvideDashboardStore(sqlStore)
+ d := dashboardservice.ProvideDashboardService(dashboardStore)
+ s := dashboardservice.ProvideFolderService(d, dashboardStore, nil)
t.Logf("Creating folder with title and UID %q", title)
- folder, err := s.CreateFolder(context.Background(), title, title)
+ folder, err := s.CreateFolder(context.Background(), user, user.OrgId, title, title)
require.NoError(t, err)
- updateFolderACL(t, sqlStore, folder.Id, items)
+ updateFolderACL(t, dashboardStore, folder.Id, items)
return folder
}
-func updateFolderACL(t *testing.T, sqlStore *sqlstore.SQLStore, folderID int64, items []folderACLItem) {
+func updateFolderACL(t *testing.T, dashboardStore *database.DashboardStore, folderID int64, items []folderACLItem) {
t.Helper()
if len(items) == 0 {
@@ -1462,7 +1458,7 @@ func updateFolderACL(t *testing.T, sqlStore *sqlstore.SQLStore, folderID int64,
})
}
- err := sqlStore.UpdateDashboardACL(context.Background(), folderID, aclItems)
+ err := dashboardStore.UpdateDashboardACL(context.Background(), folderID, aclItems)
require.NoError(t, err)
}
@@ -1519,14 +1515,14 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo
orgID := int64(1)
role := models.ROLE_ADMIN
sqlStore := sqlstore.InitTestDB(t)
- elementService := libraryelements.LibraryElementService{
- Cfg: cfg,
- SQLStore: sqlStore,
- }
+ dashboardStore := database.ProvideDashboardStore(sqlStore)
+ folderService := dashboardservice.ProvideFolderService(dashboardservice.ProvideDashboardService(dashboardStore), dashboardStore, nil)
+
+ elementService := libraryelements.ProvideService(cfg, sqlStore, routing.NewRouteRegister(), folderService)
service := LibraryPanelService{
Cfg: cfg,
SQLStore: sqlStore,
- LibraryElementService: &elementService,
+ LibraryElementService: elementService,
}
user := &models.SignedInUser{
@@ -1555,7 +1551,7 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo
user: user,
ctx: context.Background(),
service: &service,
- elementService: &elementService,
+ elementService: elementService,
sqlStore: sqlStore,
}
diff --git a/pkg/services/ngalert/ngalert.go b/pkg/services/ngalert/ngalert.go
index 53162c1fa13..9dd8be60457 100644
--- a/pkg/services/ngalert/ngalert.go
+++ b/pkg/services/ngalert/ngalert.go
@@ -5,12 +5,11 @@ import (
"net/url"
"github.com/benbjohnson/clock"
- "golang.org/x/sync/errgroup"
-
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/expr"
"github.com/grafana/grafana/pkg/infra/kvstore"
"github.com/grafana/grafana/pkg/infra/log"
+ "github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/datasourceproxy"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/ngalert/api"
@@ -25,11 +24,12 @@ import (
"github.com/grafana/grafana/pkg/services/secrets"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/setting"
+ "golang.org/x/sync/errgroup"
)
func ProvideService(cfg *setting.Cfg, dataSourceCache datasources.CacheService, routeRegister routing.RouteRegister,
sqlStore *sqlstore.SQLStore, kvStore kvstore.KVStore, expressionService *expr.Service, dataProxy *datasourceproxy.DataSourceProxyService,
- quotaService *quota.QuotaService, secretsService secrets.Service, notificationService notifications.Service, m *metrics.NGAlert) (*AlertNG, error) {
+ quotaService *quota.QuotaService, secretsService secrets.Service, notificationService notifications.Service, m *metrics.NGAlert, folderService dashboards.FolderService) (*AlertNG, error) {
ng := &AlertNG{
Cfg: cfg,
DataSourceCache: dataSourceCache,
@@ -41,8 +41,9 @@ func ProvideService(cfg *setting.Cfg, dataSourceCache datasources.CacheService,
QuotaService: quotaService,
SecretsService: secretsService,
Metrics: m,
- NotificationService: notificationService,
Log: log.New("ngalert"),
+ NotificationService: notificationService,
+ folderService: folderService,
}
if ng.IsDisabled() {
@@ -72,6 +73,7 @@ type AlertNG struct {
Log log.Logger
schedule schedule.ScheduleService
stateManager *state.Manager
+ folderService dashboards.FolderService
// Alerting notification services
MultiOrgAlertmanager *notifier.MultiOrgAlertmanager
@@ -82,9 +84,10 @@ func (ng *AlertNG) init() error {
store := &store.DBstore{
BaseInterval: ng.Cfg.UnifiedAlerting.BaseInterval,
- DefaultInterval: ng.Cfg.UnifiedAlerting.DefaultAlertForDuration,
+ DefaultInterval: ng.Cfg.UnifiedAlerting.DefaultRuleEvaluationInterval,
SQLStore: ng.SQLStore,
Logger: ng.Log,
+ FolderService: ng.folderService,
}
decryptFn := ng.SecretsService.GetDecryptedValue
diff --git a/pkg/services/ngalert/store/alert_rule.go b/pkg/services/ngalert/store/alert_rule.go
index a0a16ec7871..dead82108a1 100644
--- a/pkg/services/ngalert/store/alert_rule.go
+++ b/pkg/services/ngalert/store/alert_rule.go
@@ -12,8 +12,6 @@ import (
"github.com/grafana/grafana/pkg/models"
- "github.com/grafana/grafana/pkg/services/dashboards"
-
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/sqlstore"
@@ -395,12 +393,11 @@ func (st DBstore) GetRuleGroupAlertRules(ctx context.Context, query *ngmodels.Li
// GetNamespaces returns the folders that are visible to the user
func (st DBstore) GetNamespaces(ctx context.Context, orgID int64, user *models.SignedInUser) (map[string]*models.Folder, error) {
- s := dashboards.NewFolderService(orgID, user, st.SQLStore)
namespaceMap := make(map[string]*models.Folder)
var page int64 = 1
for {
// if limit is negative; it fetches at most 1000
- folders, err := s.GetFolders(ctx, -1, page)
+ folders, err := st.FolderService.GetFolders(ctx, user, orgID, -1, page)
if err != nil {
return nil, err
}
@@ -419,8 +416,7 @@ func (st DBstore) GetNamespaces(ctx context.Context, orgID int64, user *models.S
// GetNamespaceByTitle is a handler for retrieving a namespace by its title. Alerting rules follow a Grafana folder-like structure which we call namespaces.
func (st DBstore) GetNamespaceByTitle(ctx context.Context, namespace string, orgID int64, user *models.SignedInUser, withCanSave bool) (*models.Folder, error) {
- s := dashboards.NewFolderService(orgID, user, st.SQLStore)
- folder, err := s.GetFolderByTitle(ctx, namespace)
+ folder, err := st.FolderService.GetFolderByTitle(ctx, user, orgID, namespace)
if err != nil {
return nil, err
}
diff --git a/pkg/services/ngalert/store/database.go b/pkg/services/ngalert/store/database.go
index 7a6505704e7..aa0e601d632 100644
--- a/pkg/services/ngalert/store/database.go
+++ b/pkg/services/ngalert/store/database.go
@@ -5,6 +5,7 @@ import (
"time"
"github.com/grafana/grafana/pkg/infra/log"
+ "github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/sqlstore"
)
@@ -31,4 +32,5 @@ type DBstore struct {
DefaultInterval time.Duration
SQLStore *sqlstore.SQLStore
Logger log.Logger
+ FolderService dashboards.FolderService
}
diff --git a/pkg/services/ngalert/tests/util.go b/pkg/services/ngalert/tests/util.go
index b017436111e..b0af3a96e34 100644
--- a/pkg/services/ngalert/tests/util.go
+++ b/pkg/services/ngalert/tests/util.go
@@ -9,6 +9,8 @@ import (
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/infra/log"
+ databasestore "github.com/grafana/grafana/pkg/services/dashboards/database"
+ dashboardservice "github.com/grafana/grafana/pkg/services/dashboards/manager"
"github.com/grafana/grafana/pkg/services/ngalert"
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/services/ngalert/metrics"
@@ -40,9 +42,11 @@ func SetupTestEnv(t *testing.T, baseInterval time.Duration) (*ngalert.AlertNG, *
m := metrics.NewNGAlert(prometheus.NewRegistry())
sqlStore := sqlstore.InitTestDB(t)
secretsService := secretsManager.SetupTestService(t, database.ProvideSecretsStore(sqlStore))
+ dashboardStore := databasestore.ProvideDashboardStore(sqlStore)
+ folderService := dashboardservice.ProvideFolderService(dashboardservice.ProvideDashboardService(dashboardStore), dashboardStore, nil)
ng, err := ngalert.ProvideService(
cfg, nil, routing.NewRouteRegister(), sqlStore,
- nil, nil, nil, nil, secretsService, nil, m,
+ nil, nil, nil, nil, secretsService, nil, m, folderService,
)
require.NoError(t, err)
return ng, &store.DBstore{
diff --git a/pkg/services/provisioning/dashboards/dashboard.go b/pkg/services/provisioning/dashboards/dashboard.go
index f5d0a4dc08e..660d33fa8bb 100644
--- a/pkg/services/provisioning/dashboards/dashboard.go
+++ b/pkg/services/provisioning/dashboards/dashboard.go
@@ -6,9 +6,9 @@ import (
"os"
"github.com/grafana/grafana/pkg/bus"
- "github.com/grafana/grafana/pkg/dashboards"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
+ "github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/util/errutil"
)
@@ -23,7 +23,7 @@ type DashboardProvisioner interface {
}
// DashboardProvisionerFactory creates DashboardProvisioners based on input
-type DashboardProvisionerFactory func(context.Context, string, dashboards.Store) (DashboardProvisioner, error)
+type DashboardProvisionerFactory func(context.Context, string, dashboards.DashboardProvisioningService) (DashboardProvisioner, error)
// Provisioner is responsible for syncing dashboard from disk to Grafana's database.
type Provisioner struct {
@@ -34,7 +34,7 @@ type Provisioner struct {
}
// New returns a new DashboardProvisioner
-func New(ctx context.Context, configDirectory string, store dashboards.Store) (DashboardProvisioner, error) {
+func New(ctx context.Context, configDirectory string, service dashboards.DashboardProvisioningService) (DashboardProvisioner, error) {
logger := log.New("provisioning.dashboard")
cfgReader := &configReader{path: configDirectory, log: logger}
configs, err := cfgReader.readConfig(ctx)
@@ -42,7 +42,7 @@ func New(ctx context.Context, configDirectory string, store dashboards.Store) (D
return nil, errutil.Wrap("Failed to read dashboards config", err)
}
- fileReaders, err := getFileReaders(configs, logger, store)
+ fileReaders, err := getFileReaders(configs, logger, service)
if err != nil {
return nil, errutil.Wrap("Failed to initialize file readers", err)
}
@@ -120,14 +120,13 @@ func (provider *Provisioner) GetAllowUIUpdatesFromConfig(name string) bool {
return false
}
-func getFileReaders(configs []*config, logger log.Logger, store dashboards.Store) ([]*FileReader, error) {
+func getFileReaders(configs []*config, logger log.Logger, service dashboards.DashboardProvisioningService) ([]*FileReader, error) {
var readers []*FileReader
for _, config := range configs {
switch config.Type {
case "file":
- fileReader, err := NewDashboardFileReader(config, logger.New("type", config.Type, "name", config.Name),
- store)
+ fileReader, err := NewDashboardFileReader(config, logger.New("type", config.Type, "name", config.Name), service)
if err != nil {
return nil, errutil.Wrapf(err, "Failed to create file reader for config %v", config.Name)
}
diff --git a/pkg/services/provisioning/dashboards/file_reader.go b/pkg/services/provisioning/dashboards/file_reader.go
index 9f1bedf2f81..2b84b13dc62 100644
--- a/pkg/services/provisioning/dashboards/file_reader.go
+++ b/pkg/services/provisioning/dashboards/file_reader.go
@@ -13,7 +13,6 @@ import (
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson"
- dboards "github.com/grafana/grafana/pkg/dashboards"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/dashboards"
@@ -41,7 +40,7 @@ type FileReader struct {
}
// NewDashboardFileReader returns a new filereader based on `config`
-func NewDashboardFileReader(cfg *config, log log.Logger, store dboards.Store) (*FileReader, error) {
+func NewDashboardFileReader(cfg *config, log log.Logger, service dashboards.DashboardProvisioningService) (*FileReader, error) {
var path string
path, ok := cfg.Options["path"].(string)
if !ok {
@@ -62,7 +61,7 @@ func NewDashboardFileReader(cfg *config, log log.Logger, store dboards.Store) (*
Cfg: cfg,
Path: path,
log: log,
- dashboardProvisioningService: dashboards.NewProvisioningService(store),
+ dashboardProvisioningService: service,
FoldersFromFilesStructure: foldersFromFilesStructure,
usageTracker: newUsageTracker(),
}, nil
diff --git a/pkg/services/provisioning/dashboards/file_reader_test.go b/pkg/services/provisioning/dashboards/file_reader_test.go
index 70c7ad3cdae..810b3a8d284 100644
--- a/pkg/services/provisioning/dashboards/file_reader_test.go
+++ b/pkg/services/provisioning/dashboards/file_reader_test.go
@@ -2,8 +2,6 @@ package dashboards
import (
"context"
- "fmt"
- "math/rand"
"os"
"path/filepath"
"runtime"
@@ -11,13 +9,12 @@ import (
"time"
"github.com/grafana/grafana/pkg/bus"
- dboards "github.com/grafana/grafana/pkg/dashboards"
+ "github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/util"
-
- "github.com/grafana/grafana/pkg/infra/log"
-
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
@@ -28,10 +25,9 @@ const (
containingID = "testdata/test-dashboards/containing-id"
unprovision = "testdata/test-dashboards/unprovision"
foldersFromFilesStructure = "testdata/test-dashboards/folders-from-files-structure"
+ configName = "default"
)
-var fakeService *fakeDashboardProvisioningService
-
func TestCreatingNewDashboardFileReader(t *testing.T) {
setup := func() *config {
return &config{
@@ -98,17 +94,14 @@ func TestDashboardFileReader(t *testing.T) {
logger := log.New("test.logger")
cfg := &config{}
- origNewDashboardProvisioningService := dashboards.NewProvisioningService
- defer func() {
- dashboards.NewProvisioningService = origNewDashboardProvisioningService
- }()
+ fakeService := &dashboards.FakeDashboardProvisioning{}
+ defer fakeService.AssertExpectations(t)
setup := func() {
bus.ClearBusHandlers()
- fakeService = mockDashboardProvisioningService()
bus.AddHandler("test", mockGetDashboardQuery)
cfg = &config{
- Name: "Default",
+ Name: configName,
Type: "file",
OrgID: 1,
Folder: "",
@@ -122,45 +115,38 @@ func TestDashboardFileReader(t *testing.T) {
cfg.Options["path"] = defaultDashboards
cfg.Folder = "Team A"
+ fakeService.On("GetProvisionedDashboardData", configName).Return(nil, nil).Once()
+ fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything).Return(&models.Dashboard{Id: 1}, nil).Once()
+ fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&models.Dashboard{Id: 2}, nil).Times(2)
+
reader, err := NewDashboardFileReader(cfg, logger, nil)
+ reader.dashboardProvisioningService = fakeService
require.NoError(t, err)
err = reader.walkDisk(context.Background())
require.NoError(t, err)
-
- folders := 0
- dashboards := 0
-
- for _, i := range fakeService.inserted {
- if i.Dashboard.IsFolder {
- folders++
- } else {
- dashboards++
- }
- }
-
- require.Equal(t, folders, 1)
- require.Equal(t, dashboards, 2)
})
t.Run("Can read default dashboard and replace old version in database", func(t *testing.T) {
setup()
cfg.Options["path"] = oneDashboard
- stat, _ := os.Stat(oneDashboard + "/dashboard1.json")
-
- fakeService.getDashboard = append(fakeService.getDashboard, &models.Dashboard{
- Updated: stat.ModTime().AddDate(0, 0, -1),
- Slug: "grafana",
- })
+ inserted := 0
+ fakeService.On("GetProvisionedDashboardData", configName).Return(nil, nil).Once()
+ fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).
+ Return(&models.Dashboard{}, nil).Once().
+ Run(func(args mock.Arguments) {
+ inserted++
+ })
reader, err := NewDashboardFileReader(cfg, logger, nil)
+ reader.dashboardProvisioningService = fakeService
require.NoError(t, err)
err = reader.walkDisk(context.Background())
require.NoError(t, err)
- require.Equal(t, len(fakeService.inserted), 1)
+ assert.Equal(t, inserted, 1)
})
t.Run("Dashboard with older timestamp and the same checksum will not replace imported dashboard", func(t *testing.T) {
@@ -179,23 +165,23 @@ func TestDashboardFileReader(t *testing.T) {
checksum, err := util.Md5Sum(file)
require.NoError(t, err)
- fakeService.provisioned = map[string][]*models.DashboardProvisioning{
- "Default": {
- {
- Name: "Default",
- ExternalId: absPath,
- Updated: stat.ModTime().AddDate(0, 0, +1).Unix(),
- CheckSum: checksum,
- },
+ provisionedDashboard := []*models.DashboardProvisioning{
+ {
+ Name: "Default",
+ ExternalId: absPath,
+ Updated: stat.ModTime().AddDate(0, 0, +1).Unix(),
+ CheckSum: checksum,
},
}
+ fakeService.On("GetProvisionedDashboardData", configName).Return(provisionedDashboard, nil).Once()
+
reader, err := NewDashboardFileReader(cfg, logger, nil)
+ reader.dashboardProvisioningService = fakeService
require.NoError(t, err)
err = reader.walkDisk(context.Background())
require.NoError(t, err)
- require.Equal(t, len(fakeService.inserted), 0)
})
t.Run("Dashboard with older timestamp and different checksum will replace imported dashboard", func(t *testing.T) {
@@ -206,23 +192,24 @@ func TestDashboardFileReader(t *testing.T) {
stat, err := os.Stat(oneDashboard + "/dashboard1.json")
require.NoError(t, err)
- fakeService.provisioned = map[string][]*models.DashboardProvisioning{
- "Default": {
- {
- Name: "Default",
- ExternalId: absPath,
- Updated: stat.ModTime().AddDate(0, 0, +1).Unix(),
- CheckSum: "fakechecksum",
- },
+ provisionedDashboard := []*models.DashboardProvisioning{
+ {
+ Name: "Default",
+ ExternalId: absPath,
+ Updated: stat.ModTime().AddDate(0, 0, +1).Unix(),
+ CheckSum: "fakechecksum",
},
}
+ fakeService.On("GetProvisionedDashboardData", configName).Return(provisionedDashboard, nil).Once()
+ fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Once()
+
reader, err := NewDashboardFileReader(cfg, logger, nil)
+ reader.dashboardProvisioningService = fakeService
require.NoError(t, err)
err = reader.walkDisk(context.Background())
require.NoError(t, err)
- require.Equal(t, len(fakeService.inserted), 1)
})
t.Run("Dashboard with newer timestamp and the same checksum will not replace imported dashboard", func(t *testing.T) {
@@ -241,23 +228,23 @@ func TestDashboardFileReader(t *testing.T) {
checksum, err := util.Md5Sum(file)
require.NoError(t, err)
- fakeService.provisioned = map[string][]*models.DashboardProvisioning{
- "Default": {
- {
- Name: "Default",
- ExternalId: absPath,
- Updated: stat.ModTime().AddDate(0, 0, -1).Unix(),
- CheckSum: checksum,
- },
+ provisionedDashboard := []*models.DashboardProvisioning{
+ {
+ Name: "Default",
+ ExternalId: absPath,
+ Updated: stat.ModTime().AddDate(0, 0, -1).Unix(),
+ CheckSum: checksum,
},
}
+ fakeService.On("GetProvisionedDashboardData", configName).Return(provisionedDashboard, nil).Once()
+
reader, err := NewDashboardFileReader(cfg, logger, nil)
+ reader.dashboardProvisioningService = fakeService
require.NoError(t, err)
err = reader.walkDisk(context.Background())
require.NoError(t, err)
- require.Equal(t, len(fakeService.inserted), 0)
})
t.Run("Dashboard with newer timestamp and different checksum should replace imported dashboard", func(t *testing.T) {
@@ -268,36 +255,39 @@ func TestDashboardFileReader(t *testing.T) {
stat, err := os.Stat(oneDashboard + "/dashboard1.json")
require.NoError(t, err)
- fakeService.provisioned = map[string][]*models.DashboardProvisioning{
- "Default": {
- {
- Name: "Default",
- ExternalId: absPath,
- Updated: stat.ModTime().AddDate(0, 0, -1).Unix(),
- CheckSum: "fakechecksum",
- },
+ provisionedDashboard := []*models.DashboardProvisioning{
+ {
+ Name: "Default",
+ ExternalId: absPath,
+ Updated: stat.ModTime().AddDate(0, 0, -1).Unix(),
+ CheckSum: "fakechecksum",
},
}
+ fakeService.On("GetProvisionedDashboardData", configName).Return(provisionedDashboard, nil).Once()
+ fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Once()
+
reader, err := NewDashboardFileReader(cfg, logger, nil)
+ reader.dashboardProvisioningService = fakeService
require.NoError(t, err)
err = reader.walkDisk(context.Background())
require.NoError(t, err)
- require.Equal(t, len(fakeService.inserted), 1)
})
t.Run("Overrides id from dashboard.json files", func(t *testing.T) {
setup()
cfg.Options["path"] = containingID
+ fakeService.On("GetProvisionedDashboardData", configName).Return(nil, nil).Once()
+ fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Once()
+
reader, err := NewDashboardFileReader(cfg, logger, nil)
+ reader.dashboardProvisioningService = fakeService
require.NoError(t, err)
err = reader.walkDisk(context.Background())
require.NoError(t, err)
-
- require.Equal(t, len(fakeService.inserted), 1)
})
t.Run("Get folder from files structure", func(t *testing.T) {
@@ -305,40 +295,16 @@ func TestDashboardFileReader(t *testing.T) {
cfg.Options["path"] = foldersFromFilesStructure
cfg.Options["foldersFromFilesStructure"] = true
+ fakeService.On("GetProvisionedDashboardData", configName).Return(nil, nil).Once()
+ fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Times(2)
+ fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Times(3)
+
reader, err := NewDashboardFileReader(cfg, logger, nil)
+ reader.dashboardProvisioningService = fakeService
require.NoError(t, err)
err = reader.walkDisk(context.Background())
require.NoError(t, err)
-
- require.Equal(t, len(fakeService.inserted), 5)
-
- foldersCount := 0
- for _, d := range fakeService.inserted {
- if d.Dashboard.IsFolder {
- foldersCount++
- }
- }
- require.Equal(t, foldersCount, 2)
-
- foldersAndDashboards := make(map[string]struct{}, 5)
- for _, d := range fakeService.inserted {
- title := d.Dashboard.Title
- if _, ok := foldersAndDashboards[title]; ok {
- require.Nil(t, fmt.Errorf("dashboard title %q already exists", title))
- }
-
- switch title {
- case "folderOne", "folderTwo":
- require.True(t, d.Dashboard.IsFolder)
- case "Grafana1", "Grafana2", "RootDashboard":
- require.False(t, d.Dashboard.IsFolder)
- default:
- require.Nil(t, fmt.Errorf("unknown dashboard title %q", title))
- }
-
- foldersAndDashboards[title] = struct{}{}
- }
})
t.Run("Invalid configuration should return error", func(t *testing.T) {
@@ -367,30 +333,23 @@ func TestDashboardFileReader(t *testing.T) {
cfg1 := &config{Name: "1", Type: "file", OrgID: 1, Folder: "f1", Options: map[string]interface{}{"path": containingID}}
cfg2 := &config{Name: "2", Type: "file", OrgID: 1, Folder: "f2", Options: map[string]interface{}{"path": containingID}}
+ fakeService.On("GetProvisionedDashboardData", mock.Anything).Return(nil, nil).Times(2)
+ fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Times(2)
+ fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Times(2)
+
reader1, err := NewDashboardFileReader(cfg1, logger, nil)
+ reader1.dashboardProvisioningService = fakeService
require.NoError(t, err)
err = reader1.walkDisk(context.Background())
require.NoError(t, err)
reader2, err := NewDashboardFileReader(cfg2, logger, nil)
+ reader2.dashboardProvisioningService = fakeService
require.NoError(t, err)
err = reader2.walkDisk(context.Background())
require.NoError(t, err)
-
- var folderCount int
- var dashCount int
- for _, o := range fakeService.inserted {
- if o.Dashboard.IsFolder {
- folderCount++
- } else {
- dashCount++
- }
- }
-
- require.Equal(t, folderCount, 2)
- require.Equal(t, dashCount, 2)
})
})
@@ -422,16 +381,9 @@ func TestDashboardFileReader(t *testing.T) {
},
}
- folderID, err := getOrCreateFolderID(context.Background(), cfg, fakeService, cfg.Folder)
+ fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Once()
+ _, err := getOrCreateFolderID(context.Background(), cfg, fakeService, cfg.Folder)
require.NoError(t, err)
- inserted := false
- for _, d := range fakeService.inserted {
- if d.Dashboard.IsFolder && d.Dashboard.Id == folderID {
- inserted = true
- }
- }
- require.Equal(t, len(fakeService.inserted), 1)
- require.True(t, inserted)
})
t.Run("Walking the folder with dashboards", func(t *testing.T) {
@@ -456,56 +408,53 @@ func TestDashboardFileReader(t *testing.T) {
absPath2, err := filepath.Abs(unprovision + "/dashboard2.json")
require.NoError(t, err)
+ provisionedDashboard := []*models.DashboardProvisioning{
+ {DashboardId: 1, Name: "Default", ExternalId: absPath1},
+ {DashboardId: 2, Name: "Default", ExternalId: absPath2},
+ }
+
setupFakeService := func() {
setup()
cfg = &config{
- Name: "Default",
+ Name: configName,
Type: "file",
OrgID: 1,
Options: map[string]interface{}{
"folder": unprovision,
},
}
-
- fakeService.inserted = []*dashboards.SaveDashboardDTO{
- {Dashboard: &models.Dashboard{Id: 1}},
- {Dashboard: &models.Dashboard{Id: 2}},
- }
-
- fakeService.provisioned = map[string][]*models.DashboardProvisioning{
- "Default": {
- {DashboardId: 1, Name: "Default", ExternalId: absPath1},
- {DashboardId: 2, Name: "Default", ExternalId: absPath2},
- },
- }
}
t.Run("Missing dashboard should be unprovisioned if DisableDeletion = true", func(t *testing.T) {
setupFakeService()
+
+ fakeService.On("GetProvisionedDashboardData", configName).Return(provisionedDashboard, nil).Once()
+ fakeService.On("UnprovisionDashboard", mock.Anything, mock.Anything).Return(nil).Once()
+ fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Once()
+
cfg.DisableDeletion = true
reader, err := NewDashboardFileReader(cfg, logger, nil)
+ reader.dashboardProvisioningService = fakeService
require.NoError(t, err)
err = reader.walkDisk(context.Background())
require.NoError(t, err)
-
- require.Equal(t, len(fakeService.provisioned["Default"]), 1)
- require.Equal(t, fakeService.provisioned["Default"][0].ExternalId, absPath1)
})
t.Run("Missing dashboard should be deleted if DisableDeletion = false", func(t *testing.T) {
setupFakeService()
+
+ fakeService.On("GetProvisionedDashboardData", configName).Return(provisionedDashboard, nil).Once()
+ fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Once()
+ fakeService.On("DeleteProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once()
+
reader, err := NewDashboardFileReader(cfg, logger, nil)
+ reader.dashboardProvisioningService = fakeService
require.NoError(t, err)
err = reader.walkDisk(context.Background())
require.NoError(t, err)
-
- require.Equal(t, len(fakeService.provisioned["Default"]), 1)
- require.Equal(t, fakeService.provisioned["Default"][0].ExternalId, absPath1)
- require.Equal(t, len(fakeService.inserted), 1)
- require.Equal(t, fakeService.inserted[0].Dashboard.Id, int64(1))
})
})
}
@@ -539,111 +488,6 @@ func (ffi FakeFileInfo) Sys() interface{} {
return nil
}
-func mockDashboardProvisioningService() *fakeDashboardProvisioningService {
- mock := fakeDashboardProvisioningService{
- provisioned: map[string][]*models.DashboardProvisioning{},
- }
- dashboards.NewProvisioningService = func(dboards.Store) dashboards.DashboardProvisioningService {
- return &mock
- }
- return &mock
-}
-
-type fakeDashboardProvisioningService struct {
- dashboards.DashboardProvisioningService
-
- inserted []*dashboards.SaveDashboardDTO
- provisioned map[string][]*models.DashboardProvisioning
- getDashboard []*models.Dashboard
-}
-
-func (s *fakeDashboardProvisioningService) GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error) {
- if _, ok := s.provisioned[name]; !ok {
- s.provisioned[name] = []*models.DashboardProvisioning{}
- }
-
- return s.provisioned[name], nil
-}
-
-func (s *fakeDashboardProvisioningService) SaveProvisionedDashboard(ctx context.Context, dto *dashboards.SaveDashboardDTO,
- provisioning *models.DashboardProvisioning) (*models.Dashboard, error) {
- // Copy the structs as we need to change them but do not want to alter outside world.
- var copyProvisioning = &models.DashboardProvisioning{}
- *copyProvisioning = *provisioning
-
- var copyDto = &dashboards.SaveDashboardDTO{}
- *copyDto = *dto
-
- if copyDto.Dashboard.Id == 0 {
- copyDto.Dashboard.Id = rand.Int63n(1000000)
- } else {
- err := s.DeleteProvisionedDashboard(context.Background(), dto.Dashboard.Id, dto.Dashboard.OrgId)
- // Lets delete existing so we do not have duplicates
- if err != nil {
- return nil, err
- }
- }
-
- s.inserted = append(s.inserted, dto)
-
- if _, ok := s.provisioned[provisioning.Name]; !ok {
- s.provisioned[provisioning.Name] = []*models.DashboardProvisioning{}
- }
-
- for _, val := range s.provisioned[provisioning.Name] {
- if val.DashboardId == dto.Dashboard.Id && val.Name == provisioning.Name {
- // Do not insert duplicates
- return dto.Dashboard, nil
- }
- }
-
- copyProvisioning.DashboardId = copyDto.Dashboard.Id
-
- s.provisioned[provisioning.Name] = append(s.provisioned[provisioning.Name], copyProvisioning)
- return dto.Dashboard, nil
-}
-
-func (s *fakeDashboardProvisioningService) SaveFolderForProvisionedDashboards(ctx context.Context, dto *dashboards.SaveDashboardDTO) (*models.Dashboard, error) {
- s.inserted = append(s.inserted, dto)
- return dto.Dashboard, nil
-}
-
-func (s *fakeDashboardProvisioningService) UnprovisionDashboard(ctx context.Context, dashboardID int64) error {
- for key, val := range s.provisioned {
- for index, dashboard := range val {
- if dashboard.DashboardId == dashboardID {
- s.provisioned[key] = append(s.provisioned[key][:index], s.provisioned[key][index+1:]...)
- }
- }
- }
- return nil
-}
-
-func (s *fakeDashboardProvisioningService) DeleteProvisionedDashboard(ctx context.Context, dashboardID int64, orgID int64) error {
- err := s.UnprovisionDashboard(ctx, dashboardID)
- if err != nil {
- return err
- }
-
- for index, val := range s.inserted {
- if val.Dashboard.Id == dashboardID {
- s.inserted = append(s.inserted[:index], s.inserted[util.MinInt(index+1, len(s.inserted)):]...)
- }
- }
- return nil
-}
-
-func (s *fakeDashboardProvisioningService) GetProvisionedDashboardDataByDashboardID(dashboardID int64) (*models.DashboardProvisioning, error) {
- return nil, nil
-}
-
-func mockGetDashboardQuery(ctx context.Context, cmd *models.GetDashboardQuery) error {
- for _, d := range fakeService.getDashboard {
- if d.Slug == cmd.Slug {
- cmd.Result = d
- return nil
- }
- }
-
+func mockGetDashboardQuery(_ context.Context, _ *models.GetDashboardQuery) error {
return models.ErrDashboardNotFound
}
diff --git a/pkg/services/provisioning/dashboards/validator_test.go b/pkg/services/provisioning/dashboards/validator_test.go
index e1e588e75f9..040e4699b45 100644
--- a/pkg/services/provisioning/dashboards/validator_test.go
+++ b/pkg/services/provisioning/dashboards/validator_test.go
@@ -5,10 +5,12 @@ import (
"sort"
"testing"
- "github.com/stretchr/testify/require"
-
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/infra/log"
+ "github.com/grafana/grafana/pkg/models"
+ "github.com/grafana/grafana/pkg/services/dashboards"
+ "github.com/stretchr/testify/mock"
+ "github.com/stretchr/testify/require"
)
const (
@@ -18,7 +20,8 @@ const (
func TestDuplicatesValidator(t *testing.T) {
bus.ClearBusHandlers()
- fakeService = mockDashboardProvisioningService()
+ fakeService := &dashboards.FakeDashboardProvisioning{}
+ defer fakeService.AssertExpectations(t)
bus.AddHandler("test", mockGetDashboardQuery)
cfg := &config{
@@ -32,6 +35,11 @@ func TestDuplicatesValidator(t *testing.T) {
t.Run("Duplicates validator should collect info about duplicate UIDs and titles within folders", func(t *testing.T) {
const folderName = "duplicates-validator-folder"
+
+ fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Times(3)
+ fakeService.On("GetProvisionedDashboardData", mock.Anything).Return([]*models.DashboardProvisioning{}, nil).Times(2)
+ fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Times(2)
+
folderID, err := getOrCreateFolderID(context.Background(), cfg, fakeService, folderName)
require.NoError(t, err)
@@ -47,9 +55,11 @@ func TestDuplicatesValidator(t *testing.T) {
}
reader1, err := NewDashboardFileReader(cfg1, logger, nil)
+ reader1.dashboardProvisioningService = fakeService
require.NoError(t, err)
reader2, err := NewDashboardFileReader(cfg2, logger, nil)
+ reader2.dashboardProvisioningService = fakeService
require.NoError(t, err)
duplicateValidator := newDuplicateValidator(logger, []*FileReader{reader1, reader2})
@@ -79,6 +89,11 @@ func TestDuplicatesValidator(t *testing.T) {
t.Run("Duplicates validator should not collect info about duplicate UIDs and titles within folders for different orgs", func(t *testing.T) {
const folderName = "duplicates-validator-folder"
+
+ fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Times(3)
+ fakeService.On("GetProvisionedDashboardData", mock.Anything).Return([]*models.DashboardProvisioning{}, nil).Times(2)
+ fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Times(2)
+
folderID, err := getOrCreateFolderID(context.Background(), cfg, fakeService, folderName)
require.NoError(t, err)
@@ -94,9 +109,11 @@ func TestDuplicatesValidator(t *testing.T) {
}
reader1, err := NewDashboardFileReader(cfg1, logger, nil)
+ reader1.dashboardProvisioningService = fakeService
require.NoError(t, err)
reader2, err := NewDashboardFileReader(cfg2, logger, nil)
+ reader2.dashboardProvisioningService = fakeService
require.NoError(t, err)
duplicateValidator := newDuplicateValidator(logger, []*FileReader{reader1, reader2})
@@ -135,6 +152,10 @@ func TestDuplicatesValidator(t *testing.T) {
})
t.Run("Duplicates validator should restrict write access only for readers with duplicates", func(t *testing.T) {
+ fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Times(5)
+ fakeService.On("GetProvisionedDashboardData", mock.Anything).Return([]*models.DashboardProvisioning{}, nil).Times(3)
+ fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil).Times(6)
+
cfg1 := &config{
Name: "first", Type: "file", OrgID: 1, Folder: "duplicates-validator-folder",
Options: map[string]interface{}{"path": twoDashboardsWithUID},
@@ -149,12 +170,15 @@ func TestDuplicatesValidator(t *testing.T) {
}
reader1, err := NewDashboardFileReader(cfg1, logger, nil)
+ reader1.dashboardProvisioningService = fakeService
require.NoError(t, err)
reader2, err := NewDashboardFileReader(cfg2, logger, nil)
+ reader2.dashboardProvisioningService = fakeService
require.NoError(t, err)
reader3, err := NewDashboardFileReader(cfg3, logger, nil)
+ reader3.dashboardProvisioningService = fakeService
require.NoError(t, err)
duplicateValidator := newDuplicateValidator(logger, []*FileReader{reader1, reader2, reader3})
diff --git a/pkg/services/provisioning/provisioning.go b/pkg/services/provisioning/provisioning.go
index c7210b9279a..711b90b7962 100644
--- a/pkg/services/provisioning/provisioning.go
+++ b/pkg/services/provisioning/provisioning.go
@@ -8,6 +8,7 @@ import (
"github.com/grafana/grafana/pkg/infra/log"
plugifaces "github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/registry"
+ dashboardservice "github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/encryption"
"github.com/grafana/grafana/pkg/services/notifications"
"github.com/grafana/grafana/pkg/services/provisioning/dashboards"
@@ -20,10 +21,11 @@ import (
)
func ProvideService(cfg *setting.Cfg, sqlStore *sqlstore.SQLStore, pluginStore plugifaces.Store,
- encryptionService encryption.Internal, notificatonService *notifications.NotificationService) (*ProvisioningServiceImpl, error) {
+ encryptionService encryption.Internal, notificatonService *notifications.NotificationService,
+ dashboardService dashboardservice.DashboardProvisioningService,
+) (*ProvisioningServiceImpl, error) {
s := &ProvisioningServiceImpl{
Cfg: cfg,
- SQLStore: sqlStore,
pluginStore: pluginStore,
EncryptionService: encryptionService,
NotificationService: notificatonService,
@@ -32,6 +34,7 @@ func ProvideService(cfg *setting.Cfg, sqlStore *sqlstore.SQLStore, pluginStore p
provisionNotifiers: notifiers.Provision,
provisionDatasources: datasources.Provision,
provisionPlugins: plugins.Provision,
+ dashboardService: dashboardService,
}
return s, nil
}
@@ -88,6 +91,7 @@ type ProvisioningServiceImpl struct {
provisionDatasources func(context.Context, string) error
provisionPlugins func(context.Context, string, plugifaces.Store) error
mutex sync.Mutex
+ dashboardService dashboardservice.DashboardProvisioningService
}
func (ps *ProvisioningServiceImpl) RunInitProvisioners(ctx context.Context) error {
@@ -170,7 +174,7 @@ func (ps *ProvisioningServiceImpl) ProvisionNotifications(ctx context.Context) e
func (ps *ProvisioningServiceImpl) ProvisionDashboards(ctx context.Context) error {
dashboardPath := filepath.Join(ps.Cfg.ProvisioningPath, "dashboards")
- dashProvisioner, err := ps.newDashboardProvisioner(ctx, dashboardPath, ps.SQLStore)
+ dashProvisioner, err := ps.newDashboardProvisioner(ctx, dashboardPath, ps.dashboardService)
if err != nil {
return errutil.Wrap("Failed to create provisioner", err)
}
diff --git a/pkg/services/provisioning/provisioning_test.go b/pkg/services/provisioning/provisioning_test.go
index 731b3c0a3ce..8bf248f97fa 100644
--- a/pkg/services/provisioning/provisioning_test.go
+++ b/pkg/services/provisioning/provisioning_test.go
@@ -6,7 +6,7 @@ import (
"testing"
"time"
- dboards "github.com/grafana/grafana/pkg/dashboards"
+ dashboardstore "github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/provisioning/dashboards"
"github.com/grafana/grafana/pkg/setting"
"github.com/stretchr/testify/assert"
@@ -92,7 +92,7 @@ func setup() *serviceTestStruct {
}
serviceTest.service = newProvisioningServiceImpl(
- func(context.Context, string, dboards.Store) (dashboards.DashboardProvisioner, error) {
+ func(context.Context, string, dashboardstore.DashboardProvisioningService) (dashboards.DashboardProvisioner, error) {
return serviceTest.mock, nil
},
nil,
diff --git a/pkg/services/rendering/http_mode.go b/pkg/services/rendering/http_mode.go
index 3617b3296bd..e8820b9e1f9 100644
--- a/pkg/services/rendering/http_mode.go
+++ b/pkg/services/rendering/http_mode.go
@@ -29,8 +29,9 @@ var netClient = &http.Client{
}
var (
- remoteVersionFetchInterval time.Duration = time.Second * 15
- remoteVersionFetchRetries uint = 4
+ remoteVersionFetchInterval time.Duration = time.Second * 15
+ remoteVersionFetchRetries uint = 4
+ remoteVersionRefreshInterval = time.Minute * 15
)
func (rs *RenderingService) renderViaHTTP(ctx context.Context, renderKey string, opts Opts) (*RenderResult, error) {
@@ -251,3 +252,26 @@ func (rs *RenderingService) getRemotePluginVersion() (string, error) {
}
return info.Version, nil
}
+
+func (rs *RenderingService) refreshRemotePluginVersion() {
+ newVersion, err := rs.getRemotePluginVersion()
+ if err != nil {
+ rs.log.Info("Failed to refresh remote plugin version", "err", err)
+ return
+ }
+
+ if newVersion == "" {
+ // the image-renderer could have been temporary unavailable - skip updating the version
+ rs.log.Debug("Received empty version when trying to refresh remote plugin version")
+ return
+ }
+
+ currentVersion := rs.Version()
+ if currentVersion != newVersion {
+ rs.versionMutex.Lock()
+ defer rs.versionMutex.Unlock()
+
+ rs.log.Info("Updating remote plugin version", "currentVersion", currentVersion, "newVersion", newVersion)
+ rs.version = newVersion
+ }
+}
diff --git a/pkg/services/rendering/rendering.go b/pkg/services/rendering/rendering.go
index d62a4447ed1..2de807cb5a4 100644
--- a/pkg/services/rendering/rendering.go
+++ b/pkg/services/rendering/rendering.go
@@ -120,8 +120,19 @@ func (rs *RenderingService) Run(ctx context.Context) error {
})
rs.renderAction = rs.renderViaHTTP
rs.renderCSVAction = rs.renderCSVViaHTTP
- <-ctx.Done()
- return nil
+
+ refreshTicker := time.NewTicker(remoteVersionRefreshInterval)
+
+ for {
+ select {
+ case <-refreshTicker.C:
+ go rs.refreshRemotePluginVersion()
+ case <-ctx.Done():
+ rs.log.Debug("Grafana is shutting down - stopping image-renderer version refresh")
+ refreshTicker.Stop()
+ return nil
+ }
+ }
}
if rs.pluginAvailable() {
diff --git a/pkg/services/searchV2/auth.go b/pkg/services/searchV2/auth.go
new file mode 100644
index 00000000000..4f2d8cb6706
--- /dev/null
+++ b/pkg/services/searchV2/auth.go
@@ -0,0 +1,67 @@
+package searchV2
+
+import (
+ "context"
+
+ "github.com/grafana/grafana/pkg/models"
+ "github.com/grafana/grafana/pkg/services/sqlstore"
+ "github.com/grafana/grafana/pkg/services/sqlstore/permissions"
+)
+
+// ResourceFilter checks if a given a uid (resource identifier) check if we have the requested permission
+type ResourceFilter func(uid string) bool
+
+// FutureAuthService eventually implemented by the security service
+type FutureAuthService interface {
+ GetDashboardReadFilter(user *models.SignedInUser) (ResourceFilter, error)
+}
+
+type simpleSQLAuthService struct {
+ sql *sqlstore.SQLStore
+}
+
+type dashIdQueryResult struct {
+ UID string `xorm:"uid"`
+}
+
+func (a *simpleSQLAuthService) GetDashboardReadFilter(user *models.SignedInUser) (ResourceFilter, error) {
+ // this filter works on the legacy `dashboard_acl` table
+ // we will also need to use `accesscontrol` after https://github.com/grafana/grafana/pull/44702/files is merged
+ // see https://github.com/grafana/grafana/blob/e355bd6d3a04111b8c9959f85e81beabbeb746bf/pkg/services/sqlstore/permissions/dashboard.go#L84
+ filter := permissions.DashboardPermissionFilter{
+ OrgRole: user.OrgRole,
+ OrgId: user.OrgId,
+ Dialect: a.sql.Dialect,
+ UserId: user.UserId,
+ PermissionLevel: models.PERMISSION_VIEW,
+ }
+
+ rows := make([]*dashIdQueryResult, 0)
+
+ err := a.sql.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error {
+ sql, params := filter.Where()
+ sess.Table("dashboard").
+ Where(sql, params...).
+ Cols("uid")
+
+ err := sess.Find(&rows)
+ if err != nil {
+ return err
+ }
+
+ return nil
+ })
+
+ if err != nil {
+ return nil, err
+ }
+
+ uids := make(map[string]bool, len(rows)+1)
+ for i := 0; i < len(rows); i++ {
+ uids[rows[i].UID] = true
+ }
+
+ return func(uid string) bool {
+ return uids[uid]
+ }, err
+}
diff --git a/pkg/services/searchV2/extract/dashboard.go b/pkg/services/searchV2/extract/dashboard.go
index dac4a12305a..38e174e5b47 100644
--- a/pkg/services/searchV2/extract/dashboard.go
+++ b/pkg/services/searchV2/extract/dashboard.go
@@ -1,14 +1,13 @@
package extract
import (
- "fmt"
"io"
jsoniter "github.com/json-iterator/go"
)
func logf(format string, a ...interface{}) {
- fmt.Printf(format, a...)
+ //fmt.Printf(format, a...)
}
// nolint:gocyclo
diff --git a/pkg/services/searchV2/service.go b/pkg/services/searchV2/service.go
index d93d31dc560..c2ea11f6923 100644
--- a/pkg/services/searchV2/service.go
+++ b/pkg/services/searchV2/service.go
@@ -16,12 +16,16 @@ import (
)
type StandardSearchService struct {
- sql *sqlstore.SQLStore
+ sql *sqlstore.SQLStore
+ auth FutureAuthService // eventually injected from elsewhere
}
func ProvideService(sql *sqlstore.SQLStore) SearchService {
return &StandardSearchService{
sql: sql,
+ auth: &simpleSQLAuthService{
+ sql: sql,
+ },
}
}
@@ -29,6 +33,7 @@ type dashMeta struct {
id int64
is_folder bool
folder_id int64
+ slug string
created time.Time
updated time.Time
dash *extract.DashboardInfo
@@ -37,13 +42,34 @@ type dashMeta struct {
func (s *StandardSearchService) DoDashboardQuery(ctx context.Context, user *backend.User, orgId int64, query DashboardQuery) *backend.DataResponse {
rsp := &backend.DataResponse{}
- if user == nil || user.Role != string(models.ROLE_ADMIN) {
- rsp.Error = fmt.Errorf("search is only supported for admin users while in early development")
+ // Load and parse all dashboards for given orgId
+ dash, err := loadDashboards(ctx, orgId, s.sql)
+ if err != nil {
+ rsp.Error = err
return rsp
}
- // Load and parse all dashboards for given orgId
- dash, err := loadDashboards(ctx, orgId, s.sql)
+ // TODO - get user from context?
+ getSignedInUserQuery := &models.GetSignedInUserQuery{
+ Login: user.Login,
+ Email: user.Email,
+ OrgId: orgId,
+ }
+
+ err = s.sql.GetSignedInUser(ctx, getSignedInUserQuery)
+ if err != nil {
+ fmt.Printf("error while retrieving user %s\n", err)
+ rsp.Error = fmt.Errorf("auth error")
+ return rsp
+ }
+
+ if getSignedInUserQuery.Result == nil {
+ fmt.Printf("no user %s", user.Email)
+ rsp.Error = fmt.Errorf("auth error")
+ return rsp
+ }
+
+ dash, err = s.applyAuthFilter(getSignedInUserQuery.Result, dash)
if err != nil {
rsp.Error = err
return rsp
@@ -54,10 +80,27 @@ func (s *StandardSearchService) DoDashboardQuery(ctx context.Context, user *back
return rsp
}
+func (s *StandardSearchService) applyAuthFilter(user *models.SignedInUser, dash []dashMeta) ([]dashMeta, error) {
+ filter, err := s.auth.GetDashboardReadFilter(user)
+ if err != nil {
+ return nil, err
+ }
+
+ // create a list of all viewable dashboards for this user
+ res := make([]dashMeta, 0, len(dash))
+ for _, dash := range dash {
+ if filter(dash.dash.UID) {
+ res = append(res, dash)
+ }
+ }
+ return res, nil
+}
+
type dashDataQueryResult struct {
Id int64
- IsFolder bool `xorm:"is_folder"`
- FolderID int64 `xorm:"folder_id"`
+ IsFolder bool `xorm:"is_folder"`
+ FolderID int64 `xorm:"folder_id"`
+ Slug string `xorm:"slug"`
Data []byte
Created time.Time
Updated time.Time
@@ -76,7 +119,7 @@ func loadDashboards(ctx context.Context, orgID int64, sql *sqlstore.SQLStore) ([
sess.Table("dashboard").
Where("org_id = ?", orgID).
- Cols("id", "is_folder", "folder_id", "data", "created", "updated")
+ Cols("id", "is_folder", "folder_id", "data", "slug", "created", "updated")
err := sess.Find(&rows)
if err != nil {
@@ -90,6 +133,7 @@ func loadDashboards(ctx context.Context, orgID int64, sql *sqlstore.SQLStore) ([
id: row.Id,
is_folder: row.IsFolder,
folder_id: row.FolderID,
+ slug: row.Slug,
created: row.Created,
updated: row.Updated,
dash: dash,
@@ -136,6 +180,7 @@ func metaToFrame(meta []dashMeta) data.Frames {
dashID := data.NewFieldFromFieldType(data.FieldTypeInt64, 0)
dashUID := data.NewFieldFromFieldType(data.FieldTypeString, 0)
+ dashURL := data.NewFieldFromFieldType(data.FieldTypeString, 0)
dashFolderID := data.NewFieldFromFieldType(data.FieldTypeInt64, 0)
dashName := data.NewFieldFromFieldType(data.FieldTypeString, 0)
dashDescr := data.NewFieldFromFieldType(data.FieldTypeString, 0)
@@ -153,6 +198,12 @@ func metaToFrame(meta []dashMeta) data.Frames {
dashSchemaVersion.Name = "SchemaVersion"
dashCreated.Name = "Created"
dashUpdated.Name = "Updated"
+ dashURL.Name = "URL"
+ dashURL.Config = &data.FieldConfig{
+ Links: []data.DataLink{
+ {Title: "link", URL: "${__value.text}"},
+ },
+ }
dashTags.Config = &data.FieldConfig{
Custom: map[string]interface{}{
@@ -199,6 +250,9 @@ func metaToFrame(meta []dashMeta) data.Frames {
dashCreated.Append(row.created)
dashUpdated.Append(row.updated)
+ url := fmt.Sprintf("/d/%s/%s", row.dash.UID, row.slug)
+ dashURL.Append(url)
+
// stats
schemaVersionCounter.add(strconv.FormatInt(row.dash.SchemaVersion, 10))
@@ -226,7 +280,7 @@ func metaToFrame(meta []dashMeta) data.Frames {
return data.Frames{
data.NewFrame("folders", folderID, folderUID, folderName),
- data.NewFrame("dashboards", dashID, dashUID, dashFolderID, dashName, dashDescr, dashTags, dashSchemaVersion, dashCreated, dashUpdated),
+ data.NewFrame("dashboards", dashID, dashUID, dashURL, dashFolderID, dashName, dashDescr, dashTags, dashSchemaVersion, dashCreated, dashUpdated),
data.NewFrame("panels", panelDashID, panelID, panelName, panelDescr, panelType),
panelTypeCounter.toFrame("panel-type-counts"),
schemaVersionCounter.toFrame("schema-version-counts"),
diff --git a/pkg/services/serviceaccounts/api/api.go b/pkg/services/serviceaccounts/api/api.go
index 3c5ade4d439..1d26c54ce01 100644
--- a/pkg/services/serviceaccounts/api/api.go
+++ b/pkg/services/serviceaccounts/api/api.go
@@ -71,6 +71,8 @@ func (api *ServiceAccountsAPI) RegisterAPIEndpoints(
api.RouterRegister.Group("/api/serviceaccounts", func(serviceAccountsRoute routing.RouteRegister) {
serviceAccountsRoute.Get("/", auth(middleware.ReqOrgAdmin, accesscontrol.EvalPermission(serviceaccounts.ActionRead, serviceaccounts.ScopeAll)), routing.Wrap(api.ListServiceAccounts))
serviceAccountsRoute.Get("/:serviceAccountId", auth(middleware.ReqOrgAdmin, accesscontrol.EvalPermission(serviceaccounts.ActionRead, serviceaccounts.ScopeID)), routing.Wrap(api.RetrieveServiceAccount))
+ serviceAccountsRoute.Patch("/:serviceAccountId", auth(middleware.ReqOrgAdmin,
+ accesscontrol.EvalPermission(serviceaccounts.ActionWrite, serviceaccounts.ScopeID)), routing.Wrap(api.updateServiceAccount))
serviceAccountsRoute.Delete("/:serviceAccountId", auth(middleware.ReqOrgAdmin, accesscontrol.EvalPermission(serviceaccounts.ActionDelete, serviceaccounts.ScopeID)), routing.Wrap(api.DeleteServiceAccount))
serviceAccountsRoute.Post("/upgradeall", auth(middleware.ReqOrgAdmin, accesscontrol.EvalPermission(serviceaccounts.ActionCreate)), routing.Wrap(api.UpgradeServiceAccounts))
serviceAccountsRoute.Post("/convert/:keyId", auth(middleware.ReqOrgAdmin, accesscontrol.EvalPermission(serviceaccounts.ActionCreate, serviceaccounts.ScopeID)), routing.Wrap(api.ConvertToServiceAccount))
@@ -86,7 +88,7 @@ func (api *ServiceAccountsAPI) RegisterAPIEndpoints(
// POST /api/serviceaccounts
func (api *ServiceAccountsAPI) CreateServiceAccount(c *models.ReqContext) response.Response {
- cmd := serviceaccounts.CreateServiceaccountForm{}
+ cmd := serviceaccounts.CreateServiceAccountForm{}
if err := web.Bind(c.Req, &cmd); err != nil {
return response.Error(http.StatusBadRequest, "Bad request data", err)
}
@@ -120,7 +122,7 @@ func (api *ServiceAccountsAPI) UpgradeServiceAccounts(ctx *models.ReqContext) re
if err := api.store.UpgradeServiceAccounts(ctx.Req.Context()); err == nil {
return response.Success("service accounts upgraded")
} else {
- return response.Error(500, "Internal server error", err)
+ return response.Error(http.StatusInternalServerError, "Internal server error", err)
}
}
@@ -148,8 +150,8 @@ func (api *ServiceAccountsAPI) ListServiceAccounts(c *models.ReqContext) respons
saIDs[strconv.FormatInt(serviceAccounts[i].Id, 10)] = true
}
- metadata, err := api.getAccessControlMetadata(c, saIDs)
- if err == nil && len(metadata) != 0 {
+ metadata := api.getAccessControlMetadata(c, saIDs)
+ if len(metadata) > 0 {
for i := range serviceAccounts {
serviceAccounts[i].AccessControl = metadata[strconv.FormatInt(serviceAccounts[i].Id, 10)]
}
@@ -158,18 +160,21 @@ func (api *ServiceAccountsAPI) ListServiceAccounts(c *models.ReqContext) respons
return response.JSON(http.StatusOK, serviceAccounts)
}
-func (api *ServiceAccountsAPI) getAccessControlMetadata(c *models.ReqContext, saIDs map[string]bool) (map[string]accesscontrol.Metadata, error) {
+func (api *ServiceAccountsAPI) getAccessControlMetadata(c *models.ReqContext, saIDs map[string]bool) map[string]accesscontrol.Metadata {
if api.accesscontrol.IsDisabled() || !c.QueryBool("accesscontrol") {
- return nil, nil
+ return map[string]accesscontrol.Metadata{}
}
- userPermissions, err := api.accesscontrol.GetUserPermissions(c.Req.Context(), c.SignedInUser, accesscontrol.Options{ReloadCache: false})
- if err != nil || len(userPermissions) == 0 {
- api.log.Warn("could not fetch accesscontrol metadata for teams", "error", err)
- return nil, err
+ if c.SignedInUser.Permissions == nil {
+ return map[string]accesscontrol.Metadata{}
}
- return accesscontrol.GetResourcesMetadata(c.Req.Context(), userPermissions, "serviceaccounts", saIDs), nil
+ permissions, ok := c.SignedInUser.Permissions[c.OrgId]
+ if !ok {
+ return map[string]accesscontrol.Metadata{}
+ }
+
+ return accesscontrol.GetResourcesMetadata(c.Req.Context(), permissions, "serviceaccounts", saIDs)
}
func (api *ServiceAccountsAPI) RetrieveServiceAccount(ctx *models.ReqContext) response.Response {
@@ -189,3 +194,31 @@ func (api *ServiceAccountsAPI) RetrieveServiceAccount(ctx *models.ReqContext) re
}
return response.JSON(http.StatusOK, serviceAccount)
}
+
+func (api *ServiceAccountsAPI) updateServiceAccount(c *models.ReqContext) response.Response {
+ scopeID, err := strconv.ParseInt(web.Params(c.Req)[":serviceAccountId"], 10, 64)
+ if err != nil {
+ return response.Error(http.StatusBadRequest, "serviceAccountId is invalid", err)
+ }
+
+ cmd := &serviceaccounts.UpdateServiceAccountForm{}
+ if err := web.Bind(c.Req, &cmd); err != nil {
+ return response.Error(http.StatusBadRequest, "bad request data", err)
+ }
+
+ if cmd.Role != nil && !cmd.Role.IsValid() {
+ return response.Error(http.StatusBadRequest, "Invalid role specified", nil)
+ }
+
+ resp, err := api.store.UpdateServiceAccount(c.Req.Context(), c.OrgId, scopeID, cmd)
+ if err != nil {
+ switch {
+ case errors.Is(err, serviceaccounts.ErrServiceAccountNotFound):
+ return response.Error(http.StatusNotFound, "Failed to retrieve service account", err)
+ default:
+ return response.Error(http.StatusInternalServerError, "Failed update service account", err)
+ }
+ }
+
+ return response.JSON(http.StatusOK, resp)
+}
diff --git a/pkg/services/serviceaccounts/api/api_test.go b/pkg/services/serviceaccounts/api/api_test.go
index 84172674f3e..bff5d52cb61 100644
--- a/pkg/services/serviceaccounts/api/api_test.go
+++ b/pkg/services/serviceaccounts/api/api_test.go
@@ -1,9 +1,11 @@
package api
import (
+ "bytes"
"context"
"encoding/json"
"fmt"
+ "io"
"net/http"
"net/http/httptest"
"testing"
@@ -15,13 +17,13 @@ import (
accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"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/setting"
"github.com/grafana/grafana/pkg/web"
+ "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
-
- "github.com/grafana/grafana/pkg/services/serviceaccounts/database"
)
var (
@@ -60,7 +62,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)
+ server := setupTestServer(t, &svcmock, routing.NewRouteRegister(), testcase.acmock, store, database.NewServiceAccountsStore(store))
actual := requestResponse(server, httpmethod, fmt.Sprintf(endpoint, fmt.Sprint(createduser.Id))).Code
require.Equal(t, testcase.expectedCode, actual)
})
@@ -84,7 +86,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)
+ server := setupTestServer(t, &svcmock, routing.NewRouteRegister(), testcase.acmock, store, database.NewServiceAccountsStore(store))
actual := requestResponse(server, httpmethod, fmt.Sprintf(endpoint, createduser.Id)).Code
require.Equal(t, testcase.expectedCode, actual)
})
@@ -96,8 +98,11 @@ func serviceAccountRequestScenario(t *testing.T, httpMethod string, endpoint str
fn(httpMethod, endpoint, user)
}
-func setupTestServer(t *testing.T, svc *tests.ServiceAccountMock, routerRegister routing.RouteRegister, acmock *accesscontrolmock.Mock, sqlStore *sqlstore.SQLStore) *web.Mux {
- a := NewServiceAccountsAPI(setting.NewCfg(), svc, acmock, routerRegister, database.NewServiceAccountsStore(sqlStore), sqlStore)
+func setupTestServer(t *testing.T, svc *tests.ServiceAccountMock,
+ routerRegister routing.RouteRegister,
+ acmock *accesscontrolmock.Mock,
+ sqlStore *sqlstore.SQLStore, saStore serviceaccounts.Store) *web.Mux {
+ a := NewServiceAccountsAPI(setting.NewCfg(), svc, acmock, routerRegister, saStore, sqlStore)
a.RegisterAPIEndpoints(featuremgmt.WithFeatures(featuremgmt.FlagServiceAccounts))
a.cfg.ApiKeyMaxSecondsToLive = -1 // disable api key expiration
@@ -187,7 +192,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)
+ server := setupTestServer(t, &svcmock, routing.NewRouteRegister(), tc.acmock, store, database.NewServiceAccountsStore(store))
actual := requestResponse(server, httpmethod, fmt.Sprintf(endpoint, scopeID))
@@ -205,3 +210,129 @@ func TestServiceAccountsAPI_RetrieveServiceAccount(t *testing.T) {
})
}
}
+
+func newString(s string) *string {
+ return &s
+}
+
+func TestServiceAccountsAPI_UpdateServiceAccount(t *testing.T) {
+ store := sqlstore.InitTestDB(t)
+ svcmock := tests.ServiceAccountMock{}
+ type testUpdateSATestCase struct {
+ desc string
+ user *tests.TestUser
+ expectedCode int
+ acmock *accesscontrolmock.Mock
+ body *serviceaccounts.UpdateServiceAccountForm
+ Id int
+ }
+
+ role := models.ROLE_ADMIN
+ var invalidRole models.RoleType = "InvalidRole"
+ testCases := []testUpdateSATestCase{
+ {
+ desc: "should be ok to update serviceaccount with permissions",
+ user: &tests.TestUser{Login: "servicetest1@admin", IsServiceAccount: true, Role: "Editor", Name: "Unaltered"},
+ body: &serviceaccounts.UpdateServiceAccountForm{Name: newString("New Name"), Role: &role},
+ acmock: tests.SetupMockAccesscontrol(
+ t,
+ func(c context.Context, siu *models.SignedInUser, _ accesscontrol.Options) ([]*accesscontrol.Permission, error) {
+ return []*accesscontrol.Permission{{Action: serviceaccounts.ActionWrite, Scope: serviceaccounts.ScopeAll}}, nil
+ },
+ false,
+ ),
+ expectedCode: http.StatusOK,
+ },
+ {
+ desc: "bad request when invalid role",
+ user: &tests.TestUser{Login: "servicetest3@admin", IsServiceAccount: true, Role: "Invalid", Name: "Unaltered"},
+ body: &serviceaccounts.UpdateServiceAccountForm{Name: newString("NameB"), Role: &invalidRole},
+ acmock: tests.SetupMockAccesscontrol(
+ t,
+ func(c context.Context, siu *models.SignedInUser, _ accesscontrol.Options) ([]*accesscontrol.Permission, error) {
+ return []*accesscontrol.Permission{{Action: serviceaccounts.ActionWrite, Scope: serviceaccounts.ScopeAll}}, nil
+ },
+ false,
+ ),
+ expectedCode: http.StatusBadRequest,
+ },
+ {
+ desc: "should be forbidden to update serviceaccount if no permissions",
+ user: &tests.TestUser{Login: "servicetest2@admin", IsServiceAccount: true},
+ body: nil,
+ acmock: tests.SetupMockAccesscontrol(
+ t,
+ func(c context.Context, siu *models.SignedInUser, _ accesscontrol.Options) ([]*accesscontrol.Permission, error) {
+ return []*accesscontrol.Permission{}, nil
+ },
+ false,
+ ),
+ expectedCode: http.StatusForbidden,
+ },
+ {
+ desc: "should be not found when the user doesnt exist",
+ user: nil,
+ body: nil,
+ Id: 12,
+ acmock: tests.SetupMockAccesscontrol(
+ t,
+ func(c context.Context, siu *models.SignedInUser, _ accesscontrol.Options) ([]*accesscontrol.Permission, error) {
+ return []*accesscontrol.Permission{{Action: serviceaccounts.ActionWrite, Scope: serviceaccounts.ScopeAll}}, nil
+ },
+ false,
+ ),
+ expectedCode: http.StatusNotFound,
+ },
+ }
+
+ var requestResponse = func(server *web.Mux, httpMethod, requestpath string, body io.Reader) *httptest.ResponseRecorder {
+ req, err := http.NewRequest(httpMethod, requestpath, body)
+ req.Header.Add("Content-Type", "application/json")
+ require.NoError(t, err)
+ recorder := httptest.NewRecorder()
+ server.ServeHTTP(recorder, req)
+ return recorder
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.desc, func(t *testing.T) {
+ serviceAccountRequestScenario(t, http.MethodPatch, serviceaccountIDPath, tc.user, func(httpmethod string, endpoint string, user *tests.TestUser) {
+ scopeID := tc.Id
+ if tc.user != nil {
+ createdUser := tests.SetupUserServiceAccount(t, store, *tc.user)
+ scopeID = int(createdUser.Id)
+ }
+ server := setupTestServer(t, &svcmock, routing.NewRouteRegister(), tc.acmock, store, database.NewServiceAccountsStore(store))
+
+ var rawBody io.Reader = http.NoBody
+ if tc.body != nil {
+ body, err := json.Marshal(tc.body)
+ require.NoError(t, err)
+ rawBody = bytes.NewReader(body)
+ }
+
+ actual := requestResponse(server, httpmethod, fmt.Sprintf(endpoint, scopeID), rawBody)
+
+ actualCode := actual.Code
+ require.Equal(t, tc.expectedCode, actualCode)
+
+ if actualCode == http.StatusOK {
+ actualBody := map[string]interface{}{}
+ err := json.Unmarshal(actual.Body.Bytes(), &actualBody)
+ require.NoError(t, err)
+ assert.Equal(t, scopeID, int(actualBody["id"].(float64)))
+ assert.Equal(t, string(*tc.body.Role), actualBody["role"].(string))
+ assert.Equal(t, *tc.body.Name, actualBody["name"].(string))
+ assert.Equal(t, tc.user.Login, actualBody["login"].(string))
+
+ // Ensure the user was updated in DB
+ query := models.GetOrgUsersQuery{UserID: int64(scopeID), OrgId: 1, IsServiceAccount: true}
+ err = store.GetOrgUsers(context.Background(), &query)
+ require.NoError(t, err)
+ require.Equal(t, *tc.body.Name, query.Result[0].Name)
+ require.Equal(t, string(*tc.body.Role), query.Result[0].Role)
+ }
+ })
+ })
+ }
+}
diff --git a/pkg/services/serviceaccounts/api/token.go b/pkg/services/serviceaccounts/api/token.go
index bfeef7b4185..a66bcc226ff 100644
--- a/pkg/services/serviceaccounts/api/token.go
+++ b/pkg/services/serviceaccounts/api/token.go
@@ -16,6 +16,26 @@ import (
const failedToDeleteMsg = "Failed to delete API key"
+type TokenDTO struct {
+ Id int64 `json:"id"`
+ Name string `json:"name"`
+ Role models.RoleType `json:"role"`
+ Created *time.Time `json:"created"`
+ Expiration *time.Time `json:"expiration"`
+ SecondsUntilExpiration *float64 `json:"secondsUntilExpiration"`
+ HasExpired bool `json:"hasExpired"`
+}
+
+func hasExpired(expiration *int64) bool {
+ if expiration == nil {
+ return false
+ }
+ v := time.Unix(*expiration, 0)
+ return (v).Before(time.Now())
+}
+
+const sevenDaysAhead = 7 * 24 * time.Hour
+
func (api *ServiceAccountsAPI) ListTokens(ctx *models.ReqContext) response.Response {
saID, err := strconv.ParseInt(web.Params(ctx.Req)[":serviceAccountId"], 10, 64)
if err != nil {
@@ -23,24 +43,34 @@ func (api *ServiceAccountsAPI) ListTokens(ctx *models.ReqContext) response.Respo
}
if saTokens, err := api.store.ListTokens(ctx.Req.Context(), ctx.OrgId, saID); err == nil {
- result := make([]*models.ApiKeyDTO, len(saTokens))
+ result := make([]*TokenDTO, len(saTokens))
for i, t := range saTokens {
var expiration *time.Time = nil
+ var secondsUntilExpiration float64 = 0
+
+ isExpired := hasExpired(t.Expires)
if t.Expires != nil {
v := time.Unix(*t.Expires, 0)
expiration = &v
+ if !isExpired && (*expiration).Before(time.Now().Add(sevenDaysAhead)) {
+ secondsUntilExpiration = time.Until(*expiration).Seconds()
+ }
}
- result[i] = &models.ApiKeyDTO{
- Id: t.Id,
- Name: t.Name,
- Role: t.Role,
- Expiration: expiration,
+
+ result[i] = &TokenDTO{
+ Id: t.Id,
+ Name: t.Name,
+ Role: t.Role,
+ Created: &t.Created,
+ Expiration: expiration,
+ SecondsUntilExpiration: &secondsUntilExpiration,
+ HasExpired: isExpired,
}
}
- return response.JSON(200, result)
+ return response.JSON(http.StatusOK, result)
} else {
- return response.Error(500, "Internal server error", err)
+ return response.Error(http.StatusInternalServerError, "Internal server error", err)
}
}
@@ -71,33 +101,33 @@ func (api *ServiceAccountsAPI) CreateToken(c *models.ReqContext) response.Respon
cmd.OrgId = c.OrgId
if !cmd.Role.IsValid() {
- return response.Error(400, "Invalid role specified", nil)
+ return response.Error(http.StatusBadRequest, "Invalid role specified", nil)
}
if api.cfg.ApiKeyMaxSecondsToLive != -1 {
if cmd.SecondsToLive == 0 {
- return response.Error(400, "Number of seconds before expiration should be set", nil)
+ return response.Error(http.StatusBadRequest, "Number of seconds before expiration should be set", nil)
}
if cmd.SecondsToLive > api.cfg.ApiKeyMaxSecondsToLive {
- return response.Error(400, "Number of seconds before expiration is greater than the global limit", nil)
+ return response.Error(http.StatusBadRequest, "Number of seconds before expiration is greater than the global limit", nil)
}
}
newKeyInfo, err := apikeygen.New(cmd.OrgId, cmd.Name)
if err != nil {
- return response.Error(500, "Generating API key failed", err)
+ return response.Error(http.StatusInternalServerError, "Generating API key failed", err)
}
cmd.Key = newKeyInfo.HashedKey
if err := api.apiKeyStore.AddAPIKey(c.Req.Context(), &cmd); err != nil {
if errors.Is(err, models.ErrInvalidApiKeyExpiration) {
- return response.Error(400, err.Error(), nil)
+ return response.Error(http.StatusBadRequest, err.Error(), nil)
}
if errors.Is(err, models.ErrDuplicateApiKey) {
- return response.Error(409, err.Error(), nil)
+ return response.Error(http.StatusConflict, err.Error(), nil)
}
- return response.Error(500, "Failed to add API Key", err)
+ return response.Error(http.StatusInternalServerError, "Failed to add API Key", err)
}
result := &dtos.NewApiKeyResult{
@@ -106,7 +136,7 @@ func (api *ServiceAccountsAPI) CreateToken(c *models.ReqContext) response.Respon
Key: newKeyInfo.ClientSecret,
}
- return response.JSON(200, result)
+ return response.JSON(http.StatusOK, result)
}
// DeleteToken deletes service account tokens
@@ -134,9 +164,9 @@ func (api *ServiceAccountsAPI) DeleteToken(c *models.ReqContext) response.Respon
// confirm API key belongs to service account. TODO: refactor get & delete to single call
cmdGet := &models.GetApiKeyByIdQuery{ApiKeyId: tokenID}
if err = api.apiKeyStore.GetApiKeyById(c.Req.Context(), cmdGet); err != nil {
- status := 404
+ status := http.StatusNotFound
if err != nil && !errors.Is(err, models.ErrApiKeyNotFound) {
- status = 500
+ status = http.StatusInternalServerError
} else {
err = models.ErrApiKeyNotFound
}
@@ -146,14 +176,14 @@ func (api *ServiceAccountsAPI) DeleteToken(c *models.ReqContext) response.Respon
// verify service account ID matches the URL
if *cmdGet.Result.ServiceAccountId != saID {
- return response.Error(404, failedToDeleteMsg, err)
+ return response.Error(http.StatusNotFound, failedToDeleteMsg, err)
}
cmdDel := &models.DeleteApiKeyCommand{Id: tokenID, OrgId: c.OrgId}
if err = api.apiKeyStore.DeleteApiKey(c.Req.Context(), cmdDel); err != nil {
- status := 404
+ status := http.StatusNotFound
if err != nil && !errors.Is(err, models.ErrApiKeyNotFound) {
- status = 500
+ status = http.StatusInternalServerError
} else {
err = models.ErrApiKeyNotFound
}
diff --git a/pkg/services/serviceaccounts/api/token_test.go b/pkg/services/serviceaccounts/api/token_test.go
index b404b08549f..a90af8ec96f 100644
--- a/pkg/services/serviceaccounts/api/token_test.go
+++ b/pkg/services/serviceaccounts/api/token_test.go
@@ -9,6 +9,7 @@ import (
"net/http/httptest"
"strings"
"testing"
+ "time"
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/bus"
@@ -17,6 +18,7 @@ import (
"github.com/grafana/grafana/pkg/services/accesscontrol"
accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
"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/web"
@@ -29,7 +31,7 @@ const (
serviceaccountIDTokensDetailPath = "/api/serviceaccounts/%v/tokens/%v" // #nosec G101
)
-func createTokenforSA(t *testing.T, keyName string, orgID int64, saID int64) *models.ApiKey {
+func createTokenforSA(t *testing.T, keyName string, orgID int64, saID int64, secondsToLive int64) *models.ApiKey {
key, err := apikeygen.New(orgID, keyName)
require.NoError(t, err)
cmd := models.AddApiKeyCommand{
@@ -37,13 +39,12 @@ func createTokenforSA(t *testing.T, keyName string, orgID int64, saID int64) *mo
Role: "Viewer",
OrgId: orgID,
Key: key.HashedKey,
- SecondsToLive: 0,
+ SecondsToLive: secondsToLive,
ServiceAccountId: &saID,
Result: &models.ApiKey{},
}
err = bus.Dispatch(context.Background(), &cmd)
require.NoError(t, err)
-
return cmd.Result
}
@@ -129,7 +130,7 @@ func TestServiceAccountsAPI_CreateToken(t *testing.T) {
bodyString = string(b)
}
- server := setupTestServer(t, &svcmock, routing.NewRouteRegister(), tc.acmock, store)
+ server := setupTestServer(t, &svcmock, routing.NewRouteRegister(), tc.acmock, store, database.NewServiceAccountsStore(store))
actual := requestResponse(server, http.MethodPost, endpoint, strings.NewReader(bodyString))
actualCode := actual.Code
@@ -215,11 +216,11 @@ func TestServiceAccountsAPI_DeleteToken(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) {
- token := createTokenforSA(t, tc.keyName, sa.OrgId, sa.Id)
+ token := createTokenforSA(t, 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)
+ server := setupTestServer(t, &svcmock, routing.NewRouteRegister(), tc.acmock, store, database.NewServiceAccountsStore(store))
actual := requestResponse(server, http.MethodDelete, endpoint, strings.NewReader(bodyString))
actualCode := actual.Code
@@ -238,3 +239,122 @@ func TestServiceAccountsAPI_DeleteToken(t *testing.T) {
})
}
}
+
+type saStoreMockTokens struct {
+ serviceaccounts.Store
+ saAPIKeys []*models.ApiKey
+}
+
+func (s *saStoreMockTokens) ListTokens(ctx context.Context, orgID, saID int64) ([]*models.ApiKey, error) {
+ return s.saAPIKeys, nil
+}
+
+func TestServiceAccountsAPI_ListTokens(t *testing.T) {
+ store := sqlstore.InitTestDB(t)
+ svcmock := tests.ServiceAccountMock{}
+ sa := tests.SetupUserServiceAccount(t, store, tests.TestUser{Login: "sa", IsServiceAccount: true})
+
+ type testCreateSAToken struct {
+ desc string
+ tokens []*models.ApiKey
+ expectedHasExpired bool
+ expectedResponseBodyField string
+ expectedCode int
+ acmock *accesscontrolmock.Mock
+ }
+
+ var saId int64 = 1
+ var timeInFuture = time.Now().Add(time.Second * 100).Unix()
+ var timeInPast = time.Now().Add(-time.Second * 100).Unix()
+
+ testCases := []testCreateSAToken{
+ {
+ desc: "should be able to list serviceaccount with no expiration date",
+ tokens: []*models.ApiKey{{
+ Id: 1,
+ OrgId: 1,
+ ServiceAccountId: &saId,
+ Expires: nil,
+ Name: "Test1",
+ }},
+ acmock: tests.SetupMockAccesscontrol(
+ t,
+ func(c context.Context, siu *models.SignedInUser, _ accesscontrol.Options) ([]*accesscontrol.Permission, error) {
+ return []*accesscontrol.Permission{{Action: serviceaccounts.ActionRead, Scope: "serviceaccounts:id:1"}}, nil
+ },
+ false,
+ ),
+ expectedHasExpired: false,
+ expectedResponseBodyField: "hasExpired",
+ expectedCode: http.StatusOK,
+ },
+ {
+ desc: "should be able to list serviceaccount with secondsUntilExpiration",
+ tokens: []*models.ApiKey{{
+ Id: 1,
+ OrgId: 1,
+ ServiceAccountId: &saId,
+ Expires: &timeInFuture,
+ Name: "Test2",
+ }},
+ acmock: tests.SetupMockAccesscontrol(
+ t,
+ func(c context.Context, siu *models.SignedInUser, _ accesscontrol.Options) ([]*accesscontrol.Permission, error) {
+ return []*accesscontrol.Permission{{Action: serviceaccounts.ActionRead, Scope: "serviceaccounts:id:1"}}, nil
+ },
+ false,
+ ),
+ expectedHasExpired: false,
+ expectedResponseBodyField: "secondsUntilExpiration",
+ expectedCode: http.StatusOK,
+ },
+ {
+ desc: "should be able to list serviceaccount with expired token",
+ tokens: []*models.ApiKey{{
+ Id: 1,
+ OrgId: 1,
+ ServiceAccountId: &saId,
+ Expires: &timeInPast,
+ Name: "Test3",
+ }},
+ acmock: tests.SetupMockAccesscontrol(
+ t,
+ func(c context.Context, siu *models.SignedInUser, _ accesscontrol.Options) ([]*accesscontrol.Permission, error) {
+ return []*accesscontrol.Permission{{Action: serviceaccounts.ActionRead, Scope: "serviceaccounts:id:1"}}, nil
+ },
+ false,
+ ),
+ expectedHasExpired: true,
+ expectedResponseBodyField: "secondsUntilExpiration",
+ expectedCode: http.StatusOK,
+ },
+ }
+
+ var requestResponse = func(server *web.Mux, httpMethod, requestpath string, requestBody io.Reader) *httptest.ResponseRecorder {
+ req, err := http.NewRequest(httpMethod, requestpath, requestBody)
+ require.NoError(t, err)
+ req.Header.Add("Content-Type", "application/json")
+ recorder := httptest.NewRecorder()
+ server.ServeHTTP(recorder, req)
+ return recorder
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.desc, func(t *testing.T) {
+ endpoint := fmt.Sprintf(serviceaccountIDPath+"/tokens", sa.Id)
+ server := setupTestServer(t, &svcmock, routing.NewRouteRegister(), tc.acmock, store, &saStoreMockTokens{saAPIKeys: tc.tokens})
+ actual := requestResponse(server, http.MethodGet, endpoint, http.NoBody)
+
+ actualCode := actual.Code
+ actualBody := []map[string]interface{}{}
+
+ _ = json.Unmarshal(actual.Body.Bytes(), &actualBody)
+ require.Equal(t, tc.expectedCode, actualCode, endpoint, actualBody)
+
+ require.Equal(t, tc.expectedCode, actualCode)
+ require.Equal(t, tc.expectedHasExpired, actualBody[0]["hasExpired"])
+ _, exists := actualBody[0][tc.expectedResponseBodyField]
+ require.Equal(t, exists, true)
+ })
+ }
+}
diff --git a/pkg/services/serviceaccounts/database/database.go b/pkg/services/serviceaccounts/database/database.go
index e6f1191a584..593a23b03c3 100644
--- a/pkg/services/serviceaccounts/database/database.go
+++ b/pkg/services/serviceaccounts/database/database.go
@@ -25,7 +25,7 @@ func NewServiceAccountsStore(store *sqlstore.SQLStore) *ServiceAccountsStoreImpl
}
}
-func (s *ServiceAccountsStoreImpl) CreateServiceAccount(ctx context.Context, sa *serviceaccounts.CreateServiceaccountForm) (saDTO *serviceaccounts.ServiceAccountDTO, err error) {
+func (s *ServiceAccountsStoreImpl) CreateServiceAccount(ctx context.Context, sa *serviceaccounts.CreateServiceAccountForm) (saDTO *serviceaccounts.ServiceAccountDTO, err error) {
// create a new service account - "user" with empty permissions
generatedLogin := "Service-Account-" + uuid.New().String()
cmd := models.CreateUserCommand{
@@ -128,9 +128,9 @@ func (s *ServiceAccountsStoreImpl) ListTokens(ctx context.Context, orgID int64,
err := s.sqlStore.WithDbSession(ctx, func(dbSession *sqlstore.DBSession) error {
var sess *xorm.Session
- sess = dbSession.Limit(100, 0).
+ sess = dbSession.
Join("inner", "user", "user.id = api_key.service_account_id").
- Where("user.org_id=? AND user.id=? AND ( expires IS NULL or expires >= ?)", orgID, serviceAccountID, time.Now().Unix()).
+ Where("user.org_id=? AND user.id=?", orgID, serviceAccountID).
Asc("name")
return sess.Find(&result)
@@ -185,6 +185,64 @@ func (s *ServiceAccountsStoreImpl) RetrieveServiceAccount(ctx context.Context, o
return saProfile, err
}
+func (s *ServiceAccountsStoreImpl) UpdateServiceAccount(ctx context.Context,
+ orgID, serviceAccountID int64,
+ saForm *serviceaccounts.UpdateServiceAccountForm) (*serviceaccounts.ServiceAccountDTO, error) {
+ updatedUser := &models.OrgUserDTO{}
+
+ err := s.sqlStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error {
+ query := models.GetOrgUsersQuery{UserID: serviceAccountID, OrgId: orgID, IsServiceAccount: true}
+ if err := s.sqlStore.GetOrgUsers(ctx, &query); err != nil {
+ return err
+ }
+ if len(query.Result) != 1 {
+ return serviceaccounts.ErrServiceAccountNotFound
+ }
+
+ updatedUser = query.Result[0]
+
+ if saForm.Name == nil && saForm.Role == nil {
+ return nil
+ }
+
+ updateTime := time.Now()
+ if saForm.Role != nil {
+ var orgUser models.OrgUser
+ orgUser.Role = *saForm.Role
+ orgUser.Updated = updateTime
+
+ if _, err := sess.ID(orgUser.Id).Update(&orgUser); err != nil {
+ return err
+ }
+
+ updatedUser.Role = string(*saForm.Role)
+ }
+
+ if saForm.Name != nil {
+ user := models.User{
+ Name: *saForm.Name,
+ Updated: updateTime,
+ }
+
+ if _, err := sess.ID(serviceAccountID).Update(&user); err != nil {
+ return err
+ }
+
+ updatedUser.Name = *saForm.Name
+ }
+
+ return nil
+ })
+
+ return &serviceaccounts.ServiceAccountDTO{
+ Id: updatedUser.UserId,
+ Name: updatedUser.Name,
+ Login: updatedUser.Login,
+ Role: updatedUser.Role,
+ OrgId: updatedUser.OrgId,
+ }, err
+}
+
func contains(s []int64, e int64) bool {
for _, a := range s {
if a == e {
diff --git a/pkg/services/serviceaccounts/manager/service.go b/pkg/services/serviceaccounts/manager/service.go
index 02a8b8f0154..e74aaa5a02c 100644
--- a/pkg/services/serviceaccounts/manager/service.go
+++ b/pkg/services/serviceaccounts/manager/service.go
@@ -49,7 +49,7 @@ func ProvideServiceAccountsService(
return s, nil
}
-func (sa *ServiceAccountsService) CreateServiceAccount(ctx context.Context, saForm *serviceaccounts.CreateServiceaccountForm) (*serviceaccounts.ServiceAccountDTO, error) {
+func (sa *ServiceAccountsService) CreateServiceAccount(ctx context.Context, saForm *serviceaccounts.CreateServiceAccountForm) (*serviceaccounts.ServiceAccountDTO, error) {
if !sa.features.IsEnabled(featuremgmt.FlagServiceAccounts) {
sa.log.Debug(ServiceAccountFeatureToggleNotFound)
return nil, nil
diff --git a/pkg/services/serviceaccounts/models.go b/pkg/services/serviceaccounts/models.go
index d5577e2ecc7..81ad0337b4d 100644
--- a/pkg/services/serviceaccounts/models.go
+++ b/pkg/services/serviceaccounts/models.go
@@ -3,6 +3,7 @@ package serviceaccounts
import (
"time"
+ "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/accesscontrol"
)
@@ -22,7 +23,12 @@ type ServiceAccount struct {
Id int64
}
-type CreateServiceaccountForm struct {
+type UpdateServiceAccountForm struct {
+ Name *string `json:"name"`
+ Role *models.RoleType `json:"role"`
+}
+
+type CreateServiceAccountForm struct {
OrgID int64 `json:"-"`
Name string `json:"name" binding:"Required"`
}
diff --git a/pkg/services/serviceaccounts/serviceaccounts.go b/pkg/services/serviceaccounts/serviceaccounts.go
index 80b2d258e1f..3aa6d895169 100644
--- a/pkg/services/serviceaccounts/serviceaccounts.go
+++ b/pkg/services/serviceaccounts/serviceaccounts.go
@@ -8,13 +8,14 @@ import (
// this should reflect the api
type Service interface {
- CreateServiceAccount(ctx context.Context, saForm *CreateServiceaccountForm) (*ServiceAccountDTO, error)
+ CreateServiceAccount(ctx context.Context, saForm *CreateServiceAccountForm) (*ServiceAccountDTO, error)
DeleteServiceAccount(ctx context.Context, orgID, serviceAccountID int64) error
}
type Store interface {
- CreateServiceAccount(ctx context.Context, saForm *CreateServiceaccountForm) (*ServiceAccountDTO, error)
+ CreateServiceAccount(ctx context.Context, saForm *CreateServiceAccountForm) (*ServiceAccountDTO, error)
ListServiceAccounts(ctx context.Context, orgID, serviceAccountID int64) ([]*ServiceAccountDTO, error)
+ UpdateServiceAccount(ctx context.Context, orgID, serviceAccountID int64, saForm *UpdateServiceAccountForm) (*ServiceAccountDTO, error)
RetrieveServiceAccount(ctx context.Context, orgID, serviceAccountID int64) (*ServiceAccountProfileDTO, error)
DeleteServiceAccount(ctx context.Context, orgID, serviceAccountID int64) error
UpgradeServiceAccounts(ctx context.Context) error
diff --git a/pkg/services/serviceaccounts/tests/common.go b/pkg/services/serviceaccounts/tests/common.go
index 2262438e022..17e9f16d9b2 100644
--- a/pkg/services/serviceaccounts/tests/common.go
+++ b/pkg/services/serviceaccounts/tests/common.go
@@ -13,14 +13,23 @@ import (
)
type TestUser struct {
+ Name string
+ Role string
Login string
IsServiceAccount bool
}
func SetupUserServiceAccount(t *testing.T, sqlStore *sqlstore.SQLStore, testUser TestUser) *models.User {
+ role := string(models.ROLE_VIEWER)
+ if testUser.Role != "" {
+ role = testUser.Role
+ }
+
u1, err := sqlStore.CreateUser(context.Background(), models.CreateUserCommand{
Login: testUser.Login,
IsServiceAccount: testUser.IsServiceAccount,
+ DefaultOrgRole: role,
+ Name: testUser.Name,
})
require.NoError(t, err)
return u1
@@ -29,7 +38,7 @@ func SetupUserServiceAccount(t *testing.T, sqlStore *sqlstore.SQLStore, testUser
// create mock for serviceaccountservice
type ServiceAccountMock struct{}
-func (s *ServiceAccountMock) CreateServiceAccount(ctx context.Context, saForm *serviceaccounts.CreateServiceaccountForm) (*serviceaccounts.ServiceAccountDTO, error) {
+func (s *ServiceAccountMock) CreateServiceAccount(ctx context.Context, saForm *serviceaccounts.CreateServiceAccountForm) (*serviceaccounts.ServiceAccountDTO, error) {
return nil, nil
}
@@ -65,13 +74,14 @@ type Calls struct {
UpgradeServiceAccounts []interface{}
ConvertServiceAccounts []interface{}
ListTokens []interface{}
+ UpdateServiceAccount []interface{}
}
type ServiceAccountsStoreMock struct {
Calls Calls
}
-func (s *ServiceAccountsStoreMock) CreateServiceAccount(ctx context.Context, cmd *serviceaccounts.CreateServiceaccountForm) (*serviceaccounts.ServiceAccountDTO, error) {
+func (s *ServiceAccountsStoreMock) CreateServiceAccount(ctx context.Context, cmd *serviceaccounts.CreateServiceAccountForm) (*serviceaccounts.ServiceAccountDTO, error) {
// now we can test that the mock has these calls when we call the function
s.Calls.CreateServiceAccount = append(s.Calls.CreateServiceAccount, []interface{}{ctx, cmd})
return nil, nil
@@ -106,3 +116,11 @@ func (s *ServiceAccountsStoreMock) RetrieveServiceAccount(ctx context.Context, o
s.Calls.RetrieveServiceAccount = append(s.Calls.RetrieveServiceAccount, []interface{}{ctx, orgID, serviceAccountID})
return nil, nil
}
+
+func (s *ServiceAccountsStoreMock) UpdateServiceAccount(ctx context.Context,
+ orgID, serviceAccountID int64,
+ saForm *serviceaccounts.UpdateServiceAccountForm) (*serviceaccounts.ServiceAccountDTO, error) {
+ s.Calls.UpdateServiceAccount = append(s.Calls.UpdateServiceAccount, []interface{}{ctx, orgID, serviceAccountID, saForm})
+
+ return nil, nil
+}
diff --git a/pkg/services/sqlstore/alert_notification.go b/pkg/services/sqlstore/alert_notification.go
index b7d536d765e..0d6bd261415 100644
--- a/pkg/services/sqlstore/alert_notification.go
+++ b/pkg/services/sqlstore/alert_notification.go
@@ -513,7 +513,7 @@ func (ss *SQLStore) SetAlertNotificationStateToCompleteCommand(ctx context.Conte
}
func (ss *SQLStore) SetAlertNotificationStateToPendingCommand(ctx context.Context, cmd *models.SetAlertNotificationStateToPendingCommand) error {
- return withDbSession(ctx, x, func(sess *DBSession) error {
+ return ss.WithDbSession(ctx, func(sess *DBSession) error {
newVersion := cmd.Version + 1
sql := `UPDATE alert_notification_state SET
state = ?,
diff --git a/pkg/services/sqlstore/dashboard.go b/pkg/services/sqlstore/dashboard.go
index d7a07a72eec..6241d347274 100644
--- a/pkg/services/sqlstore/dashboard.go
+++ b/pkg/services/sqlstore/dashboard.go
@@ -4,18 +4,14 @@ import (
"context"
"fmt"
"strings"
- "time"
-
- "github.com/prometheus/client_golang/prometheus"
-
- "github.com/grafana/grafana/pkg/services/sqlstore/permissions"
- "github.com/grafana/grafana/pkg/services/sqlstore/searchstore"
"github.com/grafana/grafana/pkg/bus"
- "github.com/grafana/grafana/pkg/infra/metrics"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/search"
+ "github.com/grafana/grafana/pkg/services/sqlstore/permissions"
+ "github.com/grafana/grafana/pkg/services/sqlstore/searchstore"
"github.com/grafana/grafana/pkg/util"
+ "github.com/prometheus/client_golang/prometheus"
)
var shadowSearchCounter = prometheus.NewCounterVec(
@@ -27,11 +23,6 @@ var shadowSearchCounter = prometheus.NewCounterVec(
)
func init() {
- bus.AddHandler("sql", GetDashboardSlugById)
- bus.AddHandler("sql", GetDashboardsByPluginId)
- bus.AddHandler("sql", GetDashboardPermissionsForUser)
- bus.AddHandler("sql", HasAdminPermissionInFolders)
-
prometheus.MustRegister(shadowSearchCounter)
}
@@ -43,172 +34,16 @@ func (ss *SQLStore) addDashboardQueryAndCommandHandlers() {
bus.AddHandler("sql", ss.DeleteDashboard)
bus.AddHandler("sql", ss.GetDashboards)
bus.AddHandler("sql", ss.HasEditPermissionInFolders)
+ bus.AddHandler("sql", ss.GetDashboardPermissionsForUser)
+ bus.AddHandler("sql", ss.GetDashboardsByPluginId)
+ bus.AddHandler("sql", ss.GetDashboardSlugById)
+ bus.AddHandler("sql", ss.HasAdminPermissionInFolders)
}
var generateNewUid func() string = util.GenerateShortUID
-func (ss *SQLStore) SaveDashboard(cmd models.SaveDashboardCommand) (*models.Dashboard, error) {
- err := ss.WithTransactionalDbSession(context.Background(), func(sess *DBSession) error {
- return saveDashboard(sess, &cmd)
- })
- return cmd.Result, err
-}
-
-func saveDashboard(sess *DBSession, cmd *models.SaveDashboardCommand) error {
- dash := cmd.GetDashboardModel()
-
- userId := cmd.UserId
-
- if userId == 0 {
- userId = -1
- }
-
- if dash.Id > 0 {
- var existing models.Dashboard
- dashWithIdExists, err := sess.Where("id=? AND org_id=?", dash.Id, dash.OrgId).Get(&existing)
- if err != nil {
- return err
- }
- if !dashWithIdExists {
- return models.ErrDashboardNotFound
- }
-
- // check for is someone else has written in between
- if dash.Version != existing.Version {
- if cmd.Overwrite {
- dash.SetVersion(existing.Version)
- } else {
- return models.ErrDashboardVersionMismatch
- }
- }
-
- // do not allow plugin dashboard updates without overwrite flag
- if existing.PluginId != "" && !cmd.Overwrite {
- return models.UpdatePluginDashboardError{PluginId: existing.PluginId}
- }
- }
-
- if dash.Uid == "" {
- uid, err := generateNewDashboardUid(sess, dash.OrgId)
- if err != nil {
- return err
- }
- dash.SetUid(uid)
- }
-
- parentVersion := dash.Version
- var affectedRows int64
- var err error
-
- if dash.Id == 0 {
- dash.SetVersion(1)
- dash.Created = time.Now()
- dash.CreatedBy = userId
- dash.Updated = time.Now()
- dash.UpdatedBy = userId
- metrics.MApiDashboardInsert.Inc()
- affectedRows, err = sess.Insert(dash)
- } else {
- dash.SetVersion(dash.Version + 1)
-
- if !cmd.UpdatedAt.IsZero() {
- dash.Updated = cmd.UpdatedAt
- } else {
- dash.Updated = time.Now()
- }
-
- dash.UpdatedBy = userId
-
- affectedRows, err = sess.MustCols("folder_id").ID(dash.Id).Update(dash)
- }
-
- if err != nil {
- return err
- }
-
- if affectedRows == 0 {
- return models.ErrDashboardNotFound
- }
-
- dashVersion := &models.DashboardVersion{
- DashboardId: dash.Id,
- ParentVersion: parentVersion,
- RestoredFrom: cmd.RestoredFrom,
- Version: dash.Version,
- Created: time.Now(),
- CreatedBy: dash.UpdatedBy,
- Message: cmd.Message,
- Data: dash.Data,
- }
-
- // insert version entry
- if affectedRows, err = sess.Insert(dashVersion); err != nil {
- return err
- } else if affectedRows == 0 {
- return models.ErrDashboardNotFound
- }
-
- // delete existing tags
- _, err = sess.Exec("DELETE FROM dashboard_tag WHERE dashboard_id=?", dash.Id)
- if err != nil {
- return err
- }
-
- // insert new tags
- tags := dash.GetTags()
- if len(tags) > 0 {
- for _, tag := range tags {
- if _, err := sess.Insert(&DashboardTag{DashboardId: dash.Id, Term: tag}); err != nil {
- return err
- }
- }
- }
-
- cmd.Result = dash
-
- return nil
-}
-
-func generateNewDashboardUid(sess *DBSession, orgId int64) (string, error) {
- for i := 0; i < 3; i++ {
- uid := generateNewUid()
-
- exists, err := sess.Where("org_id=? AND uid=?", orgId, uid).Get(&models.Dashboard{})
- if err != nil {
- return "", err
- }
-
- if !exists {
- return uid, nil
- }
- }
-
- return "", models.ErrDashboardFailedGenerateUniqueUid
-}
-
-// GetDashboardByTitle gets a dashboard by its title.
-func (ss *SQLStore) GetFolderByTitle(orgID int64, title string) (*models.Dashboard, error) {
- if title == "" {
- return nil, models.ErrDashboardIdentifierNotSet
- }
-
- // there is a unique constraint on org_id, folder_id, title
- // there are no nested folders so the parent folder id is always 0
- dashboard := models.Dashboard{OrgId: orgID, FolderId: 0, Title: title}
- has, err := ss.engine.Table(&models.Dashboard{}).Where("is_folder = " + dialect.BooleanStr(true)).Where("folder_id=0").Get(&dashboard)
- if err != nil {
- return nil, err
- }
- if !has {
- return nil, models.ErrDashboardNotFound
- }
- dashboard.SetId(dashboard.Id)
- dashboard.SetUid(dashboard.Uid)
- return &dashboard, nil
-}
-
func (ss *SQLStore) GetDashboard(ctx context.Context, query *models.GetDashboardQuery) error {
- return withDbSession(ctx, x, func(dbSession *DBSession) error {
+ return ss.WithDbSession(ctx, func(dbSession *DBSession) error {
if query.Id == 0 && len(query.Slug) == 0 && len(query.Uid) == 0 {
return models.ErrDashboardIdentifierNotSet
}
@@ -243,7 +78,7 @@ type DashboardSearchProjection struct {
SortMeta int64
}
-func (ss *SQLStore) findDashboards(ctx context.Context, query *search.FindPersistedDashboardsQuery) ([]DashboardSearchProjection, error) {
+func (ss *SQLStore) FindDashboards(ctx context.Context, query *search.FindPersistedDashboardsQuery) ([]DashboardSearchProjection, error) {
filters := []interface{}{
permissions.DashboardPermissionFilter{
OrgRole: query.SignedInUser.OrgRole,
@@ -315,7 +150,7 @@ func (ss *SQLStore) findDashboards(ctx context.Context, query *search.FindPersis
}
func (ss *SQLStore) SearchDashboards(ctx context.Context, query *search.FindPersistedDashboardsQuery) error {
- res, err := ss.findDashboards(ctx, query)
+ res, err := ss.FindDashboards(ctx, query)
if err != nil {
return err
}
@@ -375,7 +210,8 @@ func makeQueryResult(query *search.FindPersistedDashboardsQuery, res []Dashboard
}
func (ss *SQLStore) GetDashboardTags(ctx context.Context, query *models.GetDashboardTagsQuery) error {
- sql := `SELECT
+ return ss.WithDbSession(ctx, func(dbSession *DBSession) error {
+ sql := `SELECT
COUNT(*) as count,
term
FROM dashboard
@@ -384,10 +220,11 @@ func (ss *SQLStore) GetDashboardTags(ctx context.Context, query *models.GetDashb
GROUP BY term
ORDER BY term`
- query.Result = make([]*models.DashboardTagCloudItem, 0)
- sess := x.SQL(sql, query.OrgId)
- err := sess.Find(&query.Result)
- return err
+ query.Result = make([]*models.DashboardTagCloudItem, 0)
+ sess := dbSession.SQL(sql, query.OrgId)
+ err := sess.Find(&query.Result)
+ return err
+ })
}
func (ss *SQLStore) DeleteDashboard(ctx context.Context, cmd *models.DeleteDashboardCommand) error {
@@ -490,65 +327,68 @@ func deleteDashboard(cmd *models.DeleteDashboardCommand, sess *DBSession) error
}
func (ss *SQLStore) GetDashboards(ctx context.Context, query *models.GetDashboardsQuery) error {
- if len(query.DashboardIds) == 0 {
- return models.ErrCommandValidationFailed
- }
+ return ss.WithDbSession(ctx, func(dbSession *DBSession) error {
+ if len(query.DashboardIds) == 0 {
+ return models.ErrCommandValidationFailed
+ }
- var dashboards = make([]*models.Dashboard, 0)
+ var dashboards = make([]*models.Dashboard, 0)
- err := x.In("id", query.DashboardIds).Find(&dashboards)
- query.Result = dashboards
- return err
+ err := dbSession.In("id", query.DashboardIds).Find(&dashboards)
+ query.Result = dashboards
+ return err
+ })
}
// GetDashboardPermissionsForUser returns the maximum permission the specified user has for a dashboard(s)
// The function takes in a list of dashboard ids and the user id and role
-func GetDashboardPermissionsForUser(ctx context.Context, query *models.GetDashboardPermissionsForUserQuery) error {
- if len(query.DashboardIds) == 0 {
- return models.ErrCommandValidationFailed
- }
-
- if query.OrgRole == models.ROLE_ADMIN {
- var permissions = make([]*models.DashboardPermissionForUser, 0)
- for _, d := range query.DashboardIds {
- permissions = append(permissions, &models.DashboardPermissionForUser{
- DashboardId: d,
- Permission: models.PERMISSION_ADMIN,
- PermissionName: models.PERMISSION_ADMIN.String(),
- })
+func (ss *SQLStore) GetDashboardPermissionsForUser(ctx context.Context, query *models.GetDashboardPermissionsForUserQuery) error {
+ return ss.WithDbSession(ctx, func(dbSession *DBSession) error {
+ if len(query.DashboardIds) == 0 {
+ return models.ErrCommandValidationFailed
}
- query.Result = permissions
- return nil
- }
+ if query.OrgRole == models.ROLE_ADMIN {
+ var permissions = make([]*models.DashboardPermissionForUser, 0)
+ for _, d := range query.DashboardIds {
+ permissions = append(permissions, &models.DashboardPermissionForUser{
+ DashboardId: d,
+ Permission: models.PERMISSION_ADMIN,
+ PermissionName: models.PERMISSION_ADMIN.String(),
+ })
+ }
+ query.Result = permissions
- params := make([]interface{}, 0)
+ return nil
+ }
- // check dashboards that have ACLs via user id, team id or role
- sql := `SELECT d.id AS dashboard_id, MAX(COALESCE(da.permission, pt.permission)) AS permission
+ params := make([]interface{}, 0)
+
+ // check dashboards that have ACLs via user id, team id or role
+ sql := `SELECT d.id AS dashboard_id, MAX(COALESCE(da.permission, pt.permission)) AS permission
FROM dashboard AS d
LEFT JOIN dashboard_acl as da on d.folder_id = da.dashboard_id or d.id = da.dashboard_id
LEFT JOIN team_member as ugm on ugm.team_id = da.team_id
LEFT JOIN org_user ou ON ou.role = da.role AND ou.user_id = ?
`
- params = append(params, query.UserId)
+ params = append(params, query.UserId)
- // check the user's role for dashboards that do not have hasAcl set
- sql += `LEFT JOIN org_user ouRole ON ouRole.user_id = ? AND ouRole.org_id = ?`
- params = append(params, query.UserId)
- params = append(params, query.OrgId)
+ // check the user's role for dashboards that do not have hasAcl set
+ sql += `LEFT JOIN org_user ouRole ON ouRole.user_id = ? AND ouRole.org_id = ?`
+ params = append(params, query.UserId)
+ params = append(params, query.OrgId)
- sql += `
+ sql += `
LEFT JOIN (SELECT 1 AS permission, 'Viewer' AS role
UNION SELECT 2 AS permission, 'Editor' AS role
UNION SELECT 4 AS permission, 'Admin' AS role) pt ON ouRole.role = pt.role
WHERE
d.Id IN (?` + strings.Repeat(",?", len(query.DashboardIds)-1) + `) `
- for _, id := range query.DashboardIds {
- params = append(params, id)
- }
+ for _, id := range query.DashboardIds {
+ params = append(params, id)
+ }
- sql += ` AND
+ sql += ` AND
d.org_id = ? AND
(
(d.has_acl = ? AND (da.user_id = ? OR ugm.user_id = ? OR ou.id IS NOT NULL))
@@ -556,48 +396,53 @@ func GetDashboardPermissionsForUser(ctx context.Context, query *models.GetDashbo
)
group by d.id
order by d.id asc`
- params = append(params, query.OrgId)
- params = append(params, dialect.BooleanStr(true))
- params = append(params, query.UserId)
- params = append(params, query.UserId)
- params = append(params, dialect.BooleanStr(false))
+ params = append(params, query.OrgId)
+ params = append(params, dialect.BooleanStr(true))
+ params = append(params, query.UserId)
+ params = append(params, query.UserId)
+ params = append(params, dialect.BooleanStr(false))
- err := x.SQL(sql, params...).Find(&query.Result)
+ err := dbSession.SQL(sql, params...).Find(&query.Result)
- for _, p := range query.Result {
- p.PermissionName = p.Permission.String()
- }
+ for _, p := range query.Result {
+ p.PermissionName = p.Permission.String()
+ }
- return err
+ return err
+ })
}
-func GetDashboardsByPluginId(ctx context.Context, query *models.GetDashboardsByPluginIdQuery) error {
- var dashboards = make([]*models.Dashboard, 0)
- whereExpr := "org_id=? AND plugin_id=? AND is_folder=" + dialect.BooleanStr(false)
+func (ss *SQLStore) GetDashboardsByPluginId(ctx context.Context, query *models.GetDashboardsByPluginIdQuery) error {
+ return ss.WithDbSession(ctx, func(dbSession *DBSession) error {
+ var dashboards = make([]*models.Dashboard, 0)
+ whereExpr := "org_id=? AND plugin_id=? AND is_folder=" + dialect.BooleanStr(false)
- err := x.Where(whereExpr, query.OrgId, query.PluginId).Find(&dashboards)
- query.Result = dashboards
- return err
+ err := dbSession.Where(whereExpr, query.OrgId, query.PluginId).Find(&dashboards)
+ query.Result = dashboards
+ return err
+ })
}
type DashboardSlugDTO struct {
Slug string
}
-func GetDashboardSlugById(ctx context.Context, query *models.GetDashboardSlugByIdQuery) error {
- var rawSQL = `SELECT slug from dashboard WHERE Id=?`
- var slug = DashboardSlugDTO{}
+func (ss *SQLStore) GetDashboardSlugById(ctx context.Context, query *models.GetDashboardSlugByIdQuery) error {
+ return ss.WithDbSession(ctx, func(dbSession *DBSession) error {
+ var rawSQL = `SELECT slug from dashboard WHERE Id=?`
+ var slug = DashboardSlugDTO{}
- exists, err := x.SQL(rawSQL, query.Id).Get(&slug)
+ exists, err := dbSession.SQL(rawSQL, query.Id).Get(&slug)
- if err != nil {
- return err
- } else if !exists {
- return models.ErrDashboardNotFound
- }
+ if err != nil {
+ return err
+ } else if !exists {
+ return models.ErrDashboardNotFound
+ }
- query.Result = slug.Slug
- return nil
+ query.Result = slug.Slug
+ return nil
+ })
}
func (ss *SQLStore) GetDashboardUIDById(ctx context.Context, query *models.GetDashboardRefByIdQuery) error {
@@ -619,158 +464,9 @@ func (ss *SQLStore) GetDashboardUIDById(ctx context.Context, query *models.GetDa
})
}
-func getExistingDashboardByIdOrUidForUpdate(sess *DBSession, dash *models.Dashboard, overwrite bool) (bool, error) {
- dashWithIdExists := false
- isParentFolderChanged := false
- var existingById models.Dashboard
-
- if dash.Id > 0 {
- var err error
- dashWithIdExists, err = sess.Where("id=? AND org_id=?", dash.Id, dash.OrgId).Get(&existingById)
- if err != nil {
- return isParentFolderChanged, fmt.Errorf("SQL query for existing dashboard by ID failed: %w", err)
- }
-
- if !dashWithIdExists {
- return isParentFolderChanged, models.ErrDashboardNotFound
- }
-
- if dash.Uid == "" {
- dash.SetUid(existingById.Uid)
- }
- }
-
- dashWithUidExists := false
- var existingByUid models.Dashboard
-
- if dash.Uid != "" {
- var err error
- dashWithUidExists, err = sess.Where("org_id=? AND uid=?", dash.OrgId, dash.Uid).Get(&existingByUid)
- if err != nil {
- return isParentFolderChanged, fmt.Errorf("SQL query for existing dashboard by UID failed: %w", err)
- }
- }
-
- if dash.FolderId > 0 {
- var existingFolder models.Dashboard
- folderExists, err := sess.Where("org_id=? AND id=? AND is_folder=?", dash.OrgId, dash.FolderId,
- dialect.BooleanStr(true)).Get(&existingFolder)
- if err != nil {
- return isParentFolderChanged, fmt.Errorf("SQL query for folder failed: %w", err)
- }
-
- if !folderExists {
- return isParentFolderChanged, models.ErrDashboardFolderNotFound
- }
- }
-
- if !dashWithIdExists && !dashWithUidExists {
- return isParentFolderChanged, nil
- }
-
- if dashWithIdExists && dashWithUidExists && existingById.Id != existingByUid.Id {
- return isParentFolderChanged, models.ErrDashboardWithSameUIDExists
- }
-
- existing := existingById
-
- if !dashWithIdExists && dashWithUidExists {
- dash.SetId(existingByUid.Id)
- dash.SetUid(existingByUid.Uid)
- existing = existingByUid
-
- if !dash.IsFolder {
- isParentFolderChanged = true
- }
- }
-
- if (existing.IsFolder && !dash.IsFolder) ||
- (!existing.IsFolder && dash.IsFolder) {
- return isParentFolderChanged, models.ErrDashboardTypeMismatch
- }
-
- if !dash.IsFolder && dash.FolderId != existing.FolderId {
- isParentFolderChanged = true
- }
-
- // check for is someone else has written in between
- if dash.Version != existing.Version {
- if overwrite {
- dash.SetVersion(existing.Version)
- } else {
- return isParentFolderChanged, models.ErrDashboardVersionMismatch
- }
- }
-
- // do not allow plugin dashboard updates without overwrite flag
- if existing.PluginId != "" && !overwrite {
- return isParentFolderChanged, models.UpdatePluginDashboardError{PluginId: existing.PluginId}
- }
-
- return isParentFolderChanged, nil
-}
-
-func getExistingDashboardByTitleAndFolder(sess *DBSession, dash *models.Dashboard, overwrite,
- isParentFolderChanged bool) (bool, error) {
- var existing models.Dashboard
- exists, err := sess.Where("org_id=? AND slug=? AND (is_folder=? OR folder_id=?)", dash.OrgId, dash.Slug,
- dialect.BooleanStr(true), dash.FolderId).Get(&existing)
- if err != nil {
- return isParentFolderChanged, fmt.Errorf("SQL query for existing dashboard by org ID or folder ID failed: %w", err)
- }
-
- if exists && dash.Id != existing.Id {
- if existing.IsFolder && !dash.IsFolder {
- return isParentFolderChanged, models.ErrDashboardWithSameNameAsFolder
- }
-
- if !existing.IsFolder && dash.IsFolder {
- return isParentFolderChanged, models.ErrDashboardFolderWithSameNameAsDashboard
- }
-
- if !dash.IsFolder && (dash.FolderId != existing.FolderId || dash.Id == 0) {
- isParentFolderChanged = true
- }
-
- if overwrite {
- dash.SetId(existing.Id)
- dash.SetUid(existing.Uid)
- dash.SetVersion(existing.Version)
- } else {
- return isParentFolderChanged, models.ErrDashboardWithSameNameInFolderExists
- }
- }
-
- return isParentFolderChanged, nil
-}
-
-func (ss *SQLStore) ValidateDashboardBeforeSave(dashboard *models.Dashboard, overwrite bool) (bool, error) {
- isParentFolderChanged := false
- err := ss.WithTransactionalDbSession(context.Background(), func(sess *DBSession) error {
- var err error
- isParentFolderChanged, err = getExistingDashboardByIdOrUidForUpdate(sess, dashboard, overwrite)
- if err != nil {
- return err
- }
-
- isParentFolderChanged, err = getExistingDashboardByTitleAndFolder(sess, dashboard, overwrite,
- isParentFolderChanged)
- if err != nil {
- return err
- }
-
- return nil
- })
- if err != nil {
- return false, err
- }
-
- return isParentFolderChanged, nil
-}
-
// HasEditPermissionInFolders validates that an user have access to a certain folder
func (ss *SQLStore) HasEditPermissionInFolders(ctx context.Context, query *models.HasEditPermissionInFoldersQuery) error {
- return withDbSession(ctx, x, func(dbSession *DBSession) error {
+ return ss.WithDbSession(ctx, func(dbSession *DBSession) error {
if query.SignedInUser.HasRole(models.ROLE_EDITOR) {
query.Result = true
return nil
@@ -796,26 +492,28 @@ func (ss *SQLStore) HasEditPermissionInFolders(ctx context.Context, query *model
})
}
-func HasAdminPermissionInFolders(ctx context.Context, query *models.HasAdminPermissionInFoldersQuery) error {
- if query.SignedInUser.HasRole(models.ROLE_ADMIN) {
- query.Result = true
+func (ss *SQLStore) HasAdminPermissionInFolders(ctx context.Context, query *models.HasAdminPermissionInFoldersQuery) error {
+ return ss.WithDbSession(ctx, func(dbSession *DBSession) error {
+ if query.SignedInUser.HasRole(models.ROLE_ADMIN) {
+ query.Result = true
+ return nil
+ }
+
+ builder := &SQLBuilder{}
+ builder.Write("SELECT COUNT(dashboard.id) AS count FROM dashboard WHERE dashboard.org_id = ? AND dashboard.is_folder = ?", query.SignedInUser.OrgId, dialect.BooleanStr(true))
+ builder.WriteDashboardPermissionFilter(query.SignedInUser, models.PERMISSION_ADMIN)
+
+ type folderCount struct {
+ Count int64
+ }
+
+ resp := make([]*folderCount, 0)
+ if err := dbSession.SQL(builder.GetSQLString(), builder.params...).Find(&resp); err != nil {
+ return err
+ }
+
+ query.Result = len(resp) > 0 && resp[0].Count > 0
+
return nil
- }
-
- builder := &SQLBuilder{}
- builder.Write("SELECT COUNT(dashboard.id) AS count FROM dashboard WHERE dashboard.org_id = ? AND dashboard.is_folder = ?", query.SignedInUser.OrgId, dialect.BooleanStr(true))
- builder.WriteDashboardPermissionFilter(query.SignedInUser, models.PERMISSION_ADMIN)
-
- type folderCount struct {
- Count int64
- }
-
- resp := make([]*folderCount, 0)
- if err := x.SQL(builder.GetSQLString(), builder.params...).Find(&resp); err != nil {
- return err
- }
-
- query.Result = len(resp) > 0 && resp[0].Count > 0
-
- return nil
+ })
}
diff --git a/pkg/services/sqlstore/dashboard_acl.go b/pkg/services/sqlstore/dashboard_acl.go
index 15a032cdecb..bcc04b2a17a 100644
--- a/pkg/services/sqlstore/dashboard_acl.go
+++ b/pkg/services/sqlstore/dashboard_acl.go
@@ -2,7 +2,6 @@ package sqlstore
import (
"context"
- "fmt"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/models"
@@ -12,40 +11,6 @@ func (ss *SQLStore) addDashboardACLQueryAndCommandHandlers() {
bus.AddHandler("sql", ss.GetDashboardAclInfoList)
}
-func (ss *SQLStore) UpdateDashboardACL(ctx context.Context, dashboardID int64, items []*models.DashboardAcl) error {
- return ss.UpdateDashboardACLCtx(ctx, dashboardID, items)
-}
-
-func (ss *SQLStore) UpdateDashboardACLCtx(ctx context.Context, dashboardID int64, items []*models.DashboardAcl) error {
- return ss.WithTransactionalDbSession(ctx, func(sess *DBSession) error {
- // delete existing items
- _, 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 {
- 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
- })
-}
-
// GetDashboardAclInfoList returns a list of permissions for a dashboard. They can be fetched from three
// different places.
// 1) Permissions for the dashboard
diff --git a/pkg/services/sqlstore/dashboard_provisioning.go b/pkg/services/sqlstore/dashboard_provisioning.go
index 053f5ad74c8..cc46473caa3 100644
--- a/pkg/services/sqlstore/dashboard_provisioning.go
+++ b/pkg/services/sqlstore/dashboard_provisioning.go
@@ -9,7 +9,6 @@ import (
)
func (ss *SQLStore) addDashboardProvisioningQueryAndCommandHandlers() {
- bus.AddHandler("sql", UnprovisionDashboard)
bus.AddHandler("sql", ss.DeleteOrphanedProvisionedDashboards)
}
@@ -20,97 +19,6 @@ type DashboardExtras struct {
Value string
}
-func (ss *SQLStore) GetProvisionedDataByDashboardID(dashboardID int64) (*models.DashboardProvisioning, error) {
- var data models.DashboardProvisioning
- exists, err := x.Where("dashboard_id = ?", dashboardID).Get(&data)
- if err != nil {
- return nil, err
- }
- if exists {
- return &data, nil
- }
- return nil, nil
-}
-
-func (ss *SQLStore) GetProvisionedDataByDashboardUID(orgID int64, dashboardUID string) (*models.DashboardProvisioning, error) {
- var provisionedDashboard models.DashboardProvisioning
- err := ss.WithTransactionalDbSession(context.Background(), func(sess *DBSession) error {
- var dashboard models.Dashboard
- exists, err := sess.Where("org_id = ? AND uid = ?", orgID, dashboardUID).Get(&dashboard)
- if err != nil {
- return err
- }
- if !exists {
- return models.
- ErrDashboardNotFound
- }
- exists, err = sess.Where("dashboard_id = ?", dashboard.Id).Get(&provisionedDashboard)
- if err != nil {
- return err
- }
- if !exists {
- return models.ErrProvisionedDashboardNotFound
- }
- return nil
- })
- return &provisionedDashboard, err
-}
-
-func (ss *SQLStore) SaveProvisionedDashboard(cmd models.SaveDashboardCommand,
- provisioning *models.DashboardProvisioning) (*models.Dashboard, error) {
- err := ss.WithTransactionalDbSession(context.Background(), func(sess *DBSession) error {
- if err := saveDashboard(sess, &cmd); err != nil {
- return err
- }
-
- if provisioning.Updated == 0 {
- provisioning.Updated = cmd.Result.Updated.Unix()
- }
-
- return saveProvisionedData(sess, provisioning, cmd.Result)
- })
-
- return cmd.Result, err
-}
-
-func saveProvisionedData(sess *DBSession, provisioning *models.DashboardProvisioning, dashboard *models.Dashboard) error {
- result := &models.DashboardProvisioning{}
-
- exist, err := sess.Where("dashboard_id=? AND name = ?", dashboard.Id, provisioning.Name).Get(result)
- if err != nil {
- return err
- }
-
- provisioning.Id = result.Id
- provisioning.DashboardId = dashboard.Id
-
- if exist {
- _, err = sess.ID(result.Id).Update(provisioning)
- } else {
- _, err = sess.Insert(provisioning)
- }
-
- return err
-}
-
-func (ss *SQLStore) GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error) {
- var result []*models.DashboardProvisioning
- if err := ss.engine.Where("name = ?", name).Find(&result); err != nil {
- return nil, err
- }
-
- return result, nil
-}
-
-// UnprovisionDashboard removes row in dashboard_provisioning for the dashboard making it seem as if manually created.
-// The dashboard will still have `created_by = -1` to see it was not created by any particular user.
-func UnprovisionDashboard(ctx context.Context, cmd *models.UnprovisionDashboardCommand) error {
- if _, err := x.Where("dashboard_id = ?", cmd.Id).Delete(&models.DashboardProvisioning{}); err != nil {
- return err
- }
- return nil
-}
-
func (ss *SQLStore) DeleteOrphanedProvisionedDashboards(ctx context.Context, cmd *models.DeleteOrphanedProvisionedDashboardsCommand) error {
var result []*models.DashboardProvisioning
diff --git a/pkg/services/sqlstore/dashboard_thumbs.go b/pkg/services/sqlstore/dashboard_thumbs.go
index 6b819ca46a2..907d7928991 100644
--- a/pkg/services/sqlstore/dashboard_thumbs.go
+++ b/pkg/services/sqlstore/dashboard_thumbs.go
@@ -81,6 +81,20 @@ func (ss *SQLStore) UpdateThumbnailState(ctx context.Context, cmd *models.Update
return err
}
+func (ss *SQLStore) FindThumbnailCount(ctx context.Context, cmd *models.FindDashboardThumbnailCountCommand) (int64, error) {
+ err := ss.WithDbSession(ctx, func(sess *DBSession) error {
+ count, err := sess.Count(&models.DashboardThumbnail{})
+ if err != nil {
+ return err
+ }
+
+ cmd.Result = count
+ return nil
+ })
+
+ return cmd.Result, err
+}
+
func (ss *SQLStore) FindDashboardsWithStaleThumbnails(ctx context.Context, cmd *models.FindDashboardsWithStaleThumbnailsCommand) ([]*models.DashboardWithStaleThumbnail, error) {
err := ss.WithDbSession(ctx, func(sess *DBSession) error {
sess.Table("dashboard")
diff --git a/pkg/services/sqlstore/dashboard_thumbs_test.go b/pkg/services/sqlstore/dashboard_thumbs_test.go
index 5cba1949f64..4fc9395cd57 100644
--- a/pkg/services/sqlstore/dashboard_thumbs_test.go
+++ b/pkg/services/sqlstore/dashboard_thumbs_test.go
@@ -185,6 +185,23 @@ func TestSqlStorage(t *testing.T) {
require.Len(t, res, 1)
require.Equal(t, dash.Id, res[0].Id)
})
+
+ t.Run("Should count all dashboard thumbnails", func(t *testing.T) {
+ setup()
+ dash := insertTestDashboard(t, sqlStore, "test dash 23", 1, savedFolder.Id, false, "prod", "webapp")
+ upsertTestDashboardThumbnail(t, sqlStore, dash.Uid, dash.OrgId, 1)
+ dash2 := insertTestDashboard(t, sqlStore, "test dash 23", 2, savedFolder.Id, false, "prod", "webapp")
+ upsertTestDashboardThumbnail(t, sqlStore, dash2.Uid, dash2.OrgId, 1)
+
+ updateTestDashboard(t, sqlStore, dash, map[string]interface{}{
+ "tags": "different-tag",
+ })
+
+ cmd := models.FindDashboardThumbnailCountCommand{}
+ res, err := sqlStore.FindThumbnailCount(context.Background(), &cmd)
+ require.NoError(t, err)
+ require.Equal(t, res, int64(2))
+ })
}
func getThumbnail(t *testing.T, sqlStore *SQLStore, dashboardUID string, orgId int64) *models.DashboardThumbnail {
diff --git a/pkg/services/sqlstore/dashboard_version_test.go b/pkg/services/sqlstore/dashboard_version_test.go
index 6b9536d1d08..07adedc92ab 100644
--- a/pkg/services/sqlstore/dashboard_version_test.go
+++ b/pkg/services/sqlstore/dashboard_version_test.go
@@ -7,10 +7,12 @@ import (
"context"
"reflect"
"testing"
+ "time"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/setting"
+ "github.com/grafana/grafana/pkg/util"
"github.com/stretchr/testify/require"
)
@@ -19,13 +21,60 @@ func updateTestDashboard(t *testing.T, sqlStore *SQLStore, dashboard *models.Das
data["id"] = dashboard.Id
- saveCmd := models.SaveDashboardCommand{
+ parentVersion := dashboard.Version
+
+ cmd := models.SaveDashboardCommand{
OrgId: dashboard.OrgId,
Overwrite: true,
Dashboard: simplejson.NewFromAny(data),
}
- _, err := sqlStore.SaveDashboard(saveCmd)
+ var dash *models.Dashboard
+ err := sqlStore.WithDbSession(context.Background(), func(sess *DBSession) error {
+ var existing models.Dashboard
+ dash = cmd.GetDashboardModel()
+ dashWithIdExists, err := sess.Where("id=? AND org_id=?", dash.Id, dash.OrgId).Get(&existing)
+ require.NoError(t, err)
+ require.True(t, dashWithIdExists)
+
+ if dash.Version != existing.Version {
+ dash.SetVersion(existing.Version)
+ dash.Version = existing.Version
+ }
+
+ dash.SetVersion(dash.Version + 1)
+ dash.Created = time.Now()
+ dash.Updated = time.Now()
+ dash.Id = dashboard.Id
+ dash.Uid = util.GenerateShortUID()
+
+ _, err = sess.MustCols("folder_id").ID(dash.Id).Update(dash)
+ return err
+ })
+
require.Nil(t, err)
+
+ err = sqlStore.WithDbSession(context.Background(), func(sess *DBSession) error {
+ dashVersion := &models.DashboardVersion{
+ DashboardId: dash.Id,
+ ParentVersion: parentVersion,
+ RestoredFrom: cmd.RestoredFrom,
+ Version: dash.Version,
+ Created: time.Now(),
+ CreatedBy: dash.UpdatedBy,
+ Message: cmd.Message,
+ Data: dash.Data,
+ }
+
+ if affectedRows, err := sess.Insert(dashVersion); err != nil {
+ return err
+ } else if affectedRows == 0 {
+ return models.ErrDashboardNotFound
+ }
+
+ return nil
+ })
+
+ require.NoError(t, err)
}
func TestGetDashboardVersion(t *testing.T) {
@@ -94,7 +143,6 @@ func TestGetDashboardVersions(t *testing.T) {
updateTestDashboard(t, sqlStore, savedDash, map[string]interface{}{
"tags": "different-tag",
})
-
query := models.GetDashboardVersionsQuery{DashboardId: savedDash.Id, OrgId: 1}
err := sqlStore.GetDashboardVersions(context.Background(), &query)
diff --git a/pkg/services/sqlstore/datasource.go b/pkg/services/sqlstore/datasource.go
index 0ad11bb52f0..6ec9c94a193 100644
--- a/pkg/services/sqlstore/datasource.go
+++ b/pkg/services/sqlstore/datasource.go
@@ -2,6 +2,7 @@ package sqlstore
import (
"context"
+ "errors"
"fmt"
"strings"
"time"
@@ -10,6 +11,7 @@ import (
"github.com/grafana/grafana/pkg/events"
"github.com/grafana/grafana/pkg/infra/metrics"
"github.com/grafana/grafana/pkg/models"
+ ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/util/errutil"
"xorm.io/xorm"
)
@@ -20,26 +22,30 @@ func (ss *SQLStore) GetDataSource(ctx context.Context, query *models.GetDataSour
metrics.MDBDataSourceQueryByID.Inc()
return ss.WithDbSession(ctx, func(sess *DBSession) error {
- if query.OrgId == 0 || (query.Id == 0 && len(query.Name) == 0 && len(query.Uid) == 0) {
- return models.ErrDataSourceIdentifierNotSet
- }
-
- datasource := &models.DataSource{Name: query.Name, OrgId: query.OrgId, Id: query.Id, Uid: query.Uid}
- has, err := sess.Get(datasource)
-
- if err != nil {
- sqlog.Error("Failed getting data source", "err", err, "uid", query.Uid, "id", query.Id, "name", query.Name, "orgId", query.OrgId)
- return err
- } else if !has {
- return models.ErrDataSourceNotFound
- }
-
- query.Result = datasource
-
- return nil
+ return ss.getDataSource(ctx, query, sess)
})
}
+func (ss *SQLStore) getDataSource(ctx context.Context, query *models.GetDataSourceQuery, sess *DBSession) error {
+ if query.OrgId == 0 || (query.Id == 0 && len(query.Name) == 0 && len(query.Uid) == 0) {
+ return models.ErrDataSourceIdentifierNotSet
+ }
+
+ datasource := &models.DataSource{Name: query.Name, OrgId: query.OrgId, Id: query.Id, Uid: query.Uid}
+ has, err := sess.Get(datasource)
+
+ if err != nil {
+ sqlog.Error("Failed getting data source", "err", err, "uid", query.Uid, "id", query.Id, "name", query.Name, "orgId", query.OrgId)
+ return err
+ } else if !has {
+ return models.ErrDataSourceNotFound
+ }
+
+ query.Result = datasource
+
+ return nil
+}
+
func (ss *SQLStore) GetDataSources(ctx context.Context, query *models.GetDataSourcesQuery) error {
var sess *xorm.Session
return ss.WithDbSession(ctx, func(dbSess *DBSession) error {
@@ -82,32 +88,34 @@ func (ss *SQLStore) GetDefaultDataSource(ctx context.Context, query *models.GetD
}
// DeleteDataSource removes a datasource by org_id as well as either uid (preferred), id, or name
-// and is added to the bus.
+// and is added to the bus. It also removes permissions related to the datasource.
func (ss *SQLStore) DeleteDataSource(ctx context.Context, cmd *models.DeleteDataSourceCommand) error {
- params := make([]interface{}, 0)
-
- makeQuery := func(sql string, p ...interface{}) {
- params = append(params, sql)
- params = append(params, p...)
- }
-
- switch {
- case cmd.OrgID == 0:
- return models.ErrDataSourceIdentifierNotSet
- case cmd.UID != "":
- makeQuery("DELETE FROM data_source WHERE uid=? and org_id=?", cmd.UID, cmd.OrgID)
- case cmd.ID != 0:
- makeQuery("DELETE FROM data_source WHERE id=? and org_id=?", cmd.ID, cmd.OrgID)
- case cmd.Name != "":
- makeQuery("DELETE FROM data_source WHERE name=? and org_id=?", cmd.Name, cmd.OrgID)
- default:
- return models.ErrDataSourceIdentifierNotSet
- }
-
return ss.WithTransactionalDbSession(ctx, func(sess *DBSession) error {
- result, err := sess.Exec(params...)
- cmd.DeletedDatasourcesCount, _ = result.RowsAffected()
+ dsQuery := &models.GetDataSourceQuery{Id: cmd.ID, Uid: cmd.UID, Name: cmd.Name, OrgId: cmd.OrgID}
+ errGettingDS := ss.getDataSource(ctx, dsQuery, sess)
+ if errGettingDS != nil && !errors.Is(errGettingDS, models.ErrDataSourceNotFound) {
+ return errGettingDS
+ }
+
+ ds := dsQuery.Result
+ if ds != nil {
+ // Delete the data source
+ result, err := sess.Exec("DELETE FROM data_source WHERE org_id=? AND id=?", ds.OrgId, ds.Id)
+ if err != nil {
+ return err
+ }
+
+ cmd.DeletedDatasourcesCount, _ = result.RowsAffected()
+
+ // Remove associated AccessControl permissions
+ if _, errDeletingPerms := sess.Exec("DELETE FROM permission WHERE scope=?",
+ ac.Scope("datasources", "id", fmt.Sprint(dsQuery.Result.Id))); errDeletingPerms != nil {
+ return errDeletingPerms
+ }
+ }
+
+ // Publish data source deletion event
sess.publishAfterCommit(&events.DataSourceDeleted{
Timestamp: time.Now(),
Name: cmd.Name,
@@ -116,7 +124,7 @@ func (ss *SQLStore) DeleteDataSource(ctx context.Context, cmd *models.DeleteData
OrgID: cmd.OrgID,
})
- return err
+ return nil
})
}
diff --git a/pkg/services/sqlstore/datasource_test.go b/pkg/services/sqlstore/datasource_test.go
index b53a7703f56..46e36620f01 100644
--- a/pkg/services/sqlstore/datasource_test.go
+++ b/pkg/services/sqlstore/datasource_test.go
@@ -6,6 +6,7 @@ package sqlstore
import (
"context"
"errors"
+ "fmt"
"strconv"
"testing"
"time"
@@ -13,6 +14,7 @@ import (
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/events"
"github.com/grafana/grafana/pkg/models"
+ ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -226,8 +228,10 @@ func TestDataAccess(t *testing.T) {
sqlStore := InitTestDB(t)
ds := initDatasource(sqlStore)
- err := sqlStore.DeleteDataSource(context.Background(), &models.DeleteDataSourceCommand{ID: ds.Id, OrgID: 123123})
+ err := sqlStore.DeleteDataSource(context.Background(),
+ &models.DeleteDataSourceCommand{ID: ds.Id, OrgID: 123123})
require.NoError(t, err)
+
query := models.GetDataSourcesQuery{OrgId: 10}
err = sqlStore.GetDataSources(context.Background(), &query)
require.NoError(t, err)
@@ -246,7 +250,8 @@ func TestDataAccess(t *testing.T) {
return nil
})
- err := sqlStore.DeleteDataSource(context.Background(), &models.DeleteDataSourceCommand{ID: ds.Id, UID: "nisse-uid", Name: "nisse", OrgID: 123123})
+ err := sqlStore.DeleteDataSource(context.Background(),
+ &models.DeleteDataSourceCommand{ID: ds.Id, UID: "nisse-uid", Name: "nisse", OrgID: int64(123123)})
require.NoError(t, err)
require.Eventually(t, func() bool {
@@ -273,6 +278,42 @@ func TestDataAccess(t *testing.T) {
require.Equal(t, 0, len(query.Result))
})
+ t.Run("DeleteDataSourceAccessControlPermissions", func(t *testing.T) {
+ sqlStore := InitTestDB(t)
+ ds := initDatasource(sqlStore)
+
+ // Init associated permission
+ errAddPermissions := sqlStore.WithTransactionalDbSession(context.TODO(), func(sess *DBSession) error {
+ _, err := sess.Table("permission").Insert(ac.Permission{
+ RoleID: 1,
+ Action: "datasources:read",
+ Scope: ac.Scope("datasources", "id", fmt.Sprintf("%d", ds.Id)),
+ Updated: time.Now(),
+ Created: time.Now(),
+ })
+ return err
+ })
+ require.NoError(t, errAddPermissions)
+ query := models.GetDataSourcesQuery{OrgId: 10}
+
+ errDeletingDS := sqlStore.DeleteDataSource(context.Background(),
+ &models.DeleteDataSourceCommand{Name: ds.Name, OrgID: ds.OrgId},
+ )
+ require.NoError(t, errDeletingDS)
+
+ // Check associated permission
+ permCount := int64(0)
+ errGetPermissions := sqlStore.WithTransactionalDbSession(context.TODO(), func(sess *DBSession) error {
+ var err error
+ permCount, err = sess.Table("permission").Count()
+ return err
+ })
+ require.NoError(t, errGetPermissions)
+ require.Zero(t, permCount, "permissions associated to the data source should have been removed")
+
+ require.Equal(t, 0, len(query.Result))
+ })
+
t.Run("GetDataSources", func(t *testing.T) {
t.Run("Number of data sources returned limited to 6 per organization", func(t *testing.T) {
sqlStore := InitTestDB(t)
diff --git a/pkg/services/sqlstore/health.go b/pkg/services/sqlstore/health.go
index a0a1b4551fc..9e1e98728cc 100644
--- a/pkg/services/sqlstore/health.go
+++ b/pkg/services/sqlstore/health.go
@@ -9,7 +9,7 @@ import (
// GetDBHealthQuery executes a query to check
// the availability of the database.
func (ss *SQLStore) GetDBHealthQuery(ctx context.Context, query *models.GetDBHealthQuery) error {
- return withDbSession(ctx, x, func(session *DBSession) error {
+ return ss.WithDbSession(ctx, func(session *DBSession) error {
_, err := session.Exec("SELECT 1")
return err
})
diff --git a/pkg/services/sqlstore/migrations/migrations_test.go b/pkg/services/sqlstore/migrations/migrations_test.go
index 3ee4d111954..83a26cc52df 100644
--- a/pkg/services/sqlstore/migrations/migrations_test.go
+++ b/pkg/services/sqlstore/migrations/migrations_test.go
@@ -65,7 +65,7 @@ func TestMigrationLock(t *testing.T) {
t.Skip()
}
- testDB := getTestDB(dbType)
+ testDB := getTestDB(t, dbType)
x, err := xorm.NewEngine(testDB.DriverName, testDB.ConnStr)
require.NoError(t, err)
@@ -154,7 +154,7 @@ func TestMigrationLock(t *testing.T) {
func TestMigratorLocking(t *testing.T) {
dbType := getDBType()
- testDB := getTestDB(dbType)
+ testDB := getTestDB(t, dbType)
x, err := xorm.NewEngine(testDB.DriverName, testDB.ConnStr)
require.NoError(t, err)
@@ -172,7 +172,7 @@ func TestMigratorLocking(t *testing.T) {
i := i // capture i variable
t.Run(fmt.Sprintf("run migration %d", i), func(t *testing.T) {
t.Parallel()
- err = mg.Start(true, 0)
+ err := mg.Start(true, 0)
if err != nil {
if errors.Is(err, ErrMigratorIsLocked) {
atomic.AddInt64(&errorNum, 1)
@@ -191,7 +191,7 @@ func TestDatabaseLocking(t *testing.T) {
t.Skip()
}
- testDB := getTestDB(dbType)
+ testDB := getTestDB(t, dbType)
x, err := xorm.NewEngine(testDB.DriverName, testDB.ConnStr)
require.NoError(t, err)
@@ -281,14 +281,24 @@ func getDBType() string {
return dbType
}
-func getTestDB(dbType string) sqlutil.TestDB {
+func getTestDB(t *testing.T, dbType string) sqlutil.TestDB {
switch dbType {
case "mysql":
return sqlutil.MySQLTestDB()
case "postgres":
return sqlutil.PostgresTestDB()
default:
- return sqlutil.SQLite3TestDB()
+ f, err := os.CreateTemp(".", "grafana-test-db-")
+ require.NoError(t, err)
+ t.Cleanup(func() {
+ err := os.Remove(f.Name())
+ require.NoError(t, err)
+ })
+
+ return sqlutil.TestDB{
+ DriverName: "sqlite3",
+ ConnStr: f.Name(),
+ }
}
}
diff --git a/pkg/services/sqlstore/migrations/ualert/tables.go b/pkg/services/sqlstore/migrations/ualert/tables.go
index 7d872119c4d..e3f42b6057a 100644
--- a/pkg/services/sqlstore/migrations/ualert/tables.go
+++ b/pkg/services/sqlstore/migrations/ualert/tables.go
@@ -327,7 +327,7 @@ func AddAlertAdminConfigMigrations(mg *migrator.Migrator) {
mg.AddMigration("create_ngalert_configuration_table", migrator.NewAddTableMigration(adminConfiguration))
mg.AddMigration("add index in ngalert_configuration on org_id column", migrator.NewAddIndexMigration(adminConfiguration, adminConfiguration.Indices[0]))
mg.AddMigration("add column send_alerts_to in ngalert_configuration", migrator.NewAddColumnMigration(adminConfiguration, &migrator.Column{
- Name: "send_alerts_to", Type: migrator.DB_SmallInt, Nullable: false,
+ Name: "send_alerts_to", Type: migrator.DB_SmallInt, Nullable: false, Default: "0",
}))
}
diff --git a/pkg/services/sqlstore/migrations/user_mig.go b/pkg/services/sqlstore/migrations/user_mig.go
index 0dfd3063f48..6c33849621c 100644
--- a/pkg/services/sqlstore/migrations/user_mig.go
+++ b/pkg/services/sqlstore/migrations/user_mig.go
@@ -127,11 +127,25 @@ func addUserMigrations(mg *Migrator) {
Cols: []string{"login", "email"},
}))
+ //Service accounts are lightweight users with restricted permissions. They support API keys
+ //and provisioning and tasks like alarms and reports.
+ // Issues in this migration: is_service_account should be nullable
mg.AddMigration("Add is_service_account column to user", NewAddColumnMigration(userV2, &Column{
Name: "is_service_account", Type: DB_Bool, Nullable: false, Default: "0",
}))
+
+ mg.AddMigration("Update is_service_account column to nullable",
+ NewRawSQLMigration("").
+ SQLite(migSQLITEisServiceAccountNullable).
+ Postgres("ALTER TABLE `user` ALTER COLUMN is_service_account DROP NOT NULL;").
+ Mysql("ALTER TABLE user MODIFY is_service_account BOOLEAN DEFAULT 0;"))
}
+const migSQLITEisServiceAccountNullable = `ALTER TABLE user ADD COLUMN tmp_service_account BOOLEAN DEFAULT 0;
+UPDATE user SET tmp_service_account = is_service_account;
+ALTER TABLE user DROP COLUMN is_service_account;
+ALTER TABLE user RENAME COLUMN tmp_service_account TO is_service_account;`
+
type AddMissingUserSaltAndRandsMigration struct {
MigrationBase
}
diff --git a/pkg/services/sqlstore/mockstore/mockstore.go b/pkg/services/sqlstore/mockstore/mockstore.go
index b9ee9aaf9ea..58387874d51 100644
--- a/pkg/services/sqlstore/mockstore/mockstore.go
+++ b/pkg/services/sqlstore/mockstore/mockstore.go
@@ -112,23 +112,7 @@ func (m *SQLStoreMock) DeleteOrg(ctx context.Context, cmd *models.DeleteOrgComma
return m.ExpectedError
}
-func (m *SQLStoreMock) GetProvisionedDataByDashboardID(dashboardID int64) (*models.DashboardProvisioning, error) {
- return &models.DashboardProvisioning{}, m.ExpectedError
-}
-
-func (m *SQLStoreMock) GetProvisionedDataByDashboardUID(orgID int64, dashboardUID string) (*models.DashboardProvisioning, error) {
- return nil, m.ExpectedError
-}
-
-func (m *SQLStoreMock) SaveProvisionedDashboard(cmd models.SaveDashboardCommand, provisioning *models.DashboardProvisioning) (*models.Dashboard, error) {
- return nil, m.ExpectedError
-}
-
-func (m *SQLStoreMock) GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error) {
- return nil, m.ExpectedError
-}
-
-func (m *SQLStoreMock) DeleteOrphanedProvisionedDashboards(ctx context.Context, cmd *models.DeleteOrphanedProvisionedDashboardsCommand) error {
+func (m SQLStoreMock) DeleteOrphanedProvisionedDashboards(ctx context.Context, cmd *models.DeleteOrphanedProvisionedDashboardsCommand) error {
return m.ExpectedError
}
@@ -382,16 +366,7 @@ func (m *SQLStoreMock) DeleteExpiredVersions(ctx context.Context, cmd *models.De
return m.ExpectedError
}
-func (m *SQLStoreMock) UpdateDashboardACL(ctx context.Context, dashboardID int64, items []*models.DashboardAcl) error {
- return m.ExpectedError
-}
-
-func (m *SQLStoreMock) UpdateDashboardACLCtx(ctx context.Context, dashboardID int64, items []*models.DashboardAcl) error {
- return m.ExpectedError
-}
-
-func (m *SQLStoreMock) GetDashboardAclInfoList(ctx context.Context, query *models.GetDashboardAclInfoListQuery) error {
- query.Result = m.ExpectedDashboardAclInfoList
+func (m SQLStoreMock) GetDashboardAclInfoList(ctx context.Context, query *models.GetDashboardAclInfoListQuery) error {
return m.ExpectedError
}
@@ -433,11 +408,7 @@ func (m *SQLStoreMock) HandleAlertsQuery(ctx context.Context, query *models.GetA
return m.ExpectedError
}
-func (m *SQLStoreMock) SaveAlerts(ctx context.Context, dashID int64, alerts []*models.Alert) error {
- return m.ExpectedError
-}
-
-func (m *SQLStoreMock) SetAlertState(ctx context.Context, cmd *models.SetAlertStateCommand) error {
+func (m SQLStoreMock) SetAlertState(ctx context.Context, cmd *models.SetAlertStateCommand) error {
return m.ExpectedError
}
@@ -484,18 +455,14 @@ func (m *SQLStoreMock) GetDashboard(ctx context.Context, query *models.GetDashbo
return m.ExpectedError
}
+func (m SQLStoreMock) SearchDashboards(ctx context.Context, query *search.FindPersistedDashboardsQuery) error {
+ return m.ExpectedError
+}
+
func (m *SQLStoreMock) GetDashboardTags(ctx context.Context, query *models.GetDashboardTagsQuery) error {
return nil // TODO: Implement
}
-func (m *SQLStoreMock) GetFolderByTitle(orgID int64, title string) (*models.Dashboard, error) {
- return nil, m.ExpectedError
-}
-
-func (m *SQLStoreMock) SearchDashboards(ctx context.Context, query *search.FindPersistedDashboardsQuery) error {
- return m.ExpectedError
-}
-
func (m *SQLStoreMock) DeleteDashboard(ctx context.Context, cmd *models.DeleteDashboardCommand) error {
cmd.Id = m.ExpectedDashboard.Id
cmd.OrgId = m.ExpectedDashboard.OrgId
@@ -510,11 +477,7 @@ func (m *SQLStoreMock) GetDashboardUIDById(ctx context.Context, query *models.Ge
return m.ExpectedError
}
-func (m *SQLStoreMock) ValidateDashboardBeforeSave(dashboard *models.Dashboard, overwrite bool) (bool, error) {
- return false, nil
-}
-
-func (m *SQLStoreMock) GetDataSource(ctx context.Context, query *models.GetDataSourceQuery) error {
+func (m SQLStoreMock) GetDataSource(ctx context.Context, query *models.GetDataSourceQuery) error {
query.Result = m.ExpectedDatasource
return m.ExpectedError
}
@@ -674,3 +637,19 @@ func (m *SQLStoreMock) SearchOrgs(ctx context.Context, query *models.SearchOrgsQ
query.Result = m.ExpectedSearchOrgList
return m.ExpectedError
}
+
+func (m *SQLStoreMock) HasAdminPermissionInFolders(ctx context.Context, query *models.HasAdminPermissionInFoldersQuery) error {
+ return m.ExpectedError
+}
+
+func (m *SQLStoreMock) GetDashboardPermissionsForUser(ctx context.Context, query *models.GetDashboardPermissionsForUserQuery) error {
+ return m.ExpectedError
+}
+
+func (m *SQLStoreMock) GetDashboardsByPluginId(ctx context.Context, query *models.GetDashboardsByPluginIdQuery) error {
+ return m.ExpectedError
+}
+
+func (m *SQLStoreMock) GetDashboardSlugById(ctx context.Context, query *models.GetDashboardSlugByIdQuery) error {
+ return m.ExpectedError
+}
diff --git a/pkg/services/sqlstore/org_test.go b/pkg/services/sqlstore/org_test.go
index 95f3056e35f..6565e1eefc2 100644
--- a/pkg/services/sqlstore/org_test.go
+++ b/pkg/services/sqlstore/org_test.go
@@ -9,8 +9,10 @@ import (
"testing"
"time"
+ "github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/setting"
+ "github.com/grafana/grafana/pkg/util"
"github.com/stretchr/testify/require"
)
@@ -165,6 +167,7 @@ func TestAccountDataAccess(t *testing.T) {
err := SearchUsers(context.Background(), &query)
require.NoError(t, err)
+ require.Len(t, query.Result.Users, 2)
require.Equal(t, query.Result.Users[0].Email, "ac1@test.com")
require.Equal(t, query.Result.Users[1].Email, "ac2@test.com")
})
@@ -329,12 +332,12 @@ func TestAccountDataAccess(t *testing.T) {
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 = testHelperUpdateDashboardAcl(t, sqlStore, dash1.Id, models.DashboardAcl{
+ 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 = testHelperUpdateDashboardAcl(t, sqlStore, dash2.Id, models.DashboardAcl{
+ 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)
@@ -370,16 +373,92 @@ func TestAccountDataAccess(t *testing.T) {
})
}
-func testHelperUpdateDashboardAcl(t *testing.T, sqlStore *SQLStore, dashboardID int64,
- items ...models.DashboardAcl) error {
+//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 := &models.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,
+ }
+
+ if affectedRows, err := sess.Insert(dashVersion); err != nil {
+ return err
+ } else if affectedRows == 0 {
+ return models.ErrDashboardNotFound
+ }
+
+ return nil
+ })
+
+ 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()
- var itemPtrs []*models.DashboardAcl
- for _, it := range items {
- item := it
- item.Created = time.Now()
- item.Updated = time.Now()
- itemPtrs = append(itemPtrs, &item)
- }
- return sqlStore.UpdateDashboardACL(context.Background(), dashboardID, itemPtrs)
+ 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/searchstore/search_test.go b/pkg/services/sqlstore/searchstore/search_test.go
index f66cc1d373f..1d17b593d50 100644
--- a/pkg/services/sqlstore/searchstore/search_test.go
+++ b/pkg/services/sqlstore/searchstore/search_test.go
@@ -1,19 +1,16 @@
-//go:build integration
-// +build integration
-
// package search_test contains integration tests for search
package searchstore_test
import (
"context"
"testing"
- "time"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/services/sqlstore/permissions"
"github.com/grafana/grafana/pkg/services/sqlstore/searchstore"
+ "github.com/grafana/grafana/pkg/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -171,11 +168,27 @@ func createDashboards(t *testing.T, db *sqlstore.SQLStore, startID, endID int, o
"version": 0
}`))
require.NoError(t, err)
- dash, err := db.SaveDashboard(models.SaveDashboardCommand{
- Dashboard: dashboard,
- UserId: 1,
- OrgId: orgID,
- UpdatedAt: time.Now(),
+
+ var dash *models.Dashboard
+ err = db.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error {
+ dash = models.NewDashboardFromJson(dashboard)
+ dash.OrgId = orgID
+ dash.Uid = util.GenerateShortUID()
+ dash.CreatedBy = 1
+ dash.UpdatedBy = 1
+ _, err := sess.Insert(dash)
+ require.NoError(t, err)
+
+ tags := dash.GetTags()
+ if len(tags) > 0 {
+ for _, tag := range tags {
+ if _, err := sess.Insert(&sqlstore.DashboardTag{DashboardId: dash.Id, Term: tag}); err != nil {
+ return err
+ }
+ }
+ }
+
+ return nil
})
require.NoError(t, err)
diff --git a/pkg/services/sqlstore/sqlbuilder_test.go b/pkg/services/sqlstore/sqlbuilder_test.go
index 7ef90102343..1b3065860f6 100644
--- a/pkg/services/sqlstore/sqlbuilder_test.go
+++ b/pkg/services/sqlstore/sqlbuilder_test.go
@@ -244,7 +244,7 @@ func createDummyDashboard(t *testing.T, sqlStore *SQLStore, dashboardProps Dashb
saveDashboardCmd.OrgId = 1
}
- dash, err := sqlStore.SaveDashboard(saveDashboardCmd)
+ dash := insertTestDashboard(t, sqlStore, "", saveDashboardCmd.OrgId, 0, false, nil)
require.NoError(t, err)
t.Logf("Created dashboard with ID %d and org ID %d\n", dash.Id, dash.OrgId)
@@ -287,7 +287,7 @@ func createDummyACL(t *testing.T, sqlStore *SQLStore, dashboardPermission *Dashb
acl.Role = &dashboardPermission.Role
}
- err := sqlStore.UpdateDashboardACL(context.Background(), dashboardID, []*models.DashboardAcl{acl})
+ err := updateDashboardAcl(t, sqlStore, dashboardID, acl)
require.NoError(t, err)
if user != nil {
return user.Id
diff --git a/pkg/services/sqlstore/sqlstore.go b/pkg/services/sqlstore/sqlstore.go
index b86c1eae856..b83ee8cbba1 100644
--- a/pkg/services/sqlstore/sqlstore.go
+++ b/pkg/services/sqlstore/sqlstore.go
@@ -56,7 +56,7 @@ type SQLStore struct {
tracer tracing.Tracer
}
-func ProvideService(cfg *setting.Cfg, cacheService *localcache.CacheService, bus bus.Bus, migrations registry.DatabaseMigrator, tracer tracing.Tracer, features *featuremgmt.FeatureManager) (*SQLStore, error) {
+func ProvideService(cfg *setting.Cfg, cacheService *localcache.CacheService, bus bus.Bus, migrations registry.DatabaseMigrator, tracer tracing.Tracer) (*SQLStore, error) {
// This change will make xorm use an empty default schema for postgres and
// by that mimic the functionality of how it was functioning before
// xorm's changes above.
@@ -66,7 +66,7 @@ func ProvideService(cfg *setting.Cfg, cacheService *localcache.CacheService, bus
return nil, err
}
- if err := s.Migrate(features.IsEnabled(featuremgmt.FlagMigrationLocking)); err != nil {
+ if err := s.Migrate(cfg.IsFeatureToggleEnabled(featuremgmt.FlagMigrationLocking)); err != nil {
return nil, err
}
diff --git a/pkg/services/sqlstore/stats.go b/pkg/services/sqlstore/stats.go
index 0a0a8da7fe4..cd1de67f128 100644
--- a/pkg/services/sqlstore/stats.go
+++ b/pkg/services/sqlstore/stats.go
@@ -9,12 +9,9 @@ import (
"github.com/grafana/grafana/pkg/models"
)
-func init() {
- bus.AddHandler("sql", GetSystemUserCountStats)
-}
-
func (ss *SQLStore) addStatsQueryAndCommandHandlers() {
bus.AddHandler("sql", ss.GetAdminStats)
+ bus.AddHandler("sql", ss.GetSystemUserCountStats)
bus.AddHandler("sql", ss.GetAlertNotifiersUsageStats)
bus.AddHandler("sql", ss.GetDataSourceAccessStats)
bus.AddHandler("sql", ss.GetDataSourceStats)
@@ -25,50 +22,57 @@ const activeUserTimeLimit = time.Hour * 24 * 30
const dailyActiveUserTimeLimit = time.Hour * 24
func (ss *SQLStore) GetAlertNotifiersUsageStats(ctx context.Context, query *models.GetAlertNotifierUsageStatsQuery) error {
- var rawSQL = `SELECT COUNT(*) AS count, type FROM ` + dialect.Quote("alert_notification") + ` GROUP BY type`
- query.Result = make([]*models.NotifierUsageStats, 0)
- err := x.SQL(rawSQL).Find(&query.Result)
- return err
+ return ss.WithDbSession(ctx, func(dbSession *DBSession) error {
+ var rawSQL = `SELECT COUNT(*) AS count, type FROM ` + dialect.Quote("alert_notification") + ` GROUP BY type`
+ query.Result = make([]*models.NotifierUsageStats, 0)
+ err := dbSession.SQL(rawSQL).Find(&query.Result)
+ return err
+ })
}
func (ss *SQLStore) GetDataSourceStats(ctx context.Context, query *models.GetDataSourceStatsQuery) error {
- var rawSQL = `SELECT COUNT(*) AS count, type FROM ` + dialect.Quote("data_source") + ` GROUP BY type`
- query.Result = make([]*models.DataSourceStats, 0)
- err := x.SQL(rawSQL).Find(&query.Result)
- return err
+ return ss.WithDbSession(ctx, func(dbSession *DBSession) error {
+ var rawSQL = `SELECT COUNT(*) AS count, type FROM ` + dialect.Quote("data_source") + ` GROUP BY type`
+ query.Result = make([]*models.DataSourceStats, 0)
+ err := dbSession.SQL(rawSQL).Find(&query.Result)
+ return err
+ })
}
func (ss *SQLStore) GetDataSourceAccessStats(ctx context.Context, query *models.GetDataSourceAccessStatsQuery) error {
- var rawSQL = `SELECT COUNT(*) AS count, type, access FROM ` + dialect.Quote("data_source") + ` GROUP BY type, access`
- query.Result = make([]*models.DataSourceAccessStats, 0)
- err := x.SQL(rawSQL).Find(&query.Result)
- return err
+ return ss.WithDbSession(ctx, func(dbSession *DBSession) error {
+ var rawSQL = `SELECT COUNT(*) AS count, type, access FROM ` + dialect.Quote("data_source") + ` GROUP BY type, access`
+ query.Result = make([]*models.DataSourceAccessStats, 0)
+ err := dbSession.SQL(rawSQL).Find(&query.Result)
+ return err
+ })
}
func (ss *SQLStore) GetSystemStats(ctx context.Context, query *models.GetSystemStatsQuery) error {
- sb := &SQLBuilder{}
- sb.Write("SELECT ")
- sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("user") + `) AS users,`)
- sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("org") + `) AS orgs,`)
- sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("data_source") + `) AS datasources,`)
- sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("star") + `) AS stars,`)
- sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("playlist") + `) AS playlists,`)
- sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("alert") + `) AS alerts,`)
+ return ss.WithDbSession(ctx, func(dbSession *DBSession) error {
+ sb := &SQLBuilder{}
+ sb.Write("SELECT ")
+ sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("user") + `) AS users,`)
+ sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("org") + `) AS orgs,`)
+ sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("data_source") + `) AS datasources,`)
+ sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("star") + `) AS stars,`)
+ sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("playlist") + `) AS playlists,`)
+ sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("alert") + `) AS alerts,`)
- now := time.Now()
- activeUserDeadlineDate := now.Add(-activeUserTimeLimit)
- sb.Write(`(SELECT COUNT(*) FROM `+dialect.Quote("user")+` WHERE last_seen_at > ?) AS active_users,`, activeUserDeadlineDate)
+ now := time.Now()
+ activeUserDeadlineDate := now.Add(-activeUserTimeLimit)
+ sb.Write(`(SELECT COUNT(*) FROM `+dialect.Quote("user")+` WHERE last_seen_at > ?) AS active_users,`, activeUserDeadlineDate)
- dailyActiveUserDeadlineDate := now.Add(-dailyActiveUserTimeLimit)
- sb.Write(`(SELECT COUNT(*) FROM `+dialect.Quote("user")+` WHERE last_seen_at > ?) AS daily_active_users,`, dailyActiveUserDeadlineDate)
+ dailyActiveUserDeadlineDate := now.Add(-dailyActiveUserTimeLimit)
+ sb.Write(`(SELECT COUNT(*) FROM `+dialect.Quote("user")+` WHERE last_seen_at > ?) AS daily_active_users,`, dailyActiveUserDeadlineDate)
- monthlyActiveUserDeadlineDate := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
- sb.Write(`(SELECT COUNT(*) FROM `+dialect.Quote("user")+` WHERE last_seen_at > ?) AS monthly_active_users,`, monthlyActiveUserDeadlineDate)
+ monthlyActiveUserDeadlineDate := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
+ sb.Write(`(SELECT COUNT(*) FROM `+dialect.Quote("user")+` WHERE last_seen_at > ?) AS monthly_active_users,`, monthlyActiveUserDeadlineDate)
- sb.Write(`(SELECT COUNT(id) FROM `+dialect.Quote("dashboard")+` WHERE is_folder = ?) AS dashboards,`, dialect.BooleanStr(false))
- sb.Write(`(SELECT COUNT(id) FROM `+dialect.Quote("dashboard")+` WHERE is_folder = ?) AS folders,`, dialect.BooleanStr(true))
+ sb.Write(`(SELECT COUNT(id) FROM `+dialect.Quote("dashboard")+` WHERE is_folder = ?) AS dashboards,`, dialect.BooleanStr(false))
+ sb.Write(`(SELECT COUNT(id) FROM `+dialect.Quote("dashboard")+` WHERE is_folder = ?) AS folders,`, dialect.BooleanStr(true))
- sb.Write(`(
+ sb.Write(`(
SELECT COUNT(acl.id)
FROM `+dialect.Quote("dashboard_acl")+` AS acl
INNER JOIN `+dialect.Quote("dashboard")+` AS d
@@ -76,7 +80,7 @@ func (ss *SQLStore) GetSystemStats(ctx context.Context, query *models.GetSystemS
WHERE d.is_folder = ?
) AS dashboard_permissions,`, dialect.BooleanStr(false))
- sb.Write(`(
+ sb.Write(`(
SELECT COUNT(acl.id)
FROM `+dialect.Quote("dashboard_acl")+` AS acl
INNER JOIN `+dialect.Quote("dashboard")+` AS d
@@ -84,40 +88,41 @@ func (ss *SQLStore) GetSystemStats(ctx context.Context, query *models.GetSystemS
WHERE d.is_folder = ?
) AS folder_permissions,`, dialect.BooleanStr(true))
- sb.Write(viewersPermissionsCounterSQL("dashboards_viewers_can_edit", false, models.PERMISSION_EDIT))
- sb.Write(viewersPermissionsCounterSQL("dashboards_viewers_can_admin", false, models.PERMISSION_ADMIN))
- sb.Write(viewersPermissionsCounterSQL("folders_viewers_can_edit", true, models.PERMISSION_EDIT))
- sb.Write(viewersPermissionsCounterSQL("folders_viewers_can_admin", true, models.PERMISSION_ADMIN))
+ sb.Write(viewersPermissionsCounterSQL("dashboards_viewers_can_edit", false, models.PERMISSION_EDIT))
+ sb.Write(viewersPermissionsCounterSQL("dashboards_viewers_can_admin", false, models.PERMISSION_ADMIN))
+ sb.Write(viewersPermissionsCounterSQL("folders_viewers_can_edit", true, models.PERMISSION_EDIT))
+ sb.Write(viewersPermissionsCounterSQL("folders_viewers_can_admin", true, models.PERMISSION_ADMIN))
- sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("dashboard_provisioning") + `) AS provisioned_dashboards,`)
- sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("dashboard_snapshot") + `) AS snapshots,`)
- sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("dashboard_version") + `) AS dashboard_versions,`)
- sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("annotation") + `) AS annotations,`)
- sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("team") + `) AS teams,`)
- sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("user_auth_token") + `) AS auth_tokens,`)
- sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("alert_rule") + `) AS alert_rules,`)
- sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("api_key") + `) AS api_keys,`)
- sb.Write(`(SELECT COUNT(id) FROM `+dialect.Quote("library_element")+` WHERE kind = ?) AS library_panels,`, models.PanelElement)
- sb.Write(`(SELECT COUNT(id) FROM `+dialect.Quote("library_element")+` WHERE kind = ?) AS library_variables,`, models.VariableElement)
+ sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("dashboard_provisioning") + `) AS provisioned_dashboards,`)
+ sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("dashboard_snapshot") + `) AS snapshots,`)
+ sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("dashboard_version") + `) AS dashboard_versions,`)
+ sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("annotation") + `) AS annotations,`)
+ sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("team") + `) AS teams,`)
+ sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("user_auth_token") + `) AS auth_tokens,`)
+ sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("alert_rule") + `) AS alert_rules,`)
+ sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("api_key") + `) AS api_keys,`)
+ sb.Write(`(SELECT COUNT(id) FROM `+dialect.Quote("library_element")+` WHERE kind = ?) AS library_panels,`, models.PanelElement)
+ sb.Write(`(SELECT COUNT(id) FROM `+dialect.Quote("library_element")+` WHERE kind = ?) AS library_variables,`, models.VariableElement)
- sb.Write(roleCounterSQL(ctx))
+ sb.Write(ss.roleCounterSQL(ctx))
- var stats models.SystemStats
- _, err := x.SQL(sb.GetSQLString(), sb.params...).Get(&stats)
- if err != nil {
- return err
- }
+ var stats models.SystemStats
+ _, err := dbSession.SQL(sb.GetSQLString(), sb.params...).Get(&stats)
+ if err != nil {
+ return err
+ }
- query.Result = &stats
+ query.Result = &stats
- return nil
+ return nil
+ })
}
-func roleCounterSQL(ctx context.Context) string {
+func (ss *SQLStore) roleCounterSQL(ctx context.Context) string {
const roleCounterTimeout = 20 * time.Second
ctx, cancel := context.WithTimeout(ctx, roleCounterTimeout)
defer cancel()
- _ = updateUserRoleCountsIfNecessary(ctx, false)
+ _ = ss.updateUserRoleCountsIfNecessary(ctx, false)
sqlQuery :=
strconv.FormatInt(userStatsCache.total.Admins, 10) + ` AS admins, ` +
strconv.FormatInt(userStatsCache.total.Editors, 10) + ` AS editors, ` +
@@ -145,12 +150,13 @@ func viewersPermissionsCounterSQL(statName string, isFolder bool, permission mod
}
func (ss *SQLStore) GetAdminStats(ctx context.Context, query *models.GetAdminStatsQuery) error {
- now := time.Now()
- activeEndDate := now.Add(-activeUserTimeLimit)
- dailyActiveEndDate := now.Add(-dailyActiveUserTimeLimit)
- monthlyActiveEndDate := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
+ return ss.WithDbSession(ctx, func(dbSession *DBSession) error {
+ now := time.Now()
+ activeEndDate := now.Add(-activeUserTimeLimit)
+ dailyActiveEndDate := now.Add(-dailyActiveUserTimeLimit)
+ monthlyActiveEndDate := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
- var rawSQL = `SELECT
+ var rawSQL = `SELECT
(
SELECT COUNT(*)
FROM ` + dialect.Quote("org") + `
@@ -199,7 +205,7 @@ func (ss *SQLStore) GetAdminStats(ctx context.Context, query *models.GetAdminSta
SELECT COUNT(*)
FROM ` + dialect.Quote("user") + ` WHERE last_seen_at > ?
) AS monthly_active_users,
- ` + roleCounterSQL(ctx) + `,
+ ` + ss.roleCounterSQL(ctx) + `,
(
SELECT COUNT(*)
FROM ` + dialect.Quote("user_auth_token") + ` WHERE rotated_at > ?
@@ -209,18 +215,19 @@ func (ss *SQLStore) GetAdminStats(ctx context.Context, query *models.GetAdminSta
FROM ` + dialect.Quote("user_auth_token") + ` WHERE rotated_at > ?
) AS daily_active_sessions`
- var stats models.AdminStats
- _, err := x.SQL(rawSQL, activeEndDate, dailyActiveEndDate, monthlyActiveEndDate, activeEndDate.Unix(), dailyActiveEndDate.Unix()).Get(&stats)
- if err != nil {
- return err
- }
+ var stats models.AdminStats
+ _, err := dbSession.SQL(rawSQL, activeEndDate, dailyActiveEndDate, monthlyActiveEndDate, activeEndDate.Unix(), dailyActiveEndDate.Unix()).Get(&stats)
+ if err != nil {
+ return err
+ }
- query.Result = &stats
- return nil
+ query.Result = &stats
+ return nil
+ })
}
-func GetSystemUserCountStats(ctx context.Context, query *models.GetSystemUserCountStatsQuery) error {
- return withDbSession(ctx, x, func(sess *DBSession) error {
+func (ss *SQLStore) GetSystemUserCountStats(ctx context.Context, query *models.GetSystemUserCountStatsQuery) error {
+ return ss.WithDbSession(ctx, func(sess *DBSession) error {
var rawSQL = `SELECT COUNT(id) AS Count FROM ` + dialect.Quote("user")
var stats models.SystemUserCountStats
_, err := sess.SQL(rawSQL).Get(&stats)
@@ -234,10 +241,10 @@ func GetSystemUserCountStats(ctx context.Context, query *models.GetSystemUserCou
})
}
-func updateUserRoleCountsIfNecessary(ctx context.Context, forced bool) error {
+func (ss *SQLStore) updateUserRoleCountsIfNecessary(ctx context.Context, forced bool) error {
memoizationPeriod := time.Now().Add(-userStatsCacheLimetime)
if forced || userStatsCache.memoized.Before(memoizationPeriod) {
- err := updateUserRoleCounts(ctx)
+ err := ss.updateUserRoleCounts(ctx)
if err != nil {
return err
}
@@ -258,8 +265,9 @@ var (
userStatsCacheLimetime = 5 * time.Minute
)
-func updateUserRoleCounts(ctx context.Context) error {
- query := `
+func (ss *SQLStore) updateUserRoleCounts(ctx context.Context) error {
+ return ss.WithDbSession(ctx, func(dbSession *DBSession) error {
+ query := `
SELECT role AS bitrole, active, COUNT(role) AS count FROM
(SELECT last_seen_at>? AS active, last_seen_at>? AS daily_active, SUM(role) AS role
FROM (SELECT
@@ -275,42 +283,43 @@ SELECT role AS bitrole, active, COUNT(role) AS count FROM
GROUP BY id, last_seen_at) AS t1
GROUP BY active, daily_active, role;`
- activeUserDeadline := time.Now().Add(-activeUserTimeLimit)
- dailyActiveUserDeadline := time.Now().Add(-dailyActiveUserTimeLimit)
+ activeUserDeadline := time.Now().Add(-activeUserTimeLimit)
+ dailyActiveUserDeadline := time.Now().Add(-dailyActiveUserTimeLimit)
- type rolebitmap struct {
- Active bool
- DailyActive bool
- Bitrole int64
- Count int64
- }
-
- bitmap := []rolebitmap{}
- err := x.Context(ctx).SQL(query, activeUserDeadline, dailyActiveUserDeadline).Find(&bitmap)
- if err != nil {
- return err
- }
-
- memo := memoUserStats{memoized: time.Now()}
- for _, role := range bitmap {
- roletype := models.ROLE_VIEWER
- if role.Bitrole&0b100 != 0 {
- roletype = models.ROLE_ADMIN
- } else if role.Bitrole&0b10 != 0 {
- roletype = models.ROLE_EDITOR
+ type rolebitmap struct {
+ Active bool
+ DailyActive bool
+ Bitrole int64
+ Count int64
}
- memo.total = addToStats(memo.total, roletype, role.Count)
- if role.Active {
- memo.active = addToStats(memo.active, roletype, role.Count)
+ bitmap := []rolebitmap{}
+ err := dbSession.Context(ctx).SQL(query, activeUserDeadline, dailyActiveUserDeadline).Find(&bitmap)
+ if err != nil {
+ return err
}
- if role.DailyActive {
- memo.dailyActive = addToStats(memo.dailyActive, roletype, role.Count)
- }
- }
- userStatsCache = memo
- return nil
+ memo := memoUserStats{memoized: time.Now()}
+ for _, role := range bitmap {
+ roletype := models.ROLE_VIEWER
+ if role.Bitrole&0b100 != 0 {
+ roletype = models.ROLE_ADMIN
+ } else if role.Bitrole&0b10 != 0 {
+ roletype = models.ROLE_EDITOR
+ }
+
+ memo.total = addToStats(memo.total, roletype, role.Count)
+ if role.Active {
+ memo.active = addToStats(memo.active, roletype, role.Count)
+ }
+ if role.DailyActive {
+ memo.dailyActive = addToStats(memo.dailyActive, roletype, role.Count)
+ }
+ }
+
+ userStatsCache = memo
+ return nil
+ })
}
func addToStats(base models.UserStats, role models.RoleType, count int64) models.UserStats {
diff --git a/pkg/services/sqlstore/stats_test.go b/pkg/services/sqlstore/stats_test.go
index 1100e8fafea..bd0a67c778d 100644
--- a/pkg/services/sqlstore/stats_test.go
+++ b/pkg/services/sqlstore/stats_test.go
@@ -32,7 +32,7 @@ func TestStatsDataAccess(t *testing.T) {
t.Run("Get system user count stats should not results in error", func(t *testing.T) {
query := models.GetSystemUserCountStatsQuery{}
- err := GetSystemUserCountStats(context.Background(), &query)
+ err := sqlStore.GetSystemUserCountStats(context.Background(), &query)
assert.NoError(t, err)
})
@@ -124,7 +124,7 @@ func populateDB(t *testing.T, sqlStore *SQLStore) {
require.NoError(t, err)
// force renewal of user stats
- err = updateUserRoleCountsIfNecessary(context.Background(), true)
+ err = sqlStore.updateUserRoleCountsIfNecessary(context.Background(), true)
require.NoError(t, err)
// add 1st api key
diff --git a/pkg/services/sqlstore/store.go b/pkg/services/sqlstore/store.go
index d97b593d1d9..100b3e75f21 100644
--- a/pkg/services/sqlstore/store.go
+++ b/pkg/services/sqlstore/store.go
@@ -24,10 +24,6 @@ type Store interface {
UpdateOrg(ctx context.Context, cmd *models.UpdateOrgCommand) error
UpdateOrgAddress(ctx context.Context, cmd *models.UpdateOrgAddressCommand) error
DeleteOrg(ctx context.Context, cmd *models.DeleteOrgCommand) error
- GetProvisionedDataByDashboardID(dashboardID int64) (*models.DashboardProvisioning, error)
- GetProvisionedDataByDashboardUID(orgID int64, dashboardUID string) (*models.DashboardProvisioning, error)
- SaveProvisionedDashboard(cmd models.SaveDashboardCommand, provisioning *models.DashboardProvisioning) (*models.Dashboard, error)
- GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error)
DeleteOrphanedProvisionedDashboards(ctx context.Context, cmd *models.DeleteOrphanedProvisionedDashboardsCommand) error
CreateLoginAttempt(ctx context.Context, cmd *models.CreateLoginAttemptCommand) error
DeleteOldLoginAttempts(ctx context.Context, cmd *models.DeleteOldLoginAttemptsCommand) error
@@ -87,8 +83,6 @@ type Store interface {
GetDashboardVersion(ctx context.Context, query *models.GetDashboardVersionQuery) error
GetDashboardVersions(ctx context.Context, query *models.GetDashboardVersionsQuery) error
DeleteExpiredVersions(ctx context.Context, cmd *models.DeleteExpiredVersionsCommand) error
- UpdateDashboardACL(ctx context.Context, dashboardID int64, items []*models.DashboardAcl) error
- UpdateDashboardACLCtx(ctx context.Context, dashboardID int64, items []*models.DashboardAcl) error
GetDashboardAclInfoList(ctx context.Context, query *models.GetDashboardAclInfoListQuery) error
CreatePlaylist(ctx context.Context, cmd *models.CreatePlaylistCommand) error
UpdatePlaylist(ctx context.Context, cmd *models.UpdatePlaylistCommand) error
@@ -99,7 +93,6 @@ type Store interface {
GetAlertById(ctx context.Context, query *models.GetAlertByIdQuery) error
GetAllAlertQueryHandler(ctx context.Context, query *models.GetAllAlertsQuery) error
HandleAlertsQuery(ctx context.Context, query *models.GetAlertsQuery) error
- SaveAlerts(ctx context.Context, dashID int64, alerts []*models.Alert) error
SetAlertState(ctx context.Context, cmd *models.SetAlertStateCommand) error
PauseAlert(ctx context.Context, cmd *models.PauseAlertCommand) error
PauseAllAlerts(ctx context.Context, cmd *models.PauseAllAlertCommand) error
@@ -109,15 +102,12 @@ type Store interface {
GetOrgUsers(ctx context.Context, query *models.GetOrgUsersQuery) error
SearchOrgUsers(ctx context.Context, query *models.SearchOrgUsersQuery) error
RemoveOrgUser(ctx context.Context, cmd *models.RemoveOrgUserCommand) error
- SaveDashboard(cmd models.SaveDashboardCommand) (*models.Dashboard, error)
GetDashboard(ctx context.Context, query *models.GetDashboardQuery) error
GetDashboardTags(ctx context.Context, query *models.GetDashboardTagsQuery) error
- GetFolderByTitle(orgID int64, title string) (*models.Dashboard, error)
SearchDashboards(ctx context.Context, query *search.FindPersistedDashboardsQuery) error
DeleteDashboard(ctx context.Context, cmd *models.DeleteDashboardCommand) error
GetDashboards(ctx context.Context, query *models.GetDashboardsQuery) error
GetDashboardUIDById(ctx context.Context, query *models.GetDashboardRefByIdQuery) error
- ValidateDashboardBeforeSave(dashboard *models.Dashboard, overwrite bool) (bool, error)
GetDataSource(ctx context.Context, query *models.GetDataSourceQuery) error
GetDataSources(ctx context.Context, query *models.GetDataSourcesQuery) error
GetDataSourcesByType(ctx context.Context, query *models.GetDataSourcesByTypeQuery) error
@@ -157,4 +147,8 @@ type Store interface {
ExpireOldUserInvites(ctx context.Context, cmd *models.ExpireTempUsersCommand) error
GetDBHealthQuery(ctx context.Context, query *models.GetDBHealthQuery) error
SearchOrgs(ctx context.Context, query *models.SearchOrgsQuery) error
+ HasAdminPermissionInFolders(ctx context.Context, query *models.HasAdminPermissionInFoldersQuery) error
+ GetDashboardPermissionsForUser(ctx context.Context, query *models.GetDashboardPermissionsForUserQuery) error
+ GetDashboardsByPluginId(ctx context.Context, query *models.GetDashboardsByPluginIdQuery) error
+ GetDashboardSlugById(ctx context.Context, query *models.GetDashboardSlugByIdQuery) error
}
diff --git a/pkg/services/sqlstore/team_test.go b/pkg/services/sqlstore/team_test.go
index ecc260ae82a..97d41c79392 100644
--- a/pkg/services/sqlstore/team_test.go
+++ b/pkg/services/sqlstore/team_test.go
@@ -274,7 +274,7 @@ func TestTeamCommandsAndQueries(t *testing.T) {
require.NoError(t, err)
err = sqlStore.AddTeamMember(userIds[2], testOrgID, groupId, false, 0)
require.NoError(t, err)
- err = testHelperUpdateDashboardAcl(t, sqlStore, 1, models.DashboardAcl{
+ err = updateDashboardAcl(t, sqlStore, 1, &models.DashboardAcl{
DashboardID: 1, OrgID: testOrgID, Permission: models.PERMISSION_EDIT, TeamID: groupId,
})
require.NoError(t, err)
diff --git a/pkg/services/sqlstore/user.go b/pkg/services/sqlstore/user.go
index b38c2f9e4ea..47da1eafb4a 100644
--- a/pkg/services/sqlstore/user.go
+++ b/pkg/services/sqlstore/user.go
@@ -113,17 +113,18 @@ func (ss *SQLStore) createUser(ctx context.Context, sess *DBSession, args userCr
// create user
user = models.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: time.Now(),
- Updated: time.Now(),
- LastSeenAt: time.Now().AddDate(-10, 0, 0),
+ Email: args.Email,
+ Name: args.Name,
+ Login: args.Login,
+ Company: args.Company,
+ IsAdmin: args.IsAdmin,
+ IsDisabled: args.IsDisabled,
+ OrgId: orgID,
+ EmailVerified: args.EmailVerified,
+ Created: time.Now(),
+ Updated: time.Now(),
+ LastSeenAt: time.Now().AddDate(-10, 0, 0),
+ IsServiceAccount: false,
}
salt, err := util.GetRandomString(10)
@@ -321,7 +322,7 @@ func (ss *SQLStore) CreateUser(ctx context.Context, cmd models.CreateUserCommand
}
func (ss SQLStore) GetUserById(ctx context.Context, query *models.GetUserByIdQuery) error {
- return withDbSession(ctx, x, func(sess *DBSession) error {
+ return ss.WithDbSession(ctx, func(sess *DBSession) error {
user := new(models.User)
has, err := sess.ID(query.Id).Get(user)
@@ -630,7 +631,8 @@ func SearchUsers(ctx context.Context, query *models.SearchUsersQuery) error {
// TODO: add to chore, for cleaning up after we have created
// service accounts table in the modelling
- whereConditions = append(whereConditions, "u.is_service_account = false")
+ whereConditions = append(whereConditions, "u.is_service_account = ?")
+ whereParams = append(whereParams, dialect.BooleanStr(false))
// Join with only most recent auth module
joinCondition := `(
diff --git a/pkg/services/sqlstore/user_test.go b/pkg/services/sqlstore/user_test.go
index 84f787afbbc..1a50fe1ccf6 100644
--- a/pkg/services/sqlstore/user_test.go
+++ b/pkg/services/sqlstore/user_test.go
@@ -239,7 +239,7 @@ func TestUserDataAccess(t *testing.T) {
})
require.Nil(t, err)
- err = testHelperUpdateDashboardAcl(t, ss, 1, models.DashboardAcl{
+ err = updateDashboardAcl(t, ss, 1, &models.DashboardAcl{
DashboardID: 1, OrgID: users[0].OrgId, UserID: users[1].Id,
Permission: models.PERMISSION_EDIT,
})
@@ -290,7 +290,7 @@ func TestUserDataAccess(t *testing.T) {
})
require.Nil(t, err)
- err = testHelperUpdateDashboardAcl(t, ss, 1, models.DashboardAcl{
+ err = updateDashboardAcl(t, ss, 1, &models.DashboardAcl{
DashboardID: 1, OrgID: users[0].OrgId, UserID: users[1].Id,
Permission: models.PERMISSION_EDIT,
})
diff --git a/pkg/services/thumbs/crawler.go b/pkg/services/thumbs/crawler.go
index d9ef0118eba..a17b48c4ae0 100644
--- a/pkg/services/thumbs/crawler.go
+++ b/pkg/services/thumbs/crawler.go
@@ -3,6 +3,7 @@ package thumbs
import (
"context"
"encoding/json"
+ "fmt"
"os"
"strings"
"sync"
@@ -83,6 +84,16 @@ func (r *simpleCrawler) broadcastStatus() {
}
func (r *simpleCrawler) Run(ctx context.Context, authOpts rendering.AuthOpts, mode CrawlerMode, theme models.Theme, thumbnailKind models.ThumbnailKind) error {
+ res, err := r.renderService.HasCapability(rendering.ScalingDownImages)
+ if err != nil {
+ return err
+ }
+
+ if !res.IsSupported {
+ return fmt.Errorf("cant run dashboard crawler - rendering service needs to be updated. "+
+ "current version: %s, requiredVersion: %s", r.renderService.Version(), res.SemverConstraint)
+ }
+
r.queueMutex.Lock()
if r.IsRunning() {
r.queueMutex.Unlock()
diff --git a/pkg/services/thumbs/dummy.go b/pkg/services/thumbs/dummy.go
index 45fd30f3b2a..6c984d412ad 100644
--- a/pkg/services/thumbs/dummy.go
+++ b/pkg/services/thumbs/dummy.go
@@ -26,6 +26,16 @@ func (ds *dummyService) Enabled() bool {
return false
}
+func (ds *dummyService) GetDashboardPreviewsSetupSettings(c *models.ReqContext) dashboardPreviewsSetupConfig {
+ return dashboardPreviewsSetupConfig{
+ SystemRequirements: dashboardPreviewsSystemRequirements{
+ Met: false,
+ RequiredImageRendererPluginVersion: "",
+ },
+ ThumbnailsExist: false,
+ }
+}
+
func (ds *dummyService) StartCrawler(c *models.ReqContext) response.Response {
result := make(map[string]string)
result["error"] = "Not enabled"
diff --git a/pkg/services/thumbs/models.go b/pkg/services/thumbs/models.go
index 38b2448b302..a7c128d195c 100644
--- a/pkg/services/thumbs/models.go
+++ b/pkg/services/thumbs/models.go
@@ -53,6 +53,16 @@ type crawlStatus struct {
Last time.Time `json:"last,omitempty"`
}
+type dashboardPreviewsSystemRequirements struct {
+ Met bool `json:"met"`
+ RequiredImageRendererPluginVersion string `json:"requiredImageRendererPluginVersion"`
+}
+
+type dashboardPreviewsSetupConfig struct {
+ SystemRequirements dashboardPreviewsSystemRequirements `json:"systemRequirements"`
+ ThumbnailsExist bool `json:"thumbnailsExist"`
+}
+
type dashRenderer interface {
// Run Assumes you have already authenticated as admin.
@@ -69,6 +79,7 @@ type dashRenderer interface {
type thumbnailRepo interface {
updateThumbnailState(ctx context.Context, state models.ThumbnailState, meta models.DashboardThumbnailMeta) error
+ doThumbnailsExist(ctx context.Context) (bool, error)
saveFromFile(ctx context.Context, filePath string, meta models.DashboardThumbnailMeta, dashboardVersion int) (int64, error)
saveFromBytes(ctx context.Context, bytes []byte, mimeType string, meta models.DashboardThumbnailMeta, dashboardVersion int) (int64, error)
getThumbnail(ctx context.Context, meta models.DashboardThumbnailMeta) (*models.DashboardThumbnail, error)
diff --git a/pkg/services/thumbs/repo.go b/pkg/services/thumbs/repo.go
index 7ce5d0aa006..71f7a7459a9 100644
--- a/pkg/services/thumbs/repo.go
+++ b/pkg/services/thumbs/repo.go
@@ -63,7 +63,7 @@ func (r *sqlThumbnailRepository) saveFromBytes(ctx context.Context, content []by
_, err := r.store.SaveThumbnail(ctx, cmd)
if err != nil {
- r.log.Error("error saving to the db", "dashboardUID", meta.DashboardUID, "err", err)
+ r.log.Error("Error saving to the db", "dashboardUID", meta.DashboardUID, "err", err)
return 0, err
}
@@ -91,3 +91,13 @@ func (r *sqlThumbnailRepository) findDashboardsWithStaleThumbnails(ctx context.C
Kind: kind,
})
}
+
+func (r *sqlThumbnailRepository) doThumbnailsExist(ctx context.Context) (bool, error) {
+ cmd := &models.FindDashboardThumbnailCountCommand{}
+ count, err := r.store.FindThumbnailCount(ctx, cmd)
+ if err != nil {
+ r.log.Error("Error finding thumbnails", "err", err)
+ return false, err
+ }
+ return count > 0, err
+}
diff --git a/pkg/services/thumbs/service.go b/pkg/services/thumbs/service.go
index 2de7249389c..f5939a3f7fc 100644
--- a/pkg/services/thumbs/service.go
+++ b/pkg/services/thumbs/service.go
@@ -27,6 +27,7 @@ type Service interface {
Run(ctx context.Context) error
Enabled() bool
GetImage(c *models.ReqContext)
+ GetDashboardPreviewsSetupSettings(c *models.ReqContext) dashboardPreviewsSetupConfig
// from dashboard page
SetImage(c *models.ReqContext) // form post
@@ -41,6 +42,7 @@ type Service interface {
type thumbService struct {
scheduleOptions crawlerScheduleOptions
renderer dashRenderer
+ renderingService rendering.Service
thumbnailRepo thumbnailRepo
lockService *serverlock.ServerLockService
features featuremgmt.FeatureToggles
@@ -71,6 +73,7 @@ func ProvideService(cfg *setting.Cfg, features featuremgmt.FeatureToggles, lockS
OrgRole: models.ROLE_ADMIN,
}
return &thumbService{
+ renderingService: renderService,
renderer: newSimpleCrawler(renderService, gl, thumbnailRepo),
thumbnailRepo: thumbnailRepo,
features: features,
@@ -196,6 +199,44 @@ func (hs *thumbService) GetImage(c *models.ReqContext) {
}
}
+func (hs *thumbService) GetDashboardPreviewsSetupSettings(c *models.ReqContext) dashboardPreviewsSetupConfig {
+ systemRequirements := hs.getSystemRequirements()
+ thumbnailsExist, err := hs.thumbnailRepo.doThumbnailsExist(c.Req.Context())
+
+ if err != nil {
+ return dashboardPreviewsSetupConfig{
+ SystemRequirements: systemRequirements,
+ ThumbnailsExist: false,
+ }
+ }
+
+ return dashboardPreviewsSetupConfig{
+ SystemRequirements: systemRequirements,
+ ThumbnailsExist: thumbnailsExist,
+ }
+}
+
+func (hs *thumbService) getSystemRequirements() dashboardPreviewsSystemRequirements {
+ res, err := hs.renderingService.HasCapability(rendering.ScalingDownImages)
+ if err != nil {
+ hs.log.Error("Error when verifying dashboard previews system requirements thumbnail", "err", err.Error())
+ return dashboardPreviewsSystemRequirements{
+ Met: false,
+ }
+ }
+
+ if !res.IsSupported {
+ return dashboardPreviewsSystemRequirements{
+ Met: false,
+ RequiredImageRendererPluginVersion: res.SemverConstraint,
+ }
+ }
+
+ return dashboardPreviewsSystemRequirements{
+ Met: true,
+ }
+}
+
// Hack for now -- lets you upload images explicitly
func (hs *thumbService) SetImage(c *models.ReqContext) {
req := hs.parseImageReq(c, false)
diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go
index b4999997dd3..51639e6748c 100644
--- a/pkg/setting/setting.go
+++ b/pkg/setting/setting.go
@@ -254,7 +254,8 @@ type Cfg struct {
// CSPEnabled toggles Content Security Policy support.
CSPEnabled bool
// CSPTemplate contains the Content Security Policy template.
- CSPTemplate string
+ CSPTemplate string
+ AngularSupportEnabled bool
TempDataLifetime time.Duration
PluginsEnableAlpha bool
@@ -1191,6 +1192,7 @@ func readSecuritySettings(iniFile *ini.File, cfg *Cfg) error {
cfg.StrictTransportSecuritySubDomains = security.Key("strict_transport_security_subdomains").MustBool(false)
cfg.CSPEnabled = security.Key("content_security_policy").MustBool(false)
cfg.CSPTemplate = security.Key("content_security_policy_template").MustString("")
+ cfg.AngularSupportEnabled = security.Key("angular_support_enabled").MustBool(true)
// read data source proxy whitelist
DataProxyWhiteList = make(map[string]bool)
diff --git a/pkg/setting/setting_unified_alerting.go b/pkg/setting/setting_unified_alerting.go
index ea385e2b50e..2873ea422d4 100644
--- a/pkg/setting/setting_unified_alerting.go
+++ b/pkg/setting/setting_unified_alerting.go
@@ -52,8 +52,8 @@ const (
// changing this value is discouraged because this could cause existing alert definition
// with intervals that are not exactly divided by this number not to be evaluated
SchedulerBaseInterval = 10 * time.Second
- // DefaultAlertForDuration indicates a default interval of for how long a rule should be evaluated to change state from Pending to Alerting
- DefaultAlertForDuration = 60 * time.Second
+ // DefaultRuleEvaluationInterval indicates a default interval of for how long a rule should be evaluated to change state from Pending to Alerting
+ DefaultRuleEvaluationInterval = SchedulerBaseInterval * 6 // == 60 seconds
)
type UnifiedAlertingSettings struct {
@@ -75,8 +75,8 @@ type UnifiedAlertingSettings struct {
// BaseInterval interval of time the scheduler updates the rules and evaluates rules.
// Only for internal use and not user configuration.
BaseInterval time.Duration
- // DefaultAlertForDuration default time for how long an alert rule should be evaluated before change state.
- DefaultAlertForDuration time.Duration
+ // DefaultRuleEvaluationInterval default interval between evaluations of a rule.
+ DefaultRuleEvaluationInterval time.Duration
}
// IsEnabled returns true if UnifiedAlertingSettings.Enabled is either nil or true.
@@ -239,9 +239,9 @@ func (cfg *Cfg) ReadUnifiedAlertingSettings(iniFile *ini.File) error {
}
uaCfg.MinInterval = uaMinInterval
- uaCfg.DefaultAlertForDuration = DefaultAlertForDuration
- if uaMinInterval > uaCfg.DefaultAlertForDuration {
- uaCfg.DefaultAlertForDuration = uaMinInterval
+ uaCfg.DefaultRuleEvaluationInterval = DefaultRuleEvaluationInterval
+ if uaMinInterval > uaCfg.DefaultRuleEvaluationInterval {
+ uaCfg.DefaultRuleEvaluationInterval = uaMinInterval
}
cfg.UnifiedAlerting = uaCfg
diff --git a/pkg/setting/setting_unified_alerting_test.go b/pkg/setting/setting_unified_alerting_test.go
index 69d92056112..5968688483c 100644
--- a/pkg/setting/setting_unified_alerting_test.go
+++ b/pkg/setting/setting_unified_alerting_test.go
@@ -70,7 +70,7 @@ func TestUnifiedAlertingSettings(t *testing.T) {
require.Equal(t, false, cfg.UnifiedAlerting.ExecuteAlerts)
require.Equal(t, 90*time.Second, cfg.UnifiedAlerting.EvaluationTimeout)
require.Equal(t, SchedulerBaseInterval, cfg.UnifiedAlerting.BaseInterval)
- require.Equal(t, DefaultAlertForDuration, cfg.UnifiedAlerting.DefaultAlertForDuration)
+ require.Equal(t, DefaultRuleEvaluationInterval, cfg.UnifiedAlerting.DefaultRuleEvaluationInterval)
},
},
{
@@ -95,7 +95,7 @@ func TestUnifiedAlertingSettings(t *testing.T) {
require.Equal(t, true, cfg.UnifiedAlerting.ExecuteAlerts)
require.Equal(t, 160*time.Second, cfg.UnifiedAlerting.EvaluationTimeout)
require.Equal(t, SchedulerBaseInterval, cfg.UnifiedAlerting.BaseInterval)
- require.Equal(t, 120*time.Second, cfg.UnifiedAlerting.DefaultAlertForDuration)
+ require.Equal(t, 120*time.Second, cfg.UnifiedAlerting.DefaultRuleEvaluationInterval)
},
},
{
@@ -119,7 +119,7 @@ func TestUnifiedAlertingSettings(t *testing.T) {
require.Equal(t, schedulereDefaultExecuteAlerts, cfg.UnifiedAlerting.ExecuteAlerts)
require.Equal(t, evaluatorDefaultEvaluationTimeout, cfg.UnifiedAlerting.EvaluationTimeout)
require.Equal(t, SchedulerBaseInterval, cfg.UnifiedAlerting.BaseInterval)
- require.Equal(t, DefaultAlertForDuration, cfg.UnifiedAlerting.DefaultAlertForDuration)
+ require.Equal(t, DefaultRuleEvaluationInterval, cfg.UnifiedAlerting.DefaultRuleEvaluationInterval)
},
},
{
@@ -143,7 +143,7 @@ func TestUnifiedAlertingSettings(t *testing.T) {
require.Equal(t, false, cfg.UnifiedAlerting.ExecuteAlerts)
require.Equal(t, 160*time.Second, cfg.UnifiedAlerting.EvaluationTimeout)
require.Equal(t, SchedulerBaseInterval, cfg.UnifiedAlerting.BaseInterval)
- require.Equal(t, 120*time.Second, cfg.UnifiedAlerting.DefaultAlertForDuration)
+ require.Equal(t, 120*time.Second, cfg.UnifiedAlerting.DefaultRuleEvaluationInterval)
},
},
}
@@ -227,10 +227,12 @@ func TestMinInterval(t *testing.T) {
},
},
{
- desc: "should adjust DefaultAlertForDuration to min interval if it is greater",
- minInterval: randPredicate(func(dur time.Duration) bool { return dur%SchedulerBaseInterval == 0 && dur > DefaultAlertForDuration }),
+ desc: "should adjust DefaultRuleEvaluationInterval to min interval if it is greater",
+ minInterval: randPredicate(func(dur time.Duration) bool {
+ return dur%SchedulerBaseInterval == 0 && dur > DefaultRuleEvaluationInterval
+ }),
verifyCfg: func(t *testing.T, cfg *Cfg, err error) {
- require.Equal(t, cfg.UnifiedAlerting.MinInterval, cfg.UnifiedAlerting.DefaultAlertForDuration)
+ require.Equal(t, cfg.UnifiedAlerting.MinInterval, cfg.UnifiedAlerting.DefaultRuleEvaluationInterval)
},
},
{
diff --git a/pkg/tests/api/alerting/api_alertmanager_test.go b/pkg/tests/api/alerting/api_alertmanager_test.go
index bd2fc42e7a0..5a0d21fe0a6 100644
--- a/pkg/tests/api/alerting/api_alertmanager_test.go
+++ b/pkg/tests/api/alerting/api_alertmanager_test.go
@@ -12,15 +12,15 @@ import (
"testing"
"time"
- "github.com/grafana/grafana/pkg/bus"
- "github.com/grafana/grafana/pkg/infra/tracing"
-
"github.com/prometheus/common/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+ "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson"
+ "github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/models"
+ "github.com/grafana/grafana/pkg/services/dashboards/database"
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
ngstore "github.com/grafana/grafana/pkg/services/ngalert/store"
@@ -2631,7 +2631,8 @@ func createFolder(t *testing.T, store *sqlstore.SQLStore, folderID int64, folder
"title": folderName,
}),
}
- f, err := store.SaveDashboard(cmd)
+ dashboardsStore := database.ProvideDashboardStore(store)
+ f, err := dashboardsStore.SaveDashboard(cmd)
if err != nil {
return "", err
diff --git a/pkg/tests/api/alerting/api_prometheus_test.go b/pkg/tests/api/alerting/api_prometheus_test.go
index ccaa162b0af..011443c546b 100644
--- a/pkg/tests/api/alerting/api_prometheus_test.go
+++ b/pkg/tests/api/alerting/api_prometheus_test.go
@@ -13,6 +13,7 @@ import (
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/models"
+ dashboardsstore "github.com/grafana/grafana/pkg/services/dashboards/database"
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/tests/testinfra"
@@ -634,6 +635,7 @@ func TestPrometheusRulesPermissions(t *testing.T) {
})
grafanaListedAddr, store := testinfra.StartGrafana(t, dir, path)
+ dashboardsStore := dashboardsstore.ProvideDashboardStore(store)
// override bus to get the GetSignedInUserQuery handler
store.Bus = bus.GetBus()
@@ -729,7 +731,7 @@ func TestPrometheusRulesPermissions(t *testing.T) {
}
// remove permissions from folder2
- require.NoError(t, store.UpdateDashboardACL(context.Background(), 2, nil))
+ require.NoError(t, dashboardsStore.UpdateDashboardACL(context.Background(), 2, nil))
// make sure that folder2 is not included in the response
{
@@ -778,7 +780,7 @@ func TestPrometheusRulesPermissions(t *testing.T) {
}
// remove permissions from _ALL_ folders
- require.NoError(t, store.UpdateDashboardACL(context.Background(), 1, nil))
+ require.NoError(t, dashboardsStore.UpdateDashboardACL(context.Background(), 1, nil))
// make sure that no folders are included in the response
{
diff --git a/pkg/tests/api/alerting/api_ruler_test.go b/pkg/tests/api/alerting/api_ruler_test.go
index a90dcdaabca..c3576ac77c6 100644
--- a/pkg/tests/api/alerting/api_ruler_test.go
+++ b/pkg/tests/api/alerting/api_ruler_test.go
@@ -13,6 +13,7 @@ import (
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/models"
+ dashboardsstore "github.com/grafana/grafana/pkg/services/dashboards/database"
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/tests/testinfra"
@@ -34,6 +35,7 @@ func TestAlertRulePermissions(t *testing.T) {
})
grafanaListedAddr, store := testinfra.StartGrafana(t, dir, path)
+ dashboardsStore := dashboardsstore.ProvideDashboardStore(store)
// override bus to get the GetSignedInUserQuery handler
store.Bus = bus.GetBus()
@@ -180,7 +182,7 @@ func TestAlertRulePermissions(t *testing.T) {
assert.JSONEq(t, expectedGetNamespaceResponseBody, body)
// remove permissions from folder2
- require.NoError(t, store.UpdateDashboardACL(context.Background(), 2, nil))
+ require.NoError(t, dashboardsStore.UpdateDashboardACL(context.Background(), 2, nil))
// make sure that folder2 is not included in the response
// nolint:gosec
@@ -253,7 +255,7 @@ func TestAlertRulePermissions(t *testing.T) {
}
// Remove permissions from ALL folders.
- require.NoError(t, store.UpdateDashboardACL(context.Background(), 1, nil))
+ require.NoError(t, dashboardsStore.UpdateDashboardACL(context.Background(), 1, nil))
{
u := fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules", grafanaListedAddr)
// nolint:gosec
diff --git a/pkg/tests/api/metrics/api_metrics_test.go b/pkg/tests/api/metrics/api_metrics_test.go
index 02d6cea7383..c23ad995701 100644
--- a/pkg/tests/api/metrics/api_metrics_test.go
+++ b/pkg/tests/api/metrics/api_metrics_test.go
@@ -32,7 +32,7 @@ func TestQueryCloudWatchMetrics(t *testing.T) {
grafDir, cfgPath := testinfra.CreateGrafDir(t)
addr, sqlStore := testinfra.StartGrafana(t, grafDir, cfgPath)
- setUpDatabase(t, sqlStore)
+ setUpDatabase(t, sqlStore, "metrics")
origNewCWClient := cloudwatch.NewCWClient
t.Cleanup(func() {
@@ -56,47 +56,27 @@ func TestQueryCloudWatchMetrics(t *testing.T) {
},
},
}
+ result := getCWMetrics(t, 1, addr)
- req := dtos.MetricRequest{
- Queries: []*simplejson.Json{
- simplejson.NewFromAny(map[string]interface{}{
- "type": "metricFindQuery",
- "subtype": "metrics",
- "region": "us-east-1",
- "namespace": "custom",
- "datasourceId": 1,
- }),
- },
+ type suggestData struct {
+ Text string
+ Value string
+ Label string
}
- result := makeCWRequest(t, req, addr)
-
- dataFrames := data.Frames{
- &data.Frame{
- RefID: "A",
- Fields: []*data.Field{
- data.NewField("text", nil, []string{"Test_MetricName"}),
- data.NewField("value", nil, []string{"Test_MetricName"}),
- },
- Meta: &data.FrameMeta{
- Custom: map[string]interface{}{
- "rowCount": float64(1),
- },
- },
- },
+ expect := []suggestData{
+ {Text: "Test_MetricName", Value: "Test_MetricName", Label: "Test_MetricName"},
}
-
- expect := backend.NewQueryDataResponse()
- expect.Responses["A"] = backend.DataResponse{
- Frames: dataFrames,
- }
- assert.Equal(t, *expect, result)
+ actual := []suggestData{}
+ err := json.Unmarshal(result, &actual)
+ require.NoError(t, err)
+ assert.Equal(t, expect, actual)
})
}
func TestQueryCloudWatchLogs(t *testing.T) {
grafDir, cfgPath := testinfra.CreateGrafDir(t)
addr, store := testinfra.StartGrafana(t, grafDir, cfgPath)
- setUpDatabase(t, store)
+ setUpDatabase(t, store, "logs")
origNewCWLogsClient := cloudwatch.NewCWLogsClient
t.Cleanup(func() {
@@ -141,6 +121,28 @@ func TestQueryCloudWatchLogs(t *testing.T) {
})
}
+func getCWMetrics(t *testing.T, datasourceId int, addr string) []byte {
+ t.Helper()
+
+ u := fmt.Sprintf("http://%s/api/datasources/%v/resources/metrics?region=us-east-1&namespace=custom", addr, datasourceId)
+ t.Logf("Making GET request to %s", u)
+ // nolint:gosec
+ resp, err := http.Get(u)
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ t.Cleanup(func() {
+ err := resp.Body.Close()
+ assert.NoError(t, err)
+ })
+
+ buf := bytes.Buffer{}
+ _, err = io.Copy(&buf, resp.Body)
+ require.NoError(t, err)
+ require.Equal(t, 200, resp.StatusCode)
+
+ return buf.Bytes()
+}
+
func makeCWRequest(t *testing.T, req dtos.MetricRequest, addr string) backend.QueryDataResponse {
t.Helper()
@@ -171,12 +173,13 @@ func makeCWRequest(t *testing.T, req dtos.MetricRequest, addr string) backend.Qu
return tr
}
-func setUpDatabase(t *testing.T, store *sqlstore.SQLStore) {
+func setUpDatabase(t *testing.T, store *sqlstore.SQLStore, uid string) {
t.Helper()
err := store.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error {
_, err := sess.Insert(&models.DataSource{
- Id: 1,
+ Id: 1,
+ Uid: uid,
// This will be the ID of the main org
OrgId: 2,
Name: "Test",
diff --git a/pkg/tsdb/azuremonitor/azure-resource-graph-datasource.go b/pkg/tsdb/azuremonitor/azure-resource-graph-datasource.go
index 6d12302416a..c501fa9e057 100644
--- a/pkg/tsdb/azuremonitor/azure-resource-graph-datasource.go
+++ b/pkg/tsdb/azuremonitor/azure-resource-graph-datasource.go
@@ -235,7 +235,7 @@ func (e *AzureResourceGraphDatasource) unmarshalResponse(res *http.Response) (Az
if res.StatusCode/100 != 2 {
azlog.Debug("Request failed", "status", res.Status, "body", string(body))
- return AzureResourceGraphResponse{}, fmt.Errorf("request failed, status: %s, body: %s", res.Status, string(body))
+ return AzureResourceGraphResponse{}, fmt.Errorf("%s. Azure Resource Graph error: %s", res.Status, string(body))
}
var data AzureResourceGraphResponse
diff --git a/pkg/tsdb/azuremonitor/azure-resource-graph-datasource_test.go b/pkg/tsdb/azuremonitor/azure-resource-graph-datasource_test.go
index cbba2109faa..42466777a9d 100644
--- a/pkg/tsdb/azuremonitor/azure-resource-graph-datasource_test.go
+++ b/pkg/tsdb/azuremonitor/azure-resource-graph-datasource_test.go
@@ -2,7 +2,9 @@ package azuremonitor
import (
"context"
+ "io"
"net/http"
+ "strings"
"testing"
"time"
@@ -150,3 +152,46 @@ func TestGetAzurePortalUrl(t *testing.T) {
assert.Equal(t, expectedAzurePortalUrl[cloud], azurePortalUrl)
}
}
+
+func TestUnmarshalResponse400(t *testing.T) {
+ datasource := &AzureResourceGraphDatasource{}
+ res, err := datasource.unmarshalResponse(&http.Response{
+ StatusCode: 400,
+ Status: "400 Bad Request",
+ Body: io.NopCloser(strings.NewReader(("Azure Error Message"))),
+ })
+
+ expectedErrMsg := "400 Bad Request. Azure Resource Graph error: Azure Error Message"
+
+ assert.Equal(t, expectedErrMsg, err.Error())
+ assert.Empty(t, res)
+}
+
+func TestUnmarshalResponse200Invalid(t *testing.T) {
+ datasource := &AzureResourceGraphDatasource{}
+ res, err := datasource.unmarshalResponse(&http.Response{
+ StatusCode: 200,
+ Status: "OK",
+ Body: io.NopCloser(strings.NewReader(("Azure Data"))),
+ })
+
+ expectedRes := AzureResourceGraphResponse{}
+ expectedErr := "invalid character 'A' looking for beginning of value"
+
+ assert.Equal(t, expectedErr, err.Error())
+ assert.Equal(t, expectedRes, res)
+}
+
+func TestUnmarshalResponse200(t *testing.T) {
+ datasource := &AzureResourceGraphDatasource{}
+ res, err2 := datasource.unmarshalResponse(&http.Response{
+ StatusCode: 200,
+ Status: "OK",
+ Body: io.NopCloser(strings.NewReader("{}")),
+ })
+
+ expectedRes := AzureResourceGraphResponse{}
+
+ assert.NoError(t, err2)
+ assert.Equal(t, expectedRes, res)
+}
diff --git a/pkg/tsdb/cloudmonitoring/test-data/9-series-response-distribution-without-points.json b/pkg/tsdb/cloudmonitoring/test-data/9-series-response-distribution-without-points.json
new file mode 100644
index 00000000000..453d3913f01
--- /dev/null
+++ b/pkg/tsdb/cloudmonitoring/test-data/9-series-response-distribution-without-points.json
@@ -0,0 +1,17 @@
+{
+ "timeSeries": [
+ {
+ "metric": {
+ "type": "loadbalancing.googleapis.com\/https\/backend_latencies"
+ },
+ "resource": {
+ "type": "https_lb_rule",
+ "labels": {
+ "project_id": "grafana-prod"
+ }
+ },
+ "metricKind": "DELTA",
+ "valueType": "DISTRIBUTION"
+ }
+ ]
+}
diff --git a/pkg/tsdb/cloudmonitoring/time_series_filter.go b/pkg/tsdb/cloudmonitoring/time_series_filter.go
index 818565f858b..2f924899e08 100644
--- a/pkg/tsdb/cloudmonitoring/time_series_filter.go
+++ b/pkg/tsdb/cloudmonitoring/time_series_filter.go
@@ -193,6 +193,9 @@ func (timeSeriesFilter *cloudMonitoringTimeSeriesFilter) parseResponse(queryRes
valueField,
},
RefID: timeSeriesFilter.RefID,
+ Meta: &data.FrameMeta{
+ ExecutedQueryString: executedQueryString,
+ },
}
if maxKey < i {
@@ -220,13 +223,20 @@ func (timeSeriesFilter *cloudMonitoringTimeSeriesFilter) parseResponse(queryRes
timeField,
valueField,
},
+ Meta: &data.FrameMeta{
+ ExecutedQueryString: executedQueryString,
+ },
}
}
}
}
for i := 0; i < len(buckets); i++ {
+ buckets[i].Meta.Custom = customFrameMeta
frames = append(frames, buckets[i])
}
+ if len(buckets) == 0 {
+ frames = append(frames, frame)
+ }
}
if len(response.TimeSeries) > 0 {
dl := timeSeriesFilter.buildDeepLink()
diff --git a/pkg/tsdb/cloudmonitoring/time_series_filter_test.go b/pkg/tsdb/cloudmonitoring/time_series_filter_test.go
index 909652a3731..72812087e51 100644
--- a/pkg/tsdb/cloudmonitoring/time_series_filter_test.go
+++ b/pkg/tsdb/cloudmonitoring/time_series_filter_test.go
@@ -11,6 +11,8 @@ import (
"time"
"github.com/grafana/grafana-plugin-sdk-go/backend"
+ sdkdata "github.com/grafana/grafana-plugin-sdk-go/data"
+
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -386,6 +388,77 @@ func TestTimeSeriesFilter(t *testing.T) {
require.True(t, ok)
assert.Equal(t, "114250375703598695", labels["resource.label.instance_id"])
})
+
+ t.Run("parseResponse successfully parses metadata for distribution valueType", func(t *testing.T) {
+ t.Run("exponential bounds", func(t *testing.T) {
+ data, err := loadTestFile("./test-data/3-series-response-distribution-exponential.json")
+ require.NoError(t, err)
+ assert.Equal(t, 1, len(data.TimeSeries))
+
+ res := &backend.DataResponse{}
+ require.NoError(t, (&cloudMonitoringTimeSeriesFilter{GroupBys: []string{"test_group_by"}}).parseResponse(res, data, "test_query"))
+
+ require.NotNil(t, res.Frames[0].Meta)
+ assert.Equal(t, sdkdata.FrameMeta{
+ ExecutedQueryString: "test_query",
+ Custom: map[string]interface{}{
+ "groupBys": []string{"test_group_by"},
+ "alignmentPeriod": "",
+ "labels": map[string]string{
+ "resource.label.project_id": "grafana-prod",
+ "resource.type": "https_lb_rule",
+ },
+ "perSeriesAligner": "",
+ },
+ }, *res.Frames[0].Meta)
+ })
+
+ t.Run("explicit bounds", func(t *testing.T) {
+ data, err := loadTestFile("./test-data/4-series-response-distribution-explicit.json")
+ require.NoError(t, err)
+ assert.Equal(t, 1, len(data.TimeSeries))
+
+ res := &backend.DataResponse{}
+ require.NoError(t, (&cloudMonitoringTimeSeriesFilter{GroupBys: []string{"test_group_by"}}).parseResponse(res, data, "test_query"))
+
+ require.NotNil(t, res.Frames[0].Meta)
+ assert.Equal(t, sdkdata.FrameMeta{
+ ExecutedQueryString: "test_query",
+ Custom: map[string]interface{}{
+ "groupBys": []string{"test_group_by"},
+ "alignmentPeriod": "",
+ "labels": map[string]string{
+ "resource.label.project_id": "grafana-demo",
+ "resource.type": "global",
+ },
+ "perSeriesAligner": "",
+ },
+ }, *res.Frames[0].Meta)
+ })
+
+ t.Run("without series points", func(t *testing.T) {
+ data, err := loadTestFile("./test-data/3-series-response-distribution-exponential.json")
+ require.NoError(t, err)
+ assert.Equal(t, 1, len(data.TimeSeries))
+
+ res := &backend.DataResponse{}
+ require.NoError(t, (&cloudMonitoringTimeSeriesFilter{GroupBys: []string{"test_group_by"}}).parseResponse(res, data, "test_query"))
+
+ require.NotNil(t, res.Frames[0].Meta)
+ assert.Equal(t, sdkdata.FrameMeta{
+ ExecutedQueryString: "test_query",
+ Custom: map[string]interface{}{
+ "groupBys": []string{"test_group_by"},
+ "alignmentPeriod": "",
+ "labels": map[string]string{
+ "resource.label.project_id": "grafana-prod",
+ "resource.type": "https_lb_rule",
+ },
+ "perSeriesAligner": "",
+ },
+ }, *res.Frames[0].Meta)
+ })
+ })
}
func loadTestFile(path string) (cloudMonitoringResponse, error) {
diff --git a/pkg/tsdb/cloudwatch/cloudwatch.go b/pkg/tsdb/cloudwatch/cloudwatch.go
index 64799982122..a2d65a23bde 100644
--- a/pkg/tsdb/cloudwatch/cloudwatch.go
+++ b/pkg/tsdb/cloudwatch/cloudwatch.go
@@ -23,6 +23,7 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/backend/datasource"
"github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt"
+ "github.com/grafana/grafana-plugin-sdk-go/backend/resource/httpadapter"
"github.com/grafana/grafana-plugin-sdk-go/data"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/httpclient"
@@ -83,11 +84,13 @@ type SessionCache interface {
}
func newExecutor(im instancemgmt.InstanceManager, cfg *setting.Cfg, sessions SessionCache) *cloudWatchExecutor {
- return &cloudWatchExecutor{
+ cwe := &cloudWatchExecutor{
im: im,
cfg: cfg,
sessions: sessions,
}
+ cwe.resourceHandler = httpadapter.New(cwe.newResourceMux())
+ return cwe
}
func NewInstanceSettings(httpClientProvider httpclient.Provider) datasource.InstanceFactoryFunc {
@@ -158,6 +161,12 @@ type cloudWatchExecutor struct {
im instancemgmt.InstanceManager
cfg *setting.Cfg
sessions SessionCache
+
+ resourceHandler backend.CallResourceHandler
+}
+
+func (e *cloudWatchExecutor) CallResource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error {
+ return e.resourceHandler.CallResource(ctx, req, sender)
}
func (e *cloudWatchExecutor) newSession(pluginCtx backend.PluginContext, region string) (*session.Session, error) {
@@ -283,8 +292,6 @@ func (e *cloudWatchExecutor) QueryData(ctx context.Context, req *backend.QueryDa
var result *backend.QueryDataResponse
switch queryType {
- case "metricFindQuery":
- result, err = e.executeMetricFindQuery(req.PluginContext, model, q)
case "annotationQuery":
result, err = e.executeAnnotationQuery(req.PluginContext, model, q)
case "logAction":
diff --git a/pkg/tsdb/cloudwatch/metric_find_query.go b/pkg/tsdb/cloudwatch/metric_find_query.go
index d4ec36f073d..0284f73d996 100644
--- a/pkg/tsdb/cloudwatch/metric_find_query.go
+++ b/pkg/tsdb/cloudwatch/metric_find_query.go
@@ -1,8 +1,10 @@
package cloudwatch
import (
+ "encoding/json"
"errors"
"fmt"
+ "net/url"
"reflect"
"sort"
"strings"
@@ -15,15 +17,14 @@ import (
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/aws/aws-sdk-go/service/resourcegroupstaggingapi"
"github.com/grafana/grafana-plugin-sdk-go/backend"
- "github.com/grafana/grafana-plugin-sdk-go/data"
- "github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/metrics"
"github.com/grafana/grafana/pkg/util/errutil"
)
type suggestData struct {
- Text string
- Value string
+ Text string `json:"text"`
+ Value string `json:"value"`
+ Label string `json:"label,omitempty"`
}
type customMetricsCache struct {
@@ -36,61 +37,6 @@ var customMetricsDimensionsMap = make(map[string]map[string]map[string]*customMe
var regionCache sync.Map
-func (e *cloudWatchExecutor) executeMetricFindQuery(pluginCtx backend.PluginContext, model *simplejson.Json, query backend.DataQuery) (*backend.QueryDataResponse, error) {
- subType := model.Get("subtype").MustString()
-
- var data []suggestData
- var err error
- switch subType {
- case "regions":
- data, err = e.handleGetRegions(pluginCtx)
- case "namespaces":
- data, err = e.handleGetNamespaces(pluginCtx)
- case "metrics":
- data, err = e.handleGetMetrics(pluginCtx, model)
- case "all_metrics":
- data, err = e.handleGetAllMetrics()
- case "dimension_keys":
- data, err = e.handleGetDimensions(pluginCtx, model)
- case "dimension_values":
- data, err = e.handleGetDimensionValues(pluginCtx, model)
- case "ebs_volume_ids":
- data, err = e.handleGetEbsVolumeIds(pluginCtx, model)
- case "ec2_instance_attribute":
- data, err = e.handleGetEc2InstanceAttribute(pluginCtx, model)
- case "resource_arns":
- data, err = e.handleGetResourceArns(pluginCtx, model)
- }
- if err != nil {
- return nil, err
- }
-
- resp := backend.NewQueryDataResponse()
- respD := resp.Responses[query.RefID]
- respD.Frames = append(respD.Frames, transformToTable(data))
- resp.Responses[query.RefID] = respD
-
- return resp, nil
-}
-
-func transformToTable(d []suggestData) *data.Frame {
- frame := data.NewFrame("",
- data.NewField("text", nil, []string{}),
- data.NewField("value", nil, []string{}))
-
- for _, r := range d {
- frame.AppendRow(r.Text, r.Value)
- }
-
- frame.Meta = &data.FrameMeta{
- Custom: map[string]interface{}{
- "rowCount": len(d),
- },
- }
-
- return frame
-}
-
func parseMultiSelectValue(input string) []string {
trimmedInput := strings.TrimSpace(input)
if strings.HasPrefix(trimmedInput, "{") {
@@ -107,7 +53,7 @@ func parseMultiSelectValue(input string) []string {
// Whenever this list is updated, the frontend list should also be updated.
// Please update the region list in public/app/plugins/datasource/cloudwatch/partials/config.html
-func (e *cloudWatchExecutor) handleGetRegions(pluginCtx backend.PluginContext) ([]suggestData, error) {
+func (e *cloudWatchExecutor) handleGetRegions(pluginCtx backend.PluginContext, parameters url.Values) ([]suggestData, error) {
dsInfo, err := e.getDSInfo(pluginCtx)
if err != nil {
return nil, err
@@ -149,14 +95,14 @@ func (e *cloudWatchExecutor) handleGetRegions(pluginCtx backend.PluginContext) (
result := make([]suggestData, 0)
for _, region := range regions {
- result = append(result, suggestData{Text: region, Value: region})
+ result = append(result, suggestData{Text: region, Value: region, Label: region})
}
regionCache.Store(profile, result)
return result, nil
}
-func (e *cloudWatchExecutor) handleGetNamespaces(pluginCtx backend.PluginContext) ([]suggestData, error) {
+func (e *cloudWatchExecutor) handleGetNamespaces(pluginCtx backend.PluginContext, parameters url.Values) ([]suggestData, error) {
var keys []string
for key := range metricsMap {
keys = append(keys, key)
@@ -175,15 +121,15 @@ func (e *cloudWatchExecutor) handleGetNamespaces(pluginCtx backend.PluginContext
result := make([]suggestData, 0)
for _, key := range keys {
- result = append(result, suggestData{Text: key, Value: key})
+ result = append(result, suggestData{Text: key, Value: key, Label: key})
}
return result, nil
}
-func (e *cloudWatchExecutor) handleGetMetrics(pluginCtx backend.PluginContext, parameters *simplejson.Json) ([]suggestData, error) {
- region := parameters.Get("region").MustString()
- namespace := parameters.Get("namespace").MustString()
+func (e *cloudWatchExecutor) handleGetMetrics(pluginCtx backend.PluginContext, parameters url.Values) ([]suggestData, error) {
+ region := parameters.Get("region")
+ namespace := parameters.Get("namespace")
var namespaceMetrics []string
if !isCustomMetrics(namespace) {
@@ -201,32 +147,40 @@ func (e *cloudWatchExecutor) handleGetMetrics(pluginCtx backend.PluginContext, p
result := make([]suggestData, 0)
for _, name := range namespaceMetrics {
- result = append(result, suggestData{Text: name, Value: name})
+ result = append(result, suggestData{Text: name, Value: name, Label: name})
}
return result, nil
}
// handleGetAllMetrics returns a slice of suggestData structs with metric and its namespace
-func (e *cloudWatchExecutor) handleGetAllMetrics() ([]suggestData, error) {
+func (e *cloudWatchExecutor) handleGetAllMetrics(pluginCtx backend.PluginContext, parameters url.Values) ([]suggestData, error) {
result := make([]suggestData, 0)
for namespace, metrics := range metricsMap {
for _, metric := range metrics {
- result = append(result, suggestData{Text: namespace, Value: metric})
+ result = append(result, suggestData{Text: namespace, Value: metric, Label: namespace})
}
}
return result, nil
}
-// handleGetDimensions returns a slice of suggestData structs with dimension keys.
+// handleGetDimensionKeys returns a slice of suggestData structs with dimension keys.
// If a dimension filters parameter is specified, a new api call to list metrics will be issued to load dimension keys for the given filter.
// If no dimension filter is specified, dimension keys will be retrieved from the hard coded map in this file.
-func (e *cloudWatchExecutor) handleGetDimensions(pluginCtx backend.PluginContext, parameters *simplejson.Json) ([]suggestData, error) {
- region := parameters.Get("region").MustString()
- namespace := parameters.Get("namespace").MustString()
- metricName := parameters.Get("metricName").MustString("")
- dimensionFilters := parameters.Get("dimensionFilters").MustMap()
+func (e *cloudWatchExecutor) handleGetDimensionKeys(pluginCtx backend.PluginContext, parameters url.Values) ([]suggestData, error) {
+ region := parameters.Get("region")
+ namespace := parameters.Get("namespace")
+ metricName := parameters.Get("metricName")
+ dimensionFilterJson := parameters.Get("dimensionFilters")
+
+ dimensionFilters := map[string]interface{}{}
+ if dimensionFilterJson != "" {
+ err := json.Unmarshal([]byte(dimensionFilterJson), &dimensionFilters)
+ if err != nil {
+ return nil, fmt.Errorf("error unmarshaling dimensionFilters: %v", err)
+ }
+ }
var dimensionValues []string
if !isCustomMetrics(namespace) {
@@ -303,7 +257,7 @@ func (e *cloudWatchExecutor) handleGetDimensions(pluginCtx backend.PluginContext
result := make([]suggestData, 0)
for _, name := range dimensionValues {
- result = append(result, suggestData{Text: name, Value: name})
+ result = append(result, suggestData{Text: name, Value: name, Label: name})
}
return result, nil
@@ -311,12 +265,18 @@ func (e *cloudWatchExecutor) handleGetDimensions(pluginCtx backend.PluginContext
// handleGetDimensionValues returns a slice of suggestData structs with dimension values.
// A call to the list metrics api is issued to retrieve the dimension values. All parameters are used as input args to the list metrics call.
-func (e *cloudWatchExecutor) handleGetDimensionValues(pluginCtx backend.PluginContext, parameters *simplejson.Json) ([]suggestData, error) {
- region := parameters.Get("region").MustString()
- namespace := parameters.Get("namespace").MustString()
- metricName := parameters.Get("metricName").MustString()
- dimensionKey := parameters.Get("dimensionKey").MustString()
- dimensionsJson := parameters.Get("dimensions").MustMap()
+func (e *cloudWatchExecutor) handleGetDimensionValues(pluginCtx backend.PluginContext, parameters url.Values) ([]suggestData, error) {
+ region := parameters.Get("region")
+ namespace := parameters.Get("namespace")
+ metricName := parameters.Get("metricName")
+ dimensionKey := parameters.Get("dimensionKey")
+ dimensionsJson := parameters.Get("dimensions")
+
+ dimensionsValues := map[string]interface{}{}
+ err := json.Unmarshal([]byte(dimensionsJson), &dimensionsValues)
+ if err != nil {
+ return nil, fmt.Errorf("error unmarshaling dimension: %v", err)
+ }
var dimensions []*cloudwatch.DimensionFilter
addDimension := func(key string, value string) {
@@ -329,8 +289,8 @@ func (e *cloudWatchExecutor) handleGetDimensionValues(pluginCtx backend.PluginCo
}
dimensions = append(dimensions, filter)
}
- for k, v := range dimensionsJson {
- // due to legacy, value can be a string, a string slice or nil
+
+ for k, v := range dimensionsValues {
if vv, ok := v.(string); ok {
addDimension(k, vv)
} else if vv, ok := v.([]interface{}); ok {
@@ -364,7 +324,7 @@ func (e *cloudWatchExecutor) handleGetDimensionValues(pluginCtx backend.PluginCo
}
dupCheck[*dim.Value] = true
- result = append(result, suggestData{Text: *dim.Value, Value: *dim.Value})
+ result = append(result, suggestData{Text: *dim.Value, Value: *dim.Value, Label: *dim.Value})
}
}
}
@@ -376,9 +336,9 @@ func (e *cloudWatchExecutor) handleGetDimensionValues(pluginCtx backend.PluginCo
return result, nil
}
-func (e *cloudWatchExecutor) handleGetEbsVolumeIds(pluginCtx backend.PluginContext, parameters *simplejson.Json) ([]suggestData, error) {
- region := parameters.Get("region").MustString()
- instanceId := parameters.Get("instanceId").MustString()
+func (e *cloudWatchExecutor) handleGetEbsVolumeIds(pluginCtx backend.PluginContext, parameters url.Values) ([]suggestData, error) {
+ region := parameters.Get("region")
+ instanceId := parameters.Get("instanceId")
instanceIds := aws.StringSlice(parseMultiSelectValue(instanceId))
instances, err := e.ec2DescribeInstances(pluginCtx, region, nil, instanceIds)
@@ -390,7 +350,7 @@ func (e *cloudWatchExecutor) handleGetEbsVolumeIds(pluginCtx backend.PluginConte
for _, reservation := range instances.Reservations {
for _, instance := range reservation.Instances {
for _, mapping := range instance.BlockDeviceMappings {
- result = append(result, suggestData{Text: *mapping.Ebs.VolumeId, Value: *mapping.Ebs.VolumeId})
+ result = append(result, suggestData{Text: *mapping.Ebs.VolumeId, Value: *mapping.Ebs.VolumeId, Label: *mapping.Ebs.VolumeId})
}
}
}
@@ -398,13 +358,19 @@ func (e *cloudWatchExecutor) handleGetEbsVolumeIds(pluginCtx backend.PluginConte
return result, nil
}
-func (e *cloudWatchExecutor) handleGetEc2InstanceAttribute(pluginCtx backend.PluginContext, parameters *simplejson.Json) ([]suggestData, error) {
- region := parameters.Get("region").MustString()
- attributeName := parameters.Get("attributeName").MustString()
- filterJson := parameters.Get("filters").MustMap()
+func (e *cloudWatchExecutor) handleGetEc2InstanceAttribute(pluginCtx backend.PluginContext, parameters url.Values) ([]suggestData, error) {
+ region := parameters.Get("region")
+ attributeName := parameters.Get("attributeName")
+ filterJson := parameters.Get("filters")
+
+ filterMap := map[string]interface{}{}
+ err := json.Unmarshal([]byte(filterJson), &filterMap)
+ if err != nil {
+ return nil, fmt.Errorf("error unmarshaling filter: %v", err)
+ }
var filters []*ec2.Filter
- for k, v := range filterJson {
+ for k, v := range filterMap {
if vv, ok := v.([]interface{}); ok {
var values []*string
for _, vvv := range vv {
@@ -466,7 +432,7 @@ func (e *cloudWatchExecutor) handleGetEc2InstanceAttribute(pluginCtx backend.Plu
}
dupCheck[data] = true
- result = append(result, suggestData{Text: data, Value: data})
+ result = append(result, suggestData{Text: data, Value: data, Label: data})
}
}
@@ -477,13 +443,19 @@ func (e *cloudWatchExecutor) handleGetEc2InstanceAttribute(pluginCtx backend.Plu
return result, nil
}
-func (e *cloudWatchExecutor) handleGetResourceArns(pluginCtx backend.PluginContext, parameters *simplejson.Json) ([]suggestData, error) {
- region := parameters.Get("region").MustString()
- resourceType := parameters.Get("resourceType").MustString()
- filterJson := parameters.Get("tags").MustMap()
+func (e *cloudWatchExecutor) handleGetResourceArns(pluginCtx backend.PluginContext, parameters url.Values) ([]suggestData, error) {
+ region := parameters.Get("region")
+ resourceType := parameters.Get("resourceType")
+ tagsJson := parameters.Get("tags")
+
+ tagsMap := map[string]interface{}{}
+ err := json.Unmarshal([]byte(tagsJson), &tagsMap)
+ if err != nil {
+ return nil, fmt.Errorf("error unmarshaling filter: %v", err)
+ }
var filters []*resourcegroupstaggingapi.TagFilter
- for k, v := range filterJson {
+ for k, v := range tagsMap {
if vv, ok := v.([]interface{}); ok {
var values []*string
for _, vvv := range vv {
@@ -509,7 +481,7 @@ func (e *cloudWatchExecutor) handleGetResourceArns(pluginCtx backend.PluginConte
result := make([]suggestData, 0)
for _, resource := range resources.ResourceTagMappingList {
data := *resource.ResourceARN
- result = append(result, suggestData{Text: data, Value: data})
+ result = append(result, suggestData{Text: data, Value: data, Label: data})
}
return result, nil
diff --git a/pkg/tsdb/cloudwatch/metric_find_query_test.go b/pkg/tsdb/cloudwatch/metric_find_query_test.go
index 705bb04c328..ebc0e25da01 100644
--- a/pkg/tsdb/cloudwatch/metric_find_query_test.go
+++ b/pkg/tsdb/cloudwatch/metric_find_query_test.go
@@ -1,8 +1,8 @@
package cloudwatch
import (
- "context"
"encoding/json"
+ "net/url"
"testing"
"github.com/aws/aws-sdk-go/aws"
@@ -54,40 +54,20 @@ func TestQuery_Metrics(t *testing.T) {
})
executor := newExecutor(im, newTestConfig(), fakeSessionCache{})
- resp, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{
- PluginContext: backend.PluginContext{
+ resp, err := executor.handleGetMetrics(
+ backend.PluginContext{
DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{},
+ }, url.Values{
+ "region": []string{"us-east-1"},
+ "namespace": []string{"custom"},
},
- Queries: []backend.DataQuery{
- {
- JSON: json.RawMessage(`{
- "type": "metricFindQuery",
- "subtype": "metrics",
- "region": "us-east-1",
- "namespace": "custom"
- }`),
- },
- },
- })
+ )
require.NoError(t, err)
- expFrame := data.NewFrame(
- "",
- data.NewField("text", nil, []string{"Test_MetricName"}),
- data.NewField("value", nil, []string{"Test_MetricName"}),
- )
- expFrame.Meta = &data.FrameMeta{
- Custom: map[string]interface{}{
- "rowCount": 1,
- },
+ expResponse := []suggestData{
+ {Text: "Test_MetricName", Value: "Test_MetricName", Label: "Test_MetricName"},
}
-
- assert.Equal(t, &backend.QueryDataResponse{Responses: backend.Responses{
- "": {
- Frames: data.Frames{expFrame},
- },
- },
- }, resp)
+ assert.Equal(t, expResponse, resp)
})
t.Run("Dimension keys for custom metrics", func(t *testing.T) {
@@ -109,39 +89,20 @@ func TestQuery_Metrics(t *testing.T) {
})
executor := newExecutor(im, newTestConfig(), fakeSessionCache{})
- resp, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{
- PluginContext: backend.PluginContext{
+ resp, err := executor.handleGetDimensionKeys(
+ backend.PluginContext{
DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{},
+ }, url.Values{
+ "region": []string{"us-east-1"},
+ "namespace": []string{"custom"},
},
- Queries: []backend.DataQuery{
- {
- JSON: json.RawMessage(`{
- "type": "metricFindQuery",
- "subtype": "dimension_keys",
- "region": "us-east-1",
- "namespace": "custom"
- }`),
- },
- },
- })
+ )
require.NoError(t, err)
- expFrame := data.NewFrame(
- "",
- data.NewField("text", nil, []string{"Test_DimensionName"}),
- data.NewField("value", nil, []string{"Test_DimensionName"}),
- )
- expFrame.Meta = &data.FrameMeta{
- Custom: map[string]interface{}{
- "rowCount": 1,
- },
+ expResponse := []suggestData{
+ {Text: "Test_DimensionName", Value: "Test_DimensionName", Label: "Test_DimensionName"},
}
- assert.Equal(t, &backend.QueryDataResponse{Responses: backend.Responses{
- "": {
- Frames: data.Frames{expFrame},
- },
- },
- }, resp)
+ assert.Equal(t, expResponse, resp)
})
}
@@ -168,21 +129,14 @@ func TestQuery_Regions(t *testing.T) {
})
executor := newExecutor(im, newTestConfig(), fakeSessionCache{})
- resp, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{
- PluginContext: backend.PluginContext{
+ resp, err := executor.handleGetRegions(
+ backend.PluginContext{
DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{},
+ }, url.Values{
+ "region": []string{"us-east-1"},
+ "namespace": []string{"custom"},
},
- Queries: []backend.DataQuery{
- {
- JSON: json.RawMessage(`{
- "type": "metricFindQuery",
- "subtype": "regions",
- "region": "us-east-1",
- "namespace": "custom"
- }`),
- },
- },
- })
+ )
require.NoError(t, err)
expRegions := append(knownRegions, regionName)
@@ -197,12 +151,11 @@ func TestQuery_Regions(t *testing.T) {
},
}
- assert.Equal(t, &backend.QueryDataResponse{Responses: backend.Responses{
- "": {
- Frames: data.Frames{expFrame},
- },
- },
- }, resp)
+ expResponse := []suggestData{}
+ for _, region := range expRegions {
+ expResponse = append(expResponse, suggestData{Text: region, Value: region, Label: region})
+ }
+ assert.Equal(t, expResponse, resp)
})
}
@@ -242,44 +195,28 @@ func TestQuery_InstanceAttributes(t *testing.T) {
return datasourceInfo{}, nil
})
- executor := newExecutor(im, newTestConfig(), fakeSessionCache{})
- resp, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{
- PluginContext: backend.PluginContext{
- DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{},
- },
- Queries: []backend.DataQuery{
- {
- JSON: json.RawMessage(`{
- "type": "metricFindQuery",
- "subtype": "ec2_instance_attribute",
- "region": "us-east-1",
- "attributeName": "InstanceId",
- "filters": {
- "tag:Environment": ["production"]
- }
- }`),
- },
- },
- })
+ filterMap := map[string][]string{
+ "tag:Environment": {"production"},
+ }
+ filterJson, err := json.Marshal(filterMap)
require.NoError(t, err)
- expFrame := data.NewFrame(
- "",
- data.NewField("text", nil, []string{instanceID}),
- data.NewField("value", nil, []string{instanceID}),
+ executor := newExecutor(im, newTestConfig(), fakeSessionCache{})
+ resp, err := executor.handleGetEc2InstanceAttribute(
+ backend.PluginContext{
+ DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{},
+ }, url.Values{
+ "region": []string{"us-east-1"},
+ "attributeName": []string{"InstanceId"},
+ "filters": []string{string(filterJson)},
+ },
)
- expFrame.Meta = &data.FrameMeta{
- Custom: map[string]interface{}{
- "rowCount": 1,
- },
- }
+ require.NoError(t, err)
- assert.Equal(t, &backend.QueryDataResponse{Responses: backend.Responses{
- "": {
- Frames: data.Frames{expFrame},
- },
- },
- }, resp)
+ expResponse := []suggestData{
+ {Text: instanceID, Value: instanceID, Label: instanceID},
+ }
+ assert.Equal(t, expResponse, resp)
})
}
@@ -342,41 +279,22 @@ func TestQuery_EBSVolumeIDs(t *testing.T) {
})
executor := newExecutor(im, newTestConfig(), fakeSessionCache{})
- resp, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{
- PluginContext: backend.PluginContext{
+ resp, err := executor.handleGetEbsVolumeIds(
+ backend.PluginContext{
DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{},
+ }, url.Values{
+ "region": []string{"us-east-1"},
+ "instanceId": []string{"{i-1, i-2, i-3}"},
},
- Queries: []backend.DataQuery{
- {
- JSON: json.RawMessage(`{
- "type": "metricFindQuery",
- "subtype": "ebs_volume_ids",
- "region": "us-east-1",
- "instanceId": "{i-1, i-2, i-3}"
- }`),
- },
- },
- })
+ )
require.NoError(t, err)
expValues := []string{"vol-1-1", "vol-1-2", "vol-2-1", "vol-2-2", "vol-3-1", "vol-3-2"}
- expFrame := data.NewFrame(
- "",
- data.NewField("text", nil, expValues),
- data.NewField("value", nil, expValues),
- )
- expFrame.Meta = &data.FrameMeta{
- Custom: map[string]interface{}{
- "rowCount": 6,
- },
+ expResponse := []suggestData{}
+ for _, value := range expValues {
+ expResponse = append(expResponse, suggestData{Text: value, Value: value, Label: value})
}
-
- assert.Equal(t, &backend.QueryDataResponse{Responses: backend.Responses{
- "": {
- Frames: data.Frames{expFrame},
- },
- },
- }, resp)
+ assert.Equal(t, expResponse, resp)
})
}
@@ -420,48 +338,33 @@ func TestQuery_ResourceARNs(t *testing.T) {
return datasourceInfo{}, nil
})
+ tagMap := map[string][]string{
+ "Environment": {"production"},
+ }
+ tagJson, err := json.Marshal(tagMap)
+ require.NoError(t, err)
+
executor := newExecutor(im, newTestConfig(), fakeSessionCache{})
- resp, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{
- PluginContext: backend.PluginContext{
+ resp, err := executor.handleGetResourceArns(
+ backend.PluginContext{
DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{},
+ }, url.Values{
+ "region": []string{"us-east-1"},
+ "resourceType": []string{"ec2:instance"},
+ "tags": []string{string(tagJson)},
},
- Queries: []backend.DataQuery{
- {
- JSON: json.RawMessage(`{
- "type": "metricFindQuery",
- "subtype": "resource_arns",
- "region": "us-east-1",
- "resourceType": "ec2:instance",
- "tags": {
- "Environment": ["production"]
- }
- }`),
- },
- },
- })
+ )
require.NoError(t, err)
expValues := []string{
"arn:aws:ec2:us-east-1:123456789012:instance/i-12345678901234567",
"arn:aws:ec2:us-east-1:123456789012:instance/i-76543210987654321",
}
- expFrame := data.NewFrame(
- "",
- data.NewField("text", nil, expValues),
- data.NewField("value", nil, expValues),
- )
- expFrame.Meta = &data.FrameMeta{
- Custom: map[string]interface{}{
- "rowCount": 2,
- },
+ expResponse := []suggestData{}
+ for _, value := range expValues {
+ expResponse = append(expResponse, suggestData{Text: value, Value: value, Label: value})
}
-
- assert.Equal(t, &backend.QueryDataResponse{Responses: backend.Responses{
- "": {
- Frames: data.Frames{expFrame},
- },
- },
- }, resp)
+ assert.Equal(t, expResponse, resp)
})
}
@@ -472,20 +375,14 @@ func TestQuery_GetAllMetrics(t *testing.T) {
})
executor := newExecutor(im, newTestConfig(), fakeSessionCache{})
- resp, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{
- PluginContext: backend.PluginContext{
+ resp, err := executor.handleGetAllMetrics(
+ backend.PluginContext{
DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{},
},
- Queries: []backend.DataQuery{
- {
- JSON: json.RawMessage(`{
- "type": "metricFindQuery",
- "subtype": "all_metrics",
- "region": "us-east-1"
- }`),
- },
+ url.Values{
+ "region": []string{"us-east-1"},
},
- })
+ )
require.NoError(t, err)
metricCount := 0
@@ -493,7 +390,7 @@ func TestQuery_GetAllMetrics(t *testing.T) {
metricCount += len(metrics)
}
- assert.Equal(t, metricCount, resp.Responses[""].Frames[0].Fields[1].Len())
+ assert.Equal(t, metricCount, len(resp))
})
}
@@ -527,46 +424,28 @@ func TestQuery_GetDimensionKeys(t *testing.T) {
})
executor := newExecutor(im, newTestConfig(), fakeSessionCache{})
- resp, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{
- PluginContext: backend.PluginContext{
+ resp, err := executor.handleGetDimensionKeys(
+ backend.PluginContext{
DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{},
},
- Queries: []backend.DataQuery{
- {
- JSON: json.RawMessage(`{
- "type": "metricFindQuery",
- "subtype": "dimension_keys",
- "region": "us-east-1",
- "namespace": "AWS/EC2",
- "dimensionFilters": {
- "InstanceId": "",
- "AutoscalingGroup": []
- }
- }`),
- },
+ url.Values{
+ "region": []string{"us-east-1"},
+ "namespace": []string{"AWS/EC2"},
+ "dimensionFilters": []string{`{
+ "InstanceId": "",
+ "AutoscalingGroup": []
+ }`},
},
- })
-
+ )
require.NoError(t, err)
expValues := []string{"Dimension1", "Dimension2", "Dimension3"}
- expFrame := data.NewFrame(
- "",
- data.NewField("text", nil, expValues),
- data.NewField("value", nil, expValues),
- )
- expFrame.Meta = &data.FrameMeta{
- Custom: map[string]interface{}{
- "rowCount": len(expValues),
- },
+ expResponse := []suggestData{}
+ for _, val := range expValues {
+ expResponse = append(expResponse, suggestData{val, val, val})
}
- assert.Equal(t, &backend.QueryDataResponse{Responses: backend.Responses{
- "": {
- Frames: data.Frames{expFrame},
- },
- },
- }, resp)
+ assert.Equal(t, expResponse, resp)
})
t.Run("should return hard coded metrics when no dimension filter is specified", func(t *testing.T) {
@@ -575,42 +454,25 @@ func TestQuery_GetDimensionKeys(t *testing.T) {
})
executor := newExecutor(im, newTestConfig(), fakeSessionCache{})
- resp, err := executor.QueryData(context.Background(), &backend.QueryDataRequest{
- PluginContext: backend.PluginContext{
+ resp, err := executor.handleGetDimensionKeys(
+ backend.PluginContext{
DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{},
},
- Queries: []backend.DataQuery{
- {
- JSON: json.RawMessage(`{
- "type": "metricFindQuery",
- "subtype": "dimension_keys",
- "region": "us-east-1",
- "namespace": "AWS/EC2",
- "dimensionFilters": {}
- }`),
- },
+ url.Values{
+ "region": []string{"us-east-1"},
+ "namespace": []string{"AWS/EC2"},
+ "dimensionFilters": []string{`{}`},
},
- })
+ )
require.NoError(t, err)
expValues := dimensionsMap["AWS/EC2"]
- expFrame := data.NewFrame(
- "",
- data.NewField("text", nil, expValues),
- data.NewField("value", nil, expValues),
- )
- expFrame.Meta = &data.FrameMeta{
- Custom: map[string]interface{}{
- "rowCount": len(expValues),
- },
+ expResponse := []suggestData{}
+ for _, val := range expValues {
+ expResponse = append(expResponse, suggestData{val, val, val})
}
- assert.Equal(t, &backend.QueryDataResponse{Responses: backend.Responses{
- "": {
- Frames: data.Frames{expFrame},
- },
- },
- }, resp)
+ assert.Equal(t, expResponse, resp)
})
}
func Test_isCustomMetrics(t *testing.T) {
diff --git a/pkg/tsdb/cloudwatch/metrics.go b/pkg/tsdb/cloudwatch/metrics.go
index 4ea31bbbc3d..6234f013913 100644
--- a/pkg/tsdb/cloudwatch/metrics.go
+++ b/pkg/tsdb/cloudwatch/metrics.go
@@ -325,75 +325,77 @@ var metricsMap = map[string][]string{
"TouchMisses",
"UnusedMemory",
},
- "AWS/ElasticBeanstalk": {"ApplicationLatencyP10", "ApplicationLatencyP50", "ApplicationLatencyP75", "ApplicationLatencyP85", "ApplicationLatencyP90", "ApplicationLatencyP95", "ApplicationLatencyP99", "ApplicationLatencyP99.9", "ApplicationRequests2xx", "ApplicationRequests3xx", "ApplicationRequests4xx", "ApplicationRequests5xx", "ApplicationRequestsTotal", "CPUIdle", "CPUIowait", "CPUIrq", "CPUNice", "CPUSoftirq", "CPUSystem", "CPUUser", "EnvironmentHealth", "InstanceHealth", "InstancesDegraded", "InstancesInfo", "InstancesNoData", "InstancesOk", "InstancesPending", "InstancesSevere", "InstancesUnknown", "InstancesWarning", "LoadAverage1min", "LoadAverage5min", "RootFilesystemUtil"},
- "AWS/ElasticInference": {"AcceleratorHealthCheckFailed", "AcceleratorMemoryUsage", "ConnectivityCheckFailed"},
- "AWS/ElasticMapReduce": {"AppsCompleted", "AppsFailed", "AppsKilled", "AppsPending", "AppsRunning", "AppsSubmitted", "BackupFailed", "CapacityRemainingGB", "Cluster Status", "ContainerAllocated", "ContainerPending", "ContainerPendingRatio", "ContainerReserved", "CoreNodesPending", "CoreNodesRunning", "CorruptBlocks", "DfsPendingReplicationBlocks", "HBase", "HDFSBytesRead", "HDFSBytesWritten", "HDFSUtilization", "HbaseBackupFailed", "IO", "IsIdle", "JobsFailed", "JobsRunning", "LiveDataNodes", "LiveTaskTrackers", "MRActiveNodes", "MRDecommissionedNodes", "MRLostNodes", "MRRebootedNodes", "MRTotalNodes", "MRUnhealthyNodes", "Map/Reduce", "MapSlotsOpen", "MapTasksRemaining", "MapTasksRunning", "MemoryAllocatedMB", "MemoryAvailableMB", "MemoryReservedMB", "MemoryTotalMB", "MissingBlocks", "MostRecentBackupDuration", "Node Status", "PendingDeletionBlocks", "ReduceSlotsOpen", "ReduceTasksRemaining", "ReduceTasksRunning", "RemainingMapTasksPerSlot", "S3BytesRead", "S3BytesWritten", "TaskNodesPending", "TaskNodesRunning", "TimeSinceLastSuccessfulBackup", "TotalLoad", "UnderReplicatedBlocks", "YARNMemoryAvailablePercentage"},
- "AWS/ElasticTranscoder": {"Billed Audio Output", "Billed HD Output", "Billed SD Output", "Errors", "Jobs Completed", "Jobs Errored", "Outputs per Job", "Standby Time", "Throttles"},
- "AWS/Events": {"DeadLetterInvocations", "FailedInvocations", "Invocations", "InvocationsFailedToBeSentToDlq", "InvocationsSentToDlq", "MatchedEvents", "ThrottledRules", "TriggeredRules"},
- "AWS/FSx": {"DataReadBytes", "DataReadOperations", "DataWriteBytes", "DataWriteOperations", "FreeDataStorageCapacity", "FreeStorageCapacity", "MetadataOperations"},
- "AWS/Firehose": {"BackupToS3.Bytes", "BackupToS3.DataFreshness", "BackupToS3.Records", "BackupToS3.Success", "DataReadFromKinesisStream.Bytes", "DataReadFromKinesisStream.Records", "DeliveryToElasticsearch.Bytes", "DeliveryToElasticsearch.Records", "DeliveryToElasticsearch.Success", "DeliveryToRedshift.Bytes", "DeliveryToRedshift.Records", "DeliveryToRedshift.Success", "DeliveryToS3.Bytes", "DeliveryToS3.DataFreshness", "DeliveryToS3.Records", "DeliveryToS3.Success", "DeliveryToSplunk.Bytes", "DeliveryToSplunk.DataFreshness", "DeliveryToSplunk.Records", "DeliveryToSplunk.Success", "DescribeDeliveryStream.Latency", "DescribeDeliveryStream.Requests", "ExecuteProcessing.Duration", "ExecuteProcessing.Success", "FailedConversion.Bytes", "FailedConversion.Records", "IncomingBytes", "IncomingRecords", "KinesisMillisBehindLatest", "ListDeliveryStreams.Latency", "ListDeliveryStreams.Requests", "PutRecord.Bytes", "PutRecord.Latency", "PutRecord.Requests", "PutRecordBatch.Bytes", "PutRecordBatch.Latency", "PutRecordBatch.Records", "PutRecordBatch.Requests", "SucceedConversion.Bytes", "SucceedConversion.Records", "SucceedProcessing.Bytes", "SucceedProcessing.Records", "ThrottledDescribeStream", "ThrottledGetRecords", "ThrottledGetShardIterator", "UpdateDeliveryStream.Latency", "UpdateDeliveryStream.Requests"},
- "AWS/GameLift": {"ActivatingGameSessions", "ActiveGameSessions", "ActiveInstances", "ActiveServerProcesses", "AvailableGameSessions", "AverageWaitTime", "CurrentPlayerSessions", "CurrentTickets", "DesiredInstances", "FirstChoiceNotViable", "FirstChoiceOutOfCapacity", "GameSessionInterruptions", "HealthyServerProcesses", "IdleInstances", "InstanceInterruptions", "LowestLatencyPlacement", "LowestPricePlacement", "MatchAcceptancesTimedOut", "MatchesAccepted", "MatchesCreated", "MatchesPlaced", "MatchesRejected", "MaxInstances", "MinInstances", "PercentAvailableGameSessions", "PercentHealthyServerProcesses", "PercentIdleInstances", "Placement", "PlacementsCanceled", "PlacementsFailed", "PlacementsStarted", "PlacementsSucceeded", "PlacementsTimedOut", "PlayerSessionActivations", "PlayersStarted", "QueueDepth", "RuleEvaluationsFailed", "RuleEvaluationsPassed", "ServerProcessAbnormalTerminations", "ServerProcessActivations", "ServerProcessTerminations", "TicketsFailed", "TicketsStarted", "TicketsTimedOut", "TimeToMatch", "TimeToTicketSuccess"},
- "AWS/GlobalAccelerator": {"NewFlowCount", "ProcessedBytesIn", "ProcessedBytesOut", "HealthyEndpointCount", "UnhealthyEndpointCount"},
- "AWS/Glue": {"glue.driver.BlockManager.disk.diskSpaceUsed_MB", "glue.driver.ExecutorAllocationManager.executors.numberAllExecutors", "glue.driver.ExecutorAllocationManager.executors.numberMaxNeededExecutors", "glue.driver.aggregate.bytesRead", "glue.driver.aggregate.elapsedTime", "glue.driver.aggregate.numCompletedStages", "glue.driver.aggregate.numCompletedTasks", "glue.driver.aggregate.numFailedTasks", "glue.driver.aggregate.numKilledTasks", "glue.driver.aggregate.recordsRead", "glue.driver.aggregate.shuffleBytesWritten", "glue.driver.aggregate.shuffleLocalBytesRead", "glue.driver.jvm.heap.usage glue.executorId.jvm.heap.usage glue.ALL.jvm.heap.usage", "glue.driver.jvm.heap.used glue.executorId.jvm.heap.used glue.ALL.jvm.heap.used", "glue.driver.s3.filesystem.read_bytes glue.executorId.s3.filesystem.read_bytes glue.ALL.s3.filesystem.read_bytes", "glue.driver.s3.filesystem.write_bytes glue.executorId.s3.filesystem.write_bytes glue.ALL.s3.filesystem.write_bytes", "glue.driver.system.cpuSystemLoad glue.executorId.system.cpuSystemLoad glue.ALL.system.cpuSystemLoad"},
- "AWS/GroundStation": {"BitErrorRate", "BlockErrorRate", "ReceivedPower", "Es/N0"},
- "AWS/Inspector": {"TotalAssessmentRunFindings", "TotalAssessmentRuns", "TotalHealthyAgents", "TotalMatchingAgents"},
- "AWS/IVS": {"ConcurrentViews", "ConcurrentStreams", "LiveDeliveredTime", "LiveInputTime", "RecordedTime"},
- "AWS/IoT": {"CanceledJobExecutionCount", "CanceledJobExecutionTotalCount", "ClientError", "Connect.AuthError", "Connect.ClientError", "Connect.ServerError", "Connect.Success", "Connect.Throttle", "DeleteThingShadow.Accepted", "FailedJobExecutionCount", "FailedJobExecutionTotalCount", "Failure", "GetThingShadow.Accepted", "InProgressJobExecutionCount", "InProgressJobExecutionTotalCount", "NonCompliantResources", "NumLogBatchesFailedToPublishThrottled", "NumLogEventsFailedToPublishThrottled", "ParseError", "Ping.Success", "PublishIn.AuthError", "PublishIn.ClientError", "PublishIn.ServerError", "PublishIn.Success", "PublishIn.Throttle", "PublishOut.AuthError", "PublishOut.ClientError", "PublishOut.Success", "QueuedJobExecutionCount", "QueuedJobExecutionTotalCount", "RejectedJobExecutionCount", "RejectedJobExecutionTotalCount", "RemovedJobExecutionCount", "RemovedJobExecutionTotalCount", "ResourcesEvaluated", "RuleMessageThrottled", "RuleNotFound", "RulesExecuted", "ServerError", "Subscribe.AuthError", "Subscribe.ClientError", "Subscribe.ServerError", "Subscribe.Success", "Subscribe.Throttle", "SuccededJobExecutionCount", "SuccededJobExecutionTotalCount", "Success", "TopicMatch", "Unsubscribe.ClientError", "Unsubscribe.ServerError", "Unsubscribe.Success", "Unsubscribe.Throttle", "UpdateThingShadow.Accepted", "Violations", "ViolationsCleared", "ViolationsInvalidated"},
- "AWS/IoTAnalytics": {"ActionExecution", "ActivityExecutionError", "IncomingMessages"},
- "AWS/KMS": {"SecondsUntilKeyMaterialExpiration"},
- "AWS/Kafka": {"ActiveControllerCount", "BytesInPerSec", "BytesOutPerSec", "CpuIdle", "CpuSystem", "CpuUser", "EstimatedMaxTimeLag", "EstimatedTimeLag", "FetchConsumerLocalTimeMsMean", "FetchConsumerRequestQueueTimeMsMean", "FetchConsumerResponseQueueTimeMsMean", "FetchConsumerResponseSendTimeMsMean", "FetchConsumerTotalTimeMsMean", "FetchFollowerLocalTimeMsMean", "FetchFollowerRequestQueueTimeMsMean", "FetchFollowerResponseQueueTimeMsMean", "FetchFollowerResponseSendTimeMsMean", "FetchFollowerTotalTimeMsMean", "FetchMessageConversionsPerSec", "FetchThrottleByteRate", "FetchThrottleQueueSize", "FetchThrottleTime", "GlobalPartitionCount", "GlobalTopicCount", "KafkaAppLogsDiskUsed", "KafkaDataLogsDiskUsed", "LeaderCount", "MaxOffsetLag", "MemoryBuffered", "MemoryCached", "MemoryFree", "MemoryUsed", "MessagesInPerSec", "NetworkProcessorAvgIdlePercent", "NetworkRxDropped", "NetworkRxErrors", "NetworkRxPackets", "NetworkTxDropped", "NetworkTxErrors", "NetworkTxPackets", "OfflinePartitionsCount", "PartitionCount", "ProduceLocalTimeMsMean", "ProduceMessageConversionsPerSec", "ProduceMessageConversionsTimeMsMean", "ProduceRequestQueueTimeMsMean", "ProduceResponseQueueTimeMsMean", "ProduceResponseSendTimeMsMean", "ProduceThrottleByteRate", "ProduceThrottleQueueSize", "ProduceThrottleTime", "ProduceTotalTimeMsMean", "ReplicationBytesInPerSec", "ReplicationBytesOutPerSec", "RequestBytesMean", "RequestExemptFromThrottleTime", "RequestHandlerAvgIdlePercent", "RequestThrottleQueueSize", "RequestThrottleTime", "RequestTime", "RootDiskUsed", "SumOffsetLag", "SwapFree", "SwapUsed", "OffsetLag", "UnderMinIsrPartitionCount", "UnderReplicatedPartitions", "ZooKeeperRequestLatencyMsMean", "ZooKeeperSessionState"},
- "AWS/Kinesis": {"GetRecords.Bytes", "GetRecords.IteratorAge", "GetRecords.IteratorAgeMilliseconds", "GetRecords.Latency", "GetRecords.Records", "GetRecords.Success", "IncomingBytes", "IncomingRecords", "IteratorAgeMilliseconds", "OutgoingBytes", "OutgoingRecords", "PutRecord.Bytes", "PutRecord.Latency", "PutRecord.Success", "PutRecords.Bytes", "PutRecords.Latency", "PutRecords.Records", "PutRecords.Success", "ReadProvisionedThroughputExceeded", "SubscribeToShard.RateExceeded", "SubscribeToShard.Success", "SubscribeToShardEvent.Bytes", "SubscribeToShardEvent.MillisBehindLatest", "SubscribeToShardEvent.Records", "SubscribeToShardEvent.Success", "WriteProvisionedThroughputExceeded"},
- "AWS/KinesisAnalytics": {"Bytes", "InputProcessing.DroppedRecords", "InputProcessing.Duration", "InputProcessing.OkBytes", "InputProcessing.OkRecords", "InputProcessing.ProcessingFailedRecords", "InputProcessing.Success", "KPUs", "LambdaDelivery.DeliveryFailedRecords", "LambdaDelivery.Duration", "LambdaDelivery.OkRecords", "MillisBehindLatest", "Records", "Success"},
- "AWS/KinesisVideo": {"GetHLSMasterPlaylist.Latency", "GetHLSMasterPlaylist.Requests", "GetHLSMasterPlaylist.Success", "GetHLSMediaPlaylist.Latency", "GetHLSMediaPlaylist.Requests", "GetHLSMediaPlaylist.Success", "GetHLSStreamingSessionURL.Latency", "GetHLSStreamingSessionURL.Requests", "GetHLSStreamingSessionURL.Success", "GetMP4InitFragment.Latency", "GetMP4InitFragment.Requests", "GetMP4InitFragment.Success", "GetMP4MediaFragment.Latency", "GetMP4MediaFragment.OutgoingBytes", "GetMP4MediaFragment.Requests", "GetMP4MediaFragment.Success", "GetMedia.ConnectionErrors", "GetMedia.MillisBehindNow", "GetMedia.OutgoingBytes", "GetMedia.OutgoingFragments", "GetMedia.OutgoingFrames", "GetMedia.Requests", "GetMedia.Success", "GetMediaForFragmentList.OutgoingBytes", "GetMediaForFragmentList.OutgoingFragments", "GetMediaForFragmentList.OutgoingFrames", "GetMediaForFragmentList.Requests", "GetMediaForFragmentList.Success", "GetTSFragment.Latency", "GetTSFragment.OutgoingBytes", "GetTSFragment.Requests", "GetTSFragment.Success", "ListFragments.Latency", "PutMedia.ActiveConnections", "PutMedia.BufferingAckLatency", "PutMedia.ConnectionErrors", "PutMedia.ErrorAckCount", "PutMedia.FragmentIngestionLatency", "PutMedia.FragmentPersistLatency", "PutMedia.IncomingBytes", "PutMedia.IncomingFragments", "PutMedia.IncomingFrames", "PutMedia.Latency", "PutMedia.PersistedAckLatency", "PutMedia.ReceivedAckLatency", "PutMedia.Requests", "PutMedia.Success"},
- "AWS/Lambda": {"ConcurrentExecutions", "DeadLetterErrors", "Duration", "Errors", "Invocations", "IteratorAge", "Throttles", "UnreservedConcurrentExecutions"},
- "AWS/Lex": {"BotChannelAuthErrors", "BotChannelConfigurationErrors", "BotChannelInboundThrottledEvents", "BotChannelOutboundThrottledEvents", "BotChannelRequestCount", "BotChannelResponseCardErrors", "BotChannelSystemErrors", "MissedUtteranceCount", "RuntimeInvalidLambdaResponses", "RuntimeLambdaErrors", "RuntimePollyErrors", "RuntimeRequestCount", "RuntimeSucessfulRequestLatency", "RuntimeSystemErrors", "RuntimeThrottledEvents", "RuntimeUserErrors"},
- "AWS/Logs": {"DeliveryErrors", "DeliveryThrottling", "ForwardedBytes", "ForwardedLogEvents", "IncomingBytes", "IncomingLogEvents"},
- "AWS/LookoutMetrics": {"ExecutionsStarted", "ExecutionsSucceeded", "ExecutionsFailed", "Delivered", "Undelivered"},
- "AWS/ML": {"PredictCount", "PredictFailureCount"},
- "AWS/MediaConnect": {"ARQRecovered", "ARQRequests", "BitRate", "CATError", "CRCError", "Connected", "ConnectedOutputs", "ContinuityCounter", "Disconnections", "DroppedPackets", "FECPackets", "FECRecovered", "NotRecoveredPackets", "OutputConnected", "OutputDisconnections", "OverflowPackets", "PATError", "PCRAccuracyError", "PCRError", "PIDError", "PMTError", "PTSError", "PacketLossPercent", "RecoveredPackets", "RoundTripTime", "SourceARQRecovered", "SourceARQRequests", "SourceBitRate", "SourceCATError", "SourceCRCError", "SourceConnected", "SourceContinuityCounter", "SourceDisconnections", "SourceDroppedPackets", "SourceFECPackets", "SourceFECRecovered", "SourceNotRecoveredPackets", "SourceOverflowPackets", "SourcePATError", "SourcePCRAccuracyError", "SourcePCRError", "SourcePIDError", "SourcePMTError", "SourcePTSError", "SourcePacketLossPercent", "SourceRecoveredPackets", "SourceRoundTripTime", "SourceTSByteError", "SourceTSSyncLoss", "SourceTotalPackets", "SourceTransportError", "TSByteError", "TSSyncLoss", "TotalPackets", "TransportError"},
- "AWS/MediaConvert": {"AudioOutputSeconds", "Errors", "HDOutputSeconds", "JobsCompletedCount", "JobsErroredCount", "SDOutputSeconds", "StandbyTime", "TranscodingTime", "UHDOutputSeconds"},
- "AWS/MediaPackage": {"ActiveInput", "EgressBytes", "EgressRequestCount", "EgressResponseTime", "IngressBytes", "IngressResponseTime"},
- "AWS/MediaStore": {"RequestCount", "4xxErrorCount", "5xxErrorCount", "BytesUploaded", "BytesDownloaded", "TotalTime", "TurnaroundTime"},
- "AWS/MediaTailor": {"AdDecisionServer.Ads", "AdDecisionServer.Duration", "AdDecisionServer.Errors", "AdDecisionServer.FillRate", "AdDecisionServer.Timeouts", "AdNotReady", "Avails.Duration", "Avails.FillRate", "Avails.FilledDuration", "GetManifest.Errors", "Origin.Errors", "Origin.Timeouts"},
- "AWS/NATGateway": {"ActiveConnectionCount", "BytesInFromDestination", "BytesInFromSource", "BytesOutToDestination", "BytesOutToSource", "ConnectionAttemptCount", "ConnectionEstablishedCount", "ErrorPortAllocation", "IdleTimeoutCount", "PacketsDropCount", "PacketsInFromDestination", "PacketsInFromSource", "PacketsOutToDestination", "PacketsOutToSource"},
- "AWS/Neptune": {"CPUUtilization", "ClusterReplicaLag", "ClusterReplicaLagMaximum", "ClusterReplicaLagMinimum", "EngineUptime", "FreeLocalStorage", "FreeableMemory", "GremlinErrors", "GremlinHttp1xx", "GremlinHttp2xx", "GremlinHttp4xx", "GremlinHttp5xx", "GremlinRequests", "GremlinRequestsPerSec", "GremlinWebSocketAvailableConnections", "GremlinWebSocketClientErrors", "GremlinWebSocketServerErrors", "GremlinWebSocketSuccess", "Http100", "Http101", "Http1xx", "Http200", "Http2xx", "Http400", "Http403", "Http405", "Http413", "Http429", "Http4xx", "Http500", "Http501", "Http5xx", "LoaderErrors", "LoaderRequests", "NetworkReceiveThroughput", "NetworkThroughput", "NetworkTransmitThroughput", "SparqlErrors", "SparqlHttp1xx", "SparqlHttp2xx", "SparqlHttp4xx", "SparqlHttp5xx", "SparqlRequests", "SparqlRequestsPerSec", "StatusErrors", "StatusRequests", "VolumeBytesUsed", "VolumeReadIOPs", "VolumeWriteIOPs"},
- "AWS/NetworkELB": {"ActiveFlowCount", "ActiveFlowCount_TLS", "ClientTLSNegotiationErrorCount", "ConsumedLCUs", "HealthyHostCount", "NewFlowCount", "NewFlowCount_TLS", "ProcessedBytes", "ProcessedBytes_TLS", "TCP_Client_Reset_Count", "TCP_ELB_Reset_Count", "TCP_Target_Reset_Count", "TargetTLSNegotiationErrorCount", "UnHealthyHostCount"},
- "AWS/GatewayELB": {"ActiveFlowCount", "ConsumedLCUs", "HealthyHostCount", "NewFlowCount", "ProcessedBytes", "UnHealthyHostCount"},
- "AWS/NetworkFirewall": {"DroppedPackets", "Packets", "PassedPackets", "ReceivedPacketCount"},
- "AWS/OpsWorks": {"cpu_idle", "cpu_nice", "cpu_steal", "cpu_system", "cpu_user", "cpu_waitio", "load_1", "load_15", "load_5", "memory_buffers", "memory_cached", "memory_free", "memory_swap", "memory_total", "memory_used", "procs"},
- "AWS/Polly": {"2XXCount", "4XXCount", "5XXCount", "RequestCharacters", "ResponseLatency"},
- "AWS/RDS": {"ActiveTransactions", "AuroraBinlogReplicaLag", "AuroraGlobalDBDataTransferBytes", "AuroraGlobalDBReplicatedWriteIO", "AuroraGlobalDBReplicationLag", "AuroraReplicaLag", "AuroraReplicaLagMaximum", "AuroraReplicaLagMinimum", "AvailabilityPercentage", "BacktrackChangeRecordsCreationRate", "BacktrackChangeRecordsStored", "BacktrackWindowActual", "BacktrackWindowAlert", "BackupRetentionPeriodStorageUsed", "BinLogDiskUsage", "BlockedTransactions", "BufferCacheHitRatio", "BurstBalance", "CPUCreditBalance", "CPUCreditUsage", "CPUUtilization", "ClientConnections", "ClientConnectionsClosed", "ClientConnectionsNoTLS", "ClientConnectionsReceived", "ClientConnectionsSetupFailedAuth", "ClientConnectionsSetupSucceeded", "ClientConnectionsTLS", "CommitLatency", "CommitThroughput", "DDLLatency", "DDLThroughput", "DMLLatency", "DMLThroughput", "DatabaseConnectionRequests", "DatabaseConnectionRequestsWithTLS", "DatabaseConnections", "DatabaseConnectionsBorrowLatency", "DatabaseConnectionsCurrentlyBorrowed", "DatabaseConnectionsCurrentlyInTransaction", "DatabaseConnectionsCurrentlySessionPinned", "DatabaseConnectionsSetupFailed", "DatabaseConnectionsSetupSucceeded", "DatabaseConnectionsWithTLS", "Deadlocks", "DeleteLatency", "DeleteThroughput", "DiskQueueDepth", "EngineUptime", "FailedSQLServerAgentJobsCount", "FreeLocalStorage", "FreeStorageSpace", "FreeableMemory", "InsertLatency", "InsertThroughput", "LoginFailures", "MaxDatabaseConnectionsAllowed", "MaximumUsedTransactionIDs", "NetworkReceiveThroughput", "NetworkThroughput", "NetworkTransmitThroughput", "OldestReplicationSlotLag", "Queries", "QueryDatabaseResponseLatency", "QueryRequests", "QueryRequestsNoTLS", "QueryRequestsTLS", "QueryResponseLatency", "RDSToAuroraPostgreSQLReplicaLag", "ReadIOPS", "ReadLatency", "ReadThroughput", "ReplicaLag", "ReplicationSlotDiskUsage", "ResultSetCacheHitRatio", "SelectLatency", "SelectThroughput", "ServerlessDatabaseCapacity", "SnapshotStorageUsed", "SwapUsage", "TotalBackupStorageBilled", "TransactionLogsDiskUsage", "TransactionLogsGeneration", "UpdateLatency", "UpdateThroughput", "VolumeBytesUsed", "VolumeReadIOPs", "VolumeWriteIOPs", "WriteIOPS", "WriteLatency", "WriteThroughput"},
- "AWS/Redshift": {"CommitQueueLength", "ConcurrencyScalingActiveClusters", "ConcurrencyScalingSeconds", "CPUUtilization", "DatabaseConnections", "HealthStatus", "MaintenanceMode", "MaxConfiguredConcurrencyScalingClusters", "NetworkReceiveThroughput", "NetworkTransmitThroughput", "PercentageDiskSpaceUsed", "QueriesCompletedPerSecond", "QueryDuration", "QueryRuntimeBreakdown", "ReadIOPS", "ReadLatency", "ReadThroughput", "TotalTableCount", "WLMQueueLength", "WLMQueueWaitTime", "WLMQueriesCompletedPerSecond", "WLMQueryDuration", "WLMRunningQueries", "WriteIOPS", "WriteLatency", "WriteThroughput", "SchemaQuota", "NumExceededSchemaQuotas", "StorageUsed", "PercentageQuotaUsed"},
- "AWS/Robomaker": {"RealTimeFactor", "vCPU", "Memory", "SimulationUnit"},
- "AWS/Route53": {"ChildHealthCheckHealthyCount", "ConnectionTime", "DNSQueries", "HealthCheckPercentageHealthy", "HealthCheckStatus", "SSLHandshakeTime", "TimeToFirstByte"},
- "AWS/Route53Resolver": {"InboundQueryVolume", "OutboundQueryVolume", "OutboundQueryAggregatedVolume"},
- "AWS/S3": {"4xxErrors", "5xxErrors", "AllRequests", "BucketSizeBytes", "BytesDownloaded", "BytesUploaded", "DeleteRequests", "FirstByteLatency", "GetRequests", "HeadRequests", "ListRequests", "NumberOfObjects", "PostRequests", "PutRequests", "SelectRequests", "SelectReturnedBytes", "SelectScannedBytes", "TotalRequestLatency"},
- "AWS/SDKMetrics": {"CallCount", "ClientErrorCount", "EndToEndLatency", "ConnectionErrorCount", "ServerErrorCount", "ThrottleCount"},
- "AWS/ServiceCatalog": {"ProvisionedProductLaunch"},
- "AWS/SES": {"Bounce", "Clicks", "Complaint", "Delivery", "Opens", "Reject", "Rendering Failures", "Reputation.BounceRate", "Reputation.ComplaintRate", "Send"},
- "AWS/SNS": {"NumberOfMessagesPublished", "NumberOfNotificationsDelivered", "NumberOfNotificationsFailed", "NumberOfNotificationsFilteredOut", "NumberOfNotificationsFilteredOut-InvalidAttributes", "NumberOfNotificationsFilteredOut-NoMessageAttributes", "PublishSize", "SMSMonthToDateSpentUSD", "SMSSuccessRate"},
- "AWS/SQS": {"ApproximateAgeOfOldestMessage", "ApproximateNumberOfMessagesDelayed", "ApproximateNumberOfMessagesNotVisible", "ApproximateNumberOfMessagesVisible", "NumberOfEmptyReceives", "NumberOfMessagesDeleted", "NumberOfMessagesReceived", "NumberOfMessagesSent", "SentMessageSize"},
- "AWS/SWF": {"ActivityTaskScheduleToCloseTime", "ActivityTaskScheduleToStartTime", "ActivityTaskStartToCloseTime", "ActivityTasksCanceled", "ActivityTasksCompleted", "ActivityTasksFailed", "ConsumedCapacity", "DecisionTaskScheduleToStartTime", "DecisionTaskStartToCloseTime", "DecisionTasksCompleted", "PendingTasks", "ProvisionedBucketSize", "ProvisionedRefillRate", "ScheduledActivityTasksTimedOutOnClose", "ScheduledActivityTasksTimedOutOnStart", "StartedActivityTasksTimedOutOnClose", "StartedActivityTasksTimedOutOnHeartbeat", "StartedDecisionTasksTimedOutOnClose", "ThrottledEvents", "WorkflowStartToCloseTime", "WorkflowsCanceled", "WorkflowsCompleted", "WorkflowsContinuedAsNew", "WorkflowsFailed", "WorkflowsTerminated", "WorkflowsTimedOut"},
- "AWS/SageMaker": {"CPUUtilization", "DatasetObjectsAutoAnnotated", "DatasetObjectsHumanAnnotated", "DatasetObjectsLabelingFailed", "DiskUtilization", "GPUMemoryUtilization", "GPUUtilization", "Invocation4XXErrors", "Invocation5XXErrors", "Invocations", "InvocationsPerInstance", "JobsFailed", "JobsStopped", "JobsSucceeded", "MemoryUtilization", "ModelLatency", "OverheadLatency", "TotalDatasetObjectsLabeled"},
- "AWS/States": {"ActivitiesFailed", "ActivitiesHeartbeatTimedOut", "ActivitiesScheduled", "ActivitiesStarted", "ActivitiesSucceeded", "ActivitiesTimedOut", "ActivityRunTime", "ActivityScheduleTime", "ActivityTime", "ConsumedCapacity", "ExecutionThrottled", "ExecutionTime", "ExecutionsAborted", "ExecutionsFailed", "ExecutionsStarted", "ExecutionsSucceeded", "ExecutionsTimedOut", "LambdaFunctionRunTime", "LambdaFunctionScheduleTime", "LambdaFunctionTime", "LambdaFunctionsFailed", "LambdaFunctionsHeartbeatTimedOut", "LambdaFunctionsScheduled", "LambdaFunctionsStarted", "LambdaFunctionsSucceeded", "LambdaFunctionsTimedOut", "ProvisionedBucketSize", "ProvisionedRefillRate", "ThrottledEvents"},
- "AWS/StorageGateway": {"CacheFree", "CacheHitPercent", "CachePercentDirty", "CachePercentUsed", "CacheUsed", "CloudBytesDownloaded", "CloudBytesUploaded", "CloudDownloadLatency", "QueuedWrites", "ReadBytes", "ReadTime", "TimeSinceLastRecoveryPoint", "TotalCacheSize", "UploadBufferFree", "UploadBufferPercentUsed", "UploadBufferUsed", "WorkingStorageFree", "WorkingStoragePercentUsed", "WorkingStorageUsed", "WriteBytes", "WriteTime"},
- "AWS/Textract": {"ResponseTime", "ServerErrorCount", "SuccessfulRequestCount", "ThrottledCount", "UserErrorCount"},
- "AWS/Timestream": {"SuccessfulRequestLatency", "SystemErrors", "UserErrors", "DataScannedBytes"},
- "AWS/ThingsGraph": {"EventStoreQueueSize", "FlowExecutionTime", "FlowExecutionsFailed", "FlowExecutionsStarted", "FlowExecutionsSucceeded", "FlowStepExecutionTime", "FlowStepExecutionsFailed", "FlowStepExecutionsStarted", "FlowStepExecutionsSucceeded"},
- "AWS/TransitGateway": {"BytesIn", "BytesOut", "PacketDropCountBlackhole", "PacketDropCountNoRoute", "PacketsIn", "PacketsOut"},
- "AWS/Transfer": {"BytesIn", "BytesOut", "FilesIn", "FilesOut"},
- "AWS/Translate": {"CharacterCount", "ResponseTime", "ServerErrorCount", "SuccessfulRequestCount", "ThrottledCount", "UserErrorCount"},
- "AWS/TrustedAdvisor": {"GreenChecks", "RedChecks", "RedResources", "ServiceLimitUsage", "YellowChecks", "YellowResources"},
- "AWS/Usage": {"CallCount", "ResourceCount"},
- "AWS/VPN": {"TunnelDataIn", "TunnelDataOut", "TunnelState"},
- "AWS/WAF": {"AllowedRequests", "BlockedRequests", "CountedRequests", "PassedRequests"},
- "AWS/WAFV2": {"AllowedRequests", "BlockedRequests", "CountedRequests", "PassedRequests"},
- "AWS/WorkSpaces": {"Available", "ConnectionAttempt", "ConnectionFailure", "ConnectionSuccess", "InSessionLatency", "Maintenance", "SessionDisconnect", "SessionLaunchTime", "Stopped", "Unhealthy", "UserConnected"},
- "ECS/ContainerInsights": {"ContainerInstanceCount", "CpuUtilized", "CpuReserved", "DeploymentCount", "DesiredTaskCount", "MemoryUtilized", "MemoryReserved", "NetworkRxBytes", "NetworkTxBytes", "PendingTaskCount", "RunningTaskCount", "ServiceCount", "StorageReadBytes", "StorageWriteBytes", "TaskCount", "TaskSetCount", "instance_cpu_limit", "instance_cpu_reserved_capacity", "instance_cpu_usage_total", "instance_cpu_utilization", "instance_filesystem_utilization", "instance_memory_limit", "instance_memory_reserved_capacity", "instance_memory_utliization", "instance_memory_working_set", "instance_network_total_bytes", "instance_number_of_running_tasks"},
- "ContainerInsights": {"cluster_failed_node_count", "cluster_node_count", "namespace_number_of_running_pods", "node_cpu_limit", "node_cpu_reserved_capacity", "node_cpu_usage_total", "node_cpu_utilization", "node_filesystem_utilization", "node_memory_limit", "node_memory_reserved_capacity", "node_memory_utilization", "node_memory_working_set", "node_network_total_bytes", "node_number_of_running_containers", "node_number_of_running_pods", "pod_cpu_reserved_capacity", "pod_cpu_utilization", "pod_cpu_utilization_over_pod_limit", "pod_memory_reserved_capacity", "pod_memory_utilization", "pod_memory_utilization_over_pod_limit", "pod_number_of_container_restarts", "pod_network_rx_bytes", "pod_network_tx_bytes", "service_number_of_running_pods"},
- "AWS/Rekognition": {"DetectedFaceCount", "DetectedLabelCount", "ResponseTime", "ServerErrorCount", "SuccessfulRequestCount", "ThrottledCount", "UserErrorCount"},
- "AWS/Cassandra": {"AccountMaxReads", "AccountMaxTableLevelReads", "AccountMaxTableLevelWrites", "AccountMaxWrites", "AccountProvisionedReadCapacityUtilization", "AccountProvisionedWriteCapacityUtilization", "ConditionalCheckFailedRequests", "ConsumedReadCapacityUnits", "ConsumedWriteCapacityUnits", "MaxProvisionedTableReadCapacityUtilization", "MaxProvisionedTableWriteCapacityUtilization", "ReturnedItemCount", "ReturnedItemCountBySelect", "SuccessfulRequestCount", "SuccessfulRequestLatency", "SystemErrors", "UserErrors"},
- "AWS/AmplifyHosting": {"Requests", "BytesDownloaded", "BytesUploaded", "4XXErrors", "5XXErrors", "Latency"},
+ "AWS/ElasticBeanstalk": {"ApplicationLatencyP10", "ApplicationLatencyP50", "ApplicationLatencyP75", "ApplicationLatencyP85", "ApplicationLatencyP90", "ApplicationLatencyP95", "ApplicationLatencyP99", "ApplicationLatencyP99.9", "ApplicationRequests2xx", "ApplicationRequests3xx", "ApplicationRequests4xx", "ApplicationRequests5xx", "ApplicationRequestsTotal", "CPUIdle", "CPUIowait", "CPUIrq", "CPUNice", "CPUSoftirq", "CPUSystem", "CPUUser", "EnvironmentHealth", "InstanceHealth", "InstancesDegraded", "InstancesInfo", "InstancesNoData", "InstancesOk", "InstancesPending", "InstancesSevere", "InstancesUnknown", "InstancesWarning", "LoadAverage1min", "LoadAverage5min", "RootFilesystemUtil"},
+ "AWS/ElasticInference": {"AcceleratorHealthCheckFailed", "AcceleratorMemoryUsage", "ConnectivityCheckFailed"},
+ "AWS/ElasticMapReduce": {"AppsCompleted", "AppsFailed", "AppsKilled", "AppsPending", "AppsRunning", "AppsSubmitted", "BackupFailed", "CapacityRemainingGB", "Cluster Status", "ContainerAllocated", "ContainerPending", "ContainerPendingRatio", "ContainerReserved", "CoreNodesPending", "CoreNodesRunning", "CorruptBlocks", "DfsPendingReplicationBlocks", "HBase", "HDFSBytesRead", "HDFSBytesWritten", "HDFSUtilization", "HbaseBackupFailed", "IO", "IsIdle", "JobsFailed", "JobsRunning", "LiveDataNodes", "LiveTaskTrackers", "MRActiveNodes", "MRDecommissionedNodes", "MRLostNodes", "MRRebootedNodes", "MRTotalNodes", "MRUnhealthyNodes", "Map/Reduce", "MapSlotsOpen", "MapTasksRemaining", "MapTasksRunning", "MemoryAllocatedMB", "MemoryAvailableMB", "MemoryReservedMB", "MemoryTotalMB", "MissingBlocks", "MostRecentBackupDuration", "Node Status", "PendingDeletionBlocks", "ReduceSlotsOpen", "ReduceTasksRemaining", "ReduceTasksRunning", "RemainingMapTasksPerSlot", "S3BytesRead", "S3BytesWritten", "TaskNodesPending", "TaskNodesRunning", "TimeSinceLastSuccessfulBackup", "TotalLoad", "UnderReplicatedBlocks", "YARNMemoryAvailablePercentage"},
+ "AWS/ElasticTranscoder": {"Billed Audio Output", "Billed HD Output", "Billed SD Output", "Errors", "Jobs Completed", "Jobs Errored", "Outputs per Job", "Standby Time", "Throttles"},
+ "AWS/Events": {"DeadLetterInvocations", "FailedInvocations", "Invocations", "InvocationsFailedToBeSentToDlq", "InvocationsSentToDlq", "MatchedEvents", "ThrottledRules", "TriggeredRules"},
+ "AWS/FSx": {"DataReadBytes", "DataReadOperations", "DataWriteBytes", "DataWriteOperations", "FreeDataStorageCapacity", "FreeStorageCapacity", "MetadataOperations"},
+ "AWS/Firehose": {"BackupToS3.Bytes", "BackupToS3.DataFreshness", "BackupToS3.Records", "BackupToS3.Success", "DataReadFromKinesisStream.Bytes", "DataReadFromKinesisStream.Records", "DeliveryToElasticsearch.Bytes", "DeliveryToElasticsearch.Records", "DeliveryToElasticsearch.Success", "DeliveryToRedshift.Bytes", "DeliveryToRedshift.Records", "DeliveryToRedshift.Success", "DeliveryToS3.Bytes", "DeliveryToS3.DataFreshness", "DeliveryToS3.Records", "DeliveryToS3.Success", "DeliveryToSplunk.Bytes", "DeliveryToSplunk.DataFreshness", "DeliveryToSplunk.Records", "DeliveryToSplunk.Success", "DescribeDeliveryStream.Latency", "DescribeDeliveryStream.Requests", "ExecuteProcessing.Duration", "ExecuteProcessing.Success", "FailedConversion.Bytes", "FailedConversion.Records", "IncomingBytes", "IncomingRecords", "KinesisMillisBehindLatest", "ListDeliveryStreams.Latency", "ListDeliveryStreams.Requests", "PutRecord.Bytes", "PutRecord.Latency", "PutRecord.Requests", "PutRecordBatch.Bytes", "PutRecordBatch.Latency", "PutRecordBatch.Records", "PutRecordBatch.Requests", "SucceedConversion.Bytes", "SucceedConversion.Records", "SucceedProcessing.Bytes", "SucceedProcessing.Records", "ThrottledDescribeStream", "ThrottledGetRecords", "ThrottledGetShardIterator", "UpdateDeliveryStream.Latency", "UpdateDeliveryStream.Requests"},
+ "AWS/GameLift": {"ActivatingGameSessions", "ActiveGameSessions", "ActiveInstances", "ActiveServerProcesses", "AvailableGameSessions", "AverageWaitTime", "CurrentPlayerSessions", "CurrentTickets", "DesiredInstances", "FirstChoiceNotViable", "FirstChoiceOutOfCapacity", "GameSessionInterruptions", "HealthyServerProcesses", "IdleInstances", "InstanceInterruptions", "LowestLatencyPlacement", "LowestPricePlacement", "MatchAcceptancesTimedOut", "MatchesAccepted", "MatchesCreated", "MatchesPlaced", "MatchesRejected", "MaxInstances", "MinInstances", "PercentAvailableGameSessions", "PercentHealthyServerProcesses", "PercentIdleInstances", "Placement", "PlacementsCanceled", "PlacementsFailed", "PlacementsStarted", "PlacementsSucceeded", "PlacementsTimedOut", "PlayerSessionActivations", "PlayersStarted", "QueueDepth", "RuleEvaluationsFailed", "RuleEvaluationsPassed", "ServerProcessAbnormalTerminations", "ServerProcessActivations", "ServerProcessTerminations", "TicketsFailed", "TicketsStarted", "TicketsTimedOut", "TimeToMatch", "TimeToTicketSuccess"},
+ "AWS/GlobalAccelerator": {"NewFlowCount", "ProcessedBytesIn", "ProcessedBytesOut", "HealthyEndpointCount", "UnhealthyEndpointCount"},
+ "AWS/Glue": {"glue.driver.BlockManager.disk.diskSpaceUsed_MB", "glue.driver.ExecutorAllocationManager.executors.numberAllExecutors", "glue.driver.ExecutorAllocationManager.executors.numberMaxNeededExecutors", "glue.driver.aggregate.bytesRead", "glue.driver.aggregate.elapsedTime", "glue.driver.aggregate.numCompletedStages", "glue.driver.aggregate.numCompletedTasks", "glue.driver.aggregate.numFailedTasks", "glue.driver.aggregate.numKilledTasks", "glue.driver.aggregate.recordsRead", "glue.driver.aggregate.shuffleBytesWritten", "glue.driver.aggregate.shuffleLocalBytesRead", "glue.driver.jvm.heap.usage glue.executorId.jvm.heap.usage glue.ALL.jvm.heap.usage", "glue.driver.jvm.heap.used glue.executorId.jvm.heap.used glue.ALL.jvm.heap.used", "glue.driver.s3.filesystem.read_bytes glue.executorId.s3.filesystem.read_bytes glue.ALL.s3.filesystem.read_bytes", "glue.driver.s3.filesystem.write_bytes glue.executorId.s3.filesystem.write_bytes glue.ALL.s3.filesystem.write_bytes", "glue.driver.system.cpuSystemLoad glue.executorId.system.cpuSystemLoad glue.ALL.system.cpuSystemLoad"},
+ "AWS/GroundStation": {"BitErrorRate", "BlockErrorRate", "ReceivedPower", "Es/N0"},
+ "AWS/Inspector": {"TotalAssessmentRunFindings", "TotalAssessmentRuns", "TotalHealthyAgents", "TotalMatchingAgents"},
+ "AWS/IVS": {"ConcurrentViews", "ConcurrentStreams", "LiveDeliveredTime", "LiveInputTime", "RecordedTime"},
+ "AWS/IoT": {"CanceledJobExecutionCount", "CanceledJobExecutionTotalCount", "ClientError", "Connect.AuthError", "Connect.ClientError", "Connect.ServerError", "Connect.Success", "Connect.Throttle", "DeleteThingShadow.Accepted", "FailedJobExecutionCount", "FailedJobExecutionTotalCount", "Failure", "GetThingShadow.Accepted", "InProgressJobExecutionCount", "InProgressJobExecutionTotalCount", "NonCompliantResources", "NumLogBatchesFailedToPublishThrottled", "NumLogEventsFailedToPublishThrottled", "ParseError", "Ping.Success", "PublishIn.AuthError", "PublishIn.ClientError", "PublishIn.ServerError", "PublishIn.Success", "PublishIn.Throttle", "PublishOut.AuthError", "PublishOut.ClientError", "PublishOut.Success", "QueuedJobExecutionCount", "QueuedJobExecutionTotalCount", "RejectedJobExecutionCount", "RejectedJobExecutionTotalCount", "RemovedJobExecutionCount", "RemovedJobExecutionTotalCount", "ResourcesEvaluated", "RuleMessageThrottled", "RuleNotFound", "RulesExecuted", "ServerError", "Subscribe.AuthError", "Subscribe.ClientError", "Subscribe.ServerError", "Subscribe.Success", "Subscribe.Throttle", "SuccededJobExecutionCount", "SuccededJobExecutionTotalCount", "Success", "TopicMatch", "Unsubscribe.ClientError", "Unsubscribe.ServerError", "Unsubscribe.Success", "Unsubscribe.Throttle", "UpdateThingShadow.Accepted", "Violations", "ViolationsCleared", "ViolationsInvalidated"},
+ "AWS/IoTAnalytics": {"ActionExecution", "ActivityExecutionError", "IncomingMessages"},
+ "AWS/KMS": {"SecondsUntilKeyMaterialExpiration"},
+ "AWS/Kafka": {"ActiveControllerCount", "BytesInPerSec", "BytesOutPerSec", "CpuIdle", "CpuSystem", "CpuUser", "EstimatedMaxTimeLag", "EstimatedTimeLag", "FetchConsumerLocalTimeMsMean", "FetchConsumerRequestQueueTimeMsMean", "FetchConsumerResponseQueueTimeMsMean", "FetchConsumerResponseSendTimeMsMean", "FetchConsumerTotalTimeMsMean", "FetchFollowerLocalTimeMsMean", "FetchFollowerRequestQueueTimeMsMean", "FetchFollowerResponseQueueTimeMsMean", "FetchFollowerResponseSendTimeMsMean", "FetchFollowerTotalTimeMsMean", "FetchMessageConversionsPerSec", "FetchThrottleByteRate", "FetchThrottleQueueSize", "FetchThrottleTime", "GlobalPartitionCount", "GlobalTopicCount", "KafkaAppLogsDiskUsed", "KafkaDataLogsDiskUsed", "LeaderCount", "MaxOffsetLag", "MemoryBuffered", "MemoryCached", "MemoryFree", "MemoryUsed", "MessagesInPerSec", "NetworkProcessorAvgIdlePercent", "NetworkRxDropped", "NetworkRxErrors", "NetworkRxPackets", "NetworkTxDropped", "NetworkTxErrors", "NetworkTxPackets", "OfflinePartitionsCount", "PartitionCount", "ProduceLocalTimeMsMean", "ProduceMessageConversionsPerSec", "ProduceMessageConversionsTimeMsMean", "ProduceRequestQueueTimeMsMean", "ProduceResponseQueueTimeMsMean", "ProduceResponseSendTimeMsMean", "ProduceThrottleByteRate", "ProduceThrottleQueueSize", "ProduceThrottleTime", "ProduceTotalTimeMsMean", "ReplicationBytesInPerSec", "ReplicationBytesOutPerSec", "RequestBytesMean", "RequestExemptFromThrottleTime", "RequestHandlerAvgIdlePercent", "RequestThrottleQueueSize", "RequestThrottleTime", "RequestTime", "RootDiskUsed", "SumOffsetLag", "SwapFree", "SwapUsed", "OffsetLag", "UnderMinIsrPartitionCount", "UnderReplicatedPartitions", "ZooKeeperRequestLatencyMsMean", "ZooKeeperSessionState"},
+ "AWS/Kinesis": {"GetRecords.Bytes", "GetRecords.IteratorAge", "GetRecords.IteratorAgeMilliseconds", "GetRecords.Latency", "GetRecords.Records", "GetRecords.Success", "IncomingBytes", "IncomingRecords", "IteratorAgeMilliseconds", "OutgoingBytes", "OutgoingRecords", "PutRecord.Bytes", "PutRecord.Latency", "PutRecord.Success", "PutRecords.Bytes", "PutRecords.Latency", "PutRecords.Records", "PutRecords.Success", "ReadProvisionedThroughputExceeded", "SubscribeToShard.RateExceeded", "SubscribeToShard.Success", "SubscribeToShardEvent.Bytes", "SubscribeToShardEvent.MillisBehindLatest", "SubscribeToShardEvent.Records", "SubscribeToShardEvent.Success", "WriteProvisionedThroughputExceeded"},
+ "AWS/KinesisAnalytics": {"Bytes", "InputProcessing.DroppedRecords", "InputProcessing.Duration", "InputProcessing.OkBytes", "InputProcessing.OkRecords", "InputProcessing.ProcessingFailedRecords", "InputProcessing.Success", "KPUs", "LambdaDelivery.DeliveryFailedRecords", "LambdaDelivery.Duration", "LambdaDelivery.OkRecords", "MillisBehindLatest", "Records", "Success"},
+ "AWS/KinesisVideo": {"GetHLSMasterPlaylist.Latency", "GetHLSMasterPlaylist.Requests", "GetHLSMasterPlaylist.Success", "GetHLSMediaPlaylist.Latency", "GetHLSMediaPlaylist.Requests", "GetHLSMediaPlaylist.Success", "GetHLSStreamingSessionURL.Latency", "GetHLSStreamingSessionURL.Requests", "GetHLSStreamingSessionURL.Success", "GetMP4InitFragment.Latency", "GetMP4InitFragment.Requests", "GetMP4InitFragment.Success", "GetMP4MediaFragment.Latency", "GetMP4MediaFragment.OutgoingBytes", "GetMP4MediaFragment.Requests", "GetMP4MediaFragment.Success", "GetMedia.ConnectionErrors", "GetMedia.MillisBehindNow", "GetMedia.OutgoingBytes", "GetMedia.OutgoingFragments", "GetMedia.OutgoingFrames", "GetMedia.Requests", "GetMedia.Success", "GetMediaForFragmentList.OutgoingBytes", "GetMediaForFragmentList.OutgoingFragments", "GetMediaForFragmentList.OutgoingFrames", "GetMediaForFragmentList.Requests", "GetMediaForFragmentList.Success", "GetTSFragment.Latency", "GetTSFragment.OutgoingBytes", "GetTSFragment.Requests", "GetTSFragment.Success", "ListFragments.Latency", "PutMedia.ActiveConnections", "PutMedia.BufferingAckLatency", "PutMedia.ConnectionErrors", "PutMedia.ErrorAckCount", "PutMedia.FragmentIngestionLatency", "PutMedia.FragmentPersistLatency", "PutMedia.IncomingBytes", "PutMedia.IncomingFragments", "PutMedia.IncomingFrames", "PutMedia.Latency", "PutMedia.PersistedAckLatency", "PutMedia.ReceivedAckLatency", "PutMedia.Requests", "PutMedia.Success"},
+ "AWS/Lambda": {"ConcurrentExecutions", "DeadLetterErrors", "Duration", "Errors", "Invocations", "IteratorAge", "Throttles", "UnreservedConcurrentExecutions"},
+ "AWS/Lex": {"BotChannelAuthErrors", "BotChannelConfigurationErrors", "BotChannelInboundThrottledEvents", "BotChannelOutboundThrottledEvents", "BotChannelRequestCount", "BotChannelResponseCardErrors", "BotChannelSystemErrors", "MissedUtteranceCount", "RuntimeInvalidLambdaResponses", "RuntimeLambdaErrors", "RuntimePollyErrors", "RuntimeRequestCount", "RuntimeSucessfulRequestLatency", "RuntimeSystemErrors", "RuntimeThrottledEvents", "RuntimeUserErrors"},
+ "AWS/Logs": {"DeliveryErrors", "DeliveryThrottling", "ForwardedBytes", "ForwardedLogEvents", "IncomingBytes", "IncomingLogEvents"},
+ "AWS/LookoutMetrics": {"ExecutionsStarted", "ExecutionsSucceeded", "ExecutionsFailed", "Delivered", "Undelivered"},
+ "AWS/ML": {"PredictCount", "PredictFailureCount"},
+ "AWS/MediaConnect": {"ARQRecovered", "ARQRequests", "BitRate", "CATError", "CRCError", "Connected", "ConnectedOutputs", "ContinuityCounter", "Disconnections", "DroppedPackets", "FECPackets", "FECRecovered", "NotRecoveredPackets", "OutputConnected", "OutputDisconnections", "OverflowPackets", "PATError", "PCRAccuracyError", "PCRError", "PIDError", "PMTError", "PTSError", "PacketLossPercent", "RecoveredPackets", "RoundTripTime", "SourceARQRecovered", "SourceARQRequests", "SourceBitRate", "SourceCATError", "SourceCRCError", "SourceConnected", "SourceContinuityCounter", "SourceDisconnections", "SourceDroppedPackets", "SourceFECPackets", "SourceFECRecovered", "SourceNotRecoveredPackets", "SourceOverflowPackets", "SourcePATError", "SourcePCRAccuracyError", "SourcePCRError", "SourcePIDError", "SourcePMTError", "SourcePTSError", "SourcePacketLossPercent", "SourceRecoveredPackets", "SourceRoundTripTime", "SourceTSByteError", "SourceTSSyncLoss", "SourceTotalPackets", "SourceTransportError", "TSByteError", "TSSyncLoss", "TotalPackets", "TransportError"},
+ "AWS/MediaConvert": {"AudioOutputSeconds", "Errors", "HDOutputSeconds", "JobsCompletedCount", "JobsErroredCount", "SDOutputSeconds", "StandbyTime", "TranscodingTime", "UHDOutputSeconds"},
+ "AWS/MediaPackage": {"ActiveInput", "EgressBytes", "EgressRequestCount", "EgressResponseTime", "IngressBytes", "IngressResponseTime"},
+ "AWS/MediaStore": {"RequestCount", "4xxErrorCount", "5xxErrorCount", "BytesUploaded", "BytesDownloaded", "TotalTime", "TurnaroundTime"},
+ "AWS/MediaTailor": {"AdDecisionServer.Ads", "AdDecisionServer.Duration", "AdDecisionServer.Errors", "AdDecisionServer.FillRate", "AdDecisionServer.Timeouts", "AdNotReady", "Avails.Duration", "Avails.FillRate", "Avails.FilledDuration", "GetManifest.Errors", "Origin.Errors", "Origin.Timeouts"},
+ "AWS/NATGateway": {"ActiveConnectionCount", "BytesInFromDestination", "BytesInFromSource", "BytesOutToDestination", "BytesOutToSource", "ConnectionAttemptCount", "ConnectionEstablishedCount", "ErrorPortAllocation", "IdleTimeoutCount", "PacketsDropCount", "PacketsInFromDestination", "PacketsInFromSource", "PacketsOutToDestination", "PacketsOutToSource"},
+ "AWS/Neptune": {"CPUUtilization", "ClusterReplicaLag", "ClusterReplicaLagMaximum", "ClusterReplicaLagMinimum", "EngineUptime", "FreeLocalStorage", "FreeableMemory", "GremlinErrors", "GremlinHttp1xx", "GremlinHttp2xx", "GremlinHttp4xx", "GremlinHttp5xx", "GremlinRequests", "GremlinRequestsPerSec", "GremlinWebSocketAvailableConnections", "GremlinWebSocketClientErrors", "GremlinWebSocketServerErrors", "GremlinWebSocketSuccess", "Http100", "Http101", "Http1xx", "Http200", "Http2xx", "Http400", "Http403", "Http405", "Http413", "Http429", "Http4xx", "Http500", "Http501", "Http5xx", "LoaderErrors", "LoaderRequests", "NetworkReceiveThroughput", "NetworkThroughput", "NetworkTransmitThroughput", "SparqlErrors", "SparqlHttp1xx", "SparqlHttp2xx", "SparqlHttp4xx", "SparqlHttp5xx", "SparqlRequests", "SparqlRequestsPerSec", "StatusErrors", "StatusRequests", "VolumeBytesUsed", "VolumeReadIOPs", "VolumeWriteIOPs"},
+ "AWS/NetworkELB": {"ActiveFlowCount", "ActiveFlowCount_TLS", "ClientTLSNegotiationErrorCount", "ConsumedLCUs", "HealthyHostCount", "NewFlowCount", "NewFlowCount_TLS", "ProcessedBytes", "ProcessedBytes_TLS", "TCP_Client_Reset_Count", "TCP_ELB_Reset_Count", "TCP_Target_Reset_Count", "TargetTLSNegotiationErrorCount", "UnHealthyHostCount"},
+ "AWS/GatewayELB": {"ActiveFlowCount", "ConsumedLCUs", "HealthyHostCount", "NewFlowCount", "ProcessedBytes", "UnHealthyHostCount"},
+ "AWS/NetworkFirewall": {"DroppedPackets", "Packets", "PassedPackets", "ReceivedPacketCount"},
+ "AWS/OpsWorks": {"cpu_idle", "cpu_nice", "cpu_steal", "cpu_system", "cpu_user", "cpu_waitio", "load_1", "load_15", "load_5", "memory_buffers", "memory_cached", "memory_free", "memory_swap", "memory_total", "memory_used", "procs"},
+ "AWS/Polly": {"2XXCount", "4XXCount", "5XXCount", "RequestCharacters", "ResponseLatency"},
+ "AWS/PrivateLinkEndpoints": {"ActiveConnections", "BytesProcessed", "NewConnections", "PacketsDropped", "RstPacketsReceived"},
+ "AWS/PrivateLinkServices": {"ActiveConnections", "BytesProcessed", "EndpointsCount", "NewConnections", "RstPacketsReceived"},
+ "AWS/RDS": {"ActiveTransactions", "AuroraBinlogReplicaLag", "AuroraGlobalDBDataTransferBytes", "AuroraGlobalDBReplicatedWriteIO", "AuroraGlobalDBReplicationLag", "AuroraReplicaLag", "AuroraReplicaLagMaximum", "AuroraReplicaLagMinimum", "AvailabilityPercentage", "BacktrackChangeRecordsCreationRate", "BacktrackChangeRecordsStored", "BacktrackWindowActual", "BacktrackWindowAlert", "BackupRetentionPeriodStorageUsed", "BinLogDiskUsage", "BlockedTransactions", "BufferCacheHitRatio", "BurstBalance", "CPUCreditBalance", "CPUCreditUsage", "CPUUtilization", "ClientConnections", "ClientConnectionsClosed", "ClientConnectionsNoTLS", "ClientConnectionsReceived", "ClientConnectionsSetupFailedAuth", "ClientConnectionsSetupSucceeded", "ClientConnectionsTLS", "CommitLatency", "CommitThroughput", "DDLLatency", "DDLThroughput", "DMLLatency", "DMLThroughput", "DatabaseConnectionRequests", "DatabaseConnectionRequestsWithTLS", "DatabaseConnections", "DatabaseConnectionsBorrowLatency", "DatabaseConnectionsCurrentlyBorrowed", "DatabaseConnectionsCurrentlyInTransaction", "DatabaseConnectionsCurrentlySessionPinned", "DatabaseConnectionsSetupFailed", "DatabaseConnectionsSetupSucceeded", "DatabaseConnectionsWithTLS", "Deadlocks", "DeleteLatency", "DeleteThroughput", "DiskQueueDepth", "EngineUptime", "FailedSQLServerAgentJobsCount", "FreeLocalStorage", "FreeStorageSpace", "FreeableMemory", "InsertLatency", "InsertThroughput", "LoginFailures", "MaxDatabaseConnectionsAllowed", "MaximumUsedTransactionIDs", "NetworkReceiveThroughput", "NetworkThroughput", "NetworkTransmitThroughput", "OldestReplicationSlotLag", "Queries", "QueryDatabaseResponseLatency", "QueryRequests", "QueryRequestsNoTLS", "QueryRequestsTLS", "QueryResponseLatency", "RDSToAuroraPostgreSQLReplicaLag", "ReadIOPS", "ReadLatency", "ReadThroughput", "ReplicaLag", "ReplicationSlotDiskUsage", "ResultSetCacheHitRatio", "SelectLatency", "SelectThroughput", "ServerlessDatabaseCapacity", "SnapshotStorageUsed", "SwapUsage", "TotalBackupStorageBilled", "TransactionLogsDiskUsage", "TransactionLogsGeneration", "UpdateLatency", "UpdateThroughput", "VolumeBytesUsed", "VolumeReadIOPs", "VolumeWriteIOPs", "WriteIOPS", "WriteLatency", "WriteThroughput"},
+ "AWS/Redshift": {"CommitQueueLength", "ConcurrencyScalingActiveClusters", "ConcurrencyScalingSeconds", "CPUUtilization", "DatabaseConnections", "HealthStatus", "MaintenanceMode", "MaxConfiguredConcurrencyScalingClusters", "NetworkReceiveThroughput", "NetworkTransmitThroughput", "PercentageDiskSpaceUsed", "QueriesCompletedPerSecond", "QueryDuration", "QueryRuntimeBreakdown", "ReadIOPS", "ReadLatency", "ReadThroughput", "TotalTableCount", "WLMQueueLength", "WLMQueueWaitTime", "WLMQueriesCompletedPerSecond", "WLMQueryDuration", "WLMRunningQueries", "WriteIOPS", "WriteLatency", "WriteThroughput", "SchemaQuota", "NumExceededSchemaQuotas", "StorageUsed", "PercentageQuotaUsed"},
+ "AWS/Robomaker": {"RealTimeFactor", "vCPU", "Memory", "SimulationUnit"},
+ "AWS/Route53": {"ChildHealthCheckHealthyCount", "ConnectionTime", "DNSQueries", "HealthCheckPercentageHealthy", "HealthCheckStatus", "SSLHandshakeTime", "TimeToFirstByte"},
+ "AWS/Route53Resolver": {"InboundQueryVolume", "OutboundQueryVolume", "OutboundQueryAggregatedVolume"},
+ "AWS/S3": {"4xxErrors", "5xxErrors", "AllRequests", "BucketSizeBytes", "BytesDownloaded", "BytesUploaded", "DeleteRequests", "FirstByteLatency", "GetRequests", "HeadRequests", "ListRequests", "NumberOfObjects", "PostRequests", "PutRequests", "SelectRequests", "SelectReturnedBytes", "SelectScannedBytes", "TotalRequestLatency"},
+ "AWS/SDKMetrics": {"CallCount", "ClientErrorCount", "EndToEndLatency", "ConnectionErrorCount", "ServerErrorCount", "ThrottleCount"},
+ "AWS/ServiceCatalog": {"ProvisionedProductLaunch"},
+ "AWS/SES": {"Bounce", "Clicks", "Complaint", "Delivery", "Opens", "Reject", "Rendering Failures", "Reputation.BounceRate", "Reputation.ComplaintRate", "Send"},
+ "AWS/SNS": {"NumberOfMessagesPublished", "NumberOfNotificationsDelivered", "NumberOfNotificationsFailed", "NumberOfNotificationsFilteredOut", "NumberOfNotificationsFilteredOut-InvalidAttributes", "NumberOfNotificationsFilteredOut-NoMessageAttributes", "PublishSize", "SMSMonthToDateSpentUSD", "SMSSuccessRate"},
+ "AWS/SQS": {"ApproximateAgeOfOldestMessage", "ApproximateNumberOfMessagesDelayed", "ApproximateNumberOfMessagesNotVisible", "ApproximateNumberOfMessagesVisible", "NumberOfEmptyReceives", "NumberOfMessagesDeleted", "NumberOfMessagesReceived", "NumberOfMessagesSent", "SentMessageSize"},
+ "AWS/SWF": {"ActivityTaskScheduleToCloseTime", "ActivityTaskScheduleToStartTime", "ActivityTaskStartToCloseTime", "ActivityTasksCanceled", "ActivityTasksCompleted", "ActivityTasksFailed", "ConsumedCapacity", "DecisionTaskScheduleToStartTime", "DecisionTaskStartToCloseTime", "DecisionTasksCompleted", "PendingTasks", "ProvisionedBucketSize", "ProvisionedRefillRate", "ScheduledActivityTasksTimedOutOnClose", "ScheduledActivityTasksTimedOutOnStart", "StartedActivityTasksTimedOutOnClose", "StartedActivityTasksTimedOutOnHeartbeat", "StartedDecisionTasksTimedOutOnClose", "ThrottledEvents", "WorkflowStartToCloseTime", "WorkflowsCanceled", "WorkflowsCompleted", "WorkflowsContinuedAsNew", "WorkflowsFailed", "WorkflowsTerminated", "WorkflowsTimedOut"},
+ "AWS/SageMaker": {"CPUUtilization", "DatasetObjectsAutoAnnotated", "DatasetObjectsHumanAnnotated", "DatasetObjectsLabelingFailed", "DiskUtilization", "GPUMemoryUtilization", "GPUUtilization", "Invocation4XXErrors", "Invocation5XXErrors", "Invocations", "InvocationsPerInstance", "JobsFailed", "JobsStopped", "JobsSucceeded", "MemoryUtilization", "ModelLatency", "OverheadLatency", "TotalDatasetObjectsLabeled"},
+ "AWS/States": {"ActivitiesFailed", "ActivitiesHeartbeatTimedOut", "ActivitiesScheduled", "ActivitiesStarted", "ActivitiesSucceeded", "ActivitiesTimedOut", "ActivityRunTime", "ActivityScheduleTime", "ActivityTime", "ConsumedCapacity", "ExecutionThrottled", "ExecutionTime", "ExecutionsAborted", "ExecutionsFailed", "ExecutionsStarted", "ExecutionsSucceeded", "ExecutionsTimedOut", "LambdaFunctionRunTime", "LambdaFunctionScheduleTime", "LambdaFunctionTime", "LambdaFunctionsFailed", "LambdaFunctionsHeartbeatTimedOut", "LambdaFunctionsScheduled", "LambdaFunctionsStarted", "LambdaFunctionsSucceeded", "LambdaFunctionsTimedOut", "ProvisionedBucketSize", "ProvisionedRefillRate", "ThrottledEvents"},
+ "AWS/StorageGateway": {"CacheFree", "CacheHitPercent", "CachePercentDirty", "CachePercentUsed", "CacheUsed", "CloudBytesDownloaded", "CloudBytesUploaded", "CloudDownloadLatency", "QueuedWrites", "ReadBytes", "ReadTime", "TimeSinceLastRecoveryPoint", "TotalCacheSize", "UploadBufferFree", "UploadBufferPercentUsed", "UploadBufferUsed", "WorkingStorageFree", "WorkingStoragePercentUsed", "WorkingStorageUsed", "WriteBytes", "WriteTime"},
+ "AWS/Textract": {"ResponseTime", "ServerErrorCount", "SuccessfulRequestCount", "ThrottledCount", "UserErrorCount"},
+ "AWS/Timestream": {"SuccessfulRequestLatency", "SystemErrors", "UserErrors", "DataScannedBytes"},
+ "AWS/ThingsGraph": {"EventStoreQueueSize", "FlowExecutionTime", "FlowExecutionsFailed", "FlowExecutionsStarted", "FlowExecutionsSucceeded", "FlowStepExecutionTime", "FlowStepExecutionsFailed", "FlowStepExecutionsStarted", "FlowStepExecutionsSucceeded"},
+ "AWS/TransitGateway": {"BytesIn", "BytesOut", "PacketDropCountBlackhole", "PacketDropCountNoRoute", "PacketsIn", "PacketsOut"},
+ "AWS/Transfer": {"BytesIn", "BytesOut", "FilesIn", "FilesOut"},
+ "AWS/Translate": {"CharacterCount", "ResponseTime", "ServerErrorCount", "SuccessfulRequestCount", "ThrottledCount", "UserErrorCount"},
+ "AWS/TrustedAdvisor": {"GreenChecks", "RedChecks", "RedResources", "ServiceLimitUsage", "YellowChecks", "YellowResources"},
+ "AWS/Usage": {"CallCount", "ResourceCount"},
+ "AWS/VPN": {"TunnelDataIn", "TunnelDataOut", "TunnelState"},
+ "AWS/WAF": {"AllowedRequests", "BlockedRequests", "CountedRequests", "PassedRequests"},
+ "AWS/WAFV2": {"AllowedRequests", "BlockedRequests", "CountedRequests", "PassedRequests"},
+ "AWS/WorkSpaces": {"Available", "ConnectionAttempt", "ConnectionFailure", "ConnectionSuccess", "InSessionLatency", "Maintenance", "SessionDisconnect", "SessionLaunchTime", "Stopped", "Unhealthy", "UserConnected"},
+ "ECS/ContainerInsights": {"ContainerInstanceCount", "CpuUtilized", "CpuReserved", "DeploymentCount", "DesiredTaskCount", "MemoryUtilized", "MemoryReserved", "NetworkRxBytes", "NetworkTxBytes", "PendingTaskCount", "RunningTaskCount", "ServiceCount", "StorageReadBytes", "StorageWriteBytes", "TaskCount", "TaskSetCount", "instance_cpu_limit", "instance_cpu_reserved_capacity", "instance_cpu_usage_total", "instance_cpu_utilization", "instance_filesystem_utilization", "instance_memory_limit", "instance_memory_reserved_capacity", "instance_memory_utliization", "instance_memory_working_set", "instance_network_total_bytes", "instance_number_of_running_tasks"},
+ "ContainerInsights": {"cluster_failed_node_count", "cluster_node_count", "namespace_number_of_running_pods", "node_cpu_limit", "node_cpu_reserved_capacity", "node_cpu_usage_total", "node_cpu_utilization", "node_filesystem_utilization", "node_memory_limit", "node_memory_reserved_capacity", "node_memory_utilization", "node_memory_working_set", "node_network_total_bytes", "node_number_of_running_containers", "node_number_of_running_pods", "pod_cpu_reserved_capacity", "pod_cpu_utilization", "pod_cpu_utilization_over_pod_limit", "pod_memory_reserved_capacity", "pod_memory_utilization", "pod_memory_utilization_over_pod_limit", "pod_number_of_container_restarts", "pod_network_rx_bytes", "pod_network_tx_bytes", "service_number_of_running_pods"},
+ "AWS/Rekognition": {"DetectedFaceCount", "DetectedLabelCount", "ResponseTime", "ServerErrorCount", "SuccessfulRequestCount", "ThrottledCount", "UserErrorCount"},
+ "AWS/Cassandra": {"AccountMaxReads", "AccountMaxTableLevelReads", "AccountMaxTableLevelWrites", "AccountMaxWrites", "AccountProvisionedReadCapacityUtilization", "AccountProvisionedWriteCapacityUtilization", "ConditionalCheckFailedRequests", "ConsumedReadCapacityUnits", "ConsumedWriteCapacityUnits", "MaxProvisionedTableReadCapacityUtilization", "MaxProvisionedTableWriteCapacityUtilization", "ReturnedItemCount", "ReturnedItemCountBySelect", "SuccessfulRequestCount", "SuccessfulRequestLatency", "SystemErrors", "UserErrors"},
+ "AWS/AmplifyHosting": {"Requests", "BytesDownloaded", "BytesUploaded", "4XXErrors", "5XXErrors", "Latency"},
}
var dimensionsMap = map[string][]string{
@@ -473,6 +475,8 @@ var dimensionsMap = map[string][]string{
"AWS/NetworkFirewall": {"AvailabilityZone", "CustomAction", "Engine", "FirewallName"},
"AWS/OpsWorks": {"InstanceId", "LayerId", "StackId"},
"AWS/Polly": {"Operation"},
+ "AWS/PrivateLinkEndpoints": {"Endpoint Type", "Service Name", "Subnet Id", "VPC Endpoint Id", "VPC Id"},
+ "AWS/PrivateLinkServices": {"Az", "Load Balancer Arn", "Service Id", "VPC Endpoint Id"},
"AWS/RDS": {"DBClusterIdentifier", "DBInstanceIdentifier", "DatabaseClass", "DbClusterIdentifier", "EngineName", "ProxyName", "Role", "SourceRegion", "Target", "TargetGroup", "TargetRole"},
"AWS/Redshift": {"ClusterIdentifier", "NodeID", "service class", "stage", "latency", "wlmid"},
"AWS/Robomaker": {"SimulationJobId"},
diff --git a/pkg/tsdb/cloudwatch/resource_handler.go b/pkg/tsdb/cloudwatch/resource_handler.go
new file mode 100644
index 00000000000..c2f76249748
--- /dev/null
+++ b/pkg/tsdb/cloudwatch/resource_handler.go
@@ -0,0 +1,59 @@
+package cloudwatch
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+
+ "github.com/grafana/grafana-plugin-sdk-go/backend"
+ "github.com/grafana/grafana-plugin-sdk-go/backend/resource/httpadapter"
+)
+
+func (e *cloudWatchExecutor) newResourceMux() *http.ServeMux {
+ mux := http.NewServeMux()
+ mux.HandleFunc("/regions", handleResourceReq(e.handleGetRegions))
+ mux.HandleFunc("/namespaces", handleResourceReq(e.handleGetNamespaces))
+ mux.HandleFunc("/metrics", handleResourceReq(e.handleGetMetrics))
+ mux.HandleFunc("/all-metrics", handleResourceReq(e.handleGetAllMetrics))
+ mux.HandleFunc("/dimension-keys", handleResourceReq(e.handleGetDimensionKeys))
+ mux.HandleFunc("/dimension-values", handleResourceReq(e.handleGetDimensionValues))
+ mux.HandleFunc("/ebs-volume-ids", handleResourceReq(e.handleGetEbsVolumeIds))
+ mux.HandleFunc("/ec2-instance-attribute", handleResourceReq(e.handleGetEc2InstanceAttribute))
+ mux.HandleFunc("/resource-arns", handleResourceReq(e.handleGetResourceArns))
+ return mux
+}
+
+type handleFn func(pluginCtx backend.PluginContext, parameters url.Values) ([]suggestData, error)
+
+func handleResourceReq(handleFunc handleFn) func(rw http.ResponseWriter, req *http.Request) {
+ return func(rw http.ResponseWriter, req *http.Request) {
+ ctx := req.Context()
+ pluginContext := httpadapter.PluginConfigFromContext(ctx)
+ err := req.ParseForm()
+ if err != nil {
+ writeResponse(rw, http.StatusBadRequest, fmt.Sprintf("unexpected error %v", err))
+ }
+ data, err := handleFunc(pluginContext, req.URL.Query())
+ if err != nil {
+ writeResponse(rw, http.StatusBadRequest, fmt.Sprintf("unexpected error %v", err))
+ }
+ body, err := json.Marshal(data)
+ if err != nil {
+ writeResponse(rw, http.StatusBadRequest, fmt.Sprintf("unexpected error %v", err))
+ }
+ rw.WriteHeader(http.StatusOK)
+ _, err = rw.Write(body)
+ if err != nil {
+ plog.Error("Unable to write HTTP response", "error", err)
+ }
+ }
+}
+
+func writeResponse(rw http.ResponseWriter, code int, msg string) {
+ rw.WriteHeader(code)
+ _, err := rw.Write([]byte(msg))
+ if err != nil {
+ plog.Error("Unable to write HTTP response", "error", err)
+ }
+}
diff --git a/pkg/tsdb/influxdb/response_parser.go b/pkg/tsdb/influxdb/response_parser.go
index f38e69fbe25..19013a315c6 100644
--- a/pkg/tsdb/influxdb/response_parser.go
+++ b/pkg/tsdb/influxdb/response_parser.go
@@ -56,32 +56,77 @@ func parseJSON(buf io.ReadCloser) (Response, error) {
func transformRows(rows []Row, query Query) data.Frames {
frames := data.Frames{}
for _, row := range rows {
- for columnIndex, column := range row.Columns {
- if column == "time" {
- continue
- }
+ var hasTimeCol = false
- var timeArray []time.Time
- var valueArray []*float64
+ for _, column := range row.Columns {
+ if strings.ToLower(column) == "time" {
+ hasTimeCol = true
+ }
+ }
+
+ if !hasTimeCol {
+ var values []string
for _, valuePair := range row.Values {
- timestamp, timestampErr := parseTimestamp(valuePair[0])
- // we only add this row if the timestamp is valid
- if timestampErr == nil {
- value := parseValue(valuePair[columnIndex])
- timeArray = append(timeArray, timestamp)
- valueArray = append(valueArray, value)
+ if strings.Contains(strings.ToLower(query.RawQuery), strings.ToLower("SHOW TAG VALUES")) {
+ if len(valuePair) >= 2 {
+ values = append(values, valuePair[1].(string))
+ }
+ } else {
+ if len(valuePair) >= 1 {
+ values = append(values, valuePair[0].(string))
+ }
}
}
- name := formatFrameName(row, column, query)
- timeField := data.NewField("time", nil, timeArray)
- valueField := data.NewField("value", row.Tags, valueArray)
+ field := data.NewField("value", nil, values)
+ frames = append(frames, data.NewFrame(row.Name, field))
+ } else {
+ for colIndex, column := range row.Columns {
+ if column == "time" {
+ continue
+ }
- // set a nice name on the value-field
- valueField.SetConfig(&data.FieldConfig{DisplayNameFromDS: name})
+ var timeArray []time.Time
+ var floatArray []*float64
+ var stringArray []string
+ var boolArray []bool
+ valType := typeof(row.Values[0][colIndex])
+ name := formatFrameName(row, column, query)
- frames = append(frames, newDataFrame(name, query.RawQuery, timeField, valueField))
+ for _, valuePair := range row.Values {
+ timestamp, timestampErr := parseTimestamp(valuePair[0])
+ // we only add this row if the timestamp is valid
+ if timestampErr == nil {
+ timeArray = append(timeArray, timestamp)
+ if valType == "string" {
+ value := valuePair[colIndex].(string)
+ stringArray = append(stringArray, value)
+ } else if valType == "json.Number" {
+ value := parseNumber(valuePair[colIndex])
+ floatArray = append(floatArray, value)
+ } else if valType == "bool" {
+ value := valuePair[colIndex].(bool)
+ boolArray = append(boolArray, value)
+ }
+ }
+ }
+
+ timeField := data.NewField("time", nil, timeArray)
+ if valType == "string" {
+ valueField := data.NewField("value", row.Tags, stringArray)
+ valueField.SetConfig(&data.FieldConfig{DisplayNameFromDS: name})
+ frames = append(frames, newDataFrame(name, query.RawQuery, timeField, valueField))
+ } else if valType == "json.Number" {
+ valueField := data.NewField("value", row.Tags, floatArray)
+ valueField.SetConfig(&data.FieldConfig{DisplayNameFromDS: name})
+ frames = append(frames, newDataFrame(name, query.RawQuery, timeField, valueField))
+ } else if valType == "bool" {
+ valueField := data.NewField("value", row.Tags, boolArray)
+ valueField.SetConfig(&data.FieldConfig{DisplayNameFromDS: name})
+ frames = append(frames, newDataFrame(name, query.RawQuery, timeField, valueField))
+ }
+ }
}
}
@@ -168,21 +213,15 @@ func parseTimestamp(value interface{}) (time.Time, error) {
return t, nil
}
-func parseValue(value interface{}) *float64 {
+func typeof(v interface{}) string {
+ return fmt.Sprintf("%T", v)
+}
+
+func parseNumber(value interface{}) *float64 {
// NOTE: we use pointers-to-float64 because we need
// to represent null-json-values. they come for example
// when we do a group-by with fill(null)
- // FIXME: the value of an influxdb-query can be:
- // - string
- // - float
- // - integer
- // - boolean
- //
- // here we only handle numeric values. this is probably
- // enough for alerting, but later if we want to support
- // arbitrary queries, we will have to improve the code
-
if value == nil {
// this is what json-nulls become
return nil
diff --git a/pkg/tsdb/influxdb/response_parser_test.go b/pkg/tsdb/influxdb/response_parser_test.go
index e47c6b4cae7..c96aba76f08 100644
--- a/pkg/tsdb/influxdb/response_parser_test.go
+++ b/pkg/tsdb/influxdb/response_parser_test.go
@@ -51,12 +51,12 @@ func TestInfluxdbResponseParser(t *testing.T) {
"series": [
{
"name": "cpu",
- "columns": ["time","mean","sum"],
+ "columns": ["time","mean","path","isActive"],
"tags": {"datacenter": "America"},
"values": [
- [111,222,333],
- [111,222,333],
- [111,null,333]
+ [111,222,"/usr/path",true],
+ [111,222,"/usr/path",false],
+ [111,null,"/usr/path",true]
]
}
]
@@ -68,24 +68,140 @@ func TestInfluxdbResponseParser(t *testing.T) {
query := &Query{}
labels, err := data.LabelsFromString("datacenter=America")
require.Nil(t, err)
- newField := data.NewField("value", labels, []*float64{
+
+ floatField := data.NewField("value", labels, []*float64{
pointer.Float64(222), pointer.Float64(222), nil,
})
- newField.Config = &data.FieldConfig{DisplayNameFromDS: "cpu.mean { datacenter: America }"}
- testFrame := data.NewFrame("cpu.mean { datacenter: America }",
+ floatField.Config = &data.FieldConfig{DisplayNameFromDS: "cpu.mean { datacenter: America }"}
+ floatFrame := data.NewFrame("cpu.mean { datacenter: America }",
data.NewField("time", nil,
[]time.Time{
time.Date(1970, 1, 1, 0, 1, 51, 0, time.UTC),
time.Date(1970, 1, 1, 0, 1, 51, 0, time.UTC),
time.Date(1970, 1, 1, 0, 1, 51, 0, time.UTC),
}),
- newField,
+ floatField,
)
- testFrame.Meta = &data.FrameMeta{ExecutedQueryString: "Test raw query"}
+ floatFrame.Meta = &data.FrameMeta{ExecutedQueryString: "Test raw query"}
+
+ stringField := data.NewField("value", labels, []string{
+ "/usr/path", "/usr/path", "/usr/path",
+ })
+ stringField.Config = &data.FieldConfig{DisplayNameFromDS: "cpu.path { datacenter: America }"}
+ stringFrame := data.NewFrame("cpu.path { datacenter: America }",
+ data.NewField("time", nil,
+ []time.Time{
+ time.Date(1970, 1, 1, 0, 1, 51, 0, time.UTC),
+ time.Date(1970, 1, 1, 0, 1, 51, 0, time.UTC),
+ time.Date(1970, 1, 1, 0, 1, 51, 0, time.UTC),
+ }),
+ stringField,
+ )
+ stringFrame.Meta = &data.FrameMeta{ExecutedQueryString: "Test raw query"}
+
+ boolField := data.NewField("value", labels, []bool{
+ true, false, true,
+ })
+ boolField.Config = &data.FieldConfig{DisplayNameFromDS: "cpu.isActive { datacenter: America }"}
+ boolFrame := data.NewFrame("cpu.isActive { datacenter: America }",
+ data.NewField("time", nil,
+ []time.Time{
+ time.Date(1970, 1, 1, 0, 1, 51, 0, time.UTC),
+ time.Date(1970, 1, 1, 0, 1, 51, 0, time.UTC),
+ time.Date(1970, 1, 1, 0, 1, 51, 0, time.UTC),
+ }),
+ boolField,
+ )
+ boolFrame.Meta = &data.FrameMeta{ExecutedQueryString: "Test raw query"}
result := parser.Parse(prepare(response), addQueryToQueries(*query))
frame := result.Responses["A"]
+ if diff := cmp.Diff(floatFrame, frame.Frames[0], data.FrameTestCompareOptions()...); diff != "" {
+ t.Errorf("Result mismatch (-want +got):\n%s", diff)
+ }
+ if diff := cmp.Diff(stringFrame, frame.Frames[1], data.FrameTestCompareOptions()...); diff != "" {
+ t.Errorf("Result mismatch (-want +got):\n%s", diff)
+ }
+ if diff := cmp.Diff(boolFrame, frame.Frames[2], data.FrameTestCompareOptions()...); diff != "" {
+ t.Errorf("Result mismatch (-want +got):\n%s", diff)
+ }
+ })
+
+ t.Run("Influxdb response parser should parse metricFindQueries normally", func(t *testing.T) {
+ parser := &ResponseParser{}
+
+ response := `
+ {
+ "results": [
+ {
+ "series": [
+ {
+ "refId": "metricFindQuery",
+ "name": "cpu",
+ "values": [
+ ["cpu"],
+ ["disk"],
+ ["logs"]
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ `
+
+ var queries []Query
+ queries = append(queries, Query{RefID: "metricFindQuery"})
+ newField := data.NewField("value", nil, []string{
+ "cpu", "disk", "logs",
+ })
+ testFrame := data.NewFrame("cpu",
+ newField,
+ )
+
+ result := parser.Parse(prepare(response), queries)
+
+ frame := result.Responses["metricFindQuery"]
+ if diff := cmp.Diff(testFrame, frame.Frames[0], data.FrameTestCompareOptions()...); diff != "" {
+ t.Errorf("Result mismatch (-want +got):\n%s", diff)
+ }
+ })
+
+ t.Run("Influxdb response parser should parse metricFindQueries->SHOW TAG VALUES normally", func(t *testing.T) {
+ parser := &ResponseParser{}
+
+ response := `
+ {
+ "results": [
+ {
+ "series": [
+ {
+ "name": "cpu",
+ "values": [
+ ["values", "cpu-total"],
+ ["values", "cpu0"],
+ ["values", "cpu1"]
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ `
+
+ var queries []Query
+ queries = append(queries, Query{RawQuery: "SHOW TAG VALUES", RefID: "metricFindQuery"})
+ newField := data.NewField("value", nil, []string{
+ "cpu-total", "cpu0", "cpu1",
+ })
+ testFrame := data.NewFrame("cpu",
+ newField,
+ )
+
+ result := parser.Parse(prepare(response), queries)
+
+ frame := result.Responses["metricFindQuery"]
if diff := cmp.Diff(testFrame, frame.Frames[0], data.FrameTestCompareOptions()...); diff != "" {
t.Errorf("Result mismatch (-want +got):\n%s", diff)
}
@@ -130,7 +246,12 @@ func TestInfluxdbResponseParser(t *testing.T) {
"series": [
{
"name": "cpu",
- "columns": ["time","mean"]
+ "columns": ["time","cpu"],
+ "values": [
+ ["values", "cpu-total"],
+ ["values", "cpu0"],
+ ["values", "cpu1"]
+ ]
}
]
}
@@ -570,18 +691,18 @@ func TestInfluxdbResponseParser(t *testing.T) {
require.EqualError(t, result.Responses["A"].Error, "error parsing query: found THING")
})
- t.Run("Influxdb response parser parseValue nil", func(t *testing.T) {
- value := parseValue(nil)
+ t.Run("Influxdb response parser parseNumber nil", func(t *testing.T) {
+ value := parseNumber(nil)
require.Nil(t, value)
})
- t.Run("Influxdb response parser parseValue valid JSON.number", func(t *testing.T) {
- value := parseValue(json.Number("95.4"))
+ t.Run("Influxdb response parser parseNumber valid JSON.number", func(t *testing.T) {
+ value := parseNumber(json.Number("95.4"))
require.Equal(t, *value, 95.4)
})
- t.Run("Influxdb response parser parseValue invalid type", func(t *testing.T) {
- value := parseValue("95.4")
+ t.Run("Influxdb response parser parseNumber invalid type", func(t *testing.T) {
+ value := parseNumber("95.4")
require.Nil(t, value)
})
@@ -593,7 +714,7 @@ func TestInfluxdbResponseParser(t *testing.T) {
require.Equal(t, timestamp.Format(time.RFC3339), "2021-01-02T03:04:05Z")
})
- t.Run("Influxdb response parser parseValue invalid type", func(t *testing.T) {
+ t.Run("Influxdb response parser parseNumber invalid type", func(t *testing.T) {
_, err := parseTimestamp("hello")
require.Error(t, err)
})
diff --git a/pkg/tsdb/legacydata/service/service_test.go b/pkg/tsdb/legacydata/service/service_test.go
index ccd402b25c6..f0b626671b9 100644
--- a/pkg/tsdb/legacydata/service/service_test.go
+++ b/pkg/tsdb/legacydata/service/service_test.go
@@ -11,6 +11,7 @@ import (
"github.com/grafana/grafana/pkg/plugins"
acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
datasourceservice "github.com/grafana/grafana/pkg/services/datasources/service"
+ "github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/oauthtoken"
"github.com/grafana/grafana/pkg/services/secrets/fakes"
secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager"
@@ -36,7 +37,7 @@ func TestHandleRequest(t *testing.T) {
return backend.NewQueryDataResponse(), nil
}
secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())
- dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, &acmock.Mock{})
+ dsService := datasourceservice.ProvideService(bus.New(), nil, secretsService, featuremgmt.WithFeatures(), &acmock.Mock{}, acmock.NewPermissionsServicesMock())
s := ProvideService(client, nil, dsService)
ds := &models.DataSource{Id: 12, Type: "unregisteredType", JsonData: simplejson.New()}
diff --git a/pkg/tsdb/loki/api.go b/pkg/tsdb/loki/api.go
new file mode 100644
index 00000000000..9168f4e9cd1
--- /dev/null
+++ b/pkg/tsdb/loki/api.go
@@ -0,0 +1,134 @@
+package loki
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+
+ "github.com/grafana/grafana/pkg/infra/log"
+ "github.com/grafana/loki/pkg/loghttp"
+ jsoniter "github.com/json-iterator/go"
+)
+
+type LokiAPI struct {
+ client *http.Client
+ url string
+ log log.Logger
+}
+
+func newLokiAPI(client *http.Client, url string, log log.Logger) *LokiAPI {
+ return &LokiAPI{client: client, url: url, log: log}
+}
+
+func makeRequest(ctx context.Context, lokiDsUrl string, query lokiQuery) (*http.Request, error) {
+ qs := url.Values{}
+ qs.Set("query", query.Expr)
+ qs.Set("step", query.Step.String())
+ qs.Set("start", strconv.FormatInt(query.Start.UnixNano(), 10))
+ qs.Set("end", strconv.FormatInt(query.End.UnixNano(), 10))
+
+ lokiUrl, err := url.Parse(lokiDsUrl)
+ if err != nil {
+ return nil, err
+ }
+
+ lokiUrl.Path = "/loki/api/v1/query_range"
+ lokiUrl.RawQuery = qs.Encode()
+
+ req, err := http.NewRequestWithContext(ctx, "GET", lokiUrl.String(), nil)
+ if err != nil {
+ return nil, err
+ }
+
+ // NOTE:
+ // 1. we are missing "dynamic" http params, like OAuth data.
+ // this never worked before (and it is not needed for alerting scenarios),
+ // so it is not a regression.
+ // twe need to have that when we migrate to backend-queries.
+ //
+ // 2. we will have to send a custom http header based on the VolumeQuery prop
+ // (again, not needed for the alerting scenario)
+ // if query.VolumeQuery {
+ // req.Header.Set("X-Query-Tags", "Source=logvolhist")
+ // }
+
+ return req, nil
+}
+
+type lokiError struct {
+ Message string
+}
+
+// we know there is an error,
+// based on the http-response-body
+// we have to make an informative error-object
+func makeLokiError(body io.ReadCloser) error {
+ var buf bytes.Buffer
+ _, err := buf.ReadFrom(body)
+ if err != nil {
+ return err
+ }
+
+ bytes := buf.Bytes()
+
+ // the error-message is probably a JSON structure,
+ // with a string-field named "message". we want the
+ // value of that field.
+ // but, the response might be just a simple string,
+ // this was used in older Loki versions.
+ // so our approach is this:
+ // - we try to convert the bytes to JSON
+ // - we take the value of the field "message"
+ // - if any of these steps fail, or if "message" is empty, we return the whole text
+
+ var data lokiError
+ err = json.Unmarshal(bytes, &data)
+ if err != nil {
+ // we were unable to convert the bytes to JSON, we return the whole text
+ return fmt.Errorf("%v", string(bytes))
+ }
+
+ errorMessage := data.Message
+
+ if errorMessage == "" {
+ // we got no usable error message, we return the whole text
+ return fmt.Errorf("%v", string(bytes))
+ }
+
+ return fmt.Errorf("%v", errorMessage)
+}
+
+func (api *LokiAPI) QueryRange(ctx context.Context, query lokiQuery) (*loghttp.QueryResponse, error) {
+ req, err := makeRequest(ctx, api.url, query)
+ if err != nil {
+ return nil, err
+ }
+
+ resp, err := api.client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+
+ defer func() {
+ if err := resp.Body.Close(); err != nil {
+ api.log.Warn("Failed to close response body", "err", err)
+ }
+ }()
+
+ if resp.StatusCode/100 != 2 {
+ return nil, makeLokiError(resp.Body)
+ }
+
+ var response loghttp.QueryResponse
+ err = jsoniter.NewDecoder(resp.Body).Decode(&response)
+ if err != nil {
+ return nil, err
+ }
+
+ return &response, nil
+}
diff --git a/pkg/tsdb/loki/framing_test.go b/pkg/tsdb/loki/framing_test.go
index acad25928f7..a77ae17c54f 100644
--- a/pkg/tsdb/loki/framing_test.go
+++ b/pkg/tsdb/loki/framing_test.go
@@ -2,6 +2,7 @@ package loki
import (
"bytes"
+ "context"
"io/ioutil"
"net/http"
"os"
@@ -11,7 +12,7 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/experimental"
- "github.com/grafana/loki/pkg/logcli/client"
+ "github.com/grafana/grafana/pkg/infra/log"
"github.com/stretchr/testify/require"
)
@@ -42,7 +43,7 @@ func TestSuccessResponse(t *testing.T) {
bytes, err := os.ReadFile(responseFileName)
require.NoError(t, err)
- frames, err := runQuery(makeMockedClient(200, "application/json", bytes), &lokiQuery{Expr: "up(ALERTS)", Step: time.Second * 42})
+ frames, err := runQuery(context.Background(), makeMockedAPI(200, "application/json", bytes), &lokiQuery{Expr: "up(ALERTS)", Step: time.Second * 42})
require.NoError(t, err)
dr := &backend.DataResponse{
@@ -60,16 +61,9 @@ func TestErrorResponse(t *testing.T) {
// NOTE: when there is an error-response, it comes with
// HTTP code 400, and the format seems to change between versions:
// 2.3.x: content-type=text/plain, content is plaintext
- // 2.4.0: content-type=application/json, content is plaintext !!!
- // 2.4.1: same as 2.4.0
- // 2.4.2: same as 2.4.0 (2.4.2 is currently the latest)
+ // 2.4.x: content-type=application/json, content is plaintext: https://github.com/grafana/loki/issues/4844
// main-branch: content-type=application/json, content is JSON
// we should always be able to to return some kind of error message
- //
- // also, the returned error message is not what we want to return
- // to the user, but this is what is currently returned to the user,
- // so the tests check for that. we will have to change this in the future.
-
tt := []struct {
name string
body []byte
@@ -85,25 +79,31 @@ func TestErrorResponse(t *testing.T) {
"message": "parse error at line 1, col 8: something is wrong"
}`),
contentType: "application/json; charset=utf-8",
- errorMessage: "Run out of attempts while querying the server",
+ errorMessage: "parse error at line 1, col 8: something is wrong",
},
{
name: "parse a non-json error body with json content type (loki 2.4.0,2.4.1,2.4.2)",
body: []byte("parse error at line 1, col 8: something is wrong"),
contentType: "application/json; charset=UTF-8",
- errorMessage: "Run out of attempts while querying the server",
+ errorMessage: "parse error at line 1, col 8: something is wrong",
},
{
name: "parse an error response in plain text",
body: []byte("parse error at line 1, col 8: something is wrong"),
contentType: "text/plain; charset=utf-8",
- errorMessage: "Run out of attempts while querying the server",
+ errorMessage: "parse error at line 1, col 8: something is wrong",
+ },
+ {
+ name: "parse an error response that is broken JSON",
+ body: []byte(`{"message":"error message but the JSON is not finished`),
+ contentType: "text/plain; charset=utf-8",
+ errorMessage: `{"message":"error message but the JSON is not finished`,
},
}
for _, test := range tt {
t.Run(test.name, func(t *testing.T) {
- frames, err := runQuery(makeMockedClient(400, test.contentType, test.body), &lokiQuery{})
+ frames, err := runQuery(context.Background(), makeMockedAPI(400, test.contentType, test.body), &lokiQuery{})
require.Len(t, frames, 0)
require.Error(t, err)
@@ -112,13 +112,13 @@ func TestErrorResponse(t *testing.T) {
}
}
-type MockedRoundTripper struct {
+type mockedRoundTripper struct {
statusCode int
responseBytes []byte
contentType string
}
-func (mockedRT *MockedRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
+func (mockedRT *mockedRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
header := http.Header{}
header.Add("Content-Type", mockedRT.contentType)
return &http.Response{
@@ -128,13 +128,10 @@ func (mockedRT *MockedRoundTripper) RoundTrip(req *http.Request) (*http.Response
}, nil
}
-func makeMockedClient(statusCode int, contentType string, responseBytes []byte) *client.DefaultClient {
- client := &client.DefaultClient{
- Address: "http://localhost:9999",
- Tripperware: func(t http.RoundTripper) http.RoundTripper {
- return &MockedRoundTripper{statusCode: statusCode, responseBytes: responseBytes, contentType: contentType}
- },
+func makeMockedAPI(statusCode int, contentType string, responseBytes []byte) *LokiAPI {
+ client := http.Client{
+ Transport: &mockedRoundTripper{statusCode: statusCode, contentType: contentType, responseBytes: responseBytes},
}
- return client
+ return newLokiAPI(&client, "http://localhost:9999", log.New("test"))
}
diff --git a/pkg/tsdb/loki/loki.go b/pkg/tsdb/loki/loki.go
index feb95716f52..69db994b5d4 100644
--- a/pkg/tsdb/loki/loki.go
+++ b/pkg/tsdb/loki/loki.go
@@ -2,8 +2,6 @@ package loki
import (
"context"
- "crypto/tls"
- "encoding/json"
"fmt"
"net/http"
"regexp"
@@ -17,12 +15,9 @@ import (
"github.com/grafana/grafana/pkg/infra/httpclient"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing"
- "github.com/grafana/loki/pkg/logcli/client"
"github.com/grafana/loki/pkg/loghttp"
- "github.com/grafana/loki/pkg/logproto"
"go.opentelemetry.io/otel/attribute"
- "github.com/prometheus/common/config"
"github.com/prometheus/common/model"
)
@@ -45,12 +40,8 @@ var (
)
type datasourceInfo struct {
- HTTPClient *http.Client
- URL string
- TLSClientConfig *tls.Config
- BasicAuthUser string
- BasicAuthPassword string
- TimeInterval string `json:"timeInterval"`
+ HTTPClient *http.Client
+ URL string
}
type QueryModel struct {
@@ -74,24 +65,9 @@ func newInstanceSettings(httpClientProvider httpclient.Provider) datasource.Inst
return nil, err
}
- tlsClientConfig, err := httpClientProvider.GetTLSConfig(opts)
- if err != nil {
- return nil, err
- }
-
- jsonData := datasourceInfo{}
- err = json.Unmarshal(settings.JSONData, &jsonData)
- if err != nil {
- return nil, fmt.Errorf("error reading settings: %w", err)
- }
-
model := &datasourceInfo{
- HTTPClient: client,
- URL: settings.URL,
- TLSClientConfig: tlsClientConfig,
- TimeInterval: jsonData.TimeInterval,
- BasicAuthUser: settings.BasicAuthUser,
- BasicAuthPassword: settings.DecryptedSecureJSONData["basicAuthPassword"],
+ HTTPClient: client,
+ URL: settings.URL,
}
return model, nil
}
@@ -105,17 +81,7 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest)
return result, err
}
- client := &client.DefaultClient{
- Address: dsInfo.URL,
- Username: dsInfo.BasicAuthUser,
- Password: dsInfo.BasicAuthPassword,
- TLSConfig: config.TLSConfig{
- InsecureSkipVerify: dsInfo.TLSClientConfig.InsecureSkipVerify,
- },
- Tripperware: func(t http.RoundTripper) http.RoundTripper {
- return dsInfo.HTTPClient.Transport
- },
- }
+ api := newLokiAPI(dsInfo.HTTPClient, dsInfo.URL, s.plog)
queries, err := parseQuery(req)
if err != nil {
@@ -130,7 +96,7 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest)
span.SetAttributes("stop_unixnano", query.End, attribute.Key("stop_unixnano").Int64(query.End.UnixNano()))
defer span.End()
- frames, err := runQuery(client, query)
+ frames, err := runQuery(ctx, api, query)
queryRes := backend.DataResponse{}
@@ -204,15 +170,8 @@ func parseResponse(value *loghttp.QueryResponse, query *lokiQuery) (data.Frames,
}
// we extracted this part of the functionality to make it easy to unit-test it
-func runQuery(client *client.DefaultClient, query *lokiQuery) (data.Frames, error) {
- // `limit` only applies to log-producing queries, and we
- // currently only support metric queries, so this can be set to any value.
- limit := 1
-
- // we do not use `interval`, so we set it to zero
- interval := time.Duration(0)
-
- value, err := client.QueryRange(query.Expr, limit, query.Start, query.End, logproto.BACKWARD, query.Step, interval, false)
+func runQuery(ctx context.Context, api *LokiAPI, query *lokiQuery) (data.Frames, error) {
+ value, err := api.QueryRange(ctx, *query)
if err != nil {
return data.Frames{}, err
}
diff --git a/pkg/tsdb/loki/loki_bench_test.go b/pkg/tsdb/loki/loki_bench_test.go
index fec2eefc29e..331b8b533f1 100644
--- a/pkg/tsdb/loki/loki_bench_test.go
+++ b/pkg/tsdb/loki/loki_bench_test.go
@@ -1,6 +1,7 @@
package loki
import (
+ "context"
"fmt"
"math/rand"
"strings"
@@ -15,7 +16,7 @@ func BenchmarkMatrixJson(b *testing.B) {
b.ResetTimer()
for n := 0; n < b.N; n++ {
- _, _ = runQuery(makeMockedClient(200, "application/json", bytes), &lokiQuery{})
+ _, _ = runQuery(context.Background(), makeMockedAPI(200, "application/json", bytes), &lokiQuery{})
}
}
diff --git a/pkg/tsdb/prometheus/time_series_query.go b/pkg/tsdb/prometheus/time_series_query.go
index b38ffd33d8b..a4d1b57e168 100644
--- a/pkg/tsdb/prometheus/time_series_query.go
+++ b/pkg/tsdb/prometheus/time_series_query.go
@@ -39,6 +39,8 @@ const (
varRateIntervalAlt = "${__rate_interval}"
)
+const legendFormatAuto = "__auto"
+
type TimeSeriesQueryType string
const (
@@ -137,11 +139,14 @@ func (s *Service) executeTimeSeriesQuery(ctx context.Context, req *backend.Query
}
func formatLegend(metric model.Metric, query *PrometheusQuery) string {
- var legend string
+ var legend = metric.String()
- if query.LegendFormat == "" {
- legend = metric.String()
- } else {
+ if query.LegendFormat == legendFormatAuto {
+ // If we have labels set legend to empty string to utilize the auto naming system
+ if len(metric) > 0 {
+ legend = ""
+ }
+ } else if query.LegendFormat != "" {
result := legendFormat.ReplaceAllFunc([]byte(query.LegendFormat), func(in []byte) []byte {
labelName := strings.Replace(string(in), "{{", "", 1)
labelName = strings.Replace(labelName, "}}", "", 1)
@@ -335,8 +340,12 @@ func matrixToDataFrames(matrix model.Matrix, query *PrometheusQuery, frames data
timeField.Name = data.TimeSeriesTimeFieldName
timeField.Config = &data.FieldConfig{Interval: float64(query.Step.Milliseconds())}
valueField.Name = data.TimeSeriesValueFieldName
- valueField.Config = &data.FieldConfig{DisplayNameFromDS: name}
valueField.Labels = tags
+
+ if name != "" {
+ valueField.Config = &data.FieldConfig{DisplayNameFromDS: name}
+ }
+
frames = append(frames, newDataFrame(name, "matrix", timeField, valueField))
}
diff --git a/pkg/tsdb/prometheus/time_series_query_test.go b/pkg/tsdb/prometheus/time_series_query_test.go
index fc50c3fb78f..f98d7484a8f 100644
--- a/pkg/tsdb/prometheus/time_series_query_test.go
+++ b/pkg/tsdb/prometheus/time_series_query_test.go
@@ -52,6 +52,30 @@ func TestPrometheus_timeSeriesQuery_formatLeged(t *testing.T) {
require.Equal(t, `{job="grafana"}`, formatLegend(metric, query))
})
+
+ t.Run("When legendFormat = __auto and no labels", func(t *testing.T) {
+ metric := map[p.LabelName]p.LabelValue{}
+
+ query := &PrometheusQuery{
+ LegendFormat: legendFormatAuto,
+ Expr: `{job="grafana"}`,
+ }
+
+ require.Equal(t, `{job="grafana"}`, formatLegend(metric, query))
+ })
+
+ t.Run("When legendFormat = __auto with labels", func(t *testing.T) {
+ metric := map[p.LabelName]p.LabelValue{
+ p.LabelName("app"): p.LabelValue("backend"),
+ }
+
+ query := &PrometheusQuery{
+ LegendFormat: legendFormatAuto,
+ Expr: `{job="grafana"}`,
+ }
+
+ require.Equal(t, "", formatLegend(metric, query))
+ })
}
func TestPrometheus_timeSeriesQuery_parseTimeSeriesQuery(t *testing.T) {
diff --git a/public/app/AppWrapper.tsx b/public/app/AppWrapper.tsx
index 4bb5b60ce7f..72eecdee141 100644
--- a/public/app/AppWrapper.tsx
+++ b/public/app/AppWrapper.tsx
@@ -15,15 +15,16 @@ import { GrafanaRoute } from './core/navigation/GrafanaRoute';
import { AppNotificationList } from './core/components/AppNotifications/AppNotificationList';
import { SearchWrapper } from 'app/features/search';
import { LiveConnectionWarning } from './features/live/LiveConnectionWarning';
-import { AngularRoot } from './angular/AngularRoot';
import { I18nProvider } from './core/localisation';
+import { AngularRoot } from './angular/AngularRoot';
+import { loadAndInitAngularIfEnabled } from './angular/loadAndInitAngularIfEnabled';
interface AppWrapperProps {
app: GrafanaApp;
}
interface AppWrapperState {
- ngInjector: any;
+ ready?: boolean;
}
/** Used by enterprise */
@@ -39,27 +40,14 @@ export function addPageBanner(fn: ComponentType) {
}
export class AppWrapper extends React.Component {
- container = React.createRef();
-
constructor(props: AppWrapperProps) {
super(props);
-
- this.state = {
- ngInjector: null,
- };
+ this.state = {};
}
- componentDidMount() {
- if (this.container) {
- this.bootstrapNgApp();
- } else {
- throw new Error('Failed to boot angular app, no container to attach to');
- }
- }
-
- bootstrapNgApp() {
- const injector = this.props.app.angularApp.bootstrap();
- this.setState({ ngInjector: injector });
+ async componentDidMount() {
+ await loadAndInitAngularIfEnabled();
+ this.setState({ ready: true });
$('.preloader').remove();
}
@@ -91,6 +79,8 @@ export class AppWrapper extends React.Component
))}
-
+
- {this.state.ngInjector && this.renderRoutes()}
+ {ready && this.renderRoutes()}
{bodyRenderHooks.map((Hook, index) => (
))}
diff --git a/public/app/angular/AngularApp.ts b/public/app/angular/AngularApp.ts
index a746e355e1b..29977589ea9 100644
--- a/public/app/angular/AngularApp.ts
+++ b/public/app/angular/AngularApp.ts
@@ -14,6 +14,11 @@ import { extend } from 'lodash';
import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv';
import { getTemplateSrv } from '@grafana/runtime';
import { registerComponents } from './registerComponents';
+import { exposeToPlugin } from 'app/features/plugins/plugin_loader';
+import appEvents from 'app/core/app_events';
+import { contextSrv } from 'app/core/services/context_srv';
+import * as sdk from 'app/plugins/sdk';
+import { promiseToDigest } from './promiseToDigest';
export class AngularApp {
ngModuleDependencies: any[];
@@ -93,6 +98,18 @@ export class AngularApp {
registerComponents();
initAngularRoutingBridge();
+ // Angular plugins import this
+ exposeToPlugin('angular', angular);
+ exposeToPlugin('app/core/utils/promiseToDigest', { promiseToDigest, __esModule: true });
+ exposeToPlugin('app/plugins/sdk', sdk);
+ exposeToPlugin('app/core/core_module', coreModule);
+ exposeToPlugin('app/core/core', {
+ coreModule: coreModule,
+ appEvents: appEvents,
+ contextSrv: contextSrv,
+ __esModule: true,
+ });
+
// disable tool tip animation
$.fn.tooltip.defaults.animation = false;
}
diff --git a/public/app/angular/index.ts b/public/app/angular/index.ts
index 2dcaee03cab..ada6fe8f6a5 100644
--- a/public/app/angular/index.ts
+++ b/public/app/angular/index.ts
@@ -9,6 +9,7 @@ import './services/popover_srv';
import './services/timer';
import './services/AngularLoader';
+import '../angular/jquery_extended';
import './dropdown_typeahead';
import './autofill_event_fix';
import './metric_segment';
@@ -37,3 +38,4 @@ import './components/plugin_component';
import './GrafanaCtrl';
export { AngularApp } from './AngularApp';
+export { coreModule } from './core_module';
diff --git a/public/app/core/jquery_extended.ts b/public/app/angular/jquery_extended.ts
similarity index 100%
rename from public/app/core/jquery_extended.ts
rename to public/app/angular/jquery_extended.ts
diff --git a/public/app/angular/loadAndInitAngularIfEnabled.ts b/public/app/angular/loadAndInitAngularIfEnabled.ts
new file mode 100644
index 00000000000..211f44ae3b8
--- /dev/null
+++ b/public/app/angular/loadAndInitAngularIfEnabled.ts
@@ -0,0 +1,22 @@
+import { config, setAngularLoader } from '@grafana/runtime';
+
+export async function loadAndInitAngularIfEnabled() {
+ if (config.angularSupportEnabled) {
+ const { AngularApp } = await import(/* webpackChunkName: "AngularApp" */ './index');
+ const app = new AngularApp();
+ app.init();
+ app.bootstrap();
+ } else {
+ setAngularLoader({
+ load: (elem, scopeProps, template) => {
+ return {
+ destroy: () => {},
+ digest: () => {},
+ getScope: () => {
+ return {};
+ },
+ };
+ },
+ });
+ }
+}
diff --git a/public/app/app.ts b/public/app/app.ts
index 7f5bcb17836..1ca8c0435d7 100644
--- a/public/app/app.ts
+++ b/public/app/app.ts
@@ -41,7 +41,7 @@ import 'app/features/all';
import { getScrollbarWidth } from '@grafana/ui';
import { variableAdapters } from './features/variables/adapters';
import { initDevFeatures } from './dev';
-import { getStandardTransformers } from 'app/core/utils/standardTransformers';
+import { getStandardTransformers } from 'app/features/transformers/standardTransformers';
import { SentryEchoBackend } from './core/services/echo/backends/sentry/SentryBackend';
import { setVariableQueryRunner, VariableQueryRunner } from './features/variables/query/VariableQueryRunner';
import { configureStore } from './store/configureStore';
@@ -62,7 +62,6 @@ import { setPanelRenderer } from '@grafana/runtime/src/components/PanelRenderer'
import { PanelDataErrorView } from './features/panel/components/PanelDataErrorView';
import { setPanelDataErrorView } from '@grafana/runtime/src/components/PanelDataErrorView';
import { DatasourceSrv } from './features/plugins/datasource_srv';
-import { AngularApp } from './angular';
import { ModalManager } from './core/services/ModalManager';
import { initWindowRuntime } from './features/runtime/init';
import { createQueryVariableAdapter } from './features/variables/query/adapter';
@@ -89,12 +88,6 @@ if (process.env.NODE_ENV === 'development') {
}
export class GrafanaApp {
- angularApp: AngularApp;
-
- constructor() {
- this.angularApp = new AngularApp();
- }
-
async init() {
try {
setBackendSrv(backendSrv);
@@ -148,9 +141,6 @@ export class GrafanaApp {
const modalManager = new ModalManager();
modalManager.init();
- // Init angular
- this.angularApp.init();
-
// Preload selected app plugins
await preloadPlugins(config.pluginsToPreload);
diff --git a/public/app/core/components/Login/LoginPage.test.tsx b/public/app/core/components/Login/LoginPage.test.tsx
index 256dcf4c6b9..3d5161182df 100644
--- a/public/app/core/components/Login/LoginPage.test.tsx
+++ b/public/app/core/components/Login/LoginPage.test.tsx
@@ -3,8 +3,11 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginPage } from './LoginPage';
+import * as runtimeMock from '@grafana/runtime';
+
const postMock = jest.fn();
jest.mock('@grafana/runtime', () => ({
+ __esModule: true,
getBackendSrv: () => ({
post: postMock,
}),
@@ -26,6 +29,10 @@ jest.mock('@grafana/runtime', () => ({
}));
describe('Login Page', () => {
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
it('renders correctly', () => {
render( );
@@ -77,4 +84,16 @@ describe('Login Page', () => {
await waitFor(() => expect(postMock).toHaveBeenCalledWith('/login', { password: 'test', user: 'admin' }));
expect(window.location.assign).toHaveBeenCalledWith('/');
});
+ it('renders social logins correctly', () => {
+ (runtimeMock as any).config.oauth = {
+ okta: {
+ name: 'Okta Test',
+ icon: 'signin',
+ },
+ };
+
+ render( );
+
+ expect(screen.getByRole('link', { name: 'Sign in with Okta Test' })).toBeInTheDocument();
+ });
});
diff --git a/public/app/core/components/Login/LoginServiceButtons.tsx b/public/app/core/components/Login/LoginServiceButtons.tsx
index e51a8b5b972..ab8a648aad7 100644
--- a/public/app/core/components/Login/LoginServiceButtons.tsx
+++ b/public/app/core/components/Login/LoginServiceButtons.tsx
@@ -55,20 +55,20 @@ const loginServices: () => LoginServices = () => {
bgColor: '#262628',
enabled: oauthEnabled && Boolean(config.oauth.grafana_com),
name: 'Grafana.com',
- hrefName: 'grafana_com',
icon: 'grafana',
+ hrefName: 'grafana_com',
},
okta: {
bgColor: '#2f2f2f',
enabled: oauthEnabled && Boolean(config.oauth.okta),
- name: 'Okta',
- icon: 'okta',
+ name: config.oauth?.okta?.name || 'Okta',
+ icon: (config.oauth?.okta?.icon as IconName) || 'okta',
},
oauth: {
bgColor: '#262628',
enabled: oauthEnabled && Boolean(config.oauth.generic_oauth),
- name: oauthEnabled && config.oauth.generic_oauth ? config.oauth.generic_oauth.name : 'OAuth',
- icon: 'signin',
+ name: config.oauth?.generic_oauth?.name || 'OAuth',
+ icon: (config.oauth?.generic_oauth?.icon as IconName) || 'signin',
hrefName: 'generic_oauth',
},
};
diff --git a/public/app/core/components/NavBar/NavBarItemMenuTrigger.tsx b/public/app/core/components/NavBar/NavBarItemMenuTrigger.tsx
index 7d85be8a65b..90df0a07fb1 100644
--- a/public/app/core/components/NavBar/NavBarItemMenuTrigger.tsx
+++ b/public/app/core/components/NavBar/NavBarItemMenuTrigger.tsx
@@ -1,4 +1,4 @@
-import React, { ReactElement, useState } from 'react';
+import React, { ReactElement, useEffect, useState } from 'react';
import { css, cx } from '@emotion/css';
import { Icon, IconName, Link, useTheme2 } from '@grafana/ui';
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
@@ -13,6 +13,7 @@ import { FocusScope } from '@react-aria/focus';
import { NavBarItemMenuContext } from './context';
import { NavFeatureHighlight } from './NavFeatureHighlight';
+import { reportExperimentView } from '@grafana/runtime';
export interface NavBarItemMenuTriggerProps extends MenuTriggerProps {
children: ReactElement;
@@ -34,6 +35,12 @@ export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactE
const ref = React.useRef(null);
const { menuTriggerProps, menuProps } = useMenuTrigger({}, state, ref);
+ useEffect(() => {
+ if (item.highlightId) {
+ reportExperimentView(`feature-highlights-${item.highlightId}-nav`, 'test', '');
+ }
+ }, [item.highlightId]);
+
const { hoverProps } = useHover({
onHoverChange: (isHovering) => {
if (isHovering) {
diff --git a/public/app/core/components/OrgSwitcher.tsx b/public/app/core/components/OrgSwitcher.tsx
index 861e455218e..79189a12b56 100644
--- a/public/app/core/components/OrgSwitcher.tsx
+++ b/public/app/core/components/OrgSwitcher.tsx
@@ -1,91 +1,67 @@
-import React from 'react';
-
-import { getBackendSrv } from '@grafana/runtime';
+import React, { ReactElement } from 'react';
+import { css } from '@emotion/css';
import { UserOrgDTO } from '@grafana/data';
-import { Modal, Button, CustomScrollbar } from '@grafana/ui';
+import { Button, CustomScrollbar, Modal } from '@grafana/ui';
import { contextSrv } from 'app/core/services/context_srv';
import config from 'app/core/config';
-import { css } from '@emotion/css';
+import { api } from '../../features/profile/api';
+import { useAsync } from 'react-use';
interface Props {
onDismiss: () => void;
}
-interface State {
- orgs: UserOrgDTO[];
-}
-
-export class OrgSwitcher extends React.PureComponent {
- state: State = {
- orgs: [],
+export function OrgSwitcher({ onDismiss }: Props): ReactElement {
+ const { value: orgs = [] } = useAsync(() => {
+ return api.loadOrgs();
+ }, []);
+ const currentOrgId = contextSrv.user.orgId;
+ const contentClassName = css({
+ display: 'flex',
+ maxHeight: 'calc(85vh - 42px)',
+ });
+ const setCurrentOrg = async (org: UserOrgDTO) => {
+ await api.setUserOrg(org);
+ window.location.href = `${config.appSubUrl}${config.appSubUrl.endsWith('/') ? '' : '/'}?orgId=${org.orgId}`;
};
- componentDidMount() {
- this.getUserOrgs();
- }
-
- getUserOrgs = async () => {
- const orgs: UserOrgDTO[] = await getBackendSrv().get('/api/user/orgs');
- this.setState({ orgs });
- };
-
- setCurrentOrg = async (org: UserOrgDTO) => {
- await getBackendSrv().post(`/api/user/using/${org.orgId}`);
- this.setWindowLocation(`${config.appSubUrl}${config.appSubUrl.endsWith('/') ? '' : '/'}?orgId=${org.orgId}`);
- };
-
- setWindowLocation(href: string) {
- window.location.href = href;
- }
-
- render() {
- const { onDismiss } = this.props;
- const { orgs } = this.state;
-
- const currentOrgId = contextSrv.user.orgId;
- const contentClassName = css({
- display: 'flex',
- maxHeight: 'calc(85vh - 42px)',
- });
-
- return (
-
-
-
-
-
- Name
- Role
-
+ return (
+
+
+
+
+
+ Name
+ Role
+
+
+
+
+ {orgs.map((org) => (
+
+ {org.name}
+ {org.role}
+
+ {org.orgId === currentOrgId ? (
+ Current
+ ) : (
+ setCurrentOrg(org)}>
+ Switch to
+
+ )}
+
-
-
- {orgs.map((org) => (
-
- {org.name}
- {org.role}
-
- {org.orgId === currentOrgId ? (
- Current
- ) : (
- this.setCurrentOrg(org)}>
- Switch to
-
- )}
-
-
- ))}
-
-
-
-
- );
- }
+ ))}
+
+
+
+
+ );
}
diff --git a/public/app/core/components/QueryOperationRow/QueryOperationRow.tsx b/public/app/core/components/QueryOperationRow/QueryOperationRow.tsx
index 146b21f9e68..4004424e07f 100644
--- a/public/app/core/components/QueryOperationRow/QueryOperationRow.tsx
+++ b/public/app/core/components/QueryOperationRow/QueryOperationRow.tsx
@@ -4,6 +4,7 @@ import { GrafanaTheme } from '@grafana/data';
import { css, cx } from '@emotion/css';
import { useUpdateEffect } from 'react-use';
import { Draggable } from 'react-beautiful-dnd';
+import { reportInteraction } from '@grafana/runtime';
interface QueryOperationRowProps {
index: number;
@@ -47,6 +48,24 @@ export const QueryOperationRow: React.FC = ({
setIsContentVisible(!isContentVisible);
}, [isContentVisible, setIsContentVisible]);
+ const reportDragMousePosition = useCallback((e) => {
+ // When drag detected react-beautiful-dnd will preventDefault the event
+ // Ref: https://github.com/atlassian/react-beautiful-dnd/blob/master/docs/guides/how-we-use-dom-events.md#a-mouse-drag-has-started-and-the-user-is-now-dragging
+ if (e.defaultPrevented) {
+ const rect = e.currentTarget.getBoundingClientRect();
+ var x = e.clientX - rect.left;
+ var y = e.clientY - rect.top;
+
+ // report relative mouse position within the header element
+ reportInteraction('query_row_reorder_drag_position', {
+ x: x / rect.width,
+ y: y / rect.height,
+ width: rect.width,
+ height: rect.height,
+ });
+ }
+ }, []);
+
useUpdateEffect(() => {
if (isContentVisible) {
if (onOpen) {
@@ -106,7 +125,9 @@ export const QueryOperationRow: React.FC = ({
return (
<>
-
{rowHeader}
+
+ {rowHeader}
+
{isContentVisible &&
{children}
}
>
diff --git a/public/app/core/core.ts b/public/app/core/core.ts
index 04cf6dbbbd8..38e3808619d 100644
--- a/public/app/core/core.ts
+++ b/public/app/core/core.ts
@@ -1,5 +1,3 @@
-import './jquery_extended';
-import './services/search_srv';
import { colors, JsonExplorer } from '@grafana/ui/';
import appEvents from './app_events';
import { assignModelProperties } from './utils/model_utils';
diff --git a/public/app/core/reducers/root.ts b/public/app/core/reducers/root.ts
index 88ff7d0fdfb..fe0d22f773c 100644
--- a/public/app/core/reducers/root.ts
+++ b/public/app/core/reducers/root.ts
@@ -10,14 +10,15 @@ import exploreReducers from 'app/features/explore/state/main';
import { reducer as pluginsReducer } from 'app/features/plugins/admin/state/reducer';
import dataSourcesReducers from 'app/features/datasources/state/reducers';
import usersReducers from 'app/features/users/state/reducers';
+import invitesReducers from 'app/features/invites/state/reducers';
import userReducers from 'app/features/profile/state/reducers';
import organizationReducers from 'app/features/org/state/reducers';
import ldapReducers from 'app/features/admin/state/reducers';
-import templatingReducers from 'app/features/variables/state/reducers';
import importDashboardReducers from 'app/features/manage-dashboards/state/reducers';
import panelEditorReducers from 'app/features/dashboard/components/PanelEditor/state/reducers';
import panelsReducers from 'app/features/panel/state/reducers';
import serviceAccountsReducer from 'app/features/serviceaccounts/state/reducers';
+import templatingReducers from 'app/features/variables/state/keyedVariablesReducer';
const rootReducers = {
...sharedReducers,
@@ -31,12 +32,13 @@ const rootReducers = {
...usersReducers,
...serviceAccountsReducer,
...userReducers,
+ ...invitesReducers,
...organizationReducers,
...ldapReducers,
- ...templatingReducers,
...importDashboardReducers,
...panelEditorReducers,
...panelsReducers,
+ ...templatingReducers,
plugins: pluginsReducer,
};
diff --git a/public/app/core/services/keybindingSrv.ts b/public/app/core/services/keybindingSrv.ts
index a232a133e4f..765b2a09b61 100644
--- a/public/app/core/services/keybindingSrv.ts
+++ b/public/app/core/services/keybindingSrv.ts
@@ -11,7 +11,7 @@ import { exitKioskMode, toggleKioskMode } from '../navigation/kiosk';
import {
RemovePanelEvent,
ShiftTimeEvent,
- ShiftTimeEventPayload,
+ ShiftTimeEventDirection,
ShowModalReactEvent,
ZoomOutEvent,
AbsoluteTimeEvent,
@@ -171,6 +171,24 @@ export class KeybindingSrv {
this.bind(keyArg, withFocusedPanel(fn));
}
+ setupTimeRangeBindings(updateUrl = true) {
+ this.bind('t z', () => {
+ appEvents.publish(new ZoomOutEvent({ scale: 2, updateUrl }));
+ });
+
+ this.bind('ctrl+z', () => {
+ appEvents.publish(new ZoomOutEvent({ scale: 2, updateUrl }));
+ });
+
+ this.bind('t left', () => {
+ appEvents.publish(new ShiftTimeEvent({ direction: ShiftTimeEventDirection.Left, updateUrl }));
+ });
+
+ this.bind('t right', () => {
+ appEvents.publish(new ShiftTimeEvent({ direction: ShiftTimeEventDirection.Right, updateUrl }));
+ });
+ }
+
setupDashboardBindings(dashboard: DashboardModel) {
this.bind('mod+o', () => {
dashboard.graphTooltip = (dashboard.graphTooltip + 1) % 3;
@@ -191,21 +209,7 @@ export class KeybindingSrv {
}
});
- this.bind('t z', () => {
- appEvents.publish(new ZoomOutEvent(2));
- });
-
- this.bind('ctrl+z', () => {
- appEvents.publish(new ZoomOutEvent(2));
- });
-
- this.bind('t left', () => {
- appEvents.publish(new ShiftTimeEvent(ShiftTimeEventPayload.Left));
- });
-
- this.bind('t right', () => {
- appEvents.publish(new ShiftTimeEvent(ShiftTimeEventPayload.Right));
- });
+ this.setupTimeRangeBindings();
// edit panel
this.bindWithPanelId('e', (panelId) => {
diff --git a/public/app/core/specs/OrgSwitcher.test.tsx b/public/app/core/specs/OrgSwitcher.test.tsx
index 9ab35afa612..829fa3ac9c0 100644
--- a/public/app/core/specs/OrgSwitcher.test.tsx
+++ b/public/app/core/specs/OrgSwitcher.test.tsx
@@ -1,15 +1,12 @@
import React from 'react';
-import { OrgSwitcher } from '../components/OrgSwitcher';
-import { shallow } from 'enzyme';
-import { OrgRole } from '@grafana/data';
+import { render, screen, waitFor, within } from '@testing-library/react';
-const postMock = jest.fn().mockImplementation(jest.fn());
+import { OrgSwitcher } from '../components/OrgSwitcher';
+import { api } from '../../features/profile/api';
+import userEvent from '@testing-library/user-event';
+import { OrgRole } from '../../types';
jest.mock('@grafana/runtime', () => ({
- getBackendSrv: () => ({
- get: jest.fn().mockResolvedValue([]),
- post: postMock,
- }),
config: {
appSubUrl: '/subUrl',
},
@@ -21,25 +18,54 @@ jest.mock('app/core/services/context_srv', () => ({
},
}));
-let wrapper;
-let orgSwitcher: OrgSwitcher;
-
describe('OrgSwitcher', () => {
+ const { location } = window;
+ let setUserOrgSpy: jest.SpyInstance;
+
+ beforeEach(async () => {
+ jest.clearAllMocks();
+ const orgs = [
+ { orgId: 1, name: 'Main Org.', role: OrgRole.Admin },
+ { orgId: 2, name: 'Org 2', role: OrgRole.Admin },
+ ];
+ const loadOrgsSpy = jest.spyOn(api, 'loadOrgs').mockResolvedValue(orgs);
+ setUserOrgSpy = jest.spyOn(api, 'setUserOrg').mockResolvedValue(undefined);
+
+ // @ts-ignore
+ delete window.location;
+ window.location = {} as Location;
+
+ render( {}} />);
+ await waitFor(() => expect(loadOrgsSpy).toHaveBeenCalledTimes(1));
+ });
+
+ afterEach(() => {
+ window.location = location;
+ });
+
describe('when switching org', () => {
- beforeEach(async () => {
- wrapper = shallow( {}} />);
- orgSwitcher = wrapper.instance() as OrgSwitcher;
- orgSwitcher.setWindowLocation = jest.fn();
- wrapper.update();
- await orgSwitcher.setCurrentOrg({ name: 'mock org', orgId: 2, role: OrgRole.Viewer });
+ it('should render correct rows', async () => {
+ expect(screen.getAllByRole('row')).toHaveLength(3); // header + 2 orgs
+ expect(screen.getByRole('row', { name: /main org. admin current/i })).toBeInTheDocument();
+ expect(screen.getByRole('row', { name: /org 2 admin switch to/i })).toBeInTheDocument();
});
- it('should switch orgId in call to backend', () => {
- expect(postMock).toBeCalledWith('/api/user/using/2');
+ it('should switch orgId in call to backend', async () => {
+ const row = screen.getByRole('row', { name: /org 2 admin switch to/i });
+ const switchToButton = within(row).getByText(/switch to/i);
+ userEvent.click(switchToButton);
+
+ await waitFor(() => expect(setUserOrgSpy).toBeCalledWith({ orgId: 2, name: 'Org 2', role: 'Admin' }));
});
- it('should switch orgId in url and redirect to home page', () => {
- expect(orgSwitcher.setWindowLocation).toBeCalledWith('/subUrl/?orgId=2');
+ it('should redirect to home page', async () => {
+ expect(window.location.href).toBeUndefined();
+
+ const row = screen.getByRole('row', { name: /org 2 admin switch to/i });
+ const switchToButton = within(row).getByText(/switch to/i);
+ userEvent.click(switchToButton);
+
+ await waitFor(() => expect(window.location.href).toEqual('/subUrl/?orgId=2'));
});
});
});
diff --git a/public/app/core/utils/fetch.ts b/public/app/core/utils/fetch.ts
index 284a5848309..fb77a6d1a7d 100644
--- a/public/app/core/utils/fetch.ts
+++ b/public/app/core/utils/fetch.ts
@@ -105,7 +105,12 @@ export async function parseResponseBody(
return response.blob() as any;
case 'json':
- return response.json();
+ try {
+ return await response.json();
+ } catch (err) {
+ console.warn(`${response.url} returned an invalid JSON -`, err);
+ return {} as unknown as T;
+ }
case 'text':
return response.text() as any;
diff --git a/public/app/core/utils/standardTransformers.ts b/public/app/core/utils/standardTransformers.ts
deleted file mode 100644
index a8815ae8adf..00000000000
--- a/public/app/core/utils/standardTransformers.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import { TransformerRegistryItem } from '@grafana/data';
-import { reduceTransformRegistryItem } from '../components/TransformersUI/ReduceTransformerEditor';
-import { filterFieldsByNameTransformRegistryItem } from '../components/TransformersUI/FilterByNameTransformerEditor';
-import { filterFramesByRefIdTransformRegistryItem } from '../components/TransformersUI/FilterByRefIdTransformerEditor';
-import { filterByValueTransformRegistryItem } from '../components/TransformersUI/FilterByValueTransformer/FilterByValueTransformerEditor';
-import { organizeFieldsTransformRegistryItem } from '../components/TransformersUI/OrganizeFieldsTransformerEditor';
-import { seriesToFieldsTransformerRegistryItem } from '../components/TransformersUI/SeriesToFieldsTransformerEditor';
-import { calculateFieldTransformRegistryItem } from '../components/TransformersUI/CalculateFieldTransformerEditor';
-import { labelsToFieldsTransformerRegistryItem } from '../components/TransformersUI/LabelsToFieldsTransformerEditor';
-import { groupByTransformRegistryItem } from '../components/TransformersUI/GroupByTransformerEditor';
-import { sortByTransformRegistryItem } from '../components/TransformersUI/SortByTransformerEditor';
-import { mergeTransformerRegistryItem } from '../components/TransformersUI/MergeTransformerEditor';
-import { seriesToRowsTransformerRegistryItem } from '../components/TransformersUI/SeriesToRowsTransformerEditor';
-import { concatenateTransformRegistryItem } from '../components/TransformersUI/ConcatenateTransformerEditor';
-import { renameByRegexTransformRegistryItem } from '../components/TransformersUI/RenameByRegexTransformer';
-import { histogramTransformRegistryItem } from '../components/TransformersUI/HistogramTransformerEditor';
-import { rowsToFieldsTransformRegistryItem } from '../components/TransformersUI/rowsToFields/RowsToFieldsTransformerEditor';
-import { configFromQueryTransformRegistryItem } from '../components/TransformersUI/configFromQuery/ConfigFromQueryTransformerEditor';
-import { prepareTimeseriesTransformerRegistryItem } from '../components/TransformersUI/prepareTimeSeries/PrepareTimeSeriesEditor';
-import { convertFieldTypeTransformRegistryItem } from '../components/TransformersUI/ConvertFieldTypeTransformerEditor';
-import { fieldLookupTransformRegistryItem } from '../components/TransformersUI/lookupGazetteer/FieldLookupTransformerEditor';
-import { extractFieldsTransformRegistryItem } from '../components/TransformersUI/extractFields/ExtractFieldsTransformerEditor';
-import { spatialTransformRegistryItem } from '../components/TransformersUI/spatial/SpatialTransformerEditor';
-
-export const getStandardTransformers = (): Array> => {
- return [
- reduceTransformRegistryItem,
- filterFieldsByNameTransformRegistryItem,
- renameByRegexTransformRegistryItem,
- filterFramesByRefIdTransformRegistryItem,
- filterByValueTransformRegistryItem,
- organizeFieldsTransformRegistryItem,
- seriesToFieldsTransformerRegistryItem,
- seriesToRowsTransformerRegistryItem,
- concatenateTransformRegistryItem,
- calculateFieldTransformRegistryItem,
- labelsToFieldsTransformerRegistryItem,
- groupByTransformRegistryItem,
- sortByTransformRegistryItem,
- mergeTransformerRegistryItem,
- histogramTransformRegistryItem,
- rowsToFieldsTransformRegistryItem,
- configFromQueryTransformRegistryItem,
- prepareTimeseriesTransformerRegistryItem,
- convertFieldTypeTransformRegistryItem,
- spatialTransformRegistryItem,
- fieldLookupTransformRegistryItem,
- extractFieldsTransformRegistryItem,
- ];
-};
diff --git a/public/app/features/admin/ServerStats.tsx b/public/app/features/admin/ServerStats.tsx
index 675b0ea38f9..c883750e161 100644
--- a/public/app/features/admin/ServerStats.tsx
+++ b/public/app/features/admin/ServerStats.tsx
@@ -94,7 +94,7 @@ export const ServerStats = () => {
No stats found.
)}
- {config.featureToggles.dashboardPreviews && }
+ {config.featureToggles.dashboardPreviews && config.featureToggles.dashboardPreviewsAdmin && }
>
);
};
diff --git a/public/app/features/alerting/AlertTab.tsx b/public/app/features/alerting/AlertTab.tsx
index 3348293a19e..2c75d68fc49 100644
--- a/public/app/features/alerting/AlertTab.tsx
+++ b/public/app/features/alerting/AlertTab.tsx
@@ -2,13 +2,10 @@ import React, { PureComponent } from 'react';
import { connect, MapDispatchToProps, MapStateToProps } from 'react-redux';
import { Alert, Button, ConfirmModal, Container, CustomScrollbar, HorizontalGroup, IconName, Modal } from '@grafana/ui';
import { selectors } from '@grafana/e2e-selectors';
-import { AngularComponent, getAngularLoader, getDataSourceSrv } from '@grafana/runtime';
+import { AngularComponent, config, getAngularLoader, getDataSourceSrv } from '@grafana/runtime';
import { getAlertingValidationMessage } from './getAlertingValidationMessage';
-
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
import StateHistory from './StateHistory';
-import 'app/features/alerting/AlertTabCtrl';
-
import { DashboardModel } from '../dashboard/state/DashboardModel';
import { PanelModel } from '../dashboard/state/PanelModel';
import { TestRuleResult } from './TestRuleResult';
@@ -57,8 +54,14 @@ class UnConnectedAlertTab extends PureComponent {
showTestRule: false,
};
- componentDidMount() {
- this.loadAlertTab();
+ async componentDidMount() {
+ if (config.angularSupportEnabled) {
+ await import(/* webpackChunkName: "AlertTabCtrl" */ 'app/features/alerting/AlertTabCtrl');
+ this.loadAlertTab();
+ } else {
+ // TODO probably need to migrate AlertTab to react
+ alert('Angular support disabled, legacy alerting cannot function without angular support');
+ }
}
onAngularPanelUpdated = () => {
diff --git a/public/app/features/alerting/unified/api/alertmanager.ts b/public/app/features/alerting/unified/api/alertmanager.ts
index 25cd0320f56..9986590701c 100644
--- a/public/app/features/alerting/unified/api/alertmanager.ts
+++ b/public/app/features/alerting/unified/api/alertmanager.ts
@@ -13,6 +13,7 @@ import {
TestReceiversAlert,
TestReceiversPayload,
TestReceiversResult,
+ ExternalAlertmanagerConfig,
} from 'app/plugins/datasource/alertmanager/types';
import { lastValueFrom } from 'rxjs';
import { getDatasourceAPIId, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource';
@@ -222,11 +223,11 @@ function getReceiverResultError(receiversResult: TestReceiversResult) {
.join('; ');
}
-export async function addAlertManagers(alertManagers: string[]): Promise {
+export async function addAlertManagers(alertManagerConfig: ExternalAlertmanagerConfig): Promise {
await lastValueFrom(
getBackendSrv().fetch({
method: 'POST',
- data: { alertmanagers: alertManagers },
+ data: alertManagerConfig,
url: '/api/v1/ngalert/admin_config',
showErrorAlert: false,
showSuccessAlert: false,
@@ -247,9 +248,9 @@ export async function fetchExternalAlertmanagers(): Promise {
+export async function fetchExternalAlertmanagerConfig(): Promise {
const result = await lastValueFrom(
- getBackendSrv().fetch<{ alertmanagers: string[] }>({
+ getBackendSrv().fetch({
method: 'GET',
url: '/api/v1/ngalert/admin_config',
showErrorAlert: false,
diff --git a/public/app/features/alerting/unified/components/admin/AddAlertManagerModal.tsx b/public/app/features/alerting/unified/components/admin/AddAlertManagerModal.tsx
index 82b258803bb..aabb017f588 100644
--- a/public/app/features/alerting/unified/components/admin/AddAlertManagerModal.tsx
+++ b/public/app/features/alerting/unified/components/admin/AddAlertManagerModal.tsx
@@ -1,19 +1,17 @@
import React, { FC, useMemo } from 'react';
import { css, cx } from '@emotion/css';
-import { useDispatch } from 'react-redux';
import { GrafanaTheme2 } from '@grafana/data';
import { Button, Field, FieldArray, Form, Icon, Input, Modal, useStyles2 } from '@grafana/ui';
-import { addExternalAlertmanagersAction } from '../../state/actions';
import { AlertmanagerUrl } from 'app/plugins/datasource/alertmanager/types';
interface Props {
onClose: () => void;
alertmanagers: AlertmanagerUrl[];
+ onChangeAlertmanagerConfig: (alertmanagers: string[]) => void;
}
-export const AddAlertManagerModal: FC = ({ alertmanagers, onClose }) => {
+export const AddAlertManagerModal: FC = ({ alertmanagers, onChangeAlertmanagerConfig, onClose }) => {
const styles = useStyles2(getStyles);
- const dispatch = useDispatch();
const defaultValues: Record = useMemo(
() => ({
alertmanagers: alertmanagers,
@@ -29,7 +27,7 @@ export const AddAlertManagerModal: FC = ({ alertmanagers, onClose }) => {
);
const onSubmit = (values: Record) => {
- dispatch(addExternalAlertmanagersAction(values.alertmanagers.map((am) => cleanAlertmanagerUrl(am.url))));
+ onChangeAlertmanagerConfig(values.alertmanagers.map((am) => cleanAlertmanagerUrl(am.url)));
onClose();
};
diff --git a/public/app/features/alerting/unified/components/admin/ExternalAlertmanagers.tsx b/public/app/features/alerting/unified/components/admin/ExternalAlertmanagers.tsx
index 9bb29cac46d..efcbccf9ab6 100644
--- a/public/app/features/alerting/unified/components/admin/ExternalAlertmanagers.tsx
+++ b/public/app/features/alerting/unified/components/admin/ExternalAlertmanagers.tsx
@@ -1,8 +1,18 @@
import React, { useCallback, useEffect, useState } from 'react';
-import { useDispatch } from 'react-redux';
-import { css } from '@emotion/css';
+import { useDispatch, useSelector } from 'react-redux';
+import { css, cx } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
-import { Button, ConfirmModal, HorizontalGroup, Icon, Tooltip, useStyles2 } from '@grafana/ui';
+import {
+ Button,
+ ConfirmModal,
+ Field,
+ HorizontalGroup,
+ Icon,
+ RadioButtonGroup,
+ Tooltip,
+ useStyles2,
+ useTheme2,
+} from '@grafana/ui';
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
import { AddAlertManagerModal } from './AddAlertManagerModal';
import {
@@ -11,13 +21,25 @@ import {
fetchExternalAlertmanagersConfigAction,
} from '../../state/actions';
import { useExternalAmSelector } from '../../hooks/useExternalAmSelector';
+import { StoreState } from 'app/types/store';
+
+const alertmanagerChoices = [
+ { value: 'internal', label: 'Only Internal' },
+ { value: 'external', label: 'Only External' },
+ { value: 'all', label: 'Both internal and external' },
+];
export const ExternalAlertmanagers = () => {
const styles = useStyles2(getStyles);
const dispatch = useDispatch();
const [modalState, setModalState] = useState({ open: false, payload: [{ url: '' }] });
const [deleteModalState, setDeleteModalState] = useState({ open: false, index: 0 });
+
const externalAlertManagers = useExternalAmSelector();
+ const alertmanagersChoice = useSelector(
+ (state: StoreState) => state.unifiedAlerting.externalAlertmanagers.alertmanagerConfig.result?.alertmanagersChoice
+ );
+ const theme = useTheme2();
useEffect(() => {
dispatch(fetchExternalAlertmanagersAction());
@@ -37,10 +59,12 @@ export const ExternalAlertmanagers = () => {
.map((am) => {
return am.url;
});
- dispatch(addExternalAlertmanagersAction(newList));
+ dispatch(
+ addExternalAlertmanagersAction({ alertmanagers: newList, alertmanagersChoice: alertmanagersChoice ?? 'all' })
+ );
setDeleteModalState({ open: false, index: 0 });
},
- [externalAlertManagers, dispatch]
+ [externalAlertManagers, dispatch, alertmanagersChoice]
);
const onEdit = useCallback(() => {
@@ -70,16 +94,26 @@ export const ExternalAlertmanagers = () => {
}));
}, [setModalState]);
+ const onChangeAlertmanagerChoice = (alertmanagersChoice: string) => {
+ dispatch(
+ addExternalAlertmanagersAction({ alertmanagers: externalAlertManagers.map((am) => am.url), alertmanagersChoice })
+ );
+ };
+
+ const onChangeAlertmanagers = (alertmanagers: string[]) => {
+ dispatch(addExternalAlertmanagersAction({ alertmanagers, alertmanagersChoice: alertmanagersChoice ?? 'all' }));
+ };
+
const getStatusColor = (status: string) => {
switch (status) {
case 'active':
- return 'green';
+ return theme.colors.success.main;
case 'pending':
- return 'yellow';
+ return theme.colors.warning.main;
default:
- return 'red';
+ return theme.colors.error.main;
}
};
@@ -107,49 +141,63 @@ export const ExternalAlertmanagers = () => {
buttonIcon="bell-slash"
/>
) : (
-
-
-
- Url
- Status
- Action
-
-
-
- {externalAlertManagers?.map((am, index) => {
- return (
-
-
- {am.url}
- {am.actualUrl ? (
-
-
-
- ) : null}
-
-
-
-
-
-
-
-
-
- setDeleteModalState({ open: true, index })}
- >
-
-
-
-
-
- );
- })}
-
-
+ <>
+
+
+
+ Url
+ Status
+ Action
+
+
+
+ {externalAlertManagers?.map((am, index) => {
+ return (
+
+
+ {am.url}
+ {am.actualUrl ? (
+
+
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+ setDeleteModalState({ open: true, index })}
+ >
+
+
+
+
+
+ );
+ })}
+
+
+
+
+ onChangeAlertmanagerChoice(value!)}
+ />
+
+
+ >
)}
{
onConfirm={() => onDelete(deleteModalState.index)}
onDismiss={() => setDeleteModalState({ open: false, index: 0 })}
/>
- {modalState.open && }
+ {modalState.open && (
+
+ )}
);
};
@@ -176,5 +230,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
display: flex;
justify-content: flex-end;
`,
- table: css``,
+ table: css`
+ margin-bottom: ${theme.spacing(2)};
+ `,
});
diff --git a/public/app/features/alerting/unified/components/amroutes/AmRoutesTable.test.ts b/public/app/features/alerting/unified/components/amroutes/AmRoutesTable.test.ts
index af54659548d..a7fcee29adf 100644
--- a/public/app/features/alerting/unified/components/amroutes/AmRoutesTable.test.ts
+++ b/public/app/features/alerting/unified/components/amroutes/AmRoutesTable.test.ts
@@ -1,7 +1,7 @@
import { MatcherOperator } from 'app/plugins/datasource/alertmanager/types';
import { FormAmRoute } from '../../types/amroutes';
import { MatcherFieldValue } from '../../types/silence-form';
-import { getFilteredRoutes } from './AmRoutesTable';
+import { deleteRoute, getFilteredRoutes, updatedRoute } from './AmRoutesTable';
const defaultAmRoute: FormAmRoute = {
id: '',
@@ -110,3 +110,79 @@ describe('getFilteredRoutes', () => {
expect(filteredRoutes).toContain(routes[2]);
});
});
+
+describe('updatedRoute', () => {
+ it('Should update an item of the same id', () => {
+ // Arrange
+ const routes: FormAmRoute[] = [buildAmRoute({ id: '1' }), buildAmRoute({ id: '2' }), buildAmRoute({ id: '3' })];
+
+ const routeUpdate: FormAmRoute = {
+ ...routes[1],
+ object_matchers: [buildMatcher('severity', 'critical', MatcherOperator.equal)],
+ };
+
+ // Act
+ const updatedRoutes = updatedRoute(routes, routeUpdate);
+
+ // Assert
+ expect(updatedRoutes).toHaveLength(3);
+ const changedRoute = updatedRoutes[1];
+
+ expect(changedRoute.object_matchers).toHaveLength(1);
+ expect(changedRoute.object_matchers[0].name).toBe('severity');
+ expect(changedRoute.object_matchers[0].value).toBe('critical');
+ expect(changedRoute.object_matchers[0].operator).toBe(MatcherOperator.equal);
+ });
+
+ it('Should not update any element when an element of matching id not found', () => {
+ // Arrange
+ const routes: FormAmRoute[] = [buildAmRoute({ id: '1' }), buildAmRoute({ id: '2' }), buildAmRoute({ id: '3' })];
+
+ const routeUpdate: FormAmRoute = {
+ ...routes[1],
+ id: '-1',
+ object_matchers: [buildMatcher('severity', 'critical', MatcherOperator.equal)],
+ };
+
+ // Act
+ const updatedRoutes = updatedRoute(routes, routeUpdate);
+
+ // Assert
+ expect(updatedRoutes).toHaveLength(3);
+
+ updatedRoutes.forEach((route) => {
+ expect(route.object_matchers).toHaveLength(0);
+ });
+ });
+});
+
+describe('deleteRoute', () => {
+ it('Should delete an element of the same id', () => {
+ // Arrange
+ const routes: FormAmRoute[] = [buildAmRoute({ id: '1' }), buildAmRoute({ id: '2' }), buildAmRoute({ id: '3' })];
+
+ const routeToDelete = routes[1];
+
+ // Act
+ const updatedRoutes = deleteRoute(routes, routeToDelete);
+
+ // Assert
+ expect(updatedRoutes).toHaveLength(2);
+ expect(updatedRoutes[0].id).toBe('1');
+ expect(updatedRoutes[1].id).toBe('3');
+ });
+
+ it('Should not delete anything when an element of matching id not found', () => {
+ // Arrange
+ const routes: FormAmRoute[] = [buildAmRoute({ id: '1' }), buildAmRoute({ id: '2' }), buildAmRoute({ id: '3' })];
+
+ // Act
+ const updatedRoutes = deleteRoute(routes, buildAmRoute({ id: '-1' }));
+
+ // Assert
+ expect(updatedRoutes).toHaveLength(3);
+ expect(updatedRoutes[0].id).toBe('1');
+ expect(updatedRoutes[1].id).toBe('2');
+ expect(updatedRoutes[2].id).toBe('3');
+ });
+});
diff --git a/public/app/features/alerting/unified/components/amroutes/AmRoutesTable.tsx b/public/app/features/alerting/unified/components/amroutes/AmRoutesTable.tsx
index b76807394e8..e5cb1df5d46 100644
--- a/public/app/features/alerting/unified/components/amroutes/AmRoutesTable.tsx
+++ b/public/app/features/alerting/unified/components/amroutes/AmRoutesTable.tsx
@@ -44,6 +44,23 @@ export const getFilteredRoutes = (routes: FormAmRoute[], labelMatcherQuery?: str
return filteredRoutes;
};
+export const updatedRoute = (routes: FormAmRoute[], updatedRoute: FormAmRoute): FormAmRoute[] => {
+ const newRoutes = [...routes];
+ const editIndex = newRoutes.findIndex((route) => route.id === updatedRoute.id);
+
+ if (editIndex >= 0) {
+ newRoutes[editIndex] = {
+ ...newRoutes[editIndex],
+ ...updatedRoute,
+ };
+ }
+ return newRoutes;
+};
+
+export const deleteRoute = (routes: FormAmRoute[], routeToRemove: FormAmRoute): FormAmRoute[] => {
+ return routes.filter((route) => route.id !== routeToRemove.id);
+};
+
export const AmRoutesTable: FC = ({
isAddMode,
onCancelAdd,
@@ -92,7 +109,7 @@ export const AmRoutesTable: FC = ({
id: 'actions',
label: 'Actions',
// eslint-disable-next-line react/display-name
- renderCell: (item, index) => {
+ renderCell: (item) => {
if (item.renderExpandedContent) {
return null;
}
@@ -118,10 +135,7 @@ export const AmRoutesTable: FC = ({
aria-label="Delete route"
name="trash-alt"
onClick={() => {
- const newRoutes = [...routes];
-
- newRoutes.splice(index, 1);
-
+ const newRoutes = deleteRoute(routes, item.data);
onChange(newRoutes);
}}
type="button"
@@ -144,11 +158,14 @@ export const AmRoutesTable: FC = ({
[isAddMode, routes, filteredRoutes]
);
- // expand the last item when adding
+ // expand the last item when adding or reset when the length changed
useEffect(() => {
if (isAddMode && dynamicTableRoutes.length) {
setExpandedId(dynamicTableRoutes[dynamicTableRoutes.length - 1].id);
}
+ if (!isAddMode && dynamicTableRoutes.length) {
+ setExpandedId(undefined);
+ }
}, [isAddMode, dynamicTableRoutes]);
if (routes.length > 0 && filteredRoutes.length === 0) {
@@ -168,7 +185,7 @@ export const AmRoutesTable: FC = ({
onCollapse={collapseItem}
onExpand={expandItem}
isExpanded={(item) => expandedId === item.id}
- renderExpandedContent={(item: RouteTableItemProps, index) =>
+ renderExpandedContent={(item: RouteTableItemProps) =>
isAddMode || editMode ? (
{
@@ -178,12 +195,8 @@ export const AmRoutesTable: FC = ({
setEditMode(false);
}}
onSave={(data) => {
- const newRoutes = [...routes];
+ const newRoutes = updatedRoute(routes, data);
- newRoutes[index] = {
- ...newRoutes[index],
- ...data,
- };
setEditMode(false);
onChange(newRoutes);
}}
@@ -193,13 +206,7 @@ export const AmRoutesTable: FC = ({
) : (
{
- const newRoutes = [...routes];
-
- newRoutes[index] = {
- ...item.data,
- ...data,
- };
-
+ const newRoutes = updatedRoute(routes, data);
onChange(newRoutes);
}}
receivers={receivers}
diff --git a/public/app/features/alerting/unified/components/receivers/form/ReceiverForm.tsx b/public/app/features/alerting/unified/components/receivers/form/ReceiverForm.tsx
index 516986bb8ee..ccc021b313b 100644
--- a/public/app/features/alerting/unified/components/receivers/form/ReceiverForm.tsx
+++ b/public/app/features/alerting/unified/components/receivers/form/ReceiverForm.tsx
@@ -5,7 +5,7 @@ import { useCleanup } from 'app/core/hooks/useCleanup';
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
import { NotifierDTO } from 'app/types';
import React, { useCallback } from 'react';
-import { useForm, FormProvider, FieldErrors, Validate } from 'react-hook-form';
+import { useForm, FormProvider, FieldErrors, Validate, SubmitHandler } from 'react-hook-form';
import { useControlledFieldArray } from '../../../hooks/useControlledFieldArray';
import { useUnifiedAlertingSelector } from '../../../hooks/useUnifiedAlertingSelector';
import { ChannelValues, CommonSettingsComponentType, ReceiverFormValues } from '../../../types/receiver-form';
@@ -94,7 +94,7 @@ export function ReceiverForm({
Because there is no default policy configured yet, this contact point will automatically be set as default.
)}
-