Compare commits

..

21 Commits

Author SHA1 Message Date
grandwizard28
807aa60906 fix: remove dead code 2025-04-28 20:37:30 +05:30
grandwizard28
208a5603a9 Merge branch 'main' into remove-use-flags 2025-04-28 17:10:13 +05:30
Vikrant Gupta
940313d28b fix(organization): return display name instead of name for organization (#7747)
### Summary

- return display name instead of name for organization
2025-04-28 10:54:53 +00:00
grandwizard28
0de779a866 ci: merge with main 2025-04-28 16:03:16 +05:30
Vibhu Pandey
9815ec7d81 chore: remove references to unused flags (#7739)
### Summary

remove references to unused flags
2025-04-28 09:27:26 +00:00
Vibhu Pandey
a7cad0f1a5 chore(conf): add clickhouse settings (#7743) 2025-04-27 14:26:36 +00:00
SagarRajput-7
a624b4758d chore: fix failing typecheck (#7742) 2025-04-27 19:51:05 +05:30
grandwizard28
5cc833b73f Merge branch 'remove-flags' into remove-use-flags 2025-04-27 19:12:35 +05:30
grandwizard28
3eee3bfec1 Merge branch 'main' into remove-flags 2025-04-27 19:12:29 +05:30
grandwizard28
01b308d507 chore(use-*): remove use-new-traces-schema and use-new-logs-schema flags 2025-04-27 19:11:16 +05:30
SagarRajput-7
ee5684b130 feat: added permission restriction for viewer for planned Maintaince (#7736)
* feat: added permission restriction for viewer for planned Maintaince

* feat: added test cases
2025-04-27 17:24:56 +05:30
grandwizard28
dcf627a683 Merge branch 'main' into remove-flags 2025-04-27 17:06:06 +05:30
SagarRajput-7
2f8da5957b feat: added custom single and multiselect components (#7497)
* feat: added new Select component for multi and single select

* feat: refactored code and added keyboard navigations in single select

* feat: different state handling in single select

* feat: updated the playground page

* feat: multi-select updates

* feat: fixed multiselect selection issues

* feat: multiselect cleanup

* feat: multiselect key navigation cleanup

* feat: added tokenization in multiselect

* feat: add on enter and handle duplicates

* feat: design update to the components

* feat: design update to the components

* feat: design update to the components

* feat: updated the playground page

* feat: edited playground data

* feat: edited styles

* feat: code cleanup

* feat: added shift + keys navigation and selection

* feat: improved styles and added darkmode styles

* feat: removed scroll bar hover style

* feat: added scroll bar on hover

* feat: added regex wrapper support

* feat: fixed right arrow navigation across chips

* feat: addressed all the single select feedbacks

* feat: addressed all the single select feedbacks

* feat: added only-all-toggle feat with ALL selection tag

* feat: remove clear, update footer info content and style and misc fixes

* feat: misc style fixes

* feat: added quotes exception to the multiselect tagging

* feat: removing demo page, and cleanup PR for reviews

* feat: resolved comments and refactoring

* feat: added test cases
2025-04-27 16:55:53 +05:30
dependabot[bot]
3f6f77d0e2 chore(deps): bump axios from 1.7.7 to 1.8.2 in /frontend (#7249)
Bumps [axios](https://github.com/axios/axios) from 1.7.7 to 1.8.2.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.7.7...v1.8.2)

---
updated-dependencies:
- dependency-name: axios
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-27 11:16:16 +00:00
Vibhu Pandey
5bceffbeaa fix: fix modules and handler (#7737)
* fix: fix modules and handler

* fix: fix sqlmigration package

* fix: fix other fmt issues

* fix: fix tests

* fix: fix tests
2025-04-27 16:38:34 +05:30
grandwizard28
49c04eb9d9 chore: remove references to unused flags 2025-04-27 01:42:03 +05:30
grandwizard28
c89a8cbb0c fix: fix tests 2025-04-26 21:04:07 +05:30
grandwizard28
b6bb71f650 fix: fix tests 2025-04-26 20:57:07 +05:30
grandwizard28
af135aa068 fix: fix other fmt issues 2025-04-26 20:46:22 +05:30
grandwizard28
4a4e4d6779 fix: fix sqlmigration package 2025-04-26 20:42:11 +05:30
grandwizard28
fc604915ed fix: fix modules and handler 2025-04-26 20:36:58 +05:30
190 changed files with 4910 additions and 10547 deletions

View File

@@ -1,42 +0,0 @@
# Github actions
## Testing the UI manually on each PR
First we need to make sure the UI is ready
* Check the `Start tunnel` step in `e2e-k8s/deploy-on-k3s-cluster` job and make sure you see `your url is: https://pull-<number>-signoz.loca.lt`
* This job will run until the PR is merged or closed to keep the local tunneling alive
- github will cancel this job if the PR wasn't merged after 6h
- if the job was cancel, go to the action and press `Re-run all jobs`
Now you can open your browser at https://pull-<number>-signoz.loca.lt and check the UI.
## Environment Variables
To run GitHub workflow, a few environment variables needs to add in GitHub secrets
<table>
<tr>
<th> Variables </th>
<th> Description </th>
<th> Example </th>
</tr>
<tr>
<td> REPONAME </td>
<td> Provide the DockerHub user/organisation name of the image. </td>
<td> signoz</td>
</tr>
<tr>
<td> DOCKERHUB_USERNAME </td>
<td> Docker hub username </td>
<td> signoz</td>
</tr>
<tr>
<td> DOCKERHUB_TOKEN </td>
<td> Docker hub password/token with push permission </td>
<td> **** </td>
</tr>
<tr>
<td> SONAR_TOKEN </td>
<td> <a href="https://sonarcloud.io">SonarCloud</a> token </td>
<td> **** </td>
</tr>

View File

@@ -1,16 +0,0 @@
name: remove-label
on:
pull_request_target:
types: [synchronize]
jobs:
remove:
runs-on: ubuntu-latest
steps:
- name: Remove label testing-deploy from PR
uses: buildsville/add-remove-label@v2.0.0
with:
label: testing-deploy
type: remove
token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -103,6 +103,13 @@ telemetrystore:
clickhouse:
# The DSN to use for clickhouse.
dsn: tcp://localhost:9000
# The query settings for clickhouse.
settings:
max_execution_time: 0
max_execution_time_leaf: 0
timeout_before_checking_execution_speed: 0
max_bytes_to_read: 0
max_result_rows_for_ch_query: 0
##################### Prometheus #####################
prometheus:

View File

@@ -13,9 +13,6 @@ import (
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/apis/fields"
"github.com/SigNoz/signoz/pkg/http/middleware"
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
"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"
@@ -26,14 +23,12 @@ 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"
)
type APIHandlerOptions struct {
DataConnector interfaces.DataConnector
SkipConfig *basemodel.SkipConfig
PreferSpanMetrics bool
AppDao dao.ModelDao
RulesManager *rules.Manager
@@ -60,13 +55,8 @@ 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()))
organizationAPI := implorganization.NewAPI(implorganization.NewModule(implorganization.NewStore(signoz.SQLStore)))
organizationModule := implorganization.NewModule(implorganization.NewStore(signoz.SQLStore))
baseHandler, err := baseapp.NewAPIHandler(baseapp.APIHandlerOpts{
Reader: opts.DataConnector,
SkipConfig: opts.SkipConfig,
PreferSpanMetrics: opts.PreferSpanMetrics,
AppDao: opts.AppDao,
RuleManager: opts.RulesManager,
@@ -76,14 +66,9 @@ func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz) (*APIHandler,
LogsParsingPipelineController: opts.LogsParsingPipelineController,
Cache: opts.Cache,
FluxInterval: opts.FluxInterval,
UseLogsNewSchema: opts.UseLogsNewSchema,
UseTraceNewSchema: opts.UseTraceNewSchema,
AlertmanagerAPI: alertmanager.NewAPI(signoz.Alertmanager),
FieldsAPI: fields.NewAPI(signoz.TelemetryStore),
Signoz: signoz,
Preference: preference,
OrganizationAPI: organizationAPI,
OrganizationModule: organizationModule,
})
if err != nil {

View File

@@ -134,7 +134,7 @@ func (ah *APIHandler) registerUser(w http.ResponseWriter, r *http.Request) {
return
}
_, registerError := baseauth.Register(ctx, req, ah.Signoz.Alertmanager, ah.OrganizationModule)
_, registerError := baseauth.Register(ctx, req, ah.Signoz.Alertmanager, ah.Signoz.Modules.Organization)
if !registerError.IsNil() {
RespondError(w, apierr, nil)
return
@@ -152,7 +152,7 @@ func (ah *APIHandler) getInvite(w http.ResponseWriter, r *http.Request) {
token := mux.Vars(r)["token"]
sourceUrl := r.URL.Query().Get("ref")
inviteObject, err := baseauth.GetInvite(r.Context(), token, ah.OrganizationModule)
inviteObject, err := baseauth.GetInvite(r.Context(), token, ah.Signoz.Modules.Organization)
if err != nil {
RespondError(w, model.BadRequest(err), nil)
return

View File

@@ -23,12 +23,10 @@ func NewDataConnector(
telemetryStore telemetrystore.TelemetryStore,
prometheus prometheus.Prometheus,
cluster string,
useLogsNewSchema bool,
useTraceNewSchema bool,
fluxIntervalForTraceDetail time.Duration,
cache cache.Cache,
) *ClickhouseReader {
chReader := basechr.NewReader(sqlDB, telemetryStore, prometheus, cluster, useLogsNewSchema, useTraceNewSchema, fluxIntervalForTraceDetail, cache)
chReader := basechr.NewReader(sqlDB, telemetryStore, prometheus, cluster, fluxIntervalForTraceDetail, cache)
return &ClickhouseReader{
conn: telemetryStore.ClickhouseDB(),
appdb: sqlDB,

View File

@@ -45,33 +45,23 @@ import (
baseconst "github.com/SigNoz/signoz/pkg/query-service/constants"
"github.com/SigNoz/signoz/pkg/query-service/healthcheck"
baseint "github.com/SigNoz/signoz/pkg/query-service/interfaces"
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
baserules "github.com/SigNoz/signoz/pkg/query-service/rules"
"github.com/SigNoz/signoz/pkg/query-service/telemetry"
"github.com/SigNoz/signoz/pkg/query-service/utils"
"go.uber.org/zap"
)
const AppDbEngine = "sqlite"
type ServerOptions struct {
Config signoz.Config
SigNoz *signoz.SigNoz
PromConfigPath string
SkipTopLvlOpsPath string
HTTPHostPort string
PrivateHostPort string
// alert specific params
DisableRules bool
RuleRepoURL string
Config signoz.Config
SigNoz *signoz.SigNoz
HTTPHostPort string
PrivateHostPort string
PreferSpanMetrics bool
CacheConfigPath string
FluxInterval string
FluxIntervalForTraceDetail string
Cluster string
GatewayUrl string
UseLogsNewSchema bool
UseTraceNewSchema bool
Jwt *authtypes.JWT
}
@@ -140,20 +130,10 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) {
serverOptions.SigNoz.TelemetryStore,
serverOptions.SigNoz.Prometheus,
serverOptions.Cluster,
serverOptions.UseLogsNewSchema,
serverOptions.UseTraceNewSchema,
fluxIntervalForTraceDetail,
serverOptions.SigNoz.Cache,
)
skipConfig := &basemodel.SkipConfig{}
if serverOptions.SkipTopLvlOpsPath != "" {
// read skip config
skipConfig, err = basemodel.ReadSkipConfig(serverOptions.SkipTopLvlOpsPath)
if err != nil {
return nil, err
}
}
var c cache.Cache
if serverOptions.CacheConfigPath != "" {
cacheOpts, err := cache.LoadFromYAMLCacheConfigFile(serverOptions.CacheConfigPath)
@@ -164,13 +144,9 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) {
}
rm, err := makeRulesManager(
serverOptions.RuleRepoURL,
serverOptions.SigNoz.SQLStore.SQLxDB(),
reader,
c,
serverOptions.DisableRules,
serverOptions.UseLogsNewSchema,
serverOptions.UseTraceNewSchema,
serverOptions.SigNoz.Alertmanager,
serverOptions.SigNoz.SQLStore,
serverOptions.SigNoz.TelemetryStore,
@@ -238,7 +214,6 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) {
apiOpts := api.APIHandlerOptions{
DataConnector: reader,
SkipConfig: skipConfig,
PreferSpanMetrics: serverOptions.PreferSpanMetrics,
AppDao: modelDao,
RulesManager: rm,
@@ -252,8 +227,6 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) {
FluxInterval: fluxInterval,
Gateway: gatewayProxy,
GatewayUrl: serverOptions.GatewayUrl,
UseLogsNewSchema: serverOptions.UseLogsNewSchema,
UseTraceNewSchema: serverOptions.UseTraceNewSchema,
JWT: serverOptions.Jwt,
}
@@ -263,8 +236,6 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) {
}
s := &Server{
// logger: logger,
// tracer: tracer,
ruleManager: rm,
serverOptions: serverOptions,
unavailableChannel: make(chan healthcheck.Status),
@@ -356,7 +327,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{"*"},
@@ -412,13 +382,7 @@ func (s *Server) initListeners() error {
// Start listening on http and private http port concurrently
func (s *Server) Start(ctx context.Context) error {
// initiate rule manager first
if !s.serverOptions.DisableRules {
s.ruleManager.Start(ctx)
} else {
zap.L().Info("msg: Rules disabled as rules.disable is set to TRUE")
}
s.ruleManager.Start(ctx)
err := s.initListeners()
if err != nil {
@@ -509,13 +473,9 @@ func (s *Server) Stop() error {
}
func makeRulesManager(
ruleRepoURL string,
db *sqlx.DB,
ch baseint.Reader,
cache cache.Cache,
disableRules bool,
useLogsNewSchema bool,
useTraceNewSchema bool,
alertmanager alertmanager.Alertmanager,
sqlstore sqlstore.SQLStore,
telemetryStore telemetrystore.TelemetryStore,
@@ -525,17 +485,13 @@ func makeRulesManager(
managerOpts := &baserules.ManagerOptions{
TelemetryStore: telemetryStore,
Prometheus: prometheus,
RepoURL: ruleRepoURL,
DBConn: db,
Context: context.Background(),
Logger: zap.L(),
DisableRules: disableRules,
Reader: ch,
Cache: cache,
EvalDelay: baseconst.GetEvalDelay(),
PrepareTaskFunc: rules.PrepareTaskFunc,
UseLogsNewSchema: useLogsNewSchema,
UseTraceNewSchema: useTraceNewSchema,
PrepareTestRuleFunc: rules.TestNotification,
Alertmanager: alertmanager,
SQLStore: sqlstore,

View File

@@ -21,6 +21,7 @@ import (
"go.uber.org/zap/zapcore"
)
// Deprecated: Please use the logger from pkg/instrumentation.
func initZapLog() *zap.Logger {
config := zap.NewProductionConfig()
config.EncoderConfig.TimeKey = "timestamp"
@@ -50,21 +51,31 @@ func main() {
var gatewayUrl string
var useLicensesV3 bool
// Deprecated
flag.BoolVar(&useLogsNewSchema, "use-logs-new-schema", false, "use logs_v2 schema for logs")
// Deprecated
flag.BoolVar(&useTraceNewSchema, "use-trace-new-schema", false, "use new schema for traces")
// Deprecated
flag.StringVar(&promConfigPath, "config", "./config/prometheus.yml", "(prometheus config to read metrics)")
// Deprecated
flag.StringVar(&skipTopLvlOpsPath, "skip-top-level-ops", "", "(config file to skip top level operations)")
// Deprecated
flag.BoolVar(&disableRules, "rules.disable", false, "(disable rule evaluation)")
flag.BoolVar(&preferSpanMetrics, "prefer-span-metrics", false, "(prefer span metrics for service level metrics)")
// Deprecated
flag.IntVar(&maxIdleConns, "max-idle-conns", 50, "(number of connections to maintain in the pool.)")
// Deprecated
flag.IntVar(&maxOpenConns, "max-open-conns", 100, "(max connections for use at any time.)")
// Deprecated
flag.DurationVar(&dialTimeout, "dial-timeout", 5*time.Second, "(the maximum time to establish a connection.)")
// Deprecated
flag.StringVar(&ruleRepoURL, "rules.repo-url", baseconst.AlertHelpPage, "(host address used to build rule link in alert messages)")
flag.StringVar(&cacheConfigPath, "experimental.cache-config", "", "(cache config to use)")
flag.StringVar(&fluxInterval, "flux-interval", "5m", "(the interval to exclude data from being cached to avoid incorrect cache for data in motion)")
flag.StringVar(&fluxIntervalForTraceDetail, "flux-interval-trace-detail", "2m", "(the interval to exclude data from being cached to avoid incorrect cache for trace data in motion)")
flag.StringVar(&cluster, "cluster", "cluster", "(cluster name - defaults to 'cluster')")
flag.StringVar(&gatewayUrl, "gateway-url", "", "(url to the gateway)")
// Deprecated
flag.BoolVar(&useLicensesV3, "use-licenses-v3", false, "use licenses_v3 schema for licenses")
flag.Parse()
@@ -121,19 +132,13 @@ func main() {
Config: config,
SigNoz: signoz,
HTTPHostPort: baseconst.HTTPHostPort,
PromConfigPath: promConfigPath,
SkipTopLvlOpsPath: skipTopLvlOpsPath,
PreferSpanMetrics: preferSpanMetrics,
PrivateHostPort: baseconst.PrivateHostPort,
DisableRules: disableRules,
RuleRepoURL: ruleRepoURL,
CacheConfigPath: cacheConfigPath,
FluxInterval: fluxInterval,
FluxIntervalForTraceDetail: fluxIntervalForTraceDetail,
Cluster: cluster,
GatewayUrl: gatewayUrl,
UseLogsNewSchema: useLogsNewSchema,
UseTraceNewSchema: useTraceNewSchema,
Jwt: jwt,
}

View File

@@ -25,8 +25,6 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
ruleId,
opts.Rule,
opts.Reader,
opts.UseLogsNewSchema,
opts.UseTraceNewSchema,
baserules.WithEvalDelay(opts.ManagerOpts.EvalDelay),
baserules.WithSQLStore(opts.SQLStore),
)
@@ -123,8 +121,6 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.Ap
alertname,
parsedRule,
opts.Reader,
opts.UseLogsNewSchema,
opts.UseTraceNewSchema,
baserules.WithSendAlways(),
baserules.WithSendUnmatched(),
baserules.WithSQLStore(opts.SQLStore),

View File

@@ -194,7 +194,7 @@ func (dialect *dialect) RenameColumn(ctx context.Context, bun bun.IDB, table str
}
if !oldColumnExists {
return false, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, fmt.Sprintf("old column: %s doesn't exist", oldColumnName))
return false, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "old column: %s doesn't exist", oldColumnName)
}
_, err = bun.

View File

@@ -4,8 +4,10 @@ import (
"context"
"database/sql"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/jackc/pgx/v5/stdlib"
"github.com/jmoiron/sqlx"
@@ -87,3 +89,20 @@ func (provider *provider) BunDBCtx(ctx context.Context) bun.IDB {
func (provider *provider) RunInTxCtx(ctx context.Context, opts *sql.TxOptions, cb func(ctx context.Context) error) error {
return provider.bundb.RunInTxCtx(ctx, opts, cb)
}
func (provider *provider) WrapNotFoundErrf(err error, code errors.Code, format string, args ...any) error {
if err == sql.ErrNoRows {
return errors.Wrapf(err, errors.TypeNotFound, code, format, args...)
}
return err
}
func (provider *provider) WrapAlreadyExistsErrf(err error, code errors.Code, format string, args ...any) error {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return errors.Wrapf(err, errors.TypeAlreadyExists, code, format, args...)
}
return err
}

View File

@@ -55,7 +55,7 @@
"ansi-to-html": "0.7.2",
"antd": "5.11.0",
"antd-table-saveas-excel": "2.2.1",
"axios": "1.7.7",
"axios": "1.8.2",
"babel-eslint": "^10.1.0",
"babel-jest": "^29.6.4",
"babel-loader": "9.1.3",

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

@@ -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'
),
);
@@ -65,10 +64,6 @@ export const TraceDetail = Loadable(
),
);
export const UsageExplorerPage = Loadable(
() => import(/* webpackChunkName: "UsageExplorerPage" */ 'modules/Usage'),
);
export const SignupPage = Loadable(
() => import(/* webpackChunkName: "SignupPage" */ 'pages/SignUp'),
);

View File

@@ -57,7 +57,6 @@ import {
TracesFunnels,
TracesSaveViews,
UnAuthorized,
UsageExplorerPage,
WorkspaceAccessRestricted,
WorkspaceBlocked,
WorkspaceSuspended,
@@ -155,13 +154,6 @@ const routes: AppRoutes[] = [
isPrivate: true,
key: 'SETTINGS',
},
{
path: ROUTES.USAGE_EXPLORER,
exact: true,
component: UsageExplorerPage,
isPrivate: true,
key: 'USAGE_EXPLORER',
},
{
path: ROUTES.ALL_DASHBOARD,
exact: true,

View File

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

View File

@@ -1,19 +0,0 @@
import apiV1 from 'api/apiV1';
import getLocalStorageKey from 'api/browser/localstorage/get';
import { ENVIRONMENT } from 'constants/env';
import { LOCALSTORAGE } from 'constants/localStorage';
import { EventSourcePolyfill } from 'event-source-polyfill';
// 10 min in ms
const TIMEOUT_IN_MS = 10 * 60 * 1000;
export const LiveTail = (queryParams: string): EventSourcePolyfill =>
new EventSourcePolyfill(
`${ENVIRONMENT.baseURL}${apiV1}logs/tail?${queryParams}`,
{
headers: {
Authorization: `Bearer ${getLocalStorageKey(LOCALSTORAGE.AUTH_TOKEN)}`,
},
heartbeatTimeout: TIMEOUT_IN_MS,
},
);

View File

@@ -5,7 +5,6 @@ import {
CreateFunnelPayload,
CreateFunnelResponse,
FunnelData,
FunnelStepData,
} from 'types/api/traceFunnels';
const FUNNELS_BASE_PATH = '/trace-funnels';
@@ -55,10 +54,10 @@ 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}/${funnelId}`,
`${FUNNELS_BASE_PATH}/get/${funnelId}`,
);
return {
@@ -98,7 +97,7 @@ export const deleteFunnel = async (
payload: DeleteFunnelPayload,
): Promise<SuccessResponse<FunnelData> | ErrorResponse> => {
const response: AxiosResponse = await axios.delete(
`${FUNNELS_BASE_PATH}/${payload.id}`,
`${FUNNELS_BASE_PATH}/delete/${payload.id}`,
);
return {
@@ -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

@@ -9,27 +9,18 @@ import { useCeleryFilterOptions } from 'components/CeleryTask/useCeleryFilterOpt
import { SelectMaxTagPlaceholder } from 'components/MessagingQueues/MQCommon/MQCommon';
import { QueryParams } from 'constants/query';
import useUrlQuery from 'hooks/useUrlQuery';
import { useCallback } from 'react';
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,
@@ -39,46 +30,12 @@ export function FilterSelect({
const history = useHistory();
const location = useLocation();
// Use externally provided `values` if `shouldSetQueryParams` is false, otherwise get from URL params.
const selectValue =
!shouldSetQueryParams && !!values?.length
? values
: getValuesFromQueryParams(queryParam, urlQuery) || [];
const handleSelectChange = useCallback(
(value: string | string[]): void => {
handleSearch('');
if (shouldSetQueryParams) {
setQueryParamsFromOptions(
value as string[],
urlQuery,
history,
location,
queryParam,
);
}
onChange?.(value);
},
[
handleSearch,
shouldSetQueryParams,
urlQuery,
history,
location,
queryParam,
onChange,
],
);
return (
<Select
key={filterType.toString()}
placeholder={placeholder}
showSearch
// eslint-disable-next-line react/jsx-props-no-spreading
{...(isMultiple ? { mode: 'multiple' } : {})}
// eslint-disable-next-line react/jsx-props-no-spreading
{...(isMultiple ? { mode: 'multiple' } : {})}
mode="multiple"
options={options}
loading={isFetching}
className="config-select-option"
@@ -86,7 +43,7 @@ export function FilterSelect({
maxTagCount={4}
allowClear
maxTagPlaceholder={SelectMaxTagPlaceholder}
value={selectValue}
value={getValuesFromQueryParams(queryParam, urlQuery) || []}
notFoundContent={
isFetching ? (
<span>
@@ -96,25 +53,14 @@ export function FilterSelect({
<span>No {placeholder} found</span>
)
}
onChange={handleSelectChange}
onChange={(value): void => {
handleSearch('');
setQueryParamsFromOptions(value, urlQuery, history, location, queryParam);
}}
/>
);
}
FilterSelect.defaultProps = {
shouldSetQueryParams: true,
onChange: (): void => {},
values: [],
isMultiple: true,
};
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

@@ -0,0 +1,13 @@
.custom-multiselect-dropdown {
.divider {
height: 1px;
background-color: #e8e8e8;
margin: 4px 0;
}
.all-option {
font-weight: 500;
border-bottom: 1px solid #f0f0f0;
margin-bottom: 8px;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,606 @@
/* eslint-disable no-nested-ternary */
/* eslint-disable sonarjs/cognitive-complexity */
/* eslint-disable react/jsx-props-no-spreading */
/* eslint-disable react/function-component-definition */
import './styles.scss';
import {
CloseOutlined,
DownOutlined,
LoadingOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import { Color } from '@signozhq/design-tokens';
import { Select } from 'antd';
import cx from 'classnames';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { capitalize, isEmpty } from 'lodash-es';
import { ArrowDown, ArrowUp } from 'lucide-react';
import type { BaseSelectRef } from 'rc-select';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { popupContainer } from 'utils/selectPopupContainer';
import { CustomSelectProps, OptionData } from './types';
import {
filterOptionsBySearch,
prioritizeOrAddOptionForSingleSelect,
SPACEKEY,
} from './utils';
/**
* CustomSelect Component
*
*/
const CustomSelect: React.FC<CustomSelectProps> = ({
placeholder = 'Search...',
className,
loading = false,
onSearch,
options = [],
value,
onChange,
defaultActiveFirstOption = true,
noDataMessage,
onClear,
getPopupContainer,
dropdownRender,
highlightSearch = true,
placement = 'bottomLeft',
popupMatchSelectWidth = true,
popupClassName,
errorMessage,
allowClear = false,
onRetry,
...rest
}) => {
// ===== State & Refs =====
const [isOpen, setIsOpen] = useState(false);
const [searchText, setSearchText] = useState('');
const [activeOptionIndex, setActiveOptionIndex] = useState<number>(-1);
// Refs for element access and scroll behavior
const selectRef = useRef<BaseSelectRef>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const optionRefs = useRef<Record<number, HTMLDivElement | null>>({});
// ===== Option Filtering & Processing Utilities =====
/**
* Checks if a label exists in the provided options
*/
const isLabelPresent = useCallback(
(options: OptionData[], label: string): boolean =>
options.some((option) => {
const lowerLabel = label.toLowerCase();
// Check in nested options if they exist
if ('options' in option && Array.isArray(option.options)) {
return option.options.some(
(subOption) => subOption.label.toLowerCase() === lowerLabel,
);
}
// Check top-level option
return option.label.toLowerCase() === lowerLabel;
}),
[],
);
/**
* Separates section and non-section options
*/
const splitOptions = useCallback((options: OptionData[]): {
sectionOptions: OptionData[];
nonSectionOptions: OptionData[];
} => {
const sectionOptions: OptionData[] = [];
const nonSectionOptions: OptionData[] = [];
options.forEach((option) => {
if ('options' in option && Array.isArray(option.options)) {
sectionOptions.push(option);
} else {
nonSectionOptions.push(option);
}
});
return { sectionOptions, nonSectionOptions };
}, []);
/**
* Apply search filtering to options
*/
const filteredOptions = useMemo(
(): OptionData[] => filterOptionsBySearch(options, searchText),
[options, searchText],
);
// ===== UI & Rendering Functions =====
/**
* Highlights matched text in search results
*/
const highlightMatchedText = useCallback(
(text: string, searchQuery: string): React.ReactNode => {
if (!searchQuery || !highlightSearch) return text;
const parts = text.split(new RegExp(`(${searchQuery})`, 'gi'));
return (
<>
{parts.map((part, i) => {
// Create a deterministic but unique key
const uniqueKey = `${text.substring(0, 3)}-${part.substring(0, 3)}-${i}`;
return part.toLowerCase() === searchQuery.toLowerCase() ? (
<span key={uniqueKey} className="highlight-text">
{part}
</span>
) : (
part
);
})}
</>
);
},
[highlightSearch],
);
/**
* Renders an individual option with proper keyboard navigation support
*/
const renderOptionItem = useCallback(
(
option: OptionData,
isSelected: boolean,
index?: number,
): React.ReactElement => {
const handleSelection = (): void => {
if (onChange) {
onChange(option.value, option);
setIsOpen(false);
}
};
const isActive = index === activeOptionIndex;
const optionId = `option-${index}`;
return (
<div
key={option.value}
id={optionId}
ref={(el): void => {
if (index !== undefined) {
optionRefs.current[index] = el;
}
}}
className={cx('option-item', {
selected: isSelected,
active: isActive,
})}
onClick={(e): void => {
e.stopPropagation();
handleSelection();
}}
onKeyDown={(e): void => {
if (e.key === 'Enter' || e.key === SPACEKEY) {
e.preventDefault();
handleSelection();
}
}}
onMouseEnter={(): void => setActiveOptionIndex(index || -1)}
role="option"
aria-selected={isSelected}
aria-disabled={option.disabled}
tabIndex={isActive ? 0 : -1}
>
<div className="option-content">
<div>{highlightMatchedText(String(option.label || ''), searchText)}</div>
{option.type === 'custom' && (
<div className="option-badge">{capitalize(option.type)}</div>
)}
</div>
</div>
);
},
[highlightMatchedText, searchText, onChange, activeOptionIndex],
);
/**
* Helper function to render option with index tracking
*/
const renderOptionWithIndex = useCallback(
(option: OptionData, isSelected: boolean, idx: number) =>
renderOptionItem(option, isSelected, idx),
[renderOptionItem],
);
/**
* Custom clear button renderer
*/
const clearIcon = useCallback(
() => (
<CloseOutlined
onClick={(e): void => {
e.stopPropagation();
if (onChange) onChange(undefined, []);
if (onClear) onClear();
}}
/>
),
[onChange, onClear],
);
// ===== Event Handlers =====
/**
* Handles search input changes
*/
const handleSearch = useCallback(
(value: string): void => {
const trimmedValue = value.trim();
setSearchText(trimmedValue);
if (onSearch) onSearch(trimmedValue);
},
[onSearch],
);
/**
* Prevents event propagation for dropdown clicks
*/
const handleDropdownClick = useCallback((e: React.MouseEvent): void => {
e.stopPropagation();
}, []);
/**
* Comprehensive keyboard navigation handler
*/
const handleKeyDown = useCallback(
(e: React.KeyboardEvent): void => {
// Handle keyboard navigation when dropdown is open
if (isOpen) {
// Get flattened list of all selectable options
const getFlatOptions = (): OptionData[] => {
if (!filteredOptions) return [];
const flatList: OptionData[] = [];
// Process options
const { sectionOptions, nonSectionOptions } = splitOptions(
isEmpty(value)
? filteredOptions
: prioritizeOrAddOptionForSingleSelect(filteredOptions, value),
);
// Add custom option if needed
if (!isEmpty(searchText) && !isLabelPresent(filteredOptions, searchText)) {
flatList.push({
label: searchText,
value: searchText,
type: 'custom',
});
}
// Add all options to flat list
flatList.push(...nonSectionOptions);
sectionOptions.forEach((section) => {
if (section.options) {
flatList.push(...section.options);
}
});
return flatList;
};
const options = getFlatOptions();
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setActiveOptionIndex((prev) =>
prev < options.length - 1 ? prev + 1 : 0,
);
break;
case 'ArrowUp':
e.preventDefault();
setActiveOptionIndex((prev) =>
prev > 0 ? prev - 1 : options.length - 1,
);
break;
case 'Tab':
// Tab navigation with Shift key support
if (e.shiftKey) {
e.preventDefault();
setActiveOptionIndex((prev) =>
prev > 0 ? prev - 1 : options.length - 1,
);
} else {
e.preventDefault();
setActiveOptionIndex((prev) =>
prev < options.length - 1 ? prev + 1 : 0,
);
}
break;
case 'Enter':
e.preventDefault();
if (activeOptionIndex >= 0 && activeOptionIndex < options.length) {
// Select the focused option
const selectedOption = options[activeOptionIndex];
if (onChange) {
onChange(selectedOption.value, selectedOption);
setIsOpen(false);
setActiveOptionIndex(-1);
}
} else if (!isEmpty(searchText)) {
// Add custom value when no option is focused
const customOption = {
label: searchText,
value: searchText,
type: 'custom',
};
if (onChange) {
onChange(customOption.value, customOption);
setIsOpen(false);
setActiveOptionIndex(-1);
}
}
break;
case 'Escape':
e.preventDefault();
setIsOpen(false);
setActiveOptionIndex(-1);
break;
case ' ': // Space key
if (activeOptionIndex >= 0 && activeOptionIndex < options.length) {
e.preventDefault();
const selectedOption = options[activeOptionIndex];
if (onChange) {
onChange(selectedOption.value, selectedOption);
setIsOpen(false);
setActiveOptionIndex(-1);
}
}
break;
default:
break;
}
} else if (e.key === 'ArrowDown' || e.key === 'Tab') {
// Open dropdown when Down or Tab is pressed while closed
e.preventDefault();
setIsOpen(true);
setActiveOptionIndex(0);
}
},
[
isOpen,
activeOptionIndex,
filteredOptions,
searchText,
onChange,
splitOptions,
value,
isLabelPresent,
],
);
// ===== Dropdown Rendering =====
/**
* Renders the custom dropdown with sections and keyboard navigation
*/
const customDropdownRender = useCallback((): React.ReactElement => {
// Process options based on current value
let processedOptions = isEmpty(value)
? filteredOptions
: prioritizeOrAddOptionForSingleSelect(filteredOptions, value);
if (!isEmpty(searchText)) {
processedOptions = filterOptionsBySearch(processedOptions, searchText);
}
const { sectionOptions, nonSectionOptions } = splitOptions(processedOptions);
// Check if we need to add a custom option based on search text
const isSearchTextNotPresent =
!isEmpty(searchText) && !isLabelPresent(processedOptions, searchText);
let optionIndex = 0;
// Add custom option if needed
if (isSearchTextNotPresent) {
nonSectionOptions.unshift({
label: searchText,
value: searchText,
type: 'custom',
});
}
// Helper function to map options with index tracking
const mapOptions = (options: OptionData[]): React.ReactNode =>
options.map((option) => {
const result = renderOptionWithIndex(
option,
option.value === value,
optionIndex,
);
optionIndex += 1;
return result;
});
const customMenu = (
<div
ref={dropdownRef}
className="custom-select-dropdown"
onClick={handleDropdownClick}
onKeyDown={handleKeyDown}
role="listbox"
tabIndex={-1}
aria-activedescendant={
activeOptionIndex >= 0 ? `option-${activeOptionIndex}` : undefined
}
>
{/* Non-section options */}
<div className="no-section-options">
{nonSectionOptions.length > 0 && mapOptions(nonSectionOptions)}
</div>
{/* Section options */}
{sectionOptions.length > 0 &&
sectionOptions.map((section) =>
!isEmpty(section.options) ? (
<div className="select-group" key={section.label}>
<div className="group-label" role="heading" aria-level={2}>
{section.label}
</div>
<div role="group" aria-label={`${section.label} options`}>
{section.options && mapOptions(section.options)}
</div>
</div>
) : null,
)}
{/* Navigation help footer */}
<div className="navigation-footer" role="note">
{!loading && !errorMessage && !noDataMessage && (
<section className="navigate">
<ArrowDown size={8} className="icons" />
<ArrowUp size={8} className="icons" />
<span className="keyboard-text">to navigate</span>
</section>
)}
{loading && (
<div className="navigation-loading">
<div className="navigation-icons">
<LoadingOutlined />
</div>
<div className="navigation-text">We are updating the values...</div>
</div>
)}
{errorMessage && !loading && (
<div className="navigation-error">
<div className="navigation-text">
{errorMessage || SOMETHING_WENT_WRONG}
</div>
<div className="navigation-icons">
<ReloadOutlined
twoToneColor={Color.BG_CHERRY_400}
onClick={(e): void => {
e.stopPropagation();
if (onRetry) onRetry();
}}
/>
</div>
</div>
)}
{noDataMessage && !loading && (
<div className="navigation-text">{noDataMessage}</div>
)}
</div>
</div>
);
return dropdownRender ? dropdownRender(customMenu) : customMenu;
}, [
value,
filteredOptions,
searchText,
splitOptions,
isLabelPresent,
handleDropdownClick,
handleKeyDown,
activeOptionIndex,
loading,
errorMessage,
noDataMessage,
dropdownRender,
renderOptionWithIndex,
onRetry,
]);
// ===== Side Effects =====
// Clear search text when dropdown closes
useEffect(() => {
if (!isOpen) {
setSearchText('');
setActiveOptionIndex(-1);
}
}, [isOpen]);
// Auto-scroll to active option for keyboard navigation
useEffect(() => {
if (
isOpen &&
activeOptionIndex >= 0 &&
optionRefs.current[activeOptionIndex]
) {
optionRefs.current[activeOptionIndex]?.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
});
}
}, [isOpen, activeOptionIndex]);
// ===== Final Processing =====
// Apply highlight to matched text in options
const optionsWithHighlight = useMemo(
() =>
options
?.filter((option) =>
String(option.label || '')
.toLowerCase()
.includes(searchText.toLowerCase()),
)
?.map((option) => ({
...option,
label: highlightMatchedText(String(option.label || ''), searchText),
})),
[options, searchText, highlightMatchedText],
);
// ===== Component Rendering =====
return (
<Select
ref={selectRef}
className={cx('custom-select', className)}
placeholder={placeholder}
showSearch
filterOption={false}
onSearch={handleSearch}
value={value}
onChange={onChange}
onDropdownVisibleChange={setIsOpen}
open={isOpen}
options={optionsWithHighlight}
defaultActiveFirstOption={defaultActiveFirstOption}
popupMatchSelectWidth={popupMatchSelectWidth}
allowClear={allowClear ? { clearIcon } : false}
getPopupContainer={getPopupContainer ?? popupContainer}
suffixIcon={<DownOutlined style={{ cursor: 'default' }} />}
dropdownRender={customDropdownRender}
menuItemSelectedIcon={null}
popupClassName={cx('custom-select-dropdown-container', popupClassName)}
listHeight={300}
placement={placement}
optionFilterProp="label"
notFoundContent={<div className="empty-message">{noDataMessage}</div>}
onKeyDown={handleKeyDown}
{...rest}
/>
);
};
export default CustomSelect;

View File

@@ -0,0 +1,263 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import CustomMultiSelect from '../CustomMultiSelect';
// Mock scrollIntoView which isn't available in JSDOM
window.HTMLElement.prototype.scrollIntoView = jest.fn();
// Mock options data
const mockOptions = [
{ label: 'Option 1', value: 'option1' },
{ label: 'Option 2', value: 'option2' },
{ label: 'Option 3', value: 'option3' },
];
const mockGroupedOptions = [
{
label: 'Group 1',
options: [
{ label: 'Group 1 - Option 1', value: 'g1-option1' },
{ label: 'Group 1 - Option 2', value: 'g1-option2' },
],
},
{
label: 'Group 2',
options: [
{ label: 'Group 2 - Option 1', value: 'g2-option1' },
{ label: 'Group 2 - Option 2', value: 'g2-option2' },
],
},
];
describe('CustomMultiSelect Component', () => {
it('renders with placeholder', () => {
const handleChange = jest.fn();
render(
<CustomMultiSelect
placeholder="Select multiple options"
options={mockOptions}
onChange={handleChange}
/>,
);
// Check placeholder exists
const placeholderElement = screen.getByText('Select multiple options');
expect(placeholderElement).toBeInTheDocument();
});
it('opens dropdown when clicked', async () => {
const handleChange = jest.fn();
render(<CustomMultiSelect options={mockOptions} onChange={handleChange} />);
// Click to open the dropdown
const selectElement = screen.getByRole('combobox');
fireEvent.mouseDown(selectElement);
// Wait for dropdown to appear
await waitFor(() => {
expect(screen.getByText('ALL')).toBeInTheDocument(); // The ALL option
expect(screen.getByText('Option 1')).toBeInTheDocument();
expect(screen.getByText('Option 2')).toBeInTheDocument();
expect(screen.getByText('Option 3')).toBeInTheDocument();
});
});
it('selects multiple options', async () => {
const handleChange = jest.fn();
// Start with option1 already selected
render(
<CustomMultiSelect
options={mockOptions}
onChange={handleChange}
value={['option1']}
/>,
);
// Open dropdown
const selectElement = screen.getByRole('combobox');
fireEvent.mouseDown(selectElement);
// Wait for dropdown to appear
await waitFor(() => {
expect(screen.getByText('Option 3')).toBeInTheDocument();
});
// Click on Option 3
const option3 = screen.getByText('Option 3');
fireEvent.click(option3);
// Verify onChange was called with the right values
expect(handleChange).toHaveBeenCalled();
});
it('selects ALL options when ALL is clicked', async () => {
const handleChange = jest.fn();
render(
<CustomMultiSelect
options={mockOptions}
onChange={handleChange}
enableAllSelection
/>,
);
// Open dropdown
const selectElement = screen.getByRole('combobox');
fireEvent.mouseDown(selectElement);
// Wait for dropdown to appear
await waitFor(() => {
expect(screen.getByText('ALL')).toBeInTheDocument();
});
// Click on ALL option
const allOption = screen.getByText('ALL');
fireEvent.click(allOption);
// Verify onChange was called with all option values
expect(handleChange).toHaveBeenCalledWith(
['option1', 'option2', 'option3'],
expect.arrayContaining([
expect.objectContaining({ value: 'option1' }),
expect.objectContaining({ value: 'option2' }),
expect.objectContaining({ value: 'option3' }),
]),
);
});
it('displays selected options as tags', async () => {
render(
<CustomMultiSelect options={mockOptions} value={['option1', 'option2']} />,
);
// Check that option values are shown as tags (not labels)
expect(screen.getByText('option1')).toBeInTheDocument();
expect(screen.getByText('option2')).toBeInTheDocument();
});
it('removes a tag when clicked', async () => {
const handleChange = jest.fn();
render(
<CustomMultiSelect
options={mockOptions}
value={['option1', 'option2']}
onChange={handleChange}
/>,
);
// Find close button on Option 1 tag and click it
const closeButtons = document.querySelectorAll(
'.ant-select-selection-item-remove',
);
fireEvent.click(closeButtons[0]);
// Verify onChange was called with remaining option
expect(handleChange).toHaveBeenCalledWith(
['option2'],
expect.arrayContaining([expect.objectContaining({ value: 'option2' })]),
);
});
it('filters options when searching', async () => {
render(<CustomMultiSelect options={mockOptions} />);
// Open dropdown
const selectElement = screen.getByRole('combobox');
fireEvent.mouseDown(selectElement);
// Type into search box - get input directly
const inputElement = selectElement.querySelector('input');
if (inputElement) {
fireEvent.change(inputElement, { target: { value: '2' } });
}
// Wait for the dropdown filtering to happen
await waitFor(() => {
// Check that the dropdown is present
const dropdownElement = document.querySelector(
'.custom-multiselect-dropdown',
);
expect(dropdownElement).toBeInTheDocument();
// Verify Option 2 is visible in the dropdown
const options = document.querySelectorAll('.option-label-text');
let foundOption2 = false;
options.forEach((option) => {
const text = option.textContent || '';
if (text.includes('Option 2')) foundOption2 = true;
});
expect(foundOption2).toBe(true);
});
});
it('renders grouped options correctly', async () => {
render(<CustomMultiSelect options={mockGroupedOptions} />);
// Open dropdown
const selectElement = screen.getByRole('combobox');
fireEvent.mouseDown(selectElement);
// Check group headers and options
await waitFor(() => {
expect(screen.getByText('Group 1')).toBeInTheDocument();
expect(screen.getByText('Group 2')).toBeInTheDocument();
expect(screen.getByText('Group 1 - Option 1')).toBeInTheDocument();
expect(screen.getByText('Group 1 - Option 2')).toBeInTheDocument();
expect(screen.getByText('Group 2 - Option 1')).toBeInTheDocument();
expect(screen.getByText('Group 2 - Option 2')).toBeInTheDocument();
});
});
it('shows loading state', () => {
render(<CustomMultiSelect options={mockOptions} loading />);
// Open dropdown
const selectElement = screen.getByRole('combobox');
fireEvent.mouseDown(selectElement);
// Check loading text is displayed
expect(screen.getByText('We are updating the values...')).toBeInTheDocument();
});
it('shows error message', () => {
render(
<CustomMultiSelect
options={mockOptions}
errorMessage="Test error message"
/>,
);
// Open dropdown
const selectElement = screen.getByRole('combobox');
fireEvent.mouseDown(selectElement);
// Check error message is displayed
expect(screen.getByText('Test error message')).toBeInTheDocument();
});
it('shows no data message', () => {
render(<CustomMultiSelect options={[]} noDataMessage="No data available" />);
// Open dropdown
const selectElement = screen.getByRole('combobox');
fireEvent.mouseDown(selectElement);
// Check no data message is displayed
expect(screen.getByText('No data available')).toBeInTheDocument();
});
it('shows "ALL" tag when all options are selected', () => {
render(
<CustomMultiSelect
options={mockOptions}
value={['option1', 'option2', 'option3']}
maxTagCount={2}
/>,
);
// When all options are selected, component shows ALL tag instead
expect(screen.getByText('ALL')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,206 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import CustomSelect from '../CustomSelect';
// Mock scrollIntoView which isn't available in JSDOM
window.HTMLElement.prototype.scrollIntoView = jest.fn();
// Mock options data
const mockOptions = [
{ label: 'Option 1', value: 'option1' },
{ label: 'Option 2', value: 'option2' },
{ label: 'Option 3', value: 'option3' },
];
const mockGroupedOptions = [
{
label: 'Group 1',
options: [
{ label: 'Group 1 - Option 1', value: 'g1-option1' },
{ label: 'Group 1 - Option 2', value: 'g1-option2' },
],
},
{
label: 'Group 2',
options: [
{ label: 'Group 2 - Option 1', value: 'g2-option1' },
{ label: 'Group 2 - Option 2', value: 'g2-option2' },
],
},
];
describe('CustomSelect Component', () => {
it('renders with placeholder and options', () => {
const handleChange = jest.fn();
render(
<CustomSelect
placeholder="Test placeholder"
options={mockOptions}
onChange={handleChange}
/>,
);
// Check placeholder exists in the DOM (not using getByPlaceholderText)
const placeholderElement = screen.getByText('Test placeholder');
expect(placeholderElement).toBeInTheDocument();
});
it('opens dropdown when clicked', async () => {
const handleChange = jest.fn();
render(<CustomSelect options={mockOptions} onChange={handleChange} />);
// Click to open the dropdown
const selectElement = screen.getByRole('combobox');
fireEvent.mouseDown(selectElement);
// Wait for dropdown to appear
await waitFor(() => {
expect(screen.getByText('Option 1')).toBeInTheDocument();
expect(screen.getByText('Option 2')).toBeInTheDocument();
expect(screen.getByText('Option 3')).toBeInTheDocument();
});
});
it('calls onChange when option is selected', async () => {
const handleChange = jest.fn();
render(<CustomSelect options={mockOptions} onChange={handleChange} />);
// Open dropdown
const selectElement = screen.getByRole('combobox');
fireEvent.mouseDown(selectElement);
// Click on an option
await waitFor(() => {
const option = screen.getByText('Option 2');
fireEvent.click(option);
});
// Check onChange was called with correct value
expect(handleChange).toHaveBeenCalledWith('option2', expect.anything());
});
it('filters options when searching', async () => {
render(<CustomSelect options={mockOptions} />);
// Open dropdown
const selectElement = screen.getByRole('combobox');
fireEvent.mouseDown(selectElement);
// Type into search box
fireEvent.change(selectElement, { target: { value: '2' } });
// Dropdown should only show Option 2
await waitFor(() => {
// Check that the dropdown is present
const dropdownElement = document.querySelector('.custom-select-dropdown');
expect(dropdownElement).toBeInTheDocument();
// Use a simple approach to verify filtering
const allOptionsInDropdown = document.querySelectorAll('.option-item');
let foundOption2 = false;
allOptionsInDropdown.forEach((option) => {
if (option.textContent?.includes('Option 2')) {
foundOption2 = true;
}
// Should not show Options 1 or 3
expect(option.textContent).not.toContain('Option 1');
expect(option.textContent).not.toContain('Option 3');
});
expect(foundOption2).toBe(true);
});
});
it('renders grouped options correctly', async () => {
const handleChange = jest.fn();
render(<CustomSelect options={mockGroupedOptions} onChange={handleChange} />);
// Open dropdown
const selectElement = screen.getByRole('combobox');
fireEvent.mouseDown(selectElement);
// Check group headers and options
await waitFor(() => {
expect(screen.getByText('Group 1')).toBeInTheDocument();
expect(screen.getByText('Group 2')).toBeInTheDocument();
expect(screen.getByText('Group 1 - Option 1')).toBeInTheDocument();
expect(screen.getByText('Group 1 - Option 2')).toBeInTheDocument();
expect(screen.getByText('Group 2 - Option 1')).toBeInTheDocument();
expect(screen.getByText('Group 2 - Option 2')).toBeInTheDocument();
});
});
it('shows loading state', () => {
render(<CustomSelect options={mockOptions} loading />);
// Open dropdown
const selectElement = screen.getByRole('combobox');
fireEvent.mouseDown(selectElement);
// Check loading text is displayed
expect(screen.getByText('We are updating the values...')).toBeInTheDocument();
});
it('shows error message', () => {
render(
<CustomSelect options={mockOptions} errorMessage="Test error message" />,
);
// Open dropdown
const selectElement = screen.getByRole('combobox');
fireEvent.mouseDown(selectElement);
// Check error message is displayed
expect(screen.getByText('Test error message')).toBeInTheDocument();
});
it('shows no data message', () => {
render(<CustomSelect options={[]} noDataMessage="No data available" />);
// Open dropdown
const selectElement = screen.getByRole('combobox');
fireEvent.mouseDown(selectElement);
// Check no data message is displayed
expect(screen.getByText('No data available')).toBeInTheDocument();
});
it('supports keyboard navigation', async () => {
const handleChange = jest.fn();
render(<CustomSelect options={mockOptions} onChange={handleChange} />);
// Open dropdown using keyboard
const selectElement = screen.getByRole('combobox');
fireEvent.focus(selectElement);
// Press down arrow to open dropdown
fireEvent.keyDown(selectElement, { key: 'ArrowDown' });
// Wait for dropdown to appear
await waitFor(() => {
expect(screen.getByText('Option 1')).toBeInTheDocument();
});
});
it('handles selection via keyboard', async () => {
const handleChange = jest.fn();
render(<CustomSelect options={mockOptions} onChange={handleChange} />);
// Open dropdown
const selectElement = screen.getByRole('combobox');
fireEvent.mouseDown(selectElement);
// Wait for dropdown to appear then press Enter
await waitFor(() => {
expect(screen.getByText('Option 1')).toBeInTheDocument();
// Press Enter to select first option
fireEvent.keyDown(screen.getByText('Option 1'), { key: 'Enter' });
});
// Check onChange was called
expect(handleChange).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,4 @@
import CustomMultiSelect from './CustomMultiSelect';
import CustomSelect from './CustomSelect';
export { CustomMultiSelect, CustomSelect };

View File

@@ -0,0 +1,838 @@
// Main container styles
// make const of #2c3044
$custom-border-color: #2c3044;
.custom-select {
width: 100%;
position: relative;
&.ant-select-focused {
.ant-select-selector {
border-color: var(--bg-robin-500);
box-shadow: 0 0 0 2px rgba(78, 116, 248, 0.2);
}
}
.ant-select-selection-placeholder {
color: rgba(192, 193, 195, 0.45);
}
// Base styles are for dark mode
.ant-select-selector {
background-color: var(--bg-ink-400);
border-color: var(--bg-slate-400);
}
.ant-select-clear {
background-color: var(--bg-ink-400);
color: rgba(192, 193, 195, 0.7);
}
}
// Keep chip styles ONLY in the multi-select
.custom-multiselect {
width: 100%;
position: relative;
.ant-select-selector {
max-height: 200px;
overflow: auto;
scrollbar-width: thin;
background-color: var(--bg-ink-400);
border-color: var(--bg-slate-400);
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background-color: $custom-border-color;
border-radius: 3px;
}
&::-webkit-scrollbar-track {
background-color: var(--bg-slate-400);
}
}
&.ant-select-focused {
.ant-select-selector {
border-color: var(--bg-robin-500);
box-shadow: 0 0 0 2px rgba(78, 116, 248, 0.2);
}
}
.ant-select-selection-placeholder {
color: rgba(192, 193, 195, 0.45);
}
// Customize tags in multiselect (dark mode by default)
.ant-select-selection-item {
background-color: var(--bg-slate-400);
border-radius: 4px;
border: 1px solid $custom-border-color;
margin-right: 4px;
transition: all 0.2s;
color: var(--bg-vanilla-400);
// Style for active tag (keyboard navigation)
&-active {
border-color: var(--bg-robin-500) !important;
background-color: rgba(78, 116, 248, 0.15) !important;
outline: 2px solid rgba(78, 116, 248, 0.2);
}
// Style for selected tags (via keyboard or mouse selection)
&-selected {
border-color: var(--bg-robin-500) !important;
background-color: rgba(78, 116, 248, 0.15) !important;
box-shadow: 0 0 0 2px rgba(78, 116, 248, 0.2);
}
.ant-select-selection-item-content {
color: var(--bg-vanilla-400);
}
.ant-select-selection-item-remove {
color: rgba(192, 193, 195, 0.7);
&:hover {
color: rgba(192, 193, 195, 1);
}
}
}
// Class applied when in selection mode
&.has-selection {
.ant-select-selection-item-selected {
cursor: move; // Indicate draggable
}
// Change cursor for selection
.ant-select-selector {
cursor: text;
}
}
}
// Dropdown styles
.custom-select-dropdown-container,
.custom-multiselect-dropdown-container {
z-index: 1050 !important;
padding: 0;
box-shadow: 0 3px 6px -4px rgba(0, 0, 0, 0.5), 0 6px 16px 0 rgba(0, 0, 0, 0.4),
0 9px 28px 8px rgba(0, 0, 0, 0.3);
background-color: var(--bg-ink-400);
border: 1px solid var(--bg-slate-400);
.ant-select-item {
padding: 8px 12px;
color: var(--bg-vanilla-400);
// Make keyboard navigation visible
&-option-active {
background-color: var(--bg-slate-400) !important;
}
&-option-selected {
background-color: rgba(78, 116, 248, 0.15) !important;
}
}
}
.custom-select-dropdown-container,
.custom-multiselect-dropdown-container {
width: 100%;
overflow-x: auto;
overflow-y: hidden;
resize: horizontal;
min-width: 300px !important;
.empty-message {
padding: 12px;
text-align: center;
color: rgba(192, 193, 195, 0.45);
}
}
// Custom dropdown styles for single select
.custom-select-dropdown {
padding: 8px 0 0 0;
max-height: 500px;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
border-radius: 4px;
border: 1px solid var(--bg-slate-400);
width: 100%;
background-color: var(--bg-ink-400);
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background-color: $custom-border-color;
border-radius: 3px;
}
&::-webkit-scrollbar-track {
background-color: var(--bg-slate-400);
}
.no-section-options {
margin-bottom: 8px;
}
.select-group {
margin-bottom: 16px;
border-radius: 4px;
overflow: hidden;
.group-label {
font-weight: 500;
padding: 4px 12px;
font-size: 13px;
color: var(--bg-vanilla-400);
background-color: var(--bg-slate-400);
border-bottom: 1px solid $custom-border-color;
border-top: 1px solid $custom-border-color;
position: relative;
z-index: 1;
margin-bottom: 4px;
}
}
.option-item {
padding: 8px 12px;
cursor: pointer;
display: flex;
align-items: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--bg-vanilla-400);
&:hover {
background-color: var(--bg-slate-400);
}
&.selected {
background-color: rgba(78, 116, 248, 0.15);
font-weight: 500;
}
&.active {
background-color: rgba(78, 116, 248, 0.15);
border-color: var(--bg-robin-500);
}
.option-content {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
.option-label-text {
margin-bottom: 0;
}
.option-badge {
font-size: 12px;
padding: 2px 6px;
border-radius: 4px;
background-color: $custom-border-color;
color: var(--bg-vanilla-400);
margin-left: 8px;
}
}
}
.loading-container {
display: flex;
justify-content: center;
padding: 12px;
}
}
.navigation-footer {
display: flex;
align-items: center;
padding: 8px 12px;
border-top: 1px solid var(--bg-slate-400);
position: sticky;
bottom: 0;
background-color: var(--bg-ink-400);
z-index: 1;
.navigation-icons {
display: flex;
margin-right: 8px;
color: var(--bg-vanilla-400);
}
.navigation-text {
color: var(--bg-vanilla-400);
font-size: 12px;
}
.navigation-error {
.navigation-text,
.navigation-icons {
color: var(--bg-cherry-500) !important;
}
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 4px;
}
.navigation-loading {
display: flex;
align-items: center;
gap: 8px;
.navigation-text,
.navigation-icons {
color: var(--bg-robin-600) !important;
}
}
.navigate {
display: flex;
align-items: center;
padding-right: 12px;
gap: 6px;
.icons {
width: 14px;
height: 14px;
flex-shrink: 0;
border-radius: 2.286px;
border-top: 1.143px solid var(--bg-ink-200);
border-right: 1.143px solid var(--bg-ink-200);
border-bottom: 2.286px solid var(--bg-ink-200);
border-left: 1.143px solid var(--bg-ink-200);
background: var(--Ink-400, var(--bg-ink-400));
}
}
}
// Custom dropdown styles for multi-select
.custom-multiselect-dropdown {
padding: 8px 0 0 0;
max-height: 500px;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
border-radius: 4px;
border: 1px solid var(--bg-slate-400);
width: 100%;
background-color: var(--bg-ink-400);
.select-all-option,
.custom-value-option {
padding: 8px 12px;
border-bottom: 1px solid $custom-border-color;
margin-bottom: 8px;
background-color: var(--bg-slate-400);
position: sticky;
top: 0;
z-index: 1;
}
.selected-values-section {
padding: 0 0 8px 0;
border-bottom: 1px solid $custom-border-color;
margin-bottom: 8px;
.selected-option {
padding: 4px 12px;
}
}
.select-group {
margin-bottom: 12px;
overflow: hidden;
.group-label {
font-weight: 500;
padding: 4px 12px;
font-size: 13px;
color: var(--bg-vanilla-400);
background-color: var(--bg-slate-400);
border-bottom: 1px solid $custom-border-color;
border-top: 1px solid $custom-border-color;
position: relative;
z-index: 1;
}
}
.option-item {
padding: 8px 12px;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--bg-vanilla-400);
&.active {
background-color: rgba(78, 116, 248, 0.15);
border-color: var(--bg-robin-500);
}
&:hover {
background-color: var(--bg-slate-400);
}
&.selected {
background-color: rgba(78, 116, 248, 0.15);
font-weight: 500;
}
&.all-option {
font-weight: 500;
border-bottom: 1px solid $custom-border-color;
margin-bottom: 8px;
}
.option-checkbox {
width: 100%;
> span:not(.ant-checkbox) {
width: 100%;
}
.option-content {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
.option-label-text {
margin-bottom: 0;
}
.option-badge {
font-size: 12px;
padding: 2px 6px;
border-radius: 4px;
background-color: $custom-border-color;
color: var(--bg-vanilla-400);
margin-left: 8px;
}
}
.only-btn {
display: none;
}
.toggle-btn {
display: none;
}
.only-btn:hover {
background-color: unset;
}
.toggle-btn:hover {
background-color: unset;
}
.option-content:hover {
.only-btn {
display: flex;
align-items: center;
justify-content: center;
height: 21px;
}
.toggle-btn {
display: none;
}
.option-badge {
display: none;
}
}
}
.option-checkbox:hover {
.toggle-btn {
display: flex;
align-items: center;
justify-content: center;
height: 21px;
}
.option-badge {
display: none;
}
}
}
.loading-container {
display: flex;
justify-content: center;
padding: 12px;
}
.empty-message {
padding: 12px;
text-align: center;
color: rgba(192, 193, 195, 0.45);
}
.status-message {
padding: 8px 12px;
text-align: center;
font-style: italic;
color: rgba(192, 193, 195, 0.65);
border-top: 1px dashed $custom-border-color;
}
}
// Custom styles for highlight text
.highlight-text {
background-color: rgba(78, 116, 248, 0.2);
padding: 0 1px;
border-radius: 2px;
font-weight: 500;
}
// Custom option styles for keyboard navigation
.custom-option {
&.focused,
&.ant-select-item-option-active {
background-color: var(--bg-slate-400) !important;
}
}
// Improve the sticky headers appearance
.custom-select-dropdown-container {
.group-label,
.ant-select-item-group {
position: sticky;
top: 0;
z-index: 2;
background-color: var(--bg-slate-400);
border-bottom: 1px solid $custom-border-color;
padding: 4px 12px;
margin: 0;
width: 100%; // Ensure the header spans full width
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); // Add subtle shadow for separation
}
// Ensure proper spacing between sections
.select-group {
margin-bottom: 8px;
position: relative; // Create a positioning context
}
}
// Custom scrollbar styling (shared between components)
@mixin custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(192, 193, 195, 0.3) rgba(29, 33, 45, 0.6);
&::-webkit-scrollbar {
width: 6px;
height: 6px;
}
&::-webkit-scrollbar-track {
background-color: rgba(29, 33, 45, 0.6);
border-radius: 10px;
}
&::-webkit-scrollbar-thumb {
background-color: rgba(192, 193, 195, 0.3);
border-radius: 10px;
transition: background-color 0.2s ease;
&:hover {
background-color: rgba(192, 193, 195, 0.5);
}
}
}
// Subtle nested scrollbar styling
@mixin nested-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(192, 193, 195, 0.2) rgba(29, 33, 45, 0.6);
&::-webkit-scrollbar {
width: 4px;
height: 4px;
}
&::-webkit-scrollbar-track {
background-color: rgba(29, 33, 45, 0.6);
border-radius: 10px;
}
&::-webkit-scrollbar-thumb {
background-color: rgba(192, 193, 195, 0.2);
border-radius: 10px;
&:hover {
background-color: rgba(192, 193, 195, 0.3);
}
}
}
// Apply to main dropdown containers
.custom-select-dropdown,
.custom-multiselect-dropdown {
@include custom-scrollbar;
// Main content area
.options-container {
@include custom-scrollbar;
padding-right: 2px; // Add slight padding to prevent content touching scrollbar
}
// Non-sectioned options
.no-section-options {
@include nested-scrollbar;
margin-right: 2px;
padding-right: 2px;
}
}
// Apply to dropdown container wrappers
.custom-select-dropdown-container,
.custom-multiselect-dropdown-container {
@include custom-scrollbar;
// Add subtle shadow inside to indicate scrollable area
&.has-overflow {
box-shadow: inset 0 -10px 10px -10px rgba(0, 0, 0, 0.2);
}
}
// Light Mode Overrides
.lightMode {
.custom-select {
.ant-select-selector {
background-color: var(--bg-vanilla-100);
border-color: #e9e9e9;
}
.ant-select-selection-placeholder {
color: rgba(0, 0, 0, 0.45);
}
.ant-select-clear {
background-color: var(--bg-vanilla-100);
color: rgba(0, 0, 0, 0.45);
}
&.ant-select-focused {
.ant-select-selector {
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
}
}
.custom-multiselect {
.ant-select-selector {
background-color: var(--bg-vanilla-100);
border-color: #e9e9e9;
&::-webkit-scrollbar-thumb {
background-color: #ccc;
}
&::-webkit-scrollbar-track {
background-color: #f0f0f0;
}
}
.ant-select-selection-placeholder {
color: rgba(0, 0, 0, 0.45);
}
.ant-select-selection-item {
background-color: #f5f5f5;
border: 1px solid #e8e8e8;
color: rgba(0, 0, 0, 0.85);
.ant-select-selection-item-content {
color: rgba(0, 0, 0, 0.85);
}
.ant-select-selection-item-remove {
color: rgba(0, 0, 0, 0.45);
&:hover {
color: rgba(0, 0, 0, 0.85);
}
}
&-active {
border-color: var(--bg-robin-500) !important;
background-color: var(--bg-vanilla-300) !important;
}
&-selected {
border-color: #1890ff !important;
background-color: var(--bg-vanilla-300) !important;
}
}
}
.custom-select-dropdown-container,
.custom-multiselect-dropdown-container {
background-color: var(--bg-vanilla-100);
border: 1px solid #f0f0f0;
box-shadow: 0 3px 6px -4px rgba(0, 0, 0, 0.12),
0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 9px 28px 8px rgba(0, 0, 0, 0.05);
.empty-message {
color: rgba(0, 0, 0, 0.45);
}
.ant-select-item {
color: rgba(0, 0, 0, 0.85);
&-option-active {
background-color: #f5f5f5 !important;
}
&-option-selected {
background-color: var(--bg-vanilla-300) !important;
}
}
}
.custom-select-dropdown,
.custom-multiselect-dropdown {
border: 1px solid #f0f0f0;
background-color: var(--bg-vanilla-100);
&::-webkit-scrollbar-thumb {
background-color: #ccc;
}
&::-webkit-scrollbar-track {
background-color: #f0f0f0;
}
.select-group {
.group-label {
color: rgba(0, 0, 0, 0.85);
background-color: #fafafa;
border-bottom: 1px solid #f0f0f0;
border-top: 1px solid #f0f0f0;
}
}
.option-item {
color: rgba(0, 0, 0, 0.85);
&:hover {
background-color: #f5f5f5;
}
&.selected {
background-color: var(--bg-vanilla-300);
}
&.active {
background-color: var(--bg-vanilla-300);
border-color: #91d5ff;
}
.option-content {
.option-badge {
background-color: #f0f0f0;
color: #666;
}
}
}
}
.navigation-footer {
border-top: 1px solid #f0f0f0;
background-color: var(--bg-vanilla-100);
.navigation-icons {
color: rgba(0, 0, 0, 0.45);
}
.navigation-text {
color: rgba(0, 0, 0, 0.45);
}
.navigate {
.icons {
border-top: 1.143px solid var(--bg-ink-200);
border-right: 1.143px solid var(--bg-ink-200);
border-bottom: 2.286px solid var(--bg-ink-200);
border-left: 1.143px solid var(--bg-ink-200);
background: var(--bg-vanilla-300);
}
}
}
.custom-multiselect-dropdown {
.select-all-option,
.custom-value-option {
border-bottom: 1px solid #f0f0f0;
background-color: #fafafa;
}
.selected-values-section {
border-bottom: 1px solid #f0f0f0;
}
.status-message {
color: rgba(0, 0, 0, 0.65);
border-top: 1px dashed #f0f0f0;
}
.option-item {
&.all-option {
border-bottom: 1px solid #f0f0f0;
}
}
}
.highlight-text {
background-color: rgba(24, 144, 255, 0.2);
}
.custom-option {
&.focused,
&.ant-select-item-option-active {
background-color: #f5f5f5 !important;
}
}
.custom-select-dropdown-container {
.group-label,
.ant-select-item-group {
background-color: #f5f0f0;
border-bottom: 1px solid #e8e8e8;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
}
// Light mode scrollbar overrides
.custom-select-dropdown,
.custom-multiselect-dropdown,
.custom-select-dropdown-container,
.custom-multiselect-dropdown-container {
scrollbar-color: rgba(0, 0, 0, 0.2) rgba(0, 0, 0, 0.05);
&::-webkit-scrollbar-track {
background-color: rgba(0, 0, 0, 0.05);
}
&::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.2);
&:hover {
background-color: rgba(0, 0, 0, 0.3);
}
}
}
}

View File

@@ -0,0 +1,60 @@
import { SelectProps } from 'antd';
export interface OptionData {
label: string;
value?: string;
disabled?: boolean;
className?: string;
style?: React.CSSProperties;
options?: OptionData[];
type?: 'defined' | 'custom' | 'regex';
}
export interface CustomSelectProps extends Omit<SelectProps, 'options'> {
placeholder?: string;
className?: string;
loading?: boolean;
onSearch?: (value: string) => void;
options?: OptionData[];
defaultActiveFirstOption?: boolean;
noDataMessage?: string;
onClear?: () => void;
getPopupContainer?: (triggerNode: HTMLElement) => HTMLElement;
dropdownRender?: (menu: React.ReactElement) => React.ReactElement;
highlightSearch?: boolean;
placement?: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight';
popupMatchSelectWidth?: boolean;
errorMessage?: string;
allowClear?: SelectProps['allowClear'];
onRetry?: () => void;
}
export interface CustomTagProps {
label: React.ReactNode;
value: string;
closable: boolean;
onClose: () => void;
}
export interface CustomMultiSelectProps
extends Omit<SelectProps<string[] | string>, 'options'> {
placeholder?: string;
className?: string;
loading?: boolean;
onSearch?: (value: string) => void;
options?: OptionData[];
defaultActiveFirstOption?: boolean;
dropdownMatchSelectWidth?: boolean | number;
noDataMessage?: string;
onClear?: () => void;
enableAllSelection?: boolean;
getPopupContainer?: (triggerNode: HTMLElement) => HTMLElement;
dropdownRender?: (menu: React.ReactElement) => React.ReactElement;
highlightSearch?: boolean;
errorMessage?: string;
popupClassName?: string;
placement?: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight';
maxTagCount?: number;
allowClear?: SelectProps['allowClear'];
onRetry?: () => void;
}

View File

@@ -0,0 +1,135 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { OptionData } from './types';
export const SPACEKEY = ' ';
export const prioritizeOrAddOptionForSingleSelect = (
options: OptionData[],
value: string,
label?: string,
): OptionData[] => {
let foundOption: OptionData | null = null;
// Separate the found option and the rest
const filteredOptions = options
.map((option) => {
if ('options' in option && Array.isArray(option.options)) {
// Filter out the value from nested options
const remainingSubOptions = option.options.filter(
(subOption) => subOption.value !== value,
);
const extractedOption = option.options.find(
(subOption) => subOption.value === value,
);
if (extractedOption) foundOption = extractedOption;
// Keep the group if it still has remaining options
return remainingSubOptions.length > 0
? { ...option, options: remainingSubOptions }
: null;
}
// Check top-level options
if (option.value === value) {
foundOption = option;
return null; // Remove it from the list
}
return option;
})
.filter(Boolean) as OptionData[]; // Remove null values
// If not found, create a new option
if (!foundOption) {
foundOption = { value, label: label ?? value };
}
// Add the found/new option at the top
return [foundOption, ...filteredOptions];
};
export const prioritizeOrAddOptionForMultiSelect = (
options: OptionData[],
values: string[], // Only supports multiple values (string[])
labels?: Record<string, string>,
): OptionData[] => {
const foundOptions: OptionData[] = [];
// Separate the found options and the rest
const filteredOptions = options
.map((option) => {
if ('options' in option && Array.isArray(option.options)) {
// Filter out selected values from nested options
const remainingSubOptions = option.options.filter(
(subOption) => subOption.value && !values.includes(subOption.value),
);
const extractedOptions = option.options.filter(
(subOption) => subOption.value && values.includes(subOption.value),
);
if (extractedOptions.length > 0) {
foundOptions.push(...extractedOptions);
}
// Keep the group if it still has remaining options
return remainingSubOptions.length > 0
? { ...option, options: remainingSubOptions }
: null;
}
// Check top-level options
if (option.value && values.includes(option.value)) {
foundOptions.push(option);
return null; // Remove it from the list
}
return option;
})
.filter(Boolean) as OptionData[]; // Remove null values
// Find missing values that were not present in the original options and create new ones
const missingValues = values.filter(
(value) => !foundOptions.some((opt) => opt.value === value),
);
const newOptions = missingValues.map((value) => ({
value,
label: labels?.[value] ?? value, // Use provided label or default to value
}));
// Add found & new options to the top
return [...newOptions, ...foundOptions, ...filteredOptions];
};
/**
* Filters options based on search text
*/
export const filterOptionsBySearch = (
options: OptionData[],
searchText: string,
): OptionData[] => {
if (!searchText.trim()) return options;
const lowerSearchText = searchText.toLowerCase();
return options
.map((option) => {
if ('options' in option && Array.isArray(option.options)) {
// Filter nested options
const filteredSubOptions = option.options.filter((subOption) =>
subOption.label.toLowerCase().includes(lowerSearchText),
);
return filteredSubOptions.length > 0
? { ...option, options: filteredSubOptions }
: undefined;
}
// Filter top-level options
return option.label.toLowerCase().includes(lowerSearchText)
? option
: undefined;
})
.filter(Boolean) as OptionData[];
};

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,5 +8,5 @@ export enum FeatureKeys {
PREMIUM_SUPPORT = 'PREMIUM_SUPPORT',
ANOMALY_DETECTION = 'ANOMALY_DETECTION',
ONBOARDING_V3 = 'ONBOARDING_V3',
THIRD_PARTY_API = 'THIRD_PARTY_API',
TRACE_FUNNELS = 'TRACE_FUNNELS',
}

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

@@ -17,7 +17,6 @@ const ROUTES = {
'/get-started/infrastructure-monitoring',
GET_STARTED_AWS_MONITORING: '/get-started/aws-monitoring',
GET_STARTED_AZURE_MONITORING: '/get-started/azure-monitoring',
USAGE_EXPLORER: '/usage-explorer',
APPLICATION: '/services',
ALL_DASHBOARD: '/dashboard',
DASHBOARD: '/dashboard/:dashboardId',

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 {
@@ -362,9 +362,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 =>
@@ -671,11 +668,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
? 0
: '0 1rem',
...(isTraceDetailsView() ||
isTracesFunnels() ||
isTracesFunnelDetails()
? { margin: 0 }
: {}),
...(isTraceDetailsView() || isTracesFunnels() ? { margin: 0 } : {}),
}}
>
{isToDisplayLayout && !renderFullScreen && <TopNav />}

View File

@@ -133,231 +133,3 @@ const ServicesListTable = memo(
),
);
ServicesListTable.displayName = 'ServicesListTable';
function ServiceMetrics({
onUpdateChecklistDoneItem,
loadingUserPreferences,
}: {
onUpdateChecklistDoneItem: (itemKey: string) => void;
loadingUserPreferences: boolean;
}): JSX.Element {
const { selectedTime: globalSelectedInterval } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
const { user, activeLicenseV3 } = useAppContext();
const [timeRange, setTimeRange] = useState(() => {
const now = new Date().getTime();
return {
startTime: now - homeInterval,
endTime: now,
selectedInterval: homeInterval,
};
});
const { queries } = useResourceAttribute();
const { safeNavigate } = useSafeNavigate();
const selectedTags = useMemo(
() => (convertRawQueriesToTraceSelectedTags(queries) as Tags[]) || [],
[queries],
);
const [isError, setIsError] = useState(false);
const queryKey: QueryKey = useMemo(
() => [
timeRange.startTime,
timeRange.endTime,
selectedTags,
globalSelectedInterval,
],
[
timeRange.startTime,
timeRange.endTime,
selectedTags,
globalSelectedInterval,
],
);
const {
data,
isLoading: isLoadingTopLevelOperations,
isError: isErrorTopLevelOperations,
} = useGetTopLevelOperations(queryKey, {
start: timeRange.startTime * 1e6,
end: timeRange.endTime * 1e6,
});
const handleTimeIntervalChange = useCallback((value: number): void => {
const timeInterval = TIME_PICKER_OPTIONS.find(
(option) => option.value === value,
);
logEvent('Homepage: Services time interval updated', {
updatedTimeInterval: timeInterval?.label,
});
const now = new Date();
setTimeRange({
startTime: now.getTime() - value,
endTime: now.getTime(),
selectedInterval: value,
});
}, []);
const topLevelOperations = useMemo(() => Object.entries(data || {}), [data]);
const queryRangeRequestData = useMemo(
() =>
getQueryRangeRequestData({
topLevelOperations,
minTime: timeRange.startTime * 1e6,
maxTime: timeRange.endTime * 1e6,
globalSelectedInterval,
}),
[
globalSelectedInterval,
timeRange.endTime,
timeRange.startTime,
topLevelOperations,
],
);
const dataQueries = useGetQueriesRange(
queryRangeRequestData,
ENTITY_VERSION_V4,
{
queryKey: useMemo(
() => [
`GetMetricsQueryRange-home-${globalSelectedInterval}`,
timeRange.endTime,
timeRange.startTime,
globalSelectedInterval,
],
[globalSelectedInterval, timeRange.endTime, timeRange.startTime],
),
keepPreviousData: true,
enabled: true,
refetchOnMount: false,
onError: () => {
setIsError(true);
},
},
);
const isLoading = useMemo(() => dataQueries.some((query) => query.isLoading), [
dataQueries,
]);
const services: ServicesList[] = useMemo(
() =>
getServiceListFromQuery({
queries: dataQueries,
topLevelOperations,
isLoading,
}),
[dataQueries, topLevelOperations, isLoading],
);
const sortedServices = useMemo(
() =>
services?.sort((a, b) => {
const aUpdateAt = new Date(a.p99).getTime();
const bUpdateAt = new Date(b.p99).getTime();
return bUpdateAt - aUpdateAt;
}) || [],
[services],
);
const servicesExist = sortedServices.length > 0;
const top5Services = useMemo(() => sortedServices.slice(0, 5), [
sortedServices,
]);
useEffect(() => {
if (!loadingUserPreferences && servicesExist) {
onUpdateChecklistDoneItem('SETUP_SERVICES');
}
}, [onUpdateChecklistDoneItem, loadingUserPreferences, servicesExist]);
const handleRowClick = useCallback(
(record: ServicesList) => {
logEvent('Homepage: Service clicked', {
serviceName: record.serviceName,
});
safeNavigate(`${ROUTES.APPLICATION}/${record.serviceName}`);
},
[safeNavigate],
);
if (isLoadingTopLevelOperations || isLoading) {
return (
<Card className="services-list-card home-data-card loading-card">
<Card.Content>
<Skeleton active />
</Card.Content>
</Card>
);
}
if (isErrorTopLevelOperations || isError) {
return (
<Card className="services-list-card home-data-card error-card">
<Card.Content>
<Skeleton active />
</Card.Content>
</Card>
);
}
return (
<Card className="services-list-card home-data-card">
{servicesExist && (
<Card.Header>
<div className="services-header home-data-card-header">
{' '}
Services
<div className="services-header-actions">
<Select
value={timeRange.selectedInterval}
onChange={handleTimeIntervalChange}
options={TIME_PICKER_OPTIONS}
className="services-header-select"
/>
</div>
</div>
</Card.Header>
)}
<Card.Content>
{servicesExist ? (
<ServicesListTable services={top5Services} onRowClick={handleRowClick} />
) : (
<EmptyState user={user} activeLicenseV3={activeLicenseV3} />
)}
</Card.Content>
{servicesExist && (
<Card.Footer>
<div className="services-footer home-data-card-footer">
<Link to="/services">
<Button
type="link"
className="periscope-btn link learn-more-link"
onClick={(): void => {
logEvent('Homepage: All Services clicked', {});
}}
>
All Services <ArrowRight size={12} />
</Button>
</Link>
</div>
</Card.Footer>
)}
</Card>
);
}
export default memo(ServiceMetrics);

View File

@@ -21,17 +21,10 @@ function Services({
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<div className="home-services-container">
{isSpanMetricEnabled ? (
<ServiceMetrics
<ServiceTraces
onUpdateChecklistDoneItem={onUpdateChecklistDoneItem}
loadingUserPreferences={loadingUserPreferences}
/>
) : (
<ServiceTraces
onUpdateChecklistDoneItem={onUpdateChecklistDoneItem}
loadingUserPreferences={loadingUserPreferences}
/>
)}
</div>
</Sentry.ErrorBoundary>
);

View File

@@ -481,7 +481,6 @@ export const apDexMetricsQueryBuilderQueries = ({
export const operationPerSec = ({
servicename,
tagFilterItems,
topLevelOperations,
}: OperationPerSecProps): QueryBuilderData => {
const autocompleteData: BaseAutocompleteData[] = [
{

View File

@@ -1,7 +1,4 @@
import logEvent from 'api/common/logEvent';
import getTopLevelOperations, {
ServiceDataProps,
} from 'api/metrics/getTopLevelOperations';
import { FeatureKeys } from 'constants/features';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
@@ -110,21 +107,6 @@ function Application(): JSX.Element {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const {
data: topLevelOperations,
error: topLevelOperationsError,
isLoading: topLevelOperationsIsLoading,
isError: topLevelOperationsIsError,
} = useQuery<ServiceDataProps>({
queryKey: [servicename, minTime, maxTime],
queryFn: (): Promise<ServiceDataProps> =>
getTopLevelOperations({
service: servicename || '',
start: minTime,
end: maxTime,
}),
});
const selectedTraceTags: string = JSON.stringify(
convertRawQueriesToTraceSelectedTags(queries) || [],
);
@@ -137,14 +119,6 @@ function Application(): JSX.Element {
[queries],
);
const topLevelOperationsRoute = useMemo(
() =>
topLevelOperations
? defaultTo(topLevelOperations[servicename || ''], [])
: [],
[servicename, topLevelOperations],
);
const operationPerSecWidget = useMemo(
() =>
getWidgetQueryBuilder({

View File

@@ -3,7 +3,7 @@ import 'dayjs/locale/en';
import { PlusOutlined } from '@ant-design/icons';
import { Color } from '@signozhq/design-tokens';
import { Button, Flex, Form, Input, Typography } from 'antd';
import { Button, Flex, Form, Input, Tooltip, Typography } from 'antd';
import getAll from 'api/alerts/getAll';
import { useDeleteDowntimeSchedule } from 'api/plannedDowntime/deleteDowntimeSchedule';
import {
@@ -13,8 +13,10 @@ import {
import dayjs from 'dayjs';
import { useNotifications } from 'hooks/useNotifications';
import { Search } from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import React, { ChangeEvent, useEffect, useState } from 'react';
import { useQuery } from 'react-query';
import { USER_ROLES } from 'types/roles';
import { PlannedDowntimeDeleteModal } from './PlannedDowntimeDeleteModal';
import { PlannedDowntimeForm } from './PlannedDowntimeForm';
@@ -33,6 +35,7 @@ export function PlannedDowntime(): JSX.Element {
});
const [isOpen, setIsOpen] = React.useState(false);
const [form] = Form.useForm();
const { user } = useAppContext();
const [initialValues, setInitialValues] = useState<
Partial<DowntimeSchedules & { editMode: boolean }>
@@ -108,18 +111,27 @@ export function PlannedDowntime(): JSX.Element {
value={searchValue}
onChange={handleSearch}
/>
<Button
icon={<PlusOutlined />}
type="primary"
onClick={(): void => {
setInitialValues({ ...defautlInitialValues, editMode: false });
setIsOpen(true);
setEditMode(false);
form.resetFields();
}}
<Tooltip
title={
user?.role === USER_ROLES.VIEWER
? 'You need edit permissions to create a planned downtime'
: ''
}
>
New downtime
</Button>
<Button
icon={<PlusOutlined />}
type="primary"
onClick={(): void => {
setInitialValues({ ...defautlInitialValues, editMode: false });
setIsOpen(true);
setEditMode(false);
form.resetFields();
}}
disabled={user?.role === USER_ROLES.VIEWER}
>
New downtime
</Button>
</Tooltip>
</Flex>
<br />
<PlannedDowntimeList

View File

@@ -0,0 +1,44 @@
import { screen } from '@testing-library/react';
import { render } from 'tests/test-utils';
import { USER_ROLES } from 'types/roles';
import { PlannedDowntime } from '../PlannedDowntime';
describe('PlannedDowntime Component', () => {
it('renders the PlannedDowntime component properly', () => {
render(<PlannedDowntime />, {}, 'ADMIN');
// Check if title is rendered
expect(screen.getByText('Planned Downtime')).toBeInTheDocument();
// Check if subtitle is rendered
expect(
screen.getByText('Create and manage planned downtimes.'),
).toBeInTheDocument();
// Check if search input is rendered
expect(
screen.getByPlaceholderText('Search for a planned downtime...'),
).toBeInTheDocument();
// Check if "New downtime" button is enabled for ADMIN
const newDowntimeButton = screen.getByRole('button', {
name: /new downtime/i,
});
expect(newDowntimeButton).toBeInTheDocument();
expect(newDowntimeButton).not.toBeDisabled();
});
it('disables the "New downtime" button for users with VIEWER role', () => {
render(<PlannedDowntime />, {}, USER_ROLES.VIEWER);
// Check if "New downtime" button is disabled for VIEWER
const newDowntimeButton = screen.getByRole('button', {
name: /new downtime/i,
});
expect(newDowntimeButton).toBeInTheDocument();
expect(newDowntimeButton).toBeDisabled();
expect(newDowntimeButton).toHaveAttribute('disabled');
});
});

View File

@@ -458,7 +458,6 @@ export const Query = memo(function Query({
query={query}
onChange={handleChangeTagFilters}
whereClauseConfig={filterConfigs?.filters}
hideSpanScopeSelector={query.dataSource !== DataSource.TRACES}
/>
) : (
<QueryBuilderSearch

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;

View File

@@ -91,10 +91,6 @@ interface QueryBuilderSearchV2Props {
className?: string;
suffixIcon?: React.ReactNode;
hardcodedAttributeKeys?: BaseAutocompleteData[];
hasPopupContainer?: boolean;
hideSpanScopeSelector?: boolean;
rootClassName?: string;
maxTagCount?: number | 'responsive';
operatorConfigKey?: OperatorConfigKeys;
}
@@ -129,11 +125,7 @@ function QueryBuilderSearchV2(
suffixIcon,
whereClauseConfig,
hardcodedAttributeKeys,
hasPopupContainer,
rootClassName,
maxTagCount,
operatorConfigKey,
hideSpanScopeSelector,
} = props;
const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys();
@@ -944,14 +936,16 @@ function QueryBuilderSearchV2(
);
};
const isTracesDataSource = useMemo(
() => query.dataSource === DataSource.TRACES,
[query.dataSource],
);
return (
<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
@@ -983,7 +977,7 @@ function QueryBuilderSearchV2(
: '',
className,
)}
rootClassName={cx('query-builder-search', rootClassName)}
rootClassName="query-builder-search"
disabled={isMetricsDataSource && !query.aggregateAttribute.key}
style={selectStyle}
onSearch={handleSearch}
@@ -1031,7 +1025,7 @@ function QueryBuilderSearchV2(
);
})}
</Select>
{!hideSpanScopeSelector && <SpanScopeSelector queryName={query.queryName} />}
{isTracesDataSource && <SpanScopeSelector queryName={query.queryName} />}
</div>
);
}
@@ -1041,12 +1035,8 @@ QueryBuilderSearchV2.defaultProps = {
className: '',
suffixIcon: null,
whereClauseConfig: {},
hasPopupContainer: true,
rootClassName: '',
hardcodedAttributeKeys: undefined,
maxTagCount: undefined,
operatorConfigKey: undefined,
hideSpanScopeSelector: true,
};
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 SpanLineActionButtons from 'container/TraceWaterfall/SpanLineActionButtons';
import { IInterestedSpan } from 'container/TraceWaterfall/TraceWaterfall';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
@@ -62,7 +61,6 @@ function SpanOverview({
isSpanCollapsed,
handleCollapseUncollapse,
setSelectedSpan,
handleAddSpanToFunnel,
selectedSpan,
}: {
span: Span;
@@ -70,8 +68,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;
@@ -149,28 +145,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>
@@ -261,15 +235,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({
@@ -282,7 +253,6 @@ function getWaterfallColumns({
isSpanCollapsed={!uncollapsedNodes.includes(props.row.original.spanId)}
selectedSpan={selectedSpan}
setSelectedSpan={setSelectedSpan}
handleAddSpanToFunnel={handleAddSpanToFunnel}
/>
),
size: 450,
@@ -349,17 +319,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({
@@ -368,7 +327,6 @@ function Success(props: ISuccessProps): JSX.Element {
traceMetadata,
selectedSpan,
setSelectedSpan,
handleAddSpanToFunnel,
}),
[
handleCollapseUncollapse,
@@ -376,7 +334,6 @@ function Success(props: ISuccessProps): JSX.Element {
traceMetadata,
selectedSpan,
setSelectedSpan,
handleAddSpanToFunnel,
],
);
@@ -448,13 +405,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,178 +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,
handleRestoreSteps,
} = 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);
}
},
onError: () => {
handleRestoreSteps(lastSavedStepsStateRef.current);
queryClient.setQueryData(
[REACT_QUERY_KEY.GET_FUNNEL_DETAILS, funnel.id],
(oldData: any) => ({
...oldData,
payload: {
...oldData.payload,
steps: lastSavedStepsStateRef.current,
},
}),
);
},
});
}
// 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(), 'ns'),
},
{
title: 'P99 Latency',
value: getYAxisFormattedValue(sourceData.p99_latency.toString(), 'ns'),
},
];
}, [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

@@ -1,224 +0,0 @@
/* eslint-disable */
//@ts-nocheck
import { Select, Space, Typography } from 'antd';
import Graph from 'components/Graph';
import { useEffect, useState } from 'react';
import { connect, useSelector } from 'react-redux';
import { withRouter } from 'react-router-dom';
import { GetService, getUsageData, UsageDataItem } from 'store/actions';
import { AppState } from 'store/reducers';
import { GlobalTime } from 'types/actions/globalTime';
import { GlobalReducer } from 'types/reducer/globalTime';
import MetricReducer from 'types/reducer/metrics';
import { isOnboardingSkipped } from 'utils/app';
import { Card } from './styles';
const { Option } = Select;
interface UsageExplorerProps {
usageData: UsageDataItem[];
getUsageData: (
minTime: number,
maxTime: number,
selectedInterval: number,
selectedService: string,
) => void;
getServicesList: ({
selectedTimeInterval,
}: {
selectedTimeInterval: GlobalReducer['selectedTime'];
}) => void;
globalTime: GlobalTime;
servicesList: servicesListItem[];
totalCount: number;
}
const timeDaysOptions = [
{ value: 30, label: 'Last 30 Days' },
{ value: 7, label: 'Last week' },
{ value: 1, label: 'Last day' },
];
const interval = [
{
value: 604800,
chartDivideMultiplier: 1,
label: 'Weekly',
applicableOn: [timeDaysOptions[0]],
},
{
value: 86400,
chartDivideMultiplier: 30,
label: 'Daily',
applicableOn: [timeDaysOptions[0], timeDaysOptions[1]],
},
{
value: 3600,
chartDivideMultiplier: 10,
label: 'Hours',
applicableOn: [timeDaysOptions[2], timeDaysOptions[1]],
},
];
function _UsageExplorer(props: UsageExplorerProps): JSX.Element {
const [selectedTime, setSelectedTime] = useState(timeDaysOptions[1]);
const [selectedInterval, setSelectedInterval] = useState(interval[2]);
const [selectedService, setSelectedService] = useState<string>('');
const { selectedTime: globalSelectedTime } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
const {
getServicesList,
getUsageData,
globalTime,
totalCount,
usageData,
} = props;
const { services } = useSelector<AppState, MetricReducer>(
(state) => state.metrics,
);
useEffect(() => {
if (selectedTime && selectedInterval) {
const maxTime = new Date().getTime() * 1000000;
const minTime = maxTime - selectedTime.value * 24 * 3600000 * 1000000;
getUsageData(minTime, maxTime, selectedInterval.value, selectedService);
}
}, [selectedTime, selectedInterval, selectedService, getUsageData]);
useEffect(() => {
getServicesList({
selectedTimeInterval: globalSelectedTime,
});
}, [globalTime, getServicesList, globalSelectedTime]);
const data = {
labels: usageData.map((s) => new Date(s.timestamp / 1000000)),
datasets: [
{
label: 'Span Count',
data: usageData.map((s) => s.count),
backgroundColor: 'rgba(255, 99, 132, 0.2)',
borderColor: 'rgba(255, 99, 132, 1)',
borderWidth: 2,
},
],
};
return (
<>
<Space style={{ marginTop: 40, marginLeft: 20 }}>
<Space>
<Select
onSelect={(value): void => {
setSelectedTime(
timeDaysOptions.filter((item) => item.value == parseInt(value))[0],
);
}}
value={selectedTime.label}
>
{timeDaysOptions.map(({ value, label }) => (
<Option key={value} value={value}>
{label}
</Option>
))}
</Select>
</Space>
<Space>
<Select
onSelect={(value): void => {
setSelectedInterval(
interval.filter((item) => item.value === parseInt(value))[0],
);
}}
value={selectedInterval.label}
>
{interval
.filter((interval) => interval.applicableOn.includes(selectedTime))
.map((item) => (
<Option key={item.label} value={item.value}>
{item.label}
</Option>
))}
</Select>
</Space>
<Space>
<Select
onSelect={(value): void => {
setSelectedService(value);
}}
value={selectedService || 'All Services'}
>
<Option value="">All Services</Option>
{services?.map((service) => (
<Option key={service.serviceName} value={service.serviceName}>
{service.serviceName}
</Option>
))}
</Select>
</Space>
{isOnboardingSkipped() && totalCount === 0 ? (
<Space
style={{
width: '100%',
margin: '40px 0',
marginLeft: 20,
justifyContent: 'center',
}}
>
<Typography>
No spans found. Please add instrumentation (follow this
<a
href="https://signoz.io/docs/instrumentation/overview"
target="_blank"
style={{ marginLeft: 3 }}
rel="noreferrer"
>
guide
</a>
)
</Typography>
</Space>
) : (
<Space style={{ display: 'block', marginLeft: 20, width: 200 }}>
<Typography>{`Total count is ${totalCount}`}</Typography>
</Space>
)}
</Space>
<Card>
<Graph name="usage" data={data} type="bar" />
</Card>
</>
);
}
const mapStateToProps = (
state: AppState,
): {
totalCount: number;
globalTime: GlobalTime;
usageData: UsageDataItem[];
} => {
let totalCount = 0;
for (const item of state.usageDate) {
totalCount += item.count;
}
return {
totalCount,
usageData: state.usageDate,
globalTime: state.globalTime,
};
};
export const UsageExplorer = withRouter(
connect(mapStateToProps, {
getUsageData,
getServicesList: GetService,
})(_UsageExplorer),
);

View File

@@ -1,7 +0,0 @@
import { UsageExplorer } from './UsageExplorer';
function UsageExplorerContainer(): JSX.Element {
return <UsageExplorer />;
}
export default UsageExplorerContainer;

View File

@@ -1,13 +0,0 @@
import { Card as CardComponent } from 'antd';
import styled from 'styled-components';
export const Card = styled(CardComponent)`
&&& {
width: 90%;
margin-top: 2rem;
}
.ant-card-body {
height: 70vh;
}
`;

View File

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

View File

@@ -1,10 +1,9 @@
import './TraceDetailV2.styles.scss';
import { Button, Tabs } from 'antd';
import logEvent from 'api/common/logEvent';
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';
@@ -34,10 +33,6 @@ function NewTraceDetail(props: INewTraceDetailProps): JSX.Element {
if (activeKey === 'trace-details') {
history.push(ROUTES.TRACES_EXPLORER);
}
if (activeKey === 'funnels') {
logEvent('Trace Funnels: visited from trace details page', {});
history.push(ROUTES.TRACES_FUNNELS);
}
}}
tabBarExtraContent={
<Button
@@ -56,7 +51,6 @@ function NewTraceDetail(props: INewTraceDetailProps): JSX.Element {
export default function TraceDetailsPage(): JSX.Element {
const [showOldTraceDetails, setShowOldTraceDetails] = useState<boolean>(false);
const items = [
{
label: (
@@ -67,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,34 +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 {
const breadcrumbItems = [
{
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>,
},
];
return (
<div>
<Breadcrumb className="funnel-breadcrumb" items={breadcrumbItems} />
</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,187 +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"
/>
</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,112 +0,0 @@
import './StepsContent.styles.scss';
import { Button, Steps } from 'antd';
import logEvent from 'api/common/logEvent';
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);
}
logEvent(
'Trace Funnels: span added for a new step from trace details page',
{},
);
}, [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>
}
/>
))}
{/* For now we are only supporting 3 steps */}
{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,74 +0,0 @@
import { ErrorTraceData, SlowTraceData } from 'api/traceFunnels';
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
import { useMemo } from 'react';
import { UseQueryResult } from 'react-query';
import { ErrorResponse, SuccessResponse } from 'types/api';
import FunnelTable from './FunnelTable';
import { topTracesTableColumns } from './utils';
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]);
return (
<FunnelTable
title={title}
tooltip={tooltip}
columns={topTracesTableColumns}
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;

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