Merge branch 'v9.0.x' of github.com:grafana/grafana into v9.0.x

This commit is contained in:
IevaVasiljeva
2022-07-26 10:29:28 +01:00
94 changed files with 2875 additions and 942 deletions
+2 -23
View File
@@ -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"],
+1
View File
@@ -5,6 +5,7 @@
# But not these files:
!.gitignore
!*.mod
!*.sum
!README.md
!Variables.mk
!variables.env
+4 -4
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+1 -1
View File
@@ -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 teams 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 |
+6 -13
View File
@@ -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.
+1
View File
@@ -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
View File
@@ -4,5 +4,5 @@
"packages": [
"packages/*"
],
"version": "9.0.3"
"version": "9.0.4"
}
+1 -1
View File
@@ -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 -2
View File
@@ -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",
+1 -1
View File
@@ -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 -2
View File
@@ -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",
+4 -4
View File
@@ -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",
+1 -1
View File
@@ -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"
+3 -3
View File
@@ -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/',
},
},
];
+4 -4
View File
@@ -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}
+5 -5
View File
@@ -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",
+2 -2
View File
@@ -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 && \
+7
View File
@@ -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
View File
@@ -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
View File
@@ -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
+3 -7
View File
@@ -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
View File
@@ -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)
}
+48
View File
@@ -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
View File
@@ -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)
+86
View File
@@ -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)
})
}
}
+3 -7
View File
@@ -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)
}
}
+9
View File
@@ -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
+10 -5
View File
@@ -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
+42 -28
View File
@@ -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)
})
}
}
+3 -2
View File
@@ -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{}
+3 -7
View File
@@ -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"])
}
})
})
+3
View File
@@ -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)
})
}
}
@@ -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",
+46 -1
View File
@@ -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
}
+118 -14
View File
@@ -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
}
+4 -8
View File
@@ -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",
+1 -1
View File
@@ -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;
}
+33 -1
View File
@@ -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';
}
+8
View File
@@ -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
@@ -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();
}
}
+5 -3
View File
@@ -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]);
});
});
+43 -73
View File
@@ -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;
}
+24 -14
View File
@@ -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'}
/>
+4 -3
View File
@@ -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 \
+1 -1
View File
@@ -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(),
+4 -5
View File
@@ -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',
],
}
+23 -23
View File
@@ -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