Compare commits

..

20 Commits

Author SHA1 Message Date
Cursor Agent d8c3ec994a feat: Add assistantStreaming feature toggle
Co-authored-by: ben.sully <ben.sully@grafana.com>
2025-12-12 16:05:46 +00:00
Matias Chomicki 7114b9cd3b Log Line Details: Fix width calculation in dashboards (#115248)
* FieldSelector: rename functions to be more explicit

* LogDetailsContext: calculate width based on field selector visibility

* LogLineDetails: Fix sidebar max width calculation

* Update functions usage

* Add regression and fix context calculation
2025-12-12 16:56:23 +01:00
Kristina Demeshchik b40d0e6ff4 Dashboards: Fix accessible color palettes not being saved in v2 schema (#115244)
* Fix palette color v2 conversion

* v2->v1 conversion
2025-12-12 10:36:31 -05:00
Yunwen Zheng 584615cf3f RecentlyViewedDashboards: Set up container on browsing dashboards page (#115164)
* RecentlyViewedDashboards: Set up container on browsing dashboards page
2025-12-12 10:32:05 -05:00
William Wernert 5f80a29a28 Alerting: Prevent users from saving rules to git-synced folders (#114944)
---------

Co-authored-by: Yuri Tseretyan <yuriy.tseretyan@grafana.com>
2025-12-12 15:25:08 +00:00
Bogdan Matei eab5d2b30e Dashboard: Fix rogue modal when exiting edit mode (#115240)
* Dashboard: Fix rogue modal when exiting edit mode

* Remove unnecessary change
2025-12-12 17:17:34 +02:00
Anna Urbiztondo f3421b9718 Docs: Git Sync scenarios (#115199)
* WIP

* Review

* Move scenarions

* Structure fix

* Edits, fix

* Vale, x-refs

* Feedback, tweaks

* Consolidate HA, titles

* Prettier

* Prettier

* Adding missing content

* Minor edits

* Links

* Prettier
2025-12-12 16:08:28 +01:00
Alex Khomenko 1addfd69b4 Provisioning: Fix duplicated breadcrumb (#115234)
* Provisioning: Fix duplicated breadcrumb

* translations
2025-12-12 15:00:40 +00:00
Gonzalo Trigueros Manzanas d4a627c5fc Provisioning: Add resource-level warning support. (#115023) 2025-12-12 15:59:45 +01:00
Johnny Kartheiser 46ef9aaa0a Alerting docs: Links fix (#115044)
* alerting docs: links fix

fixes 404 errors

* Alerting docs: Fix Slack integration links

Fixes Slack links and clarifies the first two steps.

* prettier
2025-12-12 08:58:10 -06:00
Serge Zaitsev 6ce672dd00 Chore: Fix mysql query for annotation migration (#115222)
fix mysql query for annotation migration
2025-12-12 15:37:43 +01:00
Matheus Macabu 403f4d41de APIServer: Add wiring for audit backend and policy rule evaluator (#115212) 2025-12-12 15:17:44 +01:00
Juan Cabanas 6512259acc DashboardLibrary: Restore New dashboard naming (#115184) 2025-12-12 10:10:05 -03:00
Will Assis b2dd095bd8 Unified-storage: sql backend key path backfill (#115033)
* unified-storage: add migration to backfill key_path in resource_history
2025-12-12 08:09:51 -05:00
Charandas e525b529a8 fix: Add panic for nil authorizer in installer (#115186) 2025-12-12 05:01:03 -08:00
Paul Marbach 7805e18368 Sparkline: Export a class component for now (#115189) 2025-12-12 07:56:31 -05:00
Levente Balogh 7a07a49ecc Dashboard: Update toolbar layout (option 2.) (#115210)
fix: dashboard toolbar layout updates
2025-12-12 12:22:04 +00:00
beejeebus 9a4e13800d Guard config CRUD metrics so it's safe for grafana-enterprise
Previous attempt to land this required this PR and a grafana-enterprise
PR to land at the ~same time.

This PR guards the use of `dsConfigHandlerRequestsDuration` with a nil
check, and doesn't change any existing APIs, so we can land it without
any timing issues with grafana-enterprise.

Once this has landed, we'll make a follow-up PR for grafana-enterprise.
2025-12-12 07:21:29 -05:00
Nathan Marrs a0c4e8b4f4 Suggested Dashboards: Add missing loaded event tracking for v1 of feature (#115195)
## Summary

Fixes a regression where the `loaded` analytics event was not being tracked for the `BasicProvisionedDashboardsEmptyPage` component, which is the component shown in production when the `suggestedDashboards` feature toggle is disabled (i.e. community dashboards disabled but v1 of feature enabled)

## Problem

Regression introduced by https://github.com/grafana/grafana/pull/112808/changes#diff-3a19d2e887a3344cb0bcd2449b570bd50a7d78d1d473f4a3cf623f9fe40f35fc adding community dashboard support to `SuggestedDashboards`, the `BasicProvisionedDashboardsEmptyPage` component was missing the `loaded` event tracking. Component is mounted here: https://github.com/grafana/grafana/pull/112808/changes#diff-fba79ed6f8bfb5f712bdd529155158977a3e081d1d6a5932a5fa90fb57a243e6R82. This caused analytics discrepancies where in the past 7 days (note: issue has been present for last several weeks but here is sample of data from previous week):

- 106 provisioned dashboard items were clicked
- Only 1 `loaded` event was received (from `SuggestedDashboards` when the feature toggle is enabled)
- The `loaded` events are missing for the production v1 flow (when `suggestedDashboards` feature toggle is off)

## Root Cause

The `BasicProvisionedDashboardsEmptyPage` component (used in v1 flow in production) was never updated with the `loaded` event tracking that was added to `SuggestedDashboards` in PR #113417. Since the `suggestedDashboards` feature toggle is not enabled in production, users were seeing `BasicProvisionedDashboardsEmptyPage` which had no tracking, resulting in missing analytics events.

## Solution

Added the `loaded` event tracking to `BasicProvisionedDashboardsEmptyPage` using the same approach that was previously used (tracking inside the async callback when dashboards are loaded). This ensures consistency with the existing pattern and restores analytics tracking for the production flow.

## Changes

- Added `DashboardLibraryInteractions.loaded()` call in `BasicProvisionedDashboardsEmptyPage` when dashboards are successfully loaded
- Uses the same tracking pattern as the original implementation (tracking inside async callback)
- Matches the event structure used in `SuggestedDashboards` for consistency

## Testing

- Verified that `loaded` events are now tracked when `BasicProvisionedDashboardsEmptyPage` loads dashboards
- Confirmed the event includes correct `contentKinds`, `datasourceTypes`, and `eventLocation` values
- No duplicate events are sent (tracking only occurs once per load)

## Related

- Original analytics implementation: #113417
- Related PR: #112808
- Component: [`BasicProvisionedDashboardsEmptyPage.tsx`](https://github.com/grafana/grafana/blob/main/public/app/features/dashboard/dashgrid/DashboardLibrary/BasicProvisionedDashboardsEmptyPage.tsx)
2025-12-12 09:16:55 -03:00
Victor Marin fa62113b41 Dashboards: Fix custom variable legacy model to return options when flag is set (#115154)
* fix custom var legacy model options property

* add test
2025-12-12 12:12:46 +00:00
85 changed files with 2064 additions and 212 deletions
@@ -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",
@@ -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
@@ -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.
@@ -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
@@ -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
@@ -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
@@ -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.
@@ -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
@@ -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
@@ -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 Grafanas 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 Grafanas 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/)
@@ -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/`
@@ -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
## Primaryreplica 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 activeactive.
- **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.
@@ -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 regions 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.
@@ -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 others 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.
@@ -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; |
@@ -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 */
+5
View File
@@ -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} />;
}
}
+29
View File
@@ -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}
}
+24 -16
View File
@@ -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
+27 -15
View File
@@ -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)
}
+1
View File
@@ -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()
}
+5
View File
@@ -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{
+8
View File
@@ -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()
+7
View File
@@ -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,
+7 -2
View File
@@ -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
View File
@@ -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,
+7
View File
@@ -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",
+1
View File
@@ -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
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
212 logsPanelControls preview @grafana/observability-logs false false true
213 metricsFromProfiles experimental @grafana/observability-traces-and-profiling false false true
214 grafanaAssistantInProfilesDrilldown GA @grafana/observability-traces-and-profiling false false true
215 assistantStreaming preview @grafana/grafana-app-platform-squad false false false
216 tempoAlerting experimental @grafana/observability-traces-and-profiling false false false
217 pluginsAutoUpdate experimental @grafana/plugins-platform-backend false false false
218 alertingListViewV2PreviewToggle privatePreview @grafana/alerting-squad false false true
+4
View File
@@ -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
View File
@@ -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",
+11 -2
View File
@@ -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())
+1 -1
View File
@@ -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 {
+15
View File
@@ -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) {
+1 -1
View File
@@ -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),
}),
});
@@ -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),
}),
};
}
@@ -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() {
+4 -3
View File
@@ -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",