resolved conflicts
This commit is contained in:
commit
d2c28ca2a9
143 changed files with 7977 additions and 2814 deletions
14
.github/workflows/patch-build.yaml
vendored
14
.github/workflows/patch-build.yaml
vendored
|
|
@ -146,18 +146,8 @@ jobs:
|
|||
destination_branch: "main"
|
||||
pr_title: "Updated patch build from main ${{ env.HEAD_COMMIT_ID }}"
|
||||
pr_body: |
|
||||
This PR updates the Helm chart version after building the patch from ${{ env.HEAD_COMMIT_ID }}.
|
||||
|
||||
- name: Set Remote with GITHUB_TOKEN
|
||||
run: |
|
||||
git config --unset http.https://github.com/.extraheader
|
||||
git remote set-url origin https://x-access-token:${{ secrets.ACTIONS_COMMMIT_TOKEN }}@github.com/${{ github.repository }}.git
|
||||
- name: Push ${{ secrets.ACTIONS_COMMMIT_TOKEN }} branch to tag
|
||||
run: |
|
||||
git fetch --tags
|
||||
git checkout main
|
||||
echo git push origin $BRANCH_NAME:refs/tags/$(git tag --list 'v[0-9]*' --sort=-v:refname | head -n 1) --force
|
||||
git push origin $BRANCH_NAME:refs/tags/$(git tag --list 'v[0-9]*' --sort=-v:refname | head -n 1) --force
|
||||
This PR updates the Helm chart version after building the patch from $HEAD_COMMIT_ID.
|
||||
Once this PR is merged, tag update job will run automatically.
|
||||
|
||||
# - name: Debug Job
|
||||
# if: ${{ failure() }}
|
||||
|
|
|
|||
44
.github/workflows/update-tag.yaml
vendored
44
.github/workflows/update-tag.yaml
vendored
|
|
@ -1,35 +1,43 @@
|
|||
on:
|
||||
workflow_dispatch:
|
||||
description: "This workflow will build for patches for latest tag, and will Always use commit from main branch."
|
||||
inputs:
|
||||
services:
|
||||
description: "This action will update the latest tag with current main branch HEAD. Should I proceed ? true/false"
|
||||
required: true
|
||||
default: "false"
|
||||
|
||||
name: Force Push tag with main branch HEAD
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches:
|
||||
- main
|
||||
name: Release tag update --force
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Build Patch from main
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
DEPOT_PROJECT_ID: ${{ secrets.DEPOT_PROJECT_ID }}
|
||||
if: ${{ (github.event_name == 'pull_request' && github.event.pull_request.merged == true) || github.event.inputs.services == 'true' }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Get latest release tag using GitHub API
|
||||
id: get-latest-tag
|
||||
run: |
|
||||
LATEST_TAG=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
"https://api.github.com/repos/${{ github.repository }}/releases/latest" \
|
||||
| jq -r .tag_name)
|
||||
|
||||
# Fallback to git command if API doesn't return a tag
|
||||
if [ "$LATEST_TAG" == "null" ] || [ -z "$LATEST_TAG" ]; then
|
||||
echo "Not found latest tag"
|
||||
exit 100
|
||||
fi
|
||||
|
||||
echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_ENV
|
||||
echo "Latest tag: $LATEST_TAG"
|
||||
|
||||
- name: Set Remote with GITHUB_TOKEN
|
||||
run: |
|
||||
git config --unset http.https://github.com/.extraheader
|
||||
git remote set-url origin https://x-access-token:${{ secrets.ACTIONS_COMMMIT_TOKEN }}@github.com/${{ github.repository }}.git
|
||||
|
||||
- name: Push main branch to tag
|
||||
run: |
|
||||
git fetch --tags
|
||||
git checkout main
|
||||
git push origin HEAD:refs/tags/$(git tag --list 'v[0-9]*' --sort=-v:refname | head -n 1) --force
|
||||
# - name: Debug Job
|
||||
# if: ${{ failure() }}
|
||||
# uses: mxschmitt/action-tmate@v3
|
||||
# with:
|
||||
# limit-access-to-actor: true
|
||||
echo "Updating tag ${{ env.LATEST_TAG }} to point to latest commit on main"
|
||||
git push origin HEAD:refs/tags/${{ env.LATEST_TAG }} --force
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
import logging
|
||||
|
||||
from decouple import config
|
||||
|
||||
logging.basicConfig(level=config("LOGLEVEL", default=logging.INFO))
|
||||
|
||||
if config("EXP_AUTOCOMPLETE", cast=bool, default=False):
|
||||
logging.info(">>> Using experimental autocomplete")
|
||||
from . import autocomplete_ch as autocomplete
|
||||
else:
|
||||
from . import autocomplete
|
||||
|
|
@ -85,7 +85,8 @@ def __generic_query(typename, value_length=None):
|
|||
ORDER BY value"""
|
||||
|
||||
if value_length is None or value_length > 2:
|
||||
return f"""(SELECT DISTINCT value, type
|
||||
return f"""SELECT DISTINCT ON(value,type) value, type
|
||||
((SELECT DISTINCT value, type
|
||||
FROM {TABLE}
|
||||
WHERE
|
||||
project_id = %(project_id)s
|
||||
|
|
@ -101,7 +102,7 @@ def __generic_query(typename, value_length=None):
|
|||
AND type='{typename.upper()}'
|
||||
AND value ILIKE %(value)s
|
||||
ORDER BY value
|
||||
LIMIT 5);"""
|
||||
LIMIT 5)) AS raw;"""
|
||||
return f"""SELECT DISTINCT value, type
|
||||
FROM {TABLE}
|
||||
WHERE
|
||||
|
|
@ -326,7 +327,7 @@ def __search_metadata(project_id, value, key=None, source=None):
|
|||
AND {colname} ILIKE %(svalue)s LIMIT 5)""")
|
||||
with pg_client.PostgresClient() as cur:
|
||||
cur.execute(cur.mogrify(f"""\
|
||||
SELECT key, value, 'METADATA' AS TYPE
|
||||
SELECT DISTINCT ON(key, value) key, value, 'METADATA' AS TYPE
|
||||
FROM({" UNION ALL ".join(sub_from)}) AS all_metas
|
||||
LIMIT 5;""", {"project_id": project_id, "value": helper.string_to_sql_like(value),
|
||||
"svalue": helper.string_to_sql_like("^" + value)}))
|
||||
|
|
|
|||
|
|
@ -86,7 +86,8 @@ def __generic_query(typename, value_length=None):
|
|||
ORDER BY value"""
|
||||
|
||||
if value_length is None or value_length > 2:
|
||||
return f"""(SELECT DISTINCT value, type
|
||||
return f"""SELECT DISTINCT ON(value, type) value, type
|
||||
FROM ((SELECT DISTINCT value, type
|
||||
FROM {TABLE}
|
||||
WHERE
|
||||
project_id = %(project_id)s
|
||||
|
|
@ -102,7 +103,7 @@ def __generic_query(typename, value_length=None):
|
|||
AND type='{typename.upper()}'
|
||||
AND value ILIKE %(value)s
|
||||
ORDER BY value
|
||||
LIMIT 5);"""
|
||||
LIMIT 5)) AS raw;"""
|
||||
return f"""SELECT DISTINCT value, type
|
||||
FROM {TABLE}
|
||||
WHERE
|
||||
|
|
@ -257,7 +258,7 @@ def __search_metadata(project_id, value, key=None, source=None):
|
|||
WHERE project_id = %(project_id)s
|
||||
AND {colname} ILIKE %(svalue)s LIMIT 5)""")
|
||||
with ch_client.ClickHouseClient() as cur:
|
||||
query = cur.format(query=f"""SELECT key, value, 'METADATA' AS TYPE
|
||||
query = cur.format(query=f"""SELECT DISTINCT ON(key, value) key, value, 'METADATA' AS TYPE
|
||||
FROM({" UNION ALL ".join(sub_from)}) AS all_metas
|
||||
LIMIT 5;""", parameters={"project_id": project_id, "value": helper.string_to_sql_like(value),
|
||||
"svalue": helper.string_to_sql_like("^" + value)})
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import logging
|
||||
|
||||
import schemas
|
||||
from chalicelib.core import metadata
|
||||
from chalicelib.core.errors import errors_legacy
|
||||
|
|
@ -7,6 +9,8 @@ from chalicelib.utils import ch_client, exp_ch_helper
|
|||
from chalicelib.utils import helper, metrics_helper
|
||||
from chalicelib.utils.TimeUTC import TimeUTC
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _multiple_values(values, value_key="value"):
|
||||
query_values = {}
|
||||
|
|
@ -378,9 +382,9 @@ def search(data: schemas.SearchErrorsSchema, project: schemas.ProjectContext, us
|
|||
ORDER BY timestamp) AS sub_table
|
||||
GROUP BY error_id) AS chart_details ON details.error_id=chart_details.error_id;"""
|
||||
|
||||
# print("------------")
|
||||
# print(ch.format(main_ch_query, params))
|
||||
# print("------------")
|
||||
logger.debug("------------")
|
||||
logger.debug(ch.format(main_ch_query, params))
|
||||
logger.debug("------------")
|
||||
query = ch.format(query=main_ch_query, parameters=params)
|
||||
|
||||
rows = ch.execute(query=query)
|
||||
|
|
|
|||
|
|
@ -241,14 +241,13 @@ def create_card(project: schemas.ProjectContext, user_id, data: schemas.CardSche
|
|||
params["card_info"] = json.dumps(params["card_info"])
|
||||
|
||||
query = """INSERT INTO metrics (project_id, user_id, name, is_public,
|
||||
view_type, metric_type, metric_of, metric_value,
|
||||
metric_format, default_config, thumbnail, data,
|
||||
card_info)
|
||||
VALUES (%(project_id)s, %(user_id)s, %(name)s, %(is_public)s,
|
||||
%(view_type)s, %(metric_type)s, %(metric_of)s, %(metric_value)s,
|
||||
%(metric_format)s, %(default_config)s, %(thumbnail)s, %(session_data)s,
|
||||
%(card_info)s)
|
||||
RETURNING metric_id"""
|
||||
view_type, metric_type, metric_of, metric_value,
|
||||
metric_format, default_config, thumbnail, data,
|
||||
card_info)
|
||||
VALUES (%(project_id)s, %(user_id)s, %(name)s, %(is_public)s,
|
||||
%(view_type)s, %(metric_type)s, %(metric_of)s, %(metric_value)s,
|
||||
%(metric_format)s, %(default_config)s, %(thumbnail)s, %(session_data)s,
|
||||
%(card_info)s) RETURNING metric_id"""
|
||||
if len(data.series) > 0:
|
||||
query = f"""WITH m AS ({query})
|
||||
INSERT INTO metric_series(metric_id, index, name, filter)
|
||||
|
|
@ -525,13 +524,13 @@ def get_all(project_id, user_id):
|
|||
def delete_card(project_id, metric_id, user_id):
|
||||
with pg_client.PostgresClient() as cur:
|
||||
cur.execute(
|
||||
cur.mogrify("""\
|
||||
UPDATE public.metrics
|
||||
SET deleted_at = timezone('utc'::text, now()), edited_at = timezone('utc'::text, now())
|
||||
WHERE project_id = %(project_id)s
|
||||
AND metric_id = %(metric_id)s
|
||||
AND (user_id = %(user_id)s OR is_public)
|
||||
RETURNING data;""",
|
||||
cur.mogrify(""" \
|
||||
UPDATE public.metrics
|
||||
SET deleted_at = timezone('utc'::text, now()),
|
||||
edited_at = timezone('utc'::text, now())
|
||||
WHERE project_id = %(project_id)s
|
||||
AND metric_id = %(metric_id)s
|
||||
AND (user_id = %(user_id)s OR is_public) RETURNING data;""",
|
||||
{"metric_id": metric_id, "project_id": project_id, "user_id": user_id})
|
||||
)
|
||||
|
||||
|
|
@ -615,13 +614,14 @@ def get_series_for_alert(project_id, user_id):
|
|||
FALSE AS predefined,
|
||||
metric_id,
|
||||
series_id
|
||||
FROM metric_series
|
||||
INNER JOIN metrics USING (metric_id)
|
||||
WHERE metrics.deleted_at ISNULL
|
||||
AND metrics.project_id = %(project_id)s
|
||||
AND metrics.metric_type = 'timeseries'
|
||||
AND (user_id = %(user_id)s OR is_public)
|
||||
ORDER BY name;""",
|
||||
FROM metric_series
|
||||
INNER JOIN metrics USING (metric_id)
|
||||
WHERE metrics.deleted_at ISNULL
|
||||
AND metrics.project_id = %(project_id)s
|
||||
AND metrics.metric_type = 'timeseries'
|
||||
AND (user_id = %(user_id)s
|
||||
OR is_public)
|
||||
ORDER BY name;""",
|
||||
{"project_id": project_id, "user_id": user_id}
|
||||
)
|
||||
)
|
||||
|
|
@ -632,11 +632,11 @@ def get_series_for_alert(project_id, user_id):
|
|||
def change_state(project_id, metric_id, user_id, status):
|
||||
with pg_client.PostgresClient() as cur:
|
||||
cur.execute(
|
||||
cur.mogrify("""\
|
||||
UPDATE public.metrics
|
||||
SET active = %(status)s
|
||||
WHERE metric_id = %(metric_id)s
|
||||
AND (user_id = %(user_id)s OR is_public);""",
|
||||
cur.mogrify(""" \
|
||||
UPDATE public.metrics
|
||||
SET active = %(status)s
|
||||
WHERE metric_id = %(metric_id)s
|
||||
AND (user_id = %(user_id)s OR is_public);""",
|
||||
{"metric_id": metric_id, "status": status, "user_id": user_id})
|
||||
)
|
||||
return get_card(metric_id=metric_id, project_id=project_id, user_id=user_id)
|
||||
|
|
@ -674,7 +674,8 @@ def get_funnel_sessions_by_issue(user_id, project_id, metric_id, issue_id,
|
|||
"issue": issue}
|
||||
|
||||
|
||||
def make_chart_from_card(project: schemas.ProjectContext, user_id, metric_id, data: schemas.CardSessionsSchema):
|
||||
def make_chart_from_card(project: schemas.ProjectContext, user_id, metric_id,
|
||||
data: schemas.CardSessionsSchema, for_dashboard: bool = False):
|
||||
raw_metric: dict = get_card(metric_id=metric_id, project_id=project.project_id, user_id=user_id, include_data=True)
|
||||
|
||||
if raw_metric is None:
|
||||
|
|
@ -693,7 +694,8 @@ def make_chart_from_card(project: schemas.ProjectContext, user_id, metric_id, da
|
|||
return heatmaps.search_short_session(project_id=project.project_id,
|
||||
data=schemas.HeatMapSessionsSearch(**metric.model_dump()),
|
||||
user_id=user_id)
|
||||
|
||||
elif metric.metric_type == schemas.MetricType.PATH_ANALYSIS and for_dashboard:
|
||||
metric.hide_excess = True
|
||||
return get_chart(project=project, data=metric, user_id=user_id)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -30,21 +30,23 @@ def search_properties(project_id: int, property_name: Optional[str] = None, even
|
|||
with ClickHouseClient() as ch_client:
|
||||
select = "value"
|
||||
full_args = {"project_id": project_id, "limit": 20,
|
||||
"event_name": event_name, "property_name": property_name}
|
||||
"event_name": event_name, "property_name": property_name, "q": q,
|
||||
"property_name_l": helper.string_to_sql_like(property_name),
|
||||
"q_l": helper.string_to_sql_like(q)}
|
||||
|
||||
constraints = ["project_id = %(project_id)s",
|
||||
"_timestamp >= now()-INTERVAL 1 MONTH"]
|
||||
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)s"]
|
||||
full_args["property_name"] = helper.string_to_sql_like(property_name)
|
||||
constraints += ["property_name ILIKE %(property_name_l)s"]
|
||||
|
||||
if q:
|
||||
constraints += ["value ILIKE %(q)s"]
|
||||
full_args["q"] = helper.string_to_sql_like(q)
|
||||
constraints += ["value ILIKE %(q_l)s"]
|
||||
query = ch_client.format(
|
||||
f"""SELECT {select},data_count
|
||||
FROM product_analytics.autocomplete_event_properties_grouped
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import schemas
|
|||
from chalicelib.utils import helper
|
||||
from chalicelib.utils import sql_helper as sh
|
||||
from chalicelib.utils.ch_client import ClickHouseClient
|
||||
from chalicelib.utils.exp_ch_helper import get_sub_condition
|
||||
from chalicelib.utils.exp_ch_helper import get_sub_condition, get_col_cast
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
PREDEFINED_EVENTS = {
|
||||
|
|
@ -111,11 +111,13 @@ def search_events(project_id: int, data: schemas.EventsSearchPayloadSchema):
|
|||
sub_conditions = []
|
||||
for j, ef in enumerate(f.properties.filters):
|
||||
p_k = f"e_{i}_p_{j}"
|
||||
full_args = {**full_args, **sh.multi_values(ef.value, value_key=p_k)}
|
||||
full_args = {**full_args, **sh.multi_values(ef.value, value_key=p_k, data_type=ef.data_type)}
|
||||
cast = get_col_cast(data_type=ef.data_type, value=ef.value)
|
||||
if ef.is_predefined:
|
||||
sub_condition = get_sub_condition(col_name=ef.name, val_name=p_k, operator=ef.operator)
|
||||
sub_condition = get_sub_condition(col_name=f"accurateCastOrNull(`{ef.name}`,'{cast}')",
|
||||
val_name=p_k, operator=ef.operator)
|
||||
else:
|
||||
sub_condition = get_sub_condition(col_name=f"properties.{ef.name}",
|
||||
sub_condition = get_sub_condition(col_name=f"accurateCastOrNull(properties.`{ef.name}`,{cast})",
|
||||
val_name=p_k, operator=ef.operator)
|
||||
sub_conditions.append(sh.multi_conditions(sub_condition, ef.value, value_key=p_k))
|
||||
if len(sub_conditions) > 0:
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from chalicelib.core import events, metadata
|
|||
from . import performance_event, sessions_legacy
|
||||
from chalicelib.utils import pg_client, helper, metrics_helper, ch_client, exp_ch_helper
|
||||
from chalicelib.utils import sql_helper as sh
|
||||
from chalicelib.utils.exp_ch_helper import get_sub_condition
|
||||
from chalicelib.utils.exp_ch_helper import get_sub_condition, get_col_cast
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -1264,14 +1264,15 @@ def search_query_parts_ch(data: schemas.SessionsSearchPayloadSchema, error_statu
|
|||
for l, property in enumerate(event.properties.filters):
|
||||
a_k = f"{e_k}_att_{l}"
|
||||
full_args = {**full_args,
|
||||
**sh.multi_values(property.value, value_key=a_k)}
|
||||
|
||||
**sh.multi_values(property.value, value_key=a_k, data_type=property.data_type)}
|
||||
cast = get_col_cast(data_type=property.data_type, value=property.value)
|
||||
if property.is_predefined:
|
||||
condition = get_sub_condition(col_name=f"main.{property.name}",
|
||||
condition = get_sub_condition(col_name=f"accurateCastOrNull(main.`{property.name}`,'{cast}')",
|
||||
val_name=a_k, operator=property.operator)
|
||||
else:
|
||||
condition = get_sub_condition(col_name=f"main.properties.{property.name}",
|
||||
val_name=a_k, operator=property.operator)
|
||||
condition = get_sub_condition(
|
||||
col_name=f"accurateCastOrNull(main.properties.`{property.name}`,'{cast}')",
|
||||
val_name=a_k, operator=property.operator)
|
||||
event_where.append(
|
||||
sh.multi_conditions(condition, property.value, value_key=a_k)
|
||||
)
|
||||
|
|
@ -1505,7 +1506,7 @@ def search_query_parts_ch(data: schemas.SessionsSearchPayloadSchema, error_statu
|
|||
if c_f.type == schemas.FetchFilterType.FETCH_URL.value:
|
||||
_extra_or_condition.append(
|
||||
sh.multi_conditions(f"extra_event.url_path {op} %({e_k})s",
|
||||
c_f.value, value_key=e_k))
|
||||
c_f.value, value_key=e_k))
|
||||
else:
|
||||
logging.warning(f"unsupported extra_event type:${c.type}")
|
||||
if len(_extra_or_condition) > 0:
|
||||
|
|
@ -1577,18 +1578,15 @@ def get_user_sessions(project_id, user_id, start_date, end_date):
|
|||
def get_session_user(project_id, user_id):
|
||||
with pg_client.PostgresClient() as cur:
|
||||
query = cur.mogrify(
|
||||
"""\
|
||||
SELECT
|
||||
user_id,
|
||||
count(*) as session_count,
|
||||
max(start_ts) as last_seen,
|
||||
min(start_ts) as first_seen
|
||||
FROM
|
||||
"public".sessions
|
||||
WHERE
|
||||
project_id = %(project_id)s
|
||||
AND user_id = %(userId)s
|
||||
AND duration is not null
|
||||
""" \
|
||||
SELECT user_id,
|
||||
count(*) as session_count,
|
||||
max(start_ts) as last_seen,
|
||||
min(start_ts) as first_seen
|
||||
FROM "public".sessions
|
||||
WHERE project_id = %(project_id)s
|
||||
AND user_id = %(userId)s
|
||||
AND duration is not null
|
||||
GROUP BY user_id;
|
||||
""",
|
||||
{"project_id": project_id, "userId": user_id}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import logging
|
||||
import re
|
||||
from typing import Union
|
||||
from typing import Union, Any
|
||||
|
||||
import schemas
|
||||
from chalicelib.utils import sql_helper as sh
|
||||
from schemas import SearchEventOperator
|
||||
import math
|
||||
import struct
|
||||
from decimal import Decimal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -158,8 +161,73 @@ def simplify_clickhouse_types(ch_types: list[str]) -> list[str]:
|
|||
|
||||
|
||||
def get_sub_condition(col_name: str, val_name: str,
|
||||
operator: Union[schemas.SearchEventOperator, schemas.MathOperator]):
|
||||
operator: Union[schemas.SearchEventOperator, schemas.MathOperator]) -> str:
|
||||
if operator == SearchEventOperator.PATTERN:
|
||||
return f"match({col_name}, %({val_name})s)"
|
||||
op = sh.get_sql_operator(operator)
|
||||
return f"{col_name} {op} %({val_name})s"
|
||||
|
||||
|
||||
def get_col_cast(data_type: schemas.PropertyType, value: Any) -> str:
|
||||
if value is None or len(value) == 0:
|
||||
return ""
|
||||
if isinstance(value, list):
|
||||
value = value[0]
|
||||
if data_type in (schemas.PropertyType.INT, schemas.PropertyType.FLOAT):
|
||||
return best_clickhouse_type(value)
|
||||
return data_type.capitalize()
|
||||
|
||||
|
||||
# (type_name, minimum, maximum) – ordered by increasing size
|
||||
_INT_RANGES = [
|
||||
("Int8", -128, 127),
|
||||
("UInt8", 0, 255),
|
||||
("Int16", -32_768, 32_767),
|
||||
("UInt16", 0, 65_535),
|
||||
("Int32", -2_147_483_648, 2_147_483_647),
|
||||
("UInt32", 0, 4_294_967_295),
|
||||
("Int64", -9_223_372_036_854_775_808, 9_223_372_036_854_775_807),
|
||||
("UInt64", 0, 18_446_744_073_709_551_615),
|
||||
]
|
||||
|
||||
|
||||
def best_clickhouse_type(value):
|
||||
"""
|
||||
Return the most compact ClickHouse numeric type that can store *value* loss-lessly.
|
||||
|
||||
"""
|
||||
# Treat bool like tiny int
|
||||
if isinstance(value, bool):
|
||||
value = int(value)
|
||||
|
||||
# --- Integers ---
|
||||
if isinstance(value, int):
|
||||
for name, lo, hi in _INT_RANGES:
|
||||
if lo <= value <= hi:
|
||||
return name
|
||||
# Beyond UInt64: ClickHouse offers Int128 / Int256 or Decimal
|
||||
return "Int128"
|
||||
|
||||
# --- Decimal.Decimal (exact) ---
|
||||
if isinstance(value, Decimal):
|
||||
# ClickHouse Decimal32/64/128 have 9 / 18 / 38 significant digits.
|
||||
digits = len(value.as_tuple().digits)
|
||||
if digits <= 9:
|
||||
return "Decimal32"
|
||||
elif digits <= 18:
|
||||
return "Decimal64"
|
||||
else:
|
||||
return "Decimal128"
|
||||
|
||||
# --- Floats ---
|
||||
if isinstance(value, float):
|
||||
if not math.isfinite(value):
|
||||
return "Float64" # inf / nan → always Float64
|
||||
|
||||
# Check if a round-trip through 32-bit float preserves the bit pattern
|
||||
packed = struct.pack("f", value)
|
||||
if struct.unpack("f", packed)[0] == value:
|
||||
return "Float32"
|
||||
return "Float64"
|
||||
|
||||
raise TypeError(f"Unsupported type: {type(value).__name__}")
|
||||
|
|
|
|||
|
|
@ -99,6 +99,8 @@ def allow_captcha():
|
|||
|
||||
|
||||
def string_to_sql_like(value):
|
||||
if value is None:
|
||||
return None
|
||||
value = re.sub(' +', ' ', value)
|
||||
value = value.replace("*", "%")
|
||||
if value.startswith("^"):
|
||||
|
|
@ -334,5 +336,3 @@ def cast_session_id_to_string(data):
|
|||
for key in keys:
|
||||
data[key] = cast_session_id_to_string(data[key])
|
||||
return data
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -52,12 +52,16 @@ def multi_conditions(condition, values, value_key="value", is_not=False):
|
|||
return "(" + (" AND " if is_not else " OR ").join(query) + ")"
|
||||
|
||||
|
||||
def multi_values(values, value_key="value"):
|
||||
def multi_values(values, value_key="value", data_type: schemas.PropertyType | None = None):
|
||||
query_values = {}
|
||||
if values is not None and isinstance(values, list):
|
||||
for i in range(len(values)):
|
||||
k = f"{value_key}_{i}"
|
||||
query_values[k] = values[i].value if isinstance(values[i], Enum) else values[i]
|
||||
if data_type:
|
||||
if data_type == schemas.PropertyType.STRING:
|
||||
query_values[k] = str(query_values[k])
|
||||
|
||||
return query_values
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -219,6 +219,17 @@ def get_card_chart(projectId: int, metric_id: int, data: schemas.CardSessionsSch
|
|||
return {"data": data}
|
||||
|
||||
|
||||
@app.post("/{projectId}/dashboards/{dashboardId}/cards/{metric_id}/chart", 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(...),
|
||||
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
|
||||
)
|
||||
return {"data": data}
|
||||
|
||||
|
||||
@app.post("/{projectId}/cards/{metric_id}", tags=["dashboard"])
|
||||
def update_card(projectId: int, metric_id: int, data: schemas.CardSchema = Body(...),
|
||||
context: schemas.CurrentContext = Depends(OR_context)):
|
||||
|
|
|
|||
|
|
@ -63,8 +63,12 @@ def autocomplete_events(projectId: int, q: Optional[str] = None,
|
|||
|
||||
|
||||
@app.get('/{projectId}/properties/autocomplete', tags=["autocomplete"])
|
||||
def autocomplete_properties(projectId: int, propertyName: str, eventName: Optional[str] = None,
|
||||
def autocomplete_properties(projectId: int, propertyName: Optional[str] = None, 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"]}
|
||||
return {"data": autocomplete.search_properties(project_id=projectId,
|
||||
event_name=None if not eventName \
|
||||
or len(eventName) == 0 else eventName,
|
||||
|
|
|
|||
|
|
@ -3,12 +3,13 @@ from typing import Optional, List, Union, Literal
|
|||
|
||||
from pydantic import Field, EmailStr, HttpUrl, SecretStr, AnyHttpUrl
|
||||
from pydantic import field_validator, model_validator, computed_field
|
||||
from pydantic import AfterValidator
|
||||
from pydantic.functional_validators import BeforeValidator
|
||||
|
||||
from chalicelib.utils.TimeUTC import TimeUTC
|
||||
from .overrides import BaseModel, Enum, ORUnion
|
||||
from .transformers_validators import transform_email, remove_whitespace, remove_duplicate_values, single_to_list, \
|
||||
force_is_event, NAME_PATTERN, int_to_string, check_alphanumeric
|
||||
force_is_event, NAME_PATTERN, int_to_string, check_alphanumeric, check_regex
|
||||
|
||||
|
||||
class _GRecaptcha(BaseModel):
|
||||
|
|
@ -537,7 +538,7 @@ class GraphqlFilterType(str, Enum):
|
|||
class RequestGraphqlFilterSchema(BaseModel):
|
||||
type: Union[FetchFilterType, GraphqlFilterType] = Field(...)
|
||||
value: List[Union[int, str]] = Field(...)
|
||||
operator: Union[SearchEventOperator, MathOperator] = Field(...)
|
||||
operator: Annotated[Union[SearchEventOperator, MathOperator], AfterValidator(check_regex)] = Field(...)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
|
|
@ -581,11 +582,23 @@ class EventPredefinedPropertyType(str, Enum):
|
|||
IMPORT = "$import"
|
||||
|
||||
|
||||
class PropertyType(str, Enum):
|
||||
INT = "int"
|
||||
FLOAT = "float"
|
||||
DATETIME = "datetime"
|
||||
STRING = "string"
|
||||
ARRAY = "array"
|
||||
TUPLE = "tuple"
|
||||
MAP = "map"
|
||||
NESTED = "nested"
|
||||
|
||||
|
||||
class PropertyFilterSchema(BaseModel):
|
||||
is_event: Literal[False] = False
|
||||
name: Union[EventPredefinedPropertyType, str] = Field(...)
|
||||
operator: Union[SearchEventOperator, MathOperator] = Field(...)
|
||||
value: List[Union[int, str]] = Field(...)
|
||||
data_type: PropertyType = Field(default=PropertyType.STRING.value)
|
||||
|
||||
# property_type: Optional[Literal["string", "number", "date"]] = Field(default=None)
|
||||
|
||||
|
|
@ -600,6 +613,13 @@ class PropertyFilterSchema(BaseModel):
|
|||
self.name = self.name.value
|
||||
return self
|
||||
|
||||
@model_validator(mode='after')
|
||||
def _check_regex_value(self):
|
||||
if self.operator == SearchEventOperator.PATTERN:
|
||||
for v in self.value:
|
||||
check_regex(v)
|
||||
return self
|
||||
|
||||
|
||||
class EventPropertiesSchema(BaseModel):
|
||||
operator: Literal["and", "or"] = Field(...)
|
||||
|
|
@ -645,6 +665,13 @@ class SessionSearchEventSchema(BaseModel):
|
|||
f"operator:{self.operator} is only available for event-type: {EventType.CLICK}"
|
||||
return self
|
||||
|
||||
@model_validator(mode='after')
|
||||
def _check_regex_value(self):
|
||||
if self.operator == SearchEventOperator.PATTERN:
|
||||
for v in self.value:
|
||||
check_regex(v)
|
||||
return self
|
||||
|
||||
|
||||
class SessionSearchFilterSchema(BaseModel):
|
||||
is_event: Literal[False] = False
|
||||
|
|
@ -702,6 +729,13 @@ class SessionSearchFilterSchema(BaseModel):
|
|||
|
||||
return self
|
||||
|
||||
@model_validator(mode='after')
|
||||
def _check_regex_value(self):
|
||||
if self.operator == SearchEventOperator.PATTERN:
|
||||
for v in self.value:
|
||||
check_regex(v)
|
||||
return self
|
||||
|
||||
|
||||
class _PaginatedSchema(BaseModel):
|
||||
limit: int = Field(default=200, gt=0, le=200)
|
||||
|
|
@ -868,6 +902,13 @@ class PathAnalysisSubFilterSchema(BaseModel):
|
|||
values["isEvent"] = True
|
||||
return values
|
||||
|
||||
@model_validator(mode='after')
|
||||
def _check_regex_value(self):
|
||||
if self.operator == SearchEventOperator.PATTERN:
|
||||
for v in self.value:
|
||||
check_regex(v)
|
||||
return self
|
||||
|
||||
|
||||
class _ProductAnalyticsFilter(BaseModel):
|
||||
is_event: Literal[False] = False
|
||||
|
|
@ -878,6 +919,13 @@ class _ProductAnalyticsFilter(BaseModel):
|
|||
|
||||
_remove_duplicate_values = field_validator('value', mode='before')(remove_duplicate_values)
|
||||
|
||||
@model_validator(mode='after')
|
||||
def _check_regex_value(self):
|
||||
if self.operator == SearchEventOperator.PATTERN:
|
||||
for v in self.value:
|
||||
check_regex(v)
|
||||
return self
|
||||
|
||||
|
||||
class _ProductAnalyticsEventFilter(BaseModel):
|
||||
is_event: Literal[True] = True
|
||||
|
|
@ -888,6 +936,13 @@ class _ProductAnalyticsEventFilter(BaseModel):
|
|||
|
||||
_remove_duplicate_values = field_validator('value', mode='before')(remove_duplicate_values)
|
||||
|
||||
@model_validator(mode='after')
|
||||
def _check_regex_value(self):
|
||||
if self.operator == SearchEventOperator.PATTERN:
|
||||
for v in self.value:
|
||||
check_regex(v)
|
||||
return self
|
||||
|
||||
|
||||
# this type is created to allow mixing events&filters and specifying a discriminator for PathAnalysis series filter
|
||||
ProductAnalyticsFilter = Annotated[Union[_ProductAnalyticsFilter, _ProductAnalyticsEventFilter],
|
||||
|
|
@ -1332,6 +1387,13 @@ class LiveSessionSearchFilterSchema(BaseModel):
|
|||
assert len(self.source) > 0, "source should not be empty for METADATA type"
|
||||
return self
|
||||
|
||||
@model_validator(mode='after')
|
||||
def _check_regex_value(self):
|
||||
if self.operator == SearchEventOperator.PATTERN:
|
||||
for v in self.value:
|
||||
check_regex(v)
|
||||
return self
|
||||
|
||||
|
||||
class LiveSessionsSearchPayloadSchema(_PaginatedSchema):
|
||||
filters: List[LiveSessionSearchFilterSchema] = Field([])
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import re
|
||||
from typing import Union, Any, Type
|
||||
|
||||
from pydantic import ValidationInfo
|
||||
|
|
@ -57,3 +58,17 @@ def check_alphanumeric(v: str, info: ValidationInfo) -> str:
|
|||
is_alphanumeric = v.replace(' ', '').isalnum()
|
||||
assert is_alphanumeric, f'{info.field_name} must be alphanumeric'
|
||||
return v
|
||||
|
||||
|
||||
def check_regex(v: str) -> str:
|
||||
assert v is not None, "Regex is null"
|
||||
assert isinstance(v, str), "Regex value must be a string"
|
||||
assert len(v) > 0, "Regex is empty"
|
||||
is_valid = None
|
||||
try:
|
||||
re.compile(v)
|
||||
except re.error as exc:
|
||||
is_valid = f"Invalid regex: {exc} (at position {exc.pos})"
|
||||
|
||||
assert is_valid is None, is_valid
|
||||
return v
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ package datasaver
|
|||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"encoding/json"
|
||||
"openreplay/backend/internal/config/db"
|
||||
"openreplay/backend/pkg/db/clickhouse"
|
||||
"openreplay/backend/pkg/db/postgres"
|
||||
|
|
@ -50,10 +50,6 @@ func New(log logger.Logger, cfg *db.Config, pg *postgres.Conn, ch clickhouse.Con
|
|||
}
|
||||
|
||||
func (s *saverImpl) Handle(msg Message) {
|
||||
if msg.TypeID() == MsgCustomEvent {
|
||||
defer s.Handle(types.WrapCustomEvent(msg.(*CustomEvent)))
|
||||
}
|
||||
|
||||
var (
|
||||
sessCtx = context.WithValue(context.Background(), "sessionID", msg.SessionID())
|
||||
session *sessions.Session
|
||||
|
|
@ -69,6 +65,23 @@ func (s *saverImpl) Handle(msg Message) {
|
|||
return
|
||||
}
|
||||
|
||||
if msg.TypeID() == MsgCustomEvent {
|
||||
m := msg.(*CustomEvent)
|
||||
// Try to parse custom event payload to JSON and extract or_payload field
|
||||
type CustomEventPayload struct {
|
||||
CustomTimestamp uint64 `json:"or_timestamp"`
|
||||
}
|
||||
customPayload := &CustomEventPayload{}
|
||||
if err := json.Unmarshal([]byte(m.Payload), customPayload); err == nil {
|
||||
if customPayload.CustomTimestamp >= session.Timestamp {
|
||||
s.log.Info(sessCtx, "custom event timestamp received: %v", m.Timestamp)
|
||||
msg.Meta().Timestamp = customPayload.CustomTimestamp
|
||||
s.log.Info(sessCtx, "custom event timestamp updated: %v", m.Timestamp)
|
||||
}
|
||||
}
|
||||
defer s.Handle(types.WrapCustomEvent(m))
|
||||
}
|
||||
|
||||
if IsMobileType(msg.TypeID()) {
|
||||
if err := s.handleMobileMessage(sessCtx, session, msg); err != nil {
|
||||
if !postgres.IsPkeyViolation(err) {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package datasaver
|
|||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"openreplay/backend/pkg/db/postgres"
|
||||
"openreplay/backend/pkg/db/types"
|
||||
"openreplay/backend/pkg/messages"
|
||||
|
|
|
|||
|
|
@ -726,7 +726,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{}{
|
||||
"name": msg.Name,
|
||||
"payload": msg.Payload,
|
||||
"user_device": session.UserDevice,
|
||||
"user_device_type": session.UserDeviceType,
|
||||
|
|
@ -740,11 +739,11 @@ func (c *connectorImpl) InsertCustom(session *sessions.Session, msg *messages.Cu
|
|||
session.SessionID,
|
||||
uint16(session.ProjectID),
|
||||
getUUID(msg),
|
||||
"CUSTOM",
|
||||
msg.Name,
|
||||
eventTime,
|
||||
eventTime.Unix(),
|
||||
session.UserUUID,
|
||||
true,
|
||||
false,
|
||||
session.Platform,
|
||||
session.UserOSVersion,
|
||||
session.UserOS,
|
||||
|
|
|
|||
|
|
@ -1466,7 +1466,7 @@ func (msg *SetNodeAttributeDict) TypeID() int {
|
|||
return 52
|
||||
}
|
||||
|
||||
type ResourceTimingDeprecated struct {
|
||||
type ResourceTimingDeprecatedDeprecated struct {
|
||||
message
|
||||
Timestamp uint64
|
||||
Duration uint64
|
||||
|
|
@ -1478,7 +1478,7 @@ type ResourceTimingDeprecated struct {
|
|||
Initiator string
|
||||
}
|
||||
|
||||
func (msg *ResourceTimingDeprecated) Encode() []byte {
|
||||
func (msg *ResourceTimingDeprecatedDeprecated) Encode() []byte {
|
||||
buf := make([]byte, 81+len(msg.URL)+len(msg.Initiator))
|
||||
buf[0] = 53
|
||||
p := 1
|
||||
|
|
@ -1493,11 +1493,11 @@ func (msg *ResourceTimingDeprecated) Encode() []byte {
|
|||
return buf[:p]
|
||||
}
|
||||
|
||||
func (msg *ResourceTimingDeprecated) Decode() Message {
|
||||
func (msg *ResourceTimingDeprecatedDeprecated) Decode() Message {
|
||||
return msg
|
||||
}
|
||||
|
||||
func (msg *ResourceTimingDeprecated) TypeID() int {
|
||||
func (msg *ResourceTimingDeprecatedDeprecated) TypeID() int {
|
||||
return 53
|
||||
}
|
||||
|
||||
|
|
@ -2320,6 +2320,90 @@ func (msg *Incident) TypeID() int {
|
|||
return 85
|
||||
}
|
||||
|
||||
type ResourceTiming struct {
|
||||
message
|
||||
Timestamp uint64
|
||||
Duration uint64
|
||||
TTFB uint64
|
||||
HeaderSize uint64
|
||||
EncodedBodySize uint64
|
||||
DecodedBodySize uint64
|
||||
URL string
|
||||
Initiator string
|
||||
TransferredSize uint64
|
||||
Cached bool
|
||||
Queueing uint64
|
||||
DnsLookup uint64
|
||||
InitialConnection uint64
|
||||
SSL uint64
|
||||
ContentDownload uint64
|
||||
Total uint64
|
||||
Stalled uint64
|
||||
}
|
||||
|
||||
func (msg *ResourceTiming) Encode() []byte {
|
||||
buf := make([]byte, 171+len(msg.URL)+len(msg.Initiator))
|
||||
buf[0] = 85
|
||||
p := 1
|
||||
p = WriteUint(msg.Timestamp, buf, p)
|
||||
p = WriteUint(msg.Duration, buf, p)
|
||||
p = WriteUint(msg.TTFB, buf, p)
|
||||
p = WriteUint(msg.HeaderSize, buf, p)
|
||||
p = WriteUint(msg.EncodedBodySize, buf, p)
|
||||
p = WriteUint(msg.DecodedBodySize, buf, p)
|
||||
p = WriteString(msg.URL, buf, p)
|
||||
p = WriteString(msg.Initiator, buf, p)
|
||||
p = WriteUint(msg.TransferredSize, buf, p)
|
||||
p = WriteBoolean(msg.Cached, buf, p)
|
||||
p = WriteUint(msg.Queueing, buf, p)
|
||||
p = WriteUint(msg.DnsLookup, buf, p)
|
||||
p = WriteUint(msg.InitialConnection, buf, p)
|
||||
p = WriteUint(msg.SSL, buf, p)
|
||||
p = WriteUint(msg.ContentDownload, buf, p)
|
||||
p = WriteUint(msg.Total, buf, p)
|
||||
p = WriteUint(msg.Stalled, buf, p)
|
||||
return buf[:p]
|
||||
}
|
||||
|
||||
func (msg *ResourceTiming) Decode() Message {
|
||||
return msg
|
||||
}
|
||||
|
||||
func (msg *ResourceTiming) TypeID() int {
|
||||
return 85
|
||||
}
|
||||
|
||||
type LongAnimationTask struct {
|
||||
message
|
||||
Name string
|
||||
Duration int64
|
||||
BlockingDuration int64
|
||||
FirstUIEventTimestamp int64
|
||||
StartTime int64
|
||||
Scripts string
|
||||
}
|
||||
|
||||
func (msg *LongAnimationTask) Encode() []byte {
|
||||
buf := make([]byte, 61+len(msg.Name)+len(msg.Scripts))
|
||||
buf[0] = 89
|
||||
p := 1
|
||||
p = WriteString(msg.Name, buf, p)
|
||||
p = WriteInt(msg.Duration, buf, p)
|
||||
p = WriteInt(msg.BlockingDuration, buf, p)
|
||||
p = WriteInt(msg.FirstUIEventTimestamp, buf, p)
|
||||
p = WriteInt(msg.StartTime, buf, p)
|
||||
p = WriteString(msg.Scripts, buf, p)
|
||||
return buf[:p]
|
||||
}
|
||||
|
||||
func (msg *LongAnimationTask) Decode() Message {
|
||||
return msg
|
||||
}
|
||||
|
||||
func (msg *LongAnimationTask) TypeID() int {
|
||||
return 89
|
||||
}
|
||||
|
||||
type InputChange struct {
|
||||
message
|
||||
ID uint64
|
||||
|
|
@ -2418,7 +2502,7 @@ func (msg *UnbindNodes) TypeID() int {
|
|||
return 115
|
||||
}
|
||||
|
||||
type ResourceTiming struct {
|
||||
type ResourceTimingDeprecated struct {
|
||||
message
|
||||
Timestamp uint64
|
||||
Duration uint64
|
||||
|
|
@ -2432,7 +2516,7 @@ type ResourceTiming struct {
|
|||
Cached bool
|
||||
}
|
||||
|
||||
func (msg *ResourceTiming) Encode() []byte {
|
||||
func (msg *ResourceTimingDeprecated) Encode() []byte {
|
||||
buf := make([]byte, 101+len(msg.URL)+len(msg.Initiator))
|
||||
buf[0] = 116
|
||||
p := 1
|
||||
|
|
@ -2449,11 +2533,11 @@ func (msg *ResourceTiming) Encode() []byte {
|
|||
return buf[:p]
|
||||
}
|
||||
|
||||
func (msg *ResourceTiming) Decode() Message {
|
||||
func (msg *ResourceTimingDeprecated) Decode() Message {
|
||||
return msg
|
||||
}
|
||||
|
||||
func (msg *ResourceTiming) TypeID() int {
|
||||
func (msg *ResourceTimingDeprecated) TypeID() int {
|
||||
return 116
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -873,9 +873,9 @@ func DecodeSetNodeAttributeDict(reader BytesReader) (Message, error) {
|
|||
return msg, err
|
||||
}
|
||||
|
||||
func DecodeResourceTimingDeprecated(reader BytesReader) (Message, error) {
|
||||
func DecodeResourceTimingDeprecatedDeprecated(reader BytesReader) (Message, error) {
|
||||
var err error = nil
|
||||
msg := &ResourceTimingDeprecated{}
|
||||
msg := &ResourceTimingDeprecatedDeprecated{}
|
||||
if msg.Timestamp, err = reader.ReadUint(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -1432,6 +1432,85 @@ func DecodeIncident(reader BytesReader) (Message, error) {
|
|||
return nil, err
|
||||
}
|
||||
return msg, err
|
||||
func DecodeResourceTiming(reader BytesReader) (Message, error) {
|
||||
var err error = nil
|
||||
msg := &ResourceTiming{}
|
||||
if msg.Timestamp, err = reader.ReadUint(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if msg.Duration, err = reader.ReadUint(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if msg.TTFB, err = reader.ReadUint(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if msg.HeaderSize, err = reader.ReadUint(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if msg.EncodedBodySize, err = reader.ReadUint(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if msg.DecodedBodySize, err = reader.ReadUint(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if msg.URL, err = reader.ReadString(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if msg.Initiator, err = reader.ReadString(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if msg.TransferredSize, err = reader.ReadUint(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if msg.Cached, err = reader.ReadBoolean(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if msg.Queueing, err = reader.ReadUint(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if msg.DnsLookup, err = reader.ReadUint(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if msg.InitialConnection, err = reader.ReadUint(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if msg.SSL, err = reader.ReadUint(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if msg.ContentDownload, err = reader.ReadUint(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if msg.Total, err = reader.ReadUint(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if msg.Stalled, err = reader.ReadUint(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return msg, err
|
||||
}
|
||||
|
||||
func DecodeLongAnimationTask(reader BytesReader) (Message, error) {
|
||||
var err error = nil
|
||||
msg := &LongAnimationTask{}
|
||||
if msg.Name, err = reader.ReadString(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if msg.Duration, err = reader.ReadInt(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if msg.BlockingDuration, err = reader.ReadInt(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if msg.FirstUIEventTimestamp, err = reader.ReadInt(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if msg.StartTime, err = reader.ReadInt(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if msg.Scripts, err = reader.ReadString(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return msg, err
|
||||
}
|
||||
|
||||
func DecodeInputChange(reader BytesReader) (Message, error) {
|
||||
|
|
@ -1491,9 +1570,9 @@ func DecodeUnbindNodes(reader BytesReader) (Message, error) {
|
|||
return msg, err
|
||||
}
|
||||
|
||||
func DecodeResourceTiming(reader BytesReader) (Message, error) {
|
||||
func DecodeResourceTimingDeprecated(reader BytesReader) (Message, error) {
|
||||
var err error = nil
|
||||
msg := &ResourceTiming{}
|
||||
msg := &ResourceTimingDeprecated{}
|
||||
if msg.Timestamp, err = reader.ReadUint(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -2202,7 +2281,7 @@ func ReadMessage(t uint64, reader BytesReader) (Message, error) {
|
|||
case 52:
|
||||
return DecodeSetNodeAttributeDict(reader)
|
||||
case 53:
|
||||
return DecodeResourceTimingDeprecated(reader)
|
||||
return DecodeResourceTimingDeprecatedDeprecated(reader)
|
||||
case 54:
|
||||
return DecodeConnectionInformation(reader)
|
||||
case 55:
|
||||
|
|
@ -2264,7 +2343,9 @@ func ReadMessage(t uint64, reader BytesReader) (Message, error) {
|
|||
case 84:
|
||||
return DecodeWSChannel(reader)
|
||||
case 85:
|
||||
return DecodeIncident(reader)
|
||||
return DecodeResourceTiming(reader)
|
||||
case 89:
|
||||
return DecodeLongAnimationTask(reader)
|
||||
case 112:
|
||||
return DecodeInputChange(reader)
|
||||
case 113:
|
||||
|
|
@ -2274,7 +2355,7 @@ func ReadMessage(t uint64, reader BytesReader) (Message, error) {
|
|||
case 115:
|
||||
return DecodeUnbindNodes(reader)
|
||||
case 116:
|
||||
return DecodeResourceTiming(reader)
|
||||
return DecodeResourceTimingDeprecated(reader)
|
||||
case 117:
|
||||
return DecodeTabChange(reader)
|
||||
case 118:
|
||||
|
|
|
|||
|
|
@ -154,13 +154,6 @@ func (e *handlersImpl) startSessionHandlerWeb(w http.ResponseWriter, r *http.Req
|
|||
// Add projectID to context
|
||||
r = r.WithContext(context.WithValue(r.Context(), "projectID", fmt.Sprintf("%d", p.ProjectID)))
|
||||
|
||||
// Validate tracker version
|
||||
if err := validateTrackerVersion(req.TrackerVersion); err != nil {
|
||||
e.log.Error(r.Context(), "unsupported tracker version: %s, err: %s", req.TrackerVersion, err)
|
||||
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusUpgradeRequired, errors.New("please upgrade the tracker version"), startTime, r.URL.Path, bodySize)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the project supports mobile sessions
|
||||
if !p.IsWeb() {
|
||||
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusForbidden, errors.New("project doesn't support web sessions"), startTime, r.URL.Path, bodySize)
|
||||
|
|
|
|||
2
ee/api/.gitignore
vendored
2
ee/api/.gitignore
vendored
|
|
@ -187,7 +187,7 @@ Pipfile.lock
|
|||
/chalicelib/core/announcements.py
|
||||
/chalicelib/core/assist.py
|
||||
/chalicelib/core/authorizers.py
|
||||
/chalicelib/core/autocomplete/autocomplete.py
|
||||
/chalicelib/core/autocomplete
|
||||
/chalicelib/core/boarding.py
|
||||
/chalicelib/core/canvas.py
|
||||
/chalicelib/core/collaborations/__init__.py
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
import logging
|
||||
|
||||
from decouple import config
|
||||
|
||||
logging.basicConfig(level=config("LOGLEVEL", default=logging.INFO))
|
||||
|
||||
if config("EXP_AUTOCOMPLETE", cast=bool, default=False):
|
||||
logging.info(">>> Using experimental autocomplete")
|
||||
from . import autocomplete_ch as autocomplete
|
||||
else:
|
||||
from . import autocomplete
|
||||
|
|
@ -1,9 +1,16 @@
|
|||
from typing import Union
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
import struct
|
||||
from decimal import Decimal
|
||||
from typing import Any, Union
|
||||
|
||||
from decouple import config
|
||||
|
||||
import schemas
|
||||
from chalicelib.utils import sql_helper as sh
|
||||
from chalicelib.utils.TimeUTC import TimeUTC
|
||||
from decouple import config
|
||||
import logging
|
||||
from schemas import SearchEventOperator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -110,12 +117,13 @@ def simplify_clickhouse_type(ch_type: str) -> str:
|
|||
return "int"
|
||||
|
||||
# Floats: Float32, Float64
|
||||
if re.match(r'^float(32|64)$', normalized_type):
|
||||
if re.match(r'^float(32|64)|double$', normalized_type):
|
||||
return "float"
|
||||
|
||||
# Decimal: Decimal(P, S)
|
||||
if normalized_type.startswith("decimal"):
|
||||
return "decimal"
|
||||
# return "decimal"
|
||||
return "float"
|
||||
|
||||
# Date/DateTime
|
||||
if normalized_type.startswith("date"):
|
||||
|
|
@ -131,11 +139,13 @@ def simplify_clickhouse_type(ch_type: str) -> str:
|
|||
|
||||
# UUID
|
||||
if normalized_type.startswith("uuid"):
|
||||
return "uuid"
|
||||
# return "uuid"
|
||||
return "string"
|
||||
|
||||
# Enums: Enum8(...) or Enum16(...)
|
||||
if normalized_type.startswith("enum8") or normalized_type.startswith("enum16"):
|
||||
return "enum"
|
||||
# return "enum"
|
||||
return "string"
|
||||
|
||||
# Arrays: Array(T)
|
||||
if normalized_type.startswith("array"):
|
||||
|
|
@ -166,8 +176,73 @@ def simplify_clickhouse_types(ch_types: list[str]) -> list[str]:
|
|||
|
||||
|
||||
def get_sub_condition(col_name: str, val_name: str,
|
||||
operator: Union[schemas.SearchEventOperator, schemas.MathOperator]):
|
||||
operator: Union[schemas.SearchEventOperator, schemas.MathOperator]) -> str:
|
||||
if operator == SearchEventOperator.PATTERN:
|
||||
return f"match({col_name}, %({val_name})s)"
|
||||
op = sh.get_sql_operator(operator)
|
||||
return f"{col_name} {op} %({val_name})s"
|
||||
|
||||
|
||||
def get_col_cast(data_type: schemas.PropertyType, value: Any) -> str:
|
||||
if value is None or len(value) == 0:
|
||||
return ""
|
||||
if isinstance(value, list):
|
||||
value = value[0]
|
||||
if data_type in (schemas.PropertyType.INT, schemas.PropertyType.FLOAT):
|
||||
return best_clickhouse_type(value)
|
||||
return data_type.capitalize()
|
||||
|
||||
|
||||
# (type_name, minimum, maximum) – ordered by increasing size
|
||||
_INT_RANGES = [
|
||||
("Int8", -128, 127),
|
||||
("UInt8", 0, 255),
|
||||
("Int16", -32_768, 32_767),
|
||||
("UInt16", 0, 65_535),
|
||||
("Int32", -2_147_483_648, 2_147_483_647),
|
||||
("UInt32", 0, 4_294_967_295),
|
||||
("Int64", -9_223_372_036_854_775_808, 9_223_372_036_854_775_807),
|
||||
("UInt64", 0, 18_446_744_073_709_551_615),
|
||||
]
|
||||
|
||||
|
||||
def best_clickhouse_type(value):
|
||||
"""
|
||||
Return the most compact ClickHouse numeric type that can store *value* loss-lessly.
|
||||
|
||||
"""
|
||||
# Treat bool like tiny int
|
||||
if isinstance(value, bool):
|
||||
value = int(value)
|
||||
|
||||
# --- Integers ---
|
||||
if isinstance(value, int):
|
||||
for name, lo, hi in _INT_RANGES:
|
||||
if lo <= value <= hi:
|
||||
return name
|
||||
# Beyond UInt64: ClickHouse offers Int128 / Int256 or Decimal
|
||||
return "Int128"
|
||||
|
||||
# --- Decimal.Decimal (exact) ---
|
||||
if isinstance(value, Decimal):
|
||||
# ClickHouse Decimal32/64/128 have 9 / 18 / 38 significant digits.
|
||||
digits = len(value.as_tuple().digits)
|
||||
if digits <= 9:
|
||||
return "Decimal32"
|
||||
elif digits <= 18:
|
||||
return "Decimal64"
|
||||
else:
|
||||
return "Decimal128"
|
||||
|
||||
# --- Floats ---
|
||||
if isinstance(value, float):
|
||||
if not math.isfinite(value):
|
||||
return "Float64" # inf / nan → always Float64
|
||||
|
||||
# Check if a round-trip through 32-bit float preserves the bit pattern
|
||||
packed = struct.pack("f", value)
|
||||
if struct.unpack("f", packed)[0] == value:
|
||||
return "Float32"
|
||||
return "Float64"
|
||||
|
||||
raise TypeError(f"Unsupported type: {type(value).__name__}")
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ rm -rf ./build_crons.sh
|
|||
rm -rf ./chalicelib/core/announcements.py
|
||||
rm -rf ./chalicelib/core/assist.py
|
||||
rm -rf ./chalicelib/core/authorizers.py
|
||||
rm -rf ./chalicelib/core/autocomplete/autocomplete.py
|
||||
rm -rf ./chalicelib/core/autocomplete
|
||||
rm -rf ./chalicelib/core/collaborations/__init__.py
|
||||
rm -rf ./chalicelib/core/collaborations/collaboration_base.py
|
||||
rm -rf ./chalicelib/core/collaborations/collaboration_msteams.py
|
||||
|
|
|
|||
|
|
@ -500,7 +500,7 @@ class SetNodeAttributeDict(Message):
|
|||
self.value = value
|
||||
|
||||
|
||||
class ResourceTimingDeprecated(Message):
|
||||
class ResourceTimingDeprecatedDeprecated(Message):
|
||||
__id__ = 53
|
||||
|
||||
def __init__(self, timestamp, duration, ttfb, header_size, encoded_body_size, decoded_body_size, url, initiator):
|
||||
|
|
@ -806,13 +806,39 @@ class WSChannel(Message):
|
|||
self.message_type = message_type
|
||||
|
||||
|
||||
class Incident(Message):
|
||||
class ResourceTiming(Message):
|
||||
__id__ = 85
|
||||
|
||||
def __init__(self, label, start_time, end_time):
|
||||
self.label = label
|
||||
def __init__(self, timestamp, duration, ttfb, header_size, encoded_body_size, decoded_body_size, url, initiator, transferred_size, cached, queueing, dns_lookup, initial_connection, ssl, content_download, total, stalled):
|
||||
self.timestamp = timestamp
|
||||
self.duration = duration
|
||||
self.ttfb = ttfb
|
||||
self.header_size = header_size
|
||||
self.encoded_body_size = encoded_body_size
|
||||
self.decoded_body_size = decoded_body_size
|
||||
self.url = url
|
||||
self.initiator = initiator
|
||||
self.transferred_size = transferred_size
|
||||
self.cached = cached
|
||||
self.queueing = queueing
|
||||
self.dns_lookup = dns_lookup
|
||||
self.initial_connection = initial_connection
|
||||
self.ssl = ssl
|
||||
self.content_download = content_download
|
||||
self.total = total
|
||||
self.stalled = stalled
|
||||
|
||||
|
||||
class LongAnimationTask(Message):
|
||||
__id__ = 89
|
||||
|
||||
def __init__(self, name, duration, blocking_duration, first_ui_event_timestamp, start_time, scripts):
|
||||
self.name = name
|
||||
self.duration = duration
|
||||
self.blocking_duration = blocking_duration
|
||||
self.first_ui_event_timestamp = first_ui_event_timestamp
|
||||
self.start_time = start_time
|
||||
self.end_time = end_time
|
||||
self.scripts = scripts
|
||||
|
||||
|
||||
class InputChange(Message):
|
||||
|
|
@ -850,7 +876,7 @@ class UnbindNodes(Message):
|
|||
self.total_removed_percent = total_removed_percent
|
||||
|
||||
|
||||
class ResourceTiming(Message):
|
||||
class ResourceTimingDeprecated(Message):
|
||||
__id__ = 116
|
||||
|
||||
def __init__(self, timestamp, duration, ttfb, header_size, encoded_body_size, decoded_body_size, url, initiator, transferred_size, cached):
|
||||
|
|
|
|||
|
|
@ -743,7 +743,7 @@ cdef class SetNodeAttributeDict(PyMessage):
|
|||
self.value = value
|
||||
|
||||
|
||||
cdef class ResourceTimingDeprecated(PyMessage):
|
||||
cdef class ResourceTimingDeprecatedDeprecated(PyMessage):
|
||||
cdef public int __id__
|
||||
cdef public unsigned long timestamp
|
||||
cdef public unsigned long duration
|
||||
|
|
@ -1200,17 +1200,64 @@ cdef class WSChannel(PyMessage):
|
|||
self.message_type = message_type
|
||||
|
||||
|
||||
cdef class Incident(PyMessage):
|
||||
cdef class ResourceTiming(PyMessage):
|
||||
cdef public int __id__
|
||||
cdef public str label
|
||||
cdef public long start_time
|
||||
cdef public long end_time
|
||||
cdef public unsigned long timestamp
|
||||
cdef public unsigned long duration
|
||||
cdef public unsigned long ttfb
|
||||
cdef public unsigned long header_size
|
||||
cdef public unsigned long encoded_body_size
|
||||
cdef public unsigned long decoded_body_size
|
||||
cdef public str url
|
||||
cdef public str initiator
|
||||
cdef public unsigned long transferred_size
|
||||
cdef public bint cached
|
||||
cdef public unsigned long queueing
|
||||
cdef public unsigned long dns_lookup
|
||||
cdef public unsigned long initial_connection
|
||||
cdef public unsigned long ssl
|
||||
cdef public unsigned long content_download
|
||||
cdef public unsigned long total
|
||||
cdef public unsigned long stalled
|
||||
|
||||
def __init__(self, str label, long start_time, long end_time):
|
||||
def __init__(self, unsigned long timestamp, unsigned long duration, unsigned long ttfb, unsigned long header_size, unsigned long encoded_body_size, unsigned long decoded_body_size, str url, str initiator, unsigned long transferred_size, bint cached, unsigned long queueing, unsigned long dns_lookup, unsigned long initial_connection, unsigned long ssl, unsigned long content_download, unsigned long total, unsigned long stalled):
|
||||
self.__id__ = 85
|
||||
self.label = label
|
||||
self.timestamp = timestamp
|
||||
self.duration = duration
|
||||
self.ttfb = ttfb
|
||||
self.header_size = header_size
|
||||
self.encoded_body_size = encoded_body_size
|
||||
self.decoded_body_size = decoded_body_size
|
||||
self.url = url
|
||||
self.initiator = initiator
|
||||
self.transferred_size = transferred_size
|
||||
self.cached = cached
|
||||
self.queueing = queueing
|
||||
self.dns_lookup = dns_lookup
|
||||
self.initial_connection = initial_connection
|
||||
self.ssl = ssl
|
||||
self.content_download = content_download
|
||||
self.total = total
|
||||
self.stalled = stalled
|
||||
|
||||
|
||||
cdef class LongAnimationTask(PyMessage):
|
||||
cdef public int __id__
|
||||
cdef public str name
|
||||
cdef public long duration
|
||||
cdef public long blocking_duration
|
||||
cdef public long first_ui_event_timestamp
|
||||
cdef public long start_time
|
||||
cdef public str scripts
|
||||
|
||||
def __init__(self, str name, long duration, long blocking_duration, long first_ui_event_timestamp, long start_time, str scripts):
|
||||
self.__id__ = 89
|
||||
self.name = name
|
||||
self.duration = duration
|
||||
self.blocking_duration = blocking_duration
|
||||
self.first_ui_event_timestamp = first_ui_event_timestamp
|
||||
self.start_time = start_time
|
||||
self.end_time = end_time
|
||||
self.scripts = scripts
|
||||
|
||||
|
||||
cdef class InputChange(PyMessage):
|
||||
|
|
@ -1263,7 +1310,7 @@ cdef class UnbindNodes(PyMessage):
|
|||
self.total_removed_percent = total_removed_percent
|
||||
|
||||
|
||||
cdef class ResourceTiming(PyMessage):
|
||||
cdef class ResourceTimingDeprecated(PyMessage):
|
||||
cdef public int __id__
|
||||
cdef public unsigned long timestamp
|
||||
cdef public unsigned long duration
|
||||
|
|
|
|||
|
|
@ -486,7 +486,7 @@ class MessageCodec(Codec):
|
|||
)
|
||||
|
||||
if message_id == 53:
|
||||
return ResourceTimingDeprecated(
|
||||
return ResourceTimingDeprecatedDeprecated(
|
||||
timestamp=self.read_uint(reader),
|
||||
duration=self.read_uint(reader),
|
||||
ttfb=self.read_uint(reader),
|
||||
|
|
@ -730,10 +730,34 @@ class MessageCodec(Codec):
|
|||
)
|
||||
|
||||
if message_id == 85:
|
||||
return Incident(
|
||||
label=self.read_string(reader),
|
||||
return ResourceTiming(
|
||||
timestamp=self.read_uint(reader),
|
||||
duration=self.read_uint(reader),
|
||||
ttfb=self.read_uint(reader),
|
||||
header_size=self.read_uint(reader),
|
||||
encoded_body_size=self.read_uint(reader),
|
||||
decoded_body_size=self.read_uint(reader),
|
||||
url=self.read_string(reader),
|
||||
initiator=self.read_string(reader),
|
||||
transferred_size=self.read_uint(reader),
|
||||
cached=self.read_boolean(reader),
|
||||
queueing=self.read_uint(reader),
|
||||
dns_lookup=self.read_uint(reader),
|
||||
initial_connection=self.read_uint(reader),
|
||||
ssl=self.read_uint(reader),
|
||||
content_download=self.read_uint(reader),
|
||||
total=self.read_uint(reader),
|
||||
stalled=self.read_uint(reader)
|
||||
)
|
||||
|
||||
if message_id == 89:
|
||||
return LongAnimationTask(
|
||||
name=self.read_string(reader),
|
||||
duration=self.read_int(reader),
|
||||
blocking_duration=self.read_int(reader),
|
||||
first_ui_event_timestamp=self.read_int(reader),
|
||||
start_time=self.read_int(reader),
|
||||
end_time=self.read_int(reader)
|
||||
scripts=self.read_string(reader)
|
||||
)
|
||||
|
||||
if message_id == 112:
|
||||
|
|
@ -764,7 +788,7 @@ class MessageCodec(Codec):
|
|||
)
|
||||
|
||||
if message_id == 116:
|
||||
return ResourceTiming(
|
||||
return ResourceTimingDeprecated(
|
||||
timestamp=self.read_uint(reader),
|
||||
duration=self.read_uint(reader),
|
||||
ttfb=self.read_uint(reader),
|
||||
|
|
|
|||
|
|
@ -584,7 +584,7 @@ cdef class MessageCodec:
|
|||
)
|
||||
|
||||
if message_id == 53:
|
||||
return ResourceTimingDeprecated(
|
||||
return ResourceTimingDeprecatedDeprecated(
|
||||
timestamp=self.read_uint(reader),
|
||||
duration=self.read_uint(reader),
|
||||
ttfb=self.read_uint(reader),
|
||||
|
|
@ -828,10 +828,34 @@ cdef class MessageCodec:
|
|||
)
|
||||
|
||||
if message_id == 85:
|
||||
return Incident(
|
||||
label=self.read_string(reader),
|
||||
return ResourceTiming(
|
||||
timestamp=self.read_uint(reader),
|
||||
duration=self.read_uint(reader),
|
||||
ttfb=self.read_uint(reader),
|
||||
header_size=self.read_uint(reader),
|
||||
encoded_body_size=self.read_uint(reader),
|
||||
decoded_body_size=self.read_uint(reader),
|
||||
url=self.read_string(reader),
|
||||
initiator=self.read_string(reader),
|
||||
transferred_size=self.read_uint(reader),
|
||||
cached=self.read_boolean(reader),
|
||||
queueing=self.read_uint(reader),
|
||||
dns_lookup=self.read_uint(reader),
|
||||
initial_connection=self.read_uint(reader),
|
||||
ssl=self.read_uint(reader),
|
||||
content_download=self.read_uint(reader),
|
||||
total=self.read_uint(reader),
|
||||
stalled=self.read_uint(reader)
|
||||
)
|
||||
|
||||
if message_id == 89:
|
||||
return LongAnimationTask(
|
||||
name=self.read_string(reader),
|
||||
duration=self.read_int(reader),
|
||||
blocking_duration=self.read_int(reader),
|
||||
first_ui_event_timestamp=self.read_int(reader),
|
||||
start_time=self.read_int(reader),
|
||||
end_time=self.read_int(reader)
|
||||
scripts=self.read_string(reader)
|
||||
)
|
||||
|
||||
if message_id == 112:
|
||||
|
|
@ -862,7 +886,7 @@ cdef class MessageCodec:
|
|||
)
|
||||
|
||||
if message_id == 116:
|
||||
return ResourceTiming(
|
||||
return ResourceTimingDeprecated(
|
||||
timestamp=self.read_uint(reader),
|
||||
duration=self.read_uint(reader),
|
||||
ttfb=self.read_uint(reader),
|
||||
|
|
|
|||
|
|
@ -2,6 +2,6 @@ compressionLevel: 1
|
|||
|
||||
enableGlobalCache: true
|
||||
|
||||
nodeLinker: pnpm
|
||||
nodeLinker: node-modules
|
||||
|
||||
yarnPath: .yarn/releases/yarn-4.7.0.cjs
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { Loader } from 'UI';
|
|||
import APIClient from './api_client';
|
||||
import * as routes from './routes';
|
||||
import { debounceCall } from '@/utils';
|
||||
import { hasAi } from './utils/split-utils';
|
||||
|
||||
const components: any = {
|
||||
SessionPure: lazy(() => import('Components/Session/Session')),
|
||||
|
|
@ -32,7 +33,8 @@ const components: any = {
|
|||
SpotsListPure: lazy(() => import('Components/Spots/SpotsList')),
|
||||
SpotPure: lazy(() => import('Components/Spots/SpotPlayer')),
|
||||
ScopeSetup: lazy(() => import('Components/ScopeForm')),
|
||||
HighlightsPure: lazy(() => import('Components/Highlights/HighlightsList'))
|
||||
HighlightsPure: lazy(() => import('Components/Highlights/HighlightsList')),
|
||||
KaiPure: lazy(() => import('Components/Kai/KaiChat')),
|
||||
};
|
||||
|
||||
const enhancedComponents: any = {
|
||||
|
|
@ -52,7 +54,8 @@ const enhancedComponents: any = {
|
|||
SpotsList: withSiteIdUpdater(components.SpotsListPure),
|
||||
Spot: components.SpotPure,
|
||||
ScopeSetup: components.ScopeSetup,
|
||||
Highlights: withSiteIdUpdater(components.HighlightsPure)
|
||||
Highlights: withSiteIdUpdater(components.HighlightsPure),
|
||||
Kai: withSiteIdUpdater(components.KaiPure),
|
||||
};
|
||||
|
||||
const { withSiteId } = routes;
|
||||
|
|
@ -97,9 +100,11 @@ const SPOT_PATH = routes.spot();
|
|||
const SCOPE_SETUP = routes.scopeSetup();
|
||||
|
||||
const HIGHLIGHTS_PATH = routes.highlights();
|
||||
const KAI_PATH = routes.kai();
|
||||
|
||||
function PrivateRoutes() {
|
||||
const { projectsStore, userStore, integrationsStore, searchStore } = useStore();
|
||||
const { projectsStore, userStore, integrationsStore, searchStore } =
|
||||
useStore();
|
||||
const onboarding = userStore.onboarding;
|
||||
const scope = userStore.scopeState;
|
||||
const { tenantId } = userStore.account;
|
||||
|
|
@ -123,8 +128,12 @@ function PrivateRoutes() {
|
|||
|
||||
React.useEffect(() => {
|
||||
if (!searchStore.urlParsed) return;
|
||||
debounceCall(() => searchStore.fetchSessions(true), 250)()
|
||||
}, [searchStore.urlParsed, searchStore.instance.filters, searchStore.instance.eventsOrder]);
|
||||
debounceCall(() => searchStore.fetchSessions(true), 250)();
|
||||
}, [
|
||||
searchStore.urlParsed,
|
||||
searchStore.instance.filters,
|
||||
searchStore.instance.eventsOrder,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Suspense fallback={<Loader loading className="flex-1" />}>
|
||||
|
|
@ -162,13 +171,13 @@ function PrivateRoutes() {
|
|||
case '/integrations/slack':
|
||||
client.post('integrations/slack/add', {
|
||||
code: location.search.split('=')[1],
|
||||
state: tenantId
|
||||
state: tenantId,
|
||||
});
|
||||
break;
|
||||
case '/integrations/msteams':
|
||||
client.post('integrations/msteams/add', {
|
||||
code: location.search.split('=')[1],
|
||||
state: tenantId
|
||||
state: tenantId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
|
@ -193,7 +202,7 @@ function PrivateRoutes() {
|
|||
withSiteId(DASHBOARD_PATH, siteIdList),
|
||||
withSiteId(DASHBOARD_SELECT_PATH, siteIdList),
|
||||
withSiteId(DASHBOARD_METRIC_CREATE_PATH, siteIdList),
|
||||
withSiteId(DASHBOARD_METRIC_DETAILS_PATH, siteIdList)
|
||||
withSiteId(DASHBOARD_METRIC_DETAILS_PATH, siteIdList),
|
||||
]}
|
||||
component={enhancedComponents.Dashboard}
|
||||
/>
|
||||
|
|
@ -254,7 +263,7 @@ function PrivateRoutes() {
|
|||
withSiteId(FFLAG_READ_PATH, siteIdList),
|
||||
withSiteId(FFLAG_CREATE_PATH, siteIdList),
|
||||
withSiteId(NOTES_PATH, siteIdList),
|
||||
withSiteId(BOOKMARKS_PATH, siteIdList)
|
||||
withSiteId(BOOKMARKS_PATH, siteIdList),
|
||||
]}
|
||||
component={enhancedComponents.SessionsOverview}
|
||||
/>
|
||||
|
|
@ -270,6 +279,14 @@ function PrivateRoutes() {
|
|||
path={withSiteId(LIVE_SESSION_PATH, siteIdList)}
|
||||
component={enhancedComponents.LiveSession}
|
||||
/>
|
||||
{hasAi ? (
|
||||
<Route
|
||||
exact
|
||||
strict
|
||||
path={withSiteId(KAI_PATH, siteIdList)}
|
||||
component={enhancedComponents.Kai}
|
||||
/>
|
||||
) : null}
|
||||
{Object.entries(routes.redirects).map(([fr, to]) => (
|
||||
<Redirect key={fr} exact strict from={fr} to={to} />
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ export default class APIClient {
|
|||
|
||||
private siteIdCheck: (() => { siteId: string | null }) | undefined;
|
||||
|
||||
private getJwt: () => string | null = () => null;
|
||||
public getJwt: () => string | null = () => null;
|
||||
|
||||
private onUpdateJwt: (data: { jwt?: string; spotJwt?: string }) => void;
|
||||
|
||||
|
|
@ -197,7 +197,7 @@ export default class APIClient {
|
|||
delete init.credentials;
|
||||
}
|
||||
|
||||
const noChalice = path.includes('v1/integrations') || path.includes('/spot') && !path.includes('/login');
|
||||
const noChalice = path.includes('/kai') || path.includes('v1/integrations') || path.includes('/spot') && !path.includes('/login');
|
||||
let edp = window.env.API_EDP || window.location.origin + '/api';
|
||||
if (noChalice && !edp.includes('api.openreplay.com')) {
|
||||
edp = edp.replace('/api', '');
|
||||
|
|
|
|||
BIN
frontend/app/assets/img/logo-img.png
Normal file
BIN
frontend/app/assets/img/logo-img.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
|
|
@ -3,11 +3,12 @@ import LiveSessionList from 'Shared/LiveSessionList';
|
|||
import LiveSessionSearch from 'Shared/LiveSessionSearch';
|
||||
import usePageTitle from '@/hooks/usePageTitle';
|
||||
import AssistSearchActions from './AssistSearchActions';
|
||||
import { PANEL_SIZES } from 'App/constants/panelSizes'
|
||||
|
||||
function AssistView() {
|
||||
usePageTitle('Co-Browse - OpenReplay');
|
||||
return (
|
||||
<div className="w-full mx-auto" style={{ maxWidth: '1360px' }}>
|
||||
<div className="w-full mx-auto" style={{ maxWidth: PANEL_SIZES.maxWidth }}>
|
||||
<AssistSearchActions />
|
||||
<LiveSessionSearch />
|
||||
<div className="my-4" />
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { observer } from 'mobx-react-lite';
|
|||
import RecordingsList from './RecordingsList';
|
||||
import RecordingsSearch from './RecordingsSearch';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PANEL_SIZES } from 'App/constants/panelSizes'
|
||||
|
||||
function Recordings() {
|
||||
const { t } = useTranslation();
|
||||
|
|
@ -24,7 +25,7 @@ function Recordings() {
|
|||
|
||||
return (
|
||||
<div
|
||||
style={{ maxWidth: '1360px', margin: 'auto' }}
|
||||
style={{ maxWidth: PANEL_SIZES.maxWidth, margin: 'auto' }}
|
||||
className="bg-white rounded-lg py-4 border h-screen overflow-y-scroll"
|
||||
>
|
||||
<div className="flex items-center mb-4 justify-between px-6">
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React from 'react';
|
|||
import { withRouter } from 'react-router-dom';
|
||||
import { Switch, Route, Redirect } from 'react-router';
|
||||
import { CLIENT_TABS, client as clientRoute } from 'App/routes';
|
||||
import { PANEL_SIZES } from 'App/constants/panelSizes'
|
||||
|
||||
import SessionsListingSettings from 'Components/Client/SessionsListingSettings';
|
||||
import Modules from 'Components/Client/Modules';
|
||||
|
|
@ -105,7 +106,7 @@ export default class Client extends React.PureComponent {
|
|||
},
|
||||
} = this.props;
|
||||
return (
|
||||
<div className="w-full mx-auto mb-8" style={{ maxWidth: '1360px' }}>
|
||||
<div className="w-full mx-auto mb-8" style={{ maxWidth: PANEL_SIZES.maxWidth }}>
|
||||
{activeTab && this.renderActiveTab()}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import DefaultPlaying from 'Shared/SessionSettings/components/DefaultPlaying';
|
|||
import DefaultTimezone from 'Shared/SessionSettings/components/DefaultTimezone';
|
||||
import ListingVisibility from 'Shared/SessionSettings/components/ListingVisibility';
|
||||
import MouseTrailSettings from 'Shared/SessionSettings/components/MouseTrailSettings';
|
||||
import VirtualModeSettings from '../shared/SessionSettings/components/VirtualMode';
|
||||
import DebugLog from './DebugLog';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
|
|
@ -35,6 +36,7 @@ function SessionsListingSettings() {
|
|||
<div className="flex flex-col gap-2">
|
||||
<MouseTrailSettings />
|
||||
<DebugLog />
|
||||
<VirtualModeSettings />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { useStore } from 'App/mstore';
|
|||
import AlertsList from './AlertsList';
|
||||
import AlertsSearch from './AlertsSearch';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PANEL_SIZES } from 'App/constants/panelSizes'
|
||||
|
||||
interface IAlertsView {
|
||||
siteId: string;
|
||||
|
|
@ -30,7 +31,7 @@ function AlertsView({ siteId }: IAlertsView) {
|
|||
}, [history]);
|
||||
return (
|
||||
<div
|
||||
style={{ maxWidth: '1360px', margin: 'auto' }}
|
||||
style={{ maxWidth: PANEL_SIZES.maxWidth, margin: 'auto' }}
|
||||
className="bg-white rounded-lg shadow-sm py-4 border"
|
||||
>
|
||||
<div className="flex items-center mb-4 justify-between px-6">
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import NotifyHooks from './AlertForm/NotifyHooks';
|
|||
import AlertListItem from './AlertListItem';
|
||||
import Condition from './AlertForm/Condition';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PANEL_SIZES } from 'App/constants/panelSizes'
|
||||
|
||||
function Circle({ text }: { text: string }) {
|
||||
return (
|
||||
|
|
@ -200,7 +201,7 @@ function NewAlert(props: IProps) {
|
|||
const isThreshold = instance.detectionMethod === 'threshold';
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: '1360px', margin: 'auto' }}>
|
||||
<div style={{ maxWidth: PANEL_SIZES.maxWidth, margin: 'auto' }}>
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2,11 +2,12 @@ import React from 'react';
|
|||
import withPageTitle from 'HOCs/withPageTitle';
|
||||
import DashboardList from './DashboardList';
|
||||
import Header from './Header';
|
||||
import { PANEL_SIZES } from 'App/constants/panelSizes'
|
||||
|
||||
function DashboardsView({ history, siteId }: { history: any; siteId: string }) {
|
||||
function DashboardsView() {
|
||||
return (
|
||||
<div
|
||||
style={{ maxWidth: '1360px', margin: 'auto' }}
|
||||
style={{ maxWidth: PANEL_SIZES.maxWidth, margin: 'auto' }}
|
||||
className="bg-white rounded-lg py-4 border shadow-sm"
|
||||
>
|
||||
<Header />
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { dashboardMetricCreate, withSiteId } from 'App/routes';
|
|||
import DashboardForm from '../DashboardForm';
|
||||
import DashboardMetricSelection from '../DashboardMetricSelection';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PANEL_SIZES } from 'App/constants/panelSizes'
|
||||
|
||||
interface Props extends RouteComponentProps {
|
||||
history: any;
|
||||
|
|
@ -57,7 +58,7 @@ function DashboardModal(props: Props) {
|
|||
backgroundColor: '#FAFAFA',
|
||||
zIndex: 999,
|
||||
width: '100%',
|
||||
maxWidth: '1360px',
|
||||
maxWidth: PANEL_SIZES.maxWidth,
|
||||
}}
|
||||
>
|
||||
<div className="mb-6 flex items-end justify-between">
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import DashboardHeader from '../DashboardHeader';
|
|||
import DashboardModal from '../DashboardModal';
|
||||
import DashboardWidgetGrid from '../DashboardWidgetGrid';
|
||||
import AiQuery from './AiQuery';
|
||||
import { PANEL_SIZES } from 'App/constants/panelSizes'
|
||||
|
||||
interface IProps {
|
||||
siteId: string;
|
||||
|
|
@ -103,7 +104,7 @@ function DashboardView(props: Props) {
|
|||
return (
|
||||
<Loader loading={loading}>
|
||||
<div
|
||||
style={{ maxWidth: '1360px', margin: 'auto' }}
|
||||
style={{ maxWidth: PANEL_SIZES.maxWidth, margin: 'auto' }}
|
||||
className="rounded-lg shadow-sm overflow-hidden bg-white border"
|
||||
>
|
||||
{/* @ts-ignore */}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import withPageTitle from 'HOCs/withPageTitle';
|
|||
import { observer } from 'mobx-react-lite';
|
||||
import MetricsList from '../MetricsList';
|
||||
import MetricViewHeader from '../MetricViewHeader';
|
||||
import { PANEL_SIZES } from 'App/constants/panelSizes'
|
||||
|
||||
interface Props {
|
||||
siteId: string;
|
||||
|
|
@ -10,7 +11,7 @@ interface Props {
|
|||
function MetricsView({ siteId }: Props) {
|
||||
return (
|
||||
<div
|
||||
style={{ maxWidth: '1360px', margin: 'auto' }}
|
||||
style={{ maxWidth: PANEL_SIZES.maxWidth, margin: 'auto' }}
|
||||
className="bg-white rounded-lg shadow-sm pt-4 border"
|
||||
>
|
||||
<MetricViewHeader siteId={siteId} />
|
||||
|
|
|
|||
|
|
@ -1,395 +1,394 @@
|
|||
import React, {useEffect, useState} from 'react';
|
||||
import {NoContent, Loader, Pagination} from 'UI';
|
||||
import {Button, Tag, Tooltip, Dropdown, message} from 'antd';
|
||||
import {UndoOutlined, DownOutlined} from '@ant-design/icons';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { NoContent, Loader, Pagination } from 'UI';
|
||||
import { Button, Tag, Tooltip, Dropdown, message } from 'antd';
|
||||
import { UndoOutlined, DownOutlined } from '@ant-design/icons';
|
||||
import cn from 'classnames';
|
||||
import {useStore} from 'App/mstore';
|
||||
import { useStore } from 'App/mstore';
|
||||
import SessionItem from 'Shared/SessionItem';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {DateTime} from 'luxon';
|
||||
import {debounce, numberWithCommas} from 'App/utils';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { DateTime } from 'luxon';
|
||||
import { debounce, numberWithCommas } from 'App/utils';
|
||||
import useIsMounted from 'App/hooks/useIsMounted';
|
||||
import AnimatedSVG, {ICONS} from 'Shared/AnimatedSVG/AnimatedSVG';
|
||||
import {HEATMAP, USER_PATH, FUNNEL} from 'App/constants/card';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
|
||||
import { HEATMAP, USER_PATH, FUNNEL } from 'App/constants/card';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function WidgetSessions(props: Props) {
|
||||
const {t} = useTranslation();
|
||||
const listRef = React.useRef<HTMLDivElement>(null);
|
||||
const {className = ''} = props;
|
||||
const [activeSeries, setActiveSeries] = useState('all');
|
||||
const [data, setData] = useState<any>([]);
|
||||
const isMounted = useIsMounted();
|
||||
const [loading, setLoading] = useState(false);
|
||||
// all filtering done through series now
|
||||
const filteredSessions = getListSessionsBySeries(data, 'all');
|
||||
const {dashboardStore, metricStore, sessionStore, customFieldStore} =
|
||||
useStore();
|
||||
const focusedSeries = metricStore.focusedSeriesName;
|
||||
const filter = dashboardStore.drillDownFilter;
|
||||
const widget = metricStore.instance;
|
||||
const startTime = DateTime.fromMillis(filter.startTimestamp).toFormat(
|
||||
'LLL dd, yyyy HH:mm',
|
||||
);
|
||||
const endTime = DateTime.fromMillis(filter.endTimestamp).toFormat(
|
||||
'LLL dd, yyyy HH:mm',
|
||||
);
|
||||
const [seriesOptions, setSeriesOptions] = useState([
|
||||
{label: t('All'), value: 'all'},
|
||||
]);
|
||||
const hasFilters =
|
||||
filter.filters.length > 0 ||
|
||||
filter.startTimestamp !== dashboardStore.drillDownPeriod.start ||
|
||||
filter.endTimestamp !== dashboardStore.drillDownPeriod.end;
|
||||
const filterText = filter.filters.length > 0 ? filter.filters[0].value : '';
|
||||
const metaList = customFieldStore.list.map((i: any) => i.key);
|
||||
const { t } = useTranslation();
|
||||
const listRef = React.useRef<HTMLDivElement>(null);
|
||||
const { className = '' } = props;
|
||||
const [activeSeries, setActiveSeries] = useState('all');
|
||||
const [data, setData] = useState<any>([]);
|
||||
const isMounted = useIsMounted();
|
||||
const [loading, setLoading] = useState(false);
|
||||
// all filtering done through series now
|
||||
const filteredSessions = getListSessionsBySeries(data, 'all');
|
||||
const { dashboardStore, metricStore, sessionStore, customFieldStore } =
|
||||
useStore();
|
||||
const focusedSeries = metricStore.focusedSeriesName;
|
||||
const filter = dashboardStore.drillDownFilter;
|
||||
const widget = metricStore.instance;
|
||||
const startTime = DateTime.fromMillis(filter.startTimestamp).toFormat(
|
||||
'LLL dd, yyyy HH:mm',
|
||||
);
|
||||
const endTime = DateTime.fromMillis(filter.endTimestamp).toFormat(
|
||||
'LLL dd, yyyy HH:mm',
|
||||
);
|
||||
const [seriesOptions, setSeriesOptions] = useState([
|
||||
{ label: t('All'), value: 'all' },
|
||||
]);
|
||||
const hasFilters =
|
||||
filter.filters.length > 0 ||
|
||||
filter.startTimestamp !== dashboardStore.drillDownPeriod.start ||
|
||||
filter.endTimestamp !== dashboardStore.drillDownPeriod.end;
|
||||
const filterText = filter.filters.length > 0 ? filter.filters[0].value : '';
|
||||
const metaList = customFieldStore.list.map((i: any) => i.key);
|
||||
|
||||
const seriesDropdownItems = seriesOptions.map((option) => ({
|
||||
key: option.value,
|
||||
label: (
|
||||
<div onClick={() => setActiveSeries(option.value)}>{option.label}</div>
|
||||
),
|
||||
const seriesDropdownItems = seriesOptions.map((option) => ({
|
||||
key: option.value,
|
||||
label: (
|
||||
<div onClick={() => setActiveSeries(option.value)}>{option.label}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (!widget.series) return;
|
||||
const seriesOptions = widget.series.map((item: any) => ({
|
||||
label: item.name,
|
||||
value: item.seriesId ?? item.name,
|
||||
}));
|
||||
setSeriesOptions([{ label: t('All'), value: 'all' }, ...seriesOptions]);
|
||||
}, [widget.series.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!widget.series) return;
|
||||
const seriesOptions = widget.series.map((item: any) => ({
|
||||
label: item.name,
|
||||
value: item.seriesId ?? item.name,
|
||||
}));
|
||||
setSeriesOptions([{label: t('All'), value: 'all'}, ...seriesOptions]);
|
||||
}, [widget.series.length]);
|
||||
const fetchSessions = (metricId: any, filter: any) => {
|
||||
if (!isMounted()) return;
|
||||
|
||||
const fetchSessions = (metricId: any, filter: any) => {
|
||||
if (!isMounted()) return;
|
||||
if (widget.metricType === FUNNEL) {
|
||||
if (filter.series[0].filter.filters.length === 0) {
|
||||
setLoading(false);
|
||||
return setData([]);
|
||||
}
|
||||
}
|
||||
|
||||
if (widget.metricType === FUNNEL) {
|
||||
if (filter.series[0].filter.filters.length === 0) {
|
||||
setLoading(false);
|
||||
return setData([]);
|
||||
}
|
||||
setLoading(true);
|
||||
const filterCopy = { ...filter };
|
||||
delete filterCopy.eventsOrderSupport;
|
||||
|
||||
try {
|
||||
// Handle filters properly with null checks
|
||||
if (filterCopy.filters && filterCopy.filters.length > 0) {
|
||||
// Ensure the nested path exists before pushing
|
||||
if (filterCopy.series?.[0]?.filter) {
|
||||
if (!filterCopy.series[0].filter.filters) {
|
||||
filterCopy.series[0].filter.filters = [];
|
||||
}
|
||||
filterCopy.series[0].filter.filters.push(...filterCopy.filters);
|
||||
}
|
||||
|
||||
|
||||
setLoading(true);
|
||||
const filterCopy = {...filter};
|
||||
delete filterCopy.eventsOrderSupport;
|
||||
|
||||
try {
|
||||
// Handle filters properly with null checks
|
||||
if (filterCopy.filters && filterCopy.filters.length > 0) {
|
||||
// Ensure the nested path exists before pushing
|
||||
if (filterCopy.series?.[0]?.filter) {
|
||||
if (!filterCopy.series[0].filter.filters) {
|
||||
filterCopy.series[0].filter.filters = [];
|
||||
}
|
||||
filterCopy.series[0].filter.filters.push(...filterCopy.filters);
|
||||
}
|
||||
filterCopy.filters = [];
|
||||
}
|
||||
} catch (e) {
|
||||
// do nothing
|
||||
filterCopy.filters = [];
|
||||
}
|
||||
} catch (e) {
|
||||
// do nothing
|
||||
}
|
||||
widget
|
||||
.fetchSessions(metricId, filterCopy)
|
||||
.then((res: any) => {
|
||||
setData(res);
|
||||
if (metricStore.drillDown) {
|
||||
setTimeout(() => {
|
||||
message.info(t('Sessions Refreshed!'));
|
||||
listRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
metricStore.setDrillDown(false);
|
||||
}, 0);
|
||||
}
|
||||
widget
|
||||
.fetchSessions(metricId, filterCopy)
|
||||
.then((res: any) => {
|
||||
setData(res);
|
||||
if (metricStore.drillDown) {
|
||||
setTimeout(() => {
|
||||
message.info(t('Sessions Refreshed!'));
|
||||
listRef.current?.scrollIntoView({behavior: 'smooth'});
|
||||
metricStore.setDrillDown(false);
|
||||
}, 0);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
const fetchClickmapSessions = (customFilters: Record<string, any>) => {
|
||||
sessionStore.getSessions(customFilters).then((data) => {
|
||||
setData([{...data, seriesId: 1, seriesName: 'Clicks'}]);
|
||||
});
|
||||
};
|
||||
const debounceRequest: any = React.useCallback(
|
||||
debounce(fetchSessions, 1000),
|
||||
[],
|
||||
);
|
||||
const debounceClickMapSearch = React.useCallback(
|
||||
debounce(fetchClickmapSessions, 1000),
|
||||
[],
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
const fetchClickmapSessions = (customFilters: Record<string, any>) => {
|
||||
sessionStore.getSessions(customFilters).then((data) => {
|
||||
setData([{ ...data, seriesId: 1, seriesName: 'Clicks' }]);
|
||||
});
|
||||
};
|
||||
const debounceRequest: any = React.useCallback(
|
||||
debounce(fetchSessions, 1000),
|
||||
[],
|
||||
);
|
||||
const debounceClickMapSearch = React.useCallback(
|
||||
debounce(fetchClickmapSessions, 1000),
|
||||
[],
|
||||
);
|
||||
|
||||
const depsString = JSON.stringify(widget.series);
|
||||
const depsString = JSON.stringify(widget.series);
|
||||
|
||||
const loadData = () => {
|
||||
if (widget.metricType === HEATMAP && metricStore.clickMapSearch) {
|
||||
const clickFilter = {
|
||||
value: [metricStore.clickMapSearch],
|
||||
type: 'CLICK',
|
||||
operator: 'onSelector',
|
||||
isEvent: true,
|
||||
// @ts-ignore
|
||||
filters: [],
|
||||
};
|
||||
const timeRange = {
|
||||
rangeValue: dashboardStore.drillDownPeriod.rangeValue,
|
||||
startDate: dashboardStore.drillDownPeriod.start,
|
||||
endDate: dashboardStore.drillDownPeriod.end,
|
||||
};
|
||||
const customFilter = {
|
||||
...filter,
|
||||
...timeRange,
|
||||
filters: [...sessionStore.userFilter.filters, clickFilter],
|
||||
};
|
||||
debounceClickMapSearch(customFilter);
|
||||
} else {
|
||||
const hasStartPoint =
|
||||
!!widget.startPoint && widget.metricType === USER_PATH;
|
||||
const onlyFocused = focusedSeries
|
||||
? widget.series.filter((s) => s.name === focusedSeries)
|
||||
: widget.series;
|
||||
const activeSeries = metricStore.disabledSeries.length
|
||||
? onlyFocused.filter(
|
||||
(s) => !metricStore.disabledSeries.includes(s.name),
|
||||
)
|
||||
: onlyFocused;
|
||||
const seriesJson = activeSeries.map((s) => s.toJson());
|
||||
if (hasStartPoint) {
|
||||
seriesJson[0].filter.filters.push(widget.startPoint.toJson());
|
||||
}
|
||||
if (widget.metricType === USER_PATH) {
|
||||
if (
|
||||
seriesJson[0].filter.filters[0].value[0] === '' &&
|
||||
widget.data.nodes?.length
|
||||
) {
|
||||
seriesJson[0].filter.filters[0].value = widget.data.nodes[0].name;
|
||||
} else if (
|
||||
seriesJson[0].filter.filters[0].value[0] === '' &&
|
||||
!widget.data.nodes?.length
|
||||
) {
|
||||
// no point requesting if we don't have starting point picked by api
|
||||
return;
|
||||
}
|
||||
}
|
||||
debounceRequest(widget.metricId, {
|
||||
...filter,
|
||||
series: seriesJson,
|
||||
page: metricStore.sessionsPage,
|
||||
limit: metricStore.sessionsPageSize,
|
||||
});
|
||||
const loadData = () => {
|
||||
if (widget.metricType === HEATMAP && metricStore.clickMapSearch) {
|
||||
const clickFilter = {
|
||||
value: [metricStore.clickMapSearch],
|
||||
type: 'CLICK',
|
||||
operator: 'onSelector',
|
||||
isEvent: true,
|
||||
// @ts-ignore
|
||||
filters: [],
|
||||
};
|
||||
const timeRange = {
|
||||
rangeValue: dashboardStore.drillDownPeriod.rangeValue,
|
||||
startDate: dashboardStore.drillDownPeriod.start,
|
||||
endDate: dashboardStore.drillDownPeriod.end,
|
||||
};
|
||||
const customFilter = {
|
||||
...filter,
|
||||
...timeRange,
|
||||
filters: [...sessionStore.userFilter.filters, clickFilter],
|
||||
};
|
||||
debounceClickMapSearch(customFilter);
|
||||
} else {
|
||||
const hasStartPoint =
|
||||
!!widget.startPoint && widget.metricType === USER_PATH;
|
||||
const onlyFocused = focusedSeries
|
||||
? widget.series.filter((s) => s.name === focusedSeries)
|
||||
: widget.series;
|
||||
const activeSeries = metricStore.disabledSeries.length
|
||||
? onlyFocused.filter(
|
||||
(s) => !metricStore.disabledSeries.includes(s.name),
|
||||
)
|
||||
: onlyFocused;
|
||||
const seriesJson = activeSeries.map((s) => s.toJson());
|
||||
if (hasStartPoint) {
|
||||
seriesJson[0].filter.filters.push(widget.startPoint.toJson());
|
||||
}
|
||||
if (widget.metricType === USER_PATH) {
|
||||
if (
|
||||
seriesJson[0].filter.filters[0].value[0] === '' &&
|
||||
widget.data.nodes?.length
|
||||
) {
|
||||
seriesJson[0].filter.filters[0].value = widget.data.nodes[0].name;
|
||||
} else if (
|
||||
seriesJson[0].filter.filters[0].value[0] === '' &&
|
||||
!widget.data.nodes?.length
|
||||
) {
|
||||
// no point requesting if we don't have starting point picked by api
|
||||
return;
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
metricStore.updateKey('sessionsPage', 1);
|
||||
loadData();
|
||||
}, [
|
||||
filter.startTimestamp,
|
||||
filter.endTimestamp,
|
||||
filter.filters,
|
||||
depsString,
|
||||
metricStore.clickMapSearch,
|
||||
focusedSeries,
|
||||
widget.startPoint,
|
||||
widget.data.nodes,
|
||||
metricStore.disabledSeries.length,
|
||||
]);
|
||||
useEffect(loadData, [metricStore.sessionsPage]);
|
||||
useEffect(() => {
|
||||
if (activeSeries === 'all') {
|
||||
metricStore.setFocusedSeriesName(null);
|
||||
} else {
|
||||
metricStore.setFocusedSeriesName(
|
||||
seriesOptions.find((option) => option.value === activeSeries)?.label,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}, [activeSeries]);
|
||||
useEffect(() => {
|
||||
if (focusedSeries) {
|
||||
setActiveSeries(
|
||||
seriesOptions.find((option) => option.label === focusedSeries)?.value ||
|
||||
'all',
|
||||
);
|
||||
} else {
|
||||
setActiveSeries('all');
|
||||
}
|
||||
}, [focusedSeries]);
|
||||
}
|
||||
debounceRequest(widget.metricId, {
|
||||
...filter,
|
||||
series: seriesJson,
|
||||
page: metricStore.sessionsPage,
|
||||
limit: metricStore.sessionsPageSize,
|
||||
});
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
metricStore.updateKey('sessionsPage', 1);
|
||||
loadData();
|
||||
}, [
|
||||
filter.startTimestamp,
|
||||
filter.endTimestamp,
|
||||
filter.filters,
|
||||
depsString,
|
||||
metricStore.clickMapSearch,
|
||||
focusedSeries,
|
||||
widget.startPoint,
|
||||
widget.data?.nodes,
|
||||
metricStore.disabledSeries.length,
|
||||
]);
|
||||
useEffect(loadData, [metricStore.sessionsPage]);
|
||||
useEffect(() => {
|
||||
if (activeSeries === 'all') {
|
||||
metricStore.setFocusedSeriesName(null);
|
||||
} else {
|
||||
metricStore.setFocusedSeriesName(
|
||||
seriesOptions.find((option) => option.value === activeSeries)?.label,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}, [activeSeries]);
|
||||
useEffect(() => {
|
||||
if (focusedSeries) {
|
||||
setActiveSeries(
|
||||
seriesOptions.find((option) => option.label === focusedSeries)?.value ||
|
||||
'all',
|
||||
);
|
||||
} else {
|
||||
setActiveSeries('all');
|
||||
}
|
||||
}, [focusedSeries]);
|
||||
|
||||
const clearFilters = () => {
|
||||
metricStore.updateKey('sessionsPage', 1);
|
||||
dashboardStore.resetDrillDownFilter();
|
||||
};
|
||||
const clearFilters = () => {
|
||||
metricStore.updateKey('sessionsPage', 1);
|
||||
dashboardStore.resetDrillDownFilter();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
className,
|
||||
'bg-white p-3 pb-0 rounded-xl shadow-sm border mt-3',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<h2 className="text-xl">
|
||||
{metricStore.clickMapSearch ? t('Clicks') : t('Sessions')}
|
||||
</h2>
|
||||
<div className="ml-2 color-gray-medium">
|
||||
{metricStore.clickMapLabel
|
||||
? `on "${metricStore.clickMapLabel}" `
|
||||
: null}
|
||||
{t('between')}{' '}
|
||||
<span className="font-medium color-gray-darkest">
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
className,
|
||||
'bg-white p-3 pb-0 rounded-xl shadow-sm border mt-3',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<h2 className="text-xl">
|
||||
{metricStore.clickMapSearch ? t('Clicks') : t('Sessions')}
|
||||
</h2>
|
||||
<div className="ml-2 color-gray-medium">
|
||||
{metricStore.clickMapLabel
|
||||
? `on "${metricStore.clickMapLabel}" `
|
||||
: null}
|
||||
{t('between')}{' '}
|
||||
<span className="font-medium color-gray-darkest">
|
||||
{startTime}
|
||||
</span>{' '}
|
||||
{t('and')}{' '}
|
||||
<span className="font-medium color-gray-darkest">
|
||||
{t('and')}{' '}
|
||||
<span className="font-medium color-gray-darkest">
|
||||
{endTime}
|
||||
</span>{' '}
|
||||
</div>
|
||||
{hasFilters && (
|
||||
<Tooltip title={t('Clear Drilldown')} placement="top">
|
||||
<Button type="text" size="small" onClick={clearFilters}>
|
||||
<UndoOutlined/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{hasFilters && (
|
||||
<Tooltip title={t('Clear Drilldown')} placement="top">
|
||||
<Button type="text" size="small" onClick={clearFilters}>
|
||||
<UndoOutlined />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasFilters && widget.metricType === 'table' && (
|
||||
<div className="py-2">
|
||||
<Tag
|
||||
closable
|
||||
onClose={clearFilters}
|
||||
className="truncate max-w-44 rounded-lg"
|
||||
>
|
||||
{filterText}
|
||||
</Tag>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{hasFilters && widget.metricType === 'table' && (
|
||||
<div className="py-2">
|
||||
<Tag
|
||||
closable
|
||||
onClose={clearFilters}
|
||||
className="truncate max-w-44 rounded-lg"
|
||||
>
|
||||
{filterText}
|
||||
</Tag>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{widget.metricType !== 'table' && widget.metricType !== HEATMAP && (
|
||||
<div className="flex items-center ml-6">
|
||||
<div className="flex items-center gap-4">
|
||||
{widget.metricType !== 'table' && widget.metricType !== HEATMAP && (
|
||||
<div className="flex items-center ml-6">
|
||||
<span className="mr-2 color-gray-medium">
|
||||
{t('Filter by Series')}
|
||||
</span>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: seriesDropdownItems,
|
||||
selectable: true,
|
||||
selectedKeys: [activeSeries],
|
||||
}}
|
||||
trigger={['click']}
|
||||
>
|
||||
<Button type="text" size="small">
|
||||
{seriesOptions.find((option) => option.value === activeSeries)
|
||||
?.label || t('Select Series')}
|
||||
<DownOutlined/>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: seriesDropdownItems,
|
||||
selectable: true,
|
||||
selectedKeys: [activeSeries],
|
||||
}}
|
||||
trigger={['click']}
|
||||
>
|
||||
<Button type="text" size="small">
|
||||
{seriesOptions.find((option) => option.value === activeSeries)
|
||||
?.label || t('Select Series')}
|
||||
<DownOutlined />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<Loader loading={loading}>
|
||||
<NoContent
|
||||
title={
|
||||
<div className="flex items-center justify-center flex-col">
|
||||
<AnimatedSVG name={ICONS.NO_SESSIONS} size={60}/>
|
||||
<div className="mt-4"/>
|
||||
<div className="text-center">
|
||||
{t('No relevant sessions found for the selected time period')}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
show={filteredSessions.sessions.length === 0}
|
||||
>
|
||||
{filteredSessions.sessions.map((session: any) => (
|
||||
<React.Fragment key={session.sessionId}>
|
||||
<SessionItem
|
||||
disableUser
|
||||
session={session}
|
||||
metaList={metaList}
|
||||
/>
|
||||
<div className="border-b"/>
|
||||
</React.Fragment>
|
||||
))}
|
||||
<div className="mt-3">
|
||||
<Loader loading={loading}>
|
||||
<NoContent
|
||||
title={
|
||||
<div className="flex items-center justify-center flex-col">
|
||||
<AnimatedSVG name={ICONS.NO_SESSIONS} size={60} />
|
||||
<div className="mt-4" />
|
||||
<div className="text-center">
|
||||
{t('No relevant sessions found for the selected time period')}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
show={filteredSessions.sessions.length === 0}
|
||||
>
|
||||
{filteredSessions.sessions.map((session: any) => (
|
||||
<React.Fragment key={session.sessionId}>
|
||||
<SessionItem
|
||||
disableUser
|
||||
session={session}
|
||||
metaList={metaList}
|
||||
/>
|
||||
<div className="border-b" />
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
<div
|
||||
className="flex items-center justify-between p-5"
|
||||
ref={listRef}
|
||||
>
|
||||
<div>
|
||||
{t('Showing')}{' '}
|
||||
<span className="font-medium">
|
||||
<div
|
||||
className="flex items-center justify-between p-5"
|
||||
ref={listRef}
|
||||
>
|
||||
<div>
|
||||
{t('Showing')}{' '}
|
||||
<span className="font-medium">
|
||||
{(metricStore.sessionsPage - 1) *
|
||||
metricStore.sessionsPageSize +
|
||||
1}
|
||||
metricStore.sessionsPageSize +
|
||||
1}
|
||||
</span>{' '}
|
||||
{t('to')}{' '}
|
||||
<span className="font-medium">
|
||||
{t('to')}{' '}
|
||||
<span className="font-medium">
|
||||
{(metricStore.sessionsPage - 1) *
|
||||
metricStore.sessionsPageSize +
|
||||
filteredSessions.sessions.length}
|
||||
metricStore.sessionsPageSize +
|
||||
filteredSessions.sessions.length}
|
||||
</span>{' '}
|
||||
{t('of')}{' '}
|
||||
<span className="font-medium">
|
||||
{t('of')}{' '}
|
||||
<span className="font-medium">
|
||||
{numberWithCommas(filteredSessions.total)}
|
||||
</span>{' '}
|
||||
{t('sessions.')}
|
||||
</div>
|
||||
<Pagination
|
||||
page={metricStore.sessionsPage}
|
||||
total={filteredSessions.total}
|
||||
onPageChange={(page: any) =>
|
||||
metricStore.updateKey('sessionsPage', page)
|
||||
}
|
||||
limit={metricStore.sessionsPageSize}
|
||||
debounceRequest={500}
|
||||
/>
|
||||
</div>
|
||||
</NoContent>
|
||||
</Loader>
|
||||
{t('sessions.')}
|
||||
</div>
|
||||
<Pagination
|
||||
page={metricStore.sessionsPage}
|
||||
total={filteredSessions.total}
|
||||
onPageChange={(page: any) =>
|
||||
metricStore.updateKey('sessionsPage', page)
|
||||
}
|
||||
limit={metricStore.sessionsPageSize}
|
||||
debounceRequest={500}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
</NoContent>
|
||||
</Loader>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getListSessionsBySeries = (data: any, seriesId: any) => {
|
||||
const arr = data.reduce(
|
||||
(arr: any, element: any) => {
|
||||
if (seriesId === 'all') {
|
||||
const sessionIds = arr.sessions.map((i: any) => i.sessionId);
|
||||
const sessions = element.sessions.filter(
|
||||
(i: any) => !sessionIds.includes(i.sessionId),
|
||||
);
|
||||
arr.sessions.push(...sessions);
|
||||
} else if (element.seriesId === seriesId) {
|
||||
const sessionIds = arr.sessions.map((i: any) => i.sessionId);
|
||||
const sessions = element.sessions.filter(
|
||||
(i: any) => !sessionIds.includes(i.sessionId),
|
||||
);
|
||||
const duplicates = element.sessions.length - sessions.length;
|
||||
arr.sessions.push(...sessions);
|
||||
arr.total = element.total - duplicates;
|
||||
}
|
||||
return arr;
|
||||
},
|
||||
{sessions: []},
|
||||
);
|
||||
arr.total =
|
||||
seriesId === 'all'
|
||||
? Math.max(...data.map((i: any) => i.total))
|
||||
: data.find((i: any) => i.seriesId === seriesId).total;
|
||||
return arr;
|
||||
const arr = data.reduce(
|
||||
(arr: any, element: any) => {
|
||||
if (seriesId === 'all') {
|
||||
const sessionIds = arr.sessions.map((i: any) => i.sessionId);
|
||||
const sessions = element.sessions.filter(
|
||||
(i: any) => !sessionIds.includes(i.sessionId),
|
||||
);
|
||||
arr.sessions.push(...sessions);
|
||||
} else if (element.seriesId === seriesId) {
|
||||
const sessionIds = arr.sessions.map((i: any) => i.sessionId);
|
||||
const sessions = element.sessions.filter(
|
||||
(i: any) => !sessionIds.includes(i.sessionId),
|
||||
);
|
||||
const duplicates = element.sessions.length - sessions.length;
|
||||
arr.sessions.push(...sessions);
|
||||
arr.total = element.total - duplicates;
|
||||
}
|
||||
return arr;
|
||||
},
|
||||
{ sessions: [] },
|
||||
);
|
||||
arr.total =
|
||||
seriesId === 'all'
|
||||
? Math.max(...data.map((i: any) => i.total))
|
||||
: data.find((i: any) => i.seriesId === seriesId).total;
|
||||
return arr;
|
||||
};
|
||||
|
||||
export default observer(WidgetSessions);
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import CardUserList from '../CardUserList/CardUserList';
|
|||
import WidgetSessions from '../WidgetSessions';
|
||||
import WidgetPreview from '../WidgetPreview';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PANEL_SIZES } from 'App/constants/panelSizes';
|
||||
|
||||
interface Props {
|
||||
history: any;
|
||||
|
|
@ -183,7 +184,7 @@ function WidgetView({
|
|||
: 'You have unsaved changes. Are you sure you want to leave?'
|
||||
}
|
||||
/>
|
||||
<div style={{ maxWidth: '1360px', margin: 'auto' }}>
|
||||
<div style={{ maxWidth: PANEL_SIZES.maxWidth, margin: 'auto' }}>
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import Select from 'Shared/Select';
|
|||
import usePageTitle from '@/hooks/usePageTitle';
|
||||
import FFlagItem from './FFlagItem';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PANEL_SIZES } from 'App/constants/panelSizes';
|
||||
|
||||
function FFlagsList({ siteId }: { siteId: string }) {
|
||||
const { t } = useTranslation();
|
||||
|
|
@ -24,7 +25,7 @@ function FFlagsList({ siteId }: { siteId: string }) {
|
|||
return (
|
||||
<div
|
||||
className="mb-5 w-full mx-auto bg-white rounded pb-10 pt-4 widget-wrapper"
|
||||
style={{ maxWidth: '1360px' }}
|
||||
style={{ maxWidth: PANEL_SIZES.maxWidth }}
|
||||
>
|
||||
<FFlagsListHeader siteId={siteId} />
|
||||
<div className="border-y px-3 py-2 mt-2 flex items-center w-full justify-end gap-4">
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import Multivariant from 'Components/FFlags/NewFFlag/Multivariant';
|
|||
import { toast } from 'react-toastify';
|
||||
import RolloutCondition from 'Shared/ConditionSet';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PANEL_SIZES } from "App/constants/panelSizes";
|
||||
|
||||
function FlagView({ siteId, fflagId }: { siteId: string; fflagId: string }) {
|
||||
const { t } = useTranslation();
|
||||
|
|
@ -52,7 +53,7 @@ function FlagView({ siteId, fflagId }: { siteId: string; fflagId: string }) {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="w-full mx-auto mb-4" style={{ maxWidth: '1360px' }}>
|
||||
<div className="w-full mx-auto mb-4" style={{ maxWidth: PANEL_SIZES.maxWidth }}>
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{ label: t('Feature Flags'), to: withSiteId(fflags(), siteId) },
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import Header from './Header';
|
|||
import Multivariant from './Multivariant';
|
||||
import { Payload } from './Helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PANEL_SIZES } from 'App/constants/panelSizes';
|
||||
|
||||
function NewFFlag({ siteId, fflagId }: { siteId: string; fflagId?: string }) {
|
||||
const { t } = useTranslation();
|
||||
|
|
@ -40,7 +41,7 @@ function NewFFlag({ siteId, fflagId }: { siteId: string; fflagId?: string }) {
|
|||
if (featureFlagsStore.isLoading) return <Loader loading />;
|
||||
if (!current) {
|
||||
return (
|
||||
<div className="w-full mx-auto mb-4" style={{ maxWidth: '1360px' }}>
|
||||
<div className="w-full mx-auto mb-4" style={{ maxWidth: PANEL_SIZES.maxWidth }}>
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{ label: 'Feature Flags', to: withSiteId(fflags(), siteId) },
|
||||
|
|
@ -90,7 +91,7 @@ function NewFFlag({ siteId, fflagId }: { siteId: string; fflagId?: string }) {
|
|||
|
||||
const showDescription = Boolean(current.description?.length);
|
||||
return (
|
||||
<div className="w-full mx-auto mb-4" style={{ maxWidth: '1360px' }}>
|
||||
<div className="w-full mx-auto mb-4" style={{ maxWidth: PANEL_SIZES.maxWidth }}>
|
||||
<Prompt
|
||||
when={current.hasChanged}
|
||||
message={() =>
|
||||
|
|
|
|||
201
frontend/app/components/Kai/KaiChat.tsx
Normal file
201
frontend/app/components/Kai/KaiChat.tsx
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import React from 'react';
|
||||
import { useModal } from 'App/components/Modal';
|
||||
import { MessagesSquare, Trash } from 'lucide-react';
|
||||
import ChatHeader from './components/ChatHeader';
|
||||
import { PANEL_SIZES } from 'App/constants/panelSizes';
|
||||
import ChatLog from './components/ChatLog';
|
||||
import IntroSection from './components/IntroSection';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { kaiService } from 'App/services';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useStore } from 'App/mstore';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
|
||||
function KaiChat() {
|
||||
const { userStore, projectsStore } = useStore();
|
||||
const history = useHistory();
|
||||
const [chatTitle, setTitle] = React.useState<string | null>(null);
|
||||
const userId = userStore.account.id;
|
||||
const userLetter = userStore.account.name[0].toUpperCase();
|
||||
const { activeSiteId } = projectsStore;
|
||||
const [section, setSection] = React.useState<'intro' | 'chat'>('intro');
|
||||
const [threadId, setThreadId] = React.useState<string | null>(null);
|
||||
const [initialMsg, setInitialMsg] = React.useState<string | null>(null);
|
||||
const { showModal, hideModal } = useModal();
|
||||
const location = useLocation();
|
||||
|
||||
React.useEffect(() => {
|
||||
history.replace({ search: '' });
|
||||
setThreadId(null);
|
||||
setSection('intro');
|
||||
setInitialMsg(null);
|
||||
setTitle(null);
|
||||
}, [activeSiteId, history]);
|
||||
|
||||
const openChats = () => {
|
||||
showModal(
|
||||
<ChatsModal
|
||||
projectId={activeSiteId!}
|
||||
onSelect={(threadId: string, title: string) => {
|
||||
setTitle(title);
|
||||
setThreadId(threadId);
|
||||
hideModal();
|
||||
}}
|
||||
/>,
|
||||
{ right: true, width: 300 },
|
||||
);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
activeSiteId &&
|
||||
parseInt(activeSiteId, 10) !==
|
||||
parseInt(location.pathname.split('/')[1], 10)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams(location.search);
|
||||
const threadIdFromUrl = params.get('threadId');
|
||||
if (threadIdFromUrl) {
|
||||
setThreadId(threadIdFromUrl);
|
||||
setSection('chat');
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (threadId) {
|
||||
setSection('chat');
|
||||
history.replace({ search: `?threadId=${threadId}` });
|
||||
} else {
|
||||
setTitle(null);
|
||||
history.replace({ search: '' });
|
||||
}
|
||||
}, [threadId]);
|
||||
|
||||
if (!userId || !activeSiteId) return null;
|
||||
|
||||
const canGoBack = section !== 'intro';
|
||||
const goBack = canGoBack
|
||||
? () => {
|
||||
if (section === 'chat') {
|
||||
setThreadId(null);
|
||||
setSection('intro');
|
||||
}
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const onCreate = async (firstMsg?: string) => {
|
||||
if (firstMsg) {
|
||||
setInitialMsg(firstMsg);
|
||||
}
|
||||
const newThread = await kaiService.createKaiChat(activeSiteId);
|
||||
if (newThread) {
|
||||
setThreadId(newThread.toString());
|
||||
setSection('chat');
|
||||
} else {
|
||||
toast.error("Something wen't wrong. Please try again later.");
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="w-full mx-auto" style={{ maxWidth: PANEL_SIZES.maxWidth }}>
|
||||
<div className={'w-full rounded-lg overflow-hidden border shadow'}>
|
||||
<ChatHeader
|
||||
chatTitle={chatTitle}
|
||||
openChats={openChats}
|
||||
goBack={goBack}
|
||||
/>
|
||||
<div
|
||||
className={
|
||||
'w-full bg-active-blue flex flex-col items-center justify-center py-4 relative'
|
||||
}
|
||||
style={{
|
||||
height: '70svh',
|
||||
background:
|
||||
'radial-gradient(50% 50% at 50% 50%, var(--color-glassWhite) 0%, var(--color-glassMint) 46%, var(--color-glassLavander) 100%)',
|
||||
}}
|
||||
>
|
||||
{section === 'intro' ? (
|
||||
<IntroSection onAsk={onCreate} />
|
||||
) : (
|
||||
<ChatLog
|
||||
threadId={threadId}
|
||||
projectId={activeSiteId}
|
||||
userLetter={userLetter}
|
||||
onTitleChange={setTitle}
|
||||
initialMsg={initialMsg}
|
||||
setInitialMsg={setInitialMsg}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatsModal({
|
||||
onSelect,
|
||||
projectId,
|
||||
}: {
|
||||
onSelect: (threadId: string, title: string) => void;
|
||||
projectId: string;
|
||||
}) {
|
||||
const {
|
||||
data = [],
|
||||
isPending,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ['kai', 'chats', projectId],
|
||||
queryFn: () => kaiService.getKaiChats(projectId),
|
||||
staleTime: 1000 * 60,
|
||||
});
|
||||
|
||||
const onDelete = async (id: string) => {
|
||||
try {
|
||||
await kaiService.deleteKaiChat(projectId, id);
|
||||
} catch (e) {
|
||||
toast.error("Something wen't wrong. Please try again later.");
|
||||
}
|
||||
refetch();
|
||||
};
|
||||
return (
|
||||
<div className={'h-screen w-full flex flex-col gap-2 p-4'}>
|
||||
<div className={'flex items-center font-semibold text-lg gap-2'}>
|
||||
<MessagesSquare size={16} />
|
||||
<span>Chats</span>
|
||||
</div>
|
||||
{isPending ? (
|
||||
<div className="animate-pulse text-disabled-text">Loading chats...</div>
|
||||
) : (
|
||||
<div className="flex flex-col overflow-y-auto -mx-4 px-4">
|
||||
{data.map((chat) => (
|
||||
<div
|
||||
key={chat.thread_id}
|
||||
className="flex items-center relative group min-h-8"
|
||||
>
|
||||
<div
|
||||
style={{ width: 270 - 28 - 4 }}
|
||||
className="rounded-l pl-2 h-full w-full hover:bg-active-blue flex items-center"
|
||||
>
|
||||
<div
|
||||
onClick={() => onSelect(chat.thread_id, chat.title)}
|
||||
className="cursor-pointer hover:underline truncate"
|
||||
>
|
||||
{chat.title}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
onClick={() => onDelete(chat.thread_id)}
|
||||
className="cursor-pointer opacity-0 group-hover:opacity-100 rounded-r h-full px-2 flex items-center group-hover:bg-active-blue"
|
||||
>
|
||||
<Trash size={14} className="text-disabled-text" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(KaiChat);
|
||||
80
frontend/app/components/Kai/KaiService.ts
Normal file
80
frontend/app/components/Kai/KaiService.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import AiService from '@/services/AiService';
|
||||
|
||||
export default class KaiService extends AiService {
|
||||
getKaiChats = async (
|
||||
projectId: string,
|
||||
): Promise<{ title: string; thread_id: string }[]> => {
|
||||
const r = await this.client.get(`/kai/${projectId}/chats`);
|
||||
if (!r.ok) {
|
||||
throw new Error('Failed to fetch chats');
|
||||
}
|
||||
const data = await r.json();
|
||||
return data;
|
||||
};
|
||||
|
||||
deleteKaiChat = async (
|
||||
projectId: string,
|
||||
threadId: string,
|
||||
): Promise<boolean> => {
|
||||
const r = await this.client.delete(`/kai/${projectId}/chats/${threadId}`);
|
||||
if (!r.ok) {
|
||||
throw new Error('Failed to delete chat');
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
getKaiChat = async (
|
||||
projectId: string,
|
||||
threadId: string,
|
||||
): Promise<
|
||||
{
|
||||
role: string;
|
||||
content: string;
|
||||
message_id: any;
|
||||
duration?: number;
|
||||
feedback: boolean | null;
|
||||
}[]
|
||||
> => {
|
||||
const r = await this.client.get(`/kai/${projectId}/chats/${threadId}`);
|
||||
if (!r.ok) {
|
||||
throw new Error('Failed to fetch chat');
|
||||
}
|
||||
const data = await r.json();
|
||||
return data;
|
||||
};
|
||||
|
||||
createKaiChat = async (projectId: string): Promise<number> => {
|
||||
const r = await this.client.get(`/kai/${projectId}/chat/new`);
|
||||
if (!r.ok) {
|
||||
throw new Error('Failed to create chat');
|
||||
}
|
||||
const data = await r.json();
|
||||
return data;
|
||||
};
|
||||
|
||||
feedback = async (
|
||||
positive: boolean | null,
|
||||
messageId: string,
|
||||
projectId: string,
|
||||
) => {
|
||||
const r = await this.client.post(`/kai/${projectId}/messages/feedback`, {
|
||||
message_id: messageId,
|
||||
value: positive,
|
||||
});
|
||||
if (!r.ok) {
|
||||
throw new Error('Failed to send feedback');
|
||||
}
|
||||
|
||||
return await r.json();
|
||||
};
|
||||
|
||||
cancelGeneration = async (projectId: string, threadId: string) => {
|
||||
const r = await this.client.post(`/kai/${projectId}/cancel/${threadId}`);
|
||||
if (!r.ok) {
|
||||
throw new Error('Failed to cancel generation');
|
||||
}
|
||||
|
||||
const data = await r.json();
|
||||
return data;
|
||||
};
|
||||
}
|
||||
256
frontend/app/components/Kai/KaiStore.ts
Normal file
256
frontend/app/components/Kai/KaiStore.ts
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
import { makeAutoObservable, runInAction } from 'mobx';
|
||||
import { BotChunk, ChatManager, Message } from './SocketManager';
|
||||
import { kaiService as aiService, kaiService } from 'App/services';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
class KaiStore {
|
||||
chatManager: ChatManager | null = null;
|
||||
processingStage: BotChunk | null = null;
|
||||
messages: Message[] = [];
|
||||
queryText = '';
|
||||
loadingChat = false;
|
||||
replacing = false;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this);
|
||||
}
|
||||
|
||||
get lastHumanMessage() {
|
||||
let msg = null;
|
||||
let index = null;
|
||||
for (let i = this.messages.length - 1; i >= 0; 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;
|
||||
for (let i = this.messages.length - 1; i >= 0; i--) {
|
||||
const message = this.messages[i];
|
||||
if (!message.isUser) {
|
||||
msg = message;
|
||||
index = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { msg, index };
|
||||
}
|
||||
|
||||
setQueryText = (text: string) => {
|
||||
this.queryText = text;
|
||||
};
|
||||
|
||||
setLoadingChat = (loading: boolean) => {
|
||||
this.loadingChat = loading;
|
||||
};
|
||||
|
||||
setChatManager = (chatManager: ChatManager) => {
|
||||
this.chatManager = chatManager;
|
||||
};
|
||||
|
||||
setProcessingStage = (stage: BotChunk | null) => {
|
||||
this.processingStage = stage;
|
||||
};
|
||||
|
||||
setMessages = (messages: Message[]) => {
|
||||
this.messages = messages;
|
||||
};
|
||||
|
||||
addMessage = (message: Message) => {
|
||||
this.messages.push(message);
|
||||
};
|
||||
|
||||
editMessage = (text: string) => {
|
||||
this.setQueryText(text);
|
||||
this.setReplacing(true);
|
||||
};
|
||||
|
||||
replaceAtIndex = (message: Message, index: number) => {
|
||||
const messages = [...this.messages];
|
||||
messages[index] = message;
|
||||
this.setMessages(messages);
|
||||
};
|
||||
|
||||
deleteAtIndex = (indexes: number[]) => {
|
||||
if (!indexes.length) return;
|
||||
const messages = this.messages.filter((_, i) => !indexes.includes(i));
|
||||
runInAction(() => {
|
||||
this.messages = messages;
|
||||
});
|
||||
};
|
||||
|
||||
getChat = async (projectId: string, threadId: string) => {
|
||||
this.setLoadingChat(true);
|
||||
try {
|
||||
const res = await aiService.getKaiChat(projectId, threadId);
|
||||
if (res && res.length) {
|
||||
this.setMessages(
|
||||
res.map((m) => {
|
||||
const isUser = m.role === 'human';
|
||||
return {
|
||||
text: m.content,
|
||||
isUser: isUser,
|
||||
messageId: m.message_id,
|
||||
duration: m.duration,
|
||||
feedback: m.feedback,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toast.error("Couldn't load chat history. Please try again later.");
|
||||
} finally {
|
||||
this.setLoadingChat(false);
|
||||
}
|
||||
};
|
||||
|
||||
createChatManager = (
|
||||
settings: { projectId: string; threadId: string },
|
||||
setTitle: (title: string) => void,
|
||||
initialMsg: string | null,
|
||||
) => {
|
||||
const token = kaiService.client.getJwt();
|
||||
if (!token) {
|
||||
console.error('No token found');
|
||||
return;
|
||||
}
|
||||
this.chatManager = new ChatManager({ ...settings, token });
|
||||
this.chatManager.setOnMsgHook({
|
||||
msgCallback: (msg) => {
|
||||
if ('state' in msg) {
|
||||
if (msg.state === 'running') {
|
||||
this.setProcessingStage({
|
||||
content: 'Processing your request...',
|
||||
stage: 'chart',
|
||||
messageId: Date.now().toPrecision(),
|
||||
duration: msg.start_time ? Date.now() - msg.start_time : 0,
|
||||
});
|
||||
} else {
|
||||
this.setProcessingStage(null);
|
||||
}
|
||||
} else {
|
||||
if (msg.stage === 'start') {
|
||||
this.setProcessingStage({
|
||||
...msg,
|
||||
content: 'Processing your request...',
|
||||
});
|
||||
}
|
||||
if (msg.stage === 'chart') {
|
||||
this.setProcessingStage(msg);
|
||||
}
|
||||
if (msg.stage === 'final') {
|
||||
const msgObj = {
|
||||
text: msg.content,
|
||||
isUser: false,
|
||||
messageId: msg.messageId,
|
||||
duration: msg.duration,
|
||||
feedback: null,
|
||||
};
|
||||
this.addMessage(msgObj);
|
||||
this.setProcessingStage(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
titleCallback: setTitle,
|
||||
});
|
||||
|
||||
if (initialMsg) {
|
||||
this.sendMessage(initialMsg);
|
||||
}
|
||||
};
|
||||
|
||||
setReplacing = (replacing: boolean) => {
|
||||
this.replacing = replacing;
|
||||
};
|
||||
|
||||
sendMessage = (message: string) => {
|
||||
if (this.chatManager) {
|
||||
this.chatManager.sendMessage(message, this.replacing);
|
||||
}
|
||||
if (this.replacing) {
|
||||
console.log(
|
||||
this.lastHumanMessage,
|
||||
this.lastKaiMessage,
|
||||
'replacing these two',
|
||||
);
|
||||
const deleting = [];
|
||||
if (this.lastHumanMessage.index !== null) {
|
||||
deleting.push(this.lastHumanMessage.index);
|
||||
}
|
||||
if (this.lastKaiMessage.index !== null) {
|
||||
deleting.push(this.lastKaiMessage.index);
|
||||
}
|
||||
this.deleteAtIndex(deleting);
|
||||
this.setReplacing(false);
|
||||
}
|
||||
this.addMessage({
|
||||
text: message,
|
||||
isUser: true,
|
||||
messageId: Date.now().toString(),
|
||||
feedback: null,
|
||||
duration: 0,
|
||||
});
|
||||
};
|
||||
|
||||
sendMsgFeedback = (
|
||||
feedback: string,
|
||||
messageId: string,
|
||||
projectId: string,
|
||||
) => {
|
||||
this.messages = this.messages.map((msg) => {
|
||||
if (msg.messageId === messageId) {
|
||||
return {
|
||||
...msg,
|
||||
feedback: feedback === 'like' ? true : false,
|
||||
};
|
||||
}
|
||||
return msg;
|
||||
});
|
||||
aiService
|
||||
.feedback(feedback === 'like', messageId, projectId)
|
||||
.then(() => {
|
||||
toast.success('Feedback saved.');
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast.error('Failed to send feedback. Please try again later.');
|
||||
});
|
||||
};
|
||||
|
||||
cancelGeneration = async (settings: {
|
||||
projectId: string;
|
||||
userId: string;
|
||||
threadId: string;
|
||||
}) => {
|
||||
try {
|
||||
await kaiService.cancelGeneration(settings.projectId, settings.threadId);
|
||||
this.setProcessingStage(null);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toast.error(
|
||||
'Failed to cancel the response generation, please try again later.',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
clearChat = () => {
|
||||
this.setMessages([]);
|
||||
this.setProcessingStage(null);
|
||||
this.setLoadingChat(false);
|
||||
this.setQueryText('');
|
||||
if (this.chatManager) {
|
||||
this.chatManager.disconnect();
|
||||
this.chatManager = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const kaiStore = new KaiStore();
|
||||
120
frontend/app/components/Kai/SocketManager.ts
Normal file
120
frontend/app/components/Kai/SocketManager.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import io from 'socket.io-client';
|
||||
|
||||
export class ChatManager {
|
||||
socket: ReturnType<typeof io>;
|
||||
threadId: string | null = null;
|
||||
|
||||
constructor({
|
||||
projectId,
|
||||
threadId,
|
||||
token,
|
||||
}: {
|
||||
projectId: string;
|
||||
threadId: string;
|
||||
token: string;
|
||||
}) {
|
||||
this.threadId = threadId;
|
||||
const urlObject = new URL(window.env.API_EDP || window.location.origin);
|
||||
const socket = io(`${urlObject.origin}/kai/chat`, {
|
||||
transports: ['websocket'],
|
||||
path: '/kai/chat/socket.io',
|
||||
autoConnect: true,
|
||||
reconnection: true,
|
||||
reconnectionAttempts: 5,
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionDelayMax: 5000,
|
||||
withCredentials: true,
|
||||
multiplex: true,
|
||||
query: {
|
||||
project_id: projectId,
|
||||
thread_id: threadId,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
},
|
||||
auth: {
|
||||
token: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
socket.on('connect', () => {
|
||||
console.log('Connected to server');
|
||||
});
|
||||
socket.on('disconnect', () => {
|
||||
console.log('Disconnected from server');
|
||||
});
|
||||
socket.on('error', (err) => {
|
||||
console.error('Socket error:', err);
|
||||
});
|
||||
|
||||
this.socket = socket;
|
||||
}
|
||||
|
||||
reconnect = () => {
|
||||
this.socket.connect();
|
||||
};
|
||||
|
||||
sendMessage = (message: string, isReplace = false) => {
|
||||
if (!this.socket.connected) {
|
||||
this.reconnect();
|
||||
setTimeout(() => {
|
||||
this.sendMessage(message, isReplace);
|
||||
}, 500);
|
||||
} else {
|
||||
this.socket.emit(
|
||||
'message',
|
||||
JSON.stringify({
|
||||
message,
|
||||
threadId: this.threadId,
|
||||
replace: isReplace,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
setOnMsgHook = ({
|
||||
msgCallback,
|
||||
titleCallback,
|
||||
}: {
|
||||
msgCallback: (
|
||||
msg: BotChunk | { state: string; type: 'state'; start_time?: number },
|
||||
) => void;
|
||||
titleCallback: (title: string) => void;
|
||||
}) => {
|
||||
this.socket.on('chunk', (msg: BotChunk) => {
|
||||
msgCallback(msg);
|
||||
});
|
||||
this.socket.on('title', (msg: { content: string }) => {
|
||||
titleCallback(msg.content);
|
||||
});
|
||||
this.socket.on(
|
||||
'state',
|
||||
(state: { message: 'idle' | 'running'; start_time: number }) => {
|
||||
msgCallback({
|
||||
state: state.message,
|
||||
type: 'state',
|
||||
start_time: state.start_time,
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
disconnect = () => {
|
||||
this.socket.disconnect();
|
||||
};
|
||||
}
|
||||
|
||||
export interface BotChunk {
|
||||
stage: 'start' | 'chart' | 'final' | 'title';
|
||||
content: string;
|
||||
messageId: string;
|
||||
duration?: number;
|
||||
}
|
||||
export interface Message {
|
||||
text: string;
|
||||
isUser: boolean;
|
||||
messageId: string;
|
||||
duration?: number;
|
||||
feedback: boolean | null;
|
||||
}
|
||||
|
||||
export interface SentMessage extends Message {
|
||||
replace: boolean;
|
||||
}
|
||||
54
frontend/app/components/Kai/components/ChatHeader.tsx
Normal file
54
frontend/app/components/Kai/components/ChatHeader.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import React from 'react';
|
||||
import { Icon } from 'UI';
|
||||
import { MessagesSquare, ArrowLeft } from 'lucide-react';
|
||||
|
||||
function ChatHeader({
|
||||
openChats = () => {},
|
||||
goBack,
|
||||
chatTitle,
|
||||
}: {
|
||||
goBack?: () => void;
|
||||
openChats?: () => void;
|
||||
chatTitle: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'px-4 py-2 flex items-center bg-white border-b border-b-gray-lighter'
|
||||
}
|
||||
>
|
||||
<div className={'flex-1'}>
|
||||
{goBack ? (
|
||||
<div
|
||||
className={'flex items-center gap-2 font-semibold cursor-pointer'}
|
||||
onClick={goBack}
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
<div>Back</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={'flex items-center gap-2 mx-auto max-w-[80%]'}>
|
||||
{chatTitle ? (
|
||||
<div className="font-semibold text-xl whitespace-nowrap truncate">{chatTitle}</div>
|
||||
) : (
|
||||
<>
|
||||
<Icon name={'kai_colored'} size={18} />
|
||||
<div className={'font-semibold text-xl'}>Kai</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
'font-semibold cursor-pointer flex items-center gap-2 flex-1 justify-end'
|
||||
}
|
||||
onClick={openChats}
|
||||
>
|
||||
<MessagesSquare size={14} />
|
||||
<div>Chats</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChatHeader;
|
||||
55
frontend/app/components/Kai/components/ChatInput.tsx
Normal file
55
frontend/app/components/Kai/components/ChatInput.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import React from 'react'
|
||||
import { Button, Input } from "antd";
|
||||
import { SendHorizonal, OctagonX } from "lucide-react";
|
||||
import { kaiStore } from "../KaiStore";
|
||||
import { observer } from "mobx-react-lite";
|
||||
|
||||
function ChatInput({ isLoading, onSubmit, threadId }: { isLoading?: boolean, onSubmit: (str: string) => void, threadId: string }) {
|
||||
const inputRef = React.useRef<Input>(null);
|
||||
const inputValue = kaiStore.queryText;
|
||||
const isProcessing = kaiStore.processingStage !== null
|
||||
const setInputValue = (text: string) => {
|
||||
kaiStore.setQueryText(text)
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
if (isProcessing) {
|
||||
const settings = { projectId: '2325', userId: '0', threadId, };
|
||||
void kaiStore.cancelGeneration(settings)
|
||||
} else {
|
||||
if (inputValue.length > 0) {
|
||||
onSubmit(inputValue)
|
||||
setInputValue('')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, [inputValue])
|
||||
|
||||
return (
|
||||
<Input
|
||||
onPressEnter={submit}
|
||||
ref={inputRef}
|
||||
placeholder={'Ask anything about your product and users...'}
|
||||
size={'large'}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
suffix={
|
||||
<Button
|
||||
loading={isLoading}
|
||||
onClick={submit}
|
||||
icon={isProcessing ? <OctagonX size={16} /> : <SendHorizonal size={16} />}
|
||||
type={'text'}
|
||||
size={'small'}
|
||||
shape={'circle'}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(ChatInput)
|
||||
91
frontend/app/components/Kai/components/ChatLog.tsx
Normal file
91
frontend/app/components/Kai/components/ChatLog.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import React from 'react';
|
||||
import ChatInput from './ChatInput';
|
||||
import { ChatMsg, ChatNotice } from './ChatMsg';
|
||||
import { Loader } from 'UI';
|
||||
import { kaiStore } from '../KaiStore';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
|
||||
function ChatLog({
|
||||
projectId,
|
||||
threadId,
|
||||
userLetter,
|
||||
onTitleChange,
|
||||
initialMsg,
|
||||
setInitialMsg,
|
||||
}: {
|
||||
projectId: string;
|
||||
threadId: any;
|
||||
userLetter: string;
|
||||
onTitleChange: (title: string | null) => void;
|
||||
initialMsg: string | null;
|
||||
setInitialMsg: (msg: string | null) => void;
|
||||
}) {
|
||||
const messages = kaiStore.messages;
|
||||
const loading = kaiStore.loadingChat;
|
||||
const chatRef = React.useRef<HTMLDivElement>(null);
|
||||
const processingStage = kaiStore.processingStage;
|
||||
|
||||
React.useEffect(() => {
|
||||
const settings = { projectId, threadId };
|
||||
if (threadId && !initialMsg) {
|
||||
void kaiStore.getChat(settings.projectId, threadId);
|
||||
}
|
||||
if (threadId) {
|
||||
kaiStore.createChatManager(settings, onTitleChange, initialMsg);
|
||||
}
|
||||
return () => {
|
||||
kaiStore.clearChat();
|
||||
setInitialMsg(null);
|
||||
};
|
||||
}, [threadId]);
|
||||
|
||||
const onSubmit = (text: string) => {
|
||||
kaiStore.sendMessage(text);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
chatRef.current?.scrollTo({
|
||||
top: chatRef.current.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}, [messages.length, processingStage]);
|
||||
|
||||
const lastHumanMsgInd: null | number = kaiStore.lastHumanMessage.index;
|
||||
return (
|
||||
<Loader loading={loading} className={'w-full h-full'}>
|
||||
<div
|
||||
ref={chatRef}
|
||||
className={
|
||||
'overflow-y-auto relative flex flex-col items-center justify-between w-full h-full'
|
||||
}
|
||||
>
|
||||
<div className={'flex flex-col gap-4 w-2/3 min-h-max'}>
|
||||
{messages.map((msg, index) => (
|
||||
<ChatMsg
|
||||
key={index}
|
||||
text={msg.text}
|
||||
isUser={msg.isUser}
|
||||
userName={userLetter}
|
||||
messageId={msg.messageId}
|
||||
isLast={index === lastHumanMsgInd}
|
||||
duration={msg.duration}
|
||||
feedback={msg.feedback}
|
||||
siteId={projectId}
|
||||
/>
|
||||
))}
|
||||
{processingStage ? (
|
||||
<ChatNotice
|
||||
content={processingStage.content}
|
||||
duration={processingStage.duration}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={'sticky bottom-0 pt-6 w-2/3'}>
|
||||
<ChatInput onSubmit={onSubmit} threadId={threadId} />
|
||||
</div>
|
||||
</div>
|
||||
</Loader>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(ChatLog);
|
||||
217
frontend/app/components/Kai/components/ChatMsg.tsx
Normal file
217
frontend/app/components/Kai/components/ChatMsg.tsx
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
import React from 'react';
|
||||
import { Icon, CopyButton } from 'UI';
|
||||
import cn from 'classnames';
|
||||
import Markdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import {
|
||||
Loader,
|
||||
ThumbsUp,
|
||||
ThumbsDown,
|
||||
ListRestart,
|
||||
FileDown,
|
||||
Clock,
|
||||
} from 'lucide-react';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import { kaiStore } from '../KaiStore';
|
||||
import { toast } from 'react-toastify';
|
||||
import { durationFormatted } from 'App/date';
|
||||
|
||||
export function ChatMsg({
|
||||
text,
|
||||
isUser,
|
||||
userName,
|
||||
messageId,
|
||||
isLast,
|
||||
duration,
|
||||
feedback,
|
||||
siteId,
|
||||
}: {
|
||||
text: string;
|
||||
isUser: boolean;
|
||||
messageId: string;
|
||||
userName?: string;
|
||||
isLast?: boolean;
|
||||
duration?: number;
|
||||
feedback: boolean | null;
|
||||
siteId: string;
|
||||
}) {
|
||||
const [isProcessing, setIsProcessing] = React.useState(false);
|
||||
const bodyRef = React.useRef<HTMLDivElement>(null);
|
||||
const onRetry = () => {
|
||||
kaiStore.editMessage(text);
|
||||
};
|
||||
const onFeedback = (feedback: 'like' | 'dislike', messageId: string) => {
|
||||
kaiStore.sendMsgFeedback(feedback, messageId, siteId);
|
||||
};
|
||||
|
||||
const onExport = () => {
|
||||
setIsProcessing(true);
|
||||
if (!bodyRef.current) {
|
||||
toast.error('Failed to export message');
|
||||
setIsProcessing(false);
|
||||
return;
|
||||
}
|
||||
import('jspdf')
|
||||
.then(({ jsPDF }) => {
|
||||
const doc = new jsPDF();
|
||||
doc.addImage('/assets/img/logo-img.png', 80, 3, 30, 5);
|
||||
doc.html(bodyRef.current!, {
|
||||
callback: function (doc) {
|
||||
doc.save('document.pdf');
|
||||
},
|
||||
margin: [10, 10, 10, 10],
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 190, // Target width
|
||||
windowWidth: 675, // Window width for rendering
|
||||
});
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('Error exporting message:', e);
|
||||
toast.error('Failed to export message');
|
||||
})
|
||||
.finally(() => {
|
||||
setIsProcessing(false);
|
||||
});
|
||||
};
|
||||
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'
|
||||
}
|
||||
>
|
||||
<span className={'font-semibold'}>{userName}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={
|
||||
'rounded-full bg-white 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="markdown-body" ref={bodyRef}>
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{text}</Markdown>
|
||||
</div>
|
||||
{isUser ? (
|
||||
isLast ? (
|
||||
<div
|
||||
onClick={onRetry}
|
||||
className={
|
||||
'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'
|
||||
}
|
||||
>
|
||||
<ListRestart size={16} />
|
||||
<div>Edit</div>
|
||||
</div>
|
||||
) : null
|
||||
) : (
|
||||
<div className={'flex items-center gap-2'}>
|
||||
{duration ? <MsgDuration duration={duration} /> : null}
|
||||
<div className="ml-auto" />
|
||||
<IconButton
|
||||
active={feedback === true}
|
||||
tooltip="Like this answer"
|
||||
onClick={() => onFeedback('like', messageId)}
|
||||
>
|
||||
<ThumbsUp size={16} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
active={feedback === false}
|
||||
tooltip="Dislike this answer"
|
||||
onClick={() => onFeedback('dislike', messageId)}
|
||||
>
|
||||
<ThumbsDown size={16} />
|
||||
</IconButton>
|
||||
<CopyButton
|
||||
getHtml={() => bodyRef.current?.innerHTML}
|
||||
content={text}
|
||||
isIcon
|
||||
format={'text/html'}
|
||||
/>
|
||||
<IconButton
|
||||
processing={isProcessing}
|
||||
tooltip="Export as PDF"
|
||||
onClick={onExport}
|
||||
>
|
||||
<FileDown size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IconButton({
|
||||
children,
|
||||
onClick,
|
||||
tooltip,
|
||||
processing,
|
||||
active,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
tooltip?: string;
|
||||
processing?: boolean;
|
||||
active?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip title={tooltip}>
|
||||
<Button
|
||||
onClick={onClick}
|
||||
type={active ? 'primary' : 'text'}
|
||||
icon={children}
|
||||
size="small"
|
||||
loading={processing}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatNotice({
|
||||
content,
|
||||
duration,
|
||||
}: {
|
||||
content: string;
|
||||
duration?: number;
|
||||
}) {
|
||||
const startTime = React.useRef(duration ? Date.now() - duration : Date.now());
|
||||
const [activeDuration, setDuration] = React.useState(duration ?? 0);
|
||||
|
||||
React.useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setDuration(Math.round(Date.now() - startTime.current));
|
||||
}, 250);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
return (
|
||||
<div className="flex flex-col gap-1 items-start p-2 rounded-lg bg-gray-lightest border-gray-light w-fit ">
|
||||
<div className="flex gap-2 items-start">
|
||||
<div className={'animate-spin mt-1'}>
|
||||
<Loader size={14} />
|
||||
</div>
|
||||
<div className={'animate-pulse'}>{content}</div>
|
||||
</div>
|
||||
<MsgDuration duration={activeDuration} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MsgDuration({ duration }: { duration: number }) {
|
||||
return (
|
||||
<div className="text-disabled-text text-sm flex items-center gap-1">
|
||||
<Clock size={14} />
|
||||
<span className="leading-none">{durationFormatted(duration)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
frontend/app/components/Kai/components/Ideas.tsx
Normal file
32
frontend/app/components/Kai/components/Ideas.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import React from 'react';
|
||||
import { Lightbulb, MoveRight } from 'lucide-react';
|
||||
|
||||
function Ideas({ onClick }: { onClick: (query: string) => void }) {
|
||||
return (
|
||||
<>
|
||||
<div className={'flex items-center gap-2 mb-1 text-gray-dark'}>
|
||||
<Lightbulb size={16} />
|
||||
<b>Ideas:</b>
|
||||
</div>
|
||||
<IdeaItem onClick={onClick} title={'Top user journeys'} />
|
||||
<IdeaItem onClick={onClick} title={'Where do users drop off'} />
|
||||
<IdeaItem onClick={onClick} title={'Failed network requests today'} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function IdeaItem({ title, onClick }: { title: string, onClick: (query: string) => void }) {
|
||||
return (
|
||||
<div
|
||||
onClick={() => onClick(title)}
|
||||
className={
|
||||
'flex items-center gap-2 cursor-pointer text-gray-dark hover:text-black'
|
||||
}
|
||||
>
|
||||
<MoveRight size={16} />
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Ideas;
|
||||
27
frontend/app/components/Kai/components/IntroSection.tsx
Normal file
27
frontend/app/components/Kai/components/IntroSection.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import React from 'react';
|
||||
import ChatInput from './ChatInput';
|
||||
import Ideas from './Ideas';
|
||||
|
||||
function IntroSection({ onAsk }: { onAsk: (query: string) => void }) {
|
||||
const isLoading = false;
|
||||
return (
|
||||
<>
|
||||
<div className={'text-disabled-text text-xl absolute top-4'}>
|
||||
Kai is your AI assistant, delivering smart insights in response to your
|
||||
queries.
|
||||
</div>
|
||||
<div className={'relative w-2/3'} style={{ height: 44 }}>
|
||||
{/*<GradientBorderInput placeholder={'Ask anything about your product and users...'} onButtonClick={() => null} />*/}
|
||||
<ChatInput isLoading={isLoading} onSubmit={onAsk} />
|
||||
<div className={'absolute top-full flex flex-col gap-2 mt-4'}>
|
||||
<Ideas onClick={(query) => onAsk(query)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={'text-disabled-text absolute bottom-4'}>
|
||||
OpenReplay AI can make mistakes. Verify its outputs.
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default IntroSection;
|
||||
|
|
@ -9,6 +9,7 @@ import ManageUsersTab from './components/ManageUsersTab';
|
|||
import SideMenu from './components/SideMenu';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Smartphone, AppWindow } from 'lucide-react';
|
||||
import { PANEL_SIZES } from 'App/constants/panelSizes';
|
||||
|
||||
interface Props {
|
||||
match: {
|
||||
|
|
@ -66,7 +67,7 @@ function Onboarding(props: Props) {
|
|||
<div className="w-full">
|
||||
<div
|
||||
className="bg-white w-full rounded-lg mx-auto mb-8 border"
|
||||
style={{ maxWidth: '1360px' }}
|
||||
style={{ maxWidth: PANEL_SIZES.maxWidth }}
|
||||
>
|
||||
<Switch>
|
||||
<Route exact strict path={route(OB_TABS.INSTALLING)}>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import FlagView from 'Components/FFlags/FlagView/FlagView';
|
|||
import { observer } from 'mobx-react-lite';
|
||||
import { useStore } from '@/mstore';
|
||||
import Bookmarks from 'Shared/SessionsTabOverview/components/Bookmarks/Bookmarks';
|
||||
import { PANEL_SIZES } from 'App/constants/panelSizes';
|
||||
|
||||
// @ts-ignore
|
||||
interface IProps extends RouteComponentProps {
|
||||
|
|
@ -42,12 +43,12 @@ function Overview({ match: { params } }: IProps) {
|
|||
return (
|
||||
<Switch>
|
||||
<Route exact strict path={withSiteId(sessions(), siteId)}>
|
||||
<div className="mb-5 w-full mx-auto" style={{ maxWidth: '1360px' }}>
|
||||
<div className="mb-5 w-full mx-auto" style={{ maxWidth: PANEL_SIZES.maxWidth }}>
|
||||
<SessionsTabOverview />
|
||||
</div>
|
||||
</Route>
|
||||
<Route exact strict path={withSiteId(bookmarks(), siteId)}>
|
||||
<div className="mb-5 w-full mx-auto" style={{ maxWidth: '1360px' }}>
|
||||
<div className="mb-5 w-full mx-auto" style={{ maxWidth: PANEL_SIZES.maxWidth }}>
|
||||
<Bookmarks />
|
||||
</div>
|
||||
</Route>
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ import {
|
|||
EXCEPTIONS,
|
||||
INSPECTOR,
|
||||
OVERVIEW,
|
||||
BACKENDLOGS,
|
||||
} from 'App/mstore/uiPlayerStore';
|
||||
BACKENDLOGS, LONG_TASK
|
||||
} from "App/mstore/uiPlayerStore";
|
||||
import { WebNetworkPanel } from 'Shared/DevTools/NetworkPanel';
|
||||
import Storage from 'Components/Session_/Storage';
|
||||
import { ConnectedPerformance } from 'Components/Session_/Performance';
|
||||
|
|
@ -31,6 +31,7 @@ import { PlayerContext } from 'App/components/Session/playerContext';
|
|||
import { debounce } from 'App/utils';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useStore } from 'App/mstore';
|
||||
import LongTaskPanel from "../../../shared/DevTools/LongTaskPanel/LongTaskPanel";
|
||||
import BackendLogsPanel from '../SharedComponents/BackendLogs/BackendLogsPanel';
|
||||
|
||||
interface IProps {
|
||||
|
|
@ -158,20 +159,7 @@ function Player(props: IProps) {
|
|||
onMouseDown={handleResize}
|
||||
className="w-full h-2 cursor-ns-resize absolute top-0 left-0 z-20"
|
||||
/>
|
||||
{bottomBlock === OVERVIEW && <OverviewPanel />}
|
||||
{bottomBlock === CONSOLE && <ConsolePanel />}
|
||||
{bottomBlock === NETWORK && (
|
||||
<WebNetworkPanel panelHeight={panelHeight} />
|
||||
)}
|
||||
{bottomBlock === STACKEVENTS && <WebStackEventPanel />}
|
||||
{bottomBlock === STORAGE && <Storage />}
|
||||
{bottomBlock === PROFILER && (
|
||||
<ProfilerPanel panelHeight={panelHeight} />
|
||||
)}
|
||||
{bottomBlock === PERFORMANCE && <ConnectedPerformance />}
|
||||
{bottomBlock === GRAPHQL && <GraphQL panelHeight={panelHeight} />}
|
||||
{bottomBlock === EXCEPTIONS && <Exceptions />}
|
||||
{bottomBlock === BACKENDLOGS && <BackendLogsPanel />}
|
||||
<BottomBlock block={bottomBlock} panelHeight={panelHeight} />
|
||||
</div>
|
||||
)}
|
||||
{!fullView ? (
|
||||
|
|
@ -189,4 +177,31 @@ function Player(props: IProps) {
|
|||
);
|
||||
}
|
||||
|
||||
function BottomBlock({ panelHeight, block }: { panelHeight: number; block: number }) {
|
||||
switch (block) {
|
||||
case CONSOLE:
|
||||
return <ConsolePanel />;
|
||||
case NETWORK:
|
||||
return <WebNetworkPanel panelHeight={panelHeight} />;
|
||||
case STACKEVENTS:
|
||||
return <WebStackEventPanel />;
|
||||
case STORAGE:
|
||||
return <Storage />;
|
||||
case PROFILER:
|
||||
return <ProfilerPanel panelHeight={panelHeight} />;
|
||||
case PERFORMANCE:
|
||||
return <ConnectedPerformance />;
|
||||
case GRAPHQL:
|
||||
return <GraphQL panelHeight={panelHeight} />;
|
||||
case EXCEPTIONS:
|
||||
return <Exceptions />;
|
||||
case BACKENDLOGS:
|
||||
return <BackendLogsPanel />;
|
||||
case LONG_TASK:
|
||||
return <LongTaskPanel />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default observer(Player);
|
||||
|
|
|
|||
|
|
@ -29,11 +29,12 @@ import {
|
|||
STACKEVENTS,
|
||||
STORAGE,
|
||||
BACKENDLOGS,
|
||||
} from 'App/mstore/uiPlayerStore';
|
||||
LONG_TASK
|
||||
} from "App/mstore/uiPlayerStore";
|
||||
import { Icon } from 'UI';
|
||||
import LogsButton from 'App/components/Session/Player/SharedComponents/BackendLogs/LogsButton';
|
||||
import { CodeOutlined, DashboardOutlined, ClusterOutlined } from '@ant-design/icons';
|
||||
import { ArrowDownUp, ListCollapse, Merge, Waypoints } from 'lucide-react'
|
||||
import { ArrowDownUp, ListCollapse, Merge, Waypoints, Timer } from 'lucide-react'
|
||||
|
||||
import ControlButton from './ControlButton';
|
||||
import Timeline from './Timeline';
|
||||
|
|
@ -293,7 +294,11 @@ const DevtoolsButtons = observer(
|
|||
graphql: {
|
||||
icon: <Merge size={14} strokeWidth={2} />,
|
||||
label: 'Graphql',
|
||||
}
|
||||
},
|
||||
longTask: {
|
||||
icon: <Timer size={14} strokeWidth={2} />,
|
||||
label: t('Long Tasks'),
|
||||
},
|
||||
}
|
||||
// @ts-ignore
|
||||
const getLabel = (block: string) => labels[block][showIcons ? 'icon' : 'label']
|
||||
|
|
@ -359,6 +364,14 @@ const DevtoolsButtons = observer(
|
|||
label={getLabel('performance')}
|
||||
/>
|
||||
|
||||
<ControlButton
|
||||
customKey="longTask"
|
||||
disabled={disableButtons}
|
||||
onClick={() => toggleBottomTools(LONG_TASK)}
|
||||
active={bottomBlock === LONG_TASK && !inspectorMode}
|
||||
label={getLabel('longTask')}
|
||||
/>
|
||||
|
||||
{showGraphql && (
|
||||
<ControlButton
|
||||
disabled={disableButtons}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ function SubHeader(props) {
|
|||
projectsStore,
|
||||
userStore,
|
||||
issueReportingStore,
|
||||
settingsStore
|
||||
} = useStore();
|
||||
const { t } = useTranslation();
|
||||
const { favorite } = sessionStore.current;
|
||||
|
|
@ -45,7 +46,7 @@ function SubHeader(props) {
|
|||
const currentSession = sessionStore.current;
|
||||
const projectId = projectsStore.siteId;
|
||||
const integrations = integrationsStore.issues.list;
|
||||
const { store } = React.useContext(PlayerContext);
|
||||
const { player, store } = React.useContext(PlayerContext);
|
||||
const { location: currentLocation = 'loading...' } = store.get();
|
||||
const hasIframe = localStorage.getItem(IFRAME) === 'true';
|
||||
const [hideTools, setHideTools] = React.useState(false);
|
||||
|
|
@ -127,8 +128,22 @@ function SubHeader(props) {
|
|||
});
|
||||
};
|
||||
|
||||
const showVModeBadge = store.get().vModeBadge;
|
||||
const onVMode = () => {
|
||||
settingsStore.sessionSettings.updateKey('virtualMode', true);
|
||||
player.enableVMode?.();
|
||||
location.reload();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<WarnBadge
|
||||
siteId={projectId!}
|
||||
currentLocation={currentLocation}
|
||||
version={currentSession?.trackerVersion ?? ''}
|
||||
containerStyle={{ position: 'relative', left: 0, top: 0, transform: 'none', zIndex: 10 }}
|
||||
trackerWarnStyle={{ backgroundColor: '#fffbeb' }}
|
||||
/>
|
||||
<div
|
||||
className="w-full px-4 flex items-center border-b relative"
|
||||
style={{
|
||||
|
|
@ -143,6 +158,8 @@ function SubHeader(props) {
|
|||
siteId={projectId!}
|
||||
currentLocation={currentLocation}
|
||||
version={currentSession?.trackerVersion ?? ''}
|
||||
virtualElsFailed={showVModeBadge}
|
||||
onVMode={onVMode}
|
||||
/>
|
||||
|
||||
<SessionTabs />
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React from 'react';
|
|||
import { Alert } from 'antd';
|
||||
import { Icon } from 'UI';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowUpRight, X } from 'lucide-react';
|
||||
|
||||
const localhostWarn = (project: string) => `${project}_localhost_warn`;
|
||||
|
||||
|
|
@ -29,58 +30,113 @@ function compareVersions(
|
|||
return VersionComparison.Same;
|
||||
}
|
||||
|
||||
// New optional override props added in WarnBadgeExtraProps
|
||||
interface WarnBadgeExtraProps {
|
||||
containerStyle?: React.CSSProperties;
|
||||
containerClassName?: string;
|
||||
localhostWarnStyle?: React.CSSProperties;
|
||||
localhostWarnClassName?: string;
|
||||
trackerWarnStyle?: React.CSSProperties;
|
||||
trackerWarnClassName?: string;
|
||||
}
|
||||
|
||||
type Warns = [
|
||||
localhostWarn: boolean,
|
||||
trackerWarn: boolean,
|
||||
virtualElsFailWarn: boolean,
|
||||
];
|
||||
|
||||
const WarnBadge = React.memo(
|
||||
({
|
||||
currentLocation,
|
||||
version,
|
||||
siteId,
|
||||
containerStyle,
|
||||
containerClassName,
|
||||
localhostWarnStyle,
|
||||
localhostWarnClassName,
|
||||
trackerWarnStyle,
|
||||
trackerWarnClassName,
|
||||
virtualElsFailed,
|
||||
onVMode,
|
||||
}: {
|
||||
currentLocation: string;
|
||||
version: string;
|
||||
siteId: string;
|
||||
}) => {
|
||||
virtualElsFailed: boolean;
|
||||
onVMode: () => void;
|
||||
} & WarnBadgeExtraProps) => {
|
||||
const { t } = useTranslation();
|
||||
const localhostWarnSiteKey = localhostWarn(siteId);
|
||||
const defaultLocalhostWarn =
|
||||
localStorage.getItem(localhostWarnSiteKey) !== '1';
|
||||
const localhostWarnActive =
|
||||
const localhostWarnActive = Boolean(
|
||||
currentLocation &&
|
||||
defaultLocalhostWarn &&
|
||||
/(localhost)|(127.0.0.1)|(0.0.0.0)/.test(currentLocation);
|
||||
defaultLocalhostWarn &&
|
||||
/(localhost)|(127.0.0.1)|(0.0.0.0)/.test(currentLocation),
|
||||
);
|
||||
const trackerVersion = window.env.TRACKER_VERSION ?? undefined;
|
||||
const trackerVerDiff = compareVersions(version, trackerVersion);
|
||||
const trackerWarnActive = trackerVerDiff !== VersionComparison.Same;
|
||||
|
||||
const [showLocalhostWarn, setLocalhostWarn] =
|
||||
React.useState(localhostWarnActive);
|
||||
const [showTrackerWarn, setTrackerWarn] = React.useState(trackerWarnActive);
|
||||
const [warnings, setWarnings] = React.useState<Warns>([
|
||||
localhostWarnActive,
|
||||
trackerWarnActive,
|
||||
virtualElsFailed,
|
||||
]);
|
||||
|
||||
const closeWarning = (type: 1 | 2) => {
|
||||
React.useEffect(() => {
|
||||
setWarnings([localhostWarnActive, trackerWarnActive, virtualElsFailed]);
|
||||
}, [localhostWarnActive, trackerWarnActive, virtualElsFailed]);
|
||||
|
||||
const closeWarning = (type: 0 | 1 | 2) => {
|
||||
if (type === 1) {
|
||||
localStorage.setItem(localhostWarnSiteKey, '1');
|
||||
setLocalhostWarn(false);
|
||||
}
|
||||
if (type === 2) {
|
||||
setTrackerWarn(false);
|
||||
}
|
||||
setWarnings((prev: Warns) => {
|
||||
const newWarnings = [...prev];
|
||||
newWarnings[type] = false;
|
||||
return newWarnings as Warns;
|
||||
});
|
||||
};
|
||||
|
||||
if (!showLocalhostWarn && !showTrackerWarn) return null;
|
||||
if (!warnings.some((el) => el === true)) return null;
|
||||
|
||||
// Default container styles and classes
|
||||
const defaultContainerStyle: React.CSSProperties = {
|
||||
zIndex: 999,
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
bottom: '0',
|
||||
transform: 'translate(-50%, 80%)',
|
||||
fontWeight: 500,
|
||||
};
|
||||
const defaultContainerClass = 'flex flex-col gap-2';
|
||||
const defaultWarnClass =
|
||||
'px-3 py-.5 border border-gray-lighter shadow-sm rounded bg-active-blue flex items-center justify-between';
|
||||
|
||||
// Merge defaults with any overrides
|
||||
const mergedContainerStyle = {
|
||||
...defaultContainerStyle,
|
||||
...containerStyle,
|
||||
};
|
||||
const mergedContainerClassName = containerClassName
|
||||
? defaultContainerClass + ' ' + containerClassName
|
||||
: defaultContainerClass;
|
||||
const mergedLocalhostWarnClassName = localhostWarnClassName
|
||||
? defaultWarnClass + ' ' + localhostWarnClassName
|
||||
: defaultWarnClass;
|
||||
const mergedTrackerWarnClassName = trackerWarnClassName
|
||||
? defaultWarnClass + ' ' + trackerWarnClassName
|
||||
: defaultWarnClass;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-2"
|
||||
style={{
|
||||
zIndex: 999,
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
bottom: '0',
|
||||
transform: 'translate(-50%, 80%)',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{showLocalhostWarn ? (
|
||||
<div className="px-3 py-1 border border-gray-lighter drop-shadow-md rounded bg-active-blue flex items-center justify-between">
|
||||
<div className={mergedContainerClassName} style={mergedContainerStyle}>
|
||||
{warnings[0] ? (
|
||||
<div
|
||||
className={mergedLocalhostWarnClassName}
|
||||
style={localhostWarnStyle}
|
||||
>
|
||||
<div>
|
||||
<span>{t('Some assets may load incorrectly on localhost.')}</span>
|
||||
<a
|
||||
|
|
@ -101,35 +157,80 @@ const WarnBadge = React.memo(
|
|||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{showTrackerWarn ? (
|
||||
<div className="px-3 py-1 border border-gray-lighter drop-shadow-md rounded bg-active-blue flex items-center justify-between">
|
||||
<div>
|
||||
<div>
|
||||
{t('Tracker version')} ({version})
|
||||
{warnings[1] ? (
|
||||
<div className={mergedTrackerWarnClassName} style={trackerWarnStyle}>
|
||||
<div className="flex gap-x-2 flex-wrap">
|
||||
<div className="font-normal">
|
||||
{t('Tracker version')}{' '}
|
||||
<span className="mx-1 font-semibold">{version}</span>
|
||||
{t('for this recording is')}{' '}
|
||||
{trackerVerDiff === VersionComparison.Lower
|
||||
? 'lower '
|
||||
: 'ahead of '}
|
||||
{t('the current')} ({trackerVersion}) {t('version')}.
|
||||
{t('the current')}
|
||||
<span className="mx-1 font-semibold">{trackerVersion}</span>
|
||||
{t('version')}.
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex gap-1 items-center font-normal">
|
||||
<span>{t('Some recording might display incorrectly.')}</span>
|
||||
<a
|
||||
href="https://docs.openreplay.com/en/deployment/upgrade/#tracker-compatibility"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="link ml-1"
|
||||
className="link ml-1 flex gap-1 items-center"
|
||||
>
|
||||
{t('Learn More')}
|
||||
{t('Learn More')} <ArrowUpRight size={12} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="py-1 ml-3 cursor-pointer"
|
||||
onClick={() => closeWarning(1)}
|
||||
>
|
||||
<Icon name="close" size={16} color="black" />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{warnings[2] ? (
|
||||
<div className="px-3 py-1 border border-gray-lighter drop-shadow-md rounded bg-active-blue flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<div>
|
||||
{t(
|
||||
'If you have issues displaying custom HTML elements (i.e when using LWC), consider turning on Virtual Mode.',
|
||||
)}
|
||||
</div>
|
||||
<div className="link" onClick={onVMode}>
|
||||
{t('Enable')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="py-1 ml-3 cursor-pointer"
|
||||
onClick={() => closeWarning(1)}
|
||||
>
|
||||
<X size={18} strokeWidth={1.5} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{warnings[2] ? (
|
||||
<div className="px-3 py-1 border border-gray-lighter drop-shadow-md rounded bg-active-blue flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<div>
|
||||
{t(
|
||||
'If you have issues displaying custom HTML elements (i.e when using LWC), consider turning on Virtual Mode.',
|
||||
)}
|
||||
</div>
|
||||
<div className="link" onClick={onVMode}>
|
||||
{t('Enable')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="py-1 ml-3 cursor-pointer"
|
||||
onClick={() => closeWarning(2)}
|
||||
>
|
||||
<Icon name="close" size={16} color="black" />
|
||||
<X size={18} strokeWidth={1.5} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import { toast } from 'react-toastify';
|
|||
import StepsModal from './StepsModal';
|
||||
import SidePanel from './SidePanel';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { PANEL_SIZES } from 'App/constants/panelSizes';
|
||||
const menuItems = [
|
||||
{
|
||||
key: '1',
|
||||
|
|
@ -160,7 +160,7 @@ function TestEdit() {
|
|||
const isStartingPointValid = isValidUrl(uxtestingStore.instance.startingPath);
|
||||
|
||||
return (
|
||||
<div className="w-full mx-auto" style={{ maxWidth: '1360px' }}>
|
||||
<div className="w-full mx-auto" style={{ maxWidth: PANEL_SIZES.maxWidth }}>
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ import { toast } from 'react-toastify';
|
|||
import ResponsesOverview from './ResponsesOverview';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TFunction } from 'i18next';
|
||||
import { PANEL_SIZES } from 'App/constants/panelSizes';
|
||||
|
||||
function StatusItem({
|
||||
iconName,
|
||||
|
|
@ -155,7 +156,7 @@ function TestOverview() {
|
|||
return (
|
||||
<div
|
||||
className="w-full mx-auto"
|
||||
style={{ maxWidth: '1360px' }}
|
||||
style={{ maxWidth: PANEL_SIZES.maxWidth }}
|
||||
id="pdf-anchor"
|
||||
>
|
||||
<Breadcrumb
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
} from 'App/routes';
|
||||
import withPageTitle from 'HOCs/withPageTitle';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PANEL_SIZES } from 'App/constants/panelSizes';
|
||||
|
||||
const { Search } = Input;
|
||||
|
||||
|
|
@ -76,7 +77,7 @@ function TestsTable() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="w-full mx-auto" style={{ maxWidth: '1360px' }}>
|
||||
<div className="w-full mx-auto" style={{ maxWidth: PANEL_SIZES.maxWidth }}>
|
||||
<Modal
|
||||
title={t('Create Usability Test')}
|
||||
open={isModalVisible}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import React, { useState } from 'react';
|
|||
import cn from 'classnames';
|
||||
import { Icon } from 'UI';
|
||||
import JumpButton from 'Shared/DevTools/JumpButton';
|
||||
import { Tag } from 'antd';
|
||||
import TabTag from '../TabTag';
|
||||
|
||||
interface Props {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,11 @@ function JumpButton(props: Props) {
|
|||
const { tooltip } = props;
|
||||
return (
|
||||
<div className="absolute right-2 top-0 bottom-0 my-auto flex items-center">
|
||||
{props.time ? (
|
||||
<div className="block mr-2 text-sm">
|
||||
{shortDurationFromMs(props.time)}
|
||||
</div>
|
||||
) : null}
|
||||
<Tooltip title={tooltip} disabled={!tooltip}>
|
||||
<Button
|
||||
type="default"
|
||||
|
|
@ -27,11 +32,6 @@ function JumpButton(props: Props) {
|
|||
>
|
||||
JUMP
|
||||
</Button>
|
||||
{props.time ? (
|
||||
<div className="block group-hover:hidden mr-2 text-sm">
|
||||
{shortDurationFromMs(props.time)}
|
||||
</div>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,265 @@
|
|||
import React from 'react';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Input } from 'antd';
|
||||
import { VList, VListHandle } from 'virtua';
|
||||
import { PlayerContext } from 'App/components/Session/playerContext';
|
||||
import JumpButton from '../JumpButton';
|
||||
import { useRegExListFilterMemo } from '../useListFilter';
|
||||
import BottomBlock from '../BottomBlock';
|
||||
import { NoContent, Icon } from 'UI';
|
||||
import { InfoCircleOutlined } from '@ant-design/icons';
|
||||
import { Segmented, Select, Tag } from 'antd';
|
||||
import { LongAnimationTask } from './type';
|
||||
import Script from './Script';
|
||||
import TaskTimeline from './TaskTimeline';
|
||||
import { Hourglass } from 'lucide-react';
|
||||
|
||||
interface Row extends LongAnimationTask {
|
||||
time: number;
|
||||
}
|
||||
|
||||
const TABS = {
|
||||
all: 'all',
|
||||
blocking: 'blocking',
|
||||
};
|
||||
|
||||
const SORT_BY = {
|
||||
timeAsc: 'timeAsc',
|
||||
blocking: 'blockingDesc',
|
||||
duration: 'durationDesc',
|
||||
};
|
||||
|
||||
function LongTaskPanel() {
|
||||
const { t } = useTranslation();
|
||||
const [tab, setTab] = React.useState(TABS.all);
|
||||
const [sortBy, setSortBy] = React.useState(SORT_BY.timeAsc);
|
||||
const _list = React.useRef<VListHandle>(null);
|
||||
const { player, store } = React.useContext(PlayerContext);
|
||||
const [searchValue, setSearchValue] = React.useState('');
|
||||
|
||||
const { currentTab, tabStates } = store.get();
|
||||
const longTasks = tabStates[currentTab]?.longTaskList || [];
|
||||
|
||||
const filteredList = useRegExListFilterMemo(
|
||||
longTasks,
|
||||
(task: LongAnimationTask) => [
|
||||
task.name,
|
||||
task.scripts.map((script) => script.name).join(','),
|
||||
task.scripts.map((script) => script.sourceURL).join(','),
|
||||
],
|
||||
searchValue,
|
||||
);
|
||||
|
||||
const onFilterChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
setSearchValue(value);
|
||||
};
|
||||
|
||||
const onRowClick = (time: number) => {
|
||||
player.jump(time);
|
||||
};
|
||||
|
||||
const rows: Row[] = React.useMemo(() => {
|
||||
let rowMap = filteredList.map((task) => ({
|
||||
...task,
|
||||
time: task.time ?? task.startTime,
|
||||
}));
|
||||
if (tab === 'blocking') {
|
||||
rowMap = rowMap.filter((task) => task.blockingDuration > 0);
|
||||
}
|
||||
switch (sortBy) {
|
||||
case SORT_BY.blocking:
|
||||
rowMap = rowMap.sort((a, b) => b.blockingDuration - a.blockingDuration);
|
||||
break;
|
||||
case SORT_BY.duration:
|
||||
rowMap = rowMap.sort((a, b) => b.duration - a.duration);
|
||||
break;
|
||||
default:
|
||||
rowMap = rowMap.sort((a, b) => a.time - b.time);
|
||||
}
|
||||
return rowMap;
|
||||
}, [filteredList.length, tab, sortBy]);
|
||||
|
||||
const blockingTasks = React.useMemo(() => {
|
||||
let blockingAmount = 0;
|
||||
for (const task of longTasks) {
|
||||
if (task.blockingDuration > 0) {
|
||||
blockingAmount++;
|
||||
}
|
||||
}
|
||||
return blockingAmount;
|
||||
}, [longTasks.length]);
|
||||
|
||||
return (
|
||||
<BottomBlock style={{ height: '100%' }}>
|
||||
<BottomBlock.Header>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold color-gray-medium mr-4">
|
||||
{t('Long Tasks')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Segmented
|
||||
size={'small'}
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
options={[
|
||||
{ label: t('All'), value: 'all' },
|
||||
{
|
||||
label: (
|
||||
<div>
|
||||
{t('Blocking')} ({blockingTasks})
|
||||
</div>
|
||||
),
|
||||
value: 'blocking',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
size="small"
|
||||
className="rounded-lg"
|
||||
value={sortBy}
|
||||
onChange={setSortBy}
|
||||
popupMatchSelectWidth={150}
|
||||
dropdownStyle={{ minWidth: '150px' }}
|
||||
options={[
|
||||
{ label: t('Default Order'), value: 'timeAsc' },
|
||||
{ label: t('Blocking Duration'), value: 'blockingDesc' },
|
||||
{ label: t('Task Duration'), value: 'durationDesc' },
|
||||
]}
|
||||
/>
|
||||
<Input.Search
|
||||
className="rounded-lg"
|
||||
placeholder={t('Filter by name or source URL')}
|
||||
name="filter"
|
||||
onChange={onFilterChange}
|
||||
value={searchValue}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</BottomBlock.Header>
|
||||
<BottomBlock.Content>
|
||||
<NoContent
|
||||
title={
|
||||
<div className="capitalize flex items-center gap-2">
|
||||
<InfoCircleOutlined size={18} />
|
||||
{t('No Data')}
|
||||
</div>
|
||||
}
|
||||
size="small"
|
||||
show={filteredList.length === 0}
|
||||
>
|
||||
<VList ref={_list} itemSize={25}>
|
||||
{rows.map((task) => (
|
||||
<LongTaskRow key={task.time} task={task} onJump={onRowClick} />
|
||||
))}
|
||||
</VList>
|
||||
</NoContent>
|
||||
</BottomBlock.Content>
|
||||
</BottomBlock>
|
||||
);
|
||||
}
|
||||
|
||||
function LongTaskRow({
|
||||
task,
|
||||
onJump,
|
||||
}: {
|
||||
task: Row;
|
||||
onJump: (time: number) => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'relative border-b border-neutral-950/5 group hover:bg-active-blue py-1 px-4 pe-8'
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col w-full">
|
||||
<TaskTitle expanded={expanded} entry={task} toggleExpand={() => setExpanded(!expanded)} />
|
||||
{expanded ? (
|
||||
<>
|
||||
<TaskTimeline task={task} />
|
||||
<div className={'flex items-center gap-1 mb-2'}>
|
||||
<div className={'text-neutral-900 font-medium'}>
|
||||
First UI event timestamp:
|
||||
</div>
|
||||
<div className="text-neutral-600 font-mono block">
|
||||
{Math.round(task.firstUIEventTimestamp)} ms
|
||||
</div>
|
||||
</div>
|
||||
<div className={'text-neutral-900 font-medium'}>Scripts:</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{task.scripts.map((script, index) => (
|
||||
<Script script={script} key={index} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<JumpButton time={task.time} onClick={() => onJump(task.time)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskTitle({
|
||||
entry,
|
||||
toggleExpand,
|
||||
expanded,
|
||||
}: {
|
||||
entry: {
|
||||
name: string;
|
||||
duration: number;
|
||||
blockingDuration?: number;
|
||||
scripts: LongAnimationTask['scripts'];
|
||||
};
|
||||
expanded: boolean;
|
||||
toggleExpand: () => void;
|
||||
}) {
|
||||
const isBlocking =
|
||||
entry.blockingDuration !== undefined && entry.blockingDuration > 0;
|
||||
|
||||
const scriptTitles = entry.scripts.map((script) =>
|
||||
script.invokerType ? script.invokerType : script.name,
|
||||
);
|
||||
const { title, plusMore } = getFirstTwoScripts(scriptTitles);
|
||||
return (
|
||||
<div className={'flex items-center gap-1 text-sm cursor-pointer'} onClick={toggleExpand}>
|
||||
<Icon
|
||||
name={expanded ? 'caret-down-fill' : 'caret-right-fill'}
|
||||
/>
|
||||
<span className="font-mono font-bold">{title}</span>
|
||||
<Tag color="default" bordered={false}>
|
||||
{plusMore}
|
||||
</Tag>
|
||||
<span className={'text-neutral-600 font-mono'}>
|
||||
{Math.round(entry.duration)} ms
|
||||
</span>
|
||||
{isBlocking ? (
|
||||
<Tag
|
||||
bordered={false}
|
||||
color="red"
|
||||
className="font-mono rounded-lg text-xs flex gap-1 items-center text-red-600"
|
||||
>
|
||||
<Hourglass size={11} /> {Math.round(entry.blockingDuration!)} ms
|
||||
blocking
|
||||
</Tag>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getFirstTwoScripts(titles: string[]) {
|
||||
if (titles.length === 0) {
|
||||
return { title: 'Long Animation Task', plusMore: null };
|
||||
}
|
||||
const additional = titles.length - 2;
|
||||
const additionalStr = additional > 0 ? `+ ${additional} more` : null;
|
||||
return {
|
||||
title: `${titles[0]}${titles[1] ? `, ${titles[1]}` : ''}`,
|
||||
plusMore: additionalStr,
|
||||
};
|
||||
}
|
||||
|
||||
export default observer(LongTaskPanel);
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import React from 'react';
|
||||
import { LongAnimationTask } from './type';
|
||||
import { Tag } from 'antd';
|
||||
import { Code } from 'lucide-react';
|
||||
|
||||
function getAddress(script: LongAnimationTask['scripts'][number]) {
|
||||
return `${script.sourceURL}${script.sourceFunctionName ? ':' + script.sourceFunctionName : ''}${script.sourceCharPosition && script.sourceCharPosition >= 0 ? ':' + script.sourceCharPosition : ''}`;
|
||||
}
|
||||
function ScriptTitle({
|
||||
script,
|
||||
}: {
|
||||
script: LongAnimationTask['scripts'][number];
|
||||
}) {
|
||||
return script.invokerType ? (
|
||||
<span>{script.invokerType}</span>
|
||||
) : (
|
||||
<span>{script.name}</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ScriptInfo({
|
||||
script,
|
||||
}: {
|
||||
script: LongAnimationTask['scripts'][number];
|
||||
}) {
|
||||
const hasInvoker = script.invoker !== script.sourceURL;
|
||||
return (
|
||||
<div className={'border-l border-l-gray-light pl-1'}>
|
||||
{hasInvoker ? (
|
||||
<InfoEntry title={'invoker:'} value={script.invoker} />
|
||||
) : null}
|
||||
<InfoEntry title={'address:'} value={getAddress(script)} />
|
||||
<InfoEntry
|
||||
title={'script execution:'}
|
||||
value={`${Math.round(script.duration)} ms`}
|
||||
/>
|
||||
<InfoEntry
|
||||
title={'pause duration:'}
|
||||
value={`${Math.round(script.pauseDuration)} ms`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoEntry({
|
||||
title,
|
||||
value,
|
||||
}: {
|
||||
title: string;
|
||||
value: string | number;
|
||||
}) {
|
||||
return (
|
||||
<div className={'flex items-center gap-1 text-sm'}>
|
||||
<div className={'text-disabled-text'}>{title}</div>
|
||||
<div className='font-mono text-neutral-600'>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Script({ script }: { script: LongAnimationTask['scripts'][number] }) {
|
||||
return (
|
||||
<div className="flex flex-col mb-4">
|
||||
<Tag className='w-fit font-mono text-sm font-bold flex gap-1 items-center rounded-lg'>
|
||||
<Code size={12} />
|
||||
<ScriptTitle script={script} />
|
||||
</Tag>
|
||||
<ScriptInfo script={script} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Script;
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import React from 'react'
|
||||
import { Tooltip } from 'antd'
|
||||
import { LongAnimationTask } from "./type";
|
||||
import cn from "classnames";
|
||||
|
||||
const getSeverityClass = (duration: number) => {
|
||||
if (duration > 200) return 'bg-[#CC0000]';
|
||||
if (duration > 100) return 'bg-[#EFB100]';
|
||||
return 'bg-[#66a299]';
|
||||
};
|
||||
|
||||
function TaskTimeline({ task }: { task: LongAnimationTask }) {
|
||||
const totalDuration = task.duration;
|
||||
const scriptDuration = task.scripts.reduce((sum, script) => sum + script.duration, 0);
|
||||
const layoutDuration = task.scripts.reduce(
|
||||
(sum, script) => sum + (script.forcedStyleAndLayoutDuration || 0),
|
||||
0
|
||||
);
|
||||
const idleDuration = totalDuration - scriptDuration - layoutDuration;
|
||||
|
||||
const scriptWidth = (scriptDuration / totalDuration) * 100;
|
||||
const layoutWidth = (layoutDuration / totalDuration) * 100;
|
||||
const idleWidth = (idleDuration / totalDuration) * 100;
|
||||
|
||||
return (
|
||||
<div className="w-full mb-2 mt-1">
|
||||
<div className="text-gray-dark mb-1">Timeline:</div>
|
||||
<div className="flex h-2 w-full rounded overflow-hidden">
|
||||
{scriptDuration > 0 && (
|
||||
<TimelineSegment
|
||||
classes={`${getSeverityClass(scriptDuration)} h-full`}
|
||||
name={`Script: ${Math.round(scriptDuration)}ms`}
|
||||
width={scriptWidth}
|
||||
/>
|
||||
)}
|
||||
{idleDuration > 0 && (
|
||||
<TimelineSegment
|
||||
classes="bg-gray-light h-full bg-[repeating-linear-gradient(45deg,#ccc_0px,#ccc_5px,#f2f2f2_5px,#f2f2f2_10px)]"
|
||||
width={idleWidth}
|
||||
name={`Idle: ${Math.round(idleDuration)}ms`}
|
||||
/>
|
||||
)}
|
||||
{layoutDuration > 0 && (
|
||||
<TimelineSegment
|
||||
classes="bg-[#8200db] h-full"
|
||||
width={layoutWidth}
|
||||
name={`Layout & Style: ${Math.round(layoutDuration)}ms`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>start: {Math.round(task.startTime)}ms</span>
|
||||
<span>finish: {Math.round(task.startTime + task.duration)}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function TimelineSegment({
|
||||
name,
|
||||
classes,
|
||||
width,
|
||||
}: {
|
||||
name: string;
|
||||
width: number;
|
||||
classes: string;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip title={name}>
|
||||
<div
|
||||
style={{ width: `${width}%` }}
|
||||
className={cn(classes)}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default TaskTimeline;
|
||||
268
frontend/app/components/shared/DevTools/LongTaskPanel/__mock.ts
Normal file
268
frontend/app/components/shared/DevTools/LongTaskPanel/__mock.ts
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
export const mockData = [
|
||||
{
|
||||
name: 'long-animation-frame',
|
||||
entryType: 'long-animation-frame',
|
||||
startTime: 3.5,
|
||||
duration: 74.5,
|
||||
renderStart: 77.79999923706055,
|
||||
styleAndLayoutStart: 77.89999961853027,
|
||||
firstUIEventTimestamp: 0,
|
||||
blockingDuration: 0,
|
||||
scripts: [
|
||||
{
|
||||
name: 'script',
|
||||
entryType: 'script',
|
||||
startTime: 31,
|
||||
duration: 5,
|
||||
invoker: 'http://localhost:3333/2325/session/3249172234241386834',
|
||||
invokerType: 'classic-script',
|
||||
windowAttribution: 'self',
|
||||
executionStart: 35,
|
||||
forcedStyleAndLayoutDuration: 0,
|
||||
pauseDuration: 0,
|
||||
sourceURL: 'http://localhost:3333/2325/session/3249172234241386834',
|
||||
sourceFunctionName: '',
|
||||
sourceCharPosition: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'long-animation-frame',
|
||||
entryType: 'long-animation-frame',
|
||||
startTime: 615.8999996185303,
|
||||
duration: 464.8999996185303,
|
||||
renderStart: 1080.6999998092651,
|
||||
styleAndLayoutStart: 1080.7999992370605,
|
||||
firstUIEventTimestamp: 0,
|
||||
blockingDuration: 414.361,
|
||||
scripts: [
|
||||
{
|
||||
name: 'script',
|
||||
entryType: 'script',
|
||||
startTime: 616.0999994277954,
|
||||
duration: 234,
|
||||
invoker: 'http://localhost:3333/app-3a809cc.js',
|
||||
invokerType: 'classic-script',
|
||||
windowAttribution: 'self',
|
||||
executionStart: 849.8999996185303,
|
||||
forcedStyleAndLayoutDuration: 0,
|
||||
pauseDuration: 0,
|
||||
sourceURL: 'http://localhost:3333/app-3a809cc.js',
|
||||
sourceFunctionName: '',
|
||||
sourceCharPosition: 0,
|
||||
},
|
||||
{
|
||||
name: 'script',
|
||||
entryType: 'script',
|
||||
startTime: 850.8999996185303,
|
||||
duration: 219,
|
||||
invoker: 'http://localhost:3333/app-22de34a.js',
|
||||
invokerType: 'classic-script',
|
||||
windowAttribution: 'self',
|
||||
executionStart: 930.5999994277954,
|
||||
forcedStyleAndLayoutDuration: 0,
|
||||
pauseDuration: 0,
|
||||
sourceURL: 'http://localhost:3333/app-22de34a.js',
|
||||
sourceFunctionName: '',
|
||||
sourceCharPosition: 0,
|
||||
},
|
||||
{
|
||||
name: 'script',
|
||||
entryType: 'script',
|
||||
startTime: 1070.5999994277954,
|
||||
duration: 9,
|
||||
invoker: '#document.onDOMContentLoaded',
|
||||
invokerType: 'event-listener',
|
||||
windowAttribution: 'self',
|
||||
executionStart: 1070.5999994277954,
|
||||
forcedStyleAndLayoutDuration: 0,
|
||||
pauseDuration: 0,
|
||||
sourceURL: 'http://localhost:3333/app-22de34a.js',
|
||||
sourceFunctionName: '',
|
||||
sourceCharPosition: 2298086,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'long-animation-frame',
|
||||
entryType: 'long-animation-frame',
|
||||
startTime: 1081.3999996185303,
|
||||
duration: 55.69999980926514,
|
||||
renderStart: 1136.5999994277954,
|
||||
styleAndLayoutStart: 1136.6999998092651,
|
||||
firstUIEventTimestamp: 0,
|
||||
blockingDuration: 0,
|
||||
scripts: [
|
||||
{
|
||||
name: 'script',
|
||||
entryType: 'script',
|
||||
startTime: 1081.3999996185303,
|
||||
duration: 45,
|
||||
invoker: 'MessagePort.onmessage',
|
||||
invokerType: 'event-listener',
|
||||
windowAttribution: 'self',
|
||||
executionStart: 1081.3999996185303,
|
||||
forcedStyleAndLayoutDuration: 0,
|
||||
pauseDuration: 0,
|
||||
sourceURL: 'http://localhost:3333/app-3a809cc.js',
|
||||
sourceFunctionName: 'performWorkUntilDeadline',
|
||||
sourceCharPosition: 8985606,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'long-animation-frame',
|
||||
entryType: 'long-animation-frame',
|
||||
startTime: 1495.7999992370605,
|
||||
duration: 56.40000057220459,
|
||||
renderStart: 1552.0999994277954,
|
||||
styleAndLayoutStart: 1552.0999994277954,
|
||||
firstUIEventTimestamp: 0,
|
||||
blockingDuration: 0,
|
||||
scripts: [
|
||||
{
|
||||
name: 'script',
|
||||
entryType: 'script',
|
||||
startTime: 1495.8999996185303,
|
||||
duration: 18,
|
||||
invoker:
|
||||
'http://localhost:3333/vendors-node_modules_store_ant-design-icons-virtual-42686020c5_package_es_icons_SyncOutlined_-1757ec.app-446e3c1.js',
|
||||
invokerType: 'classic-script',
|
||||
windowAttribution: 'self',
|
||||
executionStart: 1514.5999994277954,
|
||||
forcedStyleAndLayoutDuration: 0,
|
||||
pauseDuration: 0,
|
||||
sourceURL:
|
||||
'http://localhost:3333/vendors-node_modules_store_ant-design-icons-virtual-42686020c5_package_es_icons_SyncOutlined_-1757ec.app-446e3c1.js',
|
||||
sourceFunctionName: '',
|
||||
sourceCharPosition: 0,
|
||||
},
|
||||
{
|
||||
name: 'script',
|
||||
entryType: 'script',
|
||||
startTime: 1517.1999998092651,
|
||||
duration: 12,
|
||||
invoker:
|
||||
'http://localhost:3333/app_components_Session_Player_ReplayPlayer_PlayerInst_tsx-app_components_Session__Player_Over-d1e8de.app-1eb48ad.js',
|
||||
invokerType: 'classic-script',
|
||||
windowAttribution: 'self',
|
||||
executionStart: 1529.6999998092651,
|
||||
forcedStyleAndLayoutDuration: 0,
|
||||
pauseDuration: 0,
|
||||
sourceURL:
|
||||
'http://localhost:3333/app_components_Session_Player_ReplayPlayer_PlayerInst_tsx-app_components_Session__Player_Over-d1e8de.app-1eb48ad.js',
|
||||
sourceFunctionName: '',
|
||||
sourceCharPosition: 0,
|
||||
},
|
||||
{
|
||||
name: 'script',
|
||||
entryType: 'script',
|
||||
startTime: 1531.5999994277954,
|
||||
duration: 19,
|
||||
invoker:
|
||||
'http://localhost:3333/app_components_Session_Session_tsx-app_components_Session_Tabs_tabs_module_css-app_components-e2a7c2.app-fd8d38a.js',
|
||||
invokerType: 'classic-script',
|
||||
windowAttribution: 'self',
|
||||
executionStart: 1539.1999998092651,
|
||||
forcedStyleAndLayoutDuration: 0,
|
||||
pauseDuration: 0,
|
||||
sourceURL:
|
||||
'http://localhost:3333/app_components_Session_Session_tsx-app_components_Session_Tabs_tabs_module_css-app_components-e2a7c2.app-fd8d38a.js',
|
||||
sourceFunctionName: '',
|
||||
sourceCharPosition: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'long-animation-frame',
|
||||
entryType: 'long-animation-frame',
|
||||
startTime: 2392.699999809265,
|
||||
duration: 139.5,
|
||||
renderStart: 2529.8999996185303,
|
||||
styleAndLayoutStart: 2531,
|
||||
firstUIEventTimestamp: 2529.8999996185303,
|
||||
blockingDuration: 85.95,
|
||||
scripts: [
|
||||
{
|
||||
name: 'script',
|
||||
entryType: 'script',
|
||||
startTime: 2392.699999809265,
|
||||
duration: 133,
|
||||
invoker: 'Response.json.then',
|
||||
invokerType: 'resolve-promise',
|
||||
windowAttribution: 'self',
|
||||
executionStart: 2392.699999809265,
|
||||
forcedStyleAndLayoutDuration: 6,
|
||||
pauseDuration: 0,
|
||||
sourceURL: 'http://localhost:3333/app-22de34a.js',
|
||||
sourceFunctionName: '',
|
||||
sourceCharPosition: -1,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'long-animation-frame',
|
||||
entryType: 'long-animation-frame',
|
||||
startTime: 2536.2999992370605,
|
||||
duration: 117,
|
||||
renderStart: 2650.3999996185303,
|
||||
styleAndLayoutStart: 2653.2999992370605,
|
||||
firstUIEventTimestamp: 2650.3999996185303,
|
||||
blockingDuration: 60.8,
|
||||
scripts: [
|
||||
{
|
||||
name: 'script',
|
||||
entryType: 'script',
|
||||
startTime: 2541.0999994277954,
|
||||
duration: 107,
|
||||
invoker: 'Response.arrayBuffer.then',
|
||||
invokerType: 'resolve-promise',
|
||||
windowAttribution: 'self',
|
||||
executionStart: 2541.0999994277954,
|
||||
forcedStyleAndLayoutDuration: 3,
|
||||
pauseDuration: 0,
|
||||
sourceURL: 'http://localhost:3333/app-22de34a.js',
|
||||
sourceFunctionName: '',
|
||||
sourceCharPosition: -1,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'long-animation-frame',
|
||||
entryType: 'long-animation-frame',
|
||||
startTime: 5621.099999427795,
|
||||
duration: 62.90000057220459,
|
||||
renderStart: 5680.5,
|
||||
styleAndLayoutStart: 5682.099999427795,
|
||||
firstUIEventTimestamp: 5633.199999809265,
|
||||
blockingDuration: 0,
|
||||
scripts: [
|
||||
{
|
||||
name: 'script',
|
||||
entryType: 'script',
|
||||
startTime: 5635.39999961853,
|
||||
duration: 43,
|
||||
invoker: 'DIV#app.onclick',
|
||||
invokerType: 'event-listener',
|
||||
windowAttribution: 'self',
|
||||
executionStart: 5635.39999961853,
|
||||
forcedStyleAndLayoutDuration: 2,
|
||||
pauseDuration: 0,
|
||||
sourceURL: 'http://localhost:3333/app-3a809cc.js',
|
||||
sourceFunctionName: 'dispatchDiscreteEvent',
|
||||
sourceCharPosition: 7365614,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'long-animation-frame',
|
||||
entryType: 'long-animation-frame',
|
||||
startTime: 31203.599999427795,
|
||||
duration: 118.5,
|
||||
renderStart: 31322,
|
||||
styleAndLayoutStart: 31322.099999427795,
|
||||
firstUIEventTimestamp: 0,
|
||||
blockingDuration: 0,
|
||||
scripts: [],
|
||||
},
|
||||
];
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
export interface LongAnimationTask {
|
||||
name: string;
|
||||
duration: number;
|
||||
blockingDuration: number;
|
||||
firstUIEventTimestamp: number;
|
||||
startTime: number;
|
||||
time?: number;
|
||||
scripts: [
|
||||
{
|
||||
name: string;
|
||||
duration: number;
|
||||
invoker: string;
|
||||
invokerType: string;
|
||||
pauseDuration: number;
|
||||
sourceURL: string;
|
||||
sourceFunctionName: string;
|
||||
sourceCharPosition: number;
|
||||
forcedStyleAndLayoutDuration: number;
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
/* eslint-disable i18next/no-literal-string */
|
||||
import { ResourceType, Timed } from 'Player';
|
||||
import { IResourceRequest, ResourceType, Timed } from 'Player';
|
||||
import { WsChannel } from 'Player/web/messages';
|
||||
import MobilePlayer from 'Player/mobile/IOSPlayer';
|
||||
import WebPlayer from 'Player/web/WebPlayer';
|
||||
|
|
@ -11,7 +11,7 @@ import React, {
|
|||
useCallback,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import i18n from 'App/i18n'
|
||||
import i18n from 'App/i18n';
|
||||
|
||||
import { useModal } from 'App/components/Modal';
|
||||
import {
|
||||
|
|
@ -23,10 +23,7 @@ import { useStore } from 'App/mstore';
|
|||
import { formatBytes, debounceCall } from 'App/utils';
|
||||
import { Icon, NoContent, Tabs } from 'UI';
|
||||
import { Tooltip, Input, Switch, Form } from 'antd';
|
||||
import {
|
||||
SearchOutlined,
|
||||
InfoCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { SearchOutlined, InfoCircleOutlined } from '@ant-design/icons';
|
||||
|
||||
import FetchDetailsModal from 'Shared/FetchDetailsModal';
|
||||
|
||||
|
|
@ -37,7 +34,7 @@ import TimeTable from '../TimeTable';
|
|||
import useAutoscroll, { getLastItemTime } from '../useAutoscroll';
|
||||
import WSPanel from './WSPanel';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { mergeListsWithZoom, processInChunks } from './utils'
|
||||
import { mergeListsWithZoom, processInChunks } from './utils';
|
||||
|
||||
// Constants remain the same
|
||||
const INDEX_KEY = 'network';
|
||||
|
|
@ -84,9 +81,22 @@ export function renderType(r: any) {
|
|||
}
|
||||
|
||||
export function renderName(r: any) {
|
||||
const maxTtipUrlLength = 800;
|
||||
const tooltipUrl =
|
||||
r.url && r.url.length > maxTtipUrlLength
|
||||
? `${r.url.slice(0, maxTtipUrlLength / 2)}......${r.url.slice(-maxTtipUrlLength / 2)}`
|
||||
: r.url;
|
||||
|
||||
return (
|
||||
<Tooltip style={{ width: '100%' }} title={<div>{r.url}</div>}>
|
||||
<div>{r.name}</div>
|
||||
<Tooltip
|
||||
style={{ width: '100%', maxWidth: 1024 }}
|
||||
title={<div>{tooltipUrl}</div>}
|
||||
>
|
||||
<div
|
||||
style={{ maxWidth: 250, overflow: 'hidden', textOverflow: 'ellipsis' }}
|
||||
>
|
||||
{r.name}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
|
@ -94,7 +104,7 @@ export function renderName(r: any) {
|
|||
function renderSize(r: any) {
|
||||
const t = i18n.t;
|
||||
const notCaptured = t('Not captured');
|
||||
const resSizeStr = t('Resource size')
|
||||
const resSizeStr = t('Resource size');
|
||||
let triggerText;
|
||||
let content;
|
||||
if (r.responseBodySize) {
|
||||
|
|
@ -185,7 +195,6 @@ function renderStatus({
|
|||
);
|
||||
}
|
||||
|
||||
|
||||
// Main component for Network Panel
|
||||
function NetworkPanelCont({ panelHeight }: { panelHeight: number }) {
|
||||
const { player, store } = React.useContext(PlayerContext);
|
||||
|
|
@ -400,8 +409,8 @@ export const NetworkPanelComp = observer(
|
|||
transferredSize: 0,
|
||||
});
|
||||
|
||||
const originalListRef = useRef([]);
|
||||
const socketListRef = useRef([]);
|
||||
const originalListRef = useRef<IResourceRequest[]>([]);
|
||||
const socketListRef = useRef<any[]>([]);
|
||||
|
||||
const {
|
||||
sessionStore: { devTools },
|
||||
|
|
@ -433,18 +442,40 @@ export const NetworkPanelComp = observer(
|
|||
|
||||
// Heaviest operation here, will create a final merged network list
|
||||
const processData = async () => {
|
||||
const fetchUrls = new Set(
|
||||
fetchList.map((ft) => {
|
||||
return `${ft.name}-${Math.floor(ft.time / 100)}-${Math.floor(ft.duration / 100)}`;
|
||||
}),
|
||||
);
|
||||
const fetchUrlMap: Record<string, number[]> = {};
|
||||
const len = fetchList.length;
|
||||
for (let i = 0; i < len; i++) {
|
||||
const ft = fetchList[i] as any;
|
||||
const key = `${ft.name}-${Math.round(ft.time / 10)}-${Math.round(ft.duration / 10)}`;
|
||||
if (fetchUrlMap[key]) {
|
||||
fetchUrlMap[key].push(i);
|
||||
}
|
||||
fetchUrlMap[key] = [i];
|
||||
}
|
||||
|
||||
// We want to get resources that aren't in fetch list
|
||||
const filteredResources = await processInChunks(resourceList, (chunk) =>
|
||||
chunk.filter((res: any) => {
|
||||
const key = `${res.name}-${Math.floor(res.time / 100)}-${Math.floor(res.duration / 100)}`;
|
||||
return !fetchUrls.has(key);
|
||||
}),
|
||||
const filteredResources = await processInChunks(
|
||||
resourceList,
|
||||
(chunk) => {
|
||||
const clearChunk = [];
|
||||
for (const res of chunk) {
|
||||
const key = `${res.name}-${Math.floor(res.time / 10)}-${Math.floor(res.duration / 10)}`;
|
||||
const possibleRequests = fetchUrlMap[key];
|
||||
if (possibleRequests && possibleRequests.length) {
|
||||
for (const i of possibleRequests) {
|
||||
fetchList[i].timings = res.timings;
|
||||
}
|
||||
fetchUrlMap[key] = [];
|
||||
} else {
|
||||
clearChunk.push(res);
|
||||
}
|
||||
}
|
||||
return clearChunk;
|
||||
},
|
||||
// chunk.filter((res: any) => {
|
||||
// const key = `${res.name}-${Math.floor(res.time / 100)}-${Math.floor(res.duration / 100)}`;
|
||||
// return !fetchUrls.has(key);
|
||||
// }),
|
||||
BATCH_SIZE,
|
||||
25,
|
||||
);
|
||||
|
|
@ -464,8 +495,12 @@ export const NetworkPanelComp = observer(
|
|||
filteredResources as Timed[],
|
||||
fetchList,
|
||||
processedSockets as Timed[],
|
||||
{ enabled: Boolean(zoomEnabled), start: zoomStartTs ?? 0, end: zoomEndTs ?? 0 }
|
||||
)
|
||||
{
|
||||
enabled: Boolean(zoomEnabled),
|
||||
start: zoomStartTs ?? 0,
|
||||
end: zoomEndTs ?? 0,
|
||||
},
|
||||
);
|
||||
|
||||
originalListRef.current = mergedList;
|
||||
setTotalItems(mergedList.length);
|
||||
|
|
@ -489,19 +524,21 @@ export const NetworkPanelComp = observer(
|
|||
|
||||
const calculateResourceStats = (resourceList: Record<string, any>) => {
|
||||
setTimeout(() => {
|
||||
let resourcesSize = 0
|
||||
let transferredSize = 0
|
||||
resourceList.forEach(({ decodedBodySize, headerSize, encodedBodySize }: any) => {
|
||||
resourcesSize += decodedBodySize || 0
|
||||
transferredSize += (headerSize || 0) + (encodedBodySize || 0)
|
||||
})
|
||||
let resourcesSize = 0;
|
||||
let transferredSize = 0;
|
||||
resourceList.forEach(
|
||||
({ decodedBodySize, headerSize, encodedBodySize }: any) => {
|
||||
resourcesSize += decodedBodySize || 0;
|
||||
transferredSize += (headerSize || 0) + (encodedBodySize || 0);
|
||||
},
|
||||
);
|
||||
|
||||
setSummaryStats({
|
||||
resourcesSize,
|
||||
transferredSize,
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (originalListRef.current.length === 0) return;
|
||||
|
|
@ -510,27 +547,33 @@ export const NetworkPanelComp = observer(
|
|||
let filteredItems: any[] = originalListRef.current;
|
||||
|
||||
filteredItems = await processInChunks(filteredItems, (chunk) =>
|
||||
chunk.filter(
|
||||
(it) => {
|
||||
let valid = true;
|
||||
if (showOnlyErrors) {
|
||||
valid = parseInt(it.status) >= 400 || !it.success || it.error
|
||||
}
|
||||
if (filter) {
|
||||
try {
|
||||
const regex = new RegExp(filter, 'i');
|
||||
valid = valid && regex.test(it.status) || regex.test(it.name) || regex.test(it.type) || regex.test(it.method);
|
||||
} catch (e) {
|
||||
valid = valid && String(it.status).includes(filter) || it.name.includes(filter) || it.type.includes(filter) || (it.method && it.method.includes(filter));
|
||||
}
|
||||
}
|
||||
if (activeTab !== ALL) {
|
||||
valid = valid && TYPE_TO_TAB[it.type] === activeTab;
|
||||
chunk.filter((it) => {
|
||||
let valid = true;
|
||||
if (showOnlyErrors) {
|
||||
valid = parseInt(it.status) >= 400 || !it.success || it.error;
|
||||
}
|
||||
if (filter) {
|
||||
try {
|
||||
const regex = new RegExp(filter, 'i');
|
||||
valid =
|
||||
(valid && regex.test(it.status)) ||
|
||||
regex.test(it.name) ||
|
||||
regex.test(it.type) ||
|
||||
regex.test(it.method);
|
||||
} catch (e) {
|
||||
valid =
|
||||
(valid && String(it.status).includes(filter)) ||
|
||||
it.name.includes(filter) ||
|
||||
it.type.includes(filter) ||
|
||||
(it.method && it.method.includes(filter));
|
||||
}
|
||||
}
|
||||
if (activeTab !== ALL) {
|
||||
valid = valid && TYPE_TO_TAB[it.type] === activeTab;
|
||||
}
|
||||
|
||||
return valid;
|
||||
},
|
||||
),
|
||||
return valid;
|
||||
}),
|
||||
);
|
||||
|
||||
// Update displayed items
|
||||
|
|
@ -567,7 +610,7 @@ export const NetworkPanelComp = observer(
|
|||
};
|
||||
|
||||
const onFilterChange = ({ target: { value } }) => {
|
||||
setInputFilterValue(value)
|
||||
setInputFilterValue(value);
|
||||
debouncedFilter(value);
|
||||
};
|
||||
|
||||
|
|
@ -612,6 +655,7 @@ export const NetworkPanelComp = observer(
|
|||
|
||||
return setSelectedWsChannel(socketMsgList);
|
||||
}
|
||||
|
||||
setIsDetailsModalActive(true);
|
||||
showModal(
|
||||
<FetchDetailsModal
|
||||
|
|
@ -834,11 +878,11 @@ export const NetworkPanelComp = observer(
|
|||
ref={loadingRef}
|
||||
className="flex justify-center items-center text-xs text-gray-500"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-600 mr-2"></div>
|
||||
Loading more data ({totalItems - displayedItems.length}{' '}
|
||||
remaining)
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-600 mr-2"></div>
|
||||
Loading more data ({totalItems - displayedItems.length}{' '}
|
||||
remaining)
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -355,7 +355,7 @@ function RowRenderer({
|
|||
);
|
||||
}
|
||||
|
||||
const RowColumns = React.memo(({ columns, row }: any) => {
|
||||
const RowColumns = ({ columns, row }: any) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return columns.map(({ dataKey, render, width, label }: any) => (
|
||||
|
|
@ -371,6 +371,6 @@ const RowColumns = React.memo(({ columns, row }: any) => {
|
|||
)}
|
||||
</div>
|
||||
));
|
||||
});
|
||||
};
|
||||
|
||||
export default observer(TimeTable);
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ function FetchDetailsModal(props: Props) {
|
|||
}
|
||||
/>
|
||||
|
||||
{isXHR && <FetchTabs isSpot={isSpot} resource={resource} />}
|
||||
<FetchTabs isSpot={isSpot} resource={resource} isXHR={isXHR} />
|
||||
|
||||
{rows && rows.length > 0 && (
|
||||
<div className="flex justify-between absolute bottom-0 left-0 right-0 p-3 border-t bg-white">
|
||||
|
|
|
|||
|
|
@ -13,6 +13,11 @@ function FetchBasicDetails({ resource, timestamp }: Props) {
|
|||
const _duration = parseInt(resource.duration);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const maxUrlLength = 800;
|
||||
const displayUrl =
|
||||
resource.url && resource.url.length > maxUrlLength
|
||||
? `${resource.url.slice(0, maxUrlLength / 2)}......${resource.url.slice(-maxUrlLength / 2)}`
|
||||
: resource.url;
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-start py-1">
|
||||
|
|
@ -22,7 +27,7 @@ function FetchBasicDetails({ resource, timestamp }: Props) {
|
|||
bordered={false}
|
||||
style={{ maxWidth: '300px' }}
|
||||
>
|
||||
<div>{resource.url}</div>
|
||||
<div>{displayUrl}</div>
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -5,14 +5,22 @@ import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
|
|||
import Headers from '../Headers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TFunction } from 'i18next';
|
||||
import FetchTimings from './FetchTimings';
|
||||
|
||||
const HEADERS = 'HEADERS';
|
||||
const REQUEST = 'REQUEST';
|
||||
const RESPONSE = 'RESPONSE';
|
||||
const TABS = [HEADERS, REQUEST, RESPONSE].map((tab) => ({
|
||||
const TIMINGS = 'TIMINGS';
|
||||
const TABS = [HEADERS, REQUEST, RESPONSE, TIMINGS].map((tab) => ({
|
||||
text: tab,
|
||||
key: tab,
|
||||
}));
|
||||
const RESOURCE_TABS = [
|
||||
{
|
||||
text: TIMINGS,
|
||||
key: TIMINGS,
|
||||
},
|
||||
];
|
||||
|
||||
type RequestResponse = {
|
||||
headers?: Record<string, string>;
|
||||
|
|
@ -76,10 +84,11 @@ function parseRequestResponse(
|
|||
interface Props {
|
||||
resource: { request: string; response: string };
|
||||
isSpot?: boolean;
|
||||
isXHR?: boolean;
|
||||
}
|
||||
function FetchTabs({ resource, isSpot }: Props) {
|
||||
function FetchTabs({ resource, isSpot, isXHR }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState(HEADERS);
|
||||
const [activeTab, setActiveTab] = useState(isXHR ? HEADERS : TIMINGS);
|
||||
const onTabClick = (tab: string) => setActiveTab(tab);
|
||||
const [jsonRequest, setJsonRequest] = useState<object | null>(null);
|
||||
const [jsonResponse, setJsonResponse] = useState<object | null>(null);
|
||||
|
|
@ -112,6 +121,8 @@ function FetchTabs({ resource, isSpot }: Props) {
|
|||
);
|
||||
}, [resource]);
|
||||
|
||||
const noTimings = resource.timings ? Object.values(resource.timings).every((v) => v === 0) : true;
|
||||
|
||||
const renderActiveTab = () => {
|
||||
switch (activeTab) {
|
||||
case REQUEST:
|
||||
|
|
@ -122,10 +133,13 @@ function FetchTabs({ resource, isSpot }: Props) {
|
|||
<AnimatedSVG name={ICONS.NO_RESULTS} size={30} />
|
||||
<div className="mt-6 text-base font-normal">
|
||||
{t('Body is empty or not captured.')}{' '}
|
||||
<a href="https://docs.openreplay.com/en/sdk/network-options" className="link" target="_blank">
|
||||
<a
|
||||
href="https://docs.openreplay.com/en/sdk/network-options"
|
||||
className="link"
|
||||
target="_blank"
|
||||
>
|
||||
{t('Configure')}
|
||||
</a>
|
||||
{' '}
|
||||
</a>{' '}
|
||||
{t(
|
||||
'network capturing to get more out of Fetch/XHR requests.',
|
||||
)}
|
||||
|
|
@ -160,10 +174,13 @@ function FetchTabs({ resource, isSpot }: Props) {
|
|||
<AnimatedSVG name={ICONS.NO_RESULTS} size={30} />
|
||||
<div className="mt-6 text-base font-normal">
|
||||
{t('Body is empty or not captured.')}{' '}
|
||||
<a href="https://docs.openreplay.com/en/sdk/network-options" className="link" target="_blank">
|
||||
<a
|
||||
href="https://docs.openreplay.com/en/sdk/network-options"
|
||||
className="link"
|
||||
target="_blank"
|
||||
>
|
||||
{t('Configure')}
|
||||
</a>
|
||||
{' '}
|
||||
</a>{' '}
|
||||
{t(
|
||||
'network capturing to get more out of Fetch/XHR requests.',
|
||||
)}
|
||||
|
|
@ -197,11 +214,35 @@ function FetchTabs({ resource, isSpot }: Props) {
|
|||
responseHeaders={responseHeaders}
|
||||
/>
|
||||
);
|
||||
case TIMINGS:
|
||||
return <NoContent
|
||||
title={
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<AnimatedSVG name={ICONS.NO_RESULTS} size={30} />
|
||||
<div className="mt-6 text-base font-normal">
|
||||
{t('Request was instant (cached) or no timings were recorded.')}
|
||||
<br />
|
||||
<a
|
||||
href="https://docs.openreplay.com/en/sdk/network-options"
|
||||
className="link"
|
||||
target="_blank"
|
||||
>
|
||||
{t('Learn how to get more out of Fetch/XHR requests.')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
size="small"
|
||||
show={noTimings}
|
||||
>
|
||||
<FetchTimings timings={resource.timings} />
|
||||
</NoContent>
|
||||
}
|
||||
};
|
||||
const usedTabs = isXHR ? TABS : RESOURCE_TABS;
|
||||
return (
|
||||
<div>
|
||||
<Tabs tabs={TABS} active={activeTab} onClick={onTabClick} border />
|
||||
<Tabs tabs={usedTabs} active={activeTab} onClick={onTabClick} border />
|
||||
<div style={{ height: 'calc(100vh - 364px)', overflowY: 'auto' }}>
|
||||
{renderActiveTab()}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,182 @@
|
|||
import React from 'react';
|
||||
import { Tooltip } from 'antd';
|
||||
import { HelpCircle } from 'lucide-react'
|
||||
|
||||
function FetchTimings({ timings }: { timings: Record<string, number> }) {
|
||||
const formatTime = (time: number) => {
|
||||
if (time === undefined || time === null) return '—';
|
||||
if (time === 0) return '0ms';
|
||||
if (time < 1) return `${Math.round(time * 1000)}μs`;
|
||||
return `${Math.round(time)}ms`;
|
||||
};
|
||||
|
||||
const total = React.useMemo(() => {
|
||||
const sumOfComponents = Object.entries(timings)
|
||||
.filter(([key]) => key !== 'total')
|
||||
.reduce((sum, [_, value]) => sum + (value || 0), 0);
|
||||
|
||||
const largestComponent = Math.max(
|
||||
...Object.entries(timings)
|
||||
.filter(([key]) => key !== 'total')
|
||||
.map(([_, value]) => value || 0),
|
||||
);
|
||||
|
||||
return Math.max(timings.total || 0, sumOfComponents, largestComponent);
|
||||
}, [timings.total]);
|
||||
const isAdjusted = timings.total !== undefined && total !== timings.total;
|
||||
|
||||
const phases = [
|
||||
{
|
||||
category: 'Resource Scheduling',
|
||||
children: [
|
||||
{
|
||||
key: 'queueing',
|
||||
name: 'Queueing',
|
||||
color: 'bg-transparent border border-[#666]',
|
||||
description: 'Time spent in browser queue before connection start',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Connection Start',
|
||||
children: [
|
||||
{
|
||||
key: 'stalled',
|
||||
name: 'Stalled',
|
||||
color: 'bg-[#c3c3c3]',
|
||||
description: 'Time request was stalled after connection start',
|
||||
},
|
||||
{
|
||||
key: 'dnsLookup',
|
||||
name: 'DNS Lookup',
|
||||
color: 'bg-[#12546C]',
|
||||
description: 'Time spent resolving the DNS',
|
||||
},
|
||||
{
|
||||
key: 'initialConnection',
|
||||
name: 'Initial Connection',
|
||||
color: 'bg-[#DD4F18]',
|
||||
description: 'Time establishing connection (TCP handshakes/retries)',
|
||||
},
|
||||
{
|
||||
key: 'ssl',
|
||||
name: 'SSL',
|
||||
color: 'bg-[#C079FF]',
|
||||
description: 'Time spent completing SSL/TLS handshake',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Request/Response',
|
||||
children: [
|
||||
{
|
||||
key: 'ttfb',
|
||||
name: 'Request & TTFB',
|
||||
color: 'bg-[#3CB347]',
|
||||
description: 'Time waiting for first byte (server response time)',
|
||||
},
|
||||
{
|
||||
key: 'contentDownload',
|
||||
name: 'Content Download',
|
||||
color: 'bg-[#3E78F7]',
|
||||
description: 'Time spent receiving the response data',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const calculateTimelines = () => {
|
||||
let currentPosition = 0;
|
||||
const results = [];
|
||||
|
||||
for (const phase of phases) {
|
||||
const parts = [];
|
||||
for (const child of phase.children) {
|
||||
const duration = timings[child.key] || 0;
|
||||
const width = (duration / total) * 100;
|
||||
|
||||
parts.push({
|
||||
...child,
|
||||
duration,
|
||||
position: currentPosition,
|
||||
width,
|
||||
});
|
||||
|
||||
currentPosition += width;
|
||||
}
|
||||
results.push({
|
||||
category: phase.category,
|
||||
children: parts,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
const timelineData = React.useMemo(() => calculateTimelines(), [total]);
|
||||
|
||||
return (
|
||||
<div className="w-full py-4 font-sans">
|
||||
<div>
|
||||
<div className="space-y-4">
|
||||
{timelineData.map((cat, index) => (
|
||||
<div>
|
||||
<div className='text-neutral-500'>{cat.category}</div>
|
||||
<div>
|
||||
{cat.children.map((phase, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="grid grid-cols-12 items-center gap-2 space-y-2"
|
||||
>
|
||||
<div className="col-span-4 text-sm text-neutral-950 font-medium flex items-center gap-2">
|
||||
<Tooltip title={phase.description}>
|
||||
<HelpCircle size={12} />
|
||||
</Tooltip>
|
||||
<span>{phase.name}:</span>
|
||||
</div>
|
||||
|
||||
<div className="col-span-7 relative">
|
||||
<div className="h-4 bg-neutral-50 overflow-hidden">
|
||||
{phase.width > 0 && (
|
||||
<div
|
||||
className={`absolute top-0 h-full ${phase.color} hover:opacity-80 transition-opacity`}
|
||||
style={{
|
||||
left: `${phase.position}%`,
|
||||
width: `${Math.max(phase.width, 0.5)}%`, // Ensure minimum visibility
|
||||
}}
|
||||
title={`${phase.name}: ${formatTime(phase.duration)} (starts at ${formatTime((total * phase.position) / 100)})`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-1 text-right font-mono text-sm text-gray-dark">
|
||||
{formatTime(phase.duration)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="grid grid-cols-12 items-center gap-2 pt-2 border-t border-t-gray-light mt-2">
|
||||
<div className="col-span-3 text-sm text-neutral-950 font-semibold">
|
||||
Total:
|
||||
</div>
|
||||
<div className="col-span-7"></div>
|
||||
<div className="col-span-2 text-right font-mono text-sm text-neutral-950 font-semibold">
|
||||
{formatTime(total)}{' '}
|
||||
{isAdjusted ? (
|
||||
<span className="ml-1 text-xs text-yellow">
|
||||
(adjusted from reported value: {formatTime(timings.total)})
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FetchTimings;
|
||||
|
|
@ -5,6 +5,7 @@ import ListingVisibility from './components/ListingVisibility';
|
|||
import DefaultPlaying from './components/DefaultPlaying';
|
||||
import DefaultTimezone from './components/DefaultTimezone';
|
||||
import CaptureRate from './components/CaptureRate';
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function SessionSettings() {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
import React from 'react';
|
||||
import { useStore } from 'App/mstore';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { Switch } from 'UI';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function VirtualModeSettings() {
|
||||
const { settingsStore } = useStore();
|
||||
const { sessionSettings } = settingsStore;
|
||||
const { virtualMode } = sessionSettings;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const updateSettings = (checked: boolean) => {
|
||||
settingsStore.sessionSettings.updateKey('virtualMode', !virtualMode);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg">{t('Virtual Mode')}</h3>
|
||||
<div className="my-1">
|
||||
{t('Change this setting if you have issues with recordings containing Lightning Web Components (or similar custom HTML Element libraries).')}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<Switch onChange={updateSettings} checked={virtualMode} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(VirtualModeSettings);
|
||||
|
|
@ -2,12 +2,13 @@ import React from 'react';
|
|||
import NotesList from './NoteList';
|
||||
import NoteTags from './NoteTags';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PANEL_SIZES } from 'App/constants/panelSizes';
|
||||
|
||||
function NotesRoute() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="mb-5 w-full mx-auto" style={{ maxWidth: '1360px' }}>
|
||||
<div className="mb-5 w-full mx-auto" style={{ maxWidth: PANEL_SIZES.maxWidth }}>
|
||||
<div className="widget-wrapper">
|
||||
<div className="flex items-center px-4 py-2 justify-between w-full border-b">
|
||||
<div className="flex items-center justify-end w-full">
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import { Button } from 'antd';
|
||||
|
||||
function CopyButton({
|
||||
content,
|
||||
variant = 'text',
|
||||
className = 'capitalize mt-2 font-medium text-neutral-400',
|
||||
btnText = 'copy',
|
||||
size = 'small',
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copyHandler = () => {
|
||||
setCopied(true);
|
||||
copy(content);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
type={variant}
|
||||
onClick={copyHandler}
|
||||
size={size}
|
||||
className={className}
|
||||
>
|
||||
{copied ? 'copied' : btnText}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default CopyButton;
|
||||
83
frontend/app/components/ui/CopyButton/CopyButton.tsx
Normal file
83
frontend/app/components/ui/CopyButton/CopyButton.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import React, { useState } from 'react';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import { ClipboardCopy, ClipboardCheck } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
content: string;
|
||||
getHtml?: () => any;
|
||||
variant?: 'text' | 'primary' | 'ghost' | 'link' | 'default';
|
||||
className?: string;
|
||||
btnText?: string;
|
||||
size?: 'small' | 'middle' | 'large';
|
||||
isIcon?: boolean;
|
||||
format?: string;
|
||||
}
|
||||
|
||||
function CopyButton({
|
||||
content,
|
||||
getHtml,
|
||||
variant = 'text',
|
||||
className = 'capitalize mt-2 font-medium text-neutral-400',
|
||||
btnText = 'copy',
|
||||
size = 'small',
|
||||
isIcon = false,
|
||||
format = 'text/plain',
|
||||
}: Props) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const reset = () => {
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 1000);
|
||||
}
|
||||
const copyHandler = () => {
|
||||
setCopied(true);
|
||||
const contentIsGetter = !!getHtml
|
||||
const textContent = contentIsGetter ? getHtml() : content;
|
||||
const isHttps = window.location.protocol === 'https:';
|
||||
if (!isHttps) {
|
||||
copy(textContent);
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
const blob = new Blob([textContent], { type: format });
|
||||
const cbItem = new ClipboardItem({
|
||||
[format]: blob
|
||||
})
|
||||
navigator.clipboard.write([cbItem])
|
||||
.catch(e => {
|
||||
copy(textContent);
|
||||
})
|
||||
.finally(() => {
|
||||
reset()
|
||||
})
|
||||
};
|
||||
|
||||
if (isIcon) {
|
||||
return (
|
||||
<Tooltip title={copied ? 'Copied!' : 'Copy'}>
|
||||
<Button
|
||||
type="text"
|
||||
onClick={copyHandler}
|
||||
size={size}
|
||||
icon={
|
||||
copied ? <ClipboardCheck size={16} /> : <ClipboardCopy size={16} />
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
type={variant}
|
||||
onClick={copyHandler}
|
||||
size={size}
|
||||
className={className}
|
||||
>
|
||||
{copied ? 'copied' : btnText}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default CopyButton;
|
||||
|
|
@ -353,6 +353,8 @@ 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 } from './kai';
|
||||
export { default as Kai_colored } from './kai_colored';
|
||||
export { default as Key } from './key';
|
||||
export { default as Keyboard } from './keyboard';
|
||||
export { default as Layers_half } from './layers_half';
|
||||
|
|
|
|||
18
frontend/app/components/ui/Icons/kai.tsx
Normal file
18
frontend/app/components/ui/Icons/kai.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
/* Auto-generated, do not edit */
|
||||
import React from 'react';
|
||||
|
||||
interface Props {
|
||||
size?: number | string;
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
fill?: string;
|
||||
}
|
||||
|
||||
function Kai(props: Props) {
|
||||
const { size = 14, width = size, height = size, fill = '' } = props;
|
||||
return (
|
||||
<svg viewBox="0 0 14 15" fill="none" width={ `${ width }px` } height={ `${ height }px` } ><g stroke="#394EFF" strokeLinecap="round" strokeLinejoin="round"><path d="m7 1.862-1.115 3.39a1.167 1.167 0 0 1-.744.744L1.75 7.112l3.39 1.115a1.167 1.167 0 0 1 .745.744L7 12.36l1.115-3.39a1.167 1.167 0 0 1 .744-.744l3.391-1.115-3.39-1.116a1.167 1.167 0 0 1-.745-.743L7 1.862Z" fill="#fff"/><path d="M2.917 1.862v2.333M11.083 10.028v2.334M1.75 3.028h2.333M9.917 11.195h2.333"/></g></svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default Kai;
|
||||
64
frontend/app/components/ui/Icons/kai_colored.tsx
Normal file
64
frontend/app/components/ui/Icons/kai_colored.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
/* Auto-generated, do not edit */
|
||||
import React from 'react';
|
||||
|
||||
interface Props {
|
||||
size?: number | string;
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
fill?: string;
|
||||
}
|
||||
|
||||
function Kai_colored(props: Props) {
|
||||
const { size = 14, width = size, height = size, fill = '' } = props;
|
||||
return (
|
||||
<svg viewBox="0 0 23 23" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="icon <- change size here" clipPath="url(#clip0_142_2721)">
|
||||
<path id="Vector" d="M11.5 2.86172L9.74733 8.19031C9.65764 8.46302 9.50514 8.71086 9.30214 8.91387C9.09914 9.11687 8.8513 9.26936 8.57858 9.35906L3.25 11.1117L8.57858 12.8644C8.8513 12.9541 9.09914 13.1066 9.30214 13.3096C9.50514 13.5126 9.65764 13.7604 9.74733 14.0331L11.5 19.3617L13.2527 14.0331C13.3424 13.7604 13.4949 13.5126 13.6979 13.3096C13.9009 13.1066 14.1487 12.9541 14.4214 12.8644L19.75 11.1117L14.4214 9.35906C14.1487 9.26936 13.9009 9.11687 13.6979 8.91387C13.4949 8.71086 13.3424 8.46302 13.2527 8.19031L11.5 2.86172Z" fill="white" stroke="url(#paint0_linear_142_2721)" strokeWidth="2"/>
|
||||
<g id="Vector_2">
|
||||
<path d="M5.08331 2.86172V6.52839V2.86172Z" fill="white"/>
|
||||
<path d="M5.08331 2.86172V6.52839" stroke="url(#paint1_linear_142_2721)" strokeWidth="2"/>
|
||||
</g>
|
||||
<g id="Vector_3">
|
||||
<path d="M17.9167 15.695V19.3617V15.695Z" fill="white"/>
|
||||
<path d="M17.9167 15.695V19.3617" stroke="url(#paint2_linear_142_2721)" strokeWidth="2"/>
|
||||
</g>
|
||||
<g id="Vector_4">
|
||||
<path d="M3.25 4.69504H6.91667H3.25Z" fill="white"/>
|
||||
<path d="M3.25 4.69504H6.91667" stroke="url(#paint3_linear_142_2721)" strokeWidth="2"/>
|
||||
</g>
|
||||
<g id="Vector_5">
|
||||
<path d="M16.0833 17.5284H19.75H16.0833Z" fill="white"/>
|
||||
<path d="M16.0833 17.5284H19.75" stroke="url(#paint4_linear_142_2721)" strokeWidth="2"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_142_2721" x1="3.25" y1="11.1117" x2="19.75" y2="11.1117" gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="#6F5CEC"/>
|
||||
<stop offset="1" stopColor="#3CC377"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_142_2721" x1="5.08331" y1="4.69506" x2="6.08331" y2="4.69506" gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="#6F5CEC"/>
|
||||
<stop offset="1" stopColor="#3CC377"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_142_2721" x1="17.9167" y1="17.5284" x2="18.9167" y2="17.5284" gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="#6F5CEC"/>
|
||||
<stop offset="1" stopColor="#3CC377"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint3_linear_142_2721" x1="3.25" y1="5.19504" x2="6.91667" y2="5.19504" gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="#6F5CEC"/>
|
||||
<stop offset="1" stopColor="#3CC377"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint4_linear_142_2721" x1="16.0833" y1="18.0284" x2="19.75" y2="18.0284" gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="#6F5CEC"/>
|
||||
<stop offset="1" stopColor="#3CC377"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_142_2721">
|
||||
<rect width="22" height="22" fill="white" transform="translate(0.5 0.111725)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
export default Kai_colored;
|
||||
File diff suppressed because one or more lines are too long
3
frontend/app/constants/panelSizes.ts
Normal file
3
frontend/app/constants/panelSizes.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export const PANEL_SIZES = {
|
||||
maxWidth: '1360px'
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ export const GLOBAL_HAS_NO_RECORDINGS = '__$global-hasNoRecordings$__';
|
|||
export const SITE_ID_STORAGE_KEY = '__$user-siteId$__';
|
||||
export const GETTING_STARTED = '__$user-gettingStarted$__';
|
||||
export const MOUSE_TRAIL = '__$session-mouseTrail$__';
|
||||
export const VIRTUAL_MODE_KEY = '__$session-virtualMode$__'
|
||||
export const IFRAME = '__$session-iframe$__';
|
||||
export const JWT_PARAM = '__$session-jwt-param$__';
|
||||
export const MENU_COLLAPSED = '__$global-menuCollapsed$__';
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
import { MODULES } from 'Components/Client/Modules';
|
||||
import { Icon } from 'UI';
|
||||
import SVG from 'UI/SVG';
|
||||
import { hasAi } from 'App/utils/split-utils';
|
||||
|
||||
import { useStore } from 'App/mstore';
|
||||
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
|
||||
|
|
@ -41,7 +42,7 @@ function SideMenu(props: Props) {
|
|||
|
||||
const isPreferencesActive = location.pathname.includes('/client/');
|
||||
const [supportOpen, setSupportOpen] = React.useState(false);
|
||||
const { searchStore, projectsStore, userStore } = useStore();
|
||||
const { projectsStore, userStore } = useStore();
|
||||
const spotOnly = userStore.scopeState === 1;
|
||||
const { account } = userStore;
|
||||
const modules = account.settings?.modules ?? [];
|
||||
|
|
@ -103,6 +104,7 @@ function SideMenu(props: Props) {
|
|||
modules.includes(MODULES.USABILITY_TESTS),
|
||||
item.isAdmin && !isAdmin,
|
||||
item.isEnterprise && !isEnterprise,
|
||||
item.key === MENU.KAI && !hasAi
|
||||
].some((cond) => cond);
|
||||
|
||||
return { ...item, hidden: isHidden };
|
||||
|
|
@ -145,6 +147,7 @@ function SideMenu(props: Props) {
|
|||
[PREFERENCES_MENU.BILLING]: () => client(CLIENT_TABS.BILLING),
|
||||
[PREFERENCES_MENU.MODULES]: () => client(CLIENT_TABS.MODULES),
|
||||
[MENU.HIGHLIGHTS]: () => withSiteId(routes.highlights(''), siteId),
|
||||
[MENU.KAI]: () => withSiteId(routes.kai(), siteId),
|
||||
};
|
||||
|
||||
const handleClick = (item: any) => {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { TFunction } from 'i18next';
|
||||
import { IconNames } from "../components/ui/SVG";
|
||||
import React from 'react';
|
||||
|
||||
export interface MenuItem {
|
||||
label: React.ReactNode;
|
||||
key: React.Key;
|
||||
icon?: string;
|
||||
icon?: IconNames;
|
||||
children?: MenuItem[];
|
||||
route?: string;
|
||||
hidden?: boolean;
|
||||
|
|
@ -53,6 +54,7 @@ export const enum MENU {
|
|||
SUPPORT = 'support',
|
||||
EXIT = 'exit',
|
||||
SPOTS = 'spots',
|
||||
KAI = 'kai',
|
||||
}
|
||||
|
||||
export const categories: (t: TFunction) => Category[] = (t) => [
|
||||
|
|
@ -93,6 +95,13 @@ export const categories: (t: TFunction) => Category[] = (t) => [
|
|||
{ label: t('Co-Browse'), key: MENU.LIVE_SESSIONS, icon: 'broadcast' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'kai',
|
||||
items: [
|
||||
{ label: t('Kai'), key: MENU.KAI, icon: 'kai' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('Analytics'),
|
||||
key: 'analytics',
|
||||
|
|
|
|||
|
|
@ -81,63 +81,34 @@ const client = new APIClient();
|
|||
|
||||
export class RootStore {
|
||||
dashboardStore: DashboardStore;
|
||||
|
||||
metricStore: MetricStore;
|
||||
|
||||
funnelStore: FunnelStore;
|
||||
|
||||
settingsStore: SettingsStore;
|
||||
|
||||
userStore: typeof userStore;
|
||||
|
||||
roleStore: RoleStore;
|
||||
|
||||
auditStore: AuditStore;
|
||||
|
||||
errorStore: ErrorStore;
|
||||
|
||||
notificationStore: NotificationStore;
|
||||
|
||||
sessionStore: SessionStore;
|
||||
|
||||
notesStore: NotesStore;
|
||||
|
||||
recordingsStore: RecordingsStore;
|
||||
|
||||
assistMultiviewStore: AssistMultiviewStore;
|
||||
|
||||
weeklyReportStore: WeeklyReportStore;
|
||||
|
||||
alertsStore: AlertStore;
|
||||
|
||||
featureFlagsStore: FeatureFlagsStore;
|
||||
|
||||
uxtestingStore: UxtestingStore;
|
||||
|
||||
tagWatchStore: TagWatchStore;
|
||||
|
||||
aiSummaryStore: AiSummaryStore;
|
||||
|
||||
aiFiltersStore: AiFiltersStore;
|
||||
|
||||
spotStore: SpotStore;
|
||||
|
||||
loginStore: LoginStore;
|
||||
|
||||
filterStore: FilterStore;
|
||||
|
||||
uiPlayerStore: UiPlayerStore;
|
||||
|
||||
issueReportingStore: IssueReportingStore;
|
||||
|
||||
customFieldStore: CustomFieldStore;
|
||||
|
||||
searchStore: SearchStore;
|
||||
|
||||
searchStoreLive: SearchStoreLive;
|
||||
|
||||
integrationsStore: IntegrationsStore;
|
||||
|
||||
projectsStore: ProjectsStore;
|
||||
|
||||
constructor() {
|
||||
|
|
|
|||
|
|
@ -398,7 +398,6 @@ class SearchStore {
|
|||
force: boolean = false,
|
||||
bookmarked: boolean = false,
|
||||
): Promise<void> => {
|
||||
console.log(this.searchInProgress)
|
||||
if (this.searchInProgress) return;
|
||||
const filter = this.instance.toSearch();
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
SHOWN_TIMEZONE,
|
||||
DURATION_FILTER,
|
||||
MOUSE_TRAIL,
|
||||
VIRTUAL_MODE_KEY,
|
||||
} from 'App/constants/storageKeys';
|
||||
import { DateTime, Settings } from 'luxon';
|
||||
|
||||
|
|
@ -71,27 +72,19 @@ export const generateGMTZones = (): Timezone[] => {
|
|||
|
||||
export default class SessionSettings {
|
||||
defaultTimezones = [...generateGMTZones()];
|
||||
|
||||
skipToIssue: boolean = localStorage.getItem(SKIP_TO_ISSUE) === 'true';
|
||||
|
||||
timezone: Timezone;
|
||||
|
||||
durationFilter: any = JSON.parse(
|
||||
localStorage.getItem(DURATION_FILTER) ||
|
||||
JSON.stringify(defaultDurationFilter),
|
||||
);
|
||||
|
||||
captureRate: string = '0';
|
||||
|
||||
conditionalCapture: boolean = false;
|
||||
|
||||
captureConditions: { name: string; captureRate: number; filters: any[] }[] =
|
||||
[];
|
||||
|
||||
mouseTrail: boolean = localStorage.getItem(MOUSE_TRAIL) !== 'false';
|
||||
|
||||
shownTimezone: 'user' | 'local';
|
||||
|
||||
virtualMode: boolean = localStorage.getItem(VIRTUAL_MODE_KEY) === 'true';
|
||||
usingLocal: boolean = false;
|
||||
|
||||
constructor() {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { makeAutoObservable, runInAction } from 'mobx';
|
||||
import { makeAutoObservable, runInAction, observable } from 'mobx';
|
||||
import FilterSeries from './filterSeries';
|
||||
import { DateTime } from 'luxon';
|
||||
import Session from 'App/mstore/types/session';
|
||||
|
|
@ -433,13 +433,15 @@ export default class Widget {
|
|||
}
|
||||
|
||||
if (!isComparison) {
|
||||
runInAction(() => {
|
||||
Object.assign(this.data, _data);
|
||||
});
|
||||
this.setDataValue(_data);
|
||||
}
|
||||
return _data;
|
||||
}
|
||||
|
||||
setDataValue = (data: any) => {
|
||||
this.data = observable({ ...data });
|
||||
};
|
||||
|
||||
fetchSessions(metricId: any, filter: any): Promise<any> {
|
||||
return new Promise((resolve) => {
|
||||
metricService.fetchSessions(metricId, filter).then((response: any[]) => {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue