package legacysearcher import ( "context" "encoding/json" "fmt" "strconv" "strings" "google.golang.org/grpc" "k8s.io/apimachinery/pkg/selection" claims "github.com/grafana/authlib/types" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/apimachinery/utils" dashboard "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1" folderv0alpha1 "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess" "github.com/grafana/grafana/pkg/services/search/sort" "github.com/grafana/grafana/pkg/services/sqlstore/searchstore" "github.com/grafana/grafana/pkg/storage/unified/resource" unisearch "github.com/grafana/grafana/pkg/storage/unified/search" ) type DashboardSearchClient struct { resource.ResourceIndexClient dashboardStore dashboards.Store sorter sort.Service } func NewDashboardSearchClient(dashboardStore dashboards.Store, sorter sort.Service) *DashboardSearchClient { return &DashboardSearchClient{dashboardStore: dashboardStore, sorter: sorter} } var sortByMapping = map[string]string{ unisearch.DASHBOARD_VIEWS_LAST_30_DAYS: "viewed-recently-", unisearch.DASHBOARD_VIEWS_TOTAL: "viewed-", unisearch.DASHBOARD_ERRORS_LAST_30_DAYS: "errors-recently-", unisearch.DASHBOARD_ERRORS_TOTAL: "errors-", "title": "alpha-", } // 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 { return nil, err } // the "*"s will be added in the k8s handler in dashboard_service.go in order to make search work // in modes 3+. These "*"s will break the legacy sql query so we need to remove them here if strings.Contains(req.Query, "*") { req.Query = strings.ReplaceAll(req.Query, "*", "") } query := &dashboards.FindPersistedDashboardsQuery{ Title: req.Query, Limit: req.Limit, Page: req.Page, SignedInUser: user, IsDeleted: req.IsDeleted, } if req.Permission == int64(dashboardaccess.PERMISSION_EDIT) { query.Permission = dashboardaccess.PERMISSION_EDIT } var queryType string if req.Options.Key.Resource == dashboard.DASHBOARD_RESOURCE { queryType = searchstore.TypeDashboard } else if req.Options.Key.Resource == folderv0alpha1.RESOURCE { queryType = searchstore.TypeFolder } else { return nil, fmt.Errorf("bad type request") } if len(req.Federated) > 1 { return nil, fmt.Errorf("bad type request") } if len(req.Federated) == 1 && ((req.Federated[0].Resource == dashboard.DASHBOARD_RESOURCE && queryType == searchstore.TypeFolder) || (req.Federated[0].Resource == folderv0alpha1.RESOURCE && queryType == searchstore.TypeDashboard)) { queryType = "" // makes the legacy store search across both } if queryType != "" { query.Type = queryType } sortByField := "" if len(req.SortBy) != 0 { if len(req.SortBy) > 1 { return nil, fmt.Errorf("only one sort field is supported") } sort := req.SortBy[0] sortByField = strings.TrimPrefix(sort.Field, resource.SEARCH_FIELD_PREFIX) sorterName := sortByMapping[sortByField] if sort.Desc { sorterName += "desc" } else { sorterName += "asc" } if sorter, ok := c.sorter.GetSortOption(sorterName); ok { query.Sort = sorter } } // the title search will not return any sortMeta (an int64), like // most sorting will. Without this, the title will be set to sortMeta (0) if sortByField == resource.SEARCH_FIELD_TITLE { sortByField = "" } // if searching for tags, get those instead of the dashboards or folders for facet := range req.Facet { if facet == resource.SEARCH_FIELD_TAGS { tags, err := c.dashboardStore.GetDashboardTags(ctx, &dashboards.GetDashboardTagsQuery{ OrgID: user.GetOrgID(), }) if err != nil { return nil, err } list := &resource.ResourceSearchResponse{ Results: &resource.ResourceTable{}, Facet: map[string]*resource.ResourceSearchResponse_Facet{ "tags": { Terms: []*resource.ResourceSearchResponse_TermFacet{}, }, }, } for _, tag := range tags { list.Facet["tags"].Terms = append(list.Facet["tags"].Terms, &resource.ResourceSearchResponse_TermFacet{ Term: tag.Term, Count: int64(tag.Count), }) } return list, nil } } // handle deprecated dashboardIds query param for _, field := range req.Options.Labels { if field.Key == utils.LabelKeyDeprecatedInternalID { values := field.GetValues() dashboardIds := make([]int64, len(values)) for i, id := range values { if n, err := strconv.ParseInt(id, 10, 64); err == nil { dashboardIds[i] = n } } query.DashboardIds = dashboardIds } } for _, field := range req.Options.Fields { vals := field.GetValues() switch field.Key { case resource.SEARCH_FIELD_TAGS: query.Tags = field.GetValues() case resource.SEARCH_FIELD_NAME: query.DashboardUIDs = field.GetValues() query.DashboardIds = nil case resource.SEARCH_FIELD_FOLDER: folders := make([]string, len(vals)) for i, val := range vals { if val == "" { folders[i] = "general" } else { folders[i] = val } } query.FolderUIDs = folders case resource.SEARCH_FIELD_SOURCE_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.SourcePath = vals[0] case resource.SEARCH_FIELD_MANAGER_KIND: if len(vals) != 1 { return nil, fmt.Errorf("only one manager kind supported") } query.ManagedBy = utils.ManagerKind(vals[0]) case resource.SEARCH_FIELD_MANAGER_ID: if field.Operator == string(selection.NotIn) { query.ManagerIdentityNotIn = vals continue } // only one value is supported in legacy search if len(vals) != 1 { return nil, fmt.Errorf("only one repo name is supported") } query.ManagerIdentity = vals[0] } } searchFields := resource.StandardSearchFields() list := &resource.ResourceSearchResponse{ Results: &resource.ResourceTable{ Columns: []*resource.ResourceTableColumnDefinition{ searchFields.Field(resource.SEARCH_FIELD_TITLE), searchFields.Field(resource.SEARCH_FIELD_FOLDER), searchFields.Field(resource.SEARCH_FIELD_TAGS), { Name: sortByField, Type: resource.ResourceTableColumnDefinition_INT64, }, }, }, } // 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.ManagerIdentity != "" || len(query.ManagerIdentityNotIn) > 0 { if query.ManagedBy == utils.ManagerKindUnknown { return nil, fmt.Errorf("query by manager identity also requires manager.kind parameter") } var dashes []*dashboards.Dashboard if query.ManagedBy == utils.ManagerKindPlugin { dashes, err = c.dashboardStore.GetDashboardsByPluginID(ctx, &dashboards.GetDashboardsByPluginIDQuery{ PluginID: query.ManagerIdentity, OrgID: user.GetOrgID(), }) } else if query.ManagerIdentity != "" { dashes, err = c.dashboardStore.GetProvisionedDashboardsByName(ctx, query.ManagerIdentity) } else if len(query.ManagerIdentityNotIn) > 0 { dashes, err = c.dashboardStore.GetOrphanedProvisionedDashboards(ctx, query.ManagerIdentityNotIn) } 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), {}, {}}, }) } return list, nil } res, err := c.dashboardStore.FindDashboards(ctx, query) if err != nil { return nil, err } hits := formatQueryResult(res) for _, dashboard := range hits { tags, err := json.Marshal(dashboard.Tags) if err != nil { return nil, err } list.Results.Rows = append(list.Results.Rows, &resource.ResourceTableRow{ Key: getResourceKey(dashboard, req.Options.Key.Namespace), Cells: [][]byte{[]byte(dashboard.Title), []byte(dashboard.FolderUID), tags, []byte(strconv.FormatInt(dashboard.SortMeta, 10))}, }) } list.TotalHits = int64(len(list.Results.Rows)) return list, nil } func getResourceKey(item *dashboards.DashboardSearchProjection, namespace string) *resource.ResourceKey { if item.IsFolder { return &resource.ResourceKey{ Namespace: namespace, Group: folderv0alpha1.GROUP, Resource: folderv0alpha1.RESOURCE, Name: item.UID, } } return &resource.ResourceKey{ Namespace: namespace, Group: dashboard.GROUP, Resource: dashboard.DASHBOARD_RESOURCE, Name: item.UID, } } func formatQueryResult(res []dashboards.DashboardSearchProjection) []*dashboards.DashboardSearchProjection { hitList := make([]*dashboards.DashboardSearchProjection, 0) hits := make(map[string]*dashboards.DashboardSearchProjection) for _, item := range res { key := fmt.Sprintf("%s-%d", item.UID, item.OrgID) hit, exists := hits[key] if !exists { hit = &dashboards.DashboardSearchProjection{ UID: item.UID, Title: item.Title, FolderUID: item.FolderUID, Tags: []string{}, IsFolder: item.IsFolder, SortMeta: item.SortMeta, } hitList = append(hitList, hit) hits[key] = hit } if len(item.Term) > 0 { hit.Tags = append(hit.Tags, item.Term) } } return hitList } func (c *DashboardSearchClient) GetStats(ctx context.Context, req *resource.ResourceStatsRequest, opts ...grpc.CallOption) (*resource.ResourceStatsResponse, error) { info, err := claims.ParseNamespace(req.Namespace) if err != nil { return nil, fmt.Errorf("unable to read namespace") } if info.OrgID == 0 { return nil, fmt.Errorf("invalid OrgID found in namespace") } if len(req.Kinds) != 1 { return nil, fmt.Errorf("only can query for dashboard kind in legacy fallback") } parts := strings.SplitN(req.Kinds[0], "/", 2) if len(parts) != 2 { return nil, fmt.Errorf("invalid kind") } count, err := c.dashboardStore.CountInOrg(ctx, info.OrgID) if err != nil { return nil, err } return &resource.ResourceStatsResponse{ Stats: []*resource.ResourceStatsResponse_Stats{ { Group: parts[0], Resource: parts[1], Count: count, }, }, }, nil }