Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d8c3ec994a | |||
| 7114b9cd3b | |||
| b40d0e6ff4 | |||
| 584615cf3f | |||
| 5f80a29a28 | |||
| eab5d2b30e | |||
| f3421b9718 | |||
| 1addfd69b4 | |||
| d4a627c5fc | |||
| 46ef9aaa0a | |||
| 6ce672dd00 | |||
| 403f4d41de | |||
| 6512259acc | |||
| b2dd095bd8 | |||
| e525b529a8 | |||
| 7805e18368 | |||
| 7a07a49ecc | |||
| 9a4e13800d | |||
| a0c4e8b4f4 | |||
| fa62113b41 |
@@ -198,6 +198,7 @@ type JobStatus struct {
|
||||
Finished int64 `json:"finished,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
|
||||
// Optional value 0-100 that can be set while running
|
||||
Progress float64 `json:"progress,omitempty"`
|
||||
@@ -225,18 +226,20 @@ type JobResourceSummary struct {
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Total int64 `json:"total,omitempty"` // the count (if known)
|
||||
|
||||
Create int64 `json:"create,omitempty"`
|
||||
Update int64 `json:"update,omitempty"`
|
||||
Delete int64 `json:"delete,omitempty"`
|
||||
Write int64 `json:"write,omitempty"` // Create or update (export)
|
||||
Error int64 `json:"error,omitempty"` // The error count
|
||||
Create int64 `json:"create,omitempty"`
|
||||
Update int64 `json:"update,omitempty"`
|
||||
Delete int64 `json:"delete,omitempty"`
|
||||
Write int64 `json:"write,omitempty"` // Create or update (export)
|
||||
Error int64 `json:"error,omitempty"` // The error count
|
||||
Warning int64 `json:"warning,omitempty"` // The warning count
|
||||
|
||||
// No action required (useful for sync)
|
||||
Noop int64 `json:"noop,omitempty"`
|
||||
|
||||
// Report errors for this resource type
|
||||
// Report errors/warnings for this resource type
|
||||
// This may not be an exhaustive list and recommend looking at the logs for more info
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
// HistoricJob is an append only log, saving all jobs that have been processed.
|
||||
|
||||
@@ -401,6 +401,11 @@ func (in *JobResourceSummary) DeepCopyInto(out *JobResourceSummary) {
|
||||
*out = make([]string, len(*in))
|
||||
copy(*out, *in)
|
||||
}
|
||||
if in.Warnings != nil {
|
||||
in, out := &in.Warnings, &out.Warnings
|
||||
*out = make([]string, len(*in))
|
||||
copy(*out, *in)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -468,6 +473,11 @@ func (in *JobStatus) DeepCopyInto(out *JobStatus) {
|
||||
*out = make([]string, len(*in))
|
||||
copy(*out, *in)
|
||||
}
|
||||
if in.Warnings != nil {
|
||||
in, out := &in.Warnings, &out.Warnings
|
||||
*out = make([]string, len(*in))
|
||||
copy(*out, *in)
|
||||
}
|
||||
if in.Summary != nil {
|
||||
in, out := &in.Summary, &out.Summary
|
||||
*out = make([]*JobResourceSummary, len(*in))
|
||||
|
||||
@@ -889,6 +889,13 @@ func schema_pkg_apis_provisioning_v0alpha1_JobResourceSummary(ref common.Referen
|
||||
Format: "int64",
|
||||
},
|
||||
},
|
||||
"warning": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "The error count",
|
||||
Type: []string{"integer"},
|
||||
Format: "int64",
|
||||
},
|
||||
},
|
||||
"noop": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "No action required (useful for sync)",
|
||||
@@ -898,7 +905,7 @@ func schema_pkg_apis_provisioning_v0alpha1_JobResourceSummary(ref common.Referen
|
||||
},
|
||||
"errors": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "Report errors for this resource type This may not be an exhaustive list and recommend looking at the logs for more info",
|
||||
Description: "Report errors/warnings for this resource type This may not be an exhaustive list and recommend looking at the logs for more info",
|
||||
Type: []string{"array"},
|
||||
Items: &spec.SchemaOrArray{
|
||||
Schema: &spec.Schema{
|
||||
@@ -911,6 +918,20 @@ func schema_pkg_apis_provisioning_v0alpha1_JobResourceSummary(ref common.Referen
|
||||
},
|
||||
},
|
||||
},
|
||||
"warnings": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"array"},
|
||||
Items: &spec.SchemaOrArray{
|
||||
Schema: &spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: "",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1029,6 +1050,20 @@ func schema_pkg_apis_provisioning_v0alpha1_JobStatus(ref common.ReferenceCallbac
|
||||
},
|
||||
},
|
||||
},
|
||||
"warnings": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"array"},
|
||||
Items: &spec.SchemaOrArray{
|
||||
Schema: &spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: "",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"progress": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "Optional value 0-100 that can be set while running",
|
||||
|
||||
+2
@@ -3,8 +3,10 @@ API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioni
|
||||
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,FileList,Items
|
||||
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,HistoryList,Items
|
||||
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,JobResourceSummary,Errors
|
||||
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,JobResourceSummary,Warnings
|
||||
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,JobStatus,Errors
|
||||
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,JobStatus,Summary
|
||||
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,JobStatus,Warnings
|
||||
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,ManagerStats,Stats
|
||||
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,MoveJobOptions,Paths
|
||||
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,MoveJobOptions,Resources
|
||||
|
||||
+30
-10
@@ -7,16 +7,18 @@ package v0alpha1
|
||||
// JobResourceSummaryApplyConfiguration represents a declarative configuration of the JobResourceSummary type for use
|
||||
// with apply.
|
||||
type JobResourceSummaryApplyConfiguration struct {
|
||||
Group *string `json:"group,omitempty"`
|
||||
Kind *string `json:"kind,omitempty"`
|
||||
Total *int64 `json:"total,omitempty"`
|
||||
Create *int64 `json:"create,omitempty"`
|
||||
Update *int64 `json:"update,omitempty"`
|
||||
Delete *int64 `json:"delete,omitempty"`
|
||||
Write *int64 `json:"write,omitempty"`
|
||||
Error *int64 `json:"error,omitempty"`
|
||||
Noop *int64 `json:"noop,omitempty"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
Group *string `json:"group,omitempty"`
|
||||
Kind *string `json:"kind,omitempty"`
|
||||
Total *int64 `json:"total,omitempty"`
|
||||
Create *int64 `json:"create,omitempty"`
|
||||
Update *int64 `json:"update,omitempty"`
|
||||
Delete *int64 `json:"delete,omitempty"`
|
||||
Write *int64 `json:"write,omitempty"`
|
||||
Error *int64 `json:"error,omitempty"`
|
||||
Warning *int64 `json:"warning,omitempty"`
|
||||
Noop *int64 `json:"noop,omitempty"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
// JobResourceSummaryApplyConfiguration constructs a declarative configuration of the JobResourceSummary type for use with
|
||||
@@ -89,6 +91,14 @@ func (b *JobResourceSummaryApplyConfiguration) WithError(value int64) *JobResour
|
||||
return b
|
||||
}
|
||||
|
||||
// WithWarning sets the Warning field in the declarative configuration to the given value
|
||||
// and returns the receiver, so that objects can be built by chaining "With" function invocations.
|
||||
// If called multiple times, the Warning field is set to the value of the last call.
|
||||
func (b *JobResourceSummaryApplyConfiguration) WithWarning(value int64) *JobResourceSummaryApplyConfiguration {
|
||||
b.Warning = &value
|
||||
return b
|
||||
}
|
||||
|
||||
// WithNoop sets the Noop field in the declarative configuration to the given value
|
||||
// and returns the receiver, so that objects can be built by chaining "With" function invocations.
|
||||
// If called multiple times, the Noop field is set to the value of the last call.
|
||||
@@ -106,3 +116,13 @@ func (b *JobResourceSummaryApplyConfiguration) WithErrors(values ...string) *Job
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// WithWarnings adds the given value to the Warnings field in the declarative configuration
|
||||
// and returns the receiver, so that objects can be build by chaining "With" function invocations.
|
||||
// If called multiple times, values provided by each call will be appended to the Warnings field.
|
||||
func (b *JobResourceSummaryApplyConfiguration) WithWarnings(values ...string) *JobResourceSummaryApplyConfiguration {
|
||||
for i := range values {
|
||||
b.Warnings = append(b.Warnings, values[i])
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ type JobStatusApplyConfiguration struct {
|
||||
Finished *int64 `json:"finished,omitempty"`
|
||||
Message *string `json:"message,omitempty"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
Progress *float64 `json:"progress,omitempty"`
|
||||
Summary []*provisioningv0alpha1.JobResourceSummary `json:"summary,omitempty"`
|
||||
URLs *RepositoryURLsApplyConfiguration `json:"url,omitempty"`
|
||||
@@ -69,6 +70,16 @@ func (b *JobStatusApplyConfiguration) WithErrors(values ...string) *JobStatusApp
|
||||
return b
|
||||
}
|
||||
|
||||
// WithWarnings adds the given value to the Warnings field in the declarative configuration
|
||||
// and returns the receiver, so that objects can be build by chaining "With" function invocations.
|
||||
// If called multiple times, values provided by each call will be appended to the Warnings field.
|
||||
func (b *JobStatusApplyConfiguration) WithWarnings(values ...string) *JobStatusApplyConfiguration {
|
||||
for i := range values {
|
||||
b.Warnings = append(b.Warnings, values[i])
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// WithProgress sets the Progress field in the declarative configuration to the given value
|
||||
// and returns the receiver, so that objects can be built by chaining "With" function invocations.
|
||||
// If called multiple times, the Progress field is set to the value of the last call.
|
||||
|
||||
+3
-3
@@ -59,9 +59,9 @@ For more details on contact points, including how to test them and enable notifi
|
||||
|
||||
## Alertmanager settings
|
||||
|
||||
| Option | Description |
|
||||
| ------ | ---------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| URL | The Alertmanager URL. This field is [protected](ref:configure-contact-points#protected-fields) from modification in Grafana Cloud. |
|
||||
| Option | Description |
|
||||
| ------ | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| URL | The Alertmanager URL. This field is [protected](ref:configure-contact-points) from modification in Grafana Cloud. |
|
||||
|
||||
#### Optional settings
|
||||
|
||||
|
||||
+8
-8
@@ -49,14 +49,14 @@ For more details on contact points, including how to test them and enable notifi
|
||||
|
||||
### Required Settings
|
||||
|
||||
| Key | Description |
|
||||
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| URL | The URL of the REST API of your Jira instance. Supported versions: `2` and `3` (e.g., `https://your-domain.atlassian.net/rest/api/3`). This field is [protected](ref:configure-contact-points#protected-fields) from modification in Grafana Cloud. |
|
||||
| Basic Auth User | Username for authentication. For Jira Cloud, use your email address. |
|
||||
| Basic Auth Password | Password or personal token. For Jira Cloud, you need to obtain a personal token [here](https://id.atlassian.com/manage-profile/security/api-tokens) and use it as the password. |
|
||||
| API Token | An alternative to basic authentication, a bearer token is used to authorize the API requests. See [Jira documentation](https://confluence.atlassian.com/enterprise/using-personal-access-tokens-1026032365.html) for more information. |
|
||||
| Project Key | The project key identifying the project where issues will be created. Project keys are unique identifiers for a project. |
|
||||
| Issue Type | The type of issue to create (e.g., `Task`, `Bug`, `Incident`). Make sure that you specify a type that is available in your project. |
|
||||
| Key | Description |
|
||||
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| URL | The URL of the REST API of your Jira instance. Supported versions: `2` and `3` (e.g., `https://your-domain.atlassian.net/rest/api/3`). This field is [protected](ref:configure-contact-points) from modification in Grafana Cloud. |
|
||||
| Basic Auth User | Username for authentication. For Jira Cloud, use your email address. |
|
||||
| Basic Auth Password | Password or personal token. For Jira Cloud, you need to obtain a personal token [here](https://id.atlassian.com/manage-profile/security/api-tokens) and use it as the password. |
|
||||
| API Token | An alternative to basic authentication, a bearer token is used to authorize the API requests. See [Jira documentation](https://confluence.atlassian.com/enterprise/using-personal-access-tokens-1026032365.html) for more information. |
|
||||
| Project Key | The project key identifying the project where issues will be created. Project keys are unique identifiers for a project. |
|
||||
| Issue Type | The type of issue to create (e.g., `Task`, `Bug`, `Incident`). Make sure that you specify a type that is available in your project. |
|
||||
|
||||
### Optional Settings
|
||||
|
||||
|
||||
+4
-4
@@ -54,10 +54,10 @@ For more details on contact points, including how to test them and enable notifi
|
||||
|
||||
### Required Settings
|
||||
|
||||
| Option | Description |
|
||||
| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Broker URL | The URL of the MQTT broker. This field is [protected](ref:configure-contact-points#protected-fields) from modification in Grafana Cloud. |
|
||||
| Topic | The topic to which the message will be sent. |
|
||||
| Option | Description |
|
||||
| ---------- | ----------------------------------------------------------------------------------------------------------------------- |
|
||||
| Broker URL | The URL of the MQTT broker. This field is [protected](ref:configure-contact-points) from modification in Grafana Cloud. |
|
||||
| Topic | The topic to which the message will be sent. |
|
||||
|
||||
### Optional Settings
|
||||
|
||||
|
||||
+2
-2
@@ -51,8 +51,8 @@ You can customize the `title` and `body` of the Slack message using [notificatio
|
||||
|
||||
If you are using a Slack API Token, complete the following steps.
|
||||
|
||||
1. Follow steps 1 and 2 of the [Slack API Quickstart](https://api.slack.com/start/quickstart).
|
||||
1. Add the [chat:write.public](https://api.slack.com/scopes/chat:write.public) scope to give your app the ability to post in all public channels without joining.
|
||||
1. Follow step 1 of the [Slack API Quickstart](https://docs.slack.dev/app-management/quickstart-app-settings/#creating) to create the app.
|
||||
1. Continue onto the second step of the [Slack API Quickstart](https://docs.slack.dev/app-management/quickstart-app-settings/#scopes) and add the [chat:write.public](https://api.slack.com/scopes/chat:write.public) scope as described to give your app the ability to post in all public channels without joining.
|
||||
1. In OAuth Tokens for Your Workspace, copy the Bot User OAuth Token.
|
||||
1. Open your Slack workplace.
|
||||
1. Right click the channel you want to receive notifications in.
|
||||
|
||||
+3
-3
@@ -62,9 +62,9 @@ For more details on contact points, including how to test them and enable notifi
|
||||
|
||||
## Webhook settings
|
||||
|
||||
| Option | Description |
|
||||
| ------ | ----------------------------------------------------------------------------------------------------------------------------- |
|
||||
| URL | The Webhook URL. This field is [protected](ref:configure-contact-points#protected-fields) from modification in Grafana Cloud. |
|
||||
| Option | Description |
|
||||
| ------ | ------------------------------------------------------------------------------------------------------------ |
|
||||
| URL | The Webhook URL. This field is [protected](ref:configure-contact-points) from modification in Grafana Cloud. |
|
||||
|
||||
#### Optional settings
|
||||
|
||||
|
||||
+1
-1
@@ -81,7 +81,7 @@ Replace the placeholders with your values:
|
||||
|
||||
In your `grafana` directory, create a sub-folder called `dashboards`.
|
||||
|
||||
This guide shows you how to creates three separate dashboards. For all dashboard configurations, replace the placeholders with your values:
|
||||
This guide shows you how to create three separate dashboards. For all dashboard configurations, replace the placeholders with your values:
|
||||
|
||||
- _`<GRAFANA_CLOUD_STACK_NAME>`_: Name of your Grafana Cloud Stack
|
||||
- _`<GRAFANA_OPERATOR_NAMESPACE>`_: Namespace where the `grafana-operator` is deployed in your Kubernetes cluster
|
||||
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
---
|
||||
title: Git Sync deployment scenarios
|
||||
menuTitle: Deployment scenarios
|
||||
description: Learn about common Git Sync deployment patterns and configurations for different organizational needs
|
||||
weight: 450
|
||||
keywords:
|
||||
- git sync
|
||||
- deployment patterns
|
||||
- scenarios
|
||||
- multi-environment
|
||||
- teams
|
||||
---
|
||||
|
||||
# Git Sync deployment scenarios
|
||||
|
||||
This guide shows practical deployment scenarios for Grafana’s Git Sync. Learn how to configure bidirectional synchronization between Grafana and Git repositories for teams, environments, and regions.
|
||||
|
||||
{{< admonition type="caution" >}}
|
||||
Git Sync is an experimental feature. It reflects Grafana’s approach to Observability as Code and might include limitations or breaking changes. For current status and known limitations, refer to the [Git Sync introduction](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/as-code/observability-as-code/provision-resources/intro-git-sync/).
|
||||
{{< /admonition >}}
|
||||
|
||||
## Understand the relationship between key Git Sync components
|
||||
|
||||
Before you explore the scenarios, understand how the key Git Sync components relate:
|
||||
|
||||
- [Grafana instance](#grafana-instance)
|
||||
- [Git repository structure](#git-repository-structure)
|
||||
- [Git Sync repository resource](#git-sync-repository-resource)
|
||||
|
||||
### Grafana instance
|
||||
|
||||
A Grafana instance is a running Grafana server. Multiple instances can:
|
||||
|
||||
- Connect to the same Git repository using different Repository configurations.
|
||||
- Sync from different branches of the same repository.
|
||||
- Sync from different paths within the same repository.
|
||||
- Sync from different repositories.
|
||||
|
||||
### Git repository structure
|
||||
|
||||
You can organize your Git repository in several ways:
|
||||
|
||||
- Single branch, multiple paths: Use different directories for different purposes (for example, `dev/`, `prod/`, `team-a/`).
|
||||
- Multiple branches: Use different branches for different environments or teams (for example, `main`, `develop`, `team-a`).
|
||||
- Multiple repositories: Use separate repositories for different teams or environments.
|
||||
|
||||
### Git Sync repository resource
|
||||
|
||||
A repository resource is a Grafana configuration object that defines:
|
||||
|
||||
- Which Git repository to sync with.
|
||||
- Which branch to use.
|
||||
- Which directory path to synchronize.
|
||||
- Sync behavior and workflows.
|
||||
|
||||
Each repository resource creates bidirectional synchronization between a Grafana instance and a specific location in Git.
|
||||
|
||||
## How does repository sync behave?
|
||||
|
||||
With Git Sync you configure a repository resource to sync with your Grafana instance:
|
||||
|
||||
1. Grafana monitors the specified Git location (repository, branch, and path).
|
||||
2. Grafana creates a folder in Dashboards (typically named after the repository).
|
||||
3. Grafana creates dashboards from dashboard JSON files in Git within this folder.
|
||||
4. Grafana commits dashboard changes made in the UI back to Git.
|
||||
5. Grafana pulls dashboard changes made in Git and updates dashboards in the UI.
|
||||
6. Synchronization occurs at regular intervals (configurable), or instantly if you use webhooks.
|
||||
|
||||
You can find the provisioned dashboards organized in folders under **Dashboards**.
|
||||
|
||||
## Example: Relationship between repository, branch, and path
|
||||
|
||||
Here's a concrete example showing how the three parameters work together:
|
||||
|
||||
**Configuration:**
|
||||
|
||||
- **Repository**: `your-org/grafana-manifests`
|
||||
- **Branch**: `main`
|
||||
- **Path**: `team-platform/grafana/`
|
||||
|
||||
**In Git (on branch `main`):**
|
||||
|
||||
```
|
||||
your-org/grafana-manifests/
|
||||
├── .git/
|
||||
├── README.md
|
||||
├── team-platform/
|
||||
│ └── grafana/
|
||||
│ ├── cpu-metrics.json ← Synced
|
||||
│ ├── memory-usage.json ← Synced
|
||||
│ └── disk-io.json ← Synced
|
||||
├── team-data/
|
||||
│ └── grafana/
|
||||
│ └── pipeline-stats.json ← Not synced (different path)
|
||||
└── other-files.txt ← Not synced (outside path)
|
||||
```
|
||||
|
||||
**In Grafana Dashboards view:**
|
||||
|
||||
```
|
||||
Dashboards
|
||||
└── 📁 grafana-manifests/
|
||||
├── CPU Metrics Dashboard
|
||||
├── Memory Usage Dashboard
|
||||
└── Disk I/O Dashboard
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
|
||||
- Grafana only synchronizes files within the specified path (`team-platform/grafana/`).
|
||||
- Grafana ignores files in other paths or at the repository root.
|
||||
- The folder name in Grafana comes from the repository name.
|
||||
- Dashboard titles come from the JSON file content, not the filename.
|
||||
|
||||
## Repository configuration flexibility
|
||||
|
||||
Git Sync repositories support different combinations of repository URL, branch, and path:
|
||||
|
||||
- Different Git repositories: Each environment or team can use its own repository.
|
||||
- Instance A: `repository: your-org/grafana-prod`.
|
||||
- Instance B: `repository: your-org/grafana-dev`.
|
||||
- Different branches: Use separate branches within the same repository.
|
||||
- Instance A: `repository: your-org/grafana-manifests, branch: main`.
|
||||
- Instance B: `repository: your-org/grafana-manifests, branch: develop`.
|
||||
- Different paths: Use different directory paths within the same repository.
|
||||
- Instance A: `repository: your-org/grafana-manifests, branch: main, path: production/`.
|
||||
- Instance B: `repository: your-org/grafana-manifests, branch: main, path: development/`.
|
||||
- Any combination: Mix and match based on your workflow requirements.
|
||||
|
||||
## Scenarios
|
||||
|
||||
Use these deployment scenarios to plan your Git Sync setup:
|
||||
|
||||
- [Single instance](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/as-code/observability-as-code/provision-resources/git-sync-deployment-scenarios/single-instance/)
|
||||
- [Git Sync for development and production environments](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/as-code/observability-as-code/provision-resources/git-sync-deployment-scenarios/dev-prod/)
|
||||
- [Git Sync with regional replication](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/as-code/observability-as-code/provision-resources/git-sync-deployment-scenarios/multi-region/)
|
||||
- [High availability](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/as-code/observability-as-code/provision-resources/git-sync-deployment-scenarios/high-availability/)
|
||||
- [Git Sync in a shared Grafana instance](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/as-code/observability-as-code/provision-resources/git-sync-deployment-scenarios/multi-team/)
|
||||
|
||||
## Learn more
|
||||
|
||||
Refer to the following documents to learn more:
|
||||
|
||||
- [Git Sync introduction](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/as-code/observability-as-code/provision-resources/intro-git-sync/)
|
||||
- [Git Sync setup guide](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/as-code/observability-as-code/provision-resources/git-sync-setup/)
|
||||
- [Dashboard provisioning](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/administration/provisioning/)
|
||||
- [Observability as Code](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/as-code/observability-as-code/)
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
---
|
||||
title: Git Sync for development and production environments
|
||||
menuTitle: Across environments
|
||||
description: Use separate Grafana instances for development and production with Git-controlled promotion
|
||||
weight: 20
|
||||
---
|
||||
|
||||
# Git Sync for development and production environments
|
||||
|
||||
Use separate Grafana instances for development and production. Each syncs with different Git locations to test dashboards before production.
|
||||
|
||||
## Use it for
|
||||
|
||||
- **Staged deployments**: You need to test dashboard changes before production deployment.
|
||||
- **Change control**: You require approvals before dashboards reach production.
|
||||
- **Quality assurance**: You verify dashboard functionality in a non-production environment.
|
||||
- **Risk mitigation**: You minimize the risk of breaking production dashboards.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ GitHub Repository │
|
||||
│ Repository: your-org/grafana-manifests │
|
||||
│ Branch: main │
|
||||
│ │
|
||||
│ grafana-manifests/ │
|
||||
│ ├── dev/ │
|
||||
│ │ ├── dashboard-new.json ← Development dashboards │
|
||||
│ │ └── dashboard-test.json │
|
||||
│ │ │
|
||||
│ └── prod/ │
|
||||
│ ├── dashboard-stable.json ← Production dashboards │
|
||||
│ └── dashboard-approved.json │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
↕ ↕
|
||||
Git Sync (dev/) Git Sync (prod/)
|
||||
↕ ↕
|
||||
┌─────────────────────┐ ┌─────────────────────┐
|
||||
│ Dev Grafana │ │ Prod Grafana │
|
||||
│ │ │ │
|
||||
│ Repository: │ │ Repository: │
|
||||
│ - path: dev/ │ │ - path: prod/ │
|
||||
│ │ │ │
|
||||
│ Creates folder: │ │ Creates folder: │
|
||||
│ "grafana-manifests"│ │ "grafana-manifests"│
|
||||
└─────────────────────┘ └─────────────────────┘
|
||||
```
|
||||
|
||||
## Repository structure
|
||||
|
||||
**In Git:**
|
||||
|
||||
```
|
||||
your-org/grafana-manifests
|
||||
├── dev/
|
||||
│ ├── dashboard-new.json
|
||||
│ └── dashboard-test.json
|
||||
└── prod/
|
||||
├── dashboard-stable.json
|
||||
└── dashboard-approved.json
|
||||
```
|
||||
|
||||
**In Grafana Dashboards view:**
|
||||
|
||||
**Dev instance:**
|
||||
|
||||
```
|
||||
Dashboards
|
||||
└── 📁 grafana-manifests/
|
||||
├── New Dashboard
|
||||
└── Test Dashboard
|
||||
```
|
||||
|
||||
**Prod instance:**
|
||||
|
||||
```
|
||||
Dashboards
|
||||
└── 📁 grafana-manifests/
|
||||
├── Stable Dashboard
|
||||
└── Approved Dashboard
|
||||
```
|
||||
|
||||
- Both instances create a folder named "grafana-manifests" (from repository name)
|
||||
- Each instance only shows dashboards from its configured path (`dev/` or `prod/`)
|
||||
- Dashboards appear with their titles from the JSON files
|
||||
|
||||
## Configuration parameters
|
||||
|
||||
Development:
|
||||
|
||||
- Repository: `your-org/grafana-manifests`
|
||||
- Branch: `main`
|
||||
- Path: `dev/`
|
||||
|
||||
Production:
|
||||
|
||||
- Repository: `your-org/grafana-manifests`
|
||||
- Branch: `main`
|
||||
- Path: `prod/`
|
||||
|
||||
## How it works
|
||||
|
||||
1. Developers create and modify dashboards in development.
|
||||
2. Git Sync commits changes to `dev/`.
|
||||
3. You review changes in Git.
|
||||
4. You promote approved dashboards from `dev/` to `prod/`.
|
||||
5. Production syncs from `prod/`.
|
||||
6. Production dashboards update.
|
||||
|
||||
## Alternative: Use branches
|
||||
|
||||
Instead of using different paths, you can configure instances to use different branches:
|
||||
|
||||
**Development instance:**
|
||||
|
||||
- **Repository**: `your-org/grafana-manifests`
|
||||
- **Branch**: `develop`
|
||||
- **Path**: `grafana/`
|
||||
|
||||
**Production instance:**
|
||||
|
||||
- **Repository**: `your-org/grafana-manifests`
|
||||
- **Branch**: `main`
|
||||
- **Path**: `grafana/`
|
||||
|
||||
With this approach:
|
||||
|
||||
- Development changes go to the `develop` branch
|
||||
- Use Git merge or pull request workflows to promote changes from `develop` to `main`
|
||||
- Production automatically syncs from the `main` branch
|
||||
|
||||
## Alternative: Use separate repositories for stricter isolation
|
||||
|
||||
For stricter isolation, use completely separate repositories:
|
||||
|
||||
**Development instance:**
|
||||
|
||||
- **Repository**: `your-org/grafana-manifests-dev`
|
||||
- **Branch**: `main`
|
||||
- **Path**: `grafana/`
|
||||
|
||||
**Production instance:**
|
||||
|
||||
- **Repository**: `your-org/grafana-manifests-prod`
|
||||
- **Branch**: `main`
|
||||
- **Path**: `grafana/`
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
---
|
||||
title: Git Sync for high availability environments
|
||||
menuTitle: High availability
|
||||
description: Run multiple Grafana instances serving traffic simultaneously, synchronized via Git Sync
|
||||
weight: 50
|
||||
---
|
||||
|
||||
# Git Sync for high availability environments
|
||||
|
||||
## Primary–replica scenario
|
||||
|
||||
Use a primary Grafana instance and one or more replicas synchronized with the same Git location to enable failover.
|
||||
|
||||
### Use it for
|
||||
|
||||
- **Automatic failover**: You need service continuity when the primary instance fails.
|
||||
- **High availability**: Your organization requires guaranteed dashboard availability.
|
||||
- **Simple HA setup**: You want high availability without the complexity of active–active.
|
||||
- **Maintenance windows**: You perform updates while another instance serves traffic.
|
||||
- **Business continuity**: Dashboard access can't tolerate downtime.
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ GitHub Repository │
|
||||
│ Repository: your-org/grafana-manifests │
|
||||
│ Branch: main │
|
||||
│ │
|
||||
│ grafana-manifests/ │
|
||||
│ └── shared/ │
|
||||
│ ├── dashboard-metrics.json │
|
||||
│ ├── dashboard-alerts.json │
|
||||
│ └── dashboard-logs.json │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
↕ ↕
|
||||
Git Sync (shared/) Git Sync (shared/)
|
||||
↕ ↕
|
||||
┌────────────────────┐ ┌────────────────────┐
|
||||
│ Master Grafana │ │ Replica Grafana │
|
||||
│ (Active) │ │ (Standby) │
|
||||
│ │ │ │
|
||||
│ Repository: │ │ Repository: │
|
||||
│ - path: shared/ │ │ - path: shared/ │
|
||||
└────────────────────┘ └────────────────────┘
|
||||
│ │
|
||||
└───────────┬───────────────────┘
|
||||
↓
|
||||
┌──────────────────────┐
|
||||
│ Reverse Proxy │
|
||||
│ (Failover) │
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
### Repository structure
|
||||
|
||||
**In Git:**
|
||||
|
||||
```
|
||||
your-org/grafana-manifests
|
||||
└── shared/
|
||||
├── dashboard-metrics.json
|
||||
├── dashboard-alerts.json
|
||||
└── dashboard-logs.json
|
||||
```
|
||||
|
||||
**In Grafana Dashboards view (both instances):**
|
||||
|
||||
```
|
||||
Dashboards
|
||||
└── 📁 grafana-manifests/
|
||||
├── Metrics Dashboard
|
||||
├── Alerts Dashboard
|
||||
└── Logs Dashboard
|
||||
```
|
||||
|
||||
- Master and replica instances show identical folder structure.
|
||||
- Both sync from the same `shared/` path.
|
||||
- Reverse proxy routes traffic to master (active) instance.
|
||||
- If master fails, proxy automatically fails over to replica (standby).
|
||||
- Users see the same dashboards regardless of which instance is serving traffic.
|
||||
|
||||
### Configuration parameters
|
||||
|
||||
Both master and replica instances use identical parameters:
|
||||
|
||||
**Master instance:**
|
||||
|
||||
- **Repository**: `your-org/grafana-manifests`
|
||||
- **Branch**: `main`
|
||||
- **Path**: `shared/`
|
||||
|
||||
**Replica instance:**
|
||||
|
||||
- **Repository**: `your-org/grafana-manifests`
|
||||
- **Branch**: `main`
|
||||
- **Path**: `shared/`
|
||||
|
||||
### How it works
|
||||
|
||||
1. Both instances stay synchronized through Git.
|
||||
2. Reverse proxy routes traffic to primary.
|
||||
3. Users edit on primary. Git Sync commits changes.
|
||||
4. Both instances pull latest changes to keep replica in sync.
|
||||
5. On primary failure, proxy fails over to replica.
|
||||
|
||||
### Failover considerations
|
||||
|
||||
- Health checks and monitoring.
|
||||
- Continuous syncing to minimize data loss.
|
||||
- Plan failback (automatic or manual).
|
||||
|
||||
## Load balancer scenario
|
||||
|
||||
Run multiple active Grafana instances behind a load balancer. All instances sync from the same Git location.
|
||||
|
||||
### Use it for
|
||||
|
||||
- **High traffic**: Your deployment needs to handle significant user load.
|
||||
- **Load distribution**: You want to distribute user requests across instances.
|
||||
- **Maximum availability**: You need service continuity during maintenance or failures.
|
||||
- **Scalability**: You want to add instances as load increases.
|
||||
- **Performance**: Users need fast response times under heavy load.
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ GitHub Repository │
|
||||
│ Repository: your-org/grafana-manifests │
|
||||
│ Branch: main │
|
||||
│ │
|
||||
│ grafana-manifests/ │
|
||||
│ └── shared/ │
|
||||
│ ├── dashboard-metrics.json │
|
||||
│ ├── dashboard-alerts.json │
|
||||
│ └── dashboard-logs.json │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
↕ ↕
|
||||
Git Sync (shared/) Git Sync (shared/)
|
||||
↕ ↕
|
||||
┌────────────────────┐ ┌────────────────────┐
|
||||
│ Grafana Instance 1│ │ Grafana Instance 2│
|
||||
│ (Active) │ │ (Active) │
|
||||
│ │ │ │
|
||||
│ Repository: │ │ Repository: │
|
||||
│ - path: shared/ │ │ - path: shared/ │
|
||||
└────────────────────┘ └────────────────────┘
|
||||
│ │
|
||||
└───────────┬───────────────────┘
|
||||
↓
|
||||
┌──────────────────────┐
|
||||
│ Load Balancer │
|
||||
│ (Round Robin) │
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
### Repository structure
|
||||
|
||||
**In Git:**
|
||||
|
||||
```
|
||||
your-org/grafana-manifests
|
||||
└── shared/
|
||||
├── dashboard-metrics.json
|
||||
├── dashboard-alerts.json
|
||||
└── dashboard-logs.json
|
||||
```
|
||||
|
||||
**In Grafana Dashboards view (all instances):**
|
||||
|
||||
```
|
||||
Dashboards
|
||||
└── 📁 grafana-manifests/
|
||||
├── Metrics Dashboard
|
||||
├── Alerts Dashboard
|
||||
└── Logs Dashboard
|
||||
```
|
||||
|
||||
- All instances show identical folder structure.
|
||||
- All instances sync from the same `shared/` path.
|
||||
- Load balancer distributes requests across all active instances.
|
||||
- Any instance can serve read requests.
|
||||
- Any instance can accept dashboard modifications.
|
||||
- Changes propagate to all instances through Git.
|
||||
|
||||
### Configuration parameters
|
||||
|
||||
All instances use identical parameters:
|
||||
|
||||
**Instance 1:**
|
||||
|
||||
- **Repository**: `your-org/grafana-manifests`
|
||||
- **Branch**: `main`
|
||||
- **Path**: `shared/`
|
||||
|
||||
**Instance 2:**
|
||||
|
||||
- **Repository**: `your-org/grafana-manifests`
|
||||
- **Branch**: `main`
|
||||
- **Path**: `shared/`
|
||||
|
||||
### How it works
|
||||
|
||||
1. All instances stay synchronized through Git.
|
||||
2. Load balancer distributes incoming traffic across all active instances.
|
||||
3. Users can view dashboards from any instance.
|
||||
4. When a user modifies a dashboard on any instance, Git Sync commits the change.
|
||||
5. All other instances pull the updated dashboard during their next sync cycle, or instantly if webhooks are configured.
|
||||
6. If one instance fails, load balancer stops routing traffic to it and remaining instances continue serving.
|
||||
|
||||
### Important considerations
|
||||
|
||||
- **Eventually consistent**: Due to sync intervals, instances may briefly have different dashboard versions.
|
||||
- **Concurrent edits**: Multiple users editing the same dashboard on different instances can cause conflicts.
|
||||
- **Database sharing**: Instances should share the same backend database for user sessions, preferences, and annotations.
|
||||
- **Stateless design**: Design for stateless operation where possible to maximize load balancing effectiveness.
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
---
|
||||
title: Git Sync with regional replication
|
||||
menuTitle: Regional replication
|
||||
description: Synchronize multiple regional Grafana instances from a shared Git location
|
||||
weight: 30
|
||||
---
|
||||
|
||||
# Git Sync with regional replication
|
||||
|
||||
Deploy multiple Grafana instances across regions. Synchronize them with the same Git location to ensure consistent dashboards everywhere.
|
||||
|
||||
## Use it for
|
||||
|
||||
- **Geographic distribution**: You deploy Grafana close to users in different regions.
|
||||
- **Latency reduction**: Users need fast dashboard access from their location.
|
||||
- **Data sovereignty**: You keep dashboard data in specific regions.
|
||||
- **High availability**: You need dashboard availability across regions.
|
||||
- **Consistent experience**: All users see the same dashboards regardless of region.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ GitHub Repository │
|
||||
│ Repository: your-org/grafana-manifests │
|
||||
│ Branch: main │
|
||||
│ │
|
||||
│ grafana-manifests/ │
|
||||
│ └── shared/ │
|
||||
│ ├── dashboard-global.json │
|
||||
│ ├── dashboard-metrics.json │
|
||||
│ └── dashboard-logs.json │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
↕ ↕
|
||||
Git Sync (shared/) Git Sync (shared/)
|
||||
↕ ↕
|
||||
┌────────────────────┐ ┌────────────────────┐
|
||||
│ US Region │ │ EU Region │
|
||||
│ Grafana │ │ Grafana │
|
||||
│ │ │ │
|
||||
│ Repository: │ │ Repository: │
|
||||
│ - path: shared/ │ │ - path: shared/ │
|
||||
└────────────────────┘ └────────────────────┘
|
||||
```
|
||||
|
||||
## Repository structure
|
||||
|
||||
**In Git:**
|
||||
|
||||
```
|
||||
your-org/grafana-manifests
|
||||
└── shared/
|
||||
├── dashboard-global.json
|
||||
├── dashboard-metrics.json
|
||||
└── dashboard-logs.json
|
||||
```
|
||||
|
||||
**In Grafana Dashboards view (all regions):**
|
||||
|
||||
```
|
||||
Dashboards
|
||||
└── 📁 grafana-manifests/
|
||||
├── Global Dashboard
|
||||
├── Metrics Dashboard
|
||||
└── Logs Dashboard
|
||||
```
|
||||
|
||||
- All regional instances (US, EU, etc.) show identical folder structure
|
||||
- Same folder name "grafana-manifests" in every region
|
||||
- Same dashboards synced from the `shared/` path appear everywhere
|
||||
- Users in any region see the exact same dashboards with the same titles
|
||||
|
||||
## Configuration parameters
|
||||
|
||||
All regions:
|
||||
|
||||
- Repository: `your-org/grafana-manifests`
|
||||
- Branch: `main`
|
||||
- Path: `shared/`
|
||||
|
||||
## How it works
|
||||
|
||||
1. All regional instances pull dashboards from `shared/`.
|
||||
2. Any region’s change commits to Git.
|
||||
3. Other regions pull updates during the next sync (or via webhooks).
|
||||
4. Changes propagate across regions per sync interval.
|
||||
|
||||
## Considerations
|
||||
|
||||
- **Write conflicts**: If users in different regions modify the same dashboard simultaneously, Git uses last-write-wins.
|
||||
- **Primary region**: Consider designating one region as the primary location for making dashboard changes.
|
||||
- **Propagation time**: Changes propagate to all regions within the configured sync interval, or instantly if webhooks are configured.
|
||||
- **Network reliability**: Ensure all regions have reliable connectivity to the Git repository.
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
---
|
||||
title: Multiple team Git Sync
|
||||
menuTitle: Shared instance
|
||||
description: Use multiple Git repositories with one Grafana instance, one repository per team
|
||||
weight: 60
|
||||
---
|
||||
|
||||
# Git Sync in a Grafana instance shared by multiple teams
|
||||
|
||||
Use a single Grafana instance with multiple Repository resources, one per team. Each team manages its own dashboards while sharing Grafana.
|
||||
|
||||
## Use it for
|
||||
|
||||
- **Team autonomy**: Different teams manage their own dashboards independently.
|
||||
- **Organizational structure**: Dashboard organization aligns with team structure.
|
||||
- **Resource efficiency**: Multiple teams share Grafana infrastructure.
|
||||
- **Cost optimization**: You reduce infrastructure costs while maintaining team separation.
|
||||
- **Collaboration**: Teams can view each other’s dashboards while managing their own.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────┐ ┌─────────────────────────┐
|
||||
│ Platform Team Repo │ │ Data Team Repo │
|
||||
│ platform-dashboards │ │ data-dashboards │
|
||||
│ │ │ │
|
||||
│ platform-dashboards/ │ │ data-dashboards/ │
|
||||
│ └── grafana/ │ │ └── grafana/ │
|
||||
│ ├── k8s.json │ │ ├── pipeline.json │
|
||||
│ └── infra.json │ │ └── analytics.json │
|
||||
└─────────────────────────┘ └─────────────────────────┘
|
||||
↕ ↕
|
||||
Git Sync (grafana/) Git Sync (grafana/)
|
||||
↕ ↕
|
||||
┌──────────────────────────────────────┐
|
||||
│ Grafana Instance │
|
||||
│ │
|
||||
│ Repository 1: │
|
||||
│ - repo: platform-dashboards │
|
||||
│ → Creates "platform-dashboards" │
|
||||
│ │
|
||||
│ Repository 2: │
|
||||
│ - repo: data-dashboards │
|
||||
│ → Creates "data-dashboards" │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Repository structure
|
||||
|
||||
**In Git (separate repositories):**
|
||||
|
||||
**Platform team repository:**
|
||||
|
||||
```
|
||||
your-org/platform-dashboards
|
||||
└── grafana/
|
||||
├── dashboard-k8s.json
|
||||
└── dashboard-infra.json
|
||||
```
|
||||
|
||||
**Data team repository:**
|
||||
|
||||
```
|
||||
your-org/data-dashboards
|
||||
└── grafana/
|
||||
├── dashboard-pipeline.json
|
||||
└── dashboard-analytics.json
|
||||
```
|
||||
|
||||
**In Grafana Dashboards view:**
|
||||
|
||||
```
|
||||
Dashboards
|
||||
├── 📁 platform-dashboards/
|
||||
│ ├── Kubernetes Dashboard
|
||||
│ └── Infrastructure Dashboard
|
||||
└── 📁 data-dashboards/
|
||||
├── Pipeline Dashboard
|
||||
└── Analytics Dashboard
|
||||
```
|
||||
|
||||
- Two separate folders created (one per Repository resource).
|
||||
- Folder names derived from repository names.
|
||||
- Each team has complete control over their own repository.
|
||||
- Teams can independently manage permissions, branches, and workflows in their repos.
|
||||
- All teams can view each other's dashboards in Grafana but manage only their own.
|
||||
|
||||
## Configuration parameters
|
||||
|
||||
**Platform team repository:**
|
||||
|
||||
- **Repository**: `your-org/platform-dashboards`
|
||||
- **Branch**: `main`
|
||||
- **Path**: `grafana/`
|
||||
|
||||
**Data team repository:**
|
||||
|
||||
- **Repository**: `your-org/data-dashboards`
|
||||
- **Branch**: `main`
|
||||
- **Path**: `grafana/`
|
||||
|
||||
## How it works
|
||||
|
||||
1. Each team has their own Git repository for complete autonomy.
|
||||
2. Each repository resource in Grafana creates a separate folder.
|
||||
3. Platform team dashboards sync from `your-org/platform-dashboards` repository.
|
||||
4. Data team dashboards sync from `your-org/data-dashboards` repository.
|
||||
5. Teams can independently manage their repository settings, access controls, and workflows.
|
||||
6. All teams can view each other's dashboards in Grafana but edit only their own.
|
||||
|
||||
## Scale to more teams
|
||||
|
||||
Adding additional teams is straightforward. For a third team, create a new repository and configure:
|
||||
|
||||
- **Repository**: `your-org/security-dashboards`
|
||||
- **Branch**: `main`
|
||||
- **Path**: `grafana/`
|
||||
|
||||
This creates a new "security-dashboards" folder in the same Grafana instance.
|
||||
|
||||
## Alternative: Shared repository with different paths
|
||||
|
||||
For teams that prefer sharing a single repository, use different paths to separate team dashboards:
|
||||
|
||||
**In Git:**
|
||||
|
||||
```
|
||||
your-org/grafana-manifests
|
||||
├── team-platform/
|
||||
│ ├── dashboard-k8s.json
|
||||
│ └── dashboard-infra.json
|
||||
└── team-data/
|
||||
├── dashboard-pipeline.json
|
||||
└── dashboard-analytics.json
|
||||
```
|
||||
|
||||
**Configuration:**
|
||||
|
||||
**Platform team:**
|
||||
|
||||
- **Repository**: `your-org/grafana-manifests`
|
||||
- **Branch**: `main`
|
||||
- **Path**: `team-platform/`
|
||||
|
||||
**Data team:**
|
||||
|
||||
- **Repository**: `your-org/grafana-manifests`
|
||||
- **Branch**: `main`
|
||||
- **Path**: `team-data/`
|
||||
|
||||
This approach provides simpler repository management but less isolation between teams.
|
||||
|
||||
## Alternative: Different branches per team
|
||||
|
||||
For teams wanting their own branch in a shared repository:
|
||||
|
||||
**Platform team:**
|
||||
|
||||
- **Repository**: `your-org/grafana-manifests`
|
||||
- **Branch**: `team-platform`
|
||||
- **Path**: `grafana/`
|
||||
|
||||
**Data team:**
|
||||
|
||||
- **Repository**: `your-org/grafana-manifests`
|
||||
- **Branch**: `team-data`
|
||||
- **Path**: `grafana/`
|
||||
|
||||
This allows teams to use Git branch workflows for collaboration while sharing the same repository.
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
---
|
||||
title: Single instance Git Sync
|
||||
menuTitle: Single instance
|
||||
description: Synchronize a single Grafana instance with a Git repository
|
||||
weight: 10
|
||||
---
|
||||
|
||||
# Single instance Git Sync
|
||||
|
||||
Use a single Grafana instance synchronized with a Git repository. This is the foundation for Git Sync and helps you understand bidirectional synchronization.
|
||||
|
||||
## Use it for
|
||||
|
||||
- **Getting started**: You want to learn how Git Sync works before implementing complex scenarios.
|
||||
- **Personal projects**: Individual developers manage their own dashboards.
|
||||
- **Small teams**: You have a simple setup without multiple environments or complex workflows.
|
||||
- **Development environments**: You need quick prototyping and testing.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ GitHub Repository │
|
||||
│ Repository: your-org/grafana-manifests │
|
||||
│ Branch: main │
|
||||
│ │
|
||||
│ grafana-manifests/ │
|
||||
│ └── grafana/ │
|
||||
│ ├── dashboard-1.json │
|
||||
│ ├── dashboard-2.json │
|
||||
│ └── dashboard-3.json │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
↕
|
||||
Git Sync (bidirectional)
|
||||
↕
|
||||
┌─────────────────────────────┐
|
||||
│ Grafana Instance │
|
||||
│ │
|
||||
│ Repository Resource: │
|
||||
│ - url: grafana-manifests │
|
||||
│ - branch: main │
|
||||
│ - path: grafana/ │
|
||||
│ │
|
||||
│ Creates folder: │
|
||||
│ "grafana-manifests" │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
## Repository structure
|
||||
|
||||
**In Git:**
|
||||
|
||||
```
|
||||
your-org/grafana-manifests
|
||||
└── grafana/
|
||||
├── dashboard-1.json
|
||||
├── dashboard-2.json
|
||||
└── dashboard-3.json
|
||||
```
|
||||
|
||||
**In Grafana Dashboards view:**
|
||||
|
||||
```
|
||||
Dashboards
|
||||
└── 📁 grafana-manifests/
|
||||
├── Dashboard 1
|
||||
├── Dashboard 2
|
||||
└── Dashboard 3
|
||||
```
|
||||
|
||||
- A folder named "grafana-manifests" (from repository name) contains all synced dashboards.
|
||||
- Each JSON file becomes a dashboard with its title displayed in the folder.
|
||||
- Users browse dashboards organized under this folder structure.
|
||||
|
||||
## Configuration parameters
|
||||
|
||||
Configure your Grafana instance to synchronize with:
|
||||
|
||||
- **Repository**: `your-org/grafana-manifests`
|
||||
- **Branch**: `main`
|
||||
- **Path**: `grafana/`
|
||||
|
||||
## How it works
|
||||
|
||||
1. **From Grafana to Git**: When users create or modify dashboards in Grafana, Git Sync commits changes to the `grafana/` directory on the `main` branch.
|
||||
2. **From Git to Grafana**: When dashboard JSON files are added or modified in the `grafana/` directory, Git Sync pulls these changes into Grafana.
|
||||
@@ -367,5 +367,6 @@ To learn more about using Git Sync:
|
||||
|
||||
- [Work with provisioned dashboards](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/as-code/observability-as-code/provision-resources/provisioned-dashboards/)
|
||||
- [Manage provisioned repositories with Git Sync](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/as-code/observability-as-code/provision-resources/use-git-sync/)
|
||||
- [Git Sync deployment scenarios](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/as-code/observability-as-code/provision-resources/git-sync-deployment-scenarios)
|
||||
- [Export resources](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/as-code/observability-as-code/provision-resources/export-resources/)
|
||||
- [grafanactl documentation](https://grafana.github.io/grafanactl/)
|
||||
|
||||
@@ -127,7 +127,13 @@ An instance can be in one of the following Git Sync states:
|
||||
|
||||
## Common use cases
|
||||
|
||||
You can use Git Sync in the following scenarios.
|
||||
{{< admonition type="note" >}}
|
||||
|
||||
Refer to [Git Sync deployment scenarios](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/as-code/observability-as-code/provision-resources/git-sync-deployment-scenarios) for sample scenarios, including architecture and configuration details.
|
||||
|
||||
{{< /admonition >}}
|
||||
|
||||
You can use Git Sync for the following use cases:
|
||||
|
||||
### Version control and auditing
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ labels:
|
||||
- cloud
|
||||
title: Manage provisioned repositories with Git Sync
|
||||
menuTitle: Manage repositories with Git Sync
|
||||
weight: 120
|
||||
weight: 400
|
||||
canonical: https://grafana.com/docs/grafana/latest/as-code/observability-as-code/provision-resources/use-git-sync/
|
||||
aliases:
|
||||
- ../../../observability-as-code/provision-resources/use-git-sync/ # /docs/grafana/next/observability-as-code/provision-resources/use-git-sync/
|
||||
|
||||
@@ -96,6 +96,7 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general-
|
||||
| `azureMonitorLogsBuilderEditor` | Enables the logs builder mode for the Azure Monitor data source |
|
||||
| `localeFormatPreference` | Specifies the locale so the correct format for numbers and dates can be shown |
|
||||
| `logsPanelControls` | Enables a control component for the logs panel in Explore |
|
||||
| `assistantStreaming` | Enables streaming functionality for Grafana Assistant |
|
||||
| `interactiveLearning` | Enables the interactive learning app |
|
||||
| `newVizSuggestions` | Enable new visualization suggestions |
|
||||
| `preventPanelChromeOverflow` | Restrict PanelChrome contents with overflow: hidden; |
|
||||
|
||||
+5
-1
@@ -1138,7 +1138,7 @@ export type JobResourceSummary = {
|
||||
delete?: number;
|
||||
/** Create or update (export) */
|
||||
error?: number;
|
||||
/** Report errors for this resource type This may not be an exhaustive list and recommend looking at the logs for more info */
|
||||
/** Report errors/warnings for this resource type This may not be an exhaustive list and recommend looking at the logs for more info */
|
||||
errors?: string[];
|
||||
group?: string;
|
||||
kind?: string;
|
||||
@@ -1146,6 +1146,9 @@ export type JobResourceSummary = {
|
||||
noop?: number;
|
||||
total?: number;
|
||||
update?: number;
|
||||
/** The error count */
|
||||
warning?: number;
|
||||
warnings?: string[];
|
||||
write?: number;
|
||||
};
|
||||
export type RepositoryUrLs = {
|
||||
@@ -1176,6 +1179,7 @@ export type JobStatus = {
|
||||
summary?: JobResourceSummary[];
|
||||
/** URLs contains URLs for the reference branch or commit if applicable. */
|
||||
url?: RepositoryUrLs;
|
||||
warnings?: string[];
|
||||
};
|
||||
export type Job = {
|
||||
/** 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 */
|
||||
|
||||
@@ -937,6 +937,11 @@ export interface FeatureToggles {
|
||||
*/
|
||||
grafanaAssistantInProfilesDrilldown?: boolean;
|
||||
/**
|
||||
* Enables streaming functionality for Grafana Assistant
|
||||
* @default true
|
||||
*/
|
||||
assistantStreaming?: boolean;
|
||||
/**
|
||||
* Enables creating alerts from Tempo data source
|
||||
*/
|
||||
tempoAlerting?: boolean;
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface SparklineProps extends Themeable2 {
|
||||
sparkline: FieldSparkline;
|
||||
}
|
||||
|
||||
export const Sparkline: React.FC<SparklineProps> = memo((props) => {
|
||||
const SparklineFn: React.FC<SparklineProps> = memo((props) => {
|
||||
const { sparkline, config: fieldConfig, theme, width, height } = props;
|
||||
|
||||
const { frame: alignedDataFrame, warning } = prepareSeries(sparkline, fieldConfig);
|
||||
@@ -30,4 +30,14 @@ export const Sparkline: React.FC<SparklineProps> = memo((props) => {
|
||||
return <UPlotChart data={data} config={configBuilder} width={width} height={height} />;
|
||||
});
|
||||
|
||||
Sparkline.displayName = 'Sparkline';
|
||||
SparklineFn.displayName = 'Sparkline';
|
||||
|
||||
// we converted to function component above, but some apps extend Sparkline, so we need
|
||||
// to keep exporting a class component until those apps are all rolled out.
|
||||
// see https://github.com/grafana/app-observability-plugin/pull/2079
|
||||
// eslint-disable-next-line react-prefer-function-component/react-prefer-function-component
|
||||
export class Sparkline extends React.PureComponent<SparklineProps> {
|
||||
render() {
|
||||
return <SparklineFn {...this.props} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package auditing
|
||||
|
||||
import (
|
||||
auditinternal "k8s.io/apiserver/pkg/apis/audit"
|
||||
"k8s.io/apiserver/pkg/audit"
|
||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||
)
|
||||
|
||||
// NoopBackend is a no-op implementation of audit.Backend
|
||||
type NoopBackend struct{}
|
||||
|
||||
func ProvideNoopBackend() audit.Backend { return &NoopBackend{} }
|
||||
|
||||
func (b *NoopBackend) ProcessEvents(k8sEvents ...*auditinternal.Event) bool { return false }
|
||||
|
||||
func (NoopBackend) Run(stopCh <-chan struct{}) error { return nil }
|
||||
|
||||
func (NoopBackend) Shutdown() {}
|
||||
|
||||
func (NoopBackend) String() string { return "" }
|
||||
|
||||
// NoopPolicyRuleEvaluator is a no-op implementation of audit.PolicyRuleEvaluator
|
||||
type NoopPolicyRuleEvaluator struct{}
|
||||
|
||||
func ProvideNoopPolicyRuleEvaluator() audit.PolicyRuleEvaluator { return &NoopPolicyRuleEvaluator{} }
|
||||
|
||||
func (NoopPolicyRuleEvaluator) EvaluatePolicyRule(authorizer.Attributes) audit.RequestAuditConfig {
|
||||
return audit.RequestAuditConfig{Level: auditinternal.LevelNone}
|
||||
}
|
||||
@@ -61,20 +61,24 @@ func (s *legacyStorage) List(ctx context.Context, options *internalversion.ListO
|
||||
}
|
||||
|
||||
func (s *legacyStorage) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Get"), time.Since(start).Seconds())
|
||||
}()
|
||||
if s.dsConfigHandlerRequestsDuration != nil {
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Get"), time.Since(start).Seconds())
|
||||
}()
|
||||
}
|
||||
|
||||
return s.datasources.GetDataSource(ctx, name)
|
||||
}
|
||||
|
||||
// Create implements rest.Creater.
|
||||
func (s *legacyStorage) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) {
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Create"), time.Since(start).Seconds())
|
||||
}()
|
||||
if s.dsConfigHandlerRequestsDuration != nil {
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Create"), time.Since(start).Seconds())
|
||||
}()
|
||||
}
|
||||
|
||||
ds, ok := obj.(*v0alpha1.DataSource)
|
||||
if !ok {
|
||||
@@ -85,10 +89,12 @@ func (s *legacyStorage) Create(ctx context.Context, obj runtime.Object, createVa
|
||||
|
||||
// Update implements rest.Updater.
|
||||
func (s *legacyStorage) Update(ctx context.Context, name string, objInfo rest.UpdatedObjectInfo, createValidation rest.ValidateObjectFunc, updateValidation rest.ValidateObjectUpdateFunc, forceAllowCreate bool, options *metav1.UpdateOptions) (runtime.Object, bool, error) {
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Create"), time.Since(start).Seconds())
|
||||
}()
|
||||
if s.dsConfigHandlerRequestsDuration != nil {
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Create"), time.Since(start).Seconds())
|
||||
}()
|
||||
}
|
||||
|
||||
old, err := s.Get(ctx, name, &metav1.GetOptions{})
|
||||
if err != nil {
|
||||
@@ -126,10 +132,12 @@ func (s *legacyStorage) Update(ctx context.Context, name string, objInfo rest.Up
|
||||
|
||||
// Delete implements rest.GracefulDeleter.
|
||||
func (s *legacyStorage) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) {
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Create"), time.Since(start).Seconds())
|
||||
}()
|
||||
if s.dsConfigHandlerRequestsDuration != nil {
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Create"), time.Since(start).Seconds())
|
||||
}()
|
||||
}
|
||||
|
||||
err := s.datasources.DeleteDataSource(ctx, name)
|
||||
return nil, false, err
|
||||
|
||||
@@ -3,6 +3,7 @@ package datasource
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
|
||||
@@ -38,14 +39,14 @@ var (
|
||||
// DataSourceAPIBuilder is used just so wire has something unique to return
|
||||
type DataSourceAPIBuilder struct {
|
||||
datasourceResourceInfo utils.ResourceInfo
|
||||
|
||||
pluginJSON plugins.JSONData
|
||||
client PluginClient // will only ever be called with the same plugin id!
|
||||
datasources PluginDatasourceProvider
|
||||
contextProvider PluginContextWrapper
|
||||
accessControl accesscontrol.AccessControl
|
||||
queryTypes *queryV0.QueryTypeDefinitionList
|
||||
configCrudUseNewApis bool
|
||||
pluginJSON plugins.JSONData
|
||||
client PluginClient // will only ever be called with the same plugin id!
|
||||
datasources PluginDatasourceProvider
|
||||
contextProvider PluginContextWrapper
|
||||
accessControl accesscontrol.AccessControl
|
||||
queryTypes *queryV0.QueryTypeDefinitionList
|
||||
configCrudUseNewApis bool
|
||||
dataSourceCRUDMetric *prometheus.HistogramVec
|
||||
}
|
||||
|
||||
func RegisterAPIService(
|
||||
@@ -66,6 +67,16 @@ func RegisterAPIService(
|
||||
var err error
|
||||
var builder *DataSourceAPIBuilder
|
||||
|
||||
dataSourceCRUDMetric := metricutil.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Namespace: "grafana",
|
||||
Name: "ds_config_handler_requests_duration_seconds",
|
||||
Help: "Duration of requests handled by datasource configuration handlers",
|
||||
}, []string{"code_path", "handler"})
|
||||
regErr := reg.Register(dataSourceCRUDMetric)
|
||||
if regErr != nil && !errors.As(regErr, &prometheus.AlreadyRegisteredError{}) {
|
||||
return nil, regErr
|
||||
}
|
||||
|
||||
pluginJSONs, err := getDatasourcePlugins(pluginSources)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting list of datasource plugins: %s", err)
|
||||
@@ -91,6 +102,7 @@ func RegisterAPIService(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
builder.SetDataSourceCRUDMetrics(dataSourceCRUDMetric)
|
||||
|
||||
apiRegistrar.RegisterAPI(builder)
|
||||
}
|
||||
@@ -161,6 +173,10 @@ func (b *DataSourceAPIBuilder) GetGroupVersion() schema.GroupVersion {
|
||||
return b.datasourceResourceInfo.GroupVersion()
|
||||
}
|
||||
|
||||
func (b *DataSourceAPIBuilder) SetDataSourceCRUDMetrics(datasourceCRUDMetric *prometheus.HistogramVec) {
|
||||
b.dataSourceCRUDMetric = datasourceCRUDMetric
|
||||
}
|
||||
|
||||
func addKnownTypes(scheme *runtime.Scheme, gv schema.GroupVersion) {
|
||||
scheme.AddKnownTypes(gv,
|
||||
&datasourceV0.DataSource{},
|
||||
@@ -218,13 +234,9 @@ func (b *DataSourceAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver
|
||||
|
||||
if b.configCrudUseNewApis {
|
||||
legacyStore := &legacyStorage{
|
||||
datasources: b.datasources,
|
||||
resourceInfo: &ds,
|
||||
dsConfigHandlerRequestsDuration: metricutil.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Namespace: "grafana",
|
||||
Name: "ds_config_handler_requests_duration_seconds",
|
||||
Help: "Duration of requests handled by datasource configuration handlers",
|
||||
}, []string{"code_path", "handler"}),
|
||||
datasources: b.datasources,
|
||||
resourceInfo: &ds,
|
||||
dsConfigHandlerRequestsDuration: b.dataSourceCRUDMetric,
|
||||
}
|
||||
unified, err := grafanaregistry.NewRegistryStore(opts.Scheme, ds, opts.OptsGetter)
|
||||
if err != nil {
|
||||
|
||||
@@ -35,12 +35,13 @@ func maybeNotifyProgress(threshold time.Duration, fn ProgressFn) ProgressFn {
|
||||
|
||||
// FIXME: ProgressRecorder should be initialized in the queue
|
||||
type JobResourceResult struct {
|
||||
Name string
|
||||
Group string
|
||||
Kind string
|
||||
Path string
|
||||
Action repository.FileAction
|
||||
Error error
|
||||
Name string
|
||||
Group string
|
||||
Kind string
|
||||
Path string
|
||||
Action repository.FileAction
|
||||
Error error
|
||||
Warning error
|
||||
}
|
||||
|
||||
type jobProgressRecorder struct {
|
||||
@@ -193,6 +194,10 @@ func (r *jobProgressRecorder) updateSummary(result JobResourceResult) {
|
||||
errorMsg := fmt.Sprintf("%s (file: %s, name: %s, action: %s)", result.Error.Error(), result.Path, result.Name, result.Action)
|
||||
summary.Errors = append(summary.Errors, errorMsg)
|
||||
summary.Error++
|
||||
} else if result.Warning != nil {
|
||||
warningMsg := fmt.Sprintf("%s (file: %s, name: %s, action: %s)", result.Warning.Error(), result.Path, result.Name, result.Action)
|
||||
summary.Warnings = append(summary.Warnings, warningMsg)
|
||||
summary.Warning++
|
||||
} else {
|
||||
switch result.Action {
|
||||
case repository.FileActionDeleted:
|
||||
@@ -266,8 +271,17 @@ func (r *jobProgressRecorder) Complete(ctx context.Context, err error) provision
|
||||
jobStatus.Message = err.Error()
|
||||
}
|
||||
|
||||
jobStatus.Summary = r.summary()
|
||||
summaries := r.summary()
|
||||
jobStatus.Summary = summaries
|
||||
jobStatus.Errors = r.errors
|
||||
|
||||
// Extract warnings from summaries
|
||||
warnings := make([]string, 0)
|
||||
for _, summary := range summaries {
|
||||
warnings = append(warnings, summary.Warnings...)
|
||||
}
|
||||
jobStatus.Warnings = warnings
|
||||
|
||||
jobStatus.URLs = r.refURLs
|
||||
|
||||
tooManyErrors := r.maxErrors > 0 && r.errorCount >= r.maxErrors
|
||||
@@ -283,6 +297,9 @@ func (r *jobProgressRecorder) Complete(ctx context.Context, err error) provision
|
||||
jobStatus.Message = "completed with errors"
|
||||
jobStatus.State = provisioning.JobStateWarning
|
||||
}
|
||||
} else if len(jobStatus.Warnings) > 0 {
|
||||
jobStatus.State = provisioning.JobStateWarning
|
||||
jobStatus.Message = "completed with warnings"
|
||||
}
|
||||
|
||||
// Override message if progress have a more explicit message
|
||||
|
||||
@@ -2,9 +2,11 @@ package jobs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -83,3 +85,167 @@ func TestJobProgressRecorderCompleteIncludesRefURLs(t *testing.T) {
|
||||
assert.Equal(t, provisioning.JobStateSuccess, finalStatus.State)
|
||||
assert.Equal(t, "completed successfully", finalStatus.Message)
|
||||
}
|
||||
|
||||
func TestJobProgressRecorderWarningStatus(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a progress recorder
|
||||
mockProgressFn := func(ctx context.Context, status provisioning.JobStatus) error {
|
||||
return nil
|
||||
}
|
||||
recorder := newJobProgressRecorder(mockProgressFn).(*jobProgressRecorder)
|
||||
|
||||
// Record a result with a warning
|
||||
warningErr := errors.New("deprecated API used")
|
||||
result := JobResourceResult{
|
||||
Name: "test-resource",
|
||||
Group: "test.grafana.app",
|
||||
Kind: "Dashboard",
|
||||
Path: "dashboards/test.json",
|
||||
Action: repository.FileActionUpdated,
|
||||
Warning: warningErr,
|
||||
}
|
||||
recorder.Record(ctx, result)
|
||||
|
||||
// Record another result with a different warning
|
||||
warningErr2 := errors.New("missing optional field")
|
||||
result2 := JobResourceResult{
|
||||
Name: "test-resource-2",
|
||||
Group: "test.grafana.app",
|
||||
Kind: "Dashboard",
|
||||
Path: "dashboards/test2.json",
|
||||
Action: repository.FileActionCreated,
|
||||
Warning: warningErr2,
|
||||
}
|
||||
recorder.Record(ctx, result2)
|
||||
|
||||
// Record a result with a warning from a different resource type
|
||||
warningErr3 := errors.New("validation warning")
|
||||
result3 := JobResourceResult{
|
||||
Name: "test-resource-3",
|
||||
Group: "test.grafana.app",
|
||||
Kind: "DataSource",
|
||||
Path: "datasources/test.yaml",
|
||||
Action: repository.FileActionCreated,
|
||||
Warning: warningErr3,
|
||||
}
|
||||
recorder.Record(ctx, result3)
|
||||
|
||||
// Verify warnings are stored in summaries
|
||||
recorder.mu.RLock()
|
||||
require.Len(t, recorder.summaries, 2) // Dashboard and DataSource
|
||||
dashboardSummary := recorder.summaries["test.grafana.app:Dashboard"]
|
||||
require.NotNil(t, dashboardSummary)
|
||||
assert.Equal(t, int64(2), dashboardSummary.Warning)
|
||||
assert.Len(t, dashboardSummary.Warnings, 2)
|
||||
assert.Contains(t, dashboardSummary.Warnings[0], "deprecated API used")
|
||||
assert.Contains(t, dashboardSummary.Warnings[1], "missing optional field")
|
||||
|
||||
datasourceSummary := recorder.summaries["test.grafana.app:DataSource"]
|
||||
require.NotNil(t, datasourceSummary)
|
||||
assert.Equal(t, int64(1), datasourceSummary.Warning)
|
||||
assert.Len(t, datasourceSummary.Warnings, 1)
|
||||
assert.Contains(t, datasourceSummary.Warnings[0], "validation warning")
|
||||
recorder.mu.RUnlock()
|
||||
|
||||
// Complete the job
|
||||
finalStatus := recorder.Complete(ctx, nil)
|
||||
|
||||
// Verify the final status includes warnings
|
||||
require.NotNil(t, finalStatus.Warnings)
|
||||
assert.Len(t, finalStatus.Warnings, 3)
|
||||
assert.Contains(t, finalStatus.Warnings[0], "deprecated API used")
|
||||
assert.Contains(t, finalStatus.Warnings[1], "missing optional field")
|
||||
assert.Contains(t, finalStatus.Warnings[2], "validation warning")
|
||||
|
||||
// Verify the state is set to Warning
|
||||
assert.Equal(t, provisioning.JobStateWarning, finalStatus.State)
|
||||
assert.Equal(t, "completed with warnings", finalStatus.Message)
|
||||
|
||||
// Verify summaries are included
|
||||
require.Len(t, finalStatus.Summary, 2)
|
||||
|
||||
// Verify no errors were recorded
|
||||
assert.Empty(t, finalStatus.Errors)
|
||||
}
|
||||
|
||||
func TestJobProgressRecorderWarningWithErrors(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a progress recorder
|
||||
mockProgressFn := func(ctx context.Context, status provisioning.JobStatus) error {
|
||||
return nil
|
||||
}
|
||||
recorder := newJobProgressRecorder(mockProgressFn).(*jobProgressRecorder)
|
||||
|
||||
// Record a result with an error (errors take precedence)
|
||||
errorErr := errors.New("failed to process")
|
||||
result := JobResourceResult{
|
||||
Name: "test-resource",
|
||||
Group: "test.grafana.app",
|
||||
Kind: "Dashboard",
|
||||
Path: "dashboards/test.json",
|
||||
Action: repository.FileActionUpdated,
|
||||
Error: errorErr,
|
||||
}
|
||||
recorder.Record(ctx, result)
|
||||
|
||||
// Record a result with only a warning
|
||||
warningErr := errors.New("deprecated API used")
|
||||
result2 := JobResourceResult{
|
||||
Name: "test-resource-2",
|
||||
Group: "test.grafana.app",
|
||||
Kind: "Dashboard",
|
||||
Path: "dashboards/test2.json",
|
||||
Action: repository.FileActionCreated,
|
||||
Warning: warningErr,
|
||||
}
|
||||
recorder.Record(ctx, result2)
|
||||
|
||||
// Complete the job
|
||||
finalStatus := recorder.Complete(ctx, nil)
|
||||
|
||||
// When there are errors, the state should be Warning (not Error unless too many)
|
||||
// and warnings should still be included
|
||||
assert.Equal(t, provisioning.JobStateWarning, finalStatus.State)
|
||||
assert.Equal(t, "completed with errors", finalStatus.Message)
|
||||
assert.Len(t, finalStatus.Errors, 1)
|
||||
assert.Contains(t, finalStatus.Errors[0], "failed to process")
|
||||
|
||||
// Warnings should still be extracted from summaries
|
||||
require.NotNil(t, finalStatus.Warnings)
|
||||
assert.Len(t, finalStatus.Warnings, 1)
|
||||
assert.Contains(t, finalStatus.Warnings[0], "deprecated API used")
|
||||
}
|
||||
|
||||
func TestJobProgressRecorderWarningOnlyNoErrors(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a progress recorder
|
||||
mockProgressFn := func(ctx context.Context, status provisioning.JobStatus) error {
|
||||
return nil
|
||||
}
|
||||
recorder := newJobProgressRecorder(mockProgressFn).(*jobProgressRecorder)
|
||||
|
||||
// Record only warnings, no errors
|
||||
warningErr := errors.New("deprecated API used")
|
||||
result := JobResourceResult{
|
||||
Name: "test-resource",
|
||||
Group: "test.grafana.app",
|
||||
Kind: "Dashboard",
|
||||
Path: "dashboards/test.json",
|
||||
Action: repository.FileActionUpdated,
|
||||
Warning: warningErr,
|
||||
}
|
||||
recorder.Record(ctx, result)
|
||||
|
||||
// Complete the job
|
||||
finalStatus := recorder.Complete(ctx, nil)
|
||||
|
||||
// Verify the state is Warning (not Error) when only warnings exist
|
||||
assert.Equal(t, provisioning.JobStateWarning, finalStatus.State)
|
||||
assert.Equal(t, "completed with warnings", finalStatus.Message)
|
||||
assert.Empty(t, finalStatus.Errors)
|
||||
require.NotNil(t, finalStatus.Warnings)
|
||||
assert.Len(t, finalStatus.Warnings, 1)
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ func RegisterAPIService(features featuremgmt.FeatureToggles, apiregistration bui
|
||||
}
|
||||
|
||||
func (b *ServiceAPIBuilder) GetAuthorizer() authorizer.Authorizer {
|
||||
//nolint:staticcheck // not yet migrated to Resource Authorizer
|
||||
return roleauthorizer.NewRoleAuthorizer()
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package apiregistry
|
||||
import (
|
||||
"github.com/google/wire"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apiserver/auditing"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/collections"
|
||||
dashboardinternal "github.com/grafana/grafana/pkg/registry/apis/dashboard"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/datasource"
|
||||
@@ -33,6 +34,10 @@ var WireSetExts = wire.NewSet(
|
||||
|
||||
externalgroupmapping.ProvideNoopTeamGroupsREST,
|
||||
wire.Bind(new(externalgroupmapping.TeamGroupsHandler), new(*externalgroupmapping.NoopTeamGroupsREST)),
|
||||
|
||||
// Auditing Options
|
||||
auditing.ProvideNoopBackend,
|
||||
auditing.ProvideNoopPolicyRuleEvaluator,
|
||||
)
|
||||
|
||||
var provisioningExtras = wire.NewSet(
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||
restclient "k8s.io/client-go/rest"
|
||||
|
||||
"github.com/grafana/grafana-app-sdk/app"
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/appinstaller"
|
||||
roleauthorizer "github.com/grafana/grafana/pkg/services/apiserver/auth/authorizer"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
"github.com/grafana/grafana/pkg/services/correlations"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
@@ -60,6 +62,11 @@ func RegisterAppInstaller(
|
||||
return installer, nil
|
||||
}
|
||||
|
||||
func (a *AppInstaller) GetAuthorizer() authorizer.Authorizer {
|
||||
//nolint:staticcheck // not yet migrated to Resource Authorizer
|
||||
return roleauthorizer.NewRoleAuthorizer()
|
||||
}
|
||||
|
||||
func (a *AppInstaller) GetLegacyStorage(requested schema.GroupVersionResource) rest.Storage {
|
||||
kind := correlationsV0.CorrelationKind()
|
||||
gvr := schema.GroupVersionResource{
|
||||
|
||||
@@ -6,17 +6,20 @@ import (
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||
restclient "k8s.io/client-go/rest"
|
||||
|
||||
"github.com/grafana/grafana-app-sdk/app"
|
||||
appsdkapiserver "github.com/grafana/grafana-app-sdk/k8s/apiserver"
|
||||
"github.com/grafana/grafana-app-sdk/simple"
|
||||
|
||||
"github.com/grafana/grafana/apps/playlist/pkg/apis"
|
||||
playlistv0alpha1 "github.com/grafana/grafana/apps/playlist/pkg/apis/playlist/v0alpha1"
|
||||
playlistapp "github.com/grafana/grafana/apps/playlist/pkg/app"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/appinstaller"
|
||||
roleauthorizer "github.com/grafana/grafana/pkg/services/apiserver/auth/authorizer"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
playlistsvc "github.com/grafana/grafana/pkg/services/playlist"
|
||||
@@ -63,6 +66,11 @@ func RegisterAppInstaller(
|
||||
return installer, nil
|
||||
}
|
||||
|
||||
func (p *PlaylistAppInstaller) GetAuthorizer() authorizer.Authorizer {
|
||||
//nolint:staticcheck // not yet migrated to Resource Authorizer
|
||||
return roleauthorizer.NewRoleAuthorizer()
|
||||
}
|
||||
|
||||
// GetLegacyStorage returns the legacy storage for the playlist app.
|
||||
func (p *PlaylistAppInstaller) GetLegacyStorage(requested schema.GroupVersionResource) grafanarest.Storage {
|
||||
gvr := playlistv0alpha1.PlaylistKind().GroupVersionResource()
|
||||
|
||||
@@ -3,12 +3,14 @@ package quotas
|
||||
import (
|
||||
"github.com/grafana/grafana/apps/quotas/pkg/apis"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||
restclient "k8s.io/client-go/rest"
|
||||
|
||||
"github.com/grafana/grafana-app-sdk/app"
|
||||
appsdkapiserver "github.com/grafana/grafana-app-sdk/k8s/apiserver"
|
||||
"github.com/grafana/grafana-app-sdk/simple"
|
||||
quotasapp "github.com/grafana/grafana/apps/quotas/pkg/app"
|
||||
roleauthorizer "github.com/grafana/grafana/pkg/services/apiserver/auth/authorizer"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
@@ -22,6 +24,11 @@ type QuotasAppInstaller struct {
|
||||
cfg *setting.Cfg
|
||||
}
|
||||
|
||||
func (a *QuotasAppInstaller) GetAuthorizer() authorizer.Authorizer {
|
||||
//nolint:staticcheck // not yet migrated to Resource Authorizer
|
||||
return roleauthorizer.NewRoleAuthorizer()
|
||||
}
|
||||
|
||||
func RegisterAppInstaller(
|
||||
cfg *setting.Cfg,
|
||||
features featuremgmt.FeatureToggles,
|
||||
|
||||
Generated
+7
-2
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/api"
|
||||
"github.com/grafana/grafana/pkg/api/avatar"
|
||||
"github.com/grafana/grafana/pkg/api/routing"
|
||||
"github.com/grafana/grafana/pkg/apiserver/auditing"
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
"github.com/grafana/grafana/pkg/configprovider"
|
||||
"github.com/grafana/grafana/pkg/expr"
|
||||
@@ -831,7 +832,9 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
|
||||
}
|
||||
v2 := appregistry.ProvideAppInstallers(featureToggles, playlistAppInstaller, appInstaller, shortURLAppInstaller, alertingRulesAppInstaller, correlationsAppInstaller, alertingNotificationsAppInstaller, logsDrilldownAppInstaller, annotationAppInstaller, exampleAppInstaller, advisorAppInstaller, alertingHistorianAppInstaller, quotasAppInstaller)
|
||||
builderMetrics := builder.ProvideBuilderMetrics(registerer)
|
||||
apiserverService, err := apiserver.ProvideService(cfg, featureToggles, routeRegisterImpl, tracingService, serverLockService, sqlStore, kvStore, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, dualwriteService, resourceClient, inlineSecureValueSupport, eventualRestConfigProvider, v, eventualRestConfigProvider, registerer, aggregatorRunner, v2, builderMetrics)
|
||||
backend := auditing.ProvideNoopBackend()
|
||||
policyRuleEvaluator := auditing.ProvideNoopPolicyRuleEvaluator()
|
||||
apiserverService, err := apiserver.ProvideService(cfg, featureToggles, routeRegisterImpl, tracingService, serverLockService, sqlStore, kvStore, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, dualwriteService, resourceClient, inlineSecureValueSupport, eventualRestConfigProvider, v, eventualRestConfigProvider, registerer, aggregatorRunner, v2, builderMetrics, backend, policyRuleEvaluator)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1489,7 +1492,9 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
|
||||
}
|
||||
v2 := appregistry.ProvideAppInstallers(featureToggles, playlistAppInstaller, appInstaller, shortURLAppInstaller, alertingRulesAppInstaller, correlationsAppInstaller, alertingNotificationsAppInstaller, logsDrilldownAppInstaller, annotationAppInstaller, exampleAppInstaller, advisorAppInstaller, alertingHistorianAppInstaller, quotasAppInstaller)
|
||||
builderMetrics := builder.ProvideBuilderMetrics(registerer)
|
||||
apiserverService, err := apiserver.ProvideService(cfg, featureToggles, routeRegisterImpl, tracingService, serverLockService, sqlStore, kvStore, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, dualwriteService, resourceClient, inlineSecureValueSupport, eventualRestConfigProvider, v, eventualRestConfigProvider, registerer, aggregatorRunner, v2, builderMetrics)
|
||||
backend := auditing.ProvideNoopBackend()
|
||||
policyRuleEvaluator := auditing.ProvideNoopPolicyRuleEvaluator()
|
||||
apiserverService, err := apiserver.ProvideService(cfg, featureToggles, routeRegisterImpl, tracingService, serverLockService, sqlStore, kvStore, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, dualwriteService, resourceClient, inlineSecureValueSupport, eventualRestConfigProvider, v, eventualRestConfigProvider, registerer, aggregatorRunner, v2, builderMetrics, backend, policyRuleEvaluator)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -114,6 +114,8 @@ func RegisterAuthorizers(
|
||||
registrar.Register(gv, authorizer)
|
||||
logger.Debug("Registered authorizer", "group", gv.Group, "version", gv.Version, "app")
|
||||
}
|
||||
} else {
|
||||
panic("authorizer cannot be nil for api group: " + installer.GroupVersions()[0].Group)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ func TestRegisterAuthorizers(t *testing.T) {
|
||||
name string
|
||||
appInstallers []appsdkapiserver.AppInstaller
|
||||
expectedRegisters int
|
||||
expectedPanic bool
|
||||
}{
|
||||
{
|
||||
name: "empty installers list",
|
||||
@@ -30,7 +31,7 @@ func TestRegisterAuthorizers(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedRegisters: 0,
|
||||
expectedPanic: true,
|
||||
},
|
||||
{
|
||||
name: "single installer with authorizer provider",
|
||||
@@ -46,6 +47,20 @@ func TestRegisterAuthorizers(t *testing.T) {
|
||||
},
|
||||
expectedRegisters: 1,
|
||||
},
|
||||
{
|
||||
name: "single installer with invalid authorizer provider",
|
||||
appInstallers: []appsdkapiserver.AppInstaller{
|
||||
&mockAppInstallerWithAuth{
|
||||
mockAppInstaller: &mockAppInstaller{
|
||||
groupVersions: []schema.GroupVersion{
|
||||
{Group: "test.example.com", Version: "v1"},
|
||||
},
|
||||
},
|
||||
mockAuthorizer: nil,
|
||||
},
|
||||
},
|
||||
expectedPanic: true,
|
||||
},
|
||||
{
|
||||
name: "installer with multiple group versions",
|
||||
appInstallers: []appsdkapiserver.AppInstaller{
|
||||
@@ -63,7 +78,7 @@ func TestRegisterAuthorizers(t *testing.T) {
|
||||
expectedRegisters: 3,
|
||||
},
|
||||
{
|
||||
name: "multiple installers with mixed authorizer support",
|
||||
name: "multiple installers with authorizer support",
|
||||
appInstallers: []appsdkapiserver.AppInstaller{
|
||||
&mockAppInstallerWithAuth{
|
||||
mockAppInstaller: &mockAppInstaller{
|
||||
@@ -73,11 +88,6 @@ func TestRegisterAuthorizers(t *testing.T) {
|
||||
},
|
||||
mockAuthorizer: &mockAuthorizer{},
|
||||
},
|
||||
&mockAppInstaller{
|
||||
groupVersions: []schema.GroupVersion{
|
||||
{Group: "other.example.com", Version: "v1"},
|
||||
},
|
||||
},
|
||||
&mockAppInstallerWithAuth{
|
||||
mockAppInstaller: &mockAppInstaller{
|
||||
groupVersions: []schema.GroupVersion{
|
||||
@@ -88,7 +98,7 @@ func TestRegisterAuthorizers(t *testing.T) {
|
||||
mockAuthorizer: &mockAuthorizer{},
|
||||
},
|
||||
},
|
||||
expectedRegisters: 3, // 1 from first installer + 2 from third installer
|
||||
expectedRegisters: 3, // 1 from first installer + 2 from second installer
|
||||
},
|
||||
}
|
||||
|
||||
@@ -96,6 +106,13 @@ func TestRegisterAuthorizers(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
registrar := &mockAuthorizerRegistrar{}
|
||||
if tt.expectedPanic {
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Errorf("%s case did not panic as expected", t.Name())
|
||||
}
|
||||
}()
|
||||
}
|
||||
RegisterAuthorizers(ctx, tt.appInstallers, registrar)
|
||||
require.Equal(t, tt.expectedRegisters, len(registrar.registrations))
|
||||
})
|
||||
|
||||
@@ -38,12 +38,12 @@ func NewGrafanaBuiltInSTAuthorizer(cfg *setting.Cfg) *GrafanaAuthorizer {
|
||||
|
||||
// Individual services may have explicit implementations
|
||||
apis := make(map[string]authorizer.Authorizer)
|
||||
// The apiVersion flavors will run first and can return early when FGAC has appropriate rules
|
||||
authorizers = append(authorizers, &authorizerForAPI{apis})
|
||||
|
||||
// org role is last -- and will return allow for verbs that match expectations
|
||||
// The apiVersion flavors will run first and can return early when FGAC has appropriate rules
|
||||
// NOTE: role authorizer is now used by some api groups as their specific authorizer
|
||||
// but there are still some apis not directly registered in the embedded delegate that benefit from including it here
|
||||
// org role authorizer is last -- and will return allow for verbs that match expectations
|
||||
// it is only helpful here for remote APIs in some cloud use-cases.
|
||||
//nolint:staticcheck // remove once build handler chains are untangled between local and remote APIs handling
|
||||
authorizers = append(authorizers, NewRoleAuthorizer())
|
||||
return &GrafanaAuthorizer{
|
||||
apis: apis,
|
||||
|
||||
@@ -19,6 +19,7 @@ var orgRoleNoneAsViewerAPIGroups = []string{
|
||||
|
||||
type roleAuthorizer struct{}
|
||||
|
||||
// Deprecated: NewRoleAuthorizer exists for apps that were launched with simplistic authorization requirements. Consider using NewResourceAuthorizer instead.
|
||||
func NewRoleAuthorizer() *roleAuthorizer {
|
||||
return &roleAuthorizer{}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apimachinery/pkg/runtime/serializer"
|
||||
"k8s.io/apiserver/pkg/audit"
|
||||
genericapifilters "k8s.io/apiserver/pkg/endpoints/filters"
|
||||
"k8s.io/apiserver/pkg/endpoints/responsewriter"
|
||||
genericapiserver "k8s.io/apiserver/pkg/server"
|
||||
@@ -113,6 +114,9 @@ type service struct {
|
||||
appInstallers []appsdkapiserver.AppInstaller
|
||||
builderMetrics *builder.BuilderMetrics
|
||||
dualWriterMetrics *grafanarest.DualWriterMetrics
|
||||
|
||||
auditBackend audit.Backend
|
||||
auditPolicyRuleEvaluator audit.PolicyRuleEvaluator
|
||||
}
|
||||
|
||||
func ProvideService(
|
||||
@@ -137,6 +141,8 @@ func ProvideService(
|
||||
aggregatorRunner aggregatorrunner.AggregatorRunner,
|
||||
appInstallers []appsdkapiserver.AppInstaller,
|
||||
builderMetrics *builder.BuilderMetrics,
|
||||
auditBackend audit.Backend,
|
||||
auditPolicyRuleEvaluator audit.PolicyRuleEvaluator,
|
||||
) (*service, error) {
|
||||
scheme := builder.ProvideScheme()
|
||||
codecs := builder.ProvideCodecFactory(scheme)
|
||||
@@ -167,6 +173,8 @@ func ProvideService(
|
||||
appInstallers: appInstallers,
|
||||
builderMetrics: builderMetrics,
|
||||
dualWriterMetrics: grafanarest.NewDualWriterMetrics(reg),
|
||||
auditBackend: auditBackend,
|
||||
auditPolicyRuleEvaluator: auditPolicyRuleEvaluator,
|
||||
}
|
||||
// This will be used when running as a dskit service
|
||||
s.NamedService = services.NewBasicService(s.start, s.running, nil).WithName(modules.GrafanaAPIServer)
|
||||
@@ -355,6 +363,10 @@ func (s *service) start(ctx context.Context) error {
|
||||
appinstaller.BuildOpenAPIDefGetter(s.appInstallers),
|
||||
}
|
||||
|
||||
// Auditing Options
|
||||
serverConfig.AuditBackend = s.auditBackend
|
||||
serverConfig.AuditPolicyRuleEvaluator = s.auditPolicyRuleEvaluator
|
||||
|
||||
// Add OpenAPI specs for each group+version (existing builders)
|
||||
err = builder.SetupConfig(
|
||||
s.scheme,
|
||||
|
||||
@@ -1546,6 +1546,13 @@ var (
|
||||
FrontendOnly: true,
|
||||
Expression: "true",
|
||||
},
|
||||
{
|
||||
Name: "assistantStreaming",
|
||||
Description: "Enables streaming functionality for Grafana Assistant",
|
||||
Stage: FeatureStagePublicPreview,
|
||||
Owner: grafanaAppPlatformSquad,
|
||||
Expression: "true", // enabled by default
|
||||
},
|
||||
{
|
||||
Name: "tempoAlerting",
|
||||
Description: "Enables creating alerts from Tempo data source",
|
||||
|
||||
Generated
+1
@@ -212,6 +212,7 @@ unifiedNavbars,GA,@grafana/plugins-platform-backend,false,false,true
|
||||
logsPanelControls,preview,@grafana/observability-logs,false,false,true
|
||||
metricsFromProfiles,experimental,@grafana/observability-traces-and-profiling,false,false,true
|
||||
grafanaAssistantInProfilesDrilldown,GA,@grafana/observability-traces-and-profiling,false,false,true
|
||||
assistantStreaming,preview,@grafana/grafana-app-platform-squad,false,false,false
|
||||
tempoAlerting,experimental,@grafana/observability-traces-and-profiling,false,false,false
|
||||
pluginsAutoUpdate,experimental,@grafana/plugins-platform-backend,false,false,false
|
||||
alertingListViewV2PreviewToggle,privatePreview,@grafana/alerting-squad,false,false,true
|
||||
|
||||
|
Generated
+4
@@ -614,6 +614,10 @@ const (
|
||||
// use multi-tenant path for awsTempCredentials
|
||||
FlagMultiTenantTempCredentials = "multiTenantTempCredentials"
|
||||
|
||||
// FlagAssistantStreaming
|
||||
// Enables streaming functionality for Grafana Assistant
|
||||
FlagAssistantStreaming = "assistantStreaming"
|
||||
|
||||
// FlagTempoAlerting
|
||||
// Enables creating alerts from Tempo data source
|
||||
FlagTempoAlerting = "tempoAlerting"
|
||||
|
||||
+13
@@ -645,6 +645,19 @@
|
||||
"frontend": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "assistantStreaming",
|
||||
"resourceVersion": "1765555492103",
|
||||
"creationTimestamp": "2025-12-12T16:04:52Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables streaming functionality for Grafana Assistant",
|
||||
"stage": "preview",
|
||||
"codeowner": "@grafana/grafana-app-platform-squad",
|
||||
"expression": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "authZGRPCServer",
|
||||
|
||||
@@ -412,11 +412,16 @@ func (srv RulerSrv) RoutePostNameRulesConfig(c *contextmodel.ReqContext, ruleGro
|
||||
deletePermanently = true
|
||||
}
|
||||
|
||||
namespace, err := srv.store.GetNamespaceByUID(c.Req.Context(), namespaceUID, c.GetOrgID(), c.SignedInUser)
|
||||
f, err := srv.store.GetNamespaceByUID(c.Req.Context(), namespaceUID, c.GetOrgID(), c.SignedInUser)
|
||||
if err != nil {
|
||||
return toNamespaceErrorResponse(err)
|
||||
}
|
||||
|
||||
namespace := ngmodels.NewNamespace(f)
|
||||
if err := namespace.ValidateForRuleStorage(); err != nil {
|
||||
return ErrResp(http.StatusBadRequest, fmt.Errorf("%w: %s", ngmodels.ErrAlertRuleFailedValidation, err), "")
|
||||
}
|
||||
|
||||
if err := srv.checkGroupLimits(ruleGroupConfig); err != nil {
|
||||
return ErrResp(http.StatusBadRequest, err, "")
|
||||
}
|
||||
@@ -841,10 +846,14 @@ func (srv RulerSrv) RouteUpdateNamespaceRules(c *contextmodel.ReqContext, body a
|
||||
return ErrResp(http.StatusBadRequest, errors.New("missing request body"), "")
|
||||
}
|
||||
|
||||
namespace, err := srv.store.GetNamespaceByUID(c.Req.Context(), namespaceUID, c.GetOrgID(), c.SignedInUser)
|
||||
f, err := srv.store.GetNamespaceByUID(c.Req.Context(), namespaceUID, c.GetOrgID(), c.SignedInUser)
|
||||
if err != nil {
|
||||
return toNamespaceErrorResponse(err)
|
||||
}
|
||||
namespace := ngmodels.NewNamespace(f)
|
||||
if err := namespace.ValidateForRuleStorage(); err != nil {
|
||||
return ErrResp(http.StatusBadRequest, fmt.Errorf("%w: %s", ngmodels.ErrAlertRuleFailedValidation, err), "")
|
||||
}
|
||||
|
||||
ruleGroups, _, err := srv.searchAuthorizedAlertRules(c.Req.Context(), authorizedRuleGroupQuery{
|
||||
User: c.SignedInUser,
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
|
||||
@@ -1288,4 +1289,64 @@ func TestRouteUpdateNamespaceRules(t *testing.T) {
|
||||
updatedRules := getRecordedUpdatedRules(ruleStore)
|
||||
require.Empty(t, updatedRules)
|
||||
})
|
||||
|
||||
t.Run("should reject update when folder is managed by ManagerKindRepo", func(t *testing.T) {
|
||||
ruleStore := fakes.NewRuleStore(t)
|
||||
provisioningStore := fakes.NewFakeProvisioningStore()
|
||||
|
||||
// Create a managed folder
|
||||
managedFolder := randFolder()
|
||||
managedFolder.ManagedBy = utils.ManagerKindRepo
|
||||
ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], managedFolder)
|
||||
|
||||
// Create some rules in the managed folder
|
||||
ruleGen := models.RuleGen.With(
|
||||
models.RuleGen.WithOrgID(orgID),
|
||||
models.RuleGen.WithNamespaceUID(managedFolder.UID),
|
||||
)
|
||||
rules := ruleGen.GenerateManyRef(2)
|
||||
ruleStore.PutRule(context.Background(), rules...)
|
||||
|
||||
permissions := createPermissionsForRules(rules, orgID)
|
||||
requestCtx := createRequestContextWithPerms(orgID, permissions, nil)
|
||||
|
||||
svc := createServiceWithProvenanceStore(ruleStore, provisioningStore)
|
||||
response := svc.RouteUpdateNamespaceRules(requestCtx, apimodels.UpdateNamespaceRulesRequest{
|
||||
IsPaused: util.Pointer(true),
|
||||
}, managedFolder.UID)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, response.Status())
|
||||
require.Contains(t, string(response.Body()), "cannot store rules in folder managed by Git Sync")
|
||||
|
||||
// Verify no rules were updated
|
||||
updatedRules := getRecordedUpdatedRules(ruleStore)
|
||||
require.Empty(t, updatedRules)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRoutePostNameRulesConfig(t *testing.T) {
|
||||
t.Run("should reject creation when folder is managed by ManagerKindRepo", func(t *testing.T) {
|
||||
orgID := rand.Int63()
|
||||
ruleStore := fakes.NewRuleStore(t)
|
||||
|
||||
// Create a managed folder
|
||||
managedFolder := randFolder()
|
||||
managedFolder.ManagedBy = utils.ManagerKindRepo
|
||||
ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], managedFolder)
|
||||
|
||||
permissions := map[int64]map[string][]string{
|
||||
orgID: {
|
||||
dashboards.ScopeFoldersProvider.GetResourceScopeUID(managedFolder.UID): {dashboards.ActionFoldersRead},
|
||||
},
|
||||
}
|
||||
requestCtx := createRequestContextWithPerms(orgID, permissions, nil)
|
||||
|
||||
svc := createService(ruleStore, nil)
|
||||
response := svc.RoutePostNameRulesConfig(requestCtx, apimodels.PostableRuleGroupConfig{
|
||||
Name: "test-group",
|
||||
}, managedFolder.UID)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, response.Status())
|
||||
require.Contains(t, string(response.Body()), "cannot store rules in folder managed by Git Sync")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -296,7 +296,7 @@ func (srv PrometheusSrv) RouteGetRuleStatuses(c *contextmodel.ReqContext) respon
|
||||
allowedNamespaces := map[string]string{}
|
||||
for namespaceUID, folder := range namespaceMap {
|
||||
// only add namespaces that the user has access to rules in
|
||||
hasAccess, err := srv.authz.HasAccessInFolder(c.Req.Context(), c.SignedInUser, ngmodels.Namespace(*folder.ToFolderReference()))
|
||||
hasAccess, err := srv.authz.HasAccessInFolder(c.Req.Context(), c.SignedInUser, ngmodels.NewNamespace(folder))
|
||||
if err != nil {
|
||||
ruleResponse.Status = "error"
|
||||
ruleResponse.Error = fmt.Sprintf("failed to get namespaces visible to the user: %s", err.Error())
|
||||
|
||||
@@ -204,7 +204,7 @@ func IsNonRetryableError(err error) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// HasErrors returns true when Results contains at least one element and all elements are errors
|
||||
// IsError returns true when Results contains at least one element and all elements are errors
|
||||
func (evalResults Results) IsError() bool {
|
||||
for _, r := range evalResults {
|
||||
if r.State != Error {
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
|
||||
alertingModels "github.com/grafana/alerting/models"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/services/folder"
|
||||
"github.com/grafana/grafana/pkg/services/quota"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
@@ -397,6 +398,20 @@ type Namespaced interface {
|
||||
|
||||
type Namespace folder.FolderReference
|
||||
|
||||
func NewNamespace(f *folder.Folder) Namespace {
|
||||
return Namespace(*f.ToFolderReference())
|
||||
}
|
||||
|
||||
func (n Namespace) ValidateForRuleStorage() error {
|
||||
if n.UID == "" {
|
||||
return fmt.Errorf("cannot store rules in folder without UID")
|
||||
}
|
||||
if n.ManagedBy == utils.ManagerKindRepo {
|
||||
return fmt.Errorf("cannot store rules in folder managed by Git Sync")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n Namespace) GetNamespaceUID() string {
|
||||
return n.UID
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ func (service *AlertRuleService) ListAlertRules(ctx context.Context, user identi
|
||||
}
|
||||
folderUIDs := make([]string, 0, len(folders))
|
||||
for _, f := range folders {
|
||||
access, err := service.authz.HasAccessInFolder(ctx, user, models.Namespace(*f.ToFolderReference()))
|
||||
access, err := service.authz.HasAccessInFolder(ctx, user, models.NewNamespace(f))
|
||||
if err != nil {
|
||||
return nil, nil, "", err
|
||||
}
|
||||
@@ -407,6 +407,9 @@ func (service *AlertRuleService) UpdateRuleGroup(ctx context.Context, user ident
|
||||
if err := models.ValidateRuleGroupInterval(intervalSeconds, service.baseIntervalSeconds); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := service.ensureNamespace(ctx, user, user.GetOrgID(), namespaceUID); err != nil {
|
||||
return err
|
||||
}
|
||||
return service.xact.InTransaction(ctx, func(ctx context.Context) error {
|
||||
query := &models.ListAlertRulesQuery{
|
||||
OrgID: user.GetOrgID(),
|
||||
@@ -471,6 +474,10 @@ func (service *AlertRuleService) ReplaceRuleGroup(ctx context.Context, user iden
|
||||
return err
|
||||
}
|
||||
|
||||
if err := service.ensureNamespace(ctx, user, user.GetOrgID(), group.FolderUID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If the rule group is reserved for no-group rules, we cannot have multiple rules in it.
|
||||
if models.IsNoGroupRuleGroup(group.Title) && len(group.Rules) > 1 {
|
||||
return fmt.Errorf("rule group %s is reserved for no-group rules and cannot be used for rule groups with multiple rules", group.Title)
|
||||
@@ -1025,6 +1032,7 @@ func (service *AlertRuleService) checkGroupLimits(group models.AlertRuleGroup) e
|
||||
|
||||
// ensureNamespace ensures that the rule has a valid namespace UID.
|
||||
// If the rule does not have a namespace UID or the namespace (folder) does not exist it will return an error.
|
||||
// If the folder is managed by a manager, it will also return an error.
|
||||
func (service *AlertRuleService) ensureNamespace(ctx context.Context, user identity.Requester, orgID int64, namespaceUID string) error {
|
||||
if namespaceUID == "" {
|
||||
return fmt.Errorf("%w: folderUID must be set", models.ErrAlertRuleFailedValidation)
|
||||
@@ -1037,18 +1045,23 @@ func (service *AlertRuleService) ensureNamespace(ctx context.Context, user ident
|
||||
}
|
||||
|
||||
// ensure the namespace exists
|
||||
_, err := service.folderService.Get(ctx, &folder.GetFolderQuery{
|
||||
f, err := service.folderService.Get(ctx, &folder.GetFolderQuery{
|
||||
OrgID: orgID,
|
||||
UID: &namespaceUID,
|
||||
SignedInUser: user,
|
||||
})
|
||||
if err != nil {
|
||||
if err != nil || f == nil {
|
||||
if errors.Is(err, dashboards.ErrFolderNotFound) {
|
||||
return fmt.Errorf("%w: folder does not exist", models.ErrAlertRuleFailedValidation)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// check if the folder is managed by a manager
|
||||
if err := models.NewNamespace(f).ValidateForRuleStorage(); err != nil {
|
||||
return fmt.Errorf("%w: %s", models.ErrAlertRuleFailedValidation, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
"github.com/grafana/grafana/pkg/expr"
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
@@ -867,6 +868,27 @@ func TestIntegrationAlertRuleService(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(120), rule.IntervalSeconds)
|
||||
})
|
||||
|
||||
t.Run("UpdateRuleGroup should reject when folder is managed by a manager", func(t *testing.T) {
|
||||
service, _, _, ac := initService(t)
|
||||
ac.CanWriteAllRulesFunc = func(ctx context.Context, user identity.Requester) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
managedFolderUID := "managed-folder-update-group"
|
||||
fs := foldertest.NewFakeService()
|
||||
fs.AddFolder(&folder.Folder{
|
||||
OrgID: orgID,
|
||||
UID: managedFolderUID,
|
||||
Title: "Managed Folder",
|
||||
ManagedBy: utils.ManagerKindRepo,
|
||||
})
|
||||
service.folderService = fs
|
||||
|
||||
err := service.UpdateRuleGroup(context.Background(), u, managedFolderUID, "some-group", 120)
|
||||
require.ErrorIs(t, err, models.ErrAlertRuleFailedValidation)
|
||||
require.ErrorContains(t, err, "cannot store rules in folder managed by Git Sync")
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegrationCreateAlertRule(t *testing.T) {
|
||||
@@ -1166,6 +1188,30 @@ func TestIntegrationCreateAlertRule(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.True(t, models.IsNoGroupRuleGroup(retrievedRule.RuleGroup), "Rule should be considered NoGroup rule")
|
||||
})
|
||||
|
||||
t.Run("should reject creation when folder is managed by a manager", func(t *testing.T) {
|
||||
service, _, _, ac := initService(t)
|
||||
ac.CanWriteAllRulesFunc = func(ctx context.Context, user identity.Requester) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
managedFolderUID := "managed-folder"
|
||||
fs := foldertest.NewFakeService()
|
||||
fs.AddFolder(&folder.Folder{
|
||||
OrgID: orgID,
|
||||
UID: managedFolderUID,
|
||||
Title: "Managed Folder",
|
||||
ManagedBy: utils.ManagerKindRepo,
|
||||
})
|
||||
service.folderService = fs
|
||||
|
||||
rule := dummyRule("test-managed-folder", orgID)
|
||||
rule.NamespaceUID = managedFolderUID
|
||||
|
||||
_, err := service.CreateAlertRule(context.Background(), u, rule, models.ProvenanceNone)
|
||||
require.ErrorIs(t, err, models.ErrAlertRuleFailedValidation)
|
||||
require.ErrorContains(t, err, "cannot store rules in folder managed by Git Sync")
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpdateAlertRule(t *testing.T) {
|
||||
@@ -1316,6 +1362,36 @@ func TestUpdateAlertRule(t *testing.T) {
|
||||
require.Equal(t, "nogroup-update-new", updated.Title)
|
||||
require.Equal(t, originalInterval, updated.IntervalSeconds)
|
||||
})
|
||||
|
||||
t.Run("should reject update when folder is managed by a manager", func(t *testing.T) {
|
||||
service, ruleStore, provenanceStore, ac := initService(t)
|
||||
ac.CanWriteAllRulesFunc = func(ctx context.Context, user identity.Requester) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
managedFolderUID := "managed-folder-update"
|
||||
fs := foldertest.NewFakeService()
|
||||
fs.AddFolder(&folder.Folder{
|
||||
OrgID: orgID,
|
||||
UID: managedFolderUID,
|
||||
Title: "Managed Folder",
|
||||
ManagedBy: utils.ManagerKindRepo,
|
||||
})
|
||||
service.folderService = fs
|
||||
|
||||
// Create an existing rule
|
||||
existingRule := dummyRule("test-managed-folder-update", orgID)
|
||||
existingRule.NamespaceUID = managedFolderUID
|
||||
_, err := ruleStore.InsertAlertRules(context.Background(), models.NewUserUID(u), []models.InsertRule{{AlertRule: existingRule}})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, provenanceStore.SetProvenance(context.Background(), &existingRule, orgID, models.ProvenanceNone))
|
||||
|
||||
// Try to update the rule
|
||||
existingRule.Title = "Updated Title"
|
||||
_, err = service.UpdateAlertRule(context.Background(), u, existingRule, models.ProvenanceNone)
|
||||
require.ErrorIs(t, err, models.ErrAlertRuleFailedValidation)
|
||||
require.ErrorContains(t, err, "cannot store rules in folder managed by Git Sync")
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteAlertRule(t *testing.T) {
|
||||
@@ -2054,6 +2130,33 @@ func TestReplaceGroup(t *testing.T) {
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "cannot move rule out of this group")
|
||||
})
|
||||
|
||||
t.Run("should reject replace when folder is managed by a manager", func(t *testing.T) {
|
||||
service, _, _, ac := initService(t)
|
||||
ac.CanWriteAllRulesFunc = func(ctx context.Context, user identity.Requester) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
managedFolderUID := "managed-folder-replace"
|
||||
fs := foldertest.NewFakeService()
|
||||
fs.AddFolder(&folder.Folder{
|
||||
OrgID: orgID,
|
||||
UID: managedFolderUID,
|
||||
Title: "Managed Folder",
|
||||
ManagedBy: utils.ManagerKindRepo,
|
||||
})
|
||||
service.folderService = fs
|
||||
|
||||
group := models.AlertRuleGroup{
|
||||
Title: "test-group",
|
||||
FolderUID: managedFolderUID,
|
||||
Interval: 60,
|
||||
}
|
||||
|
||||
err := service.ReplaceRuleGroup(context.Background(), u, group, models.ProvenanceNone, "")
|
||||
require.ErrorIs(t, err, models.ErrAlertRuleFailedValidation)
|
||||
require.ErrorContains(t, err, "cannot store rules in folder managed by Git Sync")
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteRuleGroup(t *testing.T) {
|
||||
|
||||
@@ -530,7 +530,7 @@ func (h *RemoteLokiBackend) getFolderUIDsForFilter(ctx context.Context, query mo
|
||||
uids := make([]string, 0, len(folders))
|
||||
// now keep only UIDs of folder in which user can read rules.
|
||||
for _, f := range folders {
|
||||
hasAccess, err := h.ac.HasAccessInFolder(ctx, query.SignedInUser, models.Namespace(*f.ToFolderReference()))
|
||||
hasAccess, err := h.ac.HasAccessInFolder(ctx, query.SignedInUser, models.NewNamespace(f))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -261,8 +261,8 @@ func RunDashboardUIDMigrations(sess *xorm.Session, driverName string, logger log
|
||||
logger.Info("Starting batched dashboard_uid migration for annotations (newest first)", "batchSize", batchSize)
|
||||
updateSQL := `UPDATE annotation
|
||||
SET dashboard_uid = (SELECT uid FROM dashboard WHERE dashboard.id = annotation.dashboard_id)
|
||||
WHERE dashboard_uid IS NULL
|
||||
AND dashboard_id != 0
|
||||
WHERE dashboard_uid IS NULL
|
||||
AND dashboard_id != 0
|
||||
AND EXISTS (SELECT 1 FROM dashboard WHERE dashboard.id = annotation.dashboard_id)
|
||||
AND annotation.id IN (
|
||||
SELECT id FROM annotation
|
||||
@@ -285,19 +285,19 @@ func RunDashboardUIDMigrations(sess *xorm.Session, driverName string, logger log
|
||||
LIMIT $1
|
||||
)`
|
||||
case MySQL:
|
||||
updateSQL = `UPDATE annotation
|
||||
INNER JOIN dashboard ON annotation.dashboard_id = dashboard.id
|
||||
SET annotation.dashboard_uid = dashboard.uid
|
||||
WHERE annotation.dashboard_uid IS NULL
|
||||
AND annotation.dashboard_id != 0
|
||||
AND annotation.id IN (
|
||||
SELECT id FROM (
|
||||
SELECT id FROM annotation
|
||||
WHERE dashboard_uid IS NULL AND dashboard_id != 0
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
) AS batch
|
||||
)`
|
||||
updateSQL = `UPDATE annotation AS a
|
||||
JOIN dashboard AS d ON a.dashboard_id = d.id
|
||||
JOIN (
|
||||
SELECT id
|
||||
FROM annotation
|
||||
WHERE dashboard_uid IS NULL
|
||||
AND dashboard_id != 0
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
) AS batch ON batch.id = a.id
|
||||
SET a.dashboard_uid = d.uid
|
||||
WHERE a.dashboard_uid IS NULL
|
||||
AND a.dashboard_id != 0`
|
||||
}
|
||||
|
||||
updatedTotal := int64(0)
|
||||
|
||||
@@ -2,8 +2,11 @@ package migrations
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/bwmarrin/snowflake"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
|
||||
"github.com/grafana/grafana/pkg/util/xorm"
|
||||
)
|
||||
|
||||
func initResourceTables(mg *migrator.Migrator) string {
|
||||
@@ -204,5 +207,142 @@ func initResourceTables(mg *migrator.Migrator) string {
|
||||
Name: "IDX_resource_history_key_path",
|
||||
}))
|
||||
|
||||
mg.AddMigration("resource_history key_path backfill", &ResourceHistoryKeyPathBackfillMigration{})
|
||||
|
||||
return marker
|
||||
}
|
||||
|
||||
type ResourceHistoryKeyPathBackfillMigration struct {
|
||||
migrator.MigrationBase
|
||||
}
|
||||
|
||||
func (m *ResourceHistoryKeyPathBackfillMigration) SQL(_ migrator.Dialect) string {
|
||||
return "resource_history key_path backfill code migration"
|
||||
}
|
||||
|
||||
func (m *ResourceHistoryKeyPathBackfillMigration) Exec(sess *xorm.Session, mg *migrator.Migrator) error {
|
||||
rows, err := getResourceHistoryRows(sess, mg, resourceHistoryRow{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for len(rows) > 0 {
|
||||
if err := updateResourceHistoryKeyPath(sess, rows); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rows, err = getResourceHistoryRows(sess, mg, rows[len(rows)-1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateResourceHistoryKeyPath(sess *xorm.Session, rows []resourceHistoryRow) error {
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
updates := []resourceHistoryRow{}
|
||||
|
||||
for _, row := range rows {
|
||||
if row.KeyPath == "" {
|
||||
row.KeyPath = parseKeyPath(row)
|
||||
updates = append(updates, row)
|
||||
}
|
||||
}
|
||||
|
||||
if len(updates) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
guids := ""
|
||||
setCases := "CASE"
|
||||
for _, row := range updates {
|
||||
guids += fmt.Sprintf("'%s',", row.GUID)
|
||||
setCases += fmt.Sprintf(" WHEN guid = '%s' THEN '%s'", row.GUID, row.KeyPath)
|
||||
}
|
||||
|
||||
guids = strings.TrimRight(guids, ",")
|
||||
setCases += " ELSE key_path END "
|
||||
|
||||
// the query will look like this
|
||||
// UPDATE resource_history
|
||||
// SET key_path = CASE
|
||||
// WHEN guid = '1402de51-669b-4206-8a6c-005a00eee6e3' then 'unified/data/folder.grafana.app/folders/default/cf6lylpvls000c/1998492888241012800~created~'
|
||||
// WHEN guid = '8842cc56-f22b-45e1-82b1-99759cd443b3' then 'unified/data/dashboard.grafana.app/dashboards/default/adzvfhp/1998492902577144677~created~cf6lylpvls000c'
|
||||
// ELSE key_path END
|
||||
// WHERE guid IN ('1402de51-669b-4206-8a6c-005a00eee6e3', '8842cc56-f22b-45e1-82b1-99759cd443b3')
|
||||
// AND key_path = '';
|
||||
sql := fmt.Sprintf(`
|
||||
UPDATE resource_history
|
||||
SET key_path = %s
|
||||
WHERE guid IN (%s)
|
||||
AND key_path = '';
|
||||
`, setCases, guids)
|
||||
|
||||
if _, err := sess.Exec(sql); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseKeyPath(row resourceHistoryRow) string {
|
||||
var action string
|
||||
switch row.Action {
|
||||
case 1:
|
||||
action = "created"
|
||||
case 2:
|
||||
action = "updated"
|
||||
case 3:
|
||||
action = "deleted"
|
||||
}
|
||||
return fmt.Sprintf("unified/data/%s/%s/%s/%s/%d~%s~%s", row.Group, row.Resource, row.Namespace, row.Name, snowflakeFromRv(row.ResourceVersion), action, row.Folder)
|
||||
}
|
||||
|
||||
func snowflakeFromRv(rv int64) int64 {
|
||||
return (((rv / 1000) - snowflake.Epoch) << (snowflake.NodeBits + snowflake.StepBits)) + (rv % 1000)
|
||||
}
|
||||
|
||||
type resourceHistoryRow struct {
|
||||
GUID string `xorm:"guid"`
|
||||
Group string `xorm:"group"`
|
||||
Resource string `xorm:"resource"`
|
||||
Namespace string `xorm:"namespace"`
|
||||
Name string `xorm:"name"`
|
||||
ResourceVersion int64 `xorm:"resource_version"`
|
||||
Action int64 `xorm:"action"`
|
||||
Folder string `xorm:"folder"`
|
||||
KeyPath string `xorm:"key_path"`
|
||||
}
|
||||
|
||||
func getResourceHistoryRows(sess *xorm.Session, mg *migrator.Migrator, continueRow resourceHistoryRow) ([]resourceHistoryRow, error) {
|
||||
var rows []resourceHistoryRow
|
||||
cols := fmt.Sprintf(
|
||||
"%s, %s, %s, %s, %s, %s, %s, %s, %s",
|
||||
mg.Dialect.Quote("guid"),
|
||||
mg.Dialect.Quote("group"),
|
||||
mg.Dialect.Quote("resource"),
|
||||
mg.Dialect.Quote("namespace"),
|
||||
mg.Dialect.Quote("name"),
|
||||
mg.Dialect.Quote("resource_version"),
|
||||
mg.Dialect.Quote("action"),
|
||||
mg.Dialect.Quote("folder"),
|
||||
mg.Dialect.Quote("key_path"))
|
||||
sql := fmt.Sprintf(`
|
||||
SELECT %s
|
||||
FROM resource_history
|
||||
WHERE (resource_version > %d OR (resource_version = %d AND guid > '%s'))
|
||||
AND key_path = ''
|
||||
ORDER BY resource_version ASC, guid ASC
|
||||
LIMIT 1000;
|
||||
`, cols, continueRow.ResourceVersion, continueRow.ResourceVersion, continueRow.GUID)
|
||||
if err := sess.SQL(sql).Find(&rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
@@ -3696,7 +3696,7 @@
|
||||
"format": "int64"
|
||||
},
|
||||
"errors": {
|
||||
"description": "Report errors for this resource type This may not be an exhaustive list and recommend looking at the logs for more info",
|
||||
"description": "Report errors/warnings for this resource type This may not be an exhaustive list and recommend looking at the logs for more info",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
@@ -3722,6 +3722,18 @@
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"warning": {
|
||||
"description": "The error count",
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"warnings": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"default": ""
|
||||
}
|
||||
},
|
||||
"write": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
@@ -3849,6 +3861,13 @@
|
||||
"$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.RepositoryURLs"
|
||||
}
|
||||
]
|
||||
},
|
||||
"warnings": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"default": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { t } from '@grafana/i18n';
|
||||
import { config } from '@grafana/runtime';
|
||||
// Maps the ID of the nav item to a translated phrase to later pass to <Trans />
|
||||
// Because the navigation content is dynamic (defined in the backend), we can not use
|
||||
// the normal inline message definition method.
|
||||
@@ -49,9 +48,7 @@ export function getNavTitle(navId: string | undefined) {
|
||||
case 'dashboards/recently-deleted':
|
||||
return t('nav.recently-deleted.title', 'Recently deleted');
|
||||
case 'dashboards/new':
|
||||
return config.featureToggles.dashboardTemplates
|
||||
? t('nav.new-dashboard.empty-title', 'Empty dashboard')
|
||||
: t('nav.new-dashboard.title', 'New dashboard');
|
||||
return t('nav.new-dashboard.title', 'New dashboard');
|
||||
case 'dashboards/folder/new':
|
||||
return t('nav.new-folder.title', 'New folder');
|
||||
case 'dashboards/import':
|
||||
|
||||
@@ -27,6 +27,7 @@ import { BrowseFilters } from './components/BrowseFilters';
|
||||
import { BrowseView } from './components/BrowseView';
|
||||
import CreateNewButton from './components/CreateNewButton';
|
||||
import { FolderActionsButton } from './components/FolderActionsButton';
|
||||
import { RecentlyViewedDashboards } from './components/RecentlyViewedDashboards';
|
||||
import { SearchView } from './components/SearchView';
|
||||
import { getFolderPermissions } from './permissions';
|
||||
import { useHasSelection } from './state/hooks';
|
||||
@@ -178,6 +179,8 @@ const BrowseDashboardsPage = memo(({ queryParams }: { queryParams: Record<string
|
||||
>
|
||||
<Page.Contents className={styles.pageContents}>
|
||||
<ProvisionedFolderPreviewBanner queryParams={queryParams} />
|
||||
{/* only show recently viewed dashboards when in root */}
|
||||
{!folderUID && <RecentlyViewedDashboards />}
|
||||
<div>
|
||||
<FilterInput
|
||||
placeholder={getSearchPlaceholder(searchState.includePanels)}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useAsync } from 'react-use';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { t, Trans } from '@grafana/i18n';
|
||||
import { evaluateBooleanFlag } from '@grafana/runtime/internal';
|
||||
import { CollapsableSection, Link, Spinner, Text, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { getRecentlyViewedDashboards } from './utils';
|
||||
|
||||
const MAX_RECENT = 5;
|
||||
|
||||
export function RecentlyViewedDashboards() {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const { value: recentDashboards = [], loading } = useAsync(async () => {
|
||||
if (!evaluateBooleanFlag('recentlyViewedDashboards', false)) {
|
||||
return [];
|
||||
}
|
||||
return getRecentlyViewedDashboards(MAX_RECENT);
|
||||
}, []);
|
||||
|
||||
if (!evaluateBooleanFlag('recentlyViewedDashboards', false)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<CollapsableSection
|
||||
headerDataTestId="browseDashboardsRecentlyViewedTitle"
|
||||
label={
|
||||
<Text variant="h5" element="h3">
|
||||
<Trans i18nKey="browse-dashboards.recently-viewed.title">Recently viewed</Trans>
|
||||
</Text>
|
||||
}
|
||||
isOpen={true}
|
||||
className={styles.title}
|
||||
contentClassName={styles.content}
|
||||
>
|
||||
{/* placeholder */}
|
||||
{loading && <Spinner />}
|
||||
{/* TODO: Better empty state https://github.com/grafana/grafana/issues/114804 */}
|
||||
{!loading && recentDashboards.length === 0 && (
|
||||
<Text>{t('browse-dashboards.recently-viewed.empty', 'Nothing viewed yet')}</Text>
|
||||
)}
|
||||
|
||||
{/* TODO: implement actual card content */}
|
||||
{!loading && recentDashboards.length > 0 && (
|
||||
<>
|
||||
{recentDashboards.map((dash) => (
|
||||
<div key={dash.uid}>
|
||||
<Link href={dash.url}>{dash.name}</Link>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</CollapsableSection>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
const accent = theme.visualization.getColorByName('purple'); // or your own hex
|
||||
|
||||
return {
|
||||
title: css({
|
||||
background: `linear-gradient(90deg, ${accent} 0%, #e478eaff 100%)`,
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
'& button svg': {
|
||||
color: accent,
|
||||
},
|
||||
}),
|
||||
content: css({
|
||||
paddingTop: theme.spacing(0),
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -1,6 +1,9 @@
|
||||
import { config } from '@grafana/runtime';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import impressionSrv from 'app/core/services/impression_srv';
|
||||
import { ResourceRef } from 'app/features/provisioning/components/BulkActions/useBulkActionJob';
|
||||
import { getGrafanaSearcher } from 'app/features/search/service/searcher';
|
||||
import { DashboardQueryResult } from 'app/features/search/service/types';
|
||||
|
||||
import { DashboardTreeSelection, DashboardViewItemWithUIItems, BrowseDashboardsPermissions } from '../types';
|
||||
|
||||
@@ -60,3 +63,36 @@ export function canSelectItems(permissions: BrowseDashboardsPermissions) {
|
||||
const canSelectDashboards = canEditDashboards || canDeleteDashboards;
|
||||
return Boolean(canSelectFolders || canSelectDashboards);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns dashboard search results ordered the same way the user opened them.
|
||||
*/
|
||||
export async function getRecentlyViewedDashboards(maxItems = 5): Promise<DashboardQueryResult[]> {
|
||||
try {
|
||||
const recentlyOpened = (await impressionSrv.getDashboardOpened()).slice(0, maxItems);
|
||||
if (!recentlyOpened.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const searchResults = await getGrafanaSearcher().search({
|
||||
kind: ['dashboard'],
|
||||
limit: recentlyOpened.length,
|
||||
uid: recentlyOpened,
|
||||
});
|
||||
|
||||
const dashboards = searchResults.view.toArray();
|
||||
// Keep dashboards in the same order the user opened them.
|
||||
// When a UID is missing from the search response
|
||||
// push it to the end instead of letting indexOf return -1
|
||||
const order = (uid: string) => {
|
||||
const idx = recentlyOpened.indexOf(uid);
|
||||
return idx === -1 ? recentlyOpened.length : idx;
|
||||
};
|
||||
|
||||
dashboards.sort((a, b) => order(a.uid) - order(b.uid));
|
||||
return dashboards;
|
||||
} catch (error) {
|
||||
console.error('Failed to load recently viewed dashboards', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import impressionSrv from 'app/core/services/impression_srv';
|
||||
import { getRecentlyViewedDashboards } from 'app/features/browse-dashboards/components/utils';
|
||||
import { getGrafanaSearcher } from 'app/features/search/service/searcher';
|
||||
|
||||
import { CommandPaletteAction } from '../types';
|
||||
@@ -20,20 +20,7 @@ export async function getRecentDashboardActions(): Promise<CommandPaletteAction[
|
||||
return [];
|
||||
}
|
||||
|
||||
const recentUids = (await impressionSrv.getDashboardOpened()).slice(0, MAX_RECENT_DASHBOARDS);
|
||||
const resultsDataFrame = await getGrafanaSearcher().search({
|
||||
kind: ['dashboard'],
|
||||
limit: MAX_RECENT_DASHBOARDS,
|
||||
uid: recentUids,
|
||||
});
|
||||
|
||||
// Search results are alphabetical, so reorder them according to recently viewed
|
||||
const recentResults = resultsDataFrame.view.toArray();
|
||||
recentResults.sort((resultA, resultB) => {
|
||||
const orderA = recentUids.indexOf(resultA.uid);
|
||||
const orderB = recentUids.indexOf(resultB.uid);
|
||||
return orderA - orderB;
|
||||
});
|
||||
const recentResults = await getRecentlyViewedDashboards(MAX_RECENT_DASHBOARDS);
|
||||
|
||||
const recentDashboardActions: CommandPaletteAction[] = recentResults.map((item) => {
|
||||
const { url, name } = item; // items are backed by DataFrameView, so must hold the url in a closure
|
||||
|
||||
@@ -206,6 +206,10 @@ export class DashboardSceneChangeTracker {
|
||||
}
|
||||
|
||||
this._changesWorker!.onmessage = (e: MessageEvent<DashboardChangeInfo>) => {
|
||||
if (!this._dashboard.state.isEditing) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateIsDirty(!!e.data.hasChanges);
|
||||
};
|
||||
|
||||
|
||||
@@ -171,7 +171,6 @@ function DashboardControlsRenderer({ model }: SceneComponentProps<DashboardContr
|
||||
<DashboardControlActions dashboard={dashboard} />
|
||||
</div>
|
||||
)}
|
||||
{!hideLinksControls && !editPanel && <DashboardLinksControls links={links} dashboard={dashboard} />}
|
||||
</div>
|
||||
{!hideVariableControls && (
|
||||
<>
|
||||
@@ -179,6 +178,7 @@ function DashboardControlsRenderer({ model }: SceneComponentProps<DashboardContr
|
||||
<DashboardDataLayerControls dashboard={dashboard} />
|
||||
</>
|
||||
)}
|
||||
{!hideLinksControls && !editPanel && <DashboardLinksControls links={links} dashboard={dashboard} />}
|
||||
{!hideDashboardControls && hasDashboardControls && <DashboardControlsButton dashboard={dashboard} />}
|
||||
{editPanel && <PanelEditControls panelEditor={editPanel} />}
|
||||
{showDebugger && <SceneDebugger scene={model} key={'scene-debugger'} />}
|
||||
|
||||
@@ -64,7 +64,6 @@ function getStyles(theme: GrafanaTheme2) {
|
||||
alignItems: 'center',
|
||||
verticalAlign: 'middle',
|
||||
marginBottom: theme.spacing(1),
|
||||
marginRight: theme.spacing(1),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -36,13 +36,9 @@ export function DashboardLinksControls({ links, dashboard }: Props) {
|
||||
function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
linksContainer: css({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
display: 'inline-flex',
|
||||
gap: theme.spacing(1),
|
||||
maxWidth: '100%',
|
||||
minWidth: 0,
|
||||
order: 1,
|
||||
flex: '1 1 0%',
|
||||
marginRight: theme.spacing(1),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -271,7 +271,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
|
||||
|
||||
public onEnterEditMode = () => {
|
||||
// Save this state
|
||||
this._initialState = sceneUtils.cloneSceneObjectState(this.state);
|
||||
this._initialState = sceneUtils.cloneSceneObjectState(this.state, { isDirty: false });
|
||||
this._initialUrlState = locationService.getLocation();
|
||||
|
||||
// Switch to edit mode
|
||||
|
||||
@@ -19,7 +19,6 @@ import { AddVariableButton } from './VariableControlsAddButton';
|
||||
|
||||
export function VariableControls({ dashboard }: { dashboard: DashboardScene }) {
|
||||
const { variables } = sceneGraph.getVariables(dashboard)!.useState();
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -28,11 +27,7 @@ export function VariableControls({ dashboard }: { dashboard: DashboardScene }) {
|
||||
.map((variable) => (
|
||||
<VariableValueSelectWrapper key={variable.state.key} variable={variable} />
|
||||
))}
|
||||
{config.featureToggles.dashboardNewLayouts ? (
|
||||
<div className={styles.addButton}>
|
||||
<AddVariableButton dashboard={dashboard} />
|
||||
</div>
|
||||
) : null}
|
||||
{config.featureToggles.dashboardNewLayouts ? <AddVariableButton dashboard={dashboard} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -211,11 +206,4 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}),
|
||||
addButton: css({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
verticalAlign: 'middle',
|
||||
marginBottom: theme.spacing(1),
|
||||
marginRight: theme.spacing(1),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { PointerEventHandler, useCallback } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Trans } from '@grafana/i18n';
|
||||
import { Button } from '@grafana/ui';
|
||||
import { Button, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { openAddVariablePane } from '../settings/variables/VariableAddEditableElement';
|
||||
import { DashboardInteractions } from '../utils/interactions';
|
||||
@@ -9,6 +11,7 @@ import { DashboardInteractions } from '../utils/interactions';
|
||||
import { DashboardScene } from './DashboardScene';
|
||||
|
||||
export function AddVariableButton({ dashboard }: { dashboard: DashboardScene }) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const { editview, editPanel, isEditing, viewPanel } = dashboard.useState();
|
||||
|
||||
const handlePointerDown: PointerEventHandler = useCallback(
|
||||
@@ -30,10 +33,22 @@ export function AddVariableButton({ dashboard }: { dashboard: DashboardScene })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dashboard-canvas-add-button">
|
||||
<Button icon="plus" variant="primary" fill="text" onPointerDown={handlePointerDown}>
|
||||
<Trans i18nKey="dashboard-scene.variable-controls.add-variable">Add variable</Trans>
|
||||
</Button>
|
||||
<div className={styles.addButton}>
|
||||
<div className="dashboard-canvas-add-button">
|
||||
<Button icon="plus" variant="primary" fill="text" onPointerDown={handlePointerDown}>
|
||||
<Trans i18nKey="dashboard-scene.variable-controls.add-variable">Add variable</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
addButton: css({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
verticalAlign: 'middle',
|
||||
marginBottom: theme.spacing(1),
|
||||
marginRight: theme.spacing(1),
|
||||
}),
|
||||
});
|
||||
|
||||
+29
@@ -380,6 +380,35 @@ describe('sceneVariablesSetToVariables', () => {
|
||||
`);
|
||||
});
|
||||
|
||||
it('should handle Custom variable when sceneVariablesSetToVariables should keep options', () => {
|
||||
const variable = new CustomVariable({
|
||||
name: 'test',
|
||||
label: 'test-label',
|
||||
description: 'test-desc',
|
||||
hide: VariableHide.inControlsMenu,
|
||||
value: ['test'],
|
||||
text: ['test'],
|
||||
query: 'test,test1,test2',
|
||||
options: [
|
||||
{ label: 'test', value: 'test' },
|
||||
{ label: 'test1', value: 'test1' },
|
||||
{ label: 'test2', value: 'test2' },
|
||||
],
|
||||
includeAll: true,
|
||||
allValue: 'test-all',
|
||||
isMulti: true,
|
||||
});
|
||||
|
||||
const set = new SceneVariableSet({
|
||||
variables: [variable],
|
||||
});
|
||||
const keepQueryOptions = true;
|
||||
const result = sceneVariablesSetToVariables(set, keepQueryOptions);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].options).not.toEqual([]);
|
||||
expect(result[0].options?.length).toEqual(3);
|
||||
});
|
||||
|
||||
it('should handle ConstantVariable', () => {
|
||||
const variable = new ConstantVariable({
|
||||
name: 'test',
|
||||
|
||||
@@ -102,6 +102,10 @@ export function sceneVariablesSetToVariables(set: SceneVariables, keepQueryOptio
|
||||
}
|
||||
variables.push(variableObj);
|
||||
} else if (sceneUtils.isCustomVariable(variable)) {
|
||||
let options: VariableOption[] = [];
|
||||
if (keepQueryOptions) {
|
||||
options = variableValueOptionsToVariableOptions(variable.state);
|
||||
}
|
||||
const customVariable: VariableModel = {
|
||||
...commonProperties,
|
||||
current: {
|
||||
@@ -110,7 +114,7 @@ export function sceneVariablesSetToVariables(set: SceneVariables, keepQueryOptio
|
||||
// @ts-expect-error
|
||||
value: variable.state.value,
|
||||
},
|
||||
options: [],
|
||||
options,
|
||||
query: variable.state.query,
|
||||
multi: variable.state.isMulti,
|
||||
allValue: variable.state.allValue,
|
||||
|
||||
@@ -119,6 +119,16 @@ export function colorIdEnumToColorIdV2(colorId: FieldColorModeIdV1 | string): Fi
|
||||
return 'continuous-greens';
|
||||
case FieldColorModeIdV1.ContinuousPurples:
|
||||
return 'continuous-purples';
|
||||
case FieldColorModeIdV1.ContinuousViridis:
|
||||
return 'continuous-viridis';
|
||||
case FieldColorModeIdV1.ContinuousMagma:
|
||||
return 'continuous-magma';
|
||||
case FieldColorModeIdV1.ContinuousPlasma:
|
||||
return 'continuous-plasma';
|
||||
case FieldColorModeIdV1.ContinuousInferno:
|
||||
return 'continuous-inferno';
|
||||
case FieldColorModeIdV1.ContinuousCividis:
|
||||
return 'continuous-cividis';
|
||||
case FieldColorModeIdV1.Fixed:
|
||||
return 'fixed';
|
||||
case FieldColorModeIdV1.Shades:
|
||||
|
||||
@@ -1268,6 +1268,16 @@ function colorIdToEnumv1(colorId: FieldColorModeId): FieldColorModeIdV1 {
|
||||
return FieldColorModeIdV1.ContinuousGreens;
|
||||
case 'continuous-purples':
|
||||
return FieldColorModeIdV1.ContinuousPurples;
|
||||
case 'continuous-viridis':
|
||||
return FieldColorModeIdV1.ContinuousViridis;
|
||||
case 'continuous-magma':
|
||||
return FieldColorModeIdV1.ContinuousMagma;
|
||||
case 'continuous-plasma':
|
||||
return FieldColorModeIdV1.ContinuousPlasma;
|
||||
case 'continuous-inferno':
|
||||
return FieldColorModeIdV1.ContinuousInferno;
|
||||
case 'continuous-cividis':
|
||||
return FieldColorModeIdV1.ContinuousCividis;
|
||||
case 'fixed':
|
||||
return FieldColorModeIdV1.Fixed;
|
||||
case 'shades':
|
||||
|
||||
@@ -182,7 +182,6 @@ function getStyles(theme: GrafanaTheme2) {
|
||||
alignItems: 'center',
|
||||
verticalAlign: 'middle',
|
||||
marginBottom: theme.spacing(1),
|
||||
marginRight: theme.spacing(1),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
+11
@@ -39,6 +39,17 @@ export const BasicProvisionedDashboardsEmptyPage = ({ datasourceUid }: Props) =>
|
||||
}
|
||||
|
||||
const dashboards = await fetchProvisionedDashboards(ds.type);
|
||||
|
||||
if (dashboards.length > 0) {
|
||||
DashboardLibraryInteractions.loaded({
|
||||
numberOfItems: dashboards.length,
|
||||
contentKinds: [CONTENT_KINDS.DATASOURCE_DASHBOARD],
|
||||
datasourceTypes: [ds.type],
|
||||
sourceEntryPoint: SOURCE_ENTRY_POINTS.DATASOURCE_PAGE,
|
||||
eventLocation: EVENT_LOCATIONS.EMPTY_DASHBOARD,
|
||||
});
|
||||
}
|
||||
|
||||
return dashboards;
|
||||
}, [datasourceUid]);
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import { t } from '@grafana/i18n';
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
import { getDragStyles, InlineField, Select, useStyles2 } from '@grafana/ui';
|
||||
import {
|
||||
getSidebarWidth,
|
||||
getFieldSelectorWidth,
|
||||
LogsTableFieldSelector,
|
||||
MIN_WIDTH,
|
||||
} from 'app/features/logs/components/fieldSelector/FieldSelector';
|
||||
@@ -279,7 +279,7 @@ export function LogsTableWrap(props: Props) {
|
||||
// The panel state is updated when the user interacts with the multi-select sidebar
|
||||
}, [currentDataFrame, getColumnsFromProps]);
|
||||
|
||||
const [sidebarWidth, setSidebarWidth] = useState(getSidebarWidth(SETTING_KEY_ROOT));
|
||||
const [sidebarWidth, setSidebarWidth] = useState(getFieldSelectorWidth(SETTING_KEY_ROOT));
|
||||
const tableWidth = props.width - sidebarWidth;
|
||||
|
||||
const styles = useStyles2(getStyles, height, sidebarWidth);
|
||||
|
||||
@@ -35,7 +35,7 @@ export const LogListFieldSelector = ({ containerElement, dataFrames, logs }: Log
|
||||
const { displayedFields, onClickShowField, onClickHideField, setDisplayedFields, logOptionsStorageKey } =
|
||||
useLogListContext();
|
||||
const [sidebarHeight, setSidebarHeight] = useState(220);
|
||||
const [sidebarWidth, setSidebarWidth] = useState(getSidebarWidth(logOptionsStorageKey));
|
||||
const [sidebarWidth, setSidebarWidth] = useState(getFieldSelectorWidth(logOptionsStorageKey));
|
||||
const dragStyles = useStyles2(getDragStyles);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -74,7 +74,7 @@ export const LogListFieldSelector = ({ containerElement, dataFrames, logs }: Log
|
||||
}, [setSidebarWidthWrapper]);
|
||||
|
||||
const expand = useCallback(() => {
|
||||
const width = getSidebarWidth(logOptionsStorageKey);
|
||||
const width = getFieldSelectorWidth(logOptionsStorageKey);
|
||||
setSidebarWidthWrapper(width < 2 * MIN_WIDTH ? DEFAULT_WIDTH : width);
|
||||
reportInteraction('logs_field_selector_expand_clicked', {
|
||||
mode: 'logs',
|
||||
@@ -205,7 +205,7 @@ export const LogsTableFieldSelector = ({
|
||||
}, [setSidebarWidthWrapper]);
|
||||
|
||||
const expand = useCallback(() => {
|
||||
const width = getSidebarWidth(SETTING_KEY_ROOT);
|
||||
const width = getFieldSelectorWidth(SETTING_KEY_ROOT);
|
||||
setSidebarWidthWrapper(width < 2 * MIN_WIDTH ? DEFAULT_WIDTH : width);
|
||||
reportInteraction('logs_field_selector_expand_clicked', {
|
||||
mode: 'table',
|
||||
@@ -436,7 +436,7 @@ function getSuggestedFields(logs: LogListModel[], displayedFields: string[], def
|
||||
return suggestedFields;
|
||||
}
|
||||
|
||||
export function getSidebarWidth(logOptionsStorageKey?: string): number {
|
||||
export function getFieldSelectorWidth(logOptionsStorageKey?: string): number {
|
||||
const width =
|
||||
(logOptionsStorageKey
|
||||
? parseInt(store.get(`${logOptionsStorageKey}.fieldSelector.width`) ?? DEFAULT_WIDTH, 10)
|
||||
@@ -445,7 +445,7 @@ export function getSidebarWidth(logOptionsStorageKey?: string): number {
|
||||
return width < MIN_WIDTH ? MIN_WIDTH : width;
|
||||
}
|
||||
|
||||
export function getSidebarState(logOptionsStorageKey?: string): boolean | undefined {
|
||||
export function getFieldSelectorState(logOptionsStorageKey?: string): boolean | undefined {
|
||||
if (!logOptionsStorageKey) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createContext, ReactNode, useCallback, useContext, useEffect, useState
|
||||
|
||||
import { LogRowModel, store } from '@grafana/data';
|
||||
|
||||
import { getSidebarWidth } from '../fieldSelector/FieldSelector';
|
||||
import { getFieldSelectorWidth } from '../fieldSelector/FieldSelector';
|
||||
|
||||
import { LogLineDetailsMode } from './LogLineDetails';
|
||||
import { LogListModel } from './processing';
|
||||
@@ -56,6 +56,7 @@ export interface Props {
|
||||
logs: LogRowModel[];
|
||||
logOptionsStorageKey?: string;
|
||||
showControls: boolean;
|
||||
showFieldSelector?: boolean;
|
||||
}
|
||||
|
||||
export const LogDetailsContextProvider = ({
|
||||
@@ -68,12 +69,13 @@ export const LogDetailsContextProvider = ({
|
||||
: getDefaultDetailsMode(containerElement),
|
||||
logs,
|
||||
showControls,
|
||||
showFieldSelector,
|
||||
}: Props) => {
|
||||
const [showDetails, setShowDetails] = useState<LogListModel[]>([]);
|
||||
|
||||
const [currentLog, setCurrentLog] = useState<LogListModel | undefined>(undefined);
|
||||
const [detailsWidth, setDetailsWidthState] = useState(
|
||||
getDetailsWidth(containerElement, logOptionsStorageKey, undefined, detailsModeProp, showControls)
|
||||
getDetailsWidth(containerElement, logOptionsStorageKey, undefined, detailsModeProp, showControls, showFieldSelector)
|
||||
);
|
||||
const [detailsMode, setDetailsMode] = useState<LogLineDetailsMode>(
|
||||
detailsModeProp ?? getDefaultDetailsMode(containerElement)
|
||||
@@ -101,8 +103,10 @@ export const LogDetailsContextProvider = ({
|
||||
|
||||
// Sync log details inline and sidebar width
|
||||
useEffect(() => {
|
||||
setDetailsWidthState(getDetailsWidth(containerElement, logOptionsStorageKey, undefined, detailsMode, showControls));
|
||||
}, [containerElement, detailsMode, logOptionsStorageKey, showControls]);
|
||||
setDetailsWidthState(
|
||||
getDetailsWidth(containerElement, logOptionsStorageKey, undefined, detailsMode, showControls, showFieldSelector)
|
||||
);
|
||||
}, [containerElement, detailsMode, logOptionsStorageKey, showControls, showFieldSelector]);
|
||||
|
||||
// Sync log details width
|
||||
useEffect(() => {
|
||||
@@ -111,13 +115,20 @@ export const LogDetailsContextProvider = ({
|
||||
}
|
||||
const handleResize = debounce(() => {
|
||||
setDetailsWidthState((detailsWidth) =>
|
||||
getDetailsWidth(containerElement, logOptionsStorageKey, detailsWidth, detailsMode, showControls)
|
||||
getDetailsWidth(
|
||||
containerElement,
|
||||
logOptionsStorageKey,
|
||||
detailsWidth,
|
||||
detailsMode,
|
||||
showControls,
|
||||
showFieldSelector
|
||||
)
|
||||
);
|
||||
}, 50);
|
||||
const observer = new ResizeObserver(() => handleResize());
|
||||
observer.observe(containerElement);
|
||||
return () => observer.disconnect();
|
||||
}, [containerElement, detailsMode, logOptionsStorageKey, showControls, showDetails]);
|
||||
}, [containerElement, detailsMode, logOptionsStorageKey, showControls, showDetails, showFieldSelector]);
|
||||
|
||||
const closeDetails = useCallback(() => {
|
||||
showDetails.forEach((log) => removeDetailsScrollPosition(log));
|
||||
@@ -158,7 +169,10 @@ export const LogDetailsContextProvider = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const maxWidth = containerElement.clientWidth - getSidebarWidth(logOptionsStorageKey) - LOG_LIST_MIN_WIDTH;
|
||||
const maxWidth =
|
||||
containerElement.clientWidth -
|
||||
(showFieldSelector ? getFieldSelectorWidth(logOptionsStorageKey) : 0) -
|
||||
LOG_LIST_MIN_WIDTH;
|
||||
if (width > maxWidth) {
|
||||
return;
|
||||
}
|
||||
@@ -166,7 +180,7 @@ export const LogDetailsContextProvider = ({
|
||||
store.set(`${logOptionsStorageKey}.detailsWidth`, width);
|
||||
setDetailsWidthState(width);
|
||||
},
|
||||
[containerElement, logOptionsStorageKey]
|
||||
[containerElement, logOptionsStorageKey, showFieldSelector]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -196,12 +210,14 @@ export function getDetailsWidth(
|
||||
logOptionsStorageKey?: string,
|
||||
currentWidth?: number,
|
||||
detailsMode: LogLineDetailsMode = 'sidebar',
|
||||
showControls?: boolean
|
||||
showControls?: boolean,
|
||||
showFieldSelector?: boolean
|
||||
) {
|
||||
if (!containerElement) {
|
||||
return 0;
|
||||
}
|
||||
const availableWidth = containerElement.clientWidth - getSidebarWidth(logOptionsStorageKey);
|
||||
const availableWidth =
|
||||
containerElement.clientWidth - (showFieldSelector ? getFieldSelectorWidth(logOptionsStorageKey) : 0);
|
||||
if (detailsMode === 'inline') {
|
||||
return availableWidth - getScrollbarWidth() - (showControls ? LOG_LIST_CONTROLS_WIDTH : 0);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { setPluginLinksHook } from '@grafana/runtime';
|
||||
import { createTempoDatasource } from 'app/plugins/datasource/tempo/test/mocks';
|
||||
|
||||
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
|
||||
import { getFieldSelectorWidth } from '../fieldSelector/FieldSelector';
|
||||
import { createLogLine } from '../mocks/logRow';
|
||||
|
||||
import { emptyContextData, LogDetailsContext, LogDetailsContextData } from './LogDetailsContext';
|
||||
@@ -27,6 +28,10 @@ import { LogLineDetails, Props } from './LogLineDetails';
|
||||
import { LogListContext, LogListContextData } from './LogListContext';
|
||||
import { defaultValue } from './__mocks__/LogListContext';
|
||||
|
||||
jest.mock('../fieldSelector/FieldSelector');
|
||||
|
||||
jest.mocked(getFieldSelectorWidth).mockReturnValue(220);
|
||||
|
||||
jest.mock('@grafana/assistant', () => {
|
||||
return {
|
||||
...jest.requireActual('@grafana/assistant'),
|
||||
@@ -79,6 +84,7 @@ const setup = (
|
||||
},
|
||||
timeZone: 'browser',
|
||||
showControls: true,
|
||||
showFieldSelector: true,
|
||||
...(propOverrides || {}),
|
||||
};
|
||||
|
||||
@@ -775,4 +781,24 @@ describe('LogLineDetails', () => {
|
||||
expect(screen.getByText('value')).toBeInTheDocument();
|
||||
expect(screen.getByText('Open service overview for label')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('Width regressions', () => {
|
||||
test('should consider Fields Selector width when enabled', () => {
|
||||
jest.mocked(getFieldSelectorWidth).mockClear();
|
||||
|
||||
setup({ showFieldSelector: true }, { labels: { key1: 'label1', key2: 'label2' } });
|
||||
expect(screen.getByText('Log line')).toBeInTheDocument();
|
||||
expect(screen.getByText('Fields')).toBeInTheDocument();
|
||||
expect(getFieldSelectorWidth).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should not consider Fields Selector width when disabled', () => {
|
||||
jest.mocked(getFieldSelectorWidth).mockClear();
|
||||
|
||||
setup({ showFieldSelector: false }, { labels: { key1: 'label1', key2: 'label2' } });
|
||||
expect(screen.getByText('Log line')).toBeInTheDocument();
|
||||
expect(screen.getByText('Fields')).toBeInTheDocument();
|
||||
expect(getFieldSelectorWidth).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import { t } from '@grafana/i18n';
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
import { getDragStyles, Icon, Tab, TabsBar, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { getSidebarWidth } from '../fieldSelector/FieldSelector';
|
||||
import { getFieldSelectorWidth } from '../fieldSelector/FieldSelector';
|
||||
|
||||
import { getDetailsScrollPosition, saveDetailsScrollPosition, useLogDetailsContext } from './LogDetailsContext';
|
||||
import { LogLineDetailsComponent } from './LogLineDetailsComponent';
|
||||
@@ -22,12 +22,13 @@ export interface Props {
|
||||
timeRange: TimeRange;
|
||||
timeZone: string;
|
||||
showControls: boolean;
|
||||
showFieldSelector: boolean | undefined;
|
||||
}
|
||||
|
||||
export type LogLineDetailsMode = 'inline' | 'sidebar';
|
||||
|
||||
export const LogLineDetails = memo(
|
||||
({ containerElement, focusLogLine, logs, timeRange, timeZone, showControls }: Props) => {
|
||||
({ containerElement, focusLogLine, logs, timeRange, timeZone, showControls, showFieldSelector }: Props) => {
|
||||
const { noInteractions, logOptionsStorageKey } = useLogListContext();
|
||||
const { detailsWidth, setDetailsWidth } = useLogDetailsContext();
|
||||
const styles = useStyles2(getStyles, 'sidebar', showControls);
|
||||
@@ -48,7 +49,10 @@ export const LogLineDetails = memo(
|
||||
}
|
||||
}, [noInteractions]);
|
||||
|
||||
const maxWidth = containerElement.clientWidth - getSidebarWidth(logOptionsStorageKey) - LOG_LIST_MIN_WIDTH;
|
||||
const maxWidth =
|
||||
containerElement.clientWidth -
|
||||
(showFieldSelector ? getFieldSelectorWidth(logOptionsStorageKey) : 0) -
|
||||
LOG_LIST_MIN_WIDTH;
|
||||
|
||||
return (
|
||||
<Resizable
|
||||
|
||||
@@ -219,6 +219,7 @@ export const LogList = ({
|
||||
logs={logs}
|
||||
logOptionsStorageKey={logOptionsStorageKey}
|
||||
showControls={showControls}
|
||||
showFieldSelector={showFieldSelector}
|
||||
>
|
||||
<LogListSearchContextProvider>
|
||||
<LogListComponent
|
||||
@@ -458,6 +459,7 @@ const LogListComponent = ({
|
||||
timeRange={timeRange}
|
||||
timeZone={timeZone}
|
||||
showControls={showControls}
|
||||
showFieldSelector={showFieldSelector}
|
||||
/>
|
||||
)}
|
||||
<div className={styles.logListWrapper} ref={wrapperRef}>
|
||||
|
||||
@@ -27,7 +27,7 @@ import { config, getDataSourceSrv } from '@grafana/runtime';
|
||||
import { PopoverContent } from '@grafana/ui';
|
||||
|
||||
import { checkLogsError, checkLogsSampled, downloadLogs as download, DownloadFormat } from '../../utils';
|
||||
import { getSidebarState } from '../fieldSelector/FieldSelector';
|
||||
import { getFieldSelectorState } from '../fieldSelector/FieldSelector';
|
||||
import { getDisplayedFieldsForLogs } from '../otel/formats';
|
||||
|
||||
import { getDefaultDetailsMode, getDetailsWidth } from './LogDetailsContext';
|
||||
@@ -245,7 +245,7 @@ export const LogListContextProvider = ({
|
||||
dedupStrategy,
|
||||
fontSize,
|
||||
forceEscape: logListState.forceEscape,
|
||||
fieldSelectorOpen: getSidebarState(logOptionsStorageKey),
|
||||
fieldSelectorOpen: getFieldSelectorState(logOptionsStorageKey),
|
||||
showTime,
|
||||
showUniqueLabels,
|
||||
syntaxHighlighting,
|
||||
|
||||
@@ -61,13 +61,8 @@ export const LogListSearch = ({ listRef, logs }: Props) => {
|
||||
}
|
||||
const prev = currentResult > 0 ? currentResult - 1 : matches.length - 1;
|
||||
setCurrentResult(prev);
|
||||
if (!filterLogs) {
|
||||
// Filtering logs will only display logs containing the current search.
|
||||
// Not only scrolling is not needed in this circumstance, but it also can
|
||||
// trigger, incorrectly, infinite scrolling.
|
||||
listRef?.scrollToItem(logs.indexOf(matches[prev]), 'center');
|
||||
}
|
||||
}, [currentResult, filterLogs, listRef, logs, matches]);
|
||||
listRef?.scrollToItem(logs.indexOf(matches[prev]), 'center');
|
||||
}, [currentResult, listRef, logs, matches]);
|
||||
|
||||
const nextResult = useCallback(() => {
|
||||
if (currentResult === null) {
|
||||
@@ -83,7 +78,7 @@ export const LogListSearch = ({ listRef, logs }: Props) => {
|
||||
setCurrentResult(null);
|
||||
return;
|
||||
}
|
||||
if (currentResult === null) {
|
||||
if (!currentResult) {
|
||||
setCurrentResult(0);
|
||||
listRef?.scrollToItem(logs.indexOf(matches[0]), 'center');
|
||||
}
|
||||
|
||||
@@ -15,13 +15,10 @@ export default function GettingStartedPage({ items }: Props) {
|
||||
return (
|
||||
<Page
|
||||
navId="provisioning"
|
||||
pageNav={{
|
||||
text: t('provisioning.getting-started-page.header', 'Provisioning'),
|
||||
subTitle: t(
|
||||
'provisioning.getting-started-page.subtitle-provisioning-feature',
|
||||
'View and manage your provisioning connections'
|
||||
),
|
||||
}}
|
||||
subTitle={t(
|
||||
'provisioning.getting-started-page.subtitle-provisioning-feature',
|
||||
'View and manage your provisioning connections'
|
||||
)}
|
||||
>
|
||||
<Page.Contents>
|
||||
<Stack direction="column" gap={3}>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
// TODO: remove this when new Browse Dashboards UI is no longer feature flagged
|
||||
|
||||
import { t } from '@grafana/i18n';
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
export function getSearchPlaceholder(includePanels = false) {
|
||||
return includePanels
|
||||
@@ -11,9 +10,7 @@ export function getSearchPlaceholder(includePanels = false) {
|
||||
}
|
||||
|
||||
export function getNewDashboardPhrase() {
|
||||
return config.featureToggles.dashboardTemplates
|
||||
? t('search.dashboard-actions.empty-dashboard', 'Empty dashboard')
|
||||
: t('search.dashboard-actions.new-dashboard', 'New dashboard');
|
||||
return t('search.dashboard-actions.new-dashboard', 'New dashboard');
|
||||
}
|
||||
|
||||
export function getNewTemplateDashboardPhrase() {
|
||||
|
||||
@@ -3707,6 +3707,10 @@
|
||||
"clear": "Clear search and filters",
|
||||
"text": "No results found for your query"
|
||||
},
|
||||
"recently-viewed": {
|
||||
"empty": "Nothing viewed yet",
|
||||
"title": "Recently viewed"
|
||||
},
|
||||
"restore": {
|
||||
"all-failed_one": "Failed to restore {{count}} dashboard",
|
||||
"all-failed_other": "Failed to restore {{count}} dashboards",
|
||||
@@ -10724,7 +10728,6 @@
|
||||
"title": "New"
|
||||
},
|
||||
"new-dashboard": {
|
||||
"empty-title": "Empty dashboard",
|
||||
"title": "New dashboard"
|
||||
},
|
||||
"new-folder": {
|
||||
@@ -11868,7 +11871,6 @@
|
||||
"title-setting-connection-could-cause-temporary-outage": "Setting up this connection could cause a temporary outage"
|
||||
},
|
||||
"getting-started-page": {
|
||||
"header": "Provisioning",
|
||||
"subtitle-provisioning-feature": "View and manage your provisioning connections"
|
||||
},
|
||||
"git": {
|
||||
@@ -12634,7 +12636,6 @@
|
||||
}
|
||||
},
|
||||
"dashboard-actions": {
|
||||
"empty-dashboard": "Empty dashboard",
|
||||
"import": "Import",
|
||||
"new": "New",
|
||||
"new-dashboard": "New dashboard",
|
||||
|
||||
Reference in New Issue
Block a user