Merge branch 'v9.0.x' of github.com:grafana/grafana into v9.0.x
This commit is contained in:
+2
-23
@@ -3681,8 +3681,7 @@ 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.", "1"]
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
],
|
||||
"public/app/core/utils/deferred.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
@@ -7862,21 +7861,7 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "10"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "11"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "12"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "13"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "14"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "15"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "16"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "17"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "18"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "19"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "20"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "21"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "22"],
|
||||
[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.", "27"]
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "13"]
|
||||
],
|
||||
"public/app/plugins/datasource/loki/datasource.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
@@ -9041,12 +9026,6 @@ 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"],
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
# But not these files:
|
||||
!.gitignore
|
||||
!*.mod
|
||||
!*.sum
|
||||
!README.md
|
||||
!Variables.mk
|
||||
!variables.env
|
||||
|
||||
+4
-4
@@ -1,4 +1,4 @@
|
||||
# Auto generated binary variables helper managed by https://github.com/bwplotka/bingo v0.5.1. DO NOT EDIT.
|
||||
# Auto generated binary variables helper managed by https://github.com/bwplotka/bingo v0.6. 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.4.0
|
||||
DRONE := $(GOBIN)/drone-v1.5.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.4.0"
|
||||
@cd $(BINGO_DIR) && $(GO) build -mod=mod -modfile=drone.mod -o=$(GOBIN)/drone-v1.4.0 "github.com/drone/drone-cli/drone"
|
||||
@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"
|
||||
|
||||
WIRE := $(GOBIN)/wire-v0.5.0
|
||||
$(WIRE): $(BINGO_DIR)/wire.mod
|
||||
|
||||
+1
-1
@@ -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.4.0 // drone
|
||||
require github.com/drone/drone-cli v1.5.0 // drone
|
||||
|
||||
+1030
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.5.1. DO NOT EDIT.
|
||||
# Auto generated binary variables helper managed by https://github.com/bwplotka/bingo v0.6. 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.4.0"
|
||||
DRONE="${GOBIN}/drone-v1.5.0"
|
||||
|
||||
WIRE="${GOBIN}/wire-v0.5.0"
|
||||
|
||||
|
||||
+266
-266
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,31 @@
|
||||
<!-- 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)
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ COPY emails emails
|
||||
ENV NODE_ENV production
|
||||
RUN yarn build
|
||||
|
||||
FROM golang:1.17.11-alpine3.15 as go-builder
|
||||
FROM golang:1.17.12-alpine3.15 as go-builder
|
||||
|
||||
RUN apk add --no-cache gcc g++ make
|
||||
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ COPY emails emails
|
||||
ENV NODE_ENV production
|
||||
RUN yarn build
|
||||
|
||||
FROM golang:1.17.11 AS go-builder
|
||||
FROM golang:1.17.12 AS go-builder
|
||||
|
||||
WORKDIR /src/grafana
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ weight: 300
|
||||
|
||||
# Roles and permissions
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
You can assign a user one of three types of permissions:
|
||||
|
||||
|
||||
@@ -16,13 +16,7 @@ weight: 800
|
||||
|
||||
# Service accounts
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
> **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 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 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 >}}
|
||||
|
||||
|
||||
@@ -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 [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.
|
||||
- **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.
|
||||
|
||||
> **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.
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@ keywords:
|
||||
- guide
|
||||
- contact point
|
||||
- templating
|
||||
title: List of notifiers
|
||||
title: List of contact point types
|
||||
weight: 130
|
||||
---
|
||||
|
||||
# List of supported notifiers
|
||||
# List of supported contact point types
|
||||
|
||||
The following table lists the notifiers (contact point types) supported by Grafana.
|
||||
The following table lists the contact point types supported by Grafana.
|
||||
|
||||
| Name | Type | Grafana Alertmanager | Other Alertmanagers |
|
||||
| ------------------------------------------------ | ------------------------- | -------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
|
||||
@@ -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 | No | No |
|
||||
| Pushover | Yes | No |
|
||||
| Sensu Go | No | No |
|
||||
| Slack | No | Yes |
|
||||
| Telegram | No | No |
|
||||
|
||||
@@ -19,7 +19,9 @@ This functionality is similar to the panel inspector tasks [Inspect query perfor
|
||||
|
||||
## Query history
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
### View query history
|
||||
|
||||
@@ -43,10 +45,6 @@ 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
|
||||
|
||||
@@ -61,8 +59,6 @@ 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.
|
||||
@@ -74,12 +70,9 @@ 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 |
|
||||
| ------------------------------------------------------------- | --------------------------------------- |
|
||||
| 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. |
|
||||
| Setting | Default value |
|
||||
| ----------------------------- | ----------------- |
|
||||
| Change the default active tab | Query history tab |
|
||||
|
||||
> **Note:** Query history settings are global, and applied to both panels in split mode.
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ weight: 10000
|
||||
Here you can find detailed release notes that list everything that is included in every release as well as notices
|
||||
about deprecations, breaking changes as well as changes that relate to plugin development.
|
||||
|
||||
- [Release notes for 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" >}})
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
+++
|
||||
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
-1
@@ -4,5 +4,5 @@
|
||||
"packages": [
|
||||
"packages/*"
|
||||
],
|
||||
"version": "9.0.3"
|
||||
"version": "9.0.4"
|
||||
}
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
"license": "AGPL-3.0-only",
|
||||
"private": true,
|
||||
"name": "grafana",
|
||||
"version": "9.0.3",
|
||||
"version": "9.0.4",
|
||||
"repository": "github:grafana/grafana",
|
||||
"scripts": {
|
||||
"api-tests": "jest --notify --watch --config=devenv/e2e-api-tests/jest.js",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"author": "Grafana Labs",
|
||||
"license": "Apache-2.0",
|
||||
"name": "@grafana/data",
|
||||
"version": "9.0.3",
|
||||
"version": "9.0.4",
|
||||
"description": "Grafana Data Library",
|
||||
"keywords": [
|
||||
"typescript"
|
||||
@@ -22,7 +22,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@braintree/sanitize-url": "6.0.0",
|
||||
"@grafana/schema": "9.0.3",
|
||||
"@grafana/schema": "9.0.4",
|
||||
"@types/d3-interpolate": "^1.4.0",
|
||||
"d3-interpolate": "1.4.0",
|
||||
"date-fns": "2.28.0",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"author": "Grafana Labs",
|
||||
"license": "Apache-2.0",
|
||||
"name": "@grafana/e2e-selectors",
|
||||
"version": "9.0.3",
|
||||
"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.3",
|
||||
"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.3",
|
||||
"@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.3",
|
||||
"version": "9.0.4",
|
||||
"description": "Grafana Runtime Library",
|
||||
"keywords": [
|
||||
"grafana",
|
||||
@@ -22,9 +22,9 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@grafana/data": "9.0.3",
|
||||
"@grafana/e2e-selectors": "9.0.3",
|
||||
"@grafana/ui": "9.0.3",
|
||||
"@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.3",
|
||||
"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.3",
|
||||
"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.3",
|
||||
"@grafana/data": "9.0.4",
|
||||
"@grafana/eslint-config": "^4.0.0",
|
||||
"@grafana/tsconfig": "^1.2.0-rc1",
|
||||
"@grafana/ui": "9.0.3",
|
||||
"@grafana/ui": "9.0.4",
|
||||
"@jest/core": "27.5.1",
|
||||
"@types/command-exists": "^1.2.0",
|
||||
"@types/eslint": "8.4.1",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getStylesheetEntries, hasThemeStylesheets } from './loaders';
|
||||
import { getStylesheetEntries } from './loaders';
|
||||
|
||||
describe('Loaders', () => {
|
||||
describe('stylesheet helpers', () => {
|
||||
@@ -22,28 +22,5 @@ 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { getPluginId } from '../utils/getPluginId';
|
||||
|
||||
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
||||
|
||||
const supportedExtensions = ['css', 'scss', 'less', 'sass'];
|
||||
@@ -33,39 +31,6 @@ 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,
|
||||
@@ -139,34 +104,21 @@ export const getStyleLoaders = () => {
|
||||
};
|
||||
|
||||
export const getFileLoaders = () => {
|
||||
const shouldExtractCss = hasThemeStylesheets();
|
||||
|
||||
return [
|
||||
{
|
||||
test: /\.(png|jpe?g|gif|svg)$/,
|
||||
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',
|
||||
},
|
||||
],
|
||||
type: 'asset/resource',
|
||||
generator: {
|
||||
publicPath: `img/`,
|
||||
outputPath: 'img/',
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.(woff|woff2|eot|ttf|otf)(\?v=\d+\.\d+\.\d+)?$/,
|
||||
loader: require.resolve('file-loader'),
|
||||
options: {
|
||||
// Keep publicPath relative for host.com/grafana/ deployments
|
||||
publicPath: `public/plugins/${getPluginId()}/fonts`,
|
||||
outputPath: 'fonts',
|
||||
name: '[name].[ext]',
|
||||
type: 'asset/resource',
|
||||
generator: {
|
||||
publicPath: `fonts/`,
|
||||
outputPath: 'fonts/',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"author": "Grafana Labs",
|
||||
"license": "Apache-2.0",
|
||||
"name": "@grafana/ui",
|
||||
"version": "9.0.3",
|
||||
"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.3",
|
||||
"@grafana/e2e-selectors": "9.0.3",
|
||||
"@grafana/schema": "9.0.3",
|
||||
"@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",
|
||||
|
||||
@@ -15,6 +15,7 @@ import { IconButton } from '../IconButton/IconButton';
|
||||
export interface Props {
|
||||
pageIcon?: IconName;
|
||||
title?: string;
|
||||
section?: string;
|
||||
parent?: string;
|
||||
onGoBack?: () => void;
|
||||
titleHref?: string;
|
||||
@@ -30,6 +31,7 @@ export interface Props {
|
||||
export const PageToolbar: FC<Props> = React.memo(
|
||||
({
|
||||
title,
|
||||
section,
|
||||
parent,
|
||||
pageIcon,
|
||||
onGoBack,
|
||||
@@ -59,63 +61,77 @@ export const PageToolbar: FC<Props> = React.memo(
|
||||
className
|
||||
);
|
||||
|
||||
const leftItemChildren = leftItems?.map((child, index) => (
|
||||
<div className={styles.leftActionItem} key={index}>
|
||||
{child}
|
||||
</div>
|
||||
));
|
||||
|
||||
const titleEl = (
|
||||
<>
|
||||
<span className={styles.noLinkTitle}>{title}</span>
|
||||
{section && <span className={styles.pre}> / {section}</span>}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<nav className={mainStyle} aria-label={ariaLabel}>
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
<div className={styles.leftWrapper}>
|
||||
{pageIcon && !onGoBack && (
|
||||
<div className={styles.pageIcon}>
|
||||
<Icon name={pageIcon} size="lg" aria-hidden />
|
||||
</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>
|
||||
{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.titleText}>{title}</h1>}
|
||||
</nav>
|
||||
{leftItems?.map((child, index) => (
|
||||
<div className={styles.leftActionItem} key={index}>
|
||||
{child}
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className={styles.spacer} />
|
||||
{title && (
|
||||
<div className={styles.titleWrapper}>
|
||||
<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>
|
||||
{leftItemChildren}
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
{React.Children.toArray(children)
|
||||
.filter(Boolean)
|
||||
.map((child, index) => {
|
||||
@@ -136,21 +152,11 @@ 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};
|
||||
@@ -159,7 +165,9 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
justify-content: flex-end;
|
||||
padding: ${theme.spacing(1.5, 2)};
|
||||
`,
|
||||
spacer: css`
|
||||
leftWrapper: css`
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
flex-grow: 1;
|
||||
`,
|
||||
pageIcon: css`
|
||||
@@ -170,24 +178,38 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
align-items: center;
|
||||
}
|
||||
`,
|
||||
noLinkTitle: css`
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`,
|
||||
titleWrapper: css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
flex-grow: 1;
|
||||
margin: 0;
|
||||
`,
|
||||
navElement: css`
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
align-items: center;
|
||||
max-width: calc(100vw - 78px);
|
||||
`,
|
||||
h1Styles: css`
|
||||
margin: 0;
|
||||
line-height: inherit;
|
||||
display: flex;
|
||||
width: 300px;
|
||||
max-width: min-content;
|
||||
flex-grow: 1;
|
||||
`,
|
||||
parentIcon: css`
|
||||
margin-left: ${theme.spacing(0.5)};
|
||||
`,
|
||||
titleText: titleStyles,
|
||||
titleText: css`
|
||||
display: flex;
|
||||
font-size: ${typography.size.lg};
|
||||
margin: 0;
|
||||
border-radius: 2px;
|
||||
`,
|
||||
titleLink: css`
|
||||
&:focus-visible {
|
||||
${focusStyle}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@jaegertracing/jaeger-ui-components",
|
||||
"version": "9.0.3",
|
||||
"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.3",
|
||||
"@grafana/e2e-selectors": "9.0.3",
|
||||
"@grafana/runtime": "9.0.3",
|
||||
"@grafana/ui": "9.0.3",
|
||||
"@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",
|
||||
|
||||
@@ -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 && \
|
||||
|
||||
@@ -443,6 +443,13 @@ var orgsCreateAccessEvaluator = ac.EvalAll(
|
||||
ac.EvalPermission(ActionOrgsCreate),
|
||||
)
|
||||
|
||||
// usersInviteEvaluator is used to protect the "Configuration > Users > Invite" page access
|
||||
// accessible to org admins and server admins by default
|
||||
var usersInviteEvaluator = ac.EvalAny(
|
||||
ac.EvalPermission(ac.ActionUsersCreate),
|
||||
ac.EvalPermission(ac.ActionOrgUsersAdd),
|
||||
)
|
||||
|
||||
// teamsAccessEvaluator is used to protect the "Configuration > Teams" page access
|
||||
// grants access to a user when they can either create teams or can read and update a team
|
||||
var teamsAccessEvaluator = ac.EvalAny(
|
||||
|
||||
+2
-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"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/notifier/channels_config"
|
||||
"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, notifier.GetAvailableNotifiers())
|
||||
return response.JSON(http.StatusOK, channels_config.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.
|
||||
|
||||
+2
-2
@@ -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.ActionUsersCreate)), hs.Index)
|
||||
r.Get("/org/users/invite", authorize(reqOrgAdmin, usersInviteEvaluator), 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)
|
||||
@@ -239,7 +239,7 @@ func (hs *HTTPServer) registerRoutes() {
|
||||
|
||||
// invites
|
||||
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.Post("/invites", authorize(reqOrgAdmin, usersInviteEvaluator), quota("user"), routing.Wrap(hs.AddOrgInvite))
|
||||
orgRoute.Patch("/invites/:code/revoke", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionUsersCreate)), routing.Wrap(hs.RevokeInvite))
|
||||
|
||||
// prefs
|
||||
|
||||
@@ -622,13 +622,9 @@ 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 {
|
||||
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
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+8
-1
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
@@ -23,7 +24,13 @@ 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 badQuery *query.ErrBadQuery
|
||||
|
||||
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
|
||||
if errors.As(err, &badQuery) {
|
||||
return response.Error(http.StatusBadRequest, util.Capitalize(badQuery.Message), err)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -37,6 +39,11 @@ 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
|
||||
}
|
||||
@@ -101,3 +108,44 @@ 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:")
|
||||
})
|
||||
}
|
||||
|
||||
+20
-1
@@ -5,12 +5,14 @@ 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"
|
||||
@@ -46,9 +48,27 @@ 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)
|
||||
}
|
||||
|
||||
// Evaluate permissions for inviting a new user to Grafana
|
||||
hasAccess, err := hs.AccessControl.Evaluate(c.Req.Context(), c.SignedInUser, ac.EvalPermission(ac.ActionUsersCreate))
|
||||
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 create a new user", err)
|
||||
}
|
||||
|
||||
if setting.DisableLoginForm {
|
||||
return response.Error(400, "Cannot invite when login is disabled.", nil)
|
||||
}
|
||||
@@ -59,7 +79,6 @@ func (hs *HTTPServer) AddOrgInvite(c *models.ReqContext) response.Response {
|
||||
cmd.Name = inviteDto.Name
|
||||
cmd.Status = models.TmpUserInvitePending
|
||||
cmd.InvitedByUserId = c.UserId
|
||||
var err error
|
||||
cmd.Code, err = util.GetRandomString(30)
|
||||
if err != nil {
|
||||
return response.Error(500, "Could not generate random string", err)
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
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 and 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 and 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 and 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.StatusForbidden,
|
||||
desc: "org viewer with user add permission cannot 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.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.ActionUsersCreate}},
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -126,12 +126,8 @@ 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 {
|
||||
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
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +81,15 @@ 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,22 +3,27 @@ package adapters
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
)
|
||||
|
||||
// ModelToInstanceSettings converts a models.DataSource to a backend.DataSourceInstanceSettings.
|
||||
func ModelToInstanceSettings(ds *models.DataSource, decryptFn func(ds *models.DataSource) map[string]string,
|
||||
// ModelToInstanceSettings converts a datasources.DataSource to a backend.DataSourceInstanceSettings.
|
||||
func ModelToInstanceSettings(ds *models.DataSource, decryptFn func(ds *models.DataSource) (map[string]string, error),
|
||||
) (*backend.DataSourceInstanceSettings, error) {
|
||||
var jsonDataBytes json.RawMessage
|
||||
if ds.JsonData != nil {
|
||||
var err error
|
||||
jsonDataBytes, err = ds.JsonData.MarshalJSON()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("failed to convert data source to instance settings: %w", err)
|
||||
}
|
||||
}
|
||||
decrypted, err := decryptFn(ds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &backend.DataSourceInstanceSettings{
|
||||
ID: ds.Id,
|
||||
@@ -30,9 +35,9 @@ func ModelToInstanceSettings(ds *models.DataSource, decryptFn func(ds *models.Da
|
||||
BasicAuthEnabled: ds.BasicAuth,
|
||||
BasicAuthUser: ds.BasicAuthUser,
|
||||
JSONData: jsonDataBytes,
|
||||
DecryptedSecureJSONData: decryptFn(ds),
|
||||
DecryptedSecureJSONData: decrypted,
|
||||
Updated: ds.Updated,
|
||||
}, nil
|
||||
}, err
|
||||
}
|
||||
|
||||
// BackendUserFromSignedInUser converts Grafana's SignedInUser model
|
||||
|
||||
@@ -15,6 +15,7 @@ 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"
|
||||
@@ -24,7 +25,6 @@ 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:
|
||||
@@ -85,18 +85,18 @@ func readPluginManifest(body []byte) (*pluginManifest, error) {
|
||||
var manifest pluginManifest
|
||||
err := json.Unmarshal(block.Plaintext, &manifest)
|
||||
if err != nil {
|
||||
return nil, errutil.Wrap("Error parsing manifest JSON", err)
|
||||
return nil, fmt.Errorf("%v: %w", "Error parsing manifest JSON", err)
|
||||
}
|
||||
|
||||
keyring, err := openpgp.ReadArmoredKeyRing(bytes.NewBufferString(publicKeyText))
|
||||
if err != nil {
|
||||
return nil, errutil.Wrap("failed to parse public key", err)
|
||||
return nil, 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 nil, errutil.Wrap("failed to check signature", err)
|
||||
return nil, fmt.Errorf("%v: %w", "failed to check signature", err)
|
||||
}
|
||||
|
||||
return &manifest, nil
|
||||
@@ -145,32 +145,14 @@ func Calculate(mlog log.Logger, plugin *plugins.Plugin) (plugins.Signature, erro
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Validate that private is running within defined root URLs
|
||||
if manifest.SignatureType == plugins.PrivateSignature {
|
||||
appURL, err := url.Parse(setting.AppUrl)
|
||||
if err != 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)
|
||||
return plugins.Signature{}, err
|
||||
}
|
||||
|
||||
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 {
|
||||
} else if !match {
|
||||
mlog.Warn("Could not find root URL that matches running application URL", "plugin", plugin.ID,
|
||||
"appUrl", appURL, "rootUrls", manifest.RootURLs)
|
||||
"appUrl", setting.AppUrl, "rootUrls", manifest.RootURLs)
|
||||
return plugins.Signature{
|
||||
Status: plugins.SignatureInvalid,
|
||||
}, nil
|
||||
@@ -300,3 +282,35 @@ 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
|
||||
}
|
||||
|
||||
@@ -121,3 +121,275 @@ 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,8 +176,9 @@ const (
|
||||
type SignatureType string
|
||||
|
||||
const (
|
||||
GrafanaSignature SignatureType = "grafana"
|
||||
PrivateSignature SignatureType = "private"
|
||||
GrafanaSignature SignatureType = "grafana"
|
||||
PrivateSignature SignatureType = "private"
|
||||
PrivateGlobSignature SignatureType = "private-glob"
|
||||
)
|
||||
|
||||
type PluginFiles map[string]struct{}
|
||||
|
||||
@@ -127,12 +127,8 @@ 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 {
|
||||
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
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ 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"
|
||||
@@ -38,6 +39,7 @@ 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,
|
||||
@@ -67,6 +69,7 @@ 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 {
|
||||
if !provisioned && dto.User.IsRealUser() && !dto.User.IsAnonymous {
|
||||
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 {
|
||||
} else if dr.cfg.EditorsCanAdmin && !provisioned && dto.User.IsRealUser() && !dto.User.IsAnonymous {
|
||||
if err := dr.MakeUserAdmin(ctx, dto.OrgId, dto.User.UserId, dash.Id, !inFolder); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -169,12 +169,20 @@ func (f *FolderServiceImpl) CreateFolder(ctx context.Context, user *models.Signe
|
||||
|
||||
var permissionErr error
|
||||
if !accesscontrol.IsDisabled(f.cfg) {
|
||||
_, permissionErr = f.permissions.SetPermissions(ctx, orgID, folder.Uid, []accesscontrol.SetResourcePermissionCommand{
|
||||
{UserID: userID, Permission: models.PERMISSION_ADMIN.String()},
|
||||
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{
|
||||
{BuiltinRole: string(models.ROLE_EDITOR), Permission: models.PERMISSION_EDIT.String()},
|
||||
{BuiltinRole: string(models.ROLE_VIEWER), Permission: models.PERMISSION_VIEW.String()},
|
||||
}...)
|
||||
} else if f.cfg.EditorsCanAdmin {
|
||||
|
||||
_, permissionErr = f.permissions.SetPermissions(ctx, orgID, folder.Uid, permissions...)
|
||||
} else if f.cfg.EditorsCanAdmin && user.IsRealUser() && !user.IsAnonymous {
|
||||
permissionErr = f.MakeUserAdmin(ctx, orgID, userID, folder.Id, true)
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,9 @@ import (
|
||||
)
|
||||
|
||||
type FakeDataSourceService struct {
|
||||
lastId int64
|
||||
DataSources []*models.DataSource
|
||||
lastId int64
|
||||
DataSources []*models.DataSource
|
||||
SimulatePluginFailure bool
|
||||
}
|
||||
|
||||
var _ datasources.DataSourceService = &FakeDataSourceService{}
|
||||
@@ -107,6 +108,9 @@ 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
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ func ProvideAuthInfoStore(sqlStore sqlstore.Store, secretsService secrets.Servic
|
||||
secretsService: secretsService,
|
||||
logger: log.New("login.authinfo.store"),
|
||||
}
|
||||
InitMetrics()
|
||||
return store
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
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
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
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,3 +195,8 @@ 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,6 +370,24 @@ 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)
|
||||
@@ -403,11 +421,14 @@ func TestUserAuth(t *testing.T) {
|
||||
}
|
||||
_, err = sqlStore.CreateUser(context.Background(), dupUserLogincmd)
|
||||
require.NoError(t, err)
|
||||
// require metrics and statistics to be 2
|
||||
|
||||
// require stats to populate
|
||||
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,6 +4,7 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/login/authinfoservice/database"
|
||||
)
|
||||
|
||||
type UserProtectionService interface {
|
||||
@@ -21,4 +22,6 @@ 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,6 +5,7 @@ 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
|
||||
@@ -118,43 +119,17 @@ func (e *EmbeddedContactPoint) Valid(decryptFunc channels.GetDecryptedValueFn) e
|
||||
}
|
||||
|
||||
func (e *EmbeddedContactPoint) SecretKeys() ([]string, error) {
|
||||
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
|
||||
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
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("no secrets configured for type '%s'", e.Type)
|
||||
}
|
||||
|
||||
@@ -4,15 +4,17 @@ 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
|
||||
@@ -20,8 +22,6 @@ import (
|
||||
type WebhookNotifier struct {
|
||||
*Base
|
||||
URL string
|
||||
User string
|
||||
Password string
|
||||
HTTPMethod string
|
||||
MaxAlerts int
|
||||
log log.Logger
|
||||
@@ -29,15 +29,26 @@ 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) {
|
||||
@@ -56,11 +67,23 @@ 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: config.Settings.Get("username").MustString(),
|
||||
Password: decryptFunc(context.Background(), config.SecureSettings, "password", config.Settings.Get("password").MustString()),
|
||||
User: user,
|
||||
Password: password,
|
||||
AuthorizationScheme: authorizationScheme,
|
||||
AuthorizationCredentials: authorizationCredentials,
|
||||
HTTPMethod: config.Settings.Get("httpMethod").MustString("POST"),
|
||||
MaxAlerts: config.Settings.Get("maxAlerts").MustInt(0),
|
||||
}, nil
|
||||
@@ -77,16 +100,18 @@ 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,
|
||||
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,
|
||||
AuthorizationScheme: config.AuthorizationScheme,
|
||||
AuthorizationCredentials: config.AuthorizationCredentials,
|
||||
HTTPMethod: config.HTTPMethod,
|
||||
MaxAlerts: config.MaxAlerts,
|
||||
log: log.New("alerting.notifier.webhook"),
|
||||
ns: ns,
|
||||
images: images,
|
||||
tmpl: t,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,12 +177,18 @@ 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 {
|
||||
|
||||
@@ -27,13 +27,15 @@ func TestWebhookNotifier(t *testing.T) {
|
||||
orgID := int64(1)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
settings string
|
||||
alerts []*types.Alert
|
||||
name string
|
||||
settings string
|
||||
alerts []*types.Alert
|
||||
|
||||
expMsg *webhookMessage
|
||||
expUrl string
|
||||
expUsername string
|
||||
expPassword string
|
||||
expHeaders map[string]string
|
||||
expHttpMethod string
|
||||
expInitError string
|
||||
expMsgError error
|
||||
@@ -91,7 +93,9 @@ func TestWebhookNotifier(t *testing.T) {
|
||||
OrgID: orgID,
|
||||
},
|
||||
expMsgError: nil,
|
||||
}, {
|
||||
expHeaders: map[string]string{},
|
||||
},
|
||||
{
|
||||
name: "Custom config with multiple alerts",
|
||||
settings: `{
|
||||
"url": "http://localhost/test1",
|
||||
@@ -169,7 +173,80 @@ func TestWebhookNotifier(t *testing.T) {
|
||||
OrgID: orgID,
|
||||
},
|
||||
expMsgError: nil,
|
||||
}, {
|
||||
expHeaders: map[string]string{},
|
||||
},
|
||||
{
|
||||
name: "with Authorization set",
|
||||
settings: `{
|
||||
"url": "http://localhost/test1",
|
||||
"authorization_credentials": "mysecret",
|
||||
"httpMethod": "POST",
|
||||
"maxAlerts": 2
|
||||
}`,
|
||||
alerts: []*types.Alert{
|
||||
{
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"},
|
||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"},
|
||||
},
|
||||
},
|
||||
},
|
||||
expMsg: &webhookMessage{
|
||||
ExtendedData: &ExtendedData{
|
||||
Receiver: "my_receiver",
|
||||
Status: "firing",
|
||||
Alerts: ExtendedAlerts{
|
||||
{
|
||||
Status: "firing",
|
||||
Labels: template.KV{
|
||||
"alertname": "alert1",
|
||||
"lbl1": "val1",
|
||||
},
|
||||
Annotations: template.KV{
|
||||
"ann1": "annv1",
|
||||
},
|
||||
Fingerprint: "fac0861a85de433a",
|
||||
DashboardURL: "http://localhost/d/abcd",
|
||||
PanelURL: "http://localhost/d/abcd?viewPanel=efgh",
|
||||
SilenceURL: "http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1",
|
||||
},
|
||||
},
|
||||
GroupLabels: template.KV{
|
||||
"alertname": "",
|
||||
},
|
||||
CommonLabels: template.KV{
|
||||
"alertname": "alert1",
|
||||
"lbl1": "val1",
|
||||
},
|
||||
CommonAnnotations: template.KV{
|
||||
"ann1": "annv1",
|
||||
},
|
||||
ExternalURL: "http://localhost",
|
||||
},
|
||||
Version: "1",
|
||||
GroupKey: "alertname",
|
||||
Title: "[FIRING:1] (val1)",
|
||||
State: "alerting",
|
||||
Message: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n",
|
||||
OrgID: orgID,
|
||||
},
|
||||
expUrl: "http://localhost/test1",
|
||||
expHttpMethod: "POST",
|
||||
expHeaders: map[string]string{"Authorization": "Bearer mysecret"},
|
||||
},
|
||||
{
|
||||
name: "with both HTTP basic auth and Authorization Header set",
|
||||
settings: `{
|
||||
"url": "http://localhost/test1",
|
||||
"username": "user1",
|
||||
"password": "mysecret",
|
||||
"authorization_credentials": "mysecret",
|
||||
"httpMethod": "POST",
|
||||
"maxAlerts": 2
|
||||
}`,
|
||||
expInitError: "both HTTP Basic Authentication and Authorization Header are set, only 1 is permitted",
|
||||
},
|
||||
{
|
||||
name: "Error in initing",
|
||||
settings: `{}`,
|
||||
expInitError: `could not find url property in settings`,
|
||||
@@ -223,6 +300,7 @@ func TestWebhookNotifier(t *testing.T) {
|
||||
require.Equal(t, c.expUsername, webhookSender.Webhook.User)
|
||||
require.Equal(t, c.expPassword, webhookSender.Webhook.Password)
|
||||
require.Equal(t, c.expHttpMethod, webhookSender.Webhook.HttpMethod)
|
||||
require.Equal(t, c.expHeaders, webhookSender.Webhook.HttpHeader)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+26
-10
@@ -1,4 +1,4 @@
|
||||
package notifier
|
||||
package channels_config
|
||||
|
||||
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,18 +653,34 @@ func GetAvailableNotifiers() []*alerting.NotifierPlugin {
|
||||
PropertyName: "httpMethod",
|
||||
},
|
||||
{
|
||||
Label: "Username",
|
||||
Label: "HTTP Basic Authentication - Username",
|
||||
Element: alerting.ElementTypeInput,
|
||||
InputType: alerting.InputTypeText,
|
||||
PropertyName: "username",
|
||||
},
|
||||
{
|
||||
Label: "Password",
|
||||
Label: "HTTP Basic Authentication - 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.",
|
||||
@@ -681,7 +697,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",
|
||||
@@ -770,7 +786,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",
|
||||
@@ -856,7 +872,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",
|
||||
@@ -19,6 +19,10 @@ var (
|
||||
// ErrVersionLockedObjectNotFound is returned when an object is not
|
||||
// found using the current hash.
|
||||
ErrVersionLockedObjectNotFound = fmt.Errorf("could not find object using provided id and hash")
|
||||
// ConfigRecordsLimit defines the limit of how many alertmanager configuration versions
|
||||
// should be stored in the database for each organization including the current one.
|
||||
// Has to be > 0
|
||||
ConfigRecordsLimit int64 = 100
|
||||
)
|
||||
|
||||
// GetLatestAlertmanagerConfiguration returns the lastest version of the alertmanager configuration.
|
||||
@@ -78,7 +82,9 @@ func (st DBstore) SaveAlertmanagerConfigurationWithCallback(ctx context.Context,
|
||||
if _, err := sess.Insert(config); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := st.deleteOldConfigurations(ctx, cmd.OrgID, ConfigRecordsLimit); err != nil {
|
||||
st.Logger.Warn("failed to delete old am configs", "org", cmd.OrgID, "err", err)
|
||||
}
|
||||
if err := callback(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -118,6 +124,9 @@ func (st *DBstore) UpdateAlertmanagerConfiguration(ctx context.Context, cmd *mod
|
||||
if rows == 0 {
|
||||
return ErrVersionLockedObjectNotFound
|
||||
}
|
||||
if _, err := st.deleteOldConfigurations(ctx, cmd.OrgID, ConfigRecordsLimit); err != nil {
|
||||
st.Logger.Warn("failed to delete old am configs", "org", cmd.OrgID, "err", err)
|
||||
}
|
||||
return err
|
||||
})
|
||||
}
|
||||
@@ -196,3 +205,39 @@ func getInsertQuery(driver string) string {
|
||||
)`
|
||||
}
|
||||
}
|
||||
|
||||
func (st *DBstore) deleteOldConfigurations(ctx context.Context, orgID, limit int64) (int64, error) {
|
||||
if limit < 1 {
|
||||
return 0, fmt.Errorf("failed to delete old configurations: limit is set to '%d' but needs to be > 0", limit)
|
||||
}
|
||||
var affactedRows int64
|
||||
err := st.SQLStore.WithDbSession(ctx, func(sess *sqlstore.DBSession) error {
|
||||
res, err := sess.Exec(`
|
||||
DELETE FROM
|
||||
alert_configuration
|
||||
WHERE
|
||||
org_id = ?
|
||||
AND
|
||||
id NOT IN (
|
||||
SELECT T.* FROM (
|
||||
SELECT id
|
||||
FROM alert_configuration
|
||||
WHERE org_id = ? ORDER BY id DESC LIMIT ?
|
||||
)AS T
|
||||
)
|
||||
`, orgID, orgID, limit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affactedRows = rows
|
||||
if affactedRows > 0 {
|
||||
st.Logger.Info("deleted old alert_configuration(s)", "org", orgID, "limit", limit, "delete_count", affactedRows)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return affactedRows, err
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -18,20 +19,11 @@ func TestIntegrationAlertManagerHash(t *testing.T) {
|
||||
sqlStore := sqlstore.InitTestDB(t)
|
||||
store := &DBstore{
|
||||
SQLStore: sqlStore,
|
||||
Logger: log.NewNopLogger(),
|
||||
}
|
||||
setupConfig := func(t *testing.T, config string) (string, string) {
|
||||
config, configMD5 := config, fmt.Sprintf("%x", md5.Sum([]byte(config)))
|
||||
err := store.SaveAlertmanagerConfiguration(context.Background(), &models.SaveAlertmanagerConfigurationCmd{
|
||||
AlertmanagerConfiguration: config,
|
||||
ConfigurationVersion: "v1",
|
||||
Default: false,
|
||||
OrgID: 1,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return config, configMD5
|
||||
}
|
||||
|
||||
t.Run("After saving the DB should return the right hash", func(t *testing.T) {
|
||||
_, configMD5 := setupConfig(t, "my-config")
|
||||
_, configMD5 := setupConfig(t, "my-config", store)
|
||||
req := &models.GetLatestAlertmanagerConfigurationQuery{
|
||||
OrgID: 1,
|
||||
}
|
||||
@@ -41,7 +33,7 @@ func TestIntegrationAlertManagerHash(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("When passing the right hash the config should be updated", func(t *testing.T) {
|
||||
_, configMD5 := setupConfig(t, "my-config")
|
||||
_, configMD5 := setupConfig(t, "my-config", store)
|
||||
req := &models.GetLatestAlertmanagerConfigurationQuery{
|
||||
OrgID: 1,
|
||||
}
|
||||
@@ -64,7 +56,7 @@ func TestIntegrationAlertManagerHash(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("When passing the wrong hash the update should error", func(t *testing.T) {
|
||||
config, configMD5 := setupConfig(t, "my-config")
|
||||
config, configMD5 := setupConfig(t, "my-config", store)
|
||||
req := &models.GetLatestAlertmanagerConfigurationQuery{
|
||||
OrgID: 1,
|
||||
}
|
||||
@@ -82,3 +74,115 @@ func TestIntegrationAlertManagerHash(t *testing.T) {
|
||||
require.EqualError(t, ErrVersionLockedObjectNotFound, err.Error())
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegrationAlertManagerConfigCleanup(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
sqlStore := sqlstore.InitTestDB(t)
|
||||
store := &DBstore{
|
||||
SQLStore: sqlStore,
|
||||
Logger: log.NewNopLogger(),
|
||||
}
|
||||
t.Run("when calling the cleanup with less records than the limit all recrods should stay", func(t *testing.T) {
|
||||
var orgID int64 = 3
|
||||
oldestConfig, _ := setupConfig(t, "oldest-record", store)
|
||||
err := store.SaveAlertmanagerConfiguration(context.Background(), &models.SaveAlertmanagerConfigurationCmd{
|
||||
AlertmanagerConfiguration: oldestConfig,
|
||||
ConfigurationVersion: "v1",
|
||||
Default: false,
|
||||
OrgID: orgID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
olderConfig, _ := setupConfig(t, "older-record", store)
|
||||
err = store.SaveAlertmanagerConfiguration(context.Background(), &models.SaveAlertmanagerConfigurationCmd{
|
||||
AlertmanagerConfiguration: olderConfig,
|
||||
ConfigurationVersion: "v1",
|
||||
Default: false,
|
||||
OrgID: orgID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
config, _ := setupConfig(t, "newest-record", store)
|
||||
err = store.SaveAlertmanagerConfiguration(context.Background(), &models.SaveAlertmanagerConfigurationCmd{
|
||||
AlertmanagerConfiguration: config,
|
||||
ConfigurationVersion: "v1",
|
||||
Default: false,
|
||||
OrgID: orgID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
rowsAffacted, err := store.deleteOldConfigurations(context.Background(), orgID, 100)
|
||||
require.Equal(t, int64(0), rowsAffacted)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := &models.GetLatestAlertmanagerConfigurationQuery{
|
||||
OrgID: orgID,
|
||||
}
|
||||
err = store.GetLatestAlertmanagerConfiguration(context.Background(), req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "newest-record", req.Result.AlertmanagerConfiguration)
|
||||
})
|
||||
t.Run("when calling the cleanup only the oldest records surpassing the limit should be deleted", func(t *testing.T) {
|
||||
var orgID int64 = 2
|
||||
oldestConfig, _ := setupConfig(t, "oldest-record", store)
|
||||
err := store.SaveAlertmanagerConfiguration(context.Background(), &models.SaveAlertmanagerConfigurationCmd{
|
||||
AlertmanagerConfiguration: oldestConfig,
|
||||
ConfigurationVersion: "v1",
|
||||
Default: false,
|
||||
OrgID: orgID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
olderConfig, _ := setupConfig(t, "older-record", store)
|
||||
err = store.SaveAlertmanagerConfiguration(context.Background(), &models.SaveAlertmanagerConfigurationCmd{
|
||||
AlertmanagerConfiguration: olderConfig,
|
||||
ConfigurationVersion: "v1",
|
||||
Default: false,
|
||||
OrgID: orgID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
config, _ := setupConfig(t, "newest-record", store)
|
||||
err = store.SaveAlertmanagerConfiguration(context.Background(), &models.SaveAlertmanagerConfigurationCmd{
|
||||
AlertmanagerConfiguration: config,
|
||||
ConfigurationVersion: "v1",
|
||||
Default: false,
|
||||
OrgID: orgID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
rowsAffacted, err := store.deleteOldConfigurations(context.Background(), orgID, 1)
|
||||
require.Equal(t, int64(2), rowsAffacted)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := &models.GetLatestAlertmanagerConfigurationQuery{
|
||||
OrgID: orgID,
|
||||
}
|
||||
err = store.GetLatestAlertmanagerConfiguration(context.Background(), req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "newest-record", req.Result.AlertmanagerConfiguration)
|
||||
})
|
||||
t.Run("limit set to 0 should fail", func(t *testing.T) {
|
||||
_, err := store.deleteOldConfigurations(context.Background(), 1, 0)
|
||||
require.Error(t, err)
|
||||
})
|
||||
t.Run("limit set to negative should fail", func(t *testing.T) {
|
||||
_, err := store.deleteOldConfigurations(context.Background(), 1, -1)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func setupConfig(t *testing.T, config string, store *DBstore) (string, string) {
|
||||
t.Helper()
|
||||
config, configMD5 := config, fmt.Sprintf("%x", md5.Sum([]byte(config)))
|
||||
err := store.SaveAlertmanagerConfiguration(context.Background(), &models.SaveAlertmanagerConfigurationCmd{
|
||||
AlertmanagerConfiguration: config,
|
||||
ConfigurationVersion: "v1",
|
||||
Default: false,
|
||||
OrgID: 1,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return config, configMD5
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ func (s *Service) handleQueryData(ctx context.Context, user *models.SignedInUser
|
||||
|
||||
instanceSettings, err := adapters.ModelToInstanceSettings(ds, s.decryptSecureJsonDataFn(ctx))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert data source to instance settings: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req := &backend.QueryDataRequest{
|
||||
@@ -304,12 +304,8 @@ func (s *Service) getDataSourceFromQuery(ctx context.Context, user *models.Signe
|
||||
return nil, NewErrBadQuery("missing data source ID/UID")
|
||||
}
|
||||
|
||||
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 {
|
||||
s.log.Error("Failed to decrypt secure json data", "error", err)
|
||||
}
|
||||
return decryptedJsonData
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/notifier"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/notifier/channels_config"
|
||||
"github.com/grafana/grafana/pkg/tests/testinfra"
|
||||
)
|
||||
|
||||
@@ -47,7 +48,7 @@ func TestAvailableChannels(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
expNotifiers := notifier.GetAvailableNotifiers()
|
||||
expNotifiers := channels_config.GetAvailableNotifiers()
|
||||
expJson, err := json.Marshal(expNotifiers)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, string(expJson), string(b))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@grafana-plugins/input-datasource",
|
||||
"version": "9.0.3",
|
||||
"version": "9.0.4",
|
||||
"description": "Input Datasource",
|
||||
"private": true,
|
||||
"repository": {
|
||||
@@ -15,15 +15,15 @@
|
||||
},
|
||||
"author": "Grafana Labs",
|
||||
"devDependencies": {
|
||||
"@grafana/toolkit": "9.0.3",
|
||||
"@grafana/toolkit": "9.0.4",
|
||||
"@types/jest": "26.0.15",
|
||||
"@types/lodash": "4.14.149",
|
||||
"@types/react": "17.0.30",
|
||||
"lodash": "4.17.21"
|
||||
},
|
||||
"dependencies": {
|
||||
"@grafana/data": "9.0.3",
|
||||
"@grafana/ui": "9.0.3",
|
||||
"@grafana/data": "9.0.4",
|
||||
"@grafana/ui": "9.0.4",
|
||||
"jquery": "3.5.1",
|
||||
"react": "17.0.1",
|
||||
"react-dom": "17.0.1",
|
||||
|
||||
@@ -166,7 +166,7 @@ export class ContextSrv {
|
||||
return (this.isEditor || config.viewersCanEdit) && config.exploreEnabled;
|
||||
}
|
||||
|
||||
hasAccess(action: string, fallBack: boolean) {
|
||||
hasAccess(action: string, fallBack: boolean): boolean {
|
||||
if (!this.accessControlEnabled()) {
|
||||
return fallBack;
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ export class Node {
|
||||
}
|
||||
|
||||
export class Graph {
|
||||
nodes: any = {};
|
||||
nodes: Record<string, Node> = {};
|
||||
|
||||
constructor() {}
|
||||
|
||||
@@ -189,6 +189,34 @@ export class Graph {
|
||||
return edges;
|
||||
}
|
||||
|
||||
descendants(nodes: Node[] | string[]): Set<Node> {
|
||||
if (!nodes.length) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
const initialNodes = new Set(
|
||||
isStringArray(nodes) ? nodes.map((n) => this.nodes[n]).filter((n) => n !== undefined) : nodes
|
||||
);
|
||||
|
||||
return this.descendantsRecursive(initialNodes);
|
||||
}
|
||||
|
||||
private descendantsRecursive(nodes: Set<Node>, descendants = new Set<Node>()): Set<Node> {
|
||||
for (const node of nodes) {
|
||||
const newDescendants = new Set<Node>();
|
||||
for (const { inputNode } of node.inputEdges) {
|
||||
if (inputNode && !descendants.has(inputNode)) {
|
||||
descendants.add(inputNode);
|
||||
newDescendants.add(inputNode);
|
||||
}
|
||||
}
|
||||
|
||||
this.descendantsRecursive(newDescendants, descendants);
|
||||
}
|
||||
|
||||
return descendants;
|
||||
}
|
||||
|
||||
createEdge(): Edge {
|
||||
return new Edge();
|
||||
}
|
||||
@@ -212,3 +240,7 @@ export const printGraph = (g: Graph) => {
|
||||
console.log(`${n.name}:\n - links to: ${outputEdges}\n - links from: ${inputEdges}`);
|
||||
});
|
||||
};
|
||||
|
||||
function isStringArray(arr: unknown[]): arr is string[] {
|
||||
return arr.length > 0 && typeof arr[0] === 'string';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export function mapSet<T, R>(set: Set<T>, callback: (t: T) => R): Set<R> {
|
||||
const newSet = new Set<R>();
|
||||
for (const el of set) {
|
||||
newSet.add(callback(el));
|
||||
}
|
||||
|
||||
return newSet;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { isArray, reduce } from 'lodash';
|
||||
|
||||
import { IconName } from '@grafana/ui';
|
||||
import { QueryPartDef, QueryPart } from 'app/features/alerting/state/query_part';
|
||||
|
||||
const alertQueryDef = new QueryPartDef({
|
||||
@@ -87,7 +88,13 @@ function normalizeAlertState(state: string) {
|
||||
return state.toLowerCase().replace(/_/g, '').split(' ')[0];
|
||||
}
|
||||
|
||||
function getStateDisplayModel(state: string) {
|
||||
interface AlertStateDisplayModel {
|
||||
text: string;
|
||||
iconClass: IconName;
|
||||
stateClass: string;
|
||||
}
|
||||
|
||||
function getStateDisplayModel(state: string): AlertStateDisplayModel {
|
||||
const normalizedState = normalizeAlertState(state);
|
||||
|
||||
switch (normalizedState) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import React, { FC, useEffect, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { Badge, ConfirmModal, HorizontalGroup, Icon, Spinner, Tooltip, useStyles2 } from '@grafana/ui';
|
||||
import kbn from 'app/core/utils/kbn';
|
||||
import { CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting';
|
||||
@@ -70,7 +71,7 @@ export const RulesGroup: FC<Props> = React.memo(({ group, namespace, expandAll }
|
||||
);
|
||||
} else if (rulesSource === GRAFANA_RULES_SOURCE_NAME) {
|
||||
if (folderUID) {
|
||||
const baseUrl = `/dashboards/f/${folderUID}/${kbn.slugifyForUrl(namespace.name)}`;
|
||||
const baseUrl = `${config.appSubUrl}/dashboards/f/${folderUID}/${kbn.slugifyForUrl(namespace.name)}`;
|
||||
if (folder?.canSave) {
|
||||
actionIcons.push(
|
||||
<ActionIcon
|
||||
|
||||
+5
-1
@@ -42,7 +42,11 @@ describe('DashboardSettings', () => {
|
||||
</Provider>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Foo / Settings')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
(_, el) => el?.tagName.toLowerCase() === 'h1' && /Foo\s*\/\s*Settings/.test(el?.textContent ?? '')
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
|
||||
await userEvent.keyboard('{Escape}');
|
||||
|
||||
|
||||
@@ -163,7 +163,13 @@ export function DashboardSettings({ dashboard, editview }: Props) {
|
||||
return (
|
||||
<FocusScope contain autoFocus>
|
||||
<div className="dashboard-settings" ref={ref} {...overlayProps} {...dialogProps}>
|
||||
<PageToolbar title={`${dashboard.title} / Settings`} parent={folderTitle} onGoBack={onClose} />
|
||||
<PageToolbar
|
||||
className={styles.toolbar}
|
||||
title={dashboard.title}
|
||||
section="Settings"
|
||||
parent={folderTitle}
|
||||
onGoBack={onClose}
|
||||
/>
|
||||
<CustomScrollbar>
|
||||
<div className={styles.scrollInner}>
|
||||
<div className={styles.settingsWrapper}>
|
||||
@@ -200,6 +206,10 @@ const getStyles = stylesFactory((theme: GrafanaTheme2) => ({
|
||||
min-width: 100%;
|
||||
display: flex;
|
||||
`,
|
||||
toolbar: css`
|
||||
width: 60vw;
|
||||
min-width: min-content;
|
||||
`,
|
||||
settingsWrapper: css`
|
||||
margin: ${theme.spacing(0, 2, 2)};
|
||||
display: flex;
|
||||
|
||||
@@ -441,7 +441,7 @@ export class PanelEditorUnconnected extends PureComponent<Props> {
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper} aria-label={selectors.components.PanelEditor.General.content}>
|
||||
<PageToolbar title={`${dashboard.title} / Edit Panel`} onGoBack={this.onGoBackToDashboard}>
|
||||
<PageToolbar title={dashboard.title} section="Edit Panel" onGoBack={this.onGoBackToDashboard}>
|
||||
{this.renderEditorActions()}
|
||||
</PageToolbar>
|
||||
<div className={styles.verticalSplitPanesWrapper}>
|
||||
|
||||
@@ -205,7 +205,7 @@ export class DashboardModel implements TimeModel {
|
||||
meta.canEdit = meta.canEdit !== false;
|
||||
meta.canDelete = meta.canDelete !== false;
|
||||
|
||||
meta.showSettings = meta.canSave;
|
||||
meta.showSettings = meta.canEdit;
|
||||
meta.canMakeEditable = meta.canSave && !this.editable;
|
||||
meta.hasUnsavedFolderChange = false;
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from '@grafana/data';
|
||||
import { getTemplateSrv, RefreshEvent } from '@grafana/runtime';
|
||||
import config from 'app/core/config';
|
||||
import { safeStringifyValue } from 'app/core/utils/explore';
|
||||
import { getNextRefIdChar } from 'app/core/utils/query';
|
||||
import { QueryGroupOptions } from 'app/types';
|
||||
import {
|
||||
@@ -610,3 +611,18 @@ interface PanelOptionsCache {
|
||||
properties: any;
|
||||
fieldConfig: FieldConfigSource;
|
||||
}
|
||||
|
||||
// For cases where we immediately want to stringify the panel model without cloning each property
|
||||
export function stringifyPanelModel(panel: PanelModel) {
|
||||
const model: any = {};
|
||||
|
||||
Object.entries(panel)
|
||||
.filter(
|
||||
([prop, val]) => !notPersistedProperties[prop] && panel.hasOwnProperty(prop) && !isEqual(val, defaults[prop])
|
||||
)
|
||||
.forEach(([k, v]) => {
|
||||
model[k] = v;
|
||||
});
|
||||
|
||||
return safeStringifyValue(model);
|
||||
}
|
||||
|
||||
@@ -80,9 +80,7 @@ class AppRootPage extends Component<Props, State> {
|
||||
const { params } = this.props.match;
|
||||
|
||||
if (prevProps.match.params.pluginId !== params.pluginId) {
|
||||
this.setState({
|
||||
loading: true,
|
||||
});
|
||||
this.setState({ loading: true, plugin: null });
|
||||
this.loadPluginSettings();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { connect } from 'react-redux';
|
||||
|
||||
import { RadioButtonGroup, LinkButton, FilterInput } from '@grafana/ui';
|
||||
import { contextSrv } from 'app/core/core';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
import { AccessControlAction, StoreState } from 'app/types';
|
||||
|
||||
import { selectTotal } from '../invites/state/selectors';
|
||||
|
||||
@@ -37,7 +37,9 @@ export class UsersActionBar extends PureComponent<Props> {
|
||||
{ label: 'Users', value: 'users' },
|
||||
{ label: `Pending Invites (${pendingInvitesCount})`, value: 'invites' },
|
||||
];
|
||||
const canAddToOrg = contextSrv.hasAccess(AccessControlAction.UsersCreate, canInvite);
|
||||
const canAddToOrg: boolean =
|
||||
contextSrv.hasAccess(AccessControlAction.UsersCreate, canInvite) ||
|
||||
contextSrv.hasAccess(AccessControlAction.OrgUsersAdd, canInvite);
|
||||
|
||||
return (
|
||||
<div className="page-action-bar" data-testid="users-action-bar">
|
||||
@@ -64,7 +66,7 @@ export class UsersActionBar extends PureComponent<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state: any) {
|
||||
function mapStateToProps(state: StoreState) {
|
||||
return {
|
||||
searchQuery: getUsersSearchQuery(state.users),
|
||||
pendingInvitesCount: selectTotal(state.invites),
|
||||
|
||||
@@ -4,14 +4,9 @@ import { variableAdapters } from '../adapters';
|
||||
import { createCustomVariableAdapter } from '../custom/adapter';
|
||||
import { createDataSourceVariableAdapter } from '../datasource/adapter';
|
||||
import { createQueryVariableAdapter } from '../query/adapter';
|
||||
import { createGraph } from '../state/actions';
|
||||
|
||||
import {
|
||||
flattenPanels,
|
||||
getAffectedPanelIdsForVariable,
|
||||
getAllAffectedPanelIdsForVariableChange,
|
||||
getDependenciesForVariable,
|
||||
getPropsWithVariable,
|
||||
} from './utils';
|
||||
import { flattenPanels, getAllAffectedPanelIdsForVariableChange, getPanelVars, getPropsWithVariable } from './utils';
|
||||
|
||||
describe('getPropsWithVariable', () => {
|
||||
it('when called it should return the correct graph', () => {
|
||||
@@ -217,43 +212,12 @@ describe('getPropsWithVariable', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAffectedPanelIdsForVariable', () => {
|
||||
describe('when called with a real world example with rows and repeats', () => {
|
||||
it('then it should return correct panel ids', () => {
|
||||
const panels = dashWithRepeatsAndRows.panels.map((panel: PanelModel) => ({
|
||||
id: panel.id,
|
||||
getSaveModel: () => panel,
|
||||
}));
|
||||
const result = getAffectedPanelIdsForVariable('query0', panels);
|
||||
expect(result).toEqual([15, 16, 17, 11, 12, 13, 2, 5, 7, 6]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
variableAdapters.setInit(() => [
|
||||
createDataSourceVariableAdapter(),
|
||||
createCustomVariableAdapter(),
|
||||
createQueryVariableAdapter(),
|
||||
]);
|
||||
|
||||
describe('getDependenciesForVariable', () => {
|
||||
describe('when called with a real world example with dependencies', () => {
|
||||
it('then it should return correct dependencies', () => {
|
||||
const {
|
||||
templating: { list: variables },
|
||||
} = dashWithTemplateDependenciesAndPanels;
|
||||
const result = getDependenciesForVariable('ds_instance', variables, new Set());
|
||||
expect([...result]).toEqual([
|
||||
'ds',
|
||||
'query_with_ds',
|
||||
'depends_on_query_with_ds',
|
||||
'depends_on_query_with_ds_regex',
|
||||
'depends_on_all',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllAffectedPanelIdsForVariableChange ', () => {
|
||||
describe('when called with a real world example with dependencies and panels', () => {
|
||||
it('then it should return correct panelIds', () => {
|
||||
@@ -261,12 +225,11 @@ describe('getAllAffectedPanelIdsForVariableChange ', () => {
|
||||
panels: panelsAsJson,
|
||||
templating: { list: variables },
|
||||
} = dashWithTemplateDependenciesAndPanels;
|
||||
const panels = panelsAsJson.map((panel: PanelModel) => ({
|
||||
id: panel.id,
|
||||
getSaveModel: () => panel,
|
||||
}));
|
||||
const result = getAllAffectedPanelIdsForVariableChange('ds_instance', variables, panels);
|
||||
expect(result).toEqual([2, 3, 4, 5]);
|
||||
const panelVarPairs = getPanelVars(panelsAsJson);
|
||||
const varGraph = createGraph(variables);
|
||||
|
||||
const result = [...getAllAffectedPanelIdsForVariableChange(['ds_instance'], varGraph, panelVarPairs)];
|
||||
expect(result).toEqual([5, 2, 4, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -276,11 +239,9 @@ describe('getAllAffectedPanelIdsForVariableChange ', () => {
|
||||
panels: panelsAsJson,
|
||||
templating: { list: variables },
|
||||
} = dashWithTemplateDependenciesAndPanels;
|
||||
const panels = panelsAsJson.map((panel: PanelModel) => ({
|
||||
id: panel.id,
|
||||
getSaveModel: () => panel,
|
||||
}));
|
||||
const result = getAllAffectedPanelIdsForVariableChange('depends_on_all', variables, panels);
|
||||
const panelVarPairs = getPanelVars(panelsAsJson);
|
||||
const varGraph = createGraph(variables);
|
||||
const result = [...getAllAffectedPanelIdsForVariableChange(['depends_on_all'], varGraph, panelVarPairs)];
|
||||
expect(result).toEqual([2]);
|
||||
});
|
||||
});
|
||||
@@ -291,11 +252,9 @@ describe('getAllAffectedPanelIdsForVariableChange ', () => {
|
||||
panels: panelsAsJson,
|
||||
templating: { list: variables },
|
||||
} = dashWithAllVariables;
|
||||
const panels = panelsAsJson.map((panel: PanelModel) => ({
|
||||
id: panel.id,
|
||||
getSaveModel: () => panel,
|
||||
}));
|
||||
const result = getAllAffectedPanelIdsForVariableChange('unknown', variables, panels);
|
||||
const panelVarPairs = getPanelVars(panelsAsJson);
|
||||
const varGraph = createGraph(variables);
|
||||
const result = [...getAllAffectedPanelIdsForVariableChange(['unknown'], varGraph, panelVarPairs)];
|
||||
expect(result).toEqual([2, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { DataLinkBuiltInVars } from '@grafana/data';
|
||||
import { Graph } from 'app/core/utils/dag';
|
||||
import { mapSet } from 'app/core/utils/set';
|
||||
import { stringifyPanelModel } from 'app/features/dashboard/state/PanelModel';
|
||||
|
||||
import { safeStringifyValue } from '../../../core/utils/explore';
|
||||
import { DashboardModel, PanelModel } from '../../dashboard/state';
|
||||
@@ -51,10 +54,10 @@ export const createDependencyEdges = (variables: VariableModel[]): GraphEdge[] =
|
||||
return edges;
|
||||
};
|
||||
|
||||
function getVariableName(expression: string) {
|
||||
export function getVariableName(expression: string) {
|
||||
const match = variableRegexExec(expression);
|
||||
if (!match) {
|
||||
return null;
|
||||
return undefined;
|
||||
}
|
||||
const variableName = match.slice(1).find((match) => match !== undefined);
|
||||
return variableName;
|
||||
@@ -253,86 +256,32 @@ function createUnknownsNetwork(variables: VariableModel[], dashboard: DashboardM
|
||||
|
||||
This doesn't take circular dependencies in consideration.
|
||||
*/
|
||||
|
||||
export function getAllAffectedPanelIdsForVariableChange(
|
||||
variableId: string,
|
||||
variables: VariableModel[],
|
||||
panels: PanelModel[]
|
||||
): number[] {
|
||||
const flattenedPanels = flattenPanels(panels);
|
||||
let affectedPanelIds: number[] = getAffectedPanelIdsForVariable(variableId, flattenedPanels);
|
||||
const affectedPanelIdsForAllVariables = getAffectedPanelIdsForVariable(
|
||||
DataLinkBuiltInVars.includeVars,
|
||||
flattenedPanels
|
||||
);
|
||||
affectedPanelIds = [...new Set([...affectedPanelIdsForAllVariables, ...affectedPanelIds])];
|
||||
|
||||
const dependencies = getDependenciesForVariable(variableId, variables, new Set());
|
||||
for (const dependency of dependencies) {
|
||||
const affectedPanelIdsForDependency = getAffectedPanelIdsForVariable(dependency, flattenedPanels);
|
||||
affectedPanelIds = [...new Set([...affectedPanelIdsForDependency, ...affectedPanelIds])];
|
||||
variableIds: string[],
|
||||
variableGraph: Graph,
|
||||
panelsByVar: Record<string, Set<number>>
|
||||
): Set<number> {
|
||||
const allDependencies = mapSet(variableGraph.descendants(variableIds), (n) => n.name);
|
||||
allDependencies.add(DataLinkBuiltInVars.includeVars);
|
||||
for (const id of variableIds) {
|
||||
allDependencies.add(id);
|
||||
}
|
||||
|
||||
const affectedPanelIds = getDependentPanels([...allDependencies], panelsByVar);
|
||||
return affectedPanelIds;
|
||||
}
|
||||
|
||||
export function getDependenciesForVariable(
|
||||
variableId: string,
|
||||
variables: VariableModel[],
|
||||
deps: Set<string>
|
||||
): Set<string> {
|
||||
if (!variables.length) {
|
||||
return deps;
|
||||
}
|
||||
|
||||
for (const variable of variables) {
|
||||
if (variable.name === variableId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const depends = variableAdapters.get(variable.type).dependsOn(variable, { name: variableId });
|
||||
if (!depends) {
|
||||
continue;
|
||||
}
|
||||
|
||||
deps.add(variable.name);
|
||||
deps = getDependenciesForVariable(variable.name, variables, deps);
|
||||
}
|
||||
|
||||
return deps;
|
||||
}
|
||||
|
||||
export function getAffectedPanelIdsForVariable(variableId: string, panels: PanelModel[]): number[] {
|
||||
if (!panels.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const affectedPanelIds: number[] = [];
|
||||
const repeatRegex = new RegExp(`"repeat":"${variableId}"`);
|
||||
for (const panel of panels) {
|
||||
const panelAsJson = safeStringifyValue(panel.getSaveModel());
|
||||
|
||||
// check for repeats that don't use variableRegex
|
||||
const repeatMatches = panelAsJson.match(repeatRegex);
|
||||
if (repeatMatches?.length) {
|
||||
affectedPanelIds.push(panel.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
const matches = panelAsJson.match(variableRegex);
|
||||
if (!matches) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const match of matches) {
|
||||
const variableName = getVariableName(match);
|
||||
if (variableName === variableId) {
|
||||
affectedPanelIds.push(panel.id);
|
||||
break;
|
||||
}
|
||||
// Return an array of panel IDs depending on variables
|
||||
export function getDependentPanels(variables: string[], panelsByVarUsage: Record<string, Set<number>>) {
|
||||
const thePanels: number[] = [];
|
||||
for (const varId of variables) {
|
||||
if (panelsByVarUsage[varId]) {
|
||||
thePanels.push(...panelsByVarUsage[varId]);
|
||||
}
|
||||
}
|
||||
|
||||
return affectedPanelIds;
|
||||
return new Set(thePanels);
|
||||
}
|
||||
|
||||
export interface UsagesToNetwork {
|
||||
@@ -419,3 +368,24 @@ export function flattenPanels(panels: PanelModel[]): PanelModel[] {
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Accepts an array of panel models, and returns an array of panel IDs paired with
|
||||
// the names of any template variables found
|
||||
export function getPanelVars(panels: PanelModel[]) {
|
||||
const panelsByVar: Record<string, Set<number>> = {};
|
||||
for (const p of panels) {
|
||||
const jsonString = stringifyPanelModel(p);
|
||||
const repeats = [...jsonString.matchAll(/"repeat":"([^"]+)"/g)].map((m) => m[1]);
|
||||
const varRegexMatches = jsonString.match(variableRegex)?.map((m) => getVariableName(m)) ?? [];
|
||||
const varNames = [...repeats, ...varRegexMatches];
|
||||
for (const varName of varNames) {
|
||||
if (varName! in panelsByVar) {
|
||||
panelsByVar[varName!].add(p.id);
|
||||
} else {
|
||||
panelsByVar[varName!] = new Set([p.id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return panelsByVar;
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
isMulti,
|
||||
isQuery,
|
||||
} from '../guard';
|
||||
import { getAllAffectedPanelIdsForVariableChange } from '../inspect/utils';
|
||||
import { getAllAffectedPanelIdsForVariableChange, getPanelVars } from '../inspect/utils';
|
||||
import { cleanPickerState } from '../pickers/OptionsPicker/reducer';
|
||||
import { alignCurrentWithMulti } from '../shared/multiOptions';
|
||||
import {
|
||||
@@ -549,7 +549,7 @@ export const setOptionAsCurrent = (
|
||||
};
|
||||
};
|
||||
|
||||
const createGraph = (variables: VariableModel[]) => {
|
||||
export const createGraph = (variables: VariableModel[]) => {
|
||||
const g = new Graph();
|
||||
|
||||
variables.forEach((v) => {
|
||||
@@ -594,9 +594,14 @@ export const variableUpdated = (
|
||||
const variables = getVariablesByKey(rootStateKey, state);
|
||||
const g = createGraph(variables);
|
||||
const panels = state.dashboard?.getModel()?.panels ?? [];
|
||||
const panelVars = getPanelVars(panels);
|
||||
|
||||
const event: VariablesChangedEvent = isAdHoc(variableInState)
|
||||
? { refreshAll: true, panelIds: [] } // for adhoc variables we don't know which panels that will be impacted
|
||||
: { refreshAll: false, panelIds: getAllAffectedPanelIdsForVariableChange(variableInState.id, variables, panels) };
|
||||
: {
|
||||
refreshAll: false,
|
||||
panelIds: Array.from(getAllAffectedPanelIdsForVariableChange([variableInState.id], g, panelVars)),
|
||||
};
|
||||
|
||||
const node = g.getNode(variableInState.name);
|
||||
let promises: Array<Promise<any>> = [];
|
||||
@@ -643,7 +648,7 @@ export const onTimeRangeUpdated =
|
||||
}) as VariableWithOptions[];
|
||||
|
||||
const variableIds = variablesThatNeedRefresh.map((variable) => variable.id);
|
||||
const promises = variablesThatNeedRefresh.map((variable: VariableWithOptions) =>
|
||||
const promises = variablesThatNeedRefresh.map((variable) =>
|
||||
dispatch(timeRangeUpdated(toKeyedVariableIdentifier(variable)))
|
||||
);
|
||||
|
||||
@@ -678,8 +683,8 @@ export const templateVarsChangedInUrl =
|
||||
async (dispatch, getState) => {
|
||||
const update: Array<Promise<any>> = [];
|
||||
const dashboard = getState().dashboard.getModel();
|
||||
const panelIds = new Set<number>();
|
||||
const variables = getVariablesByKey(key, getState());
|
||||
|
||||
for (const variable of variables) {
|
||||
const key = `var-${variable.name}`;
|
||||
if (!vars.hasOwnProperty(key)) {
|
||||
@@ -706,24 +711,29 @@ export const templateVarsChangedInUrl =
|
||||
}
|
||||
}
|
||||
|
||||
// for adhoc variables we don't know which panels that will be impacted
|
||||
if (!isAdHoc(variable)) {
|
||||
getAllAffectedPanelIdsForVariableChange(variable.id, variables, dashboard?.panels ?? []).forEach((id) =>
|
||||
panelIds.add(id)
|
||||
);
|
||||
}
|
||||
|
||||
const promise = variableAdapters.get(variable.type).setValueFromUrl(variable, value);
|
||||
update.push(promise);
|
||||
}
|
||||
|
||||
const filteredVars = variables.filter((v) => {
|
||||
const key = `var-${v.name}`;
|
||||
return vars.hasOwnProperty(key) && isVariableUrlValueDifferentFromCurrent(v, vars[key].value) && !isAdHoc(v);
|
||||
});
|
||||
const varGraph = createGraph(variables);
|
||||
const panelVars = getPanelVars(dashboard?.panels ?? []);
|
||||
const affectedPanels = getAllAffectedPanelIdsForVariableChange(
|
||||
filteredVars.map((v) => v.id),
|
||||
varGraph,
|
||||
panelVars
|
||||
);
|
||||
|
||||
if (update.length) {
|
||||
await Promise.all(update);
|
||||
|
||||
events.publish(
|
||||
new VariablesChangedInUrl({
|
||||
refreshAll: panelIds.size === 0,
|
||||
panelIds: Array.from(panelIds),
|
||||
refreshAll: affectedPanels.size === 0,
|
||||
panelIds: Array.from(affectedPanels),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -942,7 +942,7 @@ describe('LokiDatasource', () => {
|
||||
|
||||
describe('prepareLogRowContextQueryTarget', () => {
|
||||
const ds = createLokiDSForTests();
|
||||
it('creates query with only labels from /labels API', () => {
|
||||
it('creates query with only labels from /labels API', async () => {
|
||||
const row: LogRowModel = {
|
||||
rowIndex: 0,
|
||||
dataFrame: new MutableDataFrame({
|
||||
@@ -956,15 +956,39 @@ describe('LokiDatasource', () => {
|
||||
}),
|
||||
labels: { bar: 'baz', foo: 'uniqueParsedLabel' },
|
||||
uid: '1',
|
||||
} as any;
|
||||
} as unknown as LogRowModel;
|
||||
|
||||
//Mock stored labels to only include "bar" label
|
||||
jest.spyOn(ds.languageProvider, 'start').mockImplementation(() => Promise.resolve([]));
|
||||
jest.spyOn(ds.languageProvider, 'getLabelKeys').mockImplementation(() => ['bar']);
|
||||
const contextQuery = ds.prepareLogRowContextQueryTarget(row, 10, 'BACKWARD');
|
||||
const contextQuery = await ds.prepareLogRowContextQueryTarget(row, 10, 'BACKWARD');
|
||||
|
||||
expect(contextQuery.query.expr).toContain('baz');
|
||||
expect(contextQuery.query.expr).not.toContain('uniqueParsedLabel');
|
||||
});
|
||||
|
||||
it('should call languageProvider.start to fetch labels', async () => {
|
||||
const row: LogRowModel = {
|
||||
rowIndex: 0,
|
||||
dataFrame: new MutableDataFrame({
|
||||
fields: [
|
||||
{
|
||||
name: 'ts',
|
||||
type: FieldType.time,
|
||||
values: [0],
|
||||
},
|
||||
],
|
||||
}),
|
||||
labels: { bar: 'baz', foo: 'uniqueParsedLabel' },
|
||||
uid: '1',
|
||||
} as unknown as LogRowModel;
|
||||
|
||||
//Mock stored labels to only include "bar" label
|
||||
jest.spyOn(ds.languageProvider, 'start').mockImplementation(() => Promise.resolve([]));
|
||||
await ds.prepareLogRowContextQueryTarget(row, 10, 'BACKWARD');
|
||||
|
||||
expect(ds.languageProvider.start).toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('logs volume data provider', () => {
|
||||
|
||||
@@ -594,10 +594,10 @@ export class LokiDatasource
|
||||
return Math.ceil(date.valueOf() * 1e6);
|
||||
}
|
||||
|
||||
getLogRowContext = (row: LogRowModel, options?: RowContextOptions): Promise<{ data: DataFrame[] }> => {
|
||||
getLogRowContext = async (row: LogRowModel, options?: RowContextOptions): Promise<{ data: DataFrame[] }> => {
|
||||
const direction = (options && options.direction) || 'BACKWARD';
|
||||
const limit = (options && options.limit) || 10;
|
||||
const { query, range } = this.prepareLogRowContextQueryTarget(row, limit, direction);
|
||||
const { query, range } = await this.prepareLogRowContextQueryTarget(row, limit, direction);
|
||||
|
||||
const processDataFrame = (frame: DataFrame): DataFrame => {
|
||||
// log-row-context requires specific field-names to work, so we set them here: "ts", "line", "id"
|
||||
@@ -660,11 +660,13 @@ export class LokiDatasource
|
||||
);
|
||||
};
|
||||
|
||||
prepareLogRowContextQueryTarget = (
|
||||
prepareLogRowContextQueryTarget = async (
|
||||
row: LogRowModel,
|
||||
limit: number,
|
||||
direction: 'BACKWARD' | 'FORWARD'
|
||||
): { query: LokiQuery; range: TimeRange } => {
|
||||
): Promise<{ query: LokiQuery; range: TimeRange }> => {
|
||||
// need to await the languageProvider to be started to have all labels. This call is not blocking after it has been called once.
|
||||
await this.languageProvider.start();
|
||||
const labels = this.languageProvider.getLabelKeys();
|
||||
const expr = Object.keys(row.labels)
|
||||
.map((label: string) => {
|
||||
|
||||
@@ -211,7 +211,7 @@ export class PrometheusDatasource
|
||||
}
|
||||
|
||||
// Use this for tab completion features, wont publish response to other components
|
||||
async metadataRequest<T = any>(url: string, params = {}) {
|
||||
async metadataRequest<T = any>(url: string, params = {}, options?: Partial<BackendSrvRequest>) {
|
||||
// If URL includes endpoint that supports POST and GET method, try to use configured method. This might fail as POST is supported only in v2.10+.
|
||||
if (GET_AND_POST_METADATA_ENDPOINTS.some((endpoint) => url.includes(endpoint))) {
|
||||
try {
|
||||
@@ -220,6 +220,7 @@ export class PrometheusDatasource
|
||||
method: this.httpMethod,
|
||||
hideFromInspector: true,
|
||||
showErrorAlert: false,
|
||||
...options,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
@@ -236,6 +237,7 @@ export class PrometheusDatasource
|
||||
this._request<T>(`/api/datasources/${this.id}/resources${url}`, params, {
|
||||
method: 'GET',
|
||||
hideFromInspector: true,
|
||||
...options,
|
||||
})
|
||||
); // toPromise until we change getTagValues, getTagKeys to Observable
|
||||
}
|
||||
@@ -998,7 +1000,7 @@ export class PrometheusDatasource
|
||||
|
||||
async loadRules() {
|
||||
try {
|
||||
const res = await this.metadataRequest('/api/v1/rules');
|
||||
const res = await this.metadataRequest('/api/v1/rules', {}, { showErrorAlert: false });
|
||||
const groups = res.data?.data?.groups;
|
||||
|
||||
if (groups) {
|
||||
@@ -1012,11 +1014,18 @@ export class PrometheusDatasource
|
||||
|
||||
async areExemplarsAvailable() {
|
||||
try {
|
||||
const res = await this.getResource('/api/v1/query_exemplars', {
|
||||
query: 'test',
|
||||
start: dateTime().subtract(30, 'minutes').valueOf(),
|
||||
end: dateTime().valueOf(),
|
||||
});
|
||||
const res = await this.metadataRequest(
|
||||
'/api/v1/query_exemplars',
|
||||
{
|
||||
query: 'test',
|
||||
start: dateTime().subtract(30, 'minutes').valueOf().toString(),
|
||||
end: dateTime().valueOf().toString(),
|
||||
},
|
||||
{
|
||||
// Avoid alerting the user if this test fails
|
||||
showErrorAlert: false,
|
||||
}
|
||||
);
|
||||
if (res.data.status === 'success') {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
HistoryItem,
|
||||
LanguageProvider,
|
||||
} from '@grafana/data';
|
||||
import { BackendSrvRequest } from '@grafana/runtime';
|
||||
import { CompletionItem, CompletionItemGroup, SearchFunctionType, TypeaheadInput, TypeaheadOutput } from '@grafana/ui';
|
||||
|
||||
import { PrometheusDatasource } from './datasource';
|
||||
@@ -120,9 +121,9 @@ export default class PromQlLanguageProvider extends LanguageProvider {
|
||||
return PromqlSyntax;
|
||||
}
|
||||
|
||||
request = async (url: string, defaultValue: any, params = {}): Promise<any> => {
|
||||
request = async (url: string, defaultValue: any, params = {}, options?: Partial<BackendSrvRequest>): Promise<any> => {
|
||||
try {
|
||||
const res = await this.datasource.metadataRequest(url, params);
|
||||
const res = await this.datasource.metadataRequest(url, params, options);
|
||||
return res.data.data;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -145,7 +146,9 @@ export default class PromQlLanguageProvider extends LanguageProvider {
|
||||
};
|
||||
|
||||
async loadMetricsMetadata() {
|
||||
this.metricsMetadata = fixSummariesMetadata(await this.request('/api/v1/metadata', {}));
|
||||
this.metricsMetadata = fixSummariesMetadata(
|
||||
await this.request('/api/v1/metadata', {}, {}, { showErrorAlert: false })
|
||||
);
|
||||
}
|
||||
|
||||
getLabelKeys(): string[] {
|
||||
|
||||
@@ -27,6 +27,8 @@ export const AlertInstances: FC<Props> = ({ alerts, options }) => {
|
||||
setDisplayInstances((display) => !display);
|
||||
}, []);
|
||||
|
||||
// TODO Filtering instances here has some implications
|
||||
// If a rule has 0 instances after filtering there is no way not to show that rule
|
||||
const filteredAlerts = useMemo(
|
||||
(): Alert[] => filterAlerts(options, sortAlerts(options.sortOrder, alerts)) ?? [],
|
||||
[alerts, options]
|
||||
|
||||
@@ -25,6 +25,7 @@ import { PromAlertingRuleState } from 'app/types/unified-alerting-dto';
|
||||
import { GroupMode, SortOrder, UnifiedAlertListOptions } from './types';
|
||||
import GroupedModeView from './unified-alerting/GroupedView';
|
||||
import UngroupedModeView from './unified-alerting/UngroupedView';
|
||||
import { filterAlerts } from './util';
|
||||
|
||||
export function UnifiedAlertList(props: PanelProps<UnifiedAlertListOptions>) {
|
||||
const dispatch = useDispatch();
|
||||
@@ -143,14 +144,15 @@ function filterRules(props: PanelProps<UnifiedAlertListOptions>, rules: PromRule
|
||||
const replacedLabelFilter = replaceVariables(options.alertInstanceLabelFilter);
|
||||
const matchers = parseMatchers(replacedLabelFilter);
|
||||
// Reduce rules and instances to only those that match
|
||||
filteredRules = filteredRules.reduce((rules, rule) => {
|
||||
filteredRules = filteredRules.reduce<PromRuleWithLocation[]>((rules, rule) => {
|
||||
const filteredAlerts = (rule.rule.alerts ?? []).filter(({ labels }) => labelsMatchMatchers(labels, matchers));
|
||||
if (filteredAlerts.length) {
|
||||
rules.push({ ...rule, rule: { ...rule.rule, alerts: filteredAlerts } });
|
||||
}
|
||||
return rules;
|
||||
}, [] as PromRuleWithLocation[]);
|
||||
}, []);
|
||||
}
|
||||
|
||||
if (options.folder) {
|
||||
filteredRules = filteredRules.filter((rule) => {
|
||||
return rule.namespaceName === options.folder.title;
|
||||
@@ -166,6 +168,19 @@ function filterRules(props: PanelProps<UnifiedAlertListOptions>, rules: PromRule
|
||||
);
|
||||
}
|
||||
|
||||
// Remove rules having 0 instances
|
||||
// AlertInstances filters instances and we need to prevent situation
|
||||
// when we display a rule with 0 instances
|
||||
filteredRules = filteredRules.reduce<PromRuleWithLocation[]>((rules, rule) => {
|
||||
const filteredAlerts = filterAlerts(options, rule.rule.alerts ?? []);
|
||||
if (filteredAlerts.length) {
|
||||
// We intentionally don't set alerts to filteredAlerts
|
||||
// because later we couldn't display that some alerts are hidden (ref AlertInstances filtering)
|
||||
rules.push(rule);
|
||||
}
|
||||
return rules;
|
||||
}, []);
|
||||
|
||||
return filteredRules;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,12 @@ import React, { FC, useMemo } from 'react';
|
||||
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
import { AlertLabel } from 'app/features/alerting/unified/components/AlertLabel';
|
||||
import { PromRuleWithLocation } from 'app/types/unified-alerting';
|
||||
import { Alert, PromRuleWithLocation } from 'app/types/unified-alerting';
|
||||
|
||||
import { AlertInstances } from '../AlertInstances';
|
||||
import { getStyles } from '../UnifiedAlertList';
|
||||
import { GroupedRules, UnifiedAlertListOptions } from '../types';
|
||||
import { filterAlerts } from '../util';
|
||||
|
||||
type GroupedModeProps = {
|
||||
rules: PromRuleWithLocation[];
|
||||
@@ -19,7 +20,7 @@ const GroupedModeView: FC<GroupedModeProps> = ({ rules, options }) => {
|
||||
const groupBy = options.groupBy;
|
||||
|
||||
const groupedRules = useMemo<GroupedRules>(() => {
|
||||
const groupedRules = new Map();
|
||||
const groupedRules = new Map<string, Alert[]>();
|
||||
|
||||
const hasInstancesWithMatchingLabels = (rule: PromRuleWithLocation) =>
|
||||
groupBy ? alertHasEveryLabel(rule, groupBy) : true;
|
||||
@@ -33,8 +34,19 @@ const GroupedModeView: FC<GroupedModeProps> = ({ rules, options }) => {
|
||||
});
|
||||
});
|
||||
|
||||
return groupedRules;
|
||||
}, [groupBy, rules]);
|
||||
// Remove groups having no instances
|
||||
// This is different from filtering Rules without instances that we do in UnifiedAlertList
|
||||
const filteredGroupedRules = Array.from(groupedRules.entries()).reduce((acc, [groupKey, groupAlerts]) => {
|
||||
const filteredAlerts = filterAlerts(options, groupAlerts);
|
||||
if (filteredAlerts.length > 0) {
|
||||
acc.set(groupKey, filteredAlerts);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, new Map<string, Alert[]>());
|
||||
|
||||
return filteredGroupedRules;
|
||||
}, [groupBy, rules, options]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { css } from '@emotion/css';
|
||||
import React, { FC } from 'react';
|
||||
|
||||
import { GrafanaTheme2, intervalToAbbreviatedDurationString } from '@grafana/data';
|
||||
import { Icon, IconName, useStyles2 } from '@grafana/ui';
|
||||
import { Icon, useStyles2 } from '@grafana/ui';
|
||||
import alertDef from 'app/features/alerting/state/alertDef';
|
||||
import { alertStateToReadable, alertStateToState, getFirstActiveAt } from 'app/features/alerting/unified/utils/rules';
|
||||
import { PromRuleWithLocation } from 'app/types/unified-alerting';
|
||||
@@ -33,7 +33,7 @@ const UngroupedModeView: FC<UngroupedModeProps> = ({ rules, options }) => {
|
||||
<li className={styles.alertRuleItem} key={`alert-${namespaceName}-${groupName}-${rule.name}-${index}`}>
|
||||
<div className={stateStyle.icon}>
|
||||
<Icon
|
||||
name={alertDef.getStateDisplayModel(rule.state).iconClass as IconName}
|
||||
name={alertDef.getStateDisplayModel(rule.state).iconClass}
|
||||
className={stateStyle[alertStateToState(rule.state)]}
|
||||
size={'lg'}
|
||||
/>
|
||||
|
||||
@@ -110,11 +110,11 @@ RUN rm dockerize-linux-amd64-v${DOCKERIZE_VERSION}.tar.gz
|
||||
# Use old Debian (this has support into 2022) in order to ensure binary compatibility with older glibc's.
|
||||
FROM debian:stretch-20210208
|
||||
|
||||
ENV GOVERSION=1.17.11 \
|
||||
ENV GOVERSION=1.17.12 \
|
||||
PATH=/usr/local/go/bin:$PATH \
|
||||
GOPATH=/go \
|
||||
NODEVERSION=16.14.0-1nodesource1 \
|
||||
YARNVERSION=1.22.15-1
|
||||
YARNVERSION=1.22.19-1
|
||||
|
||||
# Use ARG so as not to persist environment variable in image
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
@@ -126,7 +126,8 @@ COPY --from=toolchain /tmp/cue /usr/local/bin/
|
||||
COPY --from=toolchain /tmp/dockerize /usr/local/bin/
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -yq \
|
||||
apt-get install -yq \
|
||||
apt-transport-https \
|
||||
build-essential netcat-traditional clang gcc-aarch64-linux-gnu gcc-arm-linux-gnueabihf gcc-mingw-w64-x86-64 \
|
||||
apt-transport-https \
|
||||
python-pip \
|
||||
|
||||
@@ -112,7 +112,7 @@ def pr_test_backend():
|
||||
def pr_pipelines(edition):
|
||||
services = integration_test_services(edition)
|
||||
volumes = integration_test_services_volumes()
|
||||
variants = ['linux-amd64', 'linux-amd64-musl', 'darwin-amd64', 'windows-amd64', 'armv6', ]
|
||||
variants = ['linux-amd64', 'linux-amd64-musl', 'darwin-amd64', 'windows-amd64', ]
|
||||
init_steps = [
|
||||
identify_runner_step(),
|
||||
download_grabpl_step(),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
load('scripts/drone/vault.star', 'from_secret', 'github_token', 'pull_secret', 'drone_token', 'prerelease_bucket')
|
||||
|
||||
grabpl_version = 'v9.0.2-security1'
|
||||
build_image = 'grafana/build-container:1.5.5'
|
||||
publish_image = 'grafana/grafana-ci-deploy:1.3.1'
|
||||
grabpl_version = 'v2.9.50-go1.17.12'
|
||||
build_image = 'grafana/build-container:1.5.8'
|
||||
publish_image = 'grafana/grafana-ci-deploy:1.3.3'
|
||||
deploy_docker_image = 'us.gcr.io/kubernetes-dev/drone/plugins/deploy-image'
|
||||
alpine_image = 'alpine:3.15'
|
||||
curl_image = 'byrnedo/alpine-curl:0.1.8'
|
||||
@@ -226,8 +226,7 @@ def lint_backend_step(edition):
|
||||
'wire-install',
|
||||
],
|
||||
'commands': [
|
||||
# Don't use Make since it will re-download the linters
|
||||
'./bin/grabpl lint-backend --edition {}'.format(edition),
|
||||
'make lint-go',
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -3791,9 +3791,9 @@ __metadata:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@grafana-plugins/input-datasource@workspace:plugins-bundled/internal/input-datasource"
|
||||
dependencies:
|
||||
"@grafana/data": 9.0.3
|
||||
"@grafana/toolkit": 9.0.3
|
||||
"@grafana/ui": 9.0.3
|
||||
"@grafana/data": 9.0.4
|
||||
"@grafana/toolkit": 9.0.4
|
||||
"@grafana/ui": 9.0.4
|
||||
"@types/jest": 26.0.15
|
||||
"@types/lodash": 4.14.149
|
||||
"@types/react": 17.0.30
|
||||
@@ -3831,12 +3831,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@grafana/data@9.0.3, @grafana/data@workspace:*, @grafana/data@workspace:packages/grafana-data":
|
||||
"@grafana/data@9.0.4, @grafana/data@workspace:*, @grafana/data@workspace:packages/grafana-data":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@grafana/data@workspace:packages/grafana-data"
|
||||
dependencies:
|
||||
"@braintree/sanitize-url": 6.0.0
|
||||
"@grafana/schema": 9.0.3
|
||||
"@grafana/schema": 9.0.4
|
||||
"@grafana/tsconfig": ^1.2.0-rc1
|
||||
"@rollup/plugin-commonjs": 22.0.0
|
||||
"@rollup/plugin-json": 4.1.0
|
||||
@@ -3889,7 +3889,7 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@grafana/e2e-selectors@9.0.3, @grafana/e2e-selectors@workspace:*, @grafana/e2e-selectors@workspace:packages/grafana-e2e-selectors":
|
||||
"@grafana/e2e-selectors@9.0.4, @grafana/e2e-selectors@workspace:*, @grafana/e2e-selectors@workspace:packages/grafana-e2e-selectors":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@grafana/e2e-selectors@workspace:packages/grafana-e2e-selectors"
|
||||
dependencies:
|
||||
@@ -3913,7 +3913,7 @@ __metadata:
|
||||
"@babel/core": 7.17.8
|
||||
"@babel/preset-env": 7.17.10
|
||||
"@cypress/webpack-preprocessor": 5.11.1
|
||||
"@grafana/e2e-selectors": 9.0.3
|
||||
"@grafana/e2e-selectors": 9.0.4
|
||||
"@grafana/tsconfig": ^1.2.0-rc1
|
||||
"@mochajs/json-file-reporter": ^1.2.0
|
||||
"@rollup/plugin-commonjs": 22.0.0
|
||||
@@ -3998,14 +3998,14 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@grafana/runtime@9.0.3, @grafana/runtime@workspace:*, @grafana/runtime@workspace:packages/grafana-runtime":
|
||||
"@grafana/runtime@9.0.4, @grafana/runtime@workspace:*, @grafana/runtime@workspace:packages/grafana-runtime":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@grafana/runtime@workspace:packages/grafana-runtime"
|
||||
dependencies:
|
||||
"@grafana/data": 9.0.3
|
||||
"@grafana/e2e-selectors": 9.0.3
|
||||
"@grafana/data": 9.0.4
|
||||
"@grafana/e2e-selectors": 9.0.4
|
||||
"@grafana/tsconfig": ^1.2.0-rc1
|
||||
"@grafana/ui": 9.0.3
|
||||
"@grafana/ui": 9.0.4
|
||||
"@rollup/plugin-commonjs": 22.0.0
|
||||
"@rollup/plugin-node-resolve": 13.3.0
|
||||
"@sentry/browser": 6.19.7
|
||||
@@ -4034,7 +4034,7 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@grafana/schema@9.0.3, @grafana/schema@workspace:*, @grafana/schema@workspace:packages/grafana-schema":
|
||||
"@grafana/schema@9.0.4, @grafana/schema@workspace:*, @grafana/schema@workspace:packages/grafana-schema":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@grafana/schema@workspace:packages/grafana-schema"
|
||||
dependencies:
|
||||
@@ -4081,7 +4081,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@grafana/toolkit@9.0.3, @grafana/toolkit@workspace:*, @grafana/toolkit@workspace:packages/grafana-toolkit":
|
||||
"@grafana/toolkit@9.0.4, @grafana/toolkit@workspace:*, @grafana/toolkit@workspace:packages/grafana-toolkit":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@grafana/toolkit@workspace:packages/grafana-toolkit"
|
||||
dependencies:
|
||||
@@ -4097,10 +4097,10 @@ __metadata:
|
||||
"@babel/preset-env": ^7.16.11
|
||||
"@babel/preset-react": ^7.16.7
|
||||
"@babel/preset-typescript": ^7.16.7
|
||||
"@grafana/data": 9.0.3
|
||||
"@grafana/data": 9.0.4
|
||||
"@grafana/eslint-config": ^4.0.0
|
||||
"@grafana/tsconfig": ^1.2.0-rc1
|
||||
"@grafana/ui": 9.0.3
|
||||
"@grafana/ui": 9.0.4
|
||||
"@jest/core": 27.5.1
|
||||
"@types/command-exists": ^1.2.0
|
||||
"@types/eslint": 8.4.1
|
||||
@@ -4184,7 +4184,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@grafana/ui@9.0.3, @grafana/ui@workspace:*, @grafana/ui@workspace:packages/grafana-ui":
|
||||
"@grafana/ui@9.0.4, @grafana/ui@workspace:*, @grafana/ui@workspace:packages/grafana-ui":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@grafana/ui@workspace:packages/grafana-ui"
|
||||
dependencies:
|
||||
@@ -4192,9 +4192,9 @@ __metadata:
|
||||
"@emotion/css": 11.9.0
|
||||
"@emotion/react": 11.9.0
|
||||
"@grafana/aws-sdk": 0.0.36
|
||||
"@grafana/data": 9.0.3
|
||||
"@grafana/e2e-selectors": 9.0.3
|
||||
"@grafana/schema": 9.0.3
|
||||
"@grafana/data": 9.0.4
|
||||
"@grafana/e2e-selectors": 9.0.4
|
||||
"@grafana/schema": 9.0.4
|
||||
"@grafana/slate-react": 0.22.10-grafana
|
||||
"@grafana/tsconfig": ^1.2.0-rc1
|
||||
"@mdx-js/react": 1.6.22
|
||||
@@ -4431,11 +4431,11 @@ __metadata:
|
||||
resolution: "@jaegertracing/jaeger-ui-components@workspace:packages/jaeger-ui-components"
|
||||
dependencies:
|
||||
"@emotion/css": 11.9.0
|
||||
"@grafana/data": 9.0.3
|
||||
"@grafana/e2e-selectors": 9.0.3
|
||||
"@grafana/runtime": 9.0.3
|
||||
"@grafana/data": 9.0.4
|
||||
"@grafana/e2e-selectors": 9.0.4
|
||||
"@grafana/runtime": 9.0.4
|
||||
"@grafana/tsconfig": ^1.2.0-rc1
|
||||
"@grafana/ui": 9.0.3
|
||||
"@grafana/ui": 9.0.4
|
||||
"@testing-library/react": 12.1.4
|
||||
"@testing-library/user-event": 14.2.0
|
||||
"@types/classnames": ^2.2.7
|
||||
|
||||
Reference in New Issue
Block a user