diff --git a/.devenv/docker/clickhouse/compose.yaml b/.devenv/docker/clickhouse/compose.yaml index d3a825f46d9..b0975859eef 100644 --- a/.devenv/docker/clickhouse/compose.yaml +++ b/.devenv/docker/clickhouse/compose.yaml @@ -42,7 +42,7 @@ services: timeout: 5s retries: 3 schema-migrator-sync: - image: signoz/signoz-schema-migrator:v0.129.6 + image: signoz/signoz-schema-migrator:v0.129.8 container_name: schema-migrator-sync command: - sync @@ -55,7 +55,7 @@ services: condition: service_healthy restart: on-failure schema-migrator-async: - image: signoz/signoz-schema-migrator:v0.129.6 + image: signoz/signoz-schema-migrator:v0.129.8 container_name: schema-migrator-async command: - async diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b525276f814..22f05a6f34f 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -2,7 +2,7 @@ # Owners are automatically requested for review for PRs that changes code # that they own. -/frontend/ @SigNoz/frontend @YounixM +/frontend/ @YounixM @aks07 /frontend/src/container/MetricsApplication @srikanthccv /frontend/src/container/NewWidget/RightContainer/types.ts @srikanthccv @@ -48,13 +48,13 @@ .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 +/pkg/config/ @therealpandey +/pkg/errors/ @therealpandey +/pkg/factory/ @therealpandey +/pkg/types/ @therealpandey +/pkg/valuer/ @therealpandey +/cmd/ @therealpandey +.golangci.yml @therealpandey # Zeus Owners /pkg/zeus/ @vikrantgupta25 @@ -84,4 +84,4 @@ # AuthN / AuthZ Owners -/pkg/authz/ @vikrantgupta25 @grandwizard28 +/pkg/authz/ @vikrantgupta25 @therealpandey diff --git a/.github/workflows/build-community.yaml b/.github/workflows/build-community.yaml index d8d1df3a623..22d80e4a426 100644 --- a/.github/workflows/build-community.yaml +++ b/.github/workflows/build-community.yaml @@ -3,8 +3,8 @@ name: build-community on: push: tags: - - 'v[0-9]+.[0-9]+.[0-9]+' - - 'v[0-9]+.[0-9]+.[0-9]+-rc.[0-9]+' + - "v[0-9]+.[0-9]+.[0-9]+" + - "v[0-9]+.[0-9]+.[0-9]+-rc.[0-9]+" defaults: run: @@ -69,14 +69,13 @@ jobs: GO_BUILD_CONTEXT: ./cmd/community GO_BUILD_FLAGS: >- -tags timetzdata - -ldflags='-linkmode external -extldflags \"-static\" -s -w + -ldflags='-s -w -X github.com/SigNoz/signoz/pkg/version.version=${{ needs.prepare.outputs.version }} -X github.com/SigNoz/signoz/pkg/version.variant=community -X github.com/SigNoz/signoz/pkg/version.hash=${{ needs.prepare.outputs.hash }} -X github.com/SigNoz/signoz/pkg/version.time=${{ needs.prepare.outputs.time }} -X github.com/SigNoz/signoz/pkg/version.branch=${{ needs.prepare.outputs.branch }} -X github.com/SigNoz/signoz/pkg/analytics.key=9kRrJ7oPCGPEJLF6QjMPLt5bljFhRQBr' - GO_CGO_ENABLED: 1 DOCKER_BASE_IMAGES: '{"alpine": "alpine:3.20.3"}' DOCKER_DOCKERFILE_PATH: ./cmd/community/Dockerfile.multi-arch DOCKER_MANIFEST: true diff --git a/.github/workflows/build-enterprise.yaml b/.github/workflows/build-enterprise.yaml index 96d239a3877..459ea78b20a 100644 --- a/.github/workflows/build-enterprise.yaml +++ b/.github/workflows/build-enterprise.yaml @@ -84,7 +84,7 @@ jobs: JS_INPUT_ARTIFACT_CACHE_KEY: enterprise-dotenv-${{ github.sha }} JS_INPUT_ARTIFACT_PATH: frontend/.env JS_OUTPUT_ARTIFACT_CACHE_KEY: enterprise-jsbuild-${{ github.sha }} - JS_OUTPUT_ARTIFACT_PATH: frontend/build + JS_OUTPUT_ARTIFACT_PATH: frontend/build DOCKER_BUILD: false DOCKER_MANIFEST: false go-build: @@ -99,7 +99,7 @@ jobs: GO_BUILD_CONTEXT: ./cmd/enterprise GO_BUILD_FLAGS: >- -tags timetzdata - -ldflags='-linkmode external -extldflags \"-static\" -s -w + -ldflags='-s -w -X github.com/SigNoz/signoz/pkg/version.version=${{ needs.prepare.outputs.version }} -X github.com/SigNoz/signoz/pkg/version.variant=enterprise -X github.com/SigNoz/signoz/pkg/version.hash=${{ needs.prepare.outputs.hash }} @@ -107,10 +107,8 @@ jobs: -X github.com/SigNoz/signoz/pkg/version.branch=${{ needs.prepare.outputs.branch }} -X github.com/SigNoz/signoz/ee/zeus.url=https://api.signoz.cloud -X github.com/SigNoz/signoz/ee/zeus.deprecatedURL=https://license.signoz.io - -X github.com/SigNoz/signoz/ee/query-service/constants.ZeusURL=https://api.signoz.cloud -X github.com/SigNoz/signoz/ee/query-service/constants.LicenseSignozIo=https://license.signoz.io/api/v1 -X github.com/SigNoz/signoz/pkg/analytics.key=9kRrJ7oPCGPEJLF6QjMPLt5bljFhRQBr' - GO_CGO_ENABLED: 1 DOCKER_BASE_IMAGES: '{"alpine": "alpine:3.20.3"}' DOCKER_DOCKERFILE_PATH: ./cmd/enterprise/Dockerfile.multi-arch DOCKER_MANIFEST: true diff --git a/.github/workflows/build-staging.yaml b/.github/workflows/build-staging.yaml index 271a270ac52..2f53b527b5b 100644 --- a/.github/workflows/build-staging.yaml +++ b/.github/workflows/build-staging.yaml @@ -98,7 +98,7 @@ jobs: GO_BUILD_CONTEXT: ./cmd/enterprise GO_BUILD_FLAGS: >- -tags timetzdata - -ldflags='-linkmode external -extldflags \"-static\" -s -w + -ldflags='-s -w -X github.com/SigNoz/signoz/pkg/version.version=${{ needs.prepare.outputs.version }} -X github.com/SigNoz/signoz/pkg/version.variant=enterprise -X github.com/SigNoz/signoz/pkg/version.hash=${{ needs.prepare.outputs.hash }} @@ -106,10 +106,8 @@ jobs: -X github.com/SigNoz/signoz/pkg/version.branch=${{ needs.prepare.outputs.branch }} -X github.com/SigNoz/signoz/ee/zeus.url=https://api.staging.signoz.cloud -X github.com/SigNoz/signoz/ee/zeus.deprecatedURL=https://license.staging.signoz.cloud - -X github.com/SigNoz/signoz/ee/query-service/constants.ZeusURL=https://api.staging.signoz.cloud -X github.com/SigNoz/signoz/ee/query-service/constants.LicenseSignozIo=https://license.staging.signoz.cloud/api/v1 -X github.com/SigNoz/signoz/pkg/analytics.key=9kRrJ7oPCGPEJLF6QjMPLt5bljFhRQBr' - GO_CGO_ENABLED: 1 DOCKER_BASE_IMAGES: '{"alpine": "alpine:3.20.3"}' DOCKER_DOCKERFILE_PATH: ./cmd/enterprise/Dockerfile.multi-arch DOCKER_MANIFEST: true @@ -125,4 +123,4 @@ jobs: GITHUB_SILENT: true GITHUB_REPOSITORY_NAME: charts-saas-v3-staging GITHUB_EVENT_NAME: releaser - GITHUB_EVENT_PAYLOAD: "{\"deployment\": \"${{ needs.prepare.outputs.deployment }}\", \"signoz_version\": \"${{ needs.prepare.outputs.version }}\"}" + GITHUB_EVENT_PAYLOAD: '{"deployment": "${{ needs.prepare.outputs.deployment }}", "signoz_version": "${{ needs.prepare.outputs.version }}"}' diff --git a/.github/workflows/integrationci.yaml b/.github/workflows/integrationci.yaml index 35efa27f6e3..8b7ab17b6a1 100644 --- a/.github/workflows/integrationci.yaml +++ b/.github/workflows/integrationci.yaml @@ -15,15 +15,18 @@ jobs: matrix: src: - bootstrap - - auth + - passwordauthn + - callbackauthn + - cloudintegrations - querier + - ttl sqlstore-provider: - postgres - sqlite clickhouse-version: - 25.5.6 schema-migrator-version: - - v0.129.6 + - v0.129.7 postgres-version: - 15 if: | @@ -42,6 +45,20 @@ jobs: python -m pip install poetry==2.1.2 python -m poetry config virtualenvs.in-project true cd tests/integration && poetry install --no-root + - name: webdriver + run: | + wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo apt-key add - + echo "deb http://dl.google.com/linux/chrome/deb/ stable main" | sudo tee -a /etc/apt/sources.list.d/google-chrome.list + sudo apt-get update -qqy + sudo apt-get -qqy install google-chrome-stable + CHROME_VERSION=$(google-chrome-stable --version) + CHROME_FULL_VERSION=${CHROME_VERSION%%.*} + CHROME_MAJOR_VERSION=${CHROME_FULL_VERSION//[!0-9]} + sudo rm /etc/apt/sources.list.d/google-chrome.list + export CHROMEDRIVER_VERSION=`curl -s https://googlechromelabs.github.io/chrome-for-testing/LATEST_RELEASE_${CHROME_MAJOR_VERSION%%.*}` + curl -L -O "https://storage.googleapis.com/chrome-for-testing-public/${CHROMEDRIVER_VERSION}/linux64/chromedriver-linux64.zip" + unzip chromedriver-linux64.zip && chmod +x chromedriver && sudo mv chromedriver /usr/local/bin + chromedriver -version - name: run run: | cd tests/integration && \ diff --git a/.golangci.yml b/.golangci.yml index 00643925fde..d5fdafa9f6d 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,39 +1,63 @@ +version: "2" linters: - default: standard + default: none enable: - bodyclose + - depguard + - errcheck + - forbidigo + - govet + - iface + - ineffassign - misspell - nilnil - sloglint - - depguard - - iface - unparam - - forbidigo - -linters-settings: - sloglint: - no-mixed-args: true - kv-only: true - no-global: all - context: all - static-msg: true - msg-style: lowercased - key-naming-case: snake - depguard: - rules: - nozap: - deny: - - pkg: "go.uber.org/zap" - desc: "Do not use zap logger. Use slog instead." - noerrors: - deny: - - pkg: "errors" - desc: "Do not use errors package. Use github.com/SigNoz/signoz/pkg/errors instead." - iface: - enable: - - identical -issues: - exclude-dirs: - - "pkg/query-service" - - "ee/query-service" - - "scripts/" + - unused + settings: + depguard: + rules: + noerrors: + deny: + - pkg: errors + desc: Do not use errors package. Use github.com/SigNoz/signoz/pkg/errors instead. + nozap: + deny: + - pkg: go.uber.org/zap + desc: Do not use zap logger. Use slog instead. + forbidigo: + forbid: + - pattern: fmt.Errorf + - pattern: ^(fmt\.Print.*|print|println)$ + iface: + enable: + - identical + sloglint: + no-mixed-args: true + kv-only: true + no-global: all + context: all + static-msg: true + key-naming-case: snake + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + paths: + - pkg/query-service + - ee/query-service + - scripts/ + - tmp/ + - third_party$ + - builtin$ + - examples$ +formatters: + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/Makefile b/Makefile index 415dc82385d..6d5c835cd17 100644 --- a/Makefile +++ b/Makefile @@ -84,10 +84,9 @@ go-run-enterprise: ## Runs the enterprise go backend server SIGNOZ_ALERTMANAGER_PROVIDER=signoz \ SIGNOZ_TELEMETRYSTORE_PROVIDER=clickhouse \ SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_DSN=tcp://127.0.0.1:9000 \ + SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER=cluster \ go run -race \ - $(GO_BUILD_CONTEXT_ENTERPRISE)/*.go \ - --config ./conf/prometheus.yml \ - --cluster cluster + $(GO_BUILD_CONTEXT_ENTERPRISE)/*.go .PHONY: go-test go-test: ## Runs go unit tests @@ -102,10 +101,9 @@ go-run-community: ## Runs the community go backend server SIGNOZ_ALERTMANAGER_PROVIDER=signoz \ SIGNOZ_TELEMETRYSTORE_PROVIDER=clickhouse \ SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_DSN=tcp://127.0.0.1:9000 \ + SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER=cluster \ go run -race \ - $(GO_BUILD_CONTEXT_COMMUNITY)/*.go server \ - --config ./conf/prometheus.yml \ - --cluster cluster + $(GO_BUILD_CONTEXT_COMMUNITY)/*.go server .PHONY: go-build-community $(GO_BUILD_ARCHS_COMMUNITY) go-build-community: ## Builds the go backend server for community @@ -114,9 +112,9 @@ $(GO_BUILD_ARCHS_COMMUNITY): go-build-community-%: $(TARGET_DIR) @mkdir -p $(TARGET_DIR)/$(OS)-$* @echo ">> building binary $(TARGET_DIR)/$(OS)-$*/$(NAME)-community" @if [ $* = "arm64" ]; then \ - CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOARCH=$* GOOS=$(OS) go build -C $(GO_BUILD_CONTEXT_COMMUNITY) -tags timetzdata -o $(TARGET_DIR)/$(OS)-$*/$(NAME)-community -ldflags "-linkmode external -extldflags '-static' -s -w $(GO_BUILD_LDFLAGS_COMMUNITY)"; \ + GOARCH=$* GOOS=$(OS) go build -C $(GO_BUILD_CONTEXT_COMMUNITY) -tags timetzdata -o $(TARGET_DIR)/$(OS)-$*/$(NAME)-community -ldflags "-s -w $(GO_BUILD_LDFLAGS_COMMUNITY)"; \ else \ - CGO_ENABLED=1 GOARCH=$* GOOS=$(OS) go build -C $(GO_BUILD_CONTEXT_COMMUNITY) -tags timetzdata -o $(TARGET_DIR)/$(OS)-$*/$(NAME)-community -ldflags "-linkmode external -extldflags '-static' -s -w $(GO_BUILD_LDFLAGS_COMMUNITY)"; \ + GOARCH=$* GOOS=$(OS) go build -C $(GO_BUILD_CONTEXT_COMMUNITY) -tags timetzdata -o $(TARGET_DIR)/$(OS)-$*/$(NAME)-community -ldflags "-s -w $(GO_BUILD_LDFLAGS_COMMUNITY)"; \ fi @@ -127,9 +125,9 @@ $(GO_BUILD_ARCHS_ENTERPRISE): go-build-enterprise-%: $(TARGET_DIR) @mkdir -p $(TARGET_DIR)/$(OS)-$* @echo ">> building binary $(TARGET_DIR)/$(OS)-$*/$(NAME)" @if [ $* = "arm64" ]; then \ - CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOARCH=$* GOOS=$(OS) go build -C $(GO_BUILD_CONTEXT_ENTERPRISE) -tags timetzdata -o $(TARGET_DIR)/$(OS)-$*/$(NAME) -ldflags "-linkmode external -extldflags '-static' -s -w $(GO_BUILD_LDFLAGS_ENTERPRISE)"; \ + GOARCH=$* GOOS=$(OS) go build -C $(GO_BUILD_CONTEXT_ENTERPRISE) -tags timetzdata -o $(TARGET_DIR)/$(OS)-$*/$(NAME) -ldflags "-s -w $(GO_BUILD_LDFLAGS_ENTERPRISE)"; \ else \ - CGO_ENABLED=1 GOARCH=$* GOOS=$(OS) go build -C $(GO_BUILD_CONTEXT_ENTERPRISE) -tags timetzdata -o $(TARGET_DIR)/$(OS)-$*/$(NAME) -ldflags "-linkmode external -extldflags '-static' -s -w $(GO_BUILD_LDFLAGS_ENTERPRISE)"; \ + GOARCH=$* GOOS=$(OS) go build -C $(GO_BUILD_CONTEXT_ENTERPRISE) -tags timetzdata -o $(TARGET_DIR)/$(OS)-$*/$(NAME) -ldflags "-s -w $(GO_BUILD_LDFLAGS_ENTERPRISE)"; \ fi .PHONY: go-build-enterprise-race $(GO_BUILD_ARCHS_ENTERPRISE_RACE) @@ -139,9 +137,9 @@ $(GO_BUILD_ARCHS_ENTERPRISE_RACE): go-build-enterprise-race-%: $(TARGET_DIR) @mkdir -p $(TARGET_DIR)/$(OS)-$* @echo ">> building binary $(TARGET_DIR)/$(OS)-$*/$(NAME)" @if [ $* = "arm64" ]; then \ - CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOARCH=$* GOOS=$(OS) go build -C $(GO_BUILD_CONTEXT_ENTERPRISE) -race -tags timetzdata -o $(TARGET_DIR)/$(OS)-$*/$(NAME) -ldflags "-linkmode external -extldflags '-static' -s -w $(GO_BUILD_LDFLAGS_ENTERPRISE)"; \ + GOARCH=$* GOOS=$(OS) go build -C $(GO_BUILD_CONTEXT_ENTERPRISE) -race -tags timetzdata -o $(TARGET_DIR)/$(OS)-$*/$(NAME) -ldflags "-s -w $(GO_BUILD_LDFLAGS_ENTERPRISE)"; \ else \ - CGO_ENABLED=1 GOARCH=$* GOOS=$(OS) go build -C $(GO_BUILD_CONTEXT_ENTERPRISE) -race -tags timetzdata -o $(TARGET_DIR)/$(OS)-$*/$(NAME) -ldflags "-linkmode external -extldflags '-static' -s -w $(GO_BUILD_LDFLAGS_ENTERPRISE)"; \ + GOARCH=$* GOOS=$(OS) go build -C $(GO_BUILD_CONTEXT_ENTERPRISE) -race -tags timetzdata -o $(TARGET_DIR)/$(OS)-$*/$(NAME) -ldflags "-s -w $(GO_BUILD_LDFLAGS_ENTERPRISE)"; \ fi ############################################################## @@ -208,4 +206,4 @@ py-lint: ## Run lint for integration tests .PHONY: py-test py-test: ## Runs integration tests - @cd tests/integration && poetry run pytest --basetemp=./tmp/ -vv --capture=no src/ \ No newline at end of file + @cd tests/integration && poetry run pytest --basetemp=./tmp/ -vv --capture=no src/ diff --git a/README.md b/README.md index b3c4c83d8f0..a9cc50ae6b1 100644 --- a/README.md +++ b/README.md @@ -236,7 +236,7 @@ Not sure how to get started? Just ping us on `#contributing` in our [slack commu #### DevOps - [Prashant Shahi](https://github.com/prashant-shahi) -- [Vibhu Pandey](https://github.com/grandwizard28) +- [Vibhu Pandey](https://github.com/therealpandey)

diff --git a/cmd/community/.goreleaser.yaml b/cmd/community/.goreleaser.yaml index 74d165d7f4f..dc75295f071 100644 --- a/cmd/community/.goreleaser.yaml +++ b/cmd/community/.goreleaser.yaml @@ -12,12 +12,6 @@ builds: - id: signoz binary: bin/signoz main: ./cmd/community - env: - - CGO_ENABLED=1 - - >- - {{- if eq .Os "linux" }} - {{- if eq .Arch "arm64" }}CC=aarch64-linux-gnu-gcc{{- end }} - {{- end }} goos: - linux - darwin @@ -36,8 +30,6 @@ builds: - -X github.com/SigNoz/signoz/pkg/version.time={{ .CommitTimestamp }} - -X github.com/SigNoz/signoz/pkg/version.branch={{ .Branch }} - -X github.com/SigNoz/signoz/pkg/analytics.key=9kRrJ7oPCGPEJLF6QjMPLt5bljFhRQBr - - >- - {{- if eq .Os "linux" }}-linkmode external -extldflags '-static'{{- end }} mod_timestamp: "{{ .CommitTimestamp }}" tags: - timetzdata diff --git a/cmd/community/server.go b/cmd/community/server.go index a437b450c17..9d7909f4b78 100644 --- a/cmd/community/server.go +++ b/cmd/community/server.go @@ -3,11 +3,11 @@ 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/authn" "github.com/SigNoz/signoz/pkg/factory" "github.com/SigNoz/signoz/pkg/licensing" "github.com/SigNoz/signoz/pkg/licensing/nooplicensing" @@ -56,12 +56,9 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e 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{}, @@ -76,13 +73,16 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e }, signoz.NewSQLStoreProviderFactories(), signoz.NewTelemetryStoreProviderFactories(), + func(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error) { + return signoz.NewAuthNs(ctx, providerSettings, store, licensing) + }, ) if err != nil { logger.ErrorContext(ctx, "failed to create signoz", "error", err) return err } - server, err := app.NewServer(config, signoz, jwt) + server, err := app.NewServer(config, signoz) if err != nil { logger.ErrorContext(ctx, "failed to create server", "error", err) return err diff --git a/cmd/config.go b/cmd/config.go index 206d9b44d4c..85524aba621 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -3,7 +3,6 @@ package cmd import ( "context" "log/slog" - "os" "github.com/SigNoz/signoz/pkg/config" "github.com/SigNoz/signoz/pkg/config/envprovider" @@ -30,12 +29,3 @@ func NewSigNozConfig(ctx context.Context, logger *slog.Logger, flags signoz.Depr return config, nil } - -func NewJWTSecret(ctx context.Context, logger *slog.Logger) string { - jwtSecret := os.Getenv("SIGNOZ_JWT_SECRET") - if len(jwtSecret) == 0 { - logger.ErrorContext(ctx, "🚨 CRITICAL SECURITY ISSUE: No JWT secret key specified!", "error", "SIGNOZ_JWT_SECRET environment variable is not set. This has dire consequences for the security of the application. Without a JWT secret, user sessions are vulnerable to tampering and unauthorized access. Please set the SIGNOZ_JWT_SECRET environment variable immediately. For more information, please refer to https://github.com/SigNoz/signoz/issues/8400.") - } - - return jwtSecret -} diff --git a/cmd/enterprise/.goreleaser.yaml b/cmd/enterprise/.goreleaser.yaml index d5647b2f494..d8d36913210 100644 --- a/cmd/enterprise/.goreleaser.yaml +++ b/cmd/enterprise/.goreleaser.yaml @@ -12,12 +12,6 @@ builds: - id: signoz binary: bin/signoz main: ./cmd/enterprise - env: - - CGO_ENABLED=1 - - >- - {{- if eq .Os "linux" }} - {{- if eq .Arch "arm64" }}CC=aarch64-linux-gnu-gcc{{- end }} - {{- end }} goos: - linux - darwin @@ -37,11 +31,8 @@ builds: - -X github.com/SigNoz/signoz/pkg/version.branch={{ .Branch }} - -X github.com/SigNoz/signoz/ee/zeus.url=https://api.signoz.cloud - -X github.com/SigNoz/signoz/ee/zeus.deprecatedURL=https://license.signoz.io - - -X github.com/SigNoz/signoz/ee/query-service/constants.ZeusURL=https://api.signoz.cloud - -X github.com/SigNoz/signoz/ee/query-service/constants.LicenseSignozIo=https://license.signoz.io/api/v1 - -X github.com/SigNoz/signoz/pkg/analytics.key=9kRrJ7oPCGPEJLF6QjMPLt5bljFhRQBr - - >- - {{- if eq .Os "linux" }}-linkmode external -extldflags '-static'{{- end }} mod_timestamp: "{{ .CommitTimestamp }}" tags: - timetzdata diff --git a/cmd/enterprise/server.go b/cmd/enterprise/server.go index b513e9a744b..87d3354d166 100644 --- a/cmd/enterprise/server.go +++ b/cmd/enterprise/server.go @@ -6,6 +6,8 @@ import ( "time" "github.com/SigNoz/signoz/cmd" + "github.com/SigNoz/signoz/ee/authn/callbackauthn/oidccallbackauthn" + "github.com/SigNoz/signoz/ee/authn/callbackauthn/samlcallbackauthn" enterpriselicensing "github.com/SigNoz/signoz/ee/licensing" "github.com/SigNoz/signoz/ee/licensing/httplicensing" enterpriseapp "github.com/SigNoz/signoz/ee/query-service/app" @@ -14,6 +16,7 @@ import ( 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/authn" "github.com/SigNoz/signoz/pkg/factory" "github.com/SigNoz/signoz/pkg/licensing" "github.com/SigNoz/signoz/pkg/modules/organization" @@ -54,17 +57,14 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e // add enterprise sqlstore factories to the community sqlstore factories sqlstoreFactories := signoz.NewSQLStoreProviderFactories() - if err := sqlstoreFactories.Add(postgressqlstore.NewFactory(sqlstorehook.NewLoggingFactory())); err != nil { + if err := sqlstoreFactories.Add(postgressqlstore.NewFactory(sqlstorehook.NewLoggingFactory(), sqlstorehook.NewInstrumentationFactory())); 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), @@ -84,13 +84,34 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e }, sqlstoreFactories, signoz.NewTelemetryStoreProviderFactories(), + func(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error) { + samlCallbackAuthN, err := samlcallbackauthn.New(ctx, store, licensing) + if err != nil { + return nil, err + } + + oidcCallbackAuthN, err := oidccallbackauthn.New(store, licensing, providerSettings) + if err != nil { + return nil, err + } + + authNs, err := signoz.NewAuthNs(ctx, providerSettings, store, licensing) + if err != nil { + return nil, err + } + + authNs[authtypes.AuthNProviderSAML] = samlCallbackAuthN + authNs[authtypes.AuthNProviderOIDC] = oidcCallbackAuthN + + return authNs, nil + }, ) if err != nil { logger.ErrorContext(ctx, "failed to create signoz", "error", err) return err } - server, err := enterpriseapp.NewServer(config, signoz, jwt) + server, err := enterpriseapp.NewServer(config, signoz) if err != nil { logger.ErrorContext(ctx, "failed to create server", "error", err) return err diff --git a/conf/example.yaml b/conf/example.yaml index d22fa37cab0..225019eb01f 100644 --- a/conf/example.yaml +++ b/conf/example.yaml @@ -1,5 +1,5 @@ ##################### SigNoz Configuration Example ##################### -# +# # Do not modify this file # @@ -58,7 +58,7 @@ cache: # The port on which the Redis server is running. Default is usually 6379. port: 6379 # The password for authenticating with the Redis server, if required. - password: + password: # The Redis database number to use db: 0 @@ -71,6 +71,10 @@ sqlstore: sqlite: # The path to the SQLite database file. path: /var/lib/signoz/signoz.db + # Mode is the mode to use for the sqlite database. + mode: delete + # BusyTimeout is the timeout for the sqlite database to wait for a lock. + busy_timeout: 10s ##################### APIServer ##################### apiserver: @@ -238,8 +242,32 @@ statsreporter: # Whether to collect identities and traits (emails). identities: true - ##################### Gateway (License only) ##################### gateway: # The URL of the gateway's api. url: http://localhost:8080 + +##################### Tokenizer ##################### +tokenizer: + # Specifies the tokenizer provider to use. + provider: jwt + lifetime: + # The duration for which a user can be idle before being required to authenticate. + idle: 168h + # The duration for which a user can remain logged in before being asked to login. + max: 720h + rotation: + # The interval to rotate tokens in. + interval: 30m + # The duration for which the previous token pair remains valid after a token pair is rotated. + duration: 60s + jwt: + # The secret to sign the JWT tokens. + secret: secret + opaque: + gc: + # The interval to perform garbage collection. + interval: 1h + token: + # The maximum number of tokens a user can have. This limits the number of concurrent sessions a user can have. + max_per_user: 5 diff --git a/deploy/docker-swarm/docker-compose.ha.yaml b/deploy/docker-swarm/docker-compose.ha.yaml index 56537d57db3..bf4916caa40 100644 --- a/deploy/docker-swarm/docker-compose.ha.yaml +++ b/deploy/docker-swarm/docker-compose.ha.yaml @@ -176,7 +176,7 @@ services: # - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml signoz: !!merge <<: *db-depend - image: signoz/signoz:v0.96.1 + image: signoz/signoz:v0.101.0 command: - --config=/root/config/prometheus.yml ports: @@ -209,7 +209,7 @@ services: retries: 3 otel-collector: !!merge <<: *db-depend - image: signoz/signoz-otel-collector:v0.129.6 + image: signoz/signoz-otel-collector:v0.129.8 command: - --config=/etc/otel-collector-config.yaml - --manager-config=/etc/manager-config.yaml @@ -233,7 +233,7 @@ services: - signoz schema-migrator: !!merge <<: *common - image: signoz/signoz-schema-migrator:v0.129.6 + image: signoz/signoz-schema-migrator:v0.129.8 deploy: restart_policy: condition: on-failure diff --git a/deploy/docker-swarm/docker-compose.yaml b/deploy/docker-swarm/docker-compose.yaml index 46f136b7aa8..3d71ba72829 100644 --- a/deploy/docker-swarm/docker-compose.yaml +++ b/deploy/docker-swarm/docker-compose.yaml @@ -117,7 +117,7 @@ services: # - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml signoz: !!merge <<: *db-depend - image: signoz/signoz:v0.96.1 + image: signoz/signoz:v0.101.0 command: - --config=/root/config/prometheus.yml ports: @@ -150,7 +150,7 @@ services: retries: 3 otel-collector: !!merge <<: *db-depend - image: signoz/signoz-otel-collector:v0.129.6 + image: signoz/signoz-otel-collector:v0.129.8 command: - --config=/etc/otel-collector-config.yaml - --manager-config=/etc/manager-config.yaml @@ -176,7 +176,7 @@ services: - signoz schema-migrator: !!merge <<: *common - image: signoz/signoz-schema-migrator:v0.129.6 + image: signoz/signoz-schema-migrator:v0.129.8 deploy: restart_policy: condition: on-failure diff --git a/deploy/docker-swarm/otel-collector-config.yaml b/deploy/docker-swarm/otel-collector-config.yaml index 48baa16b34c..390c225115e 100644 --- a/deploy/docker-swarm/otel-collector-config.yaml +++ b/deploy/docker-swarm/otel-collector-config.yaml @@ -1,3 +1,10 @@ +connectors: + signozmeter: + metrics_flush_interval: 1h + dimensions: + - name: service.name + - name: deployment.environment + - name: host.name receivers: otlp: protocols: @@ -21,6 +28,10 @@ processors: send_batch_size: 10000 send_batch_max_size: 11000 timeout: 10s + batch/meter: + send_batch_max_size: 25000 + send_batch_size: 20000 + timeout: 1s resourcedetection: # Using OTEL_RESOURCE_ATTRIBUTES envvar, env detector adds custom labels. detectors: [env, system] @@ -66,6 +77,11 @@ exporters: dsn: tcp://clickhouse:9000/signoz_logs timeout: 10s use_new_schema: true + signozclickhousemeter: + dsn: tcp://clickhouse:9000/signoz_meter + timeout: 45s + sending_queue: + enabled: false service: telemetry: logs: @@ -77,16 +93,20 @@ service: traces: receivers: [otlp] processors: [signozspanmetrics/delta, batch] - exporters: [clickhousetraces] + exporters: [clickhousetraces, signozmeter] metrics: receivers: [otlp] processors: [batch] - exporters: [signozclickhousemetrics] + exporters: [signozclickhousemetrics, signozmeter] metrics/prometheus: receivers: [prometheus] processors: [batch] - exporters: [signozclickhousemetrics] + exporters: [signozclickhousemetrics, signozmeter] logs: receivers: [otlp] processors: [batch] - exporters: [clickhouselogsexporter] + exporters: [clickhouselogsexporter, signozmeter] + metrics/meter: + receivers: [signozmeter] + processors: [batch/meter] + exporters: [signozclickhousemeter] diff --git a/deploy/docker/docker-compose.ha.yaml b/deploy/docker/docker-compose.ha.yaml index 2faeed24fef..aa696ed5555 100644 --- a/deploy/docker/docker-compose.ha.yaml +++ b/deploy/docker/docker-compose.ha.yaml @@ -179,7 +179,7 @@ services: # - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml signoz: !!merge <<: *db-depend - image: signoz/signoz:${VERSION:-v0.96.1} + image: signoz/signoz:${VERSION:-v0.101.0} container_name: signoz command: - --config=/root/config/prometheus.yml @@ -213,7 +213,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.129.6} + image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.8} container_name: signoz-otel-collector command: - --config=/etc/otel-collector-config.yaml @@ -239,7 +239,7 @@ services: condition: service_healthy schema-migrator-sync: !!merge <<: *common - image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.6} + image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.8} container_name: schema-migrator-sync command: - sync @@ -250,7 +250,7 @@ services: condition: service_healthy schema-migrator-async: !!merge <<: *db-depend - image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.6} + image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.8} container_name: schema-migrator-async command: - async diff --git a/deploy/docker/docker-compose.yaml b/deploy/docker/docker-compose.yaml index 66e7433b768..33043fbe4ad 100644 --- a/deploy/docker/docker-compose.yaml +++ b/deploy/docker/docker-compose.yaml @@ -111,7 +111,7 @@ services: # - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml signoz: !!merge <<: *db-depend - image: signoz/signoz:${VERSION:-v0.96.1} + image: signoz/signoz:${VERSION:-v0.101.0} container_name: signoz command: - --config=/root/config/prometheus.yml @@ -144,7 +144,7 @@ services: retries: 3 otel-collector: !!merge <<: *db-depend - image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.6} + image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.8} container_name: signoz-otel-collector command: - --config=/etc/otel-collector-config.yaml @@ -166,7 +166,7 @@ services: condition: service_healthy schema-migrator-sync: !!merge <<: *common - image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.6} + image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.8} container_name: schema-migrator-sync command: - sync @@ -178,7 +178,7 @@ services: restart: on-failure schema-migrator-async: !!merge <<: *db-depend - image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.6} + image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.8} container_name: schema-migrator-async command: - async diff --git a/deploy/docker/otel-collector-config.yaml b/deploy/docker/otel-collector-config.yaml index 48baa16b34c..390c225115e 100644 --- a/deploy/docker/otel-collector-config.yaml +++ b/deploy/docker/otel-collector-config.yaml @@ -1,3 +1,10 @@ +connectors: + signozmeter: + metrics_flush_interval: 1h + dimensions: + - name: service.name + - name: deployment.environment + - name: host.name receivers: otlp: protocols: @@ -21,6 +28,10 @@ processors: send_batch_size: 10000 send_batch_max_size: 11000 timeout: 10s + batch/meter: + send_batch_max_size: 25000 + send_batch_size: 20000 + timeout: 1s resourcedetection: # Using OTEL_RESOURCE_ATTRIBUTES envvar, env detector adds custom labels. detectors: [env, system] @@ -66,6 +77,11 @@ exporters: dsn: tcp://clickhouse:9000/signoz_logs timeout: 10s use_new_schema: true + signozclickhousemeter: + dsn: tcp://clickhouse:9000/signoz_meter + timeout: 45s + sending_queue: + enabled: false service: telemetry: logs: @@ -77,16 +93,20 @@ service: traces: receivers: [otlp] processors: [signozspanmetrics/delta, batch] - exporters: [clickhousetraces] + exporters: [clickhousetraces, signozmeter] metrics: receivers: [otlp] processors: [batch] - exporters: [signozclickhousemetrics] + exporters: [signozclickhousemetrics, signozmeter] metrics/prometheus: receivers: [prometheus] processors: [batch] - exporters: [signozclickhousemetrics] + exporters: [signozclickhousemetrics, signozmeter] logs: receivers: [otlp] processors: [batch] - exporters: [clickhouselogsexporter] + exporters: [clickhouselogsexporter, signozmeter] + metrics/meter: + receivers: [signozmeter] + processors: [batch/meter] + exporters: [signozclickhousemeter] diff --git a/docs/contributing/development.md b/docs/contributing/development.md index 41f51567b1b..4ef73c31cae 100644 --- a/docs/contributing/development.md +++ b/docs/contributing/development.md @@ -13,8 +13,6 @@ Before diving in, make sure you have these tools installed: - Download from [go.dev/dl](https://go.dev/dl/) - Check [go.mod](../../go.mod#L3) for the minimum version -- **GCC** - Required for CGO dependencies - - Download from [gcc.gnu.org](https://gcc.gnu.org/) - **Node** - Powers our frontend - Download from [nodejs.org](https://nodejs.org) diff --git a/docs/otel-demo-docs.md b/docs/otel-demo-docs.md index 2c0fc92ec52..d3a8231174f 100644 --- a/docs/otel-demo-docs.md +++ b/docs/otel-demo-docs.md @@ -103,9 +103,19 @@ Remember to replace the region and ingestion key with proper values as obtained Both SigNoz and OTel demo app [frontend-proxy service, to be accurate] share common port allocation at 8080. To prevent port allocation conflicts, modify the OTel demo application config to use port 8081 as the `ENVOY_PORT` value as shown below, and run docker compose command. +Also, both SigNoz and OTel Demo App have the same `PROMETHEUS_PORT` configured, by default both of them try to start at `9090`, which may cause either of them to fail depending upon which one acquires it first. To prevent this, we need to mofify the value of `PROMETHEUS_PORT` too. + + ```sh -ENVOY_PORT=8081 docker compose up -d +ENVOY_PORT=8081 PROMETHEUS_PORT=9091 docker compose up -d ``` + +Alternatively, we can modify these values using the `.env` file too, which reduces the command as just: + +```sh +docker compose up -d +``` + This spins up multiple microservices, with OpenTelemetry instrumentation enabled. you can verify this by, ```sh docker compose ps -a diff --git a/ee/anomaly/seasonal.go b/ee/anomaly/seasonal.go index 6636188b6d0..49c136a98d5 100644 --- a/ee/anomaly/seasonal.go +++ b/ee/anomaly/seasonal.go @@ -232,7 +232,7 @@ func (p *BaseSeasonalProvider) getPredictedSeries( // moving avg of the previous period series + z score threshold * std dev of the series // moving avg of the previous period series - z score threshold * std dev of the series func (p *BaseSeasonalProvider) getBounds( - series, predictedSeries *qbtypes.TimeSeries, + series, predictedSeries, weekSeries *qbtypes.TimeSeries, zScoreThreshold float64, ) (*qbtypes.TimeSeries, *qbtypes.TimeSeries) { upperBoundSeries := &qbtypes.TimeSeries{ @@ -246,8 +246,8 @@ func (p *BaseSeasonalProvider) getBounds( } for idx, curr := range series.Values { - upperBound := p.getMovingAvg(predictedSeries, movingAvgWindowSize, idx) + zScoreThreshold*p.getStdDev(series) - lowerBound := p.getMovingAvg(predictedSeries, movingAvgWindowSize, idx) - zScoreThreshold*p.getStdDev(series) + upperBound := p.getMovingAvg(predictedSeries, movingAvgWindowSize, idx) + zScoreThreshold*p.getStdDev(weekSeries) + lowerBound := p.getMovingAvg(predictedSeries, movingAvgWindowSize, idx) - zScoreThreshold*p.getStdDev(weekSeries) upperBoundSeries.Values = append(upperBoundSeries.Values, &qbtypes.TimeSeriesValue{ Timestamp: curr.Timestamp, Value: upperBound, @@ -398,8 +398,6 @@ func (p *BaseSeasonalProvider) getAnomalies(ctx context.Context, orgID valuer.UU aggOfInterest := result.Aggregations[0] for _, series := range aggOfInterest.Series { - stdDev := p.getStdDev(series) - p.logger.InfoContext(ctx, "calculated standard deviation for series", "anomaly_std_dev", stdDev, "anomaly_labels", series.Labels) pastPeriodSeries := p.getMatchingSeries(ctx, pastPeriodResult, series) currentSeasonSeries := p.getMatchingSeries(ctx, currentSeasonResult, series) @@ -407,6 +405,9 @@ func (p *BaseSeasonalProvider) getAnomalies(ctx context.Context, orgID valuer.UU past2SeasonSeries := p.getMatchingSeries(ctx, past2SeasonResult, series) past3SeasonSeries := p.getMatchingSeries(ctx, past3SeasonResult, series) + stdDev := p.getStdDev(currentSeasonSeries) + p.logger.InfoContext(ctx, "calculated standard deviation for series", "anomaly_std_dev", stdDev, "anomaly_labels", series.Labels) + prevSeriesAvg := p.getAvg(pastPeriodSeries) currentSeasonSeriesAvg := p.getAvg(currentSeasonSeries) pastSeasonSeriesAvg := p.getAvg(pastSeasonSeries) @@ -435,6 +436,7 @@ func (p *BaseSeasonalProvider) getAnomalies(ctx context.Context, orgID valuer.UU upperBoundSeries, lowerBoundSeries := p.getBounds( series, predictedSeries, + currentSeasonSeries, zScoreThreshold, ) aggOfInterest.UpperBoundSeries = append(aggOfInterest.UpperBoundSeries, upperBoundSeries) diff --git a/ee/authn/callbackauthn/oidccallbackauthn/authn.go b/ee/authn/callbackauthn/oidccallbackauthn/authn.go new file mode 100644 index 00000000000..b1a048fbb54 --- /dev/null +++ b/ee/authn/callbackauthn/oidccallbackauthn/authn.go @@ -0,0 +1,191 @@ +package oidccallbackauthn + +import ( + "context" + "net/url" + + "github.com/SigNoz/signoz/pkg/authn" + "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/factory" + "github.com/SigNoz/signoz/pkg/http/client" + "github.com/SigNoz/signoz/pkg/licensing" + "github.com/SigNoz/signoz/pkg/types/authtypes" + "github.com/SigNoz/signoz/pkg/valuer" + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" +) + +const ( + redirectPath string = "/api/v1/complete/oidc" +) + +var ( + scopes []string = []string{"email", oidc.ScopeOpenID} +) + +var _ authn.CallbackAuthN = (*AuthN)(nil) + +type AuthN struct { + store authtypes.AuthNStore + licensing licensing.Licensing + httpClient *client.Client +} + +func New(store authtypes.AuthNStore, licensing licensing.Licensing, providerSettings factory.ProviderSettings) (*AuthN, error) { + httpClient, err := client.New(providerSettings.Logger, providerSettings.TracerProvider, providerSettings.MeterProvider) + if err != nil { + return nil, err + } + + return &AuthN{ + store: store, + licensing: licensing, + httpClient: httpClient, + }, nil +} + +func (a *AuthN) LoginURL(ctx context.Context, siteURL *url.URL, authDomain *authtypes.AuthDomain) (string, error) { + if authDomain.AuthDomainConfig().AuthNProvider != authtypes.AuthNProviderOIDC { + return "", errors.Newf(errors.TypeInternal, authtypes.ErrCodeAuthDomainMismatch, "domain type is not oidc") + } + + _, oauth2Config, err := a.oidcProviderAndoauth2Config(ctx, siteURL, authDomain) + if err != nil { + return "", err + } + + return oauth2Config.AuthCodeURL(authtypes.NewState(siteURL, authDomain.StorableAuthDomain().ID).URL.String()), nil +} + +func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtypes.CallbackIdentity, error) { + if err := query.Get("error"); err != "" { + return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "oidc: error while authenticating").WithAdditional(query.Get("error_description")) + } + + state, err := authtypes.NewStateFromString(query.Get("state")) + if err != nil { + return nil, errors.Newf(errors.TypeInvalidInput, authtypes.ErrCodeInvalidState, "oidc: invalid state").WithAdditional(err.Error()) + } + + authDomain, err := a.store.GetAuthDomainFromID(ctx, state.DomainID) + if err != nil { + return nil, err + } + + _, err = a.licensing.GetActive(ctx, authDomain.StorableAuthDomain().OrgID) + if err != nil { + return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error()) + } + + oidcProvider, oauth2Config, err := a.oidcProviderAndoauth2Config(ctx, state.URL, authDomain) + if err != nil { + return nil, err + } + + ctx = context.WithValue(ctx, oauth2.HTTPClient, a.httpClient.Client()) + token, err := oauth2Config.Exchange(ctx, query.Get("code")) + if err != nil { + var retrieveError *oauth2.RetrieveError + if errors.As(err, &retrieveError) { + return nil, errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "oidc: failed to get token").WithAdditional(retrieveError.ErrorDescription).WithAdditional(string(retrieveError.Body)) + } + + return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "oidc: failed to get token").WithAdditional(err.Error()) + } + + claims, err := a.claimsFromIDToken(ctx, authDomain, oidcProvider, token) + if err != nil && !errors.Ast(err, errors.TypeNotFound) { + return nil, err + } + + if claims == nil && authDomain.AuthDomainConfig().OIDC.GetUserInfo { + claims, err = a.claimsFromUserInfo(ctx, oidcProvider, token) + if err != nil { + return nil, err + } + } + + emailClaim, ok := claims[authDomain.AuthDomainConfig().OIDC.ClaimMapping.Email].(string) + if !ok { + return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "oidc: missing email in claims") + } + + email, err := valuer.NewEmail(emailClaim) + if err != nil { + return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "oidc: failed to parse email").WithAdditional(err.Error()) + } + + if !authDomain.AuthDomainConfig().OIDC.InsecureSkipEmailVerified { + emailVerifiedClaim, ok := claims["email_verified"].(bool) + if !ok { + return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "oidc: missing email_verified in claims") + } + + if !emailVerifiedClaim { + return nil, errors.New(errors.TypeForbidden, errors.CodeForbidden, "oidc: email is not verified") + } + } + + return authtypes.NewCallbackIdentity("", email, authDomain.StorableAuthDomain().OrgID, state), nil +} + +func (a *AuthN) oidcProviderAndoauth2Config(ctx context.Context, siteURL *url.URL, authDomain *authtypes.AuthDomain) (*oidc.Provider, *oauth2.Config, error) { + if authDomain.AuthDomainConfig().OIDC.IssuerAlias != "" { + ctx = oidc.InsecureIssuerURLContext(ctx, authDomain.AuthDomainConfig().OIDC.IssuerAlias) + } + + oidcProvider, err := oidc.NewProvider(ctx, authDomain.AuthDomainConfig().OIDC.Issuer) + if err != nil { + return nil, nil, err + } + + return oidcProvider, &oauth2.Config{ + ClientID: authDomain.AuthDomainConfig().OIDC.ClientID, + ClientSecret: authDomain.AuthDomainConfig().OIDC.ClientSecret, + Endpoint: oidcProvider.Endpoint(), + Scopes: scopes, + RedirectURL: (&url.URL{ + Scheme: siteURL.Scheme, + Host: siteURL.Host, + Path: redirectPath, + }).String(), + }, nil +} + +func (a *AuthN) claimsFromIDToken(ctx context.Context, authDomain *authtypes.AuthDomain, provider *oidc.Provider, token *oauth2.Token) (map[string]any, error) { + rawIDToken, ok := token.Extra("id_token").(string) + if !ok { + return nil, errors.New(errors.TypeNotFound, errors.CodeNotFound, "oidc: no id_token in token response") + } + + verifier := provider.Verifier(&oidc.Config{ClientID: authDomain.AuthDomainConfig().OIDC.ClientID}) + idToken, err := verifier.Verify(ctx, rawIDToken) + if err != nil { + return nil, errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "oidc: failed to verify token").WithAdditional(err.Error()) + } + + var claims map[string]any + if err := idToken.Claims(&claims); err != nil { + return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "oidc: failed to decode claims").WithAdditional(err.Error()) + } + + return claims, nil +} + +func (a *AuthN) claimsFromUserInfo(ctx context.Context, provider *oidc.Provider, token *oauth2.Token) (map[string]any, error) { + var claims map[string]any + + userInfo, err := provider.UserInfo(ctx, oauth2.StaticTokenSource(&oauth2.Token{ + AccessToken: token.AccessToken, + TokenType: "Bearer", // The UserInfo endpoint requires a bearer token as per RFC6750 + })) + if err != nil { + return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "oidc: failed to get user info").WithAdditional(err.Error()) + } + + if err := userInfo.Claims(&claims); err != nil { + return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "oidc: failed to decode claims").WithAdditional(err.Error()) + } + + return claims, nil +} diff --git a/ee/authn/callbackauthn/samlcallbackauthn/authn.go b/ee/authn/callbackauthn/samlcallbackauthn/authn.go new file mode 100644 index 00000000000..1fc99d0744e --- /dev/null +++ b/ee/authn/callbackauthn/samlcallbackauthn/authn.go @@ -0,0 +1,155 @@ +package samlcallbackauthn + +import ( + "context" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "net/url" + "strings" + + "github.com/SigNoz/signoz/pkg/authn" + "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/licensing" + "github.com/SigNoz/signoz/pkg/types/authtypes" + "github.com/SigNoz/signoz/pkg/valuer" + saml2 "github.com/russellhaering/gosaml2" + dsig "github.com/russellhaering/goxmldsig" +) + +const ( + redirectPath string = "/api/v1/complete/saml" +) + +var _ authn.CallbackAuthN = (*AuthN)(nil) + +type AuthN struct { + store authtypes.AuthNStore + licensing licensing.Licensing +} + +func New(ctx context.Context, store authtypes.AuthNStore, licensing licensing.Licensing) (*AuthN, error) { + return &AuthN{ + store: store, + licensing: licensing, + }, nil +} + +func (a *AuthN) LoginURL(ctx context.Context, siteURL *url.URL, authDomain *authtypes.AuthDomain) (string, error) { + if authDomain.AuthDomainConfig().AuthNProvider != authtypes.AuthNProviderSAML { + return "", errors.Newf(errors.TypeInternal, authtypes.ErrCodeAuthDomainMismatch, "saml: domain type is not saml") + } + + sp, err := a.serviceProvider(siteURL, authDomain) + if err != nil { + return "", err + } + + url, err := sp.BuildAuthURL(authtypes.NewState(siteURL, authDomain.StorableAuthDomain().ID).URL.String()) + if err != nil { + return "", err + } + + return url, nil +} + +func (a *AuthN) HandleCallback(ctx context.Context, formValues url.Values) (*authtypes.CallbackIdentity, error) { + state, err := authtypes.NewStateFromString(formValues.Get("RelayState")) + if err != nil { + return nil, errors.New(errors.TypeInvalidInput, authtypes.ErrCodeInvalidState, "saml: invalid state").WithAdditional(err.Error()) + } + + authDomain, err := a.store.GetAuthDomainFromID(ctx, state.DomainID) + if err != nil { + return nil, err + } + + _, err = a.licensing.GetActive(ctx, authDomain.StorableAuthDomain().OrgID) + if err != nil { + return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error()) + } + + sp, err := a.serviceProvider(state.URL, authDomain) + if err != nil { + return nil, err + } + + assertionInfo, err := sp.RetrieveAssertionInfo(formValues.Get("SAMLResponse")) + if err != nil { + if errors.As(err, &saml2.ErrVerification{}) { + return nil, errors.New(errors.TypeForbidden, errors.CodeForbidden, err.Error()) + } + + if errors.As(err, &saml2.ErrMissingElement{}) { + return nil, errors.New(errors.TypeNotFound, errors.CodeNotFound, err.Error()) + } + + return nil, err + } + + if assertionInfo.WarningInfo.InvalidTime { + return nil, errors.New(errors.TypeForbidden, errors.CodeForbidden, "saml: expired saml response") + } + + email, err := valuer.NewEmail(assertionInfo.NameID) + if err != nil { + return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "saml: invalid email").WithAdditional("The nameID assertion is used to retrieve the email address, please check your IDP configuration and try again.") + } + + return authtypes.NewCallbackIdentity("", email, authDomain.StorableAuthDomain().OrgID, state), nil +} + +func (a *AuthN) serviceProvider(siteURL *url.URL, authDomain *authtypes.AuthDomain) (*saml2.SAMLServiceProvider, error) { + certStore, err := a.getCertificateStore(authDomain) + if err != nil { + return nil, err + } + + acsURL := &url.URL{Scheme: siteURL.Scheme, Host: siteURL.Host, Path: redirectPath} + + // Note: + // The ServiceProviderIssuer is the client id in case of keycloak. Since we set it to the host here, we need to set the client id == host in keycloak. + // For AWSSSO, this is the value of Application SAML audience. + return &saml2.SAMLServiceProvider{ + IdentityProviderSSOURL: authDomain.AuthDomainConfig().SAML.SamlIdp, + IdentityProviderIssuer: authDomain.AuthDomainConfig().SAML.SamlEntity, + ServiceProviderIssuer: siteURL.Host, + AssertionConsumerServiceURL: acsURL.String(), + SignAuthnRequests: !authDomain.AuthDomainConfig().SAML.InsecureSkipAuthNRequestsSigned, + AllowMissingAttributes: true, + IDPCertificateStore: certStore, + SPKeyStore: dsig.RandomKeyStoreForTest(), + }, nil +} + +func (a *AuthN) getCertificateStore(authDomain *authtypes.AuthDomain) (dsig.X509CertificateStore, error) { + certStore := &dsig.MemoryX509CertificateStore{ + Roots: []*x509.Certificate{}, + } + + var certBytes []byte + if strings.Contains(authDomain.AuthDomainConfig().SAML.SamlCert, "-----BEGIN CERTIFICATE-----") { + block, _ := pem.Decode([]byte(authDomain.AuthDomainConfig().SAML.SamlCert)) + if block == nil { + return certStore, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "no valid pem cert found") + } + + certBytes = block.Bytes + } else { + certData, err := base64.StdEncoding.DecodeString(authDomain.AuthDomainConfig().SAML.SamlCert) + if err != nil { + return certStore, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to read certificate: %s", err.Error()) + } + + certBytes = certData + } + + idpCert, err := x509.ParseCertificate(certBytes) + if err != nil { + return certStore, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to prepare saml request, invalid cert: %s", err.Error()) + } + + certStore.Roots = append(certStore.Roots, idpCert) + + return certStore, nil +} diff --git a/ee/authz/openfgaauthz/provider.go b/ee/authz/openfgaauthz/provider.go new file mode 100644 index 00000000000..7623b047a4b --- /dev/null +++ b/ee/authz/openfgaauthz/provider.go @@ -0,0 +1,79 @@ +package openfgaauthz + +import ( + "context" + + "github.com/SigNoz/signoz/pkg/authz" + pkgopenfgaauthz "github.com/SigNoz/signoz/pkg/authz/openfgaauthz" + "github.com/SigNoz/signoz/pkg/factory" + "github.com/SigNoz/signoz/pkg/sqlstore" + "github.com/SigNoz/signoz/pkg/types/authtypes" + "github.com/SigNoz/signoz/pkg/valuer" + openfgav1 "github.com/openfga/api/proto/openfga/v1" + openfgapkgtransformer "github.com/openfga/language/pkg/go/transformer" +) + +type provider struct { + pkgAuthzService authz.AuthZ +} + +func NewProviderFactory(sqlstore sqlstore.SQLStore, openfgaSchema []openfgapkgtransformer.ModuleFile) factory.ProviderFactory[authz.AuthZ, authz.Config] { + return factory.NewProviderFactory(factory.MustNewName("openfga"), func(ctx context.Context, ps factory.ProviderSettings, config authz.Config) (authz.AuthZ, error) { + return newOpenfgaProvider(ctx, ps, config, sqlstore, openfgaSchema) + }) +} + +func newOpenfgaProvider(ctx context.Context, settings factory.ProviderSettings, config authz.Config, sqlstore sqlstore.SQLStore, openfgaSchema []openfgapkgtransformer.ModuleFile) (authz.AuthZ, error) { + pkgOpenfgaAuthzProvider := pkgopenfgaauthz.NewProviderFactory(sqlstore, openfgaSchema) + pkgAuthzService, err := pkgOpenfgaAuthzProvider.New(ctx, settings, config) + if err != nil { + return nil, err + } + + return &provider{ + pkgAuthzService: pkgAuthzService, + }, nil +} + +func (provider *provider) Start(ctx context.Context) error { + return provider.pkgAuthzService.Start(ctx) +} + +func (provider *provider) Stop(ctx context.Context) error { + return provider.pkgAuthzService.Stop(ctx) +} + +func (provider *provider) Check(ctx context.Context, tuple *openfgav1.TupleKey) error { + return provider.pkgAuthzService.Check(ctx, tuple) +} + +func (provider *provider) CheckWithTupleCreation(ctx context.Context, claims authtypes.Claims, orgID valuer.UUID, relation authtypes.Relation, _ authtypes.Relation, typeable authtypes.Typeable, selectors []authtypes.Selector) error { + subject, err := authtypes.NewSubject(authtypes.TypeUser, claims.UserID, authtypes.Relation{}) + if err != nil { + return err + } + + tuples, err := typeable.Tuples(subject, relation, selectors, orgID) + if err != nil { + return err + } + + err = provider.BatchCheck(ctx, tuples) + if err != nil { + return err + } + + return nil +} + +func (provider *provider) BatchCheck(ctx context.Context, tuples []*openfgav1.TupleKey) error { + return provider.pkgAuthzService.BatchCheck(ctx, tuples) +} + +func (provider *provider) ListObjects(ctx context.Context, subject string, relation authtypes.Relation, typeable authtypes.Typeable) ([]*authtypes.Object, error) { + return provider.pkgAuthzService.ListObjects(ctx, subject, relation, typeable) +} + +func (provider *provider) Write(ctx context.Context, additions []*openfgav1.TupleKey, deletions []*openfgav1.TupleKey) error { + return provider.pkgAuthzService.Write(ctx, additions, deletions) +} diff --git a/ee/authz/openfgaschema/base.fga b/ee/authz/openfgaschema/base.fga index e2f1f003d4c..fc4d077a4e4 100644 --- a/ee/authz/openfgaschema/base.fga +++ b/ee/authz/openfgaschema/base.fga @@ -21,12 +21,12 @@ type role define update: [user, role#assignee] define delete: [user, role#assignee] -type resources +type metaresources relations define create: [user, role#assignee] define list: [user, role#assignee] -type resource +type metaresource relations define read: [user, anonymous, role#assignee] define update: [user, role#assignee] @@ -35,6 +35,6 @@ type resource define block: [user, role#assignee] -type telemetry +type telemetryresource relations define read: [user, anonymous, role#assignee] diff --git a/ee/http/middleware/authz.go b/ee/http/middleware/authz.go deleted file mode 100644 index f443917c949..00000000000 --- a/ee/http/middleware/authz.go +++ /dev/null @@ -1,132 +0,0 @@ -package middleware - -import ( - "log/slog" - "net/http" - - "github.com/SigNoz/signoz/pkg/authz" - "github.com/SigNoz/signoz/pkg/http/render" - "github.com/SigNoz/signoz/pkg/types/authtypes" - "github.com/gorilla/mux" -) - -const ( - authzDeniedMessage string = "::AUTHZ-DENIED::" -) - -type AuthZ struct { - logger *slog.Logger - authzService authz.AuthZ -} - -func NewAuthZ(logger *slog.Logger) *AuthZ { - if logger == nil { - panic("cannot build authz middleware, logger is empty") - } - - return &AuthZ{logger: logger} -} - -func (middleware *AuthZ) ViewAccess(next http.HandlerFunc) http.HandlerFunc { - return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { - claims, err := authtypes.ClaimsFromContext(req.Context()) - if err != nil { - render.Error(rw, err) - return - } - - if err := claims.IsViewer(); err != nil { - middleware.logger.WarnContext(req.Context(), authzDeniedMessage, "claims", claims) - render.Error(rw, err) - return - } - - next(rw, req) - }) -} - -func (middleware *AuthZ) EditAccess(next http.HandlerFunc) http.HandlerFunc { - return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { - claims, err := authtypes.ClaimsFromContext(req.Context()) - if err != nil { - render.Error(rw, err) - return - } - - if err := claims.IsEditor(); err != nil { - middleware.logger.WarnContext(req.Context(), authzDeniedMessage, "claims", claims) - render.Error(rw, err) - return - } - - next(rw, req) - }) -} - -func (middleware *AuthZ) AdminAccess(next http.HandlerFunc) http.HandlerFunc { - return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { - claims, err := authtypes.ClaimsFromContext(req.Context()) - if err != nil { - render.Error(rw, err) - return - } - - if err := claims.IsAdmin(); err != nil { - middleware.logger.WarnContext(req.Context(), authzDeniedMessage, "claims", claims) - render.Error(rw, err) - return - } - - next(rw, req) - }) -} - -func (middleware *AuthZ) SelfAccess(next http.HandlerFunc) http.HandlerFunc { - return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { - claims, err := authtypes.ClaimsFromContext(req.Context()) - if err != nil { - render.Error(rw, err) - return - } - - id := mux.Vars(req)["id"] - if err := claims.IsSelfAccess(id); err != nil { - middleware.logger.WarnContext(req.Context(), authzDeniedMessage, "claims", claims) - render.Error(rw, err) - return - } - - next(rw, req) - }) -} - -func (middleware *AuthZ) OpenAccess(next http.HandlerFunc) http.HandlerFunc { - return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { - next(rw, req) - }) -} - -// Check middleware accepts the relation, typeable, parentTypeable (for direct access + group relations) and a callback function to derive selector and parentSelectors on per request basis. -func (middleware *AuthZ) Check(next http.HandlerFunc, relation authtypes.Relation, translation authtypes.Relation, typeable authtypes.Typeable, cb authtypes.SelectorCallbackFn) http.HandlerFunc { - return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { - claims, err := authtypes.ClaimsFromContext(req.Context()) - if err != nil { - render.Error(rw, err) - return - } - - selector, err := cb(req.Context(), claims) - if err != nil { - render.Error(rw, err) - return - } - - err = middleware.authzService.CheckWithTupleCreation(req.Context(), claims, relation, typeable, selector) - if err != nil { - render.Error(rw, err) - return - } - - next(rw, req) - }) -} diff --git a/ee/licensing/config.go b/ee/licensing/config.go index 598724d8e36..8fc87c0ba13 100644 --- a/ee/licensing/config.go +++ b/ee/licensing/config.go @@ -1,10 +1,10 @@ package licensing import ( - "fmt" "sync" "time" + "github.com/SigNoz/signoz/pkg/errors" "github.com/SigNoz/signoz/pkg/licensing" ) @@ -18,7 +18,7 @@ func Config(pollInterval time.Duration, failureThreshold int) licensing.Config { once.Do(func() { config = licensing.Config{PollInterval: pollInterval, FailureThreshold: failureThreshold} if err := config.Validate(); err != nil { - panic(fmt.Errorf("invalid licensing config: %w", err)) + panic(errors.WrapInternalf(err, errors.CodeInternal, "invalid licensing config")) } }) diff --git a/ee/query-service/app/api/api.go b/ee/query-service/app/api/api.go index c5fcbf64f47..d3663c91087 100644 --- a/ee/query-service/app/api/api.go +++ b/ee/query-service/app/api/api.go @@ -20,7 +20,6 @@ import ( basemodel "github.com/SigNoz/signoz/pkg/query-service/model" rules "github.com/SigNoz/signoz/pkg/query-service/rules" "github.com/SigNoz/signoz/pkg/signoz" - "github.com/SigNoz/signoz/pkg/types/authtypes" "github.com/SigNoz/signoz/pkg/version" "github.com/gorilla/mux" ) @@ -35,10 +34,7 @@ type APIHandlerOptions struct { Gateway *httputil.ReverseProxy GatewayUrl string // Querier Influx Interval - FluxInterval time.Duration - UseLogsNewSchema bool - UseTraceNewSchema bool - JWT *authtypes.JWT + FluxInterval time.Duration } type APIHandler struct { @@ -93,7 +89,8 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) { router.HandleFunc("/api/v1/features", am.ViewAccess(ah.getFeatureFlags)).Methods(http.MethodGet) // paid plans specific routes - router.HandleFunc("/api/v1/complete/saml", am.OpenAccess(ah.receiveSAML)).Methods(http.MethodPost) + router.HandleFunc("/api/v1/complete/saml", am.OpenAccess(ah.Signoz.Handlers.Session.CreateSessionBySAMLCallback)).Methods(http.MethodPost) + router.HandleFunc("/api/v1/complete/oidc", am.OpenAccess(ah.Signoz.Handlers.Session.CreateSessionByOIDCCallback)).Methods(http.MethodGet) // base overrides router.HandleFunc("/api/v1/version", am.OpenAccess(ah.getVersion)).Methods(http.MethodGet) diff --git a/ee/query-service/app/api/auth.go b/ee/query-service/app/api/auth.go deleted file mode 100644 index 3ffa247440c..00000000000 --- a/ee/query-service/app/api/auth.go +++ /dev/null @@ -1,107 +0,0 @@ -package api - -import ( - "context" - "encoding/base64" - "fmt" - "net/http" - "net/url" - - "go.uber.org/zap" - - "github.com/SigNoz/signoz/pkg/query-service/constants" - "github.com/SigNoz/signoz/pkg/valuer" -) - -func handleSsoError(w http.ResponseWriter, r *http.Request, redirectURL string) { - ssoError := []byte("Login failed. Please contact your system administrator") - dst := make([]byte, base64.StdEncoding.EncodedLen(len(ssoError))) - base64.StdEncoding.Encode(dst, ssoError) - - http.Redirect(w, r, fmt.Sprintf("%s?ssoerror=%s", redirectURL, string(dst)), http.StatusSeeOther) -} - -// receiveSAML completes a SAML request and gets user logged in -func (ah *APIHandler) receiveSAML(w http.ResponseWriter, r *http.Request) { - // this is the source url that initiated the login request - redirectUri := constants.GetDefaultSiteURL() - ctx := context.Background() - - err := r.ParseForm() - if err != nil { - zap.L().Error("[receiveSAML] failed to process response - invalid response from IDP", zap.Error(err), zap.Any("request", r)) - handleSsoError(w, r, redirectUri) - return - } - - // the relay state is sent when a login request is submitted to - // Idp. - relayState := r.FormValue("RelayState") - zap.L().Debug("[receiveML] relay state", zap.String("relayState", relayState)) - - parsedState, err := url.Parse(relayState) - if err != nil || relayState == "" { - zap.L().Error("[receiveSAML] failed to process response - invalid response from IDP", zap.Error(err), zap.Any("request", r)) - handleSsoError(w, r, redirectUri) - return - } - - // upgrade redirect url from the relay state for better accuracy - redirectUri = fmt.Sprintf("%s://%s%s", parsedState.Scheme, parsedState.Host, "/login") - - // fetch domain by parsing relay state. - domain, err := ah.Signoz.Modules.User.GetDomainFromSsoResponse(ctx, parsedState) - if err != nil { - handleSsoError(w, r, redirectUri) - return - } - - orgID, err := valuer.NewUUID(domain.OrgID) - if err != nil { - handleSsoError(w, r, redirectUri) - return - } - - _, err = ah.Signoz.Licensing.GetActive(ctx, orgID) - if err != nil { - zap.L().Error("[receiveSAML] sso requested but feature unavailable in org domain") - http.Redirect(w, r, fmt.Sprintf("%s?ssoerror=%s", redirectUri, "feature unavailable, please upgrade your billing plan to access this feature"), http.StatusMovedPermanently) - return - } - - sp, err := domain.PrepareSamlRequest(parsedState) - if err != nil { - zap.L().Error("[receiveSAML] failed to prepare saml request for domain", zap.String("domain", domain.String()), zap.Error(err)) - handleSsoError(w, r, redirectUri) - return - } - - assertionInfo, err := sp.RetrieveAssertionInfo(r.FormValue("SAMLResponse")) - if err != nil { - zap.L().Error("[receiveSAML] failed to retrieve assertion info from saml response", zap.String("domain", domain.String()), zap.Error(err)) - handleSsoError(w, r, redirectUri) - return - } - - if assertionInfo.WarningInfo.InvalidTime { - zap.L().Error("[receiveSAML] expired saml response", zap.String("domain", domain.String()), zap.Error(err)) - handleSsoError(w, r, redirectUri) - return - } - - email := assertionInfo.NameID - if email == "" { - zap.L().Error("[receiveSAML] invalid email in the SSO response", zap.String("domain", domain.String())) - handleSsoError(w, r, redirectUri) - return - } - - nextPage, err := ah.Signoz.Modules.User.PrepareSsoRedirect(ctx, redirectUri, email) - if err != nil { - zap.L().Error("[receiveSAML] failed to generate redirect URI after successful login ", zap.String("domain", domain.String()), zap.Error(err)) - handleSsoError(w, r, redirectUri) - return - } - - http.Redirect(w, r, nextPage, http.StatusSeeOther) -} diff --git a/ee/query-service/app/api/cloudIntegrations.go b/ee/query-service/app/api/cloudIntegrations.go index 101646e4ee2..1103841a340 100644 --- a/ee/query-service/app/api/cloudIntegrations.go +++ b/ee/query-service/app/api/cloudIntegrations.go @@ -10,7 +10,6 @@ import ( "strings" "time" - "github.com/SigNoz/signoz/ee/query-service/constants" "github.com/SigNoz/signoz/pkg/errors" "github.com/SigNoz/signoz/pkg/http/render" "github.com/SigNoz/signoz/pkg/modules/user" @@ -77,7 +76,7 @@ func (ah *APIHandler) CloudIntegrationsGenerateConnectionParams(w http.ResponseW return } - ingestionUrl, signozApiUrl, apiErr := getIngestionUrlAndSigNozAPIUrl(r.Context(), license.Key) + ingestionUrl, signozApiUrl, apiErr := ah.getIngestionUrlAndSigNozAPIUrl(r.Context(), license.Key) if apiErr != nil { RespondError(w, basemodel.WrapApiError( apiErr, "couldn't deduce ingestion url and signoz api url", @@ -168,82 +167,55 @@ func (ah *APIHandler) getOrCreateCloudIntegrationPAT(ctx context.Context, orgId func (ah *APIHandler) getOrCreateCloudIntegrationUser( ctx context.Context, orgId string, cloudProvider string, ) (*types.User, *basemodel.ApiError) { - cloudIntegrationUser := fmt.Sprintf("%s-integration", cloudProvider) - email := fmt.Sprintf("%s@signoz.io", cloudIntegrationUser) + cloudIntegrationUserName := fmt.Sprintf("%s-integration", cloudProvider) + email := valuer.MustNewEmail(fmt.Sprintf("%s@signoz.io", cloudIntegrationUserName)) - integrationUserResult, err := ah.Signoz.Modules.User.GetUserByEmailInOrg(ctx, orgId, email) - if err != nil && !errors.Ast(err, errors.TypeNotFound) { - return nil, basemodel.NotFoundError(fmt.Errorf("couldn't look for integration user: %w", err)) - } - - if integrationUserResult != nil { - return &integrationUserResult.User, nil - } - - zap.L().Info( - "cloud integration user not found. Attempting to create the user", - zap.String("cloudProvider", cloudProvider), - ) - - newUser, err := types.NewUser(cloudIntegrationUser, email, types.RoleViewer.String(), orgId) + cloudIntegrationUser, err := types.NewUser(cloudIntegrationUserName, email, types.RoleViewer, valuer.MustNewUUID(orgId)) if err != nil { - return nil, basemodel.InternalError(fmt.Errorf( - "couldn't create cloud integration user: %w", err, - )) + return nil, basemodel.InternalError(fmt.Errorf("couldn't create cloud integration user: %w", err)) } - password := types.MustGenerateFactorPassword(newUser.ID.StringValue()) + password := types.MustGenerateFactorPassword(cloudIntegrationUser.ID.StringValue()) - err = ah.Signoz.Modules.User.CreateUser(ctx, newUser, user.WithFactorPassword(password)) + cloudIntegrationUser, err = ah.Signoz.Modules.User.GetOrCreateUser(ctx, cloudIntegrationUser, user.WithFactorPassword(password)) if err != nil { - return nil, basemodel.InternalError(fmt.Errorf("couldn't create cloud integration user: %w", err)) + return nil, basemodel.InternalError(fmt.Errorf("couldn't look for integration user: %w", err)) } - return newUser, nil + return cloudIntegrationUser, nil } -func getIngestionUrlAndSigNozAPIUrl(ctx context.Context, licenseKey string) ( +func (ah *APIHandler) getIngestionUrlAndSigNozAPIUrl(ctx context.Context, licenseKey string) ( string, string, *basemodel.ApiError, ) { - url := fmt.Sprintf( - "%s%s", - strings.TrimSuffix(constants.ZeusURL, "/"), - "/v2/deployments/me", - ) - + // TODO: remove this struct from here type deploymentResponse struct { - Status string `json:"status"` - Error string `json:"error"` - Data struct { - Name string `json:"name"` - - ClusterInfo struct { - Region struct { - DNS string `json:"dns"` - } `json:"region"` - } `json:"cluster"` - } `json:"data"` + Name string `json:"name"` + ClusterInfo struct { + Region struct { + DNS string `json:"dns"` + } `json:"region"` + } `json:"cluster"` } - resp, apiErr := requestAndParseResponse[deploymentResponse]( - ctx, url, map[string]string{"X-Signoz-Cloud-Api-Key": licenseKey}, nil, - ) - - if apiErr != nil { - return "", "", basemodel.WrapApiError( - apiErr, "couldn't query for deployment info", - ) + respBytes, err := ah.Signoz.Zeus.GetDeployment(ctx, licenseKey) + if err != nil { + return "", "", basemodel.InternalError(fmt.Errorf( + "couldn't query for deployment info: error: %w", err, + )) } - if resp.Status != "success" { + resp := new(deploymentResponse) + + err = json.Unmarshal(respBytes, resp) + if err != nil { return "", "", basemodel.InternalError(fmt.Errorf( - "couldn't query for deployment info: status: %s, error: %s", - resp.Status, resp.Error, + "couldn't unmarshal deployment info response: error: %w", err, )) } - regionDns := resp.Data.ClusterInfo.Region.DNS - deploymentName := resp.Data.Name + regionDns := resp.ClusterInfo.Region.DNS + deploymentName := resp.Name if len(regionDns) < 1 || len(deploymentName) < 1 { // Fail early if actual response structure and expectation here ever diverge diff --git a/ee/query-service/app/server.go b/ee/query-service/app/server.go index dab2b7f5163..c9c6205c38a 100644 --- a/ee/query-service/app/server.go +++ b/ee/query-service/app/server.go @@ -7,8 +7,11 @@ import ( "net" "net/http" _ "net/http/pprof" // http profiler + "slices" "github.com/SigNoz/signoz/pkg/ruler/rulestore/sqlrulestore" + "go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux" + "go.opentelemetry.io/otel/propagation" "github.com/gorilla/handlers" @@ -25,7 +28,6 @@ import ( "github.com/SigNoz/signoz/pkg/signoz" "github.com/SigNoz/signoz/pkg/sqlstore" "github.com/SigNoz/signoz/pkg/telemetrystore" - "github.com/SigNoz/signoz/pkg/types/authtypes" "github.com/SigNoz/signoz/pkg/web" "github.com/rs/cors" "github.com/soheilhy/cmux" @@ -50,7 +52,6 @@ import ( type Server struct { config signoz.Config signoz *signoz.SigNoz - jwt *authtypes.JWT ruleManager *baserules.Manager // public http router @@ -67,7 +68,7 @@ type Server struct { } // NewServer creates and initializes Server -func NewServer(config signoz.Config, signoz *signoz.SigNoz, jwt *authtypes.JWT) (*Server, error) { +func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) { gatewayProxy, err := gateway.NewProxy(config.Gateway.URL.String(), gateway.RoutePrefix) if err != nil { return nil, err @@ -153,7 +154,6 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz, jwt *authtypes.JWT) FluxInterval: config.Querier.FluxInterval, Gateway: gatewayProxy, GatewayUrl: config.Gateway.URL.String(), - JWT: jwt, } apiHandler, err := api.NewAPIHandler(apiOpts, signoz) @@ -164,7 +164,6 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz, jwt *authtypes.JWT) s := &Server{ config: config, signoz: signoz, - jwt: jwt, ruleManager: rm, httpHostPort: baseconst.HTTPHostPort, unavailableChannel: make(chan healthcheck.Status), @@ -195,7 +194,17 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h r := baseapp.NewRouter() am := middleware.NewAuthZ(s.signoz.Instrumentation.Logger()) - r.Use(middleware.NewAuth(s.jwt, []string{"Authorization", "Sec-WebSocket-Protocol"}, s.signoz.Sharder, s.signoz.Instrumentation.Logger()).Wrap) + r.Use(otelmux.Middleware( + "apiserver", + otelmux.WithMeterProvider(s.signoz.Instrumentation.MeterProvider()), + otelmux.WithTracerProvider(s.signoz.Instrumentation.TracerProvider()), + otelmux.WithPropagators(propagation.NewCompositeTextMapPropagator(propagation.Baggage{}, propagation.TraceContext{})), + otelmux.WithFilter(func(r *http.Request) bool { + return !slices.Contains([]string{"/api/v1/health"}, r.URL.Path) + }), + otelmux.WithPublicEndpoint(), + )) + r.Use(middleware.NewAuthN([]string{"Authorization", "Sec-WebSocket-Protocol"}, s.signoz.Sharder, s.signoz.Tokenizer, s.signoz.Instrumentation.Logger()).Wrap) r.Use(middleware.NewAPIKey(s.signoz.SQLStore, []string{"SIGNOZ-API-KEY"}, s.signoz.Instrumentation.Logger(), s.signoz.Sharder).Wrap) r.Use(middleware.NewTimeout(s.signoz.Instrumentation.Logger(), s.config.APIServer.Timeout.ExcludedRoutes, diff --git a/ee/query-service/constants/constants.go b/ee/query-service/constants/constants.go index b7c6d11fc58..215e20ff28b 100644 --- a/ee/query-service/constants/constants.go +++ b/ee/query-service/constants/constants.go @@ -4,19 +4,12 @@ import ( "os" ) -const ( - DefaultSiteURL = "https://localhost:8080" -) - var LicenseSignozIo = "https://license.signoz.io/api/v1" var LicenseAPIKey = GetOrDefaultEnv("SIGNOZ_LICENSE_API_KEY", "") var SaasSegmentKey = GetOrDefaultEnv("SIGNOZ_SAAS_SEGMENT_KEY", "") var FetchFeatures = GetOrDefaultEnv("FETCH_FEATURES", "false") var ZeusFeaturesURL = GetOrDefaultEnv("ZEUS_FEATURES_URL", "ZeusFeaturesURL") -// this is set via build time variable -var ZeusURL = "https://api.signoz.cloud" - func GetOrDefaultEnv(key string, fallback string) string { v := os.Getenv(key) if len(v) == 0 { @@ -27,13 +20,6 @@ func GetOrDefaultEnv(key string, fallback string) string { // constant functions that override env vars -// GetDefaultSiteURL returns default site url, primarily -// used to send saml request and allowing backend to -// handle http redirect -func GetDefaultSiteURL() string { - return GetOrDefaultEnv("SIGNOZ_SITE_URL", DefaultSiteURL) -} - const DotMetricsEnabled = "DOT_METRICS_ENABLED" var IsDotMetricsEnabled = false diff --git a/ee/query-service/rules/anomaly.go b/ee/query-service/rules/anomaly.go index af988b5d677..f4464d1290c 100644 --- a/ee/query-service/rules/anomaly.go +++ b/ee/query-service/rules/anomaly.go @@ -78,11 +78,6 @@ func NewAnomalyRule( opts = append(opts, baserules.WithLogger(logger)) - if p.RuleCondition.CompareOp == ruletypes.ValueIsBelow { - target := -1 * *p.RuleCondition.Target - p.RuleCondition.Target = &target - } - baseRule, err := baserules.NewBaseRule(id, orgID, p, reader, opts...) if err != nil { return nil, err diff --git a/ee/sqlschema/postgressqlschema/provider.go b/ee/sqlschema/postgressqlschema/provider.go index af06994b45b..e314998e672 100644 --- a/ee/sqlschema/postgressqlschema/provider.go +++ b/ee/sqlschema/postgressqlschema/provider.go @@ -2,6 +2,7 @@ package postgressqlschema import ( "context" + "database/sql" "github.com/SigNoz/signoz/pkg/factory" "github.com/SigNoz/signoz/pkg/sqlschema" @@ -47,50 +48,45 @@ func (provider *provider) Operator() sqlschema.SQLOperator { } func (provider *provider) GetTable(ctx context.Context, tableName sqlschema.TableName) (*sqlschema.Table, []*sqlschema.UniqueConstraint, error) { - rows, err := provider. + columns := []struct { + ColumnName string `bun:"column_name"` + Nullable bool `bun:"nullable"` + SQLDataType string `bun:"udt_name"` + DefaultVal *string `bun:"column_default"` + }{} + + err := provider. sqlstore. BunDB(). - QueryContext(ctx, ` + NewRaw(` SELECT c.column_name, - c.is_nullable = 'YES', + c.is_nullable = 'YES' as nullable, c.udt_name, c.column_default FROM information_schema.columns AS c WHERE - c.table_name = ?`, string(tableName)) + c.table_name = ?`, string(tableName)). + Scan(ctx, &columns) if err != nil { return nil, nil, err } + if len(columns) == 0 { + return nil, nil, sql.ErrNoRows + } - defer func() { - if err := rows.Close(); err != nil { - provider.settings.Logger().ErrorContext(ctx, "error closing rows", "error", err) - } - }() - - columns := make([]*sqlschema.Column, 0) - for rows.Next() { - var ( - name string - sqlDataType string - nullable bool - defaultVal *string - ) - if err := rows.Scan(&name, &nullable, &sqlDataType, &defaultVal); err != nil { - return nil, nil, err - } - + sqlschemaColumns := make([]*sqlschema.Column, 0) + for _, column := range columns { columnDefault := "" - if defaultVal != nil { - columnDefault = *defaultVal + if column.DefaultVal != nil { + columnDefault = *column.DefaultVal } - columns = append(columns, &sqlschema.Column{ - Name: sqlschema.ColumnName(name), - Nullable: nullable, - DataType: provider.fmter.DataTypeOf(sqlDataType), + sqlschemaColumns = append(sqlschemaColumns, &sqlschema.Column{ + Name: sqlschema.ColumnName(column.ColumnName), + Nullable: column.Nullable, + DataType: provider.fmter.DataTypeOf(column.SQLDataType), Default: columnDefault, }) } @@ -208,7 +204,7 @@ WHERE return &sqlschema.Table{ Name: tableName, - Columns: columns, + Columns: sqlschemaColumns, PrimaryKeyConstraint: primaryKeyConstraint, ForeignKeyConstraints: foreignKeyConstraints, }, uniqueConstraints, nil diff --git a/ee/sqlstore/postgressqlstore/formatter.go b/ee/sqlstore/postgressqlstore/formatter.go new file mode 100644 index 00000000000..028944bda8f --- /dev/null +++ b/ee/sqlstore/postgressqlstore/formatter.go @@ -0,0 +1,153 @@ +package postgressqlstore + +import ( + "strings" + + "github.com/SigNoz/signoz/pkg/sqlstore" + "github.com/uptrace/bun/schema" +) + +type formatter struct { + bunf schema.Formatter +} + +func newFormatter(dialect schema.Dialect) sqlstore.SQLFormatter { + return &formatter{bunf: schema.NewFormatter(dialect)} +} + +func (f *formatter) JSONExtractString(column, path string) []byte { + var sql []byte + sql = f.bunf.AppendIdent(sql, column) + sql = append(sql, f.convertJSONPathToPostgres(path)...) + return sql +} + +func (f *formatter) JSONType(column, path string) []byte { + var sql []byte + sql = append(sql, "jsonb_typeof("...) + sql = f.bunf.AppendIdent(sql, column) + sql = append(sql, f.convertJSONPathToPostgresWithMode(path, false)...) + sql = append(sql, ')') + return sql +} + +func (f *formatter) JSONIsArray(column, path string) []byte { + var sql []byte + sql = append(sql, f.JSONType(column, path)...) + sql = append(sql, " = "...) + sql = schema.Append(f.bunf, sql, "array") + return sql +} + +func (f *formatter) JSONArrayElements(column, path, alias string) ([]byte, []byte) { + var sql []byte + sql = append(sql, "jsonb_array_elements("...) + sql = f.bunf.AppendIdent(sql, column) + sql = append(sql, f.convertJSONPathToPostgresWithMode(path, false)...) + sql = append(sql, ") AS "...) + sql = f.bunf.AppendIdent(sql, alias) + + return sql, []byte(alias) +} + +func (f *formatter) JSONArrayOfStrings(column, path, alias string) ([]byte, []byte) { + var sql []byte + sql = append(sql, "jsonb_array_elements_text("...) + sql = f.bunf.AppendIdent(sql, column) + sql = append(sql, f.convertJSONPathToPostgresWithMode(path, false)...) + sql = append(sql, ") AS "...) + sql = f.bunf.AppendIdent(sql, alias) + + return sql, append([]byte(alias), "::text"...) +} + +func (f *formatter) JSONKeys(column, path, alias string) ([]byte, []byte) { + var sql []byte + sql = append(sql, "jsonb_each("...) + sql = f.bunf.AppendIdent(sql, column) + sql = append(sql, f.convertJSONPathToPostgresWithMode(path, false)...) + sql = append(sql, ") AS "...) + sql = f.bunf.AppendIdent(sql, alias) + + return sql, append([]byte(alias), ".key"...) +} + +func (f *formatter) JSONArrayAgg(expression string) []byte { + var sql []byte + sql = append(sql, "jsonb_agg("...) + sql = append(sql, expression...) + sql = append(sql, ')') + return sql +} + +func (f *formatter) JSONArrayLiteral(values ...string) []byte { + var sql []byte + sql = append(sql, "jsonb_build_array("...) + for idx, value := range values { + if idx > 0 { + sql = append(sql, ", "...) + } + sql = schema.Append(f.bunf, sql, value) + } + sql = append(sql, ')') + return sql +} + +func (f *formatter) TextToJsonColumn(column string) []byte { + var sql []byte + sql = f.bunf.AppendIdent(sql, column) + sql = append(sql, "::jsonb"...) + return sql +} + +func (f *formatter) convertJSONPathToPostgres(jsonPath string) []byte { + return f.convertJSONPathToPostgresWithMode(jsonPath, true) +} + +func (f *formatter) convertJSONPathToPostgresWithMode(jsonPath string, asText bool) []byte { + path := strings.TrimPrefix(strings.TrimPrefix(jsonPath, "$"), ".") + + if path == "" { + return nil + } + + parts := strings.Split(path, ".") + + var validParts []string + for _, part := range parts { + if part != "" { + validParts = append(validParts, part) + } + } + + if len(validParts) == 0 { + return nil + } + + var result []byte + + for idx, part := range validParts { + if idx == len(validParts)-1 { + if asText { + result = append(result, "->>"...) + } else { + result = append(result, "->"...) + } + result = schema.Append(f.bunf, result, part) + return result + } + + result = append(result, "->"...) + result = schema.Append(f.bunf, result, part) + } + + return result +} + +func (f *formatter) LowerExpression(expression string) []byte { + var sql []byte + sql = append(sql, "lower("...) + sql = append(sql, expression...) + sql = append(sql, ')') + return sql +} diff --git a/ee/sqlstore/postgressqlstore/formatter_test.go b/ee/sqlstore/postgressqlstore/formatter_test.go new file mode 100644 index 00000000000..84b47227d63 --- /dev/null +++ b/ee/sqlstore/postgressqlstore/formatter_test.go @@ -0,0 +1,500 @@ +package postgressqlstore + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/uptrace/bun/dialect/pgdialect" +) + +func TestJSONExtractString(t *testing.T) { + tests := []struct { + name string + column string + path string + expected string + }{ + { + name: "simple path", + column: "data", + path: "$.field", + expected: `"data"->>'field'`, + }, + { + name: "nested path", + column: "metadata", + path: "$.user.name", + expected: `"metadata"->'user'->>'name'`, + }, + { + name: "deeply nested path", + column: "json_col", + path: "$.level1.level2.level3", + expected: `"json_col"->'level1'->'level2'->>'level3'`, + }, + { + name: "root path", + column: "json_col", + path: "$", + expected: `"json_col"`, + }, + { + name: "empty path", + column: "data", + path: "", + expected: `"data"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := newFormatter(pgdialect.New()) + got := string(f.JSONExtractString(tt.column, tt.path)) + assert.Equal(t, tt.expected, got) + }) + } +} + +func TestJSONType(t *testing.T) { + tests := []struct { + name string + column string + path string + expected string + }{ + { + name: "simple path", + column: "data", + path: "$.field", + expected: `jsonb_typeof("data"->'field')`, + }, + { + name: "nested path", + column: "metadata", + path: "$.user.age", + expected: `jsonb_typeof("metadata"->'user'->'age')`, + }, + { + name: "root path", + column: "json_col", + path: "$", + expected: `jsonb_typeof("json_col")`, + }, + { + name: "empty path", + column: "data", + path: "", + expected: `jsonb_typeof("data")`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := newFormatter(pgdialect.New()) + got := string(f.JSONType(tt.column, tt.path)) + assert.Equal(t, tt.expected, got) + }) + } +} + +func TestJSONIsArray(t *testing.T) { + tests := []struct { + name string + column string + path string + expected string + }{ + { + name: "simple path", + column: "data", + path: "$.items", + expected: `jsonb_typeof("data"->'items') = 'array'`, + }, + { + name: "nested path", + column: "metadata", + path: "$.user.tags", + expected: `jsonb_typeof("metadata"->'user'->'tags') = 'array'`, + }, + { + name: "root path", + column: "json_col", + path: "$", + expected: `jsonb_typeof("json_col") = 'array'`, + }, + { + name: "empty path", + column: "data", + path: "", + expected: `jsonb_typeof("data") = 'array'`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := newFormatter(pgdialect.New()) + got := string(f.JSONIsArray(tt.column, tt.path)) + assert.Equal(t, tt.expected, got) + }) + } +} + +func TestJSONArrayElements(t *testing.T) { + tests := []struct { + name string + column string + path string + alias string + expected string + }{ + { + name: "root path with dollar sign", + column: "data", + path: "$", + alias: "elem", + expected: `jsonb_array_elements("data") AS "elem"`, + }, + { + name: "root path empty", + column: "data", + path: "", + alias: "elem", + expected: `jsonb_array_elements("data") AS "elem"`, + }, + { + name: "nested path", + column: "metadata", + path: "$.items", + alias: "item", + expected: `jsonb_array_elements("metadata"->'items') AS "item"`, + }, + { + name: "deeply nested path", + column: "json_col", + path: "$.user.tags", + alias: "tag", + expected: `jsonb_array_elements("json_col"->'user'->'tags') AS "tag"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := newFormatter(pgdialect.New()) + got, _ := f.JSONArrayElements(tt.column, tt.path, tt.alias) + assert.Equal(t, tt.expected, string(got)) + }) + } +} + +func TestJSONArrayOfStrings(t *testing.T) { + tests := []struct { + name string + column string + path string + alias string + expected string + }{ + { + name: "root path with dollar sign", + column: "data", + path: "$", + alias: "str", + expected: `jsonb_array_elements_text("data") AS "str"`, + }, + { + name: "root path empty", + column: "data", + path: "", + alias: "str", + expected: `jsonb_array_elements_text("data") AS "str"`, + }, + { + name: "nested path", + column: "metadata", + path: "$.strings", + alias: "s", + expected: `jsonb_array_elements_text("metadata"->'strings') AS "s"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := newFormatter(pgdialect.New()) + got, _ := f.JSONArrayOfStrings(tt.column, tt.path, tt.alias) + assert.Equal(t, tt.expected, string(got)) + }) + } +} + +func TestJSONKeys(t *testing.T) { + tests := []struct { + name string + column string + path string + alias string + expected string + }{ + { + name: "root path with dollar sign", + column: "data", + path: "$", + alias: "k", + expected: `jsonb_each("data") AS "k"`, + }, + { + name: "root path empty", + column: "data", + path: "", + alias: "k", + expected: `jsonb_each("data") AS "k"`, + }, + { + name: "nested path", + column: "metadata", + path: "$.object", + alias: "key", + expected: `jsonb_each("metadata"->'object') AS "key"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := newFormatter(pgdialect.New()) + got, _ := f.JSONKeys(tt.column, tt.path, tt.alias) + assert.Equal(t, tt.expected, string(got)) + }) + } +} + +func TestJSONArrayAgg(t *testing.T) { + tests := []struct { + name string + expression string + expected string + }{ + { + name: "simple column", + expression: "id", + expected: "jsonb_agg(id)", + }, + { + name: "expression with function", + expression: "DISTINCT name", + expected: "jsonb_agg(DISTINCT name)", + }, + { + name: "complex expression", + expression: "data->>'field'", + expected: "jsonb_agg(data->>'field')", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := newFormatter(pgdialect.New()) + got := string(f.JSONArrayAgg(tt.expression)) + assert.Equal(t, tt.expected, got) + }) + } +} + +func TestJSONArrayLiteral(t *testing.T) { + tests := []struct { + name string + values []string + expected string + }{ + { + name: "empty array", + values: []string{}, + expected: "jsonb_build_array()", + }, + { + name: "single value", + values: []string{"value1"}, + expected: "jsonb_build_array('value1')", + }, + { + name: "multiple values", + values: []string{"value1", "value2", "value3"}, + expected: "jsonb_build_array('value1', 'value2', 'value3')", + }, + { + name: "values with special characters", + values: []string{"test", "with space", "with-dash"}, + expected: "jsonb_build_array('test', 'with space', 'with-dash')", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := newFormatter(pgdialect.New()) + got := string(f.JSONArrayLiteral(tt.values...)) + assert.Equal(t, tt.expected, got) + }) + } +} + +func TestConvertJSONPathToPostgresWithMode(t *testing.T) { + tests := []struct { + name string + jsonPath string + asText bool + expected string + }{ + { + name: "simple path as text", + jsonPath: "$.field", + asText: true, + expected: "->>'field'", + }, + { + name: "simple path as json", + jsonPath: "$.field", + asText: false, + expected: "->'field'", + }, + { + name: "nested path as text", + jsonPath: "$.user.name", + asText: true, + expected: "->'user'->>'name'", + }, + { + name: "nested path as json", + jsonPath: "$.user.name", + asText: false, + expected: "->'user'->'name'", + }, + { + name: "deeply nested as text", + jsonPath: "$.a.b.c.d", + asText: true, + expected: "->'a'->'b'->'c'->>'d'", + }, + { + name: "root path", + jsonPath: "$", + asText: true, + expected: "", + }, + { + name: "empty path", + jsonPath: "", + asText: true, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := newFormatter(pgdialect.New()).(*formatter) + got := string(f.convertJSONPathToPostgresWithMode(tt.jsonPath, tt.asText)) + assert.Equal(t, tt.expected, got) + }) + } +} + +func TestTextToJsonColumn(t *testing.T) { + tests := []struct { + name string + column string + expected string + }{ + { + name: "simple column name", + column: "data", + expected: `"data"::jsonb`, + }, + { + name: "column with underscore", + column: "user_data", + expected: `"user_data"::jsonb`, + }, + { + name: "column with special characters", + column: "json-col", + expected: `"json-col"::jsonb`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := newFormatter(pgdialect.New()) + got := string(f.TextToJsonColumn(tt.column)) + assert.Equal(t, tt.expected, got) + }) + } +} + +func TestLowerExpression(t *testing.T) { + tests := []struct { + name string + expr string + expected string + }{ + { + name: "simple column name", + expr: "name", + expected: "lower(name)", + }, + { + name: "quoted column identifier", + expr: `"column_name"`, + expected: `lower("column_name")`, + }, + { + name: "jsonb text extraction", + expr: "data->>'field'", + expected: "lower(data->>'field')", + }, + { + name: "nested jsonb extraction", + expr: "metadata->'user'->>'name'", + expected: "lower(metadata->'user'->>'name')", + }, + { + name: "jsonb_typeof expression", + expr: "jsonb_typeof(data->'field')", + expected: "lower(jsonb_typeof(data->'field'))", + }, + { + name: "string concatenation", + expr: "first_name || ' ' || last_name", + expected: "lower(first_name || ' ' || last_name)", + }, + { + name: "CAST expression", + expr: "CAST(value AS TEXT)", + expected: "lower(CAST(value AS TEXT))", + }, + { + name: "COALESCE expression", + expr: "COALESCE(name, 'default')", + expected: "lower(COALESCE(name, 'default'))", + }, + { + name: "subquery column", + expr: "users.email", + expected: "lower(users.email)", + }, + { + name: "quoted identifier with special chars", + expr: `"user-name"`, + expected: `lower("user-name")`, + }, + { + name: "jsonb to text cast", + expr: "data::text", + expected: "lower(data::text)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := newFormatter(pgdialect.New()) + got := string(f.LowerExpression(tt.expr)) + assert.Equal(t, tt.expected, got) + }) + } +} diff --git a/ee/sqlstore/postgressqlstore/provider.go b/ee/sqlstore/postgressqlstore/provider.go index 7e3bd22f34e..a969ac7fd1a 100644 --- a/ee/sqlstore/postgressqlstore/provider.go +++ b/ee/sqlstore/postgressqlstore/provider.go @@ -15,10 +15,11 @@ import ( ) type provider struct { - settings factory.ScopedProviderSettings - sqldb *sql.DB - bundb *sqlstore.BunDB - dialect *dialect + settings factory.ScopedProviderSettings + sqldb *sql.DB + bundb *sqlstore.BunDB + dialect *dialect + formatter sqlstore.SQLFormatter } func NewFactory(hookFactories ...factory.ProviderFactory[sqlstore.SQLStoreHook, sqlstore.Config]) factory.ProviderFactory[sqlstore.SQLStore, sqlstore.Config] { @@ -55,11 +56,14 @@ func New(ctx context.Context, providerSettings factory.ProviderSettings, config sqldb := stdlib.OpenDBFromPool(pool) + pgDialect := pgdialect.New() + bunDB := sqlstore.NewBunDB(settings, sqldb, pgDialect, hooks) return &provider{ - settings: settings, - sqldb: sqldb, - bundb: sqlstore.NewBunDB(settings, sqldb, pgdialect.New(), hooks), - dialect: new(dialect), + settings: settings, + sqldb: sqldb, + bundb: bunDB, + dialect: new(dialect), + formatter: newFormatter(bunDB.Dialect()), }, nil } @@ -75,6 +79,10 @@ func (provider *provider) Dialect() sqlstore.SQLDialect { return provider.dialect } +func (provider *provider) Formatter() sqlstore.SQLFormatter { + return provider.formatter +} + func (provider *provider) BunDBCtx(ctx context.Context) bun.IDB { return provider.bundb.BunDBCtx(ctx) } diff --git a/ee/zeus/config.go b/ee/zeus/config.go index 0863f160b3e..2a81e556080 100644 --- a/ee/zeus/config.go +++ b/ee/zeus/config.go @@ -1,10 +1,10 @@ package zeus import ( - "fmt" neturl "net/url" "sync" + "github.com/SigNoz/signoz/pkg/errors" "github.com/SigNoz/signoz/pkg/zeus" ) @@ -24,17 +24,17 @@ func Config() zeus.Config { once.Do(func() { parsedURL, err := neturl.Parse(url) if err != nil { - panic(fmt.Errorf("invalid zeus URL: %w", err)) + panic(errors.WrapInternalf(err, errors.CodeInternal, "invalid zeus URL")) } deprecatedParsedURL, err := neturl.Parse(deprecatedURL) if err != nil { - panic(fmt.Errorf("invalid zeus deprecated URL: %w", err)) + panic(errors.WrapInternalf(err, errors.CodeInternal, "invalid zeus deprecated URL")) } config = zeus.Config{URL: parsedURL, DeprecatedURL: deprecatedParsedURL} if err := config.Validate(); err != nil { - panic(fmt.Errorf("invalid zeus config: %w", err)) + panic(errors.WrapInternalf(err, errors.CodeInternal, "invalid zeus config")) } }) diff --git a/frontend/package.json b/frontend/package.json index 6316f619891..97ef6f88448 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -69,7 +69,7 @@ "antd": "5.11.0", "antd-table-saveas-excel": "2.2.1", "antlr4": "4.13.2", - "axios": "1.8.2", + "axios": "1.12.0", "babel-eslint": "^10.1.0", "babel-jest": "^29.6.4", "babel-loader": "9.1.3", @@ -279,6 +279,7 @@ "prismjs": "1.30.0", "got": "11.8.5", "form-data": "4.0.4", - "brace-expansion": "^2.0.2" + "brace-expansion": "^2.0.2", + "on-headers": "^1.1.0" } } diff --git a/frontend/public/Logos/anthropic-api-monitoring.svg b/frontend/public/Logos/anthropic-api-monitoring.svg new file mode 100644 index 00000000000..9f3de2f76ae --- /dev/null +++ b/frontend/public/Logos/anthropic-api-monitoring.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/public/Logos/claude-code.svg b/frontend/public/Logos/claude-code.svg new file mode 100644 index 00000000000..62dc0db12da --- /dev/null +++ b/frontend/public/Logos/claude-code.svg @@ -0,0 +1 @@ +Claude \ No newline at end of file diff --git a/frontend/public/Logos/deepseek.svg b/frontend/public/Logos/deepseek.svg new file mode 100644 index 00000000000..3fc2302406e --- /dev/null +++ b/frontend/public/Logos/deepseek.svg @@ -0,0 +1 @@ +DeepSeek \ No newline at end of file diff --git a/frontend/public/Logos/google-gemini.svg b/frontend/public/Logos/google-gemini.svg new file mode 100644 index 00000000000..f1cf357573d --- /dev/null +++ b/frontend/public/Logos/google-gemini.svg @@ -0,0 +1 @@ +Gemini \ No newline at end of file diff --git a/frontend/public/Logos/langchain.svg b/frontend/public/Logos/langchain.svg new file mode 100644 index 00000000000..2a186d0f690 --- /dev/null +++ b/frontend/public/Logos/langchain.svg @@ -0,0 +1 @@ +LangChain \ No newline at end of file diff --git a/frontend/public/Logos/llamaindex.svg b/frontend/public/Logos/llamaindex.svg new file mode 100644 index 00000000000..7b1b70a6d1a --- /dev/null +++ b/frontend/public/Logos/llamaindex.svg @@ -0,0 +1 @@ +LlamaIndex \ No newline at end of file diff --git a/frontend/public/Logos/mastra.svg b/frontend/public/Logos/mastra.svg new file mode 100644 index 00000000000..8bfc1c40922 --- /dev/null +++ b/frontend/public/Logos/mastra.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/frontend/public/Logos/microsoft-sql-server.svg b/frontend/public/Logos/microsoft-sql-server.svg new file mode 100644 index 00000000000..b025219adf5 --- /dev/null +++ b/frontend/public/Logos/microsoft-sql-server.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/Logos/nomad.svg b/frontend/public/Logos/nomad.svg new file mode 100644 index 00000000000..c0ba29fb41e --- /dev/null +++ b/frontend/public/Logos/nomad.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/frontend/public/Logos/opentelemetry.svg b/frontend/public/Logos/opentelemetry.svg new file mode 100644 index 00000000000..d7c352f83b2 --- /dev/null +++ b/frontend/public/Logos/opentelemetry.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/Logos/supabase.svg b/frontend/public/Logos/supabase.svg new file mode 100644 index 00000000000..ac43e170a1c --- /dev/null +++ b/frontend/public/Logos/supabase.svg @@ -0,0 +1,99 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/AppRoutes/index.tsx b/frontend/src/AppRoutes/index.tsx index 94ae9a90c7c..77052d9bfeb 100644 --- a/frontend/src/AppRoutes/index.tsx +++ b/frontend/src/AppRoutes/index.tsx @@ -27,6 +27,7 @@ import { IUser } from 'providers/App/types'; import { DashboardProvider } from 'providers/Dashboard/Dashboard'; import { ErrorModalProvider } from 'providers/ErrorModalProvider'; import { KBarCommandPaletteProvider } from 'providers/KBarCommandPaletteProvider'; +import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider'; import { QueryBuilderProvider } from 'providers/QueryBuilder'; import { Suspense, useCallback, useEffect, useState } from 'react'; import { Route, Router, Switch } from 'react-router-dom'; @@ -273,7 +274,7 @@ function App(): JSX.Element { chat_settings: { app_id: process.env.PYLON_APP_ID, email: user.email, - name: user.displayName, + name: user.displayName || user.email, }, }; } @@ -382,20 +383,22 @@ function App(): JSX.Element { - }> - - {routes.map(({ path, component, exact }) => ( - - ))} - - - - + + }> + + {routes.map(({ path, component, exact }) => ( + + ))} + + + + + diff --git a/frontend/src/AppRoutes/utils.ts b/frontend/src/AppRoutes/utils.ts index 804740f7a58..17d51bbdcff 100644 --- a/frontend/src/AppRoutes/utils.ts +++ b/frontend/src/AppRoutes/utils.ts @@ -2,14 +2,12 @@ import setLocalStorageApi from 'api/browser/localstorage/set'; import { LOCALSTORAGE } from 'constants/localStorage'; const afterLogin = ( - userId: string, authToken: string, refreshToken: string, interceptorRejected?: boolean, ): void => { setLocalStorageApi(LOCALSTORAGE.AUTH_TOKEN, authToken); setLocalStorageApi(LOCALSTORAGE.REFRESH_AUTH_TOKEN, refreshToken); - setLocalStorageApi(LOCALSTORAGE.USER_ID, userId); setLocalStorageApi(LOCALSTORAGE.IS_LOGGED_IN, 'true'); if (!interceptorRejected) { @@ -18,7 +16,6 @@ const afterLogin = ( detail: { accessJWT: authToken, refreshJWT: refreshToken, - id: userId, }, }), ); diff --git a/frontend/src/api/common/getQueryStats.ts b/frontend/src/api/common/getQueryStats.ts deleted file mode 100644 index c7e8bd2b4bf..00000000000 --- a/frontend/src/api/common/getQueryStats.ts +++ /dev/null @@ -1,62 +0,0 @@ -import getLocalStorageApi from 'api/browser/localstorage/get'; -import { ENVIRONMENT } from 'constants/env'; -import { LOCALSTORAGE } from 'constants/localStorage'; -import { isEmpty } from 'lodash-es'; - -export interface WsDataEvent { - read_rows: number; - read_bytes: number; - elapsed_ms: number; -} -interface GetQueryStatsProps { - queryId: string; - setData: React.Dispatch>; -} - -function getURL(baseURL: string, queryId: string): URL | string { - if (baseURL && !isEmpty(baseURL)) { - return `${baseURL}/ws/query_progress?q=${queryId}`; - } - const url = new URL(`/ws/query_progress?q=${queryId}`, window.location.href); - - if (window.location.protocol === 'http:') { - url.protocol = 'ws'; - } else { - url.protocol = 'wss'; - } - - return url; -} - -export function getQueryStats(props: GetQueryStatsProps): void { - const { queryId, setData } = props; - - const token = getLocalStorageApi(LOCALSTORAGE.AUTH_TOKEN) || ''; - - // https://github.com/whatwg/websockets/issues/20 reason for not using the relative URLs - const url = getURL(ENVIRONMENT.wsURL, queryId); - - const socket = new WebSocket(url, token); - - socket.addEventListener('message', (event) => { - try { - const parsedData = JSON.parse(event?.data); - setData(parsedData); - } catch { - setData(event?.data); - } - }); - - socket.addEventListener('error', (event) => { - console.error(event); - }); - - socket.addEventListener('close', (event) => { - // 1000 is a normal closure status code - if (event.code !== 1000) { - console.error('WebSocket closed with error:', event); - } else { - console.error('WebSocket closed normally.'); - } - }); -} diff --git a/frontend/src/api/common/logEvent.ts b/frontend/src/api/common/logEvent.ts index fd09002582f..e4460ddf1f4 100644 --- a/frontend/src/api/common/logEvent.ts +++ b/frontend/src/api/common/logEvent.ts @@ -1,4 +1,4 @@ -import { ApiBaseInstance as axios } from 'api'; +import { LogEventAxiosInstance as axios } from 'api'; import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; import { AxiosError } from 'axios'; import { ErrorResponse, SuccessResponse } from 'types/api'; diff --git a/frontend/src/api/dynamicVariables/__tests__/getFieldKeys.test.ts b/frontend/src/api/dynamicVariables/__tests__/getFieldKeys.test.ts index c94c915b622..54c7250d122 100644 --- a/frontend/src/api/dynamicVariables/__tests__/getFieldKeys.test.ts +++ b/frontend/src/api/dynamicVariables/__tests__/getFieldKeys.test.ts @@ -1,13 +1,11 @@ /* eslint-disable sonarjs/no-duplicate-string */ -import { ApiBaseInstance } from 'api'; +import axios from 'api'; import { getFieldKeys } from '../getFieldKeys'; // Mock the API instance jest.mock('api', () => ({ - ApiBaseInstance: { - get: jest.fn(), - }, + get: jest.fn(), })); describe('getFieldKeys API', () => { @@ -31,33 +29,33 @@ describe('getFieldKeys API', () => { it('should call API with correct parameters when no args provided', async () => { // Mock successful API response - (ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce(mockSuccessResponse); + (axios.get as jest.Mock).mockResolvedValueOnce(mockSuccessResponse); // Call function with no parameters await getFieldKeys(); // Verify API was called correctly with empty params object - expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/keys', { + expect(axios.get).toHaveBeenCalledWith('/fields/keys', { params: {}, }); }); it('should call API with signal parameter when provided', async () => { // Mock successful API response - (ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce(mockSuccessResponse); + (axios.get as jest.Mock).mockResolvedValueOnce(mockSuccessResponse); // Call function with signal parameter await getFieldKeys('traces'); // Verify API was called with signal parameter - expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/keys', { + expect(axios.get).toHaveBeenCalledWith('/fields/keys', { params: { signal: 'traces' }, }); }); it('should call API with name parameter when provided', async () => { // Mock successful API response - (ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce({ + (axios.get as jest.Mock).mockResolvedValueOnce({ status: 200, data: { status: 'success', @@ -72,14 +70,14 @@ describe('getFieldKeys API', () => { await getFieldKeys(undefined, 'service'); // Verify API was called with name parameter - expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/keys', { + expect(axios.get).toHaveBeenCalledWith('/fields/keys', { params: { name: 'service' }, }); }); it('should call API with both signal and name when provided', async () => { // Mock successful API response - (ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce({ + (axios.get as jest.Mock).mockResolvedValueOnce({ status: 200, data: { status: 'success', @@ -94,14 +92,14 @@ describe('getFieldKeys API', () => { await getFieldKeys('logs', 'service'); // Verify API was called with both parameters - expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/keys', { + expect(axios.get).toHaveBeenCalledWith('/fields/keys', { params: { signal: 'logs', name: 'service' }, }); }); it('should return properly formatted response', async () => { // Mock API to return our response - (ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce(mockSuccessResponse); + (axios.get as jest.Mock).mockResolvedValueOnce(mockSuccessResponse); // Call the function const result = await getFieldKeys('traces'); diff --git a/frontend/src/api/dynamicVariables/__tests__/getFieldValues.test.ts b/frontend/src/api/dynamicVariables/__tests__/getFieldValues.test.ts index 896e939e9a4..1622bc8a060 100644 --- a/frontend/src/api/dynamicVariables/__tests__/getFieldValues.test.ts +++ b/frontend/src/api/dynamicVariables/__tests__/getFieldValues.test.ts @@ -1,13 +1,11 @@ /* eslint-disable sonarjs/no-duplicate-string */ -import { ApiBaseInstance } from 'api'; +import axios from 'api'; import { getFieldValues } from '../getFieldValues'; // Mock the API instance jest.mock('api', () => ({ - ApiBaseInstance: { - get: jest.fn(), - }, + get: jest.fn(), })); describe('getFieldValues API', () => { @@ -17,7 +15,7 @@ describe('getFieldValues API', () => { it('should call the API with correct parameters (no options)', async () => { // Mock API response - (ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce({ + (axios.get as jest.Mock).mockResolvedValueOnce({ status: 200, data: { status: 'success', @@ -34,14 +32,14 @@ describe('getFieldValues API', () => { await getFieldValues(); // Verify API was called correctly with empty params - expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/values', { + expect(axios.get).toHaveBeenCalledWith('/fields/values', { params: {}, }); }); it('should call the API with signal parameter', async () => { // Mock API response - (ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce({ + (axios.get as jest.Mock).mockResolvedValueOnce({ status: 200, data: { status: 'success', @@ -58,14 +56,14 @@ describe('getFieldValues API', () => { await getFieldValues('traces'); // Verify API was called with signal parameter - expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/values', { + expect(axios.get).toHaveBeenCalledWith('/fields/values', { params: { signal: 'traces' }, }); }); it('should call the API with name parameter', async () => { // Mock API response - (ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce({ + (axios.get as jest.Mock).mockResolvedValueOnce({ status: 200, data: { status: 'success', @@ -82,14 +80,14 @@ describe('getFieldValues API', () => { await getFieldValues(undefined, 'service.name'); // Verify API was called with name parameter - expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/values', { + expect(axios.get).toHaveBeenCalledWith('/fields/values', { params: { name: 'service.name' }, }); }); it('should call the API with value parameter', async () => { // Mock API response - (ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce({ + (axios.get as jest.Mock).mockResolvedValueOnce({ status: 200, data: { status: 'success', @@ -106,14 +104,14 @@ describe('getFieldValues API', () => { await getFieldValues(undefined, 'service.name', 'front'); // Verify API was called with value parameter - expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/values', { + expect(axios.get).toHaveBeenCalledWith('/fields/values', { params: { name: 'service.name', searchText: 'front' }, }); }); it('should call the API with time range parameters', async () => { // Mock API response - (ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce({ + (axios.get as jest.Mock).mockResolvedValueOnce({ status: 200, data: { status: 'success', @@ -138,7 +136,7 @@ describe('getFieldValues API', () => { ); // Verify API was called with time range parameters (converted to milliseconds) - expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/values', { + expect(axios.get).toHaveBeenCalledWith('/fields/values', { params: { signal: 'logs', name: 'service.name', @@ -165,7 +163,7 @@ describe('getFieldValues API', () => { }, }; - (ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce(mockResponse); + (axios.get as jest.Mock).mockResolvedValueOnce(mockResponse); // Call the function const result = await getFieldValues('traces', 'mixed.values'); @@ -196,7 +194,7 @@ describe('getFieldValues API', () => { }; // Mock API to return our response - (ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce(mockApiResponse); + (axios.get as jest.Mock).mockResolvedValueOnce(mockApiResponse); // Call the function const result = await getFieldValues('traces', 'service.name'); diff --git a/frontend/src/api/dynamicVariables/getFieldKeys.ts b/frontend/src/api/dynamicVariables/getFieldKeys.ts index 12bfdf1d1e6..3a9dd000113 100644 --- a/frontend/src/api/dynamicVariables/getFieldKeys.ts +++ b/frontend/src/api/dynamicVariables/getFieldKeys.ts @@ -1,4 +1,4 @@ -import { ApiBaseInstance } from 'api'; +import axios from 'api'; import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; import { AxiosError } from 'axios'; import { ErrorV2Resp, SuccessResponseV2 } from 'types/api'; @@ -24,7 +24,7 @@ export const getFieldKeys = async ( } try { - const response = await ApiBaseInstance.get('/fields/keys', { params }); + const response = await axios.get('/fields/keys', { params }); return { httpStatusCode: response.status, diff --git a/frontend/src/api/dynamicVariables/getFieldValues.ts b/frontend/src/api/dynamicVariables/getFieldValues.ts index 6e0c60ec2b2..2ae61018825 100644 --- a/frontend/src/api/dynamicVariables/getFieldValues.ts +++ b/frontend/src/api/dynamicVariables/getFieldValues.ts @@ -1,5 +1,5 @@ /* eslint-disable sonarjs/cognitive-complexity */ -import { ApiBaseInstance } from 'api'; +import axios from 'api'; import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; import { AxiosError } from 'axios'; import { ErrorV2Resp, SuccessResponseV2 } from 'types/api'; @@ -47,7 +47,7 @@ export const getFieldValues = async ( } try { - const response = await ApiBaseInstance.get('/fields/values', { params }); + const response = await axios.get('/fields/values', { params }); // Normalize values from different types (stringValues, boolValues, etc.) if (response.data?.data?.values) { diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 9e78b902212..a24cf6be906 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -2,7 +2,7 @@ /* eslint-disable no-param-reassign */ /* eslint-disable @typescript-eslint/no-explicit-any */ import getLocalStorageApi from 'api/browser/localstorage/get'; -import loginApi from 'api/v1/login/login'; +import post from 'api/v2/sessions/rotate/post'; import afterLogin from 'AppRoutes/utils'; import axios, { AxiosError, @@ -12,6 +12,7 @@ import axios, { import { ENVIRONMENT } from 'constants/env'; import { Events } from 'constants/events'; import { LOCALSTORAGE } from 'constants/localStorage'; +import { QueryClient } from 'react-query'; import { eventEmitter } from 'utils/getEventEmitter'; import apiV1, { @@ -26,6 +27,14 @@ import apiV1, { import { Logout } from './utils'; const RESPONSE_TIMEOUT_THRESHOLD = 5000; // 5 seconds +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: false, + }, + }, +}); const interceptorsResponse = ( value: AxiosResponse, @@ -74,19 +83,25 @@ const interceptorRejected = async ( try { if (axios.isAxiosError(value) && value.response) { const { response } = value; - // reject the refresh token error - if (response.status === 401 && response.config.url !== '/login') { + + if ( + response.status === 401 && + // if the session rotate call or the create session errors out with 401 or the delete sessions call returns 401 then we do not retry! + response.config.url !== '/sessions/rotate' && + response.config.url !== '/sessions/email_password' && + !( + response.config.url === '/sessions' && response.config.method === 'delete' + ) + ) { try { - const response = await loginApi({ - refreshToken: getLocalStorageApi(LOCALSTORAGE.REFRESH_AUTH_TOKEN) || '', + const accessToken = getLocalStorageApi(LOCALSTORAGE.AUTH_TOKEN); + const refreshToken = getLocalStorageApi(LOCALSTORAGE.REFRESH_AUTH_TOKEN); + const response = await queryClient.fetchQuery({ + queryFn: () => post({ refreshToken: refreshToken || '' }), + queryKey: ['/api/v2/sessions/rotate', accessToken, refreshToken], }); - afterLogin( - response.data.userId, - response.data.accessJwt, - response.data.refreshJwt, - true, - ); + afterLogin(response.data.accessToken, response.data.refreshToken, true); try { const reResponse = await axios( @@ -95,7 +110,7 @@ const interceptorRejected = async ( method: value.config.method, headers: { ...value.config.headers, - Authorization: `Bearer ${response.data.accessJwt}`, + Authorization: `Bearer ${response.data.accessToken}`, }, data: { ...JSON.parse(value.config.data || '{}'), @@ -113,8 +128,8 @@ const interceptorRejected = async ( Logout(); } } - // when refresh token is expired - if (response.status === 401 && response.config.url === '/login') { + + if (response.status === 401 && response.config.url === '/sessions/rotate') { Logout(); } } @@ -185,15 +200,15 @@ ApiV5Instance.interceptors.request.use(interceptorsRequestResponse); // // axios Base -export const ApiBaseInstance = axios.create({ +export const LogEventAxiosInstance = axios.create({ baseURL: `${ENVIRONMENT.baseURL}${apiV1}`, }); -ApiBaseInstance.interceptors.response.use( +LogEventAxiosInstance.interceptors.response.use( interceptorsResponse, interceptorRejectedBase, ); -ApiBaseInstance.interceptors.request.use(interceptorsRequestResponse); +LogEventAxiosInstance.interceptors.request.use(interceptorsRequestResponse); // // gateway Api V1 diff --git a/frontend/src/api/infra/getHostAttributeKeys.ts b/frontend/src/api/infra/getHostAttributeKeys.ts index 1a34cd44798..ce39b797885 100644 --- a/frontend/src/api/infra/getHostAttributeKeys.ts +++ b/frontend/src/api/infra/getHostAttributeKeys.ts @@ -1,4 +1,4 @@ -import { ApiBaseInstance } from 'api'; +import axios from 'api'; import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; import { AxiosError, AxiosResponse } from 'axios'; import { baseAutoCompleteIdKeysOrder } from 'constants/queryBuilder'; @@ -17,7 +17,7 @@ export const getHostAttributeKeys = async ( try { const response: AxiosResponse<{ data: IQueryAutocompleteResponse; - }> = await ApiBaseInstance.get( + }> = await axios.get( `/${entity}/attribute_keys?dataSource=metrics&searchText=${searchText}`, { params: { diff --git a/frontend/src/api/messagingQueues/onboarding/getOnboardingStatus.ts b/frontend/src/api/messagingQueues/onboarding/getOnboardingStatus.ts index da82e701343..79bb859b042 100644 --- a/frontend/src/api/messagingQueues/onboarding/getOnboardingStatus.ts +++ b/frontend/src/api/messagingQueues/onboarding/getOnboardingStatus.ts @@ -1,4 +1,4 @@ -import { ApiBaseInstance } from 'api'; +import axios from 'api'; import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; import { AxiosError } from 'axios'; import { SOMETHING_WENT_WRONG } from 'constants/api'; @@ -20,7 +20,7 @@ const getOnboardingStatus = async (props: { }): Promise | ErrorResponse> => { const { endpointService, ...rest } = props; try { - const response = await ApiBaseInstance.post( + const response = await axios.post( `/messaging-queues/kafka/onboarding/${endpointService || 'consumers'}`, rest, ); diff --git a/frontend/src/api/metrics/getService.ts b/frontend/src/api/metrics/getService.ts index 731da11c81e..b269be96bda 100644 --- a/frontend/src/api/metrics/getService.ts +++ b/frontend/src/api/metrics/getService.ts @@ -1,13 +1,20 @@ -import axios from 'api'; +import { ApiV2Instance } from 'api'; +import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; +import { AxiosError } from 'axios'; +import { ErrorV2Resp } from 'types/api'; import { PayloadProps, Props } from 'types/api/metrics/getService'; const getService = async (props: Props): Promise => { - const response = await axios.post(`/services`, { - start: `${props.start}`, - end: `${props.end}`, - tags: props.selectedTags, - }); - return response.data; + try { + const response = await ApiV2Instance.post(`/services`, { + start: `${props.start}`, + end: `${props.end}`, + tags: props.selectedTags, + }); + return response.data.data; + } catch (error) { + ErrorResponseHandlerV2(error as AxiosError); + } }; export default getService; diff --git a/frontend/src/api/metrics/getTopOperations.ts b/frontend/src/api/metrics/getTopOperations.ts index af02e2c333e..bf72660d03b 100644 --- a/frontend/src/api/metrics/getTopOperations.ts +++ b/frontend/src/api/metrics/getTopOperations.ts @@ -1,22 +1,27 @@ -import axios from 'api'; +import { ApiV2Instance } from 'api'; +import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; +import { AxiosError } from 'axios'; +import { ErrorV2Resp } from 'types/api'; import { PayloadProps, Props } from 'types/api/metrics/getTopOperations'; const getTopOperations = async (props: Props): Promise => { - const endpoint = props.isEntryPoint - ? '/service/entry_point_operations' - : '/service/top_operations'; + try { + 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, - }); + const response = await ApiV2Instance.post(endpoint, { + start: `${props.start}`, + end: `${props.end}`, + service: props.service, + tags: props.selectedTags, + limit: 5000, + }); - if (props.isEntryPoint) { return response.data.data; + } catch (error) { + ErrorResponseHandlerV2(error as AxiosError); } - return response.data; }; export default getTopOperations; diff --git a/frontend/src/api/metricsExplorer/updateMetricMetadata.ts b/frontend/src/api/metricsExplorer/updateMetricMetadata.ts index f641772c3e1..8e52a034d98 100644 --- a/frontend/src/api/metricsExplorer/updateMetricMetadata.ts +++ b/frontend/src/api/metricsExplorer/updateMetricMetadata.ts @@ -9,6 +9,7 @@ export interface UpdateMetricMetadataProps { metricType: MetricType; temporality?: Temporality; isMonotonic?: boolean; + unit?: string; } export interface UpdateMetricMetadataResponse { diff --git a/frontend/src/api/quickFilters/getCustomFilters.ts b/frontend/src/api/quickFilters/getCustomFilters.ts index b5bd6308e8f..6bea61347ea 100644 --- a/frontend/src/api/quickFilters/getCustomFilters.ts +++ b/frontend/src/api/quickFilters/getCustomFilters.ts @@ -1,4 +1,4 @@ -import { ApiBaseInstance } from 'api'; +import axios from 'api'; import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; import { AxiosError } from 'axios'; import { ErrorResponse, SuccessResponse } from 'types/api'; @@ -9,7 +9,7 @@ const getCustomFilters = async ( ): Promise | ErrorResponse> => { const { signal } = props; try { - const response = await ApiBaseInstance.get(`orgs/me/filters/${signal}`); + const response = await axios.get(`/orgs/me/filters/${signal}`); return { statusCode: 200, diff --git a/frontend/src/api/quickFilters/updateCustomFilters.ts b/frontend/src/api/quickFilters/updateCustomFilters.ts index 46285a35a91..497dd606075 100644 --- a/frontend/src/api/quickFilters/updateCustomFilters.ts +++ b/frontend/src/api/quickFilters/updateCustomFilters.ts @@ -1,4 +1,4 @@ -import { ApiBaseInstance } from 'api'; +import axios from 'api'; import { AxiosError } from 'axios'; import { SuccessResponse } from 'types/api'; import { UpdateCustomFiltersProps } from 'types/api/quickFilters/updateCustomFilters'; @@ -6,7 +6,7 @@ import { UpdateCustomFiltersProps } from 'types/api/quickFilters/updateCustomFil const updateCustomFiltersAPI = async ( props: UpdateCustomFiltersProps, ): Promise | AxiosError> => - ApiBaseInstance.put(`orgs/me/filters`, { + axios.put(`/orgs/me/filters`, { ...props.data, }); diff --git a/frontend/src/api/settings/setRetentionV2.ts b/frontend/src/api/settings/setRetentionV2.ts index bd7e56d8e5f..a123d08d3ed 100644 --- a/frontend/src/api/settings/setRetentionV2.ts +++ b/frontend/src/api/settings/setRetentionV2.ts @@ -8,7 +8,7 @@ const setRetentionV2 = async ({ type, defaultTTLDays, coldStorageVolume, - coldStorageDuration, + coldStorageDurationDays, ttlConditions, }: PropsV2): Promise> => { try { @@ -16,7 +16,7 @@ const setRetentionV2 = async ({ type, defaultTTLDays, coldStorageVolume, - coldStorageDuration, + coldStorageDurationDays, ttlConditions, }); diff --git a/frontend/src/api/thirdPartyApis/listOverview.ts b/frontend/src/api/thirdPartyApis/listOverview.ts index 303ee03ae06..7477cce5917 100644 --- a/frontend/src/api/thirdPartyApis/listOverview.ts +++ b/frontend/src/api/thirdPartyApis/listOverview.ts @@ -1,4 +1,4 @@ -import { ApiBaseInstance } from 'api'; +import axios from 'api'; import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; import { AxiosError } from 'axios'; import { ErrorV2Resp, SuccessResponseV2 } from 'types/api'; @@ -9,15 +9,12 @@ const listOverview = async ( ): Promise> => { const { start, end, show_ip: showIp, filter } = props; try { - const response = await ApiBaseInstance.post( - `/third-party-apis/overview/list`, - { - start, - end, - show_ip: showIp, - filter, - }, - ); + const response = await axios.post(`/third-party-apis/overview/list`, { + start, + end, + show_ip: showIp, + filter, + }); return { httpStatusCode: response.status, diff --git a/frontend/src/api/v1/register/signup.ts b/frontend/src/api/trace/getSpanPercentiles.ts similarity index 51% rename from frontend/src/api/v1/register/signup.ts rename to frontend/src/api/trace/getSpanPercentiles.ts index 5838a8e7adf..5704484c805 100644 --- a/frontend/src/api/v1/register/signup.ts +++ b/frontend/src/api/trace/getSpanPercentiles.ts @@ -2,21 +2,27 @@ import axios from 'api'; import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; import { AxiosError } from 'axios'; import { ErrorV2Resp, SuccessResponseV2 } from 'types/api'; -import { PayloadProps, Signup } from 'types/api/user/loginPrecheck'; -import { Props } from 'types/api/user/signup'; +import { + GetSpanPercentilesProps, + GetSpanPercentilesResponseDataProps, +} from 'types/api/trace/getSpanPercentiles'; -const signup = async (props: Props): Promise> => { +const getSpanPercentiles = async ( + props: GetSpanPercentilesProps, +): Promise> => { try { - const response = await axios.post(`/register`, { + const response = await axios.post('/span_percentile', { ...props, }); + return { httpStatusCode: response.status, data: response.data.data, }; } catch (error) { ErrorResponseHandlerV2(error as AxiosError); + throw error; } }; -export default signup; +export default getSpanPercentiles; diff --git a/frontend/src/api/utils.ts b/frontend/src/api/utils.ts index 731b8e859af..0744e7f25e5 100644 --- a/frontend/src/api/utils.ts +++ b/frontend/src/api/utils.ts @@ -3,7 +3,15 @@ import { LOCALSTORAGE } from 'constants/localStorage'; import ROUTES from 'constants/routes'; import history from 'lib/history'; -export const Logout = (): void => { +import deleteSession from './v2/sessions/delete'; + +export const Logout = async (): Promise => { + try { + await deleteSession(); + } catch (error) { + console.error(error); + } + deleteLocalStorageKey(LOCALSTORAGE.AUTH_TOKEN); deleteLocalStorageKey(LOCALSTORAGE.IS_LOGGED_IN); deleteLocalStorageKey(LOCALSTORAGE.IS_IDENTIFIED_USER); @@ -14,7 +22,6 @@ export const Logout = (): void => { deleteLocalStorageKey(LOCALSTORAGE.USER_ID); deleteLocalStorageKey(LOCALSTORAGE.QUICK_FILTERS_SETTINGS_ANNOUNCEMENT); window.dispatchEvent(new CustomEvent('LOGOUT')); - history.push(ROUTES.LOGIN); }; diff --git a/frontend/src/api/v1/domains/delete.ts b/frontend/src/api/v1/domains/id/delete.ts similarity index 62% rename from frontend/src/api/v1/domains/delete.ts rename to frontend/src/api/v1/domains/id/delete.ts index 0c1f452248f..2a432f45061 100644 --- a/frontend/src/api/v1/domains/delete.ts +++ b/frontend/src/api/v1/domains/id/delete.ts @@ -2,11 +2,10 @@ import axios from 'api'; import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; import { AxiosError } from 'axios'; import { ErrorV2Resp, SuccessResponseV2 } from 'types/api'; -import { PayloadProps, Props } from 'types/api/SAML/deleteDomain'; -const deleteDomain = async (props: Props): Promise> => { +const deleteDomain = async (id: string): Promise> => { try { - const response = await axios.delete(`/domains/${props.id}`); + const response = await axios.delete(`/domains/${id}`); return { httpStatusCode: response.status, diff --git a/frontend/src/api/v1/domains/id/put.ts b/frontend/src/api/v1/domains/id/put.ts new file mode 100644 index 00000000000..ea11b338a94 --- /dev/null +++ b/frontend/src/api/v1/domains/id/put.ts @@ -0,0 +1,25 @@ +import axios from 'api'; +import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; +import { AxiosError } from 'axios'; +import { ErrorV2Resp, RawSuccessResponse, SuccessResponseV2 } from 'types/api'; +import { UpdatableAuthDomain } from 'types/api/v1/domains/put'; + +const put = async ( + props: UpdatableAuthDomain, +): Promise> => { + try { + const response = await axios.put>( + `/domains/${props.id}`, + { config: props.config }, + ); + + return { + httpStatusCode: response.status, + data: response.data.data, + }; + } catch (error) { + ErrorResponseHandlerV2(error as AxiosError); + } +}; + +export default put; diff --git a/frontend/src/api/v1/domains/list.ts b/frontend/src/api/v1/domains/list.ts index fc056873a06..e9fb3691909 100644 --- a/frontend/src/api/v1/domains/list.ts +++ b/frontend/src/api/v1/domains/list.ts @@ -1,12 +1,16 @@ import axios from 'api'; import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; import { AxiosError } from 'axios'; -import { ErrorV2Resp, SuccessResponseV2 } from 'types/api'; -import { AuthDomain, PayloadProps } from 'types/api/SAML/listDomain'; +import { ErrorV2Resp, RawSuccessResponse, SuccessResponseV2 } from 'types/api'; +import { GettableAuthDomain } from 'types/api/v1/domains/list'; -const listAllDomain = async (): Promise> => { +const listAllDomain = async (): Promise< + SuccessResponseV2 +> => { try { - const response = await axios.get(`/domains`); + const response = await axios.get>( + `/domains`, + ); return { httpStatusCode: response.status, diff --git a/frontend/src/api/v1/domains/post.ts b/frontend/src/api/v1/domains/post.ts new file mode 100644 index 00000000000..dc8538ba1fe --- /dev/null +++ b/frontend/src/api/v1/domains/post.ts @@ -0,0 +1,26 @@ +import axios from 'api'; +import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; +import { AxiosError } from 'axios'; +import { ErrorV2Resp, RawSuccessResponse, SuccessResponseV2 } from 'types/api'; +import { GettableAuthDomain } from 'types/api/v1/domains/list'; +import { PostableAuthDomain } from 'types/api/v1/domains/post'; + +const post = async ( + props: PostableAuthDomain, +): Promise> => { + try { + const response = await axios.post>( + `/domains`, + props, + ); + + return { + httpStatusCode: response.status, + data: response.data.data, + }; + } catch (error) { + ErrorResponseHandlerV2(error as AxiosError); + } +}; + +export default post; diff --git a/frontend/src/api/v1/domains/update.ts b/frontend/src/api/v1/domains/update.ts deleted file mode 100644 index 701555a39d1..00000000000 --- a/frontend/src/api/v1/domains/update.ts +++ /dev/null @@ -1,23 +0,0 @@ -import axios from 'api'; -import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; -import { AxiosError } from 'axios'; -import { ErrorV2Resp, SuccessResponseV2 } from 'types/api'; -import { AuthDomain } from 'types/api/SAML/listDomain'; -import { PayloadProps, Props } from 'types/api/SAML/updateDomain'; - -const updateDomain = async ( - props: Props, -): Promise> => { - try { - const response = await axios.put(`/domains/${props.id}`, props); - - return { - httpStatusCode: response.status, - data: response.data.data, - }; - } catch (error) { - ErrorResponseHandlerV2(error as AxiosError); - } -}; - -export default updateDomain; diff --git a/frontend/src/api/v1/invite/id/accept.ts b/frontend/src/api/v1/invite/id/accept.ts index 3c466fbbd28..68d17a080df 100644 --- a/frontend/src/api/v1/invite/id/accept.ts +++ b/frontend/src/api/v1/invite/id/accept.ts @@ -2,15 +2,12 @@ import axios from 'api'; import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; import { AxiosError } from 'axios'; import { ErrorV2Resp, SuccessResponseV2 } from 'types/api'; -import { - LoginPrecheckResponse, - PayloadProps, - Props, -} from 'types/api/user/accept'; +import { PayloadProps, Props } from 'types/api/user/accept'; +import { UserResponse } from 'types/api/user/getUser'; const accept = async ( props: Props, -): Promise> => { +): Promise> => { try { const response = await axios.post(`/invite/accept`, props); return { diff --git a/frontend/src/api/v1/login/loginPrecheck.ts b/frontend/src/api/v1/login/loginPrecheck.ts deleted file mode 100644 index eac00182cb5..00000000000 --- a/frontend/src/api/v1/login/loginPrecheck.ts +++ /dev/null @@ -1,28 +0,0 @@ -import axios from 'api'; -import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; -import { AxiosError } from 'axios'; -import { ErrorResponse, SuccessResponse } from 'types/api'; -import { Props, Signup as PayloadProps } from 'types/api/user/loginPrecheck'; - -const loginPrecheck = async ( - props: Props, -): Promise | ErrorResponse> => { - try { - const response = await axios.get( - `/loginPrecheck?email=${encodeURIComponent( - props.email, - )}&ref=${encodeURIComponent(window.location.href)}`, - ); - - return { - statusCode: 200, - error: null, - message: response.statusText, - payload: response.data.data, - }; - } catch (error) { - return ErrorResponseHandler(error as AxiosError); - } -}; - -export default loginPrecheck; diff --git a/frontend/src/api/v1/register/post.ts b/frontend/src/api/v1/register/post.ts new file mode 100644 index 00000000000..262c8a73c45 --- /dev/null +++ b/frontend/src/api/v1/register/post.ts @@ -0,0 +1,27 @@ +import axios from 'api'; +import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; +import { AxiosError } from 'axios'; +import { ErrorV2Resp, RawSuccessResponse, SuccessResponseV2 } from 'types/api'; +import { Props } from 'types/api/user/signup'; +import { SignupResponse } from 'types/api/v1/register/post'; + +const post = async ( + props: Props, +): Promise> => { + try { + const response = await axios.post>( + `/register`, + { + ...props, + }, + ); + return { + httpStatusCode: response.status, + data: response.data.data, + }; + } catch (error) { + ErrorResponseHandlerV2(error as AxiosError); + } +}; + +export default post; diff --git a/frontend/src/api/v1/login/login.ts b/frontend/src/api/v1/user/me/get.ts similarity index 57% rename from frontend/src/api/v1/login/login.ts rename to frontend/src/api/v1/user/me/get.ts index 46f17ad7116..f5dd8d9fa15 100644 --- a/frontend/src/api/v1/login/login.ts +++ b/frontend/src/api/v1/user/me/get.ts @@ -2,15 +2,11 @@ import axios from 'api'; import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; import { AxiosError } from 'axios'; import { ErrorV2Resp, SuccessResponseV2 } from 'types/api'; -import { PayloadProps, Props, UserLoginResponse } from 'types/api/user/login'; +import { PayloadProps, UserResponse } from 'types/api/user/getUser'; -const login = async ( - props: Props, -): Promise> => { +const get = async (): Promise> => { try { - const response = await axios.post(`/login`, { - ...props, - }); + const response = await axios.get(`/user/me`); return { httpStatusCode: response.status, @@ -21,4 +17,4 @@ const login = async ( } }; -export default login; +export default get; diff --git a/frontend/src/api/v1/domains/create.ts b/frontend/src/api/v1/version/get.ts similarity index 50% rename from frontend/src/api/v1/domains/create.ts rename to frontend/src/api/v1/version/get.ts index 18fbc21b2bd..d60b32ff361 100644 --- a/frontend/src/api/v1/domains/create.ts +++ b/frontend/src/api/v1/version/get.ts @@ -2,20 +2,19 @@ import axios from 'api'; import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; import { AxiosError } from 'axios'; import { ErrorV2Resp, SuccessResponseV2 } from 'types/api'; -import { AuthDomain } from 'types/api/SAML/listDomain'; -import { PayloadProps, Props } from 'types/api/SAML/postDomain'; +import { Info } from 'types/api/v1/version/get'; -const create = async (props: Props): Promise> => { +const get = async (): Promise> => { try { - const response = await axios.post(`/domains`, props); + const response = await axios.get(`/version`); return { httpStatusCode: response.status, - data: response.data.data, + data: response.data, }; } catch (error) { ErrorResponseHandlerV2(error as AxiosError); } }; -export default create; +export default get; diff --git a/frontend/src/api/v1/version/getVersion.ts b/frontend/src/api/v1/version/getVersion.ts deleted file mode 100644 index 0f3e7f8e838..00000000000 --- a/frontend/src/api/v1/version/getVersion.ts +++ /dev/null @@ -1,25 +0,0 @@ -import axios from 'api'; -import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; -import { AxiosError } from 'axios'; -import { getVersion } from 'constants/api'; -import { ErrorResponse, SuccessResponse } from 'types/api'; -import { PayloadProps } from 'types/api/user/getVersion'; - -const getVersionApi = async (): Promise< - SuccessResponse | ErrorResponse -> => { - try { - const response = await axios.get(`/${getVersion}`); - - return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data, - }; - } catch (error) { - return ErrorResponseHandler(error as AxiosError); - } -}; - -export default getVersionApi; diff --git a/frontend/src/api/v2/sessions/context/get.ts b/frontend/src/api/v2/sessions/context/get.ts new file mode 100644 index 00000000000..2f08c74623d --- /dev/null +++ b/frontend/src/api/v2/sessions/context/get.ts @@ -0,0 +1,27 @@ +import { ApiV2Instance as axios } from 'api'; +import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; +import { AxiosError } from 'axios'; +import { ErrorV2Resp, RawSuccessResponse, SuccessResponseV2 } from 'types/api'; +import { Props, SessionsContext } from 'types/api/v2/sessions/context/get'; + +const get = async ( + props: Props, +): Promise> => { + try { + const response = await axios.get>( + '/sessions/context', + { + params: props, + }, + ); + + return { + httpStatusCode: response.status, + data: response.data.data, + }; + } catch (error) { + ErrorResponseHandlerV2(error as AxiosError); + } +}; + +export default get; diff --git a/frontend/src/api/v2/sessions/delete.ts b/frontend/src/api/v2/sessions/delete.ts new file mode 100644 index 00000000000..339ea0639eb --- /dev/null +++ b/frontend/src/api/v2/sessions/delete.ts @@ -0,0 +1,19 @@ +import { ApiV2Instance as axios } from 'api'; +import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; +import { AxiosError } from 'axios'; +import { ErrorV2Resp, RawSuccessResponse, SuccessResponseV2 } from 'types/api'; + +const deleteSession = async (): Promise> => { + try { + const response = await axios.delete>('/sessions'); + + return { + httpStatusCode: response.status, + data: response.data.data, + }; + } catch (error) { + ErrorResponseHandlerV2(error as AxiosError); + } +}; + +export default deleteSession; diff --git a/frontend/src/api/v2/sessions/email_password/post.ts b/frontend/src/api/v2/sessions/email_password/post.ts new file mode 100644 index 00000000000..643821a0a02 --- /dev/null +++ b/frontend/src/api/v2/sessions/email_password/post.ts @@ -0,0 +1,23 @@ +import { ApiV2Instance as axios } from 'api'; +import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; +import { AxiosError } from 'axios'; +import { ErrorV2Resp, RawSuccessResponse, SuccessResponseV2 } from 'types/api'; +import { Props, Token } from 'types/api/v2/sessions/email_password/post'; + +const post = async (props: Props): Promise> => { + try { + const response = await axios.post>( + '/sessions/email_password', + props, + ); + + return { + httpStatusCode: response.status, + data: response.data.data, + }; + } catch (error) { + ErrorResponseHandlerV2(error as AxiosError); + } +}; + +export default post; diff --git a/frontend/src/api/v2/sessions/rotate/post.ts b/frontend/src/api/v2/sessions/rotate/post.ts new file mode 100644 index 00000000000..7738aba0a8f --- /dev/null +++ b/frontend/src/api/v2/sessions/rotate/post.ts @@ -0,0 +1,23 @@ +import { ApiV2Instance as axios } from 'api'; +import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; +import { AxiosError } from 'axios'; +import { ErrorV2Resp, RawSuccessResponse, SuccessResponseV2 } from 'types/api'; +import { Props, Token } from 'types/api/v2/sessions/rotate/post'; + +const post = async (props: Props): Promise> => { + try { + const response = await axios.post>( + '/sessions/rotate', + props, + ); + + return { + httpStatusCode: response.status, + data: response.data.data, + }; + } catch (error) { + ErrorResponseHandlerV2(error as AxiosError); + } +}; + +export default post; diff --git a/frontend/src/api/v5/queryRange/getQueryRange.ts b/frontend/src/api/v5/queryRange/getQueryRange.ts index 35aa474e6cb..82afa1e9ee8 100644 --- a/frontend/src/api/v5/queryRange/getQueryRange.ts +++ b/frontend/src/api/v5/queryRange/getQueryRange.ts @@ -11,7 +11,7 @@ import { export const getQueryRangeV5 = async ( props: QueryRangePayloadV5, version: string, - signal: AbortSignal, + signal?: AbortSignal, headers?: Record, ): Promise> => { try { diff --git a/frontend/src/components/ChangelogModal/components/ChangelogRenderer.styles.scss b/frontend/src/components/ChangelogModal/components/ChangelogRenderer.styles.scss index bfd29677ca0..6c4dfb5db07 100644 --- a/frontend/src/components/ChangelogModal/components/ChangelogRenderer.styles.scss +++ b/frontend/src/components/ChangelogModal/components/ChangelogRenderer.styles.scss @@ -99,7 +99,7 @@ } } - & :is(h1, h2, h3, h4, h5, h6, p, &-section-title) { + & :is(h1, h2, h3, h4, h5, h6, &-section-title) { font-weight: 600; color: var(--text-vanilla-100, #fff); } diff --git a/frontend/src/components/Graph/__tests__/yAxisConfig.test.ts b/frontend/src/components/Graph/__tests__/yAxisConfig.test.ts new file mode 100644 index 00000000000..b5ddb97b9fc --- /dev/null +++ b/frontend/src/components/Graph/__tests__/yAxisConfig.test.ts @@ -0,0 +1,371 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +import { getYAxisFormattedValue, PrecisionOptionsEnum } from '../yAxisConfig'; + +const testFullPrecisionGetYAxisFormattedValue = ( + value: string, + format: string, +): string => getYAxisFormattedValue(value, format, PrecisionOptionsEnum.FULL); + +describe('getYAxisFormattedValue - none (full precision legacy assertions)', () => { + test('large integers and decimals', () => { + expect(testFullPrecisionGetYAxisFormattedValue('250034', 'none')).toBe( + '250034', + ); + expect( + testFullPrecisionGetYAxisFormattedValue('250034897.12345', 'none'), + ).toBe('250034897.12345'); + expect( + testFullPrecisionGetYAxisFormattedValue('250034897.02354', 'none'), + ).toBe('250034897.02354'); + expect(testFullPrecisionGetYAxisFormattedValue('9999999.9999', 'none')).toBe( + '9999999.9999', + ); + }); + + test('preserves leading zeros after decimal until first non-zero', () => { + expect(testFullPrecisionGetYAxisFormattedValue('1.0000234', 'none')).toBe( + '1.0000234', + ); + expect(testFullPrecisionGetYAxisFormattedValue('0.00003', 'none')).toBe( + '0.00003', + ); + }); + + test('trims to three significant decimals and removes trailing zeros', () => { + expect( + testFullPrecisionGetYAxisFormattedValue('0.000000250034', 'none'), + ).toBe('0.000000250034'); + expect(testFullPrecisionGetYAxisFormattedValue('0.00000025', 'none')).toBe( + '0.00000025', + ); + + // Big precision, limiting the javascript precision (~16 digits) + expect( + testFullPrecisionGetYAxisFormattedValue('1.0000000000000001', 'none'), + ).toBe('1'); + expect( + testFullPrecisionGetYAxisFormattedValue('1.00555555559595876', 'none'), + ).toBe('1.005555555595958'); + + expect(testFullPrecisionGetYAxisFormattedValue('0.000000001', 'none')).toBe( + '0.000000001', + ); + expect( + testFullPrecisionGetYAxisFormattedValue('0.000000250000', 'none'), + ).toBe('0.00000025'); + }); + + test('whole numbers normalize', () => { + expect(testFullPrecisionGetYAxisFormattedValue('1000', 'none')).toBe('1000'); + expect(testFullPrecisionGetYAxisFormattedValue('99.5458', 'none')).toBe( + '99.5458', + ); + expect(testFullPrecisionGetYAxisFormattedValue('1.234567', 'none')).toBe( + '1.234567', + ); + expect(testFullPrecisionGetYAxisFormattedValue('99.998', 'none')).toBe( + '99.998', + ); + }); + + test('strip redundant decimal zeros', () => { + expect(testFullPrecisionGetYAxisFormattedValue('1000.000', 'none')).toBe( + '1000', + ); + expect(testFullPrecisionGetYAxisFormattedValue('99.500', 'none')).toBe( + '99.5', + ); + expect(testFullPrecisionGetYAxisFormattedValue('1.000', 'none')).toBe('1'); + }); + + test('edge values', () => { + expect(testFullPrecisionGetYAxisFormattedValue('0', 'none')).toBe('0'); + expect(testFullPrecisionGetYAxisFormattedValue('-0', 'none')).toBe('0'); + expect(testFullPrecisionGetYAxisFormattedValue('Infinity', 'none')).toBe('∞'); + expect(testFullPrecisionGetYAxisFormattedValue('-Infinity', 'none')).toBe( + '-∞', + ); + expect(testFullPrecisionGetYAxisFormattedValue('invalid', 'none')).toBe( + 'NaN', + ); + expect(testFullPrecisionGetYAxisFormattedValue('', 'none')).toBe('NaN'); + expect(testFullPrecisionGetYAxisFormattedValue('abc123', 'none')).toBe('NaN'); + }); + + test('small decimals keep precision as-is', () => { + expect(testFullPrecisionGetYAxisFormattedValue('0.0001', 'none')).toBe( + '0.0001', + ); + expect(testFullPrecisionGetYAxisFormattedValue('-0.0001', 'none')).toBe( + '-0.0001', + ); + expect(testFullPrecisionGetYAxisFormattedValue('0.000000001', 'none')).toBe( + '0.000000001', + ); + }); + + test('simple decimals preserved', () => { + expect(testFullPrecisionGetYAxisFormattedValue('0.1', 'none')).toBe('0.1'); + expect(testFullPrecisionGetYAxisFormattedValue('0.2', 'none')).toBe('0.2'); + expect(testFullPrecisionGetYAxisFormattedValue('0.3', 'none')).toBe('0.3'); + expect(testFullPrecisionGetYAxisFormattedValue('1.0000000001', 'none')).toBe( + '1.0000000001', + ); + }); +}); + +describe('getYAxisFormattedValue - units (full precision legacy assertions)', () => { + test('ms', () => { + expect(testFullPrecisionGetYAxisFormattedValue('1500', 'ms')).toBe('1.5 s'); + expect(testFullPrecisionGetYAxisFormattedValue('500', 'ms')).toBe('500 ms'); + expect(testFullPrecisionGetYAxisFormattedValue('60000', 'ms')).toBe('1 min'); + expect(testFullPrecisionGetYAxisFormattedValue('295.429', 'ms')).toBe( + '295.429 ms', + ); + expect(testFullPrecisionGetYAxisFormattedValue('4353.81', 'ms')).toBe( + '4.35381 s', + ); + }); + + test('s', () => { + expect(testFullPrecisionGetYAxisFormattedValue('90', 's')).toBe('1.5 mins'); + expect(testFullPrecisionGetYAxisFormattedValue('30', 's')).toBe('30 s'); + expect(testFullPrecisionGetYAxisFormattedValue('3600', 's')).toBe('1 hour'); + }); + + test('m', () => { + expect(testFullPrecisionGetYAxisFormattedValue('90', 'm')).toBe('1.5 hours'); + expect(testFullPrecisionGetYAxisFormattedValue('30', 'm')).toBe('30 min'); + expect(testFullPrecisionGetYAxisFormattedValue('1440', 'm')).toBe('1 day'); + }); + + test('bytes', () => { + expect(testFullPrecisionGetYAxisFormattedValue('1024', 'bytes')).toBe( + '1 KiB', + ); + expect(testFullPrecisionGetYAxisFormattedValue('512', 'bytes')).toBe('512 B'); + expect(testFullPrecisionGetYAxisFormattedValue('1536', 'bytes')).toBe( + '1.5 KiB', + ); + }); + + test('mbytes', () => { + expect(testFullPrecisionGetYAxisFormattedValue('1024', 'mbytes')).toBe( + '1 GiB', + ); + expect(testFullPrecisionGetYAxisFormattedValue('512', 'mbytes')).toBe( + '512 MiB', + ); + expect(testFullPrecisionGetYAxisFormattedValue('1536', 'mbytes')).toBe( + '1.5 GiB', + ); + }); + + test('kbytes', () => { + expect(testFullPrecisionGetYAxisFormattedValue('1024', 'kbytes')).toBe( + '1 MiB', + ); + expect(testFullPrecisionGetYAxisFormattedValue('512', 'kbytes')).toBe( + '512 KiB', + ); + expect(testFullPrecisionGetYAxisFormattedValue('1536', 'kbytes')).toBe( + '1.5 MiB', + ); + }); + + test('short', () => { + expect(testFullPrecisionGetYAxisFormattedValue('1000', 'short')).toBe('1 K'); + expect(testFullPrecisionGetYAxisFormattedValue('1500', 'short')).toBe( + '1.5 K', + ); + expect(testFullPrecisionGetYAxisFormattedValue('999', 'short')).toBe('999'); + + expect(testFullPrecisionGetYAxisFormattedValue('1000000', 'short')).toBe( + '1 Mil', + ); + expect(testFullPrecisionGetYAxisFormattedValue('1555600', 'short')).toBe( + '1.5556 Mil', + ); + expect(testFullPrecisionGetYAxisFormattedValue('999999', 'short')).toBe( + '999.999 K', + ); + + expect(testFullPrecisionGetYAxisFormattedValue('1000000000', 'short')).toBe( + '1 Bil', + ); + expect(testFullPrecisionGetYAxisFormattedValue('1500000000', 'short')).toBe( + '1.5 Bil', + ); + expect(testFullPrecisionGetYAxisFormattedValue('999999999', 'short')).toBe( + '999.999999 Mil', + ); + }); + + test('percent', () => { + expect(testFullPrecisionGetYAxisFormattedValue('0.15', 'percent')).toBe( + '0.15%', + ); + expect(testFullPrecisionGetYAxisFormattedValue('0.1234', 'percent')).toBe( + '0.1234%', + ); + expect(testFullPrecisionGetYAxisFormattedValue('0.123499', 'percent')).toBe( + '0.123499%', + ); + expect(testFullPrecisionGetYAxisFormattedValue('1.5', 'percent')).toBe( + '1.5%', + ); + expect(testFullPrecisionGetYAxisFormattedValue('0.0001', 'percent')).toBe( + '0.0001%', + ); + expect( + testFullPrecisionGetYAxisFormattedValue('0.000000001', 'percent'), + ).toBe('1e-9%'); + expect( + testFullPrecisionGetYAxisFormattedValue('0.000000250034', 'percent'), + ).toBe('0.000000250034%'); + expect(testFullPrecisionGetYAxisFormattedValue('0.00000025', 'percent')).toBe( + '0.00000025%', + ); + // Big precision, limiting the javascript precision (~16 digits) + expect( + testFullPrecisionGetYAxisFormattedValue('1.0000000000000001', 'percent'), + ).toBe('1%'); + expect( + testFullPrecisionGetYAxisFormattedValue('1.00555555559595876', 'percent'), + ).toBe('1.005555555595958%'); + }); + + test('ratio', () => { + expect(testFullPrecisionGetYAxisFormattedValue('0.5', 'ratio')).toBe( + '0.5 ratio', + ); + expect(testFullPrecisionGetYAxisFormattedValue('1.25', 'ratio')).toBe( + '1.25 ratio', + ); + expect(testFullPrecisionGetYAxisFormattedValue('2.0', 'ratio')).toBe( + '2 ratio', + ); + }); + + test('temperature units', () => { + expect(testFullPrecisionGetYAxisFormattedValue('25', 'celsius')).toBe( + '25 °C', + ); + expect(testFullPrecisionGetYAxisFormattedValue('0', 'celsius')).toBe('0 °C'); + expect(testFullPrecisionGetYAxisFormattedValue('-10', 'celsius')).toBe( + '-10 °C', + ); + + expect(testFullPrecisionGetYAxisFormattedValue('77', 'fahrenheit')).toBe( + '77 °F', + ); + expect(testFullPrecisionGetYAxisFormattedValue('32', 'fahrenheit')).toBe( + '32 °F', + ); + expect(testFullPrecisionGetYAxisFormattedValue('14', 'fahrenheit')).toBe( + '14 °F', + ); + }); + + test('ms edge cases', () => { + expect(testFullPrecisionGetYAxisFormattedValue('0', 'ms')).toBe('0 ms'); + expect(testFullPrecisionGetYAxisFormattedValue('-1500', 'ms')).toBe('-1.5 s'); + expect(testFullPrecisionGetYAxisFormattedValue('Infinity', 'ms')).toBe('∞'); + }); + + test('bytes edge cases', () => { + expect(testFullPrecisionGetYAxisFormattedValue('0', 'bytes')).toBe('0 B'); + expect(testFullPrecisionGetYAxisFormattedValue('-1024', 'bytes')).toBe( + '-1 KiB', + ); + }); +}); + +describe('getYAxisFormattedValue - precision option tests', () => { + test('precision 0 drops decimal part', () => { + expect(getYAxisFormattedValue('1.2345', 'none', 0)).toBe('1'); + expect(getYAxisFormattedValue('0.9999', 'none', 0)).toBe('0'); + expect(getYAxisFormattedValue('12345.6789', 'none', 0)).toBe('12345'); + expect(getYAxisFormattedValue('0.0000123456', 'none', 0)).toBe('0'); + expect(getYAxisFormattedValue('1000.000', 'none', 0)).toBe('1000'); + expect(getYAxisFormattedValue('0.000000250034', 'none', 0)).toBe('0'); + expect(getYAxisFormattedValue('1.00555555559595876', 'none', 0)).toBe('1'); + + // with unit + expect(getYAxisFormattedValue('4353.81', 'ms', 0)).toBe('4 s'); + }); + test('precision 1,2,3,4 decimals', () => { + expect(getYAxisFormattedValue('1.2345', 'none', 1)).toBe('1.2'); + expect(getYAxisFormattedValue('1.2345', 'none', 2)).toBe('1.23'); + expect(getYAxisFormattedValue('1.2345', 'none', 3)).toBe('1.234'); + expect(getYAxisFormattedValue('1.2345', 'none', 4)).toBe('1.2345'); + + expect(getYAxisFormattedValue('0.0000123456', 'none', 1)).toBe('0.00001'); + expect(getYAxisFormattedValue('0.0000123456', 'none', 2)).toBe('0.000012'); + expect(getYAxisFormattedValue('0.0000123456', 'none', 3)).toBe('0.0000123'); + expect(getYAxisFormattedValue('0.0000123456', 'none', 4)).toBe('0.00001234'); + + expect(getYAxisFormattedValue('1000.000', 'none', 1)).toBe('1000'); + expect(getYAxisFormattedValue('1000.000', 'none', 2)).toBe('1000'); + expect(getYAxisFormattedValue('1000.000', 'none', 3)).toBe('1000'); + expect(getYAxisFormattedValue('1000.000', 'none', 4)).toBe('1000'); + + expect(getYAxisFormattedValue('0.000000250034', 'none', 1)).toBe('0.0000002'); + expect(getYAxisFormattedValue('0.000000250034', 'none', 2)).toBe( + '0.00000025', + ); // leading zeros + 2 significant => same trimmed + expect(getYAxisFormattedValue('0.000000250034', 'none', 3)).toBe( + '0.00000025', + ); + expect(getYAxisFormattedValue('0.000000250304', 'none', 4)).toBe( + '0.0000002503', + ); + + expect(getYAxisFormattedValue('1.00555555559595876', 'none', 1)).toBe( + '1.005', + ); + expect(getYAxisFormattedValue('1.00555555559595876', 'none', 2)).toBe( + '1.0055', + ); + expect(getYAxisFormattedValue('1.00555555559595876', 'none', 3)).toBe( + '1.00555', + ); + expect(getYAxisFormattedValue('1.00555555559595876', 'none', 4)).toBe( + '1.005555', + ); + + // with unit + expect(getYAxisFormattedValue('4353.81', 'ms', 1)).toBe('4.4 s'); + expect(getYAxisFormattedValue('4353.81', 'ms', 2)).toBe('4.35 s'); + expect(getYAxisFormattedValue('4353.81', 'ms', 3)).toBe('4.354 s'); + expect(getYAxisFormattedValue('4353.81', 'ms', 4)).toBe('4.3538 s'); + + // Percentages + expect(getYAxisFormattedValue('0.123456', 'percent', 2)).toBe('0.12%'); + expect(getYAxisFormattedValue('0.123456', 'percent', 4)).toBe('0.1235%'); // approximation + }); + + test('precision full uses up to DEFAULT_SIGNIFICANT_DIGITS significant digits', () => { + expect( + getYAxisFormattedValue( + '0.00002625429914148441', + 'none', + PrecisionOptionsEnum.FULL, + ), + ).toBe('0.000026254299141'); + expect( + getYAxisFormattedValue( + '0.000026254299141484417', + 's', + PrecisionOptionsEnum.FULL, + ), + ).toBe('26254299141484417000000 µs'); + + expect( + getYAxisFormattedValue('4353.81', 'ms', PrecisionOptionsEnum.FULL), + ).toBe('4.35381 s'); + expect(getYAxisFormattedValue('500', 'ms', PrecisionOptionsEnum.FULL)).toBe( + '500 ms', + ); + }); +}); diff --git a/frontend/src/components/Graph/yAxisConfig.ts b/frontend/src/components/Graph/yAxisConfig.ts index a5eca129260..e5d6feafac5 100644 --- a/frontend/src/components/Graph/yAxisConfig.ts +++ b/frontend/src/components/Graph/yAxisConfig.ts @@ -1,58 +1,158 @@ +/* eslint-disable sonarjs/cognitive-complexity */ import { formattedValueToString, getValueFormat } from '@grafana/data'; +import * as Sentry from '@sentry/react'; +import { isNaN } from 'lodash-es'; +const DEFAULT_SIGNIFICANT_DIGITS = 15; +// max decimals to keep should not exceed 15 decimal places to avoid floating point precision issues +const MAX_DECIMALS = 15; + +export enum PrecisionOptionsEnum { + ZERO = 0, + ONE = 1, + TWO = 2, + THREE = 3, + FOUR = 4, + FULL = 'full', +} +export type PrecisionOption = 0 | 1 | 2 | 3 | 4 | PrecisionOptionsEnum.FULL; + +/** + * Formats a number for display, preserving leading zeros after the decimal point + * and showing up to DEFAULT_SIGNIFICANT_DIGITS digits after the first non-zero decimal digit. + * It avoids scientific notation and removes unnecessary trailing zeros. + * + * @example + * formatDecimalWithLeadingZeros(1.2345); // "1.2345" + * formatDecimalWithLeadingZeros(0.0012345); // "0.0012345" + * formatDecimalWithLeadingZeros(5.0); // "5" + * + * @param value The number to format. + * @returns The formatted string. + */ +const formatDecimalWithLeadingZeros = ( + value: number, + precision: PrecisionOption, +): string => { + if (value === 0) { + return '0'; + } + + // Use toLocaleString to get a full decimal representation without scientific notation. + const numStr = value.toLocaleString('en-US', { + useGrouping: false, + maximumFractionDigits: 20, + }); + + const [integerPart, decimalPart = ''] = numStr.split('.'); + + // If there's no decimal part, the integer part is the result. + if (!decimalPart) { + return integerPart; + } + + // Find the index of the first non-zero digit in the decimal part. + const firstNonZeroIndex = decimalPart.search(/[^0]/); + + // If the decimal part consists only of zeros, return just the integer part. + if (firstNonZeroIndex === -1) { + return integerPart; + } + + // Determine the number of decimals to keep: leading zeros + up to N significant digits. + const significantDigits = + precision === PrecisionOptionsEnum.FULL + ? DEFAULT_SIGNIFICANT_DIGITS + : precision; + const decimalsToKeep = firstNonZeroIndex + (significantDigits || 0); + + // max decimals to keep should not exceed 15 decimal places to avoid floating point precision issues + const finalDecimalsToKeep = Math.min(decimalsToKeep, MAX_DECIMALS); + const trimmedDecimalPart = decimalPart.substring(0, finalDecimalsToKeep); + + // If precision is 0, we drop the decimal part entirely. + if (precision === 0) { + return integerPart; + } + + // Remove any trailing zeros from the result to keep it clean. + const finalDecimalPart = trimmedDecimalPart.replace(/0+$/, ''); + + // Return the integer part, or the integer and decimal parts combined. + return finalDecimalPart ? `${integerPart}.${finalDecimalPart}` : integerPart; +}; + +/** + * Formats a Y-axis value based on a given format string. + * + * @param value The string value from the axis. + * @param format The format identifier (e.g. 'none', 'ms', 'bytes', 'short'). + * @returns A formatted string ready for display. + */ export const getYAxisFormattedValue = ( value: string, format: string, + precision: PrecisionOption = 2, // default precision requested ): string => { - let decimalPrecision: number | undefined; - const parsedValue = getValueFormat(format)( - parseFloat(value), - undefined, - undefined, - undefined, - ); - try { - const decimalSplitted = parsedValue.text.split('.'); - if (decimalSplitted.length === 1) { - decimalPrecision = 0; - } else { - const decimalDigits = decimalSplitted[1].split(''); - decimalPrecision = decimalDigits.length; - let nonZeroCtr = 0; - for (let idx = 0; idx < decimalDigits.length; idx += 1) { - if (decimalDigits[idx] !== '0') { - nonZeroCtr += 1; - if (nonZeroCtr >= 2) { - decimalPrecision = idx + 1; - } - } else if (nonZeroCtr) { - decimalPrecision = idx; - break; - } - } - } + const numValue = parseFloat(value); - return formattedValueToString( - getValueFormat(format)( - parseFloat(value), - decimalPrecision, - undefined, - undefined, - ), - ); - } catch (error) { - console.error(error); + // Handle non-numeric or special values first. + if (isNaN(numValue)) return 'NaN'; + if (numValue === Infinity) return '∞'; + if (numValue === -Infinity) return '-∞'; + + const decimalPlaces = value.split('.')[1]?.length || undefined; + + // Use custom formatter for the 'none' format honoring precision + if (format === 'none') { + return formatDecimalWithLeadingZeros(numValue, precision); } - return `${parseFloat(value)}`; -}; -export const getToolTipValue = (value: string, format?: string): string => { + // For all other standard formats, delegate to grafana/data's built-in formatter. + const computeDecimals = (): number | undefined => { + if (precision === PrecisionOptionsEnum.FULL) { + return decimalPlaces && decimalPlaces >= DEFAULT_SIGNIFICANT_DIGITS + ? decimalPlaces + : DEFAULT_SIGNIFICANT_DIGITS; + } + return precision; + }; + + const fallbackFormat = (): string => { + if (precision === PrecisionOptionsEnum.FULL) return numValue.toString(); + if (precision === 0) return Math.round(numValue).toString(); + return precision !== undefined + ? numValue + .toFixed(precision) + .replace(/(\.[0-9]*[1-9])0+$/, '$1') // trimming zeros + .replace(/\.$/, '') + : numValue.toString(); + }; + try { - return formattedValueToString( - getValueFormat(format)(parseFloat(value), undefined, undefined, undefined), - ); + const formatter = getValueFormat(format); + const formattedValue = formatter(numValue, computeDecimals(), undefined); + if (formattedValue.text && formattedValue.text.includes('.')) { + formattedValue.text = formatDecimalWithLeadingZeros( + parseFloat(formattedValue.text), + precision, + ); + } + return formattedValueToString(formattedValue); } catch (error) { - console.error(error); + Sentry.captureEvent({ + message: `Error applying formatter: ${ + error instanceof Error ? error.message : 'Unknown error' + }`, + level: 'error', + }); + return fallbackFormat(); } - return `${value}`; }; + +export const getToolTipValue = ( + value: string | number, + format?: string, + precision?: PrecisionOption, +): string => + getYAxisFormattedValue(value?.toString(), format || 'none', precision); diff --git a/frontend/src/components/HeaderRightSection/HeaderRightSection.tsx b/frontend/src/components/HeaderRightSection/HeaderRightSection.tsx index 84607984344..e2933d5bc53 100644 --- a/frontend/src/components/HeaderRightSection/HeaderRightSection.tsx +++ b/frontend/src/components/HeaderRightSection/HeaderRightSection.tsx @@ -86,7 +86,9 @@ function HeaderRightSection({ className="share-feedback-btn periscope-btn ghost" icon={} onClick={handleOpenFeedbackModal} - /> + > + Feedback + )} diff --git a/frontend/src/components/HostMetricsDetail/Metrics/Metrics.tsx b/frontend/src/components/HostMetricsDetail/Metrics/Metrics.tsx index 179dacb4a50..3550b0c56f3 100644 --- a/frontend/src/components/HostMetricsDetail/Metrics/Metrics.tsx +++ b/frontend/src/components/HostMetricsDetail/Metrics/Metrics.tsx @@ -60,6 +60,14 @@ function Metrics({ setElement, } = useMultiIntersectionObserver(hostWidgetInfo.length, { threshold: 0.1 }); + const legendScrollPositionRef = useRef<{ + scrollTop: number; + scrollLeft: number; + }>({ + scrollTop: 0, + scrollLeft: 0, + }); + const queryPayloads = useMemo( () => getHostQueryPayload( @@ -147,6 +155,13 @@ function Metrics({ maxTimeScale: graphTimeIntervals[idx].end, onDragSelect: (start, end) => onDragSelect(start, end, idx), query: currentQuery, + legendScrollPosition: legendScrollPositionRef.current, + setLegendScrollPosition: (position: { + scrollTop: number; + scrollLeft: number; + }) => { + legendScrollPositionRef.current = position; + }, }), ), [ diff --git a/frontend/src/components/LogDetail/LogDetails.styles.scss b/frontend/src/components/LogDetail/LogDetails.styles.scss index 37902394f74..ef6615cb44e 100644 --- a/frontend/src/components/LogDetail/LogDetails.styles.scss +++ b/frontend/src/components/LogDetail/LogDetails.styles.scss @@ -132,9 +132,9 @@ justify-content: center; } - .json-action-btn { + .log-detail-drawer__actions { display: flex; - gap: 8px; + gap: 4px; } } diff --git a/frontend/src/components/LogDetail/index.tsx b/frontend/src/components/LogDetail/index.tsx index 23a50ca3d75..34c9f061d40 100644 --- a/frontend/src/components/LogDetail/index.tsx +++ b/frontend/src/components/LogDetail/index.tsx @@ -319,31 +319,35 @@ function LogDetailInner({ - {selectedView === VIEW_TYPES.JSON && ( -
+
+ {selectedView === VIEW_TYPES.CONTEXT && ( + +
- )} - - {selectedView === VIEW_TYPES.CONTEXT && ( -
{isFilterVisible && contextQuery?.builder.queryData[0] && (
@@ -383,7 +387,8 @@ function LogDetailInner({ podName={log.resources_string?.[RESOURCE_KEYS.POD_NAME] || ''} nodeName={log.resources_string?.[RESOURCE_KEYS.NODE_NAME] || ''} hostName={log.resources_string?.[RESOURCE_KEYS.HOST_NAME] || ''} - logLineTimestamp={log.timestamp.toString()} + timestamp={log.timestamp.toString()} + dataSource={DataSource.LOGS} /> )} diff --git a/frontend/src/components/Logs/RawLogView/styles.ts b/frontend/src/components/Logs/RawLogView/styles.ts index 75375525bd4..e004a6cc26b 100644 --- a/frontend/src/components/Logs/RawLogView/styles.ts +++ b/frontend/src/components/Logs/RawLogView/styles.ts @@ -57,8 +57,8 @@ export const RawLogViewContainer = styled(Row)<{ transition: background-color 2s ease-in;` : ''} - ${({ $isCustomHighlighted, $isDarkMode, $logType }): string => - getCustomHighlightBackground($isCustomHighlighted, $isDarkMode, $logType)} + ${({ $isCustomHighlighted }): string => + getCustomHighlightBackground($isCustomHighlighted)} `; export const InfoIconWrapper = styled(Info)` diff --git a/frontend/src/components/Logs/TableView/useTableView.tsx b/frontend/src/components/Logs/TableView/useTableView.tsx index a355c6372af..6bd9dcff255 100644 --- a/frontend/src/components/Logs/TableView/useTableView.tsx +++ b/frontend/src/components/Logs/TableView/useTableView.tsx @@ -153,7 +153,9 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => { children: ( = ({ placeholder = 'Search...', className, diff --git a/frontend/src/components/NewSelect/utils.ts b/frontend/src/components/NewSelect/utils.ts index c17f15d3fec..3cae1ac6ff2 100644 --- a/frontend/src/components/NewSelect/utils.ts +++ b/frontend/src/components/NewSelect/utils.ts @@ -5,6 +5,8 @@ import { OptionData } from './types'; export const SPACEKEY = ' '; +export const ALL_SELECTED_VALUE = '__ALL__'; // Constant for the special value + export const prioritizeOrAddOptionForSingleSelect = ( options: OptionData[], value: string, diff --git a/frontend/src/components/QueryBuilderV2/QueryBuilderV2.styles.scss b/frontend/src/components/QueryBuilderV2/QueryBuilderV2.styles.scss index fcfafc3688c..e0195ef0fed 100644 --- a/frontend/src/components/QueryBuilderV2/QueryBuilderV2.styles.scss +++ b/frontend/src/components/QueryBuilderV2/QueryBuilderV2.styles.scss @@ -398,7 +398,7 @@ } .qb-search-container { - .metrics-select-container { + .metrics-container { margin-bottom: 12px; } } diff --git a/frontend/src/components/QueryBuilderV2/QueryBuilderV2.tsx b/frontend/src/components/QueryBuilderV2/QueryBuilderV2.tsx index 7be666c2708..9e2ba8ce775 100644 --- a/frontend/src/components/QueryBuilderV2/QueryBuilderV2.tsx +++ b/frontend/src/components/QueryBuilderV2/QueryBuilderV2.tsx @@ -22,6 +22,8 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({ showOnlyWhereClause = false, showTraceOperator = false, version, + onSignalSourceChange, + signalSourceChangeEnabled = false, }: QueryBuilderProps): JSX.Element { const { currentQuery, @@ -175,6 +177,8 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({ queryVariant={config?.queryVariant || 'dropdown'} showOnlyWhereClause={showOnlyWhereClause} isListViewPanel={isListViewPanel} + onSignalSourceChange={onSignalSourceChange || ((): void => {})} + signalSourceChangeEnabled={signalSourceChangeEnabled} /> ) : ( currentQuery.builder.queryData.map((query, index) => ( @@ -193,7 +197,9 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({ queryVariant={config?.queryVariant || 'dropdown'} showOnlyWhereClause={showOnlyWhereClause} isListViewPanel={isListViewPanel} - signalSource={config?.signalSource || ''} + signalSource={query.source as 'meter' | ''} + onSignalSourceChange={onSignalSourceChange || ((): void => {})} + signalSourceChangeEnabled={signalSourceChangeEnabled} /> )) )} diff --git a/frontend/src/components/QueryBuilderV2/QueryV2/MetricsSelect/MetricsSelect.styles.scss b/frontend/src/components/QueryBuilderV2/QueryV2/MetricsSelect/MetricsSelect.styles.scss index 9038e8ce433..cfa74f8c938 100644 --- a/frontend/src/components/QueryBuilderV2/QueryV2/MetricsSelect/MetricsSelect.styles.scss +++ b/frontend/src/components/QueryBuilderV2/QueryV2/MetricsSelect/MetricsSelect.styles.scss @@ -1,5 +1,14 @@ -.metrics-select-container { +.metrics-source-select-container { margin-bottom: 8px; + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 8px; + width: 100%; + + .source-selector { + width: 120px; + } .ant-select-selector { width: 100%; @@ -42,7 +51,7 @@ } .lightMode { - .metrics-select-container { + .metrics-source-select-container { .ant-select-selector { border: 1px solid var(--bg-vanilla-300) !important; background: var(--bg-vanilla-100); diff --git a/frontend/src/components/QueryBuilderV2/QueryV2/MetricsSelect/MetricsSelect.tsx b/frontend/src/components/QueryBuilderV2/QueryV2/MetricsSelect/MetricsSelect.tsx index b04e1d303ff..4a83fab1f85 100644 --- a/frontend/src/components/QueryBuilderV2/QueryV2/MetricsSelect/MetricsSelect.tsx +++ b/frontend/src/components/QueryBuilderV2/QueryV2/MetricsSelect/MetricsSelect.tsx @@ -1,34 +1,121 @@ import './MetricsSelect.styles.scss'; +import { Select } from 'antd'; +import { + initialQueriesMap, + initialQueryMeterWithType, + PANEL_TYPES, +} from 'constants/queryBuilder'; import { AggregatorFilter } from 'container/QueryBuilder/filters'; +import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations'; -import { memo } from 'react'; +import { memo, useCallback, useMemo, useState } from 'react'; +import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData'; +import { DataSource } from 'types/common/queryBuilder'; +import { SelectOption } from 'types/common/select'; + +export const SOURCE_OPTIONS: SelectOption[] = [ + { value: 'metrics', label: 'Metrics' }, + { value: 'meter', label: 'Meter' }, +]; export const MetricsSelect = memo(function MetricsSelect({ query, index, version, signalSource, + onSignalSourceChange, + signalSourceChangeEnabled = false, }: { query: IBuilderQuery; index: number; version: string; signalSource: 'meter' | ''; + onSignalSourceChange: (value: string) => void; + signalSourceChangeEnabled: boolean; }): JSX.Element { + const [attributeKeys, setAttributeKeys] = useState([]); + const { handleChangeAggregatorAttribute } = useQueryOperations({ index, query, entityVersion: version, }); + const handleAggregatorAttributeChange = useCallback( + (value: BaseAutocompleteData, isEditMode?: boolean) => { + handleChangeAggregatorAttribute(value, isEditMode, attributeKeys || []); + }, + [handleChangeAggregatorAttribute, attributeKeys], + ); + + const { updateAllQueriesOperators, handleSetQueryData } = useQueryBuilder(); + + const source = useMemo( + () => (signalSource === 'meter' ? 'meter' : 'metrics'), + [signalSource], + ); + + const defaultMeterQuery = useMemo( + () => + updateAllQueriesOperators( + initialQueryMeterWithType, + PANEL_TYPES.BAR, + DataSource.METRICS, + 'meter' as 'meter' | '', + ), + [updateAllQueriesOperators], + ); + + const defaultMetricsQuery = useMemo( + () => + updateAllQueriesOperators( + initialQueriesMap.metrics, + PANEL_TYPES.BAR, + DataSource.METRICS, + '', + ), + [updateAllQueriesOperators], + ); + + const handleSignalSourceChange = (value: string): void => { + onSignalSourceChange(value); + handleSetQueryData( + index, + value === 'meter' + ? { + ...defaultMeterQuery.builder.queryData[0], + source: 'meter', + queryName: query.queryName, + } + : { + ...defaultMetricsQuery.builder.queryData[0], + source: '', + queryName: query.queryName, + }, + ); + }; + return ( -
+
+ {signalSourceChangeEnabled && ( + { - setThresholdState({ - type: 'SET_SELECTED_QUERY', - payload: value, - }); - }} + onChange={handleSelectedQueryChange} style={{ width: 80 }} options={queryNames} + data-testid="alert-threshold-query-select" /> is {/* TODO: Add custom schedule back once the functionality is implemented */} diff --git a/frontend/src/container/CreateAlertV2/EvaluationSettings/EvaluationSettings.tsx b/frontend/src/container/CreateAlertV2/EvaluationSettings/EvaluationSettings.tsx index b0e0da59ff2..3190c2b8bc6 100644 --- a/frontend/src/container/CreateAlertV2/EvaluationSettings/EvaluationSettings.tsx +++ b/frontend/src/container/CreateAlertV2/EvaluationSettings/EvaluationSettings.tsx @@ -10,6 +10,7 @@ import { getEvaluationWindowTypeText, getTimeframeText } from './utils'; function EvaluationSettings(): JSX.Element { const { evaluationWindow, setEvaluationWindow } = useCreateAlertState(); + const [ isEvaluationWindowPopoverOpen, setIsEvaluationWindowPopoverOpen, @@ -30,7 +31,7 @@ function EvaluationSettings(): JSX.Element { trigger="click" showArrow={false} > -
@@ -154,6 +155,7 @@ function EvaluationWindowDetails({ value={evaluationWindow.startingAt.timezone || null} onChange={handleTimezoneChange} placeholder="Select timezone" + data-testid="evaluation-window-details-timezone-select" />
@@ -174,6 +176,7 @@ function EvaluationWindowDetails({ value={evaluationWindow.startingAt.number || null} onChange={handleNumberChange} placeholder="Select starting at" + data-testid="evaluation-window-details-starting-at-select" />
@@ -190,6 +193,7 @@ function EvaluationWindowDetails({ value={evaluationWindow.startingAt.timezone || null} onChange={handleTimezoneChange} placeholder="Select timezone" + data-testid="evaluation-window-details-timezone-select" />
@@ -211,6 +215,7 @@ function EvaluationWindowDetails({ value={evaluationWindow.startingAt.number} onChange={(e): void => handleNumberChange(e.target.value)} placeholder="Enter value" + data-testid="evaluation-window-details-custom-rolling-window-duration-input" />
@@ -220,6 +225,7 @@ function EvaluationWindowDetails({ value={evaluationWindow.startingAt.unit || null} onChange={handleUnitChange} placeholder="Select unit" + data-testid="evaluation-window-details-custom-rolling-window-unit-select" />
diff --git a/frontend/src/container/CreateAlertV2/EvaluationSettings/TimeInput/TimeInput.tsx b/frontend/src/container/CreateAlertV2/EvaluationSettings/TimeInput/TimeInput.tsx index a8609642df6..23b9b0f5158 100644 --- a/frontend/src/container/CreateAlertV2/EvaluationSettings/TimeInput/TimeInput.tsx +++ b/frontend/src/container/CreateAlertV2/EvaluationSettings/TimeInput/TimeInput.tsx @@ -145,7 +145,7 @@ function TimeInput({ }; return ( -
+
: :
); diff --git a/frontend/src/container/CreateAlertV2/EvaluationSettings/types.ts b/frontend/src/container/CreateAlertV2/EvaluationSettings/types.ts index 1372163f6cb..a252143e28c 100644 --- a/frontend/src/container/CreateAlertV2/EvaluationSettings/types.ts +++ b/frontend/src/container/CreateAlertV2/EvaluationSettings/types.ts @@ -12,6 +12,7 @@ export interface IAdvancedOptionItemProps { tooltipText?: string; onToggle?: () => void; defaultShowInput: boolean; + 'data-testid'?: string; } export enum RollingWindowTimeframes { diff --git a/frontend/src/container/CreateAlertV2/NotificationSettings/MultipleNotifications.tsx b/frontend/src/container/CreateAlertV2/NotificationSettings/MultipleNotifications.tsx index 21274cc6a98..b2016e2d7a8 100644 --- a/frontend/src/container/CreateAlertV2/NotificationSettings/MultipleNotifications.tsx +++ b/frontend/src/container/CreateAlertV2/NotificationSettings/MultipleNotifications.tsx @@ -24,6 +24,7 @@ function MultipleNotifications(): JSX.Element { return uniqueGroupBys.map((key) => ({ label: key, value: key, + 'data-testid': 'multiple-notifications-select-option', })); }, [currentQuery.builder.queryData]); @@ -49,6 +50,7 @@ function MultipleNotifications(): JSX.Element { disabled={!isMultipleNotificationsEnabled} aria-disabled={!isMultipleNotificationsEnabled} maxTagCount={3} + data-testid="multiple-notifications-select" /> {isMultipleNotificationsEnabled && ( diff --git a/frontend/src/container/CreateAlertV2/NotificationSettings/NotificationSettings.tsx b/frontend/src/container/CreateAlertV2/NotificationSettings/NotificationSettings.tsx index 640d2b17bbe..e35920adc8d 100644 --- a/frontend/src/container/CreateAlertV2/NotificationSettings/NotificationSettings.tsx +++ b/frontend/src/container/CreateAlertV2/NotificationSettings/NotificationSettings.tsx @@ -37,6 +37,7 @@ function NotificationSettings(): JSX.Element { }, }); }} + data-testid="repeat-notifications-time-input" />
); @@ -98,6 +101,7 @@ function NotificationSettings(): JSX.Element { }); }} defaultShowInput={notificationSettings.reNotification.enabled} + data-testid="repeat-notifications-container" /> diff --git a/frontend/src/container/CreateAlertV2/QuerySection/QuerySection.tsx b/frontend/src/container/CreateAlertV2/QuerySection/QuerySection.tsx index de0c154460c..e62f24641b0 100644 --- a/frontend/src/container/CreateAlertV2/QuerySection/QuerySection.tsx +++ b/frontend/src/container/CreateAlertV2/QuerySection/QuerySection.tsx @@ -25,8 +25,8 @@ function QuerySection(): JSX.Element { const alertDef = buildAlertDefForChartPreview({ alertType, thresholdState }); - const onQueryCategoryChange = (val: EQueryType): void => { - const query: Query = { ...currentQuery, queryType: val }; + const onQueryCategoryChange = (queryType: EQueryType): void => { + const query: Query = { ...currentQuery, queryType }; redirectWithQueryBuilderData(query); }; diff --git a/frontend/src/container/CreateAlertV2/QuerySection/__tests__/QuerySection.test.tsx b/frontend/src/container/CreateAlertV2/QuerySection/__tests__/QuerySection.test.tsx index 8f7a00bbec8..75a688756ac 100644 --- a/frontend/src/container/CreateAlertV2/QuerySection/__tests__/QuerySection.test.tsx +++ b/frontend/src/container/CreateAlertV2/QuerySection/__tests__/QuerySection.test.tsx @@ -2,16 +2,28 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { QueryParams } from 'constants/query'; +import { + initialClickHouseData, + initialQueryPromQLData, +} from 'constants/queryBuilder'; import { AlertDetectionTypes } from 'container/FormAlertRules'; import { QueryClient, QueryClientProvider } from 'react-query'; import { Provider } from 'react-redux'; import { MemoryRouter } from 'react-router-dom'; import store from 'store'; import { AlertTypes } from 'types/api/alerts/alertTypes'; +import { EQueryType } from 'types/common/dashboard'; +import { DataSource } from 'types/common/queryBuilder'; import { CreateAlertProvider } from '../../context'; import QuerySection from '../QuerySection'; +jest.mock('uuid', () => ({ + v4: (): string => 'test-uuid-12345', +})); + +const MOCK_UUID = 'test-uuid-12345'; + jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({ useQueryBuilder: jest.fn(), })); @@ -48,12 +60,27 @@ jest.mock( queryCategory, alertType, panelType, + setQueryCategory, }: any): JSX.Element { return (
{queryCategory}
{alertType}
{panelType}
+ +
); }, @@ -240,17 +267,6 @@ describe('QuerySection', () => { expect(screen.getByTestId('panel-type')).toHaveTextContent('graph'); }); - it('has correct CSS classes for tab styling', () => { - renderQuerySection(); - - const tabs = screen.getAllByRole('button'); - - tabs.forEach((tab) => { - expect(tab).toHaveClass('list-view-tab'); - expect(tab).toHaveClass('explorer-view-option'); - }); - }); - it('renders with correct container structure', () => { renderQuerySection(); @@ -307,4 +323,172 @@ describe('QuerySection', () => { expect(metricsButton).toHaveClass(ACTIVE_TAB_CLASS); expect(logsButton).not.toHaveClass(ACTIVE_TAB_CLASS); }); + + it('updates the query data when the alert type changes', async () => { + const user = userEvent.setup(); + renderQuerySection(); + + const metricsTab = screen.getByText(METRICS_TEXT); + await user.click(metricsTab); + + const result = mockUseQueryBuilder.redirectWithQueryBuilderData.mock.calls[0]; + + expect(result[0]).toEqual({ + id: MOCK_UUID, + queryType: EQueryType.QUERY_BUILDER, + unit: undefined, + builder: { + queryData: [ + expect.objectContaining({ + dataSource: DataSource.METRICS, + queryName: 'A', + }), + ], + queryFormulas: [], + queryTraceOperator: [], + }, + promql: [initialQueryPromQLData], + clickhouse_sql: [initialClickHouseData], + }); + + expect(result[1]).toEqual({ + [QueryParams.alertType]: AlertTypes.METRICS_BASED_ALERT, + [QueryParams.ruleType]: AlertDetectionTypes.THRESHOLD_ALERT, + }); + }); + + it('updates the query data when the query type changes from query_builder to promql', async () => { + const user = userEvent.setup(); + renderQuerySection(); + + const changeToPromQLButton = screen.getByTestId('change-to-promql'); + await user.click(changeToPromQLButton); + + expect( + mockUseQueryBuilder.redirectWithQueryBuilderData, + ).toHaveBeenCalledTimes(1); + + const [ + queryArg, + ] = mockUseQueryBuilder.redirectWithQueryBuilderData.mock.calls[0]; + + expect(queryArg).toEqual({ + ...mockUseQueryBuilder.currentQuery, + queryType: EQueryType.PROM, + }); + + expect(mockUseQueryBuilder.redirectWithQueryBuilderData).toHaveBeenCalledWith( + queryArg, + ); + }); + + it('updates the query data when switching from promql to query_builder for logs', async () => { + const user = userEvent.setup(); + + const mockCurrentQueryWithPromQL = { + ...mockUseQueryBuilder.currentQuery, + queryType: EQueryType.PROM, + builder: { + queryData: [ + { + dataSource: DataSource.LOGS, + }, + ], + }, + }; + + useQueryBuilder.mockReturnValue({ + ...mockUseQueryBuilder, + currentQuery: mockCurrentQueryWithPromQL, + }); + + render( + + + + + + + + + , + ); + + const changeToQueryBuilderButton = screen.getByTestId( + 'change-to-query-builder', + ); + await user.click(changeToQueryBuilderButton); + + expect( + mockUseQueryBuilder.redirectWithQueryBuilderData, + ).toHaveBeenCalledTimes(1); + + const [ + queryArg, + ] = mockUseQueryBuilder.redirectWithQueryBuilderData.mock.calls[0]; + + expect(queryArg).toEqual({ + ...mockCurrentQueryWithPromQL, + queryType: EQueryType.QUERY_BUILDER, + }); + + expect(mockUseQueryBuilder.redirectWithQueryBuilderData).toHaveBeenCalledWith( + queryArg, + ); + }); + + it('updates the query data when switching from clickhouse_sql to query_builder for traces', async () => { + const user = userEvent.setup(); + + const mockCurrentQueryWithClickhouseSQL = { + ...mockUseQueryBuilder.currentQuery, + queryType: EQueryType.CLICKHOUSE, + builder: { + queryData: [ + { + dataSource: DataSource.TRACES, + }, + ], + }, + }; + + useQueryBuilder.mockReturnValue({ + ...mockUseQueryBuilder, + currentQuery: mockCurrentQueryWithClickhouseSQL, + }); + + render( + + + + + + + + + , + ); + + const changeToQueryBuilderButton = screen.getByTestId( + 'change-to-query-builder', + ); + await user.click(changeToQueryBuilderButton); + + expect( + mockUseQueryBuilder.redirectWithQueryBuilderData, + ).toHaveBeenCalledTimes(1); + + const [ + queryArg, + ] = mockUseQueryBuilder.redirectWithQueryBuilderData.mock.calls[0]; + + expect(queryArg).toEqual({ + ...mockCurrentQueryWithClickhouseSQL, + queryType: EQueryType.QUERY_BUILDER, + }); + + expect(mockUseQueryBuilder.redirectWithQueryBuilderData).toHaveBeenCalledWith( + queryArg, + ); + }); }); diff --git a/frontend/src/container/CreateAlertV2/context/__tests__/utils.test.tsx b/frontend/src/container/CreateAlertV2/context/__tests__/utils.test.tsx new file mode 100644 index 00000000000..9b64f84b0af --- /dev/null +++ b/frontend/src/container/CreateAlertV2/context/__tests__/utils.test.tsx @@ -0,0 +1,678 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types'; +import { initialQueriesMap } from 'constants/queryBuilder'; +import { + alertDefaults, + anamolyAlertDefaults, + exceptionAlertDefaults, + logAlertDefaults, + traceAlertDefaults, +} from 'container/CreateAlertRule/defaults'; +import dayjs from 'dayjs'; +import { AlertTypes } from 'types/api/alerts/alertTypes'; + +import { + INITIAL_ADVANCED_OPTIONS_STATE, + INITIAL_ALERT_STATE, + INITIAL_ALERT_THRESHOLD_STATE, + INITIAL_EVALUATION_WINDOW_STATE, + INITIAL_NOTIFICATION_SETTINGS_STATE, +} from '../constants'; +import { + AdvancedOptionsState, + AlertState, + AlertThresholdMatchType, + AlertThresholdOperator, + AlertThresholdState, + Algorithm, + EvaluationWindowState, + NotificationSettingsState, + Seasonality, + TimeDuration, +} from '../types'; +import { + advancedOptionsReducer, + alertCreationReducer, + alertThresholdReducer, + buildInitialAlertDef, + evaluationWindowReducer, + getInitialAlertType, + getInitialAlertTypeFromURL, + notificationSettingsReducer, +} from '../utils'; + +const UNKNOWN_ACTION_TYPE = 'UNKNOWN_ACTION_TYPE'; +const TEST_RESET_TO_INITIAL_STATE = 'should reset to initial state'; +const TEST_SET_INITIAL_STATE_FROM_PAYLOAD = + 'should set initial state from payload'; +const TEST_RETURN_STATE_FOR_UNKNOWN_ACTION = + 'should return current state for unknown action'; + +describe('CreateAlertV2 Context Utils', () => { + describe('alertCreationReducer', () => { + it('should set alert name', () => { + const result = alertCreationReducer(INITIAL_ALERT_STATE, { + type: 'SET_ALERT_NAME', + payload: 'Test Alert', + }); + expect(result).toEqual({ + ...INITIAL_ALERT_STATE, + name: 'Test Alert', + }); + }); + + it('should set alert labels', () => { + const labels = { severity: 'critical', team: 'backend' }; + const result = alertCreationReducer(INITIAL_ALERT_STATE, { + type: 'SET_ALERT_LABELS', + payload: labels, + }); + expect(result).toEqual({ + ...INITIAL_ALERT_STATE, + labels, + }); + }); + + it('should set y-axis unit', () => { + const result = alertCreationReducer(INITIAL_ALERT_STATE, { + type: 'SET_Y_AXIS_UNIT', + payload: 'ms', + }); + expect(result).toEqual({ + ...INITIAL_ALERT_STATE, + yAxisUnit: 'ms', + }); + }); + + it(TEST_RESET_TO_INITIAL_STATE, () => { + const modifiedState: AlertState = { + name: 'Modified', + labels: { test: 'value' }, + yAxisUnit: 'ms', + }; + const result = alertCreationReducer(modifiedState, { type: 'RESET' }); + expect(result).toEqual(INITIAL_ALERT_STATE); + }); + + it(TEST_SET_INITIAL_STATE_FROM_PAYLOAD, () => { + const newState: AlertState = { + name: 'Custom Alert', + labels: { env: 'production' }, + yAxisUnit: 'bytes', + }; + const result = alertCreationReducer(INITIAL_ALERT_STATE, { + type: 'SET_INITIAL_STATE', + payload: newState, + }); + expect(result).toEqual(newState); + }); + + it(TEST_RETURN_STATE_FOR_UNKNOWN_ACTION, () => { + const result = alertCreationReducer( + INITIAL_ALERT_STATE, + + { type: UNKNOWN_ACTION_TYPE } as any, + ); + expect(result).toEqual(INITIAL_ALERT_STATE); + }); + }); + + describe('getInitialAlertType', () => { + it('should return METRICS_BASED_ALERT for metrics data source', () => { + const result = getInitialAlertType(initialQueriesMap.metrics); + expect(result).toBe(AlertTypes.METRICS_BASED_ALERT); + }); + + it('should return LOGS_BASED_ALERT for logs data source', () => { + const result = getInitialAlertType(initialQueriesMap.logs); + expect(result).toBe(AlertTypes.LOGS_BASED_ALERT); + }); + + it('should return TRACES_BASED_ALERT for traces data source', () => { + const result = getInitialAlertType(initialQueriesMap.traces); + expect(result).toBe(AlertTypes.TRACES_BASED_ALERT); + }); + + it('should return METRICS_BASED_ALERT for unknown data source', () => { + const queryWithUnknownDataSource = { + ...initialQueriesMap.metrics, + builder: { + ...initialQueriesMap.metrics.builder, + queryData: [], + }, + }; + const result = getInitialAlertType(queryWithUnknownDataSource); + expect(result).toBe(AlertTypes.METRICS_BASED_ALERT); + }); + }); + + describe('buildInitialAlertDef', () => { + it('should return logAlertDefaults for LOGS_BASED_ALERT', () => { + const result = buildInitialAlertDef(AlertTypes.LOGS_BASED_ALERT); + expect(result).toBe(logAlertDefaults); + }); + + it('should return traceAlertDefaults for TRACES_BASED_ALERT', () => { + const result = buildInitialAlertDef(AlertTypes.TRACES_BASED_ALERT); + expect(result).toBe(traceAlertDefaults); + }); + + it('should return exceptionAlertDefaults for EXCEPTIONS_BASED_ALERT', () => { + const result = buildInitialAlertDef(AlertTypes.EXCEPTIONS_BASED_ALERT); + expect(result).toBe(exceptionAlertDefaults); + }); + + it('should return anamolyAlertDefaults for ANOMALY_BASED_ALERT', () => { + const result = buildInitialAlertDef(AlertTypes.ANOMALY_BASED_ALERT); + expect(result).toBe(anamolyAlertDefaults); + }); + + it('should return alertDefaults for METRICS_BASED_ALERT', () => { + const result = buildInitialAlertDef(AlertTypes.METRICS_BASED_ALERT); + expect(result).toBe(alertDefaults); + }); + + it('should return alertDefaults for unknown alert type', () => { + const result = buildInitialAlertDef('UNKNOWN' as AlertTypes); + expect(result).toBe(alertDefaults); + }); + }); + + describe('getInitialAlertTypeFromURL', () => { + it('should return ANOMALY_BASED_ALERT when ruleType is anomaly_rule', () => { + const params = new URLSearchParams('?ruleType=anomaly_rule'); + const result = getInitialAlertTypeFromURL(params, initialQueriesMap.metrics); + expect(result).toBe(AlertTypes.ANOMALY_BASED_ALERT); + }); + + it('should return alert type from alertType param', () => { + const params = new URLSearchParams('?alertType=LOGS_BASED_ALERT'); + const result = getInitialAlertTypeFromURL(params, initialQueriesMap.metrics); + expect(result).toBe(AlertTypes.LOGS_BASED_ALERT); + }); + + it('should prioritize ruleType over alertType', () => { + const params = new URLSearchParams( + '?ruleType=anomaly_rule&alertType=LOGS_BASED_ALERT', + ); + const result = getInitialAlertTypeFromURL(params, initialQueriesMap.metrics); + expect(result).toBe(AlertTypes.ANOMALY_BASED_ALERT); + }); + + it('should fall back to query data source when no URL params', () => { + const params = new URLSearchParams(''); + const result = getInitialAlertTypeFromURL(params, initialQueriesMap.traces); + expect(result).toBe(AlertTypes.TRACES_BASED_ALERT); + }); + }); + + describe('alertThresholdReducer', () => { + it('should set selected query', () => { + const result = alertThresholdReducer(INITIAL_ALERT_THRESHOLD_STATE, { + type: 'SET_SELECTED_QUERY', + payload: 'B', + }); + expect(result).toEqual({ + ...INITIAL_ALERT_THRESHOLD_STATE, + selectedQuery: 'B', + }); + }); + + it('should set operator', () => { + const result = alertThresholdReducer(INITIAL_ALERT_THRESHOLD_STATE, { + type: 'SET_OPERATOR', + payload: AlertThresholdOperator.IS_BELOW, + }); + expect(result).toEqual({ + ...INITIAL_ALERT_THRESHOLD_STATE, + operator: AlertThresholdOperator.IS_BELOW, + }); + }); + + it('should set match type', () => { + const result = alertThresholdReducer(INITIAL_ALERT_THRESHOLD_STATE, { + type: 'SET_MATCH_TYPE', + payload: AlertThresholdMatchType.ALL_THE_TIME, + }); + expect(result).toEqual({ + ...INITIAL_ALERT_THRESHOLD_STATE, + matchType: AlertThresholdMatchType.ALL_THE_TIME, + }); + }); + + it('should set thresholds', () => { + const newThresholds = [ + { + id: '1', + label: 'critical', + thresholdValue: 100, + recoveryThresholdValue: 90, + unit: 'ms', + channels: ['channel1'], + color: '#FF0000', + }, + ]; + const result = alertThresholdReducer(INITIAL_ALERT_THRESHOLD_STATE, { + type: 'SET_THRESHOLDS', + payload: newThresholds, + }); + expect(result).toEqual({ + ...INITIAL_ALERT_THRESHOLD_STATE, + thresholds: newThresholds, + }); + }); + + it(TEST_RESET_TO_INITIAL_STATE, () => { + const modifiedState: AlertThresholdState = { + selectedQuery: 'B', + operator: AlertThresholdOperator.IS_BELOW, + matchType: AlertThresholdMatchType.ALL_THE_TIME, + evaluationWindow: TimeDuration.TEN_MINUTES, + algorithm: Algorithm.STANDARD, + seasonality: Seasonality.DAILY, + thresholds: [], + }; + const result = alertThresholdReducer(modifiedState, { type: 'RESET' }); + expect(result).toEqual(INITIAL_ALERT_THRESHOLD_STATE); + }); + + it(TEST_SET_INITIAL_STATE_FROM_PAYLOAD, () => { + const newState: AlertThresholdState = { + selectedQuery: 'C', + operator: AlertThresholdOperator.IS_EQUAL_TO, + matchType: AlertThresholdMatchType.ON_AVERAGE, + evaluationWindow: TimeDuration.ONE_HOUR, + algorithm: Algorithm.STANDARD, + seasonality: Seasonality.WEEKLY, + thresholds: [], + }; + const result = alertThresholdReducer(INITIAL_ALERT_THRESHOLD_STATE, { + type: 'SET_INITIAL_STATE', + payload: newState, + }); + expect(result).toEqual(newState); + }); + + it(TEST_RETURN_STATE_FOR_UNKNOWN_ACTION, () => { + const result = alertThresholdReducer( + INITIAL_ALERT_THRESHOLD_STATE, + + { type: UNKNOWN_ACTION_TYPE } as any, + ); + expect(result).toEqual(INITIAL_ALERT_THRESHOLD_STATE); + }); + }); + + describe('advancedOptionsReducer', () => { + it('should set send notification if data is missing', () => { + const result = advancedOptionsReducer(INITIAL_ADVANCED_OPTIONS_STATE, { + type: 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING', + payload: { toleranceLimit: 21, timeUnit: UniversalYAxisUnit.HOURS }, + }); + expect(result).toEqual({ + ...INITIAL_ADVANCED_OPTIONS_STATE, + sendNotificationIfDataIsMissing: { + ...INITIAL_ADVANCED_OPTIONS_STATE.sendNotificationIfDataIsMissing, + toleranceLimit: 21, + timeUnit: UniversalYAxisUnit.HOURS, + }, + }); + }); + + it('should toggle send notification if data is missing', () => { + const result = advancedOptionsReducer(INITIAL_ADVANCED_OPTIONS_STATE, { + type: 'TOGGLE_SEND_NOTIFICATION_IF_DATA_IS_MISSING', + payload: true, + }); + expect(result).toEqual({ + ...INITIAL_ADVANCED_OPTIONS_STATE, + sendNotificationIfDataIsMissing: { + ...INITIAL_ADVANCED_OPTIONS_STATE.sendNotificationIfDataIsMissing, + enabled: true, + }, + }); + }); + + it('should set enforce minimum datapoints', () => { + const result = advancedOptionsReducer(INITIAL_ADVANCED_OPTIONS_STATE, { + type: 'SET_ENFORCE_MINIMUM_DATAPOINTS', + payload: { minimumDatapoints: 10 }, + }); + expect(result).toEqual({ + ...INITIAL_ADVANCED_OPTIONS_STATE, + enforceMinimumDatapoints: { + ...INITIAL_ADVANCED_OPTIONS_STATE.enforceMinimumDatapoints, + minimumDatapoints: 10, + }, + }); + }); + + it('should toggle enforce minimum datapoints', () => { + const result = advancedOptionsReducer(INITIAL_ADVANCED_OPTIONS_STATE, { + type: 'TOGGLE_ENFORCE_MINIMUM_DATAPOINTS', + payload: true, + }); + expect(result).toEqual({ + ...INITIAL_ADVANCED_OPTIONS_STATE, + enforceMinimumDatapoints: { + ...INITIAL_ADVANCED_OPTIONS_STATE.enforceMinimumDatapoints, + enabled: true, + }, + }); + }); + + it('should set delay evaluation', () => { + const result = advancedOptionsReducer(INITIAL_ADVANCED_OPTIONS_STATE, { + type: 'SET_DELAY_EVALUATION', + payload: { delay: 10, timeUnit: UniversalYAxisUnit.HOURS }, + }); + expect(result).toEqual({ + ...INITIAL_ADVANCED_OPTIONS_STATE, + delayEvaluation: { delay: 10, timeUnit: UniversalYAxisUnit.HOURS }, + }); + }); + + it('should set evaluation cadence', () => { + const newCadence = { + default: { value: 5, timeUnit: UniversalYAxisUnit.HOURS }, + custom: { + repeatEvery: 'week', + startAt: '12:00:00', + timezone: 'America/New_York', + occurence: ['Monday', 'Friday'], + }, + rrule: { date: dayjs(), startAt: '10:00:00', rrule: 'test-rrule' }, + }; + const result = advancedOptionsReducer(INITIAL_ADVANCED_OPTIONS_STATE, { + type: 'SET_EVALUATION_CADENCE', + payload: newCadence, + }); + expect(result).toEqual({ + ...INITIAL_ADVANCED_OPTIONS_STATE, + evaluationCadence: { + ...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence, + ...newCadence, + }, + }); + }); + + it('should set evaluation cadence mode', () => { + const result = advancedOptionsReducer(INITIAL_ADVANCED_OPTIONS_STATE, { + type: 'SET_EVALUATION_CADENCE_MODE', + payload: 'custom', + }); + expect(result).toEqual({ + ...INITIAL_ADVANCED_OPTIONS_STATE, + evaluationCadence: { + ...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence, + mode: 'custom', + }, + }); + }); + + it(TEST_RESET_TO_INITIAL_STATE, () => { + const modifiedState: AdvancedOptionsState = { + ...INITIAL_ADVANCED_OPTIONS_STATE, + delayEvaluation: { delay: 10, timeUnit: UniversalYAxisUnit.HOURS }, + }; + const result = advancedOptionsReducer(modifiedState, { type: 'RESET' }); + expect(result).toEqual(INITIAL_ADVANCED_OPTIONS_STATE); + }); + + it(TEST_SET_INITIAL_STATE_FROM_PAYLOAD, () => { + const newState: AdvancedOptionsState = { + ...INITIAL_ADVANCED_OPTIONS_STATE, + sendNotificationIfDataIsMissing: { + toleranceLimit: 45, + timeUnit: UniversalYAxisUnit.SECONDS, + enabled: true, + }, + }; + const result = advancedOptionsReducer(INITIAL_ADVANCED_OPTIONS_STATE, { + type: 'SET_INITIAL_STATE', + payload: newState, + }); + expect(result).toEqual(newState); + }); + + it(TEST_RETURN_STATE_FOR_UNKNOWN_ACTION, () => { + const result = advancedOptionsReducer( + INITIAL_ADVANCED_OPTIONS_STATE, + + { type: UNKNOWN_ACTION_TYPE } as any, + ); + expect(result).toEqual(INITIAL_ADVANCED_OPTIONS_STATE); + }); + }); + + describe('evaluationWindowReducer', () => { + it('should set window type to rolling and reset timeframe', () => { + const modifiedState: EvaluationWindowState = { + ...INITIAL_EVALUATION_WINDOW_STATE, + windowType: 'cumulative', + timeframe: 'currentHour', + }; + const result = evaluationWindowReducer(modifiedState, { + type: 'SET_WINDOW_TYPE', + payload: 'rolling', + }); + expect(result).toEqual({ + windowType: 'rolling', + timeframe: INITIAL_EVALUATION_WINDOW_STATE.timeframe, + startingAt: INITIAL_EVALUATION_WINDOW_STATE.startingAt, + }); + }); + + it('should set window type to cumulative and set timeframe to currentHour', () => { + const result = evaluationWindowReducer(INITIAL_EVALUATION_WINDOW_STATE, { + type: 'SET_WINDOW_TYPE', + payload: 'cumulative', + }); + expect(result).toEqual({ + windowType: 'cumulative', + timeframe: 'currentHour', + startingAt: INITIAL_EVALUATION_WINDOW_STATE.startingAt, + }); + }); + + it('should set timeframe', () => { + const result = evaluationWindowReducer(INITIAL_EVALUATION_WINDOW_STATE, { + type: 'SET_TIMEFRAME', + payload: '10m0s', + }); + expect(result).toEqual({ + ...INITIAL_EVALUATION_WINDOW_STATE, + timeframe: '10m0s', + }); + }); + + it('should set starting at', () => { + const newStartingAt = { + time: '14:30:00', + number: '5', + timezone: 'Europe/London', + unit: UniversalYAxisUnit.HOURS, + }; + const result = evaluationWindowReducer(INITIAL_EVALUATION_WINDOW_STATE, { + type: 'SET_STARTING_AT', + payload: newStartingAt, + }); + expect(result).toEqual({ + ...INITIAL_EVALUATION_WINDOW_STATE, + startingAt: newStartingAt, + }); + }); + + it(TEST_RESET_TO_INITIAL_STATE, () => { + const modifiedState: EvaluationWindowState = { + windowType: 'cumulative', + timeframe: 'currentHour', + startingAt: { + time: '12:00:00', + number: '2', + timezone: 'America/New_York', + unit: UniversalYAxisUnit.HOURS, + }, + }; + const result = evaluationWindowReducer(modifiedState, { type: 'RESET' }); + expect(result).toEqual(INITIAL_EVALUATION_WINDOW_STATE); + }); + + it(TEST_SET_INITIAL_STATE_FROM_PAYLOAD, () => { + const newState: EvaluationWindowState = { + windowType: 'cumulative', + timeframe: 'currentDay', + startingAt: { + time: '09:00:00', + number: '3', + timezone: 'Asia/Tokyo', + unit: UniversalYAxisUnit.HOURS, + }, + }; + const result = evaluationWindowReducer(INITIAL_EVALUATION_WINDOW_STATE, { + type: 'SET_INITIAL_STATE', + payload: newState, + }); + expect(result).toEqual(newState); + }); + + it(TEST_RETURN_STATE_FOR_UNKNOWN_ACTION, () => { + const result = evaluationWindowReducer( + INITIAL_EVALUATION_WINDOW_STATE, + + { type: UNKNOWN_ACTION_TYPE } as any, + ); + expect(result).toEqual(INITIAL_EVALUATION_WINDOW_STATE); + }); + }); + + describe('notificationSettingsReducer', () => { + it('should set multiple notifications', () => { + const notifications = ['channel1', 'channel2', 'channel3']; + const result = notificationSettingsReducer( + INITIAL_NOTIFICATION_SETTINGS_STATE, + { + type: 'SET_MULTIPLE_NOTIFICATIONS', + payload: notifications, + }, + ); + expect(result).toEqual({ + ...INITIAL_NOTIFICATION_SETTINGS_STATE, + multipleNotifications: notifications, + }); + }); + + it('should set multiple notifications to null', () => { + const modifiedState = { + ...INITIAL_NOTIFICATION_SETTINGS_STATE, + multipleNotifications: ['channel1', 'channel2'], + }; + const result = notificationSettingsReducer(modifiedState, { + type: 'SET_MULTIPLE_NOTIFICATIONS', + payload: null, + }); + expect(result).toEqual({ + ...modifiedState, + multipleNotifications: null, + }); + }); + + it('should set re-notification', () => { + const reNotification = { + enabled: true, + value: 60, + unit: UniversalYAxisUnit.HOURS, + conditions: ['firing' as const, 'nodata' as const], + }; + const result = notificationSettingsReducer( + INITIAL_NOTIFICATION_SETTINGS_STATE, + { + type: 'SET_RE_NOTIFICATION', + payload: reNotification, + }, + ); + expect(result).toEqual({ + ...INITIAL_NOTIFICATION_SETTINGS_STATE, + reNotification, + }); + }); + + it('should set description', () => { + const description = 'Custom alert description with {{$value}}'; + const result = notificationSettingsReducer( + INITIAL_NOTIFICATION_SETTINGS_STATE, + { + type: 'SET_DESCRIPTION', + payload: description, + }, + ); + expect(result).toEqual({ + ...INITIAL_NOTIFICATION_SETTINGS_STATE, + description, + }); + }); + + it('should set routing policies', () => { + const result = notificationSettingsReducer( + INITIAL_NOTIFICATION_SETTINGS_STATE, + { + type: 'SET_ROUTING_POLICIES', + payload: true, + }, + ); + expect(result).toEqual({ + ...INITIAL_NOTIFICATION_SETTINGS_STATE, + routingPolicies: true, + }); + }); + + it(TEST_RESET_TO_INITIAL_STATE, () => { + const modifiedState: NotificationSettingsState = { + multipleNotifications: ['channel1'], + reNotification: { + enabled: true, + value: 120, + unit: UniversalYAxisUnit.HOURS, + conditions: ['firing'], + }, + description: 'Modified description', + routingPolicies: true, + }; + const result = notificationSettingsReducer(modifiedState, { + type: 'RESET', + }); + expect(result).toEqual(INITIAL_NOTIFICATION_SETTINGS_STATE); + }); + + it(TEST_SET_INITIAL_STATE_FROM_PAYLOAD, () => { + const newState: NotificationSettingsState = { + multipleNotifications: ['channel4', 'channel5'], + reNotification: { + enabled: true, + value: 90, + unit: UniversalYAxisUnit.MINUTES, + conditions: ['nodata'], + }, + description: 'New description', + routingPolicies: true, + }; + const result = notificationSettingsReducer( + INITIAL_NOTIFICATION_SETTINGS_STATE, + { + type: 'SET_INITIAL_STATE', + payload: newState, + }, + ); + expect(result).toEqual(newState); + }); + + it(TEST_RETURN_STATE_FOR_UNKNOWN_ACTION, () => { + const result = notificationSettingsReducer( + INITIAL_NOTIFICATION_SETTINGS_STATE, + + { type: UNKNOWN_ACTION_TYPE } as any, + ); + expect(result).toEqual(INITIAL_NOTIFICATION_SETTINGS_STATE); + }); + }); +}); diff --git a/frontend/src/container/CreateAlertV2/context/index.tsx b/frontend/src/container/CreateAlertV2/context/index.tsx index 89514dcba11..0da09dd04f9 100644 --- a/frontend/src/container/CreateAlertV2/context/index.tsx +++ b/frontend/src/container/CreateAlertV2/context/index.tsx @@ -24,7 +24,11 @@ import { INITIAL_EVALUATION_WINDOW_STATE, INITIAL_NOTIFICATION_SETTINGS_STATE, } from './constants'; -import { ICreateAlertContextProps, ICreateAlertProviderProps } from './types'; +import { + AlertThresholdMatchType, + ICreateAlertContextProps, + ICreateAlertProviderProps, +} from './types'; import { advancedOptionsReducer, alertCreationReducer, @@ -51,7 +55,13 @@ export const useCreateAlertState = (): ICreateAlertContextProps => { export function CreateAlertProvider( props: ICreateAlertProviderProps, ): JSX.Element { - const { children, initialAlertState, isEditMode, ruleId } = props; + const { + children, + initialAlertState, + isEditMode, + ruleId, + initialAlertType, + } = props; const [alertState, setAlertState] = useReducer( alertCreationReducer, @@ -61,10 +71,14 @@ export function CreateAlertProvider( const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder(); const location = useLocation(); const queryParams = new URLSearchParams(location.search); + const thresholdsFromURL = queryParams.get(QueryParams.thresholds); - const [alertType, setAlertType] = useState(() => - getInitialAlertTypeFromURL(queryParams, currentQuery), - ); + const [alertType, setAlertType] = useState(() => { + if (isEditMode) { + return initialAlertType; + } + return getInitialAlertTypeFromURL(queryParams, currentQuery); + }); const handleAlertTypeChange = useCallback( (value: AlertTypes): void => { @@ -113,7 +127,28 @@ export function CreateAlertProvider( setThresholdState({ type: 'RESET', }); - }, [alertType]); + + if (thresholdsFromURL) { + try { + const thresholds = JSON.parse(thresholdsFromURL); + setThresholdState({ + type: 'SET_THRESHOLDS', + payload: thresholds, + }); + } catch (error) { + console.error('Error parsing thresholds from URL:', error); + } + + setEvaluationWindow({ + type: 'SET_INITIAL_STATE_FOR_METER', + }); + + setThresholdState({ + type: 'SET_MATCH_TYPE', + payload: AlertThresholdMatchType.IN_TOTAL, + }); + } + }, [alertType, thresholdsFromURL]); useEffect(() => { if (isEditMode && initialAlertState) { diff --git a/frontend/src/container/CreateAlertV2/context/types.ts b/frontend/src/container/CreateAlertV2/context/types.ts index 8a3da59580a..3ca416c96ff 100644 --- a/frontend/src/container/CreateAlertV2/context/types.ts +++ b/frontend/src/container/CreateAlertV2/context/types.ts @@ -237,6 +237,7 @@ export type EvaluationWindowAction = } | { type: 'SET_EVALUATION_CADENCE_MODE'; payload: EvaluationCadenceMode } | { type: 'SET_INITIAL_STATE'; payload: EvaluationWindowState } + | { type: 'SET_INITIAL_STATE_FOR_METER' } | { type: 'RESET' }; export type EvaluationCadenceMode = 'default' | 'custom' | 'rrule'; diff --git a/frontend/src/container/CreateAlertV2/context/utils.tsx b/frontend/src/container/CreateAlertV2/context/utils.tsx index 54d03e00d2c..f4d93474547 100644 --- a/frontend/src/container/CreateAlertV2/context/utils.tsx +++ b/frontend/src/container/CreateAlertV2/context/utils.tsx @@ -1,3 +1,5 @@ +import { UTC_TIMEZONE } from 'components/CustomTimePicker/timezoneUtils'; +import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types'; import { QueryParams } from 'constants/query'; import { alertDefaults, @@ -11,6 +13,7 @@ import { AlertDef } from 'types/api/alerts/def'; import { Query } from 'types/api/queryBuilder/queryBuilderData'; import { DataSource } from 'types/common/queryBuilder'; +import { CumulativeWindowTimeframes } from '../EvaluationSettings/types'; import { INITIAL_ADVANCED_OPTIONS_STATE, INITIAL_ALERT_STATE, @@ -62,7 +65,7 @@ export const alertCreationReducer = ( export function getInitialAlertType(currentQuery: Query): AlertTypes { const dataSource = - currentQuery.builder.queryData[0].dataSource || DataSource.METRICS; + currentQuery.builder.queryData?.[0]?.dataSource || DataSource.METRICS; switch (dataSource) { case DataSource.METRICS: return AlertTypes.METRICS_BASED_ALERT; @@ -210,6 +213,18 @@ export const evaluationWindowReducer = ( return INITIAL_EVALUATION_WINDOW_STATE; case 'SET_INITIAL_STATE': return action.payload; + case 'SET_INITIAL_STATE_FOR_METER': + return { + ...state, + windowType: 'cumulative', + timeframe: CumulativeWindowTimeframes.CURRENT_DAY, + startingAt: { + time: '00:00:00', + number: '0', + timezone: UTC_TIMEZONE.value, + unit: UniversalYAxisUnit.MINUTES, + }, + }; default: return state; } diff --git a/frontend/src/container/EditAlertV2/utils.tsx b/frontend/src/container/EditAlertV2/utils.tsx new file mode 100644 index 00000000000..74763267448 --- /dev/null +++ b/frontend/src/container/EditAlertV2/utils.tsx @@ -0,0 +1,16 @@ +import { ALERTS_DATA_SOURCE_MAP } from 'constants/alerts'; +import { initialQueryBuilderFormValuesMap } from 'constants/queryBuilder'; +import { AlertTypes } from 'types/api/alerts/alertTypes'; +import { Query } from 'types/api/queryBuilder/queryBuilderData'; + +export function sanitizeDefaultAlertQuery( + query: Query, + alertType: AlertTypes, +): Query { + // If there are no queries, add a default one based on the alert type + if (query.builder.queryData.length === 0) { + const dataSource = ALERTS_DATA_SOURCE_MAP[alertType]; + query.builder.queryData.push(initialQueryBuilderFormValuesMap[dataSource]); + } + return query; +} diff --git a/frontend/src/container/EmptyLogsSearch/EmptyLogsSearch.styles.scss b/frontend/src/container/EmptyLogsSearch/EmptyLogsSearch.styles.scss index 8d1b54a8974..bf8e07f908f 100644 --- a/frontend/src/container/EmptyLogsSearch/EmptyLogsSearch.styles.scss +++ b/frontend/src/container/EmptyLogsSearch/EmptyLogsSearch.styles.scss @@ -171,3 +171,30 @@ } } } +.lightMode { + .empty-logs-search { + &__resources-card { + background: var(--bg-vanilla-100); + border: 1px solid var(--bg-vanilla-300); + } + + &__resources-title { + color: var(--bg-ink-400); + } + + &__resources-description, + &__description-list, + &__subtitle { + color: var(--bg-ink-300); + } + + &__title { + color: var(--bg-ink-500); + } + + &__clear-filters-btn { + border: 1px dashed var(--bg-vanilla-300); + color: var(--bg-ink-400); + } + } +} diff --git a/frontend/src/container/ExplorerOptions/ExplorerOptionWrapper.tsx b/frontend/src/container/ExplorerOptions/ExplorerOptionWrapper.tsx index a2ecfce579d..9115e1ca669 100644 --- a/frontend/src/container/ExplorerOptions/ExplorerOptionWrapper.tsx +++ b/frontend/src/container/ExplorerOptions/ExplorerOptionWrapper.tsx @@ -17,6 +17,7 @@ function ExplorerOptionWrapper({ isOneChartPerQuery, splitedQueries, signalSource, + handleChangeSelectedView, }: ExplorerOptionsWrapperProps): JSX.Element { const [isExplorerOptionHidden, setIsExplorerOptionHidden] = useState(false); @@ -38,6 +39,7 @@ function ExplorerOptionWrapper({ setIsExplorerOptionHidden={setIsExplorerOptionHidden} isOneChartPerQuery={isOneChartPerQuery} splitedQueries={splitedQueries} + handleChangeSelectedView={handleChangeSelectedView} /> ); } diff --git a/frontend/src/container/ExplorerOptions/ExplorerOptions.tsx b/frontend/src/container/ExplorerOptions/ExplorerOptions.tsx index d425b29d4d0..9b59f5fad14 100644 --- a/frontend/src/container/ExplorerOptions/ExplorerOptions.tsx +++ b/frontend/src/container/ExplorerOptions/ExplorerOptions.tsx @@ -72,10 +72,11 @@ import { Query } from 'types/api/queryBuilder/queryBuilderData'; import { ViewProps } from 'types/api/saveViews/types'; import { DataSource, StringOperators } from 'types/common/queryBuilder'; import { USER_ROLES } from 'types/roles'; +import { panelTypeToExplorerView } from 'utils/explorerUtils'; import { PreservedViewsTypes } from './constants'; import ExplorerOptionsHideArea from './ExplorerOptionsHideArea'; -import { PreservedViewsInLocalStorage } from './types'; +import { ChangeViewFunctionType, PreservedViewsInLocalStorage } from './types'; import { DATASOURCE_VS_ROUTES, generateRGBAFromHex, @@ -98,6 +99,7 @@ function ExplorerOptions({ setIsExplorerOptionHidden, isOneChartPerQuery = false, splitedQueries = [], + handleChangeSelectedView, }: ExplorerOptionsProps): JSX.Element { const [isExport, setIsExport] = useState(false); const [isSaveModalOpen, setIsSaveModalOpen] = useState(false); @@ -412,13 +414,22 @@ function ExplorerOptions({ if (!currentViewDetails) return; const { query, name, id, panelType: currentPanelType } = currentViewDetails; - handleExplorerTabChange(currentPanelType, { - query, - name, - id, - }); + if (handleChangeSelectedView) { + handleChangeSelectedView(panelTypeToExplorerView[currentPanelType], { + query, + name, + id, + }); + } else { + // to remove this after traces cleanup + handleExplorerTabChange(currentPanelType, { + query, + name, + id, + }); + } }, - [viewsData, handleExplorerTabChange], + [viewsData, handleExplorerTabChange, handleChangeSelectedView], ); const updatePreservedViewInLocalStorage = (option: { @@ -524,6 +535,10 @@ function ExplorerOptions({ return; } + if (handleChangeSelectedView) { + handleChangeSelectedView(panelTypeToExplorerView[PANEL_TYPES.LIST]); + } + history.replace(DATASOURCE_VS_ROUTES[sourcepage]); }; @@ -1020,6 +1035,7 @@ export interface ExplorerOptionsProps { setIsExplorerOptionHidden?: Dispatch>; isOneChartPerQuery?: boolean; splitedQueries?: Query[]; + handleChangeSelectedView?: ChangeViewFunctionType; } ExplorerOptions.defaultProps = { @@ -1029,6 +1045,7 @@ ExplorerOptions.defaultProps = { isOneChartPerQuery: false, splitedQueries: [], signalSource: '', + handleChangeSelectedView: undefined, }; export default ExplorerOptions; diff --git a/frontend/src/container/ExplorerOptions/__tests__/ExplorerOptionWrapper.test.tsx b/frontend/src/container/ExplorerOptions/__tests__/ExplorerOptionWrapper.test.tsx new file mode 100644 index 00000000000..83fceb25d05 --- /dev/null +++ b/frontend/src/container/ExplorerOptions/__tests__/ExplorerOptionWrapper.test.tsx @@ -0,0 +1,363 @@ +import { PANEL_TYPES } from 'constants/queryBuilder'; +import { MOCK_QUERY } from 'container/QueryTable/Drilldown/__tests__/mockTableData'; +import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard'; +import { rest, server } from 'mocks-server/server'; +import { render, screen, userEvent, waitFor } from 'tests/test-utils'; +import { Dashboard } from 'types/api/dashboard/getAll'; +import { Query } from 'types/api/queryBuilder/queryBuilderData'; +import { DataSource } from 'types/common/queryBuilder'; +import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink'; +import { v4 } from 'uuid'; + +import ExplorerOptionWrapper from '../ExplorerOptionWrapper'; +import { getExplorerToolBarVisibility } from '../utils'; + +// Mock dependencies +jest.mock('hooks/dashboard/useUpdateDashboard'); + +jest.mock('../utils', () => ({ + getExplorerToolBarVisibility: jest.fn(), + generateRGBAFromHex: jest.fn(() => 'rgba(0, 0, 0, 0.08)'), + getRandomColor: jest.fn(() => '#000000'), + saveNewViewHandler: jest.fn(), + setExplorerToolBarVisibility: jest.fn(), + DATASOURCE_VS_ROUTES: {}, +})); + +const mockGetExplorerToolBarVisibility = jest.mocked( + getExplorerToolBarVisibility, +); + +const mockUseUpdateDashboard = jest.mocked(useUpdateDashboard); + +// Mock data +const TEST_QUERY_ID = 'test-query-id'; +const TEST_DASHBOARD_ID = 'test-dashboard-id'; +const TEST_DASHBOARD_TITLE = 'Test Dashboard'; +const TEST_DASHBOARD_DESCRIPTION = 'Test Description'; +const TEST_TIMESTAMP = '2023-01-01T00:00:00Z'; +const TEST_DASHBOARD_TITLE_2 = 'Test Dashboard for Export'; +const NEW_DASHBOARD_ID = 'new-dashboard-id'; +const DASHBOARDS_API_ENDPOINT = '*/api/v1/dashboards'; + +// Use the existing mock query from the codebase +const mockQuery: Query = { + ...MOCK_QUERY, + id: TEST_QUERY_ID, // Override with our test ID +} as Query; + +const createMockDashboard = (id: string = TEST_DASHBOARD_ID): Dashboard => ({ + id, + data: { + title: TEST_DASHBOARD_TITLE, + description: TEST_DASHBOARD_DESCRIPTION, + tags: [], + layout: [], + variables: {}, + }, + createdAt: TEST_TIMESTAMP, + updatedAt: TEST_TIMESTAMP, + createdBy: 'test-user', + updatedBy: 'test-user', +}); + +const ADD_TO_DASHBOARD_BUTTON_NAME = /add to dashboard/i; + +// Helper function to render component with props +const renderExplorerOptionWrapper = ( + overrides = {}, +): ReturnType => { + const props = { + disabled: false, + query: mockQuery, + isLoading: false, + onExport: jest.fn() as jest.MockedFunction< + ( + dashboard: Dashboard | null, + isNewDashboard?: boolean, + queryToExport?: Query, + ) => void + >, + sourcepage: DataSource.LOGS, + isOneChartPerQuery: false, + splitedQueries: [], + signalSource: 'test-signal', + ...overrides, + }; + + return render( + , + ); +}; + +describe('ExplorerOptionWrapper', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetExplorerToolBarVisibility.mockReturnValue(true); + + // Mock useUpdateDashboard to return a mutation object + mockUseUpdateDashboard.mockReturnValue(({ + mutate: jest.fn(), + mutateAsync: jest.fn(), + isLoading: false, + isError: false, + isSuccess: false, + data: undefined, + error: null, + reset: jest.fn(), + } as unknown) as ReturnType); + }); + + describe('onExport functionality', () => { + it('should call onExport when New Dashboard button is clicked in export modal', async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + const testOnExport = jest.fn() as jest.MockedFunction< + ( + dashboard: Dashboard | null, + isNewDashboard?: boolean, + queryToExport?: Query, + ) => void + >; + + // Mock the dashboard creation API + const mockNewDashboard = createMockDashboard(NEW_DASHBOARD_ID); + server.use( + rest.post(DASHBOARDS_API_ENDPOINT, (_req, res, ctx) => + res(ctx.status(200), ctx.json({ data: mockNewDashboard })), + ), + ); + + renderExplorerOptionWrapper({ + onExport: testOnExport, + }); + + // Find and click the "Add to Dashboard" button + const addToDashboardButton = screen.getByRole('button', { + name: ADD_TO_DASHBOARD_BUTTON_NAME, + }); + await user.click(addToDashboardButton); + + // Wait for the export modal to appear + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + // Click the "New Dashboard" button + const newDashboardButton = screen.getByRole('button', { + name: /new dashboard/i, + }); + await user.click(newDashboardButton); + + // Wait for the API call to complete and onExport to be called + await waitFor(() => { + expect(testOnExport).toHaveBeenCalledWith(mockNewDashboard, true); + }); + }); + + it('should call onExport when selecting existing dashboard and clicking Export button', async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + const testOnExport = jest.fn() as jest.MockedFunction< + ( + dashboard: Dashboard | null, + isNewDashboard?: boolean, + queryToExport?: Query, + ) => void + >; + + // Mock existing dashboards with unique titles + const mockDashboard1 = createMockDashboard('dashboard-1'); + mockDashboard1.data.title = 'Dashboard 1'; + const mockDashboard2 = createMockDashboard('dashboard-2'); + mockDashboard2.data.title = 'Dashboard 2'; + const mockDashboards = [mockDashboard1, mockDashboard2]; + + server.use( + rest.get(DASHBOARDS_API_ENDPOINT, (_req, res, ctx) => + res(ctx.status(200), ctx.json({ data: mockDashboards })), + ), + ); + + renderExplorerOptionWrapper({ + onExport: testOnExport, + }); + + // Find and click the "Add to Dashboard" button + const addToDashboardButton = screen.getByRole('button', { + name: ADD_TO_DASHBOARD_BUTTON_NAME, + }); + await user.click(addToDashboardButton); + + // Wait for the export modal to appear + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + // Wait for dashboards to load and then click on the dashboard select dropdown + await waitFor(() => { + expect(screen.getByText('Select Dashboard')).toBeInTheDocument(); + }); + + // Get the modal and find the dashboard select dropdown within it + const modal = screen.getByRole('dialog'); + const dashboardSelect = modal.querySelector( + '[role="combobox"]', + ) as HTMLElement; + expect(dashboardSelect).toBeInTheDocument(); + await user.click(dashboardSelect); + + // Wait for the dropdown options to appear and select the first dashboard + await waitFor(() => { + expect(screen.getByText(mockDashboard1.data.title)).toBeInTheDocument(); + }); + + // Click on the first dashboard option + const dashboardOption = screen.getByText(mockDashboard1.data.title); + await user.click(dashboardOption); + + // Wait for the selection to be made and the Export button to be enabled + await waitFor(() => { + const exportButton = screen.getByRole('button', { name: /export/i }); + expect(exportButton).not.toBeDisabled(); + }); + + // Click the Export button + const exportButton = screen.getByRole('button', { name: /export/i }); + await user.click(exportButton); + + // Wait for onExport to be called with the selected dashboard + await waitFor(() => { + expect(testOnExport).toHaveBeenCalledWith(mockDashboard1, false); + }); + }); + + it('should test actual handleExport function with generateExportToDashboardLink and verify useUpdateDashboard is NOT called', async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + + // Mock the safeNavigate function + const mockSafeNavigate = jest.fn(); + + // Get the mock mutate function to track calls + const mockMutate = mockUseUpdateDashboard().mutate as jest.MockedFunction< + (...args: unknown[]) => void + >; + + const panelTypeParam = PANEL_TYPES.TIME_SERIES; + const widgetId = v4(); + const query = mockQuery; + + // Create a real handleExport function similar to LogsExplorerViews + // This should NOT call useUpdateDashboard (as per PR #8029) + const handleExport = (dashboard: Dashboard | null): void => { + if (!dashboard) return; + + // Call the actual generateExportToDashboardLink function (not mocked) + const dashboardEditView = generateExportToDashboardLink({ + query, + panelType: panelTypeParam, + dashboardId: dashboard.id, + widgetId, + }); + + // Simulate navigation + mockSafeNavigate(dashboardEditView); + }; + + // Mock existing dashboards + const mockDashboard = createMockDashboard('test-dashboard-id'); + mockDashboard.data.title = TEST_DASHBOARD_TITLE_2; + + server.use( + rest.get(DASHBOARDS_API_ENDPOINT, (_req, res, ctx) => + res(ctx.status(200), ctx.json({ data: [mockDashboard] })), + ), + ); + + renderExplorerOptionWrapper({ + onExport: handleExport, + }); + + // Find and click the "Add to Dashboard" button + const addToDashboardButton = screen.getByRole('button', { + name: ADD_TO_DASHBOARD_BUTTON_NAME, + }); + await user.click(addToDashboardButton); + + // Wait for the export modal to appear + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + // Wait for dashboards to load and then click on the dashboard select dropdown + await waitFor(() => { + expect(screen.getByText('Select Dashboard')).toBeInTheDocument(); + }); + + // Get the modal and find the dashboard select dropdown within it + const modal = screen.getByRole('dialog'); + const dashboardSelect = modal.querySelector( + '[role="combobox"]', + ) as HTMLElement; + expect(dashboardSelect).toBeInTheDocument(); + await user.click(dashboardSelect); + + // Wait for the dropdown options to appear and select the dashboard + await waitFor(() => { + expect(screen.getByText(mockDashboard.data.title)).toBeInTheDocument(); + }); + + // Click on the dashboard option + const dashboardOption = screen.getByText(mockDashboard.data.title); + await user.click(dashboardOption); + + // Wait for the selection to be made and the Export button to be enabled + await waitFor(() => { + const exportButton = screen.getByRole('button', { name: /export/i }); + expect(exportButton).not.toBeDisabled(); + }); + + // Click the Export button + const exportButton = screen.getByRole('button', { name: /export/i }); + await user.click(exportButton); + + // Wait for the handleExport function to be called and navigation to occur + await waitFor(() => { + expect(mockSafeNavigate).toHaveBeenCalledTimes(1); + expect(mockSafeNavigate).toHaveBeenCalledWith( + `/dashboard/test-dashboard-id/new?graphType=${panelTypeParam}&widgetId=${widgetId}&compositeQuery=${encodeURIComponent( + JSON.stringify(query), + )}`, + ); + }); + + // Assert that useUpdateDashboard was NOT called (as per PR #8029) + expect(mockMutate).not.toHaveBeenCalled(); + }); + + it('should not show export buttons when component is disabled', () => { + const testOnExport = jest.fn() as jest.MockedFunction< + ( + dashboard: Dashboard | null, + isNewDashboard?: boolean, + queryToExport?: Query, + ) => void + >; + + renderExplorerOptionWrapper({ disabled: true, onExport: testOnExport }); + + // The "Add to Dashboard" button should be disabled + const addToDashboardButton = screen.getByRole('button', { + name: ADD_TO_DASHBOARD_BUTTON_NAME, + }); + expect(addToDashboardButton).toBeDisabled(); + }); + }); +}); diff --git a/frontend/src/container/ExplorerOptions/types.ts b/frontend/src/container/ExplorerOptions/types.ts index 3f8fec96614..0f12a621ea3 100644 --- a/frontend/src/container/ExplorerOptions/types.ts +++ b/frontend/src/container/ExplorerOptions/types.ts @@ -2,6 +2,8 @@ import { NotificationInstance } from 'antd/es/notification/interface'; import { AxiosResponse } from 'axios'; import { SaveViewWithNameProps } from 'components/ExplorerCard/types'; import { PANEL_TYPES } from 'constants/queryBuilder'; +import { ICurrentQueryData } from 'hooks/useHandleExplorerTabChange'; +import { ExplorerViews } from 'pages/LogsExplorer/utils'; import { Dispatch, SetStateAction } from 'react'; import { UseMutateAsyncFunction } from 'react-query'; import { ICompositeMetricQuery } from 'types/api/alerts/compositeQuery'; @@ -38,3 +40,8 @@ export type PreservedViewType = export type PreservedViewsInLocalStorage = Partial< Record >; + +export type ChangeViewFunctionType = ( + view: ExplorerViews, + querySearchParameters?: ICurrentQueryData, +) => void; diff --git a/frontend/src/container/FormAlertRules/ChartPreview/index.tsx b/frontend/src/container/FormAlertRules/ChartPreview/index.tsx index 068103de1d2..8645a6a255e 100644 --- a/frontend/src/container/FormAlertRules/ChartPreview/index.tsx +++ b/frontend/src/container/FormAlertRules/ChartPreview/index.tsx @@ -108,6 +108,13 @@ function ChartPreview({ const [minTimeScale, setMinTimeScale] = useState(); const [maxTimeScale, setMaxTimeScale] = useState(); const [graphVisibility, setGraphVisibility] = useState([]); + const legendScrollPositionRef = useRef<{ + scrollTop: number; + scrollLeft: number; + }>({ + scrollTop: 0, + scrollLeft: 0, + }); const { currentQuery } = useQueryBuilder(); const { minTime, maxTime, selectedTime: globalSelectedInterval } = useSelector< @@ -296,6 +303,13 @@ function ChartPreview({ setGraphsVisibilityStates: setGraphVisibility, enhancedLegend: true, legendPosition, + legendScrollPosition: legendScrollPositionRef.current, + setLegendScrollPosition: (position: { + scrollTop: number; + scrollLeft: number; + }) => { + legendScrollPositionRef.current = position; + }, }), [ yAxisUnit, diff --git a/frontend/src/container/FormAlertRules/QuerySection.tsx b/frontend/src/container/FormAlertRules/QuerySection.tsx index 1c0f50b2bc5..3a23b1aebf8 100644 --- a/frontend/src/container/FormAlertRules/QuerySection.tsx +++ b/frontend/src/container/FormAlertRules/QuerySection.tsx @@ -36,6 +36,7 @@ function QuerySection({ // init namespace for translations const { t } = useTranslation('alerts'); const [currentTab, setCurrentTab] = useState(queryCategory); + const [signalSource, setSignalSource] = useState('metrics'); const handleQueryCategoryChange = (queryType: string): void => { setQueryCategory(queryType as EQueryType); @@ -48,12 +49,17 @@ function QuerySection({ const isDarkMode = useIsDarkMode(); + const handleSignalSourceChange = (value: string): void => { + setSignalSource(value); + }; + const renderMetricUI = (): JSX.Element => ( ); diff --git a/frontend/src/container/GeneralSettings/GeneralSettings.tsx b/frontend/src/container/GeneralSettings/GeneralSettings.tsx index fc8fc156a32..5951013d12e 100644 --- a/frontend/src/container/GeneralSettings/GeneralSettings.tsx +++ b/frontend/src/container/GeneralSettings/GeneralSettings.tsx @@ -137,8 +137,8 @@ function GeneralSettings({ if (logsCurrentTTLValues) { setLogsTotalRetentionPeriod(logsCurrentTTLValues.default_ttl_days * 24); setLogsS3RetentionPeriod( - logsCurrentTTLValues.logs_move_ttl_duration_hrs - ? logsCurrentTTLValues.logs_move_ttl_duration_hrs + logsCurrentTTLValues.cold_storage_ttl_days + ? logsCurrentTTLValues.cold_storage_ttl_days * 24 : null, ); } @@ -198,7 +198,12 @@ function GeneralSettings({ ); const s3Enabled = useMemo( - () => !!find(availableDisks, (disks: IDiskType) => disks?.type === 's3'), + () => + !!find( + availableDisks, + (disks: IDiskType) => + disks?.type === 's3' || disks?.type === 'ObjectStorage', + ), [availableDisks], ); @@ -289,8 +294,9 @@ function GeneralSettings({ isTracesSaveDisabled = true; if ( - logsCurrentTTLValues.logs_ttl_duration_hrs === logsTotalRetentionPeriod && - logsCurrentTTLValues.logs_move_ttl_duration_hrs === logsS3RetentionPeriod + logsCurrentTTLValues.default_ttl_days * 24 === logsTotalRetentionPeriod && + logsCurrentTTLValues.cold_storage_ttl_days && + logsCurrentTTLValues.cold_storage_ttl_days * 24 === logsS3RetentionPeriod ) isLogsSaveDisabled = true; @@ -301,8 +307,8 @@ function GeneralSettings({ errorText, ]; }, [ - logsCurrentTTLValues.logs_move_ttl_duration_hrs, - logsCurrentTTLValues.logs_ttl_duration_hrs, + logsCurrentTTLValues.cold_storage_ttl_days, + logsCurrentTTLValues.default_ttl_days, logsS3RetentionPeriod, logsTotalRetentionPeriod, metricsCurrentTTLValues.metrics_move_ttl_duration_hrs, @@ -348,11 +354,17 @@ function GeneralSettings({ try { if (type === 'logs') { + // Only send S3 values if user has specified a duration + const s3RetentionDays = + apiCallS3Retention && apiCallS3Retention > 0 + ? apiCallS3Retention / 24 + : 0; + await setRetentionApiV2({ type, defaultTTLDays: apiCallTotalRetention ? apiCallTotalRetention / 24 : -1, // convert Hours to days - coldStorageVolume: '', - coldStorageDuration: 0, + coldStorageVolume: s3RetentionDays > 0 ? 's3' : '', + coldStorageDurationDays: s3RetentionDays, ttlConditions: [], }); } else { @@ -406,8 +418,9 @@ function GeneralSettings({ // Updates the currentTTL Values in order to avoid pushing the same values. setLogsCurrentTTLValues((prev) => ({ ...prev, - logs_ttl_duration_hrs: logsTotalRetentionPeriod || -1, - logs_move_ttl_duration_hrs: logsS3RetentionPeriod || -1, + cold_storage_ttl_days: logsS3RetentionPeriod + ? logsS3RetentionPeriod / 24 + : -1, default_ttl_days: logsTotalRetentionPeriod ? logsTotalRetentionPeriod / 24 // convert Hours to days : -1, @@ -524,6 +537,7 @@ function GeneralSettings({ value: logsS3RetentionPeriod, setValue: setLogsS3RetentionPeriod, hide: !s3Enabled, + isS3Field: true, }, ], save: { @@ -577,6 +591,7 @@ function GeneralSettings({ retentionValue={retentionField.value} setRetentionValue={retentionField.setValue} hide={!!retentionField.hide} + isS3Field={'isS3Field' in retentionField && retentionField.isS3Field} /> ))} diff --git a/frontend/src/container/GeneralSettings/Retention.tsx b/frontend/src/container/GeneralSettings/Retention.tsx index 9c05fbec3ab..30e24c42bff 100644 --- a/frontend/src/container/GeneralSettings/Retention.tsx +++ b/frontend/src/container/GeneralSettings/Retention.tsx @@ -6,6 +6,7 @@ import { Dispatch, SetStateAction, useEffect, + useMemo, useRef, useState, } from 'react'; @@ -32,11 +33,31 @@ function Retention({ setRetentionValue, text, hide, + isS3Field = false, }: RetentionProps): JSX.Element | null { + // Filter available units based on type and field + const availableUnits = useMemo( + () => + TimeUnits.filter((option) => { + if (type === 'logs') { + // For S3 cold storage fields: only allow Days + if (isS3Field) { + return option.value === TimeUnitsValues.day; + } + // For total retention: allow Days and Months (not Hours) + return option.value !== TimeUnitsValues.hr; + } + return true; + }), + [type, isS3Field], + ); + + // Convert the hours value using only the available units const { value: initialValue, timeUnitValue: initialTimeUnitValue, - } = convertHoursValueToRelevantUnit(Number(retentionValue)); + } = convertHoursValueToRelevantUnit(Number(retentionValue), availableUnits); + const [selectedTimeUnit, setSelectTimeUnit] = useState(initialTimeUnitValue); const [selectedValue, setSelectedValue] = useState( initialValue, @@ -53,29 +74,27 @@ function Retention({ if (!interacted.current) setSelectTimeUnit(initialTimeUnitValue); }, [initialTimeUnitValue]); - const menuItems = TimeUnits.filter((option) => - type === 'logs' ? option.value !== TimeUnitsValues.hr : true, - ).map((option) => ( + const menuItems = availableUnits.map((option) => ( )); const currentSelectedOption = (option: SettingPeriod): void => { - const selectedValue = find(TimeUnits, (e) => e.value === option)?.value; + const selectedValue = find(availableUnits, (e) => e.value === option)?.value; if (selectedValue) setSelectTimeUnit(selectedValue); }; useEffect(() => { const inverseMultiplier = find( - TimeUnits, + availableUnits, (timeUnit) => timeUnit.value === selectedTimeUnit, )?.multiplier; if (!selectedValue) setRetentionValue(null); if (selectedValue && inverseMultiplier) { setRetentionValue(selectedValue * (1 / inverseMultiplier)); } - }, [selectedTimeUnit, selectedValue, setRetentionValue]); + }, [selectedTimeUnit, selectedValue, setRetentionValue, availableUnits]); const onChangeHandler = ( e: ChangeEvent, @@ -134,6 +153,10 @@ interface RetentionProps { text: string; setRetentionValue: Dispatch>; hide: boolean; + isS3Field?: boolean; } +Retention.defaultProps = { + isS3Field: false, +}; export default Retention; diff --git a/frontend/src/container/GeneralSettings/__tests__/GeneralSettings.test.tsx b/frontend/src/container/GeneralSettings/__tests__/GeneralSettings.test.tsx new file mode 100644 index 00000000000..046de1c5780 --- /dev/null +++ b/frontend/src/container/GeneralSettings/__tests__/GeneralSettings.test.tsx @@ -0,0 +1,390 @@ +import setRetentionApiV2 from 'api/settings/setRetentionV2'; +import { + fireEvent, + render, + screen, + userEvent, + waitFor, +} from 'tests/test-utils'; +import { IDiskType } from 'types/api/disks/getDisks'; +import { + PayloadPropsLogs, + PayloadPropsMetrics, + PayloadPropsTraces, +} from 'types/api/settings/getRetention'; + +import GeneralSettings from '../GeneralSettings'; + +// Mock dependencies +jest.mock('api/settings/setRetentionV2'); + +const mockNotifications = { + error: jest.fn(), + success: jest.fn(), +}; + +jest.mock('hooks/useNotifications', () => ({ + useNotifications: (): { notifications: typeof mockNotifications } => ({ + notifications: mockNotifications, + }), +})); + +jest.mock('hooks/useComponentPermission', () => ({ + __esModule: true, + default: jest.fn(() => [true]), +})); + +jest.mock('hooks/useGetTenantLicense', () => ({ + useGetTenantLicense: (): { isCloudUser: boolean } => ({ + isCloudUser: false, + }), +})); + +jest.mock('container/GeneralSettingsCloud', () => ({ + __esModule: true, + default: (): null => null, +})); + +// Mock data +const mockMetricsRetention: PayloadPropsMetrics = { + metrics_ttl_duration_hrs: 168, + metrics_move_ttl_duration_hrs: -1, + status: '', +}; + +const mockTracesRetention: PayloadPropsTraces = { + traces_ttl_duration_hrs: 168, + traces_move_ttl_duration_hrs: -1, + status: '', +}; + +const mockLogsRetentionWithS3: PayloadPropsLogs = { + version: 'v2', + default_ttl_days: 30, + cold_storage_ttl_days: 24, + status: '', +}; + +const mockLogsRetentionWithoutS3: PayloadPropsLogs = { + version: 'v2', + default_ttl_days: 30, + cold_storage_ttl_days: -1, + status: '', +}; + +const mockDisksWithS3: IDiskType[] = [ + { + name: 'default', + type: 's3', + }, +]; + +const mockDisksWithObjectStorage: IDiskType[] = [ + { + name: 'default', + type: 'ObjectStorage', + }, +]; + +const mockDisksWithoutS3: IDiskType[] = [ + { + name: 'default', + type: 'local', + }, +]; + +describe('GeneralSettings - S3 Logs Retention', () => { + const BUTTON_SELECTOR = 'button[type="button"]'; + const PRIMARY_BUTTON_CLASS = 'ant-btn-primary'; + + beforeEach(() => { + jest.clearAllMocks(); + (setRetentionApiV2 as jest.Mock).mockResolvedValue({ + httpStatusCode: 200, + data: { message: 'success' }, + }); + }); + + describe('Test 1: S3 Enabled - Only Days in Dropdown', () => { + it('should show only Days option for S3 retention and send correct API payload', async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + + render( + , + ); + + // Find the Logs card + const logsCard = screen.getByText('Logs').closest('.ant-card'); + expect(logsCard).toBeInTheDocument(); + + // Find all inputs in the Logs card - there should be 2 (total retention + S3) + // eslint-disable-next-line sonarjs/no-duplicate-string + const inputs = logsCard?.querySelectorAll('input[type="text"]'); + expect(inputs).toHaveLength(2); + + // The second input is the S3 retention field + const s3Input = inputs?.[1] as HTMLInputElement; + + // Find the S3 dropdown (next sibling of the S3 input) + const s3Dropdown = s3Input?.nextElementSibling?.querySelector( + '.ant-select-selector', + ) as HTMLElement; + expect(s3Dropdown).toBeInTheDocument(); + + // Click the S3 dropdown to open it + fireEvent.mouseDown(s3Dropdown); + + // Wait for dropdown options to appear and verify only "Days" is available + await waitFor(() => { + // eslint-disable-next-line sonarjs/no-duplicate-string + const dropdownOptions = document.querySelectorAll('.ant-select-item'); + expect(dropdownOptions).toHaveLength(1); + expect(dropdownOptions[0]).toHaveTextContent('Days'); + }); + + // Close dropdown + fireEvent.click(document.body); + + // Change S3 retention value to 5 days + await user.clear(s3Input); + await user.type(s3Input, '5'); + + // Find the save button in the Logs card + const buttons = logsCard?.querySelectorAll(BUTTON_SELECTOR); + // The primary button should be the save button + const saveButton = Array.from(buttons || []).find((btn) => + btn.className.includes(PRIMARY_BUTTON_CLASS), + ) as HTMLButtonElement; + + expect(saveButton).toBeInTheDocument(); + + // Wait for button to be enabled (it should enable after value changes) + await waitFor(() => { + expect(saveButton).not.toBeDisabled(); + }); + + fireEvent.click(saveButton); + + // Wait for modal to appear + const modal = await screen.findByRole('dialog'); + expect(modal).toBeInTheDocument(); + + // Click OK button + const okButton = await screen.findByRole('button', { name: /ok/i }); + fireEvent.click(okButton); + + // Verify API was called with correct payload + await waitFor(() => { + expect(setRetentionApiV2).toHaveBeenCalledWith({ + type: 'logs', + defaultTTLDays: 30, + coldStorageVolume: 's3', + coldStorageDurationDays: 5, + ttlConditions: [], + }); + }); + }); + + it('should recognize ObjectStorage disk type as S3 enabled', async () => { + render( + , + ); + + // Verify S3 field is visible + const logsCard = screen.getByText('Logs').closest('.ant-card'); + const inputs = logsCard?.querySelectorAll('input[type="text"]'); + expect(inputs).toHaveLength(2); // Total + S3 + }); + }); + + describe('Test 2: S3 Disabled - Field Hidden', () => { + it('should hide S3 retention field and send empty S3 values to API', async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + + render( + , + ); + + // Find the Logs card + const logsCard = screen.getByText('Logs').closest('.ant-card'); + expect(logsCard).toBeInTheDocument(); + + // Only 1 input should be visible (total retention, no S3) + const inputs = logsCard?.querySelectorAll('input[type="text"]'); + expect(inputs).toHaveLength(1); + + // Change total retention value + const totalInput = inputs?.[0] as HTMLInputElement; + + // First, change the dropdown to Days (it defaults to Months) + const totalDropdown = totalInput?.nextElementSibling?.querySelector( + '.ant-select-selector', + ) as HTMLElement; + await user.click(totalDropdown); + + // Wait for dropdown options to appear + await waitFor(() => { + const options = document.querySelectorAll('.ant-select-item'); + expect(options.length).toBeGreaterThan(0); + }); + + // Find and click the Days option + const options = document.querySelectorAll('.ant-select-item'); + const daysOption = Array.from(options).find((opt) => + opt.textContent?.includes('Days'), + ); + expect(daysOption).toBeInTheDocument(); + await user.click(daysOption as HTMLElement); + + // Now change the value + await user.clear(totalInput); + await user.type(totalInput, '60'); + + // Find the save button + const buttons = logsCard?.querySelectorAll(BUTTON_SELECTOR); + const saveButton = Array.from(buttons || []).find((btn) => + btn.className.includes(PRIMARY_BUTTON_CLASS), + ) as HTMLButtonElement; + + expect(saveButton).toBeInTheDocument(); + + // Wait for button to be enabled (ensures all state updates have settled) + await waitFor(() => { + expect(saveButton).not.toBeDisabled(); + }); + + // Click save button + await user.click(saveButton); + + // Wait for modal to appear + const okButton = await screen.findByRole('button', { name: /ok/i }); + expect(okButton).toBeInTheDocument(); + + // Click OK button + await user.click(okButton); + + // Verify API was called with empty S3 values (60 days) + await waitFor(() => { + expect(setRetentionApiV2).toHaveBeenCalledWith({ + type: 'logs', + defaultTTLDays: 60, + coldStorageVolume: '', + coldStorageDurationDays: 0, + ttlConditions: [], + }); + }); + }); + }); + + describe('Test 3: Save & Reload - Correct Display', () => { + it('should display retention values correctly after converting from hours', () => { + render( + , + ); + + // Find the Logs card + const logsCard = screen.getByText('Logs').closest('.ant-card'); + const inputs = logsCard?.querySelectorAll('input[type="text"]'); + + // Total retention: 720 hours = 30 days = 1 month (displays as 1 Month) + const totalInput = inputs?.[0] as HTMLInputElement; + expect(totalInput.value).toBe('1'); + + // S3 retention: 24 day + const s3Input = inputs?.[1] as HTMLInputElement; + expect(s3Input.value).toBe('24'); + + // Verify dropdowns: total shows Months, S3 shows Days + const dropdowns = logsCard?.querySelectorAll('.ant-select-selection-item'); + expect(dropdowns?.[0]).toHaveTextContent('Months'); + expect(dropdowns?.[1]).toHaveTextContent('Days'); + }); + }); + + describe('Test 4: Save Button State with S3 Disabled', () => { + it('should disable save button when cold_storage_ttl_days is -1 and no changes made', async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + + render( + , + ); + + // Find the Logs card + const logsCard = screen.getByText('Logs').closest('.ant-card'); + expect(logsCard).toBeInTheDocument(); + + // Find the save button + const buttons = logsCard?.querySelectorAll(BUTTON_SELECTOR); + const saveButton = Array.from(buttons || []).find((btn) => + btn.className.includes(PRIMARY_BUTTON_CLASS), + ) as HTMLButtonElement; + + expect(saveButton).toBeInTheDocument(); + + // Verify save button is disabled on initial load (no changes, S3 disabled with -1) + expect(saveButton).toBeDisabled(); + + // Find the total retention input + const inputs = logsCard?.querySelectorAll('input[type="text"]'); + const totalInput = inputs?.[0] as HTMLInputElement; + + // Change total retention value to trigger button enable + await user.clear(totalInput); + await user.type(totalInput, '60'); + + // Button should now be enabled after change + await waitFor(() => { + expect(saveButton).not.toBeDisabled(); + }); + + // Revert to original value (30 days displays as 1 Month) + await user.clear(totalInput); + await user.type(totalInput, '1'); + + // Button should be disabled again (back to original state) + await waitFor(() => { + expect(saveButton).toBeDisabled(); + }); + }); + }); +}); diff --git a/frontend/src/container/GeneralSettings/utils.ts b/frontend/src/container/GeneralSettings/utils.ts index b5dee556038..cf2948d431c 100644 --- a/frontend/src/container/GeneralSettings/utils.ts +++ b/frontend/src/container/GeneralSettings/utils.ts @@ -34,12 +34,21 @@ interface ITimeUnitConversion { value: number; timeUnitValue: SettingPeriod; } + +/** + * Converts hours value to the most relevant unit from the available units. + * @param value - The value in hours + * @param availableUnits - Optional array of available time units to consider. If not provided, all units are considered. + * @returns The converted value and the selected time unit + */ export const convertHoursValueToRelevantUnit = ( value: number, + availableUnits?: ITimeUnit[], ): ITimeUnitConversion => { - if (value) - for (let idx = TimeUnits.length - 1; idx >= 0; idx -= 1) { - const timeUnit = TimeUnits[idx]; + const unitsToConsider = availableUnits?.length ? availableUnits : TimeUnits; + if (value >= 0) { + for (let idx = unitsToConsider.length - 1; idx >= 0; idx -= 1) { + const timeUnit = unitsToConsider[idx]; const convertedValue = timeUnit.multiplier * value; if ( @@ -49,7 +58,10 @@ export const convertHoursValueToRelevantUnit = ( return { value: convertedValue, timeUnitValue: timeUnit.value }; } } - return { value, timeUnitValue: TimeUnits[0].value }; + } + + // Fallback to the first available unit + return { value: -1, timeUnitValue: unitsToConsider[0].value }; }; export const convertHoursValueToRelevantUnitString = ( diff --git a/frontend/src/container/GridCardLayout/GridCard/FullView/__tests__/PanelWrapperDragSelect.test.tsx b/frontend/src/container/GridCardLayout/GridCard/FullView/__tests__/PanelWrapperDragSelect.test.tsx new file mode 100644 index 00000000000..5e662cf9fc2 --- /dev/null +++ b/frontend/src/container/GridCardLayout/GridCard/FullView/__tests__/PanelWrapperDragSelect.test.tsx @@ -0,0 +1,207 @@ +/* eslint-disable sonarjs/no-duplicate-string */ + +import { PANEL_TYPES } from 'constants/queryBuilder'; +import PanelWrapper from 'container/PanelWrapper/PanelWrapper'; +import { RowData } from 'lib/query/createTableColumnsFromQuery'; +import { MutableRefObject } from 'react'; +import { render, screen, waitFor } from 'tests/test-utils'; +import { Widgets } from 'types/api/dashboard/getAll'; +import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse'; +import { EQueryType } from 'types/common/dashboard'; +import { DataSource } from 'types/common/queryBuilder'; + +// Mock dependencies +jest.mock('container/PanelWrapper/constants', () => ({ + PanelTypeVsPanelWrapper: { + [PANEL_TYPES.TIME_SERIES]: ({ + onDragSelect, + }: { + onDragSelect: (start: number, end: number) => void; + }): JSX.Element => { + const handleCanvasMouseDown = (): void => { + // Simulate drag start + const handleMouseMove = (): void => { + // Simulate drag progress + }; + + const handleMouseUp = (): void => { + // Simulate drag end and call onDragSelect + onDragSelect(1634325650, 1634325750); + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }; + + return ( +
+ + +
+ ); + }, + }, +})); + +// Mock data +const mockWidget: Widgets = { + id: 'test-widget-id', + query: { + builder: { + queryData: [ + { + dataSource: DataSource.METRICS, + queryName: 'A', + aggregateOperator: 'sum', + aggregateAttribute: { + key: 'test', + dataType: DataTypes.Float64, + type: '', + }, + functions: [], + groupBy: [], + expression: 'A', + disabled: false, + having: [], + limit: null, + orderBy: [], + stepInterval: 60, + legend: '', + spaceAggregation: 'sum', + timeAggregation: 'sum', + }, + ], + queryFormulas: [], + queryTraceOperator: [], + }, + promql: [], + clickhouse_sql: [], + id: 'test-query-id', + queryType: EQueryType.QUERY_BUILDER, + }, + panelTypes: PANEL_TYPES.TIME_SERIES, + title: 'Test Widget', + description: '', + opacity: '', + timePreferance: 'GLOBAL_TIME', + nullZeroValues: '', + yAxisUnit: '', + fillSpans: false, + softMin: null, + softMax: null, + selectedLogFields: [], + selectedTracesFields: [], +}; + +// Mock response data +const mockQueryResponse: any = { + data: { + payload: { + data: { + result: [ + { + metric: { __name__: 'test_metric' }, + values: [[1634325600, '42']], + queryName: 'A', + }, + ], + resultType: '', + newResult: { + data: { + resultType: '', + result: [ + { + queryName: 'A', + series: null, + list: null, + }, + ], + }, + }, + }, + }, + statusCode: 200, + message: 'success', + error: null, + }, + isLoading: false, + isError: false, + error: null, + isFetching: false, + refetch: jest.fn(), +}; + +describe('PanelWrapper with DragSelect', () => { + const tableProcessedDataRef = { current: [] } as MutableRefObject; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('simulates drag select on uPlot canvas', async () => { + const mockOnDragSelect = jest.fn(); + + render( + , + ); + + // Verify the panel renders + expect(screen.getByTestId('mock-time-series-panel')).toBeInTheDocument(); + + // Find the canvas element + const canvas = screen.getByTestId('uplot-canvas'); + expect(canvas).toBeInTheDocument(); + + // Simulate drag events on the canvas + // Start drag by dispatching mousedown + canvas.dispatchEvent( + new MouseEvent('mousedown', { + clientX: 10, + clientY: 10, + bubbles: true, + }), + ); + + // Simulate mouse move during drag + canvas.dispatchEvent( + new MouseEvent('mousemove', { + clientX: 60, + clientY: 60, + bubbles: true, + }), + ); + + // End drag by dispatching mouseup + canvas.dispatchEvent( + new MouseEvent('mouseup', { + clientX: 80, + clientY: 80, + bubbles: true, + }), + ); + + // Wait for the onDragSelect to be called + await waitFor(() => { + expect(mockOnDragSelect).toHaveBeenCalledWith(1634325650, 1634325750); + }); + }); +}); diff --git a/frontend/src/container/GridCardLayout/GridCard/FullView/index.tsx b/frontend/src/container/GridCardLayout/GridCard/FullView/index.tsx index f27089041c1..676d12f81f8 100644 --- a/frontend/src/container/GridCardLayout/GridCard/FullView/index.tsx +++ b/frontend/src/container/GridCardLayout/GridCard/FullView/index.tsx @@ -38,9 +38,7 @@ import { isEmpty } from 'lodash-es'; import { useAppContext } from 'providers/App/App'; import { useDashboard } from 'providers/Dashboard/Dashboard'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import { useLocation } from 'react-router-dom'; -import { UpdateTimeInterval } from 'store/actions'; +import { useSelector } from 'react-redux'; import { AppState } from 'store/reducers'; import { Warning } from 'types/api'; import { GlobalReducer } from 'types/reducer/globalTime'; @@ -67,13 +65,11 @@ function FullView({ enableDrillDown = false, }: FullViewProps): JSX.Element { const { safeNavigate } = useSafeNavigate(); - const { selectedTime: globalSelectedTime } = useSelector< + const { selectedTime: globalSelectedTime, minTime, maxTime } = useSelector< AppState, GlobalReducer >((state) => state.globalTime); - const dispatch = useDispatch(); const urlQuery = useUrlQuery(); - const location = useLocation(); const fullViewRef = useRef(null); const { handleRunQuery } = useQueryBuilder(); @@ -154,11 +150,16 @@ function FullView({ }); useEffect(() => { + const timeRange = + selectedTime.enum !== 'GLOBAL_TIME' + ? { start: undefined, end: undefined } + : { start: Math.floor(minTime / 1e9), end: Math.floor(maxTime / 1e9) }; setRequestData((prev) => ({ ...prev, selectedTime: selectedTime.enum, + ...timeRange, })); - }, [selectedTime]); + }, [selectedTime, minTime, maxTime]); // Update requestData when panel type changes useEffect(() => { @@ -181,38 +182,34 @@ function FullView({ }); }, [selectedPanelType]); - const response = useGetQueryRange( - requestData, - // selectedDashboard?.data?.version || version || DEFAULT_ENTITY_VERSION, - ENTITY_VERSION_V5, - { - queryKey: [widget?.query, selectedPanelType, requestData, version], - enabled: !isDependedDataLoaded, - keepPreviousData: true, - }, - ); + const response = useGetQueryRange(requestData, ENTITY_VERSION_V5, { + queryKey: [ + widget?.query, + selectedPanelType, + requestData, + version, + minTime, + maxTime, + ], + enabled: !isDependedDataLoaded, + keepPreviousData: true, + }); - const onDragSelect = useCallback( - (start: number, end: number): void => { - const startTimestamp = Math.trunc(start); - const endTimestamp = Math.trunc(end); + const onDragSelect = useCallback((start: number, end: number): void => { + const startTimestamp = Math.trunc(start); + const endTimestamp = Math.trunc(end); - if (startTimestamp !== endTimestamp) { - dispatch(UpdateTimeInterval('custom', [startTimestamp, endTimestamp])); - } + const { maxTime, minTime } = GetMinMax('custom', [ + startTimestamp, + endTimestamp, + ]); - const { maxTime, minTime } = GetMinMax('custom', [ - startTimestamp, - endTimestamp, - ]); - - urlQuery.set(QueryParams.startTime, minTime.toString()); - urlQuery.set(QueryParams.endTime, maxTime.toString()); - const generatedUrl = `${location.pathname}?${urlQuery.toString()}`; - safeNavigate(generatedUrl); - }, - [dispatch, location.pathname, safeNavigate, urlQuery], - ); + setRequestData((prev) => ({ + ...prev, + start: Math.floor(minTime / 1e9), + end: Math.floor(maxTime / 1e9), + })); + }, []); const [graphsVisibilityStates, setGraphsVisibilityStates] = useState< boolean[] @@ -327,6 +324,7 @@ function FullView({ panelType={selectedPanelType} version={selectedDashboard?.data?.version || 'v3'} isListViewPanel={selectedPanelType === PANEL_TYPES.LIST} + signalSourceChangeEnabled // filterConfigs={filterConfigs} // queryComponents={queryComponents} /> diff --git a/frontend/src/container/GridCardLayout/styles.ts b/frontend/src/container/GridCardLayout/styles.ts index df2004da523..0a5c9fcd579 100644 --- a/frontend/src/container/GridCardLayout/styles.ts +++ b/frontend/src/container/GridCardLayout/styles.ts @@ -17,12 +17,6 @@ export const Card = styled(CardComponent)` overflow: hidden; border-radius: 3px; border: 1px solid var(--bg-slate-500); - background: linear-gradient( - 0deg, - rgba(171, 189, 255, 0) 0%, - rgba(171, 189, 255, 0) 100% - ), - #0b0c0e; ${({ isDarkMode }): StyledCSS => !isDarkMode && diff --git a/frontend/src/container/GridTableComponent/index.tsx b/frontend/src/container/GridTableComponent/index.tsx index 6e5d3e9d2b0..f9ba1f21f25 100644 --- a/frontend/src/container/GridTableComponent/index.tsx +++ b/frontend/src/container/GridTableComponent/index.tsx @@ -48,6 +48,7 @@ function GridTableComponent({ widgetId, panelType, queryRangeRequest, + decimalPrecision, ...props }: GridTableComponentProps): JSX.Element { const { t } = useTranslation(['valueGraph']); @@ -87,10 +88,15 @@ function GridTableComponent({ const newValue = { ...val }; Object.keys(val).forEach((k) => { const unit = getColumnUnit(k, columnUnits); + if (unit) { // the check below takes care of not adding units for rows that have n/a or null values if (val[k] !== 'n/a' && val[k] !== null) { - newValue[k] = getYAxisFormattedValue(String(val[k]), unit); + newValue[k] = getYAxisFormattedValue( + String(val[k]), + unit, + decimalPrecision, + ); } else if (val[k] === null) { newValue[k] = 'n/a'; } @@ -103,7 +109,7 @@ function GridTableComponent({ return mutateDataSource; }, - [columnUnits], + [columnUnits, decimalPrecision], ); const dataSource = useMemo(() => applyColumnUnits(originalDataSource), [ diff --git a/frontend/src/container/GridTableComponent/types.ts b/frontend/src/container/GridTableComponent/types.ts index a1dac8104f6..d3e55c93389 100644 --- a/frontend/src/container/GridTableComponent/types.ts +++ b/frontend/src/container/GridTableComponent/types.ts @@ -1,4 +1,5 @@ import { TableProps } from 'antd'; +import { PrecisionOption } from 'components/Graph/yAxisConfig'; import { PANEL_TYPES } from 'constants/queryBuilder'; import { LogsExplorerTableProps } from 'container/LogsExplorerTable/LogsExplorerTable.interfaces'; import { @@ -15,6 +16,7 @@ export type GridTableComponentProps = { query: Query; thresholds?: ThresholdProps[]; columnUnits?: ColumnUnit; + decimalPrecision?: PrecisionOption; tableProcessedDataRef?: React.MutableRefObject; sticky?: TableProps['sticky']; searchTerm?: string; diff --git a/frontend/src/container/GridValueComponent/index.tsx b/frontend/src/container/GridValueComponent/index.tsx index bb1b6851f49..54f4fdd9d96 100644 --- a/frontend/src/container/GridValueComponent/index.tsx +++ b/frontend/src/container/GridValueComponent/index.tsx @@ -99,7 +99,11 @@ function GridValueComponent({ rawValue={value} value={ yAxisUnit - ? getYAxisFormattedValue(String(value), yAxisUnit) + ? getYAxisFormattedValue( + String(value), + yAxisUnit, + widget?.decimalPrecision, + ) : value.toString() } /> diff --git a/frontend/src/container/Home/Home.styles.scss b/frontend/src/container/Home/Home.styles.scss index f27d93137ee..a8a2d31895b 100644 --- a/frontend/src/container/Home/Home.styles.scss +++ b/frontend/src/container/Home/Home.styles.scss @@ -423,6 +423,7 @@ display: flex; flex-direction: row; gap: 14px; + align-items: flex-start; .section-icon { display: flex; @@ -461,7 +462,6 @@ flex-direction: column; gap: 14px; - width: 150px; justify-content: flex-end; .ant-btn { diff --git a/frontend/src/container/InfraMonitoringK8s/EntityDetailsUtils/EntityEvents/__tests__/EntityEvents.test.tsx b/frontend/src/container/InfraMonitoringK8s/EntityDetailsUtils/EntityEvents/__tests__/EntityEvents.test.tsx index bda94ab5d72..797eb7acff3 100644 --- a/frontend/src/container/InfraMonitoringK8s/EntityDetailsUtils/EntityEvents/__tests__/EntityEvents.test.tsx +++ b/frontend/src/container/InfraMonitoringK8s/EntityDetailsUtils/EntityEvents/__tests__/EntityEvents.test.tsx @@ -23,6 +23,7 @@ jest.mock('container/TopNav/DateTimeSelectionV2', () => ({ const mockUseQuery = jest.fn(); jest.mock('react-query', () => ({ + ...jest.requireActual('react-query'), useQuery: (queryKey: any, queryFn: any, options: any): any => mockUseQuery(queryKey, queryFn, options), })); diff --git a/frontend/src/container/InfraMonitoringK8s/EntityDetailsUtils/EntityMetrics/EntityMetrics.tsx b/frontend/src/container/InfraMonitoringK8s/EntityDetailsUtils/EntityMetrics/EntityMetrics.tsx index c9cd831a192..9ee3196d945 100644 --- a/frontend/src/container/InfraMonitoringK8s/EntityDetailsUtils/EntityMetrics/EntityMetrics.tsx +++ b/frontend/src/container/InfraMonitoringK8s/EntityDetailsUtils/EntityMetrics/EntityMetrics.tsx @@ -115,6 +115,13 @@ function EntityMetrics({ const graphRef = useRef(null); const dimensions = useResizeObserver(graphRef); const { currentQuery } = useQueryBuilder(); + const legendScrollPositionRef = useRef<{ + scrollTop: number; + scrollLeft: number; + }>({ + scrollTop: 0, + scrollLeft: 0, + }); const chartData = useMemo( () => @@ -184,6 +191,13 @@ function EntityMetrics({ maxTimeScale: graphTimeIntervals[idx].end, onDragSelect: (start, end) => onDragSelect(start, end, idx), query: currentQuery, + legendScrollPosition: legendScrollPositionRef.current, + setLegendScrollPosition: (position: { + scrollTop: number; + scrollLeft: number; + }) => { + legendScrollPositionRef.current = position; + }, }); }), [ diff --git a/frontend/src/container/InfraMonitoringK8s/EntityDetailsUtils/EntityMetrics/__tests__/EntityMetrics.test.tsx b/frontend/src/container/InfraMonitoringK8s/EntityDetailsUtils/EntityMetrics/__tests__/EntityMetrics.test.tsx index ad488460e97..21ed2ecb55a 100644 --- a/frontend/src/container/InfraMonitoringK8s/EntityDetailsUtils/EntityMetrics/__tests__/EntityMetrics.test.tsx +++ b/frontend/src/container/InfraMonitoringK8s/EntityDetailsUtils/EntityMetrics/__tests__/EntityMetrics.test.tsx @@ -52,6 +52,7 @@ jest.mock('container/InfraMonitoringK8s/commonUtils', () => ({ const mockUseQueries = jest.fn(); const mockUseQuery = jest.fn(); jest.mock('react-query', () => ({ + ...jest.requireActual('react-query'), useQueries: (queryConfigs: any[]): any[] => mockUseQueries(queryConfigs), useQuery: (config: any): any => mockUseQuery(config), })); diff --git a/frontend/src/container/InfraMonitoringK8s/EntityDetailsUtils/EntityTraces/__tests__/EntityTraces.test.tsx b/frontend/src/container/InfraMonitoringK8s/EntityDetailsUtils/EntityTraces/__tests__/EntityTraces.test.tsx index 40977f9ea25..98f71ce4271 100644 --- a/frontend/src/container/InfraMonitoringK8s/EntityDetailsUtils/EntityTraces/__tests__/EntityTraces.test.tsx +++ b/frontend/src/container/InfraMonitoringK8s/EntityDetailsUtils/EntityTraces/__tests__/EntityTraces.test.tsx @@ -22,6 +22,7 @@ jest.mock('container/TopNav/DateTimeSelectionV2', () => ({ const mockUseQuery = jest.fn(); jest.mock('react-query', () => ({ + ...jest.requireActual('react-query'), useQuery: (queryKey: any, queryFn: any, options: any): any => mockUseQuery(queryKey, queryFn, options), })); diff --git a/frontend/src/container/IngestionSettings/IngestionSettings.styles.scss b/frontend/src/container/IngestionSettings/IngestionSettings.styles.scss index 3d1b795ab80..5ed85277975 100644 --- a/frontend/src/container/IngestionSettings/IngestionSettings.styles.scss +++ b/frontend/src/container/IngestionSettings/IngestionSettings.styles.scss @@ -418,6 +418,11 @@ font-size: 12px; font-weight: 600; } + + .set-alert-btn { + cursor: pointer; + margin-left: 24px; + } } } diff --git a/frontend/src/container/IngestionSettings/MultiIngestionSettings.tsx b/frontend/src/container/IngestionSettings/MultiIngestionSettings.tsx index d7ed2e296f6..2d385a40ca6 100644 --- a/frontend/src/container/IngestionSettings/MultiIngestionSettings.tsx +++ b/frontend/src/container/IngestionSettings/MultiIngestionSettings.tsx @@ -19,6 +19,7 @@ import { TablePaginationConfig, TableProps as AntDTableProps, Tag, + Tooltip, Typography, } from 'antd'; import { NotificationInstance } from 'antd/es/notification/interface'; @@ -34,15 +35,20 @@ import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig'; import Tags from 'components/Tags/Tags'; import { SOMETHING_WENT_WRONG } from 'constants/api'; import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats'; +import { QueryParams } from 'constants/query'; +import { initialQueryMeterWithType } from 'constants/queryBuilder'; +import ROUTES from 'constants/routes'; +import { INITIAL_ALERT_THRESHOLD_STATE } from 'container/CreateAlertV2/context/constants'; import dayjs from 'dayjs'; import { useGetDeploymentsData } from 'hooks/CustomDomain/useGetDeploymentsData'; import { useGetAllIngestionsKeys } from 'hooks/IngestionKeys/useGetAllIngestionKeys'; import useDebouncedFn from 'hooks/useDebouncedFunction'; import { useGetTenantLicense } from 'hooks/useGetTenantLicense'; import { useNotifications } from 'hooks/useNotifications'; -import { isNil, isUndefined } from 'lodash-es'; +import { cloneDeep, isNil, isUndefined } from 'lodash-es'; import { ArrowUpRight, + BellPlus, CalendarClock, Check, Copy, @@ -60,6 +66,7 @@ import { useTimezone } from 'providers/Timezone'; import { ChangeEvent, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useMutation } from 'react-query'; +import { useHistory } from 'react-router-dom'; import { useCopyToClipboard } from 'react-use'; import { ErrorResponse } from 'types/api'; import { @@ -71,6 +78,7 @@ import { IngestionKeyProps, PaginationProps, } from 'types/api/ingestionKeys/types'; +import { MeterAggregateOperator } from 'types/common/queryBuilder'; import { USER_ROLES } from 'types/roles'; import { getDaysUntilExpiry } from 'utils/timeUtils'; @@ -170,6 +178,8 @@ function MultiIngestionSettings(): JSX.Element { const { isEnterpriseSelfHostedUser } = useGetTenantLicense(); + const history = useHistory(); + const [ hasCreateLimitForIngestionKeyError, setHasCreateLimitForIngestionKeyError, @@ -694,6 +704,68 @@ function MultiIngestionSettings(): JSX.Element { const { formatTimezoneAdjustedTimestamp } = useTimezone(); + const handleCreateAlert = ( + APIKey: IngestionKeyProps, + signal: LimitProps, + ): void => { + let metricName = ''; + + switch (signal.signal) { + case 'metrics': + metricName = 'signoz.meter.metric.datapoint.count'; + break; + case 'traces': + metricName = 'signoz.meter.span.size'; + break; + case 'logs': + metricName = 'signoz.meter.log.size'; + break; + default: + return; + } + + const threshold = + signal.signal === 'metrics' + ? signal.config?.day?.count || 0 + : signal.config?.day?.size || 0; + + const query = { + ...initialQueryMeterWithType, + builder: { + ...initialQueryMeterWithType.builder, + queryData: [ + { + ...initialQueryMeterWithType.builder.queryData[0], + aggregations: [ + { + ...initialQueryMeterWithType.builder.queryData[0].aggregations?.[0], + metricName, + timeAggregation: MeterAggregateOperator.INCREASE, + spaceAggregation: MeterAggregateOperator.SUM, + }, + ], + filter: { + expression: `signoz.workspace.key.id='${APIKey.id}'`, + }, + }, + ], + }, + }; + + const stringifiedQuery = JSON.stringify(query); + + const thresholds = cloneDeep(INITIAL_ALERT_THRESHOLD_STATE.thresholds); + thresholds[0].thresholdValue = threshold; + + const URL = `${ROUTES.ALERTS_NEW}?showNewCreateAlertsPage=true&${ + QueryParams.compositeQuery + }=${encodeURIComponent(stringifiedQuery)}&${ + QueryParams.thresholds + }=${encodeURIComponent(JSON.stringify(thresholds))}`; + + history.push(URL); + }; + const columns: AntDTableProps['columns'] = [ { title: 'Ingestion Key', @@ -1183,6 +1255,27 @@ function MultiIngestionSettings(): JSX.Element { ))} + + {((signalCfg.usesSize && + limit?.config?.day?.size !== undefined) || + (signalCfg.usesCount && + limit?.config?.day?.count !== undefined)) && ( + +