Compare commits

..

19 Commits

Author SHA1 Message Date
ahmadshaheer
b5d7ec4b78 fix: prevent the layout shift on interacting with filter, group by, ... in the query builder 2025-04-06 11:47:25 +04:30
Vibhu Pandey
2330420c0d fix(querier): remove ff (#7531) 2025-04-05 18:08:06 +00:00
Vibhu Pandey
65ac277074 fix(duration|timestamp): remove duration/timestamp sort (#7530) 2025-04-05 23:26:50 +05:30
Vibhu Pandey
b7982ca348 fix(ff): remove feature interface from ruler (#7529)
### Summary

remove feature interface from ruler
2025-04-05 12:52:26 +00:00
dependabot[bot]
2748b49a44 chore(deps): bump github.com/golang-jwt/jwt/v5 from 5.2.1 to 5.2.2 (#7401)
Bumps [github.com/golang-jwt/jwt/v5](https://github.com/golang-jwt/jwt) from 5.2.1 to 5.2.2.
- [Release notes](https://github.com/golang-jwt/jwt/releases)
- [Changelog](https://github.com/golang-jwt/jwt/blob/main/VERSION_HISTORY.md)
- [Commits](https://github.com/golang-jwt/jwt/compare/v5.2.1...v5.2.2)

---
updated-dependencies:
- dependency-name: github.com/golang-jwt/jwt/v5
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2025-04-05 01:43:43 +00:00
Vibhu Pandey
7345027762 fix(ff): remove prefer rpm (#7528) 2025-04-04 23:38:16 +05:30
Vibhu Pandey
68f874e433 chore(ff): remove unused SMART_TRACE_DETAIL feature flag (#7527) 2025-04-04 20:28:54 +05:30
Vibhu Pandey
54a82b1664 fix(dashboards): remove ff interface (#7526) 2025-04-04 19:12:31 +05:30
Yunus M
93dc585145 fix: disable sidenav items for cloud users whose license has expired (#7524) 2025-04-04 18:33:55 +05:30
Vikrant Gupta
6a143efd2c feat(sqlmigration): update the user related tables according to new schema (#7518)
* feat(sqlmigration): update the alertmanager tables

* feat(sqlmigration): update the alertmanager tables

* feat(sqlmigration): make the preference package multi tenant

* feat(preference): address nit pick comments

* feat(preference): added the cascade delete for preferences

* feat(sqlmigration): update apdex and TTL status tables  (#7481)

* feat(sqlmigration): update the apdex and ttl tables

* feat(sqlmigration): register the new migration and rename table

* feat(sqlmigration): fix the ttl queries

* feat(sqlmigration): update the TTL and apdex tables

* feat(sqlmigration): update the TTL and apdex tables

* feat(sqlmigration): fix the reset password and pat tables (#7482)

* feat(sqlmigration): fix the reset password and pat tables

* feat(sqlmigration): revert PAT changes

* feat(sqlmigration): register and rename the new migration

* feat(sqlmigration): handle updates for user tables

* feat(sqlmigration): remove unwanted changes
2025-04-04 01:46:28 +05:30
Vikrant Gupta
0116eb20ab feat(sqlmigration): update apdex and TTL status tables (#7517)
* feat(sqlmigration): update the alertmanager tables

* feat(sqlmigration): update the alertmanager tables

* feat(sqlmigration): make the preference package multi tenant

* feat(preference): address nit pick comments

* feat(preference): added the cascade delete for preferences

* feat(sqlmigration): update apdex and TTL status tables  (#7481)

* feat(sqlmigration): update the apdex and ttl tables

* feat(sqlmigration): register the new migration and rename table

* feat(sqlmigration): fix the ttl queries

* feat(sqlmigration): update the TTL and apdex tables

* feat(sqlmigration): update the TTL and apdex tables
2025-04-04 01:36:47 +05:30
Vikrant Gupta
79e9d1b357 feat(preference): multi tenant preference module (#7516)
* feat(sqlmigration): update the alertmanager tables

* feat(sqlmigration): update the alertmanager tables

* feat(sqlmigration): make the preference package multi tenant

* feat(preference): address nit pick comments

* feat(preference): added the cascade delete for preferences
2025-04-04 01:25:24 +05:30
Vikrant Gupta
b89ce82e25 feat(sqlmigration): update the alertmanager tables (#7513)
* feat(sqlmigration): update the alertmanager tables
2025-04-03 17:56:49 +00:00
dependabot[bot]
b43a198fd8 chore(deps): bump github.com/expr-lang/expr from 1.16.9 to 1.17.0 (#7342)
Bumps [github.com/expr-lang/expr](https://github.com/expr-lang/expr) from 1.16.9 to 1.17.0.
- [Release notes](https://github.com/expr-lang/expr/releases)
- [Commits](https://github.com/expr-lang/expr/compare/v1.16.9...v1.17.0)

---
updated-dependencies:
- dependency-name: github.com/expr-lang/expr
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2025-04-03 08:17:40 +00:00
Srikanth Chekuri
b40ca4baf3 fix: do not crash service on panic during rule eval (#7514) 2025-04-03 13:13:58 +05:30
SagarRajput-7
8df77c9221 fix: fixed trace funnel - header style overriding other pages (#7512)
* fix: fixed trace funnel - header style overriding other pages

* fix: fixed trace funnel - header style overriding other pages

* fix: handled nesting
2025-04-03 07:29:39 +00:00
Srikanth Chekuri
f67555576f chore: add info icon tool tips for webhook/routing/integration key (#7405) 2025-04-03 09:40:41 +05:30
Srikanth Chekuri
f0a4c37073 fix: handle maintenance windows that cross day boundaries (#7494) 2025-04-02 20:48:01 +00:00
Vikrant Gupta
7972261237 Revert "fix: use search v2 component for traces data source & minor improveme…" (#7511)
This reverts commit d7a6607a25.
2025-04-03 01:15:20 +05:30
196 changed files with 3574 additions and 8813 deletions

View File

@@ -28,11 +28,10 @@ func NewDailyProvider(opts ...GenericProviderOption[*DailyProvider]) *DailyProvi
}
dp.querierV2 = querierV2.NewQuerier(querierV2.QuerierOptions{
Reader: dp.reader,
Cache: dp.cache,
KeyGenerator: queryBuilder.NewKeyGenerator(),
FluxInterval: dp.fluxInterval,
FeatureLookup: dp.ff,
Reader: dp.reader,
Cache: dp.cache,
KeyGenerator: queryBuilder.NewKeyGenerator(),
FluxInterval: dp.fluxInterval,
})
return dp

View File

@@ -28,11 +28,10 @@ func NewHourlyProvider(opts ...GenericProviderOption[*HourlyProvider]) *HourlyPr
}
hp.querierV2 = querierV2.NewQuerier(querierV2.QuerierOptions{
Reader: hp.reader,
Cache: hp.cache,
KeyGenerator: queryBuilder.NewKeyGenerator(),
FluxInterval: hp.fluxInterval,
FeatureLookup: hp.ff,
Reader: hp.reader,
Cache: hp.cache,
KeyGenerator: queryBuilder.NewKeyGenerator(),
FluxInterval: hp.fluxInterval,
})
return hp

View File

@@ -38,12 +38,6 @@ func WithKeyGenerator[T BaseProvider](keyGenerator cache.KeyGenerator) GenericPr
}
}
func WithFeatureLookup[T BaseProvider](ff interfaces.FeatureLookup) GenericProviderOption[T] {
return func(p T) {
p.GetBaseSeasonalProvider().ff = ff
}
}
func WithReader[T BaseProvider](reader interfaces.Reader) GenericProviderOption[T] {
return func(p T) {
p.GetBaseSeasonalProvider().reader = reader
@@ -56,7 +50,6 @@ type BaseSeasonalProvider struct {
fluxInterval time.Duration
cache cache.Cache
keyGenerator cache.KeyGenerator
ff interfaces.FeatureLookup
}
func (p *BaseSeasonalProvider) getQueryParams(req *GetAnomaliesRequest) *anomalyQueryParams {

View File

@@ -27,11 +27,10 @@ func NewWeeklyProvider(opts ...GenericProviderOption[*WeeklyProvider]) *WeeklyPr
}
wp.querierV2 = querierV2.NewQuerier(querierV2.QuerierOptions{
Reader: wp.reader,
Cache: wp.cache,
KeyGenerator: queryBuilder.NewKeyGenerator(),
FluxInterval: wp.fluxInterval,
FeatureLookup: wp.ff,
Reader: wp.reader,
Cache: wp.cache,
KeyGenerator: queryBuilder.NewKeyGenerator(),
FluxInterval: wp.fluxInterval,
})
return wp

View File

@@ -11,6 +11,8 @@ import (
"github.com/SigNoz/signoz/ee/query-service/license"
"github.com/SigNoz/signoz/ee/query-service/usage"
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/modules/preference"
preferencecore "github.com/SigNoz/signoz/pkg/modules/preference/core"
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/integrations"
@@ -21,6 +23,7 @@ import (
rules "github.com/SigNoz/signoz/pkg/query-service/rules"
"github.com/SigNoz/signoz/pkg/signoz"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/preferencetypes"
"github.com/SigNoz/signoz/pkg/version"
"github.com/gorilla/mux"
)
@@ -54,6 +57,7 @@ type APIHandler struct {
// NewAPIHandler returns an APIHandler
func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz) (*APIHandler, error) {
preference := preference.NewAPI(preferencecore.NewPreference(preferencecore.NewStore(signoz.SQLStore), preferencetypes.NewDefaultPreferenceMap()))
baseHandler, err := baseapp.NewAPIHandler(baseapp.APIHandlerOpts{
Reader: opts.DataConnector,
@@ -71,6 +75,7 @@ func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz) (*APIHandler,
UseTraceNewSchema: opts.UseTraceNewSchema,
AlertmanagerAPI: alertmanager.NewAPI(signoz.Alertmanager),
Signoz: signoz,
Preference: preference,
})
if err != nil {
@@ -157,7 +162,6 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *baseapp.AuthMiddlew
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)
router.HandleFunc("/api/v1/traces/{traceId}", am.ViewAccess(ah.searchTraces)).Methods(http.MethodGet)
// PAT APIs
router.HandleFunc("/api/v1/pats", am.AdminAccess(ah.createPAT)).Methods(http.MethodPost)

View File

@@ -10,9 +10,12 @@ import (
"github.com/SigNoz/signoz/ee/query-service/model"
"github.com/SigNoz/signoz/ee/types"
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"
baseconstants "github.com/SigNoz/signoz/pkg/query-service/constants"
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
"go.uber.org/zap"
)
@@ -93,7 +96,12 @@ func (ah *APIHandler) updatePAT(w http.ResponseWriter, r *http.Request) {
}
req.UpdatedByUserID = user.ID
id := mux.Vars(r)["id"]
idStr := mux.Vars(r)["id"]
id, err := valuer.NewUUID(idStr)
if err != nil {
render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
return
}
req.UpdatedAt = time.Now()
zap.L().Info("Got Update PAT request", zap.Any("pat", req))
var apierr basemodel.BaseApiError
@@ -126,7 +134,12 @@ func (ah *APIHandler) getPATs(w http.ResponseWriter, r *http.Request) {
func (ah *APIHandler) revokePAT(w http.ResponseWriter, r *http.Request) {
ctx := context.Background()
id := mux.Vars(r)["id"]
idStr := mux.Vars(r)["id"]
id, err := valuer.NewUUID(idStr)
if err != nil {
render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
return
}
user, err := auth.GetUserFromReqContext(r.Context())
if err != nil {
RespondError(w, &model.ApiError{
@@ -136,7 +149,7 @@ func (ah *APIHandler) revokePAT(w http.ResponseWriter, r *http.Request) {
return
}
zap.L().Info("Revoke PAT with id", zap.String("id", id))
zap.L().Info("Revoke PAT with id", zap.String("id", id.StringValue()))
if apierr := ah.AppDao().RevokePAT(ctx, user.OrgID, id, user.ID); apierr != nil {
RespondError(w, apierr, nil)
return

View File

@@ -88,28 +88,24 @@ func (aH *APIHandler) queryRangeV4(w http.ResponseWriter, r *http.Request) {
anomaly.WithCache[*anomaly.WeeklyProvider](aH.opts.Cache),
anomaly.WithKeyGenerator[*anomaly.WeeklyProvider](queryBuilder.NewKeyGenerator()),
anomaly.WithReader[*anomaly.WeeklyProvider](aH.opts.DataConnector),
anomaly.WithFeatureLookup[*anomaly.WeeklyProvider](aH.opts.FeatureFlags),
)
case anomaly.SeasonalityDaily:
provider = anomaly.NewDailyProvider(
anomaly.WithCache[*anomaly.DailyProvider](aH.opts.Cache),
anomaly.WithKeyGenerator[*anomaly.DailyProvider](queryBuilder.NewKeyGenerator()),
anomaly.WithReader[*anomaly.DailyProvider](aH.opts.DataConnector),
anomaly.WithFeatureLookup[*anomaly.DailyProvider](aH.opts.FeatureFlags),
)
case anomaly.SeasonalityHourly:
provider = anomaly.NewHourlyProvider(
anomaly.WithCache[*anomaly.HourlyProvider](aH.opts.Cache),
anomaly.WithKeyGenerator[*anomaly.HourlyProvider](queryBuilder.NewKeyGenerator()),
anomaly.WithReader[*anomaly.HourlyProvider](aH.opts.DataConnector),
anomaly.WithFeatureLookup[*anomaly.HourlyProvider](aH.opts.FeatureFlags),
)
default:
provider = anomaly.NewDailyProvider(
anomaly.WithCache[*anomaly.DailyProvider](aH.opts.Cache),
anomaly.WithKeyGenerator[*anomaly.DailyProvider](queryBuilder.NewKeyGenerator()),
anomaly.WithReader[*anomaly.DailyProvider](aH.opts.DataConnector),
anomaly.WithFeatureLookup[*anomaly.DailyProvider](aH.opts.FeatureFlags),
)
}
anomalies, err := provider.GetAnomalies(r.Context(), &anomaly.GetAnomaliesRequest{Params: queryRangeParams})

View File

@@ -1,33 +0,0 @@
package api
import (
"net/http"
"github.com/SigNoz/signoz/ee/query-service/app/db"
"github.com/SigNoz/signoz/ee/query-service/model"
baseapp "github.com/SigNoz/signoz/pkg/query-service/app"
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
"go.uber.org/zap"
)
func (ah *APIHandler) searchTraces(w http.ResponseWriter, r *http.Request) {
if !ah.CheckFeature(basemodel.SmartTraceDetail) {
zap.L().Info("SmartTraceDetail feature is not enabled in this plan")
ah.APIHandler.SearchTraces(w, r)
return
}
searchTracesParams, err := baseapp.ParseSearchTracesParams(r)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, "Error reading params")
return
}
result, err := ah.opts.DataConnector.SearchTraces(r.Context(), searchTracesParams, db.SmartTraceAlgorithm)
if ah.HandleError(w, err, http.StatusBadRequest) {
return
}
ah.WriteJSON(w, r, result)
}

View File

@@ -5,36 +5,33 @@ import (
"github.com/ClickHouse/clickhouse-go/v2"
"github.com/jmoiron/sqlx"
"github.com/SigNoz/signoz/pkg/cache"
"github.com/SigNoz/signoz/pkg/prometheus"
basechr "github.com/SigNoz/signoz/pkg/query-service/app/clickhouseReader"
"github.com/SigNoz/signoz/pkg/query-service/interfaces"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/telemetrystore"
)
type ClickhouseReader struct {
conn clickhouse.Conn
appdb *sqlx.DB
appdb sqlstore.SQLStore
*basechr.ClickHouseReader
}
func NewDataConnector(
localDB *sqlx.DB,
sqlDB sqlstore.SQLStore,
telemetryStore telemetrystore.TelemetryStore,
prometheus prometheus.Prometheus,
lm interfaces.FeatureLookup,
cluster string,
useLogsNewSchema bool,
useTraceNewSchema bool,
fluxIntervalForTraceDetail time.Duration,
cache cache.Cache,
) *ClickhouseReader {
chReader := basechr.NewReader(localDB, telemetryStore, prometheus, lm, cluster, useLogsNewSchema, useTraceNewSchema, fluxIntervalForTraceDetail, cache)
chReader := basechr.NewReader(sqlDB, telemetryStore, prometheus, cluster, useLogsNewSchema, useTraceNewSchema, fluxIntervalForTraceDetail, cache)
return &ClickhouseReader{
conn: telemetryStore.ClickhouseDB(),
appdb: localDB,
appdb: sqlDB,
ClickHouseReader: chReader,
}
}

View File

@@ -44,7 +44,6 @@ import (
"github.com/SigNoz/signoz/pkg/query-service/app/logparsingpipeline"
"github.com/SigNoz/signoz/pkg/query-service/app/opamp"
opAmpModel "github.com/SigNoz/signoz/pkg/query-service/app/opamp/model"
"github.com/SigNoz/signoz/pkg/query-service/app/preferences"
"github.com/SigNoz/signoz/pkg/query-service/cache"
baseconst "github.com/SigNoz/signoz/pkg/query-service/constants"
"github.com/SigNoz/signoz/pkg/query-service/healthcheck"
@@ -116,10 +115,6 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) {
return nil, err
}
if err := preferences.InitDB(serverOptions.SigNoz.SQLStore.SQLxDB()); err != nil {
return nil, err
}
if err := dashboards.InitDB(serverOptions.SigNoz.SQLStore); err != nil {
return nil, err
}
@@ -144,10 +139,9 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) {
}
reader := db.NewDataConnector(
serverOptions.SigNoz.SQLStore.SQLxDB(),
serverOptions.SigNoz.SQLStore,
serverOptions.SigNoz.TelemetryStore,
serverOptions.SigNoz.Prometheus,
lm,
serverOptions.Cluster,
serverOptions.UseLogsNewSchema,
serverOptions.UseTraceNewSchema,
@@ -178,7 +172,6 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) {
reader,
c,
serverOptions.DisableRules,
lm,
serverOptions.UseLogsNewSchema,
serverOptions.UseTraceNewSchema,
serverOptions.SigNoz.Alertmanager,
@@ -381,7 +374,6 @@ 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{"*"},
@@ -539,7 +531,6 @@ func makeRulesManager(
ch baseint.Reader,
cache cache.Cache,
disableRules bool,
fm baseint.FeatureLookup,
useLogsNewSchema bool,
useTraceNewSchema bool,
alertmanager alertmanager.Alertmanager,
@@ -556,7 +547,6 @@ func makeRulesManager(
Context: context.Background(),
Logger: zap.L(),
DisableRules: disableRules,
FeatureFlags: fm,
Reader: ch,
Cache: cache,
EvalDelay: baseconst.GetEvalDelay(),

View File

@@ -10,6 +10,7 @@ import (
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
ossTypes "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"
)
@@ -36,10 +37,10 @@ type ModelDao interface {
GetDomainByEmail(ctx context.Context, email string) (*types.GettableOrgDomain, 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 string) 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 string) (*types.GettablePAT, basemodel.BaseApiError)
GetPATByID(ctx context.Context, orgID string, id valuer.UUID) (*types.GettablePAT, basemodel.BaseApiError)
GetUserByPAT(ctx context.Context, orgID string, token string) (*ossTypes.GettableUser, basemodel.BaseApiError)
ListPATs(ctx context.Context, orgID string) ([]types.GettablePAT, basemodel.BaseApiError)
RevokePAT(ctx context.Context, orgID string, id string, userID string) basemodel.BaseApiError
RevokePAT(ctx context.Context, orgID string, id valuer.UUID, userID string) basemodel.BaseApiError
}

View File

@@ -9,12 +9,14 @@ import (
"github.com/SigNoz/signoz/ee/types"
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
ossTypes "github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/valuer"
"go.uber.org/zap"
)
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.DB().NewInsert().
Model(&p.StorablePersonalAccessToken).
Exec(ctx)
@@ -46,11 +48,11 @@ func (m *modelDao) CreatePAT(ctx context.Context, orgID string, p types.Gettable
return p, nil
}
func (m *modelDao) UpdatePAT(ctx context.Context, orgID string, p types.GettablePAT, id string) basemodel.BaseApiError {
func (m *modelDao) UpdatePAT(ctx context.Context, orgID string, p types.GettablePAT, id valuer.UUID) basemodel.BaseApiError {
_, err := m.DB().NewUpdate().
Model(&p.StorablePersonalAccessToken).
Column("role", "name", "updated_at", "updated_by_user_id").
Where("id = ?", id).
Where("id = ?", id.StringValue()).
Where("org_id = ?", orgID).
Where("revoked = false").
Exec(ctx)
@@ -127,14 +129,14 @@ func (m *modelDao) ListPATs(ctx context.Context, orgID string) ([]types.Gettable
return patsWithUsers, nil
}
func (m *modelDao) RevokePAT(ctx context.Context, orgID string, id string, userID string) basemodel.BaseApiError {
func (m *modelDao) RevokePAT(ctx context.Context, orgID string, id valuer.UUID, userID string) basemodel.BaseApiError {
updatedAt := time.Now().Unix()
_, err := m.DB().NewUpdate().
Model(&types.StorablePersonalAccessToken{}).
Set("revoked = ?", true).
Set("updated_by_user_id = ?", userID).
Set("updated_at = ?", updatedAt).
Where("id = ?", id).
Where("id = ?", id.StringValue()).
Where("org_id = ?", orgID).
Exec(ctx)
if err != nil {
@@ -169,12 +171,12 @@ func (m *modelDao) GetPAT(ctx context.Context, token string) (*types.GettablePAT
return &patWithUser, nil
}
func (m *modelDao) GetPATByID(ctx context.Context, orgID string, id string) (*types.GettablePAT, basemodel.BaseApiError) {
func (m *modelDao) GetPATByID(ctx context.Context, orgID string, id valuer.UUID) (*types.GettablePAT, basemodel.BaseApiError) {
pats := []types.StorablePersonalAccessToken{}
if err := m.DB().NewSelect().
Model(&pats).
Where("id = ?", id).
Where("id = ?", id.StringValue()).
Where("org_id = ?", orgID).
Where("revoked = false").
Scan(ctx); err != nil {

View File

@@ -52,13 +52,6 @@ var BasicPlan = basemodel.FeatureSet{
UsageLimit: -1,
Route: "",
},
basemodel.Feature{
Name: basemodel.SmartTraceDetail,
Active: false,
Usage: 0,
UsageLimit: -1,
Route: "",
},
basemodel.Feature{
Name: basemodel.CustomMetricsFunction,
Active: false,
@@ -181,13 +174,6 @@ var ProPlan = basemodel.FeatureSet{
UsageLimit: -1,
Route: "",
},
basemodel.Feature{
Name: basemodel.SmartTraceDetail,
Active: true,
Usage: 0,
UsageLimit: -1,
Route: "",
},
basemodel.Feature{
Name: basemodel.CustomMetricsFunction,
Active: true,
@@ -310,13 +296,6 @@ var EnterprisePlan = basemodel.FeatureSet{
UsageLimit: -1,
Route: "",
},
basemodel.Feature{
Name: basemodel.SmartTraceDetail,
Active: true,
Usage: 0,
UsageLimit: -1,
Route: "",
},
basemodel.Feature{
Name: basemodel.CustomMetricsFunction,
Active: true,

View File

@@ -53,7 +53,6 @@ type AnomalyRule struct {
func NewAnomalyRule(
id string,
p *baserules.PostableRule,
featureFlags interfaces.FeatureLookup,
reader interfaces.Reader,
cache cache.Cache,
opts ...baserules.RuleOption,
@@ -89,10 +88,9 @@ func NewAnomalyRule(
zap.L().Info("using seasonality", zap.String("seasonality", t.seasonality.String()))
querierOptsV2 := querierV2.QuerierOptions{
Reader: reader,
Cache: cache,
KeyGenerator: queryBuilder.NewKeyGenerator(),
FeatureLookup: featureFlags,
Reader: reader,
Cache: cache,
KeyGenerator: queryBuilder.NewKeyGenerator(),
}
t.querierV2 = querierV2.NewQuerier(querierOptsV2)
@@ -102,21 +100,18 @@ func NewAnomalyRule(
anomaly.WithCache[*anomaly.HourlyProvider](cache),
anomaly.WithKeyGenerator[*anomaly.HourlyProvider](queryBuilder.NewKeyGenerator()),
anomaly.WithReader[*anomaly.HourlyProvider](reader),
anomaly.WithFeatureLookup[*anomaly.HourlyProvider](featureFlags),
)
} else if t.seasonality == anomaly.SeasonalityDaily {
t.provider = anomaly.NewDailyProvider(
anomaly.WithCache[*anomaly.DailyProvider](cache),
anomaly.WithKeyGenerator[*anomaly.DailyProvider](queryBuilder.NewKeyGenerator()),
anomaly.WithReader[*anomaly.DailyProvider](reader),
anomaly.WithFeatureLookup[*anomaly.DailyProvider](featureFlags),
)
} else if t.seasonality == anomaly.SeasonalityWeekly {
t.provider = anomaly.NewWeeklyProvider(
anomaly.WithCache[*anomaly.WeeklyProvider](cache),
anomaly.WithKeyGenerator[*anomaly.WeeklyProvider](queryBuilder.NewKeyGenerator()),
anomaly.WithReader[*anomaly.WeeklyProvider](reader),
anomaly.WithFeatureLookup[*anomaly.WeeklyProvider](featureFlags),
)
}
return &t, nil

View File

@@ -23,7 +23,6 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
tr, err := baserules.NewThresholdRule(
ruleId,
opts.Rule,
opts.FF,
opts.Reader,
opts.UseLogsNewSchema,
opts.UseTraceNewSchema,
@@ -66,7 +65,6 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
ar, err := NewAnomalyRule(
ruleId,
opts.Rule,
opts.FF,
opts.Reader,
opts.Cache,
baserules.WithEvalDelay(opts.ManagerOpts.EvalDelay),
@@ -123,7 +121,6 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.Ap
rule, err = baserules.NewThresholdRule(
alertname,
parsedRule,
opts.FF,
opts.Reader,
opts.UseLogsNewSchema,
opts.UseTraceNewSchema,
@@ -160,7 +157,6 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.Ap
rule, err = NewAnomalyRule(
alertname,
parsedRule,
opts.FF,
opts.Reader,
opts.Cache,
baserules.WithSendAlways(),

View File

@@ -4,10 +4,28 @@ import (
"context"
"fmt"
"reflect"
"slices"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/uptrace/bun"
)
var (
Identity = "id"
Integer = "bigint"
Text = "text"
)
var (
Org = "org"
User = "user"
)
var (
OrgReference = `("org_id") REFERENCES "organizations" ("id")`
UserReference = `("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE`
)
type dialect struct {
}
@@ -175,7 +193,10 @@ func (dialect *dialect) TableExists(ctx context.Context, bun bun.IDB, table inte
return true, nil
}
func (dialect *dialect) RenameTableAndModifyModel(ctx context.Context, bun bun.IDB, oldModel interface{}, newModel interface{}, cb func(context.Context) error) error {
func (dialect *dialect) RenameTableAndModifyModel(ctx context.Context, bun bun.IDB, oldModel interface{}, newModel interface{}, references []string, cb func(context.Context) error) error {
if len(references) == 0 {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "cannot run migration without reference")
}
exists, err := dialect.TableExists(ctx, bun, newModel)
if err != nil {
return err
@@ -184,12 +205,25 @@ func (dialect *dialect) RenameTableAndModifyModel(ctx context.Context, bun bun.I
return nil
}
_, err = bun.
var fkReferences []string
for _, reference := range references {
if reference == Org && !slices.Contains(fkReferences, OrgReference) {
fkReferences = append(fkReferences, OrgReference)
} else if reference == User && !slices.Contains(fkReferences, UserReference) {
fkReferences = append(fkReferences, UserReference)
}
}
createTable := bun.
NewCreateTable().
IfNotExists().
Model(newModel).
Exec(ctx)
Model(newModel)
for _, fk := range fkReferences {
createTable = createTable.ForeignKey(fk)
}
_, err = createTable.Exec(ctx)
if err != nil {
return err
}
@@ -218,3 +252,115 @@ func (dialect *dialect) AddNotNullDefaultToColumn(ctx context.Context, bun bun.I
}
return nil
}
func (dialect *dialect) UpdatePrimaryKey(ctx context.Context, bun bun.IDB, oldModel interface{}, newModel interface{}, reference string, cb func(context.Context) error) error {
if reference == "" {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "cannot run migration without reference")
}
oldTableName := bun.Dialect().Tables().Get(reflect.TypeOf(oldModel)).Name
newTableName := bun.Dialect().Tables().Get(reflect.TypeOf(newModel)).Name
columnType, err := dialect.GetColumnType(ctx, bun, oldTableName, Identity)
if err != nil {
return err
}
if columnType == Text {
return nil
}
fkReference := ""
if reference == Org {
fkReference = OrgReference
} else if reference == User {
fkReference = UserReference
}
_, err = bun.
NewCreateTable().
IfNotExists().
Model(newModel).
ForeignKey(fkReference).
Exec(ctx)
if err != nil {
return err
}
err = cb(ctx)
if err != nil {
return err
}
_, err = bun.
NewDropTable().
IfExists().
Model(oldModel).
Exec(ctx)
if err != nil {
return err
}
_, err = bun.
ExecContext(ctx, fmt.Sprintf("ALTER TABLE %s RENAME TO %s", newTableName, oldTableName))
if err != nil {
return err
}
return nil
}
func (dialect *dialect) AddPrimaryKey(ctx context.Context, bun bun.IDB, oldModel interface{}, newModel interface{}, reference string, cb func(context.Context) error) error {
if reference == "" {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "cannot run migration without reference")
}
oldTableName := bun.Dialect().Tables().Get(reflect.TypeOf(oldModel)).Name
newTableName := bun.Dialect().Tables().Get(reflect.TypeOf(newModel)).Name
identityExists, err := dialect.ColumnExists(ctx, bun, oldTableName, Identity)
if err != nil {
return err
}
if identityExists {
return nil
}
fkReference := ""
if reference == Org {
fkReference = OrgReference
} else if reference == User {
fkReference = UserReference
}
_, err = bun.
NewCreateTable().
IfNotExists().
Model(newModel).
ForeignKey(fkReference).
Exec(ctx)
if err != nil {
return err
}
err = cb(ctx)
if err != nil {
return err
}
_, err = bun.
NewDropTable().
IfExists().
Model(oldModel).
Exec(ctx)
if err != nil {
return err
}
_, err = bun.
ExecContext(ctx, fmt.Sprintf("ALTER TABLE %s RENAME TO %s", newTableName, oldTableName))
if err != nil {
return err
}
return nil
}

View File

@@ -6,6 +6,7 @@ import (
"time"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
)
@@ -28,11 +29,10 @@ func NewGettablePAT(name, role, userID string, expiresAt int64) GettablePAT {
}
type StorablePersonalAccessToken struct {
bun.BaseModel `bun:"table:personal_access_tokens"`
bun.BaseModel `bun:"table:personal_access_token"`
types.Identifiable
types.TimeAuditable
OrgID string `json:"orgId" bun:"org_id,type:text,notnull"`
ID int `json:"id" bun:"id,pk,autoincrement"`
Role string `json:"role" bun:"role,type:text,notnull,default:'ADMIN'"`
UserID string `json:"userId" bun:"user_id,type:text,notnull"`
Token string `json:"token" bun:"token,type:text,notnull,unique"`
@@ -69,5 +69,8 @@ func NewStorablePersonalAccessToken(name, role, userID string, expiresAt int64)
CreatedAt: now,
UpdatedAt: now,
},
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
},
}
}

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.9 KiB

View File

@@ -1 +0,0 @@
<svg width="14" height="14" fill="none" xmlns="http://www.w3.org/2000/svg"><g stroke="#C0C1C3" stroke-width="1.167" stroke-linecap="round" stroke-linejoin="round"><path d="m12.192 3.18-1.167 2.33-.583 1.165M7.31 12.74a.583.583 0 0 1-.835-.24L1.808 3.179"/><path d="M7 1.167c2.9 0 5.25.783 5.25 1.75 0 .966-2.35 1.75-5.25 1.75s-5.25-.784-5.25-1.75c0-.967 2.35-1.75 5.25-1.75ZM8.75 10.5h3.5M10.5 12.25v-3.5"/></g></svg>

Before

Width:  |  Height:  |  Size: 418 B

View File

@@ -1 +0,0 @@
<svg width="16" height="16" fill="none" xmlns="http://www.w3.org/2000/svg"><g clip-path="url(#a)" stroke-linecap="round" stroke-linejoin="round"><path d="M8 14.666A6.667 6.667 0 1 0 8 1.333a6.667 6.667 0 0 0 0 13.333Z" fill="#C0C1C3" stroke="#C0C1C3" stroke-width="2"/><path d="M8 11.333v-4H6.333M8 4.667h.007" stroke="#121317" stroke-width="1.333"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 439 B

View File

@@ -18,6 +18,13 @@
"field_send_resolved": "Send resolved alerts",
"field_channel_type": "Type",
"field_webhook_url": "Webhook URL",
"tooltip_webhook_url": "The URL of the webhook to send alerts to. Learn more about webhook integration in the docs [here](https://signoz.io/docs/alerts-management/notification-channel/webhook/). Integrates with [Incident.io](https://signoz.io/docs/alerts-management/notification-channel/incident-io/), [Rootly](https://signoz.io/docs/alerts-management/notification-channel/rootly/), [Zenduty](https://signoz.io/docs/alerts-management/notification-channel/zenduty/) and [more](https://signoz.io/docs/alerts-management/notification-channel/webhook/#my-incident-management-tool-is-not-listed-can-i-still-integrate).",
"tooltip_slack_url": "The URL of the slack [incoming webhook](https://docs.slack.dev/messaging/sending-messages-using-incoming-webhooks/) to send alerts to. Learn more about slack integration in the docs [here](https://signoz.io/docs/alerts-management/notification-channel/slack/).",
"tooltip_pager_routing_key": "Learn how to obtain the routing key from your PagerDuty account [here](https://signoz.io/docs/alerts-management/notification-channel/pagerduty/#obtaining-integration-or-routing-key).",
"tooltip_opsgenie_api_key": "Learn how to obtain the API key from your OpsGenie account [here](https://support.atlassian.com/opsgenie/docs/integrate-opsgenie-with-prometheus/).",
"tooltip_email_to": "Enter email addresses separated by commas.",
"tooltip_ms_teams_url": "The URL of the Microsoft Teams [webhook](https://support.microsoft.com/en-us/office/create-incoming-webhooks-with-workflows-for-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498) to send alerts to. Learn more about Microsoft Teams integration in the docs [here](https://signoz.io/docs/alerts-management/notification-channel/ms-teams/).",
"field_slack_recipient": "Recipient",
"field_slack_title": "Title",
"field_slack_description": "Description",

View File

@@ -18,6 +18,12 @@
"field_send_resolved": "Send resolved alerts",
"field_channel_type": "Type",
"field_webhook_url": "Webhook URL",
"tooltip_webhook_url": "The URL of the webhook to send alerts to. Learn more about webhook integration in the docs [here](https://signoz.io/docs/alerts-management/notification-channel/webhook/). Integrates with [Incident.io](https://signoz.io/docs/alerts-management/notification-channel/incident-io/), [Rootly](https://signoz.io/docs/alerts-management/notification-channel/rootly/), [Zenduty](https://signoz.io/docs/alerts-management/notification-channel/zenduty/) and [more](https://signoz.io/docs/alerts-management/notification-channel/webhook/#my-incident-management-tool-is-not-listed-can-i-still-integrate).",
"tooltip_slack_url": "The URL of the slack [incoming webhook](https://docs.slack.dev/messaging/sending-messages-using-incoming-webhooks/) to send alerts to. Learn more about slack integration in the docs [here](https://signoz.io/docs/alerts-management/notification-channel/slack/).",
"tooltip_pager_routing_key": "Learn how to obtain the routing key from your PagerDuty account [here](https://signoz.io/docs/alerts-management/notification-channel/pagerduty/#obtaining-integration-or-routing-key).",
"tooltip_opsgenie_api_key": "Learn how to obtain the API key from your OpsGenie account [here](https://support.atlassian.com/opsgenie/docs/integrate-opsgenie-with-prometheus/).",
"tooltip_email_to": "Enter email addresses separated by commas.",
"tooltip_ms_teams_url": "The URL of the Microsoft Teams [webhook](https://support.microsoft.com/en-us/office/create-incoming-webhooks-with-workflows-for-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498) to send alerts to. Learn more about Microsoft Teams integration in the docs [here](https://signoz.io/docs/alerts-management/notification-channel/ms-teams/).",
"field_slack_recipient": "Recipient",
"field_slack_title": "Title",
"field_slack_description": "Description",

View File

@@ -47,10 +47,9 @@ export const TracesFunnels = Loadable(
import(/* webpackChunkName: "Traces Funnels" */ 'pages/TracesModulePage'),
);
export const TracesFunnelDetails = Loadable(
// eslint-disable-next-line sonarjs/no-identical-functions
() =>
import(
/* webpackChunkName: "Traces Funnel Details" */ 'pages/TracesModulePage'
/* webpackChunkName: "Traces Funnel Details" */ 'pages/TracesFunnelDetails'
),
);

View File

@@ -5,7 +5,6 @@ import {
CreateFunnelPayload,
CreateFunnelResponse,
FunnelData,
FunnelStepData,
} from 'types/api/traceFunnels';
const FUNNELS_BASE_PATH = '/trace-funnels';
@@ -55,7 +54,7 @@ export const getFunnelsList = async ({
};
export const getFunnelById = async (
funnelId?: string,
funnelId: string,
): Promise<SuccessResponse<FunnelData> | ErrorResponse> => {
const response: AxiosResponse = await axios.get(
`${FUNNELS_BASE_PATH}/get/${funnelId}`,
@@ -108,267 +107,3 @@ export const deleteFunnel = async (
payload: response.data,
};
};
export interface UpdateFunnelStepsPayload {
funnel_id: string;
steps: FunnelStepData[];
updated_timestamp: number;
}
export const updateFunnelSteps = async (
payload: UpdateFunnelStepsPayload,
): Promise<SuccessResponse<FunnelData> | ErrorResponse> => {
const response: AxiosResponse = await axios.put(
`${FUNNELS_BASE_PATH}/steps/update`,
payload,
);
return {
statusCode: 200,
error: null,
message: 'Funnel steps updated successfully',
payload: response.data,
};
};
export interface ValidateFunnelPayload {
start_time: number;
end_time: number;
}
export interface ValidateFunnelResponse {
status: string;
data: Array<{
timestamp: string;
data: {
trace_id: string;
};
}> | null;
}
export const validateFunnelSteps = async (
funnelId: string,
payload: ValidateFunnelPayload,
signal?: AbortSignal,
): Promise<SuccessResponse<ValidateFunnelResponse> | ErrorResponse> => {
const response = await axios.post(
`${FUNNELS_BASE_PATH}/${funnelId}/analytics/validate`,
payload,
{ signal },
);
return {
statusCode: 200,
error: null,
message: '',
payload: response.data,
};
};
export interface UpdateFunnelStepDetailsPayload {
funnel_id: string;
steps: Array<{
step_name: string;
description: string;
}>;
updated_timestamp: number;
}
export const updateFunnelStepDetails = async ({
stepOrder,
payload,
}: {
stepOrder: number;
payload: UpdateFunnelStepDetailsPayload;
}): Promise<SuccessResponse<FunnelData> | ErrorResponse> => {
const response: AxiosResponse = await axios.put(
`${FUNNELS_BASE_PATH}/steps/${stepOrder}/update`,
payload,
);
return {
statusCode: 200,
error: null,
message: 'Funnel step details updated successfully',
payload: response.data,
};
};
interface UpdateFunnelDescriptionPayload {
funnel_id: string;
description: string;
}
export const saveFunnelDescription = async (
payload: UpdateFunnelDescriptionPayload,
): Promise<SuccessResponse<FunnelData> | ErrorResponse> => {
const response: AxiosResponse = await axios.post(
`${FUNNELS_BASE_PATH}/save`,
payload,
);
return {
statusCode: 200,
error: null,
message: 'Funnel description updated successfully',
payload: response.data,
};
};
export interface FunnelOverviewPayload {
start_time: number;
end_time: number;
step_start?: number;
step_end?: number;
}
export interface FunnelOverviewResponse {
status: string;
data: Array<{
timestamp: string;
data: {
avg_duration: number;
avg_rate: number;
conversion_rate: number | null;
errors: number;
p99_latency: number;
};
}>;
}
export const getFunnelOverview = async (
funnelId: string,
payload: FunnelOverviewPayload,
signal?: AbortSignal,
): Promise<SuccessResponse<FunnelOverviewResponse> | ErrorResponse> => {
const response = await axios.post(
`${FUNNELS_BASE_PATH}/${funnelId}/analytics/overview`,
payload,
{
signal,
},
);
return {
statusCode: 200,
error: null,
message: '',
payload: response.data,
};
};
export interface SlowTracesPayload {
start_time: number;
end_time: number;
step_a_order: number;
step_b_order: number;
}
export interface SlowTraceData {
status: string;
data: Array<{
timestamp: string;
data: {
duration_ms: string;
span_count: number;
trace_id: string;
};
}>;
}
export const getFunnelSlowTraces = async (
funnelId: string,
payload: SlowTracesPayload,
signal?: AbortSignal,
): Promise<SuccessResponse<SlowTraceData> | ErrorResponse> => {
const response = await axios.post(
`${FUNNELS_BASE_PATH}/${funnelId}/analytics/slow-traces`,
payload,
{
signal,
},
);
return {
statusCode: 200,
error: null,
message: '',
payload: response.data,
};
};
export interface ErrorTracesPayload {
start_time: number;
end_time: number;
step_a_order: number;
step_b_order: number;
}
export interface ErrorTraceData {
status: string;
data: Array<{
timestamp: string;
data: {
duration_ms: string;
span_count: number;
trace_id: string;
};
}>;
}
export const getFunnelErrorTraces = async (
funnelId: string,
payload: ErrorTracesPayload,
signal?: AbortSignal,
): Promise<SuccessResponse<ErrorTraceData> | ErrorResponse> => {
const response: AxiosResponse = await axios.post(
`${FUNNELS_BASE_PATH}/${funnelId}/analytics/error-traces`,
payload,
{
signal,
},
);
return {
statusCode: 200,
error: null,
message: '',
payload: response.data,
};
};
export interface FunnelStepsPayload {
start_time: number;
end_time: number;
}
export interface FunnelStepGraphMetrics {
[key: `total_s${number}_spans`]: number;
[key: `total_s${number}_errored_spans`]: number;
}
export interface FunnelStepsResponse {
status: string;
data: Array<{
timestamp: string;
data: FunnelStepGraphMetrics;
}>;
}
export const getFunnelSteps = async (
funnelId: string,
payload: FunnelStepsPayload,
signal?: AbortSignal,
): Promise<SuccessResponse<FunnelStepsResponse> | ErrorResponse> => {
const response = await axios.post(
`${FUNNELS_BASE_PATH}/${funnelId}/analytics/steps`,
payload,
{ signal },
);
return {
statusCode: 200,
error: null,
message: '',
payload: response.data,
};
};

View File

@@ -11,24 +11,16 @@ import { QueryParams } from 'constants/query';
import useUrlQuery from 'hooks/useUrlQuery';
import { useHistory, useLocation } from 'react-router-dom';
export interface SelectOptionConfig {
interface SelectOptionConfig {
placeholder: string;
queryParam: QueryParams;
filterType: string | string[];
shouldSetQueryParams?: boolean;
onChange?: (value: string | string[]) => void;
values?: string | string[];
isMultiple?: boolean;
}
export function FilterSelect({
function FilterSelect({
placeholder,
queryParam,
filterType,
values,
shouldSetQueryParams,
onChange,
isMultiple,
}: SelectOptionConfig): JSX.Element {
const { handleSearch, isFetching, options } = useCeleryFilterOptions(
filterType,
@@ -43,8 +35,7 @@ export function FilterSelect({
key={filterType.toString()}
placeholder={placeholder}
showSearch
// eslint-disable-next-line react/jsx-props-no-spreading
{...(isMultiple ? { mode: 'multiple' } : {})}
mode="multiple"
options={options}
loading={isFetching}
className="config-select-option"
@@ -52,11 +43,7 @@ export function FilterSelect({
maxTagCount={4}
allowClear
maxTagPlaceholder={SelectMaxTagPlaceholder}
value={
!shouldSetQueryParams && !!values?.length
? values
: getValuesFromQueryParams(queryParam, urlQuery) || []
}
value={getValuesFromQueryParams(queryParam, urlQuery) || []}
notFoundContent={
isFetching ? (
<span>
@@ -68,28 +55,12 @@ export function FilterSelect({
}
onChange={(value): void => {
handleSearch('');
if (shouldSetQueryParams) {
setQueryParamsFromOptions(
value as string[],
urlQuery,
history,
location,
queryParam,
);
}
onChange?.(value);
setQueryParamsFromOptions(value, urlQuery, history, location, queryParam);
}}
/>
);
}
FilterSelect.defaultProps = {
shouldSetQueryParams: true,
onChange: (): void => {},
values: [],
isMultiple: true,
};
function CeleryOverviewConfigOptions(): JSX.Element {
const selectConfigs: SelectOptionConfig[] = [
{

View File

@@ -1,40 +0,0 @@
.change-percentage-pill {
display: flex;
align-items: center;
gap: 2px;
padding: 1px 4px;
border-radius: 50px;
&__icon {
display: flex;
align-items: center;
justify-content: center;
}
&__label {
font-family: 'Geist Mono';
font-size: 12px;
line-height: normal;
}
&--positive {
.change-percentage-pill {
&__icon {
color: var(--bg-forest-500);
}
&__label {
color: var(--bg-forest-500);
}
}
}
&--negative {
background: rgba(229, 72, 77, 0.1);
.change-percentage-pill {
&__icon {
color: var(--bg-cherry-500);
}
&__label {
color: var(--bg-cherry-500);
}
}
}
}

View File

@@ -1,38 +0,0 @@
import './ChangePercentagePill.styles.scss';
import { Color } from '@signozhq/design-tokens';
import cx from 'classnames';
import { ArrowDown, ArrowUp } from 'lucide-react';
interface ChangePercentagePillProps {
percentage: number;
direction: number;
}
function ChangePercentagePill({
percentage,
direction,
}: ChangePercentagePillProps): JSX.Element | null {
if (direction === 0 || percentage === 0) {
return null;
}
const isPositive = direction > 0;
return (
<div
className={cx('change-percentage-pill', {
'change-percentage-pill--positive': isPositive,
'change-percentage-pill--negative': !isPositive,
})}
>
<div className="change-percentage-pill__icon">
{isPositive ? (
<ArrowUp size={12} color={Color.BG_FOREST_500} />
) : (
<ArrowDown size={12} color={Color.BG_CHERRY_500} />
)}
</div>
<div className="change-percentage-pill__label">{percentage}%</div>
</div>
);
}
export default ChangePercentagePill;

View File

@@ -1,55 +0,0 @@
.signoz-radio-group.ant-radio-group {
color: var(--text-vanilla-400);
.view-title {
display: flex;
gap: var(--margin-2);
align-items: center;
justify-content: center;
font-size: var(--font-size-xs);
font-style: normal;
font-weight: var(--font-weight-normal);
}
.tab {
border: 1px solid var(--bg-slate-400);
&:hover {
color: var(--text-vanilla-100);
}
&::before {
background: var(--bg-slate-400);
}
}
.selected_view {
&,
&:hover {
background: var(--bg-slate-300);
color: var(--text-vanilla-100);
border: 1px solid var(--bg-slate-400);
}
&::before {
background: var(--bg-slate-400);
}
}
}
// Light mode styles
.lightMode {
.signoz-radio-group {
.tab {
background: var(--bg-vanilla-100);
}
.selected_view {
background: var(--bg-vanilla-300);
border: 1px solid var(--bg-slate-300);
color: var(--text-ink-400);
}
.selected_view::before {
background: var(--bg-vanilla-300);
border-left: 1px solid var(--bg-slate-300);
}
}
}

View File

@@ -1,48 +0,0 @@
import './SignozRadioGroup.styles.scss';
import { Radio } from 'antd';
import { RadioChangeEvent } from 'antd/es/radio';
interface Option {
value: string;
label: string;
}
interface SignozRadioGroupProps {
value: string;
options: Option[];
onChange: (e: RadioChangeEvent) => void;
className?: string;
}
function SignozRadioGroup({
value,
options,
onChange,
className = '',
}: SignozRadioGroupProps): JSX.Element {
return (
<Radio.Group
value={value}
buttonStyle="solid"
className={`signoz-radio-group ${className}`}
onChange={onChange}
>
{options.map((option) => (
<Radio.Button
key={option.value}
value={option.value}
className={value === option.value ? 'selected_view tab' : 'tab'}
>
{option.label}
</Radio.Button>
))}
</Radio.Group>
);
}
SignozRadioGroup.defaultProps = {
className: '',
};
export default SignozRadioGroup;

View File

@@ -8,9 +8,6 @@ export enum FeatureKeys {
ALERT_CHANNEL_PAGERDUTY = 'ALERT_CHANNEL_PAGERDUTY',
ALERT_CHANNEL_OPSGENIE = 'ALERT_CHANNEL_OPSGENIE',
ALERT_CHANNEL_MSTEAMS = 'ALERT_CHANNEL_MSTEAMS',
DurationSort = 'DurationSort',
TimestampSort = 'TimestampSort',
SMART_TRACE_DETAIL = 'SMART_TRACE_DETAIL',
CUSTOM_METRICS_FUNCTION = 'CUSTOM_METRICS_FUNCTION',
QUERY_BUILDER_PANELS = 'QUERY_BUILDER_PANELS',
QUERY_BUILDER_ALERTS = 'QUERY_BUILDER_ALERTS',

View File

@@ -52,7 +52,7 @@ export const REACT_QUERY_KEY = {
GET_METRIC_DETAILS: 'GET_METRIC_DETAILS',
GET_RELATED_METRICS: 'GET_RELATED_METRICS',
// Traces Funnels Query Keys
// API Monitoring Query Keys
GET_DOMAINS_LIST: 'GET_DOMAINS_LIST',
GET_ENDPOINTS_LIST_BY_DOMAIN: 'GET_ENDPOINTS_LIST_BY_DOMAIN',
GET_NESTED_ENDPOINTS_LIST: 'GET_NESTED_ENDPOINTS_LIST',
@@ -68,11 +68,4 @@ export const REACT_QUERY_KEY = {
'GET_ENDPOINT_STATUS_CODE_LATENCY_BAR_CHARTS_DATA',
GET_FUNNELS_LIST: 'GET_FUNNELS_LIST',
GET_FUNNEL_DETAILS: 'GET_FUNNEL_DETAILS',
UPDATE_FUNNEL_STEPS: 'UPDATE_FUNNEL_STEPS',
VALIDATE_FUNNEL_STEPS: 'VALIDATE_FUNNEL_STEPS',
UPDATE_FUNNEL_STEP_DETAILS: 'UPDATE_FUNNEL_STEP_DETAILS',
GET_FUNNEL_OVERVIEW: 'GET_FUNNEL_OVERVIEW',
GET_FUNNEL_SLOW_TRACES: 'GET_FUNNEL_SLOW_TRACES',
GET_FUNNEL_ERROR_TRACES: 'GET_FUNNEL_ERROR_TRACES',
GET_FUNNEL_STEPS_GRAPH_DATA: 'GET_FUNNEL_STEPS_GRAPH_DATA',
} as const;

View File

@@ -31,6 +31,10 @@ jest.mock('hooks/useNotifications', () => ({
})),
}));
jest.mock('components/MarkdownRenderer/MarkdownRenderer', () => ({
MarkdownRenderer: jest.fn(() => <div>Mocked MarkdownRenderer</div>),
}));
describe('Create Alert Channel', () => {
afterEach(() => {
jest.clearAllMocks();

View File

@@ -18,6 +18,10 @@ import { render, screen } from 'tests/test-utils';
import { testLabelInputAndHelpValue } from './testUtils';
jest.mock('components/MarkdownRenderer/MarkdownRenderer', () => ({
MarkdownRenderer: jest.fn(() => <div>Mocked MarkdownRenderer</div>),
}));
describe('Create Alert Channel (Normal User)', () => {
afterEach(() => {
jest.clearAllMocks();

View File

@@ -20,6 +20,10 @@ jest.mock('hooks/useNotifications', () => ({
})),
}));
jest.mock('components/MarkdownRenderer/MarkdownRenderer', () => ({
MarkdownRenderer: jest.fn(() => <div>Mocked MarkdownRenderer</div>),
}));
describe('Should check if the edit alert channel is properly displayed ', () => {
beforeEach(() => {
render(<EditAlertChannels initialValue={editAlertChannelInitialValue} />);

View File

@@ -42,7 +42,7 @@ import { Helmet } from 'react-helmet-async';
import { useTranslation } from 'react-i18next';
import { useMutation, useQueries } from 'react-query';
import { useDispatch } from 'react-redux';
import { matchPath, useLocation } from 'react-router-dom';
import { useLocation } from 'react-router-dom';
import { Dispatch } from 'redux';
import AppActions from 'types/actions';
import {
@@ -360,9 +360,6 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
routeKey === 'INFRASTRUCTURE_MONITORING_HOSTS' ||
routeKey === 'INFRASTRUCTURE_MONITORING_KUBERNETES';
const isTracesFunnels = (): boolean => routeKey === 'TRACES_FUNNELS';
const isTracesFunnelDetails = (): boolean =>
!!matchPath(pathname, ROUTES.TRACES_FUNNELS_DETAIL);
const isPathMatch = (regex: RegExp): boolean => regex.test(pathname);
const isDashboardView = (): boolean =>
@@ -668,11 +665,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
? 0
: '0 1rem',
...(isTraceDetailsView() ||
isTracesFunnels() ||
isTracesFunnelDetails()
? { margin: 0 }
: {}),
...(isTraceDetailsView() || isTracesFunnels() ? { margin: 0 } : {}),
}}
>
{isToDisplayLayout && !renderFullScreen && <TopNav />}

View File

@@ -1,4 +1,5 @@
import { Form, Input } from 'antd';
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
import React from 'react';
import { useTranslation } from 'react-i18next';
@@ -9,7 +10,20 @@ function MsTeams({ setSelectedConfig }: MsTeamsProps): JSX.Element {
return (
<>
<Form.Item name="webhook_url" label={t('field_webhook_url')}>
<Form.Item
name="webhook_url"
label={t('field_webhook_url')}
tooltip={{
title: (
<MarkdownRenderer
markdownContent={t('tooltip_ms_teams_url')}
variables={{}}
/>
),
overlayInnerStyle: { maxWidth: 400 },
placement: 'right',
}}
>
<Input
onChange={(event): void => {
setSelectedConfig((value) => ({

View File

@@ -1,4 +1,5 @@
import { Form, Input } from 'antd';
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
import { useTranslation } from 'react-i18next';
import { OpsgenieChannel } from '../../CreateAlertChannels/config';
@@ -19,7 +20,21 @@ function OpsgenieForm({ setSelectedConfig }: OpsgenieFormProps): JSX.Element {
return (
<>
<Form.Item name="api_key" label={t('field_opsgenie_api_key')} required>
<Form.Item
name="api_key"
label={t('field_opsgenie_api_key')}
tooltip={{
title: (
<MarkdownRenderer
markdownContent={t('tooltip_opsgenie_api_key')}
variables={{}}
/>
),
overlayInnerStyle: { maxWidth: 400 },
placement: 'right',
}}
required
>
<Input
onChange={handleInputChange('api_key')}
data-testid="opsgenie-api-key-textbox"

View File

@@ -1,4 +1,5 @@
import { Form, Input } from 'antd';
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
import { Dispatch, SetStateAction } from 'react';
import { useTranslation } from 'react-i18next';
@@ -10,7 +11,20 @@ function PagerForm({ setSelectedConfig }: PagerFormProps): JSX.Element {
const { t } = useTranslation('channels');
return (
<>
<Form.Item name="routing_key" label={t('field_pager_routing_key')} required>
<Form.Item
name="routing_key"
label={t('field_pager_routing_key')}
tooltip={{
title: (
<MarkdownRenderer
markdownContent={t('tooltip_pager_routing_key')}
variables={{}}
/>
),
overlayInnerStyle: { maxWidth: 400 },
placement: 'right',
}}
>
<Input
onChange={(event): void => {
setSelectedConfig((value) => ({

View File

@@ -1,4 +1,5 @@
import { Form, Input } from 'antd';
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
import { Dispatch, SetStateAction } from 'react';
import { useTranslation } from 'react-i18next';
@@ -11,7 +12,20 @@ function Slack({ setSelectedConfig }: SlackProps): JSX.Element {
return (
<>
<Form.Item name="api_url" label={t('field_webhook_url')}>
<Form.Item
name="api_url"
label={t('field_webhook_url')}
tooltip={{
title: (
<MarkdownRenderer
markdownContent={t('tooltip_slack_url')}
variables={{}}
/>
),
overlayInnerStyle: { maxWidth: 400 },
placement: 'right',
}}
>
<Input
onChange={(event): void => {
setSelectedConfig((value) => ({

View File

@@ -1,4 +1,5 @@
import { Form, Input } from 'antd';
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
import { Dispatch, SetStateAction } from 'react';
import { useTranslation } from 'react-i18next';
@@ -9,7 +10,20 @@ function WebhookSettings({ setSelectedConfig }: WebhookProps): JSX.Element {
return (
<>
<Form.Item name="api_url" label={t('field_webhook_url')}>
<Form.Item
name="api_url"
label={t('field_webhook_url')}
tooltip={{
title: (
<MarkdownRenderer
markdownContent={t('tooltip_webhook_url')}
variables={{}}
/>
),
overlayInnerStyle: { maxWidth: 400 },
placement: 'right',
}}
>
<Input
onChange={(event): void => {
setSelectedConfig((value) => ({

View File

@@ -3,6 +3,7 @@
border-top: 1px solid var(--bg-slate-400);
border-bottom: 1px solid var(--bg-slate-400);
background: var(--bg-ink-400);
width: 100%;
// height: 280px;
// overflow: hidden;
@@ -120,6 +121,9 @@
// height: 100%;
scroll-snap-type: y mandatory;
&-row {
width: 100%;
}
.query,
.formula {

View File

@@ -4,5 +4,6 @@
}
.qb-container {
width: 100%;
padding: 0 24px;
}

View File

@@ -329,7 +329,10 @@ export const Query = memo(function Query({
const isVersionV4 = version && version === ENTITY_VERSION_V4;
return (
<Row gutter={[0, 12]} className={`query-builder-${version}`}>
<Row
gutter={[0, 12]}
className={`query-builder-queries-formula-container-row query-builder-${version}`}
>
<QBEntityOptions
isMetricsDataSource={isMetricsDataSource}
showFunctions={
@@ -453,7 +456,7 @@ export const Query = memo(function Query({
</Col>
)}
<Col flex="1" className="qb-search-container">
{[DataSource.LOGS, DataSource.TRACES].includes(query.dataSource) ? (
{query.dataSource === DataSource.LOGS ? (
<QueryBuilderSearchV2
query={query}
onChange={handleChangeTagFilters}

View File

@@ -2,7 +2,6 @@
import './QueryBuilderSearchV2.styles.scss';
import { Typography } from 'antd';
import cx from 'classnames';
import {
ArrowDown,
ArrowUp,
@@ -26,7 +25,6 @@ interface ICustomDropdownProps {
exampleQueries: TagFilter[];
onChange: (value: TagFilter) => void;
currentFilterItem?: ITag;
isLogsDataSource: boolean;
}
export default function QueryBuilderSearchDropdown(
@@ -40,14 +38,11 @@ export default function QueryBuilderSearchDropdown(
exampleQueries,
options,
onChange,
isLogsDataSource,
} = props;
const userOs = getUserOperatingSystem();
return (
<>
<div
className={cx('content', { 'non-logs-data-source': !isLogsDataSource })}
>
<div className="content">
{!currentFilterItem?.key ? (
<div className="suggested-filters">Suggested Filters</div>
) : !currentFilterItem?.op ? (

View File

@@ -1,19 +1,3 @@
.query-builder-search {
.content {
.suggested-filters {
color: var(--bg-slate-50);
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 600;
line-height: 18px;
letter-spacing: 0.88px;
text-transform: uppercase;
padding: 12px 0px 8px 14px;
}
}
}
.query-builder-search-v2 {
display: flex;
gap: 4px;
@@ -27,11 +11,6 @@
.rc-virtual-list-holder {
height: 115px;
}
&.non-logs-data-source {
.rc-virtual-list-holder {
height: 256px;
}
}
}
}

View File

@@ -88,9 +88,6 @@ interface QueryBuilderSearchV2Props {
className?: string;
suffixIcon?: React.ReactNode;
hardcodedAttributeKeys?: BaseAutocompleteData[];
hasPopupContainer?: boolean;
rootClassName?: string;
maxTagCount?: number | 'responsive';
}
export interface Option {
@@ -124,9 +121,6 @@ function QueryBuilderSearchV2(
suffixIcon,
whereClauseConfig,
hardcodedAttributeKeys,
hasPopupContainer,
rootClassName,
maxTagCount,
} = props;
const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys();
@@ -695,29 +689,12 @@ function QueryBuilderSearchV2(
})),
);
} else {
setDropdownOptions([
// Add user typed option if it doesn't exist in the payload
...(!isEmpty(tagKey) &&
!data?.payload?.attributeKeys?.some((val) => isEqual(val.key, tagKey))
? [
{
label: tagKey,
value: {
key: tagKey,
dataType: DataTypes.EMPTY,
type: '',
isColumn: false,
isJSON: false,
},
},
]
: []),
// Map existing attribute keys from payload
...(data?.payload?.attributeKeys?.map((key) => ({
setDropdownOptions(
data?.payload?.attributeKeys?.map((key) => ({
label: key.key,
value: key,
})) || []),
]);
})) || [],
);
}
}
if (currentState === DropdownState.OPERATOR) {
@@ -934,10 +911,7 @@ function QueryBuilderSearchV2(
<div className="query-builder-search-v2">
<Select
ref={selectRef}
// eslint-disable-next-line react/jsx-props-no-spreading
{...(hasPopupContainer ? { getPopupContainer: popupContainer } : {})}
// eslint-disable-next-line react/jsx-props-no-spreading
{...(maxTagCount ? { maxTagCount } : {})}
getPopupContainer={popupContainer}
key={queryTags.join('.')}
virtual={false}
showSearch
@@ -969,7 +943,7 @@ function QueryBuilderSearchV2(
: '',
className,
)}
rootClassName={cx('query-builder-search', rootClassName)}
rootClassName="query-builder-search"
disabled={isMetricsDataSource && !query.aggregateAttribute.key}
style={selectStyle}
onSearch={handleSearch}
@@ -990,7 +964,6 @@ function QueryBuilderSearchV2(
exampleQueries={suggestionsData?.payload?.example_queries || []}
tags={tags}
currentFilterItem={currentFilterItem}
isLogsDataSource={isLogsDataSource}
/>
)}
>
@@ -1026,10 +999,7 @@ QueryBuilderSearchV2.defaultProps = {
className: '',
suffixIcon: null,
whereClauseConfig: {},
hasPopupContainer: true,
rootClassName: '',
hardcodedAttributeKeys: undefined,
maxTagCount: undefined,
};
export default QueryBuilderSearchV2;

View File

@@ -1,240 +0,0 @@
// Modal base styles
.add-span-to-funnel-modal-container {
.ant-modal {
&-content,
&-header {
background: var(--bg-ink-500);
}
&-header {
border-bottom: none;
.ant-modal-title {
color: var(--bg-vanilla-100);
}
}
&-body {
padding: 14px 16px !important;
}
}
&--details {
.ant-modal-content {
height: 710px;
}
}
}
// Main modal styles
.add-span-to-funnel-modal {
// Common button styles
%button-base {
display: flex;
align-items: center;
font-family: Inter;
}
// Details view styles
&--details {
.traces-funnel-details {
height: unset;
&__steps-config {
width: unset;
border: none;
}
.funnel-step-wrapper {
gap: 15px;
}
.steps-content {
height: 500px;
}
}
}
// Search section
&__search {
display: flex;
gap: 12px;
margin-bottom: 14px;
align-items: center;
&-input {
flex: 1;
padding: 6px 8px;
background: var(--bg-ink-300);
.ant-input-prefix {
height: 18px;
margin-inline-end: 6px;
svg {
opacity: 0.4;
}
}
&,
input {
font-family: Inter;
font-size: 14px;
line-height: 18px;
letter-spacing: -0.07px;
font-weight: 400;
background: var(--bg-ink-300);
}
input::placeholder {
color: var(--bg-vanilla-400);
opacity: 0.4;
}
}
}
// Create button
&__create-button {
@extend %button-base;
width: 153px;
padding: 4px 8px;
justify-content: center;
gap: 4px;
flex-shrink: 0;
border-radius: 2px;
background: var(--bg-slate-500);
border: none;
box-shadow: none;
color: var(--bg-vanilla-400);
font-size: 12px;
font-weight: 500;
line-height: 24px;
}
.funnel-item {
padding: 8px 16px 12px;
&,
&:first-child {
border-radius: 6px;
}
&__header {
line-height: 20px;
}
&__details {
line-height: 18px;
}
}
// List section
&__list {
max-height: 400px;
overflow-y: scroll;
.funnels-empty {
&__content {
padding: 0;
}
}
.funnels-list {
gap: 8px;
.funnel-item {
padding: 8px 16px 12px;
&__details {
margin-top: 8px;
}
}
}
}
&__spinner {
height: 400px;
}
// Back button
&__back-button {
@extend %button-base;
gap: 6px;
color: var(--bg-vanilla-400);
font-size: 14px;
line-height: 20px;
margin-bottom: 14px;
}
// Details section
&__details {
display: flex;
flex-direction: column;
gap: 24px;
.funnel-configuration__steps {
padding: 0;
.funnel-step {
&__content .filters__service-and-span .ant-select {
width: 170px;
}
&__footer .error {
width: 25%;
}
}
.inter-step-config {
width: calc(100% - 104px);
}
}
.funnel-item__actions-popover {
display: none;
}
}
}
// Light mode styles
.lightMode {
.add-span-to-funnel-modal-container {
.ant-modal {
&-content,
&-header {
background: var(--bg-vanilla-100);
}
&-title {
color: var(--bg-ink-500);
}
}
}
.add-span-to-funnel-modal {
&__search-input {
background: var(--bg-vanilla-100);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-500);
input {
color: var(--bg-ink-500);
background: var(--bg-vanilla-100);
&::placeholder {
color: var(--bg-ink-400);
}
}
}
&__create-button {
background: var(--bg-vanilla-300);
color: var(--bg-ink-400);
}
&__back-button {
color: var(--bg-ink-500);
&:hover {
color: var(--bg-ink-400);
}
}
&__details h3 {
color: var(--bg-ink-500);
}
}
}

View File

@@ -1,204 +0,0 @@
import './AddSpanToFunnelModal.styles.scss';
import { LoadingOutlined } from '@ant-design/icons';
import { Button, Input, Spin } from 'antd';
import cx from 'classnames';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import SignozModal from 'components/SignozModal/SignozModal';
import {
useFunnelDetails,
useFunnelsList,
} from 'hooks/TracesFunnels/useFunnels';
import { ArrowLeft, Plus, Search } from 'lucide-react';
import FunnelConfiguration from 'pages/TracesFunnelDetails/components/FunnelConfiguration/FunnelConfiguration';
import { TracesFunnelsContentRenderer } from 'pages/TracesFunnels';
import CreateFunnel from 'pages/TracesFunnels/components/CreateFunnel/CreateFunnel';
import { FunnelListItem } from 'pages/TracesFunnels/components/FunnelsList/FunnelsList';
import { FunnelProvider } from 'pages/TracesFunnels/FunnelContext';
import { ChangeEvent, useMemo, useState } from 'react';
import { Span } from 'types/api/trace/getTraceV2';
import { FunnelData } from 'types/api/traceFunnels';
enum ModalView {
LIST = 'list',
DETAILS = 'details',
}
function FunnelDetailsView({
funnel,
span,
}: {
funnel: FunnelData;
span: Span;
}): JSX.Element {
return (
<div className="add-span-to-funnel-modal__details">
<FunnelListItem
funnel={funnel}
shouldRedirectToTracesListOnDeleteSuccess={false}
/>
<FunnelConfiguration funnel={funnel} isTraceDetailsPage span={span} />
</div>
);
}
interface AddSpanToFunnelModalProps {
isOpen: boolean;
onClose: () => void;
span: Span;
}
function AddSpanToFunnelModal({
isOpen,
onClose,
span,
}: AddSpanToFunnelModalProps): JSX.Element {
const [activeView, setActiveView] = useState<ModalView>(ModalView.LIST);
const [searchQuery, setSearchQuery] = useState<string>('');
const [selectedFunnelId, setSelectedFunnelId] = useState<string | undefined>(
undefined,
);
const [isCreateModalOpen, setIsCreateModalOpen] = useState<boolean>(false);
const handleSearch = (e: ChangeEvent<HTMLInputElement>): void => {
setSearchQuery(e.target.value);
};
const { data, isLoading, isError, isFetching } = useFunnelsList({
searchQuery: '',
});
const filteredData = useMemo(
() =>
data?.payload
?.filter((funnel) =>
funnel.funnel_name.toLowerCase().includes(searchQuery.toLowerCase()),
)
.sort(
(a, b) =>
new Date(b.creation_timestamp).getTime() -
new Date(a.creation_timestamp).getTime(),
),
[data?.payload, searchQuery],
);
const {
data: funnelDetails,
isLoading: isFunnelDetailsLoading,
isFetching: isFunnelDetailsFetching,
} = useFunnelDetails({
funnelId: selectedFunnelId,
});
const handleFunnelClick = (funnel: FunnelData): void => {
setSelectedFunnelId(funnel.id);
setActiveView(ModalView.DETAILS);
};
const handleBack = (): void => {
setActiveView(ModalView.LIST);
setSelectedFunnelId(undefined);
};
const handleCreateNewClick = (): void => {
setIsCreateModalOpen(true);
};
const renderListView = (): JSX.Element => (
<div className="add-span-to-funnel-modal">
{!!filteredData?.length && (
<div className="add-span-to-funnel-modal__search">
<Input
className="add-span-to-funnel-modal__search-input"
placeholder="Search by name, description, or tags..."
prefix={<Search size={12} />}
value={searchQuery}
onChange={handleSearch}
/>
</div>
)}
<div className="add-span-to-funnel-modal__list">
<OverlayScrollbar>
<TracesFunnelsContentRenderer
isError={isError}
isLoading={isLoading || isFetching}
data={filteredData || []}
onCreateFunnel={handleCreateNewClick}
onFunnelClick={(funnel: FunnelData): void => handleFunnelClick(funnel)}
shouldRedirectToTracesListOnDeleteSuccess={false}
/>
</OverlayScrollbar>
</div>
<CreateFunnel
isOpen={isCreateModalOpen}
onClose={(funnelId): void => {
if (funnelId) {
setSelectedFunnelId(funnelId);
setActiveView(ModalView.DETAILS);
}
setIsCreateModalOpen(false);
}}
redirectToDetails={false}
/>
</div>
);
const renderDetailsView = ({ span }: { span: Span }): JSX.Element => (
<div className="add-span-to-funnel-modal add-span-to-funnel-modal--details">
<Button
type="text"
className="add-span-to-funnel-modal__back-button"
onClick={handleBack}
>
<ArrowLeft size={14} />
All funnels
</Button>
<Spin
style={{ height: 400 }}
spinning={isFunnelDetailsLoading || isFunnelDetailsFetching}
indicator={<LoadingOutlined spin />}
>
<div className="traces-funnel-details">
<div className="traces-funnel-details__steps-config">
{selectedFunnelId && funnelDetails?.payload && (
<FunnelProvider funnelId={selectedFunnelId}>
<FunnelDetailsView funnel={funnelDetails.payload} span={span} />
</FunnelProvider>
)}
</div>
</div>
</Spin>
</div>
);
return (
<SignozModal
open={isOpen}
onCancel={onClose}
width={570}
title="Add span to funnel"
className={cx('add-span-to-funnel-modal-container', {
'add-span-to-funnel-modal-container--details':
activeView === ModalView.DETAILS,
})}
okText="Save Funnel"
footer={
activeView === ModalView.LIST && !!filteredData?.length ? (
<Button
type="default"
className="add-span-to-funnel-modal__create-button"
onClick={handleCreateNewClick}
icon={<Plus size={14} />}
>
Create new funnel
</Button>
) : null
}
>
{activeView === ModalView.LIST
? renderListView()
: renderDetailsView({ span })}
</SignozModal>
);
}
export default AddSpanToFunnelModal;

View File

@@ -95,10 +95,6 @@
border-radius: 4px;
background: rgba(171, 189, 255, 0.06) !important;
.div-td .span-overview .second-row .add-funnel-button {
opacity: 1;
}
.span-overview {
background: unset !important;
@@ -235,24 +231,6 @@
line-height: 18px; /* 128.571% */
letter-spacing: -0.07px;
}
.add-funnel-button {
position: relative;
z-index: 1;
opacity: 0;
display: flex;
align-items: center;
gap: 6px;
transition: opacity 0.1s ease-in-out;
&__separator {
color: var(--bg-vanilla-400);
}
&__button {
display: flex;
align-items: center;
justify-content: center;
}
}
}
}
}

View File

@@ -9,7 +9,6 @@ import cx from 'classnames';
import { TableV3 } from 'components/TableV3/TableV3';
import { themeColors } from 'constants/theme';
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
import AddSpanToFunnelModal from 'container/TraceWaterfall/AddSpanToFunnelModal/AddSpanToFunnelModal';
import { IInterestedSpan } from 'container/TraceWaterfall/TraceWaterfall';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import {
@@ -26,7 +25,6 @@ import {
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Span } from 'types/api/trace/getTraceV2';
import { toFixed } from 'utils/toFixed';
@@ -59,7 +57,6 @@ function SpanOverview({
isSpanCollapsed,
handleCollapseUncollapse,
setSelectedSpan,
handleAddSpanToFunnel,
selectedSpan,
}: {
span: Span;
@@ -67,7 +64,6 @@ function SpanOverview({
handleCollapseUncollapse: (id: string, collapse: boolean) => void;
selectedSpan: Span | undefined;
setSelectedSpan: Dispatch<SetStateAction<Span | undefined>>;
handleAddSpanToFunnel: (span: Span) => void;
}): JSX.Element {
const isRootSpan = span.level === 0;
@@ -145,28 +141,6 @@ function SpanOverview({
<Typography.Text className="service-name">
{span.serviceName}
</Typography.Text>
{!!span.serviceName && !!span.name && (
<div className="add-funnel-button">
<span className="add-funnel-button__separator">·</span>
<Button
type="text"
size="small"
className="add-funnel-button__button"
onClick={(e): void => {
e.preventDefault();
e.stopPropagation();
handleAddSpanToFunnel(span);
}}
icon={
<img
className="add-funnel-button__icon"
src="/Icons/funnel-add.svg"
alt="funnel-icon"
/>
}
/>
</div>
)}
</section>
</div>
</div>
@@ -236,14 +210,12 @@ function getWaterfallColumns({
traceMetadata,
selectedSpan,
setSelectedSpan,
handleAddSpanToFunnel,
}: {
handleCollapseUncollapse: (id: string, collapse: boolean) => void;
uncollapsedNodes: string[];
traceMetadata: ITraceMetadata;
selectedSpan: Span | undefined;
setSelectedSpan: Dispatch<SetStateAction<Span | undefined>>;
handleAddSpanToFunnel: (span: Span) => void;
}): ColumnDef<Span, any>[] {
const waterfallColumns: ColumnDef<Span, any>[] = [
columnDefHelper.display({
@@ -256,7 +228,6 @@ function getWaterfallColumns({
isSpanCollapsed={!uncollapsedNodes.includes(props.row.original.spanId)}
selectedSpan={selectedSpan}
setSelectedSpan={setSelectedSpan}
handleAddSpanToFunnel={handleAddSpanToFunnel}
/>
),
size: 450,
@@ -323,17 +294,6 @@ function Success(props: ISuccessProps): JSX.Element {
}
};
const [isAddSpanToFunnelModalOpen, setIsAddSpanToFunnelModalOpen] = useState(
false,
);
const [selectedSpanToAddToFunnel, setSelectedSpanToAddToFunnel] = useState<
Span | undefined
>(undefined);
const handleAddSpanToFunnel = useCallback((span: Span): void => {
setIsAddSpanToFunnelModalOpen(true);
setSelectedSpanToAddToFunnel(span);
}, []);
const columns = useMemo(
() =>
getWaterfallColumns({
@@ -342,7 +302,6 @@ function Success(props: ISuccessProps): JSX.Element {
traceMetadata,
selectedSpan,
setSelectedSpan,
handleAddSpanToFunnel,
}),
[
handleCollapseUncollapse,
@@ -350,7 +309,6 @@ function Success(props: ISuccessProps): JSX.Element {
traceMetadata,
selectedSpan,
setSelectedSpan,
handleAddSpanToFunnel,
],
);
@@ -422,13 +380,6 @@ function Success(props: ISuccessProps): JSX.Element {
virtualiserRef={virtualizerRef}
setColumnWidths={setTraceFlamegraphStatsWidth}
/>
{selectedSpanToAddToFunnel && (
<AddSpanToFunnelModal
span={selectedSpanToAddToFunnel}
isOpen={isAddSpanToFunnelModalOpen}
onClose={(): void => setIsAddSpanToFunnelModalOpen(false)}
/>
)}
</div>
);
}

View File

@@ -1,163 +0,0 @@
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import useDebounce from 'hooks/useDebounce';
import { useNotifications } from 'hooks/useNotifications';
import { isEqual } from 'lodash-es';
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useQueryClient } from 'react-query';
import { FunnelData, FunnelStepData } from 'types/api/traceFunnels';
import { useUpdateFunnelSteps } from './useFunnels';
interface UseFunnelConfiguration {
isPopoverOpen: boolean;
setIsPopoverOpen: (isPopoverOpen: boolean) => void;
steps: FunnelStepData[];
}
// Add this helper function
const normalizeSteps = (steps: FunnelStepData[]): FunnelStepData[] => {
if (steps.some((step) => !step.filters)) return steps;
return steps.map((step) => ({
...step,
filters: {
...step.filters,
items: step.filters.items.map((item) => ({
id: '',
key: item.key,
value: item.value,
op: item.op,
})),
},
}));
};
// eslint-disable-next-line sonarjs/cognitive-complexity
export default function useFunnelConfiguration({
funnel,
}: {
funnel: FunnelData;
}): UseFunnelConfiguration {
const { notifications } = useNotifications();
const {
steps,
initialSteps,
setHasIncompleteStepFields,
setHasAllEmptyStepFields,
} = useFunnelContext();
// State management
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const debouncedSteps = useDebounce(steps, 200);
const [lastValidatedSteps, setLastValidatedSteps] = useState<FunnelStepData[]>(
initialSteps,
);
// Mutation hooks
const updateStepsMutation = useUpdateFunnelSteps(funnel.id, notifications);
// Derived state
const lastSavedStepsStateRef = useRef<FunnelStepData[]>(steps);
const hasStepsChanged = useCallback(() => {
const normalizedLastSavedSteps = normalizeSteps(
lastSavedStepsStateRef.current,
);
const normalizedDebouncedSteps = normalizeSteps(debouncedSteps);
return !isEqual(normalizedDebouncedSteps, normalizedLastSavedSteps);
}, [debouncedSteps]);
const hasStepServiceOrSpanNameChanged = useCallback(
(prevSteps: FunnelStepData[], nextSteps: FunnelStepData[]): boolean => {
if (prevSteps.length !== nextSteps.length) return true;
return prevSteps.some((step, index) => {
const nextStep = nextSteps[index];
return (
step.service_name !== nextStep.service_name ||
step.span_name !== nextStep.span_name
);
});
},
[],
);
// Mutation payload preparation
const getUpdatePayload = useCallback(
() => ({
funnel_id: funnel.id,
steps: debouncedSteps,
updated_timestamp: Date.now(),
}),
[funnel.id, debouncedSteps],
);
const queryClient = useQueryClient();
const { selectedTime } = useFunnelContext();
const validateStepsQueryKey = useMemo(
() => [REACT_QUERY_KEY.VALIDATE_FUNNEL_STEPS, funnel.id, selectedTime],
[funnel.id, selectedTime],
);
useEffect(() => {
if (hasStepsChanged()) {
updateStepsMutation.mutate(getUpdatePayload(), {
onSuccess: (data) => {
const updatedFunnelSteps = data?.payload?.steps;
if (!updatedFunnelSteps) return;
queryClient.setQueryData(
[REACT_QUERY_KEY.GET_FUNNEL_DETAILS, funnel.id],
(oldData: any) => ({
...oldData,
payload: {
...oldData.payload,
steps: updatedFunnelSteps,
},
}),
);
lastSavedStepsStateRef.current = updatedFunnelSteps;
const hasIncompleteStepFields = updatedFunnelSteps.some(
(step) => step.service_name === '' || step.span_name === '',
);
const hasAllEmptyStepsData = updatedFunnelSteps.every(
(step) => step.service_name === '' && step.span_name === '',
);
setHasIncompleteStepFields(hasIncompleteStepFields);
setHasAllEmptyStepFields(hasAllEmptyStepsData);
// Only validate if service_name or span_name changed
if (
!hasIncompleteStepFields &&
hasStepServiceOrSpanNameChanged(lastValidatedSteps, debouncedSteps)
) {
queryClient.refetchQueries(validateStepsQueryKey);
setLastValidatedSteps(debouncedSteps);
}
},
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
debouncedSteps,
getUpdatePayload,
hasStepServiceOrSpanNameChanged,
hasStepsChanged,
lastValidatedSteps,
queryClient,
validateStepsQueryKey,
]);
return {
isPopoverOpen,
setIsPopoverOpen,
steps,
};
}

View File

@@ -1,207 +0,0 @@
import { Color } from '@signozhq/design-tokens';
import { FunnelStepGraphMetrics } from 'api/traceFunnels';
import { Chart, ChartConfiguration } from 'chart.js';
import ChangePercentagePill from 'components/ChangePercentagePill/ChangePercentagePill';
import { useCallback, useEffect, useRef } from 'react';
const CHART_CONFIG: Partial<ChartConfiguration> = {
type: 'bar',
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
stacked: true,
grid: {
display: false,
},
ticks: {
font: {
family: "'Geist Mono', monospace",
},
},
},
y: {
stacked: true,
beginAtZero: true,
grid: {
color: 'rgba(192, 193, 195, 0.04)',
},
ticks: {
font: {
family: "'Geist Mono', monospace",
},
},
},
},
plugins: {
legend: {
display: false,
},
tooltip: {
enabled: false,
},
},
},
};
interface UseFunnelGraphProps {
data: FunnelStepGraphMetrics | undefined;
}
interface UseFunnelGraph {
successSteps: number[];
errorSteps: number[];
totalSteps: number;
canvasRef: React.RefObject<HTMLCanvasElement>;
renderLegendItem: (
step: number,
successSpans: number,
errorSpans: number,
prevTotalSpans: number,
) => JSX.Element;
}
function useFunnelGraph({ data }: UseFunnelGraphProps): UseFunnelGraph {
const canvasRef = useRef<HTMLCanvasElement>(null);
const chartRef = useRef<Chart | null>(null);
const getPercentageChange = useCallback(
(current: number, previous: number): number => {
if (previous === 0) return 0;
return Math.abs(Math.round(((current - previous) / previous) * 100));
},
[],
);
interface StepGraphData {
successSteps: number[];
errorSteps: number[];
totalSteps: number;
}
const getStepGraphData = useCallback((): StepGraphData => {
const successSteps: number[] = [];
const errorSteps: number[] = [];
let stepCount = 1;
if (!data) return { successSteps, errorSteps, totalSteps: 0 };
while (
data[`total_s${stepCount}_spans`] !== undefined &&
data[`total_s${stepCount}_errored_spans`] !== undefined
) {
const totalSpans = data[`total_s${stepCount}_spans`];
const erroredSpans = data[`total_s${stepCount}_errored_spans`];
const successSpans = totalSpans - erroredSpans;
successSteps.push(successSpans);
errorSteps.push(erroredSpans);
stepCount += 1;
}
return {
successSteps,
errorSteps,
totalSteps: stepCount - 1,
};
}, [data]);
useEffect(() => {
if (!canvasRef.current) return;
if (chartRef.current) {
chartRef.current.destroy();
}
const ctx = canvasRef.current.getContext('2d');
if (!ctx) return;
const { successSteps, errorSteps, totalSteps } = getStepGraphData();
chartRef.current = new Chart(ctx, {
...CHART_CONFIG,
data: {
labels: Array.from({ length: totalSteps }, (_, i) => String(i + 1)),
datasets: [
{
label: 'Success spans',
data: successSteps,
backgroundColor: Color.BG_ROBIN_500,
stack: 'Stack 0',
borderRadius: 2,
borderSkipped: false,
},
{
label: 'Error spans',
data: errorSteps,
backgroundColor: Color.BG_CHERRY_500,
stack: 'Stack 0',
borderRadius: 2,
borderSkipped: false,
borderWidth: {
top: 2,
bottom: 2,
},
borderColor: 'rgba(0, 0, 0, 0)',
},
],
},
options: CHART_CONFIG.options,
} as ChartConfiguration);
}, [data, getStepGraphData]);
// Log the widths when they change
const renderLegendItem = useCallback(
(
step: number,
successSpans: number,
errorSpans: number,
prevTotalSpans: number,
): JSX.Element => {
const totalSpans = successSpans + errorSpans;
return (
<div key={step} className="funnel-graph__legend-column">
<div className="legend-item">
<div className="legend-item__left">
<span className="legend-item__dot legend-item--total" />
<span className="legend-item__label">Total spans</span>
</div>
<div className="legend-item__right">
<span className="legend-item__value">{totalSpans}</span>
{step > 1 && (
<ChangePercentagePill
direction={totalSpans < prevTotalSpans ? -1 : 1}
percentage={getPercentageChange(totalSpans, prevTotalSpans)}
/>
)}
</div>
</div>
<div className="legend-item">
<div className="legend-item__left">
<span className="legend-item__dot legend-item--error" />
<span className="legend-item__label">Error spans</span>
</div>
<div className="legend-item__right">
<span className="legend-item__value">{errorSpans}</span>
</div>
</div>
</div>
);
},
[getPercentageChange],
);
const { successSteps, errorSteps, totalSteps } = getStepGraphData();
return {
successSteps,
errorSteps,
totalSteps,
canvasRef,
renderLegendItem,
};
}
export default useFunnelGraph;

View File

@@ -1,69 +0,0 @@
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { MetricItem } from 'pages/TracesFunnelDetails/components/FunnelResults/FunnelMetricsTable';
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
import { useMemo } from 'react';
import { useFunnelOverview } from './useFunnels';
interface FunnelMetricsParams {
funnelId: string;
stepStart?: number;
stepEnd?: number;
}
export function useFunnelMetrics({
funnelId,
stepStart,
stepEnd,
}: FunnelMetricsParams): {
isLoading: boolean;
isError: boolean;
metricsData: MetricItem[];
conversionRate: number;
} {
const { startTime, endTime } = useFunnelContext();
const payload = {
start_time: startTime,
end_time: endTime,
...(stepStart !== undefined && { step_start: stepStart }),
...(stepEnd !== undefined && { step_end: stepEnd }),
};
const {
data: overviewData,
isLoading,
isFetching,
isError,
} = useFunnelOverview(funnelId, payload);
const metricsData = useMemo(() => {
const sourceData = overviewData?.payload?.data?.[0]?.data;
if (!sourceData) return [];
return [
{
title: 'Avg. Rate',
value: `${Number(sourceData.avg_rate.toFixed(2))} req/s`,
},
{ title: 'Errors', value: sourceData.errors },
{
title: 'Avg. Duration',
value: getYAxisFormattedValue(sourceData.avg_duration.toString(), 'ms'),
},
{
title: 'P99 Latency',
value: getYAxisFormattedValue(sourceData.p99_latency.toString(), 'ms'),
},
];
}, [overviewData]);
const conversionRate =
overviewData?.payload?.data?.[0]?.data?.conversion_rate ?? 0;
return {
isLoading: isLoading || isFetching,
isError,
metricsData,
conversionRate,
};
}

View File

@@ -1,31 +1,11 @@
import { NotificationInstance } from 'antd/es/notification/interface';
import {
createFunnel,
deleteFunnel,
ErrorTraceData,
ErrorTracesPayload,
FunnelOverviewPayload,
FunnelOverviewResponse,
FunnelStepsResponse,
getFunnelById,
getFunnelErrorTraces,
getFunnelOverview,
getFunnelsList,
getFunnelSlowTraces,
getFunnelSteps,
renameFunnel,
saveFunnelDescription,
SlowTraceData,
SlowTracesPayload,
updateFunnelStepDetails,
UpdateFunnelStepDetailsPayload,
updateFunnelSteps,
UpdateFunnelStepsPayload,
ValidateFunnelResponse,
validateFunnelSteps,
} from 'api/traceFunnels';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
import {
useMutation,
UseMutationResult,
@@ -40,20 +20,19 @@ import {
} from 'types/api/traceFunnels';
export const useFunnelsList = ({
searchQuery = '',
searchQuery,
}: {
searchQuery?: string;
searchQuery: string;
}): UseQueryResult<SuccessResponse<FunnelData[]> | ErrorResponse, unknown> =>
useQuery({
queryKey: [REACT_QUERY_KEY.GET_FUNNELS_LIST, searchQuery],
queryFn: () => getFunnelsList({ search: searchQuery }),
refetchOnWindowFocus: true,
});
export const useFunnelDetails = ({
funnelId,
}: {
funnelId?: string;
funnelId: string;
}): UseQueryResult<SuccessResponse<FunnelData> | ErrorResponse, unknown> =>
useQuery({
queryKey: [REACT_QUERY_KEY.GET_FUNNEL_DETAILS, funnelId],
@@ -96,148 +75,3 @@ export const useDeleteFunnel = (): UseMutationResult<
useMutation({
mutationFn: deleteFunnel,
});
export const useUpdateFunnelSteps = (
funnelId: string,
notification: NotificationInstance,
): UseMutationResult<
SuccessResponse<FunnelData> | ErrorResponse,
Error,
UpdateFunnelStepsPayload
> =>
useMutation({
mutationFn: updateFunnelSteps,
mutationKey: [REACT_QUERY_KEY.UPDATE_FUNNEL_STEPS, funnelId],
onError: (error) => {
notification.error({
message: 'Failed to update funnel steps',
description: error.message,
});
},
});
export const useValidateFunnelSteps = ({
funnelId,
selectedTime,
startTime,
endTime,
}: {
funnelId: string;
selectedTime: string;
startTime: number;
endTime: number;
}): UseQueryResult<
SuccessResponse<ValidateFunnelResponse> | ErrorResponse,
Error
> =>
useQuery({
queryFn: ({ signal }) =>
validateFunnelSteps(
funnelId,
{ start_time: startTime, end_time: endTime },
signal,
),
queryKey: [REACT_QUERY_KEY.VALIDATE_FUNNEL_STEPS, funnelId, selectedTime],
enabled: !!funnelId && !!selectedTime && !!startTime && !!endTime,
staleTime: 1000 * 60 * 5,
});
export const useUpdateFunnelStepDetails = ({
stepOrder,
}: {
stepOrder: number;
}): UseMutationResult<
SuccessResponse<FunnelData> | ErrorResponse,
Error,
UpdateFunnelStepDetailsPayload
> =>
useMutation({
mutationFn: (payload) => updateFunnelStepDetails({ payload, stepOrder }),
mutationKey: [REACT_QUERY_KEY.UPDATE_FUNNEL_STEP_DETAILS, stepOrder],
});
interface SaveFunnelDescriptionPayload {
funnel_id: string;
description: string;
}
export const useSaveFunnelDescription = (): UseMutationResult<
SuccessResponse<FunnelData> | ErrorResponse,
Error,
SaveFunnelDescriptionPayload
> =>
useMutation({
mutationFn: saveFunnelDescription,
});
export const useFunnelOverview = (
funnelId: string,
payload: FunnelOverviewPayload,
): UseQueryResult<
SuccessResponse<FunnelOverviewResponse> | ErrorResponse,
Error
> => {
const { selectedTime, validTracesCount } = useFunnelContext();
return useQuery({
queryFn: ({ signal }) => getFunnelOverview(funnelId, payload, signal),
queryKey: [
REACT_QUERY_KEY.GET_FUNNEL_OVERVIEW,
funnelId,
selectedTime,
payload.step_start ?? '',
payload.step_end ?? '',
],
enabled: !!funnelId && validTracesCount > 0,
});
};
export const useFunnelSlowTraces = (
funnelId: string,
payload: SlowTracesPayload,
): UseQueryResult<SuccessResponse<SlowTraceData> | ErrorResponse, Error> => {
const { selectedTime, validTracesCount } = useFunnelContext();
return useQuery<SuccessResponse<SlowTraceData> | ErrorResponse, Error>({
queryFn: ({ signal }) => getFunnelSlowTraces(funnelId, payload, signal),
queryKey: [REACT_QUERY_KEY.GET_FUNNEL_SLOW_TRACES, funnelId, selectedTime],
enabled: !!funnelId && validTracesCount > 0,
});
};
export const useFunnelErrorTraces = (
funnelId: string,
payload: ErrorTracesPayload,
): UseQueryResult<SuccessResponse<ErrorTraceData> | ErrorResponse, Error> => {
const { selectedTime, validTracesCount } = useFunnelContext();
return useQuery({
queryFn: ({ signal }) => getFunnelErrorTraces(funnelId, payload, signal),
queryKey: [REACT_QUERY_KEY.GET_FUNNEL_ERROR_TRACES, funnelId, selectedTime],
enabled: !!funnelId && validTracesCount > 0,
});
};
export function useFunnelStepsGraphData(
funnelId: string,
): UseQueryResult<SuccessResponse<FunnelStepsResponse> | ErrorResponse, Error> {
const {
startTime,
endTime,
selectedTime,
validTracesCount,
} = useFunnelContext();
return useQuery({
queryFn: ({ signal }) =>
getFunnelSteps(
funnelId,
{ start_time: startTime, end_time: endTime },
signal,
),
queryKey: [
REACT_QUERY_KEY.GET_FUNNEL_STEPS_GRAPH_DATA,
funnelId,
selectedTime,
],
enabled: !!funnelId && validTracesCount > 0,
});
}

View File

@@ -1,38 +1,31 @@
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { debounce } from 'lodash-es';
import { ChangeEvent, useCallback, useState } from 'react';
import { ChangeEvent, useState } from 'react';
const useHandleTraceFunnelsSearch = (): {
searchQuery: string;
handleSearch: (e: ChangeEvent<HTMLInputElement>) => void;
} => {
const { safeNavigate } = useSafeNavigate();
const urlQuery = useUrlQuery();
const [searchQuery, setSearchQuery] = useState<string>(
urlQuery.get('search') || '',
);
// eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedUpdateUrl = useCallback(
debounce((value: string) => {
const trimmedValue = value.trim();
if (trimmedValue) {
urlQuery.set('search', trimmedValue);
} else {
urlQuery.delete('search');
}
safeNavigate({ search: urlQuery.toString() });
}, 300),
[],
);
const handleSearch = (e: ChangeEvent<HTMLInputElement>): void => {
const { value } = e.target;
setSearchQuery(value);
debouncedUpdateUrl(value);
const trimmedValue = value.trim();
if (trimmedValue) {
urlQuery.set('search', trimmedValue);
} else {
urlQuery.delete('search');
}
safeNavigate({ search: urlQuery.toString() });
};
return {

View File

@@ -170,7 +170,11 @@ export const useOptions = (
(option, index, self) =>
index ===
self.findIndex(
(o) => o.label === option.label && o.value === option.value, // to remove duplicate & empty options from list
(o) =>
// to remove duplicate & empty options from list
o.label === option.label &&
o.value === option.value &&
o.dataType?.toLowerCase() === option.dataType?.toLowerCase(), // handle case sensitivity
) && option.value !== '',
) || []
).map((option) => {

View File

@@ -1,7 +1,4 @@
.traces-module-container {
.funnel-icon {
transform: rotate(180deg);
}
.trace-module {
.ant-tabs-tab {
.tab-item {

View File

@@ -3,7 +3,7 @@ import './TraceDetailV2.styles.scss';
import { Button, Tabs } from 'antd';
import ROUTES from 'constants/routes';
import history from 'lib/history';
import { Compass, Cone, TowerControl, Undo } from 'lucide-react';
import { Compass, TowerControl, Undo } from 'lucide-react';
import TraceDetail from 'pages/TraceDetail';
import { useCallback, useState } from 'react';
@@ -33,9 +33,6 @@ function NewTraceDetail(props: INewTraceDetailProps): JSX.Element {
if (activeKey === 'trace-details') {
history.push(ROUTES.TRACES_EXPLORER);
}
if (activeKey === 'funnels') {
history.push(ROUTES.TRACES_FUNNELS);
}
}}
tabBarExtraContent={
<Button
@@ -64,15 +61,6 @@ export default function TraceDetailsPage(): JSX.Element {
key: 'trace-details',
children: <TraceDetailsV2 />,
},
{
label: (
<div className="tab-item">
<Cone className="funnel-icon" size={16} /> Funnels
</div>
),
key: 'funnels',
children: <div />,
},
{
label: (
<div className="tab-item">

View File

@@ -1,28 +0,0 @@
.traces-funnel-details {
display: flex;
// 45px -> height of the tab bar
height: calc(100vh - 45px);
&__steps-config {
flex-shrink: 0;
width: 600px;
border-right: 1px solid var(--bg-slate-400);
position: relative;
}
&__steps-results {
width: 100%;
overflow-y: auto;
&::-webkit-scrollbar {
width: 0.1rem;
}
}
}
.lightMode {
.traces-funnel-details {
&__steps-config {
border-color: var(--bg-vanilla-300);
}
}
}

View File

@@ -1,42 +1,13 @@
import './TracesFunnelDetails.styles.scss';
import { Typography } from 'antd';
import Spinner from 'components/Spinner';
import { NotFoundContainer } from 'container/GridCardLayout/GridCard/FullView/styles';
import { useFunnelDetails } from 'hooks/TracesFunnels/useFunnels';
import { FunnelProvider } from 'pages/TracesFunnels/FunnelContext';
import { useParams } from 'react-router-dom';
import FunnelConfiguration from './components/FunnelConfiguration/FunnelConfiguration';
import FunnelResults from './components/FunnelResults/FunnelResults';
function TracesFunnelDetails(): JSX.Element {
const { funnelId } = useParams<{ funnelId: string }>();
const { data, isLoading, isError } = useFunnelDetails({ funnelId });
if (isLoading || !data?.payload) {
return <Spinner size="large" tip="Loading..." />;
}
if (isError) {
return (
<NotFoundContainer>
<Typography>Error loading funnel details</Typography>
</NotFoundContainer>
);
}
const { data } = useFunnelDetails({ funnelId });
return (
<FunnelProvider funnelId={funnelId}>
<div className="traces-funnel-details">
<div className="traces-funnel-details__steps-config">
<FunnelConfiguration funnel={data.payload} />
</div>
<div className="traces-funnel-details__steps-results">
<FunnelResults />
</div>
</div>
</FunnelProvider>
<div style={{ color: 'var(--bg-vanilla-400)' }}>
TracesFunnelDetails, {JSON.stringify(data)}
</div>
);
}

View File

@@ -1,135 +0,0 @@
.funnel-step-modal {
.ant-modal-content {
background: var(--bg-ink-400);
.ant-modal-header {
background: var(--bg-ink-400);
.ant-modal-title {
color: var(--bg-vanilla-100);
font-family: Inter;
font-size: 14px;
line-height: 20px;
}
}
.ant-modal-body {
padding-bottom: 20px;
}
}
&__ok-btn {
display: flex;
align-items: center;
gap: 6px;
background: var(--bg-robin-500);
border: none;
&[disabled] {
background: var(--bg-slate-400);
opacity: 1;
}
.ant-btn-icon {
margin-inline-end: 0 !important;
}
}
&__cancel-btn {
display: flex;
align-items: center;
gap: 6px;
color: var(--bg-vanilla-400);
.ant-btn-icon {
margin-inline-end: 0 !important;
}
}
}
.funnel-step-modal-content {
display: flex;
flex-direction: column;
gap: 28px;
&__field {
display: flex;
flex-direction: column;
gap: 8px;
}
&__label {
color: var(--bg-vanilla-100);
font-family: Inter;
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
&__input {
background: var(--bg-ink-300);
border: 1px solid var(--bg-slate-500);
color: var(--bg-vanilla-400);
&::placeholder {
color: var(--bg-vanilla-400);
opacity: 0.6;
}
&.ant-input-textarea {
.ant-input {
background: var(--bg-ink-300);
border: 1px solid var(--bg-slate-500);
color: var(--bg-vanilla-400);
&::placeholder {
color: var(--bg-vanilla-400);
opacity: 0.6;
}
}
}
}
}
// Light mode styles
.lightMode {
.funnel-step-modal {
.ant-modal-content {
.ant-modal-header {
background: var(--bg-vanilla-100);
.ant-modal-title {
color: var(--bg-ink-400);
}
}
}
&__cancel-btn {
color: var(--bg-ink-400);
}
}
.funnel-step-modal-content {
&__label {
color: var(--bg-ink-400);
}
&__input {
background: var(--bg-vanilla-400);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-400);
&::placeholder {
color: var(--bg-ink-100);
}
&.ant-input-textarea {
.ant-input {
background: var(--bg-vanilla-400);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-400);
&::placeholder {
color: var(--bg-ink-100);
}
}
}
}
}
}

View File

@@ -1,107 +0,0 @@
import './AddFunnelDescriptionModal.styles.scss';
import { Input } from 'antd';
import SignozModal from 'components/SignozModal/SignozModal';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useSaveFunnelDescription } from 'hooks/TracesFunnels/useFunnels';
import { useNotifications } from 'hooks/useNotifications';
import { Check, X } from 'lucide-react';
import { useState } from 'react';
import { useQueryClient } from 'react-query';
interface AddFunnelDescriptionProps {
isOpen: boolean;
onClose: () => void;
funnelId: string;
}
function AddFunnelDescriptionModal({
isOpen,
onClose,
funnelId,
}: AddFunnelDescriptionProps): JSX.Element {
const [description, setDescription] = useState<string>('');
const { notifications } = useNotifications();
const queryClient = useQueryClient();
const {
mutate: saveFunnelDescription,
isLoading,
} = useSaveFunnelDescription();
const handleCancel = (): void => {
setDescription('');
onClose();
};
const handleSave = (): void => {
saveFunnelDescription(
{
funnel_id: funnelId,
description,
},
{
onSuccess: () => {
queryClient.invalidateQueries([
REACT_QUERY_KEY.GET_FUNNEL_DETAILS,
funnelId,
]);
notifications.success({
message: 'Success',
description: 'Funnel description saved successfully',
});
handleCancel();
},
onError: (error) => {
notifications.error({
message: 'Failed to save funnel description',
description: error.message,
});
},
},
);
};
return (
<SignozModal
open={isOpen}
title="Add funnel description"
width={384}
onCancel={handleCancel}
rootClassName="funnel-step-modal funnel-modal signoz-modal"
cancelText="Cancel"
okText="Save changes"
okButtonProps={{
icon: <Check size={14} />,
type: 'primary',
className: 'funnel-step-modal__ok-btn',
onClick: handleSave,
loading: isLoading,
}}
cancelButtonProps={{
icon: <X size={14} />,
type: 'text',
className: 'funnel-step-modal__cancel-btn',
onClick: handleCancel,
disabled: isLoading,
}}
destroyOnClose
>
<div className="funnel-step-modal-content">
<div className="funnel-step-modal-content__field">
<span className="funnel-step-modal-content__label">Description</span>
<Input.TextArea
className="funnel-step-modal-content__input"
placeholder="(Optional) Eg. checkout dropoff funnel"
value={description}
onChange={(e): void => setDescription(e.target.value)}
autoSize={{ minRows: 3, maxRows: 5 }}
disabled={isLoading}
/>
</div>
</div>
</SignozModal>
);
}
export default AddFunnelDescriptionModal;

View File

@@ -1,138 +0,0 @@
.funnel-step-modal {
.ant-modal-content {
background: var(--bg-ink-400);
.ant-modal-header {
background: var(--bg-ink-400);
.ant-modal-title {
color: var(--bg-vanilla-100);
font-family: Inter;
font-size: 14px;
line-height: 20px;
}
}
.ant-modal-body {
padding-bottom: 20px;
}
}
&__ok-btn {
display: flex;
align-items: center;
gap: 6px;
background: var(--bg-robin-500);
border: none;
&[disabled] {
background: var(--bg-slate-400);
opacity: 1;
}
.ant-btn-icon {
margin-inline-end: 0 !important;
}
}
&__cancel-btn {
display: flex;
align-items: center;
gap: 6px;
color: var(--bg-vanilla-400);
.ant-btn-icon {
margin-inline-end: 0 !important;
}
}
}
.funnel-step-modal-content {
display: flex;
flex-direction: column;
gap: 28px;
&__field {
display: flex;
flex-direction: column;
gap: 8px;
}
&__label {
color: var(--bg-vanilla-100);
font-family: Inter;
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
&__input {
background: var(--bg-ink-300);
border: 1px solid var(--bg-slate-500);
color: var(--bg-vanilla-400);
&::placeholder {
color: var(--bg-vanilla-400);
opacity: 0.6;
}
&.ant-input-textarea {
.ant-input {
background: var(--bg-ink-300);
border: 1px solid var(--bg-slate-500);
color: var(--bg-vanilla-400);
&::placeholder {
color: var(--bg-vanilla-400);
opacity: 0.6;
}
}
}
}
}
// Light mode styles
.lightMode {
.funnel-step-modal {
.ant-modal-content {
.ant-modal-header {
.ant-modal-title {
color: var(--bg-ink-400);
}
}
}
&__ok-btn {
background: var(--bg-robin-500) !important;
}
&__cancel-btn {
color: var(--bg-ink-400);
}
}
.funnel-step-modal-content {
&__label {
color: var(--bg-ink-400);
}
&__input {
background: var(--bg-vanilla-400);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-400);
&::placeholder {
color: var(--bg-ink-100);
}
&.ant-input-textarea {
.ant-input {
background: var(--bg-vanilla-400);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-400);
&::placeholder {
color: var(--bg-ink-100);
}
}
}
}
}
}

View File

@@ -1,130 +0,0 @@
import './AddFunnelStepDetailsModal.styles.scss';
import { Input } from 'antd';
import SignozModal from 'components/SignozModal/SignozModal';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useUpdateFunnelStepDetails } from 'hooks/TracesFunnels/useFunnels';
import { useNotifications } from 'hooks/useNotifications';
import { Check, X } from 'lucide-react';
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
import { useState } from 'react';
import { useQueryClient } from 'react-query';
interface AddFunnelStepDetailsModalProps {
isOpen: boolean;
onClose: () => void;
stepOrder: number;
}
function AddFunnelStepDetailsModal({
isOpen,
onClose,
stepOrder,
}: AddFunnelStepDetailsModalProps): JSX.Element {
const { funnelId } = useFunnelContext();
const [stepName, setStepName] = useState<string>('');
const [description, setDescription] = useState<string>('');
const { notifications } = useNotifications();
const queryClient = useQueryClient();
const {
mutate: updateFunnelStepDetails,
isLoading,
} = useUpdateFunnelStepDetails({ stepOrder });
const handleCancel = (): void => {
setStepName('');
setDescription('');
onClose();
};
const handleSave = (): void => {
updateFunnelStepDetails(
{
funnel_id: funnelId,
steps: [
{
step_name: stepName,
description,
},
],
updated_timestamp: Date.now(),
},
{
onSuccess: () => {
queryClient.invalidateQueries([
REACT_QUERY_KEY.GET_FUNNEL_DETAILS,
funnelId,
]);
console.log('funnelId', funnelId);
notifications.success({
message: 'Success',
description: 'Funnel step details updated successfully',
});
handleCancel();
},
onError: (error) => {
notifications.error({
message: 'Failed to update funnel step details',
description: error.message,
});
},
},
);
};
return (
<SignozModal
open={isOpen}
title="Add funnel step details"
width={384}
onCancel={handleCancel}
rootClassName="funnel-step-modal funnel-modal signoz-modal"
cancelText="Cancel"
okText="Save changes"
okButtonProps={{
icon: <Check size={14} />,
type: 'primary',
className: 'funnel-step-modal__ok-btn',
onClick: handleSave,
disabled: !stepName.trim(),
loading: isLoading,
}}
cancelButtonProps={{
icon: <X size={14} />,
type: 'text',
className: 'funnel-step-modal__cancel-btn',
onClick: handleCancel,
disabled: isLoading,
}}
destroyOnClose
>
<div className="funnel-step-modal-content">
<div className="funnel-step-modal-content__field">
<span className="funnel-step-modal-content__label">Step name</span>
<Input
className="funnel-step-modal-content__input"
placeholder="Eg. checkout-dropoff-funnel-step1"
value={stepName}
onChange={(e): void => setStepName(e.target.value)}
autoFocus
disabled={isLoading}
/>
</div>
<div className="funnel-step-modal-content__field">
<span className="funnel-step-modal-content__label">Description</span>
<Input.TextArea
className="funnel-step-modal-content__input"
placeholder="Eg. checkout dropoff funnel"
value={description}
onChange={(e): void => setDescription(e.target.value)}
autoSize={{ minRows: 3, maxRows: 5 }}
disabled={isLoading}
/>
</div>
</div>
</SignozModal>
);
}
export default AddFunnelStepDetailsModal;

View File

@@ -1,134 +0,0 @@
.funnel-step-modal {
.ant-modal-content {
background: var(--bg-ink-400);
.ant-modal-header {
background: var(--bg-ink-400);
.ant-modal-title {
color: var(--bg-vanilla-100);
font-family: Inter;
font-size: 14px;
line-height: 20px;
}
}
.ant-modal-body {
padding-bottom: 20px;
}
}
&__ok-btn {
display: flex;
align-items: center;
gap: 6px;
background: var(--bg-robin-500);
border: none;
&[disabled] {
background: var(--bg-slate-400);
opacity: 1;
}
.ant-btn-icon {
margin-inline-end: 0 !important;
}
}
&__cancel-btn {
display: flex;
align-items: center;
gap: 6px;
color: var(--bg-vanilla-400);
.ant-btn-icon {
margin-inline-end: 0 !important;
}
}
}
.funnel-step-modal-content {
display: flex;
flex-direction: column;
gap: 28px;
&__field {
display: flex;
flex-direction: column;
gap: 8px;
}
&__label {
color: var(--bg-vanilla-100);
font-family: Inter;
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
&__input {
background: var(--bg-ink-300);
border: 1px solid var(--bg-slate-500);
color: var(--bg-vanilla-400);
&::placeholder {
color: var(--bg-vanilla-400);
opacity: 0.6;
}
&.ant-input-textarea {
.ant-input {
background: var(--bg-ink-300);
border: 1px solid var(--bg-slate-500);
color: var(--bg-vanilla-400);
&::placeholder {
color: var(--bg-vanilla-400);
opacity: 0.6;
}
}
}
}
}
// Light mode styles
.lightMode {
.funnel-step-modal {
.ant-modal-content {
.ant-modal-header {
.ant-modal-title {
color: var(--bg-ink-400);
}
}
}
&__cancel-btn {
color: var(--bg-ink-400);
}
}
.funnel-step-modal-content {
&__label {
color: var(--bg-ink-400);
}
&__input {
background: var(--bg-vanilla-400);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-400);
&::placeholder {
color: var(--bg-ink-100);
}
&.ant-input-textarea {
.ant-input {
background: var(--bg-vanilla-400);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-400);
&::placeholder {
color: var(--bg-ink-100);
}
}
}
}
}
}

View File

@@ -1,53 +0,0 @@
import './DeleteFunnelStep.styles.scss';
import SignozModal from 'components/SignozModal/SignozModal';
import { Trash2, X } from 'lucide-react';
interface DeleteFunnelStepProps {
isOpen: boolean;
onClose: () => void;
onStepRemove: () => void;
}
function DeleteFunnelStep({
isOpen,
onClose,
onStepRemove,
}: DeleteFunnelStepProps): JSX.Element {
const handleStepRemoval = (): void => {
onStepRemove();
onClose();
};
return (
<SignozModal
open={isOpen}
title="Delete this step"
width={390}
onCancel={onClose}
rootClassName="funnel-modal delete-funnel-modal"
cancelText="Cancel"
okText="Delete Funnel"
okButtonProps={{
icon: <Trash2 size={14} />,
type: 'primary',
className: 'funnel-modal__ok-btn',
onClick: handleStepRemoval,
}}
cancelButtonProps={{
icon: <X size={14} />,
type: 'text',
className: 'funnel-modal__cancel-btn',
onClick: onClose,
}}
destroyOnClose
>
<div className="delete-funnel-modal-content">
Deleting this step would stop further analytics using this step of the
funnel.
</div>
</SignozModal>
);
}
export default DeleteFunnelStep;

View File

@@ -1,38 +0,0 @@
.funnel-breadcrumb {
height: 20px;
&__link {
display: flex;
align-items: center;
}
li:first-of-type {
.funnel-breadcrumb__title {
color: var(--bg-vanilla-400);
}
}
.ant-breadcrumb-separator {
color: var(--bg-vanilla-100);
}
& > ol {
gap: 6px;
}
&__title {
color: var(--bg-vanilla-100);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
}
}
.lightMode {
.funnel-breadcrumb__title,
.ant-breadcrumb-separator {
color: var(--bg-ink-400);
}
li:first-of-type {
.funnel-breadcrumb__title {
color: var(--bg-ink-400);
}
}
}

View File

@@ -1,35 +0,0 @@
import './FunnelBreadcrumb.styles.scss';
import { Breadcrumb } from 'antd';
import ROUTES from 'constants/routes';
import { Link } from 'react-router-dom';
interface FunnelBreadcrumbProps {
funnelName: string;
}
function FunnelBreadcrumb({ funnelName }: FunnelBreadcrumbProps): JSX.Element {
return (
<div>
<Breadcrumb
className="funnel-breadcrumb"
items={[
{
title: (
<Link to={ROUTES.TRACES_FUNNELS}>
<span className="funnel-breadcrumb__link">
<span className="funnel-breadcrumb__title">All funnels</span>
</span>
</Link>
),
},
{
title: <div className="funnel-breadcrumb__title">{funnelName}</div>,
},
]}
/>
</div>
);
}
export default FunnelBreadcrumb;

View File

@@ -1,42 +0,0 @@
.funnel-configuration {
&__steps-wrapper {
display: flex;
flex-direction: column;
justify-content: space-between;
height: 100%;
}
&__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid var(--bg-slate-400);
}
&__description {
padding: 16px 16px 0 16px;
color: var(--bg-vanilla-400);
font-size: 12px;
line-height: 18px; /* 150% */
letter-spacing: -0.06px;
}
.funnel-item__action-icon {
opacity: 1;
}
&__steps {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
}
}
.lightMode {
.funnel-configuration {
&__header {
border-color: var(--bg-vanilla-300);
}
}
}

View File

@@ -1,68 +0,0 @@
import './FunnelConfiguration.styles.scss';
import useFunnelConfiguration from 'hooks/TracesFunnels/useFunnelConfiguration';
import FunnelItemPopover from 'pages/TracesFunnels/components/FunnelsList/FunnelItemPopover';
import { memo } from 'react';
import { Span } from 'types/api/trace/getTraceV2';
import { FunnelData } from 'types/api/traceFunnels';
import FunnelBreadcrumb from './FunnelBreadcrumb';
import StepsContent from './StepsContent';
import StepsFooter from './StepsFooter';
import StepsHeader from './StepsHeader';
interface FunnelConfigurationProps {
funnel: FunnelData;
isTraceDetailsPage?: boolean;
span?: Span;
}
function FunnelConfiguration({
funnel,
isTraceDetailsPage,
span,
}: FunnelConfigurationProps): JSX.Element {
const { isPopoverOpen, setIsPopoverOpen, steps } = useFunnelConfiguration({
funnel,
});
return (
<div className="funnel-configuration">
{!isTraceDetailsPage && (
<>
<div className="funnel-configuration__header">
<FunnelBreadcrumb funnelName={funnel.funnel_name} />
<FunnelItemPopover
isPopoverOpen={isPopoverOpen}
setIsPopoverOpen={setIsPopoverOpen}
funnel={funnel}
/>
</div>
<div className="funnel-configuration__description">
{funnel?.description}
</div>
</>
)}
<div className="funnel-configuration__steps-wrapper">
<div className="funnel-configuration__steps">
{!isTraceDetailsPage && <StepsHeader />}
<StepsContent isTraceDetailsPage={isTraceDetailsPage} span={span} />
</div>
{!isTraceDetailsPage && (
<StepsFooter
funnelId={funnel.id}
stepsCount={steps.length}
funnelDescription={funnel?.description || ''}
/>
)}
</div>
</div>
);
}
FunnelConfiguration.defaultProps = {
isTraceDetailsPage: false,
span: undefined,
};
export default memo(FunnelConfiguration);

View File

@@ -1,208 +0,0 @@
.traces-funnel-where-filter {
.keyboard-shortcuts {
display: none !important;
}
}
.funnel-step {
background: var(--bg-ink-400);
color: var(--bg-vanilla-400);
border: 1px solid var(--bg-slate-500);
border-radius: 6px;
.step-popover {
opacity: 0;
width: 22px;
height: 22px;
padding: 4px;
background: var(--bg-ink-100);
border-radius: 2px;
position: absolute;
right: -11px;
top: -11px;
}
&:hover .step-popover {
opacity: 1;
}
&__header {
display: flex;
justify-content: space-between;
align-items: start;
padding: 8px 12px;
border-bottom: 1px solid var(--bg-slate-500);
.funnel-step-details {
display: flex;
flex-direction: column;
gap: 4px;
&__title {
color: var(--bg-vanilla-400);
font-size: 14px;
line-height: 20px;
letter-spacing: -0.07px;
}
&__description {
color: var(--bg-vanilla-400);
font-size: 12px;
line-height: 18px;
letter-spacing: -0.06px;
}
}
}
&__content {
display: flex;
align-items: baseline;
gap: 6px;
padding: 16px;
padding-left: 6px;
.ant-form-item {
margin: 0;
width: 100%;
}
.drag-icon {
cursor: grab;
}
.filters {
display: flex;
flex-direction: column;
gap: 10px;
.ant-select-selector {
background: var(--bg-ink-300);
border: 1px solid var(--bg-slate-500);
.ant-select-selection-placeholder {
font-size: 12px;
line-height: 16px;
}
}
&__service-and-span {
display: flex;
align-items: center;
gap: 12px;
.ant-select-selection-placeholder {
color: var(--bg-vanilla-400);
}
.ant-select {
width: 239px;
}
}
&__where-filter {
display: flex;
align-items: center;
gap: 8px;
.label {
color: var(--bg-vanilla-400);
font-size: 14px;
line-height: 20px;
font-weight: 400;
}
.query-builder-search-v2 {
width: 100%;
}
}
}
.ant-steps.ant-steps-vertical > .ant-steps-item .ant-steps-item-description {
padding-bottom: 16px;
}
}
&__footer {
display: flex;
align-items: center;
justify-content: space-between;
border-top: 1px solid var(--bg-slate-500);
.error {
display: flex;
align-items: center;
padding: 10.5px 12px 10.5px 16px;
gap: 20px;
border-right: 1px solid var(--bg-slate-500);
width: 50%;
}
.error__label,
.latency-pointer__label {
color: var(--bg-vanilla-400);
font-size: 14px;
line-height: 20px;
letter-spacing: -0.07px;
}
.latency-pointer {
padding: 10.5px 16px 10.5px 12px;
width: 55%;
display: flex;
align-items: center;
justify-content: space-between;
.ant-space {
display: flex;
align-items: center;
cursor: pointer;
&-item {
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.07px;
&:last-child {
height: 14px;
}
}
}
}
}
}
.lightMode {
.funnel-step {
background: var(--bg-vanilla-100);
color: var(--bg-ink-400);
border-color: var(--bg-vanilla-300);
.step-popover {
background: var(--bg-vanilla-100);
}
&__header {
border-color: var(--bg-vanilla-300);
.funnel-step-details {
&__title {
color: var(--bg-ink-400);
}
&__description {
color: var(--bg-ink-400);
}
}
}
&__content {
.filters {
.ant-select-selector {
background: var(--bg-vanilla-100);
border-color: var(--bg-vanilla-300);
}
&__service-and-span {
.ant-select-selection-placeholder {
color: var(--bg-ink-400);
}
}
&__where-filter {
.label {
color: var(--bg-ink-400);
}
}
}
}
&__footer {
&,
.error {
border-color: var(--bg-vanilla-300);
}
.error__label,
.latency-pointer__label {
color: var(--bg-ink-400);
}
.latency-pointer {
.ant-space-item {
color: var(--bg-ink-400);
}
}
}
}
}

View File

@@ -1,188 +0,0 @@
import './FunnelStep.styles.scss';
import { Dropdown, Form, Space, Switch } from 'antd';
import { MenuProps } from 'antd/lib';
import { FilterSelect } from 'components/CeleryOverview/CeleryOverviewConfigOptions/CeleryOverviewConfigOptions';
import { QueryParams } from 'constants/query';
import { initialQueriesMap } from 'constants/queryBuilder';
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
import { ChevronDown, GripVertical, HardHat } from 'lucide-react';
import { LatencyPointers } from 'pages/TracesFunnelDetails/constants';
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
import { useMemo, useState } from 'react';
import { FunnelStepData } from 'types/api/traceFunnels';
import { DataSource } from 'types/common/queryBuilder';
import FunnelStepPopover from './FunnelStepPopover';
interface FunnelStepProps {
stepData: FunnelStepData;
index: number;
stepsCount: number;
}
function FunnelStep({
stepData,
index,
stepsCount,
}: FunnelStepProps): JSX.Element {
const {
handleStepChange: onStepChange,
handleStepRemoval: onStepRemove,
} = useFunnelContext();
const [form] = Form.useForm();
const currentQuery = initialQueriesMap[DataSource.TRACES];
const latencyPointerItems: MenuProps['items'] = LatencyPointers.map(
(option) => ({
key: option.value,
label: option.key,
style:
option.value === stepData.latency_pointer
? { backgroundColor: 'var(--bg-slate-100)' }
: {},
}),
);
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const updatedCurrentQuery = useMemo(
() => ({
...currentQuery,
builder: {
...currentQuery.builder,
queryData: [
{
...currentQuery.builder.queryData[0],
dataSource: DataSource.TRACES,
filters: stepData.filters ?? {
op: 'AND',
items: [],
},
},
],
},
}),
[stepData.filters, currentQuery],
);
const query = updatedCurrentQuery?.builder?.queryData[0] || null;
return (
<div className="funnel-step">
<Form form={form}>
<div className="funnel-step__header">
<div className="funnel-step-details">
{!!stepData.title && (
<div className="funnel-step-details__title">{stepData.title}</div>
)}
{!!stepData.description && (
<div className="funnel-step-details__description">
{stepData.description}
</div>
)}
</div>
<div className="funnel-step-actions">
<FunnelStepPopover
isPopoverOpen={isPopoverOpen}
setIsPopoverOpen={setIsPopoverOpen}
stepOrder={stepData.step_order}
onStepRemove={(): void => onStepRemove(index)}
stepsCount={stepsCount}
/>
</div>
</div>
<div className="funnel-step__content">
<div className="drag-icon">
<GripVertical size={14} color="var(--bg-slate-200)" />
</div>
<div className="filters">
<div className="filters__service-and-span">
<div className="service">
<Form.Item name={['steps', stepData.id, 'service_name']}>
<FilterSelect
placeholder="Select Service"
queryParam={QueryParams.service}
filterType="serviceName"
shouldSetQueryParams={false}
values={stepData.service_name}
isMultiple={false}
onChange={(v): void => {
onStepChange(index, { service_name: (v ?? '') as string });
}}
/>
</Form.Item>
</div>
<div className="span">
<Form.Item name={['steps', stepData.id, 'span_name']}>
<FilterSelect
placeholder="Select Span name"
queryParam={QueryParams.spanName}
filterType="name"
shouldSetQueryParams={false}
values={stepData.span_name}
isMultiple={false}
onChange={(v): void =>
onStepChange(index, { span_name: (v ?? '') as string })
}
/>
</Form.Item>
</div>
</div>
<div className="filters__where-filter">
<div className="label">Where</div>
<Form.Item name={['steps', stepData.id, 'filters']}>
<QueryBuilderSearchV2
query={query}
onChange={(query): void => onStepChange(index, { filters: query })}
hasPopupContainer={false}
placeholder="Search for filters..."
suffixIcon={<HardHat size={12} color="var(--bg-vanilla-400)" />}
rootClassName="traces-funnel-where-filter"
maxTagCount="responsive"
/>
</Form.Item>
</div>
</div>
</div>
<div className="funnel-step__footer">
<div className="error">
<Switch
className="error__switch"
size="small"
checked={stepData.has_errors}
onChange={(): void =>
onStepChange(index, { has_errors: !stepData.has_errors })
}
/>
<div className="error__label">Errors</div>
</div>
<div className="latency-pointer">
<div className="latency-pointer__label">Latency pointer</div>
<Dropdown
menu={{
items: latencyPointerItems,
onClick: ({ key }): void =>
onStepChange(index, {
latency_pointer: key as FunnelStepData['latency_pointer'],
}),
}}
trigger={['click']}
>
<Space>
{
LatencyPointers.find(
(option) => option.value === stepData.latency_pointer,
)?.key
}
<ChevronDown size={14} color="var(--bg-vanilla-400)" />
</Space>
</Dropdown>
</div>
</div>
</Form>
</div>
);
}
export default FunnelStep;

View File

@@ -1,129 +0,0 @@
import { Button, Popover, Tooltip } from 'antd';
import cx from 'classnames';
import { Ellipsis, PencilLine, Trash2 } from 'lucide-react';
import { useState } from 'react';
import AddFunnelStepDetailsModal from './AddFunnelStepDetailsModal';
import DeleteFunnelStep from './DeleteFunnelStep';
interface FunnelStepPopoverProps {
isPopoverOpen: boolean;
setIsPopoverOpen: (isOpen: boolean) => void;
className?: string;
stepOrder: number;
stepsCount: number;
onStepRemove: () => void;
}
interface FunnelStepActionsProps {
setIsPopoverOpen: (isOpen: boolean) => void;
setIsAddDetailsModalOpen: (isOpen: boolean) => void;
setIsDeleteModalOpen: (isOpen: boolean) => void;
stepsCount: number;
}
function FunnelStepActions({
setIsPopoverOpen,
setIsAddDetailsModalOpen,
setIsDeleteModalOpen,
stepsCount,
}: FunnelStepActionsProps): JSX.Element {
return (
<div className="funnel-item__actions">
<Button
type="text"
className="funnel-item__action-btn"
icon={<PencilLine size={14} />}
onClick={(): void => {
setIsPopoverOpen(false);
setIsAddDetailsModalOpen(true);
}}
>
Add details
</Button>
<Tooltip title={stepsCount <= 2 ? 'Minimum 2 steps required' : 'Delete'}>
<Button
type="text"
className="funnel-item__action-btn funnel-item__action-btn--delete"
icon={<Trash2 size={14} />}
disabled={stepsCount <= 2}
onClick={(): void => {
if (stepsCount > 2) {
setIsPopoverOpen(false);
setIsDeleteModalOpen(true);
}
}}
>
Delete
</Button>
</Tooltip>
</div>
);
}
function FunnelStepPopover({
isPopoverOpen,
setIsPopoverOpen,
stepOrder,
className,
onStepRemove,
stepsCount,
}: FunnelStepPopoverProps): JSX.Element {
const [isAddDetailsModalOpen, setIsAddDetailsModalOpen] = useState<boolean>(
false,
);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState<boolean>(false);
const preventDefault = (e: React.MouseEvent | React.KeyboardEvent): void => {
e.preventDefault();
e.stopPropagation();
};
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
<div onClick={preventDefault} role="button" tabIndex={0}>
<Popover
trigger="click"
rootClassName="funnel-item__actions"
open={isPopoverOpen}
onOpenChange={setIsPopoverOpen}
content={
<FunnelStepActions
setIsDeleteModalOpen={setIsDeleteModalOpen}
setIsPopoverOpen={setIsPopoverOpen}
setIsAddDetailsModalOpen={setIsAddDetailsModalOpen}
stepsCount={stepsCount}
/>
}
placement="bottomRight"
arrow={false}
>
<Ellipsis
className={cx('funnel-item__action-icon', className, {
'funnel-item__action-icon--active': isPopoverOpen,
})}
size={14}
/>
</Popover>
<DeleteFunnelStep
isOpen={isDeleteModalOpen}
onClose={(): void => setIsDeleteModalOpen(false)}
onStepRemove={onStepRemove}
/>
<AddFunnelStepDetailsModal
isOpen={isAddDetailsModalOpen}
onClose={(): void => setIsAddDetailsModalOpen(false)}
stepOrder={stepOrder}
/>
</div>
);
}
FunnelStepPopover.defaultProps = {
className: '',
};
export default FunnelStepPopover;

View File

@@ -1,57 +0,0 @@
.inter-step-config {
display: flex;
align-items: center;
gap: 6px;
.ant-form-item {
margin-bottom: 0;
}
&::before {
content: '';
position: absolute;
left: 4px;
bottom: 16px;
transform: translateY(-50%);
width: 12px;
height: 12px;
background-color: var(--bg-slate-400);
border-radius: 50%;
z-index: 1;
}
&__label {
color: var(--Vanilla-400, #c0c1c3);
font-size: 14px;
line-height: 20px;
letter-spacing: -0.07px;
flex-shrink: 0;
}
&__divider {
width: 100%;
.ant-divider {
margin: 0;
border-color: var(--bg-slate-400);
}
}
&__latency-options {
flex-shrink: 0;
}
}
.lightMode {
.inter-step-config {
background-color: var(--bg-vanilla-200);
color: var(--bg-ink-400);
&::before {
background-color: var(--bg-vanilla-400);
}
&__label {
color: var(--bg-ink-300);
}
&__divider {
.ant-divider {
border-color: var(--bg-vanilla-400);
}
}
}
}

View File

@@ -1,43 +0,0 @@
import './InterStepConfig.styles.scss';
import { Divider } from 'antd';
import SignozRadioGroup from 'components/SignozRadioGroup/SignozRadioGroup';
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
import { FunnelStepData, LatencyOptions } from 'types/api/traceFunnels';
function InterStepConfig({
index,
step,
}: {
index: number;
step: FunnelStepData;
}): JSX.Element {
const { handleStepChange: onStepChange } = useFunnelContext();
const options = Object.entries(LatencyOptions).map(([key, value]) => ({
label: key,
value,
}));
return (
<div className="inter-step-config">
<div className="inter-step-config__label">Latency type</div>
<div className="inter-step-config__divider">
<Divider dashed />
</div>
<div className="inter-step-config__latency-options">
<SignozRadioGroup
value={step.latency_type}
options={options}
onChange={(e): void =>
onStepChange(index, {
...step,
latency_type: e.target.value,
})
}
/>
</div>
</div>
);
}
export default InterStepConfig;

View File

@@ -1,156 +0,0 @@
.steps-content {
height: calc(
100vh - 253px
); // 64px (footer) + 12 (steps gap) + 32 (steps header) + 16 (steps padding) + 50 (breadcrumb) + 34 (description) + 45 (steps footer) = 219px
overflow-y: auto;
.ant-btn {
box-shadow: none;
&-icon {
margin-inline-end: 0 !important;
}
}
&__description {
display: flex;
flex-direction: column;
gap: 16px;
.funnel-step-wrapper {
display: flex;
gap: 16px;
&__replace-button {
display: flex;
height: 28px;
padding: 5px 12px;
justify-content: center;
align-items: center;
gap: 6px;
border-radius: 3px;
border: 1px solid var(--bg-slate-400);
background: var(--bg-ink-300);
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 18px;
&:disabled {
background-color: rgba(209, 209, 209, 0.074);
color: #5f5f5f;
}
}
}
}
&__add-btn {
border-radius: 2px;
border: 1px solid var(--bg-ink-200);
background: var(--bg-ink-200);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
padding: 6px 12px;
display: flex;
align-items: center;
gap: 6px;
margin-top: 2px;
}
.ant-steps-item.steps-content__add-step {
.ant-steps-item-icon {
margin-left: 4px;
margin-right: 20px;
width: 12px;
height: 12px;
}
.ant-steps-icon {
display: none;
}
}
.ant-steps-item-process .ant-steps-item-icon,
.ant-steps-item-icon {
// margin-left: 6px;
height: 20px;
width: 20px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background-color: var(--bg-slate-400) !important;
& > .ant-steps-icon {
font-size: 13px;
font-weight: 400;
line-height: normal;
letter-spacing: -0.065px;
color: var(--bg-vanilla-400);
}
}
.ant-steps.ant-steps-vertical
> .ant-steps-item
> .ant-steps-item-container
> .ant-steps-item-tail {
inset-inline-start: 9px;
}
.ant-steps-item-tail {
padding: 20px 0 0 !important;
&::after {
background-color: var(--bg-slate-400) !important;
}
}
.latency-step-marker {
&::before {
content: '';
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
width: 12px;
height: 12px;
background-color: var(--bg-ink-400);
border-radius: 50%;
z-index: 1;
}
}
}
// Light mode styles
.lightMode {
.funnel-step-wrapper__replace-button {
background: var(--bg-vanilla-300);
color: var(--bg-ink-400);
border: none;
}
.steps-content {
&__add-btn {
background: var(--bg-vanilla-300);
border: none;
color: var(--bg-ink-400);
&:hover {
background: var(--bg-vanilla-400);
}
}
.ant-steps-item-icon {
background-color: var(--bg-vanilla-400) !important;
.ant-steps-icon {
color: var(--bg-ink-400);
}
}
.ant-steps-item-tail::after {
background-color: var(--bg-vanilla-400) !important;
}
.inter-step-config::before {
background-color: var(--bg-vanilla-400);
}
.latency-step-marker::before {
background-color: var(--bg-vanilla-400);
}
}
}

View File

@@ -1,106 +0,0 @@
import './StepsContent.styles.scss';
import { Button, Steps } from 'antd';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { PlusIcon, Undo2 } from 'lucide-react';
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
import { memo, useCallback } from 'react';
import { Span } from 'types/api/trace/getTraceV2';
import FunnelStep from './FunnelStep';
import InterStepConfig from './InterStepConfig';
const { Step } = Steps;
function StepsContent({
isTraceDetailsPage,
span,
}: {
isTraceDetailsPage?: boolean;
span?: Span;
}): JSX.Element {
const { steps, handleAddStep, handleReplaceStep } = useFunnelContext();
const handleAddForNewStep = useCallback(() => {
if (!span) return;
const stepWasAdded = handleAddStep();
if (stepWasAdded) {
handleReplaceStep(steps.length, span.serviceName, span.name);
}
}, [span, handleAddStep, handleReplaceStep, steps.length]);
return (
<div className="steps-content">
<OverlayScrollbar>
<Steps direction="vertical">
{steps.map((step, index) => (
<Step
key={`step-${index + 1}`}
description={
<div className="steps-content__description">
<div className="funnel-step-wrapper">
<FunnelStep stepData={step} index={index} stepsCount={steps.length} />
{isTraceDetailsPage && span && (
<Button
type="default"
className="funnel-step-wrapper__replace-button"
icon={<Undo2 size={12} />}
disabled={
step.service_name === span.serviceName &&
step.span_name === span.name
}
onClick={(): void =>
handleReplaceStep(index, span.serviceName, span.name)
}
>
Replace
</Button>
)}
</div>
{/* Display InterStepConfig only between steps */}
{index < steps.length - 1 && (
<InterStepConfig index={index} step={step} />
)}
</div>
}
/>
))}
{steps.length < 3 && (
<Step
className="steps-content__add-step"
description={
!isTraceDetailsPage ? (
<Button
type="default"
className="steps-content__add-btn"
onClick={handleAddStep}
icon={<PlusIcon size={14} />}
>
Add Funnel Step
</Button>
) : (
<Button
type="default"
className="steps-content__add-btn"
onClick={handleAddForNewStep}
icon={<PlusIcon size={14} />}
>
Add for new Step
</Button>
)
}
/>
)}
</Steps>
</OverlayScrollbar>
</div>
);
}
StepsContent.defaultProps = {
isTraceDetailsPage: false,
span: undefined,
};
export default memo(StepsContent);

View File

@@ -1,74 +0,0 @@
.steps-footer {
border-top: 1px solid var(--bg-slate-500);
background: var(--bg-ink-500);
padding: 16px;
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
&__left {
display: flex;
gap: 6px;
align-items: center;
font-size: 14px;
line-height: 18px; /* 128.571% */
letter-spacing: -0.07px;
}
&__valid-traces {
&--none {
color: var(--text-amber-500);
}
}
&__right {
display: flex;
align-items: center;
gap: 8px;
}
&__button {
border: none;
display: flex;
align-items: center;
gap: 6px;
.ant-btn-icon {
margin-inline-end: 0 !important;
}
&--save {
background-color: var(--bg-slate-400);
}
&--run {
background-color: var(--bg-robin-500);
}
}
}
.lightMode {
.steps-footer {
border-top: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-200);
&__left {
color: var(--bg-ink-400);
}
&__valid-traces {
&--none {
color: var(--text-amber-600);
}
}
&__button {
&--save {
background: var(--bg-vanilla-300);
}
&--run {
background-color: var(--bg-robin-400);
}
}
}
}

View File

@@ -1,109 +0,0 @@
import './StepsFooter.styles.scss';
import { SyncOutlined } from '@ant-design/icons';
import { Button, Skeleton } from 'antd';
import cx from 'classnames';
import { Check, Cone, Play } from 'lucide-react';
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
import { useState } from 'react';
import AddFunnelDescriptionModal from './AddFunnelDescriptionModal';
interface StepsFooterProps {
stepsCount: number;
funnelId: string;
funnelDescription: string;
}
function ValidTracesCount(): JSX.Element {
const {
hasAllEmptyStepFields,
isValidateStepsLoading,
hasIncompleteStepFields,
validTracesCount,
} = useFunnelContext();
if (isValidateStepsLoading) {
return <Skeleton.Button size="small" />;
}
if (hasAllEmptyStepFields) {
return (
<span className="steps-footer__valid-traces">No service / span names</span>
);
}
if (hasIncompleteStepFields) {
return (
<span className="steps-footer__valid-traces">
Missing service / span names
</span>
);
}
return (
<span
className={cx('steps-footer__valid-traces', {
'steps-footer__valid-traces--none': validTracesCount === 0,
})}
>
{validTracesCount} valid traces
</span>
);
}
function StepsFooter({
stepsCount,
funnelId,
funnelDescription,
}: StepsFooterProps): JSX.Element {
const { validTracesCount, handleRunFunnel } = useFunnelContext();
const [isDescriptionModalOpen, setIsDescriptionModalOpen] = useState(false);
return (
<div className="steps-footer">
<div className="steps-footer__left">
<Cone className="funnel-icon" size={14} />
<span>{stepsCount} steps</span>
<span>·</span>
<ValidTracesCount />
</div>
<div className="steps-footer__right">
{funnelDescription ? (
<Button
type="primary"
disabled={validTracesCount === 0}
onClick={handleRunFunnel}
icon={<SyncOutlined />}
/>
) : (
<>
<Button
type="default"
className="steps-footer__button steps-footer__button--save"
icon={<Check size={16} />}
onClick={(): void => setIsDescriptionModalOpen(true)}
>
Save funnel
</Button>
<Button
disabled={validTracesCount === 0}
onClick={handleRunFunnel}
type="primary"
className="steps-footer__button steps-footer__button--run"
icon={<Play size={16} />}
>
Run funnel
</Button>
</>
)}
</div>
<AddFunnelDescriptionModal
isOpen={isDescriptionModalOpen}
onClose={(): void => setIsDescriptionModalOpen(false)}
funnelId={funnelId}
/>
</div>
);
}
export default StepsFooter;

View File

@@ -1,51 +0,0 @@
.steps-header {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
&__label {
color: var(--bg-slate-50);
font-size: 12px;
font-weight: 600;
line-height: 18px;
letter-spacing: 0.48px;
text-transform: uppercase;
flex-shrink: 0;
}
&__divider {
width: 100%;
.ant-divider {
margin: 0;
border-color: var(--bg-slate-400);
}
}
&__time-range {
min-width: 192px;
height: 32px;
flex-shrink: 0;
.timeSelection-input {
.ant-input-prefix > svg {
height: 12px;
}
&,
input {
background: var(--bg-ink-300);
font-size: 12px;
}
}
}
}
.lightMode {
.steps-header {
&__label {
color: var(--bg-ink-400);
}
.timeSelection-input {
&,
input {
background: unset;
}
}
}
}

View File

@@ -1,24 +0,0 @@
import './StepsHeader.styles.scss';
import { Divider } from 'antd';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
function StepsHeader(): JSX.Element {
return (
<div className="steps-header">
<div className="steps-header__label">FUNNEL STEPS</div>
<div className="steps-header__divider">
<Divider dashed />
</div>
<div className="steps-header__time-range">
<DateTimeSelectionV2
showAutoRefresh={false}
showRefreshText={false}
hideShareModal
/>
</div>
</div>
);
}
export default StepsHeader;

View File

@@ -1,42 +0,0 @@
.funnel-results--empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.empty-funnel-results {
display: flex;
flex-direction: column;
gap: 4px;
&__title {
color: var(--bg-vanilla-100);
font-size: 14px;
font-weight: 500;
line-height: 18px;
letter-spacing: -0.07px;
}
&__description {
color: var(--bg-vanilla-400);
font-size: 14px;
line-height: 18px;
letter-spacing: -0.07px;
}
&__learn-more {
margin-top: 8px;
}
}
.lightMode {
.empty-funnel-results {
&__title {
color: var(--bg-ink-400);
}
&__description {
color: var(--bg-ink-300);
}
}
}

View File

@@ -1,33 +0,0 @@
import './EmptyFunnelResults.styles.scss';
import LearnMore from 'components/LearnMore/LearnMore';
function EmptyFunnelResults({
title,
description,
}: {
title?: string;
description?: string;
}): JSX.Element {
return (
<div className="funnel-results funnel-results--empty">
<div className="empty-funnel-results">
<div className="empty-funnel-results__icon">
<img src="/Icons/empty-funnel-icon.svg" alt="Empty funnel results" />
</div>
<div className="empty-funnel-results__title">{title}</div>
<div className="empty-funnel-results__description">{description}</div>
<div className="empty-funnel-results__learn-more">
<LearnMore />
</div>
</div>
</div>
);
}
EmptyFunnelResults.defaultProps = {
title: 'No spans selected yet.',
description: 'Add spans to the funnel steps to start seeing analytics here.',
};
export default EmptyFunnelResults;

View File

@@ -1,117 +0,0 @@
.funnel-graph {
width: 100%;
padding: 16px;
height: 459px;
background: var(--bg-ink-500);
border-radius: 6px;
box-shadow: 0px 4px 12px 0px rgba(0, 0, 0, 0.1);
border: 1px solid var(--bg-slate-500);
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 13px;
&--2-columns {
.funnel-graph {
&__legend-column {
width: 45%;
}
&__legends {
padding-left: 10%;
padding-right: 5%;
}
}
}
&--3-columns {
.funnel-graph {
&__legend-column {
width: 30%;
}
&__legends {
padding-left: 6%;
padding-right: 2%;
}
}
}
&__chart-container {
width: 100%;
height: 370px;
}
&__legends {
display: flex;
justify-content: space-between;
padding-left: 7%;
padding-right: 2%;
width: 100%;
}
&__legend-column {
display: flex;
flex-direction: column;
gap: 8px;
.legend-item {
display: flex;
align-items: center;
justify-content: space-between;
font-family: 'Geist Mono', monospace;
font-size: 12px;
&__left {
display: flex;
align-items: center;
gap: 8px;
}
&__right {
display: flex;
align-items: center;
gap: 8px;
}
&__dot {
width: 8px;
height: 8px;
border-radius: 1px;
flex-shrink: 0;
}
&--total {
background-color: var(--bg-robin-500);
}
&--error {
background-color: var(--bg-cherry-500);
}
&__label {
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 12px;
}
&__value {
color: var(--bg-vanilla-100);
font-family: 'Geist Mono';
font-size: 12px;
}
}
}
}
.lightMode {
.funnel-graph {
background: var(--bg-vanilla-100);
border: 1px solid var(--bg-vanilla-300);
&__legend-column {
.legend-item {
&__label,
&__value {
color: var(--bg-ink-500);
}
}
}
}
}

View File

@@ -1,101 +0,0 @@
import './FunnelGraph.styles.scss';
import { LoadingOutlined } from '@ant-design/icons';
import { Empty, Spin } from 'antd';
import {
BarController,
BarElement,
CategoryScale,
Chart,
Legend,
LinearScale,
Title,
} from 'chart.js';
import cx from 'classnames';
import Spinner from 'components/Spinner';
import useFunnelGraph from 'hooks/TracesFunnels/useFunnelGraph';
import { useFunnelStepsGraphData } from 'hooks/TracesFunnels/useFunnels';
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
import { useMemo } from 'react';
// Register required components
Chart.register(
BarController,
BarElement,
CategoryScale,
LinearScale,
Legend,
Title,
);
function FunnelGraph(): JSX.Element {
const { funnelId } = useFunnelContext();
const {
data: stepsData,
isLoading,
isFetching,
isError,
} = useFunnelStepsGraphData(funnelId);
const data = useMemo(() => stepsData?.payload?.data?.[0]?.data, [
stepsData?.payload?.data,
]);
const {
successSteps,
errorSteps,
totalSteps,
canvasRef,
renderLegendItem,
} = useFunnelGraph({ data });
if (isLoading) {
return (
<div className="funnel-graph">
<Spinner size="default" />
</div>
);
}
if (!data) {
return (
<div className="funnel-graph">
<Empty description="No data" />
</div>
);
}
if (isError) {
return (
<div className="funnel-graph">
<Empty description="Error fetching data. If the problem persists, please contact support." />
</div>
);
}
return (
<Spin spinning={isFetching} indicator={<LoadingOutlined spin />}>
<div className={cx('funnel-graph', `funnel-graph--${totalSteps}-columns`)}>
<div className="funnel-graph__chart-container">
<canvas ref={canvasRef} />
</div>
<div className="funnel-graph__legends">
{Array.from({ length: totalSteps }, (_, index) => {
const prevTotalSpans =
index > 0
? successSteps[index - 1] + errorSteps[index - 1]
: successSteps[0] + errorSteps[0];
return renderLegendItem(
index + 1,
successSteps[index],
errorSteps[index],
prevTotalSpans,
);
})}
</div>
</div>
</Spin>
);
}
export default FunnelGraph;

View File

@@ -1,122 +0,0 @@
.funnel-metrics {
background: var(--bg-ink-500);
border-radius: 6px;
box-shadow: 0px 4px 12px 0px rgba(0, 0, 0, 0.1);
border: 1px solid var(--bg-slate-500);
&--loading-state,
&--empty-state {
padding: 16px;
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 16px;
border-bottom: 1px solid var(--bg-slate-500);
}
&__title {
color: var(--bg-vanilla-400);
font-size: 12px;
font-weight: 500;
line-height: 22px;
letter-spacing: 0.48px;
text-transform: uppercase;
}
&__subtitle {
display: flex;
align-items: center;
gap: 8px;
&-label {
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 14px;
line-height: 22px; /* 157.143% */
letter-spacing: -0.07px;
}
&-value {
color: var(--bg-vanilla-100);
font-family: 'Geist Mono';
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 22px; /* 157.143% */
letter-spacing: -0.07px;
}
}
&__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
}
&__item {
display: flex;
flex-direction: column;
gap: 26px;
padding: 14px 16px;
&:not(:last-child) {
border-right: 1px solid var(--bg-slate-500);
}
&-title {
color: var(--bg-vanilla-100);
font-size: 14px;
line-height: 22px; /* 157.143% */
letter-spacing: -0.07px;
}
&-value,
&-unit {
color: var(--bg-vanilla-400);
font-family: 'Geist Mono';
font-size: 14px;
line-height: 22px; /* 157.143% */
letter-spacing: -0.07px;
}
}
}
.lightMode {
.funnel-metrics {
background: var(--bg-vanilla-100);
border: 1px solid var(--bg-vanilla-300);
box-shadow: 0px 4px 12px 0px rgba(0, 0, 0, 0.05);
&__header {
border-bottom: 1px solid var(--bg-vanilla-300);
}
&__title {
color: var(--text-ink-300);
}
&__subtitle {
&-label {
color: var(--text-ink-300);
}
&-value {
color: var(--text-ink-500);
}
}
&__item {
&:not(:last-child) {
border-right: 1px solid var(--bg-vanilla-300);
}
&-title {
color: var(--text-ink-500);
}
&-value,
&-unit {
color: var(--text-ink-300);
}
}
}
}

View File

@@ -1,104 +0,0 @@
import './FunnelMetricsTable.styles.scss';
import { Empty } from 'antd';
import Spinner from 'components/Spinner';
export interface MetricItem {
title: string;
value: string | number;
}
interface FunnelMetricsTableProps {
title: string;
subtitle?: {
label: string;
value: string | number;
};
data: MetricItem[];
isLoading?: boolean;
isError?: boolean;
emptyState?: JSX.Element;
}
function FunnelMetricsContentRenderer({
data,
isLoading,
isError,
emptyState,
}: {
data: MetricItem[];
isLoading?: boolean;
isError?: boolean;
emptyState?: JSX.Element;
}): JSX.Element {
if (isLoading)
return (
<div className="funnel-metrics--loading-state">
<Spinner size="small" height="100%" />
</div>
);
if (data.length === 0 && emptyState) {
return emptyState;
}
if (isError) {
return (
<Empty description="Error fetching data. If the problem persists, please contact support." />
);
}
return (
<div className="funnel-metrics__grid">
{data.map((metric) => (
<div key={metric.title} className="funnel-metrics__item">
<div className="funnel-metrics__item-title">{metric.title}</div>
<div className="funnel-metrics__item-value">{metric.value}</div>
</div>
))}
</div>
);
}
FunnelMetricsContentRenderer.defaultProps = {
isLoading: false,
isError: false,
emptyState: <Empty className="funnel-metrics--empty-state" />,
};
function FunnelMetricsTable({
title,
subtitle,
data,
isLoading,
isError,
emptyState,
}: FunnelMetricsTableProps): JSX.Element {
return (
<div className="funnel-metrics">
<div className="funnel-metrics__header">
<div className="funnel-metrics__title">{title}</div>
{subtitle && (
<div className="funnel-metrics__subtitle">
<span className="funnel-metrics__subtitle-label">{subtitle.label}</span>
<span className="funnel-metrics__subtitle-separator"></span>
<span className="funnel-metrics__subtitle-value">{subtitle.value}</span>
</div>
)}
</div>
<FunnelMetricsContentRenderer
data={data}
isLoading={isLoading}
emptyState={emptyState}
isError={isError}
/>
</div>
);
}
FunnelMetricsTable.defaultProps = {
subtitle: undefined,
isLoading: false,
emptyState: <Empty className="funnel-metrics--empty-state" />,
isError: false,
};
export default FunnelMetricsTable;

View File

@@ -1,6 +0,0 @@
.funnel-results {
padding: 16px;
display: flex;
flex-direction: column;
gap: 20px;
}

View File

@@ -1,51 +0,0 @@
import './FunnelResults.styles.scss';
import Spinner from 'components/Spinner';
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
import EmptyFunnelResults from './EmptyFunnelResults';
import FunnelGraph from './FunnelGraph';
import OverallMetrics from './OverallMetrics';
import StepsTransitionResults from './StepsTransitionResults';
function FunnelResults(): JSX.Element {
const {
validTracesCount,
isValidateStepsLoading,
hasIncompleteStepFields,
hasAllEmptyStepFields,
} = useFunnelContext();
if (isValidateStepsLoading) {
return <Spinner size="large" />;
}
if (hasAllEmptyStepFields) return <EmptyFunnelResults />;
if (hasIncompleteStepFields)
return (
<EmptyFunnelResults
title="Missing service / span names"
description="Fill in the service and span names for all the steps"
/>
);
if (validTracesCount === 0) {
return (
<EmptyFunnelResults
title="There are no traces that match the funnel steps."
description="Check the service / span names in the funnel steps and try again to start seeing analytics here"
/>
);
}
return (
<div className="funnel-results">
<OverallMetrics />
<FunnelGraph />
<StepsTransitionResults />
</div>
);
}
export default FunnelResults;

View File

@@ -1,148 +0,0 @@
.funnel-table {
border-radius: 3px;
border: 1px solid var(--bg-slate-500);
background: linear-gradient(
0deg,
rgba(171, 189, 255, 0.01) 0%,
rgba(171, 189, 255, 0.01) 100%
),
#0b0c0e;
&__header {
padding: 12px 14px 12px;
padding-bottom: 24px;
color: var(--bg-vanilla-100);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 18px;
background: var(--bg-ink-400);
display: flex;
justify-content: space-between;
align-items: center;
}
.ant-table {
.ant-table-thead > tr > th {
padding: 2px 12px;
border-bottom: none;
color: var(--text-vanilla-400);
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 600;
line-height: 18px;
letter-spacing: 0.44px;
text-transform: uppercase;
background: none;
.ant-table-cell:first-child {
border-radius: 0px 4px 0px 0px !important;
}
&::before {
background-color: transparent;
}
}
.ant-table-cell {
padding: 12px;
font-size: 13px;
line-height: 20px;
color: var(--bg-vanilla-100);
border-bottom: none;
}
.ant-table-tbody > tr:hover > td {
background: rgba(255, 255, 255, 0.04);
}
.ant-table-cell:first-child {
text-align: justify;
background: rgba(171, 189, 255, 0.04);
}
.ant-table-cell:nth-child(2) {
padding-left: 16px;
padding-right: 16px;
}
.ant-table-cell:nth-child(n + 3) {
padding-right: 24px;
}
.column-header {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
}
.ant-table-tbody > tr > td {
border-bottom: none;
}
.ant-table-thead
> tr
> th:not(:last-child):not(.ant-table-selection-column):not(.ant-table-row-expand-icon-cell):not([colspan])::before {
background-color: transparent;
}
.ant-empty-normal {
visibility: hidden;
}
.table-row-light {
background: none;
}
.table-row-dark {
background: var(--bg-ink-300);
}
.trace-id-cell {
color: var(--bg-robin-400);
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
}
}
.lightMode {
.funnel-table {
border-color: var(--bg-vanilla-300);
.ant-table {
.ant-table-thead > tr > th {
background: var(--bg-vanilla-100);
color: var(--text-ink-300);
}
.ant-table-cell {
background: var(--bg-vanilla-100);
color: var(--bg-ink-500);
}
.ant-table-tbody > tr:hover > td {
background: rgba(0, 0, 0, 0.04);
}
.table-row-light {
background: none;
color: var(--bg-ink-500);
}
.table-row-dark {
background: none;
color: var(--bg-ink-500);
}
}
&__header {
background: var(--bg-vanilla-100);
color: var(--text-ink-300);
}
}
}

View File

@@ -1,55 +0,0 @@
import './FunnelTable.styles.scss';
import { Empty, Table, Tooltip } from 'antd';
import { ColumnProps } from 'antd/es/table';
interface FunnelTableProps {
loading?: boolean;
data?: any[];
columns: Array<ColumnProps<any>>;
title: string;
tooltip?: string;
}
function FunnelTable({
loading = false,
data = [],
columns = [],
title,
tooltip,
}: FunnelTableProps): JSX.Element {
return (
<div className="funnel-table">
<div className="funnel-table__header">
<div className="funnel-table__title">{title}</div>
<div className="funnel-table__actions">
<Tooltip title={tooltip ?? null}>
<img src="/Icons/solid-info-circle.svg" alt="info" />
</Tooltip>
</div>
</div>
<Table
columns={columns}
dataSource={data}
loading={loading}
pagination={false}
locale={{
emptyText: loading ? null : <Empty />,
}}
scroll={{ x: true }}
tableLayout="fixed"
rowClassName={(_, index): string =>
index % 2 === 0 ? 'table-row-dark' : 'table-row-light'
}
/>
</div>
);
}
FunnelTable.defaultProps = {
loading: false,
data: [],
tooltip: '',
};
export default FunnelTable;

View File

@@ -1,103 +0,0 @@
import { ErrorTraceData, SlowTraceData } from 'api/traceFunnels';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
import { useMemo } from 'react';
import { UseQueryResult } from 'react-query';
import { Link } from 'react-router-dom';
import { ErrorResponse, SuccessResponse } from 'types/api';
import FunnelTable from './FunnelTable';
interface FunnelTopTracesTableProps {
funnelId: string;
stepAOrder: number;
stepBOrder: number;
title: string;
tooltip: string;
useQueryHook: (
funnelId: string,
payload: {
start_time: number;
end_time: number;
step_a_order: number;
step_b_order: number;
},
) => UseQueryResult<
SuccessResponse<SlowTraceData | ErrorTraceData> | ErrorResponse,
Error
>;
}
function FunnelTopTracesTable({
funnelId,
stepAOrder,
stepBOrder,
title,
tooltip,
useQueryHook,
}: FunnelTopTracesTableProps): JSX.Element {
const { startTime, endTime } = useFunnelContext();
const payload = useMemo(
() => ({
start_time: startTime,
end_time: endTime,
step_a_order: stepAOrder,
step_b_order: stepBOrder,
}),
[startTime, endTime, stepAOrder, stepBOrder],
);
const { data: response, isLoading, isFetching } = useQueryHook(
funnelId,
payload,
);
const data = useMemo(() => {
if (!response?.payload?.data) return [];
return response.payload.data.map((item) => ({
trace_id: item.data.trace_id,
duration_ms: item.data.duration_ms,
span_count: item.data.span_count,
}));
}, [response]);
const columns = useMemo(
() => [
{
title: 'TRACE ID',
dataIndex: 'trace_id',
key: 'trace_id',
render: (traceId: string): JSX.Element => (
<Link to={`/trace/${traceId}`} className="trace-id-cell">
{traceId}
</Link>
),
},
{
title: 'DURATION',
dataIndex: 'duration_ms',
key: 'duration_ms',
render: (value: string): string => getYAxisFormattedValue(value, 'ms'),
},
{
title: 'SPAN COUNT',
dataIndex: 'span_count',
key: 'span_count',
render: (value: number): string => value.toString(),
},
],
[],
);
return (
<FunnelTable
title={title}
tooltip={tooltip}
columns={columns}
data={data}
loading={isLoading || isFetching}
/>
);
}
export default FunnelTopTracesTable;

View File

@@ -1,26 +0,0 @@
import { useFunnelMetrics } from 'hooks/TracesFunnels/useFunnelMetrics';
import { useParams } from 'react-router-dom';
import FunnelMetricsTable from './FunnelMetricsTable';
function OverallMetrics(): JSX.Element {
const { funnelId } = useParams<{ funnelId: string }>();
const { isLoading, metricsData, conversionRate, isError } = useFunnelMetrics({
funnelId: funnelId || '',
});
return (
<FunnelMetricsTable
title="Overall Funnel Metrics"
subtitle={{
label: 'Conversion rate',
value: `${conversionRate.toFixed(2)}%`,
}}
isLoading={isLoading}
isError={isError}
data={metricsData}
/>
);
}
export default OverallMetrics;

View File

@@ -1,53 +0,0 @@
import { useFunnelMetrics } from 'hooks/TracesFunnels/useFunnelMetrics';
import { useParams } from 'react-router-dom';
import FunnelMetricsTable from './FunnelMetricsTable';
import { StepTransition } from './StepsTransitionResults';
interface StepsTransitionMetricsProps {
selectedTransition: string;
transitions: StepTransition[];
startStep?: number;
endStep?: number;
}
function StepsTransitionMetrics({
selectedTransition,
transitions,
startStep,
endStep,
}: StepsTransitionMetricsProps): JSX.Element {
const { funnelId } = useParams<{ funnelId: string }>();
const currentTransition = transitions.find(
(transition) => transition.value === selectedTransition,
);
const { isLoading, metricsData, conversionRate } = useFunnelMetrics({
funnelId: funnelId || '',
stepStart: startStep,
stepEnd: endStep,
});
if (!currentTransition) {
return <div>No transition selected</div>;
}
return (
<FunnelMetricsTable
title={currentTransition.label}
subtitle={{
label: 'Conversion rate',
value: `${conversionRate.toFixed(2)}%`,
}}
isLoading={isLoading}
data={metricsData}
/>
);
}
StepsTransitionMetrics.defaultProps = {
startStep: undefined,
endStep: undefined,
};
export default StepsTransitionMetrics;

View File

@@ -1,38 +0,0 @@
.steps-transition-results {
display: flex;
flex-direction: column;
gap: 20px;
&__steps-selector {
display: flex;
justify-content: center;
}
&__results {
display: flex;
flex-direction: column;
gap: 16px;
}
}
.lightMode {
.steps-transition-results {
&__steps-selector {
.views-tabs {
.tab {
background: var(--bg-vanilla-100);
}
.selected_view {
background: var(--bg-vanilla-300);
border: 1px solid var(--bg-slate-300);
color: var(--text-ink-400);
}
.selected_view::before {
background: var(--bg-vanilla-300);
border-left: 1px solid var(--bg-slate-300);
}
}
}
}
}

View File

@@ -1,66 +0,0 @@
import './StepsTransitionResults.styles.scss';
import SignozRadioGroup from 'components/SignozRadioGroup/SignozRadioGroup';
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
import { useMemo, useState } from 'react';
import StepsTransitionMetrics from './StepsTransitionMetrics';
import TopSlowestTraces from './TopSlowestTraces';
import TopTracesWithErrors from './TopTracesWithErrors';
export interface StepTransition {
value: string;
label: string;
}
function generateStepTransitions(stepsCount: number): StepTransition[] {
return Array.from({ length: stepsCount - 1 }, (_, index) => ({
value: `${index + 1}_to_${index + 2}`,
label: `Step ${index + 1} -> Step ${index + 2}`,
}));
}
function StepsTransitionResults(): JSX.Element {
const { steps, funnelId } = useFunnelContext();
const stepTransitions = generateStepTransitions(steps.length);
const [selectedTransition, setSelectedTransition] = useState<string>(
stepTransitions[0]?.value || '',
);
const [stepAOrder, stepBOrder] = useMemo(() => {
const [a, b] = selectedTransition.split('_to_');
return [parseInt(a, 10), parseInt(b, 10)];
}, [selectedTransition]);
return (
<div className="steps-transition-results">
<div className="steps-transition-results__steps-selector">
<SignozRadioGroup
value={selectedTransition}
options={stepTransitions}
onChange={(e): void => setSelectedTransition(e.target.value)}
/>
</div>
<div className="steps-transition-results__results">
<StepsTransitionMetrics
selectedTransition={selectedTransition}
transitions={stepTransitions}
startStep={stepAOrder}
endStep={stepBOrder}
/>
<TopSlowestTraces
funnelId={funnelId}
stepAOrder={stepAOrder}
stepBOrder={stepBOrder}
/>
<TopTracesWithErrors
funnelId={funnelId}
stepAOrder={stepAOrder}
stepBOrder={stepBOrder}
/>
</div>
</div>
);
}
export default StepsTransitionResults;

View File

@@ -1,23 +0,0 @@
import { useFunnelSlowTraces } from 'hooks/TracesFunnels/useFunnels';
import FunnelTopTracesTable from './FunnelTopTracesTable';
interface TopSlowestTracesProps {
funnelId: string;
stepAOrder: number;
stepBOrder: number;
}
function TopSlowestTraces(props: TopSlowestTracesProps): JSX.Element {
return (
<FunnelTopTracesTable
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
title="Slowest 5 traces"
tooltip="A list of the slowest traces in the funnel"
useQueryHook={useFunnelSlowTraces}
/>
);
}
export default TopSlowestTraces;

View File

@@ -1,23 +0,0 @@
import { useFunnelErrorTraces } from 'hooks/TracesFunnels/useFunnels';
import FunnelTopTracesTable from './FunnelTopTracesTable';
interface TopTracesWithErrorsProps {
funnelId: string;
stepAOrder: number;
stepBOrder: number;
}
function TopTracesWithErrors(props: TopTracesWithErrorsProps): JSX.Element {
return (
<FunnelTopTracesTable
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
title="Traces with errors"
tooltip="A list of the traces with errors in the funnel"
useQueryHook={useFunnelErrorTraces}
/>
);
}
export default TopTracesWithErrors;

View File

@@ -1,49 +0,0 @@
import { FunnelStepData, LatencyOptions } from 'types/api/traceFunnels';
import { v4 } from 'uuid';
export const initialStepsData: FunnelStepData[] = [
{
id: v4(),
step_order: 1,
service_name: '',
span_name: '',
filters: {
items: [],
op: 'and',
},
latency_pointer: 'start',
latency_type: LatencyOptions.P95,
has_errors: false,
title: '',
description: '',
},
{
id: v4(),
step_order: 2,
service_name: '',
span_name: '',
filters: {
items: [],
op: 'and',
},
latency_pointer: 'start',
latency_type: LatencyOptions.P95,
has_errors: false,
title: '',
description: '',
},
];
export const LatencyPointers: {
value: FunnelStepData['latency_pointer'];
key: string;
}[] = [
{
value: 'start',
key: 'Start of span',
},
{
value: 'end',
key: 'End of span',
},
];

View File

@@ -1,237 +0,0 @@
import { ValidateFunnelResponse } from 'api/traceFunnels';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { Time } from 'container/TopNav/DateTimeSelection/config';
import {
CustomTimeType,
Time as TimeV2,
} from 'container/TopNav/DateTimeSelectionV2/config';
import { useValidateFunnelSteps } from 'hooks/TracesFunnels/useFunnels';
import getStartEndRangeTime from 'lib/getStartEndRangeTime';
import { initialStepsData } from 'pages/TracesFunnelDetails/constants';
import {
createContext,
Dispatch,
SetStateAction,
useCallback,
useContext,
useMemo,
useState,
} from 'react';
import { useQueryClient } from 'react-query';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { FunnelData, FunnelStepData } from 'types/api/traceFunnels';
import { GlobalReducer } from 'types/reducer/globalTime';
import { v4 } from 'uuid';
interface FunnelContextType {
startTime: number;
endTime: number;
selectedTime: CustomTimeType | Time | TimeV2;
validTracesCount: number;
funnelId: string;
steps: FunnelStepData[];
setSteps: Dispatch<SetStateAction<FunnelStepData[]>>;
initialSteps: FunnelStepData[];
handleAddStep: () => boolean;
handleStepChange: (index: number, newStep: Partial<FunnelStepData>) => void;
handleStepRemoval: (index: number) => void;
handleRunFunnel: () => void;
validationResponse:
| SuccessResponse<ValidateFunnelResponse>
| ErrorResponse
| undefined;
isValidateStepsLoading: boolean;
hasIncompleteStepFields: boolean;
setHasIncompleteStepFields: Dispatch<SetStateAction<boolean>>;
hasAllEmptyStepFields: boolean;
setHasAllEmptyStepFields: Dispatch<SetStateAction<boolean>>;
handleReplaceStep: (
index: number,
serviceName: string,
spanName: string,
) => void;
}
const FunnelContext = createContext<FunnelContextType | undefined>(undefined);
export function FunnelProvider({
children,
funnelId,
}: {
children: React.ReactNode;
funnelId: string;
}): JSX.Element {
const { selectedTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const { start, end } = getStartEndRangeTime({
type: 'GLOBAL_TIME',
interval: selectedTime,
});
const startTime = Math.floor(Number(start) * 1e9);
const endTime = Math.floor(Number(end) * 1e9);
const queryClient = useQueryClient();
const data = queryClient.getQueryData<{ payload: FunnelData }>([
REACT_QUERY_KEY.GET_FUNNEL_DETAILS,
funnelId,
]);
const funnel = data?.payload;
const initialSteps = funnel?.steps?.length ? funnel.steps : initialStepsData;
const [steps, setSteps] = useState<FunnelStepData[]>(initialSteps);
const [hasIncompleteStepFields, setHasIncompleteStepFields] = useState(
steps.some((step) => step.service_name === '' || step.span_name === ''),
);
const [hasAllEmptyStepFields, setHasAllEmptyStepFields] = useState(
steps.every((step) => step.service_name === '' && step.span_name === ''),
);
const {
data: validationResponse,
isLoading: isValidationLoading,
isFetching: isValidationFetching,
} = useValidateFunnelSteps({
funnelId,
selectedTime,
startTime,
endTime,
});
const validTracesCount = useMemo(
() => validationResponse?.payload?.data?.length || 0,
[validationResponse],
);
// Step modifications
const handleStepUpdate = useCallback(
(index: number, newStep: Partial<FunnelStepData>) => {
setSteps((prev) =>
prev.map((step, i) => (i === index ? { ...step, ...newStep } : step)),
);
},
[],
);
const addNewStep = useCallback(() => {
if (steps.length >= 3) return false;
setSteps((prev) => [
...prev,
{
...initialStepsData[0],
id: v4(),
step_order: prev.length + 1,
},
]);
return true;
}, [steps.length]);
const handleStepRemoval = useCallback((index: number) => {
setSteps((prev) =>
prev
// remove the step in the index
.filter((_, i) => i !== index)
// reset the step_order for the remaining steps
.map((step, newIndex) => ({
...step,
step_order: newIndex + 1,
})),
);
}, []);
const handleReplaceStep = useCallback(
(index: number, serviceName: string, spanName: string) => {
handleStepUpdate(index, {
service_name: serviceName,
span_name: spanName,
});
},
[handleStepUpdate],
);
if (!funnelId) {
throw new Error('Funnel ID is required');
}
const handleRunFunnel = useCallback(async (): Promise<void> => {
if (validTracesCount === 0) return;
queryClient.refetchQueries([
REACT_QUERY_KEY.GET_FUNNEL_OVERVIEW,
funnelId,
selectedTime,
]);
queryClient.refetchQueries([
REACT_QUERY_KEY.GET_FUNNEL_STEPS_GRAPH_DATA,
funnelId,
selectedTime,
]);
queryClient.refetchQueries([
REACT_QUERY_KEY.GET_FUNNEL_ERROR_TRACES,
funnelId,
selectedTime,
]);
queryClient.refetchQueries([
REACT_QUERY_KEY.GET_FUNNEL_SLOW_TRACES,
funnelId,
selectedTime,
]);
}, [funnelId, queryClient, selectedTime, validTracesCount]);
const value = useMemo<FunnelContextType>(
() => ({
funnelId,
startTime,
endTime,
validTracesCount,
selectedTime,
steps,
setSteps,
initialSteps,
handleStepChange: handleStepUpdate,
handleAddStep: addNewStep,
handleStepRemoval,
handleRunFunnel,
validationResponse,
isValidateStepsLoading: isValidationLoading || isValidationFetching,
hasIncompleteStepFields,
setHasIncompleteStepFields,
hasAllEmptyStepFields,
setHasAllEmptyStepFields,
handleReplaceStep,
}),
[
funnelId,
startTime,
endTime,
validTracesCount,
selectedTime,
steps,
initialSteps,
handleStepUpdate,
addNewStep,
handleStepRemoval,
handleRunFunnel,
validationResponse,
isValidationLoading,
isValidationFetching,
hasIncompleteStepFields,
setHasIncompleteStepFields,
hasAllEmptyStepFields,
setHasAllEmptyStepFields,
handleReplaceStep,
],
);
return (
<FunnelContext.Provider value={value}>{children}</FunnelContext.Provider>
);
}
export function useFunnelContext(): FunnelContextType {
const context = useContext(FunnelContext);
if (context === undefined) {
throw new Error('useFunnelContext must be used within a FunnelProvider');
}
return context;
}

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