mirror of
https://github.com/grafana/grafana.git
synced 2026-02-03 20:49:50 -05:00
Merge remote-tracking branch 'origin/main' into fastfrwrd/clean-up-gradient
This commit is contained in:
commit
f53f5da7a4
84 changed files with 4856 additions and 1024 deletions
|
|
@ -80,7 +80,7 @@ require (
|
|||
github.com/google/gnostic-models v0.7.1 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/grafana/alerting v0.0.0-20260129164026-85d7010c64b8 // indirect
|
||||
github.com/grafana/alerting v0.0.0-20260203165836-8b17916e8173 // indirect
|
||||
github.com/grafana/authlib v0.0.0-20260203131350-b83e80394acc // indirect
|
||||
github.com/grafana/authlib/types v0.0.0-20260203131350-b83e80394acc // indirect
|
||||
github.com/grafana/dataplane/sdata v0.0.9 // indirect
|
||||
|
|
|
|||
|
|
@ -342,8 +342,8 @@ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+
|
|||
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
|
||||
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
||||
github.com/gorilla/mux v1.7.1/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
||||
github.com/grafana/alerting v0.0.0-20260129164026-85d7010c64b8 h1:964kdD/6Xyzr4g910nZnMtj0z16ijsvpA8Ju4sFOLjA=
|
||||
github.com/grafana/alerting v0.0.0-20260129164026-85d7010c64b8/go.mod h1:Ji0SfJChcwjgq8ljy6Y5CcYfHfAYKXjKYeysOoDS/6s=
|
||||
github.com/grafana/alerting v0.0.0-20260203165836-8b17916e8173 h1:nrQnGVRvBQK1zmg9rB6TA6tOeS0sSsUUV9JS1erkw2Q=
|
||||
github.com/grafana/alerting v0.0.0-20260203165836-8b17916e8173/go.mod h1:Ji0SfJChcwjgq8ljy6Y5CcYfHfAYKXjKYeysOoDS/6s=
|
||||
github.com/grafana/authlib v0.0.0-20260203131350-b83e80394acc h1:s9+L7EMTJIQxkhrR2m5HrOUKjeJDxEE4E09hPWKnfwQ=
|
||||
github.com/grafana/authlib v0.0.0-20260203131350-b83e80394acc/go.mod h1:za8MGa5J9Bbgm2XorXc+FbGe72ln46OpN5o8P1uX9Og=
|
||||
github.com/grafana/authlib/types v0.0.0-20260203131350-b83e80394acc h1:wagsf4me4j/UFNocyMJHz5/803XpnfGJtNj8/YWy0j0=
|
||||
|
|
|
|||
|
|
@ -10,4 +10,8 @@ foldersV1beta1: {
|
|||
description?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
selectableFields: [
|
||||
"spec.title",
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,13 +5,26 @@
|
|||
package v1beta1
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/grafana/grafana-app-sdk/resource"
|
||||
)
|
||||
|
||||
// schema is unexported to prevent accidental overwrites
|
||||
var (
|
||||
schemaFolder = resource.NewSimpleSchema("folder.grafana.app", "v1beta1", NewFolder(), &FolderList{}, resource.WithKind("Folder"),
|
||||
resource.WithPlural("folders"), resource.WithScope(resource.NamespacedScope))
|
||||
resource.WithPlural("folders"), resource.WithScope(resource.NamespacedScope), resource.WithSelectableFields([]resource.SelectableField{{
|
||||
FieldSelector: "spec.title",
|
||||
FieldValueFunc: func(o resource.Object) (string, error) {
|
||||
cast, ok := o.(*Folder)
|
||||
if !ok {
|
||||
return "", errors.New("provided object must be of type *Folder")
|
||||
}
|
||||
|
||||
return cast.Spec.Title, nil
|
||||
},
|
||||
},
|
||||
}))
|
||||
kindFolder = resource.Kind{
|
||||
Schema: schemaFolder,
|
||||
Codecs: map[resource.KindEncoding]resource.Codec{
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ var appManifestData = app.ManifestData{
|
|||
Plural: "Folders",
|
||||
Scope: "Namespaced",
|
||||
Conversion: false,
|
||||
SelectableFields: []string{
|
||||
"spec.title",
|
||||
},
|
||||
},
|
||||
},
|
||||
Routes: app.ManifestVersionRoutes{
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ require (
|
|||
github.com/google/gnostic-models v0.7.1 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/grafana/alerting v0.0.0-20260129164026-85d7010c64b8 // indirect
|
||||
github.com/grafana/alerting v0.0.0-20260203165836-8b17916e8173 // indirect
|
||||
github.com/grafana/authlib v0.0.0-20260203131350-b83e80394acc // indirect
|
||||
github.com/grafana/authlib/types v0.0.0-20260203131350-b83e80394acc // indirect
|
||||
github.com/grafana/dataplane/sdata v0.0.9 // indirect
|
||||
|
|
|
|||
|
|
@ -217,8 +217,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
|
||||
github.com/grafana/alerting v0.0.0-20260129164026-85d7010c64b8 h1:964kdD/6Xyzr4g910nZnMtj0z16ijsvpA8Ju4sFOLjA=
|
||||
github.com/grafana/alerting v0.0.0-20260129164026-85d7010c64b8/go.mod h1:Ji0SfJChcwjgq8ljy6Y5CcYfHfAYKXjKYeysOoDS/6s=
|
||||
github.com/grafana/alerting v0.0.0-20260203165836-8b17916e8173 h1:nrQnGVRvBQK1zmg9rB6TA6tOeS0sSsUUV9JS1erkw2Q=
|
||||
github.com/grafana/alerting v0.0.0-20260203165836-8b17916e8173/go.mod h1:Ji0SfJChcwjgq8ljy6Y5CcYfHfAYKXjKYeysOoDS/6s=
|
||||
github.com/grafana/authlib v0.0.0-20260203131350-b83e80394acc h1:s9+L7EMTJIQxkhrR2m5HrOUKjeJDxEE4E09hPWKnfwQ=
|
||||
github.com/grafana/authlib v0.0.0-20260203131350-b83e80394acc/go.mod h1:za8MGa5J9Bbgm2XorXc+FbGe72ln46OpN5o8P1uX9Og=
|
||||
github.com/grafana/authlib/types v0.0.0-20260203131350-b83e80394acc h1:wagsf4me4j/UFNocyMJHz5/803XpnfGJtNj8/YWy0j0=
|
||||
|
|
|
|||
|
|
@ -19,97 +19,6 @@ labels:
|
|||
menuTitle: Amazon CloudWatch
|
||||
title: Amazon CloudWatch data source
|
||||
weight: 200
|
||||
refs:
|
||||
logs:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/panels-visualizations/visualizations/logs/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/panels-visualizations/visualizations/logs/
|
||||
explore:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/explore/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/explore/
|
||||
provisioning-data-sources:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/administration/provisioning/#data-sources
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/administration/provisioning/#data-sources
|
||||
configure-grafana-aws:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/#aws
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/#aws
|
||||
alerting:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/alerting/
|
||||
build-dashboards:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/build-dashboards/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/build-dashboards/
|
||||
data-source-management:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/administration/data-source-management/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/administration/data-source-management/
|
||||
configure-cloudwatch:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/aws-cloudwatch/configure/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/aws-cloudwatch/configure/
|
||||
cloudwatch-query-editor:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/aws-cloudwatch/query-editor/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/aws-cloudwatch/query-editor/
|
||||
cloudwatch-template-variables:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/aws-cloudwatch/template-variables/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/aws-cloudwatch/template-variables/
|
||||
cloudwatch-aws-authentication:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/aws-cloudwatch/aws-authentication/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/aws-cloudwatch/aws-authentication/
|
||||
query-caching:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/administration/data-source-management/#query-and-resource-caching
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/administration/data-source-management/#query-and-resource-caching
|
||||
variables:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/variables/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/visualizations/dashboards/variables/
|
||||
annotate-visualizations:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/build-dashboards/annotate-visualizations/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/visualizations/dashboards/build-dashboards/annotate-visualizations/
|
||||
set-up-grafana-monitoring:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/setup-grafana/set-up-grafana-monitoring/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/setup-grafana/set-up-grafana-monitoring/
|
||||
transformations:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/panels-visualizations/query-transform-data/transform-data/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/visualizations/panels-visualizations/query-transform-data/transform-data/
|
||||
visualizations:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/panels-visualizations/visualizations/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/
|
||||
cloudwatch-troubleshooting:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/aws-cloudwatch/troubleshooting/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/aws-cloudwatch/troubleshooting/
|
||||
---
|
||||
|
||||
# Amazon CloudWatch data source
|
||||
|
|
@ -120,11 +29,11 @@ Grafana includes native support for the Amazon CloudWatch plugin, so there's no
|
|||
|
||||
The following documents will help you get started working with the CloudWatch data source:
|
||||
|
||||
- [Configure the CloudWatch data source](ref:configure-cloudwatch)
|
||||
- [CloudWatch query editor](ref:cloudwatch-query-editor)
|
||||
- [Templates and variables](ref:cloudwatch-template-variables)
|
||||
- [Configure AWS authentication](ref:cloudwatch-aws-authentication)
|
||||
- [Troubleshoot CloudWatch issues](ref:cloudwatch-troubleshooting)
|
||||
- [Configure the CloudWatch data source](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/datasources/aws-cloudwatch/configure/)
|
||||
- [CloudWatch query editor](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/datasources/aws-cloudwatch/query-editor/)
|
||||
- [Templates and variables](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/datasources/aws-cloudwatch/template-variables/)
|
||||
- [Configure AWS authentication](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/datasources/aws-cloudwatch/aws-authentication/)
|
||||
- [Troubleshoot CloudWatch issues](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/datasources/aws-cloudwatch/troubleshooting/)
|
||||
|
||||
## Import pre-configured dashboards
|
||||
|
||||
|
|
@ -145,7 +54,7 @@ To import curated dashboards:
|
|||
|
||||
1. Click **Import** for each dashboard you want to import.
|
||||
|
||||
 CloudWatch pre-configured dashboards
|
||||

|
||||
|
||||
To customize one of these dashboards, Grafana recommends saving it under a different name; otherwise, Grafana upgrades will overwrite your customizations with the new version.
|
||||
|
||||
|
|
@ -153,12 +62,12 @@ To customize one of these dashboards, Grafana recommends saving it under a diffe
|
|||
|
||||
After installing and configuring the Amazon CloudWatch data source, you can:
|
||||
|
||||
- Create a wide variety of [visualizations](ref:visualizations)
|
||||
- Configure and use [templates and variables](ref:variables)
|
||||
- Add [transformations](ref:transformations)
|
||||
- Add [annotations](ref:annotate-visualizations)
|
||||
- Set up [alerting](ref:alerting)
|
||||
- Optimize performance with [query caching](ref:query-caching)
|
||||
- Create a wide variety of [visualizations](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/panels-visualizations/visualizations/)
|
||||
- Configure and use [templates and variables](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/dashboards/variables/)
|
||||
- Add [transformations](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/panels-visualizations/query-transform-data/transform-data/)
|
||||
- Add [annotations](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/dashboards/build-dashboards/annotate-visualizations/)
|
||||
- Set up [alerting](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/alerting/)
|
||||
- Optimize performance with [query caching](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/administration/data-source-management/#query-and-resource-caching)
|
||||
|
||||
## Control pricing
|
||||
|
||||
|
|
|
|||
|
|
@ -16,17 +16,6 @@ labels:
|
|||
menuTitle: AWS authentication
|
||||
title: Configure AWS authentication
|
||||
weight: 400
|
||||
refs:
|
||||
configure-grafana-assume-role-enabled:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/#assume_role_enabled
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/#assume_role_enabled
|
||||
configure-grafana-allowed-auth-providers:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/#allowed_auth_providers
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/#allowed_auth_providers
|
||||
---
|
||||
|
||||
# Configure AWS authentication
|
||||
|
|
@ -49,7 +38,7 @@ This document explores the following topics:
|
|||
|
||||
Available authentication methods depend on your configuration and the environment where Grafana runs.
|
||||
|
||||
Open source Grafana enables the `AWS SDK Default`, `Credentials file`, and `Access and secret key` methods by default. Cloud Grafana enables only `Access and secret key` by default. Users with server configuration access can enable or disable specific auth providers as needed. For more information, refer to the [`allowed_auth_providers` documentation](ref:configure-grafana-allowed-auth-providers).
|
||||
Open source Grafana enables the `AWS SDK Default`, `Credentials file`, and `Access and secret key` methods by default. Cloud Grafana enables only `Access and secret key` by default. Users with server configuration access can enable or disable specific auth providers as needed. For more information, refer to the [`allowed_auth_providers` documentation](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/#allowed_auth_providers).
|
||||
|
||||
- `AWS SDK Default` uses the [default provider](https://docs.aws.amazon.com/sdk-for-go/v1/developer-guide/configuring-sdk.html) from the [AWS SDK for Go](https://github.com/aws/aws-sdk-go) without custom configuration.
|
||||
This method requires configuring AWS credentials outside Grafana through [the CLI](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-files.html), or by [attaching credentials directly to an EC2 instance](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html), [in an ECS task](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-iam-roles.html), or for a [Service Account in a Kubernetes cluster](https://docs.aws.amazon.com/eks/latest/userguide/iam-roles-for-service-accounts.html). You can attach permissions directly to the data source with AWS SDK Default or combine it with the optional [`Assume Role ARN`](#assume-a-role) field.
|
||||
|
|
@ -76,7 +65,7 @@ Instead, assume role functionality lets you use one set of AWS credentials acros
|
|||
|
||||
If the **Assume Role ARN** field is left empty, Grafana uses the provided credentials from the selected authentication method directly, and permissions to AWS data must be attached directly to those credentials. The **Assume Role ARN** field is optional for all authentication methods except for Grafana Assume Role.
|
||||
|
||||
To disable this feature in open source Grafana or Grafana Enterprise, refer to [`assume_role_enabled`](ref:configure-grafana-assume-role-enabled).
|
||||
To disable this feature in open source Grafana or Grafana Enterprise, refer to [`assume_role_enabled`](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/#assume_role_enabled).
|
||||
|
||||
### Use an external ID
|
||||
|
||||
|
|
|
|||
|
|
@ -20,47 +20,11 @@ labels:
|
|||
menuTitle: Configure
|
||||
title: Configure CloudWatch
|
||||
weight: 100
|
||||
refs:
|
||||
logs:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/panels-visualizations/visualizations/logs/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/panels-visualizations/visualizations/logs/
|
||||
provisioning-data-sources:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/administration/provisioning/#data-sources
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/administration/provisioning/#data-sources
|
||||
configure-grafana-aws:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/#aws
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/#aws
|
||||
data-source-management:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/administration/data-source-management/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/administration/data-source-management/
|
||||
cloudwatch-aws-authentication:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/aws-cloudwatch/aws-authentication/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/aws-cloudwatch/aws-authentication/
|
||||
private-data-source-connect:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/
|
||||
configure-pdc:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/configure-pdc/#configure-grafana-private-data-source-connect-pdc
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/configure-pdc/#configure-grafana-private-data-source-connect-pdc
|
||||
---
|
||||
|
||||
# Configure the Amazon CloudWatch data source
|
||||
|
||||
This document provides instructions for configuring the Amazon CloudWatch data source and explains available configuration options. For general information on adding and managing data sources, refer to [Data source management](ref:data-source-management).
|
||||
This document provides instructions for configuring the Amazon CloudWatch data source and explains available configuration options. For general information on adding and managing data sources, refer to [Data source management](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/administration/data-source-management/).
|
||||
|
||||
## Before you begin
|
||||
|
||||
|
|
@ -94,7 +58,7 @@ The following are configuration options for the CloudWatch data source.
|
|||
Grafana plugin requests to AWS are made on behalf of an AWS Identity and Access Management (IAM) role or IAM user.
|
||||
The IAM user or IAM role must have the associated policies to perform certain API actions.
|
||||
|
||||
For authentication options and configuration details, refer to [AWS authentication](ref:cloudwatch-aws-authentication).
|
||||
For authentication options and configuration details, refer to [AWS authentication](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/datasources/aws-cloudwatch/aws-authentication/).
|
||||
|
||||
| Setting | Description |
|
||||
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
|
|
@ -137,15 +101,15 @@ You must use both an access key ID and a secret access key to authenticate.
|
|||
| --------------- | ----------------------------------------------------- |
|
||||
| **Data source** | Select the X-Ray data source from the drop-down menu. |
|
||||
|
||||
Grafana automatically creates a link to a trace in X-Ray data source if logs contain the `@xrayTraceId` field. To use this feature, you must already have an X-Ray data source configured. For details, see the [X-Ray data source docs](/grafana/plugins/grafana-X-Ray-datasource/). To view the X-Ray link, select the log row in either the Explore view or dashboard [Logs panel](ref:logs) to view the log details section.
|
||||
Grafana automatically creates a link to a trace in X-Ray data source if logs contain the `@xrayTraceId` field. To use this feature, you must already have an X-Ray data source configured. For details, see the [X-Ray data source docs](/grafana/plugins/grafana-X-Ray-datasource/). To view the X-Ray link, select the log row in either the Explore view or dashboard [Logs panel](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/panels-visualizations/visualizations/logs/) to view the log details section.
|
||||
|
||||
To log the `@xrayTraceId`, refer to the [AWS X-Ray documentation](https://docs.aws.amazon.com/xray/latest/devguide/xray-services.html). To provide the field to Grafana, your log queries must also contain the `@xrayTraceId` field, for example by using the query `fields @message, @xrayTraceId`.
|
||||
|
||||
**Private data source connect** - _Only for Grafana Cloud users._
|
||||
|
||||
| Setting | Description |
|
||||
| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Private data source connect** | Establishes a private, secured connection between a Grafana Cloud stack and data sources within a private network. Use the drop-down to locate the PDC URL. For setup instructions, refer to [Private data source connect (PDC)](ref:private-data-source-connect) and [Configure PDC](ref:configure-pdc). Click **Manage private data source connect** to open your PDC connection page and view your configuration details. |
|
||||
| Setting | Description |
|
||||
| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Private data source connect** | Establishes a private, secured connection between a Grafana Cloud stack and data sources within a private network. Use the drop-down to locate the PDC URL. For setup instructions, refer to [Private data source connect (PDC)](https://grafana.com/docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/) and [Configure PDC](https://grafana.com/docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/configure-pdc/#configure-grafana-private-data-source-connect-pdc). Click **Manage private data source connect** to open your PDC connection page and view your configuration details. |
|
||||
|
||||
After configuring your Amazon CloudWatch data source options, click **Save & test** at the bottom to test the connection. You should see a confirmation dialog box that says:
|
||||
|
||||
|
|
@ -158,7 +122,7 @@ To troubleshoot issues while setting up the CloudWatch data source, check the `/
|
|||
### IAM policy examples
|
||||
|
||||
To read CloudWatch metrics and EC2 tags, instances, regions, and alarms, you must grant Grafana permissions via IAM.
|
||||
You can attach these permissions to the IAM role or IAM user you configured in [AWS authentication](ref:cloudwatch-aws-authentication).
|
||||
You can attach these permissions to the IAM role or IAM user you configured in [AWS authentication](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/datasources/aws-cloudwatch/aws-authentication/).
|
||||
|
||||
**Metrics-only permissions:**
|
||||
|
||||
|
|
@ -309,7 +273,7 @@ You can attach these permissions to the IAM role or IAM user you configured in [
|
|||
Cross-account observability lets you retrieve metrics and logs across different accounts in a single region, but you can't query EC2 Instance Attributes across accounts because those come from the EC2 API and not the CloudWatch API.
|
||||
{{< /admonition >}}
|
||||
|
||||
For more information on configuring authentication, refer to [Configure AWS authentication](ref:cloudwatch-aws-authentication).
|
||||
For more information on configuring authentication, refer to [Configure AWS authentication](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/datasources/aws-cloudwatch/aws-authentication/).
|
||||
|
||||
### CloudWatch Logs data protection
|
||||
|
||||
|
|
@ -317,7 +281,7 @@ CloudWatch Logs can protect data by applying log group data protection policies.
|
|||
|
||||
### Configure the data source with grafana.ini
|
||||
|
||||
The Grafana [configuration file](ref:configure-grafana-aws) includes an `AWS` section where you can configure data source options:
|
||||
The Grafana [configuration file](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/#aws) includes an `AWS` section where you can configure data source options:
|
||||
|
||||
| Configuration option | Description |
|
||||
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
|
|
@ -328,7 +292,7 @@ The Grafana [configuration file](ref:configure-grafana-aws) includes an `AWS` se
|
|||
### Provision the data source
|
||||
|
||||
You can define and configure the data source in YAML files as part of the Grafana provisioning system.
|
||||
For more information about provisioning and available configuration options, refer to [Provision Grafana](ref:provisioning-data-sources).
|
||||
For more information about provisioning and available configuration options, refer to [Provision Grafana](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/administration/provisioning/#data-sources).
|
||||
|
||||
**Using AWS SDK (default)**:
|
||||
|
||||
|
|
|
|||
|
|
@ -18,37 +18,11 @@ labels:
|
|||
menuTitle: Query editor
|
||||
title: Amazon CloudWatch query editor
|
||||
weight: 200
|
||||
refs:
|
||||
query-transform-data:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/panels-visualizations/query-transform-data/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/panels-visualizations/query-transform-data/
|
||||
explore:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/explore/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/explore/
|
||||
query-transform-data-navigate-the-query-tab:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/panels-visualizations/query-transform-data/#navigate-the-query-tab
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/panels-visualizations/query-transform-data/#navigate-the-query-tab
|
||||
alerting:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/alerting/
|
||||
add-template-variables:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/variables/add-template-variables/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/variables/add-template-variables/
|
||||
---
|
||||
|
||||
# Amazon CloudWatch query editor
|
||||
|
||||
Grafana provides a query editor for the CloudWatch data source, which allows you to query, visualize, and alert on logs and metrics stored in Amazon CloudWatch. It is located on the [Explore](ref:explore) page. For general documentation on querying data sources in Grafana, refer to [Query and transform data](ref:query-transform-data).
|
||||
Grafana provides a query editor for the CloudWatch data source, which allows you to query, visualize, and alert on logs and metrics stored in Amazon CloudWatch. It is located on the [Explore](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/explore/) page. For general documentation on querying data sources in Grafana, refer to [Query and transform data](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/panels-visualizations/query-transform-data/).
|
||||
|
||||
## Choose a query editing mode
|
||||
|
||||
|
|
@ -142,7 +116,7 @@ The query returns the average CPU utilization for all EC2 instances in the defau
|
|||
|
||||
Auto-scaling events add new instances to the graph without manual instance ID tracking. This feature supports up to 100 metrics.
|
||||
|
||||
Click the [**Query inspector**](ref:query-transform-data-navigate-the-query-tab) button and select **Meta Data** to see the search expression that's automatically built to support wildcards.
|
||||
Click the [**Query inspector**](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/panels-visualizations/query-transform-data/#navigate-the-query-tab) button and select **Meta Data** to see the search expression that's automatically built to support wildcards.
|
||||
|
||||
To learn more about search expressions, refer to the [CloudWatch documentation](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/search-expression-syntax.html).
|
||||
The search expression is defined by default in such a way that the queried metrics must match the defined dimension names exactly.
|
||||
|
|
@ -212,7 +186,7 @@ For details about the Metrics Insights syntax, refer to the [AWS reference docum
|
|||
|
||||
For information about Metrics Insights limits, refer to the [AWS feature documentation](https://docs.aws.amazon.com/console/cloudwatch/metricsinsights).
|
||||
|
||||
You can also augment queries by using [template variables](ref:add-template-variables).
|
||||
You can also augment queries by using [template variables](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/dashboards/variables/add-template-variables/).
|
||||
|
||||
### Use Metrics Insights keywords
|
||||
|
||||
|
|
@ -299,7 +273,7 @@ WHERE `@message` LIKE '%Exception%'
|
|||
To reference log groups in a monitoring account, use ARNs instead of LogGroup names.
|
||||
|
||||
You can also write queries returning time series data by using the [`stats` command](https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/CWL_Insights-Visualizing-Log-Data.html).
|
||||
When making `stats` queries in [Explore](ref:explore), ensure you are in Metrics Explore mode.
|
||||
When making `stats` queries in [Explore](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/explore/), ensure you are in Metrics Explore mode.
|
||||
|
||||
### Create queries for alerting
|
||||
|
||||
|
|
@ -318,7 +292,7 @@ filter @message like /Exception/
|
|||
If you receive an error like `input data must be a wide series but got ...` when trying to alert on a query, make sure that your query returns valid numeric data that can be output to a Time series panel.
|
||||
{{< /admonition >}}
|
||||
|
||||
For more information on Grafana alerts, refer to [Alerting](ref:alerting).
|
||||
For more information on Grafana alerts, refer to [Alerting](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/alerting/).
|
||||
|
||||
## Cross-account observability
|
||||
|
||||
|
|
|
|||
|
|
@ -17,22 +17,6 @@ labels:
|
|||
menuTitle: Template variables
|
||||
title: CloudWatch template variables
|
||||
weight: 300
|
||||
refs:
|
||||
variable-syntax:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/variables/variable-syntax/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/variables/variable-syntax/
|
||||
add-template-variables:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/variables/add-template-variables/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/variables/add-template-variables/
|
||||
variables:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/variables/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/variables/
|
||||
---
|
||||
|
||||
# CloudWatch template variables
|
||||
|
|
@ -42,7 +26,7 @@ Grafana lists these variables in drop-down select boxes at the top of the dashbo
|
|||
|
||||
<!-- Grafana refers to such variables as template variables. -->
|
||||
|
||||
For an introduction to templating and template variables, refer to [Templating](ref:variables) and [Add and manage variables](ref:add-template-variables).
|
||||
For an introduction to templating and template variables, refer to [Templating](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/dashboards/variables/) and [Add and manage variables](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/dashboards/variables/add-template-variables/).
|
||||
|
||||
## Use query variables
|
||||
|
||||
|
|
@ -69,7 +53,7 @@ For details about the metrics CloudWatch provides, refer to the [CloudWatch docu
|
|||
### Use variables in queries
|
||||
|
||||
Use the Grafana variable syntax to include variables in queries. A query variable in dynamically retrieves values from your data source using a query.
|
||||
For details, refer to the [variable syntax documentation](ref:variable-syntax).
|
||||
For details, refer to the [variable syntax documentation](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/dashboards/variables/variable-syntax/).
|
||||
|
||||
## Use ec2_instance_attribute
|
||||
|
||||
|
|
|
|||
|
|
@ -18,37 +18,11 @@ labels:
|
|||
menuTitle: Troubleshooting
|
||||
title: Troubleshoot Amazon CloudWatch data source issues
|
||||
weight: 500
|
||||
refs:
|
||||
configure-cloudwatch:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/aws-cloudwatch/configure/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/aws-cloudwatch/configure/
|
||||
cloudwatch-aws-authentication:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/aws-cloudwatch/aws-authentication/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/aws-cloudwatch/aws-authentication/
|
||||
cloudwatch-template-variables:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/aws-cloudwatch/template-variables/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/aws-cloudwatch/template-variables/
|
||||
cloudwatch-query-editor:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/aws-cloudwatch/query-editor/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/aws-cloudwatch/query-editor/
|
||||
private-data-source-connect:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/
|
||||
---
|
||||
|
||||
# Troubleshoot Amazon CloudWatch data source issues
|
||||
|
||||
This document provides solutions to common issues you may encounter when configuring or using the Amazon CloudWatch data source. For configuration instructions, refer to [Configure CloudWatch](ref:configure-cloudwatch).
|
||||
This document provides solutions to common issues you may encounter when configuring or using the Amazon CloudWatch data source. For configuration instructions, refer to [Configure CloudWatch](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/datasources/aws-cloudwatch/configure/).
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
The data source health check validates both metrics and logs permissions. If your IAM policy only grants access to one of these (for example, metrics-only or logs-only), the health check displays a red status. However, the service you have permissions for is still usable—you can query metrics or logs based on whichever permissions are configured.
|
||||
|
|
@ -68,13 +42,13 @@ These errors occur when AWS credentials are invalid, missing, or don't have the
|
|||
|
||||
**Possible causes and solutions:**
|
||||
|
||||
| Cause | Solution |
|
||||
| --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| IAM policy missing required permissions | Attach the appropriate IAM policy to your user or role. For metrics, you need `cloudwatch:ListMetrics`, `cloudwatch:GetMetricData`, and related permissions. For logs, you need `logs:DescribeLogGroups`, `logs:StartQuery`, `logs:GetQueryResults`, and related permissions. Refer to [Configure CloudWatch](ref:configure-cloudwatch) for complete policy examples. |
|
||||
| Incorrect access key or secret key | Verify the credentials in the AWS Console under **IAM** > **Users** > your user > **Security credentials**. Generate new credentials if necessary. |
|
||||
| Credentials have expired | For temporary credentials, generate new ones. For access keys, verify they haven't been deactivated or deleted. |
|
||||
| Wrong AWS region | Verify the default region in the data source configuration matches where your resources are located. |
|
||||
| Assume Role ARN is incorrect | Verify the role ARN format: `arn:aws:iam::<account-id>:role/<role-name>`. Check that the role exists in the AWS Console. |
|
||||
| Cause | Solution |
|
||||
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| IAM policy missing required permissions | Attach the appropriate IAM policy to your user or role. For metrics, you need `cloudwatch:ListMetrics`, `cloudwatch:GetMetricData`, and related permissions. For logs, you need `logs:DescribeLogGroups`, `logs:StartQuery`, `logs:GetQueryResults`, and related permissions. Refer to [Configure CloudWatch](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/datasources/aws-cloudwatch/configure/) for complete policy examples. |
|
||||
| Incorrect access key or secret key | Verify the credentials in the AWS Console under **IAM** > **Users** > your user > **Security credentials**. Generate new credentials if necessary. |
|
||||
| Credentials have expired | For temporary credentials, generate new ones. For access keys, verify they haven't been deactivated or deleted. |
|
||||
| Wrong AWS region | Verify the default region in the data source configuration matches where your resources are located. |
|
||||
| Assume Role ARN is incorrect | Verify the role ARN format: `arn:aws:iam::<account-id>:role/<role-name>`. Check that the role exists in the AWS Console. |
|
||||
|
||||
### "Unable to assume role"
|
||||
|
||||
|
|
@ -130,7 +104,7 @@ These errors occur when AWS credentials are invalid, missing, or don't have the
|
|||
- ECS task role (if running in ECS)
|
||||
- EKS service account (if running in EKS)
|
||||
1. Ensure the Grafana process has permission to read the credentials file.
|
||||
1. For EKS with IRSA, set the pod's security context to allow user 472 (grafana) to access the projected token. Refer to [AWS authentication](ref:cloudwatch-aws-authentication) for details.
|
||||
1. For EKS with IRSA, set the pod's security context to allow user 472 (grafana) to access the projected token. Refer to [AWS authentication](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/datasources/aws-cloudwatch/aws-authentication/) for details.
|
||||
|
||||
### Credentials file not found
|
||||
|
||||
|
|
@ -163,7 +137,7 @@ These errors occur when Grafana cannot reach AWS CloudWatch endpoints.
|
|||
1. Verify network connectivity from the Grafana server to AWS endpoints.
|
||||
1. Check firewall rules allow outbound HTTPS (port 443) to AWS services.
|
||||
1. If using a VPC, ensure proper NAT gateway or VPC endpoint configuration.
|
||||
1. For Grafana Cloud connecting to private resources, configure [Private data source connect](ref:private-data-source-connect).
|
||||
1. For Grafana Cloud connecting to private resources, configure [Private data source connect](https://grafana.com/docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/).
|
||||
1. Check if the default region is correct—incorrect regions may cause longer timeouts.
|
||||
1. Increase the timeout settings if queries involve large data volumes.
|
||||
|
||||
|
|
@ -360,7 +334,7 @@ These errors occur when using template variables with the CloudWatch data source
|
|||
1. For dependent variables, ensure parent variables have valid selections.
|
||||
1. Verify the region is set correctly (use "default" for the data source's default region).
|
||||
|
||||
For more information on template variables, refer to [CloudWatch template variables](ref:cloudwatch-template-variables).
|
||||
For more information on template variables, refer to [CloudWatch template variables](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/datasources/aws-cloudwatch/template-variables/).
|
||||
|
||||
### Multi-value template variables cause query failures
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ keywords:
|
|||
- grafana
|
||||
- opentsdb
|
||||
- guide
|
||||
- time series
|
||||
- tsdb
|
||||
labels:
|
||||
products:
|
||||
- cloud
|
||||
|
|
@ -16,134 +18,90 @@ labels:
|
|||
menuTitle: OpenTSDB
|
||||
title: OpenTSDB data source
|
||||
weight: 1100
|
||||
last_reviewed: 2026-01-28
|
||||
refs:
|
||||
provisioning-data-sources:
|
||||
configure-opentsdb:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/administration/provisioning/#data-sources
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/opentsdb/configure/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/administration/provisioning/#data-sources
|
||||
variables:
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/opentsdb/configure/
|
||||
query-editor-opentsdb:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/variables/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/opentsdb/query-editor/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/variables/
|
||||
data-source-management:
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/opentsdb/query-editor/
|
||||
template-variables-opentsdb:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/administration/data-source-management/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/opentsdb/template-variables/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/administration/data-source-management/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/opentsdb/template-variables/
|
||||
alerting-opentsdb:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/opentsdb/alerting/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/opentsdb/alerting/
|
||||
annotations-opentsdb:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/opentsdb/annotations/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/opentsdb/annotations/
|
||||
troubleshooting-opentsdb:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/opentsdb/troubleshooting/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/opentsdb/troubleshooting/
|
||||
explore:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/explore/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/explore/
|
||||
---
|
||||
|
||||
# OpenTSDB data source
|
||||
|
||||
Grafana ships with advanced support for OpenTSDB.
|
||||
This topic explains configuration, variables, querying, and other features specific to the OpenTSDB data source.
|
||||
Grafana ships with support for OpenTSDB, an open source time series database built on top of HBase. Use the OpenTSDB data source to visualize metrics, create alerts, and build dashboards from your time series data.
|
||||
|
||||
For instructions on how to add a data source to Grafana, refer to the [administration documentation](ref:data-source-management).
|
||||
Only users with the organization administrator role can add data sources.
|
||||
Administrators can also [configure the data source via YAML](#provision-the-data-source) with Grafana's provisioning system.
|
||||
## Supported features
|
||||
|
||||
## OpenTSDB settings
|
||||
The OpenTSDB data source supports the following features:
|
||||
|
||||
To configure basic settings for the data source, complete the following steps:
|
||||
| Feature | Supported | Notes |
|
||||
| ------------------ | --------- | -------------------------------------------------------------------- |
|
||||
| Metrics queries | Yes | Query time series data with aggregation, downsampling, and filtering |
|
||||
| Alerting | Yes | Create alert rules based on OpenTSDB queries |
|
||||
| Annotations | Yes | Overlay events on graphs using metric-specific or global annotations |
|
||||
| Template variables | Yes | Use dynamic variables in queries |
|
||||
| Explore | Yes | Ad-hoc data exploration without dashboards |
|
||||
|
||||
1. Click **Connections** in the left-side menu.
|
||||
1. Under Your connections, click **Data sources**.
|
||||
1. Enter `OpenTSDB` in the search bar.
|
||||
1. Select **OpenTSDB**.
|
||||
## Supported OpenTSDB versions
|
||||
|
||||
The **Settings** tab of the data source is displayed.
|
||||
The data source supports OpenTSDB versions 2.1 through 2.4. Some features are version-specific:
|
||||
|
||||
1. Set the data source's basic configuration options:
|
||||
| Feature | Minimum version |
|
||||
| ------------- | --------------- |
|
||||
| Filters | 2.2 |
|
||||
| Fill policies | 2.2 |
|
||||
| Explicit tags | 2.3 |
|
||||
|
||||
| Name | Description |
|
||||
| ------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| **Name** | The data source name. This is how you refer to the data source in panels and queries. |
|
||||
| **Default** | Default data source that will be be pre-selected for new panels. |
|
||||
| **URL** | The HTTP protocol, IP, and port of your OpenTSDB server (default port is usually 4242). |
|
||||
| **Allowed cookies** | Listing of cookies to forward to the data source. |
|
||||
| **Version** | The OpenTSDB version (supported versions are: 2.4, 2.3, 2.2 and versions less than 2.1). |
|
||||
| **Resolution** | Metrics from OpenTSDB may have data points with either second or millisecond resolution. |
|
||||
| **Lookup limit** | Default is 1000. |
|
||||
## Get started
|
||||
|
||||
### Provision the data source
|
||||
The following documents help you get started with the OpenTSDB data source:
|
||||
|
||||
You can define and configure the data source in YAML files as part of Grafana's provisioning system.
|
||||
For more information about provisioning, and for available configuration options, refer to [Provisioning Grafana](ref:provisioning-data-sources).
|
||||
- [Configure the OpenTSDB data source](ref:configure-opentsdb) - Set up authentication and connect to OpenTSDB.
|
||||
- [OpenTSDB query editor](ref:query-editor-opentsdb) - Create and edit queries with aggregation, downsampling, and filtering.
|
||||
- [Template variables](ref:template-variables-opentsdb) - Create dynamic dashboards with OpenTSDB variables.
|
||||
- [Troubleshooting](ref:troubleshooting-opentsdb) - Solve common configuration and query errors.
|
||||
|
||||
#### Provisioning example
|
||||
## Additional features
|
||||
|
||||
```yaml
|
||||
apiVersion: 1
|
||||
After you have configured the OpenTSDB data source, you can:
|
||||
|
||||
datasources:
|
||||
- name: OpenTSDB
|
||||
type: opentsdb
|
||||
access: proxy
|
||||
url: http://localhost:4242
|
||||
jsonData:
|
||||
tsdbResolution: 1
|
||||
tsdbVersion: 1
|
||||
```
|
||||
- Add [Annotations](ref:annotations-opentsdb) to overlay OpenTSDB events on your graphs.
|
||||
- Configure and use [Template variables](ref:template-variables-opentsdb) for dynamic dashboards.
|
||||
- Set up [Alerting](ref:alerting-opentsdb) rules based on your time series queries.
|
||||
- Use [Explore](ref:explore) to investigate your OpenTSDB data without building a dashboard.
|
||||
|
||||
## Query editor
|
||||
## Related resources
|
||||
|
||||
Open a graph in edit mode by click the title. Query editor will differ if the data source has version <=2.1 or = 2.2.
|
||||
In the former version, only tags can be used to query OpenTSDB. But in the latter version, filters as well as tags
|
||||
can be used to query OpenTSDB. Fill Policy is also introduced in OpenTSDB 2.2.
|
||||
|
||||

|
||||
|
||||
{{< admonition type="note" >}}
|
||||
While using OpenTSDB 2.2 data source, make sure you use either Filters or Tags as they are mutually exclusive. If used together, might give you weird results.
|
||||
{{< /admonition >}}
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
When using OpenTSDB 2.4 with alerting, queries are executed with the parameter `arrays=true`. This causes OpenTSDB to return data points as an array of arrays instead of a map of key-value pairs. Grafana then converts this data into the appropriate data frame format.
|
||||
{{< /admonition >}}
|
||||
|
||||
### Auto complete suggestions
|
||||
|
||||
As you begin typing metric names, tag names, or tag values, highlighted autocomplete suggestions will appear.
|
||||
The autocomplete only works if the OpenTSDB suggest API is enabled.
|
||||
|
||||
## Templating queries
|
||||
|
||||
Instead of hard-coding things like server, application and sensor name in your metric queries you can use variables in their place.
|
||||
Variables are shown as dropdown select boxes at the top of the dashboard. These dropdowns make it easy to change the data
|
||||
being displayed in your dashboard.
|
||||
|
||||
Check out the [Templating](ref:variables) documentation for an introduction to the templating feature and the different
|
||||
types of template variables.
|
||||
|
||||
### Query variable
|
||||
|
||||
Grafana's OpenTSDB data source supports template variable queries. This means you can create template variables
|
||||
that fetch the values from OpenTSDB. For example, metric names, tag names, or tag values.
|
||||
|
||||
When using OpenTSDB with a template variable of `query` type you can use following syntax for lookup.
|
||||
|
||||
| Query | Description |
|
||||
| --------------------------- | --------------------------------------------------------------------------------- |
|
||||
| `metrics(prefix)` | Returns metric names with specific prefix (can be empty) |
|
||||
| `tag_names(cpu)` | Returns tag names (i.e. keys) for a specific cpu metric |
|
||||
| `tag_values(cpu, hostname)` | Returns tag values for metric cpu and tag key hostname |
|
||||
| `suggest_tagk(prefix)` | Returns tag names (i.e. keys) for all metrics with specific prefix (can be empty) |
|
||||
| `suggest_tagv(prefix)` | Returns tag values for all metrics with specific prefix (can be empty) |
|
||||
|
||||
If you do not see template variables being populated in `Preview of values` section, you need to enable
|
||||
`tsd.core.meta.enable_realtime_ts` in the OpenTSDB server settings. Also, to populate metadata of
|
||||
the existing time series data in OpenTSDB, you need to run `tsdb uid metasync` on the OpenTSDB server.
|
||||
|
||||
### Nested templating
|
||||
|
||||
One template variable can be used to filter tag values for another template variable. First parameter is the metric name,
|
||||
second parameter is the tag key for which you need to find tag values, and after that all other dependent template variables.
|
||||
Some examples are mentioned below to make nested template queries work successfully.
|
||||
|
||||
| Query | Description |
|
||||
| ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| `tag_values(cpu, hostname, env=$env)` | Return tag values for cpu metric, selected env tag value and tag key hostname |
|
||||
| `tag_values(cpu, hostname, env=$env, region=$region)` | Return tag values for cpu metric, selected env tag value, selected region tag value and tag key hostname |
|
||||
|
||||
For details on OpenTSDB metric queries, check out the official [OpenTSDB documentation](http://opentsdb.net/docs/build/html/index.html)
|
||||
- [Official OpenTSDB documentation](http://opentsdb.net/docs/build/html/index.html)
|
||||
- [Grafana community forums](https://community.grafana.com/)
|
||||
|
|
|
|||
195
docs/sources/datasources/opentsdb/alerting/index.md
Normal file
195
docs/sources/datasources/opentsdb/alerting/index.md
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
---
|
||||
description: Use Grafana Alerting with the OpenTSDB data source
|
||||
keywords:
|
||||
- grafana
|
||||
- opentsdb
|
||||
- alerting
|
||||
- alerts
|
||||
- notifications
|
||||
labels:
|
||||
products:
|
||||
- cloud
|
||||
- enterprise
|
||||
- oss
|
||||
menuTitle: Alerting
|
||||
title: OpenTSDB alerting
|
||||
weight: 400
|
||||
last_reviewed: 2026-01-28
|
||||
refs:
|
||||
alerting:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/alerting/
|
||||
create-alert-rule:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/alerting-rules/create-grafana-managed-rule/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/alerting/alerting-rules/create-grafana-managed-rule/
|
||||
configure-opentsdb:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/opentsdb/configure/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/opentsdb/configure/
|
||||
explore:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/explore/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/explore/
|
||||
query-editor:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/opentsdb/query-editor/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/opentsdb/query-editor/
|
||||
troubleshooting:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/opentsdb/troubleshooting/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/opentsdb/troubleshooting/
|
||||
---
|
||||
|
||||
# OpenTSDB alerting
|
||||
|
||||
You can use Grafana Alerting with OpenTSDB to create alerts based on your time series data. This allows you to monitor metrics, detect anomalies, and receive notifications when specific conditions are met.
|
||||
|
||||
For general information about Grafana Alerting, refer to [Grafana Alerting](ref:alerting).
|
||||
|
||||
## Before you begin
|
||||
|
||||
Before creating alerts with OpenTSDB, ensure you have:
|
||||
|
||||
- An OpenTSDB data source configured in Grafana. Refer to [Configure the OpenTSDB data source](ref:configure-opentsdb).
|
||||
- Appropriate permissions to create alert rules.
|
||||
- Understanding of the metrics you want to monitor.
|
||||
|
||||
## Supported features
|
||||
|
||||
OpenTSDB alerting works with standard metric queries that return time series data. The following table summarizes alerting compatibility:
|
||||
|
||||
| Query type | Alerting support | Notes |
|
||||
| ----------------------------- | ---------------- | ------------------------------------ |
|
||||
| Metrics with aggregation | Yes | Recommended for alerting |
|
||||
| Metrics with downsampling | Yes | Use appropriate intervals |
|
||||
| Metrics with rate calculation | Yes | Useful for counter metrics |
|
||||
| Metrics with filters/tags | Yes | Filter to specific hosts or services |
|
||||
|
||||
## Create an alert rule
|
||||
|
||||
To create an alert rule using OpenTSDB:
|
||||
|
||||
1. Navigate to **Alerting** > **Alert rules**.
|
||||
1. Click **New alert rule**.
|
||||
1. Enter a name for the alert rule.
|
||||
1. Select your **OpenTSDB** data source.
|
||||
1. Build your query:
|
||||
- Select the metric to monitor.
|
||||
- Choose an appropriate aggregator (for example, `avg`, `sum`, `max`).
|
||||
- Add tag filters to target specific resources.
|
||||
- Enable downsampling with an interval matching your evaluation frequency.
|
||||
1. Configure the alert condition (for example, when the value is above a threshold).
|
||||
1. Set the evaluation interval and pending period.
|
||||
1. Configure notifications and labels.
|
||||
1. Click **Save rule**.
|
||||
|
||||
For detailed instructions, refer to [Create a Grafana-managed alert rule](ref:create-alert-rule).
|
||||
|
||||
## Example alert queries
|
||||
|
||||
The following examples show common alerting scenarios with OpenTSDB.
|
||||
|
||||
### Alert on high CPU usage
|
||||
|
||||
Monitor CPU usage and alert when it exceeds 90%:
|
||||
|
||||
| Field | Value |
|
||||
| --------------------- | -------------- |
|
||||
| Metric | `sys.cpu.user` |
|
||||
| Aggregator | `avg` |
|
||||
| Tags | `host=*` |
|
||||
| Downsample Interval | `1m` |
|
||||
| Downsample Aggregator | `avg` |
|
||||
|
||||
**Condition:** When average is above `90`
|
||||
|
||||
### Alert on low disk space
|
||||
|
||||
Monitor available disk space and alert when it drops below a threshold:
|
||||
|
||||
| Field | Value |
|
||||
| --------------------- | ------------------- |
|
||||
| Metric | `sys.disk.free` |
|
||||
| Aggregator | `min` |
|
||||
| Tags | `host=*`, `mount=/` |
|
||||
| Downsample Interval | `5m` |
|
||||
| Downsample Aggregator | `min` |
|
||||
|
||||
**Condition:** When minimum is below `10737418240` (10 GB in bytes)
|
||||
|
||||
### Alert on high network traffic rate
|
||||
|
||||
Monitor network bytes received and alert on high traffic:
|
||||
|
||||
| Field | Value |
|
||||
| --------------------- | -------------------- |
|
||||
| Metric | `net.bytes.received` |
|
||||
| Aggregator | `sum` |
|
||||
| Tags | `host=webserver01` |
|
||||
| Rate | enabled |
|
||||
| Counter | enabled |
|
||||
| Downsample Interval | `1m` |
|
||||
| Downsample Aggregator | `avg` |
|
||||
|
||||
**Condition:** When sum is above `104857600` (100 MB/s in bytes)
|
||||
|
||||
### Alert on error count spike
|
||||
|
||||
Monitor application error counts:
|
||||
|
||||
| Field | Value |
|
||||
| --------------------- | ------------------------------- |
|
||||
| Metric | `app.errors.count` |
|
||||
| Aggregator | `sum` |
|
||||
| Tags | `service=api`, `env=production` |
|
||||
| Downsample Interval | `5m` |
|
||||
| Downsample Aggregator | `sum` |
|
||||
|
||||
**Condition:** When sum is above `100`
|
||||
|
||||
## Limitations
|
||||
|
||||
When using OpenTSDB with Grafana Alerting, be aware of the following limitations.
|
||||
|
||||
### Template variables not supported
|
||||
|
||||
Alert queries can't contain template variables. Grafana evaluates alert rules on the backend without dashboard context, so variables like `$hostname` or `$environment` aren't resolved.
|
||||
|
||||
If your dashboard query uses template variables, create a separate query for alerting with hard-coded values.
|
||||
|
||||
### Query complexity
|
||||
|
||||
Complex queries with many tags or long time ranges may timeout or fail to evaluate. Simplify queries for alerting by:
|
||||
|
||||
- Using specific tag filters instead of wildcards where possible.
|
||||
- Enabling downsampling with appropriate intervals.
|
||||
- Reducing the evaluation time range.
|
||||
|
||||
### OpenTSDB 2.4 behavior
|
||||
|
||||
When using OpenTSDB 2.4 with alerting, Grafana executes queries with the parameter `arrays=true`. This causes OpenTSDB to return data points as an array of arrays instead of a map of key-value pairs. Grafana automatically converts this data to the appropriate format.
|
||||
|
||||
## Best practices
|
||||
|
||||
Follow these best practices when creating OpenTSDB alerts:
|
||||
|
||||
- **Use specific tag filters:** Add tag filters to focus on relevant resources and improve query performance.
|
||||
- **Match downsample interval to evaluation:** Set the downsample interval to match or be slightly smaller than your alert evaluation interval.
|
||||
- **Test queries first:** Verify your query returns expected results in [Explore](ref:explore) before creating an alert.
|
||||
- **Set realistic thresholds:** Base alert thresholds on historical data patterns to avoid false positives.
|
||||
- **Use meaningful names:** Give alert rules descriptive names that indicate what they monitor.
|
||||
- **Enable downsampling:** Always enable downsampling for alerting queries to reduce data volume and improve reliability.
|
||||
- **Consider counter resets:** For counter metrics, enable the Counter option and set appropriate max values to handle resets correctly.
|
||||
|
||||
## Next steps
|
||||
|
||||
- [Build queries](ref:query-editor) to explore your metrics before creating alerts.
|
||||
- [Troubleshoot issues](ref:troubleshooting) if alerts aren't firing as expected.
|
||||
254
docs/sources/datasources/opentsdb/annotations/index.md
Normal file
254
docs/sources/datasources/opentsdb/annotations/index.md
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
---
|
||||
description: Use annotations with the OpenTSDB data source in Grafana
|
||||
keywords:
|
||||
- grafana
|
||||
- opentsdb
|
||||
- annotations
|
||||
- events
|
||||
labels:
|
||||
products:
|
||||
- cloud
|
||||
- enterprise
|
||||
- oss
|
||||
menuTitle: Annotations
|
||||
title: OpenTSDB annotations
|
||||
weight: 450
|
||||
last_reviewed: 2026-01-28
|
||||
refs:
|
||||
annotations:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/build-dashboards/annotate-visualizations/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/build-dashboards/annotate-visualizations/
|
||||
query-editor:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/opentsdb/query-editor/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/opentsdb/query-editor/
|
||||
template-variables:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/opentsdb/template-variables/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/opentsdb/template-variables/
|
||||
alerting:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/opentsdb/alerting/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/opentsdb/alerting/
|
||||
---
|
||||
|
||||
# OpenTSDB annotations
|
||||
|
||||
Annotations allow you to overlay event information on graphs, providing context for metric changes. The OpenTSDB data source supports both metric-specific annotations and global annotations stored in OpenTSDB.
|
||||
|
||||
For general information about annotations in Grafana, refer to [Annotate visualizations](ref:annotations).
|
||||
|
||||
## Annotation types
|
||||
|
||||
OpenTSDB supports two types of annotations:
|
||||
|
||||
| Type | Description | Use case |
|
||||
| ---------------------- | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| **Metric annotations** | Annotations attached to a specific time series (TSUID). Retrieved by querying the associated metric. | Track events affecting a specific host or service. |
|
||||
| **Global annotations** | Annotations not tied to any time series. Apply system-wide. | Track deployments, maintenance windows, or infrastructure-wide events. |
|
||||
|
||||
## How Grafana retrieves annotations
|
||||
|
||||
When you configure an annotation query, Grafana queries OpenTSDB for the specified metric and retrieves any annotations associated with that metric's time series. The query includes the `globalAnnotations=true` parameter, which allows Grafana to also retrieve global annotations when enabled.
|
||||
|
||||
Grafana displays the `description` field from each annotation as the annotation text.
|
||||
|
||||
## Configure an annotation query
|
||||
|
||||
To add OpenTSDB annotations to a dashboard:
|
||||
|
||||
1. Click the dashboard settings icon (gear) in the top navigation.
|
||||
1. Select **Annotations** in the left menu.
|
||||
1. Click **Add annotation query**.
|
||||
1. Select the **OpenTSDB** data source.
|
||||
1. Configure the annotation query fields as described in the following table.
|
||||
1. Click **Save dashboard**.
|
||||
|
||||
## Annotation query fields
|
||||
|
||||
| Field | Description |
|
||||
| --------------------------- | -------------------------------------------------------------------------------- |
|
||||
| **Name** | A descriptive name for this annotation query. Appears in the annotation legend. |
|
||||
| **Data source** | Select the OpenTSDB data source. |
|
||||
| **Enabled** | Toggle to enable or disable this annotation query. |
|
||||
| **OpenTSDB metrics query** | The metric name to query for annotations (for example, `events.deployment`). |
|
||||
| **Show Global Annotations** | Toggle to include global annotations that aren't tied to a specific time series. |
|
||||
|
||||
## Example annotation queries
|
||||
|
||||
The following examples demonstrate common annotation use cases.
|
||||
|
||||
### Track application deployments
|
||||
|
||||
Monitor when deployments occur for a specific application:
|
||||
|
||||
| Field | Value |
|
||||
| ----------------------- | --------------- |
|
||||
| Name | App Deployments |
|
||||
| OpenTSDB metrics query | `deploy.myapp` |
|
||||
| Show Global Annotations | disabled |
|
||||
|
||||
This query retrieves annotations attached to the `deploy.myapp` metric, showing deployment events for that specific application.
|
||||
|
||||
### Monitor infrastructure-wide events
|
||||
|
||||
Capture system-wide events such as network changes or datacenter maintenance:
|
||||
|
||||
| Field | Value |
|
||||
| ----------------------- | ----------------------- |
|
||||
| Name | Infrastructure Events |
|
||||
| OpenTSDB metrics query | `events.infrastructure` |
|
||||
| Show Global Annotations | enabled |
|
||||
|
||||
This query retrieves both metric-specific and global annotations, providing a complete picture of infrastructure events.
|
||||
|
||||
### Track incidents and outages
|
||||
|
||||
Mark incident start and resolution times:
|
||||
|
||||
| Field | Value |
|
||||
| ----------------------- | ----------------- |
|
||||
| Name | Incidents |
|
||||
| OpenTSDB metrics query | `events.incident` |
|
||||
| Show Global Annotations | enabled |
|
||||
|
||||
### Monitor configuration changes
|
||||
|
||||
Track when configuration changes are applied:
|
||||
|
||||
| Field | Value |
|
||||
| ----------------------- | --------------- |
|
||||
| Name | Config Changes |
|
||||
| OpenTSDB metrics query | `events.config` |
|
||||
| Show Global Annotations | disabled |
|
||||
|
||||
### Correlate multiple event types
|
||||
|
||||
You can add multiple annotation queries to a single dashboard to correlate different event types. For example:
|
||||
|
||||
1. Add a "Deployments" annotation query for `deploy.*` metrics.
|
||||
1. Add an "Incidents" annotation query for `events.incident`.
|
||||
1. Add a "Maintenance" annotation query with global annotations enabled.
|
||||
|
||||
This allows you to see how deployments, incidents, and maintenance windows relate to your metric data.
|
||||
|
||||
## How annotations appear
|
||||
|
||||
Annotations appear as vertical lines on time series panels at the timestamps where events occurred. Hover over an annotation marker to view:
|
||||
|
||||
- The annotation name (from your query configuration)
|
||||
- The event description (from the OpenTSDB annotation's `description` field)
|
||||
- The timestamp
|
||||
|
||||
Different annotation queries can be assigned different colors in the dashboard settings to distinguish between event types.
|
||||
|
||||
## Create annotations in OpenTSDB
|
||||
|
||||
To display annotations in Grafana, you must first create them in OpenTSDB. OpenTSDB provides an HTTP API for managing annotations.
|
||||
|
||||
### Annotation data structure
|
||||
|
||||
OpenTSDB annotations have the following fields:
|
||||
|
||||
| Field | Required | Description |
|
||||
| ------------- | -------- | ------------------------------------------------------------------------------------------ |
|
||||
| `startTime` | Yes | Unix epoch timestamp in seconds when the event started. |
|
||||
| `endTime` | No | Unix epoch timestamp in seconds when the event ended. Useful for duration-based events. |
|
||||
| `tsuid` | No | The time series UID to associate this annotation with. If empty, the annotation is global. |
|
||||
| `description` | No | Brief description of the event. This text displays in Grafana. |
|
||||
| `notes` | No | Detailed notes about the event. |
|
||||
| `custom` | No | A map of custom key-value pairs for additional metadata. |
|
||||
|
||||
### Create a global annotation
|
||||
|
||||
Use the OpenTSDB API to create a global annotation:
|
||||
|
||||
```sh
|
||||
curl -X POST http://<OPENTSDB_HOST>:4242/api/annotation \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"startTime": 1609459200,
|
||||
"description": "Production deployment v2.5.0",
|
||||
"notes": "Deployed new feature flags and performance improvements",
|
||||
"custom": {
|
||||
"version": "2.5.0",
|
||||
"environment": "production",
|
||||
"deployer": "jenkins"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### Create a metric-specific annotation
|
||||
|
||||
To attach an annotation to a specific time series, include the `tsuid`:
|
||||
|
||||
```sh
|
||||
curl -X POST http://<OPENTSDB_HOST>:4242/api/annotation \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"startTime": 1609459200,
|
||||
"endTime": 1609462800,
|
||||
"tsuid": "000001000001000001",
|
||||
"description": "Server maintenance",
|
||||
"notes": "Scheduled maintenance window for hardware upgrade"
|
||||
}'
|
||||
```
|
||||
|
||||
To find the TSUID for a metric, use the OpenTSDB `/api/uid/tsmeta` endpoint.
|
||||
|
||||
### Create annotations programmatically
|
||||
|
||||
Integrate annotation creation into your deployment pipelines or monitoring systems:
|
||||
|
||||
**Deployment script example:**
|
||||
|
||||
```sh
|
||||
#!/bin/bash
|
||||
VERSION=$1
|
||||
TIMESTAMP=$(date +%s)
|
||||
|
||||
curl -X POST http://opentsdb.example.com:4242/api/annotation \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"startTime\": $TIMESTAMP,
|
||||
\"description\": \"Deployed version $VERSION\",
|
||||
\"custom\": {
|
||||
\"version\": \"$VERSION\",
|
||||
\"environment\": \"production\"
|
||||
}
|
||||
}"
|
||||
```
|
||||
|
||||
For more details on the annotation API, refer to the [OpenTSDB annotation API documentation](http://opentsdb.net/docs/build/html/api_http/annotation/index.html).
|
||||
|
||||
## Troubleshoot annotation issues
|
||||
|
||||
The following section addresses common issues you may encounter when using OpenTSDB annotations.
|
||||
|
||||
### Annotations don't appear
|
||||
|
||||
**Possible causes and solutions:**
|
||||
|
||||
| Cause | Solution |
|
||||
| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Time range doesn't include annotations | Expand the dashboard time range to include the annotation timestamps. |
|
||||
| Wrong metric name | Verify the metric name in your annotation query matches the metric associated with the annotations in OpenTSDB. |
|
||||
| Annotations are global but toggle is off | Enable **Show Global Annotations** if your annotations don't have a TSUID. |
|
||||
| No annotations exist | Verify annotations exist in OpenTSDB using the API: `curl http://<OPENTSDB_HOST>:4242/api/annotation?startTime=<START>&endTime=<END>` |
|
||||
|
||||
### Annotation text is empty
|
||||
|
||||
The annotation displays but has no description text.
|
||||
|
||||
**Solution:** Ensure the `description` field is populated when creating annotations in OpenTSDB. Grafana displays the `description` field as the annotation text.
|
||||
|
||||
## Next steps
|
||||
|
||||
- [Build queries](ref:query-editor) to visualize metrics alongside annotations.
|
||||
- [Use template variables](ref:template-variables) to create dynamic dashboards.
|
||||
- [Set up alerting](ref:alerting) to get notified when metrics cross thresholds.
|
||||
290
docs/sources/datasources/opentsdb/configure/index.md
Normal file
290
docs/sources/datasources/opentsdb/configure/index.md
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
---
|
||||
description: Configure the OpenTSDB data source in Grafana
|
||||
keywords:
|
||||
- grafana
|
||||
- opentsdb
|
||||
- configuration
|
||||
- provisioning
|
||||
- terraform
|
||||
labels:
|
||||
products:
|
||||
- cloud
|
||||
- enterprise
|
||||
- oss
|
||||
menuTitle: Configure
|
||||
title: Configure the OpenTSDB data source
|
||||
weight: 100
|
||||
last_reviewed: 2026-01-28
|
||||
refs:
|
||||
provisioning-data-sources:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/administration/provisioning/#data-sources
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/administration/provisioning/#data-sources
|
||||
troubleshooting-opentsdb:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/opentsdb/troubleshooting/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/opentsdb/troubleshooting/
|
||||
query-editor:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/opentsdb/query-editor/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/opentsdb/query-editor/
|
||||
template-variables:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/opentsdb/template-variables/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/opentsdb/template-variables/
|
||||
annotations:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/opentsdb/annotations/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/opentsdb/annotations/
|
||||
alerting:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/opentsdb/alerting/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/opentsdb/alerting/
|
||||
---
|
||||
|
||||
# Configure the OpenTSDB data source
|
||||
|
||||
This document explains how to configure the OpenTSDB data source in Grafana.
|
||||
|
||||
## Before you begin
|
||||
|
||||
Before configuring the OpenTSDB data source, ensure you have:
|
||||
|
||||
- **Grafana permissions:** Organization administrator role to add data sources.
|
||||
- **OpenTSDB instance:** A running OpenTSDB server (version 2.1 or later recommended).
|
||||
- **Network access:** The Grafana server can reach the OpenTSDB HTTP API endpoint (default port 4242).
|
||||
- **Metrics in OpenTSDB:** For autocomplete to work, metrics must exist in your OpenTSDB database.
|
||||
|
||||
## Add the data source
|
||||
|
||||
To add and configure the OpenTSDB data source:
|
||||
|
||||
1. Click **Connections** in the left-side menu.
|
||||
1. Click **Add new connection**.
|
||||
1. Type `OpenTSDB` in the search bar.
|
||||
1. Select **OpenTSDB**.
|
||||
1. Click **Add new data source** in the upper right.
|
||||
1. Configure the data source settings as described in the following sections.
|
||||
|
||||
## Configuration options
|
||||
|
||||
The following table describes the available configuration options:
|
||||
|
||||
| Setting | Description |
|
||||
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **Name** | The data source name. This is how you refer to the data source in panels and queries. |
|
||||
| **Default** | Toggle to make this the default data source for new panels. |
|
||||
| **URL** | The HTTP protocol, IP address, and port of your OpenTSDB server. The default port is `4242`. Example: `http://localhost:4242`. |
|
||||
| **Allowed cookies** | Cookies to forward to the data source. Use this when your OpenTSDB server requires specific cookies for authentication. |
|
||||
| **Timeout** | HTTP request timeout in seconds. Increase this value for slow networks or complex queries. |
|
||||
|
||||
## Auth settings
|
||||
|
||||
Configure authentication if your OpenTSDB server requires it:
|
||||
|
||||
| Setting | Description |
|
||||
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **Basic auth** | Enable to authenticate with a username and password. When enabled, enter the username and password in the fields that appear. |
|
||||
| **With Credentials** | Enable to send cookies or auth headers with cross-site requests. Use this when OpenTSDB is on a different domain and requires credentials. |
|
||||
| **TLS Client Authentication** | Enable to use client certificates for authentication. Requires configuring client certificate and key. |
|
||||
| **Skip TLS Verify** | Enable to skip verification of the OpenTSDB server's TLS certificate. Only use this in development environments. |
|
||||
| **Forward OAuth Identity** | Enable to forward the user's OAuth token to the data source. Useful when OpenTSDB is behind an OAuth-protected proxy. |
|
||||
| **Custom HTTP Headers** | Add custom headers to all requests sent to OpenTSDB. Useful for API keys or custom authentication schemes. |
|
||||
|
||||
## OpenTSDB settings
|
||||
|
||||
Configure these settings based on your OpenTSDB server version and configuration:
|
||||
|
||||
| Setting | Description |
|
||||
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Version** | Select your OpenTSDB version. This affects available query features. Refer to the following section for version-specific features. |
|
||||
| **Resolution** | The resolution of your metric data. Select `second` for second-precision timestamps or `millisecond` for millisecond-precision timestamps. |
|
||||
| **Lookup limit** | Maximum number of results returned by suggest and lookup API calls. Default is `1000`. Increase this if you have many metrics or tag values. |
|
||||
|
||||
### Version-specific features
|
||||
|
||||
The version setting enables different query features in Grafana:
|
||||
|
||||
| Version | Available features |
|
||||
| --------- | -------------------------------------------------------------------------------------------------------------------- |
|
||||
| **<=2.1** | Basic queries with tags. Uses legacy tag-based filtering. |
|
||||
| **==2.2** | Adds filter support (literal_or, wildcard, regexp, and more). Filters replace tags for more flexible queries. |
|
||||
| **==2.3** | Adds explicit tags support for rate calculations and additional filter types. |
|
||||
| **==2.4** | Adds fill policy support for downsampling (none, null, zero, nan). Enables `arrays=true` for alerting compatibility. |
|
||||
|
||||
Select the version that matches your OpenTSDB server. If you're unsure, check your OpenTSDB version with the `/api/version` endpoint.
|
||||
|
||||
## Verify the connection
|
||||
|
||||
Click **Save & test** to verify that Grafana can connect to your OpenTSDB server. A successful test confirms that the URL is correct and the server is responding.
|
||||
|
||||
If the test fails, refer to [Troubleshooting](ref:troubleshooting-opentsdb) for common issues and solutions.
|
||||
|
||||
## Provision the data source
|
||||
|
||||
You can define and configure the data source in YAML files as part of the Grafana provisioning system. For more information about provisioning, and for available configuration options, refer to [Provisioning Grafana](ref:provisioning-data-sources).
|
||||
|
||||
### YAML example
|
||||
|
||||
The following example provisions an OpenTSDB data source:
|
||||
|
||||
```yaml
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: OpenTSDB
|
||||
type: opentsdb
|
||||
access: proxy
|
||||
url: http://localhost:4242
|
||||
jsonData:
|
||||
# OpenTSDB version: 1 = <=2.1, 2 = 2.2, 3 = 2.3, 4 = 2.4
|
||||
tsdbVersion: 3
|
||||
# Resolution: 1 = second, 2 = millisecond
|
||||
tsdbResolution: 1
|
||||
# Maximum results for suggest/lookup API calls
|
||||
lookupLimit: 1000
|
||||
```
|
||||
|
||||
### YAML example with basic authentication
|
||||
|
||||
The following example provisions an OpenTSDB data source with basic authentication:
|
||||
|
||||
```yaml
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: OpenTSDB
|
||||
type: opentsdb
|
||||
access: proxy
|
||||
url: http://localhost:4242
|
||||
basicAuth: true
|
||||
basicAuthUser: <USERNAME>
|
||||
jsonData:
|
||||
tsdbVersion: 3
|
||||
tsdbResolution: 1
|
||||
lookupLimit: 1000
|
||||
secureJsonData:
|
||||
basicAuthPassword: <PASSWORD>
|
||||
```
|
||||
|
||||
### YAML example with custom headers
|
||||
|
||||
The following example provisions an OpenTSDB data source with custom HTTP headers:
|
||||
|
||||
```yaml
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: OpenTSDB
|
||||
type: opentsdb
|
||||
access: proxy
|
||||
url: http://localhost:4242
|
||||
jsonData:
|
||||
tsdbVersion: 3
|
||||
tsdbResolution: 1
|
||||
lookupLimit: 1000
|
||||
httpHeaderName1: X-Custom-Header
|
||||
secureJsonData:
|
||||
httpHeaderValue1: <HEADER_VALUE>
|
||||
```
|
||||
|
||||
The following table describes the available fields:
|
||||
|
||||
| Field | Type | Description |
|
||||
| ---------------------------------- | ------- | ---------------------------------------------------------------------------- |
|
||||
| `basicAuth` | boolean | Enable basic authentication. |
|
||||
| `basicAuthUser` | string | Username for basic authentication. |
|
||||
| `jsonData.tsdbVersion` | number | OpenTSDB version: `1` (<=2.1), `2` (2.2), `3` (2.3), `4` (2.4). |
|
||||
| `jsonData.tsdbResolution` | number | Timestamp resolution: `1` (second), `2` (millisecond). |
|
||||
| `jsonData.lookupLimit` | number | Maximum results for suggest and lookup API calls. Default: `1000`. |
|
||||
| `jsonData.httpHeaderName1` | string | Name of a custom HTTP header. Use incrementing numbers for multiple headers. |
|
||||
| `secureJsonData.basicAuthPassword` | string | Password for basic authentication. |
|
||||
| `secureJsonData.httpHeaderValue1` | string | Value for the custom HTTP header. |
|
||||
|
||||
## Provision with Terraform
|
||||
|
||||
You can provision the OpenTSDB data source using [Terraform](https://www.terraform.io/) with the [Grafana Terraform provider](https://registry.terraform.io/providers/grafana/grafana/latest/docs).
|
||||
|
||||
For more information about provisioning resources with Terraform, refer to the [Grafana as code using Terraform](https://grafana.com/docs/grafana-cloud/developer-resources/infrastructure-as-code/terraform/) documentation.
|
||||
|
||||
### Terraform example
|
||||
|
||||
The following example provisions an OpenTSDB data source:
|
||||
|
||||
```hcl
|
||||
terraform {
|
||||
required_providers {
|
||||
grafana = {
|
||||
source = "grafana/grafana"
|
||||
version = ">= 2.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
provider "grafana" {
|
||||
url = "<YOUR_GRAFANA_URL>"
|
||||
auth = "<YOUR_SERVICE_ACCOUNT_TOKEN>"
|
||||
}
|
||||
|
||||
resource "grafana_data_source" "opentsdb" {
|
||||
type = "opentsdb"
|
||||
name = "OpenTSDB"
|
||||
url = "http://localhost:4242"
|
||||
|
||||
json_data_encoded = jsonencode({
|
||||
# OpenTSDB version: 1 = <=2.1, 2 = 2.2, 3 = 2.3, 4 = 2.4
|
||||
tsdbVersion = 3
|
||||
# Resolution: 1 = second, 2 = millisecond
|
||||
tsdbResolution = 1
|
||||
# Maximum results for suggest/lookup API calls
|
||||
lookupLimit = 1000
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Terraform example with basic authentication
|
||||
|
||||
The following example provisions an OpenTSDB data source with basic authentication:
|
||||
|
||||
```hcl
|
||||
resource "grafana_data_source" "opentsdb_auth" {
|
||||
type = "opentsdb"
|
||||
name = "OpenTSDB"
|
||||
url = "http://localhost:4242"
|
||||
basic_auth_enabled = true
|
||||
basic_auth_username = "<USERNAME>"
|
||||
|
||||
json_data_encoded = jsonencode({
|
||||
tsdbVersion = 3
|
||||
tsdbResolution = 1
|
||||
lookupLimit = 1000
|
||||
})
|
||||
|
||||
secure_json_data_encoded = jsonencode({
|
||||
basicAuthPassword = "<PASSWORD>"
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Replace the following placeholders:
|
||||
|
||||
- _`<YOUR_GRAFANA_URL>`_: Your Grafana instance URL (for example, `https://your-org.grafana.net` for Grafana Cloud)
|
||||
- _`<YOUR_SERVICE_ACCOUNT_TOKEN>`_: A service account token with data source permissions
|
||||
- _`<USERNAME>`_: The username for basic authentication
|
||||
- _`<PASSWORD>`_: The password for basic authentication
|
||||
|
||||
## Next steps
|
||||
|
||||
Now that you've configured OpenTSDB, you can:
|
||||
|
||||
- [Query OpenTSDB data](ref:query-editor) to build dashboards and visualizations
|
||||
- [Use template variables](ref:template-variables) to create dynamic, reusable dashboards
|
||||
- [Add annotations](ref:annotations) to overlay events on your graphs
|
||||
- [Set up alerting](ref:alerting) to get notified when metrics cross thresholds
|
||||
- [Troubleshoot issues](ref:troubleshooting-opentsdb) if you encounter problems
|
||||
403
docs/sources/datasources/opentsdb/query-editor/index.md
Normal file
403
docs/sources/datasources/opentsdb/query-editor/index.md
Normal file
|
|
@ -0,0 +1,403 @@
|
|||
---
|
||||
description: Use the OpenTSDB query editor in Grafana
|
||||
keywords:
|
||||
- grafana
|
||||
- opentsdb
|
||||
- query
|
||||
- editor
|
||||
- metrics
|
||||
- filters
|
||||
- tags
|
||||
- downsampling
|
||||
labels:
|
||||
products:
|
||||
- cloud
|
||||
- enterprise
|
||||
- oss
|
||||
menuTitle: Query editor
|
||||
title: OpenTSDB query editor
|
||||
weight: 200
|
||||
last_reviewed: 2026-01-28
|
||||
refs:
|
||||
troubleshooting-opentsdb:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/opentsdb/troubleshooting/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/opentsdb/troubleshooting/
|
||||
template-variables:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/opentsdb/template-variables/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/opentsdb/template-variables/
|
||||
alerting:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/opentsdb/alerting/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/opentsdb/alerting/
|
||||
explore:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/explore/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/explore/
|
||||
---
|
||||
|
||||
# OpenTSDB query editor
|
||||
|
||||
The query editor allows you to build OpenTSDB queries visually. The available options depend on the OpenTSDB version you configured for the data source.
|
||||
|
||||
## Access the query editor
|
||||
|
||||
The OpenTSDB query editor is located on the [Explore](ref:explore) page. You can also access the OpenTSDB query editor from a dashboard panel. Click the ellipsis in the upper right of the panel and select **Edit**.
|
||||
|
||||
## Create a query
|
||||
|
||||
To create a query:
|
||||
|
||||
1. Select the **OpenTSDB** data source in a panel.
|
||||
1. Configure the query using the sections described in the following documentation.
|
||||
|
||||
## Metric section
|
||||
|
||||
The Metric section contains the core query configuration:
|
||||
|
||||
| Field | Description |
|
||||
| -------------- | ------------------------------------------------------------------------------------------------- |
|
||||
| **Metric** | The metric name to query. Start typing to see autocomplete suggestions from your OpenTSDB server. |
|
||||
| **Aggregator** | The aggregation function to combine multiple time series. Default: `sum`. |
|
||||
| **Alias** | Custom display name for the series. Use `$tag_<tagname>` to include tag values in the alias. |
|
||||
|
||||
### Alias patterns
|
||||
|
||||
The alias field supports dynamic substitution using tag values. Use the pattern `$tag_<tagname>` where `<tagname>` is the name of a tag on your metric.
|
||||
|
||||
| Pattern | Description | Example output |
|
||||
| ---------------------- | ----------------------------------- | -------------------------- |
|
||||
| `$tag_host` | Inserts the value of the `host` tag | `webserver01` |
|
||||
| `$tag_env` | Inserts the value of the `env` tag | `production` |
|
||||
| `$tag_host - CPU` | Combines tag value with static text | `webserver01 - CPU` |
|
||||
| `$tag_host ($tag_env)` | Multiple tag substitutions | `webserver01 (production)` |
|
||||
|
||||
## Downsample section
|
||||
|
||||
Downsampling reduces the number of data points returned by aggregating values over time intervals. This improves query performance and reduces the amount of data transferred.
|
||||
|
||||
| Field | Description |
|
||||
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **Interval** | The time interval for downsampling. Leave blank to use the automatic interval based on the panel's time range and width. |
|
||||
| **Aggregator** | The aggregation function for downsampling. Default: `avg`. |
|
||||
| **Fill** | (Version 2.2+) The fill policy for missing data points. Default: `none`. |
|
||||
| **Disable downsampling** | Toggle to disable downsampling entirely. Use this when you need raw data points. |
|
||||
|
||||
### Interval format
|
||||
|
||||
The interval field accepts time duration strings:
|
||||
|
||||
| Format | Description | Example |
|
||||
| ------ | ----------- | ------- |
|
||||
| `s` | Seconds | `30s` |
|
||||
| `m` | Minutes | `5m` |
|
||||
| `h` | Hours | `1h` |
|
||||
| `d` | Days | `1d` |
|
||||
| `w` | Weeks | `1w` |
|
||||
|
||||
When the interval is left blank, Grafana automatically calculates an appropriate interval based on the panel's time range and pixel width. This ensures optimal data density for visualization.
|
||||
|
||||
## Filters section
|
||||
|
||||
Filters (available in OpenTSDB 2.2+) provide advanced filtering capabilities that replace the legacy tag-based filtering.
|
||||
|
||||
| Field | Description |
|
||||
| ------------ | --------------------------------------------------------------------------------------------------------------- |
|
||||
| **Key** | The tag key to filter on. Select from autocomplete suggestions or type a custom value. |
|
||||
| **Type** | The filter type. Determines how the filter value is matched. Default: `iliteral_or`. |
|
||||
| **Filter** | The filter value or pattern. Supports autocomplete for tag values. |
|
||||
| **Group by** | Toggle to group results by this tag key. When enabled, separate time series are returned for each unique value. |
|
||||
|
||||
### Add, edit, and remove filters
|
||||
|
||||
To manage filters:
|
||||
|
||||
1. Click the **+** button next to "Filters" to add a new filter.
|
||||
1. Configure the filter fields (Key, Type, Filter, Group by).
|
||||
1. Click **add filter** to apply the filter.
|
||||
1. To edit an existing filter, click the **pencil** icon next to it.
|
||||
1. To remove a filter, click the **x** icon next to it.
|
||||
|
||||
You can add multiple filters to a single query. All filters are combined with AND logic.
|
||||
|
||||
### Filter types
|
||||
|
||||
| Type | Description | Example |
|
||||
| ----------------- | ---------------------------------------------------------- | --------------------- |
|
||||
| `literal_or` | Matches exact values. Use `\|` to specify multiple values. | `web01\|web02\|web03` |
|
||||
| `iliteral_or` | Case-insensitive literal match. | `WEB01\|web02` |
|
||||
| `wildcard` | Matches using `*` as a wildcard character. | `web-*-prod` |
|
||||
| `iwildcard` | Case-insensitive wildcard match. | `WEB-*` |
|
||||
| `regexp` | Matches using regular expressions. | `web-[0-9]+` |
|
||||
| `not_literal_or` | Excludes exact values. | `web01\|web02` |
|
||||
| `not_iliteral_or` | Case-insensitive exclusion. | `TEST\|DEV` |
|
||||
|
||||
### Group by behavior
|
||||
|
||||
When **Group by** is enabled for a filter:
|
||||
|
||||
- Results are split into separate time series for each unique value of the filtered tag.
|
||||
- Each time series is labeled with its tag value.
|
||||
- This is useful for comparing values across hosts, environments, or other dimensions.
|
||||
|
||||
When **Group by** is disabled:
|
||||
|
||||
- All matching time series are combined using the selected aggregator.
|
||||
- A single aggregated time series is returned.
|
||||
|
||||
## Tags section
|
||||
|
||||
Tags filter metrics by key-value pairs. This is the legacy filtering method for OpenTSDB versions prior to 2.2.
|
||||
|
||||
| Field | Description |
|
||||
| --------- | ----------------------------------------------------------------- |
|
||||
| **Key** | The tag key to filter on. Select from autocomplete suggestions. |
|
||||
| **Value** | The tag value to match. Use `*` to match all values for this key. |
|
||||
|
||||
### Add, edit, and remove tags
|
||||
|
||||
To manage tags:
|
||||
|
||||
1. Click the **+** button next to "Tags" to add a new tag.
|
||||
1. Select or type a tag key.
|
||||
1. Select or type a tag value (use `*` for wildcard).
|
||||
1. Click **add tag** to apply the tag filter.
|
||||
1. To edit an existing tag, click the **pencil** icon next to it.
|
||||
1. To remove a tag, click the **x** icon next to it.
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
Tags are deprecated in OpenTSDB 2.2 and later. Use Filters instead for more powerful filtering options including wildcards, regular expressions, and exclusion patterns.
|
||||
{{< /admonition >}}
|
||||
|
||||
{{< admonition type="caution" >}}
|
||||
Tags and Filters are mutually exclusive. If you have filters defined, you cannot add tags, and vice versa. The query editor displays a warning if you attempt to use both.
|
||||
{{< /admonition >}}
|
||||
|
||||
## Rate section
|
||||
|
||||
The Rate section computes the rate of change, which is essential for counter metrics that continuously increment.
|
||||
|
||||
| Field | Description |
|
||||
| ----------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| **Rate** | Toggle to enable rate calculation. Computes the per-second rate of change between consecutive values. |
|
||||
| **Counter** | (When Rate is enabled) Toggle to indicate the metric is a monotonically increasing counter that may reset. |
|
||||
| **Counter max** | (When Counter is enabled) The maximum value before the counter wraps around. |
|
||||
| **Reset value** | (When Counter is enabled) The value the counter resets to after wrapping. Default: `0`. |
|
||||
| **Explicit tags** | (Version 2.3+) Toggle to require all specified tags to exist on matching time series. |
|
||||
|
||||
### When to use rate calculation
|
||||
|
||||
Enable **Rate** when your metric is a continuously increasing counter, such as:
|
||||
|
||||
- Network bytes sent/received
|
||||
- Request counts
|
||||
- Error counts
|
||||
- Disk I/O operations
|
||||
|
||||
The rate calculation converts cumulative values into per-second rates, making the data more meaningful for visualization.
|
||||
|
||||
### Counter settings
|
||||
|
||||
Enable **Counter** when your metric can reset to zero (for example, after a service restart). The counter settings help OpenTSDB calculate correct rates across resets:
|
||||
|
||||
- **Counter max**: Set this to the maximum value your counter can reach before wrapping. For 64-bit counters, use `18446744073709551615`. For 32-bit counters, use `4294967295`.
|
||||
- **Reset value**: The value the counter resets to, typically `0`.
|
||||
|
||||
### Explicit tags
|
||||
|
||||
When **Explicit tags** is enabled (version 2.3+), OpenTSDB only returns time series that have all the tags specified in your query. This prevents unexpected results when some time series are missing tags that others have.
|
||||
|
||||
## Aggregators
|
||||
|
||||
The aggregator function combines multiple time series into one. Grafana fetches the list of available aggregators from your OpenTSDB server, so you may see additional aggregators beyond those listed here.
|
||||
|
||||
### Common aggregators
|
||||
|
||||
| Aggregator | Description | Use case |
|
||||
| ---------- | ----------------------------------------- | -------------------------------------- |
|
||||
| `sum` | Sum all values at each timestamp. | Total requests across all servers. |
|
||||
| `avg` | Average all values at each timestamp. | Average CPU usage across hosts. |
|
||||
| `min` | Take the minimum value at each timestamp. | Lowest response time. |
|
||||
| `max` | Take the maximum value at each timestamp. | Peak memory usage. |
|
||||
| `dev` | Calculate the standard deviation. | Measure variability in response times. |
|
||||
| `count` | Count the number of data points. | Number of reporting hosts. |
|
||||
|
||||
### Interpolation aggregators
|
||||
|
||||
These aggregators handle missing data points differently:
|
||||
|
||||
| Aggregator | Description |
|
||||
| ---------- | ---------------------------------------------------- |
|
||||
| `zimsum` | Sum values, treating missing data as zero. |
|
||||
| `mimmin` | Minimum value, ignoring missing (interpolated) data. |
|
||||
| `mimmax` | Maximum value, ignoring missing (interpolated) data. |
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
The available aggregators depend on your OpenTSDB server version and configuration. The aggregator dropdown is populated dynamically from the `/api/aggregators` endpoint on your OpenTSDB server.
|
||||
{{< /admonition >}}
|
||||
|
||||
## Fill policies
|
||||
|
||||
Fill policies (available in OpenTSDB 2.2+) determine how to handle missing data points during downsampling. This is important when your data has gaps or irregular collection intervals.
|
||||
|
||||
| Policy | Description | Use case |
|
||||
| ------ | --------------------------------------------------- | ----------------------------------------------------------- |
|
||||
| `none` | Don't fill missing values. Gaps remain in the data. | Default behavior; preserves data fidelity. |
|
||||
| `nan` | Fill missing values with NaN (Not a Number). | Useful for calculations that should propagate missing data. |
|
||||
| `null` | Fill missing values with null. | Visualizations show gaps at null points. |
|
||||
| `zero` | Fill missing values with zero. | Treat missing data as zero values; useful for counters. |
|
||||
|
||||
### Choose the right fill policy
|
||||
|
||||
- Use `none` (default) when you want to see actual data gaps in your visualizations.
|
||||
- Use `null` when you want graphs to show breaks at missing data points.
|
||||
- Use `zero` when missing data should be interpreted as zero (for example, no requests during a period).
|
||||
- Use `nan` when you need missing values to propagate through calculations.
|
||||
|
||||
## Autocomplete suggestions
|
||||
|
||||
The query editor provides autocomplete suggestions to help you build queries quickly and accurately.
|
||||
|
||||
### What autocomplete provides
|
||||
|
||||
| Field | Source | Description |
|
||||
| --------------- | --------------------------- | ----------------------------------------------- |
|
||||
| **Metric** | `/api/suggest?type=metrics` | Suggests metric names as you type. |
|
||||
| **Tag keys** | Previous query results | Suggests tag keys based on the selected metric. |
|
||||
| **Tag values** | `/api/suggest?type=tagv` | Suggests tag values as you type. |
|
||||
| **Filter keys** | Previous query results | Suggests tag keys for filter configuration. |
|
||||
|
||||
### Autocomplete requirements
|
||||
|
||||
For autocomplete to work:
|
||||
|
||||
- The OpenTSDB suggest API must be enabled on your server.
|
||||
- Metrics must exist in your OpenTSDB database.
|
||||
- The **Lookup limit** setting in your data source configuration controls the maximum number of suggestions returned.
|
||||
|
||||
If autocomplete isn't working, refer to [Troubleshooting](ref:troubleshooting-opentsdb).
|
||||
|
||||
## Use template variables
|
||||
|
||||
You can use template variables in any text field in the query editor. Template variables are replaced with their current values when the query executes.
|
||||
|
||||
Common uses include:
|
||||
|
||||
- **Metric field**: `$metric` to dynamically select metrics.
|
||||
- **Filter values**: `$host` to filter by a variable-selected host.
|
||||
- **Tag values**: `$environment` to filter by environment.
|
||||
|
||||
For more information about creating and using template variables, refer to [Template variables](ref:template-variables).
|
||||
|
||||
## Query examples
|
||||
|
||||
The following examples demonstrate common query patterns.
|
||||
|
||||
### Basic metric query with tag filtering
|
||||
|
||||
| Field | Value |
|
||||
| ---------- | ------------------ |
|
||||
| Metric | `sys.cpu.user` |
|
||||
| Aggregator | `avg` |
|
||||
| Tags | `host=webserver01` |
|
||||
|
||||
This query returns the average CPU usage for the host `webserver01`.
|
||||
|
||||
### Query with wildcard filter (OpenTSDB 2.2+)
|
||||
|
||||
| Field | Value |
|
||||
| ------------ | --------------------- |
|
||||
| Metric | `http.requests.count` |
|
||||
| Aggregator | `sum` |
|
||||
| Filter Key | `host` |
|
||||
| Filter Type | `wildcard` |
|
||||
| Filter Value | `web-*` |
|
||||
| Group by | enabled |
|
||||
|
||||
This query sums HTTP request counts across all hosts matching `web-*` and groups results by host.
|
||||
|
||||
### Rate calculation for network counters
|
||||
|
||||
| Field | Value |
|
||||
| ----------- | ---------------------- |
|
||||
| Metric | `net.bytes.received` |
|
||||
| Aggregator | `sum` |
|
||||
| Rate | enabled |
|
||||
| Counter | enabled |
|
||||
| Counter max | `18446744073709551615` |
|
||||
|
||||
This query calculates the rate of bytes received per second. The counter max is set to the 64-bit unsigned integer maximum to handle counter wraps correctly.
|
||||
|
||||
### Using alias patterns
|
||||
|
||||
| Field | Value |
|
||||
| ---------- | --------------------------- |
|
||||
| Metric | `app.response.time` |
|
||||
| Aggregator | `avg` |
|
||||
| Tags | `host=*`, `env=production` |
|
||||
| Alias | `$tag_host - Response Time` |
|
||||
|
||||
This query uses the alias pattern to create readable legend labels like `webserver01 - Response Time`.
|
||||
|
||||
### Downsampling with custom interval
|
||||
|
||||
| Field | Value |
|
||||
| --------------------- | ------------------- |
|
||||
| Metric | `sys.disk.io.bytes` |
|
||||
| Aggregator | `sum` |
|
||||
| Downsample Interval | `5m` |
|
||||
| Downsample Aggregator | `avg` |
|
||||
| Fill | `zero` |
|
||||
|
||||
This query downsamples disk I/O data to 5-minute averages, filling gaps with zero values.
|
||||
|
||||
### Compare environments with filters
|
||||
|
||||
| Field | Value |
|
||||
| ------------ | --------------------- |
|
||||
| Metric | `app.errors.count` |
|
||||
| Aggregator | `sum` |
|
||||
| Filter Key | `env` |
|
||||
| Filter Type | `literal_or` |
|
||||
| Filter Value | `staging\|production` |
|
||||
| Group by | enabled |
|
||||
|
||||
This query shows error counts for both staging and production environments as separate time series for comparison.
|
||||
|
||||
### Exclude specific hosts
|
||||
|
||||
| Field | Value |
|
||||
| ------------ | ------------------------- |
|
||||
| Metric | `sys.cpu.user` |
|
||||
| Aggregator | `avg` |
|
||||
| Filter Key | `host` |
|
||||
| Filter Type | `not_literal_or` |
|
||||
| Filter Value | `test-server\|dev-server` |
|
||||
| Group by | enabled |
|
||||
|
||||
This query shows CPU usage for all hosts except test-server and dev-server.
|
||||
|
||||
### Query with explicit tags (version 2.3+)
|
||||
|
||||
| Field | Value |
|
||||
| ------------- | --------------------- |
|
||||
| Metric | `app.request.latency` |
|
||||
| Aggregator | `avg` |
|
||||
| Filter Key | `host` |
|
||||
| Filter Type | `wildcard` |
|
||||
| Filter Value | `*` |
|
||||
| Group by | enabled |
|
||||
| Explicit tags | enabled |
|
||||
|
||||
This query only returns time series that have the `host` tag defined, excluding any time series that are missing this tag.
|
||||
|
||||
## Next steps
|
||||
|
||||
- [Use template variables](ref:template-variables) to create dynamic, reusable dashboards.
|
||||
- [Set up alerting](ref:alerting) to get notified when metrics cross thresholds.
|
||||
- [Troubleshoot issues](ref:troubleshooting-opentsdb) if you encounter problems with queries.
|
||||
251
docs/sources/datasources/opentsdb/template-variables/index.md
Normal file
251
docs/sources/datasources/opentsdb/template-variables/index.md
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
---
|
||||
description: Use template variables with the OpenTSDB data source in Grafana
|
||||
keywords:
|
||||
- grafana
|
||||
- opentsdb
|
||||
- template
|
||||
- variables
|
||||
- dashboard
|
||||
- dynamic
|
||||
labels:
|
||||
products:
|
||||
- cloud
|
||||
- enterprise
|
||||
- oss
|
||||
menuTitle: Template variables
|
||||
title: OpenTSDB template variables
|
||||
weight: 300
|
||||
last_reviewed: 2026-01-28
|
||||
refs:
|
||||
variables:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/variables/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/variables/
|
||||
troubleshooting-opentsdb:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/opentsdb/troubleshooting/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/opentsdb/troubleshooting/
|
||||
query-editor:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/opentsdb/query-editor/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/opentsdb/query-editor/
|
||||
alerting:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/opentsdb/alerting/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/opentsdb/alerting/
|
||||
---
|
||||
|
||||
# OpenTSDB template variables
|
||||
|
||||
Instead of hard-coding server, application, and sensor names in your metric queries, you can use template variables. Variables appear as drop-down menus at the top of the dashboard, making it easy to change the data being displayed without editing queries.
|
||||
|
||||
For an introduction to template variables, refer to the [Variables](ref:variables) documentation.
|
||||
|
||||
## Query variable
|
||||
|
||||
The OpenTSDB data source supports query-type template variables that fetch values directly from OpenTSDB. These variables dynamically populate based on data in your OpenTSDB database.
|
||||
|
||||
### Supported query functions
|
||||
|
||||
| Query | Description | API used |
|
||||
| ---------------------------- | -------------------------------------------------------------------------------- | --------------------------- |
|
||||
| `metrics(prefix)` | Returns metric names matching the prefix. Use empty parentheses for all metrics. | `/api/suggest?type=metrics` |
|
||||
| `tag_names(metric)` | Returns tag keys (names) that exist for a specific metric. | `/api/search/lookup` |
|
||||
| `tag_values(metric, tagkey)` | Returns tag values for a specific metric and tag key. | `/api/search/lookup` |
|
||||
| `suggest_tagk(prefix)` | Returns tag keys matching the prefix across all metrics. | `/api/suggest?type=tagk` |
|
||||
| `suggest_tagv(prefix)` | Returns tag values matching the prefix across all metrics. | `/api/suggest?type=tagv` |
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
The `tag_names` and `tag_values` functions use the OpenTSDB lookup API, which requires metrics to exist in your database. The `suggest_tagk` and `suggest_tagv` functions use the suggest API, which searches across all metrics.
|
||||
{{< /admonition >}}
|
||||
|
||||
### Create a query variable
|
||||
|
||||
To create a query variable:
|
||||
|
||||
1. Navigate to **Dashboard settings** > **Variables**.
|
||||
1. Click **Add variable**.
|
||||
1. Enter a **Name** for your variable (for example, `host`).
|
||||
1. Select **Query** as the variable type.
|
||||
1. Select the **OpenTSDB** data source.
|
||||
1. Enter your query using one of the supported query functions.
|
||||
1. Optionally configure **Multi-value** to allow selecting multiple values.
|
||||
1. Optionally configure **Include All option** to add an "All" option.
|
||||
1. Click **Apply**.
|
||||
|
||||
### Query variable examples
|
||||
|
||||
**List all metrics:**
|
||||
|
||||
```
|
||||
metrics()
|
||||
```
|
||||
|
||||
Returns all metric names in your OpenTSDB database. Useful for creating a metric selector.
|
||||
|
||||
**List metrics with a prefix:**
|
||||
|
||||
```
|
||||
metrics(sys.cpu)
|
||||
```
|
||||
|
||||
Returns metrics starting with `sys.cpu`, such as `sys.cpu.user`, `sys.cpu.system`, `sys.cpu.idle`.
|
||||
|
||||
**List tag keys for a metric:**
|
||||
|
||||
```
|
||||
tag_names(sys.cpu.user)
|
||||
```
|
||||
|
||||
Returns tag keys like `host`, `env`, `datacenter` that exist on the `sys.cpu.user` metric.
|
||||
|
||||
**List tag values for a metric and tag key:**
|
||||
|
||||
```
|
||||
tag_values(sys.cpu.user, host)
|
||||
```
|
||||
|
||||
Returns all host values for the `sys.cpu.user` metric, such as `webserver01`, `webserver02`, `dbserver01`.
|
||||
|
||||
**Search for tag keys by prefix:**
|
||||
|
||||
```
|
||||
suggest_tagk(host)
|
||||
```
|
||||
|
||||
Returns tag keys matching `host` across all metrics, such as `host`, `hostname`, `host_id`.
|
||||
|
||||
**Search for tag values by prefix:**
|
||||
|
||||
```
|
||||
suggest_tagv(web)
|
||||
```
|
||||
|
||||
Returns tag values matching `web` across all metrics, such as `webserver01`, `webserver02`, `web-prod-01`.
|
||||
|
||||
If template variables aren't populating in the **Preview of values** section, refer to [Troubleshooting](ref:troubleshooting-opentsdb).
|
||||
|
||||
## Nested template variables
|
||||
|
||||
You can use one template variable to filter values for another. This creates cascading filters, such as selecting a data center first, then showing only hosts in that data center.
|
||||
|
||||
### Filter syntax
|
||||
|
||||
The `tag_values` function accepts additional tag filters after the tag key:
|
||||
|
||||
```
|
||||
tag_values(metric, tagkey, tag1=value1, tag2=value2, ...)
|
||||
```
|
||||
|
||||
Use template variables as filter values to create dynamic dependencies:
|
||||
|
||||
```
|
||||
tag_values(metric, tagkey, tag1=$variable1, tag2=$variable2)
|
||||
```
|
||||
|
||||
### Nested variable examples
|
||||
|
||||
| Query | Description |
|
||||
| ---------------------------------------------------------- | ------------------------------------------------------------ |
|
||||
| `tag_values(sys.cpu.user, host, env=$env)` | Returns host values filtered by the selected `env` value. |
|
||||
| `tag_values(sys.cpu.user, host, env=$env, datacenter=$dc)` | Returns host values filtered by both `env` and `datacenter`. |
|
||||
| `tag_values(app.requests, endpoint, service=$service)` | Returns endpoint values for the selected service. |
|
||||
|
||||
### Create cascading filters
|
||||
|
||||
To create a hierarchy of dependent variables:
|
||||
|
||||
1. **Create the parent variable:**
|
||||
- Name: `datacenter`
|
||||
- Query: `tag_values(sys.cpu.user, datacenter)`
|
||||
|
||||
2. **Create the child variable:**
|
||||
- Name: `host`
|
||||
- Query: `tag_values(sys.cpu.user, host, datacenter=$datacenter)`
|
||||
|
||||
3. **Create additional levels as needed:**
|
||||
- Name: `cpu`
|
||||
- Query: `tag_values(sys.cpu.user, cpu, datacenter=$datacenter, host=$host)`
|
||||
|
||||
When users change the data center selection, the host variable automatically refreshes to show only hosts in that data center.
|
||||
|
||||
## Use variables in queries
|
||||
|
||||
Reference variables in your queries using the `$variablename` or `${variablename}` syntax. Grafana replaces the variable with its current value when the query executes.
|
||||
|
||||
### Where to use variables
|
||||
|
||||
Variables can be used in these query editor fields:
|
||||
|
||||
| Field | Example | Description |
|
||||
| ----------------------- | ------------------- | ------------------------------------------- |
|
||||
| **Metric** | `$metric` | Dynamically select which metric to query. |
|
||||
| **Tag value** | `host=$host` | Filter by a variable-selected tag value. |
|
||||
| **Filter value** | `$host` | Use in filter value field for filtering. |
|
||||
| **Alias** | `$tag_host - $host` | Include variable values in legend labels. |
|
||||
| **Downsample interval** | `$interval` | Use a variable for the downsample interval. |
|
||||
|
||||
### Variable syntax options
|
||||
|
||||
| Syntax | Description |
|
||||
| ------------------------ | -------------------------------------------------------------------------------- |
|
||||
| `$variablename` | Simple syntax for most cases. |
|
||||
| `${variablename}` | Use when the variable is adjacent to other text (for example, `${host}_suffix`). |
|
||||
| `${variablename:format}` | Apply a specific format to the variable value. |
|
||||
|
||||
## Multi-value variables
|
||||
|
||||
When you enable **Multi-value** for a variable, users can select multiple values simultaneously. The OpenTSDB data source handles multi-value variables using pipe (`|`) separation, which is compatible with OpenTSDB's literal_or filter type.
|
||||
|
||||
### Configure multi-value variables
|
||||
|
||||
1. When creating the variable, enable **Multi-value**.
|
||||
1. Optionally enable **Include All option** to add an "All" selection.
|
||||
1. Use the variable in a filter with the `literal_or` filter type.
|
||||
|
||||
### Multi-value example
|
||||
|
||||
With a `host` variable configured as multi-value:
|
||||
|
||||
| Field | Value |
|
||||
| ------------ | ------------ |
|
||||
| Filter Key | `host` |
|
||||
| Filter Type | `literal_or` |
|
||||
| Filter Value | `$host` |
|
||||
|
||||
If the user selects `webserver01`, `webserver02`, and `webserver03`, the filter value becomes `webserver01|webserver02|webserver03`.
|
||||
|
||||
### All value behavior
|
||||
|
||||
When the user selects "All", Grafana sends all available values pipe-separated. For large value sets, consider using a wildcard filter instead:
|
||||
|
||||
| Field | Value |
|
||||
| ------------ | ---------- |
|
||||
| Filter Key | `host` |
|
||||
| Filter Type | `wildcard` |
|
||||
| Filter Value | `*` |
|
||||
|
||||
## Interval and auto-interval variables
|
||||
|
||||
Grafana provides built-in interval variables that are useful with OpenTSDB downsampling:
|
||||
|
||||
| Variable | Description |
|
||||
| ---------------- | ---------------------------------------------------------------------- |
|
||||
| `$__interval` | Automatically calculated interval based on time range and panel width. |
|
||||
| `$__interval_ms` | Same as `$__interval` but in milliseconds. |
|
||||
|
||||
Use these in the downsample interval field for automatic interval adjustment:
|
||||
|
||||
| Field | Value |
|
||||
| ------------------- | ------------- |
|
||||
| Downsample Interval | `$__interval` |
|
||||
|
||||
## Next steps
|
||||
|
||||
- [Build queries](ref:query-editor) using your template variables.
|
||||
- [Set up alerting](ref:alerting) with templated queries.
|
||||
- [Troubleshoot issues](ref:troubleshooting-opentsdb) if variables aren't populating.
|
||||
204
docs/sources/datasources/opentsdb/troubleshooting/index.md
Normal file
204
docs/sources/datasources/opentsdb/troubleshooting/index.md
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
---
|
||||
description: Troubleshoot OpenTSDB data source issues in Grafana
|
||||
keywords:
|
||||
- grafana
|
||||
- opentsdb
|
||||
- troubleshooting
|
||||
- errors
|
||||
- connection
|
||||
- query
|
||||
labels:
|
||||
products:
|
||||
- cloud
|
||||
- enterprise
|
||||
- oss
|
||||
menuTitle: Troubleshooting
|
||||
title: Troubleshoot OpenTSDB data source issues
|
||||
weight: 500
|
||||
last_reviewed: 2026-01-28
|
||||
refs:
|
||||
configure-opentsdb:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/opentsdb/configure/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/opentsdb/configure/
|
||||
template-variables-opentsdb:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/opentsdb/template-variables/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/opentsdb/template-variables/
|
||||
---
|
||||
|
||||
# Troubleshoot OpenTSDB data source issues
|
||||
|
||||
This document provides solutions to common issues you may encounter when configuring or using the OpenTSDB data source. For configuration instructions, refer to [Configure the OpenTSDB data source](ref:configure-opentsdb).
|
||||
|
||||
## Connection errors
|
||||
|
||||
These errors occur when Grafana can't connect to the OpenTSDB server.
|
||||
|
||||
### "Connection refused" or timeout errors
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Save & test fails
|
||||
- Queries return connection errors
|
||||
- Intermittent timeouts
|
||||
|
||||
**Possible causes and solutions:**
|
||||
|
||||
| Cause | Solution |
|
||||
| ---------------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| Wrong URL or port | Verify the URL includes the correct protocol, IP address, and port. The default port is `4242`. |
|
||||
| OpenTSDB not running | Check that the OpenTSDB server is running and accessible. |
|
||||
| Firewall blocking connection | Ensure firewall rules allow outbound connections from Grafana to the OpenTSDB server on the configured port. |
|
||||
| Network issues | Verify network connectivity between Grafana and OpenTSDB. Try pinging the server or using `curl` to test the API. |
|
||||
|
||||
To test connectivity manually, run:
|
||||
|
||||
```sh
|
||||
curl http://<OPENTSDB_HOST>:4242/api/version
|
||||
```
|
||||
|
||||
## Authentication errors
|
||||
|
||||
These errors occur when credentials are invalid or misconfigured.
|
||||
|
||||
### "401 Unauthorized" or "403 Forbidden"
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Save & test fails with authentication error
|
||||
- Queries return authorization errors
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. Verify that basic authentication credentials are correct in the data source configuration.
|
||||
1. Check that the OpenTSDB server is configured to accept the provided credentials.
|
||||
1. If using cookies for authentication, ensure the required cookies are listed in **Allowed cookies**.
|
||||
|
||||
## Query errors
|
||||
|
||||
These errors occur when executing queries against OpenTSDB.
|
||||
|
||||
### No data returned
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Query executes without error but returns no data
|
||||
- Panels show "No data" message
|
||||
|
||||
**Possible causes and solutions:**
|
||||
|
||||
| Cause | Solution |
|
||||
| ------------------------------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| Time range doesn't contain data | Expand the dashboard time range. Verify data exists in OpenTSDB for the selected period. |
|
||||
| Wrong metric name | Verify the metric name is correct. Use autocomplete to discover available metrics. |
|
||||
| Incorrect tag filters | Remove or adjust tag filters. Use `*` as a wildcard to match all values. |
|
||||
| Version mismatch | Ensure the configured OpenTSDB version matches your server. Filters are only available in version 2.2+. |
|
||||
| Using both Filters and Tags | Use either Filters or Tags, not both. They're mutually exclusive in OpenTSDB 2.2+. |
|
||||
|
||||
### Query timeout
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Queries take a long time and then fail
|
||||
- Error message mentions timeout
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. Reduce the time range of your query.
|
||||
1. Add more specific tag filters to reduce the data volume.
|
||||
1. Increase the **Timeout** setting in the data source configuration.
|
||||
1. Enable downsampling to reduce the number of data points returned.
|
||||
1. Check OpenTSDB server performance and HBase health.
|
||||
|
||||
## Autocomplete doesn't work
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- No suggestions appear when typing metric names, tag names, or tag values
|
||||
- Drop-down menus are empty
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. Verify that the OpenTSDB `/api/suggest` endpoint is accessible. Test it manually with `curl http://<OPENTSDB_HOST>:4242/api/suggest?type=metrics`.
|
||||
1. Increase the **Lookup limit** setting if you have many metrics or tags.
|
||||
1. Verify that the data source connection is working by clicking **Save & test**.
|
||||
1. Check that metrics exist in OpenTSDB. The suggest API only returns metrics that have been written to the database.
|
||||
|
||||
## Template variables don't populate
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Template variable drop-down menus are empty
|
||||
- **Preview of values** shows no results
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. Enable real-time metadata tracking in OpenTSDB by setting `tsd.core.meta.enable_realtime_ts` to `true` in your OpenTSDB configuration.
|
||||
1. Sync existing metadata by running `tsdb uid metasync` on the OpenTSDB server.
|
||||
1. Verify the variable query syntax is correct. Refer to [Template variables](ref:template-variables-opentsdb) for the correct syntax.
|
||||
1. Check that the data source connection is working.
|
||||
|
||||
## Performance issues
|
||||
|
||||
These issues relate to slow queries or high resource usage.
|
||||
|
||||
### Slow queries
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Dashboards take a long time to load
|
||||
- Queries are slow even for small time ranges
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. Enable downsampling in the query editor to reduce data volume.
|
||||
1. Use more specific tag filters to limit the time series returned.
|
||||
1. Reduce the time range.
|
||||
1. Check OpenTSDB and HBase performance metrics.
|
||||
1. Consider increasing OpenTSDB heap size if memory is constrained.
|
||||
|
||||
### HBase performance issues
|
||||
|
||||
OpenTSDB relies on HBase for data storage. Performance problems in HBase directly affect OpenTSDB query performance.
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. Monitor HBase region server health and compaction status.
|
||||
1. Ensure sufficient heap memory is allocated to HBase region servers.
|
||||
1. Check for region hotspots and rebalance if necessary.
|
||||
1. Refer to the [OpenTSDB troubleshooting guide](http://opentsdb.net/docs/build/html/user_guide/troubleshooting.html) for HBase-specific issues.
|
||||
|
||||
## Enable debug logging
|
||||
|
||||
To capture detailed error information for troubleshooting:
|
||||
|
||||
1. Set the Grafana log level to `debug` in the configuration file:
|
||||
|
||||
```ini
|
||||
[log]
|
||||
level = debug
|
||||
```
|
||||
|
||||
1. Review logs in `/var/log/grafana/grafana.log` (or your configured log location).
|
||||
1. Look for OpenTSDB-specific entries that include request and response details.
|
||||
1. Reset the log level to `info` after troubleshooting to avoid excessive log volume.
|
||||
|
||||
## Get additional help
|
||||
|
||||
If you've tried the solutions in this document and still encounter issues:
|
||||
|
||||
1. Check the [Grafana community forums](https://community.grafana.com/) for similar issues.
|
||||
1. Review [OpenTSDB issues on GitHub](https://github.com/grafana/grafana/issues?q=opentsdb) for known bugs.
|
||||
1. Consult the [OpenTSDB documentation](http://opentsdb.net/docs/build/html/index.html) for server-specific guidance.
|
||||
1. Contact Grafana Support if you're a Grafana Enterprise, Cloud Pro, or Cloud Contracted user.
|
||||
|
||||
When reporting issues, include:
|
||||
|
||||
- Grafana version
|
||||
- OpenTSDB version
|
||||
- Error messages (redact sensitive information)
|
||||
- Steps to reproduce
|
||||
- Data source configuration (redact credentials)
|
||||
|
|
@ -1377,11 +1377,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/features/alerting/unified/components/notification-policies/EditDefaultPolicyForm.tsx": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 5
|
||||
}
|
||||
},
|
||||
"public/app/features/alerting/unified/components/notification-policies/EditNotificationPolicyForm.tsx": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 13
|
||||
|
|
|
|||
2
go.mod
2
go.mod
|
|
@ -88,7 +88,7 @@ require (
|
|||
github.com/googleapis/gax-go/v2 v2.15.0 // @grafana/grafana-backend-group
|
||||
github.com/gorilla/mux v1.8.1 // @grafana/grafana-backend-group
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // @grafana/grafana-app-platform-squad
|
||||
github.com/grafana/alerting v0.0.0-20260129164026-85d7010c64b8 // @grafana/alerting-backend
|
||||
github.com/grafana/alerting v0.0.0-20260203165836-8b17916e8173 // @grafana/alerting-backend
|
||||
github.com/grafana/authlib v0.0.0-20260203131350-b83e80394acc // @grafana/identity-access-team
|
||||
github.com/grafana/authlib/types v0.0.0-20260203131350-b83e80394acc // @grafana/identity-access-team
|
||||
github.com/grafana/dataplane/examples v0.0.1 // @grafana/observability-metrics
|
||||
|
|
|
|||
4
go.sum
4
go.sum
|
|
@ -1600,8 +1600,8 @@ github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7Fsg
|
|||
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
|
||||
github.com/grafana/alerting v0.0.0-20260129164026-85d7010c64b8 h1:964kdD/6Xyzr4g910nZnMtj0z16ijsvpA8Ju4sFOLjA=
|
||||
github.com/grafana/alerting v0.0.0-20260129164026-85d7010c64b8/go.mod h1:Ji0SfJChcwjgq8ljy6Y5CcYfHfAYKXjKYeysOoDS/6s=
|
||||
github.com/grafana/alerting v0.0.0-20260203165836-8b17916e8173 h1:nrQnGVRvBQK1zmg9rB6TA6tOeS0sSsUUV9JS1erkw2Q=
|
||||
github.com/grafana/alerting v0.0.0-20260203165836-8b17916e8173/go.mod h1:Ji0SfJChcwjgq8ljy6Y5CcYfHfAYKXjKYeysOoDS/6s=
|
||||
github.com/grafana/authlib v0.0.0-20260203131350-b83e80394acc h1:s9+L7EMTJIQxkhrR2m5HrOUKjeJDxEE4E09hPWKnfwQ=
|
||||
github.com/grafana/authlib v0.0.0-20260203131350-b83e80394acc/go.mod h1:za8MGa5J9Bbgm2XorXc+FbGe72ln46OpN5o8P1uX9Og=
|
||||
github.com/grafana/authlib/types v0.0.0-20260203131350-b83e80394acc h1:wagsf4me4j/UFNocyMJHz5/803XpnfGJtNj8/YWy0j0=
|
||||
|
|
|
|||
|
|
@ -1287,6 +1287,11 @@ export interface FeatureToggles {
|
|||
*/
|
||||
newVizSuggestions?: boolean;
|
||||
/**
|
||||
* Enable style actions (copy/paste) in the panel editor
|
||||
* @default false
|
||||
*/
|
||||
panelStyleActions?: boolean;
|
||||
/**
|
||||
* Enable all plugins to supply visualization suggestions (including 3rd party plugins)
|
||||
* @default false
|
||||
*/
|
||||
|
|
|
|||
49
packages/grafana-schema/src/common/common.gen.ts
generated
49
packages/grafana-schema/src/common/common.gen.ts
generated
|
|
@ -1001,6 +1001,55 @@ export const defaultTableFooterOptions: Partial<TableFooterOptions> = {
|
|||
reducers: [],
|
||||
};
|
||||
|
||||
export interface TableOptions {
|
||||
/**
|
||||
* Controls the height of the rows
|
||||
*/
|
||||
cellHeight?: TableCellHeight;
|
||||
/**
|
||||
* If true, disables all keyboard events in the table. this is used when previewing a table (i.e. suggestions)
|
||||
*/
|
||||
disableKeyboardEvents?: boolean;
|
||||
/**
|
||||
* Enable pagination on the table
|
||||
*/
|
||||
enablePagination?: boolean;
|
||||
/**
|
||||
* Represents the index of the selected frame
|
||||
*/
|
||||
frameIndex: number;
|
||||
/**
|
||||
* Defines the number of columns to freeze on the left side of the table
|
||||
*/
|
||||
frozenColumns?: {
|
||||
left?: number;
|
||||
};
|
||||
/**
|
||||
* limits the maximum height of a row, if text wrapping or dynamic height is enabled
|
||||
*/
|
||||
maxRowHeight?: number;
|
||||
/**
|
||||
* Controls whether the panel should show the header
|
||||
*/
|
||||
showHeader: boolean;
|
||||
/**
|
||||
* Controls whether the header should show icons for the column types
|
||||
*/
|
||||
showTypeIcons?: boolean;
|
||||
/**
|
||||
* Used to control row sorting
|
||||
*/
|
||||
sortBy?: Array<TableSortByFieldState>;
|
||||
}
|
||||
|
||||
export const defaultTableOptions: Partial<TableOptions> = {
|
||||
cellHeight: TableCellHeight.Sm,
|
||||
frameIndex: 0,
|
||||
showHeader: true,
|
||||
showTypeIcons: false,
|
||||
sortBy: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Field options for each field within a table (e.g 10, "The String", 64.20, etc.)
|
||||
* Generally defines alignment, filtering capabilties, display options, etc.
|
||||
|
|
|
|||
|
|
@ -110,6 +110,29 @@ TableFooterOptions: {
|
|||
reducers?: [...string]
|
||||
} @cuetsy(kind="interface")
|
||||
|
||||
TableOptions: {
|
||||
// Represents the index of the selected frame
|
||||
frameIndex: number | *0
|
||||
// Controls whether the panel should show the header
|
||||
showHeader: bool | *true
|
||||
// Controls whether the header should show icons for the column types
|
||||
showTypeIcons?: bool | *false
|
||||
// Used to control row sorting
|
||||
sortBy?: [...TableSortByFieldState]
|
||||
// Enable pagination on the table
|
||||
enablePagination?: bool
|
||||
// Controls the height of the rows
|
||||
cellHeight?: TableCellHeight & (*"sm" | _)
|
||||
// limits the maximum height of a row, if text wrapping or dynamic height is enabled
|
||||
maxRowHeight?: number
|
||||
// Defines the number of columns to freeze on the left side of the table
|
||||
frozenColumns?: {
|
||||
left?: number | *0
|
||||
}
|
||||
// If true, disables all keyboard events in the table. this is used when previewing a table (i.e. suggestions)
|
||||
disableKeyboardEvents?: bool
|
||||
} @cuetsy(kind="interface")
|
||||
|
||||
// Field options for each field within a table (e.g 10, "The String", 64.20, etc.)
|
||||
// Generally defines alignment, filtering capabilties, display options, etc.
|
||||
TableFieldOptions: {
|
||||
|
|
@ -127,10 +150,10 @@ TableFieldOptions: {
|
|||
wrapText?: bool
|
||||
// Enables text wrapping for column headers
|
||||
wrapHeaderText?: bool
|
||||
// options for the footer for this field
|
||||
footer?: TableFooterOptions
|
||||
// Selecting or hovering this field will show a tooltip containing the content within the target field
|
||||
tooltip?: TableCellTooltipOptions
|
||||
// The name of the field which contains styling overrides for this cell
|
||||
styleField?: string
|
||||
// options for the footer for this field
|
||||
footer?: TableFooterOptions
|
||||
} & HideableFieldConfig @cuetsy(kind="interface")
|
||||
|
|
|
|||
|
|
@ -14,53 +14,6 @@ import * as ui from '@grafana/schema';
|
|||
|
||||
export const pluginVersion = "12.4.0-pre";
|
||||
|
||||
export interface Options {
|
||||
/**
|
||||
* Controls the height of the rows
|
||||
*/
|
||||
cellHeight?: ui.TableCellHeight;
|
||||
/**
|
||||
* If true, disables all keyboard events in the table. this is used when previewing a table (i.e. suggestions)
|
||||
*/
|
||||
disableKeyboardEvents?: boolean;
|
||||
/**
|
||||
* Enable pagination on the table
|
||||
*/
|
||||
enablePagination?: boolean;
|
||||
/**
|
||||
* Represents the index of the selected frame
|
||||
*/
|
||||
frameIndex: number;
|
||||
/**
|
||||
* Defines the number of columns to freeze on the left side of the table
|
||||
*/
|
||||
frozenColumns?: {
|
||||
left?: number;
|
||||
};
|
||||
/**
|
||||
* limits the maximum height of a row, if text wrapping or dynamic height is enabled
|
||||
*/
|
||||
maxRowHeight?: number;
|
||||
/**
|
||||
* Controls whether the panel should show the header
|
||||
*/
|
||||
showHeader: boolean;
|
||||
/**
|
||||
* Controls whether the header should show icons for the column types
|
||||
*/
|
||||
showTypeIcons?: boolean;
|
||||
/**
|
||||
* Used to control row sorting
|
||||
*/
|
||||
sortBy?: Array<ui.TableSortByFieldState>;
|
||||
}
|
||||
|
||||
export const defaultOptions: Partial<Options> = {
|
||||
cellHeight: ui.TableCellHeight.Sm,
|
||||
frameIndex: 0,
|
||||
showHeader: true,
|
||||
showTypeIcons: false,
|
||||
sortBy: [],
|
||||
};
|
||||
export interface Options extends ui.TableOptions {}
|
||||
|
||||
export interface FieldConfig extends ui.TableFieldOptions {}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import (
|
|||
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
|
||||
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
"github.com/grafana/grafana/pkg/cmd/grafana-cli/logger"
|
||||
"github.com/grafana/grafana/pkg/registry/fieldselectors"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
grafanaauthorizer "github.com/grafana/grafana/pkg/services/apiserver/auth/authorizer"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/builder"
|
||||
|
|
@ -134,6 +135,10 @@ func (b *FolderAPIBuilder) InstallSchema(scheme *runtime.Scheme) error {
|
|||
// return err
|
||||
// }
|
||||
metav1.AddToGroupVersion(scheme, gv)
|
||||
err := fieldselectors.AddSelectableFieldLabelConversions(scheme, gv, folders.FolderKind())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return scheme.SetVersionPriority(gv)
|
||||
}
|
||||
|
||||
|
|
@ -148,7 +153,10 @@ func (b *FolderAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.API
|
|||
Permissions: b.setDefaultFolderPermissions,
|
||||
})
|
||||
|
||||
unified, err := grafanaregistry.NewRegistryStore(opts.Scheme, resourceInfo, opts.OptsGetter)
|
||||
selectableFieldsOpts := grafanaregistry.SelectableFieldsOptions{
|
||||
GetAttrs: fieldselectors.BuildGetAttrsFn(folders.FolderKind()),
|
||||
}
|
||||
unified, err := grafanaregistry.NewRegistryStoreWithSelectableFields(opts.Scheme, resourceInfo, opts.OptsGetter, selectableFieldsOpts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
package apiregistry
|
||||
package fieldselectors
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
|
@ -11,7 +11,8 @@ import (
|
|||
sdkres "github.com/grafana/grafana-app-sdk/resource"
|
||||
)
|
||||
|
||||
// These helper functions are to be used in InstallSchema() in apis/*/register.go files in order for already existing kinds to use field selectors.
|
||||
// These helper functions are to be used in InstallSchema() in apis/*/register.go files
|
||||
// in order for already existing kinds to use field selectors.
|
||||
|
||||
// AddSelectableFieldLabelConversions registers field selector conversions for kinds that
|
||||
// expose selectable fields via the app SDK.
|
||||
|
|
@ -46,6 +47,9 @@ func BuildGetAttrsFn(k sdkres.Kind) func(obj runtime.Object) (labels.Set, fields
|
|||
return nil, nil, fmt.Errorf("not a resource.Object")
|
||||
} else {
|
||||
fieldsSet := fields.Set{}
|
||||
// Always include metadata.name and metadata.namespace as they are the default selectable fields
|
||||
fieldsSet["metadata.name"] = robj.GetName()
|
||||
fieldsSet["metadata.namespace"] = robj.GetNamespace()
|
||||
|
||||
for _, f := range k.SelectableFields() {
|
||||
v, err := f.FieldValueFunc(robj)
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package apiregistry
|
||||
package fieldselectors
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
|
@ -85,7 +85,9 @@ func TestSelectableFieldsBuildGetAttrsFn(t *testing.T) {
|
|||
|
||||
obj := &sdkres.TypedSpecObject[any]{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Labels: map[string]string{"label": "value"},
|
||||
Labels: map[string]string{"label": "value"},
|
||||
Namespace: "ns",
|
||||
Name: "name",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -94,5 +96,7 @@ func TestSelectableFieldsBuildGetAttrsFn(t *testing.T) {
|
|||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, labels.Set{"label": "value"}, lbls)
|
||||
require.Equal(t, fields.Set{"spec.foo": "bar"}, flds)
|
||||
require.Equal(t, fields.Set{"metadata.name": "name", "metadata.namespace": "ns", "spec.foo": "bar"}, flds)
|
||||
require.Equal(t, "name", flds["metadata.name"])
|
||||
require.Equal(t, "ns", flds["metadata.namespace"])
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
package runner
|
||||
|
||||
import (
|
||||
apiregistry "github.com/grafana/grafana/pkg/registry/apis"
|
||||
"github.com/grafana/grafana/pkg/registry/fieldselectors"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||
|
|
@ -89,7 +89,7 @@ func (b *appBuilder) InstallSchema(scheme *runtime.Scheme) error {
|
|||
if len(kind.SelectableFields()) == 0 {
|
||||
continue
|
||||
}
|
||||
err := apiregistry.AddSelectableFieldLabelConversions(scheme, gv, kind)
|
||||
err := fieldselectors.AddSelectableFieldLabelConversions(scheme, gv, kind)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2024,6 +2024,14 @@ var (
|
|||
Owner: grafanaDatavizSquad,
|
||||
Expression: "false",
|
||||
},
|
||||
{
|
||||
Name: "panelStyleActions",
|
||||
Description: "Enable style actions (copy/paste) in the panel editor",
|
||||
Stage: FeatureStageExperimental,
|
||||
FrontendOnly: true,
|
||||
Owner: grafanaDatavizSquad,
|
||||
Expression: "false",
|
||||
},
|
||||
{
|
||||
Name: "externalVizSuggestions",
|
||||
Description: "Enable all plugins to supply visualization suggestions (including 3rd party plugins)",
|
||||
|
|
|
|||
1
pkg/services/featuremgmt/toggles_gen.csv
generated
1
pkg/services/featuremgmt/toggles_gen.csv
generated
|
|
@ -253,6 +253,7 @@ Created,Name,Stage,Owner,requiresDevMode,RequiresRestart,FrontendOnly
|
|||
2025-10-20,newGauge,preview,@grafana/dataviz-squad,false,false,true
|
||||
2025-11-12,newVizSuggestions,preview,@grafana/dataviz-squad,false,false,true
|
||||
2025-12-02,externalVizSuggestions,experimental,@grafana/dataviz-squad,false,false,true
|
||||
2026-01-28,panelStyleActions,experimental,@grafana/dataviz-squad,false,false,true
|
||||
2025-12-18,heatmapRowsAxisOptions,experimental,@grafana/dataviz-squad,false,false,true
|
||||
2025-10-17,preventPanelChromeOverflow,preview,@grafana/grafana-frontend-platform,false,false,true
|
||||
2025-10-31,jaegerEnableGrpcEndpoint,experimental,@grafana/oss-big-tent,false,false,false
|
||||
|
|
|
|||
|
14
pkg/services/featuremgmt/toggles_gen.json
generated
14
pkg/services/featuremgmt/toggles_gen.json
generated
|
|
@ -3314,6 +3314,20 @@
|
|||
"expression": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "panelStyleActions",
|
||||
"resourceVersion": "1769620237787",
|
||||
"creationTimestamp": "2026-01-28T17:10:37Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enable style actions (copy/paste) in the panel editor",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/dataviz-squad",
|
||||
"frontend": true,
|
||||
"expression": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "panelTimeSettings",
|
||||
|
|
|
|||
|
|
@ -272,20 +272,20 @@ type (
|
|||
type MergeResult definition.MergeResult
|
||||
|
||||
func (m MergeResult) LogContext() []any {
|
||||
if len(m.RenamedReceivers) == 0 && len(m.RenamedTimeIntervals) == 0 {
|
||||
if len(m.Receivers) == 0 && len(m.TimeIntervals) == 0 {
|
||||
return nil
|
||||
}
|
||||
logCtx := make([]any, 0, 4)
|
||||
if len(m.RenamedReceivers) > 0 {
|
||||
if len(m.Receivers) > 0 {
|
||||
rcvBuilder := strings.Builder{}
|
||||
for from, to := range m.RenamedReceivers {
|
||||
for from, to := range m.Receivers {
|
||||
rcvBuilder.WriteString(fmt.Sprintf("'%s'->'%s',", from, to))
|
||||
}
|
||||
logCtx = append(logCtx, "renamedReceivers", fmt.Sprintf("[%s]", rcvBuilder.String()[0:rcvBuilder.Len()-1]))
|
||||
}
|
||||
if len(m.RenamedTimeIntervals) > 0 {
|
||||
if len(m.TimeIntervals) > 0 {
|
||||
intervalBuilder := strings.Builder{}
|
||||
for from, to := range m.RenamedTimeIntervals {
|
||||
for from, to := range m.TimeIntervals {
|
||||
intervalBuilder.WriteString(fmt.Sprintf("'%s'->'%s',", from, to))
|
||||
}
|
||||
logCtx = append(logCtx, "renamedTimeIntervals", fmt.Sprintf("[%s]", intervalBuilder.String()[0:intervalBuilder.Len()-1]))
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export const DEFAULT_ROW_HEIGHT = 250;
|
|||
export const MIN_PANEL_HEIGHT = GRID_CELL_HEIGHT * 3;
|
||||
|
||||
export const LS_PANEL_COPY_KEY = 'panel-copy';
|
||||
export const LS_STYLES_COPY_KEY = 'styles-copy';
|
||||
export const LS_ROW_COPY_KEY = 'row-copy';
|
||||
export const LS_TAB_COPY_KEY = 'tab-copy';
|
||||
export const PANEL_BORDER = 2;
|
||||
|
|
|
|||
|
|
@ -85,6 +85,22 @@ export function getAlertingRoutes(cfg = config): RouteDescriptor[] {
|
|||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/alerting/routes/policy/:name/edit',
|
||||
roles: evaluateAccess([
|
||||
AccessControlAction.AlertingNotificationsRead,
|
||||
...PERMISSIONS_NOTIFICATION_POLICIES_READ,
|
||||
...PERMISSIONS_NOTIFICATION_POLICIES_MODIFY,
|
||||
]),
|
||||
component: config.featureToggles.alertingMultiplePolicies
|
||||
? importAlertingComponent(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "PolicyPage" */ 'app/features/alerting/unified/components/notification-policies/PolicyPage'
|
||||
)
|
||||
)
|
||||
: () => <Navigate replace to="/alerting/routes" />,
|
||||
},
|
||||
{
|
||||
path: '/alerting/silences',
|
||||
roles: evaluateAccess([
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { produce } from 'immer';
|
||||
import { Route, Routes } from 'react-router-dom-v5-compat';
|
||||
import { clickSelectOption } from 'test/helpers/selectOptionInTest';
|
||||
import { render, screen, userEvent, within } from 'test/test-utils';
|
||||
import { byLabelText, byRole, byTestId } from 'testing-library-selector';
|
||||
|
||||
import { config } from '@grafana/runtime';
|
||||
import { AppNotificationList } from 'app/core/components/AppNotifications/AppNotificationList';
|
||||
import { PERMISSIONS_NOTIFICATION_POLICIES } from 'app/features/alerting/unified/components/notification-policies/permissions';
|
||||
import { setupMswServer } from 'app/features/alerting/unified/mockApi';
|
||||
|
|
@ -23,7 +25,6 @@ import {
|
|||
} from 'app/features/alerting/unified/mocks/server/handlers/k8s/timeIntervals.k8s';
|
||||
import { setupDataSources } from 'app/features/alerting/unified/testSetup/datasources';
|
||||
import {
|
||||
AlertManagerCortexConfig,
|
||||
AlertManagerDataSourceJsonData,
|
||||
AlertManagerImplementation,
|
||||
MatcherOperator,
|
||||
|
|
@ -32,15 +33,27 @@ import {
|
|||
import { AccessControlAction } from 'app/types/accessControl';
|
||||
|
||||
import NotificationPolicies from './NotificationPoliciesPage';
|
||||
import { findRoutesMatchingFilters } from './components/notification-policies/NotificationPoliciesList';
|
||||
import { findRoutesMatchingFilters } from './components/notification-policies/PoliciesTree';
|
||||
import PolicyPage from './components/notification-policies/PolicyPage';
|
||||
import {
|
||||
createKubernetesRoutingTreeSpec,
|
||||
k8sRouteToRoute,
|
||||
} from './components/notification-policies/useNotificationPolicyRoute';
|
||||
import {
|
||||
grantUserPermissions,
|
||||
mockDataSource,
|
||||
someCloudAlertManagerConfig,
|
||||
someCloudAlertManagerStatus,
|
||||
} from './mocks';
|
||||
import {
|
||||
deleteRoutingTree,
|
||||
getRoutingTree,
|
||||
resetRoutingTreeMap,
|
||||
setRoutingTree,
|
||||
} from './mocks/server/entities/k8s/routingtrees';
|
||||
import { ALERTMANAGER_NAME_QUERY_KEY } from './utils/constants';
|
||||
import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
|
||||
import { ROOT_ROUTE_NAME } from './utils/k8s/constants';
|
||||
|
||||
jest.mock('./useRouteGroupsMatcher');
|
||||
|
||||
|
|
@ -68,6 +81,7 @@ const openEditModal = async (
|
|||
await user.click(await ui.editButton.find());
|
||||
};
|
||||
|
||||
// This is the page for the default policy when alertingMultiplePolicies is disabled.
|
||||
const renderNotificationPolicies = (alertManagerSourceName: string = GRAFANA_RULES_SOURCE_NAME) =>
|
||||
render(
|
||||
<>
|
||||
|
|
@ -84,6 +98,25 @@ const renderNotificationPolicies = (alertManagerSourceName: string = GRAFANA_RUL
|
|||
}
|
||||
);
|
||||
|
||||
// This is the page for the default policy when alertingMultiplePolicies is enabled.
|
||||
const renderPolicyPage = (routeName: string) => () =>
|
||||
render(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/alerting/routes/policy/:name/edit"
|
||||
element={
|
||||
<>
|
||||
<AppNotificationList />
|
||||
<PolicyPage />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Routes>,
|
||||
{
|
||||
historyOptions: { initialEntries: [`/alerting/routes/policy/${routeName}/edit`] },
|
||||
}
|
||||
);
|
||||
|
||||
const dataSources = {
|
||||
am: mockDataSource({
|
||||
name: 'Alertmanager',
|
||||
|
|
@ -139,7 +172,13 @@ const getRootRoute = async () => {
|
|||
return ui.rootRouteContainer.find();
|
||||
};
|
||||
|
||||
describe('NotificationPolicies', () => {
|
||||
const OtherPolicyName = 'Some Other Policy Name';
|
||||
|
||||
describe.each([
|
||||
{ testName: 'NotificationPoliciesPage', renderPage: renderNotificationPolicies, routeName: ROOT_ROUTE_NAME },
|
||||
{ testName: 'PolicyPage', renderPage: renderPolicyPage(ROOT_ROUTE_NAME), routeName: ROOT_ROUTE_NAME },
|
||||
{ testName: 'PolicyPage', renderPage: renderPolicyPage(OtherPolicyName), routeName: OtherPolicyName },
|
||||
])('$testName - Policy: $routeName', ({ testName, renderPage, routeName }) => {
|
||||
// combobox hack :/
|
||||
beforeAll(() => {
|
||||
const mockGetBoundingClientRect = jest.fn(() => ({
|
||||
|
|
@ -170,14 +209,23 @@ describe('NotificationPolicies', () => {
|
|||
AccessControlAction.AlertingNotificationsExternalWrite,
|
||||
...PERMISSIONS_NOTIFICATION_POLICIES,
|
||||
]);
|
||||
resetRoutingTreeMap();
|
||||
// Copy default config to other policy name and clear the default, so we guarantee the tests are validating against
|
||||
// the custom route.
|
||||
const defaultRoute = getRoutingTree(ROOT_ROUTE_NAME)!;
|
||||
defaultRoute.metadata.name = routeName;
|
||||
deleteRoutingTree(ROOT_ROUTE_NAME);
|
||||
setRoutingTree(routeName, defaultRoute);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
resetRoutingTreeMap();
|
||||
});
|
||||
|
||||
it('loads and shows routes', async () => {
|
||||
const { alertmanager_config: testConfig } = getAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME);
|
||||
const defaultRoute = k8sRouteToRoute(getRoutingTree(routeName)!);
|
||||
|
||||
const { route: defaultRoute } = testConfig;
|
||||
|
||||
renderNotificationPolicies();
|
||||
renderPage();
|
||||
const rootRouteEl = await getRootRoute();
|
||||
|
||||
expect(rootRouteEl).toHaveTextContent(new RegExp(`delivered to ${defaultRoute?.receiver}`, 'i'));
|
||||
|
|
@ -209,7 +257,7 @@ describe('NotificationPolicies', () => {
|
|||
});
|
||||
|
||||
it('can edit root route if one is already defined', async () => {
|
||||
const { user } = renderNotificationPolicies();
|
||||
const { user } = renderPage();
|
||||
let rootRoute = await getRootRoute();
|
||||
|
||||
expect(rootRoute).toHaveTextContent('default policy');
|
||||
|
|
@ -246,14 +294,19 @@ describe('NotificationPolicies', () => {
|
|||
});
|
||||
|
||||
it('can edit root route if one is not defined yet', async () => {
|
||||
setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, {
|
||||
alertmanager_config: {
|
||||
route: {},
|
||||
receivers: [{ name: 'lotsa-emails' }],
|
||||
},
|
||||
template_files: {},
|
||||
});
|
||||
const { user } = renderNotificationPolicies();
|
||||
setRoutingTree(
|
||||
routeName,
|
||||
createKubernetesRoutingTreeSpec({
|
||||
name: routeName,
|
||||
routes: [],
|
||||
})
|
||||
);
|
||||
const { user } = renderPage();
|
||||
|
||||
// Sanity check to make sure we actually have an undefined root route.
|
||||
const rootRouteEl = await getRootRoute();
|
||||
expect(rootRouteEl).not.toHaveTextContent(new RegExp(`delivered to`, 'i'));
|
||||
expect(rootRouteEl).not.toHaveTextContent(new RegExp(`grouped by`, 'i'));
|
||||
|
||||
await openDefaultPolicyEditModal();
|
||||
|
||||
|
|
@ -282,7 +335,7 @@ describe('NotificationPolicies', () => {
|
|||
AccessControlAction.AlertingNotificationsExternalRead,
|
||||
]);
|
||||
|
||||
const { user } = renderNotificationPolicies();
|
||||
const { user } = renderPage();
|
||||
|
||||
expect(ui.newChildPolicyButton.query()).not.toBeInTheDocument();
|
||||
expect(ui.newSiblingPolicyButton.query()).not.toBeInTheDocument();
|
||||
|
|
@ -296,23 +349,23 @@ describe('NotificationPolicies', () => {
|
|||
makeAllAlertmanagerConfigFetchFail(getErrorResponse(errMessage));
|
||||
makeAllK8sGetEndpointsFail('alerting.config.notfound', errMessage);
|
||||
|
||||
renderNotificationPolicies();
|
||||
renderPage();
|
||||
const alert = await screen.findByRole('alert', { name: /error loading alertmanager config/i });
|
||||
expect(await within(alert).findByText(new RegExp(errMessage))).toBeInTheDocument();
|
||||
expect(ui.rootRouteContainer.query()).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('allows user to reload and update policies if its been changed by another user', async () => {
|
||||
const { user } = renderNotificationPolicies();
|
||||
const { user } = renderPage();
|
||||
const NEW_INTERVAL = '12h';
|
||||
|
||||
await getRootRoute();
|
||||
|
||||
const existingConfig = getAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME);
|
||||
const existingConfig = getRoutingTree(routeName)!;
|
||||
const modifiedConfig = produce(existingConfig, (draft) => {
|
||||
draft.alertmanager_config.route!.group_interval = NEW_INTERVAL;
|
||||
draft.spec.defaults.group_interval = NEW_INTERVAL;
|
||||
});
|
||||
setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, modifiedConfig);
|
||||
setRoutingTree(routeName, modifiedConfig);
|
||||
|
||||
await openDefaultPolicyEditModal();
|
||||
await user.click(await screen.findByRole('button', { name: /update default policy/i }));
|
||||
|
|
@ -328,18 +381,15 @@ describe('NotificationPolicies', () => {
|
|||
});
|
||||
|
||||
it('Should be able to delete an empty route', async () => {
|
||||
const defaultConfig: AlertManagerCortexConfig = {
|
||||
alertmanager_config: {
|
||||
route: {
|
||||
routes: [{}],
|
||||
},
|
||||
},
|
||||
template_files: {},
|
||||
};
|
||||
setRoutingTree(
|
||||
routeName,
|
||||
createKubernetesRoutingTreeSpec({
|
||||
name: routeName,
|
||||
routes: [{}],
|
||||
})
|
||||
);
|
||||
|
||||
setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, defaultConfig);
|
||||
|
||||
const { user } = renderNotificationPolicies(GRAFANA_RULES_SOURCE_NAME);
|
||||
const { user } = renderPage();
|
||||
|
||||
await user.click(await ui.moreActions.find());
|
||||
const deleteButtons = await ui.deleteRouteButton.find();
|
||||
|
|
@ -357,7 +407,7 @@ describe('NotificationPolicies', () => {
|
|||
});
|
||||
|
||||
it('Can add a mute timing to a route', async () => {
|
||||
const { user } = renderNotificationPolicies();
|
||||
const { user } = renderPage();
|
||||
|
||||
await openEditModal(0);
|
||||
|
||||
|
|
@ -377,6 +427,15 @@ describe('NotificationPolicies', () => {
|
|||
});
|
||||
|
||||
describe('Non-Grafana alertmanagers', () => {
|
||||
beforeAll(() => {
|
||||
setupDataSources(...Object.values(dataSources));
|
||||
grantUserPermissions([
|
||||
AccessControlAction.AlertingNotificationsExternalRead,
|
||||
AccessControlAction.AlertingNotificationsExternalWrite,
|
||||
...PERMISSIONS_NOTIFICATION_POLICIES,
|
||||
]);
|
||||
});
|
||||
|
||||
it.skip('Shows an empty config when config returns an error and the AM supports lazy config initialization', async () => {
|
||||
makeAllAlertmanagerConfigFetchFail(getErrorResponse('alertmanager storage object not found'));
|
||||
setAlertmanagerStatus(dataSources.mimir.uid, someCloudAlertManagerStatus);
|
||||
|
|
@ -528,3 +587,56 @@ describe('findRoutesMatchingFilters', () => {
|
|||
expect(matchingRoutes).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
const uiMultiRoute = {
|
||||
/** Policy table row by name */
|
||||
routeContainer: (name: string) => byTestId(`routing-tree_${name}`),
|
||||
/** Search box for routing policies */
|
||||
policyFilter: byRole('textbox', { name: /search routing trees/ }),
|
||||
};
|
||||
|
||||
describe('alertingMultiplePolicies Feature Flag', () => {
|
||||
const originalFeatureToggle = config.featureToggles.alertingMultiplePolicies;
|
||||
|
||||
afterAll(() => {
|
||||
config.featureToggles.alertingMultiplePolicies = originalFeatureToggle;
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
setupDataSources(...Object.values(dataSources));
|
||||
grantUserPermissions([AccessControlAction.AlertingNotificationsExternalRead, ...PERMISSIONS_NOTIFICATION_POLICIES]);
|
||||
});
|
||||
|
||||
it('Should render PoliciesList when alertingMultiplePolicies feature flag is enabled', async () => {
|
||||
config.featureToggles.alertingMultiplePolicies = true;
|
||||
|
||||
renderNotificationPolicies();
|
||||
await uiMultiRoute.routeContainer('user-defined').find();
|
||||
|
||||
expect(uiMultiRoute.policyFilter.get()).toBeInTheDocument();
|
||||
// This is rendered only when displaying the full policy, it shouldn't appear in the List view.
|
||||
expect(ui.rootRouteContainer.query()).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Should not render PoliciesList when alertingMultiplePolicies feature flag is disabled', async () => {
|
||||
config.featureToggles.alertingMultiplePolicies = false;
|
||||
|
||||
renderNotificationPolicies();
|
||||
await getRootRoute();
|
||||
|
||||
expect(uiMultiRoute.policyFilter.query()).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Should not render PoliciesList when alertmanager is external', async () => {
|
||||
config.featureToggles.alertingMultiplePolicies = true;
|
||||
|
||||
setAlertmanagerStatus(dataSources.promAlertManager.uid, {
|
||||
...someCloudAlertManagerStatus,
|
||||
config: someCloudAlertManagerConfig.alertmanager_config,
|
||||
});
|
||||
renderNotificationPolicies(dataSources.promAlertManager.name);
|
||||
await getRootRoute();
|
||||
|
||||
expect(uiMultiRoute.policyFilter.query()).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,10 +3,12 @@ import { useState } from 'react';
|
|||
|
||||
import { GrafanaTheme2, UrlQueryMap } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { Tab, TabContent, TabsBar, useStyles2 } from '@grafana/ui';
|
||||
import { useQueryParams } from 'app/core/hooks/useQueryParams';
|
||||
import { useMuteTimings } from 'app/features/alerting/unified/components/mute-timings/useMuteTimings';
|
||||
import { NotificationPoliciesList } from 'app/features/alerting/unified/components/notification-policies/NotificationPoliciesList';
|
||||
import { PoliciesList } from 'app/features/alerting/unified/components/notification-policies/PoliciesList';
|
||||
import { PoliciesTree } from 'app/features/alerting/unified/components/notification-policies/PoliciesTree';
|
||||
import { AlertmanagerAction, useAlertmanagerAbility } from 'app/features/alerting/unified/hooks/useAbilities';
|
||||
|
||||
import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper';
|
||||
|
|
@ -75,13 +77,26 @@ const NotificationPoliciesTabs = () => {
|
|||
)}
|
||||
</TabsBar>
|
||||
<TabContent className={styles.tabContent}>
|
||||
{policyTreeTabActive && <NotificationPoliciesList />}
|
||||
{policyTreeTabActive && <PolicyTreeTab />}
|
||||
{muteTimingsTabActive && <TimeIntervalsTable />}
|
||||
</TabContent>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const PolicyTreeTab = () => {
|
||||
const { isGrafanaAlertmanager } = useAlertmanager();
|
||||
|
||||
const useMultiplePoliciesView = config.featureToggles.alertingMultiplePolicies;
|
||||
|
||||
// Render just the single main tree if not Grafana Alertmanager or the multiple policies view is disabled.
|
||||
if (!isGrafanaAlertmanager || !useMultiplePoliciesView) {
|
||||
return <PoliciesTree />;
|
||||
}
|
||||
|
||||
return <PoliciesList />;
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
tabContent: css({
|
||||
marginTop: theme.spacing(2),
|
||||
|
|
|
|||
|
|
@ -402,10 +402,10 @@ export const alertRuleApi = alertingApi.injectEndpoints({
|
|||
}),
|
||||
keepUnusedDataFor: 0,
|
||||
}),
|
||||
exportPolicies: build.query<string, { format: ExportFormats }>({
|
||||
query: ({ format }) => ({
|
||||
exportPolicies: build.query<string, { routeName?: string; format: ExportFormats }>({
|
||||
query: ({ routeName, format }) => ({
|
||||
url: `/api/v1/provisioning/policies/export/`,
|
||||
params: { format: format },
|
||||
params: { format: format, routeName: routeName },
|
||||
responseType: 'text',
|
||||
}),
|
||||
keepUnusedDataFor: 0,
|
||||
|
|
|
|||
|
|
@ -9,12 +9,14 @@ import { FileExportPreview } from './FileExportPreview';
|
|||
import { GrafanaExportDrawer } from './GrafanaExportDrawer';
|
||||
import { ExportFormats, allGrafanaExportProviders } from './providers';
|
||||
interface GrafanaPoliciesPreviewProps {
|
||||
routeName?: string;
|
||||
exportFormat: ExportFormats;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const GrafanaPoliciesExporterPreview = ({ exportFormat, onClose }: GrafanaPoliciesPreviewProps) => {
|
||||
const GrafanaPoliciesExporterPreview = ({ routeName = '', exportFormat, onClose }: GrafanaPoliciesPreviewProps) => {
|
||||
const { currentData: policiesDefinition = '', isFetching } = alertRuleApi.useExportPoliciesQuery({
|
||||
routeName: routeName,
|
||||
format: exportFormat,
|
||||
});
|
||||
|
||||
|
|
@ -35,10 +37,11 @@ const GrafanaPoliciesExporterPreview = ({ exportFormat, onClose }: GrafanaPolici
|
|||
};
|
||||
|
||||
interface GrafanaPoliciesExporterProps {
|
||||
routeName?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const GrafanaPoliciesExporter = ({ onClose }: GrafanaPoliciesExporterProps) => {
|
||||
export const GrafanaPoliciesExporter = ({ routeName = '', onClose }: GrafanaPoliciesExporterProps) => {
|
||||
const [activeTab, setActiveTab] = useState<ExportFormats>('yaml');
|
||||
|
||||
return (
|
||||
|
|
@ -48,7 +51,7 @@ export const GrafanaPoliciesExporter = ({ onClose }: GrafanaPoliciesExporterProp
|
|||
onClose={onClose}
|
||||
formatProviders={Object.values(allGrafanaExportProviders)}
|
||||
>
|
||||
<GrafanaPoliciesExporterPreview exportFormat={activeTab} onClose={onClose} />
|
||||
<GrafanaPoliciesExporterPreview exportFormat={activeTab} onClose={onClose} routeName={routeName} />
|
||||
</GrafanaExportDrawer>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ const ui = {
|
|||
groupWaitInput: byRole('textbox', { name: /Group wait/ }),
|
||||
groupIntervalInput: byRole('textbox', { name: /Group interval/ }),
|
||||
repeatIntervalInput: byRole('textbox', { name: /Repeat interval/ }),
|
||||
routeNameInput: byRole('textbox', { name: /Name/ }),
|
||||
};
|
||||
setupMswServer();
|
||||
// TODO Default and Notification policy form should be unified so we don't need to maintain two almost identical forms
|
||||
|
|
@ -128,9 +129,37 @@ describe('EditDefaultPolicyForm', function () {
|
|||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
describe('Create Policy', function () {
|
||||
it('should render policy name in form inputs if showNameField=true', async function () {
|
||||
const onSubmit = jest.fn();
|
||||
renderRouteForm(
|
||||
{
|
||||
id: '0',
|
||||
name: 'custom policy name',
|
||||
},
|
||||
onSubmit,
|
||||
true
|
||||
);
|
||||
|
||||
expect(ui.routeNameInput.get()).toHaveValue('custom policy name');
|
||||
});
|
||||
it('should not render policy name in form inputs if showNameField omitted', async function () {
|
||||
renderRouteForm({
|
||||
id: '0',
|
||||
name: 'custom policy name',
|
||||
});
|
||||
|
||||
expect(ui.routeNameInput.query()).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function renderRouteForm(route: RouteWithID, onSubmit: (route: Partial<FormAmRoute>) => void = noop) {
|
||||
function renderRouteForm(
|
||||
route: RouteWithID,
|
||||
onSubmit: (route: Partial<FormAmRoute>) => void = noop,
|
||||
showNameField?: boolean
|
||||
) {
|
||||
return render(
|
||||
<AlertmanagerProvider accessType="instance">
|
||||
<AmRootRouteForm
|
||||
|
|
@ -138,6 +167,7 @@ function renderRouteForm(route: RouteWithID, onSubmit: (route: Partial<FormAmRou
|
|||
actionButtons={<Button type="submit">Update default policy</Button>}
|
||||
onSubmit={onSubmit}
|
||||
route={route}
|
||||
showNameField={showNameField}
|
||||
/>
|
||||
</AlertmanagerProvider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { Controller, useForm } from 'react-hook-form';
|
|||
|
||||
import { ContactPointSelector as GrafanaManagedContactPointSelector } from '@grafana/alerting/unstable';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { Collapse, Field, Link, MultiSelect, useStyles2 } from '@grafana/ui';
|
||||
import { Collapse, Field, Input, Link, MultiSelect, Stack, useStyles2 } from '@grafana/ui';
|
||||
import { ExternalAlertmanagerContactPointSelector } from 'app/features/alerting/unified/components/notification-policies/ContactPointSelector';
|
||||
import { handleContactPointSelect } from 'app/features/alerting/unified/components/notification-policies/utils';
|
||||
import { RouteWithID } from 'app/plugins/datasource/alertmanager/types';
|
||||
|
|
@ -30,9 +30,16 @@ export interface AmRootRouteFormProps {
|
|||
actionButtons: ReactNode;
|
||||
onSubmit: (route: Partial<FormAmRoute>) => void;
|
||||
route: RouteWithID;
|
||||
showNameField?: boolean;
|
||||
}
|
||||
|
||||
export const AmRootRouteForm = ({ actionButtons, alertManagerSourceName, onSubmit, route }: AmRootRouteFormProps) => {
|
||||
export const AmRootRouteForm = ({
|
||||
actionButtons,
|
||||
alertManagerSourceName,
|
||||
onSubmit,
|
||||
route,
|
||||
showNameField,
|
||||
}: AmRootRouteFormProps) => {
|
||||
const styles = useStyles2(getFormStyles);
|
||||
const [isTimingOptionsExpanded, setIsTimingOptionsExpanded] = useState(false);
|
||||
const { isGrafanaAlertmanager } = useAlertmanager();
|
||||
|
|
@ -55,146 +62,183 @@ export const AmRootRouteForm = ({ actionButtons, alertManagerSourceName, onSubmi
|
|||
});
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Field
|
||||
label={t('alerting.am-root-route-form.label-default-contact-point', 'Default contact point')}
|
||||
invalid={Boolean(errors.receiver) ? true : undefined}
|
||||
error={errors.receiver?.message}
|
||||
>
|
||||
<div className={styles.container} data-testid="am-receiver-select">
|
||||
<Controller
|
||||
render={({ field: { onChange, ref, value, ...field } }) =>
|
||||
isGrafanaAlertmanager ? (
|
||||
<GrafanaManagedContactPointSelector
|
||||
onChange={(contactPoint) => {
|
||||
handleContactPointSelect(contactPoint?.spec.title, onChange);
|
||||
}}
|
||||
isClearable={false}
|
||||
value={value}
|
||||
placeholder={t(
|
||||
'alerting.notification-policies-filter.placeholder-search-by-contact-point',
|
||||
'Choose a contact point'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<ExternalAlertmanagerContactPointSelector
|
||||
selectProps={{
|
||||
...field,
|
||||
onChange: (changeValue) => handleContactPointSelect(changeValue.value?.name, onChange),
|
||||
}}
|
||||
selectedContactPointName={value}
|
||||
/>
|
||||
)
|
||||
}
|
||||
control={control}
|
||||
name="receiver"
|
||||
rules={{
|
||||
required: { value: true, message: t('alerting.am-root-route-form.message.required', 'Required.') },
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
<Trans i18nKey="alerting.am-root-route-form.or">or</Trans>
|
||||
</span>
|
||||
<Link
|
||||
className={styles.linkText}
|
||||
href={makeAMLink('/alerting/notifications/receivers/new', alertManagerSourceName)}
|
||||
>
|
||||
<Trans i18nKey="alerting.am-root-route-form.create-a-contact-point">Create a contact point</Trans>
|
||||
</Link>
|
||||
</div>
|
||||
</Field>
|
||||
<Field
|
||||
label={t('alerting.am-root-route-form.am-group-select-label-group-by', 'Group by')}
|
||||
description={t(
|
||||
'alerting.am-root-route-form.am-group-select-description-group-by',
|
||||
'Combine multiple alerts into a single notification by grouping them by the same label values.'
|
||||
)}
|
||||
data-testid="am-group-select"
|
||||
>
|
||||
<Controller
|
||||
render={({ field: { onChange, ref, ...field } }) => (
|
||||
<MultiSelect
|
||||
aria-label={t('alerting.am-root-route-form.aria-label-group-by', 'Group by')}
|
||||
{...field}
|
||||
allowCustomValue
|
||||
className={styles.input}
|
||||
onCreateOption={(opt: string) => {
|
||||
setGroupByOptions((opts) => [...opts, stringToSelectableValue(opt)]);
|
||||
setValue('groupBy', [...(field.value || []), opt]);
|
||||
}}
|
||||
onChange={(value) => onChange(mapMultiSelectValueToStrings(value))}
|
||||
options={[...commonGroupByOptions, ...groupByOptions]}
|
||||
/>
|
||||
)}
|
||||
control={control}
|
||||
name="groupBy"
|
||||
/>
|
||||
</Field>
|
||||
<Collapse
|
||||
className={styles.collapse}
|
||||
isOpen={isTimingOptionsExpanded}
|
||||
label={t('alerting.am-root-route-form.label-timing-options', 'Timing options')}
|
||||
onToggle={setIsTimingOptionsExpanded}
|
||||
>
|
||||
<div className={styles.timingFormContainer}>
|
||||
<Stack direction="column" gap={2}>
|
||||
{showNameField && (
|
||||
<Field
|
||||
label={t('alerting.am-root-route-form.am-group-wait-label-group-wait', 'Group wait')}
|
||||
noMargin
|
||||
required
|
||||
label={t('alerting.am-root-route-form.label-name', 'Name')}
|
||||
description={t(
|
||||
'alerting.am-root-route-form.am-group-description-label',
|
||||
'The waiting time before sending the first notification for a new group of alerts. Default 30 seconds.'
|
||||
'alerting.am-root-route-form.description-unique-routing',
|
||||
'A unique name for the routing tree'
|
||||
)}
|
||||
invalid={!!errors.groupWaitValue}
|
||||
error={errors.groupWaitValue?.message}
|
||||
data-testid="am-group-wait"
|
||||
invalid={!!errors.name}
|
||||
error={errors.name?.message}
|
||||
>
|
||||
<PromDurationInput
|
||||
{...register('groupWaitValue', { validate: promDurationValidator })}
|
||||
placeholder={TIMING_OPTIONS_DEFAULTS.group_wait}
|
||||
className={styles.promDurationInput}
|
||||
aria-label={t('alerting.am-root-route-form.aria-label-group-wait', 'Group wait')}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={t('alerting.am-root-route-form.am-group-interval-label-group-interval', 'Group interval')}
|
||||
description={t(
|
||||
'alerting.am-root-route-form.am-group-interval-description',
|
||||
'The wait time before sending a notification about changes in the alert group after the first notification has been sent. Default is 5 minutes.'
|
||||
)}
|
||||
invalid={!!errors.groupIntervalValue}
|
||||
error={errors.groupIntervalValue?.message}
|
||||
data-testid="am-group-interval"
|
||||
>
|
||||
<PromDurationInput
|
||||
{...register('groupIntervalValue', { validate: promDurationValidator })}
|
||||
placeholder={TIMING_OPTIONS_DEFAULTS.group_interval}
|
||||
className={styles.promDurationInput}
|
||||
aria-label={t('alerting.am-root-route-form.aria-label-group-interval', 'Group interval')}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={t('alerting.am-root-route-form.am-repeat-interval-label-repeat-interval', 'Repeat interval')}
|
||||
description={t(
|
||||
'alerting.am-root-route-form.am-repeat-interval-description',
|
||||
'The wait time before resending a notification that has already been sent successfully. Default is 4 hours. Should be a multiple of Group interval.'
|
||||
)}
|
||||
invalid={!!errors.repeatIntervalValue}
|
||||
error={errors.repeatIntervalValue?.message}
|
||||
data-testid="am-repeat-interval"
|
||||
>
|
||||
<PromDurationInput
|
||||
{...register('repeatIntervalValue', {
|
||||
validate: (value: string) => {
|
||||
const groupInterval = getValues('groupIntervalValue');
|
||||
return repeatIntervalValidator(value, groupInterval);
|
||||
<Input
|
||||
{...register('name', {
|
||||
required: true,
|
||||
validate: (value) => {
|
||||
if (!value || value.trim().length === 0) {
|
||||
return t('alerting.am-root-route-form.validate-name', 'Name is required');
|
||||
}
|
||||
return true;
|
||||
},
|
||||
})}
|
||||
placeholder={TIMING_OPTIONS_DEFAULTS.repeat_interval}
|
||||
className={styles.promDurationInput}
|
||||
aria-label={t('alerting.am-root-route-form.aria-label-repeat-interval', 'Repeat interval')}
|
||||
className={styles.input}
|
||||
data-testid="routing-tree-name"
|
||||
aria-label={t('alerting.am-root-route-form.aria-label-name', 'Name')}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</Collapse>
|
||||
<div className={styles.container}>{actionButtons}</div>
|
||||
)}
|
||||
<Field
|
||||
noMargin
|
||||
label={t('alerting.am-root-route-form.label-default-contact-point', 'Default contact point')}
|
||||
invalid={Boolean(errors.receiver) ? true : undefined}
|
||||
error={errors.receiver?.message}
|
||||
>
|
||||
<div className={styles.container} data-testid="am-receiver-select">
|
||||
<Controller
|
||||
render={({ field: { onChange, ref, value, ...field } }) =>
|
||||
isGrafanaAlertmanager ? (
|
||||
<GrafanaManagedContactPointSelector
|
||||
onChange={(contactPoint) => {
|
||||
handleContactPointSelect(contactPoint?.spec.title, onChange);
|
||||
}}
|
||||
isClearable={false}
|
||||
value={value}
|
||||
placeholder={t(
|
||||
'alerting.notification-policies-filter.placeholder-search-by-contact-point',
|
||||
'Choose a contact point'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<ExternalAlertmanagerContactPointSelector
|
||||
selectProps={{
|
||||
...field,
|
||||
onChange: (changeValue) => handleContactPointSelect(changeValue.value?.name, onChange),
|
||||
}}
|
||||
selectedContactPointName={value}
|
||||
/>
|
||||
)
|
||||
}
|
||||
control={control}
|
||||
name="receiver"
|
||||
rules={{
|
||||
required: { value: true, message: t('alerting.am-root-route-form.message.required', 'Required.') },
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
<Trans i18nKey="alerting.am-root-route-form.or">or</Trans>
|
||||
</span>
|
||||
<Link
|
||||
className={styles.linkText}
|
||||
href={makeAMLink('/alerting/notifications/receivers/new', alertManagerSourceName)}
|
||||
>
|
||||
<Trans i18nKey="alerting.am-root-route-form.create-a-contact-point">Create a contact point</Trans>
|
||||
</Link>
|
||||
</div>
|
||||
</Field>
|
||||
<Field
|
||||
noMargin
|
||||
label={t('alerting.am-root-route-form.am-group-select-label-group-by', 'Group by')}
|
||||
description={t(
|
||||
'alerting.am-root-route-form.am-group-select-description-group-by',
|
||||
'Combine multiple alerts into a single notification by grouping them by the same label values.'
|
||||
)}
|
||||
data-testid="am-group-select"
|
||||
>
|
||||
<Controller
|
||||
render={({ field: { onChange, ref, ...field } }) => (
|
||||
<MultiSelect
|
||||
aria-label={t('alerting.am-root-route-form.aria-label-group-by', 'Group by')}
|
||||
{...field}
|
||||
allowCustomValue
|
||||
className={styles.input}
|
||||
onCreateOption={(opt: string) => {
|
||||
setGroupByOptions((opts) => [...opts, stringToSelectableValue(opt)]);
|
||||
setValue('groupBy', [...(field.value || []), opt]);
|
||||
}}
|
||||
onChange={(value) => onChange(mapMultiSelectValueToStrings(value))}
|
||||
options={[...commonGroupByOptions, ...groupByOptions]}
|
||||
/>
|
||||
)}
|
||||
control={control}
|
||||
name="groupBy"
|
||||
/>
|
||||
</Field>
|
||||
<Collapse
|
||||
className={styles.collapse}
|
||||
isOpen={isTimingOptionsExpanded}
|
||||
label={t('alerting.am-root-route-form.label-timing-options', 'Timing options')}
|
||||
onToggle={setIsTimingOptionsExpanded}
|
||||
>
|
||||
<div className={styles.timingFormContainer}>
|
||||
<Stack direction="column" gap={2}>
|
||||
<Field
|
||||
noMargin
|
||||
label={t('alerting.am-root-route-form.am-group-wait-label-group-wait', 'Group wait')}
|
||||
description={t(
|
||||
'alerting.am-root-route-form.am-group-description-label',
|
||||
'The waiting time before sending the first notification for a new group of alerts. Default 30 seconds.'
|
||||
)}
|
||||
invalid={!!errors.groupWaitValue}
|
||||
error={errors.groupWaitValue?.message}
|
||||
data-testid="am-group-wait"
|
||||
>
|
||||
<PromDurationInput
|
||||
{...register('groupWaitValue', { validate: promDurationValidator })}
|
||||
placeholder={TIMING_OPTIONS_DEFAULTS.group_wait}
|
||||
className={styles.promDurationInput}
|
||||
aria-label={t('alerting.am-root-route-form.aria-label-group-wait', 'Group wait')}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
noMargin
|
||||
label={t('alerting.am-root-route-form.am-group-interval-label-group-interval', 'Group interval')}
|
||||
description={t(
|
||||
'alerting.am-root-route-form.am-group-interval-description',
|
||||
'The wait time before sending a notification about changes in the alert group after the first notification has been sent. Default is 5 minutes.'
|
||||
)}
|
||||
invalid={!!errors.groupIntervalValue}
|
||||
error={errors.groupIntervalValue?.message}
|
||||
data-testid="am-group-interval"
|
||||
>
|
||||
<PromDurationInput
|
||||
{...register('groupIntervalValue', { validate: promDurationValidator })}
|
||||
placeholder={TIMING_OPTIONS_DEFAULTS.group_interval}
|
||||
className={styles.promDurationInput}
|
||||
aria-label={t('alerting.am-root-route-form.aria-label-group-interval', 'Group interval')}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
noMargin
|
||||
label={t('alerting.am-root-route-form.am-repeat-interval-label-repeat-interval', 'Repeat interval')}
|
||||
description={t(
|
||||
'alerting.am-root-route-form.am-repeat-interval-description',
|
||||
'The wait time before resending a notification that has already been sent successfully. Default is 4 hours. Should be a multiple of Group interval.'
|
||||
)}
|
||||
invalid={!!errors.repeatIntervalValue}
|
||||
error={errors.repeatIntervalValue?.message}
|
||||
data-testid="am-repeat-interval"
|
||||
>
|
||||
<PromDurationInput
|
||||
{...register('repeatIntervalValue', {
|
||||
validate: (value: string) => {
|
||||
const groupInterval = getValues('groupIntervalValue');
|
||||
return repeatIntervalValidator(value, groupInterval);
|
||||
},
|
||||
})}
|
||||
placeholder={TIMING_OPTIONS_DEFAULTS.repeat_interval}
|
||||
className={styles.promDurationInput}
|
||||
aria-label={t('alerting.am-root-route-form.aria-label-repeat-interval', 'Repeat interval')}
|
||||
/>
|
||||
</Field>
|
||||
</Stack>
|
||||
</div>
|
||||
</Collapse>
|
||||
<div className={styles.container}>{actionButtons}</div>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -298,10 +298,10 @@ const useAlertGroupsModal = (
|
|||
return [modalElement, handleShow, handleDismiss];
|
||||
};
|
||||
|
||||
const UpdatingModal: FC<Pick<ModalProps, 'isOpen'>> = ({ isOpen }) => (
|
||||
export const UpdatingModal: FC<Pick<ModalProps, 'isOpen' | 'onDismiss'>> = ({ isOpen, onDismiss = () => {} }) => (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onDismiss={() => {}}
|
||||
onDismiss={onDismiss}
|
||||
closeOnBackdropClick={false}
|
||||
closeOnEscape={false}
|
||||
ariaLabel={t('alerting.policies.update.updating', 'Updating...')}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,324 @@
|
|||
import { render } from 'test/test-utils';
|
||||
import { byLabelText, byRole, byTestId } from 'testing-library-selector';
|
||||
|
||||
import { config } from '@grafana/runtime';
|
||||
import { AppNotificationList } from 'app/core/components/AppNotifications/AppNotificationList';
|
||||
import { setupMswServer } from 'app/features/alerting/unified/mockApi';
|
||||
import { setupDataSources } from 'app/features/alerting/unified/testSetup/datasources';
|
||||
|
||||
import { AccessControlAction } from '../../../../../types/accessControl';
|
||||
import NotificationPolicies from '../../NotificationPoliciesPage';
|
||||
import { AlertmanagerAction, useAlertmanagerAbilities, useAlertmanagerAbility } from '../../hooks/useAbilities';
|
||||
import { grantUserPermissions, mockDataSource } from '../../mocks';
|
||||
import { getRoutingTree, getRoutingTreeList, resetRoutingTreeMap } from '../../mocks/server/entities/k8s/routingtrees';
|
||||
import { KnownProvenance } from '../../types/knownProvenance';
|
||||
import { DataSourceType } from '../../utils/datasource';
|
||||
import { K8sAnnotations, ROOT_ROUTE_NAME } from '../../utils/k8s/constants';
|
||||
|
||||
import { countPolicies } from './PoliciesList';
|
||||
import { TIMING_OPTIONS_DEFAULTS } from './timingOptions';
|
||||
|
||||
jest.mock('../../useRouteGroupsMatcher');
|
||||
|
||||
jest.mock('../../hooks/useAbilities', () => ({
|
||||
...jest.requireActual('../../hooks/useAbilities'),
|
||||
useAlertmanagerAbilities: jest.fn(),
|
||||
useAlertmanagerAbility: jest.fn(),
|
||||
}));
|
||||
|
||||
const mocks = {
|
||||
// Mock the hooks that are actually used by the components:
|
||||
useAlertmanagerAbilities: jest.mocked(useAlertmanagerAbilities),
|
||||
useAlertmanagerAbility: jest.mocked(useAlertmanagerAbility),
|
||||
};
|
||||
|
||||
setupMswServer();
|
||||
|
||||
const renderNotificationPolicies = () =>
|
||||
render(
|
||||
<>
|
||||
<AppNotificationList />
|
||||
<NotificationPolicies />
|
||||
</>,
|
||||
{
|
||||
historyOptions: {
|
||||
initialEntries: ['/alerting/routes'],
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const dataSources = {
|
||||
am: mockDataSource({
|
||||
name: 'Alertmanager',
|
||||
type: DataSourceType.Alertmanager,
|
||||
}),
|
||||
};
|
||||
|
||||
const ui = {
|
||||
/** Policy table row by name */
|
||||
routeContainer: (name: string) => byTestId(`routing-tree_${name}`),
|
||||
/** Search box for routing policies */
|
||||
policyFilter: byRole('textbox', { name: /search routing trees/ }),
|
||||
|
||||
createPolicyButton: byTestId('create-policy-button'),
|
||||
exportAllButton: byTestId('export-all-policy-button'),
|
||||
viewButton: byTestId('view-action'),
|
||||
editButton: byTestId('edit-action'),
|
||||
moreActionsButton: byTestId('more-actions'),
|
||||
exportButton: byRole('menuitem', { name: /export/i }),
|
||||
deleteButton: byRole('menuitem', { name: /delete/i }),
|
||||
|
||||
/** (deeply) Nested rows of policies under the default/root policy */
|
||||
row: byTestId('am-route-container'),
|
||||
|
||||
newChildPolicyButton: byRole('button', { name: /New child policy/ }),
|
||||
newSiblingPolicyButton: byRole('button', { name: /Add new policy/ }),
|
||||
|
||||
moreActionsDefaultPolicy: byLabelText(/more actions for default policy/i),
|
||||
moreActions: byLabelText(/more actions for policy/i),
|
||||
// editButton: byRole('menuitem', { name: 'Edit' }),
|
||||
|
||||
saveButton: byRole('button', { name: /update (default )?policy/i }),
|
||||
deleteRouteButton: byRole('menuitem', { name: 'Delete' }),
|
||||
|
||||
receiverSelect: byTestId('am-receiver-select'),
|
||||
groupSelect: byTestId('am-group-select'),
|
||||
muteTimingSelect: byTestId('am-mute-timing-select'),
|
||||
|
||||
groupWaitContainer: byTestId('am-group-wait'),
|
||||
groupIntervalContainer: byTestId('am-group-interval'),
|
||||
groupRepeatContainer: byTestId('am-repeat-interval'),
|
||||
|
||||
confirmDeleteModal: byRole('dialog'),
|
||||
confirmDeleteButton: byRole('button', { name: /yes, delete policy/i }),
|
||||
};
|
||||
|
||||
const getRoute = async (routeName: string) => {
|
||||
return ui.routeContainer(routeName).find();
|
||||
};
|
||||
|
||||
const allPolicyActions = [
|
||||
AlertmanagerAction.CreateNotificationPolicy,
|
||||
AlertmanagerAction.ViewNotificationPolicyTree,
|
||||
AlertmanagerAction.UpdateNotificationPolicyTree,
|
||||
AlertmanagerAction.DeleteNotificationPolicy,
|
||||
AlertmanagerAction.ExportNotificationPolicies,
|
||||
];
|
||||
|
||||
const grantAlertmanagerAbilities = (allowed: AlertmanagerAction[]) => {
|
||||
mocks.useAlertmanagerAbility.mockImplementation((action) => {
|
||||
// Default to all known policy actions supported and allowed, others are denied.
|
||||
const included = allowed.includes(action);
|
||||
return [true, included]; // Always supported, but only allow those from input.
|
||||
});
|
||||
|
||||
mocks.useAlertmanagerAbilities.mockImplementation((actions) => {
|
||||
// Default to all known policy actions supported and allowed, others are denied.
|
||||
return actions.map((action) => {
|
||||
const included = allowed.includes(action);
|
||||
return [true, included]; // Always supported, but only allow those from input.
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
describe('PoliciesList', () => {
|
||||
const originalFeatureToggle = config.featureToggles.alertingMultiplePolicies;
|
||||
afterAll(() => {
|
||||
config.featureToggles.alertingMultiplePolicies = originalFeatureToggle;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
config.featureToggles.alertingMultiplePolicies = true;
|
||||
setupDataSources(...Object.values(dataSources));
|
||||
jest.clearAllMocks();
|
||||
jest.restoreAllMocks();
|
||||
jest.resetAllMocks();
|
||||
|
||||
grantAlertmanagerAbilities(allPolicyActions);
|
||||
|
||||
grantUserPermissions([AccessControlAction.AlertingInstanceRead, AccessControlAction.AlertingNotificationsRead]);
|
||||
|
||||
resetRoutingTreeMap();
|
||||
});
|
||||
|
||||
describe('Route headers and metadata', () => {
|
||||
const allRoutes = getRoutingTreeList();
|
||||
expect(allRoutes).toHaveLength(5);
|
||||
it.each(allRoutes.map((route) => ({ routeName: route.metadata.name! })))(
|
||||
'policy: $routeName',
|
||||
async ({ routeName }) => {
|
||||
const route = getRoutingTree(routeName)!;
|
||||
|
||||
renderNotificationPolicies();
|
||||
const routeEl = await getRoute(routeName);
|
||||
|
||||
const size = countPolicies(route.spec);
|
||||
if (size === 0) {
|
||||
expect(routeEl).not.toHaveTextContent(new RegExp(`contains \\d+ polic(ies|y)`, 'i'));
|
||||
} else {
|
||||
expect(routeEl).toHaveTextContent(new RegExp(`contains ${size} polic(ies|y)`, 'i'));
|
||||
}
|
||||
|
||||
const groupBy = route?.spec.defaults.group_by ?? [];
|
||||
let groupingText = 'Single group';
|
||||
if (groupBy.length > 0) {
|
||||
groupingText = groupBy[0] === '...' ? 'Not grouping' : `grouped by ${groupBy.join(', ')}`;
|
||||
}
|
||||
|
||||
expect(routeEl).toHaveTextContent(new RegExp(`delivered to ${route?.spec.defaults.receiver}`, 'i'));
|
||||
expect(routeEl).toHaveTextContent(new RegExp(`${groupingText}`, 'i'));
|
||||
expect(routeEl).toHaveTextContent(
|
||||
new RegExp(`wait ${route?.spec.defaults.group_wait ?? TIMING_OPTIONS_DEFAULTS.group_wait} to group`, 'i')
|
||||
);
|
||||
expect(routeEl).toHaveTextContent(
|
||||
new RegExp(
|
||||
`wait ${route?.spec.defaults.group_interval ?? TIMING_OPTIONS_DEFAULTS.group_interval} before sending`,
|
||||
'i'
|
||||
)
|
||||
);
|
||||
expect(routeEl).toHaveTextContent(
|
||||
new RegExp(
|
||||
`repeated every ${route?.spec.defaults.repeat_interval ?? TIMING_OPTIONS_DEFAULTS.repeat_interval}`,
|
||||
'i'
|
||||
)
|
||||
);
|
||||
|
||||
if (route?.metadata.annotations?.[K8sAnnotations.Provenance] !== KnownProvenance.None) {
|
||||
expect(routeEl).toHaveTextContent(new RegExp(`Provisioned`, 'i'));
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('Action permissions', () => {
|
||||
describe('Create', () => {
|
||||
it('enable if user has permission', async () => {
|
||||
grantAlertmanagerAbilities([
|
||||
AlertmanagerAction.CreateNotificationPolicy,
|
||||
AlertmanagerAction.ViewNotificationPolicyTree,
|
||||
]);
|
||||
renderNotificationPolicies();
|
||||
await getRoute(ROOT_ROUTE_NAME);
|
||||
expect(await ui.createPolicyButton.find()).toBeInTheDocument();
|
||||
expect(ui.createPolicyButton.query()).toBeEnabled();
|
||||
});
|
||||
it('disable if user does not have permission', async () => {
|
||||
grantAlertmanagerAbilities([AlertmanagerAction.ViewNotificationPolicyTree]);
|
||||
|
||||
renderNotificationPolicies();
|
||||
await getRoute(ROOT_ROUTE_NAME);
|
||||
expect(await ui.createPolicyButton.find()).toBeInTheDocument();
|
||||
expect(ui.createPolicyButton.query()).toBeDisabled();
|
||||
});
|
||||
});
|
||||
describe('View/Edit', () => {
|
||||
it('shows view if user has no edit permission', async () => {
|
||||
grantAlertmanagerAbilities([AlertmanagerAction.ViewNotificationPolicyTree]);
|
||||
|
||||
renderNotificationPolicies();
|
||||
const defaultPolicyEl = await getRoute(ROOT_ROUTE_NAME);
|
||||
const btn = await ui.viewButton.find(defaultPolicyEl);
|
||||
expect(btn).toBeInTheDocument();
|
||||
expect(btn).toBeEnabled();
|
||||
});
|
||||
it('shows edit if user has edit permission', async () => {
|
||||
grantAlertmanagerAbilities([
|
||||
AlertmanagerAction.ViewNotificationPolicyTree,
|
||||
AlertmanagerAction.UpdateNotificationPolicyTree,
|
||||
]);
|
||||
|
||||
renderNotificationPolicies();
|
||||
const defaultPolicyEl = await getRoute(ROOT_ROUTE_NAME);
|
||||
const btn = await ui.editButton.find(defaultPolicyEl);
|
||||
expect(btn).toBeInTheDocument();
|
||||
expect(btn).toBeEnabled();
|
||||
});
|
||||
it('shows view if policy is provisioned', async () => {
|
||||
grantAlertmanagerAbilities([
|
||||
AlertmanagerAction.ViewNotificationPolicyTree,
|
||||
AlertmanagerAction.UpdateNotificationPolicyTree,
|
||||
]);
|
||||
|
||||
renderNotificationPolicies();
|
||||
const defaultPolicyEl = await getRoute('Managed Policy - Empty Provisioned');
|
||||
const btn = await ui.viewButton.find(defaultPolicyEl);
|
||||
expect(btn).toBeInTheDocument();
|
||||
expect(btn).toBeEnabled();
|
||||
});
|
||||
});
|
||||
describe('More > Export', () => {
|
||||
it('enable if user has permission', async () => {
|
||||
grantAlertmanagerAbilities([
|
||||
AlertmanagerAction.ViewNotificationPolicyTree,
|
||||
AlertmanagerAction.ExportNotificationPolicies,
|
||||
]);
|
||||
|
||||
const { user } = renderNotificationPolicies();
|
||||
const defaultPolicyEl = await getRoute(ROOT_ROUTE_NAME);
|
||||
|
||||
await user.click(await ui.moreActionsButton.find(defaultPolicyEl));
|
||||
|
||||
const btn = await ui.exportButton.find();
|
||||
expect(btn).toBeInTheDocument();
|
||||
expect(btn).toBeEnabled();
|
||||
});
|
||||
it('disable if user does not have permission', async () => {
|
||||
grantAlertmanagerAbilities([AlertmanagerAction.ViewNotificationPolicyTree]);
|
||||
|
||||
const { user } = renderNotificationPolicies();
|
||||
const defaultPolicyEl = await getRoute(ROOT_ROUTE_NAME);
|
||||
|
||||
await user.click(await ui.moreActionsButton.find(defaultPolicyEl));
|
||||
|
||||
const btn = await ui.exportButton.find();
|
||||
expect(btn).toBeInTheDocument();
|
||||
expect(btn).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Delete', () => {
|
||||
it('enable if user has permission', async () => {
|
||||
grantAlertmanagerAbilities([
|
||||
AlertmanagerAction.ViewNotificationPolicyTree,
|
||||
AlertmanagerAction.DeleteNotificationPolicy,
|
||||
]);
|
||||
|
||||
const { user } = renderNotificationPolicies();
|
||||
const defaultPolicyEl = await getRoute(ROOT_ROUTE_NAME);
|
||||
|
||||
await user.click(await ui.moreActionsButton.find(defaultPolicyEl));
|
||||
|
||||
const btn = await ui.deleteButton.find();
|
||||
expect(btn).toBeInTheDocument();
|
||||
expect(btn).toBeEnabled();
|
||||
});
|
||||
it('disable if user has no permission', async () => {
|
||||
grantAlertmanagerAbilities([AlertmanagerAction.ViewNotificationPolicyTree]);
|
||||
|
||||
const { user } = renderNotificationPolicies();
|
||||
const defaultPolicyEl = await getRoute(ROOT_ROUTE_NAME);
|
||||
|
||||
await user.click(await ui.moreActionsButton.find(defaultPolicyEl));
|
||||
|
||||
const btn = await ui.deleteButton.find();
|
||||
expect(btn).toBeInTheDocument();
|
||||
expect(btn).toBeDisabled();
|
||||
});
|
||||
it('disable if is provisioned', async () => {
|
||||
grantAlertmanagerAbilities([
|
||||
AlertmanagerAction.ViewNotificationPolicyTree,
|
||||
AlertmanagerAction.DeleteNotificationPolicy,
|
||||
]);
|
||||
|
||||
const { user } = renderNotificationPolicies();
|
||||
const defaultPolicyEl = await getRoute('Managed Policy - Empty Provisioned');
|
||||
|
||||
await user.click(await ui.moreActionsButton.find(defaultPolicyEl));
|
||||
|
||||
const viewButton = await ui.deleteButton.find();
|
||||
expect(viewButton).toBeInTheDocument();
|
||||
expect(viewButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,413 @@
|
|||
import { css } from '@emotion/css';
|
||||
import { Fragment } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Dropdown,
|
||||
EmptyState,
|
||||
LinkButton,
|
||||
LoadingPlaceholder,
|
||||
Menu,
|
||||
Pagination,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
useStyles2,
|
||||
} from '@grafana/ui';
|
||||
import { MetadataRow } from 'app/features/alerting/unified/components/notification-policies/Policy';
|
||||
import {
|
||||
AlertmanagerAction,
|
||||
useAlertmanagerAbilities,
|
||||
useAlertmanagerAbility,
|
||||
} from 'app/features/alerting/unified/hooks/useAbilities';
|
||||
import { AlertmanagerGroup, ROUTES_META_SYMBOL, Receiver, Route } from 'app/plugins/datasource/alertmanager/types';
|
||||
|
||||
import ConditionalWrap from '../../components/ConditionalWrap';
|
||||
import MoreButton from '../../components/MoreButton';
|
||||
import { usePagination } from '../../hooks/usePagination';
|
||||
import { useURLSearchParams } from '../../hooks/useURLSearchParams';
|
||||
import { useAlertmanager } from '../../state/AlertmanagerContext';
|
||||
import { K8sAnnotations, ROOT_ROUTE_NAME } from '../../utils/k8s/constants';
|
||||
import { getAnnotation } from '../../utils/k8s/utils';
|
||||
import { normalizeMatchers } from '../../utils/matchers';
|
||||
import { stringifyErrorLike } from '../../utils/misc';
|
||||
import { ProvisioningBadge } from '../Provisioning';
|
||||
import { Spacer } from '../Spacer';
|
||||
import { useGrafanaContactPoints } from '../contact-points/useContactPoints';
|
||||
|
||||
import { useAlertGroupsModal } from './Modals';
|
||||
import { useCreateRoutingTreeModal, useDeleteRoutingTreeModal } from './components/Modals';
|
||||
import { RoutingTreeFilter } from './components/RoutingTreeFilter';
|
||||
import { TIMING_OPTIONS_DEFAULTS } from './timingOptions';
|
||||
import { useExportRoutingTree } from './useExportRoutingTree';
|
||||
import {
|
||||
isRouteProvisioned,
|
||||
useCreateRoutingTree,
|
||||
useDeleteRoutingTree,
|
||||
useListNotificationPolicyRoutes,
|
||||
useRootRouteSearch,
|
||||
} from './useNotificationPolicyRoute';
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 10;
|
||||
|
||||
export const PoliciesList = () => {
|
||||
const [queryParams] = useURLSearchParams();
|
||||
|
||||
const [createPoliciesSupported, createPoliciesAllowed] = useAlertmanagerAbility(
|
||||
AlertmanagerAction.CreateNotificationPolicy
|
||||
);
|
||||
|
||||
const { currentData: allPolicies, isLoading, error: fetchPoliciesError } = useListNotificationPolicyRoutes();
|
||||
|
||||
const [createTrigger] = useCreateRoutingTree();
|
||||
const [CreateModal, showCreateModal] = useCreateRoutingTreeModal(createTrigger.execute);
|
||||
|
||||
const search = queryParams.get('search');
|
||||
|
||||
const [contactPointsSupported, canSeeContactPoints] = useAlertmanagerAbility(AlertmanagerAction.ViewContactPoint);
|
||||
const shouldFetchContactPoints = contactPointsSupported && canSeeContactPoints;
|
||||
const { contactPoints: receivers } = useGrafanaContactPoints({
|
||||
skip: !shouldFetchContactPoints,
|
||||
fetchStatuses: false,
|
||||
fetchPolicies: false,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingPlaceholder text={t('alerting.policies-list.text-loading', 'Loading....')} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack direction="column">
|
||||
{/* TODO we can add some additional info here with a ToggleTip */}
|
||||
<Stack direction="row" alignItems="end" justifyContent="space-between">
|
||||
<RoutingTreeFilter />
|
||||
|
||||
<Stack direction="row" gap={1}>
|
||||
{createPoliciesSupported && (
|
||||
<Button
|
||||
data-testid="create-policy-button"
|
||||
icon="plus"
|
||||
aria-label={t('alerting.policies-list.create.aria-label', 'add policy')}
|
||||
variant="primary"
|
||||
disabled={!createPoliciesAllowed}
|
||||
onClick={() => showCreateModal()}
|
||||
>
|
||||
<Trans i18nKey="alerting.policies-list.create.text">Create policy</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
{fetchPoliciesError ? (
|
||||
<Alert title={t('alerting.policies-list.fetch.error', 'Failed to fetch policies')}>
|
||||
{stringifyErrorLike(fetchPoliciesError)}
|
||||
</Alert>
|
||||
) : (
|
||||
<RoutingTreeList
|
||||
policies={allPolicies ?? []}
|
||||
search={search}
|
||||
pageSize={DEFAULT_PAGE_SIZE}
|
||||
receivers={receivers}
|
||||
/>
|
||||
)}
|
||||
{CreateModal}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
interface RoutingTreeListProps {
|
||||
policies: Route[];
|
||||
search?: string | null;
|
||||
pageSize: number;
|
||||
receivers?: Receiver[];
|
||||
}
|
||||
|
||||
const RoutingTreeList = ({ policies, search, pageSize = DEFAULT_PAGE_SIZE, receivers }: RoutingTreeListProps) => {
|
||||
const searchResults = useRootRouteSearch(policies, search);
|
||||
const { page, pageItems, numberOfPages, onPageChange } = usePagination(searchResults, 1, pageSize);
|
||||
|
||||
if (pageItems.length === 0) {
|
||||
return (
|
||||
<EmptyState variant="not-found" message={t('alerting.policies-list.empty-state.message', 'No policies found')} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{pageItems.map((policy, index) => {
|
||||
const key = `${policy.name}-${index}`;
|
||||
return <RoutingTree key={key} route={policy} receivers={receivers} />;
|
||||
})}
|
||||
<Pagination currentPage={page} numberOfPages={numberOfPages} onNavigate={onPageChange} hideWhenSinglePage />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface RoutingTreeProps {
|
||||
route: Route;
|
||||
receivers?: Receiver[];
|
||||
}
|
||||
|
||||
export const RoutingTree = ({ route, receivers }: RoutingTreeProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const { selectedAlertmanager } = useAlertmanager();
|
||||
|
||||
const [deleteTrigger] = useDeleteRoutingTree();
|
||||
const [DeleteModal, showDeleteModal] = useDeleteRoutingTreeModal(deleteTrigger.execute);
|
||||
const [alertInstancesModal, showAlertGroupsModal] = useAlertGroupsModal(selectedAlertmanager ?? '');
|
||||
|
||||
const matchingInstancesPreview = { enabled: false }; // Placeholder for matching instances preview logic
|
||||
const numberOfAlertInstances = undefined; // Placeholder for number of alert instances logic
|
||||
const matchingAlertGroups: AlertmanagerGroup[] | undefined = []; // Placeholder for matching alert groups logic
|
||||
const matchers = normalizeMatchers(route);
|
||||
|
||||
return (
|
||||
<div className={styles.routingTreeWrapper} data-testid={`routing-tree_${route.name ?? 'default'}`}>
|
||||
<Stack direction="column" gap={0}>
|
||||
<RoutingTreeHeader
|
||||
route={route}
|
||||
onDelete={(routeToDelete) =>
|
||||
showDeleteModal({
|
||||
name: routeToDelete[ROUTES_META_SYMBOL]?.name ?? '',
|
||||
resourceVersion: routeToDelete[ROUTES_META_SYMBOL]?.resourceVersion,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<div className={styles.routingTreeMetadataWrapper}>
|
||||
<Stack direction="column" gap={0.5}>
|
||||
<MetadataRow
|
||||
matchingInstancesPreview={matchingInstancesPreview}
|
||||
numberOfAlertInstances={numberOfAlertInstances}
|
||||
contactPoint={route.receiver ?? undefined}
|
||||
groupBy={route.group_by ?? []}
|
||||
muteTimings={route.mute_time_intervals ?? []}
|
||||
activeTimings={route.active_time_intervals ?? []}
|
||||
timingOptions={{
|
||||
group_wait: route.group_wait ?? TIMING_OPTIONS_DEFAULTS.group_wait,
|
||||
group_interval: route.group_interval ?? TIMING_OPTIONS_DEFAULTS.group_interval,
|
||||
repeat_interval: route.repeat_interval ?? TIMING_OPTIONS_DEFAULTS.repeat_interval,
|
||||
}}
|
||||
alertManagerSourceName={selectedAlertmanager ?? ''}
|
||||
receivers={receivers ?? []}
|
||||
matchingAlertGroups={matchingAlertGroups}
|
||||
matchers={matchers}
|
||||
isDefaultPolicy={true}
|
||||
onShowAlertInstances={showAlertGroupsModal}
|
||||
/>
|
||||
</Stack>
|
||||
</div>
|
||||
</Stack>
|
||||
{DeleteModal}
|
||||
{alertInstancesModal}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface RoutingTreeHeaderProps {
|
||||
route: Route;
|
||||
onDelete: (route: Route) => void;
|
||||
}
|
||||
|
||||
export const RoutingTreeHeader = ({ route, onDelete }: RoutingTreeHeaderProps) => {
|
||||
const provisioned = isRouteProvisioned(route);
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const [
|
||||
[updatePoliciesSupported, updatePoliciesAllowed],
|
||||
[deletePoliciesSupported, deletePoliciesAllowed],
|
||||
[exportPoliciesSupported, exportPoliciesAllowed],
|
||||
] = useAlertmanagerAbilities([
|
||||
AlertmanagerAction.UpdateNotificationPolicyTree,
|
||||
AlertmanagerAction.DeleteNotificationPolicy,
|
||||
AlertmanagerAction.ExportNotificationPolicies,
|
||||
]);
|
||||
|
||||
const canEdit = updatePoliciesSupported && updatePoliciesAllowed && !provisioned;
|
||||
|
||||
const [ExportDrawer, showExportDrawer] = useExportRoutingTree();
|
||||
|
||||
const menuActions: JSX.Element[] = [];
|
||||
if (exportPoliciesSupported) {
|
||||
menuActions.push(
|
||||
<Fragment key="export-contact-point">
|
||||
<Menu.Item
|
||||
icon="download-alt"
|
||||
label={t('alerting.policies-list.policy-header.export.label', 'Export')}
|
||||
ariaLabel={t('alerting.policies-list.policy-header.export.aria-label', 'export')}
|
||||
disabled={!exportPoliciesAllowed}
|
||||
data-testid="export"
|
||||
onClick={() => showExportDrawer(route.name ?? '')}
|
||||
/>
|
||||
<Menu.Divider />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
if (deletePoliciesSupported) {
|
||||
const canBeDeleted = deletePoliciesAllowed && !provisioned;
|
||||
const isDefaultPolicy = route.name === ROOT_ROUTE_NAME;
|
||||
|
||||
const cannotDeleteNoPermissions = isDefaultPolicy
|
||||
? t(
|
||||
'alerting.policies-list.reset-reasons.no-permissions',
|
||||
'You do not have the required permission to reset this routing tree'
|
||||
)
|
||||
: t(
|
||||
'alerting.policies-list.delete-reasons.no-permissions',
|
||||
'You do not have the required permission to delete this routing tree'
|
||||
);
|
||||
const cannotDeleteProvisioned = isDefaultPolicy
|
||||
? t(
|
||||
'alerting.policies-list.reset-reasons.provisioned',
|
||||
'Routing tree is provisioned and cannot be reset via the UI'
|
||||
)
|
||||
: t(
|
||||
'alerting.policies-list.delete-reasons.provisioned',
|
||||
'Routing tree is provisioned and cannot be deleted via the UI'
|
||||
);
|
||||
const cannotDeleteText = isDefaultPolicy
|
||||
? t('alerting.policies-list.reset-text', 'Routing tree cannot be reset for the following reasons:')
|
||||
: t('alerting.policies-list.delete-text', 'Routing tree cannot be deleted for the following reasons:');
|
||||
|
||||
const reasonsDeleteIsDisabled = [
|
||||
!deletePoliciesAllowed ? cannotDeleteNoPermissions : '',
|
||||
provisioned ? cannotDeleteProvisioned : '',
|
||||
].filter(Boolean);
|
||||
|
||||
const deleteTooltipContent = (
|
||||
<>
|
||||
{cannotDeleteText}
|
||||
<br />
|
||||
{reasonsDeleteIsDisabled.map((reason) => (
|
||||
<li key={reason}>{reason}</li>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
menuActions.push(
|
||||
<ConditionalWrap
|
||||
key="delete-routing-tree"
|
||||
shouldWrap={!canBeDeleted}
|
||||
wrap={(children) => (
|
||||
<Tooltip content={deleteTooltipContent} placement="top">
|
||||
<span>{children}</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
>
|
||||
<Menu.Item
|
||||
label={
|
||||
route.name === ROOT_ROUTE_NAME
|
||||
? t('alerting.policies-list.policy-header.delete.reset-label', 'Reset')
|
||||
: t('alerting.policies-list.policy-header.delete.delete-label', 'Delete')
|
||||
}
|
||||
ariaLabel={t('alerting.policies-list.policy-header.delete.aria-label', 'delete')}
|
||||
icon="trash-alt"
|
||||
destructive
|
||||
disabled={!canBeDeleted}
|
||||
onClick={() => onDelete(route)}
|
||||
/>
|
||||
</ConditionalWrap>
|
||||
);
|
||||
}
|
||||
|
||||
const routeName = route.name === ROOT_ROUTE_NAME || !route.name ? 'Default Policy' : route.name;
|
||||
const numberOfPolicies = countPolicies(route);
|
||||
|
||||
return (
|
||||
<div className={styles.headerWrapper}>
|
||||
<Stack direction="row" alignItems="center" gap={1}>
|
||||
<Stack alignItems="center" gap={1} minWidth={0}>
|
||||
<Text element="h2" variant="body" weight="medium" truncate>
|
||||
{routeName}
|
||||
</Text>
|
||||
</Stack>
|
||||
{numberOfPolicies > 0 && <>{`Contains ${numberOfPolicies} polic${numberOfPolicies > 1 ? 'ies' : 'y'}`}</>}
|
||||
{provisioned && (
|
||||
<ProvisioningBadge
|
||||
tooltip
|
||||
provenance={getAnnotation(route[ROUTES_META_SYMBOL] ?? {}, K8sAnnotations.Provenance)}
|
||||
/>
|
||||
)}
|
||||
<Spacer />
|
||||
<LinkButton
|
||||
tooltipPlacement="top"
|
||||
tooltip={
|
||||
provisioned
|
||||
? t(
|
||||
'alerting.policies-list.policy-header.view.provisioned-tooltip',
|
||||
'Provisioned routing trees cannot be edited in the UI'
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={canEdit ? 'pen' : 'eye'}
|
||||
type="button"
|
||||
data-testid={`${canEdit ? 'edit' : 'view'}-action`}
|
||||
href={`/alerting/routes/policy/${encodeURIComponent(route.name ?? '')}/edit`}
|
||||
>
|
||||
{canEdit
|
||||
? t('alerting.policies-list.policy-header.edit.text', 'Edit')
|
||||
: t('alerting.policies-list.policy-header.view.text', 'View')}
|
||||
</LinkButton>
|
||||
{menuActions.length > 0 && (
|
||||
<Dropdown overlay={<Menu>{menuActions}</Menu>}>
|
||||
<MoreButton
|
||||
data-testid="more-actions"
|
||||
aria-label={t(
|
||||
'alerting.policies-list.policy-header.more-actions.aria-label',
|
||||
'More actions for routing tree "{{name}}"',
|
||||
{
|
||||
name: route.name ?? '',
|
||||
}
|
||||
)}
|
||||
/>
|
||||
</Dropdown>
|
||||
)}
|
||||
</Stack>
|
||||
{ExportDrawer}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface HasRoutes {
|
||||
routes?: HasRoutes[];
|
||||
}
|
||||
|
||||
export function countPolicies(route: HasRoutes): number {
|
||||
let count = 0;
|
||||
if (route.routes) {
|
||||
count += route.routes.length;
|
||||
route.routes.forEach((subRoute) => {
|
||||
count += countPolicies(subRoute);
|
||||
});
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
routingTreeWrapper: css({
|
||||
borderRadius: theme.shape.radius.default,
|
||||
border: `solid 1px ${theme.colors.border.weak}`,
|
||||
borderBottom: 'none',
|
||||
}),
|
||||
headerWrapper: css({
|
||||
background: `${theme.colors.background.secondary}`,
|
||||
padding: `${theme.spacing(1)} ${theme.spacing(1.5)}`,
|
||||
|
||||
borderBottom: `solid 1px ${theme.colors.border.weak}`,
|
||||
borderTopLeftRadius: `${theme.shape.radius.default}`,
|
||||
borderTopRightRadius: `${theme.shape.radius.default}`,
|
||||
}),
|
||||
routingTreeMetadataWrapper: css({
|
||||
position: 'relative',
|
||||
|
||||
background: `${theme.colors.background.primary}`,
|
||||
padding: `${theme.spacing(1)} ${theme.spacing(1.5)}`,
|
||||
|
||||
borderBottom: `solid 1px ${theme.colors.border.weak}`,
|
||||
}),
|
||||
});
|
||||
|
|
@ -33,7 +33,11 @@ import {
|
|||
useUpdateExistingNotificationPolicy,
|
||||
} from './useNotificationPolicyRoute';
|
||||
|
||||
export const NotificationPoliciesList = () => {
|
||||
interface PoliciesTreeProps {
|
||||
routeName?: string;
|
||||
}
|
||||
|
||||
export const PoliciesTree = ({ routeName }: PoliciesTreeProps) => {
|
||||
const appNotification = useAppNotification();
|
||||
const [contactPointsSupported, canSeeContactPoints] = useAlertmanagerAbility(AlertmanagerAction.ViewContactPoint);
|
||||
|
||||
|
|
@ -53,16 +57,11 @@ export const NotificationPoliciesList = () => {
|
|||
);
|
||||
|
||||
const {
|
||||
currentData,
|
||||
currentData: defaultPolicy,
|
||||
isLoading,
|
||||
error: fetchPoliciesError,
|
||||
refetch: refetchNotificationPolicyRoute,
|
||||
} = useNotificationPolicyRoute({ alertmanager: selectedAlertmanager ?? '' });
|
||||
|
||||
// We make the assumption that the first policy is the default one
|
||||
// At the time of writing, this will be always the case for the AM config response, and the K8S API
|
||||
// TODO in the future: Generalise the component to support any number of "root" policies
|
||||
const [defaultPolicy] = currentData ?? [];
|
||||
} = useNotificationPolicyRoute({ alertmanager: selectedAlertmanager ?? '' }, routeName);
|
||||
|
||||
// deleting policies
|
||||
const [deleteNotificationPolicy, deleteNotificationPolicyState] = useDeleteNotificationPolicy({
|
||||
|
|
@ -141,7 +140,7 @@ export const NotificationPoliciesList = () => {
|
|||
}
|
||||
|
||||
async function handleDelete(route: RouteWithID) {
|
||||
await deleteNotificationPolicy.execute(route.id);
|
||||
await deleteNotificationPolicy.execute(route);
|
||||
handleActionResult({ error: deleteNotificationPolicyState.error });
|
||||
}
|
||||
|
||||
|
|
@ -152,7 +151,7 @@ export const NotificationPoliciesList = () => {
|
|||
) {
|
||||
await addNotificationPolicy.execute({
|
||||
partialRoute,
|
||||
referenceRouteIdentifier: referenceRoute.id,
|
||||
referenceRoute: referenceRoute,
|
||||
insertPosition,
|
||||
});
|
||||
handleActionResult({ error: addNotificationPolicyState.error });
|
||||
|
|
@ -36,6 +36,7 @@ import {
|
|||
|
||||
import { AlertmanagerAction, useAlertmanagerAbilities, useAlertmanagerAbility } from '../../hooks/useAbilities';
|
||||
import { getAmMatcherFormatter } from '../../utils/alertmanager';
|
||||
import { ROOT_ROUTE_NAME } from '../../utils/k8s/constants';
|
||||
import { isProvisionedResource } from '../../utils/k8s/utils';
|
||||
import { MatcherFormatter, normalizeMatchers } from '../../utils/matchers';
|
||||
import { createContactPointLink, createContactPointSearchLink, createMuteTimingLink } from '../../utils/misc';
|
||||
|
|
@ -49,7 +50,7 @@ import { Spacer } from '../Spacer';
|
|||
import { GrafanaPoliciesExporter } from '../export/GrafanaPoliciesExporter';
|
||||
|
||||
import { Matchers } from './Matchers';
|
||||
import { RoutesMatchingFilters } from './NotificationPoliciesList';
|
||||
import { RoutesMatchingFilters } from './PoliciesTree';
|
||||
import { TimingOptions } from './timingOptions';
|
||||
|
||||
const POLICIES_PER_PAGE = 20;
|
||||
|
|
@ -248,7 +249,11 @@ const Policy = (props: PolicyComponentProps) => {
|
|||
) : null}
|
||||
{isImmutablePolicy && (
|
||||
<div className={styles.noShrink}>
|
||||
{isAutogeneratedPolicyRoot ? <AutogeneratedRootIndicator /> : <DefaultPolicyIndicator />}
|
||||
{isAutogeneratedPolicyRoot ? (
|
||||
<AutogeneratedRootIndicator />
|
||||
) : (
|
||||
<DefaultPolicyIndicator route={currentRoute} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isImmutablePolicy && (
|
||||
|
|
@ -405,7 +410,7 @@ const Policy = (props: PolicyComponentProps) => {
|
|||
</>
|
||||
)}
|
||||
</div>
|
||||
{showExportDrawer && <GrafanaPoliciesExporter onClose={toggleShowExportDrawer} />}
|
||||
{showExportDrawer && <GrafanaPoliciesExporter routeName={currentRoute.name} onClose={toggleShowExportDrawer} />}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
|
@ -431,7 +436,7 @@ interface MetadataRowProps {
|
|||
) => void;
|
||||
}
|
||||
|
||||
function MetadataRow({
|
||||
export function MetadataRow({
|
||||
numberOfAlertInstances,
|
||||
isDefaultPolicy,
|
||||
timingOptions,
|
||||
|
|
@ -708,21 +713,36 @@ const ErrorsGutterIndicator: FC<{ errors: React.ReactNode[] }> = ({ errors }) =>
|
|||
);
|
||||
};
|
||||
|
||||
export function DefaultPolicyIndicator() {
|
||||
type DefaultPolicyIndicatorProps = { route?: RouteWithID };
|
||||
|
||||
export const DefaultPolicyIndicator: FC<DefaultPolicyIndicatorProps> = ({ route = { name: '' } }) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
return (
|
||||
<>
|
||||
<Text element="h2" variant="body" weight="medium">
|
||||
<Trans i18nKey="alerting.policies.default-policy.title">Default policy</Trans>
|
||||
{route.name === ROOT_ROUTE_NAME || !route.name ? (
|
||||
<Trans i18nKey="alerting.policies.default-policy.title">Default policy</Trans>
|
||||
) : (
|
||||
t('alerting.policies.root-policy.title', `Default policy for '{{routeName}}'`, {
|
||||
routeName: route.name,
|
||||
})
|
||||
)}
|
||||
</Text>
|
||||
<span className={styles.metadata}>
|
||||
<Trans i18nKey="alerting.policies.default-policy.description">
|
||||
All alert instances will be handled by the default policy if no other matching policies are found.
|
||||
</Trans>
|
||||
{route.name === ROOT_ROUTE_NAME || !route.name ? (
|
||||
<Trans i18nKey="alerting.policies.default-policy.description">
|
||||
All alert instances will be handled by the default policy if no other matching policies are found.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans i18nKey="alerting.policies.root-policy.description">
|
||||
All alert instances associated with this Route will be handled by this default policy if no other matching
|
||||
policies are found.
|
||||
</Trans>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
function AutogeneratedRootIndicator() {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
import { useParams } from 'react-router-dom-v5-compat';
|
||||
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { Alert } from '@grafana/ui';
|
||||
|
||||
import { ROOT_ROUTE_NAME } from '../../utils/k8s/constants';
|
||||
import { withPageErrorBoundary } from '../../withPageErrorBoundary';
|
||||
import { AlertmanagerPageWrapper } from '../AlertingPageWrapper';
|
||||
|
||||
import { PoliciesTree } from './PoliciesTree';
|
||||
|
||||
const PoliciesTreeWrapper = () => {
|
||||
const { name = '' } = useParams();
|
||||
|
||||
const routeName = decodeURIComponent(name);
|
||||
|
||||
if (!routeName) {
|
||||
return (
|
||||
<Alert
|
||||
severity="error"
|
||||
title={t('alerting.policies-tree-wrapper.title-routing-tree-not-found', 'Routing tree not found')}
|
||||
>
|
||||
<Trans i18nKey="alerting.policies-tree-wrapper.sorry-routing-exist">
|
||||
Sorry, this routing tree does not seem to exist.
|
||||
</Trans>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return <PoliciesTree routeName={routeName} />;
|
||||
};
|
||||
|
||||
function PolicyPage() {
|
||||
const { name = '' } = useParams();
|
||||
const routeName = name === ROOT_ROUTE_NAME ? 'Default Policy' : decodeURIComponent(name);
|
||||
|
||||
const pageNav = {
|
||||
text: routeName,
|
||||
};
|
||||
return (
|
||||
<AlertmanagerPageWrapper navId="am-routes" pageNav={pageNav} accessType="notification">
|
||||
<PoliciesTreeWrapper />
|
||||
</AlertmanagerPageWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default withPageErrorBoundary(PolicyPage);
|
||||
|
|
@ -0,0 +1,212 @@
|
|||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { Button, Modal, ModalProps } from '@grafana/ui';
|
||||
|
||||
import { RouteWithID } from '../../../../../../plugins/datasource/alertmanager/types';
|
||||
import { FormAmRoute } from '../../../types/amroutes';
|
||||
import { defaultGroupBy } from '../../../utils/amroutes';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource';
|
||||
import { stringifyErrorLike } from '../../../utils/misc';
|
||||
import { AmRootRouteForm } from '../EditDefaultPolicyForm';
|
||||
import { UpdatingModal } from '../Modals';
|
||||
|
||||
/**
|
||||
* This hook controls the delete modal for routing trees, showing loading and error states when appropriate
|
||||
*/
|
||||
export const useDeleteRoutingTreeModal = (
|
||||
handleDelete: ({ name, resourceVersion }: { name: string; resourceVersion?: string }) => Promise<unknown>
|
||||
) => {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [routingTree, setRoutingTree] = useState<{ name: string; resourceVersion?: string }>();
|
||||
const [error, setError] = useState<unknown | undefined>();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
setRoutingTree(undefined);
|
||||
setShowModal(false);
|
||||
setError(undefined);
|
||||
}, [isLoading]);
|
||||
|
||||
const handleShow = useCallback(({ name, resourceVersion }: { name: string; resourceVersion?: string }) => {
|
||||
setRoutingTree({ name, resourceVersion });
|
||||
setShowModal(true);
|
||||
setError(undefined);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (routingTree) {
|
||||
setIsLoading(true);
|
||||
handleDelete(routingTree)
|
||||
.then(() => setShowModal(false))
|
||||
.catch(setError)
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
}, [handleDelete, routingTree]);
|
||||
|
||||
const modalElement = useMemo(() => {
|
||||
if (error) {
|
||||
return <ErrorModal isOpen={showModal} onDismiss={handleDismiss} error={error} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
onDismiss={handleDismiss}
|
||||
closeOnBackdropClick={!isLoading}
|
||||
closeOnEscape={!isLoading}
|
||||
title={t(
|
||||
'alerting.use-delete-routing-tree-modal.modal-element.title-delete-routing-tree',
|
||||
'Delete routing tree'
|
||||
)}
|
||||
>
|
||||
<p>
|
||||
<Trans i18nKey="alerting.use-delete-routing-tree-modal.modal-element.deleting-routing-permanently-remove">
|
||||
Deleting this routing tree will permanently remove it.
|
||||
</Trans>
|
||||
</p>
|
||||
<p>
|
||||
<Trans i18nKey="alerting.use-delete-routing-tree-modal.modal-element.delete-routing">
|
||||
Are you sure you want to delete this routing tree?
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<Modal.ButtonRow>
|
||||
<Button type="button" variant="destructive" onClick={handleSubmit} disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<Trans i18nKey="alerting.use-delete-routing-tree-modal.modal-element.deleting">Deleting...</Trans>
|
||||
) : (
|
||||
<Trans i18nKey="alerting.use-delete-routing-tree-modal.modal-element.delete-yes">
|
||||
Yes, delete routing tree
|
||||
</Trans>
|
||||
)}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={handleDismiss} disabled={isLoading}>
|
||||
<Trans i18nKey="alerting.common.cancel">Cancel</Trans>
|
||||
</Button>
|
||||
</Modal.ButtonRow>
|
||||
</Modal>
|
||||
);
|
||||
}, [error, handleDismiss, handleSubmit, isLoading, showModal]);
|
||||
|
||||
return [modalElement, handleShow, handleDismiss] as const;
|
||||
};
|
||||
|
||||
const emptyRouteWithID = {
|
||||
id: '',
|
||||
name: '',
|
||||
group_by: defaultGroupBy,
|
||||
};
|
||||
|
||||
/**
|
||||
* This hook controls the create modal for routing trees, showing loading and error states when appropriate
|
||||
*/
|
||||
export const useCreateRoutingTreeModal = (handleCreate: (route: Partial<FormAmRoute>) => Promise<unknown>) => {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [route, setRoute] = useState<RouteWithID>(emptyRouteWithID);
|
||||
const [error, setError] = useState<unknown | undefined>();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
setShowModal(false);
|
||||
setError(undefined);
|
||||
setRoute(emptyRouteWithID);
|
||||
}, [isLoading]);
|
||||
|
||||
const handleShow = useCallback(() => {
|
||||
setShowModal(true);
|
||||
setError(undefined);
|
||||
setRoute(emptyRouteWithID);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(newRoute: Partial<FormAmRoute>) => {
|
||||
if (newRoute) {
|
||||
setIsLoading(true);
|
||||
handleCreate(newRoute)
|
||||
.then(() => setShowModal(false))
|
||||
.catch(setError)
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
},
|
||||
[handleCreate]
|
||||
);
|
||||
|
||||
const modalElement = useMemo(() => {
|
||||
if (error) {
|
||||
return <ErrorModal isOpen={showModal} onDismiss={handleDismiss} error={error} />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <UpdatingModal isOpen={showModal} onDismiss={handleDismiss} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
onDismiss={handleDismiss}
|
||||
closeOnBackdropClick={true}
|
||||
closeOnEscape={true}
|
||||
title={t(
|
||||
'alerting.use-create-routing-tree-modal.modal-element.title-create-routing-tree',
|
||||
'Create routing tree'
|
||||
)}
|
||||
>
|
||||
<AmRootRouteForm
|
||||
route={route}
|
||||
showNameField={true}
|
||||
onSubmit={handleSubmit}
|
||||
alertManagerSourceName={GRAFANA_RULES_SOURCE_NAME}
|
||||
actionButtons={
|
||||
<Modal.ButtonRow>
|
||||
<Button type="button" variant="secondary" onClick={handleDismiss} fill="outline">
|
||||
<Trans i18nKey="alerting.common.cancel">Cancel</Trans>
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
<Trans i18nKey="alerting.use-create-routing-tree-modal.modal-element.add-routing-tree">
|
||||
Add routing tree
|
||||
</Trans>
|
||||
</Button>
|
||||
</Modal.ButtonRow>
|
||||
}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}, [route, error, handleSubmit, handleDismiss, isLoading, showModal]);
|
||||
|
||||
return [modalElement, handleShow, handleDismiss] as const;
|
||||
};
|
||||
|
||||
interface ErrorModalProps extends Pick<ModalProps, 'isOpen' | 'onDismiss'> {
|
||||
error: unknown;
|
||||
}
|
||||
const ErrorModal = ({ isOpen, onDismiss, error }: ErrorModalProps) => {
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onDismiss={onDismiss}
|
||||
closeOnBackdropClick={true}
|
||||
closeOnEscape={true}
|
||||
title={t('alerting.error-modal.title-something-went-wrong', 'Something went wrong')}
|
||||
>
|
||||
<p>
|
||||
<Trans i18nKey="alerting.error-modal.failed-to-update-your-configuration">
|
||||
Failed to update your configuration:
|
||||
</Trans>
|
||||
</p>
|
||||
<pre>
|
||||
<code>{stringifyErrorLike(error)}</code>
|
||||
</pre>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import { css } from '@emotion/css';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useDebounce } from 'react-use';
|
||||
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { Button, Field, Icon, Input, Stack, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { useURLSearchParams } from '../../../hooks/useURLSearchParams';
|
||||
|
||||
const RoutingTreeFilter = () => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const [searchParams, setSearchParams] = useURLSearchParams();
|
||||
|
||||
const defaultValue = searchParams.get('search') ?? '';
|
||||
const [searchValue, setSearchValue] = useState(defaultValue);
|
||||
|
||||
const [_, cancel] = useDebounce(
|
||||
() => {
|
||||
setSearchParams({ search: searchValue }, true);
|
||||
},
|
||||
300,
|
||||
[setSearchParams, searchValue]
|
||||
);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
cancel();
|
||||
setSearchValue('');
|
||||
setSearchParams({ search: '' }, true);
|
||||
}, [cancel, setSearchParams]);
|
||||
|
||||
const hasInput = Boolean(defaultValue);
|
||||
|
||||
return (
|
||||
<Stack direction="row" alignItems="end" gap={0.5}>
|
||||
<Field
|
||||
noMargin
|
||||
className={styles.noBottom}
|
||||
label={t('alerting.routing-tree-filter.label-search-by-name-or-receiver', 'Search by name or receiver')}
|
||||
>
|
||||
<Input
|
||||
aria-label={t('alerting.routing-tree-filter.aria-label-search-routing-trees', 'search routing trees')}
|
||||
placeholder={t('alerting.routing-tree-filter.placeholder-search', 'Search')}
|
||||
width={46}
|
||||
prefix={<Icon name="search" />}
|
||||
onChange={(event) => {
|
||||
setSearchValue(event.currentTarget.value);
|
||||
}}
|
||||
value={searchValue}
|
||||
/>
|
||||
</Field>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon="times"
|
||||
onClick={() => clear()}
|
||||
disabled={!hasInput}
|
||||
aria-label={t('alerting.routing-tree-filter.aria-label-clear', 'clear')}
|
||||
>
|
||||
<Trans i18nKey="alerting.routing-tree-filter.clear">Clear</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = () => ({
|
||||
noBottom: css({
|
||||
marginBottom: 0,
|
||||
}),
|
||||
});
|
||||
|
||||
export { RoutingTreeFilter };
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useToggle } from 'react-use';
|
||||
|
||||
import { GrafanaPoliciesExporter } from '../export/GrafanaPoliciesExporter';
|
||||
|
||||
type ExportProps = [JSX.Element | null, (routeName: string) => void];
|
||||
|
||||
export const useExportRoutingTree = (): ExportProps => {
|
||||
const [routeName, setRouteName] = useState<string | null>(null);
|
||||
const [isExportDrawerOpen, toggleShowExportDrawer] = useToggle(false);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setRouteName(null);
|
||||
toggleShowExportDrawer(false);
|
||||
}, [toggleShowExportDrawer]);
|
||||
|
||||
const handleOpen = (routeName: string) => {
|
||||
setRouteName(routeName);
|
||||
toggleShowExportDrawer(true);
|
||||
};
|
||||
|
||||
const drawer = useMemo(() => {
|
||||
if (!routeName || !isExportDrawerOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <GrafanaPoliciesExporter routeName={routeName} onClose={handleClose} />;
|
||||
}, [isExportDrawerOpen, handleClose, routeName]);
|
||||
|
||||
return [drawer, handleOpen];
|
||||
};
|
||||
|
|
@ -30,6 +30,7 @@ test('k8sSubRouteToRoute', () => {
|
|||
};
|
||||
|
||||
const expected: Route = {
|
||||
name: 'test-name',
|
||||
continue: false,
|
||||
group_by: ['label1'],
|
||||
group_interval: '5m',
|
||||
|
|
@ -41,6 +42,7 @@ test('k8sSubRouteToRoute', () => {
|
|||
repeat_interval: '4h',
|
||||
routes: [
|
||||
{
|
||||
name: 'test-name',
|
||||
receiver: 'receiver2',
|
||||
matchers: undefined,
|
||||
object_matchers: [['label2', MatcherOperator.notEqual, 'value2']],
|
||||
|
|
@ -49,7 +51,7 @@ test('k8sSubRouteToRoute', () => {
|
|||
],
|
||||
};
|
||||
|
||||
expect(k8sSubRouteToRoute(input)).toStrictEqual(expected);
|
||||
expect(k8sSubRouteToRoute(input, 'test-name')).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
test('routeToK8sSubRoute', () => {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { pick } from 'lodash';
|
||||
import uFuzzy from '@leeoniya/ufuzzy';
|
||||
import { pick, uniq } from 'lodash';
|
||||
import memoize from 'micro-memoize';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { INHERITABLE_KEYS, type InheritableProperties } from '@grafana/alerting/internal';
|
||||
import { BaseAlertmanagerArgs, Skippable } from 'app/features/alerting/unified/types/hooks';
|
||||
import { MatcherOperator, ROUTES_META_SYMBOL, Route } from 'app/plugins/datasource/alertmanager/types';
|
||||
import { MatcherOperator, ROUTES_META_SYMBOL, Route, RouteWithID } from 'app/plugins/datasource/alertmanager/types';
|
||||
|
||||
import { getAPINamespace } from '../../../../../api/utils';
|
||||
import { alertmanagerApi } from '../../api/alertmanagerApi';
|
||||
|
|
@ -38,28 +40,37 @@ export function isRouteProvisioned(route: Route): boolean {
|
|||
return isProvisionedResource(provenance);
|
||||
}
|
||||
|
||||
const k8sRoutesToRoutesMemoized = memoize(k8sRoutesToRoutes, { maxSize: 1 });
|
||||
|
||||
const {
|
||||
useCreateNamespacedRoutingTreeMutation,
|
||||
useDeleteNamespacedRoutingTreeMutation,
|
||||
useListNamespacedRoutingTreeQuery,
|
||||
useReplaceNamespacedRoutingTreeMutation,
|
||||
useLazyListNamespacedRoutingTreeQuery,
|
||||
useLazyReadNamespacedRoutingTreeQuery,
|
||||
useReadNamespacedRoutingTreeQuery,
|
||||
} = routingTreeApi;
|
||||
|
||||
const { useGetAlertmanagerConfigurationQuery } = alertmanagerApi;
|
||||
|
||||
export const useNotificationPolicyRoute = ({ alertmanager }: BaseAlertmanagerArgs, { skip }: Skippable = {}) => {
|
||||
const memoK8sRouteToRoute = memoize(k8sRouteToRoute);
|
||||
|
||||
export const useNotificationPolicyRoute = (
|
||||
{ alertmanager }: BaseAlertmanagerArgs,
|
||||
routeName: string = ROOT_ROUTE_NAME,
|
||||
{ skip }: Skippable = {}
|
||||
) => {
|
||||
const k8sApiSupported = shouldUseK8sApi(alertmanager);
|
||||
|
||||
const k8sRouteQuery = useListNamespacedRoutingTreeQuery(
|
||||
{ namespace: getAPINamespace() },
|
||||
const k8sRouteQuery = useReadNamespacedRoutingTreeQuery(
|
||||
{ namespace: getAPINamespace(), name: routeName },
|
||||
{
|
||||
skip: skip || !k8sApiSupported,
|
||||
selectFromResult: (result) => {
|
||||
const { data, currentData, ...rest } = result;
|
||||
|
||||
return {
|
||||
...result,
|
||||
currentData: result.currentData ? k8sRoutesToRoutesMemoized(result.currentData.items) : undefined,
|
||||
data: result.data ? k8sRoutesToRoutesMemoized(result.data.items) : undefined,
|
||||
...rest,
|
||||
data: data ? memoK8sRouteToRoute(data) : data,
|
||||
currentData: currentData ? memoK8sRouteToRoute(currentData) : currentData,
|
||||
};
|
||||
},
|
||||
}
|
||||
|
|
@ -71,10 +82,10 @@ export const useNotificationPolicyRoute = ({ alertmanager }: BaseAlertmanagerArg
|
|||
return {
|
||||
...result,
|
||||
currentData: result.currentData?.alertmanager_config?.route
|
||||
? [parseAmConfigRoute(result.currentData.alertmanager_config.route)]
|
||||
? parseAmConfigRoute(result.currentData.alertmanager_config.route)
|
||||
: undefined,
|
||||
data: result.data?.alertmanager_config?.route
|
||||
? [parseAmConfigRoute(result.data.alertmanager_config.route)]
|
||||
? parseAmConfigRoute(result.data.alertmanager_config.route)
|
||||
: undefined,
|
||||
};
|
||||
},
|
||||
|
|
@ -83,6 +94,24 @@ export const useNotificationPolicyRoute = ({ alertmanager }: BaseAlertmanagerArg
|
|||
return k8sApiSupported ? k8sRouteQuery : amConfigQuery;
|
||||
};
|
||||
|
||||
export const useListNotificationPolicyRoutes = ({ skip }: Skippable = {}) => {
|
||||
return useListNamespacedRoutingTreeQuery(
|
||||
{ namespace: getAPINamespace() },
|
||||
{
|
||||
skip: skip,
|
||||
selectFromResult: (result) => {
|
||||
const { data, currentData, ...rest } = result;
|
||||
|
||||
return {
|
||||
...rest,
|
||||
data: data ? data.items.map(memoK8sRouteToRoute) : data,
|
||||
currentData: currentData ? currentData.items.map(memoK8sRouteToRoute) : currentData,
|
||||
};
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const parseAmConfigRoute = memoize((route: Route): Route => {
|
||||
return {
|
||||
...route,
|
||||
|
|
@ -96,25 +125,26 @@ export function useUpdateExistingNotificationPolicy({ alertmanager }: BaseAlertm
|
|||
const k8sApiSupported = shouldUseK8sApi(alertmanager);
|
||||
const [updatedNamespacedRoute] = useReplaceNamespacedRoutingTreeMutation();
|
||||
const [produceNewAlertmanagerConfiguration] = useProduceNewAlertmanagerConfiguration();
|
||||
const [listNamespacedRoutingTree] = useLazyListNamespacedRoutingTreeQuery();
|
||||
const [readNamespacedRoutingTree] = useLazyReadNamespacedRoutingTreeQuery();
|
||||
|
||||
const updateUsingK8sApi = useAsync(async (update: Partial<FormAmRoute>) => {
|
||||
const namespace = getAPINamespace();
|
||||
const result = await listNamespacedRoutingTree({ namespace });
|
||||
const name = update.name ?? ROOT_ROUTE_NAME;
|
||||
const result = await readNamespacedRoutingTree({ namespace, name: name });
|
||||
|
||||
const [rootTree] = result.data ? k8sRoutesToRoutesMemoized(result.data.items) : [];
|
||||
const rootTree = result.data;
|
||||
if (!rootTree) {
|
||||
throw new Error(`no root route found for namespace ${namespace}`);
|
||||
throw new Error(`no root route found for namespace ${namespace} and name ${name}`);
|
||||
}
|
||||
|
||||
const rootRouteWithIdentifiers = addUniqueIdentifierToRoute(rootTree);
|
||||
const rootRouteWithIdentifiers = addUniqueIdentifierToRoute(k8sRouteToRoute(rootTree));
|
||||
const newRouteTree = mergePartialAmRouteWithRouteTree(alertmanager, update, rootRouteWithIdentifiers);
|
||||
|
||||
// Create the K8s route object
|
||||
const routeObject = createKubernetesRoutingTreeSpec(newRouteTree);
|
||||
|
||||
return updatedNamespacedRoute({
|
||||
name: ROOT_ROUTE_NAME,
|
||||
name: name,
|
||||
namespace,
|
||||
comGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree: cleanKubernetesRouteIDs(routeObject),
|
||||
}).unwrap();
|
||||
|
|
@ -131,33 +161,34 @@ export function useUpdateExistingNotificationPolicy({ alertmanager }: BaseAlertm
|
|||
export function useDeleteNotificationPolicy({ alertmanager }: BaseAlertmanagerArgs) {
|
||||
const k8sApiSupported = shouldUseK8sApi(alertmanager);
|
||||
const [produceNewAlertmanagerConfiguration] = useProduceNewAlertmanagerConfiguration();
|
||||
const [listNamespacedRoutingTree] = useLazyListNamespacedRoutingTreeQuery();
|
||||
const [readNamespacedRoutingTree] = useLazyReadNamespacedRoutingTreeQuery();
|
||||
const [updatedNamespacedRoute] = useReplaceNamespacedRoutingTreeMutation();
|
||||
|
||||
const deleteFromK8sApi = useAsync(async (id: string) => {
|
||||
const deleteFromK8sApi = useAsync(async (route: RouteWithID) => {
|
||||
const namespace = getAPINamespace();
|
||||
const result = await listNamespacedRoutingTree({ namespace });
|
||||
const name = route.name ?? ROOT_ROUTE_NAME;
|
||||
const result = await readNamespacedRoutingTree({ namespace, name: name });
|
||||
|
||||
const [rootTree] = result.data ? k8sRoutesToRoutesMemoized(result.data.items) : [];
|
||||
const rootTree = result.data;
|
||||
if (!rootTree) {
|
||||
throw new Error(`no root route found for namespace ${namespace}`);
|
||||
}
|
||||
|
||||
const rootRouteWithIdentifiers = addUniqueIdentifierToRoute(rootTree);
|
||||
const newRouteTree = omitRouteFromRouteTree(id, rootRouteWithIdentifiers);
|
||||
const rootRouteWithIdentifiers = addUniqueIdentifierToRoute(k8sRouteToRoute(rootTree));
|
||||
const newRouteTree = omitRouteFromRouteTree(route.id, rootRouteWithIdentifiers);
|
||||
|
||||
// Create the K8s route object
|
||||
const routeObject = createKubernetesRoutingTreeSpec(newRouteTree);
|
||||
|
||||
return updatedNamespacedRoute({
|
||||
name: ROOT_ROUTE_NAME,
|
||||
name: name,
|
||||
namespace,
|
||||
comGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree: routeObject,
|
||||
}).unwrap();
|
||||
});
|
||||
|
||||
const deleteFromAlertmanagerConfiguration = useAsync(async (id: string) => {
|
||||
const action = deleteRouteAction({ id });
|
||||
const deleteFromAlertmanagerConfiguration = useAsync(async (route: RouteWithID) => {
|
||||
const action = deleteRouteAction({ id: route.id });
|
||||
return produceNewAlertmanagerConfiguration(action);
|
||||
});
|
||||
|
||||
|
|
@ -167,32 +198,33 @@ export function useDeleteNotificationPolicy({ alertmanager }: BaseAlertmanagerAr
|
|||
export function useAddNotificationPolicy({ alertmanager }: BaseAlertmanagerArgs) {
|
||||
const k8sApiSupported = shouldUseK8sApi(alertmanager);
|
||||
const [produceNewAlertmanagerConfiguration] = useProduceNewAlertmanagerConfiguration();
|
||||
const [listNamespacedRoutingTree] = useLazyListNamespacedRoutingTreeQuery();
|
||||
const [readNamespacedRoutingTree] = useLazyReadNamespacedRoutingTreeQuery();
|
||||
const [updatedNamespacedRoute] = useReplaceNamespacedRoutingTreeMutation();
|
||||
|
||||
const addToK8sApi = useAsync(
|
||||
async ({
|
||||
partialRoute,
|
||||
referenceRouteIdentifier,
|
||||
referenceRoute,
|
||||
insertPosition,
|
||||
}: {
|
||||
partialRoute: Partial<FormAmRoute>;
|
||||
referenceRouteIdentifier: string;
|
||||
referenceRoute: RouteWithID;
|
||||
insertPosition: InsertPosition;
|
||||
}) => {
|
||||
const namespace = getAPINamespace();
|
||||
const result = await listNamespacedRoutingTree({ namespace });
|
||||
const name = referenceRoute.name ?? ROOT_ROUTE_NAME;
|
||||
const result = await readNamespacedRoutingTree({ namespace, name: name });
|
||||
|
||||
const [rootTree] = result.data ? k8sRoutesToRoutesMemoized(result.data.items) : [];
|
||||
const rootTree = result.data;
|
||||
if (!rootTree) {
|
||||
throw new Error(`no root route found for namespace ${namespace}`);
|
||||
}
|
||||
|
||||
const rootRouteWithIdentifiers = addUniqueIdentifierToRoute(rootTree);
|
||||
const rootRouteWithIdentifiers = addUniqueIdentifierToRoute(k8sRouteToRoute(rootTree));
|
||||
const newRouteTree = addRouteToReferenceRoute(
|
||||
alertmanager ?? '',
|
||||
partialRoute,
|
||||
referenceRouteIdentifier,
|
||||
referenceRoute.id,
|
||||
rootRouteWithIdentifiers,
|
||||
insertPosition
|
||||
);
|
||||
|
|
@ -201,7 +233,7 @@ export function useAddNotificationPolicy({ alertmanager }: BaseAlertmanagerArgs)
|
|||
const routeObject = createKubernetesRoutingTreeSpec(newRouteTree);
|
||||
|
||||
return updatedNamespacedRoute({
|
||||
name: ROOT_ROUTE_NAME,
|
||||
name: name,
|
||||
namespace,
|
||||
comGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree: cleanKubernetesRouteIDs(routeObject),
|
||||
}).unwrap();
|
||||
|
|
@ -211,16 +243,16 @@ export function useAddNotificationPolicy({ alertmanager }: BaseAlertmanagerArgs)
|
|||
const addToAlertmanagerConfiguration = useAsync(
|
||||
async ({
|
||||
partialRoute,
|
||||
referenceRouteIdentifier,
|
||||
referenceRoute,
|
||||
insertPosition,
|
||||
}: {
|
||||
partialRoute: Partial<FormAmRoute>;
|
||||
referenceRouteIdentifier: string;
|
||||
referenceRoute: RouteWithID;
|
||||
insertPosition: InsertPosition;
|
||||
}) => {
|
||||
const action = addRouteAction({
|
||||
partialRoute,
|
||||
referenceRouteIdentifier,
|
||||
referenceRouteIdentifier: referenceRoute.id,
|
||||
insertPosition,
|
||||
alertmanager,
|
||||
});
|
||||
|
|
@ -231,30 +263,161 @@ export function useAddNotificationPolicy({ alertmanager }: BaseAlertmanagerArgs)
|
|||
return k8sApiSupported ? addToK8sApi : addToAlertmanagerConfiguration;
|
||||
}
|
||||
|
||||
function k8sRoutesToRoutes(routes: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree[]): Route[] {
|
||||
return routes?.map((route) => {
|
||||
return {
|
||||
...route.spec.defaults,
|
||||
routes: route.spec.routes?.map(k8sSubRouteToRoute),
|
||||
[ROUTES_META_SYMBOL]: {
|
||||
provenance: getAnnotation(route, K8sAnnotations.Provenance),
|
||||
resourceVersion: route.metadata.resourceVersion,
|
||||
name: route.metadata.name,
|
||||
},
|
||||
provenance: getAnnotation(route, K8sAnnotations.Provenance),
|
||||
};
|
||||
type DeleteRoutingTreeArgs = { name: string; resourceVersion?: string };
|
||||
export function useDeleteRoutingTree() {
|
||||
const [deleteNamespacedRoutingTree] = useDeleteNamespacedRoutingTreeMutation();
|
||||
|
||||
return useAsync(async ({ name, resourceVersion }: DeleteRoutingTreeArgs) => {
|
||||
const namespace = getAPINamespace();
|
||||
|
||||
return deleteNamespacedRoutingTree({
|
||||
name: name,
|
||||
namespace,
|
||||
ioK8SApimachineryPkgApisMetaV1DeleteOptions: { preconditions: { resourceVersion } },
|
||||
}).unwrap();
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateRoutingTree() {
|
||||
const [createNamespacedRoutingTree] = useCreateNamespacedRoutingTreeMutation();
|
||||
|
||||
return useAsync(async (partialFormRoute: Partial<FormAmRoute>) => {
|
||||
const namespace = getAPINamespace();
|
||||
|
||||
const {
|
||||
name,
|
||||
overrideGrouping,
|
||||
groupBy,
|
||||
overrideTimings,
|
||||
groupWaitValue,
|
||||
groupIntervalValue,
|
||||
repeatIntervalValue,
|
||||
receiver,
|
||||
} = partialFormRoute;
|
||||
|
||||
// This does not "inherit" from any existing route, as this is a new routing tree. If not set, it will use the system
|
||||
// defaults. Currently supported by group_by, group_wait, group_interval, and repeat_interval
|
||||
const USE_DEFAULTS = undefined;
|
||||
|
||||
const newRoute: Route = {
|
||||
name: name,
|
||||
group_by: overrideGrouping ? groupBy : USE_DEFAULTS,
|
||||
group_wait: overrideTimings && groupWaitValue ? groupWaitValue : USE_DEFAULTS,
|
||||
group_interval: overrideTimings && groupIntervalValue ? groupIntervalValue : USE_DEFAULTS,
|
||||
repeat_interval: overrideTimings && repeatIntervalValue ? repeatIntervalValue : USE_DEFAULTS,
|
||||
receiver: receiver,
|
||||
};
|
||||
|
||||
// defaults(newRoute, TIMING_OPTIONS_DEFAULTS)
|
||||
|
||||
// Create the K8s route object
|
||||
const routeObject = createKubernetesRoutingTreeSpec(newRoute);
|
||||
|
||||
return createNamespacedRoutingTree({
|
||||
namespace,
|
||||
comGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree: cleanKubernetesRouteIDs(routeObject),
|
||||
}).unwrap();
|
||||
});
|
||||
}
|
||||
|
||||
const fuzzyFinder = new uFuzzy({
|
||||
intraMode: 1,
|
||||
intraIns: 1,
|
||||
intraSub: 1,
|
||||
intraDel: 1,
|
||||
intraTrn: 1,
|
||||
});
|
||||
|
||||
export const useRootRouteSearch = (policies: Route[], search?: string | null): Route[] => {
|
||||
const nameHaystack = useMemo(() => {
|
||||
return policies.map((policy) => policy.name ?? '');
|
||||
}, [policies]);
|
||||
|
||||
const receiverHaystack = useMemo(() => {
|
||||
return policies.map((policy) => policy.receiver ?? '');
|
||||
}, [policies]);
|
||||
|
||||
if (!search) {
|
||||
return policies;
|
||||
}
|
||||
|
||||
const nameHits = fuzzyFinder.filter(nameHaystack, search) ?? [];
|
||||
const typeHits = fuzzyFinder.filter(receiverHaystack, search) ?? [];
|
||||
|
||||
const hits = [...nameHits, ...typeHits];
|
||||
|
||||
return uniq(hits).map((id) => policies[id]) ?? [];
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert Route to K8s compatible format. Make sure we aren't sending any additional properties the API doesn't recognize
|
||||
* because it will reply with excess properties in the HTTP headers
|
||||
*/
|
||||
export function createKubernetesRoutingTreeSpec(
|
||||
rootRoute: Route
|
||||
): ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree {
|
||||
const inheritableDefaultProperties: InheritableProperties = pick(routeAdapter.toPackage(rootRoute), INHERITABLE_KEYS);
|
||||
|
||||
const name = rootRoute.name ?? ROOT_ROUTE_NAME;
|
||||
|
||||
const defaults: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RouteDefaults = {
|
||||
...inheritableDefaultProperties,
|
||||
// TODO: Fix types in k8s API? Fix our types to not allow empty receiver? TBC
|
||||
receiver: rootRoute.receiver ?? '',
|
||||
};
|
||||
|
||||
const routes = rootRoute.routes?.map(routeToK8sSubRoute) ?? [];
|
||||
|
||||
const spec = {
|
||||
defaults,
|
||||
routes,
|
||||
};
|
||||
|
||||
return {
|
||||
spec: spec,
|
||||
metadata: {
|
||||
name: name,
|
||||
resourceVersion: rootRoute[ROUTES_META_SYMBOL]?.resourceVersion,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const NAMED_ROOT_LABEL_NAME = '__grafana_managed_route__';
|
||||
|
||||
export function k8sRouteToRoute(route: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree): Route {
|
||||
return {
|
||||
...route.spec.defaults,
|
||||
name: route.metadata.name,
|
||||
routes: route.spec.routes?.map((subroute) => k8sSubRouteToRoute(subroute, route.metadata.name)),
|
||||
// This assumes if a `NAMED_ROOT_LABEL_NAME` label exists, it will NOT go to the default route, which is a fair but
|
||||
// not perfect assumption since we don't yet protect the label.
|
||||
object_matchers:
|
||||
route.metadata.name === ROOT_ROUTE_NAME || !route.metadata.name
|
||||
? [[NAMED_ROOT_LABEL_NAME, MatcherOperator.equal, '']]
|
||||
: [[NAMED_ROOT_LABEL_NAME, MatcherOperator.equal, route.metadata.name]],
|
||||
[ROUTES_META_SYMBOL]: {
|
||||
provenance: getAnnotation(route, K8sAnnotations.Provenance),
|
||||
resourceVersion: route.metadata.resourceVersion,
|
||||
name: route.metadata.name,
|
||||
metadata: route.metadata,
|
||||
},
|
||||
provenance: getAnnotation(route, K8sAnnotations.Provenance),
|
||||
};
|
||||
}
|
||||
|
||||
/** Helper to provide type safety for matcher operators from API */
|
||||
function isValidMatcherOperator(type: string): type is MatcherOperator {
|
||||
return Object.values<string>(MatcherOperator).includes(type);
|
||||
}
|
||||
|
||||
export function k8sSubRouteToRoute(route: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Route): Route {
|
||||
export function k8sSubRouteToRoute(
|
||||
route: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Route,
|
||||
rootName?: string
|
||||
): Route {
|
||||
return {
|
||||
...route,
|
||||
routes: route.routes?.map(k8sSubRouteToRoute),
|
||||
name: rootName,
|
||||
routes: route.routes?.map((subroute) => k8sSubRouteToRoute(subroute, rootName)),
|
||||
matchers: undefined,
|
||||
object_matchers: route.matchers?.map(({ label, type, value }) => {
|
||||
if (!isValidMatcherOperator(type)) {
|
||||
|
|
@ -278,34 +441,3 @@ export function routeToK8sSubRoute(route: Route): ComGithubGrafanaGrafanaPkgApis
|
|||
routes: route.routes?.map(routeToK8sSubRoute),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Route to K8s compatible format. Make sure we aren't sending any additional properties the API doesn't recognize
|
||||
* because it will reply with excess properties in the HTTP headers
|
||||
*/
|
||||
export function createKubernetesRoutingTreeSpec(
|
||||
rootRoute: Route
|
||||
): ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree {
|
||||
const inheritableDefaultProperties: InheritableProperties = pick(routeAdapter.toPackage(rootRoute), INHERITABLE_KEYS);
|
||||
|
||||
const defaults: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RouteDefaults = {
|
||||
...inheritableDefaultProperties,
|
||||
// TODO: Fix types in k8s API? Fix our types to not allow empty receiver? TBC
|
||||
receiver: rootRoute.receiver ?? '',
|
||||
};
|
||||
|
||||
const routes = rootRoute.routes?.map(routeToK8sSubRoute) ?? [];
|
||||
|
||||
const spec = {
|
||||
defaults,
|
||||
routes,
|
||||
};
|
||||
|
||||
return {
|
||||
spec: spec,
|
||||
metadata: {
|
||||
name: ROOT_ROUTE_NAME,
|
||||
resourceVersion: rootRoute[ROUTES_META_SYMBOL]?.resourceVersion,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -97,7 +97,11 @@ export function NotificationPolicyDrawer({
|
|||
)}
|
||||
</Stack>
|
||||
|
||||
<TextLink href={createRelativeUrl('/alerting/routes')} external inline={false}>
|
||||
<TextLink
|
||||
href={createRelativeUrl(`/alerting/routes/policy/${encodeURIComponent(policyName ?? '')}/edit`)}
|
||||
external
|
||||
inline={false}
|
||||
>
|
||||
<Trans i18nKey="alerting.notification-policy-drawer.view-notification-policy-tree">
|
||||
View notification policy tree
|
||||
</Trans>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import { useMemo } from 'react';
|
||||
import { useAsync } from 'react-use';
|
||||
|
||||
import { useNotificationPolicyRoute } from 'app/features/alerting/unified/components/notification-policies/useNotificationPolicyRoute';
|
||||
import {
|
||||
NAMED_ROOT_LABEL_NAME,
|
||||
useNotificationPolicyRoute,
|
||||
} from 'app/features/alerting/unified/components/notification-policies/useNotificationPolicyRoute';
|
||||
|
||||
import { Labels } from '../../../../../../types/unified-alerting-dto';
|
||||
import { useRouteGroupsMatcher } from '../../../useRouteGroupsMatcher';
|
||||
|
|
@ -10,16 +13,21 @@ import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource';
|
|||
import { normalizeRoute } from '../../../utils/notification-policies';
|
||||
|
||||
export const useAlertmanagerNotificationRoutingPreview = (alertmanager: string, instances: Labels[]) => {
|
||||
// if a NAMED_ROOT_LABEL_NAME label exists, then we only match to that route.
|
||||
const routeName = useMemo(() => {
|
||||
const routeNameLabel = instances.find((instance) => instance[NAMED_ROOT_LABEL_NAME]);
|
||||
return routeNameLabel?.[NAMED_ROOT_LABEL_NAME];
|
||||
}, [instances]);
|
||||
|
||||
const {
|
||||
data: currentData,
|
||||
data: defaultPolicy,
|
||||
isLoading: isPoliciesLoading,
|
||||
error: policiesError,
|
||||
} = useNotificationPolicyRoute({ alertmanager });
|
||||
} = useNotificationPolicyRoute({ alertmanager }, routeName);
|
||||
|
||||
// this function will use a web worker to compute matching routes
|
||||
const { matchInstancesToRoutes } = useRouteGroupsMatcher();
|
||||
|
||||
const [defaultPolicy] = currentData ?? [];
|
||||
const rootRoute = useMemo(() => {
|
||||
if (!defaultPolicy) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import {
|
|||
ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Matcher,
|
||||
ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Route,
|
||||
ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree,
|
||||
ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTreeSpec,
|
||||
} from 'app/features/alerting/unified/openapi/routesApi.gen';
|
||||
import { KnownProvenance } from 'app/features/alerting/unified/types/knownProvenance';
|
||||
import { K8sAnnotations, ROOT_ROUTE_NAME } from 'app/features/alerting/unified/utils/k8s/constants';
|
||||
|
|
@ -62,26 +63,144 @@ export const getUserDefinedRoutingTree: (
|
|||
}) || [],
|
||||
};
|
||||
|
||||
return {
|
||||
metadata: {
|
||||
name: ROOT_ROUTE_NAME,
|
||||
namespace: 'default',
|
||||
annotations: {
|
||||
[K8sAnnotations.Provenance]: KnownProvenance.None,
|
||||
},
|
||||
// Resource versions are much shorter than this in reality, but this is an easy way
|
||||
// for us to mock the concurrency logic and check if the policies have updated since the last fetch
|
||||
resourceVersion: btoa(JSON.stringify(spec)),
|
||||
},
|
||||
spec,
|
||||
};
|
||||
return routingTreeFromSpec(ROOT_ROUTE_NAME, spec);
|
||||
};
|
||||
|
||||
const routingTreeFromSpec: (
|
||||
routeName: string,
|
||||
spec: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTreeSpec,
|
||||
provenance?: string
|
||||
) => ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree = (
|
||||
routeName,
|
||||
spec,
|
||||
provenance = KnownProvenance.None
|
||||
) => ({
|
||||
kind: 'RoutingTree',
|
||||
metadata: {
|
||||
name: routeName,
|
||||
namespace: 'default',
|
||||
annotations: {
|
||||
[K8sAnnotations.Provenance]: provenance,
|
||||
},
|
||||
// Resource versions are much shorter than this in reality, but this is an easy way
|
||||
// for us to mock the concurrency logic and check if the policies have updated since the last fetch
|
||||
resourceVersion: btoa(JSON.stringify(spec)),
|
||||
},
|
||||
spec: spec,
|
||||
});
|
||||
|
||||
const getDefaultRoutingTreeMap = () =>
|
||||
new Map([[ROOT_ROUTE_NAME, getUserDefinedRoutingTree(grafanaAlertmanagerConfig)]]);
|
||||
new Map([
|
||||
[ROOT_ROUTE_NAME, getUserDefinedRoutingTree(grafanaAlertmanagerConfig)],
|
||||
[
|
||||
'Managed Policy - Empty Provisioned',
|
||||
routingTreeFromSpec(
|
||||
'Managed Policy - Empty Provisioned',
|
||||
{
|
||||
defaults: {
|
||||
receiver: grafanaAlertmanagerConfig?.alertmanager_config?.receivers![0].name, // grafana-default-email
|
||||
},
|
||||
routes: [],
|
||||
},
|
||||
'api'
|
||||
),
|
||||
],
|
||||
[
|
||||
'Managed Policy - Override + Inherit',
|
||||
routingTreeFromSpec('Managed Policy - Override + Inherit', {
|
||||
defaults: {
|
||||
receiver: grafanaAlertmanagerConfig?.alertmanager_config?.receivers![1].name, // provisioned-contact-point
|
||||
group_by: ['alertname'],
|
||||
group_wait: '1s',
|
||||
group_interval: '1m',
|
||||
repeat_interval: '1h',
|
||||
},
|
||||
routes: [
|
||||
{
|
||||
// Override.
|
||||
receiver: grafanaAlertmanagerConfig?.alertmanager_config?.receivers![2].name, // lotsa-emails
|
||||
group_by: ['alertname', 'grafana_folder'],
|
||||
group_wait: '10s',
|
||||
group_interval: '10m',
|
||||
repeat_interval: '10h',
|
||||
continue: true,
|
||||
active_time_intervals: [grafanaAlertmanagerConfig?.alertmanager_config?.time_intervals![0].name], // Some interval
|
||||
mute_time_intervals: [grafanaAlertmanagerConfig?.alertmanager_config?.time_intervals![1].name], // A provisioned interval
|
||||
matchers: [{ label: 'severity', type: MatcherOperator.equal, value: 'critical' }],
|
||||
},
|
||||
{
|
||||
// Inherit.
|
||||
matchers: [{ label: 'severity', type: MatcherOperator.equal, value: 'warn' }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
[
|
||||
'Managed Policy - Many Top-Level',
|
||||
routingTreeFromSpec('Managed Policy - Many Top-Level', {
|
||||
defaults: {
|
||||
receiver: grafanaAlertmanagerConfig?.alertmanager_config?.receivers![2].name, // lotsa-emails
|
||||
group_by: ['alertname'],
|
||||
group_wait: '2s',
|
||||
group_interval: '2m',
|
||||
repeat_interval: '2h',
|
||||
},
|
||||
routes: [
|
||||
// Many top-level routes.
|
||||
{ matchers: [{ label: 'severity', type: MatcherOperator.equal, value: 'warn' }] },
|
||||
{ matchers: [{ label: 'severity', type: MatcherOperator.equal, value: 'critical' }] },
|
||||
{ matchers: [{ label: 'severity', type: MatcherOperator.equal, value: 'info' }] },
|
||||
{ matchers: [{ label: 'severity', type: MatcherOperator.equal, value: 'debug' }] },
|
||||
{ matchers: [{ label: 'severity', type: MatcherOperator.equal, value: 'unknown' }] },
|
||||
],
|
||||
}),
|
||||
],
|
||||
[
|
||||
'Managed Policy - Deeply Nested',
|
||||
routingTreeFromSpec('Managed Policy - Deeply Nested', {
|
||||
defaults: {
|
||||
receiver: grafanaAlertmanagerConfig?.alertmanager_config?.receivers![3].name, // Slack with multiple channels
|
||||
group_by: ['...'],
|
||||
group_wait: '3s',
|
||||
group_interval: '3m',
|
||||
repeat_interval: '3h',
|
||||
},
|
||||
routes: [
|
||||
// Deeply nested route.
|
||||
{
|
||||
matchers: [{ label: 'level', type: MatcherOperator.equal, value: 'one' }],
|
||||
routes: [
|
||||
{
|
||||
matchers: [{ label: 'level', type: MatcherOperator.equal, value: 'two' }],
|
||||
routes: [
|
||||
{
|
||||
matchers: [{ label: 'level', type: MatcherOperator.equal, value: 'three' }],
|
||||
routes: [
|
||||
{
|
||||
matchers: [{ label: 'level', type: MatcherOperator.equal, value: 'four' }],
|
||||
routes: [
|
||||
{
|
||||
matchers: [{ label: 'level', type: MatcherOperator.equal, value: 'five' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
]);
|
||||
|
||||
let ROUTING_TREE_MAP = getDefaultRoutingTreeMap();
|
||||
|
||||
export const getRoutingTreeList = () => {
|
||||
return Array.from(ROUTING_TREE_MAP.values());
|
||||
};
|
||||
|
||||
export const getRoutingTree = (treeName: string) => {
|
||||
return ROUTING_TREE_MAP.get(treeName);
|
||||
};
|
||||
|
|
@ -93,6 +212,14 @@ export const setRoutingTree = (
|
|||
return ROUTING_TREE_MAP.set(treeName, updatedRoutingTree);
|
||||
};
|
||||
|
||||
export const deleteRoutingTree = (treeName: string) => {
|
||||
return ROUTING_TREE_MAP.delete(treeName);
|
||||
};
|
||||
|
||||
export const resetDefaultRoutingTree = () => {
|
||||
ROUTING_TREE_MAP.set(ROOT_ROUTE_NAME, getUserDefinedRoutingTree(grafanaAlertmanagerConfig));
|
||||
};
|
||||
|
||||
export const resetRoutingTreeMap = () => {
|
||||
ROUTING_TREE_MAP = getDefaultRoutingTreeMap();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,26 +1,32 @@
|
|||
import { HttpResponse, http } from 'msw';
|
||||
|
||||
import { getRoutingTree, setRoutingTree } from 'app/features/alerting/unified/mocks/server/entities/k8s/routingtrees';
|
||||
import {
|
||||
deleteRoutingTree,
|
||||
getRoutingTree,
|
||||
getRoutingTreeList,
|
||||
resetDefaultRoutingTree,
|
||||
setRoutingTree,
|
||||
} from 'app/features/alerting/unified/mocks/server/entities/k8s/routingtrees';
|
||||
import { ALERTING_API_SERVER_BASE_URL } from 'app/features/alerting/unified/mocks/server/utils';
|
||||
import {
|
||||
ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree,
|
||||
ListNamespacedRoutingTreeApiResponse,
|
||||
} from 'app/features/alerting/unified/openapi/routesApi.gen';
|
||||
import { ROOT_ROUTE_NAME } from 'app/features/alerting/unified/utils/k8s/constants';
|
||||
import { ApiMachineryError } from 'app/features/alerting/unified/utils/k8s/errors';
|
||||
|
||||
import { ROOT_ROUTE_NAME } from '../../../../utils/k8s/constants';
|
||||
|
||||
const wrapRoutingTreeResponse: (
|
||||
route: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree
|
||||
) => ListNamespacedRoutingTreeApiResponse = (route) => ({
|
||||
routes: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree[]
|
||||
) => ListNamespacedRoutingTreeApiResponse = (routes) => ({
|
||||
kind: 'RoutingTree',
|
||||
metadata: {},
|
||||
items: [route],
|
||||
items: routes,
|
||||
});
|
||||
|
||||
const listNamespacedRoutingTreesHandler = () =>
|
||||
http.get<{ namespace: string }>(`${ALERTING_API_SERVER_BASE_URL}/namespaces/:namespace/routingtrees`, () => {
|
||||
const userDefinedTree = getRoutingTree(ROOT_ROUTE_NAME)!;
|
||||
return HttpResponse.json(wrapRoutingTreeResponse(userDefinedTree));
|
||||
return HttpResponse.json(wrapRoutingTreeResponse(getRoutingTreeList()));
|
||||
});
|
||||
|
||||
const HTTP_RESPONSE_CONFLICT: ApiMachineryError = {
|
||||
|
|
@ -50,6 +56,52 @@ const updateNamespacedRoutingTreeHandler = () =>
|
|||
}
|
||||
);
|
||||
|
||||
const handlers = [listNamespacedRoutingTreesHandler(), updateNamespacedRoutingTreeHandler()];
|
||||
const getNamespacedRoutingTreeHandler = () =>
|
||||
http.get<{ namespace: string; name: string }>(
|
||||
`${ALERTING_API_SERVER_BASE_URL}/namespaces/:namespace/routingtrees/:name`,
|
||||
({ params: { name } }) => {
|
||||
const routingTree = getRoutingTree(name);
|
||||
if (!routingTree) {
|
||||
return HttpResponse.json({ message: 'NotFound' }, { status: 404 });
|
||||
}
|
||||
return HttpResponse.json(routingTree);
|
||||
}
|
||||
);
|
||||
|
||||
const createNamespacedRoutingTreeHandler = () =>
|
||||
http.post<
|
||||
{ namespace: string; name: string },
|
||||
ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree
|
||||
>(`${ALERTING_API_SERVER_BASE_URL}/namespaces/:namespace/routingtrees`, async ({ params: { name }, request }) => {
|
||||
const routingTree = await request.json();
|
||||
setRoutingTree(name, routingTree);
|
||||
return HttpResponse.json(routingTree);
|
||||
});
|
||||
|
||||
const deleteNamespacedRoutingTreeHandler = () =>
|
||||
http.delete<{ namespace: string; name: string }>(
|
||||
`${ALERTING_API_SERVER_BASE_URL}/namespaces/:namespace/routingtrees/:name`,
|
||||
({ params: { name } }) => {
|
||||
const routingTree = getRoutingTree(name);
|
||||
if (!routingTree) {
|
||||
return HttpResponse.json({ message: 'NotFound' }, { status: 404 });
|
||||
}
|
||||
if (name === ROOT_ROUTE_NAME) {
|
||||
// Reset instead.
|
||||
resetDefaultRoutingTree();
|
||||
} else {
|
||||
deleteRoutingTree(name);
|
||||
}
|
||||
return HttpResponse.json({});
|
||||
}
|
||||
);
|
||||
|
||||
const handlers = [
|
||||
listNamespacedRoutingTreesHandler(),
|
||||
updateNamespacedRoutingTreeHandler(),
|
||||
getNamespacedRoutingTreeHandler(),
|
||||
createNamespacedRoutingTreeHandler(),
|
||||
deleteNamespacedRoutingTreeHandler(),
|
||||
];
|
||||
|
||||
export default handlers;
|
||||
|
|
|
|||
|
|
@ -25,6 +25,32 @@ const injectedRtkApi = api
|
|||
}),
|
||||
providesTags: ['RoutingTree'],
|
||||
}),
|
||||
createNamespacedRoutingTree: build.mutation<
|
||||
CreateNamespacedRoutingTreeApiResponse,
|
||||
CreateNamespacedRoutingTreeApiArg
|
||||
>({
|
||||
query: (queryArg) => ({
|
||||
url: `/apis/notifications.alerting.grafana.app/v0alpha1/namespaces/${queryArg['namespace']}/routingtrees`,
|
||||
method: 'POST',
|
||||
body: queryArg.comGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree,
|
||||
params: {
|
||||
pretty: queryArg.pretty,
|
||||
dryRun: queryArg.dryRun,
|
||||
fieldManager: queryArg.fieldManager,
|
||||
fieldValidation: queryArg.fieldValidation,
|
||||
},
|
||||
}),
|
||||
invalidatesTags: ['RoutingTree'],
|
||||
}),
|
||||
readNamespacedRoutingTree: build.query<ReadNamespacedRoutingTreeApiResponse, ReadNamespacedRoutingTreeApiArg>({
|
||||
query: (queryArg) => ({
|
||||
url: `/apis/notifications.alerting.grafana.app/v0alpha1/namespaces/${queryArg['namespace']}/routingtrees/${queryArg.name}`,
|
||||
params: {
|
||||
pretty: queryArg.pretty,
|
||||
},
|
||||
}),
|
||||
providesTags: ['RoutingTree'],
|
||||
}),
|
||||
replaceNamespacedRoutingTree: build.mutation<
|
||||
ReplaceNamespacedRoutingTreeApiResponse,
|
||||
ReplaceNamespacedRoutingTreeApiArg
|
||||
|
|
@ -42,6 +68,24 @@ const injectedRtkApi = api
|
|||
}),
|
||||
invalidatesTags: ['RoutingTree'],
|
||||
}),
|
||||
deleteNamespacedRoutingTree: build.mutation<
|
||||
DeleteNamespacedRoutingTreeApiResponse,
|
||||
DeleteNamespacedRoutingTreeApiArg
|
||||
>({
|
||||
query: (queryArg) => ({
|
||||
url: `/apis/notifications.alerting.grafana.app/v0alpha1/namespaces/${queryArg['namespace']}/routingtrees/${queryArg.name}`,
|
||||
method: 'DELETE',
|
||||
params: {
|
||||
pretty: queryArg.pretty,
|
||||
dryRun: queryArg.dryRun,
|
||||
gracePeriodSeconds: queryArg.gracePeriodSeconds,
|
||||
ignoreStoreReadErrorWithClusterBreakingPotential: queryArg.ignoreStoreReadErrorWithClusterBreakingPotential,
|
||||
orphanDependents: queryArg.orphanDependents,
|
||||
propagationPolicy: queryArg.propagationPolicy,
|
||||
},
|
||||
}),
|
||||
invalidatesTags: ['RoutingTree'],
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
});
|
||||
|
|
@ -56,7 +100,7 @@ export type ListNamespacedRoutingTreeApiArg = {
|
|||
/** allowWatchBookmarks requests watch events with type "BOOKMARK". Servers that do not implement bookmarks may ignore this flag and bookmarks are sent at the server's discretion. Clients should not assume bookmarks are returned at any specific interval, nor may they assume the server will send any BOOKMARK event during a session. If this is not a watch, this field is ignored. */
|
||||
allowWatchBookmarks?: boolean;
|
||||
/** The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the "next key".
|
||||
|
||||
|
||||
This field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications. */
|
||||
continue?: string;
|
||||
/** A selector to restrict the list of returned objects by their fields. Defaults to everything. */
|
||||
|
|
@ -64,19 +108,19 @@ export type ListNamespacedRoutingTreeApiArg = {
|
|||
/** A selector to restrict the list of returned objects by their labels. Defaults to everything. */
|
||||
labelSelector?: string;
|
||||
/** limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true.
|
||||
|
||||
|
||||
The server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned. */
|
||||
limit?: number;
|
||||
/** resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.
|
||||
|
||||
|
||||
Defaults to unset */
|
||||
resourceVersion?: string;
|
||||
/** resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.
|
||||
|
||||
|
||||
Defaults to unset */
|
||||
resourceVersionMatch?: string;
|
||||
/** `sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic "Bookmark" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `"k8s.io/initial-events-end": "true"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched.
|
||||
|
||||
|
||||
When `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan
|
||||
is interpreted as "data at least as new as the provided `resourceVersion`"
|
||||
and the bookmark event is send when the state is synced
|
||||
|
|
@ -86,7 +130,7 @@ export type ListNamespacedRoutingTreeApiArg = {
|
|||
when request started being processed.
|
||||
- `resourceVersionMatch` set to any other value or unset
|
||||
Invalid error is returned.
|
||||
|
||||
|
||||
Defaults to true if `resourceVersion=""` or `resourceVersion="0"` (for backward compatibility reasons) and to false otherwise. */
|
||||
sendInitialEvents?: boolean;
|
||||
/** Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity. */
|
||||
|
|
@ -94,6 +138,33 @@ export type ListNamespacedRoutingTreeApiArg = {
|
|||
/** Watch for changes to the described resources and return them as a stream of add, update, and remove notifications. Specify resourceVersion. */
|
||||
watch?: boolean;
|
||||
};
|
||||
export type CreateNamespacedRoutingTreeApiResponse = /** status 200 OK */
|
||||
| ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree
|
||||
| /** status 201 Created */ ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree
|
||||
| /** status 202 Accepted */ ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree;
|
||||
export type CreateNamespacedRoutingTreeApiArg = {
|
||||
/** object name and auth scope, such as for teams and projects */
|
||||
namespace: string;
|
||||
/** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */
|
||||
pretty?: string;
|
||||
/** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */
|
||||
dryRun?: string;
|
||||
/** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. */
|
||||
fieldManager?: string;
|
||||
/** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */
|
||||
fieldValidation?: string;
|
||||
comGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree;
|
||||
};
|
||||
export type ReadNamespacedRoutingTreeApiResponse =
|
||||
/** status 200 OK */ ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree;
|
||||
export type ReadNamespacedRoutingTreeApiArg = {
|
||||
/** name of the RoutingTree */
|
||||
name: string;
|
||||
/** object name and auth scope, such as for teams and projects */
|
||||
namespace: string;
|
||||
/** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */
|
||||
pretty?: string;
|
||||
};
|
||||
export type ReplaceNamespacedRoutingTreeApiResponse = /** status 200 OK */
|
||||
| ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree
|
||||
| /** status 201 Created */ ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree;
|
||||
|
|
@ -112,6 +183,28 @@ export type ReplaceNamespacedRoutingTreeApiArg = {
|
|||
fieldValidation?: string;
|
||||
comGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTree;
|
||||
};
|
||||
export type DeleteNamespacedRoutingTreeApiResponse = /** status 200 OK */
|
||||
| IoK8SApimachineryPkgApisMetaV1Status
|
||||
| /** status 202 Accepted */ IoK8SApimachineryPkgApisMetaV1Status;
|
||||
export type DeleteNamespacedRoutingTreeApiArg = {
|
||||
/** name of the RoutingTree */
|
||||
name: string;
|
||||
/** object name and auth scope, such as for teams and projects */
|
||||
namespace: string;
|
||||
/** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */
|
||||
pretty?: string;
|
||||
/** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */
|
||||
dryRun?: string;
|
||||
/** The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately. */
|
||||
gracePeriodSeconds?: number;
|
||||
/** if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it */
|
||||
ignoreStoreReadErrorWithClusterBreakingPotential?: boolean;
|
||||
/** Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the "orphan" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both. */
|
||||
orphanDependents?: boolean;
|
||||
/** Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground. */
|
||||
propagationPolicy?: string;
|
||||
ioK8SApimachineryPkgApisMetaV1DeleteOptions: IoK8SApimachineryPkgApisMetaV1DeleteOptions;
|
||||
};
|
||||
export type IoK8SApimachineryPkgApisMetaV1Time = string;
|
||||
export type IoK8SApimachineryPkgApisMetaV1FieldsV1 = object;
|
||||
export type IoK8SApimachineryPkgApisMetaV1ManagedFieldsEntry = {
|
||||
|
|
@ -150,21 +243,21 @@ export type IoK8SApimachineryPkgApisMetaV1ObjectMeta = {
|
|||
[key: string]: string;
|
||||
};
|
||||
/** CreationTimestamp is a timestamp representing the server time when this object was created. It is not guaranteed to be set in happens-before order across separate operations. Clients may not set this value. It is represented in RFC3339 form and is in UTC.
|
||||
|
||||
|
||||
Populated by the system. Read-only. Null for lists. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata */
|
||||
creationTimestamp?: IoK8SApimachineryPkgApisMetaV1Time;
|
||||
/** Number of seconds allowed for this object to gracefully terminate before it will be removed from the system. Only set when deletionTimestamp is also set. May only be shortened. Read-only. */
|
||||
deletionGracePeriodSeconds?: number;
|
||||
/** DeletionTimestamp is RFC 3339 date and time at which this resource will be deleted. This field is set by the server when a graceful deletion is requested by the user, and is not directly settable by a client. The resource is expected to be deleted (no longer visible from resource lists, and not reachable by name) after the time in this field, once the finalizers list is empty. As long as the finalizers list contains items, deletion is blocked. Once the deletionTimestamp is set, this value may not be unset or be set further into the future, although it may be shortened or the resource may be deleted prior to this time. For example, a user may request that a pod is deleted in 30 seconds. The Kubelet will react by sending a graceful termination signal to the containers in the pod. After that 30 seconds, the Kubelet will send a hard termination signal (SIGKILL) to the container and after cleanup, remove the pod from the API. In the presence of network partitions, this object may still exist after this timestamp, until an administrator or automated process can determine the resource is fully terminated. If not set, graceful deletion of the object has not been requested.
|
||||
|
||||
|
||||
Populated by the system when a graceful deletion is requested. Read-only. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata */
|
||||
deletionTimestamp?: IoK8SApimachineryPkgApisMetaV1Time;
|
||||
/** Must be empty before the object is deleted from the registry. Each entry is an identifier for the responsible component that will remove the entry from the list. If the deletionTimestamp of the object is non-nil, entries in this list can only be removed. Finalizers may be processed and removed in any order. Order is NOT enforced because it introduces significant risk of stuck finalizers. finalizers is a shared field, any actor with permission can reorder it. If the finalizer list is processed in order, then this can lead to a situation in which the component responsible for the first finalizer in the list is waiting for a signal (field value, external system, or other) produced by a component responsible for a finalizer later in the list, resulting in a deadlock. Without enforced ordering finalizers are free to order amongst themselves and are not vulnerable to ordering changes in the list. */
|
||||
finalizers?: string[];
|
||||
/** GenerateName is an optional prefix, used by the server, to generate a unique name ONLY IF the Name field has not been provided. If this field is used, the name returned to the client will be different than the name passed. This value will also be combined with a unique suffix. The provided value has the same validation rules as the Name field, and may be truncated by the length of the suffix required to make the value unique on the server.
|
||||
|
||||
|
||||
If this field is specified and the generated name exists, the server will return a 409.
|
||||
|
||||
|
||||
Applied only if Name is not specified. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#idempotency */
|
||||
generateName?: string;
|
||||
/** A sequence number representing a specific generation of the desired state. Populated by the system. Read-only. */
|
||||
|
|
@ -178,19 +271,19 @@ export type IoK8SApimachineryPkgApisMetaV1ObjectMeta = {
|
|||
/** Name must be unique within a namespace. Is required when creating resources, although some resources may allow a client to request the generation of an appropriate name automatically. Name is primarily intended for creation idempotence and configuration definition. Cannot be updated. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#names */
|
||||
name?: string;
|
||||
/** Namespace defines the space within which each name must be unique. An empty namespace is equivalent to the "default" namespace, but "default" is the canonical representation. Not all objects are required to be scoped to a namespace - the value of this field for those objects will be empty.
|
||||
|
||||
|
||||
Must be a DNS_LABEL. Cannot be updated. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces */
|
||||
namespace?: string;
|
||||
/** List of objects depended by this object. If ALL objects in the list have been deleted, this object will be garbage collected. If this object is managed by a controller, then an entry in this list will point to this controller, with the controller field set to true. There cannot be more than one managing controller. */
|
||||
ownerReferences?: IoK8SApimachineryPkgApisMetaV1OwnerReference[];
|
||||
/** An opaque value that represents the internal version of this object that can be used by clients to determine when objects have changed. May be used for optimistic concurrency, change detection, and the watch operation on a resource or set of resources. Clients must treat these values as opaque and passed unmodified back to the server. They may only be valid for a particular resource or set of resources.
|
||||
|
||||
|
||||
Populated by the system. Read-only. Value must be treated as opaque by clients and . More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency */
|
||||
resourceVersion?: string;
|
||||
/** Deprecated: selfLink is a legacy read-only field that is no longer populated by the system. */
|
||||
selfLink?: string;
|
||||
/** UID is the unique in time and space value for this object. It is typically generated by the server on successful creation of a resource and is not allowed to change on PUT operations.
|
||||
|
||||
|
||||
Populated by the system. Read-only. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids */
|
||||
uid?: string;
|
||||
};
|
||||
|
|
@ -207,6 +300,7 @@ export type ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Matcher =
|
|||
value: string;
|
||||
};
|
||||
export type ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Route = {
|
||||
active_time_intervals?: string[];
|
||||
continue?: boolean;
|
||||
group_by?: string[];
|
||||
group_interval?: string;
|
||||
|
|
@ -227,6 +321,7 @@ export type ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTr
|
|||
/** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */
|
||||
kind?: string;
|
||||
metadata: IoK8SApimachineryPkgApisMetaV1ObjectMeta;
|
||||
/** Spec is the spec of the RoutingTree */
|
||||
spec: ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTreeSpec;
|
||||
};
|
||||
export type IoK8SApimachineryPkgApisMetaV1ListMeta = {
|
||||
|
|
@ -247,3 +342,69 @@ export type ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1RoutingTr
|
|||
kind?: string;
|
||||
metadata: IoK8SApimachineryPkgApisMetaV1ListMeta;
|
||||
};
|
||||
export type IoK8SApimachineryPkgApisMetaV1StatusCause = {
|
||||
/** The field of the resource that has caused this error, as named by its JSON serialization. May include dot and postfix notation for nested attributes. Arrays are zero-indexed. Fields may appear more than once in an array of causes due to fields having multiple errors. Optional.
|
||||
|
||||
Examples:
|
||||
"name" - the field "name" on the current resource
|
||||
"items[0].name" - the field "name" on the first array entry in "items" */
|
||||
field?: string;
|
||||
/** A human-readable description of the cause of the error. This field may be presented as-is to a reader. */
|
||||
message?: string;
|
||||
/** A machine-readable description of the cause of the error. If this value is empty there is no information available. */
|
||||
reason?: string;
|
||||
};
|
||||
export type IoK8SApimachineryPkgApisMetaV1StatusDetails = {
|
||||
/** The Causes array includes more details associated with the StatusReason failure. Not all StatusReasons may provide detailed causes. */
|
||||
causes?: IoK8SApimachineryPkgApisMetaV1StatusCause[];
|
||||
/** The group attribute of the resource associated with the status StatusReason. */
|
||||
group?: string;
|
||||
/** The kind attribute of the resource associated with the status StatusReason. On some operations may differ from the requested resource Kind. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */
|
||||
kind?: string;
|
||||
/** The name attribute of the resource associated with the status StatusReason (when there is a single name which can be described). */
|
||||
name?: string;
|
||||
/** If specified, the time in seconds before the operation should be retried. Some errors may indicate the client must take an alternate action - for those errors this field may indicate how long to wait before taking the alternate action. */
|
||||
retryAfterSeconds?: number;
|
||||
/** UID of the resource. (when there is a single resource which can be described). More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids */
|
||||
uid?: string;
|
||||
};
|
||||
export type IoK8SApimachineryPkgApisMetaV1Status = {
|
||||
/** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */
|
||||
apiVersion?: string;
|
||||
/** Suggested HTTP return code for this status, 0 if not set. */
|
||||
code?: number;
|
||||
/** Extended data associated with the reason. Each reason may define its own extended details. This field is optional and the data returned is not guaranteed to conform to any schema except that defined by the reason type. */
|
||||
details?: IoK8SApimachineryPkgApisMetaV1StatusDetails;
|
||||
/** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */
|
||||
kind?: string;
|
||||
/** A human-readable description of the status of this operation. */
|
||||
message?: string;
|
||||
/** Standard list metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */
|
||||
metadata?: IoK8SApimachineryPkgApisMetaV1ListMeta;
|
||||
/** A machine-readable description of why this operation is in the "Failure" status. If this value is empty there is no information available. A Reason clarifies an HTTP status code but does not override it. */
|
||||
reason?: string;
|
||||
/** Status of the operation. One of: "Success" or "Failure". More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status */
|
||||
status?: string;
|
||||
};
|
||||
export type IoK8SApimachineryPkgApisMetaV1Preconditions = {
|
||||
/** Specifies the target ResourceVersion */
|
||||
resourceVersion?: string;
|
||||
/** Specifies the target UID. */
|
||||
uid?: string;
|
||||
};
|
||||
export type IoK8SApimachineryPkgApisMetaV1DeleteOptions = {
|
||||
/** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */
|
||||
apiVersion?: string;
|
||||
/** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */
|
||||
dryRun?: string[];
|
||||
/** The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately. */
|
||||
gracePeriodSeconds?: number;
|
||||
/** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */
|
||||
kind?: string;
|
||||
/** Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the "orphan" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both. */
|
||||
orphanDependents?: boolean;
|
||||
/** Must be fulfilled before a deletion is carried out. If not possible, a 409 Conflict status will be returned. */
|
||||
preconditions?: IoK8SApimachineryPkgApisMetaV1Preconditions;
|
||||
/** Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground. */
|
||||
propagationPolicy?: string;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ export const routeGroupsMatcher = {
|
|||
.map((matchDetails) => ({
|
||||
route,
|
||||
routeTree: {
|
||||
metadata: { name: 'user-defined' },
|
||||
metadata: { name: routeTree.name },
|
||||
expandedSpec: expandedTree,
|
||||
},
|
||||
matchDetails,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { MatcherFieldValue } from './silence-form';
|
|||
|
||||
export interface FormAmRoute {
|
||||
id: string;
|
||||
name?: string;
|
||||
object_matchers: MatcherFieldValue[];
|
||||
continue: boolean;
|
||||
receiver: string;
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ export const commonGroupByOptions = [
|
|||
|
||||
export const emptyRoute: FormAmRoute = {
|
||||
id: '',
|
||||
name: '',
|
||||
overrideGrouping: false,
|
||||
groupBy: defaultGroupBy,
|
||||
object_matchers: [],
|
||||
|
|
@ -63,9 +64,13 @@ export const emptyRoute: FormAmRoute = {
|
|||
activeTimeIntervals: [],
|
||||
};
|
||||
|
||||
export function addUniqueIdentifierToRoutes(routes: Route[]): RouteWithID[] {
|
||||
return routes.map((policy, index) => addUniqueIdentifierToRoute(policy, policy.name ?? index.toString()));
|
||||
}
|
||||
|
||||
// add unique identifiers to each route in the route tree, that way we can figure out what route we've edited / deleted
|
||||
// ⚠️ make sure this function uses _stable_ identifiers!
|
||||
export function addUniqueIdentifierToRoute(route: Route, position = '0'): RouteWithID {
|
||||
export function addUniqueIdentifierToRoute(route: Route, position = route.name ?? '0'): RouteWithID {
|
||||
const routeHash = hashRoute(route);
|
||||
const routes = route.routes ?? [];
|
||||
|
||||
|
|
@ -112,6 +117,7 @@ export const amRouteToFormAmRoute = (route: RouteWithID | undefined): FormAmRout
|
|||
|
||||
return {
|
||||
id,
|
||||
name: route.name ?? '',
|
||||
// Frontend migration to use object_matchers instead of matchers, match, and match_re
|
||||
object_matchers: [
|
||||
...matchers,
|
||||
|
|
|
|||
|
|
@ -137,6 +137,7 @@ describe('hashRoute and stabilizeRoute', () => {
|
|||
};
|
||||
|
||||
const expected: Route = {
|
||||
name: '',
|
||||
active_time_intervals: [],
|
||||
continue: false,
|
||||
group_interval: '',
|
||||
|
|
@ -164,9 +165,9 @@ describe('hashRoute and stabilizeRoute', () => {
|
|||
expect(stabilizeRoute(route)).toEqual(expected);
|
||||
|
||||
// the hash of the route should be stable (so we assert is twice)
|
||||
expect(hashRoute(route)).toBe('-1tfmmx');
|
||||
expect(hashRoute(route)).toBe('-1tfmmx');
|
||||
expect(hashRoute(expected)).toBe('-1tfmmx');
|
||||
expect(hashRoute(route)).toBe('-djke0w');
|
||||
expect(hashRoute(route)).toBe('-djke0w');
|
||||
expect(hashRoute(expected)).toBe('-djke0w');
|
||||
|
||||
// the hash of the unstabilized route should be the same as the stabilized route
|
||||
// because the hash function will stabilize the inputs
|
||||
|
|
|
|||
|
|
@ -153,13 +153,14 @@ export function findRouteInTree(
|
|||
|
||||
export function cleanRouteIDs<
|
||||
T extends RouteWithID | Route | ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Route,
|
||||
>(route: T): Omit<T, 'id'> {
|
||||
>(route: T): Omit<T, 'id' | 'name'> {
|
||||
return omit(
|
||||
{
|
||||
...route,
|
||||
routes: route.routes?.map((route) => cleanRouteIDs(route)),
|
||||
},
|
||||
'id'
|
||||
'id',
|
||||
'name'
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -198,6 +199,7 @@ export function hashRoute(route: Route): string {
|
|||
*/
|
||||
export function stabilizeRoute(route: Route): Required<Route> {
|
||||
const result: Required<Route> = {
|
||||
name: route.name ?? '',
|
||||
receiver: route.receiver ?? '',
|
||||
group_by: route.group_by ? [...route.group_by].sort() : [],
|
||||
continue: route.continue ?? false,
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import {
|
|||
import { Dashboard, DashboardCursorSync, LibraryPanel } from '@grafana/schema';
|
||||
import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2';
|
||||
import { appEvents } from 'app/core/app_events';
|
||||
import { LS_PANEL_COPY_KEY } from 'app/core/constants';
|
||||
import { LS_PANEL_COPY_KEY, LS_STYLES_COPY_KEY } from 'app/core/constants';
|
||||
import { AnnoKeyManagerKind, ManagerKind } from 'app/features/apiserver/types';
|
||||
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
|
||||
import { DecoratedRevisionModel } from 'app/features/dashboard/types/revisionModels';
|
||||
|
|
@ -39,6 +39,7 @@ import { createWorker } from '../saving/createDetectChangesWorker';
|
|||
import { buildGridItemForPanel, transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
|
||||
import { getCloneKey } from '../utils/clone';
|
||||
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
|
||||
import { DashboardInteractions } from '../utils/interactions';
|
||||
import { findVizPanelByKey, getLibraryPanelBehavior, isLibraryPanel } from '../utils/utils';
|
||||
import * as utils from '../utils/utils';
|
||||
|
||||
|
|
@ -632,6 +633,196 @@ describe('DashboardScene', () => {
|
|||
expect(store.exists(LS_PANEL_COPY_KEY)).toBe(false);
|
||||
});
|
||||
|
||||
describe('Copy/Paste panel styles', () => {
|
||||
const createTimeseriesPanel = () => {
|
||||
return new VizPanel({
|
||||
title: 'Timeseries Panel',
|
||||
key: `panel-timeseries-${Math.random()}`,
|
||||
pluginId: 'timeseries',
|
||||
fieldConfig: {
|
||||
defaults: {
|
||||
color: { mode: 'palette-classic' },
|
||||
custom: {
|
||||
lineWidth: 1,
|
||||
fillOpacity: 10,
|
||||
},
|
||||
},
|
||||
overrides: [],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
store.delete(LS_STYLES_COPY_KEY);
|
||||
config.featureToggles.panelStyleActions = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
config.featureToggles.panelStyleActions = false;
|
||||
});
|
||||
|
||||
it('Should copy panel styles when feature flag is enabled', () => {
|
||||
const spy = jest.spyOn(DashboardInteractions, 'panelStylesMenuClicked');
|
||||
const timeseriesPanel = createTimeseriesPanel();
|
||||
|
||||
scene.copyPanelStyles(timeseriesPanel);
|
||||
|
||||
expect(store.exists(LS_STYLES_COPY_KEY)).toBe(true);
|
||||
const stored = JSON.parse(store.get(LS_STYLES_COPY_KEY) || '{}');
|
||||
expect(stored.panelType).toBe('timeseries');
|
||||
expect(stored.styles).toBeDefined();
|
||||
expect(spy).not.toHaveBeenCalled(); // Analytics only called from menu
|
||||
});
|
||||
|
||||
it('Should not copy panel styles when feature flag is disabled', () => {
|
||||
config.featureToggles.panelStyleActions = false;
|
||||
const timeseriesPanel = createTimeseriesPanel();
|
||||
|
||||
scene.copyPanelStyles(timeseriesPanel);
|
||||
|
||||
expect(store.exists(LS_STYLES_COPY_KEY)).toBe(false);
|
||||
});
|
||||
|
||||
it('Should not copy styles for non-timeseries panels', () => {
|
||||
const vizPanel = findVizPanelByKey(scene, 'panel-1')!;
|
||||
scene.copyPanelStyles(vizPanel);
|
||||
|
||||
expect(store.exists(LS_STYLES_COPY_KEY)).toBe(false);
|
||||
});
|
||||
|
||||
it('Should return false for hasPanelStylesToPaste when no styles copied', () => {
|
||||
expect(DashboardScene.hasPanelStylesToPaste('timeseries')).toBe(false);
|
||||
});
|
||||
|
||||
it('Should return false for hasPanelStylesToPaste when feature flag is disabled', () => {
|
||||
store.set(LS_STYLES_COPY_KEY, JSON.stringify({ panelType: 'timeseries', styles: {} }));
|
||||
config.featureToggles.panelStyleActions = false;
|
||||
|
||||
expect(DashboardScene.hasPanelStylesToPaste('timeseries')).toBe(false);
|
||||
});
|
||||
|
||||
it('Should return true for hasPanelStylesToPaste when styles exist for matching panel type', () => {
|
||||
store.set(LS_STYLES_COPY_KEY, JSON.stringify({ panelType: 'timeseries', styles: {} }));
|
||||
|
||||
expect(DashboardScene.hasPanelStylesToPaste('timeseries')).toBe(true);
|
||||
});
|
||||
|
||||
it('Should return false for hasPanelStylesToPaste for different panel type', () => {
|
||||
store.set(LS_STYLES_COPY_KEY, JSON.stringify({ panelType: 'timeseries', styles: {} }));
|
||||
|
||||
expect(DashboardScene.hasPanelStylesToPaste('table')).toBe(false);
|
||||
});
|
||||
|
||||
it('Should paste panel styles when feature flag is enabled', () => {
|
||||
const spy = jest.spyOn(DashboardInteractions, 'panelStylesMenuClicked');
|
||||
const timeseriesPanel = createTimeseriesPanel();
|
||||
const mockOnFieldConfigChange = jest.fn();
|
||||
timeseriesPanel.onFieldConfigChange = mockOnFieldConfigChange;
|
||||
|
||||
const styles = {
|
||||
panelType: 'timeseries',
|
||||
styles: {
|
||||
fieldConfig: {
|
||||
defaults: {
|
||||
color: { mode: 'palette-classic' },
|
||||
custom: {
|
||||
lineWidth: 2,
|
||||
fillOpacity: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
store.set(LS_STYLES_COPY_KEY, JSON.stringify(styles));
|
||||
|
||||
scene.pastePanelStyles(timeseriesPanel);
|
||||
|
||||
expect(mockOnFieldConfigChange).toHaveBeenCalled();
|
||||
expect(store.exists(LS_STYLES_COPY_KEY)).toBe(true);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Should not paste panel styles when feature flag is disabled', () => {
|
||||
config.featureToggles.panelStyleActions = false;
|
||||
const timeseriesPanel = createTimeseriesPanel();
|
||||
const mockOnFieldConfigChange = jest.fn();
|
||||
timeseriesPanel.onFieldConfigChange = mockOnFieldConfigChange;
|
||||
|
||||
const styles = {
|
||||
panelType: 'timeseries',
|
||||
styles: { fieldConfig: { defaults: {} } },
|
||||
};
|
||||
store.set(LS_STYLES_COPY_KEY, JSON.stringify(styles));
|
||||
|
||||
scene.pastePanelStyles(timeseriesPanel);
|
||||
|
||||
expect(mockOnFieldConfigChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Should not paste styles when no styles are copied', () => {
|
||||
const timeseriesPanel = createTimeseriesPanel();
|
||||
const mockOnFieldConfigChange = jest.fn();
|
||||
timeseriesPanel.onFieldConfigChange = mockOnFieldConfigChange;
|
||||
|
||||
scene.pastePanelStyles(timeseriesPanel);
|
||||
|
||||
expect(mockOnFieldConfigChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Should not paste styles to different panel type', () => {
|
||||
const spy = jest.spyOn(DashboardInteractions, 'panelStylesMenuClicked');
|
||||
const timeseriesPanel = createTimeseriesPanel();
|
||||
const mockOnFieldConfigChange = jest.fn();
|
||||
timeseriesPanel.onFieldConfigChange = mockOnFieldConfigChange;
|
||||
|
||||
const styles = {
|
||||
panelType: 'table',
|
||||
styles: { fieldConfig: { defaults: {} } },
|
||||
};
|
||||
store.set(LS_STYLES_COPY_KEY, JSON.stringify(styles));
|
||||
|
||||
scene.pastePanelStyles(timeseriesPanel);
|
||||
|
||||
expect(mockOnFieldConfigChange).not.toHaveBeenCalled();
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Should allow pasting styles multiple times', () => {
|
||||
const spy = jest.spyOn(DashboardInteractions, 'panelStylesMenuClicked');
|
||||
const timeseriesPanel1 = createTimeseriesPanel();
|
||||
const timeseriesPanel2 = createTimeseriesPanel();
|
||||
const mockOnFieldConfigChange1 = jest.fn();
|
||||
const mockOnFieldConfigChange2 = jest.fn();
|
||||
timeseriesPanel1.onFieldConfigChange = mockOnFieldConfigChange1;
|
||||
timeseriesPanel2.onFieldConfigChange = mockOnFieldConfigChange2;
|
||||
|
||||
const styles = {
|
||||
panelType: 'timeseries',
|
||||
styles: { fieldConfig: { defaults: { custom: { lineWidth: 3 } } } },
|
||||
};
|
||||
store.set(LS_STYLES_COPY_KEY, JSON.stringify(styles));
|
||||
|
||||
scene.pastePanelStyles(timeseriesPanel1);
|
||||
expect(mockOnFieldConfigChange1).toHaveBeenCalled();
|
||||
expect(store.exists(LS_STYLES_COPY_KEY)).toBe(true);
|
||||
|
||||
scene.pastePanelStyles(timeseriesPanel2);
|
||||
expect(mockOnFieldConfigChange2).toHaveBeenCalled();
|
||||
expect(store.exists(LS_STYLES_COPY_KEY)).toBe(true);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Should report analytics on paste error', () => {
|
||||
const spy = jest.spyOn(DashboardInteractions, 'panelStylesMenuClicked');
|
||||
jest.spyOn(console, 'error').mockImplementation();
|
||||
|
||||
store.set(LS_STYLES_COPY_KEY, 'invalid json');
|
||||
scene.pastePanelStyles(createTimeseriesPanel());
|
||||
|
||||
expect(spy).toHaveBeenCalledWith('paste', 'timeseries', expect.any(Number), true);
|
||||
});
|
||||
});
|
||||
|
||||
it('Should unlink a library panel', () => {
|
||||
const libPanel = new VizPanel({
|
||||
title: 'Panel B',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import * as H from 'history';
|
||||
|
||||
import { CoreApp, DataQueryRequest, locationUtil, NavIndex, NavModelItem, store } from '@grafana/data';
|
||||
import { CoreApp, DataQueryRequest, FieldConfig, locationUtil, NavIndex, NavModelItem, store } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { config, locationService, RefreshEvent } from '@grafana/runtime';
|
||||
import {
|
||||
|
|
@ -19,7 +19,7 @@ import { Dashboard, DashboardLink, LibraryPanel } from '@grafana/schema';
|
|||
import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2';
|
||||
import { appEvents } from 'app/core/app_events';
|
||||
import { ScrollRefElement } from 'app/core/components/NativeScrollbar';
|
||||
import { LS_PANEL_COPY_KEY } from 'app/core/constants';
|
||||
import { LS_PANEL_COPY_KEY, LS_STYLES_COPY_KEY } from 'app/core/constants';
|
||||
import { getNavModel } from 'app/core/selectors/navModel';
|
||||
import { sortedDeepCloneWithoutNulls } from 'app/core/utils/object';
|
||||
import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api';
|
||||
|
|
@ -34,6 +34,7 @@ import { DecoratedRevisionModel } from 'app/features/dashboard/types/revisionMod
|
|||
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
|
||||
import { DashboardJson } from 'app/features/manage-dashboards/types';
|
||||
import { VariablesChanged } from 'app/features/variables/types';
|
||||
import { defaultGraphStyleConfig } from 'app/plugins/panel/timeseries/config';
|
||||
import { DashboardDTO, DashboardMeta, SaveDashboardResponseDTO } from 'app/types/dashboard';
|
||||
import { ShowConfirmModalEvent } from 'app/types/events';
|
||||
|
||||
|
|
@ -69,6 +70,7 @@ import { isRepeatCloneOrChildOf } from '../utils/clone';
|
|||
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
|
||||
import { djb2Hash } from '../utils/djb2Hash';
|
||||
import { getDashboardUrl } from '../utils/getDashboardUrl';
|
||||
import { DashboardInteractions } from '../utils/interactions';
|
||||
import {
|
||||
getClosestVizPanel,
|
||||
getDashboardSceneFor,
|
||||
|
|
@ -98,6 +100,15 @@ export const PERSISTED_PROPS = ['title', 'description', 'tags', 'editable', 'gra
|
|||
export const PANEL_SEARCH_VAR = 'systemPanelFilterVar';
|
||||
export const PANELS_PER_ROW_VAR = 'systemDynamicRowSizeVar';
|
||||
|
||||
type PanelStyles = {
|
||||
fieldConfig?: { defaults: Partial<FieldConfig> };
|
||||
};
|
||||
|
||||
type CopiedPanelStyles = {
|
||||
panelType: string;
|
||||
styles: PanelStyles;
|
||||
};
|
||||
|
||||
export interface DashboardSceneState extends SceneObjectState {
|
||||
/** The title */
|
||||
title: string;
|
||||
|
|
@ -651,6 +662,145 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
|
|||
store.delete(LS_PANEL_COPY_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hardcoded to Timeseries for this PoC
|
||||
* @internal
|
||||
*/
|
||||
private static extractPanelStyles(panel: VizPanel): PanelStyles {
|
||||
const styles: PanelStyles = {};
|
||||
|
||||
if (!panel.state.fieldConfig?.defaults) {
|
||||
return styles;
|
||||
}
|
||||
|
||||
styles.fieldConfig = { defaults: {} };
|
||||
|
||||
const defaults = styles.fieldConfig.defaults;
|
||||
const panelDefaults = panel.state.fieldConfig.defaults;
|
||||
|
||||
// default props (color)
|
||||
if (defaultGraphStyleConfig.fieldConfig?.defaultsProps) {
|
||||
for (const key of defaultGraphStyleConfig.fieldConfig.defaultsProps) {
|
||||
const value = panelDefaults[key];
|
||||
if (value !== undefined) {
|
||||
defaults[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// custom props (lineWidth, fillOpacity, etc.)
|
||||
if (panel.state.fieldConfig.defaults.custom && defaultGraphStyleConfig.fieldConfig?.defaults) {
|
||||
const customDefaults: Record<string, unknown> = {};
|
||||
const panelCustom: Record<string, unknown> = panel.state.fieldConfig.defaults.custom;
|
||||
|
||||
for (const key of defaultGraphStyleConfig.fieldConfig.defaults) {
|
||||
const value = panelCustom[key];
|
||||
if (value !== undefined) {
|
||||
customDefaults[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
defaults.custom = customDefaults;
|
||||
}
|
||||
|
||||
return styles;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
public copyPanelStyles(vizPanel: VizPanel) {
|
||||
if (!config.featureToggles.panelStyleActions) {
|
||||
return;
|
||||
}
|
||||
|
||||
const panelType = vizPanel.state.pluginId;
|
||||
|
||||
if (panelType !== 'timeseries') {
|
||||
return;
|
||||
}
|
||||
|
||||
const stylesToCopy: CopiedPanelStyles = {
|
||||
panelType,
|
||||
styles: DashboardScene.extractPanelStyles(vizPanel),
|
||||
};
|
||||
|
||||
store.set(LS_STYLES_COPY_KEY, JSON.stringify(stylesToCopy));
|
||||
appEvents.emit('alert-success', ['Panel styles copied.']);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
public static hasPanelStylesToPaste(panelType: string): boolean {
|
||||
if (!config.featureToggles.panelStyleActions) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const stylesJson = store.get(LS_STYLES_COPY_KEY);
|
||||
if (!stylesJson) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const stylesCopy: CopiedPanelStyles = JSON.parse(stylesJson);
|
||||
return stylesCopy.panelType === panelType;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
public pastePanelStyles(vizPanel: VizPanel) {
|
||||
if (!config.featureToggles.panelStyleActions) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stylesJson = store.get(LS_STYLES_COPY_KEY);
|
||||
if (!stylesJson) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stylesCopy: CopiedPanelStyles = JSON.parse(stylesJson);
|
||||
|
||||
const panelType = vizPanel.state.pluginId;
|
||||
|
||||
if (stylesCopy.panelType !== panelType) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stylesCopy.styles.fieldConfig?.defaults) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newDefaults = {
|
||||
...vizPanel.state.fieldConfig?.defaults,
|
||||
...stylesCopy.styles.fieldConfig.defaults,
|
||||
};
|
||||
|
||||
if (stylesCopy.styles.fieldConfig.defaults.custom) {
|
||||
newDefaults.custom = {
|
||||
...vizPanel.state.fieldConfig?.defaults?.custom,
|
||||
...stylesCopy.styles.fieldConfig.defaults.custom,
|
||||
};
|
||||
}
|
||||
|
||||
const newFieldConfig = {
|
||||
...vizPanel.state.fieldConfig,
|
||||
defaults: newDefaults,
|
||||
};
|
||||
vizPanel.onFieldConfigChange(newFieldConfig);
|
||||
|
||||
appEvents.emit('alert-success', ['Panel styles applied.']);
|
||||
} catch (e) {
|
||||
console.error('Error pasting panel styles:', e);
|
||||
appEvents.emit('alert-error', ['Error pasting panel styles.']);
|
||||
DashboardInteractions.panelStylesMenuClicked(
|
||||
'paste',
|
||||
vizPanel.state.pluginId,
|
||||
getPanelIdForVizPanel(vizPanel) ?? -1,
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public removePanel(panel: VizPanel) {
|
||||
getLayoutManagerFor(panel).removePanel?.(panel);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
PluginExtensionPanelContext,
|
||||
PluginExtensionTypes,
|
||||
getDefaultTimeRange,
|
||||
store,
|
||||
toDataFrame,
|
||||
urlUtil,
|
||||
} from '@grafana/data';
|
||||
|
|
@ -18,6 +19,7 @@ import {
|
|||
VizPanel,
|
||||
VizPanelMenu,
|
||||
} from '@grafana/scenes';
|
||||
import { LS_STYLES_COPY_KEY } from 'app/core/constants';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { GetExploreUrlArguments } from 'app/core/utils/explore';
|
||||
import { grantUserPermissions } from 'app/features/alerting/unified/mocks';
|
||||
|
|
@ -26,6 +28,7 @@ import * as storeModule from 'app/store/store';
|
|||
import { AccessControlAction } from 'app/types/accessControl';
|
||||
|
||||
import { buildPanelEditScene } from '../panel-edit/PanelEditor';
|
||||
import { DashboardInteractions } from '../utils/interactions';
|
||||
|
||||
import { DashboardScene } from './DashboardScene';
|
||||
import { VizPanelLinks, VizPanelLinksMenu } from './PanelLinks';
|
||||
|
|
@ -849,6 +852,75 @@ describe('panelMenuBehavior', () => {
|
|||
jest.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Panel styles menu', () => {
|
||||
async function buildTimeseriesTestScene() {
|
||||
const menu = new VizPanelMenu({ $behaviors: [panelMenuBehavior] });
|
||||
const panel = new VizPanel({
|
||||
title: 'Timeseries Panel',
|
||||
pluginId: 'timeseries',
|
||||
key: 'panel-ts',
|
||||
menu,
|
||||
});
|
||||
|
||||
panel.getPlugin = () => getPanelPlugin({ skipDataQuery: false });
|
||||
|
||||
new DashboardScene({
|
||||
title: 'My dashboard',
|
||||
uid: 'dash-1',
|
||||
meta: { canEdit: true },
|
||||
body: DefaultGridLayoutManager.fromVizPanels([panel]),
|
||||
});
|
||||
|
||||
menu.activate();
|
||||
await new Promise((r) => setTimeout(r, 1));
|
||||
|
||||
return { menu, panel };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
config.featureToggles.panelStyleActions = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
config.featureToggles.panelStyleActions = false;
|
||||
store.delete(LS_STYLES_COPY_KEY);
|
||||
});
|
||||
|
||||
it('should call analytics when copy styles is clicked', async () => {
|
||||
const spy = jest.spyOn(DashboardInteractions, 'panelStylesMenuClicked');
|
||||
const { menu } = await buildTimeseriesTestScene();
|
||||
|
||||
const copyItem = menu.state.items?.find((i) => i.text === 'Styles')?.subMenu?.[0];
|
||||
copyItem?.onClick?.({} as never);
|
||||
|
||||
expect(spy).toHaveBeenCalledWith('copy', 'timeseries', expect.any(Number));
|
||||
});
|
||||
|
||||
it('should call analytics when paste styles is clicked', async () => {
|
||||
store.set(LS_STYLES_COPY_KEY, JSON.stringify({ panelType: 'timeseries', styles: {} }));
|
||||
const spy = jest.spyOn(DashboardInteractions, 'panelStylesMenuClicked');
|
||||
const { menu } = await buildTimeseriesTestScene();
|
||||
|
||||
const pasteItem = menu.state.items?.find((i) => i.text === 'Styles')?.subMenu?.[1];
|
||||
pasteItem?.onClick?.({} as never);
|
||||
|
||||
expect(spy).toHaveBeenCalledWith('paste', 'timeseries', expect.any(Number));
|
||||
});
|
||||
|
||||
it('should not show styles menu when feature flag is disabled', async () => {
|
||||
config.featureToggles.panelStyleActions = false;
|
||||
const { menu } = await buildTimeseriesTestScene();
|
||||
|
||||
expect(menu.state.items?.find((i) => i.text === 'Styles')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not show styles menu for non-timeseries panels', async () => {
|
||||
const { menu } = await buildTestScene({});
|
||||
|
||||
expect(menu.state.items?.find((i) => i.text === 'Styles')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
interface SceneOptions {
|
||||
|
|
|
|||
|
|
@ -347,6 +347,48 @@ export function panelMenuBehavior(menu: VizPanelMenu) {
|
|||
}
|
||||
}
|
||||
|
||||
if (panel.state.pluginId === 'timeseries' && config.featureToggles.panelStyleActions) {
|
||||
const stylesSubMenu: PanelMenuItem[] = [];
|
||||
|
||||
stylesSubMenu.push({
|
||||
text: t('panel.header-menu.copy-styles', `Copy styles`),
|
||||
iconClassName: 'copy',
|
||||
onClick: () => {
|
||||
DashboardInteractions.panelStylesMenuClicked(
|
||||
'copy',
|
||||
panel.state.pluginId,
|
||||
getPanelIdForVizPanel(panel) ?? -1
|
||||
);
|
||||
dashboard.copyPanelStyles(panel);
|
||||
},
|
||||
});
|
||||
|
||||
if (DashboardScene.hasPanelStylesToPaste('timeseries')) {
|
||||
stylesSubMenu.push({
|
||||
text: t('panel.header-menu.paste-styles', `Paste styles`),
|
||||
iconClassName: 'clipboard-alt',
|
||||
onClick: () => {
|
||||
DashboardInteractions.panelStylesMenuClicked(
|
||||
'paste',
|
||||
panel.state.pluginId,
|
||||
getPanelIdForVizPanel(panel) ?? -1
|
||||
);
|
||||
dashboard.pastePanelStyles(panel);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
items.push({
|
||||
type: 'submenu',
|
||||
text: t('panel.header-menu.styles', `Styles`),
|
||||
iconClassName: 'palette',
|
||||
subMenu: stylesSubMenu,
|
||||
onClick: (e) => {
|
||||
e.preventDefault();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (moreSubMenu.length) {
|
||||
items.push({
|
||||
type: 'submenu',
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import {
|
|||
RowsLayoutRowKind,
|
||||
TabsLayoutTabKind,
|
||||
} from '@grafana/schema/dist/esm/schema/dashboard/v2';
|
||||
import { LS_PANEL_COPY_KEY, LS_ROW_COPY_KEY, LS_TAB_COPY_KEY } from 'app/core/constants';
|
||||
import { LS_PANEL_COPY_KEY, LS_ROW_COPY_KEY, LS_STYLES_COPY_KEY, LS_TAB_COPY_KEY } from 'app/core/constants';
|
||||
|
||||
import { deserializeAutoGridItem } from '../../serialization/layoutSerializers/AutoGridLayoutSerializer';
|
||||
import { deserializeGridItem } from '../../serialization/layoutSerializers/DefaultGridLayoutSerializer';
|
||||
|
|
@ -24,6 +24,7 @@ export function clearClipboard() {
|
|||
store.delete(LS_PANEL_COPY_KEY);
|
||||
store.delete(LS_ROW_COPY_KEY);
|
||||
store.delete(LS_TAB_COPY_KEY);
|
||||
store.delete(LS_STYLES_COPY_KEY);
|
||||
}
|
||||
|
||||
export interface RowStore {
|
||||
|
|
|
|||
|
|
@ -101,6 +101,11 @@ export const DashboardInteractions = {
|
|||
reportDashboardInteraction('panel_action_clicked', { item, id, source });
|
||||
},
|
||||
|
||||
// Panel styles copy/paste interactions
|
||||
panelStylesMenuClicked(action: 'copy' | 'paste', panelType: string, panelId: number, error?: boolean) {
|
||||
reportDashboardInteraction('panel_styles_menu_clicked', { action, panelType, panelId, error });
|
||||
},
|
||||
|
||||
// Dashboard edit item actions
|
||||
// dashboards_edit_action_clicked: when user adds or removes an item in edit mode
|
||||
// props: { item: string } - item is one of: add_panel, group_row, group_tab, ungroup, paste_panel, remove_row, remove_tab
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { lastValueFrom, map } from 'rxjs';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
|
||||
import { config, getBackendSrv, FetchResponse } from '@grafana/runtime';
|
||||
import { config, getBackendSrv } from '@grafana/runtime';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { DashboardDTO, SnapshotSpec } from 'app/types/dashboard';
|
||||
import { DashboardDataDTO, DashboardDTO } from 'app/types/dashboard';
|
||||
|
||||
import { getAPINamespace } from '../../../api/utils';
|
||||
|
||||
|
|
@ -82,11 +82,12 @@ interface DashboardSnapshotList {
|
|||
items: K8sSnapshotResource[];
|
||||
}
|
||||
|
||||
interface K8sDashboardSnapshot {
|
||||
// Response from the /dashboard subresource - returns a Dashboard with raw dashboard data in spec
|
||||
interface K8sDashboardSubresource {
|
||||
apiVersion: string;
|
||||
kind: 'Snapshot';
|
||||
kind: 'Dashboard';
|
||||
metadata: K8sMetadata;
|
||||
spec: SnapshotSpec;
|
||||
spec: DashboardDataDTO;
|
||||
}
|
||||
|
||||
class K8sAPI implements DashboardSnapshotSrv {
|
||||
|
|
@ -128,32 +129,32 @@ class K8sAPI implements DashboardSnapshotSrv {
|
|||
const token = `??? TODO, get anon token for snapshots (${contextSrv.user?.name}) ???`;
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
return lastValueFrom(
|
||||
getBackendSrv()
|
||||
.fetch<K8sDashboardSnapshot>({
|
||||
|
||||
// Fetch both snapshot metadata and dashboard content in parallel
|
||||
const [snapshotResponse, dashboardResponse] = await Promise.all([
|
||||
lastValueFrom(
|
||||
getBackendSrv().fetch<K8sSnapshotResource>({
|
||||
url: this.url + '/' + uid,
|
||||
method: 'GET',
|
||||
headers: headers,
|
||||
})
|
||||
.pipe(
|
||||
map((response: FetchResponse<K8sDashboardSnapshot>) => {
|
||||
return {
|
||||
dashboard: response.data.spec.dashboard,
|
||||
meta: {
|
||||
isSnapshot: true,
|
||||
canSave: false,
|
||||
canEdit: false,
|
||||
canAdmin: false,
|
||||
canStar: false,
|
||||
canShare: false,
|
||||
canDelete: false,
|
||||
isFolder: false,
|
||||
provisioned: false,
|
||||
},
|
||||
};
|
||||
})
|
||||
)
|
||||
);
|
||||
),
|
||||
lastValueFrom(
|
||||
getBackendSrv().fetch<K8sDashboardSubresource>({
|
||||
url: this.url + '/' + uid + '/dashboard',
|
||||
method: 'GET',
|
||||
headers: headers,
|
||||
})
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
dashboard: dashboardResponse.data.spec,
|
||||
meta: {
|
||||
isSnapshot: true,
|
||||
k8s: snapshotResponse.data.metadata,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ function useGetConfigurationForApps() {
|
|||
const { data: rootRoute, isLoading: isLoadingDefaultContactPoint } = useNotificationPolicyRoute({
|
||||
alertmanager: GRAFANA_RULES_SOURCE_NAME,
|
||||
});
|
||||
const defaultContactpoint = rootRoute?.[0].receiver || '';
|
||||
const defaultContactpoint = rootRoute?.receiver || '';
|
||||
const { isDone: isCreateAlertRuleDone, isLoading: isLoadingAlertCreatedDone } = useIsCreateAlertRuleDone();
|
||||
// configuration checks for incidents
|
||||
const {
|
||||
|
|
|
|||
|
|
@ -95,6 +95,9 @@ describe('ImportOverviewV2', () => {
|
|||
deleteDashboard: jest.fn(),
|
||||
listDeletedDashboards: jest.fn(),
|
||||
restoreDashboard: jest.fn(),
|
||||
listDashboardHistory: jest.fn(),
|
||||
getDashboardHistoryVersions: jest.fn(),
|
||||
restoreDashboardVersion: jest.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
16
public/app/features/panel/table/PaginationEditor.tsx
Normal file
16
public/app/features/panel/table/PaginationEditor.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import { StandardEditorProps } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { Switch } from '@grafana/ui';
|
||||
|
||||
export const PaginationEditor = ({ onChange, value, id }: StandardEditorProps<boolean>) => (
|
||||
<Switch
|
||||
id={id}
|
||||
label={selectors.components.PanelEditor.OptionsPane.fieldLabel(`Enable pagination`)}
|
||||
value={Boolean(value)}
|
||||
onChange={(event: React.FormEvent<HTMLInputElement> | undefined) => {
|
||||
onChange(event?.currentTarget.checked);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
86
public/app/features/panel/table/addTableCustomConfig.ts
Normal file
86
public/app/features/panel/table/addTableCustomConfig.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { FieldConfigEditorBuilder } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { TableFieldOptions, defaultTableFieldOptions } from '@grafana/schema';
|
||||
|
||||
export function addTableCustomConfig<T extends TableFieldOptions>(
|
||||
builder: FieldConfigEditorBuilder<T>,
|
||||
options?: {
|
||||
hideFields?: boolean;
|
||||
filters?: boolean;
|
||||
wrapHeaderText?: boolean;
|
||||
}
|
||||
) {
|
||||
const category = [t('table.category-table', 'Table')];
|
||||
builder
|
||||
.addNumberInput({
|
||||
path: 'minWidth',
|
||||
name: t('table.name-min-column-width', 'Minimum column width'),
|
||||
category,
|
||||
description: t('table.description-min-column-width', 'The minimum width for column auto resizing'),
|
||||
settings: {
|
||||
placeholder: '150',
|
||||
min: 50,
|
||||
max: 500,
|
||||
},
|
||||
shouldApply: () => true,
|
||||
defaultValue: defaultTableFieldOptions.minWidth,
|
||||
})
|
||||
.addNumberInput({
|
||||
path: 'width',
|
||||
name: t('table.name-column-width', 'Column width'),
|
||||
category,
|
||||
settings: {
|
||||
placeholder: t('table.placeholder-column-width', 'auto'),
|
||||
min: 20,
|
||||
},
|
||||
shouldApply: () => true,
|
||||
defaultValue: defaultTableFieldOptions.width,
|
||||
})
|
||||
.addRadio({
|
||||
path: 'align',
|
||||
name: t('table.name-column-alignment', 'Column alignment'),
|
||||
category,
|
||||
settings: {
|
||||
options: [
|
||||
{ label: t('table.column-alignment-options.label-auto', 'Auto'), value: 'auto' },
|
||||
{ label: t('table.column-alignment-options.label-left', 'Left'), value: 'left' },
|
||||
{ label: t('table.column-alignment-options.label-center', 'Center'), value: 'center' },
|
||||
{ label: t('table.column-alignment-options.label-right', 'Right'), value: 'right' },
|
||||
],
|
||||
},
|
||||
defaultValue: defaultTableFieldOptions.align,
|
||||
})
|
||||
.addBooleanSwitch({
|
||||
path: 'wrapText',
|
||||
name: t('table.name-wrap-text', 'Wrap text'),
|
||||
category,
|
||||
});
|
||||
|
||||
if (options?.wrapHeaderText) {
|
||||
builder.addBooleanSwitch({
|
||||
path: 'wrapHeaderText',
|
||||
name: t('table.name-wrap-header-text', 'Wrap header text'),
|
||||
category,
|
||||
});
|
||||
}
|
||||
|
||||
if (options?.filters) {
|
||||
builder.addBooleanSwitch({
|
||||
path: 'filterable',
|
||||
name: t('table.name-column-filter', 'Column filter'),
|
||||
category,
|
||||
description: t('table.description-column-filter', 'Enables/disables field filters in table'),
|
||||
defaultValue: defaultTableFieldOptions.filterable,
|
||||
});
|
||||
}
|
||||
|
||||
if (options?.hideFields) {
|
||||
builder.addBooleanSwitch({
|
||||
path: 'hideFrom.viz',
|
||||
name: t('table.name-hide-in-table', 'Hide in table'),
|
||||
category,
|
||||
defaultValue: undefined,
|
||||
hideFromDefaults: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import { PanelOptionsEditorBuilder } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { TableCellHeight, TableOptions, defaultTableOptions } from '@grafana/schema/dist/esm/common/common.gen';
|
||||
|
||||
import { PaginationEditor } from './PaginationEditor';
|
||||
|
||||
export const addTableCustomPanelOptions = <O extends TableOptions>(builder: PanelOptionsEditorBuilder<O>) => {
|
||||
const category = [t('table.category-table', 'Table')];
|
||||
builder
|
||||
.addBooleanSwitch({
|
||||
path: 'showHeader',
|
||||
name: t('table.name-show-table-header', 'Show table header'),
|
||||
category,
|
||||
defaultValue: defaultTableOptions.showHeader,
|
||||
})
|
||||
.addNumberInput({
|
||||
path: 'frozenColumns.left',
|
||||
name: t('table.name-frozen-columns', 'Frozen columns'),
|
||||
description: t('table.description-frozen-columns', 'Columns are frozen from the left side of the table'),
|
||||
settings: {
|
||||
placeholder: t('table.placeholder-frozen-columns', 'none'),
|
||||
},
|
||||
category,
|
||||
})
|
||||
.addRadio({
|
||||
path: 'cellHeight',
|
||||
name: t('table.name-cell-height', 'Cell height'),
|
||||
category,
|
||||
defaultValue: defaultTableOptions.cellHeight,
|
||||
settings: {
|
||||
options: [
|
||||
{ value: TableCellHeight.Sm, label: t('table.cell-height-options.label-small', 'Small') },
|
||||
{ value: TableCellHeight.Md, label: t('table.cell-height-options.label-medium', 'Medium') },
|
||||
{ value: TableCellHeight.Lg, label: t('table.cell-height-options.label-large', 'Large') },
|
||||
],
|
||||
},
|
||||
})
|
||||
.addNumberInput({
|
||||
path: 'maxRowHeight',
|
||||
name: t('table.name-max-height', 'Max row height'),
|
||||
category,
|
||||
settings: {
|
||||
placeholder: t('table.placeholder-max-height', 'none'),
|
||||
min: 0,
|
||||
},
|
||||
})
|
||||
.addCustomEditor({
|
||||
id: 'enablePagination',
|
||||
path: 'enablePagination',
|
||||
name: t('table.name-enable-pagination', 'Enable pagination'),
|
||||
category,
|
||||
editor: PaginationEditor,
|
||||
defaultValue: defaultTableOptions?.enablePagination,
|
||||
});
|
||||
};
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
//DOCS: https://prometheus.io/docs/alerting/latest/configuration/
|
||||
import { DataSourceJsonData, WithAccessControlMetadata } from '@grafana/data';
|
||||
import { IoK8SApimachineryPkgApisMetaV1ObjectMeta } from 'app/features/alerting/unified/openapi/receiversApi.gen';
|
||||
import { IoK8SApimachineryPkgApisMetaV1ObjectMeta } from 'app/features/alerting/unified/openapi/routesApi.gen';
|
||||
import { ExtraConfiguration } from 'app/features/alerting/unified/utils/alertmanager/extraConfigs';
|
||||
|
||||
export const ROUTES_META_SYMBOL = Symbol('routes_metadata');
|
||||
|
|
@ -127,6 +127,7 @@ export type Receiver = GrafanaManagedContactPoint | AlertmanagerReceiver;
|
|||
export type ObjectMatcher = [name: string, operator: MatcherOperator, value: string];
|
||||
|
||||
export type Route = {
|
||||
name?: string;
|
||||
receiver?: string | null;
|
||||
group_by?: string[];
|
||||
continue?: boolean;
|
||||
|
|
@ -151,6 +152,7 @@ export type Route = {
|
|||
provenance?: string;
|
||||
resourceVersion?: string;
|
||||
name?: string;
|
||||
metadata?: IoK8SApimachineryPkgApisMetaV1ObjectMeta;
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import { StandardEditorProps } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { Switch } from '@grafana/ui';
|
||||
|
||||
export function PaginationEditor({ onChange, value, id }: StandardEditorProps<boolean>) {
|
||||
const changeValue = (event: React.FormEvent<HTMLInputElement> | undefined) => {
|
||||
onChange(event?.currentTarget.checked);
|
||||
};
|
||||
|
||||
return (
|
||||
<Switch
|
||||
id={id}
|
||||
label={selectors.components.PanelEditor.OptionsPane.fieldLabel(`Enable pagination`)}
|
||||
value={Boolean(value)}
|
||||
onChange={changeValue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,18 +1,18 @@
|
|||
import { PanelPlugin, standardEditorsRegistry, identityOverrideProcessor, FieldConfigProperty } from '@grafana/data';
|
||||
import { identityOverrideProcessor, FieldConfigProperty, PanelPlugin, standardEditorsRegistry } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import {
|
||||
defaultTableFieldOptions,
|
||||
TableCellOptions,
|
||||
TableCellDisplayMode,
|
||||
TableCellHeight,
|
||||
TableCellOptions,
|
||||
TableCellTooltipPlacement,
|
||||
defaultTableFieldOptions,
|
||||
} from '@grafana/schema';
|
||||
import { addTableCustomConfig } from 'app/features/panel/table/addTableCustomConfig';
|
||||
import { addTableCustomPanelOptions } from 'app/features/panel/table/addTableCustomPanelOptions';
|
||||
|
||||
import { PaginationEditor } from './PaginationEditor';
|
||||
import { TableCellOptionEditor } from './TableCellOptionEditor';
|
||||
import { TablePanel } from './TablePanel';
|
||||
import { tableMigrationHandler, tablePanelChangedHandler } from './migrations';
|
||||
import { Options, defaultOptions, FieldConfig } from './panelcfg.gen';
|
||||
import { FieldConfig, Options } from './panelcfg.gen';
|
||||
import { tableSuggestionsSupplier } from './suggestions';
|
||||
|
||||
export const plugin = new PanelPlugin<Options, FieldConfig>(TablePanel)
|
||||
|
|
@ -25,86 +25,31 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(TablePanel)
|
|||
},
|
||||
},
|
||||
useCustomConfig: (builder) => {
|
||||
const category = [t('table.category-table', 'Table')];
|
||||
addTableCustomConfig(builder, {
|
||||
filters: true,
|
||||
wrapHeaderText: true,
|
||||
hideFields: true,
|
||||
});
|
||||
|
||||
const cellCategory = [t('table.category-cell-options', 'Cell options')];
|
||||
|
||||
builder.addCustomEditor({
|
||||
id: 'footer.reducers',
|
||||
category: [t('table.category-table-footer', 'Table footer')],
|
||||
path: 'footer.reducers',
|
||||
name: t('table.name-calculation', 'Calculation'),
|
||||
description: t('table.description-calculation', 'Choose a reducer function / calculation'),
|
||||
editor: standardEditorsRegistry.get('stats-picker').editor,
|
||||
override: standardEditorsRegistry.get('stats-picker').editor,
|
||||
defaultValue: [],
|
||||
process: identityOverrideProcessor,
|
||||
shouldApply: () => true,
|
||||
settings: {
|
||||
allowMultiple: true,
|
||||
},
|
||||
});
|
||||
|
||||
builder
|
||||
.addNumberInput({
|
||||
path: 'minWidth',
|
||||
name: t('table.name-min-column-width', 'Minimum column width'),
|
||||
category,
|
||||
description: t('table.description-min-column-width', 'The minimum width for column auto resizing'),
|
||||
settings: {
|
||||
placeholder: '150',
|
||||
min: 50,
|
||||
max: 500,
|
||||
},
|
||||
shouldApply: () => true,
|
||||
defaultValue: defaultTableFieldOptions.minWidth,
|
||||
})
|
||||
.addNumberInput({
|
||||
path: 'width',
|
||||
name: t('table.name-column-width', 'Column width'),
|
||||
category,
|
||||
settings: {
|
||||
placeholder: t('table.placeholder-column-width', 'auto'),
|
||||
min: 20,
|
||||
},
|
||||
shouldApply: () => true,
|
||||
defaultValue: defaultTableFieldOptions.width,
|
||||
})
|
||||
.addRadio({
|
||||
path: 'align',
|
||||
name: t('table.name-column-alignment', 'Column alignment'),
|
||||
category,
|
||||
settings: {
|
||||
options: [
|
||||
{ label: t('table.column-alignment-options.label-auto', 'Auto'), value: 'auto' },
|
||||
{ label: t('table.column-alignment-options.label-left', 'Left'), value: 'left' },
|
||||
{ label: t('table.column-alignment-options.label-center', 'Center'), value: 'center' },
|
||||
{ label: t('table.column-alignment-options.label-right', 'Right'), value: 'right' },
|
||||
],
|
||||
},
|
||||
defaultValue: defaultTableFieldOptions.align,
|
||||
})
|
||||
.addBooleanSwitch({
|
||||
path: 'filterable',
|
||||
name: t('table.name-column-filter', 'Column filter'),
|
||||
category,
|
||||
description: t('table.description-column-filter', 'Enables/disables field filters in table'),
|
||||
defaultValue: defaultTableFieldOptions.filterable,
|
||||
})
|
||||
.addBooleanSwitch({
|
||||
path: 'wrapText',
|
||||
name: t('table.name-wrap-text', 'Wrap text'),
|
||||
category,
|
||||
})
|
||||
.addBooleanSwitch({
|
||||
path: 'wrapHeaderText',
|
||||
name: t('table.name-wrap-header-text', 'Wrap header text'),
|
||||
category,
|
||||
})
|
||||
.addBooleanSwitch({
|
||||
path: 'hideFrom.viz',
|
||||
name: t('table.name-hide-in-table', 'Hide in table'),
|
||||
category,
|
||||
defaultValue: undefined,
|
||||
hideFromDefaults: true,
|
||||
})
|
||||
.addCustomEditor({
|
||||
id: 'footer.reducers',
|
||||
category: [t('table.category-table-footer', 'Table footer')],
|
||||
path: 'footer.reducers',
|
||||
name: t('table.name-calculation', 'Calculation'),
|
||||
description: t('table.description-calculation', 'Choose a reducer function / calculation'),
|
||||
editor: standardEditorsRegistry.get('stats-picker').editor,
|
||||
override: standardEditorsRegistry.get('stats-picker').editor,
|
||||
defaultValue: [],
|
||||
process: identityOverrideProcessor,
|
||||
shouldApply: () => true,
|
||||
settings: {
|
||||
allowMultiple: true,
|
||||
},
|
||||
})
|
||||
.addCustomEditor<void, TableCellOptions>({
|
||||
id: 'cellOptions',
|
||||
path: 'cellOptions',
|
||||
|
|
@ -179,52 +124,6 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(TablePanel)
|
|||
},
|
||||
})
|
||||
.setPanelOptions((builder) => {
|
||||
const category = [t('table.category-table', 'Table')];
|
||||
builder
|
||||
.addBooleanSwitch({
|
||||
path: 'showHeader',
|
||||
name: t('table.name-show-table-header', 'Show table header'),
|
||||
category,
|
||||
defaultValue: defaultOptions.showHeader,
|
||||
})
|
||||
.addNumberInput({
|
||||
path: 'frozenColumns.left',
|
||||
name: t('table.name-frozen-columns', 'Frozen columns'),
|
||||
description: t('table.description-frozen-columns', 'Columns are frozen from the left side of the table'),
|
||||
settings: {
|
||||
placeholder: 'none',
|
||||
},
|
||||
category,
|
||||
})
|
||||
.addRadio({
|
||||
path: 'cellHeight',
|
||||
name: t('table.name-cell-height', 'Cell height'),
|
||||
category,
|
||||
defaultValue: defaultOptions.cellHeight,
|
||||
settings: {
|
||||
options: [
|
||||
{ value: TableCellHeight.Sm, label: t('table.cell-height-options.label-small', 'Small') },
|
||||
{ value: TableCellHeight.Md, label: t('table.cell-height-options.label-medium', 'Medium') },
|
||||
{ value: TableCellHeight.Lg, label: t('table.cell-height-options.label-large', 'Large') },
|
||||
],
|
||||
},
|
||||
})
|
||||
.addNumberInput({
|
||||
path: 'maxRowHeight',
|
||||
name: t('table.name-max-height', 'Max row height'),
|
||||
category,
|
||||
settings: {
|
||||
placeholder: t('table.placeholder-max-height', 'none'),
|
||||
min: 0,
|
||||
},
|
||||
})
|
||||
.addCustomEditor({
|
||||
id: 'enablePagination',
|
||||
path: 'enablePagination',
|
||||
name: t('table.name-enable-pagination', 'Enable pagination'),
|
||||
category,
|
||||
editor: PaginationEditor,
|
||||
defaultValue: defaultOptions?.enablePagination,
|
||||
});
|
||||
addTableCustomPanelOptions(builder);
|
||||
})
|
||||
.setSuggestionsSupplier(tableSuggestionsSupplier);
|
||||
|
|
|
|||
|
|
@ -24,31 +24,8 @@ composableKinds: PanelCfg: {
|
|||
schemas: [{
|
||||
version: [0, 0]
|
||||
schema: {
|
||||
Options: {
|
||||
// Represents the index of the selected frame
|
||||
frameIndex: number | *0
|
||||
// Controls whether the panel should show the header
|
||||
showHeader: bool | *true
|
||||
// Controls whether the header should show icons for the column types
|
||||
showTypeIcons?: bool | *false
|
||||
// Used to control row sorting
|
||||
sortBy?: [...ui.TableSortByFieldState]
|
||||
// Enable pagination on the table
|
||||
enablePagination?: bool
|
||||
// Controls the height of the rows
|
||||
cellHeight?: ui.TableCellHeight & (*"sm" | _)
|
||||
// limits the maximum height of a row, if text wrapping or dynamic height is enabled
|
||||
maxRowHeight?: number
|
||||
// Defines the number of columns to freeze on the left side of the table
|
||||
frozenColumns?: {
|
||||
left?: number | *0
|
||||
}
|
||||
// If true, disables all keyboard events in the table. this is used when previewing a table (i.e. suggestions)
|
||||
disableKeyboardEvents?: bool
|
||||
} @cuetsy(kind="interface")
|
||||
FieldConfig: {
|
||||
ui.TableFieldOptions
|
||||
} @cuetsy(kind="interface")
|
||||
Options: { ui.TableOptions } @cuetsy(kind="interface")
|
||||
FieldConfig: { ui.TableFieldOptions } @cuetsy(kind="interface")
|
||||
}
|
||||
}]
|
||||
lenses: []
|
||||
|
|
|
|||
49
public/app/plugins/panel/table/panelcfg.gen.ts
generated
49
public/app/plugins/panel/table/panelcfg.gen.ts
generated
|
|
@ -12,53 +12,6 @@
|
|||
|
||||
import * as ui from '@grafana/schema';
|
||||
|
||||
export interface Options {
|
||||
/**
|
||||
* Controls the height of the rows
|
||||
*/
|
||||
cellHeight?: ui.TableCellHeight;
|
||||
/**
|
||||
* If true, disables all keyboard events in the table. this is used when previewing a table (i.e. suggestions)
|
||||
*/
|
||||
disableKeyboardEvents?: boolean;
|
||||
/**
|
||||
* Enable pagination on the table
|
||||
*/
|
||||
enablePagination?: boolean;
|
||||
/**
|
||||
* Represents the index of the selected frame
|
||||
*/
|
||||
frameIndex: number;
|
||||
/**
|
||||
* Defines the number of columns to freeze on the left side of the table
|
||||
*/
|
||||
frozenColumns?: {
|
||||
left?: number;
|
||||
};
|
||||
/**
|
||||
* limits the maximum height of a row, if text wrapping or dynamic height is enabled
|
||||
*/
|
||||
maxRowHeight?: number;
|
||||
/**
|
||||
* Controls whether the panel should show the header
|
||||
*/
|
||||
showHeader: boolean;
|
||||
/**
|
||||
* Controls whether the header should show icons for the column types
|
||||
*/
|
||||
showTypeIcons?: boolean;
|
||||
/**
|
||||
* Used to control row sorting
|
||||
*/
|
||||
sortBy?: Array<ui.TableSortByFieldState>;
|
||||
}
|
||||
|
||||
export const defaultOptions: Partial<Options> = {
|
||||
cellHeight: ui.TableCellHeight.Sm,
|
||||
frameIndex: 0,
|
||||
showHeader: true,
|
||||
showTypeIcons: false,
|
||||
sortBy: [],
|
||||
};
|
||||
export interface Options extends ui.TableOptions {}
|
||||
|
||||
export interface FieldConfig extends ui.TableFieldOptions {}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,54 @@ export const defaultGraphConfig: GraphFieldConfig = {
|
|||
showValues: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Defines graph style configuration properties. Properties from GraphFieldConfig.
|
||||
* Temporary config - PoC.
|
||||
*/
|
||||
export const defaultGraphStyleConfig = {
|
||||
fieldConfig: {
|
||||
defaultsProps: ['color'],
|
||||
defaults: [
|
||||
// Line config
|
||||
'lineColor',
|
||||
'lineInterpolation',
|
||||
'lineStyle',
|
||||
'lineWidth',
|
||||
'spanNulls',
|
||||
// Fill config
|
||||
'fillBelowTo',
|
||||
'fillColor',
|
||||
'fillOpacity',
|
||||
// Points config
|
||||
'pointColor',
|
||||
'pointSize',
|
||||
'pointSymbol',
|
||||
'showPoints',
|
||||
// Axis config
|
||||
'axisBorderShow',
|
||||
'axisCenteredZero',
|
||||
'axisColorMode',
|
||||
'axisGridShow',
|
||||
'axisLabel',
|
||||
'axisPlacement',
|
||||
'axisSoftMax',
|
||||
'axisSoftMin',
|
||||
'axisWidth',
|
||||
// Graph field config
|
||||
'drawStyle',
|
||||
'gradientMode',
|
||||
'insertNulls',
|
||||
'showValues',
|
||||
// Stacking
|
||||
'stacking',
|
||||
// Bar config
|
||||
'barAlignment',
|
||||
'barWidthFactor',
|
||||
'barMaxWidth',
|
||||
],
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type NullEditorSettings = { isTime: boolean };
|
||||
|
||||
export function getGraphFieldConfig(cfg: GraphFieldConfig, isTime = true): SetFieldConfigOptionsArgs<GraphFieldConfig> {
|
||||
|
|
|
|||
|
|
@ -663,14 +663,18 @@
|
|||
"aria-label-group-by": "Group by",
|
||||
"aria-label-group-interval": "Group interval",
|
||||
"aria-label-group-wait": "Group wait",
|
||||
"aria-label-name": "Name",
|
||||
"aria-label-repeat-interval": "Repeat interval",
|
||||
"create-a-contact-point": "Create a contact point",
|
||||
"description-unique-routing": "A unique name for the routing tree",
|
||||
"label-default-contact-point": "Default contact point",
|
||||
"label-name": "Name",
|
||||
"label-timing-options": "Timing options",
|
||||
"message": {
|
||||
"required": "Required."
|
||||
},
|
||||
"or": "or"
|
||||
"or": "or",
|
||||
"validate-name": "Name is required"
|
||||
},
|
||||
"am-routes-expanded-form": {
|
||||
"add-matcher": "Add matcher",
|
||||
|
|
@ -2137,6 +2141,10 @@
|
|||
"new-policy": "Add new policy",
|
||||
"no-matchers": "No matchers",
|
||||
"reload-policies": "Reload policies",
|
||||
"root-policy": {
|
||||
"description": "All alert instances associated with this Route will be handled by this default policy if no other matching policies are found.",
|
||||
"title": "Default policy for '{{routeName}}'"
|
||||
},
|
||||
"save-policy": "Save policy",
|
||||
"update": {
|
||||
"please-wait": "Please wait while we update your notification policies.",
|
||||
|
|
@ -2154,6 +2162,54 @@
|
|||
"title": "Failed to add or update notification policy"
|
||||
}
|
||||
},
|
||||
"policies-list": {
|
||||
"create": {
|
||||
"aria-label": "add policy",
|
||||
"text": "Create policy"
|
||||
},
|
||||
"delete-reasons": {
|
||||
"no-permissions": "You do not have the required permission to delete this routing tree",
|
||||
"provisioned": "Routing tree is provisioned and cannot be deleted via the UI"
|
||||
},
|
||||
"delete-text": "Routing tree cannot be deleted for the following reasons:",
|
||||
"empty-state": {
|
||||
"message": "No policies found"
|
||||
},
|
||||
"fetch": {
|
||||
"error": "Failed to fetch policies"
|
||||
},
|
||||
"policy-header": {
|
||||
"delete": {
|
||||
"aria-label": "delete",
|
||||
"delete-label": "Delete",
|
||||
"reset-label": "Reset"
|
||||
},
|
||||
"edit": {
|
||||
"text": "Edit"
|
||||
},
|
||||
"export": {
|
||||
"aria-label": "export",
|
||||
"label": "Export"
|
||||
},
|
||||
"more-actions": {
|
||||
"aria-label": "More actions for routing tree \"{{name}}\""
|
||||
},
|
||||
"view": {
|
||||
"provisioned-tooltip": "Provisioned routing trees cannot be edited in the UI",
|
||||
"text": "View"
|
||||
}
|
||||
},
|
||||
"reset-reasons": {
|
||||
"no-permissions": "You do not have the required permission to reset this routing tree",
|
||||
"provisioned": "Routing tree is provisioned and cannot be reset via the UI"
|
||||
},
|
||||
"reset-text": "Routing tree cannot be reset for the following reasons:",
|
||||
"text-loading": "Loading...."
|
||||
},
|
||||
"policies-tree-wrapper": {
|
||||
"sorry-routing-exist": "Sorry, this routing tree does not seem to exist.",
|
||||
"title-routing-tree-not-found": "Routing tree not found"
|
||||
},
|
||||
"policy": {
|
||||
"label-new-child-policy": "New child policy",
|
||||
"label-new-sibling-above": "New sibling above",
|
||||
|
|
@ -2337,6 +2393,13 @@
|
|||
"label-override-timings": "Override timings",
|
||||
"repeat-interval": "Repeat interval: <strong>{{repeatIntervalValue}}</strong>"
|
||||
},
|
||||
"routing-tree-filter": {
|
||||
"aria-label-clear": "clear",
|
||||
"aria-label-search-routing-trees": "search routing trees",
|
||||
"clear": "Clear",
|
||||
"label-search-by-name-or-receiver": "Search by name or receiver",
|
||||
"placeholder-search": "Search"
|
||||
},
|
||||
"rule-actions-buttons": {
|
||||
"title-edit": "Edit",
|
||||
"title-view": "View"
|
||||
|
|
@ -3102,6 +3165,12 @@
|
|||
"label-edit": "Edit",
|
||||
"label-export": "Export"
|
||||
},
|
||||
"use-create-routing-tree-modal": {
|
||||
"modal-element": {
|
||||
"add-routing-tree": "Add routing tree",
|
||||
"title-create-routing-tree": "Create routing tree"
|
||||
}
|
||||
},
|
||||
"use-delete-contact-point-modal": {
|
||||
"delete-confirm": "Yes, delete contact point",
|
||||
"deleting": "Deleting...",
|
||||
|
|
@ -3121,6 +3190,15 @@
|
|||
"title-delete-notification-policy": "Delete notification policy"
|
||||
}
|
||||
},
|
||||
"use-delete-routing-tree-modal": {
|
||||
"modal-element": {
|
||||
"delete-routing": "Are you sure you want to delete this routing tree?",
|
||||
"delete-yes": "Yes, delete routing tree",
|
||||
"deleting": "Deleting...",
|
||||
"deleting-routing-permanently-remove": "Deleting this routing tree will permanently remove it.",
|
||||
"title-delete-routing-tree": "Delete routing tree"
|
||||
}
|
||||
},
|
||||
"use-edit-configuration-drawer": {
|
||||
"drawer": {
|
||||
"internal-grafana-alertmanager-title": "Grafana built-in Alertmanager",
|
||||
|
|
@ -11290,6 +11368,7 @@
|
|||
},
|
||||
"header-menu": {
|
||||
"copy": "Copy",
|
||||
"copy-styles": "Copy styles",
|
||||
"create-library-panel": "Create library panel",
|
||||
"duplicate": "Duplicate",
|
||||
"edit": "Edit",
|
||||
|
|
@ -11301,11 +11380,13 @@
|
|||
"inspect-json": "Panel JSON",
|
||||
"more": "More...",
|
||||
"new-alert-rule": "New alert rule",
|
||||
"paste-styles": "Paste styles",
|
||||
"query": "Query",
|
||||
"remove": "Remove",
|
||||
"replace-library-panel": "Replace library panel",
|
||||
"share": "Share",
|
||||
"show-legend": "Show legend",
|
||||
"styles": "Styles",
|
||||
"time-settings": "Time settings",
|
||||
"unlink-library-panel": "Unlink library panel",
|
||||
"view": "View"
|
||||
|
|
@ -13627,6 +13708,7 @@
|
|||
"name-wrap-header-text": "Wrap header text",
|
||||
"name-wrap-text": "Wrap text",
|
||||
"placeholder-column-width": "auto",
|
||||
"placeholder-frozen-columns": "none",
|
||||
"placeholder-max-height": "none",
|
||||
"tooltip-placement-options": {
|
||||
"label-auto": "Auto",
|
||||
|
|
|
|||
|
|
@ -67,8 +67,10 @@ const config: ConfigFile = {
|
|||
apiImport: 'alertingApi',
|
||||
filterEndpoints: [
|
||||
'listNamespacedRoutingTree',
|
||||
'createNamespacedRoutingTree',
|
||||
'readNamespacedRoutingTree',
|
||||
'replaceNamespacedRoutingTree',
|
||||
'deleteCollectionNamespacedRoutingTree',
|
||||
'deleteNamespacedRoutingTree',
|
||||
],
|
||||
exportName: 'generatedRoutesApi',
|
||||
flattenArg: false,
|
||||
|
|
|
|||
Loading…
Reference in a new issue