Merge branch 'dev' of https://github.com/openreplay/openreplay into e2e_tests_frontend

This commit is contained in:
Андрей Бабушкин 2025-06-03 13:51:31 +02:00
commit 304b438154
185 changed files with 6108 additions and 2784 deletions

View file

@ -130,7 +130,7 @@ jobs:
cat /tmp/image_override.yaml cat /tmp/image_override.yaml
# Deploy command # Deploy command
mkdir -p /tmp/charts mkdir -p /tmp/charts
mv openreplay/charts/{ingress-nginx,alerts,quickwit,connector} /tmp/charts/ mv openreplay/charts/{ingress-nginx,alerts,quickwit,connector,assist-api} /tmp/charts/
rm -rf openreplay/charts/* rm -rf openreplay/charts/*
mv /tmp/charts/* 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 - 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 -

View file

@ -130,7 +130,7 @@ jobs:
cat /tmp/image_override.yaml cat /tmp/image_override.yaml
# Deploy command # Deploy command
mkdir -p /tmp/charts mkdir -p /tmp/charts
mv openreplay/charts/{ingress-nginx,alerts,quickwit,connector} /tmp/charts/ mv openreplay/charts/{ingress-nginx,alerts,quickwit,connector,assist-api} /tmp/charts/
rm -rf openreplay/charts/* rm -rf openreplay/charts/*
mv /tmp/charts/* 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 | kubectl apply -n app -f - helm template openreplay -n app openreplay -f vars.yaml -f /tmp/image_override.yaml --set ingress-nginx.enabled=false --set skipMigration=true --no-hooks | kubectl apply -n app -f -

View file

@ -127,7 +127,7 @@ jobs:
cat /tmp/image_override.yaml cat /tmp/image_override.yaml
# Deploy command # Deploy command
mkdir -p /tmp/charts mkdir -p /tmp/charts
mv openreplay/charts/{ingress-nginx,chalice,quickwit,connector} /tmp/charts/ mv openreplay/charts/{ingress-nginx,chalice,quickwit,connector,assist-api} /tmp/charts/
rm -rf openreplay/charts/* rm -rf openreplay/charts/*
mv /tmp/charts/* 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 - 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 -

View file

@ -120,7 +120,7 @@ jobs:
cat /tmp/image_override.yaml cat /tmp/image_override.yaml
# Deploy command # Deploy command
mkdir -p /tmp/charts mkdir -p /tmp/charts
mv openreplay/charts/{ingress-nginx,chalice,quickwit,connector} /tmp/charts/ mv openreplay/charts/{ingress-nginx,chalice,quickwit,connector,assist-api} /tmp/charts/
rm -rf openreplay/charts/* rm -rf openreplay/charts/*
mv /tmp/charts/* 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 | kubectl apply -n app -f - helm template openreplay -n app openreplay -f vars.yaml -f /tmp/image_override.yaml --set ingress-nginx.enabled=false --set skipMigration=true --no-hooks | kubectl apply -n app -f -

View file

@ -113,7 +113,7 @@ jobs:
cat /tmp/image_override.yaml cat /tmp/image_override.yaml
# Deploy command # Deploy command
mkdir -p /tmp/charts mkdir -p /tmp/charts
mv openreplay/charts/{ingress-nginx,assist,quickwit,connector} /tmp/charts/ mv openreplay/charts/{ingress-nginx,assist,quickwit,connector,assist-api} /tmp/charts/
rm -rf openreplay/charts/* rm -rf openreplay/charts/*
mv /tmp/charts/* 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 - 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 -

View file

@ -111,7 +111,7 @@ jobs:
cat /tmp/image_override.yaml cat /tmp/image_override.yaml
# Deploy command # Deploy command
mkdir -p /tmp/charts mkdir -p /tmp/charts
mv openreplay/charts/{ingress-nginx,assist-server,quickwit,connector} /tmp/charts/ mv openreplay/charts/{ingress-nginx,assist-server,quickwit,connector,assist-api} /tmp/charts/
rm -rf openreplay/charts/* rm -rf openreplay/charts/*
mv /tmp/charts/* 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 - 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 -

View file

@ -130,7 +130,7 @@ jobs:
cat /tmp/image_override.yaml cat /tmp/image_override.yaml
# Deploy command # Deploy command
mkdir -p /tmp/charts mkdir -p /tmp/charts
mv openreplay/charts/{ingress-nginx,assist-stats,quickwit,connector} /tmp/charts/ mv openreplay/charts/{ingress-nginx,assist-stats,quickwit,connector,assist-api} /tmp/charts/
rm -rf openreplay/charts/* rm -rf openreplay/charts/*
mv /tmp/charts/* 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 | kubectl apply -f - helm template openreplay -n app openreplay -f vars.yaml -f /tmp/image_override.yaml --set ingress-nginx.enabled=false --set skipMigration=true --no-hooks | kubectl apply -f -

View file

@ -112,7 +112,7 @@ jobs:
cat /tmp/image_override.yaml cat /tmp/image_override.yaml
# Deploy command # Deploy command
mkdir -p /tmp/charts mkdir -p /tmp/charts
mv openreplay/charts/{ingress-nginx,assist,quickwit,connector} /tmp/charts/ mv openreplay/charts/{ingress-nginx,assist,quickwit,connector,assist-api} /tmp/charts/
rm -rf openreplay/charts/* rm -rf openreplay/charts/*
mv /tmp/charts/* 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 - 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 -

View file

@ -129,7 +129,7 @@ jobs:
cat /tmp/image_override.yaml cat /tmp/image_override.yaml
# Deploy command # Deploy command
mkdir -p /tmp/charts mkdir -p /tmp/charts
mv openreplay/charts/{ingress-nginx,utilities,quickwit,connector} /tmp/charts/ mv openreplay/charts/{ingress-nginx,utilities,quickwit,connector,assist-api} /tmp/charts/
rm -rf openreplay/charts/* rm -rf openreplay/charts/*
mv /tmp/charts/* 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 - 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 -

View file

@ -76,7 +76,7 @@ jobs:
cat /tmp/image_override.yaml cat /tmp/image_override.yaml
# Deploy command # Deploy command
mkdir -p /tmp/charts mkdir -p /tmp/charts
mv openreplay/charts/{ingress-nginx,frontend,quickwit,connector} /tmp/charts/ mv openreplay/charts/{ingress-nginx,frontend,quickwit,connector,assist-api} /tmp/charts/
rm -rf openreplay/charts/* rm -rf openreplay/charts/*
mv /tmp/charts/* 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 | kubectl apply -n app -f - helm template openreplay -n app openreplay -f vars.yaml -f /tmp/image_override.yaml --set ingress-nginx.enabled=false --set skipMigration=true --no-hooks | kubectl apply -n app -f -

View file

@ -89,7 +89,7 @@ jobs:
cat /tmp/image_override.yaml cat /tmp/image_override.yaml
# Deploy command # Deploy command
mkdir -p /tmp/charts mkdir -p /tmp/charts
mv openreplay/charts/{ingress-nginx,frontend,quickwit,connector} /tmp/charts/ mv openreplay/charts/{ingress-nginx,frontend,quickwit,connector,assist-api} /tmp/charts/
rm -rf openreplay/charts/* rm -rf openreplay/charts/*
mv /tmp/charts/* 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 | kubectl apply -n app -f - helm template openreplay -n app openreplay -f vars.yaml -f /tmp/image_override.yaml --set ingress-nginx.enabled=false --set skipMigration=true --no-hooks | kubectl apply -n app -f -
@ -138,7 +138,7 @@ jobs:
cat /tmp/image_override.yaml cat /tmp/image_override.yaml
# Deploy command # Deploy command
mkdir -p /tmp/charts mkdir -p /tmp/charts
mv openreplay/charts/{ingress-nginx,frontend,quickwit,connector} /tmp/charts/ mv openreplay/charts/{ingress-nginx,frontend,quickwit,connector,assist-api} /tmp/charts/
rm -rf openreplay/charts/* rm -rf openreplay/charts/*
mv /tmp/charts/* 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 | kubectl apply -n app -f - helm template openreplay -n app openreplay -f vars.yaml -f /tmp/image_override.yaml --set ingress-nginx.enabled=false --set skipMigration=true --no-hooks | kubectl apply -n app -f -

View file

@ -20,12 +20,20 @@ jobs:
DEPOT_PROJECT_ID: ${{ secrets.DEPOT_PROJECT_ID }} DEPOT_PROJECT_ID: ${{ secrets.DEPOT_PROJECT_ID }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v2 uses: actions/checkout@v4
with: with:
fetch-depth: 1 fetch-depth: 0
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 pull --rebase origin main git remote -v
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: |
@ -48,6 +56,8 @@ 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
@ -100,7 +110,7 @@ jobs:
else else
cd $MSAAS_REPO_FOLDER/openreplay/$service cd $MSAAS_REPO_FOLDER/openreplay/$service
fi 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 IMAGE_TAG=$version DOCKER_RUNTIME="depot" DOCKER_BUILD_ARGS="--push" ARCH=amd64 DOCKER_REPO=$DOCKER_REPO_OSS PUSH_IMAGE=0 bash $BUILD_SCRIPT_NAME >> /tmp/managed_${service}.txt 2>&1 || { echo "Build failed for $service"; cat /tmp/managed_${service}.txt; exit 1; }
} }
# Checking for backend images # Checking for backend images
ls backend/cmd >> /tmp/backend.txt ls backend/cmd >> /tmp/backend.txt

View file

@ -119,7 +119,7 @@ jobs:
cat /tmp/image_override.yaml cat /tmp/image_override.yaml
# Deploy command # Deploy command
mkdir -p /tmp/charts mkdir -p /tmp/charts
mv openreplay/charts/{ingress-nginx,sourcemapreader,quickwit,connector} /tmp/charts/ mv openreplay/charts/{ingress-nginx,sourcemapreader,quickwit,connector,assist-api} /tmp/charts/
rm -rf openreplay/charts/* rm -rf openreplay/charts/*
mv /tmp/charts/* 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 | kubectl apply -n app -f - helm template openreplay -n app openreplay -f vars.yaml -f /tmp/image_override.yaml --set ingress-nginx.enabled=false --set skipMigration=true --no-hooks | kubectl apply -n app -f -

View file

@ -118,7 +118,7 @@ jobs:
cat /tmp/image_override.yaml cat /tmp/image_override.yaml
# Deploy command # Deploy command
mkdir -p /tmp/charts mkdir -p /tmp/charts
mv openreplay/charts/{ingress-nginx,sourcemapreader,quickwit,connector} /tmp/charts/ mv openreplay/charts/{ingress-nginx,sourcemapreader,quickwit,connector,assist-api} /tmp/charts/
rm -rf openreplay/charts/* rm -rf openreplay/charts/*
mv /tmp/charts/* 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 | kubectl apply -n app -f - helm template openreplay -n app openreplay -f vars.yaml -f /tmp/image_override.yaml --set ingress-nginx.enabled=false --set skipMigration=true --no-hooks | kubectl apply -n app -f -

View file

@ -148,9 +148,7 @@ jobs:
set -x set -x
echo > /tmp/image_override.yaml echo > /tmp/image_override.yaml
mkdir /tmp/helmcharts mkdir /tmp/helmcharts
mv openreplay/charts/ingress-nginx /tmp/helmcharts/ mv openreplay/charts/{ingress-nginx,quickwit,connector,assist-api} /tmp/helmcharts/
mv openreplay/charts/quickwit /tmp/helmcharts/
mv openreplay/charts/connector /tmp/helmcharts/
## Update images ## Update images
for image in $(cat /tmp/images_to_build.txt); for image in $(cat /tmp/images_to_build.txt);
do do

View file

@ -141,9 +141,7 @@ jobs:
set -x set -x
echo > /tmp/image_override.yaml echo > /tmp/image_override.yaml
mkdir /tmp/helmcharts mkdir /tmp/helmcharts
mv openreplay/charts/ingress-nginx /tmp/helmcharts/ mv openreplay/charts/{ingress-nginx,quickwit,connector,assist-api} /tmp/helmcharts/
mv openreplay/charts/quickwit /tmp/helmcharts/
mv openreplay/charts/connector /tmp/helmcharts/
## Update images ## Update images
for image in $(cat /tmp/images_to_build.txt); for image in $(cat /tmp/images_to_build.txt);
do do

View file

@ -1,18 +1,13 @@
from chalicelib.utils import ch_client from chalicelib.utils import ch_client
from .events_pg import * from .events_pg import *
from chalicelib.utils.exp_ch_helper import explode_dproperties, add_timestamp
def __explode_properties(rows):
for i in range(len(rows)):
rows[i] = {**rows[i], **rows[i]["$properties"]}
rows[i].pop("$properties")
return rows
def get_customs_by_session_id(session_id, project_id): def get_customs_by_session_id(session_id, project_id):
with ch_client.ClickHouseClient() as cur: with ch_client.ClickHouseClient() as cur:
rows = cur.execute(""" \ rows = cur.execute(""" \
SELECT `$properties`, SELECT `$properties`,
properties,
created_at, created_at,
'CUSTOM' AS type 'CUSTOM' AS type
FROM product_analytics.events FROM product_analytics.events
@ -21,8 +16,10 @@ def get_customs_by_session_id(session_id, project_id):
AND `$event_name`!='INCIDENT' AND `$event_name`!='INCIDENT'
ORDER BY created_at;""", ORDER BY created_at;""",
{"project_id": project_id, "session_id": session_id}) {"project_id": project_id, "session_id": session_id})
rows = __explode_properties(rows) rows = helper.list_to_camel_case(rows, ignore_keys=["properties"])
return helper.list_to_camel_case(rows) rows = explode_dproperties(rows)
rows = add_timestamp(rows)
return rows
def __merge_cells(rows, start, count, replacement): def __merge_cells(rows, start, count, replacement):
@ -69,12 +66,13 @@ def get_by_session_id(session_id, project_id, group_clickrage=False, event_type:
parameters={"project_id": project_id, "session_id": session_id, parameters={"project_id": project_id, "session_id": session_id,
"select_events": select_events}) "select_events": select_events})
rows = cur.execute(query) rows = cur.execute(query)
rows = __explode_properties(rows) rows = explode_dproperties(rows)
if group_clickrage and 'CLICK' in select_events: if group_clickrage and 'CLICK' in select_events:
rows = __get_grouped_clickrage(rows=rows, session_id=session_id, project_id=project_id) rows = __get_grouped_clickrage(rows=rows, session_id=session_id, project_id=project_id)
rows = helper.list_to_camel_case(rows) rows = helper.list_to_camel_case(rows)
rows = sorted(rows, key=lambda k: k["createdAt"]) rows = sorted(rows, key=lambda k: k["createdAt"])
rows = add_timestamp(rows)
return rows return rows
@ -91,7 +89,7 @@ def get_incidents_by_session_id(session_id, project_id):
ORDER BY created_at;""", ORDER BY created_at;""",
parameters={"project_id": project_id, "session_id": session_id}) parameters={"project_id": project_id, "session_id": session_id})
rows = cur.execute(query) rows = cur.execute(query)
rows = __explode_properties(rows) rows = explode_dproperties(rows)
rows = helper.list_to_camel_case(rows) rows = helper.list_to_camel_case(rows)
rows = sorted(rows, key=lambda k: k["createdAt"]) rows = sorted(rows, key=lambda k: k["createdAt"])
return rows return rows

View file

@ -131,7 +131,7 @@ def supported_types():
query=autocomplete.__generic_query( query=autocomplete.__generic_query(
typename=schemas.EventType.GRAPHQL)), typename=schemas.EventType.GRAPHQL)),
schemas.EventType.STATE_ACTION: SupportedFilter( schemas.EventType.STATE_ACTION: SupportedFilter(
get=autocomplete.__generic_autocomplete(schemas.EventType.STATEACTION), get=autocomplete.__generic_autocomplete(schemas.EventType.STATE_ACTION),
query=autocomplete.__generic_query( query=autocomplete.__generic_query(
typename=schemas.EventType.STATE_ACTION)), typename=schemas.EventType.STATE_ACTION)),
schemas.EventType.TAG: SupportedFilter(get=_search_tags, query=None), schemas.EventType.TAG: SupportedFilter(get=_search_tags, query=None),

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,10 +95,9 @@ 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) VALUES (%(username)s, %(token)s, %(user_id)s, %(url)s) RETURNING username, token, url;""",
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})
) )
@ -112,9 +111,10 @@ 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 FROM public.jira_cloud DELETE
WHERE user_id=%(user_id)s;""", FROM public.jira_cloud
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

@ -1,6 +1,6 @@
from chalicelib.utils import ch_client, helper from chalicelib.utils import ch_client, helper
import datetime import datetime
from .issues_pg import get_all_types from chalicelib.utils.exp_ch_helper import explode_dproperties, add_timestamp
def get(project_id, issue_id): def get(project_id, issue_id):
@ -21,7 +21,7 @@ def get(project_id, issue_id):
def get_by_session_id(session_id, project_id, issue_type=None): def get_by_session_id(session_id, project_id, issue_type=None):
with ch_client.ClickHouseClient() as cur: with ch_client.ClickHouseClient() as cur:
query = cur.format(query=f"""\ query = cur.format(query=f"""\
SELECT * SELECT created_at, `$properties`
FROM product_analytics.events FROM product_analytics.events
WHERE session_id = %(session_id)s WHERE session_id = %(session_id)s
AND project_id= %(project_id)s AND project_id= %(project_id)s
@ -29,8 +29,11 @@ def get_by_session_id(session_id, project_id, issue_type=None):
{"AND issue_type = %(type)s" if issue_type is not None else ""} {"AND issue_type = %(type)s" if issue_type is not None else ""}
ORDER BY created_at;""", ORDER BY created_at;""",
parameters={"session_id": session_id, "project_id": project_id, "type": issue_type}) parameters={"session_id": session_id, "project_id": project_id, "type": issue_type})
data = cur.execute(query) rows = cur.execute(query)
return helper.list_to_camel_case(data) rows = explode_dproperties(rows)
rows = helper.list_to_camel_case(rows)
rows = add_timestamp(rows)
return rows
# To reduce the number of issues in the replay; # To reduce the number of issues in the replay;

View file

@ -260,6 +260,5 @@ def get_for_filters(project_id):
"name": k, "name": k,
"displayName": metas[k], "displayName": metas[k],
"possibleTypes": ["String"], "possibleTypes": ["String"],
"autoCaptured": False, "autoCaptured": False})
"icon": None})
return {"total": len(results), "list": results} return {"total": len(results), "list": results}

View file

@ -172,7 +172,8 @@ def get_sessions_by_card_id(project: schemas.ProjectContext, user_id, metric_id,
results = [] results = []
for s in data.series: for s in data.series:
results.append({"seriesId": s.series_id, "seriesName": s.name, results.append({"seriesId": s.series_id, "seriesName": s.name,
**sessions_search.search_sessions(data=s.filter, project=project, user_id=user_id)}) **sessions_search.search_sessions(data=s.filter, project=project, user_id=user_id,
metric_of=data.metric_of)})
return results return results
@ -187,7 +188,8 @@ def get_sessions(project: schemas.ProjectContext, user_id, data: schemas.CardSes
s.filter = schemas.SessionsSearchPayloadSchema(**s.filter.model_dump(by_alias=True)) s.filter = schemas.SessionsSearchPayloadSchema(**s.filter.model_dump(by_alias=True))
results.append({"seriesId": None, "seriesName": s.name, results.append({"seriesId": None, "seriesName": s.name,
**sessions_search.search_sessions(data=s.filter, project=project, user_id=user_id)}) **sessions_search.search_sessions(data=s.filter, project=project, user_id=user_id,
metric_of=data.metric_of)})
return results return results

View file

@ -28,32 +28,32 @@ def search_events(project_id: int, q: Optional[str] = None):
def search_properties(project_id: int, property_name: Optional[str] = None, event_name: Optional[str] = None, def search_properties(project_id: int, property_name: Optional[str] = None, event_name: Optional[str] = None,
q: Optional[str] = None): q: Optional[str] = None):
with ClickHouseClient() as ch_client: with ClickHouseClient() as ch_client:
select = "value" select = "value, data_count"
grouping = ""
full_args = {"project_id": project_id, "limit": 20, full_args = {"project_id": project_id, "limit": 20,
"event_name": event_name, "property_name": property_name, "q": q, "event_name": event_name, "property_name": property_name,
"property_name_l": helper.string_to_sql_like(property_name),
"q_l": helper.string_to_sql_like(q)} "q_l": helper.string_to_sql_like(q)}
constraints = ["project_id = %(project_id)s", constraints = ["project_id = %(project_id)s",
"_timestamp >= now()-INTERVAL 1 MONTH"] "_timestamp >= now()-INTERVAL 1 MONTH",
"property_name = %(property_name)s"]
if event_name: if event_name:
constraints += ["event_name = %(event_name)s"] constraints += ["event_name = %(event_name)s"]
else:
if property_name and q: select = "value, sum(aepg.data_count) AS data_count"
constraints += ["property_name = %(property_name)s"] grouping = "GROUP BY 1"
elif property_name:
select = "DISTINCT ON(property_name) property_name AS value"
constraints += ["property_name ILIKE %(property_name_l)s"]
if q: if q:
constraints += ["value ILIKE %(q_l)s"] constraints += ["value ILIKE %(q_l)s"]
query = ch_client.format( query = ch_client.format(
f"""SELECT {select},data_count f"""SELECT {select}
FROM product_analytics.autocomplete_event_properties_grouped FROM product_analytics.autocomplete_event_properties_grouped AS aepg
WHERE {" AND ".join(constraints)} WHERE {" AND ".join(constraints)}
ORDER BY data_count DESC {grouping}
ORDER BY data_count DESC
LIMIT %(limit)s;""", LIMIT %(limit)s;""",
parameters=full_args) parameters=full_args)
rows = ch_client.execute(query) rows = ch_client.execute(query)
return {"values": helper.list_to_camel_case(rows), "_src": 2} return {"events": helper.list_to_camel_case(rows), "_src": 2}

View file

@ -7,14 +7,13 @@ from chalicelib.utils.ch_client import ClickHouseClient
from chalicelib.utils.exp_ch_helper import get_sub_condition, get_col_cast from chalicelib.utils.exp_ch_helper import get_sub_condition, get_col_cast
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
PREDEFINED_EVENTS = { PREDEFINED_EVENTS = [
"CLICK": "String", "CLICK",
"INPUT": "String", "INPUT",
"LOCATION": "String", "LOCATION",
"ERROR": "String", "ERROR",
"PERFORMANCE": "String", "REQUEST"
"REQUEST": "String" ]
}
def get_events(project_id: int, page: schemas.PaginatedSchema): def get_events(project_id: int, page: schemas.PaginatedSchema):

View file

@ -58,6 +58,14 @@ PREDEFINED_PROPERTIES = {
"message_id": "UInt64" "message_id": "UInt64"
} }
EVENT_DEFAULT_PROPERTIES = {
"CLICK": "label",
"INPUT": "label",
"LOCATION": "url_path",
"ERROR": "name",
"REQUEST": "url_path"
}
def get_all_properties(project_id: int, page: schemas.PaginatedSchema): def get_all_properties(project_id: int, page: schemas.PaginatedSchema):
with ClickHouseClient() as ch_client: with ClickHouseClient() as ch_client:
@ -104,7 +112,7 @@ def get_all_properties(project_id: int, page: schemas.PaginatedSchema):
return {"total": total, "list": properties} return {"total": total, "list": properties}
def get_event_properties(project_id: int, event_name): def get_event_properties(project_id: int, event_name: str, auto_captured: bool):
with ClickHouseClient() as ch_client: with ClickHouseClient() as ch_client:
r = ch_client.format( r = ch_client.format(
"""SELECT all_properties.property_name AS name, """SELECT all_properties.property_name AS name,
@ -115,9 +123,10 @@ def get_event_properties(project_id: int, event_name):
WHERE event_properties.project_id = %(project_id)s WHERE event_properties.project_id = %(project_id)s
AND all_properties.project_id = %(project_id)s AND all_properties.project_id = %(project_id)s
AND event_properties.event_name = %(event_name)s AND event_properties.event_name = %(event_name)s
AND event_properties.auto_captured = %(auto_captured)s
GROUP BY ALL GROUP BY ALL
ORDER BY 1;""", ORDER BY 1;""",
parameters={"project_id": project_id, "event_name": event_name}) parameters={"project_id": project_id, "event_name": event_name, "auto_captured": auto_captured})
properties = ch_client.execute(r) properties = ch_client.execute(r)
properties = helper.list_to_camel_case(properties) properties = helper.list_to_camel_case(properties)
for i, p in enumerate(properties): for i, p in enumerate(properties):
@ -127,6 +136,8 @@ def get_event_properties(project_id: int, event_name):
p["dataType"] = exp_ch_helper.simplify_clickhouse_type(PREDEFINED_PROPERTIES[p["name"]]) p["dataType"] = exp_ch_helper.simplify_clickhouse_type(PREDEFINED_PROPERTIES[p["name"]])
p["_foundInPredefinedList"] = True p["_foundInPredefinedList"] = True
p["possibleTypes"] = list(set(exp_ch_helper.simplify_clickhouse_types(p["possibleTypes"]))) p["possibleTypes"] = list(set(exp_ch_helper.simplify_clickhouse_types(p["possibleTypes"])))
p["defaultProperty"] = auto_captured and event_name in EVENT_DEFAULT_PROPERTIES \
and p["name"] == EVENT_DEFAULT_PROPERTIES[event_name]
return properties return properties

View file

@ -150,7 +150,7 @@ def search2_table(data: schemas.SessionsSearchPayloadSchema, project_id: int, de
for e in data.events: for e in data.events:
if e.type == schemas.EventType.LOCATION: if e.type == schemas.EventType.LOCATION:
if e.operator not in extra_conditions: if e.operator not in extra_conditions:
extra_conditions[e.operator] = schemas.SessionSearchEventSchema.model_validate({ extra_conditions[e.operator] = schemas.SessionSearchEventSchema(**{
"type": e.type, "type": e.type,
"isEvent": True, "isEvent": True,
"value": [], "value": [],
@ -175,7 +175,7 @@ def search2_table(data: schemas.SessionsSearchPayloadSchema, project_id: int, de
for e in data.events: for e in data.events:
if e.type == schemas.EventType.REQUEST_DETAILS: if e.type == schemas.EventType.REQUEST_DETAILS:
if e.operator not in extra_conditions: if e.operator not in extra_conditions:
extra_conditions[e.operator] = schemas.SessionSearchEventSchema.model_validate({ extra_conditions[e.operator] = schemas.SessionSearchEventSchema(**{
"type": e.type, "type": e.type,
"isEvent": True, "isEvent": True,
"value": [], "value": [],
@ -240,8 +240,10 @@ def search2_table(data: schemas.SessionsSearchPayloadSchema, project_id: int, de
main_query = f"""SELECT COUNT(DISTINCT {main_col}) OVER () AS main_count, main_query = f"""SELECT COUNT(DISTINCT {main_col}) OVER () AS main_count,
{main_col} AS name, {main_col} AS name,
count(DISTINCT session_id) AS total, count(DISTINCT session_id) AS total,
COALESCE(SUM(count(DISTINCT session_id)) OVER (), 0) AS total_count any(total_count) as total_count
FROM (SELECT s.session_id AS session_id {extra_col} FROM (SELECT s.session_id AS session_id,
count(DISTINCT s.session_id) OVER () AS total_count
{extra_col}
{query_part}) AS filtred_sessions {query_part}) AS filtred_sessions
{extra_where} {extra_where}
GROUP BY {main_col} GROUP BY {main_col}
@ -251,8 +253,10 @@ def search2_table(data: schemas.SessionsSearchPayloadSchema, project_id: int, de
main_query = f"""SELECT COUNT(DISTINCT {main_col}) OVER () AS main_count, main_query = f"""SELECT COUNT(DISTINCT {main_col}) OVER () AS main_count,
{main_col} AS name, {main_col} AS name,
count(DISTINCT user_id) AS total, count(DISTINCT user_id) AS total,
COALESCE(SUM(count(DISTINCT user_id)) OVER (), 0) AS total_count any(total_count) AS total_count
FROM (SELECT s.user_id AS user_id {extra_col} FROM (SELECT s.user_id AS user_id,
count(DISTINCT s.user_id) OVER () AS total_count
{extra_col}
{query_part} {query_part}
WHERE isNotNull(user_id) WHERE isNotNull(user_id)
AND notEmpty(user_id)) AS filtred_sessions AND notEmpty(user_id)) AS filtred_sessions

View file

@ -64,8 +64,7 @@ def __parse_metadata(metadata_map):
# This function executes the query and return result # This function executes the query and return result
def search_sessions(data: schemas.SessionsSearchPayloadSchema, project: schemas.ProjectContext, def search_sessions(data: schemas.SessionsSearchPayloadSchema, project: schemas.ProjectContext,
user_id, errors_only=False, error_status=schemas.ErrorStatus.ALL, user_id, errors_only=False, error_status=schemas.ErrorStatus.ALL,
count_only=False, issue=None, ids_only=False): count_only=False, issue=None, ids_only=False, metric_of: schemas.MetricOfTable = None):
platform = project.platform
if data.bookmarked: if data.bookmarked:
data.startTimestamp, data.endTimestamp = sessions_favorite.get_start_end_timestamp(project.project_id, user_id) data.startTimestamp, data.endTimestamp = sessions_favorite.get_start_end_timestamp(project.project_id, user_id)
if data.startTimestamp is None: if data.startTimestamp is None:
@ -75,18 +74,78 @@ def search_sessions(data: schemas.SessionsSearchPayloadSchema, project: schemas.
'sessions': [], 'sessions': [],
'_src': 2 '_src': 2
} }
# ---------------------- extra filter in order to only select sessions that has been used in the card-table
extra_event = None
# extra_deduplication = []
extra_conditions = None
if metric_of == schemas.MetricOfTable.VISITED_URL:
extra_event = f"""SELECT DISTINCT ev.session_id,
JSONExtractString(toString(ev.`$properties`), 'url_path') AS url_path
FROM {exp_ch_helper.get_main_events_table(data.startTimestamp)} AS ev
WHERE ev.created_at >= toDateTime(%(startDate)s / 1000)
AND ev.created_at <= toDateTime(%(endDate)s / 1000)
AND ev.project_id = %(project_id)s
AND ev.`$event_name` = 'LOCATION'"""
# extra_deduplication.append("url_path")
extra_conditions = {}
for e in data.events:
if e.type == schemas.EventType.LOCATION:
if e.operator not in extra_conditions:
extra_conditions[e.operator] = schemas.SessionSearchEventSchema(**{
"type": e.type,
"isEvent": True,
"value": [],
"operator": e.operator,
"filters": e.filters
})
for v in e.value:
if v not in extra_conditions[e.operator].value:
extra_conditions[e.operator].value.append(v)
extra_conditions = list(extra_conditions.values())
elif metric_of == schemas.MetricOfTable.FETCH:
extra_event = f"""SELECT DISTINCT ev.session_id
FROM {exp_ch_helper.get_main_events_table(data.startTimestamp)} AS ev
WHERE ev.created_at >= toDateTime(%(startDate)s / 1000)
AND ev.created_at <= toDateTime(%(endDate)s / 1000)
AND ev.project_id = %(project_id)s
AND ev.`$event_name` = 'REQUEST'"""
# extra_deduplication.append("url_path")
extra_conditions = {}
for e in data.events:
if e.type == schemas.EventType.REQUEST_DETAILS:
if e.operator not in extra_conditions:
extra_conditions[e.operator] = schemas.SessionSearchEventSchema(**{
"type": e.type,
"isEvent": True,
"value": [],
"operator": e.operator,
"filters": e.filters
})
for v in e.value:
if v not in extra_conditions[e.operator].value:
extra_conditions[e.operator].value.append(v)
extra_conditions = list(extra_conditions.values())
# elif metric_of == schemas.MetricOfTable.ISSUES and len(metric_value) > 0:
# data.filters.append(schemas.SessionSearchFilterSchema(value=metric_value, type=schemas.FilterType.ISSUE,
# operator=schemas.SearchEventOperator.IS))
# ----------------------
if project.platform == "web": if project.platform == "web":
full_args, query_part = sessions.search_query_parts_ch(data=data, error_status=error_status, full_args, query_part = sessions.search_query_parts_ch(data=data, error_status=error_status,
errors_only=errors_only, errors_only=errors_only,
favorite_only=data.bookmarked, issue=issue, favorite_only=data.bookmarked, issue=issue,
project_id=project.project_id, project_id=project.project_id,
user_id=user_id, platform=platform) user_id=user_id, platform=project.platform,
extra_event=extra_event,
# extra_deduplication=extra_deduplication,
extra_conditions=extra_conditions)
else: else:
full_args, query_part = sessions_legacy_mobil.search_query_parts_ch(data=data, error_status=error_status, full_args, query_part = sessions_legacy_mobil.search_query_parts_ch(data=data, error_status=error_status,
errors_only=errors_only, errors_only=errors_only,
favorite_only=data.bookmarked, issue=issue, favorite_only=data.bookmarked, issue=issue,
project_id=project.project_id, project_id=project.project_id,
user_id=user_id, platform=platform) user_id=user_id, platform=project.platform)
if data.sort == "startTs": if data.sort == "startTs":
data.sort = "datetime" data.sort = "datetime"
if data.limit is not None and data.page is not None: if data.limit is not None and data.page is not None:

View file

@ -40,7 +40,7 @@ COALESCE((SELECT TRUE
# This function executes the query and return result # This function executes the query and return result
def search_sessions(data: schemas.SessionsSearchPayloadSchema, project: schemas.ProjectContext, def search_sessions(data: schemas.SessionsSearchPayloadSchema, project: schemas.ProjectContext,
user_id, errors_only=False, error_status=schemas.ErrorStatus.ALL, user_id, errors_only=False, error_status=schemas.ErrorStatus.ALL,
count_only=False, issue=None, ids_only=False): count_only=False, issue=None, ids_only=False, metric_of: schemas.MetricOfTable = None):
platform = project.platform platform = project.platform
if data.bookmarked: if data.bookmarked:
data.startTimestamp, data.endTimestamp = sessions_favorite.get_start_end_timestamp(project.project_id, user_id) data.startTimestamp, data.endTimestamp = sessions_favorite.get_start_end_timestamp(project.project_id, user_id)

View file

@ -1,13 +1,14 @@
import logging import logging
import math
import re import re
import struct
from decimal import Decimal
from typing import Union, Any from typing import Union, Any
import schemas import schemas
from chalicelib.utils import sql_helper as sh from chalicelib.utils import sql_helper as sh
from chalicelib.utils.TimeUTC import TimeUTC
from schemas import SearchEventOperator from schemas import SearchEventOperator
import math
import struct
from decimal import Decimal
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -233,3 +234,16 @@ def best_clickhouse_type(value):
return "Float64" return "Float64"
raise TypeError(f"Unsupported type: {type(value).__name__}") raise TypeError(f"Unsupported type: {type(value).__name__}")
def explode_dproperties(rows):
for i in range(len(rows)):
rows[i] = {**rows[i], **rows[i]["$properties"]}
rows[i].pop("$properties")
return rows
def add_timestamp(rows):
for row in rows:
row["timestamp"] = TimeUTC.datetime_to_timestamp(row["createdAt"])
return rows

View file

@ -15,11 +15,11 @@ def random_string(length=36):
return "".join(random.choices(string.hexdigits, k=length)) return "".join(random.choices(string.hexdigits, k=length))
def list_to_camel_case(items: list[dict], flatten: bool = False) -> list[dict]: def list_to_camel_case(items: list[dict], flatten: bool = False, ignore_keys=[]) -> list[dict]:
for i in range(len(items)): for i in range(len(items)):
if flatten: if flatten:
items[i] = flatten_nested_dicts(items[i]) items[i] = flatten_nested_dicts(items[i])
items[i] = dict_to_camel_case(items[i]) items[i] = dict_to_camel_case(items[i], ignore_keys=[])
return items return items

View file

@ -4,8 +4,9 @@ from decouple import config
from fastapi import Depends, Body, BackgroundTasks from fastapi import Depends, Body, BackgroundTasks
import schemas import schemas
from chalicelib.core import events, projects, metadata, reset_password, log_tools, \ from chalicelib.core import projects, metadata, reset_password, log_tools, \
announcements, weekly_report, assist, mobile, tenants, boarding, notifications, webhook, users, saved_search, tags announcements, weekly_report, assist, mobile, tenants, boarding, notifications, webhook, users, saved_search, tags
from chalicelib.core.events import events
from chalicelib.core.issues import issues from chalicelib.core.issues import issues
from chalicelib.core.sourcemaps import sourcemaps from chalicelib.core.sourcemaps import sourcemaps
from chalicelib.core.metrics import custom_metrics from chalicelib.core.metrics import custom_metrics

View file

@ -220,9 +220,9 @@ def get_card_chart(projectId: int, metric_id: int, data: schemas.CardSessionsSch
@app.post("/{projectId}/dashboards/{dashboardId}/cards/{metric_id}/chart", tags=["card"]) @app.post("/{projectId}/dashboards/{dashboardId}/cards/{metric_id}/chart", tags=["card"])
@app.post("/{projectId}/dashboards/{dashboardId}/cards/{metric_id}", tags=["card"]) # @app.post("/{projectId}/dashboards/{dashboardId}/cards/{metric_id}", tags=["card"])
def get_card_chart_for_dashboard(projectId: int, dashboardId: int, metric_id: int, def get_card_chart_for_dashboard(projectId: int, dashboardId: int, metric_id: int,
data: schemas.CardSessionsSchema = Body(...), data: schemas.SavedCardSchema = Body(...),
context: schemas.CurrentContext = Depends(OR_context)): context: schemas.CurrentContext = Depends(OR_context)):
data = custom_metrics.make_chart_from_card( data = custom_metrics.make_chart_from_card(
project=context.project, user_id=context.user_id, metric_id=metric_id, data=data, for_dashboard=True project=context.project, user_id=context.user_id, metric_id=metric_id, data=data, for_dashboard=True

View file

@ -15,6 +15,9 @@ public_app, app, app_apikey = get_routers()
@app.get('/{projectId}/filters', tags=["product_analytics"]) @app.get('/{projectId}/filters', tags=["product_analytics"])
def get_all_filters(projectId: int, filter_query: Annotated[schemas.PaginatedSchema, Query()], def get_all_filters(projectId: int, filter_query: Annotated[schemas.PaginatedSchema, Query()],
context: schemas.CurrentContext = Depends(OR_context)): context: schemas.CurrentContext = Depends(OR_context)):
# TODO: fix total attribute to return the total count instead of the total number of pages
# TODO: no pagination, return everything
# TODO: remove icon
return { return {
"data": { "data": {
"events": events.get_events(project_id=projectId, page=filter_query), "events": events.get_events(project_id=projectId, page=filter_query),
@ -31,11 +34,12 @@ def get_all_events(projectId: int, filter_query: Annotated[schemas.PaginatedSche
@app.get('/{projectId}/properties/search', tags=["product_analytics"]) @app.get('/{projectId}/properties/search', tags=["product_analytics"])
def get_event_properties(projectId: int, event_name: str = None, def get_event_properties(projectId: int, en: str = Query(default=None, description="event name"),
ac: bool = Query(description="auto captured"),
context: schemas.CurrentContext = Depends(OR_context)): context: schemas.CurrentContext = Depends(OR_context)):
if not event_name or len(event_name) == 0: if not en or len(en) == 0:
return {"data": []} return {"data": []}
return {"data": properties.get_event_properties(project_id=projectId, event_name=event_name)} return {"data": properties.get_event_properties(project_id=projectId, event_name=en, auto_captured=ac)}
@app.post('/{projectId}/events/search', tags=["product_analytics"]) @app.post('/{projectId}/events/search', tags=["product_analytics"])
@ -63,15 +67,12 @@ def autocomplete_events(projectId: int, q: Optional[str] = None,
@app.get('/{projectId}/properties/autocomplete', tags=["autocomplete"]) @app.get('/{projectId}/properties/autocomplete', tags=["autocomplete"])
def autocomplete_properties(projectId: int, propertyName: Optional[str] = None, eventName: Optional[str] = None, def autocomplete_properties(projectId: int, propertyName: str, eventName: Optional[str] = None,
q: Optional[str] = None, context: schemas.CurrentContext = Depends(OR_context)): q: Optional[str] = None, context: schemas.CurrentContext = Depends(OR_context)):
if not propertyName and not eventName and not q: # Specify propertyName to get top values of that property
return {"error": ["Specify eventName to get top properties", # Specify eventName&propertyName to get top values of that property for the selected event
"Specify propertyName to get top values of that property",
"Specify eventName&propertyName to get top values of that property for the selected event"]}
return {"data": autocomplete.search_properties(project_id=projectId, return {"data": autocomplete.search_properties(project_id=projectId,
event_name=None if not eventName \ event_name=None if not eventName \
or len(eventName) == 0 else eventName, or len(eventName) == 0 else eventName,
property_name=None if not propertyName \ property_name=propertyName,
or len(propertyName) == 0 else propertyName,
q=None if not q or len(q) == 0 else q)} q=None if not q or len(q) == 0 else q)}

View file

@ -21,7 +21,9 @@ def schema_extra(schema: dict, _):
class BaseModel(_BaseModel): class BaseModel(_BaseModel):
model_config = ConfigDict(alias_generator=attribute_to_camel_case, model_config = ConfigDict(alias_generator=attribute_to_camel_case,
use_enum_values=True, use_enum_values=True,
json_schema_extra=schema_extra) json_schema_extra=schema_extra,
# extra='forbid'
)
class Enum(_Enum): class Enum(_Enum):

View file

@ -1043,11 +1043,16 @@ class MetricOfPathAnalysis(str, Enum):
session_count = MetricOfTimeseries.SESSION_COUNT.value session_count = MetricOfTimeseries.SESSION_COUNT.value
# class CardSessionsSchema(SessionsSearchPayloadSchema):
class CardSessionsSchema(_TimedSchema, _PaginatedSchema): class CardSessionsSchema(_TimedSchema, _PaginatedSchema):
startTimestamp: int = Field(default=TimeUTC.now(-7)) startTimestamp: int = Field(default=TimeUTC.now(-7))
endTimestamp: int = Field(default=TimeUTC.now()) endTimestamp: int = Field(default=TimeUTC.now())
density: int = Field(default=7, ge=1, le=200) density: int = Field(default=7, ge=1, le=200)
# we need metric_type&metric_of in the payload of sessions search
# because the API will retrun all sessions if the card is not identified
# example: table of requests contains only sessions that have a request,
# but drill-down doesn't take that into consideration
metric_type: MetricType = Field(...)
metric_of: Any
series: List[CardSeriesSchema] = Field(default_factory=list) series: List[CardSeriesSchema] = Field(default_factory=list)
# events: List[SessionSearchEventSchema2] = Field(default_factory=list, doc_hidden=True) # events: List[SessionSearchEventSchema2] = Field(default_factory=list, doc_hidden=True)
@ -1112,6 +1117,11 @@ class CardSessionsSchema(_TimedSchema, _PaginatedSchema):
return self return self
class SavedCardSchema(CardSessionsSchema):
metric_type: Optional[MetricType] = Field(default=None)
metric_of: Optional[Any] = Field(default=None)
class CardConfigSchema(BaseModel): class CardConfigSchema(BaseModel):
col: Optional[int] = Field(default=None) col: Optional[int] = Field(default=None)
row: Optional[int] = Field(default=2) row: Optional[int] = Field(default=2)
@ -1125,8 +1135,6 @@ class __CardSchema(CardSessionsSchema):
thumbnail: Optional[str] = Field(default=None) thumbnail: Optional[str] = Field(default=None)
metric_format: Optional[MetricFormatType] = Field(default=None) metric_format: Optional[MetricFormatType] = Field(default=None)
view_type: Any view_type: Any
metric_type: MetricType = Field(...)
metric_of: Any
metric_value: List[IssueType] = Field(default_factory=list) metric_value: List[IssueType] = Field(default_factory=list)
# This is used to save the selected session for heatmaps # This is used to save the selected session for heatmaps
session_id: Optional[int] = Field(default=None) session_id: Optional[int] = Field(default=None)

View file

@ -70,7 +70,7 @@ func main() {
messages.MsgMouseClickDeprecated, messages.MsgSetPageLocation, messages.MsgSetPageLocationDeprecated, messages.MsgMouseClickDeprecated, messages.MsgSetPageLocation, messages.MsgSetPageLocationDeprecated,
messages.MsgPageLoadTiming, messages.MsgPageRenderTiming, messages.MsgPageLoadTiming, messages.MsgPageRenderTiming,
messages.MsgPageEvent, messages.MsgPageEventDeprecated, messages.MsgMouseThrashing, messages.MsgInputChange, messages.MsgPageEvent, messages.MsgPageEventDeprecated, messages.MsgMouseThrashing, messages.MsgInputChange,
messages.MsgUnbindNodes, messages.MsgCanvasNode, messages.MsgTagTrigger, messages.MsgUnbindNodes, messages.MsgCanvasNode, messages.MsgTagTrigger, messages.MsgIncident,
// Mobile messages // Mobile messages
messages.MsgMobileSessionStart, messages.MsgMobileSessionEnd, messages.MsgMobileUserID, messages.MsgMobileUserAnonymousID, messages.MsgMobileSessionStart, messages.MsgMobileSessionEnd, messages.MsgMobileUserID, messages.MsgMobileUserAnonymousID,
messages.MsgMobileMetadata, messages.MsgMobileEvent, messages.MsgMobileNetworkCall, messages.MsgMobileMetadata, messages.MsgMobileEvent, messages.MsgMobileNetworkCall,

View file

@ -140,6 +140,11 @@ func (s *saverImpl) handleWebMessage(sessCtx context.Context, session *sessions.
return err return err
} }
return s.ch.InsertWebPerformanceTrackAggr(session, m) return s.ch.InsertWebPerformanceTrackAggr(session, m)
case *messages.Incident:
if err := s.pg.InsertIncident(session, m); err != nil {
return err
}
return s.ch.InsertIncident(session, m)
} }
return nil return nil
} }

View file

@ -39,6 +39,7 @@ type Connector interface {
InsertIssue(session *sessions.Session, msg *messages.IssueEvent) error InsertIssue(session *sessions.Session, msg *messages.IssueEvent) error
InsertWebInputDuration(session *sessions.Session, msg *messages.InputChange) error InsertWebInputDuration(session *sessions.Session, msg *messages.InputChange) error
InsertMouseThrashing(session *sessions.Session, msg *messages.MouseThrashing) error InsertMouseThrashing(session *sessions.Session, msg *messages.MouseThrashing) error
InsertIncident(session *sessions.Session, msg *messages.Incident) error
InsertMobileSession(session *sessions.Session) error InsertMobileSession(session *sessions.Session) error
InsertMobileCustom(session *sessions.Session, msg *messages.MobileEvent) error InsertMobileCustom(session *sessions.Session, msg *messages.MobileEvent) error
InsertMobileClick(session *sessions.Session, msg *messages.MobileClickEvent) error InsertMobileClick(session *sessions.Session, msg *messages.MobileClickEvent) error
@ -106,15 +107,15 @@ func (c *connectorImpl) newBatch(name, query string) error {
} }
var batches = map[string]string{ var batches = map[string]string{
"sessions": "INSERT INTO experimental.sessions (session_id, project_id, user_id, user_uuid, user_os, user_os_version, user_device, user_device_type, user_country, user_state, user_city, datetime, duration, pages_count, events_count, errors_count, issue_score, referrer, issue_types, tracker_version, user_browser, user_browser_version, metadata_1, metadata_2, metadata_3, metadata_4, metadata_5, metadata_6, metadata_7, metadata_8, metadata_9, metadata_10, platform, timezone, utm_source, utm_medium, utm_campaign) VALUES (?, ?, SUBSTR(?, 1, 8000), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, SUBSTR(?, 1, 8000), ?, ?, ?, ?, SUBSTR(?, 1, 8000), SUBSTR(?, 1, 8000), SUBSTR(?, 1, 8000), SUBSTR(?, 1, 8000), SUBSTR(?, 1, 8000), SUBSTR(?, 1, 8000), SUBSTR(?, 1, 8000), SUBSTR(?, 1, 8000), SUBSTR(?, 1, 8000), SUBSTR(?, 1, 8000), ?, ?, ?, ?, ?)", "sessions": "INSERT INTO experimental.sessions (session_id, project_id, user_id, user_uuid, user_os, user_os_version, user_device, user_device_type, user_country, user_state, user_city, datetime, duration, pages_count, events_count, errors_count, issue_score, referrer, issue_types, tracker_version, user_browser, user_browser_version, metadata_1, metadata_2, metadata_3, metadata_4, metadata_5, metadata_6, metadata_7, metadata_8, metadata_9, metadata_10, platform, timezone, utm_source, utm_medium, utm_campaign, screen_width, screen_height) VALUES (?, ?, SUBSTR(?, 1, 8000), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, SUBSTR(?, 1, 8000), ?, ?, ?, ?, SUBSTR(?, 1, 8000), SUBSTR(?, 1, 8000), SUBSTR(?, 1, 8000), SUBSTR(?, 1, 8000), SUBSTR(?, 1, 8000), SUBSTR(?, 1, 8000), SUBSTR(?, 1, 8000), SUBSTR(?, 1, 8000), SUBSTR(?, 1, 8000), SUBSTR(?, 1, 8000), ?, ?, ?, ?, ?, ?, ?)",
"autocompletes": "INSERT INTO experimental.autocomplete (project_id, type, value) VALUES (?, ?, SUBSTR(?, 1, 8000))", "autocompletes": "INSERT INTO experimental.autocomplete (project_id, type, value) VALUES (?, ?, SUBSTR(?, 1, 8000))",
"pages": `INSERT INTO product_analytics.events (session_id, project_id, event_id, "$event_name", created_at, "$time", distinct_id, "$auto_captured", "$device", "$os_version", "$os", "$browser", "$referrer", "$country", "$state", "$city", "$current_url", "$properties") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, "pages": `INSERT INTO product_analytics.events (session_id, project_id, event_id, "$event_name", created_at, "$time", distinct_id, "$auto_captured", "$device", "$os_version", "$os", "$browser", "$referrer", "$country", "$state", "$city", "$current_url", "$properties") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
"clicks": `INSERT INTO product_analytics.events (session_id, project_id, event_id, "$event_name", created_at, "$time", distinct_id, "$auto_captured", "$device", "$os_version", "$os", "$browser", "$referrer", "$country", "$state", "$city", "$current_url", "$properties") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, "clicks": `INSERT INTO product_analytics.events (session_id, project_id, event_id, "$event_name", created_at, "$time", distinct_id, "$auto_captured", "$device", "$os_version", "$os", "$browser", "$referrer", "$country", "$state", "$city", "$current_url", "$properties") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
"inputs": `INSERT INTO product_analytics.events (session_id, project_id, event_id, "$event_name", created_at, "$time", distinct_id, "$auto_captured", "$device", "$os_version", "$os", "$browser", "$referrer", "$country", "$state", "$city", "$current_url", "$duration_s", "$properties") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, "inputs": `INSERT INTO product_analytics.events (session_id, project_id, event_id, "$event_name", created_at, "$time", distinct_id, "$auto_captured", "$device", "$os_version", "$os", "$browser", "$referrer", "$country", "$state", "$city", "$current_url", "$duration_s", "$properties") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
"errors": `INSERT INTO product_analytics.events (session_id, project_id, event_id, "$event_name", created_at, "$time", distinct_id, "$auto_captured", "$device", "$os_version", "$os", "$browser", "$referrer", "$country", "$state", "$city", "$current_url", error_id, "$properties") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, "errors": `INSERT INTO product_analytics.events (session_id, project_id, event_id, "$event_name", created_at, "$time", distinct_id, "$auto_captured", "$device", "$os_version", "$os", "$browser", "$referrer", "$country", "$state", "$city", "$current_url", error_id, "$properties") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
"performance": `INSERT INTO product_analytics.events (session_id, project_id, event_id, "$event_name", created_at, "$time", distinct_id, "$auto_captured", "$device", "$os_version", "$os", "$browser", "$referrer", "$country", "$state", "$city", "$current_url", "$properties") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, "performance": `INSERT INTO product_analytics.events (session_id, project_id, event_id, "$event_name", created_at, "$time", distinct_id, "$auto_captured", "$device", "$os_version", "$os", "$browser", "$referrer", "$country", "$state", "$city", "$current_url", "$properties") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
"requests": `INSERT INTO product_analytics.events (session_id, project_id, event_id, "$event_name", created_at, "$time", distinct_id, "$auto_captured", "$device", "$os_version", "$os", "$browser", "$referrer", "$country", "$state", "$city", "$current_url", "$duration_s", "$properties") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, "requests": `INSERT INTO product_analytics.events (session_id, project_id, event_id, "$event_name", created_at, "$time", distinct_id, "$auto_captured", "$device", "$os_version", "$os", "$browser", "$referrer", "$country", "$state", "$city", "$current_url", "$duration_s", "$properties") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
"custom": `INSERT INTO product_analytics.events (session_id, project_id, event_id, "$event_name", created_at, "$time", distinct_id, "$auto_captured", "$device", "$os_version", "$os", "$browser", "$referrer", "$country", "$state", "$city", "$current_url", "$properties") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, "custom": `INSERT INTO product_analytics.events (session_id, project_id, event_id, "$event_name", created_at, "$time", distinct_id, "$auto_captured", "$device", "$os_version", "$os", "$browser", "$referrer", "$country", "$state", "$city", "$current_url", "$properties", properties) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
"graphql": `INSERT INTO product_analytics.events (session_id, project_id, event_id, "$event_name", created_at, "$time", distinct_id, "$auto_captured", "$device", "$os_version", "$os", "$browser", "$referrer", "$country", "$state", "$city", "$current_url", "$properties") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, "graphql": `INSERT INTO product_analytics.events (session_id, project_id, event_id, "$event_name", created_at, "$time", distinct_id, "$auto_captured", "$device", "$os_version", "$os", "$browser", "$referrer", "$country", "$state", "$city", "$current_url", "$properties") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
"issuesEvents": `INSERT INTO product_analytics.events (session_id, project_id, event_id, "$event_name", created_at, "$time", distinct_id, "$auto_captured", "$device", "$os_version", "$os", "$browser", "$referrer", "$country", "$state", "$city", "$current_url", issue_type, issue_id, "$properties") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, "issuesEvents": `INSERT INTO product_analytics.events (session_id, project_id, event_id, "$event_name", created_at, "$time", distinct_id, "$auto_captured", "$device", "$os_version", "$os", "$browser", "$referrer", "$country", "$state", "$city", "$current_url", issue_type, issue_id, "$properties") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
"issues": "INSERT INTO experimental.issues (project_id, issue_id, type, context_string) VALUES (?, ?, ?, ?)", "issues": "INSERT INTO experimental.issues (project_id, issue_id, type, context_string) VALUES (?, ?, ?, ?)",
@ -220,6 +221,8 @@ func (c *connectorImpl) InsertWebSession(session *sessions.Session) error {
session.UtmSource, session.UtmSource,
session.UtmMedium, session.UtmMedium,
session.UtmCampaign, session.UtmCampaign,
session.ScreenWidth,
session.ScreenHeight,
); err != nil { ); err != nil {
c.checkError("sessions", err) c.checkError("sessions", err)
return fmt.Errorf("can't append to sessions batch: %s", err) return fmt.Errorf("can't append to sessions batch: %s", err)
@ -725,7 +728,6 @@ func (c *connectorImpl) InsertRequest(session *sessions.Session, msg *messages.N
func (c *connectorImpl) InsertCustom(session *sessions.Session, msg *messages.CustomEvent) error { func (c *connectorImpl) InsertCustom(session *sessions.Session, msg *messages.CustomEvent) error {
jsonString, err := json.Marshal(map[string]interface{}{ jsonString, err := json.Marshal(map[string]interface{}{
"payload": msg.Payload,
"user_device": session.UserDevice, "user_device": session.UserDevice,
"user_device_type": session.UserDeviceType, "user_device_type": session.UserDeviceType,
"page_title ": msg.PageTitle, "page_title ": msg.PageTitle,
@ -733,6 +735,14 @@ func (c *connectorImpl) InsertCustom(session *sessions.Session, msg *messages.Cu
if err != nil { if err != nil {
return fmt.Errorf("can't marshal custom event: %s", err) return fmt.Errorf("can't marshal custom event: %s", err)
} }
customPayload := make(map[string]interface{})
if err := json.Unmarshal([]byte(msg.Payload), &customPayload); err != nil {
log.Printf("can't unmarshal custom event payload into object: %s", err)
customPayload = map[string]interface{}{
"payload": msg.Payload,
}
}
eventTime := datetime(msg.Timestamp) eventTime := datetime(msg.Timestamp)
if err := c.batches["custom"].Append( if err := c.batches["custom"].Append(
session.SessionID, session.SessionID,
@ -752,7 +762,8 @@ func (c *connectorImpl) InsertCustom(session *sessions.Session, msg *messages.Cu
session.UserState, session.UserState,
session.UserCity, session.UserCity,
cropString(msg.Url), cropString(msg.Url),
jsonString, jsonString, // $properties
customPayload, // properties
); err != nil { ); err != nil {
c.checkError("custom", err) c.checkError("custom", err)
return fmt.Errorf("can't append to custom batch: %s", err) return fmt.Errorf("can't append to custom batch: %s", err)
@ -799,6 +810,45 @@ func (c *connectorImpl) InsertGraphQL(session *sessions.Session, msg *messages.G
return nil return nil
} }
func (c *connectorImpl) InsertIncident(session *sessions.Session, msg *messages.Incident) error {
jsonString, err := json.Marshal(map[string]interface{}{
"label": msg.Label,
"start_time": msg.StartTime,
"end_time": msg.EndTime,
"user_device": session.UserDevice,
"user_device_type": session.UserDeviceType,
"page_title ": msg.PageTitle,
})
if err != nil {
return fmt.Errorf("can't marshal custom event: %s", err)
}
eventTime := datetime(msg.Timestamp)
if err := c.batches["custom"].Append(
session.SessionID,
uint16(session.ProjectID),
getUUID(msg),
"INCIDENT",
eventTime,
eventTime.Unix(),
session.UserUUID,
true,
session.Platform,
session.UserOSVersion,
session.UserOS,
session.UserBrowser,
session.Referrer,
session.UserCountry,
session.UserState,
session.UserCity,
cropString(msg.Url),
jsonString,
); err != nil {
c.checkError("custom", err)
return fmt.Errorf("can't append to custom batch: %s", err)
}
return nil
}
// Mobile events // Mobile events
func (c *connectorImpl) InsertMobileSession(session *sessions.Session) error { func (c *connectorImpl) InsertMobileSession(session *sessions.Session) error {

View file

@ -270,3 +270,15 @@ func (conn *Conn) InsertWebStatsPerformance(p *messages.PerformanceTrackAggr) er
) )
return nil return nil
} }
func (conn *Conn) InsertIncident(sess *sessions.Session, e *messages.Incident) error {
sessCtx := context.WithValue(context.Background(), "sessionID", sess.SessionID)
issueID := hashid.MobileIncidentID(sess.ProjectID, sess.SessionID, e.Timestamp)
if err := conn.bulks.Get("webIssues").Append(sess.ProjectID, issueID, "incident", e.Url); err != nil {
conn.log.Error(sessCtx, "insert incident issue err: %s", err)
}
if err := conn.bulks.Get("webIssueEvents").Append(sess.SessionID, issueID, e.Timestamp, truncSqIdx(e.MsgID()), nil); err != nil {
conn.log.Error(sessCtx, "insert incident issue event err: %s", err)
}
return nil
}

View file

@ -38,3 +38,11 @@ func MouseThrashingID(projectID uint32, sessID, ts uint64) string {
hash.Write([]byte(strconv.FormatUint(ts, 10))) hash.Write([]byte(strconv.FormatUint(ts, 10)))
return strconv.FormatUint(uint64(projectID), 16) + hex.EncodeToString(hash.Sum(nil)) return strconv.FormatUint(uint64(projectID), 16) + hex.EncodeToString(hash.Sum(nil))
} }
func MobileIncidentID(projectID uint32, sessID, ts uint64) string {
hash := fnv.New128a()
hash.Write([]byte("mobile_incident"))
hash.Write([]byte(strconv.FormatUint(sessID, 10)))
hash.Write([]byte(strconv.FormatUint(ts, 10)))
return strconv.FormatUint(uint64(projectID), 16) + hex.EncodeToString(hash.Sum(nil))
}

View file

@ -11,4 +11,4 @@ func IsMobileType(id int) bool {
func IsDOMType(id int) bool { func IsDOMType(id int) bool {
return 0 == id || 4 == id || 5 == id || 6 == id || 7 == id || 8 == id || 9 == id || 10 == id || 11 == id || 12 == id || 13 == id || 14 == id || 15 == id || 16 == id || 18 == id || 19 == id || 20 == id || 34 == id || 35 == id || 37 == id || 38 == id || 49 == id || 50 == id || 51 == id || 43 == id || 52 == id || 54 == id || 55 == id || 57 == id || 58 == id || 59 == id || 60 == id || 61 == id || 67 == id || 68 == id || 69 == id || 70 == id || 71 == id || 72 == id || 73 == id || 74 == id || 75 == id || 76 == id || 77 == id || 113 == id || 114 == id || 117 == id || 118 == id || 119 == id || 122 == id || 93 == id || 96 == id || 100 == id || 101 == id || 102 == id || 103 == id || 104 == id || 105 == id || 106 == id || 111 == id return 0 == id || 4 == id || 5 == id || 6 == id || 7 == id || 8 == id || 9 == id || 10 == id || 11 == id || 12 == id || 13 == id || 14 == id || 15 == id || 16 == id || 18 == id || 19 == id || 20 == id || 34 == id || 35 == id || 37 == id || 38 == id || 49 == id || 50 == id || 51 == id || 43 == id || 52 == id || 54 == id || 55 == id || 57 == id || 58 == id || 59 == id || 60 == id || 61 == id || 67 == id || 68 == id || 69 == id || 70 == id || 71 == id || 72 == id || 73 == id || 74 == id || 75 == id || 76 == id || 77 == id || 113 == id || 114 == id || 117 == id || 118 == id || 119 == id || 122 == id || 93 == id || 96 == id || 100 == id || 101 == id || 102 == id || 103 == id || 104 == id || 105 == id || 106 == id || 111 == id
} }

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

5
ee/api/.gitignore vendored
View file

@ -201,16 +201,17 @@ Pipfile.lock
/chalicelib/core/metrics/heatmaps /chalicelib/core/metrics/heatmaps
/chalicelib/core/metrics/product_analytics /chalicelib/core/metrics/product_analytics
/chalicelib/core/metrics/product_anaytics2.py /chalicelib/core/metrics/product_anaytics2.py
/chalicelib/core/events /chalicelib/core/events*
/chalicelib/core/feature_flags.py /chalicelib/core/feature_flags.py
/chalicelib/core/issue_tracking/* /chalicelib/core/issue_tracking/*
/chalicelib/core/issues.py /chalicelib/core/issues.py
/chalicelib/core/issues/
/chalicelib/core/jobs.py /chalicelib/core/jobs.py
/chalicelib/core/log_tools/* /chalicelib/core/log_tools/*
/chalicelib/core/metadata.py /chalicelib/core/metadata.py
/chalicelib/core/mobile.py /chalicelib/core/mobile.py
/chalicelib/core/saved_search.py /chalicelib/core/saved_search.py
/chalicelib/core/sessions/*.py /chalicelib/core/sessions/**/*.py
/chalicelib/core/sessions/sessions_viewed /chalicelib/core/sessions/sessions_viewed
/chalicelib/core/metrics/modules /chalicelib/core/metrics/modules
/chalicelib/core/socket_ios.py /chalicelib/core/socket_ios.py

View file

@ -21,6 +21,8 @@ python-decouple = "==3.8"
pydantic = {extras = ["email"], version = "==2.11.4"} pydantic = {extras = ["email"], version = "==2.11.4"}
apscheduler = "==3.11.0" apscheduler = "==3.11.0"
python3-saml = "==1.16.0" python3-saml = "==1.16.0"
lxml = "==5.3.0"
xmlsec = "==1.3.14"
python-multipart = "==0.0.20" python-multipart = "==0.0.20"
redis = "==6.1.0" redis = "==6.1.0"
azure-storage-blob = "==12.25.1" azure-storage-blob = "==12.25.1"

View file

@ -101,4 +101,4 @@ rm -rf ./chalicelib/core/errors/errors_details.py
rm -rf ./chalicelib/core/notes.py rm -rf ./chalicelib/core/notes.py
rm -rf ./chalicelib/utils/contextual_validators.py rm -rf ./chalicelib/utils/contextual_validators.py
rm -rf ./routers/subs/product_analytics.py rm -rf ./routers/subs/product_analytics.py
rm -rf ./schemas/product_analytics.py rm -rf ./schemas/product_analytics.py

View file

@ -19,7 +19,8 @@ apscheduler==3.11.0
# TODO: enable after xmlsec fix https://github.com/xmlsec/python-xmlsec/issues/252 # TODO: enable after xmlsec fix https://github.com/xmlsec/python-xmlsec/issues/252
#--no-binary is used to avoid libxml2 library version incompatibilities between xmlsec and lxml #--no-binary is used to avoid libxml2 library version incompatibilities between xmlsec and lxml
python3-saml==1.16.0 python3-saml==1.16.0
--no-binary=lxml lxml==5.3.0 --no-binary=lxml
xmlsec==1.3.14 --no-binary=xmlsec
python-multipart==0.0.20 python-multipart==0.0.20

View file

@ -275,8 +275,7 @@ def get_projects(context: schemas.CurrentContext = Depends(OR_context)):
def search_sessions(projectId: int, data: schemas.SessionsSearchPayloadSchema = \ def search_sessions(projectId: int, data: schemas.SessionsSearchPayloadSchema = \
Depends(contextual_validators.validate_contextual_payload), Depends(contextual_validators.validate_contextual_payload),
context: schemas.CurrentContext = Depends(OR_context)): context: schemas.CurrentContext = Depends(OR_context)):
data = sessions_search.search_sessions(data=data, project=context.project, user_id=context.user_id, data = sessions_search.search_sessions(data=data, project=context.project, user_id=context.user_id)
platform=context.project.platform)
return {'data': data} return {'data': data}
@ -285,8 +284,7 @@ def search_sessions(projectId: int, data: schemas.SessionsSearchPayloadSchema =
def session_ids_search(projectId: int, data: schemas.SessionsSearchPayloadSchema = \ def session_ids_search(projectId: int, data: schemas.SessionsSearchPayloadSchema = \
Depends(contextual_validators.validate_contextual_payload), Depends(contextual_validators.validate_contextual_payload),
context: schemas.CurrentContext = Depends(OR_context)): context: schemas.CurrentContext = Depends(OR_context)):
data = sessions_search.search_sessions(data=data, project=context.project, user_id=context.user_id, ids_only=True, data = sessions_search.search_sessions(data=data, project=context.project, user_id=context.user_id, ids_only=True)
platform=context.project.platform)
return {'data': data} return {'data': data}

View file

@ -3,4 +3,4 @@ from .schemas_ee import *
from .assist_stats_schema import * from .assist_stats_schema import *
from .product_analytics import * from .product_analytics import *
from . import overrides as _overrides from . import overrides as _overrides
from .schemas import _PaginatedSchema as PaginatedSchema from .schemas import _PaginatedSchema as PaginatedSchema

View file

@ -829,6 +829,15 @@ class ResourceTiming(Message):
self.stalled = stalled self.stalled = stalled
class Incident(Message):
__id__ = 87
def __init__(self, label, start_time, end_time):
self.label = label
self.start_time = start_time
self.end_time = end_time
class LongAnimationTask(Message): class LongAnimationTask(Message):
__id__ = 89 __id__ = 89

View file

@ -1241,6 +1241,19 @@ cdef class ResourceTiming(PyMessage):
self.stalled = stalled self.stalled = stalled
cdef class Incident(PyMessage):
cdef public int __id__
cdef public str label
cdef public long start_time
cdef public long end_time
def __init__(self, str label, long start_time, long end_time):
self.__id__ = 87
self.label = label
self.start_time = start_time
self.end_time = end_time
cdef class LongAnimationTask(PyMessage): cdef class LongAnimationTask(PyMessage):
cdef public int __id__ cdef public int __id__
cdef public str name cdef public str name

View file

@ -750,6 +750,13 @@ class MessageCodec(Codec):
stalled=self.read_uint(reader) stalled=self.read_uint(reader)
) )
if message_id == 87:
return Incident(
label=self.read_string(reader),
start_time=self.read_int(reader),
end_time=self.read_int(reader)
)
if message_id == 89: if message_id == 89:
return LongAnimationTask( return LongAnimationTask(
name=self.read_string(reader), name=self.read_string(reader),

View file

@ -848,6 +848,13 @@ cdef class MessageCodec:
stalled=self.read_uint(reader) stalled=self.read_uint(reader)
) )
if message_id == 87:
return Incident(
label=self.read_string(reader),
start_time=self.read_int(reader),
end_time=self.read_int(reader)
)
if message_id == 89: if message_id == 89:
return LongAnimationTask( return LongAnimationTask(
name=self.read_string(reader), name=self.read_string(reader),

View file

@ -1,3 +1,16 @@
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;

View file

@ -1,3 +1,16 @@
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.23.0-ee'; CREATE OR REPLACE FUNCTION openreplay_version AS() -> 'v1.23.0-ee';
DROP TABLE IF EXISTS product_analytics.all_events; DROP TABLE IF EXISTS product_analytics.all_events;
@ -41,26 +54,27 @@ CREATE TABLE IF NOT EXISTS product_analytics.event_properties
event_name String, event_name String,
property_name String, property_name String,
value_type String, value_type String,
auto_captured BOOL,
_timestamp DateTime DEFAULT now() _timestamp DateTime DEFAULT now()
) ENGINE = ReplacingMergeTree(_timestamp) ) ENGINE = ReplacingMergeTree(_timestamp)
ORDER BY (project_id, event_name, property_name, value_type); ORDER BY (project_id, event_name, property_name, value_type, auto_captured);
CREATE MATERIALIZED VIEW IF NOT EXISTS product_analytics.event_properties_extractor_mv CREATE MATERIALIZED VIEW IF NOT EXISTS product_analytics.event_properties_extractor_mv
TO product_analytics.event_properties AS TO product_analytics.event_properties AS
SELECT project_id, SELECT project_id,
`$event_name` AS event_name, `$event_name` AS event_name,
property_name, property_name,
JSONType(JSONExtractRaw(toString(`$properties`), property_name)) AS value_type toString(JSONType(JSONExtractRaw(toString(`$properties`), property_name))) AS value_type,
`$auto_captured` AS auto_captured
FROM product_analytics.events FROM product_analytics.events
ARRAY JOIN JSONExtractKeys(toString(`$properties`)) as property_name; ARRAY JOIN JSONExtractKeys(toString(`$properties`)) as property_name
UNION DISTINCT
CREATE MATERIALIZED VIEW IF NOT EXISTS product_analytics.event_cproperties_extractor
TO product_analytics.event_properties AS
SELECT project_id, SELECT project_id,
`$event_name` AS event_name, `$event_name` AS event_name,
property_name, property_name,
JSONType(JSONExtractRaw(toString(`properties`), property_name)) AS value_type toString(JSONType(JSONExtractRaw(toString(`properties`), property_name))) AS value_type,
`$auto_captured` AS auto_captured
FROM product_analytics.events FROM product_analytics.events
ARRAY JOIN JSONExtractKeys(toString(`properties`)) as property_name; ARRAY JOIN JSONExtractKeys(toString(`properties`)) as property_name;
@ -105,10 +119,8 @@ FROM product_analytics.events
WHERE (all_properties.display_name != '' WHERE (all_properties.display_name != ''
OR all_properties.description != '') OR all_properties.description != '')
AND is_event_property) AS old_data AND is_event_property) AS old_data
ON (events.project_id = old_data.project_id AND property_name = old_data.property_name); ON (events.project_id = old_data.project_id AND property_name = old_data.property_name)
UNION DISTINCT
CREATE MATERIALIZED VIEW IF NOT EXISTS product_analytics.all_cproperties_extractor_mv
TO product_analytics.all_properties AS
SELECT project_id, SELECT project_id,
property_name, property_name,
TRUE AS is_event_property, TRUE AS is_event_property,
@ -155,7 +167,7 @@ FROM product_analytics.events
WHERE randCanonical() < 0.5 -- This randomly skips inserts WHERE randCanonical() < 0.5 -- This randomly skips inserts
AND value != '' AND value != ''
LIMIT 2 BY project_id,property_name LIMIT 2 BY project_id,property_name
UNION ALL UNION DISTINCT
SELECT project_id, SELECT project_id,
property_name, property_name,
TRUE AS is_event_property, TRUE AS is_event_property,
@ -225,6 +237,16 @@ SELECT project_id,
_timestamp _timestamp
FROM product_analytics.events FROM product_analytics.events
ARRAY JOIN JSONExtractKeys(toString(`$properties`)) as property_name ARRAY JOIN JSONExtractKeys(toString(`$properties`)) as property_name
WHERE length(value) > 0 AND isNull(toFloat64OrNull(value))
AND _timestamp > now() - INTERVAL 1 MONTH
UNION DISTINCT
SELECT project_id,
`$event_name` AS event_name,
property_name,
JSONExtractString(toString(`properties`), property_name) AS value,
_timestamp
FROM product_analytics.events
ARRAY JOIN JSONExtractKeys(toString(`properties`)) as property_name
WHERE length(value) > 0 AND isNull(toFloat64OrNull(value)) WHERE length(value) > 0 AND isNull(toFloat64OrNull(value))
AND _timestamp > now() - INTERVAL 1 MONTH; AND _timestamp > now() - INTERVAL 1 MONTH;

View file

@ -106,7 +106,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,'ios'=2,'android'=3) DEFAULT 'web', platform Enum8('web'=1,'mobile'=2) DEFAULT 'web',
datetime DateTime, datetime DateTime,
timezone LowCardinality(Nullable(String)), timezone LowCardinality(Nullable(String)),
duration UInt32, duration UInt32,
@ -134,7 +134,7 @@ CREATE TABLE IF NOT EXISTS experimental.sessions
metadata_8 Nullable(String), metadata_8 Nullable(String),
metadata_9 Nullable(String), metadata_9 Nullable(String),
metadata_10 Nullable(String), metadata_10 Nullable(String),
_timestamp DateTime DEFAULT now() _timestamp DateTime DEFAULT now()
) 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)
@ -676,29 +676,29 @@ CREATE TABLE IF NOT EXISTS product_analytics.event_properties
event_name String, event_name String,
property_name String, property_name String,
value_type String, value_type String,
auto_captured BOOL,
_timestamp DateTime DEFAULT now() _timestamp DateTime DEFAULT now()
) ENGINE = ReplacingMergeTree(_timestamp) ) ENGINE = ReplacingMergeTree(_timestamp)
ORDER BY (project_id, event_name, property_name, value_type); ORDER BY (project_id, event_name, property_name, value_type, auto_captured);
-- ----------------- This is experimental, if it doesn't work, we need to do it in db worker ------------- -- ----------------- This is experimental, if it doesn't work, we need to do it in db worker -------------
-- Incremental materialized view to fill event_properties using $properties -- Incremental materialized view to fill event_properties using $properties & properties
CREATE MATERIALIZED VIEW IF NOT EXISTS product_analytics.event_properties_extractor_mv CREATE MATERIALIZED VIEW IF NOT EXISTS product_analytics.event_properties_extractor_mv
TO product_analytics.event_properties AS TO product_analytics.event_properties AS
SELECT project_id, SELECT project_id,
`$event_name` AS event_name, `$event_name` AS event_name,
property_name, property_name,
JSONType(JSONExtractRaw(toString(`$properties`), property_name)) AS value_type toString(JSONType(JSONExtractRaw(toString(`$properties`), property_name))) AS value_type,
`$auto_captured` AS auto_captured
FROM product_analytics.events FROM product_analytics.events
ARRAY JOIN JSONExtractKeys(toString(`$properties`)) as property_name; ARRAY JOIN JSONExtractKeys(toString(`$properties`)) as property_name
UNION DISTINCT
-- Incremental materialized view to fill event_properties using properties
CREATE MATERIALIZED VIEW IF NOT EXISTS product_analytics.event_cproperties_extractor
TO product_analytics.event_properties AS
SELECT project_id, SELECT project_id,
`$event_name` AS event_name, `$event_name` AS event_name,
property_name, property_name,
JSONType(JSONExtractRaw(toString(`properties`), property_name)) AS value_type toString(JSONType(JSONExtractRaw(toString(`properties`), property_name))) AS value_type,
`$auto_captured` AS auto_captured
FROM product_analytics.events FROM product_analytics.events
ARRAY JOIN JSONExtractKeys(toString(`properties`)) as property_name; ARRAY JOIN JSONExtractKeys(toString(`properties`)) as property_name;
-- -------- END --------- -- -------- END ---------
@ -724,7 +724,7 @@ CREATE TABLE IF NOT EXISTS product_analytics.all_properties
-- ----------------- This is experimental, if it doesn't work, we need to do it in db worker ------------- -- ----------------- This is experimental, if it doesn't work, we need to do it in db worker -------------
-- Incremental materialized view to fill all_properties using $properties -- Incremental materialized view to fill all_properties using $properties and properties
CREATE MATERIALIZED VIEW IF NOT EXISTS product_analytics.all_properties_extractor_mv CREATE MATERIALIZED VIEW IF NOT EXISTS product_analytics.all_properties_extractor_mv
TO product_analytics.all_properties AS TO product_analytics.all_properties AS
SELECT project_id, SELECT project_id,
@ -748,11 +748,8 @@ FROM product_analytics.events
WHERE (all_properties.display_name != '' WHERE (all_properties.display_name != ''
OR all_properties.description != '') OR all_properties.description != '')
AND is_event_property) AS old_data AND is_event_property) AS old_data
ON (events.project_id = old_data.project_id AND property_name = old_data.property_name); ON (events.project_id = old_data.project_id AND property_name = old_data.property_name)
UNION DISTINCT
-- Incremental materialized view to fill all_properties using properties
CREATE MATERIALIZED VIEW IF NOT EXISTS product_analytics.all_cproperties_extractor_mv
TO product_analytics.all_properties AS
SELECT project_id, SELECT project_id,
property_name, property_name,
TRUE AS is_event_property, TRUE AS is_event_property,
@ -802,7 +799,7 @@ FROM product_analytics.events
WHERE randCanonical() < 0.5 -- This randomly skips inserts WHERE randCanonical() < 0.5 -- This randomly skips inserts
AND value != '' AND value != ''
LIMIT 2 BY project_id,property_name LIMIT 2 BY project_id,property_name
UNION ALL UNION DISTINCT
-- using union because each table should be the target of 1 single refreshable MV -- using union because each table should be the target of 1 single refreshable MV
SELECT project_id, SELECT project_id,
property_name, property_name,
@ -873,6 +870,16 @@ SELECT project_id,
_timestamp _timestamp
FROM product_analytics.events FROM product_analytics.events
ARRAY JOIN JSONExtractKeys(toString(`$properties`)) as property_name ARRAY JOIN JSONExtractKeys(toString(`$properties`)) as property_name
WHERE length(value) > 0 AND isNull(toFloat64OrNull(value))
AND _timestamp > now() - INTERVAL 1 MONTH
UNION DISTINCT
SELECT project_id,
`$event_name` AS event_name,
property_name,
JSONExtractString(toString(`properties`), property_name) AS value,
_timestamp
FROM product_analytics.events
ARRAY JOIN JSONExtractKeys(toString(`properties`)) as property_name
WHERE length(value) > 0 AND isNull(toFloat64OrNull(value)) WHERE length(value) > 0 AND isNull(toFloat64OrNull(value))
AND _timestamp > now() - INTERVAL 1 MONTH; AND _timestamp > now() - INTERVAL 1 MONTH;

View file

@ -23,6 +23,8 @@ DROP SCHEMA IF EXISTS or_cache CASCADE;
ALTER TABLE public.tenants ALTER TABLE public.tenants
ALTER COLUMN scope_state SET DEFAULT 2; ALTER COLUMN scope_state SET DEFAULT 2;
ALTER TYPE issue_type ADD VALUE IF NOT EXISTS 'incident';
COMMIT; COMMIT;
\elif :is_next \elif :is_next

View file

@ -352,7 +352,8 @@ CREATE TYPE issue_type AS ENUM (
'custom', 'custom',
'js_exception', 'js_exception',
'mouse_thrashing', 'mouse_thrashing',
'app_crash' 'app_crash',
'incident'
); );
CREATE TABLE public.issues CREATE TABLE public.issues

View file

@ -195,7 +195,7 @@ const EChartsSankey: React.FC<Props> = (props) => {
header: { header: {
fontWeight: '600', fontWeight: '600',
fontSize: 12, fontSize: 12,
color: '#333', color: 'var(--color-gray-darkest)',
overflow: 'truncate', overflow: 'truncate',
paddingBottom: '.5rem', paddingBottom: '.5rem',
paddingLeft: '14px', paddingLeft: '14px',
@ -203,16 +203,16 @@ const EChartsSankey: React.FC<Props> = (props) => {
}, },
body: { body: {
fontSize: 12, fontSize: 12,
color: '#000', color: 'var(--color-black)',
}, },
percentage: { percentage: {
fontSize: 12, fontSize: 12,
color: '#454545', color: 'var(--color-gray-dark)',
}, },
sessions: { sessions: {
fontSize: 12, fontSize: 12,
fontFamily: "mono, 'monospace', sans-serif", fontFamily: "mono, 'monospace', sans-serif",
color: '#999999', color: 'var(--color-gray-dark)',
}, },
clickIcon: { clickIcon: {
backgroundColor: { backgroundColor: {
@ -266,6 +266,7 @@ const EChartsSankey: React.FC<Props> = (props) => {
}, },
tooltip: { tooltip: {
formatter: sankeyTooltip(echartNodes, nodeValues), formatter: sankeyTooltip(echartNodes, nodeValues),
backgroundColor: 'var(--color-white)',
}, },
nodeAlign: 'left', nodeAlign: 'left',
nodeWidth: 40, nodeWidth: 40,

View file

@ -102,18 +102,18 @@ export function customTooltipFormatter(uuid: string) {
<div class="flex flex-col gap-1 bg-white shadow border rounded p-2 z-50"> <div class="flex flex-col gap-1 bg-white shadow border rounded p-2 z-50">
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<div style=" <div style="
border-radius: 99px; border-radius: 99px;
background: ${params.color}; background: ${params.color};
width: 1rem; width: 1rem;
height: 1rem;"> height: 1rem;">
</div> </div>
<div class="font-medium text-black">${fullname}</div> <div class="font-medium text-black">${fullname}</div>
</div> </div>
<div style="border-left: 2px solid ${ <div style="border-left: 2px solid ${
params.color params.color
};" class="flex flex-col px-2 ml-2"> };" class="flex flex-col px-2 ml-2">
<div class="text-neutral-600 text-sm"> <div class="text-neutral-600 text-sm">
Total: Total:
</div> </div>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
@ -127,7 +127,7 @@ export function customTooltipFormatter(uuid: string) {
(window as any).__seriesColorMap?.[uuid]?.[partnerName] || '#999'; (window as any).__seriesColorMap?.[uuid]?.[partnerName] || '#999';
str += ` str += `
<div style="border-left: 2px dashed ${partnerColor};" class="flex flex-col px-2 ml-2"> <div style="border-left: 2px dashed ${partnerColor};" class="flex flex-col px-2 ml-2">
<div class="text-neutral-600 text-sm"> <div class="text-neutral-600 text-sm">
${isPrevious ? 'Current' : 'Previous'} Total: ${isPrevious ? 'Current' : 'Previous'} Total:
</div> </div>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
@ -168,9 +168,9 @@ export function customTooltipFormatter(uuid: string) {
<div class="flex flex-col gap-1 bg-white shadow border rounded p-2 z-50"> <div class="flex flex-col gap-1 bg-white shadow border rounded p-2 z-50">
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<div style=" <div style="
border-radius: 99px; border-radius: 99px;
background: ${params.color}; background: ${params.color};
width: 1rem; width: 1rem;
height: 1rem;"> height: 1rem;">
</div> </div>
<div class="font-medium text-black">${seriesName}</div> <div class="font-medium text-black">${seriesName}</div>
@ -179,7 +179,7 @@ export function customTooltipFormatter(uuid: string) {
<div style="border-left: 2px solid ${ <div style="border-left: 2px solid ${
params.color params.color
};" class="flex flex-col px-2 ml-2"> };" class="flex flex-col px-2 ml-2">
<div class="text-neutral-600 text-sm"> <div class="text-neutral-600 text-sm">
${firstTs ? formatTimeOrDate(firstTs) : categoryLabel} ${firstTs ? formatTimeOrDate(firstTs) : categoryLabel}
</div> </div>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
@ -194,7 +194,7 @@ export function customTooltipFormatter(uuid: string) {
(window as any).__seriesColorMap?.[uuid]?.[partnerName] || '#999'; (window as any).__seriesColorMap?.[uuid]?.[partnerName] || '#999';
tooltipContent += ` tooltipContent += `
<div style="border-left: 2px dashed ${partnerColor};" class="flex flex-col px-2 ml-2"> <div style="border-left: 2px dashed ${partnerColor};" class="flex flex-col px-2 ml-2">
<div class="text-neutral-600 text-sm"> <div class="text-neutral-600 text-sm">
${secondTs ? formatTimeOrDate(secondTs) : categoryLabel} ${secondTs ? formatTimeOrDate(secondTs) : categoryLabel}
</div> </div>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
@ -229,13 +229,13 @@ function buildCompareTag(val: number, prevVal: number): string {
return ` return `
<div style=" <div style="
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
background: ${tagColor}; background: ${tagColor};
color: ${arrowColor}; color: ${arrowColor};
padding: 2px 6px; padding: 2px 6px;
border-radius: 4px; border-radius: 4px;
font-size: 0.75rem;"> font-size: 0.75rem;">
<span>${arrow}</span> <span>${arrow}</span>
<span>${absDelta}</span> <span>${absDelta}</span>
@ -290,7 +290,7 @@ export function createSeries(
datasetId, datasetId,
encode: { x: 'idx', y: fullName }, encode: { x: 'idx', y: fullName },
lineStyle: dashed ? { type: 'dashed' } : undefined, lineStyle: dashed ? { type: 'dashed' } : undefined,
showSymbol: false, showSymbol: data.chart.length === 1,
// custom flag to hide prev data from legend // custom flag to hide prev data from legend
_hideInLegend: hideFromLegend, _hideInLegend: hideFromLegend,
itemStyle: { opacity: 1 }, itemStyle: { opacity: 1 },

View file

@ -1,10 +1,10 @@
import React from 'react'; import React from 'react';
import { Button, Form, Input, Space, Modal } from 'antd'; import { Button, Form, Input, Space } from 'antd';
import { Trash } from 'UI/Icons'; import { Trash } from 'UI/Icons';
import { useStore } from '@/mstore'; import { useStore } from '@/mstore';
import { useModal } from 'Components/ModalContext'; import { useModal } from 'Components/ModalContext';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { confirm } from 'UI';
interface Props { interface Props {
tag: any; tag: any;
projectId: number; projectId: number;
@ -23,14 +23,16 @@ function TagForm(props: Props) {
}; };
const onDelete = async () => { const onDelete = async () => {
Modal.confirm({ if (
title: t('Tag'), await confirm({
content: t('Are you sure you want to remove?'), header: t('Remove Tag'),
onOk: async () => { confirmButton: t('Remove'),
await tagWatchStore.deleteTag(tag.tagId, projectId); confirmation: t('Are you sure you want to remove this tag?'),
closeModal(); })
}, ) {
}); await tagWatchStore.deleteTag(tag.tagId, projectId);
closeModal();
}
}; };
const onSave = async () => { const onSave = async () => {

View file

@ -23,6 +23,7 @@ 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

@ -90,6 +90,7 @@ function Condition({
<label className="w-1/6 flex-shrink-0 font-normal">{t('is')}</label> <label className="w-1/6 flex-shrink-0 font-normal">{t('is')}</label>
<div className="w-2/6 flex items-center"> <div className="w-2/6 flex items-center">
<Select <Select
popupMatchSelectWidth={false}
placeholder={t('Select Condition')} placeholder={t('Select Condition')}
options={localizedConditions} options={localizedConditions}
name="operator" name="operator"

View file

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

View file

@ -7,19 +7,18 @@ import {
Button, Button,
Dropdown, Dropdown,
Modal as AntdModal, Modal as AntdModal,
Avatar, TableColumnType, Spin Avatar,
TableColumnType,
Spin,
} from 'antd'; } from 'antd';
import { import { EditOutlined, DeleteOutlined } from '@ant-design/icons';
EditOutlined,
DeleteOutlined,
} from '@ant-design/icons';
import { EllipsisVertical } from 'lucide-react'; import { EllipsisVertical } from 'lucide-react';
import { TablePaginationConfig, SorterResult } from 'antd/lib/table/interface'; import { TablePaginationConfig, SorterResult } from 'antd/lib/table/interface';
import { useStore } from 'App/mstore'; import { useStore } from 'App/mstore';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useHistory } from 'react-router'; import { useHistory } from 'react-router';
import { withSiteId } from 'App/routes'; import { withSiteId } from 'App/routes';
import { Icon } from 'UI'; import { Icon, confirm } from 'UI';
import cn from 'classnames'; import cn from 'classnames';
import { TYPE_ICONS, TYPE_NAMES } from 'App/constants/card'; import { TYPE_ICONS, TYPE_NAMES } from 'App/constants/card';
import Widget from 'App/mstore/types/widget'; import Widget from 'App/mstore/types/widget';
@ -45,7 +44,7 @@ const ListView: React.FC<Props> = ({
toggleSelection, toggleSelection,
disableSelection = false, disableSelection = false,
inLibrary = false, inLibrary = false,
loading = false loading = false,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [editingMetricId, setEditingMetricId] = useState<number | null>(null); const [editingMetricId, setEditingMetricId] = useState<number | null>(null);
@ -63,7 +62,7 @@ const ListView: React.FC<Props> = ({
<Text strong> <Text strong>
{Math.min( {Math.min(
(metricStore.pageSize || 10) * (metricStore.page || 1), (metricStore.pageSize || 10) * (metricStore.page || 1),
list.length list.length,
)} )}
</Text>{' '} </Text>{' '}
{t('of')}&nbsp;<Text strong>{list.length}</Text>&nbsp;{t('cards')} {t('of')}&nbsp;<Text strong>{list.length}</Text>&nbsp;{t('cards')}
@ -124,15 +123,17 @@ const ListView: React.FC<Props> = ({
const onMenuClick = async (metric: Widget, { key }: { key: string }) => { const onMenuClick = async (metric: Widget, { key }: { key: string }) => {
if (key === 'delete') { if (key === 'delete') {
AntdModal.confirm({ if (
title: t('Confirm'), await confirm({
content: t('Are you sure you want to permanently delete this card?'), header: t('Delete Card'),
okText: t('Yes, delete'), confirmButton: t('Delete'),
cancelText: t('No'), confirmation: t(
onOk: async () => { 'Are you sure you want to permanently delete this card? This action cannot be undone.',
await metricStore.delete(metric); ),
} })
}); ) {
await metricStore.delete(metric);
}
} }
if (key === 'rename') { if (key === 'rename') {
setEditingMetricId(metric.metricId); setEditingMetricId(metric.metricId);
@ -155,7 +156,7 @@ const ListView: React.FC<Props> = ({
const menuItems = [ const menuItems = [
{ key: 'rename', icon: <EditOutlined />, label: t('Rename') }, { key: 'rename', icon: <EditOutlined />, label: t('Rename') },
{ key: 'delete', icon: <DeleteOutlined />, label: t('Delete') } { key: 'delete', icon: <DeleteOutlined />, label: t('Delete') },
]; ];
const renderTitle = (_text: string, metric: Widget) => ( const renderTitle = (_text: string, metric: Widget) => (
@ -201,9 +202,10 @@ const ListView: React.FC<Props> = ({
key: 'title', key: 'title',
className: 'cap-first pl-4', className: 'cap-first pl-4',
sorter: true, sorter: true,
sortOrder: metricStore.sort.field === 'name' ? metricStore.sort.order : undefined, sortOrder:
metricStore.sort.field === 'name' ? metricStore.sort.order : undefined,
width: inLibrary ? '31%' : '25%', width: inLibrary ? '31%' : '25%',
render: renderTitle render: renderTitle,
}, },
{ {
title: t('Owner'), title: t('Owner'),
@ -211,19 +213,25 @@ const ListView: React.FC<Props> = ({
key: 'owner', key: 'owner',
className: 'capitalize', className: 'capitalize',
sorter: true, sorter: true,
sortOrder: metricStore.sort.field === 'owner_email' ? metricStore.sort.order : undefined, sortOrder:
metricStore.sort.field === 'owner_email'
? metricStore.sort.order
: undefined,
width: inLibrary ? '31%' : '25%', width: inLibrary ? '31%' : '25%',
render: renderOwner render: renderOwner,
}, },
{ {
title: t('Last Modified'), title: t('Last Modified'),
dataIndex: 'edited_at', dataIndex: 'edited_at',
key: 'lastModified', key: 'lastModified',
sorter: true, sorter: true,
sortOrder: metricStore.sort.field === 'edited_at' ? metricStore.sort.order : undefined, sortOrder:
metricStore.sort.field === 'edited_at'
? metricStore.sort.order
: undefined,
width: inLibrary ? '31%' : '25%', width: inLibrary ? '31%' : '25%',
render: renderLastModified render: renderLastModified,
} },
]; ];
if (!inLibrary) { if (!inLibrary) {
@ -232,14 +240,14 @@ const ListView: React.FC<Props> = ({
key: 'options', key: 'options',
className: 'text-right', className: 'text-right',
width: '5%', width: '5%',
render: renderOptions render: renderOptions,
}); });
} }
const handleTableChange = ( const handleTableChange = (
pag: TablePaginationConfig, pag: TablePaginationConfig,
_filters: Record<string, (string | number | boolean)[] | null>, _filters: Record<string, (string | number | boolean)[] | null>,
sorterParam: SorterResult<Widget> | SorterResult<Widget>[] sorterParam: SorterResult<Widget> | SorterResult<Widget>[],
) => { ) => {
const sorter = Array.isArray(sorterParam) ? sorterParam[0] : sorterParam; const sorter = Array.isArray(sorterParam) ? sorterParam[0] : sorterParam;
let order = sorter.order; let order = sorter.order;
@ -268,19 +276,19 @@ const ListView: React.FC<Props> = ({
onRow={ onRow={
inLibrary inLibrary
? (record) => ({ ? (record) => ({
onClick: () => { onClick: () => {
if (!disableSelection) toggleSelection?.(record?.metricId); if (!disableSelection) toggleSelection?.(record?.metricId);
} },
}) })
: undefined : undefined
} }
rowSelection={ rowSelection={
!disableSelection !disableSelection
? { ? {
selectedRowKeys: selectedList, selectedRowKeys: selectedList,
onChange: (keys) => toggleSelection && toggleSelection(keys), onChange: (keys) => toggleSelection && toggleSelection(keys),
columnWidth: 16 columnWidth: 16,
} }
: undefined : undefined
} }
pagination={{ pagination={{
@ -292,7 +300,7 @@ const ListView: React.FC<Props> = ({
showLessItems: true, showLessItems: true,
showTotal: () => totalMessage, showTotal: () => totalMessage,
size: 'small', size: 'small',
simple: true simple: true,
}} }}
/> />
<AntdModal <AntdModal

View file

@ -10,10 +10,10 @@ import AddCardSection from '../AddCardSection/AddCardSection';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
function MetricsList({ function MetricsList({
siteId, siteId,
onSelectionChange, onSelectionChange,
inLibrary inLibrary,
}: { }: {
siteId: string; siteId: string;
onSelectionChange?: (selected: any[]) => void; onSelectionChange?: (selected: any[]) => void;
inLibrary?: boolean; inLibrary?: boolean;
@ -26,16 +26,16 @@ function MetricsList({
const dashboard = dashboardStore.selectedDashboard; const dashboard = dashboardStore.selectedDashboard;
const existingCardIds = useMemo( const existingCardIds = useMemo(
() => dashboard?.widgets?.map((i) => parseInt(i.metricId)), () => dashboard?.widgets?.map((i) => parseInt(i.metricId)),
[dashboard] [dashboard],
); );
const cards = useMemo( const cards = useMemo(
() => () =>
onSelectionChange onSelectionChange
? metricStore.filteredCards.filter( ? metricStore.filteredCards.filter(
(i) => !existingCardIds?.includes(parseInt(i.metricId)) (i) => !existingCardIds?.includes(parseInt(i.metricId)),
) )
: metricStore.filteredCards, : metricStore.filteredCards,
[metricStore.filteredCards, existingCardIds, onSelectionChange] [metricStore.filteredCards, existingCardIds, onSelectionChange],
); );
const loading = metricStore.isLoading; const loading = metricStore.isLoading;
@ -66,7 +66,8 @@ function MetricsList({
metricStore.updateKey('sessionsPage', 1); metricStore.updateKey('sessionsPage', 1);
}, [metricStore]); }, [metricStore]);
const isFiltered = metricStore.filter.query !== '' || metricStore.filter.type !== ''; const isFiltered =
metricStore.filter.query !== '' || metricStore.filter.type !== '';
const searchImageDimensions = { width: 60, height: 'auto' }; const searchImageDimensions = { width: 60, height: 'auto' };
const defaultImageDimensions = { width: 600, height: 'auto' }; const defaultImageDimensions = { width: 600, height: 'auto' };
@ -93,7 +94,9 @@ function MetricsList({
) : ( ) : (
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<div> <div>
{t('Create and customize cards to analyze trends and user behavior effectively.')} {t(
'Create and customize cards to analyze trends and user behavior effectively.',
)}
</div> </div>
<Popover <Popover
arrow={false} arrow={false}

View file

@ -44,6 +44,7 @@ interface Props {
isSaved?: boolean; isSaved?: boolean;
isTemplate?: boolean; isTemplate?: boolean;
isPreview?: boolean; isPreview?: boolean;
height?: number;
} }
function WidgetChart(props: Props) { function WidgetChart(props: Props) {
@ -52,7 +53,7 @@ function WidgetChart(props: Props) {
triggerOnce: true, triggerOnce: true,
rootMargin: '200px 0px', rootMargin: '200px 0px',
}); });
const { isSaved = false, metric, isTemplate } = props; const { isSaved = false, metric, isTemplate, height } = props;
const { dashboardStore, metricStore } = useStore(); const { dashboardStore, metricStore } = useStore();
const _metric: any = props.metric; const _metric: any = props.metric;
const data = _metric.data; const data = _metric.data;
@ -283,6 +284,7 @@ function WidgetChart(props: Props) {
hideLegend hideLegend
onClick={onChartClick} onClick={onChartClick}
label={t('Conversion')} label={t('Conversion')}
height={height}
/> />
); );
} }
@ -293,6 +295,7 @@ function WidgetChart(props: Props) {
data={data} data={data}
compData={compData} compData={compData}
isWidget={isSaved || isTemplate} isWidget={isSaved || isTemplate}
height={height}
/> />
); );
} }
@ -308,6 +311,7 @@ function WidgetChart(props: Props) {
metric={defaultMetric} metric={defaultMetric}
data={data} data={data}
predefinedKey={_metric.metricOf} predefinedKey={_metric.metricOf}
height={height}
/> />
); );
} }
@ -331,6 +335,7 @@ function WidgetChart(props: Props) {
compData={compDataCopy} compData={compDataCopy}
onSeriesFocus={onFocus} onSeriesFocus={onFocus}
onClick={onChartClick} onClick={onChartClick}
height={height}
label={ label={
_metric.metricOf === 'sessionCount' _metric.metricOf === 'sessionCount'
? t('Number of Sessions') ? t('Number of Sessions')
@ -360,6 +365,7 @@ function WidgetChart(props: Props) {
return ( return (
<BarChart <BarChart
inGrid={!props.isPreview} inGrid={!props.isPreview}
height={height}
data={chartData} data={chartData}
compData={compDataCopy} compData={compDataCopy}
params={params} params={params}
@ -378,6 +384,7 @@ function WidgetChart(props: Props) {
if (viewType === 'progressChart') { if (viewType === 'progressChart') {
return ( return (
<ColumnChart <ColumnChart
height={height}
inGrid={!props.isPreview} inGrid={!props.isPreview}
horizontal horizontal
data={chartData} data={chartData}
@ -396,6 +403,7 @@ function WidgetChart(props: Props) {
if (viewType === 'pieChart') { if (viewType === 'pieChart') {
return ( return (
<PieChart <PieChart
height={height}
inGrid={!props.isPreview} inGrid={!props.isPreview}
data={chartData} data={chartData}
onSeriesFocus={onFocus} onSeriesFocus={onFocus}
@ -412,6 +420,7 @@ function WidgetChart(props: Props) {
<CustomMetricPercentage <CustomMetricPercentage
inGrid={!props.isPreview} inGrid={!props.isPreview}
data={data[0]} data={data[0]}
height={height}
colors={colors} colors={colors}
params={params} params={params}
label={ label={
@ -451,6 +460,7 @@ function WidgetChart(props: Props) {
return ( return (
<BugNumChart <BugNumChart
values={values} values={values}
height={height}
inGrid={!props.isPreview} inGrid={!props.isPreview}
colors={colors} colors={colors}
onSeriesFocus={onFocus} onSeriesFocus={onFocus}
@ -470,6 +480,7 @@ function WidgetChart(props: Props) {
<CustomMetricTableSessions <CustomMetricTableSessions
metric={_metric} metric={_metric}
data={data} data={data}
height={height}
isTemplate={isTemplate} isTemplate={isTemplate}
isEdit={!isSaved && !isTemplate} isEdit={!isSaved && !isTemplate}
/> />
@ -480,6 +491,7 @@ function WidgetChart(props: Props) {
<CustomMetricTableErrors <CustomMetricTableErrors
metric={_metric} metric={_metric}
data={data} data={data}
height={height}
// isTemplate={isTemplate} // isTemplate={isTemplate}
isEdit={!isSaved && !isTemplate} isEdit={!isSaved && !isTemplate}
/> />
@ -490,6 +502,7 @@ function WidgetChart(props: Props) {
<SessionsBy <SessionsBy
metric={_metric} metric={_metric}
data={data} data={data}
height={height}
onClick={onChartClick} onClick={onChartClick}
isTemplate={isTemplate} isTemplate={isTemplate}
/> />
@ -518,18 +531,18 @@ function WidgetChart(props: Props) {
</div> </div>
); );
} }
return <ClickMapCard />; return <ClickMapCard height={height} />;
} }
if (metricType === INSIGHTS) { if (metricType === INSIGHTS) {
return <InsightsCard data={data} />; return <InsightsCard height={height} data={data} />;
} }
if (metricType === USER_PATH && data && data.links) { if (metricType === USER_PATH && data && data.links) {
const isUngrouped = props.isPreview const isUngrouped = props.isPreview
? !(_metric.hideExcess ?? true) ? !(_metric.hideExcess ?? true)
: false; : false;
const height = props.isPreview ? 550 : 240; const height = props.height ? props.height : props.isPreview ? 550 : 240;
return ( return (
<SankeyChart <SankeyChart
height={height} height={height}
@ -548,6 +561,7 @@ function WidgetChart(props: Props) {
if (viewType === 'trend') { if (viewType === 'trend') {
return ( return (
<LineChart <LineChart
height={height}
data={data} data={data}
colors={colors} colors={colors}
params={params} params={params}

View file

@ -29,6 +29,12 @@ function WidgetPreview(props: Props) {
metric.viewType, metric.viewType,
); );
// [rangeStart, rangeEnd] or [period_name] -- have to check options // [rangeStart, rangeEnd] or [period_name] -- have to check options
React.useEffect(() => {
// otherwise data obj change won't be registered if you get data -> change page -> go back
return () => metricStore.init();
}, []);
const presetComparison = metric.compareTo; const presetComparison = metric.compareTo;
return ( return (
<div className={cn(className, 'bg-white rounded-xl border shadow-sm mt-0')}> <div className={cn(className, 'bg-white rounded-xl border shadow-sm mt-0')}>

View file

@ -1,7 +1,7 @@
import { useHistory } from 'react-router'; import { useHistory } from 'react-router';
import { useStore } from 'App/mstore'; import { useStore } from 'App/mstore';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { Button, Dropdown, MenuProps, Modal } from 'antd'; import { Button, Dropdown, MenuProps } from 'antd';
import { import {
BellIcon, BellIcon,
EllipsisVertical, EllipsisVertical,
@ -14,6 +14,7 @@ import { useModal } from 'Components/ModalContext';
import AlertFormModal from 'Components/Alerts/AlertFormModal/AlertFormModal'; import AlertFormModal from 'Components/Alerts/AlertFormModal/AlertFormModal';
import { showAddToDashboardModal } from 'Components/Dashboard/components/AddToDashboardButton'; import { showAddToDashboardModal } from 'Components/Dashboard/components/AddToDashboardButton';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { confirm } from 'UI';
function CardViewMenu() { function CardViewMenu() {
const { t } = useTranslation(); const { t } = useTranslation();
@ -51,29 +52,25 @@ function CardViewMenu() {
label: t('Delete'), label: t('Delete'),
icon: <TrashIcon size={15} />, icon: <TrashIcon size={15} />,
disabled: !widget.exists(), disabled: !widget.exists(),
onClick: () => { onClick: async () => {
Modal.confirm({ if (
title: t('Confirm Card Deletion'), await confirm({
icon: null, header: t('Remove Card'),
content: confirmButton: t('Remove'),
t('Are you sure you want to remove this card? This action is permanent and cannot be undone.'), confirmation: t(
footer: (_, { OkBtn, CancelBtn }) => ( 'Are you sure you want to remove this card? This action is permanent and cannot be undone.',
<> ),
<CancelBtn /> })
<OkBtn /> ) {
</> metricStore
), .delete(widget)
onOk: () => { .then(() => {
metricStore history.goBack();
.delete(widget) })
.then(() => { .catch(() => {
history.goBack(); toast.error(t('Failed to remove card'));
}) });
.catch(() => { }
toast.error(t('Failed to remove card'));
});
},
});
}, },
}, },
]; ];

View file

@ -72,7 +72,6 @@ function WidgetView({
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
const mk = params.get('mk'); const mk = params.get('mk');
if (mk) { if (mk) {
metricStore.init();
const selectedCard = CARD_LIST(t).find((c) => c.key === mk) as CardType; const selectedCard = CARD_LIST(t).find((c) => c.key === mk) as CardType;
if (selectedCard) { if (selectedCard) {
const cardData: any = { const cardData: any = {

View file

@ -1,11 +1,10 @@
import React from 'react'; import React from 'react';
import { Table, Tooltip } from 'antd'; import { Table, Dropdown } from 'antd';
import type { TableProps } from 'antd'; import type { TableProps } from 'antd';
import Widget from 'App/mstore/types/widget'; import Widget from 'App/mstore/types/widget';
import Funnel from 'App/mstore/types/funnel'; import Funnel from 'App/mstore/types/funnel';
import { ItemMenu } from 'UI';
import { EllipsisVertical } from 'lucide-react'; import { EllipsisVertical } from 'lucide-react';
import { exportAntCsv } from '../../../utils'; import { exportAntCsv } from 'App/utils';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
interface Props { interface Props {
@ -111,19 +110,20 @@ export function TableExporter({
const { t } = useTranslation(); const { t } = useTranslation();
const onClick = () => exportAntCsv(tableColumns, tableData, filename); const onClick = () => exportAntCsv(tableColumns, tableData, filename);
return ( return (
<Tooltip title={t('Export Data to CSV')}> <div
<div className={`absolute ${top || 'top-0'} ${right || '-right-1'}`}> className={`absolute ${top || 'top-0'} ${right || '-right-1'}`}
<ItemMenu style={{ zIndex: 10 }}
items={[{ icon: 'download', text: 'Export to CSV', onClick }]} >
bold <Dropdown
customTrigger={ menu={{
<div className="flex items-center justify-center bg-gradient-to-r from-[#fafafa] to-neutral-200 cursor-pointer rounded-lg h-[38px] w-[38px] btn-export-table-data"> items: [{ key: 'download', label: 'Export to CSV', onClick }],
<EllipsisVertical size={16} /> }}
</div> >
} <div className="flex items-center justify-center bg-gray-lighter cursor-pointer rounded-lg h-[38px] w-[38px] btn-export-table-data">
/> <EllipsisVertical size={16} />
</div> </div>
</Tooltip> </Dropdown>
</div>
); );
} }

View file

@ -10,13 +10,16 @@ import { useStore } from 'App/mstore';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { useHistory, useLocation } from 'react-router-dom'; import { useHistory, useLocation } from 'react-router-dom';
import ChatsModal from './components/ChatsModal'; import ChatsModal from './components/ChatsModal';
import { kaiStore } from './KaiStore';
function KaiChat() { function KaiChat() {
const { userStore, projectsStore } = useStore(); const { userStore, projectsStore } = useStore();
const history = useHistory(); const history = useHistory();
const [chatTitle, setTitle] = React.useState<string | null>(null); const chatTitle = kaiStore.chatTitle;
const setTitle = kaiStore.setTitle;
const userId = userStore.account.id; const userId = userStore.account.id;
const userLetter = userStore.account.name[0].toUpperCase(); const userName = userStore.account.name;
const limited = kaiStore.usage.percent >= 100;
const { activeSiteId } = projectsStore; const { activeSiteId } = projectsStore;
const [section, setSection] = React.useState<'intro' | 'chat'>('intro'); const [section, setSection] = React.useState<'intro' | 'chat'>('intro');
const [threadId, setThreadId] = React.useState<string | null>(null); const [threadId, setThreadId] = React.useState<string | null>(null);
@ -36,13 +39,18 @@ function KaiChat() {
showModal( showModal(
<ChatsModal <ChatsModal
projectId={activeSiteId!} projectId={activeSiteId!}
onHide={hideModal}
onSelect={(threadId: string, title: string) => { onSelect={(threadId: string, title: string) => {
setTitle(title); setTitle(title);
setThreadId(threadId); setThreadId(threadId);
hideModal(); hideModal();
}} }}
/>, />,
{ right: true, width: 300 }, {
right: true,
width: 320,
className: 'bg-none flex items-center h-screen',
},
); );
}; };
@ -91,44 +99,77 @@ function KaiChat() {
const newThread = await kaiService.createKaiChat(activeSiteId); const newThread = await kaiService.createKaiChat(activeSiteId);
if (newThread) { if (newThread) {
setThreadId(newThread.toString()); setThreadId(newThread.toString());
kaiStore.setTitle(null);
setSection('chat'); setSection('chat');
} else { } else {
toast.error("Something wen't wrong. Please try again later."); toast.error("Something wen't wrong. Please try again later.");
} }
}; };
const onCancel = () => {
if (!threadId) return;
void kaiStore.cancelGeneration({
projectId: activeSiteId,
threadId,
});
};
return ( return (
<div className="w-full mx-auto" style={{ maxWidth: PANEL_SIZES.maxWidth }}> <div
className="w-full mx-auto h-full"
style={{ maxWidth: PANEL_SIZES.maxWidth }}
>
<div <div
className={'w-full rounded-lg overflow-hidden border shadow relative'} className={
'w-full rounded-lg overflow-hidden bg-white relative h-full reset'
}
> >
<ChatHeader <ChatHeader
chatTitle={chatTitle} chatTitle={chatTitle}
openChats={openChats} openChats={openChats}
goBack={goBack} goBack={goBack}
onCreate={onCreate}
/> />
<div {section === 'intro' ? (
className={ <>
'w-full bg-active-blue flex flex-col items-center justify-center py-4 relative' <div
} className={
style={{ 'flex flex-col items-center justify-center py-4 relative'
height: '70svh', }
background: style={{
'radial-gradient(50% 50% at 50% 50%, var(--color-glassWhite) 0%, var(--color-glassMint) 46%, var(--color-glassLavander) 100%)', position: 'absolute',
}} top: '50%',
> left: 0,
{section === 'intro' ? ( width: '100%',
<IntroSection onAsk={onCreate} /> transform: 'translateY(-50%)',
) : ( }}
<ChatLog >
threadId={threadId} <IntroSection
projectId={activeSiteId} onCancel={onCancel}
userLetter={userLetter} onAsk={onCreate}
onTitleChange={setTitle} projectId={activeSiteId}
initialMsg={initialMsg} userName={userName}
setInitialMsg={setInitialMsg} limited={limited}
/> />
)} </div>
</div> <div
className={
'text-disabled-text absolute bottom-4 left-0 right-0 text-center text-sm'
}
>
OpenReplay AI can make mistakes. Verify its outputs.
</div>
</>
) : (
<ChatLog
threadId={threadId}
projectId={activeSiteId}
chatTitle={chatTitle}
initialMsg={initialMsg}
setInitialMsg={setInitialMsg}
onCancel={onCancel}
/>
)}
</div> </div>
</div> </div>
); );

View file

@ -26,8 +26,8 @@ export default class KaiService extends AiService {
getKaiChat = async ( getKaiChat = async (
projectId: string, projectId: string,
threadId: string, threadId: string,
): Promise< ): Promise<{
{ messages: {
role: string; role: string;
content: string; content: string;
message_id: any; message_id: any;
@ -36,8 +36,10 @@ export default class KaiService extends AiService {
supports_visualization: boolean; supports_visualization: boolean;
chart: string; chart: string;
chart_data: string; chart_data: string;
}[] sessions?: Record<string, any>[];
> => { }[];
title: string;
}> => {
const r = await this.client.get(`/kai/${projectId}/chats/${threadId}`); const r = await this.client.get(`/kai/${projectId}/chats/${threadId}`);
if (!r.ok) { if (!r.ok) {
throw new Error('Failed to fetch chat'); throw new Error('Failed to fetch chat');
@ -84,7 +86,7 @@ export default class KaiService extends AiService {
getMsgChart = async ( getMsgChart = async (
messageId: string, messageId: string,
projectId: string, projectId: string,
): Promise<{ filters: any[]; chart: string; eventsOrder: string }> => { ): Promise<string> => {
const r = await this.client.get( const r = await this.client.get(
`/kai/${projectId}/chats/data/${messageId}`, `/kai/${projectId}/chats/data/${messageId}`,
); );
@ -122,4 +124,19 @@ export default class KaiService extends AiService {
const data = await r.json(); const data = await r.json();
return data; return data;
}; };
getPromptSuggestions = async (
projectId: string,
threadId?: string | null,
): Promise<string[]> => {
const endpoint = threadId
? `/kai/${projectId}/chats/${threadId}/prompt-suggestions`
: `/kai/${projectId}/prompt-suggestions`;
const r = await this.client.get(endpoint);
if (!r.ok) {
throw new Error('Failed to fetch prompt suggestions');
}
const data = await r.json();
return data;
};
} }

View file

@ -3,6 +3,7 @@ import { BotChunk, ChatManager } from './SocketManager';
import { kaiService as aiService, kaiService } from 'App/services'; import { kaiService as aiService, kaiService } from 'App/services';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import Widget from 'App/mstore/types/widget'; import Widget from 'App/mstore/types/widget';
import Session, { ISession } from '@/types/session/session';
export interface Message { export interface Message {
text: string; text: string;
@ -15,6 +16,7 @@ export interface Message {
supports_visualization: boolean; supports_visualization: boolean;
feedback: boolean | null; feedback: boolean | null;
duration: number; duration: number;
sessions?: Session[];
} }
export interface SentMessage export interface SentMessage
extends Omit< extends Omit<
@ -29,6 +31,7 @@ class KaiStore {
processingStage: BotChunk | null = null; processingStage: BotChunk | null = null;
messages: Array<Message> = []; messages: Array<Message> = [];
queryText = ''; queryText = '';
chatTitle: string | null = null;
loadingChat = false; loadingChat = false;
replacing: string | null = null; replacing: string | null = null;
usage = { usage = {
@ -56,6 +59,20 @@ class KaiStore {
return { msg, index }; return { msg, index };
} }
get firstHumanMessage() {
let msg = null;
let index = null;
for (let i = 0; i < this.messages.length; i++) {
const message = this.messages[i];
if (message.isUser) {
msg = message;
index = i;
break;
}
}
return { msg, index };
}
get lastKaiMessage() { get lastKaiMessage() {
let msg = null; let msg = null;
let index = null; let index = null;
@ -70,6 +87,14 @@ class KaiStore {
return { msg, index }; return { msg, index };
} }
getPreviousMessage = (messageId: string) => {
const index = this.messages.findIndex((msg) => msg.messageId === messageId);
if (index > 0) {
return this.messages[index - 1];
}
return null;
};
setQueryText = (text: string) => { setQueryText = (text: string) => {
this.queryText = text; this.queryText = text;
}; };
@ -113,13 +138,21 @@ class KaiStore {
}); });
}; };
setTitle = (title: string | null) => {
this.chatTitle = title;
};
getChat = async (projectId: string, threadId: string) => { getChat = async (projectId: string, threadId: string) => {
this.setLoadingChat(true); this.setLoadingChat(true);
try { try {
const res = await aiService.getKaiChat(projectId, threadId); const { messages, title } = await aiService.getKaiChat(
if (res && res.length) { projectId,
threadId,
);
if (messages && messages.length) {
this.setTitle(title);
this.setMessages( this.setMessages(
res.map((m) => { messages.map((m) => {
const isUser = m.role === 'human'; const isUser = m.role === 'human';
return { return {
text: m.content, text: m.content,
@ -130,6 +163,9 @@ class KaiStore {
chart: m.chart, chart: m.chart,
supports_visualization: m.supports_visualization, supports_visualization: m.supports_visualization,
chart_data: m.chart_data, chart_data: m.chart_data,
sessions: m.sessions
? m.sessions.map((s) => new Session(s))
: undefined,
}; };
}), }),
); );
@ -144,7 +180,6 @@ class KaiStore {
createChatManager = ( createChatManager = (
settings: { projectId: string; threadId: string }, settings: { projectId: string; threadId: string },
setTitle: (title: string) => void,
initialMsg: string | null, initialMsg: string | null,
) => { ) => {
const token = kaiService.client.getJwt(); const token = kaiService.client.getJwt();
@ -190,6 +225,9 @@ class KaiStore {
chart: '', chart: '',
supports_visualization: msg.supports_visualization, supports_visualization: msg.supports_visualization,
chart_data: '', chart_data: '',
sessions: msg.sessions
? msg.sessions.map((s) => new Session(s))
: undefined,
}; };
this.bumpUsage(); this.bumpUsage();
this.addMessage(msgObj); this.addMessage(msgObj);
@ -197,7 +235,7 @@ class KaiStore {
} }
} }
}, },
titleCallback: setTitle, titleCallback: this.setTitle,
}); });
if (initialMsg) { if (initialMsg) {
@ -211,7 +249,13 @@ class KaiStore {
bumpUsage = () => { bumpUsage = () => {
this.usage.used += 1; this.usage.used += 1;
this.usage.percent = (this.usage.used / this.usage.total) * 100; this.usage.percent = Math.min(
(this.usage.used / this.usage.total) * 100,
100,
);
if (this.usage.used >= this.usage.total) {
toast.error('You have reached the daily limit for queries.');
}
}; };
sendMessage = (message: string) => { sendMessage = (message: string) => {
@ -232,7 +276,7 @@ class KaiStore {
deleting.push(this.lastKaiMessage.index); deleting.push(this.lastKaiMessage.index);
} }
this.deleteAtIndex(deleting); this.deleteAtIndex(deleting);
this.setReplacing(false); this.setReplacing(null);
} }
this.addMessage({ this.addMessage({
text: message, text: message,
@ -273,7 +317,6 @@ class KaiStore {
cancelGeneration = async (settings: { cancelGeneration = async (settings: {
projectId: string; projectId: string;
userId: string;
threadId: string; threadId: string;
}) => { }) => {
try { try {
@ -298,6 +341,7 @@ class KaiStore {
} }
}; };
charts = new Map<string, Record<string, any>>();
getMessageChart = async (msgId: string, projectId: string) => { getMessageChart = async (msgId: string, projectId: string) => {
this.setProcessingStage({ this.setProcessingStage({
content: 'Generating visualization...', content: 'Generating visualization...',
@ -308,27 +352,16 @@ class KaiStore {
supports_visualization: false, supports_visualization: false,
}); });
try { try {
const filters = await kaiService.getMsgChart(msgId, projectId); const filtersStr = await kaiService.getMsgChart(msgId, projectId);
if (!filtersStr.length) {
throw new Error('No filters found for the message');
}
const filters = JSON.parse(filtersStr);
const data = { const data = {
metricId: undefined, ...filters,
dashboardId: undefined,
widgetId: undefined,
metricOf: undefined,
metricType: undefined,
metricFormat: undefined,
viewType: undefined,
name: 'Kai Visualization',
series: [
{
name: 'Kai Visualization',
filter: {
eventsOrder: filters.eventsOrder,
filters: filters.filters,
},
},
],
}; };
const metric = new Widget().fromJson(data); const metric = new Widget().fromJson(data);
this.charts.set(msgId, data);
return metric; return metric;
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@ -338,19 +371,31 @@ class KaiStore {
} }
}; };
saveLatestChart = async (msgId: string, projectId: string) => {
const data = this.charts.get(msgId);
if (data) {
try {
await kaiService.saveChartData(msgId, projectId, data);
this.charts.delete(msgId);
} catch (e) {
console.error(e);
}
}
};
getParsedChart = (data: string) => { getParsedChart = (data: string) => {
const parsedData = JSON.parse(data); const parsedData = JSON.parse(data);
return new Widget().fromJson(parsedData); return new Widget().fromJson(parsedData);
}; };
setUsage = (usage: { total: number; used: number; percent: number }) => {
this.usage = usage;
};
checkUsage = async () => { checkUsage = async () => {
try { try {
const { total, used } = await kaiService.checkUsage(); const { total, used } = await kaiService.checkUsage();
this.usage = { this.setUsage({ total, used, percent: Math.round((used / total) * 100) });
total,
used,
percent: (used / total) * 100,
};
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} }

View file

@ -1,5 +1,6 @@
import io from 'socket.io-client'; import io from 'socket.io-client';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { ISession } from '@/types/session/session';
export class ChatManager { export class ChatManager {
socket: ReturnType<typeof io>; socket: ReturnType<typeof io>;
@ -77,9 +78,7 @@ export class ChatManager {
msgCallback, msgCallback,
titleCallback, titleCallback,
}: { }: {
msgCallback: ( msgCallback: (msg: StateEvent | BotChunk) => void;
msg: StateEvent | BotChunk,
) => void;
titleCallback: (title: string) => void; titleCallback: (title: string) => void;
}) => { }) => {
this.socket.on('chunk', (msg: BotChunk) => { this.socket.on('chunk', (msg: BotChunk) => {
@ -111,7 +110,8 @@ export interface BotChunk {
messageId: string; messageId: string;
duration: number; duration: number;
supports_visualization: boolean; supports_visualization: boolean;
type: 'chunk' sessions?: ISession[];
type: 'chunk';
} }
interface StateEvent { interface StateEvent {

View file

@ -1,56 +1,58 @@
import React from 'react'; import React from 'react';
import { Icon } from 'UI'; import { Icon } from 'UI';
import { MessagesSquare, ArrowLeft } from 'lucide-react'; import { MessagesSquare, ArrowLeft, SquarePen } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
function ChatHeader({ function ChatHeader({
openChats = () => {}, openChats = () => {},
goBack, goBack,
chatTitle, chatTitle,
onCreate,
}: { }: {
goBack?: () => void; goBack?: () => void;
openChats?: () => void; openChats?: () => void;
chatTitle: string | null; chatTitle: string | null;
onCreate: () => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div <div className="p-4 pb-0 w-full">
className={ <div
'px-4 py-2 flex items-center bg-white border-b border-b-gray-lighter' className={'px-4 py-2 flex items-center bg-gray-lightest rounded-lg'}
} >
> <div className={'flex-1'}>
<div className={'flex-1'}> {goBack ? (
{goBack ? ( <div
className={
'w-fit flex items-center gap-2 font-semibold cursor-pointer hover:text-main'
}
onClick={goBack}
>
<SquarePen size={14} />
<div>{t('New Chat')}</div>
</div>
) : (
<div className="flex items-center gap-2">
<Icon name={'kai-mono'} size={18} />
<div className={'font-semibold text-xl'}>Kai</div>
</div>
)}
</div>
<div className={'flex items-center gap-2 mx-auto max-w-[80%]'}>
{chatTitle ? (
<div className="font-semibold text-xl whitespace-nowrap truncate">
{chatTitle}
</div>
) : null}
</div>
<div className={'flex-1 justify-end flex items-center'}>
<div <div
className={ className="font-semibold w-fit cursor-pointer hover:text-main flex items-center gap-2"
'w-fit flex items-center gap-2 font-semibold cursor-pointer' onClick={openChats}
}
onClick={goBack}
> >
<ArrowLeft size={14} /> <MessagesSquare size={14} />
<div>{t('Back')}</div> <div>{t('Chats')}</div>
</div> </div>
) : null}
</div>
<div className={'flex items-center gap-2 mx-auto max-w-[80%]'}>
{chatTitle ? (
<div className="font-semibold text-xl whitespace-nowrap truncate">
{chatTitle}
</div>
) : (
<>
<Icon name={'kai_colored'} size={18} />
<div className={'font-semibold text-xl'}>Kai</div>
</>
)}
</div>
<div className={'flex-1 justify-end flex items-center gap-2'}>
<div
className="font-semibold w-fit cursor-pointer flex items-center gap-2"
onClick={openChats}
>
<MessagesSquare size={14} />
<div>{t('Chats')}</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { Button, Input, Tooltip } from 'antd'; import { Button, Input, Tooltip } from 'antd';
import { SendHorizonal, OctagonX } from 'lucide-react'; import { X, ArrowUp } from 'lucide-react';
import { kaiStore } from '../KaiStore'; import { kaiStore } from '../KaiStore';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import Usage from './Usage'; import Usage from './Usage';
@ -8,11 +8,13 @@ import Usage from './Usage';
function ChatInput({ function ChatInput({
isLoading, isLoading,
onSubmit, onSubmit,
threadId, isArea,
onCancel,
}: { }: {
isLoading?: boolean; isLoading?: boolean;
onSubmit: (str: string) => void; onSubmit: (str: string) => void;
threadId: string; onCancel: () => void;
isArea?: boolean;
}) { }) {
const inputRef = React.useRef<typeof Input>(null); const inputRef = React.useRef<typeof Input>(null);
const usage = kaiStore.usage; const usage = kaiStore.usage;
@ -23,13 +25,14 @@ function ChatInput({
kaiStore.setQueryText(text); kaiStore.setQueryText(text);
}; };
const submit = () => { const submit = (e: any) => {
e.preventDefault();
e.stopPropagation();
if (limited) { if (limited) {
return; return;
} }
if (isProcessing) { if (isProcessing) {
const settings = { projectId: '2325', userId: '0', threadId }; onCancel();
void kaiStore.cancelGeneration(settings);
} else { } else {
if (inputValue.length > 0) { if (inputValue.length > 0) {
onSubmit(inputValue); onSubmit(inputValue);
@ -50,9 +53,36 @@ function ChatInput({
}, [inputValue]); }, [inputValue]);
const isReplacing = kaiStore.replacing !== null; const isReplacing = kaiStore.replacing !== null;
const placeholder = limited
? `You've reached the daily limit for queries, come again tomorrow!`
: 'Ask anything about your product and users...';
if (isArea) {
return (
<div className="relative">
<Input.TextArea
rows={3}
className="!resize-none rounded-lg shadow"
onPressEnter={submit}
ref={inputRef}
placeholder={placeholder}
size={'large'}
disabled={limited}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
/>
<div className="absolute bottom-2 right-2">
<SendButton
isLoading={isLoading}
submit={submit}
limited={limited}
isProcessing={isProcessing}
/>
</div>
</div>
);
}
return ( return (
<div className="relative"> <div className="relative bg-white">
<Input <Input
onPressEnter={submit} onPressEnter={submit}
onKeyDown={(e) => { onKeyDown={(e) => {
@ -61,12 +91,9 @@ function ChatInput({
} }
}} }}
ref={inputRef} ref={inputRef}
placeholder={ placeholder={placeholder}
limited
? `You've reached the daily limit for queries, come again tomorrow!`
: 'Ask anything about your product and users...'
}
size={'large'} size={'large'}
className="rounded-lg shadow"
disabled={limited} disabled={limited}
value={inputValue} value={inputValue}
onChange={(e) => setInputValue(e.target.value)} onChange={(e) => setInputValue(e.target.value)}
@ -76,31 +103,19 @@ function ChatInput({
<Tooltip title={'Cancel Editing'}> <Tooltip title={'Cancel Editing'}>
<Button <Button
onClick={cancelReplace} onClick={cancelReplace}
icon={<OctagonX size={16} />} icon={<X className="reset" size={16} />}
type={'text'}
size={'small'} size={'small'}
shape={'circle'} shape={'circle'}
disabled={limited} disabled={limited}
/> />
</Tooltip> </Tooltip>
) : null} ) : null}
<Tooltip title={'Send message'}> <SendButton
<Button isLoading={isLoading}
loading={isLoading} submit={submit}
onClick={submit} limited={limited}
disabled={limited} isProcessing={isProcessing}
icon={ />
isProcessing ? (
<OctagonX size={16} />
) : (
<SendHorizonal size={16} />
)
}
type={'text'}
size={'small'}
shape={'circle'}
/>
</Tooltip>
</> </>
} }
/> />
@ -111,4 +126,42 @@ function ChatInput({
); );
} }
function SendButton({
isLoading,
submit,
limited,
isProcessing,
}: {
isLoading?: boolean;
submit: () => void;
limited: boolean;
isProcessing?: boolean;
}) {
return (
<Tooltip title={isProcessing ? 'Cancel processing' : 'Send message'}>
<Button
loading={isLoading}
onClick={submit}
disabled={limited}
icon={
isProcessing ? (
<X size={16} strokeWidth={2} className="reset" />
) : (
<div className="bg-[#fff] text-main rounded-full">
<ArrowUp size={14} strokeWidth={2} className="reset" />
</div>
)
}
type={'primary'}
size={'small'}
shape={isProcessing ? 'circle' : 'round'}
iconPosition={'end'}
className="font-semibold text-[#fff]"
>
{isProcessing ? null : 'Ask'}
</Button>
</Tooltip>
);
}
export default observer(ChatInput); export default observer(ChatInput);

View file

@ -1,25 +1,28 @@
import React from 'react'; import React from 'react';
import ChatInput from './ChatInput'; import ChatInput from './ChatInput';
import ChatMsg, { ChatNotice } from './ChatMsg'; import ChatMsg, { ChatNotice } from './ChatMsg';
import Ideas from './Ideas';
import { Loader } from 'UI'; import { Loader } from 'UI';
import { kaiStore } from '../KaiStore'; import { kaiStore } from '../KaiStore';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import EmbedPlayer from './EmbedPlayer';
function ChatLog({ function ChatLog({
projectId, projectId,
threadId, threadId,
userLetter,
onTitleChange,
initialMsg, initialMsg,
chatTitle,
setInitialMsg, setInitialMsg,
onCancel,
}: { }: {
projectId: string; projectId: string;
threadId: any; threadId: any;
userLetter: string;
onTitleChange: (title: string | null) => void;
initialMsg: string | null; initialMsg: string | null;
setInitialMsg: (msg: string | null) => void; setInitialMsg: (msg: string | null) => void;
chatTitle: string | null;
onCancel: () => void;
}) { }) {
const [embedSession, setEmbedSession] = React.useState<any>(null);
const messages = kaiStore.messages; const messages = kaiStore.messages;
const loading = kaiStore.loadingChat; const loading = kaiStore.loadingChat;
const chatRef = React.useRef<HTMLDivElement>(null); const chatRef = React.useRef<HTMLDivElement>(null);
@ -31,7 +34,7 @@ function ChatLog({
void kaiStore.getChat(settings.projectId, threadId); void kaiStore.getChat(settings.projectId, threadId);
} }
if (threadId) { if (threadId) {
kaiStore.createChatManager(settings, onTitleChange, initialMsg); kaiStore.createChatManager(settings, initialMsg);
} }
return () => { return () => {
kaiStore.clearChat(); kaiStore.clearChat();
@ -50,23 +53,38 @@ function ChatLog({
}); });
}, [messages.length, processingStage]); }, [messages.length, processingStage]);
const lastHumanMsgInd: null | number = kaiStore.lastHumanMessage.index; const lastKaiMessageInd: null | number = kaiStore.lastKaiMessage.index;
const lastHumanMsgInd: number | null = kaiStore.lastHumanMessage.index;
const showIdeas =
!processingStage && lastKaiMessageInd === messages.length - 1;
return ( return (
<Loader loading={loading} className={'w-full h-full'}> <Loader loading={loading} className={'w-full h-full'}>
<div <div
ref={chatRef} ref={chatRef}
style={{ maxHeight: 'calc(100svh - 165px)' }}
className={ className={
'overflow-y-auto relative flex flex-col items-center justify-between w-full h-full' 'overflow-y-auto relative flex flex-col items-center justify-between w-full h-full pt-4'
} }
> >
<div className={'flex flex-col gap-4 w-2/3 min-h-max'}> {embedSession ? (
<EmbedPlayer
session={embedSession}
onClose={() => setEmbedSession(null)}
/>
) : null}
<div className={'flex flex-col gap-2 w-2/3 min-h-max'}>
{messages.map((msg, index) => ( {messages.map((msg, index) => (
<React.Fragment key={msg.messageId ?? index}> <React.Fragment key={msg.messageId ?? index}>
<ChatMsg <ChatMsg
userName={userLetter}
siteId={projectId} siteId={projectId}
message={msg} message={msg}
canEdit={processingStage === null && msg.isUser && index === lastHumanMsgInd} chatTitle={chatTitle}
onReplay={(session) => setEmbedSession(session)}
canEdit={
processingStage === null &&
msg.isUser &&
index === lastHumanMsgInd
}
/> />
</React.Fragment> </React.Fragment>
))} ))}
@ -76,9 +94,17 @@ function ChatLog({
duration={processingStage.duration} duration={processingStage.duration}
/> />
) : null} ) : null}
{showIdeas ? (
<Ideas
onClick={(query) => onSubmit(query)}
projectId={projectId}
threadId={threadId}
inChat
/>
) : null}
</div> </div>
<div className={'sticky bottom-0 pt-6 w-2/3'}> <div className={'sticky bottom-0 pt-6 w-2/3 z-50'}>
<ChatInput onSubmit={onSubmit} threadId={threadId} /> <ChatInput onCancel={onCancel} onSubmit={onSubmit} />
</div> </div>
</div> </div>
</Loader> </Loader>

View file

@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { Icon, CopyButton } from 'UI'; import { CopyButton, Icon } from 'UI';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import cn from 'classnames'; import cn from 'classnames';
import Markdown from 'react-markdown'; import Markdown from 'react-markdown';
@ -8,8 +8,7 @@ import {
Loader, Loader,
ThumbsUp, ThumbsUp,
ThumbsDown, ThumbsDown,
ListRestart, SquarePen,
FileDown,
Clock, Clock,
ChartLine, ChartLine,
} from 'lucide-react'; } from 'lucide-react';
@ -20,17 +19,22 @@ import { durationFormatted } from 'App/date';
import WidgetChart from '@/components/Dashboard/components/WidgetChart'; import WidgetChart from '@/components/Dashboard/components/WidgetChart';
import Widget from 'App/mstore/types/widget'; import Widget from 'App/mstore/types/widget';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import SessionItem from 'Shared/SessionItem';
function ChatMsg({ function ChatMsg({
userName, userName,
siteId, siteId,
canEdit, canEdit,
message, message,
chatTitle,
onReplay,
}: { }: {
message: Message; message: Message;
userName?: string; userName?: string;
canEdit?: boolean; canEdit?: boolean;
siteId: string; siteId: string;
chatTitle: string | null;
onReplay: (session: any) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [metric, setMetric] = React.useState<Widget | null>(null); const [metric, setMetric] = React.useState<Widget | null>(null);
@ -47,13 +51,14 @@ function ChatMsg({
const isEditing = kaiStore.replacing && messageId === kaiStore.replacing; const isEditing = kaiStore.replacing && messageId === kaiStore.replacing;
const [isProcessing, setIsProcessing] = React.useState(false); const [isProcessing, setIsProcessing] = React.useState(false);
const bodyRef = React.useRef<HTMLDivElement>(null); const bodyRef = React.useRef<HTMLDivElement>(null);
const chartRef = React.useRef<HTMLDivElement>(null);
const onEdit = () => { const onEdit = () => {
kaiStore.editMessage(text, messageId); kaiStore.editMessage(text, messageId);
}; };
const onCancelEdit = () => { const onCancelEdit = () => {
kaiStore.setQueryText(''); kaiStore.setQueryText('');
kaiStore.setReplacing(null); kaiStore.setReplacing(null);
} };
const onFeedback = (feedback: 'like' | 'dislike', messageId: string) => { const onFeedback = (feedback: 'like' | 'dislike', messageId: string) => {
kaiStore.sendMsgFeedback(feedback, messageId, siteId); kaiStore.sendMsgFeedback(feedback, messageId, siteId);
}; };
@ -65,19 +70,79 @@ function ChatMsg({
setIsProcessing(false); setIsProcessing(false);
return; return;
} }
const userPrompt = kaiStore.getPreviousMessage(message.messageId);
import('jspdf') import('jspdf')
.then(({ jsPDF }) => { .then(async ({ jsPDF }) => {
const doc = new jsPDF(); const doc = new jsPDF();
doc.addImage('/assets/img/logo-img.png', 80, 3, 30, 5); const blockWidth = 170; // mm
doc.html(bodyRef.current!, { const content = bodyRef.current!.cloneNode(true) as HTMLElement;
if (userPrompt) {
const titleHeader = document.createElement('h2');
titleHeader.textContent = userPrompt.text;
titleHeader.style.marginBottom = '10px';
content.prepend(titleHeader);
}
// insert logo /assets/img/logo-img.png
const logo = new Image();
logo.src = '/assets/img/logo-img.png';
logo.style.width = '130px';
const container = document.createElement('div');
container.style.display = 'flex';
container.style.alignItems = 'center';
container.style.justifyContent = 'center';
container.style.marginBottom = '10mm';
container.style.width = `${blockWidth}mm`;
container.appendChild(logo);
content.prepend(container);
content.querySelectorAll('ul').forEach((ul) => {
const frag = document.createDocumentFragment();
ul.querySelectorAll('li').forEach((li) => {
const div = document.createElement('div');
div.textContent = '• ' + li.textContent;
frag.appendChild(div);
});
ul.replaceWith(frag);
});
content.querySelectorAll('h1,h2,h3,h4,h5,h6').forEach((el) => {
(el as HTMLElement).style.letterSpacing = '0.5px';
});
content.querySelectorAll('*').forEach((node) => {
node.childNodes.forEach((child) => {
if (child.nodeType === Node.TEXT_NODE) {
const txt = child.textContent || '';
const replaced = txt.replace(/-/g, '');
if (replaced !== txt) child.textContent = replaced;
}
});
});
if (metric && chartRef.current) {
const { default: html2canvas } = await import('html2canvas');
const metricContainer = chartRef.current;
const image = await html2canvas(metricContainer, {
backgroundColor: null,
scale: 2,
});
const imgData = image.toDataURL('image/png');
const imgHeight = (image.height * blockWidth) / image.width;
content.appendChild(
Object.assign(document.createElement('img'), {
src: imgData,
style: `width: ${blockWidth}mm; height: ${imgHeight}mm; margin-top: 10px;`,
}),
);
}
doc.html(content, {
callback: function (doc) { callback: function (doc) {
doc.save('document.pdf'); doc.save((chatTitle ?? 'document') + '.pdf');
}, },
margin: [10, 10, 10, 10], // top, bottom, ?, left
margin: [10, 10, 20, 20],
x: 0, x: 0,
y: 0, y: 0,
width: 190, // Target width // Target width
windowWidth: 675, // Window width for rendering width: blockWidth,
// Window width for rendering
windowWidth: 675,
}); });
}) })
.catch((e) => { .catch((e) => {
@ -107,35 +172,25 @@ function ChatMsg({
setLoadingChart(false); setLoadingChart(false);
} }
}; };
const metricData = metric?.data;
React.useEffect(() => {
if (!chart_data && metricData) {
const hasValues =
metricData.values?.length > 0 || metricData.chart?.length > 0;
if (hasValues) {
kaiStore.saveLatestChart(messageId, siteId);
}
}
}, [metricData, chart_data]);
return ( return (
<div <div className={cn('flex gap-2', isUser ? 'flex-row-reverse' : 'flex-row')}>
className={cn( <div className={'mt-1 flex flex-col group/actions max-w-[60svw]'}>
'flex items-start gap-2',
isUser ? 'flex-row-reverse' : 'flex-row',
)}
>
{isUser ? (
<div
className={
'rounded-full bg-main text-white min-w-8 min-h-8 flex items-center justify-center sticky top-0 shadow'
}
>
<span className={'font-semibold'}>{userName}</span>
</div>
) : (
<div
className={
'rounded-full bg-gray-lightest shadow min-w-8 min-h-8 flex items-center justify-center sticky top-0'
}
>
<Icon name={'kai_colored'} size={18} />
</div>
)}
<div className={'mt-1 flex flex-col'}>
<div <div
className={cn( className={cn(
'markdown-body', 'markdown-body',
isEditing ? 'border-l border-l-main pl-2' : '', isUser ? 'bg-gray-lighter px-4 rounded-full' : '',
isEditing ? '!bg-active-blue' : '',
)} )}
data-openreplay-obscured data-openreplay-obscured
ref={bodyRef} ref={bodyRef}
@ -143,36 +198,56 @@ function ChatMsg({
<Markdown remarkPlugins={[remarkGfm]}>{text}</Markdown> <Markdown remarkPlugins={[remarkGfm]}>{text}</Markdown>
</div> </div>
{metric ? ( {metric ? (
<div className="p-2 border-gray-light rounded-lg shadow"> <div
<WidgetChart metric={metric} isPreview /> ref={chartRef}
className="p-2 border-gray-light rounded-lg shadow bg-glassWhite mb-2"
>
<WidgetChart metric={metric} isPreview height={360} />
</div>
) : null}
{message.sessions ? (
<div className="flex flex-col">
{message.sessions.map((session) => (
<div className="shadow border rounded-2xl overflow-hidden mb-2">
<SessionItem
disableUser
key={session.sessionId}
session={session}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onReplay(session);
}}
slim
/>
</div>
))}
</div> </div>
) : null} ) : null}
{isUser ? ( {isUser ? (
<> <div className="invisible group-hover/actions:visible mt-1 ml-auto flex gap-2 items-center">
<div {canEdit && !isEditing ? (
onClick={onEdit} <IconButton onClick={onEdit} tooltip={t('Edit')}>
className={cn( <SquarePen size={16} />
'ml-auto flex items-center gap-2 px-2', </IconButton>
'rounded-lg border border-gray-medium text-sm cursor-pointer', ) : null}
'hover:border-main hover:text-main w-fit', {isEditing ? (
canEdit && !isEditing ? '' : 'hidden', <Button
)} onClick={onCancelEdit}
> type="text"
<ListRestart size={16} /> size="small"
<div>{t('Edit')}</div> className={'text-xs'}
</div> >
<div {t('Cancel')}
onClick={onCancelEdit} </Button>
className={cn( ) : null}
'ml-auto flex items-center gap-2 px-2', <CopyButton
'rounded-lg border border-gray-medium text-sm cursor-pointer', getHtml={() => bodyRef.current?.innerHTML}
'hover:border-main hover:text-main w-fit', content={text}
isEditing ? '' : 'hidden', isIcon
)} format={'text/html'}
> />
<div>{t('Cancel')}</div> </div>
</div>
</>
) : ( ) : (
<div className={'flex items-center gap-2'}> <div className={'flex items-center gap-2'}>
{duration ? <MsgDuration duration={duration} /> : null} {duration ? <MsgDuration duration={duration} /> : null}
@ -182,14 +257,14 @@ function ChatMsg({
tooltip="Like this answer" tooltip="Like this answer"
onClick={() => onFeedback('like', messageId)} onClick={() => onFeedback('like', messageId)}
> >
<ThumbsUp size={16} /> <ThumbsUp strokeWidth={2} size={16} />
</IconButton> </IconButton>
<IconButton <IconButton
active={feedback === false} active={feedback === false}
tooltip="Dislike this answer" tooltip="Dislike this answer"
onClick={() => onFeedback('dislike', messageId)} onClick={() => onFeedback('dislike', messageId)}
> >
<ThumbsDown size={16} /> <ThumbsDown strokeWidth={2} size={16} />
</IconButton> </IconButton>
{supports_visualization ? ( {supports_visualization ? (
<IconButton <IconButton
@ -197,7 +272,7 @@ function ChatMsg({
onClick={getChart} onClick={getChart}
processing={loadingChart} processing={loadingChart}
> >
<ChartLine size={16} /> <ChartLine strokeWidth={2} size={16} />
</IconButton> </IconButton>
) : null} ) : null}
<CopyButton <CopyButton
@ -211,7 +286,7 @@ function ChatMsg({
tooltip="Export as PDF" tooltip="Export as PDF"
onClick={onExport} onClick={onExport}
> >
<FileDown size={16} /> <Icon name="export-pdf" size={16} />
</IconButton> </IconButton>
</div> </div>
)} )}

View file

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { splitByDate } from '../utils'; import { splitByDate } from '../utils';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { MessagesSquare, Trash } from 'lucide-react'; import { MessagesSquare, Trash, X } from 'lucide-react';
import { kaiService } from 'App/services'; import { kaiService } from 'App/services';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -11,9 +11,11 @@ import { observer } from 'mobx-react-lite';
function ChatsModal({ function ChatsModal({
onSelect, onSelect,
projectId, projectId,
onHide,
}: { }: {
onSelect: (threadId: string, title: string) => void; onSelect: (threadId: string, title: string) => void;
projectId: string; projectId: string;
onHide: () => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { usage } = kaiStore; const { usage } = kaiStore;
@ -44,10 +46,22 @@ function ChatsModal({
refetch(); refetch();
}; };
return ( return (
<div className={'h-screen w-full flex flex-col gap-2 p-4'}> <div
className={'flex flex-col gap-2 p-4 mr-1 rounded-lg bg-white'}
style={{ height: 'calc(-100px + 100svh)', marginTop: 60, width: 310 }}
>
<div className={'flex items-center font-semibold text-lg gap-2'}> <div className={'flex items-center font-semibold text-lg gap-2'}>
<MessagesSquare size={16} /> <MessagesSquare size={16} />
<span>{t('Chats')}</span> <span>{t('Previous Chats')}</span>
<div className="ml-auto" />
<div>
<X
size={16}
strokeWidth={2}
className="cursor-pointer hover:text-main"
onClick={onHide}
/>
</div>
</div> </div>
{usage.percent > 80 ? ( {usage.percent > 80 ? (
<div className="text-red text-sm"> <div className="text-red text-sm">
@ -58,16 +72,20 @@ function ChatsModal({
</div> </div>
) : null} ) : null}
{isPending ? ( {isPending ? (
<div className="animate-pulse text-disabled-text">{t('Loading chats')}...</div> <div className="animate-pulse text-disabled-text">
{t('Loading chats')}...
</div>
) : ( ) : (
<div className="overflow-y-auto flex flex-col gap-2"> <div className="overflow-y-auto flex flex-col gap-2">
{datedCollections.map((col) => ( {datedCollections.map((col, i) => (
<ChatCollection <React.Fragment key={`${i}_${col.date}`}>
data={col.entries} <ChatCollection
date={col.date} data={col.entries}
onSelect={onSelect} date={col.date}
onDelete={onDelete} onSelect={onSelect}
/> onDelete={onDelete}
/>
</React.Fragment>
))} ))}
</div> </div>
)} )}
@ -87,8 +105,8 @@ function ChatCollection({
date: string; date: string;
}) { }) {
return ( return (
<div> <div className="border-b border-b-gray-lighter py-2">
<div className="text-disabled-text">{date}</div> <div className="font-semibold">{date}</div>
<ChatsList data={data} onSelect={onSelect} onDelete={onDelete} /> <ChatsList data={data} onSelect={onSelect} onDelete={onDelete} />
</div> </div>
); );

View file

@ -0,0 +1,75 @@
import React from 'react';
import cn from 'classnames';
import { useStore } from 'App/mstore';
import { Loader } from 'UI';
import { observer } from 'mobx-react-lite';
import MobileClipsPlayer from 'App/components/Session/MobileClipsPlayer';
import ClipsPlayer from 'App/components/Session/ClipsPlayer';
import Session from '@/types/session/session';
interface Clip {
sessionId: string | undefined;
range: [number, number];
message: string;
}
function EmbedPlayer({
session,
onClose,
}: {
session: Session;
onClose: () => void;
}) {
const { projectsStore } = useStore();
const clip = {
sessionId: session.sessionId,
range: [0, session.durationMs],
message: '',
};
const { isMobile } = projectsStore;
const onBgClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
}
};
return (
<div
className="w-screen h-screen fixed top-0 left-0 flex items-center justify-center"
style={{ zIndex: 100, background: 'rgba(0,0,0, 0.15)' }}
onClick={onBgClick}
>
<div
className={cn(
'rounded-lg overflow-hidden',
'rounded shadow boarder bg-white',
)}
style={{ width: 960 }}
>
{isMobile ? (
<MobileClipsPlayer
isHighlight
onClose={onClose}
clip={clip}
currentIndex={0}
isCurrent
autoplay={false}
isFull
/>
) : (
<ClipsPlayer
isHighlight
onClose={onClose}
clip={clip}
currentIndex={0}
isCurrent
autoplay={false}
isFull
/>
)}
</div>
</div>
);
}
export default observer(EmbedPlayer);

View file

@ -1,30 +1,84 @@
import React from 'react'; import React from 'react';
import { Lightbulb, MoveRight } from 'lucide-react'; import cn from 'classnames';
import { useQuery } from '@tanstack/react-query';
import { kaiService } from 'App/services';
import { useTranslation } from 'react-i18next';
function Ideas({ onClick }: { onClick: (query: string) => void }) { function Ideas({
onClick,
projectId,
threadId = null,
inChat,
limited,
}: {
onClick: (query: string) => void;
projectId: string;
threadId?: string | null;
inChat?: boolean;
limited?: boolean;
}) {
const { t } = useTranslation();
const { data: suggestedPromptIdeas = [], isPending } = useQuery({
queryKey: ['kai', projectId, 'chats', threadId, 'prompt-suggestions'],
queryFn: () => kaiService.getPromptSuggestions(projectId, threadId),
staleTime: 1000 * 60,
});
const ideas = React.useMemo(() => {
const defaultPromptIdeas = [
'Top user journeys',
'Where do users drop off',
'Failed network requests today',
];
const result = suggestedPromptIdeas;
const targetSize = 3;
while (result.length < targetSize && defaultPromptIdeas.length) {
result.push(defaultPromptIdeas.pop());
}
return result;
}, [suggestedPromptIdeas.length]);
return ( return (
<> <div>
<div className={'flex items-center gap-2 mb-1 text-gray-dark'}> <div className={'flex items-center gap-2 mb-1 text-gray-dark'}>
<Lightbulb size={16} /> <b>{inChat ? 'Suggested Follow-up Questions' : 'Suggested Ideas:'}</b>
<b>Ideas:</b>
</div> </div>
<IdeaItem onClick={onClick} title={'Top user journeys'} /> {isPending ? (
<IdeaItem onClick={onClick} title={'Where do users drop off'} /> <div className="animate-pulse text-disabled-text">
<IdeaItem onClick={onClick} title={'Failed network requests today'} /> {t('Generating ideas')}...
</> </div>
) : (
<div className="flex gap-2 flex-wrap">
{ideas.map((title) => (
<IdeaItem
limited={limited}
key={title}
onClick={onClick}
title={title}
/>
))}
</div>
)}
</div>
); );
} }
function IdeaItem({ title, onClick }: { title: string, onClick: (query: string) => void }) { function IdeaItem({
title,
onClick,
limited,
}: {
title: string;
onClick: (query: string) => void;
limited?: boolean;
}) {
return ( return (
<div <div
onClick={() => onClick(title)} onClick={() => (limited ? null : onClick(title))}
className={ className={cn(
'flex items-center gap-2 cursor-pointer text-gray-dark hover:text-black' 'cursor-pointer text-gray-dark hover:text-black rounded-full px-4 py-2 shadow border',
} limited ? 'bg-gray-lightest cursor-not-allowed' : 'bg-white',
)}
> >
<MoveRight size={16} /> {title}
<span>{title}</span>
</div> </div>
); );
} }

View file

@ -2,23 +2,35 @@ import React from 'react';
import ChatInput from './ChatInput'; import ChatInput from './ChatInput';
import Ideas from './Ideas'; import Ideas from './Ideas';
function IntroSection({ onAsk }: { onAsk: (query: string) => void }) { function IntroSection({
onAsk,
onCancel,
userName,
projectId,
limited,
}: {
onAsk: (query: string) => void;
projectId: string;
onCancel: () => void;
userName: string;
limited?: boolean;
}) {
const isLoading = false; const isLoading = false;
return ( return (
<> <>
<div className={'text-disabled-text text-xl absolute top-4'}> <div className={'relative w-2/3 flex flex-col gap-4'}>
Kai is your AI assistant, delivering smart insights in response to your <div className="font-semibold text-lg">
queries. Hey {userName}, how can I help you?
</div> </div>
<div className={'relative w-2/3'} style={{ height: 44 }}> <ChatInput
{/*<GradientBorderInput placeholder={'Ask anything about your product and users...'} onButtonClick={() => null} />*/} onCancel={onCancel}
<ChatInput isLoading={isLoading} onSubmit={onAsk} /> isLoading={isLoading}
<div className={'absolute top-full flex flex-col gap-2 mt-4'}> onSubmit={onAsk}
<Ideas onClick={(query) => onAsk(query)} /> isArea
/>
<div className={'absolute top-full flex flex-col gap-2 mt-4'}>
<Ideas limited={limited} onClick={(query) => onAsk(query)} projectId={projectId} />
</div> </div>
</div>
<div className={'text-disabled-text absolute bottom-4'}>
OpenReplay AI can make mistakes. Verify its outputs.
</div> </div>
</> </>
); );

View file

@ -2,23 +2,23 @@ import React from 'react';
import { kaiStore } from '../KaiStore'; import { kaiStore } from '../KaiStore';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { Progress, Tooltip } from 'antd'; import { Progress, Tooltip } from 'antd';
const getUsageColor = (percent: number) => {
return 'disabled-text';
};
function Usage() { function Usage() {
const { usage } = kaiStore; const usage = kaiStore.usage;
const color = getUsageColor(usage.percent);
if (usage.total === 0) { if (usage.total === 0) {
return null; return null;
} }
const roundPercent = Math.round((usage.used / usage.total) * 100);
return ( return (
<div> <div>
<Tooltip title={`Daily response limit (${usage.used}/${usage.total})`}> <Tooltip title={`Daily response limit (${usage.used}/${usage.total})`}>
<Progress <Progress
percent={usage.percent} percent={roundPercent}
strokeColor={usage.percent < 99 ? 'var(--color-main)' : 'var(--color-red)'} strokeColor={
roundPercent < 99 ? 'var(--color-main)' : 'var(--color-red)'
}
showInfo={false} showInfo={false}
type="circle" type="circle"
size={24} size={24}

View file

@ -1,6 +1,7 @@
// @ts-nocheck // @ts-nocheck
import React, { Component, createContext } from 'react'; import React, { Component, createContext } from 'react';
import Modal from './Modal'; import Modal from './Modal';
import { className } from '@medv/finder';
const ModalContext = createContext({ const ModalContext = createContext({
component: null, component: null,
@ -29,6 +30,7 @@ export class ModalProvider extends Component {
this.setState({ this.setState({
component, component,
props, props,
className: props.className || undefined,
}); });
document.addEventListener('keydown', this.handleKeyDown); document.addEventListener('keydown', this.handleKeyDown);
document.querySelector('body').style.overflow = 'hidden'; document.querySelector('body').style.overflow = 'hidden';

View file

@ -26,10 +26,11 @@ interface Props {
autoplay: boolean; autoplay: boolean;
onClose?: () => void; onClose?: () => void;
isHighlight?: boolean; isHighlight?: boolean;
isFull?: boolean;
} }
function ClipsPlayer(props: Props) { function ClipsPlayer(props: Props) {
const { clip, currentIndex, isCurrent, onClose, isHighlight } = props; const { clip, currentIndex, isCurrent, onClose, isHighlight, isFull } = props;
const { sessionStore } = useStore(); const { sessionStore } = useStore();
const { prefetched } = sessionStore; const { prefetched } = sessionStore;
const [windowActive, setWindowActive] = useState(!document.hidden); const [windowActive, setWindowActive] = useState(!document.hidden);
@ -146,6 +147,7 @@ function ClipsPlayer(props: Props) {
onClose={onClose} onClose={onClose}
range={clip.range} range={clip.range}
session={session!} session={session!}
isFull={isFull}
/> />
<ClipPlayerContent <ClipPlayerContent
message={clip.message} message={clip.message}
@ -153,6 +155,7 @@ function ClipsPlayer(props: Props) {
autoplay={props.autoplay} autoplay={props.autoplay}
range={clip.range} range={clip.range}
session={session!} session={session!}
isFull={isFull}
/> />
</> </>
) : ( ) : (

View file

@ -19,13 +19,14 @@ interface Props {
isHighlight?: boolean; isHighlight?: boolean;
message?: string; message?: string;
isMobile?: boolean; isMobile?: boolean;
isFull?: boolean;
} }
function ClipPlayerContent(props: Props) { function ClipPlayerContent(props: Props) {
const playerContext = React.useContext<IPlayerContext>(PlayerContext); const playerContext = React.useContext<IPlayerContext>(PlayerContext);
const screenWrapper = React.useRef<HTMLDivElement>(null); const screenWrapper = React.useRef<HTMLDivElement>(null);
const { time } = playerContext.store.get(); const { time } = playerContext.store.get();
const { range } = props; const { range, isFull } = props;
React.useEffect(() => { React.useEffect(() => {
if (!playerContext.player) return; if (!playerContext.player) return;
@ -90,7 +91,7 @@ function ClipPlayerContent(props: Props) {
<div className="leading-none font-medium">{props.message}</div> <div className="leading-none font-medium">{props.message}</div>
</div> </div>
) : null} ) : null}
<ClipPlayerControls session={props.session} range={props.range} /> <ClipPlayerControls isFull={isFull} session={props.session} range={props.range} />
</div> </div>
</div> </div>
); );

View file

@ -15,9 +15,11 @@ import { useTranslation } from 'react-i18next';
function ClipPlayerControls({ function ClipPlayerControls({
session, session,
range, range,
isFull,
}: { }: {
session: Session; session: Session;
range: [number, number]; range: [number, number];
isFull?: boolean;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { projectsStore } = useStore(); const { projectsStore } = useStore();
@ -47,7 +49,7 @@ function ClipPlayerControls({
<PlayButton state={state} togglePlay={togglePlay} iconSize={30} /> <PlayButton state={state} togglePlay={togglePlay} iconSize={30} />
<Timeline range={range} /> <Timeline range={range} />
<Button size="small" type="primary" onClick={showFullSession}> <Button size="small" type="primary" onClick={showFullSession}>
{t('Play Full Session')} {isFull ? t('Open Session') : t('Play Full Session')}
<CirclePlay size={16} style={{ marginLeft: '0px'}} /> <CirclePlay size={16} style={{ marginLeft: '0px'}} />
</Button> </Button>
</div> </div>

View file

@ -16,12 +16,13 @@ interface Props {
range: [number, number]; range: [number, number];
onClose?: () => void; onClose?: () => void;
isHighlight?: boolean; isHighlight?: boolean;
isFull?: boolean;
} }
function ClipPlayerHeader(props: Props) { function ClipPlayerHeader(props: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const { projectsStore } = useStore(); const { projectsStore } = useStore();
const { session, range, onClose, isHighlight } = props; const { session, range, onClose, isHighlight, isFull } = props;
const { siteId } = projectsStore; const { siteId } = projectsStore;
const { message } = App.useApp(); const { message } = App.useApp();
@ -33,7 +34,7 @@ function ClipPlayerHeader(props: Props) {
}; };
return ( return (
<div className="bg-white p-3 flex justify-between items-center border-b relative"> <div className="bg-white p-3 flex justify-between items-center border-b relative">
{isHighlight ? <PartialSessionBadge /> : null} {isHighlight && !isFull ? <PartialSessionBadge /> : null}
<UserCard session={props.session} /> <UserCard session={props.session} />
<Space> <Space>

View file

@ -1,5 +1,5 @@
import { TYPES } from 'Types/session/event'; import { TYPES } from 'Types/session/event';
import React from 'react'; import React, { useMemo } 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 UxtEvent from 'Components/Session_/EventsBlock/UxtEvent'; import UxtEvent from 'Components/Session_/EventsBlock/UxtEvent';
@ -32,6 +32,7 @@ function EventGroupWrapper(props) {
presentInSearch, presentInSearch,
isNote, isNote,
isTabChange, isTabChange,
isIncident,
filterOutNote, filterOutNote,
} = props; } = props;
const { t } = useTranslation(); const { t } = useTranslation();
@ -57,6 +58,15 @@ function EventGroupWrapper(props) {
/> />
); );
} }
if (isIncident) {
return (
<Incident
isCurrent={isCurrent}
label={event.label}
onClick={onEventClick}
/>
)
}
if (isLocation) { if (isLocation) {
return ( return (
<Event <Event
@ -100,11 +110,19 @@ function EventGroupWrapper(props) {
); );
}; };
const shadowColor = isSearched ? '#F0A930' : props.isPrev const shadowColor = useMemo(() => {
? '#A7BFFF' if (isSearched) {
: props.isCurrent return '#F0A930';
? '#394EFF' }
: 'transparent'; if (props.isPrev) {
return '#A7BFFF';
}
if (props.isCurrent) {
return '#394EFF';
}
return 'transparent';
}, [isSearched, props.isPrev, props.isCurrent]);
return ( return (
<> <>
<div> <div>
@ -172,4 +190,22 @@ function TabChange({ from, to, activeUrl, onClick }) {
); );
}; };
function Incident({ label, onClick }: { label: string; onClick: () => void }) {
const { t } = useTranslation();
return (
<div
onClick={onClick}
className="pr-6 pl-4 py-2 relative user-select-none transition-all duration-200 cursor-pointer rounded-[3px] hover:bg-[var(--color-active-blue)] bg-[var(--color-white)]"
>
<div className='flex items-center py-2 gap-[10.5px]'>
<Icon name="console/warning" size={18} color="gray-dark" />
<div className="flex flex-col">
<span style={{ fontWeight: 500 }}>{t('Incident')}</span>
<span className="text-ellipsis overflow-hidden whitespace-nowrap max-w-full text-sm text-[var(--color-gray-medium)]">{label}</span>
</div>
</div>
</div>
);
};
export default observer(EventGroupWrapper); export default observer(EventGroupWrapper);

View file

@ -34,8 +34,9 @@ function EventsBlock(props: IProps) {
const { notesStore, uxtestingStore, uiPlayerStore, sessionStore } = const { notesStore, uxtestingStore, uiPlayerStore, sessionStore } =
useStore(); useStore();
const session = sessionStore.current; const session = sessionStore.current;
const { notesWithEvents } = session; const notesWithEvents = session.notesWithEvents;
const { uxtVideo } = session; const incidents = session.incidents;
const uxtVideo = session.uxtVideo;
const { filteredEvents } = sessionStore; const { filteredEvents } = sessionStore;
const query = sessionStore.eventsQuery; const query = sessionStore.eventsQuery;
const { eventsIndex } = sessionStore; const { eventsIndex } = sessionStore;
@ -86,26 +87,28 @@ function EventsBlock(props: IProps) {
} }
}); });
} }
const eventsWithMobxNotes = [...notesWithEvents, ...notes] const eventsWithMobxNotes = [...incidents, ...notesWithEvents, ...notes, ].sort(sortEvents);
.sort(sortEvents);
const filteredTabEvents = query.length const filteredTabEvents = query.length
? tabChangeEvents ? tabChangeEvents.filter((e) => (e.activeUrl as string).includes(query))
.filter((e => (e.activeUrl as string).includes(query))) : tabChangeEvents;
: tabChangeEvents; return mergeEventLists(
const list = mergeEventLists( filteredLength > 0 ? filteredEvents : eventsWithMobxNotes,
query.length > 0 ? filteredEvents : eventsWithMobxNotes, tabChangeEvents,
filteredTabEvents
) )
if (zoomEnabled) { .filter((e) =>
return list.filter((e) =>
zoomEnabled zoomEnabled
? 'time' in e ? 'time' in e
? e.time >= zoomStartTs && e.time <= zoomEndTs ? e.time >= zoomStartTs && e.time <= zoomEndTs
: false : false
: true : true,
).filter((e: any) => !e.noteId && e.type !== 'TABCHANGE' && uiPlayerStore.showOnlySearchEvents ? e.isHighlighted : true); )
} .filter((e: any) =>
return list; !e.noteId &&
e.type !== 'TABCHANGE' &&
uiPlayerStore.showOnlySearchEvents
? e.isHighlighted
: true,
);
}, [ }, [
filteredLength, filteredLength,
query, query,
@ -114,15 +117,17 @@ function EventsBlock(props: IProps) {
zoomEnabled, zoomEnabled,
zoomStartTs, zoomStartTs,
zoomEndTs, zoomEndTs,
uiPlayerStore.showOnlySearchEvents uiPlayerStore.showOnlySearchEvents,
]); ]);
const findLastFitting = React.useCallback( const findLastFitting = React.useCallback(
(time: number) => { (time: number) => {
if (!usedEvents.length) return 0; const allEvents = usedEvents.concat(incidents);
let i = usedEvents.length - 1; if (!allEvents.length) return 0;
let i = allEvents.length - 1;
if (time > endTime / 2) { if (time > endTime / 2) {
while (i >= 0) { while (i > 0) {
const event = usedEvents[i]; const event = allEvents[i];
if ('time' in event && event.time <= time) break; if ('time' in event && event.time <= time) break;
i--; i--;
} }
@ -130,18 +135,18 @@ function EventsBlock(props: IProps) {
} }
let l = 0; let l = 0;
while (l < i) { while (l < i) {
const event = usedEvents[l]; const event = allEvents[l];
if ('time' in event && event.time >= time) break; if ('time' in event && event.time >= time) break;
l++; l++;
} }
return l; return l;
}, },
[usedEvents, time, endTime], [usedEvents, incidents, time, endTime],
); );
useEffect(() => { useEffect(() => {
setCurrentTimeEventIndex(findLastFitting(time)); setCurrentTimeEventIndex(findLastFitting(time));
}, []) }, [time]);
const write = ({ const write = ({
target: { value }, target: { value },
@ -195,9 +200,10 @@ function EventsBlock(props: IProps) {
const event = usedEvents[index]; const event = usedEvents[index];
const isNote = 'noteId' in event; const isNote = 'noteId' in event;
const isTabChange = 'type' in event && event.type === 'TABCHANGE'; const isTabChange = 'type' in event && event.type === 'TABCHANGE';
const isIncident = 'type' in event && event.type === 'INCIDENT';
const isCurrent = index === currentTimeEventIndex; const isCurrent = index === currentTimeEventIndex;
const isPrev = index < currentTimeEventIndex; const isPrev = index < currentTimeEventIndex;
const isSearched = event.isHighlighted const isSearched = event.isHighlighted;
return ( return (
<EventGroupWrapper <EventGroupWrapper
@ -213,6 +219,7 @@ function EventsBlock(props: IProps) {
showSelection={!playing} showSelection={!playing}
isNote={isNote} isNote={isNote}
isTabChange={isTabChange} isTabChange={isTabChange}
isIncident={isIncident}
isPrev={isPrev} isPrev={isPrev}
filterOutNote={filterOutNote} filterOutNote={filterOutNote}
setActiveTab={setActiveTab} setActiveTab={setActiveTab}

View file

@ -6,13 +6,15 @@ import {
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { getTimelinePosition } from './getTimelinePosition'; import { getTimelinePosition } from './getTimelinePosition';
import { useStore } from '@/mstore'; import { useStore } from '@/mstore';
import { getTimelineEventWidth } from './getTimelineEventWidth';
import { Tooltip } from 'antd';
function EventsList() { function EventsList() {
const { store } = useContext(PlayerContext); const { store } = useContext(PlayerContext);
const { uiPlayerStore } = useStore(); const { uiPlayerStore, sessionStore } = useStore();
const { eventCount, endTime, tabStates, sessionStart } = store.get();
const { incidents } = sessionStore.current;
const { eventCount, endTime } = store.get();
const { tabStates } = store.get();
const scale = 100 / endTime; const scale = 100 / endTime;
const events = React.useMemo( const events = React.useMemo(
() => Object.values(tabStates)[0]?.eventList.filter((e) => { () => Object.values(tabStates)[0]?.eventList.filter((e) => {
@ -39,10 +41,25 @@ function EventsList() {
<div <div
/* @ts-ignore TODO */ /* @ts-ignore TODO */
key={`${e.key}_${e.time}`} key={`${e.key}_${e.time}`}
className={`absolute w-[2px] h-[10px] z-[3] pointer-events-none ${e.isHighlighted ? 'bg-[#f0a930]' : 'bg-[#394eff]'}`} className={`absolute w-[2px] h-[10px] z-[4] pointer-events-none ${e.isHighlighted ? 'bg-[#f0a930]' : 'bg-[#394eff]'}`}
style={{ left: `${getTimelinePosition(e.time, scale)}%` }} style={{ left: `${getTimelinePosition(e.time, scale)}%` }}
/> />
))} ))}
{incidents?.map((i) => {
const width = getTimelineEventWidth(endTime, (i as any).time, (i as any).endTime - sessionStart);
return (
<Tooltip title={i.label} key={(i as any).startTime}>
<div
/* @ts-ignore TODO */
className={`absolute h-[10px] z-[3] bg-[#ff5454]`}
style={{
left: `${getTimelinePosition((i as any).time, scale)}%`,
width: typeof width === 'string' ? width : `${width}%`,
}}
/>
</Tooltip>
)
})}
</> </>
); );
} }

View file

@ -0,0 +1,21 @@
import { getTimelinePosition } from '@/utils';
export function getTimelineEventWidth(
sessionDuration: number,
eventStart: number,
eventEnd: number,
): number | string {
if (eventStart < 0) {
eventStart = 0;
}
if (eventEnd > sessionDuration) {
eventEnd = sessionDuration;
}
if (eventStart === eventEnd) {
return '2px';
}
const width = ((eventEnd - eventStart) / sessionDuration) * 100;
return width < 1 ? '4px' : width;
}

View file

@ -35,14 +35,11 @@ function SessionFilters() {
useEffect(() => { useEffect(() => {
// Add default location/screen filter if no filters are present // Add default location/screen filter if no filters are present
if (searchStore.instance.filters.length === 0) { if (
searchStore.addFilterByKeyAndValue( searchStore.instance.filters.length === 0 &&
activeProject?.platform === 'web' activeProject?.platform === 'web'
? FilterKey.LOCATION ) {
: FilterKey.VIEW_MOBILE, searchStore.addFilterByKeyAndValue(FilterKey.LOCATION, '', 'isAny');
'',
'isAny',
);
} }
void reloadTags(); void reloadTags();
}, [projectsStore.activeSiteId, activeProject]); }, [projectsStore.activeSiteId, activeProject]);
@ -65,7 +62,7 @@ function SessionFilters() {
}; };
const onFilterMove = (newFilters: any) => { const onFilterMove = (newFilters: any) => {
searchStore.updateSearch({ ...appliedFilter, filters: newFilters}); searchStore.updateSearch({ ...appliedFilter, filters: newFilters });
// debounceFetch(); // debounceFetch();
}; };

View file

@ -10,7 +10,7 @@ import { Icon, Link } from 'UI';
import { useStore } from 'App/mstore'; import { useStore } from 'App/mstore';
const PLAY_ICON_NAMES = { const PLAY_ICON_NAMES = {
notPlayed: 'play-fill', notPlayed: 'play-v2',
played: 'play-circle-light', played: 'play-circle-light',
} as const; } as const;
@ -76,10 +76,14 @@ function PlayLink(props: Props) {
rel={props.newTab ? 'noopener noreferrer' : undefined} rel={props.newTab ? 'noopener noreferrer' : undefined}
> >
<div className="group-hover:block hidden"> <div className="group-hover:block hidden">
<Icon name="play-hover" size={38} color={isAssist ? 'tealx' : 'teal'} /> <Icon name={`play-fill-v2${isAssist ? '-assist' : ''}`} size={38} />
</div> </div>
<div className="group-hover:hidden block"> <div className="group-hover:hidden block">
<Icon name={iconName} size={38} color={isAssist ? 'tealx' : 'teal'} /> <Icon
name={`${iconName}${isAssist ? '-assist' : ''}`}
size={38}
color="teal"
/>
</div> </div>
</Link> </Link>
); );

View file

@ -71,6 +71,7 @@ interface Props {
bookmarked?: boolean; bookmarked?: boolean;
toggleFavorite?: (sessionId: string) => void; toggleFavorite?: (sessionId: string) => void;
query?: string; query?: string;
slim?: boolean;
} }
const PREFETCH_STATE = { const PREFETCH_STATE = {
@ -81,7 +82,8 @@ const PREFETCH_STATE = {
function SessionItem(props: RouteComponentProps & Props) { function SessionItem(props: RouteComponentProps & Props) {
const { location } = useHistory(); const { location } = useHistory();
const { settingsStore, sessionStore, searchStore, searchStoreLive } = useStore(); const { settingsStore, sessionStore, searchStore, searchStoreLive } =
useStore();
const { timezone, shownTimezone } = settingsStore.sessionSettings; const { timezone, shownTimezone } = settingsStore.sessionSettings;
const { t } = useTranslation(); const { t } = useTranslation();
const [prefetchState, setPrefetched] = useState(PREFETCH_STATE.none); const [prefetchState, setPrefetched] = useState(PREFETCH_STATE.none);
@ -99,6 +101,7 @@ function SessionItem(props: RouteComponentProps & Props) {
isDisabled, isDisabled,
live: propsLive, live: propsLive,
isAdd, isAdd,
slim,
} = props; } = props;
const { const {
@ -176,7 +179,7 @@ function SessionItem(props: RouteComponentProps & Props) {
await sessionStore.getFirstMob(sessionId); await sessionStore.getFirstMob(sessionId);
setPrefetched(PREFETCH_STATE.fetched); setPrefetched(PREFETCH_STATE.fetched);
} catch (e) { } catch (e) {
setPrefetched(PREFETCH_STATE.none) setPrefetched(PREFETCH_STATE.none);
console.error('Error while prefetching first mob', e); console.error('Error while prefetching first mob', e);
} }
}, [prefetchState, live, isAssist, isMobile, sessionStore, sessionId]); }, [prefetchState, live, isAssist, isMobile, sessionStore, sessionId]);
@ -245,13 +248,13 @@ function SessionItem(props: RouteComponentProps & Props) {
); );
}, [startedAt, timezone, userTimezone]); }, [startedAt, timezone, userTimezone]);
const onMetaClick = (meta: { name: string, value: string }) => { const onMetaClick = (meta: { name: string; value: string }) => {
if (isAssist) { if (isAssist) {
searchStoreLive.addFilterByKeyAndValue(meta.name, meta.value) searchStoreLive.addFilterByKeyAndValue(meta.name, meta.value);
} else { } else {
searchStore.addFilterByKeyAndValue(meta.name, meta.value); searchStore.addFilterByKeyAndValue(meta.name, meta.value);
} }
} };
return ( return (
<Tooltip <Tooltip
title={ title={
@ -261,7 +264,11 @@ function SessionItem(props: RouteComponentProps & Props) {
} }
> >
<div <div
className={cn(stl.sessionItem, 'flex flex-col p-4')} className={cn(
stl.sessionItem,
'flex flex-col',
slim ? 'px-4 py-2 text-sm' : 'p-4',
)}
id="session-item" id="session-item"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onMouseEnter={handleHover} onMouseEnter={handleHover}
@ -291,13 +298,16 @@ function SessionItem(props: RouteComponentProps & Props) {
</div> </div>
<div className="overflow-hidden color-gray-medium ml-3 justify-between items-center shrink-0"> <div className="overflow-hidden color-gray-medium ml-3 justify-between items-center shrink-0">
<div <div
className={cn('text-lg', { className={cn(
'color-teal cursor-pointer': {
!disableUser && hasUserId && !isDisabled, 'color-teal cursor-pointer':
[stl.userName]: !disableUser && hasUserId && !isDisabled,
!disableUser && hasUserId && !isDisabled, [stl.userName]:
'color-gray-medium': disableUser || !hasUserId, !disableUser && hasUserId && !isDisabled,
})} 'color-gray-medium': disableUser || !hasUserId,
},
slim ? 'text-base' : 'text-lg',
)}
onClick={handleUserClick} onClick={handleUserClick}
> >
<TextEllipsis <TextEllipsis
@ -308,15 +318,20 @@ function SessionItem(props: RouteComponentProps & Props) {
</div> </div>
</div> </div>
</div> </div>
{_metaList.length > 0 && ( {!slim && _metaList.length > 0 && (
<SessionMetaList onMetaClick={onMetaClick} maxLength={3} metaList={_metaList} /> <SessionMetaList
onMetaClick={onMetaClick}
maxLength={3}
metaList={_metaList}
/>
)} )}
</div> </div>
)} )}
<div <div
className={cn( className={cn(
'px-2 flex flex-col justify-between gap-2 mt-3 lg:mt-0', 'px-2 flex flex-col justify-between lg:mt-0',
compact ? 'w-[40%]' : 'lg:w-1/5', compact ? 'w-[40%]' : 'lg:w-1/5',
slim ? 'gap-1 mt-1' : 'gap-2 mt-3',
)} )}
> >
<div> <div>
@ -343,7 +358,7 @@ function SessionItem(props: RouteComponentProps & Props) {
: 'Event'} : 'Event'}
</span> </span>
</div> </div>
<Icon name="circle-fill" size={3} className="mx-4" /> <Icon name="circle-fill" size={3} className="mx-2" />
</> </>
)} )}
<div> <div>
@ -353,9 +368,12 @@ function SessionItem(props: RouteComponentProps & Props) {
</div> </div>
<div <div
style={{ width: '30%' }} style={{ width: '30%' }}
className="px-2 flex flex-col justify-between gap-2" className={cn(
'px-2 flex flex-col justify-between',
slim ? 'gap-1' : 'gap-2',
)}
> >
<div style={{ height: '21px' }}> <div style={{ height: slim ? undefined : '21px' }}>
<CountryFlag <CountryFlag
userCity={userCity} userCity={userCity}
userState={userState} userState={userState}
@ -373,7 +391,7 @@ function SessionItem(props: RouteComponentProps & Props) {
</span> </span>
)} )}
{userOs && userBrowser && ( {userOs && userBrowser && (
<Icon name="circle-fill" size={3} className="mx-4" /> <Icon name="circle-fill" size={3} className="mx-2" />
)} )}
{userOs && ( {userOs && (
<span <span
@ -387,7 +405,7 @@ function SessionItem(props: RouteComponentProps & Props) {
</span> </span>
)} )}
{userOs && ( {userOs && (
<Icon name="circle-fill" size={3} className="mx-4" /> <Icon name="circle-fill" size={3} className="mx-2" />
)} )}
<span className="capitalize" style={{ maxWidth: '70px' }}> <span className="capitalize" style={{ maxWidth: '70px' }}>
<TextEllipsis <TextEllipsis
@ -452,7 +470,9 @@ function SessionItem(props: RouteComponentProps & Props) {
onClick={onClick} onClick={onClick}
queryParams={queryParams} queryParams={queryParams}
query={query} query={query}
beforeOpen={live || isAssist ? undefined : populateData} beforeOpen={
slim || live || isAssist ? undefined : populateData
}
/> />
)} )}
</div> </div>

View file

@ -1,6 +1,6 @@
import { issues_types, types } from 'Types/session/issue'; import { issues_types, types } from 'Types/session/issue';
import { Grid, Segmented } from 'antd'; import { Grid, Segmented } from 'antd';
import { Angry, CircleAlert, Skull, WifiOff, ChevronDown } from 'lucide-react'; import { Angry, CircleAlert, Skull, WifiOff, ChevronDown, MessageCircleWarning } from 'lucide-react';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { useStore } from 'App/mstore'; import { useStore } from 'App/mstore';
@ -15,6 +15,7 @@ const tagIcons = {
[types.CLICK_RAGE]: <Angry size={14} />, [types.CLICK_RAGE]: <Angry size={14} />,
[types.CRASH]: <Skull size={14} />, [types.CRASH]: <Skull size={14} />,
[types.TAP_RAGE]: <Angry size={14} />, [types.TAP_RAGE]: <Angry size={14} />,
[types.INCIDENT]: <MessageCircleWarning size={14} />,
} as Record<string, any>; } as Record<string, any>;
function SessionTags() { function SessionTags() {

View file

@ -1,7 +1,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import copy from 'copy-to-clipboard'; import copy from 'copy-to-clipboard';
import { Button, Tooltip } from 'antd'; import { Button, Tooltip } from 'antd';
import { ClipboardCopy, ClipboardCheck } from 'lucide-react'; import { Copy, Check } from 'lucide-react';
interface Props { interface Props {
content: string; content: string;
@ -30,10 +30,10 @@ function CopyButton({
setTimeout(() => { setTimeout(() => {
setCopied(false); setCopied(false);
}, 1000); }, 1000);
} };
const copyHandler = () => { const copyHandler = () => {
setCopied(true); setCopied(true);
const contentIsGetter = !!getHtml const contentIsGetter = !!getHtml;
const textContent = contentIsGetter ? getHtml() : content; const textContent = contentIsGetter ? getHtml() : content;
const isHttps = window.location.protocol === 'https:'; const isHttps = window.location.protocol === 'https:';
if (!isHttps) { if (!isHttps) {
@ -43,15 +43,16 @@ function CopyButton({
} }
const blob = new Blob([textContent], { type: format }); const blob = new Blob([textContent], { type: format });
const cbItem = new ClipboardItem({ const cbItem = new ClipboardItem({
[format]: blob [format]: blob,
}) });
navigator.clipboard.write([cbItem]) navigator.clipboard
.catch(e => { .write([cbItem])
.catch((e) => {
copy(textContent); copy(textContent);
}) })
.finally(() => { .finally(() => {
reset() reset();
}) });
}; };
if (isIcon) { if (isIcon) {
@ -62,7 +63,11 @@ function CopyButton({
onClick={copyHandler} onClick={copyHandler}
size={size} size={size}
icon={ icon={
copied ? <ClipboardCheck size={16} /> : <ClipboardCopy size={16} /> copied ? (
<Check strokeWidth={2} size={16} />
) : (
<Copy strokeWidth={2} size={16} />
)
} }
/> />
</Tooltip> </Tooltip>

View file

@ -0,0 +1,18 @@
/* Auto-generated, do not edit */
import React from 'react';
interface Props {
size?: number | string;
width?: number | string;
height?: number | string;
fill?: string;
}
function Export_pdf(props: Props) {
const { size = 14, width = size, height = size, fill = '' } = props;
return (
<svg viewBox="0 0 21 21" fill="none" width={ `${ width }px` } height={ `${ height }px` } ><path d="M11.496 3H5.454c-.805 0-1.458.653-1.458 1.458V10.5h13.333V8.833h-4.375a1.458 1.458 0 0 1-1.458-1.458V3Z" fill="#1C1B1F"/><path d="M17.33 7.583v-.071c0-.387-.154-.758-.428-1.031L13.85 3.427A1.458 1.458 0 0 0 12.817 3h-.071v4.375c0 .115.093.208.208.208h4.375Z" fill="#1C1B1F"/><path clipRule="evenodd" d="M3.163 12.792c0-.345.28-.625.625-.625h1.666a1.875 1.875 0 1 1 0 3.75H4.413v1.458a.625.625 0 1 1-1.25 0v-4.583Zm1.25 1.875h1.041a.625.625 0 0 0 0-1.25H4.413v1.25ZM8.163 12.792c0-.345.28-.625.625-.625h1.25c.54 0 1.258.134 1.858.582.63.471 1.058 1.237 1.058 2.334 0 1.098-.427 1.863-1.058 2.334-.6.449-1.319.583-1.858.583h-1.25a.625.625 0 0 1-.625-.625v-4.583Zm1.25.625v3.333h.625c.363 0 .79-.095 1.11-.334.29-.216.556-.597.556-1.333s-.267-1.116-.556-1.332c-.32-.24-.747-.334-1.11-.334h-.625ZM13.996 12.792c0-.345.28-.625.625-.625h2.917a.625.625 0 1 1 0 1.25h-2.292v1.25h1.458a.625.625 0 0 1 0 1.25h-1.458v1.458a.625.625 0 1 1-1.25 0v-4.583Z" fill="#1C1B1F"/></svg>
);
}
export default Export_pdf;

View file

@ -0,0 +1,18 @@
/* Auto-generated, do not edit */
import React from 'react';
interface Props {
size?: number | string;
width?: number | string;
height?: number | string;
fill?: string;
}
function Funnel_message_circle_warning(props: Props) {
const { size = 14, width = size, height = size, fill = '' } = props;
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width={ `${ width }px` } height={ `${ height }px` } ><path d="M7.9 20A9 9 0 1 0 4 16.1L2 22ZM12 8v4M12 16h.01"/></svg>
);
}
export default Funnel_message_circle_warning;

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