wire up the ui to the new search api (#97866)
wire up the ui to the new search api Co-authored-by: Scott Lepper <scott.lepper@gmail.com> Co-authored-by: Ryan McKinley <ryantxu@gmail.com> Co-authored-by: Dan Cech <dcech@grafana.com>
This commit is contained in:
co-authored by
Ryan McKinley
Dan Cech
parent
86a13ea4bd
commit
a8f347144d
@@ -243,6 +243,7 @@ export interface FeatureToggles {
|
||||
alertingNotificationsStepMode?: boolean;
|
||||
useV2DashboardsAPI?: boolean;
|
||||
feedbackButton?: boolean;
|
||||
unifiedStorageSearchUI?: boolean;
|
||||
elasticsearchCrossClusterSearch?: boolean;
|
||||
unifiedHistory?: boolean;
|
||||
lokiLabelNamesQueryApi?: boolean;
|
||||
|
||||
@@ -51,26 +51,16 @@ type SortableField struct {
|
||||
Type string `json:"type,omitempty"` // string or number
|
||||
}
|
||||
|
||||
// Dashboard or folder hit
|
||||
// +enum
|
||||
type HitKind string
|
||||
|
||||
// PluginType values
|
||||
const (
|
||||
HitTypeDash HitKind = "Dashboard"
|
||||
HitTypeFolder HitKind = "Folder"
|
||||
)
|
||||
|
||||
type DashboardHit struct {
|
||||
// Dashboard or folder
|
||||
Kind HitKind `json:"kind"`
|
||||
Resource string `json:"resource"` // dashboards | folders
|
||||
// The k8s "name" (eg, grafana UID)
|
||||
Name string `json:"name"`
|
||||
// The display nam
|
||||
Title string `json:"title"`
|
||||
// Filter tags
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
// The UID/name for the folder
|
||||
// The k8s name (eg, grafana UID) for the parent folder
|
||||
Folder string `json:"folder,omitempty"`
|
||||
// Stick untyped extra fields in this object (including the sort value)
|
||||
Field *common.Unstructured `json:"field,omitempty"`
|
||||
|
||||
@@ -216,13 +216,12 @@ func schema_pkg_apis_dashboard_v0alpha1_DashboardHit(ref common.ReferenceCallbac
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"object"},
|
||||
Properties: map[string]spec.Schema{
|
||||
"kind": {
|
||||
"resource": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "Dashboard or folder\n\nPossible enum values:\n - `\"Dashboard\"`\n - `\"Folder\"`",
|
||||
Description: "Dashboard or folder",
|
||||
Default: "",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
Enum: []interface{}{"Dashboard", "Folder"},
|
||||
},
|
||||
},
|
||||
"name": {
|
||||
@@ -258,7 +257,7 @@ func schema_pkg_apis_dashboard_v0alpha1_DashboardHit(ref common.ReferenceCallbac
|
||||
},
|
||||
"folder": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "The UID/name for the folder",
|
||||
Description: "The k8s name (eg, grafana UID) for the parent folder",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
@@ -283,7 +282,7 @@ func schema_pkg_apis_dashboard_v0alpha1_DashboardHit(ref common.ReferenceCallbac
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: []string{"kind", "name", "title"},
|
||||
Required: []string{"resource", "name", "title"},
|
||||
},
|
||||
},
|
||||
Dependencies: []string{
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/kube-openapi/pkg/common"
|
||||
"k8s.io/kube-openapi/pkg/spec3"
|
||||
@@ -217,16 +218,10 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
searchRequest := &resource.ResourceSearchRequest{
|
||||
Options: &resource.ListOptions{
|
||||
Key: &resource.ResourceKey{
|
||||
Namespace: user.GetNamespace(),
|
||||
Group: dashboardv0alpha1.GROUP,
|
||||
Resource: "dashboards",
|
||||
},
|
||||
},
|
||||
Query: queryParams.Get("query"),
|
||||
Limit: int64(limit),
|
||||
Offset: int64(offset),
|
||||
Options: &resource.ListOptions{},
|
||||
Query: queryParams.Get("query"),
|
||||
Limit: int64(limit),
|
||||
Offset: int64(offset),
|
||||
Fields: []string{
|
||||
"title",
|
||||
"folder",
|
||||
@@ -244,6 +239,34 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) {
|
||||
}}
|
||||
}
|
||||
|
||||
types := queryParams["type"]
|
||||
var federate *resource.ResourceKey
|
||||
switch len(types) {
|
||||
case 0:
|
||||
// When no type specified, search for dashboards
|
||||
searchRequest.Options.Key, err = asResourceKey(user.GetNamespace(), "dashboards")
|
||||
// Currently a search query is across folders and dashboards
|
||||
if searchRequest.Query != "" {
|
||||
federate, err = asResourceKey(user.GetNamespace(), "folders")
|
||||
}
|
||||
case 1:
|
||||
searchRequest.Options.Key, err = asResourceKey(user.GetNamespace(), types[0])
|
||||
case 2:
|
||||
searchRequest.Options.Key, err = asResourceKey(user.GetNamespace(), types[0])
|
||||
if err == nil {
|
||||
federate, err = asResourceKey(user.GetNamespace(), types[1])
|
||||
}
|
||||
default:
|
||||
err = apierrors.NewBadRequest("too many type requests")
|
||||
}
|
||||
if err != nil {
|
||||
errhttp.Write(ctx, err, w)
|
||||
return
|
||||
}
|
||||
if federate != nil {
|
||||
searchRequest.Federated = []*resource.ResourceKey{federate}
|
||||
}
|
||||
|
||||
// Add sorting
|
||||
if queryParams.Has("sort") {
|
||||
for _, sort := range queryParams["sort"] {
|
||||
@@ -256,15 +279,6 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Also query folders
|
||||
if searchRequest.Query != "" {
|
||||
searchRequest.Federated = []*resource.ResourceKey{{
|
||||
Namespace: searchRequest.Options.Key.Namespace,
|
||||
Group: "folder.grafana.app",
|
||||
Resource: "folders",
|
||||
}}
|
||||
}
|
||||
|
||||
// The facet term fields
|
||||
facets, ok := queryParams["facet"]
|
||||
if ok {
|
||||
@@ -277,6 +291,16 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// The tags filter
|
||||
tags, ok := queryParams["tag"]
|
||||
if ok {
|
||||
searchRequest.Options.Fields = []*resource.Requirement{{
|
||||
Key: "tags",
|
||||
Operator: "=",
|
||||
Values: tags,
|
||||
}}
|
||||
}
|
||||
|
||||
// Run the query
|
||||
result, err := s.client.Search(ctx, searchRequest)
|
||||
if err != nil {
|
||||
@@ -293,10 +317,10 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
for i, row := range result.Results.Rows {
|
||||
hit := &dashboardv0alpha1.DashboardHit{
|
||||
Kind: dashboardv0alpha1.HitTypeDash,
|
||||
Name: row.Key.Name,
|
||||
Title: string(row.Cells[0]),
|
||||
Folder: string(row.Cells[1]),
|
||||
Resource: row.Key.Resource, // folders | dashboards
|
||||
Name: row.Key.Name, // The Grafana UID
|
||||
Title: string(row.Cells[0]),
|
||||
Folder: string(row.Cells[1]),
|
||||
}
|
||||
if row.Cells[2] != nil {
|
||||
_ = json.Unmarshal(row.Cells[2], &hit.Tags)
|
||||
@@ -330,3 +354,33 @@ func (s *SearchHandler) write(w http.ResponseWriter, obj any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(obj)
|
||||
}
|
||||
|
||||
// Given a namespace and type convert it to a search key
|
||||
func asResourceKey(ns string, k string) (*resource.ResourceKey, error) {
|
||||
if ns == "" {
|
||||
return nil, apierrors.NewBadRequest("missing namespace")
|
||||
}
|
||||
switch k {
|
||||
case "folders", "folder":
|
||||
return &resource.ResourceKey{
|
||||
Namespace: ns,
|
||||
Group: "folder.grafana.app",
|
||||
Resource: "folders",
|
||||
}, nil
|
||||
case "dashboards", "dashboard":
|
||||
return &resource.ResourceKey{
|
||||
Namespace: ns,
|
||||
Group: dashboardv0alpha1.GROUP,
|
||||
Resource: "dashboards",
|
||||
}, nil
|
||||
|
||||
// NOT really supported in the dashboard search UI, but useful for manual testing
|
||||
case "playlist", "playlists":
|
||||
return &resource.ResourceKey{
|
||||
Namespace: ns,
|
||||
Group: "playlist.grafana.app",
|
||||
Resource: "playlists",
|
||||
}, nil
|
||||
}
|
||||
return nil, apierrors.NewBadRequest("unknown resource type")
|
||||
}
|
||||
|
||||
@@ -1684,6 +1684,14 @@ var (
|
||||
Owner: grafanaOperatorExperienceSquad,
|
||||
HideFromDocs: true,
|
||||
},
|
||||
{
|
||||
Name: "unifiedStorageSearchUI",
|
||||
Description: "Enable unified storage search UI",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaSearchAndStorageSquad,
|
||||
HideFromDocs: true,
|
||||
HideFromAdminPage: true,
|
||||
},
|
||||
{
|
||||
Name: "elasticsearchCrossClusterSearch",
|
||||
Description: "Enables cross cluster search in the Elasticsearch datasource",
|
||||
|
||||
@@ -224,6 +224,7 @@ azureMonitorEnableUserAuth,GA,@grafana/partner-datasources,false,false,false
|
||||
alertingNotificationsStepMode,experimental,@grafana/alerting-squad,false,false,true
|
||||
useV2DashboardsAPI,experimental,@grafana/dashboards-squad,false,true,false
|
||||
feedbackButton,experimental,@grafana/grafana-operator-experience-squad,false,false,false
|
||||
unifiedStorageSearchUI,experimental,@grafana/search-and-storage,false,false,false
|
||||
elasticsearchCrossClusterSearch,preview,@grafana/aws-datasources,false,false,false
|
||||
unifiedHistory,experimental,@grafana/grafana-frontend-platform,false,false,true
|
||||
lokiLabelNamesQueryApi,GA,@grafana/observability-logs,false,false,false
|
||||
|
||||
|
@@ -907,6 +907,10 @@ const (
|
||||
// Enables a button to send feedback from the Grafana UI
|
||||
FlagFeedbackButton = "feedbackButton"
|
||||
|
||||
// FlagUnifiedStorageSearchUI
|
||||
// Enable unified storage search UI
|
||||
FlagUnifiedStorageSearchUI = "unifiedStorageSearchUI"
|
||||
|
||||
// FlagElasticsearchCrossClusterSearch
|
||||
// Enables cross cluster search in the Elasticsearch datasource
|
||||
FlagElasticsearchCrossClusterSearch = "elasticsearchCrossClusterSearch"
|
||||
|
||||
@@ -3635,8 +3635,8 @@
|
||||
{
|
||||
"metadata": {
|
||||
"name": "unifiedStorageSearchSprinkles",
|
||||
"resourceVersion": "1734123247356",
|
||||
"creationTimestamp": "2024-12-13T20:54:07Z"
|
||||
"resourceVersion": "1734563607668",
|
||||
"creationTimestamp": "2024-12-18T23:13:27Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enable sprinkles on unified storage search",
|
||||
@@ -3646,6 +3646,23 @@
|
||||
"hideFromDocs": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "unifiedStorageSearchUI",
|
||||
"resourceVersion": "1734563607668",
|
||||
"creationTimestamp": "2024-12-10T21:28:55Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2024-12-18 23:13:27.66802 +0000 UTC"
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enable unified storage search UI",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/search-and-storage",
|
||||
"hideFromAdminPage": true,
|
||||
"hideFromDocs": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "useSeessionStorageForRedirection",
|
||||
|
||||
@@ -53,12 +53,18 @@ type IndexableDocument struct {
|
||||
// The resource key
|
||||
Key *ResourceKey `json:"key"`
|
||||
|
||||
// The resource type ( for federated indexes )
|
||||
Kind string `json:"kind,omitempty"`
|
||||
|
||||
// Resource version for the resource (if known)
|
||||
RV int64 `json:"rv,omitempty"`
|
||||
|
||||
// The generic display name
|
||||
Title string `json:"title,omitempty"`
|
||||
|
||||
// internal sort field for title ( don't set this directly )
|
||||
TitleSort string `json:"title_sort,omitempty"`
|
||||
|
||||
// A generic description -- helpful in global search
|
||||
Description string `json:"description,omitempty"`
|
||||
|
||||
@@ -99,6 +105,10 @@ type IndexableDocument struct {
|
||||
RepoInfo *utils.ResourceRepositoryInfo `json:"repository,omitempty"`
|
||||
}
|
||||
|
||||
func (m *IndexableDocument) Type() string {
|
||||
return m.Key.Resource
|
||||
}
|
||||
|
||||
type ResourceReference struct {
|
||||
Relation string `json:"relation"` // eg: depends-on
|
||||
Group string `json:"group,omitempty"` // the api group
|
||||
@@ -138,10 +148,26 @@ func (m ResourceReferences) Less(i, j int) bool {
|
||||
|
||||
// Create a new indexable document based on a generic k8s resource
|
||||
func NewIndexableDocument(key *ResourceKey, rv int64, obj utils.GrafanaMetaAccessor) *IndexableDocument {
|
||||
title := obj.FindTitle(key.Name)
|
||||
if title == key.Name {
|
||||
// TODO: something wrong with FindTitle
|
||||
spec, err := obj.GetSpec()
|
||||
if err == nil {
|
||||
specValue, ok := spec.(map[string]any)
|
||||
if ok {
|
||||
specTitle, ok := specValue["title"].(string)
|
||||
if ok {
|
||||
title = specTitle
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
doc := &IndexableDocument{
|
||||
Key: key,
|
||||
Kind: key.Resource,
|
||||
RV: rv,
|
||||
Title: obj.FindTitle(key.Name), // We always want *something* to display
|
||||
Title: title, // We always want *something* to display
|
||||
TitleSort: title,
|
||||
Labels: obj.GetLabels(),
|
||||
Folder: obj.GetFolder(),
|
||||
CreatedBy: obj.GetCreatedBy(),
|
||||
@@ -178,7 +204,6 @@ func (s *standardDocumentBuilder) BuildDocument(ctx context.Context, key *Resour
|
||||
}
|
||||
|
||||
doc := NewIndexableDocument(key, rv, obj)
|
||||
doc.Title = obj.FindTitle(key.Name)
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
@@ -220,11 +245,13 @@ func (x *searchableDocumentFields) Field(name string) *ResourceTableColumnDefini
|
||||
}
|
||||
|
||||
const SEARCH_FIELD_ID = "_id" // {namespace}/{group}/{resource}/{name}
|
||||
const SEARCH_FIELD_KIND = "kind" // resource ( for federated index filtering )
|
||||
const SEARCH_FIELD_GROUP_RESOURCE = "gr" // group/resource
|
||||
const SEARCH_FIELD_NAMESPACE = "namespace"
|
||||
const SEARCH_FIELD_NAME = "name"
|
||||
const SEARCH_FIELD_RV = "rv"
|
||||
const SEARCH_FIELD_TITLE = "title"
|
||||
const SEARCH_FIELD_TITLE_SORT = "title_sort"
|
||||
const SEARCH_FIELD_DESCRIPTION = "description"
|
||||
const SEARCH_FIELD_TAGS = "tags"
|
||||
const SEARCH_FIELD_LABELS = "labels" // All labels, not a specific one
|
||||
|
||||
@@ -33,8 +33,10 @@ func TestStandardDocumentBuilder(t *testing.T) {
|
||||
"resource": "playlists",
|
||||
"name": "test1"
|
||||
},
|
||||
"kind": "playlists",
|
||||
"rv": 10,
|
||||
"title": "test1",
|
||||
"title": "test playlist unified storage",
|
||||
"title_sort": "test playlist unified storage",
|
||||
"created": 1717236672000,
|
||||
"createdBy": "user:ABC",
|
||||
"updatedBy": "user:XYZ",
|
||||
@@ -43,5 +45,5 @@ func TestStandardDocumentBuilder(t *testing.T) {
|
||||
"path": "15",
|
||||
"hash": "xyz"
|
||||
}
|
||||
}`, string(jj))
|
||||
}`, string(jj))
|
||||
}
|
||||
|
||||
@@ -305,27 +305,9 @@ func (b *bleveIndex) Search(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Write frame as JSON
|
||||
//response.Frame, err = frame.MarshalJSON()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// parse the facet fields
|
||||
for k, v := range res.Facets {
|
||||
f := &resource.ResourceSearchResponse_Facet{
|
||||
Field: v.Field,
|
||||
Total: int64(v.Total),
|
||||
Missing: int64(v.Missing),
|
||||
}
|
||||
if v.Terms != nil {
|
||||
for _, t := range v.Terms.Terms() {
|
||||
f.Terms = append(f.Terms, &resource.ResourceSearchResponse_TermFacet{
|
||||
Term: t.Term,
|
||||
Count: int64(t.Count),
|
||||
})
|
||||
}
|
||||
}
|
||||
f := newResponseFacet(v)
|
||||
if response.Facet == nil {
|
||||
response.Facet = make(map[string]*resource.ResourceSearchResponse_Facet)
|
||||
}
|
||||
@@ -387,7 +369,7 @@ func (b *bleveIndex) getIndex(
|
||||
return nil, fmt.Errorf("federated indexes must be the same type")
|
||||
}
|
||||
if typedindex.verifyKey(req.Federated[i]) != nil {
|
||||
return nil, fmt.Errorf("federated index keys do not match")
|
||||
return nil, fmt.Errorf("federated index keys do not match (%v != %v)", typedindex, req.Federated[i])
|
||||
}
|
||||
all = append(all, typedindex.index)
|
||||
}
|
||||
@@ -397,11 +379,16 @@ func (b *bleveIndex) getIndex(
|
||||
}
|
||||
|
||||
func toBleveSearchRequest(req *resource.ResourceSearchRequest, access authz.AccessClient) (*bleve.SearchRequest, *resource.ErrorResult) {
|
||||
facets := bleve.FacetsRequest{}
|
||||
for _, f := range req.Facet {
|
||||
facets[f.Field] = bleve.NewFacetRequest(f.Field, int(f.Limit))
|
||||
}
|
||||
searchrequest := &bleve.SearchRequest{
|
||||
Fields: req.Fields,
|
||||
Size: int(req.Limit),
|
||||
From: int(req.Offset),
|
||||
Explain: req.Explain,
|
||||
Facets: facets,
|
||||
}
|
||||
|
||||
// Currently everything is within an AND query
|
||||
@@ -415,6 +402,7 @@ func toBleveSearchRequest(req *resource.ResourceSearchRequest, access authz.Acce
|
||||
queries = append(queries, q)
|
||||
}
|
||||
}
|
||||
// filters
|
||||
if len(req.Options.Fields) > 0 {
|
||||
for _, v := range req.Options.Fields {
|
||||
q, err := requirementQuery(v, "")
|
||||
@@ -425,12 +413,7 @@ func toBleveSearchRequest(req *resource.ResourceSearchRequest, access authz.Acce
|
||||
}
|
||||
}
|
||||
|
||||
if req.Query != "" {
|
||||
// ??? Should expose the full power of query parsing here?
|
||||
// it is great for exploration, but also hard to change in the future
|
||||
q := bleve.NewQueryStringQuery(req.Query)
|
||||
queries = append(queries, q)
|
||||
}
|
||||
queries = append(queries, newTextQuery(req))
|
||||
|
||||
if access != nil {
|
||||
// TODO AUTHZ!!!!
|
||||
@@ -458,30 +441,11 @@ func toBleveSearchRequest(req *resource.ResourceSearchRequest, access authz.Acce
|
||||
}
|
||||
|
||||
// Add the sort fields
|
||||
for _, sort := range req.SortBy {
|
||||
// hardcoded (for now)
|
||||
if strings.HasPrefix(sort.Field, "stats.") {
|
||||
searchrequest.Sort = append(searchrequest.Sort, &search.SortField{
|
||||
Field: sort.Field,
|
||||
Desc: sort.Desc,
|
||||
Type: search.SortFieldAsNumber, // force for now!
|
||||
Mode: search.SortFieldDefault, // ???
|
||||
Missing: search.SortFieldMissingLast,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Default support
|
||||
input := sort.Field
|
||||
if sort.Desc {
|
||||
input = "-" + sort.Field
|
||||
}
|
||||
s := search.ParseSearchSortString(input)
|
||||
searchrequest.Sort = append(searchrequest.Sort, s)
|
||||
}
|
||||
sorting := getSortFields(req)
|
||||
searchrequest.SortBy(sorting)
|
||||
|
||||
// Always sort by *something*, otherwise the order is unstable
|
||||
if len(searchrequest.Sort) == 0 {
|
||||
if len(sorting) == 0 {
|
||||
searchrequest.Sort = append(searchrequest.Sort, &search.SortDocID{
|
||||
Desc: false,
|
||||
})
|
||||
@@ -490,23 +454,71 @@ func toBleveSearchRequest(req *resource.ResourceSearchRequest, access authz.Acce
|
||||
return searchrequest, nil
|
||||
}
|
||||
|
||||
func getSortFields(req *resource.ResourceSearchRequest) []string {
|
||||
sorting := []string{}
|
||||
for _, sort := range req.SortBy {
|
||||
input := sort.Field
|
||||
if field, ok := textSortFields[input]; ok {
|
||||
input = field
|
||||
}
|
||||
|
||||
if sort.Desc {
|
||||
input = "-" + input
|
||||
}
|
||||
sorting = append(sorting, input)
|
||||
}
|
||||
return sorting
|
||||
}
|
||||
|
||||
// fields that we went to sort by the full text
|
||||
var textSortFields = map[string]string{
|
||||
resource.SEARCH_FIELD_TITLE: resource.SEARCH_FIELD_TITLE + "_sort",
|
||||
}
|
||||
|
||||
// Convert a "requirement" into a bleve query
|
||||
func requirementQuery(req *resource.Requirement, prefix string) (query.Query, *resource.ErrorResult) {
|
||||
switch selection.Operator(req.Operator) {
|
||||
case selection.Equals, selection.DoubleEquals:
|
||||
if len(req.Values) != 1 {
|
||||
return nil, resource.NewBadRequestError("equals query can have one value")
|
||||
if len(req.Values) == 0 {
|
||||
return query.NewMatchAllQuery(), nil
|
||||
}
|
||||
if len(req.Values[0]) == 1 {
|
||||
q := query.NewMatchQuery(req.Values[0])
|
||||
q.FieldVal = prefix + req.Key
|
||||
return q, nil
|
||||
}
|
||||
q := query.NewMatchQuery(req.Values[0])
|
||||
q.FieldVal = prefix + req.Key
|
||||
return q, nil
|
||||
|
||||
conjuncts := []query.Query{}
|
||||
for _, v := range req.Values {
|
||||
q := query.NewMatchQuery(v)
|
||||
q.FieldVal = prefix + req.Key
|
||||
conjuncts = append(conjuncts, q)
|
||||
}
|
||||
|
||||
return query.NewConjunctionQuery(conjuncts), nil
|
||||
case selection.NotEquals:
|
||||
case selection.DoesNotExist:
|
||||
case selection.GreaterThan:
|
||||
case selection.LessThan:
|
||||
case selection.Exists:
|
||||
case selection.In:
|
||||
if len(req.Values) == 0 {
|
||||
return query.NewMatchAllQuery(), nil
|
||||
}
|
||||
if len(req.Values) == 1 {
|
||||
q := query.NewMatchQuery(req.Values[0])
|
||||
q.FieldVal = prefix + req.Key
|
||||
return q, nil
|
||||
}
|
||||
|
||||
disjuncts := []query.Query{}
|
||||
for _, v := range req.Values {
|
||||
q := query.NewMatchQuery(v)
|
||||
q.FieldVal = prefix + req.Key
|
||||
disjuncts = append(disjuncts, q)
|
||||
}
|
||||
|
||||
return query.NewDisjunctionQuery(disjuncts), nil
|
||||
case selection.NotIn:
|
||||
}
|
||||
return nil, resource.NewBadRequestError(
|
||||
@@ -615,3 +627,29 @@ func getAllFields(standard resource.SearchableDocumentFields, custom resource.Se
|
||||
}
|
||||
return fields, nil
|
||||
}
|
||||
|
||||
func newResponseFacet(v *search.FacetResult) *resource.ResourceSearchResponse_Facet {
|
||||
f := &resource.ResourceSearchResponse_Facet{
|
||||
Field: v.Field,
|
||||
Total: int64(v.Total),
|
||||
Missing: int64(v.Missing),
|
||||
}
|
||||
if v.Terms != nil {
|
||||
for _, t := range v.Terms.Terms() {
|
||||
f.Terms = append(f.Terms, &resource.ResourceSearchResponse_TermFacet{
|
||||
Term: t.Term,
|
||||
Count: int64(t.Count),
|
||||
})
|
||||
}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func newTextQuery(req *resource.ResourceSearchRequest) query.Query {
|
||||
if req.Query == "" || req.Query == "*" {
|
||||
return bleve.NewMatchAllQuery()
|
||||
}
|
||||
// TODO: wildcard query?
|
||||
// return bleve.NewWildcardQuery(req.Query)
|
||||
return bleve.NewFuzzyQuery(req.Query)
|
||||
}
|
||||
|
||||
@@ -16,21 +16,20 @@ func getBleveMappings(fields resource.SearchableDocumentFields) mapping.IndexMap
|
||||
|
||||
func getBleveDocMappings(_ resource.SearchableDocumentFields) *mapping.DocumentMapping {
|
||||
mapper := bleve.NewDocumentStaticMapping()
|
||||
mapper.AddFieldMapping(&mapping.FieldMapping{
|
||||
Name: resource.SEARCH_FIELD_TITLE,
|
||||
Type: "text",
|
||||
// TODO - if we don't want title to be a keyword, we can use this
|
||||
// set the title field to use keyword analyzer so it sorts by the whole phrase
|
||||
// https://github.com/blevesearch/bleve/issues/417#issuecomment-245273022
|
||||
Analyzer: keyword.Name,
|
||||
Store: true,
|
||||
Index: true,
|
||||
IncludeTermVectors: true,
|
||||
IncludeInAll: true,
|
||||
DocValues: false,
|
||||
})
|
||||
|
||||
mapper.AddFieldMapping(&mapping.FieldMapping{
|
||||
// for sorting by title
|
||||
titleSortMapping := bleve.NewKeywordFieldMapping()
|
||||
mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_TITLE_SORT, titleSortMapping)
|
||||
|
||||
// for searching by title
|
||||
titleSearchMapping := bleve.NewTextFieldMapping()
|
||||
mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_TITLE, titleSearchMapping)
|
||||
|
||||
// for filtering by kind/resource ( federated search )
|
||||
kindMapping := bleve.NewTextFieldMapping()
|
||||
mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_KIND, kindMapping)
|
||||
|
||||
descriptionMapping := &mapping.FieldMapping{
|
||||
Name: resource.SEARCH_FIELD_DESCRIPTION,
|
||||
Type: "text",
|
||||
Store: true,
|
||||
@@ -38,9 +37,10 @@ func getBleveDocMappings(_ resource.SearchableDocumentFields) *mapping.DocumentM
|
||||
IncludeTermVectors: false,
|
||||
IncludeInAll: false,
|
||||
DocValues: false,
|
||||
})
|
||||
}
|
||||
mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_DESCRIPTION, descriptionMapping)
|
||||
|
||||
mapper.AddFieldMapping(&mapping.FieldMapping{
|
||||
tagsMapping := &mapping.FieldMapping{
|
||||
Name: resource.SEARCH_FIELD_TAGS,
|
||||
Type: "text",
|
||||
Analyzer: keyword.Name,
|
||||
@@ -49,9 +49,10 @@ func getBleveDocMappings(_ resource.SearchableDocumentFields) *mapping.DocumentM
|
||||
IncludeTermVectors: false,
|
||||
IncludeInAll: true,
|
||||
DocValues: false,
|
||||
})
|
||||
}
|
||||
mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_TAGS, tagsMapping)
|
||||
|
||||
mapper.AddFieldMapping(&mapping.FieldMapping{
|
||||
folderMapping := &mapping.FieldMapping{
|
||||
Name: resource.SEARCH_FIELD_FOLDER,
|
||||
Type: "text",
|
||||
Analyzer: keyword.Name,
|
||||
@@ -60,9 +61,10 @@ func getBleveDocMappings(_ resource.SearchableDocumentFields) *mapping.DocumentM
|
||||
IncludeTermVectors: false,
|
||||
IncludeInAll: true,
|
||||
DocValues: true, // will be needed for authz client
|
||||
})
|
||||
}
|
||||
mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_FOLDER, folderMapping)
|
||||
|
||||
mapper.AddFieldMapping(&mapping.FieldMapping{
|
||||
repoMapping := &mapping.FieldMapping{
|
||||
Name: resource.SEARCH_FIELD_REPOSITORY,
|
||||
Type: "text",
|
||||
Analyzer: keyword.Name,
|
||||
@@ -71,8 +73,10 @@ func getBleveDocMappings(_ resource.SearchableDocumentFields) *mapping.DocumentM
|
||||
IncludeTermVectors: false,
|
||||
IncludeInAll: true,
|
||||
DocValues: true,
|
||||
})
|
||||
}
|
||||
mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_REPOSITORY, repoMapping)
|
||||
|
||||
// TODO: we use the static mapper. why set dynamic to true?
|
||||
mapper.Dynamic = true
|
||||
|
||||
return mapper
|
||||
|
||||
@@ -42,5 +42,5 @@ func TestDocumentMapping(t *testing.T) {
|
||||
|
||||
fmt.Printf("DOC: fields %d\n", len(doc.Fields))
|
||||
fmt.Printf("DOC: size %d\n", doc.Size())
|
||||
require.Equal(t, 15, len(doc.Fields))
|
||||
require.Equal(t, 17, len(doc.Fields))
|
||||
}
|
||||
|
||||
@@ -66,11 +66,12 @@ func TestBleveBackend(t *testing.T) {
|
||||
Key: &resource.ResourceKey{
|
||||
Name: "aaa",
|
||||
Namespace: "ns",
|
||||
Group: "g",
|
||||
Resource: "dash",
|
||||
Group: "dashboard.grafana.app",
|
||||
Resource: "dashboards",
|
||||
},
|
||||
Title: "aaa (dash)",
|
||||
Folder: "xxx",
|
||||
Title: "aaa (dash)",
|
||||
TitleSort: "aaa (dash)",
|
||||
Folder: "xxx",
|
||||
Fields: map[string]any{
|
||||
DASHBOARD_LEGACY_ID: 12,
|
||||
DASHBOARD_PANEL_TYPES: []string{"timeseries", "table"},
|
||||
@@ -83,11 +84,12 @@ func TestBleveBackend(t *testing.T) {
|
||||
Key: &resource.ResourceKey{
|
||||
Name: "bbb",
|
||||
Namespace: "ns",
|
||||
Group: "g",
|
||||
Resource: "dash",
|
||||
Group: "dashboard.grafana.app",
|
||||
Resource: "dashboards",
|
||||
},
|
||||
Title: "bbb (dash)",
|
||||
Folder: "xxx",
|
||||
Title: "bbb (dash)",
|
||||
TitleSort: "bbb (dash)",
|
||||
Folder: "xxx",
|
||||
Fields: map[string]any{
|
||||
DASHBOARD_LEGACY_ID: 12,
|
||||
DASHBOARD_PANEL_TYPES: []string{"timeseries"},
|
||||
@@ -103,11 +105,12 @@ func TestBleveBackend(t *testing.T) {
|
||||
Key: &resource.ResourceKey{
|
||||
Name: "ccc",
|
||||
Namespace: "ns",
|
||||
Group: "g",
|
||||
Resource: "dash",
|
||||
Group: "dashboard.grafana.app",
|
||||
Resource: "dashboards",
|
||||
},
|
||||
Title: "ccc (dash)",
|
||||
Folder: "zzz",
|
||||
Title: "ccc (dash)",
|
||||
TitleSort: "ccc (dash)",
|
||||
Folder: "zzz",
|
||||
RepoInfo: &utils.ResourceRepositoryInfo{
|
||||
Name: "r0",
|
||||
},
|
||||
@@ -145,11 +148,7 @@ func TestBleveBackend(t *testing.T) {
|
||||
require.NotNil(t, rsp.Results)
|
||||
require.NotNil(t, rsp.Facet)
|
||||
|
||||
t.Run("x", func(t *testing.T) {
|
||||
t.Skip("flakey tests - skipping") // sort seems different in CI... sometimes!
|
||||
// Match the results
|
||||
resource.AssertTableSnapshot(t, filepath.Join("testdata", "manual-dashboard.json"), rsp.Results)
|
||||
})
|
||||
resource.AssertTableSnapshot(t, filepath.Join("testdata", "manual-dashboard.json"), rsp.Results)
|
||||
|
||||
// Get the tags facets
|
||||
facet, ok := rsp.Facet["tags"]
|
||||
@@ -193,20 +192,22 @@ func TestBleveBackend(t *testing.T) {
|
||||
Key: &resource.ResourceKey{
|
||||
Name: "zzz",
|
||||
Namespace: "ns",
|
||||
Group: "g",
|
||||
Resource: "folder",
|
||||
Group: "folder.grafana.app",
|
||||
Resource: "folders",
|
||||
},
|
||||
Title: "zzz (folder)",
|
||||
Title: "zzz (folder)",
|
||||
TitleSort: "zzz (folder)",
|
||||
})
|
||||
_ = index.Write(&resource.IndexableDocument{
|
||||
RV: 2,
|
||||
Key: &resource.ResourceKey{
|
||||
Name: "yyy",
|
||||
Namespace: "ns",
|
||||
Group: "g",
|
||||
Resource: "folder",
|
||||
Group: "folder.grafana.app",
|
||||
Resource: "folders",
|
||||
},
|
||||
Title: "yyy (folder)",
|
||||
Title: "yyy (folder)",
|
||||
TitleSort: "yyy (folder)",
|
||||
Labels: map[string]string{
|
||||
"region": "west",
|
||||
},
|
||||
@@ -232,8 +233,6 @@ func TestBleveBackend(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("simple federation", func(t *testing.T) {
|
||||
t.Skip("flakey tests - skipping") // sort seems different in CI... sometimes!
|
||||
|
||||
// The other tests must run first to build the indexes
|
||||
require.NotNil(t, dashboardsIndex)
|
||||
require.NotNil(t, foldersIndex)
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
"resource": "dashboards",
|
||||
"name": "aaa"
|
||||
},
|
||||
"kind": "dashboards",
|
||||
"rv": 1234,
|
||||
"title": "Test title",
|
||||
"title_sort": "Test title",
|
||||
"description": "test description",
|
||||
"tags": [
|
||||
"a",
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
"resource": "dashboards",
|
||||
"name": "aaa"
|
||||
},
|
||||
"kind": "dashboards",
|
||||
"rv": 1234,
|
||||
"title": "aaa",
|
||||
"title": "test-aaa",
|
||||
"title_sort": "test-aaa",
|
||||
"created": 1730490142000,
|
||||
"createdBy": "user:1",
|
||||
"repository": {
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
"resource": "dashboards",
|
||||
"name": "bbb"
|
||||
},
|
||||
"kind": "dashboards",
|
||||
"rv": 1234,
|
||||
"title": "bbb",
|
||||
"title": "test-bbb",
|
||||
"title_sort": "test-bbb",
|
||||
"created": 1730490142000,
|
||||
"createdBy": "user:1",
|
||||
"repository": {
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
"resource": "dashboards",
|
||||
"name": "aaa"
|
||||
},
|
||||
"kind": "dashboards",
|
||||
"rv": 1234,
|
||||
"title": "aaa",
|
||||
"title": "Test AAA",
|
||||
"title_sort": "Test AAA",
|
||||
"created": 1731336353000,
|
||||
"createdBy": "user:t000000001",
|
||||
"repository": {
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
"resource": "dashboards",
|
||||
"name": "aaa"
|
||||
},
|
||||
"kind": "dashboards",
|
||||
"rv": 1234,
|
||||
"title": "aaa",
|
||||
"title": "Test AAA",
|
||||
"title_sort": "Test AAA",
|
||||
"created": 1706690655000,
|
||||
"createdBy": "user:abc",
|
||||
"repository": {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"metadata": {},
|
||||
"columnDefinitions": [
|
||||
{
|
||||
"name": "title",
|
||||
"type": "string",
|
||||
"format": "",
|
||||
"description": "Display name for the resource",
|
||||
"priority": 0
|
||||
},
|
||||
{
|
||||
"name": "_id",
|
||||
"type": "string",
|
||||
"format": "",
|
||||
"description": "Unique Identifier. {namespace}/{group}/{resource}/{name}",
|
||||
"priority": 0
|
||||
}
|
||||
],
|
||||
"rows": [
|
||||
{
|
||||
"cells": [
|
||||
"yyy (folder)",
|
||||
"ns/folder.grafana.app/folders/yyy"
|
||||
],
|
||||
"object": {
|
||||
"kind": "folders",
|
||||
"apiVersion": "folder.grafana.app",
|
||||
"metadata": {
|
||||
"name": "yyy",
|
||||
"namespace": "ns",
|
||||
"creationTimestamp": null
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"cells": [
|
||||
"zzz (folder)",
|
||||
"ns/folder.grafana.app/folders/zzz"
|
||||
],
|
||||
"object": {
|
||||
"kind": "folders",
|
||||
"apiVersion": "folder.grafana.app",
|
||||
"metadata": {
|
||||
"name": "zzz",
|
||||
"namespace": "ns",
|
||||
"creationTimestamp": null
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -68,7 +68,7 @@
|
||||
"rows": [
|
||||
{
|
||||
"cells": [
|
||||
"ns/g/dash/ccc",
|
||||
"ns/dashboard.grafana.app/dashboards/ccc",
|
||||
"ccc (dash)",
|
||||
[
|
||||
"aa"
|
||||
@@ -81,8 +81,8 @@
|
||||
null
|
||||
],
|
||||
"object": {
|
||||
"kind": "dash",
|
||||
"apiVersion": "g",
|
||||
"kind": "dashboards",
|
||||
"apiVersion": "dashboard.grafana.app",
|
||||
"metadata": {
|
||||
"name": "ccc",
|
||||
"namespace": "ns",
|
||||
@@ -92,7 +92,7 @@
|
||||
},
|
||||
{
|
||||
"cells": [
|
||||
"ns/g/dash/bbb",
|
||||
"ns/dashboard.grafana.app/dashboards/bbb",
|
||||
"bbb (dash)",
|
||||
[
|
||||
"aa"
|
||||
@@ -105,8 +105,8 @@
|
||||
null
|
||||
],
|
||||
"object": {
|
||||
"kind": "dash",
|
||||
"apiVersion": "g",
|
||||
"kind": "dashboards",
|
||||
"apiVersion": "dashboard.grafana.app",
|
||||
"metadata": {
|
||||
"name": "bbb",
|
||||
"namespace": "ns",
|
||||
@@ -116,7 +116,7 @@
|
||||
},
|
||||
{
|
||||
"cells": [
|
||||
"ns/g/dash/aaa",
|
||||
"ns/dashboard.grafana.app/dashboards/aaa",
|
||||
"aaa (dash)",
|
||||
[
|
||||
"aa",
|
||||
@@ -130,8 +130,8 @@
|
||||
null
|
||||
],
|
||||
"object": {
|
||||
"kind": "dash",
|
||||
"apiVersion": "g",
|
||||
"kind": "dashboards",
|
||||
"apiVersion": "dashboard.grafana.app",
|
||||
"metadata": {
|
||||
"name": "aaa",
|
||||
"namespace": "ns",
|
||||
|
||||
+28
-28
@@ -20,26 +20,11 @@
|
||||
{
|
||||
"cells": [
|
||||
"aaa (dash)",
|
||||
"ns/g/dash/bbb"
|
||||
"ns/dashboard.grafana.app/dashboards/aaa"
|
||||
],
|
||||
"object": {
|
||||
"kind": "dash",
|
||||
"apiVersion": "g",
|
||||
"metadata": {
|
||||
"name": "bbb",
|
||||
"namespace": "ns",
|
||||
"creationTimestamp": null
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"cells": [
|
||||
"bbb (dash)",
|
||||
"ns/g/dash/aaa"
|
||||
],
|
||||
"object": {
|
||||
"kind": "dash",
|
||||
"apiVersion": "g",
|
||||
"kind": "dashboards",
|
||||
"apiVersion": "dashboard.grafana.app",
|
||||
"metadata": {
|
||||
"name": "aaa",
|
||||
"namespace": "ns",
|
||||
@@ -49,12 +34,27 @@
|
||||
},
|
||||
{
|
||||
"cells": [
|
||||
"ccc (dash)",
|
||||
"ns/g/dash/ccc"
|
||||
"bbb (dash)",
|
||||
"ns/dashboard.grafana.app/dashboards/bbb"
|
||||
],
|
||||
"object": {
|
||||
"kind": "dash",
|
||||
"apiVersion": "g",
|
||||
"kind": "dashboards",
|
||||
"apiVersion": "dashboard.grafana.app",
|
||||
"metadata": {
|
||||
"name": "bbb",
|
||||
"namespace": "ns",
|
||||
"creationTimestamp": null
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"cells": [
|
||||
"ccc (dash)",
|
||||
"ns/dashboard.grafana.app/dashboards/ccc"
|
||||
],
|
||||
"object": {
|
||||
"kind": "dashboards",
|
||||
"apiVersion": "dashboard.grafana.app",
|
||||
"metadata": {
|
||||
"name": "ccc",
|
||||
"namespace": "ns",
|
||||
@@ -65,11 +65,11 @@
|
||||
{
|
||||
"cells": [
|
||||
"yyy (folder)",
|
||||
"ns/g/folder/yyy"
|
||||
"ns/folder.grafana.app/folders/yyy"
|
||||
],
|
||||
"object": {
|
||||
"kind": "folder",
|
||||
"apiVersion": "g",
|
||||
"kind": "folders",
|
||||
"apiVersion": "folder.grafana.app",
|
||||
"metadata": {
|
||||
"name": "yyy",
|
||||
"namespace": "ns",
|
||||
@@ -80,11 +80,11 @@
|
||||
{
|
||||
"cells": [
|
||||
"zzz (folder)",
|
||||
"ns/g/folder/zzz"
|
||||
"ns/folder.grafana.app/folders/zzz"
|
||||
],
|
||||
"object": {
|
||||
"kind": "folder",
|
||||
"apiVersion": "g",
|
||||
"kind": "folders",
|
||||
"apiVersion": "folder.grafana.app",
|
||||
"metadata": {
|
||||
"name": "zzz",
|
||||
"namespace": "ns",
|
||||
|
||||
+6
-6
@@ -47,7 +47,7 @@
|
||||
"rows": [
|
||||
{
|
||||
"cells": [
|
||||
"ns/g/folder/yyy",
|
||||
"ns/folder.grafana.app/folders/yyy",
|
||||
"yyy (folder)",
|
||||
null,
|
||||
null,
|
||||
@@ -55,8 +55,8 @@
|
||||
0
|
||||
],
|
||||
"object": {
|
||||
"kind": "folder",
|
||||
"apiVersion": "g",
|
||||
"kind": "folders",
|
||||
"apiVersion": "folder.grafana.app",
|
||||
"metadata": {
|
||||
"name": "yyy",
|
||||
"namespace": "ns",
|
||||
@@ -66,7 +66,7 @@
|
||||
},
|
||||
{
|
||||
"cells": [
|
||||
"ns/g/folder/zzz",
|
||||
"ns/folder.grafana.app/folders/zzz",
|
||||
"zzz (folder)",
|
||||
null,
|
||||
null,
|
||||
@@ -74,8 +74,8 @@
|
||||
0
|
||||
],
|
||||
"object": {
|
||||
"kind": "folder",
|
||||
"apiVersion": "g",
|
||||
"kind": "folders",
|
||||
"apiVersion": "folder.grafana.app",
|
||||
"metadata": {
|
||||
"name": "zzz",
|
||||
"namespace": "ns",
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
"reflect"
|
||||
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apiserver/pkg/endpoints/request"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/errutil"
|
||||
@@ -40,6 +41,16 @@ func Write(ctx context.Context, err error, w http.ResponseWriter, opts ...func(E
|
||||
|
||||
var gErr errutil.Error
|
||||
if !errors.As(err, &gErr) {
|
||||
// Write k8s response if this is a k8s error
|
||||
k8s, ok := err.(apierrors.APIStatus)
|
||||
if ok {
|
||||
status := k8s.Status()
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
w.WriteHeader(int(status.Code))
|
||||
_ = json.NewEncoder(w).Encode(status)
|
||||
return
|
||||
}
|
||||
|
||||
gErr = fallbackOrInternalError(err, opt)
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ export async function listFolders(
|
||||
|
||||
const backendSrv = getBackendSrv();
|
||||
|
||||
// TODO: what to do here for unified search?
|
||||
let folders: NestedFolderDTO[] = [];
|
||||
if (contextSrv.hasPermission(AccessControlAction.FoldersRead)) {
|
||||
folders = await backendSrv.get<NestedFolderDTO[]>('/api/folders', {
|
||||
|
||||
@@ -18,7 +18,7 @@ export function getGrafanaSearcher(): GrafanaSearcher {
|
||||
return new FrontendSearcher(searcher);
|
||||
}
|
||||
|
||||
const useUnifiedStorageSearch = false; // TODO, frontend FF config.featureToggles.unifiedStorageSearch;
|
||||
const useUnifiedStorageSearch = config.featureToggles.unifiedStorageSearchUI;
|
||||
searcher = useUnifiedStorageSearch ? new UnifiedSearcher(sqlSearcher) : sqlSearcher;
|
||||
}
|
||||
return searcher!;
|
||||
|
||||
@@ -1,36 +1,68 @@
|
||||
import {
|
||||
DataFrame,
|
||||
DataFrameJSON,
|
||||
DataFrameView,
|
||||
getDisplayProcessor,
|
||||
SelectableValue,
|
||||
toDataFrame,
|
||||
} from '@grafana/data';
|
||||
import { isEmpty } from 'lodash';
|
||||
|
||||
import { DataFrame, DataFrameView, getDisplayProcessor, SelectableValue, toDataFrame } from '@grafana/data';
|
||||
import { config, getBackendSrv } from '@grafana/runtime';
|
||||
import { TermCount } from 'app/core/components/TagFilter/TagFilter';
|
||||
|
||||
import { DashboardQueryResult, GrafanaSearcher, QueryResponse, SearchQuery, SearchResultMeta } from './types';
|
||||
import {
|
||||
DashboardQueryResult,
|
||||
GrafanaSearcher,
|
||||
LocationInfo,
|
||||
QueryResponse,
|
||||
SearchQuery,
|
||||
SearchResultMeta,
|
||||
} from './types';
|
||||
import { replaceCurrentFolderQuery } from './utils';
|
||||
|
||||
// The backend returns an empty frame with a special name to indicate that the indexing engine is being rebuilt,
|
||||
// and that it can not serve any search requests. We are temporarily using the old SQL Search API as a fallback when that happens.
|
||||
const loadingFrameName = 'Loading';
|
||||
|
||||
const searchURI = 'api/unified-search';
|
||||
const searchURI = `apis/dashboard.grafana.app/v0alpha1/namespaces/${config.namespace}/search`;
|
||||
|
||||
type SearchHit = {
|
||||
resource: string; // dashboards | folders
|
||||
name: string;
|
||||
title: string;
|
||||
location: string;
|
||||
folder: string;
|
||||
tags: string[];
|
||||
|
||||
// calculated in the frontend
|
||||
url: string;
|
||||
};
|
||||
|
||||
type SearchAPIResponse = {
|
||||
frames: DataFrameJSON[];
|
||||
hits: SearchHit[];
|
||||
facets?: {
|
||||
tags?: {
|
||||
terms?: Array<{
|
||||
term: string;
|
||||
count: number;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const folderViewSort = 'name_sort';
|
||||
|
||||
export class UnifiedSearcher implements GrafanaSearcher {
|
||||
constructor(private fallbackSearcher: GrafanaSearcher) {}
|
||||
locationInfo: Promise<Record<string, LocationInfo>>;
|
||||
|
||||
constructor(private fallbackSearcher: GrafanaSearcher) {
|
||||
this.locationInfo = loadLocationInfo();
|
||||
}
|
||||
|
||||
async search(query: SearchQuery): Promise<QueryResponse> {
|
||||
if (query.facet?.length) {
|
||||
throw new Error('facets not supported!');
|
||||
}
|
||||
|
||||
if (query.kind?.length === 1 && query.kind[0] === 'dashboard') {
|
||||
// TODO: this is browse mode, so skip the search
|
||||
return noDataResponse();
|
||||
}
|
||||
|
||||
return this.doSearchQuery(query);
|
||||
}
|
||||
|
||||
@@ -47,41 +79,14 @@ export class UnifiedSearcher implements GrafanaSearcher {
|
||||
});
|
||||
}
|
||||
// Nothing is starred
|
||||
return {
|
||||
view: new DataFrameView({ length: 0, fields: [] }),
|
||||
totalRows: 0,
|
||||
loadMoreItems: async (startIndex: number, stopIndex: number): Promise<void> => {
|
||||
return;
|
||||
},
|
||||
isItemLoaded: (index: number): boolean => {
|
||||
return true;
|
||||
},
|
||||
};
|
||||
return noDataResponse();
|
||||
}
|
||||
|
||||
async tags(query: SearchQuery): Promise<TermCount[]> {
|
||||
const req = {
|
||||
...query,
|
||||
query: query.query ?? '*',
|
||||
sort: undefined, // no need to sort the initial query results (not used)
|
||||
facet: [{ field: 'tags' }],
|
||||
limit: 1, // 0 would be better, but is ignored by the backend
|
||||
};
|
||||
|
||||
const resp = await getBackendSrv().post<SearchAPIResponse>(searchURI, req);
|
||||
const frames = resp.frames.map((f) => toDataFrame(f));
|
||||
|
||||
if (frames[0]?.name === loadingFrameName) {
|
||||
return this.fallbackSearcher.tags(query);
|
||||
}
|
||||
|
||||
for (const frame of frames) {
|
||||
if (frame.name === 'tags') {
|
||||
return getTermCountsFrom(frame);
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
const qry = query.query ?? '*';
|
||||
let uri = `${searchURI}?facet=tags&query=${qry}&limit=1`;
|
||||
const resp = await getBackendSrv().get<SearchAPIResponse>(uri);
|
||||
return resp.facets?.tags?.terms || [];
|
||||
}
|
||||
|
||||
// TODO: Implement this correctly
|
||||
@@ -113,34 +118,37 @@ export class UnifiedSearcher implements GrafanaSearcher {
|
||||
limit: query.limit ?? firstPageSize,
|
||||
};
|
||||
|
||||
const rsp = await getBackendSrv().post<SearchAPIResponse>(searchURI, req);
|
||||
const frames = rsp.frames.map((f) => toDataFrame(f));
|
||||
let uri = searchURI;
|
||||
const qry = req.query || '*';
|
||||
uri += `?query=${encodeURIComponent(qry)}`;
|
||||
if (req.limit) {
|
||||
uri += `&limit=${req.limit}`;
|
||||
}
|
||||
|
||||
const first = frames.length ? toDataFrame(frames[0]) : { fields: [], length: 0 };
|
||||
if (req.kind) {
|
||||
// filter resource types
|
||||
uri += '&' + req.kind.map((kind) => `type=${kind}`).join('&');
|
||||
}
|
||||
|
||||
if (req.tags) {
|
||||
uri += '&' + req.tags.map((tag) => `tag=${encodeURIComponent(tag)}`).join('&');
|
||||
}
|
||||
|
||||
const rsp = await getBackendSrv().get<SearchAPIResponse>(uri);
|
||||
|
||||
const first = toDashboardResults(rsp.hits);
|
||||
if (first.name === loadingFrameName) {
|
||||
return this.fallbackSearcher.search(query);
|
||||
}
|
||||
|
||||
for (const field of first.fields) {
|
||||
field.display = getDisplayProcessor({ field, theme: config.theme2 });
|
||||
}
|
||||
|
||||
// Make sure the object exists
|
||||
if (!first.meta?.custom) {
|
||||
first.meta = {
|
||||
...first.meta,
|
||||
custom: {
|
||||
count: first.length,
|
||||
max_score: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const meta = first.meta.custom as SearchResultMeta;
|
||||
if (!meta.locationInfo) {
|
||||
meta.locationInfo = {}; // always set it so we can append
|
||||
const meta = first.meta?.custom || ({} as SearchResultMeta);
|
||||
const locationInfo = await this.locationInfo;
|
||||
const hasMissing = rsp.hits.some((hit) => !locationInfo[hit.folder]);
|
||||
if (hasMissing) {
|
||||
// sync the location info ( folders )
|
||||
this.locationInfo = loadLocationInfo();
|
||||
}
|
||||
meta.locationInfo = await this.locationInfo;
|
||||
|
||||
// Set the field name to a better display name
|
||||
if (meta.sortBy?.length) {
|
||||
@@ -155,6 +163,7 @@ export class UnifiedSearcher implements GrafanaSearcher {
|
||||
let loadMax = 0;
|
||||
let pending: Promise<void> | undefined = undefined;
|
||||
const getNextPage = async () => {
|
||||
// TODO: implement this correctly
|
||||
while (loadMax > view.dataFrame.length) {
|
||||
const from = view.dataFrame.length;
|
||||
if (from >= meta.count) {
|
||||
@@ -165,8 +174,7 @@ export class UnifiedSearcher implements GrafanaSearcher {
|
||||
from,
|
||||
limit: nextPageSizes,
|
||||
});
|
||||
const frame = toDataFrame(resp.frames[0]);
|
||||
|
||||
const frame = toDashboardResults(resp.hits);
|
||||
if (!frame) {
|
||||
console.log('no results', frame);
|
||||
return;
|
||||
@@ -220,16 +228,6 @@ export class UnifiedSearcher implements GrafanaSearcher {
|
||||
const firstPageSize = 50;
|
||||
const nextPageSizes = 100;
|
||||
|
||||
function getTermCountsFrom(frame: DataFrame): TermCount[] {
|
||||
const tags = frame.fields[0].values;
|
||||
const vals = frame.fields[1].values;
|
||||
const counts: TermCount[] = [];
|
||||
for (let i = 0; i < frame.length; i++) {
|
||||
counts.push({ term: tags[i], count: vals[i] });
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
// Enterprise only sort field values for dashboards
|
||||
const sortFields = [
|
||||
{ name: 'views_total', display: 'Views total' },
|
||||
@@ -244,6 +242,19 @@ const sortTimeFields = [
|
||||
{ name: 'updated_at', display: 'Updated time' },
|
||||
];
|
||||
|
||||
function noDataResponse(): QueryResponse | PromiseLike<QueryResponse> {
|
||||
return {
|
||||
view: new DataFrameView({ length: 0, fields: [] }),
|
||||
totalRows: 0,
|
||||
loadMoreItems: async (startIndex: number, stopIndex: number): Promise<void> => {
|
||||
return;
|
||||
},
|
||||
isItemLoaded: (index: number): boolean => {
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Given the internal field name, this gives a reasonable display name for the table colum header */
|
||||
function getSortFieldDisplayName(name: string) {
|
||||
for (const sf of sortFields) {
|
||||
@@ -258,3 +269,67 @@ function getSortFieldDisplayName(name: string) {
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function toDashboardResults(hits: SearchHit[]): DataFrame {
|
||||
if (hits.length < 1) {
|
||||
return { fields: [], length: 0 };
|
||||
}
|
||||
const dashboardHits = hits.map((hit) => {
|
||||
let location = hit.folder;
|
||||
if (hit.resource === 'dashboards' && isEmpty(location)) {
|
||||
location = 'general';
|
||||
}
|
||||
|
||||
return {
|
||||
...hit,
|
||||
url: toURL(hit.resource, hit.name),
|
||||
tags: hit.tags || [],
|
||||
folder: hit.folder || 'general',
|
||||
location,
|
||||
name: hit.title, // 🤯 FIXME hit.name is k8s name, eg grafana dashboards UID
|
||||
kind: hit.resource.substring(0, hit.resource.length - 1), // dashboard "kind" is not plural
|
||||
};
|
||||
});
|
||||
const frame = toDataFrame(dashboardHits);
|
||||
frame.meta = {
|
||||
custom: {
|
||||
count: hits.length,
|
||||
max_score: 1,
|
||||
},
|
||||
};
|
||||
for (const field of frame.fields) {
|
||||
field.display = getDisplayProcessor({ field, theme: config.theme2 });
|
||||
}
|
||||
return frame;
|
||||
}
|
||||
|
||||
async function loadLocationInfo(): Promise<Record<string, LocationInfo>> {
|
||||
const uri = `${searchURI}?type=folders`;
|
||||
const rsp = getBackendSrv()
|
||||
.get<SearchAPIResponse>(uri)
|
||||
.then((rsp) => {
|
||||
const locationInfo: Record<string, LocationInfo> = {
|
||||
general: {
|
||||
kind: 'folder',
|
||||
name: 'Dashboards',
|
||||
url: '/dashboards',
|
||||
}, // share location info with everyone
|
||||
};
|
||||
for (const hit of rsp.hits) {
|
||||
locationInfo[hit.name] = {
|
||||
name: hit.title,
|
||||
kind: 'folder',
|
||||
url: toURL('folders', hit.name),
|
||||
};
|
||||
}
|
||||
return locationInfo;
|
||||
});
|
||||
return rsp;
|
||||
}
|
||||
|
||||
function toURL(resource: string, name: string): string {
|
||||
if (resource === 'folders') {
|
||||
return `/dashboards/f/${name}`;
|
||||
}
|
||||
return `/d/${name}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user