mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-20 23:30:31 +01:00
Compare commits
24 Commits
v0.90.0
...
remove-sty
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80a2608eb1 | ||
|
|
f1921d0deb | ||
|
|
ec4f66a8c5 | ||
|
|
61e06cb43d | ||
|
|
a576982497 | ||
|
|
55eadf914b | ||
|
|
b91407416b | ||
|
|
24d6d83575 | ||
|
|
fe95ee716a | ||
|
|
b053ce23cd | ||
|
|
57febd2f52 | ||
|
|
ba6a1c594b | ||
|
|
6afdecbd0f | ||
|
|
41661a5e28 | ||
|
|
507dc86af2 | ||
|
|
ff3bb04655 | ||
|
|
31c4f800fc | ||
|
|
51c2bbcd4b | ||
|
|
5610cb1f81 | ||
|
|
478d28eda1 | ||
|
|
ebb2f1fd63 | ||
|
|
629e502703 | ||
|
|
cf4e44d341 | ||
|
|
7ce1a1cbca |
@@ -40,7 +40,7 @@ services:
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
schema-migrator-sync:
|
||||
image: signoz/signoz-schema-migrator:v0.128.1
|
||||
image: signoz/signoz-schema-migrator:v0.128.2
|
||||
container_name: schema-migrator-sync
|
||||
command:
|
||||
- sync
|
||||
@@ -53,7 +53,7 @@ services:
|
||||
condition: service_healthy
|
||||
restart: on-failure
|
||||
schema-migrator-async:
|
||||
image: signoz/signoz-schema-migrator:v0.128.1
|
||||
image: signoz/signoz-schema-migrator:v0.128.2
|
||||
container_name: schema-migrator-async
|
||||
command:
|
||||
- async
|
||||
|
||||
30
.github/CODEOWNERS
vendored
30
.github/CODEOWNERS
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/build-community.yaml
vendored
4
.github/workflows/build-community.yaml
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/build-enterprise.yaml
vendored
4
.github/workflows/build-enterprise.yaml
vendored
@@ -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 }}
|
||||
|
||||
4
.github/workflows/build-staging.yaml
vendored
4
.github/workflows/build-staging.yaml
vendored
@@ -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:
|
||||
|
||||
4
.github/workflows/gor-signoz-community.yaml
vendored
4
.github/workflows/gor-signoz-community.yaml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/gor-signoz.yaml
vendored
2
.github/workflows/gor-signoz.yaml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/integrationci.yaml
vendored
2
.github/workflows/integrationci.yaml
vendored
@@ -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:
|
||||
|
||||
4
.github/workflows/prereleaser.yaml
vendored
4
.github/workflows/prereleaser.yaml
vendored
@@ -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:
|
||||
|
||||
2
LICENSE
2
LICENSE
@@ -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.
|
||||
|
||||
|
||||
12
Makefile
12
Makefile
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -11,7 +11,7 @@ before:
|
||||
builds:
|
||||
- id: signoz
|
||||
binary: bin/signoz
|
||||
main: pkg/query-service/main.go
|
||||
main: cmd/community
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
- >-
|
||||
@@ -16,4 +16,4 @@ COPY frontend/build/ /etc/signoz/web/
|
||||
|
||||
RUN chmod 755 /root /root/signoz
|
||||
|
||||
ENTRYPOINT ["./signoz"]
|
||||
ENTRYPOINT ["./signoz", "server"]
|
||||
@@ -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
18
cmd/community/main.go
Normal 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
116
cmd/community/server.go
Normal 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
45
cmd/config.go
Normal 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
|
||||
}
|
||||
@@ -11,7 +11,7 @@ before:
|
||||
builds:
|
||||
- id: signoz
|
||||
binary: bin/signoz
|
||||
main: ee/query-service/main.go
|
||||
main: cmd/enterprise
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
- >-
|
||||
@@ -16,4 +16,4 @@ COPY frontend/build/ /etc/signoz/web/
|
||||
|
||||
RUN chmod 755 /root /root/signoz
|
||||
|
||||
ENTRYPOINT ["./signoz"]
|
||||
ENTRYPOINT ["./signoz", "server"]
|
||||
@@ -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"]
|
||||
@@ -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
18
cmd/enterprise/main.go
Normal 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
124
cmd/enterprise/server.go
Normal 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
33
cmd/root.go
Normal 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
15
cmd/zap.go
Normal 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
|
||||
}
|
||||
@@ -174,7 +174,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.90.0
|
||||
image: signoz/signoz:v0.90.1
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
ports:
|
||||
@@ -207,7 +207,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:v0.128.0
|
||||
image: signoz/signoz-otel-collector:v0.128.2
|
||||
command:
|
||||
- --config=/etc/otel-collector-config.yaml
|
||||
- --manager-config=/etc/manager-config.yaml
|
||||
@@ -231,7 +231,7 @@ services:
|
||||
- signoz
|
||||
schema-migrator:
|
||||
!!merge <<: *common
|
||||
image: signoz/signoz-schema-migrator:v0.128.1
|
||||
image: signoz/signoz-schema-migrator:v0.128.2
|
||||
deploy:
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
|
||||
@@ -115,7 +115,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.90.0
|
||||
image: signoz/signoz:v0.90.1
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
ports:
|
||||
@@ -148,7 +148,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:v0.128.0
|
||||
image: signoz/signoz-otel-collector:v0.128.2
|
||||
command:
|
||||
- --config=/etc/otel-collector-config.yaml
|
||||
- --manager-config=/etc/manager-config.yaml
|
||||
@@ -174,7 +174,7 @@ services:
|
||||
- signoz
|
||||
schema-migrator:
|
||||
!!merge <<: *common
|
||||
image: signoz/signoz-schema-migrator:v0.128.1
|
||||
image: signoz/signoz-schema-migrator:v0.128.2
|
||||
deploy:
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
|
||||
@@ -177,7 +177,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.90.0}
|
||||
image: signoz/signoz:${VERSION:-v0.90.1}
|
||||
container_name: signoz
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
@@ -211,7 +211,7 @@ services:
|
||||
# TODO: support otel-collector multiple replicas. Nginx/Traefik for loadbalancing?
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.128.0}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.128.2}
|
||||
container_name: signoz-otel-collector
|
||||
command:
|
||||
- --config=/etc/otel-collector-config.yaml
|
||||
@@ -237,7 +237,7 @@ services:
|
||||
condition: service_healthy
|
||||
schema-migrator-sync:
|
||||
!!merge <<: *common
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.128.0}
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.128.2}
|
||||
container_name: schema-migrator-sync
|
||||
command:
|
||||
- sync
|
||||
@@ -248,7 +248,7 @@ services:
|
||||
condition: service_healthy
|
||||
schema-migrator-async:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.128.0}
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.128.2}
|
||||
container_name: schema-migrator-async
|
||||
command:
|
||||
- async
|
||||
|
||||
@@ -110,7 +110,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.90.0}
|
||||
image: signoz/signoz:${VERSION:-v0.90.1}
|
||||
container_name: signoz
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
@@ -143,7 +143,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.128.0}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.128.2}
|
||||
container_name: signoz-otel-collector
|
||||
command:
|
||||
- --config=/etc/otel-collector-config.yaml
|
||||
@@ -165,7 +165,7 @@ services:
|
||||
condition: service_healthy
|
||||
schema-migrator-sync:
|
||||
!!merge <<: *common
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.128.0}
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.128.2}
|
||||
container_name: schema-migrator-sync
|
||||
command:
|
||||
- sync
|
||||
@@ -177,7 +177,7 @@ services:
|
||||
restart: on-failure
|
||||
schema-migrator-async:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.128.0}
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.128.2}
|
||||
container_name: schema-migrator-async
|
||||
command:
|
||||
- async
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
.vscode
|
||||
README.md
|
||||
signoz.db
|
||||
bin
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
24
frontend/src/api/v3/licenses/post.ts
Normal file
24
frontend/src/api/v3/licenses/post.ts
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
152
frontend/src/components/AppLoading/AppLoading.styles.scss
Normal file
152
frontend/src/components/AppLoading/AppLoading.styles.scss
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
50
frontend/src/components/AppLoading/AppLoading.tsx
Normal file
50
frontend/src/components/AppLoading/AppLoading.tsx
Normal 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;
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -370,6 +370,7 @@ function CustomTimePicker({
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onChange={handleInputChange}
|
||||
data-1p-ignore
|
||||
prefix={
|
||||
inputValue && inputStatus === 'success' ? (
|
||||
<CheckCircle size={14} color="#51E7A8" />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -72,6 +72,7 @@ function SearchBar({
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={0}
|
||||
autoFocus
|
||||
data-1p-ignore
|
||||
/>
|
||||
</div>
|
||||
<kbd className="timezone-picker__esc-key">esc</kbd>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
})}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export const urlKey = {
|
||||
pageSize: 'pageSize',
|
||||
exceptionType: 'exceptionType',
|
||||
serviceName: 'serviceName',
|
||||
compositeQuery: 'compositeQuery',
|
||||
};
|
||||
|
||||
export const isOrderParams = (orderBy: string | null): orderBy is OrderBy =>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,12 @@ 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();
|
||||
}
|
||||
}, [isVisible, changelog, getChangelogByVersionResponse]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
@@ -664,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.'
|
||||
@@ -690,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.'
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -58,6 +58,7 @@ export interface ActionItemProps {
|
||||
operator: string,
|
||||
isJSON?: boolean,
|
||||
dataType?: DataTypes,
|
||||
fieldType?: string,
|
||||
) => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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[
|
||||
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -108,6 +108,7 @@ export function getMetricDetailsQuery(
|
||||
id: `${metricName}----${metricType}---string--`,
|
||||
isColumn: true,
|
||||
isJSON: false,
|
||||
dataType: DataTypes.String,
|
||||
},
|
||||
aggregateOperator,
|
||||
timeAggregation,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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's appearance should be light or dark
|
||||
Select if SigNoz'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 />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
169
frontend/src/hooks/useDarkMode/__tests__/useDarkMode.test.tsx
Normal file
169
frontend/src/hooks/useDarkMode/__tests__/useDarkMode.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user