From c9630329158418f56e214a26dd0502cf51fdfeb0 Mon Sep 17 00:00:00 2001 From: Will Assis <35489495+gassiss@users.noreply.github.com> Date: Thu, 13 Feb 2025 17:56:29 -0300 Subject: [PATCH] search: handle "sharedwithme" use-case in both legacy/US modes (#100286) * handle "sharedwithme" use-case in both legacy/US modes * display "Shared with me" as location in dashboard list * fix missing "TotalHits" prop in mode 2 --- .../dashboard/legacysearcher/search_client.go | 2 + .../legacysearcher/search_client_test.go | 1 + pkg/registry/apis/dashboard/search.go | 166 ++++++++++-- pkg/registry/apis/dashboard/search_test.go | 244 +++++++++++++++--- .../apis/dashboard/v0alpha1/register.go | 2 +- .../search/page/components/columns.tsx | 39 ++- .../features/search/service/unified.test.ts | 10 +- public/app/features/search/service/unified.ts | 23 +- public/app/features/search/service/utils.ts | 4 + 9 files changed, 410 insertions(+), 81 deletions(-) diff --git a/pkg/registry/apis/dashboard/legacysearcher/search_client.go b/pkg/registry/apis/dashboard/legacysearcher/search_client.go index 50ada42a4c5..71501c3fdcd 100644 --- a/pkg/registry/apis/dashboard/legacysearcher/search_client.go +++ b/pkg/registry/apis/dashboard/legacysearcher/search_client.go @@ -209,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 index 0a2e2d4ca5b..14dc870bbd4 100644 --- a/pkg/registry/apis/dashboard/legacysearcher/search_client_test.go +++ b/pkg/registry/apis/dashboard/legacysearcher/search_client_test.go @@ -54,6 +54,7 @@ func TestDashboardSearchClient_Search(t *testing.T) { 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), 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/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/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' }; + } + + // 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' }; } - console.warn('Dropping search hit with missing folder', hit); - return false; + + 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'; }