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

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

View file

@ -130,7 +130,7 @@ jobs:
cat /tmp/image_override.yaml
# 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 -

View file

@ -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 -

View file

@ -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 -

View file

@ -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 -

View file

@ -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 -

View file

@ -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 -

View file

@ -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 -

View file

@ -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 -

View file

@ -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 -

View file

@ -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 -

View file

@ -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 -

View file

@ -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

View file

@ -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 -

View file

@ -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 -

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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),

View file

@ -97,8 +97,7 @@ class JIRAIntegration(base.BaseIntegration):
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;""",
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})
)
@ -113,7 +112,8 @@ class JIRAIntegration(base.BaseIntegration):
with pg_client.PostgresClient() as cur:
cur.execute(
cur.mogrify(""" \
DELETE FROM public.jira_cloud
DELETE
FROM public.jira_cloud
WHERE user_id = %(user_id)s;""",
{"user_id": self._user_id})
)
@ -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

View file

@ -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;

View file

@ -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}

View file

@ -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

View file

@ -28,32 +28,32 @@ def search_events(project_id: int, q: Optional[str] = None):
def search_properties(project_id: int, property_name: Optional[str] = None, event_name: Optional[str] = None,
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
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}

View file

@ -7,14 +7,13 @@ from chalicelib.utils.ch_client import ClickHouseClient
from chalicelib.utils.exp_ch_helper import get_sub_condition, get_col_cast
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):

View file

@ -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

View file

@ -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

View file

@ -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:

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -220,9 +220,9 @@ def get_card_chart(projectId: int, metric_id: int, data: schemas.CardSessionsSch
@app.post("/{projectId}/dashboards/{dashboardId}/cards/{metric_id}/chart", tags=["card"])
@app.post("/{projectId}/dashboards/{dashboardId}/cards/{metric_id}", 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

View file

@ -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)}

View file

@ -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):

View file

@ -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)

View file

@ -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,

View file

@ -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
}

View file

@ -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 {

View file

@ -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
}

View file

@ -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))
}

View file

@ -85,6 +85,7 @@ const (
MsgNetworkRequest = 83
MsgWSChannel = 84
MsgResourceTiming = 85
MsgIncident = 87
MsgLongAnimationTask = 89
MsgInputChange = 112
MsgSelectionChange = 113
@ -123,6 +124,7 @@ const (
MsgMobileIssueEvent = 111
)
type Timestamp struct {
message
Timestamp uint64
@ -289,6 +291,7 @@ func (msg *SetViewportScroll) TypeID() int {
type CreateDocument struct {
message
}
func (msg *CreateDocument) Encode() []byte {
@ -2349,6 +2352,31 @@ func (msg *ResourceTiming) TypeID() int {
return 85
}
type Incident struct {
message
Label string
StartTime int64
EndTime int64
}
func (msg *Incident) Encode() []byte {
buf := make([]byte, 31+len(msg.Label))
buf[0] = 87
p := 1
p = WriteString(msg.Label, buf, p)
p = WriteInt(msg.StartTime, buf, p)
p = WriteInt(msg.EndTime, buf, p)
return buf[:p]
}
func (msg *Incident) Decode() Message {
return msg
}
func (msg *Incident) TypeID() int {
return 87
}
type LongAnimationTask struct {
message
Name string
@ -3346,3 +3374,4 @@ func (msg *MobileIssueEvent) Decode() Message {
func (msg *MobileIssueEvent) TypeID() int {
return 111
}

View file

@ -1476,6 +1476,21 @@ func DecodeResourceTiming(reader BytesReader) (Message, error) {
return msg, err
}
func DecodeIncident(reader BytesReader) (Message, error) {
var err error = nil
msg := &Incident{}
if msg.Label, err = reader.ReadString(); err != nil {
return nil, err
}
if msg.StartTime, err = reader.ReadInt(); err != nil {
return nil, err
}
if msg.EndTime, err = reader.ReadInt(); err != nil {
return nil, err
}
return msg, err
}
func DecodeLongAnimationTask(reader BytesReader) (Message, error) {
var err error = nil
msg := &LongAnimationTask{}
@ -2331,6 +2346,8 @@ func ReadMessage(t uint64, reader BytesReader) (Message, error) {
return DecodeWSChannel(reader)
case 85:
return DecodeResourceTiming(reader)
case 87:
return DecodeIncident(reader)
case 89:
return DecodeLongAnimationTask(reader)
case 112:

5
ee/api/.gitignore vendored
View file

@ -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

View file

@ -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"

View file

@ -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

View file

@ -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}

View file

@ -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

View file

@ -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

View file

@ -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),

View file

@ -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),

View file

@ -1,3 +1,16 @@
SELECT 1
FROM (SELECT throwIf(platform = 'ios', 'IOS sessions found')
FROM experimental.sessions) AS raw
LIMIT 1;
SELECT 1
FROM (SELECT throwIf(platform = 'android', 'Android sessions found')
FROM experimental.sessions) AS raw
LIMIT 1;
ALTER TABLE experimental.sessions
MODIFY COLUMN platform Enum8('web'=1,'mobile'=2) DEFAULT 'web';
CREATE OR REPLACE FUNCTION openreplay_version AS() -> 'v1.22.0-ee';
SET allow_experimental_json_type = 1;

View file

@ -1,3 +1,16 @@
SELECT 1
FROM (SELECT throwIf(platform = 'ios', 'IOS sessions found')
FROM experimental.sessions) AS raw
LIMIT 1;
SELECT 1
FROM (SELECT throwIf(platform = 'android', 'Android sessions found')
FROM experimental.sessions) AS raw
LIMIT 1;
ALTER TABLE experimental.sessions
MODIFY COLUMN platform Enum8('web'=1,'mobile'=2) DEFAULT 'web';
CREATE OR REPLACE FUNCTION openreplay_version AS() -> 'v1.23.0-ee';
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;

View file

@ -106,7 +106,7 @@ CREATE TABLE IF NOT EXISTS experimental.sessions
user_country Enum8('UN'=-128, 'RW'=-127, 'SO'=-126, 'YE'=-125, 'IQ'=-124, 'SA'=-123, 'IR'=-122, 'CY'=-121, 'TZ'=-120, 'SY'=-119, 'AM'=-118, 'KE'=-117, 'CD'=-116, 'DJ'=-115, 'UG'=-114, 'CF'=-113, 'SC'=-112, 'JO'=-111, 'LB'=-110, 'KW'=-109, 'OM'=-108, 'QA'=-107, 'BH'=-106, 'AE'=-105, 'IL'=-104, 'TR'=-103, 'ET'=-102, 'ER'=-101, 'EG'=-100, 'SD'=-99, 'GR'=-98, 'BI'=-97, 'EE'=-96, 'LV'=-95, 'AZ'=-94, 'LT'=-93, 'SJ'=-92, 'GE'=-91, 'MD'=-90, 'BY'=-89, 'FI'=-88, 'AX'=-87, 'UA'=-86, 'MK'=-85, 'HU'=-84, 'BG'=-83, 'AL'=-82, 'PL'=-81, 'RO'=-80, 'XK'=-79, 'ZW'=-78, 'ZM'=-77, 'KM'=-76, 'MW'=-75, 'LS'=-74, 'BW'=-73, 'MU'=-72, 'SZ'=-71, 'RE'=-70, 'ZA'=-69, 'YT'=-68, 'MZ'=-67, 'MG'=-66, 'AF'=-65, 'PK'=-64, 'BD'=-63, 'TM'=-62, 'TJ'=-61, 'LK'=-60, 'BT'=-59, 'IN'=-58, 'MV'=-57, 'IO'=-56, 'NP'=-55, 'MM'=-54, 'UZ'=-53, 'KZ'=-52, 'KG'=-51, 'TF'=-50, 'HM'=-49, 'CC'=-48, 'PW'=-47, 'VN'=-46, 'TH'=-45, 'ID'=-44, 'LA'=-43, 'TW'=-42, 'PH'=-41, 'MY'=-40, 'CN'=-39, 'HK'=-38, 'BN'=-37, 'MO'=-36, 'KH'=-35, 'KR'=-34, 'JP'=-33, 'KP'=-32, 'SG'=-31, 'CK'=-30, 'TL'=-29, 'RU'=-28, 'MN'=-27, 'AU'=-26, 'CX'=-25, 'MH'=-24, 'FM'=-23, 'PG'=-22, 'SB'=-21, 'TV'=-20, 'NR'=-19, 'VU'=-18, 'NC'=-17, 'NF'=-16, 'NZ'=-15, 'FJ'=-14, 'LY'=-13, 'CM'=-12, 'SN'=-11, 'CG'=-10, 'PT'=-9, 'LR'=-8, 'CI'=-7, 'GH'=-6, 'GQ'=-5, 'NG'=-4, 'BF'=-3, 'TG'=-2, 'GW'=-1, 'MR'=0, 'BJ'=1, 'GA'=2, 'SL'=3, 'ST'=4, 'GI'=5, 'GM'=6, 'GN'=7, 'TD'=8, 'NE'=9, 'ML'=10, 'EH'=11, 'TN'=12, 'ES'=13, 'MA'=14, 'MT'=15, 'DZ'=16, 'FO'=17, 'DK'=18, 'IS'=19, 'GB'=20, 'CH'=21, 'SE'=22, 'NL'=23, 'AT'=24, 'BE'=25, 'DE'=26, 'LU'=27, 'IE'=28, 'MC'=29, 'FR'=30, 'AD'=31, 'LI'=32, 'JE'=33, 'IM'=34, 'GG'=35, 'SK'=36, 'CZ'=37, 'NO'=38, 'VA'=39, 'SM'=40, 'IT'=41, 'SI'=42, 'ME'=43, 'HR'=44, 'BA'=45, 'AO'=46, 'NA'=47, 'SH'=48, 'BV'=49, 'BB'=50, 'CV'=51, 'GY'=52, 'GF'=53, 'SR'=54, 'PM'=55, 'GL'=56, 'PY'=57, 'UY'=58, 'BR'=59, 'FK'=60, 'GS'=61, 'JM'=62, 'DO'=63, 'CU'=64, 'MQ'=65, 'BS'=66, 'BM'=67, 'AI'=68, 'TT'=69, 'KN'=70, 'DM'=71, 'AG'=72, 'LC'=73, 'TC'=74, 'AW'=75, 'VG'=76, 'VC'=77, 'MS'=78, 'MF'=79, 'BL'=80, 'GP'=81, 'GD'=82, 'KY'=83, 'BZ'=84, 'SV'=85, 'GT'=86, 'HN'=87, 'NI'=88, 'CR'=89, 'VE'=90, 'EC'=91, 'CO'=92, 'PA'=93, 'HT'=94, 'AR'=95, 'CL'=96, 'BO'=97, 'PE'=98, 'MX'=99, 'PF'=100, 'PN'=101, 'KI'=102, 'TK'=103, 'TO'=104, 'WF'=105, 'WS'=106, 'NU'=107, 'MP'=108, 'GU'=109, 'PR'=110, 'VI'=111, 'UM'=112, 'AS'=113, 'CA'=114, 'US'=115, 'PS'=116, 'RS'=117, 'AQ'=118, 'SX'=119, 'CW'=120, 'BQ'=121, 'SS'=122,'BU'=123, 'VD'=124, 'YD'=125, 'DD'=126),
user_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,
@ -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;

View file

@ -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

View file

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

View file

@ -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,

View file

@ -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 },

View file

@ -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 () => {
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 () => {

View file

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

View file

@ -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"

View file

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

View file

@ -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')}&nbsp;<Text strong>{list.length}</Text>&nbsp;{t('cards')}
@ -124,15 +123,17 @@ const ListView: React.FC<Props> = ({
const onMenuClick = async (metric: Widget, { key }: { key: string }) => {
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 () => {
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;
@ -270,7 +278,7 @@ const ListView: React.FC<Props> = ({
? (record) => ({
onClick: () => {
if (!disableSelection) toggleSelection?.(record?.metricId);
}
},
})
: undefined
}
@ -279,7 +287,7 @@ const ListView: React.FC<Props> = ({
? {
selectedRowKeys: selectedList,
onChange: (keys) => toggleSelection && toggleSelection(keys),
columnWidth: 16
columnWidth: 16,
}
: undefined
}
@ -292,7 +300,7 @@ const ListView: React.FC<Props> = ({
showLessItems: true,
showTotal: () => totalMessage,
size: 'small',
simple: true
simple: true,
}}
/>
<AntdModal

View file

@ -12,7 +12,7 @@ import { useTranslation } from 'react-i18next';
function MetricsList({
siteId,
onSelectionChange,
inLibrary
inLibrary,
}: {
siteId: string;
onSelectionChange?: (selected: any[]) => void;
@ -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}

View file

@ -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}

View file

@ -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')}>

View file

@ -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,19 +52,16 @@ 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 />
</>
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.',
),
onOk: () => {
})
) {
metricStore
.delete(widget)
.then(() => {
@ -72,8 +70,7 @@ function CardViewMenu() {
.catch(() => {
toast.error(t('Failed to remove card'));
});
},
});
}
},
},
];

View file

@ -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 = {

View file

@ -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">
<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>
</Tooltip>
);
}

View file

@ -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,46 +99,79 @@ 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 rounded-lg overflow-hidden border shadow relative'}
className="w-full mx-auto h-full"
style={{ maxWidth: PANEL_SIZES.maxWidth }}
>
<div
className={
'w-full rounded-lg overflow-hidden bg-white relative h-full reset'
}
>
<ChatHeader
chatTitle={chatTitle}
openChats={openChats}
goBack={goBack}
onCreate={onCreate}
/>
{section === 'intro' ? (
<>
<div
className={
'w-full bg-active-blue flex flex-col items-center justify-center py-4 relative'
'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%)',
position: 'absolute',
top: '50%',
left: 0,
width: '100%',
transform: 'translateY(-50%)',
}}
>
{section === 'intro' ? (
<IntroSection onAsk={onCreate} />
<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}
userLetter={userLetter}
onTitleChange={setTitle}
chatTitle={chatTitle}
initialMsg={initialMsg}
setInitialMsg={setInitialMsg}
onCancel={onCancel}
/>
)}
</div>
</div>
</div>
);
}

View file

@ -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;
};
}

View file

@ -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);
}

View file

@ -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 {

View file

@ -1,52 +1,53 @@
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="p-4 pb-0 w-full">
<div
className={
'px-4 py-2 flex items-center bg-white border-b border-b-gray-lighter'
}
className={'px-4 py-2 flex items-center bg-gray-lightest rounded-lg'}
>
<div className={'flex-1'}>
{goBack ? (
<div
className={
'w-fit flex items-center gap-2 font-semibold cursor-pointer'
'w-fit flex items-center gap-2 font-semibold cursor-pointer hover:text-main'
}
onClick={goBack}
>
<ArrowLeft size={14} />
<div>{t('Back')}</div>
<SquarePen size={14} />
<div>{t('New Chat')}</div>
</div>
) : null}
) : (
<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>
) : (
<>
<Icon name={'kai_colored'} size={18} />
<div className={'font-semibold text-xl'}>Kai</div>
</>
)}
) : null}
</div>
<div className={'flex-1 justify-end flex items-center gap-2'}>
<div className={'flex-1 justify-end flex items-center'}>
<div
className="font-semibold w-fit cursor-pointer flex items-center gap-2"
className="font-semibold w-fit cursor-pointer hover:text-main flex items-center gap-2"
onClick={openChats}
>
<MessagesSquare size={14} />
@ -54,6 +55,7 @@ function ChatHeader({
</div>
</div>
</div>
</div>
);
}

View file

@ -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 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'}
<SendButton
isLoading={isLoading}
submit={submit}
limited={limited}
isProcessing={isProcessing}
/>
</Tooltip>
</>
}
/>
@ -111,4 +126,42 @@ function ChatInput({
);
}
function SendButton({
isLoading,
submit,
limited,
isProcessing,
}: {
isLoading?: boolean;
submit: () => void;
limited: boolean;
isProcessing?: boolean;
}) {
return (
<Tooltip title={isProcessing ? 'Cancel processing' : 'Send message'}>
<Button
loading={isLoading}
onClick={submit}
disabled={limited}
icon={
isProcessing ? (
<X size={16} strokeWidth={2} className="reset" />
) : (
<div className="bg-[#fff] text-main rounded-full">
<ArrowUp size={14} strokeWidth={2} className="reset" />
</div>
)
}
type={'primary'}
size={'small'}
shape={isProcessing ? 'circle' : 'round'}
iconPosition={'end'}
className="font-semibold text-[#fff]"
>
{isProcessing ? null : 'Ask'}
</Button>
</Tooltip>
);
}
export default observer(ChatInput);

View file

@ -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>

View file

@ -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
<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}
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',
)}
type="text"
size="small"
className={'text-xs'}
>
<div>{t('Cancel')}</div>
{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>
)}

View file

@ -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) => (
{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>
);

View file

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

View file

@ -1,30 +1,84 @@
import React from 'react';
import { 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>
{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>
<IdeaItem onClick={onClick} title={'Top user journeys'} />
<IdeaItem onClick={onClick} title={'Where do users drop off'} />
<IdeaItem onClick={onClick} title={'Failed network requests today'} />
</>
);
}
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>
);
}

View file

@ -2,24 +2,36 @@ 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 className={'relative w-2/3 flex flex-col gap-4'}>
<div className="font-semibold text-lg">
Hey {userName}, how can I help you?
</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} />
<ChatInput
onCancel={onCancel}
isLoading={isLoading}
onSubmit={onAsk}
isArea
/>
<div className={'absolute top-full flex flex-col gap-2 mt-4'}>
<Ideas onClick={(query) => onAsk(query)} />
<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>
</>
);
}

View file

@ -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}

View file

@ -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';

View file

@ -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}
/>
</>
) : (

View file

@ -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>
);

View file

@ -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>

View file

@ -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>

View file

@ -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);

View file

@ -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.filter((e) => (e.activeUrl as string).includes(query))
: tabChangeEvents;
const list = mergeEventLists(
query.length > 0 ? filteredEvents : eventsWithMobxNotes,
filteredTabEvents
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;
: 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}

View file

@ -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>
)
})}
</>
);
}

View file

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

View file

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

View file

@ -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>
);

View file

@ -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', {
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>

View file

@ -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() {

View file

@ -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>

View file

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

View file

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

View file

@ -211,6 +211,7 @@ export { default as Exclamation_circle_fill } from './exclamation_circle_fill';
export { default as Exclamation_circle } from './exclamation_circle';
export { default as Exclamation_triangle } from './exclamation_triangle';
export { default as Explosion } from './explosion';
export { default as Export_pdf } from './export_pdf';
export { default as External_link_alt } from './external_link_alt';
export { default as Eye_slash_fill } from './eye_slash_fill';
export { default as Eye_slash } from './eye_slash';
@ -288,6 +289,7 @@ export { default as Funnel_hdd_fill } from './funnel_hdd_fill';
export { default as Funnel_hourglass_top } from './funnel_hourglass_top';
export { default as Funnel_image_fill } from './funnel_image_fill';
export { default as Funnel_image } from './funnel_image';
export { default as Funnel_message_circle_warning } from './funnel_message_circle_warning';
export { default as Funnel_microchip } from './funnel_microchip';
export { default as Funnel_mouse } from './funnel_mouse';
export { default as Funnel_patch_exclamation_fill } from './funnel_patch_exclamation_fill';
@ -353,6 +355,7 @@ export { default as Integrations_teams } from './integrations_teams';
export { default as Integrations_vuejs } from './integrations_vuejs';
export { default as Integrations_zustand } from './integrations_zustand';
export { default as Journal_code } from './journal_code';
export { default as Kai_mono } from './kai_mono';
export { default as Kai } from './kai';
export { default as Kai_colored } from './kai_colored';
export { default as Key } from './key';
@ -413,9 +416,14 @@ export { default as Play_circle_bold } from './play_circle_bold';
export { default as Play_circle_light } from './play_circle_light';
export { default as Play_circle } from './play_circle';
export { default as Play_fill_new } from './play_fill_new';
export { default as Play_fill_v2_assist } from './play_fill_v2_assist';
export { default as Play_fill_v2 } from './play_fill_v2';
export { default as Play_fill } from './play_fill';
export { default as Play_hover } from './play_hover';
export { default as Play_v2_assist } from './play_v2_assist';
export { default as Play_v2 } from './play_v2';
export { default as Play } from './play';
export { default as Played_v2 } from './played_v2';
export { default as Plug } from './plug';
export { default as Plus_circle } from './plus_circle';
export { default as Plus } from './plus';

View file

@ -0,0 +1,18 @@
/* Auto-generated, do not edit */
import React from 'react';
interface Props {
size?: number | string;
width?: number | string;
height?: number | string;
fill?: string;
}
function Kai_mono(props: Props) {
const { size = 14, width = size, height = size, fill = '' } = props;
return (
<svg viewBox="0 0 20 20" fill="none" width={ `${ width }px` } height={ `${ height }px` } ><g fill="#394DFE"><path d="M13.125 6.458a.625.625 0 1 0-1.25 0c0 2.022-.447 3.335-1.264 4.153-.818.817-2.131 1.264-4.153 1.264a.625.625 0 1 0 0 1.25c2.022 0 3.335.447 4.152 1.264.818.818 1.265 2.131 1.265 4.153a.625.625 0 1 0 1.25 0c0-2.022.447-3.335 1.264-4.153.818-.817 2.131-1.264 4.153-1.264a.625.625 0 1 0 0-1.25c-2.022 0-3.335-.447-4.153-1.264-.817-.818-1.264-2.131-1.264-4.153ZM6.042 1.458a.625.625 0 1 0-1.25 0c0 1.298-.288 2.09-.766 2.568-.478.478-1.27.766-2.568.766a.625.625 0 1 0 0 1.25c1.298 0 2.09.287 2.568.765.478.478.766 1.27.766 2.568a.625.625 0 0 0 1.25 0c0-1.298.287-2.09.765-2.568.478-.478 1.27-.765 2.568-.765a.625.625 0 0 0 0-1.25c-1.298 0-2.09-.288-2.568-.766-.478-.478-.765-1.27-.765-2.568Z"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h20v20H0z"/></clipPath></defs></svg>
);
}
export default Kai_mono;

View file

@ -0,0 +1,18 @@
/* Auto-generated, do not edit */
import React from 'react';
interface Props {
size?: number | string;
width?: number | string;
height?: number | string;
fill?: string;
}
function Play_fill_v2(props: Props) {
const { size = 14, width = size, height = size, fill = '' } = props;
return (
<svg viewBox="0 0 37 36" fill="none" width={ `${ width }px` } height={ `${ height }px` } ><rect x=".663" width="36" height="36" rx="18" fill="#394DFE"/><path d="M24.851 16.358a2 2 0 0 1 0 3.284l-7.323 5.09c-1.326.922-3.141-.027-3.141-1.642V12.91c0-1.615 1.815-2.564 3.141-1.643l7.323 5.09Z" fill="#E2E4FD"/></svg>
);
}
export default Play_fill_v2;

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