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
This commit is contained in:
Will Assis
2025-02-13 17:56:29 -03:00
committed by GitHub
parent e2081c3e0c
commit c963032915
9 changed files with 409 additions and 80 deletions

View File

@ -209,6 +209,8 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resource.Resour
}) })
} }
list.TotalHits = int64(len(list.Results.Rows))
return list, nil return list, nil
} }

View File

@ -54,6 +54,7 @@ func TestDashboardSearchClient_Search(t *testing.T) {
require.NotNil(t, resp) require.NotNil(t, resp)
searchFields := resource.StandardSearchFields() searchFields := resource.StandardSearchFields()
require.Equal(t, &resource.ResourceSearchResponse{ require.Equal(t, &resource.ResourceSearchResponse{
TotalHits: 2,
Results: &resource.ResourceTable{ Results: &resource.ResourceTable{
Columns: []*resource.ResourceTableColumnDefinition{ Columns: []*resource.ResourceTableColumnDefinition{
searchFields.Field(resource.SEARCH_FIELD_TITLE), searchFields.Field(resource.SEARCH_FIELD_TITLE),

View File

@ -3,6 +3,7 @@ package dashboard
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"net/url" "net/url"
"slices" "slices"
@ -25,7 +26,10 @@ import (
folderv0alpha1 "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" folderv0alpha1 "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
"github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/apiserver/builder" "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" 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/setting"
"github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/grafana/grafana/pkg/util/errhttp" "github.com/grafana/grafana/pkg/util/errhttp"
@ -33,17 +37,19 @@ import (
// The DTO returns everything the UI needs in a single request // The DTO returns everything the UI needs in a single request
type SearchHandler struct { type SearchHandler struct {
log log.Logger log log.Logger
client func(context.Context) resource.ResourceIndexClient client func(context.Context) resource.ResourceIndexClient
tracer trace.Tracer 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) searchClient := resource.NewSearchClient(cfg, setting.UnifiedStorageConfigKeyDashboard, unified.GetResourceClient, legacyDashboardSearcher)
return &SearchHandler{ return &SearchHandler{
client: searchClient, client: searchClient,
log: log.New("grafana-apiserver.dashboards.search"), log: log.New("grafana-apiserver.dashboards.search"),
tracer: tracer, tracer: tracer,
features: features,
} }
} }
@ -252,19 +258,6 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) {
} }
searchRequest.Fields = fields 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"] types := queryParams["type"]
var federate *resource.ResourceKey var federate *resource.ResourceKey
switch len(types) { switch len(types) {
@ -329,7 +322,33 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) {
} }
// The names filter // 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 { if searchRequest.Options.Fields == nil {
searchRequest.Options.Fields = []*resource.Requirement{} searchRequest.Options.Fields = []*resource.Requirement{}
} }
@ -378,3 +397,108 @@ func asResourceKey(ns string, k string) (*resource.ResourceKey, error) {
return key, nil 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
}

View File

@ -12,6 +12,8 @@ import (
"github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing" "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/services/user"
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/grafana/grafana/pkg/storage/unified/resource"
@ -31,7 +33,7 @@ func TestSearchFallback(t *testing.T) {
"dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode0}, "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) searchHandler.client = resource.NewSearchClient(cfg, setting.UnifiedStorageConfigKeyDashboard, mockUnifiedCtxclient, mockLegacyClient)
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
@ -59,7 +61,7 @@ func TestSearchFallback(t *testing.T) {
"dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode1}, "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) searchHandler.client = resource.NewSearchClient(cfg, setting.UnifiedStorageConfigKeyDashboard, mockUnifiedCtxclient, mockLegacyClient)
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
@ -87,7 +89,7 @@ func TestSearchFallback(t *testing.T) {
"dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode2}, "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) searchHandler.client = resource.NewSearchClient(cfg, setting.UnifiedStorageConfigKeyDashboard, mockUnifiedCtxclient, mockLegacyClient)
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
@ -115,7 +117,7 @@ func TestSearchFallback(t *testing.T) {
"dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode3}, "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) searchHandler.client = resource.NewSearchClient(cfg, setting.UnifiedStorageConfigKeyDashboard, mockUnifiedCtxclient, mockLegacyClient)
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
@ -143,7 +145,7 @@ func TestSearchFallback(t *testing.T) {
"dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode4}, "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) searchHandler.client = resource.NewSearchClient(cfg, setting.UnifiedStorageConfigKeyDashboard, mockUnifiedCtxclient, mockLegacyClient)
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
@ -171,7 +173,7 @@ func TestSearchFallback(t *testing.T) {
"dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode5}, "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) searchHandler.client = resource.NewSearchClient(cfg, setting.UnifiedStorageConfigKeyDashboard, mockUnifiedCtxclient, mockLegacyClient)
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
@ -191,17 +193,19 @@ func TestSearchFallback(t *testing.T) {
} }
func TestSearchHandler(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) { 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() rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/search?field=field1&field=field2&field=field3", nil) req := httptest.NewRequest("GET", "/search?field=field1&field=field2&field=field3", nil)
req.Header.Add("content-type", "application/json") 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) { 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() rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/search?field=field1", nil) req := httptest.NewRequest("GET", "/search?field=field1", nil)
req.Header.Add("content-type", "application/json") 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) { 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() rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/search", nil) req := httptest.NewRequest("GET", "/search", nil)
req.Header.Add("content-type", "application/json") 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) { 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() rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/search", nil) req := httptest.NewRequest("GET", "/search", nil)
req.Header.Add("content-type", "application/json") 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 // MockClient implements the ResourceIndexClient interface for testing
type MockClient struct { type MockClient struct {
resource.ResourceIndexClient resource.ResourceIndexClient
@ -287,6 +469,10 @@ type MockClient struct {
// Capture the last SearchRequest for assertions // Capture the last SearchRequest for assertions
LastSearchRequest *resource.ResourceSearchRequest LastSearchRequest *resource.ResourceSearchRequest
MockResponses []*resource.ResourceSearchResponse
MockCalls []*resource.ResourceSearchRequest
CallCount int
} }
type MockResult struct { 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) { func (m *MockClient) Search(ctx context.Context, in *resource.ResourceSearchRequest, opts ...grpc.CallOption) (*resource.ResourceSearchResponse, error) {
m.LastSearchRequest = in m.LastSearchRequest = in
m.MockCalls = append(m.MockCalls, in)
rows := make([]*resource.ResourceTableRow, len(mockResults)) var response *resource.ResourceSearchResponse
for i, r := range mockResults { if m.CallCount < len(m.MockResponses) {
rows[i] = &resource.ResourceTableRow{ response = m.MockResponses[m.CallCount]
Key: &resource.ResourceKey{
Name: r.Name,
Resource: r.Resource,
},
Cells: [][]byte{
[]byte(r.Value),
},
}
} }
return &resource.ResourceSearchResponse{ m.CallCount = m.CallCount + 1
Results: &resource.ResourceTable{
Columns: []*resource.ResourceTableColumnDefinition{ return response, nil
{Name: resource.SEARCH_FIELD_TITLE},
},
Rows: rows,
},
}, nil
} }
func (m *MockClient) GetStats(ctx context.Context, in *resource.ResourceStatsRequest, opts ...grpc.CallOption) (*resource.ResourceStatsResponse, error) { func (m *MockClient) GetStats(ctx context.Context, in *resource.ResourceStatsRequest, opts ...grpc.CallOption) (*resource.ResourceStatsResponse, error) {
return nil, nil return nil, nil

View File

@ -82,7 +82,7 @@ func RegisterAPIService(cfg *setting.Cfg, features featuremgmt.FeatureToggles,
features: features, features: features,
accessControl: accessControl, accessControl: accessControl,
unified: unified, unified: unified,
search: dashboard.NewSearchHandler(tracing, cfg, legacyDashboardSearcher), search: dashboard.NewSearchHandler(tracing, cfg, legacyDashboardSearcher, features),
legacy: &dashboard.DashboardStorage{ legacy: &dashboard.DashboardStorage{
Resource: dashboardv0alpha1.DashboardResourceInfo, Resource: dashboardv0alpha1.DashboardResourceInfo,

View File

@ -194,17 +194,34 @@ export const generateColumns = (
if (!info && p === 'general') { if (!info && p === 'general') {
info = { kind: 'folder', url: '/dashboards', name: 'Dashboards' }; info = { kind: 'folder', url: '/dashboards', name: 'Dashboards' };
} }
return info ? (
<a key={p} href={info.url} className={styles.locationItem}>
<Icon name={getIconForKind(info.kind)} />
<Text variant="body" truncate> if (info) {
{info.name} const content = (
</Text> <>
</a> <Icon name={getIconForKind(info.kind)} />
) : (
<span key={p}>{p}</span> <Text variant="body" truncate>
); {info.name}
</Text>
</>
);
if (info.url) {
return (
<a key={p} href={info.url} className={styles.locationItem}>
{content}
</a>
);
}
return (
<div key={p} className={styles.locationItem}>
{content}
</div>
);
}
return <span key={p}>{p}</span>;
})} })}
</div> </div>
)} )}

View File

@ -115,9 +115,6 @@ describe('Unified Storage Searcher', () => {
.mockResolvedValueOnce(mockResults) .mockResolvedValueOnce(mockResults)
.mockResolvedValueOnce(mockFolders); .mockResolvedValueOnce(mockFolders);
const consoleWarn = jest.fn();
jest.spyOn(console, 'warn').mockImplementationOnce(consoleWarn);
const query: SearchQuery = { const query: SearchQuery = {
query: 'test', query: 'test',
limit: 50, limit: 50,
@ -127,14 +124,15 @@ describe('Unified Storage Searcher', () => {
const response = await searcher.search(query); const response = await searcher.search(query);
expect(response.view.length).toBe(1); expect(response.view.length).toBe(2);
expect(response.view.get(0).title).toBe('DB 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 df = response.view.dataFrame;
const locationInfo = df.meta?.custom?.locationInfo; const locationInfo = df.meta?.custom?.locationInfo;
expect(locationInfo).toBeDefined(); expect(locationInfo).toBeDefined();
expect(locationInfo?.folder2.name).toBe('Folder 2'); expect(locationInfo?.folder2.name).toBe('Folder 2');
expect(consoleWarn).toHaveBeenCalled();
expect(mockSearcher.search).toHaveBeenCalledTimes(3); expect(mockSearcher.search).toHaveBeenCalledTimes(3);
}); });

View File

@ -204,15 +204,19 @@ export class UnifiedSearcher implements GrafanaSearcher {
if (!hasMissing) { if (!hasMissing) {
return rsp; 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 locationInfo = await this.locationInfo;
const hits = rsp.hits.filter((hit) => { const hits = rsp.hits.map((hit) => {
if (hit.folder === undefined || locationInfo[hit.folder] !== undefined) { if (hit.folder === undefined) {
return true; 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); const totalHits = rsp.totalHits - (rsp.hits.length - hits.length);
return { ...rsp, hits, totalHits }; return { ...rsp, hits, totalHits };
@ -370,6 +374,11 @@ async function loadLocationInfo(): Promise<Record<string, LocationInfo>> {
name: 'Dashboards', name: 'Dashboards',
url: '/dashboards', url: '/dashboards',
}, // share location info with everyone }, // share location info with everyone
sharedwithme: {
kind: 'sharedwithme',
name: 'Shared with me',
url: '',
},
}; };
for (const hit of rsp.hits) { for (const hit of rsp.hits) {
locationInfo[hit.name] = { locationInfo[hit.name] = {

View File

@ -49,6 +49,10 @@ export function getIconForKind(kind: string, isOpen?: boolean): IconName {
return isOpen ? 'folder-open' : 'folder'; return isOpen ? 'folder-open' : 'folder';
} }
if (kind === 'sharedwithme') {
return 'users-alt';
}
return 'question-circle'; return 'question-circle';
} }