diff --git a/.devenv/docker/clickhouse/compose.yaml b/.devenv/docker/clickhouse/compose.yaml index 3beed9e6bc6..d3a825f46d9 100644 --- a/.devenv/docker/clickhouse/compose.yaml +++ b/.devenv/docker/clickhouse/compose.yaml @@ -1,6 +1,6 @@ services: clickhouse: - image: clickhouse/clickhouse-server:24.1.2-alpine + image: clickhouse/clickhouse-server:25.5.6 container_name: clickhouse volumes: - ${PWD}/fs/etc/clickhouse-server/config.d/config.xml:/etc/clickhouse-server/config.d/config.xml @@ -23,8 +23,10 @@ services: retries: 3 depends_on: - zookeeper + environment: + - CLICKHOUSE_SKIP_USER_SETUP=1 zookeeper: - image: bitnami/zookeeper:3.7.1 + image: signoz/zookeeper:3.7.1 container_name: zookeeper volumes: - ${PWD}/fs/tmp/zookeeper:/bitnami/zookeeper @@ -40,7 +42,7 @@ services: timeout: 5s retries: 3 schema-migrator-sync: - image: signoz/signoz-schema-migrator:v0.111.41 + image: signoz/signoz-schema-migrator:v0.129.6 container_name: schema-migrator-sync command: - sync @@ -53,7 +55,7 @@ services: condition: service_healthy restart: on-failure schema-migrator-async: - image: signoz/signoz-schema-migrator:v0.111.41 + image: signoz/signoz-schema-migrator:v0.129.6 container_name: schema-migrator-async command: - async diff --git a/.devenv/docker/signoz-otel-collector/compose.yaml b/.devenv/docker/signoz-otel-collector/compose.yaml new file mode 100644 index 00000000000..eac598f6de6 --- /dev/null +++ b/.devenv/docker/signoz-otel-collector/compose.yaml @@ -0,0 +1,29 @@ +services: + signoz-otel-collector: + image: signoz/signoz-otel-collector:v0.129.6 + container_name: signoz-otel-collector-dev + command: + - --config=/etc/otel-collector-config.yaml + - --feature-gates=-pkg.translator.prometheus.NormalizeName + volumes: + - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml + environment: + - OTEL_RESOURCE_ATTRIBUTES=host.name=signoz-host,os.type=linux + - LOW_CARDINAL_EXCEPTION_GROUPING=false + ports: + - "4317:4317" # OTLP gRPC receiver + - "4318:4318" # OTLP HTTP receiver + - "13133:13133" # health check extension + healthcheck: + test: + - CMD + - wget + - --spider + - -q + - localhost:13133 + interval: 30s + timeout: 5s + retries: 3 + restart: unless-stopped + extra_hosts: + - "host.docker.internal:host-gateway" \ No newline at end of file diff --git a/.devenv/docker/signoz-otel-collector/otel-collector-config.yaml b/.devenv/docker/signoz-otel-collector/otel-collector-config.yaml new file mode 100644 index 00000000000..43a888fffb7 --- /dev/null +++ b/.devenv/docker/signoz-otel-collector/otel-collector-config.yaml @@ -0,0 +1,96 @@ +receivers: + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + http: + endpoint: 0.0.0.0:4318 + prometheus: + config: + global: + scrape_interval: 60s + scrape_configs: + - job_name: otel-collector + static_configs: + - targets: + - localhost:8888 + labels: + job_name: otel-collector + +processors: + batch: + send_batch_size: 10000 + send_batch_max_size: 11000 + timeout: 10s + resourcedetection: + # Using OTEL_RESOURCE_ATTRIBUTES envvar, env detector adds custom labels. + detectors: [env, system] + timeout: 2s + signozspanmetrics/delta: + metrics_exporter: signozclickhousemetrics + metrics_flush_interval: 60s + latency_histogram_buckets: [100us, 1ms, 2ms, 6ms, 10ms, 50ms, 100ms, 250ms, 500ms, 1000ms, 1400ms, 2000ms, 5s, 10s, 20s, 40s, 60s ] + dimensions_cache_size: 100000 + aggregation_temporality: AGGREGATION_TEMPORALITY_DELTA + enable_exp_histogram: true + dimensions: + - name: service.namespace + default: default + - name: deployment.environment + default: default + # This is added to ensure the uniqueness of the timeseries + # Otherwise, identical timeseries produced by multiple replicas of + # collectors result in incorrect APM metrics + - name: signoz.collector.id + - name: service.version + - name: browser.platform + - name: browser.mobile + - name: k8s.cluster.name + - name: k8s.node.name + - name: k8s.namespace.name + - name: host.name + - name: host.type + - name: container.name + +extensions: + health_check: + endpoint: 0.0.0.0:13133 + pprof: + endpoint: 0.0.0.0:1777 + +exporters: + clickhousetraces: + datasource: tcp://host.docker.internal:9000/signoz_traces + low_cardinal_exception_grouping: ${env:LOW_CARDINAL_EXCEPTION_GROUPING} + use_new_schema: true + signozclickhousemetrics: + dsn: tcp://host.docker.internal:9000/signoz_metrics + clickhouselogsexporter: + dsn: tcp://host.docker.internal:9000/signoz_logs + timeout: 10s + use_new_schema: true + +service: + telemetry: + logs: + encoding: json + extensions: + - health_check + - pprof + pipelines: + traces: + receivers: [otlp] + processors: [signozspanmetrics/delta, batch] + exporters: [clickhousetraces] + metrics: + receivers: [otlp] + processors: [batch] + exporters: [signozclickhousemetrics] + metrics/prometheus: + receivers: [prometheus] + processors: [batch] + exporters: [signozclickhousemetrics] + logs: + receivers: [otlp] + processors: [batch] + exporters: [clickhouselogsexporter] \ No newline at end of file diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 38fc2a7821f..b525276f814 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -5,10 +5,83 @@ /frontend/ @SigNoz/frontend @YounixM /frontend/src/container/MetricsApplication @srikanthccv /frontend/src/container/NewWidget/RightContainer/types.ts @srikanthccv + +# Dashboard, Alert, Metrics, Service Map, Services +/frontend/src/container/ListOfDashboard/ @srikanthccv +/frontend/src/container/NewDashboard/ @srikanthccv +/frontend/src/pages/DashboardsListPage/ @srikanthccv +/frontend/src/pages/DashboardWidget/ @srikanthccv +/frontend/src/pages/NewDashboard/ @srikanthccv +/frontend/src/providers/Dashboard/ @srikanthccv + +# Alerts +/frontend/src/container/AlertHistory/ @srikanthccv +/frontend/src/container/AllAlertChannels/ @srikanthccv +/frontend/src/container/AnomalyAlertEvaluationView/ @srikanthccv +/frontend/src/container/CreateAlertChannels/ @srikanthccv +/frontend/src/container/CreateAlertRule/ @srikanthccv +/frontend/src/container/EditAlertChannels/ @srikanthccv +/frontend/src/container/FormAlertChannels/ @srikanthccv +/frontend/src/container/FormAlertRules/ @srikanthccv +/frontend/src/container/ListAlertRules/ @srikanthccv +/frontend/src/container/TriggeredAlerts/ @srikanthccv +/frontend/src/pages/AlertChannelCreate/ @srikanthccv +/frontend/src/pages/AlertDetails/ @srikanthccv +/frontend/src/pages/AlertHistory/ @srikanthccv +/frontend/src/pages/AlertList/ @srikanthccv +/frontend/src/pages/CreateAlert/ @srikanthccv +/frontend/src/providers/Alert.tsx @srikanthccv + +# Metrics +/frontend/src/container/MetricsExplorer/ @srikanthccv +/frontend/src/pages/MetricsApplication/ @srikanthccv +/frontend/src/pages/MetricsExplorer/ @srikanthccv + +# Services and Service Map +/frontend/src/container/ServiceApplication/ @srikanthccv +/frontend/src/container/ServiceTable/ @srikanthccv +/frontend/src/pages/Services/ @srikanthccv +/frontend/src/pages/ServiceTopLevelOperations/ @srikanthccv +/frontend/src/container/Home/Services/ @srikanthccv + /deploy/ @SigNoz/devops .github @SigNoz/devops + +# Scaffold Owners /pkg/config/ @grandwizard28 /pkg/errors/ @grandwizard28 /pkg/factory/ @grandwizard28 /pkg/types/ @grandwizard28 +/pkg/valuer/ @grandwizard28 +/cmd/ @grandwizard28 +.golangci.yml @grandwizard28 + +# Zeus Owners +/pkg/zeus/ @vikrantgupta25 +/ee/zeus/ @vikrantgupta25 +/pkg/licensing/ @vikrantgupta25 +/ee/licensing/ @vikrantgupta25 + +# SQL Owners /pkg/sqlmigration/ @vikrantgupta25 +/ee/sqlmigration/ @vikrantgupta25 +/pkg/sqlschema/ @vikrantgupta25 +/ee/sqlschema/ @vikrantgupta25 + +# Analytics Owners +/pkg/analytics/ @vikrantgupta25 +/pkg/statsreporter/ @vikrantgupta25 + +# Querier Owners +/pkg/querier/ @srikanthccv +/pkg/variables/ @srikanthccv +/pkg/types/querybuildertypes/ @srikanthccv +/pkg/querybuilder/ @srikanthccv +/pkg/telemetrylogs/ @srikanthccv +/pkg/telemetrymetadata/ @srikanthccv +/pkg/telemetrymetrics/ @srikanthccv +/pkg/telemetrytraces/ @srikanthccv + +# AuthN / AuthZ Owners + +/pkg/authz/ @vikrantgupta25 @grandwizard28 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 6260fd59406..0691744c322 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -32,9 +32,7 @@ ex: > Tag the relevant teams for review: -- [ ] @SigNoz/frontend -- [ ] @SigNoz/backend -- [ ] @SigNoz/devops +- frontend / backend / devops --- diff --git a/.github/workflows/build-community.yaml b/.github/workflows/build-community.yaml index a4f0ba93f15..d8d1df3a623 100644 --- a/.github/workflows/build-community.yaml +++ b/.github/workflows/build-community.yaml @@ -62,11 +62,11 @@ jobs: secrets: inherit with: PRIMUS_REF: main - GO_VERSION: 1.23 + GO_VERSION: 1.24 GO_NAME: signoz-community GO_INPUT_ARTIFACT_CACHE_KEY: community-jsbuild-${{ github.sha }} GO_INPUT_ARTIFACT_PATH: frontend/build - GO_BUILD_CONTEXT: ./pkg/query-service + GO_BUILD_CONTEXT: ./cmd/community GO_BUILD_FLAGS: >- -tags timetzdata -ldflags='-linkmode external -extldflags \"-static\" -s -w @@ -74,9 +74,10 @@ jobs: -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/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: ./pkg/query-service/Dockerfile.multi-arch + DOCKER_DOCKERFILE_PATH: ./cmd/community/Dockerfile.multi-arch DOCKER_MANIFEST: true DOCKER_PROVIDERS: dockerhub diff --git a/.github/workflows/build-enterprise.yaml b/.github/workflows/build-enterprise.yaml index 4031abed106..96d239a3877 100644 --- a/.github/workflows/build-enterprise.yaml +++ b/.github/workflows/build-enterprise.yaml @@ -67,9 +67,8 @@ jobs: echo 'TUNNEL_URL="${{ secrets.TUNNEL_URL }}"' >> frontend/.env echo 'TUNNEL_DOMAIN="${{ secrets.TUNNEL_DOMAIN }}"' >> frontend/.env echo 'POSTHOG_KEY="${{ secrets.POSTHOG_KEY }}"' >> frontend/.env - echo 'CUSTOMERIO_ID="${{ secrets.CUSTOMERIO_ID }}"' >> frontend/.env - echo 'CUSTOMERIO_SITE_ID="${{ secrets.CUSTOMERIO_SITE_ID }}"' >> frontend/.env - echo 'USERPILOT_KEY="${{ secrets.USERPILOT_KEY }}"' >> frontend/.env + echo 'PYLON_APP_ID="${{ secrets.PYLON_APP_ID }}"' >> frontend/.env + echo 'APPCUES_APP_ID="${{ secrets.APPCUES_APP_ID }}"' >> frontend/.env - name: cache-dotenv uses: actions/cache@v4 with: @@ -94,10 +93,10 @@ jobs: secrets: inherit with: PRIMUS_REF: main - GO_VERSION: 1.23 + GO_VERSION: 1.24 GO_INPUT_ARTIFACT_CACHE_KEY: enterprise-jsbuild-${{ github.sha }} GO_INPUT_ARTIFACT_PATH: frontend/build - GO_BUILD_CONTEXT: ./ee/query-service + GO_BUILD_CONTEXT: ./cmd/enterprise GO_BUILD_FLAGS: >- -tags timetzdata -ldflags='-linkmode external -extldflags \"-static\" -s -w @@ -109,9 +108,10 @@ jobs: -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/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: ./ee/query-service/Dockerfile.multi-arch + DOCKER_DOCKERFILE_PATH: ./cmd/enterprise/Dockerfile.multi-arch DOCKER_MANIFEST: true DOCKER_PROVIDERS: ${{ needs.prepare.outputs.docker_providers }} diff --git a/.github/workflows/build-staging.yaml b/.github/workflows/build-staging.yaml index 0effc134928..271a270ac52 100644 --- a/.github/workflows/build-staging.yaml +++ b/.github/workflows/build-staging.yaml @@ -66,7 +66,8 @@ jobs: echo 'CI=1' > frontend/.env echo 'TUNNEL_URL="${{ secrets.NP_TUNNEL_URL }}"' >> frontend/.env echo 'TUNNEL_DOMAIN="${{ secrets.NP_TUNNEL_DOMAIN }}"' >> frontend/.env - echo 'USERPILOT_KEY="${{ secrets.NP_USERPILOT_KEY }}"' >> frontend/.env + echo 'PYLON_APP_ID="${{ secrets.NP_PYLON_APP_ID }}"' >> frontend/.env + echo 'APPCUES_APP_ID="${{ secrets.NP_APPCUES_APP_ID }}"' >> frontend/.env - name: cache-dotenv uses: actions/cache@v4 with: @@ -91,10 +92,10 @@ jobs: secrets: inherit with: PRIMUS_REF: main - GO_VERSION: 1.23 + GO_VERSION: 1.24 GO_INPUT_ARTIFACT_CACHE_KEY: staging-jsbuild-${{ github.sha }} GO_INPUT_ARTIFACT_PATH: frontend/build - GO_BUILD_CONTEXT: ./ee/query-service + GO_BUILD_CONTEXT: ./cmd/enterprise GO_BUILD_FLAGS: >- -tags timetzdata -ldflags='-linkmode external -extldflags \"-static\" -s -w @@ -106,10 +107,11 @@ jobs: -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/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: ./ee/query-service/Dockerfile.multi-arch + DOCKER_DOCKERFILE_PATH: ./cmd/enterprise/Dockerfile.multi-arch DOCKER_MANIFEST: true DOCKER_PROVIDERS: gcp staging: diff --git a/.github/workflows/goci.yaml b/.github/workflows/goci.yaml index 73956bbf566..bdafb9499ed 100644 --- a/.github/workflows/goci.yaml +++ b/.github/workflows/goci.yaml @@ -18,7 +18,7 @@ jobs: with: PRIMUS_REF: main GO_TEST_CONTEXT: ./... - GO_VERSION: 1.23 + GO_VERSION: 1.24 fmt: if: | (github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) || @@ -27,7 +27,7 @@ jobs: secrets: inherit with: PRIMUS_REF: main - GO_VERSION: 1.23 + GO_VERSION: 1.24 lint: if: | (github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) || @@ -36,7 +36,7 @@ jobs: secrets: inherit with: PRIMUS_REF: main - GO_VERSION: 1.23 + GO_VERSION: 1.24 deps: if: | (github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) || @@ -45,7 +45,7 @@ jobs: secrets: inherit with: PRIMUS_REF: main - GO_VERSION: 1.23 + GO_VERSION: 1.24 build: if: | (github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) || @@ -57,7 +57,7 @@ jobs: - name: go-install uses: actions/setup-go@v5 with: - go-version: "1.23" + go-version: "1.24" - name: qemu-install uses: docker/setup-qemu-action@v3 - name: aarch64-install diff --git a/.github/workflows/gor-signoz-community.yaml b/.github/workflows/gor-signoz-community.yaml index 5fb3ff1cb55..8a3ead31e39 100644 --- a/.github/workflows/gor-signoz-community.yaml +++ b/.github/workflows/gor-signoz-community.yaml @@ -36,7 +36,7 @@ jobs: - ubuntu-latest - macos-latest env: - CONFIG_PATH: pkg/query-service/.goreleaser.yaml + CONFIG_PATH: cmd/community/.goreleaser.yaml runs-on: ${{ matrix.os }} steps: - name: checkout @@ -58,7 +58,7 @@ jobs: - name: setup-go uses: actions/setup-go@v5 with: - go-version: "1.23" + go-version: "1.24" - name: cross-compilation-tools if: matrix.os == 'ubuntu-latest' run: | @@ -100,7 +100,7 @@ jobs: needs: build env: DOCKER_CLI_EXPERIMENTAL: "enabled" - WORKDIR: pkg/query-service + WORKDIR: cmd/community steps: - name: checkout uses: actions/checkout@v4 @@ -122,7 +122,7 @@ jobs: - name: setup-go uses: actions/setup-go@v5 with: - go-version: "1.23" + go-version: "1.24" # copy the caches from build - name: get-sha diff --git a/.github/workflows/gor-signoz.yaml b/.github/workflows/gor-signoz.yaml index a74f5aa92da..9ac4ab74a5d 100644 --- a/.github/workflows/gor-signoz.yaml +++ b/.github/workflows/gor-signoz.yaml @@ -33,9 +33,8 @@ jobs: echo 'TUNNEL_URL="${{ secrets.TUNNEL_URL }}"' >> .env echo 'TUNNEL_DOMAIN="${{ secrets.TUNNEL_DOMAIN }}"' >> .env echo 'POSTHOG_KEY="${{ secrets.POSTHOG_KEY }}"' >> .env - echo 'CUSTOMERIO_ID="${{ secrets.CUSTOMERIO_ID }}"' >> .env - echo 'CUSTOMERIO_SITE_ID="${{ secrets.CUSTOMERIO_SITE_ID }}"' >> .env - echo 'USERPILOT_KEY="${{ secrets.USERPILOT_KEY }}"' >> .env + echo 'PYLON_APP_ID="${{ secrets.PYLON_APP_ID }}"' >> .env + echo 'APPCUES_APP_ID="${{ secrets.APPCUES_APP_ID }}"' >> .env - name: build-frontend run: make js-build - name: upload-frontend-artifact @@ -51,7 +50,7 @@ jobs: - ubuntu-latest - macos-latest env: - CONFIG_PATH: ee/query-service/.goreleaser.yaml + CONFIG_PATH: cmd/enterprise/.goreleaser.yaml runs-on: ${{ matrix.os }} steps: - name: checkout @@ -73,7 +72,7 @@ jobs: - name: setup-go uses: actions/setup-go@v5 with: - go-version: "1.23" + go-version: "1.24" - name: cross-compilation-tools if: matrix.os == 'ubuntu-latest' run: | @@ -136,7 +135,7 @@ jobs: - name: setup-go uses: actions/setup-go@v5 with: - go-version: "1.23" + go-version: "1.24" # copy the caches from build - name: get-sha diff --git a/.github/workflows/integrationci.yaml b/.github/workflows/integrationci.yaml index 77e032ba42a..35efa27f6e3 100644 --- a/.github/workflows/integrationci.yaml +++ b/.github/workflows/integrationci.yaml @@ -15,14 +15,15 @@ jobs: matrix: src: - bootstrap + - auth + - querier sqlstore-provider: - postgres - sqlite clickhouse-version: - - 24.1.2-alpine - - 24.12-alpine + - 25.5.6 schema-migrator-version: - - v0.111.38 + - v0.129.6 postgres-version: - 15 if: | diff --git a/.github/workflows/prereleaser.yaml b/.github/workflows/prereleaser.yaml index 9f891dc0280..3bf16b03bc3 100644 --- a/.github/workflows/prereleaser.yaml +++ b/.github/workflows/prereleaser.yaml @@ -1,10 +1,6 @@ name: prereleaser on: - # schedule every wednesday 6:30 AM UTC (12:00 PM IST) - schedule: - - cron: '30 6 * * 3' - # allow manual triggering of the workflow by a maintainer workflow_dispatch: inputs: diff --git a/.github/workflows/run-e2e.yaml b/.github/workflows/run-e2e.yaml new file mode 100644 index 00000000000..3fef8663cc2 --- /dev/null +++ b/.github/workflows/run-e2e.yaml @@ -0,0 +1,62 @@ +name: e2eci + +on: + workflow_dispatch: + inputs: + userRole: + description: "Role of the user (ADMIN, EDITOR, VIEWER)" + required: true + type: choice + options: + - ADMIN + - EDITOR + - VIEWER + +jobs: + test: + name: Run Playwright Tests + runs-on: ubuntu-latest + timeout-minutes: 60 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: lts/* + + - name: Mask secrets and input + run: | + echo "::add-mask::${{ secrets.BASE_URL }}" + echo "::add-mask::${{ secrets.LOGIN_USERNAME }}" + echo "::add-mask::${{ secrets.LOGIN_PASSWORD }}" + echo "::add-mask::${{ github.event.inputs.userRole }}" + + - name: Install dependencies + working-directory: frontend + run: | + npm install -g yarn + yarn + + - name: Install Playwright Browsers + working-directory: frontend + run: yarn playwright install --with-deps + + - name: Run Playwright Tests + working-directory: frontend + run: | + BASE_URL="${{ secrets.BASE_URL }}" \ + LOGIN_USERNAME="${{ secrets.LOGIN_USERNAME }}" \ + LOGIN_PASSWORD="${{ secrets.LOGIN_PASSWORD }}" \ + USER_ROLE="${{ github.event.inputs.userRole }}" \ + yarn playwright test + + - name: Upload Playwright Report + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: frontend/playwright-report/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index 51076856927..c002fbe276c 100644 --- a/.gitignore +++ b/.gitignore @@ -66,6 +66,7 @@ e2e/.auth # go vendor/ **/main/** +__debug_bin** # git-town .git-branches.toml @@ -85,6 +86,8 @@ queries.active .devenv/**/tmp/** .qodo +.dev + ### Python ### # Byte-compiled / optimized / DLL files __pycache__/ @@ -227,4 +230,6 @@ poetry.toml # LSP config files pyrightconfig.json -# End of https://www.toptal.com/developers/gitignore/api/python \ No newline at end of file + +# cursor files +frontend/.cursor/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 00000000000..00643925fde --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,39 @@ +linters: + default: standard + enable: + - bodyclose + - 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/" diff --git a/ADVOCATE.md b/ADVOCATE.md new file mode 100644 index 00000000000..96cf65d6b5c --- /dev/null +++ b/ADVOCATE.md @@ -0,0 +1,62 @@ +# SigNoz Community Advocate Program + +Our community is filled with passionate developers who love SigNoz and have been helping spread the word about observability across the world. The SigNoz Community Advocate Program is our way of recognizing these incredible community members and creating deeper collaboration opportunities. + +## What is the SigNoz Community Advocate Program? + +The SigNoz Community Advocate Program celebrates and supports community members who are already passionate about observability and helping fellow developers. If you're someone who loves discussing SigNoz, helping others with their implementations, or sharing knowledge about observability practices, this program is designed with you in mind. + +Our advocates are the heart of the SigNoz community, helping other developers succeed with observability and providing valuable insights that help us build better products. + +## What Do Advocates Do? + +1. **Community Support** + + - Help fellow developers in our Slack community and GitHub Discussions + - Answer questions and share solutions + - Guide newcomers through SigNoz self-host implementations + +2. **Knowledge Sharing** + + - Spread awareness about observability best practices on developer forums + - Create content like blog posts, social media posts, and videos + - Host local meetups and events in their regions + +3. **Product Collaboration** + + - Provide insights on features, changes, and improvements the community needs + - Beta test new features and provide early feedback + - Help us understand real-world use cases and pain points + +## What's In It For You? + +**Recognition & Swag** + +- Official recognition as a SigNoz advocate +- Welcome hamper upon joining +- Exclusive swag box within your first 3 months +- Feature on our website (with your permission) + +**Early Access** + +- First look at new features and updates +- Direct line to the SigNoz team for feedback and suggestions +- Opportunity to influence product roadmap + +**Community Impact** + +- Help shape the observability landscape +- Build your reputation in the developer community +- Connect with like-minded developers globally + +## How Does It Work? + +Currently, the SigNoz Community Advocate Program is **invite-only**. We're starting with a small group of passionate community members who have already been making a difference. + +We'll be working closely with our first advocates to shape the program details, benefits, and structure based on what works best for everyone involved. + +If you're interested in learning more about the program or want to get more involved in the SigNoz community, join our [Slack community](https://signoz-community.slack.com/) and let us know! + +--- + +*The SigNoz Community Advocate Program recognizes and celebrates the amazing community members who are already passionate about helping fellow developers succeed with observability.* \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 42688328e57..8f49cccf606 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -78,3 +78,5 @@ Need assistance? Join our Slack community: - Set up your [development environment](docs/contributing/development.md) - Deploy and observe [SigNoz in action with OpenTelemetry Demo Application](docs/otel-demo-docs.md) +- Explore the [SigNoz Community Advocate Program](ADVOCATE.md), which recognises contributors who support the community, share their expertise, and help shape SigNoz's future. +- Write [integration tests](docs/contributing/go/integration.md) diff --git a/LICENSE b/LICENSE index 2fef891b370..7e1ae4f6bad 100644 --- a/LICENSE +++ b/LICENSE @@ -2,7 +2,7 @@ Copyright (c) 2020-present SigNoz Inc. Portions of this software are licensed as follows: -* All content that resides under the "ee/" directory of this repository, if that directory exists, is licensed under the license defined in "ee/LICENSE". +* All content that resides under the "ee/" and the "cmd/enterprise/" directory of this repository, if that directory exists, is licensed under the license defined in "ee/LICENSE". * All third party components incorporated into the SigNoz Software are licensed under the original license provided by the owner of the applicable component. * Content outside of the above mentioned directories or restrictions above is available under the "MIT Expat" license as defined below. diff --git a/Makefile b/Makefile index 31b1764b206..415dc82385d 100644 --- a/Makefile +++ b/Makefile @@ -20,18 +20,18 @@ GO_BUILD_LDFLAG_LICENSE_SIGNOZ_IO = -X github.com/SigNoz/signoz/ee/zeus.depreca GO_BUILD_VERSION_LDFLAGS = -X github.com/SigNoz/signoz/pkg/version.version=$(VERSION) -X github.com/SigNoz/signoz/pkg/version.hash=$(COMMIT_SHORT_SHA) -X github.com/SigNoz/signoz/pkg/version.time=$(TIMESTAMP) -X github.com/SigNoz/signoz/pkg/version.branch=$(BRANCH_NAME) GO_BUILD_ARCHS_COMMUNITY = $(addprefix go-build-community-,$(ARCHS)) -GO_BUILD_CONTEXT_COMMUNITY = $(SRC)/pkg/query-service +GO_BUILD_CONTEXT_COMMUNITY = $(SRC)/cmd/community GO_BUILD_LDFLAGS_COMMUNITY = $(GO_BUILD_VERSION_LDFLAGS) -X github.com/SigNoz/signoz/pkg/version.variant=community GO_BUILD_ARCHS_ENTERPRISE = $(addprefix go-build-enterprise-,$(ARCHS)) GO_BUILD_ARCHS_ENTERPRISE_RACE = $(addprefix go-build-enterprise-race-,$(ARCHS)) -GO_BUILD_CONTEXT_ENTERPRISE = $(SRC)/ee/query-service +GO_BUILD_CONTEXT_ENTERPRISE = $(SRC)/cmd/enterprise GO_BUILD_LDFLAGS_ENTERPRISE = $(GO_BUILD_VERSION_LDFLAGS) -X github.com/SigNoz/signoz/pkg/version.variant=enterprise $(GO_BUILD_LDFLAG_ZEUS_URL) $(GO_BUILD_LDFLAG_LICENSE_SIGNOZ_IO) DOCKER_BUILD_ARCHS_COMMUNITY = $(addprefix docker-build-community-,$(ARCHS)) -DOCKERFILE_COMMUNITY = $(SRC)/pkg/query-service/Dockerfile +DOCKERFILE_COMMUNITY = $(SRC)/cmd/community/Dockerfile DOCKER_REGISTRY_COMMUNITY ?= docker.io/signoz/signoz-community DOCKER_BUILD_ARCHS_ENTERPRISE = $(addprefix docker-build-enterprise-,$(ARCHS)) -DOCKERFILE_ENTERPRISE = $(SRC)/ee/query-service/Dockerfile +DOCKERFILE_ENTERPRISE = $(SRC)/cmd/enterprise/Dockerfile DOCKER_REGISTRY_ENTERPRISE ?= docker.io/signoz/signoz JS_BUILD_CONTEXT = $(SRC)/frontend @@ -61,6 +61,17 @@ devenv-postgres: ## Run postgres in devenv @cd .devenv/docker/postgres; \ docker compose -f compose.yaml up -d +.PHONY: devenv-signoz-otel-collector +devenv-signoz-otel-collector: ## Run signoz-otel-collector in devenv (requires clickhouse to be running) + @cd .devenv/docker/signoz-otel-collector; \ + docker compose -f compose.yaml up -d + +.PHONY: devenv-up +devenv-up: devenv-clickhouse devenv-signoz-otel-collector ## Start both clickhouse and signoz-otel-collector for local development + @echo "Development environment is ready!" + @echo " - ClickHouse: http://localhost:8123" + @echo " - Signoz OTel Collector: grpc://localhost:4317, http://localhost:4318" + ############################################################## # go commands ############################################################## @@ -74,7 +85,7 @@ go-run-enterprise: ## Runs the enterprise go backend server SIGNOZ_TELEMETRYSTORE_PROVIDER=clickhouse \ SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_DSN=tcp://127.0.0.1:9000 \ go run -race \ - $(GO_BUILD_CONTEXT_ENTERPRISE)/main.go \ + $(GO_BUILD_CONTEXT_ENTERPRISE)/*.go \ --config ./conf/prometheus.yml \ --cluster cluster @@ -92,7 +103,7 @@ go-run-community: ## Runs the community go backend server SIGNOZ_TELEMETRYSTORE_PROVIDER=clickhouse \ SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_DSN=tcp://127.0.0.1:9000 \ go run -race \ - $(GO_BUILD_CONTEXT_COMMUNITY)/main.go \ + $(GO_BUILD_CONTEXT_COMMUNITY)/*.go server \ --config ./conf/prometheus.yml \ --cluster cluster diff --git a/README.md b/README.md index 60f8621703a..b3c4c83d8f0 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,6 @@

All your logs, metrics, and traces in one place. Monitor your application, spot issues before they occur and troubleshoot downtime quickly with rich context. SigNoz is a cost-effective open-source alternative to Datadog and New Relic. Visit signoz.io for the full documentation, tutorials, and guide.

- Downloads GitHub issues tweet @@ -231,6 +230,8 @@ Not sure how to get started? Just ping us on `#contributing` in our [slack commu - [Shaheer Kochai](https://github.com/ahmadshaheer) - [Amlan Kumar Nandy](https://github.com/amlannandy) - [Sahil Khan](https://github.com/sawhil) +- [Aditya Singh](https://github.com/aks07) +- [Abhi Kumar](https://github.com/ahrefabhi) #### DevOps diff --git a/pkg/query-service/.goreleaser.yaml b/cmd/community/.goreleaser.yaml similarity index 93% rename from pkg/query-service/.goreleaser.yaml rename to cmd/community/.goreleaser.yaml index 8bd46312059..74d165d7f4f 100644 --- a/pkg/query-service/.goreleaser.yaml +++ b/cmd/community/.goreleaser.yaml @@ -11,7 +11,7 @@ before: builds: - id: signoz binary: bin/signoz - main: pkg/query-service/main.go + main: ./cmd/community env: - CGO_ENABLED=1 - >- @@ -35,6 +35,7 @@ builds: - -X github.com/SigNoz/signoz/pkg/version.hash={{ .ShortCommit }} - -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 }}" diff --git a/pkg/query-service/Dockerfile b/cmd/community/Dockerfile similarity index 73% rename from pkg/query-service/Dockerfile rename to cmd/community/Dockerfile index 043bfb0cd08..22433506f6a 100644 --- a/pkg/query-service/Dockerfile +++ b/cmd/community/Dockerfile @@ -11,11 +11,9 @@ RUN apk update && \ COPY ./target/${OS}-${TARGETARCH}/signoz-community /root/signoz -COPY ./conf/prometheus.yml /root/config/prometheus.yml COPY ./templates/email /root/templates COPY frontend/build/ /etc/signoz/web/ RUN chmod 755 /root /root/signoz -ENTRYPOINT ["./signoz"] -CMD ["-config", "/root/config/prometheus.yml"] +ENTRYPOINT ["./signoz", "server"] \ No newline at end of file diff --git a/pkg/query-service/Dockerfile.multi-arch b/cmd/community/Dockerfile.multi-arch similarity index 76% rename from pkg/query-service/Dockerfile.multi-arch rename to cmd/community/Dockerfile.multi-arch index dcfb738e949..3a6c479a604 100644 --- a/pkg/query-service/Dockerfile.multi-arch +++ b/cmd/community/Dockerfile.multi-arch @@ -12,11 +12,9 @@ RUN apk update && \ rm -rf /var/cache/apk/* COPY ./target/${OS}-${ARCH}/signoz-community /root/signoz-community -COPY ./conf/prometheus.yml /root/config/prometheus.yml COPY ./templates/email /root/templates COPY frontend/build/ /etc/signoz/web/ RUN chmod 755 /root /root/signoz-community -ENTRYPOINT ["./signoz-community"] -CMD ["-config", "/root/config/prometheus.yml"] +ENTRYPOINT ["./signoz-community", "server"] diff --git a/cmd/community/main.go b/cmd/community/main.go new file mode 100644 index 00000000000..e188635734d --- /dev/null +++ b/cmd/community/main.go @@ -0,0 +1,18 @@ +package main + +import ( + "log/slog" + + "github.com/SigNoz/signoz/cmd" + "github.com/SigNoz/signoz/pkg/instrumentation" +) + +func main() { + // initialize logger for logging in the cmd/ package. This logger is different from the logger used in the application. + logger := instrumentation.NewLogger(instrumentation.Config{Logs: instrumentation.LogsConfig{Level: slog.LevelInfo}}) + + // register a list of commands to the root command + registerServer(cmd.RootCmd, logger) + + cmd.Execute(logger) +} diff --git a/cmd/community/server.go b/cmd/community/server.go new file mode 100644 index 00000000000..a437b450c17 --- /dev/null +++ b/cmd/community/server.go @@ -0,0 +1,116 @@ +package main + +import ( + "context" + "log/slog" + "time" + + "github.com/SigNoz/signoz/cmd" + "github.com/SigNoz/signoz/ee/sqlstore/postgressqlstore" + "github.com/SigNoz/signoz/pkg/analytics" + "github.com/SigNoz/signoz/pkg/factory" + "github.com/SigNoz/signoz/pkg/licensing" + "github.com/SigNoz/signoz/pkg/licensing/nooplicensing" + "github.com/SigNoz/signoz/pkg/modules/organization" + "github.com/SigNoz/signoz/pkg/query-service/app" + "github.com/SigNoz/signoz/pkg/signoz" + "github.com/SigNoz/signoz/pkg/sqlschema" + "github.com/SigNoz/signoz/pkg/sqlstore" + "github.com/SigNoz/signoz/pkg/sqlstore/sqlstorehook" + "github.com/SigNoz/signoz/pkg/types/authtypes" + "github.com/SigNoz/signoz/pkg/version" + "github.com/SigNoz/signoz/pkg/zeus" + "github.com/SigNoz/signoz/pkg/zeus/noopzeus" + "github.com/spf13/cobra" +) + +func registerServer(parentCmd *cobra.Command, logger *slog.Logger) { + var flags signoz.DeprecatedFlags + + serverCmd := &cobra.Command{ + Use: "server", + Short: "Run the SigNoz server", + FParseErrWhitelist: cobra.FParseErrWhitelist{UnknownFlags: true}, + RunE: func(currCmd *cobra.Command, args []string) error { + config, err := cmd.NewSigNozConfig(currCmd.Context(), logger, flags) + if err != nil { + return err + } + + return runServer(currCmd.Context(), config, logger) + }, + } + + flags.RegisterFlags(serverCmd) + parentCmd.AddCommand(serverCmd) +} + +func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) error { + // print the version + version.Info.PrettyPrint(config.Version) + + // add enterprise sqlstore factories to the community sqlstore factories + sqlstoreFactories := signoz.NewSQLStoreProviderFactories() + if err := sqlstoreFactories.Add(postgressqlstore.NewFactory(sqlstorehook.NewLoggingFactory())); err != nil { + logger.ErrorContext(ctx, "failed to add postgressqlstore factory", "error", err) + return err + } + + jwt := authtypes.NewJWT(cmd.NewJWTSecret(ctx, logger), 30*time.Minute, 30*24*time.Hour) + + signoz, err := signoz.New( + ctx, + config, + jwt, + zeus.Config{}, + noopzeus.NewProviderFactory(), + licensing.Config{}, + func(_ sqlstore.SQLStore, _ zeus.Zeus, _ organization.Getter, _ analytics.Analytics) factory.ProviderFactory[licensing.Licensing, licensing.Config] { + return nooplicensing.NewFactory() + }, + signoz.NewEmailingProviderFactories(), + signoz.NewCacheProviderFactories(), + signoz.NewWebProviderFactories(), + func(sqlstore sqlstore.SQLStore) factory.NamedMap[factory.ProviderFactory[sqlschema.SQLSchema, sqlschema.Config]] { + return signoz.NewSQLSchemaProviderFactories(sqlstore) + }, + signoz.NewSQLStoreProviderFactories(), + signoz.NewTelemetryStoreProviderFactories(), + ) + if err != nil { + logger.ErrorContext(ctx, "failed to create signoz", "error", err) + return err + } + + server, err := app.NewServer(config, signoz, jwt) + if err != nil { + logger.ErrorContext(ctx, "failed to create server", "error", err) + return err + } + + if err := server.Start(ctx); err != nil { + logger.ErrorContext(ctx, "failed to start server", "error", err) + return err + } + + signoz.Start(ctx) + + if err := signoz.Wait(ctx); err != nil { + logger.ErrorContext(ctx, "failed to start signoz", "error", err) + return err + } + + err = server.Stop(ctx) + if err != nil { + logger.ErrorContext(ctx, "failed to stop server", "error", err) + return err + } + + err = signoz.Stop(ctx) + if err != nil { + logger.ErrorContext(ctx, "failed to stop signoz", "error", err) + return err + } + + return nil +} diff --git a/cmd/config.go b/cmd/config.go new file mode 100644 index 00000000000..206d9b44d4c --- /dev/null +++ b/cmd/config.go @@ -0,0 +1,41 @@ +package cmd + +import ( + "context" + "log/slog" + "os" + + "github.com/SigNoz/signoz/pkg/config" + "github.com/SigNoz/signoz/pkg/config/envprovider" + "github.com/SigNoz/signoz/pkg/config/fileprovider" + "github.com/SigNoz/signoz/pkg/signoz" +) + +func NewSigNozConfig(ctx context.Context, logger *slog.Logger, flags signoz.DeprecatedFlags) (signoz.Config, error) { + config, err := signoz.NewConfig( + ctx, + logger, + config.ResolverConfig{ + Uris: []string{"env:"}, + ProviderFactories: []config.ProviderFactory{ + envprovider.NewFactory(), + fileprovider.NewFactory(), + }, + }, + flags, + ) + if err != nil { + return signoz.Config{}, err + } + + return config, nil +} + +func NewJWTSecret(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/ee/query-service/.goreleaser.yaml b/cmd/enterprise/.goreleaser.yaml similarity index 94% rename from ee/query-service/.goreleaser.yaml rename to cmd/enterprise/.goreleaser.yaml index c4bf6cb011e..d5647b2f494 100644 --- a/ee/query-service/.goreleaser.yaml +++ b/cmd/enterprise/.goreleaser.yaml @@ -11,7 +11,7 @@ before: builds: - id: signoz binary: bin/signoz - main: ee/query-service/main.go + main: ./cmd/enterprise env: - CGO_ENABLED=1 - >- @@ -39,6 +39,7 @@ builds: - -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 }}" diff --git a/ee/query-service/Dockerfile b/cmd/enterprise/Dockerfile similarity index 72% rename from ee/query-service/Dockerfile rename to cmd/enterprise/Dockerfile index 1cd477222e4..798055afb1f 100644 --- a/ee/query-service/Dockerfile +++ b/cmd/enterprise/Dockerfile @@ -11,11 +11,9 @@ RUN apk update && \ COPY ./target/${OS}-${TARGETARCH}/signoz /root/signoz -COPY ./conf/prometheus.yml /root/config/prometheus.yml COPY ./templates/email /root/templates COPY frontend/build/ /etc/signoz/web/ RUN chmod 755 /root /root/signoz -ENTRYPOINT ["./signoz"] -CMD ["-config", "/root/config/prometheus.yml"] \ No newline at end of file +ENTRYPOINT ["./signoz", "server"] diff --git a/ee/query-service/Dockerfile.integration b/cmd/enterprise/Dockerfile.integration similarity index 71% rename from ee/query-service/Dockerfile.integration rename to cmd/enterprise/Dockerfile.integration index 40a76a5bbca..7e3b19e9d13 100644 --- a/ee/query-service/Dockerfile.integration +++ b/cmd/enterprise/Dockerfile.integration @@ -1,4 +1,12 @@ -FROM golang:1.23-bullseye +FROM node:18-bullseye AS build + +WORKDIR /opt/ +COPY ./frontend/ ./ +ENV NODE_OPTIONS=--max-old-space-size=8192 +RUN CI=1 yarn install +RUN CI=1 yarn build + +FROM golang:1.24-bullseye ARG OS="linux" ARG TARGETARCH @@ -23,6 +31,7 @@ COPY go.mod go.sum ./ RUN go mod download +COPY ./cmd/ ./cmd/ COPY ./ee/ ./ee/ COPY ./pkg/ ./pkg/ COPY ./templates/email /root/templates @@ -31,6 +40,8 @@ COPY Makefile Makefile RUN TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race RUN mv /root/linux-${TARGETARCH}/signoz /root/signoz +COPY --from=build /opt/build ./web/ + RUN chmod 755 /root /root/signoz -ENTRYPOINT ["/root/signoz"] +ENTRYPOINT ["/root/signoz", "server"] diff --git a/ee/query-service/Dockerfile.multi-arch b/cmd/enterprise/Dockerfile.multi-arch similarity index 76% rename from ee/query-service/Dockerfile.multi-arch rename to cmd/enterprise/Dockerfile.multi-arch index 2d3eec3d4b7..776548fcb9d 100644 --- a/ee/query-service/Dockerfile.multi-arch +++ b/cmd/enterprise/Dockerfile.multi-arch @@ -12,11 +12,9 @@ RUN apk update && \ rm -rf /var/cache/apk/* COPY ./target/${OS}-${ARCH}/signoz /root/signoz -COPY ./conf/prometheus.yml /root/config/prometheus.yml COPY ./templates/email /root/templates COPY frontend/build/ /etc/signoz/web/ RUN chmod 755 /root /root/signoz -ENTRYPOINT ["./signoz"] -CMD ["-config", "/root/config/prometheus.yml"] +ENTRYPOINT ["./signoz", "server"] diff --git a/cmd/enterprise/main.go b/cmd/enterprise/main.go new file mode 100644 index 00000000000..e188635734d --- /dev/null +++ b/cmd/enterprise/main.go @@ -0,0 +1,18 @@ +package main + +import ( + "log/slog" + + "github.com/SigNoz/signoz/cmd" + "github.com/SigNoz/signoz/pkg/instrumentation" +) + +func main() { + // initialize logger for logging in the cmd/ package. This logger is different from the logger used in the application. + logger := instrumentation.NewLogger(instrumentation.Config{Logs: instrumentation.LogsConfig{Level: slog.LevelInfo}}) + + // register a list of commands to the root command + registerServer(cmd.RootCmd, logger) + + cmd.Execute(logger) +} diff --git a/cmd/enterprise/server.go b/cmd/enterprise/server.go new file mode 100644 index 00000000000..b513e9a744b --- /dev/null +++ b/cmd/enterprise/server.go @@ -0,0 +1,124 @@ +package main + +import ( + "context" + "log/slog" + "time" + + "github.com/SigNoz/signoz/cmd" + enterpriselicensing "github.com/SigNoz/signoz/ee/licensing" + "github.com/SigNoz/signoz/ee/licensing/httplicensing" + enterpriseapp "github.com/SigNoz/signoz/ee/query-service/app" + "github.com/SigNoz/signoz/ee/sqlschema/postgressqlschema" + "github.com/SigNoz/signoz/ee/sqlstore/postgressqlstore" + enterprisezeus "github.com/SigNoz/signoz/ee/zeus" + "github.com/SigNoz/signoz/ee/zeus/httpzeus" + "github.com/SigNoz/signoz/pkg/analytics" + "github.com/SigNoz/signoz/pkg/factory" + "github.com/SigNoz/signoz/pkg/licensing" + "github.com/SigNoz/signoz/pkg/modules/organization" + "github.com/SigNoz/signoz/pkg/signoz" + "github.com/SigNoz/signoz/pkg/sqlschema" + "github.com/SigNoz/signoz/pkg/sqlstore" + "github.com/SigNoz/signoz/pkg/sqlstore/sqlstorehook" + "github.com/SigNoz/signoz/pkg/types/authtypes" + "github.com/SigNoz/signoz/pkg/version" + "github.com/SigNoz/signoz/pkg/zeus" + "github.com/spf13/cobra" +) + +func registerServer(parentCmd *cobra.Command, logger *slog.Logger) { + var flags signoz.DeprecatedFlags + + serverCmd := &cobra.Command{ + Use: "server", + Short: "Run the SigNoz server", + FParseErrWhitelist: cobra.FParseErrWhitelist{UnknownFlags: true}, + RunE: func(currCmd *cobra.Command, args []string) error { + config, err := cmd.NewSigNozConfig(currCmd.Context(), logger, flags) + if err != nil { + return err + } + + return runServer(currCmd.Context(), config, logger) + }, + } + + flags.RegisterFlags(serverCmd) + parentCmd.AddCommand(serverCmd) +} + +func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) error { + // print the version + version.Info.PrettyPrint(config.Version) + + // add enterprise sqlstore factories to the community sqlstore factories + sqlstoreFactories := signoz.NewSQLStoreProviderFactories() + if err := sqlstoreFactories.Add(postgressqlstore.NewFactory(sqlstorehook.NewLoggingFactory())); err != nil { + logger.ErrorContext(ctx, "failed to add postgressqlstore factory", "error", err) + return err + } + + jwt := authtypes.NewJWT(cmd.NewJWTSecret(ctx, logger), 30*time.Minute, 30*24*time.Hour) + + signoz, err := signoz.New( + ctx, + config, + jwt, + enterprisezeus.Config(), + httpzeus.NewProviderFactory(), + enterpriselicensing.Config(24*time.Hour, 3), + func(sqlstore sqlstore.SQLStore, zeus zeus.Zeus, orgGetter organization.Getter, analytics analytics.Analytics) factory.ProviderFactory[licensing.Licensing, licensing.Config] { + return httplicensing.NewProviderFactory(sqlstore, zeus, orgGetter, analytics) + }, + signoz.NewEmailingProviderFactories(), + signoz.NewCacheProviderFactories(), + signoz.NewWebProviderFactories(), + func(sqlstore sqlstore.SQLStore) factory.NamedMap[factory.ProviderFactory[sqlschema.SQLSchema, sqlschema.Config]] { + existingFactories := signoz.NewSQLSchemaProviderFactories(sqlstore) + if err := existingFactories.Add(postgressqlschema.NewFactory(sqlstore)); err != nil { + panic(err) + } + + return existingFactories + }, + sqlstoreFactories, + signoz.NewTelemetryStoreProviderFactories(), + ) + if err != nil { + logger.ErrorContext(ctx, "failed to create signoz", "error", err) + return err + } + + server, err := enterpriseapp.NewServer(config, signoz, jwt) + if err != nil { + logger.ErrorContext(ctx, "failed to create server", "error", err) + return err + } + + if err := server.Start(ctx); err != nil { + logger.ErrorContext(ctx, "failed to start server", "error", err) + return err + } + + signoz.Start(ctx) + + if err := signoz.Wait(ctx); err != nil { + logger.ErrorContext(ctx, "failed to start signoz", "error", err) + return err + } + + err = server.Stop(ctx) + if err != nil { + logger.ErrorContext(ctx, "failed to stop server", "error", err) + return err + } + + err = signoz.Stop(ctx) + if err != nil { + logger.ErrorContext(ctx, "failed to stop signoz", "error", err) + return err + } + + return nil +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 00000000000..a080b376450 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,33 @@ +package cmd + +import ( + "log/slog" + "os" + + "github.com/SigNoz/signoz/pkg/version" + "github.com/spf13/cobra" + "go.uber.org/zap" //nolint:depguard +) + +var RootCmd = &cobra.Command{ + Use: "signoz", + Short: "OpenTelemetry-Native Logs, Metrics and Traces in a single pane", + Version: version.Info.Version(), + SilenceUsage: true, + SilenceErrors: true, + CompletionOptions: cobra.CompletionOptions{DisableDefaultCmd: true}, +} + +func Execute(logger *slog.Logger) { + zapLogger := newZapLogger() + zap.ReplaceGlobals(zapLogger) + defer func() { + _ = zapLogger.Sync() + }() + + err := RootCmd.Execute() + if err != nil { + logger.ErrorContext(RootCmd.Context(), "error running command", "error", err) + os.Exit(1) + } +} diff --git a/cmd/zap.go b/cmd/zap.go new file mode 100644 index 00000000000..4f043eaf6bc --- /dev/null +++ b/cmd/zap.go @@ -0,0 +1,15 @@ +package cmd + +import ( + "go.uber.org/zap" //nolint:depguard + "go.uber.org/zap/zapcore" //nolint:depguard +) + +// Deprecated: Use `NewLogger` from `pkg/instrumentation` instead. +func newZapLogger() *zap.Logger { + config := zap.NewProductionConfig() + config.EncoderConfig.TimeKey = "timestamp" + config.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder + logger, _ := config.Build() + return logger +} diff --git a/conf/example.yaml b/conf/example.yaml index 1ee4a2eeeae..d22fa37cab0 100644 --- a/conf/example.yaml +++ b/conf/example.yaml @@ -90,6 +90,15 @@ apiserver: - /api/v1/version - / +##################### Querier ##################### +querier: + # The TTL for cached query results. + cache_ttl: 168h + # The interval for recent data that should not be cached. + flux_interval: 5m + # The maximum number of concurrent queries for missing ranges. + max_concurrent_queries: 4 + ##################### TelemetryStore ##################### telemetrystore: # Maximum number of idle connections in the connection pool. @@ -103,13 +112,17 @@ telemetrystore: clickhouse: # The DSN to use for clickhouse. dsn: tcp://localhost:9000 + # The cluster name to use for clickhouse. + cluster: cluster # The query settings for clickhouse. settings: max_execution_time: 0 max_execution_time_leaf: 0 timeout_before_checking_execution_speed: 0 max_bytes_to_read: 0 - max_result_rows_for_ch_query: 0 + max_result_rows: 0 + ignore_data_skipping_indices: "" + secondary_indices_enable_bulk_filtering: false ##################### Prometheus ##################### prometheus: @@ -124,10 +137,7 @@ prometheus: ##################### Alertmanager ##################### alertmanager: # Specifies the alertmanager provider to use. - provider: legacy - legacy: - # The API URL (with prefix) of the legacy Alertmanager instance. - api_url: http://localhost:9093/api + provider: signoz signoz: # The poll interval for periodically syncing the alertmanager with the config in the store. poll_interval: 1m @@ -165,8 +175,71 @@ alertmanager: # Retention of the notification logs. retention: 120h +##################### Emailing ##################### +emailing: + # Whether to enable emailing. + enabled: false + templates: + # The directory containing the email templates. This directory should contain a list of files defined at pkg/types/emailtypes/template.go. + directory: /opt/signoz/conf/templates/email + smtp: + # The SMTP server address. + address: localhost:25 + # The email address to use for the SMTP server. + from: + # The hello message to use for the SMTP server. + hello: + # The static headers to send with the email. + headers: {} + auth: + # The username to use for the SMTP server. + username: + # The password to use for the SMTP server. + password: + # The secret to use for the SMTP server. + secret: + # The identity to use for the SMTP server. + identity: + tls: + # Whether to enable TLS. It should be false in most cases since the authentication mechanism should use the STARTTLS extension instead. + enabled: false + # Whether to skip TLS verification. + insecure_skip_verify: false + # The path to the CA file. + ca_file_path: + # The path to the key file. + key_file_path: + # The path to the certificate file. + cert_file_path: + +##################### Sharder (experimental) ##################### +sharder: + # Specifies the sharder provider to use. + provider: noop + single: + # The org id to which this instance belongs to. + org_id: org_id ##################### Analytics ##################### analytics: # Whether to enable analytics. enabled: false + segment: + # The key to use for segment. + key: "" + +##################### StatsReporter ##################### +statsreporter: + # Whether to enable stats reporter. This is used to provide valuable insights to the SigNoz team. It does not collect any sensitive/PII data. + enabled: true + # The interval at which the stats are collected. + interval: 6h + collect: + # Whether to collect identities and traits (emails). + identities: true + + +##################### Gateway (License only) ##################### +gateway: + # The URL of the gateway's api. + url: http://localhost:8080 diff --git a/deploy/docker-swarm/docker-compose.ha.yaml b/deploy/docker-swarm/docker-compose.ha.yaml index a29ac8e94f7..56537d57db3 100644 --- a/deploy/docker-swarm/docker-compose.ha.yaml +++ b/deploy/docker-swarm/docker-compose.ha.yaml @@ -11,7 +11,7 @@ x-common: &common max-file: "3" x-clickhouse-defaults: &clickhouse-defaults !!merge <<: *common - image: clickhouse/clickhouse-server:24.1.2-alpine + image: clickhouse/clickhouse-server:25.5.6 tty: true deploy: labels: @@ -37,9 +37,11 @@ x-clickhouse-defaults: &clickhouse-defaults nofile: soft: 262144 hard: 262144 + environment: + - CLICKHOUSE_SKIP_USER_SETUP=1 x-zookeeper-defaults: &zookeeper-defaults !!merge <<: *common - image: bitnami/zookeeper:3.7.1 + image: signoz/zookeeper:3.7.1 user: root deploy: labels: @@ -63,7 +65,7 @@ x-db-depend: &db-depend services: init-clickhouse: !!merge <<: *common - image: clickhouse/clickhouse-server:24.1.2-alpine + image: clickhouse/clickhouse-server:25.5.6 command: - bash - -c @@ -174,7 +176,7 @@ services: # - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml signoz: !!merge <<: *db-depend - image: signoz/signoz:v0.84.1 + image: signoz/signoz:v0.96.1 command: - --config=/root/config/prometheus.yml ports: @@ -194,6 +196,7 @@ services: - TELEMETRY_ENABLED=true - DEPLOYMENT_TYPE=docker-swarm - SIGNOZ_JWT_SECRET=secret + - DOT_METRICS_ENABLED=true healthcheck: test: - CMD @@ -206,7 +209,7 @@ services: retries: 3 otel-collector: !!merge <<: *db-depend - image: signoz/signoz-otel-collector:v0.111.41 + image: signoz/signoz-otel-collector:v0.129.6 command: - --config=/etc/otel-collector-config.yaml - --manager-config=/etc/manager-config.yaml @@ -230,7 +233,7 @@ services: - signoz schema-migrator: !!merge <<: *common - image: signoz/signoz-schema-migrator:v0.111.41 + image: signoz/signoz-schema-migrator:v0.129.6 deploy: restart_policy: condition: on-failure diff --git a/deploy/docker-swarm/docker-compose.yaml b/deploy/docker-swarm/docker-compose.yaml index d7f28a70798..46f136b7aa8 100644 --- a/deploy/docker-swarm/docker-compose.yaml +++ b/deploy/docker-swarm/docker-compose.yaml @@ -11,7 +11,7 @@ x-common: &common max-file: "3" x-clickhouse-defaults: &clickhouse-defaults !!merge <<: *common - image: clickhouse/clickhouse-server:24.1.2-alpine + image: clickhouse/clickhouse-server:25.5.6 tty: true deploy: labels: @@ -36,9 +36,11 @@ x-clickhouse-defaults: &clickhouse-defaults nofile: soft: 262144 hard: 262144 + environment: + - CLICKHOUSE_SKIP_USER_SETUP=1 x-zookeeper-defaults: &zookeeper-defaults !!merge <<: *common - image: bitnami/zookeeper:3.7.1 + image: signoz/zookeeper:3.7.1 user: root deploy: labels: @@ -60,7 +62,7 @@ x-db-depend: &db-depend services: init-clickhouse: !!merge <<: *common - image: clickhouse/clickhouse-server:24.1.2-alpine + image: clickhouse/clickhouse-server:25.5.6 command: - bash - -c @@ -100,26 +102,32 @@ services: # - "9000:9000" # - "8123:8123" # - "9181:9181" + + configs: + - source: clickhouse-config + target: /etc/clickhouse-server/config.xml + - source: clickhouse-users + target: /etc/clickhouse-server/users.xml + - source: clickhouse-custom-function + target: /etc/clickhouse-server/custom-function.xml + - source: clickhouse-cluster + target: /etc/clickhouse-server/config.d/cluster.xml volumes: - - ../common/clickhouse/config.xml:/etc/clickhouse-server/config.xml - - ../common/clickhouse/users.xml:/etc/clickhouse-server/users.xml - - ../common/clickhouse/custom-function.xml:/etc/clickhouse-server/custom-function.xml - - ../common/clickhouse/user_scripts:/var/lib/clickhouse/user_scripts/ - - ../common/clickhouse/cluster.xml:/etc/clickhouse-server/config.d/cluster.xml - clickhouse:/var/lib/clickhouse/ # - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml signoz: !!merge <<: *db-depend - image: signoz/signoz:v0.84.1 + image: signoz/signoz:v0.96.1 command: - --config=/root/config/prometheus.yml ports: - "8080:8080" # signoz port # - "6060:6060" # pprof port volumes: - - ../common/signoz/prometheus.yml:/root/config/prometheus.yml - - ../common/dashboards:/root/config/dashboards - sqlite:/var/lib/signoz/ + configs: + - source: signoz-prometheus-config + target: /root/config/prometheus.yml environment: - SIGNOZ_ALERTMANAGER_PROVIDER=signoz - SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_DSN=tcp://clickhouse:9000 @@ -129,6 +137,7 @@ services: - GODEBUG=netdns=go - TELEMETRY_ENABLED=true - DEPLOYMENT_TYPE=docker-swarm + - DOT_METRICS_ENABLED=true healthcheck: test: - CMD @@ -141,15 +150,17 @@ services: retries: 3 otel-collector: !!merge <<: *db-depend - image: signoz/signoz-otel-collector:v0.111.41 + image: signoz/signoz-otel-collector:v0.129.6 command: - --config=/etc/otel-collector-config.yaml - --manager-config=/etc/manager-config.yaml - --copy-path=/var/tmp/collector-config.yaml - --feature-gates=-pkg.translator.prometheus.NormalizeName - volumes: - - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml - - ../common/signoz/otel-collector-opamp-config.yaml:/etc/manager-config.yaml + configs: + - source: otel-collector-config + target: /etc/otel-collector-config.yaml + - source: otel-manager-config + target: /etc/manager-config.yaml environment: - OTEL_RESOURCE_ATTRIBUTES=host.name={{.Node.Hostname}},os.type={{.Node.Platform.OS}} - LOW_CARDINAL_EXCEPTION_GROUPING=false @@ -165,7 +176,7 @@ services: - signoz schema-migrator: !!merge <<: *common - image: signoz/signoz-schema-migrator:v0.111.41 + image: signoz/signoz-schema-migrator:v0.129.6 deploy: restart_policy: condition: on-failure @@ -186,3 +197,24 @@ volumes: name: signoz-sqlite zookeeper-1: name: signoz-zookeeper-1 +configs: + clickhouse-config: + file: ../common/clickhouse/config.xml + clickhouse-users: + file: ../common/clickhouse/users.xml + clickhouse-custom-function: + file: ../common/clickhouse/custom-function.xml + clickhouse-cluster: + file: ../common/clickhouse/cluster.xml + signoz-prometheus-config: + file: ../common/signoz/prometheus.yml + # If you have multiple dashboard files, you can list them individually: + # dashboard-foo: + # file: ../common/dashboards/foo.json + # dashboard-bar: + # file: ../common/dashboards/bar.json + + otel-collector-config: + file: ./otel-collector-config.yaml + otel-manager-config: + file: ../common/signoz/otel-collector-opamp-config.yaml diff --git a/deploy/docker-swarm/otel-collector-config.yaml b/deploy/docker-swarm/otel-collector-config.yaml index 48e28238f13..48baa16b34c 100644 --- a/deploy/docker-swarm/otel-collector-config.yaml +++ b/deploy/docker-swarm/otel-collector-config.yaml @@ -26,7 +26,7 @@ processors: detectors: [env, system] timeout: 2s signozspanmetrics/delta: - metrics_exporter: clickhousemetricswrite, signozclickhousemetrics + metrics_exporter: signozclickhousemetrics metrics_flush_interval: 60s latency_histogram_buckets: [100us, 1ms, 2ms, 6ms, 10ms, 50ms, 100ms, 250ms, 500ms, 1000ms, 1400ms, 2000ms, 5s, 10s, 20s, 40s, 60s ] dimensions_cache_size: 100000 @@ -60,27 +60,16 @@ exporters: datasource: tcp://clickhouse:9000/signoz_traces low_cardinal_exception_grouping: ${env:LOW_CARDINAL_EXCEPTION_GROUPING} use_new_schema: true - clickhousemetricswrite: - endpoint: tcp://clickhouse:9000/signoz_metrics - resource_to_telemetry_conversion: - enabled: true - disable_v2: true - clickhousemetricswrite/prometheus: - endpoint: tcp://clickhouse:9000/signoz_metrics - disable_v2: true signozclickhousemetrics: dsn: tcp://clickhouse:9000/signoz_metrics clickhouselogsexporter: dsn: tcp://clickhouse:9000/signoz_logs timeout: 10s use_new_schema: true - # debug: {} service: telemetry: logs: encoding: json - metrics: - address: 0.0.0.0:8888 extensions: - health_check - pprof @@ -92,11 +81,11 @@ service: metrics: receivers: [otlp] processors: [batch] - exporters: [clickhousemetricswrite, signozclickhousemetrics] + exporters: [signozclickhousemetrics] metrics/prometheus: receivers: [prometheus] processors: [batch] - exporters: [clickhousemetricswrite/prometheus, signozclickhousemetrics] + exporters: [signozclickhousemetrics] logs: receivers: [otlp] processors: [batch] diff --git a/deploy/docker/docker-compose.ha.yaml b/deploy/docker/docker-compose.ha.yaml index 6dfc5f16b2f..2faeed24fef 100644 --- a/deploy/docker/docker-compose.ha.yaml +++ b/deploy/docker/docker-compose.ha.yaml @@ -10,7 +10,7 @@ x-common: &common x-clickhouse-defaults: &clickhouse-defaults !!merge <<: *common # addding non LTS version due to this fix https://github.com/ClickHouse/ClickHouse/commit/32caf8716352f45c1b617274c7508c86b7d1afab - image: clickhouse/clickhouse-server:24.1.2-alpine + image: clickhouse/clickhouse-server:25.5.6 tty: true labels: signoz.io/scrape: "true" @@ -40,9 +40,11 @@ x-clickhouse-defaults: &clickhouse-defaults nofile: soft: 262144 hard: 262144 + environment: + - CLICKHOUSE_SKIP_USER_SETUP=1 x-zookeeper-defaults: &zookeeper-defaults !!merge <<: *common - image: bitnami/zookeeper:3.7.1 + image: signoz/zookeeper:3.7.1 user: root labels: signoz.io/scrape: "true" @@ -65,7 +67,7 @@ x-db-depend: &db-depend services: init-clickhouse: !!merge <<: *common - image: clickhouse/clickhouse-server:24.1.2-alpine + image: clickhouse/clickhouse-server:25.5.6 container_name: signoz-init-clickhouse command: - bash @@ -177,7 +179,7 @@ services: # - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml signoz: !!merge <<: *db-depend - image: signoz/signoz:${VERSION:-v0.84.1} + image: signoz/signoz:${VERSION:-v0.96.1} container_name: signoz command: - --config=/root/config/prometheus.yml @@ -197,6 +199,7 @@ services: - GODEBUG=netdns=go - TELEMETRY_ENABLED=true - DEPLOYMENT_TYPE=docker-standalone-amd + - DOT_METRICS_ENABLED=true healthcheck: test: - CMD @@ -210,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.111.41} + image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.6} container_name: signoz-otel-collector command: - --config=/etc/otel-collector-config.yaml @@ -236,7 +239,7 @@ services: condition: service_healthy schema-migrator-sync: !!merge <<: *common - image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.111.41} + image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.6} container_name: schema-migrator-sync command: - sync @@ -247,7 +250,7 @@ services: condition: service_healthy schema-migrator-async: !!merge <<: *db-depend - image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.111.41} + image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.6} container_name: schema-migrator-async command: - async diff --git a/deploy/docker/docker-compose.yaml b/deploy/docker/docker-compose.yaml index 473c7693e39..66e7433b768 100644 --- a/deploy/docker/docker-compose.yaml +++ b/deploy/docker/docker-compose.yaml @@ -9,8 +9,7 @@ x-common: &common max-file: "3" x-clickhouse-defaults: &clickhouse-defaults !!merge <<: *common - # addding non LTS version due to this fix https://github.com/ClickHouse/ClickHouse/commit/32caf8716352f45c1b617274c7508c86b7d1afab - image: clickhouse/clickhouse-server:24.1.2-alpine + image: clickhouse/clickhouse-server:25.5.6 tty: true labels: signoz.io/scrape: "true" @@ -36,9 +35,11 @@ x-clickhouse-defaults: &clickhouse-defaults nofile: soft: 262144 hard: 262144 + environment: + - CLICKHOUSE_SKIP_USER_SETUP=1 x-zookeeper-defaults: &zookeeper-defaults !!merge <<: *common - image: bitnami/zookeeper:3.7.1 + image: signoz/zookeeper:3.7.1 user: root labels: signoz.io/scrape: "true" @@ -61,7 +62,7 @@ x-db-depend: &db-depend services: init-clickhouse: !!merge <<: *common - image: clickhouse/clickhouse-server:24.1.2-alpine + image: clickhouse/clickhouse-server:25.5.6 container_name: signoz-init-clickhouse command: - bash @@ -110,7 +111,7 @@ services: # - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml signoz: !!merge <<: *db-depend - image: signoz/signoz:${VERSION:-v0.84.1} + image: signoz/signoz:${VERSION:-v0.96.1} container_name: signoz command: - --config=/root/config/prometheus.yml @@ -130,6 +131,7 @@ services: - GODEBUG=netdns=go - TELEMETRY_ENABLED=true - DEPLOYMENT_TYPE=docker-standalone-amd + - DOT_METRICS_ENABLED=true healthcheck: test: - CMD @@ -142,7 +144,7 @@ services: retries: 3 otel-collector: !!merge <<: *db-depend - image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.111.41} + image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.6} container_name: signoz-otel-collector command: - --config=/etc/otel-collector-config.yaml @@ -164,7 +166,7 @@ services: condition: service_healthy schema-migrator-sync: !!merge <<: *common - image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.111.41} + image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.6} container_name: schema-migrator-sync command: - sync @@ -176,7 +178,7 @@ services: restart: on-failure schema-migrator-async: !!merge <<: *db-depend - image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.111.41} + image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.6} container_name: schema-migrator-async command: - async diff --git a/deploy/docker/otel-collector-config.yaml b/deploy/docker/otel-collector-config.yaml index 9830a122360..48baa16b34c 100644 --- a/deploy/docker/otel-collector-config.yaml +++ b/deploy/docker/otel-collector-config.yaml @@ -26,7 +26,7 @@ processors: detectors: [env, system] timeout: 2s signozspanmetrics/delta: - metrics_exporter: clickhousemetricswrite, signozclickhousemetrics + metrics_exporter: signozclickhousemetrics metrics_flush_interval: 60s latency_histogram_buckets: [100us, 1ms, 2ms, 6ms, 10ms, 50ms, 100ms, 250ms, 500ms, 1000ms, 1400ms, 2000ms, 5s, 10s, 20s, 40s, 60s ] dimensions_cache_size: 100000 @@ -60,27 +60,16 @@ exporters: datasource: tcp://clickhouse:9000/signoz_traces low_cardinal_exception_grouping: ${env:LOW_CARDINAL_EXCEPTION_GROUPING} use_new_schema: true - clickhousemetricswrite: - endpoint: tcp://clickhouse:9000/signoz_metrics - disable_v2: true - resource_to_telemetry_conversion: - enabled: true - clickhousemetricswrite/prometheus: - endpoint: tcp://clickhouse:9000/signoz_metrics - disable_v2: true signozclickhousemetrics: dsn: tcp://clickhouse:9000/signoz_metrics clickhouselogsexporter: dsn: tcp://clickhouse:9000/signoz_logs timeout: 10s use_new_schema: true - # debug: {} service: telemetry: logs: encoding: json - metrics: - address: 0.0.0.0:8888 extensions: - health_check - pprof @@ -92,11 +81,11 @@ service: metrics: receivers: [otlp] processors: [batch] - exporters: [clickhousemetricswrite, signozclickhousemetrics] + exporters: [signozclickhousemetrics] metrics/prometheus: receivers: [prometheus] processors: [batch] - exporters: [clickhousemetricswrite/prometheus, signozclickhousemetrics] + exporters: [signozclickhousemetrics] logs: receivers: [otlp] processors: [batch] diff --git a/docs/contributing/development.md b/docs/contributing/development.md index abbd2ce45dc..41f51567b1b 100644 --- a/docs/contributing/development.md +++ b/docs/contributing/development.md @@ -44,20 +44,35 @@ Before diving in, make sure you have these tools installed: SigNoz has three main components: Clickhouse, Backend, and Frontend. Let's set them up one by one. -### 1. Setting up Clickhouse +### 1. Setting up ClickHouse -First, we need to get Clickhouse running: +First, we need to get ClickHouse running: ```bash make devenv-clickhouse ``` This command: -- Starts Clickhouse in a single-shard, single-replica cluster +- Starts ClickHouse in a single-shard, single-replica cluster - Sets up Zookeeper - Runs the latest schema migrations -### 2. Starting the Backend +### 2. Setting up SigNoz OpenTelemetry Collector + +Next, start the OpenTelemetry Collector to receive telemetry data: + +```bash +make devenv-signoz-otel-collector +``` + +This command: +- Starts the SigNoz OpenTelemetry Collector +- Listens on port 4317 (gRPC) and 4318 (HTTP) for incoming telemetry data +- Forwards data to ClickHouse for storage + +> 💡 **Quick Setup**: Use `make devenv-up` to start both ClickHouse and OTel Collector together + +### 3. Starting the Backend 1. Run the backend server: ```bash @@ -73,19 +88,24 @@ This command: > 💡 **Tip**: The API server runs at `http://localhost:8080/` by default -### 3. Setting up the Frontend +### 4. Setting up the Frontend -1. Install dependencies: +1. Navigate to the frontend directory: + ```bash + cd frontend + ``` + +2. Install dependencies: ```bash yarn install ``` -2. Create a `.env` file in the `frontend` directory: +3. Create a `.env` file in this directory: ```env FRONTEND_API_ENDPOINT=http://localhost:8080 ``` -3. Start the development server: +4. Start the development server: ```bash yarn dev ``` @@ -93,3 +113,25 @@ This command: > 💡 **Tip**: `yarn dev` will automatically rebuild when you make changes to the code Now you're all set to start developing! Happy coding! 🎉 + +## Verifying Your Setup +To verify everything is working correctly: + +1. **Check ClickHouse**: `curl http://localhost:8123/ping` (should return "Ok.") +2. **Check OTel Collector**: `curl http://localhost:13133` (should return health status) +3. **Check Backend**: `curl http://localhost:8080/api/v1/health` (should return `{"status":"ok"}`) +4. **Check Frontend**: Open `http://localhost:3301` in your browser + +## How to send test data? + +You can now send telemetry data to your local SigNoz instance: + +- **OTLP gRPC**: `localhost:4317` +- **OTLP HTTP**: `localhost:4318` + +For example, using `curl` to send a test trace: +```bash +curl -X POST http://localhost:4318/v1/traces \ + -H "Content-Type: application/json" \ + -d '{"resourceSpans":[{"resource":{"attributes":[{"key":"service.name","value":{"stringValue":"test-service"}}]},"scopeSpans":[{"spans":[{"traceId":"12345678901234567890123456789012","spanId":"1234567890123456","name":"test-span","startTimeUnixNano":"1609459200000000000","endTimeUnixNano":"1609459201000000000"}]}]}]}' +``` diff --git a/docs/contributing/go/endpoint.md b/docs/contributing/go/endpoint.md new file mode 100644 index 00000000000..7c17a20b637 --- /dev/null +++ b/docs/contributing/go/endpoint.md @@ -0,0 +1,51 @@ +# Endpoint + +This guide outlines the recommended approach for designing endpoints, with a focus on entity relationships, RESTful structure, and examples from the codebase. + +## How do we design an endpoint? + +### Understand the core entities and their relationships + +Start with understanding the core entities and their relationships. For example: + +- **Organization**: an organization can have multiple users + +### Structure Endpoints RESTfully + +Endpoints should reflect the resource hierarchy and follow RESTful conventions. Use clear, **pluralized resource names** and versioning. For example: + +- `POST /v1/organizations` — Create an organization +- `GET /v1/organizations/:id` — Get an organization by id +- `DELETE /v1/organizations/:id` — Delete an organization by id +- `PUT /v1/organizations/:id` — Update an organization by id +- `GET /v1/organizations/:id/users` — Get all users in an organization +- `GET /v1/organizations/me/users` — Get all users in my organization + +Think in terms of resource navigation in a file system. For example, to find your organization, you would navigate to the root of the file system and then to the `organizations` directory. To find a user in an organization, you would navigate to the `organizations` directory and then to the `id` directory. + +```bash +v1/ +├── organizations/ +│ └── 123/ +│ └── users/ +``` + +`me` endpoints are special. They are used to determine the actual id via some auth/external mechanism. For `me` endpoints, think of the `me` directory being symlinked to your organization directory. For example, if you are a part of the organization `123`, the `me` directory will be symlinked to `/v1/organizations/123`: + +```bash +v1/ +├── organizations/ +│ └── me/ -> symlink to /v1/organizations/123 +│ └── users/ +│ └── 123/ +│ └── users/ +``` + +> 💡 **Note**: There are various ways to structure endpoints. Some prefer to use singular resource names instead of `me`. Others prefer to use singular resource names for all endpoints. We have, however, chosen to standardize our endpoints in the manner described above. + +## What should I remember? + +- Use clear, **plural resource names** +- Use `me` endpoints for determining the actual id via some auth mechanism + +> 💡 **Note**: When in doubt, diagram the relationships and walk through the user flows as if navigating a file system. This will help you design endpoints that are both logical and user-friendly. diff --git a/docs/contributing/go/integration.md b/docs/contributing/go/integration.md new file mode 100644 index 00000000000..980f4f23ec2 --- /dev/null +++ b/docs/contributing/go/integration.md @@ -0,0 +1,213 @@ +# Integration Tests + +SigNoz uses integration tests to verify that different components work together correctly in a real environment. These tests run against actual services (ClickHouse, PostgreSQL, etc.) to ensure end-to-end functionality. + +## How to set up the integration test environment? + +### Prerequisites + +Before running integration tests, ensure you have the following installed: + +- Python 3.13+ +- Poetry (for dependency management) +- Docker (for containerized services) + +### Initial Setup + +1. Navigate to the integration tests directory: +```bash +cd tests/integration +``` + +2. Install dependencies using Poetry: +```bash +poetry install --no-root +``` + +### Starting the Test Environment + +To spin up all the containers necessary for writing integration tests and keep them running: + +```bash +poetry run pytest --basetemp=./tmp/ -vv --reuse src/bootstrap/setup.py::test_setup +``` + +This command will: +- Start all required services (ClickHouse, PostgreSQL, Zookeeper, etc.) +- Keep containers running due to the `--reuse` flag +- Verify that the setup is working correctly + +### Stopping the Test Environment + +When you're done writing integration tests, clean up the environment: + +```bash +poetry run pytest --basetemp=./tmp/ -vv --teardown -s src/bootstrap/setup.py::test_teardown +``` + +This will destroy the running integration test setup and clean up resources. + +## Understanding the Integration Test Framework + +Python and pytest form the foundation of the integration testing framework. Testcontainers are used to spin up disposable integration environments. Wiremock is used to spin up **test doubles** of other services. + +- **Why Python/pytest?** It's expressive, low-boilerplate, and has powerful fixture capabilities that make integration testing straightforward. Extensive libraries for HTTP requests, JSON handling, and data analysis (numpy) make it easier to test APIs and verify data +- **Why testcontainers?** They let us spin up isolated dependencies that match our production environment without complex setup. +- **Why wiremock?** Well maintained, documented and extensible. + +``` +. +├── conftest.py +├── fixtures +│ ├── __init__.py +│ ├── auth.py +│ ├── clickhouse.py +│ ├── fs.py +│ ├── http.py +│ ├── migrator.py +│ ├── network.py +│ ├── postgres.py +│ ├── signoz.py +│ ├── sql.py +│ ├── sqlite.py +│ ├── types.py +│ └── zookeeper.py +├── poetry.lock +├── pyproject.toml +└── src + └── bootstrap + ├── __init__.py + ├── a_database.py + ├── b_register.py + └── c_license.py +``` + +Each test suite follows some important principles: + +1. **Organization**: Test suites live under `src/` in self-contained packages. Fixtures (a pytest concept) live inside `fixtures/`. +2. **Execution Order**: Files are prefixed with `a_`, `b_`, `c_` to ensure sequential execution. +3. **Time Constraints**: Each suite should complete in under 10 minutes (setup takes ~4 mins). + +### Test Suite Design + +Test suites should target functional domains or subsystems within SigNoz. When designing a test suite, consider these principles: + +- **Functional Cohesion**: Group tests around a specific capability or service boundary +- **Data Flow**: Follow the path of data through related components +- **Change Patterns**: Components frequently modified together should be tested together + +The exact boundaries for modules are intentionally flexible, allowing teams to define logical groupings based on their specific context and knowledge of the system. + +Eg: The **bootstrap** integration test suite validates core system functionality: + +- Database initialization +- Version check + +Other test suites can be **pipelines, auth, querier.** + +## How to write an integration test? + +Now start writing an integration test. Create a new file `src/bootstrap/e_version.py` and paste the following: + +```python +import requests + +from fixtures import types +from fixtures.logger import setup_logger + +logger = setup_logger(__name__) + +def test_version(signoz: types.SigNoz) -> None: + response = requests.get(signoz.self.host_config.get("/api/v1/version"), timeout=2) + logger.info(response) +``` + +We have written a simple test which calls the `version` endpoint of the container in step 1. In **order to just run this function, run the following command:** + +```bash +poetry run pytest --basetemp=./tmp/ -vv --reuse src/bootstrap/e_version.py::test_version +``` + +> Note: The `--reuse` flag is used to reuse the environment if it is already running. Always use this flag when writing and running integration tests. If you don't use this flag, the environment will be destroyed and recreated every time you run the test. + +Here's another example of how to write a more comprehensive integration test: + +```python +from http import HTTPStatus +import requests +from fixtures import types +from fixtures.logger import setup_logger + +logger = setup_logger(__name__) + +def test_user_registration(signoz: types.SigNoz) -> None: + """Test user registration functionality.""" + response = requests.post( + signoz.self.host_configs["8080"].get("/api/v1/register"), + json={ + "name": "testuser", + "orgId": "", + "orgName": "test.org", + "email": "test@example.com", + "password": "password123Z$", + }, + timeout=2, + ) + + assert response.status_code == HTTPStatus.OK + assert response.json()["setupCompleted"] is True +``` + +## How to run integration tests? + +### Running All Tests + +```bash +poetry run pytest --basetemp=./tmp/ -vv --reuse src/ +``` + +### Running Specific Test Categories + +```bash +poetry run pytest --basetemp=./tmp/ -vv --reuse src/ + +# Run querier tests +poetry run pytest --basetemp=./tmp/ -vv --reuse src/querier/ +# Run auth tests +poetry run pytest --basetemp=./tmp/ -vv --reuse src/auth/ +``` + +### Running Individual Tests + +```bash +poetry run pytest --basetemp=./tmp/ -vv --reuse src//.py::test_name + +# Run test_register in file a_register.py in auth suite +poetry run pytest --basetemp=./tmp/ -vv --reuse src/auth/a_register.py::test_register +``` + +## How to configure different options for integration tests? + +Tests can be configured using pytest options: + +- `--sqlstore-provider` - Choose database provider (default: postgres) +- `--postgres-version` - PostgreSQL version (default: 15) +- `--clickhouse-version` - ClickHouse version (default: 25.5.6) +- `--zookeeper-version` - Zookeeper version (default: 3.7.1) + +Example: +```bash +poetry run pytest --basetemp=./tmp/ -vv --reuse --sqlstore-provider=postgres --postgres-version=14 src/auth/ +``` + + +## What should I remember? + +- **Always use the `--reuse` flag** when setting up the environment to keep containers running +- **Use the `--teardown` flag** when cleaning up to avoid resource leaks +- **Follow the naming convention** with alphabetical prefixes for test execution order +- **Use proper timeouts** in HTTP requests to avoid hanging tests +- **Clean up test data** between tests to avoid interference +- **Use descriptive test names** that clearly indicate what is being tested +- **Leverage fixtures** for common setup and authentication +- **Test both success and failure scenarios** to ensure robust functionality diff --git a/docs/contributing/go/provider.md b/docs/contributing/go/provider.md new file mode 100644 index 00000000000..1270dc318eb --- /dev/null +++ b/docs/contributing/go/provider.md @@ -0,0 +1,106 @@ +# Provider + +SigNoz is built on the provider pattern, a design approach where code is organized into providers that handle specific application responsibilities. Providers act as adapter components that integrate with external services and deliver required functionality to the application. + +> 💡 **Note**: Coming from a DDD background? Providers are similar (not exactly the same) to adapter/infrastructure services. + +## How to create a new provider? + +To create a new provider, create a directory in the `pkg/` directory named after your provider. The provider package consists of four key components: + +- **Interface** (`pkg//.go`): Defines the provider's interface. Other packages should import this interface to use the provider. +- **Config** (`pkg//config.go`): Contains provider configuration, implementing the `factory.Config` interface from [factory/config.go](/pkg/factory/config.go). +- **Implementation** (`pkg///provider.go`): Contains the provider implementation, including a `NewProvider` function that returns a `factory.Provider` interface from [factory/provider.go](/pkg/factory/provider.go). +- **Mock** (`pkg//test.go`): Provides mocks for the provider, typically used by dependent packages for unit testing. + +For example, the [prometheus](/pkg/prometheus) provider delivers a prometheus engine to the application: + +- `pkg/prometheus/prometheus.go` - Interface definition +- `pkg/prometheus/config.go` - Configuration +- `pkg/prometheus/clickhouseprometheus/provider.go` - Clickhouse-powered implementation +- `pkg/prometheus/prometheustest/provider.go` - Mock implementation + +## How to wire it up? + +The `pkg/signoz` package contains the inversion of control container responsible for wiring providers. It handles instantiation, configuration, and assembly of providers based on configuration metadata. + +> 💡 **Note**: Coming from a Java background? Providers are similar to Spring beans. + +Wiring up a provider involves three steps: + +1. Wiring up the configuration +Add your config from `pkg//config.go` to the `pkg/signoz/config.Config` struct and in new factories: + +```go +type Config struct { + ... + MyProvider myprovider.Config `mapstructure:"myprovider"` + ... +} + +func NewConfig(ctx context.Context, resolverConfig config.ResolverConfig, ....) (Config, error) { + ... + configFactories := []factory.ConfigFactory{ + myprovider.NewConfigFactory(), + } + ... +} +``` + +2. Wiring up the provider +Add available provider implementations in `pkg/signoz/provider.go`: + +```go +func NewMyProviderFactories() factory.NamedMap[factory.ProviderFactory[myprovider.MyProvider, myprovider.Config]] { + return factory.MustNewNamedMap( + myproviderone.NewFactory(), + myprovidertwo.NewFactory(), + ) +} +``` + +3. Instantiate the provider by adding it to the `SigNoz` struct in `pkg/signoz/signoz.go`: + +```go +type SigNoz struct { + ... + MyProvider myprovider.MyProvider + ... +} + +func New(...) (*SigNoz, error) { + ... + myprovider, err := myproviderone.New(ctx, settings, config.MyProvider, "one/two") + if err != nil { + return nil, err + } + ... +} +``` + +## How to use it? + +To use a provider, import its interface. For example, to use the prometheus provider, import `pkg/prometheus/prometheus.go`: + +```go +import "github.com/SigNoz/signoz/pkg/prometheus/prometheus" + +func CreateSomething(ctx context.Context, prometheus prometheus.Prometheus) { + ... + prometheus.DoSomething() + ... +} +``` + +## Why do we need this? + +Like any dependency injection framework, providers decouple the codebase from implementation details. This is especially valuable in SigNoz's large codebase, where we need to swap implementations without changing dependent code. The provider pattern offers several benefits apart from the obvious one of decoupling: + +- Configuration is **defined with each provider and centralized in one place**, making it easier to understand and manage through various methods (environment variables, config files, etc.) +- Provider mocking is **straightforward for unit testing**, with a consistent pattern for locating mocks +- **Multiple implementations** of the same provider are **supported**, as demonstrated by our sqlstore provider + +## What should I remember? + +- Use the provider pattern wherever applicable. +- Always create a provider **irrespective of the number of implementations**. This makes it easier to add new implementations in the future. diff --git a/docs/otel-demo-docs.md b/docs/otel-demo-docs.md index af989efbfdc..2c0fc92ec52 100644 --- a/docs/otel-demo-docs.md +++ b/docs/otel-demo-docs.md @@ -16,7 +16,7 @@ __Table of Contents__ - [Prerequisites](#prerequisites-1) - [Install Helm Repo and Charts](#install-helm-repo-and-charts) - [Start the OpenTelemetry Demo App](#start-the-opentelemetry-demo-app-1) - - [Moniitor with SigNoz (Kubernetes)](#monitor-with-signoz-kubernetes) + - [Monitor with SigNoz (Kubernetes)](#monitor-with-signoz-kubernetes) - [What's next](#whats-next) diff --git a/ee/anomaly/daily.go b/ee/anomaly/daily.go new file mode 100644 index 00000000000..7dd93952c33 --- /dev/null +++ b/ee/anomaly/daily.go @@ -0,0 +1,34 @@ +package anomaly + +import ( + "context" + + "github.com/SigNoz/signoz/pkg/valuer" +) + +type DailyProvider struct { + BaseSeasonalProvider +} + +var _ BaseProvider = (*DailyProvider)(nil) + +func (dp *DailyProvider) GetBaseSeasonalProvider() *BaseSeasonalProvider { + return &dp.BaseSeasonalProvider +} + +func NewDailyProvider(opts ...GenericProviderOption[*DailyProvider]) *DailyProvider { + dp := &DailyProvider{ + BaseSeasonalProvider: BaseSeasonalProvider{}, + } + + for _, opt := range opts { + opt(dp) + } + + return dp +} + +func (p *DailyProvider) GetAnomalies(ctx context.Context, orgID valuer.UUID, req *AnomaliesRequest) (*AnomaliesResponse, error) { + req.Seasonality = SeasonalityDaily + return p.getAnomalies(ctx, orgID, req) +} diff --git a/ee/anomaly/hourly.go b/ee/anomaly/hourly.go new file mode 100644 index 00000000000..95a21bdaa59 --- /dev/null +++ b/ee/anomaly/hourly.go @@ -0,0 +1,35 @@ +package anomaly + +import ( + "context" + + "github.com/SigNoz/signoz/pkg/valuer" +) + +type HourlyProvider struct { + BaseSeasonalProvider +} + +var _ BaseProvider = (*HourlyProvider)(nil) + +func (hp *HourlyProvider) GetBaseSeasonalProvider() *BaseSeasonalProvider { + return &hp.BaseSeasonalProvider +} + +// NewHourlyProvider now uses the generic option type +func NewHourlyProvider(opts ...GenericProviderOption[*HourlyProvider]) *HourlyProvider { + hp := &HourlyProvider{ + BaseSeasonalProvider: BaseSeasonalProvider{}, + } + + for _, opt := range opts { + opt(hp) + } + + return hp +} + +func (p *HourlyProvider) GetAnomalies(ctx context.Context, orgID valuer.UUID, req *AnomaliesRequest) (*AnomaliesResponse, error) { + req.Seasonality = SeasonalityHourly + return p.getAnomalies(ctx, orgID, req) +} diff --git a/ee/anomaly/params.go b/ee/anomaly/params.go new file mode 100644 index 00000000000..9a1aa8a71ce --- /dev/null +++ b/ee/anomaly/params.go @@ -0,0 +1,223 @@ +package anomaly + +import ( + "time" + + qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5" + "github.com/SigNoz/signoz/pkg/valuer" +) + +type Seasonality struct{ valuer.String } + +var ( + SeasonalityHourly = Seasonality{valuer.NewString("hourly")} + SeasonalityDaily = Seasonality{valuer.NewString("daily")} + SeasonalityWeekly = Seasonality{valuer.NewString("weekly")} +) + +var ( + oneWeekOffset = uint64(24 * 7 * time.Hour.Milliseconds()) + oneDayOffset = uint64(24 * time.Hour.Milliseconds()) + oneHourOffset = uint64(time.Hour.Milliseconds()) + fiveMinOffset = uint64(5 * time.Minute.Milliseconds()) +) + +func (s Seasonality) IsValid() bool { + switch s { + case SeasonalityHourly, SeasonalityDaily, SeasonalityWeekly: + return true + default: + return false + } +} + +type AnomaliesRequest struct { + Params qbtypes.QueryRangeRequest + Seasonality Seasonality +} + +type AnomaliesResponse struct { + Results []*qbtypes.TimeSeriesData +} + +// anomalyParams is the params for anomaly detection +// prediction = avg(past_period_query) + avg(current_season_query) - mean(past_season_query, past2_season_query, past3_season_query) +// +// ^ ^ +// | | +// (rounded value for past peiod) + (seasonal growth) +// +// score = abs(value - prediction) / stddev (current_season_query) +type anomalyQueryParams struct { + // CurrentPeriodQuery is the query range params for period user is looking at or eval window + // Example: (now-5m, now), (now-30m, now), (now-1h, now) + // The results obtained from this query are used to compare with predicted values + // and to detect anomalies + CurrentPeriodQuery qbtypes.QueryRangeRequest + // PastPeriodQuery is the query range params for past period of seasonality + // Example: For weekly seasonality, (now-1w-5m, now-1w) + // : For daily seasonality, (now-1d-5m, now-1d) + // : For hourly seasonality, (now-1h-5m, now-1h) + PastPeriodQuery qbtypes.QueryRangeRequest + // CurrentSeasonQuery is the query range params for current period (seasonal) + // Example: For weekly seasonality, this is the query range params for the (now-1w-5m, now) + // : For daily seasonality, this is the query range params for the (now-1d-5m, now) + // : For hourly seasonality, this is the query range params for the (now-1h-5m, now) + CurrentSeasonQuery qbtypes.QueryRangeRequest + // PastSeasonQuery is the query range params for past seasonal period to the current season + // Example: For weekly seasonality, this is the query range params for the (now-2w-5m, now-1w) + // : For daily seasonality, this is the query range params for the (now-2d-5m, now-1d) + // : For hourly seasonality, this is the query range params for the (now-2h-5m, now-1h) + PastSeasonQuery qbtypes.QueryRangeRequest + // Past2SeasonQuery is the query range params for past 2 seasonal period to the current season + // Example: For weekly seasonality, this is the query range params for the (now-3w-5m, now-2w) + // : For daily seasonality, this is the query range params for the (now-3d-5m, now-2d) + // : For hourly seasonality, this is the query range params for the (now-3h-5m, now-2h) + Past2SeasonQuery qbtypes.QueryRangeRequest + // Past3SeasonQuery is the query range params for past 3 seasonal period to the current season + // Example: For weekly seasonality, this is the query range params for the (now-4w-5m, now-3w) + // : For daily seasonality, this is the query range params for the (now-4d-5m, now-3d) + // : For hourly seasonality, this is the query range params for the (now-4h-5m, now-3h) + Past3SeasonQuery qbtypes.QueryRangeRequest +} + +func prepareAnomalyQueryParams(req qbtypes.QueryRangeRequest, seasonality Seasonality) *anomalyQueryParams { + start := req.Start + end := req.End + + currentPeriodQuery := qbtypes.QueryRangeRequest{ + Start: start, + End: end, + RequestType: qbtypes.RequestTypeTimeSeries, + CompositeQuery: req.CompositeQuery, + NoCache: false, + } + + var pastPeriodStart, pastPeriodEnd uint64 + + switch seasonality { + // for one week period, we fetch the data from the past week with 5 min offset + case SeasonalityWeekly: + pastPeriodStart = start - oneWeekOffset - fiveMinOffset + pastPeriodEnd = end - oneWeekOffset + // for one day period, we fetch the data from the past day with 5 min offset + case SeasonalityDaily: + pastPeriodStart = start - oneDayOffset - fiveMinOffset + pastPeriodEnd = end - oneDayOffset + // for one hour period, we fetch the data from the past hour with 5 min offset + case SeasonalityHourly: + pastPeriodStart = start - oneHourOffset - fiveMinOffset + pastPeriodEnd = end - oneHourOffset + } + + pastPeriodQuery := qbtypes.QueryRangeRequest{ + Start: pastPeriodStart, + End: pastPeriodEnd, + RequestType: qbtypes.RequestTypeTimeSeries, + CompositeQuery: req.CompositeQuery, + NoCache: false, + } + + // seasonality growth trend + var currentGrowthPeriodStart, currentGrowthPeriodEnd uint64 + switch seasonality { + case SeasonalityWeekly: + currentGrowthPeriodStart = start - oneWeekOffset + currentGrowthPeriodEnd = start + case SeasonalityDaily: + currentGrowthPeriodStart = start - oneDayOffset + currentGrowthPeriodEnd = start + case SeasonalityHourly: + currentGrowthPeriodStart = start - oneHourOffset + currentGrowthPeriodEnd = start + } + + currentGrowthQuery := qbtypes.QueryRangeRequest{ + Start: currentGrowthPeriodStart, + End: currentGrowthPeriodEnd, + RequestType: qbtypes.RequestTypeTimeSeries, + CompositeQuery: req.CompositeQuery, + NoCache: false, + } + + var pastGrowthPeriodStart, pastGrowthPeriodEnd uint64 + switch seasonality { + case SeasonalityWeekly: + pastGrowthPeriodStart = start - 2*oneWeekOffset + pastGrowthPeriodEnd = start - 1*oneWeekOffset + case SeasonalityDaily: + pastGrowthPeriodStart = start - 2*oneDayOffset + pastGrowthPeriodEnd = start - 1*oneDayOffset + case SeasonalityHourly: + pastGrowthPeriodStart = start - 2*oneHourOffset + pastGrowthPeriodEnd = start - 1*oneHourOffset + } + + pastGrowthQuery := qbtypes.QueryRangeRequest{ + Start: pastGrowthPeriodStart, + End: pastGrowthPeriodEnd, + RequestType: qbtypes.RequestTypeTimeSeries, + CompositeQuery: req.CompositeQuery, + NoCache: false, + } + + var past2GrowthPeriodStart, past2GrowthPeriodEnd uint64 + switch seasonality { + case SeasonalityWeekly: + past2GrowthPeriodStart = start - 3*oneWeekOffset + past2GrowthPeriodEnd = start - 2*oneWeekOffset + case SeasonalityDaily: + past2GrowthPeriodStart = start - 3*oneDayOffset + past2GrowthPeriodEnd = start - 2*oneDayOffset + case SeasonalityHourly: + past2GrowthPeriodStart = start - 3*oneHourOffset + past2GrowthPeriodEnd = start - 2*oneHourOffset + } + + past2GrowthQuery := qbtypes.QueryRangeRequest{ + Start: past2GrowthPeriodStart, + End: past2GrowthPeriodEnd, + RequestType: qbtypes.RequestTypeTimeSeries, + CompositeQuery: req.CompositeQuery, + NoCache: false, + } + + var past3GrowthPeriodStart, past3GrowthPeriodEnd uint64 + switch seasonality { + case SeasonalityWeekly: + past3GrowthPeriodStart = start - 4*oneWeekOffset + past3GrowthPeriodEnd = start - 3*oneWeekOffset + case SeasonalityDaily: + past3GrowthPeriodStart = start - 4*oneDayOffset + past3GrowthPeriodEnd = start - 3*oneDayOffset + case SeasonalityHourly: + past3GrowthPeriodStart = start - 4*oneHourOffset + past3GrowthPeriodEnd = start - 3*oneHourOffset + } + + past3GrowthQuery := qbtypes.QueryRangeRequest{ + Start: past3GrowthPeriodStart, + End: past3GrowthPeriodEnd, + RequestType: qbtypes.RequestTypeTimeSeries, + CompositeQuery: req.CompositeQuery, + NoCache: false, + } + + return &anomalyQueryParams{ + CurrentPeriodQuery: currentPeriodQuery, + PastPeriodQuery: pastPeriodQuery, + CurrentSeasonQuery: currentGrowthQuery, + PastSeasonQuery: pastGrowthQuery, + Past2SeasonQuery: past2GrowthQuery, + Past3SeasonQuery: past3GrowthQuery, + } +} + +type anomalyQueryResults struct { + CurrentPeriodResults []*qbtypes.TimeSeriesData + PastPeriodResults []*qbtypes.TimeSeriesData + CurrentSeasonResults []*qbtypes.TimeSeriesData + PastSeasonResults []*qbtypes.TimeSeriesData + Past2SeasonResults []*qbtypes.TimeSeriesData + Past3SeasonResults []*qbtypes.TimeSeriesData +} diff --git a/ee/anomaly/provider.go b/ee/anomaly/provider.go new file mode 100644 index 00000000000..7d0686006f5 --- /dev/null +++ b/ee/anomaly/provider.go @@ -0,0 +1,11 @@ +package anomaly + +import ( + "context" + + "github.com/SigNoz/signoz/pkg/valuer" +) + +type Provider interface { + GetAnomalies(ctx context.Context, orgID valuer.UUID, req *AnomaliesRequest) (*AnomaliesResponse, error) +} diff --git a/ee/anomaly/seasonal.go b/ee/anomaly/seasonal.go new file mode 100644 index 00000000000..6636188b6d0 --- /dev/null +++ b/ee/anomaly/seasonal.go @@ -0,0 +1,463 @@ +package anomaly + +import ( + "context" + "log/slog" + "math" + + "github.com/SigNoz/signoz/pkg/querier" + "github.com/SigNoz/signoz/pkg/valuer" + + qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5" +) + +var ( + // TODO(srikanthccv): make this configurable? + movingAvgWindowSize = 7 +) + +// BaseProvider is an interface that includes common methods for all provider types +type BaseProvider interface { + GetBaseSeasonalProvider() *BaseSeasonalProvider +} + +// GenericProviderOption is a generic type for provider options +type GenericProviderOption[T BaseProvider] func(T) + +func WithQuerier[T BaseProvider](querier querier.Querier) GenericProviderOption[T] { + return func(p T) { + p.GetBaseSeasonalProvider().querier = querier + } +} + +func WithLogger[T BaseProvider](logger *slog.Logger) GenericProviderOption[T] { + return func(p T) { + p.GetBaseSeasonalProvider().logger = logger + } +} + +type BaseSeasonalProvider struct { + querier querier.Querier + logger *slog.Logger +} + +func (p *BaseSeasonalProvider) getQueryParams(req *AnomaliesRequest) *anomalyQueryParams { + if !req.Seasonality.IsValid() { + req.Seasonality = SeasonalityDaily + } + return prepareAnomalyQueryParams(req.Params, req.Seasonality) +} + +func (p *BaseSeasonalProvider) toTSResults(ctx context.Context, resp *qbtypes.QueryRangeResponse) []*qbtypes.TimeSeriesData { + + tsData := []*qbtypes.TimeSeriesData{} + + if resp == nil { + p.logger.InfoContext(ctx, "nil response from query range") + return tsData + } + + for _, item := range resp.Data.Results { + if resultData, ok := item.(*qbtypes.TimeSeriesData); ok { + tsData = append(tsData, resultData) + } + } + + return tsData +} + +func (p *BaseSeasonalProvider) getResults(ctx context.Context, orgID valuer.UUID, params *anomalyQueryParams) (*anomalyQueryResults, error) { + // TODO(srikanthccv): parallelize this? + p.logger.InfoContext(ctx, "fetching results for current period", "anomaly_current_period_query", params.CurrentPeriodQuery) + currentPeriodResults, err := p.querier.QueryRange(ctx, orgID, ¶ms.CurrentPeriodQuery) + if err != nil { + return nil, err + } + + p.logger.InfoContext(ctx, "fetching results for past period", "anomaly_past_period_query", params.PastPeriodQuery) + pastPeriodResults, err := p.querier.QueryRange(ctx, orgID, ¶ms.PastPeriodQuery) + if err != nil { + return nil, err + } + + p.logger.InfoContext(ctx, "fetching results for current season", "anomaly_current_season_query", params.CurrentSeasonQuery) + currentSeasonResults, err := p.querier.QueryRange(ctx, orgID, ¶ms.CurrentSeasonQuery) + if err != nil { + return nil, err + } + + p.logger.InfoContext(ctx, "fetching results for past season", "anomaly_past_season_query", params.PastSeasonQuery) + pastSeasonResults, err := p.querier.QueryRange(ctx, orgID, ¶ms.PastSeasonQuery) + if err != nil { + return nil, err + } + + p.logger.InfoContext(ctx, "fetching results for past 2 season", "anomaly_past_2season_query", params.Past2SeasonQuery) + past2SeasonResults, err := p.querier.QueryRange(ctx, orgID, ¶ms.Past2SeasonQuery) + if err != nil { + return nil, err + } + + p.logger.InfoContext(ctx, "fetching results for past 3 season", "anomaly_past_3season_query", params.Past3SeasonQuery) + past3SeasonResults, err := p.querier.QueryRange(ctx, orgID, ¶ms.Past3SeasonQuery) + if err != nil { + return nil, err + } + + return &anomalyQueryResults{ + CurrentPeriodResults: p.toTSResults(ctx, currentPeriodResults), + PastPeriodResults: p.toTSResults(ctx, pastPeriodResults), + CurrentSeasonResults: p.toTSResults(ctx, currentSeasonResults), + PastSeasonResults: p.toTSResults(ctx, pastSeasonResults), + Past2SeasonResults: p.toTSResults(ctx, past2SeasonResults), + Past3SeasonResults: p.toTSResults(ctx, past3SeasonResults), + }, nil +} + +// getMatchingSeries gets the matching series from the query result +// for the given series +func (p *BaseSeasonalProvider) getMatchingSeries(_ context.Context, queryResult *qbtypes.TimeSeriesData, series *qbtypes.TimeSeries) *qbtypes.TimeSeries { + if queryResult == nil || len(queryResult.Aggregations) == 0 || len(queryResult.Aggregations[0].Series) == 0 { + return nil + } + + for _, curr := range queryResult.Aggregations[0].Series { + currLabelsKey := qbtypes.GetUniqueSeriesKey(curr.Labels) + seriesLabelsKey := qbtypes.GetUniqueSeriesKey(series.Labels) + if currLabelsKey == seriesLabelsKey { + return curr + } + } + return nil +} + +func (p *BaseSeasonalProvider) getAvg(series *qbtypes.TimeSeries) float64 { + if series == nil || len(series.Values) == 0 { + return 0 + } + var sum float64 + for _, smpl := range series.Values { + sum += smpl.Value + } + return sum / float64(len(series.Values)) +} + +func (p *BaseSeasonalProvider) getStdDev(series *qbtypes.TimeSeries) float64 { + if series == nil || len(series.Values) == 0 { + return 0 + } + avg := p.getAvg(series) + var sum float64 + for _, smpl := range series.Values { + sum += math.Pow(smpl.Value-avg, 2) + } + return math.Sqrt(sum / float64(len(series.Values))) +} + +// getMovingAvg gets the moving average for the given series +// for the given window size and start index +func (p *BaseSeasonalProvider) getMovingAvg(series *qbtypes.TimeSeries, movingAvgWindowSize, startIdx int) float64 { + if series == nil || len(series.Values) == 0 { + return 0 + } + if startIdx >= len(series.Values)-movingAvgWindowSize { + startIdx = int(math.Max(0, float64(len(series.Values)-movingAvgWindowSize))) + } + var sum float64 + points := series.Values[startIdx:] + windowSize := int(math.Min(float64(movingAvgWindowSize), float64(len(points)))) + for i := 0; i < windowSize; i++ { + sum += points[i].Value + } + avg := sum / float64(windowSize) + return avg +} + +func (p *BaseSeasonalProvider) getMean(floats ...float64) float64 { + if len(floats) == 0 { + return 0 + } + var sum float64 + for _, f := range floats { + sum += f + } + return sum / float64(len(floats)) +} + +func (p *BaseSeasonalProvider) getPredictedSeries( + ctx context.Context, + series, prevSeries, currentSeasonSeries, pastSeasonSeries, past2SeasonSeries, past3SeasonSeries *qbtypes.TimeSeries, +) *qbtypes.TimeSeries { + predictedSeries := &qbtypes.TimeSeries{ + Labels: series.Labels, + Values: make([]*qbtypes.TimeSeriesValue, 0), + } + + // for each point in the series, get the predicted value + // the predicted value is the moving average (with window size = 7) of the previous period series + // plus the average of the current season series + // minus the mean of the past season series, past2 season series and past3 season series + for idx, curr := range series.Values { + movingAvg := p.getMovingAvg(prevSeries, movingAvgWindowSize, idx) + avg := p.getAvg(currentSeasonSeries) + mean := p.getMean(p.getAvg(pastSeasonSeries), p.getAvg(past2SeasonSeries), p.getAvg(past3SeasonSeries)) + predictedValue := movingAvg + avg - mean + + if predictedValue < 0 { + // this should not happen (except when the data has extreme outliers) + // we will use the moving avg of the previous period series in this case + p.logger.WarnContext(ctx, "predicted value is less than 0 for series", "anomaly_predicted_value", predictedValue, "anomaly_labels", series.Labels) + predictedValue = p.getMovingAvg(prevSeries, movingAvgWindowSize, idx) + } + + p.logger.DebugContext(ctx, "predicted value for series", + "anomaly_moving_avg", movingAvg, + "anomaly_avg", avg, + "anomaly_mean", mean, + "anomaly_labels", series.Labels, + "anomaly_predicted_value", predictedValue, + "anomaly_curr", curr.Value, + ) + predictedSeries.Values = append(predictedSeries.Values, &qbtypes.TimeSeriesValue{ + Timestamp: curr.Timestamp, + Value: predictedValue, + }) + } + + return predictedSeries +} + +// getBounds gets the upper and lower bounds for the given series +// for the given z score threshold +// 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, + zScoreThreshold float64, +) (*qbtypes.TimeSeries, *qbtypes.TimeSeries) { + upperBoundSeries := &qbtypes.TimeSeries{ + Labels: series.Labels, + Values: make([]*qbtypes.TimeSeriesValue, 0), + } + + lowerBoundSeries := &qbtypes.TimeSeries{ + Labels: series.Labels, + Values: make([]*qbtypes.TimeSeriesValue, 0), + } + + 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) + upperBoundSeries.Values = append(upperBoundSeries.Values, &qbtypes.TimeSeriesValue{ + Timestamp: curr.Timestamp, + Value: upperBound, + }) + lowerBoundSeries.Values = append(lowerBoundSeries.Values, &qbtypes.TimeSeriesValue{ + Timestamp: curr.Timestamp, + Value: math.Max(lowerBound, 0), + }) + } + + return upperBoundSeries, lowerBoundSeries +} + +// getExpectedValue gets the expected value for the given series +// for the given index +// prevSeriesAvg + currentSeasonSeriesAvg - mean of past season series, past2 season series and past3 season series +func (p *BaseSeasonalProvider) getExpectedValue( + _, prevSeries, currentSeasonSeries, pastSeasonSeries, past2SeasonSeries, past3SeasonSeries *qbtypes.TimeSeries, idx int, +) float64 { + prevSeriesAvg := p.getMovingAvg(prevSeries, movingAvgWindowSize, idx) + currentSeasonSeriesAvg := p.getAvg(currentSeasonSeries) + pastSeasonSeriesAvg := p.getAvg(pastSeasonSeries) + past2SeasonSeriesAvg := p.getAvg(past2SeasonSeries) + past3SeasonSeriesAvg := p.getAvg(past3SeasonSeries) + return prevSeriesAvg + currentSeasonSeriesAvg - p.getMean(pastSeasonSeriesAvg, past2SeasonSeriesAvg, past3SeasonSeriesAvg) +} + +// getScore gets the anomaly score for the given series +// for the given index +// (value - expectedValue) / std dev of the series +func (p *BaseSeasonalProvider) getScore( + series, prevSeries, weekSeries, weekPrevSeries, past2SeasonSeries, past3SeasonSeries *qbtypes.TimeSeries, value float64, idx int, +) float64 { + expectedValue := p.getExpectedValue(series, prevSeries, weekSeries, weekPrevSeries, past2SeasonSeries, past3SeasonSeries, idx) + if expectedValue < 0 { + expectedValue = p.getMovingAvg(prevSeries, movingAvgWindowSize, idx) + } + return (value - expectedValue) / p.getStdDev(weekSeries) +} + +// getAnomalyScores gets the anomaly scores for the given series +// for the given index +// (value - expectedValue) / std dev of the series +func (p *BaseSeasonalProvider) getAnomalyScores( + series, prevSeries, currentSeasonSeries, pastSeasonSeries, past2SeasonSeries, past3SeasonSeries *qbtypes.TimeSeries, +) *qbtypes.TimeSeries { + anomalyScoreSeries := &qbtypes.TimeSeries{ + Labels: series.Labels, + Values: make([]*qbtypes.TimeSeriesValue, 0), + } + + for idx, curr := range series.Values { + anomalyScore := p.getScore(series, prevSeries, currentSeasonSeries, pastSeasonSeries, past2SeasonSeries, past3SeasonSeries, curr.Value, idx) + anomalyScoreSeries.Values = append(anomalyScoreSeries.Values, &qbtypes.TimeSeriesValue{ + Timestamp: curr.Timestamp, + Value: anomalyScore, + }) + } + + return anomalyScoreSeries +} + +func (p *BaseSeasonalProvider) getAnomalies(ctx context.Context, orgID valuer.UUID, req *AnomaliesRequest) (*AnomaliesResponse, error) { + anomalyParams := p.getQueryParams(req) + anomalyQueryResults, err := p.getResults(ctx, orgID, anomalyParams) + if err != nil { + return nil, err + } + + currentPeriodResults := make(map[string]*qbtypes.TimeSeriesData) + for _, result := range anomalyQueryResults.CurrentPeriodResults { + currentPeriodResults[result.QueryName] = result + } + + pastPeriodResults := make(map[string]*qbtypes.TimeSeriesData) + for _, result := range anomalyQueryResults.PastPeriodResults { + pastPeriodResults[result.QueryName] = result + } + + currentSeasonResults := make(map[string]*qbtypes.TimeSeriesData) + for _, result := range anomalyQueryResults.CurrentSeasonResults { + currentSeasonResults[result.QueryName] = result + } + + pastSeasonResults := make(map[string]*qbtypes.TimeSeriesData) + for _, result := range anomalyQueryResults.PastSeasonResults { + pastSeasonResults[result.QueryName] = result + } + + past2SeasonResults := make(map[string]*qbtypes.TimeSeriesData) + for _, result := range anomalyQueryResults.Past2SeasonResults { + past2SeasonResults[result.QueryName] = result + } + + past3SeasonResults := make(map[string]*qbtypes.TimeSeriesData) + for _, result := range anomalyQueryResults.Past3SeasonResults { + past3SeasonResults[result.QueryName] = result + } + + for _, result := range currentPeriodResults { + funcs := req.Params.FuncsForQuery(result.QueryName) + + var zScoreThreshold float64 + for _, f := range funcs { + if f.Name == qbtypes.FunctionNameAnomaly { + for _, arg := range f.Args { + if arg.Name != "z_score_threshold" { + continue + } + value, ok := arg.Value.(float64) + if ok { + zScoreThreshold = value + } else { + p.logger.InfoContext(ctx, "z_score_threshold not provided, defaulting") + zScoreThreshold = 3 + } + break + } + } + } + + pastPeriodResult, ok := pastPeriodResults[result.QueryName] + if !ok { + continue + } + currentSeasonResult, ok := currentSeasonResults[result.QueryName] + if !ok { + continue + } + pastSeasonResult, ok := pastSeasonResults[result.QueryName] + if !ok { + continue + } + past2SeasonResult, ok := past2SeasonResults[result.QueryName] + if !ok { + continue + } + past3SeasonResult, ok := past3SeasonResults[result.QueryName] + if !ok { + continue + } + + // no data; + if len(result.Aggregations) == 0 { + continue + } + + 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) + pastSeasonSeries := p.getMatchingSeries(ctx, pastSeasonResult, series) + past2SeasonSeries := p.getMatchingSeries(ctx, past2SeasonResult, series) + past3SeasonSeries := p.getMatchingSeries(ctx, past3SeasonResult, series) + + prevSeriesAvg := p.getAvg(pastPeriodSeries) + currentSeasonSeriesAvg := p.getAvg(currentSeasonSeries) + pastSeasonSeriesAvg := p.getAvg(pastSeasonSeries) + past2SeasonSeriesAvg := p.getAvg(past2SeasonSeries) + past3SeasonSeriesAvg := p.getAvg(past3SeasonSeries) + p.logger.InfoContext(ctx, "calculated mean for series", + "anomaly_prev_series_avg", prevSeriesAvg, + "anomaly_current_season_series_avg", currentSeasonSeriesAvg, + "anomaly_past_season_series_avg", pastSeasonSeriesAvg, + "anomaly_past_2season_series_avg", past2SeasonSeriesAvg, + "anomaly_past_3season_series_avg", past3SeasonSeriesAvg, + "anomaly_labels", series.Labels, + ) + + predictedSeries := p.getPredictedSeries( + ctx, + series, + pastPeriodSeries, + currentSeasonSeries, + pastSeasonSeries, + past2SeasonSeries, + past3SeasonSeries, + ) + aggOfInterest.PredictedSeries = append(aggOfInterest.PredictedSeries, predictedSeries) + + upperBoundSeries, lowerBoundSeries := p.getBounds( + series, + predictedSeries, + zScoreThreshold, + ) + aggOfInterest.UpperBoundSeries = append(aggOfInterest.UpperBoundSeries, upperBoundSeries) + aggOfInterest.LowerBoundSeries = append(aggOfInterest.LowerBoundSeries, lowerBoundSeries) + + anomalyScoreSeries := p.getAnomalyScores( + series, + pastPeriodSeries, + currentSeasonSeries, + pastSeasonSeries, + past2SeasonSeries, + past3SeasonSeries, + ) + aggOfInterest.AnomalyScores = append(aggOfInterest.AnomalyScores, anomalyScoreSeries) + } + } + + results := make([]*qbtypes.TimeSeriesData, 0, len(currentPeriodResults)) + for _, result := range currentPeriodResults { + results = append(results, result) + } + + return &AnomaliesResponse{ + Results: results, + }, nil +} diff --git a/ee/anomaly/weekly.go b/ee/anomaly/weekly.go new file mode 100644 index 00000000000..c3e3f1fa914 --- /dev/null +++ b/ee/anomaly/weekly.go @@ -0,0 +1,34 @@ +package anomaly + +import ( + "context" + + "github.com/SigNoz/signoz/pkg/valuer" +) + +type WeeklyProvider struct { + BaseSeasonalProvider +} + +var _ BaseProvider = (*WeeklyProvider)(nil) + +func (wp *WeeklyProvider) GetBaseSeasonalProvider() *BaseSeasonalProvider { + return &wp.BaseSeasonalProvider +} + +func NewWeeklyProvider(opts ...GenericProviderOption[*WeeklyProvider]) *WeeklyProvider { + wp := &WeeklyProvider{ + BaseSeasonalProvider: BaseSeasonalProvider{}, + } + + for _, opt := range opts { + opt(wp) + } + + return wp +} + +func (p *WeeklyProvider) GetAnomalies(ctx context.Context, orgID valuer.UUID, req *AnomaliesRequest) (*AnomaliesResponse, error) { + req.Seasonality = SeasonalityWeekly + return p.getAnomalies(ctx, orgID, req) +} diff --git a/ee/authz/openfgaschema/base.fga b/ee/authz/openfgaschema/base.fga new file mode 100644 index 00000000000..e2f1f003d4c --- /dev/null +++ b/ee/authz/openfgaschema/base.fga @@ -0,0 +1,40 @@ +module base + +type organisation + relations + define read: [user, role#assignee] + define update: [user, role#assignee] + +type user + relations + define read: [user, role#assignee] + define update: [user, role#assignee] + define delete: [user, role#assignee] + +type anonymous + +type role + relations + define assignee: [user] + + define read: [user, role#assignee] + define update: [user, role#assignee] + define delete: [user, role#assignee] + +type resources + relations + define create: [user, role#assignee] + define list: [user, role#assignee] + +type resource + relations + define read: [user, anonymous, role#assignee] + define update: [user, role#assignee] + define delete: [user, role#assignee] + + define block: [user, role#assignee] + + +type telemetry + relations + define read: [user, anonymous, role#assignee] diff --git a/ee/authz/openfgaschema/schema.go b/ee/authz/openfgaschema/schema.go new file mode 100644 index 00000000000..605cad0501f --- /dev/null +++ b/ee/authz/openfgaschema/schema.go @@ -0,0 +1,29 @@ +package openfgaschema + +import ( + "context" + _ "embed" + + "github.com/SigNoz/signoz/pkg/authz" + openfgapkgtransformer "github.com/openfga/language/pkg/go/transformer" +) + +var ( + //go:embed base.fga + baseDSL string +) + +type schema struct{} + +func NewSchema() authz.Schema { + return &schema{} +} + +func (schema *schema) Get(ctx context.Context) []openfgapkgtransformer.ModuleFile { + return []openfgapkgtransformer.ModuleFile{ + { + Name: "base.fga", + Contents: baseDSL, + }, + } +} diff --git a/ee/http/middleware/authz.go b/ee/http/middleware/authz.go new file mode 100644 index 00000000000..f443917c949 --- /dev/null +++ b/ee/http/middleware/authz.go @@ -0,0 +1,132 @@ +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/http/middleware/pat.go b/ee/http/middleware/pat.go deleted file mode 100644 index 59a18a2b798..00000000000 --- a/ee/http/middleware/pat.go +++ /dev/null @@ -1,84 +0,0 @@ -package middleware - -import ( - "net/http" - "time" - - "github.com/SigNoz/signoz/pkg/sqlstore" - "github.com/SigNoz/signoz/pkg/types" - "github.com/SigNoz/signoz/pkg/types/authtypes" - "go.uber.org/zap" -) - -type Pat struct { - store sqlstore.SQLStore - uuid *authtypes.UUID - headers []string -} - -func NewPat(store sqlstore.SQLStore, headers []string) *Pat { - return &Pat{store: store, uuid: authtypes.NewUUID(), headers: headers} -} - -func (p *Pat) Wrap(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var values []string - var patToken string - var pat types.StorableAPIKey - - for _, header := range p.headers { - values = append(values, r.Header.Get(header)) - } - - ctx, err := p.uuid.ContextFromRequest(r.Context(), values...) - if err != nil { - next.ServeHTTP(w, r) - return - } - patToken, ok := authtypes.UUIDFromContext(ctx) - if !ok { - next.ServeHTTP(w, r) - return - } - - err = p.store.BunDB().NewSelect().Model(&pat).Where("token = ?", patToken).Scan(r.Context()) - if err != nil { - next.ServeHTTP(w, r) - return - } - - if pat.ExpiresAt.Before(time.Now()) { - next.ServeHTTP(w, r) - return - } - - // get user from db - user := types.User{} - err = p.store.BunDB().NewSelect().Model(&user).Where("id = ?", pat.UserID).Scan(r.Context()) - if err != nil { - next.ServeHTTP(w, r) - return - } - - jwt := authtypes.Claims{ - UserID: user.ID.String(), - Role: pat.Role, - Email: user.Email, - OrgID: user.OrgID, - } - - ctx = authtypes.NewContextWithClaims(ctx, jwt) - - r = r.WithContext(ctx) - - next.ServeHTTP(w, r) - - pat.LastUsed = time.Now() - _, err = p.store.BunDB().NewUpdate().Model(&pat).Column("last_used").Where("token = ?", patToken).Where("revoked = false").Exec(r.Context()) - if err != nil { - zap.L().Error("Failed to update PAT last used in db, err: %v", zap.Error(err)) - } - - }) - -} diff --git a/ee/licensing/config.go b/ee/licensing/config.go new file mode 100644 index 00000000000..598724d8e36 --- /dev/null +++ b/ee/licensing/config.go @@ -0,0 +1,26 @@ +package licensing + +import ( + "fmt" + "sync" + "time" + + "github.com/SigNoz/signoz/pkg/licensing" +) + +var ( + config licensing.Config + once sync.Once +) + +// initializes the licensing configuration +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)) + } + }) + + return config +} diff --git a/ee/licensing/httplicensing/api.go b/ee/licensing/httplicensing/api.go new file mode 100644 index 00000000000..9f9bc1f5da9 --- /dev/null +++ b/ee/licensing/httplicensing/api.go @@ -0,0 +1,168 @@ +package httplicensing + +import ( + "context" + "encoding/json" + "net/http" + "time" + + "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/http/render" + "github.com/SigNoz/signoz/pkg/licensing" + "github.com/SigNoz/signoz/pkg/types/authtypes" + "github.com/SigNoz/signoz/pkg/types/licensetypes" + "github.com/SigNoz/signoz/pkg/valuer" +) + +type licensingAPI struct { + licensing licensing.Licensing +} + +func NewLicensingAPI(licensing licensing.Licensing) licensing.API { + return &licensingAPI{licensing: licensing} +} + +func (api *licensingAPI) Activate(rw http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + claims, err := authtypes.ClaimsFromContext(ctx) + if err != nil { + render.Error(rw, err) + return + } + + orgID, err := valuer.NewUUID(claims.OrgID) + if err != nil { + render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgId is invalid")) + return + } + + req := new(licensetypes.PostableLicense) + err = json.NewDecoder(r.Body).Decode(&req) + if err != nil { + render.Error(rw, err) + return + } + + err = api.licensing.Activate(r.Context(), orgID, req.Key) + if err != nil { + render.Error(rw, err) + return + } + + render.Success(rw, http.StatusAccepted, nil) +} + +func (api *licensingAPI) GetActive(rw http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + claims, err := authtypes.ClaimsFromContext(ctx) + if err != nil { + render.Error(rw, err) + return + } + + orgID, err := valuer.NewUUID(claims.OrgID) + if err != nil { + render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgId is invalid")) + return + } + + license, err := api.licensing.GetActive(r.Context(), orgID) + if err != nil { + render.Error(rw, err) + return + } + + gettableLicense := licensetypes.NewGettableLicense(license.Data, license.Key) + render.Success(rw, http.StatusOK, gettableLicense) +} + +func (api *licensingAPI) Refresh(rw http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + claims, err := authtypes.ClaimsFromContext(ctx) + if err != nil { + render.Error(rw, err) + return + } + + orgID, err := valuer.NewUUID(claims.OrgID) + if err != nil { + render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgId is invalid")) + return + } + + err = api.licensing.Refresh(r.Context(), orgID) + if err != nil { + render.Error(rw, err) + return + } + + render.Success(rw, http.StatusNoContent, nil) +} + +func (api *licensingAPI) Checkout(rw http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + claims, err := authtypes.ClaimsFromContext(ctx) + if err != nil { + render.Error(rw, err) + return + } + + orgID, err := valuer.NewUUID(claims.OrgID) + if err != nil { + render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgId is invalid")) + return + } + + req := new(licensetypes.PostableSubscription) + if err := json.NewDecoder(r.Body).Decode(req); err != nil { + render.Error(rw, err) + return + } + + gettableSubscription, err := api.licensing.Checkout(ctx, orgID, req) + if err != nil { + render.Error(rw, err) + return + } + + render.Success(rw, http.StatusCreated, gettableSubscription) +} + +func (api *licensingAPI) Portal(rw http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + claims, err := authtypes.ClaimsFromContext(ctx) + if err != nil { + render.Error(rw, err) + return + } + + orgID, err := valuer.NewUUID(claims.OrgID) + if err != nil { + render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgId is invalid")) + return + } + + req := new(licensetypes.PostableSubscription) + if err := json.NewDecoder(r.Body).Decode(req); err != nil { + render.Error(rw, err) + return + } + + gettableSubscription, err := api.licensing.Portal(ctx, orgID, req) + if err != nil { + render.Error(rw, err) + return + } + + render.Success(rw, http.StatusCreated, gettableSubscription) +} diff --git a/ee/licensing/httplicensing/provider.go b/ee/licensing/httplicensing/provider.go new file mode 100644 index 00000000000..4bd2be39224 --- /dev/null +++ b/ee/licensing/httplicensing/provider.go @@ -0,0 +1,249 @@ +package httplicensing + +import ( + "context" + "encoding/json" + "time" + + "github.com/SigNoz/signoz/ee/licensing/licensingstore/sqllicensingstore" + "github.com/SigNoz/signoz/pkg/analytics" + "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/factory" + "github.com/SigNoz/signoz/pkg/licensing" + "github.com/SigNoz/signoz/pkg/modules/organization" + "github.com/SigNoz/signoz/pkg/sqlstore" + "github.com/SigNoz/signoz/pkg/types/analyticstypes" + "github.com/SigNoz/signoz/pkg/types/licensetypes" + "github.com/SigNoz/signoz/pkg/valuer" + "github.com/SigNoz/signoz/pkg/zeus" + "github.com/tidwall/gjson" +) + +type provider struct { + store licensetypes.Store + zeus zeus.Zeus + config licensing.Config + settings factory.ScopedProviderSettings + orgGetter organization.Getter + analytics analytics.Analytics + stopChan chan struct{} +} + +func NewProviderFactory(store sqlstore.SQLStore, zeus zeus.Zeus, orgGetter organization.Getter, analytics analytics.Analytics) factory.ProviderFactory[licensing.Licensing, licensing.Config] { + return factory.NewProviderFactory(factory.MustNewName("http"), func(ctx context.Context, providerSettings factory.ProviderSettings, config licensing.Config) (licensing.Licensing, error) { + return New(ctx, providerSettings, config, store, zeus, orgGetter, analytics) + }) +} + +func New(ctx context.Context, ps factory.ProviderSettings, config licensing.Config, sqlstore sqlstore.SQLStore, zeus zeus.Zeus, orgGetter organization.Getter, analytics analytics.Analytics) (licensing.Licensing, error) { + settings := factory.NewScopedProviderSettings(ps, "github.com/SigNoz/signoz/ee/licensing/httplicensing") + licensestore := sqllicensingstore.New(sqlstore) + return &provider{ + store: licensestore, + zeus: zeus, + config: config, + settings: settings, + orgGetter: orgGetter, + stopChan: make(chan struct{}), + analytics: analytics, + }, nil +} + +func (provider *provider) Start(ctx context.Context) error { + tick := time.NewTicker(provider.config.PollInterval) + defer tick.Stop() + + err := provider.Validate(ctx) + if err != nil { + provider.settings.Logger().ErrorContext(ctx, "failed to validate license from upstream server", "error", err) + } + + for { + select { + case <-provider.stopChan: + return nil + case <-tick.C: + err := provider.Validate(ctx) + if err != nil { + provider.settings.Logger().ErrorContext(ctx, "failed to validate license from upstream server", "error", err) + } + } + } +} + +func (provider *provider) Stop(ctx context.Context) error { + provider.settings.Logger().DebugContext(ctx, "license validation stopped") + close(provider.stopChan) + return nil +} + +func (provider *provider) Validate(ctx context.Context) error { + organizations, err := provider.orgGetter.ListByOwnedKeyRange(ctx) + if err != nil { + return err + } + + for _, organization := range organizations { + err := provider.Refresh(ctx, organization.ID) + if err != nil { + return err + } + } + + return nil +} + +func (provider *provider) Activate(ctx context.Context, organizationID valuer.UUID, key string) error { + data, err := provider.zeus.GetLicense(ctx, key) + if err != nil { + return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "unable to fetch license data with upstream server") + } + + license, err := licensetypes.NewLicense(data, organizationID) + if err != nil { + return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to create license entity") + } + + storableLicense := licensetypes.NewStorableLicenseFromLicense(license) + err = provider.store.Create(ctx, storableLicense) + if err != nil { + return err + } + + return nil +} + +func (provider *provider) GetActive(ctx context.Context, organizationID valuer.UUID) (*licensetypes.License, error) { + storableLicenses, err := provider.store.GetAll(ctx, organizationID) + if err != nil { + return nil, err + } + + activeLicense, err := licensetypes.GetActiveLicenseFromStorableLicenses(storableLicenses, organizationID) + if err != nil { + return nil, err + } + + return activeLicense, nil +} + +func (provider *provider) Refresh(ctx context.Context, organizationID valuer.UUID) error { + activeLicense, err := provider.GetActive(ctx, organizationID) + if err != nil { + if errors.Ast(err, errors.TypeNotFound) { + return nil + } + provider.settings.Logger().ErrorContext(ctx, "license validation failed", "org_id", organizationID.StringValue()) + return err + } + + data, err := provider.zeus.GetLicense(ctx, activeLicense.Key) + if err != nil { + if time.Since(activeLicense.LastValidatedAt) > time.Duration(provider.config.FailureThreshold)*provider.config.PollInterval { + activeLicense.UpdateFeatures(licensetypes.BasicPlan) + updatedStorableLicense := licensetypes.NewStorableLicenseFromLicense(activeLicense) + err = provider.store.Update(ctx, organizationID, updatedStorableLicense) + if err != nil { + return err + } + + return nil + } + return err + } + + err = activeLicense.Update(data) + if err != nil { + return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to create license entity from license data") + } + + updatedStorableLicense := licensetypes.NewStorableLicenseFromLicense(activeLicense) + err = provider.store.Update(ctx, organizationID, updatedStorableLicense) + if err != nil { + return err + } + + stats := licensetypes.NewStatsFromLicense(activeLicense) + provider.analytics.Send(ctx, + analyticstypes.Track{ + UserId: "stats_" + organizationID.String(), + Event: "License Updated", + Properties: analyticstypes.NewPropertiesFromMap(stats), + Context: &analyticstypes.Context{ + Extra: map[string]interface{}{ + analyticstypes.KeyGroupID: organizationID.String(), + }, + }, + }, + analyticstypes.Group{ + UserId: "stats_" + organizationID.String(), + GroupId: organizationID.String(), + Traits: analyticstypes.NewTraitsFromMap(stats), + }, + ) + + return nil +} + +func (provider *provider) Checkout(ctx context.Context, organizationID valuer.UUID, postableSubscription *licensetypes.PostableSubscription) (*licensetypes.GettableSubscription, error) { + activeLicense, err := provider.GetActive(ctx, organizationID) + if err != nil { + return nil, err + } + + body, err := json.Marshal(postableSubscription) + if err != nil { + return nil, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to marshal checkout payload") + } + + response, err := provider.zeus.GetCheckoutURL(ctx, activeLicense.Key, body) + if err != nil { + return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to generate checkout session") + } + + return &licensetypes.GettableSubscription{RedirectURL: gjson.GetBytes(response, "url").String()}, nil +} + +func (provider *provider) Portal(ctx context.Context, organizationID valuer.UUID, postableSubscription *licensetypes.PostableSubscription) (*licensetypes.GettableSubscription, error) { + activeLicense, err := provider.GetActive(ctx, organizationID) + if err != nil { + return nil, err + } + + body, err := json.Marshal(postableSubscription) + if err != nil { + return nil, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to marshal portal payload") + } + + response, err := provider.zeus.GetPortalURL(ctx, activeLicense.Key, body) + if err != nil { + return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to generate portal session") + } + + return &licensetypes.GettableSubscription{RedirectURL: gjson.GetBytes(response, "url").String()}, nil +} + +func (provider *provider) GetFeatureFlags(ctx context.Context, organizationID valuer.UUID) ([]*licensetypes.Feature, error) { + license, err := provider.GetActive(ctx, organizationID) + if err != nil { + if errors.Ast(err, errors.TypeNotFound) { + return licensetypes.BasicPlan, nil + } + return nil, err + } + + return license.Features, nil +} + +func (provider *provider) Collect(ctx context.Context, orgID valuer.UUID) (map[string]any, error) { + activeLicense, err := provider.GetActive(ctx, orgID) + if err != nil { + if errors.Ast(err, errors.TypeNotFound) { + return map[string]any{}, nil + } + + return nil, err + } + + return licensetypes.NewStatsFromLicense(activeLicense), nil +} diff --git a/ee/licensing/licensingstore/sqllicensingstore/store.go b/ee/licensing/licensingstore/sqllicensingstore/store.go new file mode 100644 index 00000000000..dfbb257a934 --- /dev/null +++ b/ee/licensing/licensingstore/sqllicensingstore/store.go @@ -0,0 +1,81 @@ +package sqllicensingstore + +import ( + "context" + + "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/sqlstore" + "github.com/SigNoz/signoz/pkg/types/licensetypes" + "github.com/SigNoz/signoz/pkg/valuer" +) + +type store struct { + sqlstore sqlstore.SQLStore +} + +func New(sqlstore sqlstore.SQLStore) licensetypes.Store { + return &store{sqlstore} +} + +func (store *store) Create(ctx context.Context, storableLicense *licensetypes.StorableLicense) error { + _, err := store. + sqlstore. + BunDB(). + NewInsert(). + Model(storableLicense). + Exec(ctx) + if err != nil { + return store.sqlstore.WrapAlreadyExistsErrf(err, errors.CodeAlreadyExists, "license with ID: %s already exists", storableLicense.ID) + } + + return nil +} + +func (store *store) Get(ctx context.Context, organizationID valuer.UUID, licenseID valuer.UUID) (*licensetypes.StorableLicense, error) { + storableLicense := new(licensetypes.StorableLicense) + err := store. + sqlstore. + BunDB(). + NewSelect(). + Model(storableLicense). + Where("org_id = ?", organizationID). + Where("id = ?", licenseID). + Scan(ctx) + if err != nil { + return nil, store.sqlstore.WrapNotFoundErrf(err, errors.CodeNotFound, "license with ID: %s does not exist", licenseID) + } + + return storableLicense, nil +} + +func (store *store) GetAll(ctx context.Context, organizationID valuer.UUID) ([]*licensetypes.StorableLicense, error) { + storableLicenses := make([]*licensetypes.StorableLicense, 0) + err := store. + sqlstore. + BunDB(). + NewSelect(). + Model(&storableLicenses). + Where("org_id = ?", organizationID). + Scan(ctx) + if err != nil { + return nil, store.sqlstore.WrapNotFoundErrf(err, errors.CodeNotFound, "licenses for organizationID: %s does not exists", organizationID) + } + + return storableLicenses, nil +} + +func (store *store) Update(ctx context.Context, organizationID valuer.UUID, storableLicense *licensetypes.StorableLicense) error { + _, err := store. + sqlstore. + BunDB(). + NewUpdate(). + Model(storableLicense). + WherePK(). + Where("org_id = ?", organizationID). + Exec(ctx) + if err != nil { + return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "unable to update license with ID: %s", storableLicense.ID) + } + + return nil +} diff --git a/ee/modules/user/impluser/handler.go b/ee/modules/user/impluser/handler.go deleted file mode 100644 index 79ebe46b4cb..00000000000 --- a/ee/modules/user/impluser/handler.go +++ /dev/null @@ -1,405 +0,0 @@ -package impluser - -import ( - "context" - "encoding/json" - "net/http" - "slices" - "time" - - "github.com/SigNoz/signoz/pkg/errors" - "github.com/SigNoz/signoz/pkg/http/render" - "github.com/SigNoz/signoz/pkg/modules/user" - "github.com/SigNoz/signoz/pkg/modules/user/impluser" - "github.com/SigNoz/signoz/pkg/types" - "github.com/SigNoz/signoz/pkg/types/authtypes" - "github.com/SigNoz/signoz/pkg/valuer" - "github.com/gorilla/mux" -) - -// EnterpriseHandler embeds the base handler implementation -type Handler struct { - user.Handler // Embed the base handler interface - module user.Module -} - -func NewHandler(module user.Module) user.Handler { - baseHandler := impluser.NewHandler(module) - return &Handler{ - Handler: baseHandler, - module: module, - } -} - -func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { - ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) - defer cancel() - - var req types.PostableLoginRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - render.Error(w, err) - return - } - - if req.RefreshToken == "" { - // the EE handler wrapper passes the feature flag value in context - ssoAvailable, ok := ctx.Value(types.SSOAvailable).(bool) - if !ok { - render.Error(w, errors.New(errors.TypeInternal, errors.CodeInternal, "failed to retrieve SSO availability")) - return - } - - if ssoAvailable { - _, err := h.module.CanUsePassword(ctx, req.Email) - if err != nil { - render.Error(w, err) - return - } - } - } - - user, err := h.module.GetAuthenticatedUser(ctx, req.OrgID, req.Email, req.Password, req.RefreshToken) - if err != nil { - render.Error(w, err) - return - } - - jwt, err := h.module.GetJWTForUser(ctx, user) - if err != nil { - render.Error(w, err) - return - } - - gettableLoginResponse := &types.GettableLoginResponse{ - GettableUserJwt: jwt, - UserID: user.ID.String(), - } - - render.Success(w, http.StatusOK, gettableLoginResponse) -} - -// Override only the methods you need with enterprise-specific implementations -func (h *Handler) LoginPrecheck(w http.ResponseWriter, r *http.Request) { - ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) - defer cancel() - - // assume user is valid unless proven otherwise and assign default values for rest of the fields - - email := r.URL.Query().Get("email") - sourceUrl := r.URL.Query().Get("ref") - orgID := r.URL.Query().Get("orgID") - - resp, err := h.module.LoginPrecheck(ctx, orgID, email, sourceUrl) - if err != nil { - render.Error(w, err) - return - } - - render.Success(w, http.StatusOK, resp) - -} - -func (h *Handler) AcceptInvite(w http.ResponseWriter, r *http.Request) { - ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) - defer cancel() - - req := new(types.PostableAcceptInvite) - if err := json.NewDecoder(r.Body).Decode(req); err != nil { - render.Error(w, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to decode user")) - return - } - - // get invite object - invite, err := h.module.GetInviteByToken(ctx, req.InviteToken) - if err != nil { - render.Error(w, err) - return - } - - orgDomain, err := h.module.GetAuthDomainByEmail(ctx, invite.Email) - if err != nil && !errors.Ast(err, errors.TypeNotFound) { - render.Error(w, err) - return - } - - precheckResp := &types.GettableLoginPrecheck{ - SSO: false, - IsUser: false, - } - - if invite.Name == "" && req.DisplayName != "" { - invite.Name = req.DisplayName - } - - user, err := types.NewUser(invite.Name, invite.Email, invite.Role, invite.OrgID) - if err != nil { - render.Error(w, err) - return - } - - if orgDomain != nil && orgDomain.SsoEnabled { - // sso is enabled, create user and respond precheck data - err = h.module.CreateUser(ctx, user) - if err != nil { - render.Error(w, err) - return - } - - // check if sso is enforced for the org - precheckResp, err = h.module.LoginPrecheck(ctx, invite.OrgID, user.Email, req.SourceURL) - if err != nil { - render.Error(w, err) - return - } - - } else { - password, err := types.NewFactorPassword(req.Password) - if err != nil { - render.Error(w, err) - return - } - - user, err = h.module.CreateUserWithPassword(ctx, user, password) - if err != nil { - render.Error(w, err) - return - } - - precheckResp.IsUser = true - } - - // delete the invite - if err := h.module.DeleteInvite(ctx, invite.OrgID, invite.ID); err != nil { - render.Error(w, err) - return - } - - render.Success(w, http.StatusOK, precheckResp) -} - -func (h *Handler) GetInvite(w http.ResponseWriter, r *http.Request) { - ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) - defer cancel() - - token := mux.Vars(r)["token"] - sourceUrl := r.URL.Query().Get("ref") - invite, err := h.module.GetInviteByToken(ctx, token) - if err != nil { - render.Error(w, err) - return - } - - // precheck the user - precheckResp, err := h.module.LoginPrecheck(ctx, invite.OrgID, invite.Email, sourceUrl) - if err != nil { - render.Error(w, err) - return - } - - gettableInvite := &types.GettableEEInvite{ - GettableInvite: *invite, - PreCheck: precheckResp, - } - - render.Success(w, http.StatusOK, gettableInvite) - return -} - -func (h *Handler) CreateAPIKey(w http.ResponseWriter, r *http.Request) { - ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) - defer cancel() - - claims, err := authtypes.ClaimsFromContext(ctx) - if err != nil { - render.Error(w, err) - return - } - - userID, err := valuer.NewUUID(claims.UserID) - if err != nil { - render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "userId is not a valid uuid-v7")) - return - } - - req := new(types.PostableAPIKey) - if err := json.NewDecoder(r.Body).Decode(req); err != nil { - render.Error(w, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to decode api key")) - return - } - - apiKey, err := types.NewStorableAPIKey( - req.Name, - userID, - req.Role, - req.ExpiresInDays, - ) - if err != nil { - render.Error(w, err) - return - } - - err = h.module.CreateAPIKey(ctx, apiKey) - if err != nil { - render.Error(w, err) - return - } - - // just corrected the status code, response is same, - render.Success(w, http.StatusCreated, apiKey) -} - -func (h *Handler) ListAPIKeys(w http.ResponseWriter, r *http.Request) { - ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) - defer cancel() - - claims, err := authtypes.ClaimsFromContext(ctx) - if err != nil { - render.Error(w, err) - return - } - - orgID, err := valuer.NewUUID(claims.OrgID) - if err != nil { - render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgId is not a valid uuid-v7")) - return - } - - apiKeys, err := h.module.ListAPIKeys(ctx, orgID) - if err != nil { - render.Error(w, err) - return - } - - // for backward compatibility - if len(apiKeys) == 0 { - render.Success(w, http.StatusOK, []types.GettableAPIKey{}) - return - } - - result := make([]*types.GettableAPIKey, len(apiKeys)) - for i, apiKey := range apiKeys { - result[i] = types.NewGettableAPIKeyFromStorableAPIKey(apiKey) - } - - render.Success(w, http.StatusOK, result) - -} - -func (h *Handler) UpdateAPIKey(w http.ResponseWriter, r *http.Request) { - ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) - defer cancel() - - claims, err := authtypes.ClaimsFromContext(ctx) - if err != nil { - render.Error(w, err) - return - } - - orgID, err := valuer.NewUUID(claims.OrgID) - if err != nil { - render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgId is not a valid uuid-v7")) - return - } - - userID, err := valuer.NewUUID(claims.UserID) - if err != nil { - render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "userId is not a valid uuid-v7")) - return - } - - req := types.StorableAPIKey{} - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - render.Error(w, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to decode api key")) - return - } - - idStr := mux.Vars(r)["id"] - id, err := valuer.NewUUID(idStr) - if err != nil { - render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7")) - return - } - - //get the API Key - existingAPIKey, err := h.module.GetAPIKey(ctx, orgID, id) - if err != nil { - render.Error(w, err) - return - } - - // get the user - createdByUser, err := h.module.GetUserByID(ctx, orgID.String(), existingAPIKey.UserID.String()) - if err != nil { - render.Error(w, err) - return - } - - if slices.Contains(types.AllIntegrationUserEmails, types.IntegrationUserEmail(createdByUser.Email)) { - render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "API Keys for integration users cannot be revoked")) - return - } - - err = h.module.UpdateAPIKey(ctx, id, &req, userID) - if err != nil { - render.Error(w, err) - return - } - - render.Success(w, http.StatusNoContent, nil) -} - -func (h *Handler) RevokeAPIKey(w http.ResponseWriter, r *http.Request) { - ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) - defer cancel() - - claims, err := authtypes.ClaimsFromContext(ctx) - if err != nil { - render.Error(w, err) - return - } - - idStr := mux.Vars(r)["id"] - id, err := valuer.NewUUID(idStr) - if err != nil { - render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7")) - return - } - - orgID, err := valuer.NewUUID(claims.OrgID) - if err != nil { - render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgId is not a valid uuid-v7")) - return - } - - userID, err := valuer.NewUUID(claims.UserID) - if err != nil { - render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "userId is not a valid uuid-v7")) - return - } - - //get the API Key - existingAPIKey, err := h.module.GetAPIKey(ctx, orgID, id) - if err != nil { - render.Error(w, err) - return - } - - // get the user - createdByUser, err := h.module.GetUserByID(ctx, orgID.String(), existingAPIKey.UserID.String()) - if err != nil { - render.Error(w, err) - return - } - - if slices.Contains(types.AllIntegrationUserEmails, types.IntegrationUserEmail(createdByUser.Email)) { - render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "API Keys for integration users cannot be revoked")) - return - } - - if err := h.module.RevokeAPIKey(ctx, id, userID); err != nil { - render.Error(w, err) - return - } - - render.Success(w, http.StatusNoContent, nil) -} diff --git a/ee/modules/user/impluser/module.go b/ee/modules/user/impluser/module.go deleted file mode 100644 index 07d9c0a9e54..00000000000 --- a/ee/modules/user/impluser/module.go +++ /dev/null @@ -1,250 +0,0 @@ -package impluser - -import ( - "context" - "fmt" - "net/url" - "strings" - - "github.com/SigNoz/signoz/ee/query-service/constants" - "github.com/SigNoz/signoz/pkg/errors" - "github.com/SigNoz/signoz/pkg/modules/user" - baseimpl "github.com/SigNoz/signoz/pkg/modules/user/impluser" - "github.com/SigNoz/signoz/pkg/types" - "github.com/SigNoz/signoz/pkg/types/authtypes" - "github.com/SigNoz/signoz/pkg/valuer" - "go.uber.org/zap" -) - -// EnterpriseModule embeds the base module implementation -type Module struct { - user.Module // Embed the base module implementation - store types.UserStore -} - -func NewModule(store types.UserStore) user.Module { - baseModule := baseimpl.NewModule(store) - return &Module{ - Module: baseModule, - store: store, - } -} - -func (m *Module) createUserForSAMLRequest(ctx context.Context, email string) (*types.User, error) { - // get auth domain from email domain - _, err := m.GetAuthDomainByEmail(ctx, email) - if err != nil && !errors.Ast(err, errors.TypeNotFound) { - return nil, err - } - - // get name from email - parts := strings.Split(email, "@") - if len(parts) < 2 { - return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid email format") - } - name := parts[0] - - defaultOrgID, err := m.store.GetDefaultOrgID(ctx) - if err != nil { - return nil, err - } - - user, err := types.NewUser(name, email, types.RoleViewer.String(), defaultOrgID) - if err != nil { - return nil, err - } - - err = m.CreateUser(ctx, user) - if err != nil { - return nil, err - } - - return user, nil -} - -func (m *Module) PrepareSsoRedirect(ctx context.Context, redirectUri, email string, jwt *authtypes.JWT) (string, error) { - users, err := m.GetUsersByEmail(ctx, email) - if err != nil { - zap.L().Error("failed to get user with email received from auth provider", zap.String("error", err.Error())) - return "", err - } - user := &types.User{} - - if len(users) == 0 { - newUser, err := m.createUserForSAMLRequest(ctx, email) - user = newUser - if err != nil { - zap.L().Error("failed to create user with email received from auth provider", zap.Error(err)) - return "", err - } - } else { - user = &users[0].User - } - - tokenStore, err := m.GetJWTForUser(ctx, user) - if err != nil { - zap.L().Error("failed to generate token for SSO login user", zap.Error(err)) - return "", err - } - - return fmt.Sprintf("%s?jwt=%s&usr=%s&refreshjwt=%s", - redirectUri, - tokenStore.AccessJwt, - user.ID, - tokenStore.RefreshJwt), nil -} - -func (m *Module) CanUsePassword(ctx context.Context, email string) (bool, error) { - domain, err := m.GetAuthDomainByEmail(ctx, email) - if err != nil && !errors.Ast(err, errors.TypeNotFound) { - return false, err - } - - if domain != nil && domain.SsoEnabled { - // sso is enabled, check if the user has admin role - users, err := m.GetUsersByEmail(ctx, email) - if err != nil { - return false, err - } - - if len(users) == 0 { - return false, errors.New(errors.TypeNotFound, errors.CodeNotFound, "user not found") - } - - if users[0].Role != types.RoleAdmin.String() { - return false, errors.New(errors.TypeForbidden, errors.CodeForbidden, "auth method not supported") - } - - } - - return true, nil -} - -func (m *Module) LoginPrecheck(ctx context.Context, orgID, email, sourceUrl string) (*types.GettableLoginPrecheck, error) { - resp := &types.GettableLoginPrecheck{IsUser: true, CanSelfRegister: false} - - // check if email is a valid user - users, err := m.GetUsersByEmail(ctx, email) - if err != nil { - return nil, err - } - - if len(users) == 0 { - resp.IsUser = false - } - - // give them an option to select an org - if orgID == "" && len(users) > 1 { - resp.SelectOrg = true - resp.Orgs = make([]string, len(users)) - for i, user := range users { - resp.Orgs[i] = user.OrgID - } - return resp, nil - } - - // select the user with the corresponding orgID - if len(users) > 1 { - found := false - for _, tuser := range users { - if tuser.OrgID == orgID { - // user = tuser - found = true - break - } - } - if !found { - resp.IsUser = false - return resp, nil - } - } - - // the EE handler wrapper passes the feature flag value in context - ssoAvailable, ok := ctx.Value(types.SSOAvailable).(bool) - if !ok { - zap.L().Error("failed to retrieve ssoAvailable from context") - return nil, errors.New(errors.TypeInternal, errors.CodeInternal, "failed to retrieve SSO availability") - } - - if ssoAvailable { - - // TODO(Nitya): in multitenancy this should use orgId as well. - orgDomain, err := m.GetAuthDomainByEmail(ctx, email) - if err != nil && !errors.Ast(err, errors.TypeNotFound) { - return nil, err - } - - if orgDomain != nil && orgDomain.SsoEnabled { - // this is to allow self registration - resp.IsUser = true - - // saml is enabled for this domain, lets prepare sso url - if sourceUrl == "" { - sourceUrl = constants.GetDefaultSiteURL() - } - - // parse source url that generated the login request - var err error - escapedUrl, _ := url.QueryUnescape(sourceUrl) - siteUrl, err := url.Parse(escapedUrl) - if err != nil { - return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to parse referer") - } - - // build Idp URL that will authenticat the user - // the front-end will redirect user to this url - resp.SSOUrl, err = orgDomain.BuildSsoUrl(siteUrl) - if err != nil { - zap.L().Error("failed to prepare saml request for domain", zap.String("domain", orgDomain.Name), zap.Error(err)) - return nil, errors.New(errors.TypeInternal, errors.CodeInternal, "failed to prepare saml request for domain") - } - - // set SSO to true, as the url is generated correctly - resp.SSO = true - } - } - return resp, nil -} - -func (m *Module) GetAuthDomainByEmail(ctx context.Context, email string) (*types.GettableOrgDomain, error) { - - if email == "" { - return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "email is required") - } - - components := strings.Split(email, "@") - if len(components) < 2 { - return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid email format") - } - - domain, err := m.store.GetDomainByName(ctx, components[1]) - if err != nil { - return nil, err - } - - gettableDomain := &types.GettableOrgDomain{StorableOrgDomain: *domain} - if err := gettableDomain.LoadConfig(domain.Data); err != nil { - return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to load domain config") - } - return gettableDomain, nil -} - -func (m *Module) CreateAPIKey(ctx context.Context, apiKey *types.StorableAPIKey) error { - return m.store.CreateAPIKey(ctx, apiKey) -} - -func (m *Module) UpdateAPIKey(ctx context.Context, id valuer.UUID, apiKey *types.StorableAPIKey, updaterID valuer.UUID) error { - return m.store.UpdateAPIKey(ctx, id, apiKey, updaterID) -} - -func (m *Module) ListAPIKeys(ctx context.Context, orgID valuer.UUID) ([]*types.StorableAPIKeyUser, error) { - return m.store.ListAPIKeys(ctx, orgID) -} - -func (m *Module) GetAPIKey(ctx context.Context, orgID, id valuer.UUID) (*types.StorableAPIKeyUser, error) { - return m.store.GetAPIKey(ctx, orgID, id) -} - -func (m *Module) RevokeAPIKey(ctx context.Context, id, removedByUserID valuer.UUID) error { - return m.store.RevokeAPIKey(ctx, id, removedByUserID) -} diff --git a/ee/modules/user/impluser/store.go b/ee/modules/user/impluser/store.go deleted file mode 100644 index cbd23478d79..00000000000 --- a/ee/modules/user/impluser/store.go +++ /dev/null @@ -1,37 +0,0 @@ -package impluser - -import ( - "context" - - "github.com/SigNoz/signoz/pkg/errors" - baseimpl "github.com/SigNoz/signoz/pkg/modules/user/impluser" - "github.com/SigNoz/signoz/pkg/sqlstore" - "github.com/SigNoz/signoz/pkg/types" -) - -type store struct { - *baseimpl.Store - sqlstore sqlstore.SQLStore -} - -func NewStore(sqlstore sqlstore.SQLStore) types.UserStore { - baseStore := baseimpl.NewStore(sqlstore).(*baseimpl.Store) - return &store{ - Store: baseStore, - sqlstore: sqlstore, - } -} - -func (s *store) GetDomainByName(ctx context.Context, name string) (*types.StorableOrgDomain, error) { - domain := new(types.StorableOrgDomain) - err := s.sqlstore.BunDB().NewSelect(). - Model(domain). - Where("name = ?", name). - Limit(1). - Scan(ctx) - - if err != nil { - return nil, errors.Wrapf(err, errors.TypeNotFound, errors.CodeNotFound, "failed to get domain from name") - } - return domain, nil -} diff --git a/ee/query-service/.dockerignore b/ee/query-service/.dockerignore deleted file mode 100644 index 9521c5060b3..00000000000 --- a/ee/query-service/.dockerignore +++ /dev/null @@ -1,4 +0,0 @@ -.vscode -README.md -signoz.db -bin \ No newline at end of file diff --git a/ee/query-service/app/api/api.go b/ee/query-service/app/api/api.go index 812b83b73d4..c5fcbf64f47 100644 --- a/ee/query-service/app/api/api.go +++ b/ee/query-service/app/api/api.go @@ -1,47 +1,34 @@ package api import ( - "context" "net/http" "net/http/httputil" "time" - "github.com/SigNoz/signoz/ee/query-service/dao" + "github.com/SigNoz/signoz/ee/licensing/httplicensing" "github.com/SigNoz/signoz/ee/query-service/integrations/gateway" - "github.com/SigNoz/signoz/ee/query-service/interfaces" - "github.com/SigNoz/signoz/ee/query-service/license" - "github.com/SigNoz/signoz/ee/query-service/model" "github.com/SigNoz/signoz/ee/query-service/usage" "github.com/SigNoz/signoz/pkg/alertmanager" "github.com/SigNoz/signoz/pkg/apis/fields" - "github.com/SigNoz/signoz/pkg/errors" "github.com/SigNoz/signoz/pkg/http/middleware" - "github.com/SigNoz/signoz/pkg/http/render" - "github.com/SigNoz/signoz/pkg/modules/quickfilter" - quickfilterscore "github.com/SigNoz/signoz/pkg/modules/quickfilter/core" + querierAPI "github.com/SigNoz/signoz/pkg/querier" baseapp "github.com/SigNoz/signoz/pkg/query-service/app" "github.com/SigNoz/signoz/pkg/query-service/app/cloudintegrations" "github.com/SigNoz/signoz/pkg/query-service/app/integrations" "github.com/SigNoz/signoz/pkg/query-service/app/logparsingpipeline" - baseint "github.com/SigNoz/signoz/pkg/query-service/interfaces" + "github.com/SigNoz/signoz/pkg/query-service/interfaces" 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" "github.com/SigNoz/signoz/pkg/types/authtypes" "github.com/SigNoz/signoz/pkg/version" "github.com/gorilla/mux" - "go.uber.org/zap" ) type APIHandlerOptions struct { - DataConnector interfaces.DataConnector - PreferSpanMetrics bool - AppDao dao.ModelDao + DataConnector interfaces.Reader RulesManager *rules.Manager UsageManager *usage.Manager - FeatureFlags baseint.FeatureLookup - LicenseManager *license.Manager IntegrationsController *integrations.Controller CloudIntegrationsController *cloudintegrations.Controller LogsParsingPipelineController *logparsingpipeline.LogParsingPipelineController @@ -61,22 +48,18 @@ type APIHandler struct { // NewAPIHandler returns an APIHandler func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz) (*APIHandler, error) { - quickfiltermodule := quickfilterscore.NewQuickFilters(quickfilterscore.NewStore(signoz.SQLStore)) - quickFilter := quickfilter.NewAPI(quickfiltermodule) baseHandler, err := baseapp.NewAPIHandler(baseapp.APIHandlerOpts{ Reader: opts.DataConnector, - PreferSpanMetrics: opts.PreferSpanMetrics, RuleManager: opts.RulesManager, - FeatureFlags: opts.FeatureFlags, IntegrationsController: opts.IntegrationsController, CloudIntegrationsController: opts.CloudIntegrationsController, LogsParsingPipelineController: opts.LogsParsingPipelineController, FluxInterval: opts.FluxInterval, AlertmanagerAPI: alertmanager.NewAPI(signoz.Alertmanager), - FieldsAPI: fields.NewAPI(signoz.TelemetryStore), + LicensingAPI: httplicensing.NewLicensingAPI(signoz.Licensing), + FieldsAPI: fields.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.TelemetryStore), Signoz: signoz, - QuickFilters: quickFilter, - QuickFilterModule: quickfiltermodule, + QuerierAPI: querierAPI.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.Querier, signoz.Analytics), }) if err != nil { @@ -90,83 +73,48 @@ func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz) (*APIHandler, return ah, nil } -func (ah *APIHandler) FF() baseint.FeatureLookup { - return ah.opts.FeatureFlags -} - func (ah *APIHandler) RM() *rules.Manager { return ah.opts.RulesManager } -func (ah *APIHandler) LM() *license.Manager { - return ah.opts.LicenseManager -} - func (ah *APIHandler) UM() *usage.Manager { return ah.opts.UsageManager } -func (ah *APIHandler) AppDao() dao.ModelDao { - return ah.opts.AppDao -} - func (ah *APIHandler) Gateway() *httputil.ReverseProxy { return ah.opts.Gateway } -func (ah *APIHandler) CheckFeature(f string) bool { - err := ah.FF().CheckFeature(f) - return err == nil -} - // RegisterRoutes registers routes for this handler on the given router func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) { // note: add ee override methods first // routes available only in ee version - - router.HandleFunc("/api/v1/featureFlags", am.OpenAccess(ah.getFeatureFlags)).Methods(http.MethodGet) - router.HandleFunc("/api/v1/loginPrecheck", am.OpenAccess(ah.loginPrecheck)).Methods(http.MethodGet) - - // invite - router.HandleFunc("/api/v1/invite/{token}", am.OpenAccess(ah.getInvite)).Methods(http.MethodGet) - router.HandleFunc("/api/v1/invite/accept", am.OpenAccess(ah.acceptInvite)).Methods(http.MethodPost) + 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/google", am.OpenAccess(ah.receiveGoogleAuth)).Methods(http.MethodGet) - router.HandleFunc("/api/v1/orgs/{orgId}/domains", am.AdminAccess(ah.listDomainsByOrg)).Methods(http.MethodGet) - - router.HandleFunc("/api/v1/domains", am.AdminAccess(ah.postDomain)).Methods(http.MethodPost) - router.HandleFunc("/api/v1/domains/{id}", am.AdminAccess(ah.putDomain)).Methods(http.MethodPut) - router.HandleFunc("/api/v1/domains/{id}", am.AdminAccess(ah.deleteDomain)).Methods(http.MethodDelete) // base overrides router.HandleFunc("/api/v1/version", am.OpenAccess(ah.getVersion)).Methods(http.MethodGet) - router.HandleFunc("/api/v1/login", am.OpenAccess(ah.loginUser)).Methods(http.MethodPost) - - // PAT APIs - router.HandleFunc("/api/v1/pats", am.AdminAccess(ah.Signoz.Handlers.User.CreateAPIKey)).Methods(http.MethodPost) - router.HandleFunc("/api/v1/pats", am.AdminAccess(ah.Signoz.Handlers.User.ListAPIKeys)).Methods(http.MethodGet) - router.HandleFunc("/api/v1/pats/{id}", am.AdminAccess(ah.Signoz.Handlers.User.UpdateAPIKey)).Methods(http.MethodPut) - router.HandleFunc("/api/v1/pats/{id}", am.AdminAccess(ah.Signoz.Handlers.User.RevokeAPIKey)).Methods(http.MethodDelete) - router.HandleFunc("/api/v1/checkout", am.AdminAccess(ah.checkout)).Methods(http.MethodPost) + router.HandleFunc("/api/v1/checkout", am.AdminAccess(ah.LicensingAPI.Checkout)).Methods(http.MethodPost) router.HandleFunc("/api/v1/billing", am.AdminAccess(ah.getBilling)).Methods(http.MethodGet) - router.HandleFunc("/api/v1/portal", am.AdminAccess(ah.portalSession)).Methods(http.MethodPost) - - router.HandleFunc("/api/v1/dashboards/{uuid}/lock", am.EditAccess(ah.lockDashboard)).Methods(http.MethodPut) - router.HandleFunc("/api/v1/dashboards/{uuid}/unlock", am.EditAccess(ah.unlockDashboard)).Methods(http.MethodPut) + router.HandleFunc("/api/v1/portal", am.AdminAccess(ah.LicensingAPI.Portal)).Methods(http.MethodPost) // v3 - router.HandleFunc("/api/v3/licenses", am.ViewAccess(ah.listLicensesV3)).Methods(http.MethodGet) - router.HandleFunc("/api/v3/licenses", am.AdminAccess(ah.applyLicenseV3)).Methods(http.MethodPost) - router.HandleFunc("/api/v3/licenses", am.AdminAccess(ah.refreshLicensesV3)).Methods(http.MethodPut) - router.HandleFunc("/api/v3/licenses/active", am.ViewAccess(ah.getActiveLicenseV3)).Methods(http.MethodGet) + router.HandleFunc("/api/v3/licenses", am.AdminAccess(ah.LicensingAPI.Activate)).Methods(http.MethodPost) + router.HandleFunc("/api/v3/licenses", am.AdminAccess(ah.LicensingAPI.Refresh)).Methods(http.MethodPut) + router.HandleFunc("/api/v3/licenses/active", am.ViewAccess(ah.LicensingAPI.GetActive)).Methods(http.MethodGet) // v4 router.HandleFunc("/api/v4/query_range", am.ViewAccess(ah.queryRangeV4)).Methods(http.MethodPost) + // v5 + router.HandleFunc("/api/v5/query_range", am.ViewAccess(ah.queryRangeV5)).Methods(http.MethodPost) + + router.HandleFunc("/api/v5/substitute_vars", am.ViewAccess(ah.QuerierAPI.ReplaceVariables)).Methods(http.MethodPost) + // Gateway router.PathPrefix(gateway.RoutePrefix).HandlerFunc(am.EditAccess(ah.ServeGatewayHTTP)) @@ -174,54 +122,6 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) { } -// TODO(nitya): remove this once we know how to get the FF's -func (ah *APIHandler) updateRequestContext(w http.ResponseWriter, r *http.Request) (*http.Request, error) { - ssoAvailable := true - err := ah.FF().CheckFeature(model.SSO) - if err != nil { - switch err.(type) { - case basemodel.ErrFeatureUnavailable: - // do nothing, just skip sso - ssoAvailable = false - default: - zap.L().Error("feature check failed", zap.String("featureKey", model.SSO), zap.Error(err)) - return r, errors.New(errors.TypeInternal, errors.CodeInternal, "error checking SSO feature") - } - } - ctx := context.WithValue(r.Context(), types.SSOAvailable, ssoAvailable) - return r.WithContext(ctx), nil -} - -func (ah *APIHandler) loginPrecheck(w http.ResponseWriter, r *http.Request) { - r, err := ah.updateRequestContext(w, r) - if err != nil { - render.Error(w, err) - return - } - ah.Signoz.Handlers.User.LoginPrecheck(w, r) - return -} - -func (ah *APIHandler) acceptInvite(w http.ResponseWriter, r *http.Request) { - r, err := ah.updateRequestContext(w, r) - if err != nil { - render.Error(w, err) - return - } - ah.Signoz.Handlers.User.AcceptInvite(w, r) - return -} - -func (ah *APIHandler) getInvite(w http.ResponseWriter, r *http.Request) { - r, err := ah.updateRequestContext(w, r) - if err != nil { - render.Error(w, err) - return - } - ah.Signoz.Handlers.User.GetInvite(w, r) - return -} - func (ah *APIHandler) RegisterCloudIntegrationsRoutes(router *mux.Router, am *middleware.AuthZ) { ah.APIHandler.RegisterCloudIntegrationsRoutes(router, am) diff --git a/ee/query-service/app/api/auth.go b/ee/query-service/app/api/auth.go index 1d90df80d5b..3ffa247440c 100644 --- a/ee/query-service/app/api/auth.go +++ b/ee/query-service/app/api/auth.go @@ -3,41 +3,16 @@ package api import ( "context" "encoding/base64" - "encoding/json" "fmt" - "io" "net/http" "net/url" "go.uber.org/zap" - "github.com/SigNoz/signoz/ee/query-service/constants" - "github.com/SigNoz/signoz/ee/query-service/model" - "github.com/SigNoz/signoz/pkg/http/render" + "github.com/SigNoz/signoz/pkg/query-service/constants" + "github.com/SigNoz/signoz/pkg/valuer" ) -func parseRequest(r *http.Request, req interface{}) error { - defer r.Body.Close() - requestBody, err := io.ReadAll(r.Body) - if err != nil { - return err - } - - err = json.Unmarshal(requestBody, &req) - return err -} - -// loginUser overrides base handler and considers SSO case. -func (ah *APIHandler) loginUser(w http.ResponseWriter, r *http.Request) { - r, err := ah.updateRequestContext(w, r) - if err != nil { - render.Error(w, err) - return - } - ah.Signoz.Handlers.User.Login(w, r) - return -} - 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))) @@ -46,84 +21,12 @@ func handleSsoError(w http.ResponseWriter, r *http.Request, redirectURL string) http.Redirect(w, r, fmt.Sprintf("%s?ssoerror=%s", redirectURL, string(dst)), http.StatusSeeOther) } -// receiveGoogleAuth completes google OAuth response and forwards a request -// to front-end to sign user in -func (ah *APIHandler) receiveGoogleAuth(w http.ResponseWriter, r *http.Request) { - redirectUri := constants.GetDefaultSiteURL() - ctx := context.Background() - - if !ah.CheckFeature(model.SSO) { - zap.L().Error("[receiveGoogleAuth] 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 - } - - q := r.URL.Query() - if errType := q.Get("error"); errType != "" { - zap.L().Error("[receiveGoogleAuth] failed to login with google auth", zap.String("error", errType), zap.String("error_description", q.Get("error_description"))) - http.Redirect(w, r, fmt.Sprintf("%s?ssoerror=%s", redirectUri, "failed to login through SSO "), http.StatusMovedPermanently) - return - } - - relayState := q.Get("state") - zap.L().Debug("[receiveGoogleAuth] relay state received", zap.String("state", relayState)) - - parsedState, err := url.Parse(relayState) - if err != nil || relayState == "" { - zap.L().Error("[receiveGoogleAuth] 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.AppDao().GetDomainFromSsoResponse(ctx, parsedState) - if err != nil { - handleSsoError(w, r, redirectUri) - return - } - - // now that we have domain, use domain to fetch sso settings. - // prepare google callback handler using parsedState - - // which contains redirect URL (front-end endpoint) - callbackHandler, err := domain.PrepareGoogleOAuthProvider(parsedState) - if err != nil { - zap.L().Error("[receiveGoogleAuth] failed to prepare google oauth provider", zap.String("domain", domain.String()), zap.Error(err)) - handleSsoError(w, r, redirectUri) - return - } - - identity, err := callbackHandler.HandleCallback(r) - if err != nil { - zap.L().Error("[receiveGoogleAuth] failed to process HandleCallback ", zap.String("domain", domain.String()), zap.Error(err)) - handleSsoError(w, r, redirectUri) - return - } - - nextPage, err := ah.Signoz.Modules.User.PrepareSsoRedirect(ctx, redirectUri, identity.Email, ah.opts.JWT) - if err != nil { - zap.L().Error("[receiveGoogleAuth] 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) -} - // 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() - if !ah.CheckFeature(model.SSO) { - 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 - } - 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)) @@ -147,12 +50,25 @@ func (ah *APIHandler) receiveSAML(w http.ResponseWriter, r *http.Request) { redirectUri = fmt.Sprintf("%s://%s%s", parsedState.Scheme, parsedState.Host, "/login") // fetch domain by parsing relay state. - domain, err := ah.AppDao().GetDomainFromSsoResponse(ctx, parsedState) + 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)) @@ -180,7 +96,7 @@ func (ah *APIHandler) receiveSAML(w http.ResponseWriter, r *http.Request) { return } - nextPage, err := ah.Signoz.Modules.User.PrepareSsoRedirect(ctx, redirectUri, email, ah.opts.JWT) + 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) diff --git a/ee/query-service/app/api/cloudIntegrations.go b/ee/query-service/app/api/cloudIntegrations.go index 1251a524032..101646e4ee2 100644 --- a/ee/query-service/app/api/cloudIntegrations.go +++ b/ee/query-service/app/api/cloudIntegrations.go @@ -13,11 +13,11 @@ import ( "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" basemodel "github.com/SigNoz/signoz/pkg/query-service/model" "github.com/SigNoz/signoz/pkg/types" "github.com/SigNoz/signoz/pkg/types/authtypes" "github.com/SigNoz/signoz/pkg/valuer" - "github.com/google/uuid" "github.com/gorilla/mux" "go.uber.org/zap" ) @@ -36,6 +36,12 @@ func (ah *APIHandler) CloudIntegrationsGenerateConnectionParams(w http.ResponseW return } + orgID, err := valuer.NewUUID(claims.OrgID) + if err != nil { + render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgId is invalid")) + return + } + cloudProvider := mux.Vars(r)["cloudProvider"] if cloudProvider != "aws" { RespondError(w, basemodel.BadRequest(fmt.Errorf( @@ -56,11 +62,9 @@ func (ah *APIHandler) CloudIntegrationsGenerateConnectionParams(w http.ResponseW SigNozAPIKey: apiKey, } - license, apiErr := ah.LM().GetRepo().GetActiveLicense(r.Context()) - if apiErr != nil { - RespondError(w, basemodel.WrapApiError( - apiErr, "couldn't look for active license", - ), nil) + license, err := ah.Signoz.Licensing.GetActive(r.Context(), orgID) + if err != nil { + render.Error(w, err) return } @@ -188,14 +192,14 @@ func (ah *APIHandler) getOrCreateCloudIntegrationUser( )) } - password, err := types.NewFactorPassword(uuid.NewString()) + password := types.MustGenerateFactorPassword(newUser.ID.StringValue()) - integrationUser, err := ah.Signoz.Modules.User.CreateUserWithPassword(ctx, newUser, password) + err = ah.Signoz.Modules.User.CreateUser(ctx, newUser, user.WithFactorPassword(password)) if err != nil { return nil, basemodel.InternalError(fmt.Errorf("couldn't create cloud integration user: %w", err)) } - return integrationUser, nil + return newUser, nil } func getIngestionUrlAndSigNozAPIUrl(ctx context.Context, licenseKey string) ( diff --git a/ee/query-service/app/api/dashboard.go b/ee/query-service/app/api/dashboard.go deleted file mode 100644 index a0e30ecf8c7..00000000000 --- a/ee/query-service/app/api/dashboard.go +++ /dev/null @@ -1,62 +0,0 @@ -package api - -import ( - "net/http" - "strings" - - "github.com/SigNoz/signoz/pkg/errors" - "github.com/SigNoz/signoz/pkg/http/render" - "github.com/SigNoz/signoz/pkg/types/authtypes" - "github.com/gorilla/mux" -) - -func (ah *APIHandler) lockDashboard(w http.ResponseWriter, r *http.Request) { - ah.lockUnlockDashboard(w, r, true) -} - -func (ah *APIHandler) unlockDashboard(w http.ResponseWriter, r *http.Request) { - ah.lockUnlockDashboard(w, r, false) -} - -func (ah *APIHandler) lockUnlockDashboard(w http.ResponseWriter, r *http.Request, lock bool) { - // Locking can only be done by the owner of the dashboard - // or an admin - - // - Fetch the dashboard - // - Check if the user is the owner or an admin - // - If yes, lock/unlock the dashboard - // - If no, return 403 - - // Get the dashboard UUID from the request - uuid := mux.Vars(r)["uuid"] - if strings.HasPrefix(uuid, "integration") { - render.Error(w, errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "dashboards created by integrations cannot be modified")) - return - } - - claims, err := authtypes.ClaimsFromContext(r.Context()) - if err != nil { - render.Error(w, errors.Newf(errors.TypeUnauthenticated, errors.CodeUnauthenticated, "unauthenticated")) - return - } - - dashboard, err := ah.Signoz.Modules.Dashboard.Get(r.Context(), claims.OrgID, uuid) - if err != nil { - render.Error(w, err) - return - } - - if err := claims.IsAdmin(); err != nil && (dashboard.CreatedBy != claims.Email) { - render.Error(w, errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "You are not authorized to lock/unlock this dashboard")) - return - } - - // Lock/Unlock the dashboard - err = ah.Signoz.Modules.Dashboard.LockUnlock(r.Context(), claims.OrgID, uuid, lock) - if err != nil { - render.Error(w, err) - return - } - - ah.Respond(w, "Dashboard updated successfully") -} diff --git a/ee/query-service/app/api/domains.go b/ee/query-service/app/api/domains.go deleted file mode 100644 index 770c2048f98..00000000000 --- a/ee/query-service/app/api/domains.go +++ /dev/null @@ -1,91 +0,0 @@ -package api - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - - "github.com/SigNoz/signoz/ee/query-service/model" - "github.com/SigNoz/signoz/pkg/types" - "github.com/google/uuid" - "github.com/gorilla/mux" -) - -func (ah *APIHandler) listDomainsByOrg(w http.ResponseWriter, r *http.Request) { - orgId := mux.Vars(r)["orgId"] - domains, apierr := ah.AppDao().ListDomains(context.Background(), orgId) - if apierr != nil { - RespondError(w, apierr, domains) - return - } - ah.Respond(w, domains) -} - -func (ah *APIHandler) postDomain(w http.ResponseWriter, r *http.Request) { - ctx := context.Background() - - req := types.GettableOrgDomain{} - - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - RespondError(w, model.BadRequest(err), nil) - return - } - - if err := req.ValidNew(); err != nil { - RespondError(w, model.BadRequest(err), nil) - return - } - - if apierr := ah.AppDao().CreateDomain(ctx, &req); apierr != nil { - RespondError(w, apierr, nil) - return - } - - ah.Respond(w, &req) -} - -func (ah *APIHandler) putDomain(w http.ResponseWriter, r *http.Request) { - ctx := context.Background() - - domainIdStr := mux.Vars(r)["id"] - domainId, err := uuid.Parse(domainIdStr) - if err != nil { - RespondError(w, model.BadRequest(err), nil) - return - } - - req := types.GettableOrgDomain{StorableOrgDomain: types.StorableOrgDomain{ID: domainId}} - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - RespondError(w, model.BadRequest(err), nil) - return - } - req.ID = domainId - if err := req.Valid(nil); err != nil { - RespondError(w, model.BadRequest(err), nil) - } - - if apierr := ah.AppDao().UpdateDomain(ctx, &req); apierr != nil { - RespondError(w, apierr, nil) - return - } - - ah.Respond(w, &req) -} - -func (ah *APIHandler) deleteDomain(w http.ResponseWriter, r *http.Request) { - domainIdStr := mux.Vars(r)["id"] - - domainId, err := uuid.Parse(domainIdStr) - if err != nil { - RespondError(w, model.BadRequest(fmt.Errorf("invalid domain id")), nil) - return - } - - apierr := ah.AppDao().DeleteDomain(context.Background(), domainId) - if apierr != nil { - RespondError(w, apierr, nil) - return - } - ah.Respond(w, nil) -} diff --git a/ee/query-service/app/api/featureFlags.go b/ee/query-service/app/api/featureFlags.go index 1feca4b7645..a01ad40e87c 100644 --- a/ee/query-service/app/api/featureFlags.go +++ b/ee/query-service/app/api/featureFlags.go @@ -9,13 +9,29 @@ import ( "time" "github.com/SigNoz/signoz/ee/query-service/constants" - basemodel "github.com/SigNoz/signoz/pkg/query-service/model" + pkgError "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/http/render" + "github.com/SigNoz/signoz/pkg/types/authtypes" + "github.com/SigNoz/signoz/pkg/types/licensetypes" + "github.com/SigNoz/signoz/pkg/valuer" "go.uber.org/zap" ) func (ah *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - featureSet, err := ah.FF().GetFeatureFlags() + claims, err := authtypes.ClaimsFromContext(ctx) + if err != nil { + render.Error(w, err) + return + } + + orgID, err := valuer.NewUUID(claims.OrgID) + if err != nil { + render.Error(w, pkgError.Newf(pkgError.TypeInvalidInput, pkgError.CodeInvalidInput, "orgId is invalid")) + return + } + + featureSet, err := ah.Signoz.Licensing.GetFeatureFlags(r.Context(), orgID) if err != nil { ah.HandleError(w, err, http.StatusInternalServerError) return @@ -23,7 +39,7 @@ func (ah *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) { if constants.FetchFeatures == "true" { zap.L().Debug("fetching license") - license, err := ah.LM().GetRepo().GetActiveLicense(ctx) + license, err := ah.Signoz.Licensing.GetActive(ctx, orgID) if err != nil { zap.L().Error("failed to fetch license", zap.Error(err)) } else if license == nil { @@ -43,10 +59,17 @@ func (ah *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) { } } - if ah.opts.PreferSpanMetrics { - for idx := range featureSet { - feature := &featureSet[idx] - if feature.Name == basemodel.UseSpanMetrics { + if constants.IsPreferSpanMetrics { + for idx, feature := range featureSet { + if feature.Name == licensetypes.UseSpanMetrics { + featureSet[idx].Active = true + } + } + } + + if constants.IsDotMetricsEnabled { + for idx, feature := range featureSet { + if feature.Name == licensetypes.DotMetricsEnabled { featureSet[idx].Active = true } } @@ -57,7 +80,7 @@ func (ah *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) { // fetchZeusFeatures makes an HTTP GET request to the /zeusFeatures endpoint // and returns the FeatureSet. -func fetchZeusFeatures(url, licenseKey string) (basemodel.FeatureSet, error) { +func fetchZeusFeatures(url, licenseKey string) ([]*licensetypes.Feature, error) { // Check if the URL is empty if url == "" { return nil, fmt.Errorf("url is empty") @@ -116,28 +139,28 @@ func fetchZeusFeatures(url, licenseKey string) (basemodel.FeatureSet, error) { } type ZeusFeaturesResponse struct { - Status string `json:"status"` - Data basemodel.FeatureSet `json:"data"` + Status string `json:"status"` + Data []*licensetypes.Feature `json:"data"` } // MergeFeatureSets merges two FeatureSet arrays with precedence to zeusFeatures. -func MergeFeatureSets(zeusFeatures, internalFeatures basemodel.FeatureSet) basemodel.FeatureSet { +func MergeFeatureSets(zeusFeatures, internalFeatures []*licensetypes.Feature) []*licensetypes.Feature { // Create a map to store the merged features - featureMap := make(map[string]basemodel.Feature) + featureMap := make(map[string]*licensetypes.Feature) // Add all features from the otherFeatures set to the map for _, feature := range internalFeatures { - featureMap[feature.Name] = feature + featureMap[feature.Name.StringValue()] = feature } // Add all features from the zeusFeatures set to the map // If a feature already exists (i.e., same name), the zeusFeature will overwrite it for _, feature := range zeusFeatures { - featureMap[feature.Name] = feature + featureMap[feature.Name.StringValue()] = feature } // Convert the map back to a FeatureSet slice - var mergedFeatures basemodel.FeatureSet + var mergedFeatures []*licensetypes.Feature for _, feature := range featureMap { mergedFeatures = append(mergedFeatures, feature) } diff --git a/ee/query-service/app/api/featureFlags_test.go b/ee/query-service/app/api/featureFlags_test.go index e64e2ea135c..96194e41fa3 100644 --- a/ee/query-service/app/api/featureFlags_test.go +++ b/ee/query-service/app/api/featureFlags_test.go @@ -3,78 +3,79 @@ package api import ( "testing" - basemodel "github.com/SigNoz/signoz/pkg/query-service/model" + "github.com/SigNoz/signoz/pkg/types/licensetypes" + "github.com/SigNoz/signoz/pkg/valuer" "github.com/stretchr/testify/assert" ) func TestMergeFeatureSets(t *testing.T) { tests := []struct { name string - zeusFeatures basemodel.FeatureSet - internalFeatures basemodel.FeatureSet - expected basemodel.FeatureSet + zeusFeatures []*licensetypes.Feature + internalFeatures []*licensetypes.Feature + expected []*licensetypes.Feature }{ { name: "empty zeusFeatures and internalFeatures", - zeusFeatures: basemodel.FeatureSet{}, - internalFeatures: basemodel.FeatureSet{}, - expected: basemodel.FeatureSet{}, + zeusFeatures: []*licensetypes.Feature{}, + internalFeatures: []*licensetypes.Feature{}, + expected: []*licensetypes.Feature{}, }, { name: "non-empty zeusFeatures and empty internalFeatures", - zeusFeatures: basemodel.FeatureSet{ - {Name: "Feature1", Active: true}, - {Name: "Feature2", Active: false}, + zeusFeatures: []*licensetypes.Feature{ + {Name: valuer.NewString("Feature1"), Active: true}, + {Name: valuer.NewString("Feature2"), Active: false}, }, - internalFeatures: basemodel.FeatureSet{}, - expected: basemodel.FeatureSet{ - {Name: "Feature1", Active: true}, - {Name: "Feature2", Active: false}, + internalFeatures: []*licensetypes.Feature{}, + expected: []*licensetypes.Feature{ + {Name: valuer.NewString("Feature1"), Active: true}, + {Name: valuer.NewString("Feature2"), Active: false}, }, }, { name: "empty zeusFeatures and non-empty internalFeatures", - zeusFeatures: basemodel.FeatureSet{}, - internalFeatures: basemodel.FeatureSet{ - {Name: "Feature1", Active: true}, - {Name: "Feature2", Active: false}, + zeusFeatures: []*licensetypes.Feature{}, + internalFeatures: []*licensetypes.Feature{ + {Name: valuer.NewString("Feature1"), Active: true}, + {Name: valuer.NewString("Feature2"), Active: false}, }, - expected: basemodel.FeatureSet{ - {Name: "Feature1", Active: true}, - {Name: "Feature2", Active: false}, + expected: []*licensetypes.Feature{ + {Name: valuer.NewString("Feature1"), Active: true}, + {Name: valuer.NewString("Feature2"), Active: false}, }, }, { name: "non-empty zeusFeatures and non-empty internalFeatures with no conflicts", - zeusFeatures: basemodel.FeatureSet{ - {Name: "Feature1", Active: true}, - {Name: "Feature3", Active: false}, + zeusFeatures: []*licensetypes.Feature{ + {Name: valuer.NewString("Feature1"), Active: true}, + {Name: valuer.NewString("Feature3"), Active: false}, }, - internalFeatures: basemodel.FeatureSet{ - {Name: "Feature2", Active: true}, - {Name: "Feature4", Active: false}, + internalFeatures: []*licensetypes.Feature{ + {Name: valuer.NewString("Feature2"), Active: true}, + {Name: valuer.NewString("Feature4"), Active: false}, }, - expected: basemodel.FeatureSet{ - {Name: "Feature1", Active: true}, - {Name: "Feature2", Active: true}, - {Name: "Feature3", Active: false}, - {Name: "Feature4", Active: false}, + expected: []*licensetypes.Feature{ + {Name: valuer.NewString("Feature1"), Active: true}, + {Name: valuer.NewString("Feature2"), Active: true}, + {Name: valuer.NewString("Feature3"), Active: false}, + {Name: valuer.NewString("Feature4"), Active: false}, }, }, { name: "non-empty zeusFeatures and non-empty internalFeatures with conflicts", - zeusFeatures: basemodel.FeatureSet{ - {Name: "Feature1", Active: true}, - {Name: "Feature2", Active: false}, + zeusFeatures: []*licensetypes.Feature{ + {Name: valuer.NewString("Feature1"), Active: true}, + {Name: valuer.NewString("Feature2"), Active: false}, }, - internalFeatures: basemodel.FeatureSet{ - {Name: "Feature1", Active: false}, - {Name: "Feature3", Active: true}, + internalFeatures: []*licensetypes.Feature{ + {Name: valuer.NewString("Feature1"), Active: false}, + {Name: valuer.NewString("Feature3"), Active: true}, }, - expected: basemodel.FeatureSet{ - {Name: "Feature1", Active: true}, - {Name: "Feature2", Active: false}, - {Name: "Feature3", Active: true}, + expected: []*licensetypes.Feature{ + {Name: valuer.NewString("Feature1"), Active: true}, + {Name: valuer.NewString("Feature2"), Active: false}, + {Name: valuer.NewString("Feature3"), Active: true}, }, }, } diff --git a/ee/query-service/app/api/gateway.go b/ee/query-service/app/api/gateway.go index 54fc1759ed1..fa1d52153fe 100644 --- a/ee/query-service/app/api/gateway.go +++ b/ee/query-service/app/api/gateway.go @@ -5,10 +5,26 @@ import ( "strings" "github.com/SigNoz/signoz/ee/query-service/integrations/gateway" + "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/http/render" + "github.com/SigNoz/signoz/pkg/types/authtypes" + "github.com/SigNoz/signoz/pkg/valuer" ) func (ah *APIHandler) ServeGatewayHTTP(rw http.ResponseWriter, req *http.Request) { ctx := req.Context() + claims, err := authtypes.ClaimsFromContext(ctx) + if err != nil { + render.Error(rw, err) + return + } + + orgID, err := valuer.NewUUID(claims.OrgID) + if err != nil { + render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgId is invalid")) + return + } + validPath := false for _, allowedPrefix := range gateway.AllowedPrefix { if strings.HasPrefix(req.URL.Path, gateway.RoutePrefix+allowedPrefix) { @@ -22,9 +38,9 @@ func (ah *APIHandler) ServeGatewayHTTP(rw http.ResponseWriter, req *http.Request return } - license, err := ah.LM().GetRepo().GetActiveLicense(ctx) + license, err := ah.Signoz.Licensing.GetActive(ctx, orgID) if err != nil { - RespondError(rw, err, nil) + render.Error(rw, err) return } diff --git a/ee/query-service/app/api/license.go b/ee/query-service/app/api/license.go index 8fbbb0cccc6..73b4ae56ace 100644 --- a/ee/query-service/app/api/license.go +++ b/ee/query-service/app/api/license.go @@ -6,11 +6,7 @@ import ( "net/http" "github.com/SigNoz/signoz/ee/query-service/constants" - "github.com/SigNoz/signoz/ee/query-service/integrations/signozio" "github.com/SigNoz/signoz/ee/query-service/model" - "github.com/SigNoz/signoz/pkg/http/render" - "github.com/SigNoz/signoz/pkg/query-service/telemetry" - "github.com/SigNoz/signoz/pkg/types/authtypes" ) type DayWiseBreakdown struct { @@ -49,10 +45,6 @@ type details struct { BillTotal float64 `json:"billTotal"` } -type Redirect struct { - RedirectURL string `json:"redirectURL"` -} - type billingDetails struct { Status string `json:"status"` Data struct { @@ -64,97 +56,6 @@ type billingDetails struct { } `json:"data"` } -type ApplyLicenseRequest struct { - LicenseKey string `json:"key"` -} - -func (ah *APIHandler) listLicensesV3(w http.ResponseWriter, r *http.Request) { - ah.listLicensesV2(w, r) -} - -func (ah *APIHandler) getActiveLicenseV3(w http.ResponseWriter, r *http.Request) { - activeLicense, err := ah.LM().GetRepo().GetActiveLicenseV3(r.Context()) - if err != nil { - RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil) - return - } - - // return 404 not found if there is no active license - if activeLicense == nil { - RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("no active license found")}, nil) - return - } - - // TODO deprecate this when we move away from key for stripe - activeLicense.Data["key"] = activeLicense.Key - render.Success(w, http.StatusOK, activeLicense.Data) -} - -// this function is called by zeus when inserting licenses in the query-service -func (ah *APIHandler) applyLicenseV3(w http.ResponseWriter, r *http.Request) { - claims, err := authtypes.ClaimsFromContext(r.Context()) - if err != nil { - render.Error(w, err) - return - } - - var licenseKey ApplyLicenseRequest - if err := json.NewDecoder(r.Body).Decode(&licenseKey); err != nil { - RespondError(w, model.BadRequest(err), nil) - return - } - - if licenseKey.LicenseKey == "" { - RespondError(w, model.BadRequest(fmt.Errorf("license key is required")), nil) - return - } - - _, err = ah.LM().ActivateV3(r.Context(), licenseKey.LicenseKey) - if err != nil { - telemetry.GetInstance().SendEvent(telemetry.TELEMETRY_LICENSE_ACT_FAILED, map[string]interface{}{"err": err.Error()}, claims.Email, true, false) - render.Error(w, err) - return - } - - render.Success(w, http.StatusAccepted, nil) -} - -func (ah *APIHandler) refreshLicensesV3(w http.ResponseWriter, r *http.Request) { - err := ah.LM().RefreshLicense(r.Context()) - if err != nil { - render.Error(w, err) - return - } - - render.Success(w, http.StatusNoContent, nil) -} - -func getCheckoutPortalResponse(redirectURL string) *Redirect { - return &Redirect{RedirectURL: redirectURL} -} - -func (ah *APIHandler) checkout(w http.ResponseWriter, r *http.Request) { - checkoutRequest := &model.CheckoutRequest{} - if err := json.NewDecoder(r.Body).Decode(checkoutRequest); err != nil { - RespondError(w, model.BadRequest(err), nil) - return - } - - license := ah.LM().GetActiveLicense() - if license == nil { - RespondError(w, model.BadRequestStr("cannot proceed with checkout without license key"), nil) - return - } - - redirectUrl, err := signozio.CheckoutSession(r.Context(), checkoutRequest, license.Key, ah.Signoz.Zeus) - if err != nil { - render.Error(w, err) - return - } - - ah.Respond(w, getCheckoutPortalResponse(redirectUrl)) -} - func (ah *APIHandler) getBilling(w http.ResponseWriter, r *http.Request) { licenseKey := r.URL.Query().Get("licenseKey") @@ -188,71 +89,3 @@ func (ah *APIHandler) getBilling(w http.ResponseWriter, r *http.Request) { // TODO(srikanthccv):Fetch the current day usage and add it to the response ah.Respond(w, billingResponse.Data) } - -func convertLicenseV3ToLicenseV2(licenses []*model.LicenseV3) []model.License { - licensesV2 := []model.License{} - for _, l := range licenses { - planKeyFromPlanName, ok := model.MapOldPlanKeyToNewPlanName[l.PlanName] - if !ok { - planKeyFromPlanName = model.Basic - } - licenseV2 := model.License{ - Key: l.Key, - ActivationId: "", - PlanDetails: "", - FeatureSet: l.Features, - ValidationMessage: "", - IsCurrent: l.IsCurrent, - LicensePlan: model.LicensePlan{ - PlanKey: planKeyFromPlanName, - ValidFrom: l.ValidFrom, - ValidUntil: l.ValidUntil, - Status: l.Status}, - } - licensesV2 = append(licensesV2, licenseV2) - } - return licensesV2 -} - -func (ah *APIHandler) listLicensesV2(w http.ResponseWriter, r *http.Request) { - licensesV3, apierr := ah.LM().GetLicensesV3(r.Context()) - if apierr != nil { - RespondError(w, apierr, nil) - return - } - licenses := convertLicenseV3ToLicenseV2(licensesV3) - - resp := model.Licenses{ - TrialStart: -1, - TrialEnd: -1, - OnTrial: false, - WorkSpaceBlock: false, - TrialConvertedToSubscription: false, - GracePeriodEnd: -1, - Licenses: licenses, - } - - ah.Respond(w, resp) -} - -func (ah *APIHandler) portalSession(w http.ResponseWriter, r *http.Request) { - portalRequest := &model.PortalRequest{} - if err := json.NewDecoder(r.Body).Decode(portalRequest); err != nil { - RespondError(w, model.BadRequest(err), nil) - return - } - - license := ah.LM().GetActiveLicense() - if license == nil { - RespondError(w, model.BadRequestStr("cannot request the portal session without license key"), nil) - return - } - - redirectUrl, err := signozio.PortalSession(r.Context(), portalRequest, license.Key, ah.Signoz.Zeus) - if err != nil { - render.Error(w, err) - return - } - - ah.Respond(w, getCheckoutPortalResponse(redirectUrl)) -} diff --git a/ee/query-service/app/api/queryrange.go b/ee/query-service/app/api/queryrange.go index e6801198e43..698ac2d91c7 100644 --- a/ee/query-service/app/api/queryrange.go +++ b/ee/query-service/app/api/queryrange.go @@ -2,11 +2,16 @@ package api import ( "bytes" + "context" + "encoding/json" "fmt" "io" "net/http" + "runtime/debug" + anomalyV2 "github.com/SigNoz/signoz/ee/anomaly" "github.com/SigNoz/signoz/ee/query-service/anomaly" + "github.com/SigNoz/signoz/pkg/errors" "github.com/SigNoz/signoz/pkg/http/render" baseapp "github.com/SigNoz/signoz/pkg/query-service/app" "github.com/SigNoz/signoz/pkg/query-service/app/queryBuilder" @@ -15,6 +20,8 @@ import ( "github.com/SigNoz/signoz/pkg/types/authtypes" "github.com/SigNoz/signoz/pkg/valuer" "go.uber.org/zap" + + qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5" ) func (aH *APIHandler) queryRangeV4(w http.ResponseWriter, r *http.Request) { @@ -136,3 +143,139 @@ func (aH *APIHandler) queryRangeV4(w http.ResponseWriter, r *http.Request) { aH.QueryRangeV4(w, r) } } + +func extractSeasonality(anomalyQuery *qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]) anomalyV2.Seasonality { + for _, fn := range anomalyQuery.Functions { + if fn.Name == qbtypes.FunctionNameAnomaly { + for _, arg := range fn.Args { + if arg.Name == "seasonality" { + if seasonalityStr, ok := arg.Value.(string); ok { + switch seasonalityStr { + case "weekly": + return anomalyV2.SeasonalityWeekly + case "hourly": + return anomalyV2.SeasonalityHourly + } + } + } + } + } + } + return anomalyV2.SeasonalityDaily // default +} + +func createAnomalyProvider(aH *APIHandler, seasonality anomalyV2.Seasonality) anomalyV2.Provider { + switch seasonality { + case anomalyV2.SeasonalityWeekly: + return anomalyV2.NewWeeklyProvider( + anomalyV2.WithQuerier[*anomalyV2.WeeklyProvider](aH.Signoz.Querier), + anomalyV2.WithLogger[*anomalyV2.WeeklyProvider](aH.Signoz.Instrumentation.Logger()), + ) + case anomalyV2.SeasonalityHourly: + return anomalyV2.NewHourlyProvider( + anomalyV2.WithQuerier[*anomalyV2.HourlyProvider](aH.Signoz.Querier), + anomalyV2.WithLogger[*anomalyV2.HourlyProvider](aH.Signoz.Instrumentation.Logger()), + ) + default: + return anomalyV2.NewDailyProvider( + anomalyV2.WithQuerier[*anomalyV2.DailyProvider](aH.Signoz.Querier), + anomalyV2.WithLogger[*anomalyV2.DailyProvider](aH.Signoz.Instrumentation.Logger()), + ) + } +} + +func (aH *APIHandler) handleAnomalyQuery(ctx context.Context, orgID valuer.UUID, anomalyQuery *qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation], queryRangeRequest qbtypes.QueryRangeRequest) (*anomalyV2.AnomaliesResponse, error) { + seasonality := extractSeasonality(anomalyQuery) + provider := createAnomalyProvider(aH, seasonality) + + return provider.GetAnomalies(ctx, orgID, &anomalyV2.AnomaliesRequest{Params: queryRangeRequest}) +} + +func (aH *APIHandler) queryRangeV5(rw http.ResponseWriter, req *http.Request) { + + bodyBytes, err := io.ReadAll(req.Body) + if err != nil { + render.Error(rw, errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to read request body: %v", err)) + return + } + req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + + ctx := req.Context() + + claims, err := authtypes.ClaimsFromContext(ctx) + if err != nil { + render.Error(rw, err) + return + } + + var queryRangeRequest qbtypes.QueryRangeRequest + if err := json.NewDecoder(req.Body).Decode(&queryRangeRequest); err != nil { + render.Error(rw, errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to decode request body: %v", err)) + return + } + + defer func() { + if r := recover(); r != nil { + stackTrace := string(debug.Stack()) + + queryJSON, _ := json.Marshal(queryRangeRequest) + + aH.Signoz.Instrumentation.Logger().ErrorContext(ctx, "panic in QueryRange", + "error", r, + "user", claims.UserID, + "payload", string(queryJSON), + "stacktrace", stackTrace, + ) + + render.Error(rw, errors.NewInternalf( + errors.CodeInternal, + "Something went wrong on our end. It's not you, it's us. Our team is notified about it. Reach out to support if issue persists.", + )) + } + }() + + if err := queryRangeRequest.Validate(); err != nil { + render.Error(rw, err) + return + } + + orgID, err := valuer.NewUUID(claims.OrgID) + if err != nil { + render.Error(rw, err) + return + } + + if anomalyQuery, ok := queryRangeRequest.IsAnomalyRequest(); ok { + anomalies, err := aH.handleAnomalyQuery(ctx, orgID, anomalyQuery, queryRangeRequest) + if err != nil { + render.Error(rw, errors.NewInternalf(errors.CodeInternal, "failed to get anomalies: %v", err)) + return + } + + results := []any{} + for _, item := range anomalies.Results { + results = append(results, item) + } + + finalResp := &qbtypes.QueryRangeResponse{ + Type: queryRangeRequest.RequestType, + Data: struct { + Results []any `json:"results"` + }{ + Results: results, + }, + Meta: struct { + RowsScanned uint64 `json:"rowsScanned"` + BytesScanned uint64 `json:"bytesScanned"` + DurationMS uint64 `json:"durationMs"` + }{}, + } + + render.Success(rw, http.StatusOK, finalResp) + return + } else { + // regular query range request, let the querier handle it + req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + aH.QuerierAPI.QueryRange(rw, req) + } +} diff --git a/ee/query-service/app/db/reader.go b/ee/query-service/app/db/reader.go deleted file mode 100644 index c518f5042d3..00000000000 --- a/ee/query-service/app/db/reader.go +++ /dev/null @@ -1,39 +0,0 @@ -package db - -import ( - "time" - - "github.com/ClickHouse/clickhouse-go/v2" - - "github.com/SigNoz/signoz/pkg/cache" - "github.com/SigNoz/signoz/pkg/prometheus" - basechr "github.com/SigNoz/signoz/pkg/query-service/app/clickhouseReader" - "github.com/SigNoz/signoz/pkg/sqlstore" - "github.com/SigNoz/signoz/pkg/telemetrystore" -) - -type ClickhouseReader struct { - conn clickhouse.Conn - appdb sqlstore.SQLStore - *basechr.ClickHouseReader -} - -func NewDataConnector( - sqlDB sqlstore.SQLStore, - telemetryStore telemetrystore.TelemetryStore, - prometheus prometheus.Prometheus, - cluster string, - fluxIntervalForTraceDetail time.Duration, - cache cache.Cache, -) *ClickhouseReader { - chReader := basechr.NewReader(sqlDB, telemetryStore, prometheus, cluster, fluxIntervalForTraceDetail, cache) - return &ClickhouseReader{ - conn: telemetryStore.ClickhouseDB(), - appdb: sqlDB, - ClickHouseReader: chReader, - } -} - -func (r *ClickhouseReader) GetSQLStore() sqlstore.SQLStore { - return r.appdb -} diff --git a/ee/query-service/app/server.go b/ee/query-service/app/server.go index fb94661b31c..dab2b7f5163 100644 --- a/ee/query-service/app/server.go +++ b/ee/query-service/app/server.go @@ -3,25 +3,25 @@ package app import ( "context" "fmt" + "log/slog" "net" "net/http" _ "net/http/pprof" // http profiler - "time" + + "github.com/SigNoz/signoz/pkg/ruler/rulestore/sqlrulestore" "github.com/gorilla/handlers" - "github.com/jmoiron/sqlx" - eemiddleware "github.com/SigNoz/signoz/ee/http/middleware" "github.com/SigNoz/signoz/ee/query-service/app/api" - "github.com/SigNoz/signoz/ee/query-service/app/db" - "github.com/SigNoz/signoz/ee/query-service/constants" - "github.com/SigNoz/signoz/ee/query-service/dao/sqlite" "github.com/SigNoz/signoz/ee/query-service/integrations/gateway" "github.com/SigNoz/signoz/ee/query-service/rules" + "github.com/SigNoz/signoz/ee/query-service/usage" "github.com/SigNoz/signoz/pkg/alertmanager" "github.com/SigNoz/signoz/pkg/cache" "github.com/SigNoz/signoz/pkg/http/middleware" + "github.com/SigNoz/signoz/pkg/modules/organization" "github.com/SigNoz/signoz/pkg/prometheus" + "github.com/SigNoz/signoz/pkg/querier" "github.com/SigNoz/signoz/pkg/signoz" "github.com/SigNoz/signoz/pkg/sqlstore" "github.com/SigNoz/signoz/pkg/telemetrystore" @@ -30,11 +30,9 @@ import ( "github.com/rs/cors" "github.com/soheilhy/cmux" - licensepkg "github.com/SigNoz/signoz/ee/query-service/license" - "github.com/SigNoz/signoz/ee/query-service/usage" - "github.com/SigNoz/signoz/pkg/query-service/agentConf" baseapp "github.com/SigNoz/signoz/pkg/query-service/app" + "github.com/SigNoz/signoz/pkg/query-service/app/clickhouseReader" "github.com/SigNoz/signoz/pkg/query-service/app/cloudintegrations" "github.com/SigNoz/signoz/pkg/query-service/app/integrations" "github.com/SigNoz/signoz/pkg/query-service/app/logparsingpipeline" @@ -44,86 +42,56 @@ import ( "github.com/SigNoz/signoz/pkg/query-service/healthcheck" baseint "github.com/SigNoz/signoz/pkg/query-service/interfaces" baserules "github.com/SigNoz/signoz/pkg/query-service/rules" - "github.com/SigNoz/signoz/pkg/query-service/telemetry" "github.com/SigNoz/signoz/pkg/query-service/utils" "go.uber.org/zap" ) -type ServerOptions struct { - Config signoz.Config - SigNoz *signoz.SigNoz - HTTPHostPort string - PrivateHostPort string - PreferSpanMetrics bool - FluxInterval string - FluxIntervalForTraceDetail string - Cluster string - GatewayUrl string - Jwt *authtypes.JWT -} - -// Server runs HTTP api service +// Server runs HTTP, Mux and a grpc server type Server struct { - serverOptions *ServerOptions - ruleManager *baserules.Manager + config signoz.Config + signoz *signoz.SigNoz + jwt *authtypes.JWT + ruleManager *baserules.Manager // public http router - httpConn net.Listener - httpServer *http.Server + httpConn net.Listener + httpServer *http.Server + httpHostPort string - // private http - privateConn net.Listener - privateHTTP *http.Server + opampServer *opamp.Server // Usage manager usageManager *usage.Manager - opampServer *opamp.Server - unavailableChannel chan healthcheck.Status } -// HealthCheckStatus returns health check status channel a client can subscribe to -func (s Server) HealthCheckStatus() chan healthcheck.Status { - return s.unavailableChannel -} - // NewServer creates and initializes Server -func NewServer(serverOptions *ServerOptions) (*Server, error) { - modelDao := sqlite.NewModelDao(serverOptions.SigNoz.SQLStore) - gatewayProxy, err := gateway.NewProxy(serverOptions.GatewayUrl, gateway.RoutePrefix) - if err != nil { - return nil, err - } - - // initiate license manager - lm, err := licensepkg.StartManager(serverOptions.SigNoz.SQLStore.SQLxDB(), serverOptions.SigNoz.SQLStore, serverOptions.SigNoz.Zeus) - if err != nil { - return nil, err - } - - fluxIntervalForTraceDetail, err := time.ParseDuration(serverOptions.FluxIntervalForTraceDetail) +func NewServer(config signoz.Config, signoz *signoz.SigNoz, jwt *authtypes.JWT) (*Server, error) { + gatewayProxy, err := gateway.NewProxy(config.Gateway.URL.String(), gateway.RoutePrefix) if err != nil { return nil, err } - reader := db.NewDataConnector( - serverOptions.SigNoz.SQLStore, - serverOptions.SigNoz.TelemetryStore, - serverOptions.SigNoz.Prometheus, - serverOptions.Cluster, - fluxIntervalForTraceDetail, - serverOptions.SigNoz.Cache, + reader := clickhouseReader.NewReader( + signoz.SQLStore, + signoz.TelemetryStore, + signoz.Prometheus, + signoz.TelemetryStore.Cluster(), + config.Querier.FluxInterval, + signoz.Cache, ) rm, err := makeRulesManager( - serverOptions.SigNoz.SQLStore.SQLxDB(), reader, - serverOptions.SigNoz.Cache, - serverOptions.SigNoz.Alertmanager, - serverOptions.SigNoz.SQLStore, - serverOptions.SigNoz.TelemetryStore, - serverOptions.SigNoz.Prometheus, + signoz.Cache, + signoz.Alertmanager, + signoz.SQLStore, + signoz.TelemetryStore, + signoz.Prometheus, + signoz.Modules.OrgGetter, + signoz.Querier, + signoz.Instrumentation.Logger(), ) if err != nil { @@ -131,19 +99,16 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) { } // initiate opamp - _, err = opAmpModel.InitDB(serverOptions.SigNoz.SQLStore.SQLxDB()) - if err != nil { - return nil, err - } + opAmpModel.Init(signoz.SQLStore, signoz.Instrumentation.Logger(), signoz.Modules.OrgGetter) - integrationsController, err := integrations.NewController(serverOptions.SigNoz.SQLStore) + integrationsController, err := integrations.NewController(signoz.SQLStore) if err != nil { return nil, fmt.Errorf( "couldn't create integrations controller: %w", err, ) } - cloudIntegrationsController, err := cloudintegrations.NewController(serverOptions.SigNoz.SQLStore) + cloudIntegrationsController, err := cloudintegrations.NewController(signoz.SQLStore) if err != nil { return nil, fmt.Errorf( "couldn't create cloud provider integrations controller: %w", err, @@ -152,7 +117,8 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) { // ingestion pipelines manager logParsingPipelineController, err := logparsingpipeline.NewLogParsingPipelinesController( - serverOptions.SigNoz.SQLStore, integrationsController.GetPipelinesForInstalledIntegrations, + signoz.SQLStore, + integrationsController.GetPipelinesForInstalledIntegrations, ) if err != nil { return nil, err @@ -160,7 +126,7 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) { // initiate agent config handler agentConfMgr, err := agentConf.Initiate(&agentConf.ManagerOptions{ - DB: serverOptions.SigNoz.SQLStore.SQLxDB(), + Store: signoz.SQLStore, AgentFeatures: []agentConf.AgentFeature{logParsingPipelineController}, }) if err != nil { @@ -168,59 +134,44 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) { } // start the usagemanager - usageManager, err := usage.New(modelDao, lm.GetRepo(), serverOptions.SigNoz.TelemetryStore.ClickhouseDB(), serverOptions.SigNoz.Zeus) + usageManager, err := usage.New(signoz.Licensing, signoz.TelemetryStore.ClickhouseDB(), signoz.Zeus, signoz.Modules.OrgGetter) if err != nil { return nil, err } - err = usageManager.Start() - if err != nil { - return nil, err - } - - telemetry.GetInstance().SetReader(reader) - telemetry.GetInstance().SetSqlStore(serverOptions.SigNoz.SQLStore) - telemetry.GetInstance().SetSaasOperator(constants.SaasSegmentKey) - telemetry.GetInstance().SetSavedViewsInfoCallback(telemetry.GetSavedViewsInfo) - telemetry.GetInstance().SetAlertsInfoCallback(telemetry.GetAlertsInfo) - telemetry.GetInstance().SetGetUsersCallback(telemetry.GetUsers) - telemetry.GetInstance().SetUserCountCallback(telemetry.GetUserCount) - telemetry.GetInstance().SetDashboardsInfoCallback(telemetry.GetDashboardsInfo) - - fluxInterval, err := time.ParseDuration(serverOptions.FluxInterval) + err = usageManager.Start(context.Background()) if err != nil { return nil, err } apiOpts := api.APIHandlerOptions{ DataConnector: reader, - PreferSpanMetrics: serverOptions.PreferSpanMetrics, - AppDao: modelDao, RulesManager: rm, UsageManager: usageManager, - FeatureFlags: lm, - LicenseManager: lm, IntegrationsController: integrationsController, CloudIntegrationsController: cloudIntegrationsController, LogsParsingPipelineController: logParsingPipelineController, - FluxInterval: fluxInterval, + FluxInterval: config.Querier.FluxInterval, Gateway: gatewayProxy, - GatewayUrl: serverOptions.GatewayUrl, - JWT: serverOptions.Jwt, + GatewayUrl: config.Gateway.URL.String(), + JWT: jwt, } - apiHandler, err := api.NewAPIHandler(apiOpts, serverOptions.SigNoz) + apiHandler, err := api.NewAPIHandler(apiOpts, signoz) if err != nil { return nil, err } s := &Server{ + config: config, + signoz: signoz, + jwt: jwt, ruleManager: rm, - serverOptions: serverOptions, + httpHostPort: baseconst.HTTPHostPort, unavailableChannel: make(chan healthcheck.Status), usageManager: usageManager, } - httpServer, err := s.createPublicServer(apiHandler, serverOptions.SigNoz.Web) + httpServer, err := s.createPublicServer(apiHandler, signoz.Web) if err != nil { return nil, err @@ -228,76 +179,31 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) { s.httpServer = httpServer - privateServer, err := s.createPrivateServer(apiHandler) - if err != nil { - return nil, err - } - - s.privateHTTP = privateServer - s.opampServer = opamp.InitializeServer( - &opAmpModel.AllAgents, agentConfMgr, + &opAmpModel.AllAgents, agentConfMgr, signoz.Instrumentation, ) - orgs, err := apiHandler.Signoz.Modules.Organization.GetAll(context.Background()) - if err != nil { - return nil, err - } - for _, org := range orgs { - errorList := reader.PreloadMetricsMetadata(context.Background(), org.ID) - for _, er := range errorList { - zap.L().Error("failed to preload metrics metadata", zap.Error(er)) - } - } - return s, nil } -func (s *Server) createPrivateServer(apiHandler *api.APIHandler) (*http.Server, error) { - - r := baseapp.NewRouter() - - r.Use(middleware.NewAuth(zap.L(), s.serverOptions.Jwt, []string{"Authorization", "Sec-WebSocket-Protocol"}).Wrap) - r.Use(eemiddleware.NewPat(s.serverOptions.SigNoz.SQLStore, []string{"SIGNOZ-API-KEY"}).Wrap) - r.Use(middleware.NewTimeout(zap.L(), - s.serverOptions.Config.APIServer.Timeout.ExcludedRoutes, - s.serverOptions.Config.APIServer.Timeout.Default, - s.serverOptions.Config.APIServer.Timeout.Max, - ).Wrap) - r.Use(middleware.NewAnalytics(zap.L()).Wrap) - r.Use(middleware.NewLogging(zap.L(), s.serverOptions.Config.APIServer.Logging.ExcludedRoutes).Wrap) - - apiHandler.RegisterPrivateRoutes(r) - - c := cors.New(cors.Options{ - //todo(amol): find out a way to add exact domain or - // ip here for alert manager - AllowedOrigins: []string{"*"}, - AllowedMethods: []string{"GET", "DELETE", "POST", "PUT", "PATCH"}, - AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "SIGNOZ-API-KEY", "X-SIGNOZ-QUERY-ID", "Sec-WebSocket-Protocol"}, - }) - - handler := c.Handler(r) - handler = handlers.CompressHandler(handler) - - return &http.Server{ - Handler: handler, - }, nil +// HealthCheckStatus returns health check status channel a client can subscribe to +func (s Server) HealthCheckStatus() chan healthcheck.Status { + return s.unavailableChannel } func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*http.Server, error) { r := baseapp.NewRouter() - am := middleware.NewAuthZ(s.serverOptions.SigNoz.Instrumentation.Logger()) - - r.Use(middleware.NewAuth(zap.L(), s.serverOptions.Jwt, []string{"Authorization", "Sec-WebSocket-Protocol"}).Wrap) - r.Use(eemiddleware.NewPat(s.serverOptions.SigNoz.SQLStore, []string{"SIGNOZ-API-KEY"}).Wrap) - r.Use(middleware.NewTimeout(zap.L(), - s.serverOptions.Config.APIServer.Timeout.ExcludedRoutes, - s.serverOptions.Config.APIServer.Timeout.Default, - s.serverOptions.Config.APIServer.Timeout.Max, + 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(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, + s.config.APIServer.Timeout.Default, + s.config.APIServer.Timeout.Max, ).Wrap) - r.Use(middleware.NewAnalytics(zap.L()).Wrap) - r.Use(middleware.NewLogging(zap.L(), s.serverOptions.Config.APIServer.Logging.ExcludedRoutes).Wrap) + r.Use(middleware.NewLogging(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes).Wrap) + r.Use(middleware.NewComment().Wrap) apiHandler.RegisterRoutes(r, am) apiHandler.RegisterLogsRoutes(r, am) @@ -307,10 +213,12 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h apiHandler.RegisterQueryRangeV3Routes(r, am) apiHandler.RegisterInfraMetricsRoutes(r, am) apiHandler.RegisterQueryRangeV4Routes(r, am) + apiHandler.RegisterQueryRangeV5Routes(r, am) apiHandler.RegisterWebSocketPaths(r, am) apiHandler.RegisterMessagingQueuesRoutes(r, am) apiHandler.RegisterThirdPartyApiRoutes(r, am) apiHandler.MetricExplorerRoutes(r, am) + apiHandler.RegisterTraceFunnelsRoutes(r, am) c := cors.New(cors.Options{ AllowedOrigins: []string{"*"}, @@ -336,7 +244,7 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h func (s *Server) initListeners() error { // listen on public port var err error - publicHostPort := s.serverOptions.HTTPHostPort + publicHostPort := s.httpHostPort if publicHostPort == "" { return fmt.Errorf("baseconst.HTTPHostPort is required") } @@ -346,20 +254,7 @@ func (s *Server) initListeners() error { return err } - zap.L().Info(fmt.Sprintf("Query server started listening on %s...", s.serverOptions.HTTPHostPort)) - - // listen on private port to support internal services - privateHostPort := s.serverOptions.PrivateHostPort - - if privateHostPort == "" { - return fmt.Errorf("baseconst.PrivateHostPort is required") - } - - s.privateConn, err = net.Listen("tcp", privateHostPort) - if err != nil { - return err - } - zap.L().Info(fmt.Sprintf("Query server started listening on private port %s...", s.serverOptions.PrivateHostPort)) + zap.L().Info(fmt.Sprintf("Query server started listening on %s...", s.httpHostPort)) return nil } @@ -379,7 +274,7 @@ func (s *Server) Start(ctx context.Context) error { } go func() { - zap.L().Info("Starting HTTP server", zap.Int("port", httpPort), zap.String("addr", s.serverOptions.HTTPHostPort)) + zap.L().Info("Starting HTTP server", zap.Int("port", httpPort), zap.String("addr", s.httpHostPort)) switch err := s.httpServer.Serve(s.httpConn); err { case nil, http.ErrServerClosed, cmux.ErrListenerClosed: @@ -399,26 +294,6 @@ func (s *Server) Start(ctx context.Context) error { } }() - var privatePort int - if port, err := utils.GetPort(s.privateConn.Addr()); err == nil { - privatePort = port - } - - go func() { - zap.L().Info("Starting Private HTTP server", zap.Int("port", privatePort), zap.String("addr", s.serverOptions.PrivateHostPort)) - - switch err := s.privateHTTP.Serve(s.privateConn); err { - case nil, http.ErrServerClosed, cmux.ErrListenerClosed: - // normal exit, nothing to do - zap.L().Info("private http server closed") - default: - zap.L().Error("Could not start private HTTP server", zap.Error(err)) - } - - s.unavailableChannel <- healthcheck.Unavailable - - }() - go func() { zap.L().Info("Starting OpAmp Websocket server", zap.String("addr", baseconst.OpAmpWsEndpoint)) err := s.opampServer.Start(baseconst.OpAmpWsEndpoint) @@ -431,15 +306,9 @@ func (s *Server) Start(ctx context.Context) error { return nil } -func (s *Server) Stop() error { +func (s *Server) Stop(ctx context.Context) error { if s.httpServer != nil { - if err := s.httpServer.Shutdown(context.Background()); err != nil { - return err - } - } - - if s.privateHTTP != nil { - if err := s.privateHTTP.Shutdown(context.Background()); err != nil { + if err := s.httpServer.Shutdown(ctx); err != nil { return err } } @@ -447,38 +316,36 @@ func (s *Server) Stop() error { s.opampServer.Stop() if s.ruleManager != nil { - s.ruleManager.Stop(context.Background()) + s.ruleManager.Stop(ctx) } // stop usage manager - s.usageManager.Stop() + s.usageManager.Stop(ctx) return nil } -func makeRulesManager( - db *sqlx.DB, - ch baseint.Reader, - cache cache.Cache, - alertmanager alertmanager.Alertmanager, - sqlstore sqlstore.SQLStore, - telemetryStore telemetrystore.TelemetryStore, - prometheus prometheus.Prometheus, -) (*baserules.Manager, error) { +func makeRulesManager(ch baseint.Reader, cache cache.Cache, alertmanager alertmanager.Alertmanager, sqlstore sqlstore.SQLStore, telemetryStore telemetrystore.TelemetryStore, prometheus prometheus.Prometheus, orgGetter organization.Getter, querier querier.Querier, logger *slog.Logger) (*baserules.Manager, error) { + ruleStore := sqlrulestore.NewRuleStore(sqlstore) + maintenanceStore := sqlrulestore.NewMaintenanceStore(sqlstore) // create manager opts managerOpts := &baserules.ManagerOptions{ TelemetryStore: telemetryStore, Prometheus: prometheus, - DBConn: db, Context: context.Background(), Logger: zap.L(), Reader: ch, + Querier: querier, + SLogger: logger, Cache: cache, EvalDelay: baseconst.GetEvalDelay(), PrepareTaskFunc: rules.PrepareTaskFunc, PrepareTestRuleFunc: rules.TestNotification, Alertmanager: alertmanager, - SQLStore: sqlstore, + OrgGetter: orgGetter, + RuleStore: ruleStore, + MaintenanceStore: maintenanceStore, + SqlStore: sqlstore, } // create Manager diff --git a/ee/query-service/constants/constants.go b/ee/query-service/constants/constants.go index 701495c6eb0..b7c6d11fc58 100644 --- a/ee/query-service/constants/constants.go +++ b/ee/query-service/constants/constants.go @@ -33,3 +33,18 @@ func GetOrDefaultEnv(key string, fallback string) string { func GetDefaultSiteURL() string { return GetOrDefaultEnv("SIGNOZ_SITE_URL", DefaultSiteURL) } + +const DotMetricsEnabled = "DOT_METRICS_ENABLED" + +var IsDotMetricsEnabled = false +var IsPreferSpanMetrics = false + +func init() { + if GetOrDefaultEnv(DotMetricsEnabled, "true") == "true" { + IsDotMetricsEnabled = true + } + + if GetOrDefaultEnv("USE_SPAN_METRICS", "false") == "true" { + IsPreferSpanMetrics = true + } +} diff --git a/ee/query-service/dao/interface.go b/ee/query-service/dao/interface.go deleted file mode 100644 index 2e40abcf216..00000000000 --- a/ee/query-service/dao/interface.go +++ /dev/null @@ -1,23 +0,0 @@ -package dao - -import ( - "context" - "net/url" - - basemodel "github.com/SigNoz/signoz/pkg/query-service/model" - "github.com/SigNoz/signoz/pkg/types" - "github.com/google/uuid" -) - -type ModelDao interface { - // auth methods - GetDomainFromSsoResponse(ctx context.Context, relayState *url.URL) (*types.GettableOrgDomain, error) - - // org domain (auth domains) CRUD ops - ListDomains(ctx context.Context, orgId string) ([]types.GettableOrgDomain, basemodel.BaseApiError) - GetDomain(ctx context.Context, id uuid.UUID) (*types.GettableOrgDomain, basemodel.BaseApiError) - CreateDomain(ctx context.Context, d *types.GettableOrgDomain) basemodel.BaseApiError - UpdateDomain(ctx context.Context, domain *types.GettableOrgDomain) basemodel.BaseApiError - DeleteDomain(ctx context.Context, id uuid.UUID) basemodel.BaseApiError - GetDomainByEmail(ctx context.Context, email string) (*types.GettableOrgDomain, basemodel.BaseApiError) -} diff --git a/ee/query-service/dao/sqlite/domain.go b/ee/query-service/dao/sqlite/domain.go deleted file mode 100644 index d41f7632eaa..00000000000 --- a/ee/query-service/dao/sqlite/domain.go +++ /dev/null @@ -1,272 +0,0 @@ -package sqlite - -import ( - "context" - "database/sql" - "encoding/json" - "fmt" - "net/url" - "strings" - "time" - - "github.com/SigNoz/signoz/ee/query-service/model" - basemodel "github.com/SigNoz/signoz/pkg/query-service/model" - "github.com/SigNoz/signoz/pkg/types" - ossTypes "github.com/SigNoz/signoz/pkg/types" - "github.com/google/uuid" - "go.uber.org/zap" -) - -// GetDomainFromSsoResponse uses relay state received from IdP to fetch -// user domain. The domain is further used to process validity of the response. -// when sending login request to IdP we send relay state as URL (site url) -// with domainId or domainName as query parameter. -func (m *modelDao) GetDomainFromSsoResponse(ctx context.Context, relayState *url.URL) (*types.GettableOrgDomain, error) { - // derive domain id from relay state now - var domainIdStr string - var domainNameStr string - var domain *types.GettableOrgDomain - - for k, v := range relayState.Query() { - if k == "domainId" && len(v) > 0 { - domainIdStr = strings.Replace(v[0], ":", "-", -1) - } - if k == "domainName" && len(v) > 0 { - domainNameStr = v[0] - } - } - - if domainIdStr != "" { - domainId, err := uuid.Parse(domainIdStr) - if err != nil { - zap.L().Error("failed to parse domainId from relay state", zap.Error(err)) - return nil, fmt.Errorf("failed to parse domainId from IdP response") - } - - domain, err = m.GetDomain(ctx, domainId) - if err != nil { - zap.L().Error("failed to find domain from domainId received in IdP response", zap.Error(err)) - return nil, fmt.Errorf("invalid credentials") - } - } - - if domainNameStr != "" { - - domainFromDB, err := m.GetDomainByName(ctx, domainNameStr) - domain = domainFromDB - if err != nil { - zap.L().Error("failed to find domain from domainName received in IdP response", zap.Error(err)) - return nil, fmt.Errorf("invalid credentials") - } - } - if domain != nil { - return domain, nil - } - - return nil, fmt.Errorf("failed to find domain received in IdP response") -} - -// GetDomainByName returns org domain for a given domain name -func (m *modelDao) GetDomainByName(ctx context.Context, name string) (*types.GettableOrgDomain, basemodel.BaseApiError) { - - stored := types.StorableOrgDomain{} - err := m.sqlStore.BunDB().NewSelect(). - Model(&stored). - Where("name = ?", name). - Limit(1). - Scan(ctx) - - if err != nil { - if err == sql.ErrNoRows { - return nil, model.BadRequest(fmt.Errorf("invalid domain name")) - } - return nil, model.InternalError(err) - } - - domain := &types.GettableOrgDomain{StorableOrgDomain: stored} - if err := domain.LoadConfig(stored.Data); err != nil { - return nil, model.InternalError(err) - } - return domain, nil -} - -// GetDomain returns org domain for a given domain id -func (m *modelDao) GetDomain(ctx context.Context, id uuid.UUID) (*types.GettableOrgDomain, basemodel.BaseApiError) { - - stored := types.StorableOrgDomain{} - err := m.sqlStore.BunDB().NewSelect(). - Model(&stored). - Where("id = ?", id). - Limit(1). - Scan(ctx) - - if err != nil { - if err == sql.ErrNoRows { - return nil, model.BadRequest(fmt.Errorf("invalid domain id")) - } - return nil, model.InternalError(err) - } - - domain := &types.GettableOrgDomain{StorableOrgDomain: stored} - if err := domain.LoadConfig(stored.Data); err != nil { - return nil, model.InternalError(err) - } - return domain, nil -} - -// ListDomains gets the list of auth domains by org id -func (m *modelDao) ListDomains(ctx context.Context, orgId string) ([]types.GettableOrgDomain, basemodel.BaseApiError) { - domains := []types.GettableOrgDomain{} - - stored := []types.StorableOrgDomain{} - err := m.sqlStore.BunDB().NewSelect(). - Model(&stored). - Where("org_id = ?", orgId). - Scan(ctx) - - if err != nil { - if err == sql.ErrNoRows { - return domains, nil - } - return nil, model.InternalError(err) - } - - for _, s := range stored { - domain := types.GettableOrgDomain{StorableOrgDomain: s} - if err := domain.LoadConfig(s.Data); err != nil { - zap.L().Error("ListDomains() failed", zap.Error(err)) - } - domains = append(domains, domain) - } - - return domains, nil -} - -// CreateDomain creates a new auth domain -func (m *modelDao) CreateDomain(ctx context.Context, domain *types.GettableOrgDomain) basemodel.BaseApiError { - - if domain.ID == uuid.Nil { - domain.ID = uuid.New() - } - - if domain.OrgID == "" || domain.Name == "" { - return model.BadRequest(fmt.Errorf("domain creation failed, missing fields: OrgID, Name ")) - } - - configJson, err := json.Marshal(domain) - if err != nil { - zap.L().Error("failed to unmarshal domain config", zap.Error(err)) - return model.InternalError(fmt.Errorf("domain creation failed")) - } - - storableDomain := types.StorableOrgDomain{ - ID: domain.ID, - Name: domain.Name, - OrgID: domain.OrgID, - Data: string(configJson), - TimeAuditable: ossTypes.TimeAuditable{CreatedAt: time.Now(), UpdatedAt: time.Now()}, - } - - _, err = m.sqlStore.BunDB().NewInsert(). - Model(&storableDomain). - Exec(ctx) - - if err != nil { - zap.L().Error("failed to insert domain in db", zap.Error(err)) - return model.InternalError(fmt.Errorf("domain creation failed")) - } - - return nil -} - -// UpdateDomain updates stored config params for a domain -func (m *modelDao) UpdateDomain(ctx context.Context, domain *types.GettableOrgDomain) basemodel.BaseApiError { - - if domain.ID == uuid.Nil { - zap.L().Error("domain update failed", zap.Error(fmt.Errorf("OrgDomain.Id is null"))) - return model.InternalError(fmt.Errorf("domain update failed")) - } - - configJson, err := json.Marshal(domain) - if err != nil { - zap.L().Error("domain update failed", zap.Error(err)) - return model.InternalError(fmt.Errorf("domain update failed")) - } - - storableDomain := &types.StorableOrgDomain{ - ID: domain.ID, - Name: domain.Name, - OrgID: domain.OrgID, - Data: string(configJson), - TimeAuditable: ossTypes.TimeAuditable{UpdatedAt: time.Now()}, - } - - _, err = m.sqlStore.BunDB().NewUpdate(). - Model(storableDomain). - Column("data", "updated_at"). - WherePK(). - Exec(ctx) - - if err != nil { - zap.L().Error("domain update failed", zap.Error(err)) - return model.InternalError(fmt.Errorf("domain update failed")) - } - - return nil -} - -// DeleteDomain deletes an org domain -func (m *modelDao) DeleteDomain(ctx context.Context, id uuid.UUID) basemodel.BaseApiError { - - if id == uuid.Nil { - zap.L().Error("domain delete failed", zap.Error(fmt.Errorf("OrgDomain.Id is null"))) - return model.InternalError(fmt.Errorf("domain delete failed")) - } - - storableDomain := &types.StorableOrgDomain{ID: id} - _, err := m.sqlStore.BunDB().NewDelete(). - Model(storableDomain). - WherePK(). - Exec(ctx) - - if err != nil { - zap.L().Error("domain delete failed", zap.Error(err)) - return model.InternalError(fmt.Errorf("domain delete failed")) - } - - return nil -} - -func (m *modelDao) GetDomainByEmail(ctx context.Context, email string) (*types.GettableOrgDomain, basemodel.BaseApiError) { - - if email == "" { - return nil, model.BadRequest(fmt.Errorf("could not find auth domain, missing fields: email ")) - } - - components := strings.Split(email, "@") - if len(components) < 2 { - return nil, model.BadRequest(fmt.Errorf("invalid email address")) - } - - parsedDomain := components[1] - - stored := types.StorableOrgDomain{} - err := m.sqlStore.BunDB().NewSelect(). - Model(&stored). - Where("name = ?", parsedDomain). - Limit(1). - Scan(ctx) - - if err != nil { - if err == sql.ErrNoRows { - return nil, nil - } - return nil, model.InternalError(err) - } - - domain := &types.GettableOrgDomain{StorableOrgDomain: stored} - if err := domain.LoadConfig(stored.Data); err != nil { - return nil, model.InternalError(err) - } - return domain, nil -} diff --git a/ee/query-service/dao/sqlite/modelDao.go b/ee/query-service/dao/sqlite/modelDao.go deleted file mode 100644 index 6e55649e996..00000000000 --- a/ee/query-service/dao/sqlite/modelDao.go +++ /dev/null @@ -1,18 +0,0 @@ -package sqlite - -import ( - "github.com/SigNoz/signoz/pkg/modules/user" - "github.com/SigNoz/signoz/pkg/modules/user/impluser" - "github.com/SigNoz/signoz/pkg/sqlstore" -) - -type modelDao struct { - userModule user.Module - sqlStore sqlstore.SQLStore -} - -// InitDB creates and extends base model DB repository -func NewModelDao(sqlStore sqlstore.SQLStore) *modelDao { - userModule := impluser.NewModule(impluser.NewStore(sqlStore)) - return &modelDao{userModule: userModule, sqlStore: sqlStore} -} diff --git a/ee/query-service/integrations/signozio/signozio.go b/ee/query-service/integrations/signozio/signozio.go deleted file mode 100644 index d1bd7657284..00000000000 --- a/ee/query-service/integrations/signozio/signozio.go +++ /dev/null @@ -1,67 +0,0 @@ -package signozio - -import ( - "context" - "encoding/json" - - "github.com/SigNoz/signoz/ee/query-service/model" - "github.com/SigNoz/signoz/pkg/zeus" - "github.com/tidwall/gjson" -) - -func ValidateLicenseV3(ctx context.Context, licenseKey string, zeus zeus.Zeus) (*model.LicenseV3, error) { - data, err := zeus.GetLicense(ctx, licenseKey) - if err != nil { - return nil, err - } - - var m map[string]any - if err = json.Unmarshal(data, &m); err != nil { - return nil, err - } - - license, err := model.NewLicenseV3(m) - if err != nil { - return nil, err - } - - return license, nil -} - -// SendUsage reports the usage of signoz to license server -func SendUsage(ctx context.Context, usage model.UsagePayload, zeus zeus.Zeus) error { - body, err := json.Marshal(usage) - if err != nil { - return err - } - - return zeus.PutMeters(ctx, usage.LicenseKey.String(), body) -} - -func CheckoutSession(ctx context.Context, checkoutRequest *model.CheckoutRequest, licenseKey string, zeus zeus.Zeus) (string, error) { - body, err := json.Marshal(checkoutRequest) - if err != nil { - return "", err - } - - response, err := zeus.GetCheckoutURL(ctx, licenseKey, body) - if err != nil { - return "", err - } - - return gjson.GetBytes(response, "url").String(), nil -} - -func PortalSession(ctx context.Context, portalRequest *model.PortalRequest, licenseKey string, zeus zeus.Zeus) (string, error) { - body, err := json.Marshal(portalRequest) - if err != nil { - return "", err - } - - response, err := zeus.GetPortalURL(ctx, licenseKey, body) - if err != nil { - return "", err - } - - return gjson.GetBytes(response, "url").String(), nil -} diff --git a/ee/query-service/interfaces/connector.go b/ee/query-service/interfaces/connector.go deleted file mode 100644 index 7571000daf2..00000000000 --- a/ee/query-service/interfaces/connector.go +++ /dev/null @@ -1,11 +0,0 @@ -package interfaces - -import ( - baseint "github.com/SigNoz/signoz/pkg/query-service/interfaces" -) - -// Connector defines methods for interaction -// with o11y data. for example - clickhouse -type DataConnector interface { - baseint.Reader -} diff --git a/ee/query-service/license/db.go b/ee/query-service/license/db.go deleted file mode 100644 index c241ad87662..00000000000 --- a/ee/query-service/license/db.go +++ /dev/null @@ -1,248 +0,0 @@ -package license - -import ( - "context" - "database/sql" - "encoding/json" - "fmt" - "time" - - "github.com/jmoiron/sqlx" - "github.com/mattn/go-sqlite3" - - "github.com/SigNoz/signoz/ee/query-service/model" - basemodel "github.com/SigNoz/signoz/pkg/query-service/model" - "github.com/SigNoz/signoz/pkg/sqlstore" - "github.com/SigNoz/signoz/pkg/types" - "go.uber.org/zap" -) - -// Repo is license repo. stores license keys in a secured DB -type Repo struct { - db *sqlx.DB - store sqlstore.SQLStore -} - -// NewLicenseRepo initiates a new license repo -func NewLicenseRepo(db *sqlx.DB, store sqlstore.SQLStore) Repo { - return Repo{ - db: db, - store: store, - } -} - -func (r *Repo) GetLicensesV3(ctx context.Context) ([]*model.LicenseV3, error) { - licensesData := []model.LicenseDB{} - licenseV3Data := []*model.LicenseV3{} - - query := "SELECT id,key,data FROM licenses_v3" - - err := r.db.Select(&licensesData, query) - if err != nil { - return nil, fmt.Errorf("failed to get licenses from db: %v", err) - } - - for _, l := range licensesData { - var licenseData map[string]interface{} - err := json.Unmarshal([]byte(l.Data), &licenseData) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal data into licenseData : %v", err) - } - - license, err := model.NewLicenseV3WithIDAndKey(l.ID, l.Key, licenseData) - if err != nil { - return nil, fmt.Errorf("failed to get licenses v3 schema : %v", err) - } - licenseV3Data = append(licenseV3Data, license) - } - - return licenseV3Data, nil -} - -// GetActiveLicense fetches the latest active license from DB. -// If the license is not present, expect a nil license and a nil error in the output. -func (r *Repo) GetActiveLicense(ctx context.Context) (*model.License, *basemodel.ApiError) { - activeLicenseV3, err := r.GetActiveLicenseV3(ctx) - if err != nil { - return nil, basemodel.InternalError(fmt.Errorf("failed to get active licenses from db: %v", err)) - } - - if activeLicenseV3 == nil { - return nil, nil - } - activeLicenseV2 := model.ConvertLicenseV3ToLicenseV2(activeLicenseV3) - return activeLicenseV2, nil -} - -func (r *Repo) GetActiveLicenseV3(ctx context.Context) (*model.LicenseV3, error) { - var err error - licenses := []model.LicenseDB{} - - query := "SELECT id,key,data FROM licenses_v3" - - err = r.db.Select(&licenses, query) - if err != nil { - return nil, basemodel.InternalError(fmt.Errorf("failed to get active licenses from db: %v", err)) - } - - var active *model.LicenseV3 - for _, l := range licenses { - var licenseData map[string]interface{} - err := json.Unmarshal([]byte(l.Data), &licenseData) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal data into licenseData : %v", err) - } - - license, err := model.NewLicenseV3WithIDAndKey(l.ID, l.Key, licenseData) - if err != nil { - return nil, fmt.Errorf("failed to get licenses v3 schema : %v", err) - } - - if active == nil && - (license.ValidFrom != 0) && - (license.ValidUntil == -1 || license.ValidUntil > time.Now().Unix()) { - active = license - } - if active != nil && - license.ValidFrom > active.ValidFrom && - (license.ValidUntil == -1 || license.ValidUntil > time.Now().Unix()) { - active = license - } - } - - return active, nil -} - -// InsertLicenseV3 inserts a new license v3 in db -func (r *Repo) InsertLicenseV3(ctx context.Context, l *model.LicenseV3) *model.ApiError { - - query := `INSERT INTO licenses_v3 (id, key, data) VALUES ($1, $2, $3)` - - // licsense is the entity of zeus so putting the entire license here without defining schema - licenseData, err := json.Marshal(l.Data) - if err != nil { - return &model.ApiError{Typ: basemodel.ErrorBadData, Err: err} - } - - _, err = r.db.ExecContext(ctx, - query, - l.ID, - l.Key, - string(licenseData), - ) - - if err != nil { - if sqliteErr, ok := err.(sqlite3.Error); ok { - if sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique { - zap.L().Error("error in inserting license data: ", zap.Error(sqliteErr)) - return &model.ApiError{Typ: model.ErrorConflict, Err: sqliteErr} - } - } - zap.L().Error("error in inserting license data: ", zap.Error(err)) - return &model.ApiError{Typ: basemodel.ErrorExec, Err: err} - } - - return nil -} - -// UpdateLicenseV3 updates a new license v3 in db -func (r *Repo) UpdateLicenseV3(ctx context.Context, l *model.LicenseV3) error { - - // the key and id for the license can't change so only update the data here! - query := `UPDATE licenses_v3 SET data=$1 WHERE id=$2;` - - license, err := json.Marshal(l.Data) - if err != nil { - return fmt.Errorf("insert license failed: license marshal error") - } - _, err = r.db.ExecContext(ctx, - query, - license, - l.ID, - ) - - if err != nil { - zap.L().Error("error in updating license data: ", zap.Error(err)) - return fmt.Errorf("failed to update license in db: %v", err) - } - - return nil -} - -func (r *Repo) CreateFeature(req *types.FeatureStatus) *basemodel.ApiError { - - _, err := r.store.BunDB().NewInsert(). - Model(req). - Exec(context.Background()) - if err != nil { - return &basemodel.ApiError{Typ: basemodel.ErrorInternal, Err: err} - } - return nil -} - -func (r *Repo) GetFeature(featureName string) (types.FeatureStatus, error) { - var feature types.FeatureStatus - - err := r.store.BunDB().NewSelect(). - Model(&feature). - Where("name = ?", featureName). - Scan(context.Background()) - - if err != nil { - return feature, err - } - if feature.Name == "" { - return feature, basemodel.ErrFeatureUnavailable{Key: featureName} - } - return feature, nil -} - -func (r *Repo) GetAllFeatures() ([]basemodel.Feature, error) { - - var feature []basemodel.Feature - - err := r.db.Select(&feature, - `SELECT * FROM feature_status;`) - if err != nil { - return feature, err - } - - return feature, nil -} - -func (r *Repo) UpdateFeature(req types.FeatureStatus) error { - - _, err := r.store.BunDB().NewUpdate(). - Model(&req). - Where("name = ?", req.Name). - Exec(context.Background()) - if err != nil { - return err - } - return nil -} - -func (r *Repo) InitFeatures(req []types.FeatureStatus) error { - // get a feature by name, if it doesn't exist, create it. If it does exist, update it. - for _, feature := range req { - currentFeature, err := r.GetFeature(feature.Name) - if err != nil && err == sql.ErrNoRows { - err := r.CreateFeature(&feature) - if err != nil { - return err - } - continue - } else if err != nil { - return err - } - feature.Usage = int(currentFeature.Usage) - if feature.Usage >= feature.UsageLimit && feature.UsageLimit != -1 { - feature.Active = false - } - err = r.UpdateFeature(feature) - if err != nil { - return err - } - } - return nil -} diff --git a/ee/query-service/license/manager.go b/ee/query-service/license/manager.go deleted file mode 100644 index ae5b5c89790..00000000000 --- a/ee/query-service/license/manager.go +++ /dev/null @@ -1,318 +0,0 @@ -package license - -import ( - "context" - "sync/atomic" - "time" - - "github.com/jmoiron/sqlx" - - "sync" - - baseconstants "github.com/SigNoz/signoz/pkg/query-service/constants" - "github.com/SigNoz/signoz/pkg/sqlstore" - "github.com/SigNoz/signoz/pkg/types" - "github.com/SigNoz/signoz/pkg/zeus" - - validate "github.com/SigNoz/signoz/ee/query-service/integrations/signozio" - "github.com/SigNoz/signoz/ee/query-service/model" - basemodel "github.com/SigNoz/signoz/pkg/query-service/model" - "github.com/SigNoz/signoz/pkg/query-service/telemetry" - "go.uber.org/zap" -) - -var LM *Manager - -// validate and update license every 24 hours -var validationFrequency = 24 * 60 * time.Minute - -type Manager struct { - repo *Repo - zeus zeus.Zeus - mutex sync.Mutex - validatorRunning bool - // end the license validation, this is important to gracefully - // stopping validation and protect in-consistent updates - done chan struct{} - // terminated waits for the validate go routine to end - terminated chan struct{} - // last time the license was validated - lastValidated int64 - // keep track of validation failure attempts - failedAttempts uint64 - // keep track of active license and features - activeLicenseV3 *model.LicenseV3 - activeFeatures basemodel.FeatureSet -} - -func StartManager(db *sqlx.DB, store sqlstore.SQLStore, zeus zeus.Zeus, features ...basemodel.Feature) (*Manager, error) { - if LM != nil { - return LM, nil - } - - repo := NewLicenseRepo(db, store) - m := &Manager{ - repo: &repo, - zeus: zeus, - } - if err := m.start(features...); err != nil { - return m, err - } - - LM = m - return m, nil -} - -// start loads active license in memory and initiates validator -func (lm *Manager) start(features ...basemodel.Feature) error { - return lm.LoadActiveLicenseV3(features...) -} - -func (lm *Manager) Stop() { - close(lm.done) - <-lm.terminated -} - -func (lm *Manager) SetActiveV3(l *model.LicenseV3, features ...basemodel.Feature) { - lm.mutex.Lock() - defer lm.mutex.Unlock() - - if l == nil { - return - } - - lm.activeLicenseV3 = l - lm.activeFeatures = append(l.Features, features...) - // set default features - setDefaultFeatures(lm) - - err := lm.InitFeatures(lm.activeFeatures) - if err != nil { - zap.L().Panic("Couldn't activate features", zap.Error(err)) - } - if !lm.validatorRunning { - // we want to make sure only one validator runs, - // we already have lock() so good to go - lm.validatorRunning = true - go lm.ValidatorV3(context.Background()) - } - -} - -func setDefaultFeatures(lm *Manager) { - lm.activeFeatures = append(lm.activeFeatures, baseconstants.DEFAULT_FEATURE_SET...) -} - -func (lm *Manager) LoadActiveLicenseV3(features ...basemodel.Feature) error { - active, err := lm.repo.GetActiveLicenseV3(context.Background()) - if err != nil { - return err - } - - if active != nil { - lm.SetActiveV3(active, features...) - } else { - zap.L().Info("No active license found, defaulting to basic plan") - // if no active license is found, we default to basic(free) plan with all default features - lm.activeFeatures = model.BasicPlan - setDefaultFeatures(lm) - err := lm.InitFeatures(lm.activeFeatures) - if err != nil { - zap.L().Error("Couldn't initialize features", zap.Error(err)) - return err - } - } - - return nil -} - -func (lm *Manager) GetLicensesV3(ctx context.Context) (response []*model.LicenseV3, apiError *model.ApiError) { - - licenses, err := lm.repo.GetLicensesV3(ctx) - if err != nil { - return nil, model.InternalError(err) - } - - for _, l := range licenses { - if lm.activeLicenseV3 != nil && l.Key == lm.activeLicenseV3.Key { - l.IsCurrent = true - } - if l.ValidUntil == -1 { - // for subscriptions, there is no end-date as such - // but for showing user some validity we default one year timespan - l.ValidUntil = l.ValidFrom + 31556926 - } - response = append(response, l) - } - - return response, nil -} - -// Validator validates license after an epoch of time -func (lm *Manager) ValidatorV3(ctx context.Context) { - zap.L().Info("ValidatorV3 started!") - defer close(lm.terminated) - - tick := time.NewTicker(validationFrequency) - defer tick.Stop() - - _ = lm.ValidateV3(ctx) - for { - select { - case <-lm.done: - return - default: - select { - case <-lm.done: - return - case <-tick.C: - _ = lm.ValidateV3(ctx) - } - } - - } -} - -func (lm *Manager) RefreshLicense(ctx context.Context) error { - license, err := validate.ValidateLicenseV3(ctx, lm.activeLicenseV3.Key, lm.zeus) - if err != nil { - return err - } - - err = lm.repo.UpdateLicenseV3(ctx, license) - if err != nil { - return err - } - lm.SetActiveV3(license) - - return nil -} - -func (lm *Manager) ValidateV3(ctx context.Context) (reterr error) { - if lm.activeLicenseV3 == nil { - return nil - } - - defer func() { - lm.mutex.Lock() - - lm.lastValidated = time.Now().Unix() - if reterr != nil { - zap.L().Error("License validation completed with error", zap.Error(reterr)) - - atomic.AddUint64(&lm.failedAttempts, 1) - // default to basic plan if validation fails for three consecutive times - if atomic.LoadUint64(&lm.failedAttempts) > 3 { - zap.L().Error("License validation completed with error for three consecutive times, defaulting to basic plan", zap.String("license_id", lm.activeLicenseV3.ID), zap.Bool("license_validation", false)) - lm.activeLicenseV3 = nil - lm.activeFeatures = model.BasicPlan - setDefaultFeatures(lm) - err := lm.InitFeatures(lm.activeFeatures) - if err != nil { - zap.L().Error("Couldn't initialize features", zap.Error(err)) - } - lm.done <- struct{}{} - lm.validatorRunning = false - } - - telemetry.GetInstance().SendEvent(telemetry.TELEMETRY_LICENSE_CHECK_FAILED, - map[string]interface{}{"err": reterr.Error()}, "", true, false) - } else { - // reset the failed attempts counter - atomic.StoreUint64(&lm.failedAttempts, 0) - zap.L().Info("License validation completed with no errors") - } - - lm.mutex.Unlock() - }() - - err := lm.RefreshLicense(ctx) - - if err != nil { - return err - } - return nil -} - -func (lm *Manager) ActivateV3(ctx context.Context, licenseKey string) (*model.LicenseV3, error) { - license, err := validate.ValidateLicenseV3(ctx, licenseKey, lm.zeus) - if err != nil { - return nil, err - } - - // insert the new license to the sqlite db - modelErr := lm.repo.InsertLicenseV3(ctx, license) - if modelErr != nil { - zap.L().Error("failed to activate license", zap.Error(modelErr)) - return nil, modelErr - } - - // license is valid, activate it - lm.SetActiveV3(license) - return license, nil -} - -func (lm *Manager) GetActiveLicense() *model.LicenseV3 { - return lm.activeLicenseV3 -} - -// CheckFeature will be internally used by backend routines -// for feature gating -func (lm *Manager) CheckFeature(featureKey string) error { - feature, err := lm.repo.GetFeature(featureKey) - if err != nil { - return err - } - if feature.Active { - return nil - } - return basemodel.ErrFeatureUnavailable{Key: featureKey} -} - -// GetFeatureFlags returns current active features -func (lm *Manager) GetFeatureFlags() (basemodel.FeatureSet, error) { - return lm.repo.GetAllFeatures() -} - -func (lm *Manager) InitFeatures(features basemodel.FeatureSet) error { - featureStatus := make([]types.FeatureStatus, len(features)) - for i, f := range features { - featureStatus[i] = types.FeatureStatus{ - Name: f.Name, - Active: f.Active, - Usage: int(f.Usage), - UsageLimit: int(f.UsageLimit), - Route: f.Route, - } - } - return lm.repo.InitFeatures(featureStatus) -} - -func (lm *Manager) UpdateFeatureFlag(feature basemodel.Feature) error { - return lm.repo.UpdateFeature(types.FeatureStatus{ - Name: feature.Name, - Active: feature.Active, - Usage: int(feature.Usage), - UsageLimit: int(feature.UsageLimit), - Route: feature.Route, - }) -} - -func (lm *Manager) GetFeatureFlag(key string) (basemodel.Feature, error) { - featureStatus, err := lm.repo.GetFeature(key) - if err != nil { - return basemodel.Feature{}, err - } - return basemodel.Feature{ - Name: featureStatus.Name, - Active: featureStatus.Active, - Usage: int64(featureStatus.Usage), - UsageLimit: int64(featureStatus.UsageLimit), - Route: featureStatus.Route, - }, nil -} - -// GetRepo return the license repo -func (lm *Manager) GetRepo() *Repo { - return lm.repo -} diff --git a/ee/query-service/main.go b/ee/query-service/main.go deleted file mode 100644 index 22b307dc123..00000000000 --- a/ee/query-service/main.go +++ /dev/null @@ -1,182 +0,0 @@ -package main - -import ( - "context" - "flag" - "os" - "time" - - eeuserimpl "github.com/SigNoz/signoz/ee/modules/user/impluser" - "github.com/SigNoz/signoz/ee/query-service/app" - "github.com/SigNoz/signoz/ee/sqlstore/postgressqlstore" - "github.com/SigNoz/signoz/ee/zeus" - "github.com/SigNoz/signoz/ee/zeus/httpzeus" - "github.com/SigNoz/signoz/pkg/config" - "github.com/SigNoz/signoz/pkg/config/envprovider" - "github.com/SigNoz/signoz/pkg/config/fileprovider" - "github.com/SigNoz/signoz/pkg/modules/user" - baseconst "github.com/SigNoz/signoz/pkg/query-service/constants" - "github.com/SigNoz/signoz/pkg/signoz" - "github.com/SigNoz/signoz/pkg/sqlstore" - "github.com/SigNoz/signoz/pkg/sqlstore/sqlstorehook" - "github.com/SigNoz/signoz/pkg/types/authtypes" - "github.com/SigNoz/signoz/pkg/version" - - "go.uber.org/zap" - "go.uber.org/zap/zapcore" -) - -// Deprecated: Please use the logger from pkg/instrumentation. -func initZapLog() *zap.Logger { - config := zap.NewProductionConfig() - config.EncoderConfig.TimeKey = "timestamp" - config.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder - logger, _ := config.Build() - return logger -} - -func main() { - var promConfigPath, skipTopLvlOpsPath string - - // disables rule execution but allows change to the rule definition - var disableRules bool - - // the url used to build link in the alert messages in slack and other systems - var ruleRepoURL string - var cluster string - - var useLogsNewSchema bool - var useTraceNewSchema bool - var cacheConfigPath, fluxInterval, fluxIntervalForTraceDetail string - var preferSpanMetrics bool - - var maxIdleConns int - var maxOpenConns int - var dialTimeout time.Duration - var gatewayUrl string - var useLicensesV3 bool - - // Deprecated - flag.BoolVar(&useLogsNewSchema, "use-logs-new-schema", false, "use logs_v2 schema for logs") - // Deprecated - flag.BoolVar(&useTraceNewSchema, "use-trace-new-schema", false, "use new schema for traces") - // Deprecated - flag.StringVar(&promConfigPath, "config", "./config/prometheus.yml", "(prometheus config to read metrics)") - // Deprecated - flag.StringVar(&skipTopLvlOpsPath, "skip-top-level-ops", "", "(config file to skip top level operations)") - // Deprecated - flag.BoolVar(&disableRules, "rules.disable", false, "(disable rule evaluation)") - flag.BoolVar(&preferSpanMetrics, "prefer-span-metrics", false, "(prefer span metrics for service level metrics)") - // Deprecated - flag.IntVar(&maxIdleConns, "max-idle-conns", 50, "(number of connections to maintain in the pool.)") - // Deprecated - flag.IntVar(&maxOpenConns, "max-open-conns", 100, "(max connections for use at any time.)") - // Deprecated - flag.DurationVar(&dialTimeout, "dial-timeout", 5*time.Second, "(the maximum time to establish a connection.)") - // Deprecated - flag.StringVar(&ruleRepoURL, "rules.repo-url", baseconst.AlertHelpPage, "(host address used to build rule link in alert messages)") - // Deprecated - flag.StringVar(&cacheConfigPath, "experimental.cache-config", "", "(cache config to use)") - flag.StringVar(&fluxInterval, "flux-interval", "5m", "(the interval to exclude data from being cached to avoid incorrect cache for data in motion)") - flag.StringVar(&fluxIntervalForTraceDetail, "flux-interval-trace-detail", "2m", "(the interval to exclude data from being cached to avoid incorrect cache for trace data in motion)") - flag.StringVar(&cluster, "cluster", "cluster", "(cluster name - defaults to 'cluster')") - flag.StringVar(&gatewayUrl, "gateway-url", "", "(url to the gateway)") - // Deprecated - flag.BoolVar(&useLicensesV3, "use-licenses-v3", false, "use licenses_v3 schema for licenses") - flag.Parse() - - loggerMgr := initZapLog() - zap.ReplaceGlobals(loggerMgr) - defer loggerMgr.Sync() // flushes buffer, if any - - config, err := signoz.NewConfig(context.Background(), config.ResolverConfig{ - Uris: []string{"env:"}, - ProviderFactories: []config.ProviderFactory{ - envprovider.NewFactory(), - fileprovider.NewFactory(), - }, - }, signoz.DeprecatedFlags{ - MaxIdleConns: maxIdleConns, - MaxOpenConns: maxOpenConns, - DialTimeout: dialTimeout, - Config: promConfigPath, - }) - if err != nil { - zap.L().Fatal("Failed to create config", zap.Error(err)) - } - - version.Info.PrettyPrint(config.Version) - - sqlStoreFactories := signoz.NewSQLStoreProviderFactories() - if err := sqlStoreFactories.Add(postgressqlstore.NewFactory(sqlstorehook.NewLoggingFactory())); err != nil { - zap.L().Fatal("Failed to add postgressqlstore factory", zap.Error(err)) - } - - signoz, err := signoz.New( - context.Background(), - config, - zeus.Config(), - httpzeus.NewProviderFactory(), - signoz.NewCacheProviderFactories(), - signoz.NewWebProviderFactories(), - sqlStoreFactories, - signoz.NewTelemetryStoreProviderFactories(), - func(sqlstore sqlstore.SQLStore) user.Module { - return eeuserimpl.NewModule(eeuserimpl.NewStore(sqlstore)) - }, - func(userModule user.Module) user.Handler { - return eeuserimpl.NewHandler(userModule) - }, - ) - if err != nil { - zap.L().Fatal("Failed to create signoz", zap.Error(err)) - } - - jwtSecret := os.Getenv("SIGNOZ_JWT_SECRET") - - if len(jwtSecret) == 0 { - zap.L().Warn("No JWT secret key is specified.") - } else { - zap.L().Info("JWT secret key set successfully.") - } - - jwt := authtypes.NewJWT(jwtSecret, 30*time.Minute, 30*24*time.Hour) - - serverOptions := &app.ServerOptions{ - Config: config, - SigNoz: signoz, - HTTPHostPort: baseconst.HTTPHostPort, - PreferSpanMetrics: preferSpanMetrics, - PrivateHostPort: baseconst.PrivateHostPort, - FluxInterval: fluxInterval, - FluxIntervalForTraceDetail: fluxIntervalForTraceDetail, - Cluster: cluster, - GatewayUrl: gatewayUrl, - Jwt: jwt, - } - - server, err := app.NewServer(serverOptions) - if err != nil { - zap.L().Fatal("Failed to create server", zap.Error(err)) - } - - if err := server.Start(context.Background()); err != nil { - zap.L().Fatal("Could not start server", zap.Error(err)) - } - - signoz.Start(context.Background()) - - if err := signoz.Wait(context.Background()); err != nil { - zap.L().Fatal("Failed to start signoz", zap.Error(err)) - } - - err = server.Stop() - if err != nil { - zap.L().Fatal("Failed to stop server", zap.Error(err)) - } - - err = signoz.Stop(context.Background()) - if err != nil { - zap.L().Fatal("Failed to stop signoz", zap.Error(err)) - } -} diff --git a/ee/query-service/model/errors.go b/ee/query-service/model/errors.go index cbbb0e4cc30..d3725e83299 100644 --- a/ee/query-service/model/errors.go +++ b/ee/query-service/model/errors.go @@ -1,7 +1,7 @@ package model import ( - "fmt" + "errors" basemodel "github.com/SigNoz/signoz/pkg/query-service/model" ) @@ -57,7 +57,7 @@ func Unauthorized(err error) *ApiError { func BadRequestStr(s string) *ApiError { return &ApiError{ Typ: basemodel.ErrorBadData, - Err: fmt.Errorf(s), + Err: errors.New(s), } } @@ -73,7 +73,7 @@ func InternalError(err error) *ApiError { func InternalErrorStr(s string) *ApiError { return &ApiError{ Typ: basemodel.ErrorInternal, - Err: fmt.Errorf(s), + Err: errors.New(s), } } diff --git a/ee/query-service/model/license.go b/ee/query-service/model/license.go deleted file mode 100644 index 513d080891c..00000000000 --- a/ee/query-service/model/license.go +++ /dev/null @@ -1,244 +0,0 @@ -package model - -import ( - "encoding/json" - "fmt" - "reflect" - "time" - - basemodel "github.com/SigNoz/signoz/pkg/query-service/model" - "github.com/pkg/errors" -) - -type License struct { - Key string `json:"key" db:"key"` - ActivationId string `json:"activationId" db:"activationId"` - CreatedAt time.Time `db:"created_at"` - - // PlanDetails contains the encrypted plan info - PlanDetails string `json:"planDetails" db:"planDetails"` - - // stores parsed license details - LicensePlan - - FeatureSet basemodel.FeatureSet - - // populated in case license has any errors - ValidationMessage string `db:"validationMessage"` - - // used only for sending details to front-end - IsCurrent bool `json:"isCurrent"` -} - -func (l *License) MarshalJSON() ([]byte, error) { - - return json.Marshal(&struct { - Key string `json:"key" db:"key"` - ActivationId string `json:"activationId" db:"activationId"` - ValidationMessage string `db:"validationMessage"` - IsCurrent bool `json:"isCurrent"` - PlanKey string `json:"planKey"` - ValidFrom time.Time `json:"ValidFrom"` - ValidUntil time.Time `json:"ValidUntil"` - Status string `json:"status"` - }{ - Key: l.Key, - ActivationId: l.ActivationId, - IsCurrent: l.IsCurrent, - PlanKey: l.PlanKey, - ValidFrom: time.Unix(l.ValidFrom, 0), - ValidUntil: time.Unix(l.ValidUntil, 0), - Status: l.Status, - ValidationMessage: l.ValidationMessage, - }) -} - -type LicensePlan struct { - PlanKey string `json:"planKey"` - ValidFrom int64 `json:"validFrom"` - ValidUntil int64 `json:"validUntil"` - Status string `json:"status"` -} - -type Licenses struct { - TrialStart int64 `json:"trialStart"` - TrialEnd int64 `json:"trialEnd"` - OnTrial bool `json:"onTrial"` - WorkSpaceBlock bool `json:"workSpaceBlock"` - TrialConvertedToSubscription bool `json:"trialConvertedToSubscription"` - GracePeriodEnd int64 `json:"gracePeriodEnd"` - Licenses []License `json:"licenses"` -} - -type SubscriptionServerResp struct { - Status string `json:"status"` - Data Licenses `json:"data"` -} - -type Plan struct { - Name string `json:"name"` -} - -type LicenseDB struct { - ID string `json:"id"` - Key string `json:"key"` - Data string `json:"data"` -} -type LicenseV3 struct { - ID string - Key string - Data map[string]interface{} - PlanName string - Features basemodel.FeatureSet - Status string - IsCurrent bool - ValidFrom int64 - ValidUntil int64 -} - -func extractKeyFromMapStringInterface[T any](data map[string]interface{}, key string) (T, error) { - var zeroValue T - if val, ok := data[key]; ok { - if value, ok := val.(T); ok { - return value, nil - } - return zeroValue, fmt.Errorf("%s key is not a valid %s", key, reflect.TypeOf(zeroValue)) - } - return zeroValue, fmt.Errorf("%s key is missing", key) -} - -func NewLicenseV3(data map[string]interface{}) (*LicenseV3, error) { - var features basemodel.FeatureSet - - // extract id from data - licenseID, err := extractKeyFromMapStringInterface[string](data, "id") - if err != nil { - return nil, err - } - delete(data, "id") - - // extract key from data - licenseKey, err := extractKeyFromMapStringInterface[string](data, "key") - if err != nil { - return nil, err - } - delete(data, "key") - - // extract status from data - status, err := extractKeyFromMapStringInterface[string](data, "status") - if err != nil { - return nil, err - } - - planMap, err := extractKeyFromMapStringInterface[map[string]any](data, "plan") - if err != nil { - return nil, err - } - - planName, err := extractKeyFromMapStringInterface[string](planMap, "name") - if err != nil { - return nil, err - } - // if license status is invalid then default it to basic - if status == LicenseStatusInvalid { - planName = PlanNameBasic - } - - featuresFromZeus := basemodel.FeatureSet{} - if _features, ok := data["features"]; ok { - featuresData, err := json.Marshal(_features) - if err != nil { - return nil, errors.Wrap(err, "failed to marshal features data") - } - - if err := json.Unmarshal(featuresData, &featuresFromZeus); err != nil { - return nil, errors.Wrap(err, "failed to unmarshal features data") - } - } - - switch planName { - case PlanNameEnterprise: - features = append(features, EnterprisePlan...) - case PlanNameBasic: - features = append(features, BasicPlan...) - default: - features = append(features, BasicPlan...) - } - - if len(featuresFromZeus) > 0 { - for _, feature := range featuresFromZeus { - exists := false - for i, existingFeature := range features { - if existingFeature.Name == feature.Name { - features[i] = feature // Replace existing feature - exists = true - break - } - } - if !exists { - features = append(features, feature) // Append if it doesn't exist - } - } - } - data["features"] = features - - _validFrom, err := extractKeyFromMapStringInterface[float64](data, "valid_from") - if err != nil { - _validFrom = 0 - } - validFrom := int64(_validFrom) - - _validUntil, err := extractKeyFromMapStringInterface[float64](data, "valid_until") - if err != nil { - _validUntil = 0 - } - validUntil := int64(_validUntil) - - return &LicenseV3{ - ID: licenseID, - Key: licenseKey, - Data: data, - PlanName: planName, - Features: features, - ValidFrom: validFrom, - ValidUntil: validUntil, - Status: status, - }, nil - -} - -func NewLicenseV3WithIDAndKey(id string, key string, data map[string]interface{}) (*LicenseV3, error) { - licenseDataWithIdAndKey := data - licenseDataWithIdAndKey["id"] = id - licenseDataWithIdAndKey["key"] = key - return NewLicenseV3(licenseDataWithIdAndKey) -} - -func ConvertLicenseV3ToLicenseV2(l *LicenseV3) *License { - planKeyFromPlanName, ok := MapOldPlanKeyToNewPlanName[l.PlanName] - if !ok { - planKeyFromPlanName = Basic - } - return &License{ - Key: l.Key, - ActivationId: "", - PlanDetails: "", - FeatureSet: l.Features, - ValidationMessage: "", - IsCurrent: l.IsCurrent, - LicensePlan: LicensePlan{ - PlanKey: planKeyFromPlanName, - ValidFrom: l.ValidFrom, - ValidUntil: l.ValidUntil, - Status: l.Status}, - } - -} - -type CheckoutRequest struct { - SuccessURL string `json:"url"` -} - -type PortalRequest struct { - SuccessURL string `json:"url"` -} diff --git a/ee/query-service/model/license_test.go b/ee/query-service/model/license_test.go deleted file mode 100644 index 710541eea3f..00000000000 --- a/ee/query-service/model/license_test.go +++ /dev/null @@ -1,170 +0,0 @@ -package model - -import ( - "encoding/json" - "testing" - - "github.com/SigNoz/signoz/pkg/query-service/model" - "github.com/pkg/errors" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestNewLicenseV3(t *testing.T) { - testCases := []struct { - name string - data []byte - pass bool - expected *LicenseV3 - error error - }{ - { - name: "Error for missing license id", - data: []byte(`{}`), - pass: false, - error: errors.New("id key is missing"), - }, - { - name: "Error for license id not being a valid string", - data: []byte(`{"id": 10}`), - pass: false, - error: errors.New("id key is not a valid string"), - }, - { - name: "Error for missing license key", - data: []byte(`{"id":"does-not-matter"}`), - pass: false, - error: errors.New("key key is missing"), - }, - { - name: "Error for invalid string license key", - data: []byte(`{"id":"does-not-matter","key":10}`), - pass: false, - error: errors.New("key key is not a valid string"), - }, - { - name: "Error for missing license status", - data: []byte(`{"id":"does-not-matter", "key": "does-not-matter","category":"FREE"}`), - pass: false, - error: errors.New("status key is missing"), - }, - { - name: "Error for invalid string license status", - data: []byte(`{"id":"does-not-matter","key": "does-not-matter", "category":"FREE", "status":10}`), - pass: false, - error: errors.New("status key is not a valid string"), - }, - { - name: "Error for missing license plan", - data: []byte(`{"id":"does-not-matter","key":"does-not-matter-key","category":"FREE","status":"ACTIVE"}`), - pass: false, - error: errors.New("plan key is missing"), - }, - { - name: "Error for invalid json license plan", - data: []byte(`{"id":"does-not-matter","key":"does-not-matter-key","category":"FREE","status":"ACTIVE","plan":10}`), - pass: false, - error: errors.New("plan key is not a valid map[string]interface {}"), - }, - { - name: "Error for invalid license plan", - data: []byte(`{"id":"does-not-matter","key":"does-not-matter-key","category":"FREE","status":"ACTIVE","plan":{}}`), - pass: false, - error: errors.New("name key is missing"), - }, - { - name: "Parse the entire license properly", - data: []byte(`{"id":"does-not-matter","key":"does-not-matter-key","category":"FREE","status":"ACTIVE","plan":{"name":"ENTERPRISE"},"valid_from": 1730899309,"valid_until": -1}`), - pass: true, - expected: &LicenseV3{ - ID: "does-not-matter", - Key: "does-not-matter-key", - Data: map[string]interface{}{ - "plan": map[string]interface{}{ - "name": "ENTERPRISE", - }, - "category": "FREE", - "status": "ACTIVE", - "valid_from": float64(1730899309), - "valid_until": float64(-1), - }, - PlanName: PlanNameEnterprise, - ValidFrom: 1730899309, - ValidUntil: -1, - Status: "ACTIVE", - IsCurrent: false, - Features: model.FeatureSet{}, - }, - }, - { - name: "Fallback to basic plan if license status is invalid", - data: []byte(`{"id":"does-not-matter","key":"does-not-matter-key","category":"FREE","status":"INVALID","plan":{"name":"ENTERPRISE"},"valid_from": 1730899309,"valid_until": -1}`), - pass: true, - expected: &LicenseV3{ - ID: "does-not-matter", - Key: "does-not-matter-key", - Data: map[string]interface{}{ - "plan": map[string]interface{}{ - "name": "ENTERPRISE", - }, - "category": "FREE", - "status": "INVALID", - "valid_from": float64(1730899309), - "valid_until": float64(-1), - }, - PlanName: PlanNameBasic, - ValidFrom: 1730899309, - ValidUntil: -1, - Status: "INVALID", - IsCurrent: false, - Features: model.FeatureSet{}, - }, - }, - { - name: "fallback states for validFrom and validUntil", - data: []byte(`{"id":"does-not-matter","key":"does-not-matter-key","category":"FREE","status":"ACTIVE","plan":{"name":"ENTERPRISE"},"valid_from":1234.456,"valid_until":5678.567}`), - pass: true, - expected: &LicenseV3{ - ID: "does-not-matter", - Key: "does-not-matter-key", - Data: map[string]interface{}{ - "plan": map[string]interface{}{ - "name": "ENTERPRISE", - }, - "valid_from": 1234.456, - "valid_until": 5678.567, - "category": "FREE", - "status": "ACTIVE", - }, - PlanName: PlanNameEnterprise, - ValidFrom: 1234, - ValidUntil: 5678, - Status: "ACTIVE", - IsCurrent: false, - Features: model.FeatureSet{}, - }, - }, - } - - for _, tc := range testCases { - var licensePayload map[string]interface{} - err := json.Unmarshal(tc.data, &licensePayload) - require.NoError(t, err) - license, err := NewLicenseV3(licensePayload) - if license != nil { - license.Features = make(model.FeatureSet, 0) - delete(license.Data, "features") - } - - if tc.pass { - require.NoError(t, err) - require.NotNil(t, license) - assert.Equal(t, tc.expected, license) - } else { - require.Error(t, err) - assert.EqualError(t, err, tc.error.Error()) - require.Nil(t, license) - } - - } -} diff --git a/ee/query-service/model/plans.go b/ee/query-service/model/plans.go deleted file mode 100644 index 2de2e7ccb87..00000000000 --- a/ee/query-service/model/plans.go +++ /dev/null @@ -1,131 +0,0 @@ -package model - -import ( - basemodel "github.com/SigNoz/signoz/pkg/query-service/model" -) - -const SSO = "SSO" -const Basic = "BASIC_PLAN" -const Enterprise = "ENTERPRISE_PLAN" - -var ( - PlanNameEnterprise = "ENTERPRISE" - PlanNameBasic = "BASIC" -) - -var ( - MapOldPlanKeyToNewPlanName map[string]string = map[string]string{PlanNameBasic: Basic, PlanNameEnterprise: Enterprise} -) - -var ( - LicenseStatusInvalid = "INVALID" -) - -const Onboarding = "ONBOARDING" -const ChatSupport = "CHAT_SUPPORT" -const Gateway = "GATEWAY" -const PremiumSupport = "PREMIUM_SUPPORT" - -var BasicPlan = basemodel.FeatureSet{ - basemodel.Feature{ - Name: SSO, - Active: false, - Usage: 0, - UsageLimit: -1, - Route: "", - }, - basemodel.Feature{ - Name: basemodel.UseSpanMetrics, - Active: false, - Usage: 0, - UsageLimit: -1, - Route: "", - }, - basemodel.Feature{ - Name: Gateway, - Active: false, - Usage: 0, - UsageLimit: -1, - Route: "", - }, - basemodel.Feature{ - Name: PremiumSupport, - Active: false, - Usage: 0, - UsageLimit: -1, - Route: "", - }, - basemodel.Feature{ - Name: basemodel.AnomalyDetection, - Active: false, - Usage: 0, - UsageLimit: -1, - Route: "", - }, - basemodel.Feature{ - Name: basemodel.TraceFunnels, - Active: false, - Usage: 0, - UsageLimit: -1, - Route: "", - }, -} - -var EnterprisePlan = basemodel.FeatureSet{ - basemodel.Feature{ - Name: SSO, - Active: true, - Usage: 0, - UsageLimit: -1, - Route: "", - }, - basemodel.Feature{ - Name: basemodel.UseSpanMetrics, - Active: false, - Usage: 0, - UsageLimit: -1, - Route: "", - }, - basemodel.Feature{ - Name: Onboarding, - Active: true, - Usage: 0, - UsageLimit: -1, - Route: "", - }, - basemodel.Feature{ - Name: ChatSupport, - Active: true, - Usage: 0, - UsageLimit: -1, - Route: "", - }, - basemodel.Feature{ - Name: Gateway, - Active: true, - Usage: 0, - UsageLimit: -1, - Route: "", - }, - basemodel.Feature{ - Name: PremiumSupport, - Active: true, - Usage: 0, - UsageLimit: -1, - Route: "", - }, - basemodel.Feature{ - Name: basemodel.AnomalyDetection, - Active: true, - Usage: 0, - UsageLimit: -1, - Route: "", - }, - basemodel.Feature{ - Name: basemodel.TraceFunnels, - Active: false, - Usage: 0, - UsageLimit: -1, - Route: "", - }, -} diff --git a/ee/query-service/rules/anomaly.go b/ee/query-service/rules/anomaly.go index 78801f1f1c5..af988b5d677 100644 --- a/ee/query-service/rules/anomaly.go +++ b/ee/query-service/rules/anomaly.go @@ -4,17 +4,17 @@ import ( "context" "encoding/json" "fmt" + "log/slog" "math" "strings" "sync" "time" - "go.uber.org/zap" - "github.com/SigNoz/signoz/ee/query-service/anomaly" "github.com/SigNoz/signoz/pkg/cache" "github.com/SigNoz/signoz/pkg/query-service/common" "github.com/SigNoz/signoz/pkg/query-service/model" + "github.com/SigNoz/signoz/pkg/transition" ruletypes "github.com/SigNoz/signoz/pkg/types/ruletypes" "github.com/SigNoz/signoz/pkg/valuer" @@ -30,7 +30,11 @@ import ( baserules "github.com/SigNoz/signoz/pkg/query-service/rules" - yaml "gopkg.in/yaml.v2" + querierV5 "github.com/SigNoz/signoz/pkg/querier" + + anomalyV2 "github.com/SigNoz/signoz/ee/anomaly" + + qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5" ) const ( @@ -47,7 +51,14 @@ type AnomalyRule struct { // querierV2 is used for alerts created after the introduction of new metrics query builder querierV2 interfaces.Querier - provider anomaly.Provider + // querierV5 is used for alerts migrated after the introduction of new query builder + querierV5 querierV5.Querier + + provider anomaly.Provider + providerV2 anomalyV2.Provider + + version string + logger *slog.Logger seasonality anomaly.Seasonality } @@ -57,11 +68,15 @@ func NewAnomalyRule( orgID valuer.UUID, p *ruletypes.PostableRule, reader interfaces.Reader, + querierV5 querierV5.Querier, + logger *slog.Logger, cache cache.Cache, opts ...baserules.RuleOption, ) (*AnomalyRule, error) { - zap.L().Info("creating new AnomalyRule", zap.String("id", id), zap.Any("opts", opts)) + logger.Info("creating new AnomalyRule", "rule_id", id) + + opts = append(opts, baserules.WithLogger(logger)) if p.RuleCondition.CompareOp == ruletypes.ValueIsBelow { target := -1 * *p.RuleCondition.Target @@ -88,7 +103,7 @@ func NewAnomalyRule( t.seasonality = anomaly.SeasonalityDaily } - zap.L().Info("using seasonality", zap.String("seasonality", t.seasonality.String())) + logger.Info("using seasonality", "seasonality", t.seasonality.String()) querierOptsV2 := querierV2.QuerierOptions{ Reader: reader, @@ -117,6 +132,27 @@ func NewAnomalyRule( anomaly.WithReader[*anomaly.WeeklyProvider](reader), ) } + + if t.seasonality == anomaly.SeasonalityHourly { + t.providerV2 = anomalyV2.NewHourlyProvider( + anomalyV2.WithQuerier[*anomalyV2.HourlyProvider](querierV5), + anomalyV2.WithLogger[*anomalyV2.HourlyProvider](logger), + ) + } else if t.seasonality == anomaly.SeasonalityDaily { + t.providerV2 = anomalyV2.NewDailyProvider( + anomalyV2.WithQuerier[*anomalyV2.DailyProvider](querierV5), + anomalyV2.WithLogger[*anomalyV2.DailyProvider](logger), + ) + } else if t.seasonality == anomaly.SeasonalityWeekly { + t.providerV2 = anomalyV2.NewWeeklyProvider( + anomalyV2.WithQuerier[*anomalyV2.WeeklyProvider](querierV5), + anomalyV2.WithLogger[*anomalyV2.WeeklyProvider](logger), + ) + } + + t.querierV5 = querierV5 + t.version = p.Version + t.logger = logger return &t, nil } @@ -124,20 +160,15 @@ func (r *AnomalyRule) Type() ruletypes.RuleType { return RuleTypeAnomaly } -func (r *AnomalyRule) prepareQueryRange(ts time.Time) (*v3.QueryRangeParamsV3, error) { +func (r *AnomalyRule) prepareQueryRange(ctx context.Context, ts time.Time) (*v3.QueryRangeParamsV3, error) { - zap.L().Info("prepareQueryRange", zap.Int64("ts", ts.UnixMilli()), zap.Int64("evalWindow", r.EvalWindow().Milliseconds()), zap.Int64("evalDelay", r.EvalDelay().Milliseconds())) + r.logger.InfoContext( + ctx, "prepare query range request v4", "ts", ts.UnixMilli(), "eval_window", r.EvalWindow().Milliseconds(), "eval_delay", r.EvalDelay().Milliseconds(), + ) - start := ts.Add(-time.Duration(r.EvalWindow())).UnixMilli() - end := ts.UnixMilli() - - if r.EvalDelay() > 0 { - start = start - int64(r.EvalDelay().Milliseconds()) - end = end - int64(r.EvalDelay().Milliseconds()) - } - // round to minute otherwise we could potentially miss data - start = start - (start % (60 * 1000)) - end = end - (end % (60 * 1000)) + st, en := r.Timestamps(ts) + start := st.UnixMilli() + end := en.UnixMilli() compositeQuery := r.Condition().CompositeQuery @@ -156,13 +187,34 @@ func (r *AnomalyRule) prepareQueryRange(ts time.Time) (*v3.QueryRangeParamsV3, e }, nil } +func (r *AnomalyRule) prepareQueryRangeV5(ctx context.Context, ts time.Time) (*qbtypes.QueryRangeRequest, error) { + + r.logger.InfoContext(ctx, "prepare query range request v5", "ts", ts.UnixMilli(), "eval_window", r.EvalWindow().Milliseconds(), "eval_delay", r.EvalDelay().Milliseconds()) + + startTs, endTs := r.Timestamps(ts) + start, end := startTs.UnixMilli(), endTs.UnixMilli() + + req := &qbtypes.QueryRangeRequest{ + Start: uint64(start), + End: uint64(end), + RequestType: qbtypes.RequestTypeTimeSeries, + CompositeQuery: qbtypes.CompositeQuery{ + Queries: make([]qbtypes.QueryEnvelope, 0), + }, + NoCache: true, + } + req.CompositeQuery.Queries = make([]qbtypes.QueryEnvelope, len(r.Condition().CompositeQuery.Queries)) + copy(req.CompositeQuery.Queries, r.Condition().CompositeQuery.Queries) + return req, nil +} + func (r *AnomalyRule) GetSelectedQuery() string { return r.Condition().GetSelectedQueryName() } func (r *AnomalyRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, ts time.Time) (ruletypes.Vector, error) { - params, err := r.prepareQueryRange(ts) + params, err := r.prepareQueryRange(ctx, ts) if err != nil { return nil, err } @@ -190,13 +242,70 @@ func (r *AnomalyRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, t var resultVector ruletypes.Vector scoresJSON, _ := json.Marshal(queryResult.AnomalyScores) - zap.L().Info("anomaly scores", zap.String("scores", string(scoresJSON))) + r.logger.InfoContext(ctx, "anomaly scores", "scores", string(scoresJSON)) for _, series := range queryResult.AnomalyScores { - smpl, shouldAlert := r.ShouldAlert(*series) - if shouldAlert { - resultVector = append(resultVector, smpl) + if r.Condition() != nil && r.Condition().RequireMinPoints { + if len(series.Points) < r.Condition().RequiredNumPoints { + r.logger.InfoContext(ctx, "not enough data points to evaluate series, skipping", "ruleid", r.ID(), "numPoints", len(series.Points), "requiredPoints", r.Condition().RequiredNumPoints) + continue + } } + results, err := r.Threshold.ShouldAlert(*series, r.Unit()) + if err != nil { + return nil, err + } + resultVector = append(resultVector, results...) + } + return resultVector, nil +} + +func (r *AnomalyRule) buildAndRunQueryV5(ctx context.Context, orgID valuer.UUID, ts time.Time) (ruletypes.Vector, error) { + + params, err := r.prepareQueryRangeV5(ctx, ts) + if err != nil { + return nil, err + } + + anomalies, err := r.providerV2.GetAnomalies(ctx, orgID, &anomalyV2.AnomaliesRequest{ + Params: *params, + Seasonality: anomalyV2.Seasonality{String: valuer.NewString(r.seasonality.String())}, + }) + if err != nil { + return nil, err + } + + var qbResult *qbtypes.TimeSeriesData + for _, result := range anomalies.Results { + if result.QueryName == r.GetSelectedQuery() { + qbResult = result + break + } + } + + if qbResult == nil { + r.logger.WarnContext(ctx, "nil qb result", "ts", ts.UnixMilli()) + } + + queryResult := transition.ConvertV5TimeSeriesDataToV4Result(qbResult) + + var resultVector ruletypes.Vector + + scoresJSON, _ := json.Marshal(queryResult.AnomalyScores) + r.logger.InfoContext(ctx, "anomaly scores", "scores", string(scoresJSON)) + + for _, series := range queryResult.AnomalyScores { + if r.Condition().RequireMinPoints { + if len(series.Points) < r.Condition().RequiredNumPoints { + r.logger.InfoContext(ctx, "not enough data points to evaluate series, skipping", "ruleid", r.ID(), "numPoints", len(series.Points), "requiredPoints", r.Condition().RequiredNumPoints) + continue + } + } + results, err := r.Threshold.ShouldAlert(*series, r.Unit()) + if err != nil { + return nil, err + } + resultVector = append(resultVector, results...) } return resultVector, nil } @@ -206,8 +315,17 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (interface{}, erro prevState := r.State() valueFormatter := formatter.FromUnit(r.Unit()) - res, err := r.buildAndRunQuery(ctx, r.OrgID(), ts) + var res ruletypes.Vector + var err error + + if r.version == "v5" { + r.logger.InfoContext(ctx, "running v5 query") + res, err = r.buildAndRunQueryV5(ctx, r.OrgID(), ts) + } else { + r.logger.InfoContext(ctx, "running v4 query") + res, err = r.buildAndRunQuery(ctx, r.OrgID(), ts) + } if err != nil { return nil, err } @@ -218,15 +336,20 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (interface{}, erro resultFPs := map[uint64]struct{}{} var alerts = make(map[uint64]*ruletypes.Alert, len(res)) + ruleReceivers := r.Threshold.GetRuleReceivers() + ruleReceiverMap := make(map[string][]string) + for _, value := range ruleReceivers { + ruleReceiverMap[value.Name] = value.Channels + } + for _, smpl := range res { l := make(map[string]string, len(smpl.Metric)) for _, lbl := range smpl.Metric { l[lbl.Name] = lbl.Value } - value := valueFormatter.Format(smpl.V, r.Unit()) - threshold := valueFormatter.Format(r.TargetVal(), r.Unit()) - zap.L().Debug("Alert template data for rule", zap.String("name", r.Name()), zap.String("formatter", valueFormatter.Name()), zap.String("value", value), zap.String("threshold", threshold)) + threshold := valueFormatter.Format(smpl.Target, smpl.TargetUnit) + r.logger.DebugContext(ctx, "Alert template data for rule", "rule_name", r.Name(), "formatter", valueFormatter.Name(), "value", value, "threshold", threshold) tmplData := ruletypes.AlertTemplateData(l, value, threshold) // Inject some convenience variables that are easier to remember for users @@ -247,7 +370,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (interface{}, erro result, err := tmpl.Expand() if err != nil { result = fmt.Sprintf("", err) - zap.L().Error("Expanding alert template failed", zap.Error(err), zap.Any("data", tmplData)) + r.logger.ErrorContext(ctx, "Expanding alert template failed", "error", err, "data", tmplData, "rule_name", r.Name()) } return result } @@ -269,6 +392,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (interface{}, erro } if smpl.IsMissing { lb.Set(labels.AlertNameLabel, "[No data] "+r.Name()) + lb.Set(labels.NoDataLabel, "true") } lbs := lb.Labels() @@ -276,7 +400,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (interface{}, erro resultFPs[h] = struct{}{} if _, ok := alerts[h]; ok { - zap.L().Error("the alert query returns duplicate records", zap.String("ruleid", r.ID()), zap.Any("alert", alerts[h])) + r.logger.ErrorContext(ctx, "the alert query returns duplicate records", "rule_id", r.ID(), "alert", alerts[h]) err = fmt.Errorf("duplicate alert found, vector contains metrics with the same labelset after applying alert labels") return nil, err } @@ -289,13 +413,12 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (interface{}, erro State: model.StatePending, Value: smpl.V, GeneratorURL: r.GeneratorURL(), - Receivers: r.PreferredChannels(), + Receivers: ruleReceiverMap[lbs.Map()[ruletypes.LabelThresholdName]], Missing: smpl.IsMissing, } } - zap.L().Info("number of alerts found", zap.String("name", r.Name()), zap.Int("count", len(alerts))) - + r.logger.InfoContext(ctx, "number of alerts found", "rule_name", r.Name(), "alerts_count", len(alerts)) // alerts[h] is ready, add or update active list now for h, a := range alerts { // Check whether we already have alerting state for the identifying label set. @@ -304,7 +427,9 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (interface{}, erro alert.Value = a.Value alert.Annotations = a.Annotations - alert.Receivers = r.PreferredChannels() + if v, ok := alert.Labels.Map()[ruletypes.LabelThresholdName]; ok { + alert.Receivers = ruleReceiverMap[v] + } continue } @@ -317,7 +442,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (interface{}, erro for fp, a := range r.Active { labelsJSON, err := json.Marshal(a.QueryResultLables) if err != nil { - zap.L().Error("error marshaling labels", zap.Error(err), zap.Any("labels", a.Labels)) + r.logger.ErrorContext(ctx, "error marshaling labels", "error", err, "labels", a.Labels) } if _, ok := resultFPs[fp]; !ok { // If the alert was previously firing, keep it around for a given @@ -387,7 +512,7 @@ func (r *AnomalyRule) String() string { PreferredChannels: r.PreferredChannels(), } - byt, err := yaml.Marshal(ar) + byt, err := json.Marshal(ar) if err != nil { return fmt.Sprintf("error marshaling alerting rule: %s", err.Error()) } diff --git a/ee/query-service/rules/manager.go b/ee/query-service/rules/manager.go index 48550558dc0..31009b3c309 100644 --- a/ee/query-service/rules/manager.go +++ b/ee/query-service/rules/manager.go @@ -3,8 +3,10 @@ package rules import ( "context" "fmt" + "time" + "github.com/SigNoz/signoz/pkg/errors" basemodel "github.com/SigNoz/signoz/pkg/query-service/model" baserules "github.com/SigNoz/signoz/pkg/query-service/rules" "github.com/SigNoz/signoz/pkg/query-service/utils/labels" @@ -20,6 +22,10 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error) var task baserules.Task ruleId := baserules.RuleIdFromTaskName(opts.TaskName) + evaluation, err := opts.Rule.Evaluation.GetEvaluation() + if err != nil { + return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "evaluation is invalid: %v", err) + } if opts.Rule.RuleType == ruletypes.RuleTypeThreshold { // create a threshold rule tr, err := baserules.NewThresholdRule( @@ -27,6 +33,8 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error) opts.OrgID, opts.Rule, opts.Reader, + opts.Querier, + opts.SLogger, baserules.WithEvalDelay(opts.ManagerOpts.EvalDelay), baserules.WithSQLStore(opts.SQLStore), ) @@ -38,7 +46,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error) rules = append(rules, tr) // create ch rule task for evalution - task = newTask(baserules.TaskTypeCh, opts.TaskName, time.Duration(opts.Rule.Frequency), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID) + task = newTask(baserules.TaskTypeCh, opts.TaskName, time.Duration(evaluation.GetFrequency()), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID) } else if opts.Rule.RuleType == ruletypes.RuleTypeProm { @@ -47,7 +55,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error) ruleId, opts.OrgID, opts.Rule, - opts.Logger, + opts.SLogger, opts.Reader, opts.ManagerOpts.Prometheus, baserules.WithSQLStore(opts.SQLStore), @@ -60,7 +68,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error) rules = append(rules, pr) // create promql rule task for evalution - task = newTask(baserules.TaskTypeProm, opts.TaskName, time.Duration(opts.Rule.Frequency), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID) + task = newTask(baserules.TaskTypeProm, opts.TaskName, time.Duration(evaluation.GetFrequency()), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID) } else if opts.Rule.RuleType == ruletypes.RuleTypeAnomaly { // create anomaly rule @@ -69,6 +77,8 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error) opts.OrgID, opts.Rule, opts.Reader, + opts.Querier, + opts.SLogger, opts.Cache, baserules.WithEvalDelay(opts.ManagerOpts.EvalDelay), baserules.WithSQLStore(opts.SQLStore), @@ -80,7 +90,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error) rules = append(rules, ar) // create anomaly rule task for evalution - task = newTask(baserules.TaskTypeCh, opts.TaskName, time.Duration(opts.Rule.Frequency), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID) + task = newTask(baserules.TaskTypeCh, opts.TaskName, time.Duration(evaluation.GetFrequency()), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID) } else { return nil, fmt.Errorf("unsupported rule type %s. Supported types: %s, %s", opts.Rule.RuleType, ruletypes.RuleTypeProm, ruletypes.RuleTypeThreshold) @@ -116,7 +126,6 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.Ap if parsedRule.RuleType == ruletypes.RuleTypeThreshold { // add special labels for test alerts - parsedRule.Annotations[labels.AlertSummaryLabel] = fmt.Sprintf("The rule threshold is set to %.4f, and the observed metric value is {{$value}}.", *parsedRule.RuleCondition.Target) parsedRule.Labels[labels.RuleSourceLabel] = "" parsedRule.Labels[labels.AlertRuleIdLabel] = "" @@ -126,6 +135,8 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.Ap opts.OrgID, parsedRule, opts.Reader, + opts.Querier, + opts.SLogger, baserules.WithSendAlways(), baserules.WithSendUnmatched(), baserules.WithSQLStore(opts.SQLStore), @@ -143,7 +154,7 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.Ap alertname, opts.OrgID, parsedRule, - opts.Logger, + opts.SLogger, opts.Reader, opts.ManagerOpts.Prometheus, baserules.WithSendAlways(), @@ -162,6 +173,8 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.Ap opts.OrgID, parsedRule, opts.Reader, + opts.Querier, + opts.SLogger, opts.Cache, baserules.WithSendAlways(), baserules.WithSendUnmatched(), diff --git a/ee/query-service/usage/manager.go b/ee/query-service/usage/manager.go index e42946cf9c0..ad8c8ec4baa 100644 --- a/ee/query-service/usage/manager.go +++ b/ee/query-service/usage/manager.go @@ -14,9 +14,9 @@ import ( "go.uber.org/zap" - "github.com/SigNoz/signoz/ee/query-service/dao" - "github.com/SigNoz/signoz/ee/query-service/license" "github.com/SigNoz/signoz/ee/query-service/model" + "github.com/SigNoz/signoz/pkg/licensing" + "github.com/SigNoz/signoz/pkg/modules/organization" "github.com/SigNoz/signoz/pkg/query-service/utils/encryption" "github.com/SigNoz/signoz/pkg/zeus" ) @@ -35,64 +35,68 @@ var ( type Manager struct { clickhouseConn clickhouse.Conn - licenseRepo *license.Repo + licenseService licensing.Licensing scheduler *gocron.Scheduler - modelDao dao.ModelDao - zeus zeus.Zeus + + orgGetter organization.Getter } -func New(modelDao dao.ModelDao, licenseRepo *license.Repo, clickhouseConn clickhouse.Conn, zeus zeus.Zeus) (*Manager, error) { +func New(licenseService licensing.Licensing, clickhouseConn clickhouse.Conn, zeus zeus.Zeus, orgGetter organization.Getter) (*Manager, error) { m := &Manager{ clickhouseConn: clickhouseConn, - licenseRepo: licenseRepo, + licenseService: licenseService, scheduler: gocron.NewScheduler(time.UTC).Every(1).Day().At("00:00"), // send usage every at 00:00 UTC - modelDao: modelDao, zeus: zeus, + orgGetter: orgGetter, } return m, nil } // start loads collects and exports any exported snapshot and starts the exporter -func (lm *Manager) Start() error { +func (lm *Manager) Start(ctx context.Context) error { // compares the locker and stateUnlocked if both are same lock is applied else returns error if !atomic.CompareAndSwapUint32(&locker, stateUnlocked, stateLocked) { return fmt.Errorf("usage exporter is locked") } - _, err := lm.scheduler.Do(func() { lm.UploadUsage() }) + // upload usage once when starting the service + + _, err := lm.scheduler.Do(func() { lm.UploadUsage(ctx) }) if err != nil { return err } - // upload usage once when starting the service - lm.UploadUsage() - + lm.UploadUsage(ctx) lm.scheduler.StartAsync() - return nil } -func (lm *Manager) UploadUsage() { - ctx := context.Background() - // check if license is present or not - license, err := lm.licenseRepo.GetActiveLicense(ctx) +func (lm *Manager) UploadUsage(ctx context.Context) { + organizations, err := lm.orgGetter.ListByOwnedKeyRange(ctx) if err != nil { - zap.L().Error("failed to get active license", zap.Error(err)) - return - } - if license == nil { - // we will not start the usage reporting if license is not present. - zap.L().Info("no license present, skipping usage reporting") + zap.L().Error("failed to get organizations", zap.Error(err)) return } + for _, organization := range organizations { + // check if license is present or not + license, err := lm.licenseService.GetActive(ctx, organization.ID) + if err != nil { + zap.L().Error("failed to get active license", zap.Error(err)) + return + } + if license == nil { + // we will not start the usage reporting if license is not present. + zap.L().Info("no license present, skipping usage reporting") + return + } - usages := []model.UsageDB{} + usages := []model.UsageDB{} - // get usage from clickhouse - dbs := []string{"signoz_logs", "signoz_traces", "signoz_metrics"} - query := ` + // get usage from clickhouse + dbs := []string{"signoz_logs", "signoz_traces", "signoz_metrics"} + query := ` SELECT tenant, collector_id, exporter_id, timestamp, data FROM %s.distributed_usage as u1 GLOBAL INNER JOIN @@ -107,76 +111,76 @@ func (lm *Manager) UploadUsage() { order by timestamp ` - for _, db := range dbs { - dbusages := []model.UsageDB{} - err := lm.clickhouseConn.Select(ctx, &dbusages, fmt.Sprintf(query, db, db), time.Now().Add(-(24 * time.Hour))) - if err != nil && !strings.Contains(err.Error(), "doesn't exist") { - zap.L().Error("failed to get usage from clickhouse: %v", zap.Error(err)) - return + for _, db := range dbs { + dbusages := []model.UsageDB{} + err := lm.clickhouseConn.Select(ctx, &dbusages, fmt.Sprintf(query, db, db), time.Now().Add(-(24 * time.Hour))) + if err != nil && !strings.Contains(err.Error(), "doesn't exist") { + zap.L().Error("failed to get usage from clickhouse: %v", zap.Error(err)) + return + } + for _, u := range dbusages { + u.Type = db + usages = append(usages, u) + } } - for _, u := range dbusages { - u.Type = db - usages = append(usages, u) + + if len(usages) <= 0 { + zap.L().Info("no snapshots to upload, skipping.") + return } - } - if len(usages) <= 0 { - zap.L().Info("no snapshots to upload, skipping.") - return - } + zap.L().Info("uploading usage data") + + usagesPayload := []model.Usage{} + for _, usage := range usages { + usageDataBytes, err := encryption.Decrypt([]byte(usage.ExporterID[:32]), []byte(usage.Data)) + if err != nil { + zap.L().Error("error while decrypting usage data: %v", zap.Error(err)) + return + } + + usageData := model.Usage{} + err = json.Unmarshal(usageDataBytes, &usageData) + if err != nil { + zap.L().Error("error while unmarshalling usage data: %v", zap.Error(err)) + return + } + + usageData.CollectorID = usage.CollectorID + usageData.ExporterID = usage.ExporterID + usageData.Type = usage.Type + usageData.Tenant = "default" + usageData.OrgName = "default" + usageData.TenantId = "default" + usagesPayload = append(usagesPayload, usageData) + } - zap.L().Info("uploading usage data") + key, _ := uuid.Parse(license.Key) + payload := model.UsagePayload{ + LicenseKey: key, + Usage: usagesPayload, + } - usagesPayload := []model.Usage{} - for _, usage := range usages { - usageDataBytes, err := encryption.Decrypt([]byte(usage.ExporterID[:32]), []byte(usage.Data)) - if err != nil { - zap.L().Error("error while decrypting usage data: %v", zap.Error(err)) + body, errv2 := json.Marshal(payload) + if errv2 != nil { + zap.L().Error("error while marshalling usage payload: %v", zap.Error(errv2)) return } - usageData := model.Usage{} - err = json.Unmarshal(usageDataBytes, &usageData) - if err != nil { - zap.L().Error("error while unmarshalling usage data: %v", zap.Error(err)) + errv2 = lm.zeus.PutMeters(ctx, payload.LicenseKey.String(), body) + if errv2 != nil { + zap.L().Error("failed to upload usage: %v", zap.Error(errv2)) + // not returning error here since it is captured in the failed count return } - - usageData.CollectorID = usage.CollectorID - usageData.ExporterID = usage.ExporterID - usageData.Type = usage.Type - usageData.Tenant = "default" - usageData.OrgName = "default" - usageData.TenantId = "default" - usagesPayload = append(usagesPayload, usageData) - } - - key, _ := uuid.Parse(license.Key) - payload := model.UsagePayload{ - LicenseKey: key, - Usage: usagesPayload, - } - - body, errv2 := json.Marshal(payload) - if errv2 != nil { - zap.L().Error("error while marshalling usage payload: %v", zap.Error(errv2)) - return - } - - errv2 = lm.zeus.PutMeters(ctx, payload.LicenseKey.String(), body) - if errv2 != nil { - zap.L().Error("failed to upload usage: %v", zap.Error(errv2)) - // not returning error here since it is captured in the failed count - return } } -func (lm *Manager) Stop() { +func (lm *Manager) Stop(ctx context.Context) { lm.scheduler.Stop() zap.L().Info("sending usage data before shutting down") // send usage before shutting down - lm.UploadUsage() - + lm.UploadUsage(ctx) atomic.StoreUint32(&locker, stateUnlocked) } diff --git a/ee/sqlschema/postgressqlschema/formatter.go b/ee/sqlschema/postgressqlschema/formatter.go new file mode 100644 index 00000000000..e51fd7ac951 --- /dev/null +++ b/ee/sqlschema/postgressqlschema/formatter.go @@ -0,0 +1,36 @@ +package postgressqlschema + +import ( + "strings" + + "github.com/SigNoz/signoz/pkg/sqlschema" +) + +type Formatter struct { + sqlschema.Formatter +} + +func (formatter Formatter) SQLDataTypeOf(dataType sqlschema.DataType) string { + if dataType == sqlschema.DataTypeTimestamp { + return "TIMESTAMPTZ" + } + + return strings.ToUpper(dataType.String()) +} + +func (formatter Formatter) DataTypeOf(dataType string) sqlschema.DataType { + switch strings.ToUpper(dataType) { + case "TIMESTAMPTZ", "TIMESTAMP", "TIMESTAMP WITHOUT TIME ZONE", "TIMESTAMP WITH TIME ZONE": + return sqlschema.DataTypeTimestamp + case "INT8": + return sqlschema.DataTypeBigInt + case "INT2", "INT4", "SMALLINT", "INTEGER": + return sqlschema.DataTypeInteger + case "BOOL", "BOOLEAN": + return sqlschema.DataTypeBoolean + case "VARCHAR", "CHARACTER VARYING", "CHARACTER": + return sqlschema.DataTypeText + } + + return formatter.Formatter.DataTypeOf(dataType) +} diff --git a/ee/sqlschema/postgressqlschema/provider.go b/ee/sqlschema/postgressqlschema/provider.go new file mode 100644 index 00000000000..af06994b45b --- /dev/null +++ b/ee/sqlschema/postgressqlschema/provider.go @@ -0,0 +1,285 @@ +package postgressqlschema + +import ( + "context" + + "github.com/SigNoz/signoz/pkg/factory" + "github.com/SigNoz/signoz/pkg/sqlschema" + "github.com/SigNoz/signoz/pkg/sqlstore" + "github.com/uptrace/bun" +) + +type provider struct { + settings factory.ScopedProviderSettings + fmter sqlschema.SQLFormatter + sqlstore sqlstore.SQLStore + operator sqlschema.SQLOperator +} + +func NewFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[sqlschema.SQLSchema, sqlschema.Config] { + return factory.NewProviderFactory(factory.MustNewName("postgres"), func(ctx context.Context, providerSettings factory.ProviderSettings, config sqlschema.Config) (sqlschema.SQLSchema, error) { + return New(ctx, providerSettings, config, sqlstore) + }) +} + +func New(ctx context.Context, providerSettings factory.ProviderSettings, config sqlschema.Config, sqlstore sqlstore.SQLStore) (sqlschema.SQLSchema, error) { + settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/sqlschema/postgressqlschema") + fmter := Formatter{Formatter: sqlschema.NewFormatter(sqlstore.BunDB().Dialect())} + + return &provider{ + sqlstore: sqlstore, + fmter: fmter, + settings: settings, + operator: sqlschema.NewOperator(fmter, sqlschema.OperatorSupport{ + DropConstraint: true, + ColumnIfNotExistsExists: true, + AlterColumnSetNotNull: true, + }), + }, nil +} + +func (provider *provider) Formatter() sqlschema.SQLFormatter { + return provider.fmter +} + +func (provider *provider) Operator() sqlschema.SQLOperator { + return provider.operator +} + +func (provider *provider) GetTable(ctx context.Context, tableName sqlschema.TableName) (*sqlschema.Table, []*sqlschema.UniqueConstraint, error) { + rows, err := provider. + sqlstore. + BunDB(). + QueryContext(ctx, ` +SELECT + c.column_name, + c.is_nullable = 'YES', + c.udt_name, + c.column_default +FROM + information_schema.columns AS c +WHERE + c.table_name = ?`, string(tableName)) + if err != nil { + return nil, nil, err + } + + 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 + } + + columnDefault := "" + if defaultVal != nil { + columnDefault = *defaultVal + } + + columns = append(columns, &sqlschema.Column{ + Name: sqlschema.ColumnName(name), + Nullable: nullable, + DataType: provider.fmter.DataTypeOf(sqlDataType), + Default: columnDefault, + }) + } + + constraintsRows, err := provider. + sqlstore. + BunDB(). + QueryContext(ctx, ` +SELECT + c.column_name, + constraint_name, + constraint_type +FROM + information_schema.table_constraints tc + JOIN information_schema.constraint_column_usage AS ccu USING (constraint_schema, constraint_catalog, table_name, constraint_name) + JOIN information_schema.columns AS c ON c.table_schema = tc.constraint_schema AND tc.table_name = c.table_name AND ccu.column_name = c.column_name +WHERE + c.table_name = ?`, string(tableName)) + if err != nil { + return nil, nil, err + } + + defer func() { + if err := constraintsRows.Close(); err != nil { + provider.settings.Logger().ErrorContext(ctx, "error closing rows", "error", err) + } + }() + + var primaryKeyConstraint *sqlschema.PrimaryKeyConstraint + uniqueConstraintsMap := make(map[string]*sqlschema.UniqueConstraint) + for constraintsRows.Next() { + var ( + name string + constraintName string + constraintType string + ) + + if err := constraintsRows.Scan(&name, &constraintName, &constraintType); err != nil { + return nil, nil, err + } + + if constraintType == "PRIMARY KEY" { + if primaryKeyConstraint == nil { + primaryKeyConstraint = (&sqlschema.PrimaryKeyConstraint{ + ColumnNames: []sqlschema.ColumnName{sqlschema.ColumnName(name)}, + }).Named(constraintName).(*sqlschema.PrimaryKeyConstraint) + } else { + primaryKeyConstraint.ColumnNames = append(primaryKeyConstraint.ColumnNames, sqlschema.ColumnName(name)) + } + } + + if constraintType == "UNIQUE" { + if _, ok := uniqueConstraintsMap[constraintName]; !ok { + uniqueConstraintsMap[constraintName] = (&sqlschema.UniqueConstraint{ + ColumnNames: []sqlschema.ColumnName{sqlschema.ColumnName(name)}, + }).Named(constraintName).(*sqlschema.UniqueConstraint) + } else { + uniqueConstraintsMap[constraintName].ColumnNames = append(uniqueConstraintsMap[constraintName].ColumnNames, sqlschema.ColumnName(name)) + } + } + } + + foreignKeyConstraintsRows, err := provider. + sqlstore. + BunDB(). + QueryContext(ctx, ` +SELECT + tc.constraint_name, + kcu.table_name AS referencing_table, + kcu.column_name AS referencing_column, + ccu.table_name AS referenced_table, + ccu.column_name AS referenced_column +FROM + information_schema.key_column_usage kcu + JOIN information_schema.table_constraints tc ON kcu.constraint_name = tc.constraint_name AND kcu.table_schema = tc.table_schema + JOIN information_schema.constraint_column_usage ccu ON ccu.constraint_name = tc.constraint_name AND ccu.table_schema = tc.table_schema +WHERE + tc.constraint_type = ? + AND kcu.table_name = ?`, "FOREIGN KEY", string(tableName)) + if err != nil { + return nil, nil, err + } + + defer func() { + if err := foreignKeyConstraintsRows.Close(); err != nil { + provider.settings.Logger().ErrorContext(ctx, "error closing rows", "error", err) + } + }() + + foreignKeyConstraints := make([]*sqlschema.ForeignKeyConstraint, 0) + for foreignKeyConstraintsRows.Next() { + var ( + constraintName string + referencingTable string + referencingColumn string + referencedTable string + referencedColumn string + ) + + if err := foreignKeyConstraintsRows.Scan(&constraintName, &referencingTable, &referencingColumn, &referencedTable, &referencedColumn); err != nil { + return nil, nil, err + } + + foreignKeyConstraints = append(foreignKeyConstraints, (&sqlschema.ForeignKeyConstraint{ + ReferencingColumnName: sqlschema.ColumnName(referencingColumn), + ReferencedTableName: sqlschema.TableName(referencedTable), + ReferencedColumnName: sqlschema.ColumnName(referencedColumn), + }).Named(constraintName).(*sqlschema.ForeignKeyConstraint)) + } + + uniqueConstraints := make([]*sqlschema.UniqueConstraint, 0) + for _, uniqueConstraint := range uniqueConstraintsMap { + uniqueConstraints = append(uniqueConstraints, uniqueConstraint) + } + + return &sqlschema.Table{ + Name: tableName, + Columns: columns, + PrimaryKeyConstraint: primaryKeyConstraint, + ForeignKeyConstraints: foreignKeyConstraints, + }, uniqueConstraints, nil +} + +func (provider *provider) GetIndices(ctx context.Context, name sqlschema.TableName) ([]sqlschema.Index, error) { + rows, err := provider. + sqlstore. + BunDB(). + QueryContext(ctx, ` +SELECT + ct.relname AS table_name, + ci.relname AS index_name, + i.indisunique AS unique, + i.indisprimary AS primary, + a.attname AS column_name +FROM + pg_index i + LEFT JOIN pg_class ct ON ct.oid = i.indrelid + LEFT JOIN pg_class ci ON ci.oid = i.indexrelid + LEFT JOIN pg_attribute a ON a.attrelid = ct.oid + LEFT JOIN pg_constraint con ON con.conindid = i.indexrelid +WHERE + a.attnum = ANY(i.indkey) + AND con.oid IS NULL + AND ct.relkind = 'r' + AND ct.relname = ?`, string(name)) + if err != nil { + return nil, err + } + + defer func() { + if err := rows.Close(); err != nil { + provider.settings.Logger().ErrorContext(ctx, "error closing rows", "error", err) + } + }() + + uniqueIndicesMap := make(map[string]*sqlschema.UniqueIndex) + for rows.Next() { + var ( + tableName string + indexName string + unique bool + primary bool + columnName string + ) + + if err := rows.Scan(&tableName, &indexName, &unique, &primary, &columnName); err != nil { + return nil, err + } + + if unique { + if _, ok := uniqueIndicesMap[indexName]; !ok { + uniqueIndicesMap[indexName] = &sqlschema.UniqueIndex{ + TableName: name, + ColumnNames: []sqlschema.ColumnName{sqlschema.ColumnName(columnName)}, + } + } else { + uniqueIndicesMap[indexName].ColumnNames = append(uniqueIndicesMap[indexName].ColumnNames, sqlschema.ColumnName(columnName)) + } + } + } + + indices := make([]sqlschema.Index, 0) + for _, index := range uniqueIndicesMap { + indices = append(indices, index) + } + + return indices, nil +} + +func (provider *provider) ToggleFKEnforcement(_ context.Context, _ bun.IDB, _ bool) error { + return nil +} diff --git a/ee/sqlstore/postgressqlstore/dialect.go b/ee/sqlstore/postgressqlstore/dialect.go index 7edd48dd677..4f6987bda77 100644 --- a/ee/sqlstore/postgressqlstore/dialect.go +++ b/ee/sqlstore/postgressqlstore/dialect.go @@ -17,19 +17,21 @@ var ( ) var ( - Org = "org" - User = "user" - UserNoCascade = "user_no_cascade" - FactorPassword = "factor_password" - CloudIntegration = "cloud_integration" + Org = "org" + User = "user" + UserNoCascade = "user_no_cascade" + FactorPassword = "factor_password" + CloudIntegration = "cloud_integration" + AgentConfigVersion = "agent_config_version" ) var ( - OrgReference = `("org_id") REFERENCES "organizations" ("id")` - UserReference = `("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE` - UserReferenceNoCascade = `("user_id") REFERENCES "users" ("id")` - FactorPasswordReference = `("password_id") REFERENCES "factor_password" ("id")` - CloudIntegrationReference = `("cloud_integration_id") REFERENCES "cloud_integration" ("id") ON DELETE CASCADE` + OrgReference = `("org_id") REFERENCES "organizations" ("id")` + UserReference = `("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE` + UserReferenceNoCascade = `("user_id") REFERENCES "users" ("id")` + FactorPasswordReference = `("password_id") REFERENCES "factor_password" ("id")` + CloudIntegrationReference = `("cloud_integration_id") REFERENCES "cloud_integration" ("id") ON DELETE CASCADE` + AgentConfigVersionReference = `("version_id") REFERENCES "agent_config_version" ("id")` ) type dialect struct{} @@ -274,6 +276,8 @@ func (dialect *dialect) RenameTableAndModifyModel(ctx context.Context, bun bun.I fkReferences = append(fkReferences, FactorPasswordReference) } else if reference == CloudIntegration && !slices.Contains(fkReferences, CloudIntegrationReference) { fkReferences = append(fkReferences, CloudIntegrationReference) + } else if reference == AgentConfigVersion && !slices.Contains(fkReferences, AgentConfigVersionReference) { + fkReferences = append(fkReferences, AgentConfigVersionReference) } } diff --git a/ee/sqlstore/postgressqlstore/provider.go b/ee/sqlstore/postgressqlstore/provider.go index dd2797a1faa..7e3bd22f34e 100644 --- a/ee/sqlstore/postgressqlstore/provider.go +++ b/ee/sqlstore/postgressqlstore/provider.go @@ -10,7 +10,6 @@ import ( "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/stdlib" - "github.com/jmoiron/sqlx" "github.com/uptrace/bun" "github.com/uptrace/bun/dialect/pgdialect" ) @@ -19,7 +18,6 @@ type provider struct { settings factory.ScopedProviderSettings sqldb *sql.DB bundb *sqlstore.BunDB - sqlxdb *sqlx.DB dialect *dialect } @@ -61,7 +59,6 @@ func New(ctx context.Context, providerSettings factory.ProviderSettings, config settings: settings, sqldb: sqldb, bundb: sqlstore.NewBunDB(settings, sqldb, pgdialect.New(), hooks), - sqlxdb: sqlx.NewDb(sqldb, "postgres"), dialect: new(dialect), }, nil } @@ -74,10 +71,6 @@ func (provider *provider) SQLDB() *sql.DB { return provider.sqldb } -func (provider *provider) SQLxDB() *sqlx.DB { - return provider.sqlxdb -} - func (provider *provider) Dialect() sqlstore.SQLDialect { return provider.dialect } diff --git a/frontend/.cursorrules b/frontend/.cursorrules new file mode 100644 index 00000000000..9cfa908ba60 --- /dev/null +++ b/frontend/.cursorrules @@ -0,0 +1,484 @@ +# Persona +You are an expert developer with deep knowledge of Jest, React Testing Library, MSW, and TypeScript, tasked with creating unit tests for this repository. + +# Auto-detect TypeScript Usage +Check for TypeScript in the project through tsconfig.json or package.json dependencies. +Adjust syntax based on this detection. + +# TypeScript Type Safety for Jest Tests +**CRITICAL**: All Jest tests MUST be fully type-safe with proper TypeScript types. + +**Type Safety Requirements:** +- Use proper TypeScript interfaces for all mock data +- Type all Jest mock functions with `jest.MockedFunction` +- Use generic types for React components and hooks +- Define proper return types for mock functions +- Use `as const` for literal types when needed +- Avoid `any` type – use proper typing instead + +# Unit Testing Focus +Focus on critical functionality (business logic, utility functions, component behavior) +Mock dependencies (API calls, external modules) before imports +Test multiple data scenarios (valid inputs, invalid inputs, edge cases) +Write maintainable tests with descriptive names grouped in describe blocks + +# Global vs Local Mocks +**Use Global Mocks for:** +- High-frequency dependencies (20+ test files) +- Core infrastructure (react-router-dom, react-query, antd) +- Standard implementations across the app +- Browser APIs (ResizeObserver, matchMedia, localStorage) +- Utility libraries (date-fns, lodash) + +**Use Local Mocks for:** +- Business logic dependencies (5-15 test files) +- Test-specific behavior (different data per test) +- API endpoints with specific responses +- Domain-specific components +- Error scenarios and edge cases + +**Global Mock Files Available (from jest.config.ts):** +- `uplot` → `__mocks__/uplotMock.ts` + +# Repo-specific Testing Conventions + +## Imports +Always import from our harness: +```ts +import { render, screen, userEvent, waitFor } from 'tests/test-utils'; +``` +For API mocks: +```ts +import { server, rest } from 'mocks-server/server'; +``` +Do not import directly from `@testing-library/react`. + +## Router +Use the router built into render: +```ts +render(, undefined, { initialRoute: '/traces-explorer' }); +``` +Only mock `useLocation` / `useParams` if the test depends on them. + +## Hook Mocks +Pattern: +```ts +import useFoo from 'hooks/useFoo'; +jest.mock('hooks/useFoo'); +const mockUseFoo = jest.mocked(useFoo); +mockUseFoo.mockReturnValue(/* minimal shape */ as any); +``` +Prefer helpers (`rqSuccess`, `rqLoading`, `rqError`) for React Query results. + +## MSW +Global MSW server runs automatically. +Override per-test: +```ts +server.use( + rest.get('*/api/v1/foo', (_req, res, ctx) => res(ctx.status(200), ctx.json({ ok: true }))) +); +``` +Keep large responses in `mocks-server/__mockdata_`. + +## Interactions +- Prefer `userEvent` for real user interactions (click, type, select, tab). +- Use `fireEvent` only for low-level/programmatic events not covered by `userEvent` (e.g., scroll, resize, setting `element.scrollTop` for virtualization). Wrap in `act(...)` if needed. +- Always await interactions: +```ts +const user = userEvent.setup({ pointerEventsCheck: 0 }); +await user.click(screen.getByRole('button', { name: /save/i })); +``` + +```ts +// Example: virtualized list scroll (no userEvent helper) +const scroller = container.querySelector('[data-test-id="virtuoso-scroller"]') as HTMLElement; +scroller.scrollTop = targetScrollTop; +act(() => { fireEvent.scroll(scroller); }); +``` + +## Timers +❌ No global fake timers. +✅ Per-test only, for debounce/throttle: +```ts +jest.useFakeTimers(); +const user = userEvent.setup({ advanceTimers: (ms) => jest.advanceTimersByTime(ms) }); +await user.type(screen.getByRole('textbox'), 'query'); +jest.advanceTimersByTime(400); +jest.useRealTimers(); +``` + +## Queries +Prefer accessible queries (`getByRole`, `findByRole`, `getByLabelText`). +Fallback: visible text. +Last resort: `data-testid`. + +# Example Test (using only configured global mocks) +```ts +import { render, screen, userEvent, waitFor } from 'tests/test-utils'; +import { server, rest } from 'mocks-server/server'; +import MyComponent from '../MyComponent'; + +describe('MyComponent', () => { + it('renders and interacts', async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + + server.use( + rest.get('*/api/v1/example', (_req, res, ctx) => res(ctx.status(200), ctx.json({ value: 42 }))) + ); + + render(, undefined, { initialRoute: '/foo' }); + + expect(await screen.findByText(/value: 42/i)).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: /refresh/i })); + await waitFor(() => expect(screen.getByText(/loading/i)).toBeInTheDocument()); + }); +}); +``` + +# Anti-patterns +❌ Importing RTL directly +❌ Using global fake timers +❌ Wrapping render in `act(...)` +❌ Mocking infra dependencies locally (router, react-query) +✅ Use our harness (`tests/test-utils`) +✅ Use MSW for API overrides +✅ Use userEvent + await +✅ Pin time only in tests that assert relative dates + +# Best Practices +- **Critical Functionality**: Prioritize testing business logic and utilities +- **Dependency Mocking**: Global mocks for infra, local mocks for business logic +- **Data Scenarios**: Always test valid, invalid, and edge cases +- **Descriptive Names**: Make test intent clear +- **Organization**: Group related tests in describe +- **Consistency**: Match repo conventions +- **Edge Cases**: Test null, undefined, unexpected values +- **Limit Scope**: 3–5 focused tests per file +- **Use Helpers**: `rqSuccess`, `makeUser`, etc. +- **No Any**: Enforce type safety + +# Example Test +```ts +import { render, screen, userEvent, waitFor } from 'tests/test-utils'; +import { server, rest } from 'mocks-server/server'; +import MyComponent from '../MyComponent'; + +describe('MyComponent', () => { + it('renders and interacts', async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + + server.use( + rest.get('*/api/v1/example', (_req, res, ctx) => res(ctx.status(200), ctx.json({ value: 42 }))) + ); + + render(, undefined, { initialRoute: '/foo' }); + + expect(await screen.findByText(/value: 42/i)).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: /refresh/i })); + await waitFor(() => expect(screen.getByText(/loading/i)).toBeInTheDocument()); + }); +}); +``` + +# Anti-patterns +❌ Importing RTL directly +❌ Using global fake timers +❌ Wrapping render in `act(...)` +❌ Mocking infra dependencies locally (router, react-query) +✅ Use our harness (`tests/test-utils`) +✅ Use MSW for API overrides +✅ Use userEvent + await +✅ Pin time only in tests that assert relative dates + +# TypeScript Type Safety Examples + +## Proper Mock Typing +```ts +// ✅ GOOD - Properly typed mocks +interface User { + id: number; + name: string; + email: string; +} + +interface ApiResponse { + data: T; + status: number; + message: string; +} + +// Type the mock functions +const mockFetchUser = jest.fn() as jest.MockedFunction<(id: number) => Promise>>; +const mockUpdateUser = jest.fn() as jest.MockedFunction<(user: User) => Promise>>; + +// Mock implementation with proper typing +mockFetchUser.mockResolvedValue({ + data: { id: 1, name: 'John Doe', email: 'john@example.com' }, + status: 200, + message: 'Success' +}); + +// ❌ BAD - Using any type +const mockFetchUser = jest.fn() as any; // Don't do this +``` + +## React Component Testing with Types +```ts +// ✅ GOOD - Properly typed component testing +interface ComponentProps { + title: string; + data: User[]; + onUserSelect: (user: User) => void; + isLoading?: boolean; +} + +const TestComponent: React.FC = ({ title, data, onUserSelect, isLoading = false }) => { + // Component implementation +}; + +describe('TestComponent', () => { + it('should render with proper props', () => { + // Arrange - Type the props properly + const mockProps: ComponentProps = { + title: 'Test Title', + data: [{ id: 1, name: 'John', email: 'john@example.com' }], + onUserSelect: jest.fn() as jest.MockedFunction<(user: User) => void>, + isLoading: false + }; + + // Act + render(); + + // Assert + expect(screen.getByText('Test Title')).toBeInTheDocument(); + }); +}); +``` + +## Hook Testing with Types +```ts +// ✅ GOOD - Properly typed hook testing +interface UseUserDataReturn { + user: User | null; + loading: boolean; + error: string | null; + refetch: () => void; +} + +const useUserData = (id: number): UseUserDataReturn => { + // Hook implementation +}; + +describe('useUserData', () => { + it('should return user data with proper typing', () => { + // Arrange + const mockUser: User = { id: 1, name: 'John', email: 'john@example.com' }; + mockFetchUser.mockResolvedValue({ + data: mockUser, + status: 200, + message: 'Success' + }); + + // Act + const { result } = renderHook(() => useUserData(1)); + + // Assert + expect(result.current.user).toEqual(mockUser); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBeNull(); + }); +}); +``` + +## Global Mock Type Safety +```ts +// ✅ GOOD - Type-safe global mocks +// In __mocks__/routerMock.ts +export const mockUseLocation = (overrides: Partial = {}): Location => ({ + pathname: '/traces', + search: '', + hash: '', + state: null, + key: 'test-key', + ...overrides, +}); + +// In test files +const location = useLocation(); // Properly typed from global mock +expect(location.pathname).toBe('/traces'); +``` + +# TypeScript Configuration for Jest + +## Required Jest Configuration +```json +// jest.config.ts +{ + "preset": "ts-jest/presets/js-with-ts-esm", + "globals": { + "ts-jest": { + "useESM": true, + "isolatedModules": true, + "tsconfig": "/tsconfig.jest.json" + } + }, + "extensionsToTreatAsEsm": [".ts", ".tsx"], + "moduleFileExtensions": ["ts", "tsx", "js", "json"] +} +``` + +## TypeScript Jest Configuration +```json +// tsconfig.jest.json +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["jest", "@testing-library/jest-dom"], + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "moduleResolution": "node" + }, + "include": [ + "src/**/*", + "**/*.test.ts", + "**/*.test.tsx", + "__mocks__/**/*" + ] +} +``` + +## Common Type Safety Patterns + +### Mock Function Typing +```ts +// ✅ GOOD - Proper mock function typing +const mockApiCall = jest.fn() as jest.MockedFunction; +const mockEventHandler = jest.fn() as jest.MockedFunction<(event: Event) => void>; + +// ❌ BAD - Using any +const mockApiCall = jest.fn() as any; +``` + +### Generic Mock Typing +```ts +// ✅ GOOD - Generic mock typing +interface MockApiResponse { + data: T; + status: number; +} + +const mockFetchData = jest.fn() as jest.MockedFunction< + (endpoint: string) => Promise> +>; + +// Usage +mockFetchData('/users').mockResolvedValue({ + data: { id: 1, name: 'John' }, + status: 200 +}); +``` + +### React Testing Library with Types +```ts +// ✅ GOOD - Typed testing utilities +import { render, screen, RenderResult } from '@testing-library/react'; +import { ComponentProps } from 'react'; + +type TestComponentProps = ComponentProps; + +const renderTestComponent = (props: Partial = {}): RenderResult => { + const defaultProps: TestComponentProps = { + title: 'Test', + data: [], + onSelect: jest.fn(), + ...props + }; + + return render(); +}; +``` + +### Error Handling with Types +```ts +// ✅ GOOD - Typed error handling +interface ApiError { + message: string; + code: number; + details?: Record; +} + +const mockApiError: ApiError = { + message: 'API Error', + code: 500, + details: { endpoint: '/users' } +}; + +mockFetchUser.mockRejectedValue(new Error(JSON.stringify(mockApiError))); +``` + +## Type Safety Checklist +- [ ] All mock functions use `jest.MockedFunction` +- [ ] All mock data has proper interfaces +- [ ] No `any` types in test files +- [ ] Generic types are used where appropriate +- [ ] Error types are properly defined +- [ ] Component props are typed +- [ ] Hook return types are defined +- [ ] API response types are defined +- [ ] Global mocks are type-safe +- [ ] Test utilities are properly typed + +# Mock Decision Tree +``` +Is it used in 20+ test files? +├─ YES → Use Global Mock +│ ├─ react-router-dom +│ ├─ react-query +│ ├─ antd components +│ └─ browser APIs +│ +└─ NO → Is it business logic? + ├─ YES → Use Local Mock + │ ├─ API endpoints + │ ├─ Custom hooks + │ └─ Domain components + │ + └─ NO → Is it test-specific? + ├─ YES → Use Local Mock + │ ├─ Error scenarios + │ ├─ Loading states + │ └─ Specific data + │ + └─ NO → Consider Global Mock + └─ If it becomes frequently used +``` + +# Common Anti-Patterns to Avoid + +❌ **Don't mock global dependencies locally:** +```js +// BAD - This is already globally mocked +jest.mock('react-router-dom', () => ({ ... })); +``` + +❌ **Don't create global mocks for test-specific data:** +```js +// BAD - This should be local +jest.mock('../api/tracesService', () => ({ + getTraces: jest.fn(() => specificTestData) +})); +``` + +✅ **Do use global mocks for infrastructure:** +```js +// GOOD - Use global mock +import { useLocation } from 'react-router-dom'; +``` + +✅ **Do create local mocks for business logic:** +```js +// GOOD - Local mock for specific test needs +jest.mock('../api/tracesService', () => ({ + getTraces: jest.fn(() => mockTracesData) +})); +``` \ No newline at end of file diff --git a/frontend/.eslintignore b/frontend/.eslintignore index 402f7ae028e..e9d38dfe02c 100644 --- a/frontend/.eslintignore +++ b/frontend/.eslintignore @@ -1,4 +1,5 @@ node_modules build *.typegen.ts -i18-generate-hash.js \ No newline at end of file +i18-generate-hash.js +src/parser/TraceOperatorParser/** \ No newline at end of file diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js index f0f5a7330a6..25ff23f209e 100644 --- a/frontend/.eslintrc.js +++ b/frontend/.eslintrc.js @@ -1,4 +1,5 @@ module.exports = { + ignorePatterns: ['src/parser/*.ts'], env: { browser: true, es2021: true, diff --git a/frontend/.gitignore b/frontend/.gitignore index a5489639406..cfd8dac366f 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -2,3 +2,30 @@ # Sentry Config File .env.sentry-build-plugin .qodo + +# Playwright +node_modules/ +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/playwright/test-results/ +/playwright/blob-report/ +/playwright/playwright-report/ + +e2e/test-plan/alerts/ +e2e/test-plan/dashboards/ +e2e/test-plan/exceptions/ +e2e/test-plan/external-apis/ +e2e/test-plan/help-support/ +e2e/test-plan/infrastructure/ +e2e/test-plan/logs/ +e2e/test-plan/messaging-queues/ +e2e/test-plan/metrics/ +e2e/test-plan/navigation/ +e2e/test-plan/onboarding/ +e2e/test-plan/saved-views/ +e2e/test-plan/service-map/ +e2e/test-plan/services/ +e2e/test-plan/traces/ +e2e/test-plan/user-preferences/ \ No newline at end of file diff --git a/frontend/.prettierignore b/frontend/.prettierignore index 30573bc2829..0b802be4b4e 100644 --- a/frontend/.prettierignore +++ b/frontend/.prettierignore @@ -8,3 +8,8 @@ public/ # Ignore all JSON files: **/*.json + +# Ignore all files in parser folder: +src/parser/** + +src/TraceOperator/parser/** \ No newline at end of file diff --git a/frontend/__mocks__/uplotMock.ts b/frontend/__mocks__/uplotMock.ts new file mode 100644 index 00000000000..9cf9add9f0c --- /dev/null +++ b/frontend/__mocks__/uplotMock.ts @@ -0,0 +1,51 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ + +// Mock for uplot library used in tests +export interface MockUPlotInstance { + setData: jest.Mock; + setSize: jest.Mock; + destroy: jest.Mock; + redraw: jest.Mock; + setSeries: jest.Mock; +} + +export interface MockUPlotPaths { + spline: jest.Mock; + bars: jest.Mock; +} + +// Create mock instance methods +const createMockUPlotInstance = (): MockUPlotInstance => ({ + setData: jest.fn(), + setSize: jest.fn(), + destroy: jest.fn(), + redraw: jest.fn(), + setSeries: jest.fn(), +}); + +// Create mock paths +const mockPaths: MockUPlotPaths = { + spline: jest.fn(), + bars: jest.fn(), +}; + +// Mock static methods +const mockTzDate = jest.fn( + (date: Date, _timezone: string) => new Date(date.getTime()), +); + +// Mock uPlot constructor - this needs to be a proper constructor function +function MockUPlot( + _options: unknown, + _data: unknown, + _target: HTMLElement, +): MockUPlotInstance { + return createMockUPlotInstance(); +} + +// Add static methods to the constructor +MockUPlot.tzDate = mockTzDate; +MockUPlot.paths = mockPaths; + +// Export the constructor as default +export default MockUPlot; diff --git a/frontend/__mocks__/useSafeNavigate.ts b/frontend/__mocks__/useSafeNavigate.ts new file mode 100644 index 00000000000..a1044da052c --- /dev/null +++ b/frontend/__mocks__/useSafeNavigate.ts @@ -0,0 +1,29 @@ +// Mock for useSafeNavigate hook to avoid React Router version conflicts in tests +interface SafeNavigateOptions { + replace?: boolean; + state?: unknown; +} + +interface SafeNavigateTo { + pathname?: string; + search?: string; + hash?: string; +} + +type SafeNavigateToType = string | SafeNavigateTo; + +interface UseSafeNavigateReturn { + safeNavigate: jest.MockedFunction< + (to: SafeNavigateToType, options?: SafeNavigateOptions) => void + >; +} + +export const useSafeNavigate = (): UseSafeNavigateReturn => ({ + safeNavigate: jest.fn( + (to: SafeNavigateToType, options?: SafeNavigateOptions) => { + console.log(`Mock safeNavigate called with:`, to, options); + }, + ) as jest.MockedFunction< + (to: SafeNavigateToType, options?: SafeNavigateOptions) => void + >, +}); diff --git a/frontend/e2e/test-plan/README.md b/frontend/e2e/test-plan/README.md new file mode 100644 index 00000000000..6853834d189 --- /dev/null +++ b/frontend/e2e/test-plan/README.md @@ -0,0 +1,29 @@ +# SigNoz E2E Test Plan + +This directory contains the structured test plan for the SigNoz application. Each subfolder corresponds to a main module or feature area, and contains scenario files for all user journeys, edge cases, and cross-module flows. These documents serve as the basis for generating Playwright MCP-driven E2E tests. + +## Structure + +- Each main module (e.g., logs, traces, dashboards, alerts, settings, etc.) has its own folder or markdown file. +- Each file contains detailed scenario templates, including preconditions, step-by-step actions, and expected outcomes. +- Use these documents to write, review, and update test cases as the application evolves. + +## Folders & Files + +- `logs/` — Logs module scenarios +- `traces/` — Traces module scenarios +- `metrics/` — Metrics module scenarios +- `dashboards/` — Dashboards module scenarios +- `alerts/` — Alerts module scenarios +- `services/` — Services module scenarios +- `settings/` — Settings and all sub-settings scenarios +- `onboarding/` — Onboarding and signup flows +- `navigation/` — Navigation, sidebar, and cross-module flows +- `exceptions/` — Exception and error handling scenarios +- `external-apis/` — External API monitoring scenarios +- `messaging-queues/` — Messaging queue scenarios +- `infrastructure/` — Infrastructure monitoring scenarios +- `help-support/` — Help & support scenarios +- `user-preferences/` — User preferences and personalization scenarios +- `service-map/` — Service map scenarios +- `saved-views/` — Saved views scenarios diff --git a/frontend/e2e/test-plan/settings/README.md b/frontend/e2e/test-plan/settings/README.md new file mode 100644 index 00000000000..2e424598e9d --- /dev/null +++ b/frontend/e2e/test-plan/settings/README.md @@ -0,0 +1,16 @@ +# Settings Module Test Plan + +This folder contains E2E test scenarios for the Settings module and all sub-settings. + +## Scenario Categories + +- General settings (org/workspace, branding, version info) +- Billing settings +- Members & SSO +- Custom domain +- Integrations +- Notification channels +- API keys +- Ingestion +- Account settings (profile, password, preferences) +- Keyboard shortcuts diff --git a/frontend/e2e/test-plan/settings/account-settings.md b/frontend/e2e/test-plan/settings/account-settings.md new file mode 100644 index 00000000000..63677d662f8 --- /dev/null +++ b/frontend/e2e/test-plan/settings/account-settings.md @@ -0,0 +1,43 @@ +# Account Settings E2E Scenarios (Updated) + +## 1. Update Name + +- **Precondition:** User is logged in +- **Steps:** + 1. Click 'Update name' button + 2. Edit name field in the modal/dialog + 3. Save changes +- **Expected:** Name is updated in the UI + +## 2. Update Email + +- **Note:** The email field is not editable in the current UI. + +## 3. Reset Password + +- **Precondition:** User is logged in +- **Steps:** + 1. Click 'Reset password' button + 2. Complete reset flow (modal/dialog or external flow) +- **Expected:** Password is reset + +## 4. Toggle 'Adapt to my timezone' + +- **Precondition:** User is logged in +- **Steps:** + 1. Toggle 'Adapt to my timezone' switch +- **Expected:** Timezone adapts accordingly (UI feedback/confirmation should be checked) + +## 5. Toggle Theme (Dark/Light) + +- **Precondition:** User is logged in +- **Steps:** + 1. Toggle theme radio buttons ('Dark', 'Light Beta') +- **Expected:** Theme changes + +## 6. Toggle Sidebar Always Open + +- **Precondition:** User is logged in +- **Steps:** + 1. Toggle 'Keep the primary sidebar always open' switch +- **Expected:** Sidebar remains open/closed as per toggle diff --git a/frontend/e2e/test-plan/settings/api-keys.md b/frontend/e2e/test-plan/settings/api-keys.md new file mode 100644 index 00000000000..6e2b81336f0 --- /dev/null +++ b/frontend/e2e/test-plan/settings/api-keys.md @@ -0,0 +1,26 @@ +# API Keys E2E Scenarios (Updated) + +## 1. Create a New API Key + +- **Precondition:** User is admin +- **Steps:** + 1. Click 'New Key' button + 2. Enter details in the modal/dialog + 3. Click 'Save' +- **Expected:** API key is created and listed in the table + +## 2. Revoke an API Key + +- **Precondition:** API key exists +- **Steps:** + 1. In the table, locate the API key row + 2. Click the revoke/delete button (icon button in the Action column) + 3. Confirm if prompted +- **Expected:** API key is revoked/removed from the table + +## 3. View API Key Usage + +- **Precondition:** API key exists +- **Steps:** + 1. View the 'Last used' and 'Expired' columns in the table +- **Expected:** Usage data is displayed for each API key diff --git a/frontend/e2e/test-plan/settings/billing.md b/frontend/e2e/test-plan/settings/billing.md new file mode 100644 index 00000000000..083fe6a6b2c --- /dev/null +++ b/frontend/e2e/test-plan/settings/billing.md @@ -0,0 +1,17 @@ +# Billing Settings E2E Scenarios (Updated) + +## 1. View Billing Information + +- **Precondition:** User is admin +- **Steps:** + 1. Navigate to Billing Settings + 2. Wait for the billing chart/data to finish loading +- **Expected:** + - Billing heading and subheading are displayed + - Usage/cost table is visible with columns: Unit, Data Ingested, Price per Unit, Cost (Billing period to date) + - "Download CSV" and "Manage Billing" buttons are present and enabled after loading + - Test clicking "Download CSV" and "Manage Billing" for expected behavior (e.g., file download, navigation, or modal) + +> Note: If these features are expected to trigger specific flows, document the observed behavior for each button. + + diff --git a/frontend/e2e/test-plan/settings/custom-domain.md b/frontend/e2e/test-plan/settings/custom-domain.md new file mode 100644 index 00000000000..ba58876ff83 --- /dev/null +++ b/frontend/e2e/test-plan/settings/custom-domain.md @@ -0,0 +1,18 @@ +# Custom Domain E2E Scenarios (Updated) + +## 1. Add or Update Custom Domain + +- **Precondition:** User is admin +- **Steps:** + 1. Click 'Customize team’s URL' button + 2. In the 'Customize your team’s URL' dialog, enter the preferred subdomain + 3. Click 'Apply Changes' +- **Expected:** Domain is set/updated for the team (UI feedback/confirmation should be checked) + +## 2. Verify Domain Ownership + +- **Note:** No explicit 'Verify' button or flow is present in the current UI. If verification is required, it may be handled automatically or via support. + +## 3. Remove a Custom Domain + +- **Note:** No explicit 'Remove' button or flow is present in the current UI. The only available action is to update the subdomain. diff --git a/frontend/e2e/test-plan/settings/general.md b/frontend/e2e/test-plan/settings/general.md new file mode 100644 index 00000000000..58786dd8313 --- /dev/null +++ b/frontend/e2e/test-plan/settings/general.md @@ -0,0 +1,31 @@ +# General Settings E2E Scenarios + +## 1. View General Settings + +- **Precondition:** User is logged in +- **Steps:** + 1. Navigate to General Settings +- **Expected:** General settings are displayed + +## 2. Update Organization/Workspace Name + +- **Precondition:** User is admin +- **Steps:** + 1. Edit organization/workspace name + 2. Save changes +- **Expected:** Name is updated and visible + +## 3. Update Logo or Branding + +- **Precondition:** User is admin +- **Steps:** + 1. Upload new logo/branding + 2. Save changes +- **Expected:** Branding is updated + +## 4. View Version/Build Info + +- **Precondition:** User is logged in +- **Steps:** + 1. View version/build info section +- **Expected:** Version/build info is displayed diff --git a/frontend/e2e/test-plan/settings/ingestion.md b/frontend/e2e/test-plan/settings/ingestion.md new file mode 100644 index 00000000000..96a600bb716 --- /dev/null +++ b/frontend/e2e/test-plan/settings/ingestion.md @@ -0,0 +1,20 @@ +# Ingestion E2E Scenarios (Updated) + +## 1. View Ingestion Sources + +- **Precondition:** User is admin +- **Steps:** + 1. Navigate to the Integrations page +- **Expected:** List of available data sources/integrations is displayed + +## 2. Configure Ingestion Sources + +- **Precondition:** User is admin +- **Steps:** + 1. Click 'Configure' for a data source/integration + 2. Complete the configuration flow (modal or page, as available) +- **Expected:** Source is configured (UI feedback/confirmation should be checked) + +## 3. Disable/Enable Ingestion + +- **Note:** No visible enable/disable toggle for ingestion sources in the current UI. Ingestion is managed via the Integrations configuration flows. diff --git a/frontend/e2e/test-plan/settings/integrations.md b/frontend/e2e/test-plan/settings/integrations.md new file mode 100644 index 00000000000..1d6bfdda702 --- /dev/null +++ b/frontend/e2e/test-plan/settings/integrations.md @@ -0,0 +1,51 @@ +# Integrations E2E Scenarios (Updated) + +## 1. View List of Available Integrations + +- **Precondition:** User is logged in +- **Steps:** + 1. Navigate to Integrations +- **Expected:** List of integrations is displayed, each with a name, description, and 'Configure' button + +## 2. Search Integrations by Name/Type + +- **Precondition:** Integrations exist +- **Steps:** + 1. Enter search/filter criteria in the 'Search for an integration...' box +- **Expected:** Only matching integrations are shown + +## 3. Connect a New Integration + +- **Precondition:** User is admin +- **Steps:** + 1. Click 'Configure' for an integration + 2. Complete the configuration flow (modal or page, as available) +- **Expected:** Integration is connected/configured (UI feedback/confirmation should be checked) + +## 4. Disconnect an Integration + +- **Note:** No visible 'Disconnect' button in the main list. This may be available in the configuration flow for a connected integration. + +## 5. Configure Integration Settings + +- **Note:** Configuration is handled in the flow after clicking 'Configure' for an integration. + +## 6. Test Integration Connection + +- **Note:** No visible 'Test Connection' button in the main list. This may be available in the configuration flow. + +## 7. View Integration Status/Logs + +- **Note:** No visible status/logs section in the main list. This may be available in the configuration flow. + +## 8. Filter Integrations by Category + +- **Note:** No explicit category filter in the current UI, only a search box. + +## 9. View Integration Documentation/Help + +- **Note:** No visible 'Help/Docs' button in the main list. This may be available in the configuration flow. + +## 10. Update Integration Configuration + +- **Note:** Configuration is handled in the flow after clicking 'Configure' for an integration. diff --git a/frontend/e2e/test-plan/settings/keyboard-shortcuts.md b/frontend/e2e/test-plan/settings/keyboard-shortcuts.md new file mode 100644 index 00000000000..753624e4670 --- /dev/null +++ b/frontend/e2e/test-plan/settings/keyboard-shortcuts.md @@ -0,0 +1,19 @@ +# Keyboard Shortcuts E2E Scenarios (Updated) + +## 1. View Keyboard Shortcuts + +- **Precondition:** User is logged in +- **Steps:** + 1. Navigate to Keyboard Shortcuts +- **Expected:** Shortcuts are displayed in categorized tables (Global, Logs Explorer, Query Builder, Dashboard) + +## 2. Customize Keyboard Shortcuts (if supported) + +- **Note:** Customization is not available in the current UI. Shortcuts are view-only. + +## 3. Use Keyboard Shortcuts for Navigation/Actions + +- **Precondition:** User is logged in +- **Steps:** + 1. Use shortcut for navigation/action (e.g., shift+s for Services, cmd+enter for running query) +- **Expected:** Navigation/action is performed as per shortcut diff --git a/frontend/e2e/test-plan/settings/members-sso.md b/frontend/e2e/test-plan/settings/members-sso.md new file mode 100644 index 00000000000..8fd84f391c7 --- /dev/null +++ b/frontend/e2e/test-plan/settings/members-sso.md @@ -0,0 +1,49 @@ +# Members & SSO E2E Scenarios (Updated) + +## 1. Invite a New Member + +- **Precondition:** User is admin +- **Steps:** + 1. Click 'Invite Members' button + 2. In the 'Invite team members' dialog, enter email address, name (optional), and select role + 3. (Optional) Click 'Add another team member' to invite more + 4. Click 'Invite team members' to send invite(s) +- **Expected:** Pending invite appears in the 'Pending Invites' table + +## 2. Remove a Member + +- **Precondition:** User is admin, member exists +- **Steps:** + 1. In the 'Members' table, locate the member row + 2. Click 'Delete' in the Action column + 3. Confirm removal if prompted +- **Expected:** Member is removed from the table + +## 3. Update Member Roles + +- **Precondition:** User is admin, member exists +- **Steps:** + 1. In the 'Members' table, locate the member row + 2. Click 'Edit' in the Action column + 3. Change role in the edit dialog/modal + 4. Save changes +- **Expected:** Member role is updated in the table + +## 4. Configure SSO + +- **Precondition:** User is admin +- **Steps:** + 1. In the 'Authenticated Domains' section, locate the domain row + 2. Click 'Configure SSO' or 'Edit Google Auth' as available + 3. Complete SSO provider configuration in the modal/dialog + 4. Save settings +- **Expected:** SSO is configured for the domain + +## 5. Login via SSO + +- **Precondition:** SSO is configured +- **Steps:** + 1. Log out from the app + 2. On the login page, click 'Login with SSO' + 3. Complete SSO login flow +- **Expected:** User is logged in via SSO diff --git a/frontend/e2e/test-plan/settings/notification-channels.md b/frontend/e2e/test-plan/settings/notification-channels.md new file mode 100644 index 00000000000..36e4098b8d5 --- /dev/null +++ b/frontend/e2e/test-plan/settings/notification-channels.md @@ -0,0 +1,39 @@ +# Notification Channels E2E Scenarios (Updated) + +## 1. Add a New Notification Channel + +- **Precondition:** User is admin +- **Steps:** + 1. Click 'New Alert Channel' button + 2. In the 'New Notification Channel' form, fill in required fields (Name, Type, Webhook URL, etc.) + 3. (Optional) Toggle 'Send resolved alerts' + 4. (Optional) Click 'Test' to send a test notification + 5. Click 'Save' to add the channel +- **Expected:** Channel is added and listed in the table + +## 2. Test Notification Channel + +- **Precondition:** Channel is being created or edited +- **Steps:** + 1. In the 'New Notification Channel' or 'Edit Notification Channel' form, click 'Test' +- **Expected:** Test notification is sent (UI feedback/confirmation should be checked) + +## 3. Remove a Notification Channel + +- **Precondition:** Channel is added +- **Steps:** + 1. In the table, locate the channel row + 2. Click 'Delete' in the Action column + 3. Confirm removal if prompted +- **Expected:** Channel is removed from the table + +## 4. Update Notification Channel Settings + +- **Precondition:** Channel is added +- **Steps:** + 1. In the table, locate the channel row + 2. Click 'Edit' in the Action column + 3. In the 'Edit Notification Channel' form, update fields as needed + 4. (Optional) Click 'Test' to send a test notification + 5. Click 'Save' to update the channel +- **Expected:** Settings are updated diff --git a/frontend/e2e/test-plan/validation-report.md b/frontend/e2e/test-plan/validation-report.md new file mode 100644 index 00000000000..29dd62f4bb6 --- /dev/null +++ b/frontend/e2e/test-plan/validation-report.md @@ -0,0 +1,199 @@ +# SigNoz Test Plan Validation Report + +This report documents the validation of the E2E test plan against the current live application using Playwright MCP. Each module is reviewed for coverage, gaps, and required updates. + +--- + +## Home Module + +- **Coverage:** + - Widgets for logs, traces, metrics, dashboards, alerts, services, saved views, onboarding checklist + - Quick access buttons: Explore Logs, Create dashboard, Create an alert +- **Gaps/Updates:** + - Add scenarios for checklist interactions (e.g., “I’ll do this later”, progress tracking) + - Add scenarios for Saved Views and cross-module links + - Add scenario for onboarding checklist completion + +--- + +## Logs Module + +- **Coverage:** + - Explorer, Pipelines, Views tabs + - Filtering by service, environment, severity, host, k8s, etc. + - Search, save view, create alert, add to dashboard, export, view mode switching +- **Gaps/Updates:** + - Add scenario for quick filter customization + - Add scenario for “Old Explorer” button + - Add scenario for frequency chart toggle + - Add scenario for “Stage & Run Query” workflow + +--- + +## Traces Module + +- **Coverage:** + - Tabs: Explorer, Funnels, Views + - Filtering by name, error status, duration, environment, function, service, RPC, status code, HTTP, trace ID, etc. + - Search, save view, create alert, add to dashboard, export, view mode switching (List, Traces, Time Series, Table) + - Pagination, quick filter customization, group by, aggregation +- **Gaps/Updates:** + - Add scenario for quick filter customization + - Add scenario for “Stage & Run Query” workflow + - Add scenario for all view modes (List, Traces, Time Series, Table) + - Add scenario for group by/aggregation + - Add scenario for trace detail navigation (clicking on trace row) + - Add scenario for Funnels tab (create/edit/delete funnel) + - Add scenario for Views tab (manage saved views) + +--- + +## Metrics Module + +- **Coverage:** + - Tabs: Summary, Explorer, Views + - Filtering by metric, type, unit, etc. + - Search, save view, add to dashboard, export, view mode switching (chart, table, proportion view) + - Pagination, group by, aggregation, custom queries +- **Gaps/Updates:** + - Add scenario for Proportion View in Summary + - Add scenario for all view modes (chart, table, proportion) + - Add scenario for group by/aggregation + - Add scenario for custom queries in Explorer + - Add scenario for Views tab (manage saved views) + +--- + +## Dashboards Module + +- **Coverage:** + - List, search, and filter dashboards + - Create new dashboard (button and template link) + - Edit, delete, and view dashboard details + - Add/edit/delete widgets (implied by dashboard detail) + - Pagination through dashboards +- **Gaps/Updates:** + - Add scenario for browsing dashboard templates (external link) + - Add scenario for requesting new template + - Add scenario for dashboard owner and creation info + - Add scenario for dashboard tags and filtering by tags + - Add scenario for dashboard sharing (if available) + - Add scenario for dashboard image/preview + +--- + +## Messaging Queues Module + +- **Coverage:** + - Overview tab: queue metrics, filters (Service Name, Span Name, Msg System, Destination, Kind) + - Search across all columns + - Pagination of queue data + - Sync and Share buttons + - Tabs for Kafka and Celery +- **Gaps/Updates:** + - Add scenario for Kafka tab (detailed metrics, actions) + - Add scenario for Celery tab (detailed metrics, actions) + - Add scenario for filter combinations and edge cases + - Add scenario for sharing queue data + - Add scenario for time range selection + +--- + +## External APIs Module + +- **Coverage:** + - Accessed via side navigation under MORE + - Explorer tab: domain, endpoints, last used, rate, error %, avg. latency + - Filters: Deployment Environment, Service Name, Rpc Method, Show IP addresses + - Table pagination + - Share and Stage & Run Query buttons +- **Gaps/Updates:** + - Add scenario for customizing quick filters + - Add scenario for running and staging queries + - Add scenario for sharing API data + - Add scenario for edge cases in filters and table data + +--- + +## Alerts Module + +- **Coverage:** + - Alert Rules tab: list, search, create (New Alert), edit, delete, enable/disable, severity, labels, actions + - Triggered Alerts tab (visible in tablist) + - Configuration tab (visible in tablist) + - Table pagination +- **Gaps/Updates:** + - Add scenario for triggered alerts (view, acknowledge, resolve) + - Add scenario for alert configuration (settings, integrations) + - Add scenario for edge cases in alert creation and management + - Add scenario for searching and filtering alerts + +--- + +## Integrations Module + +- **Coverage:** + - Integrations tab: list, search, configure (e.g., AWS), request new integration + - One-click setup for AWS monitoring + - Request more integrations (form) +- **Gaps/Updates:** + - Add scenario for configuring integrations (step-by-step) + - Add scenario for searching and filtering integrations + - Add scenario for requesting new integrations + - Add scenario for edge cases (e.g., failed configuration) + +--- + +## Exceptions Module + +- **Coverage:** + - All Exceptions: list, search, filter (Deployment Environment, Service Name, Host Name, K8s Cluster/Deployment/Namespace, Net Peer Name) + - Table: Exception Type, Error Message, Count, Last Seen, First Seen, Application + - Pagination + - Exception detail links + - Share and Stage & Run Query buttons +- **Gaps/Updates:** + - Add scenario for exception detail view + - Add scenario for advanced filtering and edge cases + - Add scenario for sharing and running queries + - Add scenario for error grouping and navigation + +--- + +## Service Map Module + +- **Coverage:** + - Service Map visualization (main graph) + - Filters: environment, resource attributes + - Time range selection + - Sync and Share buttons +- **Gaps/Updates:** + - Add scenario for interacting with the map (zoom, pan, select service) + - Add scenario for filtering and edge cases + - Add scenario for sharing the map + - Add scenario for time range and environment combinations + +--- + +## Billing Module + +- **Coverage:** + - Billing overview: cost monitoring, invoices, CSV download (disabled), manage billing (disabled) + - Teams Cloud section + - Billing table: Unit, Data Ingested, Price per Unit, Cost (Billing period to date) +- **Gaps/Updates:** + - Add scenario for invoice download and management (when enabled) + - Add scenario for cost monitoring and edge cases + - Add scenario for billing table data validation + - Add scenario for permissions and access control + +--- + +## Usage Explorer Module + +- **Status:** + - Not accessible in the current environment. Removing from test plan flows. + +--- + +## [Next modules will be filled as validation proceeds] diff --git a/frontend/e2e/tests/settings/account-settings/account-settings-settings.spec.ts b/frontend/e2e/tests/settings/account-settings/account-settings-settings.spec.ts new file mode 100644 index 00000000000..0cf15e66cec --- /dev/null +++ b/frontend/e2e/tests/settings/account-settings/account-settings-settings.spec.ts @@ -0,0 +1,42 @@ +import { expect, test } from '@playwright/test'; + +import { ensureLoggedIn } from '../../../utils/login.util'; + +test('Account Settings - View and Assert Static Controls', async ({ page }) => { + await ensureLoggedIn(page); + + // 1. Open the sidebar settings menu using data-testid + await page.getByTestId('settings-nav-item').click(); + + // 2. Click Account Settings in the dropdown (by role/name or data-testid if available) + await page.getByRole('menuitem', { name: 'Account Settings' }).click(); + + // Assert the main tabpanel/heading (confirmed by DOM) + await expect(page.getByTestId('settings-page-title')).toBeVisible(); + + // Assert General section and controls (confirmed by DOM) + await expect( + page.getByLabel('My Settings').getByText('General'), + ).toBeVisible(); + await expect(page.getByText('Manage your account settings.')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Update name' })).toBeVisible(); + await expect( + page.getByRole('button', { name: 'Reset password' }), + ).toBeVisible(); + + // Assert User Preferences section and controls (confirmed by DOM) + await expect(page.getByText('User Preferences')).toBeVisible(); + await expect( + page.getByText('Tailor the SigNoz console to work according to your needs.'), + ).toBeVisible(); + await expect(page.getByText('Select your theme')).toBeVisible(); + + const themeSelector = page.getByTestId('theme-selector'); + + await expect(themeSelector.getByText('Dark')).toBeVisible(); + await expect(themeSelector.getByText('Light')).toBeVisible(); + await expect(themeSelector.getByText('System')).toBeVisible(); + + await expect(page.getByTestId('timezone-adaptation-switch')).toBeVisible(); + await expect(page.getByTestId('side-nav-pinned-switch')).toBeVisible(); +}); diff --git a/frontend/e2e/tests/settings/api-keys/api-keys-settings.spec.ts b/frontend/e2e/tests/settings/api-keys/api-keys-settings.spec.ts new file mode 100644 index 00000000000..e91742e47ae --- /dev/null +++ b/frontend/e2e/tests/settings/api-keys/api-keys-settings.spec.ts @@ -0,0 +1,42 @@ +import { expect, test } from '@playwright/test'; + +import { ensureLoggedIn } from '../../../utils/login.util'; + +test('API Keys Settings - View and Interact', async ({ page }) => { + await ensureLoggedIn(page); + + // 1. Open the sidebar settings menu using data-testid + await page.getByTestId('settings-nav-item').click(); + + // 2. Click Account Settings in the dropdown (by role/name or data-testid if available) + await page.getByRole('menuitem', { name: 'Account Settings' }).click(); + + // Assert the main tabpanel/heading (confirmed by DOM) + await expect(page.getByTestId('settings-page-title')).toBeVisible(); + + // Focus on the settings page sidenav + await page.getByTestId('settings-page-sidenav').focus(); + + // Click API Keys tab in the settings sidebar (by data-testid) + await page.getByTestId('api-keys').click(); + + // Assert heading and subheading + await expect(page.getByRole('heading', { name: 'API Keys' })).toBeVisible(); + await expect( + page.getByText('Create and manage API keys for the SigNoz API'), + ).toBeVisible(); + + // Assert presence of New Key button + const newKeyBtn = page.getByRole('button', { name: 'New Key' }); + await expect(newKeyBtn).toBeVisible(); + + // Assert table columns + await expect(page.getByText('Last used').first()).toBeVisible(); + await expect(page.getByText('Expired').first()).toBeVisible(); + + // Assert at least one API key row with action buttons + // Select the first action cell's first button (icon button) + const firstActionCell = page.locator('table tr').nth(1).locator('td').last(); + const deleteBtn = firstActionCell.locator('button').first(); + await expect(deleteBtn).toBeVisible(); +}); diff --git a/frontend/e2e/tests/settings/billing/billing-settings.spec.ts b/frontend/e2e/tests/settings/billing/billing-settings.spec.ts new file mode 100644 index 00000000000..b8805426d2d --- /dev/null +++ b/frontend/e2e/tests/settings/billing/billing-settings.spec.ts @@ -0,0 +1,71 @@ +import { expect, test } from '@playwright/test'; + +import { ensureLoggedIn } from '../../../utils/login.util'; + +// E2E: Billing Settings - View Billing Information and Button Actions + +test('View Billing Information and Button Actions', async ({ + page, + context, +}) => { + // Ensure user is logged in + await ensureLoggedIn(page); + + // 1. Open the sidebar settings menu using data-testid + await page.getByTestId('settings-nav-item').click(); + + // 2. Click Account Settings in the dropdown (by role/name or data-testid if available) + await page.getByRole('menuitem', { name: 'Account Settings' }).click(); + + // Assert the main tabpanel/heading (confirmed by DOM) + await expect(page.getByTestId('settings-page-title')).toBeVisible(); + + // Focus on the settings page sidenav + await page.getByTestId('settings-page-sidenav').focus(); + + // Click Billing tab in the settings sidebar (by data-testid) + await page.getByTestId('billing').click(); + + // Wait for billing chart/data to finish loading + await page.getByText('loading').first().waitFor({ state: 'hidden' }); + + // Assert visibility of subheading (unique) + await expect( + page.getByText( + 'Manage your billing information, invoices, and monitor costs.', + ), + ).toBeVisible(); + // Assert visibility of Teams Cloud heading + await expect(page.getByRole('heading', { name: 'Teams Cloud' })).toBeVisible(); + + // Assert presence of summary and detailed tables + await expect(page.getByText('TOTAL SPENT')).toBeVisible(); + await expect(page.getByText('Data Ingested')).toBeVisible(); + await expect(page.getByText('Price per Unit')).toBeVisible(); + await expect(page.getByText('Cost (Billing period to date)')).toBeVisible(); + + // Assert presence of alert and note + await expect( + page.getByText('Your current billing period is from', { exact: false }), + ).toBeVisible(); + await expect( + page.getByText('Billing metrics are updated once every 24 hours.'), + ).toBeVisible(); + + // Test Download CSV button + const [download] = await Promise.all([ + page.waitForEvent('download'), + page.getByRole('button', { name: 'cloud-download Download CSV' }).click(), + ]); + // Optionally, check download file name + expect(download.suggestedFilename()).toContain('billing_usage'); + + // Test Manage Billing button (opens Stripe in new tab) + const [newPage] = await Promise.all([ + context.waitForEvent('page'), + page.getByTestId('header-billing-button').click(), + ]); + await newPage.waitForLoadState(); + expect(newPage.url()).toContain('stripe.com'); + await newPage.close(); +}); diff --git a/frontend/e2e/tests/settings/custom-domain/custom-domain-settings.spec.ts b/frontend/e2e/tests/settings/custom-domain/custom-domain-settings.spec.ts new file mode 100644 index 00000000000..19a0247b453 --- /dev/null +++ b/frontend/e2e/tests/settings/custom-domain/custom-domain-settings.spec.ts @@ -0,0 +1,52 @@ +import { expect, test } from '@playwright/test'; + +import { ensureLoggedIn } from '../../../utils/login.util'; + +test('Custom Domain Settings - View and Interact', async ({ page }) => { + await ensureLoggedIn(page); + + // 1. Open the sidebar settings menu using data-testid + await page.getByTestId('settings-nav-item').click(); + + // 2. Click Account Settings in the dropdown (by role/name or data-testid if available) + await page.getByRole('menuitem', { name: 'Account Settings' }).click(); + + // Assert the main tabpanel/heading (confirmed by DOM) + await expect(page.getByTestId('settings-page-title')).toBeVisible(); + + // Focus on the settings page sidenav + await page.getByTestId('settings-page-sidenav').focus(); + + // Click Custom Domain tab in the settings sidebar (by data-testid) + await page.getByTestId('custom-domain').click(); + + // Wait for custom domain chart/data to finish loading + await page.getByText('loading').first().waitFor({ state: 'hidden' }); + + // Assert heading and subheading + await expect( + page.getByRole('heading', { name: 'Custom Domain Settings' }), + ).toBeVisible(); + await expect( + page.getByText('Personalize your workspace domain effortlessly.'), + ).toBeVisible(); + + // Assert presence of Customize team’s URL button + const customizeBtn = page.getByRole('button', { + name: 'Customize team’s URL', + }); + await expect(customizeBtn).toBeVisible(); + await customizeBtn.click(); + + // Assert modal/dialog fields and buttons + await expect( + page.getByRole('dialog', { name: 'Customize your team’s URL' }), + ).toBeVisible(); + await expect(page.getByLabel('Team’s URL subdomain')).toBeVisible(); + await expect( + page.getByRole('button', { name: 'Apply Changes' }), + ).toBeVisible(); + await expect(page.getByRole('button', { name: 'Close' })).toBeVisible(); + // Close the modal + await page.getByRole('button', { name: 'Close' }).click(); +}); diff --git a/frontend/e2e/tests/settings/general/general-settings.spec.ts b/frontend/e2e/tests/settings/general/general-settings.spec.ts new file mode 100644 index 00000000000..346659fe3b3 --- /dev/null +++ b/frontend/e2e/tests/settings/general/general-settings.spec.ts @@ -0,0 +1,32 @@ +import { expect, test } from '@playwright/test'; + +import { ensureLoggedIn } from '../../../utils/login.util'; + +test('View General Settings', async ({ page }) => { + await ensureLoggedIn(page); + + // 1. Open the sidebar settings menu using data-testid + await page.getByTestId('settings-nav-item').click(); + + // 2. Click Account Settings in the dropdown (by role/name or data-testid if available) + await page.getByRole('menuitem', { name: 'Account Settings' }).click(); + + // Assert the main tabpanel/heading (confirmed by DOM) + await expect(page.getByTestId('settings-page-title')).toBeVisible(); + + // Focus on the settings page sidenav + await page.getByTestId('settings-page-sidenav').focus(); + + // Click General tab in the settings sidebar (by data-testid) + await page.getByTestId('general').click(); + + // Wait for General tab to be visible + await page.getByRole('tabpanel', { name: 'General' }).waitFor(); + + // Assert visibility of definitive/static elements + await expect(page.getByRole('heading', { name: 'Metrics' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Traces' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Logs' })).toBeVisible(); + await expect(page.getByText('Please')).toBeVisible(); + await expect(page.getByRole('link', { name: 'email us' })).toBeVisible(); +}); diff --git a/frontend/e2e/tests/settings/ingestion/ingestion-settings.spec.ts b/frontend/e2e/tests/settings/ingestion/ingestion-settings.spec.ts new file mode 100644 index 00000000000..c18fee0c804 --- /dev/null +++ b/frontend/e2e/tests/settings/ingestion/ingestion-settings.spec.ts @@ -0,0 +1,48 @@ +import { expect, test } from '@playwright/test'; + +import { ensureLoggedIn } from '../../../utils/login.util'; + +test('Ingestion Settings - View and Interact', async ({ page }) => { + await ensureLoggedIn(page); + + // 1. Open the sidebar settings menu using data-testid + await page.getByTestId('settings-nav-item').click(); + + // 2. Click Account Settings in the dropdown (by role/name or data-testid if available) + await page.getByRole('menuitem', { name: 'Account Settings' }).click(); + + // Assert the main tabpanel/heading (confirmed by DOM) + await expect(page.getByTestId('settings-page-title')).toBeVisible(); + + // Focus on the settings page sidenav + await page.getByTestId('settings-page-sidenav').focus(); + + // Click Ingestion tab in the settings sidebar (by data-testid) + await page.getByTestId('ingestion').click(); + + // Assert heading and subheading (Integrations page) + await expect( + page.getByRole('heading', { name: 'Integrations' }), + ).toBeVisible(); + await expect( + page.getByText('Manage Integrations for this workspace'), + ).toBeVisible(); + + // Assert presence of search box + await expect( + page.getByPlaceholder('Search for an integration...'), + ).toBeVisible(); + + // Assert at least one data source with Configure button + const configureBtn = page.getByRole('button', { name: 'Configure' }).first(); + await expect(configureBtn).toBeVisible(); + + // Assert Request more integrations section + await expect( + page.getByText( + "Can't find what you’re looking for? Request more integrations", + ), + ).toBeVisible(); + await expect(page.getByPlaceholder('Enter integration name...')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Submit' })).toBeVisible(); +}); diff --git a/frontend/e2e/tests/settings/integrations/integrations-settings.spec.ts b/frontend/e2e/tests/settings/integrations/integrations-settings.spec.ts new file mode 100644 index 00000000000..3c55614cec5 --- /dev/null +++ b/frontend/e2e/tests/settings/integrations/integrations-settings.spec.ts @@ -0,0 +1,48 @@ +import { expect, test } from '@playwright/test'; + +import { ensureLoggedIn } from '../../../utils/login.util'; + +test('Integrations Settings - View and Interact', async ({ page }) => { + await ensureLoggedIn(page); + + // 1. Open the sidebar settings menu using data-testid + await page.getByTestId('settings-nav-item').click(); + + // 2. Click Account Settings in the dropdown (by role/name or data-testid if available) + await page.getByRole('menuitem', { name: 'Account Settings' }).click(); + + // Assert the main tabpanel/heading (confirmed by DOM) + await expect(page.getByTestId('settings-page-title')).toBeVisible(); + + // Focus on the settings page sidenav + await page.getByTestId('settings-page-sidenav').focus(); + + // Click Integrations tab in the settings sidebar (by data-testid) + await page.getByTestId('integrations').click(); + + // Assert heading and subheading + await expect( + page.getByRole('heading', { name: 'Integrations' }), + ).toBeVisible(); + await expect( + page.getByText('Manage Integrations for this workspace'), + ).toBeVisible(); + + // Assert presence of search box + await expect( + page.getByPlaceholder('Search for an integration...'), + ).toBeVisible(); + + // Assert at least one integration with Configure button + const configureBtn = page.getByRole('button', { name: 'Configure' }).first(); + await expect(configureBtn).toBeVisible(); + + // Assert Request more integrations section + await expect( + page.getByText( + "Can't find what you’re looking for? Request more integrations", + ), + ).toBeVisible(); + await expect(page.getByPlaceholder('Enter integration name...')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Submit' })).toBeVisible(); +}); diff --git a/frontend/e2e/tests/settings/members-sso/members-sso-settings.spec.ts b/frontend/e2e/tests/settings/members-sso/members-sso-settings.spec.ts new file mode 100644 index 00000000000..02d9880fbfe --- /dev/null +++ b/frontend/e2e/tests/settings/members-sso/members-sso-settings.spec.ts @@ -0,0 +1,56 @@ +import { expect, test } from '@playwright/test'; + +import { ensureLoggedIn } from '../../../utils/login.util'; + +test('Members & SSO Settings - View and Interact', async ({ page }) => { + await ensureLoggedIn(page); + + // 1. Open the sidebar settings menu using data-testid + await page.getByTestId('settings-nav-item').click(); + + // 2. Click Account Settings in the dropdown (by role/name or data-testid if available) + await page.getByRole('menuitem', { name: 'Account Settings' }).click(); + + // Assert the main tabpanel/heading (confirmed by DOM) + await expect(page.getByTestId('settings-page-title')).toBeVisible(); + + // Focus on the settings page sidenav + await page.getByTestId('settings-page-sidenav').focus(); + + // Click Members & SSO tab in the settings sidebar (by data-testid) + await page.getByTestId('members-sso').click(); + + // Assert headings and tables + await expect( + page.getByRole('heading', { name: /Members \(\d+\)/ }), + ).toBeVisible(); + await expect( + page.getByRole('heading', { name: /Pending Invites \(\d+\)/ }), + ).toBeVisible(); + await expect( + page.getByRole('heading', { name: 'Authenticated Domains' }), + ).toBeVisible(); + + // Assert Invite Members button is visible and clickable + const inviteBtn = page.getByRole('button', { name: /Invite Members/ }); + await expect(inviteBtn).toBeVisible(); + await inviteBtn.click(); + // Assert Invite Members modal/dialog appears (modal title is unique) + await expect(page.getByText('Invite team members').first()).toBeVisible(); + // Close the modal (use unique 'Close' button) + await page.getByRole('button', { name: 'Close' }).click(); + + // Assert Edit and Delete buttons are present for at least one member + const editBtn = page.getByRole('button', { name: /Edit/ }).first(); + const deleteBtn = page.getByRole('button', { name: /Delete/ }).first(); + await expect(editBtn).toBeVisible(); + await expect(deleteBtn).toBeVisible(); + + // Assert Add Domains button is visible + await expect(page.getByRole('button', { name: /Add Domains/ })).toBeVisible(); + // Assert Configure SSO or Edit Google Auth button is visible for at least one domain + const ssoBtn = page + .getByRole('button', { name: /Configure SSO|Edit Google Auth/ }) + .first(); + await expect(ssoBtn).toBeVisible(); +}); diff --git a/frontend/e2e/tests/settings/notification-channels/notification-channels-settings.spec.ts b/frontend/e2e/tests/settings/notification-channels/notification-channels-settings.spec.ts new file mode 100644 index 00000000000..432597671f1 --- /dev/null +++ b/frontend/e2e/tests/settings/notification-channels/notification-channels-settings.spec.ts @@ -0,0 +1,57 @@ +import { expect, test } from '@playwright/test'; + +import { ensureLoggedIn } from '../../../utils/login.util'; + +test('Notification Channels Settings - View and Interact', async ({ page }) => { + await ensureLoggedIn(page); + + // 1. Open the sidebar settings menu using data-testid + await page.getByTestId('settings-nav-item').click(); + + // 2. Click Account Settings in the dropdown (by role/name or data-testid if available) + await page.getByRole('menuitem', { name: 'Account Settings' }).click(); + + // Assert the main tabpanel/heading (confirmed by DOM) + await expect(page.getByTestId('settings-page-title')).toBeVisible(); + + // Focus on the settings page sidenav + await page.getByTestId('settings-page-sidenav').focus(); + + // Click Notification Channels tab in the settings sidebar (by data-testid) + await page.getByTestId('notification-channels').click(); + + // Wait for loading to finish + await page.getByText('loading').first().waitFor({ state: 'hidden' }); + + // Assert presence of New Alert Channel button + const newChannelBtn = page.getByRole('button', { name: /New Alert Channel/ }); + await expect(newChannelBtn).toBeVisible(); + + // Assert table columns + await expect(page.getByText('Name')).toBeVisible(); + await expect(page.getByText('Type')).toBeVisible(); + await expect(page.getByText('Action')).toBeVisible(); + + // Click New Alert Channel and assert modal fields/buttons + await newChannelBtn.click(); + await expect( + page.getByRole('heading', { name: 'New Notification Channel' }), + ).toBeVisible(); + await expect(page.getByLabel('Name')).toBeVisible(); + await expect(page.getByLabel('Type')).toBeVisible(); + await expect(page.getByLabel('Webhook URL')).toBeVisible(); + await expect( + page.getByRole('switch', { name: 'Send resolved alerts' }), + ).toBeVisible(); + await expect(page.getByRole('button', { name: 'Save' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Test' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Back' })).toBeVisible(); + // Close modal + await page.getByRole('button', { name: 'Back' }).click(); + + // Assert Edit and Delete buttons for at least one channel + const editBtn = page.getByRole('button', { name: 'Edit' }).first(); + const deleteBtn = page.getByRole('button', { name: 'Delete' }).first(); + await expect(editBtn).toBeVisible(); + await expect(deleteBtn).toBeVisible(); +}); diff --git a/frontend/e2e/utils/login.util.ts b/frontend/e2e/utils/login.util.ts new file mode 100644 index 00000000000..c425e31e72e --- /dev/null +++ b/frontend/e2e/utils/login.util.ts @@ -0,0 +1,35 @@ +import { Page } from '@playwright/test'; + +// Read credentials from environment variables +const username = process.env.LOGIN_USERNAME; +const password = process.env.LOGIN_PASSWORD; +const baseURL = process.env.BASE_URL; + +/** + * Ensures the user is logged in. If not, performs the login steps. + * Follows the MCP process step-by-step. + */ +export async function ensureLoggedIn(page: Page): Promise { + // if already in home page, return + if (await page.url().includes('/home')) { + return; + } + + if (!username || !password) { + throw new Error( + 'E2E_EMAIL and E2E_PASSWORD environment variables must be set.', + ); + } + + await page.goto(`${baseURL}/login`); + await page.getByTestId('email').click(); + await page.getByTestId('email').fill(username); + await page.getByTestId('initiate_login').click(); + await page.getByTestId('password').click(); + await page.getByTestId('password').fill(password); + await page.getByRole('button', { name: 'Login' }).click(); + + await page + .getByText('Hello there, Welcome to your') + .waitFor({ state: 'visible' }); +} diff --git a/frontend/example.env b/frontend/example.env index 5e1271b1578..d875044f96e 100644 --- a/frontend/example.env +++ b/frontend/example.env @@ -1,6 +1,7 @@ NODE_ENV="development" BUNDLE_ANALYSER="true" FRONTEND_API_ENDPOINT="http://localhost:8080/" -INTERCOM_APP_ID="intercom-app-id" +PYLON_APP_ID="pylon-app-id" +APPCUES_APP_ID="appcess-app-id" CI="1" \ No newline at end of file diff --git a/frontend/jest.config.ts b/frontend/jest.config.ts index c16339d5177..a5e8d86c3fc 100644 --- a/frontend/jest.config.ts +++ b/frontend/jest.config.ts @@ -1,6 +1,9 @@ import type { Config } from '@jest/types'; +const USE_SAFE_NAVIGATE_MOCK_PATH = '/__mocks__/useSafeNavigate.ts'; + const config: Config.InitialOptions = { + silent: true, clearMocks: true, coverageDirectory: 'coverage', coverageReporters: ['text', 'cobertura', 'html', 'json-summary'], @@ -10,11 +13,17 @@ const config: Config.InitialOptions = { moduleNameMapper: { '\\.(css|less|scss)$': '/__mocks__/cssMock.ts', '\\.md$': '/__mocks__/cssMock.ts', + '^uplot$': '/__mocks__/uplotMock.ts', + '^hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH, + '^src/hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH, + '^.*/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH, }, globals: { extensionsToTreatAsEsm: ['.ts'], 'ts-jest': { useESM: true, + isolatedModules: true, + tsconfig: '/tsconfig.jest.json', }, }, testMatch: ['/src/**/*?(*.)(test).(ts|js)?(x)'], @@ -24,7 +33,7 @@ const config: Config.InitialOptions = { '^.+\\.(js|jsx)$': 'babel-jest', }, transformIgnorePatterns: [ - 'node_modules/(?!(lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@signozhq/design-tokens|d3-interpolate|d3-color|api)/)', + 'node_modules/(?!(lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@signozhq/design-tokens|@signozhq/table|@signozhq/calendar|@signozhq/input|@signozhq/popover|@signozhq/button|@signozhq/sonner|@signozhq/*|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn)/)', ], setupFilesAfterEnv: ['jest.setup.ts'], testPathIgnorePatterns: ['/node_modules/', '/public/'], diff --git a/frontend/package.json b/frontend/package.json index 72b14931383..6316f619891 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -28,6 +28,8 @@ "dependencies": { "@ant-design/colors": "6.0.0", "@ant-design/icons": "4.8.0", + "@codemirror/autocomplete": "6.18.6", + "@codemirror/lang-javascript": "6.2.3", "@dnd-kit/core": "6.1.0", "@dnd-kit/modifiers": "7.0.0", "@dnd-kit/sortable": "8.0.0", @@ -36,13 +38,27 @@ "@mdx-js/loader": "2.3.0", "@mdx-js/react": "2.3.0", "@monaco-editor/react": "^4.3.1", + "@playwright/test": "1.54.1", "@radix-ui/react-tabs": "1.0.4", "@radix-ui/react-tooltip": "1.0.7", "@sentry/react": "8.41.0", "@sentry/webpack-plugin": "2.22.6", + "@signozhq/badge": "0.0.2", + "@signozhq/button": "0.0.2", + "@signozhq/calendar": "0.0.0", + "@signozhq/callout": "0.0.2", "@signozhq/design-tokens": "1.1.4", + "@signozhq/input": "0.0.2", + "@signozhq/popover": "0.0.0", + "@signozhq/resizable": "0.0.0", + "@signozhq/sonner": "0.1.0", + "@signozhq/table": "0.3.7", + "@signozhq/tooltip": "0.0.2", "@tanstack/react-table": "8.20.6", "@tanstack/react-virtual": "3.11.2", + "@uiw/codemirror-theme-copilot": "4.23.11", + "@uiw/codemirror-theme-github": "4.24.1", + "@uiw/react-codemirror": "4.23.10", "@uiw/react-md-editor": "3.23.5", "@visx/group": "3.3.0", "@visx/hierarchy": "3.12.0", @@ -52,6 +68,7 @@ "ansi-to-html": "0.7.2", "antd": "5.11.0", "antd-table-saveas-excel": "2.2.1", + "antlr4": "4.13.2", "axios": "1.8.2", "babel-eslint": "^10.1.0", "babel-jest": "^29.6.4", @@ -78,13 +95,14 @@ "fontfaceobserver": "2.3.0", "history": "4.10.1", "html-webpack-plugin": "5.5.0", - "http-proxy-middleware": "3.0.3", + "http-proxy-middleware": "3.0.5", "http-status-codes": "2.3.0", "i18next": "^21.6.12", "i18next-browser-languagedetector": "^6.1.3", "i18next-http-backend": "^1.3.2", "jest": "^27.5.1", "js-base64": "^3.7.2", + "kbar": "0.1.0-beta.48", "less": "^4.1.2", "less-loader": "^10.2.0", "lodash-es": "^4.17.21", @@ -121,6 +139,7 @@ "redux": "^4.0.5", "redux-thunk": "^2.3.0", "rehype-raw": "7.0.0", + "rrule": "2.8.1", "stream": "^0.0.2", "style-loader": "1.3.0", "styled-components": "^5.3.11", @@ -134,7 +153,7 @@ "uuid": "^8.3.2", "web-vitals": "^0.2.4", "webpack": "5.94.0", - "webpack-dev-server": "^4.15.2", + "webpack-dev-server": "^5.2.1", "webpack-retry-chunk-load-plugin": "3.1.1", "xstate": "^4.31.0" }, @@ -197,7 +216,6 @@ "babel-plugin-styled-components": "^1.12.0", "compression-webpack-plugin": "9.0.0", "copy-webpack-plugin": "^11.0.0", - "critters-webpack-plugin": "^3.0.1", "eslint": "^7.32.0", "eslint-config-airbnb": "^19.0.4", "eslint-config-airbnb-typescript": "^16.1.4", @@ -214,7 +232,9 @@ "eslint-plugin-simple-import-sort": "^7.0.0", "eslint-plugin-sonarjs": "^0.12.0", "husky": "^7.0.4", - "image-webpack-loader": "8.1.0", + "image-minimizer-webpack-plugin": "^4.0.0", + "imagemin": "^8.0.1", + "imagemin-svgo": "^10.0.1", "is-ci": "^3.0.1", "jest-styled-components": "^7.0.8", "lint-staged": "^12.5.0", @@ -231,11 +251,12 @@ "redux-mock-store": "1.5.4", "sass": "1.66.1", "sass-loader": "13.3.2", + "sharp": "^0.33.4", "ts-jest": "^27.1.5", "ts-node": "^10.2.1", - "typescript-plugin-css-modules": "5.0.1", + "typescript-plugin-css-modules": "5.2.0", "webpack-bundle-analyzer": "^4.5.0", - "webpack-cli": "^4.9.2" + "webpack-cli": "^5.1.4" }, "lint-staged": { "*.(js|jsx|ts|tsx)": [ @@ -251,10 +272,13 @@ "xml2js": "0.5.0", "phin": "^3.7.1", "body-parser": "1.20.3", - "http-proxy-middleware": "3.0.3", + "http-proxy-middleware": "3.0.5", "cross-spawn": "7.0.5", "cookie": "^0.7.1", "serialize-javascript": "6.0.2", - "prismjs": "1.30.0" + "prismjs": "1.30.0", + "got": "11.8.5", + "form-data": "4.0.4", + "brace-expansion": "^2.0.2" } } diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 00000000000..88ee733baa1 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,95 @@ +import { defineConfig, devices } from '@playwright/test'; +import dotenv from 'dotenv'; +import path from 'path'; + +// Read from ".env" file. +dotenv.config({ path: path.resolve(__dirname, '.env') }); + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// import dotenv from 'dotenv'; +// import path from 'path'; +// dotenv.config({ path: path.resolve(__dirname, '.env') }); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './e2e/tests', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Run tests in parallel even in CI - optimized for GitHub Actions free tier */ + workers: process.env.CI ? 2 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: + process.env.SIGNOZ_E2E_BASE_URL || 'https://app.us.staging.signoz.cloud', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + colorScheme: 'dark', + locale: 'en-US', + viewport: { width: 1280, height: 720 }, + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { + launchOptions: { args: ['--start-maximized'] }, + viewport: null, + colorScheme: 'dark', + locale: 'en-US', + baseURL: 'https://app.us.staging.signoz.cloud', + trace: 'on-first-retry', + }, + }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // url: 'http://localhost:3000', + // reuseExistingServer: !process.env.CI, + // }, +}); diff --git a/frontend/prompts/generate-e2e-test.md b/frontend/prompts/generate-e2e-test.md new file mode 100644 index 00000000000..29caf5ab61b --- /dev/null +++ b/frontend/prompts/generate-e2e-test.md @@ -0,0 +1,16 @@ +RULE: All test code for this repo must be generated by following the step-by-step Playwright MCP process as described below. + +- You are a playwright test generator. +- You are given a scenario and you need to generate a playwright test for it. +- Use login util if not logged in. +- DO NOT generate test code based on the scenario alone. +- DO run steps one by one using the tools provided by the Playwright MCP. +- Only after all steps are completed, emit a Playwright TypeScript test that uses @playwright/test based on message history +- Gather correct selectors before writing the test +- DO NOT valiate for dynamic content in the tests, only validate for the correctness with meta data +- Always inspect the DOM at each navigation or interaction step to determine the correct selector for the next action. Do not assume selectors, confirm via inspection before proceeding. +- Assert visibility of definitive/static elements in the UI (such as labels, headings, or section titles) rather than dynamic values or content that may change between runs. +- Save generated test file in the tests directory +- Execute the test file and iterate until the test passes + + diff --git a/frontend/public/Images/cloud.svg b/frontend/public/Images/cloud.svg new file mode 100644 index 00000000000..c7138d589b2 --- /dev/null +++ b/frontend/public/Images/cloud.svg @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/public/Logos/argocd.svg b/frontend/public/Logos/argocd.svg new file mode 100644 index 00000000000..ef6eff54507 --- /dev/null +++ b/frontend/public/Logos/argocd.svg @@ -0,0 +1 @@ + diff --git a/frontend/public/Logos/azure-mysql.svg b/frontend/public/Logos/azure-mysql.svg new file mode 100644 index 00000000000..d1504cc763f --- /dev/null +++ b/frontend/public/Logos/azure-mysql.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/Logos/cloudflare.svg b/frontend/public/Logos/cloudflare.svg new file mode 100644 index 00000000000..84ce0dc0165 --- /dev/null +++ b/frontend/public/Logos/cloudflare.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/Logos/dynamodb.svg b/frontend/public/Logos/dynamodb.svg new file mode 100644 index 00000000000..bd4f2c30f50 --- /dev/null +++ b/frontend/public/Logos/dynamodb.svg @@ -0,0 +1,18 @@ + + + + Icon-Architecture/64/Arch_Amazon-DynamoDB_64 + Created with Sketch. + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/public/Logos/elk.svg b/frontend/public/Logos/elk.svg new file mode 100644 index 00000000000..d240ad568c8 --- /dev/null +++ b/frontend/public/Logos/elk.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/frontend/public/Logos/external-api-monitoring.svg b/frontend/public/Logos/external-api-monitoring.svg new file mode 100644 index 00000000000..327eaa95064 --- /dev/null +++ b/frontend/public/Logos/external-api-monitoring.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/public/Logos/github-actions.svg b/frontend/public/Logos/github-actions.svg new file mode 100644 index 00000000000..2929e024f39 --- /dev/null +++ b/frontend/public/Logos/github-actions.svg @@ -0,0 +1 @@ + diff --git a/frontend/public/Logos/github.svg b/frontend/public/Logos/github.svg new file mode 100644 index 00000000000..3ff1cee7c9b --- /dev/null +++ b/frontend/public/Logos/github.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/Logos/http-monitoring.svg b/frontend/public/Logos/http-monitoring.svg new file mode 100644 index 00000000000..b495ddf1989 --- /dev/null +++ b/frontend/public/Logos/http-monitoring.svg @@ -0,0 +1 @@ +IETF-Badge-HTTP \ No newline at end of file diff --git a/frontend/public/Logos/jenkins.svg b/frontend/public/Logos/jenkins.svg new file mode 100644 index 00000000000..c4e7b880d36 --- /dev/null +++ b/frontend/public/Logos/jenkins.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/Logos/newrelic.svg b/frontend/public/Logos/newrelic.svg new file mode 100644 index 00000000000..e2d586bf718 --- /dev/null +++ b/frontend/public/Logos/newrelic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/Logos/openai.svg b/frontend/public/Logos/openai.svg new file mode 100644 index 00000000000..94eb50385dc --- /dev/null +++ b/frontend/public/Logos/openai.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/Logos/s3.svg b/frontend/public/Logos/s3.svg new file mode 100644 index 00000000000..cd203eaad6e --- /dev/null +++ b/frontend/public/Logos/s3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/Logos/snowflake.svg b/frontend/public/Logos/snowflake.svg new file mode 100644 index 00000000000..f491c273133 --- /dev/null +++ b/frontend/public/Logos/snowflake.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/Logos/sns.svg b/frontend/public/Logos/sns.svg new file mode 100644 index 00000000000..6cb54adab20 --- /dev/null +++ b/frontend/public/Logos/sns.svg @@ -0,0 +1,18 @@ + + + + Icon-Architecture/64/Arch_AWS-Simple-Notification-Service_64 + Created with Sketch. + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/public/Logos/sqs.svg b/frontend/public/Logos/sqs.svg new file mode 100644 index 00000000000..b19102943cd --- /dev/null +++ b/frontend/public/Logos/sqs.svg @@ -0,0 +1 @@ +AWS Simple Queue Service (SQS) \ No newline at end of file diff --git a/frontend/public/Logos/systemd.svg b/frontend/public/Logos/systemd.svg new file mode 100644 index 00000000000..4a9d9492ff7 --- /dev/null +++ b/frontend/public/Logos/systemd.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/public/Logos/wordpress.svg b/frontend/public/Logos/wordpress.svg new file mode 100644 index 00000000000..916903f23b1 --- /dev/null +++ b/frontend/public/Logos/wordpress.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/locales/en-GB/alerts.json b/frontend/public/locales/en-GB/alerts.json index 8dd0ccbe47a..8219da73873 100644 --- a/frontend/public/locales/en-GB/alerts.json +++ b/frontend/public/locales/en-GB/alerts.json @@ -14,8 +14,8 @@ "remove_label_confirm": "This action will remove all the labels. Do you want to proceed?", "remove_label_success": "Labels cleared", "alert_form_step1": "Step 1 - Define the metric", - "alert_form_step2": "Step 2 - Define Alert Conditions", - "alert_form_step3": "Step 3 - Alert Configuration", + "alert_form_step2": "Step {{step}} - Define Alert Conditions", + "alert_form_step3": "Step {{step}} - Alert Configuration", "metric_query_max_limit": "Can not create query. You can create maximum of 5 queries", "confirm_save_title": "Save Changes", "confirm_save_content_part1": "Your alert built with", diff --git a/frontend/public/locales/en-GB/channels.json b/frontend/public/locales/en-GB/channels.json index 6ce4b6f2c15..4c1980fe9d4 100644 --- a/frontend/public/locales/en-GB/channels.json +++ b/frontend/public/locales/en-GB/channels.json @@ -9,8 +9,8 @@ "tooltip_notification_channels": "More details on how to setting notification channels", "sending_channels_note": "The alerts will be sent to all the configured channels.", "loading_channels_message": "Loading Channels..", - "page_title_create": "New Notification Channels", - "page_title_edit": "Edit Notification Channels", + "page_title_create": "New Notification Channel", + "page_title_edit": "Edit Notification Channel", "button_save_channel": "Save", "button_test_channel": "Test", "button_return": "Back", @@ -62,5 +62,8 @@ "channel_test_failed": "Failed to send a test message to this channel, please confirm that the parameters are set correctly", "channel_test_unexpected": "An unexpected error occurred while sending a message to this channel, please try again", "webhook_url_required": "Webhook URL is mandatory", - "slack_channel_help": "Specify channel or user, use #channel-name, @username (has to be all lowercase, no whitespace)" + "slack_channel_help": "Specify channel or user, use #channel-name, @username (has to be all lowercase, no whitespace)", + "api_key_required": "API Key is mandatory", + "to_required": "To field is mandatory", + "channel_name_required": "Channel name is mandatory" } \ No newline at end of file diff --git a/frontend/public/locales/en-GB/failedPayment.json b/frontend/public/locales/en-GB/failedPayment.json index a624e47c7db..05ec0cc68d4 100644 --- a/frontend/public/locales/en-GB/failedPayment.json +++ b/frontend/public/locales/en-GB/failedPayment.json @@ -8,5 +8,6 @@ "actNow": "Act now to avoid any disruptions and continue where you left off.", "contactAdmin": "Contact your admin to proceed with the upgrade.", "continueMyJourney": "Settle your bill to continue", - "somethingWentWrong": "Something went wrong" + "somethingWentWrong": "Something went wrong", + "refreshPaymentStatus": "Refresh Status" } diff --git a/frontend/public/locales/en-GB/rules.json b/frontend/public/locales/en-GB/rules.json index 63ae437d7ff..edb6d6f3ef5 100644 --- a/frontend/public/locales/en-GB/rules.json +++ b/frontend/public/locales/en-GB/rules.json @@ -7,8 +7,8 @@ "remove_label_confirm": "This action will remove all the labels. Do you want to proceed?", "remove_label_success": "Labels cleared", "alert_form_step1": "Step 1 - Define the metric", - "alert_form_step2": "Step 2 - Define Alert Conditions", - "alert_form_step3": "Step 3 - Alert Configuration", + "alert_form_step2": "Step {{step}} - Define Alert Conditions", + "alert_form_step3": "Step {{step}} - Alert Configuration", "metric_query_max_limit": "Can not create query. You can create maximum of 5 queries", "confirm_save_title": "Save Changes", "confirm_save_content_part1": "Your alert built with", diff --git a/frontend/public/locales/en-GB/services.json b/frontend/public/locales/en-GB/services.json index f04c8517599..a8c8b42c9c7 100644 --- a/frontend/public/locales/en-GB/services.json +++ b/frontend/public/locales/en-GB/services.json @@ -1,3 +1,3 @@ { - "rps_over_100": "You are sending data at more than 100 RPS, your ingestion may be rate limited. Please reach out to us via Intercom support or " + "rps_over_100": "You are sending data at more than 100 RPS, your ingestion may be rate limited. Please reach out to us via chat support or " } diff --git a/frontend/public/locales/en-GB/titles.json b/frontend/public/locales/en-GB/titles.json index 95a58e615f9..f5ce505de3c 100644 --- a/frontend/public/locales/en-GB/titles.json +++ b/frontend/public/locales/en-GB/titles.json @@ -46,5 +46,8 @@ "ALERT_HISTORY": "SigNoz | Alert Rule History", "ALERT_OVERVIEW": "SigNoz | Alert Rule Overview", "INFRASTRUCTURE_MONITORING_HOSTS": "SigNoz | Infra Monitoring", - "INFRASTRUCTURE_MONITORING_KUBERNETES": "SigNoz | Infra Monitoring" + "INFRASTRUCTURE_MONITORING_KUBERNETES": "SigNoz | Infra Monitoring", + "METER_EXPLORER": "SigNoz | Meter Explorer", + "METER_EXPLORER_VIEWS": "SigNoz | Meter Explorer Views", + "METER": "SigNoz | Meter" } diff --git a/frontend/public/locales/en/alerts.json b/frontend/public/locales/en/alerts.json index 6adeb7382bc..e49ab3bc7c3 100644 --- a/frontend/public/locales/en/alerts.json +++ b/frontend/public/locales/en/alerts.json @@ -129,5 +129,6 @@ "text_num_points": "data points in each result group", "text_alert_frequency": "Run alert every", "text_for": "minutes", - "selected_query_placeholder": "Select query" + "selected_query_placeholder": "Select query", + "alert_rule_not_found": "Alert Rule not found" } diff --git a/frontend/public/locales/en/channels.json b/frontend/public/locales/en/channels.json index 1cbc4cf46db..5068e125f7e 100644 --- a/frontend/public/locales/en/channels.json +++ b/frontend/public/locales/en/channels.json @@ -9,8 +9,8 @@ "tooltip_notification_channels": "More details on how to setting notification channels", "sending_channels_note": "The alerts will be sent to all the configured channels.", "loading_channels_message": "Loading Channels..", - "page_title_create": "New Notification Channels", - "page_title_edit": "Edit Notification Channels", + "page_title_create": "New Notification Channel", + "page_title_edit": "Edit Notification Channel", "button_save_channel": "Save", "button_test_channel": "Test", "button_return": "Back", @@ -77,5 +77,8 @@ "channel_test_failed": "Failed to send a test message to this channel, please confirm that the parameters are set correctly", "channel_test_unexpected": "An unexpected error occurred while sending a message to this channel, please try again", "webhook_url_required": "Webhook URL is mandatory", - "slack_channel_help": "Specify channel or user, use #channel-name, @username (has to be all lowercase, no whitespace)" + "slack_channel_help": "Specify channel or user, use #channel-name, @username (has to be all lowercase, no whitespace)", + "api_key_required": "API Key is mandatory", + "to_required": "To field is mandatory", + "channel_name_required": "Channel name is mandatory" } \ No newline at end of file diff --git a/frontend/public/locales/en/failedPayment.json b/frontend/public/locales/en/failedPayment.json index a624e47c7db..05ec0cc68d4 100644 --- a/frontend/public/locales/en/failedPayment.json +++ b/frontend/public/locales/en/failedPayment.json @@ -8,5 +8,6 @@ "actNow": "Act now to avoid any disruptions and continue where you left off.", "contactAdmin": "Contact your admin to proceed with the upgrade.", "continueMyJourney": "Settle your bill to continue", - "somethingWentWrong": "Something went wrong" + "somethingWentWrong": "Something went wrong", + "refreshPaymentStatus": "Refresh Status" } diff --git a/frontend/public/locales/en/rules.json b/frontend/public/locales/en/rules.json index 63ae437d7ff..edb6d6f3ef5 100644 --- a/frontend/public/locales/en/rules.json +++ b/frontend/public/locales/en/rules.json @@ -7,8 +7,8 @@ "remove_label_confirm": "This action will remove all the labels. Do you want to proceed?", "remove_label_success": "Labels cleared", "alert_form_step1": "Step 1 - Define the metric", - "alert_form_step2": "Step 2 - Define Alert Conditions", - "alert_form_step3": "Step 3 - Alert Configuration", + "alert_form_step2": "Step {{step}} - Define Alert Conditions", + "alert_form_step3": "Step {{step}} - Alert Configuration", "metric_query_max_limit": "Can not create query. You can create maximum of 5 queries", "confirm_save_title": "Save Changes", "confirm_save_content_part1": "Your alert built with", diff --git a/frontend/public/locales/en/services.json b/frontend/public/locales/en/services.json index f04c8517599..a8c8b42c9c7 100644 --- a/frontend/public/locales/en/services.json +++ b/frontend/public/locales/en/services.json @@ -1,3 +1,3 @@ { - "rps_over_100": "You are sending data at more than 100 RPS, your ingestion may be rate limited. Please reach out to us via Intercom support or " + "rps_over_100": "You are sending data at more than 100 RPS, your ingestion may be rate limited. Please reach out to us via chat support or " } diff --git a/frontend/public/locales/en/titles.json b/frontend/public/locales/en/titles.json index 9e4039a86b7..3ab532a9138 100644 --- a/frontend/public/locales/en/titles.json +++ b/frontend/public/locales/en/titles.json @@ -69,5 +69,8 @@ "METRICS_EXPLORER": "SigNoz | Metrics Explorer", "METRICS_EXPLORER_EXPLORER": "SigNoz | Metrics Explorer", "METRICS_EXPLORER_VIEWS": "SigNoz | Metrics Explorer", - "API_MONITORING": "SigNoz | External APIs" + "API_MONITORING": "SigNoz | External APIs", + "METER_EXPLORER": "SigNoz | Meter Explorer", + "METER_EXPLORER_VIEWS": "SigNoz | Meter Explorer Views", + "METER": "SigNoz | Meter" } diff --git a/frontend/src/AppRoutes/Private.tsx b/frontend/src/AppRoutes/Private.tsx index 092fedaae6a..8d80e0bbbd7 100644 --- a/frontend/src/AppRoutes/Private.tsx +++ b/frontend/src/AppRoutes/Private.tsx @@ -3,6 +3,7 @@ import setLocalStorageApi from 'api/browser/localstorage/set'; import getAll from 'api/v1/user/get'; import { FeatureKeys } from 'constants/features'; import { LOCALSTORAGE } from 'constants/localStorage'; +import { ORG_PREFERENCES } from 'constants/orgPreferences'; import ROUTES from 'constants/routes'; import { useGetTenantLicense } from 'hooks/useGetTenantLicense'; import history from 'lib/history'; @@ -14,6 +15,7 @@ import { matchPath, useLocation } from 'react-router-dom'; import { SuccessResponseV2 } from 'types/api'; import APIError from 'types/api/error'; import { LicensePlatform, LicenseState } from 'types/api/licensesV3/getActive'; +import { OrgPreference } from 'types/api/preferences/preference'; import { Organization } from 'types/api/user/getOrganization'; import { UserResponse } from 'types/api/user/getUser'; import { USER_ROLES } from 'types/roles'; @@ -36,8 +38,8 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element { user, isLoggedIn: isLoggedInState, isFetchingOrgPreferences, - activeLicenseV3, - isFetchingActiveLicenseV3, + activeLicense, + isFetchingActiveLicense, trialInfo, featureFlags, } = useAppContext(); @@ -78,7 +80,7 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element { const checkFirstTimeUser = useCallback((): boolean => { const users = usersData?.data || []; - const remainingUsers = users.filter( + const remainingUsers = (Array.isArray(users) ? users : []).filter( (user) => user.email !== 'admin@signoz.cloud', ); @@ -95,7 +97,8 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element { usersData.data ) { const isOnboardingComplete = orgPreferences?.find( - (preference: Record) => preference.key === 'ORG_ONBOARDING', + (preference: OrgPreference) => + preference.name === ORG_PREFERENCES.ORG_ONBOARDING, )?.value; const isFirstUser = checkFirstTimeUser(); @@ -123,7 +126,8 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element { const isRouteEnabledForWorkspaceBlockedState = isAdmin && - (path === ROUTES.ORG_SETTINGS || + (path === ROUTES.SETTINGS || + path === ROUTES.ORG_SETTINGS || path === ROUTES.BILLING || path === ROUTES.MY_SETTINGS); @@ -145,16 +149,16 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element { }; useEffect(() => { - if (!isFetchingActiveLicenseV3 && activeLicenseV3) { + if (!isFetchingActiveLicense && activeLicense) { const currentRoute = mapRoutes.get('current'); - const isTerminated = activeLicenseV3.state === LicenseState.TERMINATED; - const isExpired = activeLicenseV3.state === LicenseState.EXPIRED; - const isCancelled = activeLicenseV3.state === LicenseState.CANCELLED; + const isTerminated = activeLicense.state === LicenseState.TERMINATED; + const isExpired = activeLicense.state === LicenseState.EXPIRED; + const isCancelled = activeLicense.state === LicenseState.CANCELLED; const isWorkspaceAccessRestricted = isTerminated || isExpired || isCancelled; - const { platform } = activeLicenseV3; + const { platform } = activeLicense; if ( isWorkspaceAccessRestricted && @@ -164,26 +168,26 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element { navigateToWorkSpaceAccessRestricted(currentRoute); } } - }, [isFetchingActiveLicenseV3, activeLicenseV3, mapRoutes, pathname]); + }, [isFetchingActiveLicense, activeLicense, mapRoutes, pathname]); useEffect(() => { - if (!isFetchingActiveLicenseV3) { + if (!isFetchingActiveLicense) { const currentRoute = mapRoutes.get('current'); const shouldBlockWorkspace = trialInfo?.workSpaceBlock; if ( shouldBlockWorkspace && currentRoute && - activeLicenseV3?.platform === LicensePlatform.CLOUD + activeLicense?.platform === LicensePlatform.CLOUD ) { navigateToWorkSpaceBlocked(currentRoute); } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [ - isFetchingActiveLicenseV3, + isFetchingActiveLicense, trialInfo?.workSpaceBlock, - activeLicenseV3?.platform, + activeLicense?.platform, mapRoutes, pathname, ]); @@ -197,20 +201,20 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element { }; useEffect(() => { - if (!isFetchingActiveLicenseV3 && activeLicenseV3) { + if (!isFetchingActiveLicense && activeLicense) { const currentRoute = mapRoutes.get('current'); const shouldSuspendWorkspace = - activeLicenseV3.state === LicenseState.DEFAULTED; + activeLicense.state === LicenseState.DEFAULTED; if ( shouldSuspendWorkspace && currentRoute && - activeLicenseV3.platform === LicensePlatform.CLOUD + activeLicense.platform === LicensePlatform.CLOUD ) { navigateToWorkSpaceSuspended(currentRoute); } } - }, [isFetchingActiveLicenseV3, activeLicenseV3, mapRoutes, pathname]); + }, [isFetchingActiveLicense, activeLicense, mapRoutes, pathname]); useEffect(() => { if (org && org.length > 0 && org[0].id !== undefined) { diff --git a/frontend/src/AppRoutes/index.tsx b/frontend/src/AppRoutes/index.tsx index e24704a22cb..94ae9a90c7c 100644 --- a/frontend/src/AppRoutes/index.tsx +++ b/frontend/src/AppRoutes/index.tsx @@ -3,6 +3,8 @@ import { ConfigProvider } from 'antd'; import getLocalStorageApi from 'api/browser/localstorage/get'; import setLocalStorageApi from 'api/browser/localstorage/set'; import logEvent from 'api/common/logEvent'; +import AppLoading from 'components/AppLoading/AppLoading'; +import KBarCommandPalette from 'components/KBarCommandPalette/KBarCommandPalette'; import NotFound from 'components/NotFound'; import Spinner from 'components/Spinner'; import UserpilotRouteTracker from 'components/UserpilotRouteTracker/UserpilotRouteTracker'; @@ -13,9 +15,9 @@ import AppLayout from 'container/AppLayout'; import { KeyboardHotkeysProvider } from 'hooks/hotkeys/useKeyboardHotkeys'; import { useThemeConfig } from 'hooks/useDarkMode'; import { useGetTenantLicense } from 'hooks/useGetTenantLicense'; -import { LICENSE_PLAN_KEY } from 'hooks/useLicense'; import { NotificationProvider } from 'hooks/useNotifications'; import { ResourceProvider } from 'hooks/useResourceAttribute'; +import { StatusCodes } from 'http-status-codes'; import history from 'lib/history'; import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback'; import posthog from 'posthog-js'; @@ -23,10 +25,13 @@ import AlertRuleProvider from 'providers/Alert'; import { useAppContext } from 'providers/App/App'; import { IUser } from 'providers/App/types'; import { DashboardProvider } from 'providers/Dashboard/Dashboard'; +import { ErrorModalProvider } from 'providers/ErrorModalProvider'; +import { KBarCommandPaletteProvider } from 'providers/KBarCommandPaletteProvider'; import { QueryBuilderProvider } from 'providers/QueryBuilder'; import { Suspense, useCallback, useEffect, useState } from 'react'; import { Route, Router, Switch } from 'react-router-dom'; import { CompatRouter } from 'react-router-dom-v5-compat'; +import { LicenseStatus } from 'types/api/licensesV3/getActive'; import { Userpilot } from 'userpilot'; import { extractDomain } from 'utils/app'; @@ -41,14 +46,13 @@ import defaultRoutes, { function App(): JSX.Element { const themeConfig = useThemeConfig(); const { - licenses, user, isFetchingUser, - isFetchingLicenses, isFetchingFeatureFlags, trialInfo, - activeLicenseV3, - isFetchingActiveLicenseV3, + activeLicense, + isFetchingActiveLicense, + activeLicenseFetchError, userFetchError, featureFlagsFetchError, isLoggedIn: isLoggedInState, @@ -66,11 +70,11 @@ function App(): JSX.Element { const enableAnalytics = useCallback( (user: IUser): void => { // wait for the required data to be loaded before doing init for anything! - if (!isFetchingActiveLicenseV3 && activeLicenseV3 && org) { + if (!isFetchingActiveLicense && activeLicense && org) { const orgName = org && Array.isArray(org) && org.length > 0 ? org[0].displayName : ''; - const { displayName, email, role } = user; + const { displayName, email, role, id, orgId } = user; const domain = extractDomain(email); const hostNameParts = hostname.split('.'); @@ -103,6 +107,20 @@ function App(): JSX.Element { if (domain) { logEvent('Domain Identified', groupTraits, 'group'); } + if (window && window.Appcues) { + window.Appcues.identify(id, { + name: displayName, + + tenant_id: hostNameParts[0], + data_region: hostNameParts[1], + tenant_url: hostname, + company_domain: domain, + + companyName: orgName, + email, + paidUser: !!trialInfo?.trialConvertedToSubscription, + }); + } Userpilot.identify(email, { email, @@ -116,7 +134,7 @@ function App(): JSX.Element { isPaidUser: !!trialInfo?.trialConvertedToSubscription, }); - posthog?.identify(email, { + posthog?.identify(id, { email, name: displayName, orgName, @@ -128,7 +146,7 @@ function App(): JSX.Element { isPaidUser: !!trialInfo?.trialConvertedToSubscription, }); - posthog?.group('company', domain, { + posthog?.group('company', orgId, { name: orgName, tenant_id: hostNameParts[0], data_region: hostNameParts[1], @@ -137,24 +155,12 @@ function App(): JSX.Element { source: 'signoz-ui', isPaidUser: !!trialInfo?.trialConvertedToSubscription, }); - - if ( - window.cioanalytics && - typeof window.cioanalytics.identify === 'function' - ) { - window.cioanalytics.reset(); - window.cioanalytics.identify(email, { - name: user.displayName, - email, - role: user.role, - }); - } } }, [ hostname, - isFetchingActiveLicenseV3, - activeLicenseV3, + isFetchingActiveLicense, + activeLicense, org, trialInfo?.trialConvertedToSubscription, ], @@ -163,18 +169,19 @@ function App(): JSX.Element { // eslint-disable-next-line sonarjs/cognitive-complexity useEffect(() => { if ( - !isFetchingLicenses && - licenses && + !isFetchingActiveLicense && + (activeLicense || activeLicenseFetchError) && !isFetchingUser && user && !!user.email ) { + // either the active API returns error with 404 or 501 and if it returns a terminated license means it's on basic plan const isOnBasicPlan = - licenses.licenses?.some( - (license) => - license.isCurrent && license.planKey === LICENSE_PLAN_KEY.BASIC_PLAN, - ) || licenses.licenses === null; - + (activeLicenseFetchError && + [StatusCodes.NOT_FOUND, StatusCodes.NOT_IMPLEMENTED].includes( + activeLicenseFetchError?.getHttpStatusCode(), + )) || + (activeLicense?.status && activeLicense.status === LicenseStatus.INVALID); const isIdentifiedUser = getLocalStorageApi(LOCALSTORAGE.IS_IDENTIFIED_USER); if (isLoggedInState && user && user.id && user.email && !isIdentifiedUser) { @@ -187,15 +194,22 @@ function App(): JSX.Element { // if the user is on basic plan then remove billing if (isOnBasicPlan) { updatedRoutes = updatedRoutes.filter( - (route) => route?.path !== ROUTES.BILLING, + (route) => + route?.path !== ROUTES.BILLING && route?.path !== ROUTES.INTEGRATIONS, ); } + + if (isEnterpriseSelfHostedUser) { + updatedRoutes.push(LIST_LICENSES); + } + // always add support route for cloud users updatedRoutes = [...updatedRoutes, SUPPORT_ROUTE]; } else { // if not a cloud user then remove billing and add list licenses route updatedRoutes = updatedRoutes.filter( - (route) => route?.path !== ROUTES.BILLING, + (route) => + route?.path !== ROUTES.BILLING && route?.path !== ROUTES.INTEGRATIONS, ); updatedRoutes = [...updatedRoutes, LIST_LICENSES]; } @@ -204,22 +218,23 @@ function App(): JSX.Element { }, [ isLoggedInState, user, - licenses, isCloudUser, isEnterpriseSelfHostedUser, - isFetchingLicenses, + isFetchingActiveLicense, isFetchingUser, + activeLicense, + activeLicenseFetchError, ]); useEffect(() => { if (pathname === ROUTES.ONBOARDING) { - window.Intercom('update', { - hide_default_launcher: true, - }); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + window.Pylon('hideChatBubble'); } else { - window.Intercom('update', { - hide_default_launcher: false, - }); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + window.Pylon('showChatBubble'); } }, [pathname]); @@ -231,8 +246,7 @@ function App(): JSX.Element { if ( !isFetchingFeatureFlags && (featureFlags || featureFlagsFetchError) && - licenses && - activeLicenseV3 && + activeLicense && trialInfo ) { let isChatSupportEnabled = false; @@ -255,11 +269,13 @@ function App(): JSX.Element { !showAddCreditCardModal && (isCloudUser || isEnterpriseSelfHostedUser) ) { - window.Intercom('boot', { - app_id: process.env.INTERCOM_APP_ID, - email: user?.email || '', - name: user?.displayName || '', - }); + window.pylon = { + chat_settings: { + app_id: process.env.PYLON_APP_ID, + email: user.email, + name: user.displayName, + }, + }; } } }, [ @@ -270,8 +286,7 @@ function App(): JSX.Element { featureFlags, isFetchingFeatureFlags, featureFlagsFetchError, - licenses, - activeLicenseV3, + activeLicense, trialInfo, isCloudUser, isEnterpriseSelfHostedUser, @@ -322,10 +337,6 @@ function App(): JSX.Element { } else { posthog.reset(); Sentry.close(); - - if (window.cioanalytics && typeof window.cioanalytics.reset === 'function') { - window.cioanalytics.reset(); - } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isCloudUser, isEnterpriseSelfHostedUser]); @@ -333,8 +344,8 @@ function App(): JSX.Element { // if the user is in logged in state if (isLoggedInState) { // if the setup calls are loading then return a spinner - if (isFetchingLicenses || isFetchingUser || isFetchingFeatureFlags) { - return ; + if (isFetchingActiveLicense || isFetchingUser || isFetchingFeatureFlags) { + return ; } // if the required calls fails then return a something went wrong error @@ -345,7 +356,11 @@ function App(): JSX.Element { } // if all of the data is not set then return a spinner, this is required because there is some gap between loading states and data setting - if ((!licenses || !user.email || !featureFlags) && !userFetchError) { + if ( + (!activeLicense || !user.email || !featureFlags) && + !userFetchError && + !activeLicenseFetchError + ) { return ; } } @@ -355,37 +370,42 @@ function App(): JSX.Element { - - - - - - - - - - }> - - {routes.map(({ path, component, exact }) => ( - - ))} - - - - - - - - - - - - + + + + + + + + + + + + + }> + + {routes.map(({ path, component, exact }) => ( + + ))} + + + + + + + + + + + + + + diff --git a/frontend/src/AppRoutes/pageComponents.ts b/frontend/src/AppRoutes/pageComponents.ts index 80a481ca330..c2833547f49 100644 --- a/frontend/src/AppRoutes/pageComponents.ts +++ b/frontend/src/AppRoutes/pageComponents.ts @@ -128,12 +128,7 @@ export const AlertOverview = Loadable( ); export const CreateAlertChannelAlerts = Loadable( - () => - import(/* webpackChunkName: "Create Channels" */ 'pages/AlertChannelCreate'), -); - -export const EditAlertChannelsAlerts = Loadable( - () => import(/* webpackChunkName: "Edit Channels" */ 'pages/ChannelsEdit'), + () => import(/* webpackChunkName: "Create Channels" */ 'pages/Settings'), ); export const AllAlertChannels = Loadable( @@ -165,7 +160,7 @@ export const APIKeys = Loadable( ); export const MySettings = Loadable( - () => import(/* webpackChunkName: "All MySettings" */ 'pages/MySettings'), + () => import(/* webpackChunkName: "All MySettings" */ 'pages/Settings'), ); export const CustomDomainSettings = Loadable( @@ -222,7 +217,7 @@ export const LogsIndexToFields = Loadable( ); export const BillingPage = Loadable( - () => import(/* webpackChunkName: "BillingPage" */ 'pages/Billing'), + () => import(/* webpackChunkName: "BillingPage" */ 'pages/Settings'), ); export const SupportPage = Loadable( @@ -249,7 +244,7 @@ export const WorkspaceAccessRestricted = Loadable( ); export const ShortcutsPage = Loadable( - () => import(/* webpackChunkName: "ShortcutsPage" */ 'pages/Shortcuts'), + () => import(/* webpackChunkName: "ShortcutsPage" */ 'pages/Settings'), ); export const InstalledIntegrations = Loadable( diff --git a/frontend/src/AppRoutes/routes.ts b/frontend/src/AppRoutes/routes.ts index 5cf14278c2b..9553c1096ac 100644 --- a/frontend/src/AppRoutes/routes.ts +++ b/frontend/src/AppRoutes/routes.ts @@ -1,5 +1,6 @@ import ROUTES from 'constants/routes'; import MessagingQueues from 'pages/MessagingQueues'; +import MeterExplorer from 'pages/MeterExplorer'; import { RouteProps } from 'react-router-dom'; import { @@ -7,20 +8,15 @@ import { AlertOverview, AllAlertChannels, AllErrors, - APIKeys, ApiMonitoring, - BillingPage, CreateAlertChannelAlerts, CreateNewAlerts, - CustomDomainSettings, DashboardPage, DashboardWidget, - EditAlertChannelsAlerts, EditRulesPage, ErrorDetails, Home, InfrastructureMonitoring, - IngestionSettings, InstalledIntegrations, LicensePage, ListAllALertsPage, @@ -31,12 +27,10 @@ import { LogsIndexToFields, LogsSaveViews, MetricsExplorer, - MySettings, NewDashboardPage, OldLogsExplorer, Onboarding, OnboardingV2, - OrganizationSettings, OrgOnboarding, PasswordReset, PipelinePage, @@ -45,7 +39,6 @@ import { ServicesTablePage, ServiceTopLevelOperationsPage, SettingsPage, - ShortcutsPage, SignupPage, SomethingWentWrong, StatusPage, @@ -150,7 +143,7 @@ const routes: AppRoutes[] = [ }, { path: ROUTES.SETTINGS, - exact: true, + exact: false, component: SettingsPage, isPrivate: true, key: 'SETTINGS', @@ -260,13 +253,6 @@ const routes: AppRoutes[] = [ isPrivate: true, key: 'CHANNELS_NEW', }, - { - path: ROUTES.CHANNELS_EDIT, - exact: true, - component: EditAlertChannelsAlerts, - isPrivate: true, - key: 'CHANNELS_EDIT', - }, { path: ROUTES.ALL_CHANNELS, exact: true, @@ -295,41 +281,6 @@ const routes: AppRoutes[] = [ isPrivate: true, key: 'VERSION', }, - { - path: ROUTES.ORG_SETTINGS, - exact: true, - component: OrganizationSettings, - isPrivate: true, - key: 'ORG_SETTINGS', - }, - { - path: ROUTES.INGESTION_SETTINGS, - exact: true, - component: IngestionSettings, - isPrivate: true, - key: 'INGESTION_SETTINGS', - }, - { - path: ROUTES.API_KEYS, - exact: true, - component: APIKeys, - isPrivate: true, - key: 'API_KEYS', - }, - { - path: ROUTES.MY_SETTINGS, - exact: true, - component: MySettings, - isPrivate: true, - key: 'MY_SETTINGS', - }, - { - path: ROUTES.CUSTOM_DOMAIN_SETTINGS, - exact: true, - component: CustomDomainSettings, - isPrivate: true, - key: 'CUSTOM_DOMAIN_SETTINGS', - }, { path: ROUTES.LOGS, exact: true, @@ -393,13 +344,6 @@ const routes: AppRoutes[] = [ key: 'SOMETHING_WENT_WRONG', isPrivate: false, }, - { - path: ROUTES.BILLING, - exact: true, - component: BillingPage, - key: 'BILLING', - isPrivate: true, - }, { path: ROUTES.WORKSPACE_LOCKED, exact: true, @@ -421,13 +365,6 @@ const routes: AppRoutes[] = [ isPrivate: true, key: 'WORKSPACE_ACCESS_RESTRICTED', }, - { - path: ROUTES.SHORTCUTS, - exact: true, - component: ShortcutsPage, - isPrivate: true, - key: 'SHORTCUTS', - }, { path: ROUTES.INTEGRATIONS, exact: true, @@ -498,6 +435,28 @@ const routes: AppRoutes[] = [ key: 'METRICS_EXPLORER_VIEWS', isPrivate: true, }, + + { + path: ROUTES.METER, + exact: true, + component: MeterExplorer, + key: 'METER', + isPrivate: true, + }, + { + path: ROUTES.METER_EXPLORER, + exact: true, + component: MeterExplorer, + key: 'METER_EXPLORER', + isPrivate: true, + }, + { + path: ROUTES.METER_EXPLORER_VIEWS, + exact: true, + component: MeterExplorer, + key: 'METER_EXPLORER_VIEWS', + isPrivate: true, + }, { path: ROUTES.API_MONITORING, exact: true, diff --git a/frontend/src/api/APIKeys/createAPIKey.ts b/frontend/src/api/APIKeys/createAPIKey.ts deleted file mode 100644 index 2b219a01662..00000000000 --- a/frontend/src/api/APIKeys/createAPIKey.ts +++ /dev/null @@ -1,26 +0,0 @@ -import axios from 'api'; -import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; -import { AxiosError } from 'axios'; -import { ErrorResponse, SuccessResponse } from 'types/api'; -import { APIKeyProps, CreateAPIKeyProps } from 'types/api/pat/types'; - -const createAPIKey = async ( - props: CreateAPIKeyProps, -): Promise | ErrorResponse> => { - try { - const response = await axios.post('/pats', { - ...props, - }); - - return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data.data, - }; - } catch (error) { - return ErrorResponseHandler(error as AxiosError); - } -}; - -export default createAPIKey; diff --git a/frontend/src/api/APIKeys/deleteAPIKey.ts b/frontend/src/api/APIKeys/deleteAPIKey.ts deleted file mode 100644 index 03b8d595dad..00000000000 --- a/frontend/src/api/APIKeys/deleteAPIKey.ts +++ /dev/null @@ -1,24 +0,0 @@ -import axios from 'api'; -import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; -import { AxiosError } from 'axios'; -import { ErrorResponse, SuccessResponse } from 'types/api'; -import { AllAPIKeyProps } from 'types/api/pat/types'; - -const deleteAPIKey = async ( - id: string, -): Promise | ErrorResponse> => { - try { - const response = await axios.delete(`/pats/${id}`); - - return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data.data, - }; - } catch (error) { - return ErrorResponseHandler(error as AxiosError); - } -}; - -export default deleteAPIKey; diff --git a/frontend/src/api/APIKeys/getAPIKey.ts b/frontend/src/api/APIKeys/getAPIKey.ts deleted file mode 100644 index c0410d873f0..00000000000 --- a/frontend/src/api/APIKeys/getAPIKey.ts +++ /dev/null @@ -1,24 +0,0 @@ -import axios from 'api'; -import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; -import { AxiosError } from 'axios'; -import { ErrorResponse, SuccessResponse } from 'types/api'; -import { PayloadProps, Props } from 'types/api/alerts/get'; - -const get = async ( - props: Props, -): Promise | ErrorResponse> => { - try { - const response = await axios.get(`/pats/${props.id}`); - - return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data, - }; - } catch (error) { - return ErrorResponseHandler(error as AxiosError); - } -}; - -export default get; diff --git a/frontend/src/api/APIKeys/getAllAPIKeys.ts b/frontend/src/api/APIKeys/getAllAPIKeys.ts deleted file mode 100644 index 488d9dc5cfe..00000000000 --- a/frontend/src/api/APIKeys/getAllAPIKeys.ts +++ /dev/null @@ -1,6 +0,0 @@ -import axios from 'api'; -import { AxiosResponse } from 'axios'; -import { AllAPIKeyProps } from 'types/api/pat/types'; - -export const getAllAPIKeys = (): Promise> => - axios.get(`/pats`); diff --git a/frontend/src/api/APIKeys/updateAPIKey.ts b/frontend/src/api/APIKeys/updateAPIKey.ts deleted file mode 100644 index 38d20227a3b..00000000000 --- a/frontend/src/api/APIKeys/updateAPIKey.ts +++ /dev/null @@ -1,26 +0,0 @@ -import axios from 'api'; -import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; -import { AxiosError } from 'axios'; -import { ErrorResponse, SuccessResponse } from 'types/api'; -import { PayloadProps, UpdateAPIKeyProps } from 'types/api/pat/types'; - -const updateAPIKey = async ( - props: UpdateAPIKeyProps, -): Promise | ErrorResponse> => { - try { - const response = await axios.put(`/pats/${props.id}`, { - ...props.data, - }); - - return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data.data, - }; - } catch (error) { - return ErrorResponseHandler(error as AxiosError); - } -}; - -export default updateAPIKey; diff --git a/frontend/src/api/SAML/deleteDomain.ts b/frontend/src/api/SAML/deleteDomain.ts deleted file mode 100644 index 50c2b51a807..00000000000 --- a/frontend/src/api/SAML/deleteDomain.ts +++ /dev/null @@ -1,24 +0,0 @@ -import axios from 'api'; -import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; -import { AxiosError } from 'axios'; -import { ErrorResponse, SuccessResponse } from 'types/api'; -import { PayloadProps, Props } from 'types/api/SAML/deleteDomain'; - -const deleteDomain = async ( - props: Props, -): Promise | ErrorResponse> => { - try { - const response = await axios.delete(`/domains/${props.id}`); - - return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data.data, - }; - } catch (error) { - return ErrorResponseHandler(error as AxiosError); - } -}; - -export default deleteDomain; diff --git a/frontend/src/api/SAML/listAllDomain.ts b/frontend/src/api/SAML/listAllDomain.ts deleted file mode 100644 index 41620f7d3e5..00000000000 --- a/frontend/src/api/SAML/listAllDomain.ts +++ /dev/null @@ -1,24 +0,0 @@ -import axios from 'api'; -import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; -import { AxiosError } from 'axios'; -import { ErrorResponse, SuccessResponse } from 'types/api'; -import { PayloadProps, Props } from 'types/api/SAML/listDomain'; - -const listAllDomain = async ( - props: Props, -): Promise | ErrorResponse> => { - try { - const response = await axios.get(`/orgs/${props.orgId}/domains`); - - return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data.data, - }; - } catch (error) { - return ErrorResponseHandler(error as AxiosError); - } -}; - -export default listAllDomain; diff --git a/frontend/src/api/SAML/postDomain.ts b/frontend/src/api/SAML/postDomain.ts deleted file mode 100644 index 34a8ecd1f79..00000000000 --- a/frontend/src/api/SAML/postDomain.ts +++ /dev/null @@ -1,24 +0,0 @@ -import axios from 'api'; -import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; -import { AxiosError } from 'axios'; -import { ErrorResponse, SuccessResponse } from 'types/api'; -import { PayloadProps, Props } from 'types/api/SAML/postDomain'; - -const postDomain = async ( - props: Props, -): Promise | ErrorResponse> => { - try { - const response = await axios.post(`/domains`, props); - - return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data.data, - }; - } catch (error) { - return ErrorResponseHandler(error as AxiosError); - } -}; - -export default postDomain; diff --git a/frontend/src/api/SAML/updateDomain.ts b/frontend/src/api/SAML/updateDomain.ts deleted file mode 100644 index 0c4cce83af0..00000000000 --- a/frontend/src/api/SAML/updateDomain.ts +++ /dev/null @@ -1,24 +0,0 @@ -import axios from 'api'; -import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; -import { AxiosError } from 'axios'; -import { ErrorResponse, SuccessResponse } from 'types/api'; -import { PayloadProps, Props } from 'types/api/SAML/updateDomain'; - -const updateDomain = async ( - props: Props, -): Promise | ErrorResponse> => { - try { - const response = await axios.put(`/domains/${props.id}`, props); - - return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data.data, - }; - } catch (error) { - return ErrorResponseHandler(error as AxiosError); - } -}; - -export default updateDomain; diff --git a/frontend/src/api/alerts/createAlertRule.ts b/frontend/src/api/alerts/createAlertRule.ts new file mode 100644 index 00000000000..f993a244cfe --- /dev/null +++ b/frontend/src/api/alerts/createAlertRule.ts @@ -0,0 +1,28 @@ +import axios from 'api'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { + AlertRuleV2, + PostableAlertRuleV2, +} from 'types/api/alerts/alertTypesV2'; + +export interface CreateAlertRuleResponse { + data: AlertRuleV2; + status: string; +} + +const createAlertRule = async ( + props: PostableAlertRuleV2, +): Promise | ErrorResponse> => { + const response = await axios.post(`/rules`, { + ...props, + }); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data, + }; +}; + +export default createAlertRule; diff --git a/frontend/src/api/alerts/testAlertRule.ts b/frontend/src/api/alerts/testAlertRule.ts new file mode 100644 index 00000000000..6b2502f325c --- /dev/null +++ b/frontend/src/api/alerts/testAlertRule.ts @@ -0,0 +1,28 @@ +import axios from 'api'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2'; + +export interface TestAlertRuleResponse { + data: { + alertCount: number; + message: string; + }; + status: string; +} + +const testAlertRule = async ( + props: PostableAlertRuleV2, +): Promise | ErrorResponse> => { + const response = await axios.post(`/testRule`, { + ...props, + }); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data, + }; +}; + +export default testAlertRule; diff --git a/frontend/src/api/alerts/updateAlertRule.ts b/frontend/src/api/alerts/updateAlertRule.ts new file mode 100644 index 00000000000..6553d685338 --- /dev/null +++ b/frontend/src/api/alerts/updateAlertRule.ts @@ -0,0 +1,26 @@ +import axios from 'api'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2'; + +export interface UpdateAlertRuleResponse { + data: string; + status: string; +} + +const updateAlertRule = async ( + id: string, + postableAlertRule: PostableAlertRuleV2, +): Promise | ErrorResponse> => { + const response = await axios.put(`/rules/${id}`, { + ...postableAlertRule, + }); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data, + }; +}; + +export default updateAlertRule; diff --git a/frontend/src/api/apiV1.ts b/frontend/src/api/apiV1.ts index abd7d701a4c..f7f15f0cce7 100644 --- a/frontend/src/api/apiV1.ts +++ b/frontend/src/api/apiV1.ts @@ -3,6 +3,7 @@ const apiV1 = '/api/v1/'; export const apiV2 = '/api/v2/'; export const apiV3 = '/api/v3/'; export const apiV4 = '/api/v4/'; +export const apiV5 = '/api/v5/'; export const gatewayApiV1 = '/api/gateway/v1/'; export const gatewayApiV2 = '/api/gateway/v2/'; export const apiAlertManager = '/api/alertmanager/'; diff --git a/frontend/src/api/billing/checkout.ts b/frontend/src/api/billing/checkout.ts deleted file mode 100644 index f8eaf397486..00000000000 --- a/frontend/src/api/billing/checkout.ts +++ /dev/null @@ -1,29 +0,0 @@ -import axios from 'api'; -import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; -import { AxiosError } from 'axios'; -import { ErrorResponse, SuccessResponse } from 'types/api'; -import { - CheckoutRequestPayloadProps, - CheckoutSuccessPayloadProps, -} from 'types/api/billing/checkout'; - -const updateCreditCardApi = async ( - props: CheckoutRequestPayloadProps, -): Promise | ErrorResponse> => { - try { - const response = await axios.post('/checkout', { - url: props.url, - }); - - return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data.data, - }; - } catch (error) { - return ErrorResponseHandler(error as AxiosError); - } -}; - -export default updateCreditCardApi; diff --git a/frontend/src/api/billing/manage.ts b/frontend/src/api/billing/manage.ts deleted file mode 100644 index 1ea8fa762d3..00000000000 --- a/frontend/src/api/billing/manage.ts +++ /dev/null @@ -1,29 +0,0 @@ -import axios from 'api'; -import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; -import { AxiosError } from 'axios'; -import { ErrorResponse, SuccessResponse } from 'types/api'; -import { - CheckoutRequestPayloadProps, - CheckoutSuccessPayloadProps, -} from 'types/api/billing/checkout'; - -const manageCreditCardApi = async ( - props: CheckoutRequestPayloadProps, -): Promise | ErrorResponse> => { - try { - const response = await axios.post('/portal', { - url: props.url, - }); - - return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data.data, - }; - } catch (error) { - return ErrorResponseHandler(error as AxiosError); - } -}; - -export default manageCreditCardApi; diff --git a/frontend/src/api/changelog/getChangelogByVersion.ts b/frontend/src/api/changelog/getChangelogByVersion.ts new file mode 100644 index 00000000000..b9b3f1f9c93 --- /dev/null +++ b/frontend/src/api/changelog/getChangelogByVersion.ts @@ -0,0 +1,47 @@ +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import axios, { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { + ChangelogSchema, + DeploymentType, +} from 'types/api/changelog/getChangelogByVersion'; + +const getChangelogByVersion = async ( + versionId: string, + deployment_type?: DeploymentType, +): Promise | ErrorResponse> => { + try { + let queryParams = `filters[version][$eq]=${versionId}&populate[features][sort]=sort_order:asc&populate[features][populate][media][fields]=id,ext,url,mime,alternativeText`; + + if ( + deployment_type && + Object.values(DeploymentType).includes(deployment_type) + ) { + const excludedDeploymentType = + deployment_type === DeploymentType.CLOUD_ONLY + ? DeploymentType.OSS_ONLY + : DeploymentType.CLOUD_ONLY; + + queryParams = `${queryParams}&populate[features][filters][deployment_type][$notIn]=${excludedDeploymentType}`; + } + + const response = await axios.get(` + https://cms.signoz.cloud/api/release-changelogs?${queryParams} + `); + + if (!Array.isArray(response.data.data) || response.data.data.length === 0) { + throw new Error('No changelog found!'); + } + + return { + statusCode: 200, + error: null, + message: response.statusText, + payload: response.data.data[0], + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default getChangelogByVersion; diff --git a/frontend/src/api/dashboard/create.ts b/frontend/src/api/dashboard/create.ts deleted file mode 100644 index bf5458ac40a..00000000000 --- a/frontend/src/api/dashboard/create.ts +++ /dev/null @@ -1,27 +0,0 @@ -import axios from 'api'; -import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; -import { AxiosError } from 'axios'; -import { ErrorResponse, SuccessResponse } from 'types/api'; -import { PayloadProps, Props } from 'types/api/dashboard/create'; - -const createDashboard = async ( - props: Props, -): Promise | ErrorResponse> => { - const url = props.uploadedGrafana ? '/dashboards/grafana' : '/dashboards'; - try { - const response = await axios.post(url, { - ...props, - }); - - return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data.data, - }; - } catch (error) { - return ErrorResponseHandler(error as AxiosError); - } -}; - -export default createDashboard; diff --git a/frontend/src/api/dashboard/delete.ts b/frontend/src/api/dashboard/delete.ts deleted file mode 100644 index 8faf7113830..00000000000 --- a/frontend/src/api/dashboard/delete.ts +++ /dev/null @@ -1,9 +0,0 @@ -import axios from 'api'; -import { PayloadProps, Props } from 'types/api/dashboard/delete'; - -const deleteDashboard = (props: Props): Promise => - axios - .delete(`/dashboards/${props.uuid}`) - .then((response) => response.data); - -export default deleteDashboard; diff --git a/frontend/src/api/dashboard/get.ts b/frontend/src/api/dashboard/get.ts deleted file mode 100644 index 01e04c6c0f4..00000000000 --- a/frontend/src/api/dashboard/get.ts +++ /dev/null @@ -1,11 +0,0 @@ -import axios from 'api'; -import { ApiResponse } from 'types/api'; -import { Props } from 'types/api/dashboard/get'; -import { Dashboard } from 'types/api/dashboard/getAll'; - -const getDashboard = (props: Props): Promise => - axios - .get>(`/dashboards/${props.uuid}`) - .then((res) => res.data.data); - -export default getDashboard; diff --git a/frontend/src/api/dashboard/getAll.ts b/frontend/src/api/dashboard/getAll.ts deleted file mode 100644 index aafe44b3ed1..00000000000 --- a/frontend/src/api/dashboard/getAll.ts +++ /dev/null @@ -1,8 +0,0 @@ -import axios from 'api'; -import { ApiResponse } from 'types/api'; -import { Dashboard } from 'types/api/dashboard/getAll'; - -export const getAllDashboardList = (): Promise => - axios - .get>('/dashboards') - .then((res) => res.data.data); diff --git a/frontend/src/api/dashboard/lockDashboard.ts b/frontend/src/api/dashboard/lockDashboard.ts deleted file mode 100644 index 3393de8fa3a..00000000000 --- a/frontend/src/api/dashboard/lockDashboard.ts +++ /dev/null @@ -1,11 +0,0 @@ -import axios from 'api'; -import { AxiosResponse } from 'axios'; - -interface LockDashboardProps { - uuid: string; -} - -const lockDashboard = (props: LockDashboardProps): Promise => - axios.put(`/dashboards/${props.uuid}/lock`); - -export default lockDashboard; diff --git a/frontend/src/api/dashboard/substitute_vars.ts b/frontend/src/api/dashboard/substitute_vars.ts new file mode 100644 index 00000000000..2ab57b2e42c --- /dev/null +++ b/frontend/src/api/dashboard/substitute_vars.ts @@ -0,0 +1,34 @@ +import { ApiV5Instance } from 'api'; +import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; +import { QueryRangePayloadV5 } from 'api/v5/v5'; +import { AxiosError } from 'axios'; +import { ErrorV2Resp, SuccessResponseV2 } from 'types/api'; +import { ICompositeMetricQuery } from 'types/api/alerts/compositeQuery'; + +interface ISubstituteVars { + compositeQuery: ICompositeMetricQuery; +} + +export const getSubstituteVars = async ( + props?: Partial, + signal?: AbortSignal, + headers?: Record, +): Promise> => { + try { + const response = await ApiV5Instance.post<{ data: ISubstituteVars }>( + '/substitute_vars', + props, + { + signal, + headers, + }, + ); + + return { + httpStatusCode: response.status, + data: response.data.data, + }; + } catch (error) { + ErrorResponseHandlerV2(error as AxiosError); + } +}; diff --git a/frontend/src/api/dashboard/unlockDashboard.ts b/frontend/src/api/dashboard/unlockDashboard.ts deleted file mode 100644 index fd4ffbe41a0..00000000000 --- a/frontend/src/api/dashboard/unlockDashboard.ts +++ /dev/null @@ -1,11 +0,0 @@ -import axios from 'api'; -import { AxiosResponse } from 'axios'; - -interface UnlockDashboardProps { - uuid: string; -} - -const unlockDashboard = (props: UnlockDashboardProps): Promise => - axios.put(`/dashboards/${props.uuid}/unlock`); - -export default unlockDashboard; diff --git a/frontend/src/api/dashboard/update.ts b/frontend/src/api/dashboard/update.ts deleted file mode 100644 index 21216e051f3..00000000000 --- a/frontend/src/api/dashboard/update.ts +++ /dev/null @@ -1,20 +0,0 @@ -import axios from 'api'; -import { ErrorResponse, SuccessResponse } from 'types/api'; -import { PayloadProps, Props } from 'types/api/dashboard/update'; - -const updateDashboard = async ( - props: Props, -): Promise | ErrorResponse> => { - const response = await axios.put(`/dashboards/${props.uuid}`, { - ...props.data, - }); - - return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data.data, - }; -}; - -export default updateDashboard; diff --git a/frontend/src/api/dynamicVariables/__tests__/getFieldKeys.test.ts b/frontend/src/api/dynamicVariables/__tests__/getFieldKeys.test.ts new file mode 100644 index 00000000000..c94c915b622 --- /dev/null +++ b/frontend/src/api/dynamicVariables/__tests__/getFieldKeys.test.ts @@ -0,0 +1,115 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +import { ApiBaseInstance } from 'api'; + +import { getFieldKeys } from '../getFieldKeys'; + +// Mock the API instance +jest.mock('api', () => ({ + ApiBaseInstance: { + get: jest.fn(), + }, +})); + +describe('getFieldKeys API', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const mockSuccessResponse = { + status: 200, + data: { + status: 'success', + data: { + keys: { + 'service.name': [], + 'http.status_code': [], + }, + complete: true, + }, + }, + }; + + it('should call API with correct parameters when no args provided', async () => { + // Mock successful API response + (ApiBaseInstance.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', { + params: {}, + }); + }); + + it('should call API with signal parameter when provided', async () => { + // Mock successful API response + (ApiBaseInstance.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', { + params: { signal: 'traces' }, + }); + }); + + it('should call API with name parameter when provided', async () => { + // Mock successful API response + (ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce({ + status: 200, + data: { + status: 'success', + data: { + keys: { service: [] }, + complete: false, + }, + }, + }); + + // Call function with name parameter + await getFieldKeys(undefined, 'service'); + + // Verify API was called with name parameter + expect(ApiBaseInstance.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({ + status: 200, + data: { + status: 'success', + data: { + keys: { service: [] }, + complete: false, + }, + }, + }); + + // Call function with both parameters + await getFieldKeys('logs', 'service'); + + // Verify API was called with both parameters + expect(ApiBaseInstance.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); + + // Call the function + const result = await getFieldKeys('traces'); + + // Verify the returned structure matches SuccessResponseV2 format + expect(result).toEqual({ + httpStatusCode: 200, + data: mockSuccessResponse.data.data, + }); + }); +}); diff --git a/frontend/src/api/dynamicVariables/__tests__/getFieldValues.test.ts b/frontend/src/api/dynamicVariables/__tests__/getFieldValues.test.ts new file mode 100644 index 00000000000..896e939e9a4 --- /dev/null +++ b/frontend/src/api/dynamicVariables/__tests__/getFieldValues.test.ts @@ -0,0 +1,214 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +import { ApiBaseInstance } from 'api'; + +import { getFieldValues } from '../getFieldValues'; + +// Mock the API instance +jest.mock('api', () => ({ + ApiBaseInstance: { + get: jest.fn(), + }, +})); + +describe('getFieldValues API', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should call the API with correct parameters (no options)', async () => { + // Mock API response + (ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce({ + status: 200, + data: { + status: 'success', + data: { + values: { + stringValues: ['frontend', 'backend'], + }, + complete: true, + }, + }, + }); + + // Call function without parameters + await getFieldValues(); + + // Verify API was called correctly with empty params + expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/values', { + params: {}, + }); + }); + + it('should call the API with signal parameter', async () => { + // Mock API response + (ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce({ + status: 200, + data: { + status: 'success', + data: { + values: { + stringValues: ['frontend', 'backend'], + }, + complete: true, + }, + }, + }); + + // Call function with signal parameter + await getFieldValues('traces'); + + // Verify API was called with signal parameter + expect(ApiBaseInstance.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({ + status: 200, + data: { + status: 'success', + data: { + values: { + stringValues: ['frontend', 'backend'], + }, + complete: true, + }, + }, + }); + + // Call function with name parameter + await getFieldValues(undefined, 'service.name'); + + // Verify API was called with name parameter + expect(ApiBaseInstance.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({ + status: 200, + data: { + status: 'success', + data: { + values: { + stringValues: ['frontend'], + }, + complete: false, + }, + }, + }); + + // Call function with value parameter + await getFieldValues(undefined, 'service.name', 'front'); + + // Verify API was called with value parameter + expect(ApiBaseInstance.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({ + status: 200, + data: { + status: 'success', + data: { + values: { + stringValues: ['frontend', 'backend'], + }, + complete: true, + }, + }, + }); + + // Call function with time range parameters + const startUnixMilli = 1625097600000000; // Note: nanoseconds + const endUnixMilli = 1625184000000000; + await getFieldValues( + 'logs', + 'service.name', + undefined, + startUnixMilli, + endUnixMilli, + ); + + // Verify API was called with time range parameters (converted to milliseconds) + expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/values', { + params: { + signal: 'logs', + name: 'service.name', + startUnixMilli: '1625097600', // Should be converted to seconds (divided by 1000000) + endUnixMilli: '1625184000', // Should be converted to seconds (divided by 1000000) + }, + }); + }); + + it('should normalize the response values', async () => { + // Mock API response with multiple value types + const mockResponse = { + status: 200, + data: { + status: 'success', + data: { + values: { + stringValues: ['frontend', 'backend'], + numberValues: [200, 404], + boolValues: [true, false], + }, + complete: true, + }, + }, + }; + + (ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce(mockResponse); + + // Call the function + const result = await getFieldValues('traces', 'mixed.values'); + + // Verify the response has normalized values array + expect(result.data?.normalizedValues).toContain('frontend'); + expect(result.data?.normalizedValues).toContain('backend'); + expect(result.data?.normalizedValues).toContain('200'); + expect(result.data?.normalizedValues).toContain('404'); + expect(result.data?.normalizedValues).toContain('true'); + expect(result.data?.normalizedValues).toContain('false'); + expect(result.data?.normalizedValues?.length).toBe(6); + }); + + it('should return a properly formatted success response', async () => { + // Create mock response + const mockApiResponse = { + status: 200, + data: { + status: 'success', + data: { + values: { + stringValues: ['frontend', 'backend'], + }, + complete: true, + }, + }, + }; + + // Mock API to return our response + (ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce(mockApiResponse); + + // Call the function + const result = await getFieldValues('traces', 'service.name'); + + // Verify the returned structure matches SuccessResponseV2 format + expect(result).toEqual({ + httpStatusCode: 200, + data: expect.objectContaining({ + values: expect.any(Object), + normalizedValues: expect.any(Array), + complete: true, + }), + }); + }); +}); diff --git a/frontend/src/api/dynamicVariables/getFieldKeys.ts b/frontend/src/api/dynamicVariables/getFieldKeys.ts new file mode 100644 index 00000000000..12bfdf1d1e6 --- /dev/null +++ b/frontend/src/api/dynamicVariables/getFieldKeys.ts @@ -0,0 +1,38 @@ +import { ApiBaseInstance } from 'api'; +import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; +import { AxiosError } from 'axios'; +import { ErrorV2Resp, SuccessResponseV2 } from 'types/api'; +import { FieldKeyResponse } from 'types/api/dynamicVariables/getFieldKeys'; + +/** + * Get field keys for a given signal type + * @param signal Type of signal (traces, logs, metrics) + * @param name Optional search text + */ +export const getFieldKeys = async ( + signal?: 'traces' | 'logs' | 'metrics', + name?: string, +): Promise> => { + const params: Record = {}; + + if (signal) { + params.signal = encodeURIComponent(signal); + } + + if (name) { + params.name = encodeURIComponent(name); + } + + try { + const response = await ApiBaseInstance.get('/fields/keys', { params }); + + return { + httpStatusCode: response.status, + data: response.data.data, + }; + } catch (error) { + ErrorResponseHandlerV2(error as AxiosError); + } +}; + +export default getFieldKeys; diff --git a/frontend/src/api/dynamicVariables/getFieldValues.ts b/frontend/src/api/dynamicVariables/getFieldValues.ts new file mode 100644 index 00000000000..6e0c60ec2b2 --- /dev/null +++ b/frontend/src/api/dynamicVariables/getFieldValues.ts @@ -0,0 +1,87 @@ +/* eslint-disable sonarjs/cognitive-complexity */ +import { ApiBaseInstance } from 'api'; +import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; +import { AxiosError } from 'axios'; +import { ErrorV2Resp, SuccessResponseV2 } from 'types/api'; +import { FieldValueResponse } from 'types/api/dynamicVariables/getFieldValues'; + +/** + * Get field values for a given signal type and field name + * @param signal Type of signal (traces, logs, metrics) + * @param name Name of the attribute for which values are being fetched + * @param value Optional search text + * @param existingQuery Optional existing query - across all present dynamic variables + */ +export const getFieldValues = async ( + signal?: 'traces' | 'logs' | 'metrics', + name?: string, + searchText?: string, + startUnixMilli?: number, + endUnixMilli?: number, + existingQuery?: string, +): Promise> => { + const params: Record = {}; + + if (signal) { + params.signal = encodeURIComponent(signal); + } + + if (name) { + params.name = encodeURIComponent(name); + } + + if (searchText) { + params.searchText = encodeURIComponent(searchText); + } + + if (startUnixMilli) { + params.startUnixMilli = Math.floor(startUnixMilli / 1000000).toString(); + } + + if (endUnixMilli) { + params.endUnixMilli = Math.floor(endUnixMilli / 1000000).toString(); + } + + if (existingQuery) { + params.existingQuery = existingQuery; + } + + try { + const response = await ApiBaseInstance.get('/fields/values', { params }); + + // Normalize values from different types (stringValues, boolValues, etc.) + if (response.data?.data?.values) { + const allValues: string[] = []; + Object.entries(response.data?.data?.values).forEach( + ([key, valueArray]: [string, any]) => { + // Skip RelatedValues as they should be kept separate + if (key === 'relatedValues') { + return; + } + + if (Array.isArray(valueArray)) { + allValues.push(...valueArray.map(String)); + } + }, + ); + + // Add a normalized values array to the response + response.data.data.normalizedValues = allValues; + + // Add relatedValues to the response as per FieldValueResponse + if (response.data?.data?.values?.relatedValues) { + response.data.data.relatedValues = + response.data?.data?.values?.relatedValues; + } + } + + return { + httpStatusCode: response.status, + data: response.data.data, + }; + } catch (error) { + ErrorResponseHandlerV2(error as AxiosError); + } +}; + +export default getFieldValues; diff --git a/frontend/src/api/features/getFeatureFlags.ts b/frontend/src/api/features/getFeatureFlags.ts deleted file mode 100644 index 2ce37b99e15..00000000000 --- a/frontend/src/api/features/getFeatureFlags.ts +++ /dev/null @@ -1,10 +0,0 @@ -import axios from 'api'; -import { ApiResponse } from 'types/api'; -import { FeatureFlagProps } from 'types/api/features/getFeaturesFlags'; - -const getFeaturesFlags = (): Promise => - axios - .get>(`/featureFlags`) - .then((response) => response.data.data); - -export default getFeaturesFlags; diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index f58e2405163..9e78b902212 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -4,7 +4,11 @@ import getLocalStorageApi from 'api/browser/localstorage/get'; import loginApi from 'api/v1/login/login'; import afterLogin from 'AppRoutes/utils'; -import axios, { AxiosResponse, InternalAxiosRequestConfig } from 'axios'; +import axios, { + AxiosError, + AxiosResponse, + InternalAxiosRequestConfig, +} from 'axios'; import { ENVIRONMENT } from 'constants/env'; import { Events } from 'constants/events'; import { LOCALSTORAGE } from 'constants/localStorage'; @@ -15,6 +19,7 @@ import apiV1, { apiV2, apiV3, apiV4, + apiV5, gatewayApiV1, gatewayApiV2, } from './apiV1'; @@ -83,24 +88,27 @@ const interceptorRejected = async ( true, ); - const reResponse = await axios( - `${value.config.baseURL}${value.config.url?.substring(1)}`, - { - method: value.config.method, - headers: { - ...value.config.headers, - Authorization: `Bearer ${response.data.accessJwt}`, + try { + const reResponse = await axios( + `${value.config.baseURL}${value.config.url?.substring(1)}`, + { + method: value.config.method, + headers: { + ...value.config.headers, + Authorization: `Bearer ${response.data.accessJwt}`, + }, + data: { + ...JSON.parse(value.config.data || '{}'), + }, }, - data: { - ...JSON.parse(value.config.data || '{}'), - }, - }, - ); - if (reResponse.status === 200) { + ); + return await Promise.resolve(reResponse); + } catch (error) { + if ((error as AxiosError)?.response?.status === 401) { + Logout(); + } } - Logout(); - return await Promise.reject(reResponse); } catch (error) { Logout(); } @@ -164,6 +172,18 @@ ApiV4Instance.interceptors.response.use( ApiV4Instance.interceptors.request.use(interceptorsRequestResponse); // +// axios V5 +export const ApiV5Instance = axios.create({ + baseURL: `${ENVIRONMENT.baseURL}${apiV5}`, +}); + +ApiV5Instance.interceptors.response.use( + interceptorsResponse, + interceptorRejected, +); +ApiV5Instance.interceptors.request.use(interceptorsRequestResponse); +// + // axios Base export const ApiBaseInstance = axios.create({ baseURL: `${ENVIRONMENT.baseURL}${apiV1}`, diff --git a/frontend/src/api/infraMonitoring/getK8sClustersList.ts b/frontend/src/api/infraMonitoring/getK8sClustersList.ts index 2da1b214b31..b08090ed2a2 100644 --- a/frontend/src/api/infraMonitoring/getK8sClustersList.ts +++ b/frontend/src/api/infraMonitoring/getK8sClustersList.ts @@ -5,6 +5,8 @@ import { ErrorResponse, SuccessResponse } from 'types/api'; import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; import { TagFilter } from 'types/api/queryBuilder/queryBuilderData'; +import { UnderscoreToDotMap } from '../utils'; + export interface K8sClustersListPayload { filters: TagFilter; groupBy?: BaseAutocompleteData[]; @@ -40,23 +42,80 @@ export interface K8sClustersListResponse { }; } +export const clustersMetaMap = [ + { dot: 'k8s.cluster.name', under: 'k8s_cluster_name' }, + { dot: 'k8s.cluster.uid', under: 'k8s_cluster_uid' }, +] as const; + +export function mapClustersMeta( + raw: Record, +): K8sClustersData['meta'] { + const out: Record = { ...raw }; + clustersMetaMap.forEach(({ dot, under }) => { + if (dot in raw) { + const v = raw[dot]; + out[under] = typeof v === 'string' ? v : raw[under]; + } + }); + return out as K8sClustersData['meta']; +} + export const getK8sClustersList = async ( props: K8sClustersListPayload, signal?: AbortSignal, headers?: Record, + dotMetricsEnabled = false, ): Promise | ErrorResponse> => { try { - const response = await axios.post('/clusters/list', props, { + const requestProps = + dotMetricsEnabled && Array.isArray(props.filters?.items) + ? { + ...props, + filters: { + ...props.filters, + items: props.filters.items.reduce( + (acc, item) => { + if (item.value === undefined) return acc; + if ( + item.key && + typeof item.key === 'object' && + 'key' in item.key && + typeof item.key.key === 'string' + ) { + const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key; + acc.push({ + ...item, + key: { ...item.key, key: mappedKey }, + }); + } else { + acc.push(item); + } + return acc; + }, + [] as typeof props.filters.items, + ), + }, + } + : props; + + const response = await axios.post('/clusters/list', requestProps, { signal, headers, }); + const payload: K8sClustersListResponse = response.data; + + // one-liner meta mapping + payload.data.records = payload.data.records.map((record) => ({ + ...record, + meta: mapClustersMeta(record.meta as Record), + })); return { statusCode: 200, error: null, message: 'Success', - payload: response.data, - params: props, + payload, + params: requestProps, }; } catch (error) { return ErrorResponseHandler(error as AxiosError); diff --git a/frontend/src/api/infraMonitoring/getK8sDaemonSetsList.ts b/frontend/src/api/infraMonitoring/getK8sDaemonSetsList.ts index c09de10580e..a35f8871eca 100644 --- a/frontend/src/api/infraMonitoring/getK8sDaemonSetsList.ts +++ b/frontend/src/api/infraMonitoring/getK8sDaemonSetsList.ts @@ -5,6 +5,8 @@ import { ErrorResponse, SuccessResponse } from 'types/api'; import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; import { TagFilter } from 'types/api/queryBuilder/queryBuilderData'; +import { UnderscoreToDotMap } from '../utils'; + export interface K8sDaemonSetsListPayload { filters: TagFilter; groupBy?: BaseAutocompleteData[]; @@ -46,23 +48,82 @@ export interface K8sDaemonSetsListResponse { }; } +export const daemonSetsMetaMap = [ + { dot: 'k8s.namespace.name', under: 'k8s_namespace_name' }, + { dot: 'k8s.daemonset.name', under: 'k8s_daemonset_name' }, + { dot: 'k8s.cluster.name', under: 'k8s_cluster_name' }, +] as const; + +export function mapDaemonSetsMeta( + raw: Record, +): K8sDaemonSetsData['meta'] { + const out: Record = { ...raw }; + daemonSetsMetaMap.forEach(({ dot, under }) => { + if (dot in raw) { + const v = raw[dot]; + out[under] = typeof v === 'string' ? v : raw[under]; + } + }); + return out as K8sDaemonSetsData['meta']; +} + export const getK8sDaemonSetsList = async ( props: K8sDaemonSetsListPayload, signal?: AbortSignal, headers?: Record, + dotMetricsEnabled = false, ): Promise | ErrorResponse> => { try { - const response = await axios.post('/daemonsets/list', props, { + // filter prep (unchanged)… + const requestProps = + dotMetricsEnabled && Array.isArray(props.filters?.items) + ? { + ...props, + filters: { + ...props.filters, + items: props.filters.items.reduce( + (acc, item) => { + if (item.value === undefined) return acc; + if ( + item.key && + typeof item.key === 'object' && + 'key' in item.key && + typeof item.key.key === 'string' + ) { + const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key; + acc.push({ + ...item, + key: { ...item.key, key: mappedKey }, + }); + } else { + acc.push(item); + } + return acc; + }, + [] as typeof props.filters.items, + ), + }, + } + : props; + + const response = await axios.post('/daemonsets/list', requestProps, { signal, headers, }); + const payload: K8sDaemonSetsListResponse = response.data; + + // single-line meta mapping + payload.data.records = payload.data.records.map((record) => ({ + ...record, + meta: mapDaemonSetsMeta(record.meta as Record), + })); return { statusCode: 200, error: null, message: 'Success', - payload: response.data, - params: props, + payload, + params: requestProps, }; } catch (error) { return ErrorResponseHandler(error as AxiosError); diff --git a/frontend/src/api/infraMonitoring/getK8sDeploymentsList.ts b/frontend/src/api/infraMonitoring/getK8sDeploymentsList.ts index b991ce22e7c..f39c62c88bc 100644 --- a/frontend/src/api/infraMonitoring/getK8sDeploymentsList.ts +++ b/frontend/src/api/infraMonitoring/getK8sDeploymentsList.ts @@ -5,6 +5,8 @@ import { ErrorResponse, SuccessResponse } from 'types/api'; import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; import { TagFilter } from 'types/api/queryBuilder/queryBuilderData'; +import { UnderscoreToDotMap } from '../utils'; + export interface K8sDeploymentsListPayload { filters: TagFilter; groupBy?: BaseAutocompleteData[]; @@ -46,23 +48,81 @@ export interface K8sDeploymentsListResponse { }; } +export const deploymentsMetaMap = [ + { dot: 'k8s.cluster.name', under: 'k8s_cluster_name' }, + { dot: 'k8s.deployment.name', under: 'k8s_deployment_name' }, + { dot: 'k8s.namespace.name', under: 'k8s_namespace_name' }, +] as const; + +export function mapDeploymentsMeta( + raw: Record, +): K8sDeploymentsData['meta'] { + const out: Record = { ...raw }; + deploymentsMetaMap.forEach(({ dot, under }) => { + if (dot in raw) { + const v = raw[dot]; + out[under] = typeof v === 'string' ? v : raw[under]; + } + }); + return out as K8sDeploymentsData['meta']; +} + export const getK8sDeploymentsList = async ( props: K8sDeploymentsListPayload, signal?: AbortSignal, headers?: Record, + dotMetricsEnabled = false, ): Promise | ErrorResponse> => { try { - const response = await axios.post('/deployments/list', props, { + const requestProps = + dotMetricsEnabled && Array.isArray(props.filters?.items) + ? { + ...props, + filters: { + ...props.filters, + items: props.filters.items.reduce( + (acc, item) => { + if (item.value === undefined) return acc; + if ( + item.key && + typeof item.key === 'object' && + 'key' in item.key && + typeof item.key.key === 'string' + ) { + const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key; + acc.push({ + ...item, + key: { ...item.key, key: mappedKey }, + }); + } else { + acc.push(item); + } + return acc; + }, + [] as typeof props.filters.items, + ), + }, + } + : props; + + const response = await axios.post('/deployments/list', requestProps, { signal, headers, }); + const payload: K8sDeploymentsListResponse = response.data; + + // single-line mapping + payload.data.records = payload.data.records.map((record) => ({ + ...record, + meta: mapDeploymentsMeta(record.meta as Record), + })); return { statusCode: 200, error: null, message: 'Success', - payload: response.data, - params: props, + payload, + params: requestProps, }; } catch (error) { return ErrorResponseHandler(error as AxiosError); diff --git a/frontend/src/api/infraMonitoring/getK8sJobsList.ts b/frontend/src/api/infraMonitoring/getK8sJobsList.ts index 36a6bb973dc..828e42a79de 100644 --- a/frontend/src/api/infraMonitoring/getK8sJobsList.ts +++ b/frontend/src/api/infraMonitoring/getK8sJobsList.ts @@ -5,6 +5,8 @@ import { ErrorResponse, SuccessResponse } from 'types/api'; import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; import { TagFilter } from 'types/api/queryBuilder/queryBuilderData'; +import { UnderscoreToDotMap } from '../utils'; + export interface K8sJobsListPayload { filters: TagFilter; groupBy?: BaseAutocompleteData[]; @@ -48,23 +50,79 @@ export interface K8sJobsListResponse { }; } +export const jobsMetaMap = [ + { dot: 'k8s.cluster.name', under: 'k8s_cluster_name' }, + { dot: 'k8s.job.name', under: 'k8s_job_name' }, + { dot: 'k8s.namespace.name', under: 'k8s_namespace_name' }, +] as const; + +export function mapJobsMeta(raw: Record): K8sJobsData['meta'] { + const out: Record = { ...raw }; + jobsMetaMap.forEach(({ dot, under }) => { + if (dot in raw) { + const v = raw[dot]; + out[under] = typeof v === 'string' ? v : raw[under]; + } + }); + return out as K8sJobsData['meta']; +} + export const getK8sJobsList = async ( props: K8sJobsListPayload, signal?: AbortSignal, headers?: Record, + dotMetricsEnabled = false, ): Promise | ErrorResponse> => { try { - const response = await axios.post('/jobs/list', props, { + const requestProps = + dotMetricsEnabled && Array.isArray(props.filters?.items) + ? { + ...props, + filters: { + ...props.filters, + items: props.filters.items.reduce( + (acc, item) => { + if (item.value === undefined) return acc; + if ( + item.key && + typeof item.key === 'object' && + 'key' in item.key && + typeof item.key.key === 'string' + ) { + const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key; + acc.push({ + ...item, + key: { ...item.key, key: mappedKey }, + }); + } else { + acc.push(item); + } + return acc; + }, + [] as typeof props.filters.items, + ), + }, + } + : props; + + const response = await axios.post('/jobs/list', requestProps, { signal, headers, }); + const payload: K8sJobsListResponse = response.data; + + // one-liner meta mapping + payload.data.records = payload.data.records.map((record) => ({ + ...record, + meta: mapJobsMeta(record.meta as Record), + })); return { statusCode: 200, error: null, message: 'Success', - payload: response.data, - params: props, + payload, + params: requestProps, }; } catch (error) { return ErrorResponseHandler(error as AxiosError); diff --git a/frontend/src/api/infraMonitoring/getK8sNamespacesList.ts b/frontend/src/api/infraMonitoring/getK8sNamespacesList.ts index bba2249f4e9..a492aac0453 100644 --- a/frontend/src/api/infraMonitoring/getK8sNamespacesList.ts +++ b/frontend/src/api/infraMonitoring/getK8sNamespacesList.ts @@ -5,6 +5,8 @@ import { ErrorResponse, SuccessResponse } from 'types/api'; import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; import { TagFilter } from 'types/api/queryBuilder/queryBuilderData'; +import { UnderscoreToDotMap } from '../utils'; + export interface K8sNamespacesListPayload { filters: TagFilter; groupBy?: BaseAutocompleteData[]; @@ -38,23 +40,79 @@ export interface K8sNamespacesListResponse { }; } +export const namespacesMetaMap = [ + { dot: 'k8s.cluster.name', under: 'k8s_cluster_name' }, + { dot: 'k8s.namespace.name', under: 'k8s_namespace_name' }, +] as const; + +export function mapNamespacesMeta( + raw: Record, +): K8sNamespacesData['meta'] { + const out: Record = { ...raw }; + namespacesMetaMap.forEach(({ dot, under }) => { + if (dot in raw) { + const v = raw[dot]; + out[under] = typeof v === 'string' ? v : raw[under]; + } + }); + return out as K8sNamespacesData['meta']; +} + export const getK8sNamespacesList = async ( props: K8sNamespacesListPayload, signal?: AbortSignal, headers?: Record, + dotMetricsEnabled = false, ): Promise | ErrorResponse> => { try { - const response = await axios.post('/namespaces/list', props, { + const requestProps = + dotMetricsEnabled && Array.isArray(props.filters?.items) + ? { + ...props, + filters: { + ...props.filters, + items: props.filters.items.reduce( + (acc, item) => { + if (item.value === undefined) return acc; + if ( + item.key && + typeof item.key === 'object' && + 'key' in item.key && + typeof item.key.key === 'string' + ) { + const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key; + acc.push({ + ...item, + key: { ...item.key, key: mappedKey }, + }); + } else { + acc.push(item); + } + return acc; + }, + [] as typeof props.filters.items, + ), + }, + } + : props; + + const response = await axios.post('/namespaces/list', requestProps, { signal, headers, }); + const payload: K8sNamespacesListResponse = response.data; + + payload.data.records = payload.data.records.map((record) => ({ + ...record, + meta: mapNamespacesMeta(record.meta as Record), + })); return { statusCode: 200, error: null, message: 'Success', - payload: response.data, - params: props, + payload, + params: requestProps, }; } catch (error) { return ErrorResponseHandler(error as AxiosError); diff --git a/frontend/src/api/infraMonitoring/getK8sNodesList.ts b/frontend/src/api/infraMonitoring/getK8sNodesList.ts index 71228b030b5..d0045f4820f 100644 --- a/frontend/src/api/infraMonitoring/getK8sNodesList.ts +++ b/frontend/src/api/infraMonitoring/getK8sNodesList.ts @@ -5,6 +5,8 @@ import { ErrorResponse, SuccessResponse } from 'types/api'; import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; import { TagFilter } from 'types/api/queryBuilder/queryBuilderData'; +import { UnderscoreToDotMap } from '../utils'; + export interface K8sNodesListPayload { filters: TagFilter; groupBy?: BaseAutocompleteData[]; @@ -41,23 +43,81 @@ export interface K8sNodesListResponse { }; } +export const nodesMetaMap = [ + { dot: 'k8s.node.name', under: 'k8s_node_name' }, + { dot: 'k8s.node.uid', under: 'k8s_node_uid' }, + { dot: 'k8s.cluster.name', under: 'k8s_cluster_name' }, +] as const; + +export function mapNodesMeta( + raw: Record, +): K8sNodesData['meta'] { + const out: Record = { ...raw }; + nodesMetaMap.forEach(({ dot, under }) => { + if (dot in raw) { + const v = raw[dot]; + out[under] = typeof v === 'string' ? v : raw[under]; + } + }); + return out as K8sNodesData['meta']; +} + export const getK8sNodesList = async ( props: K8sNodesListPayload, signal?: AbortSignal, headers?: Record, + dotMetricsEnabled = false, ): Promise | ErrorResponse> => { try { - const response = await axios.post('/nodes/list', props, { + const requestProps = + dotMetricsEnabled && Array.isArray(props.filters?.items) + ? { + ...props, + filters: { + ...props.filters, + items: props.filters.items.reduce( + (acc, item) => { + if (item.value === undefined) return acc; + if ( + item.key && + typeof item.key === 'object' && + 'key' in item.key && + typeof item.key.key === 'string' + ) { + const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key; + acc.push({ + ...item, + key: { ...item.key, key: mappedKey }, + }); + } else { + acc.push(item); + } + return acc; + }, + [] as typeof props.filters.items, + ), + }, + } + : props; + + const response = await axios.post('/nodes/list', requestProps, { signal, headers, }); + const payload: K8sNodesListResponse = response.data; + + // one-liner to map dot→underscore + payload.data.records = payload.data.records.map((record) => ({ + ...record, + meta: mapNodesMeta(record.meta as Record), + })); return { statusCode: 200, error: null, message: 'Success', - payload: response.data, - params: props, + payload, + params: requestProps, }; } catch (error) { return ErrorResponseHandler(error as AxiosError); diff --git a/frontend/src/api/infraMonitoring/getK8sPodsList.ts b/frontend/src/api/infraMonitoring/getK8sPodsList.ts index 05258ef166b..560788285fc 100644 --- a/frontend/src/api/infraMonitoring/getK8sPodsList.ts +++ b/frontend/src/api/infraMonitoring/getK8sPodsList.ts @@ -5,6 +5,8 @@ import { ErrorResponse, SuccessResponse } from 'types/api'; import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; import { TagFilter } from 'types/api/queryBuilder/queryBuilderData'; +import { UnderscoreToDotMap } from '../utils'; + export interface K8sPodsListPayload { filters: TagFilter; groupBy?: BaseAutocompleteData[]; @@ -69,23 +71,87 @@ export interface K8sPodsListResponse { }; } +export const podsMetaMap = [ + { dot: 'k8s.cronjob.name', under: 'k8s_cronjob_name' }, + { dot: 'k8s.daemonset.name', under: 'k8s_daemonset_name' }, + { dot: 'k8s.deployment.name', under: 'k8s_deployment_name' }, + { dot: 'k8s.job.name', under: 'k8s_job_name' }, + { dot: 'k8s.namespace.name', under: 'k8s_namespace_name' }, + { dot: 'k8s.node.name', under: 'k8s_node_name' }, + { dot: 'k8s.pod.name', under: 'k8s_pod_name' }, + { dot: 'k8s.pod.uid', under: 'k8s_pod_uid' }, + { dot: 'k8s.statefulset.name', under: 'k8s_statefulset_name' }, + { dot: 'k8s.cluster.name', under: 'k8s_cluster_name' }, +] as const; + +export function mapPodsMeta(raw: Record): K8sPodsData['meta'] { + // clone everything + const out: Record = { ...raw }; + // overlay only the dot→under mappings + podsMetaMap.forEach(({ dot, under }) => { + if (dot in raw) { + const v = raw[dot]; + out[under] = typeof v === 'string' ? v : raw[under]; + } + }); + return out as K8sPodsData['meta']; +} + +// getK8sPodsList export const getK8sPodsList = async ( props: K8sPodsListPayload, signal?: AbortSignal, headers?: Record, + dotMetricsEnabled = false, ): Promise | ErrorResponse> => { try { - const response = await axios.post('/pods/list', props, { + const requestProps = + dotMetricsEnabled && Array.isArray(props.filters?.items) + ? { + ...props, + filters: { + ...props.filters, + items: props.filters.items.reduce( + (acc, item) => { + if (item.value === undefined) return acc; + if ( + item.key && + typeof item.key === 'object' && + 'key' in item.key && + typeof item.key.key === 'string' + ) { + const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key; + acc.push({ + ...item, + key: { ...item.key, key: mappedKey }, + }); + } else { + acc.push(item); + } + return acc; + }, + [] as typeof props.filters.items, + ), + }, + } + : props; + + const response = await axios.post('/pods/list', requestProps, { signal, headers, }); + const payload: K8sPodsListResponse = response.data; + payload.data.records = payload.data.records.map((record) => ({ + ...record, + meta: mapPodsMeta(record.meta as Record), + })); return { statusCode: 200, error: null, message: 'Success', - payload: response.data, - params: props, + payload, + params: requestProps, }; } catch (error) { return ErrorResponseHandler(error as AxiosError); diff --git a/frontend/src/api/infraMonitoring/getK8sVolumesList.ts b/frontend/src/api/infraMonitoring/getK8sVolumesList.ts index ea825ba05d9..2089e5cd59f 100644 --- a/frontend/src/api/infraMonitoring/getK8sVolumesList.ts +++ b/frontend/src/api/infraMonitoring/getK8sVolumesList.ts @@ -5,6 +5,8 @@ import { ErrorResponse, SuccessResponse } from 'types/api'; import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; import { TagFilter } from 'types/api/queryBuilder/queryBuilderData'; +import { UnderscoreToDotMap } from '../utils'; + export interface K8sVolumesListPayload { filters: TagFilter; groupBy?: BaseAutocompleteData[]; @@ -47,23 +49,92 @@ export interface K8sVolumesListResponse { }; } +export const volumesMetaMap: Array<{ + dot: keyof Record; + under: keyof K8sVolumesData['meta']; +}> = [ + { dot: 'k8s.cluster.name', under: 'k8s_cluster_name' }, + { dot: 'k8s.namespace.name', under: 'k8s_namespace_name' }, + { dot: 'k8s.node.name', under: 'k8s_node_name' }, + { + dot: 'k8s.persistentvolumeclaim.name', + under: 'k8s_persistentvolumeclaim_name', + }, + { dot: 'k8s.pod.name', under: 'k8s_pod_name' }, + { dot: 'k8s.pod.uid', under: 'k8s_pod_uid' }, + { dot: 'k8s.statefulset.name', under: 'k8s_statefulset_name' }, +]; + +export function mapVolumesMeta( + rawMeta: Record, +): K8sVolumesData['meta'] { + // start with everything that was already there + const out: Record = { ...rawMeta }; + + // for each dot→under rule, if the raw has the dot, overwrite the underscore + volumesMetaMap.forEach(({ dot, under }) => { + if (dot in rawMeta) { + const val = rawMeta[dot]; + out[under] = typeof val === 'string' ? val : rawMeta[under]; + } + }); + + return out as K8sVolumesData['meta']; +} + export const getK8sVolumesList = async ( props: K8sVolumesListPayload, signal?: AbortSignal, headers?: Record, + dotMetricsEnabled = false, ): Promise | ErrorResponse> => { try { - const response = await axios.post('/pvcs/list', props, { + // Prepare filters + const requestProps = + dotMetricsEnabled && Array.isArray(props.filters?.items) + ? { + ...props, + filters: { + ...props.filters, + items: props.filters.items.reduce( + (acc, item) => { + if (item.value === undefined) return acc; + if ( + item.key && + typeof item.key === 'object' && + 'key' in item.key && + typeof item.key.key === 'string' + ) { + const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key; + acc.push({ ...item, key: { ...item.key, key: mappedKey } }); + } else { + acc.push(item); + } + return acc; + }, + [] as typeof props.filters.items, + ), + }, + } + : props; + + const response = await axios.post('/pvcs/list', requestProps, { signal, headers, }); + const payload: K8sVolumesListResponse = response.data; + + payload.data.records = payload.data.records.map((record) => ({ + ...record, + meta: mapVolumesMeta(record.meta as Record), + })); return { statusCode: 200, error: null, message: 'Success', - payload: response.data, - params: props, + payload, + params: requestProps, }; } catch (error) { return ErrorResponseHandler(error as AxiosError); diff --git a/frontend/src/api/infraMonitoring/getsK8sStatefulSetsList.ts b/frontend/src/api/infraMonitoring/getsK8sStatefulSetsList.ts index 191ec069c3d..2544c0cfe3b 100644 --- a/frontend/src/api/infraMonitoring/getsK8sStatefulSetsList.ts +++ b/frontend/src/api/infraMonitoring/getsK8sStatefulSetsList.ts @@ -5,6 +5,8 @@ import { ErrorResponse, SuccessResponse } from 'types/api'; import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; import { TagFilter } from 'types/api/queryBuilder/queryBuilderData'; +import { UnderscoreToDotMap } from '../utils'; + export interface K8sStatefulSetsListPayload { filters: TagFilter; groupBy?: BaseAutocompleteData[]; @@ -45,23 +47,78 @@ export interface K8sStatefulSetsListResponse { }; } +export const statefulSetsMetaMap = [ + { dot: 'k8s.statefulset.name', under: 'k8s_statefulset_name' }, + { dot: 'k8s.namespace.name', under: 'k8s_namespace_name' }, +] as const; + +export function mapStatefulSetsMeta( + raw: Record, +): K8sStatefulSetsData['meta'] { + const out: Record = { ...raw }; + statefulSetsMetaMap.forEach(({ dot, under }) => { + if (dot in raw) { + const v = raw[dot]; + out[under] = typeof v === 'string' ? v : raw[under]; + } + }); + return out as K8sStatefulSetsData['meta']; +} + export const getK8sStatefulSetsList = async ( props: K8sStatefulSetsListPayload, signal?: AbortSignal, headers?: Record, + dotMetricsEnabled = false, ): Promise | ErrorResponse> => { try { - const response = await axios.post('/statefulsets/list', props, { + // Prepare filters + const requestProps = + dotMetricsEnabled && Array.isArray(props.filters?.items) + ? { + ...props, + filters: { + ...props.filters, + items: props.filters.items.reduce( + (acc, item) => { + if (item.value === undefined) return acc; + if ( + item.key && + typeof item.key === 'object' && + 'key' in item.key && + typeof item.key.key === 'string' + ) { + const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key; + acc.push({ ...item, key: { ...item.key, key: mappedKey } }); + } else { + acc.push(item); + } + return acc; + }, + [] as typeof props.filters.items, + ), + }, + } + : props; + + const response = await axios.post('/statefulsets/list', requestProps, { signal, headers, }); + const payload: K8sStatefulSetsListResponse = response.data; + + // apply our helper + payload.data.records = payload.data.records.map((record) => ({ + ...record, + meta: mapStatefulSetsMeta(record.meta as Record), + })); return { statusCode: 200, error: null, message: 'Success', - payload: response.data, - params: props, + payload, + params: requestProps, }; } catch (error) { return ErrorResponseHandler(error as AxiosError); diff --git a/frontend/src/api/licenses/apply.ts b/frontend/src/api/licenses/apply.ts deleted file mode 100644 index c691ad836ff..00000000000 --- a/frontend/src/api/licenses/apply.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { ApiV3Instance as axios } from 'api'; -import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; -import { AxiosError } from 'axios'; -import { ErrorResponse, SuccessResponse } from 'types/api'; -import { PayloadProps, Props } from 'types/api/licenses/apply'; - -const apply = async ( - props: Props, -): Promise | ErrorResponse> => { - try { - const response = await axios.post('/licenses', { - key: props.key, - }); - - return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data.data, - }; - } catch (error) { - return ErrorResponseHandler(error as AxiosError); - } -}; - -export default apply; diff --git a/frontend/src/api/licenses/getAll.ts b/frontend/src/api/licenses/getAll.ts deleted file mode 100644 index b05cdcb9e2c..00000000000 --- a/frontend/src/api/licenses/getAll.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { ApiV3Instance as axios } from 'api'; -import { ErrorResponse, SuccessResponse } from 'types/api'; -import { PayloadProps } from 'types/api/licenses/getAll'; - -const getAll = async (): Promise< - SuccessResponse | ErrorResponse -> => { - const response = await axios.get('/licenses'); - - return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data.data, - }; -}; - -export default getAll; diff --git a/frontend/src/api/licensesV3/getActive.ts b/frontend/src/api/licensesV3/getActive.ts deleted file mode 100644 index 48dd0a3a434..00000000000 --- a/frontend/src/api/licensesV3/getActive.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { ApiV3Instance as axios } from 'api'; -import { ErrorResponse, SuccessResponse } from 'types/api'; -import { LicenseV3EventQueueResModel } from 'types/api/licensesV3/getActive'; - -const getActive = async (): Promise< - SuccessResponse | ErrorResponse -> => { - const response = await axios.get('/licenses/active'); - - return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data.data, - }; -}; - -export default getActive; diff --git a/frontend/src/api/metrics/getQueryRange.ts b/frontend/src/api/metrics/getQueryRange.ts index 631372478df..d3ab626fa3d 100644 --- a/frontend/src/api/metrics/getQueryRange.ts +++ b/frontend/src/api/metrics/getQueryRange.ts @@ -2,7 +2,7 @@ import { ApiV3Instance, ApiV4Instance } from 'api'; import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; import { AxiosError } from 'axios'; import { ENTITY_VERSION_V4 } from 'constants/app'; -import { ErrorResponse, SuccessResponse } from 'types/api'; +import { ErrorResponse, SuccessResponse, Warning } from 'types/api'; import { MetricRangePayloadV3, QueryRangePayload, @@ -13,7 +13,9 @@ export const getMetricsQueryRange = async ( version: string, signal: AbortSignal, headers?: Record, -): Promise | ErrorResponse> => { +): Promise< + (SuccessResponse & { warning?: Warning }) | ErrorResponse +> => { try { if (version && version === ENTITY_VERSION_V4) { const response = await ApiV4Instance.post('/query_range', props, { diff --git a/frontend/src/api/metrics/getTopOperations.ts b/frontend/src/api/metrics/getTopOperations.ts index 9c85602e40f..af02e2c333e 100644 --- a/frontend/src/api/metrics/getTopOperations.ts +++ b/frontend/src/api/metrics/getTopOperations.ts @@ -2,13 +2,20 @@ import axios from 'api'; import { PayloadProps, Props } from 'types/api/metrics/getTopOperations'; const getTopOperations = async (props: Props): Promise => { - const response = await axios.post(`/service/top_operations`, { + const endpoint = props.isEntryPoint + ? '/service/entry_point_operations' + : '/service/top_operations'; + + const response = await axios.post(endpoint, { start: `${props.start}`, end: `${props.end}`, service: props.service, tags: props.selectedTags, }); + if (props.isEntryPoint) { + return response.data.data; + } return response.data; }; diff --git a/frontend/src/api/pipeline/post.ts b/frontend/src/api/pipeline/post.ts index c2e7ca27570..9c774481f6d 100644 --- a/frontend/src/api/pipeline/post.ts +++ b/frontend/src/api/pipeline/post.ts @@ -1,24 +1,20 @@ import axios from 'api'; -import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; import { AxiosError } from 'axios'; -import { ErrorResponse, SuccessResponse } from 'types/api'; +import { ErrorV2Resp, SuccessResponseV2 } from 'types/api'; import { Pipeline } from 'types/api/pipeline/def'; import { Props } from 'types/api/pipeline/post'; -const post = async ( - props: Props, -): Promise | ErrorResponse> => { +const post = async (props: Props): Promise> => { try { const response = await axios.post('/logs/pipelines', props.data); return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data.data, + httpStatusCode: response.status, + data: response.data.data, }; } catch (error) { - return ErrorResponseHandler(error as AxiosError); + ErrorResponseHandlerV2(error as AxiosError); } }; diff --git a/frontend/src/api/preferences/getAllOrgPreferences.ts b/frontend/src/api/preferences/getAllOrgPreferences.ts deleted file mode 100644 index 12f6497c0eb..00000000000 --- a/frontend/src/api/preferences/getAllOrgPreferences.ts +++ /dev/null @@ -1,18 +0,0 @@ -import axios from 'api'; -import { ErrorResponse, SuccessResponse } from 'types/api'; -import { GetAllOrgPreferencesResponseProps } from 'types/api/preferences/userOrgPreferences'; - -const getAllOrgPreferences = async (): Promise< - SuccessResponse | ErrorResponse -> => { - const response = await axios.get(`/org/preferences`); - - return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data, - }; -}; - -export default getAllOrgPreferences; diff --git a/frontend/src/api/preferences/getAllUserPreference.ts b/frontend/src/api/preferences/getAllUserPreference.ts deleted file mode 100644 index f7e94f76cb7..00000000000 --- a/frontend/src/api/preferences/getAllUserPreference.ts +++ /dev/null @@ -1,18 +0,0 @@ -import axios from 'api'; -import { ErrorResponse, SuccessResponse } from 'types/api'; -import { GetAllUserPreferencesResponseProps } from 'types/api/preferences/userOrgPreferences'; - -const getAllUserPreferences = async (): Promise< - SuccessResponse | ErrorResponse -> => { - const response = await axios.get(`/user/preferences`); - - return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data, - }; -}; - -export default getAllUserPreferences; diff --git a/frontend/src/api/preferences/getOrgPreference.ts b/frontend/src/api/preferences/getOrgPreference.ts deleted file mode 100644 index 6a016ddd3a7..00000000000 --- a/frontend/src/api/preferences/getOrgPreference.ts +++ /dev/null @@ -1,20 +0,0 @@ -import axios from 'api'; -import { ErrorResponse, SuccessResponse } from 'types/api'; -import { GetOrgPreferenceResponseProps } from 'types/api/preferences/userOrgPreferences'; - -const getOrgPreference = async ({ - preferenceID, -}: { - preferenceID: string; -}): Promise | ErrorResponse> => { - const response = await axios.get(`/org/preferences/${preferenceID}`); - - return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data, - }; -}; - -export default getOrgPreference; diff --git a/frontend/src/api/preferences/getUserPreference.ts b/frontend/src/api/preferences/getUserPreference.ts deleted file mode 100644 index a750732a925..00000000000 --- a/frontend/src/api/preferences/getUserPreference.ts +++ /dev/null @@ -1,22 +0,0 @@ -import axios from 'api'; -import { ErrorResponse, SuccessResponse } from 'types/api'; -import { GetUserPreferenceResponseProps } from 'types/api/preferences/userOrgPreferences'; - -const getUserPreference = async ({ - preferenceID, -}: { - preferenceID: string; -}): Promise< - SuccessResponse | ErrorResponse -> => { - const response = await axios.get(`/user/preferences/${preferenceID}`); - - return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data, - }; -}; - -export default getUserPreference; diff --git a/frontend/src/api/preferences/updateOrgPreference.ts b/frontend/src/api/preferences/updateOrgPreference.ts deleted file mode 100644 index aae4d83ddf0..00000000000 --- a/frontend/src/api/preferences/updateOrgPreference.ts +++ /dev/null @@ -1,28 +0,0 @@ -import axios from 'api'; -import { ErrorResponse, SuccessResponse } from 'types/api'; -import { - UpdateOrgPreferenceProps, - UpdateOrgPreferenceResponseProps, -} from 'types/api/preferences/userOrgPreferences'; - -const updateOrgPreference = async ( - preferencePayload: UpdateOrgPreferenceProps, -): Promise< - SuccessResponse | ErrorResponse -> => { - const response = await axios.put( - `/org/preferences/${preferencePayload.preferenceID}`, - { - preference_value: preferencePayload.value, - }, - ); - - return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data.data, - }; -}; - -export default updateOrgPreference; diff --git a/frontend/src/api/preferences/updateUserPreference.ts b/frontend/src/api/preferences/updateUserPreference.ts deleted file mode 100644 index 3cf215993fb..00000000000 --- a/frontend/src/api/preferences/updateUserPreference.ts +++ /dev/null @@ -1,28 +0,0 @@ -import axios from 'api'; -import { ErrorResponse, SuccessResponse } from 'types/api'; -import { - UpdateUserPreferenceProps, - UpdateUserPreferenceResponseProps, -} from 'types/api/preferences/userOrgPreferences'; - -const updateUserPreference = async ( - preferencePayload: UpdateUserPreferenceProps, -): Promise< - SuccessResponse | ErrorResponse -> => { - const response = await axios.put( - `/user/preferences/${preferencePayload.preferenceID}`, - { - preference_value: preferencePayload.value, - }, - ); - - return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data.data, - }; -}; - -export default updateUserPreference; diff --git a/frontend/src/api/queryBuilder/getAggregateAttribute.ts b/frontend/src/api/queryBuilder/getAggregateAttribute.ts index f13c3da4a89..8080d9cc6f9 100644 --- a/frontend/src/api/queryBuilder/getAggregateAttribute.ts +++ b/frontend/src/api/queryBuilder/getAggregateAttribute.ts @@ -17,6 +17,7 @@ export const getAggregateAttribute = async ({ aggregateOperator, searchText, dataSource, + source, }: IGetAggregateAttributePayload): Promise< SuccessResponse | ErrorResponse > => { @@ -27,7 +28,7 @@ export const getAggregateAttribute = async ({ `/autocomplete/aggregate_attributes?${createQueryParams({ aggregateOperator, searchText, - dataSource, + dataSource: source === 'meter' ? 'meter' : dataSource, })}`, ); diff --git a/frontend/src/api/querySuggestions/getKeySuggestions.ts b/frontend/src/api/querySuggestions/getKeySuggestions.ts new file mode 100644 index 00000000000..50626dee8b8 --- /dev/null +++ b/frontend/src/api/querySuggestions/getKeySuggestions.ts @@ -0,0 +1,30 @@ +import axios from 'api'; +import { AxiosResponse } from 'axios'; +import { + QueryKeyRequestProps, + QueryKeySuggestionsResponseProps, +} from 'types/api/querySuggestions/types'; + +export const getKeySuggestions = ( + props: QueryKeyRequestProps, +): Promise> => { + const { + signal = '', + searchText = '', + metricName = '', + fieldContext = '', + fieldDataType = '', + signalSource = '', + } = props; + + const encodedSignal = encodeURIComponent(signal); + const encodedSearchText = encodeURIComponent(searchText); + const encodedMetricName = encodeURIComponent(metricName); + const encodedFieldContext = encodeURIComponent(fieldContext); + const encodedFieldDataType = encodeURIComponent(fieldDataType); + const encodedSource = encodeURIComponent(signalSource); + + return axios.get( + `/fields/keys?signal=${encodedSignal}&searchText=${encodedSearchText}&metricName=${encodedMetricName}&fieldContext=${encodedFieldContext}&fieldDataType=${encodedFieldDataType}&source=${encodedSource}`, + ); +}; diff --git a/frontend/src/api/querySuggestions/getValueSuggestion.ts b/frontend/src/api/querySuggestions/getValueSuggestion.ts new file mode 100644 index 00000000000..f91a40139b9 --- /dev/null +++ b/frontend/src/api/querySuggestions/getValueSuggestion.ts @@ -0,0 +1,22 @@ +import axios from 'api'; +import { AxiosResponse } from 'axios'; +import { + QueryKeyValueRequestProps, + QueryKeyValueSuggestionsResponseProps, +} from 'types/api/querySuggestions/types'; + +export const getValueSuggestions = ( + props: QueryKeyValueRequestProps, +): Promise> => { + const { signal, key, searchText, signalSource, metricName } = props; + + const encodedSignal = encodeURIComponent(signal); + const encodedKey = encodeURIComponent(key); + const encodedMetricName = encodeURIComponent(metricName || ''); + const encodedSearchText = encodeURIComponent(searchText); + const encodedSource = encodeURIComponent(signalSource || ''); + + return axios.get( + `/fields/values?signal=${encodedSignal}&name=${encodedKey}&searchText=${encodedSearchText}&metricName=${encodedMetricName}&source=${encodedSource}`, + ); +}; diff --git a/frontend/src/api/routingPolicies/createRoutingPolicy.ts b/frontend/src/api/routingPolicies/createRoutingPolicy.ts new file mode 100644 index 00000000000..69bf58bd8c8 --- /dev/null +++ b/frontend/src/api/routingPolicies/createRoutingPolicy.ts @@ -0,0 +1,34 @@ +import axios from 'api'; +import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; +import { AxiosError } from 'axios'; +import { ErrorResponseV2, ErrorV2Resp, SuccessResponseV2 } from 'types/api'; + +export interface CreateRoutingPolicyBody { + name: string; + expression: string; + channels: string[]; + description?: string; +} + +export interface CreateRoutingPolicyResponse { + success: boolean; + message: string; +} + +const createRoutingPolicy = async ( + props: CreateRoutingPolicyBody, +): Promise< + SuccessResponseV2 | ErrorResponseV2 +> => { + try { + const response = await axios.post(`/route_policies`, props); + return { + httpStatusCode: response.status, + data: response.data, + }; + } catch (error) { + return ErrorResponseHandlerV2(error as AxiosError); + } +}; + +export default createRoutingPolicy; diff --git a/frontend/src/api/routingPolicies/deleteRoutingPolicy.ts b/frontend/src/api/routingPolicies/deleteRoutingPolicy.ts new file mode 100644 index 00000000000..444ec0b1ab9 --- /dev/null +++ b/frontend/src/api/routingPolicies/deleteRoutingPolicy.ts @@ -0,0 +1,28 @@ +import axios from 'api'; +import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; +import { AxiosError } from 'axios'; +import { ErrorResponseV2, ErrorV2Resp, SuccessResponseV2 } from 'types/api'; + +export interface DeleteRoutingPolicyResponse { + success: boolean; + message: string; +} + +const deleteRoutingPolicy = async ( + routingPolicyId: string, +): Promise< + SuccessResponseV2 | ErrorResponseV2 +> => { + try { + const response = await axios.delete(`/route_policies/${routingPolicyId}`); + + return { + httpStatusCode: response.status, + data: response.data, + }; + } catch (error) { + return ErrorResponseHandlerV2(error as AxiosError); + } +}; + +export default deleteRoutingPolicy; diff --git a/frontend/src/api/routingPolicies/getRoutingPolicies.ts b/frontend/src/api/routingPolicies/getRoutingPolicies.ts new file mode 100644 index 00000000000..b06d359c9d8 --- /dev/null +++ b/frontend/src/api/routingPolicies/getRoutingPolicies.ts @@ -0,0 +1,40 @@ +import axios from 'api'; +import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; +import { AxiosError } from 'axios'; +import { ErrorResponseV2, ErrorV2Resp, SuccessResponseV2 } from 'types/api'; + +export interface ApiRoutingPolicy { + id: string; + name: string; + expression: string; + description: string; + channels: string[]; + createdAt: string; + updatedAt: string; + createdBy: string; + updatedBy: string; +} + +export interface GetRoutingPoliciesResponse { + status: string; + data?: ApiRoutingPolicy[]; +} + +export const getRoutingPolicies = async ( + signal?: AbortSignal, + headers?: Record, +): Promise | ErrorResponseV2> => { + try { + const response = await axios.get('/route_policies', { + signal, + headers, + }); + + return { + httpStatusCode: response.status, + data: response.data, + }; + } catch (error) { + return ErrorResponseHandlerV2(error as AxiosError); + } +}; diff --git a/frontend/src/api/routingPolicies/updateRoutingPolicy.ts b/frontend/src/api/routingPolicies/updateRoutingPolicy.ts new file mode 100644 index 00000000000..63731fe5bf9 --- /dev/null +++ b/frontend/src/api/routingPolicies/updateRoutingPolicy.ts @@ -0,0 +1,38 @@ +import axios from 'api'; +import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; +import { AxiosError } from 'axios'; +import { ErrorResponseV2, ErrorV2Resp, SuccessResponseV2 } from 'types/api'; + +export interface UpdateRoutingPolicyBody { + name: string; + expression: string; + channels: string[]; + description: string; +} + +export interface UpdateRoutingPolicyResponse { + success: boolean; + message: string; +} + +const updateRoutingPolicy = async ( + id: string, + props: UpdateRoutingPolicyBody, +): Promise< + SuccessResponseV2 | ErrorResponseV2 +> => { + try { + const response = await axios.put(`/route_policies/${id}`, { + ...props, + }); + + return { + httpStatusCode: response.status, + data: response.data, + }; + } catch (error) { + return ErrorResponseHandlerV2(error as AxiosError); + } +}; + +export default updateRoutingPolicy; diff --git a/frontend/src/api/saveView/getAllViews.ts b/frontend/src/api/saveView/getAllViews.ts index 4a54d6af0df..a26fd13441f 100644 --- a/frontend/src/api/saveView/getAllViews.ts +++ b/frontend/src/api/saveView/getAllViews.ts @@ -4,6 +4,6 @@ import { AllViewsProps } from 'types/api/saveViews/types'; import { DataSource } from 'types/common/queryBuilder'; export const getAllViews = ( - sourcepage: DataSource, + sourcepage: DataSource | 'meter', ): Promise> => axios.get(`/explorer/views?sourcePage=${sourcepage}`); diff --git a/frontend/src/api/settings/getRetentionV2.ts b/frontend/src/api/settings/getRetentionV2.ts new file mode 100644 index 00000000000..8fa5ab1d851 --- /dev/null +++ b/frontend/src/api/settings/getRetentionV2.ts @@ -0,0 +1,25 @@ +import { ApiV2Instance } from 'api'; +import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; +import { AxiosError } from 'axios'; +import { ErrorV2Resp, SuccessResponseV2 } from 'types/api'; +import { PayloadProps } from 'types/api/settings/getRetention'; + +// Only works for logs +const getRetentionV2 = async (): Promise< + SuccessResponseV2> +> => { + try { + const response = await ApiV2Instance.get>( + `/settings/ttl`, + ); + + return { + httpStatusCode: response.status, + data: response.data, + }; + } catch (error) { + ErrorResponseHandlerV2(error as AxiosError); + } +}; + +export default getRetentionV2; diff --git a/frontend/src/api/settings/setRetention.ts b/frontend/src/api/settings/setRetention.ts index 481760bf57b..3ff38e7319d 100644 --- a/frontend/src/api/settings/setRetention.ts +++ b/frontend/src/api/settings/setRetention.ts @@ -1,14 +1,14 @@ import axios from 'api'; -import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; import { AxiosError } from 'axios'; -import { ErrorResponse, SuccessResponse } from 'types/api'; -import { PayloadProps, Props } from 'types/api/settings/setRetention'; +import { ErrorV2Resp, SuccessResponseV2 } from 'types/api'; +import { PayloadPropsV2, Props } from 'types/api/settings/setRetention'; const setRetention = async ( props: Props, -): Promise | ErrorResponse> => { +): Promise> => { try { - const response = await axios.post( + const response = await axios.post( `/settings/ttl?duration=${props.totalDuration}&type=${props.type}${ props.coldStorage ? `&coldStorage=${props.coldStorage}&toColdDuration=${props.toColdDuration}` @@ -17,13 +17,11 @@ const setRetention = async ( ); return { - statusCode: 200, - error: null, - message: 'Success', - payload: response.data, + httpStatusCode: response.status, + data: response.data, }; } catch (error) { - return ErrorResponseHandler(error as AxiosError); + ErrorResponseHandlerV2(error as AxiosError); } }; diff --git a/frontend/src/api/settings/setRetentionV2.ts b/frontend/src/api/settings/setRetentionV2.ts new file mode 100644 index 00000000000..bd7e56d8e5f --- /dev/null +++ b/frontend/src/api/settings/setRetentionV2.ts @@ -0,0 +1,32 @@ +import { ApiV2Instance } from 'api'; +import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; +import { AxiosError } from 'axios'; +import { ErrorV2Resp, SuccessResponseV2 } from 'types/api'; +import { PayloadPropsV2, PropsV2 } from 'types/api/settings/setRetention'; + +const setRetentionV2 = async ({ + type, + defaultTTLDays, + coldStorageVolume, + coldStorageDuration, + ttlConditions, +}: PropsV2): Promise> => { + try { + const response = await ApiV2Instance.post(`/settings/ttl`, { + type, + defaultTTLDays, + coldStorageVolume, + coldStorageDuration, + ttlConditions, + }); + + return { + httpStatusCode: response.status, + data: response.data, + }; + } catch (error) { + ErrorResponseHandlerV2(error as AxiosError); + } +}; + +export default setRetentionV2; diff --git a/frontend/src/api/thirdPartyApis/listOverview.ts b/frontend/src/api/thirdPartyApis/listOverview.ts new file mode 100644 index 00000000000..303ee03ae06 --- /dev/null +++ b/frontend/src/api/thirdPartyApis/listOverview.ts @@ -0,0 +1,31 @@ +import { ApiBaseInstance } from 'api'; +import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; +import { AxiosError } from 'axios'; +import { ErrorV2Resp, SuccessResponseV2 } from 'types/api'; +import { PayloadProps, Props } from 'types/api/thirdPartyApis/listOverview'; + +const listOverview = async ( + props: Props, +): 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, + }, + ); + + return { + httpStatusCode: response.status, + data: response.data, + }; + } catch (error) { + ErrorResponseHandlerV2(error as AxiosError); + } +}; + +export default listOverview; diff --git a/frontend/src/api/traceFunnels/index.ts b/frontend/src/api/traceFunnels/index.ts index d4c3b8ccca8..63623eccaaf 100644 --- a/frontend/src/api/traceFunnels/index.ts +++ b/frontend/src/api/traceFunnels/index.ts @@ -119,6 +119,7 @@ export const updateFunnelSteps = async ( export interface ValidateFunnelPayload { start_time: number; end_time: number; + steps: FunnelStepData[]; } export interface ValidateFunnelResponse { @@ -132,12 +133,11 @@ export interface ValidateFunnelResponse { } export const validateFunnelSteps = async ( - funnelId: string, payload: ValidateFunnelPayload, signal?: AbortSignal, ): Promise | ErrorResponse> => { const response = await axios.post( - `${FUNNELS_BASE_PATH}/${funnelId}/analytics/validate`, + `${FUNNELS_BASE_PATH}/analytics/validate`, payload, { signal }, ); @@ -167,8 +167,8 @@ interface UpdateFunnelDescriptionPayload { export const saveFunnelDescription = async ( payload: UpdateFunnelDescriptionPayload, ): Promise | ErrorResponse> => { - const response: AxiosResponse = await axios.post( - `${FUNNELS_BASE_PATH}/save`, + const response: AxiosResponse = await axios.put( + `${FUNNELS_BASE_PATH}/${payload.funnel_id}`, payload, ); @@ -185,6 +185,7 @@ export interface FunnelOverviewPayload { end_time: number; step_start?: number; step_end?: number; + steps: FunnelStepData[]; } export interface FunnelOverviewResponse { @@ -196,20 +197,17 @@ export interface FunnelOverviewResponse { avg_rate: number; conversion_rate: number | null; errors: number; - // TODO(shaheer): remove p99_latency once we have support for latency - p99_latency: number; latency: number; }; }>; } export const getFunnelOverview = async ( - funnelId: string, payload: FunnelOverviewPayload, signal?: AbortSignal, ): Promise | ErrorResponse> => { const response = await axios.post( - `${FUNNELS_BASE_PATH}/${funnelId}/analytics/overview`, + `${FUNNELS_BASE_PATH}/analytics/overview`, payload, { signal, @@ -237,12 +235,11 @@ export interface SlowTraceData { } export const getFunnelSlowTraces = async ( - funnelId: string, payload: FunnelOverviewPayload, signal?: AbortSignal, ): Promise | ErrorResponse> => { const response = await axios.post( - `${FUNNELS_BASE_PATH}/${funnelId}/analytics/slow-traces`, + `${FUNNELS_BASE_PATH}/analytics/slow-traces`, payload, { signal, @@ -275,7 +272,7 @@ export const getFunnelErrorTraces = async ( signal?: AbortSignal, ): Promise | ErrorResponse> => { const response: AxiosResponse = await axios.post( - `${FUNNELS_BASE_PATH}/${funnelId}/analytics/error-traces`, + `${FUNNELS_BASE_PATH}/analytics/error-traces`, payload, { signal, @@ -293,6 +290,7 @@ export const getFunnelErrorTraces = async ( export interface FunnelStepsPayload { start_time: number; end_time: number; + steps: FunnelStepData[]; } export interface FunnelStepGraphMetrics { @@ -309,12 +307,11 @@ export interface FunnelStepsResponse { } export const getFunnelSteps = async ( - funnelId: string, payload: FunnelStepsPayload, signal?: AbortSignal, ): Promise | ErrorResponse> => { const response = await axios.post( - `${FUNNELS_BASE_PATH}/${funnelId}/analytics/steps`, + `${FUNNELS_BASE_PATH}/analytics/steps`, payload, { signal }, ); @@ -332,6 +329,7 @@ export interface FunnelStepsOverviewPayload { end_time: number; step_start?: number; step_end?: number; + steps: FunnelStepData[]; } export interface FunnelStepsOverviewResponse { @@ -343,12 +341,11 @@ export interface FunnelStepsOverviewResponse { } export const getFunnelStepsOverview = async ( - funnelId: string, payload: FunnelStepsOverviewPayload, signal?: AbortSignal, ): Promise | ErrorResponse> => { const response = await axios.post( - `${FUNNELS_BASE_PATH}/${funnelId}/analytics/steps/overview`, + `${FUNNELS_BASE_PATH}/analytics/steps/overview`, payload, { signal }, ); diff --git a/frontend/src/api/userFeedback/sendFeedback.ts b/frontend/src/api/userFeedback/sendFeedback.ts deleted file mode 100644 index abf811378c2..00000000000 --- a/frontend/src/api/userFeedback/sendFeedback.ts +++ /dev/null @@ -1,21 +0,0 @@ -import axios from 'api'; -import { Props } from 'types/api/userFeedback/sendResponse'; - -const sendFeedback = async (props: Props): Promise => { - const response = await axios.post( - '/feedback', - { - email: props.email, - message: props.message, - }, - { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - }, - ); - - return response.status; -}; - -export default sendFeedback; diff --git a/frontend/src/api/utils.ts b/frontend/src/api/utils.ts index 179eac8e21e..731b8e859af 100644 --- a/frontend/src/api/utils.ts +++ b/frontend/src/api/utils.ts @@ -15,13 +15,21 @@ export const Logout = (): void => { deleteLocalStorageKey(LOCALSTORAGE.QUICK_FILTERS_SETTINGS_ANNOUNCEMENT); window.dispatchEvent(new CustomEvent('LOGOUT')); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - if (window && window.Intercom) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - window.Intercom('shutdown'); - } - history.push(ROUTES.LOGIN); }; + +export const UnderscoreToDotMap: Record = { + k8s_cluster_name: 'k8s.cluster.name', + k8s_cluster_uid: 'k8s.cluster.uid', + k8s_namespace_name: 'k8s.namespace.name', + k8s_node_name: 'k8s.node.name', + k8s_node_uid: 'k8s.node.uid', + k8s_pod_name: 'k8s.pod.name', + k8s_pod_uid: 'k8s.pod.uid', + k8s_deployment_name: 'k8s.deployment.name', + k8s_daemonset_name: 'k8s.daemonset.name', + k8s_statefulset_name: 'k8s.statefulset.name', + k8s_cronjob_name: 'k8s.cronjob.name', + k8s_job_name: 'k8s.job.name', + k8s_persistentvolumeclaim_name: 'k8s.persistentvolumeclaim.name', +}; diff --git a/frontend/src/api/v1/checkout/create.ts b/frontend/src/api/v1/checkout/create.ts new file mode 100644 index 00000000000..2e71a647686 --- /dev/null +++ b/frontend/src/api/v1/checkout/create.ts @@ -0,0 +1,28 @@ +import axios from 'api'; +import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; +import { AxiosError } from 'axios'; +import { ErrorV2Resp, SuccessResponseV2 } from 'types/api'; +import { + CheckoutRequestPayloadProps, + CheckoutSuccessPayloadProps, + PayloadProps, +} from 'types/api/billing/checkout'; + +const updateCreditCardApi = async ( + props: CheckoutRequestPayloadProps, +): Promise> => { + try { + const response = await axios.post('/checkout', { + url: props.url, + }); + + return { + httpStatusCode: response.status, + data: response.data.data, + }; + } catch (error) { + ErrorResponseHandlerV2(error as AxiosError); + } +}; + +export default updateCreditCardApi; diff --git a/frontend/src/api/v1/dashboards/create.ts b/frontend/src/api/v1/dashboards/create.ts new file mode 100644 index 00000000000..1eb7bdc12e3 --- /dev/null +++ b/frontend/src/api/v1/dashboards/create.ts @@ -0,0 +1,23 @@ +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/dashboard/create'; +import { Dashboard } from 'types/api/dashboard/getAll'; + +const create = async (props: Props): Promise> => { + try { + const response = await axios.post('/dashboards', { + ...props, + }); + + return { + httpStatusCode: response.status, + data: response.data.data, + }; + } catch (error) { + ErrorResponseHandlerV2(error as AxiosError); + } +}; + +export default create; diff --git a/frontend/src/api/v1/dashboards/getAll.ts b/frontend/src/api/v1/dashboards/getAll.ts new file mode 100644 index 00000000000..7d89ef94f03 --- /dev/null +++ b/frontend/src/api/v1/dashboards/getAll.ts @@ -0,0 +1,19 @@ +import axios from 'api'; +import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; +import { AxiosError } from 'axios'; +import { ErrorV2Resp, SuccessResponseV2 } from 'types/api'; +import { Dashboard, PayloadProps } from 'types/api/dashboard/getAll'; + +const getAll = async (): Promise> => { + try { + const response = await axios.get('/dashboards'); + return { + httpStatusCode: response.status, + data: response.data.data, + }; + } catch (error) { + ErrorResponseHandlerV2(error as AxiosError); + } +}; + +export default getAll; diff --git a/frontend/src/api/v1/dashboards/id/delete.ts b/frontend/src/api/v1/dashboards/id/delete.ts new file mode 100644 index 00000000000..e6c974df026 --- /dev/null +++ b/frontend/src/api/v1/dashboards/id/delete.ts @@ -0,0 +1,21 @@ +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/dashboard/delete'; + +const deleteDashboard = async ( + props: Props, +): Promise> => { + try { + const response = await axios.delete(`/dashboards/${props.id}`); + return { + httpStatusCode: response.status, + data: null, + }; + } catch (error) { + ErrorResponseHandlerV2(error as AxiosError); + } +}; + +export default deleteDashboard; diff --git a/frontend/src/api/v1/dashboards/id/get.ts b/frontend/src/api/v1/dashboards/id/get.ts new file mode 100644 index 00000000000..dfde573342e --- /dev/null +++ b/frontend/src/api/v1/dashboards/id/get.ts @@ -0,0 +1,20 @@ +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/dashboard/get'; +import { Dashboard } from 'types/api/dashboard/getAll'; + +const get = async (props: Props): Promise> => { + try { + const response = await axios.get(`/dashboards/${props.id}`); + return { + httpStatusCode: response.status, + data: response.data.data, + }; + } catch (error) { + ErrorResponseHandlerV2(error as AxiosError); + } +}; + +export default get; diff --git a/frontend/src/api/v1/dashboards/id/lock.ts b/frontend/src/api/v1/dashboards/id/lock.ts new file mode 100644 index 00000000000..289a4ddc990 --- /dev/null +++ b/frontend/src/api/v1/dashboards/id/lock.ts @@ -0,0 +1,23 @@ +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/dashboard/lockUnlock'; + +const lock = async (props: Props): Promise> => { + try { + const response = await axios.put( + `/dashboards/${props.id}/lock`, + { lock: props.lock }, + ); + + return { + httpStatusCode: response.status, + data: response.data.data, + }; + } catch (error) { + ErrorResponseHandlerV2(error as AxiosError); + } +}; + +export default lock; diff --git a/frontend/src/api/v1/dashboards/id/update.ts b/frontend/src/api/v1/dashboards/id/update.ts new file mode 100644 index 00000000000..82e98039bca --- /dev/null +++ b/frontend/src/api/v1/dashboards/id/update.ts @@ -0,0 +1,23 @@ +import axios from 'api'; +import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; +import { AxiosError } from 'axios'; +import { ErrorV2Resp, SuccessResponseV2 } from 'types/api'; +import { Dashboard } from 'types/api/dashboard/getAll'; +import { PayloadProps, Props } from 'types/api/dashboard/update'; + +const update = async (props: Props): Promise> => { + try { + const response = await axios.put(`/dashboards/${props.id}`, { + ...props.data, + }); + + return { + httpStatusCode: response.status, + data: response.data.data, + }; + } catch (error) { + ErrorResponseHandlerV2(error as AxiosError); + } +}; + +export default update; diff --git a/frontend/src/api/v1/domains/create.ts b/frontend/src/api/v1/domains/create.ts new file mode 100644 index 00000000000..18fbc21b2bd --- /dev/null +++ b/frontend/src/api/v1/domains/create.ts @@ -0,0 +1,21 @@ +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'; + +const create = async (props: Props): 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 create; diff --git a/frontend/src/api/v1/domains/delete.ts b/frontend/src/api/v1/domains/delete.ts new file mode 100644 index 00000000000..0c1f452248f --- /dev/null +++ b/frontend/src/api/v1/domains/delete.ts @@ -0,0 +1,20 @@ +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> => { + try { + const response = await axios.delete(`/domains/${props.id}`); + + return { + httpStatusCode: response.status, + data: null, + }; + } catch (error) { + ErrorResponseHandlerV2(error as AxiosError); + } +}; + +export default deleteDomain; diff --git a/frontend/src/api/v1/domains/list.ts b/frontend/src/api/v1/domains/list.ts new file mode 100644 index 00000000000..fc056873a06 --- /dev/null +++ b/frontend/src/api/v1/domains/list.ts @@ -0,0 +1,20 @@ +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'; + +const listAllDomain = async (): Promise> => { + try { + const response = await axios.get(`/domains`); + + return { + httpStatusCode: response.status, + data: response.data.data, + }; + } catch (error) { + ErrorResponseHandlerV2(error as AxiosError); + } +}; + +export default listAllDomain; diff --git a/frontend/src/api/v1/domains/update.ts b/frontend/src/api/v1/domains/update.ts new file mode 100644 index 00000000000..701555a39d1 --- /dev/null +++ b/frontend/src/api/v1/domains/update.ts @@ -0,0 +1,23 @@ +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/download/downloadExportData.ts b/frontend/src/api/v1/download/downloadExportData.ts new file mode 100644 index 00000000000..30bc7b25dd7 --- /dev/null +++ b/frontend/src/api/v1/download/downloadExportData.ts @@ -0,0 +1,64 @@ +import axios from 'api'; +import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; +import { AxiosError } from 'axios'; +import { ErrorV2Resp } from 'types/api'; +import { ExportRawDataProps } from 'types/api/exportRawData/getExportRawData'; + +export const downloadExportData = async ( + props: ExportRawDataProps, +): Promise => { + try { + const queryParams = new URLSearchParams(); + + queryParams.append('start', String(props.start)); + queryParams.append('end', String(props.end)); + queryParams.append('filter', props.filter); + props.columns.forEach((col) => { + queryParams.append('columns', col); + }); + queryParams.append('order_by', props.orderBy); + queryParams.append('limit', String(props.limit)); + queryParams.append('format', props.format); + + const response = await axios.get(`export_raw_data?${queryParams}`, { + responseType: 'blob', // Important: tell axios to handle response as blob + decompress: true, // Enable automatic decompression + headers: { + Accept: 'application/octet-stream', // Tell server we expect binary data + }, + timeout: 0, + }); + + // Only proceed if the response status is 200 + if (response.status !== 200) { + throw new Error( + `Failed to download data: server returned status ${response.status}`, + ); + } + // Create blob URL from response data + const blob = new Blob([response.data], { type: 'application/octet-stream' }); + const url = window.URL.createObjectURL(blob); + + // Create and configure download link + const link = document.createElement('a'); + link.href = url; + + // Get filename from Content-Disposition header or generate timestamped default + const filename = + response.headers['content-disposition'] + ?.split('filename=')[1] + ?.replace(/["']/g, '') || `exported_data.${props.format || 'txt'}`; + + link.setAttribute('download', filename); + + // Trigger download + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + } catch (error) { + ErrorResponseHandlerV2(error as AxiosError); + } +}; + +export default downloadExportData; diff --git a/frontend/src/api/v1/features/list.ts b/frontend/src/api/v1/features/list.ts new file mode 100644 index 00000000000..43a445ead5d --- /dev/null +++ b/frontend/src/api/v1/features/list.ts @@ -0,0 +1,23 @@ +import axios from 'api'; +import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; +import { AxiosError } from 'axios'; +import { ErrorV2Resp, SuccessResponseV2 } from 'types/api'; +import { + FeatureFlagProps, + PayloadProps, +} from 'types/api/features/getFeaturesFlags'; + +const list = async (): Promise> => { + try { + const response = await axios.get(`/features`); + + return { + httpStatusCode: response.status, + data: response.data.data, + }; + } catch (error) { + ErrorResponseHandlerV2(error as AxiosError); + } +}; + +export default list; diff --git a/frontend/src/api/v1/login/loginPrecheck.ts b/frontend/src/api/v1/login/loginPrecheck.ts index c0cdc3dcc43..eac00182cb5 100644 --- a/frontend/src/api/v1/login/loginPrecheck.ts +++ b/frontend/src/api/v1/login/loginPrecheck.ts @@ -2,7 +2,7 @@ import axios from 'api'; import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; import { AxiosError } from 'axios'; import { ErrorResponse, SuccessResponse } from 'types/api'; -import { PayloadProps, Props } from 'types/api/user/loginPrecheck'; +import { Props, Signup as PayloadProps } from 'types/api/user/loginPrecheck'; const loginPrecheck = async ( props: Props, diff --git a/frontend/src/api/v1/org/preferences/list.ts b/frontend/src/api/v1/org/preferences/list.ts new file mode 100644 index 00000000000..6864639908d --- /dev/null +++ b/frontend/src/api/v1/org/preferences/list.ts @@ -0,0 +1,23 @@ +import axios from 'api'; +import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; +import { AxiosError } from 'axios'; +import { ErrorV2Resp, SuccessResponseV2 } from 'types/api'; +import { PayloadProps } from 'types/api/preferences/list'; +import { OrgPreference } from 'types/api/preferences/preference'; + +const listPreference = async (): Promise< + SuccessResponseV2 +> => { + try { + const response = await axios.get(`/org/preferences`); + + return { + httpStatusCode: response.status, + data: response.data.data, + }; + } catch (error) { + ErrorResponseHandlerV2(error as AxiosError); + } +}; + +export default listPreference; diff --git a/frontend/src/api/v1/org/preferences/name/get.ts b/frontend/src/api/v1/org/preferences/name/get.ts new file mode 100644 index 00000000000..48e746d2a1b --- /dev/null +++ b/frontend/src/api/v1/org/preferences/name/get.ts @@ -0,0 +1,25 @@ +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/preferences/get'; +import { OrgPreference } from 'types/api/preferences/preference'; + +const getPreference = async ( + props: Props, +): Promise> => { + try { + const response = await axios.get( + `/org/preferences/${props.name}`, + ); + + return { + httpStatusCode: response.status, + data: response.data.data, + }; + } catch (error) { + ErrorResponseHandlerV2(error as AxiosError); + } +}; + +export default getPreference; diff --git a/frontend/src/api/v1/org/preferences/name/update.ts b/frontend/src/api/v1/org/preferences/name/update.ts new file mode 100644 index 00000000000..d226c9b96c6 --- /dev/null +++ b/frontend/src/api/v1/org/preferences/name/update.ts @@ -0,0 +1,22 @@ +import axios from 'api'; +import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; +import { AxiosError } from 'axios'; +import { ErrorV2Resp, SuccessResponseV2 } from 'types/api'; +import { Props } from 'types/api/preferences/update'; + +const update = async (props: Props): Promise> => { + try { + const response = await axios.put(`/org/preferences/${props.name}`, { + value: props.value, + }); + + return { + httpStatusCode: response.status, + data: null, + }; + } catch (error) { + ErrorResponseHandlerV2(error as AxiosError); + } +}; + +export default update; diff --git a/frontend/src/api/v1/pats/create.ts b/frontend/src/api/v1/pats/create.ts new file mode 100644 index 00000000000..c487a91bb34 --- /dev/null +++ b/frontend/src/api/v1/pats/create.ts @@ -0,0 +1,28 @@ +import axios from 'api'; +import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; +import { AxiosError } from 'axios'; +import { ErrorV2Resp, SuccessResponseV2 } from 'types/api'; +import { + APIKeyProps, + CreateAPIKeyProps, + CreatePayloadProps, +} from 'types/api/pat/types'; + +const create = async ( + props: CreateAPIKeyProps, +): Promise> => { + try { + const response = await axios.post('/pats', { + ...props, + }); + + return { + httpStatusCode: response.status, + data: response.data.data, + }; + } catch (error) { + ErrorResponseHandlerV2(error as AxiosError); + } +}; + +export default create; diff --git a/frontend/src/api/v1/pats/delete.ts b/frontend/src/api/v1/pats/delete.ts new file mode 100644 index 00000000000..716bdffc196 --- /dev/null +++ b/frontend/src/api/v1/pats/delete.ts @@ -0,0 +1,19 @@ +import axios from 'api'; +import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; +import { AxiosError } from 'axios'; +import { ErrorV2Resp, SuccessResponseV2 } from 'types/api'; + +const deleteAPIKey = async (id: string): Promise> => { + try { + const response = await axios.delete(`/pats/${id}`); + + return { + httpStatusCode: response.status, + data: null, + }; + } catch (error) { + ErrorResponseHandlerV2(error as AxiosError); + } +}; + +export default deleteAPIKey; diff --git a/frontend/src/api/v1/pats/list.ts b/frontend/src/api/v1/pats/list.ts new file mode 100644 index 00000000000..bc4833af51d --- /dev/null +++ b/frontend/src/api/v1/pats/list.ts @@ -0,0 +1,20 @@ +import axios from 'api'; +import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; +import { AxiosError } from 'axios'; +import { ErrorV2Resp, SuccessResponseV2 } from 'types/api'; +import { AllAPIKeyProps, APIKeyProps } from 'types/api/pat/types'; + +const list = async (): Promise> => { + try { + const response = await axios.get('/pats'); + + return { + httpStatusCode: response.status, + data: response.data.data, + }; + } catch (error) { + ErrorResponseHandlerV2(error as AxiosError); + } +}; + +export default list; diff --git a/frontend/src/api/v1/pats/update.ts b/frontend/src/api/v1/pats/update.ts new file mode 100644 index 00000000000..b7c1e016c8f --- /dev/null +++ b/frontend/src/api/v1/pats/update.ts @@ -0,0 +1,24 @@ +import axios from 'api'; +import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; +import { AxiosError } from 'axios'; +import { ErrorV2Resp, SuccessResponseV2 } from 'types/api'; +import { UpdateAPIKeyProps } from 'types/api/pat/types'; + +const updateAPIKey = async ( + props: UpdateAPIKeyProps, +): Promise> => { + try { + const response = await axios.put(`/pats/${props.id}`, { + ...props.data, + }); + + return { + httpStatusCode: response.status, + data: null, + }; + } catch (error) { + ErrorResponseHandlerV2(error as AxiosError); + } +}; + +export default updateAPIKey; diff --git a/frontend/src/api/v1/portal/create.ts b/frontend/src/api/v1/portal/create.ts new file mode 100644 index 00000000000..1c6854ffe29 --- /dev/null +++ b/frontend/src/api/v1/portal/create.ts @@ -0,0 +1,28 @@ +import axios from 'api'; +import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; +import { AxiosError } from 'axios'; +import { ErrorV2Resp, SuccessResponseV2 } from 'types/api'; +import { + CheckoutRequestPayloadProps, + CheckoutSuccessPayloadProps, + PayloadProps, +} from 'types/api/billing/checkout'; + +const manageCreditCardApi = async ( + props: CheckoutRequestPayloadProps, +): Promise> => { + try { + const response = await axios.post('/portal', { + url: props.url, + }); + + return { + httpStatusCode: response.status, + data: response.data.data, + }; + } catch (error) { + ErrorResponseHandlerV2(error as AxiosError); + } +}; + +export default manageCreditCardApi; diff --git a/frontend/src/api/v1/register/signup.ts b/frontend/src/api/v1/register/signup.ts index fcb483dffba..5838a8e7adf 100644 --- a/frontend/src/api/v1/register/signup.ts +++ b/frontend/src/api/v1/register/signup.ts @@ -1,25 +1,21 @@ import axios from 'api'; -import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; import { AxiosError } from 'axios'; -import { ErrorResponse, SuccessResponse } from 'types/api'; -import { PayloadProps } from 'types/api/user/loginPrecheck'; +import { ErrorV2Resp, SuccessResponseV2 } from 'types/api'; +import { PayloadProps, Signup } from 'types/api/user/loginPrecheck'; import { Props } from 'types/api/user/signup'; -const signup = async ( - props: Props, -): Promise | ErrorResponse> => { +const signup = async (props: Props): Promise> => { try { - const response = await axios.post(`/register`, { + const response = await axios.post(`/register`, { ...props, }); return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data?.data, + httpStatusCode: response.status, + data: response.data.data, }; } catch (error) { - return ErrorResponseHandler(error as AxiosError); + ErrorResponseHandlerV2(error as AxiosError); } }; diff --git a/frontend/src/api/v1/user/preferences/get.ts b/frontend/src/api/v1/user/preferences/get.ts deleted file mode 100644 index 5a29113fa44..00000000000 --- a/frontend/src/api/v1/user/preferences/get.ts +++ /dev/null @@ -1,24 +0,0 @@ -import axios from 'api'; -import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; -import { AxiosError } from 'axios'; -import { ErrorResponse, SuccessResponse } from 'types/api'; -import { PayloadProps } from 'types/api/user/getUserPreference'; - -const getPreference = async (): Promise< - SuccessResponse | ErrorResponse -> => { - try { - const response = await axios.get(`/user/preferences`); - - return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data, - }; - } catch (error) { - return ErrorResponseHandler(error as AxiosError); - } -}; - -export default getPreference; diff --git a/frontend/src/api/v1/user/preferences/list.ts b/frontend/src/api/v1/user/preferences/list.ts new file mode 100644 index 00000000000..f2af94c2c8b --- /dev/null +++ b/frontend/src/api/v1/user/preferences/list.ts @@ -0,0 +1,21 @@ +import axios from 'api'; +import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; +import { AxiosError } from 'axios'; +import { ErrorV2Resp, SuccessResponseV2 } from 'types/api'; +import { PayloadProps } from 'types/api/preferences/list'; +import { UserPreference } from 'types/api/preferences/preference'; + +const list = async (): Promise> => { + try { + const response = await axios.get(`/user/preferences`); + + return { + httpStatusCode: response.status, + data: response.data.data, + }; + } catch (error) { + ErrorResponseHandlerV2(error as AxiosError); + } +}; + +export default list; diff --git a/frontend/src/api/v1/user/preferences/name/get.ts b/frontend/src/api/v1/user/preferences/name/get.ts new file mode 100644 index 00000000000..941c96f849f --- /dev/null +++ b/frontend/src/api/v1/user/preferences/name/get.ts @@ -0,0 +1,25 @@ +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/preferences/get'; +import { UserPreference } from 'types/api/preferences/preference'; + +const get = async ( + props: Props, +): Promise> => { + try { + const response = await axios.get( + `/user/preferences/${props.name}`, + ); + + return { + httpStatusCode: response.status, + data: response.data.data, + }; + } catch (error) { + ErrorResponseHandlerV2(error as AxiosError); + } +}; + +export default get; diff --git a/frontend/src/api/v1/user/preferences/name/update.ts b/frontend/src/api/v1/user/preferences/name/update.ts new file mode 100644 index 00000000000..1f9605f8772 --- /dev/null +++ b/frontend/src/api/v1/user/preferences/name/update.ts @@ -0,0 +1,22 @@ +import axios from 'api'; +import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; +import { AxiosError } from 'axios'; +import { ErrorV2Resp, SuccessResponseV2 } from 'types/api'; +import { Props } from 'types/api/preferences/update'; + +const update = async (props: Props): Promise> => { + try { + const response = await axios.put(`/user/preferences/${props.name}`, { + value: props.value, + }); + + return { + httpStatusCode: response.status, + data: null, + }; + } catch (error) { + ErrorResponseHandlerV2(error as AxiosError); + } +}; + +export default update; diff --git a/frontend/src/api/v3/licenses/active/get.ts b/frontend/src/api/v3/licenses/active/get.ts new file mode 100644 index 00000000000..7bf73e95cad --- /dev/null +++ b/frontend/src/api/v3/licenses/active/get.ts @@ -0,0 +1,25 @@ +import { ApiV3Instance as axios } from 'api'; +import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; +import { AxiosError } from 'axios'; +import { ErrorV2Resp, SuccessResponseV2 } from 'types/api'; +import { + LicenseEventQueueResModel, + PayloadProps, +} from 'types/api/licensesV3/getActive'; + +const getActive = async (): Promise< + SuccessResponseV2 +> => { + try { + const response = await axios.get('/licenses/active'); + + return { + httpStatusCode: response.status, + data: response.data.data, + }; + } catch (error) { + ErrorResponseHandlerV2(error as AxiosError); + } +}; + +export default getActive; diff --git a/frontend/src/api/v3/licenses/post.ts b/frontend/src/api/v3/licenses/post.ts new file mode 100644 index 00000000000..4cd971acc0e --- /dev/null +++ b/frontend/src/api/v3/licenses/post.ts @@ -0,0 +1,24 @@ +import { ApiV3Instance as axios } from 'api'; +import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; +import { AxiosError } from 'axios'; +import { ErrorV2Resp, SuccessResponseV2 } from 'types/api'; +import { PayloadProps, Props } from 'types/api/licenses/apply'; + +const apply = async ( + props: Props, +): Promise> => { + try { + const response = await axios.post('/licenses', { + key: props.key, + }); + + return { + httpStatusCode: response.status, + data: response.data, + }; + } catch (error) { + ErrorResponseHandlerV2(error as AxiosError); + } +}; + +export default apply; diff --git a/frontend/src/api/v3/licenses/put.ts b/frontend/src/api/v3/licenses/put.ts new file mode 100644 index 00000000000..d07ad428de7 --- /dev/null +++ b/frontend/src/api/v3/licenses/put.ts @@ -0,0 +1,20 @@ +import { ApiV3Instance as axios } from 'api'; +import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; +import { AxiosError } from 'axios'; +import { ErrorV2Resp, SuccessResponseV2 } from 'types/api'; +import { PayloadProps } from 'types/api/licenses/apply'; + +const apply = async (): Promise> => { + try { + const response = await axios.put('/licenses'); + + return { + httpStatusCode: response.status, + data: response.data, + }; + } catch (error) { + ErrorResponseHandlerV2(error as AxiosError); + } +}; + +export default apply; diff --git a/frontend/src/api/v5/queryRange/constants.ts b/frontend/src/api/v5/queryRange/constants.ts new file mode 100644 index 00000000000..19b264cf237 --- /dev/null +++ b/frontend/src/api/v5/queryRange/constants.ts @@ -0,0 +1,168 @@ +// V5 Query Range Constants + +import { ENTITY_VERSION_V5 } from 'constants/app'; +import { + FunctionName, + RequestType, + SignalType, + Step, +} from 'types/api/v5/queryRange'; + +// ===================== Schema and Version Constants ===================== + +export const SCHEMA_VERSION_V5 = ENTITY_VERSION_V5; +export const API_VERSION_V5 = 'v5'; + +// ===================== Default Values ===================== + +export const DEFAULT_STEP_INTERVAL: Step = '60s'; +export const DEFAULT_LIMIT = 100; +export const DEFAULT_OFFSET = 0; + +// ===================== Request Type Constants ===================== + +export const REQUEST_TYPES: Record = { + SCALAR: 'scalar', + TIME_SERIES: 'time_series', + RAW: 'raw', + DISTRIBUTION: 'distribution', +} as const; + +// ===================== Signal Type Constants ===================== + +export const SIGNAL_TYPES: Record = { + TRACES: 'traces', + LOGS: 'logs', + METRICS: 'metrics', +} as const; + +// ===================== Common Aggregation Expressions ===================== + +export const TRACE_AGGREGATIONS = { + COUNT: 'count()', + COUNT_DISTINCT_TRACE_ID: 'count_distinct(traceID)', + AVG_DURATION: 'avg(duration_nano)', + P50_DURATION: 'p50(duration_nano)', + P95_DURATION: 'p95(duration_nano)', + P99_DURATION: 'p99(duration_nano)', + MAX_DURATION: 'max(duration_nano)', + MIN_DURATION: 'min(duration_nano)', + SUM_DURATION: 'sum(duration_nano)', +} as const; + +export const LOG_AGGREGATIONS = { + COUNT: 'count()', + COUNT_DISTINCT_HOST: 'count_distinct(host.name)', + COUNT_DISTINCT_SERVICE: 'count_distinct(service.name)', + COUNT_DISTINCT_CONTAINER: 'count_distinct(container.name)', +} as const; + +// ===================== Common Filter Expressions ===================== + +export const COMMON_FILTERS = { + // Trace filters + SERVER_SPANS: "kind_string = 'Server'", + CLIENT_SPANS: "kind_string = 'Client'", + INTERNAL_SPANS: "kind_string = 'Internal'", + ERROR_SPANS: 'http.status_code >= 400', + SUCCESS_SPANS: 'http.status_code < 400', + + // Common service filters + EXCLUDE_HEALTH_CHECKS: "http.route != '/health' AND http.route != '/ping'", + HTTP_REQUESTS: "http.method != ''", + + // Log filters + ERROR_LOGS: "severity_text = 'ERROR'", + WARN_LOGS: "severity_text = 'WARN'", + INFO_LOGS: "severity_text = 'INFO'", + DEBUG_LOGS: "severity_text = 'DEBUG'", +} as const; + +// ===================== Common Group By Fields ===================== + +export const COMMON_GROUP_BY_FIELDS = { + SERVICE_NAME: { + name: 'service.name', + fieldDataType: 'string' as const, + fieldContext: 'resource' as const, + }, + HTTP_METHOD: { + name: 'http.method', + fieldDataType: 'string' as const, + fieldContext: 'attribute' as const, + }, + HTTP_ROUTE: { + name: 'http.route', + fieldDataType: 'string' as const, + fieldContext: 'attribute' as const, + }, + HTTP_STATUS_CODE: { + name: 'http.status_code', + fieldDataType: 'int64' as const, + fieldContext: 'attribute' as const, + }, + HOST_NAME: { + name: 'host.name', + fieldDataType: 'string' as const, + fieldContext: 'resource' as const, + }, + CONTAINER_NAME: { + name: 'container.name', + fieldDataType: 'string' as const, + fieldContext: 'resource' as const, + }, +} as const; + +// ===================== Function Names ===================== + +export const FUNCTION_NAMES: Record = { + CUT_OFF_MIN: 'cutOffMin', + CUT_OFF_MAX: 'cutOffMax', + CLAMP_MIN: 'clampMin', + CLAMP_MAX: 'clampMax', + ABSOLUTE: 'absolute', + RUNNING_DIFF: 'runningDiff', + LOG2: 'log2', + LOG10: 'log10', + CUM_SUM: 'cumulativeSum', + EWMA3: 'ewma3', + EWMA5: 'ewma5', + EWMA7: 'ewma7', + MEDIAN3: 'median3', + MEDIAN5: 'median5', + MEDIAN7: 'median7', + TIME_SHIFT: 'timeShift', + ANOMALY: 'anomaly', +} as const; + +// ===================== Common Step Intervals ===================== + +export const STEP_INTERVALS = { + FIFTEEN_SECONDS: '15s', + THIRTY_SECONDS: '30s', + ONE_MINUTE: '60s', + FIVE_MINUTES: '300s', + TEN_MINUTES: '600s', + FIFTEEN_MINUTES: '900s', + THIRTY_MINUTES: '1800s', + ONE_HOUR: '3600s', + TWO_HOURS: '7200s', + SIX_HOURS: '21600s', + TWELVE_HOURS: '43200s', + ONE_DAY: '86400s', +} as const; + +// ===================== Time Range Presets ===================== + +export const TIME_RANGE_PRESETS = { + LAST_5_MINUTES: 5 * 60 * 1000, + LAST_15_MINUTES: 15 * 60 * 1000, + LAST_30_MINUTES: 30 * 60 * 1000, + LAST_HOUR: 60 * 60 * 1000, + LAST_3_HOURS: 3 * 60 * 60 * 1000, + LAST_6_HOURS: 6 * 60 * 60 * 1000, + LAST_12_HOURS: 12 * 60 * 60 * 1000, + LAST_24_HOURS: 24 * 60 * 60 * 1000, + LAST_3_DAYS: 3 * 24 * 60 * 60 * 1000, + LAST_7_DAYS: 7 * 24 * 60 * 60 * 1000, +} as const; diff --git a/frontend/src/api/v5/queryRange/convertV5Response.test.ts b/frontend/src/api/v5/queryRange/convertV5Response.test.ts new file mode 100644 index 00000000000..0e2d8261873 --- /dev/null +++ b/frontend/src/api/v5/queryRange/convertV5Response.test.ts @@ -0,0 +1,284 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +import { SuccessResponse } from 'types/api'; +import { + MetricRangePayloadV5, + QueryBuilderFormula, + QueryRangeRequestV5, + QueryRangeResponseV5, + RequestType, + ScalarData, + TelemetryFieldKey, + TimeSeries, + TimeSeriesData, + TimeSeriesValue, +} from 'types/api/v5/queryRange'; + +import { convertV5ResponseToLegacy } from './convertV5Response'; + +describe('convertV5ResponseToLegacy', () => { + function makeBaseSuccess( + payload: T, + params: QueryRangeRequestV5, + ): SuccessResponse { + return { + statusCode: 200, + message: 'success', + payload, + error: null, + params, + }; + } + + function makeBaseParams( + requestType: RequestType, + queries: QueryRangeRequestV5['compositeQuery']['queries'], + ): QueryRangeRequestV5 { + return { + schemaVersion: 'v1', + start: 1, + end: 2, + requestType, + compositeQuery: { queries }, + variables: {}, + formatOptions: { formatTableResultForUI: false, fillGaps: false }, + }; + } + + it('converts time_series response into legacy series structure', () => { + const timeSeries: TimeSeriesData = { + queryName: 'A', + aggregations: [ + { + index: 0, + alias: '__result_0', + meta: {}, + series: [ + ({ + labels: [ + { + key: ({ name: 'service.name' } as unknown) as TelemetryFieldKey, + value: 'adservice', + }, + ], + values: [ + ({ timestamp: 1000, value: 10 } as unknown) as TimeSeriesValue, + ({ timestamp: 2000, value: 12 } as unknown) as TimeSeriesValue, + ], + } as unknown) as TimeSeries, + ], + }, + ], + }; + + const v5Data: QueryRangeResponseV5 = { + type: 'time_series', + data: { results: [timeSeries] }, + meta: { rowsScanned: 0, bytesScanned: 0, durationMs: 0 }, + }; + + const params = makeBaseParams('time_series', [ + { + type: 'builder_query', + spec: { + name: 'A', + signal: 'traces', + stepInterval: 60, + disabled: false, + aggregations: [{ expression: 'count()' }], + }, + }, + ]); + + const input: SuccessResponse< + MetricRangePayloadV5, + QueryRangeRequestV5 + > = makeBaseSuccess({ data: v5Data }, params); + + const legendMap = { A: '{{service.name}}' }; + const result = convertV5ResponseToLegacy(input, legendMap, false); + + expect(result.payload.data.resultType).toBe('time_series'); + expect(result.payload.data.result).toHaveLength(1); + const q = result.payload.data.result[0]; + expect(q.queryName).toBe('A'); + expect(q.legend).toBe('{{service.name}}'); + expect(q.series?.[0]).toEqual( + expect.objectContaining({ + labels: { 'service.name': 'adservice' }, + values: [ + { timestamp: 1000, value: '10' }, + { timestamp: 2000, value: '12' }, + ], + metaData: expect.objectContaining({ + alias: '__result_0', + index: 0, + queryName: 'A', + }), + }), + ); + }); + + it('converts scalar to legacy table (formatForWeb=false) with names/ids resolved from aggregations', () => { + const scalar: ScalarData = { + columns: [ + // group column + ({ + name: 'service.name', + queryName: 'A', + aggregationIndex: 0, + columnType: 'group', + } as unknown) as ScalarData['columns'][number], + // aggregation 0 + ({ + name: '__result_0', + queryName: 'A', + aggregationIndex: 0, + columnType: 'aggregation', + } as unknown) as ScalarData['columns'][number], + // aggregation 1 + ({ + name: '__result_1', + queryName: 'A', + aggregationIndex: 1, + columnType: 'aggregation', + } as unknown) as ScalarData['columns'][number], + // formula F1 + ({ + name: '__result', + queryName: 'F1', + aggregationIndex: 0, + columnType: 'aggregation', + } as unknown) as ScalarData['columns'][number], + ], + data: [['adservice', 606, 1.452, 151.5]], + }; + + const v5Data: QueryRangeResponseV5 = { + type: 'scalar', + data: { results: [scalar] }, + meta: { rowsScanned: 0, bytesScanned: 0, durationMs: 0 }, + }; + + const params = makeBaseParams('scalar', [ + { + type: 'builder_query', + spec: { + name: 'A', + signal: 'traces', + stepInterval: 60, + disabled: false, + aggregations: [ + { expression: 'count()' }, + { expression: 'avg(app.ads.count)', alias: 'avg' }, + ], + }, + }, + { + type: 'builder_formula', + spec: ({ + name: 'F1', + expression: 'A * 0.25', + } as unknown) as QueryBuilderFormula, + }, + ]); + + const input: SuccessResponse< + MetricRangePayloadV5, + QueryRangeRequestV5 + > = makeBaseSuccess({ data: v5Data }, params); + const legendMap = { A: '{{service.name}}', F1: '' }; + const result = convertV5ResponseToLegacy(input, legendMap, false); + + expect(result.payload.data.resultType).toBe('scalar'); + const [tableEntry] = result.payload.data.result; + expect(tableEntry.table?.columns).toEqual([ + { + name: 'service.name', + queryName: 'A', + isValueColumn: false, + id: 'service.name', + }, + { name: 'count()', queryName: 'A', isValueColumn: true, id: 'A.count()' }, + { + name: 'avg', + queryName: 'A', + isValueColumn: true, + id: 'A.avg(app.ads.count)', + }, + { name: 'F1', queryName: 'F1', isValueColumn: true, id: 'F1' }, + ]); + expect(tableEntry.table?.rows?.[0]).toEqual({ + data: { + 'service.name': 'adservice', + 'A.count()': 606, + 'A.avg(app.ads.count)': 1.452, + F1: 151.5, + }, + }); + }); + + it('converts scalar with formatForWeb=true to UI-friendly table', () => { + const scalar: ScalarData = { + columns: [ + { + name: 'service.name', + queryName: 'A', + aggregationIndex: 0, + columnType: 'group', + } as any, + { + name: '__result_0', + queryName: 'A', + aggregationIndex: 0, + columnType: 'aggregation', + } as any, + ], + data: [['adservice', 580]], + }; + + const v5Data: QueryRangeResponseV5 = { + type: 'scalar', + data: { results: [scalar] }, + meta: { rowsScanned: 0, bytesScanned: 0, durationMs: 0 }, + }; + + const params = makeBaseParams('scalar', [ + { + type: 'builder_query', + spec: { + name: 'A', + signal: 'traces', + stepInterval: 60, + disabled: false, + aggregations: [{ expression: 'count()' }], + }, + }, + ]); + + const input: SuccessResponse< + MetricRangePayloadV5, + QueryRangeRequestV5 + > = makeBaseSuccess({ data: v5Data }, params); + const legendMap = { A: '{{service.name}}' }; + const result = convertV5ResponseToLegacy(input, legendMap, true); + + expect(result.payload.data.resultType).toBe('scalar'); + const [tableEntry] = result.payload.data.result; + expect(tableEntry.table?.columns).toEqual([ + { + name: 'service.name', + queryName: 'A', + isValueColumn: false, + id: 'service.name', + }, + // Single aggregation: name resolves to legend, id resolves to queryName + { name: '{{service.name}}', queryName: 'A', isValueColumn: true, id: 'A' }, + ]); + expect(tableEntry.table?.rows?.[0]).toEqual({ + data: { + 'service.name': 'adservice', + A: 580, + }, + }); + }); +}); diff --git a/frontend/src/api/v5/queryRange/convertV5Response.ts b/frontend/src/api/v5/queryRange/convertV5Response.ts new file mode 100644 index 00000000000..f4328dc8072 --- /dev/null +++ b/frontend/src/api/v5/queryRange/convertV5Response.ts @@ -0,0 +1,439 @@ +import { cloneDeep, isEmpty } from 'lodash-es'; +import { SuccessResponse, Warning } from 'types/api'; +import { MetricRangePayloadV3 } from 'types/api/metrics/getQueryRange'; +import { + DistributionData, + MetricRangePayloadV5, + QueryRangeRequestV5, + RawData, + ScalarData, + TimeSeriesData, +} from 'types/api/v5/queryRange'; +import { QueryDataV3 } from 'types/api/widgets/getQuery'; + +function getColName( + col: ScalarData['columns'][number], + legendMap: Record, + aggregationPerQuery: Record, +): string { + if (col.columnType === 'group') { + return col.name; + } + + const aggregation = + aggregationPerQuery?.[col.queryName]?.[col.aggregationIndex]; + const legend = legendMap[col.queryName]; + const alias = aggregation?.alias; + const expression = aggregation?.expression || ''; + const aggregationsCount = aggregationPerQuery[col.queryName]?.length || 0; + const isSingleAggregation = aggregationsCount === 1; + + if (aggregationsCount > 0) { + // Single aggregation: Priority is alias > legend > expression + if (isSingleAggregation) { + return alias || legend || expression || col.queryName; + } + + // Multiple aggregations: Each follows single rules BUT never shows legend + // Priority: alias > expression (legend is ignored for multiple aggregations) + return alias || expression || col.queryName; + } + + return legend || col.queryName; +} + +function getColId( + col: ScalarData['columns'][number], + aggregationPerQuery: Record, +): string { + if (col.columnType === 'group') { + return col.name; + } + const aggregation = + aggregationPerQuery?.[col.queryName]?.[col.aggregationIndex]; + const expression = aggregation?.expression || ''; + const aggregationsCount = aggregationPerQuery[col.queryName]?.length || 0; + const isMultipleAggregations = aggregationsCount > 1; + + if (isMultipleAggregations && expression) { + return `${col.queryName}.${expression}`; + } + + return col.queryName; +} + +/** + * Converts V5 TimeSeriesData to legacy format + */ +function convertTimeSeriesData( + timeSeriesData: TimeSeriesData, + legendMap: Record, +): QueryDataV3 { + // Convert V5 time series format to legacy QueryDataV3 format + + // Helper function to process series data + const processSeriesData = ( + aggregations: any[], + seriesKey: + | 'series' + | 'predictedSeries' + | 'upperBoundSeries' + | 'lowerBoundSeries' + | 'anomalyScores', + ): any[] => + aggregations?.flatMap((aggregation) => { + const { index, alias } = aggregation; + const seriesData = aggregation[seriesKey]; + + if (!seriesData || !seriesData.length) { + return []; + } + + return seriesData.map((series: any) => ({ + labels: series.labels + ? Object.fromEntries( + series.labels.map((label: any) => [label.key.name, label.value]), + ) + : {}, + labelsArray: series.labels + ? series.labels.map((label: any) => ({ [label.key.name]: label.value })) + : [], + values: series.values.map((value: any) => ({ + timestamp: value.timestamp, + value: String(value.value), + })), + metaData: { + alias, + index, + queryName: timeSeriesData.queryName, + }, + })); + }); + + return { + queryName: timeSeriesData.queryName, + legend: legendMap[timeSeriesData.queryName] || timeSeriesData.queryName, + series: processSeriesData(timeSeriesData?.aggregations, 'series'), + predictedSeries: processSeriesData( + timeSeriesData?.aggregations, + 'predictedSeries', + ), + upperBoundSeries: processSeriesData( + timeSeriesData?.aggregations, + 'upperBoundSeries', + ), + lowerBoundSeries: processSeriesData( + timeSeriesData?.aggregations, + 'lowerBoundSeries', + ), + anomalyScores: processSeriesData( + timeSeriesData?.aggregations, + 'anomalyScores', + ), + list: null, + }; +} + +/** + * Converts V5 ScalarData array to legacy format with table structure + */ +function convertScalarDataArrayToTable( + scalarDataArray: ScalarData[], + legendMap: Record, + aggregationPerQuery: Record, +): QueryDataV3[] { + // If no scalar data, return empty structure + + if (!scalarDataArray || scalarDataArray.length === 0) { + return []; + } + + // Process each scalar data separately to maintain query separation + return scalarDataArray?.map((scalarData) => { + // Get query name from the first column + const queryName = scalarData?.columns?.[0]?.queryName || ''; + + if ((scalarData as any)?.aggregations?.length > 0) { + return { + ...convertTimeSeriesData(scalarData as any, legendMap), + table: { + columns: [], + rows: [], + }, + list: null, + }; + } + + // Collect columns for this specific query + const columns = scalarData?.columns?.map((col) => ({ + name: getColName(col, legendMap, aggregationPerQuery), + queryName: col.queryName, + isValueColumn: col.columnType === 'aggregation', + id: getColId(col, aggregationPerQuery), + })); + + // Process rows for this specific query + const rows = scalarData?.data?.map((dataRow) => { + const rowData: Record = {}; + + scalarData?.columns?.forEach((col, colIndex) => { + const columnName = getColName(col, legendMap, aggregationPerQuery); + const columnId = getColId(col, aggregationPerQuery); + rowData[columnId || columnName] = dataRow[colIndex]; + }); + + return { data: rowData }; + }); + + return { + queryName, + legend: legendMap[queryName] || '', + series: null, + list: null, + table: { + columns, + rows, + }, + }; + }); +} + +function convertScalarWithFormatForWeb( + scalarDataArray: ScalarData[], + legendMap: Record, + aggregationPerQuery: Record, +): QueryDataV3[] { + if (!scalarDataArray || scalarDataArray.length === 0) { + return []; + } + + return scalarDataArray.map((scalarData) => { + const columns = + scalarData.columns?.map((col) => { + const colName = getColName(col, legendMap, aggregationPerQuery); + + return { + name: colName, + queryName: col.queryName, + isValueColumn: col.columnType === 'aggregation', + id: getColId(col, aggregationPerQuery), + }; + }) || []; + + const rows = + scalarData.data?.map((dataRow) => { + const rowData: Record = {}; + columns?.forEach((col, colIndex) => { + rowData[col.id || col.name] = dataRow[colIndex]; + }); + return { data: rowData }; + }) || []; + + const queryName = scalarData.columns?.[0]?.queryName || ''; + + return { + queryName, + legend: legendMap[queryName] || queryName, + series: null, + list: null, + table: { + columns, + rows, + }, + }; + }); +} + +/** + * Converts V5 RawData to legacy format + */ +function convertRawData( + rawData: RawData, + legendMap: Record, +): QueryDataV3 { + // Convert V5 raw format to legacy QueryDataV3 format + return { + queryName: rawData.queryName, + legend: legendMap[rawData.queryName] || rawData.queryName, + series: null, + list: rawData.rows?.map((row) => ({ + timestamp: row.timestamp, + data: { + // Map raw data to ILog structure - spread row.data first to include all properties + ...row.data, + date: row.timestamp, + } as any, + })), + }; +} + +/** + * Converts V5 DistributionData to legacy format + */ +function convertDistributionData( + distributionData: DistributionData, + legendMap: Record, +): any { + // eslint-disable-line @typescript-eslint/no-explicit-any + // Convert V5 distribution format to legacy histogram format + return { + ...distributionData, + legendMap, + }; +} + +/** + * Helper function to convert V5 data based on type + */ +function convertV5DataByType( + v5Data: any, + legendMap: Record, + aggregationPerQuery: Record, +): MetricRangePayloadV3['data'] { + switch (v5Data?.type) { + case 'time_series': { + const timeSeriesData = v5Data.data.results as TimeSeriesData[]; + return { + resultType: 'time_series', + result: timeSeriesData.map((timeSeries) => + convertTimeSeriesData(timeSeries, legendMap), + ), + }; + } + case 'scalar': { + const scalarData = v5Data.data.results as ScalarData[]; + // For scalar data, combine all results into separate table entries + const combinedTables = convertScalarDataArrayToTable( + scalarData, + legendMap, + aggregationPerQuery, + ); + return { + resultType: 'scalar', + result: combinedTables, + }; + } + case 'raw': { + const rawData = v5Data.data.results as RawData[]; + return { + resultType: 'raw', + result: rawData.map((raw) => convertRawData(raw, legendMap)), + }; + } + case 'trace': { + const traceData = v5Data.data.results as RawData[]; + return { + resultType: 'trace', + result: traceData.map((trace) => convertRawData(trace, legendMap)), + }; + } + case 'distribution': { + const distributionData = v5Data.data.results as DistributionData[]; + return { + resultType: 'distribution', + result: distributionData.map((distribution) => + convertDistributionData(distribution, legendMap), + ), + }; + } + default: + return { + resultType: '', + result: [], + }; + } +} + +/** + * Converts V5 API response to legacy format expected by frontend components + */ +// eslint-disable-next-line sonarjs/cognitive-complexity +export function convertV5ResponseToLegacy( + v5Response: SuccessResponse, + legendMap: Record, + formatForWeb?: boolean, +): SuccessResponse & { warning?: Warning } { + const { payload, params } = v5Response; + const v5Data = payload?.data; + + const aggregationPerQuery = + (params as QueryRangeRequestV5)?.compositeQuery?.queries + ?.filter((query) => query.type === 'builder_query') + .reduce((acc, query) => { + if ( + query.type === 'builder_query' && + 'aggregations' in query.spec && + query.spec.name + ) { + acc[query.spec.name] = query.spec.aggregations; + } + return acc; + }, {} as Record) || {}; + + // If formatForWeb is true, return as-is (like existing logic) + if (formatForWeb && v5Data?.type === 'scalar') { + const scalarData = v5Data.data.results as ScalarData[]; + const webTables = convertScalarWithFormatForWeb( + scalarData, + legendMap, + aggregationPerQuery, + ); + + return { + ...v5Response, + payload: { + data: { + resultType: 'scalar', + result: webTables, + warnings: v5Data?.data?.warning || [], + }, + warning: v5Data?.warning || undefined, + }, + warning: v5Data?.warning || undefined, + }; + } + + // Convert based on V5 response type + const convertedData = convertV5DataByType( + v5Data, + legendMap, + aggregationPerQuery, + ); + + // Create legacy-compatible response structure + const legacyResponse: SuccessResponse = { + ...v5Response, + payload: { + data: convertedData, + warning: v5Response.payload?.data?.warning || undefined, + }, + }; + + // Apply legend mapping (similar to existing logic) + if (legacyResponse.payload?.data?.result) { + legacyResponse.payload.data.result = legacyResponse.payload.data.result.map( + (queryData: any) => { + // eslint-disable-line @typescript-eslint/no-explicit-any + const newQueryData = cloneDeep(queryData); + newQueryData.legend = legendMap[queryData.queryName]; + + // If metric names is an empty object + if (isEmpty(queryData.metric)) { + // If metrics list is empty && the user haven't defined a legend then add the legend equal to the name of the query. + if (newQueryData.legend === undefined || newQueryData.legend === null) { + newQueryData.legend = queryData.queryName; + } + // If name of the query and the legend if inserted is same then add the same to the metrics object. + if (queryData.queryName === newQueryData.legend) { + newQueryData.metric = newQueryData.metric || {}; + newQueryData.metric[queryData.queryName] = queryData.queryName; + } + } + + return newQueryData; + }, + ); + } + + return legacyResponse; +} diff --git a/frontend/src/api/v5/queryRange/getQueryRange.ts b/frontend/src/api/v5/queryRange/getQueryRange.ts new file mode 100644 index 00000000000..35aa474e6cb --- /dev/null +++ b/frontend/src/api/v5/queryRange/getQueryRange.ts @@ -0,0 +1,45 @@ +import { ApiV5Instance } from 'api'; +import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; +import { AxiosError } from 'axios'; +import { ENTITY_VERSION_V5 } from 'constants/app'; +import { ErrorV2Resp, SuccessResponseV2 } from 'types/api'; +import { + MetricRangePayloadV5, + QueryRangePayloadV5, +} from 'types/api/v5/queryRange'; + +export const getQueryRangeV5 = async ( + props: QueryRangePayloadV5, + version: string, + signal: AbortSignal, + headers?: Record, +): Promise> => { + try { + if (version && version === ENTITY_VERSION_V5) { + const response = await ApiV5Instance.post('/query_range', props, { + signal, + headers, + }); + + return { + httpStatusCode: response.status, + data: response.data, + }; + } + + // Default V5 behavior + const response = await ApiV5Instance.post('/query_range', props, { + signal, + headers, + }); + + return { + httpStatusCode: response.status, + data: response.data.data, + }; + } catch (error) { + ErrorResponseHandlerV2(error as AxiosError); + } +}; + +export default getQueryRangeV5; diff --git a/frontend/src/api/v5/queryRange/prepareQueryRangePayloadV5.test.ts b/frontend/src/api/v5/queryRange/prepareQueryRangePayloadV5.test.ts new file mode 100644 index 00000000000..d3845299398 --- /dev/null +++ b/frontend/src/api/v5/queryRange/prepareQueryRangePayloadV5.test.ts @@ -0,0 +1,893 @@ +/* eslint-disable sonarjs/no-duplicate-string, simple-import-sort/imports, @typescript-eslint/indent, no-mixed-spaces-and-tabs */ +import { PANEL_TYPES } from 'constants/queryBuilder'; +import { + IBuilderFormula, + IBuilderQuery, +} from 'types/api/queryBuilder/queryBuilderData'; +import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults'; +import { + ClickHouseQuery, + LogAggregation, + LogBuilderQuery, + MetricBuilderQuery, + PromQuery, + QueryBuilderFormula as V5QueryBuilderFormula, + QueryEnvelope, + QueryRangePayloadV5, +} from 'types/api/v5/queryRange'; +import { EQueryType } from 'types/common/dashboard'; +import { DataSource } from 'types/common/queryBuilder'; +import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse'; + +import { prepareQueryRangePayloadV5 } from './prepareQueryRangePayloadV5'; + +jest.mock('lib/getStartEndRangeTime', () => ({ + __esModule: true, + default: jest.fn(() => ({ start: '100', end: '200' })), +})); + +describe('prepareQueryRangePayloadV5', () => { + const start = 1_710_000_000; // seconds + const end = 1_710_000_600; // seconds + + const baseBuilderQuery = ( + overrides?: Partial, + ): IBuilderQuery => ({ + queryName: 'A', + dataSource: DataSource.METRICS, + aggregations: [ + { + metricName: 'cpu_usage', + temporality: '', + timeAggregation: 'sum', + spaceAggregation: 'avg', + reduceTo: 'avg', + }, + ], + timeAggregation: 'sum', + spaceAggregation: 'avg', + temporality: '', + functions: [ + { + name: 'timeShift', + args: [{ value: '5m' }], + }, + ], + filter: { expression: '' }, + filters: { items: [], op: 'AND' }, + groupBy: [], + expression: 'A', + disabled: false, + having: [], + limit: null, + stepInterval: 600, + orderBy: [], + reduceTo: 'avg', + legend: 'Legend A', + ...overrides, + }); + + const baseFormula = ( + overrides?: Partial, + ): IBuilderFormula => ({ + expression: 'A + 1', + disabled: false, + queryName: 'F1', + legend: 'Formula Legend', + limit: undefined, + having: [], + stepInterval: undefined, + orderBy: [], + ...overrides, + }); + + it('builds payload for builder queries with formulas and variables', () => { + const props: GetQueryResultsProps = { + query: { + queryType: EQueryType.QUERY_BUILDER, + id: 'q1', + unit: undefined, + promql: [], + clickhouse_sql: [], + builder: { + queryData: [baseBuilderQuery()], + queryFormulas: [baseFormula()], + queryTraceOperator: [], + }, + }, + graphType: PANEL_TYPES.TIME_SERIES, + selectedTime: 'GLOBAL_TIME', + start, + end, + variables: { svc: 'api', count: 5, flag: true }, + fillGaps: true, + }; + + const result = prepareQueryRangePayloadV5(props); + + expect(result).toEqual( + expect.objectContaining({ + legendMap: { A: 'Legend A', F1: 'Formula Legend' }, + queryPayload: expect.objectContaining({ + compositeQuery: expect.objectContaining({ + queries: expect.arrayContaining([ + expect.objectContaining({ + type: 'builder_query', + spec: expect.objectContaining({ + name: 'A', + signal: 'metrics', + stepInterval: 600, + functions: [{ name: 'timeShift', args: [{ value: '5m' }] }], + aggregations: [ + expect.objectContaining({ + metricName: 'cpu_usage', + timeAggregation: 'sum', + spaceAggregation: 'avg', + reduceTo: undefined, + }), + ], + }), + }), + expect.objectContaining({ + type: 'builder_formula', + spec: expect.objectContaining({ + name: 'F1', + expression: 'A + 1', + legend: 'Formula Legend', + }), + }), + ]), + }), + requestType: 'time_series', + formatOptions: expect.objectContaining({ + formatTableResultForUI: false, + fillGaps: true, + }), + start: start * 1000, + end: end * 1000, + variables: expect.objectContaining({ + svc: { value: 'api' }, + count: { value: 5 }, + flag: { value: true }, + }), + }), + }), + ); + + // Legend map combines builder and formulas + expect(result.legendMap).toEqual({ A: 'Legend A', F1: 'Formula Legend' }); + + const payload: QueryRangePayloadV5 = result.queryPayload; + + expect(payload.schemaVersion).toBe('v1'); + expect(payload.start).toBe(start * 1000); + expect(payload.end).toBe(end * 1000); + expect(payload.requestType).toBe('time_series'); + expect(payload.formatOptions?.formatTableResultForUI).toBe(false); + expect(payload.formatOptions?.fillGaps).toBe(true); + + // Variables mapped as { key: { value } } + expect(payload.variables).toEqual({ + svc: { value: 'api' }, + count: { value: 5 }, + flag: { value: true }, + }); + + // Queries include one builder_query and one builder_formula + expect(payload.compositeQuery.queries).toHaveLength(2); + + const builderQuery = payload.compositeQuery.queries.find( + (q) => q.type === 'builder_query', + ) as QueryEnvelope; + const builderSpec = builderQuery.spec as MetricBuilderQuery; + expect(builderSpec.name).toBe('A'); + expect(builderSpec.signal).toBe('metrics'); + expect(builderSpec.aggregations?.[0]).toMatchObject({ + metricName: 'cpu_usage', + timeAggregation: 'sum', + spaceAggregation: 'avg', + }); + // reduceTo should not be present for non-scalar panels + expect(builderSpec.aggregations?.[0].reduceTo).toBeUndefined(); + // functions should be preserved/normalized + expect(builderSpec.functions?.[0]?.name).toBe('timeShift'); + + const formulaQuery = payload.compositeQuery.queries.find( + (q) => q.type === 'builder_formula', + ) as QueryEnvelope; + const formulaSpec = formulaQuery.spec as V5QueryBuilderFormula; + expect(formulaSpec.name).toBe('F1'); + expect(formulaSpec.expression).toBe('A + 1'); + expect(formulaSpec.legend).toBe('Formula Legend'); + }); + + it('builds payload for PromQL queries and respects originalGraphType for formatting', () => { + const props: GetQueryResultsProps = { + query: { + queryType: EQueryType.PROM, + id: 'q2', + unit: undefined, + promql: [ + { + name: 'A', + query: 'up', + disabled: false, + legend: 'LP', + }, + ], + clickhouse_sql: [], + builder: { queryData: [], queryFormulas: [], queryTraceOperator: [] }, + }, + graphType: PANEL_TYPES.TIME_SERIES, + originalGraphType: PANEL_TYPES.TABLE, + selectedTime: 'GLOBAL_TIME', + start, + end, + }; + + const result = prepareQueryRangePayloadV5(props); + + expect(result).toEqual( + expect.objectContaining({ + legendMap: { A: 'LP' }, + queryPayload: expect.objectContaining({ + compositeQuery: expect.objectContaining({ + queries: [ + { + type: 'promql', + spec: expect.objectContaining({ + name: 'A', + query: 'up', + legend: 'LP', + stats: false, + }), + }, + ], + }), + requestType: 'time_series', + formatOptions: expect.objectContaining({ + formatTableResultForUI: true, + fillGaps: false, + }), + start: start * 1000, + end: end * 1000, + variables: {}, + }), + }), + ); + + expect(result.legendMap).toEqual({ A: 'LP' }); + + const payload: QueryRangePayloadV5 = result.queryPayload; + expect(payload.requestType).toBe('time_series'); + expect(payload.formatOptions?.formatTableResultForUI).toBe(true); + expect(payload.compositeQuery.queries).toHaveLength(1); + + const prom = payload.compositeQuery.queries[0]; + expect(prom.type).toBe('promql'); + const promSpec = prom.spec as PromQuery; + expect(promSpec.name).toBe('A'); + expect(promSpec.query).toBe('up'); + expect(promSpec.legend).toBe('LP'); + expect(promSpec.stats).toBe(false); + }); + + it('builds payload for ClickHouse queries and maps requestType from panel', () => { + const props: GetQueryResultsProps = { + query: { + queryType: EQueryType.CLICKHOUSE, + id: 'q3', + unit: undefined, + promql: [], + clickhouse_sql: [ + { + name: 'Q', + query: 'SELECT 1', + disabled: false, + legend: 'LC', + }, + ], + builder: { queryData: [], queryFormulas: [], queryTraceOperator: [] }, + }, + graphType: PANEL_TYPES.TABLE, + selectedTime: 'GLOBAL_TIME', + start, + end, + }; + + const result = prepareQueryRangePayloadV5(props); + + expect(result).toEqual( + expect.objectContaining({ + legendMap: { Q: 'LC' }, + queryPayload: expect.objectContaining({ + compositeQuery: expect.objectContaining({ + queries: [ + { + type: 'clickhouse_sql', + spec: expect.objectContaining({ + name: 'Q', + query: 'SELECT 1', + legend: 'LC', + }), + }, + ], + }), + requestType: 'scalar', + formatOptions: expect.objectContaining({ + formatTableResultForUI: true, + fillGaps: false, + }), + start: start * 1000, + end: end * 1000, + variables: {}, + }), + }), + ); + + expect(result.legendMap).toEqual({ Q: 'LC' }); + + const payload: QueryRangePayloadV5 = result.queryPayload; + expect(payload.requestType).toBe('scalar'); + expect(payload.compositeQuery.queries).toHaveLength(1); + const ch = payload.compositeQuery.queries[0]; + expect(ch.type).toBe('clickhouse_sql'); + const chSpec = ch.spec as ClickHouseQuery; + expect(chSpec.name).toBe('Q'); + expect(chSpec.query).toBe('SELECT 1'); + expect(chSpec.legend).toBe('LC'); + }); + + it('uses getStartEndRangeTime when start/end are not provided', () => { + const props: GetQueryResultsProps = { + query: { + queryType: EQueryType.QUERY_BUILDER, + id: 'q4', + unit: undefined, + promql: [], + clickhouse_sql: [], + builder: { queryData: [], queryFormulas: [], queryTraceOperator: [] }, + }, + graphType: PANEL_TYPES.TIME_SERIES, + selectedTime: 'GLOBAL_TIME', + }; + + const result = prepareQueryRangePayloadV5(props); + + expect(result).toEqual( + expect.objectContaining({ + legendMap: {}, + queryPayload: expect.objectContaining({ + compositeQuery: { queries: [] }, + requestType: 'time_series', + formatOptions: expect.objectContaining({ + formatTableResultForUI: false, + fillGaps: false, + }), + start: 100 * 1000, + end: 200 * 1000, + variables: {}, + }), + }), + ); + + const payload: QueryRangePayloadV5 = result.queryPayload; + expect(payload.start).toBe(100 * 1000); + expect(payload.end).toBe(200 * 1000); + }); + + it('includes reduceTo for metrics in scalar panels (TABLE)', () => { + const props: GetQueryResultsProps = { + query: { + queryType: EQueryType.QUERY_BUILDER, + id: 'q5', + unit: undefined, + promql: [], + clickhouse_sql: [], + builder: { + queryData: [baseBuilderQuery()], + queryFormulas: [], + queryTraceOperator: [], + }, + }, + graphType: PANEL_TYPES.TABLE, + selectedTime: 'GLOBAL_TIME', + start, + end, + }; + + const result = prepareQueryRangePayloadV5(props); + + expect(result).toEqual( + expect.objectContaining({ + legendMap: { A: 'Legend A' }, + queryPayload: expect.objectContaining({ + compositeQuery: expect.objectContaining({ + queries: [ + { + type: 'builder_query', + spec: expect.objectContaining({ + name: 'A', + signal: 'metrics', + stepInterval: 600, + functions: [{ name: 'timeShift', args: [{ value: '5m' }] }], + aggregations: [ + expect.objectContaining({ + metricName: 'cpu_usage', + timeAggregation: 'sum', + spaceAggregation: 'avg', + reduceTo: 'avg', + temporality: undefined, + }), + ], + }), + }, + ], + }), + requestType: 'scalar', + formatOptions: expect.objectContaining({ + formatTableResultForUI: true, + fillGaps: false, + }), + start: start * 1000, + end: end * 1000, + variables: {}, + }), + }), + ); + + const payload: QueryRangePayloadV5 = result.queryPayload; + const builderQuery = payload.compositeQuery.queries.find( + (q) => q.type === 'builder_query', + ) as QueryEnvelope; + const builderSpec = builderQuery.spec as MetricBuilderQuery; + expect(builderSpec.aggregations?.[0].reduceTo).toBe('avg'); + }); + + it('omits aggregations for raw request type (LIST panel)', () => { + const logAgg: LogAggregation[] = [{ expression: 'count()' }]; + const logsQuery = baseBuilderQuery({ + dataSource: DataSource.LOGS, + aggregations: logAgg, + } as Partial); + + const props: GetQueryResultsProps = { + query: { + queryType: EQueryType.QUERY_BUILDER, + id: 'q6', + unit: undefined, + promql: [], + clickhouse_sql: [], + builder: { + queryData: [logsQuery], + queryFormulas: [], + queryTraceOperator: [], + }, + }, + graphType: PANEL_TYPES.LIST, + selectedTime: 'GLOBAL_TIME', + start, + end, + }; + + const result = prepareQueryRangePayloadV5(props); + + expect(result).toEqual( + expect.objectContaining({ + legendMap: { A: 'Legend A' }, + queryPayload: expect.objectContaining({ + compositeQuery: expect.objectContaining({ + queries: [ + { + type: 'builder_query', + spec: expect.objectContaining({ + name: 'A', + signal: 'logs', + stepInterval: 600, + functions: [{ name: 'timeShift', args: [{ value: '5m' }] }], + aggregations: undefined, + }), + }, + ], + }), + requestType: 'raw', + formatOptions: expect.objectContaining({ + formatTableResultForUI: false, + fillGaps: false, + }), + start: start * 1000, + end: end * 1000, + variables: {}, + }), + }), + ); + + const payload: QueryRangePayloadV5 = result.queryPayload; + expect(payload.requestType).toBe('raw'); + const builderQuery = payload.compositeQuery.queries.find( + (q) => q.type === 'builder_query', + ) as QueryEnvelope; + // For RAW request type, aggregations should be omitted + const logSpec = builderQuery.spec as LogBuilderQuery; + expect(logSpec.aggregations).toBeUndefined(); + }); + + it('maps groupBy, order, having, aggregations and filter for logs builder query', () => { + const getStartEndRangeTime = jest.requireMock('lib/getStartEndRangeTime') + .default as jest.Mock; + getStartEndRangeTime.mockReturnValueOnce({ + start: '1754623641', + end: '1754645241', + }); + + const props: GetQueryResultsProps = { + query: { + queryType: EQueryType.QUERY_BUILDER, + id: 'e643e387-1996-4449-97b6-9ef4498a0573', + unit: undefined, + promql: [{ name: 'A', query: '', legend: '', disabled: false }], + clickhouse_sql: [{ name: 'A', legend: '', disabled: false, query: '' }], + builder: { + queryData: [ + { + dataSource: DataSource.LOGS, + queryName: 'A', + aggregateOperator: 'count', + aggregateAttribute: { + key: '', + dataType: DataTypes.EMPTY, + type: '', + }, + timeAggregation: 'rate', + spaceAggregation: 'sum', + filter: { expression: "service.name = 'adservice'" }, + aggregations: [ + { expression: 'count() as cnt avg(code.lineno) ' } as LogAggregation, + ], + functions: [], + filters: { + items: [ + { + id: '14c790ec-54d1-42f0-a889-3b4f0fb79852', + op: '=', + key: { id: 'service.name', key: 'service.name', type: '' }, + value: 'adservice', + }, + ], + op: 'AND', + }, + expression: 'A', + disabled: false, + stepInterval: 80, + having: { expression: 'count() > 0' }, + limit: 600, + orderBy: [{ columnName: 'service.name', order: 'desc' }], + groupBy: [ + { + key: 'service.name', + type: '', + }, + ], + legend: '{{service.name}}', + reduceTo: 'avg', + offset: 0, + pageSize: 100, + }, + ], + queryFormulas: [], + queryTraceOperator: [], + }, + }, + graphType: PANEL_TYPES.TIME_SERIES, + selectedTime: 'GLOBAL_TIME', + globalSelectedInterval: 'custom' as never, + variables: {}, + }; + + const result = prepareQueryRangePayloadV5(props); + + expect(result).toEqual( + expect.objectContaining({ + legendMap: { A: '{{service.name}}' }, + queryPayload: expect.objectContaining({ + schemaVersion: 'v1', + start: 1754623641000, + end: 1754645241000, + requestType: 'time_series', + compositeQuery: expect.objectContaining({ + queries: [ + { + type: 'builder_query', + spec: expect.objectContaining({ + name: 'A', + signal: 'logs', + stepInterval: 80, + disabled: false, + filter: { expression: "service.name = 'adservice'" }, + groupBy: [ + { + name: 'service.name', + fieldDataType: '', + fieldContext: '', + }, + ], + limit: 600, + order: [ + { + key: { name: 'service.name' }, + direction: 'desc', + }, + ], + legend: '{{service.name}}', + having: { expression: 'count() > 0' }, + aggregations: [ + { expression: 'count()', alias: 'cnt' }, + { expression: 'avg(code.lineno)' }, + ], + }), + }, + ], + }), + formatOptions: { formatTableResultForUI: false, fillGaps: false }, + variables: {}, + }), + }), + ); + }); + + it('builds payload for builder queries with filters array but no filter expression', () => { + const props: GetQueryResultsProps = { + query: { + queryType: EQueryType.QUERY_BUILDER, + id: 'q8', + unit: undefined, + promql: [], + clickhouse_sql: [], + builder: { + queryData: [ + baseBuilderQuery({ + dataSource: DataSource.LOGS, + filter: { expression: '' }, + filters: { + items: [ + { + id: '1', + key: { key: 'service.name', type: 'string' }, + op: '=', + value: 'payment-service', + }, + { + id: '2', + key: { key: 'http.status_code', type: 'number' }, + op: '>=', + value: 400, + }, + { + id: '3', + key: { key: 'message', type: 'string' }, + op: 'contains', + value: 'error', + }, + ], + op: 'AND', + }, + }), + ], + queryFormulas: [], + queryTraceOperator: [], + }, + }, + graphType: PANEL_TYPES.LIST, + selectedTime: 'GLOBAL_TIME', + start, + end, + }; + + const result = prepareQueryRangePayloadV5(props); + + expect(result.legendMap).toEqual({ A: 'Legend A' }); + expect(result.queryPayload.compositeQuery.queries).toHaveLength(1); + + const builderQuery = result.queryPayload.compositeQuery.queries.find( + (q) => q.type === 'builder_query', + ) as QueryEnvelope; + const logSpec = builderQuery.spec as LogBuilderQuery; + + expect(logSpec.name).toBe('A'); + expect(logSpec.signal).toBe('logs'); + expect(logSpec.filter).toEqual({ + expression: + "service.name = 'payment-service' AND http.status_code >= 400 AND message contains 'error'", + }); + }); + + it('uses filter.expression when only expression is provided', () => { + const props: GetQueryResultsProps = { + query: { + queryType: EQueryType.QUERY_BUILDER, + id: 'q9', + unit: undefined, + promql: [], + clickhouse_sql: [], + builder: { + queryData: [ + baseBuilderQuery({ + dataSource: DataSource.LOGS, + filter: { expression: 'http.status_code >= 500' }, + filters: (undefined as unknown) as IBuilderQuery['filters'], + }), + ], + queryFormulas: [], + queryTraceOperator: [], + }, + }, + graphType: PANEL_TYPES.LIST, + selectedTime: 'GLOBAL_TIME', + start, + end, + }; + + const result = prepareQueryRangePayloadV5(props); + const builderQuery = result.queryPayload.compositeQuery.queries.find( + (q) => q.type === 'builder_query', + ) as QueryEnvelope; + const logSpec = builderQuery.spec as LogBuilderQuery; + expect(logSpec.filter).toEqual({ expression: 'http.status_code >= 500' }); + }); + + it('derives expression from filters when filter is undefined', () => { + const props: GetQueryResultsProps = { + query: { + queryType: EQueryType.QUERY_BUILDER, + id: 'q10', + unit: undefined, + promql: [], + clickhouse_sql: [], + builder: { + queryData: [ + baseBuilderQuery({ + dataSource: DataSource.LOGS, + filter: (undefined as unknown) as IBuilderQuery['filter'], + filters: { + items: [ + { + id: '1', + key: { key: 'service.name', type: 'string' }, + op: '=', + value: 'checkout', + }, + ], + op: 'AND', + }, + }), + ], + queryFormulas: [], + queryTraceOperator: [], + }, + }, + graphType: PANEL_TYPES.LIST, + selectedTime: 'GLOBAL_TIME', + start, + end, + }; + + const result = prepareQueryRangePayloadV5(props); + const builderQuery = result.queryPayload.compositeQuery.queries.find( + (q) => q.type === 'builder_query', + ) as QueryEnvelope; + const logSpec = builderQuery.spec as LogBuilderQuery; + expect(logSpec.filter).toEqual({ expression: "service.name = 'checkout'" }); + }); + + it('prefers filter.expression over filters when both are present', () => { + const props: GetQueryResultsProps = { + query: { + queryType: EQueryType.QUERY_BUILDER, + id: 'q11', + unit: undefined, + promql: [], + clickhouse_sql: [], + builder: { + queryData: [ + baseBuilderQuery({ + dataSource: DataSource.LOGS, + filter: { expression: "service.name = 'frontend'" }, + filters: { + items: [ + { + id: '1', + key: { key: 'service.name', type: 'string' }, + op: '=', + value: 'backend', + }, + ], + op: 'AND', + }, + }), + ], + queryFormulas: [], + queryTraceOperator: [], + }, + }, + graphType: PANEL_TYPES.LIST, + selectedTime: 'GLOBAL_TIME', + start, + end, + }; + + const result = prepareQueryRangePayloadV5(props); + const builderQuery = result.queryPayload.compositeQuery.queries.find( + (q) => q.type === 'builder_query', + ) as QueryEnvelope; + const logSpec = builderQuery.spec as LogBuilderQuery; + expect(logSpec.filter).toEqual({ expression: "service.name = 'frontend'" }); + }); + + it('returns empty expression when neither filter nor filters provided', () => { + const props: GetQueryResultsProps = { + query: { + queryType: EQueryType.QUERY_BUILDER, + id: 'q12', + unit: undefined, + promql: [], + clickhouse_sql: [], + builder: { + queryData: [ + baseBuilderQuery({ + dataSource: DataSource.LOGS, + filter: (undefined as unknown) as IBuilderQuery['filter'], + filters: (undefined as unknown) as IBuilderQuery['filters'], + }), + ], + queryFormulas: [], + queryTraceOperator: [], + }, + }, + graphType: PANEL_TYPES.LIST, + selectedTime: 'GLOBAL_TIME', + start, + end, + }; + + const result = prepareQueryRangePayloadV5(props); + const builderQuery = result.queryPayload.compositeQuery.queries.find( + (q) => q.type === 'builder_query', + ) as QueryEnvelope; + const logSpec = builderQuery.spec as LogBuilderQuery; + expect(logSpec.filter).toEqual({ expression: '' }); + }); + + it('returns empty expression when filters provided with empty items', () => { + const props: GetQueryResultsProps = { + query: { + queryType: EQueryType.QUERY_BUILDER, + id: 'q13', + unit: undefined, + promql: [], + clickhouse_sql: [], + builder: { + queryData: [ + baseBuilderQuery({ + dataSource: DataSource.LOGS, + filter: { expression: '' }, + filters: { items: [], op: 'AND' }, + }), + ], + queryFormulas: [], + queryTraceOperator: [], + }, + }, + graphType: PANEL_TYPES.LIST, + selectedTime: 'GLOBAL_TIME', + start, + end, + }; + + const result = prepareQueryRangePayloadV5(props); + const builderQuery = result.queryPayload.compositeQuery.queries.find( + (q) => q.type === 'builder_query', + ) as QueryEnvelope; + const logSpec = builderQuery.spec as LogBuilderQuery; + expect(logSpec.filter).toEqual({ expression: '' }); + }); +}); diff --git a/frontend/src/api/v5/queryRange/prepareQueryRangePayloadV5.ts b/frontend/src/api/v5/queryRange/prepareQueryRangePayloadV5.ts new file mode 100644 index 00000000000..d30051716ff --- /dev/null +++ b/frontend/src/api/v5/queryRange/prepareQueryRangePayloadV5.ts @@ -0,0 +1,659 @@ +/* eslint-disable sonarjs/cognitive-complexity */ +/* eslint-disable sonarjs/no-identical-functions */ +import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils'; +import { PANEL_TYPES } from 'constants/queryBuilder'; +import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults'; +import getStartEndRangeTime from 'lib/getStartEndRangeTime'; +import { mapQueryDataToApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataToApi'; +import { isEmpty } from 'lodash-es'; +import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; +import { + IBuilderQuery, + IBuilderTraceOperator, +} from 'types/api/queryBuilder/queryBuilderData'; +import { + BaseBuilderQuery, + FieldContext, + FieldDataType, + Filter, + FunctionName, + GroupByKey, + Having, + LogAggregation, + MetricAggregation, + OrderBy, + QueryEnvelope, + QueryFunction, + QueryRangePayloadV5, + QueryType, + RequestType, + TelemetryFieldKey, + TraceAggregation, + VariableItem, + VariableType, +} from 'types/api/v5/queryRange'; +import { EQueryType } from 'types/common/dashboard'; +import { DataSource } from 'types/common/queryBuilder'; +import { normalizeFunctionName } from 'utils/functionNameNormalizer'; + +type PrepareQueryRangePayloadV5Result = { + queryPayload: QueryRangePayloadV5; + legendMap: Record; +}; + +/** + * Maps panel types to V5 request types + */ +export function mapPanelTypeToRequestType(panelType: PANEL_TYPES): RequestType { + switch (panelType) { + case PANEL_TYPES.TIME_SERIES: + case PANEL_TYPES.BAR: + return 'time_series'; + case PANEL_TYPES.TABLE: + case PANEL_TYPES.PIE: + case PANEL_TYPES.VALUE: + return 'scalar'; + case PANEL_TYPES.TRACE: + return 'trace'; + case PANEL_TYPES.LIST: + return 'raw'; + case PANEL_TYPES.HISTOGRAM: + return 'distribution'; + default: + return ''; + } +} + +/** + * Gets signal type from data source + */ +function getSignalType(dataSource: string): 'traces' | 'logs' | 'metrics' { + if (dataSource === 'traces') return 'traces'; + if (dataSource === 'logs') return 'logs'; + return 'metrics'; +} + +function isDeprecatedField(fieldName: string): boolean { + const deprecatedIntrinsicFields = [ + 'traceID', + 'spanID', + 'parentSpanID', + 'spanKind', + 'durationNano', + 'statusCode', + 'statusMessage', + 'statusCodeString', + ]; + + const deprecatedCalculatedFields = [ + 'responseStatusCode', + 'externalHttpUrl', + 'httpUrl', + 'externalHttpMethod', + 'httpMethod', + 'httpHost', + 'dbName', + 'dbOperation', + 'hasError', + 'isRemote', + 'serviceName', + 'httpRoute', + 'msgSystem', + 'msgOperation', + 'dbSystem', + 'rpcSystem', + 'rpcService', + 'rpcMethod', + 'peerService', + ]; + + return ( + deprecatedIntrinsicFields.includes(fieldName) || + deprecatedCalculatedFields.includes(fieldName) + ); +} + +function getFilter(queryData: IBuilderQuery): Filter { + const { filter } = queryData; + if (filter?.expression) { + return { + expression: filter.expression, + }; + } + + if (queryData.filters && queryData.filters?.items?.length > 0) { + return convertFiltersToExpression(queryData.filters); + } + + return { + expression: '', + }; +} + +function createBaseSpec( + queryData: IBuilderQuery, + requestType: RequestType, + panelType?: PANEL_TYPES, +): BaseBuilderQuery { + const nonEmptySelectColumns = (queryData.selectColumns as ( + | BaseAutocompleteData + | TelemetryFieldKey + )[])?.filter((c) => ('key' in c ? c?.key : c?.name)); + + return { + stepInterval: queryData?.stepInterval || null, + disabled: queryData.disabled, + filter: getFilter(queryData), + groupBy: + queryData.groupBy?.length > 0 + ? queryData.groupBy.map( + (item: any): GroupByKey => ({ + name: item.key, + fieldDataType: item?.dataType || '', + fieldContext: item?.type || '', + description: item?.description, + unit: item?.unit, + signal: item?.signal, + materialized: item?.materialized, + }), + ) + : undefined, + limit: + panelType === PANEL_TYPES.TABLE || panelType === PANEL_TYPES.LIST + ? queryData.limit || queryData.pageSize || undefined + : queryData.limit || undefined, + offset: + requestType === 'raw' || requestType === 'trace' + ? queryData.offset + : undefined, + order: + queryData.orderBy?.length > 0 + ? queryData.orderBy.map( + (order: any): OrderBy => ({ + key: { + name: order.columnName, + }, + direction: order.order, + }), + ) + : undefined, + legend: isEmpty(queryData.legend) ? undefined : queryData.legend, + having: isEmpty(queryData.having) ? undefined : (queryData?.having as Having), + functions: isEmpty(queryData.functions) + ? undefined + : queryData.functions.map( + (func: QueryFunction): QueryFunction => { + // Normalize function name to handle case sensitivity + const normalizedName = normalizeFunctionName(func?.name); + return { + name: normalizedName as FunctionName, + args: isEmpty(func.namedArgs) + ? func.args?.map((arg) => ({ + value: arg?.value, + })) + : Object.entries(func?.namedArgs || {}).map(([name, value]) => ({ + name, + value, + })), + }; + }, + ), + selectFields: isEmpty(nonEmptySelectColumns) + ? undefined + : nonEmptySelectColumns?.map( + (column: any): TelemetryFieldKey => { + const fieldName = column.name ?? column.key; + const isDeprecated = isDeprecatedField(fieldName); + + const fieldObj: TelemetryFieldKey = { + name: fieldName, + fieldDataType: + column?.fieldDataType ?? (column?.dataType as FieldDataType), + signal: column?.signal ?? undefined, + }; + + // Only add fieldContext if the field is NOT deprecated + if (!isDeprecated && fieldName !== 'name') { + fieldObj.fieldContext = + column?.fieldContext ?? (column?.type as FieldContext); + } + + return fieldObj; + }, + ), + }; +} + +// Utility to parse aggregation expressions with optional alias +export function parseAggregations( + expression: string, + availableAlias?: string, +): { expression: string; alias?: string }[] { + const result: { expression: string; alias?: string }[] = []; + // Matches function calls like "count()" or "sum(field)" with optional alias like "as 'alias'" + // Handles quoted ('alias'), dash-separated (field-name), and unquoted values after "as" keyword + const regex = /([a-zA-Z0-9_]+\([^)]*\))(?:\s*as\s+((?:'[^']*'|"[^"]*"|[a-zA-Z0-9_-]+)))?/g; + let match = regex.exec(expression); + while (match !== null) { + const expr = match[1]; + let alias = match[2] || availableAlias; // Use provided alias or availableAlias if not matched + if (alias) { + // Remove quotes if present + alias = alias.replace(/^['"]|['"]$/g, ''); + result.push({ expression: expr, alias }); + } else { + result.push({ expression: expr }); + } + match = regex.exec(expression); + } + return result; +} + +export function createAggregation( + queryData: any, + panelType?: PANEL_TYPES, +): TraceAggregation[] | LogAggregation[] | MetricAggregation[] { + if (!queryData) { + return []; + } + + const haveReduceTo = + queryData.dataSource === DataSource.METRICS && + panelType && + (panelType === PANEL_TYPES.TABLE || + panelType === PANEL_TYPES.PIE || + panelType === PANEL_TYPES.VALUE); + + if (queryData.dataSource === DataSource.METRICS) { + return [ + { + metricName: + queryData?.aggregations?.[0]?.metricName || + queryData?.aggregateAttribute?.key, + temporality: + queryData?.aggregations?.[0]?.temporality || + queryData?.aggregateAttribute?.temporality, + timeAggregation: + queryData?.aggregations?.[0]?.timeAggregation || + queryData?.timeAggregation, + spaceAggregation: + queryData?.aggregations?.[0]?.spaceAggregation || + queryData?.spaceAggregation, + reduceTo: haveReduceTo + ? queryData?.aggregations?.[0]?.reduceTo || queryData?.reduceTo + : undefined, + }, + ]; + } + + if (queryData.aggregations?.length > 0) { + return queryData.aggregations.flatMap( + (agg: { expression: string; alias?: string }) => { + const parsedAggregations = parseAggregations(agg.expression, agg?.alias); + return isEmpty(parsedAggregations) + ? [{ expression: 'count()' }] + : parsedAggregations; + }, + ); + } + + return [{ expression: 'count()' }]; +} + +/** + * Converts query builder data to V5 builder queries + */ +export function convertBuilderQueriesToV5( + builderQueries: Record, // eslint-disable-line @typescript-eslint/no-explicit-any + requestType: RequestType, + panelType?: PANEL_TYPES, +): QueryEnvelope[] { + return Object.entries(builderQueries).map( + ([queryName, queryData]): QueryEnvelope => { + const signal = getSignalType(queryData.dataSource); + const baseSpec = createBaseSpec(queryData, requestType, panelType); + let spec: QueryEnvelope['spec']; + + // Skip aggregation for raw request type + const aggregations = + requestType === 'raw' ? undefined : createAggregation(queryData, panelType); + + switch (signal) { + case 'traces': + spec = { + name: queryName, + signal: 'traces' as const, + ...baseSpec, + aggregations: aggregations as TraceAggregation[], + }; + break; + case 'logs': + spec = { + name: queryName, + signal: 'logs' as const, + ...baseSpec, + aggregations: aggregations as LogAggregation[], + }; + break; + case 'metrics': + default: + spec = { + name: queryName, + signal: 'metrics' as const, + source: queryData.source || '', + ...baseSpec, + aggregations: aggregations as MetricAggregation[], + // reduceTo: queryData.reduceTo, + }; + break; + } + + return { + type: 'builder_query' as QueryType, + spec, + }; + }, + ); +} + +function createTraceOperatorBaseSpec( + queryData: IBuilderTraceOperator, + requestType: RequestType, + panelType?: PANEL_TYPES, +): BaseBuilderQuery { + const nonEmptySelectColumns = (queryData.selectColumns as ( + | BaseAutocompleteData + | TelemetryFieldKey + )[])?.filter((c) => ('key' in c ? c?.key : c?.name)); + + const { + stepInterval, + groupBy, + limit, + offset, + legend, + having, + orderBy, + pageSize, + } = queryData; + + return { + stepInterval: stepInterval || undefined, + groupBy: + groupBy?.length > 0 + ? groupBy.map( + (item: any): GroupByKey => ({ + name: item.key, + fieldDataType: item?.dataType, + fieldContext: item?.type, + description: item?.description, + unit: item?.unit, + signal: item?.signal, + materialized: item?.materialized, + }), + ) + : undefined, + limit: + panelType === PANEL_TYPES.TABLE || panelType === PANEL_TYPES.LIST + ? limit || pageSize || undefined + : limit || undefined, + offset: requestType === 'raw' || requestType === 'trace' ? offset : undefined, + order: + orderBy?.length > 0 + ? orderBy.map( + (order: any): OrderBy => ({ + key: { + name: order.columnName, + }, + direction: order.order, + }), + ) + : undefined, + legend: isEmpty(legend) ? undefined : legend, + having: isEmpty(having) ? undefined : (having as Having), + selectFields: isEmpty(nonEmptySelectColumns) + ? undefined + : nonEmptySelectColumns?.map( + (column: any): TelemetryFieldKey => ({ + name: column.name ?? column.key, + fieldDataType: + column?.fieldDataType ?? (column?.dataType as FieldDataType), + fieldContext: column?.fieldContext ?? (column?.type as FieldContext), + signal: column?.signal ?? undefined, + }), + ), + }; +} + +export function convertTraceOperatorToV5( + traceOperator: Record, + requestType: RequestType, + panelType?: PANEL_TYPES, +): QueryEnvelope[] { + return Object.entries(traceOperator).map( + ([queryName, traceOperatorData]): QueryEnvelope => { + const baseSpec = createTraceOperatorBaseSpec( + traceOperatorData, + requestType, + panelType, + ); + + // Skip aggregation for raw request type + const aggregations = + requestType === 'raw' + ? undefined + : createAggregation(traceOperatorData, panelType); + + const spec: QueryEnvelope['spec'] = { + name: queryName, + ...baseSpec, + expression: traceOperatorData.expression || '', + aggregations: aggregations as TraceAggregation[], + }; + + return { + type: 'builder_trace_operator' as QueryType, + spec, + }; + }, + ); +} + +/** + * Converts PromQL queries to V5 format + */ +export function convertPromQueriesToV5( + promQueries: Record, // eslint-disable-line @typescript-eslint/no-explicit-any +): QueryEnvelope[] { + return Object.entries(promQueries).map( + ([queryName, queryData]): QueryEnvelope => ({ + type: 'promql' as QueryType, + spec: { + name: queryName, + query: queryData.query, + disabled: queryData.disabled || false, + step: queryData?.stepInterval, + legend: isEmpty(queryData.legend) ? undefined : queryData.legend, + stats: false, // PromQL specific field + }, + }), + ); +} + +/** + * Converts ClickHouse queries to V5 format + */ +export function convertClickHouseQueriesToV5( + chQueries: Record, // eslint-disable-line @typescript-eslint/no-explicit-any +): QueryEnvelope[] { + return Object.entries(chQueries).map( + ([queryName, queryData]): QueryEnvelope => ({ + type: 'clickhouse_sql' as QueryType, + spec: { + name: queryName, + query: queryData.query, + disabled: queryData.disabled || false, + legend: isEmpty(queryData.legend) ? undefined : queryData.legend, + // ClickHouse doesn't have step or stats like PromQL + }, + }), + ); +} + +/** + * Helper function to reduce query arrays to objects + */ +function reduceQueriesToObject( + queryArray: any[], // eslint-disable-line @typescript-eslint/no-explicit-any +): { queries: Record; legends: Record } { + // eslint-disable-line @typescript-eslint/no-explicit-any + const legends: Record = {}; + const queries = queryArray.reduce((acc, queryItem) => { + if (!queryItem.query) return acc; + acc[queryItem.name] = queryItem; + legends[queryItem.name] = queryItem.legend; + return acc; + }, {} as Record); // eslint-disable-line @typescript-eslint/no-explicit-any + + return { queries, legends }; +} + +/** + * Prepares V5 query range payload from GetQueryResultsProps + */ +export const prepareQueryRangePayloadV5 = ({ + query, + globalSelectedInterval, + graphType, + selectedTime, + tableParams, + variables = {}, + start: startTime, + end: endTime, + formatForWeb, + originalGraphType, + fillGaps, + dynamicVariables, +}: GetQueryResultsProps): PrepareQueryRangePayloadV5Result => { + let legendMap: Record = {}; + const requestType = mapPanelTypeToRequestType(graphType); + let queries: QueryEnvelope[] = []; + + switch (query.queryType) { + case EQueryType.QUERY_BUILDER: { + const { queryData: data, queryFormulas, queryTraceOperator } = query.builder; + const currentQueryData = mapQueryDataToApi(data, 'queryName', tableParams); + const currentFormulas = mapQueryDataToApi(queryFormulas, 'queryName'); + + const filteredTraceOperator = + queryTraceOperator && queryTraceOperator.length > 0 + ? queryTraceOperator.filter((traceOperator) => + Boolean(traceOperator.expression.trim()), + ) + : []; + + const currentTraceOperator = mapQueryDataToApi( + filteredTraceOperator, + 'queryName', + tableParams, + ); + + // Combine legend maps + legendMap = { + ...currentQueryData.newLegendMap, + ...currentFormulas.newLegendMap, + ...currentTraceOperator.newLegendMap, + }; + + // Convert builder queries + const builderQueries = convertBuilderQueriesToV5( + currentQueryData.data, + requestType, + graphType, + ); + + // Convert formulas as separate query type + const formulaQueries = Object.entries(currentFormulas.data).map( + ([queryName, formulaData]): QueryEnvelope => ({ + type: 'builder_formula' as const, + spec: { + name: queryName, + expression: formulaData.expression || '', + disabled: formulaData.disabled, + limit: formulaData.limit ?? undefined, + legend: isEmpty(formulaData.legend) ? undefined : formulaData.legend, + order: formulaData.orderBy?.map( + // eslint-disable-next-line sonarjs/no-identical-functions + (order: any): OrderBy => ({ + key: { + name: order.columnName, + }, + direction: order.order, + }), + ), + }, + }), + ); + + const traceOperatorQueries = convertTraceOperatorToV5( + currentTraceOperator.data, + requestType, + graphType, + ); + + // Combine all query types + queries = [...builderQueries, ...formulaQueries, ...traceOperatorQueries]; + break; + } + case EQueryType.PROM: { + const promQueries = reduceQueriesToObject(query[query.queryType]); + queries = convertPromQueriesToV5(promQueries.queries); + legendMap = promQueries.legends; + break; + } + case EQueryType.CLICKHOUSE: { + const chQueries = reduceQueriesToObject(query[query.queryType]); + queries = convertClickHouseQueriesToV5(chQueries.queries); + legendMap = chQueries.legends; + break; + } + default: + break; + } + + // Calculate time range + const { start, end } = getStartEndRangeTime({ + type: selectedTime, + interval: globalSelectedInterval, + }); + + // Create V5 payload + const queryPayload: QueryRangePayloadV5 = { + schemaVersion: 'v1', + start: startTime ? startTime * 1e3 : parseInt(start, 10) * 1e3, + end: endTime ? endTime * 1e3 : parseInt(end, 10) * 1e3, + requestType, + compositeQuery: { + queries, + }, + formatOptions: { + formatTableResultForUI: + !!formatForWeb || + (originalGraphType + ? originalGraphType === PANEL_TYPES.TABLE + : graphType === PANEL_TYPES.TABLE), + fillGaps: fillGaps || false, + }, + variables: Object.entries(variables).reduce((acc, [key, value]) => { + acc[key] = { + value, + type: dynamicVariables + ?.find((v) => v.name === key) + ?.type?.toLowerCase() as VariableType, + }; + return acc; + }, {} as Record), + }; + + return { legendMap, queryPayload }; +}; diff --git a/frontend/src/api/v5/v5.ts b/frontend/src/api/v5/v5.ts new file mode 100644 index 00000000000..44d71a74104 --- /dev/null +++ b/frontend/src/api/v5/v5.ts @@ -0,0 +1,8 @@ +// V5 API exports +export * from './queryRange/constants'; +export { convertV5ResponseToLegacy } from './queryRange/convertV5Response'; +export { getQueryRangeV5 } from './queryRange/getQueryRange'; +export { prepareQueryRangePayloadV5 } from './queryRange/prepareQueryRangePayloadV5'; + +// Export types from proper location +export * from 'types/api/v5/queryRange'; diff --git a/frontend/src/assets/Error.tsx b/frontend/src/assets/Error.tsx new file mode 100644 index 00000000000..9b6924c4fcd --- /dev/null +++ b/frontend/src/assets/Error.tsx @@ -0,0 +1,191 @@ +import React from 'react'; + +type ErrorIconProps = React.SVGProps; + +function ErrorIcon({ ...props }: ErrorIconProps): JSX.Element { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default ErrorIcon; diff --git a/frontend/src/components/AppLoading/AppLoading.styles.scss b/frontend/src/components/AppLoading/AppLoading.styles.scss new file mode 100644 index 00000000000..18c12aa213c --- /dev/null +++ b/frontend/src/components/AppLoading/AppLoading.styles.scss @@ -0,0 +1,152 @@ +.app-loading-container { + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + background-color: var(--bg-ink-400, #121317); // Dark theme background + + .app-loading-content { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + + .brand { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + + margin-bottom: 12px; + + .brand-logo { + width: 40px; + height: 40px; + } + + .brand-title { + font-size: 20px; + font-weight: 600; + color: var(--bg-vanilla-100, #ffffff); // White text for dark theme + margin: 0; + } + } + + .brand-tagline { + margin-bottom: 24px; + + .ant-typography { + color: var(--bg-vanilla-400, #c0c1c3); // Light gray text for dark theme + } + } + + /* HTML:

*/ + .loader { + width: 150px; + height: 12px; + border-radius: 2px; + color: var(--bg-robin-500, #4e74f8); // Primary blue color + border: 2px solid; + position: relative; + } + .loader::before { + content: ''; + position: absolute; + margin: 2px; + inset: 0 100% 0 0; + border-radius: inherit; + background: currentColor; + animation: l6 2s infinite; + } + @keyframes l6 { + 100% { + inset: 0; + } + } + } +} + +// Light theme styles - more specific selector +.app-loading-container.lightMode { + background-color: var( + --bg-vanilla-100, + #ffffff + ) !important; // White background for light theme + + .app-loading-content { + .brand { + .brand-title { + color: var(--bg-ink-400, #121317) !important; // Dark text for light theme + } + } + + .brand-tagline { + .ant-typography { + color: var( + --bg-ink-300, + #6b7280 + ) !important; // Dark gray text for light theme + } + } + + .loader { + color: var( + --bg-robin-500, + #4e74f8 + ) !important; // Keep primary blue color for consistency + } + } +} + +.perilin-bg { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + + background: radial-gradient(circle, #fff 10%, transparent 0); + background-size: 12px 12px; + opacity: 1; + + mask-image: radial-gradient( + circle at 50% 0, + rgba(11, 12, 14, 0.1) 0, + rgba(11, 12, 14, 0) 100% + ); + -webkit-mask-image: radial-gradient( + circle at 50% 0, + rgba(11, 12, 14, 0.1) 0, + rgba(11, 12, 14, 0) 100% + ); +} + +// Dark theme styles - ensure dark theme is properly applied +.app-loading-container.dark { + background-color: var(--bg-ink-400, #121317) !important; // Dark background + + .app-loading-content { + .brand { + .brand-title { + color: var( + --bg-vanilla-100, + #ffffff + ) !important; // White text for dark theme + } + } + + .brand-tagline { + .ant-typography { + color: var( + --bg-vanilla-400, + #c0c1c3 + ) !important; // Light gray text for dark theme + } + } + + .loader { + color: var(--bg-robin-500, #4e74f8) !important; // Primary blue color + } + } +} diff --git a/frontend/src/components/AppLoading/AppLoading.tsx b/frontend/src/components/AppLoading/AppLoading.tsx new file mode 100644 index 00000000000..79c487b4528 --- /dev/null +++ b/frontend/src/components/AppLoading/AppLoading.tsx @@ -0,0 +1,50 @@ +import './AppLoading.styles.scss'; + +import { Typography } from 'antd'; +import get from 'api/browser/localstorage/get'; +import { LOCALSTORAGE } from 'constants/localStorage'; +import { THEME_MODE } from 'hooks/useDarkMode/constant'; + +function AppLoading(): JSX.Element { + // Get theme from localStorage directly to avoid context dependency + const getThemeFromStorage = (): boolean => { + try { + const theme = get(LOCALSTORAGE.THEME); + return theme !== THEME_MODE.LIGHT; // Return true for dark, false for light + } catch (error) { + // If localStorage is not available, default to dark theme + return true; + } + }; + + const isDarkMode = getThemeFromStorage(); + + return ( +
+
+
+
+ SigNoz + + + SigNoz + +
+ +
+ + OpenTelemetry-Native Logs, Metrics and Traces in a single pane + +
+ +
+
+
+ ); +} + +export default AppLoading; diff --git a/frontend/src/components/AppLoading/__tests__/AppLoading.test.tsx b/frontend/src/components/AppLoading/__tests__/AppLoading.test.tsx new file mode 100644 index 00000000000..b9c6827345a --- /dev/null +++ b/frontend/src/components/AppLoading/__tests__/AppLoading.test.tsx @@ -0,0 +1,78 @@ +import { render, screen } from '@testing-library/react'; + +import getLocal from '../../../api/browser/localstorage/get'; +import AppLoading from '../AppLoading'; + +jest.mock('../../../api/browser/localstorage/get', () => ({ + __esModule: true, + default: jest.fn(), +})); + +// Access the mocked function +const mockGet = (getLocal as unknown) as jest.Mock; + +describe('AppLoading', () => { + const SIGNOZ_TEXT = 'SigNoz'; + const TAGLINE_TEXT = + 'OpenTelemetry-Native Logs, Metrics and Traces in a single pane'; + const CONTAINER_SELECTOR = '.app-loading-container'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render loading screen with dark theme by default', () => { + // Mock localStorage to return dark theme (or undefined for default) + mockGet.mockReturnValue(undefined); + + render(); + + // Check if main elements are rendered + expect(screen.getByAltText(SIGNOZ_TEXT)).toBeInTheDocument(); + expect(screen.getByText(SIGNOZ_TEXT)).toBeInTheDocument(); + expect(screen.getByText(TAGLINE_TEXT)).toBeInTheDocument(); + + // Check if dark theme class is applied + const container = screen.getByText(SIGNOZ_TEXT).closest(CONTAINER_SELECTOR); + expect(container).toHaveClass('dark'); + expect(container).not.toHaveClass('lightMode'); + }); + + it('should have proper structure and content', () => { + // Mock localStorage to return dark theme + mockGet.mockReturnValue(undefined); + + render(); + + // Check for brand logo + const logo = screen.getByAltText(SIGNOZ_TEXT); + expect(logo).toBeInTheDocument(); + expect(logo).toHaveAttribute('src', '/Logos/signoz-brand-logo.svg'); + + // Check for brand title + const title = screen.getByText(SIGNOZ_TEXT); + expect(title).toBeInTheDocument(); + + // Check for tagline + const tagline = screen.getByText(TAGLINE_TEXT); + expect(tagline).toBeInTheDocument(); + + // Check for loader + const loader = document.querySelector('.loader'); + expect(loader).toBeInTheDocument(); + }); + + it('should handle localStorage errors gracefully', () => { + // Mock localStorage to throw an error + mockGet.mockImplementation(() => { + throw new Error('localStorage not available'); + }); + + render(); + + // Should still render with dark theme as fallback + expect(screen.getByText(SIGNOZ_TEXT)).toBeInTheDocument(); + const container = screen.getByText(SIGNOZ_TEXT).closest(CONTAINER_SELECTOR); + expect(container).toHaveClass('dark'); + }); +}); diff --git a/frontend/src/components/CeleryOverview/CeleryOverviewTable/CeleryOverviewTable.styles.scss b/frontend/src/components/CeleryOverview/CeleryOverviewTable/CeleryOverviewTable.styles.scss index 676204585db..c66a3015135 100644 --- a/frontend/src/components/CeleryOverview/CeleryOverviewTable/CeleryOverviewTable.styles.scss +++ b/frontend/src/components/CeleryOverview/CeleryOverviewTable/CeleryOverviewTable.styles.scss @@ -93,7 +93,7 @@ padding: 4px; margin: 0; - // this is to offset intercom icon + // this is to offset chat support icon padding-right: 72px; .ant-pagination-item { diff --git a/frontend/src/components/CeleryTask/CeleryTaskGraph/CeleryTaskGraph.style.scss b/frontend/src/components/CeleryTask/CeleryTaskGraph/CeleryTaskGraph.style.scss index f695d8cbd26..694559d0d1e 100644 --- a/frontend/src/components/CeleryTask/CeleryTaskGraph/CeleryTaskGraph.style.scss +++ b/frontend/src/components/CeleryTask/CeleryTaskGraph/CeleryTaskGraph.style.scss @@ -20,13 +20,15 @@ .ant-card-body { height: calc(100% - 18px); - .widget-graph-container { - &.bar { - height: calc(100% - 110px); - } - - &.graph { - height: calc(100% - 80px); + .widget-graph-component-container { + .widget-graph-container { + &.bar-panel-container { + height: calc(100% - 110px); + } + + &.graph-panel-container { + height: calc(100% - 80px); + } } } } @@ -82,9 +84,11 @@ .ant-card-body { height: calc(100% - 18px); - .widget-graph-container { - &.bar { - height: calc(100% - 110px); + .widget-graph-component-container { + .widget-graph-container { + &.bar-panel-container { + height: calc(100% - 110px); + } } } } diff --git a/frontend/src/components/CeleryTask/CeleryTaskGraph/CeleryTaskGraphUtils.ts b/frontend/src/components/CeleryTask/CeleryTaskGraph/CeleryTaskGraphUtils.ts index e13e70d6c7e..2cae18a0807 100644 --- a/frontend/src/components/CeleryTask/CeleryTaskGraph/CeleryTaskGraphUtils.ts +++ b/frontend/src/components/CeleryTask/CeleryTaskGraph/CeleryTaskGraphUtils.ts @@ -32,8 +32,6 @@ export const celeryAllStateWidgetData = ( aggregateAttribute: { dataType: DataTypes.String, id: '------false', - isColumn: false, - isJSON: false, key: '', type: '', }, @@ -50,8 +48,6 @@ export const celeryAllStateWidgetData = ( { dataType: DataTypes.String, id: 'celery.state--string--tag--false', - isColumn: false, - isJSON: false, key: 'celery.state', type: 'tag', }, @@ -88,7 +84,6 @@ export const celeryRetryStateWidgetData = ( aggregateAttribute: { dataType: DataTypes.String, id: '------false', - isColumn: false, key: '', type: '', }, @@ -103,8 +98,6 @@ export const celeryRetryStateWidgetData = ( key: { dataType: DataTypes.String, id: 'celery.state--string--tag--false', - isColumn: false, - isJSON: false, key: 'celery.state', type: 'tag', }, @@ -119,8 +112,6 @@ export const celeryRetryStateWidgetData = ( { dataType: DataTypes.String, id: 'celery.hostname--string--tag--false', - isColumn: false, - isJSON: false, key: 'celery.hostname', type: 'tag', }, @@ -153,8 +144,6 @@ export const celeryFailedStateWidgetData = ( aggregateAttribute: { dataType: DataTypes.String, id: '------false', - isColumn: false, - isJSON: false, key: '', type: '', }, @@ -169,8 +158,6 @@ export const celeryFailedStateWidgetData = ( key: { dataType: DataTypes.String, id: 'celery.state--string--tag--false', - isColumn: false, - isJSON: false, key: 'celery.state', type: 'tag', }, @@ -185,8 +172,6 @@ export const celeryFailedStateWidgetData = ( { dataType: DataTypes.String, id: 'celery.hostname--string--tag--false', - isColumn: false, - isJSON: false, key: 'celery.hostname', type: 'tag', }, @@ -219,8 +204,6 @@ export const celerySuccessStateWidgetData = ( aggregateAttribute: { dataType: DataTypes.String, id: '------false', - isColumn: false, - isJSON: false, key: '', type: '', }, @@ -235,8 +218,6 @@ export const celerySuccessStateWidgetData = ( key: { dataType: DataTypes.String, id: 'celery.state--string--tag--false', - isColumn: false, - isJSON: false, key: 'celery.state', type: 'tag', }, @@ -251,8 +232,6 @@ export const celerySuccessStateWidgetData = ( { dataType: DataTypes.String, id: 'celery.hostname--string--tag--false', - isColumn: false, - isJSON: false, key: 'celery.hostname', type: 'tag', }, @@ -284,7 +263,6 @@ export const celeryTasksByWorkerWidgetData = ( aggregateAttribute: { dataType: DataTypes.String, id: '------false', - isColumn: false, key: '', type: '', }, @@ -301,8 +279,6 @@ export const celeryTasksByWorkerWidgetData = ( { dataType: DataTypes.String, id: 'celery.hostname--string--tag--false', - isColumn: false, - isJSON: false, key: 'celery.hostname', type: 'tag', }, @@ -338,8 +314,6 @@ export const celeryErrorByWorkerWidgetData = ( aggregateAttribute: { dataType: 'string', id: 'span_id--string----true', - isColumn: true, - isJSON: false, key: 'span_id', type: '', }, @@ -353,8 +327,6 @@ export const celeryErrorByWorkerWidgetData = ( key: { dataType: DataTypes.bool, id: 'has_error--bool----true', - isColumn: true, - isJSON: false, key: 'has_error', type: '', }, @@ -373,8 +345,6 @@ export const celeryErrorByWorkerWidgetData = ( groupBy: [ { dataType: DataTypes.String, - isColumn: false, - isJSON: false, key: 'celery.hostname', type: 'tag', id: 'celery.hostname--string--tag--false', @@ -390,8 +360,6 @@ export const celeryErrorByWorkerWidgetData = ( aggregateAttribute: { dataType: 'string', id: 'span_id--string----true', - isColumn: true, - isJSON: false, key: 'span_id', type: '', }, @@ -411,8 +379,6 @@ export const celeryErrorByWorkerWidgetData = ( groupBy: [ { dataType: DataTypes.String, - isColumn: false, - isJSON: false, key: 'celery.hostname', type: 'tag', id: 'celery.hostname--string--tag--false', @@ -445,8 +411,6 @@ export const celeryLatencyByWorkerWidgetData = ( aggregateAttribute: { dataType: DataTypes.Float64, id: 'duration_nano--float64----true', - isColumn: true, - isJSON: false, key: 'duration_nano', type: '', }, @@ -463,8 +427,6 @@ export const celeryLatencyByWorkerWidgetData = ( { dataType: DataTypes.String, id: 'celery.hostname--string--tag--false', - isColumn: false, - isJSON: false, key: 'celery.hostname', type: 'tag', }, @@ -498,8 +460,6 @@ export const celeryActiveTasksWidgetData = ( dataType: DataTypes.Float64, id: 'flower_worker_number_of_currently_executing_tasks--float64--Gauge--true', - isColumn: true, - isJSON: false, key: 'flower_worker_number_of_currently_executing_tasks', type: 'Gauge', }, @@ -516,8 +476,6 @@ export const celeryActiveTasksWidgetData = ( { dataType: DataTypes.String, id: 'worker--string--tag--false', - isColumn: false, - isJSON: false, key: 'worker', type: 'tag', }, @@ -551,8 +509,6 @@ export const celeryTaskLatencyWidgetData = ( aggregateAttribute: { dataType: DataTypes.Float64, id: 'duration_nano--float64----true', - isColumn: true, - isJSON: false, key: 'duration_nano', type: '', }, @@ -569,8 +525,6 @@ export const celeryTaskLatencyWidgetData = ( { dataType: DataTypes.String, id: 'celery.task_name--string--tag--false', - isColumn: false, - isJSON: false, key: 'celery.task_name', type: 'tag', }, @@ -606,8 +560,6 @@ export const celerySlowestTasksTableWidgetData = getWidgetQueryBuilder( aggregateAttribute: { dataType: DataTypes.Float64, id: 'duration_nano--float64----true', - isColumn: true, - isJSON: false, key: 'duration_nano', type: '', }, @@ -624,8 +576,6 @@ export const celerySlowestTasksTableWidgetData = getWidgetQueryBuilder( { dataType: DataTypes.String, id: 'celery.task_name--string--tag--false', - isColumn: false, - isJSON: false, key: 'celery.task_name', type: 'tag', }, @@ -660,8 +610,6 @@ export const celeryRetryTasksTableWidgetData = getWidgetQueryBuilder( aggregateAttribute: { dataType: DataTypes.Float64, id: 'duration_nano--float64----true', - isColumn: true, - isJSON: false, key: 'duration_nano', type: '', }, @@ -676,8 +624,6 @@ export const celeryRetryTasksTableWidgetData = getWidgetQueryBuilder( key: { dataType: DataTypes.String, id: 'celery.state--string--tag--false', - isColumn: false, - isJSON: false, key: 'celery.state', type: 'tag', }, @@ -692,8 +638,6 @@ export const celeryRetryTasksTableWidgetData = getWidgetQueryBuilder( { dataType: DataTypes.String, id: 'celery.task_name--string--tag--false', - isColumn: false, - isJSON: false, key: 'celery.task_name', type: 'tag', }, @@ -729,8 +673,6 @@ export const celeryFailedTasksTableWidgetData = getWidgetQueryBuilder( aggregateAttribute: { dataType: DataTypes.Float64, id: 'duration_nano--float64----true', - isColumn: true, - isJSON: false, key: 'duration_nano', type: '', }, @@ -745,8 +687,6 @@ export const celeryFailedTasksTableWidgetData = getWidgetQueryBuilder( key: { dataType: DataTypes.String, id: 'celery.state--string--tag--false', - isColumn: false, - isJSON: false, key: 'celery.state', type: 'tag', }, @@ -761,8 +701,6 @@ export const celeryFailedTasksTableWidgetData = getWidgetQueryBuilder( { dataType: DataTypes.String, id: 'celery.task_name--string--tag--false', - isColumn: false, - isJSON: false, key: 'celery.task_name', type: 'tag', }, @@ -796,8 +734,6 @@ export const celerySuccessTasksTableWidgetData = getWidgetQueryBuilder( aggregateAttribute: { dataType: DataTypes.Float64, id: 'duration_nano--float64----true', - isColumn: true, - isJSON: false, key: 'duration_nano', type: '', }, @@ -812,8 +748,6 @@ export const celerySuccessTasksTableWidgetData = getWidgetQueryBuilder( key: { dataType: DataTypes.String, id: 'celery.state--string--tag--false', - isColumn: false, - isJSON: false, key: 'celery.state', type: 'tag', }, @@ -828,8 +762,6 @@ export const celerySuccessTasksTableWidgetData = getWidgetQueryBuilder( { dataType: DataTypes.String, id: 'celery.task_name--string--tag--false', - isColumn: false, - isJSON: false, key: 'celery.task_name', type: 'tag', }, @@ -869,8 +801,6 @@ export const celeryTimeSeriesTablesWidgetData = ( aggregateAttribute: { dataType: DataTypes.Float64, id: 'duration_nano--float64----true', - isColumn: true, - isJSON: false, key: 'duration_nano', type: '', }, @@ -885,8 +815,6 @@ export const celeryTimeSeriesTablesWidgetData = ( key: { dataType: DataTypes.String, id: `${entity}--string--tag--false`, - isColumn: false, - isJSON: false, key: `${entity}`, type: 'tag', }, @@ -901,8 +829,6 @@ export const celeryTimeSeriesTablesWidgetData = ( { dataType: DataTypes.String, id: 'celery.task_name--string--tag--false', - isColumn: false, - isJSON: false, key: 'celery.task_name', type: 'tag', }, @@ -933,8 +859,6 @@ export const celeryAllStateCountWidgetData = getWidgetQueryBuilder( aggregateAttribute: { dataType: DataTypes.String, id: 'span_id--string----true', - isColumn: true, - isJSON: false, key: 'span_id', type: '', }, @@ -972,8 +896,6 @@ export const celerySuccessStateCountWidgetData = getWidgetQueryBuilder( aggregateAttribute: { dataType: DataTypes.String, id: 'span_id--string----true', - isColumn: true, - isJSON: false, key: 'span_id', type: '', }, @@ -988,8 +910,6 @@ export const celerySuccessStateCountWidgetData = getWidgetQueryBuilder( key: { dataType: DataTypes.String, id: 'celery.state--string--tag--false', - isColumn: false, - isJSON: false, key: 'celery.state', type: 'tag', }, @@ -1025,8 +945,6 @@ export const celeryFailedStateCountWidgetData = getWidgetQueryBuilder( aggregateAttribute: { dataType: DataTypes.String, id: 'span_id--string----true', - isColumn: true, - isJSON: false, key: 'span_id', type: '', }, @@ -1041,8 +959,6 @@ export const celeryFailedStateCountWidgetData = getWidgetQueryBuilder( key: { dataType: DataTypes.String, id: 'celery.state--string--tag--false', - isColumn: false, - isJSON: false, key: 'celery.state', type: 'tag', }, @@ -1078,7 +994,6 @@ export const celeryRetryStateCountWidgetData = getWidgetQueryBuilder( aggregateAttribute: { dataType: DataTypes.String, id: 'span_id--string----true', - isColumn: true, key: 'span_id', type: '', }, @@ -1093,8 +1008,6 @@ export const celeryRetryStateCountWidgetData = getWidgetQueryBuilder( key: { dataType: DataTypes.String, id: 'celery.state--string--tag--false', - isColumn: false, - isJSON: false, key: 'celery.state', type: 'tag', }, diff --git a/frontend/src/components/CeleryTask/CeleryUtils.ts b/frontend/src/components/CeleryTask/CeleryUtils.ts index 1a2a4bfb487..41278c378e8 100644 --- a/frontend/src/components/CeleryTask/CeleryUtils.ts +++ b/frontend/src/components/CeleryTask/CeleryUtils.ts @@ -39,8 +39,6 @@ export function getFiltersFromQueryParams( key, dataType: DataTypes.String, type: 'tag', - isColumn: false, - isJSON: false, id: `${key}--string--tag--false`, }, op: '=', @@ -64,7 +62,8 @@ export function applyCeleryFilterOnWidgetData( ...queryItem, filters: { ...queryItem.filters, - items: [...queryItem.filters.items, ...filters], + items: [...(queryItem.filters?.items || []), ...filters], + op: queryItem.filters?.op || 'AND', }, } : queryItem, @@ -99,8 +98,7 @@ export const createFiltersFromData = ( key: string; dataType: DataTypes; type: string; - isColumn: boolean; - isJSON: boolean; + id: string; }; op: string; @@ -118,8 +116,6 @@ export const createFiltersFromData = ( key, dataType: DataTypes.String, type: 'tag', - isColumn: false, - isJSON: false, id: `${key}--string--tag--false`, }, op: '=', diff --git a/frontend/src/components/CeleryTask/useNavigateToExplorer.ts b/frontend/src/components/CeleryTask/useNavigateToExplorer.ts index 60bd88be52a..bae077887d6 100644 --- a/frontend/src/components/CeleryTask/useNavigateToExplorer.ts +++ b/frontend/src/components/CeleryTask/useNavigateToExplorer.ts @@ -19,6 +19,7 @@ export interface NavigateToExplorerProps { endTime?: number; sameTab?: boolean; shouldResolveQuery?: boolean; + widgetQuery?: Query; } export function useNavigateToExplorer(): ( @@ -30,26 +31,47 @@ export function useNavigateToExplorer(): ( ); const prepareQuery = useCallback( - (selectedFilters: TagFilterItem[], dataSource: DataSource): Query => ({ - ...currentQuery, - builder: { - ...currentQuery.builder, - queryData: currentQuery.builder.queryData - .map((item) => ({ - ...item, - dataSource, - aggregateOperator: MetricAggregateOperator.NOOP, - filters: { - ...item.filters, - items: selectedFilters, - }, - groupBy: [], - disabled: false, - })) - .slice(0, 1), - queryFormulas: [], - }, - }), + ( + selectedFilters: TagFilterItem[], + dataSource: DataSource, + query?: Query, + ): Query => { + const widgetQuery = query || currentQuery; + return { + ...widgetQuery, + builder: { + ...widgetQuery.builder, + queryData: widgetQuery.builder.queryData + .map((item) => { + // filter out filters with unique ids + const seen = new Set(); + const filterItems = [ + ...(item.filters?.items || []), + ...selectedFilters, + ].filter((item) => { + if (seen.has(item.id)) return false; + seen.add(item.id); + return true; + }); + + return { + ...item, + dataSource, + aggregateOperator: MetricAggregateOperator.NOOP, + filters: { + ...item.filters, + items: filterItems, + op: item.filters?.op || 'AND', + }, + groupBy: [], + disabled: false, + }; + }) + .slice(0, 1), + queryFormulas: [], + }, + }; + }, [currentQuery], ); @@ -66,6 +88,7 @@ export function useNavigateToExplorer(): ( endTime, sameTab, shouldResolveQuery, + widgetQuery, } = props; const urlParams = new URLSearchParams(); if (startTime && endTime) { @@ -76,7 +99,7 @@ export function useNavigateToExplorer(): ( urlParams.set(QueryParams.endTime, (maxTime / 1000000).toString()); } - let preparedQuery = prepareQuery(filters, dataSource); + let preparedQuery = prepareQuery(filters, dataSource, widgetQuery); if (shouldResolveQuery) { await getUpdatedQuery({ diff --git a/frontend/src/components/ChangelogModal/ChangelogModal.styles.scss b/frontend/src/components/ChangelogModal/ChangelogModal.styles.scss new file mode 100644 index 00000000000..3772a8717ca --- /dev/null +++ b/frontend/src/components/ChangelogModal/ChangelogModal.styles.scss @@ -0,0 +1,162 @@ +.changelog-modal { + .ant-modal-content { + padding: unset; + background-color: var(--bg-ink-400, #121317); + + .ant-modal-header { + margin-bottom: unset; + } + + .ant-modal-footer { + margin-top: unset; + } + } + + &-title { + display: flex; + align-items: center; + gap: 8px; + background-color: var(--bg-ink-400, #121317); + padding: 16px; + font-size: 14px; + line-height: 20px; + color: var(--text-vanilla-100, #fff); + border-bottom: 1px solid var(--bg-slate-500, #161922); + } + + &-footer.scroll-available { + .scroll-btn-container { + display: block; + } + } + + &-footer { + position: relative; + border: 1px solid var(--bg-slate-500, #161922); + padding: 12px; + display: flex; + align-items: center; + justify-content: space-between; + + &-label { + color: var(--text-robin-400, #7190f9); + font-size: 14px; + line-height: 24px; + position: relative; + padding-left: 14px; + + &::before { + content: ''; + position: absolute; + top: 50%; + left: 0; + transform: translateY(-50%); + width: 6px; + height: 6px; + border-radius: 100%; + background-color: var(--bg-robin-500, #7190f9); + } + } + + &-ctas { + display: flex; + margin-left: auto; + + & svg { + font-size: 14px; + } + } + + .scroll-btn-container { + display: none; + position: absolute; + top: -40px; + left: 50%; + transform: translateX(-50%); + + .scroll-btn { + all: unset; + padding: 4px 12px 4px 10px; + background-color: var(--bg-slate-400, #1d212d); + border-radius: 20px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + transition: background-color 0.1s; + + &:hover { + background-color: var(--bg-slate-200, #2c3140); + } + + &:active { + background-color: var(--bg-slate-600, #1c1f2a); + } + + span { + font-size: 12px; + line-height: 18px; + color: var(--text-vanilla-400, #c0c1c3); + } + + // add animation to the chevrons down icon + svg { + animation: pulse 1s infinite; + } + } + } + } + + &-content { + max-height: calc(100vh - 300px); + overflow-y: auto; + padding: 16px 16px 18px 16px; + border: 1px solid var(--bg-slate-500, #161922); + border-top-width: 0; + border-bottom-width: 0; + } +} + +// pulse for the scroll for more icon +@keyframes pulse { + 0% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +.lightMode { + .changelog-modal { + .ant-modal-content { + background-color: var(--bg-vanilla-100); + border-color: var(--bg-vanilla-300); + } + + &-title { + background: var(--bg-vanilla-100); + color: var(--bg-ink-500); + border-color: var(--bg-vanilla-300); + } + + &-content { + border-color: var(--bg-vanilla-300); + } + + &-footer { + border-color: var(--bg-vanilla-300); + + .scroll-btn-container { + .scroll-btn { + background-color: var(--bg-vanilla-300); + + span { + color: var(--text-ink-500); + } + } + } + } + } +} diff --git a/frontend/src/components/ChangelogModal/ChangelogModal.tsx b/frontend/src/components/ChangelogModal/ChangelogModal.tsx new file mode 100644 index 00000000000..586563ccfb0 --- /dev/null +++ b/frontend/src/components/ChangelogModal/ChangelogModal.tsx @@ -0,0 +1,162 @@ +import './ChangelogModal.styles.scss'; + +import { CheckOutlined, CloseOutlined } from '@ant-design/icons'; +import { Button, Modal } from 'antd'; +import updateUserPreference from 'api/v1/user/preferences/name/update'; +import cx from 'classnames'; +import { USER_PREFERENCES } from 'constants/userPreferences'; +import dayjs from 'dayjs'; +import { useGetTenantLicense } from 'hooks/useGetTenantLicense'; +import { ChevronsDown, ScrollText } from 'lucide-react'; +import { useAppContext } from 'providers/App/App'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useMutation } from 'react-query'; +import { ChangelogSchema } from 'types/api/changelog/getChangelogByVersion'; +import { UserPreference } from 'types/api/preferences/preference'; + +import ChangelogRenderer from './components/ChangelogRenderer'; + +interface Props { + changelog: ChangelogSchema; + onClose: () => void; +} + +function ChangelogModal({ changelog, onClose }: Props): JSX.Element { + const [hasScroll, setHasScroll] = useState(false); + const changelogContentSectionRef = useRef(null); + const { userPreferences, updateUserPreferenceInContext } = useAppContext(); + + const formattedReleaseDate = dayjs(changelog?.release_date).format( + 'MMMM D, YYYY', + ); + + const { isCloudUser } = useGetTenantLicense(); + + const seenChangelogVersion = userPreferences?.find( + (preference) => + preference.name === USER_PREFERENCES.LAST_SEEN_CHANGELOG_VERSION, + )?.value as string; + + const { mutate: updateUserPreferenceMutation } = useMutation( + updateUserPreference, + ); + + useEffect(() => { + // Update the seen version + if (seenChangelogVersion !== changelog.version) { + const version = { + name: USER_PREFERENCES.LAST_SEEN_CHANGELOG_VERSION, + value: changelog.version, + }; + updateUserPreferenceInContext(version as UserPreference); + updateUserPreferenceMutation(version); + } + }, [ + seenChangelogVersion, + changelog.version, + updateUserPreferenceMutation, + updateUserPreferenceInContext, + ]); + + const checkScroll = useCallback((): void => { + if (changelogContentSectionRef.current) { + const { + scrollHeight, + clientHeight, + scrollTop, + } = changelogContentSectionRef.current; + const isAtBottom = scrollHeight - clientHeight - scrollTop <= 8; + setHasScroll(scrollHeight > clientHeight + 24 && !isAtBottom); // 24px - buffer height to show show more + } + }, []); + + useEffect(() => { + checkScroll(); + const changelogContentSection = changelogContentSectionRef.current; + + if (changelogContentSection) { + changelogContentSection.addEventListener('scroll', checkScroll); + } + + return (): void => { + if (changelogContentSection) { + changelogContentSection.removeEventListener('scroll', checkScroll); + } + }; + }, [checkScroll]); + + const onClickUpdateWorkspace = (): void => { + window.open( + 'https://signoz.io/upgrade-path', + '_blank', + 'noopener,noreferrer', + ); + }; + + const onClickScrollForMore = (): void => { + if (changelogContentSectionRef.current) { + changelogContentSectionRef.current.scrollTo({ + top: changelogContentSectionRef.current.scrollTop + 600, // Scroll 600px from the current position + behavior: 'smooth', + }); + } + }; + + return ( + + + What’s New ⎯ Changelog : {formattedReleaseDate} +
+ } + width={820} + open + onCancel={onClose} + footer={ +
+ {!isCloudUser && ( +
+ + +
+ )} + {changelog && ( +
+ +
+ )} +
+ } + > +
+ {changelog && } +
+ + ); +} + +export default ChangelogModal; diff --git a/frontend/src/components/ChangelogModal/__test__/ChangelogModal.test.tsx b/frontend/src/components/ChangelogModal/__test__/ChangelogModal.test.tsx new file mode 100644 index 00000000000..36d43da6119 --- /dev/null +++ b/frontend/src/components/ChangelogModal/__test__/ChangelogModal.test.tsx @@ -0,0 +1,112 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +/* eslint-disable sonarjs/no-identical-functions */ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ + +import { fireEvent, render, screen } from '@testing-library/react'; +import { USER_PREFERENCES } from 'constants/userPreferences'; +import MockQueryClientProvider from 'providers/test/MockQueryClientProvider'; +import { + ChangelogSchema, + DeploymentType, +} from 'types/api/changelog/getChangelogByVersion'; + +import ChangelogModal from '../ChangelogModal'; + +const mockChangelog: ChangelogSchema = { + id: 1, + documentId: 'doc-1', + version: 'v1.0.0', + createdAt: '2025-06-09T12:00:00Z', + updatedAt: '2025-06-09T13:00:00Z', + publishedAt: '2025-06-09T14:00:00Z', + release_date: '2025-06-10', + features: [ + { + id: 1, + title: 'Feature 1', + description: 'Description for feature 1', + media: null, + documentId: 'feature-1', + sort_order: 1, + createdAt: '2025-06-09T12:00:00Z', + updatedAt: '2025-06-09T13:00:00Z', + publishedAt: '2025-06-09T14:00:00Z', + deployment_type: DeploymentType.ALL, + }, + ], + bug_fixes: 'Bug fix details', + maintenance: 'Maintenance details', +}; + +// Mock react-markdown to just render children as plain text +jest.mock( + 'react-markdown', + () => + function ReactMarkdown({ children }: any) { + return
{children}
; + }, +); +// mock useAppContext +jest.mock('providers/App/App', () => ({ + useAppContext: jest.fn(() => ({ + updateUserPreferenceInContext: jest.fn(), + userPreferences: [ + { + name: USER_PREFERENCES.LAST_SEEN_CHANGELOG_VERSION, + value: 'v1.0.0', + }, + ], + })), +})); + +function renderChangelog(onClose: () => void = jest.fn()): void { + render( + + + , + ); +} + +describe('ChangelogModal', () => { + it('renders modal with changelog data', () => { + renderChangelog(); + expect( + screen.getByText('What’s New ⎯ Changelog : June 10, 2025'), + ).toBeInTheDocument(); + expect(screen.getByText('Feature 1')).toBeInTheDocument(); + expect(screen.getByText('Description for feature 1')).toBeInTheDocument(); + expect(screen.getByText('Bug fix details')).toBeInTheDocument(); + expect(screen.getByText('Maintenance details')).toBeInTheDocument(); + }); + + it('calls onClose when Skip for now is clicked', () => { + const onClose = jest.fn(); + renderChangelog(onClose); + fireEvent.click(screen.getByText('Skip for now')); + expect(onClose).toHaveBeenCalled(); + }); + + it('opens migration docs when Update my workspace is clicked', () => { + window.open = jest.fn(); + renderChangelog(); + fireEvent.click(screen.getByText('Update my workspace')); + expect(window.open).toHaveBeenCalledWith( + 'https://signoz.io/upgrade-path', + '_blank', + 'noopener,noreferrer', + ); + }); + + it('scrolls for more when Scroll for more is clicked', () => { + renderChangelog(); + const scrollBtn = screen.getByTestId('scroll-more-btn'); + const contentDiv = screen.getByTestId('changelog-content'); + if (contentDiv) { + contentDiv.scrollTo = jest.fn(); + } + fireEvent.click(scrollBtn); + if (contentDiv) { + expect(contentDiv.scrollTo).toHaveBeenCalled(); + } + }); +}); diff --git a/frontend/src/components/ChangelogModal/__test__/ChangelogRenderer.test.tsx b/frontend/src/components/ChangelogModal/__test__/ChangelogRenderer.test.tsx new file mode 100644 index 00000000000..ed844f89b4b --- /dev/null +++ b/frontend/src/components/ChangelogModal/__test__/ChangelogRenderer.test.tsx @@ -0,0 +1,67 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +/* eslint-disable sonarjs/no-identical-functions */ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ + +import { render, screen } from '@testing-library/react'; +import { + ChangelogSchema, + DeploymentType, +} from 'types/api/changelog/getChangelogByVersion'; + +import ChangelogRenderer from '../components/ChangelogRenderer'; + +// Mock react-markdown to just render children as plain text +jest.mock( + 'react-markdown', + () => + function ReactMarkdown({ children }: any) { + return
{children}
; + }, +); + +const mockChangelog: ChangelogSchema = { + id: 1, + documentId: 'doc-1', + version: 'v1.0.0', + createdAt: '2025-06-09T12:00:00Z', + updatedAt: '2025-06-09T13:00:00Z', + publishedAt: '2025-06-09T14:00:00Z', + release_date: '2025-06-10', + features: [ + { + id: 1, + title: 'Feature 1', + description: 'Description for feature 1', + media: { + id: 1, + documentId: 'doc1', + ext: '.webp', + url: '/uploads/feature1.webp', + mime: 'image/webp', + alternativeText: null, + }, + documentId: 'feature-1', + sort_order: 1, + createdAt: '2025-06-09T12:00:00Z', + updatedAt: '2025-06-09T13:00:00Z', + publishedAt: '2025-06-09T14:00:00Z', + deployment_type: DeploymentType.ALL, + }, + ], + bug_fixes: 'Bug fix details', + maintenance: 'Maintenance details', +}; + +describe('ChangelogRenderer', () => { + it('renders release date', () => { + render(); + expect(screen.getByText('June 10, 2025')).toBeInTheDocument(); + }); + + it('renders features, media, and description', () => { + render(); + expect(screen.getByText('Feature 1')).toBeInTheDocument(); + expect(screen.getByAltText('Media')).toBeInTheDocument(); + expect(screen.getByText('Description for feature 1')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/ChangelogModal/components/ChangelogRenderer.styles.scss b/frontend/src/components/ChangelogModal/components/ChangelogRenderer.styles.scss new file mode 100644 index 00000000000..bfd29677ca0 --- /dev/null +++ b/frontend/src/components/ChangelogModal/components/ChangelogRenderer.styles.scss @@ -0,0 +1,153 @@ +.changelog-renderer { + position: relative; + padding-left: 20px; + + & :is(h1, h2, h3, h4, h5, h6, p, &-section-title) { + margin-bottom: 12px; + } + + &-content { + display: flex; + flex-direction: column; + gap: 32px; + } + + &-section-title { + font-size: 14px; + line-height: 20px; + color: var(--text-vanilla-400, #c0c1c3); + } + + .changelog-release-date { + font-size: 14px; + line-height: 20px; + color: var(--text-vanilla-400, #c0c1c3); + display: block; + margin-bottom: 12px; + } + + &-list { + display: flex; + flex-direction: column; + gap: 28px; + } + + &-line { + position: absolute; + left: 0; + top: 6px; + bottom: -30px; + width: 1px; + background-color: var(--bg-slate-400, #1d212d); + + .inner-ball { + position: absolute; + left: 50%; + width: 6px; + height: 6px; + border-radius: 100%; + transform: translateX(-50%); + background-color: var(--bg-robin-500, #7190f9); + } + } + + ul, + ol { + list-style: none; + display: flex; + flex-direction: column; + gap: 16px; + padding-left: 30px; + + li { + position: relative; + &::before { + content: ''; + position: absolute; + left: -10px; + top: 10px; + width: 20px; + height: 2px; + background-color: var(--bg-robin-500, #7190f9); + transform: translate(-100%, -50%); + } + } + } + + li, + p { + font-size: 14px; + line-height: 20px; + color: var(--text-vanilla-400, #c0c1c3); + } + + code { + padding: 2px 4px; + background-color: var(--bg-slate-500, #161922); + border-radius: 6px; + font-size: 95%; + vertical-align: middle; + border: 1px solid var(--bg-slate-600, #1c1f2a); + } + a { + color: var(--text-robin-500, #7190f9); + font-weight: 600; + text-decoration: underline; + + &:hover { + text-decoration: none; + } + } + + & :is(h1, h2, h3, h4, h5, h6, p, &-section-title) { + font-weight: 600; + color: var(--text-vanilla-100, #fff); + } + + h1 { + font-size: 24px; + line-height: 32px; + } + + h2, + &-section-title { + font-size: 20px; + line-height: 28px; + } + + .changelog-media-image, + .changelog-media-video { + height: auto; + width: 100%; + overflow: hidden; + border-radius: 4px; + border: 1px solid var(--bg-slate-400, #1d212d); + margin-bottom: 28px; + } + + .changelog-media-video { + margin: 12px 0; + } +} + +.lightMode { + .changelog-renderer { + .changelog-release-date { + color: var(--text-ink-500); + } + + &-line { + background-color: var(--bg-vanilla-300); + } + + & :is(h1, h2, h3, h4, h5, h6, p, li, &-section-title) { + color: var(--text-ink-500); + } + + code { + background-color: var(--bg-vanilla-300); + border: 1px solid var(--bg-vanilla-300); + color: var(--text-ink-500); + } + } +} diff --git a/frontend/src/components/ChangelogModal/components/ChangelogRenderer.tsx b/frontend/src/components/ChangelogModal/components/ChangelogRenderer.tsx new file mode 100644 index 00000000000..5113b4d6abc --- /dev/null +++ b/frontend/src/components/ChangelogModal/components/ChangelogRenderer.tsx @@ -0,0 +1,91 @@ +import './ChangelogRenderer.styles.scss'; + +import dayjs from 'dayjs'; +import ReactMarkdown from 'react-markdown'; +import { + ChangelogSchema, + Media, + SupportedImageTypes, + SupportedVideoTypes, +} from 'types/api/changelog/getChangelogByVersion'; + +interface Props { + changelog: ChangelogSchema; +} + +function renderMedia(media: Media): JSX.Element | null { + if (SupportedImageTypes.includes(media.ext)) { + return ( + {media.alternativeText + ); + } + if (SupportedVideoTypes.includes(media.ext)) { + return ( + + ); + } + + return null; +} + +function ChangelogRenderer({ changelog }: Props): JSX.Element { + const formattedReleaseDate = dayjs(changelog.release_date).format( + 'MMMM D, YYYY', + ); + + return ( +
+
+
+
+ {formattedReleaseDate} +
+ {changelog.features && changelog.features.length > 0 && ( +
+ {changelog.features.map((feature) => ( +
+
{feature.title}
+ {feature.media && renderMedia(feature.media)} + {feature.description} +
+ ))} +
+ )} + {changelog.bug_fixes && changelog.bug_fixes.length > 0 && ( +
+
Bug Fixes
+ {changelog.bug_fixes && ( + {changelog.bug_fixes} + )} +
+ )} + {changelog.maintenance && changelog.maintenance.length > 0 && ( +
+
Maintenance
+ {changelog.maintenance && ( + {changelog.maintenance} + )} +
+ )} +
+
+ ); +} + +export default ChangelogRenderer; diff --git a/frontend/src/components/ChatSupportGateway/ChatSupportGateway.tsx b/frontend/src/components/ChatSupportGateway/ChatSupportGateway.tsx index 94e8de81944..16ee925e911 100644 --- a/frontend/src/components/ChatSupportGateway/ChatSupportGateway.tsx +++ b/frontend/src/components/ChatSupportGateway/ChatSupportGateway.tsx @@ -1,14 +1,14 @@ import { Button, Modal, Typography } from 'antd'; -import updateCreditCardApi from 'api/billing/checkout'; import logEvent from 'api/common/logEvent'; -import { SOMETHING_WENT_WRONG } from 'constants/api'; +import updateCreditCardApi from 'api/v1/checkout/create'; import { useNotifications } from 'hooks/useNotifications'; -import { CreditCard, X } from 'lucide-react'; +import { CreditCard, MessageSquareText, X } from 'lucide-react'; import { useState } from 'react'; import { useMutation } from 'react-query'; import { useLocation } from 'react-router-dom'; -import { ErrorResponse, SuccessResponse } from 'types/api'; +import { SuccessResponseV2 } from 'types/api'; import { CheckoutSuccessPayloadProps } from 'types/api/billing/checkout'; +import APIError from 'types/api/error'; export default function ChatSupportGateway(): JSX.Element { const { notifications } = useNotifications(); @@ -18,20 +18,21 @@ export default function ChatSupportGateway(): JSX.Element { ); const handleBillingOnSuccess = ( - data: ErrorResponse | SuccessResponse, + data: SuccessResponseV2, ): void => { - if (data?.payload?.redirectURL) { + if (data?.data?.redirectURL) { const newTab = document.createElement('a'); - newTab.href = data.payload.redirectURL; + newTab.href = data.data.redirectURL; newTab.target = '_blank'; newTab.rel = 'noopener noreferrer'; newTab.click(); } }; - const handleBillingOnError = (): void => { + const handleBillingOnError = (error: APIError): void => { notifications.error({ - message: SOMETHING_WENT_WRONG, + message: error.getErrorCode(), + description: error.getErrorMessage(), }); }; @@ -48,7 +49,7 @@ export default function ChatSupportGateway(): JSX.Element { const handleAddCreditCard = (): void => { logEvent('Add Credit card modal: Clicked', { - source: `intercom icon`, + source: `chat support icon`, page: pathname, }); @@ -64,20 +65,14 @@ export default function ChatSupportGateway(): JSX.Element { className="chat-support-gateway-btn" onClick={(): void => { logEvent('Disabled Chat Support: Clicked', { - source: `intercom icon`, + source: `chat support icon`, page: pathname, }); setIsAddCreditCardModalOpen(true); }} > - - - +
diff --git a/frontend/src/components/ClientSideQBSearch/ClientSideQBSearch.tsx b/frontend/src/components/ClientSideQBSearch/ClientSideQBSearch.tsx index 1a6d501ff66..9cb6733c8d9 100644 --- a/frontend/src/components/ClientSideQBSearch/ClientSideQBSearch.tsx +++ b/frontend/src/components/ClientSideQBSearch/ClientSideQBSearch.tsx @@ -241,8 +241,6 @@ function ClientSideQBSearch( key: 'body', dataType: DataTypes.String, type: '', - isColumn: true, - isJSON: false, id: 'body--string----true', }, op: OPERATORS.CONTAINS, diff --git a/frontend/src/components/Common/Common.styles.scss b/frontend/src/components/Common/Common.styles.scss new file mode 100644 index 00000000000..45227c7b627 --- /dev/null +++ b/frontend/src/components/Common/Common.styles.scss @@ -0,0 +1,33 @@ +.error-state-container { + height: 240px; + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + + border-radius: 3px; + + .error-state-container-content { + display: flex; + flex-direction: column; + gap: 8px; + + .error-state-text { + font-size: 14px; + font-weight: 500; + } + + .error-state-additional-messages { + margin-top: 8px; + display: flex; + flex-direction: column; + gap: 4px; + + .error-state-additional-text { + font-size: 12px; + font-weight: 400; + margin-left: 8px; + } + } + } +} diff --git a/frontend/src/components/Common/ErrorStateComponent.tsx b/frontend/src/components/Common/ErrorStateComponent.tsx new file mode 100644 index 00000000000..8734e4ec79e --- /dev/null +++ b/frontend/src/components/Common/ErrorStateComponent.tsx @@ -0,0 +1,59 @@ +import './Common.styles.scss'; + +import { Typography } from 'antd'; + +import APIError from '../../types/api/error'; + +interface ErrorStateComponentProps { + message?: string; + error?: APIError; +} + +const defaultProps: Partial = { + message: undefined, + error: undefined, +}; + +function ErrorStateComponent({ + message, + error, +}: ErrorStateComponentProps): JSX.Element { + // Handle API Error object + if (error) { + const mainMessage = error.getErrorMessage(); + const additionalErrors = error.getErrorDetails().error.errors || []; + + return ( +
+
+ {mainMessage} + {additionalErrors.length > 0 && ( +
+ {additionalErrors.map((additionalError) => ( + + • {additionalError.message} + + ))} +
+ )} +
+
+ ); + } + + // Handle simple string message (backwards compatibility) + return ( +
+
+ {message} +
+
+ ); +} + +ErrorStateComponent.defaultProps = defaultProps; + +export default ErrorStateComponent; diff --git a/frontend/src/components/CustomTimePicker/CustomTimePicker.styles.scss b/frontend/src/components/CustomTimePicker/CustomTimePicker.styles.scss index 022a193761c..d97688b0cfe 100644 --- a/frontend/src/components/CustomTimePicker/CustomTimePicker.styles.scss +++ b/frontend/src/components/CustomTimePicker/CustomTimePicker.styles.scss @@ -1,6 +1,16 @@ .custom-time-picker { display: flex; flex-direction: column; + + .timeSelection-input { + &:hover { + border-color: #1d212d !important; + } + } + + .time-input-suffix { + display: flex; + } } .time-options-container { @@ -135,6 +145,7 @@ align-items: center; color: var(--bg-vanilla-400); gap: 6px; + .timezone { display: flex; align-items: center; @@ -163,6 +174,52 @@ cursor: pointer; } +.time-input-prefix { + .live-dot-icon { + width: 6px; + height: 6px; + border-radius: 50%; + background-color: var(--bg-forest-500); + animation: ripple 1s infinite; + + margin-right: 4px; + margin-left: 4px; + } +} + +@keyframes ripple { + 0% { + box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.4); + } + 70% { + box-shadow: 0 0 0 6px rgba(245, 158, 11, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(245, 158, 11, 0); + } +} + +.time-input-suffix-icon-badge { + display: flex; + align-items: center; + justify-content: center; + padding: 0 4px; + border-radius: 2px; + background: rgba(171, 189, 255, 0.04); + color: var(--bg-vanilla-100); + font-size: 12px; + font-weight: 400; + line-height: 16px; + letter-spacing: -0.06px; + cursor: pointer; + height: 20px; + width: 20px; + + &:hover { + background: rgba(171, 189, 255, 0.08); + } +} + .lightMode { .date-time-popover__footer { border-color: var(--bg-vanilla-400); @@ -180,8 +237,26 @@ } } } + + .custom-time-picker { + .timeSelection-input { + &:hover { + border-color: var(--bg-vanilla-300) !important; + } + } + } + .timezone-badge { color: var(--bg-ink-100); background: rgb(179 179 179 / 15%); } + + .time-input-suffix-icon-badge { + color: var(--bg-ink-100); + background: rgb(179 179 179 / 15%); + + &:hover { + background: rgb(179 179 179 / 20%); + } + } } diff --git a/frontend/src/components/CustomTimePicker/CustomTimePicker.tsx b/frontend/src/components/CustomTimePicker/CustomTimePicker.tsx index 6b330b0127a..8882c6f60a8 100644 --- a/frontend/src/components/CustomTimePicker/CustomTimePicker.tsx +++ b/frontend/src/components/CustomTimePicker/CustomTimePicker.tsx @@ -5,6 +5,7 @@ import './CustomTimePicker.styles.scss'; import { Input, Popover, Tooltip, Typography } from 'antd'; import logEvent from 'api/common/logEvent'; import cx from 'classnames'; +import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats'; import { DateTimeRangeType } from 'container/TopNav/CustomDateTimeModal'; import { FixedDurationSuggestionOptions, @@ -26,7 +27,10 @@ import { useMemo, useState, } from 'react'; +import { useSelector } from 'react-redux'; import { useLocation } from 'react-router-dom'; +import { AppState } from 'store/reducers'; +import { GlobalReducer } from 'types/reducer/globalTime'; import { popupContainer } from 'utils/selectPopupContainer'; import CustomTimePickerPopoverContent from './CustomTimePickerPopoverContent'; @@ -55,7 +59,9 @@ interface CustomTimePickerProps { customDateTimeVisible?: boolean; setCustomDTPickerVisible?: Dispatch>; onCustomDateHandler?: (dateTimeRange: DateTimeRangeType) => void; - handleGoLive?: () => void; + showLiveLogs?: boolean; + onGoLive?: () => void; + onExitLiveLogs?: () => void; } function CustomTimePicker({ @@ -72,13 +78,19 @@ function CustomTimePicker({ customDateTimeVisible, setCustomDTPickerVisible, onCustomDateHandler, - handleGoLive, + onGoLive, + onExitLiveLogs, + showLiveLogs, }: CustomTimePickerProps): JSX.Element { const [ selectedTimePlaceholderValue, setSelectedTimePlaceholderValue, ] = useState('Select / Enter Time Range'); + const { maxTime, minTime } = useSelector( + (state) => state.globalTime, + ); + const [inputValue, setInputValue] = useState(''); const [inputStatus, setInputStatus] = useState<'' | 'error' | 'success'>(''); const [inputErrorMessage, setInputErrorMessage] = useState( @@ -157,9 +169,13 @@ function CustomTimePicker({ }; useEffect(() => { - const value = getSelectedTimeRangeLabel(selectedTime, selectedValue); - setSelectedTimePlaceholderValue(value); - }, [selectedTime, selectedValue]); + if (showLiveLogs) { + setSelectedTimePlaceholderValue('Live'); + } else { + const value = getSelectedTimeRangeLabel(selectedTime, selectedValue); + setSelectedTimePlaceholderValue(value); + } + }, [selectedTime, selectedValue, showLiveLogs]); const hide = (): void => { setOpen(false); @@ -249,6 +265,11 @@ function CustomTimePicker({ }; const handleSelect = (label: string, value: string): void => { + if (label === 'Custom') { + setCustomDTPickerVisible?.(true); + return; + } + onSelect(value); setSelectedTimePlaceholderValue(label); setInputStatus(''); @@ -311,82 +332,118 @@ function CustomTimePicker({ ); }; + const getTooltipTitle = (): string => { + if (selectedTime === 'custom' && inputValue === '' && !open) { + return `${dayjs(minTime / 1000_000) + .tz(timezone.value) + .format(DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS)} - ${dayjs( + maxTime / 1000_000, + ) + .tz(timezone.value) + .format(DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS)}`; + } + + return ''; + }; + + const getInputPrefix = (): JSX.Element => { + if (showLiveLogs) { + return ( +
+
+
+ ); + } + + return ( +
+ {inputValue && inputStatus === 'success' ? ( + + ) : ( + + + + )} +
+ ); + }; + return (
- - ) : ( - content - ) - } - arrow={false} - trigger="click" - open={open} - onOpenChange={handleOpenChange} - style={{ - padding: 0, - }} - > - + + ) : ( - - - + content ) } - suffix={ - <> - {!!isTimezoneOverridden && activeTimezoneOffset && ( -
- {activeTimezoneOffset} -
- )} - handleViewChange('datetime')} - /> - - } - /> -
- + arrow={false} + trigger="click" + open={open} + onOpenChange={handleOpenChange} + style={{ + padding: 0, + }} + > + + {!!isTimezoneOverridden && activeTimezoneOffset && ( +
+ {activeTimezoneOffset} +
+ )} + { + e.stopPropagation(); + handleViewChange('datetime'); + }} + /> +
+ } + /> + + {inputStatus === 'error' && inputErrorMessage && ( {inputErrorMessage} @@ -403,6 +460,8 @@ CustomTimePicker.defaultProps = { customDateTimeVisible: false, setCustomDTPickerVisible: noop, onCustomDateHandler: noop, - handleGoLive: noop, + onGoLive: noop, onCustomTimeStatusUpdate: noop, + onExitLiveLogs: noop, + showLiveLogs: false, }; diff --git a/frontend/src/components/CustomTimePicker/CustomTimePickerPopoverContent.tsx b/frontend/src/components/CustomTimePicker/CustomTimePickerPopoverContent.tsx index d5c4339bc7c..94b45207416 100644 --- a/frontend/src/components/CustomTimePicker/CustomTimePickerPopoverContent.tsx +++ b/frontend/src/components/CustomTimePicker/CustomTimePickerPopoverContent.tsx @@ -4,6 +4,9 @@ import { Color } from '@signozhq/design-tokens'; import { Button } from 'antd'; import logEvent from 'api/common/logEvent'; import cx from 'classnames'; +import DatePickerV2 from 'components/DatePickerV2/DatePickerV2'; +import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats'; +import { QueryParams } from 'constants/query'; import ROUTES from 'constants/routes'; import { DateTimeRangeType } from 'container/TopNav/CustomDateTimeModal'; import { @@ -11,12 +14,20 @@ import { Option, RelativeDurationSuggestionOptions, } from 'container/TopNav/DateTimeSelectionV2/config'; +import dayjs from 'dayjs'; import { Clock, PenLine } from 'lucide-react'; import { useTimezone } from 'providers/Timezone'; -import { Dispatch, SetStateAction, useMemo } from 'react'; +import { + Dispatch, + SetStateAction, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; import { useLocation } from 'react-router-dom'; +import { getCustomTimeRanges } from 'utils/customTimeRangeUtils'; -import RangePickerModal from './RangePickerModal'; import TimezonePicker from './TimezonePicker'; interface CustomTimePickerPopoverContentProps { @@ -29,12 +40,21 @@ interface CustomTimePickerPopoverContentProps { lexicalContext?: LexicalContext, ) => void; onSelectHandler: (label: string, value: string) => void; - handleGoLive: () => void; + onGoLive: () => void; selectedTime: string; activeView: 'datetime' | 'timezone'; setActiveView: Dispatch>; isOpenedFromFooter: boolean; setIsOpenedFromFooter: Dispatch>; + onExitLiveLogs: () => void; +} + +interface RecentlyUsedDateTimeRange { + label: string; + value: number; + timestamp: number; + from: string; + to: string; } // eslint-disable-next-line sonarjs/cognitive-complexity @@ -45,21 +65,68 @@ function CustomTimePickerPopoverContent({ setCustomDTPickerVisible, onCustomDateHandler, onSelectHandler, - handleGoLive, + onGoLive, selectedTime, activeView, setActiveView, isOpenedFromFooter, setIsOpenedFromFooter, + onExitLiveLogs, }: CustomTimePickerPopoverContentProps): JSX.Element { const { pathname } = useLocation(); const isLogsExplorerPage = useMemo(() => pathname === ROUTES.LOGS_EXPLORER, [ pathname, ]); + + const url = new URLSearchParams(window.location.search); + + let panelTypeFromURL = url.get(QueryParams.panelTypes); + + try { + panelTypeFromURL = JSON.parse(panelTypeFromURL as string); + } catch { + // fallback → leave as-is + } + + const isLogsListView = + panelTypeFromURL !== 'table' && panelTypeFromURL !== 'graph'; // we do not select list view in the url + const { timezone } = useTimezone(); const activeTimezoneOffset = timezone.offset; + const [recentlyUsedTimeRanges, setRecentlyUsedTimeRanges] = useState< + RecentlyUsedDateTimeRange[] + >([]); + + const handleExitLiveLogs = useCallback((): void => { + if (isLogsExplorerPage) { + onExitLiveLogs(); + } + }, [isLogsExplorerPage, onExitLiveLogs]); + + useEffect(() => { + if (!customDateTimeVisible) { + const customTimeRanges = getCustomTimeRanges(); + + const formattedCustomTimeRanges: RecentlyUsedDateTimeRange[] = customTimeRanges.map( + (range) => ({ + label: `${dayjs(range.from) + .tz(timezone.value) + .format(DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS)} - ${dayjs(range.to) + .tz(timezone.value) + .format(DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS)}`, + from: range.from, + to: range.to, + value: range.timestamp, + timestamp: range.timestamp, + }), + ); + + setRecentlyUsedTimeRanges(formattedCustomTimeRanges); + } + }, [customDateTimeVisible, timezone.value]); + function getTimeChips(options: Option[]): JSX.Element { return (
@@ -69,6 +136,7 @@ function CustomTimePickerPopoverContent({ className="time-btns" key={option.label + option.value} onClick={(): void => { + handleExitLiveLogs(); onSelectHandler(option.label, option.value); }} > @@ -102,52 +170,87 @@ function CustomTimePickerPopoverContent({ ); } + const handleGoLive = (): void => { + onGoLive(); + setIsOpen(false); + }; + return ( <>
-
- {isLogsExplorerPage && ( - - )} - {options.map((option) => ( - - ))} -
+ {!customDateTimeVisible && ( +
+ {isLogsExplorerPage && isLogsListView && ( + + )} + {options.map((option) => ( + + ))} +
+ )}
- {selectedTime === 'custom' || customDateTimeVisible ? ( - ) : ( -
-
RELATIVE TIMES
-
{getTimeChips(RelativeDurationSuggestionOptions)}
+
+
+
RELATIVE TIMES
+
{getTimeChips(RelativeDurationSuggestionOptions)}
+
+ +
+
RECENTLY USED
+
+ {recentlyUsedTimeRanges.map((range: RecentlyUsedDateTimeRange) => ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + handleExitLiveLogs(); + onCustomDateHandler([dayjs(range.from), dayjs(range.to)]); + setIsOpen(false); + } + }} + key={range.value} + onClick={(): void => { + handleExitLiveLogs(); + onCustomDateHandler([dayjs(range.from), dayjs(range.to)]); + setIsOpen(false); + }} + > + {range.label} +
+ ))} +
+
)}
diff --git a/frontend/src/components/CustomTimePicker/RangePickerModal.tsx b/frontend/src/components/CustomTimePicker/RangePickerModal.tsx index 53c4171b362..e7b0c092ac1 100644 --- a/frontend/src/components/CustomTimePicker/RangePickerModal.tsx +++ b/frontend/src/components/CustomTimePicker/RangePickerModal.tsx @@ -1,9 +1,14 @@ +/* eslint-disable react/jsx-props-no-spreading */ import './RangePickerModal.styles.scss'; import { DatePicker } from 'antd'; import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats'; import { DateTimeRangeType } from 'container/TopNav/CustomDateTimeModal'; -import { LexicalContext } from 'container/TopNav/DateTimeSelectionV2/config'; +import { + CustomTimeType, + LexicalContext, + Time, +} from 'container/TopNav/DateTimeSelectionV2/config'; import dayjs, { Dayjs } from 'dayjs'; import { useTimezone } from 'providers/Timezone'; import { Dispatch, SetStateAction, useMemo } from 'react'; @@ -19,6 +24,10 @@ interface RangePickerModalProps { lexicalContext?: LexicalContext | undefined, ) => void; selectedTime: string; + onTimeChange?: ( + interval: Time | CustomTimeType, + dateTimeRange?: [number, number], + ) => void; } function RangePickerModal(props: RangePickerModalProps): JSX.Element { @@ -27,6 +36,7 @@ function RangePickerModal(props: RangePickerModalProps): JSX.Element { setIsOpen, onCustomDateHandler, selectedTime, + onTimeChange, } = props; const { RangePicker } = DatePicker; const { maxTime, minTime } = useSelector( @@ -74,13 +84,23 @@ function RangePickerModal(props: RangePickerModalProps): JSX.Element { date.tz(timezone.value).format(DATE_TIME_FORMATS.ISO_DATETIME) } onOk={onModalOkHandler} - // eslint-disable-next-line react/jsx-props-no-spreading - {...(selectedTime === 'custom' && { - value: rangeValue, - })} + data-1p-ignore + {...(selectedTime === 'custom' && + !onTimeChange && { + value: rangeValue, + })} + // use default value if onTimeChange is provided + {...(selectedTime === 'custom' && + onTimeChange && { + defaultValue: rangeValue, + })} />
); } +RangePickerModal.defaultProps = { + onTimeChange: undefined, +}; + export default RangePickerModal; diff --git a/frontend/src/components/CustomTimePicker/TimezonePicker.tsx b/frontend/src/components/CustomTimePicker/TimezonePicker.tsx index 63d4502558d..463674e74d0 100644 --- a/frontend/src/components/CustomTimePicker/TimezonePicker.tsx +++ b/frontend/src/components/CustomTimePicker/TimezonePicker.tsx @@ -72,6 +72,7 @@ function SearchBar({ onKeyDown={handleKeyDown} tabIndex={0} autoFocus + data-1p-ignore />
esc diff --git a/frontend/src/components/CustomTimePicker/timezoneUtils.ts b/frontend/src/components/CustomTimePicker/timezoneUtils.ts index 92da405ba48..bca511e4970 100644 --- a/frontend/src/components/CustomTimePicker/timezoneUtils.ts +++ b/frontend/src/components/CustomTimePicker/timezoneUtils.ts @@ -119,7 +119,9 @@ const filterAndSortTimezones = ( return createTimezoneEntry(normalizedTz, offset); }); -const generateTimezoneData = (includeEtcTimezones = false): Timezone[] => { +export const generateTimezoneData = ( + includeEtcTimezones = false, +): Timezone[] => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const allTimezones = (Intl as any).supportedValuesOf('timeZone'); const timezones: Timezone[] = []; diff --git a/frontend/src/components/DatePickerV2/DatePickerV2.styles.scss b/frontend/src/components/DatePickerV2/DatePickerV2.styles.scss new file mode 100644 index 00000000000..40f86cf29b8 --- /dev/null +++ b/frontend/src/components/DatePickerV2/DatePickerV2.styles.scss @@ -0,0 +1,114 @@ +.date-picker-v2-container { + display: flex; + flex-direction: row; +} + +.custom-date-time-picker-v2 { + padding: 12px; + + .periscope-calendar { + border-radius: 4px; + border: none !important; + background: none !important; + padding: 8px 0 !important; + } + + .periscope-calendar-day { + background: none !important; + + &.periscope-calendar-today { + &.text-accent-foreground { + color: var(--bg-vanilla-100) !important; + } + } + + button { + &:hover { + background-color: var(--bg-robin-500) !important; + color: var(--bg-vanilla-100) !important; + } + } + } + + .custom-time-selector { + display: flex; + flex-direction: row; + gap: 16px; + align-items: center; + justify-content: space-between; + + .time-input { + border-radius: 4px; + border: none !important; + background: none !important; + padding: 8px 4px !important; + color: var(--bg-vanilla-100) !important; + + &::-webkit-calendar-picker-indicator { + display: none !important; + -webkit-appearance: none; + appearance: none; + } + + &:focus { + border: none !important; + outline: none !important; + box-shadow: none !important; + } + + &:focus-visible { + border: none !important; + outline: none !important; + box-shadow: none !important; + } + } + } + + .custom-date-time-picker-footer { + display: flex; + flex-direction: row; + gap: 8px; + align-items: center; + justify-content: flex-end; + margin-top: 16px; + + .next-btn { + width: 80px; + } + + .clear-btn { + width: 80px; + } + } +} + +.invalid-date-range-tooltip { + .ant-tooltip-inner { + color: var(--bg-sakura-500) !important; + } +} + +.lightMode { + .custom-date-time-picker-v2 { + .periscope-calendar-day { + &.periscope-calendar-today { + &.text-accent-foreground { + color: var(--bg-ink-500) !important; + } + } + + button { + &:hover { + background-color: var(--bg-robin-500) !important; + color: var(--bg-ink-500) !important; + } + } + } + + .custom-time-selector { + .time-input { + color: var(--bg-ink-500) !important; + } + } + } +} diff --git a/frontend/src/components/DatePickerV2/DatePickerV2.tsx b/frontend/src/components/DatePickerV2/DatePickerV2.tsx new file mode 100644 index 00000000000..32f55573dbd --- /dev/null +++ b/frontend/src/components/DatePickerV2/DatePickerV2.tsx @@ -0,0 +1,311 @@ +import './DatePickerV2.styles.scss'; + +import { Calendar } from '@signozhq/calendar'; +import { Input } from '@signozhq/input'; +import { Button, Tooltip } from 'antd'; +import cx from 'classnames'; +import { DateTimeRangeType } from 'container/TopNav/CustomDateTimeModal'; +import { LexicalContext } from 'container/TopNav/DateTimeSelectionV2/config'; +import dayjs, { Dayjs } from 'dayjs'; +import { CornerUpLeft, MoveRight } from 'lucide-react'; +import { useTimezone } from 'providers/Timezone'; +import { useRef, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { AppState } from 'store/reducers'; +import { GlobalReducer } from 'types/reducer/globalTime'; +import { addCustomTimeRange } from 'utils/customTimeRangeUtils'; + +function DatePickerV2({ + onSetCustomDTPickerVisible, + setIsOpen, + onCustomDateHandler, +}: { + onSetCustomDTPickerVisible: (visible: boolean) => void; + setIsOpen: (isOpen: boolean) => void; + onCustomDateHandler: ( + dateTimeRange: DateTimeRangeType, + lexicalContext?: LexicalContext, + ) => void; +}): JSX.Element { + const { maxTime, minTime } = useSelector( + (state) => state.globalTime, + ); + + const timeInputRef = useRef(null); + + const { timezone } = useTimezone(); + + const [selectedDateTimeFor, setSelectedDateTimeFor] = useState<'to' | 'from'>( + 'from', + ); + + const [selectedFromDateTime, setSelectedFromDateTime] = useState( + dayjs(minTime / 1000_000).tz(timezone.value), + ); + + const [selectedToDateTime, setSelectedToDateTime] = useState( + dayjs(maxTime / 1000_000).tz(timezone.value), + ); + + const handleNext = (): void => { + if (selectedDateTimeFor === 'to') { + onCustomDateHandler([selectedFromDateTime, selectedToDateTime]); + + addCustomTimeRange([selectedFromDateTime, selectedToDateTime]); + + setIsOpen(false); + onSetCustomDTPickerVisible(false); + setSelectedDateTimeFor('from'); + } else { + setSelectedDateTimeFor('to'); + } + }; + + const handleDateChange = (date: Date | undefined): void => { + if (!date) { + return; + } + + if (selectedDateTimeFor === 'from') { + const prevFromDateTime = selectedFromDateTime; + + const newDate = dayjs(date); + + const updatedFromDateTime = prevFromDateTime + ? prevFromDateTime + .year(newDate.year()) + .month(newDate.month()) + .date(newDate.date()) + : dayjs(date).tz(timezone.value); + + setSelectedFromDateTime(updatedFromDateTime); + } else { + // eslint-disable-next-line sonarjs/no-identical-functions + setSelectedToDateTime((prev) => { + const newDate = dayjs(date); + + // Update only the date part, keeping time from existing state + return prev + ? prev.year(newDate.year()).month(newDate.month()).date(newDate.date()) + : dayjs(date).tz(timezone.value); + }); + } + + // focus the time input + timeInputRef?.current?.focus(); + }; + + const handleTimeChange = (time: string): void => { + // time should have format HH:mm:ss + if (!/^\d{2}:\d{2}:\d{2}$/.test(time)) { + return; + } + + if (selectedDateTimeFor === 'from') { + setSelectedFromDateTime((prev) => { + if (prev) { + return prev + .set('hour', parseInt(time.split(':')[0], 10)) + .set('minute', parseInt(time.split(':')[1], 10)) + .set('second', parseInt(time.split(':')[2], 10)); + } + + return prev; + }); + } + if (selectedDateTimeFor === 'to') { + // eslint-disable-next-line sonarjs/no-identical-functions + setSelectedToDateTime((prev) => { + if (prev) { + return prev + .set('hour', parseInt(time.split(':')[0], 10)) + .set('minute', parseInt(time.split(':')[1], 10)) + .set('second', parseInt(time.split(':')[2], 10)); + } + + return prev; + }); + } + }; + + const getDefaultMonth = (): Date => { + let defaultDate = null; + + if (selectedDateTimeFor === 'from') { + defaultDate = selectedFromDateTime?.toDate(); + } else if (selectedDateTimeFor === 'to') { + defaultDate = selectedToDateTime?.toDate(); + } + + return defaultDate ?? new Date(); + }; + + const isValidRange = (): boolean => { + if (selectedDateTimeFor === 'to') { + return selectedToDateTime?.isAfter(selectedFromDateTime) ?? false; + } + + return true; + }; + + const handleBack = (): void => { + setSelectedDateTimeFor('from'); + }; + + const handleHideCustomDTPicker = (): void => { + onSetCustomDTPickerVisible(false); + }; + + const handleSelectDateTimeFor = (selectedDateTimeFor: 'to' | 'from'): void => { + setSelectedDateTimeFor(selectedDateTimeFor); + }; + + return ( +
+
+
{ + if (e.key === 'Enter') { + handleHideCustomDTPicker(); + } + }} + > + + Back +
+ +
+
{ + if (e.key === 'Enter') { + handleSelectDateTimeFor('from'); + } + }} + className={cx( + 'date-time-custom-option-from', + selectedDateTimeFor === 'from' && 'active', + )} + onClick={(): void => { + handleSelectDateTimeFor('from'); + }} + > +
FROM
+
+ {selectedFromDateTime?.format('YYYY-MM-DD HH:mm:ss')} +
+
+
{ + if (e.key === 'Enter') { + handleSelectDateTimeFor('to'); + } + }} + className={cx( + 'date-time-custom-option-to', + selectedDateTimeFor === 'to' && 'active', + )} + onClick={(): void => { + handleSelectDateTimeFor('to'); + }} + > +
TO
+
+ {selectedToDateTime?.format('YYYY-MM-DD HH:mm:ss')} +
+
+
+
+
+ { + if (selectedDateTimeFor === 'to') { + // disable dates after today and before selectedFromDateTime + const currentDay = dayjs(current); + return currentDay.isAfter(dayjs()) || false; + } + + if (selectedDateTimeFor === 'from') { + // disable dates after selectedToDateTime + + return dayjs(current).isAfter(dayjs()) || false; + } + + return false; + }} + className="rounded-md border" + navLayout="after" + /> + +
+ + + + +
+ handleTimeChange(e.target.value)} + step="1" + /> +
+
+ +
+ {selectedDateTimeFor === 'to' && ( + + )} + + + +
+
+
+ ); +} + +export default DatePickerV2; diff --git a/frontend/src/components/DraggableTableRow/tests/DraggableTableRow.test.tsx b/frontend/src/components/DraggableTableRow/tests/DraggableTableRow.test.tsx index bfe099a0ed5..f33db568a3b 100644 --- a/frontend/src/components/DraggableTableRow/tests/DraggableTableRow.test.tsx +++ b/frontend/src/components/DraggableTableRow/tests/DraggableTableRow.test.tsx @@ -19,20 +19,6 @@ beforeAll(() => { }); }); -jest.mock('uplot', () => { - const paths = { - spline: jest.fn(), - bars: jest.fn(), - }; - const uplotMock = jest.fn(() => ({ - paths, - })); - return { - paths, - default: uplotMock, - }; -}); - jest.mock('react-dnd', () => ({ useDrop: jest.fn().mockImplementation(() => [jest.fn(), jest.fn(), jest.fn()]), useDrag: jest.fn().mockImplementation(() => [jest.fn(), jest.fn(), jest.fn()]), diff --git a/frontend/src/components/ErrorBoundaryHOC/README.md b/frontend/src/components/ErrorBoundaryHOC/README.md new file mode 100644 index 00000000000..4022cc96681 --- /dev/null +++ b/frontend/src/components/ErrorBoundaryHOC/README.md @@ -0,0 +1,117 @@ +# withErrorBoundary HOC + +A Higher-Order Component (HOC) that wraps React components with ErrorBoundary to provide error handling and recovery. + +## Features + +- **Automatic Error Catching**: Catches JavaScript errors in any component tree +- **Integration**: Automatically reports errors with context +- **Custom Fallback UI**: Supports custom error fallback components +- **Error Logging**: Optional custom error handlers for additional logging +- **TypeScript Support**: Fully typed with proper generics +- **Component Context**: Automatically adds component name to tags + +## Basic Usage + +```tsx +import { withErrorBoundary } from 'components/HOC'; + +// Wrap any component +const SafeComponent = withErrorBoundary(MyComponent); + +// Use it like any other component + +``` + +## Advanced Usage + +### Custom Fallback Component + +```tsx +const CustomFallback = () => ( +
+

Oops! Something went wrong

+ +
+); + +const SafeComponent = withErrorBoundary(MyComponent, { + fallback: +}); +``` + +### Custom Error Handler + +```tsx +const SafeComponent = withErrorBoundary(MyComponent, { + onError: (error, componentStack, eventId) => { + console.error('Component error:', error); + // Send to analytics, logging service, etc. + } +}); +``` + +### Sentry Configuration + +```tsx +const SafeComponent = withErrorBoundary(MyComponent, { + sentryOptions: { + tags: { + section: 'dashboard', + priority: 'high', + feature: 'metrics' + }, + level: 'error' + } +}); +``` + +## API Reference + +### `withErrorBoundary

(component, options?)` + +#### Parameters + +- `component: ComponentType

` - The React component to wrap +- `options?: WithErrorBoundaryOptions` - Configuration options + +#### Options + +```tsx +interface WithErrorBoundaryOptions { + /** Custom fallback component to render when an error occurs */ + fallback?: ReactElement; + + /** Custom error handler function */ + onError?: ( + error: unknown, + componentStack: string | undefined, + eventId: string + ) => void; + + /** Additional props to pass to the Sentry ErrorBoundary */ + sentryOptions?: { + tags?: Record; + level?: Sentry.SeverityLevel; + }; +} +``` + +## When to Use + +- **Critical Components**: Wrap important UI components that shouldn't crash the entire app +- **Third-party Integrations**: Wrap components that use external libraries +- **Data-heavy Components**: Wrap components that process complex data +- **Route Components**: Wrap page-level components to prevent navigation issues + +## Best Practices + +1. **Use Sparingly**: Don't wrap every component - focus on critical ones +2. **Meaningful Fallbacks**: Provide helpful fallback UI that guides users +3. **Log Errors**: Always implement error logging for debugging +4. **Component Names**: Ensure components have proper `displayName` for debugging +5. **Test Error Scenarios**: Test that your error boundaries work as expected + +## Examples + +See `withErrorBoundary.example.tsx` for complete usage examples. diff --git a/frontend/src/components/ErrorBoundaryHOC/__tests__/withErrorBoundary.test.tsx b/frontend/src/components/ErrorBoundaryHOC/__tests__/withErrorBoundary.test.tsx new file mode 100644 index 00000000000..3cec7083326 --- /dev/null +++ b/frontend/src/components/ErrorBoundaryHOC/__tests__/withErrorBoundary.test.tsx @@ -0,0 +1,211 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import withErrorBoundary, { + WithErrorBoundaryOptions, +} from '../withErrorBoundary'; + +// Mock dependencies before imports +jest.mock('@sentry/react', () => { + const ReactMock = jest.requireActual('react'); + + class MockErrorBoundary extends ReactMock.Component< + { + children: React.ReactNode; + fallback: React.ReactElement; + onError?: (error: Error, componentStack: string, eventId: string) => void; + beforeCapture?: (scope: { + setTag: (key: string, value: string) => void; + setLevel: (level: string) => void; + }) => void; + }, + { hasError: boolean } + > { + constructor(props: MockErrorBoundary['props']) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(): { hasError: boolean } { + return { hasError: true }; + } + + componentDidCatch(error: Error, errorInfo: { componentStack: string }): void { + const { beforeCapture, onError } = this.props; + if (beforeCapture) { + const mockScope = { + setTag: jest.fn(), + setLevel: jest.fn(), + }; + beforeCapture(mockScope); + } + if (onError) { + onError(error, errorInfo.componentStack, 'mock-event-id'); + } + } + + render(): React.ReactNode { + const { hasError } = this.state; + const { fallback, children } = this.props; + if (hasError) { + return

{fallback}
; + } + return
{children}
; + } + } + + return { + ErrorBoundary: MockErrorBoundary, + SeverityLevel: { + error: 'error', + warning: 'warning', + info: 'info', + }, + }; +}); + +jest.mock( + '../../../pages/ErrorBoundaryFallback/ErrorBoundaryFallback', + () => + function MockErrorBoundaryFallback(): JSX.Element { + return ( +
Default Error Fallback
+ ); + }, +); + +// Test component that can throw errors +interface TestComponentProps { + shouldThrow?: boolean; + message?: string; +} + +function TestComponent({ + shouldThrow = false, + message = 'Test Component', +}: TestComponentProps): JSX.Element { + if (shouldThrow) { + throw new Error('Test error'); + } + return
{message}
; +} + +TestComponent.defaultProps = { + shouldThrow: false, + message: 'Test Component', +}; + +// Test component with display name +function NamedComponent(): JSX.Element { + return
Named Component
; +} +NamedComponent.displayName = 'NamedComponent'; + +describe('withErrorBoundary', () => { + // Suppress console errors for cleaner test output + const originalError = console.error; + beforeAll(() => { + console.error = jest.fn(); + }); + + afterAll(() => { + console.error = originalError; + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should wrap component with ErrorBoundary and render successfully', () => { + // Arrange + const SafeComponent = withErrorBoundary(TestComponent); + + // Act + render(); + + // Assert + expect(screen.getByTestId('app-error-boundary')).toBeInTheDocument(); + expect(screen.getByTestId('test-component')).toBeInTheDocument(); + expect(screen.getByText('Hello World')).toBeInTheDocument(); + }); + + it('should render fallback UI when component throws error', () => { + // Arrange + const SafeComponent = withErrorBoundary(TestComponent); + + // Act + render(); + + // Assert + expect(screen.getByTestId('error-boundary-fallback')).toBeInTheDocument(); + expect(screen.getByTestId('default-error-fallback')).toBeInTheDocument(); + }); + + it('should render custom fallback component when provided', () => { + // Arrange + const customFallback = ( +
Custom Error UI
+ ); + const options: WithErrorBoundaryOptions = { + fallback: customFallback, + }; + const SafeComponent = withErrorBoundary(TestComponent, options); + + // Act + render(); + + // Assert + expect(screen.getByTestId('error-boundary-fallback')).toBeInTheDocument(); + expect(screen.getByTestId('custom-fallback')).toBeInTheDocument(); + expect(screen.getByText('Custom Error UI')).toBeInTheDocument(); + }); + + it('should call custom error handler when error occurs', () => { + // Arrange + const mockErrorHandler = jest.fn(); + const options: WithErrorBoundaryOptions = { + onError: mockErrorHandler, + }; + const SafeComponent = withErrorBoundary(TestComponent, options); + + // Act + render(); + + // Assert + expect(mockErrorHandler).toHaveBeenCalledWith( + expect.any(Error), + expect.any(String), + 'mock-event-id', + ); + expect(mockErrorHandler).toHaveBeenCalledTimes(1); + }); + + it('should set correct display name for debugging', () => { + // Arrange & Act + const SafeTestComponent = withErrorBoundary(TestComponent); + const SafeNamedComponent = withErrorBoundary(NamedComponent); + + // Assert + expect(SafeTestComponent.displayName).toBe( + 'withErrorBoundary(TestComponent)', + ); + expect(SafeNamedComponent.displayName).toBe( + 'withErrorBoundary(NamedComponent)', + ); + }); + + it('should handle component without display name', () => { + // Arrange + function AnonymousComponent(): JSX.Element { + return
Anonymous
; + } + + // Act + const SafeAnonymousComponent = withErrorBoundary(AnonymousComponent); + + // Assert + expect(SafeAnonymousComponent.displayName).toBe( + 'withErrorBoundary(AnonymousComponent)', + ); + }); +}); diff --git a/frontend/src/components/ErrorBoundaryHOC/index.ts b/frontend/src/components/ErrorBoundaryHOC/index.ts new file mode 100644 index 00000000000..1e7e5a6ae10 --- /dev/null +++ b/frontend/src/components/ErrorBoundaryHOC/index.ts @@ -0,0 +1,2 @@ +export type { WithErrorBoundaryOptions } from './withErrorBoundary'; +export { default as withErrorBoundary } from './withErrorBoundary'; diff --git a/frontend/src/components/ErrorBoundaryHOC/withErrorBoundary.example.tsx b/frontend/src/components/ErrorBoundaryHOC/withErrorBoundary.example.tsx new file mode 100644 index 00000000000..ce0c83fa537 --- /dev/null +++ b/frontend/src/components/ErrorBoundaryHOC/withErrorBoundary.example.tsx @@ -0,0 +1,143 @@ +import { Button } from 'antd'; +import { useState } from 'react'; + +import { withErrorBoundary } from './index'; + +/** + * Example component that can throw errors + */ +function ProblematicComponent(): JSX.Element { + const [shouldThrow, setShouldThrow] = useState(false); + + if (shouldThrow) { + throw new Error('This is a test error from ProblematicComponent!'); + } + + return ( +
+

Problematic Component

+

This component can throw errors when the button is clicked.

+ +
+ ); +} + +/** + * Basic usage - wraps component with default error boundary + */ +export const SafeProblematicComponent = withErrorBoundary(ProblematicComponent); + +/** + * Usage with custom fallback component + */ +function CustomErrorFallback(): JSX.Element { + return ( +
+

Custom Error Fallback

+

Something went wrong in this specific component!

+ +
+ ); +} + +export const SafeProblematicComponentWithCustomFallback = withErrorBoundary( + ProblematicComponent, + { + fallback: , + }, +); + +/** + * Usage with custom error handler + */ +export const SafeProblematicComponentWithErrorHandler = withErrorBoundary( + ProblematicComponent, + { + onError: (error, errorInfo) => { + console.error('Custom error handler:', error); + console.error('Error info:', errorInfo); + // You could also send to analytics, logging service, etc. + }, + sentryOptions: { + tags: { + section: 'dashboard', + priority: 'high', + }, + level: 'error', + }, + }, +); + +/** + * Example of wrapping an existing component from the codebase + */ +function ExistingComponent({ + title, + data, +}: { + title: string; + data: any[]; +}): JSX.Element { + // This could be any existing component that might throw errors + return ( +
+

{title}

+
    + {data.map((item, index) => ( + // eslint-disable-next-line react/no-array-index-key +
  • {item.name}
  • + ))} +
+
+ ); +} + +export const SafeExistingComponent = withErrorBoundary(ExistingComponent, { + sentryOptions: { + tags: { + component: 'ExistingComponent', + feature: 'data-display', + }, + }, +}); + +/** + * Usage examples in a container component + */ +export function ErrorBoundaryExamples(): JSX.Element { + const sampleData = [ + { name: 'Item 1' }, + { name: 'Item 2' }, + { name: 'Item 3' }, + ]; + + return ( +
+

Error Boundary HOC Examples

+ +
+

1. Basic Usage

+ +
+ +
+

2. With Custom Fallback

+ +
+ +
+

3. With Custom Error Handler

+ +
+ +
+

4. Wrapped Existing Component

+ +
+
+ ); +} diff --git a/frontend/src/components/ErrorBoundaryHOC/withErrorBoundary.tsx b/frontend/src/components/ErrorBoundaryHOC/withErrorBoundary.tsx new file mode 100644 index 00000000000..62c55264150 --- /dev/null +++ b/frontend/src/components/ErrorBoundaryHOC/withErrorBoundary.tsx @@ -0,0 +1,99 @@ +import * as Sentry from '@sentry/react'; +import { ComponentType, ReactElement } from 'react'; + +import ErrorBoundaryFallback from '../../pages/ErrorBoundaryFallback/ErrorBoundaryFallback'; + +/** + * Configuration options for the ErrorBoundary HOC + */ +interface WithErrorBoundaryOptions { + /** Custom fallback component to render when an error occurs */ + fallback?: ReactElement; + /** Custom error handler function */ + onError?: ( + error: unknown, + componentStack: string | undefined, + eventId: string, + ) => void; + /** Additional props to pass to the ErrorBoundary */ + sentryOptions?: { + tags?: Record; + level?: Sentry.SeverityLevel; + }; +} + +/** + * Higher-Order Component that wraps a component with ErrorBoundary + * + * @param WrappedComponent - The component to wrap with error boundary + * @param options - Configuration options for the error boundary + * + * @example + * // Basic usage + * const SafeComponent = withErrorBoundary(MyComponent); + * + * @example + * // With custom fallback + * const SafeComponent = withErrorBoundary(MyComponent, { + * fallback:
Something went wrong!
+ * }); + * + * @example + * // With custom error handler + * const SafeComponent = withErrorBoundary(MyComponent, { + * onError: (error, errorInfo) => { + * console.error('Component error:', error, errorInfo); + * } + * }); + */ +function withErrorBoundary

>( + WrappedComponent: ComponentType

, + options: WithErrorBoundaryOptions = {}, +): ComponentType

{ + const { + fallback = , + onError, + sentryOptions = {}, + } = options; + + function WithErrorBoundaryComponent(props: P): JSX.Element { + return ( + { + // Add component name to context + scope.setTag( + 'component', + WrappedComponent.displayName || WrappedComponent.name || 'Unknown', + ); + + // Add any custom tags + if (sentryOptions.tags) { + Object.entries(sentryOptions.tags).forEach(([key, value]) => { + scope.setTag(key, value); + }); + } + + // Set severity level if provided + if (sentryOptions.level) { + scope.setLevel(sentryOptions.level); + } + }} + onError={onError} + > + {/* eslint-disable-next-line react/jsx-props-no-spreading */} + + + ); + } + + // Set display name for debugging purposes + WithErrorBoundaryComponent.displayName = `withErrorBoundary(${ + WrappedComponent.displayName || WrappedComponent.name || 'Component' + })`; + + return WithErrorBoundaryComponent; +} + +export default withErrorBoundary; +export type { WithErrorBoundaryOptions }; diff --git a/frontend/src/components/ErrorInPlace/ErrorInPlace.tsx b/frontend/src/components/ErrorInPlace/ErrorInPlace.tsx new file mode 100644 index 00000000000..7fae206d049 --- /dev/null +++ b/frontend/src/components/ErrorInPlace/ErrorInPlace.tsx @@ -0,0 +1,79 @@ +import ErrorContent from 'components/ErrorModal/components/ErrorContent'; +import { ReactNode } from 'react'; +import APIError from 'types/api/error'; + +interface ErrorInPlaceProps { + /** The error object to display */ + error: APIError; + /** Custom class name */ + className?: string; + /** Custom style */ + style?: React.CSSProperties; + /** Whether to show a border */ + bordered?: boolean; + /** Background color */ + background?: string; + /** Padding */ + padding?: string | number; + /** Height - defaults to 100% to take available space */ + height?: string | number; + /** Width - defaults to 100% to take available space */ + width?: string | number; + /** Custom content instead of ErrorContent */ + children?: ReactNode; +} + +/** + * ErrorInPlace - A component that renders error content directly in the available space + * of its parent container. Perfect for displaying errors in widgets, cards, or any + * container where you want the error to take up the full available space. + * + * @example + * + * + * @example + * + */ +function ErrorInPlace({ + error, + className = '', + style, + bordered = false, + background, + padding = 16, + height = '100%', + width = '100%', + children, +}: ErrorInPlaceProps): JSX.Element { + const containerStyle: React.CSSProperties = { + display: 'flex', + flexDirection: 'column', + width, + height, + padding: typeof padding === 'number' ? `${padding}px` : padding, + backgroundColor: background, + border: bordered ? '1px solid var(--bg-slate-400, #374151)' : 'none', + borderRadius: bordered ? '4px' : '0', + overflow: 'auto', + ...style, + }; + + return ( +

+ {children || } +
+ ); +} + +ErrorInPlace.defaultProps = { + className: undefined, + style: undefined, + bordered: undefined, + background: undefined, + padding: undefined, + height: undefined, + width: undefined, + children: undefined, +}; + +export default ErrorInPlace; diff --git a/frontend/src/components/ErrorModal/ErrorModal.styles.scss b/frontend/src/components/ErrorModal/ErrorModal.styles.scss new file mode 100644 index 00000000000..87c2ea6edd5 --- /dev/null +++ b/frontend/src/components/ErrorModal/ErrorModal.styles.scss @@ -0,0 +1,118 @@ +.error-modal { + &__trigger { + width: fit-content; + display: flex; + align-items: center; + gap: 4px; + border-radius: 20px; + background: rgba(229, 72, 77, 0.2); + padding-left: 3px; + padding-right: 8px; + cursor: pointer; + span { + color: var(--bg-cherry-500); + font-size: 10px; + font-weight: 500; + line-height: 20px; /* 200% */ + letter-spacing: 0.4px; + text-transform: uppercase; + } + } + &__wrap { + background: linear-gradient( + 180deg, + rgba(11, 12, 14, 0.12) 0.07%, + rgba(39, 8, 14, 0.24) 50.04%, + rgba(106, 29, 44, 0.36) 75.02%, + rgba(197, 57, 85, 0.48) 87.51%, + rgba(242, 71, 105, 0.6) 100% + ); + -webkit-backdrop-filter: blur(10px); + backdrop-filter: blur(10px); + + .ant-modal { + bottom: 40px; + top: unset; + position: absolute; + width: 520px; + left: 0px; + right: 0px; + margin: auto; + } + } + &__body { + padding: 0; + background: var(--bg-ink-400); + overflow: hidden; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + } + &__header { + background: none !important; + .ant-modal-title { + display: flex; + justify-content: space-between; + align-items: center; + } + .key-value-label { + padding: 0; + border: none; + border-radius: 4px; + overflow: hidden; + &__key, + &__value { + padding: 4px 8px; + font-size: 12px; + font-style: normal; + font-weight: 500; + line-height: 16px; + letter-spacing: 0.48px; + } + &__key { + text-transform: uppercase; + &, + &:hover { + color: var(--bg-vanilla-100); + } + } + &__value { + color: var(--bg-vanilla-400); + pointer-events: none; + } + } + .close-button { + padding: 3px 7px; + background: var(--bg-ink-400); + display: inline-flex; + align-items: center; + border-radius: 4px; + border: 1px solid var(--bg-slate-500); + box-shadow: none; + } + } + &__footer { + margin: 0 !important; + height: 6px; + background: var(--bg-sakura-500); + } + &__content { + padding: 0 !important; + border-radius: 4px; + overflow: hidden; + background: none !important; + } +} + +.lightMode { + .error-modal { + &__body, + &__header .close-button { + background: var(--bg-vanilla-100); + } + &__header .close-button { + svg { + fill: var(--bg-vanilla-100); + } + } + } +} diff --git a/frontend/src/components/ErrorModal/ErrorModal.test.tsx b/frontend/src/components/ErrorModal/ErrorModal.test.tsx new file mode 100644 index 00000000000..cb39768c7d2 --- /dev/null +++ b/frontend/src/components/ErrorModal/ErrorModal.test.tsx @@ -0,0 +1,190 @@ +import { render, screen, userEvent, waitFor } from 'tests/test-utils'; +import APIError from 'types/api/error'; + +import ErrorModal from './ErrorModal'; + +// Mock the query client to return version data +const mockVersionData = { + payload: { + ee: 'Y', + version: '1.0.0', + }, +}; +jest.mock('react-query', () => ({ + ...jest.requireActual('react-query'), + useQueryClient: (): { getQueryData: () => typeof mockVersionData } => ({ + getQueryData: jest.fn(() => mockVersionData), + }), +})); +const mockError: APIError = new APIError({ + httpStatusCode: 400, + error: { + // eslint-disable-next-line sonarjs/no-duplicate-string + message: 'Something went wrong while processing your request.', + // eslint-disable-next-line sonarjs/no-duplicate-string + code: 'An error occurred', + // eslint-disable-next-line sonarjs/no-duplicate-string + url: 'https://example.com/docs', + errors: [ + { message: 'First error detail' }, + { message: 'Second error detail' }, + { message: 'Third error detail' }, + ], + }, +}); +describe('ErrorModal Component', () => { + it('should render the modal when open is true', () => { + render(); + + // Check if the error message is displayed + expect(screen.getByText('An error occurred')).toBeInTheDocument(); + expect( + screen.getByText('Something went wrong while processing your request.'), + ).toBeInTheDocument(); + }); + + it('should not render the modal when open is false', () => { + render(); + + // Check that the modal content is not in the document + expect(screen.queryByText('An error occurred')).not.toBeInTheDocument(); + }); + + it('should call onClose when the close button is clicked', async () => { + const onCloseMock = jest.fn(); + render(); + + // Click the close button + const closeButton = screen.getByTestId('close-button'); + const user = userEvent.setup({ pointerEventsCheck: 0 }); + await user.click(closeButton); + + // Check if onClose was called + expect(onCloseMock).toHaveBeenCalledTimes(1); + }); + + it('should display version data if available', async () => { + render(); + + // Check if the version data is displayed + expect(screen.getByText('ENTERPRISE')).toBeInTheDocument(); + expect(screen.getByText('1.0.0')).toBeInTheDocument(); + }); + it('should render the messages count badge when there are multiple errors', () => { + render(); + + // Check if the messages count badge is displayed + expect(screen.getByText('MESSAGES')).toBeInTheDocument(); + + expect(screen.getByText('3')).toBeInTheDocument(); + + // Check if the individual error messages are displayed + expect(screen.getByText('First error detail')).toBeInTheDocument(); + expect(screen.getByText('Second error detail')).toBeInTheDocument(); + expect(screen.getByText('Third error detail')).toBeInTheDocument(); + }); + + it('should render the open docs button when URL is provided', async () => { + render(); + + // Check if the open docs button is displayed + const openDocsButton = screen.getByTestId('error-docs-button'); + + expect(openDocsButton).toBeInTheDocument(); + + expect(openDocsButton).toHaveAttribute('href', 'https://example.com/docs'); + + expect(openDocsButton).toHaveAttribute('target', '_blank'); + }); + + it('should not display scroll for more if there are less than 10 messages', () => { + render(); + + expect(screen.queryByText('Scroll for more')).not.toBeInTheDocument(); + }); + it('should display scroll for more if there are more than 10 messages', async () => { + const longError = new APIError({ + httpStatusCode: 400, + error: { + ...mockError.error, + code: 'An error occurred', + message: 'Something went wrong while processing your request.', + url: 'https://example.com/docs', + errors: Array.from({ length: 15 }, (_, i) => ({ + message: `Error detail ${i + 1}`, + })), + }, + }); + + render(); + + // Check if the scroll hint is displayed + expect(screen.getByText('Scroll for more')).toBeInTheDocument(); + }); +}); +it('should render the trigger component if provided', () => { + const mockTrigger = ; + render( + , + ); + + // Check if the trigger component is rendered + expect(screen.getByText('Open Error Modal')).toBeInTheDocument(); +}); + +it('should open the modal when the trigger component is clicked', async () => { + const mockTrigger = ; + render( + , + ); + + // Click the trigger component + const triggerButton = screen.getByText('Open Error Modal'); + const user = userEvent.setup({ pointerEventsCheck: 0 }); + await user.click(triggerButton); + + // Check if the modal is displayed + expect(screen.getByText('An error occurred')).toBeInTheDocument(); +}); + +it('should render the default trigger tag if no trigger component is provided', () => { + render(); + + // Check if the default trigger tag is rendered + expect(screen.getByText('error')).toBeInTheDocument(); +}); + +it('should close the modal when the onCancel event is triggered', async () => { + const onCloseMock = jest.fn(); + render(); + + // Click the trigger component + const triggerButton = screen.getByText('error'); + const user = userEvent.setup({ pointerEventsCheck: 0 }); + await user.click(triggerButton); + + await waitFor(() => { + expect(screen.getByText('An error occurred')).toBeInTheDocument(); + }); + + // Trigger the onCancel event + await user.click(screen.getByTestId('close-button')); + + // Check if the modal is closed + expect(onCloseMock).toHaveBeenCalledTimes(1); + + await waitFor(() => { + // check if the modal is not visible + const modal = document.getElementsByClassName('ant-modal'); + const style = window.getComputedStyle(modal[0]); + expect(style.display).toBe('none'); + }); +}); diff --git a/frontend/src/components/ErrorModal/ErrorModal.tsx b/frontend/src/components/ErrorModal/ErrorModal.tsx new file mode 100644 index 00000000000..3765345ba45 --- /dev/null +++ b/frontend/src/components/ErrorModal/ErrorModal.tsx @@ -0,0 +1,102 @@ +import './ErrorModal.styles.scss'; + +import { Color } from '@signozhq/design-tokens'; +import { Button, Modal, Tag } from 'antd'; +import { CircleAlert, X } from 'lucide-react'; +import KeyValueLabel from 'periscope/components/KeyValueLabel'; +import { useAppContext } from 'providers/App/App'; +import React from 'react'; +import APIError from 'types/api/error'; + +import ErrorContent from './components/ErrorContent'; + +type Props = { + error: APIError; + triggerComponent?: React.ReactElement; + onClose?: () => void; + open?: boolean; +}; + +const classNames = { + body: 'error-modal__body', + mask: 'error-modal__mask', + header: 'error-modal__header', + footer: 'error-modal__footer', + content: 'error-modal__content', +}; + +function ErrorModal({ + open, + error, + triggerComponent, + onClose, +}: Props): JSX.Element { + const [visible, setVisible] = React.useState(open); + + const handleClose = (): void => { + setVisible(false); + onClose?.(); + }; + + const { versionData } = useAppContext(); + + const versionDataPayload = versionData; + + return ( + <> + {!triggerComponent ? ( + } + color="error" + onClick={(): void => setVisible(true)} + > + error + + ) : ( + React.cloneElement(triggerComponent, { + onClick: () => setVisible(true), + }) + )} + + } + title={ + <> + {versionDataPayload ? ( + + ) : ( +
+ )} + + + } + onCancel={handleClose} + closeIcon={false} + classNames={classNames} + wrapClassName="error-modal__wrap" + > + + + + ); +} + +ErrorModal.defaultProps = { + onClose: undefined, + triggerComponent: null, + open: false, +}; + +export default ErrorModal; diff --git a/frontend/src/components/ErrorModal/components/ErrorContent.styles.scss b/frontend/src/components/ErrorModal/components/ErrorContent.styles.scss new file mode 100644 index 00000000000..a00f2111f3c --- /dev/null +++ b/frontend/src/components/ErrorModal/components/ErrorContent.styles.scss @@ -0,0 +1,208 @@ +.error-content { + display: flex; + flex-direction: column; + // === SECTION: Summary (Top) + &__summary-section { + display: flex; + flex-direction: column; + border-bottom: 1px solid var(--bg-slate-400); + } + + &__summary { + display: flex; + justify-content: space-between; + padding: 16px; + } + + &__summary-left { + display: flex; + align-items: baseline; + gap: 8px; + } + + &__summary-text { + display: flex; + flex-direction: column; + gap: 6px; + } + + &__error-code { + color: var(--bg-vanilla-100); + margin: 0; + font-size: 16px; + font-weight: 500; + line-height: 24px; /* 150% */ + letter-spacing: -0.08px; + } + + &__error-message { + margin: 0; + color: var(--bg-vanilla-400); + font-size: 14px; + font-weight: 400; + line-height: 20px; /* 142.857% */ + letter-spacing: -0.07px; + } + + &__docs-button { + display: flex; + align-items: center; + gap: 6px; + padding: 9px 12.5px; + color: var(--bg-vanilla-400); + font-size: 12px; + font-weight: 400; + line-height: 18px; /* 150% */ + letter-spacing: 0.12px; + border-radius: 2px; + border: 1px solid var(--bg-slate-400); + background: var(--bg-ink-300); + box-shadow: none; + } + + &__message-badge { + display: flex; + align-items: center; + gap: 12px; + padding: 0px 16px 16px; + + .key-value-label { + width: fit-content; + border-color: var(--bg-slate-400); + border-radius: 20px; + overflow: hidden; + &__key { + padding-left: 8px; + padding-right: 8px; + } + &__value { + padding-right: 10px; + color: var(--bg-vanilla-400); + font-size: 12px; + font-weight: 500; + line-height: 18px; /* 150% */ + letter-spacing: 0.48px; + pointer-events: none; + } + } + &-label { + display: flex; + align-items: center; + gap: 6px; + &-dot { + height: 6px; + width: 6px; + background: var(--bg-sakura-500); + border-radius: 50%; + } + &-text { + color: var(--bg-vanilla-100); + font-size: 10px; + font-weight: 500; + line-height: 18px; /* 180% */ + letter-spacing: 0.5px; + } + } + &-line { + flex: 1; + height: 8px; + background-image: radial-gradient(circle, #444c63 1px, transparent 2px); + background-size: 8px 11px; + background-position: top left; + padding: 6px; + } + } + + // === SECTION: Message List (Bottom) + + &__message-list-container { + position: relative; + } + + &__message-list { + margin: 0; + padding: 0; + list-style: none; + max-height: 275px; + } + + &__message-item { + position: relative; + margin-bottom: 4px; + color: var(--bg-vanilla-400); + font-family: Geist Mono; + font-size: 12px; + font-weight: 400; + line-height: 18px; + color: var(--bg-vanilla-400); + padding: 3px 12px; + padding-left: 26px; + } + + &__message-item::before { + font-family: unset; + content: ''; + position: absolute; + left: 12px; + top: 50%; + transform: translateY(-50%); + width: 2px; + height: 4px; + border-radius: 50px; + background: var(--bg-slate-400); + } + + &__scroll-hint { + position: absolute; + bottom: 10px; + left: 0px; + right: 0px; + margin: auto; + width: fit-content; + display: inline-flex; + padding: 4px 12px 4px 10px; + justify-content: center; + align-items: center; + gap: 3px; + background: var(--bg-slate-200); + border-radius: 20px; + box-shadow: 0px 103px 12px 0px rgba(0, 0, 0, 0.01), + 0px 66px 18px 0px rgba(0, 0, 0, 0.01), 0px 37px 22px 0px rgba(0, 0, 0, 0.03), + 0px 17px 17px 0px rgba(0, 0, 0, 0.04), 0px 4px 9px 0px rgba(0, 0, 0, 0.04); + } + + &__scroll-hint-text { + color: var(--bg-vanilla-100); + + font-size: 12px; + font-weight: 400; + line-height: 18px; + letter-spacing: -0.06px; + } +} + +.lightMode { + .error-content { + &__error-code { + color: var(--bg-ink-100); + } + &__error-message { + color: var(--bg-ink-400); + } + &__message-item { + color: var(--bg-ink-400); + } + &__message-badge { + &-label-text { + color: var(--bg-ink-400); + } + .key-value-label__value { + color: var(--bg-ink-400); + } + } + &__docs-button { + background: var(--bg-vanilla-100); + color: var(--bg-ink-100); + } + } +} diff --git a/frontend/src/components/ErrorModal/components/ErrorContent.tsx b/frontend/src/components/ErrorModal/components/ErrorContent.tsx new file mode 100644 index 00000000000..6b2f914617c --- /dev/null +++ b/frontend/src/components/ErrorModal/components/ErrorContent.tsx @@ -0,0 +1,98 @@ +import './ErrorContent.styles.scss'; + +import { Color } from '@signozhq/design-tokens'; +import { Button } from 'antd'; +import ErrorIcon from 'assets/Error'; +import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar'; +import { BookOpenText, ChevronsDown } from 'lucide-react'; +import KeyValueLabel from 'periscope/components/KeyValueLabel'; +import APIError from 'types/api/error'; + +interface ErrorContentProps { + error: APIError; +} + +function ErrorContent({ error }: ErrorContentProps): JSX.Element { + const { + url: errorUrl, + errors: errorMessages, + code: errorCode, + message: errorMessage, + } = error?.error?.error || {}; + return ( +
+ {/* Summary Header */} +
+
+
+
+ +
+ +
+

{errorCode}

+

{errorMessage}

+
+
+ + {errorUrl && ( +
+ +
+ )} +
+ + {errorMessages?.length > 0 && ( +
+ +
+
MESSAGES
+
+ } + badgeValue={errorMessages.length.toString()} + /> +
+
+ )} +
+ + {/* Detailed Messages */} +
+
+ +
    + {errorMessages?.map((error) => ( +
  • + {error.message} +
  • + ))} +
+
+ {errorMessages?.length > 10 && ( +
+ + Scroll for more +
+ )} +
+
+
+ ); +} + +export default ErrorContent; diff --git a/frontend/src/components/ErrorPopover/ErrorPopover.tsx b/frontend/src/components/ErrorPopover/ErrorPopover.tsx new file mode 100644 index 00000000000..0aa30901d3c --- /dev/null +++ b/frontend/src/components/ErrorPopover/ErrorPopover.tsx @@ -0,0 +1,33 @@ +/* eslint-disable react/jsx-props-no-spreading */ +import { Popover, PopoverProps } from 'antd'; +import { ReactNode } from 'react'; + +interface ErrorPopoverProps extends Omit { + /** Content to display in the popover */ + content: ReactNode; + /** Element that triggers the popover */ + children: ReactNode; +} + +/** + * ErrorPopover - A clean wrapper around Ant Design's Popover + * that provides a simple interface for displaying content in a popover. + * + * @example + * }> + * + * + */ +function ErrorPopover({ + content, + children, + ...popoverProps +}: ErrorPopoverProps): JSX.Element { + return ( + + {children} + + ); +} + +export default ErrorPopover; diff --git a/frontend/src/components/ExplorerCard/test/ExplorerCard.test.tsx b/frontend/src/components/ExplorerCard/test/ExplorerCard.test.tsx index dd41a0498b9..fa980cce9d3 100644 --- a/frontend/src/components/ExplorerCard/test/ExplorerCard.test.tsx +++ b/frontend/src/components/ExplorerCard/test/ExplorerCard.test.tsx @@ -1,5 +1,6 @@ import { render, screen } from '@testing-library/react'; import ROUTES from 'constants/routes'; +import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider'; import MockQueryClientProvider from 'providers/test/MockQueryClientProvider'; import { DataSource } from 'types/common/queryBuilder'; @@ -52,11 +53,32 @@ jest.mock('hooks/saveViews/useDeleteView', () => ({ })), })); +// Mock usePreferenceSync +jest.mock('providers/preferences/sync/usePreferenceSync', () => ({ + usePreferenceSync: (): any => ({ + preferences: { + columns: [], + formatting: { + maxLines: 2, + format: 'table', + fontSize: 'small', + version: 1, + }, + }, + loading: false, + error: null, + updateColumns: jest.fn(), + updateFormatting: jest.fn(), + }), +})); + describe('ExplorerCard', () => { it('renders a card with a title and a description', () => { render( - child + + child + , ); expect(screen.queryByText('Query Builder')).not.toBeInTheDocument(); @@ -65,7 +87,9 @@ describe('ExplorerCard', () => { it('renders a save view button', () => { render( - child + + child + , ); expect(screen.queryByText('Save view')).not.toBeInTheDocument(); diff --git a/frontend/src/components/ExplorerCard/utils.ts b/frontend/src/components/ExplorerCard/utils.ts index 1e681e42bd5..f9fb61d5993 100644 --- a/frontend/src/components/ExplorerCard/utils.ts +++ b/frontend/src/components/ExplorerCard/utils.ts @@ -6,6 +6,7 @@ import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder'; import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi'; import isEqual from 'lodash-es/isEqual'; import { Query } from 'types/api/queryBuilder/queryBuilderData'; +import { DataSource } from 'types/common/queryBuilder'; import { DeleteViewHandlerProps, @@ -42,13 +43,13 @@ export const omitIdFromQuery = (query: Query | null): any => ({ builder: { ...query?.builder, queryData: query?.builder.queryData.map((queryData) => { - const { id, ...rest } = queryData.aggregateAttribute; + const { id, ...rest } = queryData.aggregateAttribute || {}; const newAggregateAttribute = rest; const newGroupByAttributes = queryData.groupBy.map((groupByAttribute) => { const { id, ...rest } = groupByAttribute; return rest; }); - const newItems = queryData.filters.items.map((item) => { + const newItems = queryData.filters?.items?.map((item) => { const { id, ...newItem } = item; if (item.key) { const { id, ...rest } = item.key; @@ -106,7 +107,11 @@ export const isQueryUpdatedInView = ({ !isEqual( options?.selectColumns, extraData && JSON.parse(extraData)?.selectColumns, - ) + ) || + (stagedQuery?.builder?.queryData?.[0]?.dataSource === DataSource.LOGS && + (!isEqual(options?.format, extraData && JSON.parse(extraData)?.format) || + !isEqual(options?.maxLines, extraData && JSON.parse(extraData)?.maxLines) || + !isEqual(options?.fontSize, extraData && JSON.parse(extraData)?.fontSize))) ); }; diff --git a/frontend/src/components/Graph/index.tsx b/frontend/src/components/Graph/index.tsx index 38999ef6cf0..23e6d62d511 100644 --- a/frontend/src/components/Graph/index.tsx +++ b/frontend/src/components/Graph/index.tsx @@ -74,6 +74,7 @@ const formatMap = { 'MM/dd HH:mm': DATE_TIME_FORMATS.SLASH_SHORT, 'MM/DD': DATE_TIME_FORMATS.DATE_SHORT, 'YY-MM': DATE_TIME_FORMATS.YEAR_MONTH, + 'MMM d, yyyy, h:mm:ss aaaa': DATE_TIME_FORMATS.DASH_DATETIME, YY: DATE_TIME_FORMATS.YEAR_SHORT, }; @@ -93,6 +94,8 @@ const Graph = forwardRef( containerHeight, onDragSelect, dragSelectColor, + minTime, + maxTime, }, ref, // eslint-disable-next-line sonarjs/cognitive-complexity @@ -104,7 +107,7 @@ const Graph = forwardRef( const { timezone } = useTimezone(); const currentTheme = isDarkMode ? 'dark' : 'light'; - const xAxisTimeUnit = useXAxisTimeUnit(data); // Computes the relevant time unit for x axis by analyzing the time stamp data + const xAxisTimeUnit = useXAxisTimeUnit(data, minTime, maxTime); // Computes the relevant time unit for x axis based on data or provided time range const lineChartRef = useRef(); @@ -166,6 +169,8 @@ const Graph = forwardRef( onClickHandler, data, timezone, + minTime, + maxTime, ); const chartHasData = hasData(data); @@ -201,6 +206,8 @@ const Graph = forwardRef( onClickHandler, data, timezone, + minTime, + maxTime, name, type, ]); @@ -235,6 +242,8 @@ Graph.defaultProps = { containerHeight: '90%', onDragSelect: undefined, dragSelectColor: undefined, + minTime: undefined, + maxTime: undefined, }; Graph.displayName = 'Graph'; diff --git a/frontend/src/components/Graph/types.ts b/frontend/src/components/Graph/types.ts index 4dd1d5bde4c..139365671d7 100644 --- a/frontend/src/components/Graph/types.ts +++ b/frontend/src/components/Graph/types.ts @@ -59,6 +59,8 @@ export interface GraphProps { containerHeight?: string | number; onDragSelect?: (start: number, end: number) => void; dragSelectColor?: string; + minTime?: number; + maxTime?: number; ref?: ForwardedRef; } diff --git a/frontend/src/components/Graph/utils.ts b/frontend/src/components/Graph/utils.ts index 93a0d32f1fd..b21f205b720 100644 --- a/frontend/src/components/Graph/utils.ts +++ b/frontend/src/components/Graph/utils.ts @@ -53,6 +53,8 @@ export const getGraphOptions = ( onClickHandler: GraphOnClickHandler | undefined, data: ChartData, timezone: Timezone, + minTime?: number, + maxTime?: number, // eslint-disable-next-line sonarjs/cognitive-complexity ): CustomChartOptions => ({ animation: { @@ -62,33 +64,35 @@ export const getGraphOptions = ( maintainAspectRatio: false, interaction: { mode: 'index', - intersect: false, + intersect: true, }, plugins: { - annotation: staticLine + ...(staticLine ? { - annotations: [ - { - type: 'line', - yMin: staticLine.yMin, - yMax: staticLine.yMax, - borderColor: staticLine.borderColor, - borderWidth: staticLine.borderWidth, - label: { - content: staticLine.lineText, - enabled: true, - font: { - size: 10, + annotation: { + annotations: [ + { + type: 'line', + yMin: staticLine.yMin, + yMax: staticLine.yMax, + borderColor: staticLine.borderColor, + borderWidth: staticLine.borderWidth, + label: { + content: staticLine.lineText, + enabled: true, + font: { + size: 10, + }, + borderWidth: 0, + position: 'start', + backgroundColor: 'transparent', + color: staticLine.textColor, }, - borderWidth: 0, - position: 'start', - backgroundColor: 'transparent', - color: staticLine.textColor, }, - }, - ], + ], + }, } - : undefined, + : {}), title: { display: title !== undefined, text: title, @@ -169,6 +173,12 @@ export const getGraphOptions = ( }, type: 'time', ticks: { color: getAxisLabelColor(currentTheme) }, + ...(minTime && { + min: dayjs(minTime).tz(timezone.value).format(), + }), + ...(maxTime && { + max: dayjs(maxTime).tz(timezone.value).format(), + }), }, y: { stacked: isStacked, @@ -225,3 +235,9 @@ export const getGraphOptions = ( } }, }); + +declare module 'chart.js' { + interface TooltipPositionerMap { + custom: TooltipPositionerFunction; + } +} diff --git a/frontend/src/components/Graph/xAxisConfig.ts b/frontend/src/components/Graph/xAxisConfig.ts index 3fa0b00e086..01d750001f0 100644 --- a/frontend/src/components/Graph/xAxisConfig.ts +++ b/frontend/src/components/Graph/xAxisConfig.ts @@ -88,12 +88,16 @@ export const convertTimeRange = ( /** * Accepts Chart.js data's data-structure and returns the relevant time unit for the axis based on the range of the data. */ -export const useXAxisTimeUnit = (data: Chart['data']): IAxisTimeConfig => { +export const useXAxisTimeUnit = ( + data: Chart['data'], + minTime?: number, + maxTime?: number, +): IAxisTimeConfig => { // Local time is the time range inferred from the input chart data. let localTime: ITimeRange | null; try { - let minTime = Number.POSITIVE_INFINITY; - let maxTime = Number.NEGATIVE_INFINITY; + let minTimeLocal = Number.POSITIVE_INFINITY; + let maxTimeLocal = Number.NEGATIVE_INFINITY; data?.labels?.forEach((timeStamp: unknown): void => { const getTimeStamp = (time: Date | number): Date | number | string => { if (time instanceof Date) { @@ -104,13 +108,13 @@ export const useXAxisTimeUnit = (data: Chart['data']): IAxisTimeConfig => { }; const time = getTimeStamp(timeStamp as Date | number); - minTime = Math.min(parseInt(time.toString(), 10), minTime); - maxTime = Math.max(parseInt(time.toString(), 10), maxTime); + minTimeLocal = Math.min(parseInt(time.toString(), 10), minTimeLocal); + maxTimeLocal = Math.max(parseInt(time.toString(), 10), maxTimeLocal); }); localTime = { - minTime: minTime === Number.POSITIVE_INFINITY ? null : minTime, - maxTime: maxTime === Number.NEGATIVE_INFINITY ? null : maxTime, + minTime: minTimeLocal === Number.POSITIVE_INFINITY ? null : minTimeLocal, + maxTime: maxTimeLocal === Number.NEGATIVE_INFINITY ? null : maxTimeLocal, }; } catch (error) { localTime = null; @@ -122,19 +126,27 @@ export const useXAxisTimeUnit = (data: Chart['data']): IAxisTimeConfig => { (state) => state.globalTime, ); - // Use local time if valid else use the global time range - const { maxTime, minTime } = useMemo(() => { + // Use explicit minTime/maxTime if provided and valid, otherwise use local time if valid, else use global time range + const { maxTime: finalMaxTime, minTime: finalMinTime } = useMemo(() => { + // If both minTime and maxTime are explicitly provided and valid, use them + if (minTime !== undefined && maxTime !== undefined && minTime <= maxTime) { + return { minTime, maxTime }; + } + + // Otherwise, use local time if valid if (localTime && localTime.maxTime && localTime.minTime) { return { minTime: localTime.minTime, maxTime: localTime.maxTime, }; } + + // Fall back to global time range return { minTime: globalTime.minTime / 1e6, maxTime: globalTime.maxTime / 1e6, }; - }, [globalTime, localTime]); + }, [globalTime, localTime, minTime, maxTime]); - return convertTimeRange(minTime, maxTime); + return convertTimeRange(finalMinTime, finalMaxTime); }; diff --git a/frontend/src/components/HeaderRightSection/AnnouncementsModal.tsx b/frontend/src/components/HeaderRightSection/AnnouncementsModal.tsx new file mode 100644 index 00000000000..fdd054de110 --- /dev/null +++ b/frontend/src/components/HeaderRightSection/AnnouncementsModal.tsx @@ -0,0 +1,15 @@ +import { Typography } from 'antd'; + +function AnnouncementsModal(): JSX.Element { + return ( +
+
+ + Announcements + +
+
+ ); +} + +export default AnnouncementsModal; diff --git a/frontend/src/components/HeaderRightSection/FeedbackModal.tsx b/frontend/src/components/HeaderRightSection/FeedbackModal.tsx new file mode 100644 index 00000000000..f0de75f3447 --- /dev/null +++ b/frontend/src/components/HeaderRightSection/FeedbackModal.tsx @@ -0,0 +1,160 @@ +import { toast } from '@signozhq/sonner'; +import { Button, Input, Radio, RadioChangeEvent, Typography } from 'antd'; +import logEvent from 'api/common/logEvent'; +import { useGetTenantLicense } from 'hooks/useGetTenantLicense'; +import { handleContactSupport } from 'pages/Integrations/utils'; +import { useCallback, useEffect, useState } from 'react'; +import { useLocation } from 'react-router-dom'; + +function FeedbackModal({ onClose }: { onClose: () => void }): JSX.Element { + const [activeTab, setActiveTab] = useState('feedback'); + const [feedback, setFeedback] = useState(''); + const location = useLocation(); + const { isCloudUser: isCloudUserVal } = useGetTenantLicense(); + const [isLoading, setIsLoading] = useState(false); + + const handleSubmit = async (): Promise => { + setIsLoading(true); + + let entityName = 'Feedback'; + if (activeTab === 'reportBug') { + entityName = 'Bug report'; + } else if (activeTab === 'featureRequest') { + entityName = 'Feature request'; + } + + logEvent('Feedback: Submitted', { + data: feedback, + type: activeTab, + page: location.pathname, + }) + .then(() => { + onClose(); + + toast.success(`${entityName} submitted successfully`, { + position: 'top-right', + }); + }) + .catch(() => { + console.error(`Failed to submit ${entityName}`); + toast.error(`Failed to submit ${entityName}`, { + position: 'top-right', + }); + }) + .finally(() => { + setIsLoading(false); + }); + }; + + useEffect( + () => (): void => { + setFeedback(''); + setActiveTab('feedback'); + }, + [], + ); + + const items = [ + { + label: ( +
+
+ Feedback +
+ ), + key: 'feedback', + value: 'feedback', + }, + { + label: ( +
+
+ Report a bug +
+ ), + key: 'reportBug', + value: 'reportBug', + }, + { + label: ( +
+
+ Feature request +
+ ), + key: 'featureRequest', + value: 'featureRequest', + }, + ]; + + const handleFeedbackChange = ( + e: React.ChangeEvent, + ): void => { + setFeedback(e.target.value); + }; + + const handleContactSupportClick = useCallback((): void => { + handleContactSupport(isCloudUserVal); + }, [isCloudUserVal]); + + return ( +
+
+ setActiveTab(e.target.value)} + /> +
+
+
+ +
+
+ +
+ +
+ + Have a specific issue?{' '} + + Contact Support{' '} + + or{' '} + + Read our docs + + +
+
+
+ ); +} + +export default FeedbackModal; diff --git a/frontend/src/components/HeaderRightSection/HeaderRightSection.styles.scss b/frontend/src/components/HeaderRightSection/HeaderRightSection.styles.scss new file mode 100644 index 00000000000..910200e9886 --- /dev/null +++ b/frontend/src/components/HeaderRightSection/HeaderRightSection.styles.scss @@ -0,0 +1,253 @@ +.header-right-section-container { + display: flex; + align-items: center; + gap: 8px; +} + +.share-modal-content, +.feedback-modal-container { + display: flex; + flex-direction: column; + gap: 16px; + padding: 12px; + width: 460px; + + border-radius: 4px; + box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2); + backdrop-filter: blur(20px); + + .absolute-relative-time-toggler-container { + display: flex; + gap: 8px; + align-items: center; + justify-content: space-between; + + .absolute-relative-time-toggler-label { + color: var(--bg-vanilla-100); + font-size: 13px; + font-style: normal; + font-weight: 500; + line-height: 20px; /* 142.857% */ + letter-spacing: -0.07px; + } + } + + .absolute-relative-time-toggler { + display: flex; + gap: 4px; + align-items: center; + } + + .absolute-relative-time-error { + font-size: 12px; + color: var(--bg-amber-600); + } + + .share-link { + .url-share-container { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + + .url-share-container-header { + display: flex; + flex-direction: column; + gap: 4px; + + .url-share-title, + .url-share-sub-title { + color: var(--bg-vanilla-100); + font-size: 13px; + font-style: normal; + font-weight: 500; + line-height: 20px; /* 142.857% */ + letter-spacing: -0.07px; + } + + .url-share-sub-title { + font-size: 12px; + color: var(--bg-vanilla-300); + font-weight: 400; + line-height: 18px; + letter-spacing: -0.06px; + } + } + } + } +} + +.feedback-modal-container { + .feedback-modal-tabs { + width: 100%; + display: flex; + + .ant-radio-button-wrapper { + flex: 1; + margin: 0px !important; + + border: 1px solid var(--bg-slate-400); + + &:before { + display: none; + } + + .ant-radio-button-checked { + background-color: var(--bg-slate-400); + } + } + + .feedback-modal-tab-label { + display: flex; + align-items: center; + gap: 8px; + + .tab-icon { + width: 6px; + height: 6px; + } + + .feedback-tab { + background-color: var(--bg-sakura-500); + } + + .bug-tab { + background-color: var(--bg-amber-500); + } + + .feature-tab { + background-color: var(--bg-robin-500); + } + } + + .ant-tabs-nav-list { + .ant-tabs-tab { + padding: 6px 16px; + + border-radius: 2px; + background: var(--bg-ink-400); + box-shadow: 0 0 8px 0 rgba(0, 0, 0, 0.1); + border: 1px solid var(--bg-slate-400); + + margin: 0 !important; + + .ant-tabs-tab-btn { + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: 20px; /* 166.667% */ + letter-spacing: -0.06px; + } + + &-active { + background: var(--bg-slate-400); + color: var(--bg-vanilla-100); + + border-bottom: none !important; + + .ant-tabs-tab-btn { + color: var(--bg-vanilla-100); + } + } + } + } + } + + .feedback-modal-content { + display: flex; + flex-direction: column; + gap: 16px; + + .feedback-input { + resize: none; + + text-area { + resize: none; + } + } + + .feedback-content-include-console-logs { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + } + } + + .feedback-modal-content-footer { + display: flex; + flex-direction: column; + gap: 16px; + + .feedback-modal-content-footer-info-text { + font-size: 12px; + color: var(--bg-vanilla-400, #c0c1c3); + text-align: center; + + /* button/ small */ + font-family: Inter; + font-size: 12px; + font-style: normal; + font-weight: 500; + line-height: 24px; /* 200% */ + + .contact-support-link, + .read-docs-link { + color: var(--bg-robin-400); + font-weight: 500; + font-size: 12px; + } + } + } +} + +.lightMode { + .share-modal-content, + .feedback-modal-container { + .absolute-relative-time-toggler-container { + .absolute-relative-time-toggler-label { + color: var(--bg-ink-400); + } + } + + .share-link { + .url-share-container { + .url-share-container-header { + .url-share-title, + .url-share-sub-title { + color: var(--bg-ink-400); + } + + .url-share-sub-title { + color: var(--bg-ink-300); + } + } + } + } + } + + .feedback-modal-container { + .feedback-modal-tabs { + .ant-radio-button-wrapper { + flex: 1; + margin: 0px !important; + + border: 1px solid var(--bg-vanilla-300); + + &:before { + display: none; + } + + .ant-radio-button-checked { + background-color: var(--bg-vanilla-300); + } + } + } + + .feedback-modal-content-footer { + .feedback-modal-content-footer-info-text { + color: var(--bg-slate-400); + } + } + } +} diff --git a/frontend/src/components/HeaderRightSection/HeaderRightSection.tsx b/frontend/src/components/HeaderRightSection/HeaderRightSection.tsx new file mode 100644 index 00000000000..84607984344 --- /dev/null +++ b/frontend/src/components/HeaderRightSection/HeaderRightSection.tsx @@ -0,0 +1,142 @@ +import './HeaderRightSection.styles.scss'; + +import { Button, Popover } from 'antd'; +import logEvent from 'api/common/logEvent'; +import { useGetTenantLicense } from 'hooks/useGetTenantLicense'; +import { Globe, Inbox, SquarePen } from 'lucide-react'; +import { useCallback, useState } from 'react'; +import { useLocation } from 'react-router-dom'; + +import AnnouncementsModal from './AnnouncementsModal'; +import FeedbackModal from './FeedbackModal'; +import ShareURLModal from './ShareURLModal'; + +interface HeaderRightSectionProps { + enableAnnouncements: boolean; + enableShare: boolean; + enableFeedback: boolean; +} + +function HeaderRightSection({ + enableAnnouncements, + enableShare, + enableFeedback, +}: HeaderRightSectionProps): JSX.Element | null { + const location = useLocation(); + + const [openFeedbackModal, setOpenFeedbackModal] = useState(false); + const [openShareURLModal, setOpenShareURLModal] = useState(false); + const [openAnnouncementsModal, setOpenAnnouncementsModal] = useState(false); + + const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense(); + + const handleOpenFeedbackModal = useCallback((): void => { + logEvent('Feedback: Clicked', { + page: location.pathname, + }); + + setOpenFeedbackModal(true); + setOpenShareURLModal(false); + setOpenAnnouncementsModal(false); + }, [location.pathname]); + + const handleOpenShareURLModal = useCallback((): void => { + logEvent('Share: Clicked', { + page: location.pathname, + }); + + setOpenShareURLModal(true); + setOpenFeedbackModal(false); + setOpenAnnouncementsModal(false); + }, [location.pathname]); + + const handleCloseFeedbackModal = (): void => { + setOpenFeedbackModal(false); + }; + + const handleOpenFeedbackModalChange = (open: boolean): void => { + setOpenFeedbackModal(open); + }; + + const handleOpenAnnouncementsModalChange = (open: boolean): void => { + setOpenAnnouncementsModal(open); + }; + + const handleOpenShareURLModalChange = (open: boolean): void => { + setOpenShareURLModal(open); + }; + + const isLicenseEnabled = isEnterpriseSelfHostedUser || isCloudUser; + + return ( +
+ {enableFeedback && isLicenseEnabled && ( + } + destroyTooltipOnHide + arrow={false} + trigger="click" + open={openFeedbackModal} + onOpenChange={handleOpenFeedbackModalChange} + > + + + )} +
+ ); +} + +export default HeaderRightSection; diff --git a/frontend/src/components/HeaderRightSection/ShareURLModal.tsx b/frontend/src/components/HeaderRightSection/ShareURLModal.tsx new file mode 100644 index 00000000000..c15c0d3c6ed --- /dev/null +++ b/frontend/src/components/HeaderRightSection/ShareURLModal.tsx @@ -0,0 +1,171 @@ +import { Color } from '@signozhq/design-tokens'; +import { Button, Switch, Typography } from 'antd'; +import logEvent from 'api/common/logEvent'; +import { QueryParams } from 'constants/query'; +import ROUTES from 'constants/routes'; +import useUrlQuery from 'hooks/useUrlQuery'; +import GetMinMax from 'lib/getMinMax'; +import { Check, Info, Link2 } from 'lucide-react'; +import { useMemo, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { matchPath, useLocation } from 'react-router-dom'; +import { useCopyToClipboard } from 'react-use'; +import { AppState } from 'store/reducers'; +import { GlobalReducer } from 'types/reducer/globalTime'; + +const routesToBeSharedWithTime = [ + ROUTES.LOGS_EXPLORER, + ROUTES.TRACES_EXPLORER, + ROUTES.METRICS_EXPLORER_EXPLORER, + ROUTES.METER_EXPLORER, +]; + +function ShareURLModal(): JSX.Element { + const urlQuery = useUrlQuery(); + const location = useLocation(); + const { selectedTime } = useSelector( + (state) => state.globalTime, + ); + + const [enableAbsoluteTime, setEnableAbsoluteTime] = useState( + selectedTime !== 'custom', + ); + + const startTime = urlQuery.get(QueryParams.startTime); + const endTime = urlQuery.get(QueryParams.endTime); + const relativeTime = urlQuery.get(QueryParams.relativeTime); + + const [isURLCopied, setIsURLCopied] = useState(false); + const [, handleCopyToClipboard] = useCopyToClipboard(); + + const isValidateRelativeTime = useMemo( + () => + selectedTime !== 'custom' || + (startTime && endTime && selectedTime === 'custom'), + [startTime, endTime, selectedTime], + ); + + const shareURLWithTime = useMemo( + () => relativeTime || (startTime && endTime), + [relativeTime, startTime, endTime], + ); + + const isRouteToBeSharedWithTime = useMemo( + () => + routesToBeSharedWithTime.some((route) => + matchPath(location.pathname, { path: route, exact: true }), + ), + [location.pathname], + ); + + // eslint-disable-next-line sonarjs/cognitive-complexity + const processURL = (): string => { + let currentUrl = window.location.href; + const isCustomTime = !!(startTime && endTime && selectedTime === 'custom'); + + if (shareURLWithTime || isRouteToBeSharedWithTime) { + if (enableAbsoluteTime || isCustomTime) { + if (selectedTime === 'custom') { + if (startTime && endTime) { + urlQuery.set(QueryParams.startTime, startTime.toString()); + urlQuery.set(QueryParams.endTime, endTime.toString()); + } + } else { + const { minTime, maxTime } = GetMinMax(selectedTime); + + urlQuery.set(QueryParams.startTime, minTime.toString()); + urlQuery.set(QueryParams.endTime, maxTime.toString()); + } + + urlQuery.delete(QueryParams.relativeTime); + + currentUrl = `${window.location.origin}${ + location.pathname + }?${urlQuery.toString()}`; + } else { + urlQuery.delete(QueryParams.startTime); + urlQuery.delete(QueryParams.endTime); + + urlQuery.set(QueryParams.relativeTime, selectedTime); + currentUrl = `${window.location.origin}${ + location.pathname + }?${urlQuery.toString()}`; + } + } + + return currentUrl; + }; + + const handleCopyURL = (): void => { + const URL = processURL(); + + handleCopyToClipboard(URL); + setIsURLCopied(true); + + logEvent('Share: Copy link clicked', { + page: location.pathname, + URL, + }); + + setTimeout(() => { + setIsURLCopied(false); + }, 1000); + }; + + return ( +
+ {(shareURLWithTime || isRouteToBeSharedWithTime) && ( + <> +
+ + Enable absolute time + + +
+ {!isValidateRelativeTime && ( + + )} + { + setEnableAbsoluteTime((prev) => !prev); + }} + /> +
+
+ + {!isValidateRelativeTime && ( +
+ Please select / enter valid relative time to toggle. +
+ )} + + )} + +
+
+
+ + Share page link + + + Share the current page link with your team member + +
+ + +
+
+
+ ); +} + +export default ShareURLModal; diff --git a/frontend/src/components/HeaderRightSection/__tests__/AnnouncementsModal.test.tsx b/frontend/src/components/HeaderRightSection/__tests__/AnnouncementsModal.test.tsx new file mode 100644 index 00000000000..39d01def7d9 --- /dev/null +++ b/frontend/src/components/HeaderRightSection/__tests__/AnnouncementsModal.test.tsx @@ -0,0 +1,29 @@ +import { render, screen } from '@testing-library/react'; + +import AnnouncementsModal from '../AnnouncementsModal'; + +describe('AnnouncementsModal', () => { + it('should render announcements modal with title', () => { + render(); + + expect(screen.getByText('Announcements')).toBeInTheDocument(); + }); + + it('should have proper structure and classes', () => { + render(); + + const container = screen + .getByText('Announcements') + .closest('.announcements-modal-container'); + expect(container).toBeInTheDocument(); + + const headerContainer = screen + .getByText('Announcements') + .closest('.announcements-modal-container-header'); + expect(headerContainer).toBeInTheDocument(); + }); + + it('should render without any errors', () => { + expect(() => render()).not.toThrow(); + }); +}); diff --git a/frontend/src/components/HeaderRightSection/__tests__/FeedbackModal.test.tsx b/frontend/src/components/HeaderRightSection/__tests__/FeedbackModal.test.tsx new file mode 100644 index 00000000000..2b4be0ff463 --- /dev/null +++ b/frontend/src/components/HeaderRightSection/__tests__/FeedbackModal.test.tsx @@ -0,0 +1,274 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +// Mock dependencies before imports +import { toast } from '@signozhq/sonner'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import logEvent from 'api/common/logEvent'; +import { useGetTenantLicense } from 'hooks/useGetTenantLicense'; +import { handleContactSupport } from 'pages/Integrations/utils'; +import { useLocation } from 'react-router-dom'; + +import FeedbackModal from '../FeedbackModal'; + +jest.mock('api/common/logEvent', () => ({ + __esModule: true, + default: jest.fn(() => Promise.resolve()), +})); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useLocation: jest.fn(), +})); + +jest.mock('@signozhq/sonner', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +jest.mock('hooks/useGetTenantLicense', () => ({ + useGetTenantLicense: jest.fn(), +})); + +jest.mock('pages/Integrations/utils', () => ({ + handleContactSupport: jest.fn(), +})); + +const mockLogEvent = logEvent as jest.MockedFunction; +const mockUseLocation = useLocation as jest.Mock; +const mockUseGetTenantLicense = useGetTenantLicense as jest.Mock; +const mockHandleContactSupport = handleContactSupport as jest.Mock; +const mockToast = toast as jest.Mocked; + +const mockOnClose = jest.fn(); + +const mockLocation = { + pathname: '/test-path', +}; + +describe('FeedbackModal', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseLocation.mockReturnValue(mockLocation); + mockUseGetTenantLicense.mockReturnValue({ + isCloudUser: false, + }); + mockToast.success.mockClear(); + mockToast.error.mockClear(); + }); + + it('should render feedback modal with all tabs', () => { + render(); + + expect(screen.getByText('Feedback')).toBeInTheDocument(); + expect(screen.getByText('Report a bug')).toBeInTheDocument(); + expect(screen.getByText('Feature request')).toBeInTheDocument(); + expect( + screen.getByPlaceholderText('Write your feedback here...'), + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /submit/i })).toBeInTheDocument(); + }); + + it('should switch between tabs when clicked', async () => { + const user = userEvent.setup(); + render(); + + // Initially, feedback radio should be active + const feedbackRadio = screen.getByRole('radio', { name: 'Feedback' }); + expect(feedbackRadio).toBeChecked(); + + const bugTab = screen.getByText('Report a bug'); + await user.click(bugTab); + + // Bug radio should now be active + const bugRadio = screen.getByRole('radio', { name: 'Report a bug' }); + expect(bugRadio).toBeChecked(); + + const featureTab = screen.getByText('Feature request'); + await user.click(featureTab); + + // Feature radio should now be active + const featureRadio = screen.getByRole('radio', { name: 'Feature request' }); + expect(featureRadio).toBeChecked(); + }); + + it('should update feedback text when typing in textarea', async () => { + const user = userEvent.setup(); + render(); + + const textarea = screen.getByPlaceholderText('Write your feedback here...'); + const testFeedback = 'This is my feedback'; + + await user.type(textarea, testFeedback); + + expect(textarea).toHaveValue(testFeedback); + }); + + it('should submit feedback and log event when submit button is clicked', async () => { + const user = userEvent.setup(); + render(); + + const textarea = screen.getByPlaceholderText('Write your feedback here...'); + const submitButton = screen.getByRole('button', { name: /submit/i }); + const testFeedback = 'Test feedback content'; + + await user.type(textarea, testFeedback); + await user.click(submitButton); + + expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Submitted', { + data: testFeedback, + type: 'feedback', + page: mockLocation.pathname, + }); + expect(mockOnClose).toHaveBeenCalled(); + expect(mockToast.success).toHaveBeenCalledWith( + 'Feedback submitted successfully', + { + position: 'top-right', + }, + ); + }); + + it('should submit bug report with correct type', async () => { + const user = userEvent.setup(); + render(); + + // Switch to bug report tab + const bugTab = screen.getByText('Report a bug'); + await user.click(bugTab); + + // Verify bug report radio is now active + const bugRadio = screen.getByRole('radio', { name: 'Report a bug' }); + expect(bugRadio).toBeChecked(); + + const textarea = screen.getByPlaceholderText('Write your feedback here...'); + const submitButton = screen.getByRole('button', { name: /submit/i }); + const testFeedback = 'This is a bug report'; + + await user.type(textarea, testFeedback); + await user.click(submitButton); + + expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Submitted', { + data: testFeedback, + type: 'reportBug', + page: mockLocation.pathname, + }); + expect(mockOnClose).toHaveBeenCalled(); + expect(mockToast.success).toHaveBeenCalledWith( + 'Bug report submitted successfully', + { + position: 'top-right', + }, + ); + }); + + it('should submit feature request with correct type', async () => { + const user = userEvent.setup(); + render(); + + // Switch to feature request tab + const featureTab = screen.getByText('Feature request'); + await user.click(featureTab); + + // Verify feature request radio is now active + const featureRadio = screen.getByRole('radio', { name: 'Feature request' }); + expect(featureRadio).toBeChecked(); + + const textarea = screen.getByPlaceholderText('Write your feedback here...'); + const submitButton = screen.getByRole('button', { name: /submit/i }); + const testFeedback = 'This is a feature request'; + + await user.type(textarea, testFeedback); + await user.click(submitButton); + + expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Submitted', { + data: testFeedback, + type: 'featureRequest', + page: mockLocation.pathname, + }); + expect(mockOnClose).toHaveBeenCalled(); + expect(mockToast.success).toHaveBeenCalledWith( + 'Feature request submitted successfully', + { + position: 'top-right', + }, + ); + }); + + it('should call handleContactSupport when contact support link is clicked', async () => { + const user = userEvent.setup(); + const isCloudUser = true; + mockUseGetTenantLicense.mockReturnValue({ + isCloudUser, + }); + + render(); + + const contactSupportLink = screen.getByText('Contact Support'); + await user.click(contactSupportLink); + + expect(mockHandleContactSupport).toHaveBeenCalledWith(isCloudUser); + }); + + it('should handle non-cloud user for contact support', async () => { + const user = userEvent.setup(); + const isCloudUser = false; + mockUseGetTenantLicense.mockReturnValue({ + isCloudUser, + }); + + render(); + + const contactSupportLink = screen.getByText('Contact Support'); + await user.click(contactSupportLink); + + expect(mockHandleContactSupport).toHaveBeenCalledWith(isCloudUser); + }); + + it('should render docs link with correct attributes', () => { + render(); + + const docsLink = screen.getByText('Read our docs'); + expect(docsLink).toHaveAttribute( + 'href', + 'https://signoz.io/docs/introduction/', + ); + expect(docsLink).toHaveAttribute('target', '_blank'); + expect(docsLink).toHaveAttribute('rel', 'noreferrer'); + }); + + it('should reset form state when component unmounts', async () => { + const user = userEvent.setup(); + + // Render component + const { unmount } = render(); + + // Change the form state first + const textArea = screen.getByPlaceholderText('Write your feedback here...'); + await user.type(textArea, 'Some feedback text'); + + // Change the active tab + const bugTab = screen.getByText('Report a bug'); + await user.click(bugTab); + + // Verify state has changed + expect(textArea).toHaveValue('Some feedback text'); + + // Unmount the component - this should trigger cleanup + unmount(); + + // Re-render the component to verify state was reset + render(); + + // Verify form state is reset + const newTextArea = screen.getByPlaceholderText( + 'Write your feedback here...', + ); + expect(newTextArea).toHaveValue(''); // Should be empty + + // Verify active radio is reset to default (Feedback radio) + const feedbackRadio = screen.getByRole('radio', { name: 'Feedback' }); + expect(feedbackRadio).toBeChecked(); + }); +}); diff --git a/frontend/src/components/HeaderRightSection/__tests__/HeaderRightSection.test.tsx b/frontend/src/components/HeaderRightSection/__tests__/HeaderRightSection.test.tsx new file mode 100644 index 00000000000..d0f49af5df4 --- /dev/null +++ b/frontend/src/components/HeaderRightSection/__tests__/HeaderRightSection.test.tsx @@ -0,0 +1,285 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +/* eslint-disable sonarjs/no-duplicate-string */ +/* eslint-disable react/jsx-props-no-spreading */ +// Mock dependencies before imports +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import logEvent from 'api/common/logEvent'; +import { useGetTenantLicense } from 'hooks/useGetTenantLicense'; +import { useLocation } from 'react-router-dom'; + +import HeaderRightSection from '../HeaderRightSection'; + +jest.mock('api/common/logEvent', () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useLocation: jest.fn(), +})); + +jest.mock('../FeedbackModal', () => ({ + __esModule: true, + default: ({ onClose }: { onClose: () => void }): JSX.Element => ( +
+ +
+ ), +})); + +jest.mock('../ShareURLModal', () => ({ + __esModule: true, + default: (): JSX.Element => ( +
Share URL Modal
+ ), +})); + +jest.mock('../AnnouncementsModal', () => ({ + __esModule: true, + default: (): JSX.Element => ( +
Announcements Modal
+ ), +})); + +jest.mock('hooks/useGetTenantLicense', () => ({ + useGetTenantLicense: jest.fn(), +})); + +const mockLogEvent = logEvent as jest.Mock; +const mockUseLocation = useLocation as jest.Mock; +const mockUseGetTenantLicense = useGetTenantLicense as jest.Mock; + +const defaultProps = { + enableAnnouncements: true, + enableShare: true, + enableFeedback: true, +}; + +const mockLocation = { + pathname: '/test-path', +}; + +describe('HeaderRightSection', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseLocation.mockReturnValue(mockLocation); + // Default to licensed user (Enterprise or Cloud) + mockUseGetTenantLicense.mockReturnValue({ + isCloudUser: true, + isEnterpriseSelfHostedUser: false, + isCommunityUser: false, + isCommunityEnterpriseUser: false, + }); + }); + + it('should render all buttons when all features are enabled', () => { + render(); + + const buttons = screen.getAllByRole('button'); + expect(buttons).toHaveLength(3); + expect(screen.getByRole('button', { name: /share/i })).toBeInTheDocument(); + + // Check for feedback button by class + const feedbackButton = document.querySelector( + '.share-feedback-btn[class*="share-feedback-btn"]', + ); + expect(feedbackButton).toBeInTheDocument(); + + // Check for announcements button by finding the inbox icon + const inboxIcon = document.querySelector('.lucide-inbox'); + expect(inboxIcon).toBeInTheDocument(); + }); + + it('should render only enabled features', () => { + render( + , + ); + + const buttons = screen.getAllByRole('button'); + expect(buttons).toHaveLength(1); + expect( + screen.queryByRole('button', { name: /share/i }), + ).not.toBeInTheDocument(); + + // Check that inbox icon is not present + const inboxIcon = document.querySelector('.lucide-inbox'); + expect(inboxIcon).not.toBeInTheDocument(); + + // Check that feedback button is present + const squarePenIcon = document.querySelector('.lucide-square-pen'); + expect(squarePenIcon).toBeInTheDocument(); + }); + + it('should open feedback modal and log event when feedback button is clicked', async () => { + const user = userEvent.setup(); + render(); + + const feedbackButton = document + .querySelector('.lucide-square-pen') + ?.closest('button'); + expect(feedbackButton).toBeInTheDocument(); + + await user.click(feedbackButton!); + + expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Clicked', { + page: mockLocation.pathname, + }); + expect(screen.getByTestId('feedback-modal')).toBeInTheDocument(); + }); + + it('should open share modal and log event when share button is clicked', async () => { + const user = userEvent.setup(); + render(); + + const shareButton = screen.getByRole('button', { name: /share/i }); + await user.click(shareButton); + + expect(mockLogEvent).toHaveBeenCalledWith('Share: Clicked', { + page: mockLocation.pathname, + }); + expect(screen.getByTestId('share-modal')).toBeInTheDocument(); + }); + + it('should log event when announcements button is clicked', async () => { + const user = userEvent.setup(); + render(); + + const announcementsButton = document + .querySelector('.lucide-inbox') + ?.closest('button'); + expect(announcementsButton).toBeInTheDocument(); + + await user.click(announcementsButton!); + + expect(mockLogEvent).toHaveBeenCalledWith('Announcements: Clicked', { + page: mockLocation.pathname, + }); + }); + + it('should close feedback modal when onClose is called', async () => { + const user = userEvent.setup(); + render(); + + // Open feedback modal + const feedbackButton = document + .querySelector('.lucide-square-pen') + ?.closest('button'); + expect(feedbackButton).toBeInTheDocument(); + + await user.click(feedbackButton!); + expect(screen.getByTestId('feedback-modal')).toBeInTheDocument(); + + // Close feedback modal + const closeFeedbackButton = screen.getByText('Close Feedback'); + await user.click(closeFeedbackButton); + expect(screen.queryByTestId('feedback-modal')).not.toBeInTheDocument(); + }); + + it('should close other modals when opening feedback modal', async () => { + const user = userEvent.setup(); + render(); + + // Open share modal first + const shareButton = screen.getByRole('button', { name: /share/i }); + await user.click(shareButton); + expect(screen.getByTestId('share-modal')).toBeInTheDocument(); + + // Open feedback modal - should close share modal + const feedbackButton = document + .querySelector('.lucide-square-pen') + ?.closest('button'); + expect(feedbackButton).toBeInTheDocument(); + + await user.click(feedbackButton!); + expect(screen.getByTestId('feedback-modal')).toBeInTheDocument(); + expect(screen.queryByTestId('share-modal')).not.toBeInTheDocument(); + }); + + it('should show feedback button for Cloud users when feedback is enabled', () => { + mockUseGetTenantLicense.mockReturnValue({ + isCloudUser: true, + isEnterpriseSelfHostedUser: false, + isCommunityUser: false, + isCommunityEnterpriseUser: false, + }); + + render(); + + const feedbackButton = document.querySelector('.lucide-square-pen'); + expect(feedbackButton).toBeInTheDocument(); + }); + + it('should show feedback button for Enterprise self-hosted users when feedback is enabled', () => { + mockUseGetTenantLicense.mockReturnValue({ + isCloudUser: false, + isEnterpriseSelfHostedUser: true, + isCommunityUser: false, + isCommunityEnterpriseUser: false, + }); + + render(); + + const feedbackButton = document.querySelector('.lucide-square-pen'); + expect(feedbackButton).toBeInTheDocument(); + }); + + it('should hide feedback button for Community users even when feedback is enabled', () => { + mockUseGetTenantLicense.mockReturnValue({ + isCloudUser: false, + isEnterpriseSelfHostedUser: false, + isCommunityUser: true, + isCommunityEnterpriseUser: false, + }); + + render(); + + const feedbackButton = document.querySelector('.lucide-square-pen'); + expect(feedbackButton).not.toBeInTheDocument(); + }); + + it('should hide feedback button for Community Enterprise users even when feedback is enabled', () => { + mockUseGetTenantLicense.mockReturnValue({ + isCloudUser: false, + isEnterpriseSelfHostedUser: false, + isCommunityUser: false, + isCommunityEnterpriseUser: true, + }); + + render(); + + const feedbackButton = document.querySelector('.lucide-square-pen'); + expect(feedbackButton).not.toBeInTheDocument(); + }); + + it('should render correct number of buttons when feedback is hidden due to license', () => { + mockUseGetTenantLicense.mockReturnValue({ + isCloudUser: false, + isEnterpriseSelfHostedUser: false, + isCommunityUser: true, + isCommunityEnterpriseUser: false, + }); + + render(); + + // Should have 2 buttons (announcements + share) instead of 3 + const buttons = screen.getAllByRole('button'); + expect(buttons).toHaveLength(2); + + // Verify which buttons are present + expect(screen.getByRole('button', { name: /share/i })).toBeInTheDocument(); + const inboxIcon = document.querySelector('.lucide-inbox'); + expect(inboxIcon).toBeInTheDocument(); + + // Verify feedback button is not present + const feedbackIcon = document.querySelector('.lucide-square-pen'); + expect(feedbackIcon).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/HeaderRightSection/__tests__/ShareURLModal.test.tsx b/frontend/src/components/HeaderRightSection/__tests__/ShareURLModal.test.tsx new file mode 100644 index 00000000000..bf634f57daf --- /dev/null +++ b/frontend/src/components/HeaderRightSection/__tests__/ShareURLModal.test.tsx @@ -0,0 +1,289 @@ +// Mock dependencies before imports +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import logEvent from 'api/common/logEvent'; +import ROUTES from 'constants/routes'; +import useUrlQuery from 'hooks/useUrlQuery'; +import GetMinMax from 'lib/getMinMax'; +import { useSelector } from 'react-redux'; +import { matchPath, useLocation } from 'react-router-dom'; +import { useCopyToClipboard } from 'react-use'; + +import ShareURLModal from '../ShareURLModal'; + +jest.mock('api/common/logEvent', () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useLocation: jest.fn(), + matchPath: jest.fn(), +})); + +jest.mock('hooks/useUrlQuery', () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); + +jest.mock('lib/getMinMax', () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mock('react-use', () => ({ + ...jest.requireActual('react-use'), + useCopyToClipboard: jest.fn(), +})); + +// Mock window.location +const mockLocation = { + href: 'https://example.com/test-path?param=value', + origin: 'https://example.com', +}; +Object.defineProperty(window, 'location', { + value: mockLocation, + writable: true, +}); + +const mockLogEvent = logEvent as jest.Mock; +const mockUseLocation = useLocation as jest.Mock; +const mockUseUrlQuery = useUrlQuery as jest.Mock; +const mockUseSelector = useSelector as jest.Mock; +const mockGetMinMax = GetMinMax as jest.Mock; +const mockUseCopyToClipboard = useCopyToClipboard as jest.Mock; +const mockMatchPath = matchPath as jest.Mock; + +const mockUrlQuery = { + get: jest.fn(), + set: jest.fn(), + delete: jest.fn(), + toString: jest.fn(() => 'param=value'), +}; + +const mockHandleCopyToClipboard = jest.fn(); + +const TEST_PATH = '/test-path'; +const ENABLE_ABSOLUTE_TIME_TEXT = 'Enable absolute time'; + +describe('ShareURLModal', () => { + beforeEach(() => { + jest.clearAllMocks(); + + mockUseLocation.mockReturnValue({ + pathname: TEST_PATH, + }); + + mockUseUrlQuery.mockReturnValue(mockUrlQuery); + + mockUseSelector.mockReturnValue({ + selectedTime: '5min', + }); + + mockGetMinMax.mockReturnValue({ + minTime: 1000000, + maxTime: 2000000, + }); + + mockUseCopyToClipboard.mockReturnValue([null, mockHandleCopyToClipboard]); + + mockMatchPath.mockReturnValue(false); + + // Reset URL query mocks - all return null by default + mockUrlQuery.get.mockReturnValue(null); + + // Reset mock functions + mockUrlQuery.set.mockClear(); + mockUrlQuery.delete.mockClear(); + mockUrlQuery.toString.mockReturnValue('param=value'); + }); + + it('should render share modal with copy button', () => { + render(); + + expect(screen.getByText('Share page link')).toBeInTheDocument(); + expect( + screen.getByText('Share the current page link with your team member'), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /copy page link/i }), + ).toBeInTheDocument(); + }); + + it('should copy URL and log event when copy button is clicked', async () => { + const user = userEvent.setup(); + render(); + + const copyButton = screen.getByRole('button', { name: /copy page link/i }); + await user.click(copyButton); + + expect(mockHandleCopyToClipboard).toHaveBeenCalled(); + expect(mockLogEvent).toHaveBeenCalledWith('Share: Copy link clicked', { + page: TEST_PATH, + URL: expect.any(String), + }); + }); + + it('should show absolute time toggle when on time-enabled route', () => { + mockMatchPath.mockReturnValue(true); // Simulate being on a route that supports time + + render(); + + expect(screen.getByText(ENABLE_ABSOLUTE_TIME_TEXT)).toBeInTheDocument(); + expect(screen.getByRole('switch')).toBeInTheDocument(); + }); + + it('should show absolute time toggle when URL has time parameters', () => { + mockUrlQuery.get.mockImplementation((key: string) => + key === 'relativeTime' ? '5min' : null, + ); + + render(); + + expect(screen.getByText(ENABLE_ABSOLUTE_TIME_TEXT)).toBeInTheDocument(); + }); + + it('should toggle absolute time switch', async () => { + const user = userEvent.setup(); + mockMatchPath.mockReturnValue(true); + mockUseSelector.mockReturnValue({ + selectedTime: '5min', // Non-custom time should enable absolute time by default + }); + + render(); + + const toggleSwitch = screen.getByRole('switch'); + // Should be checked by default for non-custom time + expect(toggleSwitch).toBeChecked(); + + await user.click(toggleSwitch); + expect(toggleSwitch).not.toBeChecked(); + }); + + it('should disable toggle when relative time is invalid', () => { + mockUseSelector.mockReturnValue({ + selectedTime: 'custom', + }); + + // Invalid - missing start and end time for custom + mockUrlQuery.get.mockReturnValue(null); + + mockMatchPath.mockReturnValue(true); + + render(); + + expect( + screen.getByText('Please select / enter valid relative time to toggle.'), + ).toBeInTheDocument(); + expect(screen.getByRole('switch')).toBeDisabled(); + }); + + it('should process URL with absolute time for non-custom time', async () => { + const user = userEvent.setup(); + mockMatchPath.mockReturnValue(true); + mockUseSelector.mockReturnValue({ + selectedTime: '5min', + }); + + render(); + + // Absolute time should be enabled by default for non-custom time + // Click copy button directly + const copyButton = screen.getByRole('button', { name: /copy page link/i }); + await user.click(copyButton); + + expect(mockUrlQuery.set).toHaveBeenCalledWith('startTime', '1000000'); + expect(mockUrlQuery.set).toHaveBeenCalledWith('endTime', '2000000'); + expect(mockUrlQuery.delete).toHaveBeenCalledWith('relativeTime'); + }); + + it('should process URL with custom time parameters', async () => { + const user = userEvent.setup(); + mockMatchPath.mockReturnValue(true); + mockUseSelector.mockReturnValue({ + selectedTime: 'custom', + }); + + mockUrlQuery.get.mockImplementation((key: string) => { + switch (key) { + case 'startTime': + return '1500000'; + case 'endTime': + return '1600000'; + default: + return null; + } + }); + + render(); + + // Should be enabled by default for custom time + const copyButton = screen.getByRole('button', { name: /copy page link/i }); + await user.click(copyButton); + + expect(mockUrlQuery.set).toHaveBeenCalledWith('startTime', '1500000'); + expect(mockUrlQuery.set).toHaveBeenCalledWith('endTime', '1600000'); + }); + + it('should process URL with relative time when absolute time is disabled', async () => { + const user = userEvent.setup(); + mockMatchPath.mockReturnValue(true); + mockUseSelector.mockReturnValue({ + selectedTime: '5min', + }); + + render(); + + // Disable absolute time first (it's enabled by default for non-custom time) + const toggleSwitch = screen.getByRole('switch'); + await user.click(toggleSwitch); + + const copyButton = screen.getByRole('button', { name: /copy page link/i }); + await user.click(copyButton); + + expect(mockUrlQuery.delete).toHaveBeenCalledWith('startTime'); + expect(mockUrlQuery.delete).toHaveBeenCalledWith('endTime'); + expect(mockUrlQuery.set).toHaveBeenCalledWith('relativeTime', '5min'); + }); + + it('should handle routes that should be shared with time', async () => { + const user = userEvent.setup(); + mockUseLocation.mockReturnValue({ + pathname: ROUTES.LOGS_EXPLORER, + }); + + mockMatchPath.mockImplementation( + (pathname: string, options: any) => options.path === ROUTES.LOGS_EXPLORER, + ); + + render(); + + expect(screen.getByText(ENABLE_ABSOLUTE_TIME_TEXT)).toBeInTheDocument(); + expect(screen.getByRole('switch')).toBeChecked(); + + // on clicking copy page link, the copied url should have startTime and endTime + const copyButton = screen.getByRole('button', { name: /copy page link/i }); + + await user.click(copyButton); + + expect(mockUrlQuery.set).toHaveBeenCalledWith('startTime', '1000000'); + expect(mockUrlQuery.set).toHaveBeenCalledWith('endTime', '2000000'); + expect(mockUrlQuery.delete).toHaveBeenCalledWith('relativeTime'); + + // toggle the switch to share url with relative time + const toggleSwitch = screen.getByRole('switch'); + await user.click(toggleSwitch); + + await user.click(copyButton); + + expect(mockUrlQuery.delete).toHaveBeenCalledWith('startTime'); + expect(mockUrlQuery.delete).toHaveBeenCalledWith('endTime'); + expect(mockUrlQuery.set).toHaveBeenCalledWith('relativeTime', '5min'); + }); +}); diff --git a/frontend/src/components/HostMetricsDetail/HostMetricTraces/HostMetricTraces.tsx b/frontend/src/components/HostMetricsDetail/HostMetricTraces/HostMetricTraces.tsx index 0072c3dc26a..e8e1f6bd1e2 100644 --- a/frontend/src/components/HostMetricsDetail/HostMetricTraces/HostMetricTraces.tsx +++ b/frontend/src/components/HostMetricsDetail/HostMetricTraces/HostMetricTraces.tsx @@ -26,6 +26,7 @@ import { useQuery } from 'react-query'; import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData'; import { DataSource } from 'types/common/queryBuilder'; +import { VIEWS } from '../constants'; import { getHostTracesQueryPayload, selectedColumns } from './constants'; import { getListColumns } from './utils'; @@ -39,7 +40,10 @@ interface Props { interval: Time | CustomTimeType, dateTimeRange?: [number, number], ) => void; - handleChangeTracesFilters: (value: IBuilderQuery['filters']) => void; + handleChangeTracesFilters: ( + value: IBuilderQuery['filters'], + view: VIEWS, + ) => void; tracesFilters: IBuilderQuery['filters']; selectedInterval: Time; } @@ -70,14 +74,16 @@ function HostMetricTraces({ ...currentQuery.builder.queryData[0].aggregateAttribute, }, filters: { - items: [], + items: + tracesFilters?.items?.filter((item) => item.key?.key !== 'host.name') || + [], op: 'AND', }, }, ], }, }), - [currentQuery], + [currentQuery, tracesFilters?.items], ); const query = updatedCurrentQuery?.builder?.queryData[0] || null; @@ -134,7 +140,8 @@ function HostMetricTraces({ const isDataEmpty = !isLoading && !isFetching && !isError && traces.length === 0; - const hasAdditionalFilters = tracesFilters.items.length > 1; + const hasAdditionalFilters = + tracesFilters?.items && tracesFilters?.items?.length > 1; const totalCount = data?.payload?.data?.newResult?.data?.result?.[0]?.list?.length || 0; @@ -152,15 +159,17 @@ function HostMetricTraces({
{query && ( + handleChangeTracesFilters(value, VIEWS.TRACES) + } disableNavigationShortcuts /> )}
0 && (
=> ({ diff --git a/frontend/src/components/HostMetricsDetail/HostMetricTraces/constants.ts b/frontend/src/components/HostMetricsDetail/HostMetricTraces/constants.ts index 8e2a3d62388..474c87fc0d3 100644 --- a/frontend/src/components/HostMetricsDetail/HostMetricTraces/constants.ts +++ b/frontend/src/components/HostMetricsDetail/HostMetricTraces/constants.ts @@ -55,37 +55,31 @@ export const selectedColumns: BaseAutocompleteData[] = [ key: 'timestamp', dataType: DataTypes.String, type: 'tag', - isColumn: true, }, { key: 'serviceName', dataType: DataTypes.String, type: 'tag', - isColumn: true, }, { key: 'name', dataType: DataTypes.String, type: 'tag', - isColumn: true, }, { key: 'durationNano', dataType: DataTypes.Float64, type: 'tag', - isColumn: true, }, { key: 'httpMethod', dataType: DataTypes.String, type: 'tag', - isColumn: true, }, { key: 'responseStatusCode', dataType: DataTypes.String, type: 'tag', - isColumn: true, }, ]; @@ -108,9 +102,7 @@ export const getHostTracesQueryPayload = ( id: '------false', dataType: DataTypes.EMPTY, key: '', - isColumn: false, type: '', - isJSON: false, }, timeAggregation: 'rate', spaceAggregation: 'sum', @@ -133,6 +125,7 @@ export const getHostTracesQueryPayload = ( }, ], queryFormulas: [], + queryTraceOperator: [], }, id: '572f1d91-6ac0-46c0-b726-c21488b34434', queryType: EQueryType.QUERY_BUILDER, @@ -154,8 +147,6 @@ export const getHostTracesQueryPayload = ( key: 'serviceName', dataType: 'string', type: 'tag', - isColumn: true, - isJSON: false, id: 'serviceName--string--tag--true', isIndexed: false, }, @@ -163,8 +154,6 @@ export const getHostTracesQueryPayload = ( key: 'name', dataType: 'string', type: 'tag', - isColumn: true, - isJSON: false, id: 'name--string--tag--true', isIndexed: false, }, @@ -172,8 +161,6 @@ export const getHostTracesQueryPayload = ( key: 'durationNano', dataType: 'float64', type: 'tag', - isColumn: true, - isJSON: false, id: 'durationNano--float64--tag--true', isIndexed: false, }, @@ -181,8 +168,6 @@ export const getHostTracesQueryPayload = ( key: 'httpMethod', dataType: 'string', type: 'tag', - isColumn: true, - isJSON: false, id: 'httpMethod--string--tag--true', isIndexed: false, }, @@ -190,8 +175,6 @@ export const getHostTracesQueryPayload = ( key: 'responseStatusCode', dataType: 'string', type: 'tag', - isColumn: true, - isJSON: false, id: 'responseStatusCode--string--tag--true', isIndexed: false, }, diff --git a/frontend/src/components/HostMetricsDetail/HostMetricsDetail.styles.scss b/frontend/src/components/HostMetricsDetail/HostMetricsDetail.styles.scss index 511348c463c..219c0bd4645 100644 --- a/frontend/src/components/HostMetricsDetail/HostMetricsDetail.styles.scss +++ b/frontend/src/components/HostMetricsDetail/HostMetricsDetail.styles.scss @@ -169,6 +169,7 @@ box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1); } } + .ant-drawer-close { padding: 0px; } diff --git a/frontend/src/components/HostMetricsDetail/HostMetricsDetails.tsx b/frontend/src/components/HostMetricsDetail/HostMetricsDetails.tsx index 2af30b99439..a4dbb3e81a8 100644 --- a/frontend/src/components/HostMetricsDetail/HostMetricsDetails.tsx +++ b/frontend/src/components/HostMetricsDetail/HostMetricsDetails.tsx @@ -19,6 +19,8 @@ import { initialQueryState, } from 'constants/queryBuilder'; import ROUTES from 'constants/routes'; +import { getFiltersFromParams } from 'container/InfraMonitoringK8s/commonUtils'; +import { INFRA_MONITORING_K8S_PARAMS_KEYS } from 'container/InfraMonitoringK8s/constants'; import { CustomTimeType, Time, @@ -35,8 +37,9 @@ import { ScrollText, X, } from 'lucide-react'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useSelector } from 'react-redux'; +import { useSearchParams } from 'react-router-dom-v5-compat'; import { AppState } from 'store/reducers'; import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse'; import { @@ -67,6 +70,7 @@ function HostMetricsDetails({ AppState, GlobalReducer >((state) => state.globalTime); + const [searchParams, setSearchParams] = useSearchParams(); const startMs = useMemo(() => Math.floor(Number(minTime) / 1000000000), [ minTime, @@ -82,15 +86,31 @@ function HostMetricsDetails({ endTime: endMs, })); + const lastSelectedInterval = useRef
); } diff --git a/frontend/src/components/HostMetricsDetail/HostMetricsLogs/HostMetricsLogs.tsx b/frontend/src/components/HostMetricsDetail/HostMetricsLogs/HostMetricsLogs.tsx index bb5c17a506d..95e535dad77 100644 --- a/frontend/src/components/HostMetricsDetail/HostMetricsLogs/HostMetricsLogs.tsx +++ b/frontend/src/components/HostMetricsDetail/HostMetricsLogs/HostMetricsLogs.tsx @@ -8,19 +8,13 @@ import { DEFAULT_ENTITY_VERSION } from 'constants/app'; import LogsError from 'container/LogsError/LogsError'; import { LogsLoading } from 'container/LogsLoading/LogsLoading'; import { FontSize } from 'container/OptionsMenu/types'; -import { ORDERBY_FILTERS } from 'container/QueryBuilder/filters/OrderByFilter/config'; +import { useHandleLogsPagination } from 'hooks/infraMonitoring/useHandleLogsPagination'; import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults'; -import { isEqual } from 'lodash-es'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; import { useQuery } from 'react-query'; import { Virtuoso } from 'react-virtuoso'; import { ILog } from 'types/api/logs/log'; -import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse'; -import { - IBuilderQuery, - TagFilterItem, -} from 'types/api/queryBuilder/queryBuilderData'; -import { v4 } from 'uuid'; +import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData'; import { getHostLogsQueryPayload } from './constants'; import NoLogsContainer from './NoLogsContainer'; @@ -30,51 +24,30 @@ interface Props { startTime: number; endTime: number; }; - handleChangeLogFilters: (filters: IBuilderQuery['filters']) => void; filters: IBuilderQuery['filters']; } -function HostMetricsLogs({ - timeRange, - handleChangeLogFilters, - filters, -}: Props): JSX.Element { - const [logs, setLogs] = useState([]); - const [hasReachedEndOfLogs, setHasReachedEndOfLogs] = useState(false); - const [restFilters, setRestFilters] = useState([]); - const [resetLogsList, setResetLogsList] = useState(false); - - useEffect(() => { - const newRestFilters = filters.items.filter( - (item) => item.key?.key !== 'id' && item.key?.key !== 'host.name', - ); - - const areFiltersSame = isEqual(restFilters, newRestFilters); - - if (!areFiltersSame) { - setResetLogsList(true); - } - - setRestFilters(newRestFilters); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [filters]); - - const queryPayload = useMemo(() => { - const basePayload = getHostLogsQueryPayload( - timeRange.startTime, - timeRange.endTime, - filters, - ); - - basePayload.query.builder.queryData[0].pageSize = 100; - basePayload.query.builder.queryData[0].orderBy = [ - { columnName: 'timestamp', order: ORDERBY_FILTERS.DESC }, - ]; - - return basePayload; - }, [timeRange.startTime, timeRange.endTime, filters]); - - const [isPaginating, setIsPaginating] = useState(false); +function HostMetricsLogs({ timeRange, filters }: Props): JSX.Element { + const basePayload = getHostLogsQueryPayload( + timeRange.startTime, + timeRange.endTime, + filters, + ); + const { + logs, + hasReachedEndOfLogs, + isPaginating, + currentPage, + setIsPaginating, + handleNewData, + loadMoreLogs, + queryPayload, + } = useHandleLogsPagination({ + timeRange, + filters, + excludeFilterKeys: ['host.name'], + basePayload, + }); const { data, isLoading, isFetching, isError } = useQuery({ queryKey: [ @@ -82,6 +55,7 @@ function HostMetricsLogs({ timeRange.startTime, timeRange.endTime, filters, + currentPage, ], queryFn: () => GetMetricQueryRange(queryPayload, DEFAULT_ENTITY_VERSION), enabled: !!queryPayload, @@ -90,38 +64,17 @@ function HostMetricsLogs({ useEffect(() => { if (data?.payload?.data?.newResult?.data?.result) { - const currentData = data.payload.data.newResult.data.result; - - if (resetLogsList) { - const currentLogs: ILog[] = - currentData[0].list?.map((item) => ({ - ...item.data, - timestamp: item.timestamp, - })) || []; - - setLogs(currentLogs); - - setResetLogsList(false); - } - - if (currentData.length > 0 && currentData[0].list) { - const currentLogs: ILog[] = - currentData[0].list.map((item) => ({ - ...item.data, - timestamp: item.timestamp, - })) || []; - - setLogs((prev) => [...prev, ...currentLogs]); - } else { - setHasReachedEndOfLogs(true); - } + handleNewData(data.payload.data.newResult.data.result); } - }, [data, restFilters, isPaginating, resetLogsList]); + }, [data, handleNewData]); + + useEffect(() => { + setIsPaginating(false); + }, [data, setIsPaginating]); const getItemContent = useCallback( (_: number, logToRender: ILog): JSX.Element => ( { - if (!logs.length) return; - - setIsPaginating(true); - const lastLog = logs[logs.length - 1]; - - const newItems = [ - ...filters.items.filter((item) => item.key?.key !== 'id'), - { - id: v4(), - key: { - key: 'id', - type: '', - dataType: DataTypes.String, - isColumn: true, - }, - op: '<', - value: lastLog.id, - }, - ]; - - const newFilters = { - op: 'AND', - items: newItems, - } as IBuilderQuery['filters']; - - handleChangeLogFilters(newFilters); - }, [logs, filters, handleChangeLogFilters]); - - useEffect(() => { - setIsPaginating(false); - }, [data]); - const renderFooter = useCallback( (): JSX.Element | null => ( // eslint-disable-next-line react/jsx-no-useless-fragment diff --git a/frontend/src/components/HostMetricsDetail/HostMetricsLogs/constants.ts b/frontend/src/components/HostMetricsDetail/HostMetricsLogs/constants.ts index eec48b9c393..917526c0ca2 100644 --- a/frontend/src/components/HostMetricsDetail/HostMetricsLogs/constants.ts +++ b/frontend/src/components/HostMetricsDetail/HostMetricsLogs/constants.ts @@ -26,9 +26,7 @@ export const getHostLogsQueryPayload = ( id: '------false', dataType: DataTypes.String, key: '', - isColumn: false, type: '', - isJSON: false, }, timeAggregation: 'rate', spaceAggregation: 'sum', @@ -53,13 +51,11 @@ export const getHostLogsQueryPayload = ( }, ], queryFormulas: [], + queryTraceOperator: [], }, id: uuidv4(), queryType: EQueryType.QUERY_BUILDER, }, - params: { - lastLogLineTimestamp: null, - }, start, end, }); diff --git a/frontend/src/components/HostMetricsDetail/Metrics/Metrics.tsx b/frontend/src/components/HostMetricsDetail/Metrics/Metrics.tsx index a8ab1359a00..179dacb4a50 100644 --- a/frontend/src/components/HostMetricsDetail/Metrics/Metrics.tsx +++ b/frontend/src/components/HostMetricsDetail/Metrics/Metrics.tsx @@ -13,16 +13,21 @@ import { CustomTimeType, Time, } from 'container/TopNav/DateTimeSelectionV2/config'; +import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { useIsDarkMode } from 'hooks/useDarkMode'; import { useResizeObserver } from 'hooks/useDimensions'; +import { useMultiIntersectionObserver } from 'hooks/useMultiIntersectionObserver'; import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults'; import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions'; import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData'; -import { useMemo, useRef } from 'react'; -import { useQueries, UseQueryResult } from 'react-query'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { QueryFunctionContext, useQueries, UseQueryResult } from 'react-query'; import { SuccessResponse } from 'types/api'; import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange'; +import { FeatureKeys } from '../../../constants/features'; +import { useAppContext } from '../../../providers/App/App'; + interface MetricsTabProps { timeRange: { startTime: number; @@ -45,29 +50,89 @@ function Metrics({ handleTimeChange, isModalTimeSelection, }: MetricsTabProps): JSX.Element { + const { featureFlags } = useAppContext(); + const dotMetricsEnabled = + featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED) + ?.active || false; + + const { + visibilities, + setElement, + } = useMultiIntersectionObserver(hostWidgetInfo.length, { threshold: 0.1 }); + const queryPayloads = useMemo( - () => getHostQueryPayload(hostName, timeRange.startTime, timeRange.endTime), - [hostName, timeRange.startTime, timeRange.endTime], + () => + getHostQueryPayload( + hostName, + timeRange.startTime, + timeRange.endTime, + dotMetricsEnabled, + ), + [hostName, timeRange.startTime, timeRange.endTime, dotMetricsEnabled], ); const queries = useQueries( - queryPayloads.map((payload) => ({ + queryPayloads.map((payload, index) => ({ queryKey: ['host-metrics', payload, ENTITY_VERSION_V4, 'HOST'], - queryFn: (): Promise> => - GetMetricQueryRange(payload, ENTITY_VERSION_V4), - enabled: !!payload, + queryFn: ({ + signal, + }: QueryFunctionContext): Promise< + SuccessResponse + > => GetMetricQueryRange(payload, ENTITY_VERSION_V4, undefined, signal), + enabled: !!payload && visibilities[index], + keepPreviousData: true, })), ); const isDarkMode = useIsDarkMode(); const graphRef = useRef(null); const dimensions = useResizeObserver(graphRef); + const { currentQuery } = useQueryBuilder(); const chartData = useMemo( () => queries.map(({ data }) => getUPlotChartData(data?.payload)), [queries], ); + const [graphTimeIntervals, setGraphTimeIntervals] = useState< + { + start: number; + end: number; + }[] + >( + new Array(queries.length).fill({ + start: timeRange.startTime, + end: timeRange.endTime, + }), + ); + + useEffect(() => { + setGraphTimeIntervals( + new Array(queries.length).fill({ + start: timeRange.startTime, + end: timeRange.endTime, + }), + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [timeRange]); + + const onDragSelect = useCallback( + (start: number, end: number, graphIndex: number) => { + const startTimestamp = Math.trunc(start); + const endTimestamp = Math.trunc(end); + + setGraphTimeIntervals((prev) => { + const newIntervals = [...prev]; + newIntervals[graphIndex] = { + start: Math.floor(startTimestamp / 1000), + end: Math.floor(endTimestamp / 1000), + }; + return newIntervals; + }); + }, + [], + ); + const options = useMemo( () => queries.map(({ data }, idx) => @@ -78,18 +143,27 @@ function Metrics({ yAxisUnit: hostWidgetInfo[idx].yAxisUnit, softMax: null, softMin: null, - minTimeScale: timeRange.startTime, - maxTimeScale: timeRange.endTime, + minTimeScale: graphTimeIntervals[idx].start, + maxTimeScale: graphTimeIntervals[idx].end, + onDragSelect: (start, end) => onDragSelect(start, end, idx), + query: currentQuery, }), ), - [queries, isDarkMode, dimensions, timeRange.startTime, timeRange.endTime], + [ + queries, + isDarkMode, + dimensions, + graphTimeIntervals, + onDragSelect, + currentQuery, + ], ); const renderCardContent = ( query: UseQueryResult, unknown>, idx: number, ): JSX.Element => { - if (query.isLoading) { + if ((!query.data && query.isLoading) || !visibilities[idx]) { return ; } @@ -115,7 +189,7 @@ function Metrics({
{queries.map((query, idx) => ( - + {hostWidgetInfo[idx].title} {renderCardContent(query, idx)} diff --git a/frontend/src/components/HttpStatusBadge/HttpStatusBadge.tsx b/frontend/src/components/HttpStatusBadge/HttpStatusBadge.tsx new file mode 100644 index 00000000000..97a3eb08092 --- /dev/null +++ b/frontend/src/components/HttpStatusBadge/HttpStatusBadge.tsx @@ -0,0 +1,50 @@ +import { Badge } from '@signozhq/badge'; + +type BadgeColor = + | 'vanilla' + | 'robin' + | 'forest' + | 'amber' + | 'sienna' + | 'cherry' + | 'sakura' + | 'aqua'; + +interface HttpStatusBadgeProps { + statusCode: string | number; +} + +function getStatusCodeColor(statusCode: number): BadgeColor { + if (statusCode >= 200 && statusCode < 300) { + return 'forest'; // Success - green + } + if (statusCode >= 300 && statusCode < 400) { + return 'robin'; // Redirect - blue + } + if (statusCode >= 400 && statusCode < 500) { + return 'amber'; // Client error - amber + } + if (statusCode >= 500) { + return 'cherry'; // Server error - red + } + if (statusCode >= 100 && statusCode < 200) { + return 'vanilla'; // Informational - neutral + } + return 'robin'; // Default fallback +} + +function HttpStatusBadge({ + statusCode, +}: HttpStatusBadgeProps): JSX.Element | null { + const numericStatusCode = Number(statusCode); + + if (!numericStatusCode || numericStatusCode <= 0) { + return null; + } + + const color = getStatusCodeColor(numericStatusCode); + + return {statusCode}; +} + +export default HttpStatusBadge; diff --git a/frontend/src/components/InputWithLabel/InputWithLabel.styles.scss b/frontend/src/components/InputWithLabel/InputWithLabel.styles.scss new file mode 100644 index 00000000000..c459b54b2c3 --- /dev/null +++ b/frontend/src/components/InputWithLabel/InputWithLabel.styles.scss @@ -0,0 +1,101 @@ +.input-with-label { + display: flex; + flex-direction: row; + + border-radius: 2px 0px 0px 2px; + + .label { + color: var(--bg-vanilla-400); + font-size: 12px; + font-style: normal; + font-weight: 500; + line-height: 18px; /* 128.571% */ + letter-spacing: 0.56px; + + max-width: 150px; + min-width: 60px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + padding: 0px 8px; + + border-radius: 2px 0px 0px 2px; + border: 1px solid var(--bg-slate-400); + background: var(--bg-ink-300); + + display: flex; + justify-content: flex-start; + align-items: center; + font-weight: var(--font-weight-light); + } + + .input { + flex: 1; + min-width: 150px; + font-family: 'Space Mono', monospace !important; + + border-radius: 2px 0px 0px 2px; + border: 1px solid var(--bg-slate-400); + background: var(--bg-ink-300); + + border-right: none; + border-left: none; + border-top-right-radius: 0px; + border-bottom-right-radius: 0px; + border-top-left-radius: 0px; + border-bottom-left-radius: 0px; + } + + .close-btn { + border-radius: 0px 2px 2px 0px; + border: 1px solid var(--bg-slate-400); + background: var(--bg-ink-300); + height: 38px; + width: 38px; + } + + &.labelAfter { + .input { + border-radius: 0px 2px 2px 0px; + border: 1px solid var(--bg-slate-400); + background: var(--bg-ink-300); + border-top-right-radius: 0px; + border-bottom-right-radius: 0px; + } + + .label { + border-left: none; + border-top-left-radius: 0px; + border-bottom-left-radius: 0px; + } + } +} + +.lightMode { + .input-with-label { + .label { + color: var(--bg-ink-500) !important; + + border: 1px solid var(--bg-vanilla-300) !important; + background: var(--bg-vanilla-100) !important; + } + + .input { + border: 1px solid var(--bg-vanilla-300) !important; + background: var(--bg-vanilla-100) !important; + } + + .close-btn { + border: 1px solid var(--bg-vanilla-300) !important; + background: var(--bg-vanilla-100) !important; + } + + &.labelAfter { + .input { + border: 1px solid var(--bg-vanilla-300) !important; + background: var(--bg-vanilla-100) !important; + } + } + } +} diff --git a/frontend/src/components/InputWithLabel/InputWithLabel.tsx b/frontend/src/components/InputWithLabel/InputWithLabel.tsx new file mode 100644 index 00000000000..a95318fe9cd --- /dev/null +++ b/frontend/src/components/InputWithLabel/InputWithLabel.tsx @@ -0,0 +1,75 @@ +import './InputWithLabel.styles.scss'; + +import { Button, Input, Typography } from 'antd'; +import cx from 'classnames'; +import { X } from 'lucide-react'; +import { useState } from 'react'; + +function InputWithLabel({ + label, + initialValue, + placeholder, + type, + onClose, + labelAfter, + onChange, + className, + closeIcon, +}: { + label: string; + initialValue?: string | number | null; + placeholder: string; + type?: string; + onClose?: () => void; + labelAfter?: boolean; + onChange: (value: string) => void; + className?: string; + closeIcon?: React.ReactNode; +}): JSX.Element { + const [inputValue, setInputValue] = useState( + initialValue ? initialValue.toString() : '', + ); + + const handleChange = (e: React.ChangeEvent): void => { + setInputValue(e.target.value); + onChange?.(e.target.value); + }; + + return ( +
+ {!labelAfter && {label}} + + {labelAfter && {label}} + {onClose && ( +
+ ); +} + +InputWithLabel.defaultProps = { + type: 'text', + onClose: undefined, + labelAfter: false, + initialValue: undefined, + className: undefined, + closeIcon: undefined, +}; + +export default InputWithLabel; diff --git a/frontend/src/components/KBarCommandPalette/KBarCommandPalette.scss b/frontend/src/components/KBarCommandPalette/KBarCommandPalette.scss new file mode 100644 index 00000000000..a9ef3edce6e --- /dev/null +++ b/frontend/src/components/KBarCommandPalette/KBarCommandPalette.scss @@ -0,0 +1,152 @@ +.kbar-command-palette__positioner { + position: fixed; + inset: 0; + display: flex; + align-items: flex-start; + justify-content: center; + padding: 1rem; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(6px); + z-index: 50; +} + +.kbar-command-palette__animator { + width: 100%; + max-width: 600px; +} + +.kbar-command-palette__card { + background: var(--bg-ink-500); + color: var(--text-vanilla-100); + border-radius: 3px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15); + overflow: hidden; + display: flex; + flex-direction: column; +} + +.kbar-command-palette__search { + padding: 12px 16px; + font-size: 13px; + border: none; + border-bottom: 1px solid var(--border-ink-200); + color: var(--text-vanilla-100); + outline: none; + background-color: var(--bg-ink-500); +} + +.kbar-command-palette__section { + padding: 8px 16px 4px; + font-size: 12px; + font-weight: 600; + color: var(--text-robin-500); + font-family: 'Inter', sans-serif; + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.kbar-command-palette__item { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + font-size: 13px; + cursor: pointer; + transition: background 0.15s ease; +} + +.kbar-command-palette__item:hover, +.kbar-command-palette__item--active { + background: var(--bg-ink-400); +} + +.kbar-command-palette__icon { + flex-shrink: 0; + width: 18px; + height: 18px; + color: #444; +} + +.kbar-command-palette__shortcut { + margin-left: auto; + display: flex; + gap: 4px; +} + +.kbar-command-palette__key { + padding: 2px 6px; + font-size: 12px; + border-radius: 4px; + background: var(--bg-ink-300); + color: var(--text-vanilla-300); + text-transform: uppercase; + font-family: 'Space Mono', monospace; +} + +.kbar-command-palette__results-container { + div { + &::-webkit-scrollbar { + width: 0.3rem; + height: 0.3rem; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: var(--bg-slate-300); + } + + &::-webkit-scrollbar-thumb:hover { + background: var(--bg-slate-200); + } + } +} + +.lightMode { + .kbar-command-palette__positioner { + background: rgba(0, 0, 0, 0.5); + } + + .kbar-command-palette__card { + background: #fff; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15); + } + + .kbar-command-palette__search { + border-bottom: 1px solid #e5e5e5; + color: var(--text-ink-500); + background-color: var(--bg-vanilla-100); + } + + .kbar-command-palette__item { + color: var(--text-ink-500); + } + + .kbar-command-palette__item:hover, + .kbar-command-palette__item--active { + background: #f5f5f5; + } + + .kbar-command-palette__icon { + color: #444; + } + + .kbar-command-palette__key { + background: #eee; + color: #555; + } + + .kbar-command-palette__results-container { + div { + &::-webkit-scrollbar-thumb { + background: var(--bg-vanilla-300); + } + + &::-webkit-scrollbar-thumb:hover { + background: var(--bg-vanilla-300); + } + } + } +} diff --git a/frontend/src/components/KBarCommandPalette/KBarCommandPalette.tsx b/frontend/src/components/KBarCommandPalette/KBarCommandPalette.tsx new file mode 100644 index 00000000000..dc2a098eacd --- /dev/null +++ b/frontend/src/components/KBarCommandPalette/KBarCommandPalette.tsx @@ -0,0 +1,69 @@ +import './KBarCommandPalette.scss'; + +import { + KBarAnimator, + KBarPortal, + KBarPositioner, + KBarResults, + KBarSearch, + useMatches, +} from 'kbar'; + +function Results(): JSX.Element { + const { results } = useMatches(); + + const renderResults = ({ + item, + active, + }: { + item: any; + active: boolean; + }): JSX.Element => + typeof item === 'string' ? ( +
{item}
+ ) : ( +
+ {item.icon} + {item.name} + {item.shortcut?.length ? ( + + {item.shortcut.map((sc: string) => ( + + {sc} + + ))} + + ) : null} +
+ ); + + return ( +
+ +
+ ); +} + +function KBarCommandPalette(): JSX.Element { + return ( + + + +
+ + +
+
+
+
+ ); +} + +export default KBarCommandPalette; diff --git a/frontend/src/components/LaunchChatSupport/LaunchChatSupport.tsx b/frontend/src/components/LaunchChatSupport/LaunchChatSupport.tsx index e04004a2926..72a621329fd 100644 --- a/frontend/src/components/LaunchChatSupport/LaunchChatSupport.tsx +++ b/frontend/src/components/LaunchChatSupport/LaunchChatSupport.tsx @@ -1,10 +1,9 @@ import './LaunchChatSupport.styles.scss'; import { Button, Modal, Tooltip, Typography } from 'antd'; -import updateCreditCardApi from 'api/billing/checkout'; import logEvent from 'api/common/logEvent'; +import updateCreditCardApi from 'api/v1/checkout/create'; import cx from 'classnames'; -import { SOMETHING_WENT_WRONG } from 'constants/api'; import { FeatureKeys } from 'constants/features'; import { useGetTenantLicense } from 'hooks/useGetTenantLicense'; import { useNotifications } from 'hooks/useNotifications'; @@ -14,8 +13,9 @@ import { useAppContext } from 'providers/App/App'; import { useMemo, useState } from 'react'; import { useMutation } from 'react-query'; import { useLocation } from 'react-router-dom'; -import { ErrorResponse, SuccessResponse } from 'types/api'; +import { SuccessResponseV2 } from 'types/api'; import { CheckoutSuccessPayloadProps } from 'types/api/billing/checkout'; +import APIError from 'types/api/error'; export interface LaunchChatSupportProps { eventName: string; @@ -24,7 +24,7 @@ export interface LaunchChatSupportProps { buttonText?: string; className?: string; onHoverText?: string; - intercomMessageDisabled?: boolean; + chatMessageDisabled?: boolean; } // eslint-disable-next-line sonarjs/cognitive-complexity @@ -35,7 +35,7 @@ function LaunchChatSupport({ buttonText = '', className = '', onHoverText = '', - intercomMessageDisabled = false, + chatMessageDisabled = false, }: LaunchChatSupportProps): JSX.Element | null { const { isCloudUser: isCloudUserVal } = useGetTenantLicense(); const { notifications } = useNotifications(); @@ -111,27 +111,30 @@ function LaunchChatSupport({ setIsAddCreditCardModalOpen(true); } else { logEvent(eventName, attributes); - if (window.Intercom && !intercomMessageDisabled) { - window.Intercom('showNewMessage', defaultTo(message, '')); + if (window.pylon && !chatMessageDisabled) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + window.Pylon('showNewMessage', defaultTo(message, '')); } } }; const handleBillingOnSuccess = ( - data: ErrorResponse | SuccessResponse, + data: SuccessResponseV2, ): void => { - if (data?.payload?.redirectURL) { + if (data?.data?.redirectURL) { const newTab = document.createElement('a'); - newTab.href = data.payload.redirectURL; + newTab.href = data.data.redirectURL; newTab.target = '_blank'; newTab.rel = 'noopener noreferrer'; newTab.click(); } }; - const handleBillingOnError = (): void => { + const handleBillingOnError = (error: APIError): void => { notifications.error({ - message: SOMETHING_WENT_WRONG, + message: error.getErrorCode(), + description: error.getErrorMessage(), }); }; @@ -219,7 +222,7 @@ LaunchChatSupport.defaultProps = { buttonText: '', className: '', onHoverText: '', - intercomMessageDisabled: false, + chatMessageDisabled: false, }; export default LaunchChatSupport; diff --git a/frontend/src/components/LogDetail/LogDetail.interfaces.ts b/frontend/src/components/LogDetail/LogDetail.interfaces.ts index 2c56d58fd1d..9ee892a45fd 100644 --- a/frontend/src/components/LogDetail/LogDetail.interfaces.ts +++ b/frontend/src/components/LogDetail/LogDetail.interfaces.ts @@ -10,13 +10,13 @@ import { VIEWS } from './constants'; export type LogDetailProps = { log: ILog | null; selectedTab: VIEWS; - onGroupByAttribute?: ( - fieldKey: string, - isJSON?: boolean, - dataType?: DataTypes, - ) => Promise; + onGroupByAttribute?: (fieldKey: string, dataType?: DataTypes) => Promise; isListViewPanel?: boolean; listViewPanelSelectedFields?: IField[] | null; } & Pick & Partial> & Pick; + +export type LogDetailInnerProps = LogDetailProps & { + log: NonNullable; +}; diff --git a/frontend/src/components/LogDetail/LogDetails.styles.scss b/frontend/src/components/LogDetail/LogDetails.styles.scss index 5cd014b71b3..37902394f74 100644 --- a/frontend/src/components/LogDetail/LogDetails.styles.scss +++ b/frontend/src/components/LogDetail/LogDetails.styles.scss @@ -3,6 +3,25 @@ background: var(--bg-ink-400); box-shadow: -4px 10px 16px 2px rgba(0, 0, 0, 0.2); + .log-detail-drawer__title { + display: flex; + justify-content: space-between; + align-items: center; + + .log-detail-drawer__title-left { + display: flex; + align-items: center; + gap: 8px; + } + + .log-detail-drawer__title-right { + .ant-btn { + display: flex; + align-items: center; + } + } + } + .ant-drawer-header { padding: 8px 16px; border-bottom: none; @@ -43,6 +62,10 @@ box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1); } + &-query-container { + margin-bottom: 16px; + } + .log-detail-drawer__log { width: 100%; display: flex; diff --git a/frontend/src/components/LogDetail/index.tsx b/frontend/src/components/LogDetail/index.tsx index c216694c35b..23a50ca3d75 100644 --- a/frontend/src/components/LogDetail/index.tsx +++ b/frontend/src/components/LogDetail/index.tsx @@ -2,49 +2,56 @@ import './LogDetails.styles.scss'; import { Color, Spacing } from '@signozhq/design-tokens'; -import Convert from 'ansi-to-html'; import { Button, Divider, Drawer, Radio, Tooltip, Typography } from 'antd'; import { RadioChangeEvent } from 'antd/lib'; import cx from 'classnames'; import { LogType } from 'components/Logs/LogStateIndicator/LogStateIndicator'; +import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch'; +import { convertExpressionToFilters } from 'components/QueryBuilderV2/utils'; import { LOCALSTORAGE } from 'constants/localStorage'; +import { QueryParams } from 'constants/query'; +import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder'; +import ROUTES from 'constants/routes'; import ContextView from 'container/LogDetailedView/ContextView/ContextView'; import InfraMetrics from 'container/LogDetailedView/InfraMetrics/InfraMetrics'; import JSONView from 'container/LogDetailedView/JsonView'; import Overview from 'container/LogDetailedView/Overview'; import { aggregateAttributesResourcesToString, + getSanitizedLogBody, removeEscapeCharacters, - unescapeString, } from 'container/LogDetailedView/utils'; +import useInitialQuery from 'container/LogsExplorerContext/useInitialQuery'; import { useOptionsMenu } from 'container/OptionsMenu'; -import dompurify from 'dompurify'; +import { useCopyLogLink } from 'hooks/logs/useCopyLogLink'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { useIsDarkMode } from 'hooks/useDarkMode'; import { useNotifications } from 'hooks/useNotifications'; +import { useSafeNavigate } from 'hooks/useSafeNavigate'; +import createQueryParams from 'lib/createQueryParams'; +import { cloneDeep } from 'lodash-es'; import { BarChart2, Braces, + Compass, Copy, Filter, - HardHat, Table, TextSelect, X, } from 'lucide-react'; -import { useMemo, useState } from 'react'; -import { useCopyToClipboard } from 'react-use'; +import { useCallback, useMemo, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { useCopyToClipboard, useLocation } from 'react-use'; +import { AppState } from 'store/reducers'; import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData'; import { DataSource, StringOperators } from 'types/common/queryBuilder'; -import { FORBID_DOM_PURIFY_TAGS } from 'utils/app'; +import { GlobalReducer } from 'types/reducer/globalTime'; import { RESOURCE_KEYS, VIEW_TYPES, VIEWS } from './constants'; -import { LogDetailProps } from './LogDetail.interfaces'; -import QueryBuilderSearchWrapper from './QueryBuilderSearchWrapper'; +import { LogDetailInnerProps, LogDetailProps } from './LogDetail.interfaces'; -const convert = new Convert(); - -function LogDetail({ +function LogDetailInner({ log, onClose, onAddToQuery, @@ -53,16 +60,19 @@ function LogDetail({ selectedTab, isListViewPanel = false, listViewPanelSelectedFields, -}: LogDetailProps): JSX.Element { +}: LogDetailInnerProps): JSX.Element { + const initialContextQuery = useInitialQuery(log); + const [contextQuery, setContextQuery] = useState( + initialContextQuery, + ); const [, copyToClipboard] = useCopyToClipboard(); const [selectedView, setSelectedView] = useState(selectedTab); - const [isFilterVisibile, setIsFilterVisible] = useState(false); + const [isFilterVisible, setIsFilterVisible] = useState(false); - const [contextQuery, setContextQuery] = useState(); const [filters, setFilters] = useState(null); const [isEdit, setIsEdit] = useState(false); - const { initialDataSource, stagedQuery } = useQueryBuilder(); + const { stagedQuery, updateAllQueriesOperators } = useQueryBuilder(); const listQuery = useMemo(() => { if (!stagedQuery || stagedQuery.builder.queryData.length < 1) return null; @@ -72,14 +82,21 @@ function LogDetail({ const { options } = useOptionsMenu({ storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS, - dataSource: initialDataSource || DataSource.LOGS, + dataSource: DataSource.LOGS, aggregateOperator: listQuery?.aggregateOperator || StringOperators.NOOP, }); const isDarkMode = useIsDarkMode(); + const location = useLocation(); + const { safeNavigate } = useSafeNavigate(); + const { maxTime, minTime } = useSelector( + (state) => state.globalTime, + ); const { notifications } = useNotifications(); + const { onLogCopy } = useCopyLogLink(log?.id); + const LogJsonData = log ? aggregateAttributesResourcesToString(log) : ''; const handleModeChange = (e: RadioChangeEvent): void => { @@ -89,7 +106,7 @@ function LogDetail({ }; const handleFilterVisible = (): void => { - setIsFilterVisible(!isFilterVisibile); + setIsFilterVisible(!isFilterVisible); setIsEdit(!isEdit); }; @@ -103,11 +120,7 @@ function LogDetail({ const htmlBody = useMemo( () => ({ - __html: convert.toHtml( - dompurify.sanitize(unescapeString(log?.body || ''), { - FORBID_TAGS: [...FORBID_DOM_PURIFY_TAGS], - }), - ), + __html: getSanitizedLogBody(log?.body || '', { shouldEscapeHtml: true }), }), [log?.body], ); @@ -119,10 +132,95 @@ function LogDetail({ }); }; - if (!log) { - // eslint-disable-next-line react/jsx-no-useless-fragment - return <>; - } + // Go to logs explorer page with the log data + const handleOpenInExplorer = (): void => { + const queryParams = { + [QueryParams.activeLogId]: `"${log?.id}"`, + [QueryParams.startTime]: minTime?.toString() || '', + [QueryParams.endTime]: maxTime?.toString() || '', + [QueryParams.compositeQuery]: JSON.stringify( + updateAllQueriesOperators( + initialQueriesMap[DataSource.LOGS], + PANEL_TYPES.LIST, + DataSource.LOGS, + ), + ), + }; + safeNavigate(`${ROUTES.LOGS_EXPLORER}?${createQueryParams(queryParams)}`); + }; + + const handleQueryExpressionChange = useCallback( + (value: string, queryIndex: number) => { + // update the query at the given index + setContextQuery((prev) => { + if (!prev) return prev; + + return { + ...prev, + builder: { + ...prev.builder, + queryData: prev.builder.queryData.map((query, idx) => + idx === queryIndex + ? { + ...query, + filter: { + ...query.filter, + expression: value, + }, + } + : query, + ), + }, + }; + }); + }, + [], + ); + + const handleRunQuery = (expression: string): void => { + let updatedContextQuery = cloneDeep(contextQuery); + + if (!updatedContextQuery || !updatedContextQuery.builder) { + return; + } + + const newFilters: TagFilter = { + items: expression ? convertExpressionToFilters(expression) : [], + op: 'AND', + }; + + updatedContextQuery = { + ...updatedContextQuery, + builder: { + ...updatedContextQuery?.builder, + queryData: updatedContextQuery?.builder.queryData.map((queryData) => ({ + ...queryData, + filter: { + ...queryData.filter, + expression, + }, + filters: { + ...queryData.filters, + ...newFilters, + op: queryData.filters?.op ?? 'AND', + }, + })), + }, + }; + + setContextQuery(updatedContextQuery); + + if (newFilters) { + setFilters(newFilters); + } + }; + + // Only show when opened from infra monitoring page + const showOpenInExplorerBtn = useMemo( + () => location.pathname?.includes('/infrastructure-monitoring'), + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); const logType = log?.attributes_string?.log_level || LogType.INFO; @@ -131,10 +229,23 @@ function LogDetail({ width="60%" maskStyle={{ background: 'none' }} title={ - <> - - Log details - +
+
+ + Log details +
+ {showOpenInExplorerBtn && ( +
+ +
+ )} +
} placement="right" // closable @@ -225,19 +336,25 @@ function LogDetail({ onClick={handleFilterVisible} /> )} -
- - } - /> + +
+ {isFilterVisible && contextQuery?.builder.queryData[0] && ( +
+ handleQueryExpressionChange(value, 0)} + dataSource={DataSource.LOGS} + queryData={contextQuery?.builder.queryData[0]} + onRun={handleRunQuery} + /> +
+ )} {selectedView === VIEW_TYPES.OVERVIEW && ( ; + } + + // eslint-disable-next-line react/jsx-props-no-spreading + return ; +} + export default LogDetail; diff --git a/frontend/src/components/Logs/AddToQueryHOC.tsx b/frontend/src/components/Logs/AddToQueryHOC.tsx index d7e2c7156e7..c7ec382e801 100644 --- a/frontend/src/components/Logs/AddToQueryHOC.tsx +++ b/frontend/src/components/Logs/AddToQueryHOC.tsx @@ -5,17 +5,19 @@ import cx from 'classnames'; import { OPERATORS } from 'constants/queryBuilder'; import { FontSize } from 'container/OptionsMenu/types'; import { memo, MouseEvent, ReactNode, useMemo } from 'react'; +import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse'; function AddToQueryHOC({ fieldKey, fieldValue, onAddToQuery, fontSize, + dataType = DataTypes.EMPTY, children, }: AddToQueryHOCProps): JSX.Element { const handleQueryAdd = (event: MouseEvent): void => { event.stopPropagation(); - onAddToQuery(fieldKey, fieldValue, OPERATORS['=']); + onAddToQuery(fieldKey, fieldValue, OPERATORS['='], dataType); }; const popOverContent = useMemo(() => Add to query: {fieldKey}, [ @@ -35,9 +37,19 @@ function AddToQueryHOC({ export interface AddToQueryHOCProps { fieldKey: string; fieldValue: string; - onAddToQuery: (fieldKey: string, fieldValue: string, operator: string) => void; + onAddToQuery: ( + fieldKey: string, + fieldValue: string, + operator: string, + dataType?: DataTypes, + ) => void; fontSize: FontSize; + dataType?: DataTypes; children: ReactNode; } +AddToQueryHOC.defaultProps = { + dataType: DataTypes.EMPTY, +}; + export default memo(AddToQueryHOC); diff --git a/frontend/src/components/Logs/ListLogView/index.tsx b/frontend/src/components/Logs/ListLogView/index.tsx index b30353696fd..c77d31ddc81 100644 --- a/frontend/src/components/Logs/ListLogView/index.tsx +++ b/frontend/src/components/Logs/ListLogView/index.tsx @@ -1,15 +1,13 @@ import './ListLogView.styles.scss'; import { blue } from '@ant-design/colors'; -import Convert from 'ansi-to-html'; import { Typography } from 'antd'; import cx from 'classnames'; import LogDetail from 'components/LogDetail'; import { VIEW_TYPES } from 'components/LogDetail/constants'; import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats'; -import { unescapeString } from 'container/LogDetailedView/utils'; +import { getSanitizedLogBody } from 'container/LogDetailedView/utils'; import { FontSize } from 'container/OptionsMenu/types'; -import dompurify from 'dompurify'; import { useActiveLog } from 'hooks/logs/useActiveLog'; import { useCopyLogLink } from 'hooks/logs/useCopyLogLink'; import { useIsDarkMode } from 'hooks/useDarkMode'; @@ -20,7 +18,6 @@ import { useCallback, useMemo, useState } from 'react'; // interfaces import { IField } from 'types/api/logs/fields'; import { ILog } from 'types/api/logs/log'; -import { FORBID_DOM_PURIFY_TAGS } from 'utils/app'; // components import AddToQueryHOC, { AddToQueryHOCProps } from '../AddToQueryHOC'; @@ -37,8 +34,6 @@ import { } from './styles'; import { isValidLogField } from './util'; -const convert = new Convert(); - interface LogFieldProps { fieldKey: string; fieldValue: string; @@ -57,11 +52,7 @@ function LogGeneralField({ }: LogFieldProps): JSX.Element { const html = useMemo( () => ({ - __html: convert.toHtml( - dompurify.sanitize(unescapeString(fieldValue), { - FORBID_TAGS: [...FORBID_DOM_PURIFY_TAGS], - }), - ), + __html: getSanitizedLogBody(fieldValue, { shouldEscapeHtml: true }), }), [fieldValue], ); @@ -217,7 +208,11 @@ function ListLogView({ fontSize={fontSize} >
- +
{updatedSelecedFields.some((field) => field.name === 'body') && ( diff --git a/frontend/src/components/Logs/LogStateIndicator/LogStateIndicator.styles.scss b/frontend/src/components/Logs/LogStateIndicator/LogStateIndicator.styles.scss index 5c2720e954c..b3e51fe54f4 100644 --- a/frontend/src/components/Logs/LogStateIndicator/LogStateIndicator.styles.scss +++ b/frontend/src/components/Logs/LogStateIndicator/LogStateIndicator.styles.scss @@ -7,7 +7,6 @@ height: 100%; width: 3px; border-radius: 50px; - background-color: transparent; &.small { min-height: 16px; @@ -21,24 +20,107 @@ min-height: 24px; } - &.INFO { + // Severity variant CSS classes using design tokens + // Trace variants - + &.severity-trace-0 { + background-color: var(--bg-forest-600); + } + &.severity-trace-1 { + background-color: var(--bg-forest-500); + } + &.severity-trace-2 { + background-color: var(--bg-forest-400); + } + &.severity-trace-3 { + background-color: var(--bg-forest-300); + } + &.severity-trace-4 { + background-color: var(--bg-forest-200); + } + + // Debug variants + &.severity-debug-0 { + background-color: var(--bg-aqua-600); + } + &.severity-debug-1 { + background-color: var(--bg-aqua-500); + } + &.severity-debug-2 { + background-color: var(--bg-aqua-400); + } + &.severity-debug-3 { + background-color: var(--bg-aqua-300); + } + &.severity-debug-4 { + background-color: var(--bg-aqua-200); + } + + // Info variants + &.severity-info-0 { + background-color: var(--bg-robin-600); + } + &.severity-info-1 { background-color: var(--bg-robin-500); } - &.WARNING, - &.WARN { + &.severity-info-2 { + background-color: var(--bg-robin-400); + } + &.severity-info-3 { + background-color: var(--bg-robin-300); + } + &.severity-info-4 { + background-color: var(--bg-robin-200); + } + + // Warn variants + &.severity-warn-0 { + background-color: var(--bg-amber-600); + } + &.severity-warn-1 { background-color: var(--bg-amber-500); } - &.ERROR { + &.severity-warn-2 { + background-color: var(--bg-amber-400); + } + &.severity-warn-3 { + background-color: var(--bg-amber-300); + } + &.severity-warn-4 { + background-color: var(--bg-amber-200); + } + + // Error variants + &.severity-error-0 { + background-color: var(--bg-cherry-600); + } + &.severity-error-1 { background-color: var(--bg-cherry-500); } - &.TRACE { - background-color: var(--bg-forest-400); + &.severity-error-2 { + background-color: var(--bg-cherry-400); } - &.DEBUG { - background-color: var(--bg-aqua-500); + &.severity-error-3 { + background-color: var(--bg-cherry-300); + } + &.severity-error-4 { + background-color: var(--bg-cherry-200); + } + + // Fatal variants + &.severity-fatal-0 { + background-color: var(--bg-sakura-600); } - &.FATAL { + &.severity-fatal-1 { background-color: var(--bg-sakura-500); } + &.severity-fatal-2 { + background-color: var(--bg-sakura-400); + } + &.severity-fatal-3 { + background-color: var(--bg-sakura-300); + } + &.severity-fatal-4 { + background-color: var(--bg-sakura-200); + } } } diff --git a/frontend/src/components/Logs/LogStateIndicator/LogStateIndicator.test.tsx b/frontend/src/components/Logs/LogStateIndicator/LogStateIndicator.test.tsx index 5ecddd5959c..086710d74bb 100644 --- a/frontend/src/components/Logs/LogStateIndicator/LogStateIndicator.test.tsx +++ b/frontend/src/components/Logs/LogStateIndicator/LogStateIndicator.test.tsx @@ -6,37 +6,41 @@ import LogStateIndicator from './LogStateIndicator'; describe('LogStateIndicator', () => { it('renders correctly with default props', () => { const { container } = render( - , + , ); const indicator = container.firstChild as HTMLElement; expect(indicator.classList.contains('log-state-indicator')).toBe(true); expect(indicator.classList.contains('isActive')).toBe(false); expect(container.querySelector('.line')).toBeTruthy(); - expect(container.querySelector('.line')?.classList.contains('INFO')).toBe( - true, - ); + expect( + container.querySelector('.line')?.classList.contains('severity-info-0'), + ).toBe(true); }); it('renders correctly with different types', () => { const { container: containerInfo } = render( - , - ); - expect(containerInfo.querySelector('.line')?.classList.contains('INFO')).toBe( - true, + , ); + expect( + containerInfo.querySelector('.line')?.classList.contains('severity-info-0'), + ).toBe(true); const { container: containerWarning } = render( - , + , ); expect( - containerWarning.querySelector('.line')?.classList.contains('WARNING'), + containerWarning + .querySelector('.line') + ?.classList.contains('severity-warn-0'), ).toBe(true); const { container: containerError } = render( - , + , ); expect( - containerError.querySelector('.line')?.classList.contains('ERROR'), + containerError + .querySelector('.line') + ?.classList.contains('severity-error-0'), ).toBe(true); }); }); diff --git a/frontend/src/components/Logs/LogStateIndicator/LogStateIndicator.tsx b/frontend/src/components/Logs/LogStateIndicator/LogStateIndicator.tsx index f831c6252a8..7f2eeb4ecaf 100644 --- a/frontend/src/components/Logs/LogStateIndicator/LogStateIndicator.tsx +++ b/frontend/src/components/Logs/LogStateIndicator/LogStateIndicator.tsx @@ -3,6 +3,8 @@ import './LogStateIndicator.styles.scss'; import cx from 'classnames'; import { FontSize } from 'container/OptionsMenu/types'; +import { getLogTypeBySeverityNumber } from './utils'; + export const SEVERITY_TEXT_TYPE = { TRACE: 'TRACE', TRACE2: 'TRACE2', @@ -42,18 +44,112 @@ export const LogType = { UNKNOWN: 'UNKNOWN', } as const; +// Severity variant mapping to CSS classes +const SEVERITY_VARIANT_CLASSES: Record = { + // Trace variants - forest-600 to forest-200 + TRACE: 'severity-trace-0', + Trace: 'severity-trace-1', + trace: 'severity-trace-2', + trc: 'severity-trace-3', + Trc: 'severity-trace-4', + + // Debug variants - aqua-600 to aqua-200 + DEBUG: 'severity-debug-0', + Debug: 'severity-debug-1', + debug: 'severity-debug-2', + dbg: 'severity-debug-3', + Dbg: 'severity-debug-4', + + // Info variants - robin-600 to robin-200 + INFO: 'severity-info-0', + Info: 'severity-info-1', + info: 'severity-info-2', + Information: 'severity-info-3', + information: 'severity-info-4', + + // Warn variants - amber-600 to amber-200 + WARN: 'severity-warn-0', + WARNING: 'severity-warn-0', + Warn: 'severity-warn-1', + warn: 'severity-warn-2', + warning: 'severity-warn-3', + Warning: 'severity-warn-4', + wrn: 'severity-warn-3', + Wrn: 'severity-warn-4', + + // Error variants - cherry-600 to cherry-200 + // eslint-disable-next-line sonarjs/no-duplicate-string + ERROR: 'severity-error-0', + Error: 'severity-error-1', + error: 'severity-error-2', + err: 'severity-error-3', + Err: 'severity-error-4', + ERR: 'severity-error-0', + fail: 'severity-error-2', + Fail: 'severity-error-3', + FAIL: 'severity-error-0', + + // Fatal variants - sakura-600 to sakura-200 + // eslint-disable-next-line sonarjs/no-duplicate-string + FATAL: 'severity-fatal-0', + Fatal: 'severity-fatal-1', + fatal: 'severity-fatal-2', + // eslint-disable-next-line sonarjs/no-duplicate-string + critical: 'severity-fatal-3', + Critical: 'severity-fatal-4', + CRITICAL: 'severity-fatal-0', + crit: 'severity-fatal-3', + Crit: 'severity-fatal-4', + CRIT: 'severity-fatal-0', + panic: 'severity-fatal-2', + Panic: 'severity-fatal-3', + PANIC: 'severity-fatal-0', +}; + +function getSeverityClass( + severityText?: string, + severityNumber?: number, +): string { + // Priority 1: Use severityText for exact variant mapping + if (severityText) { + const variantClass = SEVERITY_VARIANT_CLASSES[severityText.trim()]; + if (variantClass) { + return variantClass; + } + } + + // Priority 2: Use severityNumber for base color (use middle shade as default) + if (severityNumber) { + const logType = getLogTypeBySeverityNumber(severityNumber); + if (logType !== LogType.UNKNOWN) { + return `severity-${logType.toLowerCase()}-0`; // Use middle shade (index 2) + } + } + + return 'severity-info-0'; // Fallback to CSS classes based on type +} + function LogStateIndicator({ - type, fontSize, + severityText, + severityNumber, }: { - type: string; fontSize: FontSize; + severityText?: string; + severityNumber?: number; }): JSX.Element { + const severityClass = getSeverityClass(severityText, severityNumber); + return (
-
+
); } +LogStateIndicator.defaultProps = { + severityText: '', + severityNumber: 0, +}; + export default LogStateIndicator; diff --git a/frontend/src/components/Logs/LogStateIndicator/utils.ts b/frontend/src/components/Logs/LogStateIndicator/utils.ts index 03989a8dd60..963f319aceb 100644 --- a/frontend/src/components/Logs/LogStateIndicator/utils.ts +++ b/frontend/src/components/Logs/LogStateIndicator/utils.ts @@ -41,7 +41,7 @@ const getLogTypeBySeverityText = (severityText: string): string => { }; // https://opentelemetry.io/docs/specs/otel/logs/data-model/#field-severitynumber -const getLogTypeBySeverityNumber = (severityNumber: number): string => { +export const getLogTypeBySeverityNumber = (severityNumber: number): string => { if (severityNumber < 1) { return LogType.UNKNOWN; } diff --git a/frontend/src/components/Logs/RawLogView/index.tsx b/frontend/src/components/Logs/RawLogView/index.tsx index 897dbe98a79..c9b73497c59 100644 --- a/frontend/src/components/Logs/RawLogView/index.tsx +++ b/frontend/src/components/Logs/RawLogView/index.tsx @@ -1,13 +1,10 @@ -import './RawLogView.styles.scss'; - -import Convert from 'ansi-to-html'; -import { DrawerProps } from 'antd'; +import { Color } from '@signozhq/design-tokens'; +import { DrawerProps, Tooltip } from 'antd'; import LogDetail from 'components/LogDetail'; import { VIEW_TYPES, VIEWS } from 'components/LogDetail/constants'; import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats'; -import { unescapeString } from 'container/LogDetailedView/utils'; +import { getSanitizedLogBody } from 'container/LogDetailedView/utils'; import LogsExplorerContext from 'container/LogsExplorerContext'; -import dompurify from 'dompurify'; import { useActiveLog } from 'hooks/logs/useActiveLog'; import { useCopyLogLink } from 'hooks/logs/useCopyLogLink'; // hooks @@ -23,29 +20,31 @@ import { useMemo, useState, } from 'react'; -import { FORBID_DOM_PURIFY_TAGS } from 'utils/app'; import LogLinesActionButtons from '../LogLinesActionButtons/LogLinesActionButtons'; import LogStateIndicator from '../LogStateIndicator/LogStateIndicator'; import { getLogIndicatorType } from '../LogStateIndicator/utils'; // styles -import { RawLogContent, RawLogViewContainer } from './styles'; +import { InfoIconWrapper, RawLogContent, RawLogViewContainer } from './styles'; import { RawLogViewProps } from './types'; -const convert = new Convert(); - function RawLogView({ isActiveLog, isReadOnly, data, linesPerRow, isTextOverflowEllipsisDisabled, + isHighlighted, + helpTooltip, selectedFields = [], fontSize, + onLogClick, }: RawLogViewProps): JSX.Element { - const { isHighlighted, isLogsExplorerPage, onLogCopy } = useCopyLogLink( - data.id, - ); + const { + isHighlighted: isUrlHighlighted, + isLogsExplorerPage, + onLogCopy, + } = useCopyLogLink(data.id); const flattenLogData = useMemo(() => FlatLogData(data), [data]); const { @@ -131,12 +130,20 @@ function RawLogView({ formatTimezoneAdjustedTimestamp, ]); - const handleClickExpand = useCallback(() => { - if (activeContextLog || isReadOnly) return; + const handleClickExpand = useCallback( + (event: MouseEvent) => { + if (activeContextLog || isReadOnly) return; - onSetActiveLog(data); - setSelectedTab(VIEW_TYPES.OVERVIEW); - }, [activeContextLog, isReadOnly, data, onSetActiveLog]); + // Use custom click handler if provided, otherwise use default behavior + if (onLogClick) { + onLogClick(data, event); + } else { + onSetActiveLog(data); + setSelectedTab(VIEW_TYPES.OVERVIEW); + } + }, + [activeContextLog, isReadOnly, data, onSetActiveLog, onLogClick], + ); const handleCloseLogDetail: DrawerProps['onClose'] = useCallback( ( @@ -176,11 +183,7 @@ function RawLogView({ const html = useMemo( () => ({ - __html: convert.toHtml( - dompurify.sanitize(unescapeString(text), { - FORBID_TAGS: [...FORBID_DOM_PURIFY_TAGS], - }), - ), + __html: getSanitizedLogBody(text, { shouldEscapeHtml: true }), }), [text], ); @@ -192,16 +195,30 @@ function RawLogView({ align="middle" $isDarkMode={isDarkMode} $isReadOnly={isReadOnly} - $isHightlightedLog={isHighlighted} + $isHightlightedLog={isUrlHighlighted} $isActiveLog={ activeLog?.id === data.id || activeContextLog?.id === data.id || isActiveLog } + $isCustomHighlighted={isHighlighted} $logType={logType} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} fontSize={fontSize} > - + + {helpTooltip && ( + + + + )} ` @@ -50,6 +56,18 @@ export const RawLogViewContainer = styled(Row)<{ }; transition: background-color 2s ease-in;` : ''} + + ${({ $isCustomHighlighted, $isDarkMode, $logType }): string => + getCustomHighlightBackground($isCustomHighlighted, $isDarkMode, $logType)} +`; + +export const InfoIconWrapper = styled(Info)` + display: flex; + align-items: center; + margin-right: 4px; + cursor: help; + flex-shrink: 0; + height: auto; `; export const ExpandIconWrapper = styled(Col)` diff --git a/frontend/src/components/Logs/RawLogView/types.ts b/frontend/src/components/Logs/RawLogView/types.ts index ed73725dcc3..5cbc3e8c263 100644 --- a/frontend/src/components/Logs/RawLogView/types.ts +++ b/frontend/src/components/Logs/RawLogView/types.ts @@ -1,4 +1,5 @@ import { FontSize } from 'container/OptionsMenu/types'; +import { MouseEvent } from 'react'; import { IField } from 'types/api/logs/fields'; import { ILog } from 'types/api/logs/log'; @@ -6,10 +7,13 @@ export interface RawLogViewProps { isActiveLog?: boolean; isReadOnly?: boolean; isTextOverflowEllipsisDisabled?: boolean; + isHighlighted?: boolean; + helpTooltip?: string; data: ILog; linesPerRow: number; fontSize: FontSize; selectedFields?: IField[]; + onLogClick?: (log: ILog, event: MouseEvent) => void; } export interface RawLogContentProps { diff --git a/frontend/src/components/Logs/TableView/config.ts b/frontend/src/components/Logs/TableView/config.ts index 7a267dc6244..6723a8c9591 100644 --- a/frontend/src/components/Logs/TableView/config.ts +++ b/frontend/src/components/Logs/TableView/config.ts @@ -15,11 +15,13 @@ export function getDefaultCellStyle(isDarkMode?: boolean): CSSProperties { letterSpacing: '-0.07px', marginBottom: '0px', minWidth: '10rem', + width: '10rem', }; } export const defaultTableStyle: CSSProperties = { minWidth: '40rem', + maxWidth: '60rem', }; export const defaultListViewPanelStyle: CSSProperties = { diff --git a/frontend/src/components/Logs/TableView/useTableView.tsx b/frontend/src/components/Logs/TableView/useTableView.tsx index 9971f6d775a..a355c6372af 100644 --- a/frontend/src/components/Logs/TableView/useTableView.tsx +++ b/frontend/src/components/Logs/TableView/useTableView.tsx @@ -1,20 +1,16 @@ import './useTableView.styles.scss'; -import Convert from 'ansi-to-html'; import { Typography } from 'antd'; import { ColumnsType } from 'antd/es/table'; import cx from 'classnames'; import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats'; -import { unescapeString } from 'container/LogDetailedView/utils'; -import dompurify from 'dompurify'; +import { getSanitizedLogBody } from 'container/LogDetailedView/utils'; import { useIsDarkMode } from 'hooks/useDarkMode'; import { FlatLogData } from 'lib/logs/flatLogData'; import { useTimezone } from 'providers/Timezone'; import { useMemo } from 'react'; -import { FORBID_DOM_PURIFY_TAGS } from 'utils/app'; import LogStateIndicator from '../LogStateIndicator/LogStateIndicator'; -import { getLogIndicatorTypeForTable } from '../LogStateIndicator/utils'; import { defaultListViewPanelStyle, defaultTableStyle, @@ -27,8 +23,6 @@ import { UseTableViewResult, } from './types'; -const convert = new Convert(); - export const useTableView = (props: UseTableViewProps): UseTableViewResult => { const { logs, @@ -47,12 +41,22 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => { const { formatTimezoneAdjustedTimestamp } = useTimezone(); + const bodyColumnStyle = useMemo( + () => ({ + ...defaultTableStyle, + ...(fields.length > 2 ? { width: '50rem' } : {}), + }), + [fields.length], + ); + const columns: ColumnsType> = useMemo(() => { const fieldColumns: ColumnsType> = fields .filter((e) => !['id', 'body', 'timestamp'].includes(e.name)) .map(({ name }) => ({ title: name, dataIndex: name, + accessorKey: name, + id: name.toLowerCase().replace(/\./g, '_'), key: name, render: (field): ColumnTypeRender> => ({ props: { @@ -80,13 +84,17 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => { // We do not need any title and data index for the log state indicator title: '', dataIndex: '', + // eslint-disable-next-line sonarjs/no-duplicate-string key: 'state-indicator', + accessorKey: 'state-indicator', + id: 'state-indicator', render: (_, item): ColumnTypeRender> => ({ children: (
), @@ -98,6 +106,8 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => { title: 'timestamp', dataIndex: 'timestamp', key: 'timestamp', + accessorKey: 'timestamp', + id: 'timestamp', // https://github.com/ant-design/ant-design/discussions/36886 render: ( field: string | number, @@ -132,20 +142,18 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => { title: 'body', dataIndex: 'body', key: 'body', + accessorKey: 'body', + id: 'body', render: ( field: string | number, ): ColumnTypeRender> => ({ props: { - style: defaultTableStyle, + style: bodyColumnStyle, }, children: ( { linesPerRow, fontSize, formatTimezoneAdjustedTimestamp, + bodyColumnStyle, ]); return { columns, dataSource: flattenLogData }; diff --git a/frontend/src/components/LogsDownloadOptionsMenu/LogsDownloadOptionsMenu.styles.scss b/frontend/src/components/LogsDownloadOptionsMenu/LogsDownloadOptionsMenu.styles.scss new file mode 100644 index 00000000000..e9f2d92df37 --- /dev/null +++ b/frontend/src/components/LogsDownloadOptionsMenu/LogsDownloadOptionsMenu.styles.scss @@ -0,0 +1,86 @@ +.logs-download-popover { + .ant-popover-inner { + border-radius: 4px; + border: 1px solid var(--bg-slate-400); + background: linear-gradient( + 139deg, + var(--bg-ink-400) 0%, + var(--bg-ink-500) 98.68% + ); + box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2); + backdrop-filter: blur(20px); + padding: 0 8px 12px 8px; + margin: 6px 0; + } + + .export-options-container { + width: 240px; + border-radius: 4px; + + .title { + display: flex; + color: var(--bg-slate-50); + font-family: Inter; + font-size: 11px; + font-style: normal; + font-weight: 500; + line-height: 18px; + letter-spacing: 0.88px; + text-transform: uppercase; + margin-bottom: 8px; + } + + .export-format, + .row-limit, + .columns-scope { + padding: 12px 4px; + display: flex; + flex-direction: column; + + :global(.ant-radio-wrapper) { + color: var(--bg-vanilla-400); + font-family: Inter; + font-size: 13px; + } + } + + .horizontal-line { + height: 1px; + background: var(--bg-slate-400); + } + + .export-button { + width: 100%; + display: flex; + align-items: center; + justify-content: center; + } + } +} + +.lightMode { + .logs-download-popover { + .ant-popover-inner { + border: 1px solid var(--bg-vanilla-300); + background: linear-gradient( + 139deg, + var(--bg-vanilla-100) 0%, + var(--bg-vanilla-300) 98.68% + ); + box-shadow: 4px 10px 16px 2px rgba(255, 255, 255, 0.2); + } + .export-options-container { + .title { + color: var(--bg-ink-200); + } + + :global(.ant-radio-wrapper) { + color: var(--bg-ink-400); + } + + .horizontal-line { + background: var(--bg-vanilla-300); + } + } + } +} diff --git a/frontend/src/components/LogsDownloadOptionsMenu/LogsDownloadOptionsMenu.test.tsx b/frontend/src/components/LogsDownloadOptionsMenu/LogsDownloadOptionsMenu.test.tsx new file mode 100644 index 00000000000..eaf7456039c --- /dev/null +++ b/frontend/src/components/LogsDownloadOptionsMenu/LogsDownloadOptionsMenu.test.tsx @@ -0,0 +1,341 @@ +import '@testing-library/jest-dom'; + +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { message } from 'antd'; +import { ENVIRONMENT } from 'constants/env'; +import { server } from 'mocks-server/server'; +import { rest } from 'msw'; +import { TelemetryFieldKey } from 'types/api/v5/queryRange'; + +import { DownloadFormats, DownloadRowCounts } from './constants'; +import LogsDownloadOptionsMenu from './LogsDownloadOptionsMenu'; + +// Mock antd message +jest.mock('antd', () => { + const actual = jest.requireActual('antd'); + return { + ...actual, + message: { + success: jest.fn(), + error: jest.fn(), + }, + }; +}); + +const TEST_IDS = { + DOWNLOAD_BUTTON: 'periscope-btn-download-options', +} as const; + +interface TestProps { + startTime: number; + endTime: number; + filter: string; + columns: TelemetryFieldKey[]; + orderBy: string; +} + +const createTestProps = (): TestProps => ({ + startTime: 1631234567890, + endTime: 1631234567999, + filter: 'status = 200', + columns: [ + { + name: 'http.status', + fieldContext: 'attribute', + fieldDataType: 'int64', + } as TelemetryFieldKey, + ], + orderBy: 'timestamp:desc', +}); + +const testRenderContent = (props: TestProps): void => { + render( + , + ); +}; + +const testSuccessResponse = (res: any, ctx: any): any => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/octet-stream'), + ctx.set('Content-Disposition', 'attachment; filename="export.csv"'), + ctx.body('id,value\n1,2\n'), + ); + +describe('LogsDownloadOptionsMenu', () => { + const BASE_URL = ENVIRONMENT.baseURL; + const EXPORT_URL = `${BASE_URL}/api/v1/export_raw_data`; + let requestSpy: jest.Mock; + const setupDefaultServer = (): void => { + server.use( + rest.get(EXPORT_URL, (req, res, ctx) => { + const params = req.url.searchParams; + const payload = { + start: Number(params.get('start')), + end: Number(params.get('end')), + filter: params.get('filter'), + columns: params.getAll('columns'), + order_by: params.get('order_by'), + limit: Number(params.get('limit')), + format: params.get('format'), + }; + requestSpy(payload); + return testSuccessResponse(res, ctx); + }), + ); + }; + + // Mock URL.createObjectURL used by download logic + const originalCreateObjectURL = URL.createObjectURL; + const originalRevokeObjectURL = URL.revokeObjectURL; + + beforeEach(() => { + requestSpy = jest.fn(); + setupDefaultServer(); + (message.success as jest.Mock).mockReset(); + (message.error as jest.Mock).mockReset(); + // jsdom doesn't implement it by default + ((URL as unknown) as { + createObjectURL: (b: Blob) => string; + }).createObjectURL = jest.fn(() => 'blob:mock'); + ((URL as unknown) as { + revokeObjectURL: (u: string) => void; + }).revokeObjectURL = jest.fn(); + }); + + beforeAll(() => { + server.listen(); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + afterAll(() => { + server.close(); + // restore + URL.createObjectURL = originalCreateObjectURL; + URL.revokeObjectURL = originalRevokeObjectURL; + }); + + it('renders download button', () => { + const props = createTestProps(); + testRenderContent(props); + + const button = screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON); + expect(button).toBeInTheDocument(); + expect(button).toHaveClass('periscope-btn', 'ghost'); + }); + + it('shows popover with export options when download button is clicked', () => { + const props = createTestProps(); + render( + , + ); + + fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON)); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText('FORMAT')).toBeInTheDocument(); + expect(screen.getByText('Number of Rows')).toBeInTheDocument(); + expect(screen.getByText('Columns')).toBeInTheDocument(); + }); + + it('allows changing export format', () => { + const props = createTestProps(); + testRenderContent(props); + fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON)); + + const csvRadio = screen.getByRole('radio', { name: 'csv' }); + const jsonlRadio = screen.getByRole('radio', { name: 'jsonl' }); + + expect(csvRadio).toBeChecked(); + fireEvent.click(jsonlRadio); + expect(jsonlRadio).toBeChecked(); + expect(csvRadio).not.toBeChecked(); + }); + + it('allows changing row limit', () => { + const props = createTestProps(); + testRenderContent(props); + + fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON)); + + const tenKRadio = screen.getByRole('radio', { name: '10k' }); + const fiftyKRadio = screen.getByRole('radio', { name: '50k' }); + + expect(tenKRadio).toBeChecked(); + fireEvent.click(fiftyKRadio); + expect(fiftyKRadio).toBeChecked(); + expect(tenKRadio).not.toBeChecked(); + }); + + it('allows changing columns scope', () => { + const props = createTestProps(); + testRenderContent(props); + fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON)); + + const allColumnsRadio = screen.getByRole('radio', { name: 'All' }); + const selectedColumnsRadio = screen.getByRole('radio', { name: 'Selected' }); + + expect(allColumnsRadio).toBeChecked(); + fireEvent.click(selectedColumnsRadio); + expect(selectedColumnsRadio).toBeChecked(); + expect(allColumnsRadio).not.toBeChecked(); + }); + + it('calls downloadExportData with correct parameters when export button is clicked (Selected columns)', async () => { + const props = createTestProps(); + testRenderContent(props); + fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON)); + fireEvent.click(screen.getByRole('radio', { name: 'Selected' })); + fireEvent.click(screen.getByText('Export')); + + await waitFor(() => { + expect(requestSpy).toHaveBeenCalledWith( + expect.objectContaining({ + start: props.startTime, + end: props.endTime, + columns: ['attribute.http.status:int64'], + filter: props.filter, + order_by: props.orderBy, + format: DownloadFormats.CSV, + limit: DownloadRowCounts.TEN_K, + }), + ); + }); + }); + + it('calls downloadExportData with correct parameters when export button is clicked', async () => { + const props = createTestProps(); + testRenderContent(props); + + fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON)); + fireEvent.click(screen.getByRole('radio', { name: 'All' })); + fireEvent.click(screen.getByText('Export')); + + await waitFor(() => { + expect(requestSpy).toHaveBeenCalledWith( + expect.objectContaining({ + start: props.startTime, + end: props.endTime, + columns: [], + filter: props.filter, + order_by: props.orderBy, + format: DownloadFormats.CSV, + limit: DownloadRowCounts.TEN_K, + }), + ); + }); + }); + + it('handles successful export with success message', async () => { + const props = createTestProps(); + testRenderContent(props); + + fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON)); + fireEvent.click(screen.getByText('Export')); + + await waitFor(() => { + expect(message.success).toHaveBeenCalledWith( + 'Export completed successfully', + ); + }); + }); + + it('handles export failure with error message', async () => { + // Override handler to return 500 for this test + server.use(rest.get(EXPORT_URL, (_req, res, ctx) => res(ctx.status(500)))); + const props = createTestProps(); + testRenderContent(props); + + fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON)); + fireEvent.click(screen.getByText('Export')); + + await waitFor(() => { + expect(message.error).toHaveBeenCalledWith( + 'Failed to export logs. Please try again.', + ); + }); + }); + + it('handles UI state correctly during export process', async () => { + server.use( + rest.get(EXPORT_URL, (_req, res, ctx) => testSuccessResponse(res, ctx)), + ); + const props = createTestProps(); + testRenderContent(props); + + // Open popover + fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON)); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + + // Start export + fireEvent.click(screen.getByText('Export')); + + // Check button is disabled during export + expect(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON)).toBeDisabled(); + + // Check popover is closed immediately after export starts + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + + // Wait for export to complete and verify button is enabled again + await waitFor(() => { + expect(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON)).not.toBeDisabled(); + }); + }); + + it('uses filename from Content-Disposition and triggers download click', async () => { + server.use( + rest.get(EXPORT_URL, (_req, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/octet-stream'), + ctx.set('Content-Disposition', 'attachment; filename="report.jsonl"'), + ctx.body('row\n'), + ), + ), + ); + + const originalCreateElement = document.createElement.bind(document); + const anchorEl = originalCreateElement('a') as HTMLAnchorElement; + const setAttrSpy = jest.spyOn(anchorEl, 'setAttribute'); + const clickSpy = jest.spyOn(anchorEl, 'click'); + const removeSpy = jest.spyOn(anchorEl, 'remove'); + const createElSpy = jest + .spyOn(document, 'createElement') + .mockImplementation((tagName: any): any => + tagName === 'a' ? anchorEl : originalCreateElement(tagName), + ); + const appendSpy = jest.spyOn(document.body, 'appendChild'); + + const props = createTestProps(); + testRenderContent(props); + + fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON)); + fireEvent.click(screen.getByText('Export')); + + await waitFor(() => { + expect(appendSpy).toHaveBeenCalledWith(anchorEl); + expect(setAttrSpy).toHaveBeenCalledWith('download', 'report.jsonl'); + expect(clickSpy).toHaveBeenCalled(); + expect(removeSpy).toHaveBeenCalled(); + }); + expect(anchorEl.getAttribute('download')).toBe('report.jsonl'); + + createElSpy.mockRestore(); + appendSpy.mockRestore(); + }); +}); diff --git a/frontend/src/components/LogsDownloadOptionsMenu/LogsDownloadOptionsMenu.tsx b/frontend/src/components/LogsDownloadOptionsMenu/LogsDownloadOptionsMenu.tsx new file mode 100644 index 00000000000..655da183cd4 --- /dev/null +++ b/frontend/src/components/LogsDownloadOptionsMenu/LogsDownloadOptionsMenu.tsx @@ -0,0 +1,170 @@ +import './LogsDownloadOptionsMenu.styles.scss'; + +import { Button, message, Popover, Radio, Tooltip, Typography } from 'antd'; +import { downloadExportData } from 'api/v1/download/downloadExportData'; +import { Download, DownloadIcon, Loader2 } from 'lucide-react'; +import { useCallback, useMemo, useState } from 'react'; +import { TelemetryFieldKey } from 'types/api/v5/queryRange'; + +import { + DownloadColumnsScopes, + DownloadFormats, + DownloadRowCounts, +} from './constants'; + +function convertTelemetryFieldKeyToText(key: TelemetryFieldKey): string { + const prefix = key.fieldContext ? `${key.fieldContext}.` : ''; + const suffix = key.fieldDataType ? `:${key.fieldDataType}` : ''; + return `${prefix}${key.name}${suffix}`; +} + +interface LogsDownloadOptionsMenuProps { + startTime: number; + endTime: number; + filter: string; + columns: TelemetryFieldKey[]; + orderBy: string; +} + +export default function LogsDownloadOptionsMenu({ + startTime, + endTime, + filter, + columns, + orderBy, +}: LogsDownloadOptionsMenuProps): JSX.Element { + const [exportFormat, setExportFormat] = useState(DownloadFormats.CSV); + const [rowLimit, setRowLimit] = useState(DownloadRowCounts.TEN_K); + const [columnsScope, setColumnsScope] = useState( + DownloadColumnsScopes.ALL, + ); + const [isDownloading, setIsDownloading] = useState(false); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const handleExportRawData = useCallback(async (): Promise => { + setIsPopoverOpen(false); + try { + setIsDownloading(true); + const downloadOptions = { + source: 'logs', + start: startTime, + end: endTime, + columns: + columnsScope === DownloadColumnsScopes.SELECTED + ? columns.map((col) => convertTelemetryFieldKeyToText(col)) + : [], + filter, + orderBy, + format: exportFormat, + limit: rowLimit, + }; + + await downloadExportData(downloadOptions); + message.success('Export completed successfully'); + } catch (error) { + console.error('Error exporting logs:', error); + message.error('Failed to export logs. Please try again.'); + } finally { + setIsDownloading(false); + } + }, [ + startTime, + endTime, + columnsScope, + columns, + filter, + orderBy, + exportFormat, + rowLimit, + setIsDownloading, + setIsPopoverOpen, + ]); + + const popoverContent = useMemo( + () => ( +
+
+ FORMAT + setExportFormat(e.target.value)} + > + csv + jsonl + +
+ +
+ +
+ Number of Rows + setRowLimit(e.target.value)} + > + 10k + 30k + 50k + +
+ +
+ +
+ Columns + setColumnsScope(e.target.value)} + > + All + Selected + +
+ + +
+ ), + [exportFormat, rowLimit, columnsScope, isDownloading, handleExportRawData], + ); + + return ( + + +
-
{title}
+
FORMAT
{items.map( @@ -410,18 +416,18 @@ export default function LogsFormatOptionsMenu({ )}
- {addColumn?.value?.map(({ key, id }) => ( -
+ {addColumn?.value?.map(({ name }) => ( +
- - {key} + + {name}
{addColumn?.value?.length > 1 && ( addColumn.onRemove(id as string)} + onClick={(): void => addColumn.onRemove(name)} /> )}
@@ -441,3 +447,37 @@ export default function LogsFormatOptionsMenu({
); } + +function LogsFormatOptionsMenu({ + items, + selectedOptionFormat, + config, +}: LogsFormatOptionsMenuProps): JSX.Element { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + return ( + + } + trigger="click" + placement="bottomRight" + arrow={false} + open={isPopoverOpen} + onOpenChange={setIsPopoverOpen} + rootClassName="format-options-popover" + destroyTooltipOnHide + > +