Compare commits

...

22 Commits

Author SHA1 Message Date
SagarRajput-7
80a2608eb1 fix: upgrade the dependent package - typescript-plugin-css-modules@5.1.0 to latest 2025-07-23 13:39:08 +05:30
SagarRajput-7
f1921d0deb fix: used the npm provided - 0.0.1-security version 2025-07-23 13:31:17 +05:30
SagarRajput-7
ec4f66a8c5 fix: used the npm provided - 0.0.1-security version 2025-07-23 13:29:56 +05:30
SagarRajput-7
61e06cb43d fix: resolve stylus security issue by replacing with stylis 2025-07-23 12:50:46 +05:30
Yunus M
a576982497 feat: update app loading screen and add system theme option (#8567)
* feat: update app loading screen and add system theme option

* feat: update test case
2025-07-22 20:13:07 +00:00
Alpcan Yıldız
55eadf914b fix: prevent 1Password extension from interfering with time inputs (#8486)
- Add data-1p-ignore attribute to CustomTimePicker input
- Add data-1p-ignore attribute to RangePickerModal DatePicker
- Add data-1p-ignore attribute to TimezonePicker search input

This prevents 1Password extension from automatically opening when
users interact with time-related input fields, improving UX.

Fixes #8485
2025-07-22 19:34:34 +00:00
Shaheer Kochai
b91407416b feat: add support for single step funnels while creating from span details (#8492)
* feat: add support for single step funnels while creating from span details

* fix: fix the UI for loading state
2025-07-22 13:13:09 +00:00
aniketio-ctrl
24d6d83575 fix(prom-dup-labels): added fingerprint in prom labels (#8563)
* fix(prom-dup-labels): added fingerprint in prom labels

* fix(prom-dup-labels): removed fingerprint labels from result series

* fix(prom-dup-labels): removed fingerprint labels from result series

* fix(prom-dup-labels): removed fingerprint labels from result series

* fix(prom-dup-labels): removed fingerprint labels from result series

* fix(prom-dup-labels): added test cases

* Update pkg/prometheus/clickhouseprometheus/client_query_test.go

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* fix(prom-dup-labels): added test cases

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-07-22 10:39:50 +00:00
Sahil Khan
fe95ee716a fix: added attribute check for log details filtering (#8427)
* fix: added attribute check for log details filtering

* chore: added unit tests

* chore: add the missing args to onClickHandler to fix the failing build

---------

Co-authored-by: ahmadshaheer <ashaheerki@gmail.com>
2025-07-22 09:21:07 +00:00
Vibhu Pandey
b053ce23cd ci(prereleaser): remove cron scheduler (#8584) 2025-07-22 09:03:18 +00:00
Amlan Kumar Nandy
57febd2f52 fix: navigating from infra monitoring to logs with open in explorer has missing filters (#8570) 2025-07-22 15:51:48 +07:00
Abhi kumar
ba6a1c594b fix: use only needed resource attributes in context filter (#8568)
* chore: use only needed resource attributes in context filter

* chore: moved regex to constants
2025-07-21 18:37:32 +05:30
Amlan Kumar Nandy
6afdecbd0f chore: add unit tests for metric details drawer (#8515) 2025-07-21 04:45:56 +00:00
Shaheer Kochai
41661a5e28 feat: add support for entrypoint spans toggle in top operations table (#8175)
* feat: add support for entrypoint spans toggle in top operations table

* fix: write tests for entry point toggle

* chore: entry point -> entrypoint

* fix: add info icon and tooltip for entrypoint spans toggle

* fix: fix the copy and link for entrypoint toggle in top operations

* chore: update the tooltip text

* Update frontend/src/container/MetricsApplication/TopOperationsTable.tsx

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* chore: fix the failing build

* chore: update the entry point spans docs link

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-07-20 11:20:13 +00:00
Nageshbansal
507dc86af2 Remove docker downloads badge (#8556) 2025-07-19 03:04:38 +00:00
Srikanth Chekuri
ff3bb04655 chore: support legacy cols usage and address several gaps (#8552) 2025-07-18 18:37:57 +05:30
Vibhu Pandey
31c4f800fc feat: add more codeowners (#8558) 2025-07-18 12:05:31 +00:00
aniketio-ctrl
51c2bbcd4b fix(dedup-prom): added check for duplicated samples data (#8502)
* fix(dedup-prom): added check for duplicated samples data

* fix(dedup-prom): added test cases for duplicated samples data

* fix(dedup-prom): added test cases for duplicated samples data
2025-07-18 08:57:00 +00:00
scout9ll
5610cb1f81 fix: retain compositeQuery during pagination and field filtering on exceptions page (#8300) 2025-07-17 15:28:28 +00:00
Yunus M
478d28eda1 feat(license): show refetch payment status button to reconcile payments (#8551) 2025-07-17 20:00:33 +05:30
Vibhu Pandey
ebb2f1fd63 feat(cmd): add cmd package (#8535) 2025-07-17 10:38:31 +00:00
Shaheer Kochai
629e502703 feat: add support for role based access in trace funnels flows (#8481)
* feat: add support for role based access in trace funnels flows

* chore: fix the failing build
2025-07-17 05:47:54 +00:00
151 changed files with 5225 additions and 1186 deletions

30
.github/CODEOWNERS vendored
View File

@@ -7,14 +7,38 @@
/frontend/src/container/NewWidget/RightContainer/types.ts @srikanthccv
/deploy/ @SigNoz/devops
.github @SigNoz/devops
# Scaffold Owners
/pkg/config/ @grandwizard28
/pkg/errors/ @grandwizard28
/pkg/factory/ @grandwizard28
/pkg/types/ @grandwizard28
/pkg/valuer/ @grandwizard28
/cmd/ @grandwizard28
.golangci.yml @grandwizard28
# Zeus Owners
/pkg/zeus/ @vikrantgupta25
/pkg/licensing/ @vikrantgupta25
/pkg/sqlmigration/ @vikrantgupta25
/ee/zeus/ @vikrantgupta25
/pkg/licensing/ @vikrantgupta25
/ee/licensing/ @vikrantgupta25
/ee/sqlmigration/ @vikrantgupta25
# SQL Owners
/pkg/sqlmigration/ @vikrantgupta25
/ee/sqlmigration/ @vikrantgupta25
/pkg/sqlschema/ @vikrantgupta25
/ee/sqlschema/ @vikrantgupta25
# Analytics Owners
/pkg/analytics/ @vikrantgupta25
/pkg/statsreporter/ @vikrantgupta25
# Querier Owners
/pkg/querier/ @srikanthccv
/pkg/variables/ @srikanthccv
/pkg/types/querybuildertypes/ @srikanthccv
/pkg/querybuilder/ @srikanthccv
/pkg/telemetrylogs/ @srikanthccv
/pkg/telemetrymetadata/ @srikanthccv
/pkg/telemetrymetrics/ @srikanthccv
/pkg/telemetrytraces/ @srikanthccv

View File

@@ -66,7 +66,7 @@ jobs:
GO_NAME: signoz-community
GO_INPUT_ARTIFACT_CACHE_KEY: community-jsbuild-${{ github.sha }}
GO_INPUT_ARTIFACT_PATH: frontend/build
GO_BUILD_CONTEXT: ./pkg/query-service
GO_BUILD_CONTEXT: ./cmd/community
GO_BUILD_FLAGS: >-
-tags timetzdata
-ldflags='-linkmode external -extldflags \"-static\" -s -w
@@ -78,6 +78,6 @@ jobs:
-X github.com/SigNoz/signoz/pkg/analytics.key=9kRrJ7oPCGPEJLF6QjMPLt5bljFhRQBr'
GO_CGO_ENABLED: 1
DOCKER_BASE_IMAGES: '{"alpine": "alpine:3.20.3"}'
DOCKER_DOCKERFILE_PATH: ./pkg/query-service/Dockerfile.multi-arch
DOCKER_DOCKERFILE_PATH: ./cmd/community/Dockerfile.multi-arch
DOCKER_MANIFEST: true
DOCKER_PROVIDERS: dockerhub

View File

@@ -96,7 +96,7 @@ jobs:
GO_VERSION: 1.23
GO_INPUT_ARTIFACT_CACHE_KEY: enterprise-jsbuild-${{ github.sha }}
GO_INPUT_ARTIFACT_PATH: frontend/build
GO_BUILD_CONTEXT: ./ee/query-service
GO_BUILD_CONTEXT: ./cmd/enterprise
GO_BUILD_FLAGS: >-
-tags timetzdata
-ldflags='-linkmode external -extldflags \"-static\" -s -w
@@ -112,6 +112,6 @@ jobs:
-X github.com/SigNoz/signoz/pkg/analytics.key=9kRrJ7oPCGPEJLF6QjMPLt5bljFhRQBr'
GO_CGO_ENABLED: 1
DOCKER_BASE_IMAGES: '{"alpine": "alpine:3.20.3"}'
DOCKER_DOCKERFILE_PATH: ./ee/query-service/Dockerfile.multi-arch
DOCKER_DOCKERFILE_PATH: ./cmd/enterprise/Dockerfile.multi-arch
DOCKER_MANIFEST: true
DOCKER_PROVIDERS: ${{ needs.prepare.outputs.docker_providers }}

View File

@@ -95,7 +95,7 @@ jobs:
GO_VERSION: 1.23
GO_INPUT_ARTIFACT_CACHE_KEY: staging-jsbuild-${{ github.sha }}
GO_INPUT_ARTIFACT_PATH: frontend/build
GO_BUILD_CONTEXT: ./ee/query-service
GO_BUILD_CONTEXT: ./cmd/enterprise
GO_BUILD_FLAGS: >-
-tags timetzdata
-ldflags='-linkmode external -extldflags \"-static\" -s -w
@@ -111,7 +111,7 @@ jobs:
-X github.com/SigNoz/signoz/pkg/analytics.key=9kRrJ7oPCGPEJLF6QjMPLt5bljFhRQBr'
GO_CGO_ENABLED: 1
DOCKER_BASE_IMAGES: '{"alpine": "alpine:3.20.3"}'
DOCKER_DOCKERFILE_PATH: ./ee/query-service/Dockerfile.multi-arch
DOCKER_DOCKERFILE_PATH: ./cmd/enterprise/Dockerfile.multi-arch
DOCKER_MANIFEST: true
DOCKER_PROVIDERS: gcp
staging:

View File

@@ -36,7 +36,7 @@ jobs:
- ubuntu-latest
- macos-latest
env:
CONFIG_PATH: pkg/query-service/.goreleaser.yaml
CONFIG_PATH: cmd/community/.goreleaser.yaml
runs-on: ${{ matrix.os }}
steps:
- name: checkout
@@ -100,7 +100,7 @@ jobs:
needs: build
env:
DOCKER_CLI_EXPERIMENTAL: "enabled"
WORKDIR: pkg/query-service
WORKDIR: cmd/community
steps:
- name: checkout
uses: actions/checkout@v4

View File

@@ -50,7 +50,7 @@ jobs:
- ubuntu-latest
- macos-latest
env:
CONFIG_PATH: ee/query-service/.goreleaser.yaml
CONFIG_PATH: cmd/enterprise/.goreleaser.yaml
runs-on: ${{ matrix.os }}
steps:
- name: checkout

View File

@@ -20,7 +20,7 @@ jobs:
- sqlite
clickhouse-version:
- 24.1.2-alpine
- 24.12-alpine
- 25.5.6
schema-migrator-version:
- v0.128.1
postgres-version:

View File

@@ -1,10 +1,6 @@
name: prereleaser
on:
# schedule every wednesday 6:30 AM UTC (12:00 PM IST)
schedule:
- cron: '30 6 * * 3'
# allow manual triggering of the workflow by a maintainer
workflow_dispatch:
inputs:

View File

@@ -2,7 +2,7 @@ Copyright (c) 2020-present SigNoz Inc.
Portions of this software are licensed as follows:
* All content that resides under the "ee/" directory of this repository, if that directory exists, is licensed under the license defined in "ee/LICENSE".
* All content that resides under the "ee/" and the "cmd/enterprise/" directory of this repository, if that directory exists, is licensed under the license defined in "ee/LICENSE".
* All third party components incorporated into the SigNoz Software are licensed under the original license provided by the owner of the applicable component.
* Content outside of the above mentioned directories or restrictions above is available under the "MIT Expat" license as defined below.

View File

@@ -20,18 +20,18 @@ GO_BUILD_LDFLAG_LICENSE_SIGNOZ_IO = -X github.com/SigNoz/signoz/ee/zeus.depreca
GO_BUILD_VERSION_LDFLAGS = -X github.com/SigNoz/signoz/pkg/version.version=$(VERSION) -X github.com/SigNoz/signoz/pkg/version.hash=$(COMMIT_SHORT_SHA) -X github.com/SigNoz/signoz/pkg/version.time=$(TIMESTAMP) -X github.com/SigNoz/signoz/pkg/version.branch=$(BRANCH_NAME)
GO_BUILD_ARCHS_COMMUNITY = $(addprefix go-build-community-,$(ARCHS))
GO_BUILD_CONTEXT_COMMUNITY = $(SRC)/pkg/query-service
GO_BUILD_CONTEXT_COMMUNITY = $(SRC)/cmd/community
GO_BUILD_LDFLAGS_COMMUNITY = $(GO_BUILD_VERSION_LDFLAGS) -X github.com/SigNoz/signoz/pkg/version.variant=community
GO_BUILD_ARCHS_ENTERPRISE = $(addprefix go-build-enterprise-,$(ARCHS))
GO_BUILD_ARCHS_ENTERPRISE_RACE = $(addprefix go-build-enterprise-race-,$(ARCHS))
GO_BUILD_CONTEXT_ENTERPRISE = $(SRC)/ee/query-service
GO_BUILD_CONTEXT_ENTERPRISE = $(SRC)/cmd/enterprise
GO_BUILD_LDFLAGS_ENTERPRISE = $(GO_BUILD_VERSION_LDFLAGS) -X github.com/SigNoz/signoz/pkg/version.variant=enterprise $(GO_BUILD_LDFLAG_ZEUS_URL) $(GO_BUILD_LDFLAG_LICENSE_SIGNOZ_IO)
DOCKER_BUILD_ARCHS_COMMUNITY = $(addprefix docker-build-community-,$(ARCHS))
DOCKERFILE_COMMUNITY = $(SRC)/pkg/query-service/Dockerfile
DOCKERFILE_COMMUNITY = $(SRC)/cmd/community/Dockerfile
DOCKER_REGISTRY_COMMUNITY ?= docker.io/signoz/signoz-community
DOCKER_BUILD_ARCHS_ENTERPRISE = $(addprefix docker-build-enterprise-,$(ARCHS))
DOCKERFILE_ENTERPRISE = $(SRC)/ee/query-service/Dockerfile
DOCKERFILE_ENTERPRISE = $(SRC)/cmd/enterprise/Dockerfile
DOCKER_REGISTRY_ENTERPRISE ?= docker.io/signoz/signoz
JS_BUILD_CONTEXT = $(SRC)/frontend
@@ -74,7 +74,7 @@ go-run-enterprise: ## Runs the enterprise go backend server
SIGNOZ_TELEMETRYSTORE_PROVIDER=clickhouse \
SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_DSN=tcp://127.0.0.1:9000 \
go run -race \
$(GO_BUILD_CONTEXT_ENTERPRISE)/main.go \
$(GO_BUILD_CONTEXT_ENTERPRISE)/*.go \
--config ./conf/prometheus.yml \
--cluster cluster
@@ -92,7 +92,7 @@ go-run-community: ## Runs the community go backend server
SIGNOZ_TELEMETRYSTORE_PROVIDER=clickhouse \
SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_DSN=tcp://127.0.0.1:9000 \
go run -race \
$(GO_BUILD_CONTEXT_COMMUNITY)/main.go \
$(GO_BUILD_CONTEXT_COMMUNITY)/*.go \
--config ./conf/prometheus.yml \
--cluster cluster

View File

@@ -8,7 +8,6 @@
<p align="center">All your logs, metrics, and traces in one place. Monitor your application, spot issues before they occur and troubleshoot downtime quickly with rich context. SigNoz is a cost-effective open-source alternative to Datadog and New Relic. Visit <a href="https://signoz.io" target="_blank">signoz.io</a> for the full documentation, tutorials, and guide.</p>
<p align="center">
<img alt="Downloads" src="https://img.shields.io/docker/pulls/signoz/signoz.svg?label=Docker%20Downloads"> </a>
<img alt="GitHub issues" src="https://img.shields.io/github/issues/signoz/signoz"> </a>
<a href="https://twitter.com/intent/tweet?text=Monitor%20your%20applications%20and%20troubleshoot%20problems%20with%20SigNoz,%20an%20open-source%20alternative%20to%20DataDog,%20NewRelic.&url=https://signoz.io/&via=SigNozHQ&hashtags=opensource,signoz,observability">
<img alt="tweet" src="https://img.shields.io/twitter/url/http/shields.io.svg?style=social"> </a>

View File

@@ -11,7 +11,7 @@ before:
builds:
- id: signoz
binary: bin/signoz
main: pkg/query-service/main.go
main: cmd/community
env:
- CGO_ENABLED=1
- >-

View File

@@ -16,4 +16,4 @@ COPY frontend/build/ /etc/signoz/web/
RUN chmod 755 /root /root/signoz
ENTRYPOINT ["./signoz"]
ENTRYPOINT ["./signoz", "server"]

View File

@@ -17,4 +17,4 @@ COPY frontend/build/ /etc/signoz/web/
RUN chmod 755 /root /root/signoz-community
ENTRYPOINT ["./signoz-community"]
ENTRYPOINT ["./signoz-community", "server"]

18
cmd/community/main.go Normal file
View File

@@ -0,0 +1,18 @@
package main
import (
"log/slog"
"github.com/SigNoz/signoz/cmd"
"github.com/SigNoz/signoz/pkg/instrumentation"
)
func main() {
// initialize logger for logging in the cmd/ package. This logger is different from the logger used in the application.
logger := instrumentation.NewLogger(instrumentation.Config{Logs: instrumentation.LogsConfig{Level: slog.LevelInfo}})
// register a list of commands to the root command
registerServer(cmd.RootCmd, logger)
cmd.Execute(logger)
}

116
cmd/community/server.go Normal file
View File

@@ -0,0 +1,116 @@
package main
import (
"context"
"log/slog"
"time"
"github.com/SigNoz/signoz/cmd"
"github.com/SigNoz/signoz/ee/sqlstore/postgressqlstore"
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/licensing/nooplicensing"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/query-service/app"
"github.com/SigNoz/signoz/pkg/signoz"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/sqlstore/sqlstorehook"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/version"
"github.com/SigNoz/signoz/pkg/zeus"
"github.com/SigNoz/signoz/pkg/zeus/noopzeus"
"github.com/spf13/cobra"
)
func registerServer(parentCmd *cobra.Command, logger *slog.Logger) {
var flags signoz.DeprecatedFlags
serverCmd := &cobra.Command{
Use: "server",
Short: "Run the SigNoz server",
FParseErrWhitelist: cobra.FParseErrWhitelist{UnknownFlags: true},
RunE: func(currCmd *cobra.Command, args []string) error {
config, err := cmd.NewSigNozConfig(currCmd.Context(), flags)
if err != nil {
return err
}
return runServer(currCmd.Context(), config, logger)
},
}
flags.RegisterFlags(serverCmd)
parentCmd.AddCommand(serverCmd)
}
func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) error {
// print the version
version.Info.PrettyPrint(config.Version)
// add enterprise sqlstore factories to the community sqlstore factories
sqlstoreFactories := signoz.NewSQLStoreProviderFactories()
if err := sqlstoreFactories.Add(postgressqlstore.NewFactory(sqlstorehook.NewLoggingFactory())); err != nil {
logger.ErrorContext(ctx, "failed to add postgressqlstore factory", "error", err)
return err
}
jwt := authtypes.NewJWT(cmd.NewJWTSecret(ctx, logger), 30*time.Minute, 30*24*time.Hour)
signoz, err := signoz.New(
ctx,
config,
jwt,
zeus.Config{},
noopzeus.NewProviderFactory(),
licensing.Config{},
func(_ sqlstore.SQLStore, _ zeus.Zeus, _ organization.Getter, _ analytics.Analytics) factory.ProviderFactory[licensing.Licensing, licensing.Config] {
return nooplicensing.NewFactory()
},
signoz.NewEmailingProviderFactories(),
signoz.NewCacheProviderFactories(),
signoz.NewWebProviderFactories(),
func(sqlstore sqlstore.SQLStore) factory.NamedMap[factory.ProviderFactory[sqlschema.SQLSchema, sqlschema.Config]] {
return signoz.NewSQLSchemaProviderFactories(sqlstore)
},
signoz.NewSQLStoreProviderFactories(),
signoz.NewTelemetryStoreProviderFactories(),
)
if err != nil {
logger.ErrorContext(ctx, "failed to create signoz", "error", err)
return err
}
server, err := app.NewServer(config, signoz, jwt)
if err != nil {
logger.ErrorContext(ctx, "failed to create server", "error", err)
return err
}
if err := server.Start(ctx); err != nil {
logger.ErrorContext(ctx, "failed to start server", "error", err)
return err
}
signoz.Start(ctx)
if err := signoz.Wait(ctx); err != nil {
logger.ErrorContext(ctx, "failed to start signoz", "error", err)
return err
}
err = server.Stop(ctx)
if err != nil {
logger.ErrorContext(ctx, "failed to stop server", "error", err)
return err
}
err = signoz.Stop(ctx)
if err != nil {
logger.ErrorContext(ctx, "failed to stop signoz", "error", err)
return err
}
return nil
}

45
cmd/config.go Normal file
View File

@@ -0,0 +1,45 @@
package cmd
import (
"context"
"fmt"
"log/slog"
"os"
"github.com/SigNoz/signoz/pkg/config"
"github.com/SigNoz/signoz/pkg/config/envprovider"
"github.com/SigNoz/signoz/pkg/config/fileprovider"
"github.com/SigNoz/signoz/pkg/signoz"
)
func NewSigNozConfig(ctx context.Context, flags signoz.DeprecatedFlags) (signoz.Config, error) {
config, err := signoz.NewConfig(
ctx,
config.ResolverConfig{
Uris: []string{"env:"},
ProviderFactories: []config.ProviderFactory{
envprovider.NewFactory(),
fileprovider.NewFactory(),
},
},
flags,
)
if err != nil {
return signoz.Config{}, err
}
return config, nil
}
func NewJWTSecret(_ context.Context, _ *slog.Logger) string {
jwtSecret := os.Getenv("SIGNOZ_JWT_SECRET")
if len(jwtSecret) == 0 {
fmt.Println("🚨 CRITICAL SECURITY ISSUE: No JWT secret key specified!")
fmt.Println("SIGNOZ_JWT_SECRET environment variable is not set. This has dire consequences for the security of the application.")
fmt.Println("Without a JWT secret, user sessions are vulnerable to tampering and unauthorized access.")
fmt.Println("Please set the SIGNOZ_JWT_SECRET environment variable immediately.")
fmt.Println("For more information, please refer to https://github.com/SigNoz/signoz/issues/8400.")
}
return jwtSecret
}

View File

@@ -11,7 +11,7 @@ before:
builds:
- id: signoz
binary: bin/signoz
main: ee/query-service/main.go
main: cmd/enterprise
env:
- CGO_ENABLED=1
- >-

View File

@@ -16,4 +16,4 @@ COPY frontend/build/ /etc/signoz/web/
RUN chmod 755 /root /root/signoz
ENTRYPOINT ["./signoz"]
ENTRYPOINT ["./signoz", "server"]

View File

@@ -23,6 +23,7 @@ COPY go.mod go.sum ./
RUN go mod download
COPY ./cmd/ ./cmd/
COPY ./ee/ ./ee/
COPY ./pkg/ ./pkg/
COPY ./templates/email /root/templates
@@ -33,4 +34,4 @@ RUN mv /root/linux-${TARGETARCH}/signoz /root/signoz
RUN chmod 755 /root /root/signoz
ENTRYPOINT ["/root/signoz"]
ENTRYPOINT ["/root/signoz", "server"]

View File

@@ -17,4 +17,4 @@ COPY frontend/build/ /etc/signoz/web/
RUN chmod 755 /root /root/signoz
ENTRYPOINT ["./signoz"]
ENTRYPOINT ["./signoz", "server"]

18
cmd/enterprise/main.go Normal file
View File

@@ -0,0 +1,18 @@
package main
import (
"log/slog"
"github.com/SigNoz/signoz/cmd"
"github.com/SigNoz/signoz/pkg/instrumentation"
)
func main() {
// initialize logger for logging in the cmd/ package. This logger is different from the logger used in the application.
logger := instrumentation.NewLogger(instrumentation.Config{Logs: instrumentation.LogsConfig{Level: slog.LevelInfo}})
// register a list of commands to the root command
registerServer(cmd.RootCmd, logger)
cmd.Execute(logger)
}

124
cmd/enterprise/server.go Normal file
View File

@@ -0,0 +1,124 @@
package main
import (
"context"
"log/slog"
"time"
"github.com/SigNoz/signoz/cmd"
enterpriselicensing "github.com/SigNoz/signoz/ee/licensing"
"github.com/SigNoz/signoz/ee/licensing/httplicensing"
enterpriseapp "github.com/SigNoz/signoz/ee/query-service/app"
"github.com/SigNoz/signoz/ee/sqlschema/postgressqlschema"
"github.com/SigNoz/signoz/ee/sqlstore/postgressqlstore"
enterprisezeus "github.com/SigNoz/signoz/ee/zeus"
"github.com/SigNoz/signoz/ee/zeus/httpzeus"
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/signoz"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/sqlstore/sqlstorehook"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/version"
"github.com/SigNoz/signoz/pkg/zeus"
"github.com/spf13/cobra"
)
func registerServer(parentCmd *cobra.Command, logger *slog.Logger) {
var flags signoz.DeprecatedFlags
serverCmd := &cobra.Command{
Use: "server",
Short: "Run the SigNoz server",
FParseErrWhitelist: cobra.FParseErrWhitelist{UnknownFlags: true},
RunE: func(currCmd *cobra.Command, args []string) error {
config, err := cmd.NewSigNozConfig(currCmd.Context(), flags)
if err != nil {
return err
}
return runServer(currCmd.Context(), config, logger)
},
}
flags.RegisterFlags(serverCmd)
parentCmd.AddCommand(serverCmd)
}
func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) error {
// print the version
version.Info.PrettyPrint(config.Version)
// add enterprise sqlstore factories to the community sqlstore factories
sqlstoreFactories := signoz.NewSQLStoreProviderFactories()
if err := sqlstoreFactories.Add(postgressqlstore.NewFactory(sqlstorehook.NewLoggingFactory())); err != nil {
logger.ErrorContext(ctx, "failed to add postgressqlstore factory", "error", err)
return err
}
jwt := authtypes.NewJWT(cmd.NewJWTSecret(ctx, logger), 30*time.Minute, 30*24*time.Hour)
signoz, err := signoz.New(
ctx,
config,
jwt,
enterprisezeus.Config(),
httpzeus.NewProviderFactory(),
enterpriselicensing.Config(24*time.Hour, 3),
func(sqlstore sqlstore.SQLStore, zeus zeus.Zeus, orgGetter organization.Getter, analytics analytics.Analytics) factory.ProviderFactory[licensing.Licensing, licensing.Config] {
return httplicensing.NewProviderFactory(sqlstore, zeus, orgGetter, analytics)
},
signoz.NewEmailingProviderFactories(),
signoz.NewCacheProviderFactories(),
signoz.NewWebProviderFactories(),
func(sqlstore sqlstore.SQLStore) factory.NamedMap[factory.ProviderFactory[sqlschema.SQLSchema, sqlschema.Config]] {
existingFactories := signoz.NewSQLSchemaProviderFactories(sqlstore)
if err := existingFactories.Add(postgressqlschema.NewFactory(sqlstore)); err != nil {
panic(err)
}
return existingFactories
},
sqlstoreFactories,
signoz.NewTelemetryStoreProviderFactories(),
)
if err != nil {
logger.ErrorContext(ctx, "failed to create signoz", "error", err)
return err
}
server, err := enterpriseapp.NewServer(config, signoz, jwt)
if err != nil {
logger.ErrorContext(ctx, "failed to create server", "error", err)
return err
}
if err := server.Start(ctx); err != nil {
logger.ErrorContext(ctx, "failed to start server", "error", err)
return err
}
signoz.Start(ctx)
if err := signoz.Wait(ctx); err != nil {
logger.ErrorContext(ctx, "failed to start signoz", "error", err)
return err
}
err = server.Stop(ctx)
if err != nil {
logger.ErrorContext(ctx, "failed to stop server", "error", err)
return err
}
err = signoz.Stop(ctx)
if err != nil {
logger.ErrorContext(ctx, "failed to stop signoz", "error", err)
return err
}
return nil
}

33
cmd/root.go Normal file
View File

@@ -0,0 +1,33 @@
package cmd
import (
"log/slog"
"os"
"github.com/SigNoz/signoz/pkg/version"
"github.com/spf13/cobra"
"go.uber.org/zap" //nolint:depguard
)
var RootCmd = &cobra.Command{
Use: "signoz",
Short: "OpenTelemetry-Native Logs, Metrics and Traces in a single pane",
Version: version.Info.Version(),
SilenceUsage: true,
SilenceErrors: true,
CompletionOptions: cobra.CompletionOptions{DisableDefaultCmd: true},
}
func Execute(logger *slog.Logger) {
zapLogger := newZapLogger()
zap.ReplaceGlobals(zapLogger)
defer func() {
_ = zapLogger.Sync()
}()
err := RootCmd.Execute()
if err != nil {
logger.ErrorContext(RootCmd.Context(), "error running command", "error", err)
os.Exit(1)
}
}

15
cmd/zap.go Normal file
View File

@@ -0,0 +1,15 @@
package cmd
import (
"go.uber.org/zap" //nolint:depguard
"go.uber.org/zap/zapcore" //nolint:depguard
)
// Deprecated: Use `NewLogger` from `pkg/instrumentation` instead.
func newZapLogger() *zap.Logger {
config := zap.NewProductionConfig()
config.EncoderConfig.TimeKey = "timestamp"
config.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
logger, _ := config.Build()
return logger
}

View File

@@ -1,4 +0,0 @@
.vscode
README.md
signoz.db
bin

View File

@@ -1,189 +0,0 @@
package main
import (
"context"
"flag"
"os"
"time"
"github.com/SigNoz/signoz/ee/licensing"
"github.com/SigNoz/signoz/ee/licensing/httplicensing"
"github.com/SigNoz/signoz/ee/query-service/app"
"github.com/SigNoz/signoz/ee/sqlschema/postgressqlschema"
"github.com/SigNoz/signoz/ee/sqlstore/postgressqlstore"
"github.com/SigNoz/signoz/ee/zeus"
"github.com/SigNoz/signoz/ee/zeus/httpzeus"
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/config"
"github.com/SigNoz/signoz/pkg/config/envprovider"
"github.com/SigNoz/signoz/pkg/config/fileprovider"
"github.com/SigNoz/signoz/pkg/factory"
pkglicensing "github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/modules/organization"
baseconst "github.com/SigNoz/signoz/pkg/query-service/constants"
"github.com/SigNoz/signoz/pkg/signoz"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/sqlstore/sqlstorehook"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/version"
pkgzeus "github.com/SigNoz/signoz/pkg/zeus"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
// Deprecated: Please use the logger from pkg/instrumentation.
func initZapLog() *zap.Logger {
config := zap.NewProductionConfig()
config.EncoderConfig.TimeKey = "timestamp"
config.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
logger, _ := config.Build()
return logger
}
func main() {
var promConfigPath, skipTopLvlOpsPath string
// disables rule execution but allows change to the rule definition
var disableRules bool
// the url used to build link in the alert messages in slack and other systems
var ruleRepoURL string
var cluster string
var useLogsNewSchema bool
var useTraceNewSchema bool
var cacheConfigPath, fluxInterval, fluxIntervalForTraceDetail string
var preferSpanMetrics bool
var maxIdleConns int
var maxOpenConns int
var dialTimeout time.Duration
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)")
// Deprecated
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()
loggerMgr := initZapLog()
zap.ReplaceGlobals(loggerMgr)
defer loggerMgr.Sync() // flushes buffer, if any
ctx := context.Background()
config, err := signoz.NewConfig(ctx, config.ResolverConfig{
Uris: []string{"env:"},
ProviderFactories: []config.ProviderFactory{
envprovider.NewFactory(),
fileprovider.NewFactory(),
},
}, signoz.DeprecatedFlags{
MaxIdleConns: maxIdleConns,
MaxOpenConns: maxOpenConns,
DialTimeout: dialTimeout,
Config: promConfigPath,
FluxInterval: fluxInterval,
FluxIntervalForTraceDetail: fluxIntervalForTraceDetail,
Cluster: cluster,
GatewayUrl: gatewayUrl,
})
if err != nil {
zap.L().Fatal("Failed to create config", zap.Error(err))
}
version.Info.PrettyPrint(config.Version)
sqlStoreFactories := signoz.NewSQLStoreProviderFactories()
if err := sqlStoreFactories.Add(postgressqlstore.NewFactory(sqlstorehook.NewLoggingFactory())); err != nil {
zap.L().Fatal("Failed to add postgressqlstore factory", zap.Error(err))
}
jwtSecret := os.Getenv("SIGNOZ_JWT_SECRET")
if len(jwtSecret) == 0 {
zap.L().Warn("No JWT secret key is specified.")
} else {
zap.L().Info("JWT secret key set successfully.")
}
jwt := authtypes.NewJWT(jwtSecret, 30*time.Minute, 30*24*time.Hour)
signoz, err := signoz.New(
context.Background(),
config,
jwt,
zeus.Config(),
httpzeus.NewProviderFactory(),
licensing.Config(24*time.Hour, 3),
func(sqlstore sqlstore.SQLStore, zeus pkgzeus.Zeus, orgGetter organization.Getter, analytics analytics.Analytics) factory.ProviderFactory[pkglicensing.Licensing, pkglicensing.Config] {
return httplicensing.NewProviderFactory(sqlstore, zeus, orgGetter, analytics)
},
signoz.NewEmailingProviderFactories(),
signoz.NewCacheProviderFactories(),
signoz.NewWebProviderFactories(),
func(sqlstore sqlstore.SQLStore) factory.NamedMap[factory.ProviderFactory[sqlschema.SQLSchema, sqlschema.Config]] {
existingFactories := signoz.NewSQLSchemaProviderFactories(sqlstore)
if err := existingFactories.Add(postgressqlschema.NewFactory(sqlstore)); err != nil {
zap.L().Fatal("Failed to add postgressqlschema factory", zap.Error(err))
}
return existingFactories
},
sqlStoreFactories,
signoz.NewTelemetryStoreProviderFactories(),
)
if err != nil {
zap.L().Fatal("Failed to create signoz", zap.Error(err))
}
server, err := app.NewServer(config, signoz, jwt)
if err != nil {
zap.L().Fatal("Failed to create server", zap.Error(err))
}
if err := server.Start(ctx); err != nil {
zap.L().Fatal("Could not start server", zap.Error(err))
}
signoz.Start(ctx)
if err := signoz.Wait(ctx); err != nil {
zap.L().Fatal("Failed to start signoz", zap.Error(err))
}
err = server.Stop(ctx)
if err != nil {
zap.L().Fatal("Failed to stop server", zap.Error(err))
}
err = signoz.Stop(ctx)
if err != nil {
zap.L().Fatal("Failed to stop signoz", zap.Error(err))
}
}

View File

@@ -235,7 +235,7 @@
"sharp": "^0.33.4",
"ts-jest": "^27.1.5",
"ts-node": "^10.2.1",
"typescript-plugin-css-modules": "5.0.1",
"typescript-plugin-css-modules": "5.1.0",
"webpack-bundle-analyzer": "^4.5.0",
"webpack-cli": "^5.1.4"
},

View File

@@ -8,5 +8,6 @@
"actNow": "Act now to avoid any disruptions and continue where you left off.",
"contactAdmin": "Contact your admin to proceed with the upgrade.",
"continueMyJourney": "Settle your bill to continue",
"somethingWentWrong": "Something went wrong"
"somethingWentWrong": "Something went wrong",
"refreshPaymentStatus": "Refresh Status"
}

View File

@@ -8,5 +8,6 @@
"actNow": "Act now to avoid any disruptions and continue where you left off.",
"contactAdmin": "Contact your admin to proceed with the upgrade.",
"continueMyJourney": "Settle your bill to continue",
"somethingWentWrong": "Something went wrong"
"somethingWentWrong": "Something went wrong",
"refreshPaymentStatus": "Refresh Status"
}

View File

@@ -3,6 +3,7 @@ import { ConfigProvider } from 'antd';
import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
import logEvent from 'api/common/logEvent';
import AppLoading from 'components/AppLoading/AppLoading';
import NotFound from 'components/NotFound';
import Spinner from 'components/Spinner';
import UserpilotRouteTracker from 'components/UserpilotRouteTracker/UserpilotRouteTracker';
@@ -342,7 +343,7 @@ function App(): JSX.Element {
if (isLoggedInState) {
// if the setup calls are loading then return a spinner
if (isFetchingActiveLicense || isFetchingUser || isFetchingFeatureFlags) {
return <Spinner tip="Loading..." />;
return <AppLoading />;
}
// if the required calls fails then return a something went wrong error

View File

@@ -2,13 +2,20 @@ import axios from 'api';
import { PayloadProps, Props } from 'types/api/metrics/getTopOperations';
const getTopOperations = async (props: Props): Promise<PayloadProps> => {
const response = await axios.post(`/service/top_operations`, {
const endpoint = props.isEntryPoint
? '/service/entry_point_operations'
: '/service/top_operations';
const response = await axios.post(endpoint, {
start: `${props.start}`,
end: `${props.end}`,
service: props.service,
tags: props.selectedTags,
});
if (props.isEntryPoint) {
return response.data.data;
}
return response.data;
};

View File

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

View File

@@ -2,15 +2,11 @@ import { ApiV3Instance as axios } from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/licenses/apply';
import { PayloadProps } from 'types/api/licenses/apply';
const apply = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
const apply = async (): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.post<PayloadProps>('/licenses', {
key: props.key,
});
const response = await axios.put<PayloadProps>('/licenses');
return {
httpStatusCode: response.status,

View File

@@ -0,0 +1,152 @@
.app-loading-container {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background-color: var(--bg-ink-400, #121317); // Dark theme background
.app-loading-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
.brand {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 12px;
.brand-logo {
width: 40px;
height: 40px;
}
.brand-title {
font-size: 20px;
font-weight: 600;
color: var(--bg-vanilla-100, #ffffff); // White text for dark theme
margin: 0;
}
}
.brand-tagline {
margin-bottom: 24px;
.ant-typography {
color: var(--bg-vanilla-400, #c0c1c3); // Light gray text for dark theme
}
}
/* HTML: <div class="loader"></div> */
.loader {
width: 150px;
height: 12px;
border-radius: 2px;
color: var(--bg-robin-500, #4e74f8); // Primary blue color
border: 2px solid;
position: relative;
}
.loader::before {
content: '';
position: absolute;
margin: 2px;
inset: 0 100% 0 0;
border-radius: inherit;
background: currentColor;
animation: l6 2s infinite;
}
@keyframes l6 {
100% {
inset: 0;
}
}
}
}
// Light theme styles - more specific selector
.app-loading-container.lightMode {
background-color: var(
--bg-vanilla-100,
#ffffff
) !important; // White background for light theme
.app-loading-content {
.brand {
.brand-title {
color: var(--bg-ink-400, #121317) !important; // Dark text for light theme
}
}
.brand-tagline {
.ant-typography {
color: var(
--bg-ink-300,
#6b7280
) !important; // Dark gray text for light theme
}
}
.loader {
color: var(
--bg-robin-500,
#4e74f8
) !important; // Keep primary blue color for consistency
}
}
}
.perilin-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: radial-gradient(circle, #fff 10%, transparent 0);
background-size: 12px 12px;
opacity: 1;
mask-image: radial-gradient(
circle at 50% 0,
rgba(11, 12, 14, 0.1) 0,
rgba(11, 12, 14, 0) 100%
);
-webkit-mask-image: radial-gradient(
circle at 50% 0,
rgba(11, 12, 14, 0.1) 0,
rgba(11, 12, 14, 0) 100%
);
}
// Dark theme styles - ensure dark theme is properly applied
.app-loading-container.dark {
background-color: var(--bg-ink-400, #121317) !important; // Dark background
.app-loading-content {
.brand {
.brand-title {
color: var(
--bg-vanilla-100,
#ffffff
) !important; // White text for dark theme
}
}
.brand-tagline {
.ant-typography {
color: var(
--bg-vanilla-400,
#c0c1c3
) !important; // Light gray text for dark theme
}
}
.loader {
color: var(--bg-robin-500, #4e74f8) !important; // Primary blue color
}
}
}

View File

@@ -0,0 +1,50 @@
import './AppLoading.styles.scss';
import { Typography } from 'antd';
import get from 'api/browser/localstorage/get';
import { LOCALSTORAGE } from 'constants/localStorage';
import { THEME_MODE } from 'hooks/useDarkMode/constant';
function AppLoading(): JSX.Element {
// Get theme from localStorage directly to avoid context dependency
const getThemeFromStorage = (): boolean => {
try {
const theme = get(LOCALSTORAGE.THEME);
return theme !== THEME_MODE.LIGHT; // Return true for dark, false for light
} catch (error) {
// If localStorage is not available, default to dark theme
return true;
}
};
const isDarkMode = getThemeFromStorage();
return (
<div className={`app-loading-container ${isDarkMode ? 'dark' : 'lightMode'}`}>
<div className="perilin-bg" />
<div className="app-loading-content">
<div className="brand">
<img
src="/Logos/signoz-brand-logo.svg"
alt="SigNoz"
className="brand-logo"
/>
<Typography.Title level={2} className="brand-title">
SigNoz
</Typography.Title>
</div>
<div className="brand-tagline">
<Typography.Text>
OpenTelemetry-Native Logs, Metrics and Traces in a single pane
</Typography.Text>
</div>
<div className="loader" />
</div>
</div>
);
}
export default AppLoading;

View File

@@ -0,0 +1,76 @@
import { render, screen } from '@testing-library/react';
import AppLoading from '../AppLoading';
// Mock the localStorage API
const mockGet = jest.fn();
jest.mock('api/browser/localstorage/get', () => ({
__esModule: true,
default: mockGet,
}));
describe('AppLoading', () => {
const SIGNOZ_TEXT = 'SigNoz';
const TAGLINE_TEXT =
'OpenTelemetry-Native Logs, Metrics and Traces in a single pane';
const CONTAINER_SELECTOR = '.app-loading-container';
beforeEach(() => {
jest.clearAllMocks();
});
it('should render loading screen with dark theme by default', () => {
// Mock localStorage to return dark theme (or undefined for default)
mockGet.mockReturnValue(undefined);
render(<AppLoading />);
// Check if main elements are rendered
expect(screen.getByAltText(SIGNOZ_TEXT)).toBeInTheDocument();
expect(screen.getByText(SIGNOZ_TEXT)).toBeInTheDocument();
expect(screen.getByText(TAGLINE_TEXT)).toBeInTheDocument();
// Check if dark theme class is applied
const container = screen.getByText(SIGNOZ_TEXT).closest(CONTAINER_SELECTOR);
expect(container).toHaveClass('dark');
expect(container).not.toHaveClass('lightMode');
});
it('should have proper structure and content', () => {
// Mock localStorage to return dark theme
mockGet.mockReturnValue(undefined);
render(<AppLoading />);
// Check for brand logo
const logo = screen.getByAltText(SIGNOZ_TEXT);
expect(logo).toBeInTheDocument();
expect(logo).toHaveAttribute('src', '/Logos/signoz-brand-logo.svg');
// Check for brand title
const title = screen.getByText(SIGNOZ_TEXT);
expect(title).toBeInTheDocument();
// Check for tagline
const tagline = screen.getByText(TAGLINE_TEXT);
expect(tagline).toBeInTheDocument();
// Check for loader
const loader = document.querySelector('.loader');
expect(loader).toBeInTheDocument();
});
it('should handle localStorage errors gracefully', () => {
// Mock localStorage to throw an error
mockGet.mockImplementation(() => {
throw new Error('localStorage not available');
});
render(<AppLoading />);
// Should still render with dark theme as fallback
expect(screen.getByText(SIGNOZ_TEXT)).toBeInTheDocument();
const container = screen.getByText(SIGNOZ_TEXT).closest(CONTAINER_SELECTOR);
expect(container).toHaveClass('dark');
});
});

View File

@@ -370,6 +370,7 @@ function CustomTimePicker({
onFocus={handleFocus}
onBlur={handleBlur}
onChange={handleInputChange}
data-1p-ignore
prefix={
inputValue && inputStatus === 'success' ? (
<CheckCircle size={14} color="#51E7A8" />

View File

@@ -84,6 +84,7 @@ function RangePickerModal(props: RangePickerModalProps): JSX.Element {
date.tz(timezone.value).format(DATE_TIME_FORMATS.ISO_DATETIME)
}
onOk={onModalOkHandler}
data-1p-ignore
{...(selectedTime === 'custom' &&
!onTimeChange && {
value: rangeValue,

View File

@@ -72,6 +72,7 @@ function SearchBar({
onKeyDown={handleKeyDown}
tabIndex={0}
autoFocus
data-1p-ignore
/>
</div>
<kbd className="timezone-picker__esc-key">esc</kbd>

View File

@@ -9,6 +9,7 @@ import cx from 'classnames';
import { LogType } from 'components/Logs/LogStateIndicator/LogStateIndicator';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import ContextView from 'container/LogDetailedView/ContextView/ContextView';
import InfraMetrics from 'container/LogDetailedView/InfraMetrics/InfraMetrics';
@@ -26,7 +27,7 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import createQueryParams from 'lib/createQueryParams';
import {
BarChart2,
Braces,
@@ -71,7 +72,7 @@ function LogDetail({
const [contextQuery, setContextQuery] = useState<Query | undefined>();
const [filters, setFilters] = useState<TagFilter | null>(null);
const [isEdit, setIsEdit] = useState<boolean>(false);
const { stagedQuery } = useQueryBuilder();
const { stagedQuery, updateAllQueriesOperators } = useQueryBuilder();
const listQuery = useMemo(() => {
if (!stagedQuery || stagedQuery.builder.queryData.length < 1) return null;
@@ -88,7 +89,6 @@ function LogDetail({
const isDarkMode = useIsDarkMode();
const location = useLocation();
const { safeNavigate } = useSafeNavigate();
const urlQuery = useUrlQuery();
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
@@ -136,10 +136,19 @@ function LogDetail({
// Go to logs explorer page with the log data
const handleOpenInExplorer = (): void => {
urlQuery.set(QueryParams.activeLogId, `"${log?.id}"`);
urlQuery.set(QueryParams.startTime, minTime?.toString() || '');
urlQuery.set(QueryParams.endTime, maxTime?.toString() || '');
safeNavigate(`${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`);
const queryParams = {
[QueryParams.activeLogId]: `"${log?.id}"`,
[QueryParams.startTime]: minTime?.toString() || '',
[QueryParams.endTime]: maxTime?.toString() || '',
[QueryParams.compositeQuery]: JSON.stringify(
updateAllQueriesOperators(
initialQueriesMap[DataSource.LOGS],
PANEL_TYPES.LIST,
DataSource.LOGS,
),
),
};
safeNavigate(`${ROUTES.LOGS_EXPLORER}?${createQueryParams(queryParams)}`);
};
// Only show when opened from infra monitoring page

View File

@@ -0,0 +1,56 @@
import { Button, Tooltip } from 'antd';
import refreshPaymentStatus from 'api/v3/licenses/put';
import cx from 'classnames';
import { RefreshCcw } from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
function RefreshPaymentStatus({
btnShape,
type,
}: {
btnShape?: 'default' | 'round' | 'circle';
type?: 'button' | 'text' | 'tooltip';
}): JSX.Element {
const { t } = useTranslation(['failedPayment']);
const { activeLicenseRefetch } = useAppContext();
const [isLoading, setIsLoading] = useState(false);
const handleRefreshPaymentStatus = async (): Promise<void> => {
setIsLoading(true);
try {
await refreshPaymentStatus();
await Promise.all([activeLicenseRefetch()]);
} catch (e) {
console.error(e);
}
setIsLoading(false);
};
return (
<span className="refresh-payment-status-btn-wrapper">
<Tooltip title={type === 'tooltip' ? t('refreshPaymentStatus') : ''}>
<Button
type={type === 'text' ? 'text' : 'default'}
shape={btnShape}
className={cx('periscope-btn', { text: type === 'text' })}
onClick={handleRefreshPaymentStatus}
icon={<RefreshCcw size={14} />}
loading={isLoading}
>
{type !== 'tooltip' ? t('refreshPaymentStatus') : ''}
</Button>
</Tooltip>
</span>
);
}
RefreshPaymentStatus.defaultProps = {
btnShape: 'default',
type: 'button',
};
export default RefreshPaymentStatus;

View File

@@ -1,6 +1,12 @@
.signoz-radio-group.ant-radio-group {
color: var(--text-vanilla-400);
&.ant-radio-group-disabled {
opacity: 0.5;
pointer-events: none;
cursor: not-allowed;
}
.view-title {
display: flex;
gap: var(--margin-2);
@@ -37,6 +43,22 @@
// Light mode styles
.lightMode {
.signoz-radio-group {
&.ant-radio-group-disabled {
.tab,
.selected_view {
background: var(--bg-vanilla-200) !important;
border-color: var(--bg-vanilla-400) !important;
color: var(--text-ink-400) !important;
}
.tab:hover,
.selected_view:hover {
background: var(--bg-vanilla-200) !important;
border-color: var(--bg-vanilla-400) !important;
color: var(--text-ink-400) !important;
}
}
.tab {
background: var(--bg-vanilla-100);
}

View File

@@ -13,6 +13,7 @@ interface SignozRadioGroupProps {
options: Option[];
onChange: (e: RadioChangeEvent) => void;
className?: string;
disabled?: boolean;
}
function SignozRadioGroup({
@@ -20,6 +21,7 @@ function SignozRadioGroup({
options,
onChange,
className = '',
disabled = false,
}: SignozRadioGroupProps): JSX.Element {
return (
<Radio.Group
@@ -27,6 +29,7 @@ function SignozRadioGroup({
buttonStyle="solid"
className={`signoz-radio-group ${className}`}
onChange={onChange}
disabled={disabled}
>
{options.map((option) => (
<Radio.Button
@@ -43,6 +46,7 @@ function SignozRadioGroup({
SignozRadioGroup.defaultProps = {
className: '',
disabled: false,
};
export default SignozRadioGroup;

View File

@@ -4,6 +4,7 @@ export enum LOCALSTORAGE {
AUTH_TOKEN = 'AUTH_TOKEN',
REFRESH_AUTH_TOKEN = 'REFRESH_AUTH_TOKEN',
THEME = 'THEME',
THEME_AUTO_SWITCH = 'THEME_AUTO_SWITCH',
LOGS_VIEW_MODE = 'LOGS_VIEW_MODE',
LOGS_LINES_PER_ROW = 'LOGS_LINES_PER_ROW',
LOGS_LIST_OPTIONS = 'LOGS_LIST_OPTIONS',

View File

@@ -65,6 +65,7 @@ type QueryParams = {
pageSize: number;
exceptionType?: string;
serviceName?: string;
compositeQuery?: string;
};
function AllErrors(): JSX.Element {
@@ -81,6 +82,7 @@ function AllErrors(): JSX.Element {
getUpdatedPageSize,
getUpdatedExceptionType,
getUpdatedServiceName,
getUpdatedCompositeQuery,
} = useMemo(
() => ({
updatedOrder: getOrder(params.get(urlKey.order)),
@@ -89,6 +91,7 @@ function AllErrors(): JSX.Element {
getUpdatedPageSize: getUpdatePageSize(params.get(urlKey.pageSize)),
getUpdatedExceptionType: getFilterString(params.get(urlKey.exceptionType)),
getUpdatedServiceName: getFilterString(params.get(urlKey.serviceName)),
getUpdatedCompositeQuery: getFilterString(params.get(urlKey.compositeQuery)),
}),
[params],
);
@@ -203,6 +206,7 @@ function AllErrors(): JSX.Element {
offset: getUpdatedOffset,
orderParam: getUpdatedParams,
pageSize: getUpdatedPageSize,
compositeQuery: getUpdatedCompositeQuery,
};
if (exceptionFilterValue && exceptionFilterValue !== 'undefined') {
@@ -222,6 +226,7 @@ function AllErrors(): JSX.Element {
getUpdatedPageSize,
getUpdatedParams,
getUpdatedServiceName,
getUpdatedCompositeQuery,
pathname,
updatedOrder,
],
@@ -430,6 +435,7 @@ function AllErrors(): JSX.Element {
serviceName: getFilterString(params.get(urlKey.serviceName)),
exceptionType: getFilterString(params.get(urlKey.exceptionType)),
});
const compositeQuery = params.get(urlKey.compositeQuery) || '';
history.replace(
`${pathname}?${createQueryParams({
order: updatedOrder,
@@ -438,6 +444,7 @@ function AllErrors(): JSX.Element {
pageSize,
exceptionType,
serviceName,
compositeQuery,
})}`,
);
}

View File

@@ -18,6 +18,7 @@ export const urlKey = {
pageSize: 'pageSize',
exceptionType: 'exceptionType',
serviceName: 'serviceName',
compositeQuery: 'compositeQuery',
};
export const isOrderParams = (orderBy: string | null): orderBy is OrderBy =>

View File

@@ -4,6 +4,21 @@
.app-banner-wrapper {
position: relative;
width: 100%;
.refresh-payment-status {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: 4px;
.refresh-payment-status-btn-wrapper {
display: inline-block;
&:hover {
text-decoration: underline;
}
}
}
}
.app-layout {
@@ -12,24 +27,24 @@
width: 100%;
&.isWorkspaceRestricted {
height: calc(100% - 32px);
height: calc(100% - 48px);
// same styles as its either trial expired or payment failed
&.isTrialExpired {
height: calc(100% - 64px);
height: calc(100% - 96px);
}
&.isPaymentFailed {
height: calc(100% - 64px);
height: calc(100% - 96px);
}
}
&.isTrialExpired {
height: calc(100% - 32px);
height: calc(100% - 48px);
}
&.isPaymentFailed {
height: calc(100% - 32px);
height: calc(100% - 48px);
}
.app-content {
@@ -196,5 +211,5 @@
.workspace-restricted-banner,
.trial-expiry-banner,
.payment-failed-banner {
height: 32px;
height: 48px;
}

View File

@@ -16,6 +16,7 @@ import cx from 'classnames';
import ChangelogModal from 'components/ChangelogModal/ChangelogModal';
import ChatSupportGateway from 'components/ChatSupportGateway/ChatSupportGateway';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import RefreshPaymentStatus from 'components/RefreshPaymentStatus/RefreshPaymentStatus';
import { Events } from 'constants/events';
import { FeatureKeys } from 'constants/features';
import { LOCALSTORAGE } from 'constants/localStorage';
@@ -181,11 +182,11 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
]);
useEffect(() => {
// refetch the changelog only when the current tab becomes active + there isn't an active request + no changelog already available
if (!changelog && !getChangelogByVersionResponse.isLoading && isVisible) {
// refetch the changelog only when the current tab becomes active + there isn't an active request
if (!getChangelogByVersionResponse.isLoading && isVisible) {
getChangelogByVersionResponse.refetch();
}
/* eslint-disable react-hooks/exhaustive-deps */
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isVisible]);
useEffect(() => {
@@ -665,6 +666,10 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
upgrade
</a>
to continue using SigNoz features.
<span className="refresh-payment-status">
{' '}
| Already upgraded? <RefreshPaymentStatus type="text" />
</span>
</span>
) : (
'Please contact your administrator for upgrading to a paid plan.'
@@ -691,6 +696,10 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
pay the bill
</a>
to continue using SigNoz features.
<span className="refresh-payment-status">
{' '}
| Already paid? <RefreshPaymentStatus type="text" />
</span>
</span>
) : (
' Please contact your administrator to pay the bill.'

View File

@@ -20,6 +20,7 @@ import getUsage, { UsageResponsePayloadProps } from 'api/billing/getUsage';
import logEvent from 'api/common/logEvent';
import updateCreditCardApi from 'api/v1/checkout/create';
import manageCreditCardApi from 'api/v1/portal/create';
import RefreshPaymentStatus from 'components/RefreshPaymentStatus/RefreshPaymentStatus';
import Spinner from 'components/Spinner';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
@@ -440,14 +441,15 @@ export default function BillingContainer(): JSX.Element {
</Typography.Text>
) : null}
</Flex>
<Flex gap={20}>
<Flex gap={8}>
<Button
type="dashed"
type="default"
size="middle"
loading={isLoadingBilling || isLoadingManageBilling}
disabled={isLoading || isFetchingBillingData}
onClick={handleCsvDownload}
icon={<CloudDownloadOutlined />}
className="periscope-btn"
>
Download CSV
</Button>
@@ -463,6 +465,8 @@ export default function BillingContainer(): JSX.Element {
? t('manage_billing')
: t('upgrade_plan')}
</Button>
<RefreshPaymentStatus type="tooltip" />
</Flex>
</Flex>

View File

@@ -1,5 +1,5 @@
import { Button, Form, Input } from 'antd';
import apply from 'api/v3/licenses/put';
import apply from 'api/v3/licenses/post';
import { useNotifications } from 'hooks/useNotifications';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';

View File

@@ -58,6 +58,7 @@ export interface ActionItemProps {
operator: string,
isJSON?: boolean,
dataType?: DataTypes,
fieldType?: string,
) => void;
}

View File

@@ -14,6 +14,7 @@ import { ResizeTable } from 'components/ResizeTable';
import { OPERATORS } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { RESTRICTED_SELECTED_FIELDS } from 'container/LogsFilters/config';
import { MetricsType } from 'container/MetricsApplication/constant';
import { FontSize, OptionsQuery } from 'container/OptionsMenu/types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import history from 'lib/history';
@@ -113,6 +114,7 @@ function TableView({
fieldKey: string,
fieldValue: string,
dataType: string | undefined,
fieldType: string | undefined,
): void => {
const validatedFieldValue = removeJSONStringifyQuotes(fieldValue);
if (onClickActionItem) {
@@ -122,6 +124,7 @@ function TableView({
operator,
undefined,
dataType as DataTypes,
fieldType,
);
}
};
@@ -131,8 +134,9 @@ function TableView({
fieldKey: string,
fieldValue: string,
dataType: string | undefined,
fieldType: MetricsType | undefined,
) => (): void => {
handleClick(operator, fieldKey, fieldValue, dataType);
handleClick(operator, fieldKey, fieldValue, dataType, fieldType);
if (operator === OPERATORS['=']) {
setIsFilterInLoading(true);
}

View File

@@ -11,6 +11,7 @@ import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { OPERATORS } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { RESTRICTED_SELECTED_FIELDS } from 'container/LogsFilters/config';
import { MetricsType } from 'container/MetricsApplication/constant';
import dompurify from 'dompurify';
import { ArrowDownToDot, ArrowUpFromDot, Ellipsis } from 'lucide-react';
import { useTimezone } from 'providers/Timezone';
@@ -46,6 +47,7 @@ interface ITableViewActionsProps {
fieldKey: string,
fieldValue: string,
dataType: string | undefined,
logType: MetricsType | undefined,
) => () => void;
}
@@ -127,7 +129,7 @@ export default function TableViewActions(
} = props;
const { pathname } = useLocation();
const { dataType } = getFieldAttributes(record.field);
const { dataType, logType: fieldType } = getFieldAttributes(record.field);
// there is no option for where clause in old logs explorer and live logs page
const isOldLogsExplorerOrLiveLogsPage = useMemo(
@@ -234,6 +236,7 @@ export default function TableViewActions(
fieldFilterKey,
parseFieldValue(fieldData.value),
dataType,
fieldType,
)}
/>
</Tooltip>
@@ -252,6 +255,7 @@ export default function TableViewActions(
fieldFilterKey,
parseFieldValue(fieldData.value),
dataType,
fieldType,
)}
/>
</Tooltip>
@@ -312,6 +316,7 @@ export default function TableViewActions(
fieldFilterKey,
parseFieldValue(fieldData.value),
dataType,
fieldType,
)}
/>
</Tooltip>
@@ -330,6 +335,7 @@ export default function TableViewActions(
fieldFilterKey,
parseFieldValue(fieldData.value),
dataType,
fieldType,
)}
/>
</Tooltip>

View File

@@ -1,11 +1,14 @@
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { ILog } from 'types/api/logs/log';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { getFiltersFromResources } from './utils';
const RESOURCE_STARTS_WITH_REGEX = /^(k8s|cloud|host|deployment)/; // regex to filter out resources that start with the specified keywords
const RESOURCE_CONTAINS_REGEX = /(env|service|file|container|tenant)/; // regex to filter out resources that contains the spefied keywords
const useInitialQuery = (log: ILog): Query => {
const { updateAllQueriesOperators } = useQueryBuilder();
const resourcesFilters = getFiltersFromResources(log.resources_string);
@@ -16,17 +19,33 @@ const useInitialQuery = (log: ILog): Query => {
DataSource.LOGS,
);
const updateFilters = (filters: TagFilter): TagFilter => ({
...filters,
items: filters.items.filter(
(filterItem) =>
filterItem.key?.key &&
(RESOURCE_STARTS_WITH_REGEX.test(filterItem.key.key) ||
RESOURCE_CONTAINS_REGEX.test(filterItem.key.key)),
),
});
const data: Query = {
...updatedAllQueriesOperator,
builder: {
...updatedAllQueriesOperator.builder,
queryData: updatedAllQueriesOperator.builder.queryData.map((item) => ({
...item,
filters: {
queryData: updatedAllQueriesOperator.builder.queryData.map((item) => {
const filters = {
...item.filters,
items: [...item.filters.items, ...resourcesFilters],
},
})),
};
const updatedFilters = updateFilters(filters);
return {
...item,
filters: updatedFilters,
};
}),
},
};

View File

@@ -2,7 +2,7 @@ import getTopOperations from 'api/metrics/getTopOperations';
import TopOperationsTable from 'container/MetricsApplication/TopOperationsTable';
import useResourceAttribute from 'hooks/useResourceAttribute';
import { convertRawQueriesToTraceSelectedTags } from 'hooks/useResourceAttribute/utils';
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { useSelector } from 'react-redux';
import { useParams } from 'react-router-dom';
@@ -20,6 +20,8 @@ function TopOperation(): JSX.Element {
}>();
const servicename = decodeURIComponent(encodedServiceName || '');
const [isEntryPoint, setIsEntryPoint] = useState<boolean>(false);
const { queries } = useResourceAttribute();
const selectedTags = useMemo(
() => (convertRawQueriesToTraceSelectedTags(queries) as Tags[]) || [],
@@ -27,19 +29,27 @@ function TopOperation(): JSX.Element {
);
const { data, isLoading } = useQuery<PayloadProps>({
queryKey: [minTime, maxTime, servicename, selectedTags],
queryKey: [minTime, maxTime, servicename, selectedTags, isEntryPoint],
queryFn: (): Promise<PayloadProps> =>
getTopOperations({
service: servicename || '',
start: minTime,
end: maxTime,
selectedTags,
isEntryPoint,
}),
});
const topOperationData = data || [];
return <TopOperationsTable data={topOperationData} isLoading={isLoading} />;
return (
<TopOperationsTable
data={topOperationData}
isLoading={isLoading}
isEntryPoint={isEntryPoint}
onEntryPointToggle={setIsEntryPoint}
/>
);
}
export default TopOperation;

View File

@@ -1,9 +1,24 @@
.top-operation {
position: relative;
.top-operation--download {
&__controls {
display: flex;
justify-content: flex-end;
align-items: center;
position: absolute;
top: 15px;
right: 0px;
z-index: 1;
gap: 8px;
}
&__entry-point {
display: flex;
align-items: center;
gap: 6px;
}
&__download {
.ant-btn-icon {
margin: 0 !important;
}
}
}

View File

@@ -1,9 +1,10 @@
import './TopOperationsTable.styles.scss';
import { SearchOutlined } from '@ant-design/icons';
import { InputRef, Tooltip, Typography } from 'antd';
import { InputRef, Switch, Tooltip, Typography } from 'antd';
import { ColumnsType, ColumnType } from 'antd/lib/table';
import { ResizeTable } from 'components/ResizeTable';
import TextToolTip from 'components/TextToolTip';
import Download from 'container/Download/Download';
import { filterDropdown } from 'container/ServiceApplication/Filter/FilterDropdown';
import useResourceAttribute from 'hooks/useResourceAttribute';
@@ -29,6 +30,8 @@ import {
function TopOperationsTable({
data,
isLoading,
isEntryPoint,
onEntryPointToggle,
}: TopOperationsTableProps): JSX.Element {
const searchInput = useRef<InputRef>(null);
const { servicename: encodedServiceName } = useParams<IServiceName>();
@@ -174,20 +177,45 @@ function TopOperationsTable({
hideOnSinglePage: true,
};
const entryPointSpanInfo = {
text: 'Shows the spans where requests enter new services for the first time',
url:
'https://signoz.io/docs/traces-management/guides/entry-point-spans-service-overview/',
urlText: 'Learn more about Entrypoint Spans.',
};
return (
<div className="top-operation">
<div className="top-operation--download">
<Download
data={downloadableData}
isLoading={isLoading}
fileName={`top-operations-${servicename}`}
/>
<div className="top-operation__controls">
<div className="top-operation__download">
<Download
data={downloadableData}
isLoading={isLoading}
fileName={`top-operations-${servicename}`}
/>
</div>
<div className="top-operation__entry-point">
<Switch
checked={isEntryPoint}
onChange={onEntryPointToggle}
size="small"
/>
<span className="top-operation__entry-point-label">Entrypoint Spans</span>
<TextToolTip
text={entryPointSpanInfo.text}
url={entryPointSpanInfo.url}
useFilledIcon={false}
urlText={entryPointSpanInfo.urlText}
/>
</div>
</div>
<ResizeTable
columns={columns}
loading={isLoading}
showHeader
title={(): string => 'Key Operations'}
title={(): string =>
isEntryPoint ? 'Key Entrypoint Operations' : 'Key Operations'
}
tableLayout="fixed"
dataSource={data}
rowKey="name"
@@ -209,6 +237,8 @@ export interface TopOperationList {
interface TopOperationsTableProps {
data: TopOperationList[];
isLoading: boolean;
isEntryPoint: boolean;
onEntryPointToggle: (checked: boolean) => void;
}
export default TopOperationsTable;

View File

@@ -1,3 +1,6 @@
import { QueryClient } from 'react-query';
import configureStore from 'redux-mock-store';
import { TopOperationList } from '../TopOperationsTable';
interface TopOperation {
@@ -17,3 +20,59 @@ export const getTopOperationList = ({
p95: 0,
p99: 0,
} as TopOperationList);
export const defaultApiCallExpectation = {
service: 'test-service',
start: 1640995200000,
end: 1641081600000,
selectedTags: [],
isEntryPoint: false,
};
export const mockStore = configureStore([]);
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
refetchOnWindowFocus: false,
},
},
});
export const mockTopOperationsData: TopOperationList[] = [
{
name: 'GET /api/users',
p50: 1000000,
p95: 2000000,
p99: 3000000,
numCalls: 100,
errorCount: 5,
},
{
name: 'POST /api/orders',
p50: 1500000,
p95: 2500000,
p99: 3500000,
numCalls: 80,
errorCount: 2,
},
];
export const mockEntryPointData: TopOperationList[] = [
{
name: 'GET /api/health',
p50: 500000,
p95: 1000000,
p99: 1500000,
numCalls: 200,
errorCount: 0,
},
];
export const createMockStore = (): any =>
mockStore({
globalTime: {
minTime: 1640995200000,
maxTime: 1641081600000,
},
});

View File

@@ -0,0 +1,300 @@
import {
act,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import useResourceAttribute from 'hooks/useResourceAttribute';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { QueryClientProvider } from 'react-query';
import { Provider } from 'react-redux';
import {
createMockStore,
defaultApiCallExpectation,
mockEntryPointData,
mockTopOperationsData,
queryClient,
} from '../__mocks__/getTopOperation';
import TopOperation from '../Tabs/Overview/TopOperation';
// Mock dependencies
jest.mock('hooks/useResourceAttribute');
jest.mock('hooks/useResourceAttribute/utils', () => ({
convertRawQueriesToTraceSelectedTags: (): any[] => [],
}));
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
safeNavigate: jest.fn(),
}),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: (): { servicename: string } => ({
servicename: encodeURIComponent('test-service'),
}),
}));
// Mock the util functions that TopOperationsTable uses
jest.mock('../Tabs/util', () => ({
useGetAPMToTracesQueries: (): any => ({
builder: {
queryData: [
{
filters: {
items: [],
},
},
],
},
}),
}));
// Mock the resourceAttributesToTracesFilterItems function
jest.mock('container/TraceDetail/utils', () => ({
resourceAttributesToTracesFilterItems: (): any[] => [],
}));
const mockedUseResourceAttribute = useResourceAttribute as jest.MockedFunction<
typeof useResourceAttribute
>;
// Constants
const KEY_OPERATIONS_TEXT = 'Key Operations';
const KEY_ENTRY_POINT_OPERATIONS_TEXT = 'Key Entrypoint Operations';
const ENTRY_POINT_SPANS_TEXT = 'Entrypoint Spans';
const TOP_OPERATIONS_ENDPOINT = 'top_operations';
const ENTRY_POINT_OPERATIONS_ENDPOINT = 'entry_point_operations';
const renderComponent = (store = createMockStore()): any =>
render(
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<TopOperation />
</QueryClientProvider>
</Provider>,
);
// Helper function to wait for initial render and verify basic functionality
const waitForInitialRender = async (): Promise<void> => {
await waitFor(() => {
expect(screen.getByText(KEY_OPERATIONS_TEXT)).toBeInTheDocument();
});
};
// Helper function to click toggle and wait for data to load
const clickToggleAndWaitForDataLoad = async (): Promise<HTMLElement> => {
const toggleSwitch = screen.getByRole('switch');
act(() => {
fireEvent.click(toggleSwitch);
});
await waitFor(() => {
expect(screen.getByText(KEY_ENTRY_POINT_OPERATIONS_TEXT)).toBeInTheDocument();
});
return toggleSwitch;
};
describe('TopOperation API Integration', () => {
let apiCalls: { endpoint: string; body: any }[] = [];
beforeEach(() => {
jest.clearAllMocks();
queryClient.clear();
apiCalls = [];
mockedUseResourceAttribute.mockReturnValue({
queries: [],
} as any);
server.use(
rest.post(
'http://localhost/api/v1/service/top_operations',
async (req, res, ctx) => {
const body = await req.json();
apiCalls.push({ endpoint: TOP_OPERATIONS_ENDPOINT, body });
return res(ctx.status(200), ctx.json(mockTopOperationsData));
},
),
rest.post(
'http://localhost/api/v1/service/entry_point_operations',
async (req, res, ctx) => {
const body = await req.json();
apiCalls.push({ endpoint: ENTRY_POINT_OPERATIONS_ENDPOINT, body });
return res(ctx.status(200), ctx.json({ data: mockEntryPointData }));
},
),
);
});
it('renders with default key operations on initial load', async () => {
renderComponent();
await waitForInitialRender();
// Verify the toggle is present and unchecked
const toggleSwitch = screen.getByRole('switch');
expect(toggleSwitch).not.toBeChecked();
expect(screen.getByText(ENTRY_POINT_SPANS_TEXT)).toBeInTheDocument();
});
it('calls top_operations API on initial render', async () => {
renderComponent();
await waitForInitialRender();
// Wait a bit more for API calls to be captured
await waitFor(() => {
expect(apiCalls.length).toBeGreaterThan(0);
});
// Verify that only the top_operations endpoint was called
expect(apiCalls).toHaveLength(1);
expect(apiCalls[0].endpoint).toBe(TOP_OPERATIONS_ENDPOINT);
expect(apiCalls[0].body).toEqual({
start: `${defaultApiCallExpectation.start}`,
end: `${defaultApiCallExpectation.end}`,
service: defaultApiCallExpectation.service,
tags: defaultApiCallExpectation.selectedTags,
});
});
it('calls entry_point_operations API when toggle is switched to entry point', async () => {
renderComponent();
// Wait for initial render
await waitForInitialRender();
// Wait for initial API call
await waitFor(() => {
expect(apiCalls.length).toBeGreaterThan(0);
});
// Clear previous API calls
apiCalls = [];
// Toggle to entry point
await clickToggleAndWaitForDataLoad();
// Wait for the API call to be captured
await waitFor(() => {
expect(apiCalls.length).toBeGreaterThan(0);
});
// Verify that the entry_point_operations endpoint was called
expect(apiCalls).toHaveLength(1);
expect(apiCalls[0].endpoint).toBe(ENTRY_POINT_OPERATIONS_ENDPOINT);
expect(apiCalls[0].body).toEqual({
start: `${defaultApiCallExpectation.start}`,
end: `${defaultApiCallExpectation.end}`,
service: defaultApiCallExpectation.service,
tags: defaultApiCallExpectation.selectedTags,
});
});
it('switches to entry point operations when toggle is clicked', async () => {
renderComponent();
// Wait for initial render
await waitForInitialRender();
// Find and click the toggle switch
const toggleSwitch = screen.getByRole('switch');
expect(toggleSwitch).not.toBeChecked();
await clickToggleAndWaitForDataLoad();
// Check that the switch is now checked and title updates
expect(toggleSwitch).toBeChecked();
expect(screen.getByText(KEY_ENTRY_POINT_OPERATIONS_TEXT)).toBeInTheDocument();
});
it('calls correct APIs when toggling multiple times', async () => {
renderComponent();
// Wait for initial render
await waitForInitialRender();
// Wait for initial API call
await waitFor(() => {
expect(apiCalls.length).toBeGreaterThan(0);
});
// Should have called top_operations initially
expect(apiCalls).toHaveLength(1);
expect(apiCalls[0].endpoint).toBe(TOP_OPERATIONS_ENDPOINT);
// Toggle to entry point
await clickToggleAndWaitForDataLoad();
// Wait for the second API call
await waitFor(() => {
expect(apiCalls.length).toBeGreaterThan(1);
});
// Should now have called entry_point_operations
expect(apiCalls).toHaveLength(2);
expect(apiCalls[1].endpoint).toBe(ENTRY_POINT_OPERATIONS_ENDPOINT);
// Toggle back to regular operations
const toggleSwitch = screen.getByRole('switch');
act(() => {
fireEvent.click(toggleSwitch);
});
await waitFor(() => {
expect(screen.getByText(KEY_OPERATIONS_TEXT)).toBeInTheDocument();
});
// Wait for the third API call
await waitFor(() => {
expect(apiCalls.length).toBeGreaterThan(2);
});
// Should have called top_operations again
expect(apiCalls).toHaveLength(3);
expect(apiCalls[2].endpoint).toBe(TOP_OPERATIONS_ENDPOINT);
expect(toggleSwitch).not.toBeChecked();
});
it('displays entry point toggle with correct label', async () => {
renderComponent();
await waitFor(() => {
expect(screen.getByText(ENTRY_POINT_SPANS_TEXT)).toBeInTheDocument();
});
const toggleSwitch = screen.getByRole('switch');
expect(toggleSwitch).toBeInTheDocument();
});
it('switches back to key operations when toggle is clicked twice', async () => {
renderComponent();
// Wait for initial render
await waitForInitialRender();
// Toggle on (to entry point)
await clickToggleAndWaitForDataLoad();
expect(screen.getByText(KEY_ENTRY_POINT_OPERATIONS_TEXT)).toBeInTheDocument();
// Toggle off (back to key operations)
const toggleSwitch = screen.getByRole('switch');
act(() => {
fireEvent.click(toggleSwitch);
});
await waitFor(() => {
expect(screen.getByText(KEY_OPERATIONS_TEXT)).toBeInTheDocument();
});
expect(toggleSwitch).not.toBeChecked();
});
});

View File

@@ -104,6 +104,7 @@ function Metadata({
if (field.key === 'metric_type') {
return (
<Select
data-testid="metric-type-select"
options={Object.entries(METRIC_TYPE_VALUES_MAP).map(([key]) => ({
value: key,
label: METRIC_TYPE_LABEL_MAP[key as MetricType],
@@ -121,6 +122,7 @@ function Metadata({
if (field.key === 'temporality') {
return (
<Select
data-testid="temporality-select"
options={Object.values(Temporality).map((key) => ({
value: key,
label: key,
@@ -137,6 +139,7 @@ function Metadata({
}
return (
<Input
data-testid="description-input"
name={field.key}
defaultValue={
metricMetadata[

View File

@@ -1,67 +0,0 @@
import { Button, Collapse, Typography } from 'antd';
import { useMemo, useState } from 'react';
import { TopAttributesProps } from './types';
function TopAttributes({
items,
title,
loadMore,
hideLoadMore,
}: TopAttributesProps): JSX.Element {
const [activeKey, setActiveKey] = useState<string | string[]>(
'top-attributes',
);
const collapseItems = useMemo(
() => [
{
label: (
<div className="metrics-accordion-header">
<Typography.Text>{title}</Typography.Text>
</div>
),
key: 'top-attributes',
children: (
<div className="top-attributes-content">
{items.map((item) => (
<div className="top-attributes-item" key={item.key}>
<div className="top-attributes-item-progress">
<div className="top-attributes-item-key">{item.key}</div>
<div className="top-attributes-item-count">{item.count}</div>
<div
className="top-attributes-item-progress-bar"
style={{ width: `${item.percentage}%` }}
/>
</div>
<div className="top-attributes-item-percentage">
{item.percentage.toFixed(2)}%
</div>
</div>
))}
{loadMore && !hideLoadMore && (
<div className="top-attributes-load-more">
<Button type="link" onClick={loadMore}>
Load more
</Button>
</div>
)}
</div>
),
},
],
[title, items, loadMore, hideLoadMore],
);
return (
<Collapse
bordered
className="metrics-accordion"
activeKey={activeKey}
onChange={(keys): void => setActiveKey(keys)}
items={collapseItems}
/>
);
}
export default TopAttributes;

View File

@@ -1,6 +1,7 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import * as useHandleExplorerTabChange from 'hooks/useHandleExplorerTabChange';
import * as reactUseHooks from 'react-use';
import { MetricDetailsAttribute } from '../../../../api/metricsExplorer/getMetricDetails';
import ROUTES from '../../../../constants/routes';
@@ -34,6 +35,11 @@ const mockAttributes: MetricDetailsAttribute[] = [
},
];
const mockUseCopyToClipboard = jest.fn();
jest
.spyOn(reactUseHooks, 'useCopyToClipboard')
.mockReturnValue([{ value: 'value1' }, mockUseCopyToClipboard] as any);
describe('AllAttributes', () => {
it('renders attributes section with title', () => {
render(
@@ -165,4 +171,42 @@ describe('AllAttributesValue', () => {
);
expect(screen.queryByText('Show More')).not.toBeInTheDocument();
});
it('copy button should copy the attribute value to the clipboard', () => {
render(
<AllAttributesValue
filterKey="attribute1"
filterValue={['value1', 'value2']}
goToMetricsExploreWithAppliedAttribute={
mockGoToMetricsExploreWithAppliedAttribute
}
/>,
);
expect(screen.getByText('value1')).toBeInTheDocument();
fireEvent.click(screen.getByText('value1'));
expect(screen.getByText('Copy Attribute')).toBeInTheDocument();
fireEvent.click(screen.getByText('Copy Attribute'));
expect(mockUseCopyToClipboard).toHaveBeenCalledWith('value1');
});
it('explorer button should go to metrics explore with the attribute filter applied', () => {
render(
<AllAttributesValue
filterKey="attribute1"
filterValue={['value1', 'value2']}
goToMetricsExploreWithAppliedAttribute={
mockGoToMetricsExploreWithAppliedAttribute
}
/>,
);
expect(screen.getByText('value1')).toBeInTheDocument();
fireEvent.click(screen.getByText('value1'));
expect(screen.getByText('Open in Explorer')).toBeInTheDocument();
fireEvent.click(screen.getByText('Open in Explorer'));
expect(mockGoToMetricsExploreWithAppliedAttribute).toHaveBeenCalledWith(
'attribute1',
'value1',
);
});
});

View File

@@ -0,0 +1,162 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { QueryParams } from 'constants/query';
import * as useSafeNavigate from 'hooks/useSafeNavigate';
import DashboardsAndAlertsPopover from '../DashboardsAndAlertsPopover';
const mockAlert1 = {
alert_id: '1',
alert_name: 'Alert 1',
};
const mockAlert2 = {
alert_id: '2',
alert_name: 'Alert 2',
};
const mockDashboard1 = {
dashboard_id: '1',
dashboard_name: 'Dashboard 1',
};
const mockDashboard2 = {
dashboard_id: '2',
dashboard_name: 'Dashboard 2',
};
const mockAlerts = [mockAlert1, mockAlert2];
const mockDashboards = [mockDashboard1, mockDashboard2];
const mockSafeNavigate = jest.fn();
jest.spyOn(useSafeNavigate, 'useSafeNavigate').mockReturnValue({
safeNavigate: mockSafeNavigate,
});
const mockSetQuery = jest.fn();
const mockUrlQuery = {
set: mockSetQuery,
toString: jest.fn(),
};
jest.mock('hooks/useUrlQuery', () => ({
__esModule: true,
default: jest.fn(() => mockUrlQuery),
}));
describe('DashboardsAndAlertsPopover', () => {
it('renders the popover correctly with multiple dashboards and alerts', () => {
render(
<DashboardsAndAlertsPopover
alerts={mockAlerts}
dashboards={mockDashboards}
/>,
);
expect(
screen.getByText(`${mockDashboards.length} dashboards`),
).toBeInTheDocument();
expect(
screen.getByText(`${mockAlerts.length} alert rules`),
).toBeInTheDocument();
});
it('renders null with no dashboards and alerts', () => {
const { container } = render(
<DashboardsAndAlertsPopover alerts={[]} dashboards={[]} />,
);
expect(container).toBeEmptyDOMElement();
});
it('renders popover with single dashboard and alert', () => {
render(
<DashboardsAndAlertsPopover
alerts={[mockAlert1]}
dashboards={[mockDashboard1]}
/>,
);
expect(screen.getByText(`1 dashboard`)).toBeInTheDocument();
expect(screen.getByText(`1 alert rule`)).toBeInTheDocument();
});
it('renders popover with dashboard id if name is not available', () => {
render(
<DashboardsAndAlertsPopover
alerts={mockAlerts}
dashboards={[{ ...mockDashboard1, dashboard_name: undefined } as any]}
/>,
);
fireEvent.click(screen.getByText(`1 dashboard`));
expect(screen.getByText(mockDashboard1.dashboard_id)).toBeInTheDocument();
});
it('renders popover with alert id if name is not available', () => {
render(
<DashboardsAndAlertsPopover
alerts={[{ ...mockAlert1, alert_name: undefined } as any]}
dashboards={mockDashboards}
/>,
);
fireEvent.click(screen.getByText(`1 alert rule`));
expect(screen.getByText(mockAlert1.alert_id)).toBeInTheDocument();
});
it('navigates to the dashboard when the dashboard is clicked', () => {
render(
<DashboardsAndAlertsPopover
alerts={mockAlerts}
dashboards={mockDashboards}
/>,
);
// Click on 2 dashboards button
fireEvent.click(screen.getByText(`${mockDashboards.length} dashboards`));
// Popover showing list of 2 dashboards should be visible
expect(screen.getByText(mockDashboard1.dashboard_name)).toBeInTheDocument();
expect(screen.getByText(mockDashboard2.dashboard_name)).toBeInTheDocument();
// Click on the first dashboard
fireEvent.click(screen.getByText(mockDashboard1.dashboard_name));
// Should navigate to the dashboard
expect(mockSafeNavigate).toHaveBeenCalledWith(
`/dashboard/${mockDashboard1.dashboard_id}`,
);
});
it('navigates to the alert when the alert is clicked', () => {
render(
<DashboardsAndAlertsPopover
alerts={mockAlerts}
dashboards={mockDashboards}
/>,
);
// Click on 2 alert rules button
fireEvent.click(screen.getByText(`${mockAlerts.length} alert rules`));
// Popover showing list of 2 alert rules should be visible
expect(screen.getByText(mockAlert1.alert_name)).toBeInTheDocument();
expect(screen.getByText(mockAlert2.alert_name)).toBeInTheDocument();
// Click on the first alert rule
fireEvent.click(screen.getByText(mockAlert1.alert_name));
// Should navigate to the alert rule
expect(mockSetQuery).toHaveBeenCalledWith(
QueryParams.ruleId,
mockAlert1.alert_id,
);
});
it('renders unique dashboards even when there are duplicates', () => {
render(
<DashboardsAndAlertsPopover
alerts={mockAlerts}
dashboards={[...mockDashboards, mockDashboard1]}
/>,
);
expect(
screen.getByText(`${mockDashboards.length} dashboards`),
).toBeInTheDocument();
fireEvent.click(screen.getByText(`${mockDashboards.length} dashboards`));
expect(screen.getByText(mockDashboard1.dashboard_name)).toBeInTheDocument();
expect(screen.getByText(mockDashboard2.dashboard_name)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,222 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { fireEvent, render, screen } from '@testing-library/react';
import { Temporality } from 'api/metricsExplorer/getMetricDetails';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import * as useUpdateMetricMetadataHooks from 'hooks/metricsExplorer/useUpdateMetricMetadata';
import * as useNotificationsHooks from 'hooks/useNotifications';
import Metadata from '../Metadata';
const mockUseUpdateMetricMetadata = jest.fn();
jest
.spyOn(useUpdateMetricMetadataHooks, 'useUpdateMetricMetadata')
.mockReturnValue({
mutate: mockUseUpdateMetricMetadata,
isLoading: false,
} as any);
const mockErrorNotification = jest.fn();
const mockSuccessNotification = jest.fn();
jest.spyOn(useNotificationsHooks, 'useNotifications').mockReturnValue({
notifications: {
error: mockErrorNotification,
success: mockSuccessNotification,
},
} as any);
const mockMetricName = 'test_metric';
const mockMetricMetadata = {
metric_type: MetricType.GAUGE,
description: 'test_description',
unit: 'test_unit',
temporality: Temporality.DELTA,
};
const mockRefetchMetricDetails = jest.fn();
describe('Metadata', () => {
it('should render the metadata properly', () => {
render(
<Metadata
metricName={mockMetricName}
metadata={mockMetricMetadata}
refetchMetricDetails={mockRefetchMetricDetails}
/>,
);
expect(screen.getByText('Metric Type')).toBeInTheDocument();
expect(screen.getByText(mockMetricMetadata.metric_type)).toBeInTheDocument();
expect(screen.getByText('Description')).toBeInTheDocument();
expect(screen.getByText(mockMetricMetadata.description)).toBeInTheDocument();
expect(screen.getByText('Unit')).toBeInTheDocument();
expect(screen.getByText(mockMetricMetadata.unit)).toBeInTheDocument();
expect(screen.getByText('Temporality')).toBeInTheDocument();
expect(screen.getByText(mockMetricMetadata.temporality)).toBeInTheDocument();
});
it('editing the metadata should show the form inputs', () => {
render(
<Metadata
metricName={mockMetricName}
metadata={mockMetricMetadata}
refetchMetricDetails={mockRefetchMetricDetails}
/>,
);
const editButton = screen.getByText('Edit');
expect(editButton).toBeInTheDocument();
fireEvent.click(editButton);
expect(screen.getByTestId('metric-type-select')).toBeInTheDocument();
expect(screen.getByTestId('temporality-select')).toBeInTheDocument();
expect(screen.getByTestId('description-input')).toBeInTheDocument();
});
it('should update the metadata when the form is submitted', async () => {
render(
<Metadata
metricName={mockMetricName}
metadata={mockMetricMetadata}
refetchMetricDetails={mockRefetchMetricDetails}
/>,
);
const editButton = screen.getByText('Edit');
expect(editButton).toBeInTheDocument();
fireEvent.click(editButton);
const metricDescriptionInput = screen.getByTestId('description-input');
expect(metricDescriptionInput).toBeInTheDocument();
fireEvent.change(metricDescriptionInput, {
target: { value: 'Updated description' },
});
const saveButton = screen.getByText('Save');
expect(saveButton).toBeInTheDocument();
fireEvent.click(saveButton);
expect(mockUseUpdateMetricMetadata).toHaveBeenCalledWith(
expect.objectContaining({
metricName: mockMetricName,
payload: expect.objectContaining({
description: 'Updated description',
}),
}),
expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}),
);
});
it('should show success notification when metadata is updated successfully', async () => {
render(
<Metadata
metricName={mockMetricName}
metadata={mockMetricMetadata}
refetchMetricDetails={mockRefetchMetricDetails}
/>,
);
const editButton = screen.getByText('Edit');
fireEvent.click(editButton);
const metricDescriptionInput = screen.getByTestId('description-input');
fireEvent.change(metricDescriptionInput, {
target: { value: 'Updated description' },
});
const saveButton = screen.getByText('Save');
fireEvent.click(saveButton);
const onSuccessCallback =
mockUseUpdateMetricMetadata.mock.calls[0][1].onSuccess;
onSuccessCallback({ statusCode: 200 });
expect(mockSuccessNotification).toHaveBeenCalledWith({
message: 'Metadata updated successfully',
});
expect(mockRefetchMetricDetails).toHaveBeenCalled();
});
it('should show error notification when metadata update fails with non-200 response', async () => {
render(
<Metadata
metricName={mockMetricName}
metadata={mockMetricMetadata}
refetchMetricDetails={mockRefetchMetricDetails}
/>,
);
const editButton = screen.getByText('Edit');
fireEvent.click(editButton);
const metricDescriptionInput = screen.getByTestId('description-input');
fireEvent.change(metricDescriptionInput, {
target: { value: 'Updated description' },
});
const saveButton = screen.getByText('Save');
fireEvent.click(saveButton);
const onSuccessCallback =
mockUseUpdateMetricMetadata.mock.calls[0][1].onSuccess;
onSuccessCallback({ statusCode: 500 });
expect(mockErrorNotification).toHaveBeenCalledWith({
message:
'Failed to update metadata, please try again. If the issue persists, please contact support.',
});
});
it('should show error notification when metadata update fails', async () => {
render(
<Metadata
metricName={mockMetricName}
metadata={mockMetricMetadata}
refetchMetricDetails={mockRefetchMetricDetails}
/>,
);
const editButton = screen.getByText('Edit');
fireEvent.click(editButton);
const metricDescriptionInput = screen.getByTestId('description-input');
fireEvent.change(metricDescriptionInput, {
target: { value: 'Updated description' },
});
const saveButton = screen.getByText('Save');
fireEvent.click(saveButton);
const onErrorCallback = mockUseUpdateMetricMetadata.mock.calls[0][1].onError;
const error = new Error('Failed to update metadata');
onErrorCallback(error);
expect(mockErrorNotification).toHaveBeenCalledWith({
message:
'Failed to update metadata, please try again. If the issue persists, please contact support.',
});
});
it('cancel button should cancel the edit mode', () => {
render(
<Metadata
metricName={mockMetricName}
metadata={mockMetricMetadata}
refetchMetricDetails={mockRefetchMetricDetails}
/>,
);
const editButton = screen.getByText('Edit');
expect(editButton).toBeInTheDocument();
fireEvent.click(editButton);
const cancelButton = screen.getByText('Cancel');
expect(cancelButton).toBeInTheDocument();
fireEvent.click(cancelButton);
const editButton2 = screen.getByText('Edit');
expect(editButton2).toBeInTheDocument();
});
});

View File

@@ -1,16 +1,16 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { MetricDetails } from 'api/metricsExplorer/getMetricDetails';
import { MetricDetails as MetricDetailsType } from 'api/metricsExplorer/getMetricDetails';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import ROUTES from 'constants/routes';
import * as useGetMetricDetails from 'hooks/metricsExplorer/useGetMetricDetails';
import * as useUpdateMetricMetadata from 'hooks/metricsExplorer/useUpdateMetricMetadata';
import * as useHandleExplorerTabChange from 'hooks/useHandleExplorerTabChange';
import MetricDetailsView from '../MetricDetails';
import MetricDetails from '../MetricDetails';
const mockMetricName = 'test-metric';
const mockMetricDescription = 'description for a test metric';
const mockMetricData: MetricDetails = {
const mockMetricData: MetricDetailsType = {
name: mockMetricName,
description: mockMetricDescription,
unit: 'count',
@@ -84,7 +84,7 @@ jest.mock('hooks/useSafeNavigate', () => ({
describe('MetricDetails', () => {
it('renders metric details correctly', () => {
render(
<MetricDetailsView
<MetricDetails
onClose={mockOnClose}
isOpen
isModalTimeSelection
@@ -114,7 +114,7 @@ describe('MetricDetails', () => {
},
} as any);
render(
<MetricDetailsView
<MetricDetails
onClose={mockOnClose}
isOpen
metricName={mockMetricName}
@@ -143,7 +143,7 @@ describe('MetricDetails', () => {
} as any);
render(
<MetricDetailsView
<MetricDetails
onClose={mockOnClose}
isOpen
metricName={mockMetricName}
@@ -162,7 +162,7 @@ describe('MetricDetails', () => {
} as any);
render(
<MetricDetailsView
<MetricDetails
onClose={mockOnClose}
isOpen
metricName={mockMetricName}
@@ -179,7 +179,7 @@ describe('MetricDetails', () => {
.spyOn(useGetMetricDetails, 'useGetMetricDetails')
.mockReturnValue(mockUseGetMetricDetailsData as any);
render(
<MetricDetailsView
<MetricDetails
onClose={mockOnClose}
isOpen
metricName={mockMetricName}
@@ -204,7 +204,7 @@ describe('MetricDetails', () => {
},
} as any);
render(
<MetricDetailsView
<MetricDetails
onClose={mockOnClose}
isOpen
metricName={mockMetricName}

View File

@@ -0,0 +1,259 @@
import { Temporality } from 'api/metricsExplorer/getMetricDetails';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import {
determineIsMonotonic,
formatTimestampToReadableDate,
getMetricDetailsQuery,
} from '../utils';
describe('MetricDetails utils', () => {
describe('determineIsMonotonic', () => {
it('should return true for histogram metrics', () => {
expect(determineIsMonotonic(MetricType.HISTOGRAM)).toBe(true);
});
it('should return true for exponential histogram metrics', () => {
expect(determineIsMonotonic(MetricType.EXPONENTIAL_HISTOGRAM)).toBe(true);
});
it('should return false for gauge metrics', () => {
expect(determineIsMonotonic(MetricType.GAUGE)).toBe(false);
});
it('should return false for summary metrics', () => {
expect(determineIsMonotonic(MetricType.SUMMARY)).toBe(false);
});
it('should return true for sum metrics with cumulative temporality', () => {
expect(determineIsMonotonic(MetricType.SUM, Temporality.CUMULATIVE)).toBe(
true,
);
});
it('should return false for sum metrics with delta temporality', () => {
expect(determineIsMonotonic(MetricType.SUM, Temporality.DELTA)).toBe(false);
});
it('should return false by default', () => {
expect(determineIsMonotonic('' as MetricType, '' as Temporality)).toBe(
false,
);
});
});
describe('formatTimestampToReadableDate', () => {
const FEW_SECONDS_AGO = 'Few seconds ago';
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z'));
});
afterEach(() => {
jest.useRealTimers();
});
it('should return "Few seconds ago" for timestamps less than 60 seconds ago', () => {
const timestamp = '2024-01-15T11:59:30.000Z';
expect(formatTimestampToReadableDate(timestamp)).toBe(FEW_SECONDS_AGO);
});
it('should return "1 minute ago" for exactly 1 minute ago', () => {
const timestamp = '2024-01-15T11:59:00.000Z';
expect(formatTimestampToReadableDate(timestamp)).toBe('1 minute ago');
});
it('should return "X minutes ago" for multiple minutes ago', () => {
const timestamp = '2024-01-15T11:55:00.000Z';
expect(formatTimestampToReadableDate(timestamp)).toBe('5 minutes ago');
});
it('should return "1 hour ago" for exactly 1 hour ago', () => {
const timestamp = '2024-01-15T11:00:00.000Z';
expect(formatTimestampToReadableDate(timestamp)).toBe('1 hour ago');
});
it('should return "X hours ago" for multiple hours ago', () => {
const timestamp = '2024-01-15T09:00:00.000Z';
expect(formatTimestampToReadableDate(timestamp)).toBe('3 hours ago');
});
it('should return "Yesterday at HH:MM" for exactly 1 day ago', () => {
const timestamp = '2024-01-14T12:00:00.000Z';
const expectedTime = new Date(timestamp).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
expect(formatTimestampToReadableDate(timestamp)).toBe(
`Yesterday at ${expectedTime}`,
);
});
it('should return "X days ago" for multiple days ago (less than 7 days)', () => {
const timestamp = '2024-01-12T12:00:00.000Z';
expect(formatTimestampToReadableDate(timestamp)).toBe('3 days ago');
});
it('should return localized date string for dates 7 or more days ago', () => {
const oldTimestamp = '2024-01-01T12:00:00.000Z';
const result = formatTimestampToReadableDate(oldTimestamp);
expect(result).not.toBe(FEW_SECONDS_AGO);
expect(typeof result).toBe('string');
});
it('should handle future timestamps correctly', () => {
const timestamp = '2024-01-16T12:00:00.000Z';
const result = formatTimestampToReadableDate(timestamp);
expect(result).toBe(FEW_SECONDS_AGO);
});
});
describe('getMetricDetailsQuery', () => {
const TEST_METRIC_NAME = 'test_metric';
const API_GATEWAY = 'api-gateway';
it('should create correct query for SUM metric type', () => {
const query = getMetricDetailsQuery(TEST_METRIC_NAME, MetricType.SUM);
expect(query.builder.queryData[0]?.aggregateAttribute.key).toBe(
TEST_METRIC_NAME,
);
expect(query.builder.queryData[0]?.aggregateAttribute.type).toBe(
MetricType.SUM,
);
expect(query.builder.queryData[0]?.aggregateOperator).toBe('rate');
expect(query.builder.queryData[0]?.timeAggregation).toBe('rate');
expect(query.builder.queryData[0]?.spaceAggregation).toBe('sum');
});
it('should create correct query for GAUGE metric type', () => {
const query = getMetricDetailsQuery(TEST_METRIC_NAME, MetricType.GAUGE);
expect(query.builder.queryData[0]?.aggregateAttribute.key).toBe(
TEST_METRIC_NAME,
);
expect(query.builder.queryData[0]?.aggregateAttribute.type).toBe(
MetricType.GAUGE,
);
expect(query.builder.queryData[0]?.aggregateOperator).toBe('avg');
expect(query.builder.queryData[0]?.timeAggregation).toBe('avg');
expect(query.builder.queryData[0]?.spaceAggregation).toBe('avg');
});
it('should create correct query for SUMMARY metric type', () => {
const query = getMetricDetailsQuery(TEST_METRIC_NAME, MetricType.SUMMARY);
expect(query.builder.queryData[0]?.aggregateAttribute.key).toBe(
TEST_METRIC_NAME,
);
expect(query.builder.queryData[0]?.aggregateAttribute.type).toBe(
MetricType.SUMMARY,
);
expect(query.builder.queryData[0]?.aggregateOperator).toBe('noop');
expect(query.builder.queryData[0]?.timeAggregation).toBe('noop');
expect(query.builder.queryData[0]?.spaceAggregation).toBe('sum');
});
it('should create correct query for HISTOGRAM metric type', () => {
const query = getMetricDetailsQuery(TEST_METRIC_NAME, MetricType.HISTOGRAM);
expect(query.builder.queryData[0]?.aggregateAttribute.key).toBe(
TEST_METRIC_NAME,
);
expect(query.builder.queryData[0]?.aggregateAttribute.type).toBe(
MetricType.HISTOGRAM,
);
expect(query.builder.queryData[0]?.aggregateOperator).toBe('noop');
expect(query.builder.queryData[0]?.timeAggregation).toBe('noop');
expect(query.builder.queryData[0]?.spaceAggregation).toBe('p90');
});
it('should create correct query for EXPONENTIAL_HISTOGRAM metric type', () => {
const query = getMetricDetailsQuery(
TEST_METRIC_NAME,
MetricType.EXPONENTIAL_HISTOGRAM,
);
expect(query.builder.queryData[0]?.aggregateAttribute.key).toBe(
TEST_METRIC_NAME,
);
expect(query.builder.queryData[0]?.aggregateAttribute.type).toBe(
MetricType.EXPONENTIAL_HISTOGRAM,
);
expect(query.builder.queryData[0]?.aggregateOperator).toBe('noop');
expect(query.builder.queryData[0]?.timeAggregation).toBe('noop');
expect(query.builder.queryData[0]?.spaceAggregation).toBe('p90');
});
it('should create query with default values for unknown metric type', () => {
const query = getMetricDetailsQuery(TEST_METRIC_NAME, undefined);
expect(query.builder.queryData[0]?.aggregateAttribute.key).toBe(
TEST_METRIC_NAME,
);
expect(query.builder.queryData[0]?.aggregateAttribute.type).toBe('');
expect(query.builder.queryData[0]?.aggregateOperator).toBe('noop');
expect(query.builder.queryData[0]?.timeAggregation).toBe('noop');
expect(query.builder.queryData[0]?.spaceAggregation).toBe('noop');
});
it('should include filter when provided', () => {
const filter = { key: 'service', value: API_GATEWAY };
const query = getMetricDetailsQuery(
TEST_METRIC_NAME,
MetricType.SUM,
filter,
);
expect(query.builder.queryData[0]?.filters.items).toHaveLength(1);
expect(query.builder.queryData[0]?.filters.items[0]?.key?.key).toBe(
'service',
);
expect(query.builder.queryData[0]?.filters.items[0]?.value).toBe(
API_GATEWAY,
);
expect(query.builder.queryData[0]?.filters.items[0]?.op).toBe('=');
});
it('should include groupBy when provided', () => {
const groupBy = 'service';
const query = getMetricDetailsQuery(
TEST_METRIC_NAME,
MetricType.SUM,
undefined,
groupBy,
);
expect(query.builder.queryData[0]?.groupBy).toHaveLength(1);
expect(query.builder.queryData[0]?.groupBy[0]?.key).toBe('service');
expect(query.builder.queryData[0]?.groupBy[0]?.type).toBe('tag');
});
it('should include both filter and groupBy when provided', () => {
const filter = { key: 'service', value: API_GATEWAY };
const groupBy = 'endpoint';
const query = getMetricDetailsQuery(
TEST_METRIC_NAME,
MetricType.SUM,
filter,
groupBy,
);
expect(query.builder.queryData[0]?.filters.items).toHaveLength(1);
expect(query.builder.queryData[0]?.groupBy).toHaveLength(1);
expect(query.builder.queryData[0]?.filters.items[0]?.key?.key).toBe(
'service',
);
expect(query.builder.queryData[0]?.groupBy[0]?.key).toBe('endpoint');
});
it('should not include filters or groupBy when not provided', () => {
const query = getMetricDetailsQuery(TEST_METRIC_NAME, MetricType.SUM);
expect(query.builder.queryData[0]?.filters.items).toHaveLength(0);
expect(query.builder.queryData[0]?.groupBy).toHaveLength(0);
});
});
});

View File

@@ -36,14 +36,3 @@ export interface AllAttributesValueProps {
filterValue: string[];
goToMetricsExploreWithAppliedAttribute: (key: string, value: string) => void;
}
export interface TopAttributesProps {
items: Array<{
key: string;
count: number;
percentage: number;
}>;
title: string;
loadMore?: () => void;
hideLoadMore?: boolean;
}

View File

@@ -108,6 +108,7 @@ export function getMetricDetailsQuery(
id: `${metricName}----${metricType}---string--`,
isColumn: true,
isJSON: false,
dataType: DataTypes.String,
},
aggregateOperator,
timeAggregation,

View File

@@ -120,6 +120,28 @@
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.auto-theme-info {
margin-top: 8px;
padding: 8px 12px;
border-radius: 4px;
background: var(--bg-slate-400, #1d212d);
border: 1px solid var(--bg-slate-500, #161922);
.auto-theme-status {
color: var(--bg-vanilla-400, #c0c1c3);
font-family: Inter;
font-size: 11px;
font-style: normal;
line-height: 16px;
letter-spacing: -0.07px;
strong {
color: var(--bg-robin-400, #4e74f8);
font-weight: 600;
}
}
}
}
}
}
@@ -168,6 +190,19 @@
.user-preference-section-content-item-description {
color: var(--bg-ink-300);
}
.auto-theme-info {
background: var(--bg-vanilla-200);
border: 1px solid var(--bg-vanilla-300);
.auto-theme-status {
color: var(--bg-ink-300);
strong {
color: var(--bg-robin-500);
}
}
}
}
}
}

View File

@@ -7,8 +7,11 @@ const logEventFunction = jest.fn();
jest.mock('hooks/useDarkMode', () => ({
__esModule: true,
useIsDarkMode: jest.fn(() => true),
useSystemTheme: jest.fn(() => 'dark'),
default: jest.fn(() => ({
toggleTheme: toggleThemeFunction,
autoSwitch: false,
setAutoSwitch: jest.fn(),
})),
}));
@@ -45,7 +48,7 @@ describe('MySettings Flows', () => {
});
describe('Dark/Light Theme Switch', () => {
it('Should display Dark and Light theme options properly', async () => {
it('Should display Dark, Light, and System theme options properly', async () => {
// Check Dark theme option
expect(screen.getByText('Dark')).toBeInTheDocument();
const darkThemeIcon = screen.getByTestId('dark-theme-icon');
@@ -58,6 +61,12 @@ describe('MySettings Flows', () => {
expect(lightThemeIcon).toBeInTheDocument();
expect(lightThemeIcon.tagName).toBe('svg');
expect(screen.getByText('Beta')).toBeInTheDocument();
// Check System theme option
expect(screen.getByText('System')).toBeInTheDocument();
const autoThemeIcon = screen.getByTestId('auto-theme-icon');
expect(autoThemeIcon).toBeInTheDocument();
expect(autoThemeIcon.tagName).toBe('svg');
});
it('Should have Dark theme selected by default', async () => {

View File

@@ -5,9 +5,9 @@ import logEvent from 'api/common/logEvent';
import updateUserPreference from 'api/v1/user/preferences/name/update';
import { AxiosError } from 'axios';
import { USER_PREFERENCES } from 'constants/userPreferences';
import useThemeMode, { useIsDarkMode } from 'hooks/useDarkMode';
import useThemeMode, { useIsDarkMode, useSystemTheme } from 'hooks/useDarkMode';
import { useNotifications } from 'hooks/useNotifications';
import { Moon, Sun } from 'lucide-react';
import { MonitorCog, Moon, Sun } from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import { useEffect, useState } from 'react';
import { useMutation } from 'react-query';
@@ -19,8 +19,9 @@ import UserInfo from './UserInfo';
function MySettings(): JSX.Element {
const isDarkMode = useIsDarkMode();
const { toggleTheme } = useThemeMode();
const { userPreferences, updateUserPreferenceInContext } = useAppContext();
const { toggleTheme, autoSwitch, setAutoSwitch } = useThemeMode();
const systemTheme = useSystemTheme();
const { notifications } = useNotifications();
const [sideNavPinned, setSideNavPinned] = useState(false);
@@ -68,16 +69,37 @@ function MySettings(): JSX.Element {
),
value: 'light',
},
{
label: (
<div className="theme-option">
<MonitorCog size={12} data-testid="auto-theme-icon" /> System{' '}
</div>
),
value: 'auto',
},
];
const [theme, setTheme] = useState(isDarkMode ? 'dark' : 'light');
const [theme, setTheme] = useState(() => {
if (autoSwitch) return 'auto';
return isDarkMode ? 'dark' : 'light';
});
const handleThemeChange = ({ target: { value } }: RadioChangeEvent): void => {
logEvent('Account Settings: Theme Changed', {
theme: value,
});
setTheme(value);
toggleTheme();
if (value === 'auto') {
setAutoSwitch(true);
} else {
setAutoSwitch(false);
// Only toggle if the current theme is different from the target
const targetIsDark = value === 'dark';
if (targetIsDark !== isDarkMode) {
toggleTheme();
}
}
};
const handleSideNavPinnedChange = (checked: boolean): void => {
@@ -150,13 +172,23 @@ function MySettings(): JSX.Element {
optionType="button"
buttonStyle="solid"
data-testid="theme-selector"
size="small"
size="middle"
/>
</div>
<div className="user-preference-section-content-item-description">
Select if SigNoz&apos;s appearance should be light or dark
Select if SigNoz&apos;s appearance should be light, dark, or
automatically follow your system preference
</div>
{autoSwitch && (
<div className="auto-theme-info">
<div className="auto-theme-status">
Currently following system theme:{' '}
<strong>{systemTheme === 'dark' ? 'Dark' : 'Light'}</strong>
</div>
</div>
)}
</div>
<TimezoneAdaptation />

View File

@@ -1,66 +1,74 @@
// 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);
.add-span-to-funnel-modal {
&__loading-spinner {
display: flex;
align-items: center;
justify-content: center;
height: 400px;
}
&-container {
.ant-modal {
&-content,
&-header {
background: var(--bg-ink-500);
}
}
&-body {
padding: 14px 16px !important;
padding-bottom: 0 !important;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
&-header {
border-bottom: none;
&-footer {
margin-top: 0;
background: var(--bg-ink-400);
border-top: 1px solid var(--bg-slate-500);
padding: 16px !important;
.add-span-to-funnel-modal {
&__save-button {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
.ant-modal-title {
color: var(--bg-vanilla-100);
font-size: 12px;
font-weight: 500;
line-height: 24px;
width: 135px;
}
}
.ant-btn-icon {
&-body {
padding: 14px 16px !important;
padding-bottom: 0 !important;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
&-footer {
margin-top: 0;
background: var(--bg-ink-400);
border-top: 1px solid var(--bg-slate-500);
padding: 16px !important;
.add-span-to-funnel-modal {
&__save-button {
display: flex;
}
&:disabled {
color: var(--bg-vanilla-400);
align-items: center;
justify-content: center;
gap: 4px;
color: var(--bg-vanilla-100);
font-size: 12px;
font-weight: 500;
line-height: 24px;
width: 135px;
.ant-btn-icon {
svg {
stroke: var(--bg-vanilla-400);
display: flex;
}
&:disabled {
color: var(--bg-vanilla-400);
.ant-btn-icon {
svg {
stroke: var(--bg-vanilla-400);
}
}
}
}
&__discard-button {
background: var(--bg-slate-500);
}
}
&__discard-button {
background: var(--bg-slate-500);
.ant-btn {
border-radius: 2px;
padding: 4px 8px;
margin: 0 !important;
border: none;
box-shadow: none;
}
}
.ant-btn {
border-radius: 2px;
padding: 4px 8px;
margin: 0 !important;
border: none;
box-shadow: none;
}
}
}
}
@@ -89,7 +97,7 @@
}
.steps-content {
height: 500px;
max-height: 500px;
}
}
}

View File

@@ -99,6 +99,7 @@ function AddSpanToFunnelModal({
const [triggerSave, setTriggerSave] = useState<boolean>(false);
const [isUnsavedChanges, setIsUnsavedChanges] = useState<boolean>(false);
const [triggerDiscard, setTriggerDiscard] = useState<boolean>(false);
const [isCreatedFromSpan, setIsCreatedFromSpan] = useState<boolean>(false);
const handleSearch = (e: ChangeEvent<HTMLInputElement>): void => {
setSearchQuery(e.target.value);
@@ -126,6 +127,7 @@ function AddSpanToFunnelModal({
const handleFunnelClick = (funnel: FunnelData): void => {
setSelectedFunnelId(funnel.funnel_id);
setActiveView(ModalView.DETAILS);
setIsCreatedFromSpan(false);
};
const handleBack = (): void => {
@@ -133,6 +135,7 @@ function AddSpanToFunnelModal({
setSelectedFunnelId(undefined);
setIsUnsavedChanges(false);
setTriggerSave(false);
setIsCreatedFromSpan(false);
};
const handleCreateNewClick = (): void => {
@@ -188,6 +191,7 @@ function AddSpanToFunnelModal({
if (funnelId) {
setSelectedFunnelId(funnelId);
setActiveView(ModalView.DETAILS);
setIsCreatedFromSpan(true);
}
setIsCreateModalOpen(false);
}}
@@ -206,15 +210,18 @@ function AddSpanToFunnelModal({
<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">
<div className="traces-funnel-details">
<div className="traces-funnel-details__steps-config">
<Spin
className="add-span-to-funnel-modal__loading-spinner"
spinning={isFunnelDetailsLoading || isFunnelDetailsFetching}
indicator={<LoadingOutlined spin />}
>
{selectedFunnelId && funnelDetails?.payload && (
<FunnelProvider funnelId={selectedFunnelId}>
<FunnelProvider
funnelId={selectedFunnelId}
hasSingleStep={isCreatedFromSpan}
>
<FunnelDetailsView
funnel={funnelDetails.payload}
span={span}
@@ -225,9 +232,9 @@ function AddSpanToFunnelModal({
/>
</FunnelProvider>
)}
</div>
</Spin>
</div>
</Spin>
</div>
</div>
);

View File

@@ -22,6 +22,7 @@ import {
ChevronRight,
Leaf,
} from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import {
Dispatch,
SetStateAction,
@@ -70,10 +71,10 @@ 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;
const { hasEditPermission } = useAppContext();
let color = generateColor(span.serviceName, themeColors.traceDetailColors);
if (span.hasError) {
@@ -152,23 +153,32 @@ function SpanOverview({
{!!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"
/>
<Tooltip
title={
!hasEditPermission
? 'You need editor or admin access to add spans to funnels'
: ''
}
/>
>
<Button
type="text"
size="small"
className="add-funnel-button__button"
onClick={(e): void => {
e.preventDefault();
e.stopPropagation();
handleAddSpanToFunnel(span);
}}
disabled={!hasEditPermission}
icon={
<img
className="add-funnel-button__icon"
src="/Icons/funnel-add.svg"
alt="funnel-icon"
/>
}
/>
</Tooltip>
</div>
)}
</section>

View File

@@ -2,6 +2,7 @@ import { getAggregateKeys } from 'api/queryBuilder/getAttributeKeys';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { OPERATORS, QueryBuilderKeys } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { MetricsType } from 'container/MetricsApplication/constant';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useNotifications } from 'hooks/useNotifications';
@@ -82,6 +83,7 @@ export const useActiveLog = (): UseActiveLog => {
operator: string,
isJSON?: boolean,
dataType?: DataTypes,
fieldType?: MetricsType | undefined,
): Promise<void> => {
try {
const keysAutocompleteResponse = await queryClient.fetchQuery(
@@ -104,6 +106,7 @@ export const useActiveLog = (): UseActiveLog => {
fieldKey,
isJSON,
dataType,
fieldType,
);
const currentOperator = getOperatorValue(operator);

View File

@@ -0,0 +1,169 @@
import { act, renderHook } from '@testing-library/react';
import {
ThemeProvider,
useIsDarkMode,
useSystemTheme,
useThemeMode,
} from '../index';
// Mock localStorage
const localStorageMock = {
getItem: jest.fn(),
setItem: jest.fn(),
clear: jest.fn(),
};
Object.defineProperty(window, 'localStorage', {
value: localStorageMock,
});
// Helper function to create matchMedia mock
const createMatchMediaMock = (prefersDark: boolean): jest.Mock =>
jest.fn().mockImplementation((query: string) => ({
matches:
query === '(prefers-color-scheme: dark)' ? prefersDark : !prefersDark,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
}));
// Mock matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: createMatchMediaMock(true), // Default to dark theme
});
describe('useDarkMode', () => {
beforeEach(() => {
jest.clearAllMocks();
localStorageMock.getItem.mockReturnValue(null);
});
describe('useSystemTheme', () => {
it('should return dark theme by default', () => {
const { result } = renderHook(() => useSystemTheme());
expect(result.current).toBe('dark');
});
it('should return light theme when system prefers light', () => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: createMatchMediaMock(false), // Light theme
});
const { result } = renderHook(() => useSystemTheme());
expect(result.current).toBe('light');
});
});
describe('ThemeProvider', () => {
it('should provide theme context with default values', () => {
const wrapper = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => <ThemeProvider>{children}</ThemeProvider>;
const { result } = renderHook(() => useThemeMode(), { wrapper });
expect(result.current.theme).toBe('dark');
expect(typeof result.current.toggleTheme).toBe('function');
expect(result.current.autoSwitch).toBe(false);
expect(typeof result.current.setAutoSwitch).toBe('function');
});
it('should load theme from localStorage', () => {
localStorageMock.getItem.mockImplementation((key: string) => {
if (key === 'THEME') return 'light';
if (key === 'THEME_AUTO_SWITCH') return 'true';
return null;
});
const wrapper = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => <ThemeProvider>{children}</ThemeProvider>;
const { result } = renderHook(() => useThemeMode(), { wrapper });
expect(result.current.theme).toBe('light');
expect(result.current.autoSwitch).toBe(true);
});
it('should toggle theme correctly', () => {
const wrapper = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => <ThemeProvider>{children}</ThemeProvider>;
const { result } = renderHook(() => useThemeMode(), { wrapper });
act(() => {
result.current.toggleTheme();
});
expect(result.current.theme).toBe('light');
expect(localStorageMock.setItem).toHaveBeenCalledWith('THEME', 'light');
});
it('should handle auto-switch functionality', () => {
// Mock system theme as light
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: createMatchMediaMock(false), // Light theme
});
const wrapper = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => <ThemeProvider>{children}</ThemeProvider>;
const { result } = renderHook(() => useThemeMode(), { wrapper });
act(() => {
result.current.setAutoSwitch(true);
});
expect(result.current.autoSwitch).toBe(true);
expect(localStorageMock.setItem).toHaveBeenCalledWith(
'THEME_AUTO_SWITCH',
'true',
);
});
});
describe('useIsDarkMode', () => {
it('should return true for dark theme', () => {
localStorageMock.getItem.mockReturnValue('dark');
const wrapper = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => <ThemeProvider>{children}</ThemeProvider>;
const { result } = renderHook(() => useIsDarkMode(), { wrapper });
expect(result.current).toBe(true);
});
it('should return false for light theme', () => {
localStorageMock.getItem.mockReturnValue('light');
const wrapper = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => <ThemeProvider>{children}</ThemeProvider>;
const { result } = renderHook(() => useIsDarkMode(), { wrapper });
expect(result.current).toBe(false);
});
});
});

View File

@@ -5,9 +5,12 @@ import set from 'api/browser/localstorage/set';
import { LOCALSTORAGE } from 'constants/localStorage';
import {
createContext,
Dispatch,
ReactNode,
SetStateAction,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
@@ -16,13 +19,54 @@ import { THEME_MODE } from './constant';
export const ThemeContext = createContext({
theme: THEME_MODE.DARK,
toggleTheme: () => {},
toggleTheme: (): void => {},
autoSwitch: false,
setAutoSwitch: ((): void => {}) as Dispatch<SetStateAction<boolean>>,
});
// Hook to detect system theme preference
export const useSystemTheme = (): 'light' | 'dark' => {
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>('dark');
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
setSystemTheme(mediaQuery.matches ? 'dark' : 'light');
const handler = (e: MediaQueryListEvent): void => {
setSystemTheme(e.matches ? 'dark' : 'light');
};
mediaQuery.addEventListener('change', handler);
return (): void => mediaQuery.removeEventListener('change', handler);
}, []);
return systemTheme;
};
export function ThemeProvider({ children }: ThemeProviderProps): JSX.Element {
const [theme, setTheme] = useState(get(LOCALSTORAGE.THEME) || THEME_MODE.DARK);
const [autoSwitch, setAutoSwitch] = useState(
get(LOCALSTORAGE.THEME_AUTO_SWITCH) === 'true',
);
const systemTheme = useSystemTheme();
const toggleTheme = useCallback(() => {
// Handle auto-switch functionality
useEffect(() => {
if (autoSwitch) {
const newTheme = systemTheme === 'dark' ? THEME_MODE.DARK : THEME_MODE.LIGHT;
if (newTheme !== theme) {
setTheme(newTheme);
set(LOCALSTORAGE.THEME, newTheme);
}
}
}, [systemTheme, autoSwitch, theme]);
// Save auto-switch preference
useEffect(() => {
set(LOCALSTORAGE.THEME_AUTO_SWITCH, autoSwitch.toString());
}, [autoSwitch]);
const toggleTheme = useCallback((): void => {
if (theme === THEME_MODE.LIGHT) {
setTheme(THEME_MODE.DARK);
set(LOCALSTORAGE.THEME, THEME_MODE.DARK);
@@ -37,8 +81,10 @@ export function ThemeProvider({ children }: ThemeProviderProps): JSX.Element {
() => ({
theme,
toggleTheme,
autoSwitch,
setAutoSwitch,
}),
[theme, toggleTheme],
[theme, toggleTheme, autoSwitch, setAutoSwitch],
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
@@ -51,12 +97,16 @@ interface ThemeProviderProps {
interface ThemeMode {
theme: string;
toggleTheme: () => void;
autoSwitch: boolean;
setAutoSwitch: Dispatch<SetStateAction<boolean>>;
}
export const useThemeMode = (): ThemeMode => {
const { theme, toggleTheme } = useContext(ThemeContext);
const { theme, toggleTheme, autoSwitch, setAutoSwitch } = useContext(
ThemeContext,
);
return { theme, toggleTheme };
return { theme, toggleTheme, autoSwitch, setAutoSwitch };
};
export const useIsDarkMode = (): boolean => {

View File

@@ -1,9 +1,14 @@
import { initialAutocompleteData } from 'constants/queryBuilder';
import {
baseAutoCompleteIdKeysOrder,
initialAutocompleteData,
} from 'constants/queryBuilder';
import { MetricsType } from 'container/MetricsApplication/constant';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { createIdFromObjectFields } from '../createIdFromObjectFields';
import { chooseAutocompleteFromCustomValue } from '../newQueryBuilder/chooseAutocompleteFromCustomValue';
describe('chooseAutocompleteFromCustomValue', () => {
@@ -13,36 +18,108 @@ describe('chooseAutocompleteFromCustomValue', () => {
key: 'string_key',
dataType: DataTypes.String,
isJSON: false,
isColumn: false,
type: '',
id: createIdFromObjectFields(
{
dataType: DataTypes.String,
key: 'string_key',
isColumn: false,
type: '',
},
baseAutoCompleteIdKeysOrder,
),
},
{
key: 'number_key',
dataType: DataTypes.Float64,
isJSON: false,
isColumn: false,
type: '',
id: createIdFromObjectFields(
{
dataType: DataTypes.Float64,
key: 'number_key',
isColumn: false,
type: '',
},
baseAutoCompleteIdKeysOrder,
),
},
{
key: 'bool_key',
dataType: DataTypes.bool,
isJSON: false,
isColumn: false,
type: '',
id: createIdFromObjectFields(
{ dataType: DataTypes.bool, key: 'bool_key', isColumn: false, type: '' },
baseAutoCompleteIdKeysOrder,
),
},
{
key: 'float_key',
dataType: DataTypes.Float64,
isJSON: false,
isColumn: false,
type: '',
id: createIdFromObjectFields(
{
dataType: DataTypes.Float64,
key: 'float_key',
isColumn: false,
type: '',
},
baseAutoCompleteIdKeysOrder,
),
},
{
key: 'unknown_key',
dataType: DataTypes.EMPTY,
isJSON: false,
isColumn: false,
type: '',
id: createIdFromObjectFields(
{
dataType: DataTypes.EMPTY,
key: 'unknown_key',
isColumn: false,
type: '',
},
baseAutoCompleteIdKeysOrder,
),
},
{
key: 'duplicate_key',
dataType: DataTypes.String,
isJSON: false,
isColumn: false,
type: '',
id: createIdFromObjectFields(
{
dataType: DataTypes.String,
key: 'duplicate_key',
isColumn: false,
type: '',
},
baseAutoCompleteIdKeysOrder,
),
},
{
key: 'duplicate_key',
dataType: DataTypes.Float64,
isJSON: false,
isColumn: false,
type: '',
id: createIdFromObjectFields(
{
dataType: DataTypes.Float64,
key: 'duplicate_key',
isColumn: false,
type: '',
},
baseAutoCompleteIdKeysOrder,
),
},
] as BaseAutocompleteData[];
@@ -115,7 +192,23 @@ describe('chooseAutocompleteFromCustomValue', () => {
// Test case: Perfect match with isJSON true in sourceList
it('should return matching element with isJSON true', () => {
const jsonSourceList = [
{ key: 'json_key', dataType: DataTypes.String, isJSON: true },
{
key: 'json_key',
dataType: DataTypes.String,
isJSON: true,
isColumn: false,
type: '',
id: createIdFromObjectFields(
{
dataType: DataTypes.String,
key: 'json_key',
isColumn: false,
type: '',
isJSON: true,
},
baseAutoCompleteIdKeysOrder,
),
},
];
const result = chooseAutocompleteFromCustomValue(
jsonSourceList as BaseAutocompleteData[],
@@ -293,4 +386,258 @@ describe('chooseAutocompleteFromCustomValue', () => {
});
});
});
describe('when element with same value, same data type, and same fieldType found in sourceList', () => {
const fieldTypeMockSourceList = [
{
key: 'tag_key',
dataType: DataTypes.String,
isJSON: false,
type: MetricsType.Tag,
isColumn: false,
id: createIdFromObjectFields(
{
dataType: DataTypes.String,
key: 'tag_key',
isColumn: false,
type: MetricsType.Tag,
},
baseAutoCompleteIdKeysOrder,
),
},
{
key: 'resource_key',
dataType: DataTypes.Float64,
isJSON: false,
type: MetricsType.Resource,
isColumn: false,
id: createIdFromObjectFields(
{
dataType: DataTypes.Float64,
key: 'resource_key',
isColumn: false,
type: MetricsType.Resource,
},
baseAutoCompleteIdKeysOrder,
),
},
{
key: 'scope_key',
dataType: DataTypes.bool,
isJSON: false,
type: MetricsType.Scope,
isColumn: false,
id: createIdFromObjectFields(
{
dataType: DataTypes.bool,
key: 'scope_key',
isColumn: false,
type: MetricsType.Scope,
},
baseAutoCompleteIdKeysOrder,
),
},
{
key: 'tag_key_duplicate',
dataType: DataTypes.String,
isJSON: false,
type: MetricsType.Tag,
isColumn: false,
id: createIdFromObjectFields(
{
dataType: DataTypes.String,
key: 'tag_key_duplicate',
isColumn: false,
type: MetricsType.Tag,
},
baseAutoCompleteIdKeysOrder,
),
},
{
key: 'tag_key_duplicate',
dataType: DataTypes.String,
isJSON: false,
type: MetricsType.Resource,
isColumn: false,
id: createIdFromObjectFields(
{
dataType: DataTypes.String,
key: 'tag_key_duplicate',
isColumn: false,
type: MetricsType.Resource,
},
baseAutoCompleteIdKeysOrder,
),
},
] as BaseAutocompleteData[];
it('should return matching element for Tag fieldType', () => {
const result = chooseAutocompleteFromCustomValue(
fieldTypeMockSourceList,
'tag_key',
false,
'string' as DataTypes,
MetricsType.Tag,
);
expect(result).toEqual(fieldTypeMockSourceList[0]);
});
it('should return matching element for Resource fieldType', () => {
const result = chooseAutocompleteFromCustomValue(
fieldTypeMockSourceList,
'resource_key',
false,
'number' as DataTypes,
MetricsType.Resource,
);
expect(result).toEqual(fieldTypeMockSourceList[1]);
});
it('should return matching element for Scope fieldType', () => {
const result = chooseAutocompleteFromCustomValue(
fieldTypeMockSourceList,
'scope_key',
false,
'bool' as DataTypes,
MetricsType.Scope,
);
expect(result).toEqual(fieldTypeMockSourceList[2]);
});
it('should return the correct duplicate with matching fieldType', () => {
const result = chooseAutocompleteFromCustomValue(
fieldTypeMockSourceList,
'tag_key_duplicate',
false,
'string' as DataTypes,
MetricsType.Resource,
);
expect(result).toEqual(fieldTypeMockSourceList[4]);
});
});
describe('when element with same value and data type but different fieldType found in sourceList', () => {
const fieldTypeMockSourceList = [
{
key: 'test_key',
dataType: DataTypes.String,
isJSON: false,
type: MetricsType.Tag,
isColumn: false,
id: createIdFromObjectFields(
{
dataType: DataTypes.String,
key: 'test_key',
isColumn: false,
type: MetricsType.Tag,
},
baseAutoCompleteIdKeysOrder,
),
},
] as BaseAutocompleteData[];
it('should return new object with updated fieldType when existing element has different fieldType', () => {
const result = chooseAutocompleteFromCustomValue(
fieldTypeMockSourceList,
'test_key',
false,
'string' as DataTypes,
MetricsType.Resource,
);
expect(result).toEqual({
...initialAutocompleteData,
key: 'test_key',
dataType: DataTypes.String,
isJSON: false,
type: MetricsType.Resource,
});
});
});
describe('when element not found in sourceList but fieldType is provided', () => {
it('should return new object with Tag fieldType', () => {
const result = chooseAutocompleteFromCustomValue(
mockSourceList,
'new_key_with_tag_type',
false,
'string' as DataTypes,
MetricsType.Tag,
);
expect(result).toEqual({
...initialAutocompleteData,
key: 'new_key_with_tag_type',
dataType: DataTypes.String,
isJSON: false,
type: MetricsType.Tag,
});
});
it('should return new object with Resource fieldType', () => {
const result = chooseAutocompleteFromCustomValue(
mockSourceList,
'new_key_with_resource_type',
false,
'number' as DataTypes,
MetricsType.Resource,
);
expect(result).toEqual({
...initialAutocompleteData,
key: 'new_key_with_resource_type',
dataType: DataTypes.Float64,
isJSON: false,
type: MetricsType.Resource,
});
});
it('should return new object with Scope fieldType', () => {
const result = chooseAutocompleteFromCustomValue(
mockSourceList,
'new_key_with_scope_type',
false,
'bool' as DataTypes,
MetricsType.Scope,
);
expect(result).toEqual({
...initialAutocompleteData,
key: 'new_key_with_scope_type',
dataType: DataTypes.bool,
isJSON: false,
type: MetricsType.Scope,
});
});
it('should return new object with empty fieldType when undefined is passed', () => {
const result = chooseAutocompleteFromCustomValue(
mockSourceList,
'new_key_with_undefined_type',
false,
'string' as DataTypes,
undefined,
);
expect(result).toEqual({
...initialAutocompleteData,
key: 'new_key_with_undefined_type',
dataType: DataTypes.String,
isJSON: false,
type: '',
});
});
it('should return new object with isJSON true and fieldType when not found', () => {
const result = chooseAutocompleteFromCustomValue(
mockSourceList,
'json_not_found_with_type',
true,
'string' as DataTypes,
MetricsType.Tag,
);
expect(result).toEqual({
...initialAutocompleteData,
key: 'json_not_found_with_type',
dataType: DataTypes.String,
isJSON: true,
type: MetricsType.Tag,
});
});
});
});

View File

@@ -1,4 +1,5 @@
import { initialAutocompleteData } from 'constants/queryBuilder';
import { MetricsType } from 'container/MetricsApplication/constant';
import {
BaseAutocompleteData,
DataTypes,
@@ -25,12 +26,15 @@ export const chooseAutocompleteFromCustomValue = (
value: string,
isJSON?: boolean,
dataType?: DataTypes | 'number',
fieldType?: MetricsType | undefined,
): BaseAutocompleteData => {
const dataTypeToUse = getDataTypeForCustomValue(dataType);
const firstBaseAutoCompleteValue = sourceList.find(
(sourceAutoComplete) =>
value === sourceAutoComplete.key &&
(dataType === undefined || dataTypeToUse === sourceAutoComplete.dataType),
(dataType === undefined || dataTypeToUse === sourceAutoComplete.dataType) &&
((fieldType === undefined && sourceAutoComplete.type === '') ||
(fieldType !== undefined && fieldType === sourceAutoComplete.type)),
);
if (!firstBaseAutoCompleteValue) {
@@ -38,6 +42,7 @@ export const chooseAutocompleteFromCustomValue = (
...initialAutocompleteData,
key: value,
dataType: dataTypeToUse,
type: fieldType || '',
isJSON,
};
}

View File

@@ -8,6 +8,7 @@ import { PencilLine } from 'lucide-react';
import FunnelItemPopover from 'pages/TracesFunnels/components/FunnelsList/FunnelItemPopover';
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
import CopyToClipboard from 'periscope/components/CopyToClipboard';
import { useAppContext } from 'providers/App/App';
import { memo, useState } from 'react';
import { Span } from 'types/api/trace/getTraceV2';
import { FunnelData } from 'types/api/traceFunnels';
@@ -33,6 +34,7 @@ function FunnelConfiguration({
triggerAutoSave,
showNotifications,
}: FunnelConfigurationProps): JSX.Element {
const { hasEditPermission } = useAppContext();
const { triggerSave } = useFunnelContext();
const {
isPopoverOpen,
@@ -62,7 +64,10 @@ function FunnelConfiguration({
<div className="funnel-configuration__header-right">
<Tooltip
title={
funnel?.description
// eslint-disable-next-line no-nested-ternary
!hasEditPermission
? 'You need editor or admin access to edit funnel description'
: funnel?.description
? 'Edit funnel description'
: 'Add funnel description'
}
@@ -73,6 +78,7 @@ function FunnelConfiguration({
icon={<PencilLine size={14} />}
onClick={(): void => setIsDescriptionModalOpen(true)}
aria-label="Edit Funnel Description"
disabled={!hasEditPermission}
/>
</Tooltip>
<CopyToClipboard textToCopy={window.location.href} />

View File

@@ -10,6 +10,37 @@
border: 1px solid var(--bg-slate-500);
border-radius: 6px;
width: 100%;
&--readonly {
opacity: 0.7;
.filters {
pointer-events: none;
.ant-select-selector {
cursor: not-allowed;
}
.ant-select {
cursor: not-allowed;
}
.query-builder-search-v2 {
.ant-select-selector {
cursor: not-allowed;
}
.ant-select {
cursor: not-allowed;
}
}
}
.error__switch {
opacity: 0.5;
cursor: not-allowed;
}
}
.step-popover {
opacity: 0;
width: 22px;

View File

@@ -1,12 +1,14 @@
import './FunnelStep.styles.scss';
import { Button, Divider, Form, Switch, Tooltip } from 'antd';
import cx from 'classnames';
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 { HardHat, PencilLine } from 'lucide-react';
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
import { useAppContext } from 'providers/App/App';
import { useMemo, useState } from 'react';
import { FunnelStepData } from 'types/api/traceFunnels';
import { DataSource } from 'types/common/queryBuilder';
@@ -69,8 +71,14 @@ function FunnelStep({
const query = updatedCurrentQuery?.builder?.queryData[0] || null;
const { hasEditPermission } = useAppContext();
return (
<div className="funnel-step">
<div
className={cx('funnel-step', {
'funnel-step--readonly': !hasEditPermission,
})}
>
<Form form={form}>
<div className="funnel-step__header">
<div className="funnel-step-details">
@@ -92,12 +100,19 @@ function FunnelStep({
)}
</div>
<div className="funnel-step-actions">
<Tooltip title="Add details to step">
<Tooltip
title={
!hasEditPermission
? 'You need editor or admin access to add details to step'
: 'Add details to step'
}
>
<Button
type="text"
className="funnel-item__action-btn"
icon={<PencilLine size={14} />}
onClick={(): void => setIsAddDetailsModalOpen(true)}
disabled={!hasEditPermission}
/>
</Tooltip>
@@ -129,9 +144,13 @@ function FunnelStep({
shouldSetQueryParams={false}
values={stepData.service_name}
isMultiple={false}
onChange={(v): void => {
onStepChange(index, { service_name: (v ?? '') as string });
}}
onChange={
hasEditPermission
? (v): void => {
onStepChange(index, { service_name: (v ?? '') as string });
}
: undefined
}
/>
</Form.Item>
</div>
@@ -144,8 +163,11 @@ function FunnelStep({
shouldSetQueryParams={false}
values={stepData.span_name}
isMultiple={false}
onChange={(v): void =>
onStepChange(index, { span_name: (v ?? '') as string })
onChange={
hasEditPermission
? (v): void =>
onStepChange(index, { span_name: (v ?? '') as string })
: undefined
}
/>
</Form.Item>
@@ -156,7 +178,11 @@ function FunnelStep({
<Form.Item name={['steps', stepData.id, 'filters']}>
<QueryBuilderSearchV2
query={query}
onChange={(query): void => onStepChange(index, { filters: query })}
onChange={
hasEditPermission
? (query): void => onStepChange(index, { filters: query })
: (): void => {}
}
hasPopupContainer={false}
placeholder="Search for filters..."
suffixIcon={<HardHat size={12} color="var(--bg-vanilla-400)" />}
@@ -172,6 +198,7 @@ function FunnelStep({
className="error__switch"
size="small"
checked={stepData.has_errors}
disabled={!hasEditPermission}
onChange={(): void =>
onStepChange(index, { has_errors: !stepData.has_errors })
}

View File

@@ -1,6 +1,7 @@
import { Button, Popover, Tooltip } from 'antd';
import cx from 'classnames';
import { Ellipsis, PencilLine, Trash2 } from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import { useState } from 'react';
import { FunnelStepData } from 'types/api/traceFunnels';
@@ -27,6 +28,7 @@ interface FunnelStepActionsProps {
setIsAddDetailsModalOpen: (isOpen: boolean) => void;
setIsDeleteModalOpen: (isOpen: boolean) => void;
stepsCount: number;
hasEditPermission: boolean;
}
function FunnelStepActions({
@@ -34,6 +36,7 @@ function FunnelStepActions({
setIsAddDetailsModalOpen,
setIsDeleteModalOpen,
stepsCount,
hasEditPermission,
}: FunnelStepActionsProps): JSX.Element {
return (
<div className="funnel-item__actions">
@@ -41,6 +44,7 @@ function FunnelStepActions({
type="text"
className="funnel-item__action-btn"
icon={<PencilLine size={14} />}
disabled={!hasEditPermission}
onClick={(): void => {
setIsPopoverOpen(false);
setIsAddDetailsModalOpen(true);
@@ -49,12 +53,21 @@ function FunnelStepActions({
Add details
</Button>
<Tooltip title={stepsCount <= 2 ? 'Minimum 2 steps required' : 'Delete'}>
<Tooltip
title={
// eslint-disable-next-line no-nested-ternary
!hasEditPermission
? 'You need editor or admin access to delete steps'
: 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}
disabled={stepsCount <= 2 || !hasEditPermission}
onClick={(): void => {
if (stepsCount > 2) {
setIsPopoverOpen(false);
@@ -80,12 +93,26 @@ function FunnelStepPopover({
setIsAddDetailsModalOpen,
}: FunnelStepPopoverProps): JSX.Element {
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState<boolean>(false);
const { hasEditPermission } = useAppContext();
const preventDefault = (e: React.MouseEvent | React.KeyboardEvent): void => {
e.preventDefault();
e.stopPropagation();
};
if (!hasEditPermission) {
return (
<Tooltip title="You need editor or admin access to add details to step">
<Button
type="text"
className="funnel-item__action-btn"
icon={<Ellipsis size={14} />}
disabled
/>
</Tooltip>
);
}
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
<div onClick={preventDefault} role="button" tabIndex={0}>
@@ -100,6 +127,7 @@ function FunnelStepPopover({
setIsPopoverOpen={setIsPopoverOpen}
setIsAddDetailsModalOpen={setIsAddDetailsModalOpen}
stepsCount={stepsCount}
hasEditPermission={hasEditPermission}
/>
}
placement="bottomRight"

View File

@@ -3,6 +3,7 @@ import './InterStepConfig.styles.scss';
import { Divider } from 'antd';
import SignozRadioGroup from 'components/SignozRadioGroup/SignozRadioGroup';
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
import { useAppContext } from 'providers/App/App';
import { FunnelStepData, LatencyOptions } from 'types/api/traceFunnels';
function InterStepConfig({
@@ -13,6 +14,7 @@ function InterStepConfig({
step: FunnelStepData;
}): JSX.Element {
const { handleStepChange: onStepChange } = useFunnelContext();
const { hasEditPermission } = useAppContext();
const options = Object.entries(LatencyOptions).map(([key, value]) => ({
label: key,
value,
@@ -28,11 +30,15 @@ function InterStepConfig({
<SignozRadioGroup
value={step.latency_type ?? LatencyOptions.P99}
options={options}
onChange={(e): void =>
onStepChange(index, {
...step,
latency_type: e.target.value,
})
disabled={!hasEditPermission}
onChange={
hasEditPermission
? (e): void =>
onStepChange(index, {
...step,
latency_type: e.target.value,
})
: (): void => {}
}
/>
</div>

View File

@@ -1,9 +1,10 @@
import './StepsContent.styles.scss';
import { Button, Steps } from 'antd';
import { Button, Steps, Tooltip } from 'antd';
import logEvent from 'api/common/logEvent';
import { PlusIcon, Undo2 } from 'lucide-react';
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
import { useAppContext } from 'providers/App/App';
import { memo, useCallback } from 'react';
import { Span } from 'types/api/trace/getTraceV2';
@@ -20,9 +21,10 @@ function StepsContent({
span?: Span;
}): JSX.Element {
const { steps, handleAddStep, handleReplaceStep } = useFunnelContext();
const { hasEditPermission } = useAppContext();
const handleAddForNewStep = useCallback(() => {
if (!span) return;
if (!span || !hasEditPermission) return;
const stepWasAdded = handleAddStep();
if (stepWasAdded) {
@@ -32,7 +34,7 @@ function StepsContent({
'Trace Funnels: span added for a new step from trace details page',
{},
);
}, [span, handleAddStep, handleReplaceStep, steps.length]);
}, [span, handleAddStep, handleReplaceStep, steps.length, hasEditPermission]);
return (
<div className="steps-content">
@@ -45,20 +47,29 @@ function StepsContent({
<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)
<Tooltip
title={
!hasEditPermission
? 'You need editor or admin access to replace steps'
: ''
}
>
Replace
</Button>
<Button
type="default"
className="funnel-step-wrapper__replace-button"
icon={<Undo2 size={12} />}
disabled={
(step.service_name === span.serviceName &&
step.span_name === span.name) ||
!hasEditPermission
}
onClick={(): void =>
handleReplaceStep(index, span.serviceName, span.name)
}
>
Replace
</Button>
</Tooltip>
)}
</div>
{/* Display InterStepConfig only between steps */}
@@ -76,23 +87,41 @@ function StepsContent({
className="steps-content__add-step"
description={
!isTraceDetailsPage ? (
<Button
type="default"
className="steps-content__add-btn"
onClick={handleAddStep}
icon={<PlusIcon size={14} />}
<Tooltip
title={
!hasEditPermission
? 'You need editor or admin access to add steps'
: ''
}
>
Add Funnel Step
</Button>
<Button
type="default"
className="steps-content__add-btn"
onClick={handleAddStep}
icon={<PlusIcon size={14} />}
disabled={!hasEditPermission}
>
Add Funnel Step
</Button>
</Tooltip>
) : (
<Button
type="default"
className="steps-content__add-btn"
onClick={handleAddForNewStep}
icon={<PlusIcon size={14} />}
<Tooltip
title={
!hasEditPermission
? 'You need editor or admin access to add steps'
: ''
}
>
Add for new Step
</Button>
<Button
type="default"
className="steps-content__add-btn"
onClick={handleAddForNewStep}
icon={<PlusIcon size={14} />}
disabled={!hasEditPermission}
>
Add for new Step
</Button>
</Tooltip>
)
}
/>

View File

@@ -1,7 +1,7 @@
import { FunnelStepData, LatencyOptions } from 'types/api/traceFunnels';
import { v4 } from 'uuid';
export const initialStepsData: FunnelStepData[] = [
export const createInitialStepsData = (): FunnelStepData[] => [
{
id: v4(),
step_order: 1,
@@ -12,7 +12,6 @@ export const initialStepsData: FunnelStepData[] = [
op: 'and',
},
latency_pointer: 'start',
latency_type: undefined,
has_errors: false,
},
{
@@ -30,6 +29,21 @@ export const initialStepsData: FunnelStepData[] = [
},
];
export const createSingleStepData = (): FunnelStepData[] => [
{
id: v4(),
step_order: 1,
service_name: '',
span_name: '',
filters: {
items: [],
op: 'and',
},
latency_pointer: 'start',
has_errors: false,
},
];
export const LatencyPointers: {
value: FunnelStepData['latency_pointer'];
key: string;

View File

@@ -10,7 +10,10 @@ import { normalizeSteps } from 'hooks/TracesFunnels/useFunnelConfiguration';
import { useValidateFunnelSteps } from 'hooks/TracesFunnels/useFunnels';
import getStartEndRangeTime from 'lib/getStartEndRangeTime';
import { isEqual } from 'lodash-es';
import { initialStepsData } from 'pages/TracesFunnelDetails/constants';
import {
createInitialStepsData,
createSingleStepData,
} from 'pages/TracesFunnelDetails/constants';
import {
createContext,
Dispatch,
@@ -68,9 +71,11 @@ const FunnelContext = createContext<FunnelContextType | undefined>(undefined);
export function FunnelProvider({
children,
funnelId,
hasSingleStep = false,
}: {
children: React.ReactNode;
funnelId: string;
hasSingleStep?: boolean;
}): JSX.Element {
const { selectedTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
@@ -89,7 +94,13 @@ export function FunnelProvider({
funnelId,
]);
const funnel = data?.payload;
const initialSteps = funnel?.steps?.length ? funnel.steps : initialStepsData;
const defaultSteps = useMemo(
() => (hasSingleStep ? createSingleStepData() : createInitialStepsData()),
[hasSingleStep],
);
const initialSteps = funnel?.steps?.length ? funnel.steps : defaultSteps;
const [steps, setSteps] = useState<FunnelStepData[]>(initialSteps);
const [triggerSave, setTriggerSave] = useState<boolean>(false);
const [isUpdatingFunnel, setIsUpdatingFunnel] = useState<boolean>(false);
@@ -155,7 +166,7 @@ export function FunnelProvider({
setSteps((prev) => [
...prev,
{
...initialStepsData[0],
...createInitialStepsData()[0],
id: v4(),
step_order: prev.length + 1,
},
@@ -296,6 +307,10 @@ export function FunnelProvider({
);
}
FunnelProvider.defaultProps = {
hasSingleStep: false,
};
export function useFunnelContext(): FunnelContextType {
const context = useContext(FunnelContext);
if (context === undefined) {

View File

@@ -3,6 +3,7 @@ import './FunnelsEmptyState.styles.scss';
import { Button } from 'antd';
import LearnMore from 'components/LearnMore/LearnMore';
import { Plus } from 'lucide-react';
import { useAppContext } from 'providers/App/App';
interface FunnelsEmptyStateProps {
onCreateFunnel?: () => void;
@@ -11,6 +12,8 @@ interface FunnelsEmptyStateProps {
function FunnelsEmptyState({
onCreateFunnel,
}: FunnelsEmptyStateProps): JSX.Element {
const { hasEditPermission } = useAppContext();
return (
<div className="funnels-empty">
<div className="funnels-empty__content">
@@ -29,14 +32,16 @@ function FunnelsEmptyState({
</section>
<div className="funnels-empty__actions">
<Button
type="primary"
icon={<Plus size={16} />}
onClick={onCreateFunnel}
className="funnels-empty__new-btn"
>
New funnel
</Button>
{hasEditPermission && (
<Button
type="primary"
icon={<Plus size={16} />}
onClick={onCreateFunnel}
className="funnels-empty__new-btn"
>
New funnel
</Button>
)}
<LearnMore url="https://signoz.io/blog/tracing-funnels-observability-distributed-systems/" />
</div>
</div>

View File

@@ -1,6 +1,7 @@
import { Button, Popover } from 'antd';
import { Button, Popover, Tooltip } from 'antd';
import cx from 'classnames';
import { Ellipsis, PencilLine, Trash2 } from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import { useState } from 'react';
import { FunnelData } from 'types/api/traceFunnels';
@@ -61,6 +62,7 @@ function FunnelItemPopover({
}: FunnelItemPopoverProps): JSX.Element {
const [isRenameModalOpen, setIsRenameModalOpen] = useState<boolean>(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState<boolean>(false);
const { hasEditPermission } = useAppContext();
const handleRenameCancel = (): void => {
setIsRenameModalOpen(false);
@@ -71,6 +73,19 @@ function FunnelItemPopover({
e.stopPropagation();
};
if (!hasEditPermission) {
return (
<Tooltip title="You need editor or admin access to edit funnels">
<Button
type="text"
className="funnel-item__action-btn"
icon={<Ellipsis size={14} />}
disabled
/>
</Tooltip>
);
}
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
<div

View File

@@ -1,6 +1,7 @@
import { Color } from '@signozhq/design-tokens';
import { Button, Input, Popover, Typography } from 'antd';
import { Button, Input, Popover, Tooltip, Typography } from 'antd';
import { ArrowDownWideNarrow, Check, Plus, Search } from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import { ChangeEvent } from 'react';
interface SearchBarProps {
@@ -21,6 +22,8 @@ function SearchBar({
onSort,
onCreateFunnel,
}: SearchBarProps): JSX.Element {
const { hasEditPermission } = useAppContext();
return (
<div className="search">
<Popover
@@ -70,14 +73,23 @@ function SearchBar({
value={searchQuery}
onChange={onSearch}
/>
<Button
type="primary"
icon={<Plus size={16} />}
className="search__new-btn"
onClick={onCreateFunnel}
<Tooltip
title={
!hasEditPermission
? 'You need editor or admin access to create funnels'
: ''
}
>
New funnel
</Button>
<Button
type="primary"
icon={<Plus size={16} />}
className="search__new-btn"
onClick={onCreateFunnel}
disabled={!hasEditPermission}
>
New funnel
</Button>
</Tooltip>
</div>
);
}

View File

@@ -48,7 +48,7 @@ $dark-theme: 'darkMode';
&__actions {
display: flex;
align-items: center;
gap: 16px;
gap: 8px;
.ant-btn-link {
color: var(--text-vanilla-400);

View File

@@ -24,9 +24,6 @@ describe('WorkspaceLocked', () => {
});
expect(workspaceLocked).toBeInTheDocument();
const gotQuestionText = await screen.findByText(/got question?/i);
expect(gotQuestionText).toBeInTheDocument();
const contactUsBtn = await screen.findByRole('button', {
name: /Contact Us/i,
});

View File

@@ -18,6 +18,7 @@ import {
} from 'antd';
import logEvent from 'api/common/logEvent';
import updateCreditCardApi from 'api/v1/checkout/create';
import RefreshPaymentStatus from 'components/RefreshPaymentStatus/RefreshPaymentStatus';
import ROUTES from 'constants/routes';
import { useNotifications } from 'hooks/useNotifications';
import history from 'lib/history';
@@ -289,26 +290,28 @@ export default function WorkspaceBlocked(): JSX.Element {
</span>
<span className="workspace-locked__modal__header__actions">
{isAdmin && (
<Button
className="workspace-locked__modal__header__actions__billing"
type="link"
size="small"
role="button"
onClick={handleViewBilling}
>
View Billing
</Button>
<Flex gap={8} justify="center" align="center">
<Button
className="workspace-locked__modal__header__actions__billing"
type="link"
size="small"
role="button"
onClick={handleViewBilling}
>
View Billing
</Button>
<RefreshPaymentStatus btnShape="round" />
</Flex>
)}
<Typography.Text className="workspace-locked__modal__title">
Got Questions?
</Typography.Text>
<Button
type="default"
shape="round"
size="middle"
href="mailto:cloud-support@signoz.io"
role="button"
className="periscope-btn"
onClick={handleContactUsClick}
>
Contact Us
@@ -349,7 +352,7 @@ export default function WorkspaceBlocked(): JSX.Element {
justify="center"
align="middle"
className="workspace-locked__modal__cta"
gutter={[16, 16]}
gutter={[8, 8]}
>
<Col>
<Alert
@@ -360,34 +363,37 @@ export default function WorkspaceBlocked(): JSX.Element {
</Row>
)}
{isAdmin && (
<Row
justify="center"
align="middle"
className="workspace-locked__modal__cta"
gutter={[16, 16]}
>
<Col>
<Button
type="primary"
shape="round"
size="middle"
loading={isLoading}
onClick={handleUpdateCreditCard}
>
Continue my Journey
</Button>
</Col>
<Col>
<Button
type="default"
shape="round"
size="middle"
onClick={handleExtendTrial}
>
{t('needMoreTime')}
</Button>
</Col>
</Row>
<Flex gap={8} vertical justify="center" align="center">
<Row
justify="center"
align="middle"
className="workspace-locked__modal__cta"
gutter={[8, 8]}
>
<Col>
<Button
type="primary"
shape="round"
size="middle"
loading={isLoading}
onClick={handleUpdateCreditCard}
>
Continue my Journey
</Button>
</Col>
<Col>
<Button
type="default"
shape="round"
size="middle"
className="periscope-btn"
onClick={handleExtendTrial}
>
{t('needMoreTime')}
</Button>
</Col>
</Row>
</Flex>
)}
<div className="workspace-locked__tabs">

View File

@@ -4,6 +4,7 @@ import {
Alert,
Button,
Col,
Flex,
Modal,
Row,
Skeleton,
@@ -11,6 +12,7 @@ import {
Typography,
} from 'antd';
import manageCreditCardApi from 'api/v1/portal/create';
import RefreshPaymentStatus from 'components/RefreshPaymentStatus/RefreshPaymentStatus';
import ROUTES from 'constants/routes';
import dayjs from 'dayjs';
import { useNotifications } from 'hooks/useNotifications';
@@ -146,9 +148,9 @@ function WorkspaceSuspended(): JSX.Element {
justify="center"
align="middle"
className="workspace-suspended__modal__cta"
gutter={[16, 16]}
gutter={[8, 8]}
>
<Col>
<Flex gap={8} justify="center" align="center">
<Button
type="primary"
shape="round"
@@ -158,7 +160,8 @@ function WorkspaceSuspended(): JSX.Element {
>
{t('continueMyJourney')}
</Button>
</Col>
<RefreshPaymentStatus btnShape="round" />
</Flex>
</Row>
)}
<div className="workspace-suspended__creative">

View File

@@ -54,6 +54,20 @@
}
}
&.text {
color: var(--bg-vanilla-100) !important;
background-color: transparent !important;
border: none;
box-shadow: none;
box-shadow: none;
padding: 4px 4px;
&:hover {
color: var(--bg-vanilla-300) !important;
background-color: transparent !important;
}
}
&.success {
color: var(--bg-forest-400) !important;
border-radius: 2px;

View File

@@ -321,6 +321,8 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
updateChangelog,
toggleChangelogModal,
versionData: versionData?.payload || null,
hasEditPermission:
user?.role === USER_ROLES.ADMIN || user?.role === USER_ROLES.EDITOR,
}),
[
trialInfo,

View File

@@ -37,6 +37,7 @@ export interface IAppContext {
updateChangelog(payload: ChangelogSchema): void;
toggleChangelogModal(): void;
versionData: PayloadProps | null;
hasEditPermission: boolean;
}
// User

View File

@@ -16,6 +16,25 @@ body {
box-sizing: border-box;
}
// Theme transition animations
* {
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease,
box-shadow 0.3s ease;
}
// For components that shouldn't transition (like loading spinners, animations)
.no-transition,
.no-transition * {
transition: none !important;
}
// Respect user's reduced motion preference
@media (prefers-reduced-motion: reduce) {
* {
transition: none !important;
}
}
.u-legend {
max-height: 30px; // Default height for backward compatibility
overflow-y: auto;

View File

@@ -22,7 +22,7 @@ import {
LicenseState,
LicenseStatus,
} from 'types/api/licensesV3/getActive';
import { ROLES } from 'types/roles';
import { ROLES, USER_ROLES } from 'types/roles';
const queryClient = new QueryClient({
defaultOptions: {
@@ -162,6 +162,7 @@ export function getAppContextMock(
displayName: 'Pentagon',
},
],
hasEditPermission: role === USER_ROLES.ADMIN || role === USER_ROLES.EDITOR,
isFetchingUser: false,
userFetchError: null,
featureFlags: [

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