Merge branch 'dev' of https://github.com/openreplay/openreplay into e2e_tests_frontend
This commit is contained in:
commit
304b438154
185 changed files with 6108 additions and 2784 deletions
2
.github/workflows/alerts-ee.yaml
vendored
2
.github/workflows/alerts-ee.yaml
vendored
|
|
@ -130,7 +130,7 @@ jobs:
|
|||
cat /tmp/image_override.yaml
|
||||
# Deploy command
|
||||
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/*
|
||||
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 -
|
||||
|
|
|
|||
2
.github/workflows/alerts.yaml
vendored
2
.github/workflows/alerts.yaml
vendored
|
|
@ -130,7 +130,7 @@ jobs:
|
|||
cat /tmp/image_override.yaml
|
||||
# Deploy command
|
||||
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/*
|
||||
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 -
|
||||
|
|
|
|||
2
.github/workflows/api-ee.yaml
vendored
2
.github/workflows/api-ee.yaml
vendored
|
|
@ -127,7 +127,7 @@ jobs:
|
|||
cat /tmp/image_override.yaml
|
||||
# Deploy command
|
||||
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/*
|
||||
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 -
|
||||
|
|
|
|||
2
.github/workflows/api.yaml
vendored
2
.github/workflows/api.yaml
vendored
|
|
@ -120,7 +120,7 @@ jobs:
|
|||
cat /tmp/image_override.yaml
|
||||
# Deploy command
|
||||
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/*
|
||||
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 -
|
||||
|
|
|
|||
2
.github/workflows/assist-ee.yaml
vendored
2
.github/workflows/assist-ee.yaml
vendored
|
|
@ -113,7 +113,7 @@ jobs:
|
|||
cat /tmp/image_override.yaml
|
||||
# Deploy command
|
||||
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/*
|
||||
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 -
|
||||
|
|
|
|||
2
.github/workflows/assist-server-ee.yaml
vendored
2
.github/workflows/assist-server-ee.yaml
vendored
|
|
@ -111,7 +111,7 @@ jobs:
|
|||
cat /tmp/image_override.yaml
|
||||
# Deploy command
|
||||
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/*
|
||||
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 -
|
||||
|
|
|
|||
2
.github/workflows/assist-stats.yaml
vendored
2
.github/workflows/assist-stats.yaml
vendored
|
|
@ -130,7 +130,7 @@ jobs:
|
|||
cat /tmp/image_override.yaml
|
||||
# Deploy command
|
||||
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/*
|
||||
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 -
|
||||
|
|
|
|||
2
.github/workflows/assist.yaml
vendored
2
.github/workflows/assist.yaml
vendored
|
|
@ -112,7 +112,7 @@ jobs:
|
|||
cat /tmp/image_override.yaml
|
||||
# Deploy command
|
||||
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/*
|
||||
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 -
|
||||
|
|
|
|||
2
.github/workflows/crons-ee.yaml
vendored
2
.github/workflows/crons-ee.yaml
vendored
|
|
@ -129,7 +129,7 @@ jobs:
|
|||
cat /tmp/image_override.yaml
|
||||
# Deploy command
|
||||
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/*
|
||||
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 -
|
||||
|
|
|
|||
2
.github/workflows/frontend-dev.yaml
vendored
2
.github/workflows/frontend-dev.yaml
vendored
|
|
@ -76,7 +76,7 @@ jobs:
|
|||
cat /tmp/image_override.yaml
|
||||
# Deploy command
|
||||
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/*
|
||||
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 -
|
||||
|
|
|
|||
4
.github/workflows/frontend.yaml
vendored
4
.github/workflows/frontend.yaml
vendored
|
|
@ -89,7 +89,7 @@ jobs:
|
|||
cat /tmp/image_override.yaml
|
||||
# Deploy command
|
||||
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/*
|
||||
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 -
|
||||
|
|
@ -138,7 +138,7 @@ jobs:
|
|||
cat /tmp/image_override.yaml
|
||||
# Deploy command
|
||||
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/*
|
||||
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 -
|
||||
|
|
|
|||
18
.github/workflows/patch-build.yaml
vendored
18
.github/workflows/patch-build.yaml
vendored
|
|
@ -20,12 +20,20 @@ jobs:
|
|||
DEPOT_PROJECT_ID: ${{ secrets.DEPOT_PROJECT_ID }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
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
|
||||
if: github.ref != 'refs/heads/main'
|
||||
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
|
||||
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 }}
|
||||
|
||||
- uses: depot/setup-action@v1
|
||||
env:
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
- name: Get HEAD Commit ID
|
||||
run: echo "HEAD_COMMIT_ID=$(git rev-parse HEAD)" >> $GITHUB_ENV
|
||||
- name: Define Branch Name
|
||||
|
|
@ -100,7 +110,7 @@ jobs:
|
|||
else
|
||||
cd $MSAAS_REPO_FOLDER/openreplay/$service
|
||||
fi
|
||||
IMAGE_TAG=$version DOCKER_RUNTIME="depot" DOCKER_BUILD_ARGS="--push" ARCH=arm64 DOCKER_REPO=$DOCKER_REPO_ARM PUSH_IMAGE=0 bash build.sh >> /tmp/arm.txt
|
||||
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
|
||||
ls backend/cmd >> /tmp/backend.txt
|
||||
|
|
|
|||
2
.github/workflows/sourcemaps-reader-ee.yaml
vendored
2
.github/workflows/sourcemaps-reader-ee.yaml
vendored
|
|
@ -119,7 +119,7 @@ jobs:
|
|||
cat /tmp/image_override.yaml
|
||||
# Deploy command
|
||||
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/*
|
||||
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 -
|
||||
|
|
|
|||
2
.github/workflows/sourcemaps-reader.yaml
vendored
2
.github/workflows/sourcemaps-reader.yaml
vendored
|
|
@ -118,7 +118,7 @@ jobs:
|
|||
cat /tmp/image_override.yaml
|
||||
# Deploy command
|
||||
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/*
|
||||
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 -
|
||||
|
|
|
|||
4
.github/workflows/workers-ee.yaml
vendored
4
.github/workflows/workers-ee.yaml
vendored
|
|
@ -148,9 +148,7 @@ jobs:
|
|||
set -x
|
||||
echo > /tmp/image_override.yaml
|
||||
mkdir /tmp/helmcharts
|
||||
mv openreplay/charts/ingress-nginx /tmp/helmcharts/
|
||||
mv openreplay/charts/quickwit /tmp/helmcharts/
|
||||
mv openreplay/charts/connector /tmp/helmcharts/
|
||||
mv openreplay/charts/{ingress-nginx,quickwit,connector,assist-api} /tmp/helmcharts/
|
||||
## Update images
|
||||
for image in $(cat /tmp/images_to_build.txt);
|
||||
do
|
||||
|
|
|
|||
4
.github/workflows/workers.yaml
vendored
4
.github/workflows/workers.yaml
vendored
|
|
@ -141,9 +141,7 @@ jobs:
|
|||
set -x
|
||||
echo > /tmp/image_override.yaml
|
||||
mkdir /tmp/helmcharts
|
||||
mv openreplay/charts/ingress-nginx /tmp/helmcharts/
|
||||
mv openreplay/charts/quickwit /tmp/helmcharts/
|
||||
mv openreplay/charts/connector /tmp/helmcharts/
|
||||
mv openreplay/charts/{ingress-nginx,quickwit,connector,assist-api} /tmp/helmcharts/
|
||||
## Update images
|
||||
for image in $(cat /tmp/images_to_build.txt);
|
||||
do
|
||||
|
|
|
|||
|
|
@ -1,18 +1,13 @@
|
|||
from chalicelib.utils import ch_client
|
||||
from .events_pg import *
|
||||
|
||||
|
||||
def __explode_properties(rows):
|
||||
for i in range(len(rows)):
|
||||
rows[i] = {**rows[i], **rows[i]["$properties"]}
|
||||
rows[i].pop("$properties")
|
||||
return rows
|
||||
from chalicelib.utils.exp_ch_helper import explode_dproperties, add_timestamp
|
||||
|
||||
|
||||
def get_customs_by_session_id(session_id, project_id):
|
||||
with ch_client.ClickHouseClient() as cur:
|
||||
rows = cur.execute(""" \
|
||||
SELECT `$properties`,
|
||||
properties,
|
||||
created_at,
|
||||
'CUSTOM' AS type
|
||||
FROM product_analytics.events
|
||||
|
|
@ -21,8 +16,10 @@ def get_customs_by_session_id(session_id, project_id):
|
|||
AND `$event_name`!='INCIDENT'
|
||||
ORDER BY created_at;""",
|
||||
{"project_id": project_id, "session_id": session_id})
|
||||
rows = __explode_properties(rows)
|
||||
return helper.list_to_camel_case(rows)
|
||||
rows = helper.list_to_camel_case(rows, ignore_keys=["properties"])
|
||||
rows = explode_dproperties(rows)
|
||||
rows = add_timestamp(rows)
|
||||
return rows
|
||||
|
||||
|
||||
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,
|
||||
"select_events": select_events})
|
||||
rows = cur.execute(query)
|
||||
rows = __explode_properties(rows)
|
||||
rows = explode_dproperties(rows)
|
||||
if group_clickrage and 'CLICK' in select_events:
|
||||
rows = __get_grouped_clickrage(rows=rows, session_id=session_id, project_id=project_id)
|
||||
|
||||
rows = helper.list_to_camel_case(rows)
|
||||
rows = sorted(rows, key=lambda k: k["createdAt"])
|
||||
rows = add_timestamp(rows)
|
||||
return rows
|
||||
|
||||
|
||||
|
|
@ -91,7 +89,7 @@ def get_incidents_by_session_id(session_id, project_id):
|
|||
ORDER BY created_at;""",
|
||||
parameters={"project_id": project_id, "session_id": session_id})
|
||||
rows = cur.execute(query)
|
||||
rows = __explode_properties(rows)
|
||||
rows = explode_dproperties(rows)
|
||||
rows = helper.list_to_camel_case(rows)
|
||||
rows = sorted(rows, key=lambda k: k["createdAt"])
|
||||
return rows
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ def supported_types():
|
|||
query=autocomplete.__generic_query(
|
||||
typename=schemas.EventType.GRAPHQL)),
|
||||
schemas.EventType.STATE_ACTION: SupportedFilter(
|
||||
get=autocomplete.__generic_autocomplete(schemas.EventType.STATEACTION),
|
||||
get=autocomplete.__generic_autocomplete(schemas.EventType.STATE_ACTION),
|
||||
query=autocomplete.__generic_query(
|
||||
typename=schemas.EventType.STATE_ACTION)),
|
||||
schemas.EventType.TAG: SupportedFilter(get=_search_tags, query=None),
|
||||
|
|
|
|||
|
|
@ -50,8 +50,8 @@ class JIRAIntegration(base.BaseIntegration):
|
|||
cur.execute(
|
||||
cur.mogrify(
|
||||
"""SELECT username, token, url
|
||||
FROM public.jira_cloud
|
||||
WHERE user_id=%(user_id)s;""",
|
||||
FROM public.jira_cloud
|
||||
WHERE user_id = %(user_id)s;""",
|
||||
{"user_id": self._user_id})
|
||||
)
|
||||
data = helper.dict_to_camel_case(cur.fetchone())
|
||||
|
|
@ -95,10 +95,9 @@ class JIRAIntegration(base.BaseIntegration):
|
|||
def add(self, username, token, url, obfuscate=False):
|
||||
with pg_client.PostgresClient() as cur:
|
||||
cur.execute(
|
||||
cur.mogrify("""\
|
||||
INSERT INTO public.jira_cloud(username, token, user_id,url)
|
||||
VALUES (%(username)s, %(token)s, %(user_id)s,%(url)s)
|
||||
RETURNING username, token, url;""",
|
||||
cur.mogrify(""" \
|
||||
INSERT INTO public.jira_cloud(username, token, user_id, url)
|
||||
VALUES (%(username)s, %(token)s, %(user_id)s, %(url)s) RETURNING username, token, url;""",
|
||||
{"user_id": self._user_id, "username": username,
|
||||
"token": token, "url": url})
|
||||
)
|
||||
|
|
@ -112,9 +111,10 @@ class JIRAIntegration(base.BaseIntegration):
|
|||
def delete(self):
|
||||
with pg_client.PostgresClient() as cur:
|
||||
cur.execute(
|
||||
cur.mogrify("""\
|
||||
DELETE FROM public.jira_cloud
|
||||
WHERE user_id=%(user_id)s;""",
|
||||
cur.mogrify(""" \
|
||||
DELETE
|
||||
FROM public.jira_cloud
|
||||
WHERE user_id = %(user_id)s;""",
|
||||
{"user_id": self._user_id})
|
||||
)
|
||||
return {"state": "success"}
|
||||
|
|
@ -125,7 +125,7 @@ class JIRAIntegration(base.BaseIntegration):
|
|||
changes={
|
||||
"username": data.username,
|
||||
"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)
|
||||
},
|
||||
obfuscate=True
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from chalicelib.utils import ch_client, helper
|
||||
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):
|
||||
|
|
@ -21,7 +21,7 @@ def get(project_id, issue_id):
|
|||
def get_by_session_id(session_id, project_id, issue_type=None):
|
||||
with ch_client.ClickHouseClient() as cur:
|
||||
query = cur.format(query=f"""\
|
||||
SELECT *
|
||||
SELECT created_at, `$properties`
|
||||
FROM product_analytics.events
|
||||
WHERE session_id = %(session_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 ""}
|
||||
ORDER BY created_at;""",
|
||||
parameters={"session_id": session_id, "project_id": project_id, "type": issue_type})
|
||||
data = cur.execute(query)
|
||||
return helper.list_to_camel_case(data)
|
||||
rows = cur.execute(query)
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -260,6 +260,5 @@ def get_for_filters(project_id):
|
|||
"name": k,
|
||||
"displayName": metas[k],
|
||||
"possibleTypes": ["String"],
|
||||
"autoCaptured": False,
|
||||
"icon": None})
|
||||
"autoCaptured": False})
|
||||
return {"total": len(results), "list": results}
|
||||
|
|
|
|||
|
|
@ -172,7 +172,8 @@ def get_sessions_by_card_id(project: schemas.ProjectContext, user_id, metric_id,
|
|||
results = []
|
||||
for s in data.series:
|
||||
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
|
||||
|
||||
|
|
@ -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))
|
||||
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
q: Optional[str] = None):
|
||||
with ClickHouseClient() as ch_client:
|
||||
select = "value"
|
||||
select = "value, data_count"
|
||||
grouping = ""
|
||||
full_args = {"project_id": project_id, "limit": 20,
|
||||
"event_name": event_name, "property_name": property_name, "q": q,
|
||||
"property_name_l": helper.string_to_sql_like(property_name),
|
||||
"event_name": event_name, "property_name": property_name,
|
||||
"q_l": helper.string_to_sql_like(q)}
|
||||
|
||||
constraints = ["project_id = %(project_id)s",
|
||||
"_timestamp >= now()-INTERVAL 1 MONTH"]
|
||||
"_timestamp >= now()-INTERVAL 1 MONTH",
|
||||
"property_name = %(property_name)s"]
|
||||
if event_name:
|
||||
constraints += ["event_name = %(event_name)s"]
|
||||
|
||||
if property_name and q:
|
||||
constraints += ["property_name = %(property_name)s"]
|
||||
elif property_name:
|
||||
select = "DISTINCT ON(property_name) property_name AS value"
|
||||
constraints += ["property_name ILIKE %(property_name_l)s"]
|
||||
else:
|
||||
select = "value, sum(aepg.data_count) AS data_count"
|
||||
grouping = "GROUP BY 1"
|
||||
|
||||
if q:
|
||||
constraints += ["value ILIKE %(q_l)s"]
|
||||
|
||||
query = ch_client.format(
|
||||
f"""SELECT {select},data_count
|
||||
FROM product_analytics.autocomplete_event_properties_grouped
|
||||
WHERE {" AND ".join(constraints)}
|
||||
ORDER BY data_count DESC
|
||||
f"""SELECT {select}
|
||||
FROM product_analytics.autocomplete_event_properties_grouped AS aepg
|
||||
WHERE {" AND ".join(constraints)}
|
||||
{grouping}
|
||||
ORDER BY data_count DESC
|
||||
LIMIT %(limit)s;""",
|
||||
parameters=full_args)
|
||||
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}
|
||||
|
|
|
|||
|
|
@ -7,14 +7,13 @@ from chalicelib.utils.ch_client import ClickHouseClient
|
|||
from chalicelib.utils.exp_ch_helper import get_sub_condition, get_col_cast
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
PREDEFINED_EVENTS = {
|
||||
"CLICK": "String",
|
||||
"INPUT": "String",
|
||||
"LOCATION": "String",
|
||||
"ERROR": "String",
|
||||
"PERFORMANCE": "String",
|
||||
"REQUEST": "String"
|
||||
}
|
||||
PREDEFINED_EVENTS = [
|
||||
"CLICK",
|
||||
"INPUT",
|
||||
"LOCATION",
|
||||
"ERROR",
|
||||
"REQUEST"
|
||||
]
|
||||
|
||||
|
||||
def get_events(project_id: int, page: schemas.PaginatedSchema):
|
||||
|
|
|
|||
|
|
@ -58,6 +58,14 @@ PREDEFINED_PROPERTIES = {
|
|||
"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):
|
||||
with ClickHouseClient() as ch_client:
|
||||
|
|
@ -104,7 +112,7 @@ def get_all_properties(project_id: int, page: schemas.PaginatedSchema):
|
|||
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:
|
||||
r = ch_client.format(
|
||||
"""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
|
||||
AND all_properties.project_id = %(project_id)s
|
||||
AND event_properties.event_name = %(event_name)s
|
||||
AND event_properties.auto_captured = %(auto_captured)s
|
||||
GROUP BY ALL
|
||||
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 = helper.list_to_camel_case(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["_foundInPredefinedList"] = True
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ def search2_table(data: schemas.SessionsSearchPayloadSchema, project_id: int, de
|
|||
for e in data.events:
|
||||
if e.type == schemas.EventType.LOCATION:
|
||||
if e.operator not in extra_conditions:
|
||||
extra_conditions[e.operator] = schemas.SessionSearchEventSchema.model_validate({
|
||||
extra_conditions[e.operator] = schemas.SessionSearchEventSchema(**{
|
||||
"type": e.type,
|
||||
"isEvent": True,
|
||||
"value": [],
|
||||
|
|
@ -175,7 +175,7 @@ def search2_table(data: schemas.SessionsSearchPayloadSchema, project_id: int, de
|
|||
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.model_validate({
|
||||
extra_conditions[e.operator] = schemas.SessionSearchEventSchema(**{
|
||||
"type": e.type,
|
||||
"isEvent": True,
|
||||
"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_col} AS name,
|
||||
count(DISTINCT session_id) AS total,
|
||||
COALESCE(SUM(count(DISTINCT session_id)) OVER (), 0) AS total_count
|
||||
FROM (SELECT s.session_id AS session_id {extra_col}
|
||||
any(total_count) as total_count
|
||||
FROM (SELECT s.session_id AS session_id,
|
||||
count(DISTINCT s.session_id) OVER () AS total_count
|
||||
{extra_col}
|
||||
{query_part}) AS filtred_sessions
|
||||
{extra_where}
|
||||
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_col} AS name,
|
||||
count(DISTINCT user_id) AS total,
|
||||
COALESCE(SUM(count(DISTINCT user_id)) OVER (), 0) AS total_count
|
||||
FROM (SELECT s.user_id AS user_id {extra_col}
|
||||
any(total_count) AS total_count
|
||||
FROM (SELECT s.user_id AS user_id,
|
||||
count(DISTINCT s.user_id) OVER () AS total_count
|
||||
{extra_col}
|
||||
{query_part}
|
||||
WHERE isNotNull(user_id)
|
||||
AND notEmpty(user_id)) AS filtred_sessions
|
||||
|
|
|
|||
|
|
@ -64,8 +64,7 @@ def __parse_metadata(metadata_map):
|
|||
# This function executes the query and return result
|
||||
def search_sessions(data: schemas.SessionsSearchPayloadSchema, project: schemas.ProjectContext,
|
||||
user_id, errors_only=False, error_status=schemas.ErrorStatus.ALL,
|
||||
count_only=False, issue=None, ids_only=False):
|
||||
platform = project.platform
|
||||
count_only=False, issue=None, ids_only=False, metric_of: schemas.MetricOfTable = None):
|
||||
if data.bookmarked:
|
||||
data.startTimestamp, data.endTimestamp = sessions_favorite.get_start_end_timestamp(project.project_id, user_id)
|
||||
if data.startTimestamp is None:
|
||||
|
|
@ -75,18 +74,78 @@ def search_sessions(data: schemas.SessionsSearchPayloadSchema, project: schemas.
|
|||
'sessions': [],
|
||||
'_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":
|
||||
full_args, query_part = sessions.search_query_parts_ch(data=data, error_status=error_status,
|
||||
errors_only=errors_only,
|
||||
favorite_only=data.bookmarked, issue=issue,
|
||||
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:
|
||||
full_args, query_part = sessions_legacy_mobil.search_query_parts_ch(data=data, error_status=error_status,
|
||||
errors_only=errors_only,
|
||||
favorite_only=data.bookmarked, issue=issue,
|
||||
project_id=project.project_id,
|
||||
user_id=user_id, platform=platform)
|
||||
user_id=user_id, platform=project.platform)
|
||||
if data.sort == "startTs":
|
||||
data.sort = "datetime"
|
||||
if data.limit is not None and data.page is not None:
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ COALESCE((SELECT TRUE
|
|||
# This function executes the query and return result
|
||||
def search_sessions(data: schemas.SessionsSearchPayloadSchema, project: schemas.ProjectContext,
|
||||
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:
|
||||
data.startTimestamp, data.endTimestamp = sessions_favorite.get_start_end_timestamp(project.project_id, user_id)
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
import logging
|
||||
import math
|
||||
import re
|
||||
import struct
|
||||
from decimal import Decimal
|
||||
from typing import Union, Any
|
||||
|
||||
import schemas
|
||||
from chalicelib.utils import sql_helper as sh
|
||||
from chalicelib.utils.TimeUTC import TimeUTC
|
||||
from schemas import SearchEventOperator
|
||||
import math
|
||||
import struct
|
||||
from decimal import Decimal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -233,3 +234,16 @@ def best_clickhouse_type(value):
|
|||
return "Float64"
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -15,11 +15,11 @@ def random_string(length=36):
|
|||
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)):
|
||||
if flatten:
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -4,8 +4,9 @@ from decouple import config
|
|||
from fastapi import Depends, Body, BackgroundTasks
|
||||
|
||||
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
|
||||
from chalicelib.core.events import events
|
||||
from chalicelib.core.issues import issues
|
||||
from chalicelib.core.sourcemaps import sourcemaps
|
||||
from chalicelib.core.metrics import custom_metrics
|
||||
|
|
|
|||
|
|
@ -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}", 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,
|
||||
data: schemas.CardSessionsSchema = Body(...),
|
||||
data: schemas.SavedCardSchema = Body(...),
|
||||
context: schemas.CurrentContext = Depends(OR_context)):
|
||||
data = custom_metrics.make_chart_from_card(
|
||||
project=context.project, user_id=context.user_id, metric_id=metric_id, data=data, for_dashboard=True
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ public_app, app, app_apikey = get_routers()
|
|||
@app.get('/{projectId}/filters', tags=["product_analytics"])
|
||||
def get_all_filters(projectId: int, filter_query: Annotated[schemas.PaginatedSchema, Query()],
|
||||
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 {
|
||||
"data": {
|
||||
"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"])
|
||||
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)):
|
||||
if not event_name or len(event_name) == 0:
|
||||
if not en or len(en) == 0:
|
||||
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"])
|
||||
|
|
@ -63,15 +67,12 @@ def autocomplete_events(projectId: int, q: Optional[str] = None,
|
|||
|
||||
|
||||
@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)):
|
||||
if not propertyName and not eventName and not q:
|
||||
return {"error": ["Specify eventName to get top properties",
|
||||
"Specify propertyName to get top values of that property",
|
||||
"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,
|
||||
event_name=None if not eventName \
|
||||
or len(eventName) == 0 else eventName,
|
||||
property_name=None if not propertyName \
|
||||
or len(propertyName) == 0 else propertyName,
|
||||
property_name=propertyName,
|
||||
q=None if not q or len(q) == 0 else q)}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,9 @@ def schema_extra(schema: dict, _):
|
|||
class BaseModel(_BaseModel):
|
||||
model_config = ConfigDict(alias_generator=attribute_to_camel_case,
|
||||
use_enum_values=True,
|
||||
json_schema_extra=schema_extra)
|
||||
json_schema_extra=schema_extra,
|
||||
# extra='forbid'
|
||||
)
|
||||
|
||||
|
||||
class Enum(_Enum):
|
||||
|
|
|
|||
|
|
@ -1043,11 +1043,16 @@ class MetricOfPathAnalysis(str, Enum):
|
|||
session_count = MetricOfTimeseries.SESSION_COUNT.value
|
||||
|
||||
|
||||
# class CardSessionsSchema(SessionsSearchPayloadSchema):
|
||||
class CardSessionsSchema(_TimedSchema, _PaginatedSchema):
|
||||
startTimestamp: int = Field(default=TimeUTC.now(-7))
|
||||
endTimestamp: int = Field(default=TimeUTC.now())
|
||||
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)
|
||||
|
||||
# events: List[SessionSearchEventSchema2] = Field(default_factory=list, doc_hidden=True)
|
||||
|
|
@ -1112,6 +1117,11 @@ class CardSessionsSchema(_TimedSchema, _PaginatedSchema):
|
|||
return self
|
||||
|
||||
|
||||
class SavedCardSchema(CardSessionsSchema):
|
||||
metric_type: Optional[MetricType] = Field(default=None)
|
||||
metric_of: Optional[Any] = Field(default=None)
|
||||
|
||||
|
||||
class CardConfigSchema(BaseModel):
|
||||
col: Optional[int] = Field(default=None)
|
||||
row: Optional[int] = Field(default=2)
|
||||
|
|
@ -1125,8 +1135,6 @@ class __CardSchema(CardSessionsSchema):
|
|||
thumbnail: Optional[str] = Field(default=None)
|
||||
metric_format: Optional[MetricFormatType] = Field(default=None)
|
||||
view_type: Any
|
||||
metric_type: MetricType = Field(...)
|
||||
metric_of: Any
|
||||
metric_value: List[IssueType] = Field(default_factory=list)
|
||||
# This is used to save the selected session for heatmaps
|
||||
session_id: Optional[int] = Field(default=None)
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ func main() {
|
|||
messages.MsgMouseClickDeprecated, messages.MsgSetPageLocation, messages.MsgSetPageLocationDeprecated,
|
||||
messages.MsgPageLoadTiming, messages.MsgPageRenderTiming,
|
||||
messages.MsgPageEvent, messages.MsgPageEventDeprecated, messages.MsgMouseThrashing, messages.MsgInputChange,
|
||||
messages.MsgUnbindNodes, messages.MsgCanvasNode, messages.MsgTagTrigger,
|
||||
messages.MsgUnbindNodes, messages.MsgCanvasNode, messages.MsgTagTrigger, messages.MsgIncident,
|
||||
// Mobile messages
|
||||
messages.MsgMobileSessionStart, messages.MsgMobileSessionEnd, messages.MsgMobileUserID, messages.MsgMobileUserAnonymousID,
|
||||
messages.MsgMobileMetadata, messages.MsgMobileEvent, messages.MsgMobileNetworkCall,
|
||||
|
|
|
|||
|
|
@ -140,6 +140,11 @@ func (s *saverImpl) handleWebMessage(sessCtx context.Context, session *sessions.
|
|||
return err
|
||||
}
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ type Connector interface {
|
|||
InsertIssue(session *sessions.Session, msg *messages.IssueEvent) error
|
||||
InsertWebInputDuration(session *sessions.Session, msg *messages.InputChange) error
|
||||
InsertMouseThrashing(session *sessions.Session, msg *messages.MouseThrashing) error
|
||||
InsertIncident(session *sessions.Session, msg *messages.Incident) error
|
||||
InsertMobileSession(session *sessions.Session) error
|
||||
InsertMobileCustom(session *sessions.Session, msg *messages.MobileEvent) 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{
|
||||
"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))",
|
||||
"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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
"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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
"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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
"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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
"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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
"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 (?, ?, ?, ?)",
|
||||
|
|
@ -220,6 +221,8 @@ func (c *connectorImpl) InsertWebSession(session *sessions.Session) error {
|
|||
session.UtmSource,
|
||||
session.UtmMedium,
|
||||
session.UtmCampaign,
|
||||
session.ScreenWidth,
|
||||
session.ScreenHeight,
|
||||
); err != nil {
|
||||
c.checkError("sessions", 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 {
|
||||
jsonString, err := json.Marshal(map[string]interface{}{
|
||||
"payload": msg.Payload,
|
||||
"user_device": session.UserDevice,
|
||||
"user_device_type": session.UserDeviceType,
|
||||
"page_title ": msg.PageTitle,
|
||||
|
|
@ -733,6 +735,14 @@ func (c *connectorImpl) InsertCustom(session *sessions.Session, msg *messages.Cu
|
|||
if err != nil {
|
||||
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)
|
||||
if err := c.batches["custom"].Append(
|
||||
session.SessionID,
|
||||
|
|
@ -752,7 +762,8 @@ func (c *connectorImpl) InsertCustom(session *sessions.Session, msg *messages.Cu
|
|||
session.UserState,
|
||||
session.UserCity,
|
||||
cropString(msg.Url),
|
||||
jsonString,
|
||||
jsonString, // $properties
|
||||
customPayload, // properties
|
||||
); err != nil {
|
||||
c.checkError("custom", 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
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
func (c *connectorImpl) InsertMobileSession(session *sessions.Session) error {
|
||||
|
|
|
|||
|
|
@ -270,3 +270,15 @@ func (conn *Conn) InsertWebStatsPerformance(p *messages.PerformanceTrackAggr) er
|
|||
)
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,3 +38,11 @@ func MouseThrashingID(projectID uint32, sessID, ts uint64) string {
|
|||
hash.Write([]byte(strconv.FormatUint(ts, 10)))
|
||||
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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,4 +11,4 @@ func IsMobileType(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
|
||||
}
|
||||
}
|
||||
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
5
ee/api/.gitignore
vendored
|
|
@ -201,16 +201,17 @@ Pipfile.lock
|
|||
/chalicelib/core/metrics/heatmaps
|
||||
/chalicelib/core/metrics/product_analytics
|
||||
/chalicelib/core/metrics/product_anaytics2.py
|
||||
/chalicelib/core/events
|
||||
/chalicelib/core/events*
|
||||
/chalicelib/core/feature_flags.py
|
||||
/chalicelib/core/issue_tracking/*
|
||||
/chalicelib/core/issues.py
|
||||
/chalicelib/core/issues/
|
||||
/chalicelib/core/jobs.py
|
||||
/chalicelib/core/log_tools/*
|
||||
/chalicelib/core/metadata.py
|
||||
/chalicelib/core/mobile.py
|
||||
/chalicelib/core/saved_search.py
|
||||
/chalicelib/core/sessions/*.py
|
||||
/chalicelib/core/sessions/**/*.py
|
||||
/chalicelib/core/sessions/sessions_viewed
|
||||
/chalicelib/core/metrics/modules
|
||||
/chalicelib/core/socket_ios.py
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ python-decouple = "==3.8"
|
|||
pydantic = {extras = ["email"], version = "==2.11.4"}
|
||||
apscheduler = "==3.11.0"
|
||||
python3-saml = "==1.16.0"
|
||||
lxml = "==5.3.0"
|
||||
xmlsec = "==1.3.14"
|
||||
python-multipart = "==0.0.20"
|
||||
redis = "==6.1.0"
|
||||
azure-storage-blob = "==12.25.1"
|
||||
|
|
|
|||
|
|
@ -101,4 +101,4 @@ rm -rf ./chalicelib/core/errors/errors_details.py
|
|||
rm -rf ./chalicelib/core/notes.py
|
||||
rm -rf ./chalicelib/utils/contextual_validators.py
|
||||
rm -rf ./routers/subs/product_analytics.py
|
||||
rm -rf ./schemas/product_analytics.py
|
||||
rm -rf ./schemas/product_analytics.py
|
||||
|
|
|
|||
|
|
@ -19,7 +19,8 @@ apscheduler==3.11.0
|
|||
# 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
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -275,8 +275,7 @@ def get_projects(context: schemas.CurrentContext = Depends(OR_context)):
|
|||
def search_sessions(projectId: int, data: schemas.SessionsSearchPayloadSchema = \
|
||||
Depends(contextual_validators.validate_contextual_payload),
|
||||
context: schemas.CurrentContext = Depends(OR_context)):
|
||||
data = sessions_search.search_sessions(data=data, project=context.project, user_id=context.user_id,
|
||||
platform=context.project.platform)
|
||||
data = sessions_search.search_sessions(data=data, project=context.project, user_id=context.user_id)
|
||||
return {'data': data}
|
||||
|
||||
|
||||
|
|
@ -285,8 +284,7 @@ def search_sessions(projectId: int, data: schemas.SessionsSearchPayloadSchema =
|
|||
def session_ids_search(projectId: int, data: schemas.SessionsSearchPayloadSchema = \
|
||||
Depends(contextual_validators.validate_contextual_payload),
|
||||
context: schemas.CurrentContext = Depends(OR_context)):
|
||||
data = sessions_search.search_sessions(data=data, project=context.project, user_id=context.user_id, ids_only=True,
|
||||
platform=context.project.platform)
|
||||
data = sessions_search.search_sessions(data=data, project=context.project, user_id=context.user_id, ids_only=True)
|
||||
return {'data': data}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3,4 +3,4 @@ from .schemas_ee import *
|
|||
from .assist_stats_schema import *
|
||||
from .product_analytics import *
|
||||
from . import overrides as _overrides
|
||||
from .schemas import _PaginatedSchema as PaginatedSchema
|
||||
from .schemas import _PaginatedSchema as PaginatedSchema
|
||||
|
|
|
|||
|
|
@ -829,6 +829,15 @@ class ResourceTiming(Message):
|
|||
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):
|
||||
__id__ = 89
|
||||
|
||||
|
|
|
|||
|
|
@ -1241,6 +1241,19 @@ cdef class ResourceTiming(PyMessage):
|
|||
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 public int __id__
|
||||
cdef public str name
|
||||
|
|
|
|||
|
|
@ -750,6 +750,13 @@ class MessageCodec(Codec):
|
|||
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:
|
||||
return LongAnimationTask(
|
||||
name=self.read_string(reader),
|
||||
|
|
|
|||
|
|
@ -848,6 +848,13 @@ cdef class MessageCodec:
|
|||
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:
|
||||
return LongAnimationTask(
|
||||
name=self.read_string(reader),
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
SET allow_experimental_json_type = 1;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
DROP TABLE IF EXISTS product_analytics.all_events;
|
||||
|
|
@ -41,26 +54,27 @@ CREATE TABLE IF NOT EXISTS product_analytics.event_properties
|
|||
event_name String,
|
||||
property_name String,
|
||||
value_type String,
|
||||
auto_captured BOOL,
|
||||
|
||||
_timestamp DateTime DEFAULT now()
|
||||
) 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
|
||||
TO product_analytics.event_properties AS
|
||||
SELECT project_id,
|
||||
`$event_name` AS event_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
|
||||
ARRAY JOIN JSONExtractKeys(toString(`$properties`)) as property_name;
|
||||
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS product_analytics.event_cproperties_extractor
|
||||
TO product_analytics.event_properties AS
|
||||
ARRAY JOIN JSONExtractKeys(toString(`$properties`)) as property_name
|
||||
UNION DISTINCT
|
||||
SELECT project_id,
|
||||
`$event_name` AS event_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
|
||||
ARRAY JOIN JSONExtractKeys(toString(`properties`)) as property_name;
|
||||
|
||||
|
|
@ -105,10 +119,8 @@ FROM product_analytics.events
|
|||
WHERE (all_properties.display_name != ''
|
||||
OR all_properties.description != '')
|
||||
AND is_event_property) AS old_data
|
||||
ON (events.project_id = old_data.project_id AND property_name = old_data.property_name);
|
||||
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS product_analytics.all_cproperties_extractor_mv
|
||||
TO product_analytics.all_properties AS
|
||||
ON (events.project_id = old_data.project_id AND property_name = old_data.property_name)
|
||||
UNION DISTINCT
|
||||
SELECT project_id,
|
||||
property_name,
|
||||
TRUE AS is_event_property,
|
||||
|
|
@ -155,7 +167,7 @@ FROM product_analytics.events
|
|||
WHERE randCanonical() < 0.5 -- This randomly skips inserts
|
||||
AND value != ''
|
||||
LIMIT 2 BY project_id,property_name
|
||||
UNION ALL
|
||||
UNION DISTINCT
|
||||
SELECT project_id,
|
||||
property_name,
|
||||
TRUE AS is_event_property,
|
||||
|
|
@ -225,6 +237,16 @@ SELECT project_id,
|
|||
_timestamp
|
||||
FROM product_analytics.events
|
||||
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))
|
||||
AND _timestamp > now() - INTERVAL 1 MONTH;
|
||||
|
||||
|
|
|
|||
|
|
@ -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_city 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,
|
||||
timezone LowCardinality(Nullable(String)),
|
||||
duration UInt32,
|
||||
|
|
@ -134,7 +134,7 @@ CREATE TABLE IF NOT EXISTS experimental.sessions
|
|||
metadata_8 Nullable(String),
|
||||
metadata_9 Nullable(String),
|
||||
metadata_10 Nullable(String),
|
||||
_timestamp DateTime DEFAULT now()
|
||||
_timestamp DateTime DEFAULT now()
|
||||
) ENGINE = ReplacingMergeTree(_timestamp)
|
||||
PARTITION BY toYYYYMMDD(datetime)
|
||||
ORDER BY (project_id, datetime, session_id)
|
||||
|
|
@ -676,29 +676,29 @@ CREATE TABLE IF NOT EXISTS product_analytics.event_properties
|
|||
event_name String,
|
||||
property_name String,
|
||||
value_type String,
|
||||
auto_captured BOOL,
|
||||
|
||||
_timestamp DateTime DEFAULT now()
|
||||
) 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 -------------
|
||||
-- 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
|
||||
TO product_analytics.event_properties AS
|
||||
SELECT project_id,
|
||||
`$event_name` AS event_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
|
||||
ARRAY JOIN JSONExtractKeys(toString(`$properties`)) as property_name;
|
||||
|
||||
-- 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
|
||||
ARRAY JOIN JSONExtractKeys(toString(`$properties`)) as property_name
|
||||
UNION DISTINCT
|
||||
SELECT project_id,
|
||||
`$event_name` AS event_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
|
||||
ARRAY JOIN JSONExtractKeys(toString(`properties`)) as property_name;
|
||||
-- -------- 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 -------------
|
||||
-- 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
|
||||
TO product_analytics.all_properties AS
|
||||
SELECT project_id,
|
||||
|
|
@ -748,11 +748,8 @@ FROM product_analytics.events
|
|||
WHERE (all_properties.display_name != ''
|
||||
OR all_properties.description != '')
|
||||
AND is_event_property) AS old_data
|
||||
ON (events.project_id = old_data.project_id AND property_name = old_data.property_name);
|
||||
|
||||
-- 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
|
||||
ON (events.project_id = old_data.project_id AND property_name = old_data.property_name)
|
||||
UNION DISTINCT
|
||||
SELECT project_id,
|
||||
property_name,
|
||||
TRUE AS is_event_property,
|
||||
|
|
@ -802,7 +799,7 @@ FROM product_analytics.events
|
|||
WHERE randCanonical() < 0.5 -- This randomly skips inserts
|
||||
AND value != ''
|
||||
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
|
||||
SELECT project_id,
|
||||
property_name,
|
||||
|
|
@ -873,6 +870,16 @@ SELECT project_id,
|
|||
_timestamp
|
||||
FROM product_analytics.events
|
||||
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))
|
||||
AND _timestamp > now() - INTERVAL 1 MONTH;
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ DROP SCHEMA IF EXISTS or_cache CASCADE;
|
|||
ALTER TABLE public.tenants
|
||||
ALTER COLUMN scope_state SET DEFAULT 2;
|
||||
|
||||
ALTER TYPE issue_type ADD VALUE IF NOT EXISTS 'incident';
|
||||
|
||||
COMMIT;
|
||||
|
||||
\elif :is_next
|
||||
|
|
|
|||
|
|
@ -352,7 +352,8 @@ CREATE TYPE issue_type AS ENUM (
|
|||
'custom',
|
||||
'js_exception',
|
||||
'mouse_thrashing',
|
||||
'app_crash'
|
||||
'app_crash',
|
||||
'incident'
|
||||
);
|
||||
|
||||
CREATE TABLE public.issues
|
||||
|
|
|
|||
|
|
@ -195,7 +195,7 @@ const EChartsSankey: React.FC<Props> = (props) => {
|
|||
header: {
|
||||
fontWeight: '600',
|
||||
fontSize: 12,
|
||||
color: '#333',
|
||||
color: 'var(--color-gray-darkest)',
|
||||
overflow: 'truncate',
|
||||
paddingBottom: '.5rem',
|
||||
paddingLeft: '14px',
|
||||
|
|
@ -203,16 +203,16 @@ const EChartsSankey: React.FC<Props> = (props) => {
|
|||
},
|
||||
body: {
|
||||
fontSize: 12,
|
||||
color: '#000',
|
||||
color: 'var(--color-black)',
|
||||
},
|
||||
percentage: {
|
||||
fontSize: 12,
|
||||
color: '#454545',
|
||||
color: 'var(--color-gray-dark)',
|
||||
},
|
||||
sessions: {
|
||||
fontSize: 12,
|
||||
fontFamily: "mono, 'monospace', sans-serif",
|
||||
color: '#999999',
|
||||
color: 'var(--color-gray-dark)',
|
||||
},
|
||||
clickIcon: {
|
||||
backgroundColor: {
|
||||
|
|
@ -266,6 +266,7 @@ const EChartsSankey: React.FC<Props> = (props) => {
|
|||
},
|
||||
tooltip: {
|
||||
formatter: sankeyTooltip(echartNodes, nodeValues),
|
||||
backgroundColor: 'var(--color-white)',
|
||||
},
|
||||
nodeAlign: 'left',
|
||||
nodeWidth: 40,
|
||||
|
|
|
|||
|
|
@ -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 gap-2 items-center">
|
||||
<div style="
|
||||
border-radius: 99px;
|
||||
background: ${params.color};
|
||||
width: 1rem;
|
||||
border-radius: 99px;
|
||||
background: ${params.color};
|
||||
width: 1rem;
|
||||
height: 1rem;">
|
||||
</div>
|
||||
<div class="font-medium text-black">${fullname}</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div style="border-left: 2px solid ${
|
||||
params.color
|
||||
};" class="flex flex-col px-2 ml-2">
|
||||
<div class="text-neutral-600 text-sm">
|
||||
<div class="text-neutral-600 text-sm">
|
||||
Total:
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
|
|
@ -127,7 +127,7 @@ export function customTooltipFormatter(uuid: string) {
|
|||
(window as any).__seriesColorMap?.[uuid]?.[partnerName] || '#999';
|
||||
str += `
|
||||
<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:
|
||||
</div>
|
||||
<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 gap-2 items-center">
|
||||
<div style="
|
||||
border-radius: 99px;
|
||||
background: ${params.color};
|
||||
width: 1rem;
|
||||
border-radius: 99px;
|
||||
background: ${params.color};
|
||||
width: 1rem;
|
||||
height: 1rem;">
|
||||
</div>
|
||||
<div class="font-medium text-black">${seriesName}</div>
|
||||
|
|
@ -179,7 +179,7 @@ export function customTooltipFormatter(uuid: string) {
|
|||
<div style="border-left: 2px solid ${
|
||||
params.color
|
||||
};" 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}
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
|
|
@ -194,7 +194,7 @@ export function customTooltipFormatter(uuid: string) {
|
|||
(window as any).__seriesColorMap?.[uuid]?.[partnerName] || '#999';
|
||||
tooltipContent += `
|
||||
<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}
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
|
|
@ -229,13 +229,13 @@ function buildCompareTag(val: number, prevVal: number): string {
|
|||
|
||||
return `
|
||||
<div style="
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: ${tagColor};
|
||||
color: ${arrowColor};
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: ${tagColor};
|
||||
color: ${arrowColor};
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;">
|
||||
<span>${arrow}</span>
|
||||
<span>${absDelta}</span>
|
||||
|
|
@ -290,7 +290,7 @@ export function createSeries(
|
|||
datasetId,
|
||||
encode: { x: 'idx', y: fullName },
|
||||
lineStyle: dashed ? { type: 'dashed' } : undefined,
|
||||
showSymbol: false,
|
||||
showSymbol: data.chart.length === 1,
|
||||
// custom flag to hide prev data from legend
|
||||
_hideInLegend: hideFromLegend,
|
||||
itemStyle: { opacity: 1 },
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
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 { useStore } from '@/mstore';
|
||||
import { useModal } from 'Components/ModalContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { confirm } from 'UI';
|
||||
interface Props {
|
||||
tag: any;
|
||||
projectId: number;
|
||||
|
|
@ -23,14 +23,16 @@ function TagForm(props: Props) {
|
|||
};
|
||||
|
||||
const onDelete = async () => {
|
||||
Modal.confirm({
|
||||
title: t('Tag'),
|
||||
content: t('Are you sure you want to remove?'),
|
||||
onOk: async () => {
|
||||
await tagWatchStore.deleteTag(tag.tagId, projectId);
|
||||
closeModal();
|
||||
},
|
||||
});
|
||||
if (
|
||||
await confirm({
|
||||
header: t('Remove Tag'),
|
||||
confirmButton: t('Remove'),
|
||||
confirmation: t('Are you sure you want to remove this tag?'),
|
||||
})
|
||||
) {
|
||||
await tagWatchStore.deleteTag(tag.tagId, projectId);
|
||||
closeModal();
|
||||
}
|
||||
};
|
||||
|
||||
const onSave = async () => {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ function BottomButtons({
|
|||
<Button
|
||||
loading={loading}
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
disabled={loading || !instance.validate()}
|
||||
id="submit-button"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ function Condition({
|
|||
<label className="w-1/6 flex-shrink-0 font-normal">{t('is')}</label>
|
||||
<div className="w-2/6 flex items-center">
|
||||
<Select
|
||||
popupMatchSelectWidth={false}
|
||||
placeholder={t('Select Condition')}
|
||||
options={localizedConditions}
|
||||
name="operator"
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ function DashboardView(props: Props) {
|
|||
};
|
||||
|
||||
useEffect(() => {
|
||||
dashboardStore.resetPeriod();
|
||||
if (queryParams.has('modal')) {
|
||||
onAddWidgets();
|
||||
trimQuery();
|
||||
|
|
|
|||
|
|
@ -7,19 +7,18 @@ import {
|
|||
Button,
|
||||
Dropdown,
|
||||
Modal as AntdModal,
|
||||
Avatar, TableColumnType, Spin
|
||||
Avatar,
|
||||
TableColumnType,
|
||||
Spin,
|
||||
} from 'antd';
|
||||
import {
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { EditOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import { EllipsisVertical } from 'lucide-react';
|
||||
import { TablePaginationConfig, SorterResult } from 'antd/lib/table/interface';
|
||||
import { useStore } from 'App/mstore';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useHistory } from 'react-router';
|
||||
import { withSiteId } from 'App/routes';
|
||||
import { Icon } from 'UI';
|
||||
import { Icon, confirm } from 'UI';
|
||||
import cn from 'classnames';
|
||||
import { TYPE_ICONS, TYPE_NAMES } from 'App/constants/card';
|
||||
import Widget from 'App/mstore/types/widget';
|
||||
|
|
@ -45,7 +44,7 @@ const ListView: React.FC<Props> = ({
|
|||
toggleSelection,
|
||||
disableSelection = false,
|
||||
inLibrary = false,
|
||||
loading = false
|
||||
loading = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [editingMetricId, setEditingMetricId] = useState<number | null>(null);
|
||||
|
|
@ -63,7 +62,7 @@ const ListView: React.FC<Props> = ({
|
|||
<Text strong>
|
||||
{Math.min(
|
||||
(metricStore.pageSize || 10) * (metricStore.page || 1),
|
||||
list.length
|
||||
list.length,
|
||||
)}
|
||||
</Text>{' '}
|
||||
{t('of')} <Text strong>{list.length}</Text> {t('cards')}
|
||||
|
|
@ -124,15 +123,17 @@ const ListView: React.FC<Props> = ({
|
|||
|
||||
const onMenuClick = async (metric: Widget, { key }: { key: string }) => {
|
||||
if (key === 'delete') {
|
||||
AntdModal.confirm({
|
||||
title: t('Confirm'),
|
||||
content: t('Are you sure you want to permanently delete this card?'),
|
||||
okText: t('Yes, delete'),
|
||||
cancelText: t('No'),
|
||||
onOk: async () => {
|
||||
await metricStore.delete(metric);
|
||||
}
|
||||
});
|
||||
if (
|
||||
await confirm({
|
||||
header: t('Delete Card'),
|
||||
confirmButton: t('Delete'),
|
||||
confirmation: t(
|
||||
'Are you sure you want to permanently delete this card? This action cannot be undone.',
|
||||
),
|
||||
})
|
||||
) {
|
||||
await metricStore.delete(metric);
|
||||
}
|
||||
}
|
||||
if (key === 'rename') {
|
||||
setEditingMetricId(metric.metricId);
|
||||
|
|
@ -155,7 +156,7 @@ const ListView: React.FC<Props> = ({
|
|||
|
||||
const menuItems = [
|
||||
{ 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) => (
|
||||
|
|
@ -201,9 +202,10 @@ const ListView: React.FC<Props> = ({
|
|||
key: 'title',
|
||||
className: 'cap-first pl-4',
|
||||
sorter: true,
|
||||
sortOrder: metricStore.sort.field === 'name' ? metricStore.sort.order : undefined,
|
||||
sortOrder:
|
||||
metricStore.sort.field === 'name' ? metricStore.sort.order : undefined,
|
||||
width: inLibrary ? '31%' : '25%',
|
||||
render: renderTitle
|
||||
render: renderTitle,
|
||||
},
|
||||
{
|
||||
title: t('Owner'),
|
||||
|
|
@ -211,19 +213,25 @@ const ListView: React.FC<Props> = ({
|
|||
key: 'owner',
|
||||
className: 'capitalize',
|
||||
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%',
|
||||
render: renderOwner
|
||||
render: renderOwner,
|
||||
},
|
||||
{
|
||||
title: t('Last Modified'),
|
||||
dataIndex: 'edited_at',
|
||||
key: 'lastModified',
|
||||
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%',
|
||||
render: renderLastModified
|
||||
}
|
||||
render: renderLastModified,
|
||||
},
|
||||
];
|
||||
|
||||
if (!inLibrary) {
|
||||
|
|
@ -232,14 +240,14 @@ const ListView: React.FC<Props> = ({
|
|||
key: 'options',
|
||||
className: 'text-right',
|
||||
width: '5%',
|
||||
render: renderOptions
|
||||
render: renderOptions,
|
||||
});
|
||||
}
|
||||
|
||||
const handleTableChange = (
|
||||
pag: TablePaginationConfig,
|
||||
_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;
|
||||
let order = sorter.order;
|
||||
|
|
@ -268,19 +276,19 @@ const ListView: React.FC<Props> = ({
|
|||
onRow={
|
||||
inLibrary
|
||||
? (record) => ({
|
||||
onClick: () => {
|
||||
if (!disableSelection) toggleSelection?.(record?.metricId);
|
||||
}
|
||||
})
|
||||
onClick: () => {
|
||||
if (!disableSelection) toggleSelection?.(record?.metricId);
|
||||
},
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
rowSelection={
|
||||
!disableSelection
|
||||
? {
|
||||
selectedRowKeys: selectedList,
|
||||
onChange: (keys) => toggleSelection && toggleSelection(keys),
|
||||
columnWidth: 16
|
||||
}
|
||||
selectedRowKeys: selectedList,
|
||||
onChange: (keys) => toggleSelection && toggleSelection(keys),
|
||||
columnWidth: 16,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
pagination={{
|
||||
|
|
@ -292,7 +300,7 @@ const ListView: React.FC<Props> = ({
|
|||
showLessItems: true,
|
||||
showTotal: () => totalMessage,
|
||||
size: 'small',
|
||||
simple: true
|
||||
simple: true,
|
||||
}}
|
||||
/>
|
||||
<AntdModal
|
||||
|
|
|
|||
|
|
@ -10,10 +10,10 @@ import AddCardSection from '../AddCardSection/AddCardSection';
|
|||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function MetricsList({
|
||||
siteId,
|
||||
onSelectionChange,
|
||||
inLibrary
|
||||
}: {
|
||||
siteId,
|
||||
onSelectionChange,
|
||||
inLibrary,
|
||||
}: {
|
||||
siteId: string;
|
||||
onSelectionChange?: (selected: any[]) => void;
|
||||
inLibrary?: boolean;
|
||||
|
|
@ -26,16 +26,16 @@ function MetricsList({
|
|||
const dashboard = dashboardStore.selectedDashboard;
|
||||
const existingCardIds = useMemo(
|
||||
() => dashboard?.widgets?.map((i) => parseInt(i.metricId)),
|
||||
[dashboard]
|
||||
[dashboard],
|
||||
);
|
||||
const cards = useMemo(
|
||||
() =>
|
||||
onSelectionChange
|
||||
? metricStore.filteredCards.filter(
|
||||
(i) => !existingCardIds?.includes(parseInt(i.metricId))
|
||||
)
|
||||
(i) => !existingCardIds?.includes(parseInt(i.metricId)),
|
||||
)
|
||||
: metricStore.filteredCards,
|
||||
[metricStore.filteredCards, existingCardIds, onSelectionChange]
|
||||
[metricStore.filteredCards, existingCardIds, onSelectionChange],
|
||||
);
|
||||
const loading = metricStore.isLoading;
|
||||
|
||||
|
|
@ -66,7 +66,8 @@ function MetricsList({
|
|||
metricStore.updateKey('sessionsPage', 1);
|
||||
}, [metricStore]);
|
||||
|
||||
const isFiltered = metricStore.filter.query !== '' || metricStore.filter.type !== '';
|
||||
const isFiltered =
|
||||
metricStore.filter.query !== '' || metricStore.filter.type !== '';
|
||||
|
||||
const searchImageDimensions = { width: 60, height: 'auto' };
|
||||
const defaultImageDimensions = { width: 600, height: 'auto' };
|
||||
|
|
@ -93,7 +94,9 @@ function MetricsList({
|
|||
) : (
|
||||
<div className="flex flex-col items-center">
|
||||
<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>
|
||||
<Popover
|
||||
arrow={false}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ interface Props {
|
|||
isSaved?: boolean;
|
||||
isTemplate?: boolean;
|
||||
isPreview?: boolean;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
function WidgetChart(props: Props) {
|
||||
|
|
@ -52,7 +53,7 @@ function WidgetChart(props: Props) {
|
|||
triggerOnce: true,
|
||||
rootMargin: '200px 0px',
|
||||
});
|
||||
const { isSaved = false, metric, isTemplate } = props;
|
||||
const { isSaved = false, metric, isTemplate, height } = props;
|
||||
const { dashboardStore, metricStore } = useStore();
|
||||
const _metric: any = props.metric;
|
||||
const data = _metric.data;
|
||||
|
|
@ -283,6 +284,7 @@ function WidgetChart(props: Props) {
|
|||
hideLegend
|
||||
onClick={onChartClick}
|
||||
label={t('Conversion')}
|
||||
height={height}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -293,6 +295,7 @@ function WidgetChart(props: Props) {
|
|||
data={data}
|
||||
compData={compData}
|
||||
isWidget={isSaved || isTemplate}
|
||||
height={height}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -308,6 +311,7 @@ function WidgetChart(props: Props) {
|
|||
metric={defaultMetric}
|
||||
data={data}
|
||||
predefinedKey={_metric.metricOf}
|
||||
height={height}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -331,6 +335,7 @@ function WidgetChart(props: Props) {
|
|||
compData={compDataCopy}
|
||||
onSeriesFocus={onFocus}
|
||||
onClick={onChartClick}
|
||||
height={height}
|
||||
label={
|
||||
_metric.metricOf === 'sessionCount'
|
||||
? t('Number of Sessions')
|
||||
|
|
@ -360,6 +365,7 @@ function WidgetChart(props: Props) {
|
|||
return (
|
||||
<BarChart
|
||||
inGrid={!props.isPreview}
|
||||
height={height}
|
||||
data={chartData}
|
||||
compData={compDataCopy}
|
||||
params={params}
|
||||
|
|
@ -378,6 +384,7 @@ function WidgetChart(props: Props) {
|
|||
if (viewType === 'progressChart') {
|
||||
return (
|
||||
<ColumnChart
|
||||
height={height}
|
||||
inGrid={!props.isPreview}
|
||||
horizontal
|
||||
data={chartData}
|
||||
|
|
@ -396,6 +403,7 @@ function WidgetChart(props: Props) {
|
|||
if (viewType === 'pieChart') {
|
||||
return (
|
||||
<PieChart
|
||||
height={height}
|
||||
inGrid={!props.isPreview}
|
||||
data={chartData}
|
||||
onSeriesFocus={onFocus}
|
||||
|
|
@ -412,6 +420,7 @@ function WidgetChart(props: Props) {
|
|||
<CustomMetricPercentage
|
||||
inGrid={!props.isPreview}
|
||||
data={data[0]}
|
||||
height={height}
|
||||
colors={colors}
|
||||
params={params}
|
||||
label={
|
||||
|
|
@ -451,6 +460,7 @@ function WidgetChart(props: Props) {
|
|||
return (
|
||||
<BugNumChart
|
||||
values={values}
|
||||
height={height}
|
||||
inGrid={!props.isPreview}
|
||||
colors={colors}
|
||||
onSeriesFocus={onFocus}
|
||||
|
|
@ -470,6 +480,7 @@ function WidgetChart(props: Props) {
|
|||
<CustomMetricTableSessions
|
||||
metric={_metric}
|
||||
data={data}
|
||||
height={height}
|
||||
isTemplate={isTemplate}
|
||||
isEdit={!isSaved && !isTemplate}
|
||||
/>
|
||||
|
|
@ -480,6 +491,7 @@ function WidgetChart(props: Props) {
|
|||
<CustomMetricTableErrors
|
||||
metric={_metric}
|
||||
data={data}
|
||||
height={height}
|
||||
// isTemplate={isTemplate}
|
||||
isEdit={!isSaved && !isTemplate}
|
||||
/>
|
||||
|
|
@ -490,6 +502,7 @@ function WidgetChart(props: Props) {
|
|||
<SessionsBy
|
||||
metric={_metric}
|
||||
data={data}
|
||||
height={height}
|
||||
onClick={onChartClick}
|
||||
isTemplate={isTemplate}
|
||||
/>
|
||||
|
|
@ -518,18 +531,18 @@ function WidgetChart(props: Props) {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
return <ClickMapCard />;
|
||||
return <ClickMapCard height={height} />;
|
||||
}
|
||||
|
||||
if (metricType === INSIGHTS) {
|
||||
return <InsightsCard data={data} />;
|
||||
return <InsightsCard height={height} data={data} />;
|
||||
}
|
||||
|
||||
if (metricType === USER_PATH && data && data.links) {
|
||||
const isUngrouped = props.isPreview
|
||||
? !(_metric.hideExcess ?? true)
|
||||
: false;
|
||||
const height = props.isPreview ? 550 : 240;
|
||||
const height = props.height ? props.height : props.isPreview ? 550 : 240;
|
||||
return (
|
||||
<SankeyChart
|
||||
height={height}
|
||||
|
|
@ -548,6 +561,7 @@ function WidgetChart(props: Props) {
|
|||
if (viewType === 'trend') {
|
||||
return (
|
||||
<LineChart
|
||||
height={height}
|
||||
data={data}
|
||||
colors={colors}
|
||||
params={params}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,12 @@ function WidgetPreview(props: Props) {
|
|||
metric.viewType,
|
||||
);
|
||||
// [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;
|
||||
return (
|
||||
<div className={cn(className, 'bg-white rounded-xl border shadow-sm mt-0')}>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useHistory } from 'react-router';
|
||||
import { useStore } from 'App/mstore';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { Button, Dropdown, MenuProps, Modal } from 'antd';
|
||||
import { Button, Dropdown, MenuProps } from 'antd';
|
||||
import {
|
||||
BellIcon,
|
||||
EllipsisVertical,
|
||||
|
|
@ -14,6 +14,7 @@ import { useModal } from 'Components/ModalContext';
|
|||
import AlertFormModal from 'Components/Alerts/AlertFormModal/AlertFormModal';
|
||||
import { showAddToDashboardModal } from 'Components/Dashboard/components/AddToDashboardButton';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { confirm } from 'UI';
|
||||
|
||||
function CardViewMenu() {
|
||||
const { t } = useTranslation();
|
||||
|
|
@ -51,29 +52,25 @@ function CardViewMenu() {
|
|||
label: t('Delete'),
|
||||
icon: <TrashIcon size={15} />,
|
||||
disabled: !widget.exists(),
|
||||
onClick: () => {
|
||||
Modal.confirm({
|
||||
title: t('Confirm Card Deletion'),
|
||||
icon: null,
|
||||
content:
|
||||
t('Are you sure you want to remove this card? This action is permanent and cannot be undone.'),
|
||||
footer: (_, { OkBtn, CancelBtn }) => (
|
||||
<>
|
||||
<CancelBtn />
|
||||
<OkBtn />
|
||||
</>
|
||||
),
|
||||
onOk: () => {
|
||||
metricStore
|
||||
.delete(widget)
|
||||
.then(() => {
|
||||
history.goBack();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(t('Failed to remove card'));
|
||||
});
|
||||
},
|
||||
});
|
||||
onClick: async () => {
|
||||
if (
|
||||
await confirm({
|
||||
header: t('Remove Card'),
|
||||
confirmButton: t('Remove'),
|
||||
confirmation: t(
|
||||
'Are you sure you want to remove this card? This action is permanent and cannot be undone.',
|
||||
),
|
||||
})
|
||||
) {
|
||||
metricStore
|
||||
.delete(widget)
|
||||
.then(() => {
|
||||
history.goBack();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(t('Failed to remove card'));
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -72,7 +72,6 @@ function WidgetView({
|
|||
const params = new URLSearchParams(location.search);
|
||||
const mk = params.get('mk');
|
||||
if (mk) {
|
||||
metricStore.init();
|
||||
const selectedCard = CARD_LIST(t).find((c) => c.key === mk) as CardType;
|
||||
if (selectedCard) {
|
||||
const cardData: any = {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import React from 'react';
|
||||
import { Table, Tooltip } from 'antd';
|
||||
import { Table, Dropdown } from 'antd';
|
||||
import type { TableProps } from 'antd';
|
||||
import Widget from 'App/mstore/types/widget';
|
||||
import Funnel from 'App/mstore/types/funnel';
|
||||
import { ItemMenu } from 'UI';
|
||||
import { EllipsisVertical } from 'lucide-react';
|
||||
import { exportAntCsv } from '../../../utils';
|
||||
import { exportAntCsv } from 'App/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -111,19 +110,20 @@ export function TableExporter({
|
|||
const { t } = useTranslation();
|
||||
const onClick = () => exportAntCsv(tableColumns, tableData, filename);
|
||||
return (
|
||||
<Tooltip title={t('Export Data to CSV')}>
|
||||
<div className={`absolute ${top || 'top-0'} ${right || '-right-1'}`}>
|
||||
<ItemMenu
|
||||
items={[{ icon: 'download', text: 'Export to CSV', onClick }]}
|
||||
bold
|
||||
customTrigger={
|
||||
<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">
|
||||
<EllipsisVertical size={16} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div
|
||||
className={`absolute ${top || 'top-0'} ${right || '-right-1'}`}
|
||||
style={{ zIndex: 10 }}
|
||||
>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [{ key: 'download', label: 'Export to CSV', onClick }],
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,13 +10,16 @@ import { useStore } from 'App/mstore';
|
|||
import { observer } from 'mobx-react-lite';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import ChatsModal from './components/ChatsModal';
|
||||
import { kaiStore } from './KaiStore';
|
||||
|
||||
function KaiChat() {
|
||||
const { userStore, projectsStore } = useStore();
|
||||
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 userLetter = userStore.account.name[0].toUpperCase();
|
||||
const userName = userStore.account.name;
|
||||
const limited = kaiStore.usage.percent >= 100;
|
||||
const { activeSiteId } = projectsStore;
|
||||
const [section, setSection] = React.useState<'intro' | 'chat'>('intro');
|
||||
const [threadId, setThreadId] = React.useState<string | null>(null);
|
||||
|
|
@ -36,13 +39,18 @@ function KaiChat() {
|
|||
showModal(
|
||||
<ChatsModal
|
||||
projectId={activeSiteId!}
|
||||
onHide={hideModal}
|
||||
onSelect={(threadId: string, title: string) => {
|
||||
setTitle(title);
|
||||
setThreadId(threadId);
|
||||
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);
|
||||
if (newThread) {
|
||||
setThreadId(newThread.toString());
|
||||
kaiStore.setTitle(null);
|
||||
setSection('chat');
|
||||
} else {
|
||||
toast.error("Something wen't wrong. Please try again later.");
|
||||
}
|
||||
};
|
||||
|
||||
const onCancel = () => {
|
||||
if (!threadId) return;
|
||||
void kaiStore.cancelGeneration({
|
||||
projectId: activeSiteId,
|
||||
threadId,
|
||||
});
|
||||
};
|
||||
|
||||
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
|
||||
className={'w-full rounded-lg overflow-hidden border shadow relative'}
|
||||
className={
|
||||
'w-full rounded-lg overflow-hidden bg-white relative h-full reset'
|
||||
}
|
||||
>
|
||||
<ChatHeader
|
||||
chatTitle={chatTitle}
|
||||
openChats={openChats}
|
||||
goBack={goBack}
|
||||
onCreate={onCreate}
|
||||
/>
|
||||
<div
|
||||
className={
|
||||
'w-full bg-active-blue flex flex-col items-center justify-center py-4 relative'
|
||||
}
|
||||
style={{
|
||||
height: '70svh',
|
||||
background:
|
||||
'radial-gradient(50% 50% at 50% 50%, var(--color-glassWhite) 0%, var(--color-glassMint) 46%, var(--color-glassLavander) 100%)',
|
||||
}}
|
||||
>
|
||||
{section === 'intro' ? (
|
||||
<IntroSection onAsk={onCreate} />
|
||||
) : (
|
||||
<ChatLog
|
||||
threadId={threadId}
|
||||
projectId={activeSiteId}
|
||||
userLetter={userLetter}
|
||||
onTitleChange={setTitle}
|
||||
initialMsg={initialMsg}
|
||||
setInitialMsg={setInitialMsg}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{section === 'intro' ? (
|
||||
<>
|
||||
<div
|
||||
className={
|
||||
'flex flex-col items-center justify-center py-4 relative'
|
||||
}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: 'translateY(-50%)',
|
||||
}}
|
||||
>
|
||||
<IntroSection
|
||||
onCancel={onCancel}
|
||||
onAsk={onCreate}
|
||||
projectId={activeSiteId}
|
||||
userName={userName}
|
||||
limited={limited}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@ export default class KaiService extends AiService {
|
|||
getKaiChat = async (
|
||||
projectId: string,
|
||||
threadId: string,
|
||||
): Promise<
|
||||
{
|
||||
): Promise<{
|
||||
messages: {
|
||||
role: string;
|
||||
content: string;
|
||||
message_id: any;
|
||||
|
|
@ -36,8 +36,10 @@ export default class KaiService extends AiService {
|
|||
supports_visualization: boolean;
|
||||
chart: string;
|
||||
chart_data: string;
|
||||
}[]
|
||||
> => {
|
||||
sessions?: Record<string, any>[];
|
||||
}[];
|
||||
title: string;
|
||||
}> => {
|
||||
const r = await this.client.get(`/kai/${projectId}/chats/${threadId}`);
|
||||
if (!r.ok) {
|
||||
throw new Error('Failed to fetch chat');
|
||||
|
|
@ -84,7 +86,7 @@ export default class KaiService extends AiService {
|
|||
getMsgChart = async (
|
||||
messageId: string,
|
||||
projectId: string,
|
||||
): Promise<{ filters: any[]; chart: string; eventsOrder: string }> => {
|
||||
): Promise<string> => {
|
||||
const r = await this.client.get(
|
||||
`/kai/${projectId}/chats/data/${messageId}`,
|
||||
);
|
||||
|
|
@ -122,4 +124,19 @@ export default class KaiService extends AiService {
|
|||
const data = await r.json();
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { BotChunk, ChatManager } from './SocketManager';
|
|||
import { kaiService as aiService, kaiService } from 'App/services';
|
||||
import { toast } from 'react-toastify';
|
||||
import Widget from 'App/mstore/types/widget';
|
||||
import Session, { ISession } from '@/types/session/session';
|
||||
|
||||
export interface Message {
|
||||
text: string;
|
||||
|
|
@ -15,6 +16,7 @@ export interface Message {
|
|||
supports_visualization: boolean;
|
||||
feedback: boolean | null;
|
||||
duration: number;
|
||||
sessions?: Session[];
|
||||
}
|
||||
export interface SentMessage
|
||||
extends Omit<
|
||||
|
|
@ -29,6 +31,7 @@ class KaiStore {
|
|||
processingStage: BotChunk | null = null;
|
||||
messages: Array<Message> = [];
|
||||
queryText = '';
|
||||
chatTitle: string | null = null;
|
||||
loadingChat = false;
|
||||
replacing: string | null = null;
|
||||
usage = {
|
||||
|
|
@ -56,6 +59,20 @@ class KaiStore {
|
|||
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() {
|
||||
let msg = null;
|
||||
let index = null;
|
||||
|
|
@ -70,6 +87,14 @@ class KaiStore {
|
|||
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) => {
|
||||
this.queryText = text;
|
||||
};
|
||||
|
|
@ -113,13 +138,21 @@ class KaiStore {
|
|||
});
|
||||
};
|
||||
|
||||
setTitle = (title: string | null) => {
|
||||
this.chatTitle = title;
|
||||
};
|
||||
|
||||
getChat = async (projectId: string, threadId: string) => {
|
||||
this.setLoadingChat(true);
|
||||
try {
|
||||
const res = await aiService.getKaiChat(projectId, threadId);
|
||||
if (res && res.length) {
|
||||
const { messages, title } = await aiService.getKaiChat(
|
||||
projectId,
|
||||
threadId,
|
||||
);
|
||||
if (messages && messages.length) {
|
||||
this.setTitle(title);
|
||||
this.setMessages(
|
||||
res.map((m) => {
|
||||
messages.map((m) => {
|
||||
const isUser = m.role === 'human';
|
||||
return {
|
||||
text: m.content,
|
||||
|
|
@ -130,6 +163,9 @@ class KaiStore {
|
|||
chart: m.chart,
|
||||
supports_visualization: m.supports_visualization,
|
||||
chart_data: m.chart_data,
|
||||
sessions: m.sessions
|
||||
? m.sessions.map((s) => new Session(s))
|
||||
: undefined,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
|
@ -144,7 +180,6 @@ class KaiStore {
|
|||
|
||||
createChatManager = (
|
||||
settings: { projectId: string; threadId: string },
|
||||
setTitle: (title: string) => void,
|
||||
initialMsg: string | null,
|
||||
) => {
|
||||
const token = kaiService.client.getJwt();
|
||||
|
|
@ -190,6 +225,9 @@ class KaiStore {
|
|||
chart: '',
|
||||
supports_visualization: msg.supports_visualization,
|
||||
chart_data: '',
|
||||
sessions: msg.sessions
|
||||
? msg.sessions.map((s) => new Session(s))
|
||||
: undefined,
|
||||
};
|
||||
this.bumpUsage();
|
||||
this.addMessage(msgObj);
|
||||
|
|
@ -197,7 +235,7 @@ class KaiStore {
|
|||
}
|
||||
}
|
||||
},
|
||||
titleCallback: setTitle,
|
||||
titleCallback: this.setTitle,
|
||||
});
|
||||
|
||||
if (initialMsg) {
|
||||
|
|
@ -211,7 +249,13 @@ class KaiStore {
|
|||
|
||||
bumpUsage = () => {
|
||||
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) => {
|
||||
|
|
@ -232,7 +276,7 @@ class KaiStore {
|
|||
deleting.push(this.lastKaiMessage.index);
|
||||
}
|
||||
this.deleteAtIndex(deleting);
|
||||
this.setReplacing(false);
|
||||
this.setReplacing(null);
|
||||
}
|
||||
this.addMessage({
|
||||
text: message,
|
||||
|
|
@ -273,7 +317,6 @@ class KaiStore {
|
|||
|
||||
cancelGeneration = async (settings: {
|
||||
projectId: string;
|
||||
userId: string;
|
||||
threadId: string;
|
||||
}) => {
|
||||
try {
|
||||
|
|
@ -298,6 +341,7 @@ class KaiStore {
|
|||
}
|
||||
};
|
||||
|
||||
charts = new Map<string, Record<string, any>>();
|
||||
getMessageChart = async (msgId: string, projectId: string) => {
|
||||
this.setProcessingStage({
|
||||
content: 'Generating visualization...',
|
||||
|
|
@ -308,27 +352,16 @@ class KaiStore {
|
|||
supports_visualization: false,
|
||||
});
|
||||
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 = {
|
||||
metricId: undefined,
|
||||
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,
|
||||
},
|
||||
},
|
||||
],
|
||||
...filters,
|
||||
};
|
||||
const metric = new Widget().fromJson(data);
|
||||
this.charts.set(msgId, data);
|
||||
return metric;
|
||||
} catch (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) => {
|
||||
const parsedData = JSON.parse(data);
|
||||
return new Widget().fromJson(parsedData);
|
||||
};
|
||||
|
||||
setUsage = (usage: { total: number; used: number; percent: number }) => {
|
||||
this.usage = usage;
|
||||
};
|
||||
|
||||
checkUsage = async () => {
|
||||
try {
|
||||
const { total, used } = await kaiService.checkUsage();
|
||||
this.usage = {
|
||||
total,
|
||||
used,
|
||||
percent: (used / total) * 100,
|
||||
};
|
||||
this.setUsage({ total, used, percent: Math.round((used / total) * 100) });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import io from 'socket.io-client';
|
||||
import { toast } from 'react-toastify';
|
||||
import { ISession } from '@/types/session/session';
|
||||
|
||||
export class ChatManager {
|
||||
socket: ReturnType<typeof io>;
|
||||
|
|
@ -77,9 +78,7 @@ export class ChatManager {
|
|||
msgCallback,
|
||||
titleCallback,
|
||||
}: {
|
||||
msgCallback: (
|
||||
msg: StateEvent | BotChunk,
|
||||
) => void;
|
||||
msgCallback: (msg: StateEvent | BotChunk) => void;
|
||||
titleCallback: (title: string) => void;
|
||||
}) => {
|
||||
this.socket.on('chunk', (msg: BotChunk) => {
|
||||
|
|
@ -111,7 +110,8 @@ export interface BotChunk {
|
|||
messageId: string;
|
||||
duration: number;
|
||||
supports_visualization: boolean;
|
||||
type: 'chunk'
|
||||
sessions?: ISession[];
|
||||
type: 'chunk';
|
||||
}
|
||||
|
||||
interface StateEvent {
|
||||
|
|
|
|||
|
|
@ -1,56 +1,58 @@
|
|||
import React from 'react';
|
||||
import { Icon } from 'UI';
|
||||
import { MessagesSquare, ArrowLeft } from 'lucide-react';
|
||||
import { MessagesSquare, ArrowLeft, SquarePen } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function ChatHeader({
|
||||
openChats = () => {},
|
||||
goBack,
|
||||
chatTitle,
|
||||
onCreate,
|
||||
}: {
|
||||
goBack?: () => void;
|
||||
openChats?: () => void;
|
||||
chatTitle: string | null;
|
||||
onCreate: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'px-4 py-2 flex items-center bg-white border-b border-b-gray-lighter'
|
||||
}
|
||||
>
|
||||
<div className={'flex-1'}>
|
||||
{goBack ? (
|
||||
<div className="p-4 pb-0 w-full">
|
||||
<div
|
||||
className={'px-4 py-2 flex items-center bg-gray-lightest rounded-lg'}
|
||||
>
|
||||
<div className={'flex-1'}>
|
||||
{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
|
||||
className={
|
||||
'w-fit flex items-center gap-2 font-semibold cursor-pointer'
|
||||
}
|
||||
onClick={goBack}
|
||||
className="font-semibold w-fit cursor-pointer hover:text-main flex items-center gap-2"
|
||||
onClick={openChats}
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
<div>{t('Back')}</div>
|
||||
<MessagesSquare size={14} />
|
||||
<div>{t('Chats')}</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>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react';
|
||||
import { Button, Input, Tooltip } from 'antd';
|
||||
import { SendHorizonal, OctagonX } from 'lucide-react';
|
||||
import { X, ArrowUp } from 'lucide-react';
|
||||
import { kaiStore } from '../KaiStore';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import Usage from './Usage';
|
||||
|
|
@ -8,11 +8,13 @@ import Usage from './Usage';
|
|||
function ChatInput({
|
||||
isLoading,
|
||||
onSubmit,
|
||||
threadId,
|
||||
isArea,
|
||||
onCancel,
|
||||
}: {
|
||||
isLoading?: boolean;
|
||||
onSubmit: (str: string) => void;
|
||||
threadId: string;
|
||||
onCancel: () => void;
|
||||
isArea?: boolean;
|
||||
}) {
|
||||
const inputRef = React.useRef<typeof Input>(null);
|
||||
const usage = kaiStore.usage;
|
||||
|
|
@ -23,13 +25,14 @@ function ChatInput({
|
|||
kaiStore.setQueryText(text);
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
const submit = (e: any) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (limited) {
|
||||
return;
|
||||
}
|
||||
if (isProcessing) {
|
||||
const settings = { projectId: '2325', userId: '0', threadId };
|
||||
void kaiStore.cancelGeneration(settings);
|
||||
onCancel();
|
||||
} else {
|
||||
if (inputValue.length > 0) {
|
||||
onSubmit(inputValue);
|
||||
|
|
@ -50,9 +53,36 @@ function ChatInput({
|
|||
}, [inputValue]);
|
||||
|
||||
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 (
|
||||
<div className="relative">
|
||||
<div className="relative bg-white">
|
||||
<Input
|
||||
onPressEnter={submit}
|
||||
onKeyDown={(e) => {
|
||||
|
|
@ -61,12 +91,9 @@ function ChatInput({
|
|||
}
|
||||
}}
|
||||
ref={inputRef}
|
||||
placeholder={
|
||||
limited
|
||||
? `You've reached the daily limit for queries, come again tomorrow!`
|
||||
: 'Ask anything about your product and users...'
|
||||
}
|
||||
placeholder={placeholder}
|
||||
size={'large'}
|
||||
className="rounded-lg shadow"
|
||||
disabled={limited}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
|
|
@ -76,31 +103,19 @@ function ChatInput({
|
|||
<Tooltip title={'Cancel Editing'}>
|
||||
<Button
|
||||
onClick={cancelReplace}
|
||||
icon={<OctagonX size={16} />}
|
||||
type={'text'}
|
||||
icon={<X className="reset" size={16} />}
|
||||
size={'small'}
|
||||
shape={'circle'}
|
||||
disabled={limited}
|
||||
/>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<Tooltip title={'Send message'}>
|
||||
<Button
|
||||
loading={isLoading}
|
||||
onClick={submit}
|
||||
disabled={limited}
|
||||
icon={
|
||||
isProcessing ? (
|
||||
<OctagonX size={16} />
|
||||
) : (
|
||||
<SendHorizonal size={16} />
|
||||
)
|
||||
}
|
||||
type={'text'}
|
||||
size={'small'}
|
||||
shape={'circle'}
|
||||
/>
|
||||
</Tooltip>
|
||||
<SendButton
|
||||
isLoading={isLoading}
|
||||
submit={submit}
|
||||
limited={limited}
|
||||
isProcessing={isProcessing}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -1,25 +1,28 @@
|
|||
import React from 'react';
|
||||
import ChatInput from './ChatInput';
|
||||
import ChatMsg, { ChatNotice } from './ChatMsg';
|
||||
import Ideas from './Ideas';
|
||||
import { Loader } from 'UI';
|
||||
import { kaiStore } from '../KaiStore';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import EmbedPlayer from './EmbedPlayer';
|
||||
|
||||
function ChatLog({
|
||||
projectId,
|
||||
threadId,
|
||||
userLetter,
|
||||
onTitleChange,
|
||||
initialMsg,
|
||||
chatTitle,
|
||||
setInitialMsg,
|
||||
onCancel,
|
||||
}: {
|
||||
projectId: string;
|
||||
threadId: any;
|
||||
userLetter: string;
|
||||
onTitleChange: (title: string | null) => void;
|
||||
initialMsg: string | null;
|
||||
setInitialMsg: (msg: string | null) => void;
|
||||
chatTitle: string | null;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [embedSession, setEmbedSession] = React.useState<any>(null);
|
||||
const messages = kaiStore.messages;
|
||||
const loading = kaiStore.loadingChat;
|
||||
const chatRef = React.useRef<HTMLDivElement>(null);
|
||||
|
|
@ -31,7 +34,7 @@ function ChatLog({
|
|||
void kaiStore.getChat(settings.projectId, threadId);
|
||||
}
|
||||
if (threadId) {
|
||||
kaiStore.createChatManager(settings, onTitleChange, initialMsg);
|
||||
kaiStore.createChatManager(settings, initialMsg);
|
||||
}
|
||||
return () => {
|
||||
kaiStore.clearChat();
|
||||
|
|
@ -50,23 +53,38 @@ function ChatLog({
|
|||
});
|
||||
}, [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 (
|
||||
<Loader loading={loading} className={'w-full h-full'}>
|
||||
<div
|
||||
ref={chatRef}
|
||||
style={{ maxHeight: 'calc(100svh - 165px)' }}
|
||||
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) => (
|
||||
<React.Fragment key={msg.messageId ?? index}>
|
||||
<ChatMsg
|
||||
userName={userLetter}
|
||||
siteId={projectId}
|
||||
message={msg}
|
||||
canEdit={processingStage === null && msg.isUser && index === lastHumanMsgInd}
|
||||
chatTitle={chatTitle}
|
||||
onReplay={(session) => setEmbedSession(session)}
|
||||
canEdit={
|
||||
processingStage === null &&
|
||||
msg.isUser &&
|
||||
index === lastHumanMsgInd
|
||||
}
|
||||
/>
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
|
@ -76,9 +94,17 @@ function ChatLog({
|
|||
duration={processingStage.duration}
|
||||
/>
|
||||
) : null}
|
||||
{showIdeas ? (
|
||||
<Ideas
|
||||
onClick={(query) => onSubmit(query)}
|
||||
projectId={projectId}
|
||||
threadId={threadId}
|
||||
inChat
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={'sticky bottom-0 pt-6 w-2/3'}>
|
||||
<ChatInput onSubmit={onSubmit} threadId={threadId} />
|
||||
<div className={'sticky bottom-0 pt-6 w-2/3 z-50'}>
|
||||
<ChatInput onCancel={onCancel} onSubmit={onSubmit} />
|
||||
</div>
|
||||
</div>
|
||||
</Loader>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React from 'react';
|
||||
import { Icon, CopyButton } from 'UI';
|
||||
import { CopyButton, Icon } from 'UI';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import cn from 'classnames';
|
||||
import Markdown from 'react-markdown';
|
||||
|
|
@ -8,8 +8,7 @@ import {
|
|||
Loader,
|
||||
ThumbsUp,
|
||||
ThumbsDown,
|
||||
ListRestart,
|
||||
FileDown,
|
||||
SquarePen,
|
||||
Clock,
|
||||
ChartLine,
|
||||
} from 'lucide-react';
|
||||
|
|
@ -20,17 +19,22 @@ import { durationFormatted } from 'App/date';
|
|||
import WidgetChart from '@/components/Dashboard/components/WidgetChart';
|
||||
import Widget from 'App/mstore/types/widget';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import SessionItem from 'Shared/SessionItem';
|
||||
|
||||
function ChatMsg({
|
||||
userName,
|
||||
siteId,
|
||||
canEdit,
|
||||
message,
|
||||
chatTitle,
|
||||
onReplay,
|
||||
}: {
|
||||
message: Message;
|
||||
userName?: string;
|
||||
canEdit?: boolean;
|
||||
siteId: string;
|
||||
chatTitle: string | null;
|
||||
onReplay: (session: any) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [metric, setMetric] = React.useState<Widget | null>(null);
|
||||
|
|
@ -47,13 +51,14 @@ function ChatMsg({
|
|||
const isEditing = kaiStore.replacing && messageId === kaiStore.replacing;
|
||||
const [isProcessing, setIsProcessing] = React.useState(false);
|
||||
const bodyRef = React.useRef<HTMLDivElement>(null);
|
||||
const chartRef = React.useRef<HTMLDivElement>(null);
|
||||
const onEdit = () => {
|
||||
kaiStore.editMessage(text, messageId);
|
||||
};
|
||||
const onCancelEdit = () => {
|
||||
kaiStore.setQueryText('');
|
||||
kaiStore.setReplacing(null);
|
||||
}
|
||||
};
|
||||
const onFeedback = (feedback: 'like' | 'dislike', messageId: string) => {
|
||||
kaiStore.sendMsgFeedback(feedback, messageId, siteId);
|
||||
};
|
||||
|
|
@ -65,19 +70,79 @@ function ChatMsg({
|
|||
setIsProcessing(false);
|
||||
return;
|
||||
}
|
||||
const userPrompt = kaiStore.getPreviousMessage(message.messageId);
|
||||
import('jspdf')
|
||||
.then(({ jsPDF }) => {
|
||||
.then(async ({ jsPDF }) => {
|
||||
const doc = new jsPDF();
|
||||
doc.addImage('/assets/img/logo-img.png', 80, 3, 30, 5);
|
||||
doc.html(bodyRef.current!, {
|
||||
const blockWidth = 170; // mm
|
||||
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) {
|
||||
doc.save('document.pdf');
|
||||
doc.save((chatTitle ?? 'document') + '.pdf');
|
||||
},
|
||||
margin: [10, 10, 10, 10],
|
||||
// top, bottom, ?, left
|
||||
margin: [10, 10, 20, 20],
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 190, // Target width
|
||||
windowWidth: 675, // Window width for rendering
|
||||
// Target width
|
||||
width: blockWidth,
|
||||
// Window width for rendering
|
||||
windowWidth: 675,
|
||||
});
|
||||
})
|
||||
.catch((e) => {
|
||||
|
|
@ -107,35 +172,25 @@ function ChatMsg({
|
|||
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 (
|
||||
<div
|
||||
className={cn(
|
||||
'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 className={cn('flex gap-2', isUser ? 'flex-row-reverse' : 'flex-row')}>
|
||||
<div className={'mt-1 flex flex-col group/actions max-w-[60svw]'}>
|
||||
<div
|
||||
className={cn(
|
||||
'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
|
||||
ref={bodyRef}
|
||||
|
|
@ -143,36 +198,56 @@ function ChatMsg({
|
|||
<Markdown remarkPlugins={[remarkGfm]}>{text}</Markdown>
|
||||
</div>
|
||||
{metric ? (
|
||||
<div className="p-2 border-gray-light rounded-lg shadow">
|
||||
<WidgetChart metric={metric} isPreview />
|
||||
<div
|
||||
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>
|
||||
) : null}
|
||||
{isUser ? (
|
||||
<>
|
||||
<div
|
||||
onClick={onEdit}
|
||||
className={cn(
|
||||
'ml-auto flex items-center gap-2 px-2',
|
||||
'rounded-lg border border-gray-medium text-sm cursor-pointer',
|
||||
'hover:border-main hover:text-main w-fit',
|
||||
canEdit && !isEditing ? '' : 'hidden',
|
||||
)}
|
||||
>
|
||||
<ListRestart size={16} />
|
||||
<div>{t('Edit')}</div>
|
||||
</div>
|
||||
<div
|
||||
onClick={onCancelEdit}
|
||||
className={cn(
|
||||
'ml-auto flex items-center gap-2 px-2',
|
||||
'rounded-lg border border-gray-medium text-sm cursor-pointer',
|
||||
'hover:border-main hover:text-main w-fit',
|
||||
isEditing ? '' : 'hidden',
|
||||
)}
|
||||
>
|
||||
<div>{t('Cancel')}</div>
|
||||
</div>
|
||||
</>
|
||||
<div className="invisible group-hover/actions:visible mt-1 ml-auto flex gap-2 items-center">
|
||||
{canEdit && !isEditing ? (
|
||||
<IconButton onClick={onEdit} tooltip={t('Edit')}>
|
||||
<SquarePen size={16} />
|
||||
</IconButton>
|
||||
) : null}
|
||||
{isEditing ? (
|
||||
<Button
|
||||
onClick={onCancelEdit}
|
||||
type="text"
|
||||
size="small"
|
||||
className={'text-xs'}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
) : null}
|
||||
<CopyButton
|
||||
getHtml={() => bodyRef.current?.innerHTML}
|
||||
content={text}
|
||||
isIcon
|
||||
format={'text/html'}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={'flex items-center gap-2'}>
|
||||
{duration ? <MsgDuration duration={duration} /> : null}
|
||||
|
|
@ -182,14 +257,14 @@ function ChatMsg({
|
|||
tooltip="Like this answer"
|
||||
onClick={() => onFeedback('like', messageId)}
|
||||
>
|
||||
<ThumbsUp size={16} />
|
||||
<ThumbsUp strokeWidth={2} size={16} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
active={feedback === false}
|
||||
tooltip="Dislike this answer"
|
||||
onClick={() => onFeedback('dislike', messageId)}
|
||||
>
|
||||
<ThumbsDown size={16} />
|
||||
<ThumbsDown strokeWidth={2} size={16} />
|
||||
</IconButton>
|
||||
{supports_visualization ? (
|
||||
<IconButton
|
||||
|
|
@ -197,7 +272,7 @@ function ChatMsg({
|
|||
onClick={getChart}
|
||||
processing={loadingChart}
|
||||
>
|
||||
<ChartLine size={16} />
|
||||
<ChartLine strokeWidth={2} size={16} />
|
||||
</IconButton>
|
||||
) : null}
|
||||
<CopyButton
|
||||
|
|
@ -211,7 +286,7 @@ function ChatMsg({
|
|||
tooltip="Export as PDF"
|
||||
onClick={onExport}
|
||||
>
|
||||
<FileDown size={16} />
|
||||
<Icon name="export-pdf" size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
import { splitByDate } from '../utils';
|
||||
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 { toast } from 'react-toastify';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
|
@ -11,9 +11,11 @@ import { observer } from 'mobx-react-lite';
|
|||
function ChatsModal({
|
||||
onSelect,
|
||||
projectId,
|
||||
onHide,
|
||||
}: {
|
||||
onSelect: (threadId: string, title: string) => void;
|
||||
projectId: string;
|
||||
onHide: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { usage } = kaiStore;
|
||||
|
|
@ -44,10 +46,22 @@ function ChatsModal({
|
|||
refetch();
|
||||
};
|
||||
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'}>
|
||||
<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>
|
||||
{usage.percent > 80 ? (
|
||||
<div className="text-red text-sm">
|
||||
|
|
@ -58,16 +72,20 @@ function ChatsModal({
|
|||
</div>
|
||||
) : null}
|
||||
{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">
|
||||
{datedCollections.map((col) => (
|
||||
<ChatCollection
|
||||
data={col.entries}
|
||||
date={col.date}
|
||||
onSelect={onSelect}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
{datedCollections.map((col, i) => (
|
||||
<React.Fragment key={`${i}_${col.date}`}>
|
||||
<ChatCollection
|
||||
data={col.entries}
|
||||
date={col.date}
|
||||
onSelect={onSelect}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -87,8 +105,8 @@ function ChatCollection({
|
|||
date: string;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-disabled-text">{date}</div>
|
||||
<div className="border-b border-b-gray-lighter py-2">
|
||||
<div className="font-semibold">{date}</div>
|
||||
<ChatsList data={data} onSelect={onSelect} onDelete={onDelete} />
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
75
frontend/app/components/Kai/components/EmbedPlayer.tsx
Normal file
75
frontend/app/components/Kai/components/EmbedPlayer.tsx
Normal 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);
|
||||
|
|
@ -1,30 +1,84 @@
|
|||
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 (
|
||||
<>
|
||||
<div>
|
||||
<div className={'flex items-center gap-2 mb-1 text-gray-dark'}>
|
||||
<Lightbulb size={16} />
|
||||
<b>Ideas:</b>
|
||||
<b>{inChat ? 'Suggested Follow-up Questions' : 'Suggested Ideas:'}</b>
|
||||
</div>
|
||||
<IdeaItem onClick={onClick} title={'Top user journeys'} />
|
||||
<IdeaItem onClick={onClick} title={'Where do users drop off'} />
|
||||
<IdeaItem onClick={onClick} title={'Failed network requests today'} />
|
||||
</>
|
||||
{isPending ? (
|
||||
<div className="animate-pulse text-disabled-text">
|
||||
{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 (
|
||||
<div
|
||||
onClick={() => onClick(title)}
|
||||
className={
|
||||
'flex items-center gap-2 cursor-pointer text-gray-dark hover:text-black'
|
||||
}
|
||||
onClick={() => (limited ? null : onClick(title))}
|
||||
className={cn(
|
||||
'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} />
|
||||
<span>{title}</span>
|
||||
{title}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,23 +2,35 @@ import React from 'react';
|
|||
import ChatInput from './ChatInput';
|
||||
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;
|
||||
return (
|
||||
<>
|
||||
<div className={'text-disabled-text text-xl absolute top-4'}>
|
||||
Kai is your AI assistant, delivering smart insights in response to your
|
||||
queries.
|
||||
</div>
|
||||
<div className={'relative w-2/3'} style={{ height: 44 }}>
|
||||
{/*<GradientBorderInput placeholder={'Ask anything about your product and users...'} onButtonClick={() => null} />*/}
|
||||
<ChatInput isLoading={isLoading} onSubmit={onAsk} />
|
||||
<div className={'absolute top-full flex flex-col gap-2 mt-4'}>
|
||||
<Ideas onClick={(query) => onAsk(query)} />
|
||||
<div className={'relative w-2/3 flex flex-col gap-4'}>
|
||||
<div className="font-semibold text-lg">
|
||||
Hey {userName}, how can I help you?
|
||||
</div>
|
||||
<ChatInput
|
||||
onCancel={onCancel}
|
||||
isLoading={isLoading}
|
||||
onSubmit={onAsk}
|
||||
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 className={'text-disabled-text absolute bottom-4'}>
|
||||
OpenReplay AI can make mistakes. Verify its outputs.
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,23 +2,23 @@ import React from 'react';
|
|||
import { kaiStore } from '../KaiStore';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { Progress, Tooltip } from 'antd';
|
||||
const getUsageColor = (percent: number) => {
|
||||
return 'disabled-text';
|
||||
};
|
||||
|
||||
function Usage() {
|
||||
const { usage } = kaiStore;
|
||||
const color = getUsageColor(usage.percent);
|
||||
const usage = kaiStore.usage;
|
||||
|
||||
if (usage.total === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const roundPercent = Math.round((usage.used / usage.total) * 100);
|
||||
return (
|
||||
<div>
|
||||
<Tooltip title={`Daily response limit (${usage.used}/${usage.total})`}>
|
||||
<Progress
|
||||
percent={usage.percent}
|
||||
strokeColor={usage.percent < 99 ? 'var(--color-main)' : 'var(--color-red)'}
|
||||
percent={roundPercent}
|
||||
strokeColor={
|
||||
roundPercent < 99 ? 'var(--color-main)' : 'var(--color-red)'
|
||||
}
|
||||
showInfo={false}
|
||||
type="circle"
|
||||
size={24}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// @ts-nocheck
|
||||
import React, { Component, createContext } from 'react';
|
||||
import Modal from './Modal';
|
||||
import { className } from '@medv/finder';
|
||||
|
||||
const ModalContext = createContext({
|
||||
component: null,
|
||||
|
|
@ -29,6 +30,7 @@ export class ModalProvider extends Component {
|
|||
this.setState({
|
||||
component,
|
||||
props,
|
||||
className: props.className || undefined,
|
||||
});
|
||||
document.addEventListener('keydown', this.handleKeyDown);
|
||||
document.querySelector('body').style.overflow = 'hidden';
|
||||
|
|
|
|||
|
|
@ -26,10 +26,11 @@ interface Props {
|
|||
autoplay: boolean;
|
||||
onClose?: () => void;
|
||||
isHighlight?: boolean;
|
||||
isFull?: boolean;
|
||||
}
|
||||
|
||||
function ClipsPlayer(props: Props) {
|
||||
const { clip, currentIndex, isCurrent, onClose, isHighlight } = props;
|
||||
const { clip, currentIndex, isCurrent, onClose, isHighlight, isFull } = props;
|
||||
const { sessionStore } = useStore();
|
||||
const { prefetched } = sessionStore;
|
||||
const [windowActive, setWindowActive] = useState(!document.hidden);
|
||||
|
|
@ -146,6 +147,7 @@ function ClipsPlayer(props: Props) {
|
|||
onClose={onClose}
|
||||
range={clip.range}
|
||||
session={session!}
|
||||
isFull={isFull}
|
||||
/>
|
||||
<ClipPlayerContent
|
||||
message={clip.message}
|
||||
|
|
@ -153,6 +155,7 @@ function ClipsPlayer(props: Props) {
|
|||
autoplay={props.autoplay}
|
||||
range={clip.range}
|
||||
session={session!}
|
||||
isFull={isFull}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -19,13 +19,14 @@ interface Props {
|
|||
isHighlight?: boolean;
|
||||
message?: string;
|
||||
isMobile?: boolean;
|
||||
isFull?: boolean;
|
||||
}
|
||||
|
||||
function ClipPlayerContent(props: Props) {
|
||||
const playerContext = React.useContext<IPlayerContext>(PlayerContext);
|
||||
const screenWrapper = React.useRef<HTMLDivElement>(null);
|
||||
const { time } = playerContext.store.get();
|
||||
const { range } = props;
|
||||
const { range, isFull } = props;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!playerContext.player) return;
|
||||
|
|
@ -90,7 +91,7 @@ function ClipPlayerContent(props: Props) {
|
|||
<div className="leading-none font-medium">{props.message}</div>
|
||||
</div>
|
||||
) : null}
|
||||
<ClipPlayerControls session={props.session} range={props.range} />
|
||||
<ClipPlayerControls isFull={isFull} session={props.session} range={props.range} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -15,9 +15,11 @@ import { useTranslation } from 'react-i18next';
|
|||
function ClipPlayerControls({
|
||||
session,
|
||||
range,
|
||||
isFull,
|
||||
}: {
|
||||
session: Session;
|
||||
range: [number, number];
|
||||
isFull?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { projectsStore } = useStore();
|
||||
|
|
@ -47,7 +49,7 @@ function ClipPlayerControls({
|
|||
<PlayButton state={state} togglePlay={togglePlay} iconSize={30} />
|
||||
<Timeline range={range} />
|
||||
<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'}} />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -16,12 +16,13 @@ interface Props {
|
|||
range: [number, number];
|
||||
onClose?: () => void;
|
||||
isHighlight?: boolean;
|
||||
isFull?: boolean;
|
||||
}
|
||||
|
||||
function ClipPlayerHeader(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { projectsStore } = useStore();
|
||||
const { session, range, onClose, isHighlight } = props;
|
||||
const { session, range, onClose, isHighlight, isFull } = props;
|
||||
const { siteId } = projectsStore;
|
||||
const { message } = App.useApp();
|
||||
|
||||
|
|
@ -33,7 +34,7 @@ function ClipPlayerHeader(props: Props) {
|
|||
};
|
||||
return (
|
||||
<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} />
|
||||
|
||||
<Space>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { TYPES } from 'Types/session/event';
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useStore } from 'App/mstore';
|
||||
import UxtEvent from 'Components/Session_/EventsBlock/UxtEvent';
|
||||
|
|
@ -32,6 +32,7 @@ function EventGroupWrapper(props) {
|
|||
presentInSearch,
|
||||
isNote,
|
||||
isTabChange,
|
||||
isIncident,
|
||||
filterOutNote,
|
||||
} = props;
|
||||
const { t } = useTranslation();
|
||||
|
|
@ -57,6 +58,15 @@ function EventGroupWrapper(props) {
|
|||
/>
|
||||
);
|
||||
}
|
||||
if (isIncident) {
|
||||
return (
|
||||
<Incident
|
||||
isCurrent={isCurrent}
|
||||
label={event.label}
|
||||
onClick={onEventClick}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (isLocation) {
|
||||
return (
|
||||
<Event
|
||||
|
|
@ -100,11 +110,19 @@ function EventGroupWrapper(props) {
|
|||
);
|
||||
};
|
||||
|
||||
const shadowColor = isSearched ? '#F0A930' : props.isPrev
|
||||
? '#A7BFFF'
|
||||
: props.isCurrent
|
||||
? '#394EFF'
|
||||
: 'transparent';
|
||||
const shadowColor = useMemo(() => {
|
||||
if (isSearched) {
|
||||
return '#F0A930';
|
||||
}
|
||||
if (props.isPrev) {
|
||||
return '#A7BFFF';
|
||||
}
|
||||
if (props.isCurrent) {
|
||||
return '#394EFF';
|
||||
}
|
||||
return 'transparent';
|
||||
}, [isSearched, props.isPrev, props.isCurrent]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<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);
|
||||
|
|
|
|||
|
|
@ -34,8 +34,9 @@ function EventsBlock(props: IProps) {
|
|||
const { notesStore, uxtestingStore, uiPlayerStore, sessionStore } =
|
||||
useStore();
|
||||
const session = sessionStore.current;
|
||||
const { notesWithEvents } = session;
|
||||
const { uxtVideo } = session;
|
||||
const notesWithEvents = session.notesWithEvents;
|
||||
const incidents = session.incidents;
|
||||
const uxtVideo = session.uxtVideo;
|
||||
const { filteredEvents } = sessionStore;
|
||||
const query = sessionStore.eventsQuery;
|
||||
const { eventsIndex } = sessionStore;
|
||||
|
|
@ -86,26 +87,28 @@ function EventsBlock(props: IProps) {
|
|||
}
|
||||
});
|
||||
}
|
||||
const eventsWithMobxNotes = [...notesWithEvents, ...notes]
|
||||
.sort(sortEvents);
|
||||
const eventsWithMobxNotes = [...incidents, ...notesWithEvents, ...notes, ].sort(sortEvents);
|
||||
const filteredTabEvents = query.length
|
||||
? tabChangeEvents
|
||||
.filter((e => (e.activeUrl as string).includes(query)))
|
||||
: tabChangeEvents;
|
||||
const list = mergeEventLists(
|
||||
query.length > 0 ? filteredEvents : eventsWithMobxNotes,
|
||||
filteredTabEvents
|
||||
? tabChangeEvents.filter((e) => (e.activeUrl as string).includes(query))
|
||||
: tabChangeEvents;
|
||||
return mergeEventLists(
|
||||
filteredLength > 0 ? filteredEvents : eventsWithMobxNotes,
|
||||
tabChangeEvents,
|
||||
)
|
||||
if (zoomEnabled) {
|
||||
return list.filter((e) =>
|
||||
.filter((e) =>
|
||||
zoomEnabled
|
||||
? 'time' in e
|
||||
? e.time >= zoomStartTs && e.time <= zoomEndTs
|
||||
: false
|
||||
: true
|
||||
).filter((e: any) => !e.noteId && e.type !== 'TABCHANGE' && uiPlayerStore.showOnlySearchEvents ? e.isHighlighted : true);
|
||||
}
|
||||
return list;
|
||||
? 'time' in e
|
||||
? e.time >= zoomStartTs && e.time <= zoomEndTs
|
||||
: false
|
||||
: true,
|
||||
)
|
||||
.filter((e: any) =>
|
||||
!e.noteId &&
|
||||
e.type !== 'TABCHANGE' &&
|
||||
uiPlayerStore.showOnlySearchEvents
|
||||
? e.isHighlighted
|
||||
: true,
|
||||
);
|
||||
}, [
|
||||
filteredLength,
|
||||
query,
|
||||
|
|
@ -114,15 +117,17 @@ function EventsBlock(props: IProps) {
|
|||
zoomEnabled,
|
||||
zoomStartTs,
|
||||
zoomEndTs,
|
||||
uiPlayerStore.showOnlySearchEvents
|
||||
uiPlayerStore.showOnlySearchEvents,
|
||||
]);
|
||||
|
||||
const findLastFitting = React.useCallback(
|
||||
(time: number) => {
|
||||
if (!usedEvents.length) return 0;
|
||||
let i = usedEvents.length - 1;
|
||||
const allEvents = usedEvents.concat(incidents);
|
||||
if (!allEvents.length) return 0;
|
||||
let i = allEvents.length - 1;
|
||||
if (time > endTime / 2) {
|
||||
while (i >= 0) {
|
||||
const event = usedEvents[i];
|
||||
while (i > 0) {
|
||||
const event = allEvents[i];
|
||||
if ('time' in event && event.time <= time) break;
|
||||
i--;
|
||||
}
|
||||
|
|
@ -130,18 +135,18 @@ function EventsBlock(props: IProps) {
|
|||
}
|
||||
let l = 0;
|
||||
while (l < i) {
|
||||
const event = usedEvents[l];
|
||||
const event = allEvents[l];
|
||||
if ('time' in event && event.time >= time) break;
|
||||
l++;
|
||||
}
|
||||
return l;
|
||||
},
|
||||
[usedEvents, time, endTime],
|
||||
[usedEvents, incidents, time, endTime],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentTimeEventIndex(findLastFitting(time));
|
||||
}, [])
|
||||
}, [time]);
|
||||
|
||||
const write = ({
|
||||
target: { value },
|
||||
|
|
@ -195,9 +200,10 @@ function EventsBlock(props: IProps) {
|
|||
const event = usedEvents[index];
|
||||
const isNote = 'noteId' in event;
|
||||
const isTabChange = 'type' in event && event.type === 'TABCHANGE';
|
||||
const isIncident = 'type' in event && event.type === 'INCIDENT';
|
||||
const isCurrent = index === currentTimeEventIndex;
|
||||
const isPrev = index < currentTimeEventIndex;
|
||||
const isSearched = event.isHighlighted
|
||||
const isSearched = event.isHighlighted;
|
||||
|
||||
return (
|
||||
<EventGroupWrapper
|
||||
|
|
@ -213,6 +219,7 @@ function EventsBlock(props: IProps) {
|
|||
showSelection={!playing}
|
||||
isNote={isNote}
|
||||
isTabChange={isTabChange}
|
||||
isIncident={isIncident}
|
||||
isPrev={isPrev}
|
||||
filterOutNote={filterOutNote}
|
||||
setActiveTab={setActiveTab}
|
||||
|
|
|
|||
|
|
@ -6,13 +6,15 @@ import {
|
|||
import { observer } from 'mobx-react-lite';
|
||||
import { getTimelinePosition } from './getTimelinePosition';
|
||||
import { useStore } from '@/mstore';
|
||||
import { getTimelineEventWidth } from './getTimelineEventWidth';
|
||||
import { Tooltip } from 'antd';
|
||||
|
||||
function EventsList() {
|
||||
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 events = React.useMemo(
|
||||
() => Object.values(tabStates)[0]?.eventList.filter((e) => {
|
||||
|
|
@ -39,10 +41,25 @@ function EventsList() {
|
|||
<div
|
||||
/* @ts-ignore TODO */
|
||||
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)}%` }}
|
||||
/>
|
||||
))}
|
||||
{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>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -35,14 +35,11 @@ function SessionFilters() {
|
|||
|
||||
useEffect(() => {
|
||||
// Add default location/screen filter if no filters are present
|
||||
if (searchStore.instance.filters.length === 0) {
|
||||
searchStore.addFilterByKeyAndValue(
|
||||
activeProject?.platform === 'web'
|
||||
? FilterKey.LOCATION
|
||||
: FilterKey.VIEW_MOBILE,
|
||||
'',
|
||||
'isAny',
|
||||
);
|
||||
if (
|
||||
searchStore.instance.filters.length === 0 &&
|
||||
activeProject?.platform === 'web'
|
||||
) {
|
||||
searchStore.addFilterByKeyAndValue(FilterKey.LOCATION, '', 'isAny');
|
||||
}
|
||||
void reloadTags();
|
||||
}, [projectsStore.activeSiteId, activeProject]);
|
||||
|
|
@ -65,7 +62,7 @@ function SessionFilters() {
|
|||
};
|
||||
|
||||
const onFilterMove = (newFilters: any) => {
|
||||
searchStore.updateSearch({ ...appliedFilter, filters: newFilters});
|
||||
searchStore.updateSearch({ ...appliedFilter, filters: newFilters });
|
||||
// debounceFetch();
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { Icon, Link } from 'UI';
|
|||
import { useStore } from 'App/mstore';
|
||||
|
||||
const PLAY_ICON_NAMES = {
|
||||
notPlayed: 'play-fill',
|
||||
notPlayed: 'play-v2',
|
||||
played: 'play-circle-light',
|
||||
} as const;
|
||||
|
||||
|
|
@ -76,10 +76,14 @@ function PlayLink(props: Props) {
|
|||
rel={props.newTab ? 'noopener noreferrer' : undefined}
|
||||
>
|
||||
<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 className="group-hover:hidden block">
|
||||
<Icon name={iconName} size={38} color={isAssist ? 'tealx' : 'teal'} />
|
||||
<Icon
|
||||
name={`${iconName}${isAssist ? '-assist' : ''}`}
|
||||
size={38}
|
||||
color="teal"
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ interface Props {
|
|||
bookmarked?: boolean;
|
||||
toggleFavorite?: (sessionId: string) => void;
|
||||
query?: string;
|
||||
slim?: boolean;
|
||||
}
|
||||
|
||||
const PREFETCH_STATE = {
|
||||
|
|
@ -81,7 +82,8 @@ const PREFETCH_STATE = {
|
|||
|
||||
function SessionItem(props: RouteComponentProps & Props) {
|
||||
const { location } = useHistory();
|
||||
const { settingsStore, sessionStore, searchStore, searchStoreLive } = useStore();
|
||||
const { settingsStore, sessionStore, searchStore, searchStoreLive } =
|
||||
useStore();
|
||||
const { timezone, shownTimezone } = settingsStore.sessionSettings;
|
||||
const { t } = useTranslation();
|
||||
const [prefetchState, setPrefetched] = useState(PREFETCH_STATE.none);
|
||||
|
|
@ -99,6 +101,7 @@ function SessionItem(props: RouteComponentProps & Props) {
|
|||
isDisabled,
|
||||
live: propsLive,
|
||||
isAdd,
|
||||
slim,
|
||||
} = props;
|
||||
|
||||
const {
|
||||
|
|
@ -176,7 +179,7 @@ function SessionItem(props: RouteComponentProps & Props) {
|
|||
await sessionStore.getFirstMob(sessionId);
|
||||
setPrefetched(PREFETCH_STATE.fetched);
|
||||
} catch (e) {
|
||||
setPrefetched(PREFETCH_STATE.none)
|
||||
setPrefetched(PREFETCH_STATE.none);
|
||||
console.error('Error while prefetching first mob', e);
|
||||
}
|
||||
}, [prefetchState, live, isAssist, isMobile, sessionStore, sessionId]);
|
||||
|
|
@ -245,13 +248,13 @@ function SessionItem(props: RouteComponentProps & Props) {
|
|||
);
|
||||
}, [startedAt, timezone, userTimezone]);
|
||||
|
||||
const onMetaClick = (meta: { name: string, value: string }) => {
|
||||
const onMetaClick = (meta: { name: string; value: string }) => {
|
||||
if (isAssist) {
|
||||
searchStoreLive.addFilterByKeyAndValue(meta.name, meta.value)
|
||||
searchStoreLive.addFilterByKeyAndValue(meta.name, meta.value);
|
||||
} else {
|
||||
searchStore.addFilterByKeyAndValue(meta.name, meta.value);
|
||||
}
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Tooltip
|
||||
title={
|
||||
|
|
@ -261,7 +264,11 @@ function SessionItem(props: RouteComponentProps & Props) {
|
|||
}
|
||||
>
|
||||
<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"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseEnter={handleHover}
|
||||
|
|
@ -291,13 +298,16 @@ function SessionItem(props: RouteComponentProps & Props) {
|
|||
</div>
|
||||
<div className="overflow-hidden color-gray-medium ml-3 justify-between items-center shrink-0">
|
||||
<div
|
||||
className={cn('text-lg', {
|
||||
'color-teal cursor-pointer':
|
||||
!disableUser && hasUserId && !isDisabled,
|
||||
[stl.userName]:
|
||||
!disableUser && hasUserId && !isDisabled,
|
||||
'color-gray-medium': disableUser || !hasUserId,
|
||||
})}
|
||||
className={cn(
|
||||
{
|
||||
'color-teal cursor-pointer':
|
||||
!disableUser && hasUserId && !isDisabled,
|
||||
[stl.userName]:
|
||||
!disableUser && hasUserId && !isDisabled,
|
||||
'color-gray-medium': disableUser || !hasUserId,
|
||||
},
|
||||
slim ? 'text-base' : 'text-lg',
|
||||
)}
|
||||
onClick={handleUserClick}
|
||||
>
|
||||
<TextEllipsis
|
||||
|
|
@ -308,15 +318,20 @@ function SessionItem(props: RouteComponentProps & Props) {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{_metaList.length > 0 && (
|
||||
<SessionMetaList onMetaClick={onMetaClick} maxLength={3} metaList={_metaList} />
|
||||
{!slim && _metaList.length > 0 && (
|
||||
<SessionMetaList
|
||||
onMetaClick={onMetaClick}
|
||||
maxLength={3}
|
||||
metaList={_metaList}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
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',
|
||||
slim ? 'gap-1 mt-1' : 'gap-2 mt-3',
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
|
|
@ -343,7 +358,7 @@ function SessionItem(props: RouteComponentProps & Props) {
|
|||
: 'Event'}
|
||||
</span>
|
||||
</div>
|
||||
<Icon name="circle-fill" size={3} className="mx-4" />
|
||||
<Icon name="circle-fill" size={3} className="mx-2" />
|
||||
</>
|
||||
)}
|
||||
<div>
|
||||
|
|
@ -353,9 +368,12 @@ function SessionItem(props: RouteComponentProps & Props) {
|
|||
</div>
|
||||
<div
|
||||
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
|
||||
userCity={userCity}
|
||||
userState={userState}
|
||||
|
|
@ -373,7 +391,7 @@ function SessionItem(props: RouteComponentProps & Props) {
|
|||
</span>
|
||||
)}
|
||||
{userOs && userBrowser && (
|
||||
<Icon name="circle-fill" size={3} className="mx-4" />
|
||||
<Icon name="circle-fill" size={3} className="mx-2" />
|
||||
)}
|
||||
{userOs && (
|
||||
<span
|
||||
|
|
@ -387,7 +405,7 @@ function SessionItem(props: RouteComponentProps & Props) {
|
|||
</span>
|
||||
)}
|
||||
{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' }}>
|
||||
<TextEllipsis
|
||||
|
|
@ -452,7 +470,9 @@ function SessionItem(props: RouteComponentProps & Props) {
|
|||
onClick={onClick}
|
||||
queryParams={queryParams}
|
||||
query={query}
|
||||
beforeOpen={live || isAssist ? undefined : populateData}
|
||||
beforeOpen={
|
||||
slim || live || isAssist ? undefined : populateData
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { issues_types, types } from 'Types/session/issue';
|
||||
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 React, { useState, useEffect, useRef } from 'react';
|
||||
import { useStore } from 'App/mstore';
|
||||
|
|
@ -15,6 +15,7 @@ const tagIcons = {
|
|||
[types.CLICK_RAGE]: <Angry size={14} />,
|
||||
[types.CRASH]: <Skull size={14} />,
|
||||
[types.TAP_RAGE]: <Angry size={14} />,
|
||||
[types.INCIDENT]: <MessageCircleWarning size={14} />,
|
||||
} as Record<string, any>;
|
||||
|
||||
function SessionTags() {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useState } from 'react';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import { ClipboardCopy, ClipboardCheck } from 'lucide-react';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
content: string;
|
||||
|
|
@ -30,10 +30,10 @@ function CopyButton({
|
|||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 1000);
|
||||
}
|
||||
};
|
||||
const copyHandler = () => {
|
||||
setCopied(true);
|
||||
const contentIsGetter = !!getHtml
|
||||
const contentIsGetter = !!getHtml;
|
||||
const textContent = contentIsGetter ? getHtml() : content;
|
||||
const isHttps = window.location.protocol === 'https:';
|
||||
if (!isHttps) {
|
||||
|
|
@ -43,15 +43,16 @@ function CopyButton({
|
|||
}
|
||||
const blob = new Blob([textContent], { type: format });
|
||||
const cbItem = new ClipboardItem({
|
||||
[format]: blob
|
||||
})
|
||||
navigator.clipboard.write([cbItem])
|
||||
.catch(e => {
|
||||
[format]: blob,
|
||||
});
|
||||
navigator.clipboard
|
||||
.write([cbItem])
|
||||
.catch((e) => {
|
||||
copy(textContent);
|
||||
})
|
||||
.finally(() => {
|
||||
reset()
|
||||
})
|
||||
reset();
|
||||
});
|
||||
};
|
||||
|
||||
if (isIcon) {
|
||||
|
|
@ -62,7 +63,11 @@ function CopyButton({
|
|||
onClick={copyHandler}
|
||||
size={size}
|
||||
icon={
|
||||
copied ? <ClipboardCheck size={16} /> : <ClipboardCopy size={16} />
|
||||
copied ? (
|
||||
<Check strokeWidth={2} size={16} />
|
||||
) : (
|
||||
<Copy strokeWidth={2} size={16} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
|
|
|||
18
frontend/app/components/ui/Icons/export_pdf.tsx
Normal file
18
frontend/app/components/ui/Icons/export_pdf.tsx
Normal 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;
|
||||
|
|
@ -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
Loading…
Add table
Reference in a new issue