Merge branch 'v8.4.x' of github.com:grafana/grafana into v8.4.x

This commit is contained in:
Daniel Lee
2022-03-16 13:07:45 +01:00
127 changed files with 2592 additions and 683 deletions
+329 -200
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -4,7 +4,7 @@ on:
inputs:
version:
required: true
default: '7.x.x'
default: '8.x.x'
workflow_call:
inputs:
version_call:
+22
View File
@@ -1,3 +1,25 @@
<!-- 8.4.3 START -->
# 8.4.3 (2022-03-02)
### Features and enhancements
- **Alerting:** Grafana uses > instead of >= when checking the For duration. [#46010](https://github.com/grafana/grafana/issues/46010)
- **Alerting:** Use expanded labels in dashboard annotations. [#45726](https://github.com/grafana/grafana/pull/45726), [@grobinson-grafana](https://github.com/grobinson-grafana)
- **Logs:** Escape windows newline into single newline. [#45771](https://github.com/grafana/grafana/pull/45771), [@perosb](https://github.com/perosb)
### Bug fixes
- **Alerting:** Fix use of > instead of >= when checking the For duration. [#46011](https://github.com/grafana/grafana/pull/46011), [@grobinson-grafana](https://github.com/grobinson-grafana)
- **Azure Monitor:** Fixes broken log queries that use workspace. [#45820](https://github.com/grafana/grafana/pull/45820), [@sunker](https://github.com/sunker)
- **CloudWatch:** Remove error message when using multi-valued template vars in region field. [#45886](https://github.com/grafana/grafana/pull/45886), [@sunker](https://github.com/sunker)
- **Middleware:** Fix IPv6 host parsing in CSRF check. [#45911](https://github.com/grafana/grafana/pull/45911), [@ying-jeanne](https://github.com/ying-jeanne)
### Plugin development fixes & changes
- **ClipboardButton:** Use a fallback when the Clipboard API is unavailable. [#45831](https://github.com/grafana/grafana/pull/45831), [@ashharrison90](https://github.com/ashharrison90)
<!-- 8.4.3 END -->
<!-- 8.4.2 START -->
# 8.4.2 (2022-02-23)
+1 -1
View File
@@ -20,7 +20,7 @@ COPY emails emails
ENV NODE_ENV production
RUN yarn build
FROM golang:1.17.7-alpine3.15 as go-builder
FROM golang:1.17.8-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.7 AS go-builder
FROM golang:1.17.8 AS go-builder
WORKDIR /src/grafana
+2 -1
View File
@@ -122,7 +122,7 @@ path = grafana.db
# For "sqlite3" only. cache mode setting used for connecting to the database
cache_mode = private
# For "mysql" only if lockingMigration feature toggle is set. How many seconds to wait before failing to lock the database for the migrations, default is 0.
# For "mysql" only if lockingMigration feature toggle is set. How many seconds to wait before failing to lock the database for the migrations, default is 0.
locking_attempt_timeout_sec = 0
#################################### Cache server #############################
@@ -561,6 +561,7 @@ ldap_sync_ttl = 60
sync_ttl = 60
whitelist =
headers =
headers_encoded = false
enable_login_token = false
#################################### Auth JWT ##########################
+3 -1
View File
@@ -123,7 +123,7 @@
# For "sqlite3" only. cache mode setting used for connecting to the database. (private, shared)
;cache_mode = private
# For "mysql" only if lockingMigration feature toggle is set. How many seconds to wait before failing to lock the database for the migrations, default is 0.
# For "mysql" only if lockingMigration feature toggle is set. How many seconds to wait before failing to lock the database for the migrations, default is 0.
;locking_attempt_timeout_sec = 0
################################### Data sources #########################
@@ -542,6 +542,8 @@
;sync_ttl = 60
;whitelist = 192.168.1.1, 192.168.2.1
;headers = Email:X-User-Email, Name:X-User-Name
# Non-ASCII strings in header values are encoded using quoted-printable encoding
;headers_encoded = false
# Read the auth proxy docs for details on what the setting below enables
;enable_login_token = false
@@ -16,7 +16,7 @@
"gnetId": null,
"graphTooltip": 0,
"id": null,
"iteration": 1601526910610,
"iteration": 1646409057541,
"links": [
{
"icon": "external link",
@@ -73,6 +73,61 @@
"timeShift": null,
"title": "${custom.text}",
"type": "text"
},
{
"id": 4,
"gridPos": {
"h": 9,
"w": 9,
"x": 12,
"y": 0
},
"type": "stat",
"title": "Panel Title",
"fieldConfig": {
"defaults": {
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"color": {
"mode": "thresholds"
},
"links": [
{
"title": "Var Link",
"url": "/d/vmie2cmWz/bar-gauge-demo?var-custom=$custom"
}
]
},
"overrides": []
},
"options": {
"reduceOptions": {
"values": false,
"calcs": [
"lastNotNull"
],
"fields": ""
},
"orientation": "auto",
"textMode": "auto",
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto"
},
"pluginVersion": "8.5.0-pre",
"datasource": null
}
],
"schemaVersion": 26,
@@ -112,6 +167,11 @@
"selected": false,
"text": "p3",
"value": "p3"
},
{
"selected": false,
"text": "p4",
"value": "test%25value"
}
],
"query": "p1,p2,p3",
@@ -361,6 +361,8 @@ Defaults to `private`.
## [remote_cache]
Caches authentication details and session information in the configured database, Redis or Memcached. This setting does not configure [Query Caching in Grafana Enterprise]({{< relref "../enterprise/query-caching.md" >}}).
### type
Either `redis`, `memcached`, or `database`. Defaults to `database`
@@ -53,4 +53,4 @@ To change the team name or email, follow these steps:
## Change user name or email
To learn how to edit your user information, refer to [Grafana user account profile]({{< relref "../../manage-users/user-admin/user-profile.md" >}}).
To learn how to edit your user information, refer to [Edit your profile]({{< relref "../manage-user-preferences/_index.md#edit-your-profile" >}}).
@@ -11,7 +11,7 @@ Annotations and labels are key value pairs associated with alerts originating fr
## Annotations
Annotations are key-value pairs that provide additional meta-information about an alert. For example: a description, a summary, and runbook URL. These are displayed in rule and alert details in the UI and can be used in contact point message templates.
Annotations are key-value pairs that provide additional meta-information about an alert. You can use the following annotations: `description`, `summary`, `runbook_url`, `alertId`, `dashboardUid`, and `panelId`. For example, a description, a summary, and a runbook URL. These are displayed in rule and alert details in the UI and can be used in contact point message templates.
## Labels
+2
View File
@@ -32,6 +32,8 @@ whitelist =
# Optionally define more headers to sync other user attributes
# Example `headers = Name:X-WEBAUTH-NAME Role:X-WEBAUTH-ROLE Email:X-WEBAUTH-EMAIL Groups:X-WEBAUTH-GROUPS`
headers =
# Non-ASCII strings in header values are encoded using quoted-printable encoding
;headers_encoded = false
# Check out docs on this for more details on the below setting
enable_login_token = false
```
+4 -4
View File
@@ -68,7 +68,7 @@ ssl_skip_verify = false
bind_dn = "cn=admin,dc=grafana,dc=org"
# Search user bind password
# If the password contains # or ; you have to wrap it with triple quotes. Ex """#password;"""
bind_password = 'grafana'
bind_password = "grafana"
# User search filter, for example "(cn=%s)" or "(sAMAccountName=%s)" or "(uid=%s)"
# Allow login from email or username, example "(|(sAMAccountName=%s)(userPrincipalName=%s))"
@@ -236,7 +236,7 @@ use_ssl = false
start_tls = false
ssl_skip_verify = false
bind_dn = "cn=admin,dc=grafana,dc=org"
bind_password = 'grafana'
bind_password = "grafana"
search_filter = "(cn=%s)"
search_base_dns = ["dc=grafana,dc=org"]
@@ -263,7 +263,7 @@ use_ssl = false
start_tls = false
ssl_skip_verify = false
bind_dn = "cn=admin,dc=grafana,dc=org"
bind_password = 'grafana'
bind_password = "grafana"
search_filter = "(cn=%s)"
search_base_dns = ["ou=users,dc=grafana,dc=org"]
@@ -286,7 +286,7 @@ start_tls = false
ssl_skip_verify = false
bind_dn = "cn=admin,dc=grafana,dc=org"
bind_password = 'grafana'
bind_password = "grafana"
search_filter = "(cn=%s)"
search_base_dns = ["ou=users,dc=grafana,dc=org"]
@@ -36,7 +36,7 @@ Here are some examples:
### Note about Grafana alerting
For Grafana alerting, we do not support are the following syntaxes at this time.
For Grafana alerting, we do not support the following syntaxes at this time.
- now+n for future timestamps.
- now-1n/n for "start of n until end of n" since this is an absolute timestamp.
@@ -26,7 +26,77 @@ For authentication options and configuration details, see [AWS authentication]({
Grafana needs permissions granted via IAM to be able to read CloudWatch metrics and EC2 tags/instances/regions/alarms. You can attach these permissions to the IAM role or IAM user configured in the previous step.
Here is a minimal policy example:
##### Metrics only example:
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowReadingMetricsFromCloudWatch",
"Effect": "Allow",
"Action": [
"cloudwatch:DescribeAlarmsForMetric",
"cloudwatch:DescribeAlarmHistory",
"cloudwatch:DescribeAlarms",
"cloudwatch:ListMetrics",
"cloudwatch:GetMetricData",
"cloudwatch:GetInsightRuleReport"
],
"Resource": "*"
},
{
"Sid": "AllowReadingTagsInstancesRegionsFromEC2",
"Effect": "Allow",
"Action": ["ec2:DescribeTags", "ec2:DescribeInstances", "ec2:DescribeRegions"],
"Resource": "*"
},
{
"Sid": "AllowReadingResourcesForTags",
"Effect": "Allow",
"Action": "tag:GetResources",
"Resource": "*"
}
]
}
```
##### Logs only example:
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowReadingLogsFromCloudWatch",
"Effect": "Allow",
"Action": [
"logs:DescribeLogGroups",
"logs:GetLogGroupFields",
"logs:StartQuery",
"logs:StopQuery",
"logs:GetQueryResults",
"logs:GetLogEvents"
],
"Resource": "*"
},
{
"Sid": "AllowReadingTagsInstancesRegionsFromEC2",
"Effect": "Allow",
"Action": ["ec2:DescribeTags", "ec2:DescribeInstances", "ec2:DescribeRegions"],
"Resource": "*"
},
{
"Sid": "AllowReadingResourcesForTags",
"Effect": "Allow",
"Action": "tag:GetResources",
"Resource": "*"
}
]
}
```
##### Metrics and Logs example:
```json
{
@@ -40,7 +110,6 @@ Here is a minimal policy example:
"cloudwatch:DescribeAlarmHistory",
"cloudwatch:DescribeAlarms",
"cloudwatch:ListMetrics",
"cloudwatch:GetMetricStatistics",
"cloudwatch:GetMetricData",
"cloudwatch:GetInsightRuleReport"
],
@@ -303,4 +303,16 @@ func (ds *dataSource) QueryData(ctx context.Context, req *backend.QueryDataReque
}
```
In addition, if the user's token includes an ID token, Grafana will pass the user's ID token to the plugin in an `X-ID-Token` header, available on the `QueryDataRequest` object on the `QueryData` request in your backend data source.
```go
func (ds *dataSource) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) {
idToken := req.Headers["X-ID-Token"]
for _, q := range req.Queries {
// ...
}
}
```
> **Note:** Due to a bug in Grafana, using this feature with PostgreSQL can cause a deadlock. For more information, refer to [Grafana causes deadlocks in PostgreSQL, while trying to refresh users token](https://github.com/grafana/grafana/issues/20515).
@@ -12,31 +12,42 @@ This guide helps you identify the steps you need to take based on the Grafana ve
## Table of contents
- [From version 8.3.x to 8.4.x](#from-version-83x-to-84x)
- [Value Mapping Editor has been removed from @grafana-ui library](#value-mapping-editor-has-been-removed-from-grafana-ui-library)
- [Thresholds Editor has been removed from @grafana-ui library](#thresholds-editor-has-been-removed-from-grafana-ui-library)
- [8.4 Deprecations](#84-deprecations)
- [LocationService replaces getLocationSrv](#locationservice-replaces-getlocationsrv)
- [From version 7.x.x to 8.x.x](#from-version-7xx-to-8xx)
- [Backend plugin v1 support has been dropped](#backend-plugin-v1-support-has-been-dropped)
- [1. Add dependency on grafana-plugin-sdk-go](#1-add-dependency-on-grafana-plugin-sdk-go)
- [2. Update the way you bootstrap your plugin](#2-update-the-way-you-bootstrap-your-plugin)
- [3. Update the plugin package](#3-update-the-plugin-package)
- [Sign and load backend plugins](#sign-and-load-backend-plugins)
- [Update react-hook-form from v6 to v7](#update-react-hook-form-from-v6-to-v7)
- [Update the plugin.json](#update-the-pluginjson)
- [Update imports to match emotion 11](#update-imports-to-match-emotion-11)
- [8.0 Deprecations](#80-deprecations)
- [Grafana theme v1](#grafana-theme-v1)
- [From version 6.2.x to 7.4.0](#from-version-62x-to-740)
- [Legend components](#legend-components)
- [From version 6.x.x to 7.0.0](#from-version-6xx-to-700)
- [What's new in Grafana 7.0?](#whats-new-in-grafana-70)
- [Migrate a plugin from Angular to React](#migrate-a-plugin-from-angular-to-react)
- [Migrate a panel plugin](#migrate-a-panel-plugin)
- [Migrate a data source plugin](#migrate-a-data-source-plugin)
- [Migrate to data frames](#migrate-to-data-frames)
- [Troubleshoot plugin migration](#troubleshoot-plugin-migration)
- [Plugin migration guide](#plugin-migration-guide)
- [Introduction](#introduction)
- [Table of contents](#table-of-contents)
- [From version 8.3.x to 8.4.x](#from-version-83x-to-84x)
- [Value Mapping Editor has been removed from @grafana-ui library](#value-mapping-editor-has-been-removed-from-grafana-ui-library)
- [Thresholds Editor has been removed from @grafana-ui library](#thresholds-editor-has-been-removed-from-grafana-ui-library)
- [8.4 deprecations](#84-deprecations)
- [LocationService replaces getLocationSrv](#locationservice-replaces-getlocationsrv)
- [From version 7.x.x to 8.x.x](#from-version-7xx-to-8xx)
- [Backend plugin v1 support has been dropped](#backend-plugin-v1-support-has-been-dropped)
- [1. Add dependency on grafana-plugin-sdk-go](#1-add-dependency-on-grafana-plugin-sdk-go)
- [2. Update the way you bootstrap your plugin](#2-update-the-way-you-bootstrap-your-plugin)
- [3. Update the plugin package](#3-update-the-plugin-package)
- [Sign and load backend plugins](#sign-and-load-backend-plugins)
- [Update react-hook-form from v6 to v7](#update-react-hook-form-from-v6-to-v7)
- [Update the plugin.json](#update-the-pluginjson)
- [Update imports to match emotion 11](#update-imports-to-match-emotion-11)
- [Update needed for app plugins using dashboards](#update-needed-for-app-plugins-using-dashboards)
- [8.0 deprecations](#80-deprecations)
- [Grafana theme v1](#grafana-theme-v1)
- [From version 6.2.x to 7.4.0](#from-version-62x-to-740)
- [Legend components](#legend-components)
- [From version 6.5.x to 7.3.0](#from-version-65x-to-730)
- [getColorForTheme changes](#getcolorfortheme-changes)
- [From version 6.x.x to 7.0.0](#from-version-6xx-to-700)
- [What's new in Grafana 7.0?](#whats-new-in-grafana-70)
- [New data format](#new-data-format)
- [Improved TypeScript support](#improved-typescript-support)
- [Grafana Toolkit](#grafana-toolkit)
- [Field options](#field-options)
- [Backend plugins](#backend-plugins)
- [Migrate a plugin from Angular to React](#migrate-a-plugin-from-angular-to-react)
- [Migrate a panel plugin](#migrate-a-panel-plugin)
- [Migrate a data source plugin](#migrate-a-data-source-plugin)
- [Migrate to data frames](#migrate-to-data-frames)
- [Troubleshoot plugin migration](#troubleshoot-plugin-migration)
## From version 8.3.x to 8.4.x
@@ -308,6 +319,42 @@ import { cx, css } from 'emotion';
import { cx, css } from '@emotion/css';
```
### Update needed for app plugins using dashboards
To make side navigation work properly - app plugins targeting Grafana `8.+` and integrating into the side menu via [addToNav]({{< relref "metadata.md#properties-4" >}}) property need to adjust their `plugin.json` and all dashboard json files to have a matching `uid`.
**`plugin.json`**
```json "linenos=inline,hl_lines=7,linenostart=1"
{
"id": "plugin-id",
// ...
"includes": [
{
"type": "dashboard",
"name": "(Team) Situation Overview",
"path": "dashboards/example-dashboard.json",
"addToNav": true,
"defaultNav": false,
"uid": "l3KqBxCMz"
}
]
// ...
}
```
**`dashboards/example-dashboard.json`**
```json
{
// ...
"title": "Example Dashboard",
"uid": "l3KqBxCMz",
"version": 1
// ...
}
```
### 8.0 deprecations
#### Grafana theme v1
@@ -32,9 +32,9 @@ To verify ownership of your plugin, you need to generate an API key that you'll
Public plugins need to be reviewed by the Grafana team before you can sign them.
1. Submit your plugin for review by creating a pull request in the [grafana-plugin-repository](https://github.com/grafana/grafana-plugin-repository).
1. When your plugin is approved, you're granted a plugin signature level. **Without a plugin signature level, you won't be able to sign your plugin**.
1. In your plugin directory, sign the plugin with the API key you just created. Grafana Toolkit creates a [MANIFEST.txt](#plugin-manifest) file in the `dist` directory of your plugin.
1. Submit your plugin for [review]({{< relref "package-a-plugin.md#publishing-your-plugin-for-the-first-time" >}})
2. When your plugin is approved, you're granted a plugin signature level. **Without a plugin signature level, you won't be able to sign your plugin**.
3. In your plugin directory, sign the plugin with the API key you just created. Grafana Toolkit creates a [MANIFEST.txt](#plugin-manifest) file in the `dist` directory of your plugin.
```bash
export GRAFANA_API_KEY=<YOUR_API_KEY>
@@ -23,7 +23,7 @@ You can use an encryption key from AWS Key Management Service to encrypt secrets
3. Create a [programmatic credential](https://docs.aws.amazon.com/general/latest/gr/aws-sec-cred-types.html#access-keys-and-secret-access-keys) (access key ID and secret access key), which has permission to view the key that you created.
<br><br>In AWS, you can control access to your KMS keys by using [key policies](https://docs.aws.amazon.com/kms/latest/developerguide/key-policies.html), [IAM policies](https://docs.aws.amazon.com/kms/latest/developerguide/iam-policies.html), and [grants](https://docs.aws.amazon.com/kms/latest/developerguide/grants.html). You can also create [temporary credentials](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_temp_use-resources.html), which must provide a session token along with an access key ID and a secret access key.
4. From within Grafana, turn on [envelope encryption]({{< relref "../../administration/envelope-encryption.md" >}}).
4. From within Grafana, turn on [envelope encryption]({{< relref "../../administration//database-encryption.md" >}}).
5. Add your AWS KMS details to the Grafana configuration file; depending on your operating system, it is usually named `grafana.ini`:
<br><br>a. Add a new section to the configuration file, with a name in the format of `[security.encryption.awskms.<KEY-NAME>]`, where `<KEY-NAME>` is any name that uniquely identifies this key among other provider keys.
<br><br>b. Fill in the section with the following values:
@@ -24,7 +24,7 @@ You can use an encryption key from Azure Key Vault to encrypt secrets in the Gra
5. In the Key Permissions section, set encrypt and decrypt permissions, and click **Save**.
6. From within Grafana, turn on [envelope encryption]({{< relref "../../administration/envelope-encryption.md" >}}).
6. From within Grafana, turn on [envelope encryption]({{< relref "../../administration/database-encryption.md" >}}).
7. Add your Azure Key Vault details to the Grafana configuration file; depending on your operating system, is usually named `grafana.ini`:
<br><br>a. Add a new section to the configuration file, with a name in the format of `[security.encryption.azurekv.<KEY-NAME>]`, where `<KEY-NAME>` is any name that uniquely identifies this key among other provider keys.
@@ -22,7 +22,7 @@ You can use an encryption key from Google Cloud Key Management Service to encryp
4. [Create a service account key and save its JSON file](https://cloud.google.com/iam/docs/creating-managing-service-account-keys#creating) to you computer, for example, as `~/.config/gcloud/sample-project-credentials.json`.
5. From within Grafana, turn on [envelope encryption]({{< relref "../../administration/envelope-encryption.md" >}}).
5. From within Grafana, turn on [envelope encryption]({{< relref "../../administration/database-encryption.md" >}}).
6. Add your Google Cloud KMS details to the Grafana configuration file; depending on your operating system, is usually named `grafana.ini`:
<br><br>a. Add a new section to the configuration file, with a name in the format of `[security.encryption.azurekv.<KEY-NAME>]`, where `<KEY-NAME>` is any name that uniquely identifies this key among other provider keys.
@@ -20,7 +20,7 @@ You can use an encryption key from Hashicorp Vault to encrypt secrets in the Gra
3. [Create a periodic service token](https://learn.hashicorp.com/tutorials/vault/tokens#periodic-service-tokens).
4. From within Grafana, turn on [envelope encryption]({{< relref "../../administration/envelope-encryption.md" >}}).
4. From within Grafana, turn on [envelope encryption]({{< relref "../../administration/database-encryption.md" >}}).
5. Add your Hashicorp Vault details to the Grafana configuration file; depending on your operating system, is usually named `grafana.ini`:
<br><br>a. Add a new section to the configuration file, with a name in the format of `[security.encryption.hashicorpvault.<KEY-NAME>]`, where `<KEY-NAME>` is any name that uniquely identifies this key among other provider keys.
+1 -1
View File
@@ -1,6 +1,6 @@
+++
title = "Create reusable Grafana panels"
aliases = ["/docs/grafana/latest/panels/panel-library/" "/docs/sources/panels/library-panels/"]
aliases = ["/docs/grafana/latest/panels/panel-library/", "/docs/sources/panels/library-panels/"]
weight = 700
+++
+1
View File
@@ -8,6 +8,7 @@ weight = 10000
Here you can find detailed release notes that list everything that is included in every release as well as notices
about deprecations, breaking changes as well as changes that relate to plugin development.
- [Release notes for 8.4.3]({{< relref "release-notes-8-4-3" >}})
- [Release notes for 8.4.2]({{< relref "release-notes-8-4-2" >}})
- [Release notes for 8.4.1]({{< relref "release-notes-8-4-1" >}})
- [Release notes for 8.4.0-beta1]({{< relref "release-notes-8-4-0-beta1" >}})
@@ -0,0 +1,25 @@
+++
title = "Release notes for Grafana 8.4.3"
hide_menu = true
+++
<!-- Auto generated by update changelog github action -->
# Release notes for Grafana 8.4.3
### Features and enhancements
- **Alerting:** Grafana uses > instead of >= when checking the For duration. [#46010](https://github.com/grafana/grafana/issues/46010)
- **Alerting:** Use expanded labels in dashboard annotations. [#45726](https://github.com/grafana/grafana/pull/45726), [@grobinson-grafana](https://github.com/grobinson-grafana)
- **Logs:** Escape windows newline into single newline. [#45771](https://github.com/grafana/grafana/pull/45771), [@perosb](https://github.com/perosb)
### Bug fixes
- **Alerting:** Fix use of > instead of >= when checking the For duration. [#46011](https://github.com/grafana/grafana/pull/46011), [@grobinson-grafana](https://github.com/grobinson-grafana)
- **Azure Monitor:** Fixes broken log queries that use workspace. [#45820](https://github.com/grafana/grafana/pull/45820), [@sunker](https://github.com/sunker)
- **CloudWatch:** Remove error message when using multi-valued template vars in region field. [#45886](https://github.com/grafana/grafana/pull/45886), [@sunker](https://github.com/sunker)
- **Middleware:** Fix IPv6 host parsing in CSRF check. [#45911](https://github.com/grafana/grafana/pull/45911), [@ying-jeanne](https://github.com/ying-jeanne)
### Plugin development fixes & changes
- **ClipboardButton:** Use a fallback when the Clipboard API is unavailable. [#45831](https://github.com/grafana/grafana/pull/45831), [@ashharrison90](https://github.com/ashharrison90)
+1 -1
View File
@@ -76,7 +76,7 @@ Click on the node and select "Show in Graph layout" option to switch back to gra
This visualization needs a specific shape of the data to be returned from the data source in order to correctly display it.
Data source needs to return two data frames, one for nodes and one for edges, and you also have to set `frame.meta.preferredVisualisationType = 'nodeGraph'` on both data frames if you want them to be automatically shown in node graph in Explore.
Data source needs to return two data frames, one for nodes and one for edges. You have to set `frame.meta.preferredVisualisationType = 'nodeGraph'` on both data frames or name them `nodes` and `edges` respectively for the node graph to render.
### Node parameters
@@ -27,7 +27,7 @@ e2e.scenario({
`Server:pipe = A'A"A|BB\\B|CCC`,
`Server:distributed = A'A"A,Server=BB\\B,Server=CCC`,
`Server:csv = A'A"A,BB\\B,CCC`,
`Server:html = A'A&quot;A, BB\\B, CCC`,
`Server:html = A'A"A, BB\\B, CCC`,
`Server:json = ["A'A\\"A","BB\\\\B","CCC"]`,
`Server:percentencode = %7BA%27A%22A%2CBB%5CB%2CCCC%7D`,
`Server:singlequote = 'A\\'A"A','BB\\B','CCC'`,
@@ -31,7 +31,7 @@ e2e.scenario({
expect(links).to.have.length.greaterThan(13);
for (let index = 0; index < links.length; index++) {
expect(Cypress.$(links[index]).attr('href')).contains(`var-custom=${variableValue}`);
expect(Cypress.$(links[index]).attr('href')).contains(`var-custom=${encodeURI(variableValue)}`);
}
});
};
@@ -45,6 +45,14 @@ e2e.scenario({
// verify all links, should have All value
verifyLinks('All');
// Data links should percent encode var values
e2e()
.get('[aria-label="Data link"]')
.should('exist')
.and((link) => {
expect(link.attr('href')).contains(encodeURI('test%25value'));
});
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('All').should('be.visible').click();
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('p2').should('be.visible').click();
+2 -2
View File
@@ -1,4 +1,4 @@
{
"stable": "8.4.0",
"testing": "8.4.0"
"stable": "8.4.3",
"testing": "8.4.3"
}
+3
View File
@@ -15,6 +15,8 @@
"e2e:dev": "./e2e/start-and-run-suite dev",
"e2e:benchmark:live": "./e2e/start-and-run-suite benchmark live",
"test": "jest --notify --watch",
"test:coverage": "jest --coverage",
"test:coverage:changes": "jest --coverage --changedSince=origin/main",
"test:accessibility-report": "./scripts/generate-a11y-report.sh",
"lint": "yarn run lint:ts && yarn run lint:sass",
"lint:ts": "eslint . --ext .js,.tsx,.ts --cache",
@@ -36,6 +38,7 @@
"packages:clean": "lerna run clean",
"precommit": "yarn run lint-staged",
"prettier:check": "prettier --check --list-different=false --loglevel=warn \"**/*.{scss,md,mdx}\"",
"prettier:checkDocs": "prettier --check --list-different=false --loglevel=warn \"docs/**.md\" \"packages/**.{ts,tsx,scss,md,mdx}\"",
"prettier:write": "prettier --list-different \"**/*.{scss,md,mdx}\" --write",
"start": "yarn themes:generate && yarn dev --watch",
"start:noTsCheck": "yarn start --env noTsCheck=1",
@@ -60,6 +60,9 @@ export function compareDataFrameStructures(a: DataFrame, b: DataFrame, skipConfi
return false;
}
if (key === 'interval') {
continue;
}
if (key === 'custom') {
if (!shallowCompare(cfgA[key], cfgB[key])) {
return false;
@@ -1,7 +1,6 @@
import {
ApplyFieldOverrideOptions,
DataFrame,
DataLink,
DisplayProcessor,
DisplayValue,
DynamicConfigValue,
@@ -353,7 +352,7 @@ export const getLinksSupplier =
const timeRangeUrl = locationUtil.getTimeRangeUrlParams();
const { timeField } = getTimeField(frame);
return field.config.links.map((link: DataLink) => {
return field.config.links.map((link) => {
const variablesQuery = locationUtil.getVariablesUrlParams();
let dataFrameVars = {};
let valueVars = {};
@@ -439,7 +438,7 @@ export const getLinksSupplier =
}
let href = locationUtil.assureBaseUrl(link.url.replace(/\n/g, ''));
href = replaceVariables(href, variables);
href = replaceVariables(href, variables, encodeURIComponent);
href = locationUtil.processUrl(href);
const info: LinkModel<Field> = {
@@ -70,7 +70,7 @@ export const labelsToFieldsTransformer: SynchronousDataTransformerInfo<LabelsToF
continue;
}
const uniqueValues = (uniqueLabels[labelName] ||= new Set());
const uniqueValues = uniqueLabels[labelName] ?? (uniqueLabels[labelName] = new Set()); // (Safari 13.1 lacks ??= support)
uniqueValues.add(field.labels[labelName]);
}
}
+3 -3
View File
@@ -1,4 +1,4 @@
import { ComponentClass } from 'react';
import { ComponentType } from 'react';
import { KeyValue } from './data';
import { NavModel } from './navModel';
import { PluginMeta, GrafanaPlugin, PluginIncludeType } from './plugin';
@@ -48,7 +48,7 @@ export interface AppPluginMeta<T = KeyValue> extends PluginMeta<T> {
export class AppPlugin<T = KeyValue> extends GrafanaPlugin<AppPluginMeta<T>> {
// Content under: /a/${plugin-id}/*
root?: ComponentClass<AppRootProps<T>>;
root?: ComponentType<AppRootProps<T>>;
rootNav?: NavModel; // Initial navigation model
/**
@@ -66,7 +66,7 @@ export class AppPlugin<T = KeyValue> extends GrafanaPlugin<AppPluginMeta<T>> {
*
* NOTE: this structure will change in 7.2+ so that it is managed with a normal react router
*/
setRootPage(root: ComponentClass<AppRootProps<T>>, rootNav?: NavModel) {
setRootPage(root: ComponentType<AppRootProps<T>>, rootNav?: NavModel) {
this.root = root;
this.rootNav = rootNav;
return this;
+2 -2
View File
@@ -1,4 +1,4 @@
import { ComponentClass } from 'react';
import { ComponentType } from 'react';
import { KeyValue } from './data';
/** Describes plugins life cycle status */
@@ -159,7 +159,7 @@ export interface PluginConfigPage<T extends PluginMeta> {
icon?: string;
id: string; // Unique, in URL
body: ComponentClass<PluginConfigPageProps<T>>;
body: ComponentType<PluginConfigPageProps<T>>;
}
export class GrafanaPlugin<T extends PluginMeta = PluginMeta> {
@@ -126,7 +126,7 @@ Tags can be rendered inside the Card, by being wrapped in `Card.Tags` component.
### As a link
Card can be used as a clickable link item by specifying `href` prop. In this case the Card's content will be rendered inside `a`.
Card can be used as a clickable link item by specifying `href` prop.
```jsx
<Card href="https://grafana.com">
@@ -142,6 +142,45 @@ Card can be used as a clickable link item by specifying `href` prop. In this cas
</Card>
</Preview>
### As a button
Card can be used as a clickable buttons item by specifying `onClick` prop.
```jsx
<Card onClick={() => alert('Hello, Grafana!')}>
<Card.Heading>Hello, Grafana</Card.Heading>
<Card.Description>Clicking this card will create an alert</Card.Description>
</Card>
```
<Preview>
<Card onClick={() => alert('Hello, Grafana!')}>
<Card.Heading>Hello, Grafana</Card.Heading>
<Card.Description>Clicking this card will create an alert</Card.Description>
</Card>
</Preview>
> **Note**: When used in conjunction with [Metadata elements](#multiple-metadata-elements), clicking on any element
> inside `<Card.Meta>` will prevent the card action to be executed (either `href` to be followed or `onClick` to be called).
>
> Example:
```jsx
<Card onClick={() => alert('Hello, Grafana!')}>
<Card.Heading>Hello, Grafana</Card.Heading>
<Card.Meta>Clicking on this text (Meta) WILL NOT trigger the alert!</Card.Meta>
<Card.Description>Clicking on this text (Description) WILL trigger the alert!</Card.Description>
</Card>
```
<Preview>
<Card onClick={() => alert('Hello, Grafana!')}>
<Card.Heading>Hello, Grafana</Card.Heading>
<Card.Meta>Clicking on this text (Meta) WILL NOT trigger the alert!</Card.Meta>
<Card.Description>Clicking on this text (Description) WILL trigger the alert!</Card.Description>
</Card>
</Preview>
### Inside a list item
To render cards in a list, it is possible to nest them inside `li` items.
@@ -256,6 +256,7 @@ const getMetaStyles = (theme: GrafanaTheme2) => ({
margin: theme.spacing(0.5, 0, 0),
lineHeight: theme.typography.bodySmall.lineHeight,
overflowWrap: 'anywhere',
zIndex: 0,
}),
separator: css({
margin: `0 ${theme.spacing(1)}`,
@@ -60,7 +60,7 @@ export const DataLinksContextMenu: React.FC<DataLinksContextMenuProps> = ({ chil
onClick={linkModel.onClick}
target={linkModel.target}
title={linkModel.title}
style={{ display: 'flex' }}
style={{ display: 'flex', width: '100%' }}
aria-label={selectors.components.DataLinksContextMenu.singleLink}
>
{children({})}
@@ -3,6 +3,7 @@ import { Input } from '../Input/Input';
import { Button } from '../Button';
import { TextArea } from '../TextArea/TextArea';
import { InlineField } from '../Forms/InlineField';
import { InlineFieldRow } from '../Forms/InlineFieldRow';
interface Props {
label: string;
@@ -15,17 +16,19 @@ interface Props {
export const CertificationKey: FC<Props> = ({ hasCert, label, onChange, onClick, placeholder }) => {
return (
<InlineField label={label} labelWidth={14}>
{hasCert ? (
<>
<Input type="text" disabled value="configured" width={24} />
<Button variant="secondary" onClick={onClick} style={{ marginLeft: 4 }}>
Reset
</Button>
</>
) : (
<TextArea rows={7} onChange={onChange} placeholder={placeholder} required />
<InlineFieldRow>
<InlineField label={label} labelWidth={14} disabled={hasCert}>
{hasCert ? (
<Input type="text" value="configured" width={24} />
) : (
<TextArea rows={7} onChange={onChange} placeholder={placeholder} required />
)}
</InlineField>
{hasCert && (
<Button variant="secondary" onClick={onClick} style={{ marginLeft: 4 }}>
Reset
</Button>
)}
</InlineField>
</InlineFieldRow>
);
};
@@ -116,7 +116,8 @@ export class GraphNG extends React.Component<GraphNGProps, GraphNGState> {
fields || {
x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}),
y: fieldMatchers.get(FieldMatcherID.numeric).get({}),
}
},
props.timeRange
);
pluginLog('GraphNG', false, 'data aligned', alignedFrame);
@@ -97,6 +97,23 @@ describe('nullInsertThreshold Transformer', () => {
expect(result.fields[2].values.toArray()).toStrictEqual(['a', null, 'b', null, 'c']);
});
test('should insert trailing null at end +interval when timeRange.to.valueOf() exceeds threshold', () => {
const df = new MutableDataFrame({
refId: 'A',
fields: [
{ name: 'Time', type: FieldType.time, config: { interval: 1 }, values: [1, 3, 10] },
{ name: 'One', type: FieldType.number, values: [4, 6, 8] },
{ name: 'Two', type: FieldType.string, values: ['a', 'b', 'c'] },
],
});
const result = applyNullInsertThreshold(df, null, 13);
expect(result.fields[0].values.toArray()).toStrictEqual([1, 2, 3, 4, 10, 11]);
expect(result.fields[1].values.toArray()).toStrictEqual([4, null, 6, null, 8, null]);
expect(result.fields[2].values.toArray()).toStrictEqual(['a', null, 'b', null, 'c', null]);
});
// TODO: make this work
test.skip('should insert nulls at +threshold (when defined) instead of +interval', () => {
const df = new MutableDataFrame({
@@ -115,23 +132,6 @@ describe('nullInsertThreshold Transformer', () => {
expect(result.fields[2].values.toArray()).toStrictEqual(['a', null, 'b', null, 'c']);
});
test('should insert nulls at midpoints between adjacent > interval: 2', () => {
const df = new MutableDataFrame({
refId: 'A',
fields: [
{ name: 'Time', type: FieldType.time, config: { interval: 2 }, values: [5, 7, 11] },
{ name: 'One', type: FieldType.number, values: [4, 6, 8] },
{ name: 'Two', type: FieldType.string, values: ['a', 'b', 'c'] },
],
});
const result = applyNullInsertThreshold(df);
expect(result.fields[0].values.toArray()).toStrictEqual([5, 7, 9, 11]);
expect(result.fields[1].values.toArray()).toStrictEqual([4, 6, null, 8]);
expect(result.fields[2].values.toArray()).toStrictEqual(['a', 'b', null, 'c']);
});
test('should noop on fewer than two values', () => {
const df = new MutableDataFrame({
refId: 'A',
@@ -12,6 +12,7 @@ const INSERT_MODES = {
export function applyNullInsertThreshold(
frame: DataFrame,
refFieldName?: string | null,
refFieldPseudoMax: number | null = null,
insertMode: InsertMode = INSERT_MODES.threshold
): DataFrame {
if (frame.length < 2) {
@@ -48,7 +49,7 @@ export function applyNullInsertThreshold(
const frameValues = frame.fields.map((field) => field.values.toArray());
const filledFieldValues = nullInsertThreshold(refValues, frameValues, threshold, insertMode);
const filledFieldValues = nullInsertThreshold(refValues, frameValues, threshold, refFieldPseudoMax, insertMode);
if (filledFieldValues === frameValues) {
return frame;
@@ -70,7 +71,14 @@ export function applyNullInsertThreshold(
return frame;
}
function nullInsertThreshold(refValues: number[], frameValues: any[][], threshold: number, getInsertValue: InsertMode) {
function nullInsertThreshold(
refValues: number[],
frameValues: any[][],
threshold: number,
// will insert a trailing null when refFieldPseudoMax > last datapoint + threshold
refFieldPseudoMax: number | null = null,
getInsertValue: InsertMode
) {
const len = refValues.length;
let prevValue: number = refValues[0];
const refValuesNew: number[] = [prevValue];
@@ -87,6 +95,10 @@ function nullInsertThreshold(refValues: number[], frameValues: any[][], threshol
prevValue = curValue;
}
if (refFieldPseudoMax != null && prevValue + threshold <= refFieldPseudoMax) {
refValuesNew.push(getInsertValue(prevValue, refFieldPseudoMax, threshold));
}
const filledLen = refValuesNew.length;
if (filledLen === len) {
@@ -1,5 +1,5 @@
import { XYFieldMatchers } from './types';
import { ArrayVector, DataFrame, FieldConfig, FieldType, outerJoinDataFrames } from '@grafana/data';
import { ArrayVector, DataFrame, FieldConfig, FieldType, outerJoinDataFrames, TimeRange } from '@grafana/data';
import { nullToUndefThreshold } from './nullToUndefThreshold';
import { applyNullInsertThreshold } from './nullInsertThreshold';
import { AxisPlacement, GraphFieldConfig, ScaleDistribution, ScaleDistributionConfig } from '@grafana/schema';
@@ -29,9 +29,9 @@ function applySpanNullsThresholds(frame: DataFrame) {
return frame;
}
export function preparePlotFrame(frames: DataFrame[], dimFields: XYFieldMatchers) {
export function preparePlotFrame(frames: DataFrame[], dimFields: XYFieldMatchers, timeRange?: TimeRange | null) {
let alignedFrame = outerJoinDataFrames({
frames: frames.map((frame) => applyNullInsertThreshold(frame)),
frames: frames.map((frame) => applyNullInsertThreshold(frame, null, timeRange?.to.valueOf())),
joinBy: dimFields.x,
keep: dimFields.y,
keepOriginIndices: true,
@@ -0,0 +1,60 @@
import React, { useState, ChangeEvent } from 'react';
import { Story, Meta } from '@storybook/react';
import { SecretInput, Props } from './SecretInput';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
export default {
title: 'Forms/SecretInput',
component: SecretInput,
decorators: [withCenteredStory],
parameters: {
controls: {
exclude: [
'prefix',
'suffix',
'addonBefore',
'addonAfter',
'type',
'disabled',
'invalid',
'loading',
'before',
'after',
],
},
},
args: {
width: 50,
placeholder: 'Enter your secret...',
},
argTypes: {
width: { control: { type: 'range', min: 10, max: 200, step: 10 } },
},
} as Meta;
const Template: Story<Props> = (args) => {
const [secret, setSecret] = useState('');
return (
<SecretInput
width={args.width}
value={secret}
isConfigured={args.isConfigured}
placeholder={args.placeholder}
onChange={(event: ChangeEvent<HTMLInputElement>) => setSecret(event.target.value.trim())}
onReset={() => setSecret('')}
/>
);
};
export const basic = Template.bind({});
basic.args = {
isConfigured: false,
};
export const secretIsConfigured = Template.bind({});
secretIsConfigured.args = {
isConfigured: true,
};
@@ -0,0 +1,65 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SecretInput, RESET_BUTTON_TEXT, CONFIGURED_TEXT } from './SecretInput';
const PLACEHOLDER_TEXT = 'Your secret...';
describe('<SecretInput />', () => {
it('should render an input if the secret is not configured', () => {
render(<SecretInput isConfigured={false} onChange={() => {}} onReset={() => {}} placeholder={PLACEHOLDER_TEXT} />);
const input = screen.getByPlaceholderText(PLACEHOLDER_TEXT);
// Should show an enabled input
expect(input).toBeInTheDocument();
expect(input).not.toBeDisabled();
// Should not show a "Reset" button
expect(screen.queryByRole('button', { name: RESET_BUTTON_TEXT })).not.toBeInTheDocument();
});
it('should render a disabled input with a reset button if the secret is already configured', () => {
render(<SecretInput isConfigured={true} onChange={() => {}} onReset={() => {}} placeholder={PLACEHOLDER_TEXT} />);
const input = screen.getByPlaceholderText(PLACEHOLDER_TEXT);
// Should show a disabled input
expect(input).toBeInTheDocument();
expect(input).toBeDisabled();
expect(input).toHaveValue(CONFIGURED_TEXT);
// Should show a reset button
expect(screen.queryByRole('button', { name: RESET_BUTTON_TEXT })).toBeInTheDocument();
});
it('should be possible to reset a configured secret', () => {
const onReset = jest.fn();
render(<SecretInput isConfigured={true} onChange={() => {}} onReset={onReset} placeholder={PLACEHOLDER_TEXT} />);
// Should show a reset button and a disabled input
expect(screen.queryByPlaceholderText(PLACEHOLDER_TEXT)).toBeDisabled();
expect(screen.queryByRole('button', { name: RESET_BUTTON_TEXT })).toBeInTheDocument();
// Click on "Reset"
userEvent.click(screen.getByRole('button', { name: RESET_BUTTON_TEXT }));
expect(onReset).toHaveBeenCalledTimes(1);
});
it('should be possible to change the value of the secret', () => {
const onChange = jest.fn();
render(<SecretInput isConfigured={false} onChange={onChange} onReset={() => {}} placeholder={PLACEHOLDER_TEXT} />);
const input = screen.getByPlaceholderText(PLACEHOLDER_TEXT);
expect(input).toHaveValue('');
userEvent.type(input, 'Foo');
expect(onChange).toHaveBeenCalled();
expect(input).toHaveValue('Foo');
});
});
@@ -0,0 +1,26 @@
import * as React from 'react';
import { Input } from '../Input/Input';
import { HorizontalGroup } from '../Layout/Layout';
import { Button } from '../Button';
export type Props = React.ComponentProps<typeof Input> & {
/** TRUE if the secret was already configured. (It is needed as often the backend doesn't send back the actual secret, only the information that it was configured) */
isConfigured: boolean;
/** Called when the user clicks on the "Reset" button in order to clear the secret */
onReset: () => void;
};
export const CONFIGURED_TEXT = 'configured';
export const RESET_BUTTON_TEXT = 'Reset';
export const SecretInput = ({ isConfigured, onReset, ...props }: Props) => (
<HorizontalGroup>
{!isConfigured && <Input {...props} type="password" />}
{isConfigured && <Input {...props} type="text" disabled={true} value={CONFIGURED_TEXT} />}
{isConfigured && (
<Button onClick={onReset} variant="secondary">
{RESET_BUTTON_TEXT}
</Button>
)}
</HorizontalGroup>
);
@@ -0,0 +1 @@
export { SecretInput } from './SecretInput';
@@ -2,12 +2,12 @@ import React, { FC } from 'react';
import { css } from '@emotion/css';
import { getTagColorsFromName } from '../../utils';
import { stylesFactory, useTheme } from '../../themes';
import { Icon } from '../Icon/Icon';
import { GrafanaTheme } from '@grafana/data';
import { IconButton } from '../IconButton/IconButton';
interface Props {
name: string;
disabled?: boolean;
onRemove: (tag: string) => void;
}
@@ -36,6 +36,13 @@ const getStyles = stylesFactory(({ theme, name }: { theme: GrafanaTheme; name: s
nameStyle: css`
margin-right: 3px;
`,
buttonStyles: css`
margin: 0;
&:hover::before {
display: none;
}
`,
};
});
@@ -43,14 +50,22 @@ const getStyles = stylesFactory(({ theme, name }: { theme: GrafanaTheme; name: s
* @internal
* Only used internally by TagsInput
* */
export const TagItem: FC<Props> = ({ name, onRemove }) => {
export const TagItem: FC<Props> = ({ name, disabled, onRemove }) => {
const theme = useTheme();
const styles = getStyles({ theme, name });
return (
<div className={styles.itemStyle}>
<span className={styles.nameStyle}>{name}</span>
<Icon className="pointer" name="times" onClick={() => onRemove(name)} />
<IconButton
name="times"
size="lg"
disabled={disabled}
ariaLabel={`Remove ${name}`}
onClick={() => onRemove(name)}
type="button"
className={styles.buttonStyles}
/>
</div>
);
};
@@ -0,0 +1,23 @@
import React from 'react';
import { render, fireEvent, screen } from '@testing-library/react';
import { TagsInput } from './TagsInput';
describe('TagsInput', () => {
it('removes tag when clicking on remove button', async () => {
const onChange = jest.fn();
render(<TagsInput onChange={onChange} tags={['One', 'Two']} />);
fireEvent.click(await screen.findByRole('button', { name: /remove one/i }));
expect(onChange).toHaveBeenCalledWith(['Two']);
});
it('does NOT remove tag when clicking on remove button when disabled', async () => {
const onChange = jest.fn();
render(<TagsInput onChange={onChange} tags={['One', 'Two']} disabled />);
fireEvent.click(await screen.findByRole('button', { name: /remove one/i }));
expect(onChange).not.toHaveBeenCalled();
});
});
@@ -42,10 +42,7 @@ export const TagsInput: FC<Props> = ({
};
const onRemove = (tagToRemove: string) => {
if (disabled) {
return;
}
onChange(tags?.filter((x) => x !== tagToRemove));
onChange(tags.filter((x) => x !== tagToRemove));
};
const onAdd = (event?: React.MouseEvent) => {
@@ -74,7 +71,7 @@ export const TagsInput: FC<Props> = ({
<div className={cx(styles.wrapper, className, width ? css({ width: theme.spacing(width) }) : '')}>
<div className={tags?.length ? styles.tags : undefined}>
{tags?.map((tag: string, index: number) => {
return <TagItem key={`${tag}-${index}`} name={tag} onRemove={onRemove} />;
return <TagItem key={`${tag}-${index}`} name={tag} onRemove={onRemove} disabled={disabled} />;
})}
</div>
<div>
@@ -88,6 +88,87 @@ describe('preparePlotData', () => {
]
`);
});
it('negative-y transform with null/undefined values', () => {
const df = new MutableDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [9997, 9998, 9999] },
{ name: 'a', values: [-10, 20, 10, 30] },
{ name: 'b', values: [10, 10, 10, null] },
{ name: 'c', values: [null, 20, 20, 20], config: { custom: { transform: GraphTransform.NegativeY } } },
{ name: 'd', values: [20, 20, 20, null], config: { custom: { transform: GraphTransform.NegativeY } } },
{ name: 'e', values: [20, null, 20, 20], config: { custom: { transform: GraphTransform.NegativeY } } },
{ name: 'f', values: [10, 10, 10, undefined] },
{ name: 'g', values: [undefined, 20, 20, 20], config: { custom: { transform: GraphTransform.NegativeY } } },
{ name: 'h', values: [20, 20, 20, undefined], config: { custom: { transform: GraphTransform.NegativeY } } },
{ name: 'i', values: [20, undefined, 20, 20], config: { custom: { transform: GraphTransform.NegativeY } } },
],
});
expect(preparePlotData([df])).toMatchInlineSnapshot(`
Array [
Array [
9997,
9998,
9999,
undefined,
],
Array [
-10,
20,
10,
30,
],
Array [
10,
10,
10,
null,
],
Array [
null,
-20,
-20,
-20,
],
Array [
-20,
-20,
-20,
null,
],
Array [
-20,
null,
-20,
-20,
],
Array [
10,
10,
10,
undefined,
],
Array [
undefined,
-20,
-20,
-20,
],
Array [
-20,
-20,
-20,
undefined,
],
Array [
-20,
undefined,
-20,
-20,
],
]
`);
});
it('constant transform', () => {
const df = new MutableDataFrame({
fields: [
@@ -64,8 +64,9 @@ export function preparePlotData(
const customConfig: GraphFieldConfig = f.config.custom || {};
const values = f.values.toArray();
if (customConfig.transform === GraphTransform.NegativeY) {
result.push(values.map((v) => v * -1));
result.push(values.map((v) => (v == null ? v : v * -1)));
} else if (customConfig.transform === GraphTransform.Constant) {
result.push(new Array(values.length).fill(values[0]));
} else {
@@ -34,7 +34,7 @@ export const getStyles = (theme: GrafanaTheme2) => {
label: KeyValueTable;
background: ${autoColor(theme, '#fff')};
border: 1px solid ${autoColor(theme, '#ddd')};
margin-bottom: 0.7em;
margin-bottom: 0.5rem;
max-height: 450px;
overflow: auto;
`,
@@ -45,9 +45,7 @@ export const getStyles = (theme: GrafanaTheme2) => {
row: css`
label: row;
& > td {
padding: 0.25rem 0.5rem;
padding: 0.25rem 0.5rem;
vertical-align: top;
padding: 0rem 0.5rem;
}
&:nth-child(2n) > td {
background: ${autoColor(theme, '#f5f5f5')};
+8
View File
@@ -328,7 +328,11 @@ func extractNumberSet(frame *data.Frame) ([]mathexp.Number, error) {
}
n := mathexp.NewNumber("", labels)
// The new value fields' configs gets pointed to the one in the original frame
n.Frame.Fields[0].Config = frame.Fields[numericField].Config
n.SetValue(&val)
numbers[rowIdx] = n
}
return numbers, nil
@@ -358,6 +362,10 @@ func WideToMany(frame *data.Frame) ([]mathexp.Series, error) {
f := data.NewFrameOfFieldTypes(frame.Name, l, frame.Fields[tsSchema.TimeIndex].Type(), frame.Fields[valIdx].Type())
f.Fields[0].Name = frame.Fields[tsSchema.TimeIndex].Name
f.Fields[1].Name = frame.Fields[valIdx].Name
// The new value fields' configs gets pointed to the one in the original frame
f.Fields[1].Config = frame.Fields[valIdx].Config
if frame.Fields[valIdx].Labels != nil {
f.Fields[1].Labels = frame.Fields[valIdx].Labels.Copy()
}
+5
View File
@@ -201,6 +201,11 @@ func New(ctx ...interface{}) *ConcreteLogger {
return root.New(ctx...)
}
// NewNopLogger returns a logger that doesn't do anything.
func NewNopLogger() *ConcreteLogger {
return newConcreteLogger(gokitlog.NewNopLogger())
}
func with(ctxLogger *ConcreteLogger, withFunc func(gokitlog.Logger, ...interface{}) gokitlog.Logger, ctx []interface{}) *ConcreteLogger {
if len(ctx) == 0 {
return ctxLogger
@@ -84,14 +84,14 @@ type Options struct {
// New instance of the AuthProxy.
func New(cfg *setting.Cfg, options *Options) *AuthProxy {
header := options.Ctx.Req.Header.Get(cfg.AuthProxyHeaderName)
return &AuthProxy{
auth := &AuthProxy{
remoteCache: options.RemoteCache,
cfg: cfg,
ctx: options.Ctx,
orgID: options.OrgID,
header: header,
}
auth.header = auth.getDecodedHeader(cfg.AuthProxyHeaderName)
return auth
}
// IsEnabled checks if the auth proxy is enabled.
@@ -313,6 +313,17 @@ func (auth *AuthProxy) LoginViaHeader() (int64, error) {
return upsert.Result.Id, nil
}
// getDecodedHeader gets decoded value of a header with given headerName
func (auth *AuthProxy) getDecodedHeader(headerName string) string {
headerValue := auth.ctx.Req.Header.Get(headerName)
if auth.cfg.AuthProxyHeadersEncoded {
headerValue = util.DecodeQuotedPrintable(headerValue)
}
return headerValue
}
// headersIterator iterates over all non-empty supported additional headers
func (auth *AuthProxy) headersIterator(fn func(field string, header string)) {
for _, field := range supportedHeaderFields {
@@ -321,7 +332,7 @@ func (auth *AuthProxy) headersIterator(fn func(field string, header string)) {
continue
}
if value := auth.ctx.Req.Header.Get(h); value != "" {
if value := auth.getDecodedHeader(h); value != "" {
fn(field, strings.TrimSpace(value))
}
}
@@ -50,18 +50,19 @@ func (m *fakeMultiLDAP) User(login string) (
const hdrName = "markelog"
func prepareMiddleware(t *testing.T, remoteCache *remotecache.RemoteCache, cb func(*http.Request, *setting.Cfg)) *AuthProxy {
func prepareMiddleware(t *testing.T, remoteCache *remotecache.RemoteCache, configureReq func(*http.Request, *setting.Cfg)) *AuthProxy {
t.Helper()
cfg := setting.NewCfg()
cfg.AuthProxyHeaderName = "X-Killa"
req, err := http.NewRequest("POST", "http://example.com", nil)
require.NoError(t, err)
req.Header.Set(cfg.AuthProxyHeaderName, hdrName)
if cb != nil {
cb(req, cfg)
cfg := setting.NewCfg()
if configureReq != nil {
configureReq(req, cfg)
} else {
cfg.AuthProxyHeaderName = "X-Killa"
req.Header.Set(cfg.AuthProxyHeaderName, hdrName)
}
ctx := &models.ReqContext{
@@ -113,9 +114,11 @@ func TestMiddlewareContext(t *testing.T) {
require.NoError(t, err)
auth := prepareMiddleware(t, cache, func(req *http.Request, cfg *setting.Cfg) {
cfg.AuthProxyHeaderName = "X-Killa"
cfg.AuthProxyHeaders = map[string]string{"Groups": "X-WEBAUTH-GROUPS", "Role": "X-WEBAUTH-ROLE"}
req.Header.Set(cfg.AuthProxyHeaderName, hdrName)
req.Header.Set("X-WEBAUTH-GROUPS", group)
req.Header.Set("X-WEBAUTH-ROLE", role)
cfg.AuthProxyHeaders = map[string]string{"Groups": "X-WEBAUTH-GROUPS", "Role": "X-WEBAUTH-ROLE"}
})
assert.Equal(t, "auth-proxy-sync-ttl:f5acfffd56daac98d502ef8c8b8c5d56", key)
@@ -220,3 +223,26 @@ func TestMiddlewareContext_ldap(t *testing.T) {
assert.False(t, stub.loginCalled)
})
}
func TestDecodeHeader(t *testing.T) {
cache := remotecache.NewFakeStore(t)
t.Run("should not decode header if not enabled in settings", func(t *testing.T) {
auth := prepareMiddleware(t, cache, func(req *http.Request, cfg *setting.Cfg) {
cfg.AuthProxyHeaderName = "X-WEBAUTH-USER"
cfg.AuthProxyHeadersEncoded = false
req.Header.Set(cfg.AuthProxyHeaderName, "M=C3=BCnchen")
})
assert.Equal(t, "M=C3=BCnchen", auth.header)
})
t.Run("should decode header if enabled in settings", func(t *testing.T) {
auth := prepareMiddleware(t, cache, func(req *http.Request, cfg *setting.Cfg) {
cfg.AuthProxyHeaderName = "X-WEBAUTH-USER"
cfg.AuthProxyHeadersEncoded = true
req.Header.Set(cfg.AuthProxyHeaderName, "M=C3=BCnchen")
})
assert.Equal(t, "München", auth.header)
})
}
+33 -8
View File
@@ -73,6 +73,7 @@ func ProvideService(bus bus.Bus, store *sqlstore.SQLStore, secretsService secret
s.Bus.AddHandler(s.GetDefaultDataSource)
ac.RegisterAttributeScopeResolver(NewNameScopeResolver(store))
ac.RegisterAttributeScopeResolver(NewUidScopeResolver(store))
return s
}
@@ -84,16 +85,15 @@ type DataSourceRetriever interface {
// NewNameScopeResolver provides an AttributeScopeResolver able to
// translate a scope prefixed with "datasources:name:" into an id based scope.
func NewNameScopeResolver(db DataSourceRetriever) (string, accesscontrol.AttributeScopeResolveFunc) {
prefix := accesscontrol.Scope("datasources", "name", "")
dsNameResolver := func(ctx context.Context, orgID int64, initialScope string) (string, error) {
dsNames := strings.Split(initialScope, ":")
if dsNames[0] != "datasources" || len(dsNames) != 3 {
if !strings.HasPrefix(initialScope, prefix) {
return "", accesscontrol.ErrInvalidScope
}
dsName := dsNames[2]
// Special wildcard case
if dsName == "*" {
return accesscontrol.Scope("datasources", "id", "*"), nil
dsName := initialScope[len(prefix):]
if dsName == "" {
return "", accesscontrol.ErrInvalidScope
}
query := models.GetDataSourceQuery{Name: dsName, OrgId: orgID}
@@ -101,10 +101,35 @@ func NewNameScopeResolver(db DataSourceRetriever) (string, accesscontrol.Attribu
return "", err
}
return accesscontrol.Scope("datasources", "id", fmt.Sprintf("%v", query.Result.Id)), nil
return accesscontrol.Scope("datasources", "id", strconv.FormatInt(query.Result.Id, 10)), nil
}
return "datasources:name:", dsNameResolver
return prefix, dsNameResolver
}
// NewUidScopeResolver provides an AttributeScopeResolver able to
// translate a scope prefixed with "datasources:uid:" into an id based scope.
func NewUidScopeResolver(db DataSourceRetriever) (string, accesscontrol.AttributeScopeResolveFunc) {
prefix := accesscontrol.Scope("datasources", "uid", "")
dsUIDResolver := func(ctx context.Context, orgID int64, initialScope string) (string, error) {
if !strings.HasPrefix(initialScope, prefix) {
return "", accesscontrol.ErrInvalidScope
}
dsUID := initialScope[len(prefix):]
if dsUID == "" {
return "", accesscontrol.ErrInvalidScope
}
query := models.GetDataSourceQuery{Uid: dsUID, OrgId: orgID}
if err := db.GetDataSource(ctx, &query); err != nil {
return "", err
}
return accesscontrol.Scope("datasources", "id", strconv.FormatInt(query.Result.Id, 10)), nil
}
return prefix, dsUIDResolver
}
func (s *Service) GetDataSource(ctx context.Context, query *models.GetDataSourceQuery) error {
+91 -9
View File
@@ -69,19 +69,30 @@ func TestService(t *testing.T) {
}
type dataSourceMockRetriever struct {
res *models.DataSource
res []*models.DataSource
}
func (d *dataSourceMockRetriever) GetDataSource(ctx context.Context, query *models.GetDataSourceQuery) error {
if query.Name == d.res.Name {
query.Result = d.res
for _, datasource := range d.res {
nameMatch := query.Name != "" && query.Name == datasource.Name
uidMatch := query.Uid != "" && query.Uid == datasource.Uid
if nameMatch || uidMatch {
query.Result = datasource
return nil
return nil
}
}
return models.ErrDataSourceNotFound
}
func TestService_NameScopeResolver(t *testing.T) {
retriever := &dataSourceMockRetriever{[]*models.DataSource{
{Id: 1, Name: "test-datasource"},
{Id: 2, Name: "*"},
{Id: 3, Name: ":/*"},
{Id: 4, Name: ":"},
}}
type testCaseResolver struct {
desc string
given string
@@ -97,9 +108,21 @@ func TestService_NameScopeResolver(t *testing.T) {
wantErr: nil,
},
{
desc: "correct",
desc: "asterisk in name",
given: "datasources:name:*",
want: "datasources:id:*",
want: "datasources:id:2",
wantErr: nil,
},
{
desc: "complex name",
given: "datasources:name::/*",
want: "datasources:id:3",
wantErr: nil,
},
{
desc: "colon in name",
given: "datasources:name::",
want: "datasources:id:4",
wantErr: nil,
},
{
@@ -114,11 +137,70 @@ func TestService_NameScopeResolver(t *testing.T) {
want: "",
wantErr: accesscontrol.ErrInvalidScope,
},
{
desc: "empty name scope",
given: "datasources:name:",
want: "",
wantErr: accesscontrol.ErrInvalidScope,
},
}
prefix, resolver := NewNameScopeResolver(retriever)
require.Equal(t, "datasources:name:", prefix)
for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) {
resolved, err := resolver(context.Background(), 1, tc.given)
if tc.wantErr != nil {
require.Error(t, err)
require.Equal(t, tc.wantErr, err)
} else {
require.NoError(t, err)
require.Equal(t, tc.want, resolved)
}
})
}
}
func TestService_UIDScopeResolver(t *testing.T) {
retriever := &dataSourceMockRetriever{[]*models.DataSource{
{Id: 1, Uid: "NnftN9Lnz"},
}}
type testCaseResolver struct {
desc string
given string
want string
wantErr error
}
testDataSource := &models.DataSource{Id: 1, Name: "test-datasource"}
prefix, resolver := NewNameScopeResolver(&dataSourceMockRetriever{testDataSource})
require.Equal(t, "datasources:name:", prefix)
testCases := []testCaseResolver{
{
desc: "correct",
given: "datasources:uid:NnftN9Lnz",
want: "datasources:id:1",
wantErr: nil,
},
{
desc: "unknown datasource",
given: "datasources:uid:unknown",
want: "",
wantErr: models.ErrDataSourceNotFound,
},
{
desc: "malformed scope",
given: "datasources:unknown",
want: "",
wantErr: accesscontrol.ErrInvalidScope,
},
{
desc: "empty uid scope",
given: "datasources:uid:",
want: "",
wantErr: accesscontrol.ErrInvalidScope,
},
}
prefix, resolver := NewUidScopeResolver(retriever)
require.Equal(t, "datasources:uid:", prefix)
for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) {
@@ -150,9 +150,10 @@ func (c *PostableRuleGroupConfig) validate() error {
// swagger:model
type GettableRuleGroupConfig struct {
Name string `yaml:"name" json:"name"`
Interval model.Duration `yaml:"interval,omitempty" json:"interval,omitempty"`
Rules []GettableExtendedRuleNode `yaml:"rules" json:"rules"`
Name string `yaml:"name" json:"name"`
Interval model.Duration `yaml:"interval,omitempty" json:"interval,omitempty"`
SourceTenants []string `yaml:"source_tenants,omitempty" json:"source_tenants,omitempty"`
Rules []GettableExtendedRuleNode `yaml:"rules" json:"rules"`
}
func (c *GettableRuleGroupConfig) UnmarshalJSON(b []byte) error {
@@ -98,6 +98,21 @@ func Test_Rule_Group_Marshaling(t *testing.T) {
},
},
},
{
desc: "success federated lotex",
input: PostableRuleGroupConfig{
Name: "foo",
Interval: 0,
Rules: []PostableExtendedRuleNode{
{
ApiRuleNode: &ApiRuleNode{},
},
{
ApiRuleNode: &ApiRuleNode{},
},
},
},
},
{
desc: "success grafana",
input: PostableRuleGroupConfig{
@@ -189,6 +204,22 @@ func Test_Rule_Group_Type(t *testing.T) {
},
expected: LoTexRulerBackend,
},
{
desc: "success federated lotex",
input: PostableRuleGroupConfig{
Name: "foo",
Interval: 0,
Rules: []PostableExtendedRuleNode{
{
ApiRuleNode: &ApiRuleNode{},
},
{
ApiRuleNode: &ApiRuleNode{},
},
},
},
expected: LoTexRulerBackend,
},
{
desc: "success grafana",
input: PostableRuleGroupConfig{
+22 -7
View File
@@ -978,6 +978,13 @@
},
"type": "array",
"x-go-name": "Rules"
},
"source_tenants": {
"items": {
"type": "string"
},
"type": "array",
"x-go-name": "SourceTenants"
}
},
"type": "object",
@@ -2260,6 +2267,13 @@
},
"type": "array",
"x-go-name": "Rules"
},
"source_tenants": {
"items": {
"type": "string"
},
"type": "array",
"x-go-name": "SourceTenants"
}
},
"type": "object",
@@ -3229,12 +3243,11 @@
"type": "object"
},
"gettableAlerts": {
"description": "GettableAlerts gettable alerts",
"items": {
"$ref": "#/definitions/gettableAlert"
},
"type": "array",
"x-go-name": "GettableAlerts",
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
"type": "array"
},
"gettableSilence": {
"description": "GettableSilence gettable silence",
@@ -3426,7 +3439,6 @@
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
},
"postableSilence": {
"description": "PostableSilence postable silence",
"properties": {
"comment": {
"description": "comment",
@@ -3466,10 +3478,11 @@
"matchers",
"startsAt"
],
"type": "object"
"type": "object",
"x-go-name": "PostableSilence",
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
},
"receiver": {
"description": "Receiver receiver",
"properties": {
"name": {
"description": "name",
@@ -3480,7 +3493,9 @@
"required": [
"name"
],
"type": "object"
"type": "object",
"x-go-name": "Receiver",
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
},
"silence": {
"description": "Silence silence",
+19 -4
View File
@@ -2001,6 +2001,13 @@
"$ref": "#/definitions/GettableExtendedRuleNode"
},
"x-go-name": "Rules"
},
"source_tenants": {
"type": "array",
"items": {
"type": "string"
},
"x-go-name": "SourceTenants"
}
},
"x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
@@ -3284,6 +3291,13 @@
"$ref": "#/definitions/GettableExtendedRuleNode"
},
"x-go-name": "Rules"
},
"source_tenants": {
"type": "array",
"items": {
"type": "string"
},
"x-go-name": "SourceTenants"
}
},
"x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
@@ -4255,12 +4269,11 @@
"$ref": "#/definitions/gettableAlert"
},
"gettableAlerts": {
"description": "GettableAlerts gettable alerts",
"type": "array",
"items": {
"$ref": "#/definitions/gettableAlert"
},
"x-go-name": "GettableAlerts",
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models",
"$ref": "#/definitions/gettableAlerts"
},
"gettableSilence": {
@@ -4455,7 +4468,6 @@
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
},
"postableSilence": {
"description": "PostableSilence postable silence",
"type": "object",
"required": [
"comment",
@@ -4496,10 +4508,11 @@
"x-go-name": "StartsAt"
}
},
"x-go-name": "PostableSilence",
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models",
"$ref": "#/definitions/postableSilence"
},
"receiver": {
"description": "Receiver receiver",
"type": "object",
"required": [
"name"
@@ -4511,6 +4524,8 @@
"x-go-name": "Name"
}
},
"x-go-name": "Receiver",
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models",
"$ref": "#/definitions/receiver"
},
"silence": {
@@ -27,6 +27,8 @@ type Dialect interface {
BooleanStr(bool) string
DateTimeFunc(string) string
OrderBy(order string) string
CreateIndexSQL(tableName string, index *Index) string
CreateTableSQL(table *Table) string
AddColumnSQL(tableName string, col *Column) string
@@ -308,3 +310,7 @@ func (b *BaseDialect) Lock(_ LockCfg) error {
func (b *BaseDialect) Unlock(_ LockCfg) error {
return nil
}
func (b *BaseDialect) OrderBy(order string) string {
return order
}
@@ -284,6 +284,19 @@ func (db *PostgresDialect) Lock(cfg LockCfg) error {
return nil
}
// OrderBy normalizes ordering so that nulls end up last in sorting, which they do by default in both sqlite and mysql but not postgres
// order should be a string like `dashboard.id ASC`
func (db *PostgresDialect) OrderBy(order string) string {
nullSort := "FIRST"
normalizedOrder := strings.ToUpper(strings.TrimSpace(order))
if strings.HasSuffix(normalizedOrder, " DESC") {
nullSort = "LAST"
}
return fmt.Sprintf("%s NULLS %s", order, nullSort)
}
func (db *PostgresDialect) Unlock(cfg LockCfg) error {
// trying to release a previously-acquired exclusive session level advisory lock.
// it will either return true if the lock is successfully released or
+6 -1
View File
@@ -140,7 +140,12 @@ func (b *Builder) applyFilters() (ordering string) {
b.params = append(b.params, groupParams...)
}
orderBy := fmt.Sprintf(" ORDER BY %s", strings.Join(orders, ", "))
orderByCols := []string{}
for _, o := range orders {
orderByCols = append(orderByCols, b.Dialect.OrderBy(o))
}
orderBy := fmt.Sprintf(" ORDER BY %s", strings.Join(orderByCols, ", "))
b.sql.WriteString(orderBy)
order := strings.Join(orderJoins, "")
+3
View File
@@ -305,6 +305,7 @@ type Cfg struct {
AuthProxyEnableLoginToken bool
AuthProxyWhitelist string
AuthProxyHeaders map[string]string
AuthProxyHeadersEncoded bool
AuthProxySyncTTL int
// OAuth
@@ -1321,6 +1322,8 @@ func readAuthSettings(iniFile *ini.File, cfg *Cfg) (err error) {
}
}
cfg.AuthProxyHeadersEncoded = authProxy.Key("headers_encoded").MustBool(false)
return nil
}
+10 -2
View File
@@ -72,6 +72,8 @@ func ProvideService(httpClientProvider httpclient.Provider, tracer tracing.Trace
tracer: tracer,
httpClientProvider: httpClientProvider,
im: datasource.NewInstanceManager(newInstanceSettings(httpClientProvider)),
gceDefaultProjectGetter: utils.GCEDefaultProject,
}
s.resourceHandler = httpadapter.New(s.newResourceMux())
@@ -91,7 +93,10 @@ func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthReque
defaultProject, err := s.getDefaultProject(ctx, *dsInfo)
if err != nil {
return nil, err
return &backend.CheckHealthResult{
Status: backend.HealthStatusError,
Message: err.Error(),
}, nil
}
url := fmt.Sprintf("%v/v3/projects/%v/metricDescriptors", dsInfo.services[cloudMonitor].url, defaultProject)
@@ -128,6 +133,9 @@ type Service struct {
tracer tracing.Tracer
resourceHandler backend.CallResourceHandler
// mocked in tests
gceDefaultProjectGetter func(ctx context.Context) (string, error)
}
type QueryModel struct {
@@ -612,7 +620,7 @@ func (s *Service) createRequest(ctx context.Context, dsInfo *datasourceInfo, pro
func (s *Service) getDefaultProject(ctx context.Context, dsInfo datasourceInfo) (string, error) {
if dsInfo.authenticationType == gceAuthentication {
return utils.GCEDefaultProject(ctx)
return s.gceDefaultProjectGetter(ctx)
}
return dsInfo.defaultProject, nil
}
@@ -1,7 +1,9 @@
package cloudmonitoring
import (
"context"
"encoding/json"
"fmt"
"net/url"
"reflect"
"strings"
@@ -9,6 +11,8 @@ import (
"time"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/backend/datasource"
"github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -933,3 +937,29 @@ func baseReq() *backend.QueryDataRequest {
}
return query
}
func TestCheckHealth(t *testing.T) {
t.Run("and using GCE authentation should return proper error", func(t *testing.T) {
im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) {
return &datasourceInfo{
authenticationType: gceAuthentication,
}, nil
})
service := &Service{
im: im,
gceDefaultProjectGetter: func(ctx context.Context) (string, error) {
return "", fmt.Errorf("not found!")
},
}
res, err := service.CheckHealth(context.Background(), &backend.CheckHealthRequest{
PluginContext: backend.PluginContext{
DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{},
},
})
assert.Nil(t, err)
assert.Equal(t, &backend.CheckHealthResult{
Status: backend.HealthStatusError,
Message: "not found!",
}, res)
})
}
+6 -6
View File
@@ -22,13 +22,13 @@ func TestQuery_AnnotationQuery(t *testing.T) {
NewCWClient = origNewCWClient
})
var client FakeCWAnnotationsClient
var client fakeCWAnnotationsClient
NewCWClient = func(sess *session.Session) cloudwatchiface.CloudWatchAPI {
return &client
}
t.Run("DescribeAlarmsForMetric is called with minimum parameters", func(t *testing.T) {
client = FakeCWAnnotationsClient{describeAlarmsForMetricOutput: &cloudwatch.DescribeAlarmsForMetricOutput{}}
client = fakeCWAnnotationsClient{describeAlarmsForMetricOutput: &cloudwatch.DescribeAlarmsForMetricOutput{}}
im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) {
return datasourceInfo{}, nil
})
@@ -62,7 +62,7 @@ func TestQuery_AnnotationQuery(t *testing.T) {
})
t.Run("DescribeAlarms is called when prefixMatching is true", func(t *testing.T) {
client = FakeCWAnnotationsClient{describeAlarmsOutput: &cloudwatch.DescribeAlarmsOutput{}}
client = fakeCWAnnotationsClient{describeAlarmsOutput: &cloudwatch.DescribeAlarmsOutput{}}
im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) {
return datasourceInfo{}, nil
})
@@ -91,9 +91,9 @@ func TestQuery_AnnotationQuery(t *testing.T) {
require.Len(t, client.calls.describeAlarms, 1)
assert.Equal(t, &cloudwatch.DescribeAlarmsInput{
MaxRecords: pointerInt64(100),
ActionPrefix: pointerString("some_action_prefix"),
AlarmNamePrefix: pointerString("some_alarm_name_prefix"),
MaxRecords: aws.Int64(100),
ActionPrefix: aws.String("some_action_prefix"),
AlarmNamePrefix: aws.String("some_alarm_name_prefix"),
}, client.calls.describeAlarms[0])
})
}
+2 -1
View File
@@ -171,7 +171,8 @@ func (e *cloudWatchExecutor) newSession(pluginCtx backend.PluginContext, region
}
return e.sessions.GetSession(awsds.SessionConfig{
HTTPClient: dsInfo.HTTPClient,
// https://github.com/grafana/grafana/issues/46365
// HTTPClient: dsInfo.HTTPClient,
Settings: awsds.AWSDatasourceSettings{
Profile: dsInfo.profile,
Region: region,
+21 -21
View File
@@ -25,14 +25,14 @@ func TestQuery_DescribeLogGroups(t *testing.T) {
NewCWLogsClient = origNewCWLogsClient
})
var cli FakeCWLogsClient
var cli fakeCWLogsClient
NewCWLogsClient = func(sess *session.Session) cloudwatchlogsiface.CloudWatchLogsAPI {
return &cli
}
t.Run("Empty log group name prefix", func(t *testing.T) {
cli = FakeCWLogsClient{
cli = fakeCWLogsClient{
logGroups: cloudwatchlogs.DescribeLogGroupsOutput{
LogGroups: []*cloudwatchlogs.LogGroup{
{
@@ -88,7 +88,7 @@ func TestQuery_DescribeLogGroups(t *testing.T) {
})
t.Run("Non-empty log group name prefix", func(t *testing.T) {
cli = FakeCWLogsClient{
cli = fakeCWLogsClient{
logGroups: cloudwatchlogs.DescribeLogGroupsOutput{
LogGroups: []*cloudwatchlogs.LogGroup{
{
@@ -152,13 +152,13 @@ func TestQuery_GetLogGroupFields(t *testing.T) {
NewCWLogsClient = origNewCWLogsClient
})
var cli FakeCWLogsClient
var cli fakeCWLogsClient
NewCWLogsClient = func(sess *session.Session) cloudwatchlogsiface.CloudWatchLogsAPI {
return &cli
}
cli = FakeCWLogsClient{
cli = fakeCWLogsClient{
logGroupFields: cloudwatchlogs.GetLogGroupFieldsOutput{
LogGroupFields: []*cloudwatchlogs.LogGroupField{
{
@@ -229,14 +229,14 @@ func TestQuery_StartQuery(t *testing.T) {
NewCWLogsClient = origNewCWLogsClient
})
var cli FakeCWLogsClient
var cli fakeCWLogsClient
NewCWLogsClient = func(sess *session.Session) cloudwatchlogsiface.CloudWatchLogsAPI {
return &cli
}
t.Run("invalid time range", func(t *testing.T) {
cli = FakeCWLogsClient{
cli = fakeCWLogsClient{
logGroupFields: cloudwatchlogs.GetLogGroupFieldsOutput{
LogGroupFields: []*cloudwatchlogs.LogGroupField{
{
@@ -289,7 +289,7 @@ func TestQuery_StartQuery(t *testing.T) {
t.Run("valid time range", func(t *testing.T) {
const refID = "A"
cli = FakeCWLogsClient{
cli = fakeCWLogsClient{
logGroupFields: cloudwatchlogs.GetLogGroupFieldsOutput{
LogGroupFields: []*cloudwatchlogs.LogGroupField{
{
@@ -363,14 +363,14 @@ func Test_executeStartQuery(t *testing.T) {
NewCWLogsClient = origNewCWLogsClient
})
var cli FakeCWLogsClient
var cli fakeCWLogsClient
NewCWLogsClient = func(sess *session.Session) cloudwatchlogsiface.CloudWatchLogsAPI {
return &cli
}
t.Run("successfully parses information from JSON to StartQueryWithContext", func(t *testing.T) {
cli = FakeCWLogsClient{}
cli = fakeCWLogsClient{}
im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) {
return datasourceInfo{}, nil
})
@@ -396,17 +396,17 @@ func Test_executeStartQuery(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, []*cloudwatchlogs.StartQueryInput{
{
StartTime: pointerInt64(0),
EndTime: pointerInt64(1),
Limit: pointerInt64(12),
QueryString: pointerString("fields @timestamp,ltrim(@log) as __log__grafana_internal__,ltrim(@logStream) as __logstream__grafana_internal__|fields @message"),
LogGroupNames: []*string{pointerString("some name"), pointerString("another name")},
StartTime: aws.Int64(0),
EndTime: aws.Int64(1),
Limit: aws.Int64(12),
QueryString: aws.String("fields @timestamp,ltrim(@log) as __log__grafana_internal__,ltrim(@logStream) as __logstream__grafana_internal__|fields @message"),
LogGroupNames: []*string{aws.String("some name"), aws.String("another name")},
},
}, cli.calls.startQueryWithContext)
})
t.Run("cannot parse limit as float", func(t *testing.T) {
cli = FakeCWLogsClient{}
cli = fakeCWLogsClient{}
im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) {
return datasourceInfo{}, nil
})
@@ -433,7 +433,7 @@ func Test_executeStartQuery(t *testing.T) {
})
t.Run("does not populate StartQueryInput.limit when no limit provided", func(t *testing.T) {
cli = FakeCWLogsClient{}
cli = fakeCWLogsClient{}
im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) {
return datasourceInfo{}, nil
})
@@ -465,13 +465,13 @@ func TestQuery_StopQuery(t *testing.T) {
NewCWLogsClient = origNewCWLogsClient
})
var cli FakeCWLogsClient
var cli fakeCWLogsClient
NewCWLogsClient = func(sess *session.Session) cloudwatchlogsiface.CloudWatchLogsAPI {
return &cli
}
cli = FakeCWLogsClient{
cli = fakeCWLogsClient{
logGroupFields: cloudwatchlogs.GetLogGroupFieldsOutput{
LogGroupFields: []*cloudwatchlogs.LogGroupField{
{
@@ -537,14 +537,14 @@ func TestQuery_GetQueryResults(t *testing.T) {
NewCWLogsClient = origNewCWLogsClient
})
var cli FakeCWLogsClient
var cli fakeCWLogsClient
NewCWLogsClient = func(sess *session.Session) cloudwatchlogsiface.CloudWatchLogsAPI {
return &cli
}
const refID = "A"
cli = FakeCWLogsClient{
cli = fakeCWLogsClient{
queryResults: cloudwatchlogs.GetQueryResultsOutput{
Results: [][]*cloudwatchlogs.ResultField{
{
@@ -29,14 +29,14 @@ func TestQuery_Metrics(t *testing.T) {
NewCWClient = origNewCWClient
})
var cwClient FakeCWClient
var cwClient fakeCWClient
NewCWClient = func(sess *session.Session) cloudwatchiface.CloudWatchAPI {
return cwClient
}
t.Run("Custom metrics", func(t *testing.T) {
cwClient = FakeCWClient{
cwClient = fakeCWClient{
Metrics: []*cloudwatch.Metric{
{
MetricName: aws.String("Test_MetricName"),
@@ -91,7 +91,7 @@ func TestQuery_Metrics(t *testing.T) {
})
t.Run("Dimension keys for custom metrics", func(t *testing.T) {
cwClient = FakeCWClient{
cwClient = fakeCWClient{
Metrics: []*cloudwatch.Metric{
{
MetricName: aws.String("Test_MetricName"),
@@ -503,7 +503,7 @@ func TestQuery_GetDimensionKeys(t *testing.T) {
NewCWClient = origNewCWClient
})
var client FakeCWClient
var client fakeCWClient
NewCWClient = func(sess *session.Session) cloudwatchiface.CloudWatchAPI {
return client
@@ -521,7 +521,7 @@ func TestQuery_GetDimensionKeys(t *testing.T) {
}
t.Run("should fetch dimension keys from list metrics api and return unique dimensions when a dimension filter is specified", func(t *testing.T) {
client = FakeCWClient{Metrics: metrics, MetricsPerPage: 2}
client = fakeCWClient{Metrics: metrics, MetricsPerPage: 2}
im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) {
return datasourceInfo{}, nil
})
@@ -662,7 +662,7 @@ func TestQuery_ListMetricsPagination(t *testing.T) {
NewCWClient = origNewCWClient
})
var client FakeCWClient
var client fakeCWClient
NewCWClient = func(sess *session.Session) cloudwatchiface.CloudWatchAPI {
return client
@@ -682,7 +682,7 @@ func TestQuery_ListMetricsPagination(t *testing.T) {
}
t.Run("List Metrics and page limit is reached", func(t *testing.T) {
client = FakeCWClient{Metrics: metrics, MetricsPerPage: 2}
client = fakeCWClient{Metrics: metrics, MetricsPerPage: 2}
im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) {
return datasourceInfo{}, nil
})
@@ -697,7 +697,7 @@ func TestQuery_ListMetricsPagination(t *testing.T) {
})
t.Run("List Metrics and page limit is not reached", func(t *testing.T) {
client = FakeCWClient{Metrics: metrics, MetricsPerPage: 2}
client = fakeCWClient{Metrics: metrics, MetricsPerPage: 2}
im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) {
return datasourceInfo{}, nil
})
+9 -1
View File
@@ -10,10 +10,13 @@ import (
"strings"
"time"
"github.com/google/uuid"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana/pkg/components/simplejson"
)
var validMetricDataID = regexp.MustCompile(`^[a-z][a-zA-Z0-9_]*$`)
// parseQueries parses the json queries and returns a map of cloudWatchQueries by region. The cloudWatchQuery has a 1 to 1 mapping to a query editor row
func (e *cloudWatchExecutor) parseQueries(queries []backend.DataQuery, startTime time.Time, endTime time.Time) (map[string][]*cloudWatchQuery, error) {
requestQueries := make(map[string][]*cloudWatchQuery)
@@ -140,7 +143,12 @@ func parseRequestQuery(model *simplejson.Json, refId string, startTime time.Time
// Why not just use refId if id is not specified in the frontend? When specifying an id in the editor,
// and alphabetical must be used. The id must be unique, so if an id like for example a, b or c would be used,
// it would likely collide with some ref id. That's why the `query` prefix is used.
id = fmt.Sprintf("query%s", refId)
suffix := refId
if !validMetricDataID.MatchString(suffix) {
uuid := uuid.NewString()
suffix = strings.Replace(uuid, "-", "", -1)
}
id = fmt.Sprintf("query%s", suffix)
}
expression := model.Get("expression").MustString("")
sqlExpression := model.Get("sqlExpression").MustString("")
@@ -293,6 +293,23 @@ func TestRequestParser(t *testing.T) {
assert.Equal(t, GMDApiModeMathExpression, res.getGMDAPIMode())
})
})
t.Run("ID is the string `query` appended with refId if refId is a valid MetricData ID", func(t *testing.T) {
query := getBaseJsonQuery()
res, err := parseRequestQuery(query, "ref1", time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour))
require.NoError(t, err)
assert.Equal(t, "ref1", res.RefId)
assert.Equal(t, "queryref1", res.Id)
})
t.Run("Valid id is generated if ID is not provided and refId is not a valid MetricData ID", func(t *testing.T) {
query := getBaseJsonQuery()
query.Set("refId", "$$")
res, err := parseRequestQuery(query, "$$", time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour))
require.NoError(t, err)
assert.Equal(t, "$$", res.RefId)
assert.Regexp(t, validMetricDataID, res.Id)
})
}
func getBaseJsonQuery() *simplejson.Json {
@@ -27,14 +27,14 @@ func TestTimeSeriesQuery(t *testing.T) {
NewCWClient = origNewCWClient
})
var cwClient FakeCWClient
var cwClient fakeCWClient
NewCWClient = func(sess *session.Session) cloudwatchiface.CloudWatchAPI {
return cwClient
}
t.Run("Custom metrics", func(t *testing.T) {
cwClient = FakeCWClient{
cwClient = fakeCWClient{
CloudWatchAPI: nil,
GetMetricDataOutput: cloudwatch.GetMetricDataOutput{
NextToken: nil,
@@ -18,7 +18,7 @@ import (
"github.com/grafana/grafana/pkg/setting"
)
type FakeCWLogsClient struct {
type fakeCWLogsClient struct {
cloudwatchlogsiface.CloudWatchLogsAPI
calls logsQueryCalls
@@ -32,11 +32,11 @@ type logsQueryCalls struct {
startQueryWithContext []*cloudwatchlogs.StartQueryInput
}
func (m *FakeCWLogsClient) GetQueryResultsWithContext(ctx context.Context, input *cloudwatchlogs.GetQueryResultsInput, option ...request.Option) (*cloudwatchlogs.GetQueryResultsOutput, error) {
func (m *fakeCWLogsClient) GetQueryResultsWithContext(ctx context.Context, input *cloudwatchlogs.GetQueryResultsInput, option ...request.Option) (*cloudwatchlogs.GetQueryResultsOutput, error) {
return &m.queryResults, nil
}
func (m *FakeCWLogsClient) StartQueryWithContext(ctx context.Context, input *cloudwatchlogs.StartQueryInput, option ...request.Option) (*cloudwatchlogs.StartQueryOutput, error) {
func (m *fakeCWLogsClient) StartQueryWithContext(ctx context.Context, input *cloudwatchlogs.StartQueryInput, option ...request.Option) (*cloudwatchlogs.StartQueryOutput, error) {
m.calls.startQueryWithContext = append(m.calls.startQueryWithContext, input)
return &cloudwatchlogs.StartQueryOutput{
@@ -44,21 +44,21 @@ func (m *FakeCWLogsClient) StartQueryWithContext(ctx context.Context, input *clo
}, nil
}
func (m *FakeCWLogsClient) StopQueryWithContext(ctx context.Context, input *cloudwatchlogs.StopQueryInput, option ...request.Option) (*cloudwatchlogs.StopQueryOutput, error) {
func (m *fakeCWLogsClient) StopQueryWithContext(ctx context.Context, input *cloudwatchlogs.StopQueryInput, option ...request.Option) (*cloudwatchlogs.StopQueryOutput, error) {
return &cloudwatchlogs.StopQueryOutput{
Success: aws.Bool(true),
}, nil
}
func (m *FakeCWLogsClient) DescribeLogGroupsWithContext(ctx context.Context, input *cloudwatchlogs.DescribeLogGroupsInput, option ...request.Option) (*cloudwatchlogs.DescribeLogGroupsOutput, error) {
func (m *fakeCWLogsClient) DescribeLogGroupsWithContext(ctx context.Context, input *cloudwatchlogs.DescribeLogGroupsInput, option ...request.Option) (*cloudwatchlogs.DescribeLogGroupsOutput, error) {
return &m.logGroups, nil
}
func (m *FakeCWLogsClient) GetLogGroupFieldsWithContext(ctx context.Context, input *cloudwatchlogs.GetLogGroupFieldsInput, option ...request.Option) (*cloudwatchlogs.GetLogGroupFieldsOutput, error) {
func (m *fakeCWLogsClient) GetLogGroupFieldsWithContext(ctx context.Context, input *cloudwatchlogs.GetLogGroupFieldsInput, option ...request.Option) (*cloudwatchlogs.GetLogGroupFieldsOutput, error) {
return &m.logGroupFields, nil
}
type FakeCWClient struct {
type fakeCWClient struct {
cloudwatchiface.CloudWatchAPI
cloudwatch.GetMetricDataOutput
@@ -67,11 +67,11 @@ type FakeCWClient struct {
MetricsPerPage int
}
func (c FakeCWClient) GetMetricDataWithContext(aws.Context, *cloudwatch.GetMetricDataInput, ...request.Option) (*cloudwatch.GetMetricDataOutput, error) {
func (c fakeCWClient) GetMetricDataWithContext(aws.Context, *cloudwatch.GetMetricDataInput, ...request.Option) (*cloudwatch.GetMetricDataOutput, error) {
return &c.GetMetricDataOutput, nil
}
func (c FakeCWClient) ListMetricsPages(input *cloudwatch.ListMetricsInput, fn func(*cloudwatch.ListMetricsOutput, bool) bool) error {
func (c fakeCWClient) ListMetricsPages(input *cloudwatch.ListMetricsInput, fn func(*cloudwatch.ListMetricsOutput, bool) bool) error {
if c.MetricsPerPage == 0 {
c.MetricsPerPage = 1000
}
@@ -88,7 +88,7 @@ func (c FakeCWClient) ListMetricsPages(input *cloudwatch.ListMetricsInput, fn fu
return nil
}
type FakeCWAnnotationsClient struct {
type fakeCWAnnotationsClient struct {
cloudwatchiface.CloudWatchAPI
calls annontationsQueryCalls
@@ -101,13 +101,13 @@ type annontationsQueryCalls struct {
describeAlarms []*cloudwatch.DescribeAlarmsInput
}
func (c *FakeCWAnnotationsClient) DescribeAlarmsForMetric(params *cloudwatch.DescribeAlarmsForMetricInput) (*cloudwatch.DescribeAlarmsForMetricOutput, error) {
func (c *fakeCWAnnotationsClient) DescribeAlarmsForMetric(params *cloudwatch.DescribeAlarmsForMetricInput) (*cloudwatch.DescribeAlarmsForMetricOutput, error) {
c.calls.describeAlarmsForMetric = append(c.calls.describeAlarmsForMetric, params)
return c.describeAlarmsForMetricOutput, nil
}
func (c *FakeCWAnnotationsClient) DescribeAlarms(params *cloudwatch.DescribeAlarmsInput) (*cloudwatch.DescribeAlarmsOutput, error) {
func (c *fakeCWAnnotationsClient) DescribeAlarms(params *cloudwatch.DescribeAlarmsInput) (*cloudwatch.DescribeAlarmsOutput, error) {
c.calls.describeAlarms = append(c.calls.describeAlarms, params)
return c.describeAlarmsOutput, nil
@@ -201,10 +201,3 @@ func (s fakeSessionCache) GetSession(c awsds.SessionConfig) (*session.Session, e
Config: &aws.Config{},
}, nil
}
func pointerString(s string) *string {
return &s
}
func pointerInt64(i int64) *int64 {
return &i
}
+20 -20
View File
@@ -7,15 +7,15 @@ Frame[0] {
}
Name: prometheus_http_requests_total{code="200", handler="/api/v1/query_range", job="prometheus"}
Dimensions: 2 Fields by 3 Rows
+-------------------------------+--------------------------------------------------------------------------------------------------------+
| Name: Time | Name: Value |
| Labels: | Labels: __name__=prometheus_http_requests_total, code=200, handler=/api/v1/query_range, job=prometheus |
| Type: []time.Time | Type: []*float64 |
+-------------------------------+--------------------------------------------------------------------------------------------------------+
| 2022-01-11 08:25:30 +0000 UTC | 21 |
| 2022-01-11 08:25:31 +0000 UTC | 32 |
| 2022-01-11 08:25:32 +0000 UTC | 43 |
+-------------------------------+--------------------------------------------------------------------------------------------------------+
+-----------------------------------+--------------------------------------------------------------------------------------------------------+
| Name: Time | Name: Value |
| Labels: | Labels: __name__=prometheus_http_requests_total, code=200, handler=/api/v1/query_range, job=prometheus |
| Type: []time.Time | Type: []*float64 |
+-----------------------------------+--------------------------------------------------------------------------------------------------------+
| 2022-01-11 08:25:30.123 +0000 UTC | 21 |
| 2022-01-11 08:25:31.123 +0000 UTC | 32 |
| 2022-01-11 08:25:32.123 +0000 UTC | 43 |
+-----------------------------------+--------------------------------------------------------------------------------------------------------+
@@ -26,17 +26,17 @@ Frame[1] {
}
Name: prometheus_http_requests_total{code="400", handler="/api/v1/query_range", job="prometheus"}
Dimensions: 2 Fields by 3 Rows
+-------------------------------+--------------------------------------------------------------------------------------------------------+
| Name: Time | Name: Value |
| Labels: | Labels: __name__=prometheus_http_requests_total, code=400, handler=/api/v1/query_range, job=prometheus |
| Type: []time.Time | Type: []*float64 |
+-------------------------------+--------------------------------------------------------------------------------------------------------+
| 2022-01-11 08:25:30 +0000 UTC | 54 |
| 2022-01-11 08:25:31 +0000 UTC | 65 |
| 2022-01-11 08:25:32 +0000 UTC | 76 |
+-------------------------------+--------------------------------------------------------------------------------------------------------+
+-----------------------------------+--------------------------------------------------------------------------------------------------------+
| Name: Time | Name: Value |
| Labels: | Labels: __name__=prometheus_http_requests_total, code=400, handler=/api/v1/query_range, job=prometheus |
| Type: []time.Time | Type: []*float64 |
+-----------------------------------+--------------------------------------------------------------------------------------------------------+
| 2022-01-11 08:25:30.123 +0000 UTC | 54 |
| 2022-01-11 08:25:31.123 +0000 UTC | 65 |
| 2022-01-11 08:25:32.123 +0000 UTC | 76 |
+-----------------------------------+--------------------------------------------------------------------------------------------------------+
====== TEST DATA RESPONSE (arrow base64) ======
FRAME=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
FRAME=QVJST1cxAAD/////eAMAABAAAAAAAAoADgAMAAsABAAKAAAAFAAAAAAAAAEEAAoADAAAAAgABAAKAAAACAAAAOwAAAADAAAApAAAACgAAAAEAAAAKP3//wgAAAAMAAAAAAAAAAAAAAAFAAAAcmVmSWQAAABI/f//CAAAAGQAAABbAAAAcHJvbWV0aGV1c19odHRwX3JlcXVlc3RzX3RvdGFse2NvZGU9IjQwMCIsIGhhbmRsZXI9Ii9hcGkvdjEvcXVlcnlfcmFuZ2UiLCBqb2I9InByb21ldGhldXMifQAEAAAAbmFtZQAAAADA/f//CAAAACwAAAAiAAAAeyJjdXN0b20iOnsicmVzdWx0VHlwZSI6Im1hdHJpeCJ9fQAABAAAAG1ldGEAAAAAAgAAALQBAAAYAAAAAAASABgAFAATABIADAAAAAgABAASAAAAFAAAAGgBAABoAQAAAAADAWgBAAADAAAAvAAAACwAAAAEAAAASP7//wgAAAAQAAAABQAAAFZhbHVlAAAABAAAAG5hbWUAAAAAbP7//wgAAAB4AAAAbQAAAHsiX19uYW1lX18iOiJwcm9tZXRoZXVzX2h0dHBfcmVxdWVzdHNfdG90YWwiLCJjb2RlIjoiNDAwIiwiaGFuZGxlciI6Ii9hcGkvdjEvcXVlcnlfcmFuZ2UiLCJqb2IiOiJwcm9tZXRoZXVzIn0AAAAGAAAAbGFiZWxzAAD4/v//CAAAAIQAAAB5AAAAeyJkaXNwbGF5TmFtZUZyb21EUyI6InByb21ldGhldXNfaHR0cF9yZXF1ZXN0c190b3RhbHtjb2RlPVwiNDAwXCIsIGhhbmRsZXI9XCIvYXBpL3YxL3F1ZXJ5X3JhbmdlXCIsIGpvYj1cInByb21ldGhldXNcIn0ifQAAAAYAAABjb25maWcAAAAAAABW////AAACAAUAAABWYWx1ZQASABgAFAAAABMADAAAAAgABAASAAAAFAAAAHgAAACAAAAAAAAACoAAAAACAAAANAAAAAQAAADc////CAAAABAAAAAEAAAAVGltZQAAAAAEAAAAbmFtZQAAAAAIAAwACAAEAAgAAAAIAAAAHAAAABEAAAB7ImludGVydmFsIjoxMDAwfQAAAAYAAABjb25maWcAAAAAAAAAAAYACAAGAAYAAAAAAAMABAAAAFRpbWUAAAAAAAAAAP////+4AAAAFAAAAAAAAAAMABYAFAATAAwABAAMAAAAMAAAAAAAAAAUAAAAAAAAAwQACgAYAAwACAAEAAoAAAAUAAAAWAAAAAMAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAYAAAAAAAAABgAAAAAAAAAAAAAAAAAAAAYAAAAAAAAABgAAAAAAAAAAAAAAAIAAAADAAAAAAAAAAAAAAAAAAAAAwAAAAAAAAAAAAAAAAAAAABEFRTUKckWAA6wT9QpyRYA2EqL1CnJFgAAAAAAAEtAAAAAAABAUEAAAAAAAABTQBAAAAAMABQAEgAMAAgABAAMAAAAEAAAACwAAAA4AAAAAAAEAAEAAACIAwAAAAAAAMAAAAAAAAAAMAAAAAAAAAAAAAAAAAAAAAAACgAMAAAACAAEAAoAAAAIAAAA7AAAAAMAAACkAAAAKAAAAAQAAAAo/f//CAAAAAwAAAAAAAAAAAAAAAUAAAByZWZJZAAAAEj9//8IAAAAZAAAAFsAAABwcm9tZXRoZXVzX2h0dHBfcmVxdWVzdHNfdG90YWx7Y29kZT0iNDAwIiwgaGFuZGxlcj0iL2FwaS92MS9xdWVyeV9yYW5nZSIsIGpvYj0icHJvbWV0aGV1cyJ9AAQAAABuYW1lAAAAAMD9//8IAAAALAAAACIAAAB7ImN1c3RvbSI6eyJyZXN1bHRUeXBlIjoibWF0cml4In19AAAEAAAAbWV0YQAAAAACAAAAtAEAABgAAAAAABIAGAAUABMAEgAMAAAACAAEABIAAAAUAAAAaAEAAGgBAAAAAAMBaAEAAAMAAAC8AAAALAAAAAQAAABI/v//CAAAABAAAAAFAAAAVmFsdWUAAAAEAAAAbmFtZQAAAABs/v//CAAAAHgAAABtAAAAeyJfX25hbWVfXyI6InByb21ldGhldXNfaHR0cF9yZXF1ZXN0c190b3RhbCIsImNvZGUiOiI0MDAiLCJoYW5kbGVyIjoiL2FwaS92MS9xdWVyeV9yYW5nZSIsImpvYiI6InByb21ldGhldXMifQAAAAYAAABsYWJlbHMAAPj+//8IAAAAhAAAAHkAAAB7ImRpc3BsYXlOYW1lRnJvbURTIjoicHJvbWV0aGV1c19odHRwX3JlcXVlc3RzX3RvdGFse2NvZGU9XCI0MDBcIiwgaGFuZGxlcj1cIi9hcGkvdjEvcXVlcnlfcmFuZ2VcIiwgam9iPVwicHJvbWV0aGV1c1wifSJ9AAAABgAAAGNvbmZpZwAAAAAAAFb///8AAAIABQAAAFZhbHVlABIAGAAUAAAAEwAMAAAACAAEABIAAAAUAAAAeAAAAIAAAAAAAAAKgAAAAAIAAAA0AAAABAAAANz///8IAAAAEAAAAAQAAABUaW1lAAAAAAQAAABuYW1lAAAAAAgADAAIAAQACAAAAAgAAAAcAAAAEQAAAHsiaW50ZXJ2YWwiOjEwMDB9AAAABgAAAGNvbmZpZwAAAAAAAAAABgAIAAYABgAAAAAAAwAEAAAAVGltZQAAAACgAwAAQVJST1cx
FRAME=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
FRAME=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
+6 -6
View File
@@ -11,9 +11,9 @@
"job": "prometheus"
},
"values": [
[1641889530, "21"],
[1641889531, "32"],
[1641889532, "43"]
[1641889530.123, "21"],
[1641889531.123, "32"],
[1641889532.123, "43"]
]
},
{
@@ -24,9 +24,9 @@
"job": "prometheus"
},
"values": [
[1641889530, "54"],
[1641889531, "65"],
[1641889532, "76"]
[1641889530.123, "54"],
[1641889531.123, "65"],
[1641889532.123, "76"]
]
}
]
+4 -4
View File
@@ -318,7 +318,7 @@ func matrixToDataFrames(matrix model.Matrix, query *PrometheusQuery, frames data
valueField := data.NewFieldFromFieldType(data.FieldTypeNullableFloat64, len(v.Values))
for i, k := range v.Values {
timeField.Set(i, time.Unix(k.Timestamp.Unix(), 0).UTC())
timeField.Set(i, k.Timestamp.Time().UTC())
value := float64(k.Value)
if !math.IsNaN(value) {
@@ -339,7 +339,7 @@ func matrixToDataFrames(matrix model.Matrix, query *PrometheusQuery, frames data
}
func scalarToDataFrames(scalar *model.Scalar, query *PrometheusQuery, frames data.Frames) data.Frames {
timeVector := []time.Time{time.Unix(scalar.Timestamp.Unix(), 0).UTC()}
timeVector := []time.Time{scalar.Timestamp.Time().UTC()}
values := []float64{float64(scalar.Value)}
name := fmt.Sprintf("%g", values[0])
@@ -358,7 +358,7 @@ func vectorToDataFrames(vector model.Vector, query *PrometheusQuery, frames data
for _, v := range vector {
name := formatLegend(v.Metric, query)
tags := make(map[string]string, len(v.Metric))
timeVector := []time.Time{time.Unix(v.Timestamp.Unix(), 0).UTC()}
timeVector := []time.Time{v.Timestamp.Time().UTC()}
values := []float64{float64(v.Value)}
for k, v := range v.Metric {
@@ -387,7 +387,7 @@ func exemplarToDataFrames(response []apiv1.ExemplarQueryResult, query *Prometheu
for _, exemplarData := range response {
for _, exemplar := range exemplarData.Exemplars {
event := ExemplarEvent{}
exemplarTime := time.Unix(exemplar.Timestamp.Unix(), 0).UTC()
exemplarTime := exemplar.Timestamp.Time().UTC()
event.Time = exemplarTime
event.Value = float64(exemplar.Value)
event.Labels = make(map[string]string)
@@ -707,7 +707,7 @@ func TestPrometheus_parseTimeSeriesResponse(t *testing.T) {
&p.Sample{
Metric: p.Metric{"app": "Application", "tag2": "tag2"},
Value: 1,
Timestamp: 1000,
Timestamp: 123,
},
}
query := &PrometheusQuery{
@@ -730,13 +730,14 @@ func TestPrometheus_parseTimeSeriesResponse(t *testing.T) {
// Ensure the timestamps are UTC zoned
testValue := res[0].Fields[0].At(0)
require.Equal(t, "UTC", testValue.(time.Time).Location().String())
require.Equal(t, int64(123), testValue.(time.Time).UnixMilli())
})
t.Run("scalar response should be parsed normally", func(t *testing.T) {
value := make(map[TimeSeriesQueryType]interface{})
value[RangeQueryType] = &p.Scalar{
Value: 1,
Timestamp: 1000,
Timestamp: 123,
}
query := &PrometheusQuery{}
@@ -754,6 +755,7 @@ func TestPrometheus_parseTimeSeriesResponse(t *testing.T) {
// Ensure the timestamps are UTC zoned
testValue := res[0].Fields[0].At(0)
require.Equal(t, "UTC", testValue.(time.Time).Location().String())
require.Equal(t, int64(123), testValue.(time.Time).UnixMilli())
})
}
+11
View File
@@ -6,6 +6,8 @@ import (
"encoding/base64"
"encoding/hex"
"errors"
"io"
"mime/quotedprintable"
"strings"
"golang.org/x/crypto/pbkdf2"
@@ -71,3 +73,12 @@ func RandomHex(n int) (string, error) {
}
return hex.EncodeToString(bytes), nil
}
// decodeQuotedPrintable decodes quoted-printable UTF-8 string
func DecodeQuotedPrintable(encodedValue string) string {
decodedBytes, err := io.ReadAll(quotedprintable.NewReader(strings.NewReader(encodedValue)))
if err != nil {
return encodedValue
}
return string(decodedBytes)
}
+70
View File
@@ -32,3 +32,73 @@ func TestEncodePassword(t *testing.T) {
encodedPassword,
)
}
func TestDecodeQuotedPrintable(t *testing.T) {
t.Run("should return not encoded string as is", func(t *testing.T) {
testStrings := []struct {
in string
out string
}{
{"", ""},
{" ", ""},
{"munich", "munich"},
{" munich", " munich"},
{"munich gothenburg", "munich gothenburg"},
{"München", "München"},
{"München Göteborg", "München Göteborg"},
}
for _, str := range testStrings {
val := DecodeQuotedPrintable(str.in)
assert.Equal(t, str.out, val)
}
})
t.Run("should decode encoded string", func(t *testing.T) {
testStrings := []struct {
in string
out string
}{
{"M=C3=BCnchen", "München"},
{"M=C3=BCnchen G=C3=B6teborg", "München Göteborg"},
{"=E5=85=AC=E5=8F=B8", "公司"},
}
for _, str := range testStrings {
val := DecodeQuotedPrintable(str.in)
assert.Equal(t, str.out, val)
}
})
t.Run("should gracefully ignore invalid encoding sequences", func(t *testing.T) {
testStrings := []struct {
in string
out string
}{
{"=XY=ZZ", "=XY=ZZ"},
{"==58", "=X"},
{"munich = gothenburg", "munich = gothenburg"},
{"munich == tromso", "munich == tromso"},
}
for _, str := range testStrings {
val := DecodeQuotedPrintable(str.in)
assert.Equal(t, str.out, val)
}
})
t.Run("should return invalid UTF-8 sequences as is", func(t *testing.T) {
testStrings := []struct {
in string
out string
}{
{"=E5 =85=AC =E5=8F =B8", "\xE5 \x85\xAC \xE5\x8F \xB8"},
{"=00=00munich=FF=FF", "\x00\x00munich\xFF\xFF"},
}
for _, str := range testStrings {
val := DecodeQuotedPrintable(str.in)
assert.Equal(t, str.out, val)
}
})
}
+60 -40
View File
@@ -4494,15 +4494,15 @@
"parameters": [
{
"type": "string",
"x-go-name": "DatasourceID",
"name": "datasource_id",
"x-go-name": "PermissionID",
"name": "permissionId",
"in": "path",
"required": true
},
{
"type": "string",
"x-go-name": "PermissionID",
"name": "permissionId",
"x-go-name": "DatasourceID",
"name": "datasource_id",
"in": "path",
"required": true
}
@@ -7846,14 +7846,6 @@
"summary": "Add External Group.",
"operationId": "addTeamGroupApi",
"parameters": [
{
"type": "integer",
"format": "int64",
"x-go-name": "TeamID",
"name": "teamId",
"in": "path",
"required": true
},
{
"x-go-name": "Body",
"name": "body",
@@ -7862,6 +7854,14 @@
"schema": {
"$ref": "#/definitions/TeamGroupMapping"
}
},
{
"type": "integer",
"format": "int64",
"x-go-name": "TeamID",
"name": "teamId",
"in": "path",
"required": true
}
],
"responses": {
@@ -7895,16 +7895,16 @@
{
"type": "integer",
"format": "int64",
"x-go-name": "TeamID",
"name": "teamId",
"x-go-name": "GroupID",
"name": "groupId",
"in": "path",
"required": true
},
{
"type": "integer",
"format": "int64",
"x-go-name": "GroupID",
"name": "groupId",
"x-go-name": "TeamID",
"name": "teamId",
"in": "path",
"required": true
}
@@ -10143,7 +10143,7 @@
},
"x-go-package": "github.com/prometheus/common/config"
},
"BrandingOptions": {
"BrandingOptionsDTO": {
"type": "object",
"properties": {
"emailFooterLink": {
@@ -10280,7 +10280,7 @@
"x-go-name": "Name"
},
"options": {
"$ref": "#/definitions/ReportOptions"
"$ref": "#/definitions/ReportOptionsDTO"
},
"orgId": {
"type": "integer",
@@ -10296,10 +10296,11 @@
"x-go-name": "ReplyTo"
},
"schedule": {
"$ref": "#/definitions/Schedule"
"$ref": "#/definitions/ScheduleDTO"
},
"state": {
"$ref": "#/definitions/State"
"type": "string",
"x-go-name": "State"
},
"templateVars": {
"type": "object",
@@ -10480,7 +10481,7 @@
"x-go-name": "Name"
},
"options": {
"$ref": "#/definitions/ReportOptions"
"$ref": "#/definitions/ReportOptionsDTO"
},
"recipients": {
"type": "string",
@@ -10491,7 +10492,7 @@
"x-go-name": "ReplyTo"
},
"schedule": {
"$ref": "#/definitions/Schedule"
"$ref": "#/definitions/ScheduleDTO"
},
"state": {
"type": "string",
@@ -10762,6 +10763,10 @@
"type": "boolean",
"x-go-name": "CanAdmin"
},
"canDelete": {
"type": "boolean",
"x-go-name": "CanDelete"
},
"canEdit": {
"type": "boolean",
"x-go-name": "CanEdit"
@@ -11723,6 +11728,10 @@
"type": "boolean",
"x-go-name": "CanAdmin"
},
"canDelete": {
"type": "boolean",
"x-go-name": "CanDelete"
},
"canEdit": {
"type": "boolean",
"x-go-name": "CanEdit"
@@ -12068,8 +12077,8 @@
},
"exec_err_state": {
"type": "string",
"enum": ["Alerting", "Error"],
"x-go-enum-desc": "Alerting AlertingErrState\nError ErrorErrState",
"enum": ["OK", "Alerting", "Error"],
"x-go-enum-desc": "OK OkErrState\nAlerting AlertingErrState\nError ErrorErrState",
"x-go-name": "ExecErrState"
},
"id": {
@@ -12162,6 +12171,13 @@
"$ref": "#/definitions/GettableExtendedRuleNode"
},
"x-go-name": "Rules"
},
"source_tenants": {
"type": "array",
"items": {
"type": "string"
},
"x-go-name": "SourceTenants"
}
},
"x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
@@ -13906,8 +13922,8 @@
},
"exec_err_state": {
"type": "string",
"enum": ["Alerting", "Error"],
"x-go-enum-desc": "Alerting AlertingErrState\nError ErrorErrState",
"enum": ["OK", "Alerting", "Error"],
"x-go-enum-desc": "OK OkErrState\nAlerting AlertingErrState\nError ErrorErrState",
"x-go-name": "ExecErrState"
},
"no_data_state": {
@@ -14266,7 +14282,7 @@
},
"x-go-package": "github.com/grafana/grafana/pkg/extensions/report"
},
"ReportOptions": {
"ReportOptionsDTO": {
"type": "object",
"properties": {
"layout": {
@@ -14539,6 +14555,13 @@
"$ref": "#/definitions/GettableExtendedRuleNode"
},
"x-go-name": "Rules"
},
"source_tenants": {
"type": "array",
"items": {
"type": "string"
},
"x-go-name": "SourceTenants"
}
},
"x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
@@ -14673,7 +14696,7 @@
},
"x-go-package": "github.com/grafana/grafana/pkg/models"
},
"Schedule": {
"ScheduleDTO": {
"type": "object",
"properties": {
"day": {
@@ -14824,7 +14847,7 @@
"type": "object",
"properties": {
"branding": {
"$ref": "#/definitions/BrandingOptions"
"$ref": "#/definitions/BrandingOptionsDTO"
},
"id": {
"type": "integer",
@@ -15054,10 +15077,6 @@
"SmtpNotEnabled": {
"$ref": "#/definitions/ResponseDetails"
},
"State": {
"type": "string",
"x-go-package": "github.com/grafana/grafana/pkg/extensions/report"
},
"Status": {
"type": "object",
"properties": {
@@ -16772,12 +16791,11 @@
}
},
"gettableAlerts": {
"description": "GettableAlerts gettable alerts",
"type": "array",
"items": {
"$ref": "#/definitions/gettableAlert"
},
"x-go-name": "GettableAlerts",
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
}
},
"gettableSilence": {
"description": "GettableSilence gettable silence",
@@ -16951,7 +16969,6 @@
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
},
"postableSilence": {
"description": "PostableSilence postable silence",
"type": "object",
"required": ["comment", "createdBy", "endsAt", "matchers", "startsAt"],
"properties": {
@@ -16985,10 +17002,11 @@
"format": "date-time",
"x-go-name": "StartsAt"
}
}
},
"x-go-name": "PostableSilence",
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
},
"receiver": {
"description": "Receiver receiver",
"type": "object",
"required": ["name"],
"properties": {
@@ -16997,7 +17015,9 @@
"type": "string",
"x-go-name": "Name"
}
}
},
"x-go-name": "Receiver",
"x-go-package": "github.com/prometheus/alertmanager/api/v2/models"
},
"silence": {
"description": "Silence silence",
+34 -29
View File
@@ -3544,15 +3544,15 @@
"parameters": [
{
"type": "string",
"x-go-name": "DatasourceID",
"name": "datasource_id",
"x-go-name": "PermissionID",
"name": "permissionId",
"in": "path",
"required": true
},
{
"type": "string",
"x-go-name": "PermissionID",
"name": "permissionId",
"x-go-name": "DatasourceID",
"name": "datasource_id",
"in": "path",
"required": true
}
@@ -6424,14 +6424,6 @@
"summary": "Add External Group.",
"operationId": "addTeamGroupApi",
"parameters": [
{
"type": "integer",
"format": "int64",
"x-go-name": "TeamID",
"name": "teamId",
"in": "path",
"required": true
},
{
"x-go-name": "Body",
"name": "body",
@@ -6440,6 +6432,14 @@
"schema": {
"$ref": "#/definitions/TeamGroupMapping"
}
},
{
"type": "integer",
"format": "int64",
"x-go-name": "TeamID",
"name": "teamId",
"in": "path",
"required": true
}
],
"responses": {
@@ -6473,16 +6473,16 @@
{
"type": "integer",
"format": "int64",
"x-go-name": "TeamID",
"name": "teamId",
"x-go-name": "GroupID",
"name": "groupId",
"in": "path",
"required": true
},
{
"type": "integer",
"format": "int64",
"x-go-name": "GroupID",
"name": "groupId",
"x-go-name": "TeamID",
"name": "teamId",
"in": "path",
"required": true
}
@@ -8333,7 +8333,7 @@
},
"x-go-package": "github.com/grafana/grafana/pkg/models"
},
"BrandingOptions": {
"BrandingOptionsDTO": {
"type": "object",
"properties": {
"emailFooterLink": {
@@ -8436,7 +8436,7 @@
"x-go-name": "Name"
},
"options": {
"$ref": "#/definitions/ReportOptions"
"$ref": "#/definitions/ReportOptionsDTO"
},
"orgId": {
"type": "integer",
@@ -8452,10 +8452,11 @@
"x-go-name": "ReplyTo"
},
"schedule": {
"$ref": "#/definitions/Schedule"
"$ref": "#/definitions/ScheduleDTO"
},
"state": {
"$ref": "#/definitions/State"
"type": "string",
"x-go-name": "State"
},
"templateVars": {
"type": "object",
@@ -8636,7 +8637,7 @@
"x-go-name": "Name"
},
"options": {
"$ref": "#/definitions/ReportOptions"
"$ref": "#/definitions/ReportOptionsDTO"
},
"recipients": {
"type": "string",
@@ -8647,7 +8648,7 @@
"x-go-name": "ReplyTo"
},
"schedule": {
"$ref": "#/definitions/Schedule"
"$ref": "#/definitions/ScheduleDTO"
},
"state": {
"type": "string",
@@ -8918,6 +8919,10 @@
"type": "boolean",
"x-go-name": "CanAdmin"
},
"canDelete": {
"type": "boolean",
"x-go-name": "CanDelete"
},
"canEdit": {
"type": "boolean",
"x-go-name": "CanEdit"
@@ -9695,6 +9700,10 @@
"type": "boolean",
"x-go-name": "CanAdmin"
},
"canDelete": {
"type": "boolean",
"x-go-name": "CanDelete"
},
"canEdit": {
"type": "boolean",
"x-go-name": "CanEdit"
@@ -10839,7 +10848,7 @@
},
"x-go-package": "github.com/grafana/grafana/pkg/extensions/report"
},
"ReportOptions": {
"ReportOptionsDTO": {
"type": "object",
"properties": {
"layout": {
@@ -10971,7 +10980,7 @@
},
"x-go-package": "github.com/grafana/grafana/pkg/models"
},
"Schedule": {
"ScheduleDTO": {
"type": "object",
"properties": {
"day": {
@@ -11113,7 +11122,7 @@
"type": "object",
"properties": {
"branding": {
"$ref": "#/definitions/BrandingOptions"
"$ref": "#/definitions/BrandingOptionsDTO"
},
"id": {
"type": "integer",
@@ -11133,10 +11142,6 @@
},
"x-go-package": "github.com/grafana/grafana/pkg/extensions/report"
},
"State": {
"type": "string",
"x-go-package": "github.com/grafana/grafana/pkg/extensions/report"
},
"Status": {
"type": "object",
"properties": {
@@ -0,0 +1,18 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import EmptyListCTA from './EmptyListCTA';
describe('EmptyListCTA', () => {
it('should return a button element if there is no buttonLink prop', () => {
render(<EmptyListCTA title="title" buttonIcon="plus" buttonTitle="button title" />);
expect(screen.getByRole('button', { name: 'button title' }));
});
it('should return an anchor element if there is a buttonLink prop', () => {
render(<EmptyListCTA title="title" buttonIcon="plus" buttonLink="href" buttonTitle="button title" />);
expect(screen.getByRole('link', { name: 'button title' }));
});
});
@@ -1,7 +1,7 @@
import React, { MouseEvent } from 'react';
import { css } from '@emotion/css';
import { CallToActionCard, Icon, IconName, LinkButton } from '@grafana/ui';
import { selectors } from '@grafana/e2e-selectors';
import { Button, CallToActionCard, Icon, IconName, LinkButton } from '@grafana/ui';
import React, { MouseEvent } from 'react';
export interface Props {
title: string;
@@ -75,8 +75,9 @@ const EmptyListCTA: React.FunctionComponent<Props> = ({
`
: '';
const ButtonEl = buttonLink ? LinkButton : Button;
const ctaElement = (
<LinkButton
<ButtonEl
size="lg"
onClick={onClick}
href={buttonLink}
@@ -86,7 +87,7 @@ const EmptyListCTA: React.FunctionComponent<Props> = ({
disabled={buttonDisabled}
>
{buttonTitle}
</LinkButton>
</ButtonEl>
);
return <CallToActionCard className={ctaStyle} message={title} footer={footer()} callToActionElement={ctaElement} />;
@@ -1,5 +1,5 @@
import { ArrayVector, DataFrame, DataTransformerID, DataTransformerInfo, FieldType } from '@grafana/data';
import { createLineBetween } from 'app/features/geo/format/utils';
import { createGeometryCollection, createLineBetween } from 'app/features/geo/format/utils';
import { getGeometryField, getLocationMatchers } from 'app/features/geo/utils/location';
import { mergeMap, from } from 'rxjs';
import { SpatialOperation, SpatialAction, SpatialTransformOptions } from './models.gen';
@@ -26,10 +26,17 @@ async function doSetGeometry(frames: DataFrame[], options: SpatialTransformOptio
const src = getGeometryField(frame, location);
const target = getGeometryField(frame, targetLocation);
if (src.field && target.field) {
const fields = [...frame.fields];
const line = createLineBetween(src.field, target.field);
const first = fields[0];
if (first.type === FieldType.geo && first !== src.field && first !== target.field) {
fields[0] = createGeometryCollection(first, line); //
} else {
fields.unshift(line);
}
return {
...frame,
fields: [line, ...frame.fields],
fields,
};
}
return frame;
@@ -148,4 +148,19 @@ describe('AlertGroups', () => {
expect(groups[0]).toHaveTextContent('No grouping');
expect(groups[1]).toHaveTextContent('uniqueLabel=true');
});
it('should combine multiple ungrouped groups', async () => {
mocks.api.fetchAlertGroups.mockImplementation(() => {
const groups = [
mockAlertGroup({ labels: {} }),
mockAlertGroup({ labels: {}, alerts: [mockAlertmanagerAlert({ labels: { foo: 'bar' } })] }),
];
return Promise.resolve(groups);
});
renderAmNotifications();
await waitFor(() => expect(mocks.api.fetchAlertGroups).toHaveBeenCalled());
const groups = ui.group.getAll();
expect(groups).toHaveLength(1);
});
});
@@ -9,6 +9,8 @@ import {
LoadingPlaceholder,
PanelChromeLoadingIndicator,
Icon,
Button,
VerticalGroup,
} from '@grafana/ui';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { AlertingQueryRunner } from './state/AlertingQueryRunner';
@@ -28,6 +30,8 @@ import { RuleDetailsExpression } from './components/rules/RuleDetailsExpression'
import { RuleDetailsAnnotations } from './components/rules/RuleDetailsAnnotations';
import * as ruleId from './utils/rule-id';
import { AlertQuery } from '../../../types/unified-alerting-dto';
import { RuleDetailsFederatedSources } from './components/rules/RuleDetailsFederatedSources';
import { isFederatedRuleGroup } from './utils/rules';
type RuleViewerProps = GrafanaRouteComponentProps<{ id?: string; sourceName?: string }>;
@@ -115,9 +119,24 @@ export function RuleViewer({ match }: RuleViewerProps) {
</RuleViewerLayout>
);
}
const annotations = Object.entries(rule.annotations).filter(([_, value]) => !!value.trim());
const isFederatedRule = isFederatedRuleGroup(rule.group);
return (
<RuleViewerLayout wrapInContent={false} title={pageTitle}>
{isFederatedRule && (
<Alert severity="info" title="This rule is part of a federated rule group.">
<VerticalGroup>
Federated rule groups are currently an experimental feature.
<Button fill="text" icon="book">
<a href="https://grafana.com/docs/metrics-enterprise/latest/tenant-management/tenant-federation/#cross-tenant-alerting-and-recording-rule-federation">
Read documentation
</a>
</Button>
</VerticalGroup>
</Alert>
)}
<RuleViewerLayoutContent>
<div>
<h4>
@@ -143,6 +162,7 @@ export function RuleViewer({ match }: RuleViewerProps) {
</div>
<div className={styles.rightSide}>
<RuleDetailsDataSources rule={rule} rulesSource={rulesSource} />
{isFederatedRule && <RuleDetailsFederatedSources group={rule.group} />}
<DetailsField label="Namespace / Group">{`${rule.namespace.name} / ${rule.group.name}`}</DetailsField>
</div>
</div>
@@ -150,7 +170,7 @@ export function RuleViewer({ match }: RuleViewerProps) {
<RuleDetailsMatchingInstances promRule={rule.promRule} />
</div>
</RuleViewerLayoutContent>
{data && Object.keys(data).length > 0 && (
{!isFederatedRule && data && Object.keys(data).length > 0 && (
<>
<div className={styles.queriesTitle}>
Query results <PanelChromeLoadingIndicator loading={isLoading(data)} onCancel={() => runner.cancel()} />
@@ -17,6 +17,7 @@ import { CombinedRule, RulesSource } from 'app/types/unified-alerting';
import { getAlertmanagerByUid } from '../../utils/alertmanager';
import { useStateHistoryModal } from '../../hooks/useStateHistoryModal';
import { RulerGrafanaRuleDTO, RulerRuleDTO } from 'app/types/unified-alerting-dto';
import { isFederatedRuleGroup } from '../../utils/rules';
interface Props {
rule: CombinedRule;
@@ -39,6 +40,7 @@ export const RuleDetailsActionButtons: FC<Props> = ({ rule, rulesSource }) => {
const leftButtons: JSX.Element[] = [];
const rightButtons: JSX.Element[] = [];
const isFederated = isFederatedRuleGroup(group);
const { isEditable } = useIsRuleEditable(getRulesSourceName(rulesSource), rulerRule);
const returnTo = location.pathname + location.search;
const isViewMode = inViewMode(location.pathname);
@@ -67,7 +69,8 @@ export const RuleDetailsActionButtons: FC<Props> = ({ rule, rulesSource }) => {
};
// explore does not support grafana rule queries atm
if (isCloudRulesSource(rulesSource) && contextSrv.isEditor) {
// neither do "federated rules"
if (isCloudRulesSource(rulesSource) && contextSrv.isEditor && !isFederated) {
leftButtons.push(
<LinkButton
className={style.button}
@@ -173,7 +176,7 @@ export const RuleDetailsActionButtons: FC<Props> = ({ rule, rulesSource }) => {
);
}
if (isEditable && rulerRule) {
if (isEditable && rulerRule && !isFederated) {
const sourceName = getRulesSourceName(rulesSource);
const identifier = ruleId.fromRulerRule(sourceName, namespace.name, group.name, rulerRule);
@@ -0,0 +1,23 @@
import { CombinedRuleGroup } from 'app/types/unified-alerting';
import React, { FC } from 'react';
import { DetailsField } from '../DetailsField';
interface Props {
group: CombinedRuleGroup;
}
const RuleDetailsFederatedSources: FC<Props> = ({ group }) => {
const sourceTenants = group.source_tenants ?? [];
return (
<DetailsField label="Tenant sources">
<>
{sourceTenants.map((tenant) => (
<div key={tenant}>{tenant}</div>
))}
</>
</DetailsField>
);
};
export { RuleDetailsFederatedSources };
@@ -1,9 +1,9 @@
import { CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting';
import React, { FC, useState, useEffect } from 'react';
import { HorizontalGroup, Icon, Spinner, Tooltip, useStyles2 } from '@grafana/ui';
import { Badge, HorizontalGroup, Icon, Spinner, Tooltip, useStyles2 } from '@grafana/ui';
import { GrafanaTheme2 } from '@grafana/data';
import { css } from '@emotion/css';
import { isGrafanaRulerRule } from '../../utils/rules';
import { isFederatedRuleGroup, isGrafanaRulerRule } from '../../utils/rules';
import { CollapseToggle } from '../CollapseToggle';
import { RulesTable } from './RulesTable';
import { GRAFANA_RULES_SOURCE_NAME, isCloudRulesSource } from '../../utils/datasource';
@@ -38,6 +38,7 @@ export const RulesGroup: FC<Props> = React.memo(({ group, namespace, expandAll }
// group "is deleting" if rules source has ruler, but this group has no rules that are in ruler
const isDeleting = hasRuler(rulesSource) && !group.rules.find((rule) => !!rule.rulerRule);
const isFederated = isFederatedRuleGroup(group);
const actionIcons: React.ReactNode[] = [];
@@ -78,16 +79,18 @@ export const RulesGroup: FC<Props> = React.memo(({ group, namespace, expandAll }
}
}
} else if (hasRuler(rulesSource)) {
actionIcons.push(
<ActionIcon
aria-label="edit rule group"
data-testid="edit-group"
key="edit"
icon="pen"
tooltip="edit rule group"
onClick={() => setIsEditingGroup(true)}
/>
);
if (!isFederated) {
actionIcons.push(
<ActionIcon
aria-label="edit rule group"
data-testid="edit-group"
key="edit"
icon="pen"
tooltip="edit rule group"
onClick={() => setIsEditingGroup(true)}
/>
);
}
}
const groupName = isCloudRulesSource(rulesSource) ? `${namespace.name} > ${group.name}` : namespace.name;
@@ -111,7 +114,9 @@ export const RulesGroup: FC<Props> = React.memo(({ group, namespace, expandAll }
/>
</Tooltip>
)}
<h6 className={styles.heading}>{groupName}</h6>
<h6 className={styles.heading}>
{isFederated && <Badge color="purple" text="Federated" />} {groupName}
</h6>
<div className={styles.spacer} />
<div className={styles.headerStats}>
<RuleStats showInactive={false} group={group} />
@@ -105,6 +105,7 @@ function addRulerGroupsToCombinedNamespace(namespace: CombinedRuleNamespace, gro
const combinedGroup: CombinedRuleGroup = {
name: group.name,
interval: group.interval,
source_tenants: group.source_tenants,
rules: [],
};
combinedGroup.rules = group.rules.map((rule) => rulerRuleToCombinedRule(rule, namespace, combinedGroup));
@@ -10,7 +10,7 @@ export const useFilteredAmGroups = (groups: AlertmanagerGroup[]) => {
const matchers = parseMatchers(filters.queryString || '');
return useMemo(() => {
return groups.reduce((filteredGroup, group) => {
return groups.reduce((filteredGroup: AlertmanagerGroup[], group) => {
const alerts = group.alerts.filter(({ labels, status }) => {
const labelsMatch = labelsMatchMatchers(labels, matchers);
const filtersMatch = filters.alertState ? status.state === filters.alertState : true;
@@ -25,6 +25,6 @@ export const useFilteredAmGroups = (groups: AlertmanagerGroup[]) => {
}
}
return filteredGroup;
}, [] as AlertmanagerGroup[]);
}, []);
}, [groups, filters, matchers]);
};
@@ -1,11 +1,30 @@
import { useMemo } from 'react';
import { AlertmanagerGroup } from 'app/plugins/datasource/alertmanager/types';
import { Labels } from '@grafana/data';
import { uniqBy } from 'lodash';
export const useGroupedAlerts = (groups: AlertmanagerGroup[], groupBy: string[]) => {
export const useGroupedAlerts = (groups: AlertmanagerGroup[], groupBy: string[]): AlertmanagerGroup[] => {
return useMemo(() => {
if (groupBy.length === 0) {
return groups;
const emptyGroupings = groups.filter((group) => Object.keys(group.labels).length === 0);
if (emptyGroupings.length > 1) {
// Merges multiple ungrouped grouping
return groups.reduce((combinedGroups, group) => {
if (Object.keys(group.labels).length === 0) {
const noGroupingGroup = combinedGroups.find(({ labels }) => Object.keys(labels));
if (!noGroupingGroup) {
combinedGroups.push({ alerts: group.alerts, labels: {}, receiver: { name: 'NONE' } });
} else {
noGroupingGroup.alerts = uniqBy([...noGroupingGroup.alerts, ...group.alerts], 'labels');
}
} else {
combinedGroups.push(group);
}
return combinedGroups;
}, [] as AlertmanagerGroup[]);
} else {
return groups;
}
}
const alerts = groups.flatMap(({ alerts }) => alerts);
return alerts.reduce((groupings, alert) => {
@@ -12,6 +12,7 @@ import {
Alert,
AlertingRule,
CloudRuleIdentifier,
CombinedRuleGroup,
GrafanaRuleIdentifier,
PrometheusRuleIdentifier,
PromRuleWithLocation,
@@ -116,3 +117,13 @@ export function getFirstActiveAt(promRule: AlertingRule) {
return prev;
}, null as Date | null);
}
/**
* A rule group is "federated" when it has at least one "source_tenants" entry, federated rule groups will evaluate rules in multiple tenants
* Non-federated rules do not have this property
*
* see https://grafana.com/docs/metrics-enterprise/latest/tenant-management/tenant-federation/#cross-tenant-alerting-and-recording-rule-federation
*/
export function isFederatedRuleGroup(group: CombinedRuleGroup) {
return Array.isArray(group.source_tenants);
}
@@ -2,7 +2,7 @@ import { AnnotationQuery, EventBus, GrafanaTheme2 } from '@grafana/data';
import React, { useEffect, useState } from 'react';
import { getDashboardQueryRunner } from '../../../query/state/DashboardQueryRunner/DashboardQueryRunner';
import { AnnotationQueryFinished, AnnotationQueryStarted } from '../../../../types/events';
import { InlineField, InlineSwitch, useStyles2 } from '@grafana/ui';
import { InlineField, InlineFieldRow, InlineSwitch, useStyles2 } from '@grafana/ui';
import { LoadingIndicator } from '@grafana/ui/src/components/PanelChrome/LoadingIndicator';
import { css } from '@emotion/css';
@@ -41,14 +41,14 @@ export const AnnotationPicker = ({ annotation, events, onEnabledChanged }: Annot
return (
<div key={annotation.name} className={styles.annotation}>
<InlineField label={annotation.name} disabled={loading}>
<>
<InlineFieldRow>
<InlineField label={annotation.name} disabled={loading}>
<InlineSwitch value={annotation.enable} onChange={() => onEnabledChanged(annotation)} disabled={loading} />
<div className={styles.indicator}>
<LoadingIndicator loading={loading} onCancel={onCancel} />
</div>
</>
</InlineField>
</InlineField>
<div className={styles.indicator}>
<LoadingIndicator loading={true} onCancel={onCancel} />
</div>
</InlineFieldRow>
</div>
);
};
@@ -587,6 +587,14 @@ describe('given dashboard with row and panel repeat', () => {
expect(panelTypes).toEqual(['row', 'graph', 'graph', 'row', 'graph', 'graph']);
});
it('Row repeat should create new panel keys every repeat cycle', () => {
// This is the first repeated panel inside the second repeated row
// Since we create a new panel model every time (and new panel events bus) we need to create a new key here to trigger a re-mount & re-subscribe
const key1 = dashboard.panels[3].key;
dashboard.processRepeats();
expect(key1).not.toEqual(dashboard.panels[3].key);
});
it('should clean up old repeated panels', () => {
dashboardJSON.panels = [
{
@@ -796,7 +796,6 @@ export class DashboardModel {
updateRepeatedPanelIds(panel: PanelModel, repeatedByRow?: boolean) {
panel.repeatPanelId = panel.id;
panel.id = this.getNextPanelId();
panel.key = `${panel.id}`;
panel.repeatIteration = this.iteration;
if (repeatedByRow) {
panel.repeatedByRow = true;
@@ -962,7 +961,7 @@ export class DashboardModel {
for (const panel of row.panels) {
// set the y gridPos if it wasn't already set
panel.gridPos.y ??= row.gridPos.y;
panel.gridPos.y ?? (panel.gridPos.y = row.gridPos.y); // (Safari 13.1 lacks ??= support)
// make sure y is adjusted (in case row moved while collapsed)
panel.gridPos.y -= yDiff;
// insert after row

Some files were not shown because too many files have changed in this diff Show More