From d46e3d0e5ac55545ba403f03a4f75fb4a0bbd42c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Philippe=20Qu=C3=A9m=C3=A9ner?= Date: Mon, 29 Sep 2025 16:13:43 +0200 Subject: [PATCH] fix(dashboards): allow multiple types in search query (#111740) --- pkg/registry/apis/dashboard/search.go | 20 ++- pkg/tests/apis/dashboard/dashboards_test.go | 178 ++++++++++++++++++++ 2 files changed, 189 insertions(+), 9 deletions(-) diff --git a/pkg/registry/apis/dashboard/search.go b/pkg/registry/apis/dashboard/search.go index e209a3c8786..5b0e93a0444 100644 --- a/pkg/registry/apis/dashboard/search.go +++ b/pkg/registry/apis/dashboard/search.go @@ -302,20 +302,22 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) { } searchRequest.Fields = fields - // Search dashboards or folders (or both) - switch queryParams.Get("type") { - case "folder": - searchRequest.Options.Key, err = asResourceKey(user.GetNamespace(), folders.RESOURCE) - case "dashboard": - searchRequest.Options.Key, err = asResourceKey(user.GetNamespace(), dashboardv0alpha1.DASHBOARD_RESOURCE) - default: + // A search request can include multiple types, we need to acces the slice directly. + types := queryParams["type"] + hasDash := len(types) == 0 || slices.Contains(types, "dashboard") + hasFolder := len(types) == 0 || slices.Contains(types, "folder") + // If both types are present, we need to search both dashboards and folders, by default is nothing is set we also search both. + if (hasDash && hasFolder) || (!hasDash && !hasFolder) { searchRequest.Options.Key, err = asResourceKey(user.GetNamespace(), dashboardv0alpha1.DASHBOARD_RESOURCE) if err == nil { - federate, _ := asResourceKey(user.GetNamespace(), folders.RESOURCE) - if federate != nil { + if federate, _ := asResourceKey(user.GetNamespace(), folders.RESOURCE); federate != nil { searchRequest.Federated = []*resourcepb.ResourceKey{federate} } } + } else if hasFolder { + searchRequest.Options.Key, err = asResourceKey(user.GetNamespace(), folders.RESOURCE) + } else if hasDash { + searchRequest.Options.Key, err = asResourceKey(user.GetNamespace(), dashboardv0alpha1.DASHBOARD_RESOURCE) } if err != nil { errhttp.Write(ctx, err, w) diff --git a/pkg/tests/apis/dashboard/dashboards_test.go b/pkg/tests/apis/dashboard/dashboards_test.go index f0567852f48..930356e18c1 100644 --- a/pkg/tests/apis/dashboard/dashboards_test.go +++ b/pkg/tests/apis/dashboard/dashboards_test.go @@ -2,7 +2,9 @@ package dashboards import ( "context" + "encoding/json" "fmt" + "net/http" "strings" "testing" @@ -11,11 +13,13 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/dynamic" + k8srest "k8s.io/client-go/rest" "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/infra/slugify" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tests/apis" "github.com/grafana/grafana/pkg/tests/testinfra" @@ -312,3 +316,177 @@ func TestIntegrationLegacySupport(t *testing.T) { }, &dtos.DashboardFullWithMeta{}) require.Equal(t, 406, rsp.Response.StatusCode) // not acceptable } + +func TestIntegrationSearchTypeFiltering(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + modes := []rest.DualWriterMode{rest.Mode0, rest.Mode1, rest.Mode2, rest.Mode3, rest.Mode4, rest.Mode5} + for _, mode := range modes { + runDashboardSearchTest(t, mode) + } +} + +func runDashboardSearchTest(t *testing.T, mode rest.DualWriterMode) { + t.Run(fmt.Sprintf("search types with dual writer mode %d", mode), func(t *testing.T) { + ctx := context.Background() + + flags := []string{} + if mode >= rest.Mode3 { + flags = append(flags, featuremgmt.FlagUnifiedStorageSearch) + } + + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + AppModeProduction: true, + DisableAnonymous: true, + APIServerStorageType: "unified", + EnableFeatureToggles: flags, + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + "dashboards.dashboard.grafana.app": {DualWriterMode: mode}, + "folders.folder.grafana.app": {DualWriterMode: mode}, + }, + }) + defer helper.Shutdown() + + client := helper.GetResourceClient(apis.ResourceClientArgs{ + User: helper.Org1.Admin, + GVR: dashboardV0.DashboardResourceInfo.GroupVersionResource(), + }) + + // Create one folder via legacy API + { + cfg := dynamic.ConfigFor(helper.Org1.Admin.NewRestConfig()) + cfg.GroupVersion = &schema.GroupVersion{Group: "folder.grafana.app", Version: "v1beta1"} + restClient, err := k8srest.RESTClientFor(cfg) + require.NoError(t, err) + + var statusCode int + body := []byte(`{"uid":"sfolder","title":"Sample Folder"}`) + result := restClient.Post().AbsPath("api", "folders"). + Body(body). + SetHeader("Content-type", "application/json"). + Do(ctx). + StatusCode(&statusCode) + require.NoError(t, result.Error()) + require.Equal(t, int(http.StatusOK), statusCode) + } + + // Create one dashboard in root + { + obj := &unstructured.Unstructured{ + Object: map[string]any{ + "spec": map[string]any{ + "title": "X", + "schemaVersion": 1, + }, + }, + } + obj.SetGenerateName("x-") + obj.SetAPIVersion(dashboardV0.GroupVersion.String()) + obj.SetKind("Dashboard") + _, err := client.Resource.Create(ctx, obj, metav1.CreateOptions{}) + require.NoError(t, err) + } + + // Also create a dashboard via legacy API to ensure legacy search sees it in modes < 3 + { + cfg := dynamic.ConfigFor(helper.Org1.Admin.NewRestConfig()) + cfg.GroupVersion = &schema.GroupVersion{Group: "dashboard.grafana.app", Version: "v0alpha1"} + restClient, err := k8srest.RESTClientFor(cfg) + require.NoError(t, err) + var statusCode int + body := []byte(`{"dashboard":{"title":"Legacy X"},"overwrite":true}`) + result := restClient.Post().AbsPath("api", "dashboards", "db"). + Body(body). + SetHeader("Content-type", "application/json"). + Do(ctx). + StatusCode(&statusCode) + require.NoError(t, result.Error()) + require.Equal(t, int(http.StatusOK), statusCode) + } + + ns := helper.Org1.Admin.Identity.GetNamespace() + cfg := dynamic.ConfigFor(helper.Org1.Admin.NewRestConfig()) + cfg.GroupVersion = &schema.GroupVersion{Group: "dashboard.grafana.app", Version: "v0alpha1"} + restClient, err := k8srest.RESTClientFor(cfg) + require.NoError(t, err) + + call := func(params string) dashboardV0.SearchResults { + var statusCode int + req := restClient.Get().AbsPath("apis", "dashboard.grafana.app", "v0alpha1", "namespaces", ns, "search"). + Param("limit", "1000") + for _, kv := range strings.Split(params, "&") { + if kv == "" { + continue + } + parts := strings.SplitN(kv, "=", 2) + if len(parts) == 2 { + req = req.Param(parts[0], parts[1]) + } + } + res := req.Do(ctx).StatusCode(&statusCode) + require.NoError(t, res.Error()) + require.Equal(t, int(http.StatusOK), statusCode) + var sr dashboardV0.SearchResults + raw, err := res.Raw() + require.NoError(t, err) + require.NoError(t, json.Unmarshal(raw, &sr)) + return sr + } + + // No type => defaults to both + resAny := call("") + folders := 0 + dashboards := 0 + for _, h := range resAny.Hits { + if strings.HasPrefix(h.Resource, "folder") { + folders++ + } + if strings.HasPrefix(h.Resource, "dash") { + dashboards++ + } + } + require.GreaterOrEqual(t, dashboards, 1) + require.GreaterOrEqual(t, folders, 1) + + // Only folder + resFolder := call("type=folder") + for _, h := range resFolder.Hits { + require.True(t, strings.HasPrefix(h.Resource, "folder")) + } + + // Only dashboard + resDash := call("type=dashboard") + require.GreaterOrEqual(t, len(resDash.Hits), 1) + for _, h := range resDash.Hits { + require.True(t, strings.HasPrefix(h.Resource, "dash")) + } + + // Both via repetition + resBoth := call("type=folder&type=dashboard") + folders, dashboards = 0, 0 + for _, h := range resBoth.Hits { + if strings.HasPrefix(h.Resource, "folder") { + folders++ + } + if strings.HasPrefix(h.Resource, "dash") { + dashboards++ + } + } + require.GreaterOrEqual(t, dashboards, 1) + require.GreaterOrEqual(t, folders, 1) + + // Invalid => defaults to both + resInvalid := call("type=invalid") + folders, dashboards = 0, 0 + for _, h := range resInvalid.Hits { + if strings.HasPrefix(h.Resource, "folder") { + folders++ + } + if strings.HasPrefix(h.Resource, "dash") { + dashboards++ + } + } + require.GreaterOrEqual(t, dashboards, 1) + require.GreaterOrEqual(t, folders, 1) + }) +}