Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f37986e97b | |||
| 29ad717011 | |||
| a0c4e8b4f4 | |||
| fa62113b41 | |||
| b863acab05 | |||
| c7c052480d | |||
| 478ae15f0e | |||
| 8ebb1c2bc9 | |||
| 5572ce966a | |||
| e3510f6eb3 | |||
| a832e5f222 | |||
| c5a5482d7d | |||
| 169ffc15c6 | |||
| 296fe610ba | |||
| eceff8d26e | |||
| 3cdfe34ec8 | |||
| 35c214249f | |||
| c3224411c0 | |||
| b407f0062d | |||
| 0385a7a4a4 | |||
| 1611489b84 | |||
| e8039d1c3d | |||
| 652b4f2fab | |||
| c35642b04d | |||
| 91a72f2572 | |||
| f8027e4d75 | |||
| f5b2dde4a1 | |||
| 0c264b7a5f | |||
| d83b216a32 | |||
| ada14df9fd |
@@ -22,13 +22,40 @@ v0alpha1: {
|
||||
serviceaccountv0alpha1,
|
||||
externalGroupMappingv0alpha1
|
||||
]
|
||||
|
||||
routes: {
|
||||
namespaced: {
|
||||
"/searchUsers": {
|
||||
"GET": {
|
||||
request: {
|
||||
query: {
|
||||
query?: string
|
||||
limit?: int64 | 10
|
||||
offset?: int64 | 0
|
||||
page?: int64 | 1
|
||||
}
|
||||
}
|
||||
response: {
|
||||
offset: int64
|
||||
totalHits: int64
|
||||
hits: [...#UserHit]
|
||||
queryCost: float64
|
||||
maxScore: float64
|
||||
}
|
||||
responseMetadata: {
|
||||
typeMeta: false
|
||||
objectMeta: false
|
||||
}
|
||||
}
|
||||
}
|
||||
"/searchTeams": {
|
||||
"GET": {
|
||||
request: {
|
||||
query: {
|
||||
query?: string
|
||||
limit?: int64 | 50
|
||||
offset?: int64 | 0
|
||||
page?: int64 | 1
|
||||
}
|
||||
}
|
||||
response: {
|
||||
@@ -51,3 +78,15 @@ v0alpha1: {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#UserHit: {
|
||||
name: string
|
||||
title: string
|
||||
login: string
|
||||
email: string
|
||||
role: string
|
||||
lastSeenAt: int64
|
||||
lastSeenAtAge: string
|
||||
provisioned: bool
|
||||
score: float64
|
||||
}
|
||||
|
||||
@@ -29,6 +29,9 @@ userv0alpha1: userKind & {
|
||||
// }
|
||||
schema: {
|
||||
spec: v0alpha1.UserSpec
|
||||
status: {
|
||||
lastSeenAt: int64 | 0
|
||||
}
|
||||
}
|
||||
// TODO: Uncomment when the custom routes implementation is done
|
||||
// routes: {
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
package v0alpha1
|
||||
|
||||
type GetSearchTeamsRequestParams struct {
|
||||
Query *string `json:"query,omitempty"`
|
||||
Query *string `json:"query,omitempty"`
|
||||
Limit int64 `json:"limit,omitempty"`
|
||||
Offset int64 `json:"offset,omitempty"`
|
||||
Page int64 `json:"page,omitempty"`
|
||||
}
|
||||
|
||||
// NewGetSearchTeamsRequestParams creates a new GetSearchTeamsRequestParams object.
|
||||
|
||||
@@ -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 GetSearchUsersRequestParamsObject struct {
|
||||
metav1.TypeMeta `json:",inline"`
|
||||
GetSearchUsersRequestParams `json:",inline"`
|
||||
}
|
||||
|
||||
func NewGetSearchUsersRequestParamsObject() *GetSearchUsersRequestParamsObject {
|
||||
return &GetSearchUsersRequestParamsObject{}
|
||||
}
|
||||
|
||||
func (o *GetSearchUsersRequestParamsObject) DeepCopyObject() runtime.Object {
|
||||
dst := NewGetSearchUsersRequestParamsObject()
|
||||
o.DeepCopyInto(dst)
|
||||
return dst
|
||||
}
|
||||
|
||||
func (o *GetSearchUsersRequestParamsObject) DeepCopyInto(dst *GetSearchUsersRequestParamsObject) {
|
||||
dst.TypeMeta.APIVersion = o.TypeMeta.APIVersion
|
||||
dst.TypeMeta.Kind = o.TypeMeta.Kind
|
||||
dstGetSearchUsersRequestParams := GetSearchUsersRequestParams{}
|
||||
_ = resource.CopyObjectInto(&dstGetSearchUsersRequestParams, &o.GetSearchUsersRequestParams)
|
||||
}
|
||||
|
||||
var _ runtime.Object = NewGetSearchUsersRequestParamsObject()
|
||||
@@ -0,0 +1,15 @@
|
||||
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
|
||||
|
||||
package v0alpha1
|
||||
|
||||
type GetSearchUsersRequestParams struct {
|
||||
Query *string `json:"query,omitempty"`
|
||||
Limit int64 `json:"limit,omitempty"`
|
||||
Offset int64 `json:"offset,omitempty"`
|
||||
Page int64 `json:"page,omitempty"`
|
||||
}
|
||||
|
||||
// NewGetSearchUsersRequestParams creates a new GetSearchUsersRequestParams object.
|
||||
func NewGetSearchUsersRequestParams() *GetSearchUsersRequestParams {
|
||||
return &GetSearchUsersRequestParams{}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
|
||||
|
||||
package v0alpha1
|
||||
|
||||
// +k8s:openapi-gen=true
|
||||
type UserHit struct {
|
||||
Name string `json:"name"`
|
||||
Title string `json:"title"`
|
||||
Login string `json:"login"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
LastSeenAt int64 `json:"lastSeenAt"`
|
||||
LastSeenAtAge string `json:"lastSeenAtAge"`
|
||||
Provisioned bool `json:"provisioned"`
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
|
||||
// NewUserHit creates a new UserHit object.
|
||||
func NewUserHit() *UserHit {
|
||||
return &UserHit{}
|
||||
}
|
||||
|
||||
// +k8s:openapi-gen=true
|
||||
type GetSearchUsers struct {
|
||||
Offset int64 `json:"offset"`
|
||||
TotalHits int64 `json:"totalHits"`
|
||||
Hits []UserHit `json:"hits"`
|
||||
QueryCost float64 `json:"queryCost"`
|
||||
MaxScore float64 `json:"maxScore"`
|
||||
}
|
||||
|
||||
// NewGetSearchUsers creates a new GetSearchUsers object.
|
||||
func NewGetSearchUsers() *GetSearchUsers {
|
||||
return &GetSearchUsers{
|
||||
Hits: []UserHit{},
|
||||
}
|
||||
}
|
||||
+19
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/grafana/grafana-app-sdk/resource"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
type UserClient struct {
|
||||
@@ -75,6 +76,24 @@ func (c *UserClient) Patch(ctx context.Context, identifier resource.Identifier,
|
||||
return c.client.Patch(ctx, identifier, req, opts)
|
||||
}
|
||||
|
||||
func (c *UserClient) UpdateStatus(ctx context.Context, identifier resource.Identifier, newStatus UserStatus, opts resource.UpdateOptions) (*User, error) {
|
||||
return c.client.Update(ctx, &User{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: UserKind().Kind(),
|
||||
APIVersion: GroupVersion.Identifier(),
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
ResourceVersion: opts.ResourceVersion,
|
||||
Namespace: identifier.Namespace,
|
||||
Name: identifier.Name,
|
||||
},
|
||||
Status: newStatus,
|
||||
}, resource.UpdateOptions{
|
||||
Subresource: "status",
|
||||
ResourceVersion: opts.ResourceVersion,
|
||||
})
|
||||
}
|
||||
|
||||
func (c *UserClient) Delete(ctx context.Context, identifier resource.Identifier, opts resource.DeleteOptions) error {
|
||||
return c.client.Delete(ctx, identifier, opts)
|
||||
}
|
||||
|
||||
+29
-2
@@ -21,11 +21,14 @@ type User struct {
|
||||
|
||||
// Spec is the spec of the User
|
||||
Spec UserSpec `json:"spec" yaml:"spec"`
|
||||
|
||||
Status UserStatus `json:"status" yaml:"status"`
|
||||
}
|
||||
|
||||
func NewUser() *User {
|
||||
return &User{
|
||||
Spec: *NewUserSpec(),
|
||||
Spec: *NewUserSpec(),
|
||||
Status: *NewUserStatus(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,11 +46,15 @@ func (o *User) SetSpec(spec any) error {
|
||||
}
|
||||
|
||||
func (o *User) GetSubresources() map[string]any {
|
||||
return map[string]any{}
|
||||
return map[string]any{
|
||||
"status": o.Status,
|
||||
}
|
||||
}
|
||||
|
||||
func (o *User) GetSubresource(name string) (any, bool) {
|
||||
switch name {
|
||||
case "status":
|
||||
return o.Status, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
@@ -55,6 +62,13 @@ func (o *User) GetSubresource(name string) (any, bool) {
|
||||
|
||||
func (o *User) SetSubresource(name string, value any) error {
|
||||
switch name {
|
||||
case "status":
|
||||
cast, ok := value.(UserStatus)
|
||||
if !ok {
|
||||
return fmt.Errorf("cannot set status type %#v, not of type UserStatus", value)
|
||||
}
|
||||
o.Status = cast
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("subresource '%s' does not exist", name)
|
||||
}
|
||||
@@ -226,6 +240,7 @@ func (o *User) DeepCopyInto(dst *User) {
|
||||
dst.TypeMeta.Kind = o.TypeMeta.Kind
|
||||
o.ObjectMeta.DeepCopyInto(&dst.ObjectMeta)
|
||||
o.Spec.DeepCopyInto(&dst.Spec)
|
||||
o.Status.DeepCopyInto(&dst.Status)
|
||||
}
|
||||
|
||||
// Interface compliance compile-time check
|
||||
@@ -297,3 +312,15 @@ func (s *UserSpec) DeepCopy() *UserSpec {
|
||||
func (s *UserSpec) DeepCopyInto(dst *UserSpec) {
|
||||
resource.CopyObjectInto(dst, s)
|
||||
}
|
||||
|
||||
// DeepCopy creates a full deep copy of UserStatus
|
||||
func (s *UserStatus) DeepCopy() *UserStatus {
|
||||
cpy := &UserStatus{}
|
||||
s.DeepCopyInto(cpy)
|
||||
return cpy
|
||||
}
|
||||
|
||||
// DeepCopyInto deep copies UserStatus into another UserStatus object
|
||||
func (s *UserStatus) DeepCopyInto(dst *UserStatus) {
|
||||
resource.CopyObjectInto(dst, s)
|
||||
}
|
||||
|
||||
+1
-32
@@ -2,43 +2,12 @@
|
||||
|
||||
package v0alpha1
|
||||
|
||||
// +k8s:openapi-gen=true
|
||||
type UserstatusOperatorState struct {
|
||||
// lastEvaluation is the ResourceVersion last evaluated
|
||||
LastEvaluation string `json:"lastEvaluation"`
|
||||
// state describes the state of the lastEvaluation.
|
||||
// It is limited to three possible states for machine evaluation.
|
||||
State UserStatusOperatorStateState `json:"state"`
|
||||
// descriptiveState is an optional more descriptive state field which has no requirements on format
|
||||
DescriptiveState *string `json:"descriptiveState,omitempty"`
|
||||
// details contains any extra information that is operator-specific
|
||||
Details map[string]interface{} `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
// NewUserstatusOperatorState creates a new UserstatusOperatorState object.
|
||||
func NewUserstatusOperatorState() *UserstatusOperatorState {
|
||||
return &UserstatusOperatorState{}
|
||||
}
|
||||
|
||||
// +k8s:openapi-gen=true
|
||||
type UserStatus struct {
|
||||
// operatorStates is a map of operator ID to operator state evaluations.
|
||||
// Any operator which consumes this kind SHOULD add its state evaluation information to this field.
|
||||
OperatorStates map[string]UserstatusOperatorState `json:"operatorStates,omitempty"`
|
||||
// additionalFields is reserved for future use
|
||||
AdditionalFields map[string]interface{} `json:"additionalFields,omitempty"`
|
||||
LastSeenAt int64 `json:"lastSeenAt"`
|
||||
}
|
||||
|
||||
// NewUserStatus creates a new UserStatus object.
|
||||
func NewUserStatus() *UserStatus {
|
||||
return &UserStatus{}
|
||||
}
|
||||
|
||||
// +k8s:openapi-gen=true
|
||||
type UserStatusOperatorStateState string
|
||||
|
||||
const (
|
||||
UserStatusOperatorStateStateSuccess UserStatusOperatorStateState = "success"
|
||||
UserStatusOperatorStateStateInProgress UserStatusOperatorStateState = "in_progress"
|
||||
UserStatusOperatorStateStateFailed UserStatusOperatorStateState = "failed"
|
||||
)
|
||||
|
||||
+147
-83
@@ -21,6 +21,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA
|
||||
"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.GetSearchUsers": schema_pkg_apis_iam_v0alpha1_GetSearchUsers(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),
|
||||
@@ -72,10 +73,10 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA
|
||||
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.TeamStatus": schema_pkg_apis_iam_v0alpha1_TeamStatus(ref),
|
||||
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.TeamstatusOperatorState": schema_pkg_apis_iam_v0alpha1_TeamstatusOperatorState(ref),
|
||||
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.User": schema_pkg_apis_iam_v0alpha1_User(ref),
|
||||
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.UserHit": schema_pkg_apis_iam_v0alpha1_UserHit(ref),
|
||||
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.UserList": schema_pkg_apis_iam_v0alpha1_UserList(ref),
|
||||
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.UserSpec": schema_pkg_apis_iam_v0alpha1_UserSpec(ref),
|
||||
"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),
|
||||
}
|
||||
@@ -688,6 +689,62 @@ func schema_pkg_apis_iam_v0alpha1_GetSearchTeamsBody(ref common.ReferenceCallbac
|
||||
}
|
||||
}
|
||||
|
||||
func schema_pkg_apis_iam_v0alpha1_GetSearchUsers(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.UserHit"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"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.UserHit"},
|
||||
}
|
||||
}
|
||||
|
||||
func schema_pkg_apis_iam_v0alpha1_GlobalRole(ref common.ReferenceCallback) common.OpenAPIDefinition {
|
||||
return common.OpenAPIDefinition{
|
||||
Schema: spec.Schema{
|
||||
@@ -2833,12 +2890,94 @@ func schema_pkg_apis_iam_v0alpha1_User(ref common.ReferenceCallback) common.Open
|
||||
Ref: ref("github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.UserSpec"),
|
||||
},
|
||||
},
|
||||
"status": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: map[string]interface{}{},
|
||||
Ref: ref("github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.UserStatus"),
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: []string{"metadata", "spec"},
|
||||
Required: []string{"metadata", "spec", "status"},
|
||||
},
|
||||
},
|
||||
Dependencies: []string{
|
||||
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.UserSpec", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"},
|
||||
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.UserSpec", "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.UserStatus", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"},
|
||||
}
|
||||
}
|
||||
|
||||
func schema_pkg_apis_iam_v0alpha1_UserHit(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: "",
|
||||
},
|
||||
},
|
||||
"login": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: "",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"email": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: "",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"role": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: "",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"lastSeenAt": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: 0,
|
||||
Type: []string{"integer"},
|
||||
Format: "int64",
|
||||
},
|
||||
},
|
||||
"lastSeenAtAge": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: "",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"provisioned": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: false,
|
||||
Type: []string{"boolean"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"score": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: 0,
|
||||
Type: []string{"number"},
|
||||
Format: "double",
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: []string{"name", "title", "login", "email", "role", "lastSeenAt", "lastSeenAtAge", "provisioned", "score"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2965,90 +3104,15 @@ func schema_pkg_apis_iam_v0alpha1_UserStatus(ref common.ReferenceCallback) commo
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"object"},
|
||||
Properties: map[string]spec.Schema{
|
||||
"operatorStates": {
|
||||
"lastSeenAt": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "operatorStates is a map of operator ID to operator state evaluations. Any operator which consumes this kind SHOULD add its state evaluation information to this field.",
|
||||
Type: []string{"object"},
|
||||
AdditionalProperties: &spec.SchemaOrBool{
|
||||
Allows: true,
|
||||
Schema: &spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: map[string]interface{}{},
|
||||
Ref: ref("github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.UserstatusOperatorState"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"additionalFields": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "additionalFields is reserved for future use",
|
||||
Type: []string{"object"},
|
||||
AdditionalProperties: &spec.SchemaOrBool{
|
||||
Allows: true,
|
||||
Schema: &spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"object"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
Default: 0,
|
||||
Type: []string{"integer"},
|
||||
Format: "int64",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Dependencies: []string{
|
||||
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.UserstatusOperatorState"},
|
||||
}
|
||||
}
|
||||
|
||||
func schema_pkg_apis_iam_v0alpha1_UserstatusOperatorState(ref common.ReferenceCallback) common.OpenAPIDefinition {
|
||||
return common.OpenAPIDefinition{
|
||||
Schema: spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"object"},
|
||||
Properties: map[string]spec.Schema{
|
||||
"lastEvaluation": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "lastEvaluation is the ResourceVersion last evaluated",
|
||||
Default: "",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"state": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "state describes the state of the lastEvaluation. It is limited to three possible states for machine evaluation.",
|
||||
Default: "",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"descriptiveState": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "descriptiveState is an optional more descriptive state field which has no requirements on format",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"details": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "details contains any extra information that is operator-specific",
|
||||
Type: []string{"object"},
|
||||
AdditionalProperties: &spec.SchemaOrBool{
|
||||
Allows: true,
|
||||
Schema: &spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"object"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: []string{"lastEvaluation", "state"},
|
||||
Required: []string{"lastSeenAt"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
Generated
+206
@@ -173,6 +173,36 @@ var appManifestData = app.ManifestData{
|
||||
|
||||
Parameters: []*spec3.Parameter{
|
||||
|
||||
{
|
||||
ParameterProps: spec3.ParameterProps{
|
||||
Name: "limit",
|
||||
In: "query",
|
||||
Schema: &spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
ParameterProps: spec3.ParameterProps{
|
||||
Name: "offset",
|
||||
In: "query",
|
||||
Schema: &spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
ParameterProps: spec3.ParameterProps{
|
||||
Name: "page",
|
||||
In: "query",
|
||||
Schema: &spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
ParameterProps: spec3.ParameterProps{
|
||||
Name: "query",
|
||||
@@ -261,6 +291,118 @@ var appManifestData = app.ManifestData{
|
||||
},
|
||||
},
|
||||
},
|
||||
"/searchUsers": {
|
||||
Get: &spec3.Operation{
|
||||
OperationProps: spec3.OperationProps{
|
||||
|
||||
OperationId: "getSearchUsers",
|
||||
|
||||
Parameters: []*spec3.Parameter{
|
||||
|
||||
{
|
||||
ParameterProps: spec3.ParameterProps{
|
||||
Name: "limit",
|
||||
In: "query",
|
||||
Schema: &spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
ParameterProps: spec3.ParameterProps{
|
||||
Name: "offset",
|
||||
In: "query",
|
||||
Schema: &spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
ParameterProps: spec3.ParameterProps{
|
||||
Name: "page",
|
||||
In: "query",
|
||||
Schema: &spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
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{
|
||||
"hits": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"array"},
|
||||
Items: &spec.SchemaOrArray{
|
||||
Schema: &spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
|
||||
Ref: spec.MustCreateRef("#/components/schemas/getSearchUsersUserHit"),
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
"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",
|
||||
},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Cluster: map[string]spec3.PathProps{},
|
||||
Schemas: map[string]spec.Schema{
|
||||
@@ -303,6 +445,69 @@ var appManifestData = app.ManifestData{
|
||||
},
|
||||
},
|
||||
},
|
||||
"getSearchUsersUserHit": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"object"},
|
||||
Properties: map[string]spec.Schema{
|
||||
"email": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"string"},
|
||||
},
|
||||
},
|
||||
"lastSeenAt": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"integer"},
|
||||
},
|
||||
},
|
||||
"lastSeenAtAge": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"string"},
|
||||
},
|
||||
},
|
||||
"login": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"string"},
|
||||
},
|
||||
},
|
||||
"name": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"string"},
|
||||
},
|
||||
},
|
||||
"provisioned": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"boolean"},
|
||||
},
|
||||
},
|
||||
"role": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"string"},
|
||||
},
|
||||
},
|
||||
"score": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"number"},
|
||||
},
|
||||
},
|
||||
"title": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: []string{
|
||||
"name",
|
||||
"title",
|
||||
"login",
|
||||
"email",
|
||||
"role",
|
||||
"lastSeenAt",
|
||||
"lastSeenAtAge",
|
||||
"provisioned",
|
||||
"score",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -342,6 +547,7 @@ var customRouteToGoResponseType = map[string]any{
|
||||
"v0alpha1|Team|groups|GET": v0alpha1.GetGroups{},
|
||||
|
||||
"v0alpha1||<namespace>/searchTeams|GET": v0alpha1.GetSearchTeams{},
|
||||
"v0alpha1||<namespace>/searchUsers|GET": v0alpha1.GetSearchUsers{},
|
||||
}
|
||||
|
||||
// ManifestCustomRouteResponsesAssociator returns the associated response go type for a given kind, version, custom route path, and method, if one exists.
|
||||
|
||||
+2
-2
@@ -7,8 +7,8 @@ MAKEFLAGS += --no-builtin-rule
|
||||
|
||||
include docs.mk
|
||||
|
||||
.PHONY: sources/panels-visualizations/query-transform-data/transform-data/index.md
|
||||
sources/panels-visualizations/query-transform-data/transform-data/index.md: ## Generate the Transform Data page source.
|
||||
.PHONY: sources/visualizations/panels-visualizations/query-transform-data/transform-data/index.md
|
||||
sources/visualizations/panels-visualizations/query-transform-data/transform-data/index.md: ## Generate the Transform Data page source.
|
||||
cd $(CURDIR)/.. && \
|
||||
npx tsx ./scripts/docs/generate-transformations.ts && \
|
||||
npx prettier -w $(CURDIR)/$@
|
||||
|
||||
@@ -62,5 +62,6 @@ The table includes default and other fields:
|
||||
| targetBlank | bool. If true, the link will be opened in a new tab. Default is `false`. |
|
||||
| includeVars | bool. If true, includes current template variables values in the link as query params. Default is `false`. |
|
||||
| keepTime | bool. If true, includes current time range in the link as query params. Default is `false`. |
|
||||
| placement? | string. Use placement to display the link somewhere else on the dashboard other than above the visualizations. Use the `inControlsMenu` parameter to render the link in the dashboard controls dropdown menu. |
|
||||
|
||||
<!-- prettier-ignore-end -->
|
||||
|
||||
@@ -17,16 +17,6 @@ menuTitle: Elasticsearch
|
||||
title: Elasticsearch data source
|
||||
weight: 325
|
||||
refs:
|
||||
configuration:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/#sigv4_auth_enabled
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/#sigv4_auth_enabled
|
||||
provisioning-grafana:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/administration/provisioning/#data-sources
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/administration/provisioning/#data-sources
|
||||
explore:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/explore/
|
||||
@@ -44,12 +34,36 @@ refs:
|
||||
Elasticsearch is a search and analytics engine used for a variety of use cases.
|
||||
You can create many types of queries to visualize logs or metrics stored in Elasticsearch, and annotate graphs with log events stored in Elasticsearch.
|
||||
|
||||
The following will help you get started working with Elasticsearch and Grafana:
|
||||
The following resources will help you get started with Elasticsearch and Grafana:
|
||||
|
||||
- [What is Elasticsearch?](https://www.elastic.co/guide/en/elasticsearch/reference/current/elasticsearch-intro.html)
|
||||
- [Configure the Elasticsearch data source](/docs/grafana/latest/datasources/elasticsearch/configure-elasticsearch-data-source/)
|
||||
- [Elasticsearch query editor](query-editor/)
|
||||
- [Elasticsearch template variables](template-variables/)
|
||||
- [Configure the Elasticsearch data source](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/datasources/elasticsearch/configure/)
|
||||
- [Elasticsearch query editor](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/datasources/elasticsearch/query-editor/)
|
||||
- [Elasticsearch template variables](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/datasources/elasticsearch/template-variables/)
|
||||
- [Elasticsearch annotations](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/datasources/elasticsearch/annotations/)
|
||||
- [Elasticsearch alerting](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/datasources/elasticsearch/alerting/)
|
||||
- [Troubleshooting issues with the Elasticsearch data source](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/datasources/elasticsearch/troubleshooting/)
|
||||
|
||||
## Key capabilities
|
||||
|
||||
The Elasticsearch data source supports:
|
||||
|
||||
- **Metrics queries:** Aggregate and visualize numeric data using bucket and metric aggregations.
|
||||
- **Log queries:** Search, filter, and explore log data with Lucene query syntax.
|
||||
- **Annotations:** Overlay Elasticsearch events on your dashboard graphs.
|
||||
- **Alerting:** Create alerts based on Elasticsearch query results.
|
||||
|
||||
## Before you begin
|
||||
|
||||
Before you configure the Elasticsearch data source, you need:
|
||||
|
||||
- An Elasticsearch instance (v7.17+, v8.x, or v9.x)
|
||||
- Network access from Grafana to your Elasticsearch server
|
||||
- Appropriate user credentials or API keys with read access
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
If you use Amazon OpenSearch Service (the successor to Amazon Elasticsearch Service), use the [OpenSearch data source](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/datasources/opensearch/) instead.
|
||||
{{< /admonition >}}
|
||||
|
||||
## Supported Elasticsearch versions
|
||||
|
||||
@@ -63,86 +77,18 @@ This data source supports these versions of Elasticsearch:
|
||||
- v8.x
|
||||
- v9.x
|
||||
|
||||
Our maintenance policy for Elasticsearch data source is aligned with the [Elastic Product End of Life Dates](https://www.elastic.co/support/eol) and we ensure proper functionality for supported versions. If you are using an Elasticsearch with version that is past its end-of-life (EOL), you can still execute queries, but you will receive a notification in the query builder indicating that the version of Elasticsearch you are using is no longer supported. It's important to note that in such cases, we do not guarantee the correctness of the functionality, and we will not be addressing any related issues.
|
||||
The Grafana maintenance policy for the Elasticsearch data source aligns with [Elastic Product End of Life Dates](https://www.elastic.co/support/eol). Grafana ensures proper functionality for supported versions only. If you use an EOL version of Elasticsearch, you can still run queries, but the query builder displays a warning. Grafana doesn't guarantee functionality or provide fixes for EOL versions.
|
||||
|
||||
## Provision the data source
|
||||
## Additional resources
|
||||
|
||||
You can define and configure the data source in YAML files as part of Grafana's provisioning system.
|
||||
For more information about provisioning, and for available configuration options, refer to [Provisioning Grafana](ref:provisioning-grafana).
|
||||
Once you have configured the Elasticsearch data source, you can:
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
The previously used `database` field has now been [deprecated](https://github.com/grafana/grafana/pull/58647).
|
||||
You should now use the `index` field in `jsonData` to store the index name.
|
||||
Please see the examples below.
|
||||
{{< /admonition >}}
|
||||
- Use [Explore](ref:explore) to run ad-hoc queries against your Elasticsearch data.
|
||||
- Configure and use [template variables](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/datasources/elasticsearch/template-variables/) for dynamic dashboards.
|
||||
- Add [Transformations](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/panels-visualizations/query-transform-data/transform-data/) to process query results.
|
||||
- [Build dashboards](ref:build-dashboards) to visualize your Elasticsearch data.
|
||||
|
||||
### Provisioning examples
|
||||
## Related data sources
|
||||
|
||||
**Basic provisioning**
|
||||
|
||||
```yaml
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: Elastic
|
||||
type: elasticsearch
|
||||
access: proxy
|
||||
url: http://localhost:9200
|
||||
jsonData:
|
||||
index: '[metrics-]YYYY.MM.DD'
|
||||
interval: Daily
|
||||
timeField: '@timestamp'
|
||||
```
|
||||
|
||||
**Provision for logs**
|
||||
|
||||
```yaml
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: elasticsearch-v7-filebeat
|
||||
type: elasticsearch
|
||||
access: proxy
|
||||
url: http://localhost:9200
|
||||
jsonData:
|
||||
index: '[filebeat-]YYYY.MM.DD'
|
||||
interval: Daily
|
||||
timeField: '@timestamp'
|
||||
logMessageField: message
|
||||
logLevelField: fields.level
|
||||
dataLinks:
|
||||
- datasourceUid: my_jaeger_uid # Target UID needs to be known
|
||||
field: traceID
|
||||
url: '$${__value.raw}' # Careful about the double "$$" because of env var expansion
|
||||
```
|
||||
|
||||
## Configure Amazon Elasticsearch Service
|
||||
|
||||
If you use Amazon Elasticsearch Service, you can use Grafana's Elasticsearch data source to visualize data from it.
|
||||
|
||||
If you use an AWS Identity and Access Management (IAM) policy to control access to your Amazon Elasticsearch Service domain, you must use AWS Signature Version 4 (AWS SigV4) to sign all requests to that domain.
|
||||
|
||||
For details on AWS SigV4, refer to the [AWS documentation](https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html).
|
||||
|
||||
### AWS Signature Version 4 authentication
|
||||
|
||||
To sign requests to your Amazon Elasticsearch Service domain, you can enable SigV4 in Grafana's [configuration](ref:configuration).
|
||||
|
||||
Once AWS SigV4 is enabled, you can configure it on the Elasticsearch data source configuration page.
|
||||
For more information about AWS authentication options, refer to [AWS authentication](../aws-cloudwatch/aws-authentication/).
|
||||
|
||||
{{< figure src="/static/img/docs/v73/elasticsearch-sigv4-config-editor.png" max-width="500px" class="docs-image--no-shadow" caption="SigV4 configuration for AWS Elasticsearch Service" >}}
|
||||
|
||||
## Query the data source
|
||||
|
||||
You can select multiple metrics and group by multiple terms or filters when using the Elasticsearch query editor.
|
||||
|
||||
For details, see the [query editor documentation](query-editor/).
|
||||
|
||||
## Use template variables
|
||||
|
||||
Instead of hard-coding details such as server, application, and sensor names in metric queries, you can use variables.
|
||||
Grafana lists these variables in dropdown select boxes at the top of the dashboard to help you change the data displayed in your dashboard.
|
||||
Grafana refers to such variables as template variables.
|
||||
|
||||
For details, see the [template variables documentation](template-variables/).
|
||||
- [OpenSearch](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/datasources/opensearch/) - For Amazon OpenSearch Service.
|
||||
- [Loki](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/datasources/loki/) - Grafana's log aggregation system.
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
---
|
||||
aliases:
|
||||
- ../../data-sources/elasticsearch/alerting/
|
||||
description: Using Grafana Alerting with the Elasticsearch data source
|
||||
keywords:
|
||||
- grafana
|
||||
- elasticsearch
|
||||
- alerting
|
||||
- alerts
|
||||
labels:
|
||||
products:
|
||||
- cloud
|
||||
- enterprise
|
||||
- oss
|
||||
menuTitle: Alerting
|
||||
title: Elasticsearch alerting
|
||||
weight: 550
|
||||
refs:
|
||||
alerting:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/alerting/
|
||||
create-alert-rule:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/alerting-rules/create-grafana-managed-rule/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/alerting/alerting-rules/create-grafana-managed-rule/
|
||||
---
|
||||
|
||||
# Elasticsearch alerting
|
||||
|
||||
You can use Grafana Alerting with Elasticsearch to create alerts based on your Elasticsearch data. This allows you to monitor metrics, detect anomalies, and receive notifications when specific conditions are met.
|
||||
|
||||
For general information about Grafana Alerting, refer to [Grafana Alerting](ref:alerting).
|
||||
|
||||
## Before you begin
|
||||
|
||||
Before creating alerts with Elasticsearch, ensure you have:
|
||||
|
||||
- An Elasticsearch data source configured in Grafana
|
||||
- Appropriate permissions to create alert rules
|
||||
- Understanding of the metrics you want to monitor
|
||||
|
||||
## Supported query types
|
||||
|
||||
Elasticsearch alerting works best with **metrics queries** that return time series data. To create a valid alert query:
|
||||
|
||||
- Use a **Date histogram** as the last bucket aggregation (under **Group by**)
|
||||
- Select appropriate metric aggregations (Count, Average, Sum, Min, Max, etc.)
|
||||
|
||||
Queries that return time series data allow Grafana to evaluate values over time and trigger alerts when thresholds are crossed.
|
||||
|
||||
### Query types and alerting compatibility
|
||||
|
||||
| Query type | Alerting support | Notes |
|
||||
| ------------------------------ | ---------------- | ----------------------------------------------------------- |
|
||||
| Metrics with Date histogram | ✅ Full support | Recommended for alerting |
|
||||
| Metrics without Date histogram | ⚠️ Limited | May not evaluate correctly over time |
|
||||
| Logs | ❌ Not supported | Use metrics queries instead |
|
||||
| Raw data | ❌ Not supported | Use metrics queries instead |
|
||||
| Raw document (deprecated) | ❌ Not supported | Deprecated since Grafana v10.1. Use metrics queries instead |
|
||||
|
||||
## Create an alert rule
|
||||
|
||||
To create an alert rule using Elasticsearch:
|
||||
|
||||
1. Navigate to **Alerting** > **Alert rules**.
|
||||
1. Click **New alert rule**.
|
||||
1. Enter a name for the alert rule.
|
||||
1. Select your **Elasticsearch** data source.
|
||||
1. Build your query using the query editor:
|
||||
- Add metric aggregations (for example, Average, Count, Sum)
|
||||
- Add a Date histogram under **Group by**
|
||||
- Optionally add filters using Lucene query syntax
|
||||
1. Configure the alert condition (for example, when the average is above a threshold).
|
||||
1. Set the evaluation interval and pending period.
|
||||
1. Configure notifications and labels.
|
||||
1. Click **Save rule**.
|
||||
|
||||
For detailed instructions, refer to [Create a Grafana-managed alert rule](ref:create-alert-rule).
|
||||
|
||||
## Example alert queries
|
||||
|
||||
The following examples show common alerting scenarios with Elasticsearch.
|
||||
|
||||
### Alert on high error count
|
||||
|
||||
Monitor the number of error-level log entries:
|
||||
|
||||
1. **Query:** `level:error`
|
||||
1. **Metric:** Count
|
||||
1. **Group by:** Date histogram (interval: 1m)
|
||||
1. **Condition:** When count is above 100
|
||||
|
||||
### Alert on average response time
|
||||
|
||||
Monitor API response times:
|
||||
|
||||
1. **Query:** `type:api_request`
|
||||
1. **Metric:** Average on field `response_time`
|
||||
1. **Group by:** Date histogram (interval: 5m)
|
||||
1. **Condition:** When average is above 500 (milliseconds)
|
||||
|
||||
### Alert on unique user count drop
|
||||
|
||||
Detect drops in active users:
|
||||
|
||||
1. **Query:** `*` (all documents)
|
||||
1. **Metric:** Unique count on field `user_id`
|
||||
1. **Group by:** Date histogram (interval: 1h)
|
||||
1. **Condition:** When unique count is below 100
|
||||
|
||||
## Limitations
|
||||
|
||||
When using Elasticsearch with Grafana Alerting, be aware of the following limitations:
|
||||
|
||||
### Template variables not supported
|
||||
|
||||
Alert queries cannot contain template variables. Grafana evaluates alert rules on the backend without dashboard context, so variables like `$hostname` or `$environment` won't be resolved.
|
||||
|
||||
If your dashboard query uses template variables, create a separate query for alerting with hard coded values.
|
||||
|
||||
### Logs queries not supported
|
||||
|
||||
Queries using the **Logs** metric type cannot be used for alerting. Convert your query to use metric aggregations with a Date histogram instead.
|
||||
|
||||
### Query complexity
|
||||
|
||||
Complex queries with many nested aggregations may timeout or fail to evaluate. Simplify queries for alerting by:
|
||||
|
||||
- Reducing the number of bucket aggregations
|
||||
- Using appropriate time intervals
|
||||
- Adding filters to limit the data scanned
|
||||
|
||||
## Best practices
|
||||
|
||||
Follow these best practices when creating Elasticsearch alerts:
|
||||
|
||||
- **Use specific filters:** Add Lucene query filters to focus on relevant data and improve query performance.
|
||||
- **Choose appropriate intervals:** Match the Date histogram interval to your evaluation frequency.
|
||||
- **Test queries first:** Verify your query returns expected results in Explore before creating an alert.
|
||||
- **Set realistic thresholds:** Base alert thresholds on historical data patterns.
|
||||
- **Use meaningful names:** Give alert rules descriptive names that indicate what they monitor.
|
||||
@@ -0,0 +1,124 @@
|
||||
---
|
||||
aliases:
|
||||
- ../../data-sources/elasticsearch/annotations/
|
||||
description: Using annotations with Elasticsearch in Grafana
|
||||
keywords:
|
||||
- grafana
|
||||
- elasticsearch
|
||||
- annotations
|
||||
- events
|
||||
labels:
|
||||
products:
|
||||
- cloud
|
||||
- enterprise
|
||||
- oss
|
||||
menuTitle: Annotations
|
||||
title: Elasticsearch annotations
|
||||
weight: 500
|
||||
refs:
|
||||
annotate-visualizations:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/build-dashboards/annotate-visualizations/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/build-dashboards/annotate-visualizations/
|
||||
---
|
||||
|
||||
# Elasticsearch annotations
|
||||
|
||||
Annotations overlay event data on your dashboard graphs, helping you correlate log events with metrics.
|
||||
You can use Elasticsearch as a data source for annotations to display events such as deployments, alerts, or other significant occurrences on your visualizations.
|
||||
|
||||
For general information about annotations, refer to [Annotate visualizations](ref:annotate-visualizations).
|
||||
|
||||
## Before you begin
|
||||
|
||||
Before creating Elasticsearch annotations, ensure you have:
|
||||
|
||||
- An Elasticsearch data source configured in Grafana
|
||||
- Documents in Elasticsearch containing event data with timestamp fields
|
||||
- Read access to the Elasticsearch index containing your events
|
||||
|
||||
## Create an annotation query
|
||||
|
||||
To add an Elasticsearch annotation to your dashboard:
|
||||
|
||||
1. Navigate to your dashboard and click **Dashboard settings** (gear icon).
|
||||
1. Select **Annotations** in the left menu.
|
||||
1. Click **Add annotation query**.
|
||||
1. Enter a **Name** for the annotation.
|
||||
1. Select your **Elasticsearch** data source from the **Data source** drop-down.
|
||||
1. Configure the annotation query and field mappings.
|
||||
1. Click **Save dashboard**.
|
||||
|
||||
## Query
|
||||
|
||||
Use the query field to filter which Elasticsearch documents appear as annotations. The query uses [Lucene query syntax](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-query-string-query.html#query-string-syntax).
|
||||
|
||||
**Examples:**
|
||||
|
||||
| Query | Description |
|
||||
| ---------------------------------------- | ---------------------------------------------------- |
|
||||
| `*` | Matches all documents. |
|
||||
| `type:deployment` | Shows only deployment events. |
|
||||
| `level:error OR level:critical` | Shows error and critical events. |
|
||||
| `service:api AND environment:production` | Shows events for a specific service and environment. |
|
||||
| `tags:release` | Shows events tagged as releases. |
|
||||
|
||||
You can use template variables in your annotation queries. For example, `service:$service` filters annotations based on the selected service variable.
|
||||
|
||||
## Field mappings
|
||||
|
||||
Field mappings tell Grafana which Elasticsearch fields contain the annotation data.
|
||||
|
||||
### Time
|
||||
|
||||
The **Time** field specifies which field contains the annotation timestamp.
|
||||
|
||||
- **Default:** `@timestamp`
|
||||
- **Format:** The field must contain a date value that Elasticsearch recognizes.
|
||||
|
||||
### Time End
|
||||
|
||||
The **Time End** field specifies a field containing the end time for range annotations. Range annotations display as a shaded region on the graph instead of a single vertical line.
|
||||
|
||||
- **Default:** Empty (single-point annotations)
|
||||
- **Use case:** Display maintenance windows, incidents, or any event with a duration.
|
||||
|
||||
### Text
|
||||
|
||||
The **Text** field specifies which field contains the annotation description displayed when you hover over the annotation.
|
||||
|
||||
- **Default:** `tags`
|
||||
- **Tip:** Use a descriptive field like `message`, `description`, or `summary`.
|
||||
|
||||
### Tags
|
||||
|
||||
The **Tags** field specifies which field contains tags for the annotation. Tags help categorize and filter annotations.
|
||||
|
||||
- **Default:** Empty
|
||||
- **Format:** The field can contain either a comma-separated string or an array of strings.
|
||||
|
||||
## Example: Deployment annotations
|
||||
|
||||
To display deployment events as annotations:
|
||||
|
||||
1. Create an annotation query with the following settings:
|
||||
- **Query:** `type:deployment`
|
||||
- **Time:** `@timestamp`
|
||||
- **Text:** `message`
|
||||
- **Tags:** `environment`
|
||||
|
||||
This configuration displays deployment events with their messages as the annotation text and environments as tags.
|
||||
|
||||
## Example: Range annotations for incidents
|
||||
|
||||
To display incidents with duration:
|
||||
|
||||
1. Create an annotation query with the following settings:
|
||||
- **Query:** `type:incident`
|
||||
- **Time:** `start_time`
|
||||
- **Time End:** `end_time`
|
||||
- **Text:** `description`
|
||||
- **Tags:** `severity`
|
||||
|
||||
This configuration displays incidents as shaded regions from their start time to end time.
|
||||
@@ -1,209 +0,0 @@
|
||||
---
|
||||
aliases:
|
||||
- ../data-sources/elasticsearch/
|
||||
- ../features/datasources/elasticsearch/
|
||||
description: Guide for configuring the Elasticsearch data source in Grafana
|
||||
keywords:
|
||||
- grafana
|
||||
- elasticsearch
|
||||
- guide
|
||||
- data source
|
||||
labels:
|
||||
products:
|
||||
- cloud
|
||||
- enterprise
|
||||
- oss
|
||||
menuTitle: Configure Elasticsearch
|
||||
title: Configure the Elasticsearch data source
|
||||
weight: 200
|
||||
refs:
|
||||
administration-documentation:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/administration/data-source-management/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/administration/data-source-management/
|
||||
supported-expressions:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/explore/logs-integration/#log-level
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/explore/logs-integration/#log-level
|
||||
query-and-transform-data:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/panels-visualizations/query-transform-data/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/visualizations/panels-visualizations/query-transform-data/
|
||||
provisioning-data-source:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/elasticsearch/#provision-the-data-source
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/connect-externally-hosted/data-sources/elasticsearch/#provision-the-data-source
|
||||
---
|
||||
|
||||
# Configure the Elasticsearch data source
|
||||
|
||||
Grafana ships with built-in support for Elasticsearch.
|
||||
You can create a variety of queries to visualize logs or metrics stored in Elasticsearch, and annotate graphs with log events stored in Elasticsearch.
|
||||
|
||||
For instructions on how to add a data source to Grafana, refer to the [administration documentation](ref:administration-documentation).
|
||||
|
||||
Only users with the organization `administrator` role can add data sources.
|
||||
Administrators can also [configure the data source via YAML](ref:provisioning-data-source) with Grafana's provisioning system.
|
||||
|
||||
## Configuring permissions
|
||||
|
||||
When Elasticsearch security features are enabled, it is essential to configure the necessary cluster privileges to ensure seamless operation. Below is a list of the required privileges along with their purposes:
|
||||
|
||||
- **monitor** - Necessary to retrieve the version information of the connected Elasticsearch instance.
|
||||
- **view_index_metadata** - Required for accessing mapping definitions of indices.
|
||||
- **read** - Grants the ability to perform search and retrieval operations on indices. This is essential for querying and extracting data from the cluster.
|
||||
|
||||
## Add the data source
|
||||
|
||||
To add the Elasticsearch data source, complete the following steps:
|
||||
|
||||
1. Click **Connections** in the left-side menu.
|
||||
1. Under **Connections**, click **Add new connection**.
|
||||
1. Enter `Elasticsearch` in the search bar.
|
||||
1. Click **Elasticsearch** under the **Data source** section.
|
||||
1. Click **Add new data source** in the upper right.
|
||||
|
||||
You will be taken to the **Settings** tab where you will set up your Elasticsearch configuration.
|
||||
|
||||
## Configuration options
|
||||
|
||||
The following is a list of configuration options for Elasticsearch.
|
||||
|
||||
The first option to configure is the name of your connection:
|
||||
|
||||
- **Name** - The data source name. This is how you refer to the data source in panels and queries. Examples: elastic-1, elasticsearch_metrics.
|
||||
|
||||
- **Default** - Toggle to select as the default data source option. When you go to a dashboard panel or Explore, this will be the default selected data source.
|
||||
|
||||
## Connection
|
||||
|
||||
Connect the Elasticsearch data source by specifying a URL.
|
||||
|
||||
- **URL** - The URL of your Elasticsearch server. If your Elasticsearch server is local, use `http://localhost:9200`. If it is on a server within a network, this is the URL with the port where you are running Elasticsearch. Example: `http://elasticsearch.example.orgname:9200`.
|
||||
|
||||
## Authentication
|
||||
|
||||
There are several authentication methods you can choose in the Authentication section.
|
||||
Select one of the following authentication methods from the dropdown menu.
|
||||
|
||||
- **Basic authentication** - The most common authentication method. Use your `data source` user name and `data source` password to connect.
|
||||
|
||||
- **Forward OAuth identity** - Forward the OAuth access token (and the OIDC ID token if available) of the user querying the data source.
|
||||
|
||||
- **No authentication** - Make the data source available without authentication. Grafana recommends using some type of authentication method.
|
||||
|
||||
<!-- - **With credentials** - Toggle to enable credentials such as cookies or auth headers to be sent with cross-site requests. -->
|
||||
|
||||
### TLS settings
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
Use TLS (Transport Layer Security) for an additional layer of security when working with Elasticsearch. For information on setting up TLS encryption with Elasticsearch see [Configure TLS](https://www.elastic.co/guide/en/elasticsearch/reference/8.8/configuring-tls.html#configuring-tls). You must add TLS settings to your Elasticsearch configuration file **prior** to setting these options in Grafana.
|
||||
{{< /admonition >}}
|
||||
|
||||
- **Add self-signed certificate** - Check the box to authenticate with a CA certificate. Follow the instructions of the CA (Certificate Authority) to download the certificate file. Required for verifying self-signed TLS certificates.
|
||||
|
||||
- **TLS client authentication** - Check the box to authenticate with the TLS client, where the server authenticates the client. Add the `Server name`, `Client certificate` and `Client key`. The **ServerName** is used to verify the hostname on the returned certificate. The **Client certificate** can be generated from a Certificate Authority (CA) or be self-signed. The **Client key** can also be generated from a Certificate Authority (CA) or be self-signed. The client key encrypts the data between client and server.
|
||||
|
||||
- **Skip TLS certificate validation** - Check the box to bypass TLS certificate validation. Skipping TLS certificate validation is not recommended unless absolutely necessary or for testing purposes.
|
||||
|
||||
### HTTP headers
|
||||
|
||||
Click **+ Add header** to add one or more HTTP headers. HTTP headers pass additional context and metadata about the request/response.
|
||||
|
||||
- **Header** - Add a custom header. This allows custom headers to be passed based on the needs of your Elasticsearch instance.
|
||||
|
||||
- **Value** - The value of the header.
|
||||
|
||||
## Additional settings
|
||||
|
||||
Additional settings are optional settings that can be configured for more control over your data source.
|
||||
|
||||
### Advanced HTTP settings
|
||||
|
||||
- **Allowed cookies** - Specify cookies by name that should be forwarded to the data source. The Grafana proxy deletes all forwarded cookies by default.
|
||||
|
||||
- **Timeout** - The HTTP request timeout. This must be in seconds. There is no default, so this setting is up to you.
|
||||
|
||||
### Elasticsearch details
|
||||
|
||||
The following settings are specific to the Elasticsearch data source.
|
||||
|
||||
- **Index name** - Use the index settings to specify a default for the `time field` and your Elasticsearch index's name. You can use a time pattern, for example `[logstash-]YYYY.MM.DD`, or a wildcard for the index name. When specifying a time pattern, the fixed part(s) of the pattern should be wrapped in square brackets.
|
||||
|
||||
- **Pattern** - Select the matching pattern if using one in your index name. Options include:
|
||||
- no pattern
|
||||
- hourly
|
||||
- daily
|
||||
- weekly
|
||||
- monthly
|
||||
- yearly
|
||||
|
||||
Only select a pattern option if you have specified a time pattern in the Index name field.
|
||||
|
||||
- **Time field name** - Name of the time field. The default value is @timestamp. You can enter a different name.
|
||||
|
||||
- **Max concurrent shard requests** - Sets the number of shards being queried at the same time. The default is `5`. For more information on shards see [Elasticsearch's documentation](https://www.elastic.co/guide/en/elasticsearch/reference/8.9/scalability.html#scalability).
|
||||
|
||||
- **Min time interval** - Defines a lower limit for the auto group-by time interval. This value **must** be formatted as a number followed by a valid time identifier:
|
||||
|
||||
| Identifier | Description |
|
||||
| ---------- | ----------- |
|
||||
| `y` | year |
|
||||
| `M` | month |
|
||||
| `w` | week |
|
||||
| `d` | day |
|
||||
| `h` | hour |
|
||||
| `m` | minute |
|
||||
| `s` | second |
|
||||
| `ms` | millisecond |
|
||||
|
||||
We recommend setting this value to match your Elasticsearch write frequency.
|
||||
For example, set this to `1m` if Elasticsearch writes data every minute.
|
||||
|
||||
You can also override this setting in a dashboard panel under its data source options. The default is `10s`.
|
||||
|
||||
- **X-Pack enabled** - Toggle to enable `X-Pack`-specific features and options, which provide the [query editor](../query-editor/) with additional aggregations, such as `Rate` and `Top Metrics`.
|
||||
|
||||
- **Include frozen indices** - Toggle on when the `X-Pack enabled` setting is active. Includes frozen indices in searches. You can configure Grafana to include [frozen indices](https://www.elastic.co/guide/en/elasticsearch/reference/7.13/frozen-indices.html) when performing search requests.
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
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.
|
||||
|
||||
- **Message field name:** - Grabs the actual log message from the default source.
|
||||
|
||||
- **Level field name:** - Name of the field with log level/severity information. When a level label is specified, the value of this label is used to determine the log level and update the color of each log line accordingly. If the log doesn’t have a specified level label, we try to determine if its content matches any of the [supported expressions](ref:supported-expressions). The first match always determines the log level. If Grafana cannot infer a log-level field, it will be visualized with an unknown log level.
|
||||
|
||||
### Data links
|
||||
|
||||
Data links create a link from a specified field that can be accessed in Explore's logs view. You can add multiple data links by clicking **+ Add**.
|
||||
|
||||
Each data link configuration consists of:
|
||||
|
||||
- **Field** - Sets the name of the field used by the data link.
|
||||
|
||||
- **URL/query** - Sets the full link URL if the link is external. If the link is internal, this input serves as a query for the target data source.<br/>In both cases, you can interpolate the value from the field with the `${__value.raw }` macro.
|
||||
|
||||
- **URL Label** (Optional) - Sets a custom display label for the link. The link label defaults to the full external URL or name of the linked internal data source and is overridden by this setting.
|
||||
|
||||
- **Internal link** - Toggle on to set an internal link. For an internal link, you can select the target data source with a data source selector. This supports only tracing data sources.
|
||||
|
||||
## Private data source connect (PDC) and Elasticsearch
|
||||
|
||||
Use private data source connect (PDC) to connect to and query data within a secure network without opening that network to inbound traffic from Grafana Cloud. See [Private data source connect](https://grafana.com/docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/) for more information on how PDC works and [Configure Grafana private data source connect (PDC)](https://grafana.com/docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/configure-pdc/#configure-grafana-private-data-source-connect-pdc) for steps on setting up a PDC connection.
|
||||
|
||||
If you use PDC with SIGv4 (AWS Signature Version 4 Authentication), the PDC agent must allow internet egress to`sts.<region>.amazonaws.com:443`.
|
||||
|
||||
- **Private data source connect** - Click in the box to set the default PDC connection from the dropdown menu or create a new connection.
|
||||
|
||||
Once you have configured your Elasticsearch data source options, click **Save & test** at the bottom to test out your data source connection. You can also remove a connection by clicking **Delete**.
|
||||
@@ -0,0 +1,377 @@
|
||||
---
|
||||
aliases:
|
||||
- ../configure-elasticsearch-data-source/
|
||||
description: Guide for configuring the Elasticsearch data source in Grafana
|
||||
keywords:
|
||||
- grafana
|
||||
- elasticsearch
|
||||
- guide
|
||||
- data source
|
||||
labels:
|
||||
products:
|
||||
- cloud
|
||||
- enterprise
|
||||
- oss
|
||||
menuTitle: Configure
|
||||
title: Configure the Elasticsearch data source
|
||||
weight: 200
|
||||
refs:
|
||||
administration-documentation:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/administration/data-source-management/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/administration/data-source-management/
|
||||
supported-expressions:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/explore/logs-integration/#log-level
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/explore/logs-integration/#log-level
|
||||
query-and-transform-data:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/panels-visualizations/query-transform-data/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/visualizations/panels-visualizations/query-transform-data/
|
||||
provisioning-data-source:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/elasticsearch/configure/#provision-the-data-source
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/connect-externally-hosted/data-sources/elasticsearch/configure/#provision-the-data-source
|
||||
configuration:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/#sigv4_auth_enabled
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/#sigv4_auth_enabled
|
||||
provisioning-grafana:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/administration/provisioning/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/administration/provisioning/
|
||||
---
|
||||
|
||||
# Configure the Elasticsearch data source
|
||||
|
||||
Grafana ships with built-in support for Elasticsearch.
|
||||
You can create a variety of queries to visualize logs or metrics stored in Elasticsearch, and annotate graphs with log events stored in Elasticsearch.
|
||||
|
||||
For instructions on how to add a data source to Grafana, refer to the [administration documentation](ref:administration-documentation).
|
||||
Administrators can also [configure the data source via YAML](ref:provisioning-data-source) with Grafana's provisioning system.
|
||||
|
||||
## Before you begin
|
||||
|
||||
To configure the Elasticsearch data source, you need:
|
||||
|
||||
- **Grafana administrator permissions:** Only users with the organization `administrator` role can add data sources.
|
||||
- **A supported Elasticsearch version:** v7.17 or later, v8.x, or v9.x. Elastic Cloud Serverless isn't supported.
|
||||
- **Elasticsearch server URL:** The HTTP or HTTPS endpoint for your Elasticsearch instance, including the port (default: `9200`).
|
||||
- **Authentication credentials:** Depending on your Elasticsearch security configuration, you need one of the following:
|
||||
- Username and password for basic authentication
|
||||
- API key
|
||||
- No credentials (if Elasticsearch security is disabled)
|
||||
- **Network access:** Grafana must be able to reach your Elasticsearch server. For Grafana Cloud, consider using [Private data source connect (PDC)](https://grafana.com/docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/) if your Elasticsearch instance is in a private network.
|
||||
|
||||
## Elasticsearch permissions
|
||||
|
||||
When Elasticsearch security features are enabled, you must configure the following cluster privileges for the user or API key that Grafana uses to connect:
|
||||
|
||||
- **monitor** - Necessary to retrieve the version information of the connected Elasticsearch instance.
|
||||
- **view_index_metadata** - Required for accessing mapping definitions of indices.
|
||||
- **read** - Grants the ability to perform search and retrieval operations on indices. This is essential for querying and extracting data from the cluster.
|
||||
|
||||
## Add the data source
|
||||
|
||||
To add the Elasticsearch data source, complete the following steps:
|
||||
|
||||
1. Click **Connections** in the left-side menu.
|
||||
1. Under **Connections**, click **Add new connection**.
|
||||
1. Enter `Elasticsearch` in the search bar.
|
||||
1. Click **Elasticsearch** under the **Data source** section.
|
||||
1. Click **Add new data source** in the upper right.
|
||||
|
||||
You will be taken to the **Settings** tab where you will set up your Elasticsearch configuration.
|
||||
|
||||
## Configuration options
|
||||
|
||||
Configure the following basic settings for the Elasticsearch data source:
|
||||
|
||||
- **Name** - The data source name. This is how you refer to the data source in panels and queries. Examples: `elastic-1`, `elasticsearch_metrics`.
|
||||
|
||||
- **Default** - Toggle on to make this the default data source. New panels and Explore queries use the default data source.
|
||||
|
||||
## Connection
|
||||
|
||||
- **URL** - The URL of your Elasticsearch server, including the port. Examples: `http://localhost:9200`, `http://elasticsearch.example.com:9200`.
|
||||
|
||||
## Authentication
|
||||
|
||||
Select an authentication method from the drop-down menu:
|
||||
|
||||
- **Basic authentication** - Enter the username and password for your Elasticsearch user.
|
||||
|
||||
- **Forward OAuth identity** - Forward the OAuth access token (and the OIDC ID token if available) of the user querying the data source.
|
||||
|
||||
- **No authentication** - Connect without credentials. Only use this option if your Elasticsearch instance doesn't require authentication.
|
||||
|
||||
### API key authentication
|
||||
|
||||
To authenticate using an Elasticsearch API key, select **No authentication** and configure the API key using HTTP headers:
|
||||
|
||||
1. In the **HTTP headers** section, click **+ Add header**.
|
||||
1. Set **Header** to `Authorization`.
|
||||
1. Set **Value** to `ApiKey <your-api-key>`, replacing `<your-api-key>` with your base64-encoded Elasticsearch API key.
|
||||
|
||||
For information about creating API keys, refer to the [Elasticsearch API keys documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-create-api-key.html).
|
||||
|
||||
### Amazon Elasticsearch Service
|
||||
|
||||
If you use Amazon Elasticsearch Service, you can use Grafana's Elasticsearch data source to visualize data from it.
|
||||
|
||||
If you use an AWS Identity and Access Management (IAM) policy to control access to your Amazon Elasticsearch Service domain, you must use AWS Signature Version 4 (AWS SigV4) to sign all requests to that domain.
|
||||
|
||||
For details on AWS SigV4, refer to the [AWS documentation](https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html).
|
||||
|
||||
To sign requests to your Amazon Elasticsearch Service domain, you can enable SigV4 in Grafana's [configuration](ref:configuration).
|
||||
|
||||
Once AWS SigV4 is enabled, you can configure it on the Elasticsearch data source configuration page.
|
||||
For more information about AWS authentication options, refer to [AWS authentication](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/datasources/aws-cloudwatch/aws-authentication/).
|
||||
|
||||
{{< figure src="/static/img/docs/v73/elasticsearch-sigv4-config-editor.png" max-width="500px" class="docs-image--no-shadow" caption="SigV4 configuration for AWS Elasticsearch Service" >}}
|
||||
|
||||
### TLS settings
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
Use TLS (Transport Layer Security) for an additional layer of security when working with Elasticsearch. For information on setting up TLS encryption with Elasticsearch, refer to [Configure TLS](https://www.elastic.co/guide/en/elasticsearch/reference/8.8/configuring-tls.html#configuring-tls). You must add TLS settings to your Elasticsearch configuration file **prior** to setting these options in Grafana.
|
||||
{{< /admonition >}}
|
||||
|
||||
- **Add self-signed certificate** - Check the box to authenticate with a CA certificate. Follow the instructions of the CA (Certificate Authority) to download the certificate file. Required for verifying self-signed TLS certificates.
|
||||
|
||||
- **TLS client authentication** - Check the box to authenticate with the TLS client, where the server authenticates the client. Add the `Server name`, `Client certificate` and `Client key`. The **ServerName** is used to verify the hostname on the returned certificate. The **Client certificate** can be generated from a Certificate Authority (CA) or be self-signed. The **Client key** can also be generated from a Certificate Authority (CA) or be self-signed. The client key encrypts the data between client and server.
|
||||
|
||||
- **Skip TLS certificate validation** - Check the box to bypass TLS certificate validation. Skipping TLS certificate validation is not recommended unless absolutely necessary or for testing purposes.
|
||||
|
||||
### HTTP headers
|
||||
|
||||
Click **+ Add header** to add one or more HTTP headers. HTTP headers pass additional context and metadata about the request/response.
|
||||
|
||||
- **Header** - Add a custom header. This allows custom headers to be passed based on the needs of your Elasticsearch instance.
|
||||
|
||||
- **Value** - The value of the header.
|
||||
|
||||
## Additional settings
|
||||
|
||||
Additional settings are optional settings that can be configured for more control over your data source.
|
||||
|
||||
### Advanced HTTP settings
|
||||
|
||||
- **Allowed cookies** - Specify cookies by name that should be forwarded to the data source. The Grafana proxy deletes all forwarded cookies by default.
|
||||
|
||||
- **Timeout** - The HTTP request timeout. This must be in seconds. There is no default, so this setting is up to you.
|
||||
|
||||
### Elasticsearch details
|
||||
|
||||
The following settings are specific to the Elasticsearch data source.
|
||||
|
||||
- **Index name** - The name of your Elasticsearch index. You can use the following formats:
|
||||
- **Wildcard patterns** - Use `*` to match multiple indices. Examples: `logs-*`, `metrics-*`, `filebeat-*`.
|
||||
- **Time patterns** - Use date placeholders for time-based indices. Wrap the fixed portion in square brackets. Examples: `[logstash-]YYYY.MM.DD`, `[metrics-]YYYY.MM`.
|
||||
- **Specific index** - Enter the exact index name. Example: `application-logs`.
|
||||
|
||||
- **Pattern** - Select the matching pattern if you use a time pattern in your index name. Options include:
|
||||
- no pattern
|
||||
- hourly
|
||||
- daily
|
||||
- weekly
|
||||
- monthly
|
||||
- yearly
|
||||
|
||||
Only select a pattern option if you have specified a time pattern in the Index name field.
|
||||
|
||||
- **Time field name** - Name of the time field. The default value is `@timestamp`. You can enter a different name.
|
||||
|
||||
- **Max concurrent shard requests** - Sets the number of shards being queried at the same time. The default is `5`. For more information on shards, refer to the [Elasticsearch documentation](https://www.elastic.co/guide/en/elasticsearch/reference/8.9/scalability.html#scalability).
|
||||
|
||||
- **Min time interval** - Defines a lower limit for the auto group-by time interval. This value **must** be formatted as a number followed by a valid time identifier:
|
||||
|
||||
| Identifier | Description |
|
||||
| ---------- | ----------- |
|
||||
| `y` | year |
|
||||
| `M` | month |
|
||||
| `w` | week |
|
||||
| `d` | day |
|
||||
| `h` | hour |
|
||||
| `m` | minute |
|
||||
| `s` | second |
|
||||
| `ms` | millisecond |
|
||||
|
||||
We recommend setting this value to match your Elasticsearch write frequency.
|
||||
For example, set this to `1m` if Elasticsearch writes data every minute.
|
||||
|
||||
You can also override this setting in a dashboard panel under its data source options. The default is `10s`.
|
||||
|
||||
- **X-Pack enabled** - Toggle to enable `X-Pack`-specific features and options, which provide the [query editor](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/datasources/elasticsearch/query-editor/) with additional aggregations, such as `Rate` and `Top Metrics`.
|
||||
|
||||
- **Include frozen indices** - Toggle on when the `X-Pack enabled` setting is active. Includes frozen indices in searches. You can configure Grafana to include [frozen indices](https://www.elastic.co/guide/en/elasticsearch/reference/7.13/frozen-indices.html) when performing search requests.
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
Frozen indices are [deprecated in Elasticsearch](https://www.elastic.co/guide/en/elasticsearch/reference/7.17/frozen-indices.html) since v7.14.
|
||||
{{< /admonition >}}
|
||||
|
||||
### Logs
|
||||
|
||||
Configure which fields the data source uses for log messages and log levels.
|
||||
|
||||
- **Message field name** - The field that contains the log message content.
|
||||
|
||||
- **Level field name** - The field that contains log level or severity information. When specified, Grafana uses this field to determine the log level and color-code each log line. If the log doesn't have a level field, Grafana tries to match the content against [supported expressions](ref:supported-expressions). If Grafana can't determine the log level, it displays as unknown.
|
||||
|
||||
### Data links
|
||||
|
||||
Data links create a link from a specified field that can be accessed in Explore's logs view. You can add multiple data links by clicking **+ Add**.
|
||||
|
||||
Each data link configuration consists of:
|
||||
|
||||
- **Field** - Sets the name of the field used by the data link.
|
||||
|
||||
- **URL/query** - Sets the full link URL if the link is external. If the link is internal, this input serves as a query for the target data source.<br/>In both cases, you can interpolate the value from the field with the `${__value.raw }` macro.
|
||||
|
||||
- **URL Label** (Optional) - Sets a custom display label for the link. The link label defaults to the full external URL or name of the linked internal data source and is overridden by this setting.
|
||||
|
||||
- **Internal link** - Toggle on to set an internal link. For an internal link, you can select the target data source with a data source selector. This supports only tracing data sources.
|
||||
|
||||
## Private data source connect (PDC) and Elasticsearch
|
||||
|
||||
Use private data source connect (PDC) to connect to and query data within a secure network without opening that network to inbound traffic from Grafana Cloud. Refer to [Private data source connect](https://grafana.com/docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/) for more information on how PDC works and [Configure Grafana private data source connect (PDC)](https://grafana.com/docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/configure-pdc/#configure-grafana-private-data-source-connect-pdc) for steps on setting up a PDC connection.
|
||||
|
||||
If you use PDC with SigV4 (AWS Signature Version 4 Authentication), the PDC agent must allow internet egress to `sts.<region>.amazonaws.com:443`.
|
||||
|
||||
- **Private data source connect** - Click in the box to set the default PDC connection from the drop-down menu or create a new connection.
|
||||
|
||||
Once you have configured your Elasticsearch data source options, click **Save & test** to test the connection. A successful connection displays the following message:
|
||||
|
||||
`Elasticsearch data source is healthy.`
|
||||
|
||||
## Provision the data source
|
||||
|
||||
You can define and configure the data source in YAML files as part of Grafana's provisioning system.
|
||||
For more information about provisioning, and for available configuration options, refer to [Provisioning Grafana](ref:provisioning-grafana).
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
The previously used `database` field has now been [deprecated](https://github.com/grafana/grafana/pull/58647).
|
||||
Use the `index` field in `jsonData` to store the index name.
|
||||
Refer to the examples below.
|
||||
{{< /admonition >}}
|
||||
|
||||
### Basic provisioning
|
||||
|
||||
```yaml
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: Elastic
|
||||
type: elasticsearch
|
||||
access: proxy
|
||||
url: http://localhost:9200
|
||||
jsonData:
|
||||
index: '[metrics-]YYYY.MM.DD'
|
||||
interval: Daily
|
||||
timeField: '@timestamp'
|
||||
```
|
||||
|
||||
### Provision for logs
|
||||
|
||||
```yaml
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: elasticsearch-v7-filebeat
|
||||
type: elasticsearch
|
||||
access: proxy
|
||||
url: http://localhost:9200
|
||||
jsonData:
|
||||
index: '[filebeat-]YYYY.MM.DD'
|
||||
interval: Daily
|
||||
timeField: '@timestamp'
|
||||
logMessageField: message
|
||||
logLevelField: fields.level
|
||||
dataLinks:
|
||||
- datasourceUid: my_jaeger_uid # Target UID needs to be known
|
||||
field: traceID
|
||||
url: '$${__value.raw}' # Careful about the double "$$" because of env var expansion
|
||||
```
|
||||
|
||||
## Provision the data source using Terraform
|
||||
|
||||
You can provision the Elasticsearch data source using [Terraform](https://www.terraform.io/) with the [Grafana Terraform provider](https://registry.terraform.io/providers/grafana/grafana/latest/docs).
|
||||
|
||||
For more information about provisioning resources with Terraform, refer to the [Grafana as code using Terraform](https://grafana.com/docs/grafana-cloud/developer-resources/infrastructure-as-code/terraform/) documentation.
|
||||
|
||||
### Basic Terraform example
|
||||
|
||||
The following example creates a basic Elasticsearch data source for metrics:
|
||||
|
||||
```hcl
|
||||
resource "grafana_data_source" "elasticsearch" {
|
||||
name = "Elasticsearch"
|
||||
type = "elasticsearch"
|
||||
url = "http://localhost:9200"
|
||||
|
||||
json_data_encoded = jsonencode({
|
||||
index = "[metrics-]YYYY.MM.DD"
|
||||
interval = "Daily"
|
||||
timeField = "@timestamp"
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Terraform example for logs
|
||||
|
||||
The following example creates an Elasticsearch data source configured for logs with a data link to Jaeger:
|
||||
|
||||
```hcl
|
||||
resource "grafana_data_source" "elasticsearch_logs" {
|
||||
name = "Elasticsearch Logs"
|
||||
type = "elasticsearch"
|
||||
url = "http://localhost:9200"
|
||||
|
||||
json_data_encoded = jsonencode({
|
||||
index = "[filebeat-]YYYY.MM.DD"
|
||||
interval = "Daily"
|
||||
timeField = "@timestamp"
|
||||
logMessageField = "message"
|
||||
logLevelField = "fields.level"
|
||||
dataLinks = [
|
||||
{
|
||||
datasourceUid = grafana_data_source.jaeger.uid
|
||||
field = "traceID"
|
||||
url = "$${__value.raw}"
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Terraform example with basic authentication
|
||||
|
||||
The following example includes basic authentication:
|
||||
|
||||
```hcl
|
||||
resource "grafana_data_source" "elasticsearch_auth" {
|
||||
name = "Elasticsearch"
|
||||
type = "elasticsearch"
|
||||
url = "http://localhost:9200"
|
||||
|
||||
basic_auth_enabled = true
|
||||
basic_auth_username = "elastic_user"
|
||||
|
||||
secure_json_data_encoded = jsonencode({
|
||||
basicAuthPassword = var.elasticsearch_password
|
||||
})
|
||||
|
||||
json_data_encoded = jsonencode({
|
||||
index = "[metrics-]YYYY.MM.DD"
|
||||
interval = "Daily"
|
||||
timeField = "@timestamp"
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
For all available configuration options, refer to the [Grafana provider data source resource documentation](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/data_source).
|
||||
@@ -30,7 +30,7 @@ refs:
|
||||
# Elasticsearch query editor
|
||||
|
||||
Grafana provides a query editor for Elasticsearch. Elasticsearch queries are in Lucene format.
|
||||
See [Lucene query syntax](https://www.elastic.co/guide/en/kibana/current/lucene-query.html) and [Query string syntax](https://www.elastic.co/guide/en/elasticsearch/reference/8.9/query-dsl-query-string-query.html#query-string-syntax) if you are new to working with Lucene queries in Elasticsearch.
|
||||
For more information about query syntax, refer to [Lucene query syntax](https://www.elastic.co/guide/en/kibana/current/lucene-query.html) and [Query string syntax](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-query-string-query.html#query-string-syntax).
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
When composing Lucene queries, ensure that you use uppercase boolean operators: `AND`, `OR`, and `NOT`. Lowercase versions of these operators are not supported by the Lucene query syntax.
|
||||
@@ -38,17 +38,17 @@ When composing Lucene queries, ensure that you use uppercase boolean operators:
|
||||
|
||||
{{< figure src="/static/img/docs/elasticsearch/elastic-query-editor-10.1.png" max-width="800px" class="docs-image--no-shadow" caption="Elasticsearch query editor" >}}
|
||||
|
||||
For general documentation on querying data sources in Grafana, including options and functions common to all query editors, see [Query and transform data](ref:query-and-transform-data).
|
||||
For general documentation on querying data sources in Grafana, including options and functions common to all query editors, refer to [Query and transform data](ref:query-and-transform-data).
|
||||
|
||||
## Aggregation types
|
||||
|
||||
Elasticsearch groups aggregations into three categories:
|
||||
|
||||
- **Bucket** - Bucket aggregations don't calculate metrics, they create buckets of documents based on field values, ranges and a variety of other criteria. See [Bucket aggregations](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket.html) for additional information. Use bucket aggregations under `Group by` when creating a metrics query in the query builder.
|
||||
- **Bucket** - Bucket aggregations don't calculate metrics, they create buckets of documents based on field values, ranges and a variety of other criteria. Refer to [Bucket aggregations](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket.html) for additional information. Use bucket aggregations under `Group by` when creating a metrics query in the query builder.
|
||||
|
||||
- **Metrics** - Metrics aggregations perform calculations such as sum, average, min, etc. They can be single-value or multi-value. See [Metrics aggregations](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics.html) for additional information. Use metrics aggregations in the metrics query type in the query builder.
|
||||
- **Metrics** - Metrics aggregations perform calculations such as sum, average, min, etc. They can be single-value or multi-value. Refer to [Metrics aggregations](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics.html) for additional information. Use metrics aggregations in the metrics query type in the query builder.
|
||||
|
||||
- **Pipeline** - Elasticsearch pipeline aggregations work with inputs or metrics created from other aggregations (not documents or fields). There are parent and sibling and sibling pipeline aggregations. See [Pipeline aggregations](https://www.elastic.co/guide/en/elasticsearch/reference/8.9/search-aggregations-pipeline.html) for additional information.
|
||||
- **Pipeline** - Pipeline aggregations work on the output of other aggregations rather than on documents or fields. Refer to [Pipeline aggregations](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-pipeline.html) for additional information.
|
||||
|
||||
## Select a query type
|
||||
|
||||
@@ -56,44 +56,51 @@ There are three types of queries you can create with the Elasticsearch query bui
|
||||
|
||||
### Metrics query type
|
||||
|
||||
Metrics queries aggregate data and produce a variety of calculations such as count, min, max, etc. Click on the metric box to view a list of options in the dropdown menu. The default is `count`.
|
||||
Metrics queries aggregate data and produce calculations such as count, min, max, and more. Click the metric box to view options in the drop-down menu. The default is `count`.
|
||||
|
||||
- **Alias** - Aliasing only applies to **time series queries**, where the last group is `date histogram`. This is ignored for any other type of query.
|
||||
|
||||
- **Metric** - Metrics aggregations include:
|
||||
- count - see [Value count aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/8.9/search-aggregations-metrics-valuecount-aggregation.html)
|
||||
- average - see [Avg aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/8.9/search-aggregations-metrics-rate-aggregation.html)
|
||||
- sum - see [Sum aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-sum-aggregation.html)
|
||||
- max - see [Max aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/8.9/search-aggregations-metrics-max-aggregation.html)
|
||||
- min - see [Min aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/8.9/search-aggregations-metrics-min-aggregation.html)
|
||||
- extended stats - see [Extended stats aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-extendedstats-aggregation.html)
|
||||
- percentiles - see [Percentiles aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/8.9/search-aggregations-metrics-percentile-aggregation.html)
|
||||
- unique count - see [Cardinality aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/8.9/search-aggregations-metrics-cardinality-aggregation.html)
|
||||
- top metrics - see [Top metrics aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/8.9/search-aggregations-metrics-top-metrics.html)
|
||||
- rate - see [Rate aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/8.9/search-aggregations-metrics-rate-aggregation.html)
|
||||
- count - refer to [Value count aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-valuecount-aggregation.html)
|
||||
- average - refer to [Avg aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-avg-aggregation.html)
|
||||
- sum - refer to [Sum aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-sum-aggregation.html)
|
||||
- max - refer to [Max aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-max-aggregation.html)
|
||||
- min - refer to [Min aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-min-aggregation.html)
|
||||
- extended stats - refer to [Extended stats aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-extendedstats-aggregation.html)
|
||||
- percentiles - refer to [Percentiles aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-percentile-aggregation.html)
|
||||
- unique count - refer to [Cardinality aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-cardinality-aggregation.html)
|
||||
- top metrics - refer to [Top metrics aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-top-metrics.html)
|
||||
- rate - refer to [Rate aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-rate-aggregation.html)
|
||||
|
||||
- **Pipeline aggregations** - Pipeline aggregations work on the output of other aggregations rather than on documents. The following pipeline aggregations are available:
|
||||
- moving function - Calculates a value based on a sliding window of aggregated values. Refer to [Moving function aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-pipeline-movfn-aggregation.html).
|
||||
- derivative - Calculates the derivative of a metric. Refer to [Derivative aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-pipeline-derivative-aggregation.html).
|
||||
- cumulative sum - Calculates the cumulative sum of a metric. Refer to [Cumulative sum aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-pipeline-cumulative-sum-aggregation.html).
|
||||
- serial difference - Calculates the difference between values in a time series. Refer to [Serial differencing aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-pipeline-serialdiff-aggregation.html).
|
||||
- bucket script - Executes a script on metric values from other aggregations. Refer to [Bucket script aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-pipeline-bucket-script-aggregation.html).
|
||||
|
||||
You can select multiple metrics and group by multiple terms or filters when using the Elasticsearch query editor.
|
||||
|
||||
Use the **+ sign** to the right to add multiple metrics to your query. Click on the **eye icon** next to **Metric** to hide metrics, and the **garbage can icon** to remove metrics.
|
||||
|
||||
- **Group by options** - Create multiple group by options when constructing your Elasticsearch query. Date histogram is the default option. Below is a list of options in the dropdown menu.
|
||||
- terms - see [Terms aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-terms-aggregation.html).
|
||||
- filter - see [Filter aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-filter-aggregation.html).
|
||||
- geo hash grid - see [Geohash grid aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-geohashgrid-aggregation.html).
|
||||
- date histogram - for time series queries. See [Date histogram aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-datehistogram-aggregation.html).
|
||||
- histogram - Depicts frequency distributions. See [Histogram aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-histogram-aggregation.html).
|
||||
- nested (experimental) - See [Nested aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-nested-aggregation.html).
|
||||
- **Group by options** - Create multiple group by options when constructing your Elasticsearch query. Date histogram is the default option. The following options are available in the drop-down menu:
|
||||
- terms - refer to [Terms aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-terms-aggregation.html).
|
||||
- filter - refer to [Filter aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-filter-aggregation.html).
|
||||
- geo hash grid - refer to [Geohash grid aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-geohashgrid-aggregation.html).
|
||||
- date histogram - for time series queries. Refer to [Date histogram aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-datehistogram-aggregation.html).
|
||||
- histogram - Depicts frequency distributions. Refer to [Histogram aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-histogram-aggregation.html).
|
||||
- nested (experimental) - Refer to [Nested aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-nested-aggregation.html).
|
||||
|
||||
Each group by option will have a different subset of options to further narrow your query.
|
||||
|
||||
The following options are specific to the **date histogram** bucket aggregation option.
|
||||
|
||||
- **Time field** - Depicts date data options. The default option can be specified when configuring the Elasticsearch data source in the **Time field name** under the [**Elasticsearch details**](/docs/grafana/latest/datasources/elasticsearch/configure-elasticsearch-data-source/#elasticsearch-details) section. Otherwise **@timestamp** field will be used as a default option.
|
||||
- **Interval** - Group by a type of interval. There are option to choose from the dropdown menu to select seconds, minutes, hours or day. You can also add a custom interval such as `30d` (30 days). `Auto` is the default option.
|
||||
- **Min doc count** - The minimum amount of data to include in your query. The default is `0`.
|
||||
- **Thin edges** - Select to trim edges on the time series data points. The default is `0`.
|
||||
- **Offset** - Changes the start value of each bucket by the specified positive(+) or negative (-) offset duration. Examples include `1h` for 1 hour, `5s` for 5 seconds or `1d` for 1 day.
|
||||
- **Timezone** - Select a timezone from the dropdown menu. The default is `Coordinated universal time`.
|
||||
- **Time field** - The field used for time-based queries. The default can be set when configuring the data source in the **Time field name** setting under [Elasticsearch details](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/datasources/elasticsearch/configure/#elasticsearch-details). The default is `@timestamp`.
|
||||
- **Interval** - The time interval for grouping data. Select from the drop-down menu or enter a custom interval such as `30d` (30 days). The default is `Auto`.
|
||||
- **Min doc count** - The minimum number of documents required to include a bucket. The default is `0`.
|
||||
- **Trim edges** - Removes partial buckets at the edges of the time range. The default is `0`.
|
||||
- **Offset** - Shifts the start of each bucket by the specified duration. Use positive (`+`) or negative (`-`) values. Examples: `1h`, `5s`, `1d`.
|
||||
- **Timezone** - The timezone for date calculations. The default is `Coordinated Universal Time`.
|
||||
|
||||
Configure the following options for the **terms** bucket aggregation option:
|
||||
|
||||
@@ -101,7 +108,7 @@ Configure the following options for the **terms** bucket aggregation option:
|
||||
- **Size** - Limits the number of documents, or size of the data set. You can set a custom number or `no limit`.
|
||||
- **Min doc count** - The minimum amount of data to include in your query. The default is `0`.
|
||||
- **Order by** - Order terms by `term value`, `doc count` or `count`.
|
||||
- **Missing** - Defines how documents missing a value should be treated. Missing values are ignored by default, but they can be treated as if they had a value. See [Missing value](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-terms-aggregation.html#_missing_value_5) in Elasticsearch's documentation for more information.
|
||||
- **Missing** - Defines how documents missing a value should be treated. Missing values are ignored by default, but they can be treated as if they had a value. Refer to [Missing value](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-terms-aggregation.html#_missing_value_5) in the Elasticsearch documentation for more information.
|
||||
|
||||
Configure the following options for the **filters** bucket aggregation option:
|
||||
|
||||
@@ -114,8 +121,8 @@ Configure the following options for the **geo hash grid** bucket aggregation opt
|
||||
|
||||
Configure the following options for the **histogram** bucket aggregation option:
|
||||
|
||||
- **Interval** - Group by a type of interval. There are option to choose from the dropdown menu to select seconds, minutes, hours or day. You can also add a custom interval such as `30d` (30 days). `Auto` is the default option.
|
||||
- **Min doc count** - The minimum amount of data to include in your query. The default is `0`
|
||||
- **Interval** - The numeric interval for grouping values into buckets.
|
||||
- **Min doc count** - The minimum number of documents required to include a bucket. The default is `0`.
|
||||
|
||||
The **nested** group by option is currently experimental, you can select a field and then settings specific to that field.
|
||||
|
||||
@@ -141,7 +148,7 @@ The option to run a **raw document query** is deprecated as of Grafana v10.1.
|
||||
|
||||
## Use template variables
|
||||
|
||||
You can also augment queries by using [template variables](../template-variables/).
|
||||
You can also augment queries by using [template variables](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/datasources/elasticsearch/template-variables/).
|
||||
|
||||
Queries of `terms` have a 500-result limit by default.
|
||||
To set a custom limit, set the `size` property in your query.
|
||||
|
||||
@@ -22,6 +22,11 @@ refs:
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/variables/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/variables/
|
||||
add-template-variables-add-ad-hoc-filters:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/variables/add-template-variables/#add-ad-hoc-filters
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/variables/add-template-variables/#add-ad-hoc-filters
|
||||
add-template-variables-multi-value-variables:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/variables/add-template-variables/#multi-value-variables
|
||||
@@ -37,11 +42,29 @@ refs:
|
||||
# Elasticsearch template variables
|
||||
|
||||
Instead of hard-coding details such as server, application, and sensor names in metric queries, you can use variables.
|
||||
Grafana lists these variables in dropdown select boxes at the top of the dashboard to help you change the data displayed in your dashboard.
|
||||
Grafana lists these variables in drop-down select boxes at the top of the dashboard to help you change the data displayed in your dashboard.
|
||||
Grafana refers to such variables as template variables.
|
||||
|
||||
For an introduction to templating and template variables, refer to the [Templating](ref:variables) and [Add and manage variables](ref:add-template-variables) documentation.
|
||||
|
||||
## Use ad hoc filters
|
||||
|
||||
Elasticsearch supports the **Ad hoc filters** variable type.
|
||||
You can use this variable type to specify any number of key/value filters, and Grafana applies them automatically to all of your Elasticsearch queries.
|
||||
|
||||
Ad hoc filters support the following operators:
|
||||
|
||||
| Operator | Description |
|
||||
| -------- | ------------------------------------------------------------- |
|
||||
| `=` | Equals. Adds `AND field:"value"` to the query. |
|
||||
| `!=` | Not equals. Adds `AND -field:"value"` to the query. |
|
||||
| `=~` | Matches regex. Adds `AND field:/value/` to the query. |
|
||||
| `!~` | Does not match regex. Adds `AND -field:/value/` to the query. |
|
||||
| `>` | Greater than. Adds `AND field:>value` to the query. |
|
||||
| `<` | Less than. Adds `AND field:<value` to the query. |
|
||||
|
||||
For more information, refer to [Add ad hoc filters](ref:add-template-variables-add-ad-hoc-filters).
|
||||
|
||||
## Choose a variable syntax
|
||||
|
||||
The Elasticsearch data source supports two variable syntaxes for use in the **Query** field:
|
||||
@@ -50,34 +73,35 @@ The Elasticsearch data source supports two variable syntaxes for use in the **Qu
|
||||
- `[[varname]]`, such as `hostname:[[hostname]]`
|
||||
|
||||
When the _Multi-value_ or _Include all value_ options are enabled, Grafana converts the labels from plain text to a Lucene-compatible condition.
|
||||
For details, see the [Multi-value variables](ref:add-template-variables-multi-value-variables) documentation.
|
||||
For details, refer to the [Multi-value variables](ref:add-template-variables-multi-value-variables) documentation.
|
||||
|
||||
## Use variables in queries
|
||||
|
||||
You can use other variables inside the query.
|
||||
This example is used to define a variable named `$host`:
|
||||
You can use variables in the Lucene query field, metric aggregation fields, bucket aggregation fields, and the alias field.
|
||||
|
||||
### Variables in Lucene queries
|
||||
|
||||
Use variables to filter your Elasticsearch queries dynamically:
|
||||
|
||||
```
|
||||
{"find": "terms", "field": "hostname", "query": "source:$source"}
|
||||
hostname:$hostname AND level:$level
|
||||
```
|
||||
|
||||
This uses another variable named `$source` inside the query definition.
|
||||
Whenever you change the value of the `$source` variable via the dropdown, Grafana triggers an update of the `$host` variable to contain only hostnames filtered by, in this case, the `source` document property.
|
||||
### Chain or nest variables
|
||||
|
||||
These queries by default return results in term order (which can then be sorted alphabetically or numerically as for any variable).
|
||||
To produce a list of terms sorted by doc count (a top-N values list), add an `orderBy` property of "doc_count".
|
||||
This automatically selects a descending sort.
|
||||
You can create nested variables, where one variable's values depend on another variable's selection.
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
To use an ascending sort (`asc`) with doc_count (a bottom-N list), set `order: "asc"`. However, Elasticsearch [discourages this](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-terms-aggregation.html#search-aggregations-bucket-terms-aggregation-order) because sorting by ascending doc count can return inaccurate results.
|
||||
{{< /admonition >}}
|
||||
|
||||
To keep terms in the doc count order, set the variable's Sort dropdown to **Disabled**.
|
||||
You can alternatively use other sorting criteria, such as **Alphabetical**, to re-sort them.
|
||||
This example defines a variable named `$host` that only shows hosts matching the selected `$environment`:
|
||||
|
||||
```json
|
||||
{ "find": "terms", "field": "hostname", "query": "environment:$environment" }
|
||||
```
|
||||
{"find": "terms", "field": "hostname", "orderBy": "doc_count"}
|
||||
```
|
||||
|
||||
Whenever you change the value of the `$environment` variable via the drop-down, Grafana triggers an update of the `$host` variable to contain only hostnames filtered by the selected environment.
|
||||
|
||||
### Variables in aggregations
|
||||
|
||||
You can use variables in bucket aggregation fields to dynamically change how data is grouped. For example, use a variable in the **Terms** group by field to let users switch between grouping by `hostname`, `service`, or `datacenter`.
|
||||
|
||||
## Template variable examples
|
||||
|
||||
@@ -92,11 +116,36 @@ Write the query using a custom JSON string, with the field mapped as a [keyword]
|
||||
|
||||
If the query is [multi-field](https://www.elastic.co/guide/en/elasticsearch/reference/current/multi-fields.html) with both a `text` and `keyword` type, use `"field":"fieldname.keyword"` (sometimes `fieldname.raw`) to specify the keyword field in your query.
|
||||
|
||||
| Query | Description |
|
||||
| ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `{"find": "fields", "type": "keyword"}` | Returns a list of field names with the index type `keyword`. |
|
||||
| `{"find": "terms", "field": "hostname.keyword", "size": 1000}` | Returns a list of values for a keyword using term aggregation. Query will use current dashboard time range as time range query. |
|
||||
| `{"find": "terms", "field": "hostname", "query": '<Lucene query>'}` | Returns a list of values for a keyword field using term aggregation and a specified Lucene query filter. Query will use current dashboard time range as time range for query. |
|
||||
| Query | Description |
|
||||
| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ |
|
||||
| `{"find": "fields", "type": "keyword"}` | Returns a list of field names with the index type `keyword`. |
|
||||
| `{"find": "fields", "type": "number"}` | Returns a list of numeric field names (includes `float`, `double`, `integer`, `long`, `scaled_float`). |
|
||||
| `{"find": "fields", "type": "date"}` | Returns a list of date field names. |
|
||||
| `{"find": "terms", "field": "hostname.keyword", "size": 1000}` | Returns a list of values for a keyword field. Uses the current dashboard time range. |
|
||||
| `{"find": "terms", "field": "hostname", "query": "<Lucene query>"}` | Returns a list of values filtered by a Lucene query. Uses the current dashboard time range. |
|
||||
| `{"find": "terms", "field": "status", "orderBy": "doc_count"}` | Returns values sorted by document count (descending by default). |
|
||||
| `{"find": "terms", "field": "status", "orderBy": "doc_count", "order": "asc"}` | Returns values sorted by document count in ascending order. |
|
||||
|
||||
Queries of `terms` have a 500-result limit by default.
|
||||
To set a custom limit, set the `size` property in your query.
|
||||
Queries of `terms` have a 500-result limit by default. To set a custom limit, set the `size` property in your query.
|
||||
|
||||
### Sort query results
|
||||
|
||||
By default, queries return results in term order (which can then be sorted alphabetically or numerically using the variable's Sort setting).
|
||||
|
||||
To produce a list of terms sorted by document count (a top-N values list), add an `orderBy` property of `doc_count`. This automatically selects a descending sort:
|
||||
|
||||
```json
|
||||
{ "find": "terms", "field": "status", "orderBy": "doc_count" }
|
||||
```
|
||||
|
||||
You can also use the `order` property to explicitly set ascending or descending sort:
|
||||
|
||||
```json
|
||||
{ "find": "terms", "field": "hostname", "orderBy": "doc_count", "order": "asc" }
|
||||
```
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
Elasticsearch [discourages](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-terms-aggregation.html#search-aggregations-bucket-terms-aggregation-order) sorting by ascending doc count because it can return inaccurate results.
|
||||
{{< /admonition >}}
|
||||
|
||||
To keep terms in the document count order, set the variable's Sort drop-down to **Disabled**. You can alternatively use other sorting criteria, such as **Alphabetical**, to re-sort them.
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
---
|
||||
aliases:
|
||||
- ../../data-sources/elasticsearch/troubleshooting/
|
||||
description: Troubleshooting the Elasticsearch data source in Grafana
|
||||
keywords:
|
||||
- grafana
|
||||
- elasticsearch
|
||||
- troubleshooting
|
||||
- errors
|
||||
labels:
|
||||
products:
|
||||
- cloud
|
||||
- enterprise
|
||||
- oss
|
||||
menuTitle: Troubleshooting
|
||||
title: Troubleshoot issues with the Elasticsearch data source
|
||||
weight: 600
|
||||
---
|
||||
|
||||
# Troubleshoot issues with the Elasticsearch data source
|
||||
|
||||
This document provides troubleshooting information for common errors you may encounter when using the Elasticsearch data source in Grafana.
|
||||
|
||||
## Connection errors
|
||||
|
||||
The following errors occur when Grafana cannot establish or maintain a connection to Elasticsearch.
|
||||
|
||||
### Failed to connect to Elasticsearch
|
||||
|
||||
**Error message:** "Health check failed: Failed to connect to Elasticsearch"
|
||||
|
||||
**Cause:** Grafana cannot establish a network connection to the Elasticsearch server.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Verify that the Elasticsearch URL is correct in the data source configuration.
|
||||
1. Check that Elasticsearch is running and accessible from the Grafana server.
|
||||
1. Ensure there are no firewall rules blocking the connection.
|
||||
1. If using a proxy, verify the proxy settings are correct.
|
||||
1. For Grafana Cloud, ensure you have configured [Private data source connect](https://grafana.com/docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/) if your Elasticsearch instance is not publicly accessible.
|
||||
|
||||
### Request timed out
|
||||
|
||||
**Error message:** "Health check failed: Elasticsearch data source is not healthy. Request timed out"
|
||||
|
||||
**Cause:** The connection to Elasticsearch timed out before receiving a response.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Check the network latency between Grafana and Elasticsearch.
|
||||
1. Verify that Elasticsearch is not overloaded or experiencing performance issues.
|
||||
1. Increase the timeout setting in the data source configuration if needed.
|
||||
1. Check if any network devices (load balancers, proxies) are timing out the connection.
|
||||
|
||||
### Failed to parse data source URL
|
||||
|
||||
**Error message:** "Failed to parse data source URL"
|
||||
|
||||
**Cause:** The URL entered in the data source configuration is not valid.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Verify the URL format is correct (for example, `http://localhost:9200` or `https://elasticsearch.example.com:9200`).
|
||||
1. Ensure the URL includes the protocol (`http://` or `https://`).
|
||||
1. Remove any trailing slashes or invalid characters from the URL.
|
||||
|
||||
## Authentication errors
|
||||
|
||||
The following errors occur when there are issues with authentication credentials or permissions.
|
||||
|
||||
### Unauthorized (401)
|
||||
|
||||
**Error message:** "Health check failed: Elasticsearch data source is not healthy. Status: 401 Unauthorized"
|
||||
|
||||
**Cause:** The authentication credentials are invalid or missing.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Verify that the username and password are correct.
|
||||
1. If using an API key, ensure the key is valid and has not expired.
|
||||
1. Check that the authentication method selected matches your Elasticsearch configuration.
|
||||
1. Verify the user has the required permissions to access the Elasticsearch cluster.
|
||||
|
||||
### Forbidden (403)
|
||||
|
||||
**Error message:** "Health check failed: Elasticsearch data source is not healthy. Status: 403 Forbidden"
|
||||
|
||||
**Cause:** The authenticated user does not have permission to access the requested resource.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Verify the user has read access to the specified index.
|
||||
1. Check Elasticsearch security settings and role mappings.
|
||||
1. Ensure the user has permission to access the `_cluster/health` endpoint.
|
||||
1. If using AWS Elasticsearch Service with SigV4 authentication, verify the IAM policy grants the required permissions.
|
||||
|
||||
## Cluster health errors
|
||||
|
||||
The following errors occur when the Elasticsearch cluster is unhealthy or unavailable.
|
||||
|
||||
### Cluster status is red
|
||||
|
||||
**Error message:** "Health check failed: Elasticsearch data source is not healthy"
|
||||
|
||||
**Cause:** The Elasticsearch cluster health status is red, indicating one or more primary shards are not allocated.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Check the Elasticsearch cluster health using `GET /_cluster/health`.
|
||||
1. Review Elasticsearch logs for errors.
|
||||
1. Verify all nodes in the cluster are running and connected.
|
||||
1. Check for unassigned shards using `GET /_cat/shards?v&h=index,shard,prirep,state,unassigned.reason`.
|
||||
1. Consider increasing the cluster's resources or reducing the number of shards.
|
||||
|
||||
### Bad Gateway (502)
|
||||
|
||||
**Error message:** "Health check failed: Elasticsearch data source is not healthy. Status: 502 Bad Gateway"
|
||||
|
||||
**Cause:** A proxy or load balancer between Grafana and Elasticsearch returned an error.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Check the health of any proxies or load balancers in the connection path.
|
||||
1. Verify Elasticsearch is running and accepting connections.
|
||||
1. Review proxy/load balancer logs for more details.
|
||||
1. Ensure the proxy timeout is configured appropriately for Elasticsearch requests.
|
||||
|
||||
## Index errors
|
||||
|
||||
The following errors occur when there are issues with the configured index or index pattern.
|
||||
|
||||
### Index not found
|
||||
|
||||
**Error message:** "Error validating index: index_not_found"
|
||||
|
||||
**Cause:** The specified index or index pattern does not match any existing indices.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Verify the index name or pattern in the data source configuration.
|
||||
1. Check that the index exists using `GET /_cat/indices`.
|
||||
1. If using a time-based index pattern (for example, `[logs-]YYYY.MM.DD`), ensure indices exist for the selected time range.
|
||||
1. Verify the user has permission to access the index.
|
||||
|
||||
### Time field not found
|
||||
|
||||
**Error message:** "Could not find time field '@timestamp' with type date in index"
|
||||
|
||||
**Cause:** The specified time field does not exist in the index or is not of type `date`.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Verify the time field name in the data source configuration matches the field in your index.
|
||||
1. Check the field mapping using `GET /<index>/_mapping`.
|
||||
1. Ensure the time field is mapped as a `date` type, not `text` or `keyword`.
|
||||
1. If the field name is different (for example, `timestamp` instead of `@timestamp`), update the data source configuration.
|
||||
|
||||
## Query errors
|
||||
|
||||
The following errors occur when there are issues with query syntax or configuration.
|
||||
|
||||
### Too many buckets
|
||||
|
||||
**Error message:** "Trying to create too many buckets. Must be less than or equal to: [65536]."
|
||||
|
||||
**Cause:** The query is generating more aggregation buckets than Elasticsearch allows.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Reduce the time range of your query.
|
||||
1. Increase the date histogram interval (for example, change from `10s` to `1m`).
|
||||
1. Add filters to reduce the number of documents being aggregated.
|
||||
1. Increase the `search.max_buckets` setting in Elasticsearch (requires cluster admin access).
|
||||
|
||||
### Required field missing
|
||||
|
||||
**Error message:** "Required one of fields [field, script], but none were specified."
|
||||
|
||||
**Cause:** A metric aggregation (such as Average, Sum, or Min) was added without specifying a field.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Select a field for the metric aggregation in the query editor.
|
||||
1. Ensure the selected field exists in your index and contains numeric data.
|
||||
|
||||
### Unsupported interval
|
||||
|
||||
**Error message:** "unsupported interval '<interval>'"
|
||||
|
||||
**Cause:** The interval specified for the index pattern is not valid.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Use a supported interval: `Hourly`, `Daily`, `Weekly`, `Monthly`, or `Yearly`.
|
||||
1. If you don't need a time-based index pattern, use `No pattern` and specify the exact index name.
|
||||
|
||||
## Version errors
|
||||
|
||||
The following errors occur when there are Elasticsearch version compatibility issues.
|
||||
|
||||
### Unsupported Elasticsearch version
|
||||
|
||||
**Error message:** "Support for Elasticsearch versions after their end-of-life (currently versions < 7.16) was removed. Using unsupported version of Elasticsearch may lead to unexpected and incorrect results."
|
||||
|
||||
**Cause:** The Elasticsearch version is no longer supported by the Grafana data source.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Upgrade Elasticsearch to a supported version (7.17+, 8.x, or 9.x).
|
||||
1. Refer to [Elastic Product End of Life Dates](https://www.elastic.co/support/eol) for version support information.
|
||||
1. Note that queries may still work, but Grafana does not guarantee functionality for unsupported versions.
|
||||
|
||||
## Other common issues
|
||||
|
||||
The following issues don't produce specific error messages but are commonly encountered.
|
||||
|
||||
### Empty query results
|
||||
|
||||
**Cause:** The query returns no data.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Verify the time range includes data in your index.
|
||||
1. Check the Lucene query syntax for errors.
|
||||
1. Test the query directly in Elasticsearch using the `_search` API.
|
||||
1. Ensure the index contains documents matching your query filters.
|
||||
|
||||
### Slow query performance
|
||||
|
||||
**Cause:** Queries take a long time to execute.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Reduce the time range of your query.
|
||||
1. Add more specific filters to limit the data scanned.
|
||||
1. Increase the date histogram interval.
|
||||
1. Check Elasticsearch cluster performance and resource utilization.
|
||||
1. Consider using index aliases or data streams for better query routing.
|
||||
|
||||
### CORS errors in browser console
|
||||
|
||||
**Cause:** Cross-Origin Resource Sharing (CORS) is blocking requests from the browser to Elasticsearch.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Use Server (proxy) access mode instead of Browser access mode in the data source configuration.
|
||||
1. If Browser access is required, configure CORS settings in Elasticsearch:
|
||||
|
||||
```yaml
|
||||
http.cors.enabled: true
|
||||
http.cors.allow-origin: '<your-grafana-url>'
|
||||
http.cors.allow-headers: 'Authorization, Content-Type'
|
||||
http.cors.allow-credentials: true
|
||||
```
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
Server (proxy) access mode is recommended for security and reliability.
|
||||
{{< /admonition >}}
|
||||
|
||||
## Get additional help
|
||||
|
||||
If you continue to experience issues after following this troubleshooting guide:
|
||||
|
||||
1. Check the [Elasticsearch documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/index.html) for API-specific guidance.
|
||||
1. Review the [Grafana community forums](https://community.grafana.com/) for similar issues.
|
||||
1. Contact Grafana Support if you have an Enterprise license.
|
||||
@@ -83,6 +83,11 @@ This topic lists words and abbreviations that are commonly used in the Grafana d
|
||||
A commonly-used visualization that displays data as points, lines, or bars.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="vertical-align: top"><code>grafanactl</code></td>
|
||||
<td>
|
||||
A command-line tool that enables users to authenticate, manage multiple environments, and perform administrative tasks through Grafana's REST API.
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="vertical-align: top">mixin</td>
|
||||
<td>
|
||||
|
||||
+2
@@ -99,6 +99,7 @@ Add links to other dashboards at the top of your current dashboard.
|
||||
- **Include current time range** – Select this option to include the dashboard time range in the link. When the user clicks the link, the linked dashboard opens with the indicated time range already set. **Example:** https://play.grafana.org/d/000000010/annotations?orgId=1&from=now-3h&to=now
|
||||
- **Include current template variable values** – Select this option to include template variables currently used as query parameters in the link. When the user clicks the link, any matching templates in the linked dashboard are set to the values from the link. For more information, see [Dashboard URL variables](ref:dashboard-url-variables).
|
||||
- **Open link in new tab** – Select this option if you want the dashboard link to open in a new tab or window.
|
||||
- **Show in controls menu** – Select this option to display the link in the dashboard controls menu instead of at the top of the dashboard. The dashboard controls menu appears as a button in the dashboard toolbar.
|
||||
|
||||
1. Click **Save dashboard** in the top-right corner.
|
||||
1. Click **Back to dashboard** and then **Exit edit**.
|
||||
@@ -121,6 +122,7 @@ Add a link to a URL at the top of your current dashboard. You can link to any av
|
||||
- **Include current time range** – Select this option to include the dashboard time range in the link. When the user clicks the link, the linked dashboard opens with the indicated time range already set. **Example:** https://play.grafana.org/d/000000010/annotations?orgId=1&from=now-3h&to=now
|
||||
- **Include current template variable values** – Select this option to include template variables currently used as query parameters in the link. When the user clicks the link, any matching templates in the linked dashboard are set to the values from the link.
|
||||
- **Open link in new tab** – Select this option if you want the dashboard link to open in a new tab or window.
|
||||
- **Show in controls menu** – Select this option to display the link in the dashboard controls menu instead of at the top of the dashboard. The dashboard controls menu appears as a button in the dashboard toolbar.
|
||||
|
||||
1. Click **Save dashboard** in the top-right corner.
|
||||
1. Click **Back to dashboard** and then **Exit edit**.
|
||||
|
||||
@@ -123,10 +123,11 @@ To create a variable, follow these steps:
|
||||
|
||||
If you don't enter a display name, then the drop-down list label is the variable name.
|
||||
|
||||
1. Choose a **Show on dashboard** option:
|
||||
- **Label and value** - The variable drop-down list displays the variable **Name** or **Label** value. This is the default.
|
||||
- **Value:** The variable drop-down list only displays the selected variable value and a down arrow.
|
||||
- **Nothing:** No variable drop-down list is displayed on the dashboard.
|
||||
1. Choose a **Display** option:
|
||||
- **Above dashboard** - The variable drop-down list displays above the dashboard with the variable **Name** or **Label** value. This is the default.
|
||||
- **Above dashboard, label hidden** - The variable drop-down list displays above the dashboard, but without showing the name of the variable.
|
||||
- **Controls menu** - The variable is displayed in the dashboard controls menu instead of above the dashboard. The dashboard controls menu appears as a button in the dashboard toolbar.
|
||||
- **Hidden** - No variable drop-down list is displayed on the dashboard.
|
||||
|
||||
1. Click one of the following links to complete the steps for adding your selected variable type:
|
||||
- [Query](#add-a-query-variable)
|
||||
|
||||
+2
-1
@@ -12,12 +12,13 @@ comments: |
|
||||
To build this Markdown, do the following:
|
||||
|
||||
$ cd /docs (from the root of the repository)
|
||||
$ make sources/panels-visualizations/query-transform-data/transform-data/index.md
|
||||
$ make sources/visualizations/panels-visualizations/query-transform-data/transform-data/index.md
|
||||
$ make docs
|
||||
|
||||
Browse to http://localhost:3003/docs/grafana/latest/panels-visualizations/query-transform-data/transform-data/
|
||||
|
||||
Refer to ./docs/README.md "Content guidelines" for more information about editing and building these docs.
|
||||
|
||||
aliases:
|
||||
- ../../../panels/transform-data/ # /docs/grafana/next/panels/transform-data/
|
||||
- ../../../panels/transform-data/about-transformation/ # /docs/grafana/next/panels/transform-data/about-transformation/
|
||||
|
||||
@@ -419,6 +419,9 @@ test.describe(
|
||||
// Select tabs layout
|
||||
await page.getByLabel('layout-selection-option-Tabs').click();
|
||||
|
||||
// confirm layout change
|
||||
await dashboardPage.getByGrafanaSelector(selectors.pages.ConfirmModal.delete).click();
|
||||
|
||||
await expect(dashboardPage.getByGrafanaSelector(selectors.components.Tab.title('New row'))).toBeVisible();
|
||||
await expect(dashboardPage.getByGrafanaSelector(selectors.components.Tab.title('New row 1'))).toBeVisible();
|
||||
await expect(
|
||||
@@ -757,6 +760,9 @@ test.describe(
|
||||
// Select rows layout
|
||||
await page.getByLabel('layout-selection-option-Rows').click();
|
||||
|
||||
// confirm layout change
|
||||
await dashboardPage.getByGrafanaSelector(selectors.pages.ConfirmModal.delete).click();
|
||||
|
||||
await dashboardPage
|
||||
.getByGrafanaSelector(selectors.components.DashboardRow.wrapper('New tab 1'))
|
||||
.scrollIntoViewIfNeeded();
|
||||
|
||||
@@ -4,6 +4,8 @@ import { test, expect, E2ESelectorGroups, DashboardPage } from '@grafana/plugin-
|
||||
|
||||
import testV2Dashboard from '../dashboards/TestV2Dashboard.json';
|
||||
|
||||
import { switchToAutoGrid } from './utils';
|
||||
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: true,
|
||||
@@ -33,7 +35,8 @@ test.describe(
|
||||
).toHaveCount(3);
|
||||
|
||||
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
|
||||
await page.getByLabel('layout-selection-option-Auto grid').click();
|
||||
|
||||
await switchToAutoGrid(page, dashboardPage);
|
||||
|
||||
await expect(
|
||||
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel'))
|
||||
@@ -64,7 +67,8 @@ test.describe(
|
||||
).toHaveCount(3);
|
||||
|
||||
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
|
||||
await page.getByLabel('layout-selection-option-Auto grid').click();
|
||||
|
||||
await switchToAutoGrid(page, dashboardPage);
|
||||
|
||||
// Get initial positions - standard width should have panels on different rows
|
||||
const firstPanelTop = await getPanelTop(dashboardPage, selectors);
|
||||
@@ -124,7 +128,8 @@ test.describe(
|
||||
).toHaveCount(3);
|
||||
|
||||
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
|
||||
await page.getByLabel('layout-selection-option-Auto grid').click();
|
||||
|
||||
await switchToAutoGrid(page, dashboardPage);
|
||||
|
||||
await dashboardPage
|
||||
.getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.AutoGridLayout.minColumnWidth)
|
||||
@@ -181,7 +186,8 @@ test.describe(
|
||||
).toHaveCount(3);
|
||||
|
||||
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
|
||||
await page.getByLabel('layout-selection-option-Auto grid').click();
|
||||
|
||||
await switchToAutoGrid(page, dashboardPage);
|
||||
|
||||
await dashboardPage
|
||||
.getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.AutoGridLayout.maxColumns)
|
||||
@@ -216,7 +222,8 @@ test.describe(
|
||||
).toHaveCount(3);
|
||||
|
||||
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
|
||||
await page.getByLabel('layout-selection-option-Auto grid').click();
|
||||
|
||||
await switchToAutoGrid(page, dashboardPage);
|
||||
|
||||
const regularRowHeight = await getPanelHeight(dashboardPage, selectors);
|
||||
|
||||
@@ -271,7 +278,8 @@ test.describe(
|
||||
).toHaveCount(3);
|
||||
|
||||
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
|
||||
await page.getByLabel('layout-selection-option-Auto grid').click();
|
||||
|
||||
await switchToAutoGrid(page, dashboardPage);
|
||||
|
||||
const regularRowHeight = await getPanelHeight(dashboardPage, selectors);
|
||||
|
||||
@@ -328,7 +336,8 @@ test.describe(
|
||||
).toHaveCount(3);
|
||||
|
||||
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
|
||||
await page.getByLabel('layout-selection-option-Auto grid').click();
|
||||
|
||||
await switchToAutoGrid(page, dashboardPage);
|
||||
|
||||
// Set narrow column width first to ensure panels fit horizontally
|
||||
await dashboardPage
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Page } from 'playwright-core';
|
||||
|
||||
import { test, expect } from '@grafana/plugin-e2e';
|
||||
import { test, expect, DashboardPage } from '@grafana/plugin-e2e';
|
||||
|
||||
import testV2DashWithRepeats from '../dashboards/V2DashWithRepeats.json';
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
getPanelPosition,
|
||||
importTestDashboard,
|
||||
goToEmbeddedPanel,
|
||||
switchToAutoGrid,
|
||||
} from './utils';
|
||||
|
||||
const repeatTitleBase = 'repeat - ';
|
||||
@@ -42,7 +43,7 @@ test.describe(
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
|
||||
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
|
||||
|
||||
await switchToAutoGrid(page);
|
||||
await switchToAutoGrid(page, dashboardPage);
|
||||
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel')).first().click();
|
||||
|
||||
@@ -78,7 +79,8 @@ test.describe(
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
|
||||
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
|
||||
|
||||
await switchToAutoGrid(page);
|
||||
await switchToAutoGrid(page, dashboardPage);
|
||||
|
||||
await saveDashboard(dashboardPage, page, selectors);
|
||||
await page.reload();
|
||||
|
||||
@@ -117,7 +119,7 @@ test.describe(
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
|
||||
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
|
||||
|
||||
await switchToAutoGrid(page);
|
||||
await switchToAutoGrid(page, dashboardPage);
|
||||
|
||||
// select first/original repeat panel to activate edit pane
|
||||
await dashboardPage
|
||||
@@ -148,7 +150,7 @@ test.describe(
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
|
||||
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
|
||||
|
||||
await switchToAutoGrid(page);
|
||||
await switchToAutoGrid(page, dashboardPage);
|
||||
await saveDashboard(dashboardPage, page, selectors);
|
||||
await page.reload();
|
||||
|
||||
@@ -214,7 +216,7 @@ test.describe(
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
|
||||
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
|
||||
|
||||
await switchToAutoGrid(page);
|
||||
await switchToAutoGrid(page, dashboardPage);
|
||||
await saveDashboard(dashboardPage, page, selectors);
|
||||
|
||||
// loading directly into panel editor
|
||||
@@ -271,7 +273,7 @@ test.describe(
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
|
||||
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
|
||||
|
||||
await switchToAutoGrid(page);
|
||||
await switchToAutoGrid(page, dashboardPage);
|
||||
|
||||
// this moving repeated panel between two normal panels
|
||||
await movePanel(dashboardPage, selectors, `${repeatTitleBase}${repeatOptions.at(0)}`, 'New panel');
|
||||
@@ -319,7 +321,7 @@ test.describe(
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
|
||||
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
|
||||
|
||||
await switchToAutoGrid(page);
|
||||
await switchToAutoGrid(page, dashboardPage);
|
||||
await saveDashboard(dashboardPage, page, selectors);
|
||||
await page.reload();
|
||||
|
||||
@@ -382,7 +384,7 @@ test.describe(
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
|
||||
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
|
||||
|
||||
await switchToAutoGrid(page);
|
||||
await switchToAutoGrid(page, dashboardPage);
|
||||
await saveDashboard(dashboardPage, page, selectors);
|
||||
await page.reload();
|
||||
|
||||
@@ -410,7 +412,7 @@ test.describe(
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
|
||||
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
|
||||
|
||||
await switchToAutoGrid(page);
|
||||
await switchToAutoGrid(page, dashboardPage);
|
||||
await saveDashboard(dashboardPage, page, selectors);
|
||||
await page.reload();
|
||||
|
||||
@@ -462,7 +464,3 @@ test.describe(
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
async function switchToAutoGrid(page: Page) {
|
||||
await page.getByLabel('layout-selection-option-Auto grid').click();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Page } from '@playwright/test';
|
||||
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { DashboardPage, E2ESelectorGroups, expect } from '@grafana/plugin-e2e';
|
||||
|
||||
import testV2Dashboard from '../dashboards/TestV2Dashboard.json';
|
||||
@@ -239,3 +240,12 @@ export async function getTabPosition(dashboardPage: DashboardPage, selectors: E2
|
||||
const boundingBox = await tab.boundingBox();
|
||||
return boundingBox;
|
||||
}
|
||||
|
||||
export async function switchToAutoGrid(page: Page, dashboardPage: DashboardPage) {
|
||||
await page.getByLabel('layout-selection-option-Auto grid').click();
|
||||
// confirm layout change if applicable
|
||||
const confirmModal = dashboardPage.getByGrafanaSelector(selectors.pages.ConfirmModal.delete);
|
||||
if (confirmModal) {
|
||||
await confirmModal.click();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2882,11 +2882,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/features/panel/components/VizTypePicker/PanelTypeCard.tsx": {
|
||||
"@grafana/no-aria-label-selectors": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/features/panel/panellinks/linkSuppliers.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
|
||||
+3
-2
@@ -289,7 +289,7 @@
|
||||
"@grafana/google-sdk": "0.3.5",
|
||||
"@grafana/i18n": "workspace:*",
|
||||
"@grafana/lezer-logql": "0.2.9",
|
||||
"@grafana/llm": "0.22.1",
|
||||
"@grafana/llm": "1.0.1",
|
||||
"@grafana/monaco-logql": "^0.0.8",
|
||||
"@grafana/o11y-ds-frontend": "workspace:*",
|
||||
"@grafana/plugin-ui": "^0.11.1",
|
||||
@@ -459,7 +459,8 @@
|
||||
"gitconfiglocal": "2.1.0",
|
||||
"tmp@npm:^0.0.33": "~0.2.1",
|
||||
"js-yaml@npm:4.1.0": "^4.1.0",
|
||||
"js-yaml@npm:=4.1.0": "^4.1.0"
|
||||
"js-yaml@npm:=4.1.0": "^4.1.0",
|
||||
"nodemailer": "7.0.7"
|
||||
},
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
|
||||
@@ -165,6 +165,19 @@ const injectedRtkApi = api
|
||||
}),
|
||||
providesTags: ['Search'],
|
||||
}),
|
||||
getSearchUsers: build.query<GetSearchUsersApiResponse, GetSearchUsersApiArg>({
|
||||
query: (queryArg) => ({
|
||||
url: `/searchUsers`,
|
||||
params: {
|
||||
query: queryArg.query,
|
||||
limit: queryArg.limit,
|
||||
page: queryArg.page,
|
||||
offset: queryArg.offset,
|
||||
sort: queryArg.sort,
|
||||
},
|
||||
}),
|
||||
providesTags: ['Search'],
|
||||
}),
|
||||
listServiceAccount: build.query<ListServiceAccountApiResponse, ListServiceAccountApiArg>({
|
||||
query: (queryArg) => ({
|
||||
url: `/serviceaccounts`,
|
||||
@@ -896,6 +909,18 @@ export type GetSearchTeamsApiArg = {
|
||||
/** page number to start from */
|
||||
page?: number;
|
||||
};
|
||||
export type GetSearchUsersApiResponse = unknown;
|
||||
export type GetSearchUsersApiArg = {
|
||||
query?: string;
|
||||
/** number of results to return */
|
||||
limit?: number;
|
||||
/** page number (starting from 1) */
|
||||
page?: number;
|
||||
/** number of results to skip */
|
||||
offset?: number;
|
||||
/** sortable field */
|
||||
sort?: string;
|
||||
};
|
||||
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). */
|
||||
@@ -2067,6 +2092,9 @@ export type UserSpec = {
|
||||
role: string;
|
||||
title: string;
|
||||
};
|
||||
export type UserStatus = {
|
||||
lastSeenAt: number;
|
||||
};
|
||||
export type User = {
|
||||
/** 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;
|
||||
@@ -2075,6 +2103,7 @@ export type User = {
|
||||
metadata: ObjectMeta;
|
||||
/** Spec is the spec of the User */
|
||||
spec: UserSpec;
|
||||
status: UserStatus;
|
||||
};
|
||||
export type UserList = {
|
||||
/** 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 */
|
||||
@@ -2120,6 +2149,8 @@ export const {
|
||||
useUpdateExternalGroupMappingMutation,
|
||||
useGetSearchTeamsQuery,
|
||||
useLazyGetSearchTeamsQuery,
|
||||
useGetSearchUsersQuery,
|
||||
useLazyGetSearchUsersQuery,
|
||||
useListServiceAccountQuery,
|
||||
useLazyListServiceAccountQuery,
|
||||
useCreateServiceAccountMutation,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// (a <button> with clear text, for example, does not need an aria-label as it's already labeled)
|
||||
// but you still might need to select it for testing,
|
||||
// in that case please add the attribute data-testid={selector} in the component and
|
||||
// prefix your selector string with 'data-testid' so that when create the selectors we know to search for it on the right attribute
|
||||
// prefix your selector string with 'data-testid' so that when we create the selectors we know to search for it on the right attribute
|
||||
|
||||
import { VersionedSelectorGroup } from '../types';
|
||||
|
||||
@@ -1057,6 +1057,7 @@ export const versionedComponents = {
|
||||
},
|
||||
PluginVisualization: {
|
||||
item: {
|
||||
'12.4.0': (title: string) => `data-testid Plugin visualization item ${title}`,
|
||||
[MIN_GRAFANA_VERSION]: (title: string) => `Plugin visualization item ${title}`,
|
||||
},
|
||||
current: {
|
||||
|
||||
+4
@@ -17,6 +17,10 @@ export interface Options {
|
||||
* Controls the height of the rows
|
||||
*/
|
||||
cellHeight?: ui.TableCellHeight;
|
||||
/**
|
||||
* If true, disables all keyboard events in the table. this is used when previewing a table (i.e. suggestions)
|
||||
*/
|
||||
disableKeyboardEvents?: boolean;
|
||||
/**
|
||||
* Enable pagination on the table
|
||||
*/
|
||||
|
||||
Generated
+1
@@ -13,6 +13,7 @@ import * as common from '@grafana/schema';
|
||||
export const pluginVersion = "12.4.0-pre";
|
||||
|
||||
export interface Options extends common.OptionsWithTimezones, common.OptionsWithAnnotations {
|
||||
disableKeyboardEvents?: boolean;
|
||||
legend: common.VizLegendOptions;
|
||||
orientation?: common.VizOrientation;
|
||||
timeCompare?: common.TimeCompareOptions;
|
||||
|
||||
@@ -105,6 +105,7 @@ export function TableNG(props: TableNGProps) {
|
||||
const {
|
||||
cellHeight,
|
||||
data,
|
||||
disableKeyboardEvents,
|
||||
disableSanitizeHtml,
|
||||
enablePagination = false,
|
||||
enableSharedCrosshair = false,
|
||||
@@ -819,9 +820,9 @@ export function TableNG(props: TableNGProps) {
|
||||
}
|
||||
}}
|
||||
onCellKeyDown={
|
||||
hasNestedFrames
|
||||
hasNestedFrames || disableKeyboardEvents
|
||||
? (_, event) => {
|
||||
if (event.isDefaultPrevented()) {
|
||||
if (disableKeyboardEvents || event.isDefaultPrevented()) {
|
||||
// skip parent grid keyboard navigation if nested grid handled it
|
||||
event.preventGridDefault();
|
||||
}
|
||||
|
||||
@@ -138,6 +138,8 @@ export interface BaseTableProps {
|
||||
enableVirtualization?: boolean;
|
||||
// for MarkdownCell, this flag disables sanitization of HTML content. Configured via config.ini.
|
||||
disableSanitizeHtml?: boolean;
|
||||
// if true, disables all keyboard events in the table. this is used when previewing a table (i.e. suggestions)
|
||||
disableKeyboardEvents?: boolean;
|
||||
}
|
||||
|
||||
/* ---------------------------- Table cell props ---------------------------- */
|
||||
|
||||
@@ -187,6 +187,15 @@ func (hs *HTTPServer) registerRoutes() {
|
||||
publicdashboardsapi.CountPublicDashboardRequest(),
|
||||
hs.Index,
|
||||
)
|
||||
|
||||
r.Get("/bootdata/:accessToken",
|
||||
reqNoAuth,
|
||||
hs.PublicDashboardsApi.Middleware.HandleView,
|
||||
publicdashboardsapi.SetPublicDashboardAccessToken,
|
||||
publicdashboardsapi.SetPublicDashboardOrgIdOnContext(hs.PublicDashboardsApi.PublicDashboardService),
|
||||
publicdashboardsapi.CountPublicDashboardRequest(),
|
||||
hs.GetBootdata,
|
||||
)
|
||||
}
|
||||
|
||||
r.Get("/explore", authorize(ac.EvalPermission(ac.ActionDatasourcesExplore)), hs.Index)
|
||||
|
||||
@@ -22,6 +22,7 @@ type iamAuthorizer struct {
|
||||
func newIAMAuthorizer(accessClient authlib.AccessClient, legacyAccessClient authlib.AccessClient) authorizer.Authorizer {
|
||||
resourceAuthorizer := make(map[string]authorizer.Authorizer)
|
||||
|
||||
serviceAuthorizer := gfauthorizer.NewServiceAuthorizer()
|
||||
// Authorizer that allows any authenticated user
|
||||
// To be used when authorization is handled at the storage layer
|
||||
allowAuthorizer := authorizer.AuthorizerFunc(func(
|
||||
@@ -50,8 +51,7 @@ func newIAMAuthorizer(accessClient authlib.AccessClient, legacyAccessClient auth
|
||||
resourceAuthorizer[iamv0.UserResourceInfo.GetName()] = authorizer
|
||||
resourceAuthorizer[iamv0.ExternalGroupMappingResourceInfo.GetName()] = authorizer
|
||||
resourceAuthorizer[iamv0.TeamResourceInfo.GetName()] = authorizer
|
||||
|
||||
serviceAuthorizer := gfauthorizer.NewServiceAuthorizer()
|
||||
resourceAuthorizer["searchUsers"] = serviceAuthorizer
|
||||
resourceAuthorizer["searchTeams"] = serviceAuthorizer
|
||||
|
||||
return &iamAuthorizer{resourceAuthorizer: resourceAuthorizer}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package authorizer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/grafana/authlib/authn"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/client-go/dynamic"
|
||||
"k8s.io/client-go/rest"
|
||||
|
||||
dashboardv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1"
|
||||
folderv1 "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/auth"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNoConfigProvider = errors.New("no config provider for group resource")
|
||||
ErrNoVersionInfo = errors.New("no version info for group resource")
|
||||
|
||||
Versions = map[schema.GroupResource]string{
|
||||
{Group: folderv1.GROUP, Resource: folderv1.RESOURCE}: folderv1.VERSION,
|
||||
{Group: dashboardv1.GROUP, Resource: dashboardv1.DASHBOARD_RESOURCE}: dashboardv1.VERSION,
|
||||
}
|
||||
)
|
||||
|
||||
// ConfigProvider is a function that provides a rest.Config for a given context.
|
||||
type ConfigProvider func(ctx context.Context) (*rest.Config, error)
|
||||
|
||||
// DynamicClientFactory is a function that creates a dynamic.Interface from a rest.Config.
|
||||
// This can be overridden in tests.
|
||||
type DynamicClientFactory func(config *rest.Config) (dynamic.Interface, error)
|
||||
|
||||
// ParentProvider implementation that fetches the parent folder information from remote API servers.
|
||||
type ParentProviderImpl struct {
|
||||
configProviders map[schema.GroupResource]ConfigProvider
|
||||
versions map[schema.GroupResource]string
|
||||
dynamicClientFactory DynamicClientFactory
|
||||
|
||||
// Cache of dynamic clients for each group resource
|
||||
// This is used to avoid creating a new dynamic client for each request
|
||||
// and to reuse the same client for the same group resource.
|
||||
clients map[schema.GroupResource]dynamic.Interface
|
||||
clientsMu sync.Mutex
|
||||
}
|
||||
|
||||
// DialConfig holds the configuration for dialing a remote API server.
|
||||
type DialConfig struct {
|
||||
Host string
|
||||
Insecure bool
|
||||
CAFile string
|
||||
Audience string
|
||||
}
|
||||
|
||||
// NewLocalConfigProvider creates a map of ConfigProviders that return the same given config for local API servers.
|
||||
func NewLocalConfigProvider(
|
||||
configProvider ConfigProvider,
|
||||
) map[schema.GroupResource]ConfigProvider {
|
||||
return map[schema.GroupResource]ConfigProvider{
|
||||
{Group: folderv1.GROUP, Resource: folderv1.RESOURCE}: configProvider,
|
||||
{Group: dashboardv1.GROUP, Resource: dashboardv1.DASHBOARD_RESOURCE}: configProvider,
|
||||
}
|
||||
}
|
||||
|
||||
// NewRemoteConfigProvider creates a map of ConfigProviders for remote API servers based on the given DialConfig.
|
||||
func NewRemoteConfigProvider(cfg map[schema.GroupResource]DialConfig, exchangeClient authn.TokenExchanger) map[schema.GroupResource]ConfigProvider {
|
||||
configProviders := make(map[schema.GroupResource]ConfigProvider, len(cfg))
|
||||
for gr, dialConfig := range cfg {
|
||||
configProviders[gr] = func(ctx context.Context) (*rest.Config, error) {
|
||||
return &rest.Config{
|
||||
Host: dialConfig.Host,
|
||||
WrapTransport: func(rt http.RoundTripper) http.RoundTripper {
|
||||
return auth.NewRoundTripper(exchangeClient, rt, dialConfig.Audience)
|
||||
},
|
||||
TLSClientConfig: rest.TLSClientConfig{
|
||||
Insecure: dialConfig.Insecure,
|
||||
CAFile: dialConfig.CAFile,
|
||||
},
|
||||
QPS: 50,
|
||||
Burst: 100,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
return configProviders
|
||||
}
|
||||
|
||||
// NewApiParentProvider creates a new ParentProviderImpl with the given config providers and version info.
|
||||
func NewApiParentProvider(
|
||||
configProviders map[schema.GroupResource]ConfigProvider,
|
||||
version map[schema.GroupResource]string,
|
||||
) *ParentProviderImpl {
|
||||
return &ParentProviderImpl{
|
||||
configProviders: configProviders,
|
||||
versions: version,
|
||||
dynamicClientFactory: func(config *rest.Config) (dynamic.Interface, error) {
|
||||
return dynamic.NewForConfig(config)
|
||||
},
|
||||
clients: make(map[schema.GroupResource]dynamic.Interface),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ParentProviderImpl) HasParent(gr schema.GroupResource) bool {
|
||||
_, ok := p.configProviders[gr]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (p *ParentProviderImpl) getClient(ctx context.Context, gr schema.GroupResource) (dynamic.Interface, error) {
|
||||
p.clientsMu.Lock()
|
||||
client, ok := p.clients[gr]
|
||||
p.clientsMu.Unlock()
|
||||
|
||||
if ok {
|
||||
return client, nil
|
||||
}
|
||||
|
||||
provider, ok := p.configProviders[gr]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %s", ErrNoConfigProvider, gr.String())
|
||||
}
|
||||
restConfig, err := provider(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client, err = p.dynamicClientFactory(restConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p.clientsMu.Lock()
|
||||
p.clients[gr] = client
|
||||
p.clientsMu.Unlock()
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (p *ParentProviderImpl) GetParent(ctx context.Context, gr schema.GroupResource, namespace, name string) (string, error) {
|
||||
client, err := p.getClient(ctx, gr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
version, ok := p.versions[gr]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%w: %s", ErrNoVersionInfo, gr.String())
|
||||
}
|
||||
resourceClient := client.Resource(schema.GroupVersionResource{
|
||||
Group: gr.Group,
|
||||
Resource: gr.Resource,
|
||||
Version: version,
|
||||
}).Namespace(namespace)
|
||||
|
||||
unstructObj, err := resourceClient.Get(ctx, name, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return unstructObj.GetAnnotations()[utils.AnnoKeyFolder], nil
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package authorizer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/client-go/dynamic"
|
||||
"k8s.io/client-go/rest"
|
||||
|
||||
folderv1 "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
)
|
||||
|
||||
var configProvider = func(ctx context.Context) (*rest.Config, error) {
|
||||
return &rest.Config{}, nil
|
||||
}
|
||||
|
||||
func TestParentProviderImpl_GetParent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
gr schema.GroupResource
|
||||
namespace string
|
||||
resourceName string
|
||||
parentFolder string
|
||||
setupFake func(*fakeDynamicClient, *fakeResourceInterface)
|
||||
configProviders map[schema.GroupResource]ConfigProvider
|
||||
versions map[schema.GroupResource]string
|
||||
expectedError string
|
||||
expectedParent string
|
||||
}{
|
||||
{
|
||||
name: "successfully get parent folder",
|
||||
gr: schema.GroupResource{Group: folderv1.GROUP, Resource: folderv1.RESOURCE},
|
||||
namespace: "org-1",
|
||||
resourceName: "dash1",
|
||||
parentFolder: "fold1",
|
||||
setupFake: func(fakeClient *fakeDynamicClient, fakeResource *fakeResourceInterface) {
|
||||
fakeClient.resourceInterface = fakeResource
|
||||
fakeResource.getFunc = func(ctx context.Context, name string, opts metav1.GetOptions, subresources ...string) (*unstructured.Unstructured, error) {
|
||||
obj := &unstructured.Unstructured{}
|
||||
obj.SetAnnotations(map[string]string{utils.AnnoKeyFolder: "fold1"})
|
||||
return obj, nil
|
||||
}
|
||||
},
|
||||
configProviders: map[schema.GroupResource]ConfigProvider{
|
||||
{Group: folderv1.GROUP, Resource: folderv1.RESOURCE}: configProvider,
|
||||
},
|
||||
versions: Versions,
|
||||
expectedParent: "fold1",
|
||||
},
|
||||
{
|
||||
name: "resource without parent annotation returns empty",
|
||||
gr: schema.GroupResource{Group: folderv1.GROUP, Resource: folderv1.RESOURCE},
|
||||
namespace: "org-1",
|
||||
resourceName: "dash1",
|
||||
setupFake: func(fakeClient *fakeDynamicClient, fakeResource *fakeResourceInterface) {
|
||||
fakeClient.resourceInterface = fakeResource
|
||||
fakeResource.getFunc = func(ctx context.Context, name string, opts metav1.GetOptions, subresources ...string) (*unstructured.Unstructured, error) {
|
||||
obj := &unstructured.Unstructured{}
|
||||
obj.SetAnnotations(map[string]string{})
|
||||
return obj, nil
|
||||
}
|
||||
},
|
||||
configProviders: map[schema.GroupResource]ConfigProvider{
|
||||
{Group: folderv1.GROUP, Resource: folderv1.RESOURCE}: configProvider,
|
||||
},
|
||||
versions: Versions,
|
||||
expectedParent: "",
|
||||
},
|
||||
{
|
||||
name: "no config provider returns error",
|
||||
gr: schema.GroupResource{Group: "unknown.group", Resource: "unknown"},
|
||||
namespace: "org-1",
|
||||
resourceName: "resource-1",
|
||||
configProviders: map[schema.GroupResource]ConfigProvider{},
|
||||
versions: Versions,
|
||||
expectedError: ErrNoConfigProvider.Error(),
|
||||
},
|
||||
{
|
||||
name: "config provider returns error",
|
||||
gr: schema.GroupResource{Group: folderv1.GROUP, Resource: folderv1.RESOURCE},
|
||||
namespace: "org-1",
|
||||
resourceName: "resource-1",
|
||||
configProviders: map[schema.GroupResource]ConfigProvider{
|
||||
{Group: folderv1.GROUP, Resource: folderv1.RESOURCE}: func(ctx context.Context) (*rest.Config, error) {
|
||||
return nil, errors.New("config provider error")
|
||||
},
|
||||
},
|
||||
versions: Versions,
|
||||
expectedError: "config provider error",
|
||||
},
|
||||
{
|
||||
name: "no version info returns error",
|
||||
gr: schema.GroupResource{Group: folderv1.GROUP, Resource: folderv1.RESOURCE},
|
||||
namespace: "org-1",
|
||||
resourceName: "resource-1",
|
||||
configProviders: map[schema.GroupResource]ConfigProvider{
|
||||
{Group: folderv1.GROUP, Resource: folderv1.RESOURCE}: func(ctx context.Context) (*rest.Config, error) {
|
||||
return &rest.Config{}, nil
|
||||
},
|
||||
},
|
||||
versions: map[schema.GroupResource]string{},
|
||||
expectedError: ErrNoVersionInfo.Error(),
|
||||
},
|
||||
{
|
||||
name: "resource get returns error",
|
||||
gr: schema.GroupResource{Group: folderv1.GROUP, Resource: folderv1.RESOURCE},
|
||||
namespace: "org-1",
|
||||
resourceName: "resource-1",
|
||||
setupFake: func(fakeClient *fakeDynamicClient, fakeResource *fakeResourceInterface) {
|
||||
fakeClient.resourceInterface = fakeResource
|
||||
fakeResource.getFunc = func(ctx context.Context, name string, opts metav1.GetOptions, subresources ...string) (*unstructured.Unstructured, error) {
|
||||
return nil, errors.New("resource not found")
|
||||
}
|
||||
},
|
||||
configProviders: map[schema.GroupResource]ConfigProvider{
|
||||
{Group: folderv1.GROUP, Resource: folderv1.RESOURCE}: func(ctx context.Context) (*rest.Config, error) {
|
||||
return &rest.Config{}, nil
|
||||
},
|
||||
},
|
||||
versions: Versions,
|
||||
expectedError: "resource not found",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fakeClient := &fakeDynamicClient{}
|
||||
fakeResource := &fakeResourceInterface{}
|
||||
if tt.setupFake != nil {
|
||||
tt.setupFake(fakeClient, fakeResource)
|
||||
}
|
||||
|
||||
provider := &ParentProviderImpl{
|
||||
configProviders: tt.configProviders,
|
||||
versions: tt.versions,
|
||||
dynamicClientFactory: func(config *rest.Config) (dynamic.Interface, error) {
|
||||
return fakeClient, nil
|
||||
},
|
||||
clients: make(map[schema.GroupResource]dynamic.Interface),
|
||||
}
|
||||
|
||||
parent, err := provider.GetParent(context.Background(), tt.gr, tt.namespace, tt.resourceName)
|
||||
|
||||
if tt.expectedError != "" {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.expectedError)
|
||||
assert.Empty(t, parent)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.expectedParent, parent)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// fakeDynamicClient is a fake implementation of dynamic.Interface
|
||||
type fakeDynamicClient struct {
|
||||
resourceInterface dynamic.ResourceInterface
|
||||
}
|
||||
|
||||
func (f *fakeDynamicClient) Resource(resource schema.GroupVersionResource) dynamic.NamespaceableResourceInterface {
|
||||
return &fakeNamespaceableResourceInterface{
|
||||
resourceInterface: f.resourceInterface,
|
||||
}
|
||||
}
|
||||
|
||||
// fakeNamespaceableResourceInterface is a fake implementation of dynamic.NamespaceableResourceInterface
|
||||
type fakeNamespaceableResourceInterface struct {
|
||||
dynamic.NamespaceableResourceInterface
|
||||
resourceInterface dynamic.ResourceInterface
|
||||
}
|
||||
|
||||
func (f *fakeNamespaceableResourceInterface) Namespace(namespace string) dynamic.ResourceInterface {
|
||||
if f.resourceInterface != nil {
|
||||
return f.resourceInterface
|
||||
}
|
||||
return &fakeResourceInterface{}
|
||||
}
|
||||
|
||||
// fakeResourceInterface is a fake implementation of dynamic.ResourceInterface
|
||||
type fakeResourceInterface struct {
|
||||
dynamic.ResourceInterface
|
||||
getFunc func(ctx context.Context, name string, opts metav1.GetOptions, subresources ...string) (*unstructured.Unstructured, error)
|
||||
}
|
||||
|
||||
func (f *fakeResourceInterface) Get(ctx context.Context, name string, opts metav1.GetOptions, subresources ...string) (*unstructured.Unstructured, error) {
|
||||
if f.getFunc != nil {
|
||||
return f.getFunc(ctx, name, opts, subresources...)
|
||||
}
|
||||
return &unstructured.Unstructured{}, nil
|
||||
}
|
||||
@@ -10,24 +10,44 @@ import (
|
||||
|
||||
iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/auth/authorizer/storewrapper"
|
||||
)
|
||||
|
||||
// TODO: Logs, Metrics, Traces?
|
||||
|
||||
// ParentProvider interface for fetching parent information of resources
|
||||
type ParentProvider interface {
|
||||
// HasParent checks if the given GroupResource has a parent folder
|
||||
HasParent(gr schema.GroupResource) bool
|
||||
// GetParent fetches the parent folder name for the given resource
|
||||
GetParent(ctx context.Context, gr schema.GroupResource, namespace, name string) (string, error)
|
||||
}
|
||||
|
||||
// ResourcePermissionsAuthorizer
|
||||
type ResourcePermissionsAuthorizer struct {
|
||||
accessClient types.AccessClient
|
||||
accessClient types.AccessClient
|
||||
parentProvider ParentProvider
|
||||
logger log.Logger
|
||||
}
|
||||
|
||||
var _ storewrapper.ResourceStorageAuthorizer = (*ResourcePermissionsAuthorizer)(nil)
|
||||
|
||||
func NewResourcePermissionsAuthorizer(accessClient types.AccessClient) *ResourcePermissionsAuthorizer {
|
||||
func NewResourcePermissionsAuthorizer(
|
||||
accessClient types.AccessClient,
|
||||
parentProvider ParentProvider,
|
||||
) *ResourcePermissionsAuthorizer {
|
||||
return &ResourcePermissionsAuthorizer{
|
||||
accessClient: accessClient,
|
||||
accessClient: accessClient,
|
||||
parentProvider: parentProvider,
|
||||
logger: log.New("iam.resource-permissions-authorizer"),
|
||||
}
|
||||
}
|
||||
|
||||
func isAccessPolicy(authInfo types.AuthInfo) bool {
|
||||
return types.IsIdentityType(authInfo.GetIdentityType(), types.TypeAccessPolicy)
|
||||
}
|
||||
|
||||
// AfterGet implements ResourceStorageAuthorizer.
|
||||
func (r *ResourcePermissionsAuthorizer) AfterGet(ctx context.Context, obj runtime.Object) error {
|
||||
authInfo, ok := types.AuthInfoFrom(ctx)
|
||||
@@ -37,9 +57,24 @@ func (r *ResourcePermissionsAuthorizer) AfterGet(ctx context.Context, obj runtim
|
||||
switch o := obj.(type) {
|
||||
case *iamv0.ResourcePermission:
|
||||
target := o.Spec.Resource
|
||||
targetGR := schema.GroupResource{Group: target.ApiGroup, Resource: target.Resource}
|
||||
|
||||
// TODO: Fetch the resource to retrieve its parent folder.
|
||||
parent := ""
|
||||
// Fetch the parent of the resource
|
||||
// Access Policies have global scope, so no parent check needed
|
||||
if !isAccessPolicy(authInfo) && r.parentProvider.HasParent(targetGR) {
|
||||
p, err := r.parentProvider.GetParent(ctx, targetGR, o.Namespace, target.Name)
|
||||
if err != nil {
|
||||
r.logger.Error("after get: error fetching parent", "error", err.Error(),
|
||||
"namespace", o.Namespace,
|
||||
"group", target.ApiGroup,
|
||||
"resource", target.Resource,
|
||||
"name", target.Name,
|
||||
)
|
||||
return err
|
||||
}
|
||||
parent = p
|
||||
}
|
||||
|
||||
checkReq := types.CheckRequest{
|
||||
Namespace: o.Namespace,
|
||||
@@ -72,9 +107,24 @@ func (r *ResourcePermissionsAuthorizer) beforeWrite(ctx context.Context, obj run
|
||||
switch o := obj.(type) {
|
||||
case *iamv0.ResourcePermission:
|
||||
target := o.Spec.Resource
|
||||
targetGR := schema.GroupResource{Group: target.ApiGroup, Resource: target.Resource}
|
||||
|
||||
// TODO: Fetch the resource to retrieve its parent folder.
|
||||
parent := ""
|
||||
// Fetch the parent of the resource
|
||||
// Access Policies have global scope, so no parent check needed
|
||||
if !isAccessPolicy(authInfo) && r.parentProvider.HasParent(targetGR) {
|
||||
p, err := r.parentProvider.GetParent(ctx, targetGR, o.Namespace, target.Name)
|
||||
if err != nil {
|
||||
r.logger.Error("before write: error fetching parent", "error", err.Error(),
|
||||
"namespace", o.Namespace,
|
||||
"group", target.ApiGroup,
|
||||
"resource", target.Resource,
|
||||
"name", target.Name,
|
||||
)
|
||||
return err
|
||||
}
|
||||
parent = p
|
||||
}
|
||||
|
||||
checkReq := types.CheckRequest{
|
||||
Namespace: o.Namespace,
|
||||
@@ -153,8 +203,29 @@ func (r *ResourcePermissionsAuthorizer) FilterList(ctx context.Context, list run
|
||||
canViewFuncs[gr] = canView
|
||||
}
|
||||
|
||||
// TODO : Fetch the resource to retrieve its parent folder.
|
||||
target := item.Spec.Resource
|
||||
targetGR := schema.GroupResource{Group: target.ApiGroup, Resource: target.Resource}
|
||||
|
||||
parent := ""
|
||||
// Fetch the parent of the resource
|
||||
// It's not efficient to do for every item in the list, but it's a good starting point.
|
||||
// Access Policies have global scope, so no parent check needed
|
||||
if !isAccessPolicy(authInfo) && r.parentProvider.HasParent(targetGR) {
|
||||
p, err := r.parentProvider.GetParent(ctx, targetGR, item.Namespace, target.Name)
|
||||
if err != nil {
|
||||
// Skip item on error fetching parent
|
||||
r.logger.Warn("filter list: error fetching parent, skipping item",
|
||||
"error", err.Error(),
|
||||
"namespace",
|
||||
item.Namespace,
|
||||
"group", target.ApiGroup,
|
||||
"resource", target.Resource,
|
||||
"name", target.Name,
|
||||
)
|
||||
continue
|
||||
}
|
||||
parent = p
|
||||
}
|
||||
|
||||
allowed := canView(item.Spec.Resource.Name, parent)
|
||||
if allowed {
|
||||
|
||||
@@ -5,13 +5,15 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/go-jose/go-jose/v4/jwt"
|
||||
"github.com/stretchr/testify/require"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
|
||||
"github.com/grafana/authlib/authn"
|
||||
"github.com/grafana/authlib/types"
|
||||
iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/stretchr/testify/require"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -63,6 +65,7 @@ func TestResourcePermissions_AfterGet(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parent := "fold-1"
|
||||
checkFunc := func(id types.AuthInfo, req *types.CheckRequest, folder string) (types.CheckResponse, error) {
|
||||
require.NotNil(t, id)
|
||||
// Check is called with the user's identity
|
||||
@@ -74,12 +77,18 @@ func TestResourcePermissions_AfterGet(t *testing.T) {
|
||||
require.Equal(t, fold1.Spec.Resource.Resource, req.Resource)
|
||||
require.Equal(t, fold1.Spec.Resource.Name, req.Name)
|
||||
require.Equal(t, utils.VerbGetPermissions, req.Verb)
|
||||
require.Equal(t, parent, folder)
|
||||
|
||||
return types.CheckResponse{Allowed: tt.shouldAllow}, nil
|
||||
}
|
||||
getParentFunc := func(ctx context.Context, gr schema.GroupResource, namespace, name string) (string, error) {
|
||||
// For this test, we can return a fixed parent folder ID
|
||||
return parent, nil
|
||||
}
|
||||
|
||||
accessClient := &fakeAccessClient{checkFunc: checkFunc}
|
||||
resPermAuthz := NewResourcePermissionsAuthorizer(accessClient)
|
||||
fakeParentProvider := &fakeParentProvider{hasParent: true, getParentFunc: getParentFunc}
|
||||
resPermAuthz := NewResourcePermissionsAuthorizer(accessClient, fakeParentProvider)
|
||||
ctx := types.WithAuthInfo(context.Background(), user)
|
||||
|
||||
err := resPermAuthz.AfterGet(ctx, fold1)
|
||||
@@ -89,6 +98,7 @@ func TestResourcePermissions_AfterGet(t *testing.T) {
|
||||
require.Error(t, err, "expected error for denied access")
|
||||
}
|
||||
require.True(t, accessClient.checkCalled, "accessClient.Check should be called")
|
||||
require.True(t, fakeParentProvider.getParentCalled, "parentProvider.GetParent should be called")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -121,23 +131,32 @@ func TestResourcePermissions_FilterList(t *testing.T) {
|
||||
require.Equal(t, "dashboards", req.Resource)
|
||||
}
|
||||
|
||||
// Return a checker that allows only specific resources: fold-1 and dash-2
|
||||
// Return a checker that allows access to fold-1 and its content
|
||||
return func(name, folder string) bool {
|
||||
if name == "fold-1" || name == "dash-2" {
|
||||
if name == "fold-1" || folder == "fold-1" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}, &types.NoopZookie{}, nil
|
||||
}
|
||||
|
||||
getParentFunc := func(ctx context.Context, gr schema.GroupResource, namespace, name string) (string, error) {
|
||||
if name == "dash-2" {
|
||||
return "fold-1", nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
accessClient := &fakeAccessClient{compileFunc: compileFunc}
|
||||
resPermAuthz := NewResourcePermissionsAuthorizer(accessClient)
|
||||
fakeParentProvider := &fakeParentProvider{hasParent: true, getParentFunc: getParentFunc}
|
||||
resPermAuthz := NewResourcePermissionsAuthorizer(accessClient, fakeParentProvider)
|
||||
ctx := types.WithAuthInfo(context.Background(), user)
|
||||
|
||||
obj, err := resPermAuthz.FilterList(ctx, list)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, list)
|
||||
require.True(t, accessClient.compileCalled, "accessClient.Compile should be called")
|
||||
require.True(t, fakeParentProvider.getParentCalled, "parentProvider.GetParent should be called")
|
||||
|
||||
filtered, ok := obj.(*iamv0.ResourcePermissionList)
|
||||
require.True(t, ok, "response should be of type ResourcePermissionList")
|
||||
@@ -165,6 +184,7 @@ func TestResourcePermissions_beforeWrite(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parent := "fold-1"
|
||||
checkFunc := func(id types.AuthInfo, req *types.CheckRequest, folder string) (types.CheckResponse, error) {
|
||||
require.NotNil(t, id)
|
||||
// Check is called with the user's identity
|
||||
@@ -176,12 +196,18 @@ func TestResourcePermissions_beforeWrite(t *testing.T) {
|
||||
require.Equal(t, fold1.Spec.Resource.Resource, req.Resource)
|
||||
require.Equal(t, fold1.Spec.Resource.Name, req.Name)
|
||||
require.Equal(t, utils.VerbSetPermissions, req.Verb)
|
||||
require.Equal(t, parent, folder)
|
||||
|
||||
return types.CheckResponse{Allowed: tt.shouldAllow}, nil
|
||||
}
|
||||
|
||||
getParentFunc := func(ctx context.Context, gr schema.GroupResource, namespace, name string) (string, error) {
|
||||
return parent, nil
|
||||
}
|
||||
|
||||
accessClient := &fakeAccessClient{checkFunc: checkFunc}
|
||||
resPermAuthz := NewResourcePermissionsAuthorizer(accessClient)
|
||||
fakeParentProvider := &fakeParentProvider{hasParent: true, getParentFunc: getParentFunc}
|
||||
resPermAuthz := NewResourcePermissionsAuthorizer(accessClient, fakeParentProvider)
|
||||
ctx := types.WithAuthInfo(context.Background(), user)
|
||||
|
||||
err := resPermAuthz.beforeWrite(ctx, fold1)
|
||||
@@ -191,6 +217,7 @@ func TestResourcePermissions_beforeWrite(t *testing.T) {
|
||||
require.Error(t, err, "expected error for denied delete")
|
||||
}
|
||||
require.True(t, accessClient.checkCalled, "accessClient.Check should be called")
|
||||
require.True(t, fakeParentProvider.getParentCalled, "parentProvider.GetParent should be called")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -214,3 +241,18 @@ func (m *fakeAccessClient) Compile(ctx context.Context, id types.AuthInfo, req t
|
||||
}
|
||||
|
||||
var _ types.AccessClient = (*fakeAccessClient)(nil)
|
||||
|
||||
type fakeParentProvider struct {
|
||||
hasParent bool
|
||||
getParentCalled bool
|
||||
getParentFunc func(ctx context.Context, gr schema.GroupResource, namespace, name string) (string, error)
|
||||
}
|
||||
|
||||
func (f *fakeParentProvider) HasParent(gr schema.GroupResource) bool {
|
||||
return f.hasParent
|
||||
}
|
||||
|
||||
func (f *fakeParentProvider) GetParent(ctx context.Context, gr schema.GroupResource, namespace, name string) (string, error) {
|
||||
f.getParentCalled = true
|
||||
return f.getParentFunc(ctx, gr, namespace, name)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/grafana/authlib/types"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
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"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/iam/serviceaccount"
|
||||
@@ -60,6 +61,10 @@ type IdentityAccessManagementAPIBuilder struct {
|
||||
roleBindingsStorage RoleBindingStorageBackend
|
||||
externalGroupMappingStorage ExternalGroupMappingStorageBackend
|
||||
|
||||
// Required for resource permissions authorization
|
||||
// fetches resources parent folders
|
||||
resourceParentProvider iamauthorizer.ParentProvider
|
||||
|
||||
// Access Control
|
||||
authorizer authorizer.Authorizer
|
||||
// legacyAccessClient is used for the identity apis, we need to migrate to the access client
|
||||
@@ -77,10 +82,11 @@ type IdentityAccessManagementAPIBuilder struct {
|
||||
reg prometheus.Registerer
|
||||
logger log.Logger
|
||||
|
||||
dual dualwrite.Service
|
||||
unified resource.ResourceClient
|
||||
userSearchClient resourcepb.ResourceIndexClient
|
||||
teamSearch *TeamSearchHandler
|
||||
dual dualwrite.Service
|
||||
unified resource.ResourceClient
|
||||
userSearchClient resourcepb.ResourceIndexClient
|
||||
userSearchHandler *user.SearchHandler
|
||||
teamSearch *TeamSearchHandler
|
||||
|
||||
teamGroupsHandler externalgroupmapping.TeamGroupsHandler
|
||||
|
||||
|
||||
@@ -41,11 +41,13 @@ import (
|
||||
"github.com/grafana/grafana/pkg/registry/apis/iam/teambinding"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/iam/user"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver"
|
||||
gfauthorizer "github.com/grafana/grafana/pkg/services/apiserver/auth/authorizer"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/auth/authorizer/storewrapper"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/builder"
|
||||
"github.com/grafana/grafana/pkg/services/authz/zanzana"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
"github.com/grafana/grafana/pkg/services/ssosettings"
|
||||
teamservice "github.com/grafana/grafana/pkg/services/team"
|
||||
legacyuser "github.com/grafana/grafana/pkg/services/user"
|
||||
@@ -76,8 +78,10 @@ func RegisterAPIService(
|
||||
teamGroupsHandlerImpl externalgroupmapping.TeamGroupsHandler,
|
||||
dual dualwrite.Service,
|
||||
unified resource.ResourceClient,
|
||||
orgService org.Service,
|
||||
userService legacyuser.Service,
|
||||
teamService teamservice.Service,
|
||||
restConfig apiserver.RestConfigProvider,
|
||||
) (*IdentityAccessManagementAPIBuilder, error) {
|
||||
dbProvider := legacysql.NewDatabaseProvider(sql)
|
||||
store := legacy.NewLegacySQLStores(dbProvider)
|
||||
@@ -88,6 +92,11 @@ func RegisterAPIService(
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
enableAuthnMutation := features.IsEnabledGlobally(featuremgmt.FlagKubernetesAuthnMutation)
|
||||
|
||||
resourceParentProvider := iamauthorizer.NewApiParentProvider(
|
||||
iamauthorizer.NewLocalConfigProvider(restConfig.GetRestConfig),
|
||||
iamauthorizer.Versions,
|
||||
)
|
||||
|
||||
builder := &IdentityAccessManagementAPIBuilder{
|
||||
store: store,
|
||||
userLegacyStore: user.NewLegacyStore(store, accessClient, enableAuthnMutation, tracing),
|
||||
@@ -102,6 +111,7 @@ func RegisterAPIService(
|
||||
externalGroupMappingStorage: externalGroupMappingStorageBackend,
|
||||
teamGroupsHandler: teamGroupsHandlerImpl,
|
||||
sso: ssoService,
|
||||
resourceParentProvider: resourceParentProvider,
|
||||
authorizer: authorizer,
|
||||
legacyAccessClient: legacyAccessClient,
|
||||
accessClient: accessClient,
|
||||
@@ -114,9 +124,11 @@ func RegisterAPIService(
|
||||
dual: dual,
|
||||
unified: unified,
|
||||
userSearchClient: resource.NewSearchClient(dualwrite.NewSearchAdapter(dual), iamv0.UserResourceInfo.GroupResource(),
|
||||
unified, user.NewUserLegacySearchClient(userService, tracing), features),
|
||||
unified, user.NewUserLegacySearchClient(orgService, tracing, cfg), features),
|
||||
teamSearch: NewTeamSearchHandler(tracing, dual, team.NewLegacyTeamSearchClient(teamService), unified, features),
|
||||
}
|
||||
builder.userSearchHandler = user.NewSearchHandler(tracing, builder.userSearchClient, features, cfg)
|
||||
|
||||
apiregistration.RegisterAPI(builder)
|
||||
|
||||
return builder, nil
|
||||
@@ -138,6 +150,12 @@ func NewAPIService(
|
||||
resourceAuthorizer := gfauthorizer.NewResourceAuthorizer(accessClient)
|
||||
coreRoleAuthorizer := iamauthorizer.NewCoreRoleAuthorizer(accessClient)
|
||||
|
||||
// TODO: in a follow up PR, make this configurable
|
||||
resourceParentProvider := iamauthorizer.NewApiParentProvider(
|
||||
iamauthorizer.NewRemoteConfigProvider(map[schema.GroupResource]iamauthorizer.DialConfig{}, nil),
|
||||
iamauthorizer.Versions,
|
||||
)
|
||||
|
||||
return &IdentityAccessManagementAPIBuilder{
|
||||
store: store,
|
||||
display: user.NewLegacyDisplayREST(store),
|
||||
@@ -148,6 +166,7 @@ func NewAPIService(
|
||||
logger: log.New("iam.apis"),
|
||||
features: features,
|
||||
accessClient: accessClient,
|
||||
resourceParentProvider: resourceParentProvider,
|
||||
zClient: zClient,
|
||||
zTickets: make(chan bool, MaxConcurrentZanzanaWrites),
|
||||
reg: reg,
|
||||
@@ -440,7 +459,7 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateResourcePermissionsAPIGroup(
|
||||
return fmt.Errorf("expected RegistryStoreDualWrite, got %T", dw)
|
||||
}
|
||||
|
||||
authzWrapper := storewrapper.New(regStoreDW, iamauthorizer.NewResourcePermissionsAuthorizer(b.accessClient))
|
||||
authzWrapper := storewrapper.New(regStoreDW, iamauthorizer.NewResourcePermissionsAuthorizer(b.accessClient, b.resourceParentProvider))
|
||||
|
||||
storage[iamv0.ResourcePermissionInfo.StoragePath()] = authzWrapper
|
||||
return nil
|
||||
@@ -510,10 +529,18 @@ 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{} })
|
||||
|
||||
routes := b.teamSearch.GetAPIRoutes(defs)
|
||||
routes.Namespace = append(routes.Namespace, b.display.GetAPIRoutes(defs).Namespace...)
|
||||
searchRoutes := make([]*builder.APIRoutes, 0, 2)
|
||||
if b.userSearchHandler != nil {
|
||||
searchRoutes = append(searchRoutes, b.userSearchHandler.GetAPIRoutes(defs))
|
||||
}
|
||||
|
||||
return routes
|
||||
if b.teamSearch != nil {
|
||||
searchRoutes = append(searchRoutes, b.teamSearch.GetAPIRoutes(defs))
|
||||
}
|
||||
|
||||
routes := []*builder.APIRoutes{b.display.GetAPIRoutes(defs)}
|
||||
routes = append(routes, searchRoutes...)
|
||||
return mergeAPIRoutes(routes...)
|
||||
}
|
||||
|
||||
func (b *IdentityAccessManagementAPIBuilder) GetAuthorizer() authorizer.Authorizer {
|
||||
@@ -621,3 +648,15 @@ func NewLocalStore(resourceInfo utils.ResourceInfo, scheme *runtime.Scheme, defa
|
||||
store, err := grafanaregistry.NewRegistryStore(scheme, resourceInfo, optsGetter)
|
||||
return store, err
|
||||
}
|
||||
|
||||
func mergeAPIRoutes(routes ...*builder.APIRoutes) *builder.APIRoutes {
|
||||
merged := &builder.APIRoutes{}
|
||||
for _, r := range routes {
|
||||
if r == nil {
|
||||
continue
|
||||
}
|
||||
merged.Root = append(merged.Root, r.Root...)
|
||||
merged.Namespace = append(merged.Namespace, r.Namespace...)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
@@ -2,16 +2,22 @@ package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"regexp"
|
||||
"sort"
|
||||
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
res "github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
"github.com/grafana/grafana/pkg/services/search/model"
|
||||
"github.com/grafana/grafana/pkg/services/searchusers/sortopts"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"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"
|
||||
)
|
||||
@@ -21,28 +27,36 @@ const (
|
||||
UserResourceGroup = "iam.grafana.com"
|
||||
)
|
||||
|
||||
var _ resourcepb.ResourceIndexClient = (*UserLegacySearchClient)(nil)
|
||||
var (
|
||||
_ resourcepb.ResourceIndexClient = (*UserLegacySearchClient)(nil)
|
||||
fieldLogin = fmt.Sprintf("%s%s", resource.SEARCH_FIELD_PREFIX, builders.USER_LOGIN)
|
||||
fieldEmail = fmt.Sprintf("%s%s", resource.SEARCH_FIELD_PREFIX, builders.USER_EMAIL)
|
||||
fieldLastSeenAt = fmt.Sprintf("%s%s", resource.SEARCH_FIELD_PREFIX, builders.USER_LAST_SEEN_AT)
|
||||
fieldRole = fmt.Sprintf("%s%s", resource.SEARCH_FIELD_PREFIX, builders.USER_ROLE)
|
||||
wildcardsMatcher = regexp.MustCompile(`[\*\?\\]`)
|
||||
)
|
||||
|
||||
// UserLegacySearchClient is a client for searching for users in the legacy search engine.
|
||||
type UserLegacySearchClient struct {
|
||||
resourcepb.ResourceIndexClient
|
||||
userService user.Service
|
||||
log *slog.Logger
|
||||
tracer trace.Tracer
|
||||
orgService org.Service
|
||||
log *slog.Logger
|
||||
tracer trace.Tracer
|
||||
cfg *setting.Cfg
|
||||
}
|
||||
|
||||
// NewUserLegacySearchClient creates a new UserLegacySearchClient.
|
||||
func NewUserLegacySearchClient(userService user.Service, tracer trace.Tracer) *UserLegacySearchClient {
|
||||
func NewUserLegacySearchClient(orgService org.Service, tracer trace.Tracer, cfg *setting.Cfg) *UserLegacySearchClient {
|
||||
return &UserLegacySearchClient{
|
||||
userService: userService,
|
||||
log: slog.Default().With("logger", "legacy-user-search-client"),
|
||||
tracer: tracer,
|
||||
orgService: orgService,
|
||||
log: slog.Default().With("logger", "legacy-user-search-client"),
|
||||
tracer: tracer,
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
// Search searches for users in the legacy search engine.
|
||||
// It only supports exact matching for title, login, or email.
|
||||
// FIXME: This implementation only supports a single field query and will be extended in the future.
|
||||
func (c *UserLegacySearchClient) Search(ctx context.Context, req *resourcepb.ResourceSearchRequest, _ ...grpc.CallOption) (*resourcepb.ResourceSearchResponse, error) {
|
||||
ctx, span := c.tracer.Start(ctx, "user.Search")
|
||||
defer span.End()
|
||||
@@ -52,21 +66,30 @@ func (c *UserLegacySearchClient) Search(ctx context.Context, req *resourcepb.Res
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if req.Limit > 100 {
|
||||
req.Limit = 100
|
||||
if req.Limit > maxLimit {
|
||||
req.Limit = maxLimit
|
||||
}
|
||||
if req.Limit <= 0 {
|
||||
req.Limit = 1
|
||||
req.Limit = 30
|
||||
}
|
||||
|
||||
if req.Page > math.MaxInt32 || req.Page < 0 {
|
||||
return nil, fmt.Errorf("invalid page number: %d", req.Page)
|
||||
}
|
||||
|
||||
query := &user.SearchUsersQuery{
|
||||
SignedInUser: signedInUser,
|
||||
Limit: int(req.Limit),
|
||||
Page: int(req.Page),
|
||||
if req.Page < 1 {
|
||||
req.Page = 1
|
||||
}
|
||||
|
||||
legacySortOptions := convertToSortOptions(req.SortBy)
|
||||
|
||||
query := &org.SearchOrgUsersQuery{
|
||||
OrgID: signedInUser.GetOrgID(),
|
||||
Limit: int(req.Limit),
|
||||
Page: int(req.Page),
|
||||
SortOpts: legacySortOptions,
|
||||
|
||||
User: signedInUser,
|
||||
}
|
||||
|
||||
var title, login, email string
|
||||
@@ -76,19 +99,15 @@ func (c *UserLegacySearchClient) Search(ctx context.Context, req *resourcepb.Res
|
||||
c.log.Warn("only single value fields are supported for legacy search, using first value", "field", field.Key, "values", vals)
|
||||
}
|
||||
switch field.Key {
|
||||
case res.SEARCH_FIELD_TITLE:
|
||||
case resource.SEARCH_FIELD_TITLE:
|
||||
title = vals[0]
|
||||
case "fields.login":
|
||||
case fieldLogin:
|
||||
login = vals[0]
|
||||
case "fields.email":
|
||||
case fieldEmail:
|
||||
email = vals[0]
|
||||
}
|
||||
}
|
||||
|
||||
if title == "" && login == "" && email == "" {
|
||||
return nil, fmt.Errorf("at least one of title, login, or email must be provided for the query")
|
||||
}
|
||||
|
||||
// The user store's Search method combines these into an OR.
|
||||
// For legacy search we can only supply one.
|
||||
if title != "" {
|
||||
@@ -99,20 +118,35 @@ func (c *UserLegacySearchClient) Search(ctx context.Context, req *resourcepb.Res
|
||||
query.Query = email
|
||||
}
|
||||
|
||||
columns := getColumns(req.Fields)
|
||||
// Unified search `query` has wildcards, but legacy search does not support them.
|
||||
// We have to remove them here to make legacy search work as expected with SQL LIKE queries.
|
||||
if req.Query != "" {
|
||||
query.Query = wildcardsMatcher.ReplaceAllString(req.Query, "")
|
||||
}
|
||||
|
||||
fields := req.Fields
|
||||
if len(fields) == 0 {
|
||||
fields = []string{resource.SEARCH_FIELD_TITLE, fieldEmail, fieldLogin, fieldLastSeenAt, fieldRole}
|
||||
}
|
||||
|
||||
columns := getColumns(fields)
|
||||
list := &resourcepb.ResourceSearchResponse{
|
||||
Results: &resourcepb.ResourceTable{
|
||||
Columns: columns,
|
||||
},
|
||||
}
|
||||
|
||||
res, err := c.userService.Search(ctx, query)
|
||||
res, err := c.orgService.SearchOrgUsers(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, u := range res.Users {
|
||||
cells := createBaseCells(u, req.Fields)
|
||||
for _, u := range res.OrgUsers {
|
||||
if c.isHiddenUser(u.Login, signedInUser) {
|
||||
continue
|
||||
}
|
||||
|
||||
cells := createCells(u, req.Fields)
|
||||
list.Results.Rows = append(list.Results.Rows, &resourcepb.ResourceTableRow{
|
||||
Key: getResourceKey(u, req.Options.Key.Namespace),
|
||||
Cells: cells,
|
||||
@@ -123,7 +157,19 @@ func (c *UserLegacySearchClient) Search(ctx context.Context, req *resourcepb.Res
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func getResourceKey(item *user.UserSearchHitDTO, namespace string) *resourcepb.ResourceKey {
|
||||
func (c *UserLegacySearchClient) isHiddenUser(login string, signedInUser identity.Requester) bool {
|
||||
if login == "" || signedInUser.GetIsGrafanaAdmin() || login == signedInUser.GetUsername() {
|
||||
return false
|
||||
}
|
||||
|
||||
if _, hidden := c.cfg.HiddenUsers[login]; hidden {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func getResourceKey(item *org.OrgUserDTO, namespace string) *resourcepb.ResourceKey {
|
||||
return &resourcepb.ResourceKey{
|
||||
Namespace: namespace,
|
||||
Group: UserResourceGroup,
|
||||
@@ -133,42 +179,74 @@ func getResourceKey(item *user.UserSearchHitDTO, namespace string) *resourcepb.R
|
||||
}
|
||||
|
||||
func getColumns(fields []string) []*resourcepb.ResourceTableColumnDefinition {
|
||||
columns := defaultColumns()
|
||||
cols := make([]*resourcepb.ResourceTableColumnDefinition, 0, len(fields))
|
||||
standardSearchFields := resource.StandardSearchFields()
|
||||
for _, field := range fields {
|
||||
switch field {
|
||||
case "email":
|
||||
columns = append(columns, builders.UserTableColumnDefinitions[builders.USER_EMAIL])
|
||||
case "login":
|
||||
columns = append(columns, builders.UserTableColumnDefinitions[builders.USER_LOGIN])
|
||||
case resource.SEARCH_FIELD_TITLE:
|
||||
cols = append(cols, standardSearchFields.Field(resource.SEARCH_FIELD_TITLE))
|
||||
case fieldLastSeenAt:
|
||||
cols = append(cols, builders.UserTableColumnDefinitions[builders.USER_LAST_SEEN_AT])
|
||||
case fieldRole:
|
||||
cols = append(cols, builders.UserTableColumnDefinitions[builders.USER_ROLE])
|
||||
case fieldEmail:
|
||||
cols = append(cols, builders.UserTableColumnDefinitions[builders.USER_EMAIL])
|
||||
case fieldLogin:
|
||||
cols = append(cols, builders.UserTableColumnDefinitions[builders.USER_LOGIN])
|
||||
}
|
||||
}
|
||||
return columns
|
||||
return cols
|
||||
}
|
||||
|
||||
func createBaseCells(u *user.UserSearchHitDTO, fields []string) [][]byte {
|
||||
cells := createDefaultCells(u)
|
||||
func createCells(u *org.OrgUserDTO, fields []string) [][]byte {
|
||||
cells := make([][]byte, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
switch field {
|
||||
case "email":
|
||||
case resource.SEARCH_FIELD_TITLE:
|
||||
cells = append(cells, []byte(u.Name))
|
||||
case fieldEmail:
|
||||
cells = append(cells, []byte(u.Email))
|
||||
case "login":
|
||||
case fieldLogin:
|
||||
cells = append(cells, []byte(u.Login))
|
||||
case fieldLastSeenAt:
|
||||
b := make([]byte, 8)
|
||||
binary.BigEndian.PutUint64(b, uint64(u.LastSeenAt.Unix()))
|
||||
cells = append(cells, b)
|
||||
case fieldRole:
|
||||
cells = append(cells, []byte(u.Role))
|
||||
}
|
||||
}
|
||||
return cells
|
||||
}
|
||||
|
||||
func createDefaultCells(u *user.UserSearchHitDTO) [][]byte {
|
||||
return [][]byte{
|
||||
[]byte(u.UID),
|
||||
[]byte(u.Name),
|
||||
}
|
||||
}
|
||||
func convertToSortOptions(sortBy []*resourcepb.ResourceSearchRequest_Sort) []model.SortOption {
|
||||
opts := []model.SortOption{}
|
||||
for _, s := range sortBy {
|
||||
field := s.Field
|
||||
// Handle mapping if necessary
|
||||
switch field {
|
||||
case fieldLastSeenAt:
|
||||
field = "lastSeenAtAge"
|
||||
case resource.SEARCH_FIELD_TITLE:
|
||||
field = "name"
|
||||
case fieldLogin:
|
||||
field = "login"
|
||||
case fieldEmail:
|
||||
field = "email"
|
||||
}
|
||||
|
||||
func defaultColumns() []*resourcepb.ResourceTableColumnDefinition {
|
||||
searchFields := res.StandardSearchFields()
|
||||
return []*resourcepb.ResourceTableColumnDefinition{
|
||||
searchFields.Field(res.SEARCH_FIELD_NAME),
|
||||
searchFields.Field(res.SEARCH_FIELD_TITLE),
|
||||
suffix := "asc"
|
||||
if s.Desc {
|
||||
suffix = "desc"
|
||||
}
|
||||
key := fmt.Sprintf("%s-%s", field, suffix)
|
||||
|
||||
if opt, ok := sortopts.SortOptionsByQueryParam[key]; ok {
|
||||
opts = append(opts, opt)
|
||||
}
|
||||
}
|
||||
sort.Slice(opts, func(i, j int) bool {
|
||||
return opts[i].Index < opts[j].Index || (opts[i].Index == opts[j].Index && opts[i].Name < opts[j].Name)
|
||||
})
|
||||
return opts
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
type FakeUserLegacySearchClient struct {
|
||||
resourcepb.ResourceIndexClient
|
||||
SearchFunc func(ctx context.Context, req *resourcepb.ResourceSearchRequest, opts ...grpc.CallOption) (*resourcepb.ResourceSearchResponse, error)
|
||||
Users []*user.UserSearchHitDTO
|
||||
Users []*org.OrgUserDTO
|
||||
}
|
||||
|
||||
// Search calls the underlying SearchFunc or simulates a search over the Users slice.
|
||||
@@ -23,7 +23,7 @@ func (c *FakeUserLegacySearchClient) Search(ctx context.Context, req *resourcepb
|
||||
}
|
||||
|
||||
// Basic filtering for testing purposes
|
||||
var filteredUsers []*user.UserSearchHitDTO
|
||||
var filteredUsers []*org.OrgUserDTO
|
||||
var queryValue string
|
||||
|
||||
for _, field := range req.Options.Fields {
|
||||
@@ -43,7 +43,7 @@ func (c *FakeUserLegacySearchClient) Search(ctx context.Context, req *resourcepb
|
||||
for _, u := range filteredUsers {
|
||||
rows = append(rows, &resourcepb.ResourceTableRow{
|
||||
Key: getResourceKey(u, req.Options.Key.Namespace),
|
||||
Cells: createBaseCells(u, req.Fields),
|
||||
Cells: createCells(u, req.Fields),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -9,29 +9,15 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
"github.com/grafana/grafana/pkg/services/org/orgtest"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"github.com/grafana/grafana/pkg/services/user/usertest"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
res "github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
|
||||
)
|
||||
|
||||
func TestUserLegacySearchClient_Search(t *testing.T) {
|
||||
t.Run("should return error if no query fields are provided", func(t *testing.T) {
|
||||
mockUserService := usertest.NewMockService(t)
|
||||
client := NewUserLegacySearchClient(mockUserService, tracing.NewNoopTracerService())
|
||||
ctx := identity.WithRequester(context.Background(), &user.SignedInUser{OrgID: 1, UserID: 1})
|
||||
req := &resourcepb.ResourceSearchRequest{
|
||||
Options: &resourcepb.ListOptions{
|
||||
Key: &resourcepb.ResourceKey{Namespace: "default"},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := client.Search(ctx, req)
|
||||
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "at least one of title, login, or email must be provided for the query", err.Error())
|
||||
})
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
fieldKey string
|
||||
@@ -66,8 +52,8 @@ func TestUserLegacySearchClient_Search(t *testing.T) {
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
mockUserService := usertest.NewMockService(t)
|
||||
client := NewUserLegacySearchClient(mockUserService, tracing.NewNoopTracerService())
|
||||
mockOrgService := orgtest.NewMockService(t)
|
||||
client := NewUserLegacySearchClient(mockOrgService, tracing.NewNoopTracerService(), &setting.Cfg{})
|
||||
ctx := identity.WithRequester(context.Background(), &user.SignedInUser{OrgID: 1, UserID: 1})
|
||||
req := &resourcepb.ResourceSearchRequest{
|
||||
Limit: 10,
|
||||
@@ -81,14 +67,14 @@ func TestUserLegacySearchClient_Search(t *testing.T) {
|
||||
Fields: []string{"email", "login"},
|
||||
}
|
||||
|
||||
mockUsers := []*user.UserSearchHitDTO{
|
||||
{ID: 1, UID: "uid1", Name: "Test User 1", Email: "test1@example.com", Login: "testlogin1"},
|
||||
mockUsers := []*org.OrgUserDTO{
|
||||
{UID: "uid1", Name: "Test User 1", Email: "test1@example.com", Login: "testlogin1"},
|
||||
}
|
||||
|
||||
mockUserService.On("Search", mock.Anything, mock.MatchedBy(func(q *user.SearchUsersQuery) bool {
|
||||
mockOrgService.On("SearchOrgUsers", mock.Anything, mock.MatchedBy(func(q *org.SearchOrgUsersQuery) bool {
|
||||
return q.Query == tc.expectedQuery && q.Limit == 10 && q.Page == 1
|
||||
})).Return(&user.SearchUserQueryResult{
|
||||
Users: mockUsers,
|
||||
})).Return(&org.SearchOrgUsersQueryResult{
|
||||
OrgUsers: mockUsers,
|
||||
TotalCount: 1,
|
||||
}, nil)
|
||||
|
||||
@@ -113,7 +99,7 @@ func TestUserLegacySearchClient_Search(t *testing.T) {
|
||||
require.Equal(t, UserResource, row.Key.Resource)
|
||||
require.Equal(t, u.UID, row.Key.Name)
|
||||
|
||||
expectedCells := createBaseCells(&user.UserSearchHitDTO{
|
||||
expectedCells := createCells(&org.OrgUserDTO{
|
||||
UID: u.UID,
|
||||
Name: u.Name,
|
||||
Email: u.Email,
|
||||
@@ -125,8 +111,8 @@ func TestUserLegacySearchClient_Search(t *testing.T) {
|
||||
}
|
||||
|
||||
t.Run("title should have precedence over login and email", func(t *testing.T) {
|
||||
mockUserService := usertest.NewMockService(t)
|
||||
client := NewUserLegacySearchClient(mockUserService, tracing.NewNoopTracerService())
|
||||
mockOrgService := orgtest.NewMockService(t)
|
||||
client := NewUserLegacySearchClient(mockOrgService, tracing.NewNoopTracerService(), &setting.Cfg{})
|
||||
ctx := identity.WithRequester(context.Background(), &user.SignedInUser{OrgID: 1, UserID: 1})
|
||||
req := &resourcepb.ResourceSearchRequest{
|
||||
Options: &resourcepb.ListOptions{
|
||||
@@ -139,9 +125,9 @@ func TestUserLegacySearchClient_Search(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
mockUserService.On("Search", mock.Anything, mock.MatchedBy(func(q *user.SearchUsersQuery) bool {
|
||||
mockOrgService.On("SearchOrgUsers", mock.Anything, mock.MatchedBy(func(q *org.SearchOrgUsersQuery) bool {
|
||||
return q.Query == "title"
|
||||
})).Return(&user.SearchUserQueryResult{Users: []*user.UserSearchHitDTO{}, TotalCount: 0}, nil)
|
||||
})).Return(&org.SearchOrgUsersQueryResult{OrgUsers: []*org.OrgUserDTO{}, TotalCount: 0}, nil)
|
||||
|
||||
_, err := client.Search(ctx, req)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"k8s.io/apimachinery/pkg/selection"
|
||||
"k8s.io/kube-openapi/pkg/common"
|
||||
"k8s.io/kube-openapi/pkg/spec3"
|
||||
"k8s.io/kube-openapi/pkg/validation/spec"
|
||||
|
||||
iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/builder"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"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"
|
||||
"github.com/grafana/grafana/pkg/util/errhttp"
|
||||
)
|
||||
|
||||
const maxLimit = 100
|
||||
|
||||
type SearchHandler struct {
|
||||
log *slog.Logger
|
||||
client resourcepb.ResourceIndexClient
|
||||
tracer trace.Tracer
|
||||
features featuremgmt.FeatureToggles
|
||||
cfg *setting.Cfg
|
||||
}
|
||||
|
||||
func NewSearchHandler(tracer trace.Tracer, searchClient resourcepb.ResourceIndexClient, features featuremgmt.FeatureToggles, cfg *setting.Cfg) *SearchHandler {
|
||||
return &SearchHandler{
|
||||
client: searchClient,
|
||||
log: slog.Default().With("logger", "grafana-apiserver.user.search"),
|
||||
tracer: tracer,
|
||||
features: features,
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SearchHandler) GetAPIRoutes(defs map[string]common.OpenAPIDefinition) *builder.APIRoutes {
|
||||
searchResults := defs["github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GetSearchUsers"].Schema
|
||||
return &builder.APIRoutes{
|
||||
Namespace: []builder.APIRouteHandler{
|
||||
{
|
||||
Path: "searchUsers",
|
||||
Spec: &spec3.PathProps{
|
||||
Get: &spec3.Operation{
|
||||
OperationProps: spec3.OperationProps{
|
||||
Description: "User search",
|
||||
Tags: []string{"Search"},
|
||||
OperationId: "getSearchUsers",
|
||||
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",
|
||||
Required: false,
|
||||
Schema: spec.StringProperty(),
|
||||
},
|
||||
},
|
||||
{
|
||||
ParameterProps: spec3.ParameterProps{
|
||||
Name: "limit",
|
||||
In: "query",
|
||||
Description: "number of results to return",
|
||||
Example: 30,
|
||||
Required: false,
|
||||
Schema: spec.Int64Property(),
|
||||
},
|
||||
},
|
||||
{
|
||||
ParameterProps: spec3.ParameterProps{
|
||||
Name: "page",
|
||||
In: "query",
|
||||
Description: "page number (starting from 1)",
|
||||
Example: 1,
|
||||
Required: false,
|
||||
Schema: spec.Int64Property(),
|
||||
},
|
||||
},
|
||||
{
|
||||
ParameterProps: spec3.ParameterProps{
|
||||
Name: "offset",
|
||||
In: "query",
|
||||
Description: "number of results to skip",
|
||||
Example: 0,
|
||||
Required: false,
|
||||
Schema: spec.Int64Property(),
|
||||
},
|
||||
},
|
||||
{
|
||||
ParameterProps: spec3.ParameterProps{
|
||||
Name: "sort",
|
||||
In: "query",
|
||||
Description: "sortable field",
|
||||
Example: "",
|
||||
Examples: map[string]*spec3.Example{
|
||||
"": {
|
||||
ExampleProps: spec3.ExampleProps{
|
||||
Summary: "default sorting",
|
||||
Value: "",
|
||||
},
|
||||
},
|
||||
"title": {
|
||||
ExampleProps: spec3.ExampleProps{
|
||||
Summary: "title ascending",
|
||||
Value: "title",
|
||||
},
|
||||
},
|
||||
"-title": {
|
||||
ExampleProps: spec3.ExampleProps{
|
||||
Summary: "title descending",
|
||||
Value: "-title",
|
||||
},
|
||||
},
|
||||
"lastSeenAt": {
|
||||
ExampleProps: spec3.ExampleProps{
|
||||
Summary: "last seen at ascending",
|
||||
Value: "lastSeenAt",
|
||||
},
|
||||
},
|
||||
"-lastSeenAt": {
|
||||
ExampleProps: spec3.ExampleProps{
|
||||
Summary: "last seen at descending",
|
||||
Value: "-lastSeenAt",
|
||||
},
|
||||
},
|
||||
"email": {
|
||||
ExampleProps: spec3.ExampleProps{
|
||||
Summary: "email ascending",
|
||||
Value: "email",
|
||||
},
|
||||
},
|
||||
"-email": {
|
||||
ExampleProps: spec3.ExampleProps{
|
||||
Summary: "email descending",
|
||||
Value: "-email",
|
||||
},
|
||||
},
|
||||
"login": {
|
||||
ExampleProps: spec3.ExampleProps{
|
||||
Summary: "login ascending",
|
||||
Value: "login",
|
||||
},
|
||||
},
|
||||
"-login": {
|
||||
ExampleProps: spec3.ExampleProps{
|
||||
Summary: "login descending",
|
||||
Value: "-login",
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: false,
|
||||
Schema: spec.StringProperty(),
|
||||
},
|
||||
},
|
||||
},
|
||||
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: &searchResults,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Handler: s.DoSearch,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, span := s.tracer.Start(r.Context(), "user.search")
|
||||
defer span.End()
|
||||
|
||||
queryParams, err := url.ParseQuery(r.URL.RawQuery)
|
||||
if err != nil {
|
||||
errhttp.Write(ctx, err, w)
|
||||
return
|
||||
}
|
||||
|
||||
requester, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
errhttp.Write(ctx, fmt.Errorf("no identity found for request: %w", err), w)
|
||||
return
|
||||
}
|
||||
|
||||
limit := 30
|
||||
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 && limit > 0 {
|
||||
page = (offset / limit) + 1
|
||||
}
|
||||
} else if queryParams.Has("page") {
|
||||
page, _ = strconv.Atoi(queryParams.Get("page"))
|
||||
offset = (page - 1) * limit
|
||||
}
|
||||
|
||||
// Escape characters that are used by bleve wildcard search to be literal strings.
|
||||
rawQuery := escapeBleveQuery(queryParams.Get("query"))
|
||||
|
||||
searchQuery := fmt.Sprintf(`*%s*`, rawQuery)
|
||||
|
||||
userGvr := iamv0.UserResourceInfo.GroupResource()
|
||||
request := &resourcepb.ResourceSearchRequest{
|
||||
Options: &resourcepb.ListOptions{
|
||||
Key: &resourcepb.ResourceKey{
|
||||
Group: userGvr.Group,
|
||||
Resource: userGvr.Resource,
|
||||
Namespace: requester.GetNamespace(),
|
||||
},
|
||||
},
|
||||
Query: searchQuery,
|
||||
Fields: []string{resource.SEARCH_FIELD_TITLE, fieldEmail, fieldLogin, fieldLastSeenAt, fieldRole},
|
||||
Limit: int64(limit),
|
||||
Page: int64(page),
|
||||
Offset: int64(offset),
|
||||
}
|
||||
|
||||
if !requester.GetIsGrafanaAdmin() {
|
||||
// FIXME: Use the new config service instead of the legacy one
|
||||
hiddenUsers := []string{}
|
||||
for user := range s.cfg.HiddenUsers {
|
||||
if user != requester.GetUsername() {
|
||||
hiddenUsers = append(hiddenUsers, user)
|
||||
}
|
||||
}
|
||||
if len(hiddenUsers) > 0 {
|
||||
request.Options.Fields = append(request.Options.Fields, &resourcepb.Requirement{
|
||||
Key: fieldLogin,
|
||||
Operator: string(selection.NotIn),
|
||||
Values: hiddenUsers,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if queryParams.Has("sort") {
|
||||
for _, sort := range queryParams["sort"] {
|
||||
currField := sort
|
||||
desc := false
|
||||
if strings.HasPrefix(sort, "-") {
|
||||
currField = sort[1:]
|
||||
desc = true
|
||||
}
|
||||
if slices.Contains(builders.UserSortableExtraFields, currField) {
|
||||
sort = resource.SEARCH_FIELD_PREFIX + currField
|
||||
} else {
|
||||
sort = currField
|
||||
}
|
||||
s := &resourcepb.ResourceSearchRequest_Sort{
|
||||
Field: sort,
|
||||
Desc: desc,
|
||||
}
|
||||
request.SortBy = append(request.SortBy, s)
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := s.client.Search(ctx, request)
|
||||
if err != nil {
|
||||
errhttp.Write(ctx, err, w)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := ParseResults(resp)
|
||||
if err != nil {
|
||||
errhttp.Write(ctx, err, w)
|
||||
return
|
||||
}
|
||||
s.write(w, result)
|
||||
}
|
||||
|
||||
func (s *SearchHandler) write(w http.ResponseWriter, obj any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(obj); err != nil {
|
||||
s.log.Error("failed to encode JSON response", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func ParseResults(result *resourcepb.ResourceSearchResponse) (*iamv0.GetSearchUsers, error) {
|
||||
if result == nil {
|
||||
return iamv0.NewGetSearchUsers(), nil
|
||||
} else if result.Error != nil {
|
||||
return iamv0.NewGetSearchUsers(), fmt.Errorf("%d error searching: %s: %s", result.Error.Code, result.Error.Message, result.Error.Details)
|
||||
} else if result.Results == nil {
|
||||
return iamv0.NewGetSearchUsers(), nil
|
||||
}
|
||||
|
||||
titleIDX := -1
|
||||
emailIDX := -1
|
||||
loginIDX := -1
|
||||
lastSeenAtIDX := -1
|
||||
roleIDX := -1
|
||||
|
||||
for i, v := range result.Results.Columns {
|
||||
switch v.Name {
|
||||
case resource.SEARCH_FIELD_TITLE:
|
||||
titleIDX = i
|
||||
case builders.USER_EMAIL:
|
||||
emailIDX = i
|
||||
case builders.USER_LOGIN:
|
||||
loginIDX = i
|
||||
case builders.USER_LAST_SEEN_AT:
|
||||
lastSeenAtIDX = i
|
||||
case builders.USER_ROLE:
|
||||
roleIDX = i
|
||||
}
|
||||
}
|
||||
|
||||
sr := iamv0.NewGetSearchUsers()
|
||||
sr.TotalHits = result.TotalHits
|
||||
sr.QueryCost = result.QueryCost
|
||||
sr.MaxScore = result.MaxScore
|
||||
sr.Hits = make([]iamv0.UserHit, 0, len(result.Results.Rows))
|
||||
|
||||
for _, row := range result.Results.Rows {
|
||||
if len(row.Cells) != len(result.Results.Columns) {
|
||||
return iamv0.NewGetSearchUsers(), fmt.Errorf("error parsing user search response: mismatch number of columns and cells")
|
||||
}
|
||||
|
||||
var login string
|
||||
if loginIDX >= 0 && row.Cells[loginIDX] != nil {
|
||||
login = string(row.Cells[loginIDX])
|
||||
}
|
||||
|
||||
hit := iamv0.UserHit{
|
||||
Name: row.Key.Name,
|
||||
Login: login,
|
||||
}
|
||||
|
||||
if titleIDX >= 0 && row.Cells[titleIDX] != nil {
|
||||
hit.Title = string(row.Cells[titleIDX])
|
||||
}
|
||||
|
||||
if emailIDX >= 0 && row.Cells[emailIDX] != nil {
|
||||
hit.Email = string(row.Cells[emailIDX])
|
||||
}
|
||||
|
||||
if roleIDX >= 0 && row.Cells[roleIDX] != nil {
|
||||
hit.Role = string(row.Cells[roleIDX])
|
||||
}
|
||||
|
||||
if lastSeenAtIDX >= 0 && row.Cells[lastSeenAtIDX] != nil {
|
||||
if len(row.Cells[lastSeenAtIDX]) == 8 {
|
||||
hit.LastSeenAt = int64(binary.BigEndian.Uint64(row.Cells[lastSeenAtIDX]))
|
||||
hit.LastSeenAtAge = util.GetAgeString(time.Unix(hit.LastSeenAt, 0))
|
||||
}
|
||||
}
|
||||
|
||||
sr.Hits = append(sr.Hits, hit)
|
||||
}
|
||||
|
||||
return sr, nil
|
||||
}
|
||||
|
||||
var bleveEscapeRegex = regexp.MustCompile(`([\\*?])`)
|
||||
|
||||
func escapeBleveQuery(query string) string {
|
||||
return bleveEscapeRegex.ReplaceAllString(query, `\$1`)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
|
||||
iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
legacyuser "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 TestSearchFallback(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mode rest.DualWriterMode
|
||||
expectUnified bool
|
||||
}{
|
||||
{name: "should hit legacy search handler on mode 0", mode: rest.Mode0, expectUnified: false},
|
||||
{name: "should hit legacy search handler on mode 1", mode: rest.Mode1, expectUnified: false},
|
||||
{name: "should hit legacy search handler on mode 2", mode: rest.Mode2, expectUnified: false},
|
||||
{name: "should hit unified storage search handler on mode 3", mode: rest.Mode3, expectUnified: true},
|
||||
{name: "should hit unified storage search handler on mode 4", mode: rest.Mode4, expectUnified: true},
|
||||
{name: "should hit unified storage search handler on mode 5", mode: rest.Mode5, expectUnified: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
mockClient := &MockClient{}
|
||||
mockLegacyClient := &MockClient{}
|
||||
|
||||
cfg := &setting.Cfg{
|
||||
UnifiedStorage: map[string]setting.UnifiedStorageConfig{
|
||||
"users.iam.grafana.app": {DualWriterMode: tt.mode},
|
||||
},
|
||||
}
|
||||
dual := dualwrite.ProvideStaticServiceForTests(cfg)
|
||||
|
||||
searchClient := resource.NewSearchClient(dualwrite.NewSearchAdapter(dual), iamv0.UserResourceInfo.GroupResource(), mockClient, mockLegacyClient, featuremgmt.WithFeatures())
|
||||
searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), searchClient, featuremgmt.WithFeatures(), cfg)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/searchUsers", nil)
|
||||
req.Header.Add("content-type", "application/json")
|
||||
req = req.WithContext(identity.WithRequester(req.Context(), &legacyuser.SignedInUser{Namespace: "test"}))
|
||||
|
||||
searchHandler.DoSearch(rr, req)
|
||||
|
||||
if tt.expectUnified {
|
||||
if mockClient.LastSearchRequest == nil {
|
||||
t.Fatalf("expected Unified Search to be called, but it was not")
|
||||
}
|
||||
} else {
|
||||
if mockLegacyClient.LastSearchRequest == nil {
|
||||
t.Fatalf("expected Legacy Search to be called, but it was not")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// MockClient implements the ResourceIndexClient interface for testing
|
||||
type MockClient struct {
|
||||
resourcepb.ResourceIndexClient
|
||||
resource.ResourceIndex
|
||||
|
||||
LastSearchRequest *resourcepb.ResourceSearchRequest
|
||||
|
||||
MockResponses []*resourcepb.ResourceSearchResponse
|
||||
MockCalls []*resourcepb.ResourceSearchRequest
|
||||
CallCount int
|
||||
}
|
||||
|
||||
func (m *MockClient) Search(ctx context.Context, in *resourcepb.ResourceSearchRequest, opts ...grpc.CallOption) (*resourcepb.ResourceSearchResponse, error) {
|
||||
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
|
||||
|
||||
if response == nil {
|
||||
response = &resourcepb.ResourceSearchResponse{}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (m *MockClient) GetQuotaUsage(ctx context.Context, req *resourcepb.QuotaUsageRequest, opts ...grpc.CallOption) (*resourcepb.QuotaUsageResponse, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestEscapeBleveQuery(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{input: "normal", expected: "normal"},
|
||||
{input: "*", expected: "\\*"},
|
||||
{input: "?", expected: "\\?"},
|
||||
{input: "\\", expected: "\\\\"},
|
||||
{input: "\\*", expected: "\\\\\\*"},
|
||||
{input: "*\\?", expected: "\\*\\\\\\?"},
|
||||
{input: "foo*bar", expected: "foo\\*bar"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := escapeBleveQuery(tt.input)
|
||||
if got != tt.expected {
|
||||
t.Errorf("escapeBleveQuery(%q) = %q, want %q", tt.input, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package user
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
|
||||
@@ -35,7 +34,7 @@ var (
|
||||
_ rest.TableConvertor = (*LegacyStore)(nil)
|
||||
)
|
||||
|
||||
var resource = iamv0alpha1.UserResourceInfo
|
||||
var userResource = iamv0alpha1.UserResourceInfo
|
||||
|
||||
func NewLegacyStore(store legacy.LegacyIdentityStore, ac claims.AccessClient, enableAuthnMutation bool, tracer trace.Tracer) *LegacyStore {
|
||||
return &LegacyStore{store, ac, enableAuthnMutation, tracer}
|
||||
@@ -54,7 +53,7 @@ func (s *LegacyStore) Update(ctx context.Context, name string, objInfo rest.Upda
|
||||
defer span.End()
|
||||
|
||||
if !s.enableAuthnMutation {
|
||||
return nil, false, apierrors.NewMethodNotSupported(resource.GroupResource(), "update")
|
||||
return nil, false, apierrors.NewMethodNotSupported(userResource.GroupResource(), "update")
|
||||
}
|
||||
|
||||
ns, err := request.NamespaceInfoFrom(ctx, true)
|
||||
@@ -105,7 +104,7 @@ func (s *LegacyStore) Update(ctx context.Context, name string, objInfo rest.Upda
|
||||
|
||||
// DeleteCollection implements rest.CollectionDeleter.
|
||||
func (s *LegacyStore) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions, listOptions *internalversion.ListOptions) (runtime.Object, error) {
|
||||
return nil, apierrors.NewMethodNotSupported(resource.GroupResource(), "deletecollection")
|
||||
return nil, apierrors.NewMethodNotSupported(userResource.GroupResource(), "deletecollection")
|
||||
}
|
||||
|
||||
// Delete implements rest.GracefulDeleter.
|
||||
@@ -114,7 +113,7 @@ func (s *LegacyStore) Delete(ctx context.Context, name string, deleteValidation
|
||||
defer span.End()
|
||||
|
||||
if !s.enableAuthnMutation {
|
||||
return nil, false, apierrors.NewMethodNotSupported(resource.GroupResource(), "delete")
|
||||
return nil, false, apierrors.NewMethodNotSupported(userResource.GroupResource(), "delete")
|
||||
}
|
||||
|
||||
ns, err := request.NamespaceInfoFrom(ctx, true)
|
||||
@@ -131,7 +130,7 @@ func (s *LegacyStore) Delete(ctx context.Context, name string, deleteValidation
|
||||
return nil, false, err
|
||||
}
|
||||
if found == nil || len(found.Items) < 1 {
|
||||
return nil, false, resource.NewNotFound(name)
|
||||
return nil, false, userResource.NewNotFound(name)
|
||||
}
|
||||
|
||||
userToDelete := &found.Items[0]
|
||||
@@ -157,7 +156,7 @@ func (s *LegacyStore) Delete(ctx context.Context, name string, deleteValidation
|
||||
}
|
||||
|
||||
func (s *LegacyStore) New() runtime.Object {
|
||||
return resource.NewFunc()
|
||||
return userResource.NewFunc()
|
||||
}
|
||||
|
||||
func (s *LegacyStore) Destroy() {}
|
||||
@@ -167,15 +166,15 @@ func (s *LegacyStore) NamespaceScoped() bool {
|
||||
}
|
||||
|
||||
func (s *LegacyStore) GetSingularName() string {
|
||||
return resource.GetSingularName()
|
||||
return userResource.GetSingularName()
|
||||
}
|
||||
|
||||
func (s *LegacyStore) NewList() runtime.Object {
|
||||
return resource.NewListFunc()
|
||||
return userResource.NewListFunc()
|
||||
}
|
||||
|
||||
func (s *LegacyStore) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) {
|
||||
return resource.TableConverter().ConvertToTable(ctx, object, tableOptions)
|
||||
return userResource.TableConverter().ConvertToTable(ctx, object, tableOptions)
|
||||
}
|
||||
|
||||
func (s *LegacyStore) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) {
|
||||
@@ -183,7 +182,7 @@ func (s *LegacyStore) List(ctx context.Context, options *internalversion.ListOpt
|
||||
defer span.End()
|
||||
|
||||
res, err := common.List(
|
||||
ctx, resource, s.ac, common.PaginationFromListOptions(options),
|
||||
ctx, userResource, s.ac, common.PaginationFromListOptions(options),
|
||||
func(ctx context.Context, ns claims.NamespaceInfo, p common.Pagination) (*common.ListResponse[iamv0alpha1.User], error) {
|
||||
found, err := s.store.ListUsers(ctx, ns, legacy.ListUserQuery{
|
||||
Pagination: p,
|
||||
@@ -231,10 +230,10 @@ func (s *LegacyStore) Get(ctx context.Context, name string, options *metav1.GetO
|
||||
Pagination: common.Pagination{Limit: 1},
|
||||
})
|
||||
if found == nil || err != nil {
|
||||
return nil, resource.NewNotFound(name)
|
||||
return nil, userResource.NewNotFound(name)
|
||||
}
|
||||
if len(found.Items) < 1 {
|
||||
return nil, resource.NewNotFound(name)
|
||||
return nil, userResource.NewNotFound(name)
|
||||
}
|
||||
|
||||
obj := toUserItem(&found.Items[0], ns.Value)
|
||||
@@ -247,7 +246,7 @@ func (s *LegacyStore) Create(ctx context.Context, obj runtime.Object, createVali
|
||||
defer span.End()
|
||||
|
||||
if !s.enableAuthnMutation {
|
||||
return nil, apierrors.NewMethodNotSupported(resource.GroupResource(), "create")
|
||||
return nil, apierrors.NewMethodNotSupported(userResource.GroupResource(), "create")
|
||||
}
|
||||
|
||||
ns, err := request.NamespaceInfoFrom(ctx, true)
|
||||
@@ -310,18 +309,12 @@ func toUserItem(u *common.UserWithRole, ns string) iamv0alpha1.User {
|
||||
Provisioned: u.IsProvisioned,
|
||||
Role: u.Role,
|
||||
},
|
||||
Status: iamv0alpha1.UserStatus{
|
||||
LastSeenAt: u.LastSeenAt.Unix(),
|
||||
},
|
||||
}
|
||||
obj, _ := utils.MetaAccessor(item)
|
||||
obj.SetUpdatedTimestamp(&u.Updated)
|
||||
obj.SetAnnotation(AnnoKeyLastSeenAt, formatTime(&u.LastSeenAt))
|
||||
obj.SetDeprecatedInternalID(u.ID) // nolint:staticcheck
|
||||
return *item
|
||||
}
|
||||
|
||||
func formatTime(v *time.Time) string {
|
||||
txt := ""
|
||||
if v != nil && v.Unix() != 0 {
|
||||
txt = v.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return txt
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ func validateEmail(ctx context.Context, searchClient resourcepb.ResourceIndexCli
|
||||
Operator: string(selection.Equals),
|
||||
Values: []string{email},
|
||||
},
|
||||
}, []string{"name", "email", "login"})
|
||||
}, []string{"fields.email", "fields.login"})
|
||||
|
||||
resp, err := searchClient.Search(ctx, req)
|
||||
if err != nil {
|
||||
@@ -159,7 +159,7 @@ func validateLogin(ctx context.Context, searchClient resourcepb.ResourceIndexCli
|
||||
Operator: string(selection.Equals),
|
||||
Values: []string{login},
|
||||
},
|
||||
}, []string{"name", "email", "login"})
|
||||
}, []string{"fields.email", "fields.login"})
|
||||
resp, err := searchClient.Search(ctx, req)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"github.com/grafana/authlib/types"
|
||||
iamv0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
@@ -178,7 +178,7 @@ func TestValidateOnCreate(t *testing.T) {
|
||||
IsGrafanaAdmin: false,
|
||||
},
|
||||
searchClient: &FakeUserLegacySearchClient{
|
||||
Users: []*user.UserSearchHitDTO{
|
||||
Users: []*org.OrgUserDTO{
|
||||
{Email: "existing@example"},
|
||||
},
|
||||
},
|
||||
@@ -202,7 +202,7 @@ func TestValidateOnCreate(t *testing.T) {
|
||||
IsGrafanaAdmin: false,
|
||||
},
|
||||
searchClient: &FakeUserLegacySearchClient{
|
||||
Users: []*user.UserSearchHitDTO{
|
||||
Users: []*org.OrgUserDTO{
|
||||
{Login: "existinguser"},
|
||||
},
|
||||
},
|
||||
@@ -490,7 +490,7 @@ func TestValidateOnUpdate(t *testing.T) {
|
||||
IsGrafanaAdmin: true,
|
||||
},
|
||||
searchClient: &FakeUserLegacySearchClient{
|
||||
Users: []*user.UserSearchHitDTO{
|
||||
Users: []*org.OrgUserDTO{
|
||||
{Email: "two@example"},
|
||||
},
|
||||
},
|
||||
@@ -516,7 +516,7 @@ func TestValidateOnUpdate(t *testing.T) {
|
||||
IsGrafanaAdmin: true,
|
||||
},
|
||||
searchClient: &FakeUserLegacySearchClient{
|
||||
Users: []*user.UserSearchHitDTO{
|
||||
Users: []*org.OrgUserDTO{
|
||||
{Name: "other", UID: "uid456", Login: "two"},
|
||||
},
|
||||
},
|
||||
@@ -536,7 +536,7 @@ func TestValidateOnUpdate(t *testing.T) {
|
||||
IsGrafanaAdmin: true,
|
||||
},
|
||||
searchClient: &FakeUserLegacySearchClient{
|
||||
Users: []*user.UserSearchHitDTO{
|
||||
Users: []*org.OrgUserDTO{
|
||||
{Login: "testuser", Email: "test@example"},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -26,6 +26,18 @@ type StatusPatcher interface {
|
||||
Patch(ctx context.Context, repo *provisioning.Repository, patchOperations ...map[string]interface{}) error
|
||||
}
|
||||
|
||||
// HealthCheckerInterface defines the interface for health checking operations
|
||||
//
|
||||
//go:generate mockery --name=HealthCheckerInterface --structname=MockHealthChecker
|
||||
type HealthCheckerInterface interface {
|
||||
ShouldCheckHealth(repo *provisioning.Repository) bool
|
||||
RefreshHealth(ctx context.Context, repo repository.Repository) (*provisioning.TestResults, provisioning.HealthStatus, error)
|
||||
RefreshHealthWithPatchOps(ctx context.Context, repo repository.Repository) (*provisioning.TestResults, provisioning.HealthStatus, []map[string]interface{}, error)
|
||||
RefreshTimestamp(ctx context.Context, repo *provisioning.Repository) error
|
||||
RecordFailure(ctx context.Context, failureType provisioning.HealthFailureType, err error, repo *provisioning.Repository) error
|
||||
HasRecentFailure(healthStatus provisioning.HealthStatus, failureType provisioning.HealthFailureType) bool
|
||||
}
|
||||
|
||||
// HealthChecker provides unified health checking for repositories
|
||||
type HealthChecker struct {
|
||||
statusPatcher StatusPatcher
|
||||
@@ -162,6 +174,33 @@ func (hc *HealthChecker) RefreshHealth(ctx context.Context, repo repository.Repo
|
||||
return testResults, newHealthStatus, nil
|
||||
}
|
||||
|
||||
// RefreshHealthWithPatchOps performs a health check on an existing repository
|
||||
// and returns the test results, health status, and patch operations to apply.
|
||||
// This method does NOT apply the patch itself, allowing the caller to batch
|
||||
// multiple status updates together to avoid race conditions.
|
||||
func (hc *HealthChecker) RefreshHealthWithPatchOps(ctx context.Context, repo repository.Repository) (*provisioning.TestResults, provisioning.HealthStatus, []map[string]interface{}, error) {
|
||||
cfg := repo.Config()
|
||||
|
||||
// Use health checker to perform comprehensive health check with existing status
|
||||
testResults, newHealthStatus, err := hc.refreshHealth(ctx, repo, cfg.Status.Health)
|
||||
if err != nil {
|
||||
return nil, provisioning.HealthStatus{}, nil, fmt.Errorf("health check failed: %w", err)
|
||||
}
|
||||
|
||||
var patchOps []map[string]interface{}
|
||||
|
||||
// Only return patch operation if health status actually changed
|
||||
if hc.hasHealthStatusChanged(cfg.Status.Health, newHealthStatus) {
|
||||
patchOps = append(patchOps, map[string]interface{}{
|
||||
"op": "replace",
|
||||
"path": "/status/health",
|
||||
"value": newHealthStatus,
|
||||
})
|
||||
}
|
||||
|
||||
return testResults, newHealthStatus, patchOps, nil
|
||||
}
|
||||
|
||||
// RefreshTimestamp updates the health status timestamp without changing other fields
|
||||
func (hc *HealthChecker) RefreshTimestamp(ctx context.Context, repo *provisioning.Repository) error {
|
||||
// Update the timestamp on the existing health status
|
||||
|
||||
@@ -532,6 +532,136 @@ func TestRefreshHealth(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshHealthWithPatchOps(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
testResult *provisioning.TestResults
|
||||
testError error
|
||||
existingStatus provisioning.HealthStatus
|
||||
expectError bool
|
||||
expectedHealth bool
|
||||
expectPatchOps bool
|
||||
expectedPatchPath string
|
||||
}{
|
||||
{
|
||||
name: "successful health check with status change",
|
||||
testResult: &provisioning.TestResults{
|
||||
Success: true,
|
||||
Code: 200,
|
||||
},
|
||||
testError: nil,
|
||||
existingStatus: provisioning.HealthStatus{
|
||||
Healthy: false,
|
||||
Error: provisioning.HealthFailureHealth,
|
||||
Checked: time.Now().Add(-time.Hour).UnixMilli(),
|
||||
},
|
||||
expectError: false,
|
||||
expectedHealth: true,
|
||||
expectPatchOps: true,
|
||||
expectedPatchPath: "/status/health",
|
||||
},
|
||||
{
|
||||
name: "failed health check with status change",
|
||||
testResult: &provisioning.TestResults{
|
||||
Success: false,
|
||||
Code: 500,
|
||||
Errors: []provisioning.ErrorDetails{
|
||||
{Detail: "connection failed"},
|
||||
},
|
||||
},
|
||||
testError: nil,
|
||||
existingStatus: provisioning.HealthStatus{
|
||||
Healthy: true,
|
||||
Checked: time.Now().Add(-time.Hour).UnixMilli(),
|
||||
},
|
||||
expectError: false,
|
||||
expectedHealth: false,
|
||||
expectPatchOps: true,
|
||||
expectedPatchPath: "/status/health",
|
||||
},
|
||||
{
|
||||
name: "no status change - no patch ops returned",
|
||||
testResult: &provisioning.TestResults{
|
||||
Success: true,
|
||||
Code: 200,
|
||||
},
|
||||
testError: nil,
|
||||
existingStatus: provisioning.HealthStatus{
|
||||
Healthy: true,
|
||||
Checked: time.Now().Add(-15 * time.Second).UnixMilli(),
|
||||
},
|
||||
expectError: false,
|
||||
expectedHealth: true,
|
||||
expectPatchOps: false,
|
||||
},
|
||||
{
|
||||
name: "test repository error",
|
||||
testResult: nil,
|
||||
testError: errors.New("repository test failed"),
|
||||
existingStatus: provisioning.HealthStatus{
|
||||
Healthy: true,
|
||||
Checked: time.Now().Add(-time.Hour).UnixMilli(),
|
||||
},
|
||||
expectError: true,
|
||||
expectedHealth: false,
|
||||
expectPatchOps: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create mock repository
|
||||
mockRepo := &mockRepository{
|
||||
config: &provisioning.Repository{
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Title: "Test Repository",
|
||||
Type: provisioning.LocalRepositoryType,
|
||||
},
|
||||
Status: provisioning.RepositoryStatus{
|
||||
Health: tt.existingStatus,
|
||||
},
|
||||
},
|
||||
testResult: tt.testResult,
|
||||
testError: tt.testError,
|
||||
}
|
||||
|
||||
// Create health checker with validator and tester
|
||||
validator := repository.NewValidator(30*time.Second, []provisioning.SyncTargetType{provisioning.SyncTargetTypeFolder, provisioning.SyncTargetTypeInstance}, true)
|
||||
hc := NewHealthChecker(nil, prometheus.NewPedanticRegistry(), repository.NewSimpleRepositoryTester(validator))
|
||||
|
||||
// Call RefreshHealthWithPatchOps
|
||||
testResults, healthStatus, patchOps, err := hc.RefreshHealthWithPatchOps(context.Background(), mockRepo)
|
||||
|
||||
// Verify error
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, testResults)
|
||||
return
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify health status
|
||||
assert.Equal(t, tt.expectedHealth, healthStatus.Healthy)
|
||||
|
||||
// Verify patch operations
|
||||
if tt.expectPatchOps {
|
||||
assert.NotEmpty(t, patchOps, "expected patch operations to be returned")
|
||||
assert.Len(t, patchOps, 1)
|
||||
assert.Equal(t, "replace", patchOps[0]["op"])
|
||||
assert.Equal(t, tt.expectedPatchPath, patchOps[0]["path"])
|
||||
assert.Equal(t, healthStatus, patchOps[0]["value"])
|
||||
} else {
|
||||
assert.Empty(t, patchOps, "expected no patch operations to be returned")
|
||||
}
|
||||
|
||||
// Verify test results
|
||||
if tt.testResult != nil {
|
||||
assert.Equal(t, tt.testResult, testResults)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasHealthStatusChanged(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
// Code generated by mockery v2.53.4. DO NOT EDIT.
|
||||
|
||||
package mocks
|
||||
|
||||
import (
|
||||
context "context"
|
||||
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
|
||||
repository "github.com/grafana/grafana/apps/provisioning/pkg/repository"
|
||||
|
||||
v0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
)
|
||||
|
||||
// MockHealthChecker is an autogenerated mock type for the HealthCheckerInterface type
|
||||
type MockHealthChecker struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// HasRecentFailure provides a mock function with given fields: healthStatus, failureType
|
||||
func (_m *MockHealthChecker) HasRecentFailure(healthStatus v0alpha1.HealthStatus, failureType v0alpha1.HealthFailureType) bool {
|
||||
ret := _m.Called(healthStatus, failureType)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for HasRecentFailure")
|
||||
}
|
||||
|
||||
var r0 bool
|
||||
if rf, ok := ret.Get(0).(func(v0alpha1.HealthStatus, v0alpha1.HealthFailureType) bool); ok {
|
||||
r0 = rf(healthStatus, failureType)
|
||||
} else {
|
||||
r0 = ret.Get(0).(bool)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// RecordFailure provides a mock function with given fields: ctx, failureType, err, repo
|
||||
func (_m *MockHealthChecker) RecordFailure(ctx context.Context, failureType v0alpha1.HealthFailureType, err error, repo *v0alpha1.Repository) error {
|
||||
ret := _m.Called(ctx, failureType, err, repo)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for RecordFailure")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, v0alpha1.HealthFailureType, error, *v0alpha1.Repository) error); ok {
|
||||
r0 = rf(ctx, failureType, err, repo)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// RefreshHealth provides a mock function with given fields: ctx, repo
|
||||
func (_m *MockHealthChecker) RefreshHealth(ctx context.Context, repo repository.Repository) (*v0alpha1.TestResults, v0alpha1.HealthStatus, error) {
|
||||
ret := _m.Called(ctx, repo)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for RefreshHealth")
|
||||
}
|
||||
|
||||
var r0 *v0alpha1.TestResults
|
||||
var r1 v0alpha1.HealthStatus
|
||||
var r2 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, repository.Repository) (*v0alpha1.TestResults, v0alpha1.HealthStatus, error)); ok {
|
||||
return rf(ctx, repo)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(context.Context, repository.Repository) *v0alpha1.TestResults); ok {
|
||||
r0 = rf(ctx, repo)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*v0alpha1.TestResults)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(context.Context, repository.Repository) v0alpha1.HealthStatus); ok {
|
||||
r1 = rf(ctx, repo)
|
||||
} else {
|
||||
r1 = ret.Get(1).(v0alpha1.HealthStatus)
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(2).(func(context.Context, repository.Repository) error); ok {
|
||||
r2 = rf(ctx, repo)
|
||||
} else {
|
||||
r2 = ret.Error(2)
|
||||
}
|
||||
|
||||
return r0, r1, r2
|
||||
}
|
||||
|
||||
// RefreshHealthWithPatchOps provides a mock function with given fields: ctx, repo
|
||||
func (_m *MockHealthChecker) RefreshHealthWithPatchOps(ctx context.Context, repo repository.Repository) (*v0alpha1.TestResults, v0alpha1.HealthStatus, []map[string]interface{}, error) {
|
||||
ret := _m.Called(ctx, repo)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for RefreshHealthWithPatchOps")
|
||||
}
|
||||
|
||||
var r0 *v0alpha1.TestResults
|
||||
var r1 v0alpha1.HealthStatus
|
||||
var r2 []map[string]interface{}
|
||||
var r3 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, repository.Repository) (*v0alpha1.TestResults, v0alpha1.HealthStatus, []map[string]interface{}, error)); ok {
|
||||
return rf(ctx, repo)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(context.Context, repository.Repository) *v0alpha1.TestResults); ok {
|
||||
r0 = rf(ctx, repo)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*v0alpha1.TestResults)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(context.Context, repository.Repository) v0alpha1.HealthStatus); ok {
|
||||
r1 = rf(ctx, repo)
|
||||
} else {
|
||||
r1 = ret.Get(1).(v0alpha1.HealthStatus)
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(2).(func(context.Context, repository.Repository) []map[string]interface{}); ok {
|
||||
r2 = rf(ctx, repo)
|
||||
} else {
|
||||
if ret.Get(2) != nil {
|
||||
r2 = ret.Get(2).([]map[string]interface{})
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(3).(func(context.Context, repository.Repository) error); ok {
|
||||
r3 = rf(ctx, repo)
|
||||
} else {
|
||||
r3 = ret.Error(3)
|
||||
}
|
||||
|
||||
return r0, r1, r2, r3
|
||||
}
|
||||
|
||||
// RefreshTimestamp provides a mock function with given fields: ctx, repo
|
||||
func (_m *MockHealthChecker) RefreshTimestamp(ctx context.Context, repo *v0alpha1.Repository) error {
|
||||
ret := _m.Called(ctx, repo)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for RefreshTimestamp")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *v0alpha1.Repository) error); ok {
|
||||
r0 = rf(ctx, repo)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// ShouldCheckHealth provides a mock function with given fields: repo
|
||||
func (_m *MockHealthChecker) ShouldCheckHealth(repo *v0alpha1.Repository) bool {
|
||||
ret := _m.Called(repo)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for ShouldCheckHealth")
|
||||
}
|
||||
|
||||
var r0 bool
|
||||
if rf, ok := ret.Get(0).(func(*v0alpha1.Repository) bool); ok {
|
||||
r0 = rf(repo)
|
||||
} else {
|
||||
r0 = ret.Get(0).(bool)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// NewMockHealthChecker creates a new instance of MockHealthChecker. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
// The first argument is typically a *testing.T value.
|
||||
func NewMockHealthChecker(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *MockHealthChecker {
|
||||
mock := &MockHealthChecker{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// Code generated by mockery v2.52.4. DO NOT EDIT.
|
||||
// Code generated by mockery v2.53.4. DO NOT EDIT.
|
||||
|
||||
package mocks
|
||||
|
||||
|
||||
@@ -561,11 +561,16 @@ func (rc *RepositoryController) process(item *queueItem) error {
|
||||
}
|
||||
|
||||
// Handle health checks using the health checker
|
||||
_, healthStatus, err := rc.healthChecker.RefreshHealth(ctx, repo)
|
||||
_, healthStatus, healthPatchOps, err := rc.healthChecker.RefreshHealthWithPatchOps(ctx, repo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update health status: %w", err)
|
||||
}
|
||||
|
||||
// Add health patch operations first
|
||||
if len(healthPatchOps) > 0 {
|
||||
patchOperations = append(patchOperations, healthPatchOps...)
|
||||
}
|
||||
|
||||
// determine the sync strategy and sync status to apply
|
||||
syncOptions := rc.determineSyncStrategy(ctx, obj, repo, shouldResync, healthStatus)
|
||||
patchOperations = append(patchOperations, rc.determineSyncStatusOps(obj, syncOptions, healthStatus)...)
|
||||
|
||||
@@ -350,6 +350,161 @@ type mockJobsQueueStore struct {
|
||||
*jobs.MockStore
|
||||
}
|
||||
|
||||
func TestRepositoryController_process_UnhealthyRepositoryStatusUpdate(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
repo *provisioning.Repository
|
||||
healthStatus provisioning.HealthStatus
|
||||
hasHealthStatusChanged bool
|
||||
expectedUnhealthyMessage bool
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "unhealthy repository should set unhealthy message in sync status",
|
||||
repo: &provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
Namespace: "default",
|
||||
Generation: 1,
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Sync: provisioning.SyncOptions{
|
||||
Enabled: true,
|
||||
IntervalSeconds: 300,
|
||||
},
|
||||
},
|
||||
Status: provisioning.RepositoryStatus{
|
||||
ObservedGeneration: 1,
|
||||
Health: provisioning.HealthStatus{
|
||||
Healthy: true,
|
||||
Checked: time.Now().Add(-10 * time.Minute).UnixMilli(),
|
||||
},
|
||||
Sync: provisioning.SyncStatus{
|
||||
State: provisioning.JobStateSuccess,
|
||||
Finished: time.Now().Add(-1 * time.Minute).UnixMilli(),
|
||||
Message: []string{},
|
||||
},
|
||||
},
|
||||
},
|
||||
healthStatus: provisioning.HealthStatus{
|
||||
Healthy: false,
|
||||
Error: provisioning.HealthFailureHealth,
|
||||
Checked: time.Now().UnixMilli(),
|
||||
Message: []string{"connection failed"},
|
||||
},
|
||||
hasHealthStatusChanged: true,
|
||||
expectedUnhealthyMessage: true,
|
||||
description: "should set unhealthy message when repository becomes unhealthy",
|
||||
},
|
||||
{
|
||||
name: "unhealthy repository should not duplicate unhealthy message",
|
||||
repo: &provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
Namespace: "default",
|
||||
Generation: 1,
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Sync: provisioning.SyncOptions{
|
||||
Enabled: true,
|
||||
IntervalSeconds: 300,
|
||||
},
|
||||
},
|
||||
Status: provisioning.RepositoryStatus{
|
||||
ObservedGeneration: 1,
|
||||
Health: provisioning.HealthStatus{
|
||||
Healthy: false,
|
||||
Checked: time.Now().Add(-2 * time.Minute).UnixMilli(),
|
||||
},
|
||||
Sync: provisioning.SyncStatus{
|
||||
State: provisioning.JobStateError,
|
||||
Finished: time.Now().Add(-1 * time.Minute).UnixMilli(),
|
||||
Message: []string{"Repository is unhealthy"},
|
||||
},
|
||||
},
|
||||
},
|
||||
healthStatus: provisioning.HealthStatus{
|
||||
Healthy: false,
|
||||
Error: provisioning.HealthFailureHealth,
|
||||
Checked: time.Now().UnixMilli(),
|
||||
Message: []string{"connection failed"},
|
||||
},
|
||||
hasHealthStatusChanged: false,
|
||||
expectedUnhealthyMessage: false,
|
||||
description: "should not set unhealthy message when it already exists",
|
||||
},
|
||||
{
|
||||
name: "healthy repository should clear unhealthy message",
|
||||
repo: &provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
Namespace: "default",
|
||||
Generation: 1,
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Sync: provisioning.SyncOptions{
|
||||
Enabled: true,
|
||||
IntervalSeconds: 300,
|
||||
},
|
||||
},
|
||||
Status: provisioning.RepositoryStatus{
|
||||
ObservedGeneration: 1,
|
||||
Health: provisioning.HealthStatus{
|
||||
Healthy: false,
|
||||
Checked: time.Now().Add(-2 * time.Minute).UnixMilli(),
|
||||
},
|
||||
Sync: provisioning.SyncStatus{
|
||||
State: provisioning.JobStateError,
|
||||
Finished: time.Now().Add(-1 * time.Minute).UnixMilli(),
|
||||
Message: []string{"Repository is unhealthy"},
|
||||
},
|
||||
},
|
||||
},
|
||||
healthStatus: provisioning.HealthStatus{
|
||||
Healthy: true,
|
||||
Checked: time.Now().UnixMilli(),
|
||||
Message: []string{},
|
||||
},
|
||||
hasHealthStatusChanged: true,
|
||||
expectedUnhealthyMessage: false,
|
||||
description: "should clear unhealthy message when repository becomes healthy",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Create controller
|
||||
rc := &RepositoryController{}
|
||||
|
||||
// Determine sync status ops (this is a pure function, no mocks needed)
|
||||
syncOps := rc.determineSyncStatusOps(tc.repo, nil, tc.healthStatus)
|
||||
|
||||
// Verify expectations
|
||||
hasUnhealthyOp := false
|
||||
hasClearUnhealthyOp := false
|
||||
for _, op := range syncOps {
|
||||
if path, ok := op["path"].(string); ok {
|
||||
if path == "/status/sync/message" {
|
||||
if messages, ok := op["value"].([]string); ok {
|
||||
if len(messages) > 0 && messages[0] == "Repository is unhealthy" {
|
||||
hasUnhealthyOp = true
|
||||
} else if len(messages) == 0 {
|
||||
hasClearUnhealthyOp = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if tc.expectedUnhealthyMessage {
|
||||
assert.True(t, hasUnhealthyOp, tc.description+": expected unhealthy message operation")
|
||||
} else if len(tc.repo.Status.Sync.Message) > 0 && tc.healthStatus.Healthy {
|
||||
assert.True(t, hasClearUnhealthyOp, tc.description+": expected clear unhealthy message operation")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepositoryController_shouldResync_StaleSyncStatus(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package historian
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/grafana/grafana-app-sdk/app"
|
||||
appsdkapiserver "github.com/grafana/grafana-app-sdk/k8s/apiserver"
|
||||
"github.com/grafana/grafana-app-sdk/simple"
|
||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||
restclient "k8s.io/client-go/rest"
|
||||
|
||||
"github.com/grafana/grafana/apps/alerting/historian/pkg/apis"
|
||||
@@ -23,6 +26,14 @@ type AlertingHistorianAppInstaller struct {
|
||||
appsdkapiserver.AppInstaller
|
||||
}
|
||||
|
||||
func (a *AlertingHistorianAppInstaller) GetAuthorizer() authorizer.Authorizer {
|
||||
return authorizer.AuthorizerFunc(
|
||||
func(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) {
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func RegisterAppInstaller(
|
||||
cfg *setting.Cfg,
|
||||
ng *ngalert.AlertNG,
|
||||
|
||||
Generated
+2
-2
@@ -879,7 +879,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(cfg, featureToggles, apiserverService, ssosettingsimplService, sqlStore, accessControl, accessClient, zanzanaClient, registerer, storageBackendImpl, storageBackendImpl, tracingService, storageBackendImpl, storageBackendImpl, noopTeamGroupsREST, dualwriteService, resourceClient, userService, teamService)
|
||||
identityAccessManagementAPIBuilder, err := iam.RegisterAPIService(cfg, featureToggles, apiserverService, ssosettingsimplService, sqlStore, accessControl, accessClient, zanzanaClient, registerer, storageBackendImpl, storageBackendImpl, tracingService, storageBackendImpl, storageBackendImpl, noopTeamGroupsREST, dualwriteService, resourceClient, orgService, userService, teamService, eventualRestConfigProvider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1537,7 +1537,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(cfg, featureToggles, apiserverService, ssosettingsimplService, sqlStore, accessControl, accessClient, zanzanaClient, registerer, storageBackendImpl, storageBackendImpl, tracingService, storageBackendImpl, storageBackendImpl, noopTeamGroupsREST, dualwriteService, resourceClient, userService, teamService)
|
||||
identityAccessManagementAPIBuilder, err := iam.RegisterAPIService(cfg, featureToggles, apiserverService, ssosettingsimplService, sqlStore, accessControl, accessClient, zanzanaClient, registerer, storageBackendImpl, storageBackendImpl, tracingService, storageBackendImpl, storageBackendImpl, noopTeamGroupsREST, dualwriteService, resourceClient, orgService, userService, teamService, eventualRestConfigProvider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
fswebassets "github.com/grafana/grafana/pkg/services/frontend/webassets"
|
||||
"github.com/grafana/grafana/pkg/services/hooks"
|
||||
"github.com/grafana/grafana/pkg/services/licensing"
|
||||
publicdashboardsapi "github.com/grafana/grafana/pkg/services/publicdashboards/api"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/web"
|
||||
)
|
||||
@@ -164,6 +165,11 @@ func (s *frontendService) registerRoutes(m *web.Mux) {
|
||||
// uses cache busting to ensure requests aren't cached.
|
||||
s.routeGet(m, "/-/fe-boot-error", s.handleBootError)
|
||||
|
||||
s.routeGet(m, "/public-dashboards/:accessToken",
|
||||
publicdashboardsapi.SetPublicDashboardAccessToken,
|
||||
s.index.HandleRequest,
|
||||
)
|
||||
|
||||
// All other requests return index.html
|
||||
s.routeGet(m, "/*", s.index.HandleRequest)
|
||||
}
|
||||
|
||||
@@ -45,6 +45,8 @@ type IndexViewData struct {
|
||||
|
||||
// Nonce is a cryptographic identifier for use with Content Security Policy.
|
||||
Nonce string
|
||||
|
||||
PublicDashboardAccessToken string
|
||||
}
|
||||
|
||||
// Templates setup.
|
||||
@@ -138,9 +140,12 @@ func (p *IndexProvider) HandleRequest(writer http.ResponseWriter, request *http.
|
||||
return
|
||||
}
|
||||
|
||||
reqCtx := contexthandler.FromContext(ctx)
|
||||
|
||||
// TODO -- restructure so the static stuff is under one variable and the rest is dynamic
|
||||
data := p.data // copy everything
|
||||
data.Nonce = nonce
|
||||
data.PublicDashboardAccessToken = reqCtx.PublicDashboardAccessToken
|
||||
|
||||
if data.CSPEnabled {
|
||||
data.CSPContent = middleware.ReplacePolicyVariables(p.data.CSPContent, p.data.AppSubUrl, data.Nonce)
|
||||
@@ -150,7 +155,6 @@ func (p *IndexProvider) HandleRequest(writer http.ResponseWriter, request *http.
|
||||
writer.Header().Set("Content-Security-Policy-Report-Only", policy)
|
||||
}
|
||||
|
||||
reqCtx := contexthandler.FromContext(ctx)
|
||||
p.runIndexDataHooks(reqCtx, &data)
|
||||
|
||||
writer.Header().Set("Content-Type", "text/html; charset=UTF-8")
|
||||
|
||||
@@ -188,6 +188,7 @@
|
||||
// Wrap in an IIFE to avoid polluting the global scope. Intentionally global-scope properties
|
||||
// are explicitly assigned to the `window` object.
|
||||
(() => {
|
||||
const publicDashboardAccessToken = [[.PublicDashboardAccessToken]]
|
||||
// Grafana can only fail to load once
|
||||
// However, it can fail to load in multiple different places
|
||||
// To avoid double reporting the error, we use this boolean to check if we've already failed
|
||||
@@ -271,9 +272,15 @@
|
||||
async function fetchBootData() {
|
||||
const queryParams = new URLSearchParams(window.location.search);
|
||||
|
||||
let path = '/bootdata';
|
||||
// call a special bootdata url with the public access token
|
||||
// this is needed to set the access token and correct org for public dashboards on the ST backend
|
||||
if (publicDashboardAccessToken) {
|
||||
path += `/${publicDashboardAccessToken}`;
|
||||
}
|
||||
// pass the search params through to the bootdata request
|
||||
// this allows for overriding the theme/language etc
|
||||
const bootDataUrl = new URL('/bootdata', window.location.origin);
|
||||
const bootDataUrl = new URL(path, window.location.origin);
|
||||
for (const [key, value] of queryParams.entries()) {
|
||||
bootDataUrl.searchParams.append(key, value);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"gopkg.in/ini.v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apimachinery/pkg/runtime/serializer"
|
||||
"k8s.io/apiserver/pkg/endpoints/request"
|
||||
@@ -397,6 +398,9 @@ func getRestClient(config Config, log logging.Logger) (*rest.RESTClient, error)
|
||||
burst = config.Burst
|
||||
}
|
||||
|
||||
// Add a default scheme to handle K8s API error responses
|
||||
scheme := runtime.NewScheme()
|
||||
|
||||
restConfig := &rest.Config{
|
||||
Host: config.URL,
|
||||
TLSClientConfig: config.TLSClientConfig,
|
||||
@@ -407,7 +411,7 @@ func getRestClient(config Config, log logging.Logger) (*rest.RESTClient, error)
|
||||
APIPath: "/apis",
|
||||
ContentConfig: rest.ContentConfig{
|
||||
GroupVersion: &settingGroupVersion,
|
||||
NegotiatedSerializer: serializer.NewCodecFactory(nil).WithoutConversion(),
|
||||
NegotiatedSerializer: serializer.NewCodecFactory(scheme).WithoutConversion(),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -148,6 +148,82 @@ func TestRemoteSettingService_List(t *testing.T) {
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
})
|
||||
|
||||
t.Run("should handle API errors", func(t *testing.T) {
|
||||
statusResponse := `{
|
||||
"apiVersion": "v1",
|
||||
"kind": "Status",
|
||||
"metadata": {},
|
||||
"status": "Failure",
|
||||
"message": "settings.setting.grafana.app \"test\" not found",
|
||||
"reason": "NotFound",
|
||||
"details": {
|
||||
"name": "test",
|
||||
"group": "setting.grafana.app",
|
||||
"kind": "settings"
|
||||
},
|
||||
"code": 404
|
||||
}`
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(statusResponse))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newTestClient(t, server.URL, 500)
|
||||
ctx := request.WithNamespace(context.Background(), "test-namespace")
|
||||
|
||||
result, err := client.List(ctx, metav1.LabelSelector{})
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "could not find the requested resource")
|
||||
})
|
||||
|
||||
t.Run("should handle 500 internal server error", func(t *testing.T) {
|
||||
statusResponse := `{
|
||||
"apiVersion": "v1",
|
||||
"kind": "Status",
|
||||
"metadata": {},
|
||||
"status": "Failure",
|
||||
"message": "Internal error occurred: database connection failed",
|
||||
"reason": "InternalError",
|
||||
"code": 500
|
||||
}`
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(statusResponse))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newTestClient(t, server.URL, 500)
|
||||
ctx := request.WithNamespace(context.Background(), "test-namespace")
|
||||
|
||||
result, err := client.List(ctx, metav1.LabelSelector{})
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "error on the server")
|
||||
})
|
||||
|
||||
t.Run("should handle connection errors", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
serverURL := server.URL
|
||||
server.Close()
|
||||
|
||||
client := newTestClient(t, serverURL, 500)
|
||||
ctx := request.WithNamespace(context.Background(), "test-namespace")
|
||||
|
||||
result, err := client.List(ctx, metav1.LabelSelector{})
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "connection refused")
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseSettingList(t *testing.T) {
|
||||
|
||||
@@ -53,8 +53,9 @@ func TestUserDocumentBuilder(t *testing.T) {
|
||||
Group: "iam.grafana.app",
|
||||
Resource: "users",
|
||||
}, []string{
|
||||
"user-with-login-and-email",
|
||||
"user-with-login-only",
|
||||
"with-login-and-email",
|
||||
"with-login-only",
|
||||
"with-last-seen-at-and-role",
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"key": {
|
||||
"namespace": "default",
|
||||
"group": "iam.grafana.app",
|
||||
"resource": "users",
|
||||
"name": "with-last-seen-at-and-role"
|
||||
},
|
||||
"name": "with-last-seen-at-and-role",
|
||||
"rv": 1234,
|
||||
"fields": {
|
||||
"email": "user.three@test.com",
|
||||
"lastSeenAt": 1698321600,
|
||||
"login": "user.three",
|
||||
"role": "Editor"
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"metadata": {
|
||||
"name": "with-last-seen-at-and-role",
|
||||
"namespace": "default"
|
||||
},
|
||||
"spec": {
|
||||
"login": "user.three",
|
||||
"email": "user.three@test.com",
|
||||
"role": "Editor"
|
||||
},
|
||||
"status": {
|
||||
"lastSeenAt": 1698321600
|
||||
}
|
||||
}
|
||||
+5
-3
@@ -3,12 +3,14 @@
|
||||
"namespace": "default",
|
||||
"group": "iam.grafana.app",
|
||||
"resource": "users",
|
||||
"name": "user-with-login-and-email"
|
||||
"name": "with-login-and-email"
|
||||
},
|
||||
"name": "user-with-login-and-email",
|
||||
"name": "with-login-and-email",
|
||||
"rv": 1234,
|
||||
"fields": {
|
||||
"email": "user.one@test.com",
|
||||
"login": "user.one"
|
||||
"lastSeenAt": 0,
|
||||
"login": "user.one",
|
||||
"role": "Viewer"
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"metadata": {
|
||||
"name": "user-with-login-and-email",
|
||||
"name": "with-login-and-email",
|
||||
"namespace": "default"
|
||||
},
|
||||
"spec": {
|
||||
+5
-3
@@ -3,11 +3,13 @@
|
||||
"namespace": "default",
|
||||
"group": "iam.grafana.app",
|
||||
"resource": "users",
|
||||
"name": "user-with-login-only"
|
||||
"name": "with-login-only"
|
||||
},
|
||||
"name": "user-with-login-only",
|
||||
"name": "with-login-only",
|
||||
"rv": 1234,
|
||||
"fields": {
|
||||
"login": "user.two"
|
||||
"lastSeenAt": 0,
|
||||
"login": "user.two",
|
||||
"role": "Viewer"
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"metadata": {
|
||||
"name": "user-with-login-only",
|
||||
"name": "with-login-only",
|
||||
"namespace": "default"
|
||||
},
|
||||
"spec": {
|
||||
@@ -12,10 +12,20 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
USER_EMAIL = "email"
|
||||
USER_LOGIN = "login"
|
||||
USER_EMAIL = "email"
|
||||
USER_LOGIN = "login"
|
||||
USER_LAST_SEEN_AT = "lastSeenAt"
|
||||
USER_ROLE = "role"
|
||||
)
|
||||
|
||||
// UserSortableExtraFields are the additional fields that can be used for sorting user search results.
|
||||
// Should not include standard fields like title.
|
||||
var UserSortableExtraFields = []string{
|
||||
USER_EMAIL,
|
||||
USER_LOGIN,
|
||||
USER_LAST_SEEN_AT,
|
||||
}
|
||||
|
||||
var UserTableColumnDefinitions = map[string]*resourcepb.ResourceTableColumnDefinition{
|
||||
USER_EMAIL: {
|
||||
Name: USER_EMAIL,
|
||||
@@ -35,6 +45,22 @@ var UserTableColumnDefinitions = map[string]*resourcepb.ResourceTableColumnDefin
|
||||
Filterable: true,
|
||||
},
|
||||
},
|
||||
USER_LAST_SEEN_AT: {
|
||||
Name: USER_LAST_SEEN_AT,
|
||||
Type: resourcepb.ResourceTableColumnDefinition_INT64,
|
||||
Description: "The last seen timestamp of the user",
|
||||
Properties: &resourcepb.ResourceTableColumnDefinition_Properties{
|
||||
Filterable: true,
|
||||
},
|
||||
},
|
||||
USER_ROLE: {
|
||||
Name: USER_ROLE,
|
||||
Type: resourcepb.ResourceTableColumnDefinition_STRING,
|
||||
Description: "The role of the user",
|
||||
Properties: &resourcepb.ResourceTableColumnDefinition_Properties{
|
||||
Filterable: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func GetUserBuilder() (resource.DocumentBuilderInfo, error) {
|
||||
@@ -75,6 +101,8 @@ func (u *userDocumentBuilder) BuildDocument(ctx context.Context, key *resourcepb
|
||||
if user.Spec.Login != "" {
|
||||
doc.Fields[USER_LOGIN] = user.Spec.Login
|
||||
}
|
||||
doc.Fields[USER_LAST_SEEN_AT] = user.Status.LastSeenAt
|
||||
doc.Fields[USER_ROLE] = user.Spec.Role
|
||||
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,625 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
|
||||
iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/tests/apis"
|
||||
"github.com/grafana/grafana/pkg/tests/testinfra"
|
||||
"github.com/grafana/grafana/pkg/util/testutil"
|
||||
)
|
||||
|
||||
var gvrResourcePermissions = schema.GroupVersionResource{
|
||||
Group: "iam.grafana.app",
|
||||
Version: "v0alpha1",
|
||||
Resource: "resourcepermissions",
|
||||
}
|
||||
|
||||
var gvrFolders = schema.GroupVersionResource{
|
||||
Group: "folder.grafana.app",
|
||||
Version: "v1beta1",
|
||||
Resource: "folders",
|
||||
}
|
||||
|
||||
var gvrDashboards = schema.GroupVersionResource{
|
||||
Group: "dashboard.grafana.app",
|
||||
Version: "v1beta1",
|
||||
Resource: "dashboards",
|
||||
}
|
||||
|
||||
type permission struct {
|
||||
kind string
|
||||
name string
|
||||
verb string
|
||||
}
|
||||
|
||||
func newPermission(kind, name, verb string) permission {
|
||||
return permission{
|
||||
kind: kind,
|
||||
name: name,
|
||||
verb: verb,
|
||||
}
|
||||
}
|
||||
|
||||
func (p permission) ToMap() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"kind": p.kind,
|
||||
"name": p.name,
|
||||
"verb": p.verb,
|
||||
}
|
||||
}
|
||||
|
||||
func newPermissionMaps(permissions ...permission) []map[string]interface{} {
|
||||
permissionsMaps := make([]map[string]interface{}, len(permissions))
|
||||
for i, permission := range permissions {
|
||||
permissionsMaps[i] = permission.ToMap()
|
||||
}
|
||||
return permissionsMaps
|
||||
}
|
||||
|
||||
type k8sTestClients struct {
|
||||
rpAdmin *apis.K8sResourceClient
|
||||
rpEditor *apis.K8sResourceClient
|
||||
rpViewer *apis.K8sResourceClient
|
||||
}
|
||||
|
||||
func newk8sTestHelperClients(helper *apis.K8sTestHelper) *k8sTestClients {
|
||||
return &k8sTestClients{
|
||||
rpAdmin: helper.GetResourceClient(apis.ResourceClientArgs{
|
||||
User: helper.Org1.Admin,
|
||||
Namespace: helper.Namespacer(helper.Org1.OrgID),
|
||||
GVR: gvrResourcePermissions,
|
||||
}),
|
||||
rpEditor: helper.GetResourceClient(apis.ResourceClientArgs{
|
||||
User: helper.Org1.Editor,
|
||||
Namespace: helper.Namespacer(helper.Org1.OrgID),
|
||||
GVR: gvrResourcePermissions,
|
||||
}),
|
||||
rpViewer: helper.GetResourceClient(apis.ResourceClientArgs{
|
||||
User: helper.Org1.Viewer,
|
||||
Namespace: helper.Namespacer(helper.Org1.OrgID),
|
||||
GVR: gvrResourcePermissions,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationResourcePermissions(t *testing.T) {
|
||||
testutil.SkipIntegrationTestInShortMode(t)
|
||||
|
||||
modes := []rest.DualWriterMode{rest.Mode0, rest.Mode1, rest.Mode2, rest.Mode3}
|
||||
for _, mode := range modes {
|
||||
if mode >= rest.Mode3 {
|
||||
t.Skip("Skipping ResourcePermission tests for Mode3+ because default permissions are not written through the new APIs")
|
||||
continue
|
||||
}
|
||||
t.Run(fmt.Sprintf("ResourcePermission CRUD with dual writer mode %d", mode), func(t *testing.T) {
|
||||
// Turn off authorization cache so permission changes apply right away in tests
|
||||
t.Setenv("GF_AUTHORIZATION_CACHE_TTL", "0s")
|
||||
|
||||
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
|
||||
AppModeProduction: false,
|
||||
DisableAnonymous: true,
|
||||
APIServerStorageType: "unified",
|
||||
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
|
||||
"resourcepermissions.iam.grafana.app": {
|
||||
DualWriterMode: mode,
|
||||
},
|
||||
"folders.folder.grafana.app": {
|
||||
DualWriterMode: mode,
|
||||
},
|
||||
"dashboards.dashboard.grafana.app": {
|
||||
DualWriterMode: mode,
|
||||
},
|
||||
},
|
||||
EnableFeatureToggles: []string{
|
||||
featuremgmt.FlagKubernetesAuthzResourcePermissionApis,
|
||||
// Prevents nested folders from having default permissions
|
||||
featuremgmt.FlagKubernetesDashboards,
|
||||
},
|
||||
})
|
||||
|
||||
// Work around the default permissions applied on root folders
|
||||
// so we can test the ResourcePermission APIs without the default permissions interfering
|
||||
parentFolder := createRootFolderWithoutDefaultPermissions(t, helper)
|
||||
parentUID := parentFolder.GetName()
|
||||
|
||||
clients := newk8sTestHelperClients(helper)
|
||||
doResourcePermissionCRUDTests(t, helper, clients, parentUID)
|
||||
doResourcePermissionAuthzTests(t, helper, clients, parentUID)
|
||||
doResourcePermissionHierarchyTests(t, helper, clients, parentUID)
|
||||
doResourcePermissionListFilteringTests(t, helper, clients, parentUID)
|
||||
// TODO: Add tests for External JWT authentication
|
||||
// doResourcePermissionAccessPolicyTests(t, helper)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func doResourcePermissionCRUDTests(t *testing.T, helper *apis.K8sTestHelper, clients *k8sTestClients, parentUID string) {
|
||||
t.Run("should create/get/update/delete ResourcePermission using the new APIs", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create ResourcePermission for the folder
|
||||
permission := newPermission("ServiceAccount", helper.Org1.ViewerServiceAccount.UID, "view")
|
||||
toCreate := createResourcePermissionObject(parentUID, gvrFolders.Group, gvrFolders.Resource, permission)
|
||||
|
||||
created, err := clients.rpAdmin.Resource.Create(ctx, toCreate, metav1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, created)
|
||||
|
||||
createdName := created.GetName()
|
||||
require.NotEmpty(t, createdName)
|
||||
|
||||
// Verify spec
|
||||
spec := created.Object["spec"].(map[string]interface{})
|
||||
resource := spec["resource"].(map[string]interface{})
|
||||
require.Equal(t, gvrFolders.Group, resource["apiGroup"])
|
||||
require.Equal(t, gvrFolders.Resource, resource["resource"])
|
||||
require.Equal(t, parentUID, resource["name"])
|
||||
|
||||
// Get the ResourcePermission
|
||||
fetched, err := clients.rpAdmin.Resource.Get(ctx, createdName, metav1.GetOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, fetched)
|
||||
require.Equal(t, createdName, fetched.GetName())
|
||||
|
||||
// Update the ResourcePermission
|
||||
fetched.Object["spec"].(map[string]interface{})["permissions"] = newPermissionMaps(
|
||||
newPermission("User", helper.Org1.Viewer.Identity.GetIdentifier(), "edit"),
|
||||
)
|
||||
updated, err := clients.rpAdmin.Resource.Update(ctx, fetched, metav1.UpdateOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, updated)
|
||||
|
||||
updatedSpec := updated.Object["spec"].(map[string]interface{})
|
||||
permissions := updatedSpec["permissions"].([]interface{})
|
||||
require.Len(t, permissions, 1)
|
||||
perm := permissions[0].(map[string]interface{})
|
||||
require.Equal(t, helper.Org1.Viewer.Identity.GetIdentifier(), perm["name"])
|
||||
require.Equal(t, "edit", perm["verb"])
|
||||
|
||||
// Delete should work
|
||||
err = clients.rpAdmin.Resource.Delete(ctx, createdName, metav1.DeleteOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = clients.rpAdmin.Resource.Get(ctx, createdName, metav1.GetOptions{})
|
||||
require.Error(t, err)
|
||||
var statusErr *errors.StatusError
|
||||
require.ErrorAs(t, err, &statusErr)
|
||||
require.Equal(t, int32(404), statusErr.ErrStatus.Code)
|
||||
})
|
||||
|
||||
t.Run("should return 404 for non-existent ResourcePermission", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := clients.rpAdmin.Resource.Get(ctx, "folder.grafana.app-folders-unknown", metav1.GetOptions{})
|
||||
require.Error(t, err)
|
||||
var statusErr *errors.StatusError
|
||||
require.ErrorAs(t, err, &statusErr)
|
||||
require.Equal(t, int32(404), statusErr.ErrStatus.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func doResourcePermissionAuthzTests(t *testing.T, helper *apis.K8sTestHelper, clients *k8sTestClients, parentUID string) {
|
||||
t.Run("admin can create/update/delete ResourcePermission", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
folder := createTestFolder(t, helper, helper.Org1.Admin, "test-folder-admin", parentUID)
|
||||
folderUID := folder.GetName()
|
||||
|
||||
permission := newPermission("User", helper.Org1.Admin.Identity.GetIdentifier(), "admin")
|
||||
|
||||
toCreate := createResourcePermissionObject(folderUID, gvrFolders.Group, gvrFolders.Resource, permission)
|
||||
created, err := clients.rpAdmin.Resource.Create(ctx, toCreate, metav1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, created)
|
||||
|
||||
createdName := created.GetName()
|
||||
|
||||
// Get the created object
|
||||
fetched, err := clients.rpAdmin.Resource.Get(ctx, createdName, metav1.GetOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, fetched)
|
||||
|
||||
// Update should work
|
||||
permission = newPermission("Team", helper.Org1.Staff.UID, "edit")
|
||||
fetched.Object["spec"].(map[string]interface{})["permissions"] = []interface{}{permission.ToMap()}
|
||||
|
||||
_, err = clients.rpAdmin.Resource.Update(ctx, fetched, metav1.UpdateOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Delete should work
|
||||
err = clients.rpAdmin.Resource.Delete(ctx, createdName, metav1.DeleteOptions{})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("editor cannot create ResourcePermission (insufficient permissions)", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
folder := createTestFolder(t, helper, helper.Org1.Admin, "test-folder-editor-deny", parentUID)
|
||||
folderUID := folder.GetName()
|
||||
|
||||
permission := newPermission("User", helper.Org1.Editor.Identity.GetIdentifier(), "admin")
|
||||
toCreate := createResourcePermissionObject(folderUID, gvrFolders.Group, gvrFolders.Resource, permission)
|
||||
_, err := clients.rpEditor.Resource.Create(ctx, toCreate, metav1.CreateOptions{})
|
||||
require.Error(t, err)
|
||||
var statusErr *errors.StatusError
|
||||
require.ErrorAs(t, err, &statusErr)
|
||||
require.Equal(t, int32(403), statusErr.ErrStatus.Code)
|
||||
})
|
||||
|
||||
t.Run("viewer cannot create ResourcePermission (insufficient permissions)", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
folder := createTestFolder(t, helper, helper.Org1.Admin, "test-folder-viewer-deny", parentUID)
|
||||
folderUID := folder.GetName()
|
||||
|
||||
permission := newPermission("User", helper.Org1.Viewer.Identity.GetIdentifier(), "admin")
|
||||
toCreate := createResourcePermissionObject(folderUID, gvrFolders.Group, gvrFolders.Resource, permission)
|
||||
_, err := clients.rpViewer.Resource.Create(ctx, toCreate, metav1.CreateOptions{})
|
||||
require.Error(t, err)
|
||||
var statusErr *errors.StatusError
|
||||
require.ErrorAs(t, err, &statusErr)
|
||||
require.Equal(t, int32(403), statusErr.ErrStatus.Code)
|
||||
})
|
||||
|
||||
t.Run("viewer can update ResourcePermission of folder they can admin", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
folder := createTestFolder(t, helper, helper.Org1.Admin, "test-folder-viewer-admin", parentUID)
|
||||
folderUID := folder.GetName()
|
||||
|
||||
// Grant admin permissions to the viewer
|
||||
permission := newPermission("User", helper.Org1.Viewer.Identity.GetIdentifier(), "admin")
|
||||
toCreate := createResourcePermissionObject(folderUID, gvrFolders.Group, gvrFolders.Resource, permission)
|
||||
_, err := clients.rpAdmin.Resource.Create(ctx, toCreate, metav1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
// As a Viewer we should now be able to get and update the ResourcePermission of the folder
|
||||
fetched, err := clients.rpViewer.Resource.Get(ctx, "folder.grafana.app-folders-"+folderUID, metav1.GetOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, fetched)
|
||||
|
||||
// Update the ResourcePermission to grant editor permissions
|
||||
fetched.Object["spec"].(map[string]interface{})["permissions"] = newPermissionMaps(
|
||||
newPermission("BasicRole", "Editor", "edit"),
|
||||
newPermission("User", helper.Org1.Viewer.Identity.GetIdentifier(), "admin"),
|
||||
)
|
||||
|
||||
_, err = clients.rpViewer.Resource.Update(ctx, fetched, metav1.UpdateOptions{})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func doResourcePermissionHierarchyTests(t *testing.T, helper *apis.K8sTestHelper, clients *k8sTestClients, parentUID string) {
|
||||
permission := newPermission("BasicRole", "Editor", "admin")
|
||||
|
||||
t.Run("should respect folder hierarchy for folder permissions", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
sub1 := createTestFolder(t, helper, helper.Org1.Admin, "sub1-folder-hierarchy", parentUID)
|
||||
sub1UID := sub1.GetName()
|
||||
|
||||
sub2 := createTestFolder(t, helper, helper.Org1.Admin, "sub2-folder-hierarchy", sub1UID)
|
||||
sub2UID := sub2.GetName()
|
||||
|
||||
toCreate := createResourcePermissionObject(sub2UID, gvrFolders.Group, gvrFolders.Resource, permission)
|
||||
_, err := clients.rpAdmin.Resource.Create(ctx, toCreate, metav1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("editor can update ResourcePermission of sub2", func(t *testing.T) {
|
||||
fetched, err := clients.rpEditor.Resource.Get(ctx, "folder.grafana.app-folders-"+sub2UID, metav1.GetOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
fetched.Object["spec"].(map[string]interface{})["permissions"] = newPermissionMaps(
|
||||
newPermission("BasicRole", "Editor", "admin"),
|
||||
newPermission("User", helper.Org1.Viewer.Identity.GetIdentifier(), "edit"),
|
||||
)
|
||||
_, err = clients.rpEditor.Resource.Update(ctx, fetched, metav1.UpdateOptions{})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
t.Run("editor cannot create ResourcePermission of sub1", func(t *testing.T) {
|
||||
toCreate := createResourcePermissionObject(sub1UID, gvrFolders.Group, gvrFolders.Resource, permission)
|
||||
_, err := clients.rpEditor.Resource.Create(ctx, toCreate, metav1.CreateOptions{})
|
||||
require.Error(t, err)
|
||||
var statusErr *errors.StatusError
|
||||
require.ErrorAs(t, err, &statusErr)
|
||||
require.Equal(t, int32(403), statusErr.ErrStatus.Code)
|
||||
})
|
||||
|
||||
// Delete the ResourcePermission of sub2
|
||||
err = clients.rpAdmin.Resource.Delete(ctx, "folder.grafana.app-folders-"+sub2UID, metav1.DeleteOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a new ResourcePermission for sub2
|
||||
toCreate = createResourcePermissionObject(sub1UID, gvrFolders.Group, gvrFolders.Resource, permission)
|
||||
_, err = clients.rpAdmin.Resource.Create(ctx, toCreate, metav1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("editor can create ResourcePermission of sub2 with parent folder permission", func(t *testing.T) {
|
||||
toCreate := createResourcePermissionObject(sub2UID, gvrFolders.Group, gvrFolders.Resource, permission)
|
||||
|
||||
_, err = clients.rpEditor.Resource.Create(ctx, toCreate, metav1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
fetched, err := clients.rpEditor.Resource.Get(ctx, "folder.grafana.app-folders-"+sub2UID, metav1.GetOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, fetched)
|
||||
|
||||
permissions := fetched.Object["spec"].(map[string]interface{})["permissions"]
|
||||
require.Len(t, permissions, 1)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("should respect folder hierarchy for dashboard permissions", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create folder and a nested dashboard
|
||||
folder := createTestFolder(t, helper, helper.Org1.Admin, "sub1-dashboard-hierarchy", parentUID)
|
||||
folderUID := folder.GetName()
|
||||
|
||||
dashboard := createTestDashboard(t, helper, helper.Org1.Admin, "sub2-dashboard", folderUID)
|
||||
dashboardUID := dashboard.GetName()
|
||||
|
||||
// Verify dashboard has parent folder annotation
|
||||
annotations := dashboard.GetAnnotations()
|
||||
require.Equal(t, folderUID, annotations[utils.AnnoKeyFolder])
|
||||
|
||||
t.Run("editor cannot create ResourcePermission of dashboard without parent folder permission", func(t *testing.T) {
|
||||
toCreate := createResourcePermissionObject(dashboardUID, gvrDashboards.Group, gvrDashboards.Resource, permission)
|
||||
_, err := clients.rpEditor.Resource.Create(ctx, toCreate, metav1.CreateOptions{})
|
||||
require.Error(t, err)
|
||||
var statusErr *errors.StatusError
|
||||
require.ErrorAs(t, err, &statusErr)
|
||||
require.Equal(t, int32(403), statusErr.ErrStatus.Code)
|
||||
})
|
||||
|
||||
// Admin creates ResourcePermission for parent folder
|
||||
toCreate := createResourcePermissionObject(folderUID, gvrFolders.Group, gvrFolders.Resource, permission)
|
||||
created, err := clients.rpAdmin.Resource.Create(ctx, toCreate, metav1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, created)
|
||||
|
||||
t.Run("editor can create ResourcePermission of dashboard with parent folder permission", func(t *testing.T) {
|
||||
toCreate := createResourcePermissionObject(dashboardUID, gvrDashboards.Group, gvrDashboards.Resource, permission)
|
||||
_, err := clients.rpEditor.Resource.Create(ctx, toCreate, metav1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
fetched, err := clients.rpEditor.Resource.Get(ctx, "dashboard.grafana.app-dashboards-"+dashboardUID, metav1.GetOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, fetched)
|
||||
|
||||
permissions := fetched.Object["spec"].(map[string]interface{})["permissions"]
|
||||
require.Len(t, permissions, 1)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func doResourcePermissionListFilteringTests(t *testing.T, helper *apis.K8sTestHelper, clients *k8sTestClients, parentUID string) {
|
||||
viewerCanAdmin := newPermission("BasicRole", "Viewer", "admin")
|
||||
viewerCanView := newPermission("BasicRole", "Viewer", "view")
|
||||
editorCanAdmin := newPermission("BasicRole", "Editor", "admin")
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create two folders
|
||||
editorFolder := createTestFolder(t, helper, helper.Org1.Admin, "editor-only-folder", parentUID)
|
||||
editorFolderUID := editorFolder.GetName()
|
||||
|
||||
viewerFolder := createTestFolder(t, helper, helper.Org1.Admin, "viewer-only-folder", parentUID)
|
||||
viewerFolderUID := viewerFolder.GetName()
|
||||
|
||||
dashboardViewerCanAdmin := createTestDashboard(t, helper, helper.Org1.Admin, "dashboard-in-editor-folder-viewer-can-admin", editorFolderUID)
|
||||
dashboardViewerCanAdminUID := dashboardViewerCanAdmin.GetName()
|
||||
dashboardViewerCanView := createTestDashboard(t, helper, helper.Org1.Admin, "dashboard-in-editor-folder-viewer-can-view", editorFolderUID)
|
||||
dashboardViewerCanViewUID := dashboardViewerCanView.GetName()
|
||||
|
||||
// Grant admin permissions to the viewer on folder2 and dashboard1
|
||||
rp1 := createResourcePermissionObject(viewerFolderUID, gvrFolders.Group, gvrFolders.Resource, viewerCanAdmin)
|
||||
_, err := clients.rpAdmin.Resource.Create(ctx, rp1, metav1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
rp2 := createResourcePermissionObject(dashboardViewerCanAdminUID, gvrDashboards.Group, gvrDashboards.Resource, viewerCanAdmin)
|
||||
_, err = clients.rpAdmin.Resource.Create(ctx, rp2, metav1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
// Grant admin permissions to the editor on folder1
|
||||
rp3 := createResourcePermissionObject(editorFolderUID, gvrFolders.Group, gvrFolders.Resource, editorCanAdmin)
|
||||
_, err = clients.rpAdmin.Resource.Create(ctx, rp3, metav1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
// Grant view permissions to the viewer on dashboard2
|
||||
rp4 := createResourcePermissionObject(dashboardViewerCanViewUID, gvrDashboards.Group, gvrDashboards.Resource, viewerCanView)
|
||||
_, err = clients.rpAdmin.Resource.Create(ctx, rp4, metav1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("Admin can list all ResourcePermissions", func(t *testing.T) {
|
||||
// Admin can list all ResourcePermissions
|
||||
list, err := clients.rpAdmin.Resource.List(ctx, metav1.ListOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, list)
|
||||
|
||||
// Check that all expected items are present (there may be more from other tests)
|
||||
itemNames := getNamesFromList(list)
|
||||
require.Contains(t, itemNames, rp1.GetName(), "Admin should see viewer folder permission")
|
||||
require.Contains(t, itemNames, rp2.GetName(), "Admin should see dashboard viewer can admin permission")
|
||||
require.Contains(t, itemNames, rp3.GetName(), "Admin should see editor folder permission")
|
||||
require.Contains(t, itemNames, rp4.GetName(), "Admin should see dashboard viewer can view permission")
|
||||
})
|
||||
|
||||
t.Run("Viewer can list ResourcePermissions of folder2 and dashboard1", func(t *testing.T) {
|
||||
list, err := clients.rpViewer.Resource.List(ctx, metav1.ListOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, list)
|
||||
|
||||
itemNames := getNamesFromList(list)
|
||||
// Viewer should see permissions for resources they can admin
|
||||
require.Contains(t, itemNames, rp1.GetName(), "Viewer should see viewer folder permission")
|
||||
require.Contains(t, itemNames, rp2.GetName(), "Viewer should see dashboard viewer can admin permission")
|
||||
|
||||
// Viewer should NOT see permissions for resources they cannot admin
|
||||
require.NotContains(t, itemNames, rp3.GetName(), "Viewer should NOT see editor folder permission")
|
||||
require.NotContains(t, itemNames, rp4.GetName(), "Viewer should NOT see dashboard viewer can view permission")
|
||||
})
|
||||
t.Run("Editor can list ResourcePermissions of folder1 and its nested dashboards", func(t *testing.T) {
|
||||
list, err := clients.rpEditor.Resource.List(ctx, metav1.ListOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, list)
|
||||
|
||||
itemNames := getNamesFromList(list)
|
||||
// Editor has admin on editorFolder, so they should see:
|
||||
// - rp3 (editorFolder permission)
|
||||
// - rp2 (dashboard in editorFolder with admin permission)
|
||||
// - rp4 (dashboard in editorFolder with view permission)
|
||||
require.Contains(t, itemNames, rp2.GetName(), "Editor should see dashboard admin permission in their folder")
|
||||
require.Contains(t, itemNames, rp3.GetName(), "Editor should see their folder permission")
|
||||
require.Contains(t, itemNames, rp4.GetName(), "Editor should see dashboard view permission in their folder")
|
||||
|
||||
// Editor should NOT see permissions for viewerFolder
|
||||
require.NotContains(t, itemNames, rp1.GetName(), "Editor should NOT see viewer-only folder permission")
|
||||
})
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func createTestFolder(t *testing.T, helper *apis.K8sTestHelper, user apis.User, title string, parentUID string) *unstructured.Unstructured {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
folderClient := helper.GetResourceClient(apis.ResourceClientArgs{
|
||||
User: user,
|
||||
Namespace: helper.Namespacer(user.Identity.GetOrgID()),
|
||||
GVR: gvrFolders,
|
||||
})
|
||||
metadata := map[string]interface{}{
|
||||
"generateName": "test-folder-",
|
||||
"namespace": helper.Namespacer(user.Identity.GetOrgID()),
|
||||
}
|
||||
|
||||
if parentUID != "" {
|
||||
metadata["annotations"] = map[string]interface{}{
|
||||
utils.AnnoKeyFolder: parentUID,
|
||||
}
|
||||
}
|
||||
|
||||
folder := &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": "folder.grafana.app/v1beta1",
|
||||
"kind": "Folder",
|
||||
"metadata": metadata,
|
||||
"spec": map[string]interface{}{
|
||||
"title": title,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
created, err := folderClient.Resource.Create(ctx, folder, metav1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, created)
|
||||
return created
|
||||
}
|
||||
|
||||
// Helper function to delete default permissions
|
||||
func deleteDefaultPermissions(t *testing.T, client *apis.K8sResourceClient, resourceName string) {
|
||||
ctx := context.Background()
|
||||
// Delete the resource permission
|
||||
err := client.Resource.Delete(ctx, resourceName, metav1.DeleteOptions{})
|
||||
require.NoError(t, err)
|
||||
// Check if the resource permission is deleted
|
||||
_, err = client.Resource.Get(ctx, resourceName, metav1.GetOptions{})
|
||||
require.Error(t, err)
|
||||
var statusErr *errors.StatusError
|
||||
require.ErrorAs(t, err, &statusErr)
|
||||
require.Equal(t, int32(404), statusErr.ErrStatus.Code)
|
||||
}
|
||||
|
||||
// Helper function to create a root folder without default permissions
|
||||
func createRootFolderWithoutDefaultPermissions(t *testing.T, helper *apis.K8sTestHelper) *unstructured.Unstructured {
|
||||
t.Helper()
|
||||
|
||||
// Create folder as admin
|
||||
folder := createTestFolder(t, helper, helper.Org1.Admin, "root-without-permissions", "")
|
||||
folderUID := folder.GetName()
|
||||
|
||||
// Delete default permissions
|
||||
rpClient := helper.GetResourceClient(apis.ResourceClientArgs{
|
||||
User: helper.Org1.Admin,
|
||||
Namespace: helper.Namespacer(helper.Org1.OrgID),
|
||||
GVR: gvrResourcePermissions,
|
||||
})
|
||||
|
||||
// It would be better to create the folder without default permissions, but this is a workaround for the time being
|
||||
deleteDefaultPermissions(t, rpClient, "folder.grafana.app-folders-"+folderUID)
|
||||
|
||||
return folder
|
||||
}
|
||||
|
||||
func createTestDashboard(t *testing.T, helper *apis.K8sTestHelper, user apis.User, title, folderUID string) *unstructured.Unstructured {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
dashboardClient := helper.GetResourceClient(apis.ResourceClientArgs{
|
||||
User: user,
|
||||
Namespace: helper.Namespacer(user.Identity.GetOrgID()),
|
||||
GVR: gvrDashboards,
|
||||
})
|
||||
|
||||
dashboard := &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": "dashboard.grafana.app/v1beta1",
|
||||
"kind": "Dashboard",
|
||||
"metadata": map[string]interface{}{
|
||||
"generateName": "test-dashboard-",
|
||||
"namespace": helper.Namespacer(user.Identity.GetOrgID()),
|
||||
"annotations": map[string]interface{}{
|
||||
utils.AnnoKeyFolder: folderUID,
|
||||
},
|
||||
},
|
||||
"spec": map[string]interface{}{
|
||||
"title": title,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
created, err := dashboardClient.Resource.Create(ctx, dashboard, metav1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, created)
|
||||
return created
|
||||
}
|
||||
|
||||
func createResourcePermissionObject(resourceName, apiGroup, resource string, permissions ...permission) *unstructured.Unstructured {
|
||||
permissionMaps := newPermissionMaps(permissions...)
|
||||
return &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": iamv0.GROUP + "/" + iamv0.VERSION,
|
||||
"kind": "ResourcePermission",
|
||||
"metadata": map[string]interface{}{
|
||||
"name": apiGroup + "-" + resource + "-" + resourceName,
|
||||
},
|
||||
"spec": map[string]interface{}{
|
||||
"resource": map[string]interface{}{
|
||||
"apiGroup": apiGroup,
|
||||
"resource": resource,
|
||||
"name": resourceName,
|
||||
},
|
||||
"permissions": permissionMaps,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func getNamesFromList(list *unstructured.UnstructuredList) []string {
|
||||
names := make([]string, len(list.Items))
|
||||
for i, item := range list.Items {
|
||||
names[i] = item.GetName()
|
||||
}
|
||||
return names
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
|
||||
iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/tests/apis"
|
||||
"github.com/grafana/grafana/pkg/tests/testinfra"
|
||||
"github.com/grafana/grafana/pkg/util/testutil"
|
||||
)
|
||||
|
||||
func TestIntegrationUserSearch(t *testing.T) {
|
||||
testutil.SkipIntegrationTestInShortMode(t)
|
||||
|
||||
modes := []rest.DualWriterMode{rest.Mode0, rest.Mode1, rest.Mode2, rest.Mode3, rest.Mode4, rest.Mode5}
|
||||
for _, mode := range modes {
|
||||
t.Run(fmt.Sprintf("DualWriterMode %d", mode), func(t *testing.T) {
|
||||
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
|
||||
AppModeProduction: false,
|
||||
DisableAnonymous: true,
|
||||
APIServerStorageType: "unified",
|
||||
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
|
||||
"users.iam.grafana.app": {
|
||||
DualWriterMode: mode,
|
||||
},
|
||||
},
|
||||
EnableFeatureToggles: []string{
|
||||
featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs,
|
||||
featuremgmt.FlagKubernetesAuthnMutation,
|
||||
},
|
||||
UnifiedStorageEnableSearch: true,
|
||||
})
|
||||
|
||||
t.Cleanup(func() {
|
||||
helper.Shutdown()
|
||||
})
|
||||
|
||||
setupUsers(t, helper)
|
||||
|
||||
t.Run("search by title", func(t *testing.T) {
|
||||
res := searchUsers(t, helper, "Alice")
|
||||
require.Len(t, res.Hits, 1)
|
||||
require.Equal(t, "TestUser Alice", res.Hits[0].Title)
|
||||
})
|
||||
|
||||
t.Run("search by login", func(t *testing.T) {
|
||||
res := searchUsers(t, helper, "bob")
|
||||
require.Len(t, res.Hits, 1)
|
||||
require.Equal(t, "bob", res.Hits[0].Login)
|
||||
})
|
||||
|
||||
t.Run("search by email", func(t *testing.T) {
|
||||
res := searchUsers(t, helper, "charlie@example.com")
|
||||
require.Len(t, res.Hits, 1)
|
||||
require.Equal(t, "charlie@example.com", res.Hits[0].Email)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationUserSearch_WithSorting(t *testing.T) {
|
||||
testutil.SkipIntegrationTestInShortMode(t)
|
||||
|
||||
modes := []rest.DualWriterMode{rest.Mode0, rest.Mode1, rest.Mode2, rest.Mode3, rest.Mode4, rest.Mode5}
|
||||
for _, mode := range modes {
|
||||
t.Run(fmt.Sprintf("DualWriterMode %d", mode), func(t *testing.T) {
|
||||
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
|
||||
AppModeProduction: false,
|
||||
DisableAnonymous: true,
|
||||
APIServerStorageType: "unified",
|
||||
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
|
||||
"users.iam.grafana.app": {
|
||||
DualWriterMode: mode,
|
||||
},
|
||||
},
|
||||
EnableFeatureToggles: []string{
|
||||
featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs,
|
||||
featuremgmt.FlagKubernetesAuthnMutation,
|
||||
},
|
||||
UnifiedStorageEnableSearch: true,
|
||||
})
|
||||
|
||||
t.Cleanup(func() {
|
||||
helper.Shutdown()
|
||||
})
|
||||
|
||||
setupUsers(t, helper)
|
||||
|
||||
tests := []struct {
|
||||
field string
|
||||
extractor func(iamv0.UserHit) string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
field: "title",
|
||||
extractor: func(h iamv0.UserHit) string { return h.Title },
|
||||
expected: []string{"TestUser Alice", "TestUser Bob", "TestUser Charlie", "TestUser Editor", "TestUser Viewer"},
|
||||
},
|
||||
{
|
||||
field: "login",
|
||||
extractor: func(h iamv0.UserHit) string { return h.Login },
|
||||
expected: []string{"alice", "bob", "charlie", "testuser-editor", "testuser-viewer"},
|
||||
},
|
||||
{
|
||||
field: "email",
|
||||
extractor: func(h iamv0.UserHit) string { return h.Email },
|
||||
expected: []string{"alice@example.com", "bob@example.com", "charlie@example.com", "testuser-editor@example.com", "testuser-viewer@example.com"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run("sort by "+tc.field, func(t *testing.T) {
|
||||
// ASC
|
||||
res := searchUsersWithSort(t, helper, "TestUser", tc.field)
|
||||
require.GreaterOrEqual(t, len(res.Hits), 5)
|
||||
verifyOrder(t, res.Hits, tc.expected, tc.extractor)
|
||||
|
||||
// DESC
|
||||
res = searchUsersWithSort(t, helper, "TestUser", "-"+tc.field)
|
||||
require.GreaterOrEqual(t, len(res.Hits), 5)
|
||||
|
||||
// Reverse expected
|
||||
reversed := make([]string, len(tc.expected))
|
||||
copy(reversed, tc.expected)
|
||||
sort.Sort(sort.Reverse(sort.StringSlice(reversed)))
|
||||
verifyOrder(t, res.Hits, reversed, tc.extractor)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("sort by lastSeenAt", func(t *testing.T) {
|
||||
if mode >= rest.Mode3 {
|
||||
t.Skip("Skipping lastSeenAt sort test for Mode >= 3: API does not persist status.lastSeenAt")
|
||||
}
|
||||
// Populate lastSeenAt
|
||||
// Alice: 30 minutes ago
|
||||
// Bob: 1 minute ago
|
||||
// Charlie: 2 hours ago
|
||||
// Editor: 40 minutes ago
|
||||
// Viewer: 1h 30 mins ago
|
||||
now := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
updateLastSeenAt(t, helper, "alice", now.Add(-30*time.Minute), mode)
|
||||
updateLastSeenAt(t, helper, "bob", now.Add(-1*time.Minute), mode)
|
||||
updateLastSeenAt(t, helper, "charlie", now.Add(-2*time.Hour), mode)
|
||||
updateLastSeenAt(t, helper, "testuser-editor", now.Add(-40*time.Minute), mode)
|
||||
updateLastSeenAt(t, helper, "testuser-viewer", now.Add(-90*time.Minute), mode)
|
||||
|
||||
// lastSeenAt ASC means oldest date first to match legacy behavior
|
||||
res := searchUsersWithSort(t, helper, "TestUser", "lastSeenAt")
|
||||
require.GreaterOrEqual(t, len(res.Hits), 5)
|
||||
verifyOrder(t, res.Hits, []string{"charlie", "testuser-viewer", "testuser-editor", "alice", "bob"}, func(h iamv0.UserHit) string { return h.Login })
|
||||
|
||||
res = searchUsersWithSort(t, helper, "TestUser", "-lastSeenAt")
|
||||
require.GreaterOrEqual(t, len(res.Hits), 5)
|
||||
verifyOrder(t, res.Hits, []string{"bob", "alice", "testuser-editor", "testuser-viewer", "charlie"}, func(h iamv0.UserHit) string { return h.Login })
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationUserSearch_SortCompareLegacy(t *testing.T) {
|
||||
testutil.SkipIntegrationTestInShortMode(t)
|
||||
|
||||
modes := []rest.DualWriterMode{rest.Mode2}
|
||||
for _, mode := range modes {
|
||||
t.Run(fmt.Sprintf("DualWriterMode %d", mode), func(t *testing.T) {
|
||||
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
|
||||
AppModeProduction: false,
|
||||
DisableAnonymous: true,
|
||||
APIServerStorageType: "unified",
|
||||
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
|
||||
"users.iam.grafana.app": {
|
||||
DualWriterMode: mode,
|
||||
},
|
||||
},
|
||||
EnableFeatureToggles: []string{
|
||||
featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs,
|
||||
featuremgmt.FlagKubernetesAuthnMutation,
|
||||
},
|
||||
UnifiedStorageEnableSearch: true,
|
||||
})
|
||||
|
||||
t.Cleanup(func() {
|
||||
helper.Shutdown()
|
||||
})
|
||||
|
||||
setupUsers(t, helper)
|
||||
|
||||
// Populate lastSeenAt for sorting comparison
|
||||
now := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
updateLastSeenAt(t, helper, "alice", now.Add(-30*time.Minute), mode)
|
||||
updateLastSeenAt(t, helper, "bob", now.Add(-1*time.Minute), mode)
|
||||
updateLastSeenAt(t, helper, "charlie", now.Add(-2*time.Hour), mode)
|
||||
updateLastSeenAt(t, helper, "testuser-editor", now.Add(-40*time.Minute), mode)
|
||||
updateLastSeenAt(t, helper, "testuser-viewer", now.Add(-90*time.Minute), mode)
|
||||
|
||||
fields := []string{"login", "email", "name", "lastSeenAt"}
|
||||
for _, field := range fields {
|
||||
for _, order := range []string{"asc", "desc"} {
|
||||
t.Run(fmt.Sprintf("compare %s %s", field, order), func(t *testing.T) {
|
||||
// Legacy API uses "name" for Name/Title, "login" for Login, "email" for Email.
|
||||
// "lastSeenAt" maps to "lastSeenAtAge" in legacy.
|
||||
legacySort := field
|
||||
if field == "lastSeenAt" {
|
||||
legacySort = "lastSeenAtAge"
|
||||
}
|
||||
legacySort += "-" + order
|
||||
|
||||
newSort := field
|
||||
if order == "desc" {
|
||||
newSort = "-" + field
|
||||
}
|
||||
|
||||
legacyRes := searchUsersLegacy(t, helper, "TestUser", legacySort)
|
||||
newRes := searchUsersWithSort(t, helper, "TestUser", newSort)
|
||||
|
||||
require.Equal(t, len(legacyRes), len(newRes.Hits))
|
||||
for i := range legacyRes {
|
||||
require.Equal(t, legacyRes[i].Login, newRes.Hits[i].Login, "Mismatch at index %d for sort %s", i, newSort)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationUserSearch_Paging(t *testing.T) {
|
||||
testutil.SkipIntegrationTestInShortMode(t)
|
||||
|
||||
modes := []rest.DualWriterMode{rest.Mode0, rest.Mode1, rest.Mode2, rest.Mode3, rest.Mode4, rest.Mode5}
|
||||
for _, mode := range modes {
|
||||
t.Run(fmt.Sprintf("DualWriterMode %d", mode), func(t *testing.T) {
|
||||
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
|
||||
AppModeProduction: false,
|
||||
DisableAnonymous: true,
|
||||
APIServerStorageType: "unified",
|
||||
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
|
||||
"users.iam.grafana.app": {
|
||||
DualWriterMode: mode,
|
||||
},
|
||||
},
|
||||
EnableFeatureToggles: []string{
|
||||
featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs,
|
||||
featuremgmt.FlagKubernetesAuthnMutation,
|
||||
},
|
||||
UnifiedStorageEnableSearch: true,
|
||||
})
|
||||
|
||||
t.Cleanup(func() {
|
||||
helper.Shutdown()
|
||||
})
|
||||
|
||||
setupUsers(t, helper)
|
||||
|
||||
t.Run("paging with page and limit", func(t *testing.T) {
|
||||
// There are 5 users matching "TestUser"
|
||||
query := "TestUser"
|
||||
|
||||
// Page 1, Limit 2
|
||||
res1 := searchUsersWithPaging(t, helper, query, 1, 2)
|
||||
require.Equal(t, int64(5), res1.TotalHits)
|
||||
require.Len(t, res1.Hits, 2)
|
||||
|
||||
// Page 2, Limit 2
|
||||
res2 := searchUsersWithPaging(t, helper, query, 2, 2)
|
||||
require.Equal(t, int64(5), res2.TotalHits)
|
||||
require.Len(t, res2.Hits, 2)
|
||||
|
||||
// Page 3, Limit 2
|
||||
res3 := searchUsersWithPaging(t, helper, query, 3, 2)
|
||||
require.Equal(t, int64(5), res3.TotalHits)
|
||||
require.Len(t, res3.Hits, 1)
|
||||
|
||||
seen := make(map[string]bool)
|
||||
for _, h := range res1.Hits {
|
||||
seen[h.Login] = true
|
||||
}
|
||||
for _, h := range res2.Hits {
|
||||
require.False(t, seen[h.Login], "User %s seen in page 1 and 2", h.Login)
|
||||
seen[h.Login] = true
|
||||
}
|
||||
for _, h := range res3.Hits {
|
||||
require.False(t, seen[h.Login], "User %s seen in previous pages", h.Login)
|
||||
seen[h.Login] = true
|
||||
}
|
||||
require.Len(t, seen, 5)
|
||||
})
|
||||
|
||||
t.Run("paging with offset and limit", func(t *testing.T) {
|
||||
// There are 5 users matching "TestUser"
|
||||
query := "TestUser"
|
||||
|
||||
// Offset 0, Limit 2 (equivalent to Page 1)
|
||||
res1 := searchUsersWithOffset(t, helper, query, 0, 2)
|
||||
require.Equal(t, int64(5), res1.TotalHits)
|
||||
require.Len(t, res1.Hits, 2)
|
||||
|
||||
// Offset 2, Limit 2 (equivalent to Page 2)
|
||||
res2 := searchUsersWithOffset(t, helper, query, 2, 2)
|
||||
require.Equal(t, int64(5), res2.TotalHits)
|
||||
require.Len(t, res2.Hits, 2)
|
||||
|
||||
// Offset 4, Limit 2 (equivalent to Page 3)
|
||||
res3 := searchUsersWithOffset(t, helper, query, 4, 2)
|
||||
require.Equal(t, int64(5), res3.TotalHits)
|
||||
require.Len(t, res3.Hits, 1)
|
||||
|
||||
// Verify uniqueness
|
||||
seen := make(map[string]bool)
|
||||
for _, h := range res1.Hits {
|
||||
seen[h.Login] = true
|
||||
}
|
||||
for _, h := range res2.Hits {
|
||||
require.False(t, seen[h.Login], "User %s seen in offset 0 and 2", h.Login)
|
||||
seen[h.Login] = true
|
||||
}
|
||||
for _, h := range res3.Hits {
|
||||
require.False(t, seen[h.Login], "User %s seen in previous offsets", h.Login)
|
||||
seen[h.Login] = true
|
||||
}
|
||||
require.Len(t, seen, 5)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func setupUsers(t *testing.T, helper *apis.K8sTestHelper) {
|
||||
ctx := context.Background()
|
||||
userClient := helper.GetResourceClient(apis.ResourceClientArgs{
|
||||
User: helper.Org1.Admin,
|
||||
Namespace: helper.Namespacer(helper.Org1.Admin.Identity.GetOrgID()),
|
||||
GVR: gvrUsers,
|
||||
})
|
||||
|
||||
users := []iamv0.User{
|
||||
{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "testuser-editor",
|
||||
},
|
||||
Spec: iamv0.UserSpec{
|
||||
Title: "TestUser Editor",
|
||||
Login: "testuser-editor",
|
||||
Email: "testuser-editor@example.com",
|
||||
Role: "Editor",
|
||||
},
|
||||
},
|
||||
{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "testuser-viewer",
|
||||
},
|
||||
Spec: iamv0.UserSpec{
|
||||
Title: "TestUser Viewer",
|
||||
Login: "testuser-viewer",
|
||||
Email: "testuser-viewer@example.com",
|
||||
Role: "Viewer",
|
||||
},
|
||||
},
|
||||
{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "alice",
|
||||
},
|
||||
Spec: iamv0.UserSpec{
|
||||
Title: "TestUser Alice",
|
||||
Login: "alice",
|
||||
Email: "alice@example.com",
|
||||
Role: "Viewer",
|
||||
},
|
||||
},
|
||||
{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "bob",
|
||||
},
|
||||
Spec: iamv0.UserSpec{
|
||||
Title: "TestUser Bob",
|
||||
Login: "bob",
|
||||
Email: "bob@example.com",
|
||||
Role: "Viewer",
|
||||
},
|
||||
},
|
||||
{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "charlie",
|
||||
},
|
||||
Spec: iamv0.UserSpec{
|
||||
Title: "TestUser Charlie",
|
||||
Login: "charlie",
|
||||
Email: "charlie@example.com",
|
||||
Role: "Viewer",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, u := range users {
|
||||
uMap, err := runtime.DefaultUnstructuredConverter.ToUnstructured(&u)
|
||||
require.NoError(t, err)
|
||||
_, err = userClient.Resource.Create(ctx, &unstructured.Unstructured{Object: uMap}, metav1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Wait for indexing
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
|
||||
func searchUsers(t *testing.T, helper *apis.K8sTestHelper, query string) *iamv0.GetSearchUsers {
|
||||
return searchUsersWithSort(t, helper, query, "")
|
||||
}
|
||||
|
||||
func searchUsersWithSort(t *testing.T, helper *apis.K8sTestHelper, query string, sort string) *iamv0.GetSearchUsers {
|
||||
q := url.Values{}
|
||||
q.Set("query", query)
|
||||
if sort != "" {
|
||||
q.Set("sort", sort)
|
||||
}
|
||||
q.Set("limit", "100")
|
||||
|
||||
path := fmt.Sprintf("/apis/iam.grafana.app/v0alpha1/namespaces/default/searchUsers?%s", q.Encode())
|
||||
|
||||
res := &iamv0.GetSearchUsers{}
|
||||
rsp := apis.DoRequest(helper, apis.RequestParams{
|
||||
User: helper.Org1.Admin,
|
||||
Method: "GET",
|
||||
Path: path,
|
||||
}, res)
|
||||
|
||||
require.Equal(t, 200, rsp.Response.StatusCode)
|
||||
return res
|
||||
}
|
||||
|
||||
func searchUsersWithPaging(t *testing.T, helper *apis.K8sTestHelper, query string, page, limit int) *iamv0.GetSearchUsers {
|
||||
q := url.Values{}
|
||||
q.Set("query", query)
|
||||
q.Set("page", fmt.Sprintf("%d", page))
|
||||
q.Set("limit", fmt.Sprintf("%d", limit))
|
||||
// Sort by login to ensure deterministic paging
|
||||
q.Set("sort", "login")
|
||||
|
||||
path := fmt.Sprintf("/apis/iam.grafana.app/v0alpha1/namespaces/default/searchUsers?%s", q.Encode())
|
||||
|
||||
res := &iamv0.GetSearchUsers{}
|
||||
rsp := apis.DoRequest(helper, apis.RequestParams{
|
||||
User: helper.Org1.Admin,
|
||||
Method: "GET",
|
||||
Path: path,
|
||||
}, res)
|
||||
|
||||
require.Equal(t, 200, rsp.Response.StatusCode)
|
||||
return res
|
||||
}
|
||||
|
||||
func searchUsersWithOffset(t *testing.T, helper *apis.K8sTestHelper, query string, offset, limit int) *iamv0.GetSearchUsers {
|
||||
q := url.Values{}
|
||||
q.Set("query", query)
|
||||
q.Set("offset", fmt.Sprintf("%d", offset))
|
||||
q.Set("limit", fmt.Sprintf("%d", limit))
|
||||
// Sort by login to ensure deterministic paging
|
||||
q.Set("sort", "login")
|
||||
|
||||
path := fmt.Sprintf("/apis/iam.grafana.app/v0alpha1/namespaces/default/searchUsers?%s", q.Encode())
|
||||
|
||||
res := &iamv0.GetSearchUsers{}
|
||||
rsp := apis.DoRequest(helper, apis.RequestParams{
|
||||
User: helper.Org1.Admin,
|
||||
Method: "GET",
|
||||
Path: path,
|
||||
}, res)
|
||||
|
||||
require.Equal(t, 200, rsp.Response.StatusCode)
|
||||
return res
|
||||
}
|
||||
|
||||
type LegacyUserSearchHit struct {
|
||||
UserId int64 `json:"userId"`
|
||||
Name string `json:"name"`
|
||||
Login string `json:"login"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
func searchUsersLegacy(t *testing.T, helper *apis.K8sTestHelper, query string, sort string) []LegacyUserSearchHit {
|
||||
q := url.Values{}
|
||||
q.Set("query", query)
|
||||
|
||||
if sort != "" {
|
||||
q.Set("sort", sort)
|
||||
}
|
||||
q.Set("perpage", "100")
|
||||
q.Set("page", "1")
|
||||
|
||||
path := fmt.Sprintf("/api/org/users/search?%s", q.Encode())
|
||||
|
||||
var res struct {
|
||||
OrgUsers []LegacyUserSearchHit `json:"orgUsers"`
|
||||
}
|
||||
|
||||
rsp := apis.DoRequest(helper, apis.RequestParams{
|
||||
User: helper.Org1.Admin,
|
||||
Method: "GET",
|
||||
Path: path,
|
||||
}, &res)
|
||||
|
||||
require.Equal(t, 200, rsp.Response.StatusCode)
|
||||
return res.OrgUsers
|
||||
}
|
||||
|
||||
// verifyOrder checks that the extracted values from hits are in the expected order.
|
||||
// It filters hits to only include those with expected values, because search returns more results than just the test users.
|
||||
// Like other users in the system that have been created by the test framework.
|
||||
func verifyOrder(t *testing.T, hits []iamv0.UserHit, expectedValues []string, extractor func(iamv0.UserHit) string) {
|
||||
// Filter hits to only include expected values
|
||||
var actualValues []string
|
||||
expectedSet := make(map[string]bool)
|
||||
for _, v := range expectedValues {
|
||||
expectedSet[v] = true
|
||||
}
|
||||
|
||||
for _, h := range hits {
|
||||
val := extractor(h)
|
||||
if expectedSet[val] {
|
||||
actualValues = append(actualValues, val)
|
||||
}
|
||||
}
|
||||
|
||||
require.Equal(t, expectedValues, actualValues)
|
||||
}
|
||||
|
||||
func updateLastSeenAt(t *testing.T, helper *apis.K8sTestHelper, login string, lastSeen time.Time, mode rest.DualWriterMode) {
|
||||
if mode < rest.Mode3 {
|
||||
err := helper.GetEnv().SQLStore.WithDbSession(context.Background(), func(sess *db.Session) error {
|
||||
_, err := sess.Table("user").Where("login = ?", login).Update(map[string]interface{}{
|
||||
"last_seen_at": lastSeen,
|
||||
})
|
||||
return err
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Use the new APIs to update the user resource status in Mode3+
|
||||
if mode >= rest.Mode3 {
|
||||
ctx := context.Background()
|
||||
userClient := helper.GetResourceClient(apis.ResourceClientArgs{
|
||||
User: helper.Org1.Admin,
|
||||
Namespace: helper.Namespacer(helper.Org1.Admin.Identity.GetOrgID()),
|
||||
GVR: gvrUsers,
|
||||
})
|
||||
|
||||
u, err := userClient.Resource.Get(ctx, login, metav1.GetOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = unstructured.SetNestedField(u.Object, lastSeen.Unix(), "status", "lastSeenAt")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = userClient.Resource.Update(ctx, u, metav1.UpdateOptions{})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
}
|
||||
+275
-43
@@ -1021,6 +1021,156 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/apis/iam.grafana.app/v0alpha1/namespaces/{namespace}/searchUsers": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Search"
|
||||
],
|
||||
"description": "User search",
|
||||
"operationId": "getSearchUsers",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "namespace",
|
||||
"in": "path",
|
||||
"description": "workspace",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"example": "default"
|
||||
},
|
||||
{
|
||||
"name": "query",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"description": "number of results to return",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"example": 30
|
||||
},
|
||||
{
|
||||
"name": "page",
|
||||
"in": "query",
|
||||
"description": "page number (starting from 1)",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"example": 1
|
||||
},
|
||||
{
|
||||
"name": "offset",
|
||||
"in": "query",
|
||||
"description": "number of results to skip",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"example": 0
|
||||
},
|
||||
{
|
||||
"name": "sort",
|
||||
"in": "query",
|
||||
"description": "sortable field",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"examples": {
|
||||
"": {
|
||||
"summary": "default sorting"
|
||||
},
|
||||
"-email": {
|
||||
"summary": "email descending",
|
||||
"value": "-email"
|
||||
},
|
||||
"-lastSeenAt": {
|
||||
"summary": "last seen at descending",
|
||||
"value": "-lastSeenAt"
|
||||
},
|
||||
"-login": {
|
||||
"summary": "login descending",
|
||||
"value": "-login"
|
||||
},
|
||||
"-title": {
|
||||
"summary": "title descending",
|
||||
"value": "-title"
|
||||
},
|
||||
"email": {
|
||||
"summary": "email ascending",
|
||||
"value": "email"
|
||||
},
|
||||
"lastSeenAt": {
|
||||
"summary": "last seen at ascending",
|
||||
"value": "lastSeenAt"
|
||||
},
|
||||
"login": {
|
||||
"summary": "login ascending",
|
||||
"value": "login"
|
||||
},
|
||||
"title": {
|
||||
"summary": "title ascending",
|
||||
"value": "title"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"default": {
|
||||
"description": "Default OK response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/apis/iam.grafana.app/v0alpha1/namespaces/{namespace}/serviceaccounts": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -5614,7 +5764,8 @@
|
||||
"type": "object",
|
||||
"required": [
|
||||
"metadata",
|
||||
"spec"
|
||||
"spec",
|
||||
"status"
|
||||
],
|
||||
"properties": {
|
||||
"apiVersion": {
|
||||
@@ -5641,6 +5792,14 @@
|
||||
"$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.UserSpec"
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"default": {},
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.UserStatus"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"x-kubernetes-group-version-kind": [
|
||||
@@ -5741,6 +5900,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.UserStatus": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"lastSeenAt"
|
||||
],
|
||||
"properties": {
|
||||
"lastSeenAt": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"default": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.VersionsV0alpha1Kinds7RoutesGroupsGETResponseExternalGroupMapping": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -6566,6 +6738,44 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GetSearchUsers": {
|
||||
"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": [
|
||||
@@ -7760,7 +7970,8 @@
|
||||
"type": "object",
|
||||
"required": [
|
||||
"metadata",
|
||||
"spec"
|
||||
"spec",
|
||||
"status"
|
||||
],
|
||||
"properties": {
|
||||
"apiVersion": {
|
||||
@@ -7777,6 +7988,63 @@
|
||||
"spec": {
|
||||
"description": "Spec is the spec of the User",
|
||||
"default": {}
|
||||
},
|
||||
"status": {
|
||||
"default": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.UserHit": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"title",
|
||||
"login",
|
||||
"email",
|
||||
"role",
|
||||
"lastSeenAt",
|
||||
"lastSeenAtAge",
|
||||
"provisioned",
|
||||
"score"
|
||||
],
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"default": ""
|
||||
},
|
||||
"lastSeenAt": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"default": 0
|
||||
},
|
||||
"lastSeenAtAge": {
|
||||
"type": "string",
|
||||
"default": ""
|
||||
},
|
||||
"login": {
|
||||
"type": "string",
|
||||
"default": ""
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"default": ""
|
||||
},
|
||||
"provisioned": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"role": {
|
||||
"type": "string",
|
||||
"default": ""
|
||||
},
|
||||
"score": {
|
||||
"type": "number",
|
||||
"format": "double",
|
||||
"default": 0
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"default": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -7854,51 +8122,15 @@
|
||||
}
|
||||
},
|
||||
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.UserStatus": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"additionalFields": {
|
||||
"description": "additionalFields is reserved for future use",
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"operatorStates": {
|
||||
"description": "operatorStates is a map of operator ID to operator state evaluations. Any operator which consumes this kind SHOULD add its state evaluation information to this field.",
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"default": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.UserstatusOperatorState": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"lastEvaluation",
|
||||
"state"
|
||||
"lastSeenAt"
|
||||
],
|
||||
"properties": {
|
||||
"descriptiveState": {
|
||||
"description": "descriptiveState is an optional more descriptive state field which has no requirements on format",
|
||||
"type": "string"
|
||||
},
|
||||
"details": {
|
||||
"description": "details contains any extra information that is operator-specific",
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"lastEvaluation": {
|
||||
"description": "lastEvaluation is the ResourceVersion last evaluated",
|
||||
"type": "string",
|
||||
"default": ""
|
||||
},
|
||||
"state": {
|
||||
"description": "state describes the state of the lastEvaluation. It is limited to three possible states for machine evaluation.",
|
||||
"type": "string",
|
||||
"default": ""
|
||||
"lastSeenAt": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"default": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package opentsdb
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
)
|
||||
|
||||
func (s *Service) HandleSuggestQuery(rw http.ResponseWriter, req *http.Request) {
|
||||
logger := logger.FromContext(req.Context())
|
||||
|
||||
dsInfo, err := s.getDSInfo(req.Context(), backend.PluginConfigFromContext(req.Context()))
|
||||
if err != nil {
|
||||
http.Error(rw, fmt.Sprintf("failed to get datasource info: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
u, err := url.Parse(dsInfo.URL)
|
||||
if err != nil {
|
||||
http.Error(rw, fmt.Sprintf("failed to parse datasource URL: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
u.Path = path.Join(u.Path, "api/suggest")
|
||||
u.RawQuery = req.URL.RawQuery
|
||||
httpReq, err := http.NewRequestWithContext(req.Context(), http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
http.Error(rw, fmt.Sprintf("failed to create request: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
res, err := dsInfo.HTTPClient.Do(httpReq)
|
||||
if err != nil {
|
||||
http.Error(rw, fmt.Sprintf("failed to execute request: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := res.Body.Close(); err != nil {
|
||||
logger.Error("Failed to close response body", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
responseBody, err := DecodeResponseBody(res, logger)
|
||||
if err != nil {
|
||||
http.Error(rw, fmt.Sprintf("failed to decode response: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
for name, values := range res.Header {
|
||||
if name == "Content-Encoding" || name == "Content-Length" {
|
||||
continue
|
||||
}
|
||||
for _, value := range values {
|
||||
rw.Header().Add(name, value)
|
||||
}
|
||||
}
|
||||
|
||||
rw.WriteHeader(res.StatusCode)
|
||||
if _, err := rw.Write(responseBody); err != nil {
|
||||
logger.Error("Failed to write response", "error", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
+12
-245
@@ -4,21 +4,15 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/datasource"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/log"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/resource/httpadapter"
|
||||
)
|
||||
|
||||
var logger = backend.NewLoggerWith("tsdb.opentsdb")
|
||||
@@ -155,6 +149,14 @@ func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthReque
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) CallResource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/suggest", s.HandleSuggestQuery)
|
||||
|
||||
handler := httpadapter.New(mux)
|
||||
return handler.CallResource(ctx, req, sender)
|
||||
}
|
||||
|
||||
func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) {
|
||||
logger := logger.FromContext(ctx)
|
||||
|
||||
@@ -166,16 +168,15 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest)
|
||||
result := backend.NewQueryDataResponse()
|
||||
|
||||
for _, query := range req.Queries {
|
||||
// Build OpenTsdbQuery with per-query time range
|
||||
tsdbQuery := OpenTsdbQuery{
|
||||
Start: query.TimeRange.From.Unix(),
|
||||
End: query.TimeRange.To.Unix(),
|
||||
Queries: []map[string]any{
|
||||
s.buildMetric(query),
|
||||
BuildMetric(query),
|
||||
},
|
||||
}
|
||||
|
||||
httpReq, err := s.createRequest(ctx, logger, dsInfo, tsdbQuery)
|
||||
httpReq, err := CreateRequest(ctx, logger, dsInfo, tsdbQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -191,251 +192,17 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest)
|
||||
}
|
||||
}()
|
||||
|
||||
queryRes, err := s.parseResponse(logger, httpRes, query.RefID, dsInfo.TSDBVersion)
|
||||
queryRes, err := ParseResponse(logger, httpRes, query.RefID, dsInfo.TSDBVersion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Attach parsed result for this query's RefID
|
||||
result.Responses[query.RefID] = queryRes.Responses[query.RefID]
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) createRequest(ctx context.Context, logger log.Logger, dsInfo *datasourceInfo, data OpenTsdbQuery) (*http.Request, error) {
|
||||
u, err := url.Parse(dsInfo.URL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.Path = path.Join(u.Path, "api/query")
|
||||
if dsInfo.TSDBVersion == 4 {
|
||||
queryParams := u.Query()
|
||||
queryParams.Set("arrays", "true")
|
||||
u.RawQuery = queryParams.Encode()
|
||||
}
|
||||
|
||||
postData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
logger.Info("Failed marshaling data", "error", err)
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), strings.NewReader(string(postData)))
|
||||
if err != nil {
|
||||
logger.Info("Failed to create request", "error", err)
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func createInitialFrame(val OpenTsdbCommon, length int, refID string) *data.Frame {
|
||||
labels := data.Labels{}
|
||||
for label, value := range val.Tags {
|
||||
labels[label] = value
|
||||
}
|
||||
|
||||
tagKeys := make([]string, 0, len(val.Tags)+len(val.AggregateTags))
|
||||
for tagKey := range val.Tags {
|
||||
tagKeys = append(tagKeys, tagKey)
|
||||
}
|
||||
sort.Strings(tagKeys)
|
||||
tagKeys = append(tagKeys, val.AggregateTags...)
|
||||
|
||||
frame := data.NewFrameOfFieldTypes(val.Metric, length, data.FieldTypeTime, data.FieldTypeFloat64)
|
||||
frame.Meta = &data.FrameMeta{
|
||||
Type: data.FrameTypeTimeSeriesMulti,
|
||||
TypeVersion: data.FrameTypeVersion{0, 1},
|
||||
Custom: map[string]any{"tagKeys": tagKeys},
|
||||
}
|
||||
frame.RefID = refID
|
||||
timeField := frame.Fields[0]
|
||||
timeField.Name = data.TimeSeriesTimeFieldName
|
||||
dataField := frame.Fields[1]
|
||||
dataField.Name = val.Metric
|
||||
dataField.Labels = labels
|
||||
|
||||
return frame
|
||||
}
|
||||
|
||||
// Parse response function for OpenTSDB version 2.4
|
||||
func parseResponse24(responseData []OpenTsdbResponse24, refID string, frames data.Frames) data.Frames {
|
||||
for _, val := range responseData {
|
||||
frame := createInitialFrame(val.OpenTsdbCommon, len(val.DataPoints), refID)
|
||||
|
||||
for i, point := range val.DataPoints {
|
||||
frame.SetRow(i, time.Unix(int64(point[0]), 0).UTC(), point[1])
|
||||
}
|
||||
|
||||
frames = append(frames, frame)
|
||||
}
|
||||
|
||||
return frames
|
||||
}
|
||||
|
||||
// Parse response function for OpenTSDB versions < 2.4
|
||||
func parseResponseLT24(responseData []OpenTsdbResponse, refID string, frames data.Frames) (data.Frames, error) {
|
||||
for _, val := range responseData {
|
||||
frame := createInitialFrame(val.OpenTsdbCommon, len(val.DataPoints), refID)
|
||||
|
||||
// Order the timestamps in ascending order to avoid issues like https://github.com/grafana/grafana/issues/38729
|
||||
timestamps := make([]string, 0, len(val.DataPoints))
|
||||
for timestamp := range val.DataPoints {
|
||||
timestamps = append(timestamps, timestamp)
|
||||
}
|
||||
sort.Strings(timestamps)
|
||||
|
||||
for i, timeString := range timestamps {
|
||||
timestamp, err := strconv.ParseInt(timeString, 10, 64)
|
||||
if err != nil {
|
||||
logger.Info("Failed to unmarshal opentsdb timestamp", "timestamp", timeString)
|
||||
return frames, err
|
||||
}
|
||||
frame.SetRow(i, time.Unix(timestamp, 0).UTC(), val.DataPoints[timeString])
|
||||
}
|
||||
|
||||
frames = append(frames, frame)
|
||||
}
|
||||
|
||||
return frames, nil
|
||||
}
|
||||
|
||||
func (s *Service) parseResponse(logger log.Logger, res *http.Response, refID string, tsdbVersion float32) (*backend.QueryDataResponse, error) {
|
||||
resp := backend.NewQueryDataResponse()
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
if err := res.Body.Close(); err != nil {
|
||||
logger.Warn("Failed to close response body", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if res.StatusCode/100 != 2 {
|
||||
logger.Info("Request failed", "status", res.Status, "body", string(body))
|
||||
return nil, fmt.Errorf("request failed, status: %s", res.Status)
|
||||
}
|
||||
|
||||
frames := data.Frames{}
|
||||
|
||||
var responseData []OpenTsdbResponse
|
||||
var responseData24 []OpenTsdbResponse24
|
||||
if tsdbVersion == 4 {
|
||||
err = json.Unmarshal(body, &responseData24)
|
||||
if err != nil {
|
||||
logger.Info("Failed to unmarshal opentsdb response", "error", err, "status", res.Status, "body", string(body))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
frames = parseResponse24(responseData24, refID, frames)
|
||||
} else {
|
||||
err = json.Unmarshal(body, &responseData)
|
||||
if err != nil {
|
||||
logger.Info("Failed to unmarshal opentsdb response", "error", err, "status", res.Status, "body", string(body))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
frames, err = parseResponseLT24(responseData, refID, frames)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
result := resp.Responses[refID]
|
||||
result.Frames = frames
|
||||
resp.Responses[refID] = result
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *Service) buildMetric(query backend.DataQuery) map[string]any {
|
||||
metric := make(map[string]any)
|
||||
|
||||
var model QueryModel
|
||||
if err := json.Unmarshal(query.JSON, &model); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Setting metric and aggregator
|
||||
metric["metric"] = model.Metric
|
||||
metric["aggregator"] = model.Aggregator
|
||||
|
||||
// Setting downsampling options
|
||||
if !model.DisableDownsampling {
|
||||
downsampleInterval := model.DownsampleInterval
|
||||
if downsampleInterval == "" {
|
||||
if ms := query.Interval.Milliseconds(); ms > 0 {
|
||||
downsampleInterval = FormatDownsampleInterval(ms)
|
||||
} else {
|
||||
downsampleInterval = "1m"
|
||||
}
|
||||
} else if strings.Contains(downsampleInterval, ".") && strings.HasSuffix(downsampleInterval, "s") {
|
||||
if val, err := strconv.ParseFloat(strings.TrimSuffix(downsampleInterval, "s"), 64); err == nil {
|
||||
downsampleInterval = strconv.FormatInt(int64(val*1000), 10) + "ms"
|
||||
}
|
||||
}
|
||||
|
||||
downsample := downsampleInterval + "-" + model.DownsampleAggregator
|
||||
if model.DownsampleFillPolicy != "" && model.DownsampleFillPolicy != "none" {
|
||||
metric["downsample"] = downsample + "-" + model.DownsampleFillPolicy
|
||||
} else {
|
||||
metric["downsample"] = downsample
|
||||
}
|
||||
}
|
||||
|
||||
// Setting rate options
|
||||
if model.ShouldComputeRate {
|
||||
metric["rate"] = true
|
||||
rateOptions := make(map[string]any)
|
||||
rateOptions["counter"] = model.IsCounter
|
||||
|
||||
var counterMax *float64
|
||||
if model.CounterMax != "" {
|
||||
if val, err := strconv.ParseFloat(model.CounterMax, 64); err == nil {
|
||||
counterMax = &val
|
||||
}
|
||||
}
|
||||
if counterMax != nil {
|
||||
rateOptions["counterMax"] = *counterMax
|
||||
}
|
||||
|
||||
var counterResetValue *float64
|
||||
if model.CounterResetValue != "" {
|
||||
if val, err := strconv.ParseFloat(model.CounterResetValue, 64); err == nil {
|
||||
counterResetValue = &val
|
||||
}
|
||||
}
|
||||
if counterResetValue != nil {
|
||||
rateOptions["resetValue"] = *counterResetValue
|
||||
}
|
||||
|
||||
if counterMax == nil && (counterResetValue == nil || *counterResetValue == 0) {
|
||||
rateOptions["dropResets"] = true
|
||||
}
|
||||
|
||||
metric["rateOptions"] = rateOptions
|
||||
}
|
||||
|
||||
// Setting tags
|
||||
if len(model.Tags) > 0 {
|
||||
metric["tags"] = model.Tags
|
||||
}
|
||||
|
||||
// Setting filters
|
||||
if len(model.Filters) > 0 {
|
||||
metric["filters"] = model.Filters
|
||||
}
|
||||
|
||||
if model.ExplicitTags {
|
||||
metric["explicitTags"] = true
|
||||
}
|
||||
|
||||
return metric
|
||||
}
|
||||
|
||||
func (s *Service) getDSInfo(ctx context.Context, pluginCtx backend.PluginContext) (*datasourceInfo, error) {
|
||||
i, err := s.im.Get(ctx, pluginCtx)
|
||||
if err != nil {
|
||||
|
||||
@@ -71,8 +71,6 @@ func TestCheckHealth(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildMetric(t *testing.T) {
|
||||
service := &Service{}
|
||||
|
||||
t.Run("Metric with no downsampleInterval should use query interval", func(t *testing.T) {
|
||||
query := backend.DataQuery{
|
||||
JSON: []byte(`
|
||||
@@ -88,7 +86,7 @@ func TestBuildMetric(t *testing.T) {
|
||||
Interval: 30 * time.Second,
|
||||
}
|
||||
|
||||
metric := service.buildMetric(query)
|
||||
metric := BuildMetric(query)
|
||||
require.Equal(t, "30s-avg", metric["downsample"], "should use query interval formatted as seconds")
|
||||
})
|
||||
|
||||
@@ -106,7 +104,7 @@ func TestBuildMetric(t *testing.T) {
|
||||
),
|
||||
}
|
||||
|
||||
metric := service.buildMetric(query)
|
||||
metric := BuildMetric(query)
|
||||
require.Equal(t, "500ms-avg", metric["downsample"], "should convert 0.5s to 500ms")
|
||||
})
|
||||
|
||||
@@ -125,7 +123,7 @@ func TestBuildMetric(t *testing.T) {
|
||||
Interval: 500 * time.Millisecond,
|
||||
}
|
||||
|
||||
metric := service.buildMetric(query)
|
||||
metric := BuildMetric(query)
|
||||
require.Equal(t, "500ms-avg", metric["downsample"], "should use query interval formatted as milliseconds")
|
||||
})
|
||||
|
||||
@@ -144,7 +142,7 @@ func TestBuildMetric(t *testing.T) {
|
||||
Interval: 5 * time.Minute,
|
||||
}
|
||||
|
||||
metric := service.buildMetric(query)
|
||||
metric := BuildMetric(query)
|
||||
require.Equal(t, "5m-sum", metric["downsample"], "should use query interval formatted as minutes")
|
||||
})
|
||||
|
||||
@@ -163,7 +161,7 @@ func TestBuildMetric(t *testing.T) {
|
||||
Interval: 2 * time.Hour,
|
||||
}
|
||||
|
||||
metric := service.buildMetric(query)
|
||||
metric := BuildMetric(query)
|
||||
require.Equal(t, "2h-max", metric["downsample"], "should use query interval formatted as hours")
|
||||
})
|
||||
|
||||
@@ -182,7 +180,7 @@ func TestBuildMetric(t *testing.T) {
|
||||
Interval: 48 * time.Hour,
|
||||
}
|
||||
|
||||
metric := service.buildMetric(query)
|
||||
metric := BuildMetric(query)
|
||||
require.Equal(t, "2d-min", metric["downsample"], "should use query interval formatted as days")
|
||||
})
|
||||
|
||||
@@ -201,7 +199,7 @@ func TestBuildMetric(t *testing.T) {
|
||||
),
|
||||
}
|
||||
|
||||
metric := service.buildMetric(query)
|
||||
metric := BuildMetric(query)
|
||||
require.True(t, metric["explicitTags"].(bool), "explicitTags should be true")
|
||||
|
||||
metricTags := metric["tags"].(map[string]any)
|
||||
@@ -223,16 +221,14 @@ func TestBuildMetric(t *testing.T) {
|
||||
),
|
||||
}
|
||||
|
||||
metric := service.buildMetric(query)
|
||||
metric := BuildMetric(query)
|
||||
require.Nil(t, metric["explicitTags"], "explicitTags should not be present when false")
|
||||
})
|
||||
}
|
||||
|
||||
func TestOpenTsdbExecutor(t *testing.T) {
|
||||
service := &Service{}
|
||||
|
||||
t.Run("create request", func(t *testing.T) {
|
||||
req, err := service.createRequest(context.Background(), logger, &datasourceInfo{}, OpenTsdbQuery{})
|
||||
req, err := CreateRequest(context.Background(), logger, &datasourceInfo{}, OpenTsdbQuery{})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "POST", req.Method)
|
||||
@@ -247,7 +243,7 @@ func TestOpenTsdbExecutor(t *testing.T) {
|
||||
response := `{ invalid }`
|
||||
|
||||
tsdbVersion := float32(4)
|
||||
result, err := service.parseResponse(logger, &http.Response{Body: io.NopCloser(strings.NewReader(response))}, "A", tsdbVersion)
|
||||
result, err := ParseResponse(logger, &http.Response{Body: io.NopCloser(strings.NewReader(response))}, "A", tsdbVersion)
|
||||
require.Nil(t, result)
|
||||
require.Error(t, err)
|
||||
})
|
||||
@@ -284,7 +280,7 @@ func TestOpenTsdbExecutor(t *testing.T) {
|
||||
|
||||
resp := http.Response{Body: io.NopCloser(strings.NewReader(response))}
|
||||
resp.StatusCode = 200
|
||||
result, err := service.parseResponse(logger, &resp, "A", tsdbVersion)
|
||||
result, err := ParseResponse(logger, &resp, "A", tsdbVersion)
|
||||
require.NoError(t, err)
|
||||
|
||||
frame := result.Responses["A"]
|
||||
@@ -326,7 +322,7 @@ func TestOpenTsdbExecutor(t *testing.T) {
|
||||
|
||||
resp := http.Response{Body: io.NopCloser(strings.NewReader(response))}
|
||||
resp.StatusCode = 200
|
||||
result, err := service.parseResponse(logger, &resp, "A", tsdbVersion)
|
||||
result, err := ParseResponse(logger, &resp, "A", tsdbVersion)
|
||||
require.NoError(t, err)
|
||||
|
||||
frame := result.Responses["A"]
|
||||
@@ -399,7 +395,7 @@ func TestOpenTsdbExecutor(t *testing.T) {
|
||||
|
||||
resp := http.Response{Body: io.NopCloser(strings.NewReader(response))}
|
||||
resp.StatusCode = 200
|
||||
result, err := service.parseResponse(logger, &resp, "A", tsdbVersion)
|
||||
result, err := ParseResponse(logger, &resp, "A", tsdbVersion)
|
||||
require.NoError(t, err)
|
||||
|
||||
frame := result.Responses["A"]
|
||||
@@ -444,7 +440,7 @@ func TestOpenTsdbExecutor(t *testing.T) {
|
||||
|
||||
resp := http.Response{Body: io.NopCloser(strings.NewReader(response))}
|
||||
resp.StatusCode = 200
|
||||
result, err := service.parseResponse(logger, &resp, myRefid, tsdbVersion)
|
||||
result, err := ParseResponse(logger, &resp, myRefid, tsdbVersion)
|
||||
require.NoError(t, err)
|
||||
|
||||
if diff := cmp.Diff(testFrame, result.Responses[myRefid].Frames[0], data.FrameTestCompareOptions()...); diff != "" {
|
||||
@@ -473,7 +469,7 @@ func TestOpenTsdbExecutor(t *testing.T) {
|
||||
|
||||
resp := http.Response{Body: io.NopCloser(strings.NewReader(response))}
|
||||
resp.StatusCode = 200
|
||||
result, err := service.parseResponse(logger, &resp, "A", tsdbVersion)
|
||||
result, err := ParseResponse(logger, &resp, "A", tsdbVersion)
|
||||
require.NoError(t, err)
|
||||
|
||||
frame := result.Responses["A"].Frames[0]
|
||||
@@ -505,7 +501,7 @@ func TestOpenTsdbExecutor(t *testing.T) {
|
||||
),
|
||||
}
|
||||
|
||||
metric := service.buildMetric(query)
|
||||
metric := BuildMetric(query)
|
||||
|
||||
require.Len(t, metric, 3)
|
||||
require.Equal(t, "cpu.average.percent", metric["metric"])
|
||||
@@ -527,7 +523,7 @@ func TestOpenTsdbExecutor(t *testing.T) {
|
||||
),
|
||||
}
|
||||
|
||||
metric := service.buildMetric(query)
|
||||
metric := BuildMetric(query)
|
||||
|
||||
require.Len(t, metric, 2)
|
||||
require.Equal(t, "cpu.average.percent", metric["metric"])
|
||||
@@ -548,7 +544,7 @@ func TestOpenTsdbExecutor(t *testing.T) {
|
||||
),
|
||||
}
|
||||
|
||||
metric := service.buildMetric(query)
|
||||
metric := BuildMetric(query)
|
||||
|
||||
require.Len(t, metric, 3)
|
||||
require.Equal(t, "cpu.average.percent", metric["metric"])
|
||||
@@ -574,7 +570,7 @@ func TestOpenTsdbExecutor(t *testing.T) {
|
||||
),
|
||||
}
|
||||
|
||||
metric := service.buildMetric(query)
|
||||
metric := BuildMetric(query)
|
||||
|
||||
require.Len(t, metric, 3)
|
||||
require.Equal(t, "cpu.average.percent", metric["metric"])
|
||||
@@ -605,7 +601,7 @@ func TestOpenTsdbExecutor(t *testing.T) {
|
||||
),
|
||||
}
|
||||
|
||||
metric := service.buildMetric(query)
|
||||
metric := BuildMetric(query)
|
||||
|
||||
require.Len(t, metric, 5)
|
||||
require.Equal(t, "cpu.average.percent", metric["metric"])
|
||||
@@ -640,7 +636,7 @@ func TestOpenTsdbExecutor(t *testing.T) {
|
||||
),
|
||||
}
|
||||
|
||||
metric := service.buildMetric(query)
|
||||
metric := BuildMetric(query)
|
||||
t.Log(metric)
|
||||
|
||||
require.Len(t, metric, 5)
|
||||
|
||||
@@ -10,8 +10,9 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
_ backend.QueryDataHandler = (*Datasource)(nil)
|
||||
_ backend.CheckHealthHandler = (*Datasource)(nil)
|
||||
_ backend.CheckHealthHandler = (*Datasource)(nil)
|
||||
_ backend.CallResourceHandler = (*Datasource)(nil)
|
||||
_ backend.QueryDataHandler = (*Datasource)(nil)
|
||||
)
|
||||
|
||||
type Datasource struct {
|
||||
@@ -24,10 +25,14 @@ func NewDatasource(context.Context, backend.DataSourceInstanceSettings) (instanc
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *Datasource) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) {
|
||||
return d.Service.QueryData(ctx, req)
|
||||
}
|
||||
|
||||
func (d *Datasource) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) {
|
||||
return d.Service.CheckHealth(ctx, req)
|
||||
}
|
||||
|
||||
func (d *Datasource) CallResource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error {
|
||||
return d.Service.CallResource(ctx, req, sender)
|
||||
}
|
||||
|
||||
func (d *Datasource) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) {
|
||||
return d.Service.QueryData(ctx, req)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
package opentsdb
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/log"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
)
|
||||
|
||||
func FormatDownsampleInterval(ms int64) string {
|
||||
@@ -29,3 +43,262 @@ func FormatDownsampleInterval(ms int64) string {
|
||||
days := int64(duration / (24 * time.Hour))
|
||||
return strconv.FormatInt(days, 10) + "d"
|
||||
}
|
||||
|
||||
func BuildMetric(query backend.DataQuery) map[string]any {
|
||||
metric := make(map[string]any)
|
||||
|
||||
var model QueryModel
|
||||
if err := json.Unmarshal(query.JSON, &model); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Setting metric and aggregator
|
||||
metric["metric"] = model.Metric
|
||||
metric["aggregator"] = model.Aggregator
|
||||
|
||||
// Setting downsampling options
|
||||
if !model.DisableDownsampling {
|
||||
downsampleInterval := model.DownsampleInterval
|
||||
if downsampleInterval == "" {
|
||||
if ms := query.Interval.Milliseconds(); ms > 0 {
|
||||
downsampleInterval = FormatDownsampleInterval(ms)
|
||||
} else {
|
||||
downsampleInterval = "1m"
|
||||
}
|
||||
} else if strings.Contains(downsampleInterval, ".") && strings.HasSuffix(downsampleInterval, "s") {
|
||||
if val, err := strconv.ParseFloat(strings.TrimSuffix(downsampleInterval, "s"), 64); err == nil {
|
||||
downsampleInterval = strconv.FormatInt(int64(val*1000), 10) + "ms"
|
||||
}
|
||||
}
|
||||
|
||||
downsample := downsampleInterval + "-" + model.DownsampleAggregator
|
||||
if model.DownsampleFillPolicy != "" && model.DownsampleFillPolicy != "none" {
|
||||
metric["downsample"] = downsample + "-" + model.DownsampleFillPolicy
|
||||
} else {
|
||||
metric["downsample"] = downsample
|
||||
}
|
||||
}
|
||||
|
||||
// Setting rate options
|
||||
if model.ShouldComputeRate {
|
||||
metric["rate"] = true
|
||||
rateOptions := make(map[string]any)
|
||||
rateOptions["counter"] = model.IsCounter
|
||||
|
||||
var counterMax *float64
|
||||
if model.CounterMax != "" {
|
||||
if val, err := strconv.ParseFloat(model.CounterMax, 64); err == nil {
|
||||
counterMax = &val
|
||||
}
|
||||
}
|
||||
if counterMax != nil {
|
||||
rateOptions["counterMax"] = *counterMax
|
||||
}
|
||||
|
||||
var counterResetValue *float64
|
||||
if model.CounterResetValue != "" {
|
||||
if val, err := strconv.ParseFloat(model.CounterResetValue, 64); err == nil {
|
||||
counterResetValue = &val
|
||||
}
|
||||
}
|
||||
if counterResetValue != nil {
|
||||
rateOptions["resetValue"] = *counterResetValue
|
||||
}
|
||||
|
||||
if counterMax == nil && (counterResetValue == nil || *counterResetValue == 0) {
|
||||
rateOptions["dropResets"] = true
|
||||
}
|
||||
|
||||
metric["rateOptions"] = rateOptions
|
||||
}
|
||||
|
||||
// Setting tags
|
||||
if len(model.Tags) > 0 {
|
||||
metric["tags"] = model.Tags
|
||||
}
|
||||
|
||||
// Setting filters
|
||||
if len(model.Filters) > 0 {
|
||||
metric["filters"] = model.Filters
|
||||
}
|
||||
|
||||
if model.ExplicitTags {
|
||||
metric["explicitTags"] = true
|
||||
}
|
||||
|
||||
return metric
|
||||
}
|
||||
|
||||
func CreateRequest(ctx context.Context, logger log.Logger, dsInfo *datasourceInfo, data OpenTsdbQuery) (*http.Request, error) {
|
||||
u, err := url.Parse(dsInfo.URL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.Path = path.Join(u.Path, "api/query")
|
||||
if dsInfo.TSDBVersion == 4 {
|
||||
queryParams := u.Query()
|
||||
queryParams.Set("arrays", "true")
|
||||
u.RawQuery = queryParams.Encode()
|
||||
}
|
||||
|
||||
postData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
logger.Info("Failed marshaling data", "error", err)
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), strings.NewReader(string(postData)))
|
||||
if err != nil {
|
||||
logger.Info("Failed to create request", "error", err)
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func DecodeResponseBody(res *http.Response, logger log.Logger) ([]byte, error) {
|
||||
encoding := res.Header.Get("Content-Encoding")
|
||||
var reader io.Reader
|
||||
|
||||
switch encoding {
|
||||
case "gzip":
|
||||
gzipReader, err := gzip.NewReader(res.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create gzip reader: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := gzipReader.Close(); err != nil {
|
||||
logger.Warn("Failed to close gzip reader", "error", err)
|
||||
}
|
||||
}()
|
||||
reader = gzipReader
|
||||
default:
|
||||
reader = res.Body
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func CreateDataFrame(val OpenTsdbCommon, length int, refID string) *data.Frame {
|
||||
labels := data.Labels{}
|
||||
for label, value := range val.Tags {
|
||||
labels[label] = value
|
||||
}
|
||||
|
||||
tagKeys := make([]string, 0, len(val.Tags)+len(val.AggregateTags))
|
||||
for tagKey := range val.Tags {
|
||||
tagKeys = append(tagKeys, tagKey)
|
||||
}
|
||||
sort.Strings(tagKeys)
|
||||
tagKeys = append(tagKeys, val.AggregateTags...)
|
||||
|
||||
frame := data.NewFrameOfFieldTypes(val.Metric, length, data.FieldTypeTime, data.FieldTypeFloat64)
|
||||
frame.Meta = &data.FrameMeta{
|
||||
Type: data.FrameTypeTimeSeriesMulti,
|
||||
TypeVersion: data.FrameTypeVersion{0, 1},
|
||||
Custom: map[string]any{"tagKeys": tagKeys},
|
||||
}
|
||||
frame.RefID = refID
|
||||
timeField := frame.Fields[0]
|
||||
timeField.Name = data.TimeSeriesTimeFieldName
|
||||
dataField := frame.Fields[1]
|
||||
dataField.Name = val.Metric
|
||||
dataField.Labels = labels
|
||||
|
||||
return frame
|
||||
}
|
||||
|
||||
func ParseResponse(logger log.Logger, res *http.Response, refID string, tsdbVersion float32) (*backend.QueryDataResponse, error) {
|
||||
resp := backend.NewQueryDataResponse()
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
if err := res.Body.Close(); err != nil {
|
||||
logger.Warn("Failed to close response body", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if res.StatusCode/100 != 2 {
|
||||
logger.Info("Request failed", "status", res.Status, "body", string(body))
|
||||
return nil, fmt.Errorf("request failed, status: %s", res.Status)
|
||||
}
|
||||
|
||||
frames := data.Frames{}
|
||||
|
||||
var responseData []OpenTsdbResponse
|
||||
var responseData24 []OpenTsdbResponse24
|
||||
if tsdbVersion == 4 {
|
||||
err = json.Unmarshal(body, &responseData24)
|
||||
if err != nil {
|
||||
logger.Info("Failed to unmarshal opentsdb response", "error", err, "status", res.Status, "body", string(body))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
frames = ParseResponse24(responseData24, refID, frames)
|
||||
} else {
|
||||
err = json.Unmarshal(body, &responseData)
|
||||
if err != nil {
|
||||
logger.Info("Failed to unmarshal opentsdb response", "error", err, "status", res.Status, "body", string(body))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
frames, err = ParseResponseLT24(responseData, refID, frames)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
result := resp.Responses[refID]
|
||||
result.Frames = frames
|
||||
resp.Responses[refID] = result
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func ParseResponse24(responseData []OpenTsdbResponse24, refID string, frames data.Frames) data.Frames {
|
||||
for _, val := range responseData {
|
||||
frame := CreateDataFrame(val.OpenTsdbCommon, len(val.DataPoints), refID)
|
||||
|
||||
for i, point := range val.DataPoints {
|
||||
frame.SetRow(i, time.Unix(int64(point[0]), 0).UTC(), point[1])
|
||||
}
|
||||
|
||||
frames = append(frames, frame)
|
||||
}
|
||||
|
||||
return frames
|
||||
}
|
||||
|
||||
func ParseResponseLT24(responseData []OpenTsdbResponse, refID string, frames data.Frames) (data.Frames, error) {
|
||||
for _, val := range responseData {
|
||||
frame := CreateDataFrame(val.OpenTsdbCommon, len(val.DataPoints), refID)
|
||||
|
||||
// Order the timestamps in ascending order to avoid issues like https://github.com/grafana/grafana/issues/38729
|
||||
timestamps := make([]string, 0, len(val.DataPoints))
|
||||
for timestamp := range val.DataPoints {
|
||||
timestamps = append(timestamps, timestamp)
|
||||
}
|
||||
sort.Strings(timestamps)
|
||||
|
||||
for i, timeString := range timestamps {
|
||||
timestamp, err := strconv.ParseInt(timeString, 10, 64)
|
||||
if err != nil {
|
||||
logger.Info("Failed to unmarshal opentsdb timestamp", "timestamp", timeString)
|
||||
return frames, err
|
||||
}
|
||||
frame.SetRow(i, time.Unix(timestamp, 0).UTC(), val.DataPoints[timeString])
|
||||
}
|
||||
|
||||
frames = append(frames, frame)
|
||||
}
|
||||
|
||||
return frames, nil
|
||||
}
|
||||
|
||||
@@ -28,7 +28,23 @@ export const HelpTopBarButton = memo(function HelpTopBarButton({ isSmallScreen }
|
||||
|
||||
const interactiveLearningPluginId = getInteractiveLearningPluginId(availableComponents);
|
||||
|
||||
if (isSmallScreen || !enrichedHelpNode.hideFromTabs || interactiveLearningPluginId === undefined) {
|
||||
// Check if the component is actually registered, not just if the plugin exists
|
||||
// This allows plugins to conditionally register their sidebar component (e.g., for A/B testing)
|
||||
const componentId = interactiveLearningPluginId
|
||||
? getComponentIdFromComponentMeta(interactiveLearningPluginId, 'Interactive learning')
|
||||
: undefined;
|
||||
|
||||
// Show native help dropdown if:
|
||||
// - Screen is small (mobile/responsive), OR
|
||||
// - hideFromTabs is false, OR
|
||||
// - Interactive learning plugin is not installed, OR
|
||||
// - Interactive learning component is not registered (plugin may exist but chose not to register)
|
||||
if (
|
||||
isSmallScreen ||
|
||||
!enrichedHelpNode.hideFromTabs ||
|
||||
interactiveLearningPluginId === undefined ||
|
||||
componentId === undefined
|
||||
) {
|
||||
return (
|
||||
<Dropdown overlay={() => <TopNavBarMenu node={enrichedHelpNode} />} placement="bottom-end">
|
||||
<ToolbarButton
|
||||
@@ -41,7 +57,6 @@ export const HelpTopBarButton = memo(function HelpTopBarButton({ isSmallScreen }
|
||||
);
|
||||
}
|
||||
|
||||
const componentId = getComponentIdFromComponentMeta(interactiveLearningPluginId, 'Interactive learning');
|
||||
const isOpen = dockedComponentId === componentId;
|
||||
|
||||
return (
|
||||
|
||||
@@ -93,7 +93,6 @@ export const SingleTopBar = memo(function SingleTopBar({
|
||||
justifyContent={'flex-end'}
|
||||
flex={1}
|
||||
data-testid={!showToolbarLevel ? Components.NavToolbar.container : undefined}
|
||||
minWidth={{ xs: 'unset', lg: 0 }}
|
||||
>
|
||||
<TopBarExtensionPoint />
|
||||
<TopSearchBarCommandPaletteTrigger />
|
||||
|
||||
@@ -58,6 +58,8 @@ export const triageScene = new EmbeddedSceneWithContext({
|
||||
baseFilters: [],
|
||||
layout: 'combobox',
|
||||
expressionBuilder: prometheusExpressionBuilder,
|
||||
// Filter out __name__ from options as this is the metric name not a filterable label
|
||||
tagKeyRegexFilter: /^(?!__name__$).*/,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -33,7 +33,7 @@ export function convertToWorkbenchRows(series: DataFrame[], groupBy: string[] =
|
||||
const ruleUIDIndex = fieldIndex.get('grafana_rule_uid');
|
||||
|
||||
// These should always exist due to validation above, but handle gracefully
|
||||
if (!alertnameIndex || !folderIndex || !ruleUIDIndex) {
|
||||
if (alertnameIndex === undefined || folderIndex === undefined || ruleUIDIndex === undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,12 @@ import { AdHocFilterWithLabels } from '@grafana/scenes';
|
||||
* Custom expression builder for Prometheus that properly handles regex operators.
|
||||
* Unlike the default builder, this doesn't escape regex metacharacters when using =~ or !~
|
||||
* operators, allowing users to enter raw regex patterns.
|
||||
*
|
||||
* Note: __name__ is excluded from filters as it represents the metric name itself,
|
||||
* not a label, and should be handled separately in query construction.
|
||||
*/
|
||||
export function prometheusExpressionBuilder(filters: AdHocFilterWithLabels[]): string {
|
||||
const applicableFilters = filters.filter((f) => !f.nonApplicable && !f.hidden);
|
||||
const applicableFilters = filters.filter((f) => !f.nonApplicable && !f.hidden && f.key !== '__name__');
|
||||
return applicableFilters.map(renderFilter).join(',');
|
||||
}
|
||||
|
||||
|
||||
@@ -59,9 +59,9 @@ export class DashboardEditableElement implements EditableDashboardElement {
|
||||
};
|
||||
}
|
||||
|
||||
public getOutlineChildren(): SceneObject[] {
|
||||
public getOutlineChildren(isEditing: boolean): SceneObject[] {
|
||||
const { $variables, body } = this.dashboard.state;
|
||||
return [$variables!, ...body.getOutlineChildren()];
|
||||
return isEditing ? [$variables!, ...body.getOutlineChildren()] : body.getOutlineChildren();
|
||||
}
|
||||
|
||||
public useEditPaneOptions = useEditPaneOptions.bind(this, this.dashboard);
|
||||
|
||||
@@ -51,7 +51,7 @@ function DashboardOutlineNode({ sceneObject, editPane, isEditing, depth, index }
|
||||
|
||||
const noTitleText = t('dashboard.outline.tree-item.no-title', '<no title>');
|
||||
|
||||
const children = editableElement.getOutlineChildren?.() ?? [];
|
||||
const children = editableElement.getOutlineChildren?.(isEditing) ?? [];
|
||||
const elementInfo = editableElement.getEditableElementInfo();
|
||||
const instanceName = elementInfo.instanceName === '' ? noTitleText : elementInfo.instanceName;
|
||||
const outlineRename = useOutlineRename(editableElement, isEditing);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { debounce } from 'lodash';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback, useId, useMemo, useState } from 'react';
|
||||
import { useSessionStorage } from 'react-use';
|
||||
|
||||
import { GrafanaTheme2, PanelData } from '@grafana/data';
|
||||
@@ -8,7 +8,7 @@ import { selectors } from '@grafana/e2e-selectors';
|
||||
import { t, Trans } from '@grafana/i18n';
|
||||
import { config, reportInteraction } from '@grafana/runtime';
|
||||
import { VizPanel } from '@grafana/scenes';
|
||||
import { Button, FilterInput, ScrollContainer, Stack, Tab, TabContent, TabsBar, useStyles2 } from '@grafana/ui';
|
||||
import { Button, Field, FilterInput, ScrollContainer, Stack, Tab, TabContent, TabsBar, useStyles2 } from '@grafana/ui';
|
||||
import { LS_VISUALIZATION_SELECT_TAB_KEY } from 'app/core/constants';
|
||||
import { VisualizationSelectPaneTab } from 'app/features/dashboard/components/PanelEditor/types';
|
||||
import { VisualizationSuggestions } from 'app/features/panel/components/VizTypePicker/VisualizationSuggestions';
|
||||
@@ -44,6 +44,7 @@ const getTabs = (): Array<{ label: string; value: VisualizationSelectPaneTab }>
|
||||
export function PanelVizTypePicker({ panel, data, onChange, onClose, showBackButton }: Props) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const panelModel = useMemo(() => new PanelModelCompatibilityWrapper(panel), [panel]);
|
||||
const filterId = useId();
|
||||
|
||||
/** SEARCH */
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
@@ -101,26 +102,36 @@ export function PanelVizTypePicker({ panel, data, onChange, onClose, showBackBut
|
||||
)}
|
||||
{listMode === VisualizationSelectPaneTab.Visualizations && (
|
||||
<Stack gap={1} direction="column">
|
||||
<Stack direction="row" gap={1}>
|
||||
{showBackButton && (
|
||||
<Button
|
||||
aria-label={t('dashboard-scene.panel-viz-type-picker.title-close', 'Close')}
|
||||
fill="text"
|
||||
variant="secondary"
|
||||
icon="arrow-left"
|
||||
data-testid={selectors.components.PanelEditor.toggleVizPicker}
|
||||
onClick={onClose}
|
||||
>
|
||||
<Trans i18nKey="dashboard-scene.panel-viz-type-picker.button.close">Back</Trans>
|
||||
</Button>
|
||||
)}
|
||||
<FilterInput
|
||||
className={styles.filter}
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder={t('dashboard-scene.panel-viz-type-picker.placeholder-search-for', 'Search for...')}
|
||||
/>
|
||||
</Stack>
|
||||
<Field
|
||||
tabIndex={0}
|
||||
className={styles.searchField}
|
||||
noMargin
|
||||
htmlFor={filterId}
|
||||
aria-label={t('dashboard-scene.panel-viz-type-picker.placeholder-search-for', 'Search for...')}
|
||||
>
|
||||
<Stack direction="row" gap={1}>
|
||||
{showBackButton && (
|
||||
<Button
|
||||
aria-label={t('dashboard-scene.panel-viz-type-picker.title-close', 'Close')}
|
||||
fill="text"
|
||||
variant="secondary"
|
||||
icon="arrow-left"
|
||||
data-testid={selectors.components.PanelEditor.toggleVizPicker}
|
||||
onClick={onClose}
|
||||
>
|
||||
<Trans i18nKey="dashboard-scene.panel-viz-type-picker.button.close">Back</Trans>
|
||||
</Button>
|
||||
)}
|
||||
<FilterInput
|
||||
id={filterId}
|
||||
className={styles.filter}
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder={t('dashboard-scene.panel-viz-type-picker.placeholder-search-for', 'Search for...')}
|
||||
/>
|
||||
</Stack>
|
||||
</Field>
|
||||
|
||||
<VizTypePicker
|
||||
pluginId={panel.state.pluginId}
|
||||
searchQuery={searchQuery}
|
||||
@@ -143,9 +154,8 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
height: '100%',
|
||||
gap: theme.spacing(2),
|
||||
}),
|
||||
searchRow: css({
|
||||
display: 'flex',
|
||||
marginBottom: theme.spacing(2),
|
||||
searchField: css({
|
||||
marginTop: theme.spacing(0.5), // input glow with the boundary without this
|
||||
}),
|
||||
tabs: css({
|
||||
width: '100%',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as H from 'history';
|
||||
|
||||
import { CoreApp, DataQueryRequest, NavIndex, NavModelItem, locationUtil } from '@grafana/data';
|
||||
import { CoreApp, DataQueryRequest, locationUtil, NavIndex, NavModelItem } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { config, locationService, RefreshEvent } from '@grafana/runtime';
|
||||
import {
|
||||
@@ -32,7 +32,7 @@ import { PanelModel } from 'app/features/dashboard/state/PanelModel';
|
||||
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
|
||||
import { DashboardJson } from 'app/features/manage-dashboards/types';
|
||||
import { VariablesChanged } from 'app/features/variables/types';
|
||||
import { DashboardMeta, KioskMode, SaveDashboardResponseDTO, DashboardDTO } from 'app/types/dashboard';
|
||||
import { DashboardDTO, DashboardMeta, KioskMode, SaveDashboardResponseDTO } from 'app/types/dashboard';
|
||||
import { ShowConfirmModalEvent } from 'app/types/events';
|
||||
|
||||
import {
|
||||
@@ -323,17 +323,46 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
|
||||
return;
|
||||
}
|
||||
|
||||
appEvents.publish(
|
||||
new ShowConfirmModalEvent({
|
||||
title: t('dashboard-scene.dashboard-scene.title.discard-changes-to-dashboard', 'Discard changes to dashboard?'),
|
||||
text: `You have unsaved changes to this dashboard. Are you sure you want to discard them?`,
|
||||
icon: 'trash-alt',
|
||||
yesText: 'Discard',
|
||||
onConfirm: () => {
|
||||
this.exitEditModeConfirmed();
|
||||
},
|
||||
})
|
||||
);
|
||||
if (config.featureToggles.dashboardNewLayouts) {
|
||||
appEvents.publish(
|
||||
new ShowConfirmModalEvent({
|
||||
title: t('dashboard-scene.dashboard-scene.modal.title.unsaved-changes', 'Unsaved changes'),
|
||||
text: t(
|
||||
'dashboard-scene.dashboard-scene.modal.text.save-changes-question',
|
||||
'Do you want to save your changes?'
|
||||
),
|
||||
icon: 'trash-alt',
|
||||
altActionText: t('dashboard-scene.dashboard-scene.modal.save', 'Save'),
|
||||
noText: t('dashboard-scene.dashboard-scene.modal.cancel', 'Cancel'),
|
||||
yesText: t('dashboard-scene.dashboard-scene.modal.discard', 'Discard'),
|
||||
yesButtonVariant: 'destructive',
|
||||
onAltAction: () => {
|
||||
this.openSaveDrawer({});
|
||||
},
|
||||
onConfirm: () => {
|
||||
this.exitEditModeConfirmed();
|
||||
},
|
||||
})
|
||||
);
|
||||
} else {
|
||||
appEvents.publish(
|
||||
new ShowConfirmModalEvent({
|
||||
title: t(
|
||||
'dashboard-scene.dashboard-scene.title.discard-changes-to-dashboard',
|
||||
'Discard changes to dashboard?'
|
||||
),
|
||||
icon: 'trash-alt',
|
||||
text: t(
|
||||
'dashboard-scene.dashboard-scene.title.unsaved-changes-question',
|
||||
'You have unsaved changes to this dashboard. Are you sure you want to discard them?'
|
||||
),
|
||||
yesText: t('dashboard-scene.dashboard-scene.modal.discard', 'Discard'),
|
||||
onConfirm: () => {
|
||||
this.exitEditModeConfirmed();
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private exitEditModeConfirmed(restoreInitialState = true) {
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { getPanelPlugin } from '@grafana/data/test';
|
||||
import { setPluginImportUtils } from '@grafana/runtime';
|
||||
import { SceneGridLayout, VizPanel, SceneVariableSet } from '@grafana/scenes';
|
||||
|
||||
import { activateFullSceneTree } from '../../utils/test-utils';
|
||||
import { DashboardScene } from '../DashboardScene';
|
||||
import { DashboardGridItem } from '../layout-default/DashboardGridItem';
|
||||
import { DefaultGridLayoutManager } from '../layout-default/DefaultGridLayoutManager';
|
||||
import { RowItem } from '../layout-rows/RowItem';
|
||||
import { RowsLayoutManager } from '../layout-rows/RowsLayoutManager';
|
||||
import { LayoutParent } from '../types/LayoutParent';
|
||||
|
||||
import { DashboardLayoutSelector } from './DashboardLayoutSelector';
|
||||
|
||||
const switchLayoutMock = jest.fn();
|
||||
|
||||
setPluginImportUtils({
|
||||
importPanelPlugin: (_) => Promise.resolve(getPanelPlugin({})),
|
||||
getPanelPluginFromCache: (_) => undefined,
|
||||
});
|
||||
|
||||
describe('DashboardLayoutSelector', () => {
|
||||
it('should show confirmation modal when switching layouts', async () => {
|
||||
const user = userEvent.setup();
|
||||
const scene = buildTestScene();
|
||||
const layoutManager = scene.state.body;
|
||||
(layoutManager.parent as LayoutParent).switchLayout = switchLayoutMock;
|
||||
|
||||
render(<DashboardLayoutSelector layoutManager={layoutManager} />);
|
||||
|
||||
await user.click(screen.getByLabelText('layout-selection-option-Tabs'));
|
||||
|
||||
const confirmButton = screen.getByRole('button', { name: 'Change layout' });
|
||||
|
||||
expect(confirmButton).toBeInTheDocument();
|
||||
|
||||
await user.click(confirmButton);
|
||||
expect(switchLayoutMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
const buildTestScene = () => {
|
||||
const scene = new DashboardScene({
|
||||
title: 'testScene',
|
||||
editable: true,
|
||||
$variables: new SceneVariableSet({
|
||||
variables: [],
|
||||
}),
|
||||
body: new RowsLayoutManager({
|
||||
rows: [
|
||||
new RowItem({
|
||||
title: 'Row 1',
|
||||
layout: new DefaultGridLayoutManager({
|
||||
grid: new SceneGridLayout({
|
||||
children: [
|
||||
new DashboardGridItem({
|
||||
body: new VizPanel({ key: 'panel-1', pluginId: 'text' }),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
activateFullSceneTree(scene);
|
||||
return scene;
|
||||
};
|
||||
+39
-19
@@ -1,8 +1,8 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { t } from '@grafana/i18n';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { RadioButtonGroup, Box } from '@grafana/ui';
|
||||
import { RadioButtonGroup, Box, ConfirmModal } from '@grafana/ui';
|
||||
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
|
||||
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface Props {
|
||||
export function DashboardLayoutSelector({ layoutManager }: Props) {
|
||||
const isGridLayout = layoutManager.descriptor.isGridLayout;
|
||||
const options = layoutRegistry.list().filter((layout) => layout.isGridLayout === isGridLayout);
|
||||
const [newLayout, setNewLayout] = useState<LayoutRegistryItem | undefined>();
|
||||
|
||||
const disableTabs = useMemo(() => {
|
||||
if (config.featureToggles.unlimitedLayoutsNesting) {
|
||||
@@ -36,16 +37,23 @@ export function DashboardLayoutSelector({ layoutManager }: Props) {
|
||||
return false;
|
||||
}, [layoutManager]);
|
||||
|
||||
const onChangeLayout = useCallback(
|
||||
(newLayout: LayoutRegistryItem) => {
|
||||
const layoutParent = layoutManager.parent;
|
||||
const onChangeLayout = useCallback((newLayout: LayoutRegistryItem) => setNewLayout(newLayout), []);
|
||||
|
||||
if (layoutParent && isLayoutParent(layoutParent)) {
|
||||
layoutParent.switchLayout(newLayout.createFromLayout(layoutManager));
|
||||
}
|
||||
},
|
||||
[layoutManager]
|
||||
);
|
||||
const onConfirmNewLayout = useCallback(() => {
|
||||
if (!newLayout) {
|
||||
return;
|
||||
}
|
||||
|
||||
const layoutParent = layoutManager.parent;
|
||||
|
||||
if (layoutParent && isLayoutParent(layoutParent)) {
|
||||
layoutParent.switchLayout(newLayout.createFromLayout(layoutManager));
|
||||
}
|
||||
|
||||
setNewLayout(undefined);
|
||||
}, [newLayout, layoutManager]);
|
||||
|
||||
const onDismissNewLayout = useCallback(() => setNewLayout(undefined), []);
|
||||
|
||||
const disabledOptions: LayoutRegistryItem[] = [];
|
||||
|
||||
@@ -66,15 +74,27 @@ export function DashboardLayoutSelector({ layoutManager }: Props) {
|
||||
});
|
||||
|
||||
return (
|
||||
<Box paddingBottom={2} display="flex" grow={1} alignItems="stretch" gap={2} direction={'column'}>
|
||||
<RadioButtonGroup
|
||||
fullWidth
|
||||
value={layoutManager.descriptor}
|
||||
options={radioOptions}
|
||||
onChange={onChangeLayout}
|
||||
disabledOptions={disabledOptions}
|
||||
<>
|
||||
<Box paddingBottom={2} display="flex" grow={1} alignItems="stretch" gap={2} direction={'column'}>
|
||||
<RadioButtonGroup
|
||||
fullWidth
|
||||
value={layoutManager.descriptor}
|
||||
options={radioOptions}
|
||||
onChange={onChangeLayout}
|
||||
disabledOptions={disabledOptions}
|
||||
/>
|
||||
</Box>
|
||||
<ConfirmModal
|
||||
isOpen={!!newLayout}
|
||||
title={t('dashboard.layout.panel.modal.title', 'Change layout')}
|
||||
body={t('dashboard.layout.panel.modal.body', 'Changing the layout will reset all panel positions and sizes.')}
|
||||
confirmText={t('dashboard.layout.panel.modal.confirm', 'Change layout')}
|
||||
dismissText={t('dashboard.layout.panel.modal.dismiss', 'Cancel')}
|
||||
confirmButtonVariant="primary"
|
||||
onConfirm={onConfirmNewLayout}
|
||||
onDismiss={onDismissNewLayout}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
export function useLayoutCategory(layoutManager: DashboardLayoutManager) {
|
||||
|
||||
@@ -74,7 +74,7 @@ export interface EditableDashboardElement {
|
||||
/**
|
||||
* Container objects can have children
|
||||
*/
|
||||
getOutlineChildren?(): SceneObject[];
|
||||
getOutlineChildren?(isEditing?: boolean): SceneObject[];
|
||||
}
|
||||
|
||||
export interface EditableDashboardElementInfo {
|
||||
|
||||
+29
@@ -380,6 +380,35 @@ describe('sceneVariablesSetToVariables', () => {
|
||||
`);
|
||||
});
|
||||
|
||||
it('should handle Custom variable when sceneVariablesSetToVariables should keep options', () => {
|
||||
const variable = new CustomVariable({
|
||||
name: 'test',
|
||||
label: 'test-label',
|
||||
description: 'test-desc',
|
||||
hide: VariableHide.inControlsMenu,
|
||||
value: ['test'],
|
||||
text: ['test'],
|
||||
query: 'test,test1,test2',
|
||||
options: [
|
||||
{ label: 'test', value: 'test' },
|
||||
{ label: 'test1', value: 'test1' },
|
||||
{ label: 'test2', value: 'test2' },
|
||||
],
|
||||
includeAll: true,
|
||||
allValue: 'test-all',
|
||||
isMulti: true,
|
||||
});
|
||||
|
||||
const set = new SceneVariableSet({
|
||||
variables: [variable],
|
||||
});
|
||||
const keepQueryOptions = true;
|
||||
const result = sceneVariablesSetToVariables(set, keepQueryOptions);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].options).not.toEqual([]);
|
||||
expect(result[0].options?.length).toEqual(3);
|
||||
});
|
||||
|
||||
it('should handle ConstantVariable', () => {
|
||||
const variable = new ConstantVariable({
|
||||
name: 'test',
|
||||
|
||||
@@ -102,6 +102,10 @@ export function sceneVariablesSetToVariables(set: SceneVariables, keepQueryOptio
|
||||
}
|
||||
variables.push(variableObj);
|
||||
} else if (sceneUtils.isCustomVariable(variable)) {
|
||||
let options: VariableOption[] = [];
|
||||
if (keepQueryOptions) {
|
||||
options = variableValueOptionsToVariableOptions(variable.state);
|
||||
}
|
||||
const customVariable: VariableModel = {
|
||||
...commonProperties,
|
||||
current: {
|
||||
@@ -110,7 +114,7 @@ export function sceneVariablesSetToVariables(set: SceneVariables, keepQueryOptio
|
||||
// @ts-expect-error
|
||||
value: variable.state.value,
|
||||
},
|
||||
options: [],
|
||||
options,
|
||||
query: variable.state.query,
|
||||
multi: variable.state.isMulti,
|
||||
allValue: variable.state.allValue,
|
||||
|
||||
+11
@@ -39,6 +39,17 @@ export const BasicProvisionedDashboardsEmptyPage = ({ datasourceUid }: Props) =>
|
||||
}
|
||||
|
||||
const dashboards = await fetchProvisionedDashboards(ds.type);
|
||||
|
||||
if (dashboards.length > 0) {
|
||||
DashboardLibraryInteractions.loaded({
|
||||
numberOfItems: dashboards.length,
|
||||
contentKinds: [CONTENT_KINDS.DATASOURCE_DASHBOARD],
|
||||
datasourceTypes: [ds.type],
|
||||
sourceEntryPoint: SOURCE_ENTRY_POINTS.DATASOURCE_PAGE,
|
||||
eventLocation: EVENT_LOCATIONS.EMPTY_DASHBOARD,
|
||||
});
|
||||
}
|
||||
|
||||
return dashboards;
|
||||
}, [datasourceUid]);
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ export const getPublicDashboardRoutes = (): RouteDescriptor[] => {
|
||||
{
|
||||
path: '/public-dashboards/:accessToken',
|
||||
pageClass: 'page-dashboard',
|
||||
allowAnonymous: true,
|
||||
routeName: DashboardRoutes.Public,
|
||||
chromeless: true,
|
||||
component: SafeDynamicImport(
|
||||
|
||||
@@ -39,7 +39,7 @@ const LibraryPanelCardComponent = ({ libraryPanel, onClick, onDelete, showSecond
|
||||
title={libraryPanel.name}
|
||||
description={libraryPanel.description}
|
||||
plugin={panelPlugin}
|
||||
onClick={() => onClick?.(libraryPanel)}
|
||||
onSelect={() => onClick?.(libraryPanel)}
|
||||
onDelete={showSecondaryActions ? () => setShowDeletionModal(true) : undefined}
|
||||
>
|
||||
<FolderLink libraryPanel={libraryPanel} />
|
||||
|
||||
+2
-2
@@ -252,7 +252,7 @@ describe('LibraryPanelsSearch', () => {
|
||||
}
|
||||
);
|
||||
|
||||
const card = () => screen.getByLabelText(/plugin visualization item time series/i);
|
||||
const card = () => screen.getByTestId(/plugin visualization item time series/i);
|
||||
|
||||
expect(screen.queryByText(/you haven\'t created any library panels yet/i)).not.toBeInTheDocument();
|
||||
expect(card()).toBeInTheDocument();
|
||||
@@ -293,7 +293,7 @@ describe('LibraryPanelsSearch', () => {
|
||||
}
|
||||
);
|
||||
|
||||
const card = () => screen.getByLabelText(/plugin visualization item time series/i);
|
||||
const card = () => screen.getByTestId(/plugin visualization item time series/i);
|
||||
|
||||
expect(screen.queryByText(/you haven\'t created any library panels yet/i)).not.toBeInTheDocument();
|
||||
expect(card()).toBeInTheDocument();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user