mirror of
https://github.com/grafana/grafana.git
synced 2025-12-21 12:04:45 +08:00
Compare commits
15 Commits
fix/groupb
...
elasticsea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d419c33970 | ||
|
|
9b6f306956 | ||
|
|
15c93100ab | ||
|
|
ab9b070eb0 | ||
|
|
e40673b298 | ||
|
|
7ea009c7f8 | ||
|
|
fef6196195 | ||
|
|
b50cf6e067 | ||
|
|
ccdb6ff261 | ||
|
|
692712961b | ||
|
|
b2e1b257b3 | ||
|
|
598be0cf49 | ||
|
|
bad5bd627d | ||
|
|
850963c8fe | ||
|
|
5e79510352 |
6
.github/workflows/add-to-whats-new.yml
vendored
6
.github/workflows/add-to-whats-new.yml
vendored
@@ -1,11 +1,11 @@
|
||||
name: Add comment about adding a What's new note
|
||||
name: Add comment about adding a What's new note for either what's new or breaking changes
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
|
||||
jobs:
|
||||
add-comment:
|
||||
if: ${{ ! github.event.pull_request.head.repo.fork && contains(github.event.pull_request.labels.*.name, 'add to what''s new') }}
|
||||
if: ${{ ! github.event.pull_request.head.repo.fork && (contains(github.event.pull_request.labels.*.name, 'add to what''s new') || contains(github.event.pull_request.labels.*.name, 'breaking change') || contains(github.event.pull_request.labels.*.name, 'levitate breaking change')) }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
@@ -13,4 +13,4 @@ jobs:
|
||||
- uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
|
||||
with:
|
||||
message: |
|
||||
Since you've added the `Add to what's new` label, consider drafting a [What's new note](https://admin.grafana.com/content-admin/#/collections/whats-new/new) for this feature.
|
||||
Since you've added the `What's New` or a breaking change label, consider drafting a [What's new note](https://admin.grafana.com/content-admin/#/collections/whats-new/new) for this feature.
|
||||
|
||||
@@ -22,4 +22,32 @@ v0alpha1: {
|
||||
serviceaccountv0alpha1,
|
||||
externalGroupMappingv0alpha1
|
||||
]
|
||||
routes: {
|
||||
namespaced: {
|
||||
"/searchTeams": {
|
||||
"GET": {
|
||||
request: {
|
||||
query: {
|
||||
query?: string
|
||||
}
|
||||
}
|
||||
response: {
|
||||
#TeamHit: {
|
||||
name: string
|
||||
title: string
|
||||
email: string
|
||||
provisioned: bool
|
||||
externalUID: string
|
||||
}
|
||||
offset: int64
|
||||
totalHits: int64
|
||||
hits: [...#TeamHit]
|
||||
queryCost: float64
|
||||
maxScore: float64
|
||||
}
|
||||
responseMetadata: objectMeta: false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
33
apps/iam/pkg/apis/iam/v0alpha1/getsearchteams_request_params_object_gen.go
generated
Normal file
33
apps/iam/pkg/apis/iam/v0alpha1/getsearchteams_request_params_object_gen.go
generated
Normal file
@@ -0,0 +1,33 @@
|
||||
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
|
||||
|
||||
package v0alpha1
|
||||
|
||||
import (
|
||||
"github.com/grafana/grafana-app-sdk/resource"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
)
|
||||
|
||||
type GetSearchTeamsRequestParamsObject struct {
|
||||
metav1.TypeMeta `json:",inline"`
|
||||
GetSearchTeamsRequestParams `json:",inline"`
|
||||
}
|
||||
|
||||
func NewGetSearchTeamsRequestParamsObject() *GetSearchTeamsRequestParamsObject {
|
||||
return &GetSearchTeamsRequestParamsObject{}
|
||||
}
|
||||
|
||||
func (o *GetSearchTeamsRequestParamsObject) DeepCopyObject() runtime.Object {
|
||||
dst := NewGetSearchTeamsRequestParamsObject()
|
||||
o.DeepCopyInto(dst)
|
||||
return dst
|
||||
}
|
||||
|
||||
func (o *GetSearchTeamsRequestParamsObject) DeepCopyInto(dst *GetSearchTeamsRequestParamsObject) {
|
||||
dst.TypeMeta.APIVersion = o.TypeMeta.APIVersion
|
||||
dst.TypeMeta.Kind = o.TypeMeta.Kind
|
||||
dstGetSearchTeamsRequestParams := GetSearchTeamsRequestParams{}
|
||||
_ = resource.CopyObjectInto(&dstGetSearchTeamsRequestParams, &o.GetSearchTeamsRequestParams)
|
||||
}
|
||||
|
||||
var _ runtime.Object = NewGetSearchTeamsRequestParamsObject()
|
||||
12
apps/iam/pkg/apis/iam/v0alpha1/getsearchteams_request_params_types_gen.go
generated
Normal file
12
apps/iam/pkg/apis/iam/v0alpha1/getsearchteams_request_params_types_gen.go
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
|
||||
|
||||
package v0alpha1
|
||||
|
||||
type GetSearchTeamsRequestParams struct {
|
||||
Query *string `json:"query,omitempty"`
|
||||
}
|
||||
|
||||
// NewGetSearchTeamsRequestParams creates a new GetSearchTeamsRequestParams object.
|
||||
func NewGetSearchTeamsRequestParams() *GetSearchTeamsRequestParams {
|
||||
return &GetSearchTeamsRequestParams{}
|
||||
}
|
||||
33
apps/iam/pkg/apis/iam/v0alpha1/getsearchteams_response_body_types_gen.go
generated
Normal file
33
apps/iam/pkg/apis/iam/v0alpha1/getsearchteams_response_body_types_gen.go
generated
Normal file
@@ -0,0 +1,33 @@
|
||||
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
|
||||
|
||||
package v0alpha1
|
||||
|
||||
// +k8s:openapi-gen=true
|
||||
type VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit struct {
|
||||
Name string `json:"name"`
|
||||
Title string `json:"title"`
|
||||
Email string `json:"email"`
|
||||
Provisioned bool `json:"provisioned"`
|
||||
ExternalUID string `json:"externalUID"`
|
||||
}
|
||||
|
||||
// NewVersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit creates a new VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit object.
|
||||
func NewVersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit() *VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit {
|
||||
return &VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit{}
|
||||
}
|
||||
|
||||
// +k8s:openapi-gen=true
|
||||
type GetSearchTeamsBody struct {
|
||||
Offset int64 `json:"offset"`
|
||||
TotalHits int64 `json:"totalHits"`
|
||||
Hits []VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit `json:"hits"`
|
||||
QueryCost float64 `json:"queryCost"`
|
||||
MaxScore float64 `json:"maxScore"`
|
||||
}
|
||||
|
||||
// NewGetSearchTeamsBody creates a new GetSearchTeamsBody object.
|
||||
func NewGetSearchTeamsBody() *GetSearchTeamsBody {
|
||||
return &GetSearchTeamsBody{
|
||||
Hits: []VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit{},
|
||||
}
|
||||
}
|
||||
37
apps/iam/pkg/apis/iam/v0alpha1/getsearchteams_response_object_types_gen.go
generated
Normal file
37
apps/iam/pkg/apis/iam/v0alpha1/getsearchteams_response_object_types_gen.go
generated
Normal file
@@ -0,0 +1,37 @@
|
||||
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
|
||||
|
||||
package v0alpha1
|
||||
|
||||
import (
|
||||
"github.com/grafana/grafana-app-sdk/resource"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
)
|
||||
|
||||
// +k8s:openapi-gen=true
|
||||
type GetSearchTeams struct {
|
||||
metav1.TypeMeta `json:",inline"`
|
||||
GetSearchTeamsBody `json:",inline"`
|
||||
}
|
||||
|
||||
func NewGetSearchTeams() *GetSearchTeams {
|
||||
return &GetSearchTeams{}
|
||||
}
|
||||
|
||||
func (t *GetSearchTeamsBody) DeepCopyInto(dst *GetSearchTeamsBody) {
|
||||
_ = resource.CopyObjectInto(dst, t)
|
||||
}
|
||||
|
||||
func (o *GetSearchTeams) DeepCopyObject() runtime.Object {
|
||||
dst := NewGetSearchTeams()
|
||||
o.DeepCopyInto(dst)
|
||||
return dst
|
||||
}
|
||||
|
||||
func (o *GetSearchTeams) DeepCopyInto(dst *GetSearchTeams) {
|
||||
dst.TypeMeta.APIVersion = o.TypeMeta.APIVersion
|
||||
dst.TypeMeta.Kind = o.TypeMeta.Kind
|
||||
o.GetSearchTeamsBody.DeepCopyInto(&dst.GetSearchTeamsBody)
|
||||
}
|
||||
|
||||
var _ runtime.Object = NewGetSearchTeams()
|
||||
@@ -317,6 +317,7 @@ func AddAuthNKnownTypes(scheme *runtime.Scheme) error {
|
||||
&ServiceAccountList{},
|
||||
&Team{},
|
||||
&TeamList{},
|
||||
&GetSearchTeams{},
|
||||
&TeamBinding{},
|
||||
&TeamBindingList{},
|
||||
&ExternalGroupMapping{},
|
||||
|
||||
35
apps/iam/pkg/apis/iam/v0alpha1/team_search.go
Normal file
35
apps/iam/pkg/apis/iam/v0alpha1/team_search.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package v0alpha1
|
||||
|
||||
import (
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
// +k8s:deepcopy-gen=true
|
||||
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
|
||||
type TeamSearchResults struct {
|
||||
metav1.TypeMeta `json:",inline"`
|
||||
|
||||
// Where the query started from
|
||||
Offset int64 `json:"offset,omitempty"`
|
||||
|
||||
// The number of matching results
|
||||
TotalHits int64 `json:"totalHits"`
|
||||
|
||||
// The team body
|
||||
Hits []TeamHit `json:"hits"`
|
||||
|
||||
// Cost of running the query
|
||||
QueryCost float64 `json:"queryCost,omitempty"`
|
||||
|
||||
// Max score
|
||||
MaxScore float64 `json:"maxScore,omitempty"`
|
||||
}
|
||||
|
||||
// +k8s:deepcopy-gen=true
|
||||
type TeamHit struct {
|
||||
Name string `json:"name"`
|
||||
Title string `json:"title"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Provisioned bool `json:"provisioned,omitempty"`
|
||||
ExternalUID string `json:"externalUID,omitempty"`
|
||||
}
|
||||
177
apps/iam/pkg/apis/iam/v0alpha1/zz_openapi_gen.go
generated
177
apps/iam/pkg/apis/iam/v0alpha1/zz_openapi_gen.go
generated
@@ -24,6 +24,8 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA
|
||||
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.ExternalGroupMappingTeamRef": schema_pkg_apis_iam_v0alpha1_ExternalGroupMappingTeamRef(ref),
|
||||
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GetGroups": schema_pkg_apis_iam_v0alpha1_GetGroups(ref),
|
||||
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GetGroupsBody": schema_pkg_apis_iam_v0alpha1_GetGroupsBody(ref),
|
||||
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GetSearchTeams": schema_pkg_apis_iam_v0alpha1_GetSearchTeams(ref),
|
||||
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GetSearchTeamsBody": schema_pkg_apis_iam_v0alpha1_GetSearchTeamsBody(ref),
|
||||
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GlobalRole": schema_pkg_apis_iam_v0alpha1_GlobalRole(ref),
|
||||
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GlobalRoleBinding": schema_pkg_apis_iam_v0alpha1_GlobalRoleBinding(ref),
|
||||
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GlobalRoleBindingList": schema_pkg_apis_iam_v0alpha1_GlobalRoleBindingList(ref),
|
||||
@@ -80,6 +82,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA
|
||||
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.UserStatus": schema_pkg_apis_iam_v0alpha1_UserStatus(ref),
|
||||
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.UserstatusOperatorState": schema_pkg_apis_iam_v0alpha1_UserstatusOperatorState(ref),
|
||||
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.VersionsV0alpha1Kinds7RoutesGroupsGETResponseExternalGroupMapping": schema_pkg_apis_iam_v0alpha1_VersionsV0alpha1Kinds7RoutesGroupsGETResponseExternalGroupMapping(ref),
|
||||
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit": schema_pkg_apis_iam_v0alpha1_VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit(ref),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -564,6 +567,132 @@ func schema_pkg_apis_iam_v0alpha1_GetGroupsBody(ref common.ReferenceCallback) co
|
||||
}
|
||||
}
|
||||
|
||||
func schema_pkg_apis_iam_v0alpha1_GetSearchTeams(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{
|
||||
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{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"apiVersion": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
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{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"offset": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: 0,
|
||||
Type: []string{"integer"},
|
||||
Format: "int64",
|
||||
},
|
||||
},
|
||||
"totalHits": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: 0,
|
||||
Type: []string{"integer"},
|
||||
Format: "int64",
|
||||
},
|
||||
},
|
||||
"hits": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"array"},
|
||||
Items: &spec.SchemaOrArray{
|
||||
Schema: &spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: map[string]interface{}{},
|
||||
Ref: ref("github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"queryCost": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: 0,
|
||||
Type: []string{"number"},
|
||||
Format: "double",
|
||||
},
|
||||
},
|
||||
"maxScore": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: 0,
|
||||
Type: []string{"number"},
|
||||
Format: "double",
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: []string{"offset", "totalHits", "hits", "queryCost", "maxScore"},
|
||||
},
|
||||
},
|
||||
Dependencies: []string{
|
||||
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit"},
|
||||
}
|
||||
}
|
||||
|
||||
func schema_pkg_apis_iam_v0alpha1_GetSearchTeamsBody(ref common.ReferenceCallback) common.OpenAPIDefinition {
|
||||
return common.OpenAPIDefinition{
|
||||
Schema: spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"object"},
|
||||
Properties: map[string]spec.Schema{
|
||||
"offset": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: 0,
|
||||
Type: []string{"integer"},
|
||||
Format: "int64",
|
||||
},
|
||||
},
|
||||
"totalHits": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: 0,
|
||||
Type: []string{"integer"},
|
||||
Format: "int64",
|
||||
},
|
||||
},
|
||||
"hits": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"array"},
|
||||
Items: &spec.SchemaOrArray{
|
||||
Schema: &spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: map[string]interface{}{},
|
||||
Ref: ref("github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"queryCost": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: 0,
|
||||
Type: []string{"number"},
|
||||
Format: "double",
|
||||
},
|
||||
},
|
||||
"maxScore": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: 0,
|
||||
Type: []string{"number"},
|
||||
Format: "double",
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: []string{"offset", "totalHits", "hits", "queryCost", "maxScore"},
|
||||
},
|
||||
},
|
||||
Dependencies: []string{
|
||||
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit"},
|
||||
}
|
||||
}
|
||||
|
||||
func schema_pkg_apis_iam_v0alpha1_GlobalRole(ref common.ReferenceCallback) common.OpenAPIDefinition {
|
||||
return common.OpenAPIDefinition{
|
||||
Schema: spec.Schema{
|
||||
@@ -2956,3 +3085,51 @@ func schema_pkg_apis_iam_v0alpha1_VersionsV0alpha1Kinds7RoutesGroupsGETResponseE
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func schema_pkg_apis_iam_v0alpha1_VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit(ref common.ReferenceCallback) common.OpenAPIDefinition {
|
||||
return common.OpenAPIDefinition{
|
||||
Schema: spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"object"},
|
||||
Properties: map[string]spec.Schema{
|
||||
"name": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: "",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"title": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: "",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"email": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: "",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"provisioned": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: false,
|
||||
Type: []string{"boolean"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"externalUID": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: "",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: []string{"name", "title", "email", "provisioned", "externalUID"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
138
apps/iam/pkg/apis/iam_manifest.go
generated
138
apps/iam/pkg/apis/iam_manifest.go
generated
@@ -157,9 +157,139 @@ var appManifestData = app.ManifestData{
|
||||
},
|
||||
},
|
||||
Routes: app.ManifestVersionRoutes{
|
||||
Namespaced: map[string]spec3.PathProps{},
|
||||
Cluster: map[string]spec3.PathProps{},
|
||||
Schemas: map[string]spec.Schema{},
|
||||
Namespaced: map[string]spec3.PathProps{
|
||||
"/searchTeams": {
|
||||
Get: &spec3.Operation{
|
||||
OperationProps: spec3.OperationProps{
|
||||
|
||||
OperationId: "getSearchTeams",
|
||||
|
||||
Parameters: []*spec3.Parameter{
|
||||
|
||||
{
|
||||
ParameterProps: spec3.ParameterProps{
|
||||
Name: "query",
|
||||
In: "query",
|
||||
Schema: &spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Responses: &spec3.Responses{
|
||||
ResponsesProps: spec3.ResponsesProps{
|
||||
Default: &spec3.Response{
|
||||
ResponseProps: spec3.ResponseProps{
|
||||
Description: "Default OK response",
|
||||
Content: map[string]*spec3.MediaType{
|
||||
"application/json": {
|
||||
MediaTypeProps: spec3.MediaTypeProps{
|
||||
Schema: &spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"object"},
|
||||
Properties: map[string]spec.Schema{
|
||||
"apiVersion": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"string"},
|
||||
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",
|
||||
},
|
||||
},
|
||||
"hits": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"array"},
|
||||
},
|
||||
},
|
||||
"kind": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"string"},
|
||||
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",
|
||||
},
|
||||
},
|
||||
"maxScore": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"number"},
|
||||
},
|
||||
},
|
||||
"offset": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"integer"},
|
||||
},
|
||||
},
|
||||
"queryCost": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"number"},
|
||||
},
|
||||
},
|
||||
"totalHits": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"integer"},
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: []string{
|
||||
"offset",
|
||||
"totalHits",
|
||||
"hits",
|
||||
"queryCost",
|
||||
"maxScore",
|
||||
"apiVersion",
|
||||
"kind",
|
||||
},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Cluster: map[string]spec3.PathProps{},
|
||||
Schemas: map[string]spec.Schema{
|
||||
"getSearchTeamsTeamHit": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"object"},
|
||||
Properties: map[string]spec.Schema{
|
||||
"email": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"string"},
|
||||
},
|
||||
},
|
||||
"externalUID": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"string"},
|
||||
},
|
||||
},
|
||||
"name": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"string"},
|
||||
},
|
||||
},
|
||||
"provisioned": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"boolean"},
|
||||
},
|
||||
},
|
||||
"title": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: []string{
|
||||
"name",
|
||||
"title",
|
||||
"email",
|
||||
"provisioned",
|
||||
"externalUID",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -196,6 +326,8 @@ func ManifestGoTypeAssociator(kind, version string) (goType resource.Kind, exist
|
||||
|
||||
var customRouteToGoResponseType = map[string]any{
|
||||
"v0alpha1|Team|groups|GET": v0alpha1.GetGroups{},
|
||||
|
||||
"v0alpha1||<namespace>/searchTeams|GET": v0alpha1.GetSearchTeams{},
|
||||
}
|
||||
|
||||
// ManifestCustomRouteResponsesAssociator returns the associated response go type for a given kind, version, custom route path, and method, if one exists.
|
||||
|
||||
@@ -174,6 +174,8 @@ You can also override this setting in a dashboard panel under its data source op
|
||||
Frozen indices are [deprecated in Elasticsearch](https://www.elastic.co/guide/en/elasticsearch/reference/7.17/frozen-indices.html) since v7.14.
|
||||
{{< /admonition >}}
|
||||
|
||||
- **Default query mode** - Specifies which query mode the data source uses by default. Options are `Metrics`, `Logs`, `Raw data`, and `Raw document`. The default is `Metrics`.
|
||||
|
||||
### Logs
|
||||
|
||||
In this section you can configure which fields the data source uses for log messages and log levels.
|
||||
|
||||
@@ -3,6 +3,7 @@ export const addTagTypes = [
|
||||
'API Discovery',
|
||||
'Display',
|
||||
'ExternalGroupMapping',
|
||||
'Search',
|
||||
'ServiceAccount',
|
||||
'SSOSetting',
|
||||
'TeamBinding',
|
||||
@@ -152,6 +153,18 @@ const injectedRtkApi = api
|
||||
}),
|
||||
invalidatesTags: ['ExternalGroupMapping'],
|
||||
}),
|
||||
getSearchTeams: build.query<GetSearchTeamsApiResponse, GetSearchTeamsApiArg>({
|
||||
query: (queryArg) => ({
|
||||
url: `/searchTeams`,
|
||||
params: {
|
||||
query: queryArg.query,
|
||||
limit: queryArg.limit,
|
||||
offset: queryArg.offset,
|
||||
page: queryArg.page,
|
||||
},
|
||||
}),
|
||||
providesTags: ['Search'],
|
||||
}),
|
||||
listServiceAccount: build.query<ListServiceAccountApiResponse, ListServiceAccountApiArg>({
|
||||
query: (queryArg) => ({
|
||||
url: `/serviceaccounts`,
|
||||
@@ -862,6 +875,27 @@ export type UpdateExternalGroupMappingApiArg = {
|
||||
force?: boolean;
|
||||
patch: Patch;
|
||||
};
|
||||
export type GetSearchTeamsApiResponse = /** 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;
|
||||
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;
|
||||
maxScore: number;
|
||||
offset: number;
|
||||
queryCost: number;
|
||||
totalHits: number;
|
||||
};
|
||||
export type GetSearchTeamsApiArg = {
|
||||
/** team name query string */
|
||||
query?: string;
|
||||
/** limit the number of results */
|
||||
limit?: number;
|
||||
/** start the query at the given offset */
|
||||
offset?: number;
|
||||
/** page number to start from */
|
||||
page?: number;
|
||||
};
|
||||
export type ListServiceAccountApiResponse = /** status 200 OK */ ServiceAccountList;
|
||||
export type ListServiceAccountApiArg = {
|
||||
/** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */
|
||||
@@ -2084,6 +2118,8 @@ export const {
|
||||
useReplaceExternalGroupMappingMutation,
|
||||
useDeleteExternalGroupMappingMutation,
|
||||
useUpdateExternalGroupMappingMutation,
|
||||
useGetSearchTeamsQuery,
|
||||
useLazyGetSearchTeamsQuery,
|
||||
useListServiceAccountQuery,
|
||||
useLazyListServiceAccountQuery,
|
||||
useCreateServiceAccountMutation,
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
import stringHash from 'string-hash';
|
||||
import tinycolor from 'tinycolor2';
|
||||
|
||||
import { t } from '@grafana/i18n';
|
||||
|
||||
import { getContrastRatio } from '../themes/colorManipulator';
|
||||
import { GrafanaTheme2 } from '../themes/types';
|
||||
import { reduceField } from '../transformations/fieldReducer';
|
||||
@@ -30,10 +32,14 @@ export interface FieldColorMode extends RegistryItem {
|
||||
isContinuous?: boolean;
|
||||
isByValue?: boolean;
|
||||
useSeriesName?: boolean;
|
||||
group?: string;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export const fieldColorModeRegistry = new Registry<FieldColorMode>(() => {
|
||||
const accessibleGroup = t('grafana-data.field.fieldColor.accessibleGroup', 'Accessible');
|
||||
const otherGroup = t('grafana-data.field.fieldColor.otherGroup', 'Others');
|
||||
|
||||
return [
|
||||
{
|
||||
id: FieldColorModeId.Fixed,
|
||||
@@ -88,6 +94,7 @@ export const fieldColorModeRegistry = new Registry<FieldColorMode>(() => {
|
||||
isContinuous: true,
|
||||
isByValue: true,
|
||||
interpolator: interpolateViridis,
|
||||
group: accessibleGroup,
|
||||
}),
|
||||
new FieldColorSchemeMode({
|
||||
id: FieldColorModeId.ContinuousMagma,
|
||||
@@ -95,6 +102,7 @@ export const fieldColorModeRegistry = new Registry<FieldColorMode>(() => {
|
||||
isContinuous: true,
|
||||
isByValue: true,
|
||||
interpolator: interpolateMagma,
|
||||
group: accessibleGroup,
|
||||
}),
|
||||
new FieldColorSchemeMode({
|
||||
id: FieldColorModeId.ContinuousPlasma,
|
||||
@@ -102,6 +110,7 @@ export const fieldColorModeRegistry = new Registry<FieldColorMode>(() => {
|
||||
isContinuous: true,
|
||||
isByValue: true,
|
||||
interpolator: interpolatePlasma,
|
||||
group: accessibleGroup,
|
||||
}),
|
||||
new FieldColorSchemeMode({
|
||||
id: FieldColorModeId.ContinuousInferno,
|
||||
@@ -109,6 +118,7 @@ export const fieldColorModeRegistry = new Registry<FieldColorMode>(() => {
|
||||
isContinuous: true,
|
||||
isByValue: true,
|
||||
interpolator: interpolateInferno,
|
||||
group: accessibleGroup,
|
||||
}),
|
||||
new FieldColorSchemeMode({
|
||||
id: FieldColorModeId.ContinuousCividis,
|
||||
@@ -116,6 +126,7 @@ export const fieldColorModeRegistry = new Registry<FieldColorMode>(() => {
|
||||
isContinuous: true,
|
||||
isByValue: true,
|
||||
interpolator: interpolateCividis,
|
||||
group: accessibleGroup,
|
||||
}),
|
||||
new FieldColorSchemeMode({
|
||||
id: FieldColorModeId.ContinuousGrYlRd,
|
||||
@@ -123,6 +134,7 @@ export const fieldColorModeRegistry = new Registry<FieldColorMode>(() => {
|
||||
isContinuous: true,
|
||||
isByValue: true,
|
||||
getColors: (theme: GrafanaTheme2) => ['green', 'yellow', 'red'],
|
||||
group: otherGroup,
|
||||
}),
|
||||
new FieldColorSchemeMode({
|
||||
id: FieldColorModeId.ContinuousRdYlGr,
|
||||
@@ -130,6 +142,7 @@ export const fieldColorModeRegistry = new Registry<FieldColorMode>(() => {
|
||||
isContinuous: true,
|
||||
isByValue: true,
|
||||
getColors: (theme: GrafanaTheme2) => ['red', 'yellow', 'green'],
|
||||
group: otherGroup,
|
||||
}),
|
||||
new FieldColorSchemeMode({
|
||||
id: FieldColorModeId.ContinuousBlYlRd,
|
||||
@@ -137,6 +150,7 @@ export const fieldColorModeRegistry = new Registry<FieldColorMode>(() => {
|
||||
isContinuous: true,
|
||||
isByValue: true,
|
||||
getColors: (theme: GrafanaTheme2) => ['dark-blue', 'super-light-yellow', 'dark-red'],
|
||||
group: otherGroup,
|
||||
}),
|
||||
new FieldColorSchemeMode({
|
||||
id: FieldColorModeId.ContinuousYlRd,
|
||||
@@ -144,6 +158,7 @@ export const fieldColorModeRegistry = new Registry<FieldColorMode>(() => {
|
||||
isContinuous: true,
|
||||
isByValue: true,
|
||||
getColors: (theme: GrafanaTheme2) => ['super-light-yellow', 'dark-red'],
|
||||
group: otherGroup,
|
||||
}),
|
||||
new FieldColorSchemeMode({
|
||||
id: FieldColorModeId.ContinuousBlPu,
|
||||
@@ -151,6 +166,7 @@ export const fieldColorModeRegistry = new Registry<FieldColorMode>(() => {
|
||||
isContinuous: true,
|
||||
isByValue: true,
|
||||
getColors: (theme: GrafanaTheme2) => ['blue', 'purple'],
|
||||
group: otherGroup,
|
||||
}),
|
||||
new FieldColorSchemeMode({
|
||||
id: FieldColorModeId.ContinuousYlBl,
|
||||
@@ -158,6 +174,7 @@ export const fieldColorModeRegistry = new Registry<FieldColorMode>(() => {
|
||||
isContinuous: true,
|
||||
isByValue: true,
|
||||
getColors: (theme: GrafanaTheme2) => ['super-light-yellow', 'dark-blue'],
|
||||
group: otherGroup,
|
||||
}),
|
||||
new FieldColorSchemeMode({
|
||||
id: FieldColorModeId.ContinuousBlues,
|
||||
@@ -165,6 +182,7 @@ export const fieldColorModeRegistry = new Registry<FieldColorMode>(() => {
|
||||
isContinuous: true,
|
||||
isByValue: true,
|
||||
getColors: (theme: GrafanaTheme2) => ['panel-bg', 'dark-blue'],
|
||||
group: otherGroup,
|
||||
}),
|
||||
new FieldColorSchemeMode({
|
||||
id: FieldColorModeId.ContinuousReds,
|
||||
@@ -172,6 +190,7 @@ export const fieldColorModeRegistry = new Registry<FieldColorMode>(() => {
|
||||
isContinuous: true,
|
||||
isByValue: true,
|
||||
getColors: (theme: GrafanaTheme2) => ['panel-bg', 'dark-red'],
|
||||
group: otherGroup,
|
||||
}),
|
||||
new FieldColorSchemeMode({
|
||||
id: FieldColorModeId.ContinuousGreens,
|
||||
@@ -179,6 +198,7 @@ export const fieldColorModeRegistry = new Registry<FieldColorMode>(() => {
|
||||
isContinuous: true,
|
||||
isByValue: true,
|
||||
getColors: (theme: GrafanaTheme2) => ['panel-bg', 'dark-green'],
|
||||
group: otherGroup,
|
||||
}),
|
||||
new FieldColorSchemeMode({
|
||||
id: FieldColorModeId.ContinuousPurples,
|
||||
@@ -186,6 +206,7 @@ export const fieldColorModeRegistry = new Registry<FieldColorMode>(() => {
|
||||
isContinuous: true,
|
||||
isByValue: true,
|
||||
getColors: (theme: GrafanaTheme2) => ['panel-bg', 'dark-purple'],
|
||||
group: otherGroup,
|
||||
}),
|
||||
];
|
||||
});
|
||||
@@ -197,6 +218,7 @@ interface BaseFieldColorSchemeModeOptions {
|
||||
isContinuous: boolean;
|
||||
isByValue: boolean;
|
||||
useSeriesName?: boolean;
|
||||
group?: string;
|
||||
}
|
||||
|
||||
interface FieldColorSchemeModeInterpolator extends BaseFieldColorSchemeModeOptions {
|
||||
@@ -222,6 +244,7 @@ export class FieldColorSchemeMode implements FieldColorMode {
|
||||
colorCacheTheme?: GrafanaTheme2;
|
||||
interpolator?: (value: number) => string;
|
||||
getNamedColors?: (theme: GrafanaTheme2) => string[];
|
||||
group?: string;
|
||||
|
||||
constructor(options: FieldColorSchemeModeOptions) {
|
||||
this.id = options.id;
|
||||
@@ -232,6 +255,7 @@ export class FieldColorSchemeMode implements FieldColorMode {
|
||||
this.isByValue = options.isByValue;
|
||||
this.useSeriesName = options.useSeriesName;
|
||||
this.interpolator = options.interpolator;
|
||||
this.group = options.group;
|
||||
}
|
||||
|
||||
getColors(theme: GrafanaTheme2): string[] {
|
||||
|
||||
@@ -377,10 +377,14 @@ export interface FeatureToggles {
|
||||
*/
|
||||
perPanelNonApplicableDrilldowns?: boolean;
|
||||
/**
|
||||
* Enabled a group by action per panel
|
||||
* Enables a group by action per panel
|
||||
*/
|
||||
panelGroupBy?: boolean;
|
||||
/**
|
||||
* Enables filtering by grouping labels on the panel level through legend or tooltip
|
||||
*/
|
||||
perPanelFiltering?: boolean;
|
||||
/**
|
||||
* Enables use of the `systemPanelFilterVar` variable to filter panels in a dashboard
|
||||
*/
|
||||
panelFilterVariable?: boolean;
|
||||
|
||||
@@ -1490,6 +1490,16 @@ export const versionedComponents = {
|
||||
},
|
||||
},
|
||||
},
|
||||
VizTooltipFooter: {
|
||||
buttons: {
|
||||
apply: {
|
||||
['12.1.0']: 'data-testid viz-tooltip-footer-apply-filters-button',
|
||||
},
|
||||
applyInverse: {
|
||||
['12.1.0']: 'data-testid viz-tooltip-footer-apply-inverse-filters-button',
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies VersionedSelectorGroup;
|
||||
|
||||
export type VersionedComponents = typeof versionedComponents;
|
||||
|
||||
@@ -55,6 +55,15 @@ export interface PanelContext {
|
||||
*/
|
||||
onAddAdHocFilter?: (item: AdHocFilterItem) => void;
|
||||
|
||||
/**
|
||||
* Returns filters based on existing grouping or an empty array
|
||||
*/
|
||||
getFiltersBasedOnGrouping?: (items: AdHocFilterItem[]) => AdHocFilterItem[];
|
||||
/**
|
||||
*
|
||||
* Used to apply multiple filters at once
|
||||
*/
|
||||
onAddAdHocFilters?: (items: AdHocFilterItem[]) => void;
|
||||
/**
|
||||
* Enables modifying thresholds directly from the panel
|
||||
*
|
||||
|
||||
@@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event';
|
||||
import { MemoryRouter } from 'react-router-dom-v5-compat';
|
||||
|
||||
import { Field, FieldType, LinkModel } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
|
||||
import { VizTooltipFooter, AdHocFilterModel } from './VizTooltipFooter';
|
||||
|
||||
@@ -89,4 +90,65 @@ describe('VizTooltipFooter', () => {
|
||||
|
||||
expect(screen.queryByRole('button', { name: /filter for 'testValue'/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render filter by grouping buttons and fire onclick', async () => {
|
||||
const onForClick = jest.fn();
|
||||
const onOutClick = jest.fn();
|
||||
|
||||
const filterByGroupedLabels = {
|
||||
onFilterForGroupedLabels: onForClick,
|
||||
onFilterOutGroupedLabels: onOutClick,
|
||||
};
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<VizTooltipFooter dataLinks={[]} filterByGroupedLabels={filterByGroupedLabels} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const onForButton = screen.getByRole('button', { name: /Apply as filter/i });
|
||||
expect(onForButton).toBeInTheDocument();
|
||||
|
||||
const onOutButton = screen.getByRole('button', { name: /Apply as inverse filter/i });
|
||||
expect(onOutButton).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(onForButton);
|
||||
expect(onForClick).toHaveBeenCalled();
|
||||
|
||||
await userEvent.click(onOutButton);
|
||||
expect(onOutClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not render filter by grouping buttons when there are one-click links', () => {
|
||||
const filterByGroupedLabels = {
|
||||
onFilterForGroupedLabels: jest.fn(),
|
||||
onFilterOutGroupedLabels: jest.fn(),
|
||||
};
|
||||
|
||||
const onClick = jest.fn();
|
||||
const field: Field = {
|
||||
name: '',
|
||||
type: FieldType.string,
|
||||
values: [],
|
||||
config: {},
|
||||
};
|
||||
|
||||
const oneClickLink: LinkModel<Field> = {
|
||||
href: '#',
|
||||
onClick,
|
||||
title: 'One Click Link',
|
||||
origin: field,
|
||||
target: undefined,
|
||||
oneClick: true,
|
||||
};
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<VizTooltipFooter dataLinks={[oneClickLink]} filterByGroupedLabels={filterByGroupedLabels} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId(selectors.components.VizTooltipFooter.buttons.apply)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(selectors.components.VizTooltipFooter.buttons.applyInverse)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { css } from '@emotion/css';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { ActionModel, Field, GrafanaTheme2, LinkModel, ThemeSpacingTokens } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { Trans } from '@grafana/i18n';
|
||||
|
||||
import { useStyles2 } from '../../themes/ThemeContext';
|
||||
@@ -17,10 +18,16 @@ export interface AdHocFilterModel extends AdHocFilterItem {
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export interface FilterByGroupedLabelsModel {
|
||||
onFilterForGroupedLabels?: () => void;
|
||||
onFilterOutGroupedLabels?: () => void;
|
||||
}
|
||||
|
||||
interface VizTooltipFooterProps {
|
||||
dataLinks: Array<LinkModel<Field>>;
|
||||
actions?: Array<ActionModel<Field>>;
|
||||
adHocFilters?: AdHocFilterModel[];
|
||||
filterByGroupedLabels?: FilterByGroupedLabelsModel;
|
||||
annotate?: () => void;
|
||||
}
|
||||
|
||||
@@ -85,7 +92,13 @@ const renderActions = makeRenderLinksOrActions<ActionModel>(
|
||||
(item, i) => <ActionButton key={i} action={item} variant="secondary" />
|
||||
);
|
||||
|
||||
export const VizTooltipFooter = ({ dataLinks, actions = [], annotate, adHocFilters = [] }: VizTooltipFooterProps) => {
|
||||
export const VizTooltipFooter = ({
|
||||
dataLinks,
|
||||
actions = [],
|
||||
annotate,
|
||||
adHocFilters = [],
|
||||
filterByGroupedLabels,
|
||||
}: VizTooltipFooterProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const hasOneClickLink = useMemo(() => dataLinks.some((link) => link.oneClick === true), [dataLinks]);
|
||||
const hasOneClickAction = useMemo(() => actions.some((action) => action.oneClick === true), [actions]);
|
||||
@@ -105,6 +118,39 @@ export const VizTooltipFooter = ({ dataLinks, actions = [], annotate, adHocFilte
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasOneClickLink && !hasOneClickAction && filterByGroupedLabels && (
|
||||
<div className={styles.footerSection}>
|
||||
<Stack direction="column" gap={0.5} width="fit-content">
|
||||
<Button
|
||||
icon="filter"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={filterByGroupedLabels.onFilterForGroupedLabels}
|
||||
>
|
||||
<Trans
|
||||
i18nKey="grafana-ui.viz-tooltip.footer-apply-series-as-filter"
|
||||
data-testid={selectors.components.VizTooltipFooter.buttons.apply}
|
||||
>
|
||||
Apply as filter
|
||||
</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
icon="filter"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={filterByGroupedLabels.onFilterOutGroupedLabels}
|
||||
>
|
||||
<Trans
|
||||
i18nKey="grafana-ui.viz-tooltip.footer-apply-series-as-inverse-filter"
|
||||
data-testid={selectors.components.VizTooltipFooter.buttons.applyInverse}
|
||||
>
|
||||
Apply as inverse filter
|
||||
</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
</div>
|
||||
)}
|
||||
{!hasOneClickLink && !hasOneClickAction && annotate != null && (
|
||||
<div className={styles.footerSection}>
|
||||
<Button icon="comment-alt" variant="secondary" size="sm" id={ADD_ANNOTATION_ID} onClick={annotate}>
|
||||
|
||||
@@ -84,7 +84,11 @@ export { EmotionPerfTest } from '../components/ThemeDemos/EmotionPerfTest';
|
||||
export { ThemeDemo } from '../components/ThemeDemos/ThemeDemo';
|
||||
|
||||
export { VizTooltipContent } from '../components/VizTooltip/VizTooltipContent';
|
||||
export { VizTooltipFooter, type AdHocFilterModel } from '../components/VizTooltip/VizTooltipFooter';
|
||||
export {
|
||||
VizTooltipFooter,
|
||||
type AdHocFilterModel,
|
||||
type FilterByGroupedLabelsModel,
|
||||
} from '../components/VizTooltip/VizTooltipFooter';
|
||||
export { VizTooltipHeader } from '../components/VizTooltip/VizTooltipHeader';
|
||||
export { VizTooltipWrapper } from '../components/VizTooltip/VizTooltipWrapper';
|
||||
export { VizTooltipRow } from '../components/VizTooltip/VizTooltipRow';
|
||||
|
||||
@@ -51,6 +51,9 @@ func newIAMAuthorizer(accessClient authlib.AccessClient, legacyAccessClient auth
|
||||
resourceAuthorizer[iamv0.ExternalGroupMappingResourceInfo.GetName()] = authorizer
|
||||
resourceAuthorizer[iamv0.TeamResourceInfo.GetName()] = authorizer
|
||||
|
||||
serviceAuthorizer := gfauthorizer.NewServiceAuthorizer()
|
||||
resourceAuthorizer["searchTeams"] = serviceAuthorizer
|
||||
|
||||
return &iamAuthorizer{resourceAuthorizer: resourceAuthorizer}
|
||||
}
|
||||
|
||||
@@ -77,6 +80,13 @@ func newLegacyAccessClient(ac accesscontrol.AccessControl, store legacy.LegacyId
|
||||
utils.VerbList: true,
|
||||
},
|
||||
},
|
||||
accesscontrol.ResourceAuthorizerOptions{
|
||||
Resource: "searchTeams",
|
||||
Unchecked: map[string]bool{
|
||||
utils.VerbGet: true,
|
||||
utils.VerbList: true,
|
||||
},
|
||||
},
|
||||
accesscontrol.ResourceAuthorizerOptions{
|
||||
Resource: iamv0.TeamResourceInfo.GetName(),
|
||||
Attr: "id",
|
||||
|
||||
@@ -80,6 +80,7 @@ type IdentityAccessManagementAPIBuilder struct {
|
||||
dual dualwrite.Service
|
||||
unified resource.ResourceClient
|
||||
userSearchClient resourcepb.ResourceIndexClient
|
||||
teamSearch *TeamSearchHandler
|
||||
|
||||
teamGroupsHandler externalgroupmapping.TeamGroupsHandler
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
iamauthorizer "github.com/grafana/grafana/pkg/registry/apis/iam/authorizer"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/iam/externalgroupmapping"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/iam/legacy"
|
||||
@@ -46,7 +47,9 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/authz/zanzana"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/ssosettings"
|
||||
teamservice "github.com/grafana/grafana/pkg/services/team"
|
||||
legacyuser "github.com/grafana/grafana/pkg/services/user"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/storage/legacysql"
|
||||
"github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/apistore"
|
||||
@@ -56,6 +59,7 @@ import (
|
||||
const MaxConcurrentZanzanaWrites = 20
|
||||
|
||||
func RegisterAPIService(
|
||||
cfg *setting.Cfg,
|
||||
features featuremgmt.FeatureToggles,
|
||||
apiregistration builder.APIRegistrar,
|
||||
ssoService ssosettings.Service,
|
||||
@@ -66,12 +70,14 @@ func RegisterAPIService(
|
||||
reg prometheus.Registerer,
|
||||
coreRolesStorage CoreRoleStorageBackend,
|
||||
rolesStorage RoleStorageBackend,
|
||||
tracing *tracing.TracingService,
|
||||
roleBindingsStorage RoleBindingStorageBackend,
|
||||
externalGroupMappingStorageBackend ExternalGroupMappingStorageBackend,
|
||||
teamGroupsHandlerImpl externalgroupmapping.TeamGroupsHandler,
|
||||
dual dualwrite.Service,
|
||||
unified resource.ResourceClient,
|
||||
userService legacyuser.Service,
|
||||
teamService teamservice.Service,
|
||||
) (*IdentityAccessManagementAPIBuilder, error) {
|
||||
dbProvider := legacysql.NewDatabaseProvider(sql)
|
||||
store := legacy.NewLegacySQLStores(dbProvider)
|
||||
@@ -109,6 +115,7 @@ func RegisterAPIService(
|
||||
unified: unified,
|
||||
userSearchClient: resource.NewSearchClient(dualwrite.NewSearchAdapter(dual), iamv0.UserResourceInfo.GroupResource(),
|
||||
unified, user.NewUserLegacySearchClient(userService), features),
|
||||
teamSearch: NewTeamSearchHandler(tracing, dual, team.NewLegacyTeamSearchClient(teamService), unified, features),
|
||||
}
|
||||
apiregistration.RegisterAPI(builder)
|
||||
|
||||
@@ -502,7 +509,11 @@ func (b *IdentityAccessManagementAPIBuilder) PostProcessOpenAPI(oas *spec3.OpenA
|
||||
|
||||
func (b *IdentityAccessManagementAPIBuilder) GetAPIRoutes(gv schema.GroupVersion) *builder.APIRoutes {
|
||||
defs := b.GetOpenAPIDefinitions()(func(path string) spec.Ref { return spec.Ref{} })
|
||||
return b.display.GetAPIRoutes(defs)
|
||||
|
||||
routes := b.teamSearch.GetAPIRoutes(defs)
|
||||
routes.Namespace = append(routes.Namespace, b.display.GetAPIRoutes(defs).Namespace...)
|
||||
|
||||
return routes
|
||||
}
|
||||
|
||||
func (b *IdentityAccessManagementAPIBuilder) GetAuthorizer() authorizer.Authorizer {
|
||||
|
||||
141
pkg/registry/apis/iam/team/legacy_search.go
Normal file
141
pkg/registry/apis/iam/team/legacy_search.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package team
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/services/team"
|
||||
res "github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/search/builders"
|
||||
)
|
||||
|
||||
const (
|
||||
TeamResource = "teams"
|
||||
TeamResourceGroup = "iam.grafana.com"
|
||||
)
|
||||
|
||||
// LegacyTeamSearchClient is a client for searching for teams in the legacy search engine.
|
||||
type LegacyTeamSearchClient struct {
|
||||
resourcepb.ResourceIndexClient
|
||||
teamService team.Service
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewLegacyTeamSearchClient creates a new LegacyTeamSearchClient.
|
||||
func NewLegacyTeamSearchClient(teamService team.Service) *LegacyTeamSearchClient {
|
||||
return &LegacyTeamSearchClient{
|
||||
teamService: teamService,
|
||||
log: slog.Default().With("logger", "legacy-team-search-client"),
|
||||
}
|
||||
}
|
||||
|
||||
// Search searches for teams in the legacy search engine.
|
||||
func (c *LegacyTeamSearchClient) Search(ctx context.Context, req *resourcepb.ResourceSearchRequest, _ ...grpc.CallOption) (*resourcepb.ResourceSearchResponse, error) {
|
||||
signedInUser, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if req.Limit > 100 {
|
||||
req.Limit = 100
|
||||
}
|
||||
if req.Limit <= 0 {
|
||||
req.Limit = 1
|
||||
}
|
||||
|
||||
if req.Page > math.MaxInt32 || req.Page < 0 {
|
||||
return nil, fmt.Errorf("invalid page number: %d", req.Page)
|
||||
}
|
||||
|
||||
query := &team.SearchTeamsQuery{
|
||||
SignedInUser: signedInUser,
|
||||
Limit: int(req.Limit),
|
||||
Page: int(req.Page),
|
||||
Query: req.Query,
|
||||
OrgID: signedInUser.GetOrgID(),
|
||||
}
|
||||
|
||||
res, err := c.teamService.SearchTeams(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
columns := getColumns(req.Fields)
|
||||
list := &resourcepb.ResourceSearchResponse{
|
||||
Results: &resourcepb.ResourceTable{
|
||||
Columns: columns,
|
||||
},
|
||||
}
|
||||
|
||||
namespace := signedInUser.GetNamespace()
|
||||
|
||||
for _, t := range res.Teams {
|
||||
cells := createCells(t, req.Fields)
|
||||
list.Results.Rows = append(list.Results.Rows, &resourcepb.ResourceTableRow{
|
||||
Key: getResourceKey(t, namespace),
|
||||
Cells: cells,
|
||||
})
|
||||
}
|
||||
|
||||
list.TotalHits = res.TotalCount
|
||||
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func getResourceKey(t *team.TeamDTO, namespace string) *resourcepb.ResourceKey {
|
||||
return &resourcepb.ResourceKey{
|
||||
Namespace: namespace,
|
||||
Group: TeamResourceGroup,
|
||||
Resource: TeamResource,
|
||||
Name: t.UID,
|
||||
}
|
||||
}
|
||||
|
||||
func getColumns(fields []string) []*resourcepb.ResourceTableColumnDefinition {
|
||||
columns := getDefaultColumns()
|
||||
|
||||
for _, field := range fields {
|
||||
if col, ok := builders.TeamSearchTableColumnDefinitions[field]; ok {
|
||||
columns = append(columns, col)
|
||||
}
|
||||
}
|
||||
|
||||
return columns
|
||||
}
|
||||
|
||||
func getDefaultColumns() []*resourcepb.ResourceTableColumnDefinition {
|
||||
searchFields := res.StandardSearchFields()
|
||||
return []*resourcepb.ResourceTableColumnDefinition{
|
||||
searchFields.Field(res.SEARCH_FIELD_NAME),
|
||||
searchFields.Field(res.SEARCH_FIELD_TITLE),
|
||||
}
|
||||
}
|
||||
|
||||
func createCells(t *team.TeamDTO, fields []string) [][]byte {
|
||||
cells := createDefaultCells(t)
|
||||
for _, field := range fields {
|
||||
switch field {
|
||||
case builders.TEAM_SEARCH_EMAIL:
|
||||
cells = append(cells, []byte(t.Email))
|
||||
case builders.TEAM_SEARCH_PROVISIONED:
|
||||
cells = append(cells, []byte(strconv.FormatBool(t.IsProvisioned)))
|
||||
case builders.TEAM_SEARCH_EXTERNAL_UID:
|
||||
cells = append(cells, []byte(t.ExternalUID))
|
||||
}
|
||||
}
|
||||
return cells
|
||||
}
|
||||
|
||||
func createDefaultCells(t *team.TeamDTO) [][]byte {
|
||||
return [][]byte{
|
||||
[]byte(t.UID),
|
||||
[]byte(t.Name),
|
||||
}
|
||||
}
|
||||
107
pkg/registry/apis/iam/team/legacy_search_test.go
Normal file
107
pkg/registry/apis/iam/team/legacy_search_test.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package team
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/services/team"
|
||||
"github.com/grafana/grafana/pkg/services/team/teamtest"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
|
||||
)
|
||||
|
||||
func TestLegacyTeamSearchClient_Search(t *testing.T) {
|
||||
t.Run("search by query", func(t *testing.T) {
|
||||
mockTeamService := teamtest.NewFakeService()
|
||||
client := NewLegacyTeamSearchClient(mockTeamService)
|
||||
|
||||
ctx := identity.WithRequester(context.Background(), &user.SignedInUser{OrgID: 1, UserID: 1, Namespace: "default"})
|
||||
req := &resourcepb.ResourceSearchRequest{
|
||||
Limit: 10,
|
||||
Page: 1,
|
||||
Query: "test",
|
||||
Fields: []string{"name", "email", "provisioned", "externalUID"},
|
||||
}
|
||||
|
||||
mockTeamService.ExpectedSearchTeamsResult = team.SearchTeamQueryResult{
|
||||
Teams: []*team.TeamDTO{
|
||||
{
|
||||
UID: "testTeamUID",
|
||||
Name: "test team",
|
||||
Email: "test@example.com",
|
||||
IsProvisioned: true,
|
||||
ExternalUID: "testExternalUID",
|
||||
},
|
||||
},
|
||||
TotalCount: 1,
|
||||
Page: 1,
|
||||
PerPage: 10,
|
||||
}
|
||||
|
||||
resp, err := client.Search(ctx, req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), resp.TotalHits)
|
||||
require.Len(t, resp.Results.Rows, 1)
|
||||
require.Len(t, resp.Results.Columns, 5)
|
||||
require.Equal(t, "default", resp.Results.Rows[0].Key.Namespace)
|
||||
require.Equal(t, "iam.grafana.com", resp.Results.Rows[0].Key.Group)
|
||||
require.Equal(t, "teams", resp.Results.Rows[0].Key.Resource)
|
||||
require.Equal(t, "testTeamUID", resp.Results.Rows[0].Key.Name)
|
||||
require.Equal(t, "testTeamUID", string(resp.Results.Rows[0].Cells[0]))
|
||||
require.Equal(t, "test team", string(resp.Results.Rows[0].Cells[1]))
|
||||
require.Equal(t, "test@example.com", string(resp.Results.Rows[0].Cells[2]))
|
||||
require.Equal(t, "true", string(resp.Results.Rows[0].Cells[3]))
|
||||
require.Equal(t, "testExternalUID", string(resp.Results.Rows[0].Cells[4]))
|
||||
})
|
||||
|
||||
t.Run("returns error if page is negative", func(t *testing.T) {
|
||||
mockTeamService := teamtest.NewFakeService()
|
||||
client := NewLegacyTeamSearchClient(mockTeamService)
|
||||
ctx := identity.WithRequester(context.Background(), &user.SignedInUser{OrgID: 1, UserID: 1, Namespace: "default"})
|
||||
req := &resourcepb.ResourceSearchRequest{
|
||||
Limit: 10,
|
||||
Page: -1,
|
||||
}
|
||||
|
||||
_, err := client.Search(ctx, req)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "invalid page number: -1", err.Error())
|
||||
})
|
||||
|
||||
t.Run("returns error if page is greater than math.MaxInt32", func(t *testing.T) {
|
||||
mockTeamService := teamtest.NewFakeService()
|
||||
client := NewLegacyTeamSearchClient(mockTeamService)
|
||||
ctx := identity.WithRequester(context.Background(), &user.SignedInUser{OrgID: 1, UserID: 1, Namespace: "default"})
|
||||
req := &resourcepb.ResourceSearchRequest{
|
||||
Limit: 10,
|
||||
Page: math.MaxInt32 + 1,
|
||||
}
|
||||
|
||||
_, err := client.Search(ctx, req)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "invalid page number: 2147483648", err.Error())
|
||||
})
|
||||
|
||||
t.Run("returns error if search teams fails", func(t *testing.T) {
|
||||
mockTeamService := teamtest.NewFakeService()
|
||||
client := NewLegacyTeamSearchClient(mockTeamService)
|
||||
ctx := identity.WithRequester(context.Background(), &user.SignedInUser{OrgID: 1, UserID: 1, Namespace: "default"})
|
||||
req := &resourcepb.ResourceSearchRequest{
|
||||
Limit: 10,
|
||||
Page: 1,
|
||||
Query: "test",
|
||||
}
|
||||
|
||||
mockTeamService.ExpectedError = errors.New("search teams failed")
|
||||
|
||||
_, err := client.Search(ctx, req)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "search teams failed", err.Error())
|
||||
})
|
||||
}
|
||||
192
pkg/registry/apis/iam/team_search.go
Normal file
192
pkg/registry/apis/iam/team_search.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package iam
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
common "k8s.io/kube-openapi/pkg/common"
|
||||
"k8s.io/kube-openapi/pkg/spec3"
|
||||
"k8s.io/kube-openapi/pkg/validation/spec"
|
||||
|
||||
iamv0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/builder"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
teamsearch "github.com/grafana/grafana/pkg/services/team/search"
|
||||
"github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/search/builders"
|
||||
"github.com/grafana/grafana/pkg/util/errhttp"
|
||||
)
|
||||
|
||||
type TeamSearchHandler struct {
|
||||
log log.Logger
|
||||
client resourcepb.ResourceIndexClient
|
||||
tracer trace.Tracer
|
||||
features featuremgmt.FeatureToggles
|
||||
}
|
||||
|
||||
func NewTeamSearchHandler(tracer trace.Tracer, dual dualwrite.Service, legacyTeamSearcher resourcepb.ResourceIndexClient, resourceClient resource.ResourceClient, features featuremgmt.FeatureToggles) *TeamSearchHandler {
|
||||
searchClient := resource.NewSearchClient(dualwrite.NewSearchAdapter(dual), iamv0alpha1.TeamResourceInfo.GroupResource(), resourceClient, legacyTeamSearcher, features)
|
||||
|
||||
return &TeamSearchHandler{
|
||||
client: searchClient,
|
||||
log: log.New("grafana-apiserver.teams.search"),
|
||||
tracer: tracer,
|
||||
features: features,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TeamSearchHandler) GetAPIRoutes(defs map[string]common.OpenAPIDefinition) *builder.APIRoutes {
|
||||
searchResults := defs["github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GetSearchTeams"].Schema
|
||||
|
||||
return &builder.APIRoutes{
|
||||
Namespace: []builder.APIRouteHandler{
|
||||
{
|
||||
Path: "searchTeams",
|
||||
Spec: &spec3.PathProps{
|
||||
Get: &spec3.Operation{
|
||||
OperationProps: spec3.OperationProps{
|
||||
Tags: []string{"Search"},
|
||||
Description: "Team search",
|
||||
Parameters: []*spec3.Parameter{
|
||||
{
|
||||
ParameterProps: spec3.ParameterProps{
|
||||
Name: "namespace",
|
||||
In: "path",
|
||||
Required: true,
|
||||
Example: "default",
|
||||
Description: "workspace",
|
||||
Schema: spec.StringProperty(),
|
||||
},
|
||||
},
|
||||
{
|
||||
ParameterProps: spec3.ParameterProps{
|
||||
Name: "query",
|
||||
In: "query",
|
||||
Description: "team name query string",
|
||||
Required: false,
|
||||
Schema: spec.StringProperty(),
|
||||
},
|
||||
},
|
||||
{
|
||||
ParameterProps: spec3.ParameterProps{
|
||||
Name: "limit",
|
||||
In: "query",
|
||||
Description: "limit the number of results",
|
||||
Required: false,
|
||||
Schema: spec.Int64Property(),
|
||||
},
|
||||
},
|
||||
{
|
||||
ParameterProps: spec3.ParameterProps{
|
||||
Name: "offset",
|
||||
In: "query",
|
||||
Description: "start the query at the given offset",
|
||||
Required: false,
|
||||
Schema: spec.Int64Property(),
|
||||
},
|
||||
},
|
||||
{
|
||||
ParameterProps: spec3.ParameterProps{
|
||||
Name: "page",
|
||||
In: "query",
|
||||
Description: "page number to start from",
|
||||
Required: false,
|
||||
Schema: spec.Int64Property(),
|
||||
},
|
||||
},
|
||||
},
|
||||
Responses: &spec3.Responses{
|
||||
ResponsesProps: spec3.ResponsesProps{
|
||||
StatusCodeResponses: map[int]*spec3.Response{
|
||||
200: {
|
||||
ResponseProps: spec3.ResponseProps{
|
||||
Content: map[string]*spec3.MediaType{
|
||||
"application/json": {
|
||||
MediaTypeProps: spec3.MediaTypeProps{
|
||||
Schema: &searchResults,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Handler: s.DoTeamSearch,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TeamSearchHandler) DoTeamSearch(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, span := s.tracer.Start(r.Context(), "team.search")
|
||||
defer span.End()
|
||||
|
||||
queryParams, err := url.ParseQuery(r.URL.RawQuery)
|
||||
if err != nil {
|
||||
errhttp.Write(ctx, err, w)
|
||||
return
|
||||
}
|
||||
|
||||
limit := 50
|
||||
offset := 0
|
||||
page := 1
|
||||
if queryParams.Has("limit") {
|
||||
limit, _ = strconv.Atoi(queryParams.Get("limit"))
|
||||
}
|
||||
if queryParams.Has("offset") {
|
||||
offset, _ = strconv.Atoi(queryParams.Get("offset"))
|
||||
if offset > 0 {
|
||||
page = (offset / limit) + 1
|
||||
}
|
||||
} else if queryParams.Has("page") {
|
||||
page, _ = strconv.Atoi(queryParams.Get("page"))
|
||||
offset = (page - 1) * limit
|
||||
}
|
||||
|
||||
searchRequest := &resourcepb.ResourceSearchRequest{
|
||||
Options: &resourcepb.ListOptions{},
|
||||
Query: queryParams.Get("query"),
|
||||
Limit: int64(limit),
|
||||
Offset: int64(offset),
|
||||
Page: int64(page),
|
||||
Explain: queryParams.Has("explain") && queryParams.Get("explain") != "false",
|
||||
Fields: []string{
|
||||
builders.TEAM_SEARCH_EMAIL,
|
||||
builders.TEAM_SEARCH_PROVISIONED,
|
||||
builders.TEAM_SEARCH_EXTERNAL_UID,
|
||||
},
|
||||
}
|
||||
|
||||
result, err := s.client.Search(ctx, searchRequest)
|
||||
if err != nil {
|
||||
errhttp.Write(ctx, err, w)
|
||||
return
|
||||
}
|
||||
|
||||
searchResults, err := teamsearch.ParseResults(result, searchRequest.Offset)
|
||||
if err != nil {
|
||||
errhttp.Write(ctx, err, w)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.write(w, searchResults); err != nil {
|
||||
s.log.Error("failed to write team search results", "error", err)
|
||||
errhttp.Write(ctx, err, w)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TeamSearchHandler) write(w http.ResponseWriter, obj any) error {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
return json.NewEncoder(w).Encode(obj)
|
||||
}
|
||||
286
pkg/registry/apis/iam/team_search_test.go
Normal file
286
pkg/registry/apis/iam/team_search_test.go
Normal file
@@ -0,0 +1,286 @@
|
||||
package iam
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
|
||||
)
|
||||
|
||||
func TestTeamSearchFallback(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
mode rest.DualWriterMode
|
||||
expectedLegacyCalled bool
|
||||
expectedUnifiedCalled bool
|
||||
}{
|
||||
{name: "mode 0", mode: rest.Mode0, expectedLegacyCalled: true, expectedUnifiedCalled: false},
|
||||
{name: "mode 1", mode: rest.Mode1, expectedLegacyCalled: true, expectedUnifiedCalled: false},
|
||||
{name: "mode 2", mode: rest.Mode2, expectedLegacyCalled: true, expectedUnifiedCalled: false},
|
||||
{name: "mode 3", mode: rest.Mode3, expectedLegacyCalled: false, expectedUnifiedCalled: true},
|
||||
{name: "mode 4", mode: rest.Mode4, expectedLegacyCalled: false, expectedUnifiedCalled: true},
|
||||
{name: "mode 5", mode: rest.Mode5, expectedLegacyCalled: false, expectedUnifiedCalled: true},
|
||||
}
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
mockClient := &MockClient{}
|
||||
mockLegacyClient := &MockClient{}
|
||||
|
||||
cfg := &setting.Cfg{
|
||||
UnifiedStorage: map[string]setting.UnifiedStorageConfig{
|
||||
"teams.iam.grafana.app": {DualWriterMode: testCase.mode},
|
||||
},
|
||||
}
|
||||
dual := dualwrite.ProvideStaticServiceForTests(cfg)
|
||||
searchHandler := NewTeamSearchHandler(tracing.NewNoopTracerService(), dual, mockLegacyClient, mockClient, nil)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/teams/search", nil)
|
||||
req.Header.Add("content-type", "application/json")
|
||||
req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"}))
|
||||
|
||||
searchHandler.DoTeamSearch(rr, req)
|
||||
|
||||
if !testCase.expectedUnifiedCalled && mockClient.LastSearchRequest != nil {
|
||||
t.Fatalf("expected Unified Search NOT to be called, but it was")
|
||||
}
|
||||
if testCase.expectedLegacyCalled && mockLegacyClient.LastSearchRequest == nil {
|
||||
t.Fatalf("expected Legacy Search to be called, but it was not")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTeamSearchHandler(t *testing.T) {
|
||||
t.Run("search using default team search fields", func(t *testing.T) {
|
||||
mockClient := &MockClient{}
|
||||
|
||||
features := featuremgmt.WithFeatures()
|
||||
searchHandler := TeamSearchHandler{
|
||||
log: log.New("grafana-apiserver.teams.search"),
|
||||
client: mockClient,
|
||||
tracer: tracing.NewNoopTracerService(),
|
||||
features: features,
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/teams/search", nil)
|
||||
req.Header.Add("content-type", "application/json")
|
||||
req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"}))
|
||||
|
||||
searchHandler.DoTeamSearch(rr, req)
|
||||
|
||||
if mockClient.LastSearchRequest == nil {
|
||||
t.Fatalf("expected Search to be called, but it was not")
|
||||
}
|
||||
expectedFields := []string{"email", "provisioned", "externalUID"}
|
||||
if fmt.Sprintf("%v", mockClient.LastSearchRequest.Fields) != fmt.Sprintf("%v", expectedFields) {
|
||||
t.Errorf("expected fields %v, got %v", expectedFields, mockClient.LastSearchRequest.Fields)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns error if search fails", func(t *testing.T) {
|
||||
mockClient := &MockClient{
|
||||
MockError: errors.New("search failed"),
|
||||
}
|
||||
|
||||
features := featuremgmt.WithFeatures()
|
||||
searchHandler := TeamSearchHandler{
|
||||
log: log.New("grafana-apiserver.teams.search"),
|
||||
client: mockClient,
|
||||
tracer: tracing.NewNoopTracerService(),
|
||||
features: features,
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/teams/search?query=test", nil)
|
||||
req.Header.Add("content-type", "application/json")
|
||||
req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"}))
|
||||
|
||||
searchHandler.DoTeamSearch(rr, req)
|
||||
|
||||
if rr.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected StatusInternalServerError, got %d", rr.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("should calculate offset and page parameters", func(t *testing.T) {
|
||||
limit := 50
|
||||
for i, tt := range []struct {
|
||||
offset int
|
||||
page int
|
||||
expectedOffset int
|
||||
expectedPage int
|
||||
}{
|
||||
{
|
||||
offset: 0,
|
||||
page: 0,
|
||||
expectedOffset: 0,
|
||||
expectedPage: 1,
|
||||
},
|
||||
{
|
||||
offset: 0,
|
||||
page: 1,
|
||||
expectedOffset: 0,
|
||||
expectedPage: 1,
|
||||
},
|
||||
{
|
||||
offset: 0,
|
||||
page: 2,
|
||||
expectedOffset: 50,
|
||||
expectedPage: 2,
|
||||
},
|
||||
{
|
||||
offset: 0,
|
||||
page: 3,
|
||||
expectedOffset: 100,
|
||||
expectedPage: 3,
|
||||
},
|
||||
{
|
||||
offset: 50,
|
||||
page: 0,
|
||||
expectedOffset: 50,
|
||||
expectedPage: 2,
|
||||
},
|
||||
{
|
||||
offset: 100,
|
||||
page: 0,
|
||||
expectedOffset: 100,
|
||||
expectedPage: 3,
|
||||
},
|
||||
{
|
||||
offset: 149,
|
||||
page: 0,
|
||||
expectedOffset: 149,
|
||||
expectedPage: 3,
|
||||
},
|
||||
{
|
||||
offset: 150,
|
||||
page: 0,
|
||||
expectedOffset: 150,
|
||||
expectedPage: 4,
|
||||
},
|
||||
} {
|
||||
mockClient := &MockClient{}
|
||||
|
||||
cfg := &setting.Cfg{
|
||||
UnifiedStorage: map[string]setting.UnifiedStorageConfig{
|
||||
"teams.iam.grafana.app": {DualWriterMode: rest.Mode0},
|
||||
},
|
||||
}
|
||||
dual := dualwrite.ProvideStaticServiceForTests(cfg)
|
||||
searchHandler := NewTeamSearchHandler(tracing.NewNoopTracerService(), dual, mockClient, mockClient, nil)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
endpoint := fmt.Sprintf("/teams/search?limit=%d", limit)
|
||||
if tt.offset > 0 {
|
||||
endpoint = fmt.Sprintf("%s&offset=%d", endpoint, tt.offset)
|
||||
}
|
||||
if tt.page > 0 {
|
||||
endpoint = fmt.Sprintf("%s&page=%d", endpoint, tt.page)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", endpoint, nil)
|
||||
req.Header.Add("content-type", "application/json")
|
||||
req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"}))
|
||||
|
||||
searchHandler.DoTeamSearch(rr, req)
|
||||
|
||||
if mockClient.LastSearchRequest == nil {
|
||||
t.Fatalf("expected Team Search to be called, but it was not")
|
||||
}
|
||||
|
||||
require.Equal(t, tt.expectedOffset, int(mockClient.LastSearchRequest.Offset), fmt.Sprintf("mismatch offset in test %d", i))
|
||||
require.Equal(t, tt.expectedPage, int(mockClient.LastSearchRequest.Page), fmt.Sprintf("mismatch page in test %d", i))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type MockClient struct {
|
||||
resourcepb.ResourceIndexClient
|
||||
resource.ResourceIndex
|
||||
|
||||
// Capture the last SearchRequest for assertions
|
||||
LastSearchRequest *resourcepb.ResourceSearchRequest
|
||||
|
||||
MockResponses []*resourcepb.ResourceSearchResponse
|
||||
MockError error
|
||||
MockCalls []*resourcepb.ResourceSearchRequest
|
||||
CallCount int
|
||||
}
|
||||
|
||||
func (m *MockClient) Search(ctx context.Context, in *resourcepb.ResourceSearchRequest, opts ...grpc.CallOption) (*resourcepb.ResourceSearchResponse, error) {
|
||||
if m.MockError != nil {
|
||||
return nil, m.MockError
|
||||
}
|
||||
|
||||
m.LastSearchRequest = in
|
||||
m.MockCalls = append(m.MockCalls, in)
|
||||
|
||||
var response *resourcepb.ResourceSearchResponse
|
||||
if m.CallCount < len(m.MockResponses) {
|
||||
response = m.MockResponses[m.CallCount]
|
||||
}
|
||||
|
||||
m.CallCount = m.CallCount + 1
|
||||
|
||||
return response, nil
|
||||
}
|
||||
func (m *MockClient) GetStats(ctx context.Context, in *resourcepb.ResourceStatsRequest, opts ...grpc.CallOption) (*resourcepb.ResourceStatsResponse, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *MockClient) CountManagedObjects(ctx context.Context, in *resourcepb.CountManagedObjectsRequest, opts ...grpc.CallOption) (*resourcepb.CountManagedObjectsResponse, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *MockClient) Watch(ctx context.Context, in *resourcepb.WatchRequest, opts ...grpc.CallOption) (resourcepb.ResourceStore_WatchClient, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *MockClient) Delete(ctx context.Context, in *resourcepb.DeleteRequest, opts ...grpc.CallOption) (*resourcepb.DeleteResponse, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *MockClient) Create(ctx context.Context, in *resourcepb.CreateRequest, opts ...grpc.CallOption) (*resourcepb.CreateResponse, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *MockClient) Update(ctx context.Context, in *resourcepb.UpdateRequest, opts ...grpc.CallOption) (*resourcepb.UpdateResponse, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *MockClient) Read(ctx context.Context, in *resourcepb.ReadRequest, opts ...grpc.CallOption) (*resourcepb.ReadResponse, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *MockClient) GetBlob(ctx context.Context, in *resourcepb.GetBlobRequest, opts ...grpc.CallOption) (*resourcepb.GetBlobResponse, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *MockClient) PutBlob(ctx context.Context, in *resourcepb.PutBlobRequest, opts ...grpc.CallOption) (*resourcepb.PutBlobResponse, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *MockClient) List(ctx context.Context, in *resourcepb.ListRequest, opts ...grpc.CallOption) (*resourcepb.ListResponse, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *MockClient) ListManagedObjects(ctx context.Context, in *resourcepb.ListManagedObjectsRequest, opts ...grpc.CallOption) (*resourcepb.ListManagedObjectsResponse, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *MockClient) IsHealthy(ctx context.Context, in *resourcepb.HealthCheckRequest, opts ...grpc.CallOption) (*resourcepb.HealthCheckResponse, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *MockClient) BulkProcess(ctx context.Context, opts ...grpc.CallOption) (resourcepb.BulkStore_BulkProcessClient, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *MockClient) UpdateIndex(ctx context.Context, reason string) error {
|
||||
return nil
|
||||
}
|
||||
@@ -338,8 +338,8 @@ func prepareQuery(
|
||||
}, nil
|
||||
}
|
||||
|
||||
func handlePreparedQuery(ctx context.Context, pq *preparedQuery) (*backend.QueryDataResponse, error) {
|
||||
resp, err := service.QueryData(ctx, pq.logger, pq.cache, pq.exprSvc, pq.mReq, pq.builder, pq.headers)
|
||||
func handlePreparedQuery(ctx context.Context, pq *preparedQuery, concurrentQueryLimit int) (*backend.QueryDataResponse, error) {
|
||||
resp, err := service.QueryData(ctx, pq.logger, pq.cache, pq.exprSvc, pq.mReq, pq.builder, pq.headers, concurrentQueryLimit)
|
||||
pq.reportMetrics()
|
||||
return resp, err
|
||||
}
|
||||
@@ -357,7 +357,7 @@ func handleQuery(
|
||||
responder.Error(err)
|
||||
return nil, err
|
||||
}
|
||||
return handlePreparedQuery(ctx, pq)
|
||||
return handlePreparedQuery(ctx, pq, b.concurrentQueryLimit)
|
||||
}
|
||||
|
||||
type responderWrapper struct {
|
||||
|
||||
@@ -3,10 +3,11 @@ package query
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"runtime"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
apiruntime "k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
@@ -62,6 +63,7 @@ func NewQueryAPIBuilder(
|
||||
tracer tracing.Tracer,
|
||||
legacyDatasourceLookup service.LegacyDataSourceLookup,
|
||||
connections DataSourceConnectionProvider,
|
||||
concurrentQueryLimit int,
|
||||
) (*QueryAPIBuilder, error) {
|
||||
// Include well typed query definitions
|
||||
var queryTypes *query.QueryTypeDefinitionList
|
||||
@@ -80,7 +82,7 @@ func NewQueryAPIBuilder(
|
||||
}
|
||||
|
||||
return &QueryAPIBuilder{
|
||||
concurrentQueryLimit: 4,
|
||||
concurrentQueryLimit: concurrentQueryLimit,
|
||||
log: log.New("query_apiserver"),
|
||||
instanceProvider: instanceProvider,
|
||||
authorizer: ar,
|
||||
@@ -142,6 +144,7 @@ func RegisterAPIService(
|
||||
tracer,
|
||||
legacyDatasourceLookup,
|
||||
&connectionsProvider{dsService: dataSourcesService, registry: reg},
|
||||
cfg.SectionWithEnvOverrides("query").Key("concurrent_query_limit").MustInt(runtime.NumCPU()),
|
||||
)
|
||||
apiregistration.RegisterAPI(builder)
|
||||
return builder, err
|
||||
@@ -151,7 +154,7 @@ func (b *QueryAPIBuilder) GetGroupVersion() schema.GroupVersion {
|
||||
return query.SchemeGroupVersion
|
||||
}
|
||||
|
||||
func addKnownTypes(scheme *runtime.Scheme, gv schema.GroupVersion) {
|
||||
func addKnownTypes(scheme *apiruntime.Scheme, gv schema.GroupVersion) {
|
||||
scheme.AddKnownTypes(gv,
|
||||
&query.DataSourceApiServer{},
|
||||
&query.DataSourceApiServerList{},
|
||||
@@ -165,7 +168,7 @@ func addKnownTypes(scheme *runtime.Scheme, gv schema.GroupVersion) {
|
||||
)
|
||||
}
|
||||
|
||||
func (b *QueryAPIBuilder) InstallSchema(scheme *runtime.Scheme) error {
|
||||
func (b *QueryAPIBuilder) InstallSchema(scheme *apiruntime.Scheme) error {
|
||||
addKnownTypes(scheme, query.SchemeGroupVersion)
|
||||
metav1.AddToGroupVersion(scheme, query.SchemeGroupVersion)
|
||||
return scheme.SetVersionPriority(query.SchemeGroupVersion)
|
||||
|
||||
4
pkg/server/wire_gen.go
generated
4
pkg/server/wire_gen.go
generated
@@ -873,7 +873,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
|
||||
folderAPIBuilder := folders.RegisterAPIService(cfg, featureToggles, apiserverService, folderimplService, folderPermissionsService, accessControl, acimplService, accessClient, registerer, resourceClient, zanzanaClient)
|
||||
storageBackendImpl := noopstorage.ProvideStorageBackend()
|
||||
noopTeamGroupsREST := externalgroupmapping.ProvideNoopTeamGroupsREST()
|
||||
identityAccessManagementAPIBuilder, err := iam.RegisterAPIService(featureToggles, apiserverService, ssosettingsimplService, sqlStore, accessControl, accessClient, zanzanaClient, registerer, storageBackendImpl, storageBackendImpl, storageBackendImpl, storageBackendImpl, noopTeamGroupsREST, dualwriteService, resourceClient, userService)
|
||||
identityAccessManagementAPIBuilder, err := iam.RegisterAPIService(cfg, featureToggles, apiserverService, ssosettingsimplService, sqlStore, accessControl, accessClient, zanzanaClient, registerer, storageBackendImpl, storageBackendImpl, tracingService, storageBackendImpl, storageBackendImpl, noopTeamGroupsREST, dualwriteService, resourceClient, userService, teamService)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1526,7 +1526,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
|
||||
folderAPIBuilder := folders.RegisterAPIService(cfg, featureToggles, apiserverService, folderimplService, folderPermissionsService, accessControl, acimplService, accessClient, registerer, resourceClient, zanzanaClient)
|
||||
storageBackendImpl := noopstorage.ProvideStorageBackend()
|
||||
noopTeamGroupsREST := externalgroupmapping.ProvideNoopTeamGroupsREST()
|
||||
identityAccessManagementAPIBuilder, err := iam.RegisterAPIService(featureToggles, apiserverService, ssosettingsimplService, sqlStore, accessControl, accessClient, zanzanaClient, registerer, storageBackendImpl, storageBackendImpl, storageBackendImpl, storageBackendImpl, noopTeamGroupsREST, dualwriteService, resourceClient, userService)
|
||||
identityAccessManagementAPIBuilder, err := iam.RegisterAPIService(cfg, featureToggles, apiserverService, ssosettingsimplService, sqlStore, accessControl, accessClient, zanzanaClient, registerer, storageBackendImpl, storageBackendImpl, tracingService, storageBackendImpl, storageBackendImpl, noopTeamGroupsREST, dualwriteService, resourceClient, userService, teamService)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -609,7 +609,14 @@ var (
|
||||
},
|
||||
{
|
||||
Name: "panelGroupBy",
|
||||
Description: "Enabled a group by action per panel",
|
||||
Description: "Enables a group by action per panel",
|
||||
Stage: FeatureStageExperimental,
|
||||
FrontendOnly: true,
|
||||
Owner: grafanaDashboardsSquad,
|
||||
},
|
||||
{
|
||||
Name: "perPanelFiltering",
|
||||
Description: "Enables filtering by grouping labels on the panel level through legend or tooltip",
|
||||
Stage: FeatureStageExperimental,
|
||||
FrontendOnly: true,
|
||||
Owner: grafanaDashboardsSquad,
|
||||
|
||||
1
pkg/services/featuremgmt/toggles_gen.csv
generated
1
pkg/services/featuremgmt/toggles_gen.csv
generated
@@ -85,6 +85,7 @@ dashboardUndoRedo,experimental,@grafana/dashboards-squad,false,false,true
|
||||
unlimitedLayoutsNesting,experimental,@grafana/dashboards-squad,false,false,true
|
||||
perPanelNonApplicableDrilldowns,experimental,@grafana/dashboards-squad,false,false,true
|
||||
panelGroupBy,experimental,@grafana/dashboards-squad,false,false,true
|
||||
perPanelFiltering,experimental,@grafana/dashboards-squad,false,false,true
|
||||
panelFilterVariable,experimental,@grafana/dashboards-squad,false,false,true
|
||||
pdfTables,preview,@grafana/grafana-operator-experience-squad,false,false,false
|
||||
canvasPanelPanZoom,preview,@grafana/dataviz-squad,false,false,true
|
||||
|
||||
|
654
pkg/services/featuremgmt/toggles_gen.json
generated
654
pkg/services/featuremgmt/toggles_gen.json
generated
File diff suppressed because it is too large
Load Diff
@@ -226,7 +226,7 @@ func buildErrorResponses(err error, queries []*simplejson.Json) splitResponse {
|
||||
return splitResponse{er, http.Header{}}
|
||||
}
|
||||
|
||||
func QueryData(ctx context.Context, log log.Logger, dscache datasources.CacheService, exprService *expr.Service, reqDTO dtos.MetricRequest, qsDatasourceClientBuilder dsquerierclient.QSDatasourceClientBuilder, headers map[string]string) (*backend.QueryDataResponse, error) {
|
||||
func QueryData(ctx context.Context, log log.Logger, dscache datasources.CacheService, exprService *expr.Service, reqDTO dtos.MetricRequest, qsDatasourceClientBuilder dsquerierclient.QSDatasourceClientBuilder, headers map[string]string, concurrentQueryLimit int) (*backend.QueryDataResponse, error) {
|
||||
s := &ServiceImpl{
|
||||
log: log,
|
||||
dataSourceCache: dscache,
|
||||
@@ -234,7 +234,7 @@ func QueryData(ctx context.Context, log log.Logger, dscache datasources.CacheSer
|
||||
dataSourceRequestValidator: validations.ProvideValidator(),
|
||||
qsDatasourceClientBuilder: qsDatasourceClientBuilder,
|
||||
headers: headers,
|
||||
concurrentQueryLimit: 16, // TODO: make it configurable
|
||||
concurrentQueryLimit: concurrentQueryLimit,
|
||||
}
|
||||
|
||||
user, err := identity.GetRequester(ctx)
|
||||
|
||||
82
pkg/services/team/search/search.go
Normal file
82
pkg/services/team/search/search.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/search/builders"
|
||||
)
|
||||
|
||||
func ParseResults(result *resourcepb.ResourceSearchResponse, offset int64) (v0alpha1.TeamSearchResults, error) {
|
||||
if result == nil {
|
||||
return v0alpha1.TeamSearchResults{}, nil
|
||||
} else if result.Error != nil {
|
||||
return v0alpha1.TeamSearchResults{}, fmt.Errorf("%d error searching: %s: %s", result.Error.Code, result.Error.Message, result.Error.Details)
|
||||
} else if result.Results == nil {
|
||||
return v0alpha1.TeamSearchResults{}, nil
|
||||
}
|
||||
|
||||
titleIDX := -1
|
||||
emailIDX := -1
|
||||
provisionedIDX := -1
|
||||
externalUIDIDX := -1
|
||||
|
||||
for i, v := range result.Results.Columns {
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
switch v.Name {
|
||||
case resource.SEARCH_FIELD_TITLE:
|
||||
titleIDX = i
|
||||
case builders.TEAM_SEARCH_EMAIL:
|
||||
emailIDX = i
|
||||
case builders.TEAM_SEARCH_PROVISIONED:
|
||||
provisionedIDX = i
|
||||
case builders.TEAM_SEARCH_EXTERNAL_UID:
|
||||
externalUIDIDX = i
|
||||
}
|
||||
}
|
||||
|
||||
sr := v0alpha1.TeamSearchResults{
|
||||
Offset: offset,
|
||||
TotalHits: result.TotalHits,
|
||||
QueryCost: result.QueryCost,
|
||||
MaxScore: result.MaxScore,
|
||||
Hits: make([]v0alpha1.TeamHit, len(result.Results.Rows)),
|
||||
}
|
||||
|
||||
for i, row := range result.Results.Rows {
|
||||
if len(row.Cells) != len(result.Results.Columns) {
|
||||
return v0alpha1.TeamSearchResults{}, fmt.Errorf("error parsing team search response: mismatch number of columns and cells")
|
||||
}
|
||||
|
||||
hit := &v0alpha1.TeamHit{
|
||||
Name: row.Key.Name,
|
||||
}
|
||||
|
||||
if titleIDX >= 0 && row.Cells[titleIDX] != nil {
|
||||
hit.Title = string(row.Cells[titleIDX])
|
||||
} else {
|
||||
hit.Title = "(no title)"
|
||||
}
|
||||
|
||||
if emailIDX >= 0 && row.Cells[emailIDX] != nil {
|
||||
hit.Email = string(row.Cells[emailIDX])
|
||||
}
|
||||
|
||||
if provisionedIDX >= 0 && row.Cells[provisionedIDX] != nil {
|
||||
hit.Provisioned = string(row.Cells[provisionedIDX]) == "true"
|
||||
}
|
||||
|
||||
if externalUIDIDX >= 0 && row.Cells[externalUIDIDX] != nil {
|
||||
hit.ExternalUID = string(row.Cells[externalUIDIDX])
|
||||
}
|
||||
|
||||
sr.Hits[i] = *hit
|
||||
}
|
||||
|
||||
return sr, nil
|
||||
}
|
||||
227
pkg/services/team/search/search_test.go
Normal file
227
pkg/services/team/search/search_test.go
Normal file
@@ -0,0 +1,227 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
|
||||
)
|
||||
|
||||
func TestParseResults(t *testing.T) {
|
||||
t.Run("should parse results", func(t *testing.T) {
|
||||
searchResp := &resourcepb.ResourceSearchResponse{
|
||||
Results: &resourcepb.ResourceTable{
|
||||
Columns: []*resourcepb.ResourceTableColumnDefinition{
|
||||
{
|
||||
Name: "title",
|
||||
Type: resourcepb.ResourceTableColumnDefinition_STRING,
|
||||
},
|
||||
{
|
||||
Name: "email",
|
||||
Type: resourcepb.ResourceTableColumnDefinition_STRING,
|
||||
},
|
||||
{
|
||||
Name: "provisioned",
|
||||
Type: resourcepb.ResourceTableColumnDefinition_BOOLEAN,
|
||||
},
|
||||
{
|
||||
Name: "externalUID",
|
||||
Type: resourcepb.ResourceTableColumnDefinition_STRING,
|
||||
},
|
||||
},
|
||||
Rows: []*resourcepb.ResourceTableRow{
|
||||
{
|
||||
Key: &resourcepb.ResourceKey{
|
||||
Name: "uid",
|
||||
Resource: "team",
|
||||
},
|
||||
Cells: [][]byte{
|
||||
[]byte("Team 1"),
|
||||
[]byte("team1@example.com"),
|
||||
[]byte("true"),
|
||||
[]byte("team1-uid"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
TotalHits: 1,
|
||||
}
|
||||
|
||||
results, err := ParseResults(searchResp, 0)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, results.Hits, 1)
|
||||
require.Equal(t, "Team 1", results.Hits[0].Title)
|
||||
require.Equal(t, "team1@example.com", results.Hits[0].Email)
|
||||
require.True(t, results.Hits[0].Provisioned)
|
||||
require.Equal(t, "team1-uid", results.Hits[0].ExternalUID)
|
||||
})
|
||||
|
||||
t.Run("should handle nil result", func(t *testing.T) {
|
||||
results, err := ParseResults(nil, 0)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, results.Hits)
|
||||
require.Zero(t, results.TotalHits)
|
||||
})
|
||||
|
||||
t.Run("should handle nil Results", func(t *testing.T) {
|
||||
searchResp := &resourcepb.ResourceSearchResponse{
|
||||
Results: nil,
|
||||
TotalHits: 0,
|
||||
}
|
||||
|
||||
results, err := ParseResults(searchResp, 0)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, results.Hits)
|
||||
require.Zero(t, results.TotalHits)
|
||||
})
|
||||
|
||||
t.Run("should handle nil Results.Rows", func(t *testing.T) {
|
||||
searchResp := &resourcepb.ResourceSearchResponse{
|
||||
Results: &resourcepb.ResourceTable{
|
||||
Columns: []*resourcepb.ResourceTableColumnDefinition{
|
||||
{
|
||||
Name: "title",
|
||||
Type: resourcepb.ResourceTableColumnDefinition_STRING,
|
||||
},
|
||||
},
|
||||
Rows: nil,
|
||||
},
|
||||
TotalHits: 0,
|
||||
}
|
||||
|
||||
results, err := ParseResults(searchResp, 0)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, results.Hits)
|
||||
require.Zero(t, results.TotalHits)
|
||||
})
|
||||
|
||||
t.Run("should return error for mismatched number of columns and cells", func(t *testing.T) {
|
||||
searchResp := &resourcepb.ResourceSearchResponse{
|
||||
Results: &resourcepb.ResourceTable{
|
||||
Columns: []*resourcepb.ResourceTableColumnDefinition{
|
||||
{
|
||||
Name: "title",
|
||||
Type: resourcepb.ResourceTableColumnDefinition_STRING,
|
||||
},
|
||||
{
|
||||
Name: "email",
|
||||
Type: resourcepb.ResourceTableColumnDefinition_STRING,
|
||||
},
|
||||
{
|
||||
Name: "provisioned",
|
||||
Type: resourcepb.ResourceTableColumnDefinition_BOOLEAN,
|
||||
},
|
||||
},
|
||||
Rows: []*resourcepb.ResourceTableRow{
|
||||
{
|
||||
Key: &resourcepb.ResourceKey{
|
||||
Name: "uid",
|
||||
Resource: "team",
|
||||
},
|
||||
Cells: [][]byte{
|
||||
[]byte("Team 1"),
|
||||
[]byte("team1@example.com"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
TotalHits: 1,
|
||||
}
|
||||
|
||||
results, err := ParseResults(searchResp, 0)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "mismatch number of columns and cells")
|
||||
require.Empty(t, results.Hits)
|
||||
})
|
||||
|
||||
t.Run("should return error for error response", func(t *testing.T) {
|
||||
searchResp := &resourcepb.ResourceSearchResponse{
|
||||
Error: &resourcepb.ErrorResult{
|
||||
Code: 500,
|
||||
Message: "Internal server error",
|
||||
Details: &resourcepb.ErrorDetails{
|
||||
Name: "test-resource",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
results, err := ParseResults(searchResp, 0)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "500 error searching: Internal server error")
|
||||
require.Empty(t, results.Hits)
|
||||
})
|
||||
|
||||
t.Run("should use (no title) fallback when title cell is nil", func(t *testing.T) {
|
||||
searchResp := &resourcepb.ResourceSearchResponse{
|
||||
Results: &resourcepb.ResourceTable{
|
||||
Columns: []*resourcepb.ResourceTableColumnDefinition{
|
||||
{
|
||||
Name: "title",
|
||||
Type: resourcepb.ResourceTableColumnDefinition_STRING,
|
||||
},
|
||||
{
|
||||
Name: "email",
|
||||
Type: resourcepb.ResourceTableColumnDefinition_STRING,
|
||||
},
|
||||
},
|
||||
Rows: []*resourcepb.ResourceTableRow{
|
||||
{
|
||||
Key: &resourcepb.ResourceKey{
|
||||
Name: "uid",
|
||||
Resource: "team",
|
||||
},
|
||||
Cells: [][]byte{
|
||||
nil, // title cell is nil
|
||||
[]byte("team1@example.com"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
TotalHits: 1,
|
||||
}
|
||||
|
||||
results, err := ParseResults(searchResp, 0)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, results.Hits, 1)
|
||||
require.Equal(t, "(no title)", results.Hits[0].Title)
|
||||
require.Equal(t, "team1@example.com", results.Hits[0].Email)
|
||||
})
|
||||
|
||||
t.Run("should use (no title) fallback when title column is missing", func(t *testing.T) {
|
||||
searchResp := &resourcepb.ResourceSearchResponse{
|
||||
Results: &resourcepb.ResourceTable{
|
||||
Columns: []*resourcepb.ResourceTableColumnDefinition{
|
||||
{
|
||||
Name: "email",
|
||||
Type: resourcepb.ResourceTableColumnDefinition_STRING,
|
||||
},
|
||||
{
|
||||
Name: "provisioned",
|
||||
Type: resourcepb.ResourceTableColumnDefinition_BOOLEAN,
|
||||
},
|
||||
},
|
||||
Rows: []*resourcepb.ResourceTableRow{
|
||||
{
|
||||
Key: &resourcepb.ResourceKey{
|
||||
Name: "uid",
|
||||
Resource: "team",
|
||||
},
|
||||
Cells: [][]byte{
|
||||
[]byte("team1@example.com"),
|
||||
[]byte("true"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
TotalHits: 1,
|
||||
}
|
||||
|
||||
results, err := ParseResults(searchResp, 0)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, results.Hits, 1)
|
||||
require.Equal(t, "(no title)", results.Hits[0].Title)
|
||||
require.Equal(t, "team1@example.com", results.Hits[0].Email)
|
||||
require.True(t, results.Hits[0].Provisioned)
|
||||
})
|
||||
}
|
||||
@@ -7,13 +7,14 @@ import (
|
||||
)
|
||||
|
||||
type FakeService struct {
|
||||
ExpectedTeam team.Team
|
||||
ExpectedIsMember bool
|
||||
ExpectedIsAdmin bool
|
||||
ExpectedTeamDTO *team.TeamDTO
|
||||
ExpectedTeamsByUser []*team.TeamDTO
|
||||
ExpectedMembers []*team.TeamMemberDTO
|
||||
ExpectedError error
|
||||
ExpectedTeam team.Team
|
||||
ExpectedIsMember bool
|
||||
ExpectedIsAdmin bool
|
||||
ExpectedTeamDTO *team.TeamDTO
|
||||
ExpectedTeamsByUser []*team.TeamDTO
|
||||
ExpectedMembers []*team.TeamMemberDTO
|
||||
ExpectedSearchTeamsResult team.SearchTeamQueryResult
|
||||
ExpectedError error
|
||||
}
|
||||
|
||||
func NewFakeService() *FakeService {
|
||||
@@ -39,7 +40,7 @@ func (s *FakeService) DeleteTeam(ctx context.Context, cmd *team.DeleteTeamComman
|
||||
}
|
||||
|
||||
func (s *FakeService) SearchTeams(ctx context.Context, query *team.SearchTeamsQuery) (team.SearchTeamQueryResult, error) {
|
||||
return team.SearchTeamQueryResult{}, s.ExpectedError
|
||||
return s.ExpectedSearchTeamsResult, s.ExpectedError
|
||||
}
|
||||
|
||||
func (s *FakeService) GetTeamByID(ctx context.Context, query *team.GetTeamByIDQuery) (*team.TeamDTO, error) {
|
||||
|
||||
@@ -67,5 +67,10 @@ func All(sql db.DB, sprinkles DashboardStats) ([]resource.DocumentBuilderInfo, e
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []resource.DocumentBuilderInfo{dashboards, users, extGroupMappings}, nil
|
||||
teams, err := GetTeamSearchBuilder()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []resource.DocumentBuilderInfo{dashboards, users, extGroupMappings, teams}, nil
|
||||
}
|
||||
|
||||
@@ -70,6 +70,18 @@ func TestExternalGroupMappingDocumentBuilder(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestTeamSearchBuilder(t *testing.T) {
|
||||
info, err := GetTeamSearchBuilder()
|
||||
require.NoError(t, err)
|
||||
doSnapshotTests(t, info.Builder, "team", &resourcepb.ResourceKey{
|
||||
Namespace: "default",
|
||||
Group: "iam.grafana.app",
|
||||
Resource: "searchTeams",
|
||||
}, []string{
|
||||
"with-email-and-external-uid",
|
||||
})
|
||||
}
|
||||
|
||||
func TestDashboardDocumentBuilder(t *testing.T) {
|
||||
key := &resourcepb.ResourceKey{
|
||||
Namespace: "default",
|
||||
|
||||
87
pkg/storage/unified/search/builders/team_search.go
Normal file
87
pkg/storage/unified/search/builders/team_search.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package builders
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
|
||||
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
|
||||
)
|
||||
|
||||
const (
|
||||
TEAM_SEARCH_EMAIL = "email"
|
||||
TEAM_SEARCH_PROVISIONED = "provisioned"
|
||||
TEAM_SEARCH_EXTERNAL_UID = "externalUID"
|
||||
)
|
||||
|
||||
var TeamSearchTableColumnDefinitions = map[string]*resourcepb.ResourceTableColumnDefinition{
|
||||
TEAM_SEARCH_EMAIL: {
|
||||
Name: TEAM_SEARCH_EMAIL,
|
||||
Type: resourcepb.ResourceTableColumnDefinition_STRING,
|
||||
Description: "Email of the team",
|
||||
},
|
||||
TEAM_SEARCH_PROVISIONED: {
|
||||
Name: TEAM_SEARCH_PROVISIONED,
|
||||
Type: resourcepb.ResourceTableColumnDefinition_BOOLEAN,
|
||||
Description: "Whether the team is provisioned",
|
||||
},
|
||||
TEAM_SEARCH_EXTERNAL_UID: {
|
||||
Name: TEAM_SEARCH_EXTERNAL_UID,
|
||||
Type: resourcepb.ResourceTableColumnDefinition_STRING,
|
||||
Description: "External UID of the team",
|
||||
},
|
||||
}
|
||||
|
||||
func GetTeamSearchBuilder() (resource.DocumentBuilderInfo, error) {
|
||||
values := make([]*resourcepb.ResourceTableColumnDefinition, 0, len(TeamSearchTableColumnDefinitions))
|
||||
for _, v := range TeamSearchTableColumnDefinitions {
|
||||
values = append(values, v)
|
||||
}
|
||||
fields, err := resource.NewSearchableDocumentFields(values)
|
||||
|
||||
return resource.DocumentBuilderInfo{
|
||||
GroupResource: schema.GroupResource{
|
||||
Group: "iam.grafana.app",
|
||||
Resource: "searchTeams",
|
||||
},
|
||||
Fields: fields,
|
||||
Builder: new(teamSearchBuilder),
|
||||
}, err
|
||||
}
|
||||
|
||||
var _ resource.DocumentBuilder = new(teamSearchBuilder)
|
||||
|
||||
type teamSearchBuilder struct{}
|
||||
|
||||
func (t *teamSearchBuilder) BuildDocument(ctx context.Context, key *resourcepb.ResourceKey, rv int64, value []byte) (*resource.IndexableDocument, error) {
|
||||
team := &v0alpha1.Team{}
|
||||
err := json.NewDecoder(bytes.NewReader(value)).Decode(team)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
obj, err := utils.MetaAccessor(team)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
doc := resource.NewIndexableDocument(key, rv, obj)
|
||||
|
||||
doc.Fields = make(map[string]any)
|
||||
if team.Spec.Email != "" {
|
||||
doc.Fields[TEAM_SEARCH_EMAIL] = team.Spec.Email
|
||||
}
|
||||
if team.Spec.Provisioned {
|
||||
doc.Fields[TEAM_SEARCH_PROVISIONED] = team.Spec.Provisioned
|
||||
}
|
||||
if team.Spec.ExternalUID != "" {
|
||||
doc.Fields[TEAM_SEARCH_EXTERNAL_UID] = team.Spec.ExternalUID
|
||||
}
|
||||
|
||||
return doc, nil
|
||||
}
|
||||
18
pkg/storage/unified/search/builders/testdata/doc/team-with-email-and-external-uid-out.json
vendored
Normal file
18
pkg/storage/unified/search/builders/testdata/doc/team-with-email-and-external-uid-out.json
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"key": {
|
||||
"namespace": "default",
|
||||
"group": "iam.grafana.app",
|
||||
"resource": "searchTeams",
|
||||
"name": "with-email-and-external-uid"
|
||||
},
|
||||
"name": "with-email-and-external-uid",
|
||||
"rv": 1234,
|
||||
"title": "Team With Email And External UID",
|
||||
"title_ngram": "Team With Email And External UID",
|
||||
"title_phrase": "team with email and external uid",
|
||||
"fields": {
|
||||
"email": "test@example.com",
|
||||
"externalUID": "external-uid",
|
||||
"provisioned": true
|
||||
}
|
||||
}
|
||||
13
pkg/storage/unified/search/builders/testdata/doc/team-with-email-and-external-uid.json
vendored
Normal file
13
pkg/storage/unified/search/builders/testdata/doc/team-with-email-and-external-uid.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"apiVersion": "iam.grafana.app/v0alpha1",
|
||||
"kind": "Team",
|
||||
"metadata": {
|
||||
"name": "team-with-email-and-external-uid"
|
||||
},
|
||||
"spec": {
|
||||
"title": "Team With Email And External UID",
|
||||
"email": "test@example.com",
|
||||
"provisioned": true,
|
||||
"externalUID": "external-uid"
|
||||
}
|
||||
}
|
||||
@@ -912,6 +912,115 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"/apis/iam.grafana.app/v0alpha1/namespaces/{namespace}/searchTeams": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Search"
|
||||
],
|
||||
"description": "Team search",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "namespace",
|
||||
"in": "path",
|
||||
"description": "workspace",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"example": "default"
|
||||
},
|
||||
{
|
||||
"name": "query",
|
||||
"in": "query",
|
||||
"description": "team name query string",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"description": "limit the number of results",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "offset",
|
||||
"in": "query",
|
||||
"description": "start the query at the given offset",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "page",
|
||||
"in": "query",
|
||||
"description": "page number to start from",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"offset",
|
||||
"totalHits",
|
||||
"hits",
|
||||
"queryCost",
|
||||
"maxScore"
|
||||
],
|
||||
"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"
|
||||
},
|
||||
"hits": {
|
||||
"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": {
|
||||
"type": "number",
|
||||
"format": "double",
|
||||
"default": 0
|
||||
},
|
||||
"offset": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"default": 0
|
||||
},
|
||||
"queryCost": {
|
||||
"type": "number",
|
||||
"format": "double",
|
||||
"default": 0
|
||||
},
|
||||
"totalHits": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"default": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/apis/iam.grafana.app/v0alpha1/namespaces/{namespace}/serviceaccounts": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -6373,6 +6482,90 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GetSearchTeams": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"offset",
|
||||
"totalHits",
|
||||
"hits",
|
||||
"queryCost",
|
||||
"maxScore"
|
||||
],
|
||||
"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"
|
||||
},
|
||||
"hits": {
|
||||
"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": {
|
||||
"type": "number",
|
||||
"format": "double",
|
||||
"default": 0
|
||||
},
|
||||
"offset": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"default": 0
|
||||
},
|
||||
"queryCost": {
|
||||
"type": "number",
|
||||
"format": "double",
|
||||
"default": 0
|
||||
},
|
||||
"totalHits": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"default": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GetSearchTeamsBody": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"offset",
|
||||
"totalHits",
|
||||
"hits",
|
||||
"queryCost",
|
||||
"maxScore"
|
||||
],
|
||||
"properties": {
|
||||
"hits": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"default": {}
|
||||
}
|
||||
},
|
||||
"maxScore": {
|
||||
"type": "number",
|
||||
"format": "double",
|
||||
"default": 0
|
||||
},
|
||||
"offset": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"default": 0
|
||||
},
|
||||
"queryCost": {
|
||||
"type": "number",
|
||||
"format": "double",
|
||||
"default": 0
|
||||
},
|
||||
"totalHits": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"default": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GlobalRole": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -7726,6 +7919,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"title",
|
||||
"email",
|
||||
"provisioned",
|
||||
"externalUID"
|
||||
],
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"default": ""
|
||||
},
|
||||
"externalUID": {
|
||||
"type": "string",
|
||||
"default": ""
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"default": ""
|
||||
},
|
||||
"provisioned": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"default": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"io.k8s.apimachinery.pkg.apis.meta.v1.APIResource": {
|
||||
"description": "APIResource specifies the name of a resource and whether it is namespaced.",
|
||||
"type": "object",
|
||||
|
||||
@@ -29,22 +29,39 @@ export const FieldColorEditor = ({ value, onChange, item, id }: Props) => {
|
||||
? fieldColorModeRegistry.list()
|
||||
: fieldColorModeRegistry.list().filter((m) => !m.isByValue);
|
||||
|
||||
const options = availableOptions
|
||||
.filter((mode) => !mode.excludeFromPicker)
|
||||
.map((mode) => {
|
||||
let suffix = mode.isByValue ? ' (by value)' : '';
|
||||
const filteredOptions = availableOptions.filter((option) => !option.excludeFromPicker);
|
||||
|
||||
return {
|
||||
value: mode.id,
|
||||
label: `${mode.name}${suffix}`,
|
||||
description: mode.description,
|
||||
isContinuous: mode.isContinuous,
|
||||
isByValue: mode.isByValue,
|
||||
component() {
|
||||
return <FieldColorModeViz mode={mode} theme={theme} />;
|
||||
},
|
||||
};
|
||||
});
|
||||
const options: Array<SelectableValue<string>> = [];
|
||||
// collect any grouped options in this map
|
||||
// this allows us to easily push to the child array without having to rescan the options array
|
||||
// it also allows us to maintain group position in the order they're first encountered
|
||||
const groupMap = new Map<string, Array<SelectableValue<string>>>();
|
||||
|
||||
for (const option of filteredOptions) {
|
||||
const suffix = option.isByValue ? ' (by value)' : '';
|
||||
|
||||
const groupName = option.group;
|
||||
const selectOption = {
|
||||
value: option.id,
|
||||
label: `${option.name}${suffix}`,
|
||||
description: option.description,
|
||||
component() {
|
||||
return <FieldColorModeViz mode={option} theme={theme} />;
|
||||
},
|
||||
};
|
||||
|
||||
if (groupName) {
|
||||
let group = groupMap.get(groupName);
|
||||
if (!group) {
|
||||
group = [];
|
||||
groupMap.set(groupName, group);
|
||||
options.push({ label: groupName, options: group });
|
||||
}
|
||||
group.push(selectOption);
|
||||
} else {
|
||||
options.push(selectOption);
|
||||
}
|
||||
}
|
||||
|
||||
const onModeChange = (newMode: SelectableValue<string>) => {
|
||||
onChange({
|
||||
|
||||
@@ -44,20 +44,21 @@ interface ShowMoreInstancesProps {
|
||||
|
||||
function ShowMoreInstances({ stats, onClick, href }: ShowMoreInstancesProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const { visibleItemsCount, totalItemsCount } = stats;
|
||||
|
||||
return (
|
||||
<div className={styles.footerRow}>
|
||||
<div>
|
||||
<Trans
|
||||
i18nKey="alerting.rule-details-matching-instances.showing-count"
|
||||
values={{ visibleItems: stats.visibleItemsCount, totalItems: stats.totalItemsCount }}
|
||||
values={{ visibleItemsCount, totalItemsCount }}
|
||||
>
|
||||
Showing {'{{visibleItems}}'} out of {'{{totalItems}}'} instances
|
||||
Showing {{ visibleItemsCount }} out of {{ totalItemsCount }} instances
|
||||
</Trans>
|
||||
</div>
|
||||
<LinkButton size="sm" variant="secondary" data-testid="show-all" onClick={onClick} href={href}>
|
||||
<Trans i18nKey="alerting.rule-details-matching-instances.button-show-all" count={stats.totalItemsCount}>
|
||||
Show all {'{{totalItems}}'} alert instances
|
||||
<Trans i18nKey="alerting.rule-details-matching-instances.button-show-all" values={{ totalItemsCount }}>
|
||||
Show all {{ totalItemsCount }} alert instances
|
||||
</Trans>
|
||||
</LinkButton>
|
||||
</div>
|
||||
|
||||
@@ -130,6 +130,12 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
width: `var(${DRAGGED_ITEM_WIDTH})`,
|
||||
height: `var(${DRAGGED_ITEM_HEIGHT})`,
|
||||
opacity: 0.8,
|
||||
|
||||
// Unfortunately, we need to re-enforce the absolute position here. Otherwise, the position will be overwritten with
|
||||
// a relative position by .dashboard-visible-hidden-element
|
||||
'&.dashboard-visible-hidden-element': {
|
||||
position: 'absolute',
|
||||
},
|
||||
}),
|
||||
draggedRepeatWrapper: css({
|
||||
visibility: 'hidden',
|
||||
|
||||
@@ -65,6 +65,7 @@ export class AutoGridLayout extends SceneObjectBase<AutoGridLayoutState> impleme
|
||||
top: number;
|
||||
left: number;
|
||||
} | null = null;
|
||||
private _lastDropTargetGridItemKey: string | null = null;
|
||||
|
||||
public constructor(state: Partial<AutoGridLayoutState>) {
|
||||
super({
|
||||
@@ -145,6 +146,7 @@ export class AutoGridLayout extends SceneObjectBase<AutoGridLayoutState> impleme
|
||||
}
|
||||
|
||||
this._draggedGridItem = gridItem;
|
||||
this._lastDropTargetGridItemKey = gridItem.state.key!;
|
||||
|
||||
const { top, left, width, height } = this._draggedGridItem.getBoundingBox();
|
||||
this._initialGridItemPosition = { pageX: evt.pageX, pageY: evt.pageY, top, left: left };
|
||||
@@ -166,6 +168,7 @@ export class AutoGridLayout extends SceneObjectBase<AutoGridLayoutState> impleme
|
||||
|
||||
this._draggedGridItem = null;
|
||||
this._initialGridItemPosition = null;
|
||||
this._lastDropTargetGridItemKey = null;
|
||||
this._resetPanelPositionAndSize();
|
||||
|
||||
this.setState({ draggingKey: undefined });
|
||||
@@ -196,7 +199,7 @@ export class AutoGridLayout extends SceneObjectBase<AutoGridLayoutState> impleme
|
||||
})
|
||||
?.getAttribute('data-auto-grid-item-drop-target');
|
||||
|
||||
if (dropTargetGridItemKey) {
|
||||
if (dropTargetGridItemKey && dropTargetGridItemKey !== this._lastDropTargetGridItemKey) {
|
||||
this._onDragOverItem(dropTargetGridItemKey);
|
||||
}
|
||||
}
|
||||
@@ -207,12 +210,14 @@ export class AutoGridLayout extends SceneObjectBase<AutoGridLayoutState> impleme
|
||||
const draggedIdx = children.findIndex((child) => child === this._draggedGridItem);
|
||||
const draggedOverIdx = children.findIndex((child) => child.state.key === key);
|
||||
|
||||
if (draggedIdx === -1 || draggedOverIdx === -1) {
|
||||
if (draggedIdx === -1 || draggedOverIdx === -1 || draggedIdx === draggedOverIdx) {
|
||||
this._lastDropTargetGridItemKey = key;
|
||||
return;
|
||||
}
|
||||
|
||||
children.splice(draggedIdx, 1);
|
||||
children.splice(draggedOverIdx, 0, this._draggedGridItem!);
|
||||
this._lastDropTargetGridItemKey = this._draggedGridItem!.state.key!;
|
||||
|
||||
this.setState({ children });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { EventBusSrv } from '@grafana/data';
|
||||
import { BackendSrv, setBackendSrv } from '@grafana/runtime';
|
||||
import { PanelContext } from '@grafana/ui';
|
||||
import { AdHocVariableModel, EventBusSrv, GroupByVariableModel, VariableModel } from '@grafana/data';
|
||||
import { BackendSrv, config, setBackendSrv } from '@grafana/runtime';
|
||||
import { GroupByVariable, sceneGraph } from '@grafana/scenes';
|
||||
import { AdHocFilterItem, PanelContext } from '@grafana/ui';
|
||||
|
||||
import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
|
||||
import { findVizPanelByKey } from '../utils/utils';
|
||||
@@ -159,6 +160,146 @@ describe('setDashboardPanelContext', () => {
|
||||
expect(variable.state.filters[1].operator).toBe('!=');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFiltersBasedOnGrouping', () => {
|
||||
beforeAll(() => {
|
||||
config.featureToggles.groupByVariable = true;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
config.featureToggles.groupByVariable = false;
|
||||
});
|
||||
|
||||
it('should return filters based on grouping', () => {
|
||||
const { scene, context } = buildTestScene({ existingFilterVariable: true, existingGroupByVariable: true });
|
||||
|
||||
const groupBy = sceneGraph.getVariables(scene).state.variables.find((f) => f instanceof GroupByVariable);
|
||||
|
||||
groupBy?.changeValueTo(['container', 'cluster']);
|
||||
|
||||
const filters: AdHocFilterItem[] = [
|
||||
{ key: 'container', value: 'container', operator: '=' },
|
||||
{ key: 'cluster', value: 'cluster', operator: '=' },
|
||||
{ key: 'cpu', value: 'cpu', operator: '=' },
|
||||
{ key: 'id', value: 'id', operator: '=' },
|
||||
];
|
||||
|
||||
const result = context.getFiltersBasedOnGrouping?.(filters);
|
||||
expect(result).toEqual([
|
||||
{ key: 'container', value: 'container', operator: '=' },
|
||||
{ key: 'cluster', value: 'cluster', operator: '=' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return empty filters if there is no groupBy selection', () => {
|
||||
const { context } = buildTestScene({ existingFilterVariable: true, existingGroupByVariable: true });
|
||||
|
||||
const filters: AdHocFilterItem[] = [
|
||||
{ key: 'container', value: 'container', operator: '=' },
|
||||
{ key: 'cluster', value: 'cluster', operator: '=' },
|
||||
{ key: 'cpu', value: 'cpu', operator: '=' },
|
||||
{ key: 'id', value: 'id', operator: '=' },
|
||||
];
|
||||
|
||||
const result = context.getFiltersBasedOnGrouping?.(filters);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty filters if there is no groupBy variable', () => {
|
||||
const { context } = buildTestScene({ existingFilterVariable: true, existingGroupByVariable: false });
|
||||
|
||||
const filters: AdHocFilterItem[] = [
|
||||
{ key: 'container', value: 'container', operator: '=' },
|
||||
{ key: 'cluster', value: 'cluster', operator: '=' },
|
||||
{ key: 'cpu', value: 'cpu', operator: '=' },
|
||||
{ key: 'id', value: 'id', operator: '=' },
|
||||
];
|
||||
|
||||
const result = context.getFiltersBasedOnGrouping?.(filters);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty filters if panel and groupBy ds differs', () => {
|
||||
const { scene, context } = buildTestScene({
|
||||
existingFilterVariable: true,
|
||||
existingGroupByVariable: true,
|
||||
groupByDatasourceUid: 'different-ds',
|
||||
});
|
||||
|
||||
const groupBy = sceneGraph.getVariables(scene).state.variables.find((f) => f instanceof GroupByVariable);
|
||||
|
||||
groupBy?.changeValueTo(['container', 'cluster']);
|
||||
|
||||
const filters: AdHocFilterItem[] = [
|
||||
{ key: 'container', value: 'container', operator: '=' },
|
||||
{ key: 'cluster', value: 'cluster', operator: '=' },
|
||||
{ key: 'cpu', value: 'cpu', operator: '=' },
|
||||
{ key: 'id', value: 'id', operator: '=' },
|
||||
];
|
||||
|
||||
const result = context.getFiltersBasedOnGrouping?.(filters);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onAddAdHocFilters', () => {
|
||||
it('should add adhoc filters', () => {
|
||||
const { scene, context } = buildTestScene({
|
||||
existingFilterVariable: true,
|
||||
});
|
||||
|
||||
const variable = getAdHocFilterVariableFor(scene, { uid: 'my-ds-uid' });
|
||||
|
||||
const filters: AdHocFilterItem[] = [
|
||||
{ key: 'existing', value: 'val', operator: '=' },
|
||||
{ key: 'cluster', value: 'cluster', operator: '=' },
|
||||
];
|
||||
|
||||
context.onAddAdHocFilters?.(filters);
|
||||
expect(variable.state.filters).toEqual([
|
||||
{ key: 'existing', value: 'val', operator: '=' },
|
||||
{ key: 'cluster', value: 'cluster', operator: '=' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should update and add adhoc filters', () => {
|
||||
const { scene, context } = buildTestScene({
|
||||
existingFilterVariable: true,
|
||||
});
|
||||
|
||||
const variable = getAdHocFilterVariableFor(scene, { uid: 'my-ds-uid' });
|
||||
|
||||
variable.setState({ filters: [{ key: 'existing', value: 'val', operator: '=' }] });
|
||||
|
||||
const filters: AdHocFilterItem[] = [
|
||||
{ key: 'existing', value: 'val', operator: '!=' },
|
||||
{ key: 'cluster', value: 'cluster', operator: '=' },
|
||||
{ key: 'cpu', value: 'cpu', operator: '=' },
|
||||
{ key: 'id', value: 'id', operator: '=' },
|
||||
];
|
||||
|
||||
context.onAddAdHocFilters?.(filters);
|
||||
expect(variable.state.filters).toEqual([
|
||||
{ key: 'existing', value: 'val', operator: '!=' },
|
||||
{ key: 'cluster', value: 'cluster', operator: '=' },
|
||||
{ key: 'cpu', value: 'cpu', operator: '=' },
|
||||
{ key: 'id', value: 'id', operator: '=' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not do anything if filters empty', () => {
|
||||
const { scene, context } = buildTestScene({
|
||||
existingFilterVariable: true,
|
||||
});
|
||||
|
||||
const variable = getAdHocFilterVariableFor(scene, { uid: 'my-ds-uid' });
|
||||
|
||||
const filters: AdHocFilterItem[] = [];
|
||||
|
||||
context.onAddAdHocFilters?.(filters);
|
||||
expect(variable.state.filters).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
interface SceneOptions {
|
||||
@@ -169,9 +310,29 @@ interface SceneOptions {
|
||||
canDelete?: boolean;
|
||||
orgCanEdit?: boolean;
|
||||
existingFilterVariable?: boolean;
|
||||
existingGroupByVariable?: boolean;
|
||||
groupByDatasourceUid?: string;
|
||||
}
|
||||
|
||||
function buildTestScene(options: SceneOptions) {
|
||||
const varList: VariableModel[] = [];
|
||||
|
||||
if (options.existingFilterVariable) {
|
||||
varList.push({
|
||||
type: 'adhoc',
|
||||
name: 'Filters',
|
||||
datasource: { uid: 'my-ds-uid' },
|
||||
} as AdHocVariableModel);
|
||||
}
|
||||
|
||||
if (options.existingGroupByVariable) {
|
||||
varList.push({
|
||||
type: 'groupby',
|
||||
name: 'Group By',
|
||||
datasource: { uid: options.groupByDatasourceUid ?? 'my-ds-uid', type: 'prometheus' },
|
||||
} as GroupByVariableModel);
|
||||
}
|
||||
|
||||
const scene = transformSaveModelToScene({
|
||||
dashboard: {
|
||||
title: 'hello',
|
||||
@@ -203,15 +364,7 @@ function buildTestScene(options: SceneOptions) {
|
||||
},
|
||||
],
|
||||
templating: {
|
||||
list: options.existingFilterVariable
|
||||
? [
|
||||
{
|
||||
type: 'adhoc',
|
||||
name: 'Filters',
|
||||
datasource: { uid: 'my-ds-uid' },
|
||||
},
|
||||
]
|
||||
: [],
|
||||
list: varList,
|
||||
},
|
||||
},
|
||||
meta: {
|
||||
|
||||
@@ -133,6 +133,43 @@ export function setDashboardPanelContext(vizPanel: VizPanel, context: PanelConte
|
||||
updateAdHocFilterVariable(filterVar, newFilter);
|
||||
};
|
||||
|
||||
context.getFiltersBasedOnGrouping = (items: AdHocFilterItem[]) => {
|
||||
const dashboard = getDashboardSceneFor(vizPanel);
|
||||
|
||||
const queryRunner = getQueryRunnerFor(vizPanel);
|
||||
if (!queryRunner) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const groupByVar = getGroupByVariableFor(dashboard, queryRunner.state.datasource);
|
||||
|
||||
if (!groupByVar) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const currentValues = Array.isArray(groupByVar.state.value)
|
||||
? groupByVar.state.value
|
||||
: groupByVar.state.value
|
||||
? [groupByVar.state.value]
|
||||
: [];
|
||||
|
||||
return items
|
||||
.map((item) => (currentValues.find((key) => key === item.key) ? item : undefined))
|
||||
.filter((item) => item !== undefined);
|
||||
};
|
||||
|
||||
context.onAddAdHocFilters = (items: AdHocFilterItem[]) => {
|
||||
const dashboard = getDashboardSceneFor(vizPanel);
|
||||
|
||||
const queryRunner = getQueryRunnerFor(vizPanel);
|
||||
if (!queryRunner) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filterVar = getAdHocFilterVariableFor(dashboard, queryRunner.state.datasource);
|
||||
bulkUpdateAdHocFiltersVariable(filterVar, items);
|
||||
};
|
||||
|
||||
context.canExecuteActions = () => {
|
||||
const dashboard = getDashboardSceneFor(vizPanel);
|
||||
return dashboard.canEditDashboard();
|
||||
@@ -167,6 +204,21 @@ function reRunBuiltInAnnotationsLayer(scene: DashboardScene) {
|
||||
}
|
||||
}
|
||||
|
||||
function getGroupByVariableFor(scene: DashboardScene, ds: DataSourceRef | null | undefined) {
|
||||
const variables = sceneGraph.getVariables(scene);
|
||||
|
||||
for (const variable of variables.state.variables) {
|
||||
if (sceneUtils.isGroupByVariable(variable)) {
|
||||
const filtersDs = variable.state.datasource;
|
||||
if (filtersDs === ds || filtersDs?.uid === ds?.uid) {
|
||||
return variable;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getAdHocFilterVariableFor(scene: DashboardScene, ds: DataSourceRef | null | undefined) {
|
||||
const variables = sceneGraph.getVariables(scene);
|
||||
|
||||
@@ -195,6 +247,35 @@ export function getAdHocFilterVariableFor(scene: DashboardScene, ds: DataSourceR
|
||||
return newVariable;
|
||||
}
|
||||
|
||||
function bulkUpdateAdHocFiltersVariable(filterVar: AdHocFiltersVariable, newFilters: AdHocFilterItem[]) {
|
||||
if (!newFilters.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedFilters = filterVar.state.filters.slice();
|
||||
let hasChanges = false;
|
||||
|
||||
for (const newFilter of newFilters) {
|
||||
const filterToReplaceIndex = updatedFilters.findIndex(
|
||||
(filter) =>
|
||||
filter.key === newFilter.key && filter.value === newFilter.value && filter.operator !== newFilter.operator
|
||||
);
|
||||
|
||||
if (filterToReplaceIndex >= 0) {
|
||||
updatedFilters.splice(filterToReplaceIndex, 1, newFilter);
|
||||
hasChanges = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
updatedFilters.push(newFilter);
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
filterVar.updateFilters(updatedFilters);
|
||||
}
|
||||
}
|
||||
|
||||
function updateAdHocFilterVariable(filterVar: AdHocFiltersVariable, newFilter: AdHocFilterItem) {
|
||||
// This function handles 'Filter for value' and 'Filter out value' from table cell
|
||||
// We are allowing to add filters with the same key because elastic search ds supports that
|
||||
|
||||
@@ -709,6 +709,7 @@ exports[`transformSceneToSaveModel Given a scene with rows Should transform back
|
||||
"title": "Repeating rows",
|
||||
"uid": "Repeating-rows-uid",
|
||||
"version": 1,
|
||||
"weekStart": "",
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -143,6 +143,7 @@ export function transformSceneToSaveModel(scene: DashboardScene, isSnapshot = fa
|
||||
description: state.description || undefined,
|
||||
uid: state.uid,
|
||||
id: state.id,
|
||||
editable: state.editable,
|
||||
preload: state.preload,
|
||||
time: {
|
||||
from: timeRange.from,
|
||||
@@ -158,7 +159,7 @@ export function transformSceneToSaveModel(scene: DashboardScene, isSnapshot = fa
|
||||
},
|
||||
version: state.version,
|
||||
fiscalYearStartMonth: timeRange.fiscalYearStartMonth,
|
||||
weekStart: timeRange.weekStart,
|
||||
weekStart: timeRange.weekStart ?? '',
|
||||
tags: state.tags,
|
||||
links: state.links,
|
||||
graphTooltip,
|
||||
@@ -170,10 +171,7 @@ export function transformSceneToSaveModel(scene: DashboardScene, isSnapshot = fa
|
||||
};
|
||||
|
||||
// Only add optional fields if they are explicitly set (not default values)
|
||||
if (state.editable !== undefined) {
|
||||
dashboard.editable = state.editable;
|
||||
}
|
||||
if (timeRange.timeZone !== undefined && timeRange.timeZone !== '') {
|
||||
if (timeRange.timeZone !== '') {
|
||||
dashboard.timezone = timeRange.timeZone;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { NavModel, NavModelItem, PageLayoutType } from '@grafana/data';
|
||||
import { SceneComponentProps, SceneObjectBase, SceneVariable, SceneVariables, sceneGraph } from '@grafana/scenes';
|
||||
@@ -109,10 +110,8 @@ export class VariablesEditView extends SceneObjectBase<VariablesEditViewState> i
|
||||
newName = `copy_of_${variableToUpdate.state.name}_${copyNumber}`;
|
||||
}
|
||||
|
||||
//clone the original variable
|
||||
const newVariable = variableToUpdate.clone(variableToUpdate.state);
|
||||
// update state name of the new variable
|
||||
newVariable.setState({ name: newName });
|
||||
//clone the original variable, update name and key
|
||||
const newVariable = variableToUpdate.clone({ ...variableToUpdate.state, name: newName, key: uuidv4() });
|
||||
|
||||
const updatedVariables = [
|
||||
...variables.slice(0, variableIndex + 1),
|
||||
|
||||
@@ -18,12 +18,12 @@ import {
|
||||
} from './dataquery.gen';
|
||||
import {
|
||||
defaultBucketAgg,
|
||||
defaultMetricAgg,
|
||||
findMetricById,
|
||||
highlightTags,
|
||||
defaultGeoHashPrecisionString,
|
||||
queryTypeToMetricType,
|
||||
} from './queryDef';
|
||||
import { TermsQuery } from './types';
|
||||
import { QueryType, TermsQuery } from './types';
|
||||
import { convertOrderByToMetricId, getScriptValue } from './utils';
|
||||
|
||||
// Omitting 1m, 1h, 1d for now, as these cover the main use cases for calendar_interval
|
||||
@@ -31,9 +31,11 @@ export const calendarIntervals: string[] = ['1w', '1M', '1q', '1y'];
|
||||
|
||||
export class ElasticQueryBuilder {
|
||||
timeField: string;
|
||||
defaultQueryMode?: QueryType;
|
||||
|
||||
constructor(options: { timeField: string }) {
|
||||
constructor(options: { timeField: string; defaultQueryMode?: QueryType }) {
|
||||
this.timeField = options.timeField;
|
||||
this.defaultQueryMode = options.defaultQueryMode;
|
||||
}
|
||||
|
||||
getRangeFilter() {
|
||||
@@ -174,7 +176,10 @@ export class ElasticQueryBuilder {
|
||||
|
||||
build(target: ElasticsearchDataQuery) {
|
||||
// make sure query has defaults;
|
||||
target.metrics = target.metrics || [defaultMetricAgg()];
|
||||
if (!target.metrics || target.metrics.length === 0) {
|
||||
const metricType = queryTypeToMetricType(this.defaultQueryMode);
|
||||
target.metrics = [{ type: metricType, id: '1' }];
|
||||
}
|
||||
target.bucketAggs = target.bucketAggs || [defaultBucketAgg()];
|
||||
target.timeField = this.timeField;
|
||||
let metric: MetricAggregation;
|
||||
|
||||
@@ -62,10 +62,10 @@ export const ElasticsearchProvider = ({
|
||||
// useStatelessReducer will then call `onChange` with the newly generated query
|
||||
useEffect(() => {
|
||||
if (shouldRunInit && isUninitialized) {
|
||||
dispatch(initQuery());
|
||||
dispatch(initQuery(datasource.defaultQueryMode));
|
||||
setShouldRunInit(false);
|
||||
}
|
||||
}, [shouldRunInit, dispatch, isUninitialized]);
|
||||
}, [shouldRunInit, dispatch, isUninitialized, datasource.defaultQueryMode]);
|
||||
|
||||
if (isUninitialized) {
|
||||
return null;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Action } from '@reduxjs/toolkit';
|
||||
|
||||
import { ElasticsearchDataQuery, MetricAggregation } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
|
||||
|
||||
import { defaultMetricAgg } from '../../../../queryDef';
|
||||
import { defaultMetricAgg, queryTypeToMetricType } from '../../../../queryDef';
|
||||
import { removeEmpty } from '../../../../utils';
|
||||
import { initQuery } from '../../state';
|
||||
import { isMetricAggregationWithMeta, isMetricAggregationWithSettings, isPipelineAggregation } from '../aggregations';
|
||||
@@ -162,7 +162,8 @@ export const reducer = (
|
||||
if (state && state.length > 0) {
|
||||
return state;
|
||||
}
|
||||
return [defaultMetricAgg('1')];
|
||||
const metricType = queryTypeToMetricType(action.payload);
|
||||
return [{ type: metricType, id: '1' }];
|
||||
}
|
||||
|
||||
return state;
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { ElasticsearchDataQuery } from '../../dataquery.gen';
|
||||
import { useDispatch } from '../../hooks/useStatelessReducer';
|
||||
import { renderWithESProvider } from '../../test-helpers/render';
|
||||
|
||||
import { changeMetricType } from './MetricAggregationsEditor/state/actions';
|
||||
import { QueryTypeSelector } from './QueryTypeSelector';
|
||||
|
||||
jest.mock('../../hooks/useStatelessReducer');
|
||||
|
||||
describe('QueryTypeSelector', () => {
|
||||
let dispatch: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
dispatch = jest.fn();
|
||||
jest.mocked(useDispatch).mockReturnValue(dispatch);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render radio buttons with correct options', () => {
|
||||
const query: ElasticsearchDataQuery = {
|
||||
refId: 'A',
|
||||
query: '',
|
||||
metrics: [{ id: '1', type: 'count' }],
|
||||
bucketAggs: [{ type: 'date_histogram', id: '2' }],
|
||||
};
|
||||
|
||||
renderWithESProvider(<QueryTypeSelector />, { providerProps: { query } });
|
||||
|
||||
expect(screen.getByRole('radio', { name: 'Metrics' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('radio', { name: 'Logs' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('radio', { name: 'Raw Data' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('radio', { name: 'Raw Document' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should dispatch changeMetricType action when radio button is changed', async () => {
|
||||
const query: ElasticsearchDataQuery = {
|
||||
refId: 'A',
|
||||
query: '',
|
||||
metrics: [{ id: '1', type: 'count' }],
|
||||
bucketAggs: [{ type: 'date_histogram', id: '2' }],
|
||||
};
|
||||
|
||||
renderWithESProvider(<QueryTypeSelector />, { providerProps: { query } });
|
||||
|
||||
const logsRadio = screen.getByRole('radio', { name: 'Logs' });
|
||||
await userEvent.click(logsRadio);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(changeMetricType({ id: '1', type: 'logs' }));
|
||||
});
|
||||
|
||||
it('should convert query type to metric type correctly for raw_data', async () => {
|
||||
const query: ElasticsearchDataQuery = {
|
||||
refId: 'A',
|
||||
query: '',
|
||||
metrics: [{ id: '1', type: 'count' }],
|
||||
bucketAggs: [{ type: 'date_histogram', id: '2' }],
|
||||
};
|
||||
|
||||
renderWithESProvider(<QueryTypeSelector />, { providerProps: { query } });
|
||||
|
||||
const rawDataRadio = screen.getByRole('radio', { name: 'Raw Data' });
|
||||
await userEvent.click(rawDataRadio);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(changeMetricType({ id: '1', type: 'raw_data' }));
|
||||
});
|
||||
|
||||
it('should convert query type to metric type correctly for raw_document', async () => {
|
||||
const query: ElasticsearchDataQuery = {
|
||||
refId: 'A',
|
||||
query: '',
|
||||
metrics: [{ id: '1', type: 'count' }],
|
||||
bucketAggs: [{ type: 'date_histogram', id: '2' }],
|
||||
};
|
||||
|
||||
renderWithESProvider(<QueryTypeSelector />, { providerProps: { query } });
|
||||
|
||||
const rawDocumentRadio = screen.getByRole('radio', { name: 'Raw Document' });
|
||||
await userEvent.click(rawDocumentRadio);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(changeMetricType({ id: '1', type: 'raw_document' }));
|
||||
});
|
||||
|
||||
it('should convert metrics query type to count metric type', async () => {
|
||||
const query: ElasticsearchDataQuery = {
|
||||
refId: 'A',
|
||||
query: '',
|
||||
metrics: [{ id: '1', type: 'logs' }],
|
||||
bucketAggs: [{ type: 'date_histogram', id: '2' }],
|
||||
};
|
||||
|
||||
renderWithESProvider(<QueryTypeSelector />, { providerProps: { query } });
|
||||
|
||||
const metricsRadio = screen.getByRole('radio', { name: 'Metrics' });
|
||||
await userEvent.click(metricsRadio);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(changeMetricType({ id: '1', type: 'count' }));
|
||||
});
|
||||
|
||||
it('should return null when query has no metrics', () => {
|
||||
const query: ElasticsearchDataQuery = {
|
||||
refId: 'A',
|
||||
query: '',
|
||||
metrics: [],
|
||||
bucketAggs: [{ type: 'date_histogram', id: '2' }],
|
||||
};
|
||||
|
||||
const { container } = renderWithESProvider(<QueryTypeSelector />, { providerProps: { query } });
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,35 +1,14 @@
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { RadioButtonGroup } from '@grafana/ui';
|
||||
|
||||
import { MetricAggregation } from '../../dataquery.gen';
|
||||
import { QUERY_TYPE_SELECTOR_OPTIONS } from '../../configuration/utils';
|
||||
import { useDispatch } from '../../hooks/useStatelessReducer';
|
||||
import { queryTypeToMetricType } from '../../queryDef';
|
||||
import { QueryType } from '../../types';
|
||||
|
||||
import { useQuery } from './ElasticsearchQueryContext';
|
||||
import { changeMetricType } from './MetricAggregationsEditor/state/actions';
|
||||
import { metricAggregationConfig } from './MetricAggregationsEditor/utils';
|
||||
|
||||
const OPTIONS: Array<SelectableValue<QueryType>> = [
|
||||
{ value: 'metrics', label: 'Metrics' },
|
||||
{ value: 'logs', label: 'Logs' },
|
||||
{ value: 'raw_data', label: 'Raw Data' },
|
||||
{ value: 'raw_document', label: 'Raw Document' },
|
||||
];
|
||||
|
||||
function queryTypeToMetricType(type: QueryType): MetricAggregation['type'] {
|
||||
switch (type) {
|
||||
case 'logs':
|
||||
case 'raw_data':
|
||||
case 'raw_document':
|
||||
return type;
|
||||
case 'metrics':
|
||||
return 'count';
|
||||
default:
|
||||
// should never happen
|
||||
throw new Error(`invalid query type: ${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const QueryTypeSelector = () => {
|
||||
const query = useQuery();
|
||||
const dispatch = useDispatch();
|
||||
@@ -47,5 +26,12 @@ export const QueryTypeSelector = () => {
|
||||
dispatch(changeMetricType({ id: firstMetric.id, type: queryTypeToMetricType(newQueryType) }));
|
||||
};
|
||||
|
||||
return <RadioButtonGroup<QueryType> fullWidth={false} options={OPTIONS} value={queryType} onChange={onChange} />;
|
||||
return (
|
||||
<RadioButtonGroup<QueryType>
|
||||
fullWidth={false}
|
||||
options={QUERY_TYPE_SELECTOR_OPTIONS}
|
||||
value={queryType}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Action, createAction } from '@reduxjs/toolkit';
|
||||
|
||||
import { ElasticsearchDataQuery } from '../../dataquery.gen';
|
||||
import { QueryType } from '../../types';
|
||||
|
||||
/**
|
||||
* When the `initQuery` Action is dispatched, the query gets populated with default values where values are not present.
|
||||
* This means it won't override any existing value in place, but just ensure the query is in a "runnable" state.
|
||||
*/
|
||||
export const initQuery = createAction('init');
|
||||
export const initQuery = createAction<QueryType | undefined>('init');
|
||||
|
||||
export const changeQuery = createAction<ElasticsearchDataQuery['query']>('change_query');
|
||||
|
||||
|
||||
@@ -41,4 +41,16 @@ describe('ElasticDetails', () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should change default query mode when selected', async () => {
|
||||
const onChangeMock = jest.fn();
|
||||
render(<ElasticDetails onChange={onChangeMock} value={createDefaultConfigOptions()} />);
|
||||
const selectEl = screen.getByLabelText('Default query mode');
|
||||
|
||||
await selectEvent.select(selectEl, 'Logs', { container: document.body });
|
||||
|
||||
expect(onChangeMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ jsonData: expect.objectContaining({ defaultQueryMode: 'logs' }) })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { DataSourceSettings, SelectableValue } from '@grafana/data';
|
||||
import type { DataSourceSettings, SelectableValue } from '@grafana/data';
|
||||
import { ConfigDescriptionLink, ConfigSubSection } from '@grafana/plugin-ui';
|
||||
import { InlineField, Input, Select, InlineSwitch } from '@grafana/ui';
|
||||
|
||||
import { ElasticsearchOptions, Interval } from '../types';
|
||||
import type { ElasticsearchOptions, Interval, QueryType } from '../types';
|
||||
|
||||
import { QUERY_TYPE_SELECTOR_OPTIONS } from './utils';
|
||||
|
||||
const indexPatternTypes: Array<SelectableValue<'none' | Interval>> = [
|
||||
{ label: 'No pattern', value: 'none' },
|
||||
@@ -127,6 +129,29 @@ export const ElasticDetails = ({ value, onChange }: Props) => {
|
||||
onChange={jsonDataSwitchChangeHandler('includeFrozen', value, onChange)}
|
||||
/>
|
||||
</InlineField>
|
||||
|
||||
<InlineField
|
||||
label="Default query mode"
|
||||
htmlFor="es_config_defaultQueryMode"
|
||||
labelWidth={29}
|
||||
tooltip="Default query mode to use for the data source. Defaults to 'Metrics'."
|
||||
>
|
||||
<Select
|
||||
inputId="es_config_defaultQueryMode"
|
||||
value={value.jsonData.defaultQueryMode}
|
||||
options={QUERY_TYPE_SELECTOR_OPTIONS}
|
||||
onChange={(selectedMode) => {
|
||||
onChange({
|
||||
...value,
|
||||
jsonData: {
|
||||
...value.jsonData,
|
||||
defaultQueryMode: selectedMode.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
width={24}
|
||||
/>
|
||||
</InlineField>
|
||||
</ConfigSubSection>
|
||||
);
|
||||
};
|
||||
@@ -209,3 +234,6 @@ const intervalHandler =
|
||||
export function defaultMaxConcurrentShardRequests() {
|
||||
return 5;
|
||||
}
|
||||
export function defaultQueryMode(): QueryType {
|
||||
return 'metrics';
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export function createDefaultConfigOptions(): DataSourceSettings<ElasticsearchOp
|
||||
maxConcurrentShardRequests: 300,
|
||||
logMessageField: 'test.message',
|
||||
logLevelField: 'test.level',
|
||||
defaultQueryMode: 'metrics',
|
||||
},
|
||||
secureJsonFields: {},
|
||||
} as DataSourceSettings<ElasticsearchOptions>;
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { DataSourceSettings } from '@grafana/data';
|
||||
import { DataSourceSettings, SelectableValue } from '@grafana/data';
|
||||
|
||||
import { ElasticsearchOptions } from '../types';
|
||||
import { ElasticsearchOptions, QueryType } from '../types';
|
||||
|
||||
import { defaultMaxConcurrentShardRequests } from './ElasticDetails';
|
||||
import { defaultMaxConcurrentShardRequests, defaultQueryMode } from './ElasticDetails';
|
||||
|
||||
export const QUERY_TYPE_SELECTOR_OPTIONS: Array<SelectableValue<QueryType>> = [
|
||||
{ value: 'metrics', label: 'Metrics' },
|
||||
{ value: 'logs', label: 'Logs' },
|
||||
{ value: 'raw_data', label: 'Raw Data' },
|
||||
{ value: 'raw_document', label: 'Raw Document' },
|
||||
];
|
||||
|
||||
export const coerceOptions = (
|
||||
options: DataSourceSettings<ElasticsearchOptions, {}>
|
||||
@@ -16,6 +23,7 @@ export const coerceOptions = (
|
||||
logMessageField: options.jsonData.logMessageField || '',
|
||||
logLevelField: options.jsonData.logLevelField || '',
|
||||
includeFrozen: options.jsonData.includeFrozen ?? false,
|
||||
defaultQueryMode: options.jsonData.defaultQueryMode || defaultQueryMode(),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -81,6 +81,7 @@ import {
|
||||
isElasticsearchResponseWithAggregations,
|
||||
isElasticsearchResponseWithHits,
|
||||
ElasticsearchHits,
|
||||
QueryType,
|
||||
} from './types';
|
||||
import { getScriptValue, isTimeSeriesQuery } from './utils';
|
||||
|
||||
@@ -127,6 +128,7 @@ export class ElasticDatasource
|
||||
includeFrozen: boolean;
|
||||
isProxyAccess: boolean;
|
||||
databaseVersion: SemVer | null;
|
||||
defaultQueryMode?: QueryType;
|
||||
|
||||
constructor(
|
||||
instanceSettings: DataSourceInstanceSettings<ElasticsearchOptions>,
|
||||
@@ -146,9 +148,6 @@ export class ElasticDatasource
|
||||
this.intervalPattern = settingsData.interval;
|
||||
this.interval = settingsData.timeInterval;
|
||||
this.maxConcurrentShardRequests = settingsData.maxConcurrentShardRequests;
|
||||
this.queryBuilder = new ElasticQueryBuilder({
|
||||
timeField: this.timeField,
|
||||
});
|
||||
this.logLevelField = settingsData.logLevelField || '';
|
||||
this.dataLinks = settingsData.dataLinks || [];
|
||||
this.includeFrozen = settingsData.includeFrozen ?? false;
|
||||
@@ -157,7 +156,11 @@ export class ElasticDatasource
|
||||
this.annotations = {
|
||||
QueryEditor: ElasticsearchAnnotationsQueryEditor,
|
||||
};
|
||||
|
||||
this.defaultQueryMode = settingsData.defaultQueryMode;
|
||||
this.queryBuilder = new ElasticQueryBuilder({
|
||||
timeField: this.timeField,
|
||||
defaultQueryMode: this.defaultQueryMode,
|
||||
});
|
||||
if (this.logLevelField === '') {
|
||||
this.logLevelField = undefined;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isPipelineAgg, isPipelineAggWithMultipleBucketPaths } from './queryDef';
|
||||
import { isPipelineAgg, isPipelineAggWithMultipleBucketPaths, queryTypeToMetricType } from './queryDef';
|
||||
import type { QueryType } from './types';
|
||||
|
||||
describe('ElasticQueryDef', () => {
|
||||
describe('isPipelineMetric', () => {
|
||||
@@ -36,4 +37,49 @@ describe('ElasticQueryDef', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('queryTypeToMetricType', () => {
|
||||
describe('when type is undefined', () => {
|
||||
test('should return count as default', () => {
|
||||
const result = queryTypeToMetricType(undefined);
|
||||
expect(result).toBe('count');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when type is metrics', () => {
|
||||
test('should return count', () => {
|
||||
const result = queryTypeToMetricType('metrics' as QueryType);
|
||||
expect(result).toBe('count');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when type is logs', () => {
|
||||
test('should return logs', () => {
|
||||
const result = queryTypeToMetricType('logs' as QueryType);
|
||||
expect(result).toBe('logs');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when type is raw_data', () => {
|
||||
test('should return raw_data', () => {
|
||||
const result = queryTypeToMetricType('raw_data' as QueryType);
|
||||
expect(result).toBe('raw_data');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when type is raw_document', () => {
|
||||
test('should return raw_document', () => {
|
||||
const result = queryTypeToMetricType('raw_document' as QueryType);
|
||||
expect(result).toBe('raw_document');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when type is invalid', () => {
|
||||
test('should throw an error', () => {
|
||||
expect(() => {
|
||||
queryTypeToMetricType('invalid_type' as QueryType);
|
||||
}).toThrow('invalid query type: invalid_type');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
MetricAggregationType,
|
||||
MovingAverageModelOption,
|
||||
} from './dataquery.gen';
|
||||
import type { QueryType } from './types';
|
||||
|
||||
export const extendedStats: ExtendedStat[] = [
|
||||
{ label: 'Avg', value: 'avg' },
|
||||
@@ -42,6 +43,24 @@ export function defaultBucketAgg(id = '1'): DateHistogram {
|
||||
return { type: 'date_histogram', id, settings: { interval: 'auto' } };
|
||||
}
|
||||
|
||||
export function queryTypeToMetricType(type?: QueryType): MetricAggregation['type'] {
|
||||
if (!type) {
|
||||
return 'count'; // Default fallback
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'logs':
|
||||
case 'raw_data':
|
||||
case 'raw_document':
|
||||
return type;
|
||||
case 'metrics':
|
||||
return 'count';
|
||||
default:
|
||||
// should never happen
|
||||
throw new Error(`invalid query type: ${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const findMetricById = (metrics: MetricAggregation[], id: MetricAggregation['id']) =>
|
||||
metrics.find((metric) => metric.id === id);
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ export interface ElasticsearchOptions extends DataSourceJsonData {
|
||||
index?: string;
|
||||
sigV4Auth?: boolean;
|
||||
oauthPassThru?: boolean;
|
||||
defaultQueryMode?: QueryType;
|
||||
}
|
||||
|
||||
export type QueryType = 'metrics' | 'logs' | 'raw_data' | 'raw_document';
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
XAxisInteractionAreaPlugin,
|
||||
usePanelContext,
|
||||
} from '@grafana/ui';
|
||||
import { TimeRange2, TooltipHoverMode } from '@grafana/ui/internal';
|
||||
import { FILTER_OUT_OPERATOR, TimeRange2, TooltipHoverMode } from '@grafana/ui/internal';
|
||||
import { TimeSeries } from 'app/core/components/TimeSeries/TimeSeries';
|
||||
import { config } from 'app/core/config';
|
||||
|
||||
@@ -31,7 +31,7 @@ import { OutsideRangePlugin } from './plugins/OutsideRangePlugin';
|
||||
import { ThresholdControlsPlugin } from './plugins/ThresholdControlsPlugin';
|
||||
import { getXAnnotationFrames } from './plugins/utils';
|
||||
import { getPrepareTimeseriesSuggestion } from './suggestions';
|
||||
import { getTimezones, prepareGraphableFields } from './utils';
|
||||
import { getGroupedFilters, getTimezones, prepareGraphableFields } from './utils';
|
||||
|
||||
interface TimeSeriesPanelProps extends PanelProps<Options> {}
|
||||
|
||||
@@ -56,6 +56,8 @@ export const TimeSeriesPanel = ({
|
||||
showThresholds,
|
||||
eventBus,
|
||||
canExecuteActions,
|
||||
getFiltersBasedOnGrouping,
|
||||
onAddAdHocFilters,
|
||||
} = usePanelContext();
|
||||
|
||||
const { dataLinkPostProcessor } = useDataLinksContext();
|
||||
@@ -175,6 +177,11 @@ export const TimeSeriesPanel = ({
|
||||
dismiss();
|
||||
};
|
||||
|
||||
const groupingFilters =
|
||||
seriesIdx !== null && config.featureToggles.perPanelFiltering && getFiltersBasedOnGrouping
|
||||
? getGroupedFilters(alignedFrame, seriesIdx, getFiltersBasedOnGrouping)
|
||||
: [];
|
||||
|
||||
return (
|
||||
// not sure it header time here works for annotations, since it's taken from nearest datapoint index
|
||||
<TimeSeriesTooltip
|
||||
@@ -189,6 +196,17 @@ export const TimeSeriesPanel = ({
|
||||
maxHeight={options.tooltip.maxHeight}
|
||||
replaceVariables={replaceVariables}
|
||||
dataLinks={dataLinks}
|
||||
filterByGroupedLabels={
|
||||
config.featureToggles.perPanelFiltering && groupingFilters.length && onAddAdHocFilters
|
||||
? {
|
||||
onFilterForGroupedLabels: () => onAddAdHocFilters(groupingFilters),
|
||||
onFilterOutGroupedLabels: () =>
|
||||
onAddAdHocFilters(
|
||||
groupingFilters.map((item) => ({ ...item, operator: FILTER_OUT_OPERATOR }))
|
||||
),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
canExecuteActions={userCanExecuteActions}
|
||||
compareDiffMs={compareDiffMs}
|
||||
/>
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
getContentItems,
|
||||
VizTooltipItem,
|
||||
AdHocFilterModel,
|
||||
FilterByGroupedLabelsModel,
|
||||
} from '@grafana/ui/internal';
|
||||
|
||||
import { getFieldActions } from '../status-history/utils';
|
||||
@@ -50,6 +51,7 @@ export interface TimeSeriesTooltipProps {
|
||||
dataLinks: LinkModel[];
|
||||
hideZeros?: boolean;
|
||||
adHocFilters?: AdHocFilterModel[];
|
||||
filterByGroupedLabels?: FilterByGroupedLabelsModel;
|
||||
canExecuteActions?: boolean;
|
||||
compareDiffMs?: number[];
|
||||
}
|
||||
@@ -70,8 +72,10 @@ export const TimeSeriesTooltip = ({
|
||||
adHocFilters,
|
||||
canExecuteActions,
|
||||
compareDiffMs,
|
||||
filterByGroupedLabels,
|
||||
}: TimeSeriesTooltipProps) => {
|
||||
const pluginContext = usePluginContext();
|
||||
|
||||
const xField = series.fields[0];
|
||||
let xVal = xField.values[dataIdxs[0]!];
|
||||
|
||||
@@ -107,7 +111,13 @@ export const TimeSeriesTooltip = ({
|
||||
: [];
|
||||
|
||||
footer = (
|
||||
<VizTooltipFooter dataLinks={dataLinks} actions={actions} annotate={annotate} adHocFilters={adHocFilters} />
|
||||
<VizTooltipFooter
|
||||
dataLinks={dataLinks}
|
||||
actions={actions}
|
||||
annotate={annotate}
|
||||
adHocFilters={adHocFilters}
|
||||
filterByGroupedLabels={filterByGroupedLabels}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { createTheme, FieldType, createDataFrame, toDataFrame } from '@grafana/data';
|
||||
import { LineInterpolation } from '@grafana/ui';
|
||||
|
||||
import { prepareGraphableFields } from './utils';
|
||||
import { AdHocFilterItem } from '../../../../../packages/grafana-ui/src/components/Table/TableNG/types';
|
||||
|
||||
import { getGroupedFilters, prepareGraphableFields } from './utils';
|
||||
|
||||
describe('prepare timeseries graph', () => {
|
||||
it('errors with no time fields', () => {
|
||||
@@ -178,4 +180,83 @@ describe('prepare timeseries graph', () => {
|
||||
expect(frames![0].fields[1].config.custom.lineInterpolation).toEqual(LineInterpolation.StepAfter);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGroupedFilters', () => {
|
||||
it('returns empty array if no field', () => {
|
||||
const df = createDataFrame({
|
||||
fields: [{ name: 'time', type: FieldType.time, values: [1, 2, 3] }],
|
||||
});
|
||||
|
||||
expect(getGroupedFilters(df, 1, jest.fn())).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array if no labels', () => {
|
||||
const df = createDataFrame({
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1, 2, 3] },
|
||||
{
|
||||
name: 'value',
|
||||
type: FieldType.number,
|
||||
values: [1, 2, 3],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(getGroupedFilters(df, 1, jest.fn())).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array if field not filterable', () => {
|
||||
const df = createDataFrame({
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1, 2, 3] },
|
||||
{
|
||||
name: 'value',
|
||||
type: FieldType.number,
|
||||
values: [1, 2, 3],
|
||||
labels: {
|
||||
test: 'value',
|
||||
label: 'value2',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(getGroupedFilters(df, 1, jest.fn())).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns grouped filters', () => {
|
||||
const df = createDataFrame({
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1, 2, 3] },
|
||||
{
|
||||
name: 'value',
|
||||
type: FieldType.number,
|
||||
values: [1, 2, 3],
|
||||
labels: {
|
||||
test: 'value',
|
||||
label: 'value2',
|
||||
},
|
||||
config: {
|
||||
filterable: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const filtersGroupingFn = (filters: AdHocFilterItem[]) => filters;
|
||||
|
||||
expect(getGroupedFilters(df, 1, filtersGroupingFn)).toEqual([
|
||||
{
|
||||
key: 'test',
|
||||
operator: '=',
|
||||
value: 'value',
|
||||
},
|
||||
{
|
||||
key: 'label',
|
||||
operator: '=',
|
||||
value: 'value2',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
} from '@grafana/data';
|
||||
import { convertFieldType } from '@grafana/data/internal';
|
||||
import { GraphFieldConfig, LineInterpolation, TooltipDisplayMode, VizTooltipOptions } from '@grafana/schema';
|
||||
import { buildScaleKey } from '@grafana/ui/internal';
|
||||
import { AdHocFilterItem } from '@grafana/ui';
|
||||
import { buildScaleKey, FILTER_FOR_OPERATOR } from '@grafana/ui/internal';
|
||||
|
||||
import { HeatmapTooltip } from '../heatmap/panelcfg.gen';
|
||||
|
||||
@@ -329,3 +330,28 @@ export function getTimezones(timezones: string[] | undefined, defaultTimezone: s
|
||||
export const isTooltipScrollable = (tooltipOptions: VizTooltipOptions | HeatmapTooltip) => {
|
||||
return tooltipOptions.mode === TooltipDisplayMode.Multi && tooltipOptions.maxHeight != null;
|
||||
};
|
||||
|
||||
export function getGroupedFilters(
|
||||
frame: DataFrame,
|
||||
seriesIdx: number,
|
||||
getFiltersBasedOnGrouping: (filters: AdHocFilterItem[]) => AdHocFilterItem[]
|
||||
) {
|
||||
const groupingFilters: AdHocFilterItem[] = [];
|
||||
const xField = frame.fields[seriesIdx];
|
||||
|
||||
if (xField && xField.labels && xField.config.filterable) {
|
||||
const seriesFilters: AdHocFilterItem[] = [];
|
||||
|
||||
Object.entries(xField.labels).forEach(([key, value]) => {
|
||||
seriesFilters.push({
|
||||
key,
|
||||
operator: FILTER_FOR_OPERATOR,
|
||||
value,
|
||||
});
|
||||
});
|
||||
|
||||
groupingFilters.push(...getFiltersBasedOnGrouping(seriesFilters));
|
||||
}
|
||||
|
||||
return groupingFilters;
|
||||
}
|
||||
|
||||
@@ -2334,9 +2334,8 @@
|
||||
"label-tenant-sources": "Tenant sources"
|
||||
},
|
||||
"rule-details-matching-instances": {
|
||||
"button-show-all_one": "Show all {{totalItems}} alert instances",
|
||||
"button-show-all_other": "Show all {{totalItems}} alert instances",
|
||||
"showing-count": "Showing {{visibleItems}} out of {{totalItems}} instances"
|
||||
"button-show-all": "Show all {{totalItemsCount}} alert instances",
|
||||
"showing-count": "Showing {{visibleItemsCount}} out of {{totalItemsCount}} instances"
|
||||
},
|
||||
"rule-editor": {
|
||||
"get-content": {
|
||||
@@ -8252,6 +8251,12 @@
|
||||
"nextNYears_other": "Next {{count}} years"
|
||||
}
|
||||
},
|
||||
"field": {
|
||||
"fieldColor": {
|
||||
"accessibleGroup": "Accessible",
|
||||
"otherGroup": "Others"
|
||||
}
|
||||
},
|
||||
"valueFormats": {
|
||||
"categories": {
|
||||
"acceleration": {
|
||||
@@ -9252,6 +9257,8 @@
|
||||
"actions-confirmation-label": "Confirmation message",
|
||||
"actions-confirmation-message": "Provide a descriptive prompt to confirm or cancel the action.",
|
||||
"footer-add-annotation": "Add annotation",
|
||||
"footer-apply-series-as-filter": "Apply as filter",
|
||||
"footer-apply-series-as-inverse-filter": "Apply as inverse filter",
|
||||
"footer-click-to-action": "Click to {{actionTitle}}",
|
||||
"footer-click-to-navigate": "Click to open {{linkTitle}}",
|
||||
"footer-filter-for-value": "Filter for '{{value}}'",
|
||||
|
||||
Reference in New Issue
Block a user