mirror of
https://github.com/grafana/grafana.git
synced 2026-01-08 21:22:59 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c256012971 |
@@ -3681,7 +3681,8 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "3"]
|
||||
],
|
||||
"public/app/core/utils/dag.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
|
||||
],
|
||||
"public/app/core/utils/deferred.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
@@ -4543,6 +4544,12 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
|
||||
],
|
||||
"public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.tsx:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "2"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "3"]
|
||||
],
|
||||
"public/app/features/dashboard/components/SubMenu/SubMenu.tsx:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
],
|
||||
@@ -4843,8 +4850,7 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "24"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "25"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "26"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "27"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "28"]
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "27"]
|
||||
],
|
||||
"public/app/features/dashboard/state/TimeModel.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
@@ -6224,6 +6230,9 @@ exports[`better eslint`] = {
|
||||
"public/app/features/transformers/utils.ts:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
"public/app/features/users/UsersActionBar.tsx:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
],
|
||||
"public/app/features/users/__mocks__/userMocks.ts:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "1"],
|
||||
@@ -7866,7 +7875,8 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "23"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "24"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "25"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "26"]
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "26"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "27"]
|
||||
],
|
||||
"public/app/plugins/datasource/loki/datasource.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
@@ -9031,6 +9041,12 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "2"]
|
||||
],
|
||||
"public/app/plugins/panel/alertlist/UnifiedAlertList.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
"public/app/plugins/panel/alertlist/unified-alerting/UngroupedView.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
"public/app/plugins/panel/annolist/AnnoListPanel.test.tsx:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
|
||||
|
||||
1
.bingo/.gitignore
vendored
1
.bingo/.gitignore
vendored
@@ -5,7 +5,6 @@
|
||||
# But not these files:
|
||||
!.gitignore
|
||||
!*.mod
|
||||
!*.sum
|
||||
!README.md
|
||||
!Variables.mk
|
||||
!variables.env
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Auto generated binary variables helper managed by https://github.com/bwplotka/bingo v0.6. DO NOT EDIT.
|
||||
# Auto generated binary variables helper managed by https://github.com/bwplotka/bingo v0.5.1. DO NOT EDIT.
|
||||
# All tools are designed to be build inside $GOBIN.
|
||||
BINGO_DIR := $(dir $(lastword $(MAKEFILE_LIST)))
|
||||
GOPATH ?= $(shell go env GOPATH)
|
||||
@@ -17,11 +17,11 @@ GO ?= $(shell which go)
|
||||
# @echo "Running drone"
|
||||
# @$(DRONE) <flags/args..>
|
||||
#
|
||||
DRONE := $(GOBIN)/drone-v1.5.0
|
||||
DRONE := $(GOBIN)/drone-v1.4.0
|
||||
$(DRONE): $(BINGO_DIR)/drone.mod
|
||||
@# Install binary/ries using Go 1.14+ build command. This is using bwplotka/bingo-controlled, separate go module with pinned dependencies.
|
||||
@echo "(re)installing $(GOBIN)/drone-v1.5.0"
|
||||
@cd $(BINGO_DIR) && $(GO) build -mod=mod -modfile=drone.mod -o=$(GOBIN)/drone-v1.5.0 "github.com/drone/drone-cli/drone"
|
||||
@echo "(re)installing $(GOBIN)/drone-v1.4.0"
|
||||
@cd $(BINGO_DIR) && $(GO) build -mod=mod -modfile=drone.mod -o=$(GOBIN)/drone-v1.4.0 "github.com/drone/drone-cli/drone"
|
||||
|
||||
WIRE := $(GOBIN)/wire-v0.5.0
|
||||
$(WIRE): $(BINGO_DIR)/wire.mod
|
||||
|
||||
@@ -4,4 +4,4 @@ go 1.17
|
||||
|
||||
replace github.com/docker/docker => github.com/docker/engine v17.12.0-ce-rc1.0.20200309214505-aa6a9891b09c+incompatible
|
||||
|
||||
require github.com/drone/drone-cli v1.5.0 // drone
|
||||
require github.com/drone/drone-cli v1.4.0 // drone
|
||||
|
||||
1030
.bingo/drone.sum
1030
.bingo/drone.sum
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
# Auto generated binary variables helper managed by https://github.com/bwplotka/bingo v0.6. DO NOT EDIT.
|
||||
# Auto generated binary variables helper managed by https://github.com/bwplotka/bingo v0.5.1. DO NOT EDIT.
|
||||
# All tools are designed to be build inside $GOBIN.
|
||||
# Those variables will work only until 'bingo get' was invoked, or if tools were installed via Makefile's Variables.mk.
|
||||
GOBIN=${GOBIN:=$(go env GOBIN)}
|
||||
@@ -8,7 +8,7 @@ if [ -z "$GOBIN" ]; then
|
||||
fi
|
||||
|
||||
|
||||
DRONE="${GOBIN}/drone-v1.5.0"
|
||||
DRONE="${GOBIN}/drone-v1.4.0"
|
||||
|
||||
WIRE="${GOBIN}/wire-v0.5.0"
|
||||
|
||||
|
||||
532
.drone.yml
532
.drone.yml
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,4 @@ on:
|
||||
|
||||
jobs:
|
||||
workflow-call:
|
||||
uses: grafana/code-coverage/.github/workflows/code-coverage.yml@v0.1.6
|
||||
with:
|
||||
frontend-path-regexp: public\/app\/plugins\/datasource\/(grafana-azure-monitor-datasource|cloud-monitoring|cloudwatch)
|
||||
backend-path-regexp: pkg\/tsdb\/(azuremonitor|cloudmonitoring|cloudwatch)
|
||||
uses: grafana/code-coverage/.github/workflows/code-coverage.yml@v0.1.2
|
||||
|
||||
66
CHANGELOG.md
66
CHANGELOG.md
@@ -1,69 +1,3 @@
|
||||
<!-- 9.0.6 START -->
|
||||
|
||||
# 9.0.6 (2022-08-01)
|
||||
|
||||
### Features and enhancements
|
||||
|
||||
- **Access Control:** Allow org admins to invite new users to their organization. [#52904](https://github.com/grafana/grafana/pull/52904), [@IevaVasiljeva](https://github.com/IevaVasiljeva)
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- **Grafana/toolkit:** Fix incorrect image and font generation for plugin builds. [#52927](https://github.com/grafana/grafana/pull/52927), [@academo](https://github.com/academo)
|
||||
- **Prometheus:** Fix adding of multiple values for regex operator. [#52978](https://github.com/grafana/grafana/pull/52978), [@ivanahuckova](https://github.com/ivanahuckova)
|
||||
- **UI/Card:** Fix card items always having pointer cursor. [#52809](https://github.com/grafana/grafana/pull/52809), [@gillesdemey](https://github.com/gillesdemey)
|
||||
|
||||
<!-- 9.0.6 END -->
|
||||
<!-- 9.0.5 START -->
|
||||
|
||||
# 9.0.5 (2022-07-26)
|
||||
|
||||
### Features and enhancements
|
||||
|
||||
- **Access control:** Show dashboard settings to users who can edit dashboard. [#52535](https://github.com/grafana/grafana/pull/52535), [@grafanabot](https://github.com/grafanabot)
|
||||
- **Alerting:** Allow the webhook notifier to support a custom Authorization header. [#52515](https://github.com/grafana/grafana/pull/52515), [@gotjosh](https://github.com/gotjosh)
|
||||
- **Chore:** Upgrade to Go version 1.17.12. [#52523](https://github.com/grafana/grafana/pull/52523), [@sakjur](https://github.com/sakjur)
|
||||
- **Plugins:** Add signature wildcard globbing for dedicated private plugin type. [#52163](https://github.com/grafana/grafana/pull/52163), [@wbrowne](https://github.com/wbrowne)
|
||||
- **Prometheus:** Don't show errors from unsuccessful API checks like rules or exemplar checks. [#52193](https://github.com/grafana/grafana/pull/52193), [@darrenjaneczek](https://github.com/darrenjaneczek)
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- **Access control:** Allow organisation admins to add existing users to org (#51668). [#52553](https://github.com/grafana/grafana/pull/52553), [@vtorosyan](https://github.com/vtorosyan)
|
||||
- **Alerting:** Fix alert panel instance-based rules filtering. [#52583](https://github.com/grafana/grafana/pull/52583), [@konrad147](https://github.com/konrad147)
|
||||
- **Apps:** Fixes navigation between different app plugin pages. [#52571](https://github.com/grafana/grafana/pull/52571), [@torkelo](https://github.com/torkelo)
|
||||
- **Cloudwatch:** Upgrade grafana-aws-sdk to fix auth issue with secret keys. [#52420](https://github.com/grafana/grafana/pull/52420), [@sarahzinger](https://github.com/sarahzinger)
|
||||
- **Grafana/toolkit:** Fix incorrect image and font generation for plugin builds. [#52661](https://github.com/grafana/grafana/pull/52661), [@academo](https://github.com/academo)
|
||||
- **Loki:** Fix `show context` not working in some occasions. [#52458](https://github.com/grafana/grafana/pull/52458), [@svennergr](https://github.com/svennergr)
|
||||
- **RBAC:** Fix permissions on dashboards and folders created by anonymous users. [#52615](https://github.com/grafana/grafana/pull/52615), [@gamab](https://github.com/gamab)
|
||||
|
||||
<!-- 9.0.5 END -->
|
||||
<!-- 9.0.4 START -->
|
||||
|
||||
# 9.0.4 (2022-07-20)
|
||||
|
||||
### Features and enhancements
|
||||
|
||||
- **Browse/Search:** Make browser back work properly when visiting Browse or search. [#52271](https://github.com/grafana/grafana/pull/52271), [@torkelo](https://github.com/torkelo)
|
||||
- **Logs:** Improve getLogRowContext API. [#52130](https://github.com/grafana/grafana/pull/52130), [@gabor](https://github.com/gabor)
|
||||
- **Loki:** Improve handling of empty responses. [#52397](https://github.com/grafana/grafana/pull/52397), [@gabor](https://github.com/gabor)
|
||||
- **Plugins:** Always validate root URL if specified in signature manfiest. [#52332](https://github.com/grafana/grafana/pull/52332), [@wbrowne](https://github.com/wbrowne)
|
||||
- **Preferences:** Get home dashboard from teams. [#52225](https://github.com/grafana/grafana/pull/52225), [@sakjur](https://github.com/sakjur)
|
||||
- **SQLStore:** Support Upserting multiple rows. [#52228](https://github.com/grafana/grafana/pull/52228), [@joeblubaugh](https://github.com/joeblubaugh)
|
||||
- **Traces:** Add more template variables in Tempo & Zipkin. [#52306](https://github.com/grafana/grafana/pull/52306), [@joey-grafana](https://github.com/joey-grafana)
|
||||
- **Traces:** Remove serviceMap feature flag. [#52375](https://github.com/grafana/grafana/pull/52375), [@joey-grafana](https://github.com/joey-grafana)
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- **Access Control:** Fix missing folder permissions. [#52410](https://github.com/grafana/grafana/pull/52410), [@IevaVasiljeva](https://github.com/IevaVasiljeva)
|
||||
- **Access control:** Fix org user removal for OSS users. [#52473](https://github.com/grafana/grafana/pull/52473), [@IevaVasiljeva](https://github.com/IevaVasiljeva)
|
||||
- **Alerting:** Fix Slack notification preview. [#50230](https://github.com/grafana/grafana/pull/50230), [@ekrucio](https://github.com/ekrucio)
|
||||
- **Alerting:** Fix Slack push notifications. [#52391](https://github.com/grafana/grafana/pull/52391), [@grobinson-grafana](https://github.com/grobinson-grafana)
|
||||
- **Alerting:** Fixes slack push notifications. [#50267](https://github.com/grafana/grafana/pull/50267), [@jgillick](https://github.com/jgillick)
|
||||
- **Alerting:** Preserve new-lines from custom email templates in rendered email. [#52253](https://github.com/grafana/grafana/pull/52253), [@alexweav](https://github.com/alexweav)
|
||||
- **Insights:** Fix dashboard and data source insights pages. (Enterprise)
|
||||
- **Log:** Fix text logging for unsupported types. [#51306](https://github.com/grafana/grafana/pull/51306), [@papagian](https://github.com/papagian)
|
||||
- **Loki:** Fix incorrect TopK value type in query builder. [#52226](https://github.com/grafana/grafana/pull/52226), [@ivanahuckova](https://github.com/ivanahuckova)
|
||||
|
||||
<!-- 9.0.4 END -->
|
||||
<!-- 9.0.3 START -->
|
||||
|
||||
# 9.0.3 (2022-07-14)
|
||||
|
||||
@@ -20,13 +20,14 @@ COPY emails emails
|
||||
ENV NODE_ENV production
|
||||
RUN yarn build
|
||||
|
||||
FROM golang:1.17.12-alpine3.15 as go-builder
|
||||
FROM golang:1.17.11-alpine3.15 as go-builder
|
||||
|
||||
RUN apk add --no-cache gcc g++ make
|
||||
|
||||
WORKDIR /grafana
|
||||
|
||||
COPY go.mod go.sum embed.go Makefile build.go package.json ./
|
||||
COPY cue cue
|
||||
COPY packages/grafana-schema packages/grafana-schema
|
||||
COPY public/app/plugins public/app/plugins
|
||||
COPY public/api-spec.json public/api-spec.json
|
||||
|
||||
@@ -21,7 +21,7 @@ COPY emails emails
|
||||
ENV NODE_ENV production
|
||||
RUN yarn build
|
||||
|
||||
FROM golang:1.17.12 AS go-builder
|
||||
FROM golang:1.17.11 AS go-builder
|
||||
|
||||
WORKDIR /src/grafana
|
||||
|
||||
@@ -29,6 +29,7 @@ COPY go.mod go.sum embed.go ./
|
||||
COPY Makefile build.go package.json ./
|
||||
COPY .bingo .bingo
|
||||
COPY pkg pkg/
|
||||
COPY cue cue/
|
||||
COPY cue.mod cue.mod/
|
||||
COPY packages/grafana-schema packages/grafana-schema/
|
||||
COPY public/app/plugins public/app/plugins/
|
||||
|
||||
@@ -125,7 +125,7 @@ path = grafana.db
|
||||
# For "sqlite3" only. cache mode setting used for connecting to the database
|
||||
cache_mode = private
|
||||
|
||||
# For "mysql" only if migrationLocking feature toggle is set. How many seconds to wait before failing to lock the database for the migrations, default is 0.
|
||||
# For "mysql" only if lockingMigration feature toggle is set. How many seconds to wait before failing to lock the database for the migrations, default is 0.
|
||||
locking_attempt_timeout_sec = 0
|
||||
|
||||
#################################### Cache server #############################
|
||||
@@ -842,8 +842,8 @@ max_attempts = 3
|
||||
min_interval = 10s
|
||||
|
||||
[unified_alerting.screenshots]
|
||||
# Enable screenshots in notifications. This option requires the Grafana Image Renderer plugin.
|
||||
# For more information on configuration options, refer to [rendering].
|
||||
# Enable screenshots in notifications. This option requires a remote HTTP image rendering service. Please
|
||||
# see [rendering] for further configuration options.
|
||||
capture = false
|
||||
|
||||
# The maximum number of screenshots that can be taken at the same time. This option is different from
|
||||
|
||||
@@ -126,7 +126,7 @@
|
||||
# For "sqlite3" only. cache mode setting used for connecting to the database. (private, shared)
|
||||
;cache_mode = private
|
||||
|
||||
# For "mysql" only if migrationLocking feature toggle is set. How many seconds to wait before failing to lock the database for the migrations, default is 0.
|
||||
# For "mysql" only if lockingMigration feature toggle is set. How many seconds to wait before failing to lock the database for the migrations, default is 0.
|
||||
;locking_attempt_timeout_sec = 0
|
||||
|
||||
################################### Data sources #########################
|
||||
|
||||
@@ -191,4 +191,4 @@ import { Plural } from "@lingui/macro"
|
||||
|
||||
## Documentation
|
||||
|
||||
[Grafana's documentation](https://grafana.com/docs/grafana/latest/) is not yet open for translation and should be authored in American English only.
|
||||
[Grafana's documentation](https://grafana.com/docs/grafana/latest/) is not yet open for translation and should be authored in English only.
|
||||
@@ -14,18 +14,24 @@ weight: 700
|
||||
|
||||
# API keys
|
||||
|
||||
An API key is a randomly generated string that external systems use to interact with Grafana HTTP APIs.
|
||||
API keys can be used to interact with Grafana HTTP APIs.
|
||||
|
||||
When you create an API key, you specify a **Role** that determines the permissions associated with the API key. Role permissions control that actions the API key can perform on Grafana resources.
|
||||
|
||||
> **Note:** If you use Grafana v8.5 or newer, use service accounts instead of API keys. For more information, refer to [Service accounts]({{< relref "../service-accounts/" >}}).
|
||||
We recommend using service accounts instead of API keys if you are on Grafana 8.5+, for more information refer to [About service accounts]({{< relref "../service-accounts/about-service-accounts/#" >}}).
|
||||
|
||||
{{< section >}}
|
||||
|
||||
## About API keys
|
||||
|
||||
An API key is a randomly generated string that external systems use to interact with Grafana HTTP APIs.
|
||||
|
||||
When you create an API key, you specify a **Role** that determines the permissions associated with the API key. Role permissions control that actions the API key can perform on Grafana resources. For more information about creating API keys, refer to [Create an API key]({{< relref "create-api-key/#" >}}).
|
||||
|
||||
## Create an API key
|
||||
|
||||
Create an API key when you want to manage your computed workload with a user.
|
||||
|
||||
For more information about API keys, refer to [About API keys in Grafana]({{< relref "about-api-keys/" >}}).
|
||||
|
||||
This topic shows you how to create an API key using the Grafana UI. You can also create an API key using the Grafana HTTP API. For more information about creating API keys via the API, refer to [Create API key via API]({{< relref "../../developers/http_api/create-api-tokens-for-org/#how-to-create-a-new-organization-and-an-api-token" >}}).
|
||||
|
||||
### Before you begin:
|
||||
|
||||
@@ -11,7 +11,7 @@ weight: 300
|
||||
|
||||
# Roles and permissions
|
||||
|
||||
A _user_ is any individual who can log in to Grafana. Each user is associated with a _role_ that includes _permissions_. Permissions determine the tasks a user can perform in the system. For example, the **Admin** role includes permissions for an administrator to create and delete users.
|
||||
A _user_ is defined as any individual who can log in to Grafana. Each user is associated with a _role_ that includes _permissions_. Permissions determine the tasks a user can perform in the system. For example, the **Admin** role includes permissions for an administrator to create and delete users.
|
||||
|
||||
You can assign a user one of three types of permissions:
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ The following list contains role-based access control actions.
|
||||
| `licensing:read` | n/a | Read licensing information. |
|
||||
| `licensing:write` | n/a | Update the license token. |
|
||||
| `org.users:write` | `users:*` <br> `users:id:*` | Update the organization role (`Viewer`, `Editor`, or `Admin`) of a user. |
|
||||
| `org.users:add` | `users:*` | Add a user to an organization or invite a new user to an organization. |
|
||||
| `org.users:add` | `users:*` | Add a user to an organization. |
|
||||
| `org.users:read` | `users:*` <br> `users:id:*` | Get user profiles within an organization. |
|
||||
| `org.users:remove` | `users:*` <br> `users:id:*` | Remove a user from an organization. |
|
||||
| `org:create` | n/a | Create an organization. |
|
||||
|
||||
@@ -60,7 +60,7 @@ The following tables list permissions associated with basic and fixed roles.
|
||||
| `fixed:licensing:reader` | `licensing:read`<br>`licensing.reports:read` | Read licensing information and licensing reports. |
|
||||
| `fixed:licensing:writer` | All permissions from `fixed:licensing:viewer` and <br>`licensing:write`<br>`licensing:delete` | Read licensing information and licensing reports, update and delete the license token. |
|
||||
| `fixed:org.users:reader` | `org.users:read` | Read users within a single organization. |
|
||||
| `fixed:org.users:writer` | All permissions from `fixed:org.users:reader` and <br>`org.users:add`<br>`org.users:remove`<br>`org.users:write` | Within a single organization, add a user, invite a new user, read information about a user and their role, remove a user from that organization, or change the role of a user. |
|
||||
| `fixed:org.users:writer` | All permissions from `fixed:org.users:reader` and <br>`org.users:add`<br>`org.users:remove`<br>`org.users:write` | Within a single organization, add a user, invite a user, read information about a user and their role, remove a user from that organization, or change the role of a user. |
|
||||
| `fixed:organization:maintainer` | All permissions from `fixed:organization:reader` and <br> `orgs:write`<br>`orgs:create`<br>`orgs:delete`<br>`orgs.quotas:write` | Create, read, write, or delete an organization. Read or write its quotas. This role needs to be assigned globally. |
|
||||
| `fixed:organization:reader` | `orgs:read`<br>`orgs.quotas:read` | Read an organization and its quotas. |
|
||||
| `fixed:organization:writer` | All permissions from `fixed:organization:reader` and <br> `orgs:write`<br>`orgs.preferences:read`<br>`orgs.preferences:write` | Read an organization, its quotas, or its preferences. Update organization properties, or its preferences. |
|
||||
|
||||
@@ -16,7 +16,13 @@ weight: 800
|
||||
|
||||
# Service accounts
|
||||
|
||||
You can use a service account to run automated workloads in Grafana, such as dashboard provisioning, configuration, or report generation. Create service accounts and tokens to authenticate applications, such as Terraform, with the Grafana API.
|
||||
You can use service accounts to run automated or compute workloads.
|
||||
|
||||
{{< section >}}
|
||||
|
||||
## About service accounts
|
||||
|
||||
A service account can be used to run automated workloads in Grafana, like dashboard provisioning, configuration, or report generation. Create service accounts and tokens to authenticate applications like Terraform with the Grafana API.
|
||||
|
||||
> **Note:** Service accounts are available in Grafana 8.5+ as a beta feature. To enable service accounts, refer to [Enable service accounts]({{< relref "enable-service-accounts/#" >}}) section. Service accounts will eventually replace [API keys]({{< relref "../api-keys/" >}}) as the primary way to authenticate applications that interact with Grafana.
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ weight: 114
|
||||
|
||||
Grafana Alerting allows you to learn about problems in your systems moments after they occur. Create, manage, and take action on your alerts in a single, consolidated view, and improve your team’s ability to identify and resolve issues quickly.
|
||||
|
||||
Grafana Alerting is available for Grafana OSS, Grafana Enterprise, or Grafana Cloud. With Mimir and Loki alert rules you can run alert expressions closer to your data and at massive scale, all managed by the Grafana UI you are already familiar with.
|
||||
Grafana Alerting is available for for Grafana OSS, Grafana Enterprise, or Grafana Cloud. With Mimir and Loki alert rules you can run alert expressions closer to your data and at massive scale, all managed by the Grafana UI you are already familiar with.
|
||||
|
||||
Watch this video to learn more about Grafana Alerting: {{< vimeo 720001629 >}}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ weight: 400
|
||||
|
||||
# Create a Grafana managed alerting rule
|
||||
|
||||
Grafana allows you to create alerting rules that query one or more data sources, reduce or transform the results and compare them to each other or to fix thresholds. When these are executed, Grafana sends notifications to the contact point. For information on Grafana Alerting, see [About Grafana Alerting]({{< relref "../" >}}) which explains the various components of Grafana Alerting. We also recommend that you familiarize yourself with some of the [fundamental concepts]({{< relref "../fundamentals/" >}}) of Grafana Alerting.
|
||||
Grafana allows you to create alerting rules that query one or more data sources, reduce or transform the results and compare them to each other or to fix thresholds. When these are executed, Grafana sends notifications to the contact point. For information on Grafana Alerting, see [About Grafana Alerting]({{< relref "../about-alerting/" >}}) which explains the various components of Grafana Alerting. We also recommend that you familiarize yourself with some of the [fundamental concepts]({{< relref "../fundamentals/" >}}) of Grafana Alerting.
|
||||
|
||||
Watch this video to learn more about creating alerts: {{< vimeo 720001934 >}}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ You can create and manage recording rules for an external Grafana Mimir or Loki
|
||||
|
||||
- **Loki** - The `local` rule storage type, default for the Loki data source, supports only viewing of rules. To edit rules, configure one of the other rule storage types.
|
||||
|
||||
- **Grafana Mimir** - use the `/prometheus` prefix. The Prometheus data source supports both Grafana Mimir and Prometheus, and Grafana expects that both the [Query API](https://grafana.com/docs/mimir/latest/operators-guide/reference-http-api/#querier--query-frontend) and [Ruler API](https://grafana.com/docs/mimir/latest/operators-guide/reference-http-api/#ruler) are under the same URL. You cannot provide a separate URL for the Ruler API.
|
||||
- **Grafana Mimir** - use the [legacy `/api/prom` prefix](https://grafana.com/docs/mimir/latest/operators-guide/reference-http-api/#path-prefixes), not `/prometheus`. The Prometheus data source supports both Grafana Mimir and Prometheus, and Grafana expects that both the [Query API](https://grafana.com/docs/mimir/latest/operators-guide/reference-http-api/#querier--query-frontend) and [Ruler API](https://grafana.com/docs/mimir/latest/operators-guide/reference-http-api/#ruler) are under the same URL. You cannot provide a separate URL for the Ruler API.
|
||||
|
||||
> **Note:** If you do not want to manage alerting rules for a particular Loki or Prometheus data source, go to its settings and clear the **Manage alerts via Alerting UI** checkbox.
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ weight: 400
|
||||
|
||||
# Create a Grafana Mimir or Loki managed alerting rule
|
||||
|
||||
Grafana allows you to create alerting rules for an external Grafana Mimir or Loki instance that has ruler API enabled. For information on Grafana Alerting, see [About Grafana Alerting]({{< relref "../" >}}) which explains the various components of Grafana Alerting. We also recommend that you familiarize yourself with some of the [fundamental concepts]({{< relref "../fundamentals/" >}}) of Grafana Alerting.
|
||||
Grafana allows you to create alerting rules for an external Grafana Mimir or Loki instance that has ruler API enabled. For information on Grafana Alerting, see [About Grafana Alerting]({{< relref "../about-alerting/" >}}) which explains the various components of Grafana Alerting. We also recommend that you familiarize yourself with some of the [fundamental concepts]({{< relref "../fundamentals/" >}}) of Grafana Alerting.
|
||||
|
||||
## Before you begin
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@ keywords:
|
||||
- guide
|
||||
- contact point
|
||||
- templating
|
||||
title: List of contact point types
|
||||
title: List of notifiers
|
||||
weight: 130
|
||||
---
|
||||
|
||||
# List of supported contact point types
|
||||
# List of supported notifiers
|
||||
|
||||
The following table lists the contact point types supported by Grafana.
|
||||
The following table lists the notifiers (contact point types) supported by Grafana.
|
||||
|
||||
| Name | Type | Grafana Alertmanager | Other Alertmanagers |
|
||||
| ------------------------------------------------ | ------------------------- | -------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
|
||||
@@ -23,7 +23,7 @@ These are the data sources that are compatible with and supported by Grafana Ale
|
||||
- [Graphite]({{< relref "../../datasources/graphite/" >}})
|
||||
- [InfluxDB]({{< relref "../../datasources/influxdb/" >}})
|
||||
- [Loki]({{< relref "../../datasources/loki/" >}})
|
||||
- [Microsoft SQL Server (MSSQL)]({{< relref "../../datasources/mssql/" >}})
|
||||
- ]Microsoft SQL Server (MSSQL)]({{< relref "../../datasources/mssql/" >}})
|
||||
- [MySQL]({{< relref "../../datasources/mysql/" >}})
|
||||
- [Open TSDB]({{< relref "../../datasources/opentsdb/" >}})
|
||||
- [PostgreSQL]({{< relref "../../datasources/postgres/" >}})
|
||||
|
||||
@@ -34,8 +34,8 @@ To use images in notifications, Grafana must be set up to use [image rendering](
|
||||
|
||||
If Grafana has been set up to use [image rendering]({{< relref "../setup-grafana/image-rendering/" >}}) images in notifications can be turned on via the `capture` option in `[unified_alerting.screenshots]`:
|
||||
|
||||
# Enable screenshots in notifications. This option requires the Grafana Image Renderer plugin.
|
||||
# For more information on configuration options, refer to [rendering].
|
||||
# Enable screenshots in notifications. This option requires a remote HTTP image rendering service. Please
|
||||
# see [rendering] for further configuration options.
|
||||
capture = true
|
||||
|
||||
It is recommended that `max_concurrent_screenshots` is set to a value that is less than or equal to `concurrent_render_request_limit`. The default value for both `max_concurrent_screenshots` and `concurrent_render_request_limit` is `5`:
|
||||
@@ -71,7 +71,7 @@ Images in notifications are supported in the following notifiers and additional
|
||||
| Opsgenie | No | Yes |
|
||||
| Pagerduty | No | Yes |
|
||||
| Prometheus Alertmanager | No | No |
|
||||
| Pushover | Yes | No |
|
||||
| Pushover | No | No |
|
||||
| Sensu Go | No | No |
|
||||
| Slack | No | Yes |
|
||||
| Telegram | No | No |
|
||||
|
||||
@@ -30,7 +30,6 @@ Read more about the available dimensions in the [CloudWatch Metrics and Dimensio
|
||||
| `EC2 Instance Attributes` | Returns a list of attributes matching the specified `region`, `attribute_name`, and `filters`. |
|
||||
| `Resource ARNs` | Returns a list of ARNs matching the specified `region`, `resource_type` and `tags`. |
|
||||
| `Statistics` | Returns a list of all the standard statistics. |
|
||||
| `LogGroups` | Returns a list of all log groups matching the specified `region`. |
|
||||
|
||||
For details about the metrics CloudWatch provides, please refer to the [CloudWatch documentation](https://docs.aws.amazon.com/AmazonCloudWatch/latest/DeveloperGuide/CW_Support_For_AWS.html).
|
||||
|
||||
|
||||
@@ -18,9 +18,7 @@ weight: 999
|
||||
|
||||
# Deprecated Application Insights and Insights Analytics
|
||||
|
||||
Application Insights and Insights Analytics are two ways to query the same Azure Application Insights data, which can also be queried from Metrics and Logs. In Grafana 8.0, Application Insights and Insights Analytics were deprecated and made read-only in favor of querying this data through Metrics and Logs.
|
||||
|
||||
These query methods were completely removed in Grafana 9.0.
|
||||
Application Insights and Insights Analytics are two ways to query the same Azure Application Insights data, which can also be queried from Metrics and Logs. In Grafana 8.0, Application Insights and Insights Analytics are deprecated and made read-only in favor of querying this data through Metrics and Logs. Existing queries will continue to work, but you cannot edit them. New panels are not able to use Application Insights or Insights Analytics.
|
||||
|
||||
Azure Monitor Metrics and Azure Monitor Logs do not use Application Insights API keys, so make sure the data source is configured with an Azure AD app registration that has access to Application Insights.
|
||||
|
||||
|
||||
@@ -471,7 +471,7 @@ Content-Type: application/json
|
||||
|
||||
```http
|
||||
POST /api/admin/ldap/reload HTTP/1.1
|
||||
Accept: application/json
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ title: 'Alerting HTTP API '
|
||||
|
||||
# Alerting API
|
||||
|
||||
> **Note:** This topic is relevant for the [legacy dashboard alerts](https://grafana.com/docs/grafana/v8.5/alerting/old-alerting/) only.
|
||||
> **Note:** This topic is relevant for the [legacy dashboard alerts]({{< relref "../../old-alerting/" >}}) only.
|
||||
|
||||
You can find Grafana alerting API specification details [here](https://editor.swagger.io/?url=https://raw.githubusercontent.com/grafana/grafana/main/pkg/services/ngalert/api/tooling/post.json). Also, refer to [Grafana alerting alerts documentation]({{< relref "../../alerting/" >}}) for details on how to create and manage new alerts.
|
||||
|
||||
|
||||
@@ -185,7 +185,7 @@ Content-Type: application/json
|
||||
|
||||
```
|
||||
|
||||
**Example response**:
|
||||
**Example response**:
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
|
||||
@@ -19,9 +19,7 @@ This functionality is similar to the panel inspector tasks [Inspect query perfor
|
||||
|
||||
## Query history
|
||||
|
||||
Query history is a list of queries that you used in Explore. The history is stored in the Grafana database and it is not shared with other users. The retention period for queries in history is two weeks. Queries older than two weeks are automatically deleted. To open and interact with your history, click the **Query history** button in Explore.
|
||||
|
||||
> **Note**: Starred queries are not subject to the two weeks retention period and they are not deleted.
|
||||
Query history is a list of queries that you have used in Explore. The history is local to your browser and is not shared. To open and interact with your history, click the **Query history** button in Explore.
|
||||
|
||||
### View query history
|
||||
|
||||
@@ -45,6 +43,10 @@ By default, query history shows you the most recent queries. You can sort your h
|
||||
1. Select one of the following options:
|
||||
- Newest first
|
||||
- Oldest first
|
||||
- Data source A-Z
|
||||
- Data source Z-A
|
||||
|
||||
> **Note:** If you are in split mode, then the chosen sorting mode applies only to the active panel.
|
||||
|
||||
### Filter query history
|
||||
|
||||
@@ -59,6 +61,8 @@ In **Query history** tab it is also possible to filter queries by date using the
|
||||
- By dragging top handle, adjust start date.
|
||||
- By dragging top handle, adjust end date.
|
||||
|
||||
> **Note:** If you are in split mode, filters are applied only to your currently active panel.
|
||||
|
||||
### Search in query history
|
||||
|
||||
You can search in your history across queries and your comments. Search is possible for queries in the Query history tab and Starred tab.
|
||||
@@ -70,9 +74,12 @@ You can search in your history across queries and your comments. Search is possi
|
||||
|
||||
You can customize the query history in the Settings tab. Options are described in the table below.
|
||||
|
||||
| Setting | Default value |
|
||||
| ----------------------------- | ----------------- |
|
||||
| Change the default active tab | Query history tab |
|
||||
| Setting | Default value |
|
||||
| ------------------------------------------------------------- | --------------------------------------- |
|
||||
| Period of time for which Grafana will save your query history | 1 week |
|
||||
| Change the default active tab | Query history tab |
|
||||
| Only show queries for data source currently active in Explore | True |
|
||||
| Clear query history | Permanently deletes all stored queries. |
|
||||
|
||||
> **Note:** Query history settings are global, and applied to both panels in split mode.
|
||||
|
||||
|
||||
@@ -27,15 +27,9 @@ Grafana can be installed on many different operating systems. For a list of the
|
||||
|
||||
To sign in to Grafana for the first time:
|
||||
|
||||
1. Open your web browser and go to http://localhost:3000/.
|
||||
|
||||
The default HTTP port that Grafana listens to is `3000` unless you have configured a different port.
|
||||
|
||||
1. On the sign-in page, enter `admin` for the username and password.
|
||||
1. Click **Sign in**.
|
||||
|
||||
If successful, you will see a prompt to change the password.
|
||||
|
||||
1. Open your web browser and go to http://localhost:3000/. The default HTTP port that Grafana listens to is `3000` unless you have configured a different port.
|
||||
1. On the signin page, enter `admin` for username and password.
|
||||
1. Click **Sign in**. If successful, you will see a prompt to change the password.
|
||||
1. Click **OK** on the prompt and change your password.
|
||||
|
||||
> **Note:** We strongly recommend that you change the default administrator password.
|
||||
@@ -47,10 +41,7 @@ To create your first dashboard:
|
||||
1. Click the **New dashboard** item under the **Dashboards** icon in the side menu.
|
||||
1. On the dashboard, click **Add an empty panel**.
|
||||
1. In the New dashboard/Edit panel view, go to the **Query** tab.
|
||||
1. Configure your [query]({{< relref "../panels/query-a-data-source/add-a-query/" >}}) by selecting `-- Grafana --` from the data source selector.
|
||||
|
||||
This generates the Random Walk dashboard.
|
||||
|
||||
1. Configure your [query]({{< relref "../panels/query-a-data-source/add-a-query/" >}}) by selecting `-- Grafana --` from the data source selector. This generates the Random Walk dashboard.
|
||||
1. Click the **Save** icon in the top right corner of your screen to save the dashboard.
|
||||
1. Add a descriptive name, and then click **Save**.
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
aliases:
|
||||
- /docs/grafana/latest/panels/working-with-panels/configure-legend/
|
||||
- /docs/grafana/latest/visualizations/configure-legend/
|
||||
- /docs/sources/panels/working-with-panels/configure-legend/
|
||||
title: Configure a legend
|
||||
weight: 1300
|
||||
weight: 70
|
||||
---
|
||||
|
||||
# Configure a legend
|
||||
@@ -41,7 +41,13 @@ This topic currently applies to the following visualizations:
|
||||
|
||||
As way to add more context to a visualization, you can add series data values to a legend. You can add as many values as you'd like; after you apply your changes, you can horizontally scroll the legend to see all values.
|
||||
|
||||
1. Edit a panel.
|
||||
### Before you begin
|
||||
|
||||
- Add a panel
|
||||
|
||||
**To add values to a legend**:
|
||||
|
||||
1. Open a panel.
|
||||
|
||||
1. In the panel display options pane, locate the **Legend** section.
|
||||
|
||||
@@ -55,7 +61,13 @@ As way to add more context to a visualization, you can add series data values to
|
||||
|
||||
By default, Grafana specifies the color of your series data, which you can change.
|
||||
|
||||
1. Edit a panel.
|
||||
### Before you begin
|
||||
|
||||
- Add a panel
|
||||
|
||||
**To change a series color, perform the following steps**:
|
||||
|
||||
1. Open the panel.
|
||||
|
||||
1. In the legend, click the color bar associated with the series.
|
||||
|
||||
@@ -67,7 +79,7 @@ By default, Grafana specifies the color of your series data, which you can chang
|
||||
|
||||
## Sort series
|
||||
|
||||
You can change legend mode to **Table** and choose [calculations]({{< relref "../../panels/calculation-types/" >}}) to be displayed in the legend. Click the calculation name header in the legend table to sort the values in the table in ascending or descending order.
|
||||
Change legend mode to **Table** and choose [calculations]({{< relref "../calculation-types/" >}}) to be displayed in the legend. Click the calculation name header in the legend table to sort the values in the table in ascending or descending order.
|
||||
|
||||
The sort order affects the positions of the bars in the Bar chart panel as well as the order of stacked series in the Time series and Bar chart panels.
|
||||
|
||||
@@ -19,7 +19,7 @@ You can place any panel in any location you want and controls its size. The chan
|
||||
|
||||
1. Hover your cursor over the panel, and click-and-drag the panel to its new location.
|
||||
|
||||
1. To resize a panel, click the zoom in (+) and zoom out (-) icons.
|
||||
1. To resize a penal, click the zoom in (+) and zoom out (-) icons.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -10,9 +10,6 @@ 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 9.0.6]({{< relref "release-notes-9-0-6" >}})
|
||||
- [Release notes for 9.0.5]({{< relref "release-notes-9-0-5" >}})
|
||||
- [Release notes for 9.0.4]({{< relref "release-notes-9-0-4" >}})
|
||||
- [Release notes for 9.0.3]({{< relref "release-notes-9-0-3" >}})
|
||||
- [Release notes for 9.0.2]({{< relref "release-notes-9-0-2" >}})
|
||||
- [Release notes for 9.0.1]({{< relref "release-notes-9-0-1" >}})
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
+++
|
||||
title = "Release notes for Grafana 9.0.4"
|
||||
hide_menu = true
|
||||
+++
|
||||
|
||||
<!-- Auto generated by update changelog github action -->
|
||||
|
||||
# Release notes for Grafana 9.0.4
|
||||
|
||||
### Features and enhancements
|
||||
|
||||
- **Browse/Search:** Make browser back work properly when visiting Browse or search. [#52271](https://github.com/grafana/grafana/pull/52271), [@torkelo](https://github.com/torkelo)
|
||||
- **Logs:** Improve getLogRowContext API. [#52130](https://github.com/grafana/grafana/pull/52130), [@gabor](https://github.com/gabor)
|
||||
- **Loki:** Improve handling of empty responses. [#52397](https://github.com/grafana/grafana/pull/52397), [@gabor](https://github.com/gabor)
|
||||
- **Plugins:** Always validate root URL if specified in signature manfiest. [#52332](https://github.com/grafana/grafana/pull/52332), [@wbrowne](https://github.com/wbrowne)
|
||||
- **Preferences:** Get home dashboard from teams. [#52225](https://github.com/grafana/grafana/pull/52225), [@sakjur](https://github.com/sakjur)
|
||||
- **SQLStore:** Support Upserting multiple rows. [#52228](https://github.com/grafana/grafana/pull/52228), [@joeblubaugh](https://github.com/joeblubaugh)
|
||||
- **Traces:** Add more template variables in Tempo & Zipkin. [#52306](https://github.com/grafana/grafana/pull/52306), [@joey-grafana](https://github.com/joey-grafana)
|
||||
- **Traces:** Remove serviceMap feature flag. [#52375](https://github.com/grafana/grafana/pull/52375), [@joey-grafana](https://github.com/joey-grafana)
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- **Access Control:** Fix missing folder permissions. [#52410](https://github.com/grafana/grafana/pull/52410), [@IevaVasiljeva](https://github.com/IevaVasiljeva)
|
||||
- **Access control:** Fix org user removal for OSS users. [#52473](https://github.com/grafana/grafana/pull/52473), [@IevaVasiljeva](https://github.com/IevaVasiljeva)
|
||||
- **Alerting:** Fix Slack notification preview. [#50230](https://github.com/grafana/grafana/pull/50230), [@ekrucio](https://github.com/ekrucio)
|
||||
- **Alerting:** Fix Slack push notifications. [#52391](https://github.com/grafana/grafana/pull/52391), [@grobinson-grafana](https://github.com/grobinson-grafana)
|
||||
- **Alerting:** Fixes slack push notifications. [#50267](https://github.com/grafana/grafana/pull/50267), [@jgillick](https://github.com/jgillick)
|
||||
- **Alerting:** Preserve new-lines from custom email templates in rendered email. [#52253](https://github.com/grafana/grafana/pull/52253), [@alexweav](https://github.com/alexweav)
|
||||
- **Insights:** Fix dashboard and data source insights pages. (Enterprise)
|
||||
- **Log:** Fix text logging for unsupported types. [#51306](https://github.com/grafana/grafana/pull/51306), [@papagian](https://github.com/papagian)
|
||||
- **Loki:** Fix `show context` not working in some occasions. [#52458](https://github.com/grafana/grafana/pull/52458), [@svennergr](https://github.com/svennergr)
|
||||
- **Loki:** Fix incorrect TopK value type in query builder. [#52226](https://github.com/grafana/grafana/pull/52226), [@ivanahuckova](https://github.com/ivanahuckova)
|
||||
@@ -1,26 +0,0 @@
|
||||
+++
|
||||
title = "Release notes for Grafana 9.0.5"
|
||||
hide_menu = true
|
||||
+++
|
||||
|
||||
<!-- Auto generated by update changelog github action -->
|
||||
|
||||
# Release notes for Grafana 9.0.5
|
||||
|
||||
### Features and enhancements
|
||||
|
||||
- **Access control:** Show dashboard settings to users who can edit dashboard. [#52535](https://github.com/grafana/grafana/pull/52535), [@grafanabot](https://github.com/grafanabot)
|
||||
- **Alerting:** Allow the webhook notifier to support a custom Authorization header. [#52515](https://github.com/grafana/grafana/pull/52515), [@gotjosh](https://github.com/gotjosh)
|
||||
- **Chore:** Upgrade to Go version 1.17.12. [#52523](https://github.com/grafana/grafana/pull/52523), [@sakjur](https://github.com/sakjur)
|
||||
- **Plugins:** Add signature wildcard globbing for dedicated private plugin type. [#52163](https://github.com/grafana/grafana/pull/52163), [@wbrowne](https://github.com/wbrowne)
|
||||
- **Prometheus:** Don't show errors from unsuccessful API checks like rules or exemplar checks. [#52193](https://github.com/grafana/grafana/pull/52193), [@darrenjaneczek](https://github.com/darrenjaneczek)
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- **Access control:** Allow organisation admins to add existing users to org (#51668). [#52553](https://github.com/grafana/grafana/pull/52553), [@vtorosyan](https://github.com/vtorosyan)
|
||||
- **Alerting:** Fix alert panel instance-based rules filtering. [#52583](https://github.com/grafana/grafana/pull/52583), [@konrad147](https://github.com/konrad147)
|
||||
- **Apps:** Fixes navigation between different app plugin pages. [#52571](https://github.com/grafana/grafana/pull/52571), [@torkelo](https://github.com/torkelo)
|
||||
- **Cloudwatch:** Upgrade grafana-aws-sdk to fix auth issue with secret keys. [#52420](https://github.com/grafana/grafana/pull/52420), [@sarahzinger](https://github.com/sarahzinger)
|
||||
- **Grafana/toolkit:** Fix incorrect image and font generation for plugin builds. [#52661](https://github.com/grafana/grafana/pull/52661), [@academo](https://github.com/academo)
|
||||
- **Loki:** Fix `show context` not working in some occasions. [#52458](https://github.com/grafana/grafana/pull/52458), [@svennergr](https://github.com/svennergr)
|
||||
- **RBAC:** Fix permissions on dashboards and folders created by anonymous users. [#52615](https://github.com/grafana/grafana/pull/52615), [@gamab](https://github.com/gamab)
|
||||
@@ -1,18 +0,0 @@
|
||||
+++
|
||||
title = "Release notes for Grafana 9.0.6"
|
||||
hide_menu = true
|
||||
+++
|
||||
|
||||
<!-- Auto generated by update changelog github action -->
|
||||
|
||||
# Release notes for Grafana 9.0.6
|
||||
|
||||
### Features and enhancements
|
||||
|
||||
- **Access Control:** Allow org admins to invite new users to their organization. [#52904](https://github.com/grafana/grafana/pull/52904), [@IevaVasiljeva](https://github.com/IevaVasiljeva)
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- **Grafana/toolkit:** Fix incorrect image and font generation for plugin builds. [#52927](https://github.com/grafana/grafana/pull/52927), [@academo](https://github.com/academo)
|
||||
- **Prometheus:** Fix adding of multiple values for regex operator. [#52978](https://github.com/grafana/grafana/pull/52978), [@ivanahuckova](https://github.com/ivanahuckova)
|
||||
- **UI/Card:** Fix card items always having pointer cursor. [#52809](https://github.com/grafana/grafana/pull/52809), [@gillesdemey](https://github.com/gillesdemey)
|
||||
@@ -324,7 +324,7 @@ Sets the maximum amount of time a connection may be reused. The default is 14400
|
||||
|
||||
### locking_attempt_timeout_sec
|
||||
|
||||
For "mysql", if the `migrationLocking` feature toggle is set, specify the time (in seconds) to wait before failing to lock the database for the migrations. Default is 0.
|
||||
For "mysql", if `lockingMigration` feature toggle is set, specify the time (in seconds) to wait before failing to lock the database for the migrations. Default is 0.
|
||||
|
||||
### log_queries
|
||||
|
||||
@@ -675,8 +675,7 @@ Default is `false`.
|
||||
|
||||
Set to `true` to automatically add new users to the main organization
|
||||
(id 1). When set to `false`, new users automatically cause a new
|
||||
organization to be created for that new user. The organization will be
|
||||
created even if the `allow_org_create` setting is set to `false`. Default is `true`.
|
||||
organization to be created for that new user. Default is `true`.
|
||||
|
||||
### auto_assign_org_id
|
||||
|
||||
@@ -1204,11 +1203,11 @@ The interval string is a possibly signed sequence of decimal numbers, followed b
|
||||
|
||||
### ha_listen_address
|
||||
|
||||
Listen IP address and port to receive unified alerting messages for other Grafana instances. The port is used for both TCP and UDP. It is assumed other Grafana instances are also running on the same port. The default value is `0.0.0.0:9094`.
|
||||
Listen address/hostname and port to receive unified alerting messages for other Grafana instances. The port is used for both TCP and UDP. It is assumed other Grafana instances are also running on the same port. The default value is `0.0.0.0:9094`.
|
||||
|
||||
### ha_advertise_address
|
||||
|
||||
Explicit IP address and port to advertise other Grafana instances. The port is used for both TCP and UDP.
|
||||
Explicit address/hostname and port to advertise other Grafana instances. The port is used for both TCP and UDP.
|
||||
|
||||
### ha_peers
|
||||
|
||||
|
||||
@@ -6,4 +6,4 @@ title: Stack series link
|
||||
|
||||
## Axis
|
||||
|
||||
For full instructions, refer to [Change axis display]({{< relref "../../visualizations/time-series/change-axis-display/" >}}).
|
||||
For full instructions, refer to [Change axis display]({{< relref "../time-series/change-axis-display/" >}}).
|
||||
|
||||
@@ -6,4 +6,4 @@ title: Stack series link
|
||||
|
||||
### Stack series
|
||||
|
||||
For full instructions, refer to [Graph stacked time series]({{< relref "../../visualizations/time-series/graph-time-series-stacking/" >}}).
|
||||
For full instructions, refer to [Graph stacked time series]({{< relref "../time-series/graph-time-series-stacking/" >}}).
|
||||
|
||||
@@ -21,7 +21,7 @@ This panel visualization allows you to graph categorical data.
|
||||
|
||||
## Supported data formats
|
||||
|
||||
Only one data frame is supported and it must have at least one string field that will be used as the category for an X or Y axis and one or more numerical fields.
|
||||
Only one data frame is supported and it needs to have at least one string field that will be used as the category for an X or Y axis and one or more numerical fields.
|
||||
|
||||
Example:
|
||||
|
||||
@@ -107,8 +107,6 @@ Gradient color is generated based on the hue of the line color.
|
||||
|
||||
Choose which of the [standard calculations]({{< relref "../panels/calculation-types/" >}}) to show in the legend. You can have more than one.
|
||||
|
||||
For more information about the legend, refer to [Configure a legend](../configure-legend/).
|
||||
|
||||
## Text size
|
||||
|
||||
Enter a **Value** to change the size of the text on your bar chart.
|
||||
|
||||
@@ -80,7 +80,5 @@ The following example shows a pie chart with **Name** and **Percent** labels dis
|
||||
|
||||
Select values to display in the legend. You can select more than one.
|
||||
|
||||
- **Percent:** The percentage of the whole.
|
||||
- **Value:** The raw numerical value.
|
||||
|
||||
For more information about the legend, refer to [Configure a legend](../configure-legend/).
|
||||
**Percent -** The percentage of the whole.
|
||||
**Value -** The raw numerical value.
|
||||
|
||||
@@ -33,8 +33,6 @@ These options are available whether you are graphing your time series as lines,
|
||||
|
||||
Choose which of the [standard calculations]({{< relref "../../panels/calculation-types/" >}}) to show in the legend. You can have more than one.
|
||||
|
||||
For more information about the legend, refer to [Configure a legend](../configure-legend/).
|
||||
|
||||
## Graph styles
|
||||
|
||||
Use these options to choose how to display your time series data.
|
||||
|
||||
@@ -74,7 +74,7 @@ You can now dynamically apply value filters to any table column. This option can
|
||||
|
||||
{{< figure src="/static/img/docs/v72/table_column_filters.png" max-width="800px" caption="Table column filters" >}}
|
||||
|
||||
[Filter table columns]({{< relref "../visualizations/table/#filter-table-columns" >}}) has been added as a result of this feature.
|
||||
[Filter table columns]({{< relref "../visualizations/table/filter-table-columns/" >}}) has been added as a result of this feature.
|
||||
|
||||
### New field override selection options
|
||||
|
||||
|
||||
@@ -114,6 +114,8 @@ You can now provide detailed information to alert notification recipients by inj
|
||||
|
||||
{{< figure src="/static/img/docs/alerting/alert-notification-template-7-4.png" max-width="700px" caption="Variable support in alert notifications" >}}
|
||||
|
||||
For more information, refer to the [alert notification docs]({{< ref "/docs/grafana/v8.5/alerting/old-alerting/add-notification-template/" >}}).
|
||||
|
||||
### Content security policy support
|
||||
|
||||
We have added support for [Content Security Policy (CSP)](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP), a layer of security that helps detect and mitigate certain types of attacks, including Cross Site Scripting (XSS) and data injection attacks.
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
"packages": [
|
||||
"packages/*"
|
||||
],
|
||||
"version": "9.0.7"
|
||||
"version": "9.0.4"
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"license": "AGPL-3.0-only",
|
||||
"private": true,
|
||||
"name": "grafana",
|
||||
"version": "9.0.7",
|
||||
"version": "9.0.4",
|
||||
"repository": "github:grafana/grafana",
|
||||
"scripts": {
|
||||
"api-tests": "jest --notify --watch --config=devenv/e2e-api-tests/jest.js",
|
||||
@@ -330,7 +330,7 @@
|
||||
"logfmt": "^1.3.2",
|
||||
"lru-cache": "7.10.1",
|
||||
"memoize-one": "6.0.0",
|
||||
"moment": "2.29.4",
|
||||
"moment": "2.29.3",
|
||||
"moment-timezone": "0.5.34",
|
||||
"monaco-editor": "^0.31.1",
|
||||
"monaco-promql": "1.7.4",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"author": "Grafana Labs",
|
||||
"license": "Apache-2.0",
|
||||
"name": "@grafana/data",
|
||||
"version": "9.0.7",
|
||||
"version": "9.0.4",
|
||||
"description": "Grafana Data Library",
|
||||
"keywords": [
|
||||
"typescript"
|
||||
@@ -22,14 +22,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@braintree/sanitize-url": "6.0.0",
|
||||
"@grafana/schema": "9.0.7",
|
||||
"@grafana/schema": "9.0.4",
|
||||
"@types/d3-interpolate": "^1.4.0",
|
||||
"d3-interpolate": "1.4.0",
|
||||
"date-fns": "2.28.0",
|
||||
"eventemitter3": "4.0.7",
|
||||
"lodash": "4.17.21",
|
||||
"marked": "4.0.16",
|
||||
"moment": "2.29.4",
|
||||
"moment": "2.29.3",
|
||||
"moment-timezone": "0.5.34",
|
||||
"ol": "6.14.1",
|
||||
"papaparse": "5.3.2",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"author": "Grafana Labs",
|
||||
"license": "Apache-2.0",
|
||||
"name": "@grafana/e2e-selectors",
|
||||
"version": "9.0.7",
|
||||
"version": "9.0.4",
|
||||
"description": "Grafana End-to-End Test Selectors Library",
|
||||
"keywords": [
|
||||
"cli",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"author": "Grafana Labs",
|
||||
"license": "Apache-2.0",
|
||||
"name": "@grafana/e2e",
|
||||
"version": "9.0.7",
|
||||
"version": "9.0.4",
|
||||
"description": "Grafana End-to-End Test Library",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -48,7 +48,7 @@
|
||||
"@babel/core": "7.17.8",
|
||||
"@babel/preset-env": "7.17.10",
|
||||
"@cypress/webpack-preprocessor": "5.11.1",
|
||||
"@grafana/e2e-selectors": "9.0.7",
|
||||
"@grafana/e2e-selectors": "9.0.4",
|
||||
"@grafana/tsconfig": "^1.2.0-rc1",
|
||||
"@mochajs/json-file-reporter": "^1.2.0",
|
||||
"babel-loader": "8.2.5",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"author": "Grafana Labs",
|
||||
"license": "Apache-2.0",
|
||||
"name": "@grafana/runtime",
|
||||
"version": "9.0.7",
|
||||
"version": "9.0.4",
|
||||
"description": "Grafana Runtime Library",
|
||||
"keywords": [
|
||||
"grafana",
|
||||
@@ -22,9 +22,9 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@grafana/data": "9.0.7",
|
||||
"@grafana/e2e-selectors": "9.0.7",
|
||||
"@grafana/ui": "9.0.7",
|
||||
"@grafana/data": "9.0.4",
|
||||
"@grafana/e2e-selectors": "9.0.4",
|
||||
"@grafana/ui": "9.0.4",
|
||||
"@sentry/browser": "6.19.7",
|
||||
"history": "4.10.1",
|
||||
"lodash": "4.17.21",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"author": "Grafana Labs",
|
||||
"license": "Apache-2.0",
|
||||
"name": "@grafana/schema",
|
||||
"version": "9.0.7",
|
||||
"version": "9.0.4",
|
||||
"description": "Grafana Schema Library",
|
||||
"keywords": [
|
||||
"typescript"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"author": "Grafana Labs",
|
||||
"license": "Apache-2.0",
|
||||
"name": "@grafana/toolkit",
|
||||
"version": "9.0.7",
|
||||
"version": "9.0.4",
|
||||
"description": "Grafana Toolkit",
|
||||
"keywords": [
|
||||
"grafana",
|
||||
@@ -38,10 +38,10 @@
|
||||
"@babel/preset-env": "^7.16.11",
|
||||
"@babel/preset-react": "^7.16.7",
|
||||
"@babel/preset-typescript": "^7.16.7",
|
||||
"@grafana/data": "9.0.7",
|
||||
"@grafana/data": "9.0.4",
|
||||
"@grafana/eslint-config": "^4.0.0",
|
||||
"@grafana/tsconfig": "^1.2.0-rc1",
|
||||
"@grafana/ui": "9.0.7",
|
||||
"@grafana/ui": "9.0.4",
|
||||
"@jest/core": "27.5.1",
|
||||
"@types/command-exists": "^1.2.0",
|
||||
"@types/eslint": "8.4.1",
|
||||
|
||||
@@ -5,7 +5,7 @@ import rimrafCallback from 'rimraf';
|
||||
import { promisify } from 'util';
|
||||
|
||||
import { getPluginId } from '../../config/utils/getPluginId';
|
||||
import { assertRootUrlIsValid, getPluginJson } from '../../config/utils/pluginValidation';
|
||||
import { getPluginJson } from '../../config/utils/pluginValidation';
|
||||
import {
|
||||
getJobFolder,
|
||||
writeJobStats,
|
||||
@@ -140,8 +140,7 @@ const packagePluginRunner: TaskRunner<PluginCIOptions> = async ({ signatureType,
|
||||
if (signatureType) {
|
||||
manifest.signatureType = signatureType;
|
||||
}
|
||||
if (rootUrls && rootUrls.length > 0) {
|
||||
rootUrls.forEach(assertRootUrlIsValid);
|
||||
if (rootUrls) {
|
||||
manifest.rootUrls = rootUrls;
|
||||
}
|
||||
const signedManifest = await signManifest(manifest);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import path from 'path';
|
||||
|
||||
import { assertRootUrlIsValid } from '../../config/utils/pluginValidation';
|
||||
import { buildManifest, signManifest, saveManifest } from '../../plugins/manifest';
|
||||
|
||||
import { getToolkitVersion } from './plugin.utils';
|
||||
@@ -22,8 +21,7 @@ const pluginSignRunner: TaskRunner<PluginSignOptions> = async ({ signatureType,
|
||||
if (signatureType) {
|
||||
manifest.signatureType = signatureType;
|
||||
}
|
||||
if (rootUrls && rootUrls.length > 0) {
|
||||
rootUrls.forEach(assertRootUrlIsValid);
|
||||
if (rootUrls) {
|
||||
manifest.rootUrls = rootUrls;
|
||||
}
|
||||
|
||||
|
||||
@@ -36,11 +36,3 @@ export const getPluginJson = (path: string): PluginMeta => {
|
||||
|
||||
return pluginJson as PluginMeta;
|
||||
};
|
||||
|
||||
export const assertRootUrlIsValid = (rootUrl: string) => {
|
||||
try {
|
||||
new URL(rootUrl);
|
||||
} catch (err) {
|
||||
throw new Error(`${rootUrl} is not a valid URL`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getStylesheetEntries } from './loaders';
|
||||
import { getStylesheetEntries, hasThemeStylesheets } from './loaders';
|
||||
|
||||
describe('Loaders', () => {
|
||||
describe('stylesheet helpers', () => {
|
||||
@@ -22,5 +22,28 @@ describe('Loaders', () => {
|
||||
expect(result).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasThemeStylesheets', () => {
|
||||
it('throws when only one theme file is defined', () => {
|
||||
const errorSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||
const result = () => {
|
||||
hasThemeStylesheets(`${__dirname}/../mocks/stylesheetsSupport/missing-theme-file`);
|
||||
};
|
||||
expect(result).toThrow();
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('returns false when no theme files present', () => {
|
||||
const result = hasThemeStylesheets(`${__dirname}/../mocks/stylesheetsSupport/no-theme-files`);
|
||||
|
||||
expect(result).toBeFalsy();
|
||||
});
|
||||
|
||||
it('returns true when theme files present', () => {
|
||||
const result = hasThemeStylesheets(`${__dirname}/../mocks/stylesheetsSupport/ok`);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,39 @@ export const getStylesheetEntries = (root: string = process.cwd()) => {
|
||||
return entries;
|
||||
};
|
||||
|
||||
export const hasThemeStylesheets = (root: string = process.cwd()) => {
|
||||
const stylesheetsPaths = getStylesheetPaths(root);
|
||||
const stylesheetsSummary: boolean[] = [];
|
||||
|
||||
const result = stylesheetsPaths.reduce((acc, current) => {
|
||||
if (fs.existsSync(`${current}.css`) || fs.existsSync(`${current}.scss`)) {
|
||||
stylesheetsSummary.push(true);
|
||||
return acc && true;
|
||||
} else {
|
||||
stylesheetsSummary.push(false);
|
||||
return false;
|
||||
}
|
||||
}, true);
|
||||
|
||||
const hasMissingStylesheets = stylesheetsSummary.filter((s) => s).length === 1;
|
||||
|
||||
// seems like there is one theme file defined only
|
||||
if (result === false && hasMissingStylesheets) {
|
||||
console.error('\nWe think you want to specify theme stylesheet, but it seems like there is something missing...');
|
||||
stylesheetsSummary.forEach((s, i) => {
|
||||
if (s) {
|
||||
console.log(stylesheetsPaths[i], 'discovered');
|
||||
} else {
|
||||
console.log(stylesheetsPaths[i], 'missing');
|
||||
}
|
||||
});
|
||||
|
||||
throw new Error('Stylesheet missing!');
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const getStyleLoaders = () => {
|
||||
const extractionLoader = {
|
||||
loader: MiniCssExtractPlugin.loader,
|
||||
@@ -106,21 +139,34 @@ export const getStyleLoaders = () => {
|
||||
};
|
||||
|
||||
export const getFileLoaders = () => {
|
||||
const shouldExtractCss = hasThemeStylesheets();
|
||||
|
||||
return [
|
||||
{
|
||||
test: /\.(png|jpe?g|gif|svg)$/,
|
||||
type: 'asset/resource',
|
||||
generator: {
|
||||
publicPath: `public/plugins/${getPluginId()}/img/`,
|
||||
outputPath: 'img/',
|
||||
},
|
||||
use: [
|
||||
shouldExtractCss
|
||||
? {
|
||||
loader: require.resolve('file-loader'),
|
||||
options: {
|
||||
outputPath: '/',
|
||||
name: '[path][name].[ext]',
|
||||
},
|
||||
}
|
||||
: // When using single css import images are inlined as base64 URIs in the result bundle
|
||||
{
|
||||
loader: 'url-loader',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
test: /\.(woff|woff2|eot|ttf|otf)(\?v=\d+\.\d+\.\d+)?$/,
|
||||
type: 'asset/resource',
|
||||
generator: {
|
||||
publicPath: `public/plugins/${getPluginId()}/fonts/`,
|
||||
outputPath: 'fonts/',
|
||||
loader: require.resolve('file-loader'),
|
||||
options: {
|
||||
// Keep publicPath relative for host.com/grafana/ deployments
|
||||
publicPath: `public/plugins/${getPluginId()}/fonts`,
|
||||
outputPath: 'fonts',
|
||||
name: '[name].[ext]',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"author": "Grafana Labs",
|
||||
"license": "Apache-2.0",
|
||||
"name": "@grafana/ui",
|
||||
"version": "9.0.7",
|
||||
"version": "9.0.4",
|
||||
"description": "Grafana Components Library",
|
||||
"keywords": [
|
||||
"grafana",
|
||||
@@ -33,9 +33,9 @@
|
||||
"@emotion/css": "11.9.0",
|
||||
"@emotion/react": "11.9.0",
|
||||
"@grafana/aws-sdk": "0.0.36",
|
||||
"@grafana/data": "9.0.7",
|
||||
"@grafana/e2e-selectors": "9.0.7",
|
||||
"@grafana/schema": "9.0.7",
|
||||
"@grafana/data": "9.0.4",
|
||||
"@grafana/e2e-selectors": "9.0.4",
|
||||
"@grafana/schema": "9.0.4",
|
||||
"@grafana/slate-react": "0.22.10-grafana",
|
||||
"@monaco-editor/react": "4.3.1",
|
||||
"@popperjs/core": "2.11.5",
|
||||
@@ -58,7 +58,7 @@
|
||||
"jquery": "3.6.0",
|
||||
"lodash": "4.17.21",
|
||||
"memoize-one": "6.0.0",
|
||||
"moment": "2.29.4",
|
||||
"moment": "2.29.3",
|
||||
"monaco-editor": "^0.31.1",
|
||||
"ol": "6.14.1",
|
||||
"prismjs": "1.28.0",
|
||||
|
||||
@@ -67,7 +67,7 @@ export const CardContainer = ({
|
||||
};
|
||||
|
||||
export const getCardContainerStyles = stylesFactory(
|
||||
(theme: GrafanaTheme2, disabled = false, disableHover = false, isSelected) => {
|
||||
(theme: GrafanaTheme2, disabled = false, disableHover = false, isSelected = false) => {
|
||||
const isSelectable = isSelected !== undefined;
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import { PageToolbar } from '..';
|
||||
|
||||
describe('PageToolbar', () => {
|
||||
it('renders left items when title is not set', () => {
|
||||
const leftItemContent = 'Left Item!';
|
||||
render(<PageToolbar leftItems={[<div key="left-item">{leftItemContent}</div>]} />);
|
||||
|
||||
expect(screen.getByText(leftItemContent)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,6 @@ import { IconButton } from '../IconButton/IconButton';
|
||||
export interface Props {
|
||||
pageIcon?: IconName;
|
||||
title?: string;
|
||||
section?: string;
|
||||
parent?: string;
|
||||
onGoBack?: () => void;
|
||||
titleHref?: string;
|
||||
@@ -31,7 +30,6 @@ export interface Props {
|
||||
export const PageToolbar: FC<Props> = React.memo(
|
||||
({
|
||||
title,
|
||||
section,
|
||||
parent,
|
||||
pageIcon,
|
||||
onGoBack,
|
||||
@@ -61,78 +59,63 @@ export const PageToolbar: FC<Props> = React.memo(
|
||||
className
|
||||
);
|
||||
|
||||
const titleEl = (
|
||||
<>
|
||||
<span className={styles.noLinkTitle}>{title}</span>
|
||||
{section && <span className={styles.pre}> / {section}</span>}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<nav className={mainStyle} aria-label={ariaLabel}>
|
||||
<div className={styles.leftWrapper}>
|
||||
{pageIcon && !onGoBack && (
|
||||
<div className={styles.pageIcon}>
|
||||
<Icon name={pageIcon} size="lg" aria-hidden />
|
||||
</div>
|
||||
{pageIcon && !onGoBack && (
|
||||
<div className={styles.pageIcon}>
|
||||
<Icon name={pageIcon} size="lg" aria-hidden />
|
||||
</div>
|
||||
)}
|
||||
{onGoBack && (
|
||||
<div className={styles.pageIcon}>
|
||||
<IconButton
|
||||
name="arrow-left"
|
||||
tooltip="Go back (Esc)"
|
||||
tooltipPlacement="bottom"
|
||||
size="xxl"
|
||||
aria-label={selectors.components.BackButton.backArrow}
|
||||
onClick={onGoBack}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<nav aria-label="Search links" className={styles.navElement}>
|
||||
{parent && parentHref && (
|
||||
<>
|
||||
<Link
|
||||
aria-label={`Search dashboard in the ${parent} folder`}
|
||||
className={cx(styles.titleText, styles.parentLink, styles.titleLink)}
|
||||
href={parentHref}
|
||||
>
|
||||
{parent} <span className={styles.parentIcon}></span>
|
||||
</Link>
|
||||
{titleHref && (
|
||||
<span className={cx(styles.titleText, styles.titleDivider, styles.parentLink)} aria-hidden>
|
||||
/
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{onGoBack && (
|
||||
<div className={styles.pageIcon}>
|
||||
<IconButton
|
||||
name="arrow-left"
|
||||
tooltip="Go back (Esc)"
|
||||
tooltipPlacement="bottom"
|
||||
size="xxl"
|
||||
aria-label={selectors.components.BackButton.backArrow}
|
||||
onClick={onGoBack}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{title && titleHref && (
|
||||
<h1 className={styles.h1Styles}>
|
||||
<Link
|
||||
aria-label="Search dashboard by name"
|
||||
className={cx(styles.titleText, styles.titleLink)}
|
||||
href={titleHref}
|
||||
>
|
||||
{title}
|
||||
</Link>
|
||||
</h1>
|
||||
)}
|
||||
<nav aria-label="Search links" className={styles.navElement}>
|
||||
{parent && parentHref && (
|
||||
<>
|
||||
<Link
|
||||
aria-label={`Search dashboard in the ${parent} folder`}
|
||||
className={cx(styles.titleText, styles.parentLink, styles.titleLink)}
|
||||
href={parentHref}
|
||||
>
|
||||
{parent} <span className={styles.parentIcon}></span>
|
||||
</Link>
|
||||
{titleHref && (
|
||||
<span className={cx(styles.titleText, styles.titleDivider, styles.parentLink)} aria-hidden>
|
||||
/
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{title && !titleHref && <h1 className={styles.titleText}>{title}</h1>}
|
||||
</nav>
|
||||
{leftItems?.map((child, index) => (
|
||||
<div className={styles.leftActionItem} key={index}>
|
||||
{child}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{(title || leftItems?.length) && (
|
||||
<div className={styles.titleWrapper}>
|
||||
{title && (
|
||||
<h1 className={styles.h1Styles}>
|
||||
{titleHref ? (
|
||||
<Link
|
||||
aria-label="Search dashboard by name"
|
||||
className={cx(styles.titleText, styles.titleLink)}
|
||||
href={titleHref}
|
||||
>
|
||||
{titleEl}
|
||||
</Link>
|
||||
) : (
|
||||
<div className={styles.titleText}>{titleEl}</div>
|
||||
)}
|
||||
</h1>
|
||||
)}
|
||||
|
||||
{leftItems?.map((child, index) => (
|
||||
<div className={styles.leftActionItem} key={index}>
|
||||
{child}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
<div className={styles.spacer} />
|
||||
{React.Children.toArray(children)
|
||||
.filter(Boolean)
|
||||
.map((child, index) => {
|
||||
@@ -153,11 +136,21 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
const { spacing, typography } = theme;
|
||||
|
||||
const focusStyle = getFocusStyles(theme);
|
||||
const titleStyles = css`
|
||||
font-size: ${typography.size.lg};
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
max-width: 240px;
|
||||
border-radius: 2px;
|
||||
|
||||
@media ${styleMixins.mediaUp(theme.v1.breakpoints.xl)} {
|
||||
max-width: unset;
|
||||
}
|
||||
`;
|
||||
|
||||
return {
|
||||
pre: css`
|
||||
white-space: pre;
|
||||
`,
|
||||
toolbar: css`
|
||||
align-items: center;
|
||||
background: ${theme.colors.background.canvas};
|
||||
@@ -166,9 +159,7 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
justify-content: flex-end;
|
||||
padding: ${theme.spacing(1.5, 2)};
|
||||
`,
|
||||
leftWrapper: css`
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
spacer: css`
|
||||
flex-grow: 1;
|
||||
`,
|
||||
pageIcon: css`
|
||||
@@ -179,38 +170,24 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
align-items: center;
|
||||
}
|
||||
`,
|
||||
noLinkTitle: css`
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`,
|
||||
titleWrapper: css`
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
margin: 0;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
`,
|
||||
navElement: css`
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
align-items: center;
|
||||
max-width: calc(100vw - 78px);
|
||||
`,
|
||||
h1Styles: css`
|
||||
margin: 0;
|
||||
line-height: inherit;
|
||||
width: 300px;
|
||||
max-width: min-content;
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
`,
|
||||
parentIcon: css`
|
||||
margin-left: ${theme.spacing(0.5)};
|
||||
`,
|
||||
titleText: css`
|
||||
display: flex;
|
||||
font-size: ${typography.size.lg};
|
||||
margin: 0;
|
||||
border-radius: 2px;
|
||||
`,
|
||||
titleText: titleStyles,
|
||||
titleLink: css`
|
||||
&:focus-visible {
|
||||
${focusStyle}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@jaegertracing/jaeger-ui-components",
|
||||
"version": "9.0.7",
|
||||
"version": "9.0.4",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
@@ -28,10 +28,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/css": "11.9.0",
|
||||
"@grafana/data": "9.0.7",
|
||||
"@grafana/e2e-selectors": "9.0.7",
|
||||
"@grafana/runtime": "9.0.7",
|
||||
"@grafana/ui": "9.0.7",
|
||||
"@grafana/data": "9.0.4",
|
||||
"@grafana/e2e-selectors": "9.0.4",
|
||||
"@grafana/runtime": "9.0.4",
|
||||
"@grafana/ui": "9.0.4",
|
||||
"chance": "^1.0.10",
|
||||
"classnames": "^2.2.5",
|
||||
"combokeys": "^3.0.0",
|
||||
@@ -43,7 +43,7 @@
|
||||
"lodash": "4.17.21",
|
||||
"lru-memoize": "^1.1.0",
|
||||
"memoize-one": "6.0.0",
|
||||
"moment": "2.29.4",
|
||||
"moment": "2.29.3",
|
||||
"moment-timezone": "0.5.34",
|
||||
"prop-types": "15.8.1",
|
||||
"react": "17.0.2",
|
||||
|
||||
@@ -33,13 +33,13 @@ RUN apk add --no-cache openssl --repository=http://dl-cdn.alpinelinux.org/alpine
|
||||
|
||||
# Oracle Support for x86_64 only
|
||||
RUN if [ `arch` = "x86_64" ]; then \
|
||||
apk add --no-cache libaio libnsl && \
|
||||
ln -s /usr/lib/libnsl.so.2 /usr/lib/libnsl.so.1 && \
|
||||
wget https://github.com/sgerrand/alpine-pkg-glibc/releases/download/2.35-r0/glibc-2.35-r0.apk \
|
||||
-O /tmp/glibc-2.35-r0.apk && \
|
||||
wget https://github.com/sgerrand/alpine-pkg-glibc/releases/download/2.35-r0/glibc-bin-2.35-r0.apk \
|
||||
-O /tmp/glibc-bin-2.35-r0.apk && \
|
||||
apk add --no-cache --allow-untrusted /tmp/glibc-2.35-r0.apk /tmp/glibc-bin-2.35-r0.apk && \
|
||||
rm -f /lib64/ld-linux-x86-64.so.2 && \
|
||||
ln -s /usr/glibc-compat/lib64/ld-linux-x86-64.so.2 /lib64/ld-linux-x86-64.so.2 && \
|
||||
rm -f /tmp/glibc-2.35-r0.apk && \
|
||||
rm -f /tmp/glibc-bin-2.35-r0.apk && \
|
||||
rm -f /lib/ld-linux-x86-64.so.2 && \
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/alerting"
|
||||
"github.com/grafana/grafana/pkg/services/guardian"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/notifier/channels_config"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/notifier"
|
||||
"github.com/grafana/grafana/pkg/services/search"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
@@ -197,7 +197,7 @@ func (hs *HTTPServer) GetAlert(c *models.ReqContext) response.Response {
|
||||
func (hs *HTTPServer) GetAlertNotifiers(ngalertEnabled bool) func(*models.ReqContext) response.Response {
|
||||
return func(_ *models.ReqContext) response.Response {
|
||||
if ngalertEnabled {
|
||||
return response.JSON(http.StatusOK, channels_config.GetAvailableNotifiers())
|
||||
return response.JSON(http.StatusOK, notifier.GetAvailableNotifiers())
|
||||
}
|
||||
// TODO(codesome): This wont be required in 8.0 since ngalert
|
||||
// will be enabled by default with no disabling. This is to be removed later.
|
||||
|
||||
@@ -59,7 +59,7 @@ func (hs *HTTPServer) registerRoutes() {
|
||||
r.Get("/datasources/edit/*", authorize(reqOrgAdmin, datasources.EditPageAccess), hs.Index)
|
||||
r.Get("/org/users", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersRead)), hs.Index)
|
||||
r.Get("/org/users/new", reqOrgAdmin, hs.Index)
|
||||
r.Get("/org/users/invite", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersAdd)), hs.Index)
|
||||
r.Get("/org/users/invite", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionUsersCreate)), hs.Index)
|
||||
r.Get("/org/teams", authorize(reqCanAccessTeams, ac.EvalPermission(ac.ActionTeamsRead)), hs.Index)
|
||||
r.Get("/org/teams/edit/*", authorize(reqCanAccessTeams, teamsEditAccessEvaluator), hs.Index)
|
||||
r.Get("/org/teams/new", authorize(reqCanAccessTeams, ac.EvalPermission(ac.ActionTeamsCreate)), hs.Index)
|
||||
@@ -238,9 +238,9 @@ func (hs *HTTPServer) registerRoutes() {
|
||||
orgRoute.Delete("/users/:userId", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersRemove, userIDScope)), routing.Wrap(hs.RemoveOrgUserForCurrentOrg))
|
||||
|
||||
// invites
|
||||
orgRoute.Get("/invites", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersAdd)), routing.Wrap(hs.GetPendingOrgInvites))
|
||||
orgRoute.Post("/invites", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersAdd)), quota("user"), routing.Wrap(hs.AddOrgInvite))
|
||||
orgRoute.Patch("/invites/:code/revoke", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersAdd)), routing.Wrap(hs.RevokeInvite))
|
||||
orgRoute.Get("/invites", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionUsersCreate)), routing.Wrap(hs.GetPendingOrgInvites))
|
||||
orgRoute.Post("/invites", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionUsersCreate)), quota("user"), routing.Wrap(hs.AddOrgInvite))
|
||||
orgRoute.Patch("/invites/:code/revoke", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionUsersCreate)), routing.Wrap(hs.RevokeInvite))
|
||||
|
||||
// prefs
|
||||
orgRoute.Get("/preferences", authorize(reqOrgAdmin, ac.EvalPermission(ActionOrgsPreferencesRead)), routing.Wrap(hs.GetOrgPreferences))
|
||||
|
||||
@@ -153,17 +153,14 @@ func (hs *HTTPServer) CreateDashboardSnapshot(c *models.ReqContext) response.Res
|
||||
func (hs *HTTPServer) GetDashboardSnapshot(c *models.ReqContext) response.Response {
|
||||
key := web.Params(c.Req)[":key"]
|
||||
if len(key) == 0 {
|
||||
return response.Error(http.StatusBadRequest, "Empty snapshot key", nil)
|
||||
return response.Error(404, "Snapshot not found", nil)
|
||||
}
|
||||
|
||||
query := &models.GetDashboardSnapshotQuery{Key: key}
|
||||
|
||||
err := hs.DashboardsnapshotsService.GetDashboardSnapshot(c.Req.Context(), query)
|
||||
if err != nil {
|
||||
if errors.Is(err, models.ErrDashboardSnapshotNotFound) {
|
||||
return response.Error(http.StatusNotFound, "Failed to find dashboard snapshot", err)
|
||||
}
|
||||
return response.Error(http.StatusInternalServerError, "Failed to get dashboard snapshot", err)
|
||||
return response.Error(500, "Failed to get dashboard snapshot", err)
|
||||
}
|
||||
|
||||
snapshot := query.Result
|
||||
@@ -230,10 +227,7 @@ func (hs *HTTPServer) DeleteDashboardSnapshotByDeleteKey(c *models.ReqContext) r
|
||||
query := &models.GetDashboardSnapshotQuery{DeleteKey: key}
|
||||
err := hs.DashboardsnapshotsService.GetDashboardSnapshot(c.Req.Context(), query)
|
||||
if err != nil {
|
||||
if errors.Is(err, models.ErrDashboardSnapshotNotFound) {
|
||||
return response.Error(http.StatusNotFound, "Failed to find dashboard snapshot", err)
|
||||
}
|
||||
return response.Error(http.StatusInternalServerError, "Failed to get dashboard snapshot", err)
|
||||
return response.Error(500, "Failed to get dashboard snapshot", err)
|
||||
}
|
||||
|
||||
if query.Result.External {
|
||||
@@ -266,10 +260,7 @@ func (hs *HTTPServer) DeleteDashboardSnapshot(c *models.ReqContext) response.Res
|
||||
|
||||
err := hs.DashboardsnapshotsService.GetDashboardSnapshot(c.Req.Context(), query)
|
||||
if err != nil {
|
||||
if errors.Is(err, models.ErrDashboardSnapshotNotFound) {
|
||||
return response.Error(http.StatusNotFound, "Failed to find dashboard snapshot", err)
|
||||
}
|
||||
return response.Error(http.StatusInternalServerError, "Failed to get dashboard snapshot", err)
|
||||
return response.Error(500, "Failed to get dashboard snapshot", err)
|
||||
}
|
||||
if query.Result == nil {
|
||||
return response.Error(404, "Failed to get dashboard snapshot", nil)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -230,71 +229,3 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) {
|
||||
}, sqlmock)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetDashboardSnapshotNotFound(t *testing.T) {
|
||||
sqlmock := mockstore.NewSQLStoreMock()
|
||||
sqlmock.ExpectedTeamsByUser = []*models.TeamDTO{}
|
||||
sqlmock.ExpectedError = models.ErrDashboardSnapshotNotFound
|
||||
hs := &HTTPServer{DashboardsnapshotsService: &dashboardsnapshots.Service{SQLStore: sqlmock}}
|
||||
|
||||
loggedInUserScenarioWithRole(t,
|
||||
"GET /snapshots/{key} should return 404 when the snapshot does not exist", "GET",
|
||||
"/api/snapshots/12345", "/api/snapshots/:key", models.ROLE_EDITOR, func(sc *scenarioContext) {
|
||||
sc.handlerFunc = hs.GetDashboardSnapshot
|
||||
sc.fakeReqWithParams("GET", sc.url, map[string]string{"key": "12345"}).exec()
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, sc.resp.Code)
|
||||
}, sqlmock)
|
||||
|
||||
loggedInUserScenarioWithRole(t,
|
||||
"DELETE /snapshots/{key} should return 404 when the snapshot does not exist", "DELETE",
|
||||
"/api/snapshots/12345", "/api/snapshots/:key", models.ROLE_EDITOR, func(sc *scenarioContext) {
|
||||
sc.handlerFunc = hs.DeleteDashboardSnapshot
|
||||
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec()
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, sc.resp.Code)
|
||||
}, sqlmock)
|
||||
|
||||
loggedInUserScenarioWithRole(t,
|
||||
"GET /snapshots-delete/{deleteKey} should return 404 when the snapshot does not exist", "DELETE",
|
||||
"/api/snapshots-delete/12345", "/api/snapshots-delete/:deleteKey", models.ROLE_EDITOR, func(sc *scenarioContext) {
|
||||
sc.handlerFunc = hs.DeleteDashboardSnapshotByDeleteKey
|
||||
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"deleteKey": "12345"}).exec()
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, sc.resp.Code)
|
||||
}, sqlmock)
|
||||
}
|
||||
|
||||
func TestGetDashboardSnapshotFailure(t *testing.T) {
|
||||
sqlmock := mockstore.NewSQLStoreMock()
|
||||
sqlmock.ExpectedTeamsByUser = []*models.TeamDTO{}
|
||||
sqlmock.ExpectedError = errors.New("something went wrong")
|
||||
hs := &HTTPServer{DashboardsnapshotsService: &dashboardsnapshots.Service{SQLStore: sqlmock}}
|
||||
|
||||
loggedInUserScenarioWithRole(t,
|
||||
"GET /snapshots/{key} should return 404 when the snapshot does not exist", "GET",
|
||||
"/api/snapshots/12345", "/api/snapshots/:key", models.ROLE_EDITOR, func(sc *scenarioContext) {
|
||||
sc.handlerFunc = hs.GetDashboardSnapshot
|
||||
sc.fakeReqWithParams("GET", sc.url, map[string]string{"key": "12345"}).exec()
|
||||
|
||||
assert.Equal(t, http.StatusInternalServerError, sc.resp.Code)
|
||||
}, sqlmock)
|
||||
|
||||
loggedInUserScenarioWithRole(t,
|
||||
"DELETE /snapshots/{key} should return 404 when the snapshot does not exist", "DELETE",
|
||||
"/api/snapshots/12345", "/api/snapshots/:key", models.ROLE_EDITOR, func(sc *scenarioContext) {
|
||||
sc.handlerFunc = hs.DeleteDashboardSnapshot
|
||||
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec()
|
||||
|
||||
assert.Equal(t, http.StatusInternalServerError, sc.resp.Code)
|
||||
}, sqlmock)
|
||||
|
||||
loggedInUserScenarioWithRole(t,
|
||||
"GET /snapshots-delete/{deleteKey} should return 404 when the snapshot does not exist", "DELETE",
|
||||
"/api/snapshots-delete/12345", "/api/snapshots-delete/:deleteKey", models.ROLE_EDITOR, func(sc *scenarioContext) {
|
||||
sc.handlerFunc = hs.DeleteDashboardSnapshotByDeleteKey
|
||||
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"deleteKey": "12345"}).exec()
|
||||
|
||||
assert.Equal(t, http.StatusInternalServerError, sc.resp.Code)
|
||||
}, sqlmock)
|
||||
}
|
||||
|
||||
@@ -235,8 +235,7 @@ func (hs *HTTPServer) DeleteDataSourceByName(c *models.ReqContext) response.Resp
|
||||
|
||||
func validateURL(cmdType string, url string) response.Response {
|
||||
if _, err := datasource.ValidateURL(cmdType, url); err != nil {
|
||||
datasourcesLogger.Error("Failed to validate URL", "url", url)
|
||||
return response.Error(http.StatusBadRequest, "Validation error, invalid URL", err)
|
||||
return response.Error(400, fmt.Sprintf("Validation error, invalid URL: %q", url), err)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -623,9 +622,13 @@ func (hs *HTTPServer) checkDatasourceHealth(c *models.ReqContext, ds *models.Dat
|
||||
return response.JSON(http.StatusOK, payload)
|
||||
}
|
||||
|
||||
func (hs *HTTPServer) decryptSecureJsonDataFn(ctx context.Context) func(ds *models.DataSource) (map[string]string, error) {
|
||||
return func(ds *models.DataSource) (map[string]string, error) {
|
||||
return hs.DataSourcesService.DecryptedValues(ctx, ds)
|
||||
func (hs *HTTPServer) decryptSecureJsonDataFn(ctx context.Context) func(ds *models.DataSource) map[string]string {
|
||||
return func(ds *models.DataSource) map[string]string {
|
||||
decryptedJsonData, err := hs.DataSourcesService.DecryptedValues(ctx, ds)
|
||||
if err != nil {
|
||||
hs.log.Error("Failed to decrypt secure json data", "error", err)
|
||||
}
|
||||
return decryptedJsonData
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@ import (
|
||||
//
|
||||
// Responses:
|
||||
// 200: snapshotResponse
|
||||
// 400: badRequestError
|
||||
// 404: notFoundError
|
||||
// 500: internalServerError
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
@@ -24,13 +23,7 @@ func (hs *HTTPServer) handleQueryMetricsError(err error) *response.NormalRespons
|
||||
if errors.Is(err, models.ErrDataSourceNotFound) {
|
||||
return response.Error(http.StatusNotFound, "Data source not found", err)
|
||||
}
|
||||
|
||||
var secretsPlugin models.ErrDatasourceSecretsPluginUserFriendly
|
||||
if errors.As(err, &secretsPlugin) {
|
||||
return response.Error(http.StatusInternalServerError, fmt.Sprint("Secrets Plugin error: ", err.Error()), err)
|
||||
}
|
||||
|
||||
var badQuery query.ErrBadQuery
|
||||
var badQuery *query.ErrBadQuery
|
||||
if errors.As(err, &badQuery) {
|
||||
return response.Error(http.StatusBadRequest, util.Capitalize(badQuery.Message), err)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -39,11 +37,6 @@ type fakePluginRequestValidator struct {
|
||||
err error
|
||||
}
|
||||
|
||||
type secretsErrorResponseBody struct {
|
||||
Error string `json:"error"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (rv *fakePluginRequestValidator) Validate(dsURL string, req *http.Request) error {
|
||||
return rv.err
|
||||
}
|
||||
@@ -108,44 +101,3 @@ func TestAPIEndpoint_Metrics_QueryMetricsV2(t *testing.T) {
|
||||
require.Equal(t, http.StatusMultiStatus, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAPIEndpoint_Metrics_PluginDecryptionFailure(t *testing.T) {
|
||||
qds := query.ProvideService(
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
&fakePluginRequestValidator{},
|
||||
&fakeDatasources.FakeDataSourceService{SimulatePluginFailure: true},
|
||||
&fakePluginClient{
|
||||
QueryDataHandlerFunc: func(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) {
|
||||
resp := backend.Responses{
|
||||
"A": backend.DataResponse{
|
||||
Error: fmt.Errorf("query failed"),
|
||||
},
|
||||
}
|
||||
return &backend.QueryDataResponse{Responses: resp}, nil
|
||||
},
|
||||
},
|
||||
&fakeOAuthTokenService{},
|
||||
)
|
||||
httpServer := SetupAPITestServer(t, func(hs *HTTPServer) {
|
||||
hs.queryDataService = qds
|
||||
})
|
||||
|
||||
t.Run("Status code is 500 and a secrets plugin error is returned if there is a problem getting secrets from the remote plugin", func(t *testing.T) {
|
||||
req := httpServer.NewPostRequest("/api/ds/query", strings.NewReader(queryDatasourceInput))
|
||||
webtest.RequestWithSignedInUser(req, &models.SignedInUser{UserId: 1, OrgId: 1, OrgRole: models.ROLE_VIEWER})
|
||||
resp, err := httpServer.SendJSON(req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusInternalServerError, resp.StatusCode)
|
||||
buf := new(bytes.Buffer)
|
||||
_, err = buf.ReadFrom(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
var resObj secretsErrorResponseBody
|
||||
err = json.Unmarshal(buf.Bytes(), &resObj)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "unknown error", resObj.Error)
|
||||
require.Contains(t, resObj.Message, "Secrets Plugin error:")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,14 +5,12 @@ 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/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/setting"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
"github.com/grafana/grafana/pkg/web"
|
||||
@@ -40,9 +38,6 @@ func (hs *HTTPServer) AddOrgInvite(c *models.ReqContext) response.Response {
|
||||
if !inviteDto.Role.IsValid() {
|
||||
return response.Error(400, "Invalid role specified", nil)
|
||||
}
|
||||
if !c.OrgRole.Includes(inviteDto.Role) && !c.IsGrafanaAdmin {
|
||||
return response.Error(http.StatusForbidden, "Cannot assign a role higher than user's role", nil)
|
||||
}
|
||||
|
||||
// first try get existing user
|
||||
userQuery := models.GetUserByLoginQuery{LoginOrEmail: inviteDto.LoginOrEmail}
|
||||
@@ -51,15 +46,6 @@ func (hs *HTTPServer) AddOrgInvite(c *models.ReqContext) response.Response {
|
||||
return response.Error(500, "Failed to query db for existing user check", err)
|
||||
}
|
||||
} else {
|
||||
// Evaluate permissions for adding an existing user to the organization
|
||||
userIDScope := ac.Scope("users", "id", strconv.Itoa(int(userQuery.Result.Id)))
|
||||
hasAccess, err := hs.AccessControl.Evaluate(c.Req.Context(), c.SignedInUser, ac.EvalPermission(ac.ActionOrgUsersAdd, userIDScope))
|
||||
if err != nil {
|
||||
return response.Error(http.StatusInternalServerError, "Failed to evaluate permissions", err)
|
||||
}
|
||||
if !hasAccess {
|
||||
return response.Error(http.StatusForbidden, "Permission denied: not permitted to add an existing user to this organisation", err)
|
||||
}
|
||||
return hs.inviteExistingUserToOrg(c, userQuery.Result, &inviteDto)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
)
|
||||
|
||||
func TestOrgInvitesAPIEndpointAccess(t *testing.T) {
|
||||
type accessControlTestCase2 struct {
|
||||
expectedCode int
|
||||
desc string
|
||||
url string
|
||||
method string
|
||||
permissions []*accesscontrol.Permission
|
||||
input string
|
||||
}
|
||||
tests := []accessControlTestCase2{
|
||||
{
|
||||
expectedCode: http.StatusOK,
|
||||
desc: "org viewer with the correct permissions can invite an existing user to his org",
|
||||
url: "/api/org/invites",
|
||||
method: http.MethodPost,
|
||||
permissions: []*accesscontrol.Permission{{Action: accesscontrol.ActionOrgUsersAdd, Scope: accesscontrol.ScopeUsersAll}},
|
||||
input: `{"loginOrEmail": "` + testAdminOrg2.Login + `", "role": "` + string(models.ROLE_VIEWER) + `"}`,
|
||||
},
|
||||
{
|
||||
expectedCode: http.StatusForbidden,
|
||||
desc: "org viewer with missing permissions cannot invite an existing user to his org",
|
||||
url: "/api/org/invites",
|
||||
method: http.MethodPost,
|
||||
permissions: []*accesscontrol.Permission{},
|
||||
input: `{"loginOrEmail": "` + testAdminOrg2.Login + `", "role": "` + string(models.ROLE_VIEWER) + `"}`,
|
||||
},
|
||||
{
|
||||
expectedCode: http.StatusForbidden,
|
||||
desc: "org viewer with the wrong scope cannot invite an existing user to his org",
|
||||
url: "/api/org/invites",
|
||||
method: http.MethodPost,
|
||||
permissions: []*accesscontrol.Permission{{Action: accesscontrol.ActionOrgUsersAdd, Scope: "users:id:100"}},
|
||||
input: `{"loginOrEmail": "` + testAdminOrg2.Login + `", "role": "` + string(models.ROLE_VIEWER) + `"}`,
|
||||
},
|
||||
{
|
||||
expectedCode: http.StatusOK,
|
||||
desc: "org viewer with the correct permissions can invite a new user to his org",
|
||||
url: "/api/org/invites",
|
||||
method: http.MethodPost,
|
||||
permissions: []*accesscontrol.Permission{{Action: accesscontrol.ActionOrgUsersAdd, Scope: accesscontrol.ScopeUsersAll}},
|
||||
input: `{"loginOrEmail": "new user", "role": "` + string(models.ROLE_VIEWER) + `"}`,
|
||||
},
|
||||
{
|
||||
expectedCode: http.StatusForbidden,
|
||||
desc: "org viewer with missing permissions cannot invite a new user to his org",
|
||||
url: "/api/org/invites",
|
||||
method: http.MethodPost,
|
||||
permissions: []*accesscontrol.Permission{},
|
||||
input: `{"loginOrEmail": "new user", "role": "` + string(models.ROLE_VIEWER) + `"}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.desc, func(t *testing.T) {
|
||||
sc := setupHTTPServer(t, true, true)
|
||||
setInitCtxSignedInViewer(sc.initCtx)
|
||||
setupOrgUsersDBForAccessControlTests(t, sc.db)
|
||||
setAccessControlPermissions(sc.acmock, test.permissions, sc.initCtx.OrgId)
|
||||
|
||||
input := strings.NewReader(test.input)
|
||||
response := callAPI(sc.server, test.method, test.url, input, t)
|
||||
assert.Equal(t, test.expectedCode, response.Code)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ func (hs *HTTPServer) AddOrgUserToCurrentOrg(c *models.ReqContext) response.Resp
|
||||
return response.Error(http.StatusBadRequest, "bad request data", err)
|
||||
}
|
||||
cmd.OrgId = c.OrgId
|
||||
return hs.addOrgUserHelper(c, cmd)
|
||||
return hs.addOrgUserHelper(c.Req.Context(), cmd)
|
||||
}
|
||||
|
||||
// POST /api/orgs/:orgId/users
|
||||
@@ -36,19 +36,16 @@ func (hs *HTTPServer) AddOrgUser(c *models.ReqContext) response.Response {
|
||||
if err != nil {
|
||||
return response.Error(http.StatusBadRequest, "orgId is invalid", err)
|
||||
}
|
||||
return hs.addOrgUserHelper(c, cmd)
|
||||
return hs.addOrgUserHelper(c.Req.Context(), cmd)
|
||||
}
|
||||
|
||||
func (hs *HTTPServer) addOrgUserHelper(c *models.ReqContext, cmd models.AddOrgUserCommand) response.Response {
|
||||
func (hs *HTTPServer) addOrgUserHelper(ctx context.Context, cmd models.AddOrgUserCommand) response.Response {
|
||||
if !cmd.Role.IsValid() {
|
||||
return response.Error(400, "Invalid role specified", nil)
|
||||
}
|
||||
if !c.OrgRole.Includes(cmd.Role) && !c.IsGrafanaAdmin {
|
||||
return response.Error(http.StatusForbidden, "Cannot assign a role higher than user's role", nil)
|
||||
}
|
||||
|
||||
userQuery := models.GetUserByLoginQuery{LoginOrEmail: cmd.LoginOrEmail}
|
||||
err := hs.SQLStore.GetUserByLogin(c.Req.Context(), &userQuery)
|
||||
err := hs.SQLStore.GetUserByLogin(ctx, &userQuery)
|
||||
if err != nil {
|
||||
return response.Error(404, "User not found", nil)
|
||||
}
|
||||
@@ -57,7 +54,7 @@ func (hs *HTTPServer) addOrgUserHelper(c *models.ReqContext, cmd models.AddOrgUs
|
||||
|
||||
cmd.UserId = userToAdd.Id
|
||||
|
||||
if err := hs.SQLStore.AddOrgUser(c.Req.Context(), &cmd); err != nil {
|
||||
if err := hs.SQLStore.AddOrgUser(ctx, &cmd); err != nil {
|
||||
if errors.Is(err, models.ErrOrgUserAlreadyAdded) {
|
||||
return response.JSON(409, util.DynMap{
|
||||
"message": "User is already member of this organization",
|
||||
@@ -220,7 +217,7 @@ func (hs *HTTPServer) UpdateOrgUserForCurrentOrg(c *models.ReqContext) response.
|
||||
if err != nil {
|
||||
return response.Error(http.StatusBadRequest, "userId is invalid", err)
|
||||
}
|
||||
return hs.updateOrgUserHelper(c, cmd)
|
||||
return hs.updateOrgUserHelper(c.Req.Context(), cmd)
|
||||
}
|
||||
|
||||
// PATCH /api/orgs/:orgId/users/:userId
|
||||
@@ -238,17 +235,14 @@ func (hs *HTTPServer) UpdateOrgUser(c *models.ReqContext) response.Response {
|
||||
if err != nil {
|
||||
return response.Error(http.StatusBadRequest, "userId is invalid", err)
|
||||
}
|
||||
return hs.updateOrgUserHelper(c, cmd)
|
||||
return hs.updateOrgUserHelper(c.Req.Context(), cmd)
|
||||
}
|
||||
|
||||
func (hs *HTTPServer) updateOrgUserHelper(c *models.ReqContext, cmd models.UpdateOrgUserCommand) response.Response {
|
||||
func (hs *HTTPServer) updateOrgUserHelper(ctx context.Context, cmd models.UpdateOrgUserCommand) response.Response {
|
||||
if !cmd.Role.IsValid() {
|
||||
return response.Error(400, "Invalid role specified", nil)
|
||||
}
|
||||
if !c.OrgRole.Includes(cmd.Role) && !c.IsGrafanaAdmin {
|
||||
return response.Error(http.StatusForbidden, "Cannot assign a role higher than user's role", nil)
|
||||
}
|
||||
if err := hs.SQLStore.UpdateOrgUser(c.Req.Context(), &cmd); err != nil {
|
||||
if err := hs.SQLStore.UpdateOrgUser(ctx, &cmd); err != nil {
|
||||
if errors.Is(err, models.ErrLastOrgAdmin) {
|
||||
return response.Error(400, "Cannot change role so that there is no organization admin left", nil)
|
||||
}
|
||||
|
||||
@@ -570,112 +570,6 @@ func TestPostOrgUsersAPIEndpoint_AccessControl(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrgUsersAPIEndpointWithSetPerms_AccessControl(t *testing.T) {
|
||||
type accessControlTestCase2 struct {
|
||||
expectedCode int
|
||||
desc string
|
||||
url string
|
||||
method string
|
||||
permissions []*accesscontrol.Permission
|
||||
input string
|
||||
}
|
||||
tests := []accessControlTestCase2{
|
||||
{
|
||||
expectedCode: http.StatusOK,
|
||||
desc: "org viewer with the correct permissions can add a user as a viewer to his org",
|
||||
url: "/api/org/users",
|
||||
method: http.MethodPost,
|
||||
permissions: []*accesscontrol.Permission{{Action: accesscontrol.ActionOrgUsersAdd, Scope: accesscontrol.ScopeUsersAll}},
|
||||
input: `{"loginOrEmail": "` + testAdminOrg2.Login + `", "role": "` + string(models.ROLE_VIEWER) + `"}`,
|
||||
},
|
||||
{
|
||||
expectedCode: http.StatusForbidden,
|
||||
desc: "org viewer with the correct permissions cannot add a user as an editor to his org",
|
||||
url: "/api/org/users",
|
||||
method: http.MethodPost,
|
||||
permissions: []*accesscontrol.Permission{{Action: accesscontrol.ActionOrgUsersAdd, Scope: accesscontrol.ScopeUsersAll}},
|
||||
input: `{"loginOrEmail": "` + testAdminOrg2.Login + `", "role": "` + string(models.ROLE_EDITOR) + `"}`,
|
||||
},
|
||||
{
|
||||
expectedCode: http.StatusOK,
|
||||
desc: "org viewer with the correct permissions can add a user as a viewer to his org",
|
||||
url: "/api/orgs/1/users",
|
||||
method: http.MethodPost,
|
||||
permissions: []*accesscontrol.Permission{{Action: accesscontrol.ActionOrgUsersAdd, Scope: accesscontrol.ScopeUsersAll}},
|
||||
input: `{"loginOrEmail": "` + testAdminOrg2.Login + `", "role": "` + string(models.ROLE_VIEWER) + `"}`,
|
||||
},
|
||||
{
|
||||
expectedCode: http.StatusForbidden,
|
||||
desc: "org viewer with the correct permissions cannot add a user as an editor to his org",
|
||||
url: "/api/orgs/1/users",
|
||||
method: http.MethodPost,
|
||||
permissions: []*accesscontrol.Permission{{Action: accesscontrol.ActionOrgUsersAdd, Scope: accesscontrol.ScopeUsersAll}},
|
||||
input: `{"loginOrEmail": "` + testAdminOrg2.Login + `", "role": "` + string(models.ROLE_EDITOR) + `"}`,
|
||||
},
|
||||
{
|
||||
expectedCode: http.StatusOK,
|
||||
desc: "org viewer with the correct permissions can update a user's role to a viewer in his org",
|
||||
url: fmt.Sprintf("/api/org/users/%d", testEditorOrg1.UserId),
|
||||
method: http.MethodPatch,
|
||||
permissions: []*accesscontrol.Permission{{Action: accesscontrol.ActionOrgUsersWrite, Scope: accesscontrol.ScopeUsersAll}},
|
||||
input: `{"role": "` + string(models.ROLE_VIEWER) + `"}`,
|
||||
},
|
||||
{
|
||||
expectedCode: http.StatusForbidden,
|
||||
desc: "org viewer with the correct permissions cannot update a user's role to a viewer in his org",
|
||||
url: fmt.Sprintf("/api/org/users/%d", testEditorOrg1.UserId),
|
||||
method: http.MethodPatch,
|
||||
permissions: []*accesscontrol.Permission{{Action: accesscontrol.ActionOrgUsersWrite, Scope: accesscontrol.ScopeUsersAll}},
|
||||
input: `{"role": "` + string(models.ROLE_EDITOR) + `"}`,
|
||||
},
|
||||
{
|
||||
expectedCode: http.StatusOK,
|
||||
desc: "org viewer with the correct permissions can update a user's role to a viewer in his org",
|
||||
url: fmt.Sprintf("/api/orgs/1/users/%d", testEditorOrg1.UserId),
|
||||
method: http.MethodPatch,
|
||||
permissions: []*accesscontrol.Permission{{Action: accesscontrol.ActionOrgUsersWrite, Scope: accesscontrol.ScopeUsersAll}},
|
||||
input: `{"role": "` + string(models.ROLE_VIEWER) + `"}`,
|
||||
},
|
||||
{
|
||||
expectedCode: http.StatusForbidden,
|
||||
desc: "org viewer with the correct permissions cannot update a user's role to a viewer in his org",
|
||||
url: fmt.Sprintf("/api/orgs/1/users/%d", testEditorOrg1.UserId),
|
||||
method: http.MethodPatch,
|
||||
permissions: []*accesscontrol.Permission{{Action: accesscontrol.ActionOrgUsersWrite, Scope: accesscontrol.ScopeUsersAll}},
|
||||
input: `{"role": "` + string(models.ROLE_EDITOR) + `"}`,
|
||||
},
|
||||
{
|
||||
expectedCode: http.StatusOK,
|
||||
desc: "org viewer with the correct permissions can invite a user as a viewer in his org",
|
||||
url: "/api/org/invites",
|
||||
method: http.MethodPost,
|
||||
permissions: []*accesscontrol.Permission{{Action: accesscontrol.ActionOrgUsersAdd, Scope: accesscontrol.ScopeUsersAll}},
|
||||
input: `{"loginOrEmail": "newUserEmail@test.com", "sendEmail": false, "role": "` + string(models.ROLE_VIEWER) + `"}`,
|
||||
},
|
||||
{
|
||||
expectedCode: http.StatusForbidden,
|
||||
desc: "org viewer with the correct permissions cannot invite a user as an editor in his org",
|
||||
url: "/api/org/invites",
|
||||
method: http.MethodPost,
|
||||
permissions: []*accesscontrol.Permission{{Action: accesscontrol.ActionUsersCreate}},
|
||||
input: `{"loginOrEmail": "newUserEmail@test.com", "sendEmail": false, "role": "` + string(models.ROLE_EDITOR) + `"}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.desc, func(t *testing.T) {
|
||||
sc := setupHTTPServer(t, true, true)
|
||||
setInitCtxSignedInViewer(sc.initCtx)
|
||||
setupOrgUsersDBForAccessControlTests(t, sc.db)
|
||||
setAccessControlPermissions(sc.acmock, test.permissions, sc.initCtx.OrgId)
|
||||
|
||||
input := strings.NewReader(test.input)
|
||||
response := callAPI(sc.server, test.method, test.url, input, t)
|
||||
assert.Equal(t, test.expectedCode, response.Code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchOrgUsersAPIEndpoint_AccessControl(t *testing.T) {
|
||||
url := "/api/orgs/%v/users/%v"
|
||||
type testCase struct {
|
||||
|
||||
@@ -126,8 +126,12 @@ func hiddenRefIDs(queries []Query) (map[string]struct{}, error) {
|
||||
return hidden, nil
|
||||
}
|
||||
|
||||
func (s *Service) decryptSecureJsonDataFn(ctx context.Context) func(ds *models.DataSource) (map[string]string, error) {
|
||||
return func(ds *models.DataSource) (map[string]string, error) {
|
||||
return s.dataSourceService.DecryptedValues(ctx, ds)
|
||||
func (s *Service) decryptSecureJsonDataFn(ctx context.Context) func(ds *models.DataSource) map[string]string {
|
||||
return func(ds *models.DataSource) map[string]string {
|
||||
decryptedJsonData, err := s.dataSourceService.DecryptedValues(ctx, ds)
|
||||
if err != nil {
|
||||
logger.Error("Failed to decrypt secure json data", "error", err)
|
||||
}
|
||||
return decryptedJsonData
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,15 +81,6 @@ func (ds DataSource) AllowedCookies() []string {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
// Specific error type for grpc secrets management so that we can show more detailed plugin errors to users
|
||||
type ErrDatasourceSecretsPluginUserFriendly struct {
|
||||
Err string
|
||||
}
|
||||
|
||||
func (e ErrDatasourceSecretsPluginUserFriendly) Error() string {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// ----------------------
|
||||
// COMMANDS
|
||||
|
||||
|
||||
@@ -3,27 +3,22 @@ package adapters
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
)
|
||||
|
||||
// ModelToInstanceSettings converts a datasources.DataSource to a backend.DataSourceInstanceSettings.
|
||||
func ModelToInstanceSettings(ds *models.DataSource, decryptFn func(ds *models.DataSource) (map[string]string, error),
|
||||
// ModelToInstanceSettings converts a models.DataSource to a backend.DataSourceInstanceSettings.
|
||||
func ModelToInstanceSettings(ds *models.DataSource, decryptFn func(ds *models.DataSource) map[string]string,
|
||||
) (*backend.DataSourceInstanceSettings, error) {
|
||||
var jsonDataBytes json.RawMessage
|
||||
if ds.JsonData != nil {
|
||||
var err error
|
||||
jsonDataBytes, err = ds.JsonData.MarshalJSON()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert data source to instance settings: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
decrypted, err := decryptFn(ds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &backend.DataSourceInstanceSettings{
|
||||
ID: ds.Id,
|
||||
@@ -35,9 +30,9 @@ func ModelToInstanceSettings(ds *models.DataSource, decryptFn func(ds *models.Da
|
||||
BasicAuthEnabled: ds.BasicAuth,
|
||||
BasicAuthUser: ds.BasicAuthUser,
|
||||
JSONData: jsonDataBytes,
|
||||
DecryptedSecureJSONData: decrypted,
|
||||
DecryptedSecureJSONData: decryptFn(ds),
|
||||
Updated: ds.Updated,
|
||||
}, err
|
||||
}, nil
|
||||
}
|
||||
|
||||
// BackendUserFromSignedInUser converts Grafana's SignedInUser model
|
||||
|
||||
@@ -15,8 +15,6 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gobwas/glob"
|
||||
|
||||
// TODO: replace deprecated `golang.org/x/crypto` package https://github.com/grafana/grafana/issues/46050
|
||||
// nolint:staticcheck
|
||||
"golang.org/x/crypto/openpgp"
|
||||
@@ -26,6 +24,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/util/errutil"
|
||||
)
|
||||
|
||||
// Soon we can fetch keys from:
|
||||
@@ -86,11 +85,18 @@ func readPluginManifest(body []byte) (*pluginManifest, error) {
|
||||
var manifest pluginManifest
|
||||
err := json.Unmarshal(block.Plaintext, &manifest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%v: %w", "Error parsing manifest JSON", err)
|
||||
return nil, errutil.Wrap("Error parsing manifest JSON", err)
|
||||
}
|
||||
|
||||
if err = validateManifest(manifest, block); err != nil {
|
||||
return nil, err
|
||||
keyring, err := openpgp.ReadArmoredKeyRing(bytes.NewBufferString(publicKeyText))
|
||||
if err != nil {
|
||||
return nil, errutil.Wrap("failed to parse public key", err)
|
||||
}
|
||||
|
||||
if _, err := openpgp.CheckDetachedSignature(keyring,
|
||||
bytes.NewBuffer(block.Bytes),
|
||||
block.ArmoredSignature.Body); err != nil {
|
||||
return nil, errutil.Wrap("failed to check signature", err)
|
||||
}
|
||||
|
||||
return &manifest, nil
|
||||
@@ -126,7 +132,7 @@ func Calculate(mlog log.Logger, plugin *plugins.Plugin) (plugins.Signature, erro
|
||||
|
||||
manifest, err := readPluginManifest(byteValue)
|
||||
if err != nil {
|
||||
mlog.Debug("Plugin signature invalid", "id", plugin.ID, "err", err)
|
||||
mlog.Debug("Plugin signature invalid", "id", plugin.ID)
|
||||
return plugins.Signature{
|
||||
Status: plugins.SignatureInvalid,
|
||||
}, nil
|
||||
@@ -139,14 +145,32 @@ func Calculate(mlog log.Logger, plugin *plugins.Plugin) (plugins.Signature, erro
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Validate that plugin is running within defined root URLs
|
||||
if len(manifest.RootURLs) > 0 {
|
||||
if match, err := urlMatch(manifest.RootURLs, setting.AppUrl, manifest.SignatureType); err != nil {
|
||||
mlog.Warn("Could not verify if root URLs match", "plugin", plugin.ID, "rootUrls", manifest.RootURLs)
|
||||
// Validate that private is running within defined root URLs
|
||||
if manifest.SignatureType == plugins.PrivateSignature {
|
||||
appURL, err := url.Parse(setting.AppUrl)
|
||||
if err != nil {
|
||||
return plugins.Signature{}, err
|
||||
} else if !match {
|
||||
}
|
||||
|
||||
foundMatch := false
|
||||
for _, u := range manifest.RootURLs {
|
||||
rootURL, err := url.Parse(u)
|
||||
if err != nil {
|
||||
mlog.Warn("Could not parse plugin root URL", "plugin", plugin.ID, "rootUrl", rootURL)
|
||||
return plugins.Signature{}, err
|
||||
}
|
||||
|
||||
if rootURL.Scheme == appURL.Scheme &&
|
||||
rootURL.Host == appURL.Host &&
|
||||
path.Clean(rootURL.RequestURI()) == path.Clean(appURL.RequestURI()) {
|
||||
foundMatch = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !foundMatch {
|
||||
mlog.Warn("Could not find root URL that matches running application URL", "plugin", plugin.ID,
|
||||
"appUrl", setting.AppUrl, "rootUrls", manifest.RootURLs)
|
||||
"appUrl", appURL, "rootUrls", manifest.RootURLs)
|
||||
return plugins.Signature{
|
||||
Status: plugins.SignatureInvalid,
|
||||
}, nil
|
||||
@@ -276,84 +300,3 @@ func pluginFilesRequiringVerification(plugin *plugins.Plugin) ([]string, error)
|
||||
|
||||
return files, err
|
||||
}
|
||||
|
||||
func urlMatch(specs []string, target string, signatureType plugins.SignatureType) (bool, error) {
|
||||
targetURL, err := url.Parse(target)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
for _, spec := range specs {
|
||||
specURL, err := url.Parse(spec)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if specURL.Scheme == targetURL.Scheme && specURL.Host == targetURL.Host &&
|
||||
path.Clean(specURL.RequestURI()) == path.Clean(targetURL.RequestURI()) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if signatureType != plugins.PrivateGlobSignature {
|
||||
continue
|
||||
}
|
||||
|
||||
sp, err := glob.Compile(spec, '/', '.')
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if match := sp.Match(target); match {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
type invalidFieldErr struct {
|
||||
field string
|
||||
}
|
||||
|
||||
func (r invalidFieldErr) Error() string {
|
||||
return fmt.Sprintf("valid manifest field %s is required", r.field)
|
||||
}
|
||||
|
||||
func validateManifest(m pluginManifest, block *clearsign.Block) error {
|
||||
if len(m.Plugin) == 0 {
|
||||
return invalidFieldErr{field: "plugin"}
|
||||
}
|
||||
if len(m.Version) == 0 {
|
||||
return invalidFieldErr{field: "version"}
|
||||
}
|
||||
if len(m.KeyID) == 0 {
|
||||
return invalidFieldErr{field: "keyId"}
|
||||
}
|
||||
if m.Time == 0 {
|
||||
return invalidFieldErr{field: "time"}
|
||||
}
|
||||
if len(m.Files) == 0 {
|
||||
return invalidFieldErr{field: "files"}
|
||||
}
|
||||
if m.isV2() {
|
||||
if len(m.SignedByOrg) == 0 {
|
||||
return invalidFieldErr{field: "signedByOrg"}
|
||||
}
|
||||
if len(m.SignedByOrgName) == 0 {
|
||||
return invalidFieldErr{field: "signedByOrgName"}
|
||||
}
|
||||
if !m.SignatureType.IsValid() {
|
||||
return fmt.Errorf("%s is not a valid signature type", m.SignatureType)
|
||||
}
|
||||
}
|
||||
keyring, err := openpgp.ReadArmoredKeyRing(bytes.NewBufferString(publicKeyText))
|
||||
if err != nil {
|
||||
return fmt.Errorf("%v: %w", "failed to parse public key", err)
|
||||
}
|
||||
|
||||
if _, err = openpgp.CheckDetachedSignature(keyring,
|
||||
bytes.NewBuffer(block.Bytes),
|
||||
block.ArmoredSignature.Body); err != nil {
|
||||
return fmt.Errorf("%v: %w", "failed to check signature", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -121,359 +121,3 @@ func fileList(manifest *pluginManifest) []string {
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func Test_urlMatch_privateGlob(t *testing.T) {
|
||||
type args struct {
|
||||
specs []string
|
||||
target string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
shouldMatch bool
|
||||
}{
|
||||
{
|
||||
name: "Support single wildcard matching single subdomain",
|
||||
args: args{
|
||||
specs: []string{"https://*.example.com"},
|
||||
target: "https://test.example.com",
|
||||
},
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
name: "Do not support single wildcard matching multiple subdomains",
|
||||
args: args{
|
||||
specs: []string{"https://*.example.com"},
|
||||
target: "https://more.test.example.com",
|
||||
},
|
||||
shouldMatch: false,
|
||||
},
|
||||
{
|
||||
name: "Support multiple wildcards matching multiple subdomains",
|
||||
args: args{
|
||||
specs: []string{"https://**.example.com"},
|
||||
target: "https://test.example.com",
|
||||
},
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
name: "Support multiple wildcards matching multiple subdomains",
|
||||
args: args{
|
||||
specs: []string{"https://**.example.com"},
|
||||
target: "https://more.test.example.com",
|
||||
},
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
name: "Support single wildcard matching single paths",
|
||||
args: args{
|
||||
specs: []string{"https://www.example.com/*"},
|
||||
target: "https://www.example.com/grafana1",
|
||||
},
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
name: "Do not support single wildcard matching multiple paths",
|
||||
args: args{
|
||||
specs: []string{"https://www.example.com/*"},
|
||||
target: "https://www.example.com/other/grafana",
|
||||
},
|
||||
shouldMatch: false,
|
||||
},
|
||||
{
|
||||
name: "Support double wildcard matching multiple paths",
|
||||
args: args{
|
||||
specs: []string{"https://www.example.com/**"},
|
||||
target: "https://www.example.com/other/grafana",
|
||||
},
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
name: "Do not support subdomain mismatch",
|
||||
args: args{
|
||||
specs: []string{"https://www.test.example.com/grafana/docs"},
|
||||
target: "https://www.dev.example.com/grafana/docs",
|
||||
},
|
||||
shouldMatch: false,
|
||||
},
|
||||
{
|
||||
name: "Support single wildcard matching single path",
|
||||
args: args{
|
||||
specs: []string{"https://www.example.com/grafana*"},
|
||||
target: "https://www.example.com/grafana1",
|
||||
},
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
name: "Do not support single wildcard matching different path prefix",
|
||||
args: args{
|
||||
specs: []string{"https://www.example.com/grafana*"},
|
||||
target: "https://www.example.com/somethingelse",
|
||||
},
|
||||
shouldMatch: false,
|
||||
},
|
||||
{
|
||||
name: "Do not support path mismatch",
|
||||
args: args{
|
||||
specs: []string{"https://example.com/grafana"},
|
||||
target: "https://example.com/grafana1",
|
||||
},
|
||||
shouldMatch: false,
|
||||
},
|
||||
{
|
||||
name: "Support both domain and path wildcards",
|
||||
args: args{
|
||||
specs: []string{"https://*.example.com/*"},
|
||||
target: "https://www.example.com/grafana1",
|
||||
},
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
name: "Do not support wildcards without TLDs",
|
||||
args: args{
|
||||
specs: []string{"https://example.*"},
|
||||
target: "https://www.example.com/grafana1",
|
||||
},
|
||||
shouldMatch: false,
|
||||
},
|
||||
{
|
||||
name: "Support exact match",
|
||||
args: args{
|
||||
specs: []string{"https://example.com/test"},
|
||||
target: "https://example.com/test",
|
||||
},
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
name: "Does not support scheme mismatch",
|
||||
args: args{
|
||||
specs: []string{"https://test.example.com/grafana"},
|
||||
target: "http://test.example.com/grafana",
|
||||
},
|
||||
shouldMatch: false,
|
||||
},
|
||||
{
|
||||
name: "Support trailing slash in spec",
|
||||
args: args{
|
||||
specs: []string{"https://example.com/"},
|
||||
target: "https://example.com",
|
||||
},
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
name: "Support trailing slash in target",
|
||||
args: args{
|
||||
specs: []string{"https://example.com"},
|
||||
target: "https://example.com/",
|
||||
},
|
||||
shouldMatch: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := urlMatch(tt.args.specs, tt.args.target, plugins.PrivateGlobSignature)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.shouldMatch, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_urlMatch_private(t *testing.T) {
|
||||
type args struct {
|
||||
specs []string
|
||||
target string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
shouldMatch bool
|
||||
}{
|
||||
{
|
||||
name: "Support exact match",
|
||||
args: args{
|
||||
specs: []string{"https://example.com/test"},
|
||||
target: "https://example.com/test",
|
||||
},
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
name: "Support trailing slash in spec",
|
||||
args: args{
|
||||
specs: []string{"https://example.com/test/"},
|
||||
target: "https://example.com/test",
|
||||
},
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
name: "Support trailing slash in target",
|
||||
args: args{
|
||||
specs: []string{"https://example.com/test"},
|
||||
target: "https://example.com/test/",
|
||||
},
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
name: "Do not support single wildcard matching single subdomain",
|
||||
args: args{
|
||||
specs: []string{"https://*.example.com"},
|
||||
target: "https://test.example.com",
|
||||
},
|
||||
shouldMatch: false,
|
||||
},
|
||||
{
|
||||
name: "Do not support multiple wildcards matching multiple subdomains",
|
||||
args: args{
|
||||
specs: []string{"https://**.example.com"},
|
||||
target: "https://more.test.example.com",
|
||||
},
|
||||
shouldMatch: false,
|
||||
},
|
||||
{
|
||||
name: "Do not support single wildcard matching single paths",
|
||||
args: args{
|
||||
specs: []string{"https://www.example.com/*"},
|
||||
target: "https://www.example.com/grafana1",
|
||||
},
|
||||
shouldMatch: false,
|
||||
},
|
||||
{
|
||||
name: "Do not support double wildcard matching multiple paths",
|
||||
args: args{
|
||||
specs: []string{"https://www.example.com/**"},
|
||||
target: "https://www.example.com/other/grafana",
|
||||
},
|
||||
shouldMatch: false,
|
||||
},
|
||||
{
|
||||
name: "Do not support subdomain mismatch",
|
||||
args: args{
|
||||
specs: []string{"https://www.test.example.com/grafana/docs"},
|
||||
target: "https://www.dev.example.com/grafana/docs",
|
||||
},
|
||||
shouldMatch: false,
|
||||
},
|
||||
{
|
||||
name: "Do not support path mismatch",
|
||||
args: args{
|
||||
specs: []string{"https://example.com/grafana"},
|
||||
target: "https://example.com/grafana1",
|
||||
},
|
||||
shouldMatch: false,
|
||||
},
|
||||
{
|
||||
name: "Do not support both domain and path wildcards",
|
||||
args: args{
|
||||
specs: []string{"https://*.example.com/*"},
|
||||
target: "https://www.example.com/grafana1",
|
||||
},
|
||||
shouldMatch: false,
|
||||
},
|
||||
{
|
||||
name: "Do not support wildcards without TLDs",
|
||||
args: args{
|
||||
specs: []string{"https://example.*"},
|
||||
target: "https://www.example.com/grafana1",
|
||||
},
|
||||
shouldMatch: false,
|
||||
},
|
||||
{
|
||||
name: "Do not support scheme mismatch",
|
||||
args: args{
|
||||
specs: []string{"https://test.example.com/grafana"},
|
||||
target: "http://test.example.com/grafana",
|
||||
},
|
||||
shouldMatch: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := urlMatch(tt.args.specs, tt.args.target, plugins.PrivateSignature)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.shouldMatch, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_validateManifest(t *testing.T) {
|
||||
tcs := []struct {
|
||||
name string
|
||||
manifest *pluginManifest
|
||||
expectedErr string
|
||||
}{
|
||||
{
|
||||
name: "Empty plugin field",
|
||||
manifest: createV2Manifest(t, func(m *pluginManifest) { m.Plugin = "" }),
|
||||
expectedErr: "valid manifest field plugin is required",
|
||||
},
|
||||
{
|
||||
name: "Empty keyId field",
|
||||
manifest: createV2Manifest(t, func(m *pluginManifest) { m.KeyID = "" }),
|
||||
expectedErr: "valid manifest field keyId is required",
|
||||
},
|
||||
{
|
||||
name: "Empty signedByOrg field",
|
||||
manifest: createV2Manifest(t, func(m *pluginManifest) { m.SignedByOrg = "" }),
|
||||
expectedErr: "valid manifest field signedByOrg is required",
|
||||
},
|
||||
{
|
||||
name: "Empty signedByOrgName field",
|
||||
manifest: createV2Manifest(t, func(m *pluginManifest) { m.SignedByOrgName = "" }),
|
||||
expectedErr: "valid manifest field SignedByOrgName is required",
|
||||
},
|
||||
{
|
||||
name: "Empty signatureType field",
|
||||
manifest: createV2Manifest(t, func(m *pluginManifest) { m.SignatureType = "" }),
|
||||
expectedErr: "valid manifest field signatureType is required",
|
||||
},
|
||||
{
|
||||
name: "Invalid signatureType field",
|
||||
manifest: createV2Manifest(t, func(m *pluginManifest) { m.SignatureType = "invalidSignatureType" }),
|
||||
expectedErr: "valid manifest field signatureType is required",
|
||||
},
|
||||
{
|
||||
name: "Empty files field",
|
||||
manifest: createV2Manifest(t, func(m *pluginManifest) { m.Files = map[string]string{} }),
|
||||
expectedErr: "valid manifest field files is required",
|
||||
},
|
||||
{
|
||||
name: "Empty time field",
|
||||
manifest: createV2Manifest(t, func(m *pluginManifest) { m.Time = 0 }),
|
||||
expectedErr: "valid manifest field time is required",
|
||||
},
|
||||
{
|
||||
name: "Empty version field",
|
||||
manifest: createV2Manifest(t, func(m *pluginManifest) { m.Version = "" }),
|
||||
expectedErr: "valid manifest field version is required",
|
||||
},
|
||||
}
|
||||
for _, tc := range tcs {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := validateManifest(*tc.manifest, nil)
|
||||
require.Errorf(t, err, tc.expectedErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func createV2Manifest(t *testing.T, cbs ...func(*pluginManifest)) *pluginManifest {
|
||||
t.Helper()
|
||||
|
||||
m := &pluginManifest{
|
||||
Plugin: "grafana-test-app",
|
||||
Version: "2.5.3",
|
||||
KeyID: "7e4d0c6a708866e7",
|
||||
Time: 1586817677115,
|
||||
Files: map[string]string{
|
||||
"plugin.json": "55556b845e91935cc48fae3aa67baf0f22694c3f",
|
||||
},
|
||||
ManifestVersion: "2.0.0",
|
||||
SignatureType: plugins.GrafanaSignature,
|
||||
SignedByOrg: "grafana",
|
||||
SignedByOrgName: "grafana",
|
||||
}
|
||||
|
||||
for _, cb := range cbs {
|
||||
cb(m)
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -176,21 +176,10 @@ const (
|
||||
type SignatureType string
|
||||
|
||||
const (
|
||||
GrafanaSignature SignatureType = "grafana"
|
||||
CommercialSignature SignatureType = "commercial"
|
||||
CommunitySignature SignatureType = "community"
|
||||
PrivateSignature SignatureType = "private"
|
||||
PrivateGlobSignature SignatureType = "private-glob"
|
||||
GrafanaSignature SignatureType = "grafana"
|
||||
PrivateSignature SignatureType = "private"
|
||||
)
|
||||
|
||||
func (s SignatureType) IsValid() bool {
|
||||
switch s {
|
||||
case GrafanaSignature, CommercialSignature, CommunitySignature, PrivateSignature, PrivateGlobSignature:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type PluginFiles map[string]struct{}
|
||||
|
||||
type Signature struct {
|
||||
|
||||
@@ -127,8 +127,12 @@ func (p *Provider) getCachedPluginSettings(ctx context.Context, pluginID string,
|
||||
return ps, nil
|
||||
}
|
||||
|
||||
func (p *Provider) decryptSecureJsonDataFn(ctx context.Context) func(ds *models.DataSource) (map[string]string, error) {
|
||||
return func(ds *models.DataSource) (map[string]string, error) {
|
||||
return p.dataSourceService.DecryptedValues(ctx, ds)
|
||||
func (p *Provider) decryptSecureJsonDataFn(ctx context.Context) func(ds *models.DataSource) map[string]string {
|
||||
return func(ds *models.DataSource) map[string]string {
|
||||
decryptedJsonData, err := p.dataSourceService.DecryptedValues(ctx, ds)
|
||||
if err != nil {
|
||||
p.logger.Error("Failed to decrypt secure json data", "error", err)
|
||||
}
|
||||
return decryptedJsonData
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/guardian"
|
||||
"github.com/grafana/grafana/pkg/services/live"
|
||||
"github.com/grafana/grafana/pkg/services/live/pushhttp"
|
||||
"github.com/grafana/grafana/pkg/services/login/authinfoservice"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert"
|
||||
"github.com/grafana/grafana/pkg/services/notifications"
|
||||
plugindashboardsservice "github.com/grafana/grafana/pkg/services/plugindashboards/service"
|
||||
@@ -39,7 +38,6 @@ func ProvideBackgroundServiceRegistry(
|
||||
pluginsUpdateChecker *updatechecker.PluginsService, metrics *metrics.InternalMetricsService,
|
||||
secretsService *secretsManager.SecretsService, remoteCache *remotecache.RemoteCache,
|
||||
thumbnailsService thumbs.Service, StorageService store.StorageService, searchService searchV2.SearchService, entityEventsService store.EntityEventsService,
|
||||
authInfoService *authinfoservice.Implementation,
|
||||
// Need to make sure these are initialized, is there a better place to put them?
|
||||
_ *dashboardsnapshots.Service, _ *alerting.AlertNotificationService,
|
||||
_ serviceaccounts.Service, _ *guardian.Provider,
|
||||
@@ -69,7 +67,6 @@ func ProvideBackgroundServiceRegistry(
|
||||
thumbnailsService,
|
||||
searchService,
|
||||
entityEventsService,
|
||||
authInfoService,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -489,7 +489,7 @@ func (dr *DashboardServiceImpl) setDefaultPermissions(ctx context.Context, dto *
|
||||
inFolder := dash.FolderId > 0
|
||||
if !accesscontrol.IsDisabled(dr.cfg) {
|
||||
var permissions []accesscontrol.SetResourcePermissionCommand
|
||||
if !provisioned && dto.User.IsRealUser() && !dto.User.IsAnonymous {
|
||||
if !provisioned {
|
||||
permissions = append(permissions, accesscontrol.SetResourcePermissionCommand{
|
||||
UserID: dto.User.UserId, Permission: models.PERMISSION_ADMIN.String(),
|
||||
})
|
||||
@@ -511,7 +511,7 @@ func (dr *DashboardServiceImpl) setDefaultPermissions(ctx context.Context, dto *
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if dr.cfg.EditorsCanAdmin && !provisioned && dto.User.IsRealUser() && !dto.User.IsAnonymous {
|
||||
} else if dr.cfg.EditorsCanAdmin && !provisioned {
|
||||
if err := dr.MakeUserAdmin(ctx, dto.OrgId, dto.User.UserId, dash.Id, !inFolder); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -169,20 +169,12 @@ func (f *FolderServiceImpl) CreateFolder(ctx context.Context, user *models.Signe
|
||||
|
||||
var permissionErr error
|
||||
if !accesscontrol.IsDisabled(f.cfg) {
|
||||
var permissions []accesscontrol.SetResourcePermissionCommand
|
||||
if user.IsRealUser() && !user.IsAnonymous {
|
||||
permissions = append(permissions, accesscontrol.SetResourcePermissionCommand{
|
||||
UserID: userID, Permission: models.PERMISSION_ADMIN.String(),
|
||||
})
|
||||
}
|
||||
|
||||
permissions = append(permissions, []accesscontrol.SetResourcePermissionCommand{
|
||||
_, permissionErr = f.permissions.SetPermissions(ctx, orgID, folder.Uid, []accesscontrol.SetResourcePermissionCommand{
|
||||
{UserID: userID, Permission: models.PERMISSION_ADMIN.String()},
|
||||
{BuiltinRole: string(models.ROLE_EDITOR), Permission: models.PERMISSION_EDIT.String()},
|
||||
{BuiltinRole: string(models.ROLE_VIEWER), Permission: models.PERMISSION_VIEW.String()},
|
||||
}...)
|
||||
|
||||
_, permissionErr = f.permissions.SetPermissions(ctx, orgID, folder.Uid, permissions...)
|
||||
} else if f.cfg.EditorsCanAdmin && user.IsRealUser() && !user.IsAnonymous {
|
||||
} else if f.cfg.EditorsCanAdmin {
|
||||
permissionErr = f.MakeUserAdmin(ctx, orgID, userID, folder.Id, true)
|
||||
}
|
||||
|
||||
|
||||
@@ -11,9 +11,8 @@ import (
|
||||
)
|
||||
|
||||
type FakeDataSourceService struct {
|
||||
lastId int64
|
||||
DataSources []*models.DataSource
|
||||
SimulatePluginFailure bool
|
||||
lastId int64
|
||||
DataSources []*models.DataSource
|
||||
}
|
||||
|
||||
var _ datasources.DataSourceService = &FakeDataSourceService{}
|
||||
@@ -108,9 +107,6 @@ func (s *FakeDataSourceService) GetHTTPTransport(ctx context.Context, ds *models
|
||||
}
|
||||
|
||||
func (s *FakeDataSourceService) DecryptedValues(ctx context.Context, ds *models.DataSource) (map[string]string, error) {
|
||||
if s.SimulatePluginFailure {
|
||||
return nil, models.ErrDatasourceSecretsPluginUserFriendly{Err: "unknown error"}
|
||||
}
|
||||
values := make(map[string]string)
|
||||
return values, nil
|
||||
}
|
||||
|
||||
@@ -27,12 +27,7 @@ SELECT DISTINCT
|
||||
, (SELECT COUNT(connection_id) FROM ` + models.LibraryElementConnectionTableName + ` WHERE element_id = le.id AND kind=1) AS connected_dashboards`
|
||||
)
|
||||
|
||||
const deleteInvalidConnections = `
|
||||
DELETE FROM library_element_connection
|
||||
WHERE connection_id IN (
|
||||
SELECT connection_id as id FROM library_element_connection
|
||||
WHERE element_id=? AND connection_id NOT IN (SELECT id as connection_id from dashboard)
|
||||
)`
|
||||
const deleteInvalidConnections = "DELETE FROM library_element_connection WHERE connection_id IN (SELECT connection_id as id FROM library_element_connection WHERE element_id=? EXCEPT SELECT id from dashboard)"
|
||||
|
||||
func getFromLibraryElementDTOWithMeta(dialect migrator.Dialect) string {
|
||||
user := dialect.Quote("user")
|
||||
@@ -594,7 +589,7 @@ func (l *LibraryElementService) getConnections(c context.Context, signedInUser *
|
||||
return connections, err
|
||||
}
|
||||
|
||||
// getElementsForDashboardID gets all elements for a specific dashboard
|
||||
//getElementsForDashboardID gets all elements for a specific dashboard
|
||||
func (l *LibraryElementService) getElementsForDashboardID(c context.Context, dashboardID int64) (map[string]LibraryElementDTO, error) {
|
||||
libraryElementMap := make(map[string]LibraryElementDTO)
|
||||
err := l.SQLStore.WithDbSession(c, func(session *sqlstore.DBSession) error {
|
||||
|
||||
@@ -25,7 +25,6 @@ func ProvideAuthInfoStore(sqlStore sqlstore.Store, secretsService secrets.Servic
|
||||
secretsService: secretsService,
|
||||
logger: log.New("login.authinfo.store"),
|
||||
}
|
||||
InitMetrics()
|
||||
return store
|
||||
}
|
||||
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
type LoginStats struct {
|
||||
DuplicateUserEntries int `xorm:"duplicate_user_entries"`
|
||||
MixedCasedUsers int `xorm:"mixed_cased_users"`
|
||||
}
|
||||
|
||||
const (
|
||||
ExporterName = "grafana"
|
||||
metricsCollectionInterval = time.Second * 60 * 4 // every 4 hours, indication of duplicate users
|
||||
)
|
||||
|
||||
var (
|
||||
// MStatDuplicateUserEntries is a indication metric gauge for number of users with duplicate emails or logins
|
||||
MStatDuplicateUserEntries prometheus.Gauge
|
||||
|
||||
// MStatHasDuplicateEntries is a metric for if there is duplicate users
|
||||
MStatHasDuplicateEntries prometheus.Gauge
|
||||
|
||||
// MStatMixedCasedUsers is a metric for if there is duplicate users
|
||||
MStatMixedCasedUsers prometheus.Gauge
|
||||
|
||||
once sync.Once
|
||||
Initialised bool = false
|
||||
)
|
||||
|
||||
func InitMetrics() {
|
||||
once.Do(func() {
|
||||
MStatDuplicateUserEntries = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "stat_users_total_duplicate_user_entries",
|
||||
Help: "total number of duplicate user entries by email or login",
|
||||
Namespace: ExporterName,
|
||||
})
|
||||
|
||||
MStatHasDuplicateEntries = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "stat_users_has_duplicate_user_entries",
|
||||
Help: "instance has duplicate user entries by email or login",
|
||||
Namespace: ExporterName,
|
||||
})
|
||||
|
||||
MStatMixedCasedUsers = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "stat_users_total_mixed_cased_users",
|
||||
Help: "total number of users with upper and lower case logins or emails",
|
||||
Namespace: ExporterName,
|
||||
})
|
||||
|
||||
prometheus.MustRegister(
|
||||
MStatDuplicateUserEntries,
|
||||
MStatHasDuplicateEntries,
|
||||
MStatMixedCasedUsers,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *AuthInfoStore) RunMetricsCollection(ctx context.Context) error {
|
||||
if _, err := s.GetLoginStats(ctx); err != nil {
|
||||
s.logger.Warn("Failed to get authinfo metrics", "error", err.Error())
|
||||
}
|
||||
updateStatsTicker := time.NewTicker(metricsCollectionInterval)
|
||||
defer updateStatsTicker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-updateStatsTicker.C:
|
||||
if _, err := s.GetLoginStats(ctx); err != nil {
|
||||
s.logger.Warn("Failed to get authinfo metrics", "error", err.Error())
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AuthInfoStore) GetLoginStats(ctx context.Context) (LoginStats, error) {
|
||||
var stats LoginStats
|
||||
outerErr := s.sqlStore.WithDbSession(ctx, func(dbSession *sqlstore.DBSession) error {
|
||||
rawSQL := `SELECT
|
||||
(SELECT COUNT(*) FROM (` + s.duplicateUserEntriesSQL(ctx) + `) AS d WHERE (d.dup_login IS NOT NULL OR d.dup_email IS NOT NULL)) as duplicate_user_entries,
|
||||
(SELECT COUNT(*) FROM (` + s.mixedCasedUsers(ctx) + `) AS mcu) AS mixed_cased_users
|
||||
`
|
||||
_, err := dbSession.SQL(rawSQL).Get(&stats)
|
||||
return err
|
||||
})
|
||||
if outerErr != nil {
|
||||
return stats, outerErr
|
||||
}
|
||||
|
||||
// set prometheus metrics stats
|
||||
MStatDuplicateUserEntries.Set(float64(stats.DuplicateUserEntries))
|
||||
if stats.DuplicateUserEntries == 0 {
|
||||
MStatHasDuplicateEntries.Set(float64(0))
|
||||
} else {
|
||||
MStatHasDuplicateEntries.Set(float64(1))
|
||||
}
|
||||
|
||||
MStatMixedCasedUsers.Set(float64(stats.MixedCasedUsers))
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (s *AuthInfoStore) CollectLoginStats(ctx context.Context) (map[string]interface{}, error) {
|
||||
m := map[string]interface{}{}
|
||||
|
||||
loginStats, err := s.GetLoginStats(ctx)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to get login stats", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m["stats.users.duplicate_user_entries"] = loginStats.DuplicateUserEntries
|
||||
if loginStats.DuplicateUserEntries > 0 {
|
||||
m["stats.users.has_duplicate_user_entries"] = 1
|
||||
} else {
|
||||
m["stats.users.has_duplicate_user_entries"] = 0
|
||||
}
|
||||
|
||||
m["stats.users.mixed_cased_users"] = loginStats.MixedCasedUsers
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (s *AuthInfoStore) duplicateUserEntriesSQL(ctx context.Context) string {
|
||||
userDialect := s.sqlStore.GetDialect().Quote("user")
|
||||
// this query counts how many users have the same login or email.
|
||||
// which might be confusing, but gives a good indication
|
||||
// we want this query to not require too much cpu
|
||||
sqlQuery := `SELECT
|
||||
(SELECT login from ` + userDialect + ` WHERE (LOWER(login) = LOWER(u.login)) AND (login != u.login)) AS dup_login,
|
||||
(SELECT email from ` + userDialect + ` WHERE (LOWER(email) = LOWER(u.email)) AND (email != u.email)) AS dup_email
|
||||
FROM ` + userDialect + ` AS u`
|
||||
return sqlQuery
|
||||
}
|
||||
|
||||
func (s *AuthInfoStore) mixedCasedUsers(ctx context.Context) string {
|
||||
userDialect := s.sqlStore.GetDialect().Quote("user")
|
||||
// this query counts how many users have upper case and lower case login or emails.
|
||||
// why
|
||||
// users login via IDP or service providers get upper cased domains at times :shrug:
|
||||
sqlQuery := `SELECT login, email FROM ` + userDialect + ` WHERE (LOWER(login) != login OR lower(email) != email)`
|
||||
return sqlQuery
|
||||
}
|
||||
56
pkg/services/login/authinfoservice/database/usagestats.go
Normal file
56
pkg/services/login/authinfoservice/database/usagestats.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||
)
|
||||
|
||||
type loginStats struct {
|
||||
DuplicateUserEntries int `xorm:"duplicate_user_entries"`
|
||||
}
|
||||
|
||||
func (s *AuthInfoStore) GetLoginStats(ctx context.Context) (loginStats, error) {
|
||||
var stats loginStats
|
||||
outerErr := s.sqlStore.WithDbSession(ctx, func(dbSession *sqlstore.DBSession) error {
|
||||
rawSQL := `SELECT COUNT(*) as duplicate_user_entries FROM (` + s.duplicateUserEntriesSQL(ctx) + `)`
|
||||
_, err := dbSession.SQL(rawSQL).Get(&stats)
|
||||
return err
|
||||
})
|
||||
if outerErr != nil {
|
||||
return stats, outerErr
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (s *AuthInfoStore) CollectLoginStats(ctx context.Context) (map[string]interface{}, error) {
|
||||
m := map[string]interface{}{}
|
||||
|
||||
loginStats, err := s.GetLoginStats(ctx)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to get login stats", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m["stats.users.duplicate_user_entries"] = loginStats.DuplicateUserEntries
|
||||
if loginStats.DuplicateUserEntries > 0 {
|
||||
m["stats.users.has_duplicate_user_entries"] = 1
|
||||
} else {
|
||||
m["stats.users.has_duplicate_user_entries"] = 0
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (s *AuthInfoStore) duplicateUserEntriesSQL(ctx context.Context) string {
|
||||
userDialect := s.sqlStore.GetDialect().Quote("user")
|
||||
// this query counts how many users have the same login or email.
|
||||
// which might be confusing, but gives a good indication
|
||||
// we want this query to not require too much cpu
|
||||
sqlQuery := `SELECT
|
||||
(SELECT login from ` + userDialect + ` WHERE (LOWER(login) = LOWER(u.login)) AND (login != u.login)) AS dup_login,
|
||||
(SELECT email from ` + userDialect + ` WHERE (LOWER(email) = LOWER(u.email)) AND (email != u.email)) AS dup_email
|
||||
FROM ` + userDialect + ` AS u
|
||||
WHERE (dup_login IS NOT NULL OR dup_email IS NOT NULL)
|
||||
`
|
||||
return sqlQuery
|
||||
}
|
||||
@@ -195,8 +195,3 @@ func (s *Implementation) SetAuthInfo(ctx context.Context, cmd *models.SetAuthInf
|
||||
func (s *Implementation) GetExternalUserInfoByLogin(ctx context.Context, query *models.GetExternalUserInfoByLoginQuery) error {
|
||||
return s.authInfoStore.GetExternalUserInfoByLogin(ctx, query)
|
||||
}
|
||||
|
||||
func (s *Implementation) Run(ctx context.Context) error {
|
||||
s.logger.Debug("Started AuthInfo Metrics collection service")
|
||||
return s.authInfoStore.RunMetricsCollection(ctx)
|
||||
}
|
||||
|
||||
@@ -370,24 +370,6 @@ func TestUserAuth(t *testing.T) {
|
||||
require.Nil(t, user)
|
||||
})
|
||||
|
||||
t.Run("should be able to run query in all dbs", func(t *testing.T) {
|
||||
// Restore after destructive operation
|
||||
sqlStore = sqlstore.InitTestDB(t)
|
||||
for i := 0; i < 5; i++ {
|
||||
cmd := models.CreateUserCommand{
|
||||
Email: fmt.Sprint("user", i, "@test.com"),
|
||||
Name: fmt.Sprint("user", i),
|
||||
Login: fmt.Sprint("loginuser", i),
|
||||
OrgId: 1,
|
||||
}
|
||||
_, err := sqlStore.CreateUser(context.Background(), cmd)
|
||||
require.Nil(t, err)
|
||||
}
|
||||
|
||||
_, err := srv.authInfoStore.GetLoginStats(context.Background())
|
||||
require.Nil(t, err)
|
||||
})
|
||||
|
||||
t.Run("calculate metrics on duplicate userstats", func(t *testing.T) {
|
||||
// Restore after destructive operation
|
||||
sqlStore = sqlstore.InitTestDB(t)
|
||||
@@ -421,14 +403,11 @@ func TestUserAuth(t *testing.T) {
|
||||
}
|
||||
_, err = sqlStore.CreateUser(context.Background(), dupUserLogincmd)
|
||||
require.NoError(t, err)
|
||||
|
||||
// require stats to populate
|
||||
// require metrics and statistics to be 2
|
||||
m, err := srv.authInfoStore.CollectLoginStats(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 2, m["stats.users.duplicate_user_entries"])
|
||||
require.Equal(t, 1, m["stats.users.has_duplicate_user_entries"])
|
||||
|
||||
require.Equal(t, 1, m["stats.users.mixed_cased_users"])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/login/authinfoservice/database"
|
||||
)
|
||||
|
||||
type UserProtectionService interface {
|
||||
@@ -22,6 +21,4 @@ type Store interface {
|
||||
GetUserByLogin(ctx context.Context, login string) (*models.User, error)
|
||||
GetUserByEmail(ctx context.Context, email string) (*models.User, error)
|
||||
CollectLoginStats(ctx context.Context) (map[string]interface{}, error)
|
||||
RunMetricsCollection(ctx context.Context) error
|
||||
GetLoginStats(ctx context.Context) (database.LoginStats, error)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/notifier/channels"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/notifier/channels_config"
|
||||
)
|
||||
|
||||
// swagger:route GET /api/v1/provisioning/contact-points provisioning stable RouteGetContactpoints
|
||||
@@ -119,17 +118,43 @@ func (e *EmbeddedContactPoint) Valid(decryptFunc channels.GetDecryptedValueFn) e
|
||||
}
|
||||
|
||||
func (e *EmbeddedContactPoint) SecretKeys() ([]string, error) {
|
||||
notifiers := channels_config.GetAvailableNotifiers()
|
||||
for _, n := range notifiers {
|
||||
if n.Type == e.Type {
|
||||
secureFields := []string{}
|
||||
for _, field := range n.Options {
|
||||
if field.Secure {
|
||||
secureFields = append(secureFields, field.PropertyName)
|
||||
}
|
||||
}
|
||||
return secureFields, nil
|
||||
}
|
||||
switch e.Type {
|
||||
case "alertmanager":
|
||||
return []string{"basicAuthPassword"}, nil
|
||||
case "dingding":
|
||||
return []string{}, nil
|
||||
case "discord":
|
||||
return []string{}, nil
|
||||
case "email":
|
||||
return []string{}, nil
|
||||
case "googlechat":
|
||||
return []string{}, nil
|
||||
case "kafka":
|
||||
return []string{}, nil
|
||||
case "line":
|
||||
return []string{"token"}, nil
|
||||
case "opsgenie":
|
||||
return []string{"apiKey"}, nil
|
||||
case "pagerduty":
|
||||
return []string{"integrationKey"}, nil
|
||||
case "pushover":
|
||||
return []string{"userKey", "apiToken"}, nil
|
||||
case "sensugo":
|
||||
return []string{"apiKey"}, nil
|
||||
case "slack":
|
||||
return []string{"url", "token"}, nil
|
||||
case "teams":
|
||||
return []string{}, nil
|
||||
case "telegram":
|
||||
return []string{"bottoken"}, nil
|
||||
case "threema":
|
||||
return []string{"api_secret"}, nil
|
||||
case "victorops":
|
||||
return []string{}, nil
|
||||
case "webhook":
|
||||
return []string{}, nil
|
||||
case "wecom":
|
||||
return []string{"url"}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("no secrets configured for type '%s'", e.Type)
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ import (
|
||||
)
|
||||
|
||||
var searchRegex = regexp.MustCompile(`\{(\w+)\}`)
|
||||
var errInvalidRecipientFormat = errors.New("invalid recipient (datasource) identifier format. Only integer is expected")
|
||||
|
||||
var NotImplementedResp = ErrResp(http.StatusNotImplemented, errors.New("endpoint not implemented"), "")
|
||||
|
||||
func toMacaronPath(path string) string {
|
||||
@@ -37,8 +37,7 @@ func toMacaronPath(path string) string {
|
||||
|
||||
func backendTypeByUID(ctx *models.ReqContext, cache datasources.CacheService) (apimodels.Backend, error) {
|
||||
datasourceUID := web.Params(ctx.Req)[":DatasourceUID"]
|
||||
ds, err := cache.GetDatasourceByUID(ctx.Req.Context(), datasourceUID, ctx.SignedInUser, ctx.SkipCache)
|
||||
if err == nil {
|
||||
if ds, err := cache.GetDatasourceByUID(ctx.Req.Context(), datasourceUID, ctx.SignedInUser, ctx.SkipCache); err == nil {
|
||||
switch ds.Type {
|
||||
case "loki", "prometheus":
|
||||
return apimodels.LoTexRulerBackend, nil
|
||||
@@ -48,7 +47,7 @@ func backendTypeByUID(ctx *models.ReqContext, cache datasources.CacheService) (a
|
||||
return 0, fmt.Errorf("unexpected backend type (%v)", ds.Type)
|
||||
}
|
||||
}
|
||||
return 0, errors.New("no datasource was found matching the given UID")
|
||||
return 0, fmt.Errorf("unexpected backend type (%v)", datasourceUID)
|
||||
}
|
||||
|
||||
// macaron unsafely asserts the http.ResponseWriter is an http.CloseNotifier, which will panic.
|
||||
@@ -101,7 +100,7 @@ func (p *AlertingProxy) withReq(
|
||||
if datasourceID != "" {
|
||||
recipient, err := strconv.ParseInt(web.Params(ctx.Req)[":DatasourceID"], 10, 64)
|
||||
if err != nil {
|
||||
return ErrResp(http.StatusBadRequest, errInvalidRecipientFormat, "")
|
||||
return ErrResp(http.StatusBadRequest, err, "DatasourceID is invalid")
|
||||
}
|
||||
|
||||
p.DataProxy.ProxyDatasourceRequestWithID(newCtx, recipient)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package channels_config
|
||||
package notifier
|
||||
|
||||
import (
|
||||
"github.com/grafana/grafana/pkg/services/alerting"
|
||||
@@ -112,7 +112,7 @@ func GetAvailableNotifiers() []*alerting.NotifierPlugin {
|
||||
Heading: "DingDing settings",
|
||||
Options: []alerting.NotifierOption{
|
||||
{
|
||||
Label: "URL",
|
||||
Label: "Url",
|
||||
Element: alerting.ElementTypeInput,
|
||||
InputType: alerting.InputTypeText,
|
||||
Placeholder: "https://oapi.dingtalk.com/robot/send?access_token=xxxxxxxxx",
|
||||
@@ -276,7 +276,7 @@ func GetAvailableNotifiers() []*alerting.NotifierPlugin {
|
||||
Heading: "VictorOps settings",
|
||||
Options: []alerting.NotifierOption{
|
||||
{
|
||||
Label: "URL",
|
||||
Label: "Url",
|
||||
Element: alerting.ElementTypeInput,
|
||||
InputType: alerting.InputTypeText,
|
||||
Placeholder: "VictorOps url",
|
||||
@@ -631,14 +631,14 @@ func GetAvailableNotifiers() []*alerting.NotifierPlugin {
|
||||
Heading: "Webhook settings",
|
||||
Options: []alerting.NotifierOption{
|
||||
{
|
||||
Label: "URL",
|
||||
Label: "Url",
|
||||
Element: alerting.ElementTypeInput,
|
||||
InputType: alerting.InputTypeText,
|
||||
PropertyName: "url",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Label: "HTTP Method",
|
||||
Label: "Http Method",
|
||||
Element: alerting.ElementTypeSelect,
|
||||
SelectOptions: []alerting.SelectOption{
|
||||
{
|
||||
@@ -653,34 +653,18 @@ func GetAvailableNotifiers() []*alerting.NotifierPlugin {
|
||||
PropertyName: "httpMethod",
|
||||
},
|
||||
{
|
||||
Label: "HTTP Basic Authentication - Username",
|
||||
Label: "Username",
|
||||
Element: alerting.ElementTypeInput,
|
||||
InputType: alerting.InputTypeText,
|
||||
PropertyName: "username",
|
||||
},
|
||||
{
|
||||
Label: "HTTP Basic Authentication - Password",
|
||||
Label: "Password",
|
||||
Element: alerting.ElementTypeInput,
|
||||
InputType: alerting.InputTypePassword,
|
||||
PropertyName: "password",
|
||||
Secure: true,
|
||||
},
|
||||
{ // New in 9.1
|
||||
Label: "Authorization Header - Scheme",
|
||||
Description: "Optionally provide a scheme for the Authorization Request Header. Default is Bearer.",
|
||||
Element: alerting.ElementTypeInput,
|
||||
InputType: alerting.InputTypeText,
|
||||
PropertyName: "authorization_scheme",
|
||||
Placeholder: "Bearer",
|
||||
},
|
||||
{ // New in 9.1
|
||||
Label: "Authorization Header - Credentials",
|
||||
Description: "Credentials for the Authorization Request header. Only one of HTTP Basic Authentication or Authorization Request Header can be set.",
|
||||
Element: alerting.ElementTypeInput,
|
||||
InputType: alerting.InputTypeText,
|
||||
PropertyName: "authorization_credentials",
|
||||
Secure: true,
|
||||
},
|
||||
{ // New in 8.0. TODO: How to enforce only numbers?
|
||||
Label: "Max Alerts",
|
||||
Description: "Max alerts to include in a notification. Remaining alerts in the same batch will be ignored above this number. 0 means no limit.",
|
||||
@@ -697,7 +681,7 @@ func GetAvailableNotifiers() []*alerting.NotifierPlugin {
|
||||
Heading: "WeCom settings",
|
||||
Options: []alerting.NotifierOption{
|
||||
{
|
||||
Label: "URL",
|
||||
Label: "Url",
|
||||
Element: alerting.ElementTypeInput,
|
||||
InputType: alerting.InputTypeText,
|
||||
Placeholder: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxxxxxx",
|
||||
@@ -786,7 +770,7 @@ func GetAvailableNotifiers() []*alerting.NotifierPlugin {
|
||||
Heading: "Google Hangouts Chat settings",
|
||||
Options: []alerting.NotifierOption{
|
||||
{
|
||||
Label: "URL",
|
||||
Label: "Url",
|
||||
Element: alerting.ElementTypeInput,
|
||||
InputType: alerting.InputTypeText,
|
||||
Placeholder: "Google Hangouts Chat incoming webhook url",
|
||||
@@ -872,7 +856,7 @@ func GetAvailableNotifiers() []*alerting.NotifierPlugin {
|
||||
Secure: true,
|
||||
},
|
||||
{
|
||||
Label: "Alert API URL",
|
||||
Label: "Alert API Url",
|
||||
Element: alerting.ElementTypeInput,
|
||||
InputType: alerting.InputTypeText,
|
||||
Placeholder: "https://api.opsgenie.com/v2/alerts",
|
||||
@@ -4,17 +4,15 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/prometheus/alertmanager/notify"
|
||||
"github.com/prometheus/alertmanager/template"
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
"github.com/prometheus/common/model"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/services/notifications"
|
||||
"github.com/prometheus/alertmanager/notify"
|
||||
"github.com/prometheus/alertmanager/template"
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
"github.com/prometheus/common/model"
|
||||
)
|
||||
|
||||
// WebhookNotifier is responsible for sending
|
||||
@@ -22,6 +20,8 @@ import (
|
||||
type WebhookNotifier struct {
|
||||
*Base
|
||||
URL string
|
||||
User string
|
||||
Password string
|
||||
HTTPMethod string
|
||||
MaxAlerts int
|
||||
log log.Logger
|
||||
@@ -29,26 +29,15 @@ type WebhookNotifier struct {
|
||||
images ImageStore
|
||||
tmpl *template.Template
|
||||
orgID int64
|
||||
|
||||
User string
|
||||
Password string
|
||||
|
||||
AuthorizationScheme string
|
||||
AuthorizationCredentials string
|
||||
}
|
||||
|
||||
type WebhookConfig struct {
|
||||
*NotificationChannelConfig
|
||||
URL string
|
||||
User string
|
||||
Password string
|
||||
HTTPMethod string
|
||||
MaxAlerts int
|
||||
|
||||
// Authorization Header.
|
||||
AuthorizationScheme string
|
||||
AuthorizationCredentials string
|
||||
// HTTP Basic Authentication.
|
||||
User string
|
||||
Password string
|
||||
}
|
||||
|
||||
func WebHookFactory(fc FactoryConfig) (NotificationChannel, error) {
|
||||
@@ -67,23 +56,11 @@ func NewWebHookConfig(config *NotificationChannelConfig, decryptFunc GetDecrypte
|
||||
if url == "" {
|
||||
return nil, errors.New("could not find url property in settings")
|
||||
}
|
||||
|
||||
user := config.Settings.Get("username").MustString()
|
||||
password := decryptFunc(context.Background(), config.SecureSettings, "password", config.Settings.Get("password").MustString())
|
||||
authorizationScheme := config.Settings.Get("authorization_scheme").MustString("Bearer")
|
||||
authorizationCredentials := decryptFunc(context.Background(), config.SecureSettings, "authorization_credentials", config.Settings.Get("authorization_credentials").MustString())
|
||||
|
||||
if user != "" && password != "" && authorizationScheme != "" && authorizationCredentials != "" {
|
||||
return nil, errors.New("both HTTP Basic Authentication and Authorization Header are set, only 1 is permitted")
|
||||
}
|
||||
|
||||
return &WebhookConfig{
|
||||
NotificationChannelConfig: config,
|
||||
URL: url,
|
||||
User: user,
|
||||
Password: password,
|
||||
AuthorizationScheme: authorizationScheme,
|
||||
AuthorizationCredentials: authorizationCredentials,
|
||||
User: config.Settings.Get("username").MustString(),
|
||||
Password: decryptFunc(context.Background(), config.SecureSettings, "password", config.Settings.Get("password").MustString()),
|
||||
HTTPMethod: config.Settings.Get("httpMethod").MustString("POST"),
|
||||
MaxAlerts: config.Settings.Get("maxAlerts").MustInt(0),
|
||||
}, nil
|
||||
@@ -100,18 +77,16 @@ func NewWebHookNotifier(config *WebhookConfig, ns notifications.WebhookSender, i
|
||||
DisableResolveMessage: config.DisableResolveMessage,
|
||||
Settings: config.Settings,
|
||||
}),
|
||||
orgID: config.OrgID,
|
||||
URL: config.URL,
|
||||
User: config.User,
|
||||
Password: config.Password,
|
||||
AuthorizationScheme: config.AuthorizationScheme,
|
||||
AuthorizationCredentials: config.AuthorizationCredentials,
|
||||
HTTPMethod: config.HTTPMethod,
|
||||
MaxAlerts: config.MaxAlerts,
|
||||
log: log.New("alerting.notifier.webhook"),
|
||||
ns: ns,
|
||||
images: images,
|
||||
tmpl: t,
|
||||
orgID: config.OrgID,
|
||||
URL: config.URL,
|
||||
User: config.User,
|
||||
Password: config.Password,
|
||||
HTTPMethod: config.HTTPMethod,
|
||||
MaxAlerts: config.MaxAlerts,
|
||||
log: log.New("alerting.notifier.webhook"),
|
||||
ns: ns,
|
||||
images: images,
|
||||
tmpl: t,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,18 +152,12 @@ func (wn *WebhookNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool
|
||||
return false, err
|
||||
}
|
||||
|
||||
headers := make(map[string]string)
|
||||
if wn.AuthorizationScheme != "" && wn.AuthorizationCredentials != "" {
|
||||
headers["Authorization"] = fmt.Sprintf("%s %s", wn.AuthorizationScheme, wn.AuthorizationCredentials)
|
||||
}
|
||||
|
||||
cmd := &models.SendWebhookSync{
|
||||
Url: wn.URL,
|
||||
User: wn.User,
|
||||
Password: wn.Password,
|
||||
Body: string(body),
|
||||
HttpMethod: wn.HTTPMethod,
|
||||
HttpHeader: headers,
|
||||
}
|
||||
|
||||
if err := wn.ns.SendWebhookSync(ctx, cmd); err != nil {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user