From 1b818dd05d563cb186a6ea9ef2d9b7c22e598944 Mon Sep 17 00:00:00 2001 From: Vikrant Gupta Date: Mon, 29 Sep 2025 17:45:52 +0530 Subject: [PATCH 1/5] feat(authz): build role module (#9136) * feat(authz): build role module * feat(authz): build role module * feat(authz): refactor the role module to move transactions out * feat(authz): add handler implementation except patch objects * feat(authz): added the missing handler * feat(authz): added changes for selectors * feat(authz): added changes for selectors * feat(authz): added changes for selectors * feat(authz): make the role create handler just to create metadata * feat(authz): address review comments * feat(authz): address review comments * feat(authz): address review comments * feat(authz): address review comments --- ee/authz/openfgaschema/base.fga | 4 - ee/http/middleware/authz.go | 6 +- pkg/authz/authz.go | 10 +- pkg/authz/openfgaauthz/provider.go | 82 +++-- pkg/http/middleware/authz.go | 2 +- pkg/modules/dashboard/dashboard.go | 2 + pkg/modules/dashboard/impldashboard/module.go | 5 + pkg/modules/role/implrole/handler.go | 291 ++++++++++++++++++ pkg/modules/role/implrole/module.go | 172 +++++++++++ pkg/modules/role/implrole/store.go | 103 +++++++ pkg/modules/role/role.go | 66 ++++ pkg/types/authtypes/name.go | 32 +- pkg/types/authtypes/organization.go | 23 -- pkg/types/authtypes/relation.go | 44 ++- pkg/types/authtypes/resource.go | 37 --- pkg/types/authtypes/resources.go | 26 -- pkg/types/authtypes/role.go | 31 -- pkg/types/authtypes/selector.go | 75 +++-- pkg/types/authtypes/transaction.go | 108 +++++++ pkg/types/authtypes/typeable.go | 95 +++++- pkg/types/authtypes/typeable_organization.go | 33 ++ pkg/types/authtypes/typeable_resource.go | 47 +++ pkg/types/authtypes/typeable_resources.go | 47 +++ pkg/types/authtypes/typeable_role.go | 33 ++ pkg/types/authtypes/typeable_user.go | 33 ++ pkg/types/authtypes/user.go | 31 -- pkg/types/dashboardtypes/dashboard.go | 6 + pkg/types/roletypes/role.go | 224 ++++++++++++++ pkg/types/roletypes/store.go | 16 + 29 files changed, 1463 insertions(+), 221 deletions(-) create mode 100644 pkg/modules/role/implrole/handler.go create mode 100644 pkg/modules/role/implrole/module.go create mode 100644 pkg/modules/role/implrole/store.go create mode 100644 pkg/modules/role/role.go delete mode 100644 pkg/types/authtypes/organization.go delete mode 100644 pkg/types/authtypes/resource.go delete mode 100644 pkg/types/authtypes/resources.go delete mode 100644 pkg/types/authtypes/role.go create mode 100644 pkg/types/authtypes/transaction.go create mode 100644 pkg/types/authtypes/typeable_organization.go create mode 100644 pkg/types/authtypes/typeable_resource.go create mode 100644 pkg/types/authtypes/typeable_resources.go create mode 100644 pkg/types/authtypes/typeable_role.go create mode 100644 pkg/types/authtypes/typeable_user.go delete mode 100644 pkg/types/authtypes/user.go create mode 100644 pkg/types/roletypes/role.go create mode 100644 pkg/types/roletypes/store.go diff --git a/ee/authz/openfgaschema/base.fga b/ee/authz/openfgaschema/base.fga index 17cbaec7d87d..e2f1f003d4cc 100644 --- a/ee/authz/openfgaschema/base.fga +++ b/ee/authz/openfgaschema/base.fga @@ -26,10 +26,6 @@ type resources define create: [user, role#assignee] define list: [user, role#assignee] - define read: [user, role#assignee] - define update: [user, role#assignee] - define delete: [user, role#assignee] - type resource relations define read: [user, anonymous, role#assignee] diff --git a/ee/http/middleware/authz.go b/ee/http/middleware/authz.go index c6b20eda39c1..f443917c9490 100644 --- a/ee/http/middleware/authz.go +++ b/ee/http/middleware/authz.go @@ -107,7 +107,7 @@ func (middleware *AuthZ) OpenAccess(next http.HandlerFunc) http.HandlerFunc { } // Check middleware accepts the relation, typeable, parentTypeable (for direct access + group relations) and a callback function to derive selector and parentSelectors on per request basis. -func (middleware *AuthZ) Check(next http.HandlerFunc, relation authtypes.Relation, translation authtypes.Relation, typeable authtypes.Typeable, parentTypeable authtypes.Typeable, cb authtypes.SelectorCallbackFn) http.HandlerFunc { +func (middleware *AuthZ) Check(next http.HandlerFunc, relation authtypes.Relation, translation authtypes.Relation, typeable authtypes.Typeable, cb authtypes.SelectorCallbackFn) http.HandlerFunc { return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { claims, err := authtypes.ClaimsFromContext(req.Context()) if err != nil { @@ -115,13 +115,13 @@ func (middleware *AuthZ) Check(next http.HandlerFunc, relation authtypes.Relatio return } - selector, parentSelectors, err := cb(req) + selector, err := cb(req.Context(), claims) if err != nil { render.Error(rw, err) return } - err = middleware.authzService.CheckWithTupleCreation(req.Context(), claims, relation, typeable, selector, parentTypeable, parentSelectors...) + err = middleware.authzService.CheckWithTupleCreation(req.Context(), claims, relation, typeable, selector) if err != nil { render.Error(rw, err) return diff --git a/pkg/authz/authz.go b/pkg/authz/authz.go index 26be6a9d39d4..3e490ee3c243 100644 --- a/pkg/authz/authz.go +++ b/pkg/authz/authz.go @@ -12,8 +12,14 @@ type AuthZ interface { factory.Service // Check returns error when the upstream authorization server is unavailable or the subject (s) doesn't have relation (r) on object (o). - Check(context.Context, *openfgav1.CheckRequestTupleKey) error + Check(context.Context, *openfgav1.TupleKey) error // CheckWithTupleCreation takes upon the responsibility for generating the tuples alongside everything Check does. - CheckWithTupleCreation(context.Context, authtypes.Claims, authtypes.Relation, authtypes.Typeable, authtypes.Selector, authtypes.Typeable, ...authtypes.Selector) error + CheckWithTupleCreation(context.Context, authtypes.Claims, authtypes.Relation, authtypes.Typeable, []authtypes.Selector) error + + // writes the tuples to upstream server + Write(context.Context, *openfgav1.WriteRequest) error + + // lists the selectors for objects assigned to subject (s) with relation (r) on resource (s) + ListObjects(context.Context, string, authtypes.Relation, authtypes.Typeable) ([]*authtypes.Object, error) } diff --git a/pkg/authz/openfgaauthz/provider.go b/pkg/authz/openfgaauthz/provider.go index 8903656ef5ac..7d0d58a7f81c 100644 --- a/pkg/authz/openfgaauthz/provider.go +++ b/pkg/authz/openfgaauthz/provider.go @@ -176,13 +176,17 @@ func (provider *provider) isModelEqual(expected *openfgav1.AuthorizationModel, a } -func (provider *provider) Check(ctx context.Context, tupleReq *openfgav1.CheckRequestTupleKey) error { +func (provider *provider) Check(ctx context.Context, tupleReq *openfgav1.TupleKey) error { checkResponse, err := provider.openfgaServer.Check( ctx, &openfgav1.CheckRequest{ StoreId: provider.storeID, AuthorizationModelId: provider.modelID, - TupleKey: tupleReq, + TupleKey: &openfgav1.CheckRequestTupleKey{ + User: tupleReq.User, + Relation: tupleReq.Relation, + Object: tupleReq.Object, + }, }) if err != nil { return errors.Newf(errors.TypeInternal, authtypes.ErrCodeAuthZUnavailable, "authorization server is unavailable").WithAdditional(err.Error()) @@ -195,39 +199,79 @@ func (provider *provider) Check(ctx context.Context, tupleReq *openfgav1.CheckRe return nil } -func (provider *provider) CheckWithTupleCreation(ctx context.Context, claims authtypes.Claims, relation authtypes.Relation, typeable authtypes.Typeable, selector authtypes.Selector, parentTypeable authtypes.Typeable, parentSelectors ...authtypes.Selector) error { +func (provider *provider) BatchCheck(ctx context.Context, tupleReq []*openfgav1.TupleKey) error { + batchCheckItems := make([]*openfgav1.BatchCheckItem, 0) + for _, tuple := range tupleReq { + batchCheckItems = append(batchCheckItems, &openfgav1.BatchCheckItem{ + TupleKey: &openfgav1.CheckRequestTupleKey{ + User: tuple.User, + Relation: tuple.Relation, + Object: tuple.Object, + }, + }) + } + + checkResponse, err := provider.openfgaServer.BatchCheck( + ctx, + &openfgav1.BatchCheckRequest{ + StoreId: provider.storeID, + AuthorizationModelId: provider.modelID, + Checks: batchCheckItems, + }) + if err != nil { + return errors.Newf(errors.TypeInternal, authtypes.ErrCodeAuthZUnavailable, "authorization server is unavailable").WithAdditional(err.Error()) + } + + for _, checkResponse := range checkResponse.Result { + if checkResponse.GetAllowed() { + return nil + } + } + + return errors.New(errors.TypeForbidden, authtypes.ErrCodeAuthZForbidden, "") + +} + +func (provider *provider) CheckWithTupleCreation(ctx context.Context, claims authtypes.Claims, relation authtypes.Relation, typeable authtypes.Typeable, selectors []authtypes.Selector) error { subject, err := authtypes.NewSubject(authtypes.TypeUser, claims.UserID, authtypes.Relation{}) if err != nil { return err } - tuples, err := typeable.Tuples(subject, relation, selector, parentTypeable, parentSelectors...) + tuples, err := typeable.Tuples(subject, relation, selectors) if err != nil { return err } - check, err := provider.sequentialCheck(ctx, tuples) + err = provider.BatchCheck(ctx, tuples) if err != nil { return err } - if !check { - return errors.Newf(errors.TypeForbidden, authtypes.ErrCodeAuthZForbidden, "subject %s cannot %s object %s", subject, relation.StringValue(), typeable.Type().StringValue()) - } return nil } -func (provider *provider) sequentialCheck(ctx context.Context, tuplesReq []*openfgav1.CheckRequestTupleKey) (bool, error) { - for _, tupleReq := range tuplesReq { - err := provider.Check(ctx, tupleReq) - if err == nil { - return true, nil - } - if errors.Ast(err, errors.TypeInternal) { - // return at the first internal error as the evaluation will be incorrect - return false, err - } +func (provider *provider) Write(ctx context.Context, req *openfgav1.WriteRequest) error { + _, err := provider.openfgaServer.Write(ctx, &openfgav1.WriteRequest{ + StoreId: provider.storeID, + AuthorizationModelId: provider.modelID, + Writes: req.Writes, + }) + + return err +} + +func (provider *provider) ListObjects(ctx context.Context, subject string, relation authtypes.Relation, typeable authtypes.Typeable) ([]*authtypes.Object, error) { + response, err := provider.openfgaServer.ListObjects(ctx, &openfgav1.ListObjectsRequest{ + StoreId: provider.storeID, + AuthorizationModelId: provider.modelID, + User: subject, + Relation: relation.StringValue(), + Type: typeable.Type().StringValue(), + }) + if err != nil { + return nil, errors.Wrapf(err, errors.TypeInternal, authtypes.ErrCodeAuthZUnavailable, "cannot list objects for subject %s with relation %s for type %s", subject, relation.StringValue(), typeable.Type().StringValue()) } - return false, nil + return authtypes.MustNewObjectsFromStringSlice(response.Objects), nil } diff --git a/pkg/http/middleware/authz.go b/pkg/http/middleware/authz.go index 6abd9aeaecbc..35f68d93ea3f 100644 --- a/pkg/http/middleware/authz.go +++ b/pkg/http/middleware/authz.go @@ -114,7 +114,7 @@ func (middleware *AuthZ) Check(next http.HandlerFunc, _ authtypes.Relation, tran return } - err = middleware.authzService.CheckWithTupleCreation(req.Context(), claims, translation, authtypes.TypeableOrganization, authtypes.MustNewSelector(authtypes.TypeOrganization, claims.OrgID), nil) + err = middleware.authzService.CheckWithTupleCreation(req.Context(), claims, translation, authtypes.TypeableOrganization, []authtypes.Selector{authtypes.MustNewSelector(authtypes.TypeOrganization, claims.OrgID)}) if err != nil { render.Error(rw, err) return diff --git a/pkg/modules/dashboard/dashboard.go b/pkg/modules/dashboard/dashboard.go index c72aa0d7f10c..8ef4525e7eef 100644 --- a/pkg/modules/dashboard/dashboard.go +++ b/pkg/modules/dashboard/dashboard.go @@ -4,6 +4,7 @@ import ( "context" "net/http" + "github.com/SigNoz/signoz/pkg/modules/role" "github.com/SigNoz/signoz/pkg/statsreporter" "github.com/SigNoz/signoz/pkg/types" "github.com/SigNoz/signoz/pkg/types/dashboardtypes" @@ -26,6 +27,7 @@ type Module interface { GetByMetricNames(ctx context.Context, orgID valuer.UUID, metricNames []string) (map[string][]map[string]string, error) statsreporter.StatsCollector + role.RegisterTypeable } type Handler interface { diff --git a/pkg/modules/dashboard/impldashboard/module.go b/pkg/modules/dashboard/impldashboard/module.go index c4e39586d914..4ef2c9004026 100644 --- a/pkg/modules/dashboard/impldashboard/module.go +++ b/pkg/modules/dashboard/impldashboard/module.go @@ -10,6 +10,7 @@ import ( "github.com/SigNoz/signoz/pkg/modules/dashboard" "github.com/SigNoz/signoz/pkg/sqlstore" "github.com/SigNoz/signoz/pkg/types" + "github.com/SigNoz/signoz/pkg/types/authtypes" "github.com/SigNoz/signoz/pkg/types/dashboardtypes" "github.com/SigNoz/signoz/pkg/valuer" ) @@ -222,3 +223,7 @@ func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[strin return dashboardtypes.NewStatsFromStorableDashboards(dashboards), nil } + +func (module *module) MustGetTypeables() []authtypes.Typeable { + return []authtypes.Typeable{dashboardtypes.ResourceDashboard, dashboardtypes.ResourcesDashboards} +} diff --git a/pkg/modules/role/implrole/handler.go b/pkg/modules/role/implrole/handler.go new file mode 100644 index 000000000000..a2d43b07156e --- /dev/null +++ b/pkg/modules/role/implrole/handler.go @@ -0,0 +1,291 @@ +package implrole + +import ( + "net/http" + + "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/http/binding" + "github.com/SigNoz/signoz/pkg/http/render" + "github.com/SigNoz/signoz/pkg/modules/role" + "github.com/SigNoz/signoz/pkg/types/authtypes" + "github.com/SigNoz/signoz/pkg/types/roletypes" + "github.com/SigNoz/signoz/pkg/valuer" + "github.com/gorilla/mux" +) + +type handler struct { + module role.Module +} + +func NewHandler(module role.Module) (role.Handler, error) { + return &handler{module: module}, nil +} + +func (handler *handler) Create(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + claims, err := authtypes.ClaimsFromContext(ctx) + if err != nil { + render.Error(rw, err) + return + } + orgID, err := valuer.NewUUID(claims.OrgID) + if err != nil { + render.Error(rw, err) + return + } + + req := new(roletypes.PostableRole) + if err := binding.JSON.BindBody(r.Body, req); err != nil { + render.Error(rw, err) + return + } + + role, err := handler.module.Create(ctx, orgID, req.DisplayName, req.Description) + if err != nil { + render.Error(rw, err) + return + } + + render.Success(rw, http.StatusCreated, role.ID.StringValue()) +} + +func (handler *handler) Get(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + claims, err := authtypes.ClaimsFromContext(ctx) + if err != nil { + render.Error(rw, err) + return + } + orgID, err := valuer.NewUUID(claims.OrgID) + if err != nil { + render.Error(rw, err) + return + } + + id, ok := mux.Vars(r)["id"] + if !ok { + render.Error(rw, errors.New(errors.TypeInvalidInput, roletypes.ErrCodeRoleInvalidInput, "id is missing from the request")) + return + } + roleID, err := valuer.NewUUID(id) + if err != nil { + render.Error(rw, err) + return + } + + role, err := handler.module.Get(ctx, orgID, roleID) + if err != nil { + render.Error(rw, err) + return + } + + render.Success(rw, http.StatusOK, role) +} + +func (handler *handler) GetObjects(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + claims, err := authtypes.ClaimsFromContext(ctx) + if err != nil { + render.Error(rw, err) + return + } + orgID, err := valuer.NewUUID(claims.OrgID) + if err != nil { + render.Error(rw, err) + return + } + + id, ok := mux.Vars(r)["id"] + if !ok { + render.Error(rw, errors.New(errors.TypeInvalidInput, roletypes.ErrCodeRoleInvalidInput, "id is missing from the request")) + return + } + roleID, err := valuer.NewUUID(id) + if err != nil { + render.Error(rw, err) + return + } + + relationStr, ok := mux.Vars(r)["relation"] + if !ok { + render.Error(rw, errors.New(errors.TypeInvalidInput, roletypes.ErrCodeRoleInvalidInput, "relation is missing from the request")) + return + } + relation, err := authtypes.NewRelation(relationStr) + if err != nil { + render.Error(rw, err) + return + } + + objects, err := handler.module.GetObjects(ctx, orgID, roleID, relation) + if err != nil { + render.Error(rw, err) + return + } + + render.Success(rw, http.StatusOK, objects) +} + +func (handler *handler) GetResources(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + resources := handler.module.GetResources(ctx) + + var resourceRelations = struct { + Resources []*authtypes.Resource `json:"resources"` + Relations map[authtypes.Type][]authtypes.Relation `json:"relations"` + }{ + Resources: resources, + Relations: authtypes.TypeableRelations, + } + render.Success(rw, http.StatusOK, resourceRelations) +} + +func (handler *handler) List(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + claims, err := authtypes.ClaimsFromContext(ctx) + if err != nil { + render.Error(rw, err) + return + } + orgID, err := valuer.NewUUID(claims.OrgID) + if err != nil { + render.Error(rw, err) + return + } + + roles, err := handler.module.List(ctx, orgID) + if err != nil { + render.Error(rw, err) + return + } + + render.Success(rw, http.StatusOK, roles) +} + +func (handler *handler) Patch(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + claims, err := authtypes.ClaimsFromContext(ctx) + if err != nil { + render.Error(rw, err) + return + } + orgID, err := valuer.NewUUID(claims.OrgID) + if err != nil { + render.Error(rw, err) + return + } + + id, ok := mux.Vars(r)["id"] + if !ok { + render.Error(rw, errors.New(errors.TypeInvalidInput, roletypes.ErrCodeRoleInvalidInput, "id is missing from the request")) + return + } + roleID, err := valuer.NewUUID(id) + if err != nil { + render.Error(rw, err) + return + } + + req := new(roletypes.PatchableRole) + if err := binding.JSON.BindBody(r.Body, req); err != nil { + render.Error(rw, err) + return + } + + err = handler.module.Patch(ctx, orgID, roleID, req.DisplayName, req.Description) + if err != nil { + render.Error(rw, err) + return + } + + render.Success(rw, http.StatusAccepted, nil) +} + +func (handler *handler) PatchObjects(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + claims, err := authtypes.ClaimsFromContext(ctx) + if err != nil { + render.Error(rw, err) + return + } + orgID, err := valuer.NewUUID(claims.OrgID) + if err != nil { + render.Error(rw, err) + return + } + + id, ok := mux.Vars(r)["id"] + if !ok { + render.Error(rw, errors.New(errors.TypeInvalidInput, roletypes.ErrCodeRoleInvalidInput, "id is missing from the request")) + return + } + roleID, err := valuer.NewUUID(id) + if err != nil { + render.Error(rw, err) + return + } + + relationStr, ok := mux.Vars(r)["relation"] + if !ok { + render.Error(rw, errors.New(errors.TypeInvalidInput, roletypes.ErrCodeRoleInvalidInput, "relation is missing from the request")) + return + } + relation, err := authtypes.NewRelation(relationStr) + if err != nil { + render.Error(rw, err) + return + } + + req := new(roletypes.PatchableObjects) + if err := binding.JSON.BindBody(r.Body, req); err != nil { + render.Error(rw, err) + return + } + + patchableObjects, err := roletypes.NewPatchableObjects(req.Additions, req.Deletions, relation) + if err != nil { + render.Error(rw, err) + return + } + + err = handler.module.PatchObjects(ctx, orgID, roleID, relation, patchableObjects.Additions, patchableObjects.Deletions) + if err != nil { + render.Error(rw, err) + return + } + + render.Success(rw, http.StatusAccepted, nil) +} + +func (handler *handler) Delete(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + claims, err := authtypes.ClaimsFromContext(ctx) + if err != nil { + render.Error(rw, err) + return + } + orgID, err := valuer.NewUUID(claims.OrgID) + if err != nil { + render.Error(rw, err) + return + } + + id, ok := mux.Vars(r)["id"] + if !ok { + render.Error(rw, errors.New(errors.TypeInvalidInput, roletypes.ErrCodeRoleInvalidInput, "id is missing from the request")) + return + } + roleID, err := valuer.NewUUID(id) + if err != nil { + render.Error(rw, err) + return + } + + err = handler.module.Delete(ctx, orgID, roleID) + if err != nil { + render.Error(rw, err) + return + } + + render.Success(rw, http.StatusNoContent, nil) +} diff --git a/pkg/modules/role/implrole/module.go b/pkg/modules/role/implrole/module.go new file mode 100644 index 000000000000..f426b90dfccd --- /dev/null +++ b/pkg/modules/role/implrole/module.go @@ -0,0 +1,172 @@ +package implrole + +import ( + "context" + "slices" + + "github.com/SigNoz/signoz/pkg/authz" + "github.com/SigNoz/signoz/pkg/modules/role" + "github.com/SigNoz/signoz/pkg/types/authtypes" + "github.com/SigNoz/signoz/pkg/types/roletypes" + "github.com/SigNoz/signoz/pkg/valuer" + openfgav1 "github.com/openfga/api/proto/openfga/v1" +) + +type module struct { + store roletypes.Store + registry []role.RegisterTypeable + authz authz.AuthZ +} + +func NewModule(ctx context.Context, store roletypes.Store, authz authz.AuthZ, registry []role.RegisterTypeable) (role.Module, error) { + return &module{ + store: store, + authz: authz, + registry: registry, + }, nil +} + +func (module *module) Create(ctx context.Context, orgID valuer.UUID, displayName, description string) (*roletypes.Role, error) { + role := roletypes.NewRole(displayName, description, orgID) + + storableRole, err := roletypes.NewStorableRoleFromRole(role) + if err != nil { + return nil, err + } + + err = module.store.Create(ctx, storableRole) + if err != nil { + return nil, err + } + + return role, nil +} + +func (module *module) GetResources(_ context.Context) []*authtypes.Resource { + typeables := make([]authtypes.Typeable, 0) + for _, register := range module.registry { + typeables = append(typeables, register.MustGetTypeables()...) + } + + resources := make([]*authtypes.Resource, 0) + for _, typeable := range typeables { + resources = append(resources, &authtypes.Resource{Name: typeable.Name(), Type: typeable.Type()}) + } + + return resources +} + +func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*roletypes.Role, error) { + storableRole, err := module.store.Get(ctx, orgID, id) + if err != nil { + return nil, err + } + + role, err := roletypes.NewRoleFromStorableRole(storableRole) + if err != nil { + return nil, err + } + + return role, nil +} + +func (module *module) GetObjects(ctx context.Context, orgID valuer.UUID, id valuer.UUID, relation authtypes.Relation) ([]*authtypes.Object, error) { + storableRole, err := module.store.Get(ctx, orgID, id) + if err != nil { + return nil, err + } + + objects := make([]*authtypes.Object, 0) + for _, resource := range module.GetResources(ctx) { + if slices.Contains(authtypes.TypeableRelations[resource.Type], relation) { + resourceObjects, err := module. + authz. + ListObjects( + ctx, + authtypes.MustNewSubject(authtypes.TypeRole, storableRole.ID.String(), authtypes.RelationAssignee), + relation, + authtypes.MustNewTypeableFromType(resource.Type, resource.Name), + ) + if err != nil { + return nil, err + } + + objects = append(objects, resourceObjects...) + } + } + + return objects, nil +} + +func (module *module) List(ctx context.Context, orgID valuer.UUID) ([]*roletypes.Role, error) { + storableRoles, err := module.store.List(ctx, orgID) + if err != nil { + return nil, err + } + + roles := make([]*roletypes.Role, len(storableRoles)) + for idx, storableRole := range storableRoles { + role, err := roletypes.NewRoleFromStorableRole(storableRole) + if err != nil { + return nil, err + } + roles[idx] = role + } + + return roles, nil +} + +func (module *module) Patch(ctx context.Context, orgID valuer.UUID, id valuer.UUID, displayName, description *string) error { + storableRole, err := module.store.Get(ctx, orgID, id) + if err != nil { + return err + } + + role, err := roletypes.NewRoleFromStorableRole(storableRole) + if err != nil { + return err + } + + role.PatchMetadata(displayName, description) + updatedRole, err := roletypes.NewStorableRoleFromRole(role) + if err != nil { + return err + } + + err = module.store.Update(ctx, orgID, updatedRole) + if err != nil { + return err + } + + return nil +} + +func (module *module) PatchObjects(ctx context.Context, orgID valuer.UUID, id valuer.UUID, relation authtypes.Relation, additions, deletions []*authtypes.Object) error { + additionTuples, err := roletypes.GetAdditionTuples(id, relation, additions) + if err != nil { + return err + } + + deletionTuples, err := roletypes.GetDeletionTuples(id, relation, deletions) + if err != nil { + return err + } + + err = module.authz.Write(ctx, &openfgav1.WriteRequest{ + Writes: &openfgav1.WriteRequestWrites{ + TupleKeys: additionTuples, + }, + Deletes: &openfgav1.WriteRequestDeletes{ + TupleKeys: deletionTuples, + }, + }) + if err != nil { + return err + } + + return nil +} + +func (module *module) Delete(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error { + return module.store.Delete(ctx, orgID, id) +} diff --git a/pkg/modules/role/implrole/store.go b/pkg/modules/role/implrole/store.go new file mode 100644 index 000000000000..65a1c79b8b15 --- /dev/null +++ b/pkg/modules/role/implrole/store.go @@ -0,0 +1,103 @@ +package implrole + +import ( + "context" + + "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/sqlstore" + "github.com/SigNoz/signoz/pkg/types/roletypes" + "github.com/SigNoz/signoz/pkg/valuer" +) + +type store struct { + sqlstore sqlstore.SQLStore +} + +func NewStore(sqlstore sqlstore.SQLStore) (roletypes.Store, error) { + return &store{sqlstore: sqlstore}, nil +} + +func (store *store) Create(ctx context.Context, role *roletypes.StorableRole) error { + _, err := store. + sqlstore. + BunDB(). + NewInsert(). + Model(role). + Exec(ctx) + if err != nil { + return store.sqlstore.WrapAlreadyExistsErrf(err, errors.CodeAlreadyExists, "role with id: %s already exists", role.ID) + } + + return nil +} + +func (store *store) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*roletypes.StorableRole, error) { + role := new(roletypes.StorableRole) + err := store. + sqlstore. + BunDB(). + NewSelect(). + Model(role). + Where("orgID = ?", orgID). + Where("id = ?", id). + Scan(ctx) + if err != nil { + return nil, store.sqlstore.WrapNotFoundErrf(err, roletypes.ErrCodeRoleNotFound, "role with id: %s doesn't exist", id) + } + + return role, nil +} + +func (store *store) List(ctx context.Context, orgID valuer.UUID) ([]*roletypes.StorableRole, error) { + roles := make([]*roletypes.StorableRole, 0) + err := store. + sqlstore. + BunDB(). + NewSelect(). + Model(&roles). + Where("orgID = ?", orgID). + Scan(ctx) + if err != nil { + return nil, store.sqlstore.WrapNotFoundErrf(err, roletypes.ErrCodeRoleNotFound, "no roles found in org_id: %s", orgID) + } + + return roles, nil +} + +func (store *store) Update(ctx context.Context, orgID valuer.UUID, role *roletypes.StorableRole) error { + _, err := store. + sqlstore. + BunDB(). + NewUpdate(). + Model(role). + WherePK(). + Where("org_id = ?", orgID). + Exec(ctx) + if err != nil { + return store.sqlstore.WrapNotFoundErrf(err, errors.CodeAlreadyExists, "role with id %s doesn't exist", role.ID) + } + + return nil +} + +func (store *store) Delete(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error { + _, err := store. + sqlstore. + BunDB(). + NewDelete(). + Model(new(roletypes.StorableRole)). + Where("org_id = ?", orgID). + Where("id = ?", id). + Exec(ctx) + if err != nil { + return store.sqlstore.WrapNotFoundErrf(err, roletypes.ErrCodeRoleNotFound, "role with id %s doesn't exist", id) + } + + return nil +} + +func (store *store) RunInTx(ctx context.Context, cb func(ctx context.Context) error) error { + return store.sqlstore.RunInTxCtx(ctx, nil, func(ctx context.Context) error { + return cb(ctx) + }) +} diff --git a/pkg/modules/role/role.go b/pkg/modules/role/role.go new file mode 100644 index 000000000000..f19604be9f78 --- /dev/null +++ b/pkg/modules/role/role.go @@ -0,0 +1,66 @@ +package role + +import ( + "context" + "net/http" + + "github.com/SigNoz/signoz/pkg/types/authtypes" + "github.com/SigNoz/signoz/pkg/types/roletypes" + "github.com/SigNoz/signoz/pkg/valuer" +) + +type Module interface { + // Creates the role metadata + Create(context.Context, valuer.UUID, string, string) (*roletypes.Role, error) + + // Gets the role metadata + Get(context.Context, valuer.UUID, valuer.UUID) (*roletypes.Role, error) + + // Gets the objects associated with the given role and relation + GetObjects(context.Context, valuer.UUID, valuer.UUID, authtypes.Relation) ([]*authtypes.Object, error) + + // Lists all the roles metadata for the organization + List(context.Context, valuer.UUID) ([]*roletypes.Role, error) + + // Gets all the typeable resources registered from role registry + GetResources(context.Context) []*authtypes.Resource + + // Patches the roles metadata + Patch(context.Context, valuer.UUID, valuer.UUID, *string, *string) error + + // Patches the objects in authorization server associated with the given role and relation + PatchObjects(context.Context, valuer.UUID, valuer.UUID, authtypes.Relation, []*authtypes.Object, []*authtypes.Object) error + + // Deletes the role metadata and tuples in authorization server + Delete(context.Context, valuer.UUID, valuer.UUID) error +} + +type RegisterTypeable interface { + MustGetTypeables() []authtypes.Typeable +} + +type Handler interface { + // Creates the role metadata and tuples in authorization server + Create(http.ResponseWriter, *http.Request) + + // Gets the role metadata + Get(http.ResponseWriter, *http.Request) + + // Gets the objects for the given relation and role + GetObjects(http.ResponseWriter, *http.Request) + + // Gets all the resources and the relations + GetResources(http.ResponseWriter, *http.Request) + + // Lists all the roles metadata for the organization + List(http.ResponseWriter, *http.Request) + + // Patches the role metdata + Patch(http.ResponseWriter, *http.Request) + + // Patches the objects for the given relation and role + PatchObjects(http.ResponseWriter, *http.Request) + + // Deletes the role metadata and tuples in authorization server + Delete(http.ResponseWriter, *http.Request) +} diff --git a/pkg/types/authtypes/name.go b/pkg/types/authtypes/name.go index 32994f54f568..e22e21345ea4 100644 --- a/pkg/types/authtypes/name.go +++ b/pkg/types/authtypes/name.go @@ -1,6 +1,7 @@ package authtypes import ( + "encoding/json" "regexp" "github.com/SigNoz/signoz/pkg/errors" @@ -14,14 +15,39 @@ type Name struct { val string } -func MustNewName(name string) Name { +func NewName(name string) (Name, error) { if !nameRegex.MatchString(name) { - panic(errors.NewInternalf(errors.CodeInternal, "name must conform to regex %s", nameRegex.String())) + return Name{}, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "name must conform to regex %s", nameRegex.String()) } - return Name{val: name} + return Name{val: name}, nil +} + +func MustNewName(name string) Name { + named, err := NewName(name) + if err != nil { + panic(err) + } + + return named } func (name Name) String() string { return name.val } + +func (name *Name) UnmarshalJSON(data []byte) error { + nameStr := "" + err := json.Unmarshal(data, &nameStr) + if err != nil { + return err + } + + shadow, err := NewName(nameStr) + if err != nil { + return err + } + + *name = shadow + return nil +} diff --git a/pkg/types/authtypes/organization.go b/pkg/types/authtypes/organization.go deleted file mode 100644 index d1510cdf62c5..000000000000 --- a/pkg/types/authtypes/organization.go +++ /dev/null @@ -1,23 +0,0 @@ -package authtypes - -import ( - "strings" - - openfgav1 "github.com/openfga/api/proto/openfga/v1" -) - -var _ Typeable = new(organization) - -type organization struct{} - -func (organization *organization) Tuples(subject string, relation Relation, selector Selector, parentTypeable Typeable, parentSelectors ...Selector) ([]*openfgav1.CheckRequestTupleKey, error) { - tuples := make([]*openfgav1.CheckRequestTupleKey, 0) - object := strings.Join([]string{TypeRole.StringValue(), selector.String()}, ":") - tuples = append(tuples, &openfgav1.CheckRequestTupleKey{User: subject, Relation: relation.StringValue(), Object: object}) - - return tuples, nil -} - -func (organization *organization) Type() Type { - return TypeOrganization -} diff --git a/pkg/types/authtypes/relation.go b/pkg/types/authtypes/relation.go index e0c1b8236d1c..165b888e75db 100644 --- a/pkg/types/authtypes/relation.go +++ b/pkg/types/authtypes/relation.go @@ -1,6 +1,13 @@ package authtypes -import "github.com/SigNoz/signoz/pkg/valuer" +import ( + "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/valuer" +) + +var ( + ErrCodeAuthZInvalidRelation = errors.MustNewCode("authz_invalid_relation") +) var ( RelationCreate = Relation{valuer.NewString("create")} @@ -12,12 +19,33 @@ var ( RelationAssignee = Relation{valuer.NewString("assignee")} ) -var ( - TypeUserSupportedRelations = []Relation{RelationRead, RelationUpdate, RelationDelete} - TypeRoleSupportedRelations = []Relation{RelationAssignee, RelationRead, RelationUpdate, RelationDelete} - TypeOrganizationSupportedRelations = []Relation{RelationCreate, RelationRead, RelationUpdate, RelationDelete, RelationList} - TypeResourceSupportedRelations = []Relation{RelationRead, RelationUpdate, RelationDelete, RelationBlock} - TypeResourcesSupportedRelations = []Relation{RelationCreate, RelationRead, RelationUpdate, RelationDelete, RelationList} -) +var TypeableRelations = map[Type][]Relation{ + TypeUser: {RelationRead, RelationUpdate, RelationDelete}, + TypeRole: {RelationAssignee, RelationRead, RelationUpdate, RelationDelete}, + TypeOrganization: {RelationCreate, RelationRead, RelationUpdate, RelationDelete, RelationList}, + TypeResource: {RelationRead, RelationUpdate, RelationDelete, RelationBlock}, + TypeResources: {RelationCreate, RelationList}, +} type Relation struct{ valuer.String } + +func NewRelation(relation string) (Relation, error) { + switch relation { + case "create": + return RelationCreate, nil + case "read": + return RelationRead, nil + case "update": + return RelationUpdate, nil + case "delete": + return RelationDelete, nil + case "list": + return RelationList, nil + case "block": + return RelationBlock, nil + case "assignee": + return RelationAssignee, nil + default: + return Relation{}, errors.Newf(errors.TypeInvalidInput, ErrCodeAuthZInvalidRelation, "invalid relation %s", relation) + } +} diff --git a/pkg/types/authtypes/resource.go b/pkg/types/authtypes/resource.go deleted file mode 100644 index 0bbfc5f9c28d..000000000000 --- a/pkg/types/authtypes/resource.go +++ /dev/null @@ -1,37 +0,0 @@ -package authtypes - -import ( - "strings" - - openfgav1 "github.com/openfga/api/proto/openfga/v1" -) - -var _ Typeable = new(resource) - -type resource struct { - name Name -} - -func MustNewResource(name string) Typeable { - return &resource{name: MustNewName(name)} -} - -func (resource *resource) Tuples(subject string, relation Relation, selector Selector, parentTypeable Typeable, parentSelectors ...Selector) ([]*openfgav1.CheckRequestTupleKey, error) { - tuples := make([]*openfgav1.CheckRequestTupleKey, 0) - for _, selector := range parentSelectors { - resourcesTuples, err := parentTypeable.Tuples(subject, relation, selector, nil) - if err != nil { - return nil, err - } - tuples = append(tuples, resourcesTuples...) - } - - object := strings.Join([]string{TypeResource.StringValue(), resource.name.String(), selector.String()}, ":") - tuples = append(tuples, &openfgav1.CheckRequestTupleKey{User: subject, Relation: relation.StringValue(), Object: object}) - - return tuples, nil -} - -func (resource *resource) Type() Type { - return TypeResource -} diff --git a/pkg/types/authtypes/resources.go b/pkg/types/authtypes/resources.go deleted file mode 100644 index 6213b5dd8e09..000000000000 --- a/pkg/types/authtypes/resources.go +++ /dev/null @@ -1,26 +0,0 @@ -package authtypes - -import ( - "strings" - - openfgav1 "github.com/openfga/api/proto/openfga/v1" -) - -var _ Typeable = new(resources) - -type resources struct { - name Name -} - -func MustNewResources(name string) Typeable { - return &resources{name: MustNewName(name)} -} - -func (resources *resources) Tuples(subject string, relation Relation, selector Selector, _ Typeable, _ ...Selector) ([]*openfgav1.CheckRequestTupleKey, error) { - object := strings.Join([]string{TypeResources.StringValue(), resources.name.String(), selector.String()}, ":") - return []*openfgav1.CheckRequestTupleKey{{User: subject, Relation: relation.StringValue(), Object: object}}, nil -} - -func (resources *resources) Type() Type { - return TypeResources -} diff --git a/pkg/types/authtypes/role.go b/pkg/types/authtypes/role.go deleted file mode 100644 index 9467adb193f9..000000000000 --- a/pkg/types/authtypes/role.go +++ /dev/null @@ -1,31 +0,0 @@ -package authtypes - -import ( - "strings" - - openfgav1 "github.com/openfga/api/proto/openfga/v1" -) - -var _ Typeable = new(role) - -type role struct{} - -func (role *role) Tuples(subject string, relation Relation, selector Selector, parentTypeable Typeable, parentSelectors ...Selector) ([]*openfgav1.CheckRequestTupleKey, error) { - tuples := make([]*openfgav1.CheckRequestTupleKey, 0) - for _, selector := range parentSelectors { - resourcesTuples, err := parentTypeable.Tuples(subject, relation, selector, nil) - if err != nil { - return nil, err - } - tuples = append(tuples, resourcesTuples...) - } - - object := strings.Join([]string{TypeRole.StringValue(), selector.String()}, ":") - tuples = append(tuples, &openfgav1.CheckRequestTupleKey{User: subject, Relation: relation.StringValue(), Object: object}) - - return tuples, nil -} - -func (role *role) Type() Type { - return TypeRole -} diff --git a/pkg/types/authtypes/selector.go b/pkg/types/authtypes/selector.go index 1480dbdaca1f..86787e493401 100644 --- a/pkg/types/authtypes/selector.go +++ b/pkg/types/authtypes/selector.go @@ -1,53 +1,67 @@ package authtypes import ( - "net/http" + "context" + "encoding/json" "regexp" "github.com/SigNoz/signoz/pkg/errors" ) +var ( + ErrCodeAuthZInvalidSelectorRegex = errors.MustNewCode("authz_invalid_selector_regex") +) + var ( typeUserSelectorRegex = regexp.MustCompile(`^[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$`) typeRoleSelectorRegex = regexp.MustCompile(`^[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$`) typeOrganizationSelectorRegex = regexp.MustCompile(`^[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$`) typeResourceSelectorRegex = regexp.MustCompile(`^[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$`) - typeResourcesSelectorRegex = regexp.MustCompile(`^org:[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$`) + typeResourcesSelectorRegex = regexp.MustCompile(`^org/[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$`) ) -type SelectorCallbackFn func(*http.Request) (Selector, []Selector, error) +type SelectorCallbackFn func(context.Context, Claims) ([]Selector, error) type Selector struct { val string } func NewSelector(typed Type, selector string) (Selector, error) { - switch typed { - case TypeUser: - if !typeUserSelectorRegex.MatchString(selector) { - return Selector{}, errors.Newf(errors.TypeInvalidInput, ErrCodeAuthZInvalidSelectorRegex, "selector must conform to regex %s", typeUserSelectorRegex.String()) - } - case TypeRole: - if !typeRoleSelectorRegex.MatchString(selector) { - return Selector{}, errors.Newf(errors.TypeInvalidInput, ErrCodeAuthZInvalidSelectorRegex, "selector must conform to regex %s", typeRoleSelectorRegex.String()) - } - case TypeOrganization: - if !typeOrganizationSelectorRegex.MatchString(selector) { - return Selector{}, errors.Newf(errors.TypeInvalidInput, ErrCodeAuthZInvalidSelectorRegex, "selector must conform to regex %s", typeOrganizationSelectorRegex.String()) - } - case TypeResource: - if !typeResourceSelectorRegex.MatchString(selector) { - return Selector{}, errors.Newf(errors.TypeInvalidInput, ErrCodeAuthZInvalidSelectorRegex, "selector must conform to regex %s", typeResourceSelectorRegex.String()) - } - case TypeResources: - if !typeResourcesSelectorRegex.MatchString(selector) { - return Selector{}, errors.Newf(errors.TypeInvalidInput, ErrCodeAuthZInvalidSelectorRegex, "selector must conform to regex %s", typeResourcesSelectorRegex.String()) - } + err := IsValidSelector(typed, Selector{val: selector}) + if err != nil { + return Selector{}, err } return Selector{val: selector}, nil } +func IsValidSelector(typed Type, selector Selector) error { + switch typed { + case TypeUser: + if !typeUserSelectorRegex.MatchString(selector.String()) { + return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthZInvalidSelectorRegex, "selector must conform to regex %s", typeUserSelectorRegex.String()) + } + case TypeRole: + if !typeRoleSelectorRegex.MatchString(selector.String()) { + return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthZInvalidSelectorRegex, "selector must conform to regex %s", typeRoleSelectorRegex.String()) + } + case TypeOrganization: + if !typeOrganizationSelectorRegex.MatchString(selector.String()) { + return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthZInvalidSelectorRegex, "selector must conform to regex %s", typeOrganizationSelectorRegex.String()) + } + case TypeResource: + if !typeResourceSelectorRegex.MatchString(selector.String()) { + return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthZInvalidSelectorRegex, "selector must conform to regex %s", typeResourceSelectorRegex.String()) + } + case TypeResources: + if !typeResourcesSelectorRegex.MatchString(selector.String()) { + return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthZInvalidSelectorRegex, "selector must conform to regex %s", typeResourcesSelectorRegex.String()) + } + } + + return nil +} + func MustNewSelector(typed Type, input string) Selector { selector, err := NewSelector(typed, input) if err != nil { @@ -60,3 +74,16 @@ func MustNewSelector(typed Type, input string) Selector { func (selector Selector) String() string { return selector.val } + +func (typed *Selector) UnmarshalJSON(data []byte) error { + str := "" + err := json.Unmarshal(data, &str) + if err != nil { + return err + } + + shadow := Selector{val: str} + *typed = shadow + + return nil +} diff --git a/pkg/types/authtypes/transaction.go b/pkg/types/authtypes/transaction.go new file mode 100644 index 000000000000..34f6c4ca9ba3 --- /dev/null +++ b/pkg/types/authtypes/transaction.go @@ -0,0 +1,108 @@ +package authtypes + +import ( + "encoding/json" + "slices" + "strings" + + "github.com/SigNoz/signoz/pkg/errors" +) + +type Resource struct { + Name Name `json:"name"` + Type Type `json:"type"` +} + +type Object struct { + Resource Resource `json:"resource"` + Selector Selector `json:"selector"` +} + +type Transaction struct { + Relation Relation `json:"relation"` + Object Object `json:"object"` +} + +func NewObject(resource Resource, selector Selector) (*Object, error) { + err := IsValidSelector(resource.Type, selector) + if err != nil { + return nil, err + } + + return &Object{Resource: resource, Selector: selector}, nil +} + +func MustNewObjectFromString(input string) *Object { + parts := strings.Split(input, ":") + if len(parts) != 3 { + panic(errors.Newf(errors.TypeInternal, errors.CodeInternal, "invalid list objects output: %s", input)) + } + + resource := Resource{ + Type: MustNewType(parts[0]), + Name: MustNewName(parts[1]), + } + + object := &Object{ + Resource: resource, + Selector: MustNewSelector(resource.Type, parts[2]), + } + + return object +} + +func MustNewObjectsFromStringSlice(input []string) []*Object { + objects := make([]*Object, 0, len(input)) + for _, str := range input { + objects = append(objects, MustNewObjectFromString(str)) + } + return objects +} + +func (object *Object) UnmarshalJSON(data []byte) error { + var shadow = struct { + Resource Resource + Selector Selector + }{} + + err := json.Unmarshal(data, &shadow) + if err != nil { + return err + } + + obj, err := NewObject(shadow.Resource, shadow.Selector) + if err != nil { + return err + } + + *object = *obj + return nil +} + +func NewTransaction(relation Relation, object Object) (*Transaction, error) { + if !slices.Contains(TypeableRelations[object.Resource.Type], relation) { + return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeAuthZInvalidRelation, "invalid relation %s for type %s", relation.StringValue(), object.Resource.Type.StringValue()) + } + + return &Transaction{Relation: relation, Object: object}, nil +} + +func (transaction *Transaction) UnmarshalJSON(data []byte) error { + var shadow = struct { + Relation Relation + Object Object + }{} + + err := json.Unmarshal(data, &shadow) + if err != nil { + return err + } + + txn, err := NewTransaction(shadow.Relation, shadow.Object) + if err != nil { + return err + } + + *transaction = *txn + return nil +} diff --git a/pkg/types/authtypes/typeable.go b/pkg/types/authtypes/typeable.go index 2b07b2613710..e637d0c35569 100644 --- a/pkg/types/authtypes/typeable.go +++ b/pkg/types/authtypes/typeable.go @@ -1,17 +1,16 @@ package authtypes import ( + "encoding/json" + "github.com/SigNoz/signoz/pkg/errors" "github.com/SigNoz/signoz/pkg/valuer" openfgav1 "github.com/openfga/api/proto/openfga/v1" ) var ( - ErrCodeAuthZUnavailable = errors.MustNewCode("authz_unavailable") - ErrCodeAuthZForbidden = errors.MustNewCode("authz_forbidden") - ErrCodeAuthZInvalidSelectorRegex = errors.MustNewCode("authz_invalid_selector_regex") - ErrCodeAuthZUnsupportedRelation = errors.MustNewCode("authz_unsupported_relation") - ErrCodeAuthZInvalidSubject = errors.MustNewCode("authz_invalid_subject") + ErrCodeAuthZUnavailable = errors.MustNewCode("authz_unavailable") + ErrCodeAuthZForbidden = errors.MustNewCode("authz_forbidden") ) var ( @@ -23,14 +22,92 @@ var ( ) var ( - TypeableUser = &user{} - TypeableRole = &role{} - TypeableOrganization = &organization{} + TypeableUser = &typeableUser{} + TypeableRole = &typeableRole{} + TypeableOrganization = &typeableOrganization{} ) type Typeable interface { Type() Type - Tuples(subject string, relation Relation, selector Selector, parentType Typeable, parentSelectors ...Selector) ([]*openfgav1.CheckRequestTupleKey, error) + Name() Name + Prefix() string + Tuples(subject string, relation Relation, selector []Selector) ([]*openfgav1.TupleKey, error) } type Type struct{ valuer.String } + +func MustNewType(input string) Type { + typed, err := NewType(input) + if err != nil { + panic(err) + } + + return typed +} + +func NewType(input string) (Type, error) { + switch input { + case "user": + return TypeUser, nil + case "role": + return TypeRole, nil + case "organization": + return TypeOrganization, nil + case "resource": + return TypeResource, nil + case "resources": + return TypeResources, nil + default: + return Type{}, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid type: %s", input) + } +} + +func (typed *Type) UnmarshalJSON(data []byte) error { + str := "" + err := json.Unmarshal(data, &str) + if err != nil { + return err + } + + shadow, err := NewType(str) + if err != nil { + return err + } + + *typed = shadow + return nil +} + +func NewTypeableFromType(typed Type, name Name) (Typeable, error) { + switch typed { + case TypeRole: + return TypeableRole, nil + case TypeUser: + return TypeableUser, nil + case TypeOrganization: + return TypeableOrganization, nil + case TypeResource: + resource, err := NewTypeableResource(name) + if err != nil { + return nil, err + } + return resource, nil + case TypeResources: + resources, err := NewTypeableResources(name) + if err != nil { + return nil, err + } + return resources, nil + } + + return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid type") +} + +func MustNewTypeableFromType(typed Type, name Name) Typeable { + typeable, err := NewTypeableFromType(typed, name) + if err != nil { + panic(err) + } + + return typeable +} diff --git a/pkg/types/authtypes/typeable_organization.go b/pkg/types/authtypes/typeable_organization.go new file mode 100644 index 000000000000..888e77a608c9 --- /dev/null +++ b/pkg/types/authtypes/typeable_organization.go @@ -0,0 +1,33 @@ +package authtypes + +import ( + "strings" + + openfgav1 "github.com/openfga/api/proto/openfga/v1" +) + +var _ Typeable = new(typeableOrganization) + +type typeableOrganization struct{} + +func (typeableOrganization *typeableOrganization) Tuples(subject string, relation Relation, selector []Selector) ([]*openfgav1.TupleKey, error) { + tuples := make([]*openfgav1.TupleKey, 0) + for _, selector := range selector { + object := strings.Join([]string{typeableOrganization.Type().StringValue(), selector.String()}, ":") + tuples = append(tuples, &openfgav1.TupleKey{User: subject, Relation: relation.StringValue(), Object: object}) + } + + return tuples, nil +} + +func (typeableOrganization *typeableOrganization) Type() Type { + return TypeOrganization +} + +func (typeableOrganization *typeableOrganization) Name() Name { + return MustNewName("organization") +} + +func (typeableOrganization *typeableOrganization) Prefix() string { + return typeableOrganization.Type().StringValue() +} diff --git a/pkg/types/authtypes/typeable_resource.go b/pkg/types/authtypes/typeable_resource.go new file mode 100644 index 000000000000..57251dc72e30 --- /dev/null +++ b/pkg/types/authtypes/typeable_resource.go @@ -0,0 +1,47 @@ +package authtypes + +import ( + "strings" + + openfgav1 "github.com/openfga/api/proto/openfga/v1" +) + +var _ Typeable = new(typeableResource) + +type typeableResource struct { + name Name +} + +func NewTypeableResource(name Name) (Typeable, error) { + return &typeableResource{name: name}, nil +} + +func MustNewTypeableResource(name Name) Typeable { + typeableesource, err := NewTypeableResource(name) + if err != nil { + panic(err) + } + return typeableesource +} + +func (typeableResource *typeableResource) Tuples(subject string, relation Relation, selector []Selector) ([]*openfgav1.TupleKey, error) { + tuples := make([]*openfgav1.TupleKey, 0) + for _, selector := range selector { + object := typeableResource.Prefix() + "/" + selector.String() + tuples = append(tuples, &openfgav1.TupleKey{User: subject, Relation: relation.StringValue(), Object: object}) + } + + return tuples, nil +} + +func (typeableResource *typeableResource) Type() Type { + return TypeResource +} + +func (typeableResource *typeableResource) Name() Name { + return typeableResource.name +} + +func (typeableResource *typeableResource) Prefix() string { + return strings.Join([]string{typeableResource.Type().StringValue(), typeableResource.Name().String()}, ":") +} diff --git a/pkg/types/authtypes/typeable_resources.go b/pkg/types/authtypes/typeable_resources.go new file mode 100644 index 000000000000..8674a5d49f00 --- /dev/null +++ b/pkg/types/authtypes/typeable_resources.go @@ -0,0 +1,47 @@ +package authtypes + +import ( + "strings" + + openfgav1 "github.com/openfga/api/proto/openfga/v1" +) + +var _ Typeable = new(typeableResources) + +type typeableResources struct { + name Name +} + +func NewTypeableResources(name Name) (Typeable, error) { + return &typeableResources{name: name}, nil +} + +func MustNewTypeableResources(name Name) Typeable { + resources, err := NewTypeableResources(name) + if err != nil { + panic(err) + } + return resources +} + +func (typeableResources *typeableResources) Tuples(subject string, relation Relation, selector []Selector) ([]*openfgav1.TupleKey, error) { + tuples := make([]*openfgav1.TupleKey, 0) + for _, selector := range selector { + object := typeableResources.Prefix() + "/" + selector.String() + tuples = append(tuples, &openfgav1.TupleKey{User: subject, Relation: relation.StringValue(), Object: object}) + } + + return tuples, nil +} + +func (typeableResources *typeableResources) Type() Type { + return TypeResources +} + +func (typeableResources *typeableResources) Name() Name { + return typeableResources.name +} + +func (typeableResources *typeableResources) Prefix() string { + return strings.Join([]string{typeableResources.Type().StringValue(), typeableResources.Name().String()}, ":") +} diff --git a/pkg/types/authtypes/typeable_role.go b/pkg/types/authtypes/typeable_role.go new file mode 100644 index 000000000000..2af109116b17 --- /dev/null +++ b/pkg/types/authtypes/typeable_role.go @@ -0,0 +1,33 @@ +package authtypes + +import ( + "strings" + + openfgav1 "github.com/openfga/api/proto/openfga/v1" +) + +var _ Typeable = new(typeableRole) + +type typeableRole struct{} + +func (typeableRole *typeableRole) Tuples(subject string, relation Relation, selector []Selector) ([]*openfgav1.TupleKey, error) { + tuples := make([]*openfgav1.TupleKey, 0) + for _, selector := range selector { + object := strings.Join([]string{typeableRole.Type().StringValue(), selector.String()}, ":") + tuples = append(tuples, &openfgav1.TupleKey{User: subject, Relation: relation.StringValue(), Object: object}) + } + + return tuples, nil +} + +func (typeableRole *typeableRole) Type() Type { + return TypeRole +} + +func (typeableRole *typeableRole) Name() Name { + return MustNewName("role") +} + +func (typeableRole *typeableRole) Prefix() string { + return typeableRole.Type().StringValue() +} diff --git a/pkg/types/authtypes/typeable_user.go b/pkg/types/authtypes/typeable_user.go new file mode 100644 index 000000000000..041c279ebcab --- /dev/null +++ b/pkg/types/authtypes/typeable_user.go @@ -0,0 +1,33 @@ +package authtypes + +import ( + "strings" + + openfgav1 "github.com/openfga/api/proto/openfga/v1" +) + +var _ Typeable = new(typeableUser) + +type typeableUser struct{} + +func (typeableUser *typeableUser) Tuples(subject string, relation Relation, selector []Selector) ([]*openfgav1.TupleKey, error) { + tuples := make([]*openfgav1.TupleKey, 0) + for _, selector := range selector { + object := strings.Join([]string{typeableUser.Type().StringValue(), selector.String()}, ":") + tuples = append(tuples, &openfgav1.TupleKey{User: subject, Relation: relation.StringValue(), Object: object}) + } + + return tuples, nil +} + +func (typeableUser *typeableUser) Type() Type { + return TypeUser +} + +func (typeableUser *typeableUser) Name() Name { + return MustNewName("user") +} + +func (typeableUser *typeableUser) Prefix() string { + return typeableUser.Type().StringValue() +} diff --git a/pkg/types/authtypes/user.go b/pkg/types/authtypes/user.go deleted file mode 100644 index 9de8fd0c2cb6..000000000000 --- a/pkg/types/authtypes/user.go +++ /dev/null @@ -1,31 +0,0 @@ -package authtypes - -import ( - "strings" - - openfgav1 "github.com/openfga/api/proto/openfga/v1" -) - -var _ Typeable = new(user) - -type user struct{} - -func (user *user) Tuples(subject string, relation Relation, selector Selector, parentTypeable Typeable, parentSelectors ...Selector) ([]*openfgav1.CheckRequestTupleKey, error) { - tuples := make([]*openfgav1.CheckRequestTupleKey, 0) - for _, selector := range parentSelectors { - resourcesTuples, err := parentTypeable.Tuples(subject, relation, selector, nil) - if err != nil { - return nil, err - } - tuples = append(tuples, resourcesTuples...) - } - - object := strings.Join([]string{TypeUser.StringValue(), selector.String()}, ":") - tuples = append(tuples, &openfgav1.CheckRequestTupleKey{User: subject, Relation: relation.StringValue(), Object: object}) - - return tuples, nil -} - -func (user *user) Type() Type { - return TypeUser -} diff --git a/pkg/types/dashboardtypes/dashboard.go b/pkg/types/dashboardtypes/dashboard.go index b6a5c2070959..3f6855aafe7f 100644 --- a/pkg/types/dashboardtypes/dashboard.go +++ b/pkg/types/dashboardtypes/dashboard.go @@ -7,10 +7,16 @@ import ( "github.com/SigNoz/signoz/pkg/errors" "github.com/SigNoz/signoz/pkg/types" + "github.com/SigNoz/signoz/pkg/types/authtypes" "github.com/SigNoz/signoz/pkg/valuer" "github.com/uptrace/bun" ) +var ( + ResourceDashboard = authtypes.MustNewTypeableResource(authtypes.MustNewName("dashboard")) + ResourcesDashboards = authtypes.MustNewTypeableResources(authtypes.MustNewName("dashboards")) +) + type StorableDashboard struct { bun.BaseModel `bun:"table:dashboard"` diff --git a/pkg/types/roletypes/role.go b/pkg/types/roletypes/role.go new file mode 100644 index 000000000000..5a4e593c7c0b --- /dev/null +++ b/pkg/types/roletypes/role.go @@ -0,0 +1,224 @@ +package roletypes + +import ( + "encoding/json" + "slices" + "time" + + "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/types" + "github.com/SigNoz/signoz/pkg/types/authtypes" + "github.com/SigNoz/signoz/pkg/valuer" + openfgav1 "github.com/openfga/api/proto/openfga/v1" + "github.com/uptrace/bun" +) + +var ( + ErrCodeRoleInvalidInput = errors.MustNewCode("role_invalid_input") + ErrCodeRoleEmptyPatch = errors.MustNewCode("role_empty_patch") + ErrCodeInvalidTypeRelation = errors.MustNewCode("role_invalid_type_relation") + ErrCodeRoleNotFound = errors.MustNewCode("role_not_found") + ErrCodeRoleFailedTransactionsFromString = errors.MustNewCode("role_failed_transactions_from_string") +) + +type StorableRole struct { + bun.BaseModel `bun:"table:role"` + + types.Identifiable + types.TimeAuditable + DisplayName string `bun:"display_name,type:string"` + Description string `bun:"description,type:string"` + OrgID string `bun:"org_id,type:string"` +} + +type Role struct { + types.Identifiable + types.TimeAuditable + DisplayName string `json:"displayName"` + Description string `json:"description"` + OrgID valuer.UUID `json:"org_id"` +} + +type PostableRole struct { + DisplayName string `json:"displayName"` + Description string `json:"description"` +} + +type PatchableRole struct { + DisplayName *string `json:"displayName"` + Description *string `json:"description"` +} + +type PatchableObjects struct { + Additions []*authtypes.Object `json:"additions"` + Deletions []*authtypes.Object `json:"deletions"` +} + +func NewStorableRoleFromRole(role *Role) (*StorableRole, error) { + return &StorableRole{ + Identifiable: role.Identifiable, + TimeAuditable: role.TimeAuditable, + DisplayName: role.DisplayName, + Description: role.Description, + OrgID: role.OrgID.StringValue(), + }, nil +} + +func NewRoleFromStorableRole(storableRole *StorableRole) (*Role, error) { + orgID, err := valuer.NewUUID(storableRole.OrgID) + if err != nil { + return nil, err + } + + return &Role{ + Identifiable: storableRole.Identifiable, + TimeAuditable: storableRole.TimeAuditable, + DisplayName: storableRole.DisplayName, + Description: storableRole.Description, + OrgID: orgID, + }, nil +} + +func NewRole(displayName, description string, orgID valuer.UUID) *Role { + return &Role{ + Identifiable: types.Identifiable{ + ID: valuer.GenerateUUID(), + }, + TimeAuditable: types.TimeAuditable{ + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, + DisplayName: displayName, + Description: description, + OrgID: orgID, + } +} + +func NewPatchableObjects(additions []*authtypes.Object, deletions []*authtypes.Object, relation authtypes.Relation) (*PatchableObjects, error) { + if len(additions) == 0 && len(deletions) == 0 { + return nil, errors.New(errors.TypeInvalidInput, ErrCodeRoleEmptyPatch, "empty object patch request received, at least one of additions or deletions must be present") + } + + for _, object := range additions { + if !slices.Contains(authtypes.TypeableRelations[object.Resource.Type], relation) { + return nil, errors.Newf(errors.TypeInvalidInput, authtypes.ErrCodeAuthZInvalidRelation, "relation %s is invalid for type %s", relation.StringValue(), object.Resource.Type.StringValue()) + } + } + + for _, object := range deletions { + if !slices.Contains(authtypes.TypeableRelations[object.Resource.Type], relation) { + return nil, errors.Newf(errors.TypeInvalidInput, authtypes.ErrCodeAuthZInvalidRelation, "relation %s is invalid for type %s", relation.StringValue(), object.Resource.Type.StringValue()) + } + } + + return &PatchableObjects{Additions: additions, Deletions: deletions}, nil +} + +func (role *Role) PatchMetadata(displayName, description *string) { + if displayName != nil { + role.DisplayName = *displayName + } + if description != nil { + role.Description = *description + } + role.UpdatedAt = time.Now() +} + +func (role *PostableRole) UnmarshalJSON(data []byte) error { + type shadowPostableRole struct { + DisplayName string `json:"displayName"` + Description string `json:"description"` + } + + var shadowRole shadowPostableRole + if err := json.Unmarshal(data, &shadowRole); err != nil { + return err + } + + if shadowRole.DisplayName == "" { + return errors.New(errors.TypeInvalidInput, ErrCodeRoleInvalidInput, "displayName is missing from the request") + } + + role.DisplayName = shadowRole.DisplayName + role.Description = shadowRole.Description + + return nil +} + +func (role *PatchableRole) UnmarshalJSON(data []byte) error { + type shadowPatchableRole struct { + DisplayName *string `json:"displayName"` + Description *string `json:"description"` + } + + var shadowRole shadowPatchableRole + if err := json.Unmarshal(data, &shadowRole); err != nil { + return err + } + + if shadowRole.DisplayName == nil && shadowRole.Description == nil { + return errors.New(errors.TypeInvalidInput, ErrCodeRoleEmptyPatch, "empty role patch request received, at least one of displayName or description must be present") + } + + role.DisplayName = shadowRole.DisplayName + role.Description = shadowRole.Description + + return nil +} + +func GetAdditionTuples(id valuer.UUID, relation authtypes.Relation, additions []*authtypes.Object) ([]*openfgav1.TupleKey, error) { + tuples := make([]*openfgav1.TupleKey, 0) + + for _, object := range additions { + typeable := authtypes.MustNewTypeableFromType(object.Resource.Type, object.Resource.Name) + transactionTuples, err := typeable.Tuples( + authtypes.MustNewSubject( + authtypes.TypeRole, + id.String(), + authtypes.RelationAssignee, + ), + relation, + []authtypes.Selector{object.Selector}, + ) + if err != nil { + return nil, err + } + + tuples = append(tuples, transactionTuples...) + } + + return tuples, nil +} + +func GetDeletionTuples(id valuer.UUID, relation authtypes.Relation, deletions []*authtypes.Object) ([]*openfgav1.TupleKeyWithoutCondition, error) { + tuples := make([]*openfgav1.TupleKeyWithoutCondition, 0) + + for _, object := range deletions { + typeable := authtypes.MustNewTypeableFromType(object.Resource.Type, object.Resource.Name) + transactionTuples, err := typeable.Tuples( + authtypes.MustNewSubject( + authtypes.TypeRole, + id.String(), + authtypes.RelationAssignee, + ), + relation, + []authtypes.Selector{object.Selector}, + ) + if err != nil { + return nil, err + } + + deletionTuples := make([]*openfgav1.TupleKeyWithoutCondition, len(transactionTuples)) + for idx, tuple := range transactionTuples { + deletionTuples[idx] = &openfgav1.TupleKeyWithoutCondition{ + User: tuple.User, + Relation: tuple.Relation, + Object: tuple.Object, + } + } + + tuples = append(tuples, deletionTuples...) + } + + return tuples, nil +} diff --git a/pkg/types/roletypes/store.go b/pkg/types/roletypes/store.go new file mode 100644 index 000000000000..bb7d1f942c78 --- /dev/null +++ b/pkg/types/roletypes/store.go @@ -0,0 +1,16 @@ +package roletypes + +import ( + "context" + + "github.com/SigNoz/signoz/pkg/valuer" +) + +type Store interface { + Create(context.Context, *StorableRole) error + Get(context.Context, valuer.UUID, valuer.UUID) (*StorableRole, error) + List(context.Context, valuer.UUID) ([]*StorableRole, error) + Update(context.Context, valuer.UUID, *StorableRole) error + Delete(context.Context, valuer.UUID, valuer.UUID) error + RunInTx(context.Context, func(ctx context.Context) error) error +} From 8b21ba5db9eb2b80ad603da370c3ae6f10ee6d04 Mon Sep 17 00:00:00 2001 From: Abhi kumar Date: Mon, 29 Sep 2025 19:12:50 +0530 Subject: [PATCH 2/5] ISSUE:2806 - View traces/logs functionality across the product with new QB (#9207) * fix: issue-2806 view traces/logs functionality across the product with new qb * test: added test for getfilter * test: updated tests --- .../prepareQueryRangePayloadV5.test.ts | 256 ++++++++++++++++++ .../queryRange/prepareQueryRangePayloadV5.ts | 21 +- .../CeleryTask/useNavigateToExplorer.ts | 37 ++- 3 files changed, 301 insertions(+), 13 deletions(-) diff --git a/frontend/src/api/v5/queryRange/prepareQueryRangePayloadV5.test.ts b/frontend/src/api/v5/queryRange/prepareQueryRangePayloadV5.test.ts index 952ee1c09125..d38452993981 100644 --- a/frontend/src/api/v5/queryRange/prepareQueryRangePayloadV5.test.ts +++ b/frontend/src/api/v5/queryRange/prepareQueryRangePayloadV5.test.ts @@ -634,4 +634,260 @@ describe('prepareQueryRangePayloadV5', () => { }), ); }); + + it('builds payload for builder queries with filters array but no filter expression', () => { + const props: GetQueryResultsProps = { + query: { + queryType: EQueryType.QUERY_BUILDER, + id: 'q8', + unit: undefined, + promql: [], + clickhouse_sql: [], + builder: { + queryData: [ + baseBuilderQuery({ + dataSource: DataSource.LOGS, + filter: { expression: '' }, + filters: { + items: [ + { + id: '1', + key: { key: 'service.name', type: 'string' }, + op: '=', + value: 'payment-service', + }, + { + id: '2', + key: { key: 'http.status_code', type: 'number' }, + op: '>=', + value: 400, + }, + { + id: '3', + key: { key: 'message', type: 'string' }, + op: 'contains', + value: 'error', + }, + ], + op: 'AND', + }, + }), + ], + queryFormulas: [], + queryTraceOperator: [], + }, + }, + graphType: PANEL_TYPES.LIST, + selectedTime: 'GLOBAL_TIME', + start, + end, + }; + + const result = prepareQueryRangePayloadV5(props); + + expect(result.legendMap).toEqual({ A: 'Legend A' }); + expect(result.queryPayload.compositeQuery.queries).toHaveLength(1); + + const builderQuery = result.queryPayload.compositeQuery.queries.find( + (q) => q.type === 'builder_query', + ) as QueryEnvelope; + const logSpec = builderQuery.spec as LogBuilderQuery; + + expect(logSpec.name).toBe('A'); + expect(logSpec.signal).toBe('logs'); + expect(logSpec.filter).toEqual({ + expression: + "service.name = 'payment-service' AND http.status_code >= 400 AND message contains 'error'", + }); + }); + + it('uses filter.expression when only expression is provided', () => { + const props: GetQueryResultsProps = { + query: { + queryType: EQueryType.QUERY_BUILDER, + id: 'q9', + unit: undefined, + promql: [], + clickhouse_sql: [], + builder: { + queryData: [ + baseBuilderQuery({ + dataSource: DataSource.LOGS, + filter: { expression: 'http.status_code >= 500' }, + filters: (undefined as unknown) as IBuilderQuery['filters'], + }), + ], + queryFormulas: [], + queryTraceOperator: [], + }, + }, + graphType: PANEL_TYPES.LIST, + selectedTime: 'GLOBAL_TIME', + start, + end, + }; + + const result = prepareQueryRangePayloadV5(props); + const builderQuery = result.queryPayload.compositeQuery.queries.find( + (q) => q.type === 'builder_query', + ) as QueryEnvelope; + const logSpec = builderQuery.spec as LogBuilderQuery; + expect(logSpec.filter).toEqual({ expression: 'http.status_code >= 500' }); + }); + + it('derives expression from filters when filter is undefined', () => { + const props: GetQueryResultsProps = { + query: { + queryType: EQueryType.QUERY_BUILDER, + id: 'q10', + unit: undefined, + promql: [], + clickhouse_sql: [], + builder: { + queryData: [ + baseBuilderQuery({ + dataSource: DataSource.LOGS, + filter: (undefined as unknown) as IBuilderQuery['filter'], + filters: { + items: [ + { + id: '1', + key: { key: 'service.name', type: 'string' }, + op: '=', + value: 'checkout', + }, + ], + op: 'AND', + }, + }), + ], + queryFormulas: [], + queryTraceOperator: [], + }, + }, + graphType: PANEL_TYPES.LIST, + selectedTime: 'GLOBAL_TIME', + start, + end, + }; + + const result = prepareQueryRangePayloadV5(props); + const builderQuery = result.queryPayload.compositeQuery.queries.find( + (q) => q.type === 'builder_query', + ) as QueryEnvelope; + const logSpec = builderQuery.spec as LogBuilderQuery; + expect(logSpec.filter).toEqual({ expression: "service.name = 'checkout'" }); + }); + + it('prefers filter.expression over filters when both are present', () => { + const props: GetQueryResultsProps = { + query: { + queryType: EQueryType.QUERY_BUILDER, + id: 'q11', + unit: undefined, + promql: [], + clickhouse_sql: [], + builder: { + queryData: [ + baseBuilderQuery({ + dataSource: DataSource.LOGS, + filter: { expression: "service.name = 'frontend'" }, + filters: { + items: [ + { + id: '1', + key: { key: 'service.name', type: 'string' }, + op: '=', + value: 'backend', + }, + ], + op: 'AND', + }, + }), + ], + queryFormulas: [], + queryTraceOperator: [], + }, + }, + graphType: PANEL_TYPES.LIST, + selectedTime: 'GLOBAL_TIME', + start, + end, + }; + + const result = prepareQueryRangePayloadV5(props); + const builderQuery = result.queryPayload.compositeQuery.queries.find( + (q) => q.type === 'builder_query', + ) as QueryEnvelope; + const logSpec = builderQuery.spec as LogBuilderQuery; + expect(logSpec.filter).toEqual({ expression: "service.name = 'frontend'" }); + }); + + it('returns empty expression when neither filter nor filters provided', () => { + const props: GetQueryResultsProps = { + query: { + queryType: EQueryType.QUERY_BUILDER, + id: 'q12', + unit: undefined, + promql: [], + clickhouse_sql: [], + builder: { + queryData: [ + baseBuilderQuery({ + dataSource: DataSource.LOGS, + filter: (undefined as unknown) as IBuilderQuery['filter'], + filters: (undefined as unknown) as IBuilderQuery['filters'], + }), + ], + queryFormulas: [], + queryTraceOperator: [], + }, + }, + graphType: PANEL_TYPES.LIST, + selectedTime: 'GLOBAL_TIME', + start, + end, + }; + + const result = prepareQueryRangePayloadV5(props); + const builderQuery = result.queryPayload.compositeQuery.queries.find( + (q) => q.type === 'builder_query', + ) as QueryEnvelope; + const logSpec = builderQuery.spec as LogBuilderQuery; + expect(logSpec.filter).toEqual({ expression: '' }); + }); + + it('returns empty expression when filters provided with empty items', () => { + const props: GetQueryResultsProps = { + query: { + queryType: EQueryType.QUERY_BUILDER, + id: 'q13', + unit: undefined, + promql: [], + clickhouse_sql: [], + builder: { + queryData: [ + baseBuilderQuery({ + dataSource: DataSource.LOGS, + filter: { expression: '' }, + filters: { items: [], op: 'AND' }, + }), + ], + queryFormulas: [], + queryTraceOperator: [], + }, + }, + graphType: PANEL_TYPES.LIST, + selectedTime: 'GLOBAL_TIME', + start, + end, + }; + + const result = prepareQueryRangePayloadV5(props); + const builderQuery = result.queryPayload.compositeQuery.queries.find( + (q) => q.type === 'builder_query', + ) as QueryEnvelope; + const logSpec = builderQuery.spec as LogBuilderQuery; + expect(logSpec.filter).toEqual({ expression: '' }); + }); }); diff --git a/frontend/src/api/v5/queryRange/prepareQueryRangePayloadV5.ts b/frontend/src/api/v5/queryRange/prepareQueryRangePayloadV5.ts index ba4f8f3f8225..d30051716fff 100644 --- a/frontend/src/api/v5/queryRange/prepareQueryRangePayloadV5.ts +++ b/frontend/src/api/v5/queryRange/prepareQueryRangePayloadV5.ts @@ -1,5 +1,6 @@ /* eslint-disable sonarjs/cognitive-complexity */ /* eslint-disable sonarjs/no-identical-functions */ +import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils'; import { PANEL_TYPES } from 'constants/queryBuilder'; import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults'; import getStartEndRangeTime from 'lib/getStartEndRangeTime'; @@ -14,6 +15,7 @@ import { BaseBuilderQuery, FieldContext, FieldDataType, + Filter, FunctionName, GroupByKey, Having, @@ -111,6 +113,23 @@ function isDeprecatedField(fieldName: string): boolean { ); } +function getFilter(queryData: IBuilderQuery): Filter { + const { filter } = queryData; + if (filter?.expression) { + return { + expression: filter.expression, + }; + } + + if (queryData.filters && queryData.filters?.items?.length > 0) { + return convertFiltersToExpression(queryData.filters); + } + + return { + expression: '', + }; +} + function createBaseSpec( queryData: IBuilderQuery, requestType: RequestType, @@ -124,7 +143,7 @@ function createBaseSpec( return { stepInterval: queryData?.stepInterval || null, disabled: queryData.disabled, - filter: queryData?.filter?.expression ? queryData.filter : undefined, + filter: getFilter(queryData), groupBy: queryData.groupBy?.length > 0 ? queryData.groupBy.map( diff --git a/frontend/src/components/CeleryTask/useNavigateToExplorer.ts b/frontend/src/components/CeleryTask/useNavigateToExplorer.ts index ea6b204afe40..bae077887d66 100644 --- a/frontend/src/components/CeleryTask/useNavigateToExplorer.ts +++ b/frontend/src/components/CeleryTask/useNavigateToExplorer.ts @@ -42,18 +42,31 @@ export function useNavigateToExplorer(): ( builder: { ...widgetQuery.builder, queryData: widgetQuery.builder.queryData - .map((item) => ({ - ...item, - dataSource, - aggregateOperator: MetricAggregateOperator.NOOP, - filters: { - ...item.filters, - items: [...(item.filters?.items || []), ...selectedFilters], - op: item.filters?.op || 'AND', - }, - groupBy: [], - disabled: false, - })) + .map((item) => { + // filter out filters with unique ids + const seen = new Set(); + const filterItems = [ + ...(item.filters?.items || []), + ...selectedFilters, + ].filter((item) => { + if (seen.has(item.id)) return false; + seen.add(item.id); + return true; + }); + + return { + ...item, + dataSource, + aggregateOperator: MetricAggregateOperator.NOOP, + filters: { + ...item.filters, + items: filterItems, + op: item.filters?.op || 'AND', + }, + groupBy: [], + disabled: false, + }; + }) .slice(0, 1), queryFormulas: [], }, From 1a1ef5aff85e51dfdb398a1a1c5d6a11ca295c9e Mon Sep 17 00:00:00 2001 From: Amlan Kumar Nandy <45410599+amlannandy@users.noreply.github.com> Date: Tue, 30 Sep 2025 00:03:29 +0700 Subject: [PATCH 3/5] chore: create alerts ux improvements and api integration (#9165) --- frontend/src/api/alerts/createAlertRule.ts | 28 ++ frontend/src/api/alerts/testAlertRule.ts | 28 ++ .../src/container/CreateAlertRule/index.tsx | 11 + .../AlertCondition/AlertCondition.tsx | 54 ++- .../AlertCondition/AlertThreshold.tsx | 104 ++++- .../AlertCondition/AnomalyThreshold.tsx | 89 ++++- .../AlertCondition/ThresholdItem.tsx | 203 ++++++---- .../__tests__/AlertCondition.test.tsx | 37 +- .../__tests__/AlertThreshold.test.tsx | 23 +- .../__tests__/AnomalyThreshold.test.tsx | 30 +- .../__tests__/ThresholdItem.test.tsx | 136 +++---- .../CreateAlertV2/AlertCondition/styles.scss | 259 +++++++++++- .../CreateAlertV2/AlertCondition/types.ts | 24 +- .../CreateAlertV2/AlertCondition/utils.tsx | 367 ++++++++++++++++++ .../CreateAlertHeader/CreateAlertHeader.tsx | 10 - .../__tests__/CreateAlertHeader.test.tsx | 31 +- .../CreateAlertHeader/styles.scss | 90 ++++- .../CreateAlertV2/CreateAlertV2.styles.scss | 27 +- .../container/CreateAlertV2/CreateAlertV2.tsx | 24 +- .../AdvancedOptionItem/styles.scss | 8 + .../EvaluationSettings/AdvancedOptions.tsx | 41 +- .../EvaluationCadence/EvaluationCadence.tsx | 17 +- .../EvaluationCadence/styles.scss | 17 + .../EvaluationWindowDetails.tsx | 20 +- .../EvaluationWindowPopover.tsx | 12 +- .../__tests__/AdvancedOptions.test.tsx | 19 +- .../__tests__/EvaluationCadence.test.tsx | 15 +- .../EvaluationWindowDetails.test.tsx | 9 +- .../EvaluationWindowPopover.test.tsx | 9 +- .../EvaluationSettings/__tests__/testUtils.ts | 5 + .../EvaluationSettings/constants.ts | 87 ++++- .../EvaluationSettings/styles.scss | 26 ++ .../container/CreateAlertV2/Footer/Footer.tsx | 169 ++++++++ .../container/CreateAlertV2/Footer/index.ts | 3 + .../CreateAlertV2/Footer/styles.scss | 51 +++ .../container/CreateAlertV2/Footer/types.ts | 20 + .../container/CreateAlertV2/Footer/utils.tsx | 347 +++++++++++++++++ .../NotificationMessage.tsx | 85 ++-- .../NotificationSettings/styles.scss | 34 ++ .../ChartPreview/ChartPreview.tsx | 32 +- .../QuerySection/QuerySection.tsx | 33 +- .../__tests__/ChartPreview.test.tsx | 2 +- .../__tests__/QuerySection.test.tsx | 7 +- .../CreateAlertV2/QuerySection/styles.scss | 95 +++++ .../CreateAlertV2/Stepper/styles.scss | 19 + .../CreateAlertV2/context/constants.ts | 21 +- .../container/CreateAlertV2/context/index.tsx | 46 +++ .../container/CreateAlertV2/context/types.ts | 37 +- .../container/CreateAlertV2/context/utils.tsx | 44 ++- frontend/src/container/CreateAlertV2/types.ts | 5 + .../src/container/CreateAlertV2/utils.tsx | 18 + .../FormAlertRules/QuerySection.styles.scss | 1 + .../container/FormAlertRules/QuerySection.tsx | 7 +- .../src/hooks/alerts/useCreateAlertRule.ts | 20 + frontend/src/hooks/alerts/useTestAlertRule.ts | 18 + frontend/src/pages/CreateAlert/index.tsx | 10 - frontend/src/types/api/alerts/alertTypesV2.ts | 68 ++++ 57 files changed, 2583 insertions(+), 469 deletions(-) create mode 100644 frontend/src/api/alerts/createAlertRule.ts create mode 100644 frontend/src/api/alerts/testAlertRule.ts create mode 100644 frontend/src/container/CreateAlertV2/Footer/Footer.tsx create mode 100644 frontend/src/container/CreateAlertV2/Footer/index.ts create mode 100644 frontend/src/container/CreateAlertV2/Footer/styles.scss create mode 100644 frontend/src/container/CreateAlertV2/Footer/types.ts create mode 100644 frontend/src/container/CreateAlertV2/Footer/utils.tsx create mode 100644 frontend/src/container/CreateAlertV2/types.ts create mode 100644 frontend/src/hooks/alerts/useCreateAlertRule.ts create mode 100644 frontend/src/hooks/alerts/useTestAlertRule.ts create mode 100644 frontend/src/types/api/alerts/alertTypesV2.ts diff --git a/frontend/src/api/alerts/createAlertRule.ts b/frontend/src/api/alerts/createAlertRule.ts new file mode 100644 index 000000000000..f993a244cfed --- /dev/null +++ b/frontend/src/api/alerts/createAlertRule.ts @@ -0,0 +1,28 @@ +import axios from 'api'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { + AlertRuleV2, + PostableAlertRuleV2, +} from 'types/api/alerts/alertTypesV2'; + +export interface CreateAlertRuleResponse { + data: AlertRuleV2; + status: string; +} + +const createAlertRule = async ( + props: PostableAlertRuleV2, +): Promise | ErrorResponse> => { + const response = await axios.post(`/rules`, { + ...props, + }); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data, + }; +}; + +export default createAlertRule; diff --git a/frontend/src/api/alerts/testAlertRule.ts b/frontend/src/api/alerts/testAlertRule.ts new file mode 100644 index 000000000000..6b2502f325cc --- /dev/null +++ b/frontend/src/api/alerts/testAlertRule.ts @@ -0,0 +1,28 @@ +import axios from 'api'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2'; + +export interface TestAlertRuleResponse { + data: { + alertCount: number; + message: string; + }; + status: string; +} + +const testAlertRule = async ( + props: PostableAlertRuleV2, +): Promise | ErrorResponse> => { + const response = await axios.post(`/testRule`, { + ...props, + }); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data, + }; +}; + +export default testAlertRule; diff --git a/frontend/src/container/CreateAlertRule/index.tsx b/frontend/src/container/CreateAlertRule/index.tsx index 79919950a847..89fc094584d9 100644 --- a/frontend/src/container/CreateAlertRule/index.tsx +++ b/frontend/src/container/CreateAlertRule/index.tsx @@ -2,6 +2,8 @@ import { Form, Row } from 'antd'; import logEvent from 'api/common/logEvent'; import { ENTITY_VERSION_V5 } from 'constants/app'; import { QueryParams } from 'constants/query'; +import CreateAlertV2 from 'container/CreateAlertV2'; +import { showNewCreateAlertsPage } from 'container/CreateAlertV2/utils'; import FormAlertRules, { AlertDetectionTypes } from 'container/FormAlertRules'; import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types'; import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam'; @@ -125,6 +127,15 @@ function CreateRules(): JSX.Element { ); } + const showNewCreateAlertsPageFlag = showNewCreateAlertsPage(); + + if ( + showNewCreateAlertsPageFlag && + alertType !== AlertTypes.ANOMALY_BASED_ALERT + ) { + return ; + } + return ( , APIError>(['getChannels'], { + queryFn: () => getAllChannels(), + }); + const channels = data?.data || []; + const showMultipleTabs = alertType === AlertTypes.ANOMALY_BASED_ALERT || alertType === AlertTypes.METRICS_BASED_ALERT; @@ -27,15 +42,16 @@ function AlertCondition(): JSX.Element { icon: , value: AlertTypes.METRICS_BASED_ALERT, }, - ...(showMultipleTabs - ? [ - { - label: 'Anomaly', - icon: , - value: AlertTypes.ANOMALY_BASED_ALERT, - }, - ] - : []), + // Hide anomaly tab for now + // ...(showMultipleTabs + // ? [ + // { + // label: 'Anomaly', + // icon: , + // value: AlertTypes.ANOMALY_BASED_ALERT, + // }, + // ] + // : []), ]; const handleAlertTypeChange = (value: AlertTypes): void => { @@ -76,8 +92,22 @@ function AlertCondition(): JSX.Element { ))} - {alertType !== AlertTypes.ANOMALY_BASED_ALERT && } - {alertType === AlertTypes.ANOMALY_BASED_ALERT && } + {alertType !== AlertTypes.ANOMALY_BASED_ALERT && ( + + )} + {alertType === AlertTypes.ANOMALY_BASED_ALERT && ( + + )} {showCondensedLayoutFlag ? (
diff --git a/frontend/src/container/CreateAlertV2/AlertCondition/AlertThreshold.tsx b/frontend/src/container/CreateAlertV2/AlertCondition/AlertThreshold.tsx index ca1934a774a6..c2d57e4c3829 100644 --- a/frontend/src/container/CreateAlertV2/AlertCondition/AlertThreshold.tsx +++ b/frontend/src/container/CreateAlertV2/AlertCondition/AlertThreshold.tsx @@ -1,14 +1,10 @@ import './styles.scss'; +import '../EvaluationSettings/styles.scss'; -import { Button, Select, Typography } from 'antd'; -import getAllChannels from 'api/channels/getAll'; +import { Button, Select, Tooltip, Typography } from 'antd'; import classNames from 'classnames'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { Plus } from 'lucide-react'; -import { useQuery } from 'react-query'; -import { SuccessResponseV2 } from 'types/api'; -import { Channels } from 'types/api/channels/getAll'; -import APIError from 'types/api/error'; import { useCreateAlertState } from '../context'; import { @@ -21,27 +17,30 @@ import { import EvaluationSettings from '../EvaluationSettings/EvaluationSettings'; import { showCondensedLayout } from '../utils'; import ThresholdItem from './ThresholdItem'; -import { UpdateThreshold } from './types'; +import { AnomalyAndThresholdProps, UpdateThreshold } from './types'; import { getCategoryByOptionId, getCategorySelectOptionByName, + getMatchTypeTooltip, getQueryNames, + RoutingPolicyBanner, } from './utils'; -function AlertThreshold(): JSX.Element { +function AlertThreshold({ + channels, + isLoadingChannels, + isErrorChannels, + refreshChannels, +}: AnomalyAndThresholdProps): JSX.Element { const { alertState, thresholdState, setThresholdState, + notificationSettings, + setNotificationSettings, } = useCreateAlertState(); - const { data, isLoading: isLoadingChannels } = useQuery< - SuccessResponseV2, - APIError - >(['getChannels'], { - queryFn: () => getAllChannels(), - }); + const showCondensedLayoutFlag = showCondensedLayout(); - const channels = data?.data || []; const { currentQuery } = useQueryBuilder(); @@ -85,6 +84,65 @@ function AlertThreshold(): JSX.Element { }); }; + const onTooltipOpenChange = (open: boolean): void => { + // Stop propagation of click events on tooltip text to dropdown + if (open) { + setTimeout(() => { + const tooltipElement = document.querySelector( + '.copyable-tooltip .ant-tooltip-inner', + ); + if (tooltipElement) { + tooltipElement.addEventListener( + 'click', + (e) => { + e.stopPropagation(); + e.preventDefault(); + }, + true, + ); + tooltipElement.addEventListener( + 'mousedown', + (e) => { + e.stopPropagation(); + e.preventDefault(); + }, + true, + ); + } + }, 0); + } + }; + + const matchTypeOptionsWithTooltips = THRESHOLD_MATCH_TYPE_OPTIONS.map( + (option) => ({ + ...option, + label: ( + + {option.label} + + ), + }), + ); + const evaluationWindowContext = showCondensedLayoutFlag ? ( ) : ( @@ -114,8 +172,7 @@ function AlertThreshold(): JSX.Element { style={{ width: 80 }} options={queryNames} /> -
-
+ is + updateThreshold(thresholdState.thresholds[0].id, 'channels', value) + } + style={{ width: 350 }} + options={channels.map((channel) => ({ + value: channel.id, + label: channel.name, + }))} + mode="multiple" + placeholder="Select notification channels" + showSearch + maxTagCount={2} + maxTagPlaceholder={(omittedValues): string => + `+${omittedValues.length} more` + } + maxTagTextLength={10} + filterOption={(input, option): boolean => + option?.label?.toLowerCase().includes(input.toLowerCase()) || false + } + status={isErrorChannels ? 'error' : undefined} + disabled={isLoadingChannels} + notFoundContent={ + + } + /> + + ) : ( + + seasonality + + )}
+ ); } diff --git a/frontend/src/container/CreateAlertV2/AlertCondition/ThresholdItem.tsx b/frontend/src/container/CreateAlertV2/AlertCondition/ThresholdItem.tsx index 1d59fddbeb02..836c4761d822 100644 --- a/frontend/src/container/CreateAlertV2/AlertCondition/ThresholdItem.tsx +++ b/frontend/src/container/CreateAlertV2/AlertCondition/ThresholdItem.tsx @@ -1,8 +1,12 @@ -import { Button, Input, Select, Space, Tooltip, Typography } from 'antd'; -import { ChartLine, CircleX } from 'lucide-react'; +import { Button, Input, Select, Tooltip, Typography } from 'antd'; +import { CircleX, Trash } from 'lucide-react'; +import { useAppContext } from 'providers/App/App'; import { useMemo, useState } from 'react'; +import { useCreateAlertState } from '../context'; +import { AlertThresholdOperator } from '../context/types'; import { ThresholdItemProps } from './types'; +import { NotificationChannelsNotFoundContent } from './utils'; function ThresholdItem({ threshold, @@ -11,7 +15,12 @@ function ThresholdItem({ showRemoveButton, channels, units, + isErrorChannels, + refreshChannels, + isLoadingChannels, }: ThresholdItemProps): JSX.Element { + const { user } = useAppContext(); + const { thresholdState, notificationSettings } = useCreateAlertState(); const [showRecoveryThreshold, setShowRecoveryThreshold] = useState(false); const yAxisUnitSelect = useMemo(() => { @@ -45,6 +54,31 @@ function ThresholdItem({ return component; }, [units, threshold.unit, updateThreshold, threshold.id]); + const getOperatorSymbol = (): string => { + switch (thresholdState.operator) { + case AlertThresholdOperator.IS_ABOVE: + return '>'; + case AlertThresholdOperator.IS_BELOW: + return '<'; + case AlertThresholdOperator.IS_EQUAL_TO: + return '='; + case AlertThresholdOperator.IS_NOT_EQUAL_TO: + return '!='; + default: + return ''; + } + }; + + // const addRecoveryThreshold = (): void => { + // setShowRecoveryThreshold(true); + // updateThreshold(threshold.id, 'recoveryThresholdValue', 0); + // }; + + const removeRecoveryThreshold = (): void => { + setShowRecoveryThreshold(false); + updateThreshold(threshold.id, 'recoveryThresholdValue', null); + }; + return (
@@ -54,80 +88,111 @@ function ThresholdItem({ style={{ backgroundColor: threshold.color }} />
- -
- - - updateThreshold(threshold.id, 'label', e.target.value) - } - style={{ width: 260 }} - /> - - updateThreshold(threshold.id, 'thresholdValue', e.target.value) - } - style={{ width: 210 }} - /> - {yAxisUnitSelect} - -
- to - + updateThreshold(threshold.id, 'label', e.target.value) } - style={{ width: 260 }} - options={channels.map((channel) => ({ - value: channel.id, - label: channel.name, - }))} - mode="multiple" - placeholder="Select notification channels" + style={{ width: 200 }} /> + on value + + {getOperatorSymbol()} + + + updateThreshold(threshold.id, 'thresholdValue', e.target.value) + } + style={{ width: 100 }} + type="number" + /> + {yAxisUnitSelect} + {!notificationSettings.routingPolicies && ( + <> + send to + + updateThreshold(threshold.id, 'recoveryThresholdValue', e.target.value) + } + style={{ width: 100 }} + type="number" + /> + +
- {showRecoveryThreshold && ( - - - - updateThreshold(threshold.id, 'recoveryThresholdValue', e.target.value) - } - style={{ width: 210 }} - /> - - )} ); } diff --git a/frontend/src/container/CreateAlertV2/AlertCondition/__tests__/AlertCondition.test.tsx b/frontend/src/container/CreateAlertV2/AlertCondition/__tests__/AlertCondition.test.tsx index 7823616a10db..fd5e8bd10e55 100644 --- a/frontend/src/container/CreateAlertV2/AlertCondition/__tests__/AlertCondition.test.tsx +++ b/frontend/src/container/CreateAlertV2/AlertCondition/__tests__/AlertCondition.test.tsx @@ -3,6 +3,7 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from 'react-query'; import { MemoryRouter } from 'react-router-dom'; +import { AlertTypes } from 'types/api/alerts/alertTypes'; import { CreateAlertProvider } from '../../context'; import AlertCondition from '../AlertCondition'; @@ -105,7 +106,7 @@ const renderAlertCondition = ( return render( - + @@ -126,9 +127,10 @@ describe('AlertCondition', () => { // Verify default alertType is METRICS_BASED_ALERT (shows AlertThreshold component) expect(screen.getByTestId(ALERT_THRESHOLD_TEST_ID)).toBeInTheDocument(); - expect( - screen.queryByTestId(ANOMALY_THRESHOLD_TEST_ID), - ).not.toBeInTheDocument(); + // TODO: uncomment this when anomaly tab is implemented + // expect( + // screen.queryByTestId(ANOMALY_THRESHOLD_TEST_ID), + // ).not.toBeInTheDocument(); // Verify threshold tab is active by default const thresholdTab = screen.getByText(THRESHOLD_TAB_TEXT); @@ -136,7 +138,8 @@ describe('AlertCondition', () => { // Verify both tabs are visible (METRICS_BASED_ALERT supports multiple tabs) expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument(); - expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument(); + // TODO: uncomment this when anomaly tab is implemented + // expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument(); }); it('renders threshold tab by default', () => { @@ -151,7 +154,8 @@ describe('AlertCondition', () => { ).not.toBeInTheDocument(); }); - it('renders anomaly tab when alert type supports multiple tabs', () => { + // TODO: Unskip this when anomaly tab is implemented + it.skip('renders anomaly tab when alert type supports multiple tabs', () => { renderAlertCondition(); expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument(); expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument(); @@ -165,7 +169,8 @@ describe('AlertCondition', () => { ).not.toBeInTheDocument(); }); - it('shows AnomalyThreshold component when alert type is anomaly based', () => { + // TODO: Unskip this when anomaly tab is implemented + it.skip('shows AnomalyThreshold component when alert type is anomaly based', () => { renderAlertCondition(); // Click on anomaly tab to switch to anomaly-based alert @@ -176,7 +181,8 @@ describe('AlertCondition', () => { expect(screen.queryByTestId(ALERT_THRESHOLD_TEST_ID)).not.toBeInTheDocument(); }); - it('switches between threshold and anomaly tabs', () => { + // TODO: Unskip this when anomaly tab is implemented + it.skip('switches between threshold and anomaly tabs', () => { renderAlertCondition(); // Initially shows threshold component @@ -201,7 +207,8 @@ describe('AlertCondition', () => { ).not.toBeInTheDocument(); }); - it('applies active tab styling correctly', () => { + // TODO: Unskip this when anomaly tab is implemented + it.skip('applies active tab styling correctly', () => { renderAlertCondition(); const thresholdTab = screen.getByText(THRESHOLD_TAB_TEXT); @@ -222,21 +229,21 @@ describe('AlertCondition', () => { it('shows multiple tabs for METRICS_BASED_ALERT', () => { renderAlertCondition('METRIC_BASED_ALERT'); - // Both tabs should be visible + // TODO: uncomment this when anomaly tab is implemented expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument(); - expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument(); + // expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument(); expect(screen.getByTestId(THRESHOLD_VIEW_TEST_ID)).toBeInTheDocument(); - expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument(); + // expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument(); }); it('shows multiple tabs for ANOMALY_BASED_ALERT', () => { renderAlertCondition('ANOMALY_BASED_ALERT'); - // Both tabs should be visible expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument(); - expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument(); expect(screen.getByTestId(THRESHOLD_VIEW_TEST_ID)).toBeInTheDocument(); - expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument(); + // TODO: uncomment this when anomaly tab is implemented + // expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument(); + // expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument(); }); it('shows only threshold tab for LOGS_BASED_ALERT', () => { diff --git a/frontend/src/container/CreateAlertV2/AlertCondition/__tests__/AlertThreshold.test.tsx b/frontend/src/container/CreateAlertV2/AlertCondition/__tests__/AlertThreshold.test.tsx index dd4b6385f343..78cfadcb5bbe 100644 --- a/frontend/src/container/CreateAlertV2/AlertCondition/__tests__/AlertThreshold.test.tsx +++ b/frontend/src/container/CreateAlertV2/AlertCondition/__tests__/AlertThreshold.test.tsx @@ -3,11 +3,23 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from 'react-query'; import { MemoryRouter } from 'react-router-dom'; +import { AlertTypes } from 'types/api/alerts/alertTypes'; import { Channels } from 'types/api/channels/getAll'; import { CreateAlertProvider } from '../../context'; import AlertThreshold from '../AlertThreshold'; +const mockChannels: Channels[] = []; +const mockRefreshChannels = jest.fn(); +const mockIsLoadingChannels = false; +const mockIsErrorChannels = false; +const mockProps = { + channels: mockChannels, + isLoadingChannels: mockIsLoadingChannels, + isErrorChannels: mockIsErrorChannels, + refreshChannels: mockRefreshChannels, +}; + jest.mock('uplot', () => { const paths = { spline: jest.fn(), @@ -99,7 +111,7 @@ jest.mock('container/NewWidget/RightContainer/alertFomatCategories', () => ({ const TEST_STRINGS = { ADD_THRESHOLD: 'Add Threshold', AT_LEAST_ONCE: 'AT LEAST ONCE', - IS_ABOVE: 'IS ABOVE', + IS_ABOVE: 'ABOVE', } as const; const createTestQueryClient = (): QueryClient => @@ -116,8 +128,8 @@ const renderAlertThreshold = (): ReturnType => { return render( - - + + , @@ -125,7 +137,10 @@ const renderAlertThreshold = (): ReturnType => { }; const verifySelectRenders = (title: string): void => { - const select = screen.getByTitle(title); + let select = screen.queryByTitle(title); + if (!select) { + select = screen.getByText(title); + } expect(select).toBeInTheDocument(); }; diff --git a/frontend/src/container/CreateAlertV2/AlertCondition/__tests__/AnomalyThreshold.test.tsx b/frontend/src/container/CreateAlertV2/AlertCondition/__tests__/AnomalyThreshold.test.tsx index 6729519830f5..1e2a6e982684 100644 --- a/frontend/src/container/CreateAlertV2/AlertCondition/__tests__/AnomalyThreshold.test.tsx +++ b/frontend/src/container/CreateAlertV2/AlertCondition/__tests__/AnomalyThreshold.test.tsx @@ -1,14 +1,15 @@ /* eslint-disable react/jsx-props-no-spreading */ /* eslint-disable @typescript-eslint/no-explicit-any */ import { render, screen } from '@testing-library/react'; -import { - INITIAL_ALERT_STATE, - INITIAL_ALERT_THRESHOLD_STATE, -} from 'container/CreateAlertV2/context/constants'; +import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils'; +import { getAppContextMockState } from 'container/RoutingPolicies/__tests__/testUtils'; +import * as appHooks from 'providers/App/App'; import * as context from '../../context'; import AnomalyThreshold from '../AnomalyThreshold'; +jest.spyOn(appHooks, 'useAppContext').mockReturnValue(getAppContextMockState()); + jest.mock('uplot', () => { const paths = { spline: jest.fn(), @@ -23,12 +24,12 @@ jest.mock('uplot', () => { const mockSetAlertState = jest.fn(); const mockSetThresholdState = jest.fn(); -jest.spyOn(context, 'useCreateAlertState').mockReturnValue({ - alertState: INITIAL_ALERT_STATE, - setAlertState: mockSetAlertState, - thresholdState: INITIAL_ALERT_THRESHOLD_STATE, - setThresholdState: mockSetThresholdState, -} as any); +jest.spyOn(context, 'useCreateAlertState').mockReturnValue( + createMockAlertContextState({ + setThresholdState: mockSetThresholdState, + setAlertState: mockSetAlertState, + }), +); // Mock useQueryBuilder hook jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({ @@ -54,7 +55,14 @@ jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({ })); const renderAnomalyThreshold = (): ReturnType => - render(); + render( + , + ); describe('AnomalyThreshold', () => { beforeEach(() => { diff --git a/frontend/src/container/CreateAlertV2/AlertCondition/__tests__/ThresholdItem.test.tsx b/frontend/src/container/CreateAlertV2/AlertCondition/__tests__/ThresholdItem.test.tsx index 01fcbf97ce92..17300be43241 100644 --- a/frontend/src/container/CreateAlertV2/AlertCondition/__tests__/ThresholdItem.test.tsx +++ b/frontend/src/container/CreateAlertV2/AlertCondition/__tests__/ThresholdItem.test.tsx @@ -2,15 +2,37 @@ /* eslint-disable react/jsx-props-no-spreading */ import { fireEvent, render, screen } from '@testing-library/react'; import { DefaultOptionType } from 'antd/es/select'; +import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils'; +import { getAppContextMockState } from 'container/RoutingPolicies/__tests__/testUtils'; +import * as appHooks from 'providers/App/App'; import { Channels } from 'types/api/channels/getAll'; +import * as context from '../../context'; import ThresholdItem from '../ThresholdItem'; import { ThresholdItemProps } from '../types'; -// Mock the enableRecoveryThreshold utility -jest.mock('../../utils', () => ({ - enableRecoveryThreshold: jest.fn(() => true), -})); +jest.spyOn(appHooks, 'useAppContext').mockReturnValue(getAppContextMockState()); + +jest.mock('uplot', () => { + const paths = { + spline: jest.fn(), + bars: jest.fn(), + }; + const uplotMock: any = jest.fn(() => ({ + paths, + })); + uplotMock.paths = paths; + return uplotMock; +}); + +const mockSetAlertState = jest.fn(); +const mockSetThresholdState = jest.fn(); +jest.spyOn(context, 'useCreateAlertState').mockReturnValue( + createMockAlertContextState({ + setThresholdState: mockSetThresholdState, + setAlertState: mockSetAlertState, + }), +); const TEST_CONSTANTS = { THRESHOLD_ID: 'test-threshold-1', @@ -21,6 +43,7 @@ const TEST_CONSTANTS = { CHANNEL_2: 'channel-2', CHANNEL_3: 'channel-3', EMAIL_CHANNEL_NAME: 'Email Channel', + EMAIL_CHANNEL_TRUNCATED: 'Email Chan...', ENTER_THRESHOLD_NAME: 'Enter threshold name', ENTER_THRESHOLD_VALUE: 'Enter threshold value', ENTER_RECOVERY_THRESHOLD_VALUE: 'Enter recovery threshold value', @@ -59,6 +82,8 @@ const defaultProps: ThresholdItemProps = { channels: mockChannels, isLoadingChannels: false, units: mockUnits, + isErrorChannels: false, + refreshChannels: jest.fn(), }; const renderThresholdItem = ( @@ -77,10 +102,11 @@ const verifySelectorWidth = ( expect(selector.closest('.ant-select')).toHaveStyle(`width: ${expectedWidth}`); }; -const showRecoveryThreshold = (): void => { - const recoveryButton = screen.getByRole('button', { name: '' }); - fireEvent.click(recoveryButton); -}; +// TODO: Unskip this when recovery threshold is implemented +// const showRecoveryThreshold = (): void => { +// const recoveryButton = screen.getByRole('button', { name: '' }); +// fireEvent.click(recoveryButton); +// }; const verifyComponentRendersWithLoading = (): void => { expect( @@ -122,7 +148,7 @@ describe('ThresholdItem', () => { const valueInput = screen.getByPlaceholderText( TEST_CONSTANTS.ENTER_THRESHOLD_VALUE, ); - expect(valueInput).toHaveValue('100'); + expect(valueInput).toHaveValue(100); }); it('renders unit selector with correct value', () => { @@ -132,15 +158,6 @@ describe('ThresholdItem', () => { expect(screen.getByText('Bytes')).toBeInTheDocument(); }); - it('renders channels selector with correct value', () => { - renderThresholdItem(); - - // Check for the channels selector by looking for the displayed text - expect( - screen.getByText(TEST_CONSTANTS.EMAIL_CHANNEL_NAME), - ).toBeInTheDocument(); - }); - it('updates threshold label when label input changes', () => { const updateThreshold = jest.fn(); renderThresholdItem({ updateThreshold }); @@ -212,38 +229,31 @@ describe('ThresholdItem', () => { // The remove button is the second button (with circle-x icon) const buttons = screen.getAllByRole('button'); - expect(buttons).toHaveLength(2); // Recovery button + remove button + expect(buttons).toHaveLength(1); // remove button }); it('does not show remove button when showRemoveButton is false', () => { renderThresholdItem({ showRemoveButton: false }); - // Only the recovery button should be present - const buttons = screen.getAllByRole('button'); - expect(buttons).toHaveLength(1); // Only recovery button + // No buttons should be present + const buttons = screen.queryAllByRole('button'); + expect(buttons).toHaveLength(0); }); it('calls removeThreshold when remove button is clicked', () => { const removeThreshold = jest.fn(); renderThresholdItem({ showRemoveButton: true, removeThreshold }); - // The remove button is the second button (with circle-x icon) + // The remove button is the first button (with circle-x icon) const buttons = screen.getAllByRole('button'); - const removeButton = buttons[1]; // Second button is the remove button + const removeButton = buttons[0]; fireEvent.click(removeButton); expect(removeThreshold).toHaveBeenCalledWith(TEST_CONSTANTS.THRESHOLD_ID); }); - it('shows recovery threshold button when recovery threshold is enabled', () => { - renderThresholdItem(); - - // The recovery button is the first button (with chart-line icon) - const buttons = screen.getAllByRole('button'); - expect(buttons).toHaveLength(1); // Recovery button - }); - - it('shows recovery threshold inputs when recovery button is clicked', () => { + // TODO: Unskip this when recovery threshold is implemented + it.skip('shows recovery threshold inputs when recovery button is clicked', () => { renderThresholdItem(); // The recovery button is the first button (with chart-line icon) @@ -251,13 +261,16 @@ describe('ThresholdItem', () => { const recoveryButton = buttons[0]; // First button is the recovery button fireEvent.click(recoveryButton); - expect(screen.getByPlaceholderText('Recovery threshold')).toBeInTheDocument(); + expect( + screen.getByPlaceholderText('Enter recovery threshold value'), + ).toBeInTheDocument(); expect( screen.getByPlaceholderText(TEST_CONSTANTS.ENTER_RECOVERY_THRESHOLD_VALUE), ).toBeInTheDocument(); }); - it('updates recovery threshold value when input changes', () => { + // TODO: Unskip this when recovery threshold is implemented + it.skip('updates recovery threshold value when input changes', () => { const updateThreshold = jest.fn(); renderThresholdItem({ updateThreshold }); @@ -290,22 +303,6 @@ describe('ThresholdItem', () => { verifyUnitSelectorDisabled(); }); - it('renders channels as multiple select options', () => { - renderThresholdItem(); - - // Check that channels are rendered as multiple select - expect( - screen.getByText(TEST_CONSTANTS.EMAIL_CHANNEL_NAME), - ).toBeInTheDocument(); - - // Should be able to select multiple channels - const channelSelectors = screen.getAllByRole('combobox'); - const channelSelector = channelSelectors[1]; // Second combobox is the channels selector - fireEvent.change(channelSelector, { - target: { value: [TEST_CONSTANTS.CHANNEL_1, TEST_CONSTANTS.CHANNEL_2] }, - }); - }); - it('handles empty threshold values correctly', () => { const emptyThreshold = { ...mockThreshold, @@ -318,7 +315,7 @@ describe('ThresholdItem', () => { renderThresholdItem({ threshold: emptyThreshold }); expect(screen.getByPlaceholderText('Enter threshold name')).toHaveValue(''); - expect(screen.getByPlaceholderText('Enter threshold value')).toHaveValue('0'); + expect(screen.getByPlaceholderText('Enter threshold value')).toHaveValue(0); }); it('renders with correct input widths', () => { @@ -331,13 +328,13 @@ describe('ThresholdItem', () => { TEST_CONSTANTS.ENTER_THRESHOLD_VALUE, ); - expect(labelInput).toHaveStyle('width: 260px'); - expect(valueInput).toHaveStyle('width: 210px'); + expect(labelInput).toHaveStyle('width: 200px'); + expect(valueInput).toHaveStyle('width: 100px'); }); it('renders channels selector with correct width', () => { renderThresholdItem(); - verifySelectorWidth(1, '260px'); + verifySelectorWidth(1, '350px'); }); it('renders unit selector with correct width', () => { @@ -350,37 +347,14 @@ describe('ThresholdItem', () => { verifyComponentRendersWithLoading(); }); - it('renders recovery threshold with correct initial value', () => { + it.skip('renders recovery threshold with correct initial value', () => { renderThresholdItem(); - showRecoveryThreshold(); + // showRecoveryThreshold(); const recoveryValueInput = screen.getByPlaceholderText( TEST_CONSTANTS.ENTER_RECOVERY_THRESHOLD_VALUE, ); - expect(recoveryValueInput).toHaveValue('80'); - }); - - it('renders recovery threshold label as disabled', () => { - renderThresholdItem(); - showRecoveryThreshold(); - - const recoveryLabelInput = screen.getByPlaceholderText('Recovery threshold'); - expect(recoveryLabelInput).toBeDisabled(); - }); - - it('renders correct channel options', () => { - renderThresholdItem(); - - // Check that channels are rendered - expect( - screen.getByText(TEST_CONSTANTS.EMAIL_CHANNEL_NAME), - ).toBeInTheDocument(); - - // Should be able to select different channels - const channelSelectors = screen.getAllByRole('combobox'); - const channelSelector = channelSelectors[1]; // Second combobox is the channels selector - fireEvent.change(channelSelector, { target: { value: 'channel-2' } }); - expect(screen.getByText('Slack Channel')).toBeInTheDocument(); + expect(recoveryValueInput).toHaveValue(80); }); it('handles threshold without channels', () => { diff --git a/frontend/src/container/CreateAlertV2/AlertCondition/styles.scss b/frontend/src/container/CreateAlertV2/AlertCondition/styles.scss index 8e9fc6e223cc..d8ef5bd295d9 100644 --- a/frontend/src/container/CreateAlertV2/AlertCondition/styles.scss +++ b/frontend/src/container/CreateAlertV2/AlertCondition/styles.scss @@ -67,7 +67,7 @@ padding-right: 72px; background-color: var(--bg-ink-500); border: 1px solid var(--bg-slate-400); - width: fit-content; + width: 100%; .alert-condition-sentences { display: flex; @@ -90,7 +90,7 @@ } .ant-select { - width: 240px !important; + width: 240px; .ant-select-selector { background-color: var(--bg-ink-300); @@ -148,6 +148,7 @@ display: flex; align-items: center; gap: 8px; + flex-wrap: wrap; .ant-input { background-color: var(--bg-ink-400); @@ -277,6 +278,29 @@ } } } + + .routing-policies-info-banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-top: 16px; + background-color: #4568dc1a; + border: 1px solid var(--bg-robin-500); + padding: 8px 16px; + + .ant-typography { + color: var(--bg-robin-500); + } + } +} + +.anomaly-threshold-container { + .ant-select { + .ant-select-selector { + min-width: 150px; + } + } } .condensed-alert-threshold-container, @@ -293,7 +317,8 @@ .ant-btn { display: flex; align-items: center; - width: 240px; + min-width: 240px; + width: auto; justify-content: space-between; background-color: var(--bg-ink-300); border: 1px solid var(--bg-slate-400); @@ -301,6 +326,7 @@ .evaluate-alert-conditions-button-left { color: var(--bg-vanilla-400); font-size: 12px; + flex-shrink: 0; } .evaluate-alert-conditions-button-right { @@ -308,6 +334,7 @@ align-items: center; color: var(--bg-vanilla-400); gap: 8px; + flex-shrink: 0; .evaluate-alert-conditions-button-right-text { font-size: 12px; @@ -318,3 +345,229 @@ } } } + +.lightMode { + .alert-condition-container { + .alert-condition { + .alert-condition-tabs { + border: 1px solid var(--bg-vanilla-300); + background: var(--bg-vanilla-300); + + .explorer-view-option { + border-left: 0.5px solid var(--bg-vanilla-300); + border-bottom: 0.5px solid var(--bg-vanilla-300); + + &.active-tab { + background-color: var(--bg-vanilla-100); + + &:hover { + background-color: var(--bg-vanilla-100) !important; + } + } + + &:disabled { + background-color: var(--bg-vanilla-300); + } + + &:hover { + color: var(--bg-ink-400); + } + } + } + } + } + + .alert-threshold-container, + .anomaly-threshold-container { + background-color: var(--bg-vanilla-100); + border: 1px solid var(--bg-vanilla-300); + + .alert-condition-sentences { + .alert-condition-sentence { + .sentence-text { + color: var(--text-ink-400); + } + + .ant-select { + .ant-select-selector { + background-color: var(--bg-vanilla-300); + border: 1px solid var(--bg-vanilla-300); + color: var(--text-ink-400); + + &:hover { + border-color: var(--bg-ink-300); + } + + &:focus { + border-color: var(--bg-ink-300); + box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1); + } + } + + .ant-select-selection-item { + color: var(--bg-ink-400); + } + + .ant-select-arrow { + color: var(--bg-ink-400); + } + } + } + } + + .thresholds-section { + .threshold-item { + .threshold-row { + .threshold-controls { + .threshold-inputs { + display: flex; + align-items: center; + gap: 8px; + } + + .ant-input { + background-color: var(--bg-vanilla-200); + border: 1px solid var(--bg-vanilla-300); + color: var(--bg-ink-400); + + &:hover { + border-color: var(--bg-ink-300); + } + + &:focus { + border-color: var(--bg-ink-300); + box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1); + } + } + + .ant-select { + .ant-select-selector { + background-color: var(--bg-vanilla-200); + border: 1px solid var(--bg-vanilla-300); + color: var(--bg-ink-400); + + &:hover { + border-color: var(--bg-ink-300); + } + + &:focus { + border-color: var(--bg-ink-300); + box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1); + } + } + + .ant-select-selection-item { + color: var(--bg-ink-400); + } + + .ant-select-arrow { + color: var(--bg-ink-400); + } + } + + .icon-btn { + color: var(--bg-ink-400); + border: 1px solid var(--bg-vanilla-300); + } + } + } + + .recovery-threshold-input-group { + .recovery-threshold-btn { + color: var(--bg-ink-400); + background-color: var(--bg-vanilla-200) !important; + border: 1px solid var(--bg-vanilla-300); + } + + .ant-input { + background-color: var(--bg-vanilla-200); + border: 1px solid var(--bg-vanilla-300); + color: var(--bg-ink-400); + + &:hover { + border-color: var(--bg-ink-300); + } + + &:focus { + border-color: var(--bg-ink-300); + box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1); + } + } + } + + .add-threshold-btn { + border: 1px dashed var(--bg-vanilla-300); + color: var(--bg-ink-300); + + &:hover { + border-color: var(--bg-ink-300); + color: var(--bg-ink-400); + } + } + } + } + } + + .condensed-evaluation-settings-container { + .ant-btn { + background-color: var(--bg-vanilla-300); + border: 1px solid var(--bg-vanilla-300); + min-width: 240px; + width: auto; + + .evaluate-alert-conditions-button-left { + color: var(--bg-ink-400); + flex-shrink: 0; + } + + .evaluate-alert-conditions-button-right { + color: var(--bg-ink-400); + flex-shrink: 0; + + .evaluate-alert-conditions-button-right-text { + background-color: var(--bg-vanilla-300); + } + } + } + } +} + +.highlighted-text { + font-weight: bold; + color: var(--bg-robin-400); + margin: 0 4px; +} + +// Tooltip styles +.tooltip-content { + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-start; + + .tooltip-description { + margin-bottom: 8px; + + span { + font-weight: bold; + color: var(--bg-robin-400); + } + } + + .tooltip-example { + margin-bottom: 8px; + color: #8b92a0; + } + + .tooltip-link { + .tooltip-link-text { + color: #1890ff; + font-size: 11px; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + } +} diff --git a/frontend/src/container/CreateAlertV2/AlertCondition/types.ts b/frontend/src/container/CreateAlertV2/AlertCondition/types.ts index 382955b58a10..a6dd211089b7 100644 --- a/frontend/src/container/CreateAlertV2/AlertCondition/types.ts +++ b/frontend/src/container/CreateAlertV2/AlertCondition/types.ts @@ -1,14 +1,18 @@ import { DefaultOptionType } from 'antd/es/select'; import { Channels } from 'types/api/channels/getAll'; -import { Threshold } from '../context/types'; +import { + NotificationSettingsAction, + NotificationSettingsState, + Threshold, +} from '../context/types'; export type UpdateThreshold = { (thresholdId: string, field: 'channels', value: string[]): void; ( thresholdId: string, field: Exclude, - value: string, + value: string | number | null, ): void; }; @@ -20,4 +24,20 @@ export interface ThresholdItemProps { channels: Channels[]; isLoadingChannels: boolean; units: DefaultOptionType[]; + isErrorChannels: boolean; + refreshChannels: () => void; +} + +export interface AnomalyAndThresholdProps { + channels: Channels[]; + isLoadingChannels: boolean; + isErrorChannels: boolean; + refreshChannels: () => void; +} + +export interface RoutingPolicyBannerProps { + notificationSettings: NotificationSettingsState; + setNotificationSettings: ( + notificationSettings: NotificationSettingsAction, + ) => void; } diff --git a/frontend/src/container/CreateAlertV2/AlertCondition/utils.tsx b/frontend/src/container/CreateAlertV2/AlertCondition/utils.tsx index af4d89c47f51..51e4a591e9e9 100644 --- a/frontend/src/container/CreateAlertV2/AlertCondition/utils.tsx +++ b/frontend/src/container/CreateAlertV2/AlertCondition/utils.tsx @@ -1,9 +1,19 @@ +import { Button, Flex, Switch, Typography } from 'antd'; import { BaseOptionType, DefaultOptionType, SelectProps } from 'antd/es/select'; import { getInvolvedQueriesInTraceOperator } from 'components/QueryBuilderV2/QueryV2/TraceOperator/utils/utils'; import { Y_AXIS_CATEGORIES } from 'components/YAxisUnitSelector/constants'; +import ROUTES from 'constants/routes'; +import { + AlertThresholdMatchType, + AlertThresholdOperator, +} from 'container/CreateAlertV2/context/types'; import { getSelectedQueryOptions } from 'container/FormAlertRules/utils'; +import { IUser } from 'providers/App/types'; import { Query } from 'types/api/queryBuilder/queryBuilderData'; import { EQueryType } from 'types/common/dashboard'; +import { USER_ROLES } from 'types/roles'; + +import { RoutingPolicyBannerProps } from './types'; export function getQueryNames(currentQuery: Query): BaseOptionType[] { const involvedQueriesInTraceOperator = getInvolvedQueriesInTraceOperator( @@ -44,3 +54,360 @@ export function getCategorySelectOptionByName( ) || [] ); } + +const getOperatorWord = (op: AlertThresholdOperator): string => { + switch (op) { + case AlertThresholdOperator.IS_ABOVE: + return 'exceed'; + case AlertThresholdOperator.IS_BELOW: + return 'fall below'; + case AlertThresholdOperator.IS_EQUAL_TO: + return 'equal'; + case AlertThresholdOperator.IS_NOT_EQUAL_TO: + return 'not equal'; + default: + return 'exceed'; + } +}; + +const getThresholdValue = (op: AlertThresholdOperator): number => { + switch (op) { + case AlertThresholdOperator.IS_ABOVE: + return 80; + case AlertThresholdOperator.IS_BELOW: + return 50; + case AlertThresholdOperator.IS_EQUAL_TO: + return 100; + case AlertThresholdOperator.IS_NOT_EQUAL_TO: + return 0; + default: + return 80; + } +}; + +const getDataPoints = ( + matchType: AlertThresholdMatchType, + op: AlertThresholdOperator, +): number[] => { + const dataPointMap: Record< + AlertThresholdMatchType, + Record + > = { + [AlertThresholdMatchType.AT_LEAST_ONCE]: { + [AlertThresholdOperator.IS_BELOW]: [60, 45, 40, 55, 35], + [AlertThresholdOperator.IS_EQUAL_TO]: [95, 100, 105, 90, 100], + [AlertThresholdOperator.IS_NOT_EQUAL_TO]: [5, 0, 10, 15, 0], + [AlertThresholdOperator.IS_ABOVE]: [75, 85, 90, 78, 95], + [AlertThresholdOperator.ABOVE_BELOW]: [75, 85, 90, 78, 95], + }, + [AlertThresholdMatchType.ALL_THE_TIME]: { + [AlertThresholdOperator.IS_BELOW]: [45, 40, 35, 42, 38], + [AlertThresholdOperator.IS_EQUAL_TO]: [100, 100, 100, 100, 100], + [AlertThresholdOperator.IS_NOT_EQUAL_TO]: [5, 10, 15, 8, 12], + [AlertThresholdOperator.IS_ABOVE]: [85, 87, 90, 88, 95], + [AlertThresholdOperator.ABOVE_BELOW]: [85, 87, 90, 88, 95], + }, + [AlertThresholdMatchType.ON_AVERAGE]: { + [AlertThresholdOperator.IS_BELOW]: [60, 40, 45, 35, 45], + [AlertThresholdOperator.IS_EQUAL_TO]: [95, 105, 100, 95, 105], + [AlertThresholdOperator.IS_NOT_EQUAL_TO]: [5, 10, 15, 8, 12], + [AlertThresholdOperator.IS_ABOVE]: [75, 85, 90, 78, 95], + [AlertThresholdOperator.ABOVE_BELOW]: [75, 85, 90, 78, 95], + }, + [AlertThresholdMatchType.IN_TOTAL]: { + [AlertThresholdOperator.IS_BELOW]: [8, 5, 10, 12, 8], + [AlertThresholdOperator.IS_EQUAL_TO]: [20, 20, 20, 20, 20], + [AlertThresholdOperator.IS_NOT_EQUAL_TO]: [10, 15, 25, 5, 30], + [AlertThresholdOperator.IS_ABOVE]: [10, 15, 25, 5, 30], + [AlertThresholdOperator.ABOVE_BELOW]: [10, 15, 25, 5, 30], + }, + [AlertThresholdMatchType.LAST]: { + [AlertThresholdOperator.IS_BELOW]: [75, 85, 90, 78, 45], + [AlertThresholdOperator.IS_EQUAL_TO]: [75, 85, 90, 78, 100], + [AlertThresholdOperator.IS_NOT_EQUAL_TO]: [75, 85, 90, 78, 25], + [AlertThresholdOperator.IS_ABOVE]: [75, 85, 90, 78, 95], + [AlertThresholdOperator.ABOVE_BELOW]: [75, 85, 90, 78, 95], + }, + }; + + return dataPointMap[matchType]?.[op] || [75, 85, 90, 78, 95]; +}; + +const getTooltipOperatorSymbol = (op: AlertThresholdOperator): string => { + const symbolMap: Record = { + [AlertThresholdOperator.IS_ABOVE]: '>', + [AlertThresholdOperator.IS_BELOW]: '<', + [AlertThresholdOperator.IS_EQUAL_TO]: '=', + [AlertThresholdOperator.IS_NOT_EQUAL_TO]: '!=', + [AlertThresholdOperator.ABOVE_BELOW]: '>', + }; + return symbolMap[op] || '>'; +}; + +const handleTooltipClick = ( + e: React.MouseEvent | React.KeyboardEvent, +): void => { + e.stopPropagation(); +}; + +function TooltipContent({ + children, +}: { + children: React.ReactNode; +}): JSX.Element { + return ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + handleTooltipClick(e); + } + }} + className="tooltip-content" + > + {children} +
+ ); +} + +function TooltipExample({ + children, + dataPoints, + operatorSymbol, + thresholdValue, + matchType, +}: { + children: React.ReactNode; + dataPoints: number[]; + operatorSymbol: string; + thresholdValue: number; + matchType: AlertThresholdMatchType; +}): JSX.Element { + return ( +
+ Example: +
+ Say, For a 5-minute window (configured in Evaluation settings), 1 min + aggregation interval (set up in query) → 5{' '} + {matchType === AlertThresholdMatchType.IN_TOTAL + ? 'error counts' + : 'data points'} + : [{dataPoints.join(', ')}]
+ With threshold {operatorSymbol} {thresholdValue}: {children} +
+ ); +} + +function TooltipLink(): JSX.Element { + return ( + + ); +} + +export const getMatchTypeTooltip = ( + matchType: AlertThresholdMatchType, + operator: AlertThresholdOperator, +): React.ReactNode => { + const operatorSymbol = getTooltipOperatorSymbol(operator); + const operatorWord = getOperatorWord(operator); + const thresholdValue = getThresholdValue(operator); + const dataPoints = getDataPoints(matchType, operator); + const getMatchingPointsCount = (): number => + dataPoints.filter((p) => { + switch (operator) { + case AlertThresholdOperator.IS_ABOVE: + return p > thresholdValue; + case AlertThresholdOperator.IS_BELOW: + return p < thresholdValue; + case AlertThresholdOperator.IS_EQUAL_TO: + return p === thresholdValue; + case AlertThresholdOperator.IS_NOT_EQUAL_TO: + return p !== thresholdValue; + default: + return p > thresholdValue; + } + }).length; + + switch (matchType) { + case AlertThresholdMatchType.AT_LEAST_ONCE: + return ( + +
+ Data is aggregated at each interval within your evaluation window, + creating multiple data points. This option triggers if ANY of + those aggregated data points crosses the threshold. +
+ + Alert triggers ({getMatchingPointsCount()} points {operatorWord}{' '} + {thresholdValue}) + + +
+ ); + + case AlertThresholdMatchType.ALL_THE_TIME: + return ( + +
+ Data is aggregated at each interval within your evaluation window, + creating multiple data points. This option triggers if ALL{' '} + aggregated data points cross the threshold. +
+ + Alert triggers (all points {operatorWord} {thresholdValue})
+ If any point was {thresholdValue}, no alert would fire +
+ +
+ ); + + case AlertThresholdMatchType.ON_AVERAGE: { + const average = ( + dataPoints.reduce((a, b) => a + b, 0) / dataPoints.length + ).toFixed(1); + return ( + +
+ Data is aggregated at each interval within your evaluation window, + creating multiple data points. This option triggers if the{' '} + AVERAGE of all aggregated data points crosses the threshold. +
+ + Alert triggers (average = {average}) + + +
+ ); + } + + case AlertThresholdMatchType.IN_TOTAL: { + const total = dataPoints.reduce((a, b) => a + b, 0); + return ( + +
+ Data is aggregated at each interval within your evaluation window, + creating multiple data points. This option triggers if the{' '} + SUM of all aggregated data points crosses the threshold. +
+ + Alert triggers (total = {total}) + + +
+ ); + } + + case AlertThresholdMatchType.LAST: { + const lastPoint = dataPoints[dataPoints.length - 1]; + return ( + +
+ Data is aggregated at each interval within your evaluation window, + creating multiple data points. This option triggers based on the{' '} + MOST RECENT aggregated data point only. +
+ + Alert triggers (last point = {lastPoint}) + + +
+ ); + } + + default: + return ''; + } +}; + +export function NotificationChannelsNotFoundContent({ + user, + refreshChannels, +}: { + user: IUser; + refreshChannels: () => void; +}): JSX.Element { + return ( + + + No channels yet. + {user?.role === USER_ROLES.ADMIN ? ( + + Create one + + + ) : ( + Please ask your admin to create one. + )} + + + + ); +} + +export function RoutingPolicyBanner({ + notificationSettings, + setNotificationSettings, +}: RoutingPolicyBannerProps): JSX.Element { + return ( +
+ + Use Routing Policies for dynamic routing + + { + setNotificationSettings({ + type: 'SET_ROUTING_POLICIES', + payload: value, + }); + }} + /> +
+ ); +} diff --git a/frontend/src/container/CreateAlertV2/CreateAlertHeader/CreateAlertHeader.tsx b/frontend/src/container/CreateAlertV2/CreateAlertHeader/CreateAlertHeader.tsx index 8ad31b7c5981..becfe20fdd87 100644 --- a/frontend/src/container/CreateAlertV2/CreateAlertHeader/CreateAlertHeader.tsx +++ b/frontend/src/container/CreateAlertV2/CreateAlertHeader/CreateAlertHeader.tsx @@ -38,7 +38,6 @@ function CreateAlertHeader(): JSX.Element {
New Alert Rule
-
- - setAlertState({ type: 'SET_ALERT_DESCRIPTION', payload: e.target.value }) - } - className="alert-header__input description" - placeholder="Click to add description..." - /> diff --git a/frontend/src/container/CreateAlertV2/CreateAlertHeader/__tests__/CreateAlertHeader.test.tsx b/frontend/src/container/CreateAlertV2/CreateAlertHeader/__tests__/CreateAlertHeader.test.tsx index adb4e8ed8b97..ff74fc01476c 100644 --- a/frontend/src/container/CreateAlertV2/CreateAlertHeader/__tests__/CreateAlertHeader.test.tsx +++ b/frontend/src/container/CreateAlertV2/CreateAlertHeader/__tests__/CreateAlertHeader.test.tsx @@ -1,9 +1,21 @@ /* eslint-disable react/jsx-props-no-spreading */ import { fireEvent, render, screen } from '@testing-library/react'; +import { AlertTypes } from 'types/api/alerts/alertTypes'; +import * as useCreateAlertRuleHook from '../../../../hooks/alerts/useCreateAlertRule'; +import * as useTestAlertRuleHook from '../../../../hooks/alerts/useTestAlertRule'; import { CreateAlertProvider } from '../../context'; import CreateAlertHeader from '../CreateAlertHeader'; +jest.spyOn(useCreateAlertRuleHook, 'useCreateAlertRule').mockReturnValue({ + mutate: jest.fn(), + isLoading: false, +} as any); +jest.spyOn(useTestAlertRuleHook, 'useTestAlertRule').mockReturnValue({ + mutate: jest.fn(), + isLoading: false, +} as any); + jest.mock('uplot', () => { const paths = { spline: jest.fn(), @@ -27,7 +39,7 @@ jest.mock('react-router-dom', () => ({ const renderCreateAlertHeader = (): ReturnType => render( - + , ); @@ -44,14 +56,6 @@ describe('CreateAlertHeader', () => { expect(nameInput).toBeInTheDocument(); }); - it('renders description input with placeholder', () => { - renderCreateAlertHeader(); - const descriptionInput = screen.getByPlaceholderText( - 'Click to add description...', - ); - expect(descriptionInput).toBeInTheDocument(); - }); - it('renders LabelsInput component', () => { renderCreateAlertHeader(); expect(screen.getByText('+ Add labels')).toBeInTheDocument(); @@ -65,13 +69,4 @@ describe('CreateAlertHeader', () => { expect(nameInput).toHaveValue('Test Alert'); }); - - it('updates description when typing in description input', () => { - renderCreateAlertHeader(); - const descriptionInput = screen.getByPlaceholderText( - 'Click to add description...', - ); - fireEvent.change(descriptionInput, { target: { value: 'Test Description' } }); - expect(descriptionInput).toHaveValue('Test Description'); - }); }); diff --git a/frontend/src/container/CreateAlertV2/CreateAlertHeader/styles.scss b/frontend/src/container/CreateAlertV2/CreateAlertHeader/styles.scss index c594cbebc226..e586d98a5016 100644 --- a/frontend/src/container/CreateAlertV2/CreateAlertHeader/styles.scss +++ b/frontend/src/container/CreateAlertV2/CreateAlertHeader/styles.scss @@ -3,21 +3,6 @@ font-family: inherit; color: var(--text-vanilla-100); - /* Top bar with diagonal stripes */ - &__tab-bar { - height: 32px; - display: flex; - align-items: center; - background: repeating-linear-gradient( - -45deg, - #0f0f0f, - #0f0f0f 10px, - #101010 10px, - #101010 20px - ); - padding-left: 0; - } - /* Tab block visuals */ &__tab { display: flex; @@ -44,6 +29,8 @@ display: flex; flex-direction: column; gap: 8px; + min-width: 300px; + flex: 1; } &__input.title { @@ -51,6 +38,8 @@ font-weight: 500; background-color: transparent; color: var(--text-vanilla-100); + width: 100%; + min-width: 300px; } &__input:focus, @@ -64,6 +53,15 @@ background-color: transparent; color: var(--text-vanilla-300); } + + .ant-btn { + display: flex; + gap: 4px; + align-items: center; + color: var(--text-vanilla-100); + border: 1px solid var(--bg-slate-300); + margin-right: 16px; + } } .labels-input { @@ -149,3 +147,65 @@ } } } + +.lightMode { + .alert-header { + background-color: var(--bg-vanilla-100); + color: var(--text-ink-100); + + &__tab { + background-color: var(--bg-vanilla-100); + color: var(--text-ink-100); + } + + &__tab::before { + color: var(--bg-ink-100); + } + + &__content { + background: var(--bg-vanilla-100); + } + + &__input.title { + color: var(--text-ink-100); + } + + &__input.description { + color: var(--text-ink-300); + } + } + + .labels-input { + &__add-button { + color: var(--bg-ink-400); + border: 1px solid var(--bg-vanilla-300); + + &:hover { + border-color: var(--bg-ink-300); + color: var(--bg-ink-500); + } + } + + &__label-pill { + background-color: #ad7f581a; + color: var(--bg-sienna-400); + border: 1px solid var(--bg-sienna-500); + } + + &__remove-button { + color: var(--bg-sienna-400); + + &:hover { + color: var(--text-ink-100); + } + } + + &__input { + color: var(--bg-ink-500); + + &::placeholder { + color: var(--bg-ink-300); + } + } + } +} diff --git a/frontend/src/container/CreateAlertV2/CreateAlertV2.styles.scss b/frontend/src/container/CreateAlertV2/CreateAlertV2.styles.scss index 23c38b075b8b..7ecb3fce40a6 100644 --- a/frontend/src/container/CreateAlertV2/CreateAlertV2.styles.scss +++ b/frontend/src/container/CreateAlertV2/CreateAlertV2.styles.scss @@ -1,17 +1,20 @@ -$top-nav-background-1: #0f0f0f; -$top-nav-background-2: #101010; - .create-alert-v2-container { background-color: var(--bg-ink-500); + padding-bottom: 100px; } -.top-nav-container { - background: repeating-linear-gradient( - -45deg, - $top-nav-background-1, - $top-nav-background-1 10px, - $top-nav-background-2 10px, - $top-nav-background-2 20px - ); - margin-bottom: 0; +.lightMode { + .create-alert-v2-container { + background-color: var(--bg-vanilla-100); + } +} + +.sticky-page-spinner { + position: fixed; + inset: 0; + display: grid; + place-items: center; + background: rgba(0, 0, 0, 0.35); + z-index: 10000; + pointer-events: auto; } diff --git a/frontend/src/container/CreateAlertV2/CreateAlertV2.tsx b/frontend/src/container/CreateAlertV2/CreateAlertV2.tsx index 0ce7e0821fff..dc60cedb7c93 100644 --- a/frontend/src/container/CreateAlertV2/CreateAlertV2.tsx +++ b/frontend/src/container/CreateAlertV2/CreateAlertV2.tsx @@ -2,27 +2,32 @@ import './CreateAlertV2.styles.scss'; import { initialQueriesMap } from 'constants/queryBuilder'; import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl'; -import { Query } from 'types/api/queryBuilder/queryBuilderData'; +import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi'; import AlertCondition from './AlertCondition'; import { CreateAlertProvider } from './context'; +import { buildInitialAlertDef } from './context/utils'; import CreateAlertHeader from './CreateAlertHeader'; import EvaluationSettings from './EvaluationSettings'; +import Footer from './Footer'; import NotificationSettings from './NotificationSettings'; import QuerySection from './QuerySection'; -import { showCondensedLayout } from './utils'; +import { CreateAlertV2Props } from './types'; +import { showCondensedLayout, Spinner } from './utils'; -function CreateAlertV2({ - initialQuery = initialQueriesMap.metrics, -}: { - initialQuery?: Query; -}): JSX.Element { - useShareBuilderUrl({ defaultValue: initialQuery }); +function CreateAlertV2({ alertType }: CreateAlertV2Props): JSX.Element { + const queryToRedirect = buildInitialAlertDef(alertType); + const currentQueryToRedirect = mapQueryDataFromApi( + queryToRedirect.condition.compositeQuery, + ); + + useShareBuilderUrl({ defaultValue: currentQueryToRedirect }); const showCondensedLayoutFlag = showCondensedLayout(); return ( - + +
@@ -30,6 +35,7 @@ function CreateAlertV2({ {!showCondensedLayoutFlag ? : null}
+