resolved conflicts

This commit is contained in:
Андрей Бабушкин 2025-05-15 16:30:10 +02:00
commit d2c28ca2a9
143 changed files with 7977 additions and 2814 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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([])

View file

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

View file

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

View file

@ -2,7 +2,6 @@ package datasaver
import (
"context"
"openreplay/backend/pkg/db/postgres"
"openreplay/backend/pkg/db/types"
"openreplay/backend/pkg/messages"

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,6 +2,6 @@ compressionLevel: 1
enableGlobalCache: true
nodeLinker: pnpm
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.7.0.cjs

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View 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();

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

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

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

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

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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')}&nbsp;({version})&nbsp;
{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')}&nbsp;({trackerVersion})&nbsp;{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}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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: [],
},
];

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

@ -0,0 +1,18 @@
/* Auto-generated, do not edit */
import React from 'react';
interface Props {
size?: number | string;
width?: number | string;
height?: number | string;
fill?: string;
}
function Kai(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;

View 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 &#60;- 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

View file

@ -0,0 +1,3 @@
export const PANEL_SIZES = {
maxWidth: '1360px'
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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