mirror of
https://github.com/SigNoz/signoz.git
synced 2026-02-05 01:22:12 +00:00
Compare commits
1 Commits
variable-u
...
tfbtest
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99cbd023b5 |
9
.github/workflows/goci.yaml
vendored
9
.github/workflows/goci.yaml
vendored
@@ -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')) ||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,3 @@ func NewDataConnector(
|
||||
ClickHouseReader: chReader,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ClickhouseReader) GetSQLStore() sqlstore.SQLStore {
|
||||
return r.appdb
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -311,6 +322,7 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
|
||||
apiHandler.RegisterMessagingQueuesRoutes(r, am)
|
||||
apiHandler.RegisterThirdPartyApiRoutes(r, am)
|
||||
apiHandler.MetricExplorerRoutes(r, am)
|
||||
apiHandler.RegisterTraceFunnelsRoutes(r, am)
|
||||
|
||||
c := cors.New(cors.Options{
|
||||
AllowedOrigins: []string{"*"},
|
||||
|
||||
10
ee/query-service/dao/factory.go
Normal file
10
ee/query-service/dao/factory.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
191
ee/query-service/dao/sqlite/auth.go
Normal file
191
ee/query-service/dao/sqlite/auth.go
Normal 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
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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))
|
||||
|
||||
12
ee/query-service/model/auth.go
Normal file
12
ee/query-service/model/auth.go
Normal 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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
31
ee/query-service/sso/model.go
Normal file
31
ee/query-service/sso/model.go
Normal 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)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package ssotypes
|
||||
package saml
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
@@ -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,
|
||||
@@ -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',
|
||||
{},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import NotFound from 'components/NotFound';
|
||||
import Spinner from 'components/Spinner';
|
||||
import UserpilotRouteTracker from 'components/UserpilotRouteTracker/UserpilotRouteTracker';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import ROUTES from 'constants/routes';
|
||||
@@ -70,14 +69,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 +105,7 @@ function App(): JSX.Element {
|
||||
|
||||
Userpilot.identify(email, {
|
||||
email,
|
||||
name: displayName,
|
||||
name,
|
||||
orgName,
|
||||
tenant_id: hostNameParts[0],
|
||||
data_region: hostNameParts[1],
|
||||
@@ -118,7 +117,7 @@ function App(): JSX.Element {
|
||||
|
||||
posthog?.identify(email, {
|
||||
email,
|
||||
name: displayName,
|
||||
name,
|
||||
orgName,
|
||||
tenant_id: hostNameParts[0],
|
||||
data_region: hostNameParts[1],
|
||||
@@ -144,7 +143,7 @@ function App(): JSX.Element {
|
||||
) {
|
||||
window.cioanalytics.reset();
|
||||
window.cioanalytics.identify(email, {
|
||||
name: user.displayName,
|
||||
name: user.name,
|
||||
email,
|
||||
role: user.role,
|
||||
});
|
||||
@@ -258,7 +257,7 @@ function App(): JSX.Element {
|
||||
window.Intercom('boot', {
|
||||
app_id: process.env.INTERCOM_APP_ID,
|
||||
email: user?.email || '',
|
||||
name: user?.displayName || '',
|
||||
name: user?.name || '',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -355,7 +354,6 @@ function App(): JSX.Element {
|
||||
<ConfigProvider theme={themeConfig}>
|
||||
<Router history={history}>
|
||||
<CompatRouter>
|
||||
<UserpilotRouteTracker />
|
||||
<NotificationProvider>
|
||||
<PrivateRoute>
|
||||
<ResourceProvider>
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
24
frontend/src/api/v1/invite/getPendingInvites.ts
Normal file
24
frontend/src/api/v1/invite/getPendingInvites.ts
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
24
frontend/src/api/v1/user/getOrgUser.ts
Normal file
24
frontend/src/api/v1/user/getOrgUser.ts
Normal 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;
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
28
frontend/src/api/v1/user/id/getRoles.ts
Normal file
28
frontend/src/api/v1/user/id/getRoles.ts
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
26
frontend/src/api/v1/user/id/updateRole.ts
Normal file
26
frontend/src/api/v1/user/id/updateRole.ts
Normal 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;
|
||||
26
frontend/src/api/v1/user/login.ts
Normal file
26
frontend/src/api/v1/user/login.ts
Normal 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;
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { Userpilot } from 'userpilot';
|
||||
|
||||
import UserpilotRouteTracker from './UserpilotRouteTracker';
|
||||
|
||||
// Mock constants
|
||||
const INITIAL_PATH = '/initial';
|
||||
const TIMER_DELAY = 100;
|
||||
|
||||
// Mock the userpilot module
|
||||
jest.mock('userpilot', () => ({
|
||||
Userpilot: {
|
||||
reload: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock location state
|
||||
let mockLocation = {
|
||||
pathname: INITIAL_PATH,
|
||||
search: '',
|
||||
hash: '',
|
||||
state: null,
|
||||
};
|
||||
|
||||
// Mock react-router-dom
|
||||
jest.mock('react-router-dom', () => {
|
||||
const originalModule = jest.requireActual('react-router-dom');
|
||||
|
||||
return {
|
||||
...originalModule,
|
||||
useLocation: jest.fn(() => mockLocation),
|
||||
};
|
||||
});
|
||||
|
||||
describe('UserpilotRouteTracker', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// Reset timers
|
||||
jest.useFakeTimers();
|
||||
// Reset error mock implementation
|
||||
(Userpilot.reload as jest.Mock).mockImplementation(() => {});
|
||||
// Reset location to initial state
|
||||
mockLocation = {
|
||||
pathname: INITIAL_PATH,
|
||||
search: '',
|
||||
hash: '',
|
||||
state: null,
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('calls Userpilot.reload on initial render', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<UserpilotRouteTracker />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// Fast-forward timer to trigger the setTimeout in reloadUserpilot
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(TIMER_DELAY);
|
||||
});
|
||||
|
||||
expect(Userpilot.reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls Userpilot.reload when pathname changes', () => {
|
||||
const { rerender } = render(
|
||||
<MemoryRouter>
|
||||
<UserpilotRouteTracker />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// Fast-forward initial render timer
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(TIMER_DELAY);
|
||||
});
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Create a new location object with different pathname
|
||||
const newLocation = {
|
||||
...mockLocation,
|
||||
pathname: '/new-path',
|
||||
};
|
||||
|
||||
// Update the mock location with new path and trigger re-render
|
||||
act(() => {
|
||||
mockLocation = newLocation;
|
||||
// Force a component update with the new location
|
||||
rerender(
|
||||
<MemoryRouter>
|
||||
<UserpilotRouteTracker />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
// Fast-forward timer to allow the setTimeout to execute
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(TIMER_DELAY);
|
||||
});
|
||||
|
||||
expect(Userpilot.reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls Userpilot.reload when search parameters change', () => {
|
||||
const { rerender } = render(
|
||||
<MemoryRouter>
|
||||
<UserpilotRouteTracker />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// Fast-forward initial render timer
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(TIMER_DELAY);
|
||||
});
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Create a new location object with different search params
|
||||
const newLocation = {
|
||||
...mockLocation,
|
||||
search: '?param=value',
|
||||
};
|
||||
|
||||
// Update the mock location with new search and trigger re-render
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
act(() => {
|
||||
mockLocation = newLocation;
|
||||
// Force a component update with the new location
|
||||
rerender(
|
||||
<MemoryRouter>
|
||||
<UserpilotRouteTracker />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
// Fast-forward timer to allow the setTimeout to execute
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(TIMER_DELAY);
|
||||
});
|
||||
|
||||
expect(Userpilot.reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('handles errors in Userpilot.reload gracefully', () => {
|
||||
// Mock console.error to prevent test output noise and capture calls
|
||||
const consoleErrorSpy = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
// Instead of using the component, we test the error handling behavior directly
|
||||
const errorMsg = 'Error message';
|
||||
|
||||
// Set up a function that has the same error handling behavior as in component
|
||||
const testErrorHandler = (): void => {
|
||||
try {
|
||||
if (typeof Userpilot !== 'undefined' && Userpilot.reload) {
|
||||
Userpilot.reload();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Userpilot] Error reloading on route change:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Make Userpilot.reload throw an error
|
||||
(Userpilot.reload as jest.Mock).mockImplementation(() => {
|
||||
throw new Error(errorMsg);
|
||||
});
|
||||
|
||||
// Execute the function that should handle errors
|
||||
testErrorHandler();
|
||||
|
||||
// Verify error was logged
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'[Userpilot] Error reloading on route change:',
|
||||
expect.any(Error),
|
||||
);
|
||||
|
||||
// Restore console mock
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('does not call Userpilot.reload when same route is rendered again', () => {
|
||||
const { rerender } = render(
|
||||
<MemoryRouter>
|
||||
<UserpilotRouteTracker />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// Fast-forward initial render timer
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(TIMER_DELAY);
|
||||
});
|
||||
jest.clearAllMocks();
|
||||
|
||||
act(() => {
|
||||
mockLocation = {
|
||||
pathname: mockLocation.pathname,
|
||||
search: mockLocation.search,
|
||||
hash: mockLocation.hash,
|
||||
state: mockLocation.state,
|
||||
};
|
||||
// Force a component update with the same location
|
||||
rerender(
|
||||
<MemoryRouter>
|
||||
<UserpilotRouteTracker />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
// Fast-forward timer
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(TIMER_DELAY);
|
||||
});
|
||||
|
||||
// Should not call reload since path and search are the same
|
||||
expect(Userpilot.reload).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Userpilot } from 'userpilot';
|
||||
|
||||
/**
|
||||
* UserpilotRouteTracker - A component that tracks route changes and calls Userpilot.reload
|
||||
* on actual page changes (pathname changes or significant query parameter changes).
|
||||
*
|
||||
* This component renders nothing and is designed to be placed once high in the component tree.
|
||||
*/
|
||||
function UserpilotRouteTracker(): null {
|
||||
const location = useLocation();
|
||||
const prevPathRef = useRef<string>(location.pathname);
|
||||
const prevSearchRef = useRef<string>(location.search);
|
||||
const isFirstRenderRef = useRef<boolean>(true);
|
||||
|
||||
// Function to reload Userpilot safely - using useCallback to avoid dependency issues
|
||||
const reloadUserpilot = useCallback((): void => {
|
||||
try {
|
||||
if (typeof Userpilot !== 'undefined' && Userpilot.reload) {
|
||||
setTimeout(() => {
|
||||
Userpilot.reload();
|
||||
}, 100);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Userpilot] Error reloading on route change:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle first render
|
||||
useEffect(() => {
|
||||
if (isFirstRenderRef.current) {
|
||||
isFirstRenderRef.current = false;
|
||||
reloadUserpilot();
|
||||
}
|
||||
}, [reloadUserpilot]);
|
||||
|
||||
// Handle route/query changes
|
||||
useEffect(() => {
|
||||
// Skip first render as it's handled by the effect above
|
||||
if (isFirstRenderRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the path has changed or if significant query params have changed
|
||||
const pathChanged = location.pathname !== prevPathRef.current;
|
||||
const searchChanged = location.search !== prevSearchRef.current;
|
||||
|
||||
if (pathChanged || searchChanged) {
|
||||
// Update refs
|
||||
prevPathRef.current = location.pathname;
|
||||
prevSearchRef.current = location.search;
|
||||
reloadUserpilot();
|
||||
}
|
||||
}, [location.pathname, location.search, reloadUserpilot]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default UserpilotRouteTracker;
|
||||
@@ -46,5 +46,4 @@ export enum QueryParams {
|
||||
msgSystem = 'msgSystem',
|
||||
destination = 'destination',
|
||||
kindString = 'kindString',
|
||||
variables = 'variables',
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -21,7 +21,6 @@ function MetricOverTimeGraph({
|
||||
onDragSelect={onDragSelect}
|
||||
customOnDragSelect={(): void => {}}
|
||||
customTimeRange={timeRange}
|
||||
customTimeRangeWindowForCoRelation="5m"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
},
|
||||
[
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -31,11 +31,10 @@ import { v4 as uuid } from 'uuid';
|
||||
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 +42,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);
|
||||
@@ -224,18 +223,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 +241,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 +266,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 +278,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 +306,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 +318,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 +343,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 +355,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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -6,7 +6,6 @@ import { isPasswordNotValidMessage, isPasswordValid } from 'pages/SignUp/utils';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { Password } from '../styles';
|
||||
|
||||
@@ -45,22 +44,36 @@ function PasswordContainer(): JSX.Element {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
await changeMyPassword({
|
||||
|
||||
const { statusCode, error } = await changeMyPassword({
|
||||
newPassword: updatePassword,
|
||||
oldPassword: currentPassword,
|
||||
userId: user.id,
|
||||
});
|
||||
notifications.success({
|
||||
message: t('success', {
|
||||
ns: 'common',
|
||||
}),
|
||||
});
|
||||
|
||||
if (statusCode === 200) {
|
||||
notifications.success({
|
||||
message: t('success', {
|
||||
ns: 'common',
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
notifications.error({
|
||||
message:
|
||||
error ||
|
||||
t('something_went_wrong', {
|
||||
ns: 'common',
|
||||
}),
|
||||
});
|
||||
}
|
||||
setIsLoading(false);
|
||||
} catch (error) {
|
||||
setIsLoading(false);
|
||||
|
||||
notifications.error({
|
||||
message: (error as APIError).error.error.code,
|
||||
description: (error as APIError).error.error.message,
|
||||
message: t('something_went_wrong', {
|
||||
ns: 'common',
|
||||
}),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -8,7 +8,6 @@ import { PencilIcon } from 'lucide-react';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { NameInput } from '../styles';
|
||||
|
||||
@@ -16,9 +15,7 @@ function UserInfo(): JSX.Element {
|
||||
const { user, org, updateUser } = useAppContext();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [changedName, setChangedName] = useState<string>(
|
||||
user?.displayName || '',
|
||||
);
|
||||
const [changedName, setChangedName] = useState<string>(user?.name || '');
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const { notifications } = useNotifications();
|
||||
@@ -30,25 +27,34 @@ function UserInfo(): JSX.Element {
|
||||
const onClickUpdateHandler = async (): Promise<void> => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await editUser({
|
||||
displayName: changedName,
|
||||
const { statusCode } = await editUser({
|
||||
name: changedName,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
notifications.success({
|
||||
message: t('success', {
|
||||
ns: 'common',
|
||||
}),
|
||||
});
|
||||
updateUser({
|
||||
...user,
|
||||
displayName: changedName,
|
||||
});
|
||||
if (statusCode === 200) {
|
||||
notifications.success({
|
||||
message: t('success', {
|
||||
ns: 'common',
|
||||
}),
|
||||
});
|
||||
updateUser({
|
||||
...user,
|
||||
name: changedName,
|
||||
});
|
||||
} else {
|
||||
notifications.error({
|
||||
message: t('something_went_wrong', {
|
||||
ns: 'common',
|
||||
}),
|
||||
});
|
||||
}
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
notifications.error({
|
||||
message: (error as APIError).getErrorCode(),
|
||||
description: (error as APIError).getErrorMessage(),
|
||||
message: t('something_went_wrong', {
|
||||
ns: 'common',
|
||||
}),
|
||||
});
|
||||
}
|
||||
setLoading(false);
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
.dynamic-variable-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 32px 200px;
|
||||
gap: 32px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
margin: 24px 0;
|
||||
|
||||
.ant-select {
|
||||
.ant-select-selector {
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-input {
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.dynamic-variable-from-text {
|
||||
font-family: 'Space Mono';
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.dynamic-variable-container {
|
||||
.ant-select {
|
||||
.ant-select-selector {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-input {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
import './DynamicVariable.styles.scss';
|
||||
|
||||
import { Select, Typography } from 'antd';
|
||||
import CustomSelect from 'components/NewSelect/CustomSelect';
|
||||
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
|
||||
import { useGetFieldKeys } from 'hooks/dynamicVariables/useGetFieldKeys';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
import {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { FieldKey } from 'types/api/dynamicVariables/getFieldKeys';
|
||||
|
||||
enum AttributeSource {
|
||||
ALL_SOURCES = 'All Sources',
|
||||
LOGS = 'Logs',
|
||||
METRICS = 'Metrics',
|
||||
TRACES = 'Traces',
|
||||
}
|
||||
|
||||
function DynamicVariable({
|
||||
setDynamicVariablesSelectedValue,
|
||||
dynamicVariablesSelectedValue,
|
||||
}: {
|
||||
setDynamicVariablesSelectedValue: Dispatch<
|
||||
SetStateAction<
|
||||
| {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
| undefined
|
||||
>
|
||||
>;
|
||||
dynamicVariablesSelectedValue:
|
||||
| {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
| undefined;
|
||||
}): JSX.Element {
|
||||
const sources = [
|
||||
AttributeSource.ALL_SOURCES,
|
||||
AttributeSource.LOGS,
|
||||
AttributeSource.TRACES,
|
||||
AttributeSource.METRICS,
|
||||
];
|
||||
|
||||
const [attributeSource, setAttributeSource] = useState<AttributeSource>();
|
||||
const [attributes, setAttributes] = useState<Record<string, FieldKey[]>>({});
|
||||
const [selectedAttribute, setSelectedAttribute] = useState<string>();
|
||||
const [apiSearchText, setApiSearchText] = useState<string>('');
|
||||
|
||||
const debouncedApiSearchText = useDebounce(apiSearchText, DEBOUNCE_DELAY);
|
||||
|
||||
const [filteredAttributes, setFilteredAttributes] = useState<
|
||||
Record<string, FieldKey[]>
|
||||
>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (dynamicVariablesSelectedValue?.name) {
|
||||
setSelectedAttribute(dynamicVariablesSelectedValue.name);
|
||||
}
|
||||
|
||||
if (dynamicVariablesSelectedValue?.value) {
|
||||
setAttributeSource(dynamicVariablesSelectedValue.value as AttributeSource);
|
||||
}
|
||||
}, [
|
||||
dynamicVariablesSelectedValue?.name,
|
||||
dynamicVariablesSelectedValue?.value,
|
||||
]);
|
||||
|
||||
const { data, error, isLoading, refetch } = useGetFieldKeys({
|
||||
signal:
|
||||
attributeSource === AttributeSource.ALL_SOURCES
|
||||
? undefined
|
||||
: (attributeSource?.toLowerCase() as 'traces' | 'logs' | 'metrics'),
|
||||
name: debouncedApiSearchText,
|
||||
});
|
||||
|
||||
const isComplete = useMemo(() => data?.payload?.complete === true, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
const newAttributes = data.payload?.keys ?? {};
|
||||
setAttributes(newAttributes);
|
||||
setFilteredAttributes(newAttributes);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
// refetch when attributeSource changes
|
||||
useEffect(() => {
|
||||
if (attributeSource) {
|
||||
refetch();
|
||||
}
|
||||
}, [attributeSource, refetch, debouncedApiSearchText]);
|
||||
|
||||
// Handle search based on whether we have complete data or not
|
||||
const handleSearch = useCallback(
|
||||
(text: string) => {
|
||||
if (isComplete) {
|
||||
// If complete is true, do client-side filtering
|
||||
if (!text) {
|
||||
setFilteredAttributes(attributes);
|
||||
return;
|
||||
}
|
||||
|
||||
const filtered: Record<string, FieldKey[]> = {};
|
||||
Object.keys(attributes).forEach((key) => {
|
||||
if (key.toLowerCase().includes(text.toLowerCase())) {
|
||||
filtered[key] = attributes[key];
|
||||
}
|
||||
});
|
||||
setFilteredAttributes(filtered);
|
||||
} else {
|
||||
// If complete is false, debounce the API call
|
||||
setApiSearchText(text);
|
||||
}
|
||||
},
|
||||
[attributes, isComplete],
|
||||
);
|
||||
|
||||
// update setDynamicVariablesSelectedValue with debounce when attribute and source is selected
|
||||
useEffect(() => {
|
||||
if (selectedAttribute || attributeSource) {
|
||||
setDynamicVariablesSelectedValue({
|
||||
name: selectedAttribute || dynamicVariablesSelectedValue?.name || '',
|
||||
value:
|
||||
attributeSource ||
|
||||
dynamicVariablesSelectedValue?.value ||
|
||||
AttributeSource.ALL_SOURCES,
|
||||
});
|
||||
}
|
||||
}, [
|
||||
selectedAttribute,
|
||||
attributeSource,
|
||||
setDynamicVariablesSelectedValue,
|
||||
dynamicVariablesSelectedValue?.name,
|
||||
dynamicVariablesSelectedValue?.value,
|
||||
]);
|
||||
|
||||
const errorMessage = (error as any)?.message;
|
||||
return (
|
||||
<div className="dynamic-variable-container">
|
||||
<CustomSelect
|
||||
placeholder="Select an Attribute"
|
||||
options={Object.keys(filteredAttributes).map((key) => ({
|
||||
label: key,
|
||||
value: key,
|
||||
}))}
|
||||
loading={isLoading}
|
||||
status={errorMessage ? 'error' : undefined}
|
||||
onChange={(value): void => {
|
||||
setSelectedAttribute(value);
|
||||
}}
|
||||
showSearch
|
||||
errorMessage={errorMessage as any}
|
||||
value={selectedAttribute || dynamicVariablesSelectedValue?.name}
|
||||
onSearch={handleSearch}
|
||||
onRetry={(): void => {
|
||||
refetch();
|
||||
}}
|
||||
/>
|
||||
<Typography className="dynamic-variable-from-text">from</Typography>
|
||||
<Select
|
||||
placeholder="Source"
|
||||
defaultValue={AttributeSource.ALL_SOURCES}
|
||||
options={sources.map((source) => ({ label: source, value: source }))}
|
||||
onChange={(value): void => setAttributeSource(value as AttributeSource)}
|
||||
value={attributeSource || dynamicVariablesSelectedValue?.value}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DynamicVariable;
|
||||
@@ -1,376 +0,0 @@
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { useGetFieldKeys } from 'hooks/dynamicVariables/useGetFieldKeys';
|
||||
|
||||
import DynamicVariable from '../DynamicVariable';
|
||||
|
||||
// Mock scrollIntoView since it's not available in JSDOM
|
||||
window.HTMLElement.prototype.scrollIntoView = jest.fn();
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('hooks/dynamicVariables/useGetFieldKeys', () => ({
|
||||
useGetFieldKeys: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useDebounce', () => ({
|
||||
__esModule: true,
|
||||
default: (value: any): any => value, // Return the same value without debouncing for testing
|
||||
}));
|
||||
|
||||
describe('DynamicVariable Component', () => {
|
||||
const mockSetDynamicVariablesSelectedValue = jest.fn();
|
||||
const ATTRIBUTE_PLACEHOLDER = 'Select an Attribute';
|
||||
const LOADING_TEXT = 'We are updating the values...';
|
||||
const DEFAULT_PROPS = {
|
||||
setDynamicVariablesSelectedValue: mockSetDynamicVariablesSelectedValue,
|
||||
dynamicVariablesSelectedValue: undefined,
|
||||
};
|
||||
|
||||
const mockFieldKeysResponse = {
|
||||
payload: {
|
||||
keys: {
|
||||
'service.name': [],
|
||||
'http.status_code': [],
|
||||
duration: [],
|
||||
},
|
||||
complete: true,
|
||||
},
|
||||
statusCode: 200,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Default mock implementation
|
||||
(useGetFieldKeys as jest.Mock).mockReturnValue({
|
||||
data: mockFieldKeysResponse,
|
||||
error: null,
|
||||
isLoading: false,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
// Helper function to get the attribute select element
|
||||
const getAttributeSelect = (): HTMLElement =>
|
||||
screen.getAllByRole('combobox')[0];
|
||||
|
||||
// Helper function to get the source select element
|
||||
const getSourceSelect = (): HTMLElement => screen.getAllByRole('combobox')[1];
|
||||
|
||||
it('renders with default state', () => {
|
||||
render(<DynamicVariable {...DEFAULT_PROPS} />);
|
||||
|
||||
// Check for main components
|
||||
expect(screen.getByText(ATTRIBUTE_PLACEHOLDER)).toBeInTheDocument();
|
||||
expect(screen.getByText('All Sources')).toBeInTheDocument();
|
||||
expect(screen.getByText('from')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses existing values from dynamicVariablesSelectedValue prop', () => {
|
||||
const selectedValue = {
|
||||
name: 'service.name',
|
||||
value: 'Logs',
|
||||
};
|
||||
|
||||
render(
|
||||
<DynamicVariable
|
||||
setDynamicVariablesSelectedValue={mockSetDynamicVariablesSelectedValue}
|
||||
dynamicVariablesSelectedValue={selectedValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Verify values are set
|
||||
expect(screen.getByText('service.name')).toBeInTheDocument();
|
||||
expect(screen.getByText('Logs')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows loading state when fetching data', () => {
|
||||
(useGetFieldKeys as jest.Mock).mockReturnValue({
|
||||
data: null,
|
||||
error: null,
|
||||
isLoading: true,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<DynamicVariable {...DEFAULT_PROPS} />);
|
||||
|
||||
// Open the CustomSelect dropdown
|
||||
const attributeSelectElement = getAttributeSelect();
|
||||
fireEvent.mouseDown(attributeSelectElement);
|
||||
|
||||
// Should show loading state
|
||||
expect(screen.getByText(LOADING_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error message when API fails', () => {
|
||||
const errorMessage = 'Failed to fetch field keys';
|
||||
|
||||
(useGetFieldKeys as jest.Mock).mockReturnValue({
|
||||
data: null,
|
||||
error: { message: errorMessage },
|
||||
isLoading: false,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<DynamicVariable {...DEFAULT_PROPS} />);
|
||||
|
||||
// Open the CustomSelect dropdown
|
||||
const attributeSelectElement = getAttributeSelect();
|
||||
fireEvent.mouseDown(attributeSelectElement);
|
||||
|
||||
// Should show error message
|
||||
expect(screen.getByText(errorMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates filteredAttributes when data is loaded', async () => {
|
||||
render(<DynamicVariable {...DEFAULT_PROPS} />);
|
||||
|
||||
// Open the CustomSelect dropdown
|
||||
const attributeSelectElement = getAttributeSelect();
|
||||
fireEvent.mouseDown(attributeSelectElement);
|
||||
|
||||
// Wait for options to appear in the dropdown
|
||||
await waitFor(() => {
|
||||
// Looking for option-content elements inside the CustomSelect dropdown
|
||||
const options = document.querySelectorAll('.option-content');
|
||||
expect(options.length).toBeGreaterThan(0);
|
||||
|
||||
// Check if all expected options are present
|
||||
let foundServiceName = false;
|
||||
let foundHttpStatusCode = false;
|
||||
let foundDuration = false;
|
||||
|
||||
options.forEach((option) => {
|
||||
const text = option.textContent?.trim();
|
||||
if (text === 'service.name') foundServiceName = true;
|
||||
if (text === 'http.status_code') foundHttpStatusCode = true;
|
||||
if (text === 'duration') foundDuration = true;
|
||||
});
|
||||
|
||||
expect(foundServiceName).toBe(true);
|
||||
expect(foundHttpStatusCode).toBe(true);
|
||||
expect(foundDuration).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls setDynamicVariablesSelectedValue when attribute is selected', async () => {
|
||||
render(<DynamicVariable {...DEFAULT_PROPS} />);
|
||||
|
||||
// Open the attribute dropdown
|
||||
const attributeSelectElement = getAttributeSelect();
|
||||
fireEvent.mouseDown(attributeSelectElement);
|
||||
|
||||
// Wait for options to appear, then click on service.name
|
||||
await waitFor(() => {
|
||||
// Need to find the option-item containing service.name
|
||||
const serviceNameOption = screen.getByText('service.name');
|
||||
expect(serviceNameOption).not.toBeNull();
|
||||
expect(serviceNameOption?.textContent).toBe('service.name');
|
||||
|
||||
// Click on the option-item that contains service.name
|
||||
const optionElement = serviceNameOption?.closest('.option-item');
|
||||
if (optionElement) {
|
||||
fireEvent.click(optionElement);
|
||||
}
|
||||
});
|
||||
|
||||
// Check if the setter was called with the correct value
|
||||
expect(mockSetDynamicVariablesSelectedValue).toHaveBeenCalledWith({
|
||||
name: 'service.name',
|
||||
value: 'All Sources',
|
||||
});
|
||||
});
|
||||
|
||||
it('calls setDynamicVariablesSelectedValue when source is selected', () => {
|
||||
const mockRefetch = jest.fn();
|
||||
|
||||
(useGetFieldKeys as jest.Mock).mockReturnValue({
|
||||
data: mockFieldKeysResponse,
|
||||
error: null,
|
||||
isLoading: false,
|
||||
refetch: mockRefetch,
|
||||
});
|
||||
|
||||
render(<DynamicVariable {...DEFAULT_PROPS} />);
|
||||
|
||||
// Get the Select component
|
||||
const select = screen
|
||||
.getByText('All Sources')
|
||||
.closest('div[class*="ant-select"]');
|
||||
expect(select).toBeInTheDocument();
|
||||
|
||||
// Directly call the onChange handler by simulating the Select's onChange
|
||||
// Find the props.onChange of the Select component and call it directly
|
||||
fireEvent.mouseDown(select as HTMLElement);
|
||||
|
||||
// Use a more specific selector to find the "Logs" option
|
||||
const optionsContainer = document.querySelector(
|
||||
'.rc-virtual-list-holder-inner',
|
||||
);
|
||||
expect(optionsContainer).not.toBeNull();
|
||||
|
||||
// Find the option with Logs text content
|
||||
const logsOption = Array.from(
|
||||
optionsContainer?.querySelectorAll('.ant-select-item-option-content') || [],
|
||||
)
|
||||
.find((element) => element.textContent === 'Logs')
|
||||
?.closest('.ant-select-item-option');
|
||||
|
||||
expect(logsOption).not.toBeNull();
|
||||
|
||||
// Click on it
|
||||
if (logsOption) {
|
||||
fireEvent.click(logsOption);
|
||||
}
|
||||
|
||||
// Check if the setter was called with the correct value
|
||||
expect(mockSetDynamicVariablesSelectedValue).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
value: 'Logs',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('filters attributes locally when complete is true', async () => {
|
||||
render(<DynamicVariable {...DEFAULT_PROPS} />);
|
||||
|
||||
// Open the attribute dropdown
|
||||
const attributeSelectElement = getAttributeSelect();
|
||||
fireEvent.mouseDown(attributeSelectElement);
|
||||
|
||||
// Mock the filter function behavior
|
||||
const attributeKeys = Object.keys(mockFieldKeysResponse.payload.keys);
|
||||
|
||||
// Only "http.status_code" should match the filter
|
||||
const expectedFilteredKeys = attributeKeys.filter((key) =>
|
||||
key.includes('http'),
|
||||
);
|
||||
|
||||
// Verify our expected filtering logic
|
||||
expect(expectedFilteredKeys).toContain('http.status_code');
|
||||
expect(expectedFilteredKeys).not.toContain('service.name');
|
||||
expect(expectedFilteredKeys).not.toContain('duration');
|
||||
|
||||
// Now verify the component's filtering ability by inputting the search text
|
||||
const inputElement = screen
|
||||
.getAllByRole('combobox')[0]
|
||||
.querySelector('input');
|
||||
if (inputElement) {
|
||||
fireEvent.change(inputElement, { target: { value: 'http' } });
|
||||
}
|
||||
});
|
||||
|
||||
it('triggers API call when complete is false and search text changes', async () => {
|
||||
const mockRefetch = jest.fn();
|
||||
|
||||
// Set up the mock to indicate that data is not complete
|
||||
// and needs to be fetched from the server
|
||||
(useGetFieldKeys as jest.Mock).mockReturnValue({
|
||||
data: {
|
||||
payload: {
|
||||
keys: {
|
||||
'http.status_code': [],
|
||||
},
|
||||
complete: false, // This indicates server-side filtering is needed
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
isLoading: false,
|
||||
refetch: mockRefetch,
|
||||
});
|
||||
|
||||
// Render with Logs as the initial source
|
||||
render(
|
||||
<DynamicVariable
|
||||
{...DEFAULT_PROPS}
|
||||
dynamicVariablesSelectedValue={{
|
||||
name: '',
|
||||
value: 'Logs',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Clear any initial calls
|
||||
mockRefetch.mockClear();
|
||||
|
||||
// Now test the search functionality
|
||||
const attributeSelectElement = getAttributeSelect();
|
||||
fireEvent.mouseDown(attributeSelectElement);
|
||||
|
||||
// Find the input element and simulate typing
|
||||
const inputElement = document.querySelector(
|
||||
'.ant-select-selection-search-input',
|
||||
);
|
||||
|
||||
if (inputElement) {
|
||||
// Simulate typing in the search input
|
||||
fireEvent.change(inputElement, { target: { value: 'http' } });
|
||||
|
||||
// Verify that the input has the correct value
|
||||
expect((inputElement as HTMLInputElement).value).toBe('http');
|
||||
|
||||
// Wait for the effect to run and verify refetch was called
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(mockRefetch).toHaveBeenCalled();
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
); // Increase timeout to give more time for the effect to run
|
||||
}
|
||||
});
|
||||
|
||||
it('triggers refetch when attributeSource changes', async () => {
|
||||
const mockRefetch = jest.fn();
|
||||
|
||||
(useGetFieldKeys as jest.Mock).mockReturnValue({
|
||||
data: mockFieldKeysResponse,
|
||||
error: null,
|
||||
isLoading: false,
|
||||
refetch: mockRefetch,
|
||||
});
|
||||
|
||||
render(<DynamicVariable {...DEFAULT_PROPS} />);
|
||||
|
||||
// Clear any initial calls
|
||||
mockRefetch.mockClear();
|
||||
|
||||
// Find and click on the source select to open dropdown
|
||||
const sourceSelectElement = getSourceSelect();
|
||||
fireEvent.mouseDown(sourceSelectElement);
|
||||
|
||||
// Find and click on the "Metrics" option
|
||||
const metricsOption = screen.getByText('Metrics');
|
||||
fireEvent.click(metricsOption);
|
||||
|
||||
// Wait for the effect to run
|
||||
await waitFor(() => {
|
||||
// Verify that refetch was called after source selection
|
||||
expect(mockRefetch).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows retry button when error occurs', () => {
|
||||
const mockRefetch = jest.fn();
|
||||
|
||||
(useGetFieldKeys as jest.Mock).mockReturnValue({
|
||||
data: null,
|
||||
error: { message: 'Failed to fetch field keys' },
|
||||
isLoading: false,
|
||||
refetch: mockRefetch,
|
||||
});
|
||||
|
||||
render(<DynamicVariable {...DEFAULT_PROPS} />);
|
||||
|
||||
// Open the attribute dropdown
|
||||
const attributeSelectElement = getAttributeSelect();
|
||||
fireEvent.mouseDown(attributeSelectElement);
|
||||
|
||||
// Find and click reload icon (retry button)
|
||||
const reloadIcon = screen.getByLabelText('reload');
|
||||
fireEvent.click(reloadIcon);
|
||||
|
||||
// Should trigger refetch
|
||||
expect(mockRefetch).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -100,6 +100,7 @@
|
||||
|
||||
.variable-type-btn-group {
|
||||
display: flex;
|
||||
width: 342px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 2px;
|
||||
@@ -198,21 +199,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.default-value-section {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
|
||||
.default-value-description {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: Inter;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.06px;
|
||||
}
|
||||
}
|
||||
|
||||
.variable-textbox-section {
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0;
|
||||
|
||||
@@ -6,9 +6,7 @@ import { Button, Collapse, Input, Select, Switch, Tag, Typography } from 'antd';
|
||||
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
|
||||
import cx from 'classnames';
|
||||
import Editor from 'components/Editor';
|
||||
import { CustomSelect } from 'components/NewSelect';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useGetFieldValues } from 'hooks/dynamicVariables/useGetFieldValues';
|
||||
import { commaValuesParser } from 'lib/dashbaordVariables/customCommaValuesParser';
|
||||
import sortValues from 'lib/dashbaordVariables/sortVariableValues';
|
||||
import { map } from 'lodash-es';
|
||||
@@ -18,25 +16,20 @@ import {
|
||||
ClipboardType,
|
||||
DatabaseZap,
|
||||
LayoutList,
|
||||
Pyramid,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
import {
|
||||
IDashboardVariable,
|
||||
TSortVariableValuesType,
|
||||
TVariableQueryType,
|
||||
VariableSortTypeArr,
|
||||
} from 'types/api/dashboard/getAll';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { v4 as generateUUID } from 'uuid';
|
||||
|
||||
import { variablePropsToPayloadVariables } from '../../../utils';
|
||||
import { TVariableMode } from '../types';
|
||||
import DynamicVariable from './DynamicVariable/DynamicVariable';
|
||||
import { LabelContainer, VariableItemRow } from './styles';
|
||||
|
||||
const { Option } = Select;
|
||||
@@ -64,7 +57,7 @@ function VariableItem({
|
||||
variableData.description || '',
|
||||
);
|
||||
const [queryType, setQueryType] = useState<TVariableQueryType>(
|
||||
variableData.type || 'DYNAMIC',
|
||||
variableData.type || 'QUERY',
|
||||
);
|
||||
const [variableQueryValue, setVariableQueryValue] = useState<string>(
|
||||
variableData.queryValue || '',
|
||||
@@ -88,53 +81,11 @@ function VariableItem({
|
||||
variableData.showALLOption || false,
|
||||
);
|
||||
const [previewValues, setPreviewValues] = useState<string[]>([]);
|
||||
const [variableDefaultValue, setVariableDefaultValue] = useState<string>(
|
||||
(variableData.defaultValue as string) || '',
|
||||
);
|
||||
|
||||
const [
|
||||
dynamicVariablesSelectedValue,
|
||||
setDynamicVariablesSelectedValue,
|
||||
] = useState<{ name: string; value: string }>();
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
variableData.dynamicVariablesAttribute &&
|
||||
variableData.dynamicVariablesSource
|
||||
) {
|
||||
setDynamicVariablesSelectedValue({
|
||||
name: variableData.dynamicVariablesAttribute,
|
||||
value: variableData.dynamicVariablesSource,
|
||||
});
|
||||
}
|
||||
}, [
|
||||
variableData.dynamicVariablesAttribute,
|
||||
variableData.dynamicVariablesSource,
|
||||
]);
|
||||
// Error messages
|
||||
const [errorName, setErrorName] = useState<boolean>(false);
|
||||
const [errorPreview, setErrorPreview] = useState<string | null>(null);
|
||||
|
||||
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
const { data: fieldValues } = useGetFieldValues({
|
||||
signal:
|
||||
dynamicVariablesSelectedValue?.value === 'All Sources'
|
||||
? undefined
|
||||
: (dynamicVariablesSelectedValue?.value?.toLowerCase() as
|
||||
| 'traces'
|
||||
| 'logs'
|
||||
| 'metrics'),
|
||||
name: dynamicVariablesSelectedValue?.name || '',
|
||||
enabled:
|
||||
!!dynamicVariablesSelectedValue?.name &&
|
||||
!!dynamicVariablesSelectedValue?.value,
|
||||
startUnixMilli: minTime,
|
||||
endUnixMilli: maxTime,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (queryType === 'CUSTOM') {
|
||||
setPreviewValues(
|
||||
@@ -155,29 +106,6 @@ function VariableItem({
|
||||
variableSortType,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
queryType === 'DYNAMIC' &&
|
||||
fieldValues &&
|
||||
dynamicVariablesSelectedValue?.name &&
|
||||
dynamicVariablesSelectedValue?.value
|
||||
) {
|
||||
setPreviewValues(
|
||||
sortValues(
|
||||
fieldValues.payload?.normalizedValues || [],
|
||||
variableSortType,
|
||||
) as never,
|
||||
);
|
||||
}
|
||||
}, [
|
||||
fieldValues,
|
||||
variableSortType,
|
||||
queryType,
|
||||
dynamicVariablesSelectedValue?.name,
|
||||
dynamicVariablesSelectedValue?.value,
|
||||
dynamicVariablesSelectedValue,
|
||||
]);
|
||||
|
||||
const handleSave = (): void => {
|
||||
const variable: IDashboardVariable = {
|
||||
name: variableName,
|
||||
@@ -193,16 +121,9 @@ function VariableItem({
|
||||
selectedValue: (variableData.selectedValue ||
|
||||
variableTextboxValue) as never,
|
||||
}),
|
||||
...(queryType !== 'TEXTBOX' && {
|
||||
defaultValue: variableDefaultValue as never,
|
||||
}),
|
||||
modificationUUID: generateUUID(),
|
||||
id: variableData.id || generateUUID(),
|
||||
order: variableData.order,
|
||||
...(queryType === 'DYNAMIC' && {
|
||||
dynamicVariablesAttribute: dynamicVariablesSelectedValue?.name,
|
||||
dynamicVariablesSource: dynamicVariablesSelectedValue?.value,
|
||||
}),
|
||||
};
|
||||
|
||||
onSave(mode, variable);
|
||||
@@ -318,18 +239,18 @@ function VariableItem({
|
||||
<div className="variable-type-btn-group">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<Pyramid size={14} />}
|
||||
icon={<DatabaseZap size={14} />}
|
||||
className={cx(
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
'variable-type-btn',
|
||||
queryType === 'DYNAMIC' ? 'selected' : '',
|
||||
queryType === 'QUERY' ? 'selected' : '',
|
||||
)}
|
||||
onClick={(): void => {
|
||||
setQueryType('DYNAMIC');
|
||||
setQueryType('QUERY');
|
||||
setPreviewValues([]);
|
||||
}}
|
||||
>
|
||||
Dynamic
|
||||
Query
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
@@ -359,31 +280,8 @@ function VariableItem({
|
||||
>
|
||||
Custom
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<DatabaseZap size={14} />}
|
||||
className={cx(
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
'variable-type-btn',
|
||||
queryType === 'QUERY' ? 'selected' : '',
|
||||
)}
|
||||
onClick={(): void => {
|
||||
setQueryType('QUERY');
|
||||
setPreviewValues([]);
|
||||
}}
|
||||
>
|
||||
Query
|
||||
</Button>
|
||||
</div>
|
||||
</VariableItemRow>
|
||||
{queryType === 'DYNAMIC' && (
|
||||
<div className="variable-dynamic-section">
|
||||
<DynamicVariable
|
||||
setDynamicVariablesSelectedValue={setDynamicVariablesSelectedValue}
|
||||
dynamicVariablesSelectedValue={dynamicVariablesSelectedValue}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{queryType === 'QUERY' && (
|
||||
<div className="query-container">
|
||||
<LabelContainer>
|
||||
@@ -471,9 +369,7 @@ function VariableItem({
|
||||
/>
|
||||
</VariableItemRow>
|
||||
)}
|
||||
{(queryType === 'QUERY' ||
|
||||
queryType === 'CUSTOM' ||
|
||||
queryType === 'DYNAMIC') && (
|
||||
{(queryType === 'QUERY' || queryType === 'CUSTOM') && (
|
||||
<>
|
||||
<VariableItemRow className="variables-preview-section">
|
||||
<LabelContainer style={{ width: '100%' }}>
|
||||
@@ -542,25 +438,6 @@ function VariableItem({
|
||||
/>
|
||||
</VariableItemRow>
|
||||
)}
|
||||
<VariableItemRow className="default-value-section">
|
||||
<LabelContainer>
|
||||
<Typography className="typography-variables">Default Value</Typography>
|
||||
<Typography className="default-value-description">
|
||||
{queryType === 'QUERY'
|
||||
? 'Click Test Run Query to see the values or add custom value'
|
||||
: 'Select a value from the preview values or add custom value'}
|
||||
</Typography>
|
||||
</LabelContainer>
|
||||
<CustomSelect
|
||||
placeholder="Select a default value"
|
||||
value={variableDefaultValue}
|
||||
onChange={(value): void => setVariableDefaultValue(value)}
|
||||
options={previewValues.map((value) => ({
|
||||
label: value,
|
||||
value,
|
||||
}))}
|
||||
/>
|
||||
</VariableItemRow>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -18,11 +18,6 @@
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 16px; /* 133.333% */
|
||||
|
||||
.info-icon {
|
||||
margin-left: 4px;
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
}
|
||||
|
||||
.variable-value {
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import { Row } from 'antd';
|
||||
import { ALL_SELECTED_VALUE } from 'components/NewSelect/utils';
|
||||
import useVariablesFromUrl from 'hooks/dashboard/useVariablesFromUrl';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { initializeDefaultVariables } from 'providers/Dashboard/initializeDefaultVariables';
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import DynamicVariableSelection from './DynamicVariableSelection';
|
||||
import {
|
||||
buildDependencies,
|
||||
buildDependencyGraph,
|
||||
@@ -29,8 +25,6 @@ function DashboardVariableSelection(): JSX.Element | null {
|
||||
setVariablesToGetUpdated,
|
||||
} = useDashboard();
|
||||
|
||||
const { updateUrlVariable, getUrlVariables } = useVariablesFromUrl();
|
||||
|
||||
const { data } = selectedDashboard || {};
|
||||
|
||||
const { variables } = data || {};
|
||||
@@ -64,11 +58,8 @@ function DashboardVariableSelection(): JSX.Element | null {
|
||||
tableRowData.sort((a, b) => a.order - b.order);
|
||||
|
||||
setVariablesTableData(tableRowData);
|
||||
|
||||
// Initialize variables with default values if not in URL
|
||||
initializeDefaultVariables(variables, getUrlVariables, updateUrlVariable);
|
||||
}
|
||||
}, [getUrlVariables, updateUrlVariable, variables]);
|
||||
}, [variables]);
|
||||
|
||||
useEffect(() => {
|
||||
if (variablesTableData.length > 0) {
|
||||
@@ -109,18 +100,12 @@ function DashboardVariableSelection(): JSX.Element | null {
|
||||
id: string,
|
||||
value: IDashboardVariable['selectedValue'],
|
||||
allSelected: boolean,
|
||||
haveCustomValuesSelected?: boolean,
|
||||
// isMountedCall?: boolean,
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
): void => {
|
||||
if (id) {
|
||||
updateLocalStorageDashboardVariables(name, value, allSelected);
|
||||
|
||||
if (allSelected) {
|
||||
updateUrlVariable(name || id, ALL_SELECTED_VALUE);
|
||||
} else {
|
||||
updateUrlVariable(name || id, value);
|
||||
}
|
||||
|
||||
if (selectedDashboard) {
|
||||
setSelectedDashboard((prev) => {
|
||||
if (prev) {
|
||||
@@ -132,7 +117,6 @@ function DashboardVariableSelection(): JSX.Element | null {
|
||||
...oldVariables[id],
|
||||
selectedValue: value,
|
||||
allSelected,
|
||||
haveCustomValuesSelected,
|
||||
};
|
||||
}
|
||||
if (oldVariables[name]) {
|
||||
@@ -140,7 +124,6 @@ function DashboardVariableSelection(): JSX.Element | null {
|
||||
...oldVariables[name],
|
||||
selectedValue: value,
|
||||
allSelected,
|
||||
haveCustomValuesSelected,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -187,32 +170,20 @@ function DashboardVariableSelection(): JSX.Element | null {
|
||||
{orderBasedSortedVariables &&
|
||||
Array.isArray(orderBasedSortedVariables) &&
|
||||
orderBasedSortedVariables.length > 0 &&
|
||||
orderBasedSortedVariables.map((variable) =>
|
||||
variable.type === 'DYNAMIC' ? (
|
||||
<DynamicVariableSelection
|
||||
key={`${variable.name}${variable.id}}${variable.order}`}
|
||||
existingVariables={variables}
|
||||
variableData={{
|
||||
name: variable.name,
|
||||
...variable,
|
||||
}}
|
||||
onValueUpdate={onValueUpdate}
|
||||
/>
|
||||
) : (
|
||||
<VariableItem
|
||||
key={`${variable.name}${variable.id}}${variable.order}`}
|
||||
existingVariables={variables}
|
||||
variableData={{
|
||||
name: variable.name,
|
||||
...variable,
|
||||
}}
|
||||
onValueUpdate={onValueUpdate}
|
||||
variablesToGetUpdated={variablesToGetUpdated}
|
||||
setVariablesToGetUpdated={setVariablesToGetUpdated}
|
||||
dependencyData={dependencyData}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
orderBasedSortedVariables.map((variable) => (
|
||||
<VariableItem
|
||||
key={`${variable.name}${variable.id}}${variable.order}`}
|
||||
existingVariables={variables}
|
||||
variableData={{
|
||||
name: variable.name,
|
||||
...variable,
|
||||
}}
|
||||
onValueUpdate={onValueUpdate}
|
||||
variablesToGetUpdated={variablesToGetUpdated}
|
||||
setVariablesToGetUpdated={setVariablesToGetUpdated}
|
||||
dependencyData={dependencyData}
|
||||
/>
|
||||
))}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,385 +0,0 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
/* eslint-disable no-nested-ternary */
|
||||
import './DashboardVariableSelection.styles.scss';
|
||||
|
||||
import { Tooltip, Typography } from 'antd';
|
||||
import { getFieldValues } from 'api/dynamicVariables/getFieldValues';
|
||||
import { CustomMultiSelect, CustomSelect } from 'components/NewSelect';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
import { isEmpty, isUndefined } from 'lodash-es';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { ALL_SELECT_VALUE } from '../utils';
|
||||
import { SelectItemStyle } from './styles';
|
||||
import { areArraysEqual } from './util';
|
||||
import { getSelectValue } from './VariableItem';
|
||||
|
||||
interface DynamicVariableSelectionProps {
|
||||
variableData: IDashboardVariable;
|
||||
existingVariables: Record<string, IDashboardVariable>;
|
||||
onValueUpdate: (
|
||||
name: string,
|
||||
id: string,
|
||||
arg1: IDashboardVariable['selectedValue'],
|
||||
allSelected: boolean,
|
||||
haveCustomValuesSelected?: boolean,
|
||||
) => void;
|
||||
}
|
||||
|
||||
function DynamicVariableSelection({
|
||||
variableData,
|
||||
onValueUpdate,
|
||||
existingVariables,
|
||||
}: DynamicVariableSelectionProps): JSX.Element {
|
||||
const [optionsData, setOptionsData] = useState<(string | number | boolean)[]>(
|
||||
[],
|
||||
);
|
||||
|
||||
const [errorMessage, setErrorMessage] = useState<null | string>(null);
|
||||
|
||||
const [isComplete, setIsComplete] = useState<boolean>(false);
|
||||
|
||||
const [filteredOptionsData, setFilteredOptionsData] = useState<
|
||||
(string | number | boolean)[]
|
||||
>([]);
|
||||
|
||||
const [tempSelection, setTempSelection] = useState<
|
||||
string | string[] | undefined
|
||||
>(undefined);
|
||||
|
||||
// Create a dependency key from all dynamic variables
|
||||
const dynamicVariablesKey = useMemo(() => {
|
||||
if (!existingVariables) return 'no_variables';
|
||||
|
||||
const dynamicVars = Object.values(existingVariables)
|
||||
.filter((v) => v.type === 'DYNAMIC')
|
||||
.map(
|
||||
(v) => `${v.name || 'unnamed'}:${JSON.stringify(v.selectedValue || null)}`,
|
||||
)
|
||||
.join('|');
|
||||
|
||||
return dynamicVars || 'no_dynamic_variables';
|
||||
}, [existingVariables]);
|
||||
|
||||
const [apiSearchText, setApiSearchText] = useState<string>('');
|
||||
|
||||
const debouncedApiSearchText = useDebounce(apiSearchText, DEBOUNCE_DELAY);
|
||||
|
||||
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
const { isLoading, refetch } = useQuery(
|
||||
[
|
||||
REACT_QUERY_KEY.DASHBOARD_BY_ID,
|
||||
variableData.name || `variable_${variableData.id}`,
|
||||
dynamicVariablesKey,
|
||||
minTime,
|
||||
maxTime,
|
||||
],
|
||||
{
|
||||
enabled: variableData.type === 'DYNAMIC',
|
||||
queryFn: () =>
|
||||
getFieldValues(
|
||||
variableData.dynamicVariablesSource?.toLowerCase() === 'all sources'
|
||||
? undefined
|
||||
: (variableData.dynamicVariablesSource?.toLowerCase() as
|
||||
| 'traces'
|
||||
| 'logs'
|
||||
| 'metrics'),
|
||||
variableData.dynamicVariablesAttribute,
|
||||
debouncedApiSearchText,
|
||||
minTime,
|
||||
maxTime,
|
||||
),
|
||||
onSuccess: (data) => {
|
||||
setOptionsData(data.payload?.normalizedValues || []);
|
||||
setIsComplete(data.payload?.complete || false);
|
||||
setFilteredOptionsData(data.payload?.normalizedValues || []);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
if (error) {
|
||||
let message = SOMETHING_WENT_WRONG;
|
||||
if (error?.message) {
|
||||
message = error?.message;
|
||||
} else {
|
||||
message =
|
||||
'Please make sure configuration is valid and you have required setup and permissions';
|
||||
}
|
||||
setErrorMessage(message);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(inputValue: string | string[]): void => {
|
||||
const value = variableData.multiSelect && !inputValue ? [] : inputValue;
|
||||
|
||||
if (
|
||||
value === variableData.selectedValue ||
|
||||
(Array.isArray(value) &&
|
||||
Array.isArray(variableData.selectedValue) &&
|
||||
areArraysEqual(value, variableData.selectedValue))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (variableData.name) {
|
||||
if (
|
||||
value === ALL_SELECT_VALUE ||
|
||||
(Array.isArray(value) && value.includes(ALL_SELECT_VALUE))
|
||||
) {
|
||||
onValueUpdate(variableData.name, variableData.id, optionsData, true);
|
||||
} else {
|
||||
onValueUpdate(
|
||||
variableData.name,
|
||||
variableData.id,
|
||||
value,
|
||||
optionsData.every((v) => value.includes(v.toString())),
|
||||
Array.isArray(value) &&
|
||||
!value.every((v) => optionsData.includes(v.toString())),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
[variableData, onValueUpdate, optionsData],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
variableData.dynamicVariablesSource &&
|
||||
variableData.dynamicVariablesAttribute
|
||||
) {
|
||||
refetch();
|
||||
}
|
||||
}, [
|
||||
refetch,
|
||||
variableData.dynamicVariablesSource,
|
||||
variableData.dynamicVariablesAttribute,
|
||||
debouncedApiSearchText,
|
||||
]);
|
||||
|
||||
const handleSearch = useCallback(
|
||||
(text: string) => {
|
||||
if (isComplete) {
|
||||
if (!text) {
|
||||
setFilteredOptionsData(optionsData);
|
||||
return;
|
||||
}
|
||||
|
||||
const localFilteredOptionsData: (string | number | boolean)[] = [];
|
||||
optionsData.forEach((option) => {
|
||||
if (option.toString().toLowerCase().includes(text.toLowerCase())) {
|
||||
localFilteredOptionsData.push(option);
|
||||
}
|
||||
});
|
||||
setFilteredOptionsData(localFilteredOptionsData);
|
||||
} else {
|
||||
setApiSearchText(text);
|
||||
}
|
||||
},
|
||||
[isComplete, optionsData],
|
||||
);
|
||||
|
||||
const { selectedValue } = variableData;
|
||||
const selectedValueStringified = useMemo(
|
||||
() => getSelectValue(selectedValue, variableData),
|
||||
[selectedValue, variableData],
|
||||
);
|
||||
|
||||
const enableSelectAll = variableData.multiSelect && variableData.showALLOption;
|
||||
|
||||
const selectValue =
|
||||
variableData.allSelected && enableSelectAll
|
||||
? ALL_SELECT_VALUE
|
||||
: selectedValueStringified;
|
||||
|
||||
// Add a handler for tracking temporary selection changes
|
||||
const handleTempChange = (inputValue: string | string[]): void => {
|
||||
// Store the selection in temporary state while dropdown is open
|
||||
const value = variableData.multiSelect && !inputValue ? [] : inputValue;
|
||||
setTempSelection(value);
|
||||
};
|
||||
|
||||
// Handle dropdown visibility changes
|
||||
const handleDropdownVisibleChange = (visible: boolean): void => {
|
||||
// Initialize temp selection when opening dropdown
|
||||
if (visible) {
|
||||
if (isUndefined(tempSelection) && selectValue === ALL_SELECT_VALUE) {
|
||||
// set all options from the optionsData and the selectedValue, make sure to remove duplicates
|
||||
const allOptions = [
|
||||
...new Set([
|
||||
...optionsData.map((option) => option.toString()),
|
||||
...(variableData.selectedValue
|
||||
? Array.isArray(variableData.selectedValue)
|
||||
? variableData.selectedValue.map((v) => v.toString())
|
||||
: [variableData.selectedValue.toString()]
|
||||
: []),
|
||||
]),
|
||||
];
|
||||
setTempSelection(allOptions);
|
||||
} else {
|
||||
setTempSelection(getSelectValue(variableData.selectedValue, variableData));
|
||||
}
|
||||
}
|
||||
// Apply changes when closing dropdown
|
||||
else if (!visible && tempSelection !== undefined) {
|
||||
// Call handleChange with the temporarily stored selection
|
||||
handleChange(tempSelection);
|
||||
setTempSelection(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
const finalSelectedValues = useMemo(() => {
|
||||
if (variableData.multiSelect) {
|
||||
let value = tempSelection || selectedValue;
|
||||
if (isEmpty(value)) {
|
||||
if (variableData.showALLOption) {
|
||||
if (variableData.defaultValue) {
|
||||
value = variableData.defaultValue;
|
||||
} else {
|
||||
value = optionsData;
|
||||
}
|
||||
} else if (variableData.defaultValue) {
|
||||
value = variableData.defaultValue;
|
||||
} else {
|
||||
value = optionsData?.[0];
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
if (isEmpty(selectedValue)) {
|
||||
if (variableData.defaultValue) {
|
||||
return variableData.defaultValue;
|
||||
}
|
||||
return optionsData[0]?.toString();
|
||||
}
|
||||
|
||||
return selectedValue;
|
||||
}, [
|
||||
variableData.multiSelect,
|
||||
variableData.showALLOption,
|
||||
variableData.defaultValue,
|
||||
selectedValue,
|
||||
tempSelection,
|
||||
optionsData,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
(variableData.multiSelect && !(tempSelection || selectValue)) ||
|
||||
isEmpty(selectValue)
|
||||
) {
|
||||
handleChange(finalSelectedValues as string[] | string);
|
||||
}
|
||||
}, [
|
||||
finalSelectedValues,
|
||||
handleChange,
|
||||
selectValue,
|
||||
tempSelection,
|
||||
variableData.multiSelect,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="variable-item">
|
||||
<Typography.Text className="variable-name" ellipsis>
|
||||
${variableData.name}
|
||||
</Typography.Text>
|
||||
<div className="variable-value">
|
||||
{variableData.multiSelect ? (
|
||||
<CustomMultiSelect
|
||||
key={
|
||||
selectValue && Array.isArray(selectValue)
|
||||
? selectValue.join(' ')
|
||||
: selectValue || variableData.id
|
||||
}
|
||||
options={filteredOptionsData.map((option) => ({
|
||||
label: option.toString(),
|
||||
value: option.toString(),
|
||||
}))}
|
||||
defaultValue={variableData.defaultValue}
|
||||
onChange={handleTempChange}
|
||||
bordered={false}
|
||||
placeholder="Select value"
|
||||
placement="bottomLeft"
|
||||
style={SelectItemStyle}
|
||||
loading={isLoading}
|
||||
showSearch
|
||||
data-testid="variable-select"
|
||||
className="variable-select"
|
||||
popupClassName="dropdown-styles"
|
||||
maxTagCount={2}
|
||||
getPopupContainer={popupContainer}
|
||||
value={
|
||||
(tempSelection || selectValue) === ALL_SELECT_VALUE
|
||||
? 'ALL'
|
||||
: tempSelection || selectValue
|
||||
}
|
||||
onDropdownVisibleChange={handleDropdownVisibleChange}
|
||||
errorMessage={errorMessage}
|
||||
// eslint-disable-next-line react/no-unstable-nested-components
|
||||
maxTagPlaceholder={(omittedValues): JSX.Element => (
|
||||
<Tooltip title={omittedValues.map(({ value }) => value).join(', ')}>
|
||||
<span>+ {omittedValues.length} </span>
|
||||
</Tooltip>
|
||||
)}
|
||||
onClear={(): void => {
|
||||
handleChange([]);
|
||||
}}
|
||||
enableAllSelection={enableSelectAll}
|
||||
maxTagTextLength={30}
|
||||
onSearch={handleSearch}
|
||||
onRetry={(): void => {
|
||||
refetch();
|
||||
}}
|
||||
showIncompleteDataMessage={!isComplete && filteredOptionsData.length > 0}
|
||||
/>
|
||||
) : (
|
||||
<CustomSelect
|
||||
key={
|
||||
selectValue && Array.isArray(selectValue)
|
||||
? selectValue.join(' ')
|
||||
: selectValue || variableData.id
|
||||
}
|
||||
onChange={handleChange}
|
||||
bordered={false}
|
||||
placeholder="Select value"
|
||||
style={SelectItemStyle}
|
||||
loading={isLoading}
|
||||
showSearch
|
||||
data-testid="variable-select"
|
||||
className="variable-select"
|
||||
popupClassName="dropdown-styles"
|
||||
getPopupContainer={popupContainer}
|
||||
options={filteredOptionsData.map((option) => ({
|
||||
label: option.toString(),
|
||||
value: option.toString(),
|
||||
}))}
|
||||
value={selectValue}
|
||||
defaultValue={variableData.defaultValue}
|
||||
errorMessage={errorMessage}
|
||||
onSearch={handleSearch}
|
||||
onRetry={(): void => {
|
||||
refetch();
|
||||
}}
|
||||
showIncompleteDataMessage={!isComplete && filteredOptionsData.length > 0}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DynamicVariableSelection;
|
||||
@@ -7,15 +7,24 @@
|
||||
import './DashboardVariableSelection.styles.scss';
|
||||
|
||||
import { orange } from '@ant-design/colors';
|
||||
import { InfoCircleOutlined, WarningOutlined } from '@ant-design/icons';
|
||||
import { Input, Popover, Tooltip, Typography } from 'antd';
|
||||
import { WarningOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
Checkbox,
|
||||
Input,
|
||||
Popover,
|
||||
Select,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { CheckboxChangeEvent } from 'antd/es/checkbox';
|
||||
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
|
||||
import { CustomMultiSelect, CustomSelect } from 'components/NewSelect';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { commaValuesParser } from 'lib/dashbaordVariables/customCommaValuesParser';
|
||||
import sortValues from 'lib/dashbaordVariables/sortVariableValues';
|
||||
import { debounce, isArray, isEmpty, isString } from 'lodash-es';
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { debounce, isArray, isString } from 'lodash-es';
|
||||
import map from 'lodash-es/map';
|
||||
import { ChangeEvent, memo, useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
@@ -24,10 +33,17 @@ import { VariableResponseProps } from 'types/api/dashboard/variables/query';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { ALL_SELECT_VALUE, variablePropsToPayloadVariables } from '../utils';
|
||||
import { variablePropsToPayloadVariables } from '../utils';
|
||||
import { SelectItemStyle } from './styles';
|
||||
import { areArraysEqual, checkAPIInvocation, IDependencyData } from './util';
|
||||
|
||||
const ALL_SELECT_VALUE = '__ALL__';
|
||||
|
||||
enum ToggleTagValue {
|
||||
Only = 'Only',
|
||||
All = 'All',
|
||||
}
|
||||
|
||||
interface VariableItemProps {
|
||||
variableData: IDashboardVariable;
|
||||
existingVariables: Record<string, IDashboardVariable>;
|
||||
@@ -42,7 +58,7 @@ interface VariableItemProps {
|
||||
dependencyData: IDependencyData | null;
|
||||
}
|
||||
|
||||
export const getSelectValue = (
|
||||
const getSelectValue = (
|
||||
selectedValue: IDashboardVariable['selectedValue'],
|
||||
variableData: IDashboardVariable,
|
||||
): string | string[] | undefined => {
|
||||
@@ -67,9 +83,6 @@ function VariableItem({
|
||||
const [optionsData, setOptionsData] = useState<(string | number | boolean)[]>(
|
||||
[],
|
||||
);
|
||||
const [tempSelection, setTempSelection] = useState<
|
||||
string | string[] | undefined
|
||||
>(undefined);
|
||||
|
||||
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
@@ -133,10 +146,18 @@ function VariableItem({
|
||||
variableData.name &&
|
||||
(validVariableUpdate() || valueNotInList || variableData.allSelected)
|
||||
) {
|
||||
const value = variableData.selectedValue;
|
||||
let value = variableData.selectedValue;
|
||||
let allSelected = false;
|
||||
|
||||
if (variableData.multiSelect) {
|
||||
// The default value for multi-select is ALL and first value for
|
||||
// single select
|
||||
if (valueNotInList) {
|
||||
if (variableData.multiSelect) {
|
||||
value = newOptionsData;
|
||||
allSelected = true;
|
||||
} else {
|
||||
[value] = newOptionsData;
|
||||
}
|
||||
} else if (variableData.multiSelect) {
|
||||
const { selectedValue } = variableData;
|
||||
allSelected =
|
||||
newOptionsData.length > 0 &&
|
||||
@@ -221,57 +242,26 @@ function VariableItem({
|
||||
},
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(inputValue: string | string[]): void => {
|
||||
const value = variableData.multiSelect && !inputValue ? [] : inputValue;
|
||||
|
||||
if (
|
||||
value === variableData.selectedValue ||
|
||||
(Array.isArray(value) &&
|
||||
Array.isArray(variableData.selectedValue) &&
|
||||
areArraysEqual(value, variableData.selectedValue))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (variableData.name) {
|
||||
if (
|
||||
value === ALL_SELECT_VALUE ||
|
||||
(Array.isArray(value) && value.includes(ALL_SELECT_VALUE))
|
||||
) {
|
||||
onValueUpdate(variableData.name, variableData.id, optionsData, true);
|
||||
} else {
|
||||
onValueUpdate(variableData.name, variableData.id, value, false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
variableData.multiSelect,
|
||||
variableData.selectedValue,
|
||||
variableData.name,
|
||||
variableData.id,
|
||||
onValueUpdate,
|
||||
optionsData,
|
||||
],
|
||||
);
|
||||
|
||||
// Add a handler for tracking temporary selection changes
|
||||
const handleTempChange = (inputValue: string | string[]): void => {
|
||||
// Store the selection in temporary state while dropdown is open
|
||||
const handleChange = (inputValue: string | string[]): void => {
|
||||
const value = variableData.multiSelect && !inputValue ? [] : inputValue;
|
||||
setTempSelection(value);
|
||||
};
|
||||
|
||||
// Handle dropdown visibility changes
|
||||
const handleDropdownVisibleChange = (visible: boolean): void => {
|
||||
// Initialize temp selection when opening dropdown
|
||||
if (visible) {
|
||||
setTempSelection(getSelectValue(variableData.selectedValue, variableData));
|
||||
if (
|
||||
value === variableData.selectedValue ||
|
||||
(Array.isArray(value) &&
|
||||
Array.isArray(variableData.selectedValue) &&
|
||||
areArraysEqual(value, variableData.selectedValue))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Apply changes when closing dropdown
|
||||
else if (!visible && tempSelection !== undefined) {
|
||||
// Call handleChange with the temporarily stored selection
|
||||
handleChange(tempSelection);
|
||||
setTempSelection(undefined);
|
||||
if (variableData.name) {
|
||||
if (
|
||||
value === ALL_SELECT_VALUE ||
|
||||
(Array.isArray(value) && value.includes(ALL_SELECT_VALUE))
|
||||
) {
|
||||
onValueUpdate(variableData.name, variableData.id, optionsData, true);
|
||||
} else {
|
||||
onValueUpdate(variableData.name, variableData.id, value, false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -291,58 +281,10 @@ function VariableItem({
|
||||
? 'ALL'
|
||||
: selectedValueStringified;
|
||||
|
||||
// Apply default value on first render if no selection exists
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
const finalSelectedValues = useMemo(() => {
|
||||
if (variableData.multiSelect) {
|
||||
let value = tempSelection || selectedValue;
|
||||
if (isEmpty(value)) {
|
||||
if (variableData.showALLOption) {
|
||||
if (variableData.defaultValue) {
|
||||
value = variableData.defaultValue;
|
||||
} else {
|
||||
value = optionsData;
|
||||
}
|
||||
} else if (variableData.defaultValue) {
|
||||
value = variableData.defaultValue;
|
||||
} else {
|
||||
value = optionsData?.[0];
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
if (isEmpty(selectedValue)) {
|
||||
if (variableData.defaultValue) {
|
||||
return variableData.defaultValue;
|
||||
}
|
||||
return optionsData[0]?.toString();
|
||||
}
|
||||
|
||||
return selectedValue;
|
||||
}, [
|
||||
variableData.multiSelect,
|
||||
variableData.showALLOption,
|
||||
variableData.defaultValue,
|
||||
selectedValue,
|
||||
tempSelection,
|
||||
optionsData,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
(variableData.multiSelect && !(tempSelection || selectValue)) ||
|
||||
isEmpty(selectValue)
|
||||
) {
|
||||
handleChange(finalSelectedValues as string[] | string);
|
||||
}
|
||||
}, [
|
||||
finalSelectedValues,
|
||||
handleChange,
|
||||
selectValue,
|
||||
tempSelection,
|
||||
variableData.multiSelect,
|
||||
]);
|
||||
const mode: 'multiple' | undefined =
|
||||
variableData.multiSelect && !variableData.allSelected
|
||||
? 'multiple'
|
||||
: undefined;
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch options for CUSTOM Type
|
||||
@@ -352,17 +294,118 @@ function VariableItem({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [variableData.type, variableData.customValue]);
|
||||
|
||||
const checkAll = (e: MouseEvent): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const isChecked =
|
||||
variableData.allSelected || selectValue?.includes(ALL_SELECT_VALUE);
|
||||
|
||||
if (isChecked) {
|
||||
handleChange([]);
|
||||
} else {
|
||||
handleChange(ALL_SELECT_VALUE);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOptionSelect = (
|
||||
e: CheckboxChangeEvent,
|
||||
option: string | number | boolean,
|
||||
): void => {
|
||||
const newSelectedValue = Array.isArray(selectedValue)
|
||||
? ((selectedValue.filter(
|
||||
(val) => val.toString() !== option.toString(),
|
||||
) as unknown) as string[])
|
||||
: [];
|
||||
|
||||
if (
|
||||
!e.target.checked &&
|
||||
Array.isArray(selectedValueStringified) &&
|
||||
selectedValueStringified.includes(option.toString())
|
||||
) {
|
||||
if (newSelectedValue.length === 1) {
|
||||
handleChange(newSelectedValue[0].toString());
|
||||
return;
|
||||
}
|
||||
handleChange(newSelectedValue);
|
||||
} else if (!e.target.checked && selectedValue === option.toString()) {
|
||||
handleChange(ALL_SELECT_VALUE);
|
||||
} else if (newSelectedValue.length === optionsData.length - 1) {
|
||||
handleChange(ALL_SELECT_VALUE);
|
||||
}
|
||||
};
|
||||
|
||||
const [optionState, setOptionState] = useState({
|
||||
tag: '',
|
||||
visible: false,
|
||||
});
|
||||
|
||||
function currentToggleTagValue({
|
||||
option,
|
||||
}: {
|
||||
option: string;
|
||||
}): ToggleTagValue {
|
||||
if (
|
||||
option.toString() === selectValue ||
|
||||
(Array.isArray(selectValue) &&
|
||||
selectValue?.includes(option.toString()) &&
|
||||
selectValue.length === 1)
|
||||
) {
|
||||
return ToggleTagValue.All;
|
||||
}
|
||||
return ToggleTagValue.Only;
|
||||
}
|
||||
|
||||
function handleToggle(e: ChangeEvent, option: string): void {
|
||||
e.stopPropagation();
|
||||
const mode = currentToggleTagValue({ option: option as string });
|
||||
const isChecked =
|
||||
variableData.allSelected ||
|
||||
option.toString() === selectValue ||
|
||||
(Array.isArray(selectValue) && selectValue?.includes(option.toString()));
|
||||
|
||||
if (isChecked) {
|
||||
if (mode === ToggleTagValue.Only && variableData.multiSelect) {
|
||||
handleChange([option.toString()]);
|
||||
} else if (!variableData.multiSelect) {
|
||||
handleChange(option.toString());
|
||||
} else {
|
||||
handleChange(ALL_SELECT_VALUE);
|
||||
}
|
||||
} else {
|
||||
handleChange(option.toString());
|
||||
}
|
||||
}
|
||||
|
||||
function retProps(
|
||||
option: string,
|
||||
): {
|
||||
onMouseOver: () => void;
|
||||
onMouseOut: () => void;
|
||||
} {
|
||||
return {
|
||||
onMouseOver: (): void =>
|
||||
setOptionState({
|
||||
tag: option.toString(),
|
||||
visible: true,
|
||||
}),
|
||||
onMouseOut: (): void =>
|
||||
setOptionState({
|
||||
tag: option.toString(),
|
||||
visible: false,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const ensureValidOption = (option: string): boolean =>
|
||||
!(
|
||||
currentToggleTagValue({ option }) === ToggleTagValue.All && !enableSelectAll
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="variable-item">
|
||||
<Typography.Text className="variable-name" ellipsis>
|
||||
${variableData.name}
|
||||
{variableData.description && (
|
||||
<Tooltip title={variableData.description}>
|
||||
<InfoCircleOutlined className="info-icon" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Typography.Text>
|
||||
|
||||
<div className="variable-value">
|
||||
{variableData.type === 'TEXTBOX' ? (
|
||||
<Input
|
||||
@@ -379,73 +422,105 @@ function VariableItem({
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
optionsData &&
|
||||
(variableData.multiSelect ? (
|
||||
<CustomMultiSelect
|
||||
!errorMessage &&
|
||||
optionsData && (
|
||||
<Select
|
||||
key={
|
||||
selectValue && Array.isArray(selectValue)
|
||||
? selectValue.join(' ')
|
||||
: selectValue || variableData.id
|
||||
}
|
||||
options={optionsData.map((option) => ({
|
||||
label: option.toString(),
|
||||
value: option.toString(),
|
||||
}))}
|
||||
defaultValue={variableData.defaultValue || selectValue}
|
||||
onChange={handleTempChange}
|
||||
defaultValue={selectValue}
|
||||
onChange={handleChange}
|
||||
bordered={false}
|
||||
placeholder="Select value"
|
||||
placement="bottomLeft"
|
||||
mode={mode}
|
||||
style={SelectItemStyle}
|
||||
loading={isLoading}
|
||||
showSearch
|
||||
data-testid="variable-select"
|
||||
className="variable-select"
|
||||
popupClassName="dropdown-styles"
|
||||
maxTagCount={2}
|
||||
maxTagCount={4}
|
||||
getPopupContainer={popupContainer}
|
||||
value={tempSelection || selectValue}
|
||||
onDropdownVisibleChange={handleDropdownVisibleChange}
|
||||
errorMessage={errorMessage}
|
||||
// eslint-disable-next-line react/no-unstable-nested-components
|
||||
tagRender={(props): JSX.Element => (
|
||||
<Tag closable onClose={props.onClose}>
|
||||
{props.value}
|
||||
</Tag>
|
||||
)}
|
||||
// eslint-disable-next-line react/no-unstable-nested-components
|
||||
maxTagPlaceholder={(omittedValues): JSX.Element => (
|
||||
<Tooltip title={omittedValues.map(({ value }) => value).join(', ')}>
|
||||
<span>+ {omittedValues.length} </span>
|
||||
</Tooltip>
|
||||
)}
|
||||
onClear={(): void => {
|
||||
handleChange([]);
|
||||
}}
|
||||
enableAllSelection={enableSelectAll}
|
||||
maxTagTextLength={30}
|
||||
allowClear={selectValue !== ALL_SELECT_VALUE && selectValue !== 'ALL'}
|
||||
/>
|
||||
) : (
|
||||
<CustomSelect
|
||||
key={
|
||||
selectValue && Array.isArray(selectValue)
|
||||
? selectValue.join(' ')
|
||||
: selectValue || variableData.id
|
||||
}
|
||||
defaultValue={variableData.defaultValue || selectValue}
|
||||
onChange={handleChange}
|
||||
bordered={false}
|
||||
placeholder="Select value"
|
||||
style={SelectItemStyle}
|
||||
loading={isLoading}
|
||||
showSearch
|
||||
data-testid="variable-select"
|
||||
className="variable-select"
|
||||
popupClassName="dropdown-styles"
|
||||
getPopupContainer={popupContainer}
|
||||
options={optionsData.map((option) => ({
|
||||
label: option.toString(),
|
||||
value: option.toString(),
|
||||
}))}
|
||||
value={selectValue}
|
||||
errorMessage={errorMessage}
|
||||
/>
|
||||
))
|
||||
allowClear
|
||||
>
|
||||
{enableSelectAll && (
|
||||
<Select.Option data-testid="option-ALL" value={ALL_SELECT_VALUE}>
|
||||
<div className="all-label" onClick={(e): void => checkAll(e as any)}>
|
||||
<Checkbox checked={variableData.allSelected} />
|
||||
ALL
|
||||
</div>
|
||||
</Select.Option>
|
||||
)}
|
||||
{map(optionsData, (option) => (
|
||||
<Select.Option
|
||||
data-testid={`option-${option}`}
|
||||
key={option.toString()}
|
||||
value={option}
|
||||
>
|
||||
<div
|
||||
className={variableData.multiSelect ? 'dropdown-checkbox-label' : ''}
|
||||
>
|
||||
{variableData.multiSelect && (
|
||||
<Checkbox
|
||||
onChange={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
handleOptionSelect(e, option);
|
||||
}}
|
||||
checked={
|
||||
variableData.allSelected ||
|
||||
option.toString() === selectValue ||
|
||||
(Array.isArray(selectValue) &&
|
||||
selectValue?.includes(option.toString()))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="dropdown-value"
|
||||
{...retProps(option as string)}
|
||||
onClick={(e): void => handleToggle(e as any, option as string)}
|
||||
>
|
||||
<Typography.Text
|
||||
ellipsis={{
|
||||
tooltip: {
|
||||
placement: variableData.multiSelect ? 'top' : 'right',
|
||||
autoAdjustOverflow: true,
|
||||
},
|
||||
}}
|
||||
className="option-text"
|
||||
>
|
||||
{option.toString()}
|
||||
</Typography.Text>
|
||||
|
||||
{variableData.multiSelect &&
|
||||
optionState.tag === option.toString() &&
|
||||
optionState.visible &&
|
||||
ensureValidOption(option as string) && (
|
||||
<Typography.Text className="toggle-tag-label">
|
||||
{currentToggleTagValue({ option: option as string })}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
)
|
||||
)}
|
||||
{variableData.type !== 'TEXTBOX' && errorMessage && (
|
||||
<span style={{ margin: '0 0.5rem' }}>
|
||||
|
||||
@@ -1,274 +0,0 @@
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import * as ReactQuery from 'react-query';
|
||||
import * as ReactRedux from 'react-redux';
|
||||
import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
|
||||
import DynamicVariableSelection from '../DynamicVariableSelection';
|
||||
|
||||
// Don't mock the components - use real ones
|
||||
|
||||
// Mock for useQuery
|
||||
const mockQueryResult = {
|
||||
data: undefined,
|
||||
error: null,
|
||||
isError: false,
|
||||
isIdle: false,
|
||||
isLoading: false,
|
||||
isPreviousData: false,
|
||||
isSuccess: true,
|
||||
status: 'success',
|
||||
isFetched: true,
|
||||
isFetchingNextPage: false,
|
||||
isFetchingPreviousPage: false,
|
||||
isPlaceholderData: false,
|
||||
isPaused: false,
|
||||
isRefetchError: false,
|
||||
isRefetching: false,
|
||||
isStale: false,
|
||||
isLoadingError: false,
|
||||
isFetching: false,
|
||||
isFetchedAfterMount: true,
|
||||
dataUpdatedAt: 0,
|
||||
errorUpdatedAt: 0,
|
||||
failureCount: 0,
|
||||
refetch: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
fetchNextPage: jest.fn(),
|
||||
fetchPreviousPage: jest.fn(),
|
||||
hasNextPage: false,
|
||||
hasPreviousPage: false,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any;
|
||||
|
||||
// Sample data for testing
|
||||
const mockApiResponse = {
|
||||
payload: {
|
||||
normalizedValues: ['frontend', 'backend', 'database'],
|
||||
complete: true,
|
||||
},
|
||||
statusCode: 200,
|
||||
};
|
||||
|
||||
// Mock scrollIntoView since it's not available in JSDOM
|
||||
window.HTMLElement.prototype.scrollIntoView = jest.fn();
|
||||
|
||||
describe('DynamicVariableSelection Component', () => {
|
||||
const mockOnValueUpdate = jest.fn();
|
||||
|
||||
const mockDynamicVariableData: IDashboardVariable = {
|
||||
id: 'var1',
|
||||
name: 'service',
|
||||
type: 'DYNAMIC',
|
||||
dynamicVariablesAttribute: 'service.name',
|
||||
dynamicVariablesSource: 'Traces',
|
||||
selectedValue: 'frontend',
|
||||
multiSelect: false,
|
||||
showALLOption: false,
|
||||
allSelected: false,
|
||||
description: '',
|
||||
sort: 'DISABLED',
|
||||
};
|
||||
|
||||
const mockMultiSelectDynamicVariableData: IDashboardVariable = {
|
||||
...mockDynamicVariableData,
|
||||
id: 'var2',
|
||||
name: 'services',
|
||||
multiSelect: true,
|
||||
selectedValue: ['frontend', 'backend'],
|
||||
showALLOption: true,
|
||||
};
|
||||
|
||||
const mockExistingVariables: Record<string, IDashboardVariable> = {
|
||||
var1: mockDynamicVariableData,
|
||||
var2: mockMultiSelectDynamicVariableData,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockOnValueUpdate.mockClear();
|
||||
|
||||
// Mock useSelector
|
||||
const useSelectorSpy = jest.spyOn(ReactRedux, 'useSelector');
|
||||
useSelectorSpy.mockReturnValue({
|
||||
minTime: '2023-01-01T00:00:00Z',
|
||||
maxTime: '2023-01-02T00:00:00Z',
|
||||
});
|
||||
|
||||
// Mock useQuery with success state
|
||||
const useQuerySpy = jest.spyOn(ReactQuery, 'useQuery');
|
||||
useQuerySpy.mockReturnValue({
|
||||
...mockQueryResult,
|
||||
data: mockApiResponse,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders with single select variable correctly', () => {
|
||||
render(
|
||||
<DynamicVariableSelection
|
||||
variableData={mockDynamicVariableData}
|
||||
existingVariables={mockExistingVariables}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Verify component renders correctly
|
||||
expect(
|
||||
screen.getByText(`$${mockDynamicVariableData.name}`),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Verify the selected value is displayed
|
||||
const selectedItem = screen.getByRole('combobox');
|
||||
expect(selectedItem).toBeInTheDocument();
|
||||
|
||||
// CustomSelect doesn't use the 'mode' attribute for single select
|
||||
expect(selectedItem).not.toHaveAttribute('mode');
|
||||
});
|
||||
|
||||
it('renders with multi select variable correctly', () => {
|
||||
// First set up allSelected to true to properly test the ALL display
|
||||
const multiSelectWithAllSelected = {
|
||||
...mockMultiSelectDynamicVariableData,
|
||||
allSelected: true,
|
||||
};
|
||||
|
||||
render(
|
||||
<DynamicVariableSelection
|
||||
variableData={multiSelectWithAllSelected}
|
||||
existingVariables={mockExistingVariables}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Verify variable name is rendered
|
||||
expect(
|
||||
screen.getByText(`$${multiSelectWithAllSelected.name}`),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// In ALL selected mode, there should be an "ALL" text element
|
||||
expect(screen.getByText('ALL')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows loading state correctly', () => {
|
||||
// Mock loading state
|
||||
jest.spyOn(ReactQuery, 'useQuery').mockReturnValue({
|
||||
...mockQueryResult,
|
||||
data: null,
|
||||
isLoading: true,
|
||||
isFetching: true,
|
||||
isSuccess: false,
|
||||
status: 'loading',
|
||||
});
|
||||
|
||||
render(
|
||||
<DynamicVariableSelection
|
||||
variableData={mockDynamicVariableData}
|
||||
existingVariables={mockExistingVariables}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Verify component renders in loading state
|
||||
expect(
|
||||
screen.getByText(`$${mockDynamicVariableData.name}`),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Open dropdown to see loading text
|
||||
const selectElement = screen.getByRole('combobox');
|
||||
fireEvent.mouseDown(selectElement);
|
||||
|
||||
// The loading text should appear in the dropdown
|
||||
expect(screen.getByText('We are updating the values...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles error state correctly', () => {
|
||||
const errorMessage = 'Failed to fetch data';
|
||||
|
||||
// Mock error state
|
||||
jest.spyOn(ReactQuery, 'useQuery').mockReturnValue({
|
||||
...mockQueryResult,
|
||||
data: null,
|
||||
isLoading: false,
|
||||
isSuccess: false,
|
||||
isError: true,
|
||||
status: 'error',
|
||||
error: { message: errorMessage },
|
||||
});
|
||||
|
||||
render(
|
||||
<DynamicVariableSelection
|
||||
variableData={mockDynamicVariableData}
|
||||
existingVariables={mockExistingVariables}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Verify the component renders
|
||||
expect(
|
||||
screen.getByText(`$${mockDynamicVariableData.name}`),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// For error states, we should check that error handling is in place
|
||||
// Without opening the dropdown as the error message might be handled differently
|
||||
expect(ReactQuery.useQuery).toHaveBeenCalled();
|
||||
// We don't need to check refetch as it might be called during component initialization
|
||||
});
|
||||
|
||||
it('makes API call to fetch variable values', () => {
|
||||
render(
|
||||
<DynamicVariableSelection
|
||||
variableData={mockDynamicVariableData}
|
||||
existingVariables={mockExistingVariables}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Verify the useQuery hook was called with expected parameters
|
||||
expect(ReactQuery.useQuery).toHaveBeenCalledWith(
|
||||
[
|
||||
'DASHBOARD_BY_ID',
|
||||
mockDynamicVariableData.name,
|
||||
'service:"frontend"|services:["frontend","backend"]', // The actual dynamicVariablesKey
|
||||
'2023-01-01T00:00:00Z', // minTime from useSelector mock
|
||||
'2023-01-02T00:00:00Z', // maxTime from useSelector mock
|
||||
],
|
||||
expect.objectContaining({
|
||||
enabled: true, // Type is 'DYNAMIC'
|
||||
queryFn: expect.any(Function),
|
||||
onSuccess: expect.any(Function),
|
||||
onError: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('has the correct selected value', () => {
|
||||
// Use a different variable configuration to test different behavior
|
||||
const customVariable = {
|
||||
...mockDynamicVariableData,
|
||||
id: 'custom1',
|
||||
name: 'customService',
|
||||
selectedValue: 'backend',
|
||||
};
|
||||
|
||||
render(
|
||||
<DynamicVariableSelection
|
||||
variableData={customVariable}
|
||||
existingVariables={{ ...mockExistingVariables, custom1: customVariable }}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Verify the component correctly displays the selected value
|
||||
expect(screen.getByText(`$${customVariable.name}`)).toBeInTheDocument();
|
||||
|
||||
// Find the selection item in the component using data-testid
|
||||
const selectElement = screen.getByTestId('variable-select');
|
||||
expect(selectElement).toBeInTheDocument();
|
||||
|
||||
// Check that the selected value is displayed in the select element
|
||||
expect(selectElement).toHaveTextContent('backend');
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user