Compare commits

..

3 Commits

319 changed files with 7785 additions and 11854 deletions

View File

@@ -37,15 +37,6 @@ jobs:
with:
PRIMUS_REF: main
GO_VERSION: 1.23
deps:
if: |
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
uses: signoz/primus.workflows/.github/workflows/go-deps.yaml@main
secrets: inherit
with:
PRIMUS_REF: main
GO_VERSION: 1.23
build:
if: |
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||

View File

@@ -174,7 +174,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.83.0
image: signoz/signoz:v0.82.0
command:
- --config=/root/config/prometheus.yml
ports:

View File

@@ -110,7 +110,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.83.0
image: signoz/signoz:v0.82.0
command:
- --config=/root/config/prometheus.yml
ports:

View File

@@ -177,7 +177,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.83.0}
image: signoz/signoz:${VERSION:-v0.82.0}
container_name: signoz
command:
- --config=/root/config/prometheus.yml

View File

@@ -110,7 +110,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.83.0}
image: signoz/signoz:${VERSION:-v0.82.0}
container_name: signoz
command:
- --config=/root/config/prometheus.yml

View File

@@ -61,14 +61,14 @@ func (p *Pat) Wrap(next http.Handler) http.Handler {
return
}
role, err := types.NewRole(user.Role)
role, err := authtypes.NewRole(user.Role)
if err != nil {
next.ServeHTTP(w, r)
return
}
jwt := authtypes.Claims{
UserID: user.ID.String(),
UserID: user.ID,
Role: role,
Email: user.Email,
OrgID: user.OrgID,

View File

@@ -1,203 +0,0 @@
package impluser
import (
"context"
"encoding/json"
"net/http"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
"github.com/SigNoz/signoz/pkg/types"
"github.com/gorilla/mux"
)
// EnterpriseHandler embeds the base handler implementation
type Handler struct {
user.Handler // Embed the base handler interface
module user.Module
}
func NewHandler(module user.Module) user.Handler {
baseHandler := impluser.NewHandler(module)
return &Handler{
Handler: baseHandler,
module: module,
}
}
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
var req types.PostableLoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
render.Error(w, err)
return
}
if req.RefreshToken == "" {
// the EE handler wrapper passes the feature flag value in context
ssoAvailable, ok := ctx.Value(types.SSOAvailable).(bool)
if !ok {
render.Error(w, errors.New(errors.TypeInternal, errors.CodeInternal, "failed to retrieve SSO availability"))
return
}
if ssoAvailable {
_, err := h.module.CanUsePassword(ctx, req.Email)
if err != nil {
render.Error(w, err)
return
}
}
}
user, err := h.module.GetAuthenticatedUser(ctx, req.OrgID, req.Email, req.Password, req.RefreshToken)
if err != nil {
render.Error(w, err)
return
}
jwt, err := h.module.GetJWTForUser(ctx, user)
if err != nil {
render.Error(w, err)
return
}
gettableLoginResponse := &types.GettableLoginResponse{
GettableUserJwt: jwt,
UserID: user.ID.String(),
}
render.Success(w, http.StatusOK, gettableLoginResponse)
}
// Override only the methods you need with enterprise-specific implementations
func (h *Handler) LoginPrecheck(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
// assume user is valid unless proven otherwise and assign default values for rest of the fields
email := r.URL.Query().Get("email")
sourceUrl := r.URL.Query().Get("ref")
orgID := r.URL.Query().Get("orgID")
resp, err := h.module.LoginPrecheck(ctx, orgID, email, sourceUrl)
if err != nil {
render.Error(w, err)
return
}
render.Success(w, http.StatusOK, resp)
}
func (h *Handler) AcceptInvite(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
req := new(types.PostableAcceptInvite)
if err := json.NewDecoder(r.Body).Decode(req); err != nil {
render.Error(w, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to decode user"))
return
}
// get invite object
invite, err := h.module.GetInviteByToken(ctx, req.InviteToken)
if err != nil {
render.Error(w, err)
return
}
orgDomain, err := h.module.GetAuthDomainByEmail(ctx, invite.Email)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
render.Error(w, err)
return
}
precheckResp := &types.GettableLoginPrecheck{
SSO: false,
IsUser: false,
}
if invite.Name == "" && req.DisplayName != "" {
invite.Name = req.DisplayName
}
user, err := types.NewUser(invite.Name, invite.Email, invite.Role, invite.OrgID)
if err != nil {
render.Error(w, err)
return
}
if orgDomain != nil && orgDomain.SsoEnabled {
// sso is enabled, create user and respond precheck data
err = h.module.CreateUser(ctx, user)
if err != nil {
render.Error(w, err)
return
}
// check if sso is enforced for the org
precheckResp, err = h.module.LoginPrecheck(ctx, invite.OrgID, user.Email, req.SourceURL)
if err != nil {
render.Error(w, err)
return
}
} else {
password, err := types.NewFactorPassword(req.Password)
if err != nil {
render.Error(w, err)
return
}
user, err = h.module.CreateUserWithPassword(ctx, user, password)
if err != nil {
render.Error(w, err)
return
}
precheckResp.IsUser = true
}
// delete the invite
if err := h.module.DeleteInvite(ctx, invite.OrgID, invite.ID); err != nil {
render.Error(w, err)
return
}
render.Success(w, http.StatusOK, precheckResp)
}
func (h *Handler) GetInvite(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
token := mux.Vars(r)["token"]
sourceUrl := r.URL.Query().Get("ref")
invite, err := h.module.GetInviteByToken(ctx, token)
if err != nil {
render.Error(w, err)
return
}
// precheck the user
precheckResp, err := h.module.LoginPrecheck(ctx, invite.OrgID, invite.Email, sourceUrl)
if err != nil {
render.Error(w, err)
return
}
gettableInvite := &types.GettableEEInvite{
GettableInvite: *invite,
PreCheck: precheckResp,
}
render.Success(w, http.StatusOK, gettableInvite)
return
}

View File

@@ -1,229 +0,0 @@
package impluser
import (
"context"
"fmt"
"net/url"
"strings"
"github.com/SigNoz/signoz/ee/query-service/constants"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/modules/user"
baseimpl "github.com/SigNoz/signoz/pkg/modules/user/impluser"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"go.uber.org/zap"
)
// EnterpriseModule embeds the base module implementation
type Module struct {
*baseimpl.Module // Embed the base module implementation
store types.UserStore
}
func NewModule(store types.UserStore) user.Module {
baseModule := baseimpl.NewModule(store).(*baseimpl.Module)
return &Module{
Module: baseModule,
store: store,
}
}
func (m *Module) createUserForSAMLRequest(ctx context.Context, email string) (*types.User, error) {
// get auth domain from email domain
_, err := m.GetAuthDomainByEmail(ctx, email)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
// get name from email
parts := strings.Split(email, "@")
if len(parts) < 2 {
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid email format")
}
name := parts[0]
defaultOrgID, err := m.store.GetDefaultOrgID(ctx)
if err != nil {
return nil, err
}
user, err := types.NewUser(name, email, types.RoleViewer.String(), defaultOrgID)
if err != nil {
return nil, err
}
err = m.CreateUser(ctx, user)
if err != nil {
return nil, err
}
return user, nil
}
func (m *Module) PrepareSsoRedirect(ctx context.Context, redirectUri, email string, jwt *authtypes.JWT) (string, error) {
users, err := m.GetUsersByEmail(ctx, email)
if err != nil {
zap.L().Error("failed to get user with email received from auth provider", zap.String("error", err.Error()))
return "", err
}
user := &types.User{}
if len(users) == 0 {
newUser, err := m.createUserForSAMLRequest(ctx, email)
user = newUser
if err != nil {
zap.L().Error("failed to create user with email received from auth provider", zap.Error(err))
return "", err
}
} else {
user = &users[0].User
}
tokenStore, err := m.GetJWTForUser(ctx, user)
if err != nil {
zap.L().Error("failed to generate token for SSO login user", zap.Error(err))
return "", err
}
return fmt.Sprintf("%s?jwt=%s&usr=%s&refreshjwt=%s",
redirectUri,
tokenStore.AccessJwt,
user.ID,
tokenStore.RefreshJwt), nil
}
func (m *Module) CanUsePassword(ctx context.Context, email string) (bool, error) {
domain, err := m.GetAuthDomainByEmail(ctx, email)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return false, err
}
if domain != nil && domain.SsoEnabled {
// sso is enabled, check if the user has admin role
users, err := m.GetUsersByEmail(ctx, email)
if err != nil {
return false, err
}
if len(users) == 0 {
return false, errors.New(errors.TypeNotFound, errors.CodeNotFound, "user not found")
}
if users[0].Role != types.RoleAdmin.String() {
return false, errors.New(errors.TypeForbidden, errors.CodeForbidden, "auth method not supported")
}
}
return true, nil
}
func (m *Module) LoginPrecheck(ctx context.Context, orgID, email, sourceUrl string) (*types.GettableLoginPrecheck, error) {
resp := &types.GettableLoginPrecheck{IsUser: true, CanSelfRegister: false}
// check if email is a valid user
users, err := m.GetUsersByEmail(ctx, email)
if err != nil {
return nil, err
}
if len(users) == 0 {
resp.IsUser = false
}
// give them an option to select an org
if orgID == "" && len(users) > 1 {
resp.SelectOrg = true
resp.Orgs = make([]string, len(users))
for i, user := range users {
resp.Orgs[i] = user.OrgID
}
return resp, nil
}
// select the user with the corresponding orgID
if len(users) > 1 {
found := false
for _, tuser := range users {
if tuser.OrgID == orgID {
// user = tuser
found = true
break
}
}
if !found {
resp.IsUser = false
return resp, nil
}
}
// the EE handler wrapper passes the feature flag value in context
ssoAvailable, ok := ctx.Value(types.SSOAvailable).(bool)
if !ok {
zap.L().Error("failed to retrieve ssoAvailable from context")
return nil, errors.New(errors.TypeInternal, errors.CodeInternal, "failed to retrieve SSO availability")
}
if ssoAvailable {
// TODO(Nitya): in multitenancy this should use orgId as well.
orgDomain, err := m.GetAuthDomainByEmail(ctx, email)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
if orgDomain != nil && orgDomain.SsoEnabled {
// this is to allow self registration
resp.IsUser = true
// saml is enabled for this domain, lets prepare sso url
if sourceUrl == "" {
sourceUrl = constants.GetDefaultSiteURL()
}
// parse source url that generated the login request
var err error
escapedUrl, _ := url.QueryUnescape(sourceUrl)
siteUrl, err := url.Parse(escapedUrl)
if err != nil {
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to parse referer")
}
// build Idp URL that will authenticat the user
// the front-end will redirect user to this url
resp.SSOUrl, err = orgDomain.BuildSsoUrl(siteUrl)
if err != nil {
zap.L().Error("failed to prepare saml request for domain", zap.String("domain", orgDomain.Name), zap.Error(err))
return nil, errors.New(errors.TypeInternal, errors.CodeInternal, "failed to prepare saml request for domain")
}
// set SSO to true, as the url is generated correctly
resp.SSO = true
}
}
return resp, nil
}
func (m *Module) GetAuthDomainByEmail(ctx context.Context, email string) (*types.GettableOrgDomain, error) {
if email == "" {
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "email is required")
}
components := strings.Split(email, "@")
if len(components) < 2 {
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid email format")
}
domain, err := m.store.GetDomainByName(ctx, components[1])
if err != nil {
return nil, err
}
gettableDomain := &types.GettableOrgDomain{StorableOrgDomain: *domain}
if err := gettableDomain.LoadConfig(domain.Data); err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to load domain config")
}
return gettableDomain, nil
}

View File

@@ -1,37 +0,0 @@
package impluser
import (
"context"
"github.com/SigNoz/signoz/pkg/errors"
baseimpl "github.com/SigNoz/signoz/pkg/modules/user/impluser"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
)
type store struct {
*baseimpl.Store
sqlstore sqlstore.SQLStore
}
func NewStore(sqlstore sqlstore.SQLStore) types.UserStore {
baseStore := baseimpl.NewStore(sqlstore).(*baseimpl.Store)
return &store{
Store: baseStore,
sqlstore: sqlstore,
}
}
func (s *store) GetDomainByName(ctx context.Context, name string) (*types.StorableOrgDomain, error) {
domain := new(types.StorableOrgDomain)
err := s.sqlstore.BunDB().NewSelect().
Model(domain).
Where("name = ?", name).
Limit(1).
Scan(ctx)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeNotFound, errors.CodeNotFound, "failed to get domain from name")
}
return domain, nil
}

View File

@@ -1,7 +1,6 @@
package api
import (
"context"
"net/http"
"net/http/httputil"
"time"
@@ -10,13 +9,10 @@ import (
"github.com/SigNoz/signoz/ee/query-service/integrations/gateway"
"github.com/SigNoz/signoz/ee/query-service/interfaces"
"github.com/SigNoz/signoz/ee/query-service/license"
"github.com/SigNoz/signoz/ee/query-service/model"
"github.com/SigNoz/signoz/ee/query-service/usage"
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/apis/fields"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/middleware"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/modules/quickfilter"
quickfilterscore "github.com/SigNoz/signoz/pkg/modules/quickfilter/core"
baseapp "github.com/SigNoz/signoz/pkg/query-service/app"
@@ -27,11 +23,9 @@ import (
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
rules "github.com/SigNoz/signoz/pkg/query-service/rules"
"github.com/SigNoz/signoz/pkg/signoz"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/version"
"github.com/gorilla/mux"
"go.uber.org/zap"
)
type APIHandlerOptions struct {
@@ -66,6 +60,7 @@ func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz) (*APIHandler,
baseHandler, err := baseapp.NewAPIHandler(baseapp.APIHandlerOpts{
Reader: opts.DataConnector,
PreferSpanMetrics: opts.PreferSpanMetrics,
AppDao: opts.AppDao,
RuleManager: opts.RulesManager,
FeatureFlags: opts.FeatureFlags,
IntegrationsController: opts.IntegrationsController,
@@ -125,24 +120,43 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
// routes available only in ee version
router.HandleFunc("/api/v1/featureFlags", am.OpenAccess(ah.getFeatureFlags)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/loginPrecheck", am.OpenAccess(ah.loginPrecheck)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/featureFlags",
am.OpenAccess(ah.getFeatureFlags)).
Methods(http.MethodGet)
// invite
router.HandleFunc("/api/v1/invite/{token}", am.OpenAccess(ah.getInvite)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/invite/accept", am.OpenAccess(ah.acceptInvite)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/loginPrecheck",
am.OpenAccess(ah.precheckLogin)).
Methods(http.MethodGet)
// paid plans specific routes
router.HandleFunc("/api/v1/complete/saml", am.OpenAccess(ah.receiveSAML)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/complete/google", am.OpenAccess(ah.receiveGoogleAuth)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/orgs/{orgId}/domains", am.AdminAccess(ah.listDomainsByOrg)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/complete/saml",
am.OpenAccess(ah.receiveSAML)).
Methods(http.MethodPost)
router.HandleFunc("/api/v1/domains", am.AdminAccess(ah.postDomain)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/domains/{id}", am.AdminAccess(ah.putDomain)).Methods(http.MethodPut)
router.HandleFunc("/api/v1/domains/{id}", am.AdminAccess(ah.deleteDomain)).Methods(http.MethodDelete)
router.HandleFunc("/api/v1/complete/google",
am.OpenAccess(ah.receiveGoogleAuth)).
Methods(http.MethodGet)
router.HandleFunc("/api/v1/orgs/{orgId}/domains",
am.AdminAccess(ah.listDomainsByOrg)).
Methods(http.MethodGet)
router.HandleFunc("/api/v1/domains",
am.AdminAccess(ah.postDomain)).
Methods(http.MethodPost)
router.HandleFunc("/api/v1/domains/{id}",
am.AdminAccess(ah.putDomain)).
Methods(http.MethodPut)
router.HandleFunc("/api/v1/domains/{id}",
am.AdminAccess(ah.deleteDomain)).
Methods(http.MethodDelete)
// base overrides
router.HandleFunc("/api/v1/version", am.OpenAccess(ah.getVersion)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/invite/{token}", am.OpenAccess(ah.getInvite)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/register", am.OpenAccess(ah.registerUser)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/login", am.OpenAccess(ah.loginUser)).Methods(http.MethodPost)
// PAT APIs
@@ -174,54 +188,6 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
}
// TODO(nitya): remove this once we know how to get the FF's
func (ah *APIHandler) updateRequestContext(w http.ResponseWriter, r *http.Request) (*http.Request, error) {
ssoAvailable := true
err := ah.FF().CheckFeature(model.SSO)
if err != nil {
switch err.(type) {
case basemodel.ErrFeatureUnavailable:
// do nothing, just skip sso
ssoAvailable = false
default:
zap.L().Error("feature check failed", zap.String("featureKey", model.SSO), zap.Error(err))
return r, errors.New(errors.TypeInternal, errors.CodeInternal, "error checking SSO feature")
}
}
ctx := context.WithValue(r.Context(), types.SSOAvailable, ssoAvailable)
return r.WithContext(ctx), nil
}
func (ah *APIHandler) loginPrecheck(w http.ResponseWriter, r *http.Request) {
r, err := ah.updateRequestContext(w, r)
if err != nil {
render.Error(w, err)
return
}
ah.Signoz.Handlers.User.LoginPrecheck(w, r)
return
}
func (ah *APIHandler) acceptInvite(w http.ResponseWriter, r *http.Request) {
r, err := ah.updateRequestContext(w, r)
if err != nil {
render.Error(w, err)
return
}
ah.Signoz.Handlers.User.AcceptInvite(w, r)
return
}
func (ah *APIHandler) getInvite(w http.ResponseWriter, r *http.Request) {
r, err := ah.updateRequestContext(w, r)
if err != nil {
render.Error(w, err)
return
}
ah.Signoz.Handlers.User.GetInvite(w, r)
return
}
func (ah *APIHandler) RegisterCloudIntegrationsRoutes(router *mux.Router, am *middleware.AuthZ) {
ah.APIHandler.RegisterCloudIntegrationsRoutes(router, am)

View File

@@ -9,11 +9,13 @@ import (
"net/http"
"net/url"
"github.com/gorilla/mux"
"go.uber.org/zap"
"github.com/SigNoz/signoz/ee/query-service/constants"
"github.com/SigNoz/signoz/ee/query-service/model"
"github.com/SigNoz/signoz/pkg/http/render"
baseauth "github.com/SigNoz/signoz/pkg/query-service/auth"
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
)
func parseRequest(r *http.Request, req interface{}) error {
@@ -29,13 +31,161 @@ func parseRequest(r *http.Request, req interface{}) error {
// loginUser overrides base handler and considers SSO case.
func (ah *APIHandler) loginUser(w http.ResponseWriter, r *http.Request) {
r, err := ah.updateRequestContext(w, r)
req := basemodel.LoginRequest{}
err := parseRequest(r, &req)
if err != nil {
render.Error(w, err)
RespondError(w, model.BadRequest(err), nil)
return
}
ah.Signoz.Handlers.User.Login(w, r)
return
ctx := context.Background()
if req.Email != "" && ah.CheckFeature(model.SSO) {
var apierr basemodel.BaseApiError
_, apierr = ah.AppDao().CanUsePassword(ctx, req.Email)
if apierr != nil && !apierr.IsNil() {
RespondError(w, apierr, nil)
}
}
// if all looks good, call auth
resp, err := baseauth.Login(ctx, &req, ah.opts.JWT)
if ah.HandleError(w, err, http.StatusUnauthorized) {
return
}
ah.WriteJSON(w, r, resp)
}
// registerUser registers a user and responds with a precheck
// so the front-end can decide the login method
func (ah *APIHandler) registerUser(w http.ResponseWriter, r *http.Request) {
if !ah.CheckFeature(model.SSO) {
ah.APIHandler.Register(w, r)
return
}
ctx := context.Background()
var req *baseauth.RegisterRequest
defer r.Body.Close()
requestBody, err := io.ReadAll(r.Body)
if err != nil {
zap.L().Error("received no input in api", zap.Error(err))
RespondError(w, model.BadRequest(err), nil)
return
}
err = json.Unmarshal(requestBody, &req)
if err != nil {
zap.L().Error("received invalid user registration request", zap.Error(err))
RespondError(w, model.BadRequest(fmt.Errorf("failed to register user")), nil)
return
}
// get invite object
invite, err := baseauth.ValidateInvite(ctx, req)
if err != nil {
zap.L().Error("failed to validate invite token", zap.Error(err))
RespondError(w, model.BadRequest(err), nil)
return
}
if invite == nil {
zap.L().Error("failed to validate invite token: it is either empty or invalid", zap.Error(err))
RespondError(w, model.BadRequest(basemodel.ErrSignupFailed{}), nil)
return
}
// get auth domain from email domain
domain, apierr := ah.AppDao().GetDomainByEmail(ctx, invite.Email)
if apierr != nil {
zap.L().Error("failed to get domain from email", zap.Error(apierr))
RespondError(w, model.InternalError(basemodel.ErrSignupFailed{}), nil)
}
precheckResp := &basemodel.PrecheckResponse{
SSO: false,
IsUser: false,
}
if domain != nil && domain.SsoEnabled {
// sso is enabled, create user and respond precheck data
user, apierr := baseauth.RegisterInvitedUser(ctx, req, true)
if apierr != nil {
RespondError(w, apierr, nil)
return
}
var precheckError basemodel.BaseApiError
precheckResp, precheckError = ah.AppDao().PrecheckLogin(ctx, user.Email, req.SourceUrl)
if precheckError != nil {
RespondError(w, precheckError, precheckResp)
}
} else {
// no-sso, validate password
if err := baseauth.ValidatePassword(req.Password); err != nil {
RespondError(w, model.InternalError(fmt.Errorf("password is not in a valid format")), nil)
return
}
_, registerError := baseauth.Register(ctx, req, ah.Signoz.Alertmanager, ah.Signoz.Modules.Organization, ah.QuickFilterModule)
if !registerError.IsNil() {
RespondError(w, apierr, nil)
return
}
precheckResp.IsUser = true
}
ah.Respond(w, precheckResp)
}
// getInvite returns the invite object details for the given invite token. We do not need to
// protect this API because invite token itself is meant to be private.
func (ah *APIHandler) getInvite(w http.ResponseWriter, r *http.Request) {
token := mux.Vars(r)["token"]
sourceUrl := r.URL.Query().Get("ref")
inviteObject, err := baseauth.GetInvite(r.Context(), token, ah.Signoz.Modules.Organization)
if err != nil {
RespondError(w, model.BadRequest(err), nil)
return
}
resp := model.GettableInvitation{
InvitationResponseObject: inviteObject,
}
precheck, apierr := ah.AppDao().PrecheckLogin(r.Context(), inviteObject.Email, sourceUrl)
resp.Precheck = precheck
if apierr != nil {
RespondError(w, apierr, resp)
}
ah.WriteJSON(w, r, resp)
}
// PrecheckLogin enables browser login page to display appropriate
// login methods
func (ah *APIHandler) precheckLogin(w http.ResponseWriter, r *http.Request) {
ctx := context.Background()
email := r.URL.Query().Get("email")
sourceUrl := r.URL.Query().Get("ref")
resp, apierr := ah.AppDao().PrecheckLogin(ctx, email, sourceUrl)
if apierr != nil {
RespondError(w, apierr, resp)
}
ah.Respond(w, resp)
}
func handleSsoError(w http.ResponseWriter, r *http.Request, redirectURL string) {
@@ -102,7 +252,7 @@ func (ah *APIHandler) receiveGoogleAuth(w http.ResponseWriter, r *http.Request)
return
}
nextPage, err := ah.Signoz.Modules.User.PrepareSsoRedirect(ctx, redirectUri, identity.Email, ah.opts.JWT)
nextPage, err := ah.AppDao().PrepareSsoRedirect(ctx, redirectUri, identity.Email, ah.opts.JWT)
if err != nil {
zap.L().Error("[receiveGoogleAuth] failed to generate redirect URI after successful login ", zap.String("domain", domain.String()), zap.Error(err))
handleSsoError(w, r, redirectUri)
@@ -180,7 +330,7 @@ func (ah *APIHandler) receiveSAML(w http.ResponseWriter, r *http.Request) {
return
}
nextPage, err := ah.Signoz.Modules.User.PrepareSsoRedirect(ctx, redirectUri, email, ah.opts.JWT)
nextPage, err := ah.AppDao().PrepareSsoRedirect(ctx, redirectUri, email, ah.opts.JWT)
if err != nil {
zap.L().Error("[receiveSAML] failed to generate redirect URI after successful login ", zap.String("domain", domain.String()), zap.Error(err))
handleSsoError(w, r, redirectUri)

View File

@@ -12,8 +12,8 @@ import (
"github.com/SigNoz/signoz/ee/query-service/constants"
eeTypes "github.com/SigNoz/signoz/ee/types"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/query-service/auth"
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
@@ -123,7 +123,7 @@ func (ah *APIHandler) getOrCreateCloudIntegrationPAT(ctx context.Context, orgId
))
}
for _, p := range allPats {
if p.UserID == integrationUser.ID.String() && p.Name == integrationPATName {
if p.UserID == integrationUser.ID && p.Name == integrationPATName {
return p.Token, nil
}
}
@@ -135,8 +135,8 @@ func (ah *APIHandler) getOrCreateCloudIntegrationPAT(ctx context.Context, orgId
newPAT := eeTypes.NewGettablePAT(
integrationPATName,
types.RoleViewer.String(),
integrationUser.ID.String(),
authtypes.RoleViewer.String(),
integrationUser.ID,
0,
)
integrationPAT, err := ah.AppDao().CreatePAT(ctx, orgId, newPAT)
@@ -154,9 +154,10 @@ func (ah *APIHandler) getOrCreateCloudIntegrationUser(
cloudIntegrationUser := fmt.Sprintf("%s-integration", cloudProvider)
email := fmt.Sprintf("%s@signoz.io", cloudIntegrationUser)
integrationUserResult, err := ah.Signoz.Modules.User.GetUserByEmailInOrg(ctx, orgId, email)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return nil, basemodel.NotFoundError(fmt.Errorf("couldn't look for integration user: %w", err))
// TODO(nitya): there should be orgId here
integrationUserResult, apiErr := ah.AppDao().GetUserByEmail(ctx, email)
if apiErr != nil {
return nil, basemodel.WrapApiError(apiErr, "couldn't look for integration user")
}
if integrationUserResult != nil {
@@ -168,18 +169,29 @@ func (ah *APIHandler) getOrCreateCloudIntegrationUser(
zap.String("cloudProvider", cloudProvider),
)
newUser, err := types.NewUser(cloudIntegrationUser, email, types.RoleViewer.String(), orgId)
if err != nil {
return nil, basemodel.InternalError(fmt.Errorf(
"couldn't create cloud integration user: %w", err,
))
newUser := &types.User{
ID: uuid.New().String(),
Name: cloudIntegrationUser,
Email: email,
TimeAuditable: types.TimeAuditable{
CreatedAt: time.Now(),
},
OrgID: orgId,
}
password, err := types.NewFactorPassword(uuid.NewString())
newUser.Role = authtypes.RoleViewer.String()
integrationUser, err := ah.Signoz.Modules.User.CreateUserWithPassword(ctx, newUser, password)
passwordHash, err := auth.PasswordHash(uuid.NewString())
if err != nil {
return nil, basemodel.InternalError(fmt.Errorf("couldn't create cloud integration user: %w", err))
return nil, basemodel.InternalError(fmt.Errorf(
"couldn't hash random password for cloud integration user: %w", err,
))
}
newUser.Password = passwordHash
integrationUser, apiErr := ah.AppDao().CreateUser(ctx, newUser, false)
if apiErr != nil {
return nil, basemodel.WrapApiError(apiErr, "couldn't create cloud integration user")
}
return integrationUser, nil

View File

@@ -6,6 +6,7 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/query-service/app/dashboards"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/gorilla/mux"
)
@@ -40,9 +41,9 @@ func (ah *APIHandler) lockUnlockDashboard(w http.ResponseWriter, r *http.Request
return
}
dashboard, err := ah.Signoz.Modules.Dashboard.Get(r.Context(), claims.OrgID, uuid)
if err != nil {
render.Error(w, err)
dashboard, apiErr := dashboards.GetDashboard(r.Context(), claims.OrgID, uuid)
if apiErr != nil {
render.Error(w, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to get dashboard"))
return
}
@@ -52,9 +53,9 @@ func (ah *APIHandler) lockUnlockDashboard(w http.ResponseWriter, r *http.Request
}
// Lock/Unlock the dashboard
err = ah.Signoz.Modules.Dashboard.LockUnlock(r.Context(), claims.OrgID, uuid, lock)
if err != nil {
render.Error(w, err)
apiErr = dashboards.LockUnlockDashboard(r.Context(), claims.OrgID, uuid, lock)
if apiErr != nil {
render.Error(w, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to lock/unlock dashboard"))
return
}

View File

@@ -7,7 +7,7 @@ import (
"net/http"
"github.com/SigNoz/signoz/ee/query-service/model"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/ee/types"
"github.com/google/uuid"
"github.com/gorilla/mux"
)

View File

@@ -56,7 +56,7 @@ func (ah *APIHandler) createPAT(w http.ResponseWriter, r *http.Request) {
}
func validatePATRequest(req eeTypes.GettablePAT) error {
_, err := types.NewRole(req.Role)
_, err := authtypes.NewRole(req.Role)
if err != nil {
return err
}
@@ -93,16 +93,16 @@ func (ah *APIHandler) updatePAT(w http.ResponseWriter, r *http.Request) {
}
//get the pat
existingPAT, err := ah.AppDao().GetPATByID(r.Context(), claims.OrgID, id)
if err != nil {
render.Error(w, errorsV2.Newf(errorsV2.TypeInvalidInput, errorsV2.CodeInvalidInput, err.Error()))
existingPAT, paterr := ah.AppDao().GetPATByID(r.Context(), claims.OrgID, id)
if paterr != nil {
render.Error(w, errorsV2.Newf(errorsV2.TypeInvalidInput, errorsV2.CodeInvalidInput, paterr.Error()))
return
}
// get the user
createdByUser, err := ah.Signoz.Modules.User.GetUserByID(r.Context(), claims.OrgID, existingPAT.UserID)
if err != nil {
render.Error(w, err)
createdByUser, usererr := ah.AppDao().GetUser(r.Context(), existingPAT.UserID)
if usererr != nil {
render.Error(w, errorsV2.Newf(errorsV2.TypeInvalidInput, errorsV2.CodeInvalidInput, usererr.Error()))
return
}
@@ -119,6 +119,7 @@ func (ah *APIHandler) updatePAT(w http.ResponseWriter, r *http.Request) {
req.UpdatedByUserID = claims.UserID
req.UpdatedAt = time.Now()
zap.L().Info("Got Update PAT request", zap.Any("pat", req))
var apierr basemodel.BaseApiError
if apierr = ah.AppDao().UpdatePAT(r.Context(), claims.OrgID, req, id); apierr != nil {
RespondError(w, apierr, nil)
@@ -166,9 +167,9 @@ func (ah *APIHandler) revokePAT(w http.ResponseWriter, r *http.Request) {
}
// get the user
createdByUser, err := ah.Signoz.Modules.User.GetUserByID(r.Context(), claims.OrgID, existingPAT.UserID)
if err != nil {
render.Error(w, err)
createdByUser, usererr := ah.AppDao().GetUser(r.Context(), existingPAT.UserID)
if usererr != nil {
render.Error(w, errorsV2.Newf(errorsV2.TypeInvalidInput, errorsV2.CodeInvalidInput, usererr.Error()))
return
}

View File

@@ -33,7 +33,3 @@ func NewDataConnector(
ClickHouseReader: chReader,
}
}
func (r *ClickhouseReader) GetSQLStore() sqlstore.SQLStore {
return r.appdb
}

View File

@@ -15,7 +15,7 @@ import (
"github.com/SigNoz/signoz/ee/query-service/app/api"
"github.com/SigNoz/signoz/ee/query-service/app/db"
"github.com/SigNoz/signoz/ee/query-service/constants"
"github.com/SigNoz/signoz/ee/query-service/dao/sqlite"
"github.com/SigNoz/signoz/ee/query-service/dao"
"github.com/SigNoz/signoz/ee/query-service/integrations/gateway"
"github.com/SigNoz/signoz/ee/query-service/rules"
"github.com/SigNoz/signoz/pkg/alertmanager"
@@ -36,6 +36,8 @@ import (
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
baseapp "github.com/SigNoz/signoz/pkg/query-service/app"
"github.com/SigNoz/signoz/pkg/query-service/app/cloudintegrations"
"github.com/SigNoz/signoz/pkg/query-service/app/dashboards"
baseexplorer "github.com/SigNoz/signoz/pkg/query-service/app/explorer"
"github.com/SigNoz/signoz/pkg/query-service/app/integrations"
"github.com/SigNoz/signoz/pkg/query-service/app/logparsingpipeline"
"github.com/SigNoz/signoz/pkg/query-service/app/opamp"
@@ -90,7 +92,19 @@ func (s Server) HealthCheckStatus() chan healthcheck.Status {
// NewServer creates and initializes Server
func NewServer(serverOptions *ServerOptions) (*Server, error) {
modelDao := sqlite.NewModelDao(serverOptions.SigNoz.SQLStore)
modelDao, err := dao.InitDao(serverOptions.SigNoz.SQLStore)
if err != nil {
return nil, err
}
if err := baseexplorer.InitWithDSN(serverOptions.SigNoz.SQLStore); err != nil {
return nil, err
}
if err := dashboards.InitDB(serverOptions.SigNoz.SQLStore); err != nil {
return nil, err
}
gatewayProxy, err := gateway.NewProxy(serverOptions.GatewayUrl, gateway.RoutePrefix)
if err != nil {
return nil, err
@@ -102,6 +116,9 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) {
return nil, err
}
// set license manager as feature flag provider in dao
modelDao.SetFlagProvider(lm)
fluxIntervalForTraceDetail, err := time.ParseDuration(serverOptions.FluxIntervalForTraceDetail)
if err != nil {
return nil, err
@@ -178,13 +195,7 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) {
}
telemetry.GetInstance().SetReader(reader)
telemetry.GetInstance().SetSqlStore(serverOptions.SigNoz.SQLStore)
telemetry.GetInstance().SetSaasOperator(constants.SaasSegmentKey)
telemetry.GetInstance().SetSavedViewsInfoCallback(telemetry.GetSavedViewsInfo)
telemetry.GetInstance().SetAlertsInfoCallback(telemetry.GetAlertsInfo)
telemetry.GetInstance().SetGetUsersCallback(telemetry.GetUsers)
telemetry.GetInstance().SetUserCountCallback(telemetry.GetUserCount)
telemetry.GetInstance().SetDashboardsInfoCallback(telemetry.GetDashboardsInfo)
fluxInterval, err := time.ParseDuration(serverOptions.FluxInterval)
if err != nil {

View File

@@ -0,0 +1,10 @@
package dao
import (
"github.com/SigNoz/signoz/ee/query-service/dao/sqlite"
"github.com/SigNoz/signoz/pkg/sqlstore"
)
func InitDao(sqlStore sqlstore.SQLStore) (ModelDao, error) {
return sqlite.InitDB(sqlStore)
}

View File

@@ -4,15 +4,27 @@ import (
"context"
"net/url"
eeTypes "github.com/SigNoz/signoz/ee/types"
"github.com/SigNoz/signoz/ee/types"
basedao "github.com/SigNoz/signoz/pkg/query-service/dao"
baseint "github.com/SigNoz/signoz/pkg/query-service/interfaces"
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/google/uuid"
"github.com/uptrace/bun"
)
type ModelDao interface {
basedao.ModelDao
// SetFlagProvider sets the feature lookup provider
SetFlagProvider(flags baseint.FeatureLookup)
DB() *bun.DB
// auth methods
CanUsePassword(ctx context.Context, email string) (bool, basemodel.BaseApiError)
PrepareSsoRedirect(ctx context.Context, redirectUri, email string, jwt *authtypes.JWT) (redirectURL string, apierr basemodel.BaseApiError)
GetDomainFromSsoResponse(ctx context.Context, relayState *url.URL) (*types.GettableOrgDomain, error)
// org domain (auth domains) CRUD ops
@@ -23,10 +35,10 @@ type ModelDao interface {
DeleteDomain(ctx context.Context, id uuid.UUID) basemodel.BaseApiError
GetDomainByEmail(ctx context.Context, email string) (*types.GettableOrgDomain, basemodel.BaseApiError)
CreatePAT(ctx context.Context, orgID string, p eeTypes.GettablePAT) (eeTypes.GettablePAT, basemodel.BaseApiError)
UpdatePAT(ctx context.Context, orgID string, p eeTypes.GettablePAT, id valuer.UUID) basemodel.BaseApiError
GetPAT(ctx context.Context, pat string) (*eeTypes.GettablePAT, basemodel.BaseApiError)
GetPATByID(ctx context.Context, orgID string, id valuer.UUID) (*eeTypes.GettablePAT, basemodel.BaseApiError)
ListPATs(ctx context.Context, orgID string) ([]eeTypes.GettablePAT, basemodel.BaseApiError)
CreatePAT(ctx context.Context, orgID string, p types.GettablePAT) (types.GettablePAT, basemodel.BaseApiError)
UpdatePAT(ctx context.Context, orgID string, p types.GettablePAT, id valuer.UUID) basemodel.BaseApiError
GetPAT(ctx context.Context, pat string) (*types.GettablePAT, basemodel.BaseApiError)
GetPATByID(ctx context.Context, orgID string, id valuer.UUID) (*types.GettablePAT, basemodel.BaseApiError)
ListPATs(ctx context.Context, orgID string) ([]types.GettablePAT, basemodel.BaseApiError)
RevokePAT(ctx context.Context, orgID string, id valuer.UUID, userID string) basemodel.BaseApiError
}

View File

@@ -0,0 +1,191 @@
package sqlite
import (
"context"
"fmt"
"net/url"
"time"
"github.com/SigNoz/signoz/ee/query-service/constants"
"github.com/SigNoz/signoz/ee/query-service/model"
baseauth "github.com/SigNoz/signoz/pkg/query-service/auth"
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/SigNoz/signoz/pkg/query-service/utils"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/google/uuid"
"go.uber.org/zap"
)
func (m *modelDao) createUserForSAMLRequest(ctx context.Context, email string) (*types.User, basemodel.BaseApiError) {
// get auth domain from email domain
domain, apierr := m.GetDomainByEmail(ctx, email)
if apierr != nil {
zap.L().Error("failed to get domain from email", zap.Error(apierr))
return nil, model.InternalErrorStr("failed to get domain from email")
}
if domain == nil {
zap.L().Error("email domain does not match any authenticated domain", zap.String("email", email))
return nil, model.InternalErrorStr("email domain does not match any authenticated domain")
}
hash, err := baseauth.PasswordHash(utils.GeneratePassowrd())
if err != nil {
zap.L().Error("failed to generate password hash when registering a user via SSO redirect", zap.Error(err))
return nil, model.InternalErrorStr("failed to generate password hash")
}
user := &types.User{
ID: uuid.New().String(),
Name: "",
Email: email,
Password: hash,
TimeAuditable: types.TimeAuditable{
CreatedAt: time.Now(),
},
ProfilePictureURL: "", // Currently unused
Role: authtypes.RoleViewer.String(),
OrgID: domain.OrgID,
}
user, apiErr := m.CreateUser(ctx, user, false)
if apiErr != nil {
zap.L().Error("CreateUser failed", zap.Error(apiErr))
return nil, apiErr
}
return user, nil
}
// PrepareSsoRedirect prepares redirect page link after SSO response
// is successfully parsed (i.e. valid email is available)
func (m *modelDao) PrepareSsoRedirect(ctx context.Context, redirectUri, email string, jwt *authtypes.JWT) (redirectURL string, apierr basemodel.BaseApiError) {
userPayload, apierr := m.GetUserByEmail(ctx, email)
if !apierr.IsNil() {
zap.L().Error("failed to get user with email received from auth provider", zap.String("error", apierr.Error()))
return "", model.BadRequestStr("invalid user email received from the auth provider")
}
user := &types.User{}
if userPayload == nil {
newUser, apiErr := m.createUserForSAMLRequest(ctx, email)
user = newUser
if apiErr != nil {
zap.L().Error("failed to create user with email received from auth provider", zap.Error(apiErr))
return "", apiErr
}
} else {
user = &userPayload.User
}
tokenStore, err := baseauth.GenerateJWTForUser(user, jwt)
if err != nil {
zap.L().Error("failed to generate token for SSO login user", zap.Error(err))
return "", model.InternalErrorStr("failed to generate token for the user")
}
return fmt.Sprintf("%s?jwt=%s&usr=%s&refreshjwt=%s",
redirectUri,
tokenStore.AccessJwt,
user.ID,
tokenStore.RefreshJwt), nil
}
func (m *modelDao) CanUsePassword(ctx context.Context, email string) (bool, basemodel.BaseApiError) {
domain, apierr := m.GetDomainByEmail(ctx, email)
if apierr != nil {
return false, apierr
}
if domain != nil && domain.SsoEnabled {
// sso is enabled, check if the user has admin role
userPayload, baseapierr := m.GetUserByEmail(ctx, email)
if baseapierr != nil || userPayload == nil {
return false, baseapierr
}
if userPayload.Role != authtypes.RoleAdmin.String() {
return false, model.BadRequest(fmt.Errorf("auth method not supported"))
}
}
return true, nil
}
// PrecheckLogin is called when the login or signup page is loaded
// to check sso login is to be prompted
func (m *modelDao) PrecheckLogin(ctx context.Context, email, sourceUrl string) (*basemodel.PrecheckResponse, basemodel.BaseApiError) {
// assume user is valid unless proven otherwise
resp := &basemodel.PrecheckResponse{IsUser: true, CanSelfRegister: false}
// check if email is a valid user
userPayload, baseApiErr := m.GetUserByEmail(ctx, email)
if baseApiErr != nil {
return resp, baseApiErr
}
if userPayload == nil {
resp.IsUser = false
}
ssoAvailable := true
err := m.checkFeature(model.SSO)
if err != nil {
switch err.(type) {
case basemodel.ErrFeatureUnavailable:
// do nothing, just skip sso
ssoAvailable = false
default:
zap.L().Error("feature check failed", zap.String("featureKey", model.SSO), zap.Error(err))
return resp, model.BadRequestStr(err.Error())
}
}
if ssoAvailable {
resp.IsUser = true
// find domain from email
orgDomain, apierr := m.GetDomainByEmail(ctx, email)
if apierr != nil {
zap.L().Error("failed to get org domain from email", zap.String("email", email), zap.Error(apierr.ToError()))
return resp, apierr
}
if orgDomain != nil && orgDomain.SsoEnabled {
// saml is enabled for this domain, lets prepare sso url
if sourceUrl == "" {
sourceUrl = constants.GetDefaultSiteURL()
}
// parse source url that generated the login request
var err error
escapedUrl, _ := url.QueryUnescape(sourceUrl)
siteUrl, err := url.Parse(escapedUrl)
if err != nil {
zap.L().Error("failed to parse referer", zap.Error(err))
return resp, model.InternalError(fmt.Errorf("failed to generate login request"))
}
// build Idp URL that will authenticat the user
// the front-end will redirect user to this url
resp.SsoUrl, err = orgDomain.BuildSsoUrl(siteUrl)
if err != nil {
zap.L().Error("failed to prepare saml request for domain", zap.String("domain", orgDomain.Name), zap.Error(err))
return resp, model.InternalError(err)
}
// set SSO to true, as the url is generated correctly
resp.SSO = true
}
}
return resp, nil
}

View File

@@ -10,8 +10,8 @@ import (
"time"
"github.com/SigNoz/signoz/ee/query-service/model"
"github.com/SigNoz/signoz/ee/types"
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/SigNoz/signoz/pkg/types"
ossTypes "github.com/SigNoz/signoz/pkg/types"
"github.com/google/uuid"
"go.uber.org/zap"
@@ -44,7 +44,7 @@ func (m *modelDao) GetDomainFromSsoResponse(ctx context.Context, relayState *url
}
domain, err = m.GetDomain(ctx, domainId)
if err != nil {
if (err != nil) || domain == nil {
zap.L().Error("failed to find domain from domainId received in IdP response", zap.Error(err))
return nil, fmt.Errorf("invalid credentials")
}
@@ -54,7 +54,7 @@ func (m *modelDao) GetDomainFromSsoResponse(ctx context.Context, relayState *url
domainFromDB, err := m.GetDomainByName(ctx, domainNameStr)
domain = domainFromDB
if err != nil {
if (err != nil) || domain == nil {
zap.L().Error("failed to find domain from domainName received in IdP response", zap.Error(err))
return nil, fmt.Errorf("invalid credentials")
}
@@ -70,7 +70,7 @@ func (m *modelDao) GetDomainFromSsoResponse(ctx context.Context, relayState *url
func (m *modelDao) GetDomainByName(ctx context.Context, name string) (*types.GettableOrgDomain, basemodel.BaseApiError) {
stored := types.StorableOrgDomain{}
err := m.sqlStore.BunDB().NewSelect().
err := m.DB().NewSelect().
Model(&stored).
Where("name = ?", name).
Limit(1).
@@ -94,7 +94,7 @@ func (m *modelDao) GetDomainByName(ctx context.Context, name string) (*types.Get
func (m *modelDao) GetDomain(ctx context.Context, id uuid.UUID) (*types.GettableOrgDomain, basemodel.BaseApiError) {
stored := types.StorableOrgDomain{}
err := m.sqlStore.BunDB().NewSelect().
err := m.DB().NewSelect().
Model(&stored).
Where("id = ?", id).
Limit(1).
@@ -119,7 +119,7 @@ func (m *modelDao) ListDomains(ctx context.Context, orgId string) ([]types.Getta
domains := []types.GettableOrgDomain{}
stored := []types.StorableOrgDomain{}
err := m.sqlStore.BunDB().NewSelect().
err := m.DB().NewSelect().
Model(&stored).
Where("org_id = ?", orgId).
Scan(ctx)
@@ -167,7 +167,7 @@ func (m *modelDao) CreateDomain(ctx context.Context, domain *types.GettableOrgDo
TimeAuditable: ossTypes.TimeAuditable{CreatedAt: time.Now(), UpdatedAt: time.Now()},
}
_, err = m.sqlStore.BunDB().NewInsert().
_, err = m.DB().NewInsert().
Model(&storableDomain).
Exec(ctx)
@@ -201,7 +201,7 @@ func (m *modelDao) UpdateDomain(ctx context.Context, domain *types.GettableOrgDo
TimeAuditable: ossTypes.TimeAuditable{UpdatedAt: time.Now()},
}
_, err = m.sqlStore.BunDB().NewUpdate().
_, err = m.DB().NewUpdate().
Model(storableDomain).
Column("data", "updated_at").
WherePK().
@@ -224,7 +224,7 @@ func (m *modelDao) DeleteDomain(ctx context.Context, id uuid.UUID) basemodel.Bas
}
storableDomain := &types.StorableOrgDomain{ID: id}
_, err := m.sqlStore.BunDB().NewDelete().
_, err := m.DB().NewDelete().
Model(storableDomain).
WherePK().
Exec(ctx)
@@ -251,7 +251,7 @@ func (m *modelDao) GetDomainByEmail(ctx context.Context, email string) (*types.G
parsedDomain := components[1]
stored := types.StorableOrgDomain{}
err := m.sqlStore.BunDB().NewSelect().
err := m.DB().NewSelect().
Model(&stored).
Where("name = ?", parsedDomain).
Limit(1).

View File

@@ -1,18 +1,46 @@
package sqlite
import (
"github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
"fmt"
basedao "github.com/SigNoz/signoz/pkg/query-service/dao"
basedsql "github.com/SigNoz/signoz/pkg/query-service/dao/sqlite"
baseint "github.com/SigNoz/signoz/pkg/query-service/interfaces"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/uptrace/bun"
)
type modelDao struct {
userModule user.Module
sqlStore sqlstore.SQLStore
*basedsql.ModelDaoSqlite
flags baseint.FeatureLookup
}
// SetFlagProvider sets the feature lookup provider
func (m *modelDao) SetFlagProvider(flags baseint.FeatureLookup) {
m.flags = flags
}
// CheckFeature confirms if a feature is available
func (m *modelDao) checkFeature(key string) error {
if m.flags == nil {
return fmt.Errorf("flag provider not set")
}
return m.flags.CheckFeature(key)
}
// InitDB creates and extends base model DB repository
func NewModelDao(sqlStore sqlstore.SQLStore) *modelDao {
userModule := impluser.NewModule(impluser.NewStore(sqlStore))
return &modelDao{userModule: userModule, sqlStore: sqlStore}
func InitDB(sqlStore sqlstore.SQLStore) (*modelDao, error) {
dao, err := basedsql.InitDB(sqlStore)
if err != nil {
return nil, err
}
// set package variable so dependent base methods (e.g. AuthCache) will work
basedao.SetDB(dao)
m := &modelDao{ModelDaoSqlite: dao}
return m, nil
}
func (m *modelDao) DB() *bun.DB {
return m.ModelDaoSqlite.DB()
}

View File

@@ -17,7 +17,7 @@ import (
func (m *modelDao) CreatePAT(ctx context.Context, orgID string, p types.GettablePAT) (types.GettablePAT, basemodel.BaseApiError) {
p.StorablePersonalAccessToken.OrgID = orgID
p.StorablePersonalAccessToken.ID = valuer.GenerateUUID()
_, err := m.sqlStore.BunDB().NewInsert().
_, err := m.DB().NewInsert().
Model(&p.StorablePersonalAccessToken).
Exec(ctx)
if err != nil {
@@ -25,7 +25,7 @@ func (m *modelDao) CreatePAT(ctx context.Context, orgID string, p types.Gettable
return types.GettablePAT{}, model.InternalError(fmt.Errorf("PAT insertion failed"))
}
createdByUser, _ := m.userModule.GetUserByID(ctx, orgID, p.UserID)
createdByUser, _ := m.GetUser(ctx, p.UserID)
if createdByUser == nil {
p.CreatedByUser = types.PatUser{
NotFound: true,
@@ -33,15 +33,14 @@ func (m *modelDao) CreatePAT(ctx context.Context, orgID string, p types.Gettable
} else {
p.CreatedByUser = types.PatUser{
User: ossTypes.User{
Identifiable: ossTypes.Identifiable{
ID: createdByUser.ID,
},
DisplayName: createdByUser.DisplayName,
Email: createdByUser.Email,
ID: createdByUser.ID,
Name: createdByUser.Name,
Email: createdByUser.Email,
TimeAuditable: ossTypes.TimeAuditable{
CreatedAt: createdByUser.CreatedAt,
UpdatedAt: createdByUser.UpdatedAt,
},
ProfilePictureURL: createdByUser.ProfilePictureURL,
},
NotFound: false,
}
@@ -50,7 +49,7 @@ func (m *modelDao) CreatePAT(ctx context.Context, orgID string, p types.Gettable
}
func (m *modelDao) UpdatePAT(ctx context.Context, orgID string, p types.GettablePAT, id valuer.UUID) basemodel.BaseApiError {
_, err := m.sqlStore.BunDB().NewUpdate().
_, err := m.DB().NewUpdate().
Model(&p.StorablePersonalAccessToken).
Column("role", "name", "updated_at", "updated_by_user_id").
Where("id = ?", id.StringValue()).
@@ -67,7 +66,7 @@ func (m *modelDao) UpdatePAT(ctx context.Context, orgID string, p types.Gettable
func (m *modelDao) ListPATs(ctx context.Context, orgID string) ([]types.GettablePAT, basemodel.BaseApiError) {
pats := []types.StorablePersonalAccessToken{}
if err := m.sqlStore.BunDB().NewSelect().
if err := m.DB().NewSelect().
Model(&pats).
Where("revoked = false").
Where("org_id = ?", orgID).
@@ -83,7 +82,7 @@ func (m *modelDao) ListPATs(ctx context.Context, orgID string) ([]types.Gettable
StorablePersonalAccessToken: pats[i],
}
createdByUser, _ := m.userModule.GetUserByID(ctx, orgID, pats[i].UserID)
createdByUser, _ := m.GetUser(ctx, pats[i].UserID)
if createdByUser == nil {
patWithUser.CreatedByUser = types.PatUser{
NotFound: true,
@@ -91,21 +90,20 @@ func (m *modelDao) ListPATs(ctx context.Context, orgID string) ([]types.Gettable
} else {
patWithUser.CreatedByUser = types.PatUser{
User: ossTypes.User{
Identifiable: ossTypes.Identifiable{
ID: createdByUser.ID,
},
DisplayName: createdByUser.DisplayName,
Email: createdByUser.Email,
ID: createdByUser.ID,
Name: createdByUser.Name,
Email: createdByUser.Email,
TimeAuditable: ossTypes.TimeAuditable{
CreatedAt: createdByUser.CreatedAt,
UpdatedAt: createdByUser.UpdatedAt,
},
ProfilePictureURL: createdByUser.ProfilePictureURL,
},
NotFound: false,
}
}
updatedByUser, _ := m.userModule.GetUserByID(ctx, orgID, pats[i].UpdatedByUserID)
updatedByUser, _ := m.GetUser(ctx, pats[i].UpdatedByUserID)
if updatedByUser == nil {
patWithUser.UpdatedByUser = types.PatUser{
NotFound: true,
@@ -113,15 +111,14 @@ func (m *modelDao) ListPATs(ctx context.Context, orgID string) ([]types.Gettable
} else {
patWithUser.UpdatedByUser = types.PatUser{
User: ossTypes.User{
Identifiable: ossTypes.Identifiable{
ID: updatedByUser.ID,
},
DisplayName: updatedByUser.DisplayName,
Email: updatedByUser.Email,
ID: updatedByUser.ID,
Name: updatedByUser.Name,
Email: updatedByUser.Email,
TimeAuditable: ossTypes.TimeAuditable{
CreatedAt: updatedByUser.CreatedAt,
UpdatedAt: updatedByUser.UpdatedAt,
},
ProfilePictureURL: updatedByUser.ProfilePictureURL,
},
NotFound: false,
}
@@ -134,7 +131,7 @@ func (m *modelDao) ListPATs(ctx context.Context, orgID string) ([]types.Gettable
func (m *modelDao) RevokePAT(ctx context.Context, orgID string, id valuer.UUID, userID string) basemodel.BaseApiError {
updatedAt := time.Now().Unix()
_, err := m.sqlStore.BunDB().NewUpdate().
_, err := m.DB().NewUpdate().
Model(&types.StorablePersonalAccessToken{}).
Set("revoked = ?", true).
Set("updated_by_user_id = ?", userID).
@@ -152,7 +149,7 @@ func (m *modelDao) RevokePAT(ctx context.Context, orgID string, id valuer.UUID,
func (m *modelDao) GetPAT(ctx context.Context, token string) (*types.GettablePAT, basemodel.BaseApiError) {
pats := []types.StorablePersonalAccessToken{}
if err := m.sqlStore.BunDB().NewSelect().
if err := m.DB().NewSelect().
Model(&pats).
Where("token = ?", token).
Where("revoked = false").
@@ -177,7 +174,7 @@ func (m *modelDao) GetPAT(ctx context.Context, token string) (*types.GettablePAT
func (m *modelDao) GetPATByID(ctx context.Context, orgID string, id valuer.UUID) (*types.GettablePAT, basemodel.BaseApiError) {
pats := []types.StorablePersonalAccessToken{}
if err := m.sqlStore.BunDB().NewSelect().
if err := m.DB().NewSelect().
Model(&pats).
Where("id = ?", id.StringValue()).
Where("org_id = ?", orgID).

View File

@@ -5,6 +5,7 @@ import (
"database/sql"
"encoding/json"
"fmt"
"github.com/SigNoz/signoz/ee/query-service/constants"
"time"
"github.com/jmoiron/sqlx"
@@ -207,6 +208,14 @@ func (r *Repo) GetAllFeatures() ([]basemodel.Feature, error) {
return feature, err
}
env := constants.GetOrDefaultEnv("DOT_METRICS_MIGRATED", "false")
if env == "true" {
feature = append(feature, basemodel.Feature{
Name: "DOT_METRICS_ENABLED",
Active: true,
})
}
return feature, nil
}

View File

@@ -6,7 +6,6 @@ import (
"os"
"time"
eeuserimpl "github.com/SigNoz/signoz/ee/modules/user/impluser"
"github.com/SigNoz/signoz/ee/query-service/app"
"github.com/SigNoz/signoz/ee/sqlstore/postgressqlstore"
"github.com/SigNoz/signoz/ee/zeus"
@@ -14,10 +13,8 @@ import (
"github.com/SigNoz/signoz/pkg/config"
"github.com/SigNoz/signoz/pkg/config/envprovider"
"github.com/SigNoz/signoz/pkg/config/fileprovider"
"github.com/SigNoz/signoz/pkg/modules/user"
baseconst "github.com/SigNoz/signoz/pkg/query-service/constants"
"github.com/SigNoz/signoz/pkg/signoz"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/sqlstore/sqlstorehook"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/version"
@@ -121,12 +118,6 @@ func main() {
signoz.NewWebProviderFactories(),
sqlStoreFactories,
signoz.NewTelemetryStoreProviderFactories(),
func(sqlstore sqlstore.SQLStore) user.Module {
return eeuserimpl.NewModule(eeuserimpl.NewStore(sqlstore))
},
func(userModule user.Module) user.Handler {
return eeuserimpl.NewHandler(userModule)
},
)
if err != nil {
zap.L().Fatal("Failed to create signoz", zap.Error(err))

View File

@@ -0,0 +1,12 @@
package model
import (
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
)
// GettableInvitation overrides base object and adds precheck into
// response
type GettableInvitation struct {
*basemodel.InvitationResponseObject
Precheck *basemodel.PrecheckResponse `json:"precheck"`
}

View File

@@ -1,34 +1,34 @@
package ssotypes
package sso
import (
"context"
"errors"
"fmt"
"errors"
"context"
"net/http"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
type GoogleOAuthProvider struct {
RedirectURI string
OAuth2Config *oauth2.Config
Verifier *oidc.IDTokenVerifier
Cancel context.CancelFunc
HostedDomain string
RedirectURI string
OAuth2Config *oauth2.Config
Verifier *oidc.IDTokenVerifier
Cancel context.CancelFunc
HostedDomain string
}
func (g *GoogleOAuthProvider) BuildAuthURL(state string) (string, error) {
var opts []oauth2.AuthCodeOption
// set hosted domain. google supports multiple hosted domains but in our case
// we have one config per host domain.
// we have one config per host domain.
opts = append(opts, oauth2.SetAuthURLParam("hd", g.HostedDomain))
return g.OAuth2Config.AuthCodeURL(state, opts...), nil
}
type oauth2Error struct {
type oauth2Error struct{
error string
errorDescription string
}
@@ -54,6 +54,7 @@ func (g *GoogleOAuthProvider) HandleCallback(r *http.Request) (identity *SSOIden
return g.createIdentity(r.Context(), token)
}
func (g *GoogleOAuthProvider) createIdentity(ctx context.Context, token *oauth2.Token) (identity *SSOIdentity, err error) {
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
@@ -75,7 +76,7 @@ func (g *GoogleOAuthProvider) createIdentity(ctx context.Context, token *oauth2.
}
if claims.HostedDomain != g.HostedDomain {
return identity, fmt.Errorf("oidc: unexpected hd claim %v", claims.HostedDomain)
return identity, fmt.Errorf("oidc: unexpected hd claim %v", claims.HostedDomain)
}
identity = &SSOIdentity{
@@ -88,3 +89,4 @@ func (g *GoogleOAuthProvider) createIdentity(ctx context.Context, token *oauth2.
return identity, nil
}

View File

@@ -0,0 +1,31 @@
package sso
import (
"net/http"
)
// SSOIdentity contains details of user received from SSO provider
type SSOIdentity struct {
UserID string
Username string
PreferredUsername string
Email string
EmailVerified bool
ConnectorData []byte
}
// OAuthCallbackProvider is an interface implemented by connectors which use an OAuth
// style redirect flow to determine user information.
type OAuthCallbackProvider interface {
// The initial URL user would be redirect to.
// OAuth2 implementations support various scopes but we only need profile and user as
// the roles are still being managed in SigNoz.
BuildAuthURL(state string) (string, error)
// Handle the callback to the server (after login at oauth provider site)
// and return a email identity.
// At the moment we dont support auto signup flow (based on domain), so
// the full identity (including name, group etc) is not required outside of the
// connector
HandleCallback(r *http.Request) (identity *SSOIdentity, err error)
}

View File

@@ -1,4 +1,4 @@
package ssotypes
package saml
import (
"crypto/x509"

View File

@@ -19,14 +19,12 @@ var (
var (
Org = "org"
User = "user"
FactorPassword = "factor_password"
CloudIntegration = "cloud_integration"
)
var (
OrgReference = `("org_id") REFERENCES "organizations" ("id")`
UserReference = `("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE`
FactorPasswordReference = `("password_id") REFERENCES "factor_password" ("id")`
CloudIntegrationReference = `("cloud_integration_id") REFERENCES "cloud_integration" ("id") ON DELETE CASCADE`
)
@@ -266,8 +264,6 @@ func (dialect *dialect) RenameTableAndModifyModel(ctx context.Context, bun bun.I
fkReferences = append(fkReferences, OrgReference)
} else if reference == User && !slices.Contains(fkReferences, UserReference) {
fkReferences = append(fkReferences, UserReference)
} else if reference == FactorPassword && !slices.Contains(fkReferences, FactorPasswordReference) {
fkReferences = append(fkReferences, FactorPasswordReference)
} else if reference == CloudIntegration && !slices.Contains(fkReferences, CloudIntegrationReference) {
fkReferences = append(fkReferences, CloudIntegrationReference)
}

View File

@@ -6,7 +6,9 @@ import (
"net/url"
"strings"
"github.com/SigNoz/signoz/pkg/types/ssotypes"
"github.com/SigNoz/signoz/ee/query-service/sso"
"github.com/SigNoz/signoz/ee/query-service/sso/saml"
"github.com/SigNoz/signoz/pkg/types"
"github.com/google/uuid"
"github.com/pkg/errors"
saml2 "github.com/russellhaering/gosaml2"
@@ -17,7 +19,7 @@ import (
type StorableOrgDomain struct {
bun.BaseModel `bun:"table:org_domains"`
TimeAuditable
types.TimeAuditable
ID uuid.UUID `json:"id" bun:"id,pk,type:text"`
OrgID string `json:"orgId" bun:"org_id,type:text,notnull"`
Name string `json:"name" bun:"name,type:varchar(50),notnull,unique"`
@@ -38,10 +40,10 @@ type GettableOrgDomain struct {
SsoEnabled bool `json:"ssoEnabled"`
SsoType SSOType `json:"ssoType"`
SamlConfig *ssotypes.SamlConfig `json:"samlConfig"`
GoogleAuthConfig *ssotypes.GoogleOAuthConfig `json:"googleAuthConfig"`
SamlConfig *SamlConfig `json:"samlConfig"`
GoogleAuthConfig *GoogleOAuthConfig `json:"googleAuthConfig"`
Org *Organization
Org *types.Organization
}
func (od *GettableOrgDomain) String() string {
@@ -110,7 +112,7 @@ func (od *GettableOrgDomain) GetSAMLCert() string {
// PrepareGoogleOAuthProvider creates GoogleProvider that is used in
// requesting OAuth and also used in processing response from google
func (od *GettableOrgDomain) PrepareGoogleOAuthProvider(siteUrl *url.URL) (ssotypes.OAuthCallbackProvider, error) {
func (od *GettableOrgDomain) PrepareGoogleOAuthProvider(siteUrl *url.URL) (sso.OAuthCallbackProvider, error) {
if od.GoogleAuthConfig == nil {
return nil, fmt.Errorf("GOOGLE OAUTH is not setup correctly for this domain")
}
@@ -141,7 +143,7 @@ func (od *GettableOrgDomain) PrepareSamlRequest(siteUrl *url.URL) (*saml2.SAMLSe
// currently we default it to host from window.location (received from browser)
issuer := siteUrl.Host
return ssotypes.PrepareRequest(issuer, acs, sourceUrl, od.GetSAMLEntityID(), od.GetSAMLIdpURL(), od.GetSAMLCert())
return saml.PrepareRequest(issuer, acs, sourceUrl, od.GetSAMLEntityID(), od.GetSAMLIdpURL(), od.GetSAMLCert())
}
func (od *GettableOrgDomain) BuildSsoUrl(siteUrl *url.URL) (ssoUrl string, err error) {

View File

@@ -1,41 +1,15 @@
package ssotypes
package types
import (
"context"
"fmt"
"net/http"
"net/url"
"github.com/SigNoz/signoz/ee/query-service/sso"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
// SSOIdentity contains details of user received from SSO provider
type SSOIdentity struct {
UserID string
Username string
PreferredUsername string
Email string
EmailVerified bool
ConnectorData []byte
}
// OAuthCallbackProvider is an interface implemented by connectors which use an OAuth
// style redirect flow to determine user information.
type OAuthCallbackProvider interface {
// The initial URL user would be redirect to.
// OAuth2 implementations support various scopes but we only need profile and user as
// the roles are still being managed in SigNoz.
BuildAuthURL(state string) (string, error)
// Handle the callback to the server (after login at oauth provider site)
// and return a email identity.
// At the moment we dont support auto signup flow (based on domain), so
// the full identity (including name, group etc) is not required outside of the
// connector
HandleCallback(r *http.Request) (identity *SSOIdentity, err error)
}
type SamlConfig struct {
SamlEntity string `json:"samlEntity"`
SamlIdp string `json:"samlIdp"`
@@ -53,7 +27,7 @@ const (
googleIssuerURL = "https://accounts.google.com"
)
func (g *GoogleOAuthConfig) GetProvider(domain string, siteUrl *url.URL) (OAuthCallbackProvider, error) {
func (g *GoogleOAuthConfig) GetProvider(domain string, siteUrl *url.URL) (sso.OAuthCallbackProvider, error) {
ctx, cancel := context.WithCancel(context.Background())
@@ -73,7 +47,7 @@ func (g *GoogleOAuthConfig) GetProvider(domain string, siteUrl *url.URL) (OAuthC
siteUrl.Host,
"api/v1/complete/google")
return &GoogleOAuthProvider{
return &sso.GoogleOAuthProvider{
RedirectURI: g.RedirectURI,
OAuth2Config: &oauth2.Config{
ClientID: g.ClientID,

View File

@@ -110,8 +110,6 @@ module.exports = {
// eslint rules need to remove
'@typescript-eslint/no-shadow': 'off',
'import/no-cycle': 'off',
// https://typescript-eslint.io/rules/consistent-return/ check the warning for details
'consistent-return': 'off',
'prettier/prettier': [
'error',
{},

View File

@@ -69,5 +69,5 @@
"METRICS_EXPLORER": "SigNoz | Metrics Explorer",
"METRICS_EXPLORER_EXPLORER": "SigNoz | Metrics Explorer",
"METRICS_EXPLORER_VIEWS": "SigNoz | Metrics Explorer",
"API_MONITORING": "SigNoz | Third Party API"
"API_MONITORING": "SigNoz | API Monitoring"
}

View File

@@ -1,6 +1,6 @@
import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
import getAll from 'api/v1/user/get';
import getOrgUser from 'api/v1/user/getOrgUser';
import { FeatureKeys } from 'constants/features';
import { LOCALSTORAGE } from 'constants/localStorage';
import ROUTES from 'constants/routes';
@@ -11,11 +11,8 @@ import { useAppContext } from 'providers/App/App';
import { ReactChild, useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { matchPath, useLocation } from 'react-router-dom';
import { SuccessResponseV2 } from 'types/api';
import APIError from 'types/api/error';
import { LicensePlatform, LicenseState } from 'types/api/licensesV3/getActive';
import { Organization } from 'types/api/user/getOrganization';
import { UserResponse } from 'types/api/user/getUser';
import { USER_ROLES } from 'types/roles';
import { routePermission } from 'utils/permission';
@@ -61,13 +58,12 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
const [orgData, setOrgData] = useState<Organization | undefined>(undefined);
const { data: usersData, isFetching: isFetchingUsers } = useQuery<
SuccessResponseV2<UserResponse[]> | undefined,
APIError
>({
const { data: orgUsers, isFetching: isFetchingOrgUsers } = useQuery({
queryFn: () => {
if (orgData && orgData.id !== undefined) {
return getAll();
return getOrgUser({
orgId: orgData.id,
});
}
return undefined;
},
@@ -76,23 +72,23 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
});
const checkFirstTimeUser = useCallback((): boolean => {
const users = usersData?.data || [];
const users = orgUsers?.payload || [];
const remainingUsers = users.filter(
(user) => user.email !== 'admin@signoz.cloud',
);
return remainingUsers.length === 1;
}, [usersData?.data]);
}, [orgUsers?.payload]);
useEffect(() => {
if (
isCloudUserVal &&
!isFetchingOrgPreferences &&
orgPreferences &&
!isFetchingUsers &&
usersData &&
usersData.data
!isFetchingOrgUsers &&
orgUsers &&
orgUsers.payload
) {
const isOnboardingComplete = orgPreferences?.find(
(preference: Record<string, any>) => preference.key === 'ORG_ONBOARDING',
@@ -112,9 +108,9 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
checkFirstTimeUser,
isCloudUserVal,
isFetchingOrgPreferences,
isFetchingUsers,
isFetchingOrgUsers,
orgPreferences,
usersData,
orgUsers,
pathname,
]);

View File

@@ -70,14 +70,14 @@ function App(): JSX.Element {
const orgName =
org && Array.isArray(org) && org.length > 0 ? org[0].displayName : '';
const { displayName, email, role } = user;
const { name, email, role } = user;
const domain = extractDomain(email);
const hostNameParts = hostname.split('.');
const identifyPayload = {
email,
name: displayName,
name,
company_name: orgName,
tenant_id: hostNameParts[0],
data_region: hostNameParts[1],
@@ -106,7 +106,7 @@ function App(): JSX.Element {
Userpilot.identify(email, {
email,
name: displayName,
name,
orgName,
tenant_id: hostNameParts[0],
data_region: hostNameParts[1],
@@ -118,7 +118,7 @@ function App(): JSX.Element {
posthog?.identify(email, {
email,
name: displayName,
name,
orgName,
tenant_id: hostNameParts[0],
data_region: hostNameParts[1],
@@ -144,7 +144,7 @@ function App(): JSX.Element {
) {
window.cioanalytics.reset();
window.cioanalytics.identify(email, {
name: user.displayName,
name: user.name,
email,
role: user.role,
});
@@ -258,7 +258,7 @@ function App(): JSX.Element {
window.Intercom('boot', {
app_id: process.env.INTERCOM_APP_ID,
email: user?.email || '',
name: user?.displayName || '',
name: user?.name || '',
});
}
}

View File

@@ -1,34 +0,0 @@
import { ApiBaseInstance } from 'api';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { FieldKeyResponse } from 'types/api/dynamicVariables/getFieldKeys';
/**
* Get field keys for a given signal type
* @param signal Type of signal (traces, logs, metrics)
* @param name Optional search text
*/
export const getFieldKeys = async (
signal?: 'traces' | 'logs' | 'metrics',
name?: string,
): Promise<SuccessResponse<FieldKeyResponse> | ErrorResponse> => {
const params: Record<string, string> = {};
if (signal) {
params.signal = signal;
}
if (name) {
params.name = name;
}
const response = await ApiBaseInstance.get('/fields/keys', { params });
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
};
export default getFieldKeys;

View File

@@ -1,63 +0,0 @@
import { ApiBaseInstance } from 'api';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { FieldValueResponse } from 'types/api/dynamicVariables/getFieldValues';
/**
* Get field values for a given signal type and field name
* @param signal Type of signal (traces, logs, metrics)
* @param name Name of the attribute for which values are being fetched
* @param value Optional search text
*/
export const getFieldValues = async (
signal?: 'traces' | 'logs' | 'metrics',
name?: string,
value?: string,
startUnixMilli?: number,
endUnixMilli?: number,
): Promise<SuccessResponse<FieldValueResponse> | ErrorResponse> => {
const params: Record<string, string> = {};
if (signal) {
params.signal = signal;
}
if (name) {
params.name = name;
}
if (value) {
params.value = value;
}
if (startUnixMilli) {
params.startUnixMilli = Math.floor(startUnixMilli / 1000000).toString();
}
if (endUnixMilli) {
params.endUnixMilli = Math.floor(endUnixMilli / 1000000).toString();
}
const response = await ApiBaseInstance.get('/fields/values', { params });
// Normalize values from different types (stringValues, boolValues, etc.)
if (response.data?.data?.values) {
const allValues: string[] = [];
Object.values(response.data.data.values).forEach((valueArray: any) => {
if (Array.isArray(valueArray)) {
allValues.push(...valueArray.map(String));
}
});
// Add a normalized values array to the response
response.data.data.normalizedValues = allValues;
}
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
};
export default getFieldValues;

View File

@@ -2,7 +2,7 @@
/* eslint-disable no-param-reassign */
/* eslint-disable @typescript-eslint/no-explicit-any */
import getLocalStorageApi from 'api/browser/localstorage/get';
import loginApi from 'api/v1/login/login';
import loginApi from 'api/v1/user/login';
import afterLogin from 'AppRoutes/utils';
import axios, { AxiosResponse, InternalAxiosRequestConfig } from 'axios';
import { ENVIRONMENT } from 'constants/env';
@@ -71,15 +71,15 @@ const interceptorRejected = async (
const { response } = value;
// reject the refresh token error
if (response.status === 401 && response.config.url !== '/login') {
try {
const response = await loginApi({
refreshToken: getLocalStorageApi(LOCALSTORAGE.REFRESH_AUTH_TOKEN) || '',
});
const response = await loginApi({
refreshToken: getLocalStorageApi(LOCALSTORAGE.REFRESH_AUTH_TOKEN) || '',
});
if (response.statusCode === 200) {
afterLogin(
response.data.userId,
response.data.accessJwt,
response.data.refreshJwt,
response.payload.userId,
response.payload.accessJwt,
response.payload.refreshJwt,
true,
);
@@ -89,22 +89,23 @@ const interceptorRejected = async (
method: value.config.method,
headers: {
...value.config.headers,
Authorization: `Bearer ${response.data.accessJwt}`,
Authorization: `Bearer ${response.payload.accessJwt}`,
},
data: {
...JSON.parse(value.config.data || '{}'),
},
},
);
if (reResponse.status === 200) {
return await Promise.resolve(reResponse);
}
Logout();
return await Promise.reject(reResponse);
} catch (error) {
Logout();
}
Logout();
}
// when refresh token is expired
if (response.status === 401 && response.config.url === '/login') {
Logout();

View File

@@ -1,26 +1,25 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/user/changeMyPassword';
const changeMyPassword = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.post<PayloadProps>(
`/changePassword/${props.userId}`,
{
...props,
},
);
const response = await axios.post(`/changePassword/${props.userId}`, {
...props,
});
return {
httpStatusCode: response.status,
data: response.data,
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
return ErrorResponseHandler(error as AxiosError);
}
};

View File

@@ -1,27 +1,23 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import {
GetResetPasswordToken,
PayloadProps,
Props,
} from 'types/api/user/getResetPasswordToken';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/user/getResetPasswordToken';
const getResetPasswordToken = async (
props: Props,
): Promise<SuccessResponseV2<GetResetPasswordToken>> => {
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.get<PayloadProps>(
`/getResetPasswordToken/${props.userId}`,
);
const response = await axios.get(`/getResetPasswordToken/${props.userId}`);
return {
httpStatusCode: response.status,
data: response.data.data,
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
return ErrorResponseHandler(error as AxiosError);
}
};

View File

@@ -1,23 +1,25 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/user/resetPassword';
const resetPassword = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.post<PayloadProps>(`/resetPassword`, {
const response = await axios.post(`/resetPassword`, {
...props,
});
return {
httpStatusCode: response.status,
data: response.data,
statusCode: 200,
error: null,
message: response.statusText,
payload: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
return ErrorResponseHandler(error as AxiosError);
}
};

View File

@@ -1,21 +1,18 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { UsersProps } from 'types/api/user/inviteUsers';
import { SuccessResponse } from 'types/api';
import { InviteUsersResponse, UsersProps } from 'types/api/user/inviteUsers';
const inviteUsers = async (
users: UsersProps,
): Promise<SuccessResponseV2<null>> => {
try {
const response = await axios.post(`/invite/bulk`, users);
return {
httpStatusCode: response.status,
data: null,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
): Promise<SuccessResponse<InviteUsersResponse>> => {
const response = await axios.post(`/invite/bulk`, users);
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data,
};
};
export default inviteUsers;

View File

@@ -1,23 +1,25 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/user/setInvite';
const sendInvite = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.post<PayloadProps>(`/invite`, {
const response = await axios.post(`/invite`, {
...props,
});
return {
httpStatusCode: response.status,
data: response.data,
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
return ErrorResponseHandler(error as AxiosError);
}
};

View File

@@ -1,19 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, PendingInvite } from 'types/api/user/getPendingInvites';
const get = async (): Promise<SuccessResponseV2<PendingInvite[]>> => {
try {
const response = await axios.get<PayloadProps>(`/invite`);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default get;

View File

@@ -0,0 +1,24 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps } from 'types/api/user/getPendingInvites';
const getPendingInvites = async (): Promise<
SuccessResponse<PayloadProps> | ErrorResponse
> => {
try {
const response = await axios.get(`/invite`);
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default getPendingInvites;

View File

@@ -1,25 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import {
LoginPrecheckResponse,
PayloadProps,
Props,
} from 'types/api/user/accept';
const accept = async (
props: Props,
): Promise<SuccessResponseV2<LoginPrecheckResponse>> => {
try {
const response = await axios.post<PayloadProps>(`/invite/accept`, props);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default accept;

View File

@@ -1,20 +1,24 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { Props } from 'types/api/user/deleteInvite';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/user/deleteInvite';
const del = async (props: Props): Promise<SuccessResponseV2<null>> => {
const deleteInvite = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.delete(`/invite/${props.id}`);
const response = await axios.delete(`/invite/${props.email}`);
return {
httpStatusCode: response.status,
data: null,
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
return ErrorResponseHandler(error as AxiosError);
}
};
export default del;
export default deleteInvite;

View File

@@ -1,27 +1,25 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import {
InviteDetails,
PayloadProps,
Props,
} from 'types/api/user/getInviteDetails';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/user/getInviteDetails';
const getInviteDetails = async (
props: Props,
): Promise<SuccessResponseV2<InviteDetails>> => {
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.get<PayloadProps>(
const response = await axios.get(
`/invite/${props.inviteId}?ref=${window.location.href}`,
);
return {
httpStatusCode: response.status,
data: response.data.data,
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
return ErrorResponseHandler(error as AxiosError);
}
};

View File

@@ -2,11 +2,11 @@ import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props, UserLoginResponse } from 'types/api/user/login';
import { PayloadProps, Props } from 'types/api/user/login';
const login = async (
props: Props,
): Promise<SuccessResponseV2<UserLoginResponse>> => {
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.post<PayloadProps>(`/login`, {
...props,
@@ -14,10 +14,12 @@ const login = async (
return {
httpStatusCode: response.status,
data: response.data.data,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
// this line is never reached but ts isn't detecting the never type properly for the ErrorResponseHandlerV2
throw error;
}
};

View File

@@ -1,21 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { UserResponse } from 'types/api/user/getUser';
import { PayloadProps } from 'types/api/user/getUsers';
const getAll = async (): Promise<SuccessResponseV2<UserResponse[]>> => {
try {
const response = await axios.get<PayloadProps>(`/user`);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default getAll;

View File

@@ -0,0 +1,24 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/user/getOrgMembers';
const getOrgUser = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.get(`/orgUsers/${props.orgId}`);
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default getOrgUser;

View File

@@ -1,19 +1,23 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { Props } from 'types/api/user/deleteUser';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/user/deleteUser';
const deleteUser = async (props: Props): Promise<SuccessResponseV2<null>> => {
const deleteUser = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.delete(`/user/${props.userId}`);
return {
httpStatusCode: response.status,
data: null,
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
return ErrorResponseHandler(error as AxiosError);
}
};

View File

@@ -1,22 +1,18 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props, UserResponse } from 'types/api/user/getUser';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/user/getUser';
const getUser = async (
props: Props,
): Promise<SuccessResponseV2<UserResponse>> => {
try {
const response = await axios.get<PayloadProps>(`/user/${props.userId}`);
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
const response = await axios.get(`/user/${props.userId}`);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
return {
statusCode: 200,
error: null,
message: 'Success',
payload: response.data,
};
};
export default getUser;

View File

@@ -0,0 +1,28 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/user/getUserRole';
const getRoles = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.get(`/rbac/role/${props.userId}`, {
headers: {
Authorization: `bearer ${props.token}`,
},
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default getRoles;

View File

@@ -1,23 +1,26 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { Props } from 'types/api/user/editUser';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/user/editUser';
const update = async (props: Props): Promise<SuccessResponseV2<null>> => {
const editUser = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.put(`/user/${props.userId}`, {
displayName: props.displayName,
role: props.role,
Name: props.name,
});
return {
httpStatusCode: response.status,
data: null,
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
return ErrorResponseHandler(error as AxiosError);
}
};
export default update;
export default editUser;

View File

@@ -0,0 +1,26 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/user/updateRole';
const updateRole = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.put(`/rbac/role/${props.userId}`, {
group_name: props.group_name,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default updateRole;

View File

@@ -0,0 +1,26 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/user/login';
const login = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.post(`/login`, {
...props,
});
return {
statusCode: 200,
error: null,
message: response.statusText,
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default login;

View File

@@ -27,9 +27,7 @@ import { popupContainer } from 'utils/selectPopupContainer';
import { CustomMultiSelectProps, CustomTagProps, OptionData } from './types';
import {
ALL_SELECTED_VALUE,
filterOptionsBySearch,
handleScrollToBottom,
prioritizeOrAddOptionForMultiSelect,
SPACEKEY,
} from './utils';
@@ -39,6 +37,8 @@ enum ToggleTagValue {
All = 'All',
}
const ALL_SELECTED_VALUE = '__all__'; // Constant for the special value
const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
placeholder = 'Search...',
className,
@@ -62,8 +62,6 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
allowClear = false,
onRetry,
maxTagTextLength,
onDropdownVisibleChange,
showIncompleteDataMessage = false,
...rest
}) => {
// ===== State & Refs =====
@@ -80,8 +78,6 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
const optionRefs = useRef<Record<number, HTMLDivElement | null>>({});
const [visibleOptions, setVisibleOptions] = useState<OptionData[]>([]);
const isClickInsideDropdownRef = useRef(false);
const justOpenedRef = useRef<boolean>(false);
const [isScrolledToBottom, setIsScrolledToBottom] = useState(false);
// Convert single string value to array for consistency
const selectedValues = useMemo(
@@ -128,12 +124,6 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
return allAvailableValues.every((val) => selectedValues.includes(val));
}, [selectedValues, allAvailableValues, enableAllSelection]);
// Define allOptionShown earlier in the code
const allOptionShown = useMemo(
() => value === ALL_SELECTED_VALUE || value === 'ALL',
[value],
);
// Value passed to the underlying Ant Select component
const displayValue = useMemo(
() => (isAllSelected ? [ALL_SELECTED_VALUE] : selectedValues),
@@ -142,18 +132,10 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
// ===== Internal onChange Handler =====
const handleInternalChange = useCallback(
(newValue: string | string[], directCaller?: boolean): void => {
(newValue: string | string[]): void => {
// Ensure newValue is an array
const currentNewValue = Array.isArray(newValue) ? newValue : [];
if (
(allOptionShown || isAllSelected) &&
!directCaller &&
currentNewValue.length === 0
) {
return;
}
if (!onChange) return;
// Case 1: Cleared (empty array or undefined)
@@ -162,7 +144,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
return;
}
// Case 2: "__ALL__" is selected (means select all actual values)
// Case 2: "__all__" is selected (means select all actual values)
if (currentNewValue.includes(ALL_SELECTED_VALUE)) {
const allActualOptions = allAvailableValues.map(
(v) => options.flat().find((o) => o.value === v) || { label: v, value: v },
@@ -193,14 +175,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
}
}
},
[
allOptionShown,
isAllSelected,
onChange,
allAvailableValues,
options,
enableAllSelection,
],
[onChange, allAvailableValues, options, enableAllSelection],
);
// ===== Existing Callbacks (potentially needing adjustment later) =====
@@ -535,19 +510,11 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
}
// Normal single value handling
const trimmedValue = value.trim();
setSearchText(trimmedValue);
setSearchText(value.trim());
if (!isOpen) {
setIsOpen(true);
justOpenedRef.current = true;
}
// Reset active index when search changes if dropdown is open
if (isOpen && trimmedValue) {
setActiveIndex(0);
}
if (onSearch) onSearch(trimmedValue);
if (onSearch) onSearch(value.trim());
},
[onSearch, isOpen, selectedValues, onChange],
);
@@ -561,34 +528,28 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
(text: string, searchQuery: string): React.ReactNode => {
if (!searchQuery || !highlightSearch) return text;
try {
const parts = text.split(
new RegExp(
`(${searchQuery.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')})`,
'gi',
),
);
return (
<>
{parts.map((part, i) => {
// Create a unique key that doesn't rely on array index
const uniqueKey = `${text.substring(0, 3)}-${part.substring(0, 3)}-${i}`;
const parts = text.split(
new RegExp(
`(${searchQuery.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&')})`,
'gi',
),
);
return (
<>
{parts.map((part, i) => {
// Create a unique key that doesn't rely on array index
const uniqueKey = `${text.substring(0, 3)}-${part.substring(0, 3)}-${i}`;
return part.toLowerCase() === searchQuery.toLowerCase() ? (
<span key={uniqueKey} className="highlight-text">
{part}
</span>
) : (
part
);
})}
</>
);
} catch (error) {
// If regex fails, return the original text without highlighting
console.error('Error in text highlighting:', error);
return text;
}
return part.toLowerCase() === searchQuery.toLowerCase() ? (
<span key={uniqueKey} className="highlight-text">
{part}
</span>
) : (
part
);
})}
</>
);
},
[highlightSearch],
);
@@ -599,10 +560,10 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
if (isAllSelected) {
// If all are selected, deselect all
handleInternalChange([], true);
handleInternalChange([]);
} else {
// Otherwise, select all
handleInternalChange([ALL_SELECTED_VALUE], true);
handleInternalChange([ALL_SELECTED_VALUE]);
}
}, [options, isAllSelected, handleInternalChange]);
@@ -777,26 +738,6 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
// Enhanced keyboard navigation with support for maxTagCount
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLElement>): void => {
// Simple early return if ALL is selected - block all possible keyboard interactions
// that could remove the ALL tag, but still allow dropdown navigation and search
if (
(allOptionShown || isAllSelected) &&
(e.key === 'Backspace' || e.key === 'Delete')
) {
// Only prevent default if the input is empty or cursor is at start position
const activeElement = document.activeElement as HTMLInputElement;
const isInputActive = activeElement?.tagName === 'INPUT';
const isInputEmpty = isInputActive && !activeElement?.value;
const isCursorAtStart =
isInputActive && activeElement?.selectionStart === 0;
if (isInputEmpty || isCursorAtStart) {
e.preventDefault();
e.stopPropagation();
return;
}
}
// Get flattened list of all selectable options
const getFlatOptions = (): OptionData[] => {
if (!visibleOptions) return [];
@@ -811,7 +752,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
if (hasAll) {
flatList.push({
label: 'ALL',
value: ALL_SELECTED_VALUE, // Special value for the ALL option
value: '__all__', // Special value for the ALL option
type: 'defined',
});
}
@@ -843,17 +784,6 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
const flatOptions = getFlatOptions();
// If we just opened the dropdown and have options, set first option as active
if (justOpenedRef.current && flatOptions.length > 0) {
setActiveIndex(0);
justOpenedRef.current = false;
}
// If no option is active but we have options and dropdown is open, activate the first one
if (isOpen && activeIndex === -1 && flatOptions.length > 0) {
setActiveIndex(0);
}
// Get the active input element to check cursor position
const activeElement = document.activeElement as HTMLInputElement;
const isInputActive = activeElement?.tagName === 'INPUT';
@@ -1199,7 +1129,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
// If there's an active option in the dropdown, prioritize selecting it
if (activeIndex >= 0 && activeIndex < flatOptions.length) {
const selectedOption = flatOptions[activeIndex];
if (selectedOption.value === ALL_SELECTED_VALUE) {
if (selectedOption.value === '__all__') {
handleSelectAll();
} else if (selectedOption.value && onChange) {
const newValues = selectedValues.includes(selectedOption.value)
@@ -1229,10 +1159,6 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
e.preventDefault();
setIsOpen(false);
setActiveIndex(-1);
// Call onDropdownVisibleChange when Escape is pressed to close dropdown
if (onDropdownVisibleChange) {
onDropdownVisibleChange(false);
}
break;
case SPACEKEY:
@@ -1242,7 +1168,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
const selectedOption = flatOptions[activeIndex];
// Check if it's the ALL option
if (selectedOption.value === ALL_SELECTED_VALUE) {
if (selectedOption.value === '__all__') {
handleSelectAll();
} else if (selectedOption.value && onChange) {
const newValues = selectedValues.includes(selectedOption.value)
@@ -1288,7 +1214,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
e.stopPropagation();
e.preventDefault();
setIsOpen(true);
justOpenedRef.current = true; // Set flag to initialize active option on next render
setActiveIndex(0);
setActiveChipIndex(-1);
break;
@@ -1334,14 +1260,9 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
}
},
[
allOptionShown,
isAllSelected,
isOpen,
activeIndex,
getVisibleChipIndices,
getLastVisibleChipIndex,
selectedChips,
isSelectionMode,
isOpen,
activeChipIndex,
selectedValues,
visibleOptions,
@@ -1357,8 +1278,10 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
startSelection,
selectionEnd,
extendSelection,
onDropdownVisibleChange,
activeIndex,
handleSelectAll,
getVisibleChipIndices,
getLastVisibleChipIndex,
],
);
@@ -1383,14 +1306,6 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
setIsOpen(false);
}, []);
// Add a scroll handler for the dropdown
const handleDropdownScroll = useCallback(
(e: React.UIEvent<HTMLDivElement>): void => {
setIsScrolledToBottom(handleScrollToBottom(e));
},
[],
);
// Custom dropdown render with sections support
const customDropdownRender = useCallback((): React.ReactElement => {
// Process options based on current search
@@ -1467,7 +1382,6 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
onMouseDown={handleDropdownMouseDown}
onClick={handleDropdownClick}
onKeyDown={handleKeyDown}
onScroll={handleDropdownScroll}
onBlur={handleBlur}
role="listbox"
aria-multiselectable="true"
@@ -1546,18 +1460,15 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
{/* Navigation help footer */}
<div className="navigation-footer" role="note">
{!loading &&
!errorMessage &&
!noDataMessage &&
!(showIncompleteDataMessage && isScrolledToBottom) && (
<section className="navigate">
<ArrowDown size={8} className="icons" />
<ArrowUp size={8} className="icons" />
<ArrowLeft size={8} className="icons" />
<ArrowRight size={8} className="icons" />
<span className="keyboard-text">to navigate</span>
</section>
)}
{!loading && !errorMessage && !noDataMessage && (
<section className="navigate">
<ArrowDown size={8} className="icons" />
<ArrowUp size={8} className="icons" />
<ArrowLeft size={8} className="icons" />
<ArrowRight size={8} className="icons" />
<span className="keyboard-text">to navigate</span>
</section>
)}
{loading && (
<div className="navigation-loading">
<div className="navigation-icons">
@@ -1583,19 +1494,9 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
</div>
)}
{showIncompleteDataMessage &&
isScrolledToBottom &&
!loading &&
!errorMessage && (
<div className="navigation-text-incomplete">
Use search for more options
</div>
)}
{noDataMessage &&
!loading &&
!(showIncompleteDataMessage && isScrolledToBottom) &&
!errorMessage && <div className="navigation-text">{noDataMessage}</div>}
{noDataMessage && !loading && (
<div className="navigation-text">{noDataMessage}</div>
)}
</div>
</div>
);
@@ -1612,7 +1513,6 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
handleDropdownMouseDown,
handleDropdownClick,
handleKeyDown,
handleDropdownScroll,
handleBlur,
activeIndex,
loading,
@@ -1622,31 +1522,8 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
renderOptionWithIndex,
handleSelectAll,
onRetry,
showIncompleteDataMessage,
isScrolledToBottom,
]);
// Custom handler for dropdown visibility changes
const handleDropdownVisibleChange = useCallback(
(visible: boolean): void => {
setIsOpen(visible);
if (visible) {
justOpenedRef.current = true;
setActiveIndex(0);
setActiveChipIndex(-1);
} else {
setSearchText('');
setActiveIndex(-1);
// Don't clear activeChipIndex when dropdown closes to maintain tag focus
}
// Pass through to the parent component's handler if provided
if (onDropdownVisibleChange) {
onDropdownVisibleChange(visible);
}
},
[onDropdownVisibleChange],
);
// ===== Side Effects =====
// Clear search when dropdown closes
@@ -1711,9 +1588,52 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
const { label, value, closable, onClose } = props;
// If the display value is the special ALL value, render the ALL tag
if (allOptionShown) {
// Don't render a visible tag - will be shown as placeholder
return <div style={{ display: 'none' }} />;
if (value === ALL_SELECTED_VALUE && isAllSelected) {
const handleAllTagClose = (
e: React.MouseEvent | React.KeyboardEvent,
): void => {
e.stopPropagation();
e.preventDefault();
handleInternalChange([]); // Clear selection when ALL tag is closed
};
const handleAllTagKeyDown = (e: React.KeyboardEvent): void => {
if (e.key === 'Enter' || e.key === SPACEKEY) {
handleAllTagClose(e);
}
// Prevent Backspace/Delete propagation if needed, handle in main keydown handler
};
return (
<div
className={cx('ant-select-selection-item', {
'ant-select-selection-item-active': activeChipIndex === 0, // Treat ALL tag as index 0 when active
'ant-select-selection-item-selected': selectedChips.includes(0),
})}
style={
activeChipIndex === 0 || selectedChips.includes(0)
? {
borderColor: Color.BG_ROBIN_500,
backgroundColor: Color.BG_SLATE_400,
}
: undefined
}
>
<span className="ant-select-selection-item-content">ALL</span>
{closable && (
<span
className="ant-select-selection-item-remove"
onClick={handleAllTagClose}
onKeyDown={handleAllTagKeyDown}
role="button"
tabIndex={0}
aria-label="Remove ALL tag (deselect all)"
>
×
</span>
)}
</div>
);
}
// If not isAllSelected, render individual tags using previous logic
@@ -1793,70 +1713,52 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
// Fallback for safety, should not be reached
return <div />;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[isAllSelected, activeChipIndex, selectedChips, selectedValues, maxTagCount],
[
isAllSelected,
handleInternalChange,
activeChipIndex,
selectedChips,
selectedValues,
maxTagCount,
],
);
// Simple onClear handler to prevent clearing ALL
const onClearHandler = useCallback((): void => {
// Skip clearing if ALL is selected
if (allOptionShown || isAllSelected) {
return;
}
// Normal clear behavior
handleInternalChange([], true);
if (onClear) onClear();
}, [onClear, handleInternalChange, allOptionShown, isAllSelected]);
// ===== Component Rendering =====
return (
<div
className={cx('custom-multiselect-wrapper', {
'all-selected': allOptionShown || isAllSelected,
<Select
ref={selectRef}
className={cx('custom-multiselect', className, {
'has-selection': selectedChips.length > 0 && !isAllSelected,
'is-all-selected': isAllSelected,
})}
>
{(allOptionShown || isAllSelected) && !searchText && (
<div className="all-text">ALL</div>
)}
<Select
ref={selectRef}
className={cx('custom-multiselect', className, {
'has-selection': selectedChips.length > 0 && !isAllSelected,
'is-all-selected': isAllSelected,
})}
placeholder={placeholder}
mode="multiple"
showSearch
filterOption={false}
onSearch={handleSearch}
value={displayValue}
onChange={(newValue): void => {
console.log('newValue', newValue);
handleInternalChange(newValue, false);
}}
onClear={onClearHandler}
onDropdownVisibleChange={handleDropdownVisibleChange}
open={isOpen}
defaultActiveFirstOption={defaultActiveFirstOption}
popupMatchSelectWidth={dropdownMatchSelectWidth}
allowClear={allowClear}
getPopupContainer={getPopupContainer ?? popupContainer}
suffixIcon={<DownOutlined style={{ cursor: 'default' }} />}
dropdownRender={customDropdownRender}
menuItemSelectedIcon={null}
popupClassName={cx('custom-multiselect-dropdown-container', popupClassName)}
notFoundContent={<div className="empty-message">{noDataMessage}</div>}
onKeyDown={handleKeyDown}
tagRender={tagRender as any}
placement={placement}
listHeight={300}
searchValue={searchText}
maxTagTextLength={maxTagTextLength}
maxTagCount={isAllSelected ? undefined : maxTagCount}
{...rest}
/>
</div>
placeholder={placeholder}
mode="multiple"
showSearch
filterOption={false}
onSearch={handleSearch}
value={displayValue}
onChange={handleInternalChange}
onClear={(): void => handleInternalChange([])}
onDropdownVisibleChange={setIsOpen}
open={isOpen}
defaultActiveFirstOption={defaultActiveFirstOption}
popupMatchSelectWidth={dropdownMatchSelectWidth}
allowClear={allowClear}
getPopupContainer={getPopupContainer ?? popupContainer}
suffixIcon={<DownOutlined style={{ cursor: 'default' }} />}
dropdownRender={customDropdownRender}
menuItemSelectedIcon={null}
popupClassName={cx('custom-multiselect-dropdown-container', popupClassName)}
notFoundContent={<div className="empty-message">{noDataMessage}</div>}
onKeyDown={handleKeyDown}
tagRender={tagRender as any}
placement={placement}
listHeight={300}
searchValue={searchText}
maxTagTextLength={maxTagTextLength}
maxTagCount={isAllSelected ? 1 : maxTagCount}
{...rest}
/>
);
};

View File

@@ -29,7 +29,6 @@ import { popupContainer } from 'utils/selectPopupContainer';
import { CustomSelectProps, OptionData } from './types';
import {
filterOptionsBySearch,
handleScrollToBottom,
prioritizeOrAddOptionForSingleSelect,
SPACEKEY,
} from './utils';
@@ -58,29 +57,17 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
errorMessage,
allowClear = false,
onRetry,
showIncompleteDataMessage = false,
...rest
}) => {
// ===== State & Refs =====
const [isOpen, setIsOpen] = useState(false);
const [searchText, setSearchText] = useState('');
const [activeOptionIndex, setActiveOptionIndex] = useState<number>(-1);
const [isScrolledToBottom, setIsScrolledToBottom] = useState(false);
// Refs for element access and scroll behavior
const selectRef = useRef<BaseSelectRef>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const optionRefs = useRef<Record<number, HTMLDivElement | null>>({});
// Flag to track if dropdown just opened
const justOpenedRef = useRef<boolean>(false);
// Add a scroll handler for the dropdown
const handleDropdownScroll = useCallback(
(e: React.UIEvent<HTMLDivElement>): void => {
setIsScrolledToBottom(handleScrollToBottom(e));
},
[],
);
// ===== Option Filtering & Processing Utilities =====
@@ -143,33 +130,23 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
(text: string, searchQuery: string): React.ReactNode => {
if (!searchQuery || !highlightSearch) return text;
try {
const parts = text.split(
new RegExp(
`(${searchQuery.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')})`,
'gi',
),
);
return (
<>
{parts.map((part, i) => {
// Create a deterministic but unique key
const uniqueKey = `${text.substring(0, 3)}-${part.substring(0, 3)}-${i}`;
const parts = text.split(new RegExp(`(${searchQuery})`, 'gi'));
return (
<>
{parts.map((part, i) => {
// Create a deterministic but unique key
const uniqueKey = `${text.substring(0, 3)}-${part.substring(0, 3)}-${i}`;
return part.toLowerCase() === searchQuery.toLowerCase() ? (
<span key={uniqueKey} className="highlight-text">
{part}
</span>
) : (
part
);
})}
</>
);
} catch (error) {
console.error('Error in text highlighting:', error);
return text;
}
return part.toLowerCase() === searchQuery.toLowerCase() ? (
<span key={uniqueKey} className="highlight-text">
{part}
</span>
) : (
part
);
})}
</>
);
},
[highlightSearch],
);
@@ -269,14 +246,9 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
const trimmedValue = value.trim();
setSearchText(trimmedValue);
// Reset active option index when search changes
if (isOpen) {
setActiveOptionIndex(0);
}
if (onSearch) onSearch(trimmedValue);
},
[onSearch, isOpen],
[onSearch],
);
/**
@@ -300,23 +272,14 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
const flatList: OptionData[] = [];
// Process options
let processedOptions = isEmpty(value)
? filteredOptions
: prioritizeOrAddOptionForSingleSelect(filteredOptions, value);
if (!isEmpty(searchText)) {
processedOptions = filterOptionsBySearch(processedOptions, searchText);
}
const { sectionOptions, nonSectionOptions } = splitOptions(
processedOptions,
isEmpty(value)
? filteredOptions
: prioritizeOrAddOptionForSingleSelect(filteredOptions, value),
);
// Add custom option if needed
if (
!isEmpty(searchText) &&
!isLabelPresent(processedOptions, searchText)
) {
if (!isEmpty(searchText) && !isLabelPresent(filteredOptions, searchText)) {
flatList.push({
label: searchText,
value: searchText,
@@ -337,52 +300,33 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
const options = getFlatOptions();
// If we just opened the dropdown and have options, set first option as active
if (justOpenedRef.current && options.length > 0) {
setActiveOptionIndex(0);
justOpenedRef.current = false;
}
// If no option is active but we have options, activate the first one
if (activeOptionIndex === -1 && options.length > 0) {
setActiveOptionIndex(0);
}
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
if (options.length > 0) {
setActiveOptionIndex((prev) =>
prev < options.length - 1 ? prev + 1 : 0,
);
}
setActiveOptionIndex((prev) =>
prev < options.length - 1 ? prev + 1 : 0,
);
break;
case 'ArrowUp':
e.preventDefault();
if (options.length > 0) {
setActiveOptionIndex((prev) =>
prev > 0 ? prev - 1 : options.length - 1,
);
}
setActiveOptionIndex((prev) =>
prev > 0 ? prev - 1 : options.length - 1,
);
break;
case 'Tab':
// Tab navigation with Shift key support
if (e.shiftKey) {
e.preventDefault();
if (options.length > 0) {
setActiveOptionIndex((prev) =>
prev > 0 ? prev - 1 : options.length - 1,
);
}
setActiveOptionIndex((prev) =>
prev > 0 ? prev - 1 : options.length - 1,
);
} else {
e.preventDefault();
if (options.length > 0) {
setActiveOptionIndex((prev) =>
prev < options.length - 1 ? prev + 1 : 0,
);
}
setActiveOptionIndex((prev) =>
prev < options.length - 1 ? prev + 1 : 0,
);
}
break;
@@ -395,7 +339,6 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
onChange(selectedOption.value, selectedOption);
setIsOpen(false);
setActiveOptionIndex(-1);
setSearchText('');
}
} else if (!isEmpty(searchText)) {
// Add custom value when no option is focused
@@ -408,7 +351,6 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
onChange(customOption.value, customOption);
setIsOpen(false);
setActiveOptionIndex(-1);
setSearchText('');
}
}
break;
@@ -417,7 +359,6 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
e.preventDefault();
setIsOpen(false);
setActiveOptionIndex(-1);
setSearchText('');
break;
case ' ': // Space key
@@ -428,7 +369,6 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
onChange(selectedOption.value, selectedOption);
setIsOpen(false);
setActiveOptionIndex(-1);
setSearchText('');
}
}
break;
@@ -439,7 +379,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
// Open dropdown when Down or Tab is pressed while closed
e.preventDefault();
setIsOpen(true);
justOpenedRef.current = true; // Set flag to initialize active option on next render
setActiveOptionIndex(0);
}
},
[
@@ -504,7 +444,6 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
className="custom-select-dropdown"
onClick={handleDropdownClick}
onKeyDown={handleKeyDown}
onScroll={handleDropdownScroll}
role="listbox"
tabIndex={-1}
aria-activedescendant={
@@ -515,6 +454,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
<div className="no-section-options">
{nonSectionOptions.length > 0 && mapOptions(nonSectionOptions)}
</div>
{/* Section options */}
{sectionOptions.length > 0 &&
sectionOptions.map((section) =>
@@ -532,16 +472,13 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
{/* Navigation help footer */}
<div className="navigation-footer" role="note">
{!loading &&
!errorMessage &&
!noDataMessage &&
!(showIncompleteDataMessage && isScrolledToBottom) && (
<section className="navigate">
<ArrowDown size={8} className="icons" />
<ArrowUp size={8} className="icons" />
<span className="keyboard-text">to navigate</span>
</section>
)}
{!loading && !errorMessage && !noDataMessage && (
<section className="navigate">
<ArrowDown size={8} className="icons" />
<ArrowUp size={8} className="icons" />
<span className="keyboard-text">to navigate</span>
</section>
)}
{loading && (
<div className="navigation-loading">
<div className="navigation-icons">
@@ -567,19 +504,9 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
</div>
)}
{showIncompleteDataMessage &&
isScrolledToBottom &&
!loading &&
!errorMessage && (
<div className="navigation-text-incomplete">
Use search for more options
</div>
)}
{noDataMessage &&
!loading &&
!(showIncompleteDataMessage && isScrolledToBottom) &&
!errorMessage && <div className="navigation-text">{noDataMessage}</div>}
{noDataMessage && !loading && (
<div className="navigation-text">{noDataMessage}</div>
)}
</div>
</div>
);
@@ -593,7 +520,6 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
isLabelPresent,
handleDropdownClick,
handleKeyDown,
handleDropdownScroll,
activeOptionIndex,
loading,
errorMessage,
@@ -601,22 +527,8 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
dropdownRender,
renderOptionWithIndex,
onRetry,
showIncompleteDataMessage,
isScrolledToBottom,
]);
// Handle dropdown visibility changes
const handleDropdownVisibleChange = useCallback((visible: boolean): void => {
setIsOpen(visible);
if (visible) {
justOpenedRef.current = true;
setActiveOptionIndex(0);
} else {
setSearchText('');
setActiveOptionIndex(-1);
}
}, []);
// ===== Side Effects =====
// Clear search text when dropdown closes
@@ -670,7 +582,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
onSearch={handleSearch}
value={value}
onChange={onChange}
onDropdownVisibleChange={handleDropdownVisibleChange}
onDropdownVisibleChange={setIsOpen}
open={isOpen}
options={optionsWithHighlight}
defaultActiveFirstOption={defaultActiveFirstOption}

View File

@@ -35,43 +35,6 @@ $custom-border-color: #2c3044;
width: 100%;
position: relative;
&.is-all-selected {
.ant-select-selection-search-input {
caret-color: transparent;
}
.ant-select-selection-placeholder {
opacity: 1 !important;
color: var(--bg-vanilla-400) !important;
font-weight: 500;
visibility: visible !important;
pointer-events: none;
z-index: 2;
.lightMode & {
color: rgba(0, 0, 0, 0.85) !important;
}
}
&.ant-select-focused .ant-select-selection-placeholder {
opacity: 0.45 !important;
}
}
.all-selected-text {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--bg-vanilla-400);
z-index: 1;
pointer-events: none;
.lightMode & {
color: rgba(0, 0, 0, 0.85);
}
}
.ant-select-selector {
max-height: 200px;
overflow: auto;
@@ -195,7 +158,7 @@ $custom-border-color: #2c3044;
// Custom dropdown styles for single select
.custom-select-dropdown {
padding: 8px 0 0 0;
max-height: 300px;
max-height: 500px;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
@@ -313,10 +276,6 @@ $custom-border-color: #2c3044;
font-size: 12px;
}
.navigation-text-incomplete {
color: var(--bg-amber-600) !important;
}
.navigation-error {
.navigation-text,
.navigation-icons {
@@ -363,7 +322,7 @@ $custom-border-color: #2c3044;
// Custom dropdown styles for multi-select
.custom-multiselect-dropdown {
padding: 8px 0 0 0;
max-height: 350px;
max-height: 500px;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
@@ -697,10 +656,6 @@ $custom-border-color: #2c3044;
border: 1px solid #e8e8e8;
color: rgba(0, 0, 0, 0.85);
font-size: 12px !important;
height: 20px;
line-height: 18px;
.ant-select-selection-item-content {
color: rgba(0, 0, 0, 0.85);
}
@@ -881,38 +836,3 @@ $custom-border-color: #2c3044;
}
}
}
.custom-multiselect-wrapper {
position: relative;
width: 100%;
&.all-selected {
.all-text {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--bg-vanilla-400);
font-weight: 500;
z-index: 2;
pointer-events: none;
transition: opacity 0.2s ease, visibility 0.2s ease;
.lightMode & {
color: rgba(0, 0, 0, 0.85);
}
}
&:focus-within .all-text {
opacity: 0.45;
}
.ant-select-selection-search-input {
caret-color: auto;
}
.ant-select-selection-placeholder {
display: none;
}
}
}

View File

@@ -24,10 +24,9 @@ export interface CustomSelectProps extends Omit<SelectProps, 'options'> {
highlightSearch?: boolean;
placement?: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight';
popupMatchSelectWidth?: boolean;
errorMessage?: string | null;
errorMessage?: string;
allowClear?: SelectProps['allowClear'];
onRetry?: () => void;
showIncompleteDataMessage?: boolean;
}
export interface CustomTagProps {
@@ -52,12 +51,10 @@ export interface CustomMultiSelectProps
getPopupContainer?: (triggerNode: HTMLElement) => HTMLElement;
dropdownRender?: (menu: React.ReactElement) => React.ReactElement;
highlightSearch?: boolean;
errorMessage?: string | null;
errorMessage?: string;
popupClassName?: string;
placement?: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight';
maxTagCount?: number;
allowClear?: SelectProps['allowClear'];
onRetry?: () => void;
maxTagTextLength?: number;
showIncompleteDataMessage?: boolean;
}

View File

@@ -3,8 +3,6 @@ import { OptionData } from './types';
export const SPACEKEY = ' ';
export const ALL_SELECTED_VALUE = '__ALL__'; // Constant for the special value
export const prioritizeOrAddOptionForSingleSelect = (
options: OptionData[],
value: string,
@@ -135,15 +133,3 @@ export const filterOptionsBySearch = (
})
.filter(Boolean) as OptionData[];
};
/**
* Utility function to handle dropdown scroll and detect when scrolled to bottom
* Returns true when scrolled to within 20px of the bottom
*/
export const handleScrollToBottom = (
e: React.UIEvent<HTMLDivElement>,
): boolean => {
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
// Consider "scrolled to bottom" when within 20px of the bottom or at the bottom
return scrollHeight - scrollTop - clientHeight < 20;
};

View File

@@ -1,7 +1,3 @@
export const ENVIRONMENT = {
baseURL:
process?.env?.FRONTEND_API_ENDPOINT ||
process?.env?.GITPOD_WORKSPACE_URL?.replace('://', '://8080-') ||
'',
wsURL: process?.env?.WEBSOCKET_API_ENDPOINT || '',
baseURL: 'http://localhost:8080',
};

View File

@@ -10,4 +10,5 @@ export enum FeatureKeys {
ONBOARDING_V3 = 'ONBOARDING_V3',
THIRD_PARTY_API = 'THIRD_PARTY_API',
TRACE_FUNNELS = 'TRACE_FUNNELS',
DOT_METRICS_ENABLED = 'DOT_METRICS_ENABLED',
}

View File

@@ -46,5 +46,4 @@ export enum QueryParams {
msgSystem = 'msgSystem',
destination = 'destination',
kindString = 'kindString',
variables = 'variables',
}

View File

@@ -7,12 +7,10 @@ import {
import GridCard from 'container/GridCardLayout/GridCard';
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { isEqual } from 'lodash-es';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { useApiMonitoringParams } from '../../../queryParams';
import { SPAN_ATTRIBUTES, VIEWS } from './constants';
function AllEndPoints({
@@ -37,7 +35,6 @@ function AllEndPoints({
initialFilters: IBuilderQuery['filters'];
setInitialFiltersEndPointStats: (filters: IBuilderQuery['filters']) => void;
}): JSX.Element {
const [params, setParams] = useApiMonitoringParams();
const [groupBySearchValue, setGroupBySearchValue] = useState<string>('');
const [allAvailableGroupByOptions, setAllAvailableGroupByOptions] = useState<{
[key: string]: any;
@@ -58,38 +55,87 @@ function AllEndPoints({
{ value: string; label: string }[]
>([]);
// --- FILTERS STATE SYNC ---
const [filters, setFilters] = useState<IBuilderQuery['filters']>(() => {
if (params.allEndpointsLocalFilters) {
return params.allEndpointsLocalFilters;
const handleGroupByChange = useCallback(
(value: IBuilderQuery['groupBy']) => {
const newGroupBy = [];
for (let index = 0; index < value.length; index++) {
const element = (value[index] as unknown) as string;
// Check if the key exists in our cached options first
if (allAvailableGroupByOptions[element]) {
newGroupBy.push(allAvailableGroupByOptions[element]);
} else {
// If not found in cache, check the current filtered results
const key = groupByFiltersData?.payload?.attributeKeys?.find(
(key) => key.key === element,
);
if (key) {
newGroupBy.push(key);
}
}
}
setGroupBy(newGroupBy);
setGroupBySearchValue('');
},
[groupByFiltersData, setGroupBy, allAvailableGroupByOptions],
);
useEffect(() => {
if (groupByFiltersData?.payload) {
// Update dropdown options
setGroupByOptions(
groupByFiltersData?.payload?.attributeKeys?.map((filter) => ({
value: filter.key,
label: filter.key,
})) || [],
);
// Cache all available options to preserve selected values using functional update
// to avoid dependency on allAvailableGroupByOptions
setAllAvailableGroupByOptions((prevOptions) => {
const newOptions = { ...prevOptions };
groupByFiltersData?.payload?.attributeKeys?.forEach((filter) => {
newOptions[filter.key] = filter;
});
return newOptions;
});
}
}, [groupByFiltersData]); // Only depends on groupByFiltersData now
// Cache existing selected options on component mount
useEffect(() => {
if (groupBy && groupBy.length > 0) {
setAllAvailableGroupByOptions((prevOptions) => {
const newOptions = { ...prevOptions };
groupBy.forEach((option) => {
newOptions[option.key] = option;
});
return newOptions;
});
}
}, [groupBy]); // Removed allAvailableGroupByOptions from dependencies
const currentQuery = initialQueriesMap[DataSource.TRACES];
// Local state for filters, combining endpoint filter and search filters
const [filters, setFilters] = useState<IBuilderQuery['filters']>(() => {
// Initialize filters based on the initial endPointName prop
const initialItems = [...initialFilters.items];
return { op: 'AND', items: initialItems };
});
// Sync params to local filters state on param change
useEffect(() => {
if (
params.allEndpointsLocalFilters &&
!isEqual(params.allEndpointsLocalFilters, filters)
) {
setFilters(params.allEndpointsLocalFilters);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [params.allEndpointsLocalFilters]);
// Handler for changes from the QueryBuilderSearchV2 component
const handleFilterChange = useCallback(
(newFilters: IBuilderQuery['filters']): void => {
// 1. Update local filters state immediately
setFilters(newFilters);
setParams({ allEndpointsLocalFilters: newFilters });
},
[setParams],
[], // Dependencies for the callback
);
const currentQuery = initialQueriesMap[DataSource.TRACES];
const updatedCurrentQuery = useMemo(
() => ({
...currentQuery,
@@ -114,91 +160,6 @@ function AllEndPoints({
[groupBy, domainName, filters],
);
// --- GROUP BY STATE SYNC (existing) ---
const handleGroupByChange = useCallback(
(value: IBuilderQuery['groupBy']) => {
const newGroupBy = [];
for (let index = 0; index < value.length; index++) {
const element = (value[index] as unknown) as string;
// Check if the key exists in our cached options first
if (allAvailableGroupByOptions[element]) {
newGroupBy.push(allAvailableGroupByOptions[element]);
} else {
// If not found in cache, check the current filtered results
const key = groupByFiltersData?.payload?.attributeKeys?.find(
(key) => key.key === element,
);
if (key) {
newGroupBy.push(key);
}
}
}
setGroupBy(newGroupBy);
setGroupBySearchValue('');
// Update params
setParams({ groupBy: newGroupBy.map((g) => g.key) });
},
[groupByFiltersData, setGroupBy, allAvailableGroupByOptions, setParams],
);
// Sync params to local groupBy state on param change
useEffect(() => {
if (
params.groupBy &&
Array.isArray(params.groupBy) &&
!isEqual(
params.groupBy,
groupBy.map((g) => g.key),
)
) {
// Only update if different
const newGroupBy = params.groupBy
.map((key) => allAvailableGroupByOptions[key])
.filter(Boolean);
if (newGroupBy.length === params.groupBy.length) {
setGroupBy(newGroupBy);
}
}
}, [params.groupBy, allAvailableGroupByOptions, groupBy, setGroupBy]);
useEffect(() => {
if (groupByFiltersData?.payload) {
// Update dropdown options
setGroupByOptions(
groupByFiltersData?.payload?.attributeKeys?.map((filter) => ({
value: filter.key,
label: filter.key,
})) || [],
);
// Cache all available options to preserve selected values using functional update
setAllAvailableGroupByOptions((prevOptions) => {
const newOptions = { ...prevOptions };
groupByFiltersData?.payload?.attributeKeys?.forEach((filter) => {
newOptions[filter.key] = filter;
});
return newOptions;
});
}
}, [groupByFiltersData]); // Only depends on groupByFiltersData now
// Cache existing selected options on component mount
useEffect(() => {
if (groupBy && groupBy.length > 0) {
setAllAvailableGroupByOptions((prevOptions) => {
const newOptions = { ...prevOptions };
groupBy.forEach((option) => {
newOptions[option.key] = option;
});
return newOptions;
});
}
}, [groupBy]);
const onRowClick = useCallback(
(props: any): void => {
setSelectedEndPointName(props[SPAN_ATTRIBUTES.URL_PATH] as string);
@@ -211,14 +172,6 @@ function AllEndPoints({
items: initialItems,
op: 'AND',
});
setParams({
selectedEndPointName: props[SPAN_ATTRIBUTES.URL_PATH] as string,
selectedView: VIEWS.ENDPOINT_STATS,
endPointDetailsLocalFilters: {
items: initialItems,
op: 'AND',
},
});
},
[
filters,
@@ -226,7 +179,6 @@ function AllEndPoints({
setSelectedEndPointName,
setSelectedView,
groupBy,
setParams,
],
);

View File

@@ -11,13 +11,12 @@ import {
import { useIsDarkMode } from 'hooks/useDarkMode';
import GetMinMax from 'lib/getMinMax';
import { ArrowDown, ArrowUp, X } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { GlobalReducer } from 'types/reducer/globalTime';
import { useApiMonitoringParams } from '../../../queryParams';
import AllEndPoints from './AllEndPoints';
import DomainMetrics from './components/DomainMetrics';
import { VIEW_TYPES, VIEWS } from './constants';
@@ -41,13 +40,8 @@ function DomainDetails({
domainListLength: number;
domainListFilters: IBuilderQuery['filters'];
}): JSX.Element {
const [params, setParams] = useApiMonitoringParams();
const [selectedView, setSelectedView] = useState<VIEWS>(
(params.selectedView as VIEWS) || VIEWS.ALL_ENDPOINTS,
);
const [selectedEndPointName, setSelectedEndPointName] = useState<string>(
params.selectedEndPointName || '',
);
const [selectedView, setSelectedView] = useState<VIEWS>(VIEWS.ALL_ENDPOINTS);
const [selectedEndPointName, setSelectedEndPointName] = useState<string>('');
const [endPointsGroupBy, setEndPointsGroupBy] = useState<
IBuilderQuery['groupBy']
>([]);
@@ -58,27 +52,8 @@ function DomainDetails({
const handleTabChange = (e: RadioChangeEvent): void => {
setSelectedView(e.target.value);
setParams({ selectedView: e.target.value });
};
const handleEndPointChange = (name: string): void => {
setSelectedEndPointName(name);
setParams({ selectedEndPointName: name });
};
useEffect(() => {
if (params.selectedView && params.selectedView !== selectedView) {
setSelectedView(params.selectedView as VIEWS);
}
if (
params.selectedEndPointName !== undefined &&
params.selectedEndPointName !== selectedEndPointName
) {
setSelectedEndPointName(params.selectedEndPointName);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [params.selectedView, params.selectedEndPointName]);
const { maxTime, minTime, selectedTime } = useSelector<
AppState,
GlobalReducer
@@ -92,62 +67,34 @@ function DomainDetails({
]);
const [selectedInterval, setSelectedInterval] = useState<Time>(
(params.selectedInterval as Time) || (selectedTime as Time),
selectedTime as Time,
);
// Sync params to local selectedInterval state on param change
useEffect(() => {
if (params.selectedInterval && params.selectedInterval !== selectedInterval) {
setSelectedInterval(params.selectedInterval as Time);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [params.selectedInterval]);
const [modalTimeRange, setModalTimeRange] = useState(() => {
if (params.modalTimeRange) {
return params.modalTimeRange;
}
return {
startTime: startMs,
endTime: endMs,
};
});
// Sync params to local modalTimeRange state on param change
useEffect(() => {
if (
params.modalTimeRange &&
JSON.stringify(params.modalTimeRange) !== JSON.stringify(modalTimeRange)
) {
setModalTimeRange(params.modalTimeRange);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [params.modalTimeRange]);
const [modalTimeRange, setModalTimeRange] = useState(() => ({
startTime: startMs,
endTime: endMs,
}));
const handleTimeChange = useCallback(
(interval: Time | CustomTimeType, dateTimeRange?: [number, number]): void => {
setSelectedInterval(interval as Time);
setParams({ selectedInterval: interval as string });
if (interval === 'custom' && dateTimeRange) {
const newRange = {
setModalTimeRange({
startTime: Math.floor(dateTimeRange[0] / 1000),
endTime: Math.floor(dateTimeRange[1] / 1000),
};
setModalTimeRange(newRange);
setParams({ modalTimeRange: newRange });
});
} else {
const { maxTime, minTime } = GetMinMax(interval);
const newRange = {
setModalTimeRange({
startTime: Math.floor(minTime / TimeRangeOffset),
endTime: Math.floor(maxTime / TimeRangeOffset),
};
setModalTimeRange(newRange);
setParams({ modalTimeRange: newRange });
});
}
},
[setParams],
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
return (
@@ -227,6 +174,7 @@ function DomainDetails({
>
<Radio.Button
className={
// eslint-disable-next-line sonarjs/no-duplicate-string
selectedView === VIEW_TYPES.ALL_ENDPOINTS ? 'selected_view tab' : 'tab'
}
value={VIEW_TYPES.ALL_ENDPOINTS}
@@ -256,7 +204,7 @@ function DomainDetails({
{selectedView === VIEW_TYPES.ALL_ENDPOINTS && (
<AllEndPoints
domainName={domainData.domainName}
setSelectedEndPointName={handleEndPointChange}
setSelectedEndPointName={setSelectedEndPointName}
setSelectedView={setSelectedView}
groupBy={endPointsGroupBy}
setGroupBy={setEndPointsGroupBy}
@@ -270,7 +218,7 @@ function DomainDetails({
<EndPointDetails
domainName={domainData.domainName}
endPointName={selectedEndPointName}
setSelectedEndPointName={handleEndPointChange}
setSelectedEndPointName={setSelectedEndPointName}
initialFilters={initialFiltersEndPointStats}
timeRange={modalTimeRange}
handleTimeChange={handleTimeChange}

View File

@@ -1,6 +1,5 @@
import { ENTITY_VERSION_V4 } from 'constants/app';
import { initialQueriesMap } from 'constants/queryBuilder';
import { useApiMonitoringParams } from 'container/ApiMonitoring/queryParams';
import {
END_POINT_DETAILS_QUERY_KEYS_ARRAY,
extractPortAndEndpoint,
@@ -60,16 +59,13 @@ function EndPointDetails({
) => void;
}): JSX.Element {
const { startTime: minTime, endTime: maxTime } = timeRange;
const [params, setParams] = useApiMonitoringParams();
const currentQuery = initialQueriesMap[DataSource.TRACES];
// Local state for filters, combining endpoint filter and search filters
const [filters, setFilters] = useState<IBuilderQuery['filters']>(() => {
// Initialize filters based on the initial endPointName prop
const initialItems = params.endPointDetailsLocalFilters
? [...params.endPointDetailsLocalFilters.items]
: [...initialFilters.items];
const initialItems = [...initialFilters.items];
if (endPointName) {
initialItems.push({
id: '92b8a1c1',
@@ -111,26 +107,11 @@ function EndPointDetails({
});
}, [endPointName]);
// Separate effect to update params when filters change
useEffect(() => {
const filtersWithoutHttpUrl = {
op: 'AND',
items: filters.items.filter((item) => item.key?.key !== httpUrlKey.key),
};
setParams({ endPointDetailsLocalFilters: filtersWithoutHttpUrl });
}, [filters, setParams]);
// Handler for changes from the QueryBuilderSearchV2 component
const handleFilterChange = useCallback(
(newFilters: IBuilderQuery['filters']): void => {
// 1. Update local filters state immediately
setFilters(newFilters);
// Filter out http.url filter before saving to params
const filteredNewFilters = {
op: 'AND',
items: newFilters.items.filter((item) => item.key?.key !== httpUrlKey.key),
};
setParams({ endPointDetailsLocalFilters: filteredNewFilters });
// 2. Derive the endpoint name from the *new* filters state
const httpUrlFilter = newFilters.items.find(
@@ -145,7 +126,7 @@ function EndPointDetails({
setSelectedEndPointName(derivedEndPointName);
}
},
[endPointName, setSelectedEndPointName, setParams], // Dependencies for the callback
[endPointName, setSelectedEndPointName], // Dependencies for the callback
);
const updatedCurrentQuery = useMemo(

View File

@@ -215,9 +215,7 @@ function TopErrors({
/>
<Typography.Text className="no-filtered-endpoints-message">
{showStatusCodeErrors
? 'Please disable "Status Message Exists" toggle to see all errors'
: 'This query had no results. Edit your query and try again!'}
This query had no results. Edit your query and try again!
</Typography.Text>
</div>
</div>

View File

@@ -21,7 +21,6 @@ function MetricOverTimeGraph({
onDragSelect={onDragSelect}
customOnDragSelect={(): void => {}}
customTimeRange={timeRange}
customTimeRangeWindowForCoRelation="5m"
/>
</div>
</Card>

View File

@@ -19,7 +19,6 @@ import { useResizeObserver } from 'hooks/useDimensions';
import { useNotifications } from 'hooks/useNotifications';
import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
import { getStartAndEndTimesInMilliseconds } from 'pages/MessagingQueues/MessagingQueuesUtils';
import { useCallback, useMemo, useRef, useState } from 'react';
import { UseQueryResult } from 'react-query';
import { SuccessResponse } from 'types/api';
@@ -151,12 +150,7 @@ function StatusCodeBarCharts({
metric?: { [key: string]: string },
queryData?: { queryName: string; inFocusOrNot: boolean },
): void => {
const TWO_AND_HALF_MINUTES_IN_MILLISECONDS = 2.5 * 60 * 1000; // 150,000 milliseconds
const customFilters = getCustomFiltersForBarChart(metric);
const { start, end } = getStartAndEndTimesInMilliseconds(
xValue,
TWO_AND_HALF_MINUTES_IN_MILLISECONDS,
);
handleGraphClick({
xValue,
yValue,
@@ -170,10 +164,6 @@ function StatusCodeBarCharts({
notifications,
graphClick,
customFilters,
customTracesTimeRange: {
start,
end,
},
});
},
[

View File

@@ -16,7 +16,7 @@ import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQue
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
@@ -25,7 +25,6 @@ import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import { DEFAULT_PARAMS, useApiMonitoringParams } from '../../queryParams';
import {
columnsConfig,
formatDataForTable,
@@ -33,9 +32,7 @@ import {
} from '../../utils';
import DomainDetails from './DomainDetails/DomainDetails';
function DomainList(): JSX.Element {
const [params, setParams] = useApiMonitoringParams();
const { showIP, selectedDomain } = params;
function DomainList({ showIP }: { showIP: boolean }): JSX.Element {
const [selectedDomainIndex, setSelectedDomainIndex] = useState<number>(-1);
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
@@ -132,16 +129,6 @@ function DomainList(): JSX.Element {
[data],
);
// Open drawer if selectedDomain is set in URL
useEffect(() => {
if (selectedDomain && formattedDataForTable?.length > 0) {
const idx = formattedDataForTable.findIndex(
(item) => item.domainName === selectedDomain,
);
setSelectedDomainIndex(idx);
}
}, [selectedDomain, formattedDataForTable]);
return (
<section className={cx('api-module-right-section')}>
<Toolbar
@@ -192,7 +179,6 @@ function DomainList(): JSX.Element {
(item) => item.key === record.key,
);
setSelectedDomainIndex(dataIndex);
setParams({ selectedDomain: record.domainName });
logEvent('API Monitoring: Domain name row clicked', {});
}
},
@@ -210,7 +196,6 @@ function DomainList(): JSX.Element {
domainListLength={formattedDataForTable.length}
handleClose={(): void => {
setSelectedDomainIndex(-1);
setParams(DEFAULT_PARAMS);
}}
domainListFilters={query?.filters}
/>

View File

@@ -8,15 +8,13 @@ import cx from 'classnames';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource } from 'components/QuickFilters/types';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { useApiMonitoringParams } from '../queryParams';
import { ApiMonitoringQuickFiltersConfig } from '../utils';
import DomainList from './Domains/DomainList';
function Explorer(): JSX.Element {
const [params, setParams] = useApiMonitoringParams();
const showIP = params.showIP ?? true;
const [showIP, setShowIP] = useState<boolean>(true);
useEffect(() => {
logEvent('API Monitoring: Landing page visited', {});
@@ -36,12 +34,14 @@ function Explorer(): JSX.Element {
<Switch
size="small"
style={{ marginLeft: 'auto' }}
checked={showIP ?? true}
checked={showIP}
onClick={(): void => {
logEvent('API Monitoring: Show IP addresses clicked', {
showIP: !(showIP ?? true),
setShowIP((showIP): boolean => {
logEvent('API Monitoring: Show IP addresses clicked', {
showIP: !showIP,
});
return !showIP;
});
setParams({ showIP });
}}
/>
</div>
@@ -52,7 +52,7 @@ function Explorer(): JSX.Element {
handleFilterVisibilityChange={(): void => {}}
/>
</section>
<DomainList />
<DomainList showIP={showIP} />
</div>
</Sentry.ErrorBoundary>
);

View File

@@ -79,24 +79,6 @@ jest.mock('antd', () => {
};
});
// Mock useApiMonitoringParams hook
jest.mock('container/ApiMonitoring/queryParams', () => ({
useApiMonitoringParams: jest.fn().mockReturnValue([
{
showIP: true,
selectedDomain: '',
selectedView: 'all_endpoints',
selectedEndPointName: '',
groupBy: [],
allEndpointsLocalFilters: undefined,
endPointDetailsLocalFilters: undefined,
modalTimeRange: undefined,
selectedInterval: undefined,
},
jest.fn(),
]),
}));
describe('AllEndPoints', () => {
const mockProps = {
domainName: 'test-domain',

View File

@@ -25,24 +25,6 @@ jest.mock('react-query', () => ({
useQueries: jest.fn(),
}));
// Mock useApiMonitoringParams hook
jest.mock('container/ApiMonitoring/queryParams', () => ({
useApiMonitoringParams: jest.fn().mockReturnValue([
{
showIP: true,
selectedDomain: '',
selectedView: 'all_endpoints',
selectedEndPointName: '',
groupBy: [],
allEndpointsLocalFilters: undefined,
endPointDetailsLocalFilters: undefined,
modalTimeRange: undefined,
selectedInterval: undefined,
},
jest.fn(),
]),
}));
jest.mock('container/ApiMonitoring/utils', () => ({
END_POINT_DETAILS_QUERY_KEYS_ARRAY: [
'endPointMetricsData',

View File

@@ -1,276 +0,0 @@
/* eslint-disable sonarjs/no-duplicate-string */
import {
ApiMonitoringParams,
DEFAULT_PARAMS,
getApiMonitoringParams,
setApiMonitoringParams,
} from 'container/ApiMonitoring/queryParams';
// Mock react-router-dom hooks
jest.mock('react-router-dom', () => {
const originalModule = jest.requireActual('react-router-dom');
return {
...originalModule,
useLocation: jest.fn(),
useHistory: jest.fn(),
};
});
describe('API Monitoring Query Params', () => {
describe('getApiMonitoringParams', () => {
it('returns default params when no query param exists', () => {
const search = '';
expect(getApiMonitoringParams(search)).toEqual(DEFAULT_PARAMS);
});
it('parses URL query params correctly', () => {
const mockParams: Partial<ApiMonitoringParams> = {
showIP: false,
selectedDomain: 'test-domain',
selectedView: 'test-view',
selectedEndPointName: '/api/test',
};
// Create a URL search string with encoded params
const urlParams = new URLSearchParams();
urlParams.set(
'apiMonitoringParams',
encodeURIComponent(JSON.stringify(mockParams)),
);
const search = `?${urlParams.toString()}`;
const result = getApiMonitoringParams(search);
// Only check specific properties that we set, not all DEFAULT_PARAMS
expect(result.showIP).toBe(mockParams.showIP);
expect(result.selectedDomain).toBe(mockParams.selectedDomain);
expect(result.selectedView).toBe(mockParams.selectedView);
expect(result.selectedEndPointName).toBe(mockParams.selectedEndPointName);
});
it('returns default params when parsing fails', () => {
const urlParams = new URLSearchParams();
urlParams.set('apiMonitoringParams', 'invalid-json');
const search = `?${urlParams.toString()}`;
expect(getApiMonitoringParams(search)).toEqual(DEFAULT_PARAMS);
});
});
describe('setApiMonitoringParams', () => {
it('updates URL with new params (push mode)', () => {
const history = {
push: jest.fn(),
replace: jest.fn(),
};
const search = '';
const newParams: Partial<ApiMonitoringParams> = {
showIP: false,
selectedDomain: 'updated-domain',
};
setApiMonitoringParams(newParams, search, history as any, false);
expect(history.push).toHaveBeenCalledWith({
search: expect.stringContaining('apiMonitoringParams'),
});
expect(history.replace).not.toHaveBeenCalled();
// Verify that the search string contains the expected encoded params
const searchArg = history.push.mock.calls[0][0].search;
const params = new URLSearchParams(searchArg);
const decoded = JSON.parse(
decodeURIComponent(params.get('apiMonitoringParams') || ''),
);
// Test only the specific fields we set
expect(decoded.showIP).toBe(newParams.showIP);
expect(decoded.selectedDomain).toBe(newParams.selectedDomain);
});
it('updates URL with new params (replace mode)', () => {
const history = {
push: jest.fn(),
replace: jest.fn(),
};
const search = '';
const newParams: Partial<ApiMonitoringParams> = {
showIP: false,
selectedDomain: 'updated-domain',
};
setApiMonitoringParams(newParams, search, history as any, true);
expect(history.replace).toHaveBeenCalledWith({
search: expect.stringContaining('apiMonitoringParams'),
});
expect(history.push).not.toHaveBeenCalled();
});
it('merges new params with existing params', () => {
const history = {
push: jest.fn(),
replace: jest.fn(),
};
// Start with some existing params
const existingParams: Partial<ApiMonitoringParams> = {
showIP: true,
selectedDomain: 'domain-1',
selectedView: 'view-1',
};
const urlParams = new URLSearchParams();
urlParams.set(
'apiMonitoringParams',
encodeURIComponent(JSON.stringify(existingParams)),
);
const search = `?${urlParams.toString()}`;
// Add some new params
const newParams: Partial<ApiMonitoringParams> = {
selectedDomain: 'domain-2',
selectedEndPointName: '/api/test',
};
setApiMonitoringParams(newParams, search, history as any, false);
// Verify merged params
const searchArg = history.push.mock.calls[0][0].search;
const params = new URLSearchParams(searchArg);
const decoded = JSON.parse(
decodeURIComponent(params.get('apiMonitoringParams') || ''),
);
// Test only the specific fields
expect(decoded.showIP).toBe(existingParams.showIP);
expect(decoded.selectedView).toBe(existingParams.selectedView);
expect(decoded.selectedDomain).toBe(newParams.selectedDomain); // This should be overwritten
expect(decoded.selectedEndPointName).toBe(newParams.selectedEndPointName);
});
});
describe('useApiMonitoringParams hook without calling hook directly', () => {
// Instead of using the hook directly, We are testing the individual functions that make up the hook
// as the original hook contains react core hooks
const mockUseLocationAndHistory = (initialSearch = ''): any => {
// Create mock location object
const location = {
search: initialSearch,
pathname: '/some-path',
hash: '',
state: null,
};
// Create mock history object
const history = {
push: jest.fn((args) => {
// Simulate updating the location search
location.search = args.search;
}),
replace: jest.fn((args) => {
location.search = args.search;
}),
length: 1,
location,
};
// Set up mocks for useLocation and useHistory
const useLocationMock = jest.requireMock('react-router-dom').useLocation;
const useHistoryMock = jest.requireMock('react-router-dom').useHistory;
useLocationMock.mockReturnValue(location);
useHistoryMock.mockReturnValue(history);
return { location, history };
};
it('retrieves URL params correctly from location', () => {
const testParams: Partial<ApiMonitoringParams> = {
showIP: false,
selectedDomain: 'test-domain',
selectedView: 'custom-view',
};
const urlParams = new URLSearchParams();
urlParams.set(
'apiMonitoringParams',
encodeURIComponent(JSON.stringify(testParams)),
);
const search = `?${urlParams.toString()}`;
const result = getApiMonitoringParams(search);
// Test only specific fields
expect(result.showIP).toBe(testParams.showIP);
expect(result.selectedDomain).toBe(testParams.selectedDomain);
expect(result.selectedView).toBe(testParams.selectedView);
});
it('updates URL correctly with new params', () => {
const { location, history } = mockUseLocationAndHistory();
const newParams: Partial<ApiMonitoringParams> = {
selectedDomain: 'new-domain',
showIP: false,
};
// Manually execute the core logic of the hook's setParams function
setApiMonitoringParams(newParams, location.search, history as any);
expect(history.push).toHaveBeenCalledWith({
search: expect.stringContaining('apiMonitoringParams'),
});
const searchArg = history.push.mock.calls[0][0].search;
const params = new URLSearchParams(searchArg);
const decoded = JSON.parse(
decodeURIComponent(params.get('apiMonitoringParams') || ''),
);
// Test only specific fields
expect(decoded.selectedDomain).toBe(newParams.selectedDomain);
expect(decoded.showIP).toBe(newParams.showIP);
});
it('preserves existing params when updating', () => {
// Create a search string with existing params
const initialParams: Partial<ApiMonitoringParams> = {
showIP: false,
selectedDomain: 'initial-domain',
};
const urlParams = new URLSearchParams();
urlParams.set(
'apiMonitoringParams',
encodeURIComponent(JSON.stringify(initialParams)),
);
const initialSearch = `?${urlParams.toString()}`;
// Set up mocks
const { location, history } = mockUseLocationAndHistory(initialSearch);
// Manually execute the core logic
setApiMonitoringParams(
{ selectedView: 'new-view' },
location.search,
history as any,
);
// Verify history was updated
expect(history.push).toHaveBeenCalled();
// Parse the new query params from the URL
const searchArg = history.push.mock.calls[0][0].search;
const params = new URLSearchParams(searchArg);
const decoded = JSON.parse(
decodeURIComponent(params.get('apiMonitoringParams') || ''),
);
// Test only specific fields
expect(decoded.showIP).toBe(initialParams.showIP);
expect(decoded.selectedDomain).toBe(initialParams.selectedDomain);
expect(decoded.selectedView).toBe('new-view');
});
});
});

View File

@@ -1,88 +0,0 @@
import { useCallback } from 'react';
import { useHistory, useLocation } from 'react-router-dom';
// --- Types for all API Monitoring query params ---
export interface ApiMonitoringParams {
showIP?: boolean;
selectedDomain?: string;
selectedView?: string;
selectedEndPointName?: string;
groupBy?: string[];
allEndpointsLocalFilters?: any;
endPointDetailsLocalFilters?: any;
modalTimeRange?: { startTime: number; endTime: number };
selectedInterval?: string;
// Add more params as needed
}
export const DEFAULT_PARAMS: ApiMonitoringParams = {
showIP: true,
selectedDomain: '',
selectedView: 'all_endpoints',
selectedEndPointName: '',
groupBy: [],
allEndpointsLocalFilters: undefined,
endPointDetailsLocalFilters: undefined,
modalTimeRange: undefined,
selectedInterval: undefined,
};
const PARAM_KEY = 'apiMonitoringParams';
// --- Parse and serialize helpers ---
function encodeParams(params: ApiMonitoringParams): string {
return encodeURIComponent(JSON.stringify(params));
}
function decodeParams(value: string | null): ApiMonitoringParams {
if (!value) return DEFAULT_PARAMS;
try {
return JSON.parse(decodeURIComponent(value));
} catch {
return DEFAULT_PARAMS;
}
}
// --- Read query params from URL ---
export function getApiMonitoringParams(search: string): ApiMonitoringParams {
const params = new URLSearchParams(search);
return decodeParams(params.get(PARAM_KEY));
}
// --- Set query params in URL (replace or push) ---
export function setApiMonitoringParams(
newParams: Partial<ApiMonitoringParams>,
search: string,
history: ReturnType<typeof useHistory>,
replace = false,
): void {
const urlParams = new URLSearchParams(search);
const current = decodeParams(urlParams.get(PARAM_KEY));
const merged = { ...current, ...newParams };
urlParams.set(PARAM_KEY, encodeParams(merged));
const newSearch = `?${urlParams.toString()}`;
if (replace) {
history.replace({ search: newSearch });
} else {
history.push({ search: newSearch });
}
}
// --- React hook to use query params in a component ---
export function useApiMonitoringParams(): [
ApiMonitoringParams,
(newParams: Partial<ApiMonitoringParams>, replace?: boolean) => void,
] {
const location = useLocation();
const history = useHistory();
const params = getApiMonitoringParams(location.search);
const setParams = useCallback(
(newParams: Partial<ApiMonitoringParams>, replace = false) => {
setApiMonitoringParams(newParams, location.search, history, replace);
},
[location.search, history],
);
return [params, setParams];
}

View File

@@ -3796,9 +3796,9 @@ export const getRateOverTimeWidgetData = (
): Widgets => {
let legend = domainName;
if (endPointName) {
const { endpoint } = extractPortAndEndpoint(endPointName);
const { endpoint, port } = extractPortAndEndpoint(endPointName);
// eslint-disable-next-line sonarjs/no-nested-template-literals
legend = `${endpoint}`;
legend = `${port !== '-' && port !== 'n/a' ? `${port}:` : ''}${endpoint}`;
}
return getWidgetQueryBuilder(
@@ -3861,9 +3861,9 @@ export const getLatencyOverTimeWidgetData = (
): Widgets => {
let legend = domainName;
if (endPointName) {
const { endpoint } = extractPortAndEndpoint(endPointName);
const { endpoint, port } = extractPortAndEndpoint(endPointName);
// eslint-disable-next-line sonarjs/no-nested-template-literals
legend = `${endpoint}`;
legend = `${port !== '-' && port !== 'n/a' ? `${port}:` : ''}${endpoint}`;
}
return getWidgetQueryBuilder(

View File

@@ -1,6 +1,6 @@
import '../GridCardLayout.styles.scss';
import { Skeleton, Tooltip, Typography } from 'antd';
import { Skeleton, Typography } from 'antd';
import cx from 'classnames';
import { useNavigateToExplorer } from 'components/CeleryTask/useNavigateToExplorer';
import { ToggleGraphProps } from 'components/Graph/types';
@@ -9,17 +9,12 @@ import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { placeWidgetAtBottom } from 'container/NewWidget/utils';
import PanelWrapper from 'container/PanelWrapper/PanelWrapper';
import useGetResolvedText from 'hooks/dashboard/useGetResolvedText';
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import createQueryParams from 'lib/createQueryParams';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import {
getCustomTimeRangeWindowSweepInMS,
getStartAndEndTimesInMilliseconds,
} from 'pages/MessagingQueues/MessagingQueuesUtils';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import {
Dispatch,
@@ -62,7 +57,6 @@ function WidgetGraphComponent({
customSeries,
customErrorMessage,
customOnRowClick,
customTimeRangeWindowForCoRelation,
}: WidgetGraphComponentProps): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const [deleteModal, setDeleteModal] = useState(false);
@@ -269,13 +263,6 @@ function WidgetGraphComponent({
metric?: { [key: string]: string },
queryData?: { queryName: string; inFocusOrNot: boolean },
): void => {
const customTracesTimeRange = getCustomTimeRangeWindowSweepInMS(
customTimeRangeWindowForCoRelation,
);
const { start, end } = getStartAndEndTimesInMilliseconds(
xValue,
customTracesTimeRange,
);
handleGraphClick({
xValue,
yValue,
@@ -288,17 +275,9 @@ function WidgetGraphComponent({
navigateToExplorer,
notifications,
graphClick,
...(customTimeRangeWindowForCoRelation
? { customTracesTimeRange: { start, end } }
: {}),
});
};
const { truncatedText, fullText } = useGetResolvedText({
text: widget.title as string,
maxLength: 100,
});
return (
<div
style={{
@@ -332,11 +311,7 @@ function WidgetGraphComponent({
</Modal>
<Modal
title={
<Tooltip title={fullText} placement="top">
<span>{truncatedText || fullText || 'View'}</span>
</Tooltip>
}
title={widget?.title || 'View'}
footer={[]}
centered
open={isFullViewOpen}
@@ -418,7 +393,6 @@ WidgetGraphComponent.defaultProps = {
yAxisUnit: undefined,
setLayout: undefined,
onClickHandler: undefined,
customTimeRangeWindowForCoRelation: undefined,
};
export default WidgetGraphComponent;

View File

@@ -12,6 +12,7 @@ import { isEqual } from 'lodash-es';
import isEmpty from 'lodash-es/isEmpty';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { memo, useEffect, useRef, useState } from 'react';
import { useQueryClient } from 'react-query';
import { useDispatch, useSelector } from 'react-redux';
import { UpdateTimeInterval } from 'store/actions';
import { AppState } from 'store/reducers';
@@ -26,7 +27,6 @@ import { GridCardGraphProps } from './types';
import { isDataAvailableByPanelType } from './utils';
import WidgetGraphComponent from './WidgetGraphComponent';
// eslint-disable-next-line sonarjs/cognitive-complexity
function GridCardGraph({
widget,
headerMenuList = [MenuItemKeys.View],
@@ -49,7 +49,6 @@ function GridCardGraph({
analyticsEvent,
customTimeRange,
customOnRowClick,
customTimeRangeWindowForCoRelation,
}: GridCardGraphProps): JSX.Element {
const dispatch = useDispatch();
const [errorMessage, setErrorMessage] = useState<string>();
@@ -59,12 +58,14 @@ function GridCardGraph({
const {
toScrollWidgetId,
setToScrollWidgetId,
variablesToGetUpdated,
setDashboardQueryRangeCalled,
} = useDashboard();
const { minTime, maxTime, selectedTime: globalSelectedInterval } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
const queryClient = useQueryClient();
const handleBackNavigation = (): void => {
const searchParams = new URLSearchParams(window.location.search);
@@ -115,7 +116,11 @@ function GridCardGraph({
const isEmptyWidget =
widget?.id === PANEL_TYPES.EMPTY_WIDGET || isEmpty(widget);
const queryEnabledCondition = isVisible && !isEmptyWidget && isQueryEnabled;
const queryEnabledCondition =
isVisible &&
!isEmptyWidget &&
isQueryEnabled &&
isEmpty(variablesToGetUpdated);
const [requestData, setRequestData] = useState<GetQueryResultsProps>(() => {
if (widget.panelTypes !== PANEL_TYPES.LIST) {
@@ -155,22 +160,22 @@ function GridCardGraph({
// TODO [vikrantgupta25] remove this useEffect with refactor as this is prone to race condition
// this is added to tackle the case of async communication between VariableItem.tsx and GridCard.tsx
// useEffect(() => {
// if (variablesToGetUpdated.length > 0) {
// queryClient.cancelQueries([
// maxTime,
// minTime,
// globalSelectedInterval,
// variables,
// widget?.query,
// widget?.panelTypes,
// widget.timePreferance,
// widget.fillSpans,
// requestData,
// ]);
// }
// // eslint-disable-next-line react-hooks/exhaustive-deps
// }, [variablesToGetUpdated]);
useEffect(() => {
if (variablesToGetUpdated.length > 0) {
queryClient.cancelQueries([
maxTime,
minTime,
globalSelectedInterval,
variables,
widget?.query,
widget?.panelTypes,
widget.timePreferance,
widget.fillSpans,
requestData,
]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [variablesToGetUpdated]);
useEffect(() => {
if (!isEqual(updatedQuery, requestData.query)) {
@@ -203,15 +208,6 @@ function GridCardGraph({
widget.timePreferance,
widget.fillSpans,
requestData,
variables
? Object.entries(variables).reduce(
(acc, [id, variable]) => ({
...acc,
[id]: variable.selectedValue,
}),
{},
)
: {},
...(customTimeRange && customTimeRange.startTime && customTimeRange.endTime
? [customTimeRange.startTime, customTimeRange.endTime]
: []),
@@ -293,7 +289,6 @@ function GridCardGraph({
customSeries={customSeries}
customErrorMessage={isInternalServerError ? customErrorMessage : undefined}
customOnRowClick={customOnRowClick}
customTimeRangeWindowForCoRelation={customTimeRangeWindowForCoRelation}
/>
)}
</div>
@@ -308,7 +303,6 @@ GridCardGraph.defaultProps = {
headerMenuList: [MenuItemKeys.View],
version: 'v3',
analyticsEvent: undefined,
customTimeRangeWindowForCoRelation: undefined,
};
export default memo(GridCardGraph);

View File

@@ -40,7 +40,6 @@ export interface WidgetGraphComponentProps {
customSeries?: (data: QueryData[]) => uPlot.Series[];
customErrorMessage?: string;
customOnRowClick?: (record: RowData) => void;
customTimeRangeWindowForCoRelation?: string | undefined;
}
export interface GridCardGraphProps {
@@ -68,7 +67,6 @@ export interface GridCardGraphProps {
endTime: number;
};
customOnRowClick?: (record: RowData) => void;
customTimeRangeWindowForCoRelation?: string | undefined;
}
export interface GetGraphVisibilityStateOnLegendClickProps {

View File

@@ -179,7 +179,6 @@ interface HandleGraphClickParams {
notifications: NotificationInstance;
graphClick: (props: GraphClickProps) => void;
customFilters?: TagFilterItem[];
customTracesTimeRange?: { start: number; end: number };
}
export const handleGraphClick = async ({
@@ -195,7 +194,6 @@ export const handleGraphClick = async ({
notifications,
graphClick,
customFilters,
customTracesTimeRange,
}: HandleGraphClickParams): Promise<void> => {
const { stepInterval } = widget?.query?.builder?.queryData?.[0] ?? {};
@@ -227,10 +225,8 @@ export const handleGraphClick = async ({
navigateToExplorer({
filters: [...result[key].filters, ...(customFilters || [])],
dataSource: result[key].dataSource as DataSource,
startTime: customTracesTimeRange ? customTracesTimeRange?.start : xValue,
endTime: customTracesTimeRange
? customTracesTimeRange?.end
: xValue + (stepInterval ?? 60),
startTime: xValue,
endTime: xValue + (stepInterval ?? 60),
shouldResolveQuery: true,
}),
}));

View File

@@ -16,7 +16,6 @@ import { Dropdown, Input, MenuProps, Tooltip, Typography } from 'antd';
import Spinner from 'components/Spinner';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import useGetResolvedText from 'hooks/dashboard/useGetResolvedText';
import useCreateAlerts from 'hooks/queryBuilder/useCreateAlerts';
import useComponentPermission from 'hooks/useComponentPermission';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
@@ -206,11 +205,6 @@ function WidgetHeader({
[updatedMenuList, onMenuItemSelectHandler],
);
const { truncatedText, fullText } = useGetResolvedText({
text: widget.title as string,
maxLength: 100,
});
if (widget.id === PANEL_TYPES.EMPTY_WIDGET) {
return null;
}
@@ -243,15 +237,13 @@ function WidgetHeader({
) : (
<>
<div className="widget-header-title-container">
<Tooltip title={fullText} placement="top">
<Typography.Text
ellipsis
data-testid={title}
className="widget-header-title"
>
{truncatedText}
</Typography.Text>
</Tooltip>
<Typography.Text
ellipsis
data-testid={title}
className="widget-header-title"
>
{title}
</Typography.Text>
{widget.description && (
<Tooltip
title={widget.description}

View File

@@ -30,6 +30,7 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import { Tags } from 'types/reducer/trace';
import { USER_ROLES } from 'types/roles';
import { FeatureKeys } from '../../../constants/features';
import { DOCS_LINKS } from '../constants';
import { columns, TIME_PICKER_OPTIONS } from './constants';
@@ -210,6 +211,11 @@ function ServiceMetrics({
const topLevelOperations = useMemo(() => Object.entries(data || {}), [data]);
const { featureFlags } = useAppContext();
const dotMetricsEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
?.active || false;
const queryRangeRequestData = useMemo(
() =>
getQueryRangeRequestData({
@@ -217,12 +223,14 @@ function ServiceMetrics({
minTime: timeRange.startTime * 1e6,
maxTime: timeRange.endTime * 1e6,
globalSelectedInterval,
dotMetricsEnabled,
}),
[
globalSelectedInterval,
timeRange.endTime,
timeRange.startTime,
topLevelOperations,
dotMetricsEnabled,
],
);

View File

@@ -1,8 +1,8 @@
import { Button, Form, Input, Space, Tooltip, Typography } from 'antd';
import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
import loginApi from 'api/v1/login/login';
import loginPrecheckApi from 'api/v1/login/loginPrecheck';
import loginApi from 'api/v1/user/login';
import getUserVersion from 'api/v1/version/getVersion';
import afterLogin from 'AppRoutes/utils';
import { LOCALSTORAGE } from 'constants/localStorage';
@@ -13,7 +13,6 @@ import { useAppContext } from 'providers/App/App';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from 'react-query';
import APIError from 'types/api/error';
import { PayloadProps as PrecheckResultType } from 'types/api/user/loginPrecheck';
import { FormContainer, FormWrapper, Label, ParentContainer } from './styles';
@@ -167,18 +166,22 @@ function Login({
email,
password,
});
afterLogin(
response.data.userId,
response.data.accessJwt,
response.data.refreshJwt,
);
if (response.statusCode === 200) {
afterLogin(
response.payload.userId,
response.payload.accessJwt,
response.payload.refreshJwt,
);
} else {
notifications.error({
message: response.error || t('unexpected_error'),
});
}
setIsLoading(false);
} catch (error) {
setIsLoading(false);
notifications.error({
message: (error as APIError).getErrorCode(),
description: (error as APIError).getErrorMessage(),
message: t('unexpected_error'),
});
}
};

View File

@@ -21,6 +21,7 @@ export const databaseCallsRPS = ({
servicename,
legend,
tagFilterItems,
dotMetricsEnabled,
}: DatabaseCallsRPSProps): QueryBuilderData => {
const autocompleteData: BaseAutocompleteData[] = [
{
@@ -34,7 +35,7 @@ export const databaseCallsRPS = ({
{
dataType: DataTypes.String,
isColumn: false,
key: 'db_system',
key: dotMetricsEnabled ? WidgetKeys.Db_system : WidgetKeys.Db_system_norm,
type: 'tag',
},
];
@@ -43,7 +44,9 @@ export const databaseCallsRPS = ({
{
id: '',
key: {
key: WidgetKeys.Service_name,
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
dataType: DataTypes.String,
isColumn: false,
type: MetricsType.Resource,
@@ -75,6 +78,7 @@ export const databaseCallsRPS = ({
export const databaseCallsAvgDuration = ({
servicename,
tagFilterItems,
dotMetricsEnabled,
}: DatabaseCallProps): QueryBuilderData => {
const autocompleteDataA: BaseAutocompleteData = {
key: WidgetKeys.SignozDbLatencySum,
@@ -93,7 +97,9 @@ export const databaseCallsAvgDuration = ({
{
id: '',
key: {
key: WidgetKeys.Service_name,
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
dataType: DataTypes.String,
isColumn: false,
type: MetricsType.Resource,

View File

@@ -33,6 +33,7 @@ export const externalCallErrorPercent = ({
servicename,
legend,
tagFilterItems,
dotMetricsEnabled,
}: ExternalCallDurationByAddressProps): QueryBuilderData => {
const autocompleteDataA: BaseAutocompleteData = {
key: WidgetKeys.SignozExternalCallLatencyCount,
@@ -51,7 +52,9 @@ export const externalCallErrorPercent = ({
{
id: '',
key: {
key: WidgetKeys.Service_name,
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
dataType: DataTypes.String,
isColumn: false,
type: MetricsType.Resource,
@@ -62,7 +65,7 @@ export const externalCallErrorPercent = ({
{
id: '',
key: {
key: WidgetKeys.StatusCode,
key: dotMetricsEnabled ? WidgetKeys.StatusCode : WidgetKeys.StatusCodeNorm,
dataType: DataTypes.Int64,
isColumn: false,
type: MetricsType.Tag,
@@ -76,7 +79,9 @@ export const externalCallErrorPercent = ({
{
id: '',
key: {
key: WidgetKeys.Service_name,
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
dataType: DataTypes.String,
isColumn: false,
type: MetricsType.Resource,
@@ -121,6 +126,7 @@ export const externalCallErrorPercent = ({
export const externalCallDuration = ({
servicename,
tagFilterItems,
dotMetricsEnabled,
}: ExternalCallProps): QueryBuilderData => {
const autocompleteDataA: BaseAutocompleteData = {
dataType: DataTypes.Float64,
@@ -144,7 +150,9 @@ export const externalCallDuration = ({
key: {
dataType: DataTypes.String,
isColumn: false,
key: WidgetKeys.Service_name,
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
type: MetricsType.Resource,
},
op: OPERATORS.IN,
@@ -184,6 +192,7 @@ export const externalCallRpsByAddress = ({
servicename,
legend,
tagFilterItems,
dotMetricsEnabled,
}: ExternalCallDurationByAddressProps): QueryBuilderData => {
const autocompleteData: BaseAutocompleteData[] = [
{
@@ -200,7 +209,9 @@ export const externalCallRpsByAddress = ({
key: {
dataType: DataTypes.String,
isColumn: false,
key: WidgetKeys.Service_name,
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
type: MetricsType.Resource,
},
op: OPERATORS.IN,
@@ -231,6 +242,7 @@ export const externalCallDurationByAddress = ({
servicename,
legend,
tagFilterItems,
dotMetricsEnabled,
}: ExternalCallDurationByAddressProps): QueryBuilderData => {
const autocompleteDataA: BaseAutocompleteData = {
dataType: DataTypes.Float64,
@@ -253,7 +265,9 @@ export const externalCallDurationByAddress = ({
key: {
dataType: DataTypes.String,
isColumn: false,
key: WidgetKeys.Service_name,
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
type: MetricsType.Resource,
},
op: OPERATORS.IN,

View File

@@ -37,10 +37,18 @@ export const latency = ({
tagFilterItems,
isSpanMetricEnable = false,
topLevelOperationsRoute,
dotMetricsEnabled,
}: LatencyProps): QueryBuilderData => {
const signozLatencyBucketMetrics = dotMetricsEnabled
? WidgetKeys.Signoz_latency_bucket
: WidgetKeys.Signoz_latency_bucket_norm;
const signozMetricsServiceName = dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm;
const newAutoCompleteData: BaseAutocompleteData = {
key: isSpanMetricEnable
? WidgetKeys.Signoz_latency_bucket
? signozLatencyBucketMetrics
: WidgetKeys.DurationNano,
dataType: DataTypes.Float64,
isColumn: true,
@@ -53,7 +61,7 @@ export const latency = ({
{
id: '',
key: {
key: isSpanMetricEnable ? WidgetKeys.Service_name : WidgetKeys.ServiceName,
key: isSpanMetricEnable ? signozMetricsServiceName : WidgetKeys.ServiceName,
dataType: DataTypes.String,
type: isSpanMetricEnable ? MetricsType.Resource : MetricsType.Tag,
isColumn: !isSpanMetricEnable,
@@ -295,23 +303,30 @@ export const apDexMetricsQueryBuilderQueries = ({
threashold,
delta,
metricsBuckets,
dotMetricsEnabled,
}: ApDexMetricsQueryBuilderQueriesProps): QueryBuilderData => {
const autoCompleteDataA: BaseAutocompleteData = {
key: WidgetKeys.SignozLatencyCount,
key: dotMetricsEnabled
? WidgetKeys.SignozLatencyCount
: WidgetKeys.SignozLatencyCountNorm,
dataType: DataTypes.Float64,
isColumn: true,
type: '',
};
const autoCompleteDataB: BaseAutocompleteData = {
key: WidgetKeys.Signoz_latency_bucket,
key: dotMetricsEnabled
? WidgetKeys.Signoz_latency_bucket
: WidgetKeys.Signoz_latency_bucket_norm,
dataType: DataTypes.Float64,
isColumn: true,
type: '',
};
const autoCompleteDataC: BaseAutocompleteData = {
key: WidgetKeys.Signoz_latency_bucket,
key: dotMetricsEnabled
? WidgetKeys.Signoz_latency_bucket
: WidgetKeys.Signoz_latency_bucket_norm,
dataType: DataTypes.Float64,
isColumn: true,
type: '',
@@ -321,7 +336,9 @@ export const apDexMetricsQueryBuilderQueries = ({
{
id: '',
key: {
key: WidgetKeys.Service_name,
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
dataType: DataTypes.String,
isColumn: false,
type: MetricsType.Tag,
@@ -347,7 +364,7 @@ export const apDexMetricsQueryBuilderQueries = ({
{
id: '',
key: {
key: WidgetKeys.StatusCode,
key: dotMetricsEnabled ? WidgetKeys.StatusCode : WidgetKeys.StatusCodeNorm,
dataType: DataTypes.String,
isColumn: false,
type: MetricsType.Tag,
@@ -369,7 +386,9 @@ export const apDexMetricsQueryBuilderQueries = ({
{
id: '',
key: {
key: WidgetKeys.Service_name,
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
dataType: DataTypes.String,
isColumn: false,
type: MetricsType.Tag,
@@ -406,7 +425,7 @@ export const apDexMetricsQueryBuilderQueries = ({
{
id: '',
key: {
key: WidgetKeys.StatusCode,
key: dotMetricsEnabled ? WidgetKeys.StatusCode : WidgetKeys.StatusCodeNorm,
dataType: DataTypes.String,
isColumn: false,
type: MetricsType.Tag,
@@ -417,7 +436,9 @@ export const apDexMetricsQueryBuilderQueries = ({
{
id: '',
key: {
key: WidgetKeys.Service_name,
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
dataType: DataTypes.String,
isColumn: false,
type: MetricsType.Tag,
@@ -482,10 +503,13 @@ export const operationPerSec = ({
servicename,
tagFilterItems,
topLevelOperations,
dotMetricsEnabled,
}: OperationPerSecProps): QueryBuilderData => {
const autocompleteData: BaseAutocompleteData[] = [
{
key: WidgetKeys.SignozLatencyCount,
key: dotMetricsEnabled
? WidgetKeys.SignozLatencyCount
: WidgetKeys.SignozLatencyCountNorm,
dataType: DataTypes.Float64,
isColumn: true,
type: '',
@@ -497,7 +521,9 @@ export const operationPerSec = ({
{
id: '',
key: {
key: WidgetKeys.Service_name,
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
dataType: DataTypes.String,
isColumn: false,
type: MetricsType.Resource,
@@ -540,6 +566,7 @@ export const errorPercentage = ({
servicename,
tagFilterItems,
topLevelOperations,
dotMetricsEnabled,
}: OperationPerSecProps): QueryBuilderData => {
const autocompleteDataA: BaseAutocompleteData = {
key: WidgetKeys.SignozCallsTotal,
@@ -560,7 +587,9 @@ export const errorPercentage = ({
{
id: '',
key: {
key: WidgetKeys.Service_name,
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
dataType: DataTypes.String,
isColumn: false,
type: MetricsType.Resource,
@@ -582,7 +611,9 @@ export const errorPercentage = ({
{
id: '',
key: {
key: WidgetKeys.StatusCode,
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.StatusCodeNorm,
dataType: DataTypes.Int64,
isColumn: false,
type: MetricsType.Tag,
@@ -597,7 +628,9 @@ export const errorPercentage = ({
{
id: '',
key: {
key: WidgetKeys.Service_name,
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
dataType: DataTypes.String,
isColumn: false,
type: MetricsType.Resource,

View File

@@ -21,9 +21,12 @@ import { getQueryBuilderQuerieswithFormula } from './MetricsPageQueriesFactory';
export const topOperationQueries = ({
servicename,
dotMetricsEnabled,
}: TopOperationQueryFactoryProps): QueryBuilderData => {
const latencyAutoCompleteData: BaseAutocompleteData = {
key: WidgetKeys.Signoz_latency_bucket,
key: dotMetricsEnabled
? WidgetKeys.Signoz_latency_bucket
: WidgetKeys.Signoz_latency_bucket_norm,
dataType: DataTypes.Float64,
isColumn: true,
type: '',
@@ -37,7 +40,9 @@ export const topOperationQueries = ({
};
const numOfCallAutoCompleteData: BaseAutocompleteData = {
key: WidgetKeys.SignozLatencyCount,
key: dotMetricsEnabled
? WidgetKeys.SignozLatencyCount
: WidgetKeys.SignozLatencyCountNorm,
dataType: DataTypes.Float64,
isColumn: true,
type: '',
@@ -47,7 +52,9 @@ export const topOperationQueries = ({
{
id: '',
key: {
key: WidgetKeys.Service_name,
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
dataType: DataTypes.String,
isColumn: false,
type: MetricsType.Resource,
@@ -63,7 +70,9 @@ export const topOperationQueries = ({
key: {
dataType: DataTypes.String,
isColumn: false,
key: WidgetKeys.Service_name,
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
type: MetricsType.Resource,
},
op: OPERATORS.IN,
@@ -74,7 +83,7 @@ export const topOperationQueries = ({
key: {
dataType: DataTypes.Int64,
isColumn: false,
key: WidgetKeys.StatusCode,
key: dotMetricsEnabled ? WidgetKeys.StatusCode : WidgetKeys.StatusCodeNorm,
type: MetricsType.Tag,
},
op: OPERATORS.IN,

View File

@@ -26,6 +26,8 @@ import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { v4 as uuid } from 'uuid';
import { FeatureKeys } from '../../../constants/features';
import { useAppContext } from '../../../providers/App/App';
import { GraphTitle, MENU_ITEMS, SERVICE_CHART_ID } from '../constant';
import { getWidgetQueryBuilder } from '../MetricsApplication.factory';
import { Card, GraphContainer, Row } from '../styles';
@@ -80,8 +82,11 @@ function DBCall(): JSX.Element {
[queries],
);
const legend = '{{db_system}}';
const legend = '{{db.system}}';
const { featureFlags } = useAppContext();
const dotMetricsEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
?.active || false;
const databaseCallsRPSWidget = useMemo(
() =>
getWidgetQueryBuilder({
@@ -92,6 +97,7 @@ function DBCall(): JSX.Element {
servicename,
legend,
tagFilterItems,
dotMetricsEnabled,
}),
clickhouse_sql: [],
id: uuid(),
@@ -102,7 +108,7 @@ function DBCall(): JSX.Element {
id: SERVICE_CHART_ID.dbCallsRPS,
fillSpans: false,
}),
[servicename, tagFilterItems],
[servicename, tagFilterItems, dotMetricsEnabled],
);
const databaseCallsAverageDurationWidget = useMemo(
() =>
@@ -113,6 +119,7 @@ function DBCall(): JSX.Element {
builder: databaseCallsAvgDuration({
servicename,
tagFilterItems,
dotMetricsEnabled,
}),
clickhouse_sql: [],
id: uuid(),
@@ -123,7 +130,7 @@ function DBCall(): JSX.Element {
id: GraphTitle.DATABASE_CALLS_AVG_DURATION,
fillSpans: true,
}),
[servicename, tagFilterItems],
[servicename, tagFilterItems, dotMetricsEnabled],
);
const stepInterval = useMemo(

View File

@@ -28,14 +28,15 @@ import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { EQueryType } from 'types/common/dashboard';
import { v4 as uuid } from 'uuid';
import { FeatureKeys } from '../../../constants/features';
import { useAppContext } from '../../../providers/App/App';
import { GraphTitle, legend, MENU_ITEMS } from '../constant';
import { getWidgetQueryBuilder } from '../MetricsApplication.factory';
import { Card, GraphContainer, Row } from '../styles';
import GraphControlsPanel from './Overview/GraphControlsPanel/GraphControlsPanel';
import { Button } from './styles';
import { IServiceName } from './types';
import {
handleNonInQueryRange,
onViewAPIMonitoringPopupClick,
onViewTracePopupClick,
useGetAPMToTracesQueries,
useGraphClickHandler,
@@ -43,7 +44,7 @@ import {
function External(): JSX.Element {
const [selectedTimeStamp, setSelectedTimeStamp] = useState<number>(0);
const [selectedData, setSelectedData] = useState<any>(undefined);
const { servicename: encodedServiceName } = useParams<IServiceName>();
const servicename = decodeURIComponent(encodedServiceName);
@@ -75,6 +76,10 @@ function External(): JSX.Element {
handleNonInQueryRange(resourceAttributesToTagFilterItems(queries)) || [],
[queries],
);
const { featureFlags } = useAppContext();
const dotMetricsEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
?.active || false;
const externalCallErrorWidget = useMemo(
() =>
@@ -86,6 +91,7 @@ function External(): JSX.Element {
servicename,
legend: legend.address,
tagFilterItems,
dotMetricsEnabled,
}),
clickhouse_sql: [],
id: uuid(),
@@ -95,7 +101,7 @@ function External(): JSX.Element {
yAxisUnit: '%',
id: GraphTitle.EXTERNAL_CALL_ERROR_PERCENTAGE,
}),
[servicename, tagFilterItems],
[servicename, tagFilterItems, dotMetricsEnabled],
);
const selectedTraceTags = useMemo(
@@ -112,6 +118,7 @@ function External(): JSX.Element {
builder: externalCallDuration({
servicename,
tagFilterItems,
dotMetricsEnabled,
}),
clickhouse_sql: [],
id: uuid(),
@@ -122,7 +129,7 @@ function External(): JSX.Element {
id: GraphTitle.EXTERNAL_CALL_DURATION,
fillSpans: true,
}),
[servicename, tagFilterItems],
[servicename, tagFilterItems, dotMetricsEnabled],
);
const errorApmToTraceQuery = useGetAPMToTracesQueries({
@@ -181,6 +188,7 @@ function External(): JSX.Element {
servicename,
legend: legend.address,
tagFilterItems,
dotMetricsEnabled,
}),
clickhouse_sql: [],
id: uuid(),
@@ -191,7 +199,7 @@ function External(): JSX.Element {
id: GraphTitle.EXTERNAL_CALL_RPS_BY_ADDRESS,
fillSpans: true,
}),
[servicename, tagFilterItems],
[servicename, tagFilterItems, dotMetricsEnabled],
);
const externalCallDurationAddressWidget = useMemo(
@@ -204,6 +212,7 @@ function External(): JSX.Element {
servicename,
legend: legend.address,
tagFilterItems,
dotMetricsEnabled,
}),
clickhouse_sql: [],
id: uuid(),
@@ -214,7 +223,7 @@ function External(): JSX.Element {
id: GraphTitle.EXTERNAL_CALL_DURATION_BY_ADDRESS,
fillSpans: true,
}),
[servicename, tagFilterItems],
[servicename, tagFilterItems, dotMetricsEnabled],
);
const apmToTraceQuery = useGetAPMToTracesQueries({
@@ -224,18 +233,17 @@ function External(): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const onGraphClickHandler = useGraphClickHandler(
setSelectedTimeStamp,
setSelectedData,
);
const onGraphClickHandler = useGraphClickHandler(setSelectedTimeStamp);
return (
<>
<Row gutter={24}>
<Col span={12}>
<GraphControlsPanel
<Button
type="default"
size="small"
id="external_call_error_percentage_button"
onViewTracesClick={onViewTracePopupClick({
onClick={onViewTracePopupClick({
servicename,
selectedTraceTags,
timestamp: selectedTimeStamp,
@@ -243,28 +251,21 @@ function External(): JSX.Element {
stepInterval,
safeNavigate,
})}
onViewAPIMonitoringClick={onViewAPIMonitoringPopupClick({
servicename,
timestamp: selectedTimeStamp,
domainName: selectedData?.address || '',
isError: true,
stepInterval: 300,
safeNavigate,
})}
/>
>
View Traces
</Button>
<Card data-testid="external_call_error_percentage">
<GraphContainer>
<Graph
headerMenuList={MENU_ITEMS}
widget={externalCallErrorWidget}
onClickHandler={(xValue, yValue, mouseX, mouseY, data): void => {
onClickHandler={(xValue, yValue, mouseX, mouseY): void => {
onGraphClickHandler(
xValue,
yValue,
mouseX,
mouseY,
'external_call_error_percentage',
data,
);
}}
onDragSelect={onDragSelect}
@@ -275,9 +276,11 @@ function External(): JSX.Element {
</Col>
<Col span={12}>
<GraphControlsPanel
<Button
type="default"
size="small"
id="external_call_duration_button"
onViewTracesClick={onViewTracePopupClick({
onClick={onViewTracePopupClick({
servicename,
selectedTraceTags,
timestamp: selectedTimeStamp,
@@ -285,29 +288,22 @@ function External(): JSX.Element {
stepInterval,
safeNavigate,
})}
onViewAPIMonitoringClick={onViewAPIMonitoringPopupClick({
servicename,
timestamp: selectedTimeStamp,
domainName: selectedData?.address,
isError: false,
stepInterval: 300,
safeNavigate,
})}
/>
>
View Traces
</Button>
<Card data-testid="external_call_duration">
<GraphContainer>
<Graph
headerMenuList={MENU_ITEMS}
widget={externalCallDurationWidget}
onClickHandler={(xValue, yValue, mouseX, mouseY, data): void => {
onClickHandler={(xValue, yValue, mouseX, mouseY): void => {
onGraphClickHandler(
xValue,
yValue,
mouseX,
mouseY,
'external_call_duration',
data,
);
}}
onDragSelect={onDragSelect}
@@ -320,9 +316,11 @@ function External(): JSX.Element {
<Row gutter={24}>
<Col span={12}>
<GraphControlsPanel
<Button
type="default"
size="small"
id="external_call_rps_by_address_button"
onViewTracesClick={onViewTracePopupClick({
onClick={onViewTracePopupClick({
servicename,
selectedTraceTags,
timestamp: selectedTimeStamp,
@@ -330,28 +328,21 @@ function External(): JSX.Element {
stepInterval,
safeNavigate,
})}
onViewAPIMonitoringClick={onViewAPIMonitoringPopupClick({
servicename,
timestamp: selectedTimeStamp,
domainName: selectedData?.address,
isError: false,
stepInterval: 300,
safeNavigate,
})}
/>
>
View Traces
</Button>
<Card data-testid="external_call_rps_by_address">
<GraphContainer>
<Graph
widget={externalCallRPSWidget}
headerMenuList={MENU_ITEMS}
onClickHandler={(xValue, yValue, mouseX, mouseY, data): Promise<void> =>
onClickHandler={(xValue, yValue, mouseX, mouseY): Promise<void> =>
onGraphClickHandler(
xValue,
yValue,
mouseX,
mouseY,
'external_call_rps_by_address',
data,
)
}
onDragSelect={onDragSelect}
@@ -362,9 +353,11 @@ function External(): JSX.Element {
</Col>
<Col span={12}>
<GraphControlsPanel
<Button
type="default"
size="small"
id="external_call_duration_by_address_button"
onViewTracesClick={onViewTracePopupClick({
onClick={onViewTracePopupClick({
servicename,
selectedTraceTags,
timestamp: selectedTimeStamp,
@@ -372,29 +365,22 @@ function External(): JSX.Element {
stepInterval,
safeNavigate,
})}
onViewAPIMonitoringClick={onViewAPIMonitoringPopupClick({
servicename,
timestamp: selectedTimeStamp,
domainName: selectedData?.address,
isError: false,
stepInterval: 300,
safeNavigate,
})}
/>
>
View Traces
</Button>
<Card data-testid="external_call_duration_by_address">
<GraphContainer>
<Graph
widget={externalCallDurationAddressWidget}
headerMenuList={MENU_ITEMS}
onClickHandler={(xValue, yValue, mouseX, mouseY, data): void => {
onClickHandler={(xValue, yValue, mouseX, mouseY): void => {
onGraphClickHandler(
xValue,
yValue,
mouseX,
mouseY,
'external_call_duration_by_address',
data,
);
}}
onDragSelect={onDragSelect}

View File

@@ -145,6 +145,10 @@ function Application(): JSX.Element {
[servicename, topLevelOperations],
);
const dotMetricsEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
?.active || false;
const operationPerSecWidget = useMemo(
() =>
getWidgetQueryBuilder({
@@ -155,6 +159,7 @@ function Application(): JSX.Element {
servicename,
tagFilterItems,
topLevelOperations: topLevelOperationsRoute,
dotMetricsEnabled,
}),
clickhouse_sql: [],
id: uuid(),
@@ -164,7 +169,7 @@ function Application(): JSX.Element {
yAxisUnit: 'ops',
id: SERVICE_CHART_ID.rps,
}),
[servicename, tagFilterItems, topLevelOperationsRoute],
[servicename, tagFilterItems, topLevelOperationsRoute, dotMetricsEnabled],
);
const errorPercentageWidget = useMemo(
@@ -177,6 +182,7 @@ function Application(): JSX.Element {
servicename,
tagFilterItems,
topLevelOperations: topLevelOperationsRoute,
dotMetricsEnabled,
}),
clickhouse_sql: [],
id: uuid(),
@@ -187,7 +193,7 @@ function Application(): JSX.Element {
id: SERVICE_CHART_ID.errorPercentage,
fillSpans: true,
}),
[servicename, tagFilterItems, topLevelOperationsRoute],
[servicename, tagFilterItems, topLevelOperationsRoute, dotMetricsEnabled],
);
const stepInterval = useMemo(

View File

@@ -20,6 +20,8 @@ import { useParams } from 'react-router-dom';
import { EQueryType } from 'types/common/dashboard';
import { v4 as uuid } from 'uuid';
import { FeatureKeys } from '../../../../../constants/features';
import { useAppContext } from '../../../../../providers/App/App';
import { IServiceName } from '../../types';
import { ApDexMetricsProps } from './types';
@@ -34,7 +36,10 @@ function ApDexMetrics({
}: ApDexMetricsProps): JSX.Element {
const { servicename: encodedServiceName } = useParams<IServiceName>();
const servicename = decodeURIComponent(encodedServiceName);
const { featureFlags } = useAppContext();
const dotMetricsEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
?.active || false;
const apDexMetricsWidget = useMemo(
() =>
getWidgetQueryBuilder({
@@ -48,6 +53,7 @@ function ApDexMetrics({
threashold: thresholdValue || 0,
delta: delta || false,
metricsBuckets: metricsBuckets || [],
dotMetricsEnabled,
}),
clickhouse_sql: [],
id: uuid(),
@@ -73,6 +79,7 @@ function ApDexMetrics({
tagFilterItems,
thresholdValue,
topLevelOperationsRoute,
dotMetricsEnabled,
],
);

View File

@@ -2,7 +2,7 @@
position: absolute;
z-index: 999;
display: none;
width: 150px;
width: 110px;
padding: 5px;
border-radius: 5px;
background: var(--bg-slate-400);

View File

@@ -2,20 +2,18 @@ import './GraphControlsPanel.styles.scss';
import { Color } from '@signozhq/design-tokens';
import { Button } from 'antd';
import { Binoculars, DraftingCompass, ScrollText } from 'lucide-react';
import { DraftingCompass, ScrollText } from 'lucide-react';
interface GraphControlsPanelProps {
id: string;
onViewLogsClick?: () => void;
onViewLogsClick: () => void;
onViewTracesClick: () => void;
onViewAPIMonitoringClick?: () => void;
}
function GraphControlsPanel({
id,
onViewLogsClick,
onViewTracesClick,
onViewAPIMonitoringClick,
}: GraphControlsPanelProps): JSX.Element {
return (
<div id={id} className="graph-controls-panel">
@@ -28,35 +26,17 @@ function GraphControlsPanel({
>
View traces
</Button>
{onViewLogsClick && (
<Button
type="link"
icon={<ScrollText size={14} />}
size="small"
onClick={onViewLogsClick}
style={{ color: Color.BG_VANILLA_100 }}
>
View logs
</Button>
)}
{onViewAPIMonitoringClick && (
<Button
type="link"
icon={<Binoculars size={14} />}
size="small"
onClick={onViewAPIMonitoringClick}
style={{ color: Color.BG_VANILLA_100 }}
>
View Third Party API
</Button>
)}
<Button
type="link"
icon={<ScrollText size={14} />}
size="small"
onClick={onViewLogsClick}
style={{ color: Color.BG_VANILLA_100 }}
>
View logs
</Button>
</div>
);
}
GraphControlsPanel.defaultProps = {
onViewLogsClick: undefined,
onViewAPIMonitoringClick: undefined,
};
export default GraphControlsPanel;

View File

@@ -56,6 +56,10 @@ function ServiceOverview({
[isSpanMetricEnable, queries],
);
const dotMetricsEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_SPAN_METRICS)
?.active || false;
const latencyWidget = useMemo(
() =>
getWidgetQueryBuilder({
@@ -67,6 +71,7 @@ function ServiceOverview({
tagFilterItems,
isSpanMetricEnable,
topLevelOperationsRoute,
dotMetricsEnabled,
}),
clickhouse_sql: [],
id: uuid(),
@@ -76,7 +81,13 @@ function ServiceOverview({
yAxisUnit: 'ns',
id: SERVICE_CHART_ID.latency,
}),
[isSpanMetricEnable, servicename, tagFilterItems, topLevelOperationsRoute],
[
isSpanMetricEnable,
servicename,
tagFilterItems,
topLevelOperationsRoute,
dotMetricsEnabled,
],
);
const isQueryEnabled =

View File

@@ -18,6 +18,8 @@ import { EQueryType } from 'types/common/dashboard';
import { GlobalReducer } from 'types/reducer/globalTime';
import { v4 as uuid } from 'uuid';
import { FeatureKeys } from '../../../../constants/features';
import { useAppContext } from '../../../../providers/App/App';
import { IServiceName } from '../types';
import { title } from './config';
import ColumnWithLink from './TableRenderer/ColumnWithLink';
@@ -40,6 +42,11 @@ function TopOperationMetrics(): JSX.Element {
convertRawQueriesToTraceSelectedTags(queries) || [],
);
const { featureFlags } = useAppContext();
const dotMetricsEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
?.active || false;
const keyOperationWidget = useMemo(
() =>
getWidgetQueryBuilder({
@@ -48,13 +55,14 @@ function TopOperationMetrics(): JSX.Element {
promql: [],
builder: topOperationQueries({
servicename,
dotMetricsEnabled,
}),
clickhouse_sql: [],
id: uuid(),
},
panelTypes: PANEL_TYPES.TABLE,
}),
[servicename],
[servicename, dotMetricsEnabled],
);
const updatedQuery = useStepInterval(keyOperationWidget.query);

View File

@@ -10,6 +10,7 @@ export interface IServiceName {
export interface TopOperationQueryFactoryProps {
servicename: IServiceName['servicename'];
dotMetricsEnabled: boolean;
}
export interface ExternalCallDurationByAddressProps extends ExternalCallProps {
@@ -19,6 +20,7 @@ export interface ExternalCallDurationByAddressProps extends ExternalCallProps {
export interface ExternalCallProps {
servicename: IServiceName['servicename'];
tagFilterItems: TagFilterItem[];
dotMetricsEnabled: boolean;
}
export interface BuilderQueriesProps {
@@ -50,6 +52,7 @@ export interface OperationPerSecProps {
servicename: IServiceName['servicename'];
tagFilterItems: TagFilterItem[];
topLevelOperations: string[];
dotMetricsEnabled: boolean;
}
export interface LatencyProps {
@@ -57,6 +60,7 @@ export interface LatencyProps {
tagFilterItems: TagFilterItem[];
isSpanMetricEnable?: boolean;
topLevelOperationsRoute: string[];
dotMetricsEnabled: boolean;
}
export interface ApDexProps {
@@ -74,4 +78,5 @@ export interface TableRendererProps {
export interface ApDexMetricsQueryBuilderQueriesProps extends ApDexProps {
delta: boolean;
metricsBuckets: number[];
dotMetricsEnabled: boolean;
}

View File

@@ -7,7 +7,6 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import useClickOutside from 'hooks/useClickOutside';
import useResourceAttribute from 'hooks/useResourceAttribute';
import { resourceAttributesToTracesFilterItems } from 'hooks/useResourceAttribute/utils';
import createQueryParams from 'lib/createQueryParams';
import { prepareQueryWithDefaultTimestamp } from 'pages/LogsExplorer/utils';
import { traceFilterKeys } from 'pages/TracesExplorer/Filter/filterUtils';
import { Dispatch, SetStateAction, useMemo, useRef } from 'react';
@@ -15,11 +14,7 @@ import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
IBuilderQuery,
Query,
TagFilterItem,
} from 'types/api/queryBuilder/queryBuilderData';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { Tags } from 'types/reducer/trace';
import { secondsToMilliseconds } from 'utils/timeUtils';
@@ -45,16 +40,6 @@ interface OnViewTracePopupClickProps {
safeNavigate: (url: string) => void;
}
interface OnViewAPIMonitoringPopupClickProps {
servicename: string;
timestamp: number;
stepInterval?: number;
domainName: string;
isError: boolean;
safeNavigate: (url: string) => void;
}
export function generateExplorerPath(
isViewLogsClicked: boolean | undefined,
urlParams: URLSearchParams,
@@ -122,92 +107,14 @@ export function onViewTracePopupClick({
};
}
const generateAPIMonitoringPath = (
domainName: string,
startTime: number,
endTime: number,
filters: IBuilderQuery['filters'],
): string => {
const basePath = ROUTES.API_MONITORING;
return `${basePath}?${createQueryParams({
apiMonitoringParams: JSON.stringify({
selectedDomain: domainName,
selectedView: 'endpoint_stats',
modalTimeRange: {
startTime,
endTime,
},
selectedInterval: 'custom',
endPointDetailsLocalFilters: filters,
}),
})}`;
};
export function onViewAPIMonitoringPopupClick({
servicename,
timestamp,
domainName,
isError,
stepInterval,
safeNavigate,
}: OnViewAPIMonitoringPopupClickProps): VoidFunction {
return (): void => {
const endTime = timestamp + (stepInterval || 60);
const startTime = timestamp - (stepInterval || 60);
const filters = {
items: [
...(isError
? [
{
id: uuid().slice(0, 8),
key: {
key: 'hasError',
dataType: DataTypes.bool,
type: 'tag',
isColumn: true,
isJSON: false,
id: 'hasError--bool--tag--true',
},
op: 'in',
value: ['true'],
},
]
: []),
{
id: uuid().slice(0, 8),
key: {
key: 'service.name',
dataType: DataTypes.String,
type: 'resource',
isColumn: false,
isJSON: false,
},
op: '=',
value: servicename,
},
],
op: 'AND',
};
const newPath = generateAPIMonitoringPath(
domainName,
startTime,
endTime,
filters,
);
safeNavigate(newPath);
};
}
export function useGraphClickHandler(
setSelectedTimeStamp: (n: number) => void | Dispatch<SetStateAction<number>>,
setSelectedData?: (data: any) => void | Dispatch<SetStateAction<any>>,
): (
xValue: number,
yValue: number,
mouseX: number,
mouseY: number,
type: string,
data?: any,
) => Promise<void> {
const buttonRef = useRef<HTMLElement | null>(null);
@@ -227,7 +134,6 @@ export function useGraphClickHandler(
mouseX: number,
mouseY: number,
type: string,
data?: any,
): Promise<void> => {
const id = `${type}_button`;
const buttonElement = document.getElementById(id);
@@ -239,9 +145,6 @@ export function useGraphClickHandler(
buttonElement.style.left = `${mouseX}px`;
buttonElement.style.top = `${mouseY}px`;
setSelectedTimeStamp(Math.floor(xValue));
if (setSelectedData && data) {
setSelectedData(data);
}
}
} else if (buttonElement && buttonElement.style.display === 'block') {
buttonElement.style.display = 'none';

Some files were not shown because too many files have changed in this diff Show More