Merge branch 'v8.4.x' of github.com:grafana/grafana into v8.4.x
This commit is contained in:
+329
-200
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@ on:
|
||||
inputs:
|
||||
version:
|
||||
required: true
|
||||
default: '7.x.x'
|
||||
default: '8.x.x'
|
||||
workflow_call:
|
||||
inputs:
|
||||
version_call:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
+61
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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>
|
||||
|
||||
+1
-1
@@ -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:
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+1
-1
@@ -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,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
|
||||
+++
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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"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
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"stable": "8.4.0",
|
||||
"testing": "8.4.0"
|
||||
"stable": "8.4.3",
|
||||
"testing": "8.4.3"
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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')};
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, "")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
@@ -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=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
|
||||
FRAME=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
|
||||
FRAME=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
|
||||
|
||||
+6
-6
@@ -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"]
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
Reference in New Issue
Block a user