Search: Include managedBy in DashboardHit result (#111600)

This commit is contained in:
Ryan McKinley
2025-09-29 14:28:26 +03:00
committed by GitHub
parent 34b662c3e1
commit c4a2bb5540
12 changed files with 373 additions and 101 deletions
@@ -4,6 +4,7 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/utils"
)
// +k8s:deepcopy-gen=true
@@ -69,6 +70,8 @@ type DashboardHit struct {
Tags []string `json:"tags,omitempty"`
// The k8s name (eg, grafana UID) for the parent folder
Folder string `json:"folder,omitempty"`
// The resource is managed
ManagedBy ManagedBy `json:"managedBy,omitzero,omitempty"`
// Stick untyped extra fields in this object (including the sort value)
Field *common.Unstructured `json:"field,omitzero,omitempty"`
// When using "real" search, this is the score
@@ -77,6 +80,15 @@ type DashboardHit struct {
Explain *common.Unstructured `json:"explain,omitzero,omitempty"`
}
type ManagedBy struct {
Kind utils.ManagerKind `json:"kind"`
ID string `json:"id,omitempty"`
}
func (m ManagedBy) IsZero() bool {
return m.Kind == "" && m.ID == ""
}
// +k8s:deepcopy-gen=true
type FacetResult struct {
Field string `json:"field,omitempty"`
@@ -76,6 +76,7 @@ func (in *DashboardHit) DeepCopyInto(out *DashboardHit) {
*out = make([]string, len(*in))
copy(*out, *in)
}
out.ManagedBy = in.ManagedBy
if in.Field != nil {
in, out := &in.Field, &out.Field
*out = (*in).DeepCopy()
@@ -35,6 +35,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1.LibraryPanelList": schema_pkg_apis_dashboard_v0alpha1_LibraryPanelList(ref),
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1.LibraryPanelSpec": schema_pkg_apis_dashboard_v0alpha1_LibraryPanelSpec(ref),
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1.LibraryPanelStatus": schema_pkg_apis_dashboard_v0alpha1_LibraryPanelStatus(ref),
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1.ManagedBy": schema_pkg_apis_dashboard_v0alpha1_ManagedBy(ref),
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1.SearchResults": schema_pkg_apis_dashboard_v0alpha1_SearchResults(ref),
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1.SortBy": schema_pkg_apis_dashboard_v0alpha1_SortBy(ref),
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1.SortableField": schema_pkg_apis_dashboard_v0alpha1_SortableField(ref),
@@ -337,6 +338,13 @@ func schema_pkg_apis_dashboard_v0alpha1_DashboardHit(ref common.ReferenceCallbac
Format: "",
},
},
"managedBy": {
SchemaProps: spec.SchemaProps{
Description: "The resource is managed",
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1.ManagedBy"),
},
},
"field": {
SchemaProps: spec.SchemaProps{
Description: "Stick untyped extra fields in this object (including the sort value)",
@@ -361,7 +369,7 @@ func schema_pkg_apis_dashboard_v0alpha1_DashboardHit(ref common.ReferenceCallbac
},
},
Dependencies: []string{
"github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.Unstructured"},
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1.ManagedBy", "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.Unstructured"},
}
}
@@ -1027,6 +1035,32 @@ func schema_pkg_apis_dashboard_v0alpha1_LibraryPanelStatus(ref common.ReferenceC
}
}
func schema_pkg_apis_dashboard_v0alpha1_ManagedBy(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"kind": {
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
"id": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Format: "",
},
},
},
Required: []string{"kind"},
},
},
}
}
func schema_pkg_apis_dashboard_v0alpha1_SearchResults(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
+52
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"maps"
"strings"
"github.com/prometheus/client_golang/prometheus"
apierrors "k8s.io/apimachinery/pkg/api/errors"
@@ -640,6 +641,57 @@ func (b *DashboardsAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefiniti
func (b *DashboardsAPIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3.OpenAPI, error) {
oas.Info.Description = "Grafana dashboards as resources"
// Add dashboard hits manually
if oas.Info.Title == "dashboard.grafana.app/v0alpha1" {
defs := b.GetOpenAPIDefinitions()(func(path string) spec.Ref { return spec.Ref{} })
defsBase := "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1."
refsBase := "com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v0alpha1."
kinds := []string{"SearchResults", "DashboardHit", "ManagedBy", "FacetResult", "TermFacet", "SortBy"}
// Add any missing definitions
//-----------------------------
for _, k := range kinds {
v := defs[defsBase+k]
clean := strings.Replace(k, defsBase, refsBase, 1)
if oas.Components.Schemas[clean] == nil {
switch k {
case "SearchResults":
v.Schema.Properties["sortBy"] = *spec.RefProperty(
"#/components/schemas/SortBy")
v.Schema.Properties["hits"] = *spec.ArrayProperty(
spec.RefProperty("#/components/schemas/DashboardHit"),
)
v.Schema.Properties["facets"] = *spec.MapProperty(
spec.RefProperty("#/components/schemas/FacetResult"),
)
case "DashboardHit":
v.Schema.Properties["managedBy"] = *spec.RefProperty(
"#/components/schemas/ManagedBy")
case "FacetResult":
v.Schema.Properties["terms"] = *spec.ArrayProperty(
spec.RefProperty("#/components/schemas/TermFacet"),
)
}
oas.Components.Schemas[clean] = &v.Schema
}
}
p := oas.Paths.Paths["/apis/dashboard.grafana.app/v0alpha1/namespaces/{namespace}/search"]
p.Get.Responses.StatusCodeResponses[200] = &spec3.Response{
ResponseProps: spec3.ResponseProps{
Content: map[string]*spec3.MediaType{
"application/json": {
MediaTypeProps: spec3.MediaTypeProps{
Schema: spec.RefSchema("#/components/schemas/SearchResults"),
},
},
},
},
}
}
return oas, nil
}
+1 -1
View File
@@ -291,7 +291,7 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) {
Page: int64(page), // for modes 0-2 (legacy)
Explain: queryParams.Has("explain") && queryParams.Get("explain") != "false",
}
fields := []string{"title", "folder", "tags", "description"}
fields := []string{"title", "folder", "tags", "description", "manager.kind", "manager.id"}
if queryParams.Has("field") {
// add fields to search and exclude duplicates
for _, f := range queryParams["field"] {
+3 -3
View File
@@ -213,7 +213,7 @@ func TestSearchHandler(t *testing.T) {
if mockClient.LastSearchRequest == nil {
t.Fatalf("expected Search to be called, but it was not")
}
expectedFields := []string{"title", "folder", "tags", "description", "field1", "field2", "field3"}
expectedFields := []string{"title", "folder", "tags", "description", "manager.kind", "manager.id", "field1", "field2", "field3"}
if fmt.Sprintf("%v", mockClient.LastSearchRequest.Fields) != fmt.Sprintf("%v", expectedFields) {
t.Errorf("expected fields %v, got %v", expectedFields, mockClient.LastSearchRequest.Fields)
}
@@ -242,7 +242,7 @@ func TestSearchHandler(t *testing.T) {
if mockClient.LastSearchRequest == nil {
t.Fatalf("expected Search to be called, but it was not")
}
expectedFields := []string{"title", "folder", "tags", "description", "field1"}
expectedFields := []string{"title", "folder", "tags", "description", "manager.kind", "manager.id", "field1"}
if fmt.Sprintf("%v", mockClient.LastSearchRequest.Fields) != fmt.Sprintf("%v", expectedFields) {
t.Errorf("expected fields %v, got %v", expectedFields, mockClient.LastSearchRequest.Fields)
}
@@ -271,7 +271,7 @@ func TestSearchHandler(t *testing.T) {
if mockClient.LastSearchRequest == nil {
t.Fatalf("expected Search to be called, but it was not")
}
expectedFields := []string{"title", "folder", "tags", "description"}
expectedFields := []string{"title", "folder", "tags", "description", "manager.kind", "manager.id"}
if fmt.Sprintf("%v", mockClient.LastSearchRequest.Fields) != fmt.Sprintf("%v", expectedFields) {
t.Errorf("expected fields %v, got %v", expectedFields, mockClient.LastSearchRequest.Fields)
}
@@ -2043,13 +2043,13 @@ func (dr *DashboardServiceImpl) searchProvisionedDashboardsThroughK8s(ctx contex
dashs := make([]*dashboardProvisioningWithUID, 0)
for _, hit := range searchResults.Hits {
if utils.ParseManagerKindString(hit.Field.GetNestedString(resource.SEARCH_FIELD_MANAGER_KIND)) != utils.ManagerKindClassicFP { // nolint:staticcheck
if utils.ParseManagerKindString(string(hit.ManagedBy.Kind)) != utils.ManagerKindClassicFP { // nolint:staticcheck
continue
}
provisioning := &dashboardProvisioningWithUID{
DashboardProvisioning: dashboards.DashboardProvisioning{
Name: hit.Field.GetNestedString(resource.SEARCH_FIELD_MANAGER_ID),
Name: hit.ManagedBy.ID,
ExternalID: hit.Field.GetNestedString(resource.SEARCH_FIELD_SOURCE_PATH),
CheckSum: hit.Field.GetNestedString(resource.SEARCH_FIELD_SOURCE_CHECKSUM),
Updated: hit.Field.GetNestedInt64(resource.SEARCH_FIELD_SOURCE_TIME),
@@ -5,21 +5,24 @@ import (
"encoding/json"
"fmt"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
)
var (
excludedFields = map[string]string{
resource.SEARCH_FIELD_EXPLAIN: "",
resource.SEARCH_FIELD_SCORE: "",
resource.SEARCH_FIELD_TITLE: "",
resource.SEARCH_FIELD_FOLDER: "",
resource.SEARCH_FIELD_TAGS: "",
resource.SEARCH_FIELD_DESCRIPTION: "",
// These fields exist at the top-level of DashboardHit
standardFields = map[string]string{
resource.SEARCH_FIELD_EXPLAIN: "",
resource.SEARCH_FIELD_SCORE: "",
resource.SEARCH_FIELD_TITLE: "",
resource.SEARCH_FIELD_FOLDER: "",
resource.SEARCH_FIELD_TAGS: "",
resource.SEARCH_FIELD_DESCRIPTION: "",
resource.SEARCH_FIELD_MANAGER_ID: "",
resource.SEARCH_FIELD_MANAGER_KIND: "",
}
IncludeFields = []string{
@@ -56,6 +59,8 @@ func ParseResults(result *resourcepb.ResourceSearchResponse, offset int64) (v0al
descriptionIDX := -1
scoreIDX := -1
explainIDX := -1
managerKindIDX := -1
managerIdIDX := -1
for i, v := range result.Results.Columns {
switch v.Name {
@@ -69,6 +74,10 @@ func ParseResults(result *resourcepb.ResourceSearchResponse, offset int64) (v0al
folderIDX = i
case resource.SEARCH_FIELD_TAGS:
tagsIDX = i
case resource.SEARCH_FIELD_MANAGER_ID:
managerIdIDX = i
case resource.SEARCH_FIELD_MANAGER_KIND:
managerKindIDX = i
case resource.SEARCH_FIELD_DESCRIPTION:
descriptionIDX = i
}
@@ -89,9 +98,10 @@ func ParseResults(result *resourcepb.ResourceSearchResponse, offset int64) (v0al
return v0alpha1.SearchResults{}, fmt.Errorf("error parsing Search Response: mismatch number of columns and cells")
}
// Dynamically defined fields
fields := &common.Unstructured{}
for colIndex, col := range result.Results.Columns {
if _, ok := excludedFields[col.Name]; !ok {
if _, ok := standardFields[col.Name]; !ok {
val, err := resource.DecodeCell(col, colIndex, row.Cells[colIndex])
if err != nil {
return v0alpha1.SearchResults{}, err
@@ -122,6 +132,12 @@ func ParseResults(result *resourcepb.ResourceSearchResponse, offset int64) (v0al
if descriptionIDX >= 0 && row.Cells[descriptionIDX] != nil {
hit.Description = string(row.Cells[descriptionIDX])
}
if managerIdIDX >= 0 && row.Cells[managerIdIDX] != nil {
hit.ManagedBy.ID = string(row.Cells[managerIdIDX])
}
if managerKindIDX >= 0 && row.Cells[managerKindIDX] != nil {
hit.ManagedBy.Kind = utils.ManagerKind(row.Cells[managerKindIDX])
}
if tagsIDX >= 0 && row.Cells[tagsIDX] != nil {
_ = json.Unmarshal(row.Cells[tagsIDX], &hit.Tags)
}
@@ -276,12 +276,8 @@ func (ss *FolderUnifiedStoreImpl) GetChildren(ctx context.Context, q folder.GetC
UID: item.Name,
Title: item.Title,
ParentUID: item.Folder,
ManagedBy: item.ManagedBy.Kind,
}
if item.Field.GetNestedString(resource.SEARCH_FIELD_MANAGER_KIND) != "" {
f.ManagedBy = utils.ParseManagerKindString(item.Field.GetNestedString(resource.SEARCH_FIELD_MANAGER_KIND))
}
hits = append(hits, f)
}
@@ -1856,59 +1856,7 @@
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"totalHits",
"hits"
],
"properties": {
"apiVersion": {
"description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
"type": "string"
},
"facets": {
"description": "Facet results",
"type": "object",
"additionalProperties": {
"default": {}
}
},
"hits": {
"description": "The dashboard body (unstructured for now)",
"type": "array",
"items": {
"default": {}
}
},
"kind": {
"description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
"type": "string"
},
"maxScore": {
"description": "Max score",
"type": "number",
"format": "double"
},
"offset": {
"description": "Where the query started from",
"type": "integer",
"format": "int64"
},
"queryCost": {
"description": "Cost of running the query",
"type": "number",
"format": "double"
},
"sortBy": {
"description": "How are the results sorted"
},
"totalHits": {
"description": "The number of matching results",
"type": "integer",
"format": "int64",
"default": 0
}
}
"$ref": "#/components/schemas/SearchResults"
}
}
}
@@ -1970,6 +1918,180 @@
},
"components": {
"schemas": {
"DashboardHit": {
"type": "object",
"required": [
"resource",
"name",
"title"
],
"properties": {
"description": {
"description": "Dashboard description",
"type": "string"
},
"explain": {
"description": "Explain the score (if possible)"
},
"field": {
"description": "Stick untyped extra fields in this object (including the sort value)"
},
"folder": {
"description": "The k8s name (eg, grafana UID) for the parent folder",
"type": "string"
},
"managedBy": {
"$ref": "#/components/schemas/ManagedBy"
},
"name": {
"description": "The k8s \"name\" (eg, grafana UID)",
"type": "string",
"default": ""
},
"resource": {
"description": "Dashboard or folder",
"type": "string",
"default": ""
},
"score": {
"description": "When using \"real\" search, this is the score",
"type": "number",
"format": "double"
},
"tags": {
"description": "Filter tags",
"type": "array",
"items": {
"type": "string",
"default": ""
}
},
"title": {
"description": "The display name",
"type": "string",
"default": ""
}
}
},
"FacetResult": {
"type": "object",
"properties": {
"field": {
"type": "string"
},
"missing": {
"description": "The number of documents that do *not* have this field",
"type": "integer",
"format": "int64"
},
"terms": {
"type": "array",
"items": {
"$ref": "#/components/schemas/TermFacet"
}
},
"total": {
"description": "The distinct terms",
"type": "integer",
"format": "int64"
}
}
},
"ManagedBy": {
"type": "object",
"required": [
"kind"
],
"properties": {
"id": {
"type": "string"
},
"kind": {
"type": "string",
"default": ""
}
}
},
"SearchResults": {
"type": "object",
"required": [
"totalHits",
"hits"
],
"properties": {
"apiVersion": {
"description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
"type": "string"
},
"facets": {
"type": "object",
"additionalProperties": {
"$ref": "#/components/schemas/FacetResult"
}
},
"hits": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DashboardHit"
}
},
"kind": {
"description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
"type": "string"
},
"maxScore": {
"description": "Max score",
"type": "number",
"format": "double"
},
"offset": {
"description": "Where the query started from",
"type": "integer",
"format": "int64"
},
"queryCost": {
"description": "Cost of running the query",
"type": "number",
"format": "double"
},
"sortBy": {
"$ref": "#/components/schemas/SortBy"
},
"totalHits": {
"description": "The number of matching results",
"type": "integer",
"format": "int64",
"default": 0
}
}
},
"SortBy": {
"type": "object",
"required": [
"field"
],
"properties": {
"desc": {
"type": "boolean"
},
"field": {
"type": "string",
"default": ""
}
}
},
"TermFacet": {
"type": "object",
"properties": {
"count": {
"type": "integer",
"format": "int64"
},
"term": {
"type": "string"
}
}
},
"com.github.grafana.grafana-plugin-sdk-go.experimental.apis.data.v0alpha1.DataQuery": {
"description": "Generic query properties",
"type": "object",
@@ -26,28 +26,7 @@ const injectedRtkApi = api
overrideExisting: false,
});
export { injectedRtkApi as generatedAPI };
export type GetSearchApiResponse = /** status 200 undefined */ {
/** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */
apiVersion?: string;
/** Facet results */
facets?: {
[key: string]: any;
};
/** The dashboard body (unstructured for now) */
hits: any[];
/** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */
kind?: string;
/** Max score */
maxScore?: number;
/** Where the query started from */
offset?: number;
/** Cost of running the query */
queryCost?: number;
/** How are the results sorted */
sortBy?: any;
/** The number of matching results */
totalHits: number;
};
export type GetSearchApiResponse = /** status 200 undefined */ SearchResults;
export type GetSearchApiArg = {
/** user query string */
query?: string;
@@ -66,3 +45,63 @@ export type GetSearchApiArg = {
/** add debugging info that may help explain why the result matched */
explain?: boolean;
};
export type TermFacet = {
count?: number;
term?: string;
};
export type FacetResult = {
field?: string;
/** The number of documents that do *not* have this field */
missing?: number;
terms?: TermFacet[];
/** The distinct terms */
total?: number;
};
export type ManagedBy = {
id?: string;
kind: string;
};
export type DashboardHit = {
/** Dashboard description */
description?: string;
/** Explain the score (if possible) */
explain?: any;
/** Stick untyped extra fields in this object (including the sort value) */
field?: any;
/** The k8s name (eg, grafana UID) for the parent folder */
folder?: string;
managedBy?: ManagedBy;
/** The k8s "name" (eg, grafana UID) */
name: string;
/** Dashboard or folder */
resource: string;
/** When using "real" search, this is the score */
score?: number;
/** Filter tags */
tags?: string[];
/** The display name */
title: string;
};
export type SortBy = {
desc?: boolean;
field: string;
};
export type SearchResults = {
/** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */
apiVersion?: string;
facets?: {
[key: string]: FacetResult;
};
hits: DashboardHit[];
/** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */
kind?: string;
/** Max score */
maxScore?: number;
/** Where the query started from */
offset?: number;
/** Cost of running the query */
queryCost?: number;
sortBy?: SortBy;
/** The number of matching results */
totalHits: number;
};
@@ -6,7 +6,7 @@ import { dashboardAPIv0alpha1 } from 'app/api/clients/dashboard/v0alpha1';
import { DashboardViewItemWithUIItems, DashboardsTreeItem } from 'app/features/browse-dashboards/types';
import { useDispatch, useSelector } from 'app/types/store';
import { AnnoKeyManagerKind, ManagerKind } from '../../../features/apiserver/types';
import { ManagerKind } from '../../../features/apiserver/types';
import { PAGE_SIZE } from '../../../features/browse-dashboards/api/services';
import { getPaginationPlaceholders } from '../../../features/browse-dashboards/state/utils';
@@ -138,8 +138,8 @@ export function useFoldersQueryAppPlatform({
// query by it.
uid: name,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
managedBy: item.metadata?.annotations?.[AnnoKeyManagerKind] as ManagerKind | undefined,
parentUID: item.parentUID,
managedBy: item.managedBy?.kind as ManagerKind | undefined,
parentUID: item.folder,
},
};