Compare commits

..

9 commits

Author SHA1 Message Date
Андрей Бабушкин
ecde743bb3 pulled dev 2025-03-18 11:05:49 +01:00
Андрей Бабушкин
34d2201a90 updated widget url 2025-03-18 11:03:59 +01:00
nick-delirium
4c967d4bc1
ui: update tracker import examples 2025-03-17 13:42:34 +01:00
Alexander
3fdf799bd7 feat(http): unsupported tracker error with projectID in logs 2025-03-17 13:32:00 +01:00
Андрей Бабушкин
f3af4cb5a5 pulled dev 2025-03-17 11:26:07 +01:00
nick-delirium
9aca716e6b
tracker: 16.0.2 fix str dictionary keys 2025-03-17 11:25:54 +01:00
Андрей Бабушкин
0e9f87be72 fix calls 2025-03-17 11:25:45 +01:00
Shekar Siri
cf9ecdc9a4 refactor(searchStore): reformat filterMap function parameters
- Reformat the parameters of the filterMap function for better readability.
- Comment out the fetchSessions call in clearSearch method to avoid unnecessary session fetch.
2025-03-14 19:47:42 +01:00
Андрей Бабушкин
c79e10ebed updated widget link 2025-03-14 10:13:33 +01:00
217 changed files with 3010 additions and 5294 deletions

View file

@ -1,122 +0,0 @@
# This action will push the assist changes to aws
on:
workflow_dispatch:
inputs:
skip_security_checks:
description: "Skip Security checks if there is a unfixable vuln or error. Value: true/false"
required: false
default: "false"
push:
branches:
- dev
paths:
- "ee/assist-server/**"
name: Build and Deploy Assist-Server EE
jobs:
deploy:
name: Deploy
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
with:
# We need to diff with old commit
# to see which workers got changed.
fetch-depth: 2
- uses: ./.github/composite-actions/update-keys
with:
assist_jwt_secret: ${{ secrets.ASSIST_JWT_SECRET }}
assist_key: ${{ secrets.ASSIST_KEY }}
domain_name: ${{ secrets.EE_DOMAIN_NAME }}
jwt_refresh_secret: ${{ secrets.JWT_REFRESH_SECRET }}
jwt_secret: ${{ secrets.EE_JWT_SECRET }}
jwt_spot_refresh_secret: ${{ secrets.JWT_SPOT_REFRESH_SECRET }}
jwt_spot_secret: ${{ secrets.JWT_SPOT_SECRET }}
license_key: ${{ secrets.EE_LICENSE_KEY }}
minio_access_key: ${{ secrets.EE_MINIO_ACCESS_KEY }}
minio_secret_key: ${{ secrets.EE_MINIO_SECRET_KEY }}
pg_password: ${{ secrets.EE_PG_PASSWORD }}
registry_url: ${{ secrets.OSS_REGISTRY_URL }}
name: Update Keys
- name: Docker login
run: |
docker login ${{ secrets.EE_REGISTRY_URL }} -u ${{ secrets.EE_DOCKER_USERNAME }} -p "${{ secrets.EE_REGISTRY_TOKEN }}"
- uses: azure/k8s-set-context@v1
with:
method: kubeconfig
kubeconfig: ${{ secrets.EE_KUBECONFIG }} # Use content of kubeconfig in secret.
id: setcontext
- name: Building and Pushing Assist-Server image
id: build-image
env:
DOCKER_REPO: ${{ secrets.EE_REGISTRY_URL }}
IMAGE_TAG: ${{ github.ref_name }}_${{ github.sha }}-ee
ENVIRONMENT: staging
run: |
skip_security_checks=${{ github.event.inputs.skip_security_checks }}
cd assist-server
PUSH_IMAGE=0 bash -x ./build.sh ee
[[ "x$skip_security_checks" == "xtrue" ]] || {
curl -L https://github.com/aquasecurity/trivy/releases/download/v0.56.2/trivy_0.56.2_Linux-64bit.tar.gz | tar -xzf - -C ./
images=("assist-server")
for image in ${images[*]};do
./trivy image --db-repository ghcr.io/aquasecurity/trivy-db:2 --db-repository public.ecr.aws/aquasecurity/trivy-db:2 --exit-code 1 --security-checks vuln --vuln-type os,library --severity "HIGH,CRITICAL" --ignore-unfixed $DOCKER_REPO/$image:$IMAGE_TAG
done
err_code=$?
[[ $err_code -ne 0 ]] && {
exit $err_code
}
} && {
echo "Skipping Security Checks"
}
images=("assist-server")
for image in ${images[*]};do
docker push $DOCKER_REPO/$image:$IMAGE_TAG
done
- name: Creating old image input
run: |
#
# Create yaml with existing image tags
#
kubectl get pods -n app -o jsonpath="{.items[*].spec.containers[*].image}" |\
tr -s '[[:space:]]' '\n' | sort | uniq -c | grep '/foss/' | cut -d '/' -f3 > /tmp/image_tag.txt
echo > /tmp/image_override.yaml
for line in `cat /tmp/image_tag.txt`;
do
image_array=($(echo "$line" | tr ':' '\n'))
cat <<EOF >> /tmp/image_override.yaml
${image_array[0]}:
image:
# We've to strip off the -ee, as helm will append it.
tag: `echo ${image_array[1]} | cut -d '-' -f 1`
EOF
done
- name: Deploy to kubernetes
run: |
pwd
cd scripts/helmcharts/
# Update changed image tag
sed -i "/assist-server/{n;n;n;s/.*/ tag: ${IMAGE_TAG}/}" /tmp/image_override.yaml
cat /tmp/image_override.yaml
# Deploy command
mkdir -p /tmp/charts
mv openreplay/charts/{ingress-nginx,assist-server,quickwit,connector} /tmp/charts/
rm -rf openreplay/charts/*
mv /tmp/charts/* openreplay/charts/
helm template openreplay -n app openreplay -f vars.yaml -f /tmp/image_override.yaml --set ingress-nginx.enabled=false --set skipMigration=true --no-hooks --kube-version=$k_version | kubectl apply -f -
env:
DOCKER_REPO: ${{ secrets.EE_REGISTRY_URL }}
# We're not passing -ee flag, because helm will add that.
IMAGE_TAG: ${{ github.ref_name }}_${{ github.sha }}
ENVIRONMENT: staging

View file

@ -1,189 +0,0 @@
# Ref: https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions
on:
workflow_dispatch:
inputs:
services:
description: 'Comma separated names of services to build(in small letters).'
required: true
default: 'chalice,frontend'
tag:
description: 'Tag to update.'
required: true
type: string
branch:
description: 'Branch to build patches from. Make sure the branch is uptodate with tag. Else itll cause missing commits.'
required: true
type: string
name: Build patches from tag, rewrite commit HEAD to older timestamp, and Push the tag
jobs:
deploy:
name: Build Patch from old tag
runs-on: ubuntu-latest
env:
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
DEPOT_PROJECT_ID: ${{ secrets.DEPOT_PROJECT_ID }}
steps:
- name: Checkout
uses: actions/checkout@v2
with:
fetch-depth: 4
ref: ${{ github.event.inputs.tag }}
- name: Set Remote with GITHUB_TOKEN
run: |
git config --unset http.https://github.com/.extraheader
git remote set-url origin https://x-access-token:${{ secrets.ACTIONS_COMMMIT_TOKEN }}@github.com/${{ github.repository }}.git
- name: Create backup tag with timestamp
run: |
set -e # Exit immediately if a command exits with a non-zero status
TIMESTAMP=$(date +%Y%m%d%H%M%S)
BACKUP_TAG="${{ github.event.inputs.tag }}-backup-${TIMESTAMP}"
echo "BACKUP_TAG=${BACKUP_TAG}" >> $GITHUB_ENV
echo "INPUT_TAG=${{ github.event.inputs.tag }}" >> $GITHUB_ENV
git tag $BACKUP_TAG || { echo "Failed to create backup tag"; exit 1; }
git push origin $BACKUP_TAG || { echo "Failed to push backup tag"; exit 1; }
echo "Created backup tag: $BACKUP_TAG"
# Get the oldest commit date from the last 3 commits in raw format
OLDEST_COMMIT_TIMESTAMP=$(git log -3 --pretty=format:"%at" | tail -1)
echo "Oldest commit timestamp: $OLDEST_COMMIT_TIMESTAMP"
# Add 1 second to the timestamp
NEW_TIMESTAMP=$((OLDEST_COMMIT_TIMESTAMP + 1))
echo "NEW_TIMESTAMP=$NEW_TIMESTAMP" >> $GITHUB_ENV
- name: Setup yq
uses: mikefarah/yq@master
# Configure AWS credentials for the first registry
- name: Configure AWS credentials for RELEASE_ARM_REGISTRY
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.AWS_DEPOT_ACCESS_KEY }}
aws-secret-access-key: ${{ secrets.AWS_DEPOT_SECRET_KEY }}
aws-region: ${{ secrets.AWS_DEPOT_DEFAULT_REGION }}
- name: Login to Amazon ECR for RELEASE_ARM_REGISTRY
id: login-ecr-arm
run: |
aws ecr get-login-password --region ${{ secrets.AWS_DEPOT_DEFAULT_REGION }} | docker login --username AWS --password-stdin ${{ secrets.RELEASE_ARM_REGISTRY }}
aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin ${{ secrets.RELEASE_OSS_REGISTRY }}
- uses: depot/setup-action@v1
- name: Get HEAD Commit ID
run: echo "HEAD_COMMIT_ID=$(git rev-parse HEAD)" >> $GITHUB_ENV
- name: Define Branch Name
run: echo "BRANCH_NAME=${{inputs.branch}}" >> $GITHUB_ENV
- name: Build
id: build-image
env:
DOCKER_REPO_ARM: ${{ secrets.RELEASE_ARM_REGISTRY }}
DOCKER_REPO_OSS: ${{ secrets.RELEASE_OSS_REGISTRY }}
MSAAS_REPO_CLONE_TOKEN: ${{ secrets.MSAAS_REPO_CLONE_TOKEN }}
MSAAS_REPO_URL: ${{ secrets.MSAAS_REPO_URL }}
MSAAS_REPO_FOLDER: /tmp/msaas
run: |
set -exo pipefail
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git checkout -b $BRANCH_NAME
working_dir=$(pwd)
function image_version(){
local service=$1
chart_path="$working_dir/scripts/helmcharts/openreplay/charts/$service/Chart.yaml"
current_version=$(yq eval '.AppVersion' $chart_path)
new_version=$(echo $current_version | awk -F. '{$NF += 1 ; print $1"."$2"."$3}')
echo $new_version
# yq eval ".AppVersion = \"$new_version\"" -i $chart_path
}
function clone_msaas() {
[ -d $MSAAS_REPO_FOLDER ] || {
git clone -b $INPUT_TAG --recursive https://x-access-token:$MSAAS_REPO_CLONE_TOKEN@$MSAAS_REPO_URL $MSAAS_REPO_FOLDER
cd $MSAAS_REPO_FOLDER
cd openreplay && git fetch origin && git checkout $INPUT_TAG
git log -1
cd $MSAAS_REPO_FOLDER
bash git-init.sh
git checkout
}
}
function build_managed() {
local service=$1
local version=$2
echo building managed
clone_msaas
if [[ $service == 'chalice' ]]; then
cd $MSAAS_REPO_FOLDER/openreplay/api
else
cd $MSAAS_REPO_FOLDER/openreplay/$service
fi
IMAGE_TAG=$version DOCKER_RUNTIME="depot" DOCKER_BUILD_ARGS="--push" ARCH=arm64 DOCKER_REPO=$DOCKER_REPO_ARM PUSH_IMAGE=0 bash build.sh >> /tmp/arm.txt
}
# Checking for backend images
ls backend/cmd >> /tmp/backend.txt
echo Services: "${{ github.event.inputs.services }}"
IFS=',' read -ra SERVICES <<< "${{ github.event.inputs.services }}"
BUILD_SCRIPT_NAME="build.sh"
# Build FOSS
for SERVICE in "${SERVICES[@]}"; do
# Check if service is backend
if grep -q $SERVICE /tmp/backend.txt; then
cd backend
foss_build_args="nil $SERVICE"
ee_build_args="ee $SERVICE"
else
[[ $SERVICE == 'chalice' || $SERVICE == 'alerts' || $SERVICE == 'crons' ]] && cd $working_dir/api || cd $SERVICE
[[ $SERVICE == 'alerts' || $SERVICE == 'crons' ]] && BUILD_SCRIPT_NAME="build_${SERVICE}.sh"
ee_build_args="ee"
fi
version=$(image_version $SERVICE)
echo IMAGE_TAG=$version DOCKER_RUNTIME="depot" DOCKER_BUILD_ARGS="--push" ARCH=amd64 DOCKER_REPO=$DOCKER_REPO_OSS PUSH_IMAGE=0 bash ${BUILD_SCRIPT_NAME} $foss_build_args
IMAGE_TAG=$version DOCKER_RUNTIME="depot" DOCKER_BUILD_ARGS="--push" ARCH=amd64 DOCKER_REPO=$DOCKER_REPO_OSS PUSH_IMAGE=0 bash ${BUILD_SCRIPT_NAME} $foss_build_args
echo IMAGE_TAG=$version-ee DOCKER_RUNTIME="depot" DOCKER_BUILD_ARGS="--push" ARCH=amd64 DOCKER_REPO=$DOCKER_REPO_OSS PUSH_IMAGE=0 bash ${BUILD_SCRIPT_NAME} $ee_build_args
IMAGE_TAG=$version-ee DOCKER_RUNTIME="depot" DOCKER_BUILD_ARGS="--push" ARCH=amd64 DOCKER_REPO=$DOCKER_REPO_OSS PUSH_IMAGE=0 bash ${BUILD_SCRIPT_NAME} $ee_build_args
if [[ "$SERVICE" != "chalice" && "$SERVICE" != "frontend" ]]; then
IMAGE_TAG=$version DOCKER_RUNTIME="depot" DOCKER_BUILD_ARGS="--push" ARCH=arm64 DOCKER_REPO=$DOCKER_REPO_ARM PUSH_IMAGE=0 bash ${BUILD_SCRIPT_NAME} $foss_build_args
echo IMAGE_TAG=$version DOCKER_RUNTIME="depot" DOCKER_BUILD_ARGS="--push" ARCH=arm64 DOCKER_REPO=$DOCKER_REPO_ARM PUSH_IMAGE=0 bash ${BUILD_SCRIPT_NAME} $foss_build_args
else
build_managed $SERVICE $version
fi
cd $working_dir
chart_path="$working_dir/scripts/helmcharts/openreplay/charts/$SERVICE/Chart.yaml"
yq eval ".AppVersion = \"$version\"" -i $chart_path
git add $chart_path
git commit -m "Increment $SERVICE chart version"
done
- name: Change commit timestamp
run: |
# Convert the timestamp to a date format git can understand
NEW_DATE=$(perl -le 'print scalar gmtime($ARGV[0])." +0000"' $NEW_TIMESTAMP)
echo "Setting commit date to: $NEW_DATE"
# Amend the commit with the new date
GIT_COMMITTER_DATE="$NEW_DATE" git commit --amend --no-edit --date="$NEW_DATE"
# Verify the change
git log -1 --pretty=format:"Commit now dated: %cD"
# git tag and push
git tag $INPUT_TAG -f
git push origin $INPUT_TAG -f
# - name: Debug Job
# if: ${{ failure() }}
# uses: mxschmitt/action-tmate@v3
# env:
# DOCKER_REPO_ARM: ${{ secrets.RELEASE_ARM_REGISTRY }}
# DOCKER_REPO_OSS: ${{ secrets.RELEASE_OSS_REGISTRY }}
# MSAAS_REPO_CLONE_TOKEN: ${{ secrets.MSAAS_REPO_CLONE_TOKEN }}
# MSAAS_REPO_URL: ${{ secrets.MSAAS_REPO_URL }}
# MSAAS_REPO_FOLDER: /tmp/msaas
# with:
# limit-access-to-actor: true

View file

@ -2,6 +2,7 @@
on: on:
workflow_dispatch: workflow_dispatch:
description: 'This workflow will build for patches for latest tag, and will Always use commit from main branch.'
inputs: inputs:
services: services:
description: 'Comma separated names of services to build(in small letters).' description: 'Comma separated names of services to build(in small letters).'
@ -19,20 +20,12 @@ jobs:
DEPOT_PROJECT_ID: ${{ secrets.DEPOT_PROJECT_ID }} DEPOT_PROJECT_ID: ${{ secrets.DEPOT_PROJECT_ID }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v2
with: with:
fetch-depth: 0 fetch-depth: 1
token: ${{ secrets.GITHUB_TOKEN }}
- name: Rebase with main branch, to make sure the code has latest main changes - name: Rebase with main branch, to make sure the code has latest main changes
if: github.ref != 'refs/heads/main'
run: | run: |
git remote -v git pull --rebase origin main
git config --global user.email "action@github.com"
git config --global user.name "GitHub Action"
git config --global rebase.autoStash true
git fetch origin main:main
git rebase main
git log -3
- name: Downloading yq - name: Downloading yq
run: | run: |
@ -55,8 +48,6 @@ jobs:
aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin ${{ secrets.RELEASE_OSS_REGISTRY }} aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin ${{ secrets.RELEASE_OSS_REGISTRY }}
- uses: depot/setup-action@v1 - uses: depot/setup-action@v1
env:
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
- name: Get HEAD Commit ID - name: Get HEAD Commit ID
run: echo "HEAD_COMMIT_ID=$(git rev-parse HEAD)" >> $GITHUB_ENV run: echo "HEAD_COMMIT_ID=$(git rev-parse HEAD)" >> $GITHUB_ENV
- name: Define Branch Name - name: Define Branch Name
@ -74,168 +65,78 @@ jobs:
MSAAS_REPO_CLONE_TOKEN: ${{ secrets.MSAAS_REPO_CLONE_TOKEN }} MSAAS_REPO_CLONE_TOKEN: ${{ secrets.MSAAS_REPO_CLONE_TOKEN }}
MSAAS_REPO_URL: ${{ secrets.MSAAS_REPO_URL }} MSAAS_REPO_URL: ${{ secrets.MSAAS_REPO_URL }}
MSAAS_REPO_FOLDER: /tmp/msaas MSAAS_REPO_FOLDER: /tmp/msaas
SERVICES_INPUT: ${{ github.event.inputs.services }}
run: | run: |
#!/bin/bash set -exo pipefail
set -euo pipefail git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
# Configuration git checkout -b $BRANCH_NAME
readonly WORKING_DIR=$(pwd) working_dir=$(pwd)
readonly BUILD_SCRIPT_NAME="build.sh" function image_version(){
readonly BACKEND_SERVICES_FILE="/tmp/backend.txt" local service=$1
chart_path="$working_dir/scripts/helmcharts/openreplay/charts/$service/Chart.yaml"
# Initialize git configuration current_version=$(yq eval '.AppVersion' $chart_path)
setup_git() { new_version=$(echo $current_version | awk -F. '{$NF += 1 ; print $1"."$2"."$3}')
git config --local user.email "action@github.com" echo $new_version
git config --local user.name "GitHub Action" # yq eval ".AppVersion = \"$new_version\"" -i $chart_path
git checkout -b "$BRANCH_NAME"
} }
function clone_msaas() {
# Get and increment image version [ -d $MSAAS_REPO_FOLDER ] || {
image_version() { git clone -b dev --recursive https://x-access-token:$MSAAS_REPO_CLONE_TOKEN@$MSAAS_REPO_URL $MSAAS_REPO_FOLDER
local service=$1 cd $MSAAS_REPO_FOLDER
local chart_path="$WORKING_DIR/scripts/helmcharts/openreplay/charts/$service/Chart.yaml" cd openreplay && git fetch origin && git checkout main # This have to be changed to specific tag
local current_version new_version git log -1
cd $MSAAS_REPO_FOLDER
current_version=$(yq eval '.AppVersion' "$chart_path") bash git-init.sh
new_version=$(echo "$current_version" | awk -F. '{$NF += 1; print $1"."$2"."$3}') git checkout
echo "$new_version" }
} }
function build_managed() {
# Clone MSAAS repository if not exists local service=$1
clone_msaas() { local version=$2
if [[ ! -d "$MSAAS_REPO_FOLDER" ]]; then echo building managed
git clone -b dev --recursive "https://x-access-token:${MSAAS_REPO_CLONE_TOKEN}@${MSAAS_REPO_URL}" "$MSAAS_REPO_FOLDER" clone_msaas
cd "$MSAAS_REPO_FOLDER" if [[ $service == 'chalice' ]]; then
cd openreplay && git fetch origin && git checkout main cd $MSAAS_REPO_FOLDER/openreplay/api
git log -1 else
cd "$MSAAS_REPO_FOLDER" cd $MSAAS_REPO_FOLDER/openreplay/$service
bash git-init.sh fi
git checkout IMAGE_TAG=$version DOCKER_RUNTIME="depot" DOCKER_BUILD_ARGS="--push" ARCH=arm64 DOCKER_REPO=$DOCKER_REPO_ARM PUSH_IMAGE=0 bash build.sh >> /tmp/arm.txt
fi
} }
# Checking for backend images
# Build managed services ls backend/cmd >> /tmp/backend.txt
build_managed() { echo Services: "${{ github.event.inputs.services }}"
local service=$1 IFS=',' read -ra SERVICES <<< "${{ github.event.inputs.services }}"
local version=$2 BUILD_SCRIPT_NAME="build.sh"
# Build FOSS
echo "Building managed service: $service" for SERVICE in "${SERVICES[@]}"; do
clone_msaas # Check if service is backend
if grep -q $SERVICE /tmp/backend.txt; then
if [[ $service == 'chalice' ]]; then cd backend
cd "$MSAAS_REPO_FOLDER/openreplay/api" foss_build_args="nil $SERVICE"
else ee_build_args="ee $SERVICE"
cd "$MSAAS_REPO_FOLDER/openreplay/$service" else
fi [[ $SERVICE == 'chalice' || $SERVICE == 'alerts' || $SERVICE == 'crons' ]] && cd $working_dir/api || cd $SERVICE
[[ $SERVICE == 'alerts' || $SERVICE == 'crons' ]] && BUILD_SCRIPT_NAME="build_${SERVICE}.sh"
local build_cmd="IMAGE_TAG=$version DOCKER_RUNTIME=depot DOCKER_BUILD_ARGS=--push ARCH=arm64 DOCKER_REPO=$DOCKER_REPO_ARM PUSH_IMAGE=0 bash build.sh" ee_build_args="ee"
fi
echo "Executing: $build_cmd" version=$(image_version $SERVICE)
if ! eval "$build_cmd" 2>&1; then echo IMAGE_TAG=$version DOCKER_RUNTIME="depot" DOCKER_BUILD_ARGS="--push" ARCH=amd64 DOCKER_REPO=$DOCKER_REPO_OSS PUSH_IMAGE=0 bash ${BUILD_SCRIPT_NAME} $foss_build_args
echo "Build failed for $service" IMAGE_TAG=$version DOCKER_RUNTIME="depot" DOCKER_BUILD_ARGS="--push" ARCH=amd64 DOCKER_REPO=$DOCKER_REPO_OSS PUSH_IMAGE=0 bash ${BUILD_SCRIPT_NAME} $foss_build_args
exit 1 echo IMAGE_TAG=$version-ee DOCKER_RUNTIME="depot" DOCKER_BUILD_ARGS="--push" ARCH=amd64 DOCKER_REPO=$DOCKER_REPO_OSS PUSH_IMAGE=0 bash ${BUILD_SCRIPT_NAME} $ee_build_args
fi IMAGE_TAG=$version-ee DOCKER_RUNTIME="depot" DOCKER_BUILD_ARGS="--push" ARCH=amd64 DOCKER_REPO=$DOCKER_REPO_OSS PUSH_IMAGE=0 bash ${BUILD_SCRIPT_NAME} $ee_build_args
} if [[ "$SERVICE" != "chalice" && "$SERVICE" != "frontend" ]]; then
IMAGE_TAG=$version DOCKER_RUNTIME="depot" DOCKER_BUILD_ARGS="--push" ARCH=arm64 DOCKER_REPO=$DOCKER_REPO_ARM PUSH_IMAGE=0 bash ${BUILD_SCRIPT_NAME} $foss_build_args
# Build service with given arguments echo IMAGE_TAG=$version DOCKER_RUNTIME="depot" DOCKER_BUILD_ARGS="--push" ARCH=arm64 DOCKER_REPO=$DOCKER_REPO_ARM PUSH_IMAGE=0 bash ${BUILD_SCRIPT_NAME} $foss_build_args
build_service() { else
local service=$1 build_managed $SERVICE $version
local version=$2 fi
local build_args=$3 cd $working_dir
local build_script=${4:-$BUILD_SCRIPT_NAME} chart_path="$working_dir/scripts/helmcharts/openreplay/charts/$SERVICE/Chart.yaml"
yq eval ".AppVersion = \"$version\"" -i $chart_path
local command="IMAGE_TAG=$version DOCKER_RUNTIME=depot DOCKER_BUILD_ARGS=--push ARCH=amd64 DOCKER_REPO=$DOCKER_REPO_OSS PUSH_IMAGE=0 bash $build_script $build_args" git add $chart_path
echo "Executing: $command" git commit -m "Increment $SERVICE chart version"
eval "$command" git push --set-upstream origin $BRANCH_NAME
} done
# Update chart version and commit changes
update_chart_version() {
local service=$1
local version=$2
local chart_path="$WORKING_DIR/scripts/helmcharts/openreplay/charts/$service/Chart.yaml"
# Ensure we're in the original working directory/repository
cd "$WORKING_DIR"
yq eval ".AppVersion = \"$version\"" -i "$chart_path"
git add "$chart_path"
git commit -m "Increment $service chart version to $version"
git push --set-upstream origin "$BRANCH_NAME"
cd -
}
# Main execution
main() {
setup_git
# Get backend services list
ls backend/cmd >"$BACKEND_SERVICES_FILE"
# Parse services input (fix for GitHub Actions syntax)
echo "Services: ${SERVICES_INPUT:-$1}"
IFS=',' read -ra services <<<"${SERVICES_INPUT:-$1}"
# Process each service
for service in "${services[@]}"; do
echo "Processing service: $service"
cd "$WORKING_DIR"
local foss_build_args="" ee_build_args="" build_script="$BUILD_SCRIPT_NAME"
# Determine build configuration based on service type
if grep -q "$service" "$BACKEND_SERVICES_FILE"; then
# Backend service
cd backend
foss_build_args="nil $service"
ee_build_args="ee $service"
else
# Non-backend service
case "$service" in
chalice | alerts | crons)
cd "$WORKING_DIR/api"
;;
*)
cd "$service"
;;
esac
# Special build scripts for alerts/crons
if [[ $service == 'alerts' || $service == 'crons' ]]; then
build_script="build_${service}.sh"
fi
ee_build_args="ee"
fi
# Get version and build
local version
version=$(image_version "$service")
# Build FOSS and EE versions
build_service "$service" "$version" "$foss_build_args"
build_service "$service" "${version}-ee" "$ee_build_args"
# Build managed version for specific services
if [[ "$service" != "chalice" && "$service" != "frontend" ]]; then
echo "Nothing to build in managed for service $service"
else
build_managed "$service" "$version"
fi
# Update chart and commit
update_chart_version "$service" "$version"
done
cd "$WORKING_DIR"
# Cleanup
rm -f "$BACKEND_SERVICES_FILE"
}
echo "Working directory: $WORKING_DIR"
# Run main function with all arguments
main "$SERVICES_INPUT"
- name: Create Pull Request - name: Create Pull Request
uses: repo-sync/pull-request@v2 uses: repo-sync/pull-request@v2
@ -246,7 +147,8 @@ jobs:
pr_title: "Updated patch build from main ${{ env.HEAD_COMMIT_ID }}" pr_title: "Updated patch build from main ${{ env.HEAD_COMMIT_ID }}"
pr_body: | pr_body: |
This PR updates the Helm chart version after building the patch from $HEAD_COMMIT_ID. This PR updates the Helm chart version after building the patch from $HEAD_COMMIT_ID.
Once this PR is merged, tag update job will run automatically. Once this PR is merged, To update the latest tag, run the following workflow.
https://github.com/openreplay/openreplay/actions/workflows/update-tag.yaml
# - name: Debug Job # - name: Debug Job
# if: ${{ failure() }} # if: ${{ failure() }}

View file

@ -1,42 +1,35 @@
on: on:
pull_request: workflow_dispatch:
types: [closed] description: "This workflow will build for patches for latest tag, and will Always use commit from main branch."
branches: inputs:
- main services:
name: Release tag update --force description: "This action will update the latest tag with current main branch HEAD. Should I proceed ? true/false"
required: true
default: "false"
name: Force Push tag with main branch HEAD
jobs: jobs:
deploy: deploy:
name: Build Patch from main name: Build Patch from main
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ (github.event_name == 'pull_request' && github.event.pull_request.merged == true) || github.event.inputs.services == 'true' }} env:
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
DEPOT_PROJECT_ID: ${{ secrets.DEPOT_PROJECT_ID }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v2 uses: actions/checkout@v2
- name: Get latest release tag using GitHub API
id: get-latest-tag
run: |
LATEST_TAG=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"https://api.github.com/repos/${{ github.repository }}/releases/latest" \
| jq -r .tag_name)
# Fallback to git command if API doesn't return a tag
if [ "$LATEST_TAG" == "null" ] || [ -z "$LATEST_TAG" ]; then
echo "Not found latest tag"
exit 100
fi
echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_ENV
echo "Latest tag: $LATEST_TAG"
- name: Set Remote with GITHUB_TOKEN - name: Set Remote with GITHUB_TOKEN
run: | run: |
git config --unset http.https://github.com/.extraheader git config --unset http.https://github.com/.extraheader
git remote set-url origin https://x-access-token:${{ secrets.ACTIONS_COMMMIT_TOKEN }}@github.com/${{ github.repository }} git remote set-url origin https://x-access-token:${{ secrets.ACTIONS_COMMMIT_TOKEN }}@github.com/${{ github.repository }}.git
- name: Push main branch to tag - name: Push main branch to tag
run: | run: |
git fetch --tags
git checkout main git checkout main
echo "Updating tag ${{ env.LATEST_TAG }} to point to latest commit on main" git push origin HEAD:refs/tags/$(git tag --list 'v[0-9]*' --sort=-v:refname | head -n 1) --force
git push origin HEAD:refs/tags/${{ env.LATEST_TAG }} --force # - name: Debug Job
# if: ${{ failure() }}
# uses: mxschmitt/action-tmate@v3
# with:
# limit-access-to-actor: true

View file

@ -85,8 +85,7 @@ def __generic_query(typename, value_length=None):
ORDER BY value""" ORDER BY value"""
if value_length is None or value_length > 2: if value_length is None or value_length > 2:
return f"""SELECT DISTINCT ON(value,type) value, type return f"""(SELECT DISTINCT value, type
((SELECT DISTINCT value, type
FROM {TABLE} FROM {TABLE}
WHERE WHERE
project_id = %(project_id)s project_id = %(project_id)s
@ -102,7 +101,7 @@ def __generic_query(typename, value_length=None):
AND type='{typename.upper()}' AND type='{typename.upper()}'
AND value ILIKE %(value)s AND value ILIKE %(value)s
ORDER BY value ORDER BY value
LIMIT 5)) AS raw;""" LIMIT 5);"""
return f"""SELECT DISTINCT value, type return f"""SELECT DISTINCT value, type
FROM {TABLE} FROM {TABLE}
WHERE WHERE
@ -327,7 +326,7 @@ def __search_metadata(project_id, value, key=None, source=None):
AND {colname} ILIKE %(svalue)s LIMIT 5)""") AND {colname} ILIKE %(svalue)s LIMIT 5)""")
with pg_client.PostgresClient() as cur: with pg_client.PostgresClient() as cur:
cur.execute(cur.mogrify(f"""\ cur.execute(cur.mogrify(f"""\
SELECT DISTINCT ON(key, value) key, value, 'METADATA' AS TYPE SELECT key, value, 'METADATA' AS TYPE
FROM({" UNION ALL ".join(sub_from)}) AS all_metas FROM({" UNION ALL ".join(sub_from)}) AS all_metas
LIMIT 5;""", {"project_id": project_id, "value": helper.string_to_sql_like(value), LIMIT 5;""", {"project_id": project_id, "value": helper.string_to_sql_like(value),
"svalue": helper.string_to_sql_like("^" + value)})) "svalue": helper.string_to_sql_like("^" + value)}))

View file

@ -338,14 +338,14 @@ def search(data: schemas.SearchErrorsSchema, project: schemas.ProjectContext, us
SELECT details.error_id as error_id, SELECT details.error_id as error_id,
name, message, users, total, name, message, users, total,
sessions, last_occurrence, first_occurrence, chart sessions, last_occurrence, first_occurrence, chart
FROM (SELECT error_id, FROM (SELECT JSONExtractString(toString(`$properties`), 'error_id') AS error_id,
JSONExtractString(toString(`$properties`), 'name') AS name, JSONExtractString(toString(`$properties`), 'name') AS name,
JSONExtractString(toString(`$properties`), 'message') AS message, JSONExtractString(toString(`$properties`), 'message') AS message,
COUNT(DISTINCT user_id) AS users, COUNT(DISTINCT user_id) AS users,
COUNT(DISTINCT events.session_id) AS sessions, COUNT(DISTINCT events.session_id) AS sessions,
MAX(created_at) AS max_datetime, MAX(created_at) AS max_datetime,
MIN(created_at) AS min_datetime, MIN(created_at) AS min_datetime,
COUNT(DISTINCT error_id) COUNT(DISTINCT JSONExtractString(toString(`$properties`), 'error_id'))
OVER() AS total OVER() AS total
FROM {MAIN_EVENTS_TABLE} AS events FROM {MAIN_EVENTS_TABLE} AS events
INNER JOIN (SELECT session_id, coalesce(user_id,toString(user_uuid)) AS user_id INNER JOIN (SELECT session_id, coalesce(user_id,toString(user_uuid)) AS user_id
@ -357,7 +357,7 @@ def search(data: schemas.SearchErrorsSchema, project: schemas.ProjectContext, us
GROUP BY error_id, name, message GROUP BY error_id, name, message
ORDER BY {sort} {order} ORDER BY {sort} {order}
LIMIT %(errors_limit)s OFFSET %(errors_offset)s) AS details LIMIT %(errors_limit)s OFFSET %(errors_offset)s) AS details
INNER JOIN (SELECT error_id, INNER JOIN (SELECT JSONExtractString(toString(`$properties`), 'error_id') AS error_id,
toUnixTimestamp(MAX(created_at))*1000 AS last_occurrence, toUnixTimestamp(MAX(created_at))*1000 AS last_occurrence,
toUnixTimestamp(MIN(created_at))*1000 AS first_occurrence toUnixTimestamp(MIN(created_at))*1000 AS first_occurrence
FROM {MAIN_EVENTS_TABLE} FROM {MAIN_EVENTS_TABLE}
@ -366,7 +366,7 @@ def search(data: schemas.SearchErrorsSchema, project: schemas.ProjectContext, us
GROUP BY error_id) AS time_details GROUP BY error_id) AS time_details
ON details.error_id=time_details.error_id ON details.error_id=time_details.error_id
INNER JOIN (SELECT error_id, groupArray([timestamp, count]) AS chart INNER JOIN (SELECT error_id, groupArray([timestamp, count]) AS chart
FROM (SELECT error_id, FROM (SELECT JSONExtractString(toString(`$properties`), 'error_id') AS error_id,
gs.generate_series AS timestamp, gs.generate_series AS timestamp,
COUNT(DISTINCT session_id) AS count COUNT(DISTINCT session_id) AS count
FROM generate_series(%(startDate)s, %(endDate)s, %(step_size)s) AS gs FROM generate_series(%(startDate)s, %(endDate)s, %(step_size)s) AS gs

View file

@ -50,8 +50,8 @@ class JIRAIntegration(base.BaseIntegration):
cur.execute( cur.execute(
cur.mogrify( cur.mogrify(
"""SELECT username, token, url """SELECT username, token, url
FROM public.jira_cloud FROM public.jira_cloud
WHERE user_id = %(user_id)s;""", WHERE user_id=%(user_id)s;""",
{"user_id": self._user_id}) {"user_id": self._user_id})
) )
data = helper.dict_to_camel_case(cur.fetchone()) data = helper.dict_to_camel_case(cur.fetchone())
@ -95,9 +95,10 @@ class JIRAIntegration(base.BaseIntegration):
def add(self, username, token, url, obfuscate=False): def add(self, username, token, url, obfuscate=False):
with pg_client.PostgresClient() as cur: with pg_client.PostgresClient() as cur:
cur.execute( cur.execute(
cur.mogrify(""" \ cur.mogrify("""\
INSERT INTO public.jira_cloud(username, token, user_id, url) INSERT INTO public.jira_cloud(username, token, user_id,url)
VALUES (%(username)s, %(token)s, %(user_id)s, %(url)s) RETURNING username, token, url;""", VALUES (%(username)s, %(token)s, %(user_id)s,%(url)s)
RETURNING username, token, url;""",
{"user_id": self._user_id, "username": username, {"user_id": self._user_id, "username": username,
"token": token, "url": url}) "token": token, "url": url})
) )
@ -111,10 +112,9 @@ class JIRAIntegration(base.BaseIntegration):
def delete(self): def delete(self):
with pg_client.PostgresClient() as cur: with pg_client.PostgresClient() as cur:
cur.execute( cur.execute(
cur.mogrify(""" \ cur.mogrify("""\
DELETE DELETE FROM public.jira_cloud
FROM public.jira_cloud WHERE user_id=%(user_id)s;""",
WHERE user_id = %(user_id)s;""",
{"user_id": self._user_id}) {"user_id": self._user_id})
) )
return {"state": "success"} return {"state": "success"}
@ -125,7 +125,7 @@ class JIRAIntegration(base.BaseIntegration):
changes={ changes={
"username": data.username, "username": data.username,
"token": data.token if len(data.token) > 0 and data.token.find("***") == -1 \ "token": data.token if len(data.token) > 0 and data.token.find("***") == -1 \
else self.integration["token"], else self.integration.token,
"url": str(data.url) "url": str(data.url)
}, },
obfuscate=True obfuscate=True

View file

@ -85,9 +85,6 @@ def __complete_missing_steps(start_time, end_time, density, neutral, rows, time_
# compute avg_time_from_previous at the same level as sessions_count (this was removed in v1.22) # compute avg_time_from_previous at the same level as sessions_count (this was removed in v1.22)
# if start-point is selected, the selected event is ranked n°1 # if start-point is selected, the selected event is ranked n°1
def path_analysis(project_id: int, data: schemas.CardPathAnalysis): def path_analysis(project_id: int, data: schemas.CardPathAnalysis):
if not data.hide_excess:
data.hide_excess = True
data.rows = 50
sub_events = [] sub_events = []
start_points_conditions = [] start_points_conditions = []
step_0_conditions = [] step_0_conditions = []

View file

@ -153,7 +153,7 @@ def search2_table(data: schemas.SessionsSearchPayloadSchema, project_id: int, de
"isEvent": True, "isEvent": True,
"value": [], "value": [],
"operator": e.operator, "operator": e.operator,
"filters": e.filters "filters": []
}) })
for v in e.value: for v in e.value:
if v not in extra_conditions[e.operator].value: if v not in extra_conditions[e.operator].value:
@ -178,7 +178,7 @@ def search2_table(data: schemas.SessionsSearchPayloadSchema, project_id: int, de
"isEvent": True, "isEvent": True,
"value": [], "value": [],
"operator": e.operator, "operator": e.operator,
"filters": e.filters "filters": []
}) })
for v in e.value: for v in e.value:
if v not in extra_conditions[e.operator].value: if v not in extra_conditions[e.operator].value:
@ -1108,12 +1108,8 @@ def search_query_parts_ch(data: schemas.SessionsSearchPayloadSchema, error_statu
is_any = sh.isAny_opreator(f.operator) is_any = sh.isAny_opreator(f.operator)
if is_any or len(f.value) == 0: if is_any or len(f.value) == 0:
continue continue
is_negative_operator = sh.is_negation_operator(f.operator)
f.value = helper.values_for_operator(value=f.value, op=f.operator) f.value = helper.values_for_operator(value=f.value, op=f.operator)
op = sh.get_sql_operator(f.operator) op = sh.get_sql_operator(f.operator)
r_op = ""
if is_negative_operator:
r_op = sh.reverse_sql_operator(op)
e_k_f = e_k + f"_fetch{j}" e_k_f = e_k + f"_fetch{j}"
full_args = {**full_args, **sh.multi_values(f.value, value_key=e_k_f)} full_args = {**full_args, **sh.multi_values(f.value, value_key=e_k_f)}
if f.type == schemas.FetchFilterType.FETCH_URL: if f.type == schemas.FetchFilterType.FETCH_URL:
@ -1122,12 +1118,6 @@ def search_query_parts_ch(data: schemas.SessionsSearchPayloadSchema, error_statu
)) ))
events_conditions[-1]["condition"].append(event_where[-1]) events_conditions[-1]["condition"].append(event_where[-1])
apply = True apply = True
if is_negative_operator:
events_conditions_not.append(
{
"type": f"sub.`$event_name`='{exp_ch_helper.get_event_type(event_type, platform=platform)}'"})
events_conditions_not[-1]["condition"] = sh.multi_conditions(
f"sub.`$properties`.url_path {r_op} %({e_k_f})s", f.value, value_key=e_k_f)
elif f.type == schemas.FetchFilterType.FETCH_STATUS_CODE: elif f.type == schemas.FetchFilterType.FETCH_STATUS_CODE:
event_where.append(json_condition( event_where.append(json_condition(
"main", "$properties", 'status', op, f.value, e_k_f, True, True "main", "$properties", 'status', op, f.value, e_k_f, True, True
@ -1140,13 +1130,6 @@ def search_query_parts_ch(data: schemas.SessionsSearchPayloadSchema, error_statu
)) ))
events_conditions[-1]["condition"].append(event_where[-1]) events_conditions[-1]["condition"].append(event_where[-1])
apply = True apply = True
if is_negative_operator:
events_conditions_not.append(
{
"type": f"sub.`$event_name`='{exp_ch_helper.get_event_type(event_type, platform=platform)}'"})
events_conditions_not[-1]["condition"] = sh.multi_conditions(
f"sub.`$properties`.method {r_op} %({e_k_f})s", f.value,
value_key=e_k_f)
elif f.type == schemas.FetchFilterType.FETCH_DURATION: elif f.type == schemas.FetchFilterType.FETCH_DURATION:
event_where.append( event_where.append(
sh.multi_conditions(f"main.`$duration_s` {f.operator} %({e_k_f})s/1000", f.value, sh.multi_conditions(f"main.`$duration_s` {f.operator} %({e_k_f})s/1000", f.value,
@ -1159,26 +1142,12 @@ def search_query_parts_ch(data: schemas.SessionsSearchPayloadSchema, error_statu
)) ))
events_conditions[-1]["condition"].append(event_where[-1]) events_conditions[-1]["condition"].append(event_where[-1])
apply = True apply = True
if is_negative_operator:
events_conditions_not.append(
{
"type": f"sub.`$event_name`='{exp_ch_helper.get_event_type(event_type, platform=platform)}'"})
events_conditions_not[-1]["condition"] = sh.multi_conditions(
f"sub.`$properties`.request_body {r_op} %({e_k_f})s", f.value,
value_key=e_k_f)
elif f.type == schemas.FetchFilterType.FETCH_RESPONSE_BODY: elif f.type == schemas.FetchFilterType.FETCH_RESPONSE_BODY:
event_where.append(json_condition( event_where.append(json_condition(
"main", "$properties", 'response_body', op, f.value, e_k_f "main", "$properties", 'response_body', op, f.value, e_k_f
)) ))
events_conditions[-1]["condition"].append(event_where[-1]) events_conditions[-1]["condition"].append(event_where[-1])
apply = True apply = True
if is_negative_operator:
events_conditions_not.append(
{
"type": f"sub.`$event_name`='{exp_ch_helper.get_event_type(event_type, platform=platform)}'"})
events_conditions_not[-1]["condition"] = sh.multi_conditions(
f"sub.`$properties`.response_body {r_op} %({e_k_f})s", f.value,
value_key=e_k_f)
else: else:
logging.warning(f"undefined FETCH filter: {f.type}") logging.warning(f"undefined FETCH filter: {f.type}")
if not apply: if not apply:
@ -1426,30 +1395,17 @@ def search_query_parts_ch(data: schemas.SessionsSearchPayloadSchema, error_statu
if extra_conditions and len(extra_conditions) > 0: if extra_conditions and len(extra_conditions) > 0:
_extra_or_condition = [] _extra_or_condition = []
for i, c in enumerate(extra_conditions): for i, c in enumerate(extra_conditions):
if sh.isAny_opreator(c.operator) and c.type != schemas.EventType.REQUEST_DETAILS.value: if sh.isAny_opreator(c.operator):
continue continue
e_k = f"ec_value{i}" e_k = f"ec_value{i}"
op = sh.get_sql_operator(c.operator) op = sh.get_sql_operator(c.operator)
c.value = helper.values_for_operator(value=c.value, op=c.operator) c.value = helper.values_for_operator(value=c.value, op=c.operator)
full_args = {**full_args, full_args = {**full_args,
**sh.multi_values(c.value, value_key=e_k)} **sh.multi_values(c.value, value_key=e_k)}
if c.type in (schemas.EventType.LOCATION.value, schemas.EventType.REQUEST.value): if c.type == events.EventType.LOCATION.ui_type:
_extra_or_condition.append( _extra_or_condition.append(
sh.multi_conditions(f"extra_event.url_path {op} %({e_k})s", sh.multi_conditions(f"extra_event.url_path {op} %({e_k})s",
c.value, value_key=e_k)) c.value, value_key=e_k))
elif c.type == schemas.EventType.REQUEST_DETAILS.value:
for j, c_f in enumerate(c.filters):
if sh.isAny_opreator(c_f.operator) or len(c_f.value) == 0:
continue
e_k += f"_{j}"
op = sh.get_sql_operator(c_f.operator)
c_f.value = helper.values_for_operator(value=c_f.value, op=c_f.operator)
full_args = {**full_args,
**sh.multi_values(c_f.value, value_key=e_k)}
if c_f.type == schemas.FetchFilterType.FETCH_URL.value:
_extra_or_condition.append(
sh.multi_conditions(f"extra_event.url_path {op} %({e_k})s",
c_f.value, value_key=e_k))
else: else:
logging.warning(f"unsupported extra_event type:${c.type}") logging.warning(f"unsupported extra_event type:${c.type}")
if len(_extra_or_condition) > 0: if len(_extra_or_condition) > 0:

View file

@ -148,7 +148,7 @@ def search2_table(data: schemas.SessionsSearchPayloadSchema, project_id: int, de
"isEvent": True, "isEvent": True,
"value": [], "value": [],
"operator": e.operator, "operator": e.operator,
"filters": e.filters "filters": []
}) })
for v in e.value: for v in e.value:
if v not in extra_conditions[e.operator].value: if v not in extra_conditions[e.operator].value:
@ -165,7 +165,7 @@ def search2_table(data: schemas.SessionsSearchPayloadSchema, project_id: int, de
"isEvent": True, "isEvent": True,
"value": [], "value": [],
"operator": e.operator, "operator": e.operator,
"filters": e.filters "filters": []
}) })
for v in e.value: for v in e.value:
if v not in extra_conditions[e.operator].value: if v not in extra_conditions[e.operator].value:
@ -989,7 +989,7 @@ def search_query_parts(data: schemas.SessionsSearchPayloadSchema, error_status,
sh.multi_conditions(f"ev.{events.EventType.LOCATION.column} {op} %({e_k})s", sh.multi_conditions(f"ev.{events.EventType.LOCATION.column} {op} %({e_k})s",
c.value, value_key=e_k)) c.value, value_key=e_k))
else: else:
logger.warning(f"unsupported extra_event type: {c.type}") logger.warning(f"unsupported extra_event type:${c.type}")
if len(_extra_or_condition) > 0: if len(_extra_or_condition) > 0:
extra_constraints.append("(" + " OR ".join(_extra_or_condition) + ")") extra_constraints.append("(" + " OR ".join(_extra_or_condition) + ")")
query_part = f"""\ query_part = f"""\

View file

@ -122,10 +122,7 @@ def search_sessions(data: schemas.SessionsSearchPayloadSchema, project: schemas.
sort = 'session_id' sort = 'session_id'
if data.sort is not None and data.sort != "session_id": if data.sort is not None and data.sort != "session_id":
# sort += " " + data.order + "," + helper.key_to_snake_case(data.sort) # sort += " " + data.order + "," + helper.key_to_snake_case(data.sort)
if data.sort == 'datetime': sort = helper.key_to_snake_case(data.sort)
sort = 'start_ts'
else:
sort = helper.key_to_snake_case(data.sort)
meta_keys = metadata.get(project_id=project.project_id) meta_keys = metadata.get(project_id=project.project_id)
main_query = cur.mogrify(f"""SELECT COUNT(full_sessions) AS count, main_query = cur.mogrify(f"""SELECT COUNT(full_sessions) AS count,

View file

@ -34,10 +34,7 @@ if config("CH_COMPRESSION", cast=bool, default=True):
def transform_result(self, original_function): def transform_result(self, original_function):
@wraps(original_function) @wraps(original_function)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
if kwargs.get("parameters"): logger.debug(str.encode(self.format(query=kwargs.get("query", ""), parameters=kwargs.get("parameters"))))
logger.debug(str.encode(self.format(query=kwargs.get("query", ""), parameters=kwargs.get("parameters"))))
elif len(args) > 0:
logger.debug(str.encode(args[0]))
result = original_function(*args, **kwargs) result = original_function(*args, **kwargs)
if isinstance(result, clickhouse_connect.driver.query.QueryResult): if isinstance(result, clickhouse_connect.driver.query.QueryResult):
column_names = result.column_names column_names = result.column_names
@ -149,11 +146,13 @@ class ClickHouseClient:
def __enter__(self): def __enter__(self):
return self.__client return self.__client
def format(self, query, parameters=None): def format(self, query, *, parameters=None):
if parameters: if parameters is None:
ctx = QueryContext(query=query, parameters=parameters) return query
return ctx.final_query return query % {
return query key: f"'{value}'" if isinstance(value, str) else value
for key, value in parameters.items()
}
def __exit__(self, *args): def __exit__(self, *args):
if config('CH_POOL', cast=bool, default=True): if config('CH_POOL', cast=bool, default=True):

View file

@ -4,41 +4,37 @@ import schemas
def get_sql_operator(op: Union[schemas.SearchEventOperator, schemas.ClickEventExtraOperator, schemas.MathOperator]): def get_sql_operator(op: Union[schemas.SearchEventOperator, schemas.ClickEventExtraOperator, schemas.MathOperator]):
if isinstance(op, Enum):
op = op.value
return { return {
schemas.SearchEventOperator.IS.value: "=", schemas.SearchEventOperator.IS: "=",
schemas.SearchEventOperator.ON.value: "=", schemas.SearchEventOperator.ON: "=",
schemas.SearchEventOperator.ON_ANY.value: "IN", schemas.SearchEventOperator.ON_ANY: "IN",
schemas.SearchEventOperator.IS_NOT.value: "!=", schemas.SearchEventOperator.IS_NOT: "!=",
schemas.SearchEventOperator.NOT_ON.value: "!=", schemas.SearchEventOperator.NOT_ON: "!=",
schemas.SearchEventOperator.CONTAINS.value: "ILIKE", schemas.SearchEventOperator.CONTAINS: "ILIKE",
schemas.SearchEventOperator.NOT_CONTAINS.value: "NOT ILIKE", schemas.SearchEventOperator.NOT_CONTAINS: "NOT ILIKE",
schemas.SearchEventOperator.STARTS_WITH.value: "ILIKE", schemas.SearchEventOperator.STARTS_WITH: "ILIKE",
schemas.SearchEventOperator.ENDS_WITH.value: "ILIKE", schemas.SearchEventOperator.ENDS_WITH: "ILIKE",
# Selector operators: # Selector operators:
schemas.ClickEventExtraOperator.IS.value: "=", schemas.ClickEventExtraOperator.IS: "=",
schemas.ClickEventExtraOperator.IS_NOT.value: "!=", schemas.ClickEventExtraOperator.IS_NOT: "!=",
schemas.ClickEventExtraOperator.CONTAINS.value: "ILIKE", schemas.ClickEventExtraOperator.CONTAINS: "ILIKE",
schemas.ClickEventExtraOperator.NOT_CONTAINS.value: "NOT ILIKE", schemas.ClickEventExtraOperator.NOT_CONTAINS: "NOT ILIKE",
schemas.ClickEventExtraOperator.STARTS_WITH.value: "ILIKE", schemas.ClickEventExtraOperator.STARTS_WITH: "ILIKE",
schemas.ClickEventExtraOperator.ENDS_WITH.value: "ILIKE", schemas.ClickEventExtraOperator.ENDS_WITH: "ILIKE",
schemas.MathOperator.GREATER.value: ">", schemas.MathOperator.GREATER: ">",
schemas.MathOperator.GREATER_EQ.value: ">=", schemas.MathOperator.GREATER_EQ: ">=",
schemas.MathOperator.LESS.value: "<", schemas.MathOperator.LESS: "<",
schemas.MathOperator.LESS_EQ.value: "<=", schemas.MathOperator.LESS_EQ: "<=",
}.get(op, "=") }.get(op, "=")
def is_negation_operator(op: schemas.SearchEventOperator): def is_negation_operator(op: schemas.SearchEventOperator):
if isinstance(op, Enum): return op in [schemas.SearchEventOperator.IS_NOT,
op = op.value schemas.SearchEventOperator.NOT_ON,
return op in [schemas.SearchEventOperator.IS_NOT.value, schemas.SearchEventOperator.NOT_CONTAINS,
schemas.SearchEventOperator.NOT_ON.value, schemas.ClickEventExtraOperator.IS_NOT,
schemas.SearchEventOperator.NOT_CONTAINS.value, schemas.ClickEventExtraOperator.NOT_CONTAINS]
schemas.ClickEventExtraOperator.IS_NOT.value,
schemas.ClickEventExtraOperator.NOT_CONTAINS.value]
def reverse_sql_operator(op): def reverse_sql_operator(op):

View file

@ -0,0 +1,591 @@
-- -- Original Q3
-- WITH ranked_events AS (SELECT *
-- FROM ranked_events_1736344377403),
-- n1 AS (SELECT event_number_in_session,
-- event_type,
-- e_value,
-- next_type,
-- next_value,
-- COUNT(1) AS sessions_count
-- FROM ranked_events
-- WHERE event_number_in_session = 1
-- AND isNotNull(next_value)
-- GROUP BY event_number_in_session, event_type, e_value, next_type, next_value
-- ORDER BY sessions_count DESC
-- LIMIT 8),
-- n2 AS (SELECT *
-- FROM (SELECT re.event_number_in_session AS event_number_in_session,
-- re.event_type AS event_type,
-- re.e_value AS e_value,
-- re.next_type AS next_type,
-- re.next_value AS next_value,
-- COUNT(1) AS sessions_count
-- FROM n1
-- INNER JOIN ranked_events AS re
-- ON (n1.next_value = re.e_value AND n1.next_type = re.event_type)
-- WHERE re.event_number_in_session = 2
-- GROUP BY re.event_number_in_session, re.event_type, re.e_value, re.next_type,
-- re.next_value) AS sub_level
-- ORDER BY sessions_count DESC
-- LIMIT 8),
-- n3 AS (SELECT *
-- FROM (SELECT re.event_number_in_session AS event_number_in_session,
-- re.event_type AS event_type,
-- re.e_value AS e_value,
-- re.next_type AS next_type,
-- re.next_value AS next_value,
-- COUNT(1) AS sessions_count
-- FROM n2
-- INNER JOIN ranked_events AS re
-- ON (n2.next_value = re.e_value AND n2.next_type = re.event_type)
-- WHERE re.event_number_in_session = 3
-- GROUP BY re.event_number_in_session, re.event_type, re.e_value, re.next_type,
-- re.next_value) AS sub_level
-- ORDER BY sessions_count DESC
-- LIMIT 8),
-- n4 AS (SELECT *
-- FROM (SELECT re.event_number_in_session AS event_number_in_session,
-- re.event_type AS event_type,
-- re.e_value AS e_value,
-- re.next_type AS next_type,
-- re.next_value AS next_value,
-- COUNT(1) AS sessions_count
-- FROM n3
-- INNER JOIN ranked_events AS re
-- ON (n3.next_value = re.e_value AND n3.next_type = re.event_type)
-- WHERE re.event_number_in_session = 4
-- GROUP BY re.event_number_in_session, re.event_type, re.e_value, re.next_type,
-- re.next_value) AS sub_level
-- ORDER BY sessions_count DESC
-- LIMIT 8),
-- n5 AS (SELECT *
-- FROM (SELECT re.event_number_in_session AS event_number_in_session,
-- re.event_type AS event_type,
-- re.e_value AS e_value,
-- re.next_type AS next_type,
-- re.next_value AS next_value,
-- COUNT(1) AS sessions_count
-- FROM n4
-- INNER JOIN ranked_events AS re
-- ON (n4.next_value = re.e_value AND n4.next_type = re.event_type)
-- WHERE re.event_number_in_session = 5
-- GROUP BY re.event_number_in_session, re.event_type, re.e_value, re.next_type,
-- re.next_value) AS sub_level
-- ORDER BY sessions_count DESC
-- LIMIT 8)
-- SELECT *
-- FROM (SELECT event_number_in_session,
-- event_type,
-- e_value,
-- next_type,
-- next_value,
-- sessions_count
-- FROM n1
-- UNION ALL
-- SELECT event_number_in_session,
-- event_type,
-- e_value,
-- next_type,
-- next_value,
-- sessions_count
-- FROM n2
-- UNION ALL
-- SELECT event_number_in_session,
-- event_type,
-- e_value,
-- next_type,
-- next_value,
-- sessions_count
-- FROM n3
-- UNION ALL
-- SELECT event_number_in_session,
-- event_type,
-- e_value,
-- next_type,
-- next_value,
-- sessions_count
-- FROM n4
-- UNION ALL
-- SELECT event_number_in_session,
-- event_type,
-- e_value,
-- next_type,
-- next_value,
-- sessions_count
-- FROM n5) AS chart_steps
-- ORDER BY event_number_in_session;
-- Q1
-- CREATE TEMPORARY TABLE pre_ranked_events_1736344377403 AS
CREATE TABLE pre_ranked_events_1736344377403 ENGINE = Memory AS
(WITH initial_event AS (SELECT events.session_id, MIN(datetime) AS start_event_timestamp
FROM experimental.events AS events
WHERE ((event_type = 'LOCATION' AND (url_path = '/en/deployment/')))
AND events.project_id = toUInt16(65)
AND events.datetime >= toDateTime(1735599600000 / 1000)
AND events.datetime < toDateTime(1736290799999 / 1000)
GROUP BY 1),
pre_ranked_events AS (SELECT *
FROM (SELECT session_id,
event_type,
datetime,
url_path AS e_value,
row_number() OVER (PARTITION BY session_id
ORDER BY datetime ,
message_id ) AS event_number_in_session
FROM experimental.events AS events
INNER JOIN initial_event ON (events.session_id = initial_event.session_id)
WHERE events.project_id = toUInt16(65)
AND events.datetime >= toDateTime(1735599600000 / 1000)
AND events.datetime < toDateTime(1736290799999 / 1000)
AND (events.event_type = 'LOCATION')
AND events.datetime >= initial_event.start_event_timestamp
) AS full_ranked_events
WHERE event_number_in_session <= 5)
SELECT *
FROM pre_ranked_events);
;
SELECT *
FROM pre_ranked_events_1736344377403
WHERE event_number_in_session < 3;
-- ---------Q2-----------
-- CREATE TEMPORARY TABLE ranked_events_1736344377403 AS
DROP TABLE ranked_events_1736344377403;
CREATE TABLE ranked_events_1736344377403 ENGINE = Memory AS
(WITH pre_ranked_events AS (SELECT *
FROM pre_ranked_events_1736344377403),
start_points AS (SELECT DISTINCT session_id
FROM pre_ranked_events
WHERE ((event_type = 'LOCATION' AND (e_value = '/en/deployment/')))
AND pre_ranked_events.event_number_in_session = 1),
ranked_events AS (SELECT pre_ranked_events.*,
leadInFrame(e_value)
OVER (PARTITION BY session_id ORDER BY datetime
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS next_value,
leadInFrame(toNullable(event_type))
OVER (PARTITION BY session_id ORDER BY datetime
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS next_type
FROM start_points
INNER JOIN pre_ranked_events USING (session_id))
SELECT *
FROM ranked_events);
-- ranked events
SELECT event_number_in_session,
event_type,
e_value,
next_type,
next_value,
COUNT(1) AS sessions_count
FROM ranked_events_1736344377403
WHERE event_number_in_session = 2
-- AND e_value='/en/deployment/deploy-docker/'
-- AND next_value NOT IN ('/en/deployment/','/en/plugins/','/en/using-or/')
-- AND e_value NOT IN ('/en/deployment/deploy-docker/','/en/getting-started/','/en/deployment/deploy-ubuntu/')
AND isNotNull(next_value)
GROUP BY event_number_in_session, event_type, e_value, next_type, next_value
ORDER BY event_number_in_session, sessions_count DESC;
SELECT event_number_in_session,
event_type,
e_value,
COUNT(1) AS sessions_count
FROM ranked_events_1736344377403
WHERE event_number_in_session = 1
GROUP BY event_number_in_session, event_type, e_value
ORDER BY event_number_in_session, sessions_count DESC;
SELECT COUNT(1) AS sessions_count
FROM ranked_events_1736344377403
WHERE event_number_in_session = 2
AND isNull(next_value)
;
-- ---------Q3 MORE -----------
WITH ranked_events AS (SELECT *
FROM ranked_events_1736344377403),
n1 AS (SELECT event_number_in_session,
event_type,
e_value,
next_type,
next_value,
COUNT(1) AS sessions_count
FROM ranked_events
WHERE event_number_in_session = 1
GROUP BY event_number_in_session, event_type, e_value, next_type, next_value
ORDER BY sessions_count DESC),
n2 AS (SELECT event_number_in_session,
event_type,
e_value,
next_type,
next_value,
COUNT(1) AS sessions_count
FROM ranked_events
WHERE event_number_in_session = 2
GROUP BY event_number_in_session, event_type, e_value, next_type, next_value
ORDER BY sessions_count DESC),
n3 AS (SELECT event_number_in_session,
event_type,
e_value,
next_type,
next_value,
COUNT(1) AS sessions_count
FROM ranked_events
WHERE event_number_in_session = 3
GROUP BY event_number_in_session, event_type, e_value, next_type, next_value
ORDER BY sessions_count DESC),
drop_n AS (-- STEP 1
SELECT event_number_in_session,
event_type,
e_value,
'DROP' AS next_type,
NULL AS next_value,
sessions_count
FROM n1
WHERE isNull(n1.next_type)
UNION ALL
-- STEP 2
SELECT event_number_in_session,
event_type,
e_value,
'DROP' AS next_type,
NULL AS next_value,
sessions_count
FROM n2
WHERE isNull(n2.next_type)),
-- TODO: make this as top_steps, where every step will go to next as top/others
top_n1 AS (-- STEP 1
SELECT event_number_in_session,
event_type,
e_value,
next_type,
next_value,
sessions_count
FROM n1
WHERE isNotNull(next_type)
ORDER BY sessions_count DESC
LIMIT 3),
top_n2 AS (-- STEP 2
SELECT event_number_in_session,
event_type,
e_value,
next_type,
next_value,
sessions_count
FROM n2
WHERE (event_type, e_value) IN (SELECT event_type,
e_value
FROM n2
WHERE isNotNull(next_type)
GROUP BY event_type, e_value
ORDER BY SUM(sessions_count) DESC
LIMIT 3)
ORDER BY sessions_count DESC),
top_n AS (SELECT *
FROM top_n1
UNION ALL
SELECT *
FROM top_n2),
u_top_n AS (SELECT DISTINCT event_number_in_session,
event_type,
e_value
FROM top_n),
others_n AS (
-- STEP 1
SELECT event_number_in_session,
event_type,
e_value,
next_type,
next_value,
sessions_count
FROM n1
WHERE isNotNull(next_type)
ORDER BY sessions_count DESC
LIMIT 1000000 OFFSET 3
UNION ALL
-- STEP 2
SELECT event_number_in_session,
event_type,
e_value,
next_type,
next_value,
sessions_count
FROM n2
WHERE isNotNull(next_type)
-- GROUP BY event_number_in_session, event_type, e_value
ORDER BY sessions_count DESC
LIMIT 1000000 OFFSET 3)
SELECT *
FROM (
-- Top
SELECT *
FROM top_n
-- UNION ALL
-- -- Others
-- SELECT event_number_in_session,
-- event_type,
-- e_value,
-- 'OTHER' AS next_type,
-- NULL AS next_value,
-- SUM(sessions_count)
-- FROM others_n
-- GROUP BY event_number_in_session, event_type, e_value
-- UNION ALL
-- -- Top go to Drop
-- SELECT drop_n.event_number_in_session,
-- drop_n.event_type,
-- drop_n.e_value,
-- drop_n.next_type,
-- drop_n.next_value,
-- drop_n.sessions_count
-- FROM drop_n
-- INNER JOIN u_top_n ON (drop_n.event_number_in_session = u_top_n.event_number_in_session
-- AND drop_n.event_type = u_top_n.event_type
-- AND drop_n.e_value = u_top_n.e_value)
-- ORDER BY drop_n.event_number_in_session
-- -- -- UNION ALL
-- -- -- Top go to Others
-- SELECT top_n.event_number_in_session,
-- top_n.event_type,
-- top_n.e_value,
-- 'OTHER' AS next_type,
-- NULL AS next_value,
-- SUM(top_n.sessions_count) AS sessions_count
-- FROM top_n
-- LEFT JOIN others_n ON (others_n.event_number_in_session = (top_n.event_number_in_session + 1)
-- AND top_n.next_type = others_n.event_type
-- AND top_n.next_value = others_n.e_value)
-- WHERE others_n.event_number_in_session IS NULL
-- AND top_n.next_type IS NOT NULL
-- GROUP BY event_number_in_session, event_type, e_value
-- UNION ALL
-- -- Others got to Top
-- SELECT others_n.event_number_in_session,
-- 'OTHER' AS event_type,
-- NULL AS e_value,
-- others_n.s_next_type AS next_type,
-- others_n.s_next_value AS next_value,
-- SUM(sessions_count) AS sessions_count
-- FROM others_n
-- INNER JOIN top_n ON (others_n.event_number_in_session = top_n.event_number_in_session + 1 AND
-- others_n.s_next_type = top_n.event_type AND
-- others_n.s_next_value = top_n.event_type)
-- GROUP BY others_n.event_number_in_session, next_type, next_value
-- UNION ALL
-- -- TODO: find if this works or not
-- -- Others got to Others
-- SELECT others_n.event_number_in_session,
-- 'OTHER' AS event_type,
-- NULL AS e_value,
-- 'OTHERS' AS next_type,
-- NULL AS next_value,
-- SUM(sessions_count) AS sessions_count
-- FROM others_n
-- LEFT JOIN u_top_n ON ((others_n.event_number_in_session + 1) = u_top_n.event_number_in_session
-- AND others_n.s_next_type = u_top_n.event_type
-- AND others_n.s_next_value = u_top_n.e_value)
-- WHERE u_top_n.event_number_in_session IS NULL
-- GROUP BY others_n.event_number_in_session
)
ORDER BY event_number_in_session;
-- ---------Q3 TOP ON VALUE ONLY -----------
WITH ranked_events AS (SELECT *
FROM ranked_events_1736344377403),
n1 AS (SELECT event_number_in_session,
event_type,
e_value,
next_type,
next_value,
COUNT(1) AS sessions_count
FROM ranked_events
WHERE event_number_in_session = 1
GROUP BY event_number_in_session, event_type, e_value, next_type, next_value
ORDER BY sessions_count DESC),
n2 AS (SELECT event_number_in_session,
event_type,
e_value,
next_type,
next_value,
COUNT(1) AS sessions_count
FROM ranked_events
WHERE event_number_in_session = 2
GROUP BY event_number_in_session, event_type, e_value, next_type, next_value
ORDER BY sessions_count DESC),
n3 AS (SELECT event_number_in_session,
event_type,
e_value,
next_type,
next_value,
COUNT(1) AS sessions_count
FROM ranked_events
WHERE event_number_in_session = 3
GROUP BY event_number_in_session, event_type, e_value, next_type, next_value
ORDER BY sessions_count DESC),
drop_n AS (-- STEP 1
SELECT event_number_in_session,
event_type,
e_value,
'DROP' AS next_type,
NULL AS next_value,
sessions_count
FROM n1
WHERE isNull(n1.next_type)
UNION ALL
-- STEP 2
SELECT event_number_in_session,
event_type,
e_value,
'DROP' AS next_type,
NULL AS next_value,
sessions_count
FROM n2
WHERE isNull(n2.next_type)),
top_n AS (SELECT event_number_in_session,
event_type,
e_value,
SUM(sessions_count) AS sessions_count
FROM n1
GROUP BY event_number_in_session, event_type, e_value
LIMIT 1
UNION ALL
-- STEP 2
SELECT event_number_in_session,
event_type,
e_value,
SUM(sessions_count) AS sessions_count
FROM n2
GROUP BY event_number_in_session, event_type, e_value
ORDER BY sessions_count DESC
LIMIT 3
UNION ALL
-- STEP 3
SELECT event_number_in_session,
event_type,
e_value,
SUM(sessions_count) AS sessions_count
FROM n3
GROUP BY event_number_in_session, event_type, e_value
ORDER BY sessions_count DESC
LIMIT 3),
top_n_with_next AS (SELECT n1.*
FROM n1
UNION ALL
SELECT n2.*
FROM n2
INNER JOIN top_n ON (n2.event_number_in_session = top_n.event_number_in_session
AND n2.event_type = top_n.event_type
AND n2.e_value = top_n.e_value)),
others_n AS (
-- STEP 2
SELECT n2.*
FROM n2
WHERE (n2.event_number_in_session, n2.event_type, n2.e_value) NOT IN
(SELECT event_number_in_session, event_type, e_value
FROM top_n
WHERE top_n.event_number_in_session = 2)
UNION ALL
-- STEP 3
SELECT n3.*
FROM n3
WHERE (n3.event_number_in_session, n3.event_type, n3.e_value) NOT IN
(SELECT event_number_in_session, event_type, e_value
FROM top_n
WHERE top_n.event_number_in_session = 3))
SELECT *
FROM (
-- SELECT sum(top_n_with_next.sessions_count)
-- FROM top_n_with_next
-- WHERE event_number_in_session = 1
-- -- AND isNotNull(next_value)
-- AND (next_type, next_value) IN
-- (SELECT others_n.event_type, others_n.e_value FROM others_n WHERE others_n.event_number_in_session = 2)
-- -- SELECT * FROM others_n
-- -- SELECT * FROM n2
-- SELECT *
-- FROM top_n
-- );
-- Top to Top: valid
SELECT top_n_with_next.*
FROM top_n_with_next
INNER JOIN top_n
ON (top_n_with_next.event_number_in_session + 1 = top_n.event_number_in_session
AND top_n_with_next.next_type = top_n.event_type
AND top_n_with_next.next_value = top_n.e_value)
UNION ALL
-- Top to Others: valid
SELECT top_n_with_next.event_number_in_session,
top_n_with_next.event_type,
top_n_with_next.e_value,
'OTHER' AS next_type,
NULL AS next_value,
SUM(top_n_with_next.sessions_count) AS sessions_count
FROM top_n_with_next
WHERE (top_n_with_next.event_number_in_session + 1, top_n_with_next.next_type, top_n_with_next.next_value) IN
(SELECT others_n.event_number_in_session, others_n.event_type, others_n.e_value FROM others_n)
GROUP BY top_n_with_next.event_number_in_session, top_n_with_next.event_type, top_n_with_next.e_value
UNION ALL
-- Top go to Drop: valid
SELECT drop_n.event_number_in_session,
drop_n.event_type,
drop_n.e_value,
drop_n.next_type,
drop_n.next_value,
drop_n.sessions_count
FROM drop_n
INNER JOIN top_n ON (drop_n.event_number_in_session = top_n.event_number_in_session
AND drop_n.event_type = top_n.event_type
AND drop_n.e_value = top_n.e_value)
ORDER BY drop_n.event_number_in_session
UNION ALL
-- Others got to Drop: valid
SELECT others_n.event_number_in_session,
'OTHER' AS event_type,
NULL AS e_value,
'DROP' AS next_type,
NULL AS next_value,
SUM(others_n.sessions_count) AS sessions_count
FROM others_n
WHERE isNull(others_n.next_type)
AND others_n.event_number_in_session < 3
GROUP BY others_n.event_number_in_session, next_type, next_value
UNION ALL
-- Others got to Top:valid
SELECT others_n.event_number_in_session,
'OTHER' AS event_type,
NULL AS e_value,
others_n.next_type,
others_n.next_value,
SUM(others_n.sessions_count) AS sessions_count
FROM others_n
WHERE isNotNull(others_n.next_type)
AND (others_n.event_number_in_session + 1, others_n.next_type, others_n.next_value) IN
(SELECT top_n.event_number_in_session, top_n.event_type, top_n.e_value FROM top_n)
GROUP BY others_n.event_number_in_session, others_n.next_type, others_n.next_value
UNION ALL
-- Others got to Others
SELECT others_n.event_number_in_session,
'OTHER' AS event_type,
NULL AS e_value,
'OTHERS' AS next_type,
NULL AS next_value,
SUM(sessions_count) AS sessions_count
FROM others_n
WHERE isNotNull(others_n.next_type)
AND others_n.event_number_in_session < 3
AND (others_n.event_number_in_session + 1, others_n.next_type, others_n.next_value) NOT IN
(SELECT event_number_in_session, event_type, e_value FROM top_n)
GROUP BY others_n.event_number_in_session)
ORDER BY event_number_in_session, sessions_count
DESC;

View file

@ -960,6 +960,36 @@ class CardSessionsSchema(_TimedSchema, _PaginatedSchema):
return self return self
# We don't need this as the UI is expecting filters to override the full series' filters
# @model_validator(mode="after")
# def __merge_out_filters_with_series(self):
# for f in self.filters:
# for s in self.series:
# found = False
#
# if f.is_event:
# sub = s.filter.events
# else:
# sub = s.filter.filters
#
# for e in sub:
# if f.type == e.type and f.operator == e.operator:
# found = True
# if f.is_event:
# # If extra event: append value
# for v in f.value:
# if v not in e.value:
# e.value.append(v)
# else:
# # If extra filter: override value
# e.value = f.value
# if not found:
# sub.append(f)
#
# self.filters = []
#
# return self
# UI is expecting filters to override the full series' filters # UI is expecting filters to override the full series' filters
@model_validator(mode="after") @model_validator(mode="after")
def __override_series_filters_with_outer_filters(self): def __override_series_filters_with_outer_filters(self):
@ -1030,16 +1060,6 @@ class CardTable(__CardSchema):
values["metricValue"] = [] values["metricValue"] = []
return values return values
@model_validator(mode="after")
def __enforce_AND_operator(self):
self.metric_of = MetricOfTable(self.metric_of)
if self.metric_of in (MetricOfTable.VISITED_URL, MetricOfTable.FETCH, \
MetricOfTable.VISITED_URL.value, MetricOfTable.FETCH.value):
for s in self.series:
if s.filter is not None:
s.filter.events_order = SearchEventOrder.AND
return self
@model_validator(mode="after") @model_validator(mode="after")
def __transform(self): def __transform(self):
self.metric_of = MetricOfTable(self.metric_of) self.metric_of = MetricOfTable(self.metric_of)
@ -1115,7 +1135,7 @@ class CardPathAnalysis(__CardSchema):
view_type: MetricOtherViewType = Field(...) view_type: MetricOtherViewType = Field(...)
metric_value: List[ProductAnalyticsSelectedEventType] = Field(default_factory=list) metric_value: List[ProductAnalyticsSelectedEventType] = Field(default_factory=list)
density: int = Field(default=4, ge=2, le=10) density: int = Field(default=4, ge=2, le=10)
rows: int = Field(default=5, ge=1, le=10) rows: int = Field(default=3, ge=1, le=10)
start_type: Literal["start", "end"] = Field(default="start") start_type: Literal["start", "end"] = Field(default="start")
start_point: List[PathAnalysisSubFilterSchema] = Field(default_factory=list) start_point: List[PathAnalysisSubFilterSchema] = Field(default_factory=list)

View file

@ -19,16 +19,14 @@ const EVENTS_DEFINITION = {
} }
}; };
EVENTS_DEFINITION.emit = { EVENTS_DEFINITION.emit = {
NEW_AGENT: "NEW_AGENT", NEW_AGENT: "NEW_AGENT",
NO_AGENTS: "NO_AGENT", NO_AGENTS: "NO_AGENT",
AGENT_DISCONNECT: "AGENT_DISCONNECTED", AGENT_DISCONNECT: "AGENT_DISCONNECTED",
AGENTS_CONNECTED: "AGENTS_CONNECTED", AGENTS_CONNECTED: "AGENTS_CONNECTED",
AGENTS_INFO_CONNECTED: "AGENTS_INFO_CONNECTED", NO_SESSIONS: "SESSION_DISCONNECTED",
NO_SESSIONS: "SESSION_DISCONNECTED", SESSION_ALREADY_CONNECTED: "SESSION_ALREADY_CONNECTED",
SESSION_ALREADY_CONNECTED: "SESSION_ALREADY_CONNECTED", SESSION_RECONNECTED: "SESSION_RECONNECTED",
SESSION_RECONNECTED: "SESSION_RECONNECTED", UPDATE_EVENT: EVENTS_DEFINITION.listen.UPDATE_EVENT
UPDATE_EVENT: EVENTS_DEFINITION.listen.UPDATE_EVENT,
WEBRTC_CONFIG: "WEBRTC_CONFIG",
}; };
const BASE_sessionInfo = { const BASE_sessionInfo = {

View file

@ -27,14 +27,9 @@ const respond = function (req, res, data) {
res.setHeader('Content-Type', 'application/json'); res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(result)); res.end(JSON.stringify(result));
} else { } else {
if (!res.aborted) { res.cork(() => {
res.cork(() => { res.writeStatus('200 OK').writeHeader('Content-Type', 'application/json').end(JSON.stringify(result));
res.writeStatus('200 OK').writeHeader('Content-Type', 'application/json').end(JSON.stringify(result)); });
});
} else {
logger.debug("response aborted");
return;
}
} }
const duration = performance.now() - req.startTs; const duration = performance.now() - req.startTs;
IncreaseTotalRequests(); IncreaseTotalRequests();

View file

@ -42,7 +42,7 @@ const findSessionSocketId = async (io, roomId, tabId) => {
}; };
async function getRoomData(io, roomID) { async function getRoomData(io, roomID) {
let tabsCount = 0, agentsCount = 0, tabIDs = [], agentIDs = [], config = null, agentInfos = []; let tabsCount = 0, agentsCount = 0, tabIDs = [], agentIDs = [];
const connected_sockets = await io.in(roomID).fetchSockets(); const connected_sockets = await io.in(roomID).fetchSockets();
if (connected_sockets.length > 0) { if (connected_sockets.length > 0) {
for (let socket of connected_sockets) { for (let socket of connected_sockets) {
@ -52,19 +52,13 @@ async function getRoomData(io, roomID) {
} else { } else {
agentsCount++; agentsCount++;
agentIDs.push(socket.id); agentIDs.push(socket.id);
agentInfos.push({ ...socket.handshake.query.agentInfo, socketId: socket.id });
if (socket.handshake.query.config !== undefined) {
config = socket.handshake.query.config;
}
} }
} }
} else { } else {
tabsCount = -1; tabsCount = -1;
agentsCount = -1; agentsCount = -1;
agentInfos = [];
agentIDs = [];
} }
return {tabsCount, agentsCount, tabIDs, agentIDs, config, agentInfos}; return {tabsCount, agentsCount, tabIDs, agentIDs};
} }
function processNewSocket(socket) { function processNewSocket(socket) {
@ -84,7 +78,7 @@ async function onConnect(socket) {
IncreaseOnlineConnections(socket.handshake.query.identity); IncreaseOnlineConnections(socket.handshake.query.identity);
const io = getServer(); const io = getServer();
const {tabsCount, agentsCount, tabIDs, agentInfos, agentIDs, config} = await getRoomData(io, socket.handshake.query.roomId); const {tabsCount, agentsCount, tabIDs, agentIDs} = await getRoomData(io, socket.handshake.query.roomId);
if (socket.handshake.query.identity === IDENTITIES.session) { if (socket.handshake.query.identity === IDENTITIES.session) {
// Check if session with the same tabID already connected, if so, refuse new connexion // Check if session with the same tabID already connected, if so, refuse new connexion
@ -106,9 +100,7 @@ async function onConnect(socket) {
// Inform all connected agents about reconnected session // Inform all connected agents about reconnected session
if (agentsCount > 0) { if (agentsCount > 0) {
logger.debug(`notifying new session about agent-existence`); logger.debug(`notifying new session about agent-existence`);
io.to(socket.id).emit(EVENTS_DEFINITION.emit.WEBRTC_CONFIG, config);
io.to(socket.id).emit(EVENTS_DEFINITION.emit.AGENTS_CONNECTED, agentIDs); io.to(socket.id).emit(EVENTS_DEFINITION.emit.AGENTS_CONNECTED, agentIDs);
io.to(socket.id).emit(EVENTS_DEFINITION.emit.AGENTS_INFO_CONNECTED, agentInfos);
socket.to(socket.handshake.query.roomId).emit(EVENTS_DEFINITION.emit.SESSION_RECONNECTED, socket.id); socket.to(socket.handshake.query.roomId).emit(EVENTS_DEFINITION.emit.SESSION_RECONNECTED, socket.id);
} }
} else if (tabsCount <= 0) { } else if (tabsCount <= 0) {
@ -126,8 +118,7 @@ async function onConnect(socket) {
// Stats // Stats
startAssist(socket, socket.handshake.query.agentID); startAssist(socket, socket.handshake.query.agentID);
} }
io.to(socket.handshake.query.roomId).emit(EVENTS_DEFINITION.emit.WEBRTC_CONFIG, socket.handshake.query.config); socket.to(socket.handshake.query.roomId).emit(EVENTS_DEFINITION.emit.NEW_AGENT, socket.id, socket.handshake.query.agentInfo);
socket.to(socket.handshake.query.roomId).emit(EVENTS_DEFINITION.emit.NEW_AGENT, socket.id, { ...socket.handshake.query.agentInfo });
} }
// Set disconnect handler // Set disconnect handler

View file

@ -2,12 +2,11 @@ package datasaver
import ( import (
"context" "context"
"encoding/json"
"openreplay/backend/pkg/db/types"
"openreplay/backend/internal/config/db" "openreplay/backend/internal/config/db"
"openreplay/backend/pkg/db/clickhouse" "openreplay/backend/pkg/db/clickhouse"
"openreplay/backend/pkg/db/postgres" "openreplay/backend/pkg/db/postgres"
"openreplay/backend/pkg/db/types"
"openreplay/backend/pkg/logger" "openreplay/backend/pkg/logger"
. "openreplay/backend/pkg/messages" . "openreplay/backend/pkg/messages"
queue "openreplay/backend/pkg/queue/types" queue "openreplay/backend/pkg/queue/types"
@ -51,6 +50,10 @@ func New(log logger.Logger, cfg *db.Config, pg *postgres.Conn, ch clickhouse.Con
} }
func (s *saverImpl) Handle(msg Message) { func (s *saverImpl) Handle(msg Message) {
if msg.TypeID() == MsgCustomEvent {
defer s.Handle(types.WrapCustomEvent(msg.(*CustomEvent)))
}
var ( var (
sessCtx = context.WithValue(context.Background(), "sessionID", msg.SessionID()) sessCtx = context.WithValue(context.Background(), "sessionID", msg.SessionID())
session *sessions.Session session *sessions.Session
@ -66,23 +69,6 @@ func (s *saverImpl) Handle(msg Message) {
return return
} }
if msg.TypeID() == MsgCustomEvent {
m := msg.(*CustomEvent)
// Try to parse custom event payload to JSON and extract or_payload field
type CustomEventPayload struct {
CustomTimestamp uint64 `json:"or_timestamp"`
}
customPayload := &CustomEventPayload{}
if err := json.Unmarshal([]byte(m.Payload), customPayload); err == nil {
if customPayload.CustomTimestamp >= session.Timestamp {
s.log.Info(sessCtx, "custom event timestamp received: %v", m.Timestamp)
msg.Meta().Timestamp = customPayload.CustomTimestamp
s.log.Info(sessCtx, "custom event timestamp updated: %v", m.Timestamp)
}
}
defer s.Handle(types.WrapCustomEvent(m))
}
if IsMobileType(msg.TypeID()) { if IsMobileType(msg.TypeID()) {
if err := s.handleMobileMessage(sessCtx, session, msg); err != nil { if err := s.handleMobileMessage(sessCtx, session, msg); err != nil {
if !postgres.IsPkeyViolation(err) { if !postgres.IsPkeyViolation(err) {

View file

@ -135,11 +135,6 @@ func (e *handlersImpl) startSessionHandlerWeb(w http.ResponseWriter, r *http.Req
// Add tracker version to context // Add tracker version to context
r = r.WithContext(context.WithValue(r.Context(), "tracker", req.TrackerVersion)) r = r.WithContext(context.WithValue(r.Context(), "tracker", req.TrackerVersion))
if err := validateTrackerVersion(req.TrackerVersion); err != nil {
e.log.Error(r.Context(), "unsupported tracker version: %s, err: %s", req.TrackerVersion, err)
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusUpgradeRequired, errors.New("please upgrade the tracker version"), startTime, r.URL.Path, bodySize)
return
}
// Handler's logic // Handler's logic
if req.ProjectKey == nil { if req.ProjectKey == nil {
@ -162,6 +157,13 @@ func (e *handlersImpl) startSessionHandlerWeb(w http.ResponseWriter, r *http.Req
// Add projectID to context // Add projectID to context
r = r.WithContext(context.WithValue(r.Context(), "projectID", fmt.Sprintf("%d", p.ProjectID))) r = r.WithContext(context.WithValue(r.Context(), "projectID", fmt.Sprintf("%d", p.ProjectID)))
// Validate tracker version
if err := validateTrackerVersion(req.TrackerVersion); err != nil {
e.log.Error(r.Context(), "unsupported tracker version: %s, err: %s", req.TrackerVersion, err)
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusUpgradeRequired, errors.New("please upgrade the tracker version"), startTime, r.URL.Path, bodySize)
return
}
// Check if the project supports mobile sessions // Check if the project supports mobile sessions
if !p.IsWeb() { if !p.IsWeb() {
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusForbidden, errors.New("project doesn't support web sessions"), startTime, r.URL.Path, bodySize) e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusForbidden, errors.New("project doesn't support web sessions"), startTime, r.URL.Path, bodySize)

3
ee/api/.gitignore vendored
View file

@ -225,7 +225,8 @@ Pipfile.lock
/chalicelib/core/sessions/unprocessed_sessions.py /chalicelib/core/sessions/unprocessed_sessions.py
/chalicelib/core/metrics/modules /chalicelib/core/metrics/modules
/chalicelib/core/socket_ios.py /chalicelib/core/socket_ios.py
/chalicelib/core/sourcemaps /chalicelib/core/sourcemaps.py
/chalicelib/core/sourcemaps_parser.py
/chalicelib/core/tags.py /chalicelib/core/tags.py
/chalicelib/saml /chalicelib/saml
/chalicelib/utils/__init__.py /chalicelib/utils/__init__.py

View file

@ -86,8 +86,7 @@ def __generic_query(typename, value_length=None):
ORDER BY value""" ORDER BY value"""
if value_length is None or value_length > 2: if value_length is None or value_length > 2:
return f"""SELECT DISTINCT ON(value, type) value, type return f"""(SELECT DISTINCT value, type
FROM ((SELECT DISTINCT value, type
FROM {TABLE} FROM {TABLE}
WHERE WHERE
project_id = %(project_id)s project_id = %(project_id)s
@ -103,7 +102,7 @@ def __generic_query(typename, value_length=None):
AND type='{typename.upper()}' AND type='{typename.upper()}'
AND value ILIKE %(value)s AND value ILIKE %(value)s
ORDER BY value ORDER BY value
LIMIT 5)) AS raw;""" LIMIT 5);"""
return f"""SELECT DISTINCT value, type return f"""SELECT DISTINCT value, type
FROM {TABLE} FROM {TABLE}
WHERE WHERE
@ -258,7 +257,7 @@ def __search_metadata(project_id, value, key=None, source=None):
WHERE project_id = %(project_id)s WHERE project_id = %(project_id)s
AND {colname} ILIKE %(svalue)s LIMIT 5)""") AND {colname} ILIKE %(svalue)s LIMIT 5)""")
with ch_client.ClickHouseClient() as cur: with ch_client.ClickHouseClient() as cur:
query = cur.format(query=f"""SELECT DISTINCT ON(key, value) key, value, 'METADATA' AS TYPE query = cur.format(query=f"""SELECT key, value, 'METADATA' AS TYPE
FROM({" UNION ALL ".join(sub_from)}) AS all_metas FROM({" UNION ALL ".join(sub_from)}) AS all_metas
LIMIT 5;""", parameters={"project_id": project_id, "value": helper.string_to_sql_like(value), LIMIT 5;""", parameters={"project_id": project_id, "value": helper.string_to_sql_like(value),
"svalue": helper.string_to_sql_like("^" + value)}) "svalue": helper.string_to_sql_like("^" + value)})

View file

@ -71,7 +71,7 @@ def get_details(project_id, error_id, user_id, **data):
MAIN_EVENTS_TABLE = exp_ch_helper.get_main_events_table(0) MAIN_EVENTS_TABLE = exp_ch_helper.get_main_events_table(0)
ch_basic_query = errors_helper.__get_basic_constraints_ch(time_constraint=False) ch_basic_query = errors_helper.__get_basic_constraints_ch(time_constraint=False)
ch_basic_query.append("error_id = %(error_id)s") ch_basic_query.append("toString(`$properties`.error_id) = %(error_id)s")
with ch_client.ClickHouseClient() as ch: with ch_client.ClickHouseClient() as ch:
data["startDate24"] = TimeUTC.now(-1) data["startDate24"] = TimeUTC.now(-1)
@ -95,7 +95,7 @@ def get_details(project_id, error_id, user_id, **data):
"error_id": error_id} "error_id": error_id}
main_ch_query = f"""\ main_ch_query = f"""\
WITH pre_processed AS (SELECT error_id, WITH pre_processed AS (SELECT toString(`$properties`.error_id) AS error_id,
toString(`$properties`.name) AS name, toString(`$properties`.name) AS name,
toString(`$properties`.message) AS message, toString(`$properties`.message) AS message,
session_id, session_id,
@ -183,7 +183,7 @@ def get_details(project_id, error_id, user_id, **data):
AND `$event_name` = 'ERROR' AND `$event_name` = 'ERROR'
AND events.created_at >= toDateTime(timestamp / 1000) AND events.created_at >= toDateTime(timestamp / 1000)
AND events.created_at < toDateTime((timestamp + %(step_size24)s) / 1000) AND events.created_at < toDateTime((timestamp + %(step_size24)s) / 1000)
AND error_id = %(error_id)s AND toString(`$properties`.error_id) = %(error_id)s
GROUP BY timestamp GROUP BY timestamp
ORDER BY timestamp) AS chart_details ORDER BY timestamp) AS chart_details
) AS chart_details24 ON TRUE ) AS chart_details24 ON TRUE
@ -196,7 +196,7 @@ def get_details(project_id, error_id, user_id, **data):
AND `$event_name` = 'ERROR' AND `$event_name` = 'ERROR'
AND events.created_at >= toDateTime(timestamp / 1000) AND events.created_at >= toDateTime(timestamp / 1000)
AND events.created_at < toDateTime((timestamp + %(step_size30)s) / 1000) AND events.created_at < toDateTime((timestamp + %(step_size30)s) / 1000)
AND error_id = %(error_id)s AND toString(`$properties`.error_id) = %(error_id)s
GROUP BY timestamp GROUP BY timestamp
ORDER BY timestamp) AS chart_details ORDER BY timestamp) AS chart_details
) AS chart_details30 ON TRUE;""" ) AS chart_details30 ON TRUE;"""

View file

@ -141,7 +141,7 @@ def search_sessions(data: schemas.SessionsSearchPayloadSchema, project: schemas.
) AS users_sessions;""", ) AS users_sessions;""",
full_args) full_args)
elif ids_only: elif ids_only:
main_query = cur.format(query=f"""SELECT DISTINCT ON(s.session_id) s.session_id AS session_id main_query = cur.format(query=f"""SELECT DISTINCT ON(s.session_id) s.session_id
{query_part} {query_part}
ORDER BY s.session_id desc ORDER BY s.session_id desc
LIMIT %(sessions_limit)s OFFSET %(sessions_limit_s)s;""", LIMIT %(sessions_limit)s OFFSET %(sessions_limit_s)s;""",

View file

@ -927,12 +927,12 @@ def authenticate_sso(email: str, internal_id: str):
aud=AUDIENCE, jwt_jti=j_r.jwt_refresh_jti), aud=AUDIENCE, jwt_jti=j_r.jwt_refresh_jti),
"refreshTokenMaxAge": config("JWT_REFRESH_EXPIRATION", cast=int), "refreshTokenMaxAge": config("JWT_REFRESH_EXPIRATION", cast=int),
"spotJwt": authorizers.generate_jwt(user_id=r['userId'], tenant_id=r['tenantId'], "spotJwt": authorizers.generate_jwt(user_id=r['userId'], tenant_id=r['tenantId'],
iat=j_r.spot_jwt_iat, aud=spot.AUDIENCE, for_spot=True), iat=j_r.spot_jwt_iat, aud=spot.AUDIENCE),
"spotRefreshToken": authorizers.generate_jwt_refresh(user_id=r['userId'], "spotRefreshToken": authorizers.generate_jwt_refresh(user_id=r['userId'],
tenant_id=r['tenantId'], tenant_id=r['tenantId'],
iat=j_r.spot_jwt_refresh_iat, iat=j_r.spot_jwt_refresh_iat,
aud=spot.AUDIENCE, aud=spot.AUDIENCE,
jwt_jti=j_r.spot_jwt_refresh_jti, for_spot=True), jwt_jti=j_r.spot_jwt_refresh_jti),
"spotRefreshTokenMaxAge": config("JWT_SPOT_REFRESH_EXPIRATION", cast=int) "spotRefreshTokenMaxAge": config("JWT_SPOT_REFRESH_EXPIRATION", cast=int)
} }
return response return response

View file

@ -46,7 +46,8 @@ rm -rf ./chalicelib/core/sessions/sessions_viewed/sessions_viewed.py
rm -rf ./chalicelib/core/sessions/unprocessed_sessions.py rm -rf ./chalicelib/core/sessions/unprocessed_sessions.py
rm -rf ./chalicelib/core/metrics/modules rm -rf ./chalicelib/core/metrics/modules
rm -rf ./chalicelib/core/socket_ios.py rm -rf ./chalicelib/core/socket_ios.py
rm -rf ./chalicelib/core/sourcemaps rm -rf ./chalicelib/core/sourcemaps.py
rm -rf ./chalicelib/core/sourcemaps_parser.py
rm -rf ./chalicelib/core/user_testing.py rm -rf ./chalicelib/core/user_testing.py
rm -rf ./chalicelib/core/tags.py rm -rf ./chalicelib/core/tags.py
rm -rf ./chalicelib/saml rm -rf ./chalicelib/saml

View file

@ -83,11 +83,9 @@ if (process.env.uws !== "true") {
const uWrapper = function (fn) { const uWrapper = function (fn) {
return (res, req) => { return (res, req) => {
res.id = 1; res.id = 1;
res.aborted = false;
req.startTs = performance.now(); // track request's start timestamp req.startTs = performance.now(); // track request's start timestamp
req.method = req.getMethod(); req.method = req.getMethod();
res.onAborted(() => { res.onAborted(() => {
res.aborted = true;
onAbortedOrFinishedResponse(res); onAbortedOrFinishedResponse(res);
}); });
return fn(req, res); return fn(req, res);

View file

@ -3,50 +3,20 @@ const {getCompressionConfig} = require("./helper");
const {logger} = require('./logger'); const {logger} = require('./logger');
let io; let io;
const getServer = function () {return io;}
const useRedis = process.env.redis === "true"; const getServer = function () {
let inMemorySocketsCache = []; return io;
let lastCacheUpdateTime = 0;
const CACHE_REFRESH_INTERVAL = parseInt(process.env.cacheRefreshInterval) || 5000;
const doFetchAllSockets = async function () {
if (useRedis) {
const now = Date.now();
logger.info(`Using in-memory cache (age: ${now - lastCacheUpdateTime}ms)`);
return inMemorySocketsCache;
} else {
try {
return await io.fetchSockets();
} catch (error) {
logger.error('Error fetching sockets:', error);
return [];
}
}
} }
// Background refresher that runs independently of requests let redisClient;
let cacheRefresher = null; const useRedis = process.env.redis === "true";
function startCacheRefresher() {
if (cacheRefresher) clearInterval(cacheRefresher);
cacheRefresher = setInterval(async () => { if (useRedis) {
const now = Date.now(); const {createClient} = require("redis");
// Only refresh if cache is stale const REDIS_URL = (process.env.REDIS_URL || "localhost:6379").replace(/((^\w+:|^)\/\/|^)/, 'redis://');
if (now - lastCacheUpdateTime >= CACHE_REFRESH_INTERVAL) { redisClient = createClient({url: REDIS_URL});
logger.debug('Background refresh triggered'); redisClient.on("error", (error) => logger.error(`Redis error : ${error}`));
try { void redisClient.connect();
const startTime = performance.now();
const result = await io.fetchSockets();
inMemorySocketsCache = result;
lastCacheUpdateTime = now;
const duration = performance.now() - startTime;
logger.info(`Background refresh complete: ${duration}ms, ${result.length} sockets`);
} catch (error) {
logger.error(`Background refresh error: ${error}`);
}
}
}, CACHE_REFRESH_INTERVAL / 2);
} }
const processSocketsList = function (sockets) { const processSocketsList = function (sockets) {
@ -58,6 +28,24 @@ const processSocketsList = function (sockets) {
return res return res
} }
const doFetchAllSockets = async function () {
if (useRedis) {
try {
let cachedResult = await redisClient.get('fetchSocketsResult');
if (cachedResult) {
return JSON.parse(cachedResult);
}
let result = await io.fetchSockets();
let cachedString = JSON.stringify(processSocketsList(result));
await redisClient.set('fetchSocketsResult', cachedString, {EX: 5});
return result;
} catch (error) {
logger.error('Error setting value with expiration:', error);
}
}
return await io.fetchSockets();
}
const fetchSockets = async function (roomID) { const fetchSockets = async function (roomID) {
if (!io) { if (!io) {
return []; return [];
@ -96,7 +84,6 @@ const createSocketIOServer = function (server, prefix) {
}); });
io.attachApp(server); io.attachApp(server);
} }
startCacheRefresher();
return io; return io;
} }

View file

@ -1,16 +1,3 @@
SELECT 1
FROM (SELECT throwIf(platform = 'ios', 'IOS sessions found')
FROM experimental.sessions) AS raw
LIMIT 1;
SELECT 1
FROM (SELECT throwIf(platform = 'android', 'Android sessions found')
FROM experimental.sessions) AS raw
LIMIT 1;
ALTER TABLE experimental.sessions
MODIFY COLUMN platform Enum8('web'=1,'mobile'=2) DEFAULT 'web';
CREATE OR REPLACE FUNCTION openreplay_version AS() -> 'v1.22.0-ee'; CREATE OR REPLACE FUNCTION openreplay_version AS() -> 'v1.22.0-ee';
SET allow_experimental_json_type = 1; SET allow_experimental_json_type = 1;
@ -164,7 +151,8 @@ CREATE TABLE IF NOT EXISTS product_analytics.events
_timestamp DateTime DEFAULT now() _timestamp DateTime DEFAULT now()
) ENGINE = ReplacingMergeTree(_timestamp) ) ENGINE = ReplacingMergeTree(_timestamp)
ORDER BY (project_id, "$event_name", created_at, session_id) ORDER BY (project_id, "$event_name", created_at, session_id)
TTL _deleted_at + INTERVAL 1 DAY DELETE WHERE _deleted_at != '1970-01-01 00:00:00'; TTL _timestamp + INTERVAL 1 MONTH ,
_deleted_at + INTERVAL 1 DAY DELETE WHERE _deleted_at != '1970-01-01 00:00:00';
-- The list of events that should not be ingested, -- The list of events that should not be ingested,
-- according to a specific event_name and optional properties -- according to a specific event_name and optional properties

View file

@ -9,7 +9,8 @@ CREATE TABLE IF NOT EXISTS experimental.autocomplete
_timestamp DateTime DEFAULT now() _timestamp DateTime DEFAULT now()
) ENGINE = ReplacingMergeTree(_timestamp) ) ENGINE = ReplacingMergeTree(_timestamp)
PARTITION BY toYYYYMM(_timestamp) PARTITION BY toYYYYMM(_timestamp)
ORDER BY (project_id, type, value); ORDER BY (project_id, type, value)
TTL _timestamp + INTERVAL 1 MONTH;
CREATE TABLE IF NOT EXISTS experimental.events CREATE TABLE IF NOT EXISTS experimental.events
( (
@ -86,7 +87,8 @@ CREATE TABLE IF NOT EXISTS experimental.events
_timestamp DateTime DEFAULT now() _timestamp DateTime DEFAULT now()
) ENGINE = ReplacingMergeTree(_timestamp) ) ENGINE = ReplacingMergeTree(_timestamp)
PARTITION BY toYYYYMM(datetime) PARTITION BY toYYYYMM(datetime)
ORDER BY (project_id, datetime, event_type, session_id, message_id); ORDER BY (project_id, datetime, event_type, session_id, message_id)
TTL datetime + INTERVAL 3 MONTH;
@ -106,7 +108,7 @@ CREATE TABLE IF NOT EXISTS experimental.sessions
user_country Enum8('UN'=-128, 'RW'=-127, 'SO'=-126, 'YE'=-125, 'IQ'=-124, 'SA'=-123, 'IR'=-122, 'CY'=-121, 'TZ'=-120, 'SY'=-119, 'AM'=-118, 'KE'=-117, 'CD'=-116, 'DJ'=-115, 'UG'=-114, 'CF'=-113, 'SC'=-112, 'JO'=-111, 'LB'=-110, 'KW'=-109, 'OM'=-108, 'QA'=-107, 'BH'=-106, 'AE'=-105, 'IL'=-104, 'TR'=-103, 'ET'=-102, 'ER'=-101, 'EG'=-100, 'SD'=-99, 'GR'=-98, 'BI'=-97, 'EE'=-96, 'LV'=-95, 'AZ'=-94, 'LT'=-93, 'SJ'=-92, 'GE'=-91, 'MD'=-90, 'BY'=-89, 'FI'=-88, 'AX'=-87, 'UA'=-86, 'MK'=-85, 'HU'=-84, 'BG'=-83, 'AL'=-82, 'PL'=-81, 'RO'=-80, 'XK'=-79, 'ZW'=-78, 'ZM'=-77, 'KM'=-76, 'MW'=-75, 'LS'=-74, 'BW'=-73, 'MU'=-72, 'SZ'=-71, 'RE'=-70, 'ZA'=-69, 'YT'=-68, 'MZ'=-67, 'MG'=-66, 'AF'=-65, 'PK'=-64, 'BD'=-63, 'TM'=-62, 'TJ'=-61, 'LK'=-60, 'BT'=-59, 'IN'=-58, 'MV'=-57, 'IO'=-56, 'NP'=-55, 'MM'=-54, 'UZ'=-53, 'KZ'=-52, 'KG'=-51, 'TF'=-50, 'HM'=-49, 'CC'=-48, 'PW'=-47, 'VN'=-46, 'TH'=-45, 'ID'=-44, 'LA'=-43, 'TW'=-42, 'PH'=-41, 'MY'=-40, 'CN'=-39, 'HK'=-38, 'BN'=-37, 'MO'=-36, 'KH'=-35, 'KR'=-34, 'JP'=-33, 'KP'=-32, 'SG'=-31, 'CK'=-30, 'TL'=-29, 'RU'=-28, 'MN'=-27, 'AU'=-26, 'CX'=-25, 'MH'=-24, 'FM'=-23, 'PG'=-22, 'SB'=-21, 'TV'=-20, 'NR'=-19, 'VU'=-18, 'NC'=-17, 'NF'=-16, 'NZ'=-15, 'FJ'=-14, 'LY'=-13, 'CM'=-12, 'SN'=-11, 'CG'=-10, 'PT'=-9, 'LR'=-8, 'CI'=-7, 'GH'=-6, 'GQ'=-5, 'NG'=-4, 'BF'=-3, 'TG'=-2, 'GW'=-1, 'MR'=0, 'BJ'=1, 'GA'=2, 'SL'=3, 'ST'=4, 'GI'=5, 'GM'=6, 'GN'=7, 'TD'=8, 'NE'=9, 'ML'=10, 'EH'=11, 'TN'=12, 'ES'=13, 'MA'=14, 'MT'=15, 'DZ'=16, 'FO'=17, 'DK'=18, 'IS'=19, 'GB'=20, 'CH'=21, 'SE'=22, 'NL'=23, 'AT'=24, 'BE'=25, 'DE'=26, 'LU'=27, 'IE'=28, 'MC'=29, 'FR'=30, 'AD'=31, 'LI'=32, 'JE'=33, 'IM'=34, 'GG'=35, 'SK'=36, 'CZ'=37, 'NO'=38, 'VA'=39, 'SM'=40, 'IT'=41, 'SI'=42, 'ME'=43, 'HR'=44, 'BA'=45, 'AO'=46, 'NA'=47, 'SH'=48, 'BV'=49, 'BB'=50, 'CV'=51, 'GY'=52, 'GF'=53, 'SR'=54, 'PM'=55, 'GL'=56, 'PY'=57, 'UY'=58, 'BR'=59, 'FK'=60, 'GS'=61, 'JM'=62, 'DO'=63, 'CU'=64, 'MQ'=65, 'BS'=66, 'BM'=67, 'AI'=68, 'TT'=69, 'KN'=70, 'DM'=71, 'AG'=72, 'LC'=73, 'TC'=74, 'AW'=75, 'VG'=76, 'VC'=77, 'MS'=78, 'MF'=79, 'BL'=80, 'GP'=81, 'GD'=82, 'KY'=83, 'BZ'=84, 'SV'=85, 'GT'=86, 'HN'=87, 'NI'=88, 'CR'=89, 'VE'=90, 'EC'=91, 'CO'=92, 'PA'=93, 'HT'=94, 'AR'=95, 'CL'=96, 'BO'=97, 'PE'=98, 'MX'=99, 'PF'=100, 'PN'=101, 'KI'=102, 'TK'=103, 'TO'=104, 'WF'=105, 'WS'=106, 'NU'=107, 'MP'=108, 'GU'=109, 'PR'=110, 'VI'=111, 'UM'=112, 'AS'=113, 'CA'=114, 'US'=115, 'PS'=116, 'RS'=117, 'AQ'=118, 'SX'=119, 'CW'=120, 'BQ'=121, 'SS'=122,'BU'=123, 'VD'=124, 'YD'=125, 'DD'=126), user_country Enum8('UN'=-128, 'RW'=-127, 'SO'=-126, 'YE'=-125, 'IQ'=-124, 'SA'=-123, 'IR'=-122, 'CY'=-121, 'TZ'=-120, 'SY'=-119, 'AM'=-118, 'KE'=-117, 'CD'=-116, 'DJ'=-115, 'UG'=-114, 'CF'=-113, 'SC'=-112, 'JO'=-111, 'LB'=-110, 'KW'=-109, 'OM'=-108, 'QA'=-107, 'BH'=-106, 'AE'=-105, 'IL'=-104, 'TR'=-103, 'ET'=-102, 'ER'=-101, 'EG'=-100, 'SD'=-99, 'GR'=-98, 'BI'=-97, 'EE'=-96, 'LV'=-95, 'AZ'=-94, 'LT'=-93, 'SJ'=-92, 'GE'=-91, 'MD'=-90, 'BY'=-89, 'FI'=-88, 'AX'=-87, 'UA'=-86, 'MK'=-85, 'HU'=-84, 'BG'=-83, 'AL'=-82, 'PL'=-81, 'RO'=-80, 'XK'=-79, 'ZW'=-78, 'ZM'=-77, 'KM'=-76, 'MW'=-75, 'LS'=-74, 'BW'=-73, 'MU'=-72, 'SZ'=-71, 'RE'=-70, 'ZA'=-69, 'YT'=-68, 'MZ'=-67, 'MG'=-66, 'AF'=-65, 'PK'=-64, 'BD'=-63, 'TM'=-62, 'TJ'=-61, 'LK'=-60, 'BT'=-59, 'IN'=-58, 'MV'=-57, 'IO'=-56, 'NP'=-55, 'MM'=-54, 'UZ'=-53, 'KZ'=-52, 'KG'=-51, 'TF'=-50, 'HM'=-49, 'CC'=-48, 'PW'=-47, 'VN'=-46, 'TH'=-45, 'ID'=-44, 'LA'=-43, 'TW'=-42, 'PH'=-41, 'MY'=-40, 'CN'=-39, 'HK'=-38, 'BN'=-37, 'MO'=-36, 'KH'=-35, 'KR'=-34, 'JP'=-33, 'KP'=-32, 'SG'=-31, 'CK'=-30, 'TL'=-29, 'RU'=-28, 'MN'=-27, 'AU'=-26, 'CX'=-25, 'MH'=-24, 'FM'=-23, 'PG'=-22, 'SB'=-21, 'TV'=-20, 'NR'=-19, 'VU'=-18, 'NC'=-17, 'NF'=-16, 'NZ'=-15, 'FJ'=-14, 'LY'=-13, 'CM'=-12, 'SN'=-11, 'CG'=-10, 'PT'=-9, 'LR'=-8, 'CI'=-7, 'GH'=-6, 'GQ'=-5, 'NG'=-4, 'BF'=-3, 'TG'=-2, 'GW'=-1, 'MR'=0, 'BJ'=1, 'GA'=2, 'SL'=3, 'ST'=4, 'GI'=5, 'GM'=6, 'GN'=7, 'TD'=8, 'NE'=9, 'ML'=10, 'EH'=11, 'TN'=12, 'ES'=13, 'MA'=14, 'MT'=15, 'DZ'=16, 'FO'=17, 'DK'=18, 'IS'=19, 'GB'=20, 'CH'=21, 'SE'=22, 'NL'=23, 'AT'=24, 'BE'=25, 'DE'=26, 'LU'=27, 'IE'=28, 'MC'=29, 'FR'=30, 'AD'=31, 'LI'=32, 'JE'=33, 'IM'=34, 'GG'=35, 'SK'=36, 'CZ'=37, 'NO'=38, 'VA'=39, 'SM'=40, 'IT'=41, 'SI'=42, 'ME'=43, 'HR'=44, 'BA'=45, 'AO'=46, 'NA'=47, 'SH'=48, 'BV'=49, 'BB'=50, 'CV'=51, 'GY'=52, 'GF'=53, 'SR'=54, 'PM'=55, 'GL'=56, 'PY'=57, 'UY'=58, 'BR'=59, 'FK'=60, 'GS'=61, 'JM'=62, 'DO'=63, 'CU'=64, 'MQ'=65, 'BS'=66, 'BM'=67, 'AI'=68, 'TT'=69, 'KN'=70, 'DM'=71, 'AG'=72, 'LC'=73, 'TC'=74, 'AW'=75, 'VG'=76, 'VC'=77, 'MS'=78, 'MF'=79, 'BL'=80, 'GP'=81, 'GD'=82, 'KY'=83, 'BZ'=84, 'SV'=85, 'GT'=86, 'HN'=87, 'NI'=88, 'CR'=89, 'VE'=90, 'EC'=91, 'CO'=92, 'PA'=93, 'HT'=94, 'AR'=95, 'CL'=96, 'BO'=97, 'PE'=98, 'MX'=99, 'PF'=100, 'PN'=101, 'KI'=102, 'TK'=103, 'TO'=104, 'WF'=105, 'WS'=106, 'NU'=107, 'MP'=108, 'GU'=109, 'PR'=110, 'VI'=111, 'UM'=112, 'AS'=113, 'CA'=114, 'US'=115, 'PS'=116, 'RS'=117, 'AQ'=118, 'SX'=119, 'CW'=120, 'BQ'=121, 'SS'=122,'BU'=123, 'VD'=124, 'YD'=125, 'DD'=126),
user_city LowCardinality(String), user_city LowCardinality(String),
user_state LowCardinality(String), user_state LowCardinality(String),
platform Enum8('web'=1,'mobile'=2) DEFAULT 'web', platform Enum8('web'=1,'ios'=2,'android'=3) DEFAULT 'web',
datetime DateTime, datetime DateTime,
timezone LowCardinality(Nullable(String)), timezone LowCardinality(Nullable(String)),
duration UInt32, duration UInt32,
@ -138,6 +140,7 @@ CREATE TABLE IF NOT EXISTS experimental.sessions
) ENGINE = ReplacingMergeTree(_timestamp) ) ENGINE = ReplacingMergeTree(_timestamp)
PARTITION BY toYYYYMMDD(datetime) PARTITION BY toYYYYMMDD(datetime)
ORDER BY (project_id, datetime, session_id) ORDER BY (project_id, datetime, session_id)
TTL datetime + INTERVAL 3 MONTH
SETTINGS index_granularity = 512; SETTINGS index_granularity = 512;
CREATE TABLE IF NOT EXISTS experimental.user_favorite_sessions CREATE TABLE IF NOT EXISTS experimental.user_favorite_sessions
@ -149,7 +152,8 @@ CREATE TABLE IF NOT EXISTS experimental.user_favorite_sessions
sign Int8 sign Int8
) ENGINE = CollapsingMergeTree(sign) ) ENGINE = CollapsingMergeTree(sign)
PARTITION BY toYYYYMM(_timestamp) PARTITION BY toYYYYMM(_timestamp)
ORDER BY (project_id, user_id, session_id); ORDER BY (project_id, user_id, session_id)
TTL _timestamp + INTERVAL 3 MONTH;
CREATE TABLE IF NOT EXISTS experimental.user_viewed_sessions CREATE TABLE IF NOT EXISTS experimental.user_viewed_sessions
( (
@ -159,7 +163,8 @@ CREATE TABLE IF NOT EXISTS experimental.user_viewed_sessions
_timestamp DateTime DEFAULT now() _timestamp DateTime DEFAULT now()
) ENGINE = ReplacingMergeTree(_timestamp) ) ENGINE = ReplacingMergeTree(_timestamp)
PARTITION BY toYYYYMM(_timestamp) PARTITION BY toYYYYMM(_timestamp)
ORDER BY (project_id, user_id, session_id); ORDER BY (project_id, user_id, session_id)
TTL _timestamp + INTERVAL 3 MONTH;
CREATE TABLE IF NOT EXISTS experimental.user_viewed_errors CREATE TABLE IF NOT EXISTS experimental.user_viewed_errors
( (
@ -169,7 +174,8 @@ CREATE TABLE IF NOT EXISTS experimental.user_viewed_errors
_timestamp DateTime DEFAULT now() _timestamp DateTime DEFAULT now()
) ENGINE = ReplacingMergeTree(_timestamp) ) ENGINE = ReplacingMergeTree(_timestamp)
PARTITION BY toYYYYMM(_timestamp) PARTITION BY toYYYYMM(_timestamp)
ORDER BY (project_id, user_id, error_id); ORDER BY (project_id, user_id, error_id)
TTL _timestamp + INTERVAL 3 MONTH;
CREATE TABLE IF NOT EXISTS experimental.issues CREATE TABLE IF NOT EXISTS experimental.issues
( (
@ -182,7 +188,8 @@ CREATE TABLE IF NOT EXISTS experimental.issues
_timestamp DateTime DEFAULT now() _timestamp DateTime DEFAULT now()
) ENGINE = ReplacingMergeTree(_timestamp) ) ENGINE = ReplacingMergeTree(_timestamp)
PARTITION BY toYYYYMM(_timestamp) PARTITION BY toYYYYMM(_timestamp)
ORDER BY (project_id, issue_id, type); ORDER BY (project_id, issue_id, type)
TTL _timestamp + INTERVAL 3 MONTH;
@ -285,7 +292,8 @@ CREATE TABLE IF NOT EXISTS experimental.sessions_feature_flags
_timestamp DateTime DEFAULT now() _timestamp DateTime DEFAULT now()
) ENGINE = ReplacingMergeTree(_timestamp) ) ENGINE = ReplacingMergeTree(_timestamp)
PARTITION BY toYYYYMM(datetime) PARTITION BY toYYYYMM(datetime)
ORDER BY (project_id, datetime, session_id, feature_flag_id, condition_id); ORDER BY (project_id, datetime, session_id, feature_flag_id, condition_id)
TTL datetime + INTERVAL 3 MONTH;
CREATE TABLE IF NOT EXISTS experimental.ios_events CREATE TABLE IF NOT EXISTS experimental.ios_events
( (
@ -321,7 +329,8 @@ CREATE TABLE IF NOT EXISTS experimental.ios_events
_timestamp DateTime DEFAULT now() _timestamp DateTime DEFAULT now()
) ENGINE = ReplacingMergeTree(_timestamp) ) ENGINE = ReplacingMergeTree(_timestamp)
PARTITION BY toYYYYMM(datetime) PARTITION BY toYYYYMM(datetime)
ORDER BY (project_id, datetime, event_type, session_id, message_id); ORDER BY (project_id, datetime, event_type, session_id, message_id)
TTL datetime + INTERVAL 3 MONTH;
SET allow_experimental_json_type = 1; SET allow_experimental_json_type = 1;
@ -475,7 +484,8 @@ CREATE TABLE IF NOT EXISTS product_analytics.events
_timestamp DateTime DEFAULT now() _timestamp DateTime DEFAULT now()
) ENGINE = ReplacingMergeTree(_timestamp) ) ENGINE = ReplacingMergeTree(_timestamp)
ORDER BY (project_id, "$event_name", created_at, session_id) ORDER BY (project_id, "$event_name", created_at, session_id)
TTL _deleted_at + INTERVAL 1 DAY DELETE WHERE _deleted_at != '1970-01-01 00:00:00'; TTL _timestamp + INTERVAL 1 MONTH ,
_deleted_at + INTERVAL 1 DAY DELETE WHERE _deleted_at != '1970-01-01 00:00:00';
-- The list of events that should not be ingested, -- The list of events that should not be ingested,
-- according to a specific event_name and optional properties -- according to a specific event_name and optional properties

View file

@ -1,4 +1,5 @@
import withSiteIdUpdater from 'HOCs/withSiteIdUpdater'; import withSiteIdUpdater from 'HOCs/withSiteIdUpdater';
import withSiteIdUpdater from 'HOCs/withSiteIdUpdater';
import React, { Suspense, lazy } from 'react'; import React, { Suspense, lazy } from 'react';
import { Redirect, Route, Switch } from 'react-router-dom'; import { Redirect, Route, Switch } from 'react-router-dom';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
@ -9,7 +10,7 @@ import { Loader } from 'UI';
import APIClient from './api_client'; import APIClient from './api_client';
import * as routes from './routes'; import * as routes from './routes';
import { debounceCall } from '@/utils'; import { debounce } from '@/utils';
const components: any = { const components: any = {
SessionPure: lazy(() => import('Components/Session/Session')), SessionPure: lazy(() => import('Components/Session/Session')),
@ -87,6 +88,7 @@ const ASSIST_PATH = routes.assist();
const LIVE_SESSION_PATH = routes.liveSession(); const LIVE_SESSION_PATH = routes.liveSession();
const MULTIVIEW_PATH = routes.multiview(); const MULTIVIEW_PATH = routes.multiview();
const MULTIVIEW_INDEX_PATH = routes.multiviewIndex(); const MULTIVIEW_INDEX_PATH = routes.multiviewIndex();
const ASSIST_STATS_PATH = routes.assistStats();
const USABILITY_TESTING_PATH = routes.usabilityTesting(); const USABILITY_TESTING_PATH = routes.usabilityTesting();
const USABILITY_TESTING_EDIT_PATH = routes.usabilityTestingEdit(); const USABILITY_TESTING_EDIT_PATH = routes.usabilityTestingEdit();
@ -97,6 +99,7 @@ const SPOT_PATH = routes.spot();
const SCOPE_SETUP = routes.scopeSetup(); const SCOPE_SETUP = routes.scopeSetup();
const HIGHLIGHTS_PATH = routes.highlights(); const HIGHLIGHTS_PATH = routes.highlights();
let debounceSearch: any = () => {};
function PrivateRoutes() { function PrivateRoutes() {
const { projectsStore, userStore, integrationsStore, searchStore } = useStore(); const { projectsStore, userStore, integrationsStore, searchStore } = useStore();
@ -121,10 +124,14 @@ function PrivateRoutes() {
} }
}, [siteId]); }, [siteId]);
React.useEffect(() => {
debounceSearch = debounce(() => searchStore.fetchSessions(), 500);
}, []);
React.useEffect(() => { React.useEffect(() => {
if (!searchStore.urlParsed) return; if (!searchStore.urlParsed) return;
debounceCall(() => searchStore.fetchSessions(true), 250)() debounceSearch();
}, [searchStore.urlParsed, searchStore.instance.filters, searchStore.instance.eventsOrder]); }, [searchStore.instance.filters, searchStore.instance.eventsOrder]);
return ( return (
<Suspense fallback={<Loader loading className="flex-1" />}> <Suspense fallback={<Loader loading className="flex-1" />}>

View file

@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import cn from 'classnames'; import cn from 'classnames';
import Counter from 'App/components/shared/SessionItem/Counter'; import Counter from 'App/components/shared/SessionItem/Counter';
import { useDraggable } from '@neodrag/react'; import Draggable from 'react-draggable';
import type { LocalStream } from 'Player'; import type { LocalStream } from 'Player';
import { PlayerContext } from 'App/components/Session/playerContext'; import { PlayerContext } from 'App/components/Session/playerContext';
import ChatControls from '../ChatControls/ChatControls'; import ChatControls from '../ChatControls/ChatControls';
@ -25,8 +25,6 @@ function ChatWindow({
isPrestart, isPrestart,
}: Props) { }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const dragRef = React.useRef<HTMLDivElement>(null);
useDraggable(dragRef, { bounds: 'body', defaultPosition: { x: 50, y: 200 } })
const { player } = React.useContext(PlayerContext); const { player } = React.useContext(PlayerContext);
const { toggleVideoLocalStream } = player.assistManager; const { toggleVideoLocalStream } = player.assistManager;
@ -41,7 +39,11 @@ function ChatWindow({
}, [localVideoEnabled]); }, [localVideoEnabled]);
return ( return (
<div ref={dragRef}> <Draggable
handle=".handle"
bounds="body"
defaultPosition={{ x: 50, y: 200 }}
>
<div <div
className={cn(stl.wrapper, 'fixed radius bg-white shadow-xl mt-16')} className={cn(stl.wrapper, 'fixed radius bg-white shadow-xl mt-16')}
style={{ width: '280px' }} style={{ width: '280px' }}
@ -100,7 +102,7 @@ function ChatWindow({
isPrestart={isPrestart} isPrestart={isPrestart}
/> />
</div> </div>
</div> </Draggable>
); );
} }

View file

@ -16,10 +16,10 @@ function ProfilerDoc() {
? sites.find((site) => site.id === siteId)?.projectKey ? sites.find((site) => site.id === siteId)?.projectKey
: sites[0]?.projectKey; : sites[0]?.projectKey;
const usage = `import OpenReplay from '@openreplay/tracker'; const usage = `import { tracker } from '@openreplay/tracker';
import trackerProfiler from '@openreplay/tracker-profiler'; import trackerProfiler from '@openreplay/tracker-profiler';
//... //...
const tracker = new OpenReplay({ tracker.configure({
projectKey: '${projectKey}' projectKey: '${projectKey}'
}); });
tracker.start() tracker.start()
@ -29,10 +29,12 @@ export const profiler = tracker.use(trackerProfiler());
const fn = profiler('call_name')(() => { const fn = profiler('call_name')(() => {
//... //...
}, thisArg); // thisArg is optional`; }, thisArg); // thisArg is optional`;
const usageCjs = `import OpenReplay from '@openreplay/tracker/cjs'; const usageCjs = `import { tracker } from '@openreplay/tracker/cjs';
// alternatively you can use dynamic import without /cjs suffix to prevent issues with window scope
import trackerProfiler from '@openreplay/tracker-profiler/cjs'; import trackerProfiler from '@openreplay/tracker-profiler/cjs';
//... //...
const tracker = new OpenReplay({ tracker.configure({
projectKey: '${projectKey}' projectKey: '${projectKey}'
}); });
//... //...

View file

@ -7,17 +7,19 @@ import { useTranslation } from 'react-i18next';
function AssistNpm(props) { function AssistNpm(props) {
const { t } = useTranslation(); const { t } = useTranslation();
const usage = `import OpenReplay from '@openreplay/tracker'; const usage = `import { tracker } from '@openreplay/tracker';
import trackerAssist from '@openreplay/tracker-assist'; import trackerAssist from '@openreplay/tracker-assist';
const tracker = new OpenReplay({ tracker.configure({
projectKey: '${props.projectKey}', projectKey: '${props.projectKey}',
}); });
tracker.start() tracker.start()
tracker.use(trackerAssist(options)); // check the list of available options below`; tracker.use(trackerAssist(options)); // check the list of available options below`;
const usageCjs = `import OpenReplay from '@openreplay/tracker/cjs'; const usageCjs = `import { tracker } from '@openreplay/tracker/cjs';
// alternatively you can use dynamic import without /cjs suffix to prevent issues with window scope
import trackerAssist from '@openreplay/tracker-assist/cjs'; import trackerAssist from '@openreplay/tracker-assist/cjs';
const tracker = new OpenReplay({
tracker.configure({
projectKey: '${props.projectKey}' projectKey: '${props.projectKey}'
}); });
const trackerAssist = tracker.use(trackerAssist(options)); // check the list of available options below const trackerAssist = tracker.use(trackerAssist(options)); // check the list of available options below

View file

@ -14,19 +14,20 @@ function GraphQLDoc() {
const projectKey = siteId const projectKey = siteId
? sites.find((site) => site.id === siteId)?.projectKey ? sites.find((site) => site.id === siteId)?.projectKey
: sites[0]?.projectKey; : sites[0]?.projectKey;
const usage = `import OpenReplay from '@openreplay/tracker'; const usage = `import { tracker } from '@openreplay/tracker';
import trackerGraphQL from '@openreplay/tracker-graphql'; import trackerGraphQL from '@openreplay/tracker-graphql';
//... //...
const tracker = new OpenReplay({ tracker.configure({
projectKey: '${projectKey}' projectKey: '${projectKey}'
}); });
tracker.start() tracker.start()
//... //...
export const recordGraphQL = tracker.use(trackerGraphQL());`; export const recordGraphQL = tracker.use(trackerGraphQL());`;
const usageCjs = `import OpenReplay from '@openreplay/tracker/cjs'; const usageCjs = `import { tracker } from '@openreplay/tracker/cjs';
// alternatively you can use dynamic import without /cjs suffix to prevent issues with window scope
import trackerGraphQL from '@openreplay/tracker-graphql/cjs'; import trackerGraphQL from '@openreplay/tracker-graphql/cjs';
//... //...
const tracker = new OpenReplay({ tracker.configure({
projectKey: '${projectKey}' projectKey: '${projectKey}'
}); });
//... //...

View file

@ -15,20 +15,21 @@ function MobxDoc() {
? sites.find((site) => site.id === siteId)?.projectKey ? sites.find((site) => site.id === siteId)?.projectKey
: sites[0]?.projectKey; : sites[0]?.projectKey;
const mobxUsage = `import OpenReplay from '@openreplay/tracker'; const mobxUsage = `import { tracker } from '@openreplay/tracker';
import trackerMobX from '@openreplay/tracker-mobx'; import trackerMobX from '@openreplay/tracker-mobx';
//... //...
const tracker = new OpenReplay({ tracker.configure({
projectKey: '${projectKey}' projectKey: '${projectKey}'
}); });
tracker.use(trackerMobX(<options>)); // check list of available options below tracker.use(trackerMobX(<options>)); // check list of available options below
tracker.start(); tracker.start();
`; `;
const mobxUsageCjs = `import OpenReplay from '@openreplay/tracker/cjs'; const mobxUsageCjs = `import { tracker } from '@openreplay/tracker/cjs';
// alternatively you can use dynamic import without /cjs suffix to prevent issues with window scope
import trackerMobX from '@openreplay/tracker-mobx/cjs'; import trackerMobX from '@openreplay/tracker-mobx/cjs';
//... //...
const tracker = new OpenReplay({ tracker.configure({
projectKey: '${projectKey}' projectKey: '${projectKey}'
}); });
tracker.use(trackerMobX(<options>)); // check list of available options below tracker.use(trackerMobX(<options>)); // check list of available options below

View file

@ -16,10 +16,10 @@ function NgRxDoc() {
: sites[0]?.projectKey; : sites[0]?.projectKey;
const usage = `import { StoreModule } from '@ngrx/store'; const usage = `import { StoreModule } from '@ngrx/store';
import { reducers } from './reducers'; import { reducers } from './reducers';
import OpenReplay from '@openreplay/tracker'; import { tracker } from '@openreplay/tracker';
import trackerNgRx from '@openreplay/tracker-ngrx'; import trackerNgRx from '@openreplay/tracker-ngrx';
//... //...
const tracker = new OpenReplay({ tracker.configure({
projectKey: '${projectKey}' projectKey: '${projectKey}'
}); });
tracker.start() tracker.start()
@ -32,10 +32,11 @@ const metaReducers = [tracker.use(trackerNgRx(<options>))]; // check list of ava
export class AppModule {}`; export class AppModule {}`;
const usageCjs = `import { StoreModule } from '@ngrx/store'; const usageCjs = `import { StoreModule } from '@ngrx/store';
import { reducers } from './reducers'; import { reducers } from './reducers';
import OpenReplay from '@openreplay/tracker/cjs'; import { tracker } from '@openreplay/tracker/cjs';
// alternatively you can use dynamic import without /cjs suffix to prevent issues with window scope
import trackerNgRx from '@openreplay/tracker-ngrx/cjs'; import trackerNgRx from '@openreplay/tracker-ngrx/cjs';
//... //...
const tracker = new OpenReplay({ tracker.configure({
projectKey: '${projectKey}' projectKey: '${projectKey}'
}); });
//... //...

View file

@ -17,10 +17,10 @@ function PiniaDoc() {
? sites.find((site) => site.id === siteId)?.projectKey ? sites.find((site) => site.id === siteId)?.projectKey
: sites[0]?.projectKey; : sites[0]?.projectKey;
const usage = `import Vuex from 'vuex' const usage = `import Vuex from 'vuex'
import OpenReplay from '@openreplay/tracker'; import { tracker } from '@openreplay/tracker';
import trackerVuex from '@openreplay/tracker-vuex'; import trackerVuex from '@openreplay/tracker-vuex';
//... //...
const tracker = new OpenReplay({ tracker.configure({
projectKey: '${projectKey}' projectKey: '${projectKey}'
}); });
tracker.start() tracker.start()

View file

@ -16,10 +16,10 @@ function ReduxDoc() {
: sites[0]?.projectKey; : sites[0]?.projectKey;
const usage = `import { applyMiddleware, createStore } from 'redux'; const usage = `import { applyMiddleware, createStore } from 'redux';
import OpenReplay from '@openreplay/tracker'; import { tracker } from '@openreplay/tracker';
import trackerRedux from '@openreplay/tracker-redux'; import trackerRedux from '@openreplay/tracker-redux';
//... //...
const tracker = new OpenReplay({ tracker.configure({
projectKey: '${projectKey}' projectKey: '${projectKey}'
}); });
tracker.start() tracker.start()
@ -29,10 +29,11 @@ const store = createStore(
applyMiddleware(tracker.use(trackerRedux(<options>))) // check list of available options below applyMiddleware(tracker.use(trackerRedux(<options>))) // check list of available options below
);`; );`;
const usageCjs = `import { applyMiddleware, createStore } from 'redux'; const usageCjs = `import { applyMiddleware, createStore } from 'redux';
import OpenReplay from '@openreplay/tracker/cjs'; import { tracker } from '@openreplay/tracker/cjs';
// alternatively you can use dynamic import without /cjs suffix to prevent issues with window scope
import trackerRedux from '@openreplay/tracker-redux/cjs'; import trackerRedux from '@openreplay/tracker-redux/cjs';
//... //...
const tracker = new OpenReplay({ tracker.configure({
projectKey: '${projectKey}' projectKey: '${projectKey}'
}); });
//... //...

View file

@ -16,10 +16,10 @@ function VueDoc() {
: sites[0]?.projectKey; : sites[0]?.projectKey;
const usage = `import Vuex from 'vuex' const usage = `import Vuex from 'vuex'
import OpenReplay from '@openreplay/tracker'; import { tracker } from '@openreplay/tracker';
import trackerVuex from '@openreplay/tracker-vuex'; import trackerVuex from '@openreplay/tracker-vuex';
//... //...
const tracker = new OpenReplay({ tracker.configure({
projectKey: '${projectKey}' projectKey: '${projectKey}'
}); });
tracker.start() tracker.start()
@ -29,10 +29,11 @@ const store = new Vuex.Store({
plugins: [tracker.use(trackerVuex(<options>))] // check list of available options below plugins: [tracker.use(trackerVuex(<options>))] // check list of available options below
});`; });`;
const usageCjs = `import Vuex from 'vuex' const usageCjs = `import Vuex from 'vuex'
import OpenReplay from '@openreplay/tracker/cjs'; import { tracker } from '@openreplay/tracker/cjs';
// alternatively you can use dynamic import without /cjs suffix to prevent issues with window scope
import trackerVuex from '@openreplay/tracker-vuex/cjs'; import trackerVuex from '@openreplay/tracker-vuex/cjs';
//... //...
const tracker = new OpenReplay({ tracker.configure({
projectKey: '${projectKey}' projectKey: '${projectKey}'
}); });
//... //...

View file

@ -16,11 +16,10 @@ function ZustandDoc(props) {
: sites[0]?.projectKey; : sites[0]?.projectKey;
const usage = `import create from "zustand"; const usage = `import create from "zustand";
import Tracker from '@openreplay/tracker'; import { tracker } from '@openreplay/tracker';
import trackerZustand, { StateLogger } from '@openreplay/tracker-zustand'; import trackerZustand, { StateLogger } from '@openreplay/tracker-zustand';
tracker.configure({
const tracker = new Tracker({
projectKey: ${projectKey}, projectKey: ${projectKey},
}); });
@ -43,11 +42,12 @@ const useBearStore = create(
) )
`; `;
const usageCjs = `import create from "zustand"; const usageCjs = `import create from "zustand";
import Tracker from '@openreplay/tracker/cjs'; import { tracker } from '@openreplay/tracker/cjs';
// alternatively you can use dynamic import without /cjs suffix to prevent issues with window scope
import trackerZustand, { StateLogger } from '@openreplay/tracker-zustand/cjs'; import trackerZustand, { StateLogger } from '@openreplay/tracker-zustand/cjs';
const tracker = new Tracker({ tracker.configure({
projectKey: ${projectKey}, projectKey: ${projectKey},
}); });

View file

@ -24,7 +24,7 @@ function ModuleCard(props: Props) {
<Switch <Switch
size="small" size="small"
checked={!module.isEnabled} checked={!module.isEnabled}
title={!module.isEnabled ? 'Enabled' : 'Disabled'} title={module.isEnabled ? 'Enabled' : 'Disabled'}
onChange={() => props.onToggle(module)} onChange={() => props.onToggle(module)}
/> />
</div> </div>

View file

@ -40,12 +40,11 @@ function Modules() {
}; };
useEffect(() => { useEffect(() => {
const moduleList = list(t) list(t).forEach((module) => {
moduleList.forEach((module) => {
module.isEnabled = modules.includes(module.key); module.isEnabled = modules.includes(module.key);
}); });
setModulesState( setModulesState(
moduleList.filter( list(t).filter(
(module) => !module.hidden && (!module.enterprise || isEnterprise), (module) => !module.hidden && (!module.enterprise || isEnterprise),
), ),
); );

View file

@ -6,7 +6,6 @@ import DefaultPlaying from 'Shared/SessionSettings/components/DefaultPlaying';
import DefaultTimezone from 'Shared/SessionSettings/components/DefaultTimezone'; import DefaultTimezone from 'Shared/SessionSettings/components/DefaultTimezone';
import ListingVisibility from 'Shared/SessionSettings/components/ListingVisibility'; import ListingVisibility from 'Shared/SessionSettings/components/ListingVisibility';
import MouseTrailSettings from 'Shared/SessionSettings/components/MouseTrailSettings'; import MouseTrailSettings from 'Shared/SessionSettings/components/MouseTrailSettings';
import VirtualModeSettings from '../shared/SessionSettings/components/VirtualMode';
import DebugLog from './DebugLog'; import DebugLog from './DebugLog';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -36,7 +35,6 @@ function SessionsListingSettings() {
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<MouseTrailSettings /> <MouseTrailSettings />
<DebugLog /> <DebugLog />
<VirtualModeSettings />
</div> </div>
</div> </div>
</div> </div>

View file

@ -6,7 +6,6 @@ import CardSessionsByList from 'Components/Dashboard/Widgets/CardSessionsByList'
import { useModal } from 'Components/ModalContext'; import { useModal } from 'Components/ModalContext';
import Widget from '@/mstore/types/widget'; import Widget from '@/mstore/types/widget';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { FilterKey } from 'Types/filter/filterType';
interface Props { interface Props {
metric?: any; metric?: any;
@ -36,20 +35,20 @@ function SessionsBy(props: Props) {
...filtersMap[metric.metricOf], ...filtersMap[metric.metricOf],
value: [data.name], value: [data.name],
type: filtersMap[metric.metricOf].key, type: filtersMap[metric.metricOf].key,
filters: [], filters: filtersMap[metric.metricOf].filters?.map((f: any) => {
const {
key,
operatorOptions,
category,
icon,
label,
options,
...cleaned
} = f;
return { ...cleaned, type: f.key, value: [] };
}),
}; };
if (metric.metricOf === FilterKey.FETCH) {
baseFilter.filters = [
{
key: FilterKey.FETCH_URL,
operator: 'is',
value: [data.name],
type: FilterKey.FETCH_URL,
}
];
}
const { const {
key, key,
operatorOptions, operatorOptions,

View file

@ -23,7 +23,6 @@ function BottomButtons({
<Button <Button
loading={loading} loading={loading}
type="primary" type="primary"
htmlType="submit"
disabled={loading || !instance.validate()} disabled={loading || !instance.validate()}
id="submit-button" id="submit-button"
> >

View file

@ -43,7 +43,7 @@ function ClickMapRagePicker() {
<Checkbox onChange={onToggle} label={t('Include rage clicks')} /> <Checkbox onChange={onToggle} label={t('Include rage clicks')} />
<Button size="small" onClick={refreshHeatmapSession}> <Button size="small" onClick={refreshHeatmapSession}>
{t('Get new image')} {t('Get new session')}
</Button> </Button>
</div> </div>
); );

View file

@ -64,7 +64,6 @@ function DashboardView(props: Props) {
}; };
useEffect(() => { useEffect(() => {
dashboardStore.resetPeriod();
if (queryParams.has('modal')) { if (queryParams.has('modal')) {
onAddWidgets(); onAddWidgets();
trimQuery(); trimQuery();

View file

@ -117,6 +117,8 @@ const ListView: React.FC<Props> = ({
if (disableSelection) { if (disableSelection) {
const path = withSiteId(`/metrics/${metric.metricId}`, siteId); const path = withSiteId(`/metrics/${metric.metricId}`, siteId);
history.push(path); history.push(path);
} else {
toggleSelection?.(metric.metricId);
} }
}; };

View file

@ -68,7 +68,7 @@ function MetricsList({
}, [metricStore]); }, [metricStore]);
const isFiltered = metricStore.filter.query !== '' || metricStore.filter.type !== ''; const isFiltered = metricStore.filter.query !== '' || metricStore.filter.type !== 'all';
const searchImageDimensions = { width: 60, height: 'auto' }; const searchImageDimensions = { width: 60, height: 'auto' };
const defaultImageDimensions = { width: 600, height: 'auto' }; const defaultImageDimensions = { width: 600, height: 'auto' };

View file

@ -181,10 +181,9 @@ function WidgetChart(props: Props) {
} }
prevMetricRef.current = _metric; prevMetricRef.current = _metric;
const timestmaps = drillDownPeriod.toTimestamps(); const timestmaps = drillDownPeriod.toTimestamps();
const density = props.isPreview ? metric.density : dashboardStore.selectedDensity
const payload = isSaved const payload = isSaved
? { ...metricParams, density } ? { ...metricParams }
: { ...params, ...timestmaps, ..._metric.toJson(), density }; : { ...params, ...timestmaps, ..._metric.toJson() };
debounceRequest( debounceRequest(
_metric, _metric,
payload, payload,

View file

@ -11,7 +11,6 @@ import { useTranslation } from 'react-i18next';
const initTableProps = [ const initTableProps = [
{ {
title: <span className="font-medium">Series</span>, title: <span className="font-medium">Series</span>,
_pureTitle: 'Series',
dataIndex: 'seriesName', dataIndex: 'seriesName',
key: 'seriesName', key: 'seriesName',
sorter: (a, b) => a.seriesName.localeCompare(b.seriesName), sorter: (a, b) => a.seriesName.localeCompare(b.seriesName),
@ -19,7 +18,6 @@ const initTableProps = [
}, },
{ {
title: <span className="font-medium">Avg.</span>, title: <span className="font-medium">Avg.</span>,
_pureTitle: 'Avg.',
dataIndex: 'average', dataIndex: 'average',
key: 'average', key: 'average',
sorter: (a, b) => a.average - b.average, sorter: (a, b) => a.average - b.average,
@ -96,8 +94,6 @@ function WidgetDatatable(props: Props) {
tableCols.push({ tableCols.push({
title: <span className="font-medium">{name}</span>, title: <span className="font-medium">{name}</span>,
dataIndex: `${name}_${i}`, dataIndex: `${name}_${i}`,
// @ts-ignore
_pureTitle: name,
key: `${name}_${i}`, key: `${name}_${i}`,
sorter: (a, b) => a[`${name}_${i}`] - b[`${name}_${i}`], sorter: (a, b) => a[`${name}_${i}`] - b[`${name}_${i}`],
}); });

View file

@ -55,7 +55,7 @@ function RangeGranularity({
} }
const PAST_24_HR_MS = 24 * 60 * 60 * 1000; const PAST_24_HR_MS = 24 * 60 * 60 * 1000;
export function calculateGranularities(periodDurationMs: number) { function calculateGranularities(periodDurationMs: number) {
const granularities = [ const granularities = [
{ label: 'Hourly', durationMs: 60 * 60 * 1000 }, { label: 'Hourly', durationMs: 60 * 60 * 1000 },
{ label: 'Daily', durationMs: 24 * 60 * 60 * 1000 }, { label: 'Daily', durationMs: 24 * 60 * 60 * 1000 },

View file

@ -1,395 +1,376 @@
import React, {useEffect, useState} from 'react'; import React, { useEffect, useState } from 'react';
import {NoContent, Loader, Pagination} from 'UI'; import { NoContent, Loader, Pagination } from 'UI';
import {Button, Tag, Tooltip, Dropdown, message} from 'antd'; import { Button, Tag, Tooltip, Dropdown, message } from 'antd';
import {UndoOutlined, DownOutlined} from '@ant-design/icons'; import { UndoOutlined, DownOutlined } from '@ant-design/icons';
import cn from 'classnames'; import cn from 'classnames';
import {useStore} from 'App/mstore'; import { useStore } from 'App/mstore';
import SessionItem from 'Shared/SessionItem'; import SessionItem from 'Shared/SessionItem';
import {observer} from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import {DateTime} from 'luxon'; import { DateTime } from 'luxon';
import {debounce, numberWithCommas} from 'App/utils'; import { debounce, numberWithCommas } from 'App/utils';
import useIsMounted from 'App/hooks/useIsMounted'; import useIsMounted from 'App/hooks/useIsMounted';
import AnimatedSVG, {ICONS} from 'Shared/AnimatedSVG/AnimatedSVG'; import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
import {HEATMAP, USER_PATH, FUNNEL} from 'App/constants/card'; import { HEATMAP, USER_PATH, FUNNEL } from 'App/constants/card';
import {useTranslation} from 'react-i18next'; import { useTranslation } from 'react-i18next';
interface Props { interface Props {
className?: string; className?: string;
} }
function WidgetSessions(props: Props) { function WidgetSessions(props: Props) {
const {t} = useTranslation(); const { t } = useTranslation();
const listRef = React.useRef<HTMLDivElement>(null); const listRef = React.useRef<HTMLDivElement>(null);
const {className = ''} = props; const { className = '' } = props;
const [activeSeries, setActiveSeries] = useState('all'); const [activeSeries, setActiveSeries] = useState('all');
const [data, setData] = useState<any>([]); const [data, setData] = useState<any>([]);
const isMounted = useIsMounted(); const isMounted = useIsMounted();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
// all filtering done through series now // all filtering done through series now
const filteredSessions = getListSessionsBySeries(data, 'all'); const filteredSessions = getListSessionsBySeries(data, 'all');
const {dashboardStore, metricStore, sessionStore, customFieldStore} = const { dashboardStore, metricStore, sessionStore, customFieldStore } =
useStore(); useStore();
const focusedSeries = metricStore.focusedSeriesName; const focusedSeries = metricStore.focusedSeriesName;
const filter = dashboardStore.drillDownFilter; const filter = dashboardStore.drillDownFilter;
const widget = metricStore.instance; const widget = metricStore.instance;
const startTime = DateTime.fromMillis(filter.startTimestamp).toFormat( const startTime = DateTime.fromMillis(filter.startTimestamp).toFormat(
'LLL dd, yyyy HH:mm', 'LLL dd, yyyy HH:mm',
); );
const endTime = DateTime.fromMillis(filter.endTimestamp).toFormat( const endTime = DateTime.fromMillis(filter.endTimestamp).toFormat(
'LLL dd, yyyy HH:mm', 'LLL dd, yyyy HH:mm',
); );
const [seriesOptions, setSeriesOptions] = useState([ const [seriesOptions, setSeriesOptions] = useState([
{label: t('All'), value: 'all'}, { label: t('All'), value: 'all' },
]); ]);
const hasFilters = const hasFilters =
filter.filters.length > 0 || filter.filters.length > 0 ||
filter.startTimestamp !== dashboardStore.drillDownPeriod.start || filter.startTimestamp !== dashboardStore.drillDownPeriod.start ||
filter.endTimestamp !== dashboardStore.drillDownPeriod.end; filter.endTimestamp !== dashboardStore.drillDownPeriod.end;
const filterText = filter.filters.length > 0 ? filter.filters[0].value : ''; const filterText = filter.filters.length > 0 ? filter.filters[0].value : '';
const metaList = customFieldStore.list.map((i: any) => i.key); const metaList = customFieldStore.list.map((i: any) => i.key);
const seriesDropdownItems = seriesOptions.map((option) => ({ const seriesDropdownItems = seriesOptions.map((option) => ({
key: option.value, key: option.value,
label: ( label: (
<div onClick={() => setActiveSeries(option.value)}>{option.label}</div> <div onClick={() => setActiveSeries(option.value)}>{option.label}</div>
), ),
}));
useEffect(() => {
if (!widget.series) return;
const seriesOptions = widget.series.map((item: any) => ({
label: item.name,
value: item.seriesId ?? item.name,
})); }));
setSeriesOptions([{ label: t('All'), value: 'all' }, ...seriesOptions]);
}, [widget.series.length]);
useEffect(() => { const fetchSessions = (metricId: any, filter: any) => {
if (!widget.series) return; if (!isMounted()) return;
const seriesOptions = widget.series.map((item: any) => ({ setLoading(true);
label: item.name, delete filter.eventsOrderSupport;
value: item.seriesId ?? item.name, if (widget.metricType === FUNNEL) {
})); if (filter.series[0].filter.filters.length === 0) {
setSeriesOptions([{label: t('All'), value: 'all'}, ...seriesOptions]); setLoading(false);
}, [widget.series.length]); return setData([]);
}
}
const fetchSessions = (metricId: any, filter: any) => { widget
if (!isMounted()) return; .fetchSessions(metricId, filter)
.then((res: any) => {
if (widget.metricType === FUNNEL) { setData(res);
if (filter.series[0].filter.filters.length === 0) { if (metricStore.drillDown) {
setLoading(false); setTimeout(() => {
return setData([]); message.info(t('Sessions Refreshed!'));
} listRef.current?.scrollIntoView({ behavior: 'smooth' });
metricStore.setDrillDown(false);
}, 0);
} }
})
.finally(() => {
setLoading(false);
});
};
const fetchClickmapSessions = (customFilters: Record<string, any>) => {
sessionStore.getSessions(customFilters).then((data) => {
setData([{ ...data, seriesId: 1, seriesName: 'Clicks' }]);
});
};
const debounceRequest: any = React.useCallback(
debounce(fetchSessions, 1000),
[],
);
const debounceClickMapSearch = React.useCallback(
debounce(fetchClickmapSessions, 1000),
[],
);
const depsString = JSON.stringify(widget.series);
setLoading(true); const loadData = () => {
const filterCopy = {...filter}; if (widget.metricType === HEATMAP && metricStore.clickMapSearch) {
delete filterCopy.eventsOrderSupport; const clickFilter = {
value: [metricStore.clickMapSearch],
try { type: 'CLICK',
// Handle filters properly with null checks operator: 'onSelector',
if (filterCopy.filters && filterCopy.filters.length > 0) { isEvent: true,
// Ensure the nested path exists before pushing // @ts-ignore
if (filterCopy.series?.[0]?.filter) { filters: [],
if (!filterCopy.series[0].filter.filters) { };
filterCopy.series[0].filter.filters = []; const timeRange = {
} rangeValue: dashboardStore.drillDownPeriod.rangeValue,
filterCopy.series[0].filter.filters.push(...filterCopy.filters); startDate: dashboardStore.drillDownPeriod.start,
} endDate: dashboardStore.drillDownPeriod.end,
filterCopy.filters = []; };
} const customFilter = {
} catch (e) { ...filter,
// do nothing ...timeRange,
filters: [...sessionStore.userFilter.filters, clickFilter],
};
debounceClickMapSearch(customFilter);
} else {
const hasStartPoint =
!!widget.startPoint && widget.metricType === USER_PATH;
const onlyFocused = focusedSeries
? widget.series.filter((s) => s.name === focusedSeries)
: widget.series;
const activeSeries = metricStore.disabledSeries.length
? onlyFocused.filter(
(s) => !metricStore.disabledSeries.includes(s.name),
)
: onlyFocused;
const seriesJson = activeSeries.map((s) => s.toJson());
if (hasStartPoint) {
seriesJson[0].filter.filters.push(widget.startPoint.toJson());
}
if (widget.metricType === USER_PATH) {
if (
seriesJson[0].filter.filters[0].value[0] === '' &&
widget.data.nodes
) {
seriesJson[0].filter.filters[0].value = widget.data.nodes[0].name;
} else if (
seriesJson[0].filter.filters[0].value[0] === '' &&
!widget.data.nodes?.length
) {
// no point requesting if we don't have starting point picked by api
return;
} }
widget }
.fetchSessions(metricId, filterCopy) debounceRequest(widget.metricId, {
.then((res: any) => { ...filter,
setData(res); series: seriesJson,
if (metricStore.drillDown) { page: metricStore.sessionsPage,
setTimeout(() => { limit: metricStore.sessionsPageSize,
message.info(t('Sessions Refreshed!')); });
listRef.current?.scrollIntoView({behavior: 'smooth'}); }
metricStore.setDrillDown(false); };
}, 0); useEffect(() => {
} metricStore.updateKey('sessionsPage', 1);
}) loadData();
.finally(() => { }, [
setLoading(false); filter.startTimestamp,
}); filter.endTimestamp,
}; filter.filters,
const fetchClickmapSessions = (customFilters: Record<string, any>) => { depsString,
sessionStore.getSessions(customFilters).then((data) => { metricStore.clickMapSearch,
setData([{...data, seriesId: 1, seriesName: 'Clicks'}]); focusedSeries,
}); widget.startPoint,
}; widget.data.nodes,
const debounceRequest: any = React.useCallback( metricStore.disabledSeries.length,
debounce(fetchSessions, 1000), ]);
[], useEffect(loadData, [metricStore.sessionsPage]);
); useEffect(() => {
const debounceClickMapSearch = React.useCallback( if (activeSeries === 'all') {
debounce(fetchClickmapSessions, 1000), metricStore.setFocusedSeriesName(null);
[], } else {
); metricStore.setFocusedSeriesName(
seriesOptions.find((option) => option.value === activeSeries)?.label,
false,
);
}
}, [activeSeries]);
useEffect(() => {
if (focusedSeries) {
setActiveSeries(
seriesOptions.find((option) => option.label === focusedSeries)?.value ||
'all',
);
} else {
setActiveSeries('all');
}
}, [focusedSeries]);
const depsString = JSON.stringify(widget.series); const clearFilters = () => {
metricStore.updateKey('sessionsPage', 1);
dashboardStore.resetDrillDownFilter();
};
const loadData = () => { return (
if (widget.metricType === HEATMAP && metricStore.clickMapSearch) { <div
const clickFilter = { className={cn(
value: [metricStore.clickMapSearch], className,
type: 'CLICK', 'bg-white p-3 pb-0 rounded-xl shadow-sm border mt-3',
operator: 'onSelector', )}
isEvent: true, >
// @ts-ignore <div className="flex items-center justify-between">
filters: [], <div>
}; <div className="flex items-baseline gap-2">
const timeRange = { <h2 className="text-xl">
rangeValue: dashboardStore.drillDownPeriod.rangeValue, {metricStore.clickMapSearch ? t('Clicks') : t('Sessions')}
startDate: dashboardStore.drillDownPeriod.start, </h2>
endDate: dashboardStore.drillDownPeriod.end, <div className="ml-2 color-gray-medium">
}; {metricStore.clickMapLabel
const customFilter = { ? `on "${metricStore.clickMapLabel}" `
...filter, : null}
...timeRange, {t('between')}{' '}
filters: [...sessionStore.userFilter.filters, clickFilter], <span className="font-medium color-gray-darkest">
};
debounceClickMapSearch(customFilter);
} else {
const hasStartPoint =
!!widget.startPoint && widget.metricType === USER_PATH;
const onlyFocused = focusedSeries
? widget.series.filter((s) => s.name === focusedSeries)
: widget.series;
const activeSeries = metricStore.disabledSeries.length
? onlyFocused.filter(
(s) => !metricStore.disabledSeries.includes(s.name),
)
: onlyFocused;
const seriesJson = activeSeries.map((s) => s.toJson());
if (hasStartPoint) {
seriesJson[0].filter.filters.push(widget.startPoint.toJson());
}
if (widget.metricType === USER_PATH) {
if (
seriesJson[0].filter.filters[0].value[0] === '' &&
widget.data.nodes?.length
) {
seriesJson[0].filter.filters[0].value = widget.data.nodes[0].name;
} else if (
seriesJson[0].filter.filters[0].value[0] === '' &&
!widget.data.nodes?.length
) {
// no point requesting if we don't have starting point picked by api
return;
}
}
debounceRequest(widget.metricId, {
...filter,
series: seriesJson,
page: metricStore.sessionsPage,
limit: metricStore.sessionsPageSize,
});
}
};
useEffect(() => {
metricStore.updateKey('sessionsPage', 1);
loadData();
}, [
filter.startTimestamp,
filter.endTimestamp,
filter.filters,
depsString,
metricStore.clickMapSearch,
focusedSeries,
widget.startPoint,
widget.data.nodes,
metricStore.disabledSeries.length,
]);
useEffect(loadData, [metricStore.sessionsPage]);
useEffect(() => {
if (activeSeries === 'all') {
metricStore.setFocusedSeriesName(null);
} else {
metricStore.setFocusedSeriesName(
seriesOptions.find((option) => option.value === activeSeries)?.label,
false,
);
}
}, [activeSeries]);
useEffect(() => {
if (focusedSeries) {
setActiveSeries(
seriesOptions.find((option) => option.label === focusedSeries)?.value ||
'all',
);
} else {
setActiveSeries('all');
}
}, [focusedSeries]);
const clearFilters = () => {
metricStore.updateKey('sessionsPage', 1);
dashboardStore.resetDrillDownFilter();
};
return (
<div
className={cn(
className,
'bg-white p-3 pb-0 rounded-xl shadow-sm border mt-3',
)}
>
<div className="flex items-center justify-between">
<div>
<div className="flex items-baseline gap-2">
<h2 className="text-xl">
{metricStore.clickMapSearch ? t('Clicks') : t('Sessions')}
</h2>
<div className="ml-2 color-gray-medium">
{metricStore.clickMapLabel
? `on "${metricStore.clickMapLabel}" `
: null}
{t('between')}{' '}
<span className="font-medium color-gray-darkest">
{startTime} {startTime}
</span>{' '} </span>{' '}
{t('and')}{' '} {t('and')}{' '}
<span className="font-medium color-gray-darkest"> <span className="font-medium color-gray-darkest">
{endTime} {endTime}
</span>{' '} </span>{' '}
</div> </div>
{hasFilters && ( {hasFilters && (
<Tooltip title={t('Clear Drilldown')} placement="top"> <Tooltip title={t('Clear Drilldown')} placement="top">
<Button type="text" size="small" onClick={clearFilters}> <Button type="text" size="small" onClick={clearFilters}>
<UndoOutlined/> <UndoOutlined />
</Button> </Button>
</Tooltip> </Tooltip>
)} )}
</div> </div>
{hasFilters && widget.metricType === 'table' && ( {hasFilters && widget.metricType === 'table' && (
<div className="py-2"> <div className="py-2">
<Tag <Tag
closable closable
onClose={clearFilters} onClose={clearFilters}
className="truncate max-w-44 rounded-lg" className="truncate max-w-44 rounded-lg"
> >
{filterText} {filterText}
</Tag> </Tag>
</div> </div>
)} )}
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{widget.metricType !== 'table' && widget.metricType !== HEATMAP && ( {widget.metricType !== 'table' && widget.metricType !== HEATMAP && (
<div className="flex items-center ml-6"> <div className="flex items-center ml-6">
<span className="mr-2 color-gray-medium"> <span className="mr-2 color-gray-medium">
{t('Filter by Series')} {t('Filter by Series')}
</span> </span>
<Dropdown <Dropdown
menu={{ menu={{
items: seriesDropdownItems, items: seriesDropdownItems,
selectable: true, selectable: true,
selectedKeys: [activeSeries], selectedKeys: [activeSeries],
}} }}
trigger={['click']} trigger={['click']}
> >
<Button type="text" size="small"> <Button type="text" size="small">
{seriesOptions.find((option) => option.value === activeSeries) {seriesOptions.find((option) => option.value === activeSeries)
?.label || t('Select Series')} ?.label || t('Select Series')}
<DownOutlined/> <DownOutlined />
</Button> </Button>
</Dropdown> </Dropdown>
</div>
)}
</div>
</div> </div>
)}
</div>
</div>
<div className="mt-3"> <div className="mt-3">
<Loader loading={loading}> <Loader loading={loading}>
<NoContent <NoContent
title={ title={
<div className="flex items-center justify-center flex-col"> <div className="flex items-center justify-center flex-col">
<AnimatedSVG name={ICONS.NO_SESSIONS} size={60}/> <AnimatedSVG name={ICONS.NO_SESSIONS} size={60} />
<div className="mt-4"/> <div className="mt-4" />
<div className="text-center"> <div className="text-center">
{t('No relevant sessions found for the selected time period')} {t('No relevant sessions found for the selected time period')}
</div> </div>
</div> </div>
} }
show={filteredSessions.sessions.length === 0} show={filteredSessions.sessions.length === 0}
> >
{filteredSessions.sessions.map((session: any) => ( {filteredSessions.sessions.map((session: any) => (
<React.Fragment key={session.sessionId}> <React.Fragment key={session.sessionId}>
<SessionItem <SessionItem
disableUser disableUser
session={session} session={session}
metaList={metaList} metaList={metaList}
/> />
<div className="border-b"/> <div className="border-b" />
</React.Fragment> </React.Fragment>
))} ))}
<div <div
className="flex items-center justify-between p-5" className="flex items-center justify-between p-5"
ref={listRef} ref={listRef}
> >
<div> <div>
{t('Showing')}{' '} {t('Showing')}{' '}
<span className="font-medium"> <span className="font-medium">
{(metricStore.sessionsPage - 1) * {(metricStore.sessionsPage - 1) *
metricStore.sessionsPageSize + metricStore.sessionsPageSize +
1} 1}
</span>{' '} </span>{' '}
{t('to')}{' '} {t('to')}{' '}
<span className="font-medium"> <span className="font-medium">
{(metricStore.sessionsPage - 1) * {(metricStore.sessionsPage - 1) *
metricStore.sessionsPageSize + metricStore.sessionsPageSize +
filteredSessions.sessions.length} filteredSessions.sessions.length}
</span>{' '} </span>{' '}
{t('of')}{' '} {t('of')}{' '}
<span className="font-medium"> <span className="font-medium">
{numberWithCommas(filteredSessions.total)} {numberWithCommas(filteredSessions.total)}
</span>{' '} </span>{' '}
{t('sessions.')} {t('sessions.')}
</div> </div>
<Pagination <Pagination
page={metricStore.sessionsPage} page={metricStore.sessionsPage}
total={filteredSessions.total} total={filteredSessions.total}
onPageChange={(page: any) => onPageChange={(page: any) =>
metricStore.updateKey('sessionsPage', page) metricStore.updateKey('sessionsPage', page)
} }
limit={metricStore.sessionsPageSize} limit={metricStore.sessionsPageSize}
debounceRequest={500} debounceRequest={500}
/> />
</div>
</NoContent>
</Loader>
</div> </div>
</div> </NoContent>
); </Loader>
</div>
</div>
);
} }
const getListSessionsBySeries = (data: any, seriesId: any) => { const getListSessionsBySeries = (data: any, seriesId: any) => {
const arr = data.reduce( const arr = data.reduce(
(arr: any, element: any) => { (arr: any, element: any) => {
if (seriesId === 'all') { if (seriesId === 'all') {
const sessionIds = arr.sessions.map((i: any) => i.sessionId); const sessionIds = arr.sessions.map((i: any) => i.sessionId);
const sessions = element.sessions.filter( const sessions = element.sessions.filter(
(i: any) => !sessionIds.includes(i.sessionId), (i: any) => !sessionIds.includes(i.sessionId),
); );
arr.sessions.push(...sessions); arr.sessions.push(...sessions);
} else if (element.seriesId === seriesId) { } else if (element.seriesId === seriesId) {
const sessionIds = arr.sessions.map((i: any) => i.sessionId); const sessionIds = arr.sessions.map((i: any) => i.sessionId);
const sessions = element.sessions.filter( const sessions = element.sessions.filter(
(i: any) => !sessionIds.includes(i.sessionId), (i: any) => !sessionIds.includes(i.sessionId),
); );
const duplicates = element.sessions.length - sessions.length; const duplicates = element.sessions.length - sessions.length;
arr.sessions.push(...sessions); arr.sessions.push(...sessions);
arr.total = element.total - duplicates; arr.total = element.total - duplicates;
} }
return arr; return arr;
}, },
{sessions: []}, { sessions: [] },
); );
arr.total = arr.total =
seriesId === 'all' seriesId === 'all'
? Math.max(...data.map((i: any) => i.total)) ? Math.max(...data.map((i: any) => i.total))
: data.find((i: any) => i.seriesId === seriesId).total; : data.find((i: any) => i.seriesId === seriesId).total;
return arr; return arr;
}; };
export default observer(WidgetSessions); export default observer(WidgetSessions);

View file

@ -92,9 +92,6 @@ function WidgetView({
filter: { filters: selectedCard.filters }, filter: { filters: selectedCard.filters },
}), }),
]; ];
} else if (selectedCard.cardType === TABLE) {
cardData.series = [new FilterSeries()];
cardData.series[0].filter.eventsOrder = 'and';
} }
if (selectedCard.cardType === FUNNEL) { if (selectedCard.cardType === FUNNEL) {
cardData.series = [new FilterSeries()]; cardData.series = [new FilterSeries()];

View file

@ -83,7 +83,6 @@ function WidgetWrapperNew(props: Props & RouteComponentProps) {
}); });
const onChartClick = () => { const onChartClick = () => {
dashboardStore.setDrillDownPeriod(dashboardStore.period);
// if (!isWidget || isPredefined) return; // if (!isWidget || isPredefined) return;
props.history.push( props.history.push(
withSiteId( withSiteId(

View file

@ -1,80 +1,52 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect } from 'react';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { useStore } from 'App/mstore'; import { useStore } from 'App/mstore';
import ReCAPTCHA from 'react-google-recaptcha';
import { Form, Input, Loader, Icon, Message } from 'UI'; import { Form, Input, Loader, Icon, Message } from 'UI';
import { Button } from 'antd'; import { Button } from 'antd';
import { validatePassword } from 'App/validate'; import { validatePassword } from 'App/validate';
import { PASSWORD_POLICY } from 'App/constants'; import { PASSWORD_POLICY } from 'App/constants';
import stl from './forgotPassword.module.css';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import withCaptcha, { WithCaptchaProps } from 'App/withRecaptcha';
const recaptchaRef = React.createRef();
const ERROR_DONT_MATCH = (t) => t("Passwords don't match."); const ERROR_DONT_MATCH = (t) => t("Passwords don't match.");
const CAPTCHA_ENABLED = window.env.CAPTCHA_ENABLED === 'true';
const { CAPTCHA_SITE_KEY } = window.env;
interface Props { interface Props {
params: any; params: any;
} }
function CreatePassword(props: Props) {
function CreatePassword(props: Props & WithCaptchaProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { params } = props; const { params } = props;
const { userStore } = useStore(); const { userStore } = useStore();
const { loading } = userStore; const { loading } = userStore;
const { resetPassword } = userStore; const { resetPassword } = userStore;
const [error, setError] = useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
const [validationError, setValidationError] = useState<string | null>(null); const [validationError, setValidationError] = React.useState<string | null>(
const [updated, setUpdated] = useState(false); null,
const [passwordRepeat, setPasswordRepeat] = useState(''); );
const [password, setPassword] = useState(''); const [updated, setUpdated] = React.useState(false);
const [passwordRepeat, setPasswordRepeat] = React.useState('');
const [password, setPassword] = React.useState('');
const pass = params.get('pass'); const pass = params.get('pass');
const invitation = params.get('invitation'); const invitation = params.get('invitation');
const { submitWithCaptcha, isVerifyingCaptcha, resetCaptcha } = props; const handleSubmit = () => {
const handleSubmit = (token?: string) => {
if (!validatePassword(password)) { if (!validatePassword(password)) {
return; return;
} }
void resetPassword({ invitation, pass, password });
resetPassword({
invitation,
pass,
password,
'g-recaptcha-response': token
})
.then(() => {
setUpdated(true);
})
.catch((err) => {
setError(err.message);
// Reset captcha for the next attempt
resetCaptcha();
});
}; };
const onSubmit = () => { const onSubmit = (e: any) => {
// Validate before attempting captcha verification e.preventDefault();
if (!validatePassword(password) || password !== passwordRepeat) { if (CAPTCHA_ENABLED && recaptchaRef.current) {
setValidationError( recaptchaRef.current.execute();
password !== passwordRepeat } else if (!CAPTCHA_ENABLED) {
? ERROR_DONT_MATCH(t) handleSubmit();
: PASSWORD_POLICY(t)
);
return;
} }
// Reset any previous errors
setError(null);
setValidationError(null);
submitWithCaptcha({ pass, invitation, password })
.then((data) => {
handleSubmit(data['g-recaptcha-response']);
})
.catch((error) => {
console.error('Captcha verification failed:', error);
// The component will handle showing appropriate messages
});
}; };
const write = (e: any) => { const write = (e: any) => {
@ -91,7 +63,7 @@ function CreatePassword(props: Props & WithCaptchaProps) {
} else { } else {
setValidationError(null); setValidationError(null);
} }
}, [passwordRepeat, password, t]); }, [passwordRepeat, password]);
return ( return (
<Form <Form
@ -101,8 +73,19 @@ function CreatePassword(props: Props & WithCaptchaProps) {
> >
{!error && ( {!error && (
<> <>
<Loader loading={loading || isVerifyingCaptcha}> <Loader loading={loading}>
<div data-hidden={updated} className="w-full"> <div data-hidden={updated} className="w-full">
{CAPTCHA_ENABLED && (
<div className={stl.recaptcha}>
<ReCAPTCHA
ref={recaptchaRef}
size="invisible"
sitekey={CAPTCHA_SITE_KEY}
onChange={(token: any) => handleSubmit(token)}
/>
</div>
)}
<Form.Field> <Form.Field>
<label>{t('New password')}</label> <label>{t('New password')}</label>
<Input <Input
@ -149,15 +132,10 @@ function CreatePassword(props: Props & WithCaptchaProps) {
<Button <Button
htmlType="submit" htmlType="submit"
type="primary" type="primary"
loading={loading || isVerifyingCaptcha} loading={loading}
disabled={loading || isVerifyingCaptcha || validationError !== null}
className="w-full mt-4" className="w-full mt-4"
> >
{isVerifyingCaptcha {t('Create')}
? t('Verifying...')
: loading
? t('Processing...')
: t('Create')}
</Button> </Button>
)} )}
</> </>
@ -175,4 +153,4 @@ function CreatePassword(props: Props & WithCaptchaProps) {
); );
} }
export default withCaptcha(observer(CreatePassword)); export default observer(CreatePassword);

View file

@ -1,26 +1,24 @@
import React, { useState } from 'react'; import React from 'react';
import { Loader, Icon } from 'UI'; import { Loader, Icon } from 'UI';
import ReCAPTCHA from 'react-google-recaptcha';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { useStore } from 'App/mstore'; import { useStore } from 'App/mstore';
import { Form, Input, Button, Typography } from 'antd'; import { Form, Input, Button, Typography } from 'antd';
import { SquareArrowOutUpRight } from 'lucide-react'; import { SquareArrowOutUpRight } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import withCaptcha, { WithCaptchaProps } from 'App/withRecaptcha';
interface Props { function ResetPasswordRequest() {
}
function ResetPasswordRequest(props: Props & WithCaptchaProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { userStore } = useStore(); const { userStore } = useStore();
const { loading } = userStore; const { loading } = userStore;
const { requestResetPassword } = userStore; const { requestResetPassword } = userStore;
const [requested, setRequested] = useState(false); const recaptchaRef = React.createRef();
const [email, setEmail] = useState(''); const [requested, setRequested] = React.useState(false);
const [error, setError] = useState(null); const [email, setEmail] = React.useState('');
const [smtpError, setSmtpError] = useState<boolean>(false); const [error, setError] = React.useState(null);
const CAPTCHA_ENABLED = window.env.CAPTCHA_ENABLED === 'true';
const { submitWithCaptcha, isVerifyingCaptcha, resetCaptcha } = props; const { CAPTCHA_SITE_KEY } = window.env;
const [smtpError, setSmtpError] = React.useState<boolean>(false);
const write = (e: any) => { const write = (e: any) => {
const { name, value } = e.target; const { name, value } = e.target;
@ -28,21 +26,22 @@ function ResetPasswordRequest(props: Props & WithCaptchaProps) {
}; };
const onSubmit = () => { const onSubmit = () => {
// Validation check // e.preventDefault();
if (!email || email.trim() === '') { if (CAPTCHA_ENABLED && recaptchaRef.current) {
return; recaptchaRef.current.execute();
} else if (!CAPTCHA_ENABLED) {
handleSubmit();
} }
submitWithCaptcha({ email: email.trim() })
.then((data) => {
handleSubmit(data['g-recaptcha-response']);
})
.catch((error: any) => {
console.error('Captcha verification failed:', error);
});
}; };
const handleSubmit = (token?: string) => { const handleSubmit = (token?: any) => {
if (
CAPTCHA_ENABLED &&
recaptchaRef.current &&
(token === null || token === undefined)
)
return;
setError(null); setError(null);
requestResetPassword({ email: email.trim(), 'g-recaptcha-response': token }) requestResetPassword({ email: email.trim(), 'g-recaptcha-response': token })
.catch((err: any) => { .catch((err: any) => {
@ -51,21 +50,29 @@ function ResetPasswordRequest(props: Props & WithCaptchaProps) {
} }
setError(err.message); setError(err.message);
// Reset captcha for the next attempt
resetCaptcha();
}) })
.finally(() => { .finally(() => {
setRequested(true); setRequested(true);
}); });
}; };
return ( return (
<Form <Form
onFinish={onSubmit} onFinish={onSubmit}
style={{ minWidth: '50%' }} style={{ minWidth: '50%' }}
className="flex flex-col" className="flex flex-col"
> >
<Loader loading={loading || isVerifyingCaptcha}> <Loader loading={false}>
{CAPTCHA_ENABLED && (
<div className="flex justify-center">
<ReCAPTCHA
ref={recaptchaRef}
size="invisible"
data-hidden={requested}
sitekey={CAPTCHA_SITE_KEY}
onChange={(token: any) => handleSubmit(token)}
/>
</div>
)}
{!requested && ( {!requested && (
<> <>
<Form.Item> <Form.Item>
@ -85,14 +92,10 @@ function ResetPasswordRequest(props: Props & WithCaptchaProps) {
<Button <Button
type="primary" type="primary"
htmlType="submit" htmlType="submit"
loading={loading || isVerifyingCaptcha} loading={loading}
disabled={loading || isVerifyingCaptcha} disabled={loading}
> >
{isVerifyingCaptcha {t('Email Password Reset Link')}
? t('Verifying...')
: loading
? t('Processing...')
: t('Email Password Reset Link')}
</Button> </Button>
</> </>
)} )}
@ -143,4 +146,4 @@ function ResetPasswordRequest(props: Props & WithCaptchaProps) {
); );
} }
export default withCaptcha(observer(ResetPasswordRequest)); export default observer(ResetPasswordRequest);

View file

@ -1,18 +1,23 @@
import withPageTitle from 'HOCs/withPageTitle'; import withPageTitle from 'HOCs/withPageTitle';
import cn from 'classnames'; import cn from 'classnames';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
// Consider using a different approach for titles in functional components
import ReCAPTCHA from 'react-google-recaptcha';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { ENTERPRISE_REQUEIRED } from 'App/constants';
import { forgotPassword, signup } from 'App/routes'; import { forgotPassword, signup } from 'App/routes';
import { Icon, Link, Loader } from 'UI'; import { Icon, Link, Loader, Tooltip } from 'UI';
import { Button, Form, Input } from 'antd'; import { Button, Form, Input } from 'antd';
import Copyright from 'Shared/Copyright'; import Copyright from 'Shared/Copyright';
import stl from './login.module.css';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useStore } from 'App/mstore'; import { useStore } from 'App/mstore';
import LanguageSwitcher from '../LanguageSwitcher'; import LanguageSwitcher from '../LanguageSwitcher';
import withCaptcha, { WithCaptchaProps } from 'App/withRecaptcha';
import SSOLogin from './SSOLogin';
const FORGOT_PASSWORD = forgotPassword(); const FORGOT_PASSWORD = forgotPassword();
const SIGNUP_ROUTE = signup(); const SIGNUP_ROUTE = signup();
@ -21,15 +26,14 @@ interface LoginProps {
location: Location; location: Location;
} }
function Login({ const CAPTCHA_ENABLED = window.env.CAPTCHA_ENABLED === 'true';
location,
submitWithCaptcha, function Login({ location }: LoginProps) {
isVerifyingCaptcha,
resetCaptcha,
}: LoginProps & WithCaptchaProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
// const CAPTCHA_ENABLED = useMemo(() => window.env.CAPTCHA_ENABLED === 'true', []);
const recaptchaRef = useRef<ReCAPTCHA>(null);
const { loginStore, userStore } = useStore(); const { loginStore, userStore } = useStore();
const { errors } = userStore.loginRequest; const { errors } = userStore.loginRequest;
const { loading } = loginStore; const { loading } = loginStore;
@ -45,6 +49,7 @@ function Login({
}, [authDetails]); }, [authDetails]);
useEffect(() => { useEffect(() => {
// void fetchTenants();
const jwt = params.get('jwt'); const jwt = params.get('jwt');
const spotJwt = params.get('spotJwt'); const spotJwt = params.get('spotJwt');
if (spotJwt) { if (spotJwt) {
@ -103,36 +108,32 @@ function Login({
if (resp) { if (resp) {
userStore.syntheticLogin(resp); userStore.syntheticLogin(resp);
setJwt({ jwt: resp.jwt, spotJwt: resp.spotJwt ?? null }); setJwt({ jwt: resp.jwt, spotJwt: resp.spotJwt ?? null });
if (resp.spotJwt) { handleSpotLogin(resp.spotJwt);
handleSpotLogin(resp.spotJwt);
}
} }
}) })
.catch((e) => { .catch((e) => {
userStore.syntheticLoginError(e); userStore.syntheticLoginError(e);
resetCaptcha();
}); });
}; };
const onSubmit = () => { const onSubmit = () => {
if (!email || !password) { if (CAPTCHA_ENABLED && recaptchaRef.current) {
return; recaptchaRef.current.execute();
} else if (!CAPTCHA_ENABLED) {
handleSubmit();
} }
submitWithCaptcha({ email: email.trim(), password })
.then((data) => {
handleSubmit(data['g-recaptcha-response']);
})
.catch((error: any) => {
console.error('Captcha error:', error);
});
}; };
const ssoLink =
window !== window.top
? `${window.location.origin}/api/sso/saml2?iFrame=true`
: `${window.location.origin}/api/sso/saml2`;
return ( return (
<div className="flex items-center justify-center h-screen"> <div className="flex items-center justify-center h-screen">
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<div className="m-10 "> <div className="m-10 ">
<img src="/assets/logo.svg" width={200} alt="Company Logo" /> <img src="/assets/logo.svg" width={200} />
</div> </div>
<div className="border rounded-lg bg-white shadow-sm"> <div className="border rounded-lg bg-white shadow-sm">
<h2 className="text-center text-2xl font-medium mb-6 border-b p-5 w-full"> <h2 className="text-center text-2xl font-medium mb-6 border-b p-5 w-full">
@ -144,7 +145,15 @@ function Login({
className={cn('flex items-center justify-center flex-col')} className={cn('flex items-center justify-center flex-col')}
style={{ width: '350px' }} style={{ width: '350px' }}
> >
<Loader loading={loading || isVerifyingCaptcha}> <Loader loading={loading}>
{CAPTCHA_ENABLED && (
<ReCAPTCHA
ref={recaptchaRef}
size="invisible"
sitekey={window.env.CAPTCHA_SITE_KEY}
onChange={(token) => handleSubmit(token)}
/>
)}
<div style={{ width: '350px' }} className="px-8"> <div style={{ width: '350px' }} className="px-8">
<Form.Item> <Form.Item>
<label>{t('Email Address')}</label> <label>{t('Email Address')}</label>
@ -177,8 +186,8 @@ function Login({
</Loader> </Loader>
{errors && errors.length ? ( {errors && errors.length ? (
<div className="px-8 my-2 w-full"> <div className="px-8 my-2 w-full">
{errors.map((error, index) => ( {errors.map((error) => (
<div key={index} className="flex items-center bg-red-lightest rounded p-3"> <div className="flex items-center bg-red-lightest rounded p-3">
<Icon name="info" color="red" size="20" /> <Icon name="info" color="red" size="20" />
<span className="color-red ml-2"> <span className="color-red ml-2">
{error} {error}
@ -195,14 +204,8 @@ function Login({
className="mt-2 w-full text-center rounded-lg" className="mt-2 w-full text-center rounded-lg"
type="primary" type="primary"
htmlType="submit" htmlType="submit"
loading={loading || isVerifyingCaptcha}
disabled={loading || isVerifyingCaptcha}
> >
{isVerifyingCaptcha {t('Login')}
? t('Verifying...')
: loading
? t('Logging in...')
: t('Login')}
</Button> </Button>
<div className="my-8 flex justify-center items-center flex-wrap"> <div className="my-8 flex justify-center items-center flex-wrap">
@ -216,12 +219,63 @@ function Login({
</div> </div>
</Form> </Form>
<SSOLogin authDetails={authDetails} /> <div className={cn(stl.sso, 'py-2 flex flex-col items-center')}>
{authDetails.sso ? (
<a href={ssoLink} rel="noopener noreferrer">
<Button type="text" htmlType="submit">
{`${t('Login with SSO')} ${
authDetails.ssoProvider
? `(${authDetails.ssoProvider})`
: ''
}`}
</Button>
</a>
) : (
<Tooltip
delay={0}
title={
<div className="text-center">
{authDetails.edition === 'ee' ? (
<span>
{t('SSO has not been configured.')}
<br />
{t('Please reach out to your admin.')}
</span>
) : (
ENTERPRISE_REQUEIRED(t)
)}
</div>
}
placement="top"
>
<Button
type="text"
htmlType="submit"
className="pointer-events-none opacity-30"
>
{`${t('Login with SSO')} ${
authDetails.ssoProvider
? `(${authDetails.ssoProvider})`
: ''
}`}
</Button>
</Tooltip>
)}
</div>
</div>
<div
className={cn('flex items-center w-96 justify-center my-8', {
'!hidden': !authDetails?.enforceSSO,
})}
>
<a href={ssoLink} rel="noopener noreferrer">
<Button type="primary">
{`${t('Login with SSO')} ${
authDetails.ssoProvider ? `(${authDetails.ssoProvider})` : ''
}`}
</Button>
</a>
</div> </div>
{authDetails?.enforceSSO && (
<SSOLogin authDetails={authDetails} enforceSSO={true} />
)}
</div> </div>
</div> </div>
@ -233,6 +287,4 @@ function Login({
); );
} }
export default withPageTitle('Login - OpenReplay')( export default withPageTitle('Login - OpenReplay')(observer(Login));
withCaptcha(observer(Login))
);

View file

@ -1,78 +0,0 @@
import React from 'react';
import cn from 'classnames';
import { Button, Tooltip } from 'antd';
import { useTranslation } from 'react-i18next';
import { ENTERPRISE_REQUEIRED } from 'App/constants';
import stl from './login.module.css';
import { useStore } from 'App/mstore';
interface SSOLoginProps {
authDetails: any;
enforceSSO?: boolean;
}
const SSOLogin = ({ authDetails, enforceSSO = false }: SSOLoginProps) => {
const { userStore } = useStore();
const { t } = useTranslation();
const { isSSOSupported } = userStore;
const getSSOLink = () =>
window !== window.top
? `${window.location.origin}/api/sso/saml2?iFrame=true`
: `${window.location.origin}/api/sso/saml2`;
const ssoLink = getSSOLink();
const ssoButtonText = `${t('Login with SSO')} ${authDetails.ssoProvider ? `(${authDetails.ssoProvider})` : ''
}`;
if (enforceSSO) {
return (
<div className={cn('flex items-center w-96 justify-center my-8')}>
<a href={ssoLink} rel="noopener noreferrer">
<Button type="primary">{ssoButtonText}</Button>
</a>
</div>
);
}
return (
<div className={cn(stl.sso, 'py-2 flex flex-col items-center')}>
{authDetails.sso ? (
<a href={ssoLink} rel="noopener noreferrer">
<Button type="text" htmlType="submit">
{ssoButtonText}
</Button>
</a>
) : (
<Tooltip
title={
<div className="text-center">
{isSSOSupported ? (
<span>
{t('SSO has not been configured.')}
<br />
{t('Please reach out to your admin.')}
</span>
) : (
ENTERPRISE_REQUEIRED(t)
)}
</div>
}
placement="top"
>
<span className="cursor-not-allowed">
<Button
type="text"
htmlType="submit"
disabled={true}
>
{ssoButtonText}
</Button>
</span>
</Tooltip>
)}
</div>
);
};
export default SSOLogin;

View file

@ -1,14 +1,16 @@
import React from 'react'; import React from 'react';
import { Redirect, Route, RouteComponentProps, Switch } from 'react-router'; import { Redirect, Route, RouteComponentProps, Switch } from 'react-router';
import { withRouter } from 'react-router-dom'; import { withRouter } from 'react-router-dom';
import { OB_TABS, onboarding as onboardingRoute, withSiteId } from 'App/routes'; import { OB_TABS, onboarding as onboardingRoute, withSiteId } from 'App/routes';
import { Icon } from 'UI';
import IdentifyUsersTab from './components/IdentifyUsersTab'; import IdentifyUsersTab from './components/IdentifyUsersTab';
import InstallOpenReplayTab from './components/InstallOpenReplayTab'; import InstallOpenReplayTab from './components/InstallOpenReplayTab';
import IntegrationsTab from './components/IntegrationsTab'; import IntegrationsTab from './components/IntegrationsTab';
import ManageUsersTab from './components/ManageUsersTab'; import ManageUsersTab from './components/ManageUsersTab';
import SideMenu from './components/SideMenu'; import SideMenu from './components/SideMenu';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Smartphone, AppWindow } from 'lucide-react';
interface Props { interface Props {
match: { match: {
@ -31,7 +33,7 @@ function Onboarding(props: Props) {
{ {
label: ( label: (
<div className="font-semibold flex gap-2 items-center"> <div className="font-semibold flex gap-2 items-center">
<AppWindow size={16} /> <Icon name="browser/browser" size={16} />
&nbsp;{t('Web')} &nbsp;{t('Web')}
</div> </div>
), ),
@ -40,7 +42,7 @@ function Onboarding(props: Props) {
{ {
label: ( label: (
<div className="font-semibold flex gap-2 items-center"> <div className="font-semibold flex gap-2 items-center">
<Smartphone size={16} /> <Icon name="mobile" size={16} />
&nbsp;{t('Mobile')} &nbsp;{t('Mobile')}
</div> </div>
), ),

View file

@ -130,20 +130,18 @@ function IdentifyUsersTab(props: Props) {
'To identify users through metadata, you will have to explicitly specify your user metadata so it can be injected during sessions. Follow the below steps', 'To identify users through metadata, you will have to explicitly specify your user metadata so it can be injected during sessions. Follow the below steps',
)} )}
</p> </p>
<div className="flex items-center gap-2 mb-2"> <div className="flex items-start">
<CircleNumber text="1" /> <CircleNumber text="1" />
<MetadataList /> <MetadataList />
</div> </div>
<div className="my-6" /> <div className="my-6" />
<div className="flex items-start"> <div className="flex items-start">
<div> <CircleNumber text="2" />
<CircleNumber text="2" /> <div className="pt-1 w-full">
<span className="font-bold"> <span className="font-bold">
{t('Inject metadata when recording sessions')} {t('Inject metadata when recording sessions')}
</span> </span>
</div>
<div className="pt-1 w-full">
<div className="my-2"> <div className="my-2">
{t('Use the')}&nbsp; {t('Use the')}&nbsp;
<span className="highlight-blue">setMetadata</span>{' '} <span className="highlight-blue">setMetadata</span>{' '}

View file

@ -8,7 +8,6 @@ import MobileOnboardingTabs from '../OnboardingTabs/OnboardingMobileTabs';
import ProjectFormButton from '../ProjectFormButton'; import ProjectFormButton from '../ProjectFormButton';
import withOnboarding, { WithOnboardingProps } from '../withOnboarding'; import withOnboarding, { WithOnboardingProps } from '../withOnboarding';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { CircleHelp } from 'lucide-react'
interface Props extends WithOnboardingProps { interface Props extends WithOnboardingProps {
platforms: Array<{ platforms: Array<{
@ -46,8 +45,8 @@ function InstallOpenReplayTab(props: Props) {
</div> </div>
<a href={"https://docs.openreplay.com/en/sdk/using-or/"} target="_blank"> <a href={"https://docs.openreplay.com/en/sdk/using-or/"} target="_blank">
<Button size={"small"} type={"text"} className="ml-2 flex items-center gap-2"> <Button size={"small"} type={"text"} className="ml-2 flex items-center gap-2">
<CircleHelp size={14} /> <Icon name={"question-circle"} />
<div>{t('See Documentation')}</div> <div className={"text-main"}>{t('See Documentation')}</div>
</Button> </Button>
</a> </a>
</h1> </h1>

View file

@ -55,14 +55,16 @@ function MetadataList() {
<Button type="default" onClick={() => openModal()}> <Button type="default" onClick={() => openModal()}>
{t('Add Metadata')} {t('Add Metadata')}
</Button> </Button>
{fields.map((f, index) => ( <div className="flex ml-2">
<TagBadge {fields.map((f, index) => (
key={index} <TagBadge
text={f.key} key={index}
onRemove={() => removeMetadata(f)} text={f.key}
outline onRemove={() => removeMetadata(f)}
/> outline
))} />
))}
</div>
</div> </div>
); );
} }

View file

@ -1,32 +0,0 @@
import React from 'react'
import DocCard from "App/components/shared/DocCard";
import { useTranslation } from 'react-i18next';
import { Mail } from 'lucide-react'
import { CopyButton } from "UI";
export function CollabCard({ showUserModal }: { showUserModal: () => void }) {
const { t } = useTranslation();
return (
<DocCard title={t('Need help from team member?')}>
<div className={'text-main cursor-pointer flex items-center gap-2'} onClick={showUserModal}>
<Mail size={14} />
<span>
{t('Invite and Collaborate')}
</span>
</div>
</DocCard>
)
}
export function ProjectKeyCard({ projectKey }: { projectKey: string }) {
const { t } = useTranslation();
return (
<DocCard title={t('Project Key')}>
<div className="p-2 rounded bg-white flex justify-between items-center">
<div className={'font-mono'}>{projectKey}</div>
<CopyButton content={projectKey} className={'capitalize font-medium text-neutral-400'} />
</div>
</DocCard>
)
}

View file

@ -7,16 +7,17 @@ import stl from './installDocs.module.css';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
const installationCommand = 'npm i @openreplay/tracker'; const installationCommand = 'npm i @openreplay/tracker';
const usageCode = `import Tracker from '@openreplay/tracker'; const usageCode = `import { tracker } from '@openreplay/tracker';
const tracker = new Tracker({ tracker.configure({
projectKey: "PROJECT_KEY", projectKey: "PROJECT_KEY",
ingestPoint: "https://${window.location.hostname}/ingest", ingestPoint: "https://${window.location.hostname}/ingest",
}); });
tracker.start()`; tracker.start()`;
const usageCodeSST = `import Tracker from '@openreplay/tracker/cjs'; const usageCodeSST = `import { tracker } from '@openreplay/tracker/cjs';
// alternatively you can use dynamic import without /cjs suffix to prevent issues with window scope
const tracker = new Tracker({ tracker.configure({
projectKey: "PROJECT_KEY", projectKey: "PROJECT_KEY",
ingestPoint: "https://${window.location.hostname}/ingest", ingestPoint: "https://${window.location.hostname}/ingest",
}); });

View file

@ -4,7 +4,6 @@ import DocCard from 'Shared/DocCard/DocCard';
import { useModal } from 'App/components/Modal'; import { useModal } from 'App/components/Modal';
import UserForm from 'App/components/Client/Users/components/UserForm/UserForm'; import UserForm from 'App/components/Client/Users/components/UserForm/UserForm';
import AndroidInstallDocs from 'Components/Onboarding/components/OnboardingTabs/InstallDocs/AndroidInstallDocs'; import AndroidInstallDocs from 'Components/Onboarding/components/OnboardingTabs/InstallDocs/AndroidInstallDocs';
import { CollabCard, ProjectKeyCard } from "./Callouts";
import MobileInstallDocs from './InstallDocs/MobileInstallDocs'; import MobileInstallDocs from './InstallDocs/MobileInstallDocs';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -40,9 +39,18 @@ function MobileTrackingCodeModal(props: Props) {
</div> </div>
<div className="col-span-2"> <div className="col-span-2">
<CollabCard showUserModal={showUserModal} /> <DocCard title={t('Need help from team member?')}>
<a className="link" onClick={showUserModal}>
{t('Invite and Collaborate')}
</a>
</DocCard>
<ProjectKeyCard projectKey={site.projectKey} /> <DocCard title={t('Project Key')}>
<div className="p-2 rounded bg-white flex justify-between items-center">
{site.projectKey}
<CopyButton content={site.projectKey} />
</div>
</DocCard>
</div> </div>
</div> </div>
); );
@ -54,9 +62,18 @@ function MobileTrackingCodeModal(props: Props) {
</div> </div>
<div className="col-span-2"> <div className="col-span-2">
<CollabCard showUserModal={showUserModal} /> <DocCard title={t('Need help from team member?')}>
<a className="link" onClick={showUserModal}>
{t('Invite and Collaborate')}
</a>
</DocCard>
<ProjectKeyCard projectKey={site.projectKey} /> <DocCard title={t('Project Key')}>
<div className="p-2 rounded bg-white flex justify-between items-center">
{site.projectKey}
<CopyButton content={site.projectKey} />
</div>
</DocCard>
</div> </div>
</div> </div>
); );

View file

@ -3,7 +3,6 @@ import { Tabs, Icon, CopyButton } from 'UI';
import DocCard from 'Shared/DocCard/DocCard'; import DocCard from 'Shared/DocCard/DocCard';
import { useModal } from 'App/components/Modal'; import { useModal } from 'App/components/Modal';
import UserForm from 'App/components/Client/Users/components/UserForm/UserForm'; import UserForm from 'App/components/Client/Users/components/UserForm/UserForm';
import { CollabCard, ProjectKeyCard } from "./Callouts";
import InstallDocs from './InstallDocs'; import InstallDocs from './InstallDocs';
import ProjectCodeSnippet from './ProjectCodeSnippet'; import ProjectCodeSnippet from './ProjectCodeSnippet';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -38,9 +37,20 @@ function TrackingCodeModal(props: Props) {
</div> </div>
<div className="col-span-2"> <div className="col-span-2">
<CollabCard showUserModal={showUserModal} /> <DocCard title="Need help from team member?">
<a className="link" onClick={showUserModal}>
<ProjectKeyCard projectKey={site.projectKey} /> {t('Invite and Collaborate')}
</a>
</DocCard>
<DocCard title="Project Key">
<div className="rounded bg-white px-2 py-1 flex items-center justify-between">
<span>{site.projectKey}</span>
<CopyButton
content={site.projectKey}
className="capitalize"
/>
</div>
</DocCard>
<DocCard title="Other ways to install"> <DocCard title="Other ways to install">
<a <a
className="link flex items-center" className="link flex items-center"
@ -67,9 +77,18 @@ function TrackingCodeModal(props: Props) {
</div> </div>
<div className="col-span-2"> <div className="col-span-2">
<CollabCard showUserModal={showUserModal} /> <DocCard title="Need help from team member?">
<a className="link" onClick={showUserModal}>
{t('Invite and Collaborate')}
</a>
</DocCard>
<ProjectKeyCard projectKey={site.projectKey} /> <DocCard title="Project Key">
<div className="p-2 rounded bg-white flex justify-between items-center">
{site.projectKey}
<CopyButton content={site.projectKey} />
</div>
</DocCard>
</div> </div>
</div> </div>
); );

View file

@ -41,7 +41,7 @@ function SideMenu(props: Props) {
<Menu <Menu
mode="inline" mode="inline"
onClick={handleClick} onClick={handleClick}
style={{ border: 'none' }} style={{ marginTop: '8px', border: 'none' }}
selectedKeys={activeTab ? [activeTab] : []} selectedKeys={activeTab ? [activeTab] : []}
> >
<Menu.Item <Menu.Item

View file

@ -8,7 +8,7 @@ import {
LikeFilled, LikeFilled,
LikeOutlined, LikeOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { Tour, TourProps } from 'antd'; import { Tour, TourProps } from './.store/antd-virtual-7db13b4af6/package';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
interface Props { interface Props {

View file

@ -91,7 +91,7 @@ function PlayerBlockHeader(props: Props) {
)} )}
</div> </div>
</div> </div>
<div className="relative border-l" style={{ minWidth: activeTab === 'EXPORT' ? '360px' : '270px' }}> <div className="relative border-l" style={{ minWidth: '270px' }}>
<Tabs <Tabs
tabs={TABS} tabs={TABS}
active={activeTab} active={activeTab}

View file

@ -61,7 +61,7 @@ function PlayerContent({
className="w-full" className="w-full"
style={ style={
activeTab && !fullscreen activeTab && !fullscreen
? { maxWidth: `calc(100% - ${activeTab === 'EXPORT' ? '360px' : '270px'})` } ? { maxWidth: 'calc(100% - 270px)' }
: undefined : undefined
} }
> >

View file

@ -42,7 +42,7 @@ function DropdownAudioPlayer({
return { return {
url: data.url, url: data.url,
timestamp: data.timestamp, timestamp: data.timestamp,
start: Math.max(0, startTs), start: startTs,
}; };
}), }),
[audioEvents.length, sessionStart], [audioEvents.length, sessionStart],

View file

@ -114,17 +114,19 @@ function PlayerBlockHeader(props: any) {
)} )}
{_metaList.length > 0 && ( {_metaList.length > 0 && (
<SessionMetaList <div className="h-full flex items-center px-2 gap-1">
horizontal <SessionMetaList
metaList={_metaList} className=""
maxLength={2} metaList={_metaList}
/> maxLength={2}
/>
</div>
)} )}
</div> </div>
</div> </div>
<div <div
className="px-2 relative border-l border-l-gray-lighter" className="px-2 relative border-l border-l-gray-lighter"
style={{ minWidth: activeTab === 'EXPORT' ? '360px' : '270px' }} style={{ minWidth: '270px' }}
> >
<Tabs <Tabs
tabs={TABS} tabs={TABS}

View file

@ -65,7 +65,7 @@ function PlayerContent({
className="w-full" className="w-full"
style={ style={
activeTab && !fullscreen activeTab && !fullscreen
? { maxWidth: `calc(100% - ${activeTab === 'EXPORT' ? '360px' : '270px'})` } ? { maxWidth: 'calc(100% - 270px)' }
: undefined : undefined
} }
> >

View file

@ -182,7 +182,6 @@ function Player(props: IProps) {
setActiveTab={(tab: string) => setActiveTab={(tab: string) =>
activeTab === tab ? props.setActiveTab('') : props.setActiveTab(tab) activeTab === tab ? props.setActiveTab('') : props.setActiveTab(tab)
} }
activeTab={activeTab}
speedDown={playerContext.player.speedDown} speedDown={playerContext.player.speedDown}
speedUp={playerContext.player.speedUp} speedUp={playerContext.player.speedUp}
jump={playerContext.player.jump} jump={playerContext.player.jump}

View file

@ -7,16 +7,13 @@ import { Icon } from 'UI';
function LogsButton({ function LogsButton({
integrated, integrated,
onClick, onClick,
shorten,
}: { }: {
integrated: string[]; integrated: string[];
onClick: () => void; onClick: () => void;
shorten?: boolean;
}) { }) {
return ( return (
<ControlButton <ControlButton
label={shorten ? null : "Traces"} label="Traces"
customKey="traces"
customTags={ customTags={
<Avatar.Group> <Avatar.Group>
{integrated.map((name) => ( {integrated.map((name) => (

View file

@ -38,8 +38,8 @@ function WebPlayer(props: any) {
uxtestingStore, uxtestingStore,
uiPlayerStore, uiPlayerStore,
integrationsStore, integrationsStore,
userStore,
} = useStore(); } = useStore();
const devTools = sessionStore.devTools
const session = sessionStore.current; const session = sessionStore.current;
const { prefetched } = sessionStore; const { prefetched } = sessionStore;
const startedAt = sessionStore.current.startedAt || 0; const startedAt = sessionStore.current.startedAt || 0;
@ -57,17 +57,14 @@ function WebPlayer(props: any) {
const [fullView, setFullView] = useState(false); const [fullView, setFullView] = useState(false);
React.useEffect(() => { React.useEffect(() => {
const handleActivation = () => { if (windowActive) {
if (!document.hidden) { const handleActivation = () => {
setWindowActive(true); if (!document.hidden) {
document.removeEventListener('visibilitychange', handleActivation); setWindowActive(true);
} document.removeEventListener('visibilitychange', handleActivation);
}; }
document.addEventListener('visibilitychange', handleActivation); };
document.addEventListener('visibilitychange', handleActivation);
return () => {
devTools.update('network', { activeTab: 'ALL' });
document.removeEventListener('visibilitychange', handleActivation);
} }
}, []); }, []);

View file

@ -169,6 +169,6 @@ function TabChange({ from, to, activeUrl, onClick }) {
</div> </div>
</div> </div>
); );
}; }
export default observer(EventGroupWrapper); export default observer(EventGroupWrapper);

View file

@ -4,17 +4,17 @@ import cn from 'classnames';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import React from 'react'; import React from 'react';
import { VList, VListHandle } from 'virtua'; import { VList, VListHandle } from 'virtua';
import { Button } from 'antd'; import { Button } from 'antd'
import { PlayerContext } from 'App/components/Session/playerContext'; import { PlayerContext } from 'App/components/Session/playerContext';
import { useStore } from 'App/mstore'; import { useStore } from 'App/mstore';
import { Icon } from 'UI'; import { Icon } from 'UI';
import { Search } from 'lucide-react'; import { Search } from 'lucide-react'
import EventGroupWrapper from './EventGroupWrapper'; import EventGroupWrapper from './EventGroupWrapper';
import EventSearch from './EventSearch/EventSearch'; import EventSearch from './EventSearch/EventSearch';
import styles from './eventsBlock.module.css'; import styles from './eventsBlock.module.css';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { CloseOutlined } from "@ant-design/icons"; import { CloseOutlined } from ".store/@ant-design-icons-virtual-42686020c5/package";
import { Tooltip } from "antd"; import { Tooltip } from ".store/antd-virtual-9dbfadb7f6/package";
import { getDefaultFramework, frameworkIcons } from "../UnitStepsModal"; import { getDefaultFramework, frameworkIcons } from "../UnitStepsModal";
interface IProps { interface IProps {
@ -25,7 +25,7 @@ const MODES = {
SELECT: 'select', SELECT: 'select',
SEARCH: 'search', SEARCH: 'search',
EXPORT: 'export', EXPORT: 'export',
}; }
function EventsBlock(props: IProps) { function EventsBlock(props: IProps) {
const defaultFramework = getDefaultFramework(); const defaultFramework = getDefaultFramework();
@ -95,7 +95,7 @@ function EventsBlock(props: IProps) {
? e.time >= zoomStartTs && e.time <= zoomEndTs ? e.time >= zoomStartTs && e.time <= zoomEndTs
: false : false
: true, : true,
); );
}, [ }, [
filteredLength, filteredLength,
notesWithEvtsLength, notesWithEvtsLength,
@ -126,7 +126,6 @@ function EventsBlock(props: IProps) {
}, },
[usedEvents, time, endTime], [usedEvents, time, endTime],
); );
const currentTimeEventIndex = findLastFitting(time); const currentTimeEventIndex = findLastFitting(time);
const write = ({ const write = ({
@ -183,7 +182,6 @@ function EventsBlock(props: IProps) {
const isTabChange = 'type' in event && event.type === 'TABCHANGE'; const isTabChange = 'type' in event && event.type === 'TABCHANGE';
const isCurrent = index === currentTimeEventIndex; const isCurrent = index === currentTimeEventIndex;
const isPrev = index < currentTimeEventIndex; const isPrev = index < currentTimeEventIndex;
return ( return (
<EventGroupWrapper <EventGroupWrapper
query={query} query={query}
@ -251,14 +249,12 @@ function EventsBlock(props: IProps) {
onClick={() => setMode(MODES.SEARCH)} onClick={() => setMode(MODES.SEARCH)}
> >
<Search size={14} /> <Search size={14} />
<div> <div>{t('Search')}&nbsp;{usedEvents.length}&nbsp;{t('events')}</div>
{t('Search')}&nbsp;{usedEvents.length}&nbsp;{t('events')}
</div>
</Button> </Button>
<Tooltip title={t('Close Panel')} placement="bottom"> <Tooltip title={t('Close Panel')} placement='bottom' >
<Button <Button
className="ml-auto" className="ml-auto"
type="text" type='text'
onClick={() => { onClick={() => {
setActiveTab(''); setActiveTab('');
}} }}
@ -267,23 +263,19 @@ function EventsBlock(props: IProps) {
</Tooltip> </Tooltip>
</div> </div>
) : null} ) : null}
{mode === MODES.SEARCH ? ( {mode === MODES.SEARCH ?
<div className={'flex items-center gap-2'}> <div className={'flex items-center gap-2'}>
<EventSearch <EventSearch
onChange={write} onChange={write}
setActiveTab={setActiveTab} setActiveTab={setActiveTab}
value={query} value={query}
eventsText={ eventsText={
usedEvents.length usedEvents.length ? `${usedEvents.length} ${t('Events')}` : `0 ${t('Events')}`
? `${usedEvents.length} ${t('Events')}`
: `0 ${t('Events')}`
} }
/> />
<Button type={'text'} onClick={() => setMode(MODES.SELECT)}> <Button type={'text'} onClick={() => setMode(MODES.SELECT)}>{t('Cancel')}</Button>
{t('Cancel')}
</Button>
</div> </div>
) : null} : null}
</div> </div>
<div <div
className={cn('flex-1 pb-4', styles.eventsList)} className={cn('flex-1 pb-4', styles.eventsList)}

View file

@ -65,6 +65,7 @@ function GraphQL({ panelHeight }: { panelHeight: number }) {
const filterList = (list: any, value: string) => { const filterList = (list: any, value: string) => {
const filterRE = getRE(value, 'i'); const filterRE = getRE(value, 'i');
const { t } = useTranslation();
return value return value
? list.filter( ? list.filter(

View file

@ -4,7 +4,7 @@ import { Popover, Button } from 'antd';
import stl from './controlButton.module.css'; import stl from './controlButton.module.css';
interface IProps { interface IProps {
label: React.ReactNode; label: string;
icon?: string; icon?: string;
disabled?: boolean; disabled?: boolean;
onClick?: () => void; onClick?: () => void;
@ -18,7 +18,6 @@ interface IProps {
noIcon?: boolean; noIcon?: boolean;
popover?: React.ReactNode; popover?: React.ReactNode;
customTags?: React.ReactNode; customTags?: React.ReactNode;
customKey?: string;
} }
function ControlButton({ function ControlButton({
@ -29,28 +28,29 @@ function ControlButton({
active = false, active = false,
popover = undefined, popover = undefined,
customTags, customTags,
customKey,
}: IProps) { }: IProps) {
return ( return (
<Popover content={popover} open={popover ? undefined : false}> <Popover content={popover} open={popover ? undefined : false}>
<Button <Button
size="small" size="small"
onClick={onClick} onClick={onClick}
id={`control-button-${customKey ? customKey.toLowerCase() : label!.toString().toLowerCase()}`} id={`control-button-${label.toLowerCase()}`}
disabled={disabled} disabled={disabled}
> >
{customTags} {customTags}
{hasErrors && ( {hasErrors && (
<div className="w-2 h-2 rounded-full bg-red" /> <div className={stl.labels}>
<div className={stl.errorSymbol} />
</div>
)} )}
{label && <span <span
className={cn( className={cn(
'font-semibold hover:text-main', 'font-semibold hover:text-main',
active ? 'color-main' : 'color-gray-darkest', active ? 'color-main' : 'color-gray-darkest',
)} )}
> >
{label} {label}
</span>} </span>
</Button> </Button>
</Popover> </Popover>
); );

View file

@ -32,8 +32,6 @@ import {
} from 'App/mstore/uiPlayerStore'; } from 'App/mstore/uiPlayerStore';
import { Icon } from 'UI'; import { Icon } from 'UI';
import LogsButton from 'App/components/Session/Player/SharedComponents/BackendLogs/LogsButton'; import LogsButton from 'App/components/Session/Player/SharedComponents/BackendLogs/LogsButton';
import { CodeOutlined, DashboardOutlined, ClusterOutlined } from '@ant-design/icons';
import { ArrowDownUp, ListCollapse, Merge, Waypoints } from 'lucide-react'
import ControlButton from './ControlButton'; import ControlButton from './ControlButton';
import Timeline from './Timeline'; import Timeline from './Timeline';
@ -54,23 +52,23 @@ export const SKIP_INTERVALS = {
function getStorageName(type: any) { function getStorageName(type: any) {
switch (type) { switch (type) {
case STORAGE_TYPES.REDUX: case STORAGE_TYPES.REDUX:
return { name: 'Redux', icon: <Icon name='integrations/redux' size={14} /> }; return 'Redux';
case STORAGE_TYPES.MOBX: case STORAGE_TYPES.MOBX:
return { name: 'Mobx', icon: <Icon name='integrations/mobx' size={14} /> }; return 'Mobx';
case STORAGE_TYPES.VUEX: case STORAGE_TYPES.VUEX:
return { name: 'Vuex', icon: <Icon name='integrations/vuejs' size={14} /> }; return 'Vuex';
case STORAGE_TYPES.NGRX: case STORAGE_TYPES.NGRX:
return { name: 'NgRx', icon: <Icon name='integrations/ngrx' size={14} /> }; return 'NgRx';
case STORAGE_TYPES.ZUSTAND: case STORAGE_TYPES.ZUSTAND:
return { name: 'Zustand', icon: <Icon name='integrations/zustand' size={14} /> }; return 'Zustand';
case STORAGE_TYPES.NONE: case STORAGE_TYPES.NONE:
return { name: 'State', icon: <ClusterOutlined size={14} /> }; return 'State';
default: default:
return { name: 'State', icon: <ClusterOutlined size={14} /> }; return 'State';
} }
} }
function Controls({ setActiveTab, activeTab }: any) { function Controls({ setActiveTab }: any) {
const { player, store } = React.useContext(PlayerContext); const { player, store } = React.useContext(PlayerContext);
const { const {
uxtestingStore, uxtestingStore,
@ -193,7 +191,6 @@ function Controls({ setActiveTab, activeTab }: any) {
bottomBlock={bottomBlock} bottomBlock={bottomBlock}
disabled={disabled} disabled={disabled}
events={events} events={events}
activeTab={activeTab}
/> />
)} )}
@ -215,7 +212,6 @@ interface IDevtoolsButtons {
bottomBlock: number; bottomBlock: number;
disabled: boolean; disabled: boolean;
events: any[]; events: any[];
activeTab?: string;
} }
const DevtoolsButtons = observer( const DevtoolsButtons = observer(
@ -225,7 +221,6 @@ const DevtoolsButtons = observer(
bottomBlock, bottomBlock,
disabled, disabled,
events, events,
activeTab,
}: IDevtoolsButtons) => { }: IDevtoolsButtons) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { aiSummaryStore, integrationsStore } = useStore(); const { aiSummaryStore, integrationsStore } = useStore();
@ -267,36 +262,6 @@ const DevtoolsButtons = observer(
const possibleAudio = events.filter((e) => e.name.includes('media/audio')); const possibleAudio = events.filter((e) => e.name.includes('media/audio'));
const integratedServices = const integratedServices =
integrationsStore.integrations.backendLogIntegrations; integrationsStore.integrations.backendLogIntegrations;
const showIcons = activeTab === 'EXPORT'
const labels = {
console: {
icon: <CodeOutlined size={14} />,
label: t('Console'),
},
performance: {
icon: <DashboardOutlined size={14} />,
label: t('Performance'),
},
network: {
icon: <ArrowDownUp size={14} strokeWidth={2} />,
label: t('Network'),
},
events: {
icon: <ListCollapse size={14} strokeWidth={2} />,
label: t('Events'),
},
state: {
icon: getStorageName(storageType).icon,
label: getStorageName(storageType).name,
},
graphql: {
icon: <Merge size={14} strokeWidth={2} />,
label: 'Graphql',
}
}
// @ts-ignore
const getLabel = (block: string) => labels[block][showIcons ? 'icon' : 'label']
return ( return (
<> <>
{isSaas ? <SummaryButton onClick={showSummary} /> : null} {isSaas ? <SummaryButton onClick={showSummary} /> : null}
@ -309,7 +274,6 @@ const DevtoolsButtons = observer(
</div> </div>
</div> </div>
} }
customKey="xray"
label="X-Ray" label="X-Ray"
onClick={() => toggleBottomTools(OVERVIEW)} onClick={() => toggleBottomTools(OVERVIEW)}
active={bottomBlock === OVERVIEW && !inspectorMode} active={bottomBlock === OVERVIEW && !inspectorMode}
@ -322,11 +286,10 @@ const DevtoolsButtons = observer(
<div>{t('Launch Console')}</div> <div>{t('Launch Console')}</div>
</div> </div>
} }
customKey="console"
disabled={disableButtons} disabled={disableButtons}
onClick={() => toggleBottomTools(CONSOLE)} onClick={() => toggleBottomTools(CONSOLE)}
active={bottomBlock === CONSOLE && !inspectorMode} active={bottomBlock === CONSOLE && !inspectorMode}
label={getLabel('console')} label={t('Console')}
hasErrors={logRedCount > 0 || showExceptions} hasErrors={logRedCount > 0 || showExceptions}
/> />
@ -337,11 +300,10 @@ const DevtoolsButtons = observer(
<div>{t('Launch Network')}</div> <div>{t('Launch Network')}</div>
</div> </div>
} }
customKey="network"
disabled={disableButtons} disabled={disableButtons}
onClick={() => toggleBottomTools(NETWORK)} onClick={() => toggleBottomTools(NETWORK)}
active={bottomBlock === NETWORK && !inspectorMode} active={bottomBlock === NETWORK && !inspectorMode}
label={getLabel('network')} label={t('Network')}
hasErrors={resourceRedCount > 0} hasErrors={resourceRedCount > 0}
/> />
@ -352,11 +314,10 @@ const DevtoolsButtons = observer(
<div>{t('Launch Performance')}</div> <div>{t('Launch Performance')}</div>
</div> </div>
} }
customKey="performance"
disabled={disableButtons} disabled={disableButtons}
onClick={() => toggleBottomTools(PERFORMANCE)} onClick={() => toggleBottomTools(PERFORMANCE)}
active={bottomBlock === PERFORMANCE && !inspectorMode} active={bottomBlock === PERFORMANCE && !inspectorMode}
label={getLabel('performance')} label="Performance"
/> />
{showGraphql && ( {showGraphql && (
@ -364,8 +325,7 @@ const DevtoolsButtons = observer(
disabled={disableButtons} disabled={disableButtons}
onClick={() => toggleBottomTools(GRAPHQL)} onClick={() => toggleBottomTools(GRAPHQL)}
active={bottomBlock === GRAPHQL && !inspectorMode} active={bottomBlock === GRAPHQL && !inspectorMode}
label={getLabel('graphql')} label="Graphql"
customKey="graphql"
/> />
)} )}
@ -377,11 +337,10 @@ const DevtoolsButtons = observer(
<div>{t('Launch State')}</div> <div>{t('Launch State')}</div>
</div> </div>
} }
customKey="state"
disabled={disableButtons} disabled={disableButtons}
onClick={() => toggleBottomTools(STORAGE)} onClick={() => toggleBottomTools(STORAGE)}
active={bottomBlock === STORAGE && !inspectorMode} active={bottomBlock === STORAGE && !inspectorMode}
label={getLabel('state')} label={getStorageName(storageType) as string}
/> />
)} )}
<ControlButton <ControlButton
@ -391,16 +350,14 @@ const DevtoolsButtons = observer(
<div>{t('Launch Events')}</div> <div>{t('Launch Events')}</div>
</div> </div>
} }
customKey="events"
disabled={disableButtons} disabled={disableButtons}
onClick={() => toggleBottomTools(STACKEVENTS)} onClick={() => toggleBottomTools(STACKEVENTS)}
active={bottomBlock === STACKEVENTS && !inspectorMode} active={bottomBlock === STACKEVENTS && !inspectorMode}
label={getLabel('events')} label={t('Events')}
hasErrors={stackRedCount > 0} hasErrors={stackRedCount > 0}
/> />
{showProfiler && ( {showProfiler && (
<ControlButton <ControlButton
customKey="profiler"
disabled={disableButtons} disabled={disableButtons}
onClick={() => toggleBottomTools(PROFILER)} onClick={() => toggleBottomTools(PROFILER)}
active={bottomBlock === PROFILER && !inspectorMode} active={bottomBlock === PROFILER && !inspectorMode}
@ -411,7 +368,6 @@ const DevtoolsButtons = observer(
<LogsButton <LogsButton
integrated={integratedServices.map((service) => service.name)} integrated={integratedServices.map((service) => service.name)}
onClick={() => toggleBottomTools(BACKENDLOGS)} onClick={() => toggleBottomTools(BACKENDLOGS)}
shorten={showIcons}
/> />
) : null} ) : null}
{possibleAudio.length ? ( {possibleAudio.length ? (

View file

@ -6,11 +6,9 @@ import {
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import stl from './timeline.module.css'; import stl from './timeline.module.css';
import { getTimelinePosition } from './getTimelinePosition'; import { getTimelinePosition } from './getTimelinePosition';
import { useStore } from '@/mstore';
function EventsList() { function EventsList() {
const { store } = useContext(PlayerContext); const { store } = useContext(PlayerContext);
const { uiPlayerStore } = useStore();
const { eventCount, endTime } = store.get(); const { eventCount, endTime } = store.get();
const { tabStates } = store.get(); const { tabStates } = store.get();
@ -19,6 +17,7 @@ function EventsList() {
() => Object.values(tabStates)[0]?.eventList.filter((e) => e.time) || [], () => Object.values(tabStates)[0]?.eventList.filter((e) => e.time) || [],
[eventCount], [eventCount],
); );
React.useEffect(() => { React.useEffect(() => {
const hasDuplicates = events.some( const hasDuplicates = events.some(
(e, i) => (e, i) =>

View file

@ -49,6 +49,7 @@
z-index: 2; z-index: 2;
} }
.event { .event {
position: absolute; position: absolute;
width: 2px; width: 2px;

View file

@ -38,7 +38,6 @@ function SubHeader(props) {
projectsStore, projectsStore,
userStore, userStore,
issueReportingStore, issueReportingStore,
settingsStore
} = useStore(); } = useStore();
const { t } = useTranslation(); const { t } = useTranslation();
const { favorite } = sessionStore.current; const { favorite } = sessionStore.current;
@ -46,7 +45,7 @@ function SubHeader(props) {
const currentSession = sessionStore.current; const currentSession = sessionStore.current;
const projectId = projectsStore.siteId; const projectId = projectsStore.siteId;
const integrations = integrationsStore.issues.list; const integrations = integrationsStore.issues.list;
const { player, store } = React.useContext(PlayerContext); const { store } = React.useContext(PlayerContext);
const { location: currentLocation = 'loading...' } = store.get(); const { location: currentLocation = 'loading...' } = store.get();
const hasIframe = localStorage.getItem(IFRAME) === 'true'; const hasIframe = localStorage.getItem(IFRAME) === 'true';
const [hideTools, setHideTools] = React.useState(false); const [hideTools, setHideTools] = React.useState(false);
@ -128,13 +127,6 @@ function SubHeader(props) {
}); });
}; };
const showVModeBadge = store.get().vModeBadge;
const onVMode = () => {
settingsStore.sessionSettings.updateKey('virtualMode', true);
player.enableVMode?.();
location.reload();
}
return ( return (
<> <>
<div <div
@ -151,8 +143,6 @@ function SubHeader(props) {
siteId={projectId!} siteId={projectId!}
currentLocation={currentLocation} currentLocation={currentLocation}
version={currentSession?.trackerVersion ?? ''} version={currentSession?.trackerVersion ?? ''}
virtualElsFailed={showVModeBadge}
onVMode={onVMode}
/> />
<SessionTabs /> <SessionTabs />

View file

@ -202,7 +202,7 @@ function UnitStepsModal({ onClose }: Props) {
<div className={'w-full'}> <div className={'w-full'}>
<CodeBlock <CodeBlock
width={340} width={340}
height={'calc(100vh - 174px)'} height={'calc(100vh - 146px)'}
extra={`${events.length} Events`} extra={`${events.length} Events`}
copy copy
code={eventStr} code={eventStr}

View file

@ -34,46 +34,38 @@ const WarnBadge = React.memo(
currentLocation, currentLocation,
version, version,
siteId, siteId,
virtualElsFailed,
onVMode,
}: { }: {
currentLocation: string; currentLocation: string;
version: string; version: string;
siteId: string; siteId: string;
virtualElsFailed: boolean;
onVMode: () => void;
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const localhostWarnSiteKey = localhostWarn(siteId); const localhostWarnSiteKey = localhostWarn(siteId);
const defaultLocalhostWarn = const defaultLocalhostWarn =
localStorage.getItem(localhostWarnSiteKey) !== '1'; localStorage.getItem(localhostWarnSiteKey) !== '1';
const localhostWarnActive = Boolean( const localhostWarnActive =
currentLocation && currentLocation &&
defaultLocalhostWarn && defaultLocalhostWarn &&
/(localhost)|(127.0.0.1)|(0.0.0.0)/.test(currentLocation) /(localhost)|(127.0.0.1)|(0.0.0.0)/.test(currentLocation);
)
const trackerVersion = window.env.TRACKER_VERSION ?? undefined; const trackerVersion = window.env.TRACKER_VERSION ?? undefined;
const trackerVerDiff = compareVersions(version, trackerVersion); const trackerVerDiff = compareVersions(version, trackerVersion);
const trackerWarnActive = trackerVerDiff !== VersionComparison.Same; const trackerWarnActive = trackerVerDiff !== VersionComparison.Same;
const [warnings, setWarnings] = React.useState<[localhostWarn: boolean, trackerWarn: boolean, virtualElsFailWarn: boolean]>([localhostWarnActive, trackerWarnActive, virtualElsFailed]) const [showLocalhostWarn, setLocalhostWarn] =
React.useState(localhostWarnActive);
const [showTrackerWarn, setTrackerWarn] = React.useState(trackerWarnActive);
React.useEffect(() => { const closeWarning = (type: 1 | 2) => {
setWarnings([localhostWarnActive, trackerWarnActive, virtualElsFailed])
}, [localhostWarnActive, trackerWarnActive, virtualElsFailed])
const closeWarning = (type: 0 | 1 | 2) => {
if (type === 1) { if (type === 1) {
localStorage.setItem(localhostWarnSiteKey, '1'); localStorage.setItem(localhostWarnSiteKey, '1');
setLocalhostWarn(false);
}
if (type === 2) {
setTrackerWarn(false);
} }
setWarnings((prev) => {
const newWarnings = [...prev];
newWarnings[type] = false;
return newWarnings;
});
}; };
if (!warnings.some(el => el === true)) return null; if (!showLocalhostWarn && !showTrackerWarn) return null;
return ( return (
<div <div
@ -87,7 +79,7 @@ const WarnBadge = React.memo(
fontWeight: 500, fontWeight: 500,
}} }}
> >
{warnings[0] ? ( {showLocalhostWarn ? (
<div className="px-3 py-1 border border-gray-lighter drop-shadow-md rounded bg-active-blue flex items-center justify-between"> <div className="px-3 py-1 border border-gray-lighter drop-shadow-md rounded bg-active-blue flex items-center justify-between">
<div> <div>
<span>{t('Some assets may load incorrectly on localhost.')}</span> <span>{t('Some assets may load incorrectly on localhost.')}</span>
@ -109,7 +101,7 @@ const WarnBadge = React.memo(
</div> </div>
</div> </div>
) : null} ) : null}
{warnings[1] ? ( {showTrackerWarn ? (
<div className="px-3 py-1 border border-gray-lighter drop-shadow-md rounded bg-active-blue flex items-center justify-between"> <div className="px-3 py-1 border border-gray-lighter drop-shadow-md rounded bg-active-blue flex items-center justify-between">
<div> <div>
<div> <div>
@ -133,21 +125,6 @@ const WarnBadge = React.memo(
</div> </div>
</div> </div>
<div
className="py-1 ml-3 cursor-pointer"
onClick={() => closeWarning(1)}
>
<Icon name="close" size={16} color="black" />
</div>
</div>
) : null}
{warnings[2] ? (
<div className="px-3 py-1 border border-gray-lighter drop-shadow-md rounded bg-active-blue flex items-center justify-between">
<div className="flex flex-col">
<div>{t('If you have issues displaying custom HTML elements (i.e when using LWC), consider turning on Virtual Mode.')}</div>
<div className='link' onClick={onVMode}>{t('Enable')}</div>
</div>
<div <div
className="py-1 ml-3 cursor-pointer" className="py-1 ml-3 cursor-pointer"
onClick={() => closeWarning(2)} onClick={() => closeWarning(2)}

View file

@ -12,123 +12,60 @@ import {
getDateRangeFromValue, getDateRangeFromValue,
getDateRangeLabel, getDateRangeLabel,
} from 'App/dateRange'; } from 'App/dateRange';
import { DateTime, Interval, Settings } from 'luxon'; import { DateTime, Interval } from 'luxon';
import styles from './dateRangePopup.module.css'; import styles from './dateRangePopup.module.css';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
function DateRangePopup(props: any) { function DateRangePopup(props: any) {
const { t } = useTranslation(); const { t } = useTranslation();
const [displayDates, setDisplayDates] = React.useState<[Date, Date]>([new Date(), new Date()]);
const [range, setRange] = React.useState( const [range, setRange] = React.useState(
props.selectedDateRange || props.selectedDateRange ||
Interval.fromDateTimes(DateTime.now(), DateTime.now()), Interval.fromDateTimes(DateTime.now(), DateTime.now()),
); );
const [value, setValue] = React.useState<string | null>(null); const [value, setValue] = React.useState<string | null>(null);
React.useEffect(() => { const selectCustomRange = (range) => {
if (props.selectedDateRange) { let newRange;
const start = new Date( if (props.singleDay) {
props.selectedDateRange.start.year, newRange = Interval.fromDateTimes(
props.selectedDateRange.start.month - 1, // JS months are 0-based DateTime.fromJSDate(range),
props.selectedDateRange.start.day DateTime.fromJSDate(range),
); );
const end = new Date( } else {
props.selectedDateRange.end.year, newRange = Interval.fromDateTimes(
props.selectedDateRange.end.month - 1, DateTime.fromJSDate(range[0]),
props.selectedDateRange.end.day DateTime.fromJSDate(range[1]),
); );
setDisplayDates([start, end]); }
} setRange(newRange);
}, [props.selectedDateRange]);
const createNaiveTime = (dateTime: DateTime) => {
if (!dateTime) return null;
return DateTime.fromObject({
hour: dateTime.hour,
minute: dateTime.minute
});
};
const selectCustomRange = (newDates: [Date, Date]) => {
if (!newDates || !newDates[0] || !newDates[1]) return;
setDisplayDates(newDates);
const selectedTzStart = DateTime.fromObject({
year: newDates[0].getFullYear(),
month: newDates[0].getMonth() + 1,
day: newDates[0].getDate(),
hour: 0,
minute: 0
}).setZone(Settings.defaultZone);
const selectedTzEnd = DateTime.fromObject({
year: newDates[1].getFullYear(),
month: newDates[1].getMonth() + 1,
day: newDates[1].getDate(),
hour: 23,
minute: 59
}).setZone(Settings.defaultZone);
const updatedRange = Interval.fromDateTimes(selectedTzStart, selectedTzEnd);
setRange(updatedRange);
setValue(CUSTOM_RANGE); setValue(CUSTOM_RANGE);
}; };
const setRangeTimeStart = (naiveTime: DateTime) => { const setRangeTimeStart = (value: DateTime) => {
if (!range.end || !naiveTime) return; if (!range.end || value > range.end) {
return;
const newStart = range.start.set({ }
hour: naiveTime.hour, const newRange = range.start.set({
minute: naiveTime.minute hour: value.hour,
minute: value.minute,
}); });
setRange(Interval.fromDateTimes(newRange, range.end));
if (newStart > range.end) return;
setRange(Interval.fromDateTimes(newStart, range.end));
setValue(CUSTOM_RANGE); setValue(CUSTOM_RANGE);
}; };
const setRangeTimeEnd = (naiveTime: DateTime) => { const setRangeTimeEnd = (value: DateTime) => {
if (!range.start || !naiveTime) return; if (!range.start || (value && value < range.start)) {
return;
const newEnd = range.end.set({ }
hour: naiveTime.hour, const newRange = range.end.set({ hour: value.hour, minute: value.minute });
minute: naiveTime.minute setRange(Interval.fromDateTimes(range.start, newRange));
});
if (newEnd < range.start) return;
setRange(Interval.fromDateTimes(range.start, newEnd));
setValue(CUSTOM_RANGE); setValue(CUSTOM_RANGE);
}; };
const selectValue = (value: string) => { const selectValue = (value: string) => {
const newRange = getDateRangeFromValue(value); const range = getDateRangeFromValue(value);
setRange(range);
if (!newRange.start || !newRange.end) {
setRange(Interval.fromDateTimes(DateTime.now(), DateTime.now()));
setDisplayDates([new Date(), new Date()]);
setValue(null);
return;
}
const zonedStart = newRange.start.setZone(Settings.defaultZone);
const zonedEnd = newRange.end.setZone(Settings.defaultZone);
setRange(Interval.fromDateTimes(zonedStart, zonedEnd));
const start = new Date(
zonedStart.year,
zonedStart.month - 1,
zonedStart.day
);
const end = new Date(
zonedEnd.year,
zonedEnd.month - 1,
zonedEnd.day
);
setDisplayDates([start, end]);
setValue(value); setValue(value);
}; };
@ -140,9 +77,9 @@ function DateRangePopup(props: any) {
const isUSLocale = const isUSLocale =
navigator.language === 'en-US' || navigator.language.startsWith('en-US'); navigator.language === 'en-US' || navigator.language.startsWith('en-US');
const naiveStartTime = createNaiveTime(range.start); const rangeForDisplay = props.singleDay
const naiveEndTime = createNaiveTime(range.end); ? range.start.ts
: [range.start!.startOf('day').ts, range.end!.startOf('day').ts];
return ( return (
<div className={styles.wrapper}> <div className={styles.wrapper}>
<div className={`${styles.body} h-fit`}> <div className={`${styles.body} h-fit`}>
@ -166,7 +103,7 @@ function DateRangePopup(props: any) {
shouldCloseCalendar={() => false} shouldCloseCalendar={() => false}
isOpen isOpen
maxDate={new Date()} maxDate={new Date()}
value={displayDates} value={rangeForDisplay}
calendarProps={{ calendarProps={{
tileDisabled: props.isTileDisabled, tileDisabled: props.isTileDisabled,
selectRange: !props.singleDay, selectRange: !props.singleDay,
@ -185,7 +122,7 @@ function DateRangePopup(props: any) {
<span>{range.start.toFormat(isUSLocale ? 'MM/dd' : 'dd/MM')} </span> <span>{range.start.toFormat(isUSLocale ? 'MM/dd' : 'dd/MM')} </span>
<TimePicker <TimePicker
format={isUSLocale ? 'hh:mm a' : 'HH:mm'} format={isUSLocale ? 'hh:mm a' : 'HH:mm'}
value={naiveStartTime} value={range.start}
onChange={setRangeTimeStart} onChange={setRangeTimeStart}
needConfirm={false} needConfirm={false}
showNow={false} showNow={false}
@ -195,7 +132,7 @@ function DateRangePopup(props: any) {
<span>{range.end.toFormat(isUSLocale ? 'MM/dd' : 'dd/MM')} </span> <span>{range.end.toFormat(isUSLocale ? 'MM/dd' : 'dd/MM')} </span>
<TimePicker <TimePicker
format={isUSLocale ? 'hh:mm a' : 'HH:mm'} format={isUSLocale ? 'hh:mm a' : 'HH:mm'}
value={naiveEndTime} value={range.end}
onChange={setRangeTimeEnd} onChange={setRangeTimeEnd}
needConfirm={false} needConfirm={false}
showNow={false} showNow={false}

View file

@ -1,17 +1,9 @@
/* eslint-disable i18next/no-literal-string */ /* eslint-disable i18next/no-literal-string */
import { ResourceType, Timed } from 'Player'; import { ResourceType, Timed } from 'Player';
import { WsChannel } from 'Player/web/messages';
import MobilePlayer from 'Player/mobile/IOSPlayer'; import MobilePlayer from 'Player/mobile/IOSPlayer';
import WebPlayer from 'Player/web/WebPlayer'; import WebPlayer from 'Player/web/WebPlayer';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import React, { import React, { useMemo, useState } from 'react';
useMemo,
useState,
useEffect,
useCallback,
useRef,
} from 'react';
import i18n from 'App/i18n'
import { useModal } from 'App/components/Modal'; import { useModal } from 'App/components/Modal';
import { import {
@ -20,27 +12,25 @@ import {
} from 'App/components/Session/playerContext'; } from 'App/components/Session/playerContext';
import { formatMs } from 'App/date'; import { formatMs } from 'App/date';
import { useStore } from 'App/mstore'; import { useStore } from 'App/mstore';
import { formatBytes, debounceCall } from 'App/utils'; import { formatBytes } from 'App/utils';
import { Icon, NoContent, Tabs } from 'UI'; import { Icon, NoContent, Tabs } from 'UI';
import { Tooltip, Input, Switch, Form } from 'antd'; import { Tooltip, Input, Switch, Form } from 'antd';
import { import { SearchOutlined, InfoCircleOutlined } from '@ant-design/icons';
SearchOutlined,
InfoCircleOutlined,
} from '@ant-design/icons';
import FetchDetailsModal from 'Shared/FetchDetailsModal'; import FetchDetailsModal from 'Shared/FetchDetailsModal';
import { WsChannel } from 'App/player/web/messages';
import BottomBlock from '../BottomBlock'; import BottomBlock from '../BottomBlock';
import InfoLine from '../BottomBlock/InfoLine'; import InfoLine from '../BottomBlock/InfoLine';
import TabSelector from '../TabSelector'; import TabSelector from '../TabSelector';
import TimeTable from '../TimeTable'; import TimeTable from '../TimeTable';
import useAutoscroll, { getLastItemTime } from '../useAutoscroll'; import useAutoscroll, { getLastItemTime } from '../useAutoscroll';
import { useRegExListFilterMemo, useTabListFilterMemo } from '../useListFilter';
import WSPanel from './WSPanel'; import WSPanel from './WSPanel';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { mergeListsWithZoom, processInChunks } from './utils'
// Constants remain the same
const INDEX_KEY = 'network'; const INDEX_KEY = 'network';
const ALL = 'ALL'; const ALL = 'ALL';
const XHR = 'xhr'; const XHR = 'xhr';
const JS = 'js'; const JS = 'js';
@ -72,9 +62,6 @@ export const NETWORK_TABS = TAP_KEYS.map((tab) => ({
const DOM_LOADED_TIME_COLOR = 'teal'; const DOM_LOADED_TIME_COLOR = 'teal';
const LOAD_TIME_COLOR = 'red'; const LOAD_TIME_COLOR = 'red';
const BATCH_SIZE = 2500;
const INITIAL_LOAD_SIZE = 5000;
export function renderType(r: any) { export function renderType(r: any) {
return ( return (
<Tooltip style={{ width: '100%' }} title={<div>{r.type}</div>}> <Tooltip style={{ width: '100%' }} title={<div>{r.type}</div>}>
@ -92,17 +79,13 @@ export function renderName(r: any) {
} }
function renderSize(r: any) { function renderSize(r: any) {
const t = i18n.t; const { t } = useTranslation();
const notCaptured = t('Not captured'); if (r.responseBodySize) return formatBytes(r.responseBodySize);
const resSizeStr = t('Resource size')
let triggerText; let triggerText;
let content; let content;
if (r.responseBodySize) { if (r.decodedBodySize == null || r.decodedBodySize === 0) {
triggerText = formatBytes(r.responseBodySize);
content = undefined;
} else if (r.decodedBodySize == null || r.decodedBodySize === 0) {
triggerText = 'x'; triggerText = 'x';
content = notCaptured; content = t('Not captured');
} else { } else {
const headerSize = r.headerSize || 0; const headerSize = r.headerSize || 0;
const showTransferred = r.headerSize != null; const showTransferred = r.headerSize != null;
@ -117,7 +100,7 @@ function renderSize(r: any) {
)} transferred over network`} )} transferred over network`}
</li> </li>
)} )}
<li>{`${resSizeStr}: ${formatBytes(r.decodedBodySize)} `}</li> <li>{`${t('Resource size')}: ${formatBytes(r.decodedBodySize)} `}</li>
</ul> </ul>
); );
} }
@ -185,8 +168,6 @@ function renderStatus({
); );
} }
// Main component for Network Panel
function NetworkPanelCont({ panelHeight }: { panelHeight: number }) { function NetworkPanelCont({ panelHeight }: { panelHeight: number }) {
const { player, store } = React.useContext(PlayerContext); const { player, store } = React.useContext(PlayerContext);
const { sessionStore, uiPlayerStore } = useStore(); const { sessionStore, uiPlayerStore } = useStore();
@ -235,7 +216,6 @@ function NetworkPanelCont({ panelHeight }: { panelHeight: number }) {
const getTabNum = (tab: string) => tabsArr.findIndex((t) => t === tab) + 1; const getTabNum = (tab: string) => tabsArr.findIndex((t) => t === tab) + 1;
const getTabName = (tabId: string) => tabNames[tabId]; const getTabName = (tabId: string) => tabNames[tabId];
return ( return (
<NetworkPanelComp <NetworkPanelComp
loadTime={loadTime} loadTime={loadTime}
@ -248,8 +228,8 @@ function NetworkPanelCont({ panelHeight }: { panelHeight: number }) {
resourceListNow={resourceListNow} resourceListNow={resourceListNow}
player={player} player={player}
startedAt={startedAt} startedAt={startedAt}
websocketList={websocketList} websocketList={websocketList as WSMessage[]}
websocketListNow={websocketListNow} websocketListNow={websocketListNow as WSMessage[]}
getTabNum={getTabNum} getTabNum={getTabNum}
getTabName={getTabName} getTabName={getTabName}
showSingleTab={showSingleTab} showSingleTab={showSingleTab}
@ -289,7 +269,9 @@ function MobileNetworkPanelCont({ panelHeight }: { panelHeight: number }) {
resourceListNow={resourceListNow} resourceListNow={resourceListNow}
player={player} player={player}
startedAt={startedAt} startedAt={startedAt}
// @ts-ignore
websocketList={websocketList} websocketList={websocketList}
// @ts-ignore
websocketListNow={websocketListNow} websocketListNow={websocketListNow}
zoomEnabled={zoomEnabled} zoomEnabled={zoomEnabled}
zoomStartTs={zoomStartTs} zoomStartTs={zoomStartTs}
@ -298,35 +280,12 @@ function MobileNetworkPanelCont({ panelHeight }: { panelHeight: number }) {
); );
} }
const useInfiniteScroll = (loadMoreCallback: () => void, hasMore: boolean) => { type WSMessage = Timed & {
const observerRef = useRef<IntersectionObserver>(null); channelName: string;
const loadingRef = useRef<HTMLDivElement>(null); data: string;
timestamp: number;
useEffect(() => { dir: 'up' | 'down';
const observer = new IntersectionObserver( messageType: string;
(entries) => {
if (entries[0]?.isIntersecting && hasMore) {
loadMoreCallback();
}
},
{ threshold: 0.1 },
);
if (loadingRef.current) {
observer.observe(loadingRef.current);
}
// @ts-ignore
observerRef.current = observer;
return () => {
if (observerRef.current) {
observerRef.current.disconnect();
}
};
}, [loadMoreCallback, hasMore, loadingRef]);
return loadingRef;
}; };
interface Props { interface Props {
@ -343,8 +302,8 @@ interface Props {
resourceList: Timed[]; resourceList: Timed[];
fetchListNow: Timed[]; fetchListNow: Timed[];
resourceListNow: Timed[]; resourceListNow: Timed[];
websocketList: Array<WsChannel>; websocketList: Array<WSMessage>;
websocketListNow: Array<WsChannel>; websocketListNow: Array<WSMessage>;
player: WebPlayer | MobilePlayer; player: WebPlayer | MobilePlayer;
startedAt: number; startedAt: number;
isMobile?: boolean; isMobile?: boolean;
@ -390,189 +349,107 @@ export const NetworkPanelComp = observer(
>(null); >(null);
const { showModal } = useModal(); const { showModal } = useModal();
const [showOnlyErrors, setShowOnlyErrors] = useState(false); const [showOnlyErrors, setShowOnlyErrors] = useState(false);
const [isDetailsModalActive, setIsDetailsModalActive] = useState(false); const [isDetailsModalActive, setIsDetailsModalActive] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isProcessing, setIsProcessing] = useState(false);
const [displayedItems, setDisplayedItems] = useState([]);
const [totalItems, setTotalItems] = useState(0);
const [summaryStats, setSummaryStats] = useState({
resourcesSize: 0,
transferredSize: 0,
});
const originalListRef = useRef([]);
const socketListRef = useRef([]);
const { const {
sessionStore: { devTools }, sessionStore: { devTools },
} = useStore(); } = useStore();
const { filter } = devTools[INDEX_KEY]; const { filter } = devTools[INDEX_KEY];
const { activeTab } = devTools[INDEX_KEY]; const { activeTab } = devTools[INDEX_KEY];
const activeIndex = activeOutsideIndex ?? devTools[INDEX_KEY].index; const activeIndex = activeOutsideIndex ?? devTools[INDEX_KEY].index;
const [inputFilterValue, setInputFilterValue] = useState(filter);
const debouncedFilter = useCallback( const socketList = useMemo(
debounceCall((filterValue) => { () =>
devTools.update(INDEX_KEY, { filter: filterValue }); websocketList.filter(
}, 300), (ws, i, arr) =>
[], arr.findIndex((it) => it.channelName === ws.channelName) === i,
),
[websocketList],
); );
// Process socket lists once const list = useMemo(
useEffect(() => { () =>
const uniqueSocketList = websocketList.filter( // TODO: better merge (with body size info) - do it in player
(ws, i, arr) => resourceList
arr.findIndex((it) => it.channelName === ws.channelName) === i, .filter(
); (res) =>
socketListRef.current = uniqueSocketList; !fetchList.some((ft) => {
}, [websocketList.length]); // res.url !== ft.url doesn't work on relative URLs appearing within fetchList (to-fix in player)
if (res.name === ft.name) {
// Initial data processing - do this only once when data changes if (res.time === ft.time) return true;
useEffect(() => { if (res.url.includes(ft.url)) {
setIsLoading(true); return (
Math.abs(res.time - ft.time) < 350 ||
// Heaviest operation here, will create a final merged network list Math.abs(res.timestamp - ft.timestamp) < 350
const processData = async () => { );
const fetchUrls = new Set( }
fetchList.map((ft) => {
return `${ft.name}-${Math.floor(ft.time / 100)}-${Math.floor(ft.duration / 100)}`;
}),
);
// We want to get resources that aren't in fetch list
const filteredResources = await processInChunks(resourceList, (chunk) =>
chunk.filter((res: any) => {
const key = `${res.name}-${Math.floor(res.time / 100)}-${Math.floor(res.duration / 100)}`;
return !fetchUrls.has(key);
}),
BATCH_SIZE,
25,
);
const processedSockets = socketListRef.current.map((ws: any) => ({
...ws,
type: 'websocket',
method: 'ws',
url: ws.channelName,
name: ws.channelName,
status: '101',
duration: 0,
transferredBodySize: 0,
}));
const mergedList: Timed[] = mergeListsWithZoom(
filteredResources as Timed[],
fetchList,
processedSockets as Timed[],
{ enabled: Boolean(zoomEnabled), start: zoomStartTs ?? 0, end: zoomEndTs ?? 0 }
)
originalListRef.current = mergedList;
setTotalItems(mergedList.length);
calculateResourceStats(resourceList);
// Only display initial chunk
setDisplayedItems(mergedList.slice(0, INITIAL_LOAD_SIZE));
setIsLoading(false);
};
void processData();
}, [
resourceList.length,
fetchList.length,
socketListRef.current.length,
zoomEnabled,
zoomStartTs,
zoomEndTs,
]);
const calculateResourceStats = (resourceList: Record<string, any>) => {
setTimeout(() => {
let resourcesSize = 0
let transferredSize = 0
resourceList.forEach(({ decodedBodySize, headerSize, encodedBodySize }: any) => {
resourcesSize += decodedBodySize || 0
transferredSize += (headerSize || 0) + (encodedBodySize || 0)
})
setSummaryStats({
resourcesSize,
transferredSize,
});
}, 0);
}
useEffect(() => {
if (originalListRef.current.length === 0) return;
setIsProcessing(true);
const applyFilters = async () => {
let filteredItems: any[] = originalListRef.current;
filteredItems = await processInChunks(filteredItems, (chunk) =>
chunk.filter(
(it) => {
let valid = true;
if (showOnlyErrors) {
valid = parseInt(it.status) >= 400 || !it.success || it.error
}
if (filter) {
try {
const regex = new RegExp(filter, 'i');
valid = valid && regex.test(it.status) || regex.test(it.name) || regex.test(it.type) || regex.test(it.method);
} catch (e) {
valid = valid && String(it.status).includes(filter) || it.name.includes(filter) || it.type.includes(filter) || (it.method && it.method.includes(filter));
} }
}
if (activeTab !== ALL) {
valid = valid && TYPE_TO_TAB[it.type] === activeTab;
}
return valid; if (res.name !== ft.name) {
}, return false;
), }
); if (Math.abs(res.time - ft.time) > 250) {
return false;
} // TODO: find good epsilons
if (Math.abs(res.duration - ft.duration) > 200) {
return false;
}
// Update displayed items return true;
setDisplayedItems(filteredItems.slice(0, INITIAL_LOAD_SIZE)); }),
setTotalItems(filteredItems.length); )
setIsProcessing(false); .concat(fetchList)
}; .concat(
socketList.map((ws) => ({
...ws,
type: 'websocket',
method: 'ws',
url: ws.channelName,
name: ws.channelName,
status: '101',
duration: 0,
transferredBodySize: 0,
})),
)
.filter((req) =>
zoomEnabled
? req.time >= zoomStartTs! && req.time <= zoomEndTs!
: true,
)
.sort((a, b) => a.time - b.time),
[resourceList.length, fetchList.length, socketList.length],
);
void applyFilters(); let filteredList = useMemo(() => {
}, [filter, activeTab, showOnlyErrors]); if (!showOnlyErrors) {
return list;
}
return list.filter(
(it) => parseInt(it.status) >= 400 || !it.success || it.error,
);
}, [showOnlyErrors, list]);
filteredList = useRegExListFilterMemo(
filteredList,
(it) => [it.status, it.name, it.type, it.method],
filter,
);
filteredList = useTabListFilterMemo(
filteredList,
(it) => TYPE_TO_TAB[it.type],
ALL,
activeTab,
);
const loadMoreItems = useCallback(() => { const onTabClick = (activeTab: (typeof TAP_KEYS)[number]) =>
if (isProcessing) return;
setIsProcessing(true);
setTimeout(() => {
setDisplayedItems((prevItems) => {
const currentLength = prevItems.length;
const newItems = originalListRef.current.slice(
currentLength,
currentLength + BATCH_SIZE,
);
return [...prevItems, ...newItems];
});
setIsProcessing(false);
}, 10);
}, [isProcessing]);
const hasMoreItems = displayedItems.length < totalItems;
const loadingRef = useInfiniteScroll(loadMoreItems, hasMoreItems);
const onTabClick = (activeTab) => {
devTools.update(INDEX_KEY, { activeTab }); devTools.update(INDEX_KEY, { activeTab });
}; const onFilterChange = ({
target: { value },
const onFilterChange = ({ target: { value } }) => { }: React.ChangeEvent<HTMLInputElement>) =>
setInputFilterValue(value) devTools.update(INDEX_KEY, { filter: value });
debouncedFilter(value);
};
// AutoScroll
const [timeoutStartAutoscroll, stopAutoscroll] = useAutoscroll( const [timeoutStartAutoscroll, stopAutoscroll] = useAutoscroll(
displayedItems, filteredList,
getLastItemTime(fetchListNow, resourceListNow), getLastItemTime(fetchListNow, resourceListNow),
activeIndex, activeIndex,
(index) => devTools.update(INDEX_KEY, { index }), (index) => devTools.update(INDEX_KEY, { index }),
@ -585,6 +462,24 @@ export const NetworkPanelComp = observer(
timeoutStartAutoscroll(); timeoutStartAutoscroll();
}; };
const resourcesSize = useMemo(
() =>
resourceList.reduce(
(sum, { decodedBodySize }) => sum + (decodedBodySize || 0),
0,
),
[resourceList.length],
);
const transferredSize = useMemo(
() =>
resourceList.reduce(
(sum, { headerSize, encodedBodySize }) =>
sum + (headerSize || 0) + (encodedBodySize || 0),
0,
),
[resourceList.length],
);
const referenceLines = useMemo(() => { const referenceLines = useMemo(() => {
const arr = []; const arr = [];
@ -618,7 +513,7 @@ export const NetworkPanelComp = observer(
isSpot={isSpot} isSpot={isSpot}
time={item.time + startedAt} time={item.time + startedAt}
resource={item} resource={item}
rows={displayedItems} rows={filteredList}
fetchPresented={fetchList.length > 0} fetchPresented={fetchList.length > 0}
/>, />,
{ {
@ -630,10 +525,12 @@ export const NetworkPanelComp = observer(
}, },
}, },
); );
devTools.update(INDEX_KEY, { index: filteredList.indexOf(item) });
stopAutoscroll();
}; };
const tableCols = useMemo(() => { const tableCols = React.useMemo(() => {
const cols = [ const cols: any[] = [
{ {
label: t('Status'), label: t('Status'),
dataKey: 'status', dataKey: 'status',
@ -688,7 +585,7 @@ export const NetworkPanelComp = observer(
}); });
} }
return cols; return cols;
}, [showSingleTab, activeTab, t, getTabName, getTabNum, isSpot]); }, [showSingleTab]);
return ( return (
<BottomBlock <BottomBlock
@ -720,7 +617,7 @@ export const NetworkPanelComp = observer(
name="filter" name="filter"
onChange={onFilterChange} onChange={onFilterChange}
width={280} width={280}
value={inputFilterValue} value={filter}
size="small" size="small"
prefix={<SearchOutlined className="text-neutral-400" />} prefix={<SearchOutlined className="text-neutral-400" />}
/> />
@ -728,7 +625,7 @@ export const NetworkPanelComp = observer(
</BottomBlock.Header> </BottomBlock.Header>
<BottomBlock.Content> <BottomBlock.Content>
<div className="flex items-center justify-between px-4 border-b bg-teal/5 h-8"> <div className="flex items-center justify-between px-4 border-b bg-teal/5 h-8">
<div className="flex items-center"> <div>
<Form.Item name="show-errors-only" className="mb-0"> <Form.Item name="show-errors-only" className="mb-0">
<label <label
style={{ style={{
@ -745,29 +642,21 @@ export const NetworkPanelComp = observer(
<span className="text-sm ms-2">4xx-5xx Only</span> <span className="text-sm ms-2">4xx-5xx Only</span>
</label> </label>
</Form.Item> </Form.Item>
{isProcessing && (
<span className="text-xs text-gray-500 ml-4">
Processing data...
</span>
)}
</div> </div>
<InfoLine> <InfoLine>
<InfoLine.Point label={`${totalItems}`} value="requests" />
<InfoLine.Point <InfoLine.Point
label={`${displayedItems.length}/${totalItems}`} label={`${filteredList.length}`}
value="displayed" value=" requests"
display={displayedItems.length < totalItems}
/> />
<InfoLine.Point <InfoLine.Point
label={formatBytes(summaryStats.transferredSize)} label={formatBytes(transferredSize)}
value="transferred" value="transferred"
display={summaryStats.transferredSize > 0} display={transferredSize > 0}
/> />
<InfoLine.Point <InfoLine.Point
label={formatBytes(summaryStats.resourcesSize)} label={formatBytes(resourcesSize)}
value="resources" value="resources"
display={summaryStats.resourcesSize > 0} display={resourcesSize > 0}
/> />
<InfoLine.Point <InfoLine.Point
label={formatMs(domBuildingTime)} label={formatMs(domBuildingTime)}
@ -790,67 +679,42 @@ export const NetworkPanelComp = observer(
/> />
</InfoLine> </InfoLine>
</div> </div>
<NoContent
{isLoading ? ( title={
<div className="flex items-center justify-center h-full"> <div className="capitalize flex items-center gap-2">
<div className="text-center"> <InfoCircleOutlined size={18} />
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 mx-auto mb-2"></div> {t('No Data')}
<p>Processing initial network data...</p>
</div> </div>
</div> }
) : ( size="small"
<NoContent show={filteredList.length === 0}
title={ >
<div className="capitalize flex items-center gap-2"> {/* @ts-ignore */}
<InfoCircleOutlined size={18} /> <TimeTable
{t('No Data')} rows={filteredList}
</div> tableHeight={panelHeight - 102}
} referenceLines={referenceLines}
size="small" renderPopup
show={displayedItems.length === 0} onRowClick={showDetailsModal}
sortBy="time"
sortAscending
onJump={(row: any) => {
devTools.update(INDEX_KEY, {
index: filteredList.indexOf(row),
});
player.jump(row.time);
}}
activeIndex={activeIndex}
> >
<div> {tableCols}
<TimeTable </TimeTable>
rows={displayedItems} {selectedWsChannel ? (
tableHeight={panelHeight - 102 - (hasMoreItems ? 30 : 0)} <WSPanel
referenceLines={referenceLines} socketMsgList={selectedWsChannel}
renderPopup onClose={() => setSelectedWsChannel(null)}
onRowClick={showDetailsModal} />
sortBy="time" ) : null}
sortAscending </NoContent>
onJump={(row) => {
devTools.update(INDEX_KEY, {
index: displayedItems.indexOf(row),
});
player.jump(row.time);
}}
activeIndex={activeIndex}
>
{tableCols}
</TimeTable>
{hasMoreItems && (
<div
ref={loadingRef}
className="flex justify-center items-center text-xs text-gray-500"
>
<div className="flex items-center">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-600 mr-2"></div>
Loading more data ({totalItems - displayedItems.length}{' '}
remaining)
</div>
</div>
)}
</div>
{selectedWsChannel ? (
<WSPanel
socketMsgList={selectedWsChannel}
onClose={() => setSelectedWsChannel(null)}
/>
) : null}
</NoContent>
)}
</BottomBlock.Content> </BottomBlock.Content>
</BottomBlock> </BottomBlock>
); );
@ -858,6 +722,7 @@ export const NetworkPanelComp = observer(
); );
const WebNetworkPanel = observer(NetworkPanelCont); const WebNetworkPanel = observer(NetworkPanelCont);
const MobileNetworkPanel = observer(MobileNetworkPanelCont); const MobileNetworkPanel = observer(MobileNetworkPanelCont);
export { WebNetworkPanel, MobileNetworkPanel }; export { WebNetworkPanel, MobileNetworkPanel };

View file

@ -1,178 +0,0 @@
export function mergeListsWithZoom<
T extends Record<string, any>,
Y extends Record<string, any>,
Z extends Record<string, any>,
>(
arr1: T[],
arr2: Y[],
arr3: Z[],
zoom?: { enabled: boolean; start: number; end: number },
): Array<T | Y | Z> {
// Early return for empty arrays
if (arr1.length === 0 && arr2.length === 0 && arr3.length === 0) {
return [];
}
// Optimized for common case - no zoom
if (!zoom?.enabled) {
return mergeThreeSortedArrays(arr1, arr2, arr3);
}
// Binary search for start indexes (faster than linear search for large arrays)
const index1 = binarySearchStartIndex(arr1, zoom.start);
const index2 = binarySearchStartIndex(arr2, zoom.start);
const index3 = binarySearchStartIndex(arr3, zoom.start);
// Merge arrays within zoom range
return mergeThreeSortedArraysWithinRange(
arr1,
arr2,
arr3,
index1,
index2,
index3,
zoom.start,
zoom.end,
);
}
function binarySearchStartIndex<T extends Record<string, any>>(
arr: T[],
threshold: number,
): number {
if (arr.length === 0) return 0;
let low = 0;
let high = arr.length - 1;
// Handle edge cases first for better performance
if (arr[high].time < threshold) return arr.length;
if (arr[low].time >= threshold) return 0;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
if (arr[mid].time < threshold) {
low = mid + 1;
} else {
high = mid - 1;
}
}
return low;
}
function mergeThreeSortedArrays<
T extends Record<string, any>,
Y extends Record<string, any>,
Z extends Record<string, any>,
>(arr1: T[], arr2: Y[], arr3: Z[]): Array<T | Y | Z> {
const totalLength = arr1.length + arr2.length + arr3.length;
// prealloc array size
const result = new Array(totalLength);
let i = 0,
j = 0,
k = 0,
index = 0;
while (i < arr1.length || j < arr2.length || k < arr3.length) {
const val1 = i < arr1.length ? arr1[i].time : Infinity;
const val2 = j < arr2.length ? arr2[j].time : Infinity;
const val3 = k < arr3.length ? arr3[k].time : Infinity;
if (val1 <= val2 && val1 <= val3) {
result[index++] = arr1[i++];
} else if (val2 <= val1 && val2 <= val3) {
result[index++] = arr2[j++];
} else {
result[index++] = arr3[k++];
}
}
return result;
}
// same as above, just with zoom stuff
function mergeThreeSortedArraysWithinRange<
T extends Record<string, any>,
Y extends Record<string, any>,
Z extends Record<string, any>,
>(
arr1: T[],
arr2: Y[],
arr3: Z[],
startIdx1: number,
startIdx2: number,
startIdx3: number,
start: number,
end: number,
): Array<T | Y | Z> {
// we don't know beforehand how many items will be there
const result = [];
let i = startIdx1;
let j = startIdx2;
let k = startIdx3;
while (i < arr1.length || j < arr2.length || k < arr3.length) {
const val1 = i < arr1.length ? arr1[i].time : Infinity;
const val2 = j < arr2.length ? arr2[j].time : Infinity;
const val3 = k < arr3.length ? arr3[k].time : Infinity;
// Early termination: if all remaining values exceed end time
if (Math.min(val1, val2, val3) > end) {
break;
}
if (val1 <= val2 && val1 <= val3) {
if (val1 <= end) {
result.push(arr1[i]);
}
i++;
} else if (val2 <= val1 && val2 <= val3) {
if (val2 <= end) {
result.push(arr2[j]);
}
j++;
} else {
if (val3 <= end) {
result.push(arr3[k]);
}
k++;
}
}
return result;
}
export function processInChunks(
items: any[],
processFn: (item: any) => any,
chunkSize = 1000,
overscan = 0,
) {
return new Promise((resolve) => {
if (items.length === 0) {
resolve([]);
return;
}
let result: any[] = [];
let index = 0;
const processNextChunk = () => {
const chunk = items.slice(index, index + chunkSize + overscan);
result = result.concat(processFn(chunk));
index += chunkSize;
if (index < items.length) {
setTimeout(processNextChunk, 0);
} else {
resolve(result);
}
};
processNextChunk();
});
}

View file

@ -18,7 +18,7 @@ function DocCard(props: Props) {
} = props; } = props;
return ( return (
<div className={cn('p-5 bg-gray-lightest mb-4 rounded-lg', className)}> <div className={cn('p-5 bg-gray-lightest mb-4 rounded', className)}>
<div className="font-medium mb-2 flex items-center"> <div className="font-medium mb-2 flex items-center">
{props.icon && ( {props.icon && (
<div <div

View file

@ -5,7 +5,6 @@ import cn from 'classnames';
import { Loader } from 'UI'; import { Loader } from 'UI';
import OutsideClickDetectingDiv from 'Shared/OutsideClickDetectingDiv'; import OutsideClickDetectingDiv from 'Shared/OutsideClickDetectingDiv';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { VList } from 'virtua';
function TruncatedText({ function TruncatedText({
text, text,
@ -125,7 +124,7 @@ export function AutocompleteModal({
if (index === blocksAmount - 1 && blocksAmount > 1) { if (index === blocksAmount - 1 && blocksAmount > 1) {
str += ' and '; str += ' and ';
} }
str += block.trim(); str += `"${block.trim()}"`;
if (index < blocksAmount - 2) { if (index < blocksAmount - 2) {
str += ', '; str += ', ';
} }
@ -171,27 +170,25 @@ export function AutocompleteModal({
<> <>
<div <div
className="flex flex-col gap-2 overflow-y-auto py-2 overflow-x-hidden text-ellipsis" className="flex flex-col gap-2 overflow-y-auto py-2 overflow-x-hidden text-ellipsis"
style={{ height: Math.min(sortedOptions.length * 32, 240) }} style={{ maxHeight: 200 }}
> >
<VList count={sortedOptions.length} itemSize={18}> {sortedOptions.map((item) => (
{sortedOptions.map((item) => ( <div
<div key={item.value}
key={item.value} onClick={() => onSelectOption(item)}
onClick={() => onSelectOption(item)} className="cursor-pointer w-full py-1 hover:bg-active-blue rounded px-2"
className="cursor-pointer w-full py-1 hover:bg-active-blue rounded px-2" >
> <Checkbox checked={isSelected(item)} /> {item.label}
<Checkbox checked={isSelected(item)} /> {item.label} </div>
</div> ))}
))}
</VList>
</div> </div>
{query.length ? ( {query.length ? (
<div className="border-y border-y-gray-light py-2"> <div className="border-y border-y-gray-light py-2">
<div <div
className="whitespace-nowrap truncate w-full rounded cursor-pointer text-teal hover:bg-active-blue px-2 py-1" className="whitespace-normal rounded cursor-pointer text-teal hover:bg-active-blue px-2 py-1"
onClick={applyQuery} onClick={applyQuery}
> >
{t('Apply')}&nbsp;<span className='font-semibold'>{queryStr}</span> {t('Apply')}&nbsp;{queryStr}
</div> </div>
</div> </div>
) : null} ) : null}

View file

@ -128,10 +128,8 @@ const FilterAutoComplete = observer(
}; };
const handleFocus = () => { const handleFocus = () => {
if (!initialFocus) {
setOptions(topValues.map((i) => ({ value: i.value, label: i.value })));
}
setInitialFocus(true); setInitialFocus(true);
setOptions(topValues.map((i) => ({ value: i.value, label: i.value })));
}; };
return ( return (

View file

@ -9,10 +9,8 @@ function LiveSessionSearch() {
const appliedFilter = searchStoreLive.instance; const appliedFilter = searchStoreLive.instance;
useEffect(() => { useEffect(() => {
if (projectsStore.activeSiteId) { void searchStoreLive.fetchSessions();
void searchStoreLive.fetchSessions(true); }, []);
}
}, [projectsStore.activeSiteId])
const onAddFilter = (filter: any) => { const onAddFilter = (filter: any) => {
filter.autoOpen = true; filter.autoOpen = true;

View file

@ -53,6 +53,9 @@ function SessionFilters() {
onBeforeLoad: async () => { onBeforeLoad: async () => {
await reloadTags(); await reloadTags();
}, },
onLoaded: () => {
debounceFetch = debounce(() => searchStore.fetchSessions(), 500);
}
}); });
const onAddFilter = (filter: any) => { const onAddFilter = (filter: any) => {

View file

@ -19,13 +19,11 @@ export default function MetaItem(props: Props) {
<TextEllipsis <TextEllipsis
text={label} text={label}
className="p-0" className="p-0"
maxWidth={'300px'}
popupProps={{ size: 'small', disabled: true }} popupProps={{ size: 'small', disabled: true }}
/> />
<span className="bg-neutral-200 inline-block w-[1px] min-h-[17px]"></span> <span className="bg-neutral-200 inline-block w-[1px] min-h-[17px]"></span>
<TextEllipsis <TextEllipsis
text={value} text={value}
maxWidth={'350px'}
className="p-0 text-neutral-500" className="p-0 text-neutral-500"
popupProps={{ size: 'small', disabled: true }} popupProps={{ size: 'small', disabled: true }}
/> />

View file

@ -7,15 +7,13 @@ interface Props {
className?: string; className?: string;
metaList: any[]; metaList: any[];
maxLength?: number; maxLength?: number;
onMetaClick?: (meta: { name: string, value: string }) => void;
horizontal?: boolean;
} }
export default function SessionMetaList(props: Props) { export default function SessionMetaList(props: Props) {
const { className = '', metaList, maxLength = 14, horizontal = false } = props; const { className = '', metaList, maxLength = 14 } = props;
return ( return (
<div className={cn('flex items-center gap-1', horizontal ? '' : 'flex-wrap', className)}> <div className={cn('flex items-center flex-wrap gap-1', className)}>
{metaList.slice(0, maxLength).map(({ label, value }, index) => ( {metaList.slice(0, maxLength).map(({ label, value }, index) => (
<React.Fragment key={index}> <React.Fragment key={index}>
<MetaItem label={label} value={`${value}`} /> <MetaItem label={label} value={`${value}`} />

View file

@ -5,7 +5,6 @@ import ListingVisibility from './components/ListingVisibility';
import DefaultPlaying from './components/DefaultPlaying'; import DefaultPlaying from './components/DefaultPlaying';
import DefaultTimezone from './components/DefaultTimezone'; import DefaultTimezone from './components/DefaultTimezone';
import CaptureRate from './components/CaptureRate'; import CaptureRate from './components/CaptureRate';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
function SessionSettings() { function SessionSettings() {

View file

@ -1,30 +0,0 @@
import React from 'react';
import { useStore } from 'App/mstore';
import { observer } from 'mobx-react-lite';
import { Switch } from 'UI';
import { useTranslation } from 'react-i18next';
function VirtualModeSettings() {
const { settingsStore } = useStore();
const { sessionSettings } = settingsStore;
const { virtualMode } = sessionSettings;
const { t } = useTranslation();
const updateSettings = (checked: boolean) => {
settingsStore.sessionSettings.updateKey('virtualMode', !virtualMode);
};
return (
<div>
<h3 className="text-lg">{t('Virtual Mode')}</h3>
<div className="my-1">
{t('Change this setting if you have issues with recordings containing Lightning Web Components (or similar custom HTML Element libraries).')}
</div>
<div className="mt-2">
<Switch onChange={updateSettings} checked={virtualMode} />
</div>
</div>
);
}
export default observer(VirtualModeSettings);

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