diff --git a/docs/sources/datasources/tempo/_index.md b/docs/sources/datasources/tempo/_index.md index 8b2123b5cd4..836db6cac14 100644 --- a/docs/sources/datasources/tempo/_index.md +++ b/docs/sources/datasources/tempo/_index.md @@ -71,7 +71,14 @@ refs: # Tempo data source -Grafana ships with built-in support for [Tempo](https://grafana.com/docs/tempo/latest/), a high-volume, minimal-dependency trace storage, open source tracing solution from Grafana Labs. This topic explains configuration and queries specific to the Tempo data source. +Grafana ships with built-in support for [Tempo](https://grafana.com/docs/tempo//), a high-volume, minimal-dependency trace storage, open source tracing solution from Grafana Labs. + +To learn more about traces, refer to [Introduction to tracing](https://grafana.com/docs/tempo//introduction/). + +To use traces, you need you have an application or service that is instrumented to emit traces. +Refer to the [Instrument for tracing](https://grafana.com/docs/tempo//getting-started/instrumentation/) for more information. + +## Add a data source For instructions on how to add a data source to Grafana, refer to the [administration documentation](ref:data-source-management). Only users with the organization administrator role can add data sources. @@ -81,6 +88,8 @@ This video explains how to add data sources, including Loki, Tempo, and Mimir, t {{< youtube id="cqHO0oYW6Ic" start="298" >}} -Once you've added the data source, you can [configure it]({{< relref "./configure-tempo-data-source/" >}}) so that your Grafana instance's users can create queries in its [query editor]({{< relref "./query-editor/" >}}) when they [build dashboards](ref:build-dashboards) and use [Explore](ref:explore). +## Learn more + +After you've added the data source, you can [configure it](./configure-tempo-data-source/) so that your Grafana instance's users can create queries in its [query editor](./query-editor/) when they [build dashboards](ref:build-dashboards) and use [Explore](ref:explore). {{< section withDescriptions="true">}} diff --git a/docs/sources/datasources/tempo/configure-tempo-data-source.md b/docs/sources/datasources/tempo/configure-tempo-data-source.md index 24844b82ef3..39d3cdf751a 100644 --- a/docs/sources/datasources/tempo/configure-tempo-data-source.md +++ b/docs/sources/datasources/tempo/configure-tempo-data-source.md @@ -131,7 +131,7 @@ To use streaming, you need to: - Run Tempo version 2.2 or later, or Grafana Enterprise Traces (GET) version 2.2 or later, or use Grafana Cloud Traces. - Tempo must have `stream_over_http_enabled: true` for streaming to work. - For more information, refer to [Tempo GRPC API](https://grafana.com/docs/tempo//api_docs/#tempo-grpc-api). + For more information, refer to [Tempo gRPC API](https://grafana.com/docs/tempo//api_docs/#tempo-grpc-api). - For self-managed Tempo or GET instances: If your Tempo or GET instance is behind a load balancer or proxy that doesn't supporting gRPC or HTTP2, streaming may not work and should be deactivated. diff --git a/docs/sources/datasources/tempo/query-editor/_index.md b/docs/sources/datasources/tempo/query-editor/_index.md index b561a54bdcd..b6aa5c0f49b 100644 --- a/docs/sources/datasources/tempo/query-editor/_index.md +++ b/docs/sources/datasources/tempo/query-editor/_index.md @@ -105,7 +105,7 @@ Refer to the [Search using the TraceQL query builder documentation]({{< relref " The **TraceQL** query editor lets you search by trace ID and write TraceQL queries using autocomplete. -Refer to the [TraceQL query editor documentation]({{< relref "./traceql-editor" >}}) to learn more about constructing queries using a code-editor-like experience. +Refer to the [TraceQL query editor documentation](./traceql-editor) to learn more about constructing queries using a code-editor-like experience. ![The TraceQL query editor](/media/docs/grafana/data-sources/tempo/query-editor/tempo-ds-query-traceql-v11.png) @@ -113,8 +113,8 @@ You can also search for a trace ID by entering it into the query field. ### Service graph view -Grafana’s **Service Graph** view uses metrics to display span request rates, error rates, and durations, as well as service graphs. -Once the requirements are set up, this preconfigured view is immediately available. +The **Service Graph** view uses metrics to display span request rates, error rates, and durations, as well as service graphs. +After the requirements are set up, this preconfigured view is immediately available. Using the service graph view, you can: diff --git a/docs/sources/datasources/tempo/query-editor/traceql-editor.md b/docs/sources/datasources/tempo/query-editor/traceql-editor.md index 7752a290229..cc5b38beaaa 100644 --- a/docs/sources/datasources/tempo/query-editor/traceql-editor.md +++ b/docs/sources/datasources/tempo/query-editor/traceql-editor.md @@ -12,7 +12,7 @@ labels: - oss menuTitle: Write TraceQL queries title: Write TraceQL queries with the editor -weight: 300 +weight: 400 refs: explore: - pattern: /docs/grafana/ @@ -41,4 +41,4 @@ refs: [//]: # 'Shared content for the TraceQL query editor' [//]: # 'This content is located in /docs/sources/shared/datasources/tempo-editor-traceql.md' -{{< docs/shared source="grafana" lookup="datasources/tempo-editor-traceql.md" version="" >}} +{{< docs/shared source="grafana" lookup="datasources/tempo-editor-traceql.md" version="" >}} diff --git a/docs/sources/datasources/tempo/query-editor/traceql-search.md b/docs/sources/datasources/tempo/query-editor/traceql-search.md index b885f1239d8..84ac0b396c4 100644 --- a/docs/sources/datasources/tempo/query-editor/traceql-search.md +++ b/docs/sources/datasources/tempo/query-editor/traceql-search.md @@ -56,4 +56,4 @@ To enable the TraceQL query builder in self-hosted Grafana through version 10.1, [//]: # 'Shared content for the Search - TraceQL query builder' -{{< docs/shared source="grafana" lookup="datasources/tempo-search-traceql.md" leveloffset="+1" version="" >}} +{{< docs/shared source="grafana" lookup="datasources/tempo-search-traceql.md" leveloffset="+1" version="" >}} diff --git a/docs/sources/datasources/tempo/query-editor/traceql-structure.md b/docs/sources/datasources/tempo/query-editor/traceql-structure.md new file mode 100644 index 00000000000..b67d57cedf3 --- /dev/null +++ b/docs/sources/datasources/tempo/query-editor/traceql-structure.md @@ -0,0 +1,21 @@ +--- +description: Learn how to create TraceQL queries are structured. +keywords: + - queries + - TraceQL +labels: + products: + - cloud + - enterprise + - oss +menuTitle: TraceQL query structure +title: TraceQL query structure +weight: 200 +--- + +# TraceQL query structure + +[//]: # 'Shared content for best practices for traces' +[//]: # 'This content is located in /tempo/docs/sources/shared/trace-structure.md' + +{{< docs/shared source="tempo" lookup="traceql-query-structure.md" version="" >}} diff --git a/docs/sources/datasources/tempo/service-graph.md b/docs/sources/datasources/tempo/service-graph.md index 0e6c1f8b657..d1644d41b73 100644 --- a/docs/sources/datasources/tempo/service-graph.md +++ b/docs/sources/datasources/tempo/service-graph.md @@ -72,8 +72,8 @@ You use the Service Graph to detect performance issues; track increases in error ## Display the Service Graph -1. [Configure Grafana Alloy](https://grafana.com/docs/tempo/latest/configuration/grafana-alloy/) or [Tempo or GET](https://grafana.com/docs/tempo/latest/metrics-generator/service_graphs/#tempo) to generate Service Graph data. -1. Link a Prometheus data source in the Tempo data source's [Service Graph]({{< relref "./configure-tempo-data-source#configure-service-graph" >}}) settings. +1. [Configure Grafana Alloy](https://grafana.com/docs/tempo//configuration/grafana-alloy/) or [Tempo or GET](https://grafana.com/docs/tempo//metrics-generator/service_graphs/#tempo) to generate Service Graph data. +1. Link a Prometheus data source in the Tempo data source's [Service Graph](./configure-tempo-data-source#configure-service-graph) settings. 1. Navigate to [Explore](ref:explore). 1. Select the Tempo data source. 1. Select the **Service Graph** query type. @@ -113,10 +113,10 @@ To open the Service Graph view: 1. Run the query. 1. _(Optional)_ Filter your results. -{{% admonition type="note" %}} +{{< admonition type="note" >}} Grafana uses the `traces_spanmetrics_calls_total` metric to display the name, rate, and error rate columns, and `traces_spanmetrics_latency_bucket` to display the duration column. These metrics must exist in your Prometheus data source. -{{% /admonition %}} +{{< /admonition >}} To open a query in Prometheus with the span name of that row automatically set in the query, click a row in the **rate**, **error rate**, or **duration** columns. diff --git a/docs/sources/datasources/tempo/traces-in-grafana/_index.md b/docs/sources/datasources/tempo/traces-in-grafana/_index.md new file mode 100644 index 00000000000..c3f622bb57d --- /dev/null +++ b/docs/sources/datasources/tempo/traces-in-grafana/_index.md @@ -0,0 +1,23 @@ +--- +description: Learn about how you can use tracing data in Grafana Cloud to query data, + generate metrics, and link your tracing data with logs, metrics, and profiles. +keywords: + - Grafana + - traces + - tracing +title: Use traces in Grafana +weight: 275 +--- + +# Use traces in Grafana + +Using traces, you can search for traces, generate metrics from spans, and link your tracing data with logs, metrics, and profiles. + +This page provides a summary of how you can use tracing data in Grafana. + +For general documentation on querying data sources in Grafana, refer to [Query and transform data](https://grafana.com/docs/grafana-cloud/visualizations/panels-visualizations/query-transform-data/). + +[//]: # 'Shared content for best practices for traces' +[//]: # 'This content is located in /tempo/docs/sources/shared/tempo-in-grafana.md' + +{{< docs/shared source="tempo" lookup="tempo-in-grafana.md" version="" >}} diff --git a/docs/sources/datasources/tempo/json-trace-file.md b/docs/sources/datasources/tempo/traces-in-grafana/json-trace-file.md similarity index 82% rename from docs/sources/datasources/tempo/json-trace-file.md rename to docs/sources/datasources/tempo/traces-in-grafana/json-trace-file.md index 0387eab883a..77b45011a2f 100644 --- a/docs/sources/datasources/tempo/json-trace-file.md +++ b/docs/sources/datasources/tempo/traces-in-grafana/json-trace-file.md @@ -12,7 +12,9 @@ labels: - oss menuTitle: Upload JSON trace file title: Upload a JSON trace file -weight: 400 +weight: 900 +aliases: + - ../json-trace-file/ # /docs/grafana/latest/datasources/tempo/json-trace-file --- # Upload a JSON trace file @@ -20,7 +22,15 @@ weight: 400 You can upload a JSON file that contains a single trace and visualize it. If the file has multiple traces, Grafana visualizes the first trace. -**To download a trace or Service Graph through the inspector:** +To upload a trace file: + +1. Select **Explore** in Grafana. +1. Select **Import trace** in the right corner. +1. Upload your JSON trace file. + +## Download a trace or service graph + +To download a trace or Service Graph through the [Inspector panel](https://grafana.com/docs/grafana//explore/explore-inspector/): 1. Open the inspector. 1. Navigate to the **Data** tab. diff --git a/docs/sources/datasources/tempo/link-trace-id.md b/docs/sources/datasources/tempo/traces-in-grafana/link-trace-id.md similarity index 87% rename from docs/sources/datasources/tempo/link-trace-id.md rename to docs/sources/datasources/tempo/traces-in-grafana/link-trace-id.md index d53496e3784..07c7f6f2bec 100644 --- a/docs/sources/datasources/tempo/link-trace-id.md +++ b/docs/sources/datasources/tempo/traces-in-grafana/link-trace-id.md @@ -12,7 +12,10 @@ labels: - oss menuTitle: Link to a trace ID title: Link to a trace ID -weight: 700 +weight: 800 +aliases: + - ../link-trace-id/ # /docs/grafana/latest/datasources/tempo/link-trace-id/ + refs: configure-grafana-feature-toggles: - pattern: /docs/grafana/ @@ -69,10 +72,10 @@ You can link to Tempo traces from logs or metrics. You can link to Tempo traces from logs in Loki, Elasticsearch, Splunk, and other logs data sources by configuring an internal link. -To configure this feature, see the [Derived fields]({{< relref "../loki#configure-derived-fields" >}}) section of the Loki data source docs or the [Data links]({{< relref "../elasticsearch#data-links" >}}) section of the Elasticsearch or Splunk data source docs. +To configure this feature, refer to the [Derived fields](../../loki#configure-derived-fields) section of the Loki data source docs or the [Data links](../../elasticsearch#data-links) section of the Elasticsearch or Splunk data source docs. ## Link to a trace ID from metrics You can link to Tempo traces from metrics in Prometheus data sources by configuring an exemplar. -To configure this feature, see the [introduction to exemplars](ref:exemplars) documentation. +To configure this feature, refer to the [Exemplars](ref:exemplars) documentation. diff --git a/docs/sources/explore/trace-integration.md b/docs/sources/explore/trace-integration.md index f9b0779b12d..532bb3ca8f2 100644 --- a/docs/sources/explore/trace-integration.md +++ b/docs/sources/explore/trace-integration.md @@ -157,7 +157,8 @@ The node graph requires data to be returned from the data source in a specific f ## Service graph -A service graph visualizes span metrics, including rates, error rates, and durations (RED), along with service relationships. Once the requirements are configured, this pre-configured view is immediately available. +A service graph visualizes rates, error rates, and durations (RED), along with service relationships. +After the requirements are configured, this pre-configured view is immediately available. For additional information refer to the following documentation: diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/saml-ui/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/saml-ui/index.md index 1be5e2d993e..0a192c80baf 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/saml-ui/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/saml-ui/index.md @@ -113,8 +113,26 @@ Sign in to Grafana and navigate to **Administration > Authentication > Configure 1. If you wish to [map user information from SAML assertions]({{< relref "../saml#assertion-mapping" >}}), complete the **Assertion attributes mappings** section. - You also need to configure the **Groups attribute** field if you want to use group synchronization. Group sync allows you to automatically map users to Grafana teams or role-based access control roles based on their SAML group membership. - To learn more about how to configure group synchronization, refer to [Configure team sync]({{< relref "../../configure-team-sync" >}}) and [Configure group attribute sync](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-group-attribute-sync) documentation. +If Azure is the Identity Provider over SAML there are caveats for the assertion attribute mappings. Due to how Azure interprets these attributes the full URL will need to be entered in the corresponding fields within the UI, which should match the URLs from the metadata XML. There are differences depending on whether it's a Role or Group claim vs other assertions which Microsoft has [documented](https://learn.microsoft.com/en-us/entra/identity-platform/reference-claims-customization#table-2-saml-restricted-claim-set). + +Group and Role: + +``` +http://schemas.microsoft.com/ws/2008/06/identity/claims/role +http://schemas.microsoft.com/ws/2008/06/identity/claims/groups +http://schemas.microsoft.com/identity/claims/displayname +``` + +Other Assertions: + +``` +http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress +``` + +![image](https://github.com/user-attachments/assets/23910ab8-20ec-4dfd-8ef6-7dbaec51ac90) + +You also need to configure the **Groups attribute** field if you want to use group synchronization. Group sync allows you to automatically map users to Grafana teams or role-based access control roles based on their SAML group membership. +To learn more about how to configure group synchronization, refer to [Configure team sync]({{< relref "../../configure-team-sync" >}}) and [Configure group attribute sync](https://grafana.com/docs/grafana//setup-grafana/configure-security/configure-group-attribute-sync) documentation. 1. If you want to automatically assign users' roles based on their SAML roles, complete the **Role mapping** section. diff --git a/e2e/various-suite/solo-route.spec.ts b/e2e/various-suite/solo-route.spec.ts index 415257ead7c..c7f90f8cd36 100644 --- a/e2e/various-suite/solo-route.spec.ts +++ b/e2e/various-suite/solo-route.spec.ts @@ -22,7 +22,7 @@ describe('Solo Route', () => { cy.contains('uplot-main-div').should('not.exist'); }); - it('Can view solo repeated panel in scenes', () => { + /*it('Can view solo repeated panel in scenes', () => { // open Panel Tests - Graph NG e2e.pages.SoloPanel.visit( 'templating-repeating-panels/templating-repeating-panels?orgId=1&from=1699934989607&to=1699956589607&panelId=panel-2-clone-0&__feature.dashboardSceneSolo=true' @@ -30,7 +30,7 @@ describe('Solo Route', () => { e2e.components.Panels.Panel.title('server=A').should('exist'); cy.contains('uplot-main-div').should('not.exist'); - }); + });*/ it('Can view solo in repeated row and panel in scenes', () => { // open Panel Tests - Graph NG diff --git a/pkg/apis/dashboard/utils.go b/pkg/apis/dashboard/utils.go new file mode 100644 index 00000000000..a7fa10af2d0 --- /dev/null +++ b/pkg/apis/dashboard/utils.go @@ -0,0 +1,51 @@ +package dashboard + +import ( + "strings" + + "github.com/grafana/grafana/pkg/apimachinery/utils" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +var PluginIDRepoName = "plugin" +var fileProvisionedRepoPrefix = "file:" + +// ProvisionedFileNameWithPrefix adds the `file:` prefix to the +// provisioner name, to be used as the annotation for dashboards +// provisioned from files +func ProvisionedFileNameWithPrefix(name string) string { + if name == "" { + return "" + } + + return fileProvisionedRepoPrefix + name +} + +// GetProvisionedFileNameFromMeta returns the provisioner name +// from a given annotation string, which is in the form file: +func GetProvisionedFileNameFromMeta(annotation string) (string, bool) { + return strings.CutPrefix(annotation, fileProvisionedRepoPrefix) +} + +// SetPluginIDMeta sets the repo name to "plugin" and the path to the plugin ID +func SetPluginIDMeta(obj unstructured.Unstructured, pluginID string) { + if pluginID == "" { + return + } + + annotations := obj.GetAnnotations() + if annotations == nil { + annotations = map[string]string{} + } + annotations[utils.AnnoKeyRepoName] = PluginIDRepoName + annotations[utils.AnnoKeyRepoPath] = pluginID + obj.SetAnnotations(annotations) +} + +// GetPluginIDFromMeta returns the plugin ID from the meta if the repo name is "plugin" +func GetPluginIDFromMeta(obj utils.GrafanaMetaAccessor) string { + if obj.GetRepositoryName() == PluginIDRepoName { + return obj.GetRepositoryPath() + } + return "" +} diff --git a/pkg/registry/apis/dashboard/legacy/sql_dashboards.go b/pkg/registry/apis/dashboard/legacy/sql_dashboards.go index 6c1553f9bad..0ba1e67cc88 100644 --- a/pkg/registry/apis/dashboard/legacy/sql_dashboards.go +++ b/pkg/registry/apis/dashboard/legacy/sql_dashboards.go @@ -23,7 +23,6 @@ import ( "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" gapiutil "github.com/grafana/grafana/pkg/services/apiserver/utils" "github.com/grafana/grafana/pkg/services/dashboards" - "github.com/grafana/grafana/pkg/services/dashboards/service" "github.com/grafana/grafana/pkg/services/provisioning" "github.com/grafana/grafana/pkg/storage/legacysql" "github.com/grafana/grafana/pkg/storage/unified/resource" @@ -312,7 +311,7 @@ func (a *dashboardSqlAccess) scanRow(rows *sql.Rows, history bool) (*dashboardRo ts := time.Unix(origin_ts.Int64, 0) repo := &utils.ResourceRepositoryInfo{ - Name: origin_name.String, + Name: dashboard.ProvisionedFileNameWithPrefix(origin_name.String), Hash: origin_hash.String, Timestamp: &ts, } @@ -331,7 +330,7 @@ func (a *dashboardSqlAccess) scanRow(rows *sql.Rows, history bool) (*dashboardRo meta.SetRepositoryInfo(repo) } else if plugin_id.String != "" { meta.SetRepositoryInfo(&utils.ResourceRepositoryInfo{ - Name: "plugin", + Name: dashboard.PluginIDRepoName, Path: plugin_id.String, }) } @@ -427,7 +426,7 @@ func (a *dashboardSqlAccess) SaveDashboard(ctx context.Context, orgId int64, das out, err := a.dashStore.SaveDashboard(ctx, dashboards.SaveDashboardCommand{ OrgID: orgId, Message: meta.GetMessage(), - PluginID: service.GetPluginIDFromMeta(meta), + PluginID: dashboard.GetPluginIDFromMeta(meta), Dashboard: simplejson.NewFromAny(dash.Spec.UnstructuredContent()), FolderUID: meta.GetFolder(), Overwrite: true, // already passed the revisionVersion checks! diff --git a/pkg/registry/apis/dashboard/legacy/sql_dashboards_test.go b/pkg/registry/apis/dashboard/legacy/sql_dashboards_test.go new file mode 100644 index 00000000000..7a75f7acf3b --- /dev/null +++ b/pkg/registry/apis/dashboard/legacy/sql_dashboards_test.go @@ -0,0 +1,110 @@ +package legacy + +import ( + "context" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" + "github.com/grafana/grafana/pkg/apimachinery/utils" + "github.com/grafana/grafana/pkg/services/provisioning" + "github.com/stretchr/testify/require" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestScanRow(t *testing.T) { + mockDB, mock, err := sqlmock.New() + require.NoError(t, err) + defer mockDB.Close() // nolint:errcheck + + pathToFile := "path/to/file" + provisioner := provisioning.NewProvisioningServiceMock(context.Background()) + provisioner.GetDashboardProvisionerResolvedPathFunc = func(name string) string { return "provisioner" } + store := &dashboardSqlAccess{ + namespacer: func(_ int64) string { return "default" }, + provisioning: provisioner, + } + + columns := []string{"orgId", "dashboard_id", "name", "folder_uid", "deleted", "plugin_id", "origin_name", "origin_path", "origin_hash", "origin_ts", "created", "createdBy", "createdByID", "updated", "updatedBy", "updatedByID", "version", "message", "data"} + id := int64(100) + title := "Test Dashboard" + folderUID := "folder123" + timestamp := time.Now() + k8sTimestamp := v1.Time{Time: timestamp} + version := int64(2) + message := "updated message" + createdUser := "creator" + updatedUser := "updator" + + t.Run("Should scan a valid row correctly", func(t *testing.T) { + rows := sqlmock.NewRows(columns).AddRow(1, id, title, folderUID, nil, "", "", "", "", 0, timestamp, createdUser, 0, timestamp, updatedUser, 0, version, message, []byte(`{"key": "value"}`)) + mock.ExpectQuery("SELECT *").WillReturnRows(rows) + resultRows, err := mockDB.Query("SELECT *") + require.NoError(t, err) + defer resultRows.Close() // nolint:errcheck + resultRows.Next() + + row, err := store.scanRow(resultRows, false) + require.NoError(t, err) + require.NotNil(t, row) + require.Equal(t, "Test Dashboard", row.Dash.Name) + require.Equal(t, version, row.RV) // rv should be the dashboard version + require.Equal(t, v0alpha1.Unstructured{ + Object: map[string]interface{}{"key": "value"}, + }, row.Dash.Spec) + require.Equal(t, "default", row.Dash.Namespace) + require.Equal(t, &continueToken{orgId: int64(1), id: id}, row.token) + + meta, err := utils.MetaAccessor(row.Dash) + require.NoError(t, err) + require.Equal(t, id, meta.GetDeprecatedInternalID()) // nolint:staticcheck + require.Equal(t, version, meta.GetGeneration()) // generation should be dash version + require.Equal(t, k8sTimestamp, meta.GetCreationTimestamp()) + require.Equal(t, "user:"+createdUser, meta.GetCreatedBy()) // should be prefixed by user: + require.Equal(t, "user:"+updatedUser, meta.GetUpdatedBy()) // should be prefixed by user: + require.Equal(t, message, meta.GetMessage()) + require.Equal(t, folderUID, meta.GetFolder()) + }) + + t.Run("File provisioned dashboard should have annotations", func(t *testing.T) { + rows := sqlmock.NewRows(columns).AddRow(1, id, title, folderUID, nil, "", "provisioner", pathToFile, "hashing", 100000, timestamp, createdUser, 0, timestamp, updatedUser, 0, version, message, []byte(`{"key": "value"}`)) + mock.ExpectQuery("SELECT *").WillReturnRows(rows) + resultRows, err := mockDB.Query("SELECT *") + require.NoError(t, err) + defer resultRows.Close() // nolint:errcheck + resultRows.Next() + + row, err := store.scanRow(resultRows, false) + require.NoError(t, err) + require.NotNil(t, row) + + meta, err := utils.MetaAccessor(row.Dash) + require.NoError(t, err) + require.Equal(t, "file:provisioner", meta.GetRepositoryName()) // should be prefixed by file: + require.Equal(t, "../"+pathToFile, meta.GetRepositoryPath()) // relative to provisioner + require.Equal(t, "hashing", meta.GetRepositoryHash()) + ts, err := meta.GetRepositoryTimestamp() + require.NoError(t, err) + require.Equal(t, int64(100000), ts.Unix()) + }) + + t.Run("Plugin provisioned dashboard should have annotations", func(t *testing.T) { + rows := sqlmock.NewRows(columns).AddRow(1, id, title, folderUID, nil, "slo", "", "", "", 0, timestamp, createdUser, 0, timestamp, updatedUser, 0, version, message, []byte(`{"key": "value"}`)) + mock.ExpectQuery("SELECT *").WillReturnRows(rows) + resultRows, err := mockDB.Query("SELECT *") + require.NoError(t, err) + defer resultRows.Close() // nolint:errcheck + resultRows.Next() + + row, err := store.scanRow(resultRows, false) + require.NoError(t, err) + require.NotNil(t, row) + + meta, err := utils.MetaAccessor(row.Dash) + require.NoError(t, err) + require.Equal(t, "plugin", meta.GetRepositoryName()) + require.Equal(t, "slo", meta.GetRepositoryPath()) // the ID of the plugin + require.Equal(t, "", meta.GetRepositoryHash()) // hash is not used on plugins + }) +} diff --git a/pkg/registry/apis/dashboard/legacysearcher/search_client.go b/pkg/registry/apis/dashboard/legacysearcher/search_client.go index c0e18288ea5..71501c3fdcd 100644 --- a/pkg/registry/apis/dashboard/legacysearcher/search_client.go +++ b/pkg/registry/apis/dashboard/legacysearcher/search_client.go @@ -18,6 +18,7 @@ import ( "github.com/grafana/grafana/pkg/services/sqlstore/searchstore" "github.com/grafana/grafana/pkg/storage/unified/resource" "google.golang.org/grpc" + "k8s.io/apimachinery/pkg/selection" ) type DashboardSearchClient struct { @@ -29,6 +30,7 @@ func NewDashboardSearchClient(dashboardStore dashboards.Store) *DashboardSearchC return &DashboardSearchClient{dashboardStore: dashboardStore} } +// nolint:gocyclo func (c *DashboardSearchClient) Search(ctx context.Context, req *resource.ResourceSearchRequest, opts ...grpc.CallOption) (*resource.ResourceSearchResponse, error) { user, err := identity.GetRequester(ctx) if err != nil { @@ -103,6 +105,8 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resource.Resour } for _, field := range req.Options.Fields { + vals := field.GetValues() + switch field.Key { case resource.SEARCH_FIELD_TAGS: query.Tags = field.GetValues() @@ -110,7 +114,6 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resource.Resour query.DashboardUIDs = field.GetValues() query.DashboardIds = nil case resource.SEARCH_FIELD_FOLDER: - vals := field.GetValues() folders := make([]string, len(vals)) for i, val := range vals { @@ -122,12 +125,28 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resource.Resour } query.FolderUIDs = folders - } - } + case resource.SEARCH_FIELD_REPOSITORY_PATH: + // only one value is supported in legacy search + if len(vals) != 1 { + return nil, fmt.Errorf("only one repo path query is supported") + } + query.ProvisionedPath = vals[0] + case resource.SEARCH_FIELD_REPOSITORY_NAME: + if field.Operator == string(selection.NotIn) { + for _, val := range vals { + name, _ := dashboard.GetProvisionedFileNameFromMeta(val) + query.ProvisionedReposNotIn = append(query.ProvisionedReposNotIn, name) + } + continue + } - res, err := c.dashboardStore.FindDashboards(ctx, query) - if err != nil { - return nil, err + // only one value is supported in legacy search + if len(vals) != 1 { + return nil, fmt.Errorf("only one repo name is supported") + } + + query.ProvisionedRepo, _ = dashboard.GetProvisionedFileNameFromMeta(vals[0]) + } } searchFields := resource.StandardSearchFields() @@ -141,6 +160,41 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resource.Resour }, } + // if we are querying for provisioning information, we need to use a different + // legacy sql query, since legacy search does not support this + if query.ProvisionedRepo != "" || len(query.ProvisionedReposNotIn) > 0 { + var dashes []*dashboards.Dashboard + if query.ProvisionedRepo == dashboard.PluginIDRepoName { + dashes, err = c.dashboardStore.GetDashboardsByPluginID(ctx, &dashboards.GetDashboardsByPluginIDQuery{ + PluginID: query.ProvisionedPath, + OrgID: user.GetOrgID(), + }) + } else if query.ProvisionedRepo != "" { + dashes, err = c.dashboardStore.GetProvisionedDashboardsByName(ctx, query.ProvisionedRepo) + } else if len(query.ProvisionedReposNotIn) > 0 { + dashes, err = c.dashboardStore.GetOrphanedProvisionedDashboards(ctx, query.ProvisionedReposNotIn) + } + if err != nil { + return nil, err + } + + for _, dashboard := range dashes { + list.Results.Rows = append(list.Results.Rows, &resource.ResourceTableRow{ + Key: getResourceKey(&dashboards.DashboardSearchProjection{ + UID: dashboard.UID, + }, req.Options.Key.Namespace), + Cells: [][]byte{[]byte(dashboard.Title), []byte(dashboard.FolderUID), []byte{}}, + }) + } + + return list, nil + } + + res, err := c.dashboardStore.FindDashboards(ctx, query) + if err != nil { + return nil, err + } + hits := formatQueryResult(res) for _, dashboard := range hits { @@ -155,6 +209,8 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resource.Resour }) } + list.TotalHits = int64(len(list.Results.Rows)) + return list, nil } diff --git a/pkg/registry/apis/dashboard/legacysearcher/search_client_test.go b/pkg/registry/apis/dashboard/legacysearcher/search_client_test.go new file mode 100644 index 00000000000..14dc870bbd4 --- /dev/null +++ b/pkg/registry/apis/dashboard/legacysearcher/search_client_test.go @@ -0,0 +1,264 @@ +package legacysearcher + +import ( + "context" + "encoding/json" + "testing" + + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/apimachinery/utils" + "github.com/grafana/grafana/pkg/apis/dashboard" + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/storage/unified/resource" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/selection" +) + +func TestDashboardSearchClient_Search(t *testing.T) { + mockStore := dashboards.NewFakeDashboardStore(t) + client := NewDashboardSearchClient(mockStore) + ctx := context.Background() + user := &user.SignedInUser{OrgID: 2} + ctx = identity.WithRequester(ctx, user) + + dashboardKey := &resource.ResourceKey{ + Name: "uid", + Resource: dashboard.DASHBOARD_RESOURCE, + } + + t.Run("Should parse results into GRPC", func(t *testing.T) { + mockStore.On("FindDashboards", mock.Anything, &dashboards.FindPersistedDashboardsQuery{ + SignedInUser: user, // user from context should be used + Type: "dash-db", // should set type based off of key + }).Return([]dashboards.DashboardSearchProjection{ + {UID: "uid", Title: "Test Dashboard", FolderUID: "folder1", Term: "term"}, + {UID: "uid2", Title: "Test Dashboard2", FolderUID: "folder2"}, + }, nil).Once() + + req := &resource.ResourceSearchRequest{ + Options: &resource.ListOptions{ + Key: dashboardKey, + }, + } + resp, err := client.Search(ctx, req) + require.NoError(t, err) + + tags, err := json.Marshal([]string{"term"}) + require.NoError(t, err) + + emptyTags, err := json.Marshal([]string{}) + require.NoError(t, err) + + require.NotNil(t, resp) + searchFields := resource.StandardSearchFields() + require.Equal(t, &resource.ResourceSearchResponse{ + TotalHits: 2, + Results: &resource.ResourceTable{ + Columns: []*resource.ResourceTableColumnDefinition{ + searchFields.Field(resource.SEARCH_FIELD_TITLE), + searchFields.Field(resource.SEARCH_FIELD_FOLDER), + searchFields.Field(resource.SEARCH_FIELD_TAGS), + }, + Rows: []*resource.ResourceTableRow{ + { + Key: &resource.ResourceKey{ + Name: "uid", + Group: dashboard.GROUP, + Resource: dashboard.DASHBOARD_RESOURCE, + }, + Cells: [][]byte{ + []byte("Test Dashboard"), + []byte("folder1"), + tags, + }, + }, + { + Key: &resource.ResourceKey{ + Name: "uid2", + Group: dashboard.GROUP, + Resource: dashboard.DASHBOARD_RESOURCE, + }, + Cells: [][]byte{ + []byte("Test Dashboard2"), + []byte("folder2"), + emptyTags, + }, + }, + }, + }, + }, resp) + mockStore.AssertExpectations(t) + }) + + t.Run("Query should be set as the title, and * should be removed", func(t *testing.T) { + mockStore.On("FindDashboards", mock.Anything, &dashboards.FindPersistedDashboardsQuery{ + Title: "test", + SignedInUser: user, // user from context should be used + Type: "dash-db", // should set type based off of key + }).Return([]dashboards.DashboardSearchProjection{ + {UID: "uid", Title: "Test Dashboard", FolderUID: "folder1"}, + }, nil).Once() + + req := &resource.ResourceSearchRequest{ + Options: &resource.ListOptions{ + Key: dashboardKey, + }, + Query: "*test*", + } + resp, err := client.Search(ctx, req) + + require.NoError(t, err) + require.NotNil(t, resp) + mockStore.AssertExpectations(t) + }) + + t.Run("Should read labels for the dashboard ids", func(t *testing.T) { + mockStore.On("FindDashboards", mock.Anything, &dashboards.FindPersistedDashboardsQuery{ + DashboardIds: []int64{1, 2}, + SignedInUser: user, // user from context should be used + Type: "dash-db", // should set type based off of key + }).Return([]dashboards.DashboardSearchProjection{ + {UID: "uid", Title: "Test Dashboard", FolderUID: "folder1"}, + }, nil).Once() + + req := &resource.ResourceSearchRequest{ + Options: &resource.ListOptions{ + Key: dashboardKey, + Labels: []*resource.Requirement{ + { + Key: utils.LabelKeyDeprecatedInternalID, + Operator: "in", + Values: []string{"1", "2"}, + }, + }, + }, + } + resp, err := client.Search(ctx, req) + + require.NoError(t, err) + require.NotNil(t, resp) + mockStore.AssertExpectations(t) + }) + + t.Run("Should modify fields to legacy compatible queries", func(t *testing.T) { + mockStore.On("FindDashboards", mock.Anything, &dashboards.FindPersistedDashboardsQuery{ + DashboardUIDs: []string{"uid1", "uid2"}, + Tags: []string{"tag1", "tag2"}, + FolderUIDs: []string{"general", "folder1"}, + SignedInUser: user, // user from context should be used + Type: "dash-db", // should set type based off of key + }).Return([]dashboards.DashboardSearchProjection{ + {UID: "uid", Title: "Test Dashboard", FolderUID: "folder1"}, + }, nil).Once() + + req := &resource.ResourceSearchRequest{ + Options: &resource.ListOptions{ + Key: dashboardKey, + Fields: []*resource.Requirement{ + { + Key: resource.SEARCH_FIELD_TAGS, + Operator: "in", + Values: []string{"tag1", "tag2"}, + }, + { + Key: resource.SEARCH_FIELD_NAME, // name should be used as uid + Operator: "in", + Values: []string{"uid1", "uid2"}, + }, + { + Key: resource.SEARCH_FIELD_FOLDER, + Operator: "in", + Values: []string{"", "folder1"}, // empty folder should be general + }, + }, + }, + } + resp, err := client.Search(ctx, req) + + require.NoError(t, err) + require.NotNil(t, resp) + mockStore.AssertExpectations(t) + }) + + t.Run("Should retrieve dashboards by plugin through a different function", func(t *testing.T) { + mockStore.On("GetDashboardsByPluginID", mock.Anything, &dashboards.GetDashboardsByPluginIDQuery{ + PluginID: "slo", + OrgID: 2, // retrieved from the signed in user + }).Return([]*dashboards.Dashboard{ + {UID: "uid", Title: "Test Dashboard", FolderUID: "folder1"}, + }, nil).Once() + + req := &resource.ResourceSearchRequest{ + Options: &resource.ListOptions{ + Key: dashboardKey, + Fields: []*resource.Requirement{ + { + Key: resource.SEARCH_FIELD_REPOSITORY_PATH, + Operator: "in", + Values: []string{"slo"}, + }, + { + Key: resource.SEARCH_FIELD_REPOSITORY_NAME, + Operator: "in", + Values: []string{"plugin"}, + }, + }, + }, + } + resp, err := client.Search(ctx, req) + + require.NoError(t, err) + require.NotNil(t, resp) + mockStore.AssertExpectations(t) + }) + + t.Run("Should retrieve dashboards by provisioner name through a different function", func(t *testing.T) { + mockStore.On("GetProvisionedDashboardsByName", mock.Anything, "test").Return([]*dashboards.Dashboard{ + {UID: "uid", Title: "Test Dashboard", FolderUID: "folder1"}, + }, nil).Once() + + req := &resource.ResourceSearchRequest{ + Options: &resource.ListOptions{ + Key: dashboardKey, + Fields: []*resource.Requirement{ + { + Key: resource.SEARCH_FIELD_REPOSITORY_NAME, + Operator: "in", + Values: []string{"file:test"}, // file prefix should be removed before going to legacy + }, + }, + }, + } + resp, err := client.Search(ctx, req) + + require.NoError(t, err) + require.NotNil(t, resp) + mockStore.AssertExpectations(t) + }) + + t.Run("Should retrieve orphaned dashboards if provisioner not in is specified", func(t *testing.T) { + mockStore.On("GetOrphanedProvisionedDashboards", mock.Anything, []string{"test", "test2"}).Return([]*dashboards.Dashboard{ + {UID: "uid", Title: "Test Dashboard", FolderUID: "folder1"}, + }, nil).Once() + + req := &resource.ResourceSearchRequest{ + Options: &resource.ListOptions{ + Key: dashboardKey, + Fields: []*resource.Requirement{ + { + Key: resource.SEARCH_FIELD_REPOSITORY_NAME, + Operator: string(selection.NotIn), + Values: []string{"file:test", "file:test2"}, // file prefix should be removed before going to legacy + }, + }, + }, + } + resp, err := client.Search(ctx, req) + + require.NoError(t, err) + require.NotNil(t, resp) + mockStore.AssertExpectations(t) + }) +} diff --git a/pkg/registry/apis/dashboard/search.go b/pkg/registry/apis/dashboard/search.go index c4ad9532066..9edd79f5f83 100644 --- a/pkg/registry/apis/dashboard/search.go +++ b/pkg/registry/apis/dashboard/search.go @@ -3,6 +3,7 @@ package dashboard import ( "context" "encoding/json" + "fmt" "net/http" "net/url" "slices" @@ -25,7 +26,10 @@ import ( folderv0alpha1 "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/apiserver/builder" + "github.com/grafana/grafana/pkg/services/dashboards" dashboardsearch "github.com/grafana/grafana/pkg/services/dashboards/service/search" + "github.com/grafana/grafana/pkg/services/featuremgmt" + foldermodel "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/grafana/grafana/pkg/util/errhttp" @@ -33,17 +37,19 @@ import ( // The DTO returns everything the UI needs in a single request type SearchHandler struct { - log log.Logger - client func(context.Context) resource.ResourceIndexClient - tracer trace.Tracer + log log.Logger + client func(context.Context) resource.ResourceIndexClient + tracer trace.Tracer + features featuremgmt.FeatureToggles } -func NewSearchHandler(tracer trace.Tracer, cfg *setting.Cfg, legacyDashboardSearcher resource.ResourceIndexClient) *SearchHandler { +func NewSearchHandler(tracer trace.Tracer, cfg *setting.Cfg, legacyDashboardSearcher resource.ResourceIndexClient, features featuremgmt.FeatureToggles) *SearchHandler { searchClient := resource.NewSearchClient(cfg, setting.UnifiedStorageConfigKeyDashboard, unified.GetResourceClient, legacyDashboardSearcher) return &SearchHandler{ - client: searchClient, - log: log.New("grafana-apiserver.dashboards.search"), - tracer: tracer, + client: searchClient, + log: log.New("grafana-apiserver.dashboards.search"), + tracer: tracer, + features: features, } } @@ -252,19 +258,6 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) { } searchRequest.Fields = fields - // Add the folder constraint. Note this does not do recursive search - folder := queryParams.Get("folder") - if folder != "" { - if folder == rootFolder { - folder = "" // root folder is empty in the search index - } - searchRequest.Options.Fields = []*resource.Requirement{{ - Key: "folder", - Operator: "=", - Values: []string{folder}, - }} - } - types := queryParams["type"] var federate *resource.ResourceKey switch len(types) { @@ -329,7 +322,33 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) { } // The names filter - if names, ok := queryParams["name"]; ok { + names := queryParams["name"] + + // Add the folder constraint. Note this does not do recursive search + folder := queryParams.Get("folder") + if folder == foldermodel.SharedWithMeFolderUID { + dashboardUIDs, err := s.getDashboardsUIDsSharedWithUser(ctx, user) + if err != nil { + errhttp.Write(ctx, err, w) + return + } + + // hijacks the "name" query param to only search for shared dashboard UIDs + if len(dashboardUIDs) > 0 { + names = append(names, dashboardUIDs...) + } + } else if folder != "" { + if folder == rootFolder { + folder = "" // root folder is empty in the search index + } + searchRequest.Options.Fields = []*resource.Requirement{{ + Key: "folder", + Operator: "=", + Values: []string{folder}, + }} + } + + if len(names) > 0 { if searchRequest.Options.Fields == nil { searchRequest.Options.Fields = []*resource.Requirement{} } @@ -378,3 +397,108 @@ func asResourceKey(ns string, k string) (*resource.ResourceKey, error) { return key, nil } + +func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, user identity.Requester) ([]string, error) { + if !s.features.IsEnabledGlobally(featuremgmt.FlagUnifiedStorageSearchPermissionFiltering) { + return []string{}, nil + } + + // gets dashboards that the user was granted read access to + permissions := user.GetPermissions() + dashboardPermissions := permissions[dashboards.ActionDashboardsRead] + dashboardUids := make([]string, 0) + sharedDashboards := make([]string, 0) + + for _, dashboardPermission := range dashboardPermissions { + if dashboardUid, found := strings.CutPrefix(dashboardPermission, dashboards.ScopeDashboardsPrefix); found { + if !slices.Contains(dashboardUids, dashboardUid) { + dashboardUids = append(dashboardUids, dashboardUid) + } + } + } + + if len(dashboardUids) == 0 { + return sharedDashboards, nil + } + + key, err := asResourceKey(user.GetNamespace(), dashboard.DASHBOARD_RESOURCE) + if err != nil { + return sharedDashboards, err + } + + dashboardSearchRequest := &resource.ResourceSearchRequest{ + Fields: []string{"folder"}, + Limit: int64(len(dashboardUids)), + Options: &resource.ListOptions{ + Key: key, + Fields: []*resource.Requirement{{ + Key: "name", + Operator: "in", + Values: dashboardUids, + }}, + }, + } + // get all dashboards user has access to, along with their parent folder uid + dashboardResult, err := s.client(ctx).Search(ctx, dashboardSearchRequest) + if err != nil { + return sharedDashboards, err + } + + folderUidIdx := -1 + for i, col := range dashboardResult.Results.Columns { + if col.Name == "folder" { + folderUidIdx = i + } + } + + if folderUidIdx == -1 { + return sharedDashboards, fmt.Errorf("Error retrieving folder information") + } + + // populate list of unique folder UIDs in the list of dashboards user has read permissions + allFolders := make([]string, 0) + for _, dash := range dashboardResult.Results.Rows { + folderUid := string(dash.Cells[folderUidIdx]) + if folderUid != "" && !slices.Contains(allFolders, folderUid) { + allFolders = append(allFolders, folderUid) + } + } + + // only folders the user has access to will be returned here + folderKey, err := asResourceKey(user.GetNamespace(), folderv0alpha1.RESOURCE) + if err != nil { + return sharedDashboards, err + } + + folderSearchRequest := &resource.ResourceSearchRequest{ + Fields: []string{"folder"}, + Limit: int64(len(allFolders)), + Options: &resource.ListOptions{ + Key: folderKey, + Fields: []*resource.Requirement{{ + Key: "name", + Operator: "in", + Values: allFolders, + }}, + }, + } + foldersResult, err := s.client(ctx).Search(ctx, folderSearchRequest) + if err != nil { + return sharedDashboards, err + } + + foldersWithAccess := make([]string, 0, len(foldersResult.Results.Rows)) + for _, fold := range foldersResult.Results.Rows { + foldersWithAccess = append(foldersWithAccess, fold.Key.Name) + } + + // add to sharedDashboards dashboards user has access to, but does NOT have access to it's parent folder + for _, dash := range dashboardResult.Results.Rows { + dashboardUid := dash.Key.Name + folderUid := string(dash.Cells[folderUidIdx]) + if folderUid != "" && !slices.Contains(foldersWithAccess, folderUid) { + sharedDashboards = append(sharedDashboards, dashboardUid) + } + } + return sharedDashboards, nil +} diff --git a/pkg/registry/apis/dashboard/search_test.go b/pkg/registry/apis/dashboard/search_test.go index 91af78295a3..c92b39efdd0 100644 --- a/pkg/registry/apis/dashboard/search_test.go +++ b/pkg/registry/apis/dashboard/search_test.go @@ -12,6 +12,8 @@ import ( "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/storage/unified/resource" @@ -31,7 +33,7 @@ func TestSearchFallback(t *testing.T) { "dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode0}, }, } - searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), cfg, mockLegacyClient) + searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), cfg, mockLegacyClient, nil) searchHandler.client = resource.NewSearchClient(cfg, setting.UnifiedStorageConfigKeyDashboard, mockUnifiedCtxclient, mockLegacyClient) rr := httptest.NewRecorder() @@ -59,7 +61,7 @@ func TestSearchFallback(t *testing.T) { "dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode1}, }, } - searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), cfg, mockLegacyClient) + searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), cfg, mockLegacyClient, nil) searchHandler.client = resource.NewSearchClient(cfg, setting.UnifiedStorageConfigKeyDashboard, mockUnifiedCtxclient, mockLegacyClient) rr := httptest.NewRecorder() @@ -87,7 +89,7 @@ func TestSearchFallback(t *testing.T) { "dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode2}, }, } - searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), cfg, mockLegacyClient) + searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), cfg, mockLegacyClient, nil) searchHandler.client = resource.NewSearchClient(cfg, setting.UnifiedStorageConfigKeyDashboard, mockUnifiedCtxclient, mockLegacyClient) rr := httptest.NewRecorder() @@ -115,7 +117,7 @@ func TestSearchFallback(t *testing.T) { "dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode3}, }, } - searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), cfg, mockLegacyClient) + searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), cfg, mockLegacyClient, nil) searchHandler.client = resource.NewSearchClient(cfg, setting.UnifiedStorageConfigKeyDashboard, mockUnifiedCtxclient, mockLegacyClient) rr := httptest.NewRecorder() @@ -143,7 +145,7 @@ func TestSearchFallback(t *testing.T) { "dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode4}, }, } - searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), cfg, mockLegacyClient) + searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), cfg, mockLegacyClient, nil) searchHandler.client = resource.NewSearchClient(cfg, setting.UnifiedStorageConfigKeyDashboard, mockUnifiedCtxclient, mockLegacyClient) rr := httptest.NewRecorder() @@ -171,7 +173,7 @@ func TestSearchFallback(t *testing.T) { "dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode5}, }, } - searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), cfg, mockLegacyClient) + searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), cfg, mockLegacyClient, nil) searchHandler.client = resource.NewSearchClient(cfg, setting.UnifiedStorageConfigKeyDashboard, mockUnifiedCtxclient, mockLegacyClient) rr := httptest.NewRecorder() @@ -191,17 +193,19 @@ func TestSearchFallback(t *testing.T) { } func TestSearchHandler(t *testing.T) { - // Create a mock client - mockClient := &MockClient{} - - // Initialize the search handler with the mock client - searchHandler := SearchHandler{ - log: log.New("test", "test"), - client: func(context.Context) resource.ResourceIndexClient { return mockClient }, - tracer: tracing.NewNoopTracerService(), - } - t.Run("Multiple comma separated fields will be appended to default dashboard search fields", func(t *testing.T) { + // Create a mock client + mockClient := &MockClient{} + + features := featuremgmt.WithFeatures() + // Initialize the search handler with the mock client + searchHandler := SearchHandler{ + log: log.New("test", "test"), + client: func(context.Context) resource.ResourceIndexClient { return mockClient }, + tracer: tracing.NewNoopTracerService(), + features: features, + } + rr := httptest.NewRecorder() req := httptest.NewRequest("GET", "/search?field=field1&field=field2&field=field3", nil) req.Header.Add("content-type", "application/json") @@ -219,6 +223,18 @@ func TestSearchHandler(t *testing.T) { }) t.Run("Single field will be appended to default dashboard search fields", func(t *testing.T) { + // Create a mock client + mockClient := &MockClient{} + + features := featuremgmt.WithFeatures() + // Initialize the search handler with the mock client + searchHandler := SearchHandler{ + log: log.New("test", "test"), + client: func(context.Context) resource.ResourceIndexClient { return mockClient }, + tracer: tracing.NewNoopTracerService(), + features: features, + } + rr := httptest.NewRecorder() req := httptest.NewRequest("GET", "/search?field=field1", nil) req.Header.Add("content-type", "application/json") @@ -236,6 +252,18 @@ func TestSearchHandler(t *testing.T) { }) t.Run("Passing no fields will search using default dashboard fields", func(t *testing.T) { + // Create a mock client + mockClient := &MockClient{} + + features := featuremgmt.WithFeatures() + // Initialize the search handler with the mock client + searchHandler := SearchHandler{ + log: log.New("test", "test"), + client: func(context.Context) resource.ResourceIndexClient { return mockClient }, + tracer: tracing.NewNoopTracerService(), + features: features, + } + rr := httptest.NewRecorder() req := httptest.NewRequest("GET", "/search", nil) req.Header.Add("content-type", "application/json") @@ -253,6 +281,41 @@ func TestSearchHandler(t *testing.T) { }) t.Run("Sort - default sort by resource then title", func(t *testing.T) { + rows := make([]*resource.ResourceTableRow, len(mockResults)) + for i, r := range mockResults { + rows[i] = &resource.ResourceTableRow{ + Key: &resource.ResourceKey{ + Name: r.Name, + Resource: r.Resource, + }, + Cells: [][]byte{ + []byte(r.Value), + }, + } + } + + mockResponse := &resource.ResourceSearchResponse{ + Results: &resource.ResourceTable{ + Columns: []*resource.ResourceTableColumnDefinition{ + {Name: resource.SEARCH_FIELD_TITLE}, + }, + Rows: rows, + }, + } + // Create a mock client + mockClient := &MockClient{ + MockResponses: []*resource.ResourceSearchResponse{mockResponse}, + } + + features := featuremgmt.WithFeatures() + // Initialize the search handler with the mock client + searchHandler := SearchHandler{ + log: log.New("test", "test"), + client: func(context.Context) resource.ResourceIndexClient { return mockClient }, + tracer: tracing.NewNoopTracerService(), + features: features, + } + rr := httptest.NewRecorder() req := httptest.NewRequest("GET", "/search", nil) req.Header.Add("content-type", "application/json") @@ -280,6 +343,125 @@ func TestSearchHandler(t *testing.T) { }) } +func TestSearchHandlerSharedDashboards(t *testing.T) { + t.Run("should bail out if FlagUnifiedStorageSearchPermissionFiltering is not enabled globally", func(t *testing.T) { + mockClient := &MockClient{} + + features := featuremgmt.WithFeatures() + searchHandler := SearchHandler{ + log: log.New("test", "test"), + client: func(context.Context) resource.ResourceIndexClient { return mockClient }, + tracer: tracing.NewNoopTracerService(), + features: features, + } + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/search?folder=sharedwithme", nil) + req.Header.Add("content-type", "application/json") + req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"})) + + searchHandler.DoSearch(rr, req) + + assert.Equal(t, mockClient.CallCount, 1) + }) + + t.Run("should return the dashboards shared with the user", func(t *testing.T) { + // dashboardSearchRequest + mockResponse1 := &resource.ResourceSearchResponse{ + Results: &resource.ResourceTable{ + Columns: []*resource.ResourceTableColumnDefinition{ + { + Name: "folder", + }, + }, + Rows: []*resource.ResourceTableRow{ + { + Key: &resource.ResourceKey{ + Name: "dashboardinroot", + Resource: "dashboard", + }, + Cells: [][]byte{[]byte("")}, // root folder doesn't have uid + }, + { + Key: &resource.ResourceKey{ + Name: "dashboardinprivatefolder", + Resource: "dashboard", + }, + Cells: [][]byte{ + []byte("privatefolder"), // folder uid + }, + }, + { + Key: &resource.ResourceKey{ + Name: "dashboardinpublicfolder", + Resource: "dashboard", + }, + Cells: [][]byte{ + []byte("publicfolder"), // folder uid + }, + }, + }, + }, + } + + // folderSearchRequest + mockResponse2 := &resource.ResourceSearchResponse{ + Results: &resource.ResourceTable{ + Columns: []*resource.ResourceTableColumnDefinition{ + { + Name: "folder", + }, + }, + Rows: []*resource.ResourceTableRow{ + { + Key: &resource.ResourceKey{ + Name: "publicfolder", + Resource: "folder", + }, + Cells: [][]byte{ + []byte(""), // root folder uid + }, + }, + }, + }, + } + + mockClient := &MockClient{ + MockResponses: []*resource.ResourceSearchResponse{mockResponse1, mockResponse2}, + } + + features := featuremgmt.WithFeatures(featuremgmt.FlagUnifiedStorageSearchPermissionFiltering) + searchHandler := SearchHandler{ + log: log.New("test", "test"), + client: func(context.Context) resource.ResourceIndexClient { return mockClient }, + tracer: tracing.NewNoopTracerService(), + features: features, + } + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/search?folder=sharedwithme", nil) + req.Header.Add("content-type", "application/json") + allPermissions := make(map[int64]map[string][]string) + permissions := make(map[string][]string) + permissions[dashboards.ActionDashboardsRead] = []string{"dashboards:uid:dashboardinroot", "dashboards:uid:dashboardinprivatefolder", "dashboards:uid:dashboardinpublicfolder"} + allPermissions[1] = permissions + req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test", OrgID: 1, Permissions: allPermissions})) + + searchHandler.DoSearch(rr, req) + + assert.Equal(t, mockClient.CallCount, 3) + + // first call gets all dashboards user has permission for + firstCall := mockClient.MockCalls[0] + assert.Equal(t, firstCall.Options.Fields[0].Values, []string{"dashboardinroot", "dashboardinprivatefolder", "dashboardinpublicfolder"}) + // second call gets folders associated with the previous dashboards + secondCall := mockClient.MockCalls[1] + assert.Equal(t, secondCall.Options.Fields[0].Values, []string{"privatefolder", "publicfolder"}) + // lastly, search ONLY for dashboards user has permission to read that are within folders the user does NOT have + // permission to read + thirdCall := mockClient.MockCalls[2] + assert.Equal(t, thirdCall.Options.Fields[0].Values, []string{"dashboardinprivatefolder"}) + }) +} + // MockClient implements the ResourceIndexClient interface for testing type MockClient struct { resource.ResourceIndexClient @@ -287,6 +469,10 @@ type MockClient struct { // Capture the last SearchRequest for assertions LastSearchRequest *resource.ResourceSearchRequest + + MockResponses []*resource.ResourceSearchResponse + MockCalls []*resource.ResourceSearchRequest + CallCount int } type MockResult struct { @@ -320,28 +506,16 @@ var mockResults = []MockResult{ func (m *MockClient) Search(ctx context.Context, in *resource.ResourceSearchRequest, opts ...grpc.CallOption) (*resource.ResourceSearchResponse, error) { m.LastSearchRequest = in + m.MockCalls = append(m.MockCalls, in) - rows := make([]*resource.ResourceTableRow, len(mockResults)) - for i, r := range mockResults { - rows[i] = &resource.ResourceTableRow{ - Key: &resource.ResourceKey{ - Name: r.Name, - Resource: r.Resource, - }, - Cells: [][]byte{ - []byte(r.Value), - }, - } + var response *resource.ResourceSearchResponse + if m.CallCount < len(m.MockResponses) { + response = m.MockResponses[m.CallCount] } - return &resource.ResourceSearchResponse{ - Results: &resource.ResourceTable{ - Columns: []*resource.ResourceTableColumnDefinition{ - {Name: resource.SEARCH_FIELD_TITLE}, - }, - Rows: rows, - }, - }, nil + m.CallCount = m.CallCount + 1 + + return response, nil } func (m *MockClient) GetStats(ctx context.Context, in *resource.ResourceStatsRequest, opts ...grpc.CallOption) (*resource.ResourceStatsResponse, error) { return nil, nil diff --git a/pkg/registry/apis/dashboard/sub_dto.go b/pkg/registry/apis/dashboard/sub_dto.go index e48970a7344..a3c930694ea 100644 --- a/pkg/registry/apis/dashboard/sub_dto.go +++ b/pkg/registry/apis/dashboard/sub_dto.go @@ -125,7 +125,7 @@ func (r *DTOConnector) Connect(ctx context.Context, name string, opts runtime.Ob if err != nil { return nil, err } - if repo != nil && repo.Name == "plugin" { + if repo != nil && repo.Name == dashboard.PluginIDRepoName { dto.PluginID = repo.Path } diff --git a/pkg/registry/apis/dashboard/v0alpha1/register.go b/pkg/registry/apis/dashboard/v0alpha1/register.go index c051bb7595f..b85e5f49329 100644 --- a/pkg/registry/apis/dashboard/v0alpha1/register.go +++ b/pkg/registry/apis/dashboard/v0alpha1/register.go @@ -82,7 +82,7 @@ func RegisterAPIService(cfg *setting.Cfg, features featuremgmt.FeatureToggles, features: features, accessControl: accessControl, unified: unified, - search: dashboard.NewSearchHandler(tracing, cfg, legacyDashboardSearcher), + search: dashboard.NewSearchHandler(tracing, cfg, legacyDashboardSearcher, features), legacy: &dashboard.DashboardStorage{ Resource: dashboardv0alpha1.DashboardResourceInfo, diff --git a/pkg/services/dashboards/dashboard.go b/pkg/services/dashboards/dashboard.go index 8d56c056afa..2561b74e987 100644 --- a/pkg/services/dashboards/dashboard.go +++ b/pkg/services/dashboards/dashboard.go @@ -81,6 +81,8 @@ type Store interface { GetProvisionedDashboardData(ctx context.Context, name string) ([]*DashboardProvisioning, error) GetProvisionedDataByDashboardID(ctx context.Context, dashboardID int64) (*DashboardProvisioning, error) GetProvisionedDataByDashboardUID(ctx context.Context, orgID int64, dashboardUID string) (*DashboardProvisioning, error) + GetProvisionedDashboardsByName(ctx context.Context, name string) ([]*Dashboard, error) + GetOrphanedProvisionedDashboards(ctx context.Context, notIn []string) ([]*Dashboard, error) SaveDashboard(ctx context.Context, cmd SaveDashboardCommand) (*Dashboard, error) SaveProvisionedDashboard(ctx context.Context, dash *Dashboard, provisioning *DashboardProvisioning) error UnprovisionDashboard(ctx context.Context, id int64) error diff --git a/pkg/services/dashboards/dashboard_provisioning_mock.go b/pkg/services/dashboards/dashboard_provisioning_mock.go index a743752fcd5..ce7893a24a9 100644 --- a/pkg/services/dashboards/dashboard_provisioning_mock.go +++ b/pkg/services/dashboards/dashboard_provisioning_mock.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.32.0. DO NOT EDIT. +// Code generated by mockery v2.52.2. DO NOT EDIT. package dashboards @@ -18,6 +18,10 @@ type FakeDashboardProvisioning struct { func (_m *FakeDashboardProvisioning) DeleteOrphanedProvisionedDashboards(ctx context.Context, cmd *DeleteOrphanedProvisionedDashboardsCommand) error { ret := _m.Called(ctx, cmd) + if len(ret) == 0 { + panic("no return value specified for DeleteOrphanedProvisionedDashboards") + } + var r0 error if rf, ok := ret.Get(0).(func(context.Context, *DeleteOrphanedProvisionedDashboardsCommand) error); ok { r0 = rf(ctx, cmd) @@ -32,6 +36,10 @@ func (_m *FakeDashboardProvisioning) DeleteOrphanedProvisionedDashboards(ctx con func (_m *FakeDashboardProvisioning) DeleteProvisionedDashboard(ctx context.Context, dashboardID int64, orgID int64) error { ret := _m.Called(ctx, dashboardID, orgID) + if len(ret) == 0 { + panic("no return value specified for DeleteProvisionedDashboard") + } + var r0 error if rf, ok := ret.Get(0).(func(context.Context, int64, int64) error); ok { r0 = rf(ctx, dashboardID, orgID) @@ -46,6 +54,10 @@ func (_m *FakeDashboardProvisioning) DeleteProvisionedDashboard(ctx context.Cont func (_m *FakeDashboardProvisioning) GetProvisionedDashboardData(ctx context.Context, name string) ([]*DashboardProvisioning, error) { ret := _m.Called(ctx, name) + if len(ret) == 0 { + panic("no return value specified for GetProvisionedDashboardData") + } + var r0 []*DashboardProvisioning var r1 error if rf, ok := ret.Get(0).(func(context.Context, string) ([]*DashboardProvisioning, error)); ok { @@ -72,6 +84,10 @@ func (_m *FakeDashboardProvisioning) GetProvisionedDashboardData(ctx context.Con func (_m *FakeDashboardProvisioning) GetProvisionedDashboardDataByDashboardID(ctx context.Context, dashboardID int64) (*DashboardProvisioning, error) { ret := _m.Called(ctx, dashboardID) + if len(ret) == 0 { + panic("no return value specified for GetProvisionedDashboardDataByDashboardID") + } + var r0 *DashboardProvisioning var r1 error if rf, ok := ret.Get(0).(func(context.Context, int64) (*DashboardProvisioning, error)); ok { @@ -98,6 +114,10 @@ func (_m *FakeDashboardProvisioning) GetProvisionedDashboardDataByDashboardID(ct func (_m *FakeDashboardProvisioning) GetProvisionedDashboardDataByDashboardUID(ctx context.Context, orgID int64, dashboardUID string) (*DashboardProvisioning, error) { ret := _m.Called(ctx, orgID, dashboardUID) + if len(ret) == 0 { + panic("no return value specified for GetProvisionedDashboardDataByDashboardUID") + } + var r0 *DashboardProvisioning var r1 error if rf, ok := ret.Get(0).(func(context.Context, int64, string) (*DashboardProvisioning, error)); ok { @@ -124,6 +144,10 @@ func (_m *FakeDashboardProvisioning) GetProvisionedDashboardDataByDashboardUID(c func (_m *FakeDashboardProvisioning) SaveFolderForProvisionedDashboards(_a0 context.Context, _a1 *folder.CreateFolderCommand) (*folder.Folder, error) { ret := _m.Called(_a0, _a1) + if len(ret) == 0 { + panic("no return value specified for SaveFolderForProvisionedDashboards") + } + var r0 *folder.Folder var r1 error if rf, ok := ret.Get(0).(func(context.Context, *folder.CreateFolderCommand) (*folder.Folder, error)); ok { @@ -150,6 +174,10 @@ func (_m *FakeDashboardProvisioning) SaveFolderForProvisionedDashboards(_a0 cont func (_m *FakeDashboardProvisioning) SaveProvisionedDashboard(ctx context.Context, dto *SaveDashboardDTO, provisioning *DashboardProvisioning) (*Dashboard, error) { ret := _m.Called(ctx, dto, provisioning) + if len(ret) == 0 { + panic("no return value specified for SaveProvisionedDashboard") + } + var r0 *Dashboard var r1 error if rf, ok := ret.Get(0).(func(context.Context, *SaveDashboardDTO, *DashboardProvisioning) (*Dashboard, error)); ok { @@ -176,6 +204,10 @@ func (_m *FakeDashboardProvisioning) SaveProvisionedDashboard(ctx context.Contex func (_m *FakeDashboardProvisioning) UnprovisionDashboard(ctx context.Context, dashboardID int64) error { ret := _m.Called(ctx, dashboardID) + if len(ret) == 0 { + panic("no return value specified for UnprovisionDashboard") + } + var r0 error if rf, ok := ret.Get(0).(func(context.Context, int64) error); ok { r0 = rf(ctx, dashboardID) diff --git a/pkg/services/dashboards/dashboard_service_mock.go b/pkg/services/dashboards/dashboard_service_mock.go index af8a6184e2a..2e37ea6f3b8 100644 --- a/pkg/services/dashboards/dashboard_service_mock.go +++ b/pkg/services/dashboards/dashboard_service_mock.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.42.2. DO NOT EDIT. +// Code generated by mockery v2.52.2. DO NOT EDIT. package dashboards @@ -74,6 +74,34 @@ func (_m *FakeDashboardService) CleanUpDeletedDashboards(ctx context.Context) (i return r0, r1 } +// CountDashboardsInOrg provides a mock function with given fields: ctx, orgID +func (_m *FakeDashboardService) CountDashboardsInOrg(ctx context.Context, orgID int64) (int64, error) { + ret := _m.Called(ctx, orgID) + + if len(ret) == 0 { + panic("no return value specified for CountDashboardsInOrg") + } + + var r0 int64 + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, int64) (int64, error)); ok { + return rf(ctx, orgID) + } + if rf, ok := ret.Get(0).(func(context.Context, int64) int64); ok { + r0 = rf(ctx, orgID) + } else { + r0 = ret.Get(0).(int64) + } + + if rf, ok := ret.Get(1).(func(context.Context, int64) error); ok { + r1 = rf(ctx, orgID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // CountInFolders provides a mock function with given fields: ctx, orgID, folderUIDs, user func (_m *FakeDashboardService) CountInFolders(ctx context.Context, orgID int64, folderUIDs []string, user identity.Requester) (int64, error) { ret := _m.Called(ctx, orgID, folderUIDs, user) @@ -102,6 +130,24 @@ func (_m *FakeDashboardService) CountInFolders(ctx context.Context, orgID int64, return r0, r1 } +// DeleteAllDashboards provides a mock function with given fields: ctx, orgID +func (_m *FakeDashboardService) DeleteAllDashboards(ctx context.Context, orgID int64) error { + ret := _m.Called(ctx, orgID) + + if len(ret) == 0 { + panic("no return value specified for DeleteAllDashboards") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int64) error); ok { + r0 = rf(ctx, orgID) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // DeleteDashboard provides a mock function with given fields: ctx, dashboardId, dashboardUID, orgId func (_m *FakeDashboardService) DeleteDashboard(ctx context.Context, dashboardId int64, dashboardUID string, orgId int64) error { ret := _m.Called(ctx, dashboardId, dashboardUID, orgId) @@ -120,24 +166,6 @@ func (_m *FakeDashboardService) DeleteDashboard(ctx context.Context, dashboardId return r0 } -// DeleteAllDashboards provides a mock function with given fields: ctx, orgID -func (_m *FakeDashboardService) DeleteAllDashboards(ctx context.Context, orgID int64) error { - ret := _m.Called(ctx, orgID) - - if len(ret) == 0 { - panic("no return value specified for DeleteDashboard") - } - - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, int64) error); ok { - r0 = rf(ctx, orgID) - } else { - r0 = ret.Error(0) - } - - return r0 -} - // FindDashboards provides a mock function with given fields: ctx, query func (_m *FakeDashboardService) FindDashboards(ctx context.Context, query *FindPersistedDashboardsQuery) ([]DashboardSearchProjection, error) { ret := _m.Called(ctx, query) @@ -168,36 +196,6 @@ func (_m *FakeDashboardService) FindDashboards(ctx context.Context, query *FindP return r0, r1 } -// CountDashboardsInOrg provides a mock function with given fields: ctx, orgID -func (_m *FakeDashboardService) CountDashboardsInOrg(ctx context.Context, orgID int64) (int64, error) { - ret := _m.Called(ctx, orgID) - - if len(ret) == 0 { - panic("no return value specified for CountDashboardsInOrg") - } - - var r0 int64 - var r1 error - if rf, ok := ret.Get(0).(func(context.Context, int64) (int64, error)); ok { - return rf(ctx, orgID) - } - if rf, ok := ret.Get(0).(func(context.Context, int64) int64); ok { - r0 = rf(ctx, orgID) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(int64) - } - } - - if rf, ok := ret.Get(1).(func(context.Context, int64) error); ok { - r1 = rf(ctx, orgID) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - // GetAllDashboards provides a mock function with given fields: ctx func (_m *FakeDashboardService) GetAllDashboards(ctx context.Context) ([]*Dashboard, error) { ret := _m.Called(ctx) @@ -228,6 +226,7 @@ func (_m *FakeDashboardService) GetAllDashboards(ctx context.Context) ([]*Dashbo return r0, r1 } +// GetAllDashboardsByOrgId provides a mock function with given fields: ctx, orgID func (_m *FakeDashboardService) GetAllDashboardsByOrgId(ctx context.Context, orgID int64) ([]*Dashboard, error) { ret := _m.Called(ctx, orgID) diff --git a/pkg/services/dashboards/database/database.go b/pkg/services/dashboards/database/database.go index a9dd51c56a0..06c0c1a0550 100644 --- a/pkg/services/dashboards/database/database.go +++ b/pkg/services/dashboards/database/database.go @@ -146,6 +146,38 @@ func (d *dashboardStore) GetProvisionedDashboardData(ctx context.Context, name s return result, err } +func (d *dashboardStore) GetProvisionedDashboardsByName(ctx context.Context, name string) ([]*dashboards.Dashboard, error) { + ctx, span := tracer.Start(ctx, "dashboards.database.GetProvisionedDashboardsByName") + defer span.End() + + dashes := []*dashboards.Dashboard{} + err := d.store.WithDbSession(ctx, func(sess *db.Session) error { + return sess.Table(`dashboard`). + Join(`INNER`, `dashboard_provisioning`, `dashboard.id = dashboard_provisioning.dashboard_id`). + Where(`dashboard_provisioning.name = ?`, name).Find(&dashes) + }) + if err != nil { + return nil, err + } + return dashes, nil +} + +func (d *dashboardStore) GetOrphanedProvisionedDashboards(ctx context.Context, notIn []string) ([]*dashboards.Dashboard, error) { + ctx, span := tracer.Start(ctx, "dashboards.database.GetOrphanedProvisionedDashboards") + defer span.End() + + dashes := []*dashboards.Dashboard{} + err := d.store.WithDbSession(ctx, func(sess *db.Session) error { + return sess.Table(`dashboard`). + Join(`INNER`, `dashboard_provisioning`, `dashboard.id = dashboard_provisioning.dashboard_id`). + NotIn(`dashboard_provisioning.name`, notIn).Find(&dashes) + }) + if err != nil { + return nil, err + } + return dashes, nil +} + func (d *dashboardStore) SaveProvisionedDashboard(ctx context.Context, dash *dashboards.Dashboard, provisioning *dashboards.DashboardProvisioning) error { ctx, span := tracer.Start(ctx, "dashboards.database.SaveProvisionedDashboard") defer span.End() diff --git a/pkg/services/dashboards/database/database_test.go b/pkg/services/dashboards/database/database_test.go index ff33a306a9e..79b94fa7ab7 100644 --- a/pkg/services/dashboards/database/database_test.go +++ b/pkg/services/dashboards/database/database_test.go @@ -251,26 +251,51 @@ func TestIntegrationDashboardDataAccess(t *testing.T) { t.Run("Should delete associated provisioning info, even without the dashboard existing in the db", func(t *testing.T) { setup() + dash1 := insertTestDashboard(t, dashboardStore, "provisioned", 1, 0, "", false, "provisioned") + dash2 := insertTestDashboard(t, dashboardStore, "orphaned", 1, 0, "", false, "orphaned") provisioningData := &dashboards.DashboardProvisioning{ ID: 1, - DashboardID: 200, + DashboardID: dash1.ID, Name: "test", CheckSum: "123", Updated: 54321, ExternalID: "/path/to/dashboard", } - err := dashboardStore.SaveProvisionedDashboard(context.Background(), &dashboards.Dashboard{ - ID: 200, - }, provisioningData) + err := dashboardStore.SaveProvisionedDashboard(context.Background(), dash1, provisioningData) + require.NoError(t, err) + provisioningData2 := &dashboards.DashboardProvisioning{ + ID: 1, + DashboardID: dash2.ID, + Name: "orphaned", + CheckSum: "123", + Updated: 54321, + ExternalID: "/path/to/dashboard", + } + err = dashboardStore.SaveProvisionedDashboard(context.Background(), dash2, provisioningData2) require.NoError(t, err) + // get provisioning data res, err := dashboardStore.GetProvisionedDashboardData(context.Background(), "test") require.NoError(t, err) require.Len(t, res, 1) require.Equal(t, res[0], provisioningData) + // get dashboards within the provisioner + dashs, err := dashboardStore.GetProvisionedDashboardsByName(context.Background(), "test") + require.NoError(t, err) + require.Len(t, dashs, 1) + + // find dashboards not within that provisioner + dashs, err = dashboardStore.GetOrphanedProvisionedDashboards(context.Background(), []string{"test"}) + require.NoError(t, err) + require.Len(t, dashs, 1) + // if both are provided, nothing should be returned + dashs, err = dashboardStore.GetOrphanedProvisionedDashboards(context.Background(), []string{"test", "orphaned"}) + require.NoError(t, err) + require.Len(t, dashs, 0) + err = dashboardStore.CleanupAfterDelete(context.Background(), &dashboards.DeleteDashboardCommand{ - ID: 200, + ID: dash1.ID, OrgID: 1, UID: "test", }) diff --git a/pkg/services/dashboards/service/dashboard_service.go b/pkg/services/dashboards/service/dashboard_service.go index ed8a8fa1d19..eea8dd574ad 100644 --- a/pkg/services/dashboards/service/dashboard_service.go +++ b/pkg/services/dashboards/service/dashboard_service.go @@ -958,7 +958,7 @@ func (dr *DashboardServiceImpl) GetDashboardsByPluginID(ctx context.Context, que if dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesCliDashboards) { dashs, err := dr.searchDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{ OrgId: query.OrgID, - ProvisionedRepo: pluginIDRepoName, + ProvisionedRepo: dashboard.PluginIDRepoName, ProvisionedPath: query.PluginID, }) if err != nil { @@ -1559,7 +1559,7 @@ func (dr *DashboardServiceImpl) saveProvisionedDashboardThroughK8s(ctx context.C delete(annotations, utils.AnnoKeyRepoHash) delete(annotations, utils.AnnoKeyRepoTimestamp) } else { - annotations[utils.AnnoKeyRepoName] = provisionedFileNameWithPrefix(provisioning.Name) + annotations[utils.AnnoKeyRepoName] = dashboard.ProvisionedFileNameWithPrefix(provisioning.Name) annotations[utils.AnnoKeyRepoPath] = provisioning.ExternalID annotations[utils.AnnoKeyRepoHash] = provisioning.CheckSum annotations[utils.AnnoKeyRepoTimestamp] = time.Unix(provisioning.Updated, 0).UTC().Format(time.RFC3339) @@ -1580,7 +1580,7 @@ func (dr *DashboardServiceImpl) saveDashboardThroughK8s(ctx context.Context, cmd return nil, err } - setPluginID(obj, cmd.PluginID) + dashboard.SetPluginIDMeta(obj, cmd.PluginID) out, err := dr.createOrUpdateDash(ctx, obj, orgID) if err != nil { @@ -1827,13 +1827,13 @@ func (dr *DashboardServiceImpl) searchProvisionedDashboardsThroughK8s(ctx contex ctx, _ = identity.WithServiceIdentity(ctx, query.OrgId) if query.ProvisionedRepo != "" { - query.ProvisionedRepo = provisionedFileNameWithPrefix(query.ProvisionedRepo) + query.ProvisionedRepo = dashboard.ProvisionedFileNameWithPrefix(query.ProvisionedRepo) } if len(query.ProvisionedReposNotIn) > 0 { repos := make([]string, len(query.ProvisionedReposNotIn)) for i, v := range query.ProvisionedReposNotIn { - repos[i] = provisionedFileNameWithPrefix(v) + repos[i] = dashboard.ProvisionedFileNameWithPrefix(v) } query.ProvisionedReposNotIn = repos } @@ -1865,7 +1865,7 @@ func (dr *DashboardServiceImpl) searchProvisionedDashboardsThroughK8s(ctx contex } // ensure the repo is set due to file provisioning, otherwise skip it - fileRepo, found := getProvisionedFileNameFromMeta(meta) + fileRepo, found := dashboard.GetProvisionedFileNameFromMeta(meta.GetRepositoryName()) if !found { return nil } @@ -1967,7 +1967,7 @@ func (dr *DashboardServiceImpl) UnstructuredToLegacyDashboard(ctx context.Contex out.Deleted = obj.GetDeletionTimestamp().Time } - out.PluginID = GetPluginIDFromMeta(obj) + out.PluginID = dashboard.GetPluginIDFromMeta(obj) creator, err := dr.k8sclient.GetUserFromMeta(ctx, obj.GetCreatedBy()) if err != nil { @@ -2012,42 +2012,6 @@ func (dr *DashboardServiceImpl) UnstructuredToLegacyDashboard(ctx context.Contex return &out, nil } -var pluginIDRepoName = "plugin" -var fileProvisionedRepoPrefix = "file:" - -func setPluginID(obj unstructured.Unstructured, pluginID string) { - if pluginID == "" { - return - } - - annotations := obj.GetAnnotations() - if annotations == nil { - annotations = map[string]string{} - } - annotations[utils.AnnoKeyRepoName] = pluginIDRepoName - annotations[utils.AnnoKeyRepoPath] = pluginID - obj.SetAnnotations(annotations) -} - -func provisionedFileNameWithPrefix(name string) string { - if name == "" { - return "" - } - - return fileProvisionedRepoPrefix + name -} - -func getProvisionedFileNameFromMeta(obj utils.GrafanaMetaAccessor) (string, bool) { - return strings.CutPrefix(obj.GetRepositoryName(), fileProvisionedRepoPrefix) -} - -func GetPluginIDFromMeta(obj utils.GrafanaMetaAccessor) string { - if obj.GetRepositoryName() == pluginIDRepoName { - return obj.GetRepositoryPath() - } - return "" -} - func LegacySaveCommandToUnstructured(cmd *dashboards.SaveDashboardCommand, namespace string) (unstructured.Unstructured, error) { uid := cmd.GetDashboardModel().UID if uid == "" { diff --git a/pkg/services/dashboards/service/dashboard_service_test.go b/pkg/services/dashboards/service/dashboard_service_test.go index ccfa8f9e3a3..68ebc0ac61a 100644 --- a/pkg/services/dashboards/service/dashboard_service_test.go +++ b/pkg/services/dashboards/service/dashboard_service_test.go @@ -15,6 +15,7 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/apimachinery/utils" + "github.com/grafana/grafana/pkg/apis/dashboard" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/apiserver/client" @@ -547,7 +548,7 @@ func TestGetProvisionedDashboardData(t *testing.T) { utils.LabelKeyDeprecatedInternalID: "1", // nolint:staticcheck }, "annotations": map[string]any{ - utils.AnnoKeyRepoName: fileProvisionedRepoPrefix + "test", + utils.AnnoKeyRepoName: dashboard.ProvisionedFileNameWithPrefix("test"), utils.AnnoKeyRepoHash: "hash", utils.AnnoKeyRepoPath: "path/to/file", utils.AnnoKeyRepoTimestamp: "2025-01-01T00:00:00Z", @@ -563,7 +564,7 @@ func TestGetProvisionedDashboardData(t *testing.T) { k8sCliMock.On("Search", mock.Anything, int64(1), mock.MatchedBy(func(req *resource.ResourceSearchRequest) bool { // ensure the prefix is added to the query - return req.Options.Fields[0].Values[0] == provisionedFileNameWithPrefix(repo) + return req.Options.Fields[0].Values[0] == dashboard.ProvisionedFileNameWithPrefix(repo) })).Return(&resource.ResourceSearchResponse{ Results: &resource.ResourceTable{ Columns: []*resource.ResourceTableColumnDefinition{}, @@ -573,7 +574,7 @@ func TestGetProvisionedDashboardData(t *testing.T) { }, nil).Once() k8sCliMock.On("Search", mock.Anything, int64(2), mock.MatchedBy(func(req *resource.ResourceSearchRequest) bool { // ensure the prefix is added to the query - return req.Options.Fields[0].Values[0] == provisionedFileNameWithPrefix(repo) + return req.Options.Fields[0].Values[0] == dashboard.ProvisionedFileNameWithPrefix(repo) })).Return(&resource.ResourceSearchResponse{ Results: &resource.ResourceTable{ Columns: []*resource.ResourceTableColumnDefinition{ @@ -646,7 +647,7 @@ func TestGetProvisionedDashboardDataByDashboardID(t *testing.T) { utils.LabelKeyDeprecatedInternalID: "1", // nolint:staticcheck }, "annotations": map[string]any{ - utils.AnnoKeyRepoName: fileProvisionedRepoPrefix + "test", + utils.AnnoKeyRepoName: dashboard.ProvisionedFileNameWithPrefix("test"), utils.AnnoKeyRepoHash: "hash", utils.AnnoKeyRepoPath: "path/to/file", utils.AnnoKeyRepoTimestamp: "2025-01-01T00:00:00Z", @@ -736,7 +737,7 @@ func TestGetProvisionedDashboardDataByDashboardUID(t *testing.T) { utils.LabelKeyDeprecatedInternalID: "1", // nolint:staticcheck }, "annotations": map[string]any{ - utils.AnnoKeyRepoName: fileProvisionedRepoPrefix + "test", + utils.AnnoKeyRepoName: dashboard.ProvisionedFileNameWithPrefix("test"), utils.AnnoKeyRepoHash: "hash", utils.AnnoKeyRepoPath: "path/to/file", utils.AnnoKeyRepoTimestamp: "2025-01-01T00:00:00Z", @@ -825,7 +826,7 @@ func TestDeleteOrphanedProvisionedDashboards(t *testing.T) { "metadata": map[string]any{ "name": "uid", "annotations": map[string]any{ - utils.AnnoKeyRepoName: fileProvisionedRepoPrefix + "orphaned", + utils.AnnoKeyRepoName: dashboard.ProvisionedFileNameWithPrefix("orphaned"), utils.AnnoKeyRepoHash: "hash", utils.AnnoKeyRepoPath: "path/to/file", utils.AnnoKeyRepoTimestamp: "2025-01-01T00:00:00Z", @@ -838,7 +839,7 @@ func TestDeleteOrphanedProvisionedDashboards(t *testing.T) { "metadata": map[string]any{ "name": "uid2", "annotations": map[string]any{ - utils.AnnoKeyRepoName: "plugin", + utils.AnnoKeyRepoName: dashboard.PluginIDRepoName, utils.AnnoKeyRepoHash: "app", }, }, @@ -849,7 +850,7 @@ func TestDeleteOrphanedProvisionedDashboards(t *testing.T) { "metadata": map[string]any{ "name": "uid3", "annotations": map[string]any{ - utils.AnnoKeyRepoName: fileProvisionedRepoPrefix + "orphaned", + utils.AnnoKeyRepoName: dashboard.ProvisionedFileNameWithPrefix("orphaned"), utils.AnnoKeyRepoHash: "hash", utils.AnnoKeyRepoPath: "path/to/file", utils.AnnoKeyRepoTimestamp: "2025-01-01T00:00:00Z", @@ -858,7 +859,7 @@ func TestDeleteOrphanedProvisionedDashboards(t *testing.T) { "spec": map[string]any{}, }}, nil).Once() k8sCliMock.On("Search", mock.Anything, int64(1), mock.MatchedBy(func(req *resource.ResourceSearchRequest) bool { - return req.Options.Fields[0].Key == "repo.name" && req.Options.Fields[0].Values[0] == provisionedFileNameWithPrefix("test") && req.Options.Fields[0].Operator == "notin" + return req.Options.Fields[0].Key == "repo.name" && req.Options.Fields[0].Values[0] == dashboard.ProvisionedFileNameWithPrefix("test") && req.Options.Fields[0].Operator == "notin" })).Return(&resource.ResourceSearchResponse{ Results: &resource.ResourceTable{ Columns: []*resource.ResourceTableColumnDefinition{ @@ -888,7 +889,7 @@ func TestDeleteOrphanedProvisionedDashboards(t *testing.T) { }, nil).Once() k8sCliMock.On("Search", mock.Anything, int64(2), mock.MatchedBy(func(req *resource.ResourceSearchRequest) bool { - return req.Options.Fields[0].Key == "repo.name" && req.Options.Fields[0].Values[0] == provisionedFileNameWithPrefix("test") && req.Options.Fields[0].Operator == "notin" + return req.Options.Fields[0].Key == "repo.name" && req.Options.Fields[0].Values[0] == dashboard.ProvisionedFileNameWithPrefix("test") && req.Options.Fields[0].Operator == "notin" })).Return(&resource.ResourceSearchResponse{ Results: &resource.ResourceTable{ Columns: []*resource.ResourceTableColumnDefinition{ @@ -959,7 +960,7 @@ func TestUnprovisionDashboard(t *testing.T) { "metadata": map[string]any{ "name": "uid", "annotations": map[string]any{ - utils.AnnoKeyRepoName: fileProvisionedRepoPrefix + "test", + utils.AnnoKeyRepoName: dashboard.ProvisionedFileNameWithPrefix("test"), utils.AnnoKeyRepoHash: "hash", utils.AnnoKeyRepoPath: "path/to/file", utils.AnnoKeyRepoTimestamp: "2025-01-01T00:00:00Z", @@ -1053,7 +1054,7 @@ func TestGetDashboardsByPluginID(t *testing.T) { k8sCliMock.On("Get", mock.Anything, "uid", mock.Anything, mock.Anything, mock.Anything).Return(uidUnstructured, nil) k8sCliMock.On("GetUserFromMeta", mock.Anything, mock.Anything).Return(&user.User{}, nil) k8sCliMock.On("Search", mock.Anything, mock.Anything, mock.MatchedBy(func(req *resource.ResourceSearchRequest) bool { - return req.Options.Fields[0].Key == "repo.name" && req.Options.Fields[0].Values[0] == "plugin" && + return req.Options.Fields[0].Key == "repo.name" && req.Options.Fields[0].Values[0] == dashboard.PluginIDRepoName && req.Options.Fields[1].Key == "repo.path" && req.Options.Fields[1].Values[0] == "testing" })).Return(&resource.ResourceSearchResponse{ Results: &resource.ResourceTable{ @@ -1983,7 +1984,7 @@ func TestSearchProvisionedDashboardsThroughK8sRaw(t *testing.T) { "metadata": map[string]any{ "name": "uid", "annotations": map[string]any{ - utils.AnnoKeyRepoName: fileProvisionedRepoPrefix + "test", + utils.AnnoKeyRepoName: dashboard.ProvisionedFileNameWithPrefix("test"), utils.AnnoKeyRepoHash: "hash", utils.AnnoKeyRepoPath: "path/to/file", utils.AnnoKeyRepoTimestamp: "2025-01-01T00:00:00Z", diff --git a/pkg/services/dashboards/store_mock.go b/pkg/services/dashboards/store_mock.go index eed18fda8db..508fece061c 100644 --- a/pkg/services/dashboards/store_mock.go +++ b/pkg/services/dashboards/store_mock.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.42.2. DO NOT EDIT. +// Code generated by mockery v2.52.2. DO NOT EDIT. package dashboards @@ -66,36 +66,6 @@ func (_m *FakeDashboardStore) Count(_a0 context.Context, _a1 *quota.ScopeParamet return r0, r1 } -// CountInOrg provides a mock function with given fields: _a0, _a1 -func (_m *FakeDashboardStore) CountInOrg(_a0 context.Context, _a1 int64) (int64, error) { - ret := _m.Called(_a0, _a1) - - if len(ret) == 0 { - panic("no return value specified for Count") - } - - var r0 int64 - var r1 error - if rf, ok := ret.Get(0).(func(context.Context, int64) (int64, error)); ok { - return rf(_a0, _a1) - } - if rf, ok := ret.Get(0).(func(context.Context, int64) int64); ok { - r0 = rf(_a0, _a1) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(int64) - } - } - - if rf, ok := ret.Get(1).(func(context.Context, int64) error); ok { - r1 = rf(_a0, _a1) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - // CountDashboardsInFolders provides a mock function with given fields: ctx, request func (_m *FakeDashboardStore) CountDashboardsInFolders(ctx context.Context, request *CountDashboardsInFolderRequest) (int64, error) { ret := _m.Called(ctx, request) @@ -124,6 +94,35 @@ func (_m *FakeDashboardStore) CountDashboardsInFolders(ctx context.Context, requ return r0, r1 } +// CountInOrg provides a mock function with given fields: ctx, orgID +func (_m *FakeDashboardStore) CountInOrg(ctx context.Context, orgID int64) (int64, error) { + ret := _m.Called(ctx, orgID) + + if len(ret) == 0 { + panic("no return value specified for Count") + } + + var r0 int64 + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, int64) (int64, error)); ok { + return rf(ctx, orgID) + } + if rf, ok := ret.Get(0).(func(context.Context, int64) int64); ok { + r0 = rf(ctx, orgID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(int64) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, int64) error); ok { + r1 = rf(ctx, orgID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} // DeleteAllDashboards provides a mock function with given fields: ctx, orgID func (_m *FakeDashboardStore) DeleteAllDashboards(ctx context.Context, orgID int64) error { ret := _m.Called(ctx, orgID) @@ -436,6 +435,36 @@ func (_m *FakeDashboardStore) GetDashboardsByPluginID(ctx context.Context, query return r0, r1 } +// GetOrphanedProvisionedDashboards provides a mock function with given fields: ctx, notIn +func (_m *FakeDashboardStore) GetOrphanedProvisionedDashboards(ctx context.Context, notIn []string) ([]*Dashboard, error) { + ret := _m.Called(ctx, notIn) + + if len(ret) == 0 { + panic("no return value specified for GetOrphanedProvisionedDashboards") + } + + var r0 []*Dashboard + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, []string) ([]*Dashboard, error)); ok { + return rf(ctx, notIn) + } + if rf, ok := ret.Get(0).(func(context.Context, []string) []*Dashboard); ok { + r0 = rf(ctx, notIn) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*Dashboard) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, []string) error); ok { + r1 = rf(ctx, notIn) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetProvisionedDashboardData provides a mock function with given fields: ctx, name func (_m *FakeDashboardStore) GetProvisionedDashboardData(ctx context.Context, name string) ([]*DashboardProvisioning, error) { ret := _m.Called(ctx, name) @@ -466,6 +495,36 @@ func (_m *FakeDashboardStore) GetProvisionedDashboardData(ctx context.Context, n return r0, r1 } +// GetProvisionedDashboardsByName provides a mock function with given fields: ctx, name +func (_m *FakeDashboardStore) GetProvisionedDashboardsByName(ctx context.Context, name string) ([]*Dashboard, error) { + ret := _m.Called(ctx, name) + + if len(ret) == 0 { + panic("no return value specified for GetProvisionedDashboardsByName") + } + + var r0 []*Dashboard + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) ([]*Dashboard, error)); ok { + return rf(ctx, name) + } + if rf, ok := ret.Get(0).(func(context.Context, string) []*Dashboard); ok { + r0 = rf(ctx, name) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*Dashboard) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, name) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetProvisionedDataByDashboardID provides a mock function with given fields: ctx, dashboardID func (_m *FakeDashboardStore) GetProvisionedDataByDashboardID(ctx context.Context, dashboardID int64) (*DashboardProvisioning, error) { ret := _m.Called(ctx, dashboardID) diff --git a/public/app/features/search/page/components/columns.tsx b/public/app/features/search/page/components/columns.tsx index 05c4b2e6792..3f982658dd0 100644 --- a/public/app/features/search/page/components/columns.tsx +++ b/public/app/features/search/page/components/columns.tsx @@ -194,17 +194,34 @@ export const generateColumns = ( if (!info && p === 'general') { info = { kind: 'folder', url: '/dashboards', name: 'Dashboards' }; } - return info ? ( - - - - {info.name} - - - ) : ( - {p} - ); + if (info) { + const content = ( + <> + + + + {info.name} + + + ); + + if (info.url) { + return ( + + {content} + + ); + } + + return ( +
+ {content} +
+ ); + } + + return {p}; })} )} diff --git a/public/app/features/search/service/unified.test.ts b/public/app/features/search/service/unified.test.ts index e62a126f38f..8841418cd5a 100644 --- a/public/app/features/search/service/unified.test.ts +++ b/public/app/features/search/service/unified.test.ts @@ -115,9 +115,6 @@ describe('Unified Storage Searcher', () => { .mockResolvedValueOnce(mockResults) .mockResolvedValueOnce(mockFolders); - const consoleWarn = jest.fn(); - jest.spyOn(console, 'warn').mockImplementationOnce(consoleWarn); - const query: SearchQuery = { query: 'test', limit: 50, @@ -127,14 +124,15 @@ describe('Unified Storage Searcher', () => { const response = await searcher.search(query); - expect(response.view.length).toBe(1); - expect(response.view.get(0).title).toBe('DB 2'); + expect(response.view.length).toBe(2); + expect(response.view.get(0).title).toBe('DB 1'); + expect(response.view.get(0).folder).toBe('sharedwithme'); + expect(response.view.get(1).title).toBe('DB 2'); const df = response.view.dataFrame; const locationInfo = df.meta?.custom?.locationInfo; expect(locationInfo).toBeDefined(); expect(locationInfo?.folder2.name).toBe('Folder 2'); - expect(consoleWarn).toHaveBeenCalled(); expect(mockSearcher.search).toHaveBeenCalledTimes(3); }); diff --git a/public/app/features/search/service/unified.ts b/public/app/features/search/service/unified.ts index 9d942f668d3..8e270aba37b 100644 --- a/public/app/features/search/service/unified.ts +++ b/public/app/features/search/service/unified.ts @@ -204,15 +204,19 @@ export class UnifiedSearcher implements GrafanaSearcher { if (!hasMissing) { return rsp; } - // we still have results here with folders we can't find - // filter the results since we probably don't have access to that folder + const locationInfo = await this.locationInfo; - const hits = rsp.hits.filter((hit) => { - if (hit.folder === undefined || locationInfo[hit.folder] !== undefined) { - return true; + const hits = rsp.hits.map((hit) => { + if (hit.folder === undefined) { + return { ...hit, location: 'general', folder: 'general' }; } - console.warn('Dropping search hit with missing folder', hit); - return false; + + // this means user has permission to see this dashboard, but not the folder contents + if (locationInfo[hit.folder] === undefined) { + return { ...hit, location: 'sharedwithme', folder: 'sharedwithme' }; + } + + return hit; }); const totalHits = rsp.totalHits - (rsp.hits.length - hits.length); return { ...rsp, hits, totalHits }; @@ -370,6 +374,11 @@ async function loadLocationInfo(): Promise> { name: 'Dashboards', url: '/dashboards', }, // share location info with everyone + sharedwithme: { + kind: 'sharedwithme', + name: 'Shared with me', + url: '', + }, }; for (const hit of rsp.hits) { locationInfo[hit.name] = { diff --git a/public/app/features/search/service/utils.ts b/public/app/features/search/service/utils.ts index 6aeeaefba92..85c0168e480 100644 --- a/public/app/features/search/service/utils.ts +++ b/public/app/features/search/service/utils.ts @@ -49,6 +49,10 @@ export function getIconForKind(kind: string, isOpen?: boolean): IconName { return isOpen ? 'folder-open' : 'folder'; } + if (kind === 'sharedwithme') { + return 'users-alt'; + } + return 'question-circle'; } diff --git a/public/app/features/trails/DataTrail.tsx b/public/app/features/trails/DataTrail.tsx index 32d353a9e9b..aef53bad053 100644 --- a/public/app/features/trails/DataTrail.tsx +++ b/public/app/features/trails/DataTrail.tsx @@ -650,10 +650,9 @@ function getVariableSet( addFilterButtonText: 'Select resource attributes', datasource: trailDS, hide: VariableHide.hideVariable, - layout: 'vertical', + layout: 'combobox', defaultKeys: [], applyMode: 'manual', - supportsMultiValueOperators: true, allowCustomValue: true, }), new AdHocFiltersVariable({ @@ -662,12 +661,10 @@ function getVariableSet( datasource: trailDS, // default to use var filters and have otel off hide: VariableHide.hideLabel, - layout: 'vertical', + layout: 'combobox', filters: initialFilters ?? [], baseFilters: getBaseFiltersForMetric(metric), applyMode: 'manual', - // since we only support prometheus datasources, this is always true - supportsMultiValueOperators: true, allowCustomValue: true, expressionBuilder: (filters: AdHocVariableFilter[]) => { return [...getBaseFiltersForMetric(metric), ...filters] @@ -691,12 +688,10 @@ function getVariableSet( addFilterButtonText: 'Filter', datasource: trailDS, hide: VariableHide.hideVariable, - layout: 'vertical', + layout: 'combobox', filters: initialFilters ?? [], baseFilters: getBaseFiltersForMetric(metric), applyMode: 'manual', - // since we only support prometheus datasources, this is always true - supportsMultiValueOperators: true, allowCustomValue: true, // skipUrlSync: true }), diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index 46fe1314938..fed79005cc6 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -34,12 +34,6 @@ "save-button": "" } }, - "actions-editor": { - "inline": { - "add-button": "", - "one-click-link": "" - } - }, "admin": { "anon-users": { "not-found": "" @@ -174,6 +168,24 @@ } }, "alerting": { + "alert": { + "alert-state": "", + "annotations": "", + "evaluation": "", + "evaluation-paused": "", + "evaluation-paused-description": "", + "last-evaluated": "", + "last-evaluation-duration": "", + "last-updated-at": "", + "last-updated-by": "", + "no-annotations": "", + "pending-period": "", + "rule": "", + "rule-identifier": "", + "rule-type": "", + "state-error-timeout": "", + "state-no-data": "" + }, "alert-recording-rule-form": { "evaluation-behaviour": { "description": { @@ -283,6 +295,7 @@ "contactPointFilter": { "label": "" }, + "copy-to-clipboard": "", "export": { "subtitle": { "formats": "", @@ -299,6 +312,13 @@ } } }, + "group-actions": { + "actions-trigger": "", + "delete": "", + "edit": "", + "export": "", + "reorder": "" + }, "list-view": { "empty": { "new-alert-rule": "", @@ -484,11 +504,23 @@ }, "rule-list": { "configure-datasource": "", + "ds-error-boundary": { + "description": "", + "title": "" + }, "filter-view": { "no-more-results": "", "no-rules-found": "" }, - "new-alert-rule": "" + "new-alert-rule": "", + "pagination": { + "next-page": "", + "previous-page": "" + }, + "return-button": { + "title": "" + }, + "rulerrule-loading-error": "" }, "rule-state": { "creating": "", @@ -690,7 +722,10 @@ "title": "" }, "custom-value": { - "label": "" + "description": "" + }, + "group": { + "undefined": "" }, "options": { "no-found": "" @@ -863,6 +898,82 @@ "redirect-link": "Liste in Grafana Alerting", "subtitle": "Benachrichtigungsregeln im Zusammenhang mit diesem Dashboard" }, + "default-layout": { + "description": "", + "item-options": { + "repeat": { + "direction": { + "horizontal": "", + "title": "", + "vertical": "" + }, + "max": "", + "title": "", + "variable": { + "description": "", + "title": "" + } + } + }, + "name": "", + "row-actions": { + "delete": "", + "modal": { + "alt-action": "", + "text": "", + "title": "" + } + }, + "row-options": { + "button": { + "label": "" + }, + "form": { + "cancel": "", + "repeat-for": { + "label": "", + "learn-more": "", + "warning": { + "text": "" + } + }, + "title": "", + "update": "" + }, + "modal": { + "title": "" + } + } + }, + "edit-pane": { + "objects": { + "multi-select": { + "selection-number": "" + } + }, + "open": "", + "panels": { + "multi-select": { + "selection-number": "" + } + }, + "row": { + "header": { + "hide": "", + "title": "" + }, + "multi-select": { + "options-header": "", + "selection-number": "" + } + }, + "tab": { + "multi-select": { + "options-header": "", + "selection-number": "" + } + } + }, "empty": { "add-library-panel-body": "Visualisierungen hinzufügen, die mit anderen Dashboards geteilt werden.", "add-library-panel-button": "Bibliotheksfenster hinzufügen", @@ -874,6 +985,9 @@ "import-a-dashboard-header": "Dashboard importieren", "import-dashboard-button": "Dashboard importieren" }, + "errors": { + "failed-to-load": "" + }, "inspect": { "data-tab": "Daten", "error-tab": "Fehler", @@ -926,19 +1040,130 @@ "rows": "Gesamtanzahl an Zeilen", "table-title": "Statistiken" }, + "options": { + "description": "", + "title": "", + "title-option": "" + }, + "panel-edit": { + "alerting-tab": { + "dashboard-not-saved": "", + "no-rules": "" + } + }, + "responsive-layout": { + "description": "", + "item-options": { + "hide-no-data": "", + "title": "" + }, + "name": "", + "options": { + "columns": "", + "fixed": "", + "min": "", + "one-column": "", + "rows": "", + "three-columns": "", + "two-columns": "" + } + }, + "rows-layout": { + "description": "", + "name": "", + "row": { + "collapse": "", + "expand": "", + "new": "", + "repeat": { + "learn-more": "", + "warning": "" + } + }, + "row-options": { + "height": { + "expand": "", + "hide-row-header": "", + "min": "", + "title": "" + }, + "repeat": { + "title": "", + "variable": { + "title": "" + } + }, + "title": "", + "title-option": "" + } + }, + "tabs-layout": { + "description": "", + "name": "", + "tab": { + "new": "" + }, + "tab-options": { + "title": "", + "title-option": "" + } + }, "toolbar": { "add": "Hinzufügen", + "add-panel": "", + "add-panel-lib": "", + "add-row": "", + "add-tab": "", "alert-rules": "Warnregeln", + "back-to-dashboard": "", + "dashboard-settings": { + "label": "", + "tooltip": "" + }, + "discard-library-panel-changes": "", + "discard-panel": "", + "discard-panel-new": "", + "edit": { + "label": "", + "tooltip": "" + }, + "edit-dashboard-v2-schema": "", + "enter-edit-mode": { + "label": "", + "tooltip": "" + }, + "exit-edit-mode": { + "label": "", + "tooltip": "" + }, "mark-favorite": "Als Favorit markieren", + "more-save-options": "", "open-original": "Original-Dashboard öffnen", "playlist-next": "Zum nächsten Dashboard", "playlist-previous": "Zum vorherigen Dashboard", "playlist-stop": "Wiedergabeliste stoppen", + "public-dashboard": "", "refresh": "Dashboard aktualisieren", "save": "Dashboard speichern", + "save-dashboard": { + "label": "", + "tooltip": "" + }, + "save-dashboard-copy": { + "label": "", + "tooltip": "" + }, + "save-dashboard-short": "", + "save-library-panel": "", "settings": "Dashboard-Einstellungen", - "share": "Dashboard oder Panel teilen", + "share": { + "label": "", + "tooltip": "" + }, "share-button": "Teilen", + "show-hidden-elements": "", + "switch-old-dashboard": "", + "unlink-library-panel": "", "unmark-favorite": "Markierung als Favorit entfernen" }, "validation": { @@ -946,6 +1171,14 @@ "invalid-json": "JSON ungültig", "tags-expected-array": "Array der jeweiligen Tags", "tags-expected-strings": "String-Array der jeweiligen Tags" + }, + "viz-panel": { + "options": { + "description": "", + "title": "", + "title-option": "", + "transparent-background": "" + } } }, "dashboard-import": { @@ -1048,6 +1281,12 @@ "angular-deprecation-description": "", "angular-deprecation-heading": "" }, + "panel-queries": { + "add-query-from-library": "" + }, + "query-library": { + "add-query-button": "" + }, "settings": { "variables": { "dependencies": { @@ -1125,40 +1364,11 @@ "scan-for-older-logs": "", "stop-scan": "" }, - "query-library": { - "add-edit-description": "", - "cancel": "", - "default-description": "", - "delete-query": "", - "delete-query-text": "", - "delete-query-title": "", - "private": "", - "public": "", - "query-deleted": "", - "query-template-add-error": "", - "query-template-added": "", - "query-template-edit-error": "", - "query-template-edited": "", - "save": "" - }, - "query-template-modal": { - "add-info": "", - "add-title": "", - "auto-star": "", - "data-source-name": "", - "description": "", - "edit-info": "", - "edit-title": "", - "query": "", - "visibility": "" - }, - "query-template-modall": { - "data-source-type": "" - }, "rich-history": { "close-tooltip": "Abfrageverlauf schließen", "datasource-a-z": "Datenquelle A-Z", "datasource-z-a": "Datenquelle Z-A", + "library-history-dropdown": "", "newest-first": "Neueste zuerst", "oldest-first": "Älteste zuerst", "query-history": "Abfrageverlauf", @@ -1305,10 +1515,9 @@ "sortBy": "" }, "related-logs": { - "docsLink": "", + "LrrDocsLink": "", "openExploreLogs": "", - "relatedLogsUnavailableAfterDocsLink": "", - "relatedLogsUnavailableBeforeDocsLink": "", + "relatedLogsUnavailable": "", "warnExperimentalFeature": "" }, "viewBy": "" @@ -1357,6 +1566,24 @@ "send-custom-feedback": "" }, "grafana-ui": { + "action-editor": { + "button": { + "confirm": "", + "confirm-action": "" + }, + "inline": { + "add-action": "", + "edit-action": "" + }, + "modal": { + "action-body": "", + "action-method": "", + "action-query-params": "", + "action-title": "", + "action-title-placeholder": "", + "one-click-description": "" + } + }, "auto-save-field": { "saved": "", "saving": "" @@ -1373,11 +1600,21 @@ }, "data-link-editor-modal": { "cancel": "", + "one-click-description": "", "save": "" }, + "data-link-inline-editor": { + "one-click": "" + }, "data-links-inline-editor": { "add-link": "", - "one-click-link": "" + "edit-link": "", + "one-click": "", + "one-click-enabled": "", + "title-not-provided": "", + "tooltip-edit": "", + "tooltip-remove": "", + "url-not-provided": "" }, "data-source-http-settings": { "access-help": "", @@ -1458,7 +1695,12 @@ "right-axis-indicator": "" }, "viz-tooltip": { - "footer-add-annotation": "" + "actions-confirmation-input-placeholder": "", + "actions-confirmation-label": "", + "actions-confirmation-message": "", + "footer-add-annotation": "", + "footer-click-to-action": "", + "footer-click-to-navigate": "" } }, "graph": { @@ -1780,7 +2022,8 @@ }, "log-row-message": { "ellipsis": "", - "more": "" + "more": "", + "see-details": "" }, "log-rows": { "disable-popover": { @@ -1792,6 +2035,13 @@ "shortcut": "" } }, + "logs-navigation": { + "newer-logs": "", + "older-logs": "", + "scroll-bottom": "", + "scroll-top": "", + "start-of-range": "" + }, "popover-menu": { "copy": "", "disable-menu": "", @@ -1881,6 +2131,7 @@ "title": "" }, "migrated-counts": { + "alert_rule_groups": "", "alert_rules": "", "contact_points": "", "dashboards": "", @@ -1941,9 +2192,8 @@ "public-preview": { "button-text": "", "message": "", - "message-plugins": "", - "title": "", - "title-plugins": "" + "message-cloud": "", + "title": "" }, "resource-details": { "dismiss-button": "", @@ -1986,6 +2236,7 @@ }, "resource-type": { "alert_rule": "", + "alert_rule_group": "", "contact_point": "", "dashboard": "Dashboard", "datasource": "Datenquelle", @@ -2031,6 +2282,15 @@ "title": "Warum mit Grafana hosten?" } }, + "multicombobox": { + "all": { + "title": "", + "title-filtered": "" + }, + "clear": { + "title": "" + } + }, "nav": { "add-new-connections": { "title": "Neue Verbindung hinzufügen" @@ -2384,7 +2644,8 @@ "list-label": "Navigation", "open": "", "undock": "Menü abdocken" - } + }, + "rss-button": "" }, "news": { "drawer": { @@ -2516,19 +2777,35 @@ } }, "details": { + "connections-tab": { + "description": "" + }, "labels": { "contactGrafanaLabs": "", + "customLinks": "", + "customLinksTooltip": "", "dependencies": "", + "documentation": "", "downloads": "", "from": "", "installedVersion": "", "lastCommitDate": "", "latestVersion": "", - "links": "", + "license": "", + "raiseAnIssue": "", "reportAbuse": "", + "reportAbuseTooltip": "", + "repository": "", "signature": "", "status": "", "updatedAt": "" + }, + "modal": { + "cancel": "", + "copyEmail": "", + "description": "", + "node": "", + "title": "" } }, "empty-state": { @@ -2743,14 +3020,6 @@ "role-label": "Rolle" } }, - "query-library": { - "datasource-names": "", - "delete-query-button": "", - "query-template-get-error": "", - "search": "", - "user-info-get-error": "", - "user-names": "" - }, "query-operation": { "header": { "collapse-row": "Abfragezeile einklappen", @@ -2760,7 +3029,6 @@ "expand-row": "Suchzeile erweitern", "hide-response": "", "remove-query": "Abfrage entfernen", - "save-to-query-library": "", "show-response": "", "toggle-edit-mode": "Textbearbeitungsmodus umschalten" }, @@ -2862,6 +3130,10 @@ "title": "" }, "save-dashboards": { + "message-length": { + "info": "", + "title": "" + }, "name-exists": { "message-info": "", "message-suggestion": "", @@ -3145,6 +3417,7 @@ "home-dashboard-placeholder": "Standard-Dashboard", "locale-label": "Sprache", "locale-placeholder": "Sprache wählen", + "theme-description": "", "theme-label": "UI-Design", "week-start-label": "Wochenbeginn" }, @@ -3216,6 +3489,13 @@ "url-column-header": "Snapshot url", "view-button": "Anzeigen" }, + "table": { + "container": { + "content": "", + "show-all-series": "", + "show-only-series": "" + } + }, "tag-filter": { "clear-button": "", "loading": "Wird geladen ...", @@ -3309,20 +3589,26 @@ "start-your-metrics-exploration": "", "subtitle": "" }, - "metric-overview": { - "description-label": "", - "labels": "", - "metric-attributes": "", - "no-description": "", - "type-label": "", - "unit-label": "", - "unknown-type": "" - }, "metric-select": { "filter-by": "", + "native-histogram": "", "new-badge": "", "otel-switch": "" }, + "native-histogram-banner": { + "ch-heatmap": "", + "ch-histogram": "", + "click-histogram": "", + "hide-examples": "", + "learn-more": "", + "metric-examples": "", + "nh-heatmap": "", + "nh-histogram": "", + "now": "", + "previously": "", + "see-examples": "", + "sentence": "" + }, "recent-metrics": { "or-view-a-recent-exploration": "" }, diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index f324380f834..02db11b3d08 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -34,12 +34,6 @@ "save-button": "" } }, - "actions-editor": { - "inline": { - "add-button": "", - "one-click-link": "" - } - }, "admin": { "anon-users": { "not-found": "" @@ -174,6 +168,24 @@ } }, "alerting": { + "alert": { + "alert-state": "", + "annotations": "", + "evaluation": "", + "evaluation-paused": "", + "evaluation-paused-description": "", + "last-evaluated": "", + "last-evaluation-duration": "", + "last-updated-at": "", + "last-updated-by": "", + "no-annotations": "", + "pending-period": "", + "rule": "", + "rule-identifier": "", + "rule-type": "", + "state-error-timeout": "", + "state-no-data": "" + }, "alert-recording-rule-form": { "evaluation-behaviour": { "description": { @@ -283,6 +295,7 @@ "contactPointFilter": { "label": "" }, + "copy-to-clipboard": "", "export": { "subtitle": { "formats": "", @@ -299,6 +312,13 @@ } } }, + "group-actions": { + "actions-trigger": "", + "delete": "", + "edit": "", + "export": "", + "reorder": "" + }, "list-view": { "empty": { "new-alert-rule": "", @@ -484,11 +504,23 @@ }, "rule-list": { "configure-datasource": "", + "ds-error-boundary": { + "description": "", + "title": "" + }, "filter-view": { "no-more-results": "", "no-rules-found": "" }, - "new-alert-rule": "" + "new-alert-rule": "", + "pagination": { + "next-page": "", + "previous-page": "" + }, + "return-button": { + "title": "" + }, + "rulerrule-loading-error": "" }, "rule-state": { "creating": "", @@ -690,7 +722,10 @@ "title": "" }, "custom-value": { - "label": "" + "description": "" + }, + "group": { + "undefined": "" }, "options": { "no-found": "" @@ -863,6 +898,82 @@ "redirect-link": "Lista en Alertas de Grafana", "subtitle": "Reglas de alerta relacionadas con este tablero" }, + "default-layout": { + "description": "", + "item-options": { + "repeat": { + "direction": { + "horizontal": "", + "title": "", + "vertical": "" + }, + "max": "", + "title": "", + "variable": { + "description": "", + "title": "" + } + } + }, + "name": "", + "row-actions": { + "delete": "", + "modal": { + "alt-action": "", + "text": "", + "title": "" + } + }, + "row-options": { + "button": { + "label": "" + }, + "form": { + "cancel": "", + "repeat-for": { + "label": "", + "learn-more": "", + "warning": { + "text": "" + } + }, + "title": "", + "update": "" + }, + "modal": { + "title": "" + } + } + }, + "edit-pane": { + "objects": { + "multi-select": { + "selection-number": "" + } + }, + "open": "", + "panels": { + "multi-select": { + "selection-number": "" + } + }, + "row": { + "header": { + "hide": "", + "title": "" + }, + "multi-select": { + "options-header": "", + "selection-number": "" + } + }, + "tab": { + "multi-select": { + "options-header": "", + "selection-number": "" + } + } + }, "empty": { "add-library-panel-body": "Añadir las visualizaciones que se comparten con otros tableros.", "add-library-panel-button": "Añadir panel de biblioteca", @@ -874,6 +985,9 @@ "import-a-dashboard-header": "Importar un tablero", "import-dashboard-button": "Importar panel de control" }, + "errors": { + "failed-to-load": "" + }, "inspect": { "data-tab": "Datos", "error-tab": "Error", @@ -926,19 +1040,130 @@ "rows": "Número total de filas", "table-title": "Estadísticas" }, + "options": { + "description": "", + "title": "", + "title-option": "" + }, + "panel-edit": { + "alerting-tab": { + "dashboard-not-saved": "", + "no-rules": "" + } + }, + "responsive-layout": { + "description": "", + "item-options": { + "hide-no-data": "", + "title": "" + }, + "name": "", + "options": { + "columns": "", + "fixed": "", + "min": "", + "one-column": "", + "rows": "", + "three-columns": "", + "two-columns": "" + } + }, + "rows-layout": { + "description": "", + "name": "", + "row": { + "collapse": "", + "expand": "", + "new": "", + "repeat": { + "learn-more": "", + "warning": "" + } + }, + "row-options": { + "height": { + "expand": "", + "hide-row-header": "", + "min": "", + "title": "" + }, + "repeat": { + "title": "", + "variable": { + "title": "" + } + }, + "title": "", + "title-option": "" + } + }, + "tabs-layout": { + "description": "", + "name": "", + "tab": { + "new": "" + }, + "tab-options": { + "title": "", + "title-option": "" + } + }, "toolbar": { "add": "Añadir", + "add-panel": "", + "add-panel-lib": "", + "add-row": "", + "add-tab": "", "alert-rules": "Reglas de alerta", + "back-to-dashboard": "", + "dashboard-settings": { + "label": "", + "tooltip": "" + }, + "discard-library-panel-changes": "", + "discard-panel": "", + "discard-panel-new": "", + "edit": { + "label": "", + "tooltip": "" + }, + "edit-dashboard-v2-schema": "", + "enter-edit-mode": { + "label": "", + "tooltip": "" + }, + "exit-edit-mode": { + "label": "", + "tooltip": "" + }, "mark-favorite": "Marcar como favorito", + "more-save-options": "", "open-original": "Abrir el panel de control original", "playlist-next": "Ir al siguiente panel de control", "playlist-previous": "Ir al panel de control anterior", "playlist-stop": "Detener la lista de reproducción", + "public-dashboard": "", "refresh": "Actualizar panel de control", "save": "Guardar panel de control", + "save-dashboard": { + "label": "", + "tooltip": "" + }, + "save-dashboard-copy": { + "label": "", + "tooltip": "" + }, + "save-dashboard-short": "", + "save-library-panel": "", "settings": "Ajustes del panel de control", - "share": "Compartir panel o panel de control", + "share": { + "label": "", + "tooltip": "" + }, "share-button": "Compartir", + "show-hidden-elements": "", + "switch-old-dashboard": "", + "unlink-library-panel": "", "unmark-favorite": "Deshacer marca como favorito" }, "validation": { @@ -946,6 +1171,14 @@ "invalid-json": "JSON no válido", "tags-expected-array": "etiquetas: matriz prevista", "tags-expected-strings": "etiquetas: matriz de cadenas prevista" + }, + "viz-panel": { + "options": { + "description": "", + "title": "", + "title-option": "", + "transparent-background": "" + } } }, "dashboard-import": { @@ -1048,6 +1281,12 @@ "angular-deprecation-description": "", "angular-deprecation-heading": "" }, + "panel-queries": { + "add-query-from-library": "" + }, + "query-library": { + "add-query-button": "" + }, "settings": { "variables": { "dependencies": { @@ -1125,40 +1364,11 @@ "scan-for-older-logs": "", "stop-scan": "" }, - "query-library": { - "add-edit-description": "", - "cancel": "", - "default-description": "", - "delete-query": "", - "delete-query-text": "", - "delete-query-title": "", - "private": "", - "public": "", - "query-deleted": "", - "query-template-add-error": "", - "query-template-added": "", - "query-template-edit-error": "", - "query-template-edited": "", - "save": "" - }, - "query-template-modal": { - "add-info": "", - "add-title": "", - "auto-star": "", - "data-source-name": "", - "description": "", - "edit-info": "", - "edit-title": "", - "query": "", - "visibility": "" - }, - "query-template-modall": { - "data-source-type": "" - }, "rich-history": { "close-tooltip": "Cerrar el historial de consultas", "datasource-a-z": "Fuente de datos A-Z", "datasource-z-a": "Fuente de datos Z-A", + "library-history-dropdown": "", "newest-first": "El más reciente primero", "oldest-first": "El más antiguo primero", "query-history": "Historial de consultas", @@ -1305,10 +1515,9 @@ "sortBy": "" }, "related-logs": { - "docsLink": "", + "LrrDocsLink": "", "openExploreLogs": "", - "relatedLogsUnavailableAfterDocsLink": "", - "relatedLogsUnavailableBeforeDocsLink": "", + "relatedLogsUnavailable": "", "warnExperimentalFeature": "" }, "viewBy": "" @@ -1357,6 +1566,24 @@ "send-custom-feedback": "" }, "grafana-ui": { + "action-editor": { + "button": { + "confirm": "", + "confirm-action": "" + }, + "inline": { + "add-action": "", + "edit-action": "" + }, + "modal": { + "action-body": "", + "action-method": "", + "action-query-params": "", + "action-title": "", + "action-title-placeholder": "", + "one-click-description": "" + } + }, "auto-save-field": { "saved": "", "saving": "" @@ -1373,11 +1600,21 @@ }, "data-link-editor-modal": { "cancel": "", + "one-click-description": "", "save": "" }, + "data-link-inline-editor": { + "one-click": "" + }, "data-links-inline-editor": { "add-link": "", - "one-click-link": "" + "edit-link": "", + "one-click": "", + "one-click-enabled": "", + "title-not-provided": "", + "tooltip-edit": "", + "tooltip-remove": "", + "url-not-provided": "" }, "data-source-http-settings": { "access-help": "", @@ -1458,7 +1695,12 @@ "right-axis-indicator": "" }, "viz-tooltip": { - "footer-add-annotation": "" + "actions-confirmation-input-placeholder": "", + "actions-confirmation-label": "", + "actions-confirmation-message": "", + "footer-add-annotation": "", + "footer-click-to-action": "", + "footer-click-to-navigate": "" } }, "graph": { @@ -1780,7 +2022,8 @@ }, "log-row-message": { "ellipsis": "", - "more": "" + "more": "", + "see-details": "" }, "log-rows": { "disable-popover": { @@ -1792,6 +2035,13 @@ "shortcut": "" } }, + "logs-navigation": { + "newer-logs": "", + "older-logs": "", + "scroll-bottom": "", + "scroll-top": "", + "start-of-range": "" + }, "popover-menu": { "copy": "", "disable-menu": "", @@ -1881,6 +2131,7 @@ "title": "" }, "migrated-counts": { + "alert_rule_groups": "", "alert_rules": "", "contact_points": "", "dashboards": "", @@ -1941,9 +2192,8 @@ "public-preview": { "button-text": "", "message": "", - "message-plugins": "", - "title": "", - "title-plugins": "" + "message-cloud": "", + "title": "" }, "resource-details": { "dismiss-button": "", @@ -1986,6 +2236,7 @@ }, "resource-type": { "alert_rule": "", + "alert_rule_group": "", "contact_point": "", "dashboard": "Panel de control", "datasource": "Fuente de datos", @@ -2031,6 +2282,15 @@ "title": "¿Por qué alojar con Grafana?" } }, + "multicombobox": { + "all": { + "title": "", + "title-filtered": "" + }, + "clear": { + "title": "" + } + }, "nav": { "add-new-connections": { "title": "Añadir nueva conexión" @@ -2384,7 +2644,8 @@ "list-label": "Navegación", "open": "", "undock": "Desanclar el menú" - } + }, + "rss-button": "" }, "news": { "drawer": { @@ -2516,19 +2777,35 @@ } }, "details": { + "connections-tab": { + "description": "" + }, "labels": { "contactGrafanaLabs": "", + "customLinks": "", + "customLinksTooltip": "", "dependencies": "", + "documentation": "", "downloads": "", "from": "", "installedVersion": "", "lastCommitDate": "", "latestVersion": "", - "links": "", + "license": "", + "raiseAnIssue": "", "reportAbuse": "", + "reportAbuseTooltip": "", + "repository": "", "signature": "", "status": "", "updatedAt": "" + }, + "modal": { + "cancel": "", + "copyEmail": "", + "description": "", + "node": "", + "title": "" } }, "empty-state": { @@ -2743,14 +3020,6 @@ "role-label": "Rol" } }, - "query-library": { - "datasource-names": "", - "delete-query-button": "", - "query-template-get-error": "", - "search": "", - "user-info-get-error": "", - "user-names": "" - }, "query-operation": { "header": { "collapse-row": "Contraer la fila de la consulta", @@ -2760,7 +3029,6 @@ "expand-row": "Expandir la fila de la consulta", "hide-response": "", "remove-query": "Eliminar consulta", - "save-to-query-library": "", "show-response": "", "toggle-edit-mode": "Alternar el modo de edición de texto" }, @@ -2862,6 +3130,10 @@ "title": "" }, "save-dashboards": { + "message-length": { + "info": "", + "title": "" + }, "name-exists": { "message-info": "", "message-suggestion": "", @@ -3145,6 +3417,7 @@ "home-dashboard-placeholder": "Panel de control por defecto", "locale-label": "Idioma", "locale-placeholder": "Cambiar idioma", + "theme-description": "", "theme-label": "Tema de interfaz de usuario", "week-start-label": "Inicio de la semana" }, @@ -3216,6 +3489,13 @@ "url-column-header": "URL de la instantánea", "view-button": "Vista" }, + "table": { + "container": { + "content": "", + "show-all-series": "", + "show-only-series": "" + } + }, "tag-filter": { "clear-button": "", "loading": "Cargando...", @@ -3309,20 +3589,26 @@ "start-your-metrics-exploration": "", "subtitle": "" }, - "metric-overview": { - "description-label": "", - "labels": "", - "metric-attributes": "", - "no-description": "", - "type-label": "", - "unit-label": "", - "unknown-type": "" - }, "metric-select": { "filter-by": "", + "native-histogram": "", "new-badge": "", "otel-switch": "" }, + "native-histogram-banner": { + "ch-heatmap": "", + "ch-histogram": "", + "click-histogram": "", + "hide-examples": "", + "learn-more": "", + "metric-examples": "", + "nh-heatmap": "", + "nh-histogram": "", + "now": "", + "previously": "", + "see-examples": "", + "sentence": "" + }, "recent-metrics": { "or-view-a-recent-exploration": "" }, diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index 2ea9883ffe1..33cfe2d4b40 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -34,12 +34,6 @@ "save-button": "" } }, - "actions-editor": { - "inline": { - "add-button": "", - "one-click-link": "" - } - }, "admin": { "anon-users": { "not-found": "" @@ -174,6 +168,24 @@ } }, "alerting": { + "alert": { + "alert-state": "", + "annotations": "", + "evaluation": "", + "evaluation-paused": "", + "evaluation-paused-description": "", + "last-evaluated": "", + "last-evaluation-duration": "", + "last-updated-at": "", + "last-updated-by": "", + "no-annotations": "", + "pending-period": "", + "rule": "", + "rule-identifier": "", + "rule-type": "", + "state-error-timeout": "", + "state-no-data": "" + }, "alert-recording-rule-form": { "evaluation-behaviour": { "description": { @@ -283,6 +295,7 @@ "contactPointFilter": { "label": "" }, + "copy-to-clipboard": "", "export": { "subtitle": { "formats": "", @@ -299,6 +312,13 @@ } } }, + "group-actions": { + "actions-trigger": "", + "delete": "", + "edit": "", + "export": "", + "reorder": "" + }, "list-view": { "empty": { "new-alert-rule": "", @@ -484,11 +504,23 @@ }, "rule-list": { "configure-datasource": "", + "ds-error-boundary": { + "description": "", + "title": "" + }, "filter-view": { "no-more-results": "", "no-rules-found": "" }, - "new-alert-rule": "" + "new-alert-rule": "", + "pagination": { + "next-page": "", + "previous-page": "" + }, + "return-button": { + "title": "" + }, + "rulerrule-loading-error": "" }, "rule-state": { "creating": "", @@ -690,7 +722,10 @@ "title": "" }, "custom-value": { - "label": "" + "description": "" + }, + "group": { + "undefined": "" }, "options": { "no-found": "" @@ -863,6 +898,82 @@ "redirect-link": "Liste dans Alertes Grafana", "subtitle": "Règles d'alerte liées à ce tableau de bord" }, + "default-layout": { + "description": "", + "item-options": { + "repeat": { + "direction": { + "horizontal": "", + "title": "", + "vertical": "" + }, + "max": "", + "title": "", + "variable": { + "description": "", + "title": "" + } + } + }, + "name": "", + "row-actions": { + "delete": "", + "modal": { + "alt-action": "", + "text": "", + "title": "" + } + }, + "row-options": { + "button": { + "label": "" + }, + "form": { + "cancel": "", + "repeat-for": { + "label": "", + "learn-more": "", + "warning": { + "text": "" + } + }, + "title": "", + "update": "" + }, + "modal": { + "title": "" + } + } + }, + "edit-pane": { + "objects": { + "multi-select": { + "selection-number": "" + } + }, + "open": "", + "panels": { + "multi-select": { + "selection-number": "" + } + }, + "row": { + "header": { + "hide": "", + "title": "" + }, + "multi-select": { + "options-header": "", + "selection-number": "" + } + }, + "tab": { + "multi-select": { + "options-header": "", + "selection-number": "" + } + } + }, "empty": { "add-library-panel-body": "Ajoutez des visualisations partagées avec d'autres tableaux de bord.", "add-library-panel-button": "Ajouter un panneau Bibliothèque", @@ -874,6 +985,9 @@ "import-a-dashboard-header": "Importer un tableau de bord", "import-dashboard-button": "Importer un tableau de bord" }, + "errors": { + "failed-to-load": "" + }, "inspect": { "data-tab": "Données", "error-tab": "Erreur", @@ -926,19 +1040,130 @@ "rows": "Nombre total de lignes", "table-title": "Statistiques" }, + "options": { + "description": "", + "title": "", + "title-option": "" + }, + "panel-edit": { + "alerting-tab": { + "dashboard-not-saved": "", + "no-rules": "" + } + }, + "responsive-layout": { + "description": "", + "item-options": { + "hide-no-data": "", + "title": "" + }, + "name": "", + "options": { + "columns": "", + "fixed": "", + "min": "", + "one-column": "", + "rows": "", + "three-columns": "", + "two-columns": "" + } + }, + "rows-layout": { + "description": "", + "name": "", + "row": { + "collapse": "", + "expand": "", + "new": "", + "repeat": { + "learn-more": "", + "warning": "" + } + }, + "row-options": { + "height": { + "expand": "", + "hide-row-header": "", + "min": "", + "title": "" + }, + "repeat": { + "title": "", + "variable": { + "title": "" + } + }, + "title": "", + "title-option": "" + } + }, + "tabs-layout": { + "description": "", + "name": "", + "tab": { + "new": "" + }, + "tab-options": { + "title": "", + "title-option": "" + } + }, "toolbar": { "add": "Ajouter", + "add-panel": "", + "add-panel-lib": "", + "add-row": "", + "add-tab": "", "alert-rules": "Règles d'alerte", + "back-to-dashboard": "", + "dashboard-settings": { + "label": "", + "tooltip": "" + }, + "discard-library-panel-changes": "", + "discard-panel": "", + "discard-panel-new": "", + "edit": { + "label": "", + "tooltip": "" + }, + "edit-dashboard-v2-schema": "", + "enter-edit-mode": { + "label": "", + "tooltip": "" + }, + "exit-edit-mode": { + "label": "", + "tooltip": "" + }, "mark-favorite": "Marquer comme favori", + "more-save-options": "", "open-original": "Ouvrir le tableau de bord d'origine", "playlist-next": "Accéder au tableau de bord suivant", "playlist-previous": "Accéder au tableau de bord précédent", "playlist-stop": "Arrêter la liste de lecture", + "public-dashboard": "", "refresh": "Actualiser le tableau de bord", "save": "Enregistrer le tableau de bord", + "save-dashboard": { + "label": "", + "tooltip": "" + }, + "save-dashboard-copy": { + "label": "", + "tooltip": "" + }, + "save-dashboard-short": "", + "save-library-panel": "", "settings": "Paramètres du tableau de bord", - "share": "Partager le tableau de bord ou le panneau", + "share": { + "label": "", + "tooltip": "" + }, "share-button": "Partager", + "show-hidden-elements": "", + "switch-old-dashboard": "", + "unlink-library-panel": "", "unmark-favorite": "Supprimer des favoris" }, "validation": { @@ -946,6 +1171,14 @@ "invalid-json": "JSON non valide", "tags-expected-array": "étiquettes attendues tableau", "tags-expected-strings": "étiquettes attendues tableau de chaînes" + }, + "viz-panel": { + "options": { + "description": "", + "title": "", + "title-option": "", + "transparent-background": "" + } } }, "dashboard-import": { @@ -1048,6 +1281,12 @@ "angular-deprecation-description": "", "angular-deprecation-heading": "" }, + "panel-queries": { + "add-query-from-library": "" + }, + "query-library": { + "add-query-button": "" + }, "settings": { "variables": { "dependencies": { @@ -1125,40 +1364,11 @@ "scan-for-older-logs": "", "stop-scan": "" }, - "query-library": { - "add-edit-description": "", - "cancel": "", - "default-description": "", - "delete-query": "", - "delete-query-text": "", - "delete-query-title": "", - "private": "", - "public": "", - "query-deleted": "", - "query-template-add-error": "", - "query-template-added": "", - "query-template-edit-error": "", - "query-template-edited": "", - "save": "" - }, - "query-template-modal": { - "add-info": "", - "add-title": "", - "auto-star": "", - "data-source-name": "", - "description": "", - "edit-info": "", - "edit-title": "", - "query": "", - "visibility": "" - }, - "query-template-modall": { - "data-source-type": "" - }, "rich-history": { "close-tooltip": "Fermer l'historique des requêtes", "datasource-a-z": "Source de données A-Z", "datasource-z-a": "Source de données Z-A", + "library-history-dropdown": "", "newest-first": "Plus récent en premier", "oldest-first": "Plus ancien en premier", "query-history": "Historique des requêtes", @@ -1305,10 +1515,9 @@ "sortBy": "" }, "related-logs": { - "docsLink": "", + "LrrDocsLink": "", "openExploreLogs": "", - "relatedLogsUnavailableAfterDocsLink": "", - "relatedLogsUnavailableBeforeDocsLink": "", + "relatedLogsUnavailable": "", "warnExperimentalFeature": "" }, "viewBy": "" @@ -1357,6 +1566,24 @@ "send-custom-feedback": "" }, "grafana-ui": { + "action-editor": { + "button": { + "confirm": "", + "confirm-action": "" + }, + "inline": { + "add-action": "", + "edit-action": "" + }, + "modal": { + "action-body": "", + "action-method": "", + "action-query-params": "", + "action-title": "", + "action-title-placeholder": "", + "one-click-description": "" + } + }, "auto-save-field": { "saved": "", "saving": "" @@ -1373,11 +1600,21 @@ }, "data-link-editor-modal": { "cancel": "", + "one-click-description": "", "save": "" }, + "data-link-inline-editor": { + "one-click": "" + }, "data-links-inline-editor": { "add-link": "", - "one-click-link": "" + "edit-link": "", + "one-click": "", + "one-click-enabled": "", + "title-not-provided": "", + "tooltip-edit": "", + "tooltip-remove": "", + "url-not-provided": "" }, "data-source-http-settings": { "access-help": "", @@ -1458,7 +1695,12 @@ "right-axis-indicator": "" }, "viz-tooltip": { - "footer-add-annotation": "" + "actions-confirmation-input-placeholder": "", + "actions-confirmation-label": "", + "actions-confirmation-message": "", + "footer-add-annotation": "", + "footer-click-to-action": "", + "footer-click-to-navigate": "" } }, "graph": { @@ -1780,7 +2022,8 @@ }, "log-row-message": { "ellipsis": "", - "more": "" + "more": "", + "see-details": "" }, "log-rows": { "disable-popover": { @@ -1792,6 +2035,13 @@ "shortcut": "" } }, + "logs-navigation": { + "newer-logs": "", + "older-logs": "", + "scroll-bottom": "", + "scroll-top": "", + "start-of-range": "" + }, "popover-menu": { "copy": "", "disable-menu": "", @@ -1881,6 +2131,7 @@ "title": "" }, "migrated-counts": { + "alert_rule_groups": "", "alert_rules": "", "contact_points": "", "dashboards": "", @@ -1941,9 +2192,8 @@ "public-preview": { "button-text": "", "message": "", - "message-plugins": "", - "title": "", - "title-plugins": "" + "message-cloud": "", + "title": "" }, "resource-details": { "dismiss-button": "", @@ -1986,6 +2236,7 @@ }, "resource-type": { "alert_rule": "", + "alert_rule_group": "", "contact_point": "", "dashboard": "Tableau de bord", "datasource": "Source de données", @@ -2031,6 +2282,15 @@ "title": "Pourquoi héberger avec Grafana ?" } }, + "multicombobox": { + "all": { + "title": "", + "title-filtered": "" + }, + "clear": { + "title": "" + } + }, "nav": { "add-new-connections": { "title": "Ajouter une nouvelle connexion" @@ -2384,7 +2644,8 @@ "list-label": "Navigation", "open": "", "undock": "Ancrer le menu" - } + }, + "rss-button": "" }, "news": { "drawer": { @@ -2516,19 +2777,35 @@ } }, "details": { + "connections-tab": { + "description": "" + }, "labels": { "contactGrafanaLabs": "", + "customLinks": "", + "customLinksTooltip": "", "dependencies": "", + "documentation": "", "downloads": "", "from": "", "installedVersion": "", "lastCommitDate": "", "latestVersion": "", - "links": "", + "license": "", + "raiseAnIssue": "", "reportAbuse": "", + "reportAbuseTooltip": "", + "repository": "", "signature": "", "status": "", "updatedAt": "" + }, + "modal": { + "cancel": "", + "copyEmail": "", + "description": "", + "node": "", + "title": "" } }, "empty-state": { @@ -2743,14 +3020,6 @@ "role-label": "Rôle" } }, - "query-library": { - "datasource-names": "", - "delete-query-button": "", - "query-template-get-error": "", - "search": "", - "user-info-get-error": "", - "user-names": "" - }, "query-operation": { "header": { "collapse-row": "Réduire la ligne de requête", @@ -2760,7 +3029,6 @@ "expand-row": "Développer la ligne de requête", "hide-response": "", "remove-query": "Supprimer la requête", - "save-to-query-library": "", "show-response": "", "toggle-edit-mode": "Activer/désactiver le mode édition de texte" }, @@ -2862,6 +3130,10 @@ "title": "" }, "save-dashboards": { + "message-length": { + "info": "", + "title": "" + }, "name-exists": { "message-info": "", "message-suggestion": "", @@ -3145,6 +3417,7 @@ "home-dashboard-placeholder": "Tableau de bord par défaut", "locale-label": "Langue", "locale-placeholder": "Choisir une langue", + "theme-description": "", "theme-label": "Thème de l'interface utilisateur", "week-start-label": "Début de la semaine" }, @@ -3216,6 +3489,13 @@ "url-column-header": "URL de l'instantané", "view-button": "Afficher" }, + "table": { + "container": { + "content": "", + "show-all-series": "", + "show-only-series": "" + } + }, "tag-filter": { "clear-button": "", "loading": "Chargement en cours...", @@ -3309,20 +3589,26 @@ "start-your-metrics-exploration": "", "subtitle": "" }, - "metric-overview": { - "description-label": "", - "labels": "", - "metric-attributes": "", - "no-description": "", - "type-label": "", - "unit-label": "", - "unknown-type": "" - }, "metric-select": { "filter-by": "", + "native-histogram": "", "new-badge": "", "otel-switch": "" }, + "native-histogram-banner": { + "ch-heatmap": "", + "ch-histogram": "", + "click-histogram": "", + "hide-examples": "", + "learn-more": "", + "metric-examples": "", + "nh-heatmap": "", + "nh-histogram": "", + "now": "", + "previously": "", + "see-examples": "", + "sentence": "" + }, "recent-metrics": { "or-view-a-recent-exploration": "" }, diff --git a/public/locales/pt-BR/grafana.json b/public/locales/pt-BR/grafana.json index 5e0b7cdc2d4..4e648b574bc 100644 --- a/public/locales/pt-BR/grafana.json +++ b/public/locales/pt-BR/grafana.json @@ -34,12 +34,6 @@ "save-button": "" } }, - "actions-editor": { - "inline": { - "add-button": "", - "one-click-link": "" - } - }, "admin": { "anon-users": { "not-found": "" @@ -174,6 +168,24 @@ } }, "alerting": { + "alert": { + "alert-state": "", + "annotations": "", + "evaluation": "", + "evaluation-paused": "", + "evaluation-paused-description": "", + "last-evaluated": "", + "last-evaluation-duration": "", + "last-updated-at": "", + "last-updated-by": "", + "no-annotations": "", + "pending-period": "", + "rule": "", + "rule-identifier": "", + "rule-type": "", + "state-error-timeout": "", + "state-no-data": "" + }, "alert-recording-rule-form": { "evaluation-behaviour": { "description": { @@ -283,6 +295,7 @@ "contactPointFilter": { "label": "" }, + "copy-to-clipboard": "", "export": { "subtitle": { "formats": "", @@ -299,6 +312,13 @@ } } }, + "group-actions": { + "actions-trigger": "", + "delete": "", + "edit": "", + "export": "", + "reorder": "" + }, "list-view": { "empty": { "new-alert-rule": "", @@ -484,11 +504,23 @@ }, "rule-list": { "configure-datasource": "", + "ds-error-boundary": { + "description": "", + "title": "" + }, "filter-view": { "no-more-results": "", "no-rules-found": "" }, - "new-alert-rule": "" + "new-alert-rule": "", + "pagination": { + "next-page": "", + "previous-page": "" + }, + "return-button": { + "title": "" + }, + "rulerrule-loading-error": "" }, "rule-state": { "creating": "", @@ -690,7 +722,10 @@ "title": "" }, "custom-value": { - "label": "" + "description": "" + }, + "group": { + "undefined": "" }, "options": { "no-found": "" @@ -863,6 +898,82 @@ "redirect-link": "Lista no alerta do Grafana", "subtitle": "Regras de alerta relacionadas a este painel de controle" }, + "default-layout": { + "description": "", + "item-options": { + "repeat": { + "direction": { + "horizontal": "", + "title": "", + "vertical": "" + }, + "max": "", + "title": "", + "variable": { + "description": "", + "title": "" + } + } + }, + "name": "", + "row-actions": { + "delete": "", + "modal": { + "alt-action": "", + "text": "", + "title": "" + } + }, + "row-options": { + "button": { + "label": "" + }, + "form": { + "cancel": "", + "repeat-for": { + "label": "", + "learn-more": "", + "warning": { + "text": "" + } + }, + "title": "", + "update": "" + }, + "modal": { + "title": "" + } + } + }, + "edit-pane": { + "objects": { + "multi-select": { + "selection-number": "" + } + }, + "open": "", + "panels": { + "multi-select": { + "selection-number": "" + } + }, + "row": { + "header": { + "hide": "", + "title": "" + }, + "multi-select": { + "options-header": "", + "selection-number": "" + } + }, + "tab": { + "multi-select": { + "options-header": "", + "selection-number": "" + } + } + }, "empty": { "add-library-panel-body": "Adicione visualizações que são compartilhadas com outros painéis de controle.", "add-library-panel-button": "Adicionar painel de biblioteca", @@ -874,6 +985,9 @@ "import-a-dashboard-header": "Importar um painel de controle", "import-dashboard-button": "Importar painel de controle" }, + "errors": { + "failed-to-load": "" + }, "inspect": { "data-tab": "Dados", "error-tab": "Erro", @@ -926,19 +1040,130 @@ "rows": "Número total de linhas", "table-title": "Estatísticas" }, + "options": { + "description": "", + "title": "", + "title-option": "" + }, + "panel-edit": { + "alerting-tab": { + "dashboard-not-saved": "", + "no-rules": "" + } + }, + "responsive-layout": { + "description": "", + "item-options": { + "hide-no-data": "", + "title": "" + }, + "name": "", + "options": { + "columns": "", + "fixed": "", + "min": "", + "one-column": "", + "rows": "", + "three-columns": "", + "two-columns": "" + } + }, + "rows-layout": { + "description": "", + "name": "", + "row": { + "collapse": "", + "expand": "", + "new": "", + "repeat": { + "learn-more": "", + "warning": "" + } + }, + "row-options": { + "height": { + "expand": "", + "hide-row-header": "", + "min": "", + "title": "" + }, + "repeat": { + "title": "", + "variable": { + "title": "" + } + }, + "title": "", + "title-option": "" + } + }, + "tabs-layout": { + "description": "", + "name": "", + "tab": { + "new": "" + }, + "tab-options": { + "title": "", + "title-option": "" + } + }, "toolbar": { "add": "Adicionar", + "add-panel": "", + "add-panel-lib": "", + "add-row": "", + "add-tab": "", "alert-rules": "Regras de alerta", + "back-to-dashboard": "", + "dashboard-settings": { + "label": "", + "tooltip": "" + }, + "discard-library-panel-changes": "", + "discard-panel": "", + "discard-panel-new": "", + "edit": { + "label": "", + "tooltip": "" + }, + "edit-dashboard-v2-schema": "", + "enter-edit-mode": { + "label": "", + "tooltip": "" + }, + "exit-edit-mode": { + "label": "", + "tooltip": "" + }, "mark-favorite": "Marcar como favorito", + "more-save-options": "", "open-original": "Abrir painel de controle original", "playlist-next": "Ir para o próximo painel de controle", "playlist-previous": "Ir para o painel de controle anterior", "playlist-stop": "Parar lista de reprodução", + "public-dashboard": "", "refresh": "Atualizar painel de controle", "save": "Salvar painel de controle", + "save-dashboard": { + "label": "", + "tooltip": "" + }, + "save-dashboard-copy": { + "label": "", + "tooltip": "" + }, + "save-dashboard-short": "", + "save-library-panel": "", "settings": "Configurações do painel de controle", - "share": "Compartilhar painel de controle", + "share": { + "label": "", + "tooltip": "" + }, "share-button": "Compartilhar", + "show-hidden-elements": "", + "switch-old-dashboard": "", + "unlink-library-panel": "", "unmark-favorite": "Desmarcar como favorito" }, "validation": { @@ -946,6 +1171,14 @@ "invalid-json": "JSON inválido", "tags-expected-array": "matriz de tags esperada", "tags-expected-strings": "matriz de strings de tags esperada" + }, + "viz-panel": { + "options": { + "description": "", + "title": "", + "title-option": "", + "transparent-background": "" + } } }, "dashboard-import": { @@ -1048,6 +1281,12 @@ "angular-deprecation-description": "", "angular-deprecation-heading": "" }, + "panel-queries": { + "add-query-from-library": "" + }, + "query-library": { + "add-query-button": "" + }, "settings": { "variables": { "dependencies": { @@ -1125,40 +1364,11 @@ "scan-for-older-logs": "", "stop-scan": "" }, - "query-library": { - "add-edit-description": "", - "cancel": "", - "default-description": "", - "delete-query": "", - "delete-query-text": "", - "delete-query-title": "", - "private": "", - "public": "", - "query-deleted": "", - "query-template-add-error": "", - "query-template-added": "", - "query-template-edit-error": "", - "query-template-edited": "", - "save": "" - }, - "query-template-modal": { - "add-info": "", - "add-title": "", - "auto-star": "", - "data-source-name": "", - "description": "", - "edit-info": "", - "edit-title": "", - "query": "", - "visibility": "" - }, - "query-template-modall": { - "data-source-type": "" - }, "rich-history": { "close-tooltip": "Fechar histórico de consultas", "datasource-a-z": "Fonte de dados A-Z", "datasource-z-a": "Fonte de dados Z-A", + "library-history-dropdown": "", "newest-first": "Mais recentes primeiro", "oldest-first": "Mais antigos primeiro", "query-history": "Histórico de consultas", @@ -1305,10 +1515,9 @@ "sortBy": "" }, "related-logs": { - "docsLink": "", + "LrrDocsLink": "", "openExploreLogs": "", - "relatedLogsUnavailableAfterDocsLink": "", - "relatedLogsUnavailableBeforeDocsLink": "", + "relatedLogsUnavailable": "", "warnExperimentalFeature": "" }, "viewBy": "" @@ -1357,6 +1566,24 @@ "send-custom-feedback": "" }, "grafana-ui": { + "action-editor": { + "button": { + "confirm": "", + "confirm-action": "" + }, + "inline": { + "add-action": "", + "edit-action": "" + }, + "modal": { + "action-body": "", + "action-method": "", + "action-query-params": "", + "action-title": "", + "action-title-placeholder": "", + "one-click-description": "" + } + }, "auto-save-field": { "saved": "", "saving": "" @@ -1373,11 +1600,21 @@ }, "data-link-editor-modal": { "cancel": "", + "one-click-description": "", "save": "" }, + "data-link-inline-editor": { + "one-click": "" + }, "data-links-inline-editor": { "add-link": "", - "one-click-link": "" + "edit-link": "", + "one-click": "", + "one-click-enabled": "", + "title-not-provided": "", + "tooltip-edit": "", + "tooltip-remove": "", + "url-not-provided": "" }, "data-source-http-settings": { "access-help": "", @@ -1458,7 +1695,12 @@ "right-axis-indicator": "" }, "viz-tooltip": { - "footer-add-annotation": "" + "actions-confirmation-input-placeholder": "", + "actions-confirmation-label": "", + "actions-confirmation-message": "", + "footer-add-annotation": "", + "footer-click-to-action": "", + "footer-click-to-navigate": "" } }, "graph": { @@ -1780,7 +2022,8 @@ }, "log-row-message": { "ellipsis": "", - "more": "" + "more": "", + "see-details": "" }, "log-rows": { "disable-popover": { @@ -1792,6 +2035,13 @@ "shortcut": "" } }, + "logs-navigation": { + "newer-logs": "", + "older-logs": "", + "scroll-bottom": "", + "scroll-top": "", + "start-of-range": "" + }, "popover-menu": { "copy": "", "disable-menu": "", @@ -1881,6 +2131,7 @@ "title": "" }, "migrated-counts": { + "alert_rule_groups": "", "alert_rules": "", "contact_points": "", "dashboards": "", @@ -1941,9 +2192,8 @@ "public-preview": { "button-text": "", "message": "", - "message-plugins": "", - "title": "", - "title-plugins": "" + "message-cloud": "", + "title": "" }, "resource-details": { "dismiss-button": "", @@ -1986,6 +2236,7 @@ }, "resource-type": { "alert_rule": "", + "alert_rule_group": "", "contact_point": "", "dashboard": "Painel de controle", "datasource": "Fonte de dados", @@ -2031,6 +2282,15 @@ "title": "Por que hospedar com o Grafana?" } }, + "multicombobox": { + "all": { + "title": "", + "title-filtered": "" + }, + "clear": { + "title": "" + } + }, "nav": { "add-new-connections": { "title": "Adicionar nova conexão" @@ -2384,7 +2644,8 @@ "list-label": "Navegação", "open": "", "undock": "Desacoplar menu" - } + }, + "rss-button": "" }, "news": { "drawer": { @@ -2516,19 +2777,35 @@ } }, "details": { + "connections-tab": { + "description": "" + }, "labels": { "contactGrafanaLabs": "", + "customLinks": "", + "customLinksTooltip": "", "dependencies": "", + "documentation": "", "downloads": "", "from": "", "installedVersion": "", "lastCommitDate": "", "latestVersion": "", - "links": "", + "license": "", + "raiseAnIssue": "", "reportAbuse": "", + "reportAbuseTooltip": "", + "repository": "", "signature": "", "status": "", "updatedAt": "" + }, + "modal": { + "cancel": "", + "copyEmail": "", + "description": "", + "node": "", + "title": "" } }, "empty-state": { @@ -2743,14 +3020,6 @@ "role-label": "Função" } }, - "query-library": { - "datasource-names": "", - "delete-query-button": "", - "query-template-get-error": "", - "search": "", - "user-info-get-error": "", - "user-names": "" - }, "query-operation": { "header": { "collapse-row": "Recolher linha de consulta", @@ -2760,7 +3029,6 @@ "expand-row": "Expandir linha de consulta", "hide-response": "", "remove-query": "Remover consulta", - "save-to-query-library": "", "show-response": "", "toggle-edit-mode": "Alternar modo de edição de texto" }, @@ -2862,6 +3130,10 @@ "title": "" }, "save-dashboards": { + "message-length": { + "info": "", + "title": "" + }, "name-exists": { "message-info": "", "message-suggestion": "", @@ -3145,6 +3417,7 @@ "home-dashboard-placeholder": "Painel de controle padrão", "locale-label": "Idioma", "locale-placeholder": "Escolher idioma", + "theme-description": "", "theme-label": "Tema da interface", "week-start-label": "Início da semana" }, @@ -3216,6 +3489,13 @@ "url-column-header": "URL da captura", "view-button": "Visualizar" }, + "table": { + "container": { + "content": "", + "show-all-series": "", + "show-only-series": "" + } + }, "tag-filter": { "clear-button": "", "loading": "Carregando...", @@ -3309,20 +3589,26 @@ "start-your-metrics-exploration": "", "subtitle": "" }, - "metric-overview": { - "description-label": "", - "labels": "", - "metric-attributes": "", - "no-description": "", - "type-label": "", - "unit-label": "", - "unknown-type": "" - }, "metric-select": { "filter-by": "", + "native-histogram": "", "new-badge": "", "otel-switch": "" }, + "native-histogram-banner": { + "ch-heatmap": "", + "ch-histogram": "", + "click-histogram": "", + "hide-examples": "", + "learn-more": "", + "metric-examples": "", + "nh-heatmap": "", + "nh-histogram": "", + "now": "", + "previously": "", + "see-examples": "", + "sentence": "" + }, "recent-metrics": { "or-view-a-recent-exploration": "" }, diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index c5cba66ace2..ba17087b1e7 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -34,12 +34,6 @@ "save-button": "" } }, - "actions-editor": { - "inline": { - "add-button": "", - "one-click-link": "" - } - }, "admin": { "anon-users": { "not-found": "" @@ -174,6 +168,24 @@ } }, "alerting": { + "alert": { + "alert-state": "", + "annotations": "", + "evaluation": "", + "evaluation-paused": "", + "evaluation-paused-description": "", + "last-evaluated": "", + "last-evaluation-duration": "", + "last-updated-at": "", + "last-updated-by": "", + "no-annotations": "", + "pending-period": "", + "rule": "", + "rule-identifier": "", + "rule-type": "", + "state-error-timeout": "", + "state-no-data": "" + }, "alert-recording-rule-form": { "evaluation-behaviour": { "description": { @@ -281,6 +293,7 @@ "contactPointFilter": { "label": "" }, + "copy-to-clipboard": "", "export": { "subtitle": { "formats": "", @@ -297,6 +310,13 @@ } } }, + "group-actions": { + "actions-trigger": "", + "delete": "", + "edit": "", + "export": "", + "reorder": "" + }, "list-view": { "empty": { "new-alert-rule": "", @@ -480,11 +500,23 @@ }, "rule-list": { "configure-datasource": "", + "ds-error-boundary": { + "description": "", + "title": "" + }, "filter-view": { "no-more-results": "", "no-rules-found": "" }, - "new-alert-rule": "" + "new-alert-rule": "", + "pagination": { + "next-page": "", + "previous-page": "" + }, + "return-button": { + "title": "" + }, + "rulerrule-loading-error": "" }, "rule-state": { "creating": "", @@ -681,7 +713,10 @@ "title": "" }, "custom-value": { - "label": "" + "description": "" + }, + "group": { + "undefined": "" }, "options": { "no-found": "" @@ -854,6 +889,82 @@ "redirect-link": "Grafana Alerting 中的列表", "subtitle": "与此仪表板相关的警报规则" }, + "default-layout": { + "description": "", + "item-options": { + "repeat": { + "direction": { + "horizontal": "", + "title": "", + "vertical": "" + }, + "max": "", + "title": "", + "variable": { + "description": "", + "title": "" + } + } + }, + "name": "", + "row-actions": { + "delete": "", + "modal": { + "alt-action": "", + "text": "", + "title": "" + } + }, + "row-options": { + "button": { + "label": "" + }, + "form": { + "cancel": "", + "repeat-for": { + "label": "", + "learn-more": "", + "warning": { + "text": "" + } + }, + "title": "", + "update": "" + }, + "modal": { + "title": "" + } + } + }, + "edit-pane": { + "objects": { + "multi-select": { + "selection-number": "" + } + }, + "open": "", + "panels": { + "multi-select": { + "selection-number": "" + } + }, + "row": { + "header": { + "hide": "", + "title": "" + }, + "multi-select": { + "options-header": "", + "selection-number": "" + } + }, + "tab": { + "multi-select": { + "options-header": "", + "selection-number": "" + } + } + }, "empty": { "add-library-panel-body": "添加与其他仪表板共享的可视化。", "add-library-panel-button": "添加库面板", @@ -865,6 +976,9 @@ "import-a-dashboard-header": "导入仪表板", "import-dashboard-button": "导入仪表板" }, + "errors": { + "failed-to-load": "" + }, "inspect": { "data-tab": "数据", "error-tab": "错误", @@ -917,19 +1031,130 @@ "rows": "总行数", "table-title": "统计信息" }, + "options": { + "description": "", + "title": "", + "title-option": "" + }, + "panel-edit": { + "alerting-tab": { + "dashboard-not-saved": "", + "no-rules": "" + } + }, + "responsive-layout": { + "description": "", + "item-options": { + "hide-no-data": "", + "title": "" + }, + "name": "", + "options": { + "columns": "", + "fixed": "", + "min": "", + "one-column": "", + "rows": "", + "three-columns": "", + "two-columns": "" + } + }, + "rows-layout": { + "description": "", + "name": "", + "row": { + "collapse": "", + "expand": "", + "new": "", + "repeat": { + "learn-more": "", + "warning": "" + } + }, + "row-options": { + "height": { + "expand": "", + "hide-row-header": "", + "min": "", + "title": "" + }, + "repeat": { + "title": "", + "variable": { + "title": "" + } + }, + "title": "", + "title-option": "" + } + }, + "tabs-layout": { + "description": "", + "name": "", + "tab": { + "new": "" + }, + "tab-options": { + "title": "", + "title-option": "" + } + }, "toolbar": { "add": "添加", + "add-panel": "", + "add-panel-lib": "", + "add-row": "", + "add-tab": "", "alert-rules": "警报规则", + "back-to-dashboard": "", + "dashboard-settings": { + "label": "", + "tooltip": "" + }, + "discard-library-panel-changes": "", + "discard-panel": "", + "discard-panel-new": "", + "edit": { + "label": "", + "tooltip": "" + }, + "edit-dashboard-v2-schema": "", + "enter-edit-mode": { + "label": "", + "tooltip": "" + }, + "exit-edit-mode": { + "label": "", + "tooltip": "" + }, "mark-favorite": "标记为收藏", + "more-save-options": "", "open-original": "打开原始仪表板", "playlist-next": "前往下一个仪表板", "playlist-previous": "前往上一个仪表板", "playlist-stop": "停止播放列表", + "public-dashboard": "", "refresh": "刷新仪表板", "save": "保存仪表板", + "save-dashboard": { + "label": "", + "tooltip": "" + }, + "save-dashboard-copy": { + "label": "", + "tooltip": "" + }, + "save-dashboard-short": "", + "save-library-panel": "", "settings": "仪表板设置", - "share": "分享仪表板或面板", + "share": { + "label": "", + "tooltip": "" + }, "share-button": "分享", + "show-hidden-elements": "", + "switch-old-dashboard": "", + "unlink-library-panel": "", "unmark-favorite": "取消标记为收藏" }, "validation": { @@ -937,6 +1162,14 @@ "invalid-json": "无有效的 JSON", "tags-expected-array": "标签预期数组", "tags-expected-strings": "标签预期字符串数组" + }, + "viz-panel": { + "options": { + "description": "", + "title": "", + "title-option": "", + "transparent-background": "" + } } }, "dashboard-import": { @@ -1039,6 +1272,12 @@ "angular-deprecation-description": "", "angular-deprecation-heading": "" }, + "panel-queries": { + "add-query-from-library": "" + }, + "query-library": { + "add-query-button": "" + }, "settings": { "variables": { "dependencies": { @@ -1116,40 +1355,11 @@ "scan-for-older-logs": "", "stop-scan": "" }, - "query-library": { - "add-edit-description": "", - "cancel": "", - "default-description": "", - "delete-query": "", - "delete-query-text": "", - "delete-query-title": "", - "private": "", - "public": "", - "query-deleted": "", - "query-template-add-error": "", - "query-template-added": "", - "query-template-edit-error": "", - "query-template-edited": "", - "save": "" - }, - "query-template-modal": { - "add-info": "", - "add-title": "", - "auto-star": "", - "data-source-name": "", - "description": "", - "edit-info": "", - "edit-title": "", - "query": "", - "visibility": "" - }, - "query-template-modall": { - "data-source-type": "" - }, "rich-history": { "close-tooltip": "关闭查询历史记录", "datasource-a-z": "数据源 A-Z", "datasource-z-a": "数据源 Z-A", + "library-history-dropdown": "", "newest-first": "最新在前", "oldest-first": "最早在前", "query-history": "查询历史记录", @@ -1296,10 +1506,9 @@ "sortBy": "" }, "related-logs": { - "docsLink": "", + "LrrDocsLink": "", "openExploreLogs": "", - "relatedLogsUnavailableAfterDocsLink": "", - "relatedLogsUnavailableBeforeDocsLink": "", + "relatedLogsUnavailable": "", "warnExperimentalFeature": "" }, "viewBy": "" @@ -1348,6 +1557,24 @@ "send-custom-feedback": "" }, "grafana-ui": { + "action-editor": { + "button": { + "confirm": "", + "confirm-action": "" + }, + "inline": { + "add-action": "", + "edit-action": "" + }, + "modal": { + "action-body": "", + "action-method": "", + "action-query-params": "", + "action-title": "", + "action-title-placeholder": "", + "one-click-description": "" + } + }, "auto-save-field": { "saved": "", "saving": "" @@ -1364,11 +1591,21 @@ }, "data-link-editor-modal": { "cancel": "", + "one-click-description": "", "save": "" }, + "data-link-inline-editor": { + "one-click": "" + }, "data-links-inline-editor": { "add-link": "", - "one-click-link": "" + "edit-link": "", + "one-click": "", + "one-click-enabled": "", + "title-not-provided": "", + "tooltip-edit": "", + "tooltip-remove": "", + "url-not-provided": "" }, "data-source-http-settings": { "access-help": "", @@ -1449,7 +1686,12 @@ "right-axis-indicator": "" }, "viz-tooltip": { - "footer-add-annotation": "" + "actions-confirmation-input-placeholder": "", + "actions-confirmation-label": "", + "actions-confirmation-message": "", + "footer-add-annotation": "", + "footer-click-to-action": "", + "footer-click-to-navigate": "" } }, "graph": { @@ -1770,7 +2012,8 @@ }, "log-row-message": { "ellipsis": "", - "more": "" + "more": "", + "see-details": "" }, "log-rows": { "disable-popover": { @@ -1782,6 +2025,13 @@ "shortcut": "" } }, + "logs-navigation": { + "newer-logs": "", + "older-logs": "", + "scroll-bottom": "", + "scroll-top": "", + "start-of-range": "" + }, "popover-menu": { "copy": "", "disable-menu": "", @@ -1871,6 +2121,7 @@ "title": "" }, "migrated-counts": { + "alert_rule_groups": "", "alert_rules": "", "contact_points": "", "dashboards": "", @@ -1931,9 +2182,8 @@ "public-preview": { "button-text": "", "message": "", - "message-plugins": "", - "title": "", - "title-plugins": "" + "message-cloud": "", + "title": "" }, "resource-details": { "dismiss-button": "", @@ -1976,6 +2226,7 @@ }, "resource-type": { "alert_rule": "", + "alert_rule_group": "", "contact_point": "", "dashboard": "仪表板", "datasource": "数据源", @@ -2021,6 +2272,15 @@ "title": "为什么使用 Grafana 托管?" } }, + "multicombobox": { + "all": { + "title": "", + "title-filtered": "" + }, + "clear": { + "title": "" + } + }, "nav": { "add-new-connections": { "title": "添加新连接" @@ -2374,7 +2634,8 @@ "list-label": "导航", "open": "", "undock": "取消停靠菜单" - } + }, + "rss-button": "" }, "news": { "drawer": { @@ -2506,19 +2767,35 @@ } }, "details": { + "connections-tab": { + "description": "" + }, "labels": { "contactGrafanaLabs": "", + "customLinks": "", + "customLinksTooltip": "", "dependencies": "", + "documentation": "", "downloads": "", "from": "", "installedVersion": "", "lastCommitDate": "", "latestVersion": "", - "links": "", + "license": "", + "raiseAnIssue": "", "reportAbuse": "", + "reportAbuseTooltip": "", + "repository": "", "signature": "", "status": "", "updatedAt": "" + }, + "modal": { + "cancel": "", + "copyEmail": "", + "description": "", + "node": "", + "title": "" } }, "empty-state": { @@ -2732,14 +3009,6 @@ "role-label": "角色" } }, - "query-library": { - "datasource-names": "", - "delete-query-button": "", - "query-template-get-error": "", - "search": "", - "user-info-get-error": "", - "user-names": "" - }, "query-operation": { "header": { "collapse-row": "折叠查询行", @@ -2749,7 +3018,6 @@ "expand-row": "展开查询行", "hide-response": "", "remove-query": "删除查询", - "save-to-query-library": "", "show-response": "", "toggle-edit-mode": "切换文本编辑模式" }, @@ -2848,6 +3116,10 @@ "title": "" }, "save-dashboards": { + "message-length": { + "info": "", + "title": "" + }, "name-exists": { "message-info": "", "message-suggestion": "", @@ -3131,6 +3403,7 @@ "home-dashboard-placeholder": "默认仪表板", "locale-label": "语言", "locale-placeholder": "选择语言", + "theme-description": "", "theme-label": "UI 主题", "week-start-label": "每周开始日" }, @@ -3202,6 +3475,13 @@ "url-column-header": "快照网址", "view-button": "查看" }, + "table": { + "container": { + "content": "", + "show-all-series": "", + "show-only-series": "" + } + }, "tag-filter": { "clear-button": "", "loading": "加载中...", @@ -3295,20 +3575,26 @@ "start-your-metrics-exploration": "", "subtitle": "" }, - "metric-overview": { - "description-label": "", - "labels": "", - "metric-attributes": "", - "no-description": "", - "type-label": "", - "unit-label": "", - "unknown-type": "" - }, "metric-select": { "filter-by": "", + "native-histogram": "", "new-badge": "", "otel-switch": "" }, + "native-histogram-banner": { + "ch-heatmap": "", + "ch-histogram": "", + "click-histogram": "", + "hide-examples": "", + "learn-more": "", + "metric-examples": "", + "nh-heatmap": "", + "nh-histogram": "", + "now": "", + "previously": "", + "see-examples": "", + "sentence": "" + }, "recent-metrics": { "or-view-a-recent-exploration": "" },