Compare commits
25 commits
main
...
update_uns
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f9ed3a087 | ||
|
|
1e2dde09b4 | ||
|
|
3cdfe76134 | ||
|
|
39855651d5 | ||
|
|
dd469d2349 | ||
|
|
3d448320bf | ||
|
|
7b0771a581 | ||
|
|
988b396223 | ||
|
|
fa3b585785 | ||
|
|
91e0ebeb56 | ||
|
|
8e68eb9a20 | ||
|
|
13bd3d9121 | ||
|
|
048ae0913c | ||
|
|
73fff8b817 | ||
|
|
605fa96a34 | ||
|
|
2cb33d7894 | ||
|
|
15d427418d | ||
|
|
ed3e553726 | ||
|
|
7eace68de6 | ||
|
|
8009882cef | ||
|
|
7365d8639c | ||
|
|
4c967d4bc1 | ||
|
|
3fdf799bd7 | ||
|
|
9aca716e6b | ||
|
|
cf9ecdc9a4 |
95 changed files with 1522 additions and 1509 deletions
11
api/Pipfile
11
api/Pipfile
|
|
@ -6,16 +6,15 @@ name = "pypi"
|
||||||
[packages]
|
[packages]
|
||||||
urllib3 = "==2.3.0"
|
urllib3 = "==2.3.0"
|
||||||
requests = "==2.32.3"
|
requests = "==2.32.3"
|
||||||
boto3 = "==1.36.12"
|
boto3 = "==1.37.16"
|
||||||
pyjwt = "==2.10.1"
|
pyjwt = "==2.10.1"
|
||||||
psycopg2-binary = "==2.9.10"
|
psycopg2-binary = "==2.9.10"
|
||||||
psycopg = {extras = ["pool", "binary"], version = "==3.2.4"}
|
psycopg = {extras = ["binary", "pool"], version = "==3.2.6"}
|
||||||
clickhouse-driver = {extras = ["lz4"], version = "==0.2.9"}
|
|
||||||
clickhouse-connect = "==0.8.15"
|
clickhouse-connect = "==0.8.15"
|
||||||
elasticsearch = "==8.17.1"
|
elasticsearch = "==8.17.2"
|
||||||
jira = "==3.8.0"
|
jira = "==3.8.0"
|
||||||
cachetools = "==5.5.1"
|
cachetools = "==5.5.2"
|
||||||
fastapi = "==0.115.8"
|
fastapi = "==0.115.11"
|
||||||
uvicorn = {extras = ["standard"], version = "==0.34.0"}
|
uvicorn = {extras = ["standard"], version = "==0.34.0"}
|
||||||
python-decouple = "==3.8"
|
python-decouple = "==3.8"
|
||||||
pydantic = {extras = ["email"], version = "==2.10.6"}
|
pydantic = {extras = ["email"], version = "==2.10.6"}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ from chalicelib.utils import helper
|
||||||
from chalicelib.utils import pg_client, ch_client
|
from chalicelib.utils import pg_client, ch_client
|
||||||
from crons import core_crons, core_dynamic_crons
|
from crons import core_crons, core_dynamic_crons
|
||||||
from routers import core, core_dynamic
|
from routers import core, core_dynamic
|
||||||
from routers.subs import insights, metrics, v1_api, health, usability_tests, spot, product_anaytics
|
from routers.subs import insights, metrics, v1_api, health, usability_tests, spot, product_analytics
|
||||||
|
|
||||||
loglevel = config("LOGLEVEL", default=logging.WARNING)
|
loglevel = config("LOGLEVEL", default=logging.WARNING)
|
||||||
print(f">Loglevel set to: {loglevel}")
|
print(f">Loglevel set to: {loglevel}")
|
||||||
|
|
@ -129,6 +129,6 @@ app.include_router(spot.public_app)
|
||||||
app.include_router(spot.app)
|
app.include_router(spot.app)
|
||||||
app.include_router(spot.app_apikey)
|
app.include_router(spot.app_apikey)
|
||||||
|
|
||||||
app.include_router(product_anaytics.public_app)
|
app.include_router(product_analytics.public_app, prefix="/pa")
|
||||||
app.include_router(product_anaytics.app)
|
app.include_router(product_analytics.app, prefix="/pa")
|
||||||
app.include_router(product_anaytics.app_apikey)
|
app.include_router(product_analytics.app_apikey, prefix="/pa")
|
||||||
|
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
from chalicelib.utils.ch_client import ClickHouseClient
|
|
||||||
|
|
||||||
|
|
||||||
def search_events(project_id: int, data: dict):
|
|
||||||
with ClickHouseClient() as ch_client:
|
|
||||||
r = ch_client.format(
|
|
||||||
"""SELECT *
|
|
||||||
FROM taha.events
|
|
||||||
WHERE project_id=%(project_id)s
|
|
||||||
ORDER BY created_at;""",
|
|
||||||
params={"project_id": project_id})
|
|
||||||
x = ch_client.execute(r)
|
|
||||||
|
|
||||||
return x
|
|
||||||
0
api/chalicelib/core/product_analytics/__init__.py
Normal file
0
api/chalicelib/core/product_analytics/__init__.py
Normal file
28
api/chalicelib/core/product_analytics/events.py
Normal file
28
api/chalicelib/core/product_analytics/events.py
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
from chalicelib.utils import helper
|
||||||
|
from chalicelib.utils.ch_client import ClickHouseClient
|
||||||
|
|
||||||
|
|
||||||
|
def get_events(project_id: int):
|
||||||
|
with ClickHouseClient() as ch_client:
|
||||||
|
r = ch_client.format(
|
||||||
|
"""SELECT event_name, display_name
|
||||||
|
FROM product_analytics.all_events
|
||||||
|
WHERE project_id=%(project_id)s
|
||||||
|
ORDER BY display_name;""",
|
||||||
|
parameters={"project_id": project_id})
|
||||||
|
x = ch_client.execute(r)
|
||||||
|
|
||||||
|
return helper.list_to_camel_case(x)
|
||||||
|
|
||||||
|
|
||||||
|
def search_events(project_id: int, data: dict):
|
||||||
|
with ClickHouseClient() as ch_client:
|
||||||
|
r = ch_client.format(
|
||||||
|
"""SELECT *
|
||||||
|
FROM product_analytics.events
|
||||||
|
WHERE project_id=%(project_id)s
|
||||||
|
ORDER BY created_at;""",
|
||||||
|
parameters={"project_id": project_id})
|
||||||
|
x = ch_client.execute(r)
|
||||||
|
|
||||||
|
return helper.list_to_camel_case(x)
|
||||||
19
api/chalicelib/core/product_analytics/properties.py
Normal file
19
api/chalicelib/core/product_analytics/properties.py
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
from chalicelib.utils import helper
|
||||||
|
from chalicelib.utils.ch_client import ClickHouseClient
|
||||||
|
|
||||||
|
|
||||||
|
def get_properties(project_id: int, event_name):
|
||||||
|
with ClickHouseClient() as ch_client:
|
||||||
|
r = ch_client.format(
|
||||||
|
"""SELECT all_properties.property_name,
|
||||||
|
all_properties.display_name
|
||||||
|
FROM product_analytics.event_properties
|
||||||
|
INNER JOIN product_analytics.all_properties USING (property_name)
|
||||||
|
WHERE event_properties.project_id=%(project_id)s
|
||||||
|
AND all_properties.project_id=%(project_id)s
|
||||||
|
AND event_properties.event_name=%(event_name)s
|
||||||
|
ORDER BY created_at;""",
|
||||||
|
parameters={"project_id": project_id,"event_name": event_name})
|
||||||
|
properties = ch_client.execute(r)
|
||||||
|
|
||||||
|
return helper.list_to_camel_case(properties)
|
||||||
|
|
@ -6,8 +6,18 @@ logger = logging.getLogger(__name__)
|
||||||
from . import sessions_pg
|
from . import sessions_pg
|
||||||
from . import sessions_pg as sessions_legacy
|
from . import sessions_pg as sessions_legacy
|
||||||
from . import sessions_ch
|
from . import sessions_ch
|
||||||
|
from . import sessions_search_pg
|
||||||
|
from . import sessions_search_pg as sessions_search_legacy
|
||||||
|
|
||||||
if config("EXP_METRICS", cast=bool, default=False):
|
if config("EXP_SESSIONS_SEARCH", cast=bool, default=False):
|
||||||
|
logger.info(">>> Using experimental sessions search")
|
||||||
from . import sessions_ch as sessions
|
from . import sessions_ch as sessions
|
||||||
|
from . import sessions_search_ch as sessions_search
|
||||||
else:
|
else:
|
||||||
from . import sessions_pg as sessions
|
from . import sessions_pg as sessions
|
||||||
|
from . import sessions_search_pg as sessions_search
|
||||||
|
|
||||||
|
# if config("EXP_METRICS", cast=bool, default=False):
|
||||||
|
# from . import sessions_ch as sessions
|
||||||
|
# else:
|
||||||
|
# from . import sessions_pg as sessions
|
||||||
|
|
|
||||||
|
|
@ -671,24 +671,36 @@ def search_query_parts_ch(data: schemas.SessionsSearchPayloadSchema, error_statu
|
||||||
events_conditions.append({"type": event_where[-1]})
|
events_conditions.append({"type": event_where[-1]})
|
||||||
if not is_any:
|
if not is_any:
|
||||||
if schemas.ClickEventExtraOperator.has_value(event.operator):
|
if schemas.ClickEventExtraOperator.has_value(event.operator):
|
||||||
event_where.append(json_condition(
|
# event_where.append(json_condition(
|
||||||
"main",
|
# "main",
|
||||||
"$properties",
|
# "$properties",
|
||||||
"selector", op, event.value, e_k)
|
# "selector", op, event.value, e_k)
|
||||||
|
# )
|
||||||
|
event_where.append(
|
||||||
|
sh.multi_conditions(f"main.`$properties`.selector {op} %({e_k})s",
|
||||||
|
event.value, value_key=e_k)
|
||||||
)
|
)
|
||||||
events_conditions[-1]["condition"] = event_where[-1]
|
events_conditions[-1]["condition"] = event_where[-1]
|
||||||
else:
|
else:
|
||||||
if is_not:
|
if is_not:
|
||||||
event_where.append(json_condition(
|
# event_where.append(json_condition(
|
||||||
"sub", "$properties", _column, op, event.value, e_k
|
# "sub", "$properties", _column, op, event.value, e_k
|
||||||
))
|
# ))
|
||||||
|
event_where.append(
|
||||||
|
sh.multi_conditions(f"sub.`$properties`.{_column} {op} %({e_k})s",
|
||||||
|
event.value, value_key=e_k)
|
||||||
|
)
|
||||||
events_conditions_not.append(
|
events_conditions_not.append(
|
||||||
{
|
{
|
||||||
"type": f"sub.`$event_name`='{exp_ch_helper.get_event_type(event_type, platform=platform)}'"})
|
"type": f"sub.`$event_name`='{exp_ch_helper.get_event_type(event_type, platform=platform)}'"})
|
||||||
events_conditions_not[-1]["condition"] = event_where[-1]
|
events_conditions_not[-1]["condition"] = event_where[-1]
|
||||||
else:
|
else:
|
||||||
|
# event_where.append(
|
||||||
|
# json_condition("main", "$properties", _column, op, event.value, e_k)
|
||||||
|
# )
|
||||||
event_where.append(
|
event_where.append(
|
||||||
json_condition("main", "$properties", _column, op, event.value, e_k)
|
sh.multi_conditions(f"main.`$properties`.{_column} {op} %({e_k})s",
|
||||||
|
event.value, value_key=e_k)
|
||||||
)
|
)
|
||||||
events_conditions[-1]["condition"] = event_where[-1]
|
events_conditions[-1]["condition"] = event_where[-1]
|
||||||
else:
|
else:
|
||||||
|
|
@ -870,12 +882,15 @@ def search_query_parts_ch(data: schemas.SessionsSearchPayloadSchema, error_statu
|
||||||
events_conditions[-1]["condition"] = []
|
events_conditions[-1]["condition"] = []
|
||||||
if not is_any and event.value not in [None, "*", ""]:
|
if not is_any and event.value not in [None, "*", ""]:
|
||||||
event_where.append(
|
event_where.append(
|
||||||
sh.multi_conditions(f"(toString(main1.`$properties`.message) {op} %({e_k})s OR toString(main1.`$properties`.name) {op} %({e_k})s)",
|
sh.multi_conditions(
|
||||||
|
f"(toString(main1.`$properties`.message) {op} %({e_k})s OR toString(main1.`$properties`.name) {op} %({e_k})s)",
|
||||||
event.value, value_key=e_k))
|
event.value, value_key=e_k))
|
||||||
events_conditions[-1]["condition"].append(event_where[-1])
|
events_conditions[-1]["condition"].append(event_where[-1])
|
||||||
events_extra_join += f" AND {event_where[-1]}"
|
events_extra_join += f" AND {event_where[-1]}"
|
||||||
if len(event.source) > 0 and event.source[0] not in [None, "*", ""]:
|
if len(event.source) > 0 and event.source[0] not in [None, "*", ""]:
|
||||||
event_where.append(sh.multi_conditions(f"toString(main1.`$properties`.source) = %({s_k})s", event.source, value_key=s_k))
|
event_where.append(
|
||||||
|
sh.multi_conditions(f"toString(main1.`$properties`.source) = %({s_k})s", event.source,
|
||||||
|
value_key=s_k))
|
||||||
events_conditions[-1]["condition"].append(event_where[-1])
|
events_conditions[-1]["condition"].append(event_where[-1])
|
||||||
events_extra_join += f" AND {event_where[-1]}"
|
events_extra_join += f" AND {event_where[-1]}"
|
||||||
|
|
||||||
|
|
@ -1193,6 +1208,28 @@ def search_query_parts_ch(data: schemas.SessionsSearchPayloadSchema, error_statu
|
||||||
events_conditions[-1]["condition"] = " AND ".join(events_conditions[-1]["condition"])
|
events_conditions[-1]["condition"] = " AND ".join(events_conditions[-1]["condition"])
|
||||||
else:
|
else:
|
||||||
continue
|
continue
|
||||||
|
if event.properties is not None and len(event.properties.filters) > 0:
|
||||||
|
event_fiters = []
|
||||||
|
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)}
|
||||||
|
op = sh.get_sql_operator(property.operator)
|
||||||
|
condition = f"main.properties.{property.name} {op} %({a_k})s"
|
||||||
|
if property.is_predefined:
|
||||||
|
condition = f"main.{property.name} {op} %({a_k})s"
|
||||||
|
event_where.append(
|
||||||
|
sh.multi_conditions(condition, property.value, value_key=a_k)
|
||||||
|
)
|
||||||
|
event_fiters.append(event_where[-1])
|
||||||
|
if len(event_fiters) > 0:
|
||||||
|
events_conditions[-1]["condition"] += " AND ("
|
||||||
|
for l, e_f in enumerate(event_fiters):
|
||||||
|
if l > 0:
|
||||||
|
events_conditions[-1]["condition"] += event.properties.operators[l - 1] + e_f
|
||||||
|
else:
|
||||||
|
events_conditions[-1]["condition"] += e_f
|
||||||
|
events_conditions[-1]["condition"] += ")"
|
||||||
if event_index == 0 or or_events:
|
if event_index == 0 or or_events:
|
||||||
event_where += ss_constraints
|
event_where += ss_constraints
|
||||||
if is_not:
|
if is_not:
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import ast
|
import ast
|
||||||
import logging
|
import logging
|
||||||
from typing import List, Union
|
|
||||||
|
|
||||||
import schemas
|
import schemas
|
||||||
from chalicelib.core import events, metadata, projects
|
from chalicelib.core import events, metadata, projects
|
||||||
|
|
@ -141,7 +141,7 @@ def search_sessions(data: schemas.SessionsSearchPayloadSchema, project: schemas.
|
||||||
) AS users_sessions;""",
|
) AS users_sessions;""",
|
||||||
full_args)
|
full_args)
|
||||||
elif ids_only:
|
elif ids_only:
|
||||||
main_query = cur.format(query=f"""SELECT DISTINCT ON(s.session_id) s.session_id
|
main_query = cur.format(query=f"""SELECT DISTINCT ON(s.session_id) s.session_id AS session_id
|
||||||
{query_part}
|
{query_part}
|
||||||
ORDER BY s.session_id desc
|
ORDER BY s.session_id desc
|
||||||
LIMIT %(sessions_limit)s OFFSET %(sessions_limit_s)s;""",
|
LIMIT %(sessions_limit)s OFFSET %(sessions_limit_s)s;""",
|
||||||
|
|
@ -175,11 +175,11 @@ def search_sessions(data: schemas.SessionsSearchPayloadSchema, project: schemas.
|
||||||
ORDER BY sort_key {data.order}
|
ORDER BY sort_key {data.order}
|
||||||
LIMIT %(sessions_limit)s OFFSET %(sessions_limit_s)s) AS sorted_sessions;""",
|
LIMIT %(sessions_limit)s OFFSET %(sessions_limit_s)s) AS sorted_sessions;""",
|
||||||
parameters=full_args)
|
parameters=full_args)
|
||||||
logging.debug("--------------------")
|
|
||||||
logging.debug(main_query)
|
|
||||||
logging.debug("--------------------")
|
|
||||||
try:
|
try:
|
||||||
|
logging.debug("--------------------")
|
||||||
sessions_list = cur.execute(main_query)
|
sessions_list = cur.execute(main_query)
|
||||||
|
logging.debug("--------------------")
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
logging.warning("--------- SESSIONS-CH SEARCH QUERY EXCEPTION -----------")
|
logging.warning("--------- SESSIONS-CH SEARCH QUERY EXCEPTION -----------")
|
||||||
logging.warning(main_query)
|
logging.warning(main_query)
|
||||||
|
|
@ -122,6 +122,9 @@ def search_sessions(data: schemas.SessionsSearchPayloadSchema, project: schemas.
|
||||||
sort = 'session_id'
|
sort = 'session_id'
|
||||||
if data.sort is not None and data.sort != "session_id":
|
if data.sort is not None and data.sort != "session_id":
|
||||||
# sort += " " + data.order + "," + helper.key_to_snake_case(data.sort)
|
# sort += " " + data.order + "," + helper.key_to_snake_case(data.sort)
|
||||||
|
if data.sort == 'datetime':
|
||||||
|
sort = 'start_ts'
|
||||||
|
else:
|
||||||
sort = helper.key_to_snake_case(data.sort)
|
sort = helper.key_to_snake_case(data.sort)
|
||||||
|
|
||||||
meta_keys = metadata.get(project_id=project.project_id)
|
meta_keys = metadata.get(project_id=project.project_id)
|
||||||
|
|
@ -11,9 +11,3 @@ if smtp.has_smtp():
|
||||||
logger.info("valid SMTP configuration found")
|
logger.info("valid SMTP configuration found")
|
||||||
else:
|
else:
|
||||||
logger.info("no SMTP configuration found or SMTP validation failed")
|
logger.info("no SMTP configuration found or SMTP validation failed")
|
||||||
|
|
||||||
if config("EXP_CH_DRIVER", cast=bool, default=True):
|
|
||||||
logging.info(">>> Using new CH driver")
|
|
||||||
from . import ch_client_exp as ch_client
|
|
||||||
else:
|
|
||||||
from . import ch_client
|
|
||||||
|
|
|
||||||
|
|
@ -1,73 +1,185 @@
|
||||||
import logging
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from functools import wraps
|
||||||
|
from queue import Queue, Empty
|
||||||
|
|
||||||
import clickhouse_driver
|
import clickhouse_connect
|
||||||
|
from clickhouse_connect.driver.query import QueryContext
|
||||||
from decouple import config
|
from decouple import config
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_CH_CONFIG = {"host": config("ch_host"),
|
||||||
|
"user": config("ch_user", default="default"),
|
||||||
|
"password": config("ch_password", default=""),
|
||||||
|
"port": config("ch_port_http", cast=int),
|
||||||
|
"client_name": config("APP_NAME", default="PY")}
|
||||||
|
CH_CONFIG = dict(_CH_CONFIG)
|
||||||
|
|
||||||
settings = {}
|
settings = {}
|
||||||
if config('ch_timeout', cast=int, default=-1) > 0:
|
if config('ch_timeout', cast=int, default=-1) > 0:
|
||||||
logger.info(f"CH-max_execution_time set to {config('ch_timeout')}s")
|
logging.info(f"CH-max_execution_time set to {config('ch_timeout')}s")
|
||||||
settings = {**settings, "max_execution_time": config('ch_timeout', cast=int)}
|
settings = {**settings, "max_execution_time": config('ch_timeout', cast=int)}
|
||||||
|
|
||||||
if config('ch_receive_timeout', cast=int, default=-1) > 0:
|
if config('ch_receive_timeout', cast=int, default=-1) > 0:
|
||||||
logger.info(f"CH-receive_timeout set to {config('ch_receive_timeout')}s")
|
logging.info(f"CH-receive_timeout set to {config('ch_receive_timeout')}s")
|
||||||
settings = {**settings, "receive_timeout": config('ch_receive_timeout', cast=int)}
|
settings = {**settings, "receive_timeout": config('ch_receive_timeout', cast=int)}
|
||||||
|
|
||||||
|
extra_args = {}
|
||||||
|
if config("CH_COMPRESSION", cast=bool, default=True):
|
||||||
|
extra_args["compression"] = "lz4"
|
||||||
|
|
||||||
|
|
||||||
|
def transform_result(self, original_function):
|
||||||
|
@wraps(original_function)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
if kwargs.get("parameters"):
|
||||||
|
if config("LOCAL_DEV", cast=bool, default=False):
|
||||||
|
logger.debug(self.format(query=kwargs.get("query", ""), parameters=kwargs.get("parameters")))
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
str.encode(self.format(query=kwargs.get("query", ""), parameters=kwargs.get("parameters"))))
|
||||||
|
elif len(args) > 0:
|
||||||
|
if config("LOCAL_DEV", cast=bool, default=False):
|
||||||
|
logger.debug(args[0])
|
||||||
|
else:
|
||||||
|
logger.debug(str.encode(args[0]))
|
||||||
|
result = original_function(*args, **kwargs)
|
||||||
|
if isinstance(result, clickhouse_connect.driver.query.QueryResult):
|
||||||
|
column_names = result.column_names
|
||||||
|
result = result.result_rows
|
||||||
|
result = [dict(zip(column_names, row)) for row in result]
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
class ClickHouseConnectionPool:
|
||||||
|
def __init__(self, min_size, max_size):
|
||||||
|
self.min_size = min_size
|
||||||
|
self.max_size = max_size
|
||||||
|
self.pool = Queue()
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
self.total_connections = 0
|
||||||
|
|
||||||
|
# Initialize the pool with min_size connections
|
||||||
|
for _ in range(self.min_size):
|
||||||
|
client = clickhouse_connect.get_client(**CH_CONFIG,
|
||||||
|
database=config("ch_database", default="default"),
|
||||||
|
settings=settings,
|
||||||
|
**extra_args)
|
||||||
|
self.pool.put(client)
|
||||||
|
self.total_connections += 1
|
||||||
|
|
||||||
|
def get_connection(self):
|
||||||
|
try:
|
||||||
|
# Try to get a connection without blocking
|
||||||
|
client = self.pool.get_nowait()
|
||||||
|
return client
|
||||||
|
except Empty:
|
||||||
|
with self.lock:
|
||||||
|
if self.total_connections < self.max_size:
|
||||||
|
client = clickhouse_connect.get_client(**CH_CONFIG,
|
||||||
|
database=config("ch_database", default="default"),
|
||||||
|
settings=settings,
|
||||||
|
**extra_args)
|
||||||
|
self.total_connections += 1
|
||||||
|
return client
|
||||||
|
# If max_size reached, wait until a connection is available
|
||||||
|
client = self.pool.get()
|
||||||
|
return client
|
||||||
|
|
||||||
|
def release_connection(self, client):
|
||||||
|
self.pool.put(client)
|
||||||
|
|
||||||
|
def close_all(self):
|
||||||
|
with self.lock:
|
||||||
|
while not self.pool.empty():
|
||||||
|
client = self.pool.get()
|
||||||
|
client.close()
|
||||||
|
self.total_connections = 0
|
||||||
|
|
||||||
|
|
||||||
|
CH_pool: ClickHouseConnectionPool = None
|
||||||
|
|
||||||
|
RETRY_MAX = config("CH_RETRY_MAX", cast=int, default=50)
|
||||||
|
RETRY_INTERVAL = config("CH_RETRY_INTERVAL", cast=int, default=2)
|
||||||
|
RETRY = 0
|
||||||
|
|
||||||
|
|
||||||
|
def make_pool():
|
||||||
|
if not config('CH_POOL', cast=bool, default=True):
|
||||||
|
return
|
||||||
|
global CH_pool
|
||||||
|
global RETRY
|
||||||
|
if CH_pool is not None:
|
||||||
|
try:
|
||||||
|
CH_pool.close_all()
|
||||||
|
except Exception as error:
|
||||||
|
logger.error("Error while closing all connexions to CH", exc_info=error)
|
||||||
|
try:
|
||||||
|
CH_pool = ClickHouseConnectionPool(min_size=config("CH_MINCONN", cast=int, default=4),
|
||||||
|
max_size=config("CH_MAXCONN", cast=int, default=8))
|
||||||
|
if CH_pool is not None:
|
||||||
|
logger.info("Connection pool created successfully for CH")
|
||||||
|
except ConnectionError as error:
|
||||||
|
logger.error("Error while connecting to CH", exc_info=error)
|
||||||
|
if RETRY < RETRY_MAX:
|
||||||
|
RETRY += 1
|
||||||
|
logger.info(f"waiting for {RETRY_INTERVAL}s before retry n°{RETRY}")
|
||||||
|
time.sleep(RETRY_INTERVAL)
|
||||||
|
make_pool()
|
||||||
|
else:
|
||||||
|
raise error
|
||||||
|
|
||||||
|
|
||||||
class ClickHouseClient:
|
class ClickHouseClient:
|
||||||
__client = None
|
__client = None
|
||||||
|
|
||||||
def __init__(self, database=None):
|
def __init__(self, database=None):
|
||||||
extra_args = {}
|
if self.__client is None:
|
||||||
if config("CH_COMPRESSION", cast=bool, default=True):
|
if database is not None or not config('CH_POOL', cast=bool, default=True):
|
||||||
extra_args["compression"] = "lz4"
|
self.__client = clickhouse_connect.get_client(**CH_CONFIG,
|
||||||
self.__client = clickhouse_driver.Client(host=config("ch_host"),
|
|
||||||
database=database if database else config("ch_database",
|
database=database if database else config("ch_database",
|
||||||
default="default"),
|
default="default"),
|
||||||
user=config("ch_user", default="default"),
|
|
||||||
password=config("ch_password", default=""),
|
|
||||||
port=config("ch_port", cast=int),
|
|
||||||
settings=settings,
|
settings=settings,
|
||||||
**extra_args) \
|
**extra_args)
|
||||||
if self.__client is None else self.__client
|
|
||||||
|
else:
|
||||||
|
self.__client = CH_pool.get_connection()
|
||||||
|
|
||||||
|
self.__client.execute = transform_result(self, self.__client.query)
|
||||||
|
self.__client.format = self.format
|
||||||
|
|
||||||
def __enter__(self):
|
def __enter__(self):
|
||||||
return self
|
|
||||||
|
|
||||||
def execute(self, query, parameters=None, **args):
|
|
||||||
try:
|
|
||||||
results = self.__client.execute(query=query, params=parameters, with_column_types=True, **args)
|
|
||||||
keys = tuple(x for x, y in results[1])
|
|
||||||
return [dict(zip(keys, i)) for i in results[0]]
|
|
||||||
except Exception as err:
|
|
||||||
logger.error("--------- CH EXCEPTION -----------", exc_info=err)
|
|
||||||
logger.error("--------- CH QUERY EXCEPTION -----------")
|
|
||||||
logger.error(self.format(query=query, parameters=parameters)
|
|
||||||
.replace('\n', '\\n')
|
|
||||||
.replace(' ', ' ')
|
|
||||||
.replace(' ', ' '))
|
|
||||||
logger.error("--------------------")
|
|
||||||
raise err
|
|
||||||
|
|
||||||
def insert(self, query, params=None, **args):
|
|
||||||
return self.__client.execute(query=query, params=params, **args)
|
|
||||||
|
|
||||||
def client(self):
|
|
||||||
return self.__client
|
return self.__client
|
||||||
|
|
||||||
def format(self, query, parameters):
|
def format(self, query, parameters=None):
|
||||||
if parameters is None:
|
if parameters:
|
||||||
|
ctx = QueryContext(query=query, parameters=parameters)
|
||||||
|
return ctx.final_query
|
||||||
return query
|
return query
|
||||||
return self.__client.substitute_params(query, parameters, self.__client.connection.context)
|
|
||||||
|
|
||||||
def __exit__(self, *args):
|
def __exit__(self, *args):
|
||||||
pass
|
if config('CH_POOL', cast=bool, default=True):
|
||||||
|
CH_pool.release_connection(self.__client)
|
||||||
|
else:
|
||||||
|
self.__client.close()
|
||||||
|
|
||||||
|
|
||||||
async def init():
|
async def init():
|
||||||
logger.info(f">CH_POOL:not defined")
|
logger.info(f">use CH_POOL:{config('CH_POOL', default=True)}")
|
||||||
|
if config('CH_POOL', cast=bool, default=True):
|
||||||
|
make_pool()
|
||||||
|
|
||||||
|
|
||||||
async def terminate():
|
async def terminate():
|
||||||
pass
|
global CH_pool
|
||||||
|
if CH_pool is not None:
|
||||||
|
try:
|
||||||
|
CH_pool.close_all()
|
||||||
|
logger.info("Closed all connexions to CH")
|
||||||
|
except Exception as error:
|
||||||
|
logger.error("Error while closing all connexions to CH", exc_info=error)
|
||||||
|
|
|
||||||
|
|
@ -1,177 +0,0 @@
|
||||||
import logging
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from functools import wraps
|
|
||||||
from queue import Queue, Empty
|
|
||||||
|
|
||||||
import clickhouse_connect
|
|
||||||
from clickhouse_connect.driver.query import QueryContext
|
|
||||||
from decouple import config
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
_CH_CONFIG = {"host": config("ch_host"),
|
|
||||||
"user": config("ch_user", default="default"),
|
|
||||||
"password": config("ch_password", default=""),
|
|
||||||
"port": config("ch_port_http", cast=int),
|
|
||||||
"client_name": config("APP_NAME", default="PY")}
|
|
||||||
CH_CONFIG = dict(_CH_CONFIG)
|
|
||||||
|
|
||||||
settings = {}
|
|
||||||
if config('ch_timeout', cast=int, default=-1) > 0:
|
|
||||||
logging.info(f"CH-max_execution_time set to {config('ch_timeout')}s")
|
|
||||||
settings = {**settings, "max_execution_time": config('ch_timeout', cast=int)}
|
|
||||||
|
|
||||||
if config('ch_receive_timeout', cast=int, default=-1) > 0:
|
|
||||||
logging.info(f"CH-receive_timeout set to {config('ch_receive_timeout')}s")
|
|
||||||
settings = {**settings, "receive_timeout": config('ch_receive_timeout', cast=int)}
|
|
||||||
|
|
||||||
extra_args = {}
|
|
||||||
if config("CH_COMPRESSION", cast=bool, default=True):
|
|
||||||
extra_args["compression"] = "lz4"
|
|
||||||
|
|
||||||
|
|
||||||
def transform_result(self, original_function):
|
|
||||||
@wraps(original_function)
|
|
||||||
def wrapper(*args, **kwargs):
|
|
||||||
logger.debug(str.encode(self.format(query=kwargs.get("query", ""), parameters=kwargs.get("parameters"))))
|
|
||||||
result = original_function(*args, **kwargs)
|
|
||||||
if isinstance(result, clickhouse_connect.driver.query.QueryResult):
|
|
||||||
column_names = result.column_names
|
|
||||||
result = result.result_rows
|
|
||||||
result = [dict(zip(column_names, row)) for row in result]
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
|
|
||||||
class ClickHouseConnectionPool:
|
|
||||||
def __init__(self, min_size, max_size):
|
|
||||||
self.min_size = min_size
|
|
||||||
self.max_size = max_size
|
|
||||||
self.pool = Queue()
|
|
||||||
self.lock = threading.Lock()
|
|
||||||
self.total_connections = 0
|
|
||||||
|
|
||||||
# Initialize the pool with min_size connections
|
|
||||||
for _ in range(self.min_size):
|
|
||||||
client = clickhouse_connect.get_client(**CH_CONFIG,
|
|
||||||
database=config("ch_database", default="default"),
|
|
||||||
settings=settings,
|
|
||||||
**extra_args)
|
|
||||||
self.pool.put(client)
|
|
||||||
self.total_connections += 1
|
|
||||||
|
|
||||||
def get_connection(self):
|
|
||||||
try:
|
|
||||||
# Try to get a connection without blocking
|
|
||||||
client = self.pool.get_nowait()
|
|
||||||
return client
|
|
||||||
except Empty:
|
|
||||||
with self.lock:
|
|
||||||
if self.total_connections < self.max_size:
|
|
||||||
client = clickhouse_connect.get_client(**CH_CONFIG,
|
|
||||||
database=config("ch_database", default="default"),
|
|
||||||
settings=settings,
|
|
||||||
**extra_args)
|
|
||||||
self.total_connections += 1
|
|
||||||
return client
|
|
||||||
# If max_size reached, wait until a connection is available
|
|
||||||
client = self.pool.get()
|
|
||||||
return client
|
|
||||||
|
|
||||||
def release_connection(self, client):
|
|
||||||
self.pool.put(client)
|
|
||||||
|
|
||||||
def close_all(self):
|
|
||||||
with self.lock:
|
|
||||||
while not self.pool.empty():
|
|
||||||
client = self.pool.get()
|
|
||||||
client.close()
|
|
||||||
self.total_connections = 0
|
|
||||||
|
|
||||||
|
|
||||||
CH_pool: ClickHouseConnectionPool = None
|
|
||||||
|
|
||||||
RETRY_MAX = config("CH_RETRY_MAX", cast=int, default=50)
|
|
||||||
RETRY_INTERVAL = config("CH_RETRY_INTERVAL", cast=int, default=2)
|
|
||||||
RETRY = 0
|
|
||||||
|
|
||||||
|
|
||||||
def make_pool():
|
|
||||||
if not config('CH_POOL', cast=bool, default=True):
|
|
||||||
return
|
|
||||||
global CH_pool
|
|
||||||
global RETRY
|
|
||||||
if CH_pool is not None:
|
|
||||||
try:
|
|
||||||
CH_pool.close_all()
|
|
||||||
except Exception as error:
|
|
||||||
logger.error("Error while closing all connexions to CH", exc_info=error)
|
|
||||||
try:
|
|
||||||
CH_pool = ClickHouseConnectionPool(min_size=config("CH_MINCONN", cast=int, default=4),
|
|
||||||
max_size=config("CH_MAXCONN", cast=int, default=8))
|
|
||||||
if CH_pool is not None:
|
|
||||||
logger.info("Connection pool created successfully for CH")
|
|
||||||
except ConnectionError as error:
|
|
||||||
logger.error("Error while connecting to CH", exc_info=error)
|
|
||||||
if RETRY < RETRY_MAX:
|
|
||||||
RETRY += 1
|
|
||||||
logger.info(f"waiting for {RETRY_INTERVAL}s before retry n°{RETRY}")
|
|
||||||
time.sleep(RETRY_INTERVAL)
|
|
||||||
make_pool()
|
|
||||||
else:
|
|
||||||
raise error
|
|
||||||
|
|
||||||
|
|
||||||
class ClickHouseClient:
|
|
||||||
__client = None
|
|
||||||
|
|
||||||
def __init__(self, database=None):
|
|
||||||
if self.__client is None:
|
|
||||||
if database is not None or not config('CH_POOL', cast=bool, default=True):
|
|
||||||
self.__client = clickhouse_connect.get_client(**CH_CONFIG,
|
|
||||||
database=database if database else config("ch_database",
|
|
||||||
default="default"),
|
|
||||||
settings=settings,
|
|
||||||
**extra_args)
|
|
||||||
|
|
||||||
else:
|
|
||||||
self.__client = CH_pool.get_connection()
|
|
||||||
|
|
||||||
self.__client.execute = transform_result(self, self.__client.query)
|
|
||||||
self.__client.format = self.format
|
|
||||||
|
|
||||||
def __enter__(self):
|
|
||||||
return self.__client
|
|
||||||
|
|
||||||
def format(self, query, *, parameters=None):
|
|
||||||
if parameters is None:
|
|
||||||
return query
|
|
||||||
return query % {
|
|
||||||
key: f"'{value}'" if isinstance(value, str) else value
|
|
||||||
for key, value in parameters.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
def __exit__(self, *args):
|
|
||||||
if config('CH_POOL', cast=bool, default=True):
|
|
||||||
CH_pool.release_connection(self.__client)
|
|
||||||
else:
|
|
||||||
self.__client.close()
|
|
||||||
|
|
||||||
|
|
||||||
async def init():
|
|
||||||
logger.info(f">use CH_POOL:{config('CH_POOL', default=True)}")
|
|
||||||
if config('CH_POOL', cast=bool, default=True):
|
|
||||||
make_pool()
|
|
||||||
|
|
||||||
|
|
||||||
async def terminate():
|
|
||||||
global CH_pool
|
|
||||||
if CH_pool is not None:
|
|
||||||
try:
|
|
||||||
CH_pool.close_all()
|
|
||||||
logger.info("Closed all connexions to CH")
|
|
||||||
except Exception as error:
|
|
||||||
logger.error("Error while closing all connexions to CH", exc_info=error)
|
|
||||||
|
|
@ -75,3 +75,4 @@ EXP_AUTOCOMPLETE=true
|
||||||
EXP_ALERTS=true
|
EXP_ALERTS=true
|
||||||
EXP_ERRORS_SEARCH=true
|
EXP_ERRORS_SEARCH=true
|
||||||
EXP_METRICS=true
|
EXP_METRICS=true
|
||||||
|
EXP_SESSIONS_SEARCH=true
|
||||||
|
|
@ -1,591 +0,0 @@
|
||||||
-- -- Original Q3
|
|
||||||
-- WITH ranked_events AS (SELECT *
|
|
||||||
-- FROM ranked_events_1736344377403),
|
|
||||||
-- n1 AS (SELECT event_number_in_session,
|
|
||||||
-- event_type,
|
|
||||||
-- e_value,
|
|
||||||
-- next_type,
|
|
||||||
-- next_value,
|
|
||||||
-- COUNT(1) AS sessions_count
|
|
||||||
-- FROM ranked_events
|
|
||||||
-- WHERE event_number_in_session = 1
|
|
||||||
-- AND isNotNull(next_value)
|
|
||||||
-- GROUP BY event_number_in_session, event_type, e_value, next_type, next_value
|
|
||||||
-- ORDER BY sessions_count DESC
|
|
||||||
-- LIMIT 8),
|
|
||||||
-- n2 AS (SELECT *
|
|
||||||
-- FROM (SELECT re.event_number_in_session AS event_number_in_session,
|
|
||||||
-- re.event_type AS event_type,
|
|
||||||
-- re.e_value AS e_value,
|
|
||||||
-- re.next_type AS next_type,
|
|
||||||
-- re.next_value AS next_value,
|
|
||||||
-- COUNT(1) AS sessions_count
|
|
||||||
-- FROM n1
|
|
||||||
-- INNER JOIN ranked_events AS re
|
|
||||||
-- ON (n1.next_value = re.e_value AND n1.next_type = re.event_type)
|
|
||||||
-- WHERE re.event_number_in_session = 2
|
|
||||||
-- GROUP BY re.event_number_in_session, re.event_type, re.e_value, re.next_type,
|
|
||||||
-- re.next_value) AS sub_level
|
|
||||||
-- ORDER BY sessions_count DESC
|
|
||||||
-- LIMIT 8),
|
|
||||||
-- n3 AS (SELECT *
|
|
||||||
-- FROM (SELECT re.event_number_in_session AS event_number_in_session,
|
|
||||||
-- re.event_type AS event_type,
|
|
||||||
-- re.e_value AS e_value,
|
|
||||||
-- re.next_type AS next_type,
|
|
||||||
-- re.next_value AS next_value,
|
|
||||||
-- COUNT(1) AS sessions_count
|
|
||||||
-- FROM n2
|
|
||||||
-- INNER JOIN ranked_events AS re
|
|
||||||
-- ON (n2.next_value = re.e_value AND n2.next_type = re.event_type)
|
|
||||||
-- WHERE re.event_number_in_session = 3
|
|
||||||
-- GROUP BY re.event_number_in_session, re.event_type, re.e_value, re.next_type,
|
|
||||||
-- re.next_value) AS sub_level
|
|
||||||
-- ORDER BY sessions_count DESC
|
|
||||||
-- LIMIT 8),
|
|
||||||
-- n4 AS (SELECT *
|
|
||||||
-- FROM (SELECT re.event_number_in_session AS event_number_in_session,
|
|
||||||
-- re.event_type AS event_type,
|
|
||||||
-- re.e_value AS e_value,
|
|
||||||
-- re.next_type AS next_type,
|
|
||||||
-- re.next_value AS next_value,
|
|
||||||
-- COUNT(1) AS sessions_count
|
|
||||||
-- FROM n3
|
|
||||||
-- INNER JOIN ranked_events AS re
|
|
||||||
-- ON (n3.next_value = re.e_value AND n3.next_type = re.event_type)
|
|
||||||
-- WHERE re.event_number_in_session = 4
|
|
||||||
-- GROUP BY re.event_number_in_session, re.event_type, re.e_value, re.next_type,
|
|
||||||
-- re.next_value) AS sub_level
|
|
||||||
-- ORDER BY sessions_count DESC
|
|
||||||
-- LIMIT 8),
|
|
||||||
-- n5 AS (SELECT *
|
|
||||||
-- FROM (SELECT re.event_number_in_session AS event_number_in_session,
|
|
||||||
-- re.event_type AS event_type,
|
|
||||||
-- re.e_value AS e_value,
|
|
||||||
-- re.next_type AS next_type,
|
|
||||||
-- re.next_value AS next_value,
|
|
||||||
-- COUNT(1) AS sessions_count
|
|
||||||
-- FROM n4
|
|
||||||
-- INNER JOIN ranked_events AS re
|
|
||||||
-- ON (n4.next_value = re.e_value AND n4.next_type = re.event_type)
|
|
||||||
-- WHERE re.event_number_in_session = 5
|
|
||||||
-- GROUP BY re.event_number_in_session, re.event_type, re.e_value, re.next_type,
|
|
||||||
-- re.next_value) AS sub_level
|
|
||||||
-- ORDER BY sessions_count DESC
|
|
||||||
-- LIMIT 8)
|
|
||||||
-- SELECT *
|
|
||||||
-- FROM (SELECT event_number_in_session,
|
|
||||||
-- event_type,
|
|
||||||
-- e_value,
|
|
||||||
-- next_type,
|
|
||||||
-- next_value,
|
|
||||||
-- sessions_count
|
|
||||||
-- FROM n1
|
|
||||||
-- UNION ALL
|
|
||||||
-- SELECT event_number_in_session,
|
|
||||||
-- event_type,
|
|
||||||
-- e_value,
|
|
||||||
-- next_type,
|
|
||||||
-- next_value,
|
|
||||||
-- sessions_count
|
|
||||||
-- FROM n2
|
|
||||||
-- UNION ALL
|
|
||||||
-- SELECT event_number_in_session,
|
|
||||||
-- event_type,
|
|
||||||
-- e_value,
|
|
||||||
-- next_type,
|
|
||||||
-- next_value,
|
|
||||||
-- sessions_count
|
|
||||||
-- FROM n3
|
|
||||||
-- UNION ALL
|
|
||||||
-- SELECT event_number_in_session,
|
|
||||||
-- event_type,
|
|
||||||
-- e_value,
|
|
||||||
-- next_type,
|
|
||||||
-- next_value,
|
|
||||||
-- sessions_count
|
|
||||||
-- FROM n4
|
|
||||||
-- UNION ALL
|
|
||||||
-- SELECT event_number_in_session,
|
|
||||||
-- event_type,
|
|
||||||
-- e_value,
|
|
||||||
-- next_type,
|
|
||||||
-- next_value,
|
|
||||||
-- sessions_count
|
|
||||||
-- FROM n5) AS chart_steps
|
|
||||||
-- ORDER BY event_number_in_session;
|
|
||||||
|
|
||||||
-- Q1
|
|
||||||
-- CREATE TEMPORARY TABLE pre_ranked_events_1736344377403 AS
|
|
||||||
CREATE TABLE pre_ranked_events_1736344377403 ENGINE = Memory AS
|
|
||||||
(WITH initial_event AS (SELECT events.session_id, MIN(datetime) AS start_event_timestamp
|
|
||||||
FROM experimental.events AS events
|
|
||||||
WHERE ((event_type = 'LOCATION' AND (url_path = '/en/deployment/')))
|
|
||||||
AND events.project_id = toUInt16(65)
|
|
||||||
AND events.datetime >= toDateTime(1735599600000 / 1000)
|
|
||||||
AND events.datetime < toDateTime(1736290799999 / 1000)
|
|
||||||
GROUP BY 1),
|
|
||||||
pre_ranked_events AS (SELECT *
|
|
||||||
FROM (SELECT session_id,
|
|
||||||
event_type,
|
|
||||||
datetime,
|
|
||||||
url_path AS e_value,
|
|
||||||
row_number() OVER (PARTITION BY session_id
|
|
||||||
ORDER BY datetime ,
|
|
||||||
message_id ) AS event_number_in_session
|
|
||||||
FROM experimental.events AS events
|
|
||||||
INNER JOIN initial_event ON (events.session_id = initial_event.session_id)
|
|
||||||
WHERE events.project_id = toUInt16(65)
|
|
||||||
AND events.datetime >= toDateTime(1735599600000 / 1000)
|
|
||||||
AND events.datetime < toDateTime(1736290799999 / 1000)
|
|
||||||
AND (events.event_type = 'LOCATION')
|
|
||||||
AND events.datetime >= initial_event.start_event_timestamp
|
|
||||||
) AS full_ranked_events
|
|
||||||
WHERE event_number_in_session <= 5)
|
|
||||||
SELECT *
|
|
||||||
FROM pre_ranked_events);
|
|
||||||
;
|
|
||||||
|
|
||||||
SELECT *
|
|
||||||
FROM pre_ranked_events_1736344377403
|
|
||||||
WHERE event_number_in_session < 3;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
-- ---------Q2-----------
|
|
||||||
-- CREATE TEMPORARY TABLE ranked_events_1736344377403 AS
|
|
||||||
DROP TABLE ranked_events_1736344377403;
|
|
||||||
CREATE TABLE ranked_events_1736344377403 ENGINE = Memory AS
|
|
||||||
(WITH pre_ranked_events AS (SELECT *
|
|
||||||
FROM pre_ranked_events_1736344377403),
|
|
||||||
start_points AS (SELECT DISTINCT session_id
|
|
||||||
FROM pre_ranked_events
|
|
||||||
WHERE ((event_type = 'LOCATION' AND (e_value = '/en/deployment/')))
|
|
||||||
AND pre_ranked_events.event_number_in_session = 1),
|
|
||||||
ranked_events AS (SELECT pre_ranked_events.*,
|
|
||||||
leadInFrame(e_value)
|
|
||||||
OVER (PARTITION BY session_id ORDER BY datetime
|
|
||||||
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS next_value,
|
|
||||||
leadInFrame(toNullable(event_type))
|
|
||||||
OVER (PARTITION BY session_id ORDER BY datetime
|
|
||||||
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS next_type
|
|
||||||
FROM start_points
|
|
||||||
INNER JOIN pre_ranked_events USING (session_id))
|
|
||||||
SELECT *
|
|
||||||
FROM ranked_events);
|
|
||||||
|
|
||||||
|
|
||||||
-- ranked events
|
|
||||||
SELECT event_number_in_session,
|
|
||||||
event_type,
|
|
||||||
e_value,
|
|
||||||
next_type,
|
|
||||||
next_value,
|
|
||||||
COUNT(1) AS sessions_count
|
|
||||||
FROM ranked_events_1736344377403
|
|
||||||
WHERE event_number_in_session = 2
|
|
||||||
-- AND e_value='/en/deployment/deploy-docker/'
|
|
||||||
-- AND next_value NOT IN ('/en/deployment/','/en/plugins/','/en/using-or/')
|
|
||||||
-- AND e_value NOT IN ('/en/deployment/deploy-docker/','/en/getting-started/','/en/deployment/deploy-ubuntu/')
|
|
||||||
AND isNotNull(next_value)
|
|
||||||
GROUP BY event_number_in_session, event_type, e_value, next_type, next_value
|
|
||||||
ORDER BY event_number_in_session, sessions_count DESC;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
SELECT event_number_in_session,
|
|
||||||
event_type,
|
|
||||||
e_value,
|
|
||||||
COUNT(1) AS sessions_count
|
|
||||||
FROM ranked_events_1736344377403
|
|
||||||
WHERE event_number_in_session = 1
|
|
||||||
GROUP BY event_number_in_session, event_type, e_value
|
|
||||||
ORDER BY event_number_in_session, sessions_count DESC;
|
|
||||||
|
|
||||||
SELECT COUNT(1) AS sessions_count
|
|
||||||
FROM ranked_events_1736344377403
|
|
||||||
WHERE event_number_in_session = 2
|
|
||||||
AND isNull(next_value)
|
|
||||||
;
|
|
||||||
|
|
||||||
-- ---------Q3 MORE -----------
|
|
||||||
WITH ranked_events AS (SELECT *
|
|
||||||
FROM ranked_events_1736344377403),
|
|
||||||
n1 AS (SELECT event_number_in_session,
|
|
||||||
event_type,
|
|
||||||
e_value,
|
|
||||||
next_type,
|
|
||||||
next_value,
|
|
||||||
COUNT(1) AS sessions_count
|
|
||||||
FROM ranked_events
|
|
||||||
WHERE event_number_in_session = 1
|
|
||||||
GROUP BY event_number_in_session, event_type, e_value, next_type, next_value
|
|
||||||
ORDER BY sessions_count DESC),
|
|
||||||
n2 AS (SELECT event_number_in_session,
|
|
||||||
event_type,
|
|
||||||
e_value,
|
|
||||||
next_type,
|
|
||||||
next_value,
|
|
||||||
COUNT(1) AS sessions_count
|
|
||||||
FROM ranked_events
|
|
||||||
WHERE event_number_in_session = 2
|
|
||||||
GROUP BY event_number_in_session, event_type, e_value, next_type, next_value
|
|
||||||
ORDER BY sessions_count DESC),
|
|
||||||
n3 AS (SELECT event_number_in_session,
|
|
||||||
event_type,
|
|
||||||
e_value,
|
|
||||||
next_type,
|
|
||||||
next_value,
|
|
||||||
COUNT(1) AS sessions_count
|
|
||||||
FROM ranked_events
|
|
||||||
WHERE event_number_in_session = 3
|
|
||||||
GROUP BY event_number_in_session, event_type, e_value, next_type, next_value
|
|
||||||
ORDER BY sessions_count DESC),
|
|
||||||
drop_n AS (-- STEP 1
|
|
||||||
SELECT event_number_in_session,
|
|
||||||
event_type,
|
|
||||||
e_value,
|
|
||||||
'DROP' AS next_type,
|
|
||||||
NULL AS next_value,
|
|
||||||
sessions_count
|
|
||||||
FROM n1
|
|
||||||
WHERE isNull(n1.next_type)
|
|
||||||
UNION ALL
|
|
||||||
-- STEP 2
|
|
||||||
SELECT event_number_in_session,
|
|
||||||
event_type,
|
|
||||||
e_value,
|
|
||||||
'DROP' AS next_type,
|
|
||||||
NULL AS next_value,
|
|
||||||
sessions_count
|
|
||||||
FROM n2
|
|
||||||
WHERE isNull(n2.next_type)),
|
|
||||||
-- TODO: make this as top_steps, where every step will go to next as top/others
|
|
||||||
top_n1 AS (-- STEP 1
|
|
||||||
SELECT event_number_in_session,
|
|
||||||
event_type,
|
|
||||||
e_value,
|
|
||||||
next_type,
|
|
||||||
next_value,
|
|
||||||
sessions_count
|
|
||||||
FROM n1
|
|
||||||
WHERE isNotNull(next_type)
|
|
||||||
ORDER BY sessions_count DESC
|
|
||||||
LIMIT 3),
|
|
||||||
top_n2 AS (-- STEP 2
|
|
||||||
SELECT event_number_in_session,
|
|
||||||
event_type,
|
|
||||||
e_value,
|
|
||||||
next_type,
|
|
||||||
next_value,
|
|
||||||
sessions_count
|
|
||||||
FROM n2
|
|
||||||
WHERE (event_type, e_value) IN (SELECT event_type,
|
|
||||||
e_value
|
|
||||||
FROM n2
|
|
||||||
WHERE isNotNull(next_type)
|
|
||||||
GROUP BY event_type, e_value
|
|
||||||
ORDER BY SUM(sessions_count) DESC
|
|
||||||
LIMIT 3)
|
|
||||||
ORDER BY sessions_count DESC),
|
|
||||||
top_n AS (SELECT *
|
|
||||||
FROM top_n1
|
|
||||||
UNION ALL
|
|
||||||
SELECT *
|
|
||||||
FROM top_n2),
|
|
||||||
u_top_n AS (SELECT DISTINCT event_number_in_session,
|
|
||||||
event_type,
|
|
||||||
e_value
|
|
||||||
FROM top_n),
|
|
||||||
others_n AS (
|
|
||||||
-- STEP 1
|
|
||||||
SELECT event_number_in_session,
|
|
||||||
event_type,
|
|
||||||
e_value,
|
|
||||||
next_type,
|
|
||||||
next_value,
|
|
||||||
sessions_count
|
|
||||||
FROM n1
|
|
||||||
WHERE isNotNull(next_type)
|
|
||||||
ORDER BY sessions_count DESC
|
|
||||||
LIMIT 1000000 OFFSET 3
|
|
||||||
UNION ALL
|
|
||||||
-- STEP 2
|
|
||||||
SELECT event_number_in_session,
|
|
||||||
event_type,
|
|
||||||
e_value,
|
|
||||||
next_type,
|
|
||||||
next_value,
|
|
||||||
sessions_count
|
|
||||||
FROM n2
|
|
||||||
WHERE isNotNull(next_type)
|
|
||||||
-- GROUP BY event_number_in_session, event_type, e_value
|
|
||||||
ORDER BY sessions_count DESC
|
|
||||||
LIMIT 1000000 OFFSET 3)
|
|
||||||
SELECT *
|
|
||||||
FROM (
|
|
||||||
-- Top
|
|
||||||
SELECT *
|
|
||||||
FROM top_n
|
|
||||||
-- UNION ALL
|
|
||||||
-- -- Others
|
|
||||||
-- SELECT event_number_in_session,
|
|
||||||
-- event_type,
|
|
||||||
-- e_value,
|
|
||||||
-- 'OTHER' AS next_type,
|
|
||||||
-- NULL AS next_value,
|
|
||||||
-- SUM(sessions_count)
|
|
||||||
-- FROM others_n
|
|
||||||
-- GROUP BY event_number_in_session, event_type, e_value
|
|
||||||
-- UNION ALL
|
|
||||||
-- -- Top go to Drop
|
|
||||||
-- SELECT drop_n.event_number_in_session,
|
|
||||||
-- drop_n.event_type,
|
|
||||||
-- drop_n.e_value,
|
|
||||||
-- drop_n.next_type,
|
|
||||||
-- drop_n.next_value,
|
|
||||||
-- drop_n.sessions_count
|
|
||||||
-- FROM drop_n
|
|
||||||
-- INNER JOIN u_top_n ON (drop_n.event_number_in_session = u_top_n.event_number_in_session
|
|
||||||
-- AND drop_n.event_type = u_top_n.event_type
|
|
||||||
-- AND drop_n.e_value = u_top_n.e_value)
|
|
||||||
-- ORDER BY drop_n.event_number_in_session
|
|
||||||
-- -- -- UNION ALL
|
|
||||||
-- -- -- Top go to Others
|
|
||||||
-- SELECT top_n.event_number_in_session,
|
|
||||||
-- top_n.event_type,
|
|
||||||
-- top_n.e_value,
|
|
||||||
-- 'OTHER' AS next_type,
|
|
||||||
-- NULL AS next_value,
|
|
||||||
-- SUM(top_n.sessions_count) AS sessions_count
|
|
||||||
-- FROM top_n
|
|
||||||
-- LEFT JOIN others_n ON (others_n.event_number_in_session = (top_n.event_number_in_session + 1)
|
|
||||||
-- AND top_n.next_type = others_n.event_type
|
|
||||||
-- AND top_n.next_value = others_n.e_value)
|
|
||||||
-- WHERE others_n.event_number_in_session IS NULL
|
|
||||||
-- AND top_n.next_type IS NOT NULL
|
|
||||||
-- GROUP BY event_number_in_session, event_type, e_value
|
|
||||||
-- UNION ALL
|
|
||||||
-- -- Others got to Top
|
|
||||||
-- SELECT others_n.event_number_in_session,
|
|
||||||
-- 'OTHER' AS event_type,
|
|
||||||
-- NULL AS e_value,
|
|
||||||
-- others_n.s_next_type AS next_type,
|
|
||||||
-- others_n.s_next_value AS next_value,
|
|
||||||
-- SUM(sessions_count) AS sessions_count
|
|
||||||
-- FROM others_n
|
|
||||||
-- INNER JOIN top_n ON (others_n.event_number_in_session = top_n.event_number_in_session + 1 AND
|
|
||||||
-- others_n.s_next_type = top_n.event_type AND
|
|
||||||
-- others_n.s_next_value = top_n.event_type)
|
|
||||||
-- GROUP BY others_n.event_number_in_session, next_type, next_value
|
|
||||||
-- UNION ALL
|
|
||||||
-- -- TODO: find if this works or not
|
|
||||||
-- -- Others got to Others
|
|
||||||
-- SELECT others_n.event_number_in_session,
|
|
||||||
-- 'OTHER' AS event_type,
|
|
||||||
-- NULL AS e_value,
|
|
||||||
-- 'OTHERS' AS next_type,
|
|
||||||
-- NULL AS next_value,
|
|
||||||
-- SUM(sessions_count) AS sessions_count
|
|
||||||
-- FROM others_n
|
|
||||||
-- LEFT JOIN u_top_n ON ((others_n.event_number_in_session + 1) = u_top_n.event_number_in_session
|
|
||||||
-- AND others_n.s_next_type = u_top_n.event_type
|
|
||||||
-- AND others_n.s_next_value = u_top_n.e_value)
|
|
||||||
-- WHERE u_top_n.event_number_in_session IS NULL
|
|
||||||
-- GROUP BY others_n.event_number_in_session
|
|
||||||
)
|
|
||||||
ORDER BY event_number_in_session;
|
|
||||||
|
|
||||||
|
|
||||||
-- ---------Q3 TOP ON VALUE ONLY -----------
|
|
||||||
WITH ranked_events AS (SELECT *
|
|
||||||
FROM ranked_events_1736344377403),
|
|
||||||
n1 AS (SELECT event_number_in_session,
|
|
||||||
event_type,
|
|
||||||
e_value,
|
|
||||||
next_type,
|
|
||||||
next_value,
|
|
||||||
COUNT(1) AS sessions_count
|
|
||||||
FROM ranked_events
|
|
||||||
WHERE event_number_in_session = 1
|
|
||||||
GROUP BY event_number_in_session, event_type, e_value, next_type, next_value
|
|
||||||
ORDER BY sessions_count DESC),
|
|
||||||
n2 AS (SELECT event_number_in_session,
|
|
||||||
event_type,
|
|
||||||
e_value,
|
|
||||||
next_type,
|
|
||||||
next_value,
|
|
||||||
COUNT(1) AS sessions_count
|
|
||||||
FROM ranked_events
|
|
||||||
WHERE event_number_in_session = 2
|
|
||||||
GROUP BY event_number_in_session, event_type, e_value, next_type, next_value
|
|
||||||
ORDER BY sessions_count DESC),
|
|
||||||
n3 AS (SELECT event_number_in_session,
|
|
||||||
event_type,
|
|
||||||
e_value,
|
|
||||||
next_type,
|
|
||||||
next_value,
|
|
||||||
COUNT(1) AS sessions_count
|
|
||||||
FROM ranked_events
|
|
||||||
WHERE event_number_in_session = 3
|
|
||||||
GROUP BY event_number_in_session, event_type, e_value, next_type, next_value
|
|
||||||
ORDER BY sessions_count DESC),
|
|
||||||
|
|
||||||
drop_n AS (-- STEP 1
|
|
||||||
SELECT event_number_in_session,
|
|
||||||
event_type,
|
|
||||||
e_value,
|
|
||||||
'DROP' AS next_type,
|
|
||||||
NULL AS next_value,
|
|
||||||
sessions_count
|
|
||||||
FROM n1
|
|
||||||
WHERE isNull(n1.next_type)
|
|
||||||
UNION ALL
|
|
||||||
-- STEP 2
|
|
||||||
SELECT event_number_in_session,
|
|
||||||
event_type,
|
|
||||||
e_value,
|
|
||||||
'DROP' AS next_type,
|
|
||||||
NULL AS next_value,
|
|
||||||
sessions_count
|
|
||||||
FROM n2
|
|
||||||
WHERE isNull(n2.next_type)),
|
|
||||||
top_n AS (SELECT event_number_in_session,
|
|
||||||
event_type,
|
|
||||||
e_value,
|
|
||||||
SUM(sessions_count) AS sessions_count
|
|
||||||
FROM n1
|
|
||||||
GROUP BY event_number_in_session, event_type, e_value
|
|
||||||
LIMIT 1
|
|
||||||
UNION ALL
|
|
||||||
-- STEP 2
|
|
||||||
SELECT event_number_in_session,
|
|
||||||
event_type,
|
|
||||||
e_value,
|
|
||||||
SUM(sessions_count) AS sessions_count
|
|
||||||
FROM n2
|
|
||||||
GROUP BY event_number_in_session, event_type, e_value
|
|
||||||
ORDER BY sessions_count DESC
|
|
||||||
LIMIT 3
|
|
||||||
UNION ALL
|
|
||||||
-- STEP 3
|
|
||||||
SELECT event_number_in_session,
|
|
||||||
event_type,
|
|
||||||
e_value,
|
|
||||||
SUM(sessions_count) AS sessions_count
|
|
||||||
FROM n3
|
|
||||||
GROUP BY event_number_in_session, event_type, e_value
|
|
||||||
ORDER BY sessions_count DESC
|
|
||||||
LIMIT 3),
|
|
||||||
top_n_with_next AS (SELECT n1.*
|
|
||||||
FROM n1
|
|
||||||
UNION ALL
|
|
||||||
SELECT n2.*
|
|
||||||
FROM n2
|
|
||||||
INNER JOIN top_n ON (n2.event_number_in_session = top_n.event_number_in_session
|
|
||||||
AND n2.event_type = top_n.event_type
|
|
||||||
AND n2.e_value = top_n.e_value)),
|
|
||||||
others_n AS (
|
|
||||||
-- STEP 2
|
|
||||||
SELECT n2.*
|
|
||||||
FROM n2
|
|
||||||
WHERE (n2.event_number_in_session, n2.event_type, n2.e_value) NOT IN
|
|
||||||
(SELECT event_number_in_session, event_type, e_value
|
|
||||||
FROM top_n
|
|
||||||
WHERE top_n.event_number_in_session = 2)
|
|
||||||
UNION ALL
|
|
||||||
-- STEP 3
|
|
||||||
SELECT n3.*
|
|
||||||
FROM n3
|
|
||||||
WHERE (n3.event_number_in_session, n3.event_type, n3.e_value) NOT IN
|
|
||||||
(SELECT event_number_in_session, event_type, e_value
|
|
||||||
FROM top_n
|
|
||||||
WHERE top_n.event_number_in_session = 3))
|
|
||||||
SELECT *
|
|
||||||
FROM (
|
|
||||||
-- SELECT sum(top_n_with_next.sessions_count)
|
|
||||||
-- FROM top_n_with_next
|
|
||||||
-- WHERE event_number_in_session = 1
|
|
||||||
-- -- AND isNotNull(next_value)
|
|
||||||
-- AND (next_type, next_value) IN
|
|
||||||
-- (SELECT others_n.event_type, others_n.e_value FROM others_n WHERE others_n.event_number_in_session = 2)
|
|
||||||
-- -- SELECT * FROM others_n
|
|
||||||
-- -- SELECT * FROM n2
|
|
||||||
-- SELECT *
|
|
||||||
-- FROM top_n
|
|
||||||
-- );
|
|
||||||
-- Top to Top: valid
|
|
||||||
SELECT top_n_with_next.*
|
|
||||||
FROM top_n_with_next
|
|
||||||
INNER JOIN top_n
|
|
||||||
ON (top_n_with_next.event_number_in_session + 1 = top_n.event_number_in_session
|
|
||||||
AND top_n_with_next.next_type = top_n.event_type
|
|
||||||
AND top_n_with_next.next_value = top_n.e_value)
|
|
||||||
UNION ALL
|
|
||||||
-- Top to Others: valid
|
|
||||||
SELECT top_n_with_next.event_number_in_session,
|
|
||||||
top_n_with_next.event_type,
|
|
||||||
top_n_with_next.e_value,
|
|
||||||
'OTHER' AS next_type,
|
|
||||||
NULL AS next_value,
|
|
||||||
SUM(top_n_with_next.sessions_count) AS sessions_count
|
|
||||||
FROM top_n_with_next
|
|
||||||
WHERE (top_n_with_next.event_number_in_session + 1, top_n_with_next.next_type, top_n_with_next.next_value) IN
|
|
||||||
(SELECT others_n.event_number_in_session, others_n.event_type, others_n.e_value FROM others_n)
|
|
||||||
GROUP BY top_n_with_next.event_number_in_session, top_n_with_next.event_type, top_n_with_next.e_value
|
|
||||||
UNION ALL
|
|
||||||
-- Top go to Drop: valid
|
|
||||||
SELECT drop_n.event_number_in_session,
|
|
||||||
drop_n.event_type,
|
|
||||||
drop_n.e_value,
|
|
||||||
drop_n.next_type,
|
|
||||||
drop_n.next_value,
|
|
||||||
drop_n.sessions_count
|
|
||||||
FROM drop_n
|
|
||||||
INNER JOIN top_n ON (drop_n.event_number_in_session = top_n.event_number_in_session
|
|
||||||
AND drop_n.event_type = top_n.event_type
|
|
||||||
AND drop_n.e_value = top_n.e_value)
|
|
||||||
ORDER BY drop_n.event_number_in_session
|
|
||||||
UNION ALL
|
|
||||||
-- Others got to Drop: valid
|
|
||||||
SELECT others_n.event_number_in_session,
|
|
||||||
'OTHER' AS event_type,
|
|
||||||
NULL AS e_value,
|
|
||||||
'DROP' AS next_type,
|
|
||||||
NULL AS next_value,
|
|
||||||
SUM(others_n.sessions_count) AS sessions_count
|
|
||||||
FROM others_n
|
|
||||||
WHERE isNull(others_n.next_type)
|
|
||||||
AND others_n.event_number_in_session < 3
|
|
||||||
GROUP BY others_n.event_number_in_session, next_type, next_value
|
|
||||||
UNION ALL
|
|
||||||
-- Others got to Top:valid
|
|
||||||
SELECT others_n.event_number_in_session,
|
|
||||||
'OTHER' AS event_type,
|
|
||||||
NULL AS e_value,
|
|
||||||
others_n.next_type,
|
|
||||||
others_n.next_value,
|
|
||||||
SUM(others_n.sessions_count) AS sessions_count
|
|
||||||
FROM others_n
|
|
||||||
WHERE isNotNull(others_n.next_type)
|
|
||||||
AND (others_n.event_number_in_session + 1, others_n.next_type, others_n.next_value) IN
|
|
||||||
(SELECT top_n.event_number_in_session, top_n.event_type, top_n.e_value FROM top_n)
|
|
||||||
GROUP BY others_n.event_number_in_session, others_n.next_type, others_n.next_value
|
|
||||||
UNION ALL
|
|
||||||
-- Others got to Others
|
|
||||||
SELECT others_n.event_number_in_session,
|
|
||||||
'OTHER' AS event_type,
|
|
||||||
NULL AS e_value,
|
|
||||||
'OTHERS' AS next_type,
|
|
||||||
NULL AS next_value,
|
|
||||||
SUM(sessions_count) AS sessions_count
|
|
||||||
FROM others_n
|
|
||||||
WHERE isNotNull(others_n.next_type)
|
|
||||||
AND others_n.event_number_in_session < 3
|
|
||||||
AND (others_n.event_number_in_session + 1, others_n.next_type, others_n.next_value) NOT IN
|
|
||||||
(SELECT event_number_in_session, event_type, e_value FROM top_n)
|
|
||||||
GROUP BY others_n.event_number_in_session)
|
|
||||||
ORDER BY event_number_in_session, sessions_count
|
|
||||||
DESC;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,16 +1,15 @@
|
||||||
urllib3==2.3.0
|
urllib3==2.3.0
|
||||||
requests==2.32.3
|
requests==2.32.3
|
||||||
boto3==1.36.12
|
boto3==1.37.16
|
||||||
pyjwt==2.10.1
|
pyjwt==2.10.1
|
||||||
psycopg2-binary==2.9.10
|
psycopg2-binary==2.9.10
|
||||||
psycopg[pool,binary]==3.2.4
|
psycopg[pool,binary]==3.2.6
|
||||||
clickhouse-driver[lz4]==0.2.9
|
|
||||||
clickhouse-connect==0.8.15
|
clickhouse-connect==0.8.15
|
||||||
elasticsearch==8.17.1
|
elasticsearch==8.17.2
|
||||||
jira==3.8.0
|
jira==3.8.0
|
||||||
cachetools==5.5.1
|
cachetools==5.5.2
|
||||||
|
|
||||||
fastapi==0.115.8
|
fastapi==0.115.11
|
||||||
uvicorn[standard]==0.34.0
|
uvicorn[standard]==0.34.0
|
||||||
python-decouple==3.8
|
python-decouple==3.8
|
||||||
pydantic[email]==2.10.6
|
pydantic[email]==2.10.6
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,15 @@
|
||||||
urllib3==2.3.0
|
urllib3==2.3.0
|
||||||
requests==2.32.3
|
requests==2.32.3
|
||||||
boto3==1.36.12
|
boto3==1.37.16
|
||||||
pyjwt==2.10.1
|
pyjwt==2.10.1
|
||||||
psycopg2-binary==2.9.10
|
psycopg2-binary==2.9.10
|
||||||
psycopg[pool,binary]==3.2.4
|
psycopg[pool,binary]==3.2.6
|
||||||
clickhouse-driver[lz4]==0.2.9
|
|
||||||
clickhouse-connect==0.8.15
|
clickhouse-connect==0.8.15
|
||||||
elasticsearch==8.17.1
|
elasticsearch==8.17.2
|
||||||
jira==3.8.0
|
jira==3.8.0
|
||||||
cachetools==5.5.1
|
cachetools==5.5.2
|
||||||
|
|
||||||
fastapi==0.115.8
|
fastapi==0.115.11
|
||||||
uvicorn[standard]==0.34.0
|
uvicorn[standard]==0.34.0
|
||||||
python-decouple==3.8
|
python-decouple==3.8
|
||||||
pydantic[email]==2.10.6
|
pydantic[email]==2.10.6
|
||||||
|
|
|
||||||
28
api/routers/subs/product_analytics.py
Normal file
28
api/routers/subs/product_analytics.py
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import schemas
|
||||||
|
from chalicelib.core.product_analytics import events, properties
|
||||||
|
from fastapi import Depends
|
||||||
|
from or_dependencies import OR_context
|
||||||
|
from routers.base import get_routers
|
||||||
|
|
||||||
|
public_app, app, app_apikey = get_routers()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get('/{projectId}/properties/search', tags=["product_analytics"])
|
||||||
|
def get_event_properties(projectId: int, event_name: str = None,
|
||||||
|
context: schemas.CurrentContext = Depends(OR_context)):
|
||||||
|
if not event_name or len(event_name) == 0:
|
||||||
|
return {"data": []}
|
||||||
|
return {"data": properties.get_properties(project_id=projectId, event_name=event_name)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get('/{projectId}/events/names', tags=["dashboard"])
|
||||||
|
def get_all_events(projectId: int,
|
||||||
|
context: schemas.CurrentContext = Depends(OR_context)):
|
||||||
|
return {"data": events.get_events(project_id=projectId)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post('/{projectId}/events/search', tags=["dashboard"])
|
||||||
|
def search_events(projectId: int,
|
||||||
|
# data: schemas.CreateDashboardSchema = Body(...),
|
||||||
|
context: schemas.CurrentContext = Depends(OR_context)):
|
||||||
|
return {"data": events.search_events(project_id=projectId, data={})}
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
import schemas
|
|
||||||
from chalicelib.core.metrics import product_anaytics2
|
|
||||||
from fastapi import Depends
|
|
||||||
from or_dependencies import OR_context
|
|
||||||
from routers.base import get_routers
|
|
||||||
|
|
||||||
|
|
||||||
public_app, app, app_apikey = get_routers()
|
|
||||||
|
|
||||||
|
|
||||||
@app.post('/{projectId}/events/search', tags=["dashboard"])
|
|
||||||
def search_events(projectId: int,
|
|
||||||
# data: schemas.CreateDashboardSchema = Body(...),
|
|
||||||
context: schemas.CurrentContext = Depends(OR_context)):
|
|
||||||
return product_anaytics2.search_events(project_id=projectId, data={})
|
|
||||||
|
|
@ -545,6 +545,70 @@ class RequestGraphqlFilterSchema(BaseModel):
|
||||||
return values
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
class EventPredefinedPropertyType(str, Enum):
|
||||||
|
TIME = "$time"
|
||||||
|
SOURCE = "$source"
|
||||||
|
DURATION_S = "$duration_s"
|
||||||
|
DESCRIPTION = "description"
|
||||||
|
AUTO_CAPTURED = "$auto_captured"
|
||||||
|
SDK_EDITION = "$sdk_edition"
|
||||||
|
SDK_VERSION = "$sdk_version"
|
||||||
|
DEVICE_ID = "$device_id"
|
||||||
|
OS = "$os"
|
||||||
|
OS_VERSION = "$os_version"
|
||||||
|
BROWSER = "$browser"
|
||||||
|
BROWSER_VERSION = "$browser_version"
|
||||||
|
DEVICE = "$device"
|
||||||
|
SCREEN_HEIGHT = "$screen_height"
|
||||||
|
SCREEN_WIDTH = "$screen_width"
|
||||||
|
CURRENT_URL = "$current_url"
|
||||||
|
INITIAL_REFERRER = "$initial_referrer"
|
||||||
|
REFERRING_DOMAIN = "$referring_domain"
|
||||||
|
REFERRER = "$referrer"
|
||||||
|
INITIAL_REFERRING_DOMAIN = "$initial_referring_domain"
|
||||||
|
SEARCH_ENGINE = "$search_engine"
|
||||||
|
SEARCH_ENGINE_KEYWORD = "$search_engine_keyword"
|
||||||
|
UTM_SOURCE = "utm_source"
|
||||||
|
UTM_MEDIUM = "utm_medium"
|
||||||
|
UTM_CAMPAIGN = "utm_campaign"
|
||||||
|
COUNTRY = "$country"
|
||||||
|
STATE = "$state"
|
||||||
|
CITY = "$city"
|
||||||
|
ISSUE_TYPE = "issue_type"
|
||||||
|
TAGS = "$tags"
|
||||||
|
IMPORT = "$import"
|
||||||
|
|
||||||
|
|
||||||
|
class PropertyFilterSchema(BaseModel):
|
||||||
|
name: Union[EventPredefinedPropertyType, str] = Field(...)
|
||||||
|
operator: Union[SearchEventOperator, MathOperator] = Field(...)
|
||||||
|
value: List[Union[int, str]] = Field(...)
|
||||||
|
property_type: Optional[Literal["string", "number", "date"]] = Field(default=None)
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def is_predefined(self) -> bool:
|
||||||
|
return EventPredefinedPropertyType.has_value(self.name)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def transform_name(self):
|
||||||
|
if isinstance(self.name, Enum):
|
||||||
|
self.name = self.name.value
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class EventPropertiesSchema(BaseModel):
|
||||||
|
operators: List[Literal["and", "or"]] = Field(...)
|
||||||
|
filters: List[PropertyFilterSchema] = Field(...)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def event_filter_validator(self):
|
||||||
|
assert len(self.filters) == 0 \
|
||||||
|
or len(self.operators) == len(self.filters) - 1, \
|
||||||
|
"Number of operators must match the number of filter-1"
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class SessionSearchEventSchema2(BaseModel):
|
class SessionSearchEventSchema2(BaseModel):
|
||||||
is_event: Literal[True] = True
|
is_event: Literal[True] = True
|
||||||
value: List[Union[str, int]] = Field(...)
|
value: List[Union[str, int]] = Field(...)
|
||||||
|
|
@ -553,6 +617,7 @@ class SessionSearchEventSchema2(BaseModel):
|
||||||
source: Optional[List[Union[ErrorSource, int, str]]] = Field(default=None)
|
source: Optional[List[Union[ErrorSource, int, str]]] = Field(default=None)
|
||||||
sourceOperator: Optional[MathOperator] = Field(default=None)
|
sourceOperator: Optional[MathOperator] = Field(default=None)
|
||||||
filters: Optional[List[RequestGraphqlFilterSchema]] = Field(default_factory=list)
|
filters: Optional[List[RequestGraphqlFilterSchema]] = Field(default_factory=list)
|
||||||
|
properties: Optional[EventPropertiesSchema] = Field(default=None)
|
||||||
|
|
||||||
_remove_duplicate_values = field_validator('value', mode='before')(remove_duplicate_values)
|
_remove_duplicate_values = field_validator('value', mode='before')(remove_duplicate_values)
|
||||||
_single_to_list_values = field_validator('value', mode='before')(single_to_list)
|
_single_to_list_values = field_validator('value', mode='before')(single_to_list)
|
||||||
|
|
@ -1529,3 +1594,30 @@ class TagCreate(TagUpdate):
|
||||||
|
|
||||||
class ScopeSchema(BaseModel):
|
class ScopeSchema(BaseModel):
|
||||||
scope: int = Field(default=1, ge=1, le=2)
|
scope: int = Field(default=1, ge=1, le=2)
|
||||||
|
|
||||||
|
|
||||||
|
class SessionModel(BaseModel):
|
||||||
|
duration: int
|
||||||
|
errorsCount: int
|
||||||
|
eventsCount: int
|
||||||
|
favorite: bool = Field(default=False)
|
||||||
|
issueScore: int
|
||||||
|
issueTypes: List[IssueType] = Field(default=[])
|
||||||
|
metadata: dict = Field(default={})
|
||||||
|
pagesCount: int
|
||||||
|
platform: str
|
||||||
|
projectId: int
|
||||||
|
sessionId: str
|
||||||
|
startTs: int
|
||||||
|
timezone: Optional[str]
|
||||||
|
userAnonymousId: Optional[str]
|
||||||
|
userBrowser: str
|
||||||
|
userCity: str
|
||||||
|
userCountry: str
|
||||||
|
userDevice: Optional[str]
|
||||||
|
userDeviceType: str
|
||||||
|
userId: Optional[str]
|
||||||
|
userOs: str
|
||||||
|
userState: str
|
||||||
|
userUuid: str
|
||||||
|
viewed: bool = Field(default=False)
|
||||||
|
|
|
||||||
|
|
@ -27,9 +27,14 @@ const respond = function (req, res, data) {
|
||||||
res.setHeader('Content-Type', 'application/json');
|
res.setHeader('Content-Type', 'application/json');
|
||||||
res.end(JSON.stringify(result));
|
res.end(JSON.stringify(result));
|
||||||
} else {
|
} else {
|
||||||
|
if (!res.aborted) {
|
||||||
res.cork(() => {
|
res.cork(() => {
|
||||||
res.writeStatus('200 OK').writeHeader('Content-Type', 'application/json').end(JSON.stringify(result));
|
res.writeStatus('200 OK').writeHeader('Content-Type', 'application/json').end(JSON.stringify(result));
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
logger.debug("response aborted");
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const duration = performance.now() - req.startTs;
|
const duration = performance.now() - req.startTs;
|
||||||
IncreaseTotalRequests();
|
IncreaseTotalRequests();
|
||||||
|
|
|
||||||
|
|
@ -135,11 +135,6 @@ func (e *handlersImpl) startSessionHandlerWeb(w http.ResponseWriter, r *http.Req
|
||||||
|
|
||||||
// Add tracker version to context
|
// Add tracker version to context
|
||||||
r = r.WithContext(context.WithValue(r.Context(), "tracker", req.TrackerVersion))
|
r = r.WithContext(context.WithValue(r.Context(), "tracker", req.TrackerVersion))
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handler's logic
|
// Handler's logic
|
||||||
if req.ProjectKey == nil {
|
if req.ProjectKey == nil {
|
||||||
|
|
@ -162,6 +157,13 @@ func (e *handlersImpl) startSessionHandlerWeb(w http.ResponseWriter, r *http.Req
|
||||||
// Add projectID to context
|
// Add projectID to context
|
||||||
r = r.WithContext(context.WithValue(r.Context(), "projectID", fmt.Sprintf("%d", p.ProjectID)))
|
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
|
// Check if the project supports mobile sessions
|
||||||
if !p.IsWeb() {
|
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)
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusForbidden, errors.New("project doesn't support web sessions"), startTime, r.URL.Path, bodySize)
|
||||||
|
|
|
||||||
7
ee/api/.gitignore
vendored
7
ee/api/.gitignore
vendored
|
|
@ -223,11 +223,14 @@ Pipfile.lock
|
||||||
/chalicelib/core/sessions/performance_event.py
|
/chalicelib/core/sessions/performance_event.py
|
||||||
/chalicelib/core/sessions/sessions_viewed/sessions_viewed.py
|
/chalicelib/core/sessions/sessions_viewed/sessions_viewed.py
|
||||||
/chalicelib/core/sessions/unprocessed_sessions.py
|
/chalicelib/core/sessions/unprocessed_sessions.py
|
||||||
|
/chalicelib/core/sessions/__init__.py
|
||||||
|
/chalicelib/core/sessions/sessions_legacy_mobil.py
|
||||||
|
/chalicelib/core/sessions/sessions_search_exp.py
|
||||||
/chalicelib/core/metrics/modules
|
/chalicelib/core/metrics/modules
|
||||||
/chalicelib/core/socket_ios.py
|
/chalicelib/core/socket_ios.py
|
||||||
/chalicelib/core/sourcemaps.py
|
/chalicelib/core/sourcemaps
|
||||||
/chalicelib/core/sourcemaps_parser.py
|
|
||||||
/chalicelib/core/tags.py
|
/chalicelib/core/tags.py
|
||||||
|
/chalicelib/core/product_analytics
|
||||||
/chalicelib/saml
|
/chalicelib/saml
|
||||||
/chalicelib/utils/__init__.py
|
/chalicelib/utils/__init__.py
|
||||||
/chalicelib/utils/args_transformer.py
|
/chalicelib/utils/args_transformer.py
|
||||||
|
|
|
||||||
|
|
@ -6,23 +6,20 @@ name = "pypi"
|
||||||
[packages]
|
[packages]
|
||||||
urllib3 = "==2.3.0"
|
urllib3 = "==2.3.0"
|
||||||
requests = "==2.32.3"
|
requests = "==2.32.3"
|
||||||
boto3 = "==1.36.12"
|
boto3 = "==1.37.16"
|
||||||
pyjwt = "==2.10.1"
|
pyjwt = "==2.10.1"
|
||||||
psycopg2-binary = "==2.9.10"
|
psycopg2-binary = "==2.9.10"
|
||||||
psycopg = {extras = ["pool", "binary"], version = "==3.2.4"}
|
psycopg = {extras = ["binary", "pool"], version = "==3.2.6"}
|
||||||
clickhouse-driver = {extras = ["lz4"], version = "==0.2.9"}
|
|
||||||
clickhouse-connect = "==0.8.15"
|
clickhouse-connect = "==0.8.15"
|
||||||
elasticsearch = "==8.17.1"
|
elasticsearch = "==8.17.2"
|
||||||
jira = "==3.8.0"
|
jira = "==3.8.0"
|
||||||
cachetools = "==5.5.1"
|
cachetools = "==5.5.2"
|
||||||
fastapi = "==0.115.8"
|
fastapi = "==0.115.11"
|
||||||
uvicorn = {extras = ["standard"], version = "==0.34.0"}
|
uvicorn = {extras = ["standard"], version = "==0.34.0"}
|
||||||
gunicorn = "==23.0.0"
|
gunicorn = "==23.0.0"
|
||||||
python-decouple = "==3.8"
|
python-decouple = "==3.8"
|
||||||
pydantic = {extras = ["email"], version = "==2.10.6"}
|
pydantic = {extras = ["email"], version = "==2.10.6"}
|
||||||
apscheduler = "==3.11.0"
|
apscheduler = "==3.11.0"
|
||||||
python3-saml = "==1.16.0"
|
|
||||||
python-multipart = "==0.0.20"
|
|
||||||
redis = "==5.2.1"
|
redis = "==5.2.1"
|
||||||
azure-storage-blob = "==12.24.1"
|
azure-storage-blob = "==12.24.1"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -150,9 +150,9 @@ app.include_router(spot.public_app)
|
||||||
app.include_router(spot.app)
|
app.include_router(spot.app)
|
||||||
app.include_router(spot.app_apikey)
|
app.include_router(spot.app_apikey)
|
||||||
|
|
||||||
app.include_router(product_anaytics.public_app)
|
app.include_router(product_anaytics.public_app, prefix="/ap")
|
||||||
app.include_router(product_anaytics.app)
|
app.include_router(product_anaytics.app, prefix="/ap")
|
||||||
app.include_router(product_anaytics.app_apikey)
|
app.include_router(product_anaytics.app_apikey, prefix="/ap")
|
||||||
|
|
||||||
if config("ENABLE_SSO", cast=bool, default=True):
|
if config("ENABLE_SSO", cast=bool, default=True):
|
||||||
app.include_router(saml.public_app)
|
app.include_router(saml.public_app)
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
import logging
|
|
||||||
|
|
||||||
from decouple import config
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
from . import sessions_pg
|
|
||||||
from . import sessions_pg as sessions_legacy
|
|
||||||
from . import sessions_ch
|
|
||||||
from . import sessions_search as sessions_search_legacy
|
|
||||||
|
|
||||||
if config("EXP_SESSIONS_SEARCH", cast=bool, default=False):
|
|
||||||
logger.info(">>> Using experimental sessions search")
|
|
||||||
from . import sessions_ch as sessions
|
|
||||||
from . import sessions_search_exp as sessions_search
|
|
||||||
else:
|
|
||||||
from . import sessions_pg as sessions
|
|
||||||
from . import sessions_search as sessions_search
|
|
||||||
|
|
@ -44,12 +44,15 @@ rm -rf ./chalicelib/core/sessions/sessions_search.py
|
||||||
rm -rf ./chalicelib/core/sessions/performance_event.py
|
rm -rf ./chalicelib/core/sessions/performance_event.py
|
||||||
rm -rf ./chalicelib/core/sessions/sessions_viewed/sessions_viewed.py
|
rm -rf ./chalicelib/core/sessions/sessions_viewed/sessions_viewed.py
|
||||||
rm -rf ./chalicelib/core/sessions/unprocessed_sessions.py
|
rm -rf ./chalicelib/core/sessions/unprocessed_sessions.py
|
||||||
|
rm -rf ./chalicelib/core/sessions/__init__.py
|
||||||
|
rm -rf ./chalicelib/core/sessions/sessions_legacy_mobil.py
|
||||||
|
rm -rf ./chalicelib/core/sessions/sessions_search_exp.py
|
||||||
rm -rf ./chalicelib/core/metrics/modules
|
rm -rf ./chalicelib/core/metrics/modules
|
||||||
rm -rf ./chalicelib/core/socket_ios.py
|
rm -rf ./chalicelib/core/socket_ios.py
|
||||||
rm -rf ./chalicelib/core/sourcemaps.py
|
rm -rf ./chalicelib/core/sourcemaps
|
||||||
rm -rf ./chalicelib/core/sourcemaps_parser.py
|
|
||||||
rm -rf ./chalicelib/core/user_testing.py
|
rm -rf ./chalicelib/core/user_testing.py
|
||||||
rm -rf ./chalicelib/core/tags.py
|
rm -rf ./chalicelib/core/tags.py
|
||||||
|
rm -rf ./chalicelib/core/product_analytics
|
||||||
rm -rf ./chalicelib/saml
|
rm -rf ./chalicelib/saml
|
||||||
rm -rf ./chalicelib/utils/__init__.py
|
rm -rf ./chalicelib/utils/__init__.py
|
||||||
rm -rf ./chalicelib/utils/args_transformer.py
|
rm -rf ./chalicelib/utils/args_transformer.py
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,15 @@
|
||||||
urllib3==2.3.0
|
urllib3==2.3.0
|
||||||
requests==2.32.3
|
requests==2.32.3
|
||||||
boto3==1.36.12
|
boto3==1.37.16
|
||||||
pyjwt==2.10.1
|
pyjwt==2.10.1
|
||||||
psycopg2-binary==2.9.10
|
psycopg2-binary==2.9.10
|
||||||
psycopg[pool,binary]==3.2.4
|
psycopg[pool,binary]==3.2.6
|
||||||
clickhouse-driver[lz4]==0.2.9
|
|
||||||
clickhouse-connect==0.8.15
|
clickhouse-connect==0.8.15
|
||||||
elasticsearch==8.17.1
|
elasticsearch==8.17.2
|
||||||
jira==3.8.0
|
jira==3.8.0
|
||||||
cachetools==5.5.1
|
cachetools==5.5.2
|
||||||
|
|
||||||
fastapi==0.115.8
|
fastapi==0.115.11
|
||||||
uvicorn[standard]==0.34.0
|
uvicorn[standard]==0.34.0
|
||||||
python-decouple==3.8
|
python-decouple==3.8
|
||||||
pydantic[email]==2.10.6
|
pydantic[email]==2.10.6
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,15 @@
|
||||||
urllib3==2.3.0
|
urllib3==2.3.0
|
||||||
requests==2.32.3
|
requests==2.32.3
|
||||||
boto3==1.36.12
|
boto3==1.37.16
|
||||||
pyjwt==2.10.1
|
pyjwt==2.10.1
|
||||||
psycopg2-binary==2.9.10
|
psycopg2-binary==2.9.10
|
||||||
psycopg[pool,binary]==3.2.4
|
psycopg[pool,binary]==3.2.6
|
||||||
clickhouse-driver[lz4]==0.2.9
|
|
||||||
clickhouse-connect==0.8.15
|
clickhouse-connect==0.8.15
|
||||||
elasticsearch==8.17.1
|
elasticsearch==8.17.2
|
||||||
jira==3.8.0
|
jira==3.8.0
|
||||||
cachetools==5.5.1
|
cachetools==5.5.2
|
||||||
|
|
||||||
fastapi==0.115.8
|
fastapi==0.115.11
|
||||||
python-decouple==3.8
|
python-decouple==3.8
|
||||||
pydantic[email]==2.10.6
|
pydantic[email]==2.10.6
|
||||||
apscheduler==3.11.0
|
apscheduler==3.11.0
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,15 @@
|
||||||
urllib3==2.3.0
|
urllib3==2.3.0
|
||||||
requests==2.32.3
|
requests==2.32.3
|
||||||
boto3==1.36.12
|
boto3==1.37.16
|
||||||
pyjwt==2.10.1
|
pyjwt==2.10.1
|
||||||
psycopg2-binary==2.9.10
|
psycopg2-binary==2.9.10
|
||||||
psycopg[pool,binary]==3.2.4
|
psycopg[pool,binary]==3.2.6
|
||||||
clickhouse-driver[lz4]==0.2.9
|
|
||||||
clickhouse-connect==0.8.15
|
clickhouse-connect==0.8.15
|
||||||
elasticsearch==8.17.1
|
elasticsearch==8.17.2
|
||||||
jira==3.8.0
|
jira==3.8.0
|
||||||
cachetools==5.5.1
|
cachetools==5.5.2
|
||||||
|
|
||||||
fastapi==0.115.8
|
fastapi==0.115.11
|
||||||
uvicorn[standard]==0.34.0
|
uvicorn[standard]==0.34.0
|
||||||
gunicorn==23.0.0
|
gunicorn==23.0.0
|
||||||
python-decouple==3.8
|
python-decouple==3.8
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ from pydantic import Field, EmailStr, field_validator, model_validator
|
||||||
|
|
||||||
from chalicelib.utils.TimeUTC import TimeUTC
|
from chalicelib.utils.TimeUTC import TimeUTC
|
||||||
from . import schemas
|
from . import schemas
|
||||||
from .overrides import BaseModel, Enum, ORUnion
|
from .overrides import BaseModel, Enum
|
||||||
from .transformers_validators import remove_whitespace
|
from .transformers_validators import remove_whitespace
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -91,33 +91,6 @@ class TrailSearchPayloadSchema(schemas._PaginatedSchema):
|
||||||
return values
|
return values
|
||||||
|
|
||||||
|
|
||||||
class SessionModel(BaseModel):
|
|
||||||
duration: int
|
|
||||||
errorsCount: int
|
|
||||||
eventsCount: int
|
|
||||||
favorite: bool = Field(default=False)
|
|
||||||
issueScore: int
|
|
||||||
issueTypes: List[schemas.IssueType] = Field(default=[])
|
|
||||||
metadata: dict = Field(default={})
|
|
||||||
pagesCount: int
|
|
||||||
platform: str
|
|
||||||
projectId: int
|
|
||||||
sessionId: str
|
|
||||||
startTs: int
|
|
||||||
timezone: Optional[str]
|
|
||||||
userAnonymousId: Optional[str]
|
|
||||||
userBrowser: str
|
|
||||||
userCity: str
|
|
||||||
userCountry: str
|
|
||||||
userDevice: Optional[str]
|
|
||||||
userDeviceType: str
|
|
||||||
userId: Optional[str]
|
|
||||||
userOs: str
|
|
||||||
userState: str
|
|
||||||
userUuid: str
|
|
||||||
viewed: bool = Field(default=False)
|
|
||||||
|
|
||||||
|
|
||||||
class AssistRecordUpdatePayloadSchema(BaseModel):
|
class AssistRecordUpdatePayloadSchema(BaseModel):
|
||||||
name: str = Field(..., min_length=1)
|
name: str = Field(..., min_length=1)
|
||||||
_transform_name = field_validator('name', mode="before")(remove_whitespace)
|
_transform_name = field_validator('name', mode="before")(remove_whitespace)
|
||||||
|
|
|
||||||
|
|
@ -83,9 +83,11 @@ if (process.env.uws !== "true") {
|
||||||
const uWrapper = function (fn) {
|
const uWrapper = function (fn) {
|
||||||
return (res, req) => {
|
return (res, req) => {
|
||||||
res.id = 1;
|
res.id = 1;
|
||||||
|
res.aborted = false;
|
||||||
req.startTs = performance.now(); // track request's start timestamp
|
req.startTs = performance.now(); // track request's start timestamp
|
||||||
req.method = req.getMethod();
|
req.method = req.getMethod();
|
||||||
res.onAborted(() => {
|
res.onAborted(() => {
|
||||||
|
res.aborted = true;
|
||||||
onAbortedOrFinishedResponse(res);
|
onAbortedOrFinishedResponse(res);
|
||||||
});
|
});
|
||||||
return fn(req, res);
|
return fn(req, res);
|
||||||
|
|
|
||||||
|
|
@ -3,20 +3,50 @@ const {getCompressionConfig} = require("./helper");
|
||||||
const {logger} = require('./logger');
|
const {logger} = require('./logger');
|
||||||
|
|
||||||
let io;
|
let io;
|
||||||
|
const getServer = function () {return io;}
|
||||||
|
|
||||||
const getServer = function () {
|
const useRedis = process.env.redis === "true";
|
||||||
return io;
|
let inMemorySocketsCache = [];
|
||||||
|
let lastCacheUpdateTime = 0;
|
||||||
|
const CACHE_REFRESH_INTERVAL = parseInt(process.env.cacheRefreshInterval) || 5000;
|
||||||
|
|
||||||
|
const doFetchAllSockets = async function () {
|
||||||
|
if (useRedis) {
|
||||||
|
const now = Date.now();
|
||||||
|
logger.info(`Using in-memory cache (age: ${now - lastCacheUpdateTime}ms)`);
|
||||||
|
return inMemorySocketsCache;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
return await io.fetchSockets();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error fetching sockets:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let redisClient;
|
// Background refresher that runs independently of requests
|
||||||
const useRedis = process.env.redis === "true";
|
let cacheRefresher = null;
|
||||||
|
function startCacheRefresher() {
|
||||||
|
if (cacheRefresher) clearInterval(cacheRefresher);
|
||||||
|
|
||||||
if (useRedis) {
|
cacheRefresher = setInterval(async () => {
|
||||||
const {createClient} = require("redis");
|
const now = Date.now();
|
||||||
const REDIS_URL = (process.env.REDIS_URL || "localhost:6379").replace(/((^\w+:|^)\/\/|^)/, 'redis://');
|
// Only refresh if cache is stale
|
||||||
redisClient = createClient({url: REDIS_URL});
|
if (now - lastCacheUpdateTime >= CACHE_REFRESH_INTERVAL) {
|
||||||
redisClient.on("error", (error) => logger.error(`Redis error : ${error}`));
|
logger.debug('Background refresh triggered');
|
||||||
void redisClient.connect();
|
try {
|
||||||
|
const startTime = performance.now();
|
||||||
|
const result = await io.fetchSockets();
|
||||||
|
inMemorySocketsCache = result;
|
||||||
|
lastCacheUpdateTime = now;
|
||||||
|
const duration = performance.now() - startTime;
|
||||||
|
logger.info(`Background refresh complete: ${duration}ms, ${result.length} sockets`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Background refresh error: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, CACHE_REFRESH_INTERVAL / 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
const processSocketsList = function (sockets) {
|
const processSocketsList = function (sockets) {
|
||||||
|
|
@ -28,24 +58,6 @@ const processSocketsList = function (sockets) {
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
const doFetchAllSockets = async function () {
|
|
||||||
if (useRedis) {
|
|
||||||
try {
|
|
||||||
let cachedResult = await redisClient.get('fetchSocketsResult');
|
|
||||||
if (cachedResult) {
|
|
||||||
return JSON.parse(cachedResult);
|
|
||||||
}
|
|
||||||
let result = await io.fetchSockets();
|
|
||||||
let cachedString = JSON.stringify(processSocketsList(result));
|
|
||||||
await redisClient.set('fetchSocketsResult', cachedString, {EX: 5});
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error setting value with expiration:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return await io.fetchSockets();
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchSockets = async function (roomID) {
|
const fetchSockets = async function (roomID) {
|
||||||
if (!io) {
|
if (!io) {
|
||||||
return [];
|
return [];
|
||||||
|
|
@ -84,6 +96,7 @@ const createSocketIOServer = function (server, prefix) {
|
||||||
});
|
});
|
||||||
io.attachApp(server);
|
io.attachApp(server);
|
||||||
}
|
}
|
||||||
|
startCacheRefresher();
|
||||||
return io;
|
return io;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
13
ee/scripts/schema/db/init_dbs/clickhouse/1.23.0/1.23.0.sql
Normal file
13
ee/scripts/schema/db/init_dbs/clickhouse/1.23.0/1.23.0.sql
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
CREATE OR REPLACE FUNCTION openreplay_version AS() -> 'v1.23.0-ee';
|
||||||
|
|
||||||
|
|
||||||
|
-- The full list of event-properties (used to tell which property belongs to which event)
|
||||||
|
CREATE TABLE IF NOT EXISTS product_analytics.event_properties
|
||||||
|
(
|
||||||
|
project_id UInt16,
|
||||||
|
event_name String,
|
||||||
|
property_name String,
|
||||||
|
|
||||||
|
_timestamp DateTime DEFAULT now()
|
||||||
|
) ENGINE = ReplacingMergeTree(_timestamp)
|
||||||
|
ORDER BY (project_id, event_name, property_name);
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
CREATE OR REPLACE FUNCTION openreplay_version AS() -> 'v1.22.0-ee';
|
CREATE OR REPLACE FUNCTION openreplay_version AS() -> 'v1.23.0-ee';
|
||||||
CREATE DATABASE IF NOT EXISTS experimental;
|
CREATE DATABASE IF NOT EXISTS experimental;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS experimental.autocomplete
|
CREATE TABLE IF NOT EXISTS experimental.autocomplete
|
||||||
|
|
@ -654,6 +654,17 @@ CREATE TABLE IF NOT EXISTS product_analytics.all_events
|
||||||
ORDER BY (project_id, event_name);
|
ORDER BY (project_id, event_name);
|
||||||
|
|
||||||
|
|
||||||
|
-- The full list of event-properties (used to tell which property belongs to which event)
|
||||||
|
CREATE TABLE IF NOT EXISTS product_analytics.event_properties
|
||||||
|
(
|
||||||
|
project_id UInt16,
|
||||||
|
event_name String,
|
||||||
|
property_name String,
|
||||||
|
|
||||||
|
_timestamp DateTime DEFAULT now()
|
||||||
|
) ENGINE = ReplacingMergeTree(_timestamp)
|
||||||
|
ORDER BY (project_id, event_name, property_name);
|
||||||
|
|
||||||
-- The full list of properties (events and users)
|
-- The full list of properties (events and users)
|
||||||
CREATE TABLE IF NOT EXISTS product_analytics.all_properties
|
CREATE TABLE IF NOT EXISTS product_analytics.all_properties
|
||||||
(
|
(
|
||||||
|
|
|
||||||
30
ee/scripts/schema/db/init_dbs/postgresql/1.23.0/1.23.0.sql
Normal file
30
ee/scripts/schema/db/init_dbs/postgresql/1.23.0/1.23.0.sql
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
\set previous_version 'v1.22.0-ee'
|
||||||
|
\set next_version 'v1.23.0-ee'
|
||||||
|
SELECT openreplay_version() AS current_version,
|
||||||
|
openreplay_version() = :'previous_version' AS valid_previous,
|
||||||
|
openreplay_version() = :'next_version' AS is_next
|
||||||
|
\gset
|
||||||
|
|
||||||
|
\if :valid_previous
|
||||||
|
\echo valid previous DB version :'previous_version', starting DB upgrade to :'next_version'
|
||||||
|
BEGIN;
|
||||||
|
SELECT format($fn_def$
|
||||||
|
CREATE OR REPLACE FUNCTION openreplay_version()
|
||||||
|
RETURNS text AS
|
||||||
|
$$
|
||||||
|
SELECT '%1$s'
|
||||||
|
$$ LANGUAGE sql IMMUTABLE;
|
||||||
|
$fn_def$, :'next_version')
|
||||||
|
\gexec
|
||||||
|
|
||||||
|
--
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
\elif :is_next
|
||||||
|
\echo new version detected :'next_version', nothing to do
|
||||||
|
\else
|
||||||
|
\warn skipping DB upgrade of :'next_version', expected previous version :'previous_version', found :'current_version'
|
||||||
|
\endif
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
\set or_version 'v1.22.0-ee'
|
\set or_version 'v1.23.0-ee'
|
||||||
SET client_min_messages TO NOTICE;
|
SET client_min_messages TO NOTICE;
|
||||||
\set ON_ERROR_STOP true
|
\set ON_ERROR_STOP true
|
||||||
SELECT EXISTS (SELECT 1
|
SELECT EXISTS (SELECT 1
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
CREATE OR REPLACE FUNCTION openreplay_version AS() -> 'v1.22.0-ee';
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS product_analytics.event_properties;
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
\set previous_version 'v1.23.0-ee'
|
||||||
|
\set next_version 'v1.22.0-ee'
|
||||||
|
SELECT openreplay_version() AS current_version,
|
||||||
|
openreplay_version() = :'previous_version' AS valid_previous,
|
||||||
|
openreplay_version() = :'next_version' AS is_next
|
||||||
|
\gset
|
||||||
|
|
||||||
|
\if :valid_previous
|
||||||
|
\echo valid previous DB version :'previous_version', starting DB downgrade to :'next_version'
|
||||||
|
BEGIN;
|
||||||
|
SELECT format($fn_def$
|
||||||
|
CREATE OR REPLACE FUNCTION openreplay_version()
|
||||||
|
RETURNS text AS
|
||||||
|
$$
|
||||||
|
SELECT '%1$s'
|
||||||
|
$$ LANGUAGE sql IMMUTABLE;
|
||||||
|
$fn_def$, :'next_version')
|
||||||
|
\gexec
|
||||||
|
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
\elif :is_next
|
||||||
|
\echo new version detected :'next_version', nothing to do
|
||||||
|
\else
|
||||||
|
\warn skipping DB downgrade of :'next_version', expected previous version :'previous_version', found :'current_version'
|
||||||
|
\endif
|
||||||
|
|
@ -82,7 +82,7 @@ function AssistActions({ userId, isCallActive, agentIds }: Props) {
|
||||||
{ stream: MediaStream; isAgent: boolean }[] | null
|
{ stream: MediaStream; isAgent: boolean }[] | null
|
||||||
>([]);
|
>([]);
|
||||||
const [localStream, setLocalStream] = useState<LocalStream | null>(null);
|
const [localStream, setLocalStream] = useState<LocalStream | null>(null);
|
||||||
const [callObject, setCallObject] = useState<{ end: () => void } | null>(
|
const [callObject, setCallObject] = useState<{ end: () => void } | null | undefined>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -135,6 +135,7 @@ function AssistActions({ userId, isCallActive, agentIds }: Props) {
|
||||||
}, [peerConnectionStatus]);
|
}, [peerConnectionStatus]);
|
||||||
|
|
||||||
const addIncomeStream = (stream: MediaStream, isAgent: boolean) => {
|
const addIncomeStream = (stream: MediaStream, isAgent: boolean) => {
|
||||||
|
if (!stream.active) return;
|
||||||
setIncomeStream((oldState) => {
|
setIncomeStream((oldState) => {
|
||||||
if (oldState === null) return [{ stream, isAgent }];
|
if (oldState === null) return [{ stream, isAgent }];
|
||||||
if (
|
if (
|
||||||
|
|
@ -149,13 +150,8 @@ function AssistActions({ userId, isCallActive, agentIds }: Props) {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeIncomeStream = (stream: MediaStream) => {
|
const removeIncomeStream = () => {
|
||||||
setIncomeStream((prevState) => {
|
setIncomeStream([]);
|
||||||
if (!prevState) return [];
|
|
||||||
return prevState.filter(
|
|
||||||
(existingStream) => existingStream.stream.id !== stream.id,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function onReject() {
|
function onReject() {
|
||||||
|
|
@ -181,7 +177,12 @@ function AssistActions({ userId, isCallActive, agentIds }: Props) {
|
||||||
() => {
|
() => {
|
||||||
player.assistManager.ping(AssistActionsPing.call.end, agentId);
|
player.assistManager.ping(AssistActionsPing.call.end, agentId);
|
||||||
lStream.stop.apply(lStream);
|
lStream.stop.apply(lStream);
|
||||||
removeIncomeStream(lStream.stream);
|
removeIncomeStream();
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
player.assistManager.ping(AssistActionsPing.call.end, agentId);
|
||||||
|
lStream.stop.apply(lStream);
|
||||||
|
removeIncomeStream();
|
||||||
},
|
},
|
||||||
onReject,
|
onReject,
|
||||||
onError,
|
onError,
|
||||||
|
|
|
||||||
|
|
@ -34,43 +34,40 @@ function VideoContainer({
|
||||||
}
|
}
|
||||||
const iid = setInterval(() => {
|
const iid = setInterval(() => {
|
||||||
const track = stream.getVideoTracks()[0];
|
const track = stream.getVideoTracks()[0];
|
||||||
const settings = track?.getSettings();
|
|
||||||
const isDummyVideoTrack = settings
|
|
||||||
? settings.width === 2 ||
|
|
||||||
settings.frameRate === 0 ||
|
|
||||||
(!settings.frameRate && !settings.width)
|
|
||||||
: true;
|
|
||||||
const shouldBeEnabled = track.enabled && !isDummyVideoTrack;
|
|
||||||
|
|
||||||
if (isEnabled !== shouldBeEnabled) {
|
if (track) {
|
||||||
setEnabled(shouldBeEnabled);
|
if (!track.enabled) {
|
||||||
setRemoteEnabled?.(shouldBeEnabled);
|
setEnabled(false);
|
||||||
|
setRemoteEnabled?.(false);
|
||||||
|
} else {
|
||||||
|
setEnabled(true);
|
||||||
|
setRemoteEnabled?.(true);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setEnabled(false);
|
||||||
|
setRemoteEnabled?.(false);
|
||||||
}
|
}
|
||||||
}, 500);
|
}, 500);
|
||||||
return () => clearInterval(iid);
|
return () => clearInterval(iid);
|
||||||
}, [stream, isEnabled]);
|
}, [stream]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
style={{
|
style={{
|
||||||
display: isEnabled ? undefined : 'none',
|
|
||||||
width: isEnabled ? undefined : '0px!important',
|
width: isEnabled ? undefined : '0px!important',
|
||||||
height: isEnabled ? undefined : '0px!important',
|
height: isEnabled ? undefined : '0px !important',
|
||||||
border: '1px solid grey',
|
border: '1px solid grey',
|
||||||
transform: local ? 'scaleX(-1)' : undefined,
|
transform: local ? 'scaleX(-1)' : undefined,
|
||||||
|
display: isEnabled ? 'block' : 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<video autoPlay ref={ref} muted={muted} style={{ height }} />
|
<video
|
||||||
{isAgent ? (
|
autoPlay
|
||||||
<div
|
ref={ref}
|
||||||
style={{
|
muted={muted}
|
||||||
position: 'absolute',
|
style={{ height }}
|
||||||
}}
|
/>
|
||||||
>
|
|
||||||
{t('Agent')}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,10 @@ function ProfilerDoc() {
|
||||||
? sites.find((site) => site.id === siteId)?.projectKey
|
? sites.find((site) => site.id === siteId)?.projectKey
|
||||||
: sites[0]?.projectKey;
|
: sites[0]?.projectKey;
|
||||||
|
|
||||||
const usage = `import OpenReplay from '@openreplay/tracker';
|
const usage = `import { tracker } from '@openreplay/tracker';
|
||||||
import trackerProfiler from '@openreplay/tracker-profiler';
|
import trackerProfiler from '@openreplay/tracker-profiler';
|
||||||
//...
|
//...
|
||||||
const tracker = new OpenReplay({
|
tracker.configure({
|
||||||
projectKey: '${projectKey}'
|
projectKey: '${projectKey}'
|
||||||
});
|
});
|
||||||
tracker.start()
|
tracker.start()
|
||||||
|
|
@ -29,10 +29,12 @@ export const profiler = tracker.use(trackerProfiler());
|
||||||
const fn = profiler('call_name')(() => {
|
const fn = profiler('call_name')(() => {
|
||||||
//...
|
//...
|
||||||
}, thisArg); // thisArg is optional`;
|
}, thisArg); // thisArg is optional`;
|
||||||
const usageCjs = `import OpenReplay from '@openreplay/tracker/cjs';
|
const usageCjs = `import { tracker } from '@openreplay/tracker/cjs';
|
||||||
|
// alternatively you can use dynamic import without /cjs suffix to prevent issues with window scope
|
||||||
|
|
||||||
import trackerProfiler from '@openreplay/tracker-profiler/cjs';
|
import trackerProfiler from '@openreplay/tracker-profiler/cjs';
|
||||||
//...
|
//...
|
||||||
const tracker = new OpenReplay({
|
tracker.configure({
|
||||||
projectKey: '${projectKey}'
|
projectKey: '${projectKey}'
|
||||||
});
|
});
|
||||||
//...
|
//...
|
||||||
|
|
|
||||||
|
|
@ -7,17 +7,19 @@ import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
function AssistNpm(props) {
|
function AssistNpm(props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const usage = `import OpenReplay from '@openreplay/tracker';
|
const usage = `import { tracker } from '@openreplay/tracker';
|
||||||
import trackerAssist from '@openreplay/tracker-assist';
|
import trackerAssist from '@openreplay/tracker-assist';
|
||||||
const tracker = new OpenReplay({
|
tracker.configure({
|
||||||
projectKey: '${props.projectKey}',
|
projectKey: '${props.projectKey}',
|
||||||
});
|
});
|
||||||
tracker.start()
|
tracker.start()
|
||||||
|
|
||||||
tracker.use(trackerAssist(options)); // check the list of available options below`;
|
tracker.use(trackerAssist(options)); // check the list of available options below`;
|
||||||
const usageCjs = `import OpenReplay from '@openreplay/tracker/cjs';
|
const usageCjs = `import { tracker } from '@openreplay/tracker/cjs';
|
||||||
|
// alternatively you can use dynamic import without /cjs suffix to prevent issues with window scope
|
||||||
import trackerAssist from '@openreplay/tracker-assist/cjs';
|
import trackerAssist from '@openreplay/tracker-assist/cjs';
|
||||||
const tracker = new OpenReplay({
|
|
||||||
|
tracker.configure({
|
||||||
projectKey: '${props.projectKey}'
|
projectKey: '${props.projectKey}'
|
||||||
});
|
});
|
||||||
const trackerAssist = tracker.use(trackerAssist(options)); // check the list of available options below
|
const trackerAssist = tracker.use(trackerAssist(options)); // check the list of available options below
|
||||||
|
|
|
||||||
|
|
@ -14,19 +14,20 @@ function GraphQLDoc() {
|
||||||
const projectKey = siteId
|
const projectKey = siteId
|
||||||
? sites.find((site) => site.id === siteId)?.projectKey
|
? sites.find((site) => site.id === siteId)?.projectKey
|
||||||
: sites[0]?.projectKey;
|
: sites[0]?.projectKey;
|
||||||
const usage = `import OpenReplay from '@openreplay/tracker';
|
const usage = `import { tracker } from '@openreplay/tracker';
|
||||||
import trackerGraphQL from '@openreplay/tracker-graphql';
|
import trackerGraphQL from '@openreplay/tracker-graphql';
|
||||||
//...
|
//...
|
||||||
const tracker = new OpenReplay({
|
tracker.configure({
|
||||||
projectKey: '${projectKey}'
|
projectKey: '${projectKey}'
|
||||||
});
|
});
|
||||||
tracker.start()
|
tracker.start()
|
||||||
//...
|
//...
|
||||||
export const recordGraphQL = tracker.use(trackerGraphQL());`;
|
export const recordGraphQL = tracker.use(trackerGraphQL());`;
|
||||||
const usageCjs = `import OpenReplay from '@openreplay/tracker/cjs';
|
const usageCjs = `import { tracker } from '@openreplay/tracker/cjs';
|
||||||
|
// alternatively you can use dynamic import without /cjs suffix to prevent issues with window scope
|
||||||
import trackerGraphQL from '@openreplay/tracker-graphql/cjs';
|
import trackerGraphQL from '@openreplay/tracker-graphql/cjs';
|
||||||
//...
|
//...
|
||||||
const tracker = new OpenReplay({
|
tracker.configure({
|
||||||
projectKey: '${projectKey}'
|
projectKey: '${projectKey}'
|
||||||
});
|
});
|
||||||
//...
|
//...
|
||||||
|
|
|
||||||
|
|
@ -15,20 +15,21 @@ function MobxDoc() {
|
||||||
? sites.find((site) => site.id === siteId)?.projectKey
|
? sites.find((site) => site.id === siteId)?.projectKey
|
||||||
: sites[0]?.projectKey;
|
: sites[0]?.projectKey;
|
||||||
|
|
||||||
const mobxUsage = `import OpenReplay from '@openreplay/tracker';
|
const mobxUsage = `import { tracker } from '@openreplay/tracker';
|
||||||
import trackerMobX from '@openreplay/tracker-mobx';
|
import trackerMobX from '@openreplay/tracker-mobx';
|
||||||
//...
|
//...
|
||||||
const tracker = new OpenReplay({
|
tracker.configure({
|
||||||
projectKey: '${projectKey}'
|
projectKey: '${projectKey}'
|
||||||
});
|
});
|
||||||
tracker.use(trackerMobX(<options>)); // check list of available options below
|
tracker.use(trackerMobX(<options>)); // check list of available options below
|
||||||
tracker.start();
|
tracker.start();
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const mobxUsageCjs = `import OpenReplay from '@openreplay/tracker/cjs';
|
const mobxUsageCjs = `import { tracker } from '@openreplay/tracker/cjs';
|
||||||
|
// alternatively you can use dynamic import without /cjs suffix to prevent issues with window scope
|
||||||
import trackerMobX from '@openreplay/tracker-mobx/cjs';
|
import trackerMobX from '@openreplay/tracker-mobx/cjs';
|
||||||
//...
|
//...
|
||||||
const tracker = new OpenReplay({
|
tracker.configure({
|
||||||
projectKey: '${projectKey}'
|
projectKey: '${projectKey}'
|
||||||
});
|
});
|
||||||
tracker.use(trackerMobX(<options>)); // check list of available options below
|
tracker.use(trackerMobX(<options>)); // check list of available options below
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,10 @@ function NgRxDoc() {
|
||||||
: sites[0]?.projectKey;
|
: sites[0]?.projectKey;
|
||||||
const usage = `import { StoreModule } from '@ngrx/store';
|
const usage = `import { StoreModule } from '@ngrx/store';
|
||||||
import { reducers } from './reducers';
|
import { reducers } from './reducers';
|
||||||
import OpenReplay from '@openreplay/tracker';
|
import { tracker } from '@openreplay/tracker';
|
||||||
import trackerNgRx from '@openreplay/tracker-ngrx';
|
import trackerNgRx from '@openreplay/tracker-ngrx';
|
||||||
//...
|
//...
|
||||||
const tracker = new OpenReplay({
|
tracker.configure({
|
||||||
projectKey: '${projectKey}'
|
projectKey: '${projectKey}'
|
||||||
});
|
});
|
||||||
tracker.start()
|
tracker.start()
|
||||||
|
|
@ -32,10 +32,11 @@ const metaReducers = [tracker.use(trackerNgRx(<options>))]; // check list of ava
|
||||||
export class AppModule {}`;
|
export class AppModule {}`;
|
||||||
const usageCjs = `import { StoreModule } from '@ngrx/store';
|
const usageCjs = `import { StoreModule } from '@ngrx/store';
|
||||||
import { reducers } from './reducers';
|
import { reducers } from './reducers';
|
||||||
import OpenReplay from '@openreplay/tracker/cjs';
|
import { tracker } from '@openreplay/tracker/cjs';
|
||||||
|
// alternatively you can use dynamic import without /cjs suffix to prevent issues with window scope
|
||||||
import trackerNgRx from '@openreplay/tracker-ngrx/cjs';
|
import trackerNgRx from '@openreplay/tracker-ngrx/cjs';
|
||||||
//...
|
//...
|
||||||
const tracker = new OpenReplay({
|
tracker.configure({
|
||||||
projectKey: '${projectKey}'
|
projectKey: '${projectKey}'
|
||||||
});
|
});
|
||||||
//...
|
//...
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,10 @@ function PiniaDoc() {
|
||||||
? sites.find((site) => site.id === siteId)?.projectKey
|
? sites.find((site) => site.id === siteId)?.projectKey
|
||||||
: sites[0]?.projectKey;
|
: sites[0]?.projectKey;
|
||||||
const usage = `import Vuex from 'vuex'
|
const usage = `import Vuex from 'vuex'
|
||||||
import OpenReplay from '@openreplay/tracker';
|
import { tracker } from '@openreplay/tracker';
|
||||||
import trackerVuex from '@openreplay/tracker-vuex';
|
import trackerVuex from '@openreplay/tracker-vuex';
|
||||||
//...
|
//...
|
||||||
const tracker = new OpenReplay({
|
tracker.configure({
|
||||||
projectKey: '${projectKey}'
|
projectKey: '${projectKey}'
|
||||||
});
|
});
|
||||||
tracker.start()
|
tracker.start()
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,10 @@ function ReduxDoc() {
|
||||||
: sites[0]?.projectKey;
|
: sites[0]?.projectKey;
|
||||||
|
|
||||||
const usage = `import { applyMiddleware, createStore } from 'redux';
|
const usage = `import { applyMiddleware, createStore } from 'redux';
|
||||||
import OpenReplay from '@openreplay/tracker';
|
import { tracker } from '@openreplay/tracker';
|
||||||
import trackerRedux from '@openreplay/tracker-redux';
|
import trackerRedux from '@openreplay/tracker-redux';
|
||||||
//...
|
//...
|
||||||
const tracker = new OpenReplay({
|
tracker.configure({
|
||||||
projectKey: '${projectKey}'
|
projectKey: '${projectKey}'
|
||||||
});
|
});
|
||||||
tracker.start()
|
tracker.start()
|
||||||
|
|
@ -29,10 +29,11 @@ const store = createStore(
|
||||||
applyMiddleware(tracker.use(trackerRedux(<options>))) // check list of available options below
|
applyMiddleware(tracker.use(trackerRedux(<options>))) // check list of available options below
|
||||||
);`;
|
);`;
|
||||||
const usageCjs = `import { applyMiddleware, createStore } from 'redux';
|
const usageCjs = `import { applyMiddleware, createStore } from 'redux';
|
||||||
import OpenReplay from '@openreplay/tracker/cjs';
|
import { tracker } from '@openreplay/tracker/cjs';
|
||||||
|
// alternatively you can use dynamic import without /cjs suffix to prevent issues with window scope
|
||||||
import trackerRedux from '@openreplay/tracker-redux/cjs';
|
import trackerRedux from '@openreplay/tracker-redux/cjs';
|
||||||
//...
|
//...
|
||||||
const tracker = new OpenReplay({
|
tracker.configure({
|
||||||
projectKey: '${projectKey}'
|
projectKey: '${projectKey}'
|
||||||
});
|
});
|
||||||
//...
|
//...
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,10 @@ function VueDoc() {
|
||||||
: sites[0]?.projectKey;
|
: sites[0]?.projectKey;
|
||||||
|
|
||||||
const usage = `import Vuex from 'vuex'
|
const usage = `import Vuex from 'vuex'
|
||||||
import OpenReplay from '@openreplay/tracker';
|
import { tracker } from '@openreplay/tracker';
|
||||||
import trackerVuex from '@openreplay/tracker-vuex';
|
import trackerVuex from '@openreplay/tracker-vuex';
|
||||||
//...
|
//...
|
||||||
const tracker = new OpenReplay({
|
tracker.configure({
|
||||||
projectKey: '${projectKey}'
|
projectKey: '${projectKey}'
|
||||||
});
|
});
|
||||||
tracker.start()
|
tracker.start()
|
||||||
|
|
@ -29,10 +29,11 @@ const store = new Vuex.Store({
|
||||||
plugins: [tracker.use(trackerVuex(<options>))] // check list of available options below
|
plugins: [tracker.use(trackerVuex(<options>))] // check list of available options below
|
||||||
});`;
|
});`;
|
||||||
const usageCjs = `import Vuex from 'vuex'
|
const usageCjs = `import Vuex from 'vuex'
|
||||||
import OpenReplay from '@openreplay/tracker/cjs';
|
import { tracker } from '@openreplay/tracker/cjs';
|
||||||
|
// alternatively you can use dynamic import without /cjs suffix to prevent issues with window scope
|
||||||
import trackerVuex from '@openreplay/tracker-vuex/cjs';
|
import trackerVuex from '@openreplay/tracker-vuex/cjs';
|
||||||
//...
|
//...
|
||||||
const tracker = new OpenReplay({
|
tracker.configure({
|
||||||
projectKey: '${projectKey}'
|
projectKey: '${projectKey}'
|
||||||
});
|
});
|
||||||
//...
|
//...
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,10 @@ function ZustandDoc(props) {
|
||||||
: sites[0]?.projectKey;
|
: sites[0]?.projectKey;
|
||||||
|
|
||||||
const usage = `import create from "zustand";
|
const usage = `import create from "zustand";
|
||||||
import Tracker from '@openreplay/tracker';
|
import { tracker } from '@openreplay/tracker';
|
||||||
import trackerZustand, { StateLogger } from '@openreplay/tracker-zustand';
|
import trackerZustand, { StateLogger } from '@openreplay/tracker-zustand';
|
||||||
|
|
||||||
|
tracker.configure({
|
||||||
const tracker = new Tracker({
|
|
||||||
projectKey: ${projectKey},
|
projectKey: ${projectKey},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -43,11 +42,12 @@ const useBearStore = create(
|
||||||
)
|
)
|
||||||
`;
|
`;
|
||||||
const usageCjs = `import create from "zustand";
|
const usageCjs = `import create from "zustand";
|
||||||
import Tracker from '@openreplay/tracker/cjs';
|
import { tracker } from '@openreplay/tracker/cjs';
|
||||||
|
// alternatively you can use dynamic import without /cjs suffix to prevent issues with window scope
|
||||||
import trackerZustand, { StateLogger } from '@openreplay/tracker-zustand/cjs';
|
import trackerZustand, { StateLogger } from '@openreplay/tracker-zustand/cjs';
|
||||||
|
|
||||||
|
|
||||||
const tracker = new Tracker({
|
tracker.configure({
|
||||||
projectKey: ${projectKey},
|
projectKey: ${projectKey},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { useTranslation } from 'react-i18next';
|
||||||
const initTableProps = [
|
const initTableProps = [
|
||||||
{
|
{
|
||||||
title: <span className="font-medium">Series</span>,
|
title: <span className="font-medium">Series</span>,
|
||||||
|
_pureTitle: 'Series',
|
||||||
dataIndex: 'seriesName',
|
dataIndex: 'seriesName',
|
||||||
key: 'seriesName',
|
key: 'seriesName',
|
||||||
sorter: (a, b) => a.seriesName.localeCompare(b.seriesName),
|
sorter: (a, b) => a.seriesName.localeCompare(b.seriesName),
|
||||||
|
|
@ -18,6 +19,7 @@ const initTableProps = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: <span className="font-medium">Avg.</span>,
|
title: <span className="font-medium">Avg.</span>,
|
||||||
|
_pureTitle: 'Avg.',
|
||||||
dataIndex: 'average',
|
dataIndex: 'average',
|
||||||
key: 'average',
|
key: 'average',
|
||||||
sorter: (a, b) => a.average - b.average,
|
sorter: (a, b) => a.average - b.average,
|
||||||
|
|
@ -94,6 +96,8 @@ function WidgetDatatable(props: Props) {
|
||||||
tableCols.push({
|
tableCols.push({
|
||||||
title: <span className="font-medium">{name}</span>,
|
title: <span className="font-medium">{name}</span>,
|
||||||
dataIndex: `${name}_${i}`,
|
dataIndex: `${name}_${i}`,
|
||||||
|
// @ts-ignore
|
||||||
|
_pureTitle: name,
|
||||||
key: `${name}_${i}`,
|
key: `${name}_${i}`,
|
||||||
sorter: (a, b) => a[`${name}_${i}`] - b[`${name}_${i}`],
|
sorter: (a, b) => a[`${name}_${i}`] - b[`${name}_${i}`],
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,52 +1,80 @@
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { observer } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
import { useStore } from 'App/mstore';
|
import { useStore } from 'App/mstore';
|
||||||
import ReCAPTCHA from 'react-google-recaptcha';
|
|
||||||
import { Form, Input, Loader, Icon, Message } from 'UI';
|
import { Form, Input, Loader, Icon, Message } from 'UI';
|
||||||
import { Button } from 'antd';
|
import { Button } from 'antd';
|
||||||
import { validatePassword } from 'App/validate';
|
import { validatePassword } from 'App/validate';
|
||||||
import { PASSWORD_POLICY } from 'App/constants';
|
import { PASSWORD_POLICY } from 'App/constants';
|
||||||
import stl from './forgotPassword.module.css';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import withCaptcha, { WithCaptchaProps } from 'App/withRecaptcha';
|
||||||
|
|
||||||
const recaptchaRef = React.createRef();
|
|
||||||
const ERROR_DONT_MATCH = (t) => t("Passwords don't match.");
|
const ERROR_DONT_MATCH = (t) => t("Passwords don't match.");
|
||||||
const CAPTCHA_ENABLED = window.env.CAPTCHA_ENABLED === 'true';
|
|
||||||
const { CAPTCHA_SITE_KEY } = window.env;
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: any;
|
params: any;
|
||||||
}
|
}
|
||||||
function CreatePassword(props: Props) {
|
|
||||||
|
function CreatePassword(props: Props & WithCaptchaProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { params } = props;
|
const { params } = props;
|
||||||
const { userStore } = useStore();
|
const { userStore } = useStore();
|
||||||
const { loading } = userStore;
|
const { loading } = userStore;
|
||||||
const { resetPassword } = userStore;
|
const { resetPassword } = userStore;
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [validationError, setValidationError] = React.useState<string | null>(
|
const [validationError, setValidationError] = useState<string | null>(null);
|
||||||
null,
|
const [updated, setUpdated] = useState(false);
|
||||||
);
|
const [passwordRepeat, setPasswordRepeat] = useState('');
|
||||||
const [updated, setUpdated] = React.useState(false);
|
const [password, setPassword] = useState('');
|
||||||
const [passwordRepeat, setPasswordRepeat] = React.useState('');
|
|
||||||
const [password, setPassword] = React.useState('');
|
|
||||||
const pass = params.get('pass');
|
const pass = params.get('pass');
|
||||||
const invitation = params.get('invitation');
|
const invitation = params.get('invitation');
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const { submitWithCaptcha, isVerifyingCaptcha, resetCaptcha } = props;
|
||||||
if (!validatePassword(password)) {
|
|
||||||
|
const handleSubmit = (token?: string) => {
|
||||||
|
if (!validatePassword(password) || !token) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void resetPassword({ invitation, pass, password });
|
|
||||||
|
resetPassword({
|
||||||
|
invitation,
|
||||||
|
pass,
|
||||||
|
password,
|
||||||
|
'g-recaptcha-response': token
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
setUpdated(true);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setError(err.message);
|
||||||
|
// Reset captcha for the next attempt
|
||||||
|
resetCaptcha();
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = (e: any) => {
|
const onSubmit = () => {
|
||||||
e.preventDefault();
|
// Validate before attempting captcha verification
|
||||||
if (CAPTCHA_ENABLED && recaptchaRef.current) {
|
if (!validatePassword(password) || password !== passwordRepeat) {
|
||||||
recaptchaRef.current.execute();
|
setValidationError(
|
||||||
} else if (!CAPTCHA_ENABLED) {
|
password !== passwordRepeat
|
||||||
handleSubmit();
|
? ERROR_DONT_MATCH(t)
|
||||||
|
: PASSWORD_POLICY(t)
|
||||||
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset any previous errors
|
||||||
|
setError(null);
|
||||||
|
setValidationError(null);
|
||||||
|
|
||||||
|
submitWithCaptcha({ pass, invitation, password })
|
||||||
|
.then((data) => {
|
||||||
|
handleSubmit(data['g-recaptcha-response']);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Captcha verification failed:', error);
|
||||||
|
// The component will handle showing appropriate messages
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const write = (e: any) => {
|
const write = (e: any) => {
|
||||||
|
|
@ -63,7 +91,7 @@ function CreatePassword(props: Props) {
|
||||||
} else {
|
} else {
|
||||||
setValidationError(null);
|
setValidationError(null);
|
||||||
}
|
}
|
||||||
}, [passwordRepeat, password]);
|
}, [passwordRepeat, password, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form
|
<Form
|
||||||
|
|
@ -73,19 +101,8 @@ function CreatePassword(props: Props) {
|
||||||
>
|
>
|
||||||
{!error && (
|
{!error && (
|
||||||
<>
|
<>
|
||||||
<Loader loading={loading}>
|
<Loader loading={loading || isVerifyingCaptcha}>
|
||||||
<div data-hidden={updated} className="w-full">
|
<div data-hidden={updated} className="w-full">
|
||||||
{CAPTCHA_ENABLED && (
|
|
||||||
<div className={stl.recaptcha}>
|
|
||||||
<ReCAPTCHA
|
|
||||||
ref={recaptchaRef}
|
|
||||||
size="invisible"
|
|
||||||
sitekey={CAPTCHA_SITE_KEY}
|
|
||||||
onChange={(token: any) => handleSubmit(token)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Form.Field>
|
<Form.Field>
|
||||||
<label>{t('New password')}</label>
|
<label>{t('New password')}</label>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -132,10 +149,15 @@ function CreatePassword(props: Props) {
|
||||||
<Button
|
<Button
|
||||||
htmlType="submit"
|
htmlType="submit"
|
||||||
type="primary"
|
type="primary"
|
||||||
loading={loading}
|
loading={loading || isVerifyingCaptcha}
|
||||||
|
disabled={loading || isVerifyingCaptcha || validationError !== null}
|
||||||
className="w-full mt-4"
|
className="w-full mt-4"
|
||||||
>
|
>
|
||||||
{t('Create')}
|
{isVerifyingCaptcha
|
||||||
|
? t('Verifying...')
|
||||||
|
: loading
|
||||||
|
? t('Processing...')
|
||||||
|
: t('Create')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
@ -153,4 +175,4 @@ function CreatePassword(props: Props) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default observer(CreatePassword);
|
export default withCaptcha(observer(CreatePassword));
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,26 @@
|
||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Loader, Icon } from 'UI';
|
import { Loader, Icon } from 'UI';
|
||||||
import ReCAPTCHA from 'react-google-recaptcha';
|
|
||||||
import { observer } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
import { useStore } from 'App/mstore';
|
import { useStore } from 'App/mstore';
|
||||||
import { Form, Input, Button, Typography } from 'antd';
|
import { Form, Input, Button, Typography } from 'antd';
|
||||||
import { SquareArrowOutUpRight } from 'lucide-react';
|
import { SquareArrowOutUpRight } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import withCaptcha, { WithCaptchaProps } from 'App/withRecaptcha';
|
||||||
|
|
||||||
function ResetPasswordRequest() {
|
interface Props {
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResetPasswordRequest(props: Props & WithCaptchaProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { userStore } = useStore();
|
const { userStore } = useStore();
|
||||||
const { loading } = userStore;
|
const { loading } = userStore;
|
||||||
const { requestResetPassword } = userStore;
|
const { requestResetPassword } = userStore;
|
||||||
const recaptchaRef = React.createRef();
|
const [requested, setRequested] = useState(false);
|
||||||
const [requested, setRequested] = React.useState(false);
|
const [email, setEmail] = useState('');
|
||||||
const [email, setEmail] = React.useState('');
|
const [error, setError] = useState(null);
|
||||||
const [error, setError] = React.useState(null);
|
const [smtpError, setSmtpError] = useState<boolean>(false);
|
||||||
const CAPTCHA_ENABLED = window.env.CAPTCHA_ENABLED === 'true';
|
|
||||||
const { CAPTCHA_SITE_KEY } = window.env;
|
const { submitWithCaptcha, isVerifyingCaptcha, resetCaptcha } = props;
|
||||||
const [smtpError, setSmtpError] = React.useState<boolean>(false);
|
|
||||||
|
|
||||||
const write = (e: any) => {
|
const write = (e: any) => {
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
|
|
@ -26,21 +28,22 @@ function ResetPasswordRequest() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = () => {
|
const onSubmit = () => {
|
||||||
// e.preventDefault();
|
// Validation check
|
||||||
if (CAPTCHA_ENABLED && recaptchaRef.current) {
|
if (!email || email.trim() === '') {
|
||||||
recaptchaRef.current.execute();
|
return;
|
||||||
} else if (!CAPTCHA_ENABLED) {
|
|
||||||
handleSubmit();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
submitWithCaptcha({ email: email.trim() })
|
||||||
|
.then((data) => {
|
||||||
|
handleSubmit(data['g-recaptcha-response']);
|
||||||
|
})
|
||||||
|
.catch((error: any) => {
|
||||||
|
console.error('Captcha verification failed:', error);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = (token?: any) => {
|
const handleSubmit = (token?: string) => {
|
||||||
if (
|
if (!token) return;
|
||||||
CAPTCHA_ENABLED &&
|
|
||||||
recaptchaRef.current &&
|
|
||||||
(token === null || token === undefined)
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
|
|
||||||
setError(null);
|
setError(null);
|
||||||
requestResetPassword({ email: email.trim(), 'g-recaptcha-response': token })
|
requestResetPassword({ email: email.trim(), 'g-recaptcha-response': token })
|
||||||
|
|
@ -50,29 +53,21 @@ function ResetPasswordRequest() {
|
||||||
}
|
}
|
||||||
|
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
|
// Reset captcha for the next attempt
|
||||||
|
resetCaptcha();
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
setRequested(true);
|
setRequested(true);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form
|
<Form
|
||||||
onFinish={onSubmit}
|
onFinish={onSubmit}
|
||||||
style={{ minWidth: '50%' }}
|
style={{ minWidth: '50%' }}
|
||||||
className="flex flex-col"
|
className="flex flex-col"
|
||||||
>
|
>
|
||||||
<Loader loading={false}>
|
<Loader loading={loading || isVerifyingCaptcha}>
|
||||||
{CAPTCHA_ENABLED && (
|
|
||||||
<div className="flex justify-center">
|
|
||||||
<ReCAPTCHA
|
|
||||||
ref={recaptchaRef}
|
|
||||||
size="invisible"
|
|
||||||
data-hidden={requested}
|
|
||||||
sitekey={CAPTCHA_SITE_KEY}
|
|
||||||
onChange={(token: any) => handleSubmit(token)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!requested && (
|
{!requested && (
|
||||||
<>
|
<>
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
|
|
@ -92,10 +87,14 @@ function ResetPasswordRequest() {
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
htmlType="submit"
|
htmlType="submit"
|
||||||
loading={loading}
|
loading={loading || isVerifyingCaptcha}
|
||||||
disabled={loading}
|
disabled={loading || isVerifyingCaptcha}
|
||||||
>
|
>
|
||||||
{t('Email Password Reset Link')}
|
{isVerifyingCaptcha
|
||||||
|
? t('Verifying...')
|
||||||
|
: loading
|
||||||
|
? t('Processing...')
|
||||||
|
: t('Email Password Reset Link')}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
@ -146,4 +145,4 @@ function ResetPasswordRequest() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default observer(ResetPasswordRequest);
|
export default withCaptcha(observer(ResetPasswordRequest));
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,18 @@
|
||||||
import withPageTitle from 'HOCs/withPageTitle';
|
import withPageTitle from 'HOCs/withPageTitle';
|
||||||
import cn from 'classnames';
|
import cn from 'classnames';
|
||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
// Consider using a different approach for titles in functional components
|
|
||||||
import ReCAPTCHA from 'react-google-recaptcha';
|
|
||||||
import { useHistory } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
import { observer } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
import { ENTERPRISE_REQUEIRED } from 'App/constants';
|
|
||||||
import { forgotPassword, signup } from 'App/routes';
|
import { forgotPassword, signup } from 'App/routes';
|
||||||
import { Icon, Link, Loader, Tooltip } from 'UI';
|
import { Icon, Link, Loader } from 'UI';
|
||||||
import { Button, Form, Input } from 'antd';
|
import { Button, Form, Input } from 'antd';
|
||||||
|
|
||||||
import Copyright from 'Shared/Copyright';
|
import Copyright from 'Shared/Copyright';
|
||||||
|
|
||||||
import stl from './login.module.css';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useStore } from 'App/mstore';
|
import { useStore } from 'App/mstore';
|
||||||
import LanguageSwitcher from '../LanguageSwitcher';
|
import LanguageSwitcher from '../LanguageSwitcher';
|
||||||
|
import withCaptcha, { WithCaptchaProps } from 'App/withRecaptcha';
|
||||||
|
import SSOLogin from './SSOLogin';
|
||||||
|
|
||||||
const FORGOT_PASSWORD = forgotPassword();
|
const FORGOT_PASSWORD = forgotPassword();
|
||||||
const SIGNUP_ROUTE = signup();
|
const SIGNUP_ROUTE = signup();
|
||||||
|
|
@ -26,14 +21,15 @@ interface LoginProps {
|
||||||
location: Location;
|
location: Location;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CAPTCHA_ENABLED = window.env.CAPTCHA_ENABLED === 'true';
|
function Login({
|
||||||
|
location,
|
||||||
function Login({ location }: LoginProps) {
|
submitWithCaptcha,
|
||||||
|
isVerifyingCaptcha,
|
||||||
|
resetCaptcha,
|
||||||
|
}: LoginProps & WithCaptchaProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
// const CAPTCHA_ENABLED = useMemo(() => window.env.CAPTCHA_ENABLED === 'true', []);
|
|
||||||
const recaptchaRef = useRef<ReCAPTCHA>(null);
|
|
||||||
const { loginStore, userStore } = useStore();
|
const { loginStore, userStore } = useStore();
|
||||||
const { errors } = userStore.loginRequest;
|
const { errors } = userStore.loginRequest;
|
||||||
const { loading } = loginStore;
|
const { loading } = loginStore;
|
||||||
|
|
@ -49,7 +45,6 @@ function Login({ location }: LoginProps) {
|
||||||
}, [authDetails]);
|
}, [authDetails]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// void fetchTenants();
|
|
||||||
const jwt = params.get('jwt');
|
const jwt = params.get('jwt');
|
||||||
const spotJwt = params.get('spotJwt');
|
const spotJwt = params.get('spotJwt');
|
||||||
if (spotJwt) {
|
if (spotJwt) {
|
||||||
|
|
@ -108,32 +103,36 @@ function Login({ location }: LoginProps) {
|
||||||
if (resp) {
|
if (resp) {
|
||||||
userStore.syntheticLogin(resp);
|
userStore.syntheticLogin(resp);
|
||||||
setJwt({ jwt: resp.jwt, spotJwt: resp.spotJwt ?? null });
|
setJwt({ jwt: resp.jwt, spotJwt: resp.spotJwt ?? null });
|
||||||
|
if (resp.spotJwt) {
|
||||||
handleSpotLogin(resp.spotJwt);
|
handleSpotLogin(resp.spotJwt);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
userStore.syntheticLoginError(e);
|
userStore.syntheticLoginError(e);
|
||||||
|
resetCaptcha();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = () => {
|
const onSubmit = () => {
|
||||||
if (CAPTCHA_ENABLED && recaptchaRef.current) {
|
if (!email || !password) {
|
||||||
recaptchaRef.current.execute();
|
return;
|
||||||
} else if (!CAPTCHA_ENABLED) {
|
|
||||||
handleSubmit();
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const ssoLink =
|
submitWithCaptcha({ email: email.trim(), password })
|
||||||
window !== window.top
|
.then((data) => {
|
||||||
? `${window.location.origin}/api/sso/saml2?iFrame=true`
|
handleSubmit(data['g-recaptcha-response']);
|
||||||
: `${window.location.origin}/api/sso/saml2`;
|
})
|
||||||
|
.catch((error: any) => {
|
||||||
|
console.error('Captcha error:', error);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-screen">
|
<div className="flex items-center justify-center h-screen">
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<div className="m-10 ">
|
<div className="m-10 ">
|
||||||
<img src="/assets/logo.svg" width={200} />
|
<img src="/assets/logo.svg" width={200} alt="Company Logo" />
|
||||||
</div>
|
</div>
|
||||||
<div className="border rounded-lg bg-white shadow-sm">
|
<div className="border rounded-lg bg-white shadow-sm">
|
||||||
<h2 className="text-center text-2xl font-medium mb-6 border-b p-5 w-full">
|
<h2 className="text-center text-2xl font-medium mb-6 border-b p-5 w-full">
|
||||||
|
|
@ -145,15 +144,7 @@ function Login({ location }: LoginProps) {
|
||||||
className={cn('flex items-center justify-center flex-col')}
|
className={cn('flex items-center justify-center flex-col')}
|
||||||
style={{ width: '350px' }}
|
style={{ width: '350px' }}
|
||||||
>
|
>
|
||||||
<Loader loading={loading}>
|
<Loader loading={loading || isVerifyingCaptcha}>
|
||||||
{CAPTCHA_ENABLED && (
|
|
||||||
<ReCAPTCHA
|
|
||||||
ref={recaptchaRef}
|
|
||||||
size="invisible"
|
|
||||||
sitekey={window.env.CAPTCHA_SITE_KEY}
|
|
||||||
onChange={(token) => handleSubmit(token)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div style={{ width: '350px' }} className="px-8">
|
<div style={{ width: '350px' }} className="px-8">
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<label>{t('Email Address')}</label>
|
<label>{t('Email Address')}</label>
|
||||||
|
|
@ -186,8 +177,8 @@ function Login({ location }: LoginProps) {
|
||||||
</Loader>
|
</Loader>
|
||||||
{errors && errors.length ? (
|
{errors && errors.length ? (
|
||||||
<div className="px-8 my-2 w-full">
|
<div className="px-8 my-2 w-full">
|
||||||
{errors.map((error) => (
|
{errors.map((error, index) => (
|
||||||
<div className="flex items-center bg-red-lightest rounded p-3">
|
<div key={index} className="flex items-center bg-red-lightest rounded p-3">
|
||||||
<Icon name="info" color="red" size="20" />
|
<Icon name="info" color="red" size="20" />
|
||||||
<span className="color-red ml-2">
|
<span className="color-red ml-2">
|
||||||
{error}
|
{error}
|
||||||
|
|
@ -204,8 +195,14 @@ function Login({ location }: LoginProps) {
|
||||||
className="mt-2 w-full text-center rounded-lg"
|
className="mt-2 w-full text-center rounded-lg"
|
||||||
type="primary"
|
type="primary"
|
||||||
htmlType="submit"
|
htmlType="submit"
|
||||||
|
loading={loading || isVerifyingCaptcha}
|
||||||
|
disabled={loading || isVerifyingCaptcha}
|
||||||
>
|
>
|
||||||
{t('Login')}
|
{isVerifyingCaptcha
|
||||||
|
? t('Verifying...')
|
||||||
|
: loading
|
||||||
|
? t('Logging in...')
|
||||||
|
: t('Login')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="my-8 flex justify-center items-center flex-wrap">
|
<div className="my-8 flex justify-center items-center flex-wrap">
|
||||||
|
|
@ -219,64 +216,13 @@ function Login({ location }: LoginProps) {
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
<div className={cn(stl.sso, 'py-2 flex flex-col items-center')}>
|
<SSOLogin authDetails={authDetails} />
|
||||||
{authDetails.sso ? (
|
</div>
|
||||||
<a href={ssoLink} rel="noopener noreferrer">
|
|
||||||
<Button type="text" htmlType="submit">
|
{authDetails?.enforceSSO && (
|
||||||
{`${t('Login with SSO')} ${
|
<SSOLogin authDetails={authDetails} enforceSSO={true} />
|
||||||
authDetails.ssoProvider
|
|
||||||
? `(${authDetails.ssoProvider})`
|
|
||||||
: ''
|
|
||||||
}`}
|
|
||||||
</Button>
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<Tooltip
|
|
||||||
delay={0}
|
|
||||||
title={
|
|
||||||
<div className="text-center">
|
|
||||||
{authDetails.edition === 'ee' ? (
|
|
||||||
<span>
|
|
||||||
{t('SSO has not been configured.')}
|
|
||||||
<br />
|
|
||||||
{t('Please reach out to your admin.')}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
ENTERPRISE_REQUEIRED(t)
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
placement="top"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
htmlType="submit"
|
|
||||||
className="pointer-events-none opacity-30"
|
|
||||||
>
|
|
||||||
{`${t('Login with SSO')} ${
|
|
||||||
authDetails.ssoProvider
|
|
||||||
? `(${authDetails.ssoProvider})`
|
|
||||||
: ''
|
|
||||||
}`}
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={cn('flex items-center w-96 justify-center my-8', {
|
|
||||||
'!hidden': !authDetails?.enforceSSO,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<a href={ssoLink} rel="noopener noreferrer">
|
|
||||||
<Button type="primary">
|
|
||||||
{`${t('Login with SSO')} ${
|
|
||||||
authDetails.ssoProvider ? `(${authDetails.ssoProvider})` : ''
|
|
||||||
}`}
|
|
||||||
</Button>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Copyright />
|
<Copyright />
|
||||||
|
|
@ -287,4 +233,6 @@ function Login({ location }: LoginProps) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withPageTitle('Login - OpenReplay')(observer(Login));
|
export default withPageTitle('Login - OpenReplay')(
|
||||||
|
withCaptcha(observer(Login))
|
||||||
|
);
|
||||||
|
|
|
||||||
78
frontend/app/components/Login/SSOLogin.tsx
Normal file
78
frontend/app/components/Login/SSOLogin.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
import React from 'react';
|
||||||
|
import cn from 'classnames';
|
||||||
|
import { Button, Tooltip } from 'antd';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { ENTERPRISE_REQUEIRED } from 'App/constants';
|
||||||
|
import stl from './login.module.css';
|
||||||
|
import { useStore } from 'App/mstore';
|
||||||
|
|
||||||
|
interface SSOLoginProps {
|
||||||
|
authDetails: any;
|
||||||
|
enforceSSO?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SSOLogin = ({ authDetails, enforceSSO = false }: SSOLoginProps) => {
|
||||||
|
const { userStore } = useStore();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { isEnterprise } = userStore;
|
||||||
|
|
||||||
|
const getSSOLink = () =>
|
||||||
|
window !== window.top
|
||||||
|
? `${window.location.origin}/api/sso/saml2?iFrame=true`
|
||||||
|
: `${window.location.origin}/api/sso/saml2`;
|
||||||
|
|
||||||
|
const ssoLink = getSSOLink();
|
||||||
|
const ssoButtonText = `${t('Login with SSO')} ${authDetails.ssoProvider ? `(${authDetails.ssoProvider})` : ''
|
||||||
|
}`;
|
||||||
|
|
||||||
|
if (enforceSSO) {
|
||||||
|
return (
|
||||||
|
<div className={cn('flex items-center w-96 justify-center my-8')}>
|
||||||
|
<a href={ssoLink} rel="noopener noreferrer">
|
||||||
|
<Button type="primary">{ssoButtonText}</Button>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn(stl.sso, 'py-2 flex flex-col items-center')}>
|
||||||
|
{authDetails.sso ? (
|
||||||
|
<a href={ssoLink} rel="noopener noreferrer">
|
||||||
|
<Button type="text" htmlType="submit">
|
||||||
|
{ssoButtonText}
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<Tooltip
|
||||||
|
title={
|
||||||
|
<div className="text-center">
|
||||||
|
{isEnterprise ? (
|
||||||
|
<span>
|
||||||
|
{t('SSO has not been configured.')}
|
||||||
|
<br />
|
||||||
|
{t('Please reach out to your admin.')}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
ENTERPRISE_REQUEIRED(t)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
|
<span className="cursor-not-allowed">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
htmlType="submit"
|
||||||
|
disabled={true}
|
||||||
|
>
|
||||||
|
{ssoButtonText}
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SSOLogin;
|
||||||
|
|
@ -1,16 +1,14 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Redirect, Route, RouteComponentProps, Switch } from 'react-router';
|
import { Redirect, Route, RouteComponentProps, Switch } from 'react-router';
|
||||||
import { withRouter } from 'react-router-dom';
|
import { withRouter } from 'react-router-dom';
|
||||||
|
|
||||||
import { OB_TABS, onboarding as onboardingRoute, withSiteId } from 'App/routes';
|
import { OB_TABS, onboarding as onboardingRoute, withSiteId } from 'App/routes';
|
||||||
import { Icon } from 'UI';
|
|
||||||
|
|
||||||
import IdentifyUsersTab from './components/IdentifyUsersTab';
|
import IdentifyUsersTab from './components/IdentifyUsersTab';
|
||||||
import InstallOpenReplayTab from './components/InstallOpenReplayTab';
|
import InstallOpenReplayTab from './components/InstallOpenReplayTab';
|
||||||
import IntegrationsTab from './components/IntegrationsTab';
|
import IntegrationsTab from './components/IntegrationsTab';
|
||||||
import ManageUsersTab from './components/ManageUsersTab';
|
import ManageUsersTab from './components/ManageUsersTab';
|
||||||
import SideMenu from './components/SideMenu';
|
import SideMenu from './components/SideMenu';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Smartphone, AppWindow } from 'lucide-react';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
match: {
|
match: {
|
||||||
|
|
@ -33,7 +31,7 @@ function Onboarding(props: Props) {
|
||||||
{
|
{
|
||||||
label: (
|
label: (
|
||||||
<div className="font-semibold flex gap-2 items-center">
|
<div className="font-semibold flex gap-2 items-center">
|
||||||
<Icon name="browser/browser" size={16} />
|
<AppWindow size={16} />
|
||||||
{t('Web')}
|
{t('Web')}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
|
@ -42,7 +40,7 @@ function Onboarding(props: Props) {
|
||||||
{
|
{
|
||||||
label: (
|
label: (
|
||||||
<div className="font-semibold flex gap-2 items-center">
|
<div className="font-semibold flex gap-2 items-center">
|
||||||
<Icon name="mobile" size={16} />
|
<Smartphone size={16} />
|
||||||
{t('Mobile')}
|
{t('Mobile')}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -130,18 +130,20 @@ function IdentifyUsersTab(props: Props) {
|
||||||
'To identify users through metadata, you will have to explicitly specify your user metadata so it can be injected during sessions. Follow the below steps',
|
'To identify users through metadata, you will have to explicitly specify your user metadata so it can be injected during sessions. Follow the below steps',
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-start">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<CircleNumber text="1" />
|
<CircleNumber text="1" />
|
||||||
<MetadataList />
|
<MetadataList />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="my-6" />
|
<div className="my-6" />
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
|
<div>
|
||||||
<CircleNumber text="2" />
|
<CircleNumber text="2" />
|
||||||
<div className="pt-1 w-full">
|
|
||||||
<span className="font-bold">
|
<span className="font-bold">
|
||||||
{t('Inject metadata when recording sessions')}
|
{t('Inject metadata when recording sessions')}
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="pt-1 w-full">
|
||||||
<div className="my-2">
|
<div className="my-2">
|
||||||
{t('Use the')}
|
{t('Use the')}
|
||||||
<span className="highlight-blue">setMetadata</span>{' '}
|
<span className="highlight-blue">setMetadata</span>{' '}
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,6 @@ function MetadataList() {
|
||||||
<Button type="default" onClick={() => openModal()}>
|
<Button type="default" onClick={() => openModal()}>
|
||||||
{t('Add Metadata')}
|
{t('Add Metadata')}
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex ml-2">
|
|
||||||
{fields.map((f, index) => (
|
{fields.map((f, index) => (
|
||||||
<TagBadge
|
<TagBadge
|
||||||
key={index}
|
key={index}
|
||||||
|
|
@ -65,7 +64,6 @@ function MetadataList() {
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,16 +7,17 @@ import stl from './installDocs.module.css';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
const installationCommand = 'npm i @openreplay/tracker';
|
const installationCommand = 'npm i @openreplay/tracker';
|
||||||
const usageCode = `import Tracker from '@openreplay/tracker';
|
const usageCode = `import { tracker } from '@openreplay/tracker';
|
||||||
|
|
||||||
const tracker = new Tracker({
|
tracker.configure({
|
||||||
projectKey: "PROJECT_KEY",
|
projectKey: "PROJECT_KEY",
|
||||||
ingestPoint: "https://${window.location.hostname}/ingest",
|
ingestPoint: "https://${window.location.hostname}/ingest",
|
||||||
});
|
});
|
||||||
tracker.start()`;
|
tracker.start()`;
|
||||||
const usageCodeSST = `import Tracker from '@openreplay/tracker/cjs';
|
const usageCodeSST = `import { tracker } from '@openreplay/tracker/cjs';
|
||||||
|
// alternatively you can use dynamic import without /cjs suffix to prevent issues with window scope
|
||||||
|
|
||||||
const tracker = new Tracker({
|
tracker.configure({
|
||||||
projectKey: "PROJECT_KEY",
|
projectKey: "PROJECT_KEY",
|
||||||
ingestPoint: "https://${window.location.hostname}/ingest",
|
ingestPoint: "https://${window.location.hostname}/ingest",
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ const AUTOREFRESH_INTERVAL = 2 * 60 * 1000;
|
||||||
const PER_PAGE = 10;
|
const PER_PAGE = 10;
|
||||||
|
|
||||||
function LiveSessionList() {
|
function LiveSessionList() {
|
||||||
const { searchStoreLive, sessionStore, customFieldStore } = useStore();
|
const { searchStoreLive, sessionStore, customFieldStore, projectsStore } = useStore();
|
||||||
const filter = searchStoreLive.instance;
|
const filter = searchStoreLive.instance;
|
||||||
const list = sessionStore.liveSessions;
|
const list = sessionStore.liveSessions;
|
||||||
const { totalLiveSessions } = sessionStore;
|
const { totalLiveSessions } = sessionStore;
|
||||||
|
|
@ -72,6 +72,12 @@ function LiveSessionList() {
|
||||||
void searchStoreLive.fetchSessions();
|
void searchStoreLive.fetchSessions();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (projectsStore.activeSiteId) {
|
||||||
|
void searchStoreLive.fetchSessions(true);
|
||||||
|
}
|
||||||
|
}, [projectsStore.activeSiteId])
|
||||||
|
|
||||||
const onUserClick = (userId: string, userAnonymousId: string) => {
|
const onUserClick = (userId: string, userAnonymousId: string) => {
|
||||||
if (userId) {
|
if (userId) {
|
||||||
searchStoreLive.addFilterByKeyAndValue(FilterKey.USERID, userId);
|
searchStoreLive.addFilterByKeyAndValue(FilterKey.USERID, userId);
|
||||||
|
|
@ -98,7 +104,7 @@ function LiveSessionList() {
|
||||||
<div>
|
<div>
|
||||||
<div className="bg-white py-3 rounded-lg border shadow-sm">
|
<div className="bg-white py-3 rounded-lg border shadow-sm">
|
||||||
<div className="flex mb-4 pb-2 px-3 justify-between items-center border-b border-b-gray-lighter">
|
<div className="flex mb-4 pb-2 px-3 justify-between items-center border-b border-b-gray-lighter">
|
||||||
<LiveSessionReloadButton onClick={refetch} />
|
<LiveSessionReloadButton />
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="flex items-center ml-6">
|
<div className="flex items-center ml-6">
|
||||||
<span className="mr-2 color-gray-medium">{t('Sort By')}</span>
|
<span className="mr-2 color-gray-medium">{t('Sort By')}</span>
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,11 @@ import { observer } from 'mobx-react-lite';
|
||||||
import ReloadButton from '../ReloadButton';
|
import ReloadButton from '../ReloadButton';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
interface Props {
|
function LiveSessionReloadButton() {
|
||||||
onClick: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function LiveSessionReloadButton(props: Props) {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { sessionStore } = useStore();
|
const { searchStoreLive } = useStore();
|
||||||
const { onClick } = props;
|
const onClick = searchStoreLive.fetchSessions
|
||||||
const loading = sessionStore.loadingLiveSessions;
|
const loading = searchStoreLive.loading;
|
||||||
return (
|
return (
|
||||||
<ReloadButton label={t('Refresh')} buttonSize={'small'} iconSize={14} loading={loading} onClick={onClick} className="cursor-pointer" />
|
<ReloadButton label={t('Refresh')} buttonSize={'small'} iconSize={14} loading={loading} onClick={onClick} className="cursor-pointer" />
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ export default function ReloadButton(props: Props) {
|
||||||
<Button
|
<Button
|
||||||
type="default"
|
type="default"
|
||||||
size={buttonSize}
|
size={buttonSize}
|
||||||
|
loading={loading}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
icon={<SyncOutlined style={{ fontSize: iconSize }} />}
|
icon={<SyncOutlined style={{ fontSize: iconSize }} />}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -5,17 +5,18 @@ import stl from './installDocs.module.css';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
const installationCommand = 'npm i @openreplay/tracker';
|
const installationCommand = 'npm i @openreplay/tracker';
|
||||||
const usageCode = `import Tracker from '@openreplay/tracker';
|
const usageCode = `import { tracker } from '@openreplay/tracker';
|
||||||
|
|
||||||
const tracker = new Tracker({
|
tracker.configure({
|
||||||
projectKey: "PROJECT_KEY",
|
projectKey: "PROJECT_KEY",
|
||||||
ingestPoint: "https://${window.location.hostname}/ingest",
|
ingestPoint: "https://${window.location.hostname}/ingest",
|
||||||
});
|
});
|
||||||
|
|
||||||
tracker.start()`;
|
tracker.start()`;
|
||||||
const usageCodeSST = `import Tracker from '@openreplay/tracker/cjs';
|
const usageCodeSST = `import { tracker } from '@openreplay/tracker/cjs';
|
||||||
|
// alternatively you can use dynamic import without /cjs suffix to prevent issues with window scope
|
||||||
|
|
||||||
const tracker = new Tracker({
|
tracker.configure({
|
||||||
projectKey: "PROJECT_KEY",
|
projectKey: "PROJECT_KEY",
|
||||||
ingestPoint: "https://${window.location.hostname}/ingest",
|
ingestPoint: "https://${window.location.hostname}/ingest",
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ const Input = React.forwardRef((props: Props, ref: any) => {
|
||||||
{icon && (
|
{icon && (
|
||||||
<Icon
|
<Icon
|
||||||
name={icon}
|
name={icon}
|
||||||
className="absolute top-0 bottom-0 my-auto ml-4"
|
className="absolute top-0 bottom-0 my-auto ml-4 z-10"
|
||||||
size="14"
|
size="14"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ export const filterMap = ({
|
||||||
filters,
|
filters,
|
||||||
sort,
|
sort,
|
||||||
order
|
order
|
||||||
}: any) => ({
|
}: any) => ({
|
||||||
value: checkValues(key, value),
|
value: checkValues(key, value),
|
||||||
custom,
|
custom,
|
||||||
type: category === FilterCategory.METADATA ? FilterKey.METADATA : key,
|
type: category === FilterCategory.METADATA ? FilterKey.METADATA : key,
|
||||||
|
|
@ -254,7 +254,7 @@ class SearchStore {
|
||||||
|
|
||||||
this.savedSearch = new SavedSearch({});
|
this.savedSearch = new SavedSearch({});
|
||||||
sessionStore.clearList();
|
sessionStore.clearList();
|
||||||
void this.fetchSessions(true);
|
// void this.fetchSessions(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkForLatestSessionCount(): Promise<void> {
|
async checkForLatestSessionCount(): Promise<void> {
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,8 @@ class SearchStoreLive {
|
||||||
|
|
||||||
loadingFilterSearch = false;
|
loadingFilterSearch = false;
|
||||||
|
|
||||||
|
loading = false;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
makeAutoObservable(this);
|
makeAutoObservable(this);
|
||||||
|
|
||||||
|
|
@ -242,11 +244,25 @@ class SearchStoreLive {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
async fetchSessions() {
|
setLoading = (val: boolean) => {
|
||||||
|
this.loading = val;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchSessions = async (force?: boolean) => {
|
||||||
|
if (!force && this.loading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.setLoading(true)
|
||||||
|
try {
|
||||||
await sessionStore.fetchLiveSessions({
|
await sessionStore.fetchLiveSessions({
|
||||||
...this.instance.toSearch(),
|
...this.instance.toSearch(),
|
||||||
page: this.currentPage,
|
page: this.currentPage,
|
||||||
});
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error fetching sessions:', e);
|
||||||
|
} finally {
|
||||||
|
this.setLoading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,9 @@ class UserStore {
|
||||||
get isEnterprise() {
|
get isEnterprise() {
|
||||||
return (
|
return (
|
||||||
this.account?.edition === 'ee' ||
|
this.account?.edition === 'ee' ||
|
||||||
this.authStore.authDetails?.edition === 'ee'
|
this.account?.edition === 'msaas' ||
|
||||||
|
this.authStore.authDetails?.edition === 'ee' ||
|
||||||
|
this.authStore.authDetails?.edition === 'msaas'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -416,9 +418,9 @@ class UserStore {
|
||||||
this.jwt = data.jwt;
|
this.jwt = data.jwt;
|
||||||
this.spotJwt = data.spotJwt;
|
this.spotJwt = data.spotJwt;
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (e) {
|
||||||
toast.error(this.t('Error resetting your password; please try again'));
|
toast.error(e.message || this.t('Error resetting your password; please try again'));
|
||||||
return error.response;
|
throw e;
|
||||||
} finally {
|
} finally {
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
|
|
@ -663,14 +665,14 @@ class AuthStore {
|
||||||
{
|
{
|
||||||
key: 'authDetails',
|
key: 'authDetails',
|
||||||
serialize: (ad) => {
|
serialize: (ad) => {
|
||||||
delete ad.edition;
|
// delete ad.edition;
|
||||||
return Object.keys(ad).length > 0
|
return Object.keys(ad).length > 0
|
||||||
? JSON.stringify(ad)
|
? JSON.stringify(ad)
|
||||||
: JSON.stringify({});
|
: JSON.stringify({});
|
||||||
},
|
},
|
||||||
deserialize: (json) => {
|
deserialize: (json) => {
|
||||||
const ad = JSON.parse(json);
|
const ad = JSON.parse(json);
|
||||||
delete ad.edition;
|
// delete ad.edition;
|
||||||
return ad;
|
return ad;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -150,7 +150,7 @@ export default class MessageLoader {
|
||||||
});
|
});
|
||||||
|
|
||||||
const sortedMsgs = msgs
|
const sortedMsgs = msgs
|
||||||
// .sort((m1, m2) => m1.time - m2.time);
|
// .sort((m1, m2) => m1.time - m2.time)
|
||||||
.sort(brokenDomSorter)
|
.sort(brokenDomSorter)
|
||||||
.sort(sortIframes);
|
.sort(sortIframes);
|
||||||
|
|
||||||
|
|
@ -383,7 +383,6 @@ const DOMMessages = [
|
||||||
MType.CreateElementNode,
|
MType.CreateElementNode,
|
||||||
MType.CreateTextNode,
|
MType.CreateTextNode,
|
||||||
MType.MoveNode,
|
MType.MoveNode,
|
||||||
MType.RemoveNode,
|
|
||||||
MType.CreateIFrameDocument,
|
MType.CreateIFrameDocument,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -395,6 +394,11 @@ function brokenDomSorter(m1: PlayerMsg, m2: PlayerMsg) {
|
||||||
if (m1.tp !== MType.CreateDocument && m2.tp === MType.CreateDocument)
|
if (m1.tp !== MType.CreateDocument && m2.tp === MType.CreateDocument)
|
||||||
return 1;
|
return 1;
|
||||||
|
|
||||||
|
if (m1.tp === MType.RemoveNode)
|
||||||
|
return 1;
|
||||||
|
if (m2.tp === MType.RemoveNode)
|
||||||
|
return -1;
|
||||||
|
|
||||||
const m1IsDOM = DOMMessages.includes(m1.tp);
|
const m1IsDOM = DOMMessages.includes(m1.tp);
|
||||||
const m2IsDOM = DOMMessages.includes(m2.tp);
|
const m2IsDOM = DOMMessages.includes(m2.tp);
|
||||||
if (m1IsDOM && m2IsDOM) {
|
if (m1IsDOM && m2IsDOM) {
|
||||||
|
|
|
||||||
|
|
@ -185,11 +185,14 @@ export default class AssistManager {
|
||||||
const socket: Socket = (this.socket = io(urlObject.origin, {
|
const socket: Socket = (this.socket = io(urlObject.origin, {
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
multiplex: true,
|
multiplex: true,
|
||||||
transports: ['websocket'],
|
transports: ['polling', 'websocket'],
|
||||||
path: '/ws-assist/socket',
|
path: '/ws-assist/socket',
|
||||||
auth: {
|
auth: {
|
||||||
token: agentToken,
|
token: agentToken,
|
||||||
},
|
},
|
||||||
|
extraHeaders: {
|
||||||
|
sessionId: this.session.sessionId,
|
||||||
|
},
|
||||||
query: {
|
query: {
|
||||||
peerId: this.peerID,
|
peerId: this.peerID,
|
||||||
projectId,
|
projectId,
|
||||||
|
|
|
||||||
|
|
@ -185,8 +185,7 @@ export default class Call {
|
||||||
pc.ontrack = (event) => {
|
pc.ontrack = (event) => {
|
||||||
const stream = event.streams[0];
|
const stream = event.streams[0];
|
||||||
if (stream && !this.videoStreams[remotePeerId]) {
|
if (stream && !this.videoStreams[remotePeerId]) {
|
||||||
const clonnedStream = stream.clone();
|
this.videoStreams[remotePeerId] = stream.getVideoTracks()[0];
|
||||||
this.videoStreams[remotePeerId] = clonnedStream.getVideoTracks()[0];
|
|
||||||
if (this.store.get().calling !== CallingState.OnCall) {
|
if (this.store.get().calling !== CallingState.OnCall) {
|
||||||
this.store.update({ calling: CallingState.OnCall });
|
this.store.update({ calling: CallingState.OnCall });
|
||||||
}
|
}
|
||||||
|
|
@ -305,7 +304,6 @@ export default class Call {
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// if the connection is not established yet, then set remoteDescription to peer
|
// if the connection is not established yet, then set remoteDescription to peer
|
||||||
if (!pc.localDescription) {
|
|
||||||
await pc.setRemoteDescription(new RTCSessionDescription(data.offer));
|
await pc.setRemoteDescription(new RTCSessionDescription(data.offer));
|
||||||
const answer = await pc.createAnswer();
|
const answer = await pc.createAnswer();
|
||||||
await pc.setLocalDescription(answer);
|
await pc.setLocalDescription(answer);
|
||||||
|
|
@ -319,9 +317,6 @@ export default class Call {
|
||||||
} else {
|
} else {
|
||||||
this.socket.emit('webrtc_call_answer', { from: fromCallId, answer });
|
this.socket.emit('webrtc_call_answer', { from: fromCallId, answer });
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
logger.warn('Skipping setRemoteDescription: Already in stable state');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Error setting remote description from answer', e);
|
logger.error('Error setting remote description from answer', e);
|
||||||
this.callArgs?.onError?.(e);
|
this.callArgs?.onError?.(e);
|
||||||
|
|
@ -388,13 +383,13 @@ export default class Call {
|
||||||
private handleCallEnd() {
|
private handleCallEnd() {
|
||||||
// If the call is not completed, then call onCallEnd
|
// If the call is not completed, then call onCallEnd
|
||||||
if (this.store.get().calling !== CallingState.NoCall) {
|
if (this.store.get().calling !== CallingState.NoCall) {
|
||||||
this.callArgs && this.callArgs.onCallEnd();
|
this.callArgs && this.callArgs.onRemoteCallEnd();
|
||||||
}
|
}
|
||||||
// change state to NoCall
|
// change state to NoCall
|
||||||
this.store.update({ calling: CallingState.NoCall });
|
this.store.update({ calling: CallingState.NoCall });
|
||||||
// Close all created RTCPeerConnection
|
// Close all created RTCPeerConnection
|
||||||
Object.values(this.connections).forEach((pc) => pc.close());
|
Object.values(this.connections).forEach((pc) => pc.close());
|
||||||
this.callArgs?.onCallEnd();
|
this.callArgs?.onRemoteCallEnd();
|
||||||
// Clear connections
|
// Clear connections
|
||||||
this.connections = {};
|
this.connections = {};
|
||||||
this.callArgs = null;
|
this.callArgs = null;
|
||||||
|
|
@ -414,7 +409,7 @@ export default class Call {
|
||||||
// Close all connections and reset callArgs
|
// Close all connections and reset callArgs
|
||||||
Object.values(this.connections).forEach((pc) => pc.close());
|
Object.values(this.connections).forEach((pc) => pc.close());
|
||||||
this.connections = {};
|
this.connections = {};
|
||||||
this.callArgs?.onCallEnd();
|
this.callArgs?.onRemoteCallEnd();
|
||||||
this.store.update({ calling: CallingState.NoCall });
|
this.store.update({ calling: CallingState.NoCall });
|
||||||
this.callArgs = null;
|
this.callArgs = null;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -443,7 +438,8 @@ export default class Call {
|
||||||
private callArgs: {
|
private callArgs: {
|
||||||
localStream: LocalStream;
|
localStream: LocalStream;
|
||||||
onStream: (s: MediaStream, isAgent: boolean) => void;
|
onStream: (s: MediaStream, isAgent: boolean) => void;
|
||||||
onCallEnd: () => void;
|
onRemoteCallEnd: () => void;
|
||||||
|
onLocalCallEnd: () => void;
|
||||||
onReject: () => void;
|
onReject: () => void;
|
||||||
onError?: (arg?: any) => void;
|
onError?: (arg?: any) => void;
|
||||||
} | null = null;
|
} | null = null;
|
||||||
|
|
@ -451,14 +447,16 @@ export default class Call {
|
||||||
setCallArgs(
|
setCallArgs(
|
||||||
localStream: LocalStream,
|
localStream: LocalStream,
|
||||||
onStream: (s: MediaStream, isAgent: boolean) => void,
|
onStream: (s: MediaStream, isAgent: boolean) => void,
|
||||||
onCallEnd: () => void,
|
onRemoteCallEnd: () => void,
|
||||||
|
onLocalCallEnd: () => void,
|
||||||
onReject: () => void,
|
onReject: () => void,
|
||||||
onError?: (e?: any) => void,
|
onError?: (e?: any) => void,
|
||||||
) {
|
) {
|
||||||
this.callArgs = {
|
this.callArgs = {
|
||||||
localStream,
|
localStream,
|
||||||
onStream,
|
onStream,
|
||||||
onCallEnd,
|
onRemoteCallEnd,
|
||||||
|
onLocalCallEnd,
|
||||||
onReject,
|
onReject,
|
||||||
onError,
|
onError,
|
||||||
};
|
};
|
||||||
|
|
@ -549,7 +547,7 @@ export default class Call {
|
||||||
void this.initiateCallEnd();
|
void this.initiateCallEnd();
|
||||||
Object.values(this.connections).forEach((pc) => pc.close());
|
Object.values(this.connections).forEach((pc) => pc.close());
|
||||||
this.connections = {};
|
this.connections = {};
|
||||||
this.callArgs?.onCallEnd();
|
this.callArgs?.onLocalCallEnd();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -138,26 +138,9 @@ export default class UserService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async resetPassword(data: any) {
|
async resetPassword(data: any) {
|
||||||
try {
|
|
||||||
const response = await this.client.post('/password/reset', data);
|
const response = await this.client.post('/password/reset', data);
|
||||||
const responseData = await response.json();
|
const responseData = await response.json();
|
||||||
if (responseData.errors) {
|
|
||||||
throw new Error(
|
|
||||||
responseData.errors[0] || 'An unexpected error occurred.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return responseData || {};
|
return responseData || {};
|
||||||
} catch (error: any) {
|
|
||||||
if (error.response) {
|
|
||||||
const errorData = await error.response.json();
|
|
||||||
const errorMessage = errorData.errors
|
|
||||||
? errorData.errors[0]
|
|
||||||
: 'An unexpected error occurred.';
|
|
||||||
throw new Error(errorMessage);
|
|
||||||
}
|
|
||||||
throw new Error('An unexpected error occurred.');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async requestResetPassword(data: any) {
|
async requestResetPassword(data: any) {
|
||||||
|
|
|
||||||
|
|
@ -597,8 +597,7 @@ function saveAsFile(blob: Blob, filename: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function exportAntCsv(tableColumns, tableData, filename = 'table.csv') {
|
export function exportAntCsv(tableColumns, tableData, filename = 'table.csv') {
|
||||||
console.log(tableColumns, tableData);
|
const headers = tableColumns.map((col) => col._pureTitle).join(',');
|
||||||
const headers = tableColumns.map((col) => col.title).join(',');
|
|
||||||
const rows = tableData.map((row) =>
|
const rows = tableData.map((row) =>
|
||||||
tableColumns
|
tableColumns
|
||||||
.map((col) => {
|
.map((col) => {
|
||||||
|
|
|
||||||
219
frontend/app/withRecaptcha.tsx
Normal file
219
frontend/app/withRecaptcha.tsx
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
import React, { useState, useRef, ComponentType, ReactNode, useCallback, useEffect, useLayoutEffect } from 'react';
|
||||||
|
import ReCAPTCHA from 'react-google-recaptcha';
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
|
// Define a more specific type for submission data
|
||||||
|
export interface SubmissionData {
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WithCaptchaProps {
|
||||||
|
submitWithCaptcha: (data: SubmissionData) => Promise<any>;
|
||||||
|
hasCaptchaError: boolean;
|
||||||
|
isVerifyingCaptcha: boolean;
|
||||||
|
resetCaptcha: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WithCaptchaOptions {
|
||||||
|
position?: 'visible' | 'hidden';
|
||||||
|
errorMessage?: string;
|
||||||
|
theme?: 'light' | 'dark';
|
||||||
|
size?: 'normal' | 'compact' | 'invisible';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safely get environment variables with fallbacks
|
||||||
|
const getCaptchaConfig = () => {
|
||||||
|
const enabled = typeof window !== 'undefined' &&
|
||||||
|
window.env?.CAPTCHA_ENABLED === 'true';
|
||||||
|
|
||||||
|
const siteKey = typeof window !== 'undefined' ?
|
||||||
|
window.env?.CAPTCHA_SITE_KEY || '' : '';
|
||||||
|
|
||||||
|
return { enabled, siteKey };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Higher-Order Component that adds reCAPTCHA functionality to a form component
|
||||||
|
*
|
||||||
|
* @param WrappedComponent The component to wrap with CAPTCHA functionality
|
||||||
|
* @param options Configuration options for the CAPTCHA behavior
|
||||||
|
* @returns A new component with CAPTCHA capabilities
|
||||||
|
*/
|
||||||
|
const withCaptcha = <P extends object>(
|
||||||
|
WrappedComponent: ComponentType<P & WithCaptchaProps>,
|
||||||
|
options: WithCaptchaOptions = {}
|
||||||
|
): React.FC<P> => {
|
||||||
|
// Default options
|
||||||
|
const {
|
||||||
|
position = 'hidden',
|
||||||
|
errorMessage = 'Please complete the CAPTCHA verification',
|
||||||
|
theme = 'light',
|
||||||
|
size = 'invisible'
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const WithCaptchaComponent: React.FC<P> = (props: P) => {
|
||||||
|
const { enabled: CAPTCHA_ENABLED, siteKey: CAPTCHA_SITE_KEY } = getCaptchaConfig();
|
||||||
|
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
|
||||||
|
const [isVerifyingCaptcha, setIsVerifyingCaptcha] = useState<boolean>(false);
|
||||||
|
const [tokenExpired, setTokenExpired] = useState<boolean>(false);
|
||||||
|
const recaptchaRef = useRef<ReCAPTCHA>(null);
|
||||||
|
|
||||||
|
// Reset token when expired
|
||||||
|
useEffect(() => {
|
||||||
|
if (tokenExpired) {
|
||||||
|
setCaptchaToken(null);
|
||||||
|
setTokenExpired(false);
|
||||||
|
}
|
||||||
|
}, [tokenExpired]);
|
||||||
|
|
||||||
|
// Handle token expiration
|
||||||
|
const onCaptchaExpired = useCallback(() => {
|
||||||
|
setTokenExpired(true);
|
||||||
|
if (CAPTCHA_ENABLED) {
|
||||||
|
toast.warning('CAPTCHA verification expired. Please verify again.');
|
||||||
|
}
|
||||||
|
}, [CAPTCHA_ENABLED]);
|
||||||
|
|
||||||
|
// Handle token change
|
||||||
|
let onCaptchaChange = (token: string | null) => {
|
||||||
|
console.log('Standard captcha callback received token:', !!token);
|
||||||
|
setCaptchaToken(token);
|
||||||
|
setTokenExpired(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reset captcha manually
|
||||||
|
const resetCaptcha = useCallback(() => {
|
||||||
|
recaptchaRef.current?.reset();
|
||||||
|
setCaptchaToken(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Submit with captcha verification
|
||||||
|
const submitWithCaptcha = useCallback(
|
||||||
|
(data: SubmissionData): Promise<any> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!CAPTCHA_ENABLED) {
|
||||||
|
// CAPTCHA not enabled, resolve with original data
|
||||||
|
resolve(data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsVerifyingCaptcha(true);
|
||||||
|
|
||||||
|
// Special handling for invisible reCAPTCHA
|
||||||
|
if (size === 'invisible') {
|
||||||
|
// Create a direct token handler function
|
||||||
|
const handleToken = (receivedToken: string | null) => {
|
||||||
|
console.log('reCAPTCHA token received:', !!receivedToken);
|
||||||
|
|
||||||
|
if (receivedToken) {
|
||||||
|
// We have a token, resolve the promise
|
||||||
|
const dataWithCaptcha = {
|
||||||
|
...data,
|
||||||
|
'g-recaptcha-response': receivedToken
|
||||||
|
};
|
||||||
|
|
||||||
|
resolve(dataWithCaptcha);
|
||||||
|
|
||||||
|
// Reset for next use
|
||||||
|
setTimeout(() => {
|
||||||
|
recaptchaRef.current?.reset();
|
||||||
|
setIsVerifyingCaptcha(false);
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set up a callback directly on the reCAPTCHA ref
|
||||||
|
if (recaptchaRef.current) {
|
||||||
|
console.log('Executing invisible reCAPTCHA');
|
||||||
|
|
||||||
|
// Execute the reCAPTCHA challenge
|
||||||
|
recaptchaRef.current.executeAsync()
|
||||||
|
.then((token: string | null) => {
|
||||||
|
handleToken(token);
|
||||||
|
})
|
||||||
|
.catch((error: any) => {
|
||||||
|
console.error('reCAPTCHA execution failed:', error);
|
||||||
|
setIsVerifyingCaptcha(false);
|
||||||
|
reject(new Error('CAPTCHA verification failed'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set a timeout in case the promise doesn't resolve
|
||||||
|
setTimeout(() => {
|
||||||
|
if (isVerifyingCaptcha) {
|
||||||
|
console.log('reCAPTCHA verification timed out');
|
||||||
|
setIsVerifyingCaptcha(false);
|
||||||
|
toast.error(errorMessage || 'Verification timed out. Please try again.');
|
||||||
|
reject(new Error('CAPTCHA verification timeout'));
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
} else {
|
||||||
|
console.error('reCAPTCHA ref not available');
|
||||||
|
setIsVerifyingCaptcha(false);
|
||||||
|
reject(new Error('CAPTCHA component not initialized'));
|
||||||
|
}
|
||||||
|
} else if (captchaToken) {
|
||||||
|
// Standard reCAPTCHA with token already available
|
||||||
|
const dataWithCaptcha = {
|
||||||
|
...data,
|
||||||
|
'g-recaptcha-response': captchaToken
|
||||||
|
};
|
||||||
|
|
||||||
|
resolve(dataWithCaptcha);
|
||||||
|
recaptchaRef.current?.reset();
|
||||||
|
setCaptchaToken(null);
|
||||||
|
setIsVerifyingCaptcha(false);
|
||||||
|
} else {
|
||||||
|
// Standard reCAPTCHA but no token yet
|
||||||
|
toast.error(errorMessage || 'Please complete the CAPTCHA verification');
|
||||||
|
reject(new Error('CAPTCHA verification required'));
|
||||||
|
setIsVerifyingCaptcha(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[CAPTCHA_ENABLED, captchaToken, errorMessage, size, isVerifyingCaptcha]
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasCaptchaError = !captchaToken && CAPTCHA_ENABLED === true;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{CAPTCHA_ENABLED && (
|
||||||
|
<div className={position === 'hidden' ? 'sr-only' : 'mb-4'}>
|
||||||
|
<ReCAPTCHA
|
||||||
|
ref={recaptchaRef}
|
||||||
|
sitekey={CAPTCHA_SITE_KEY}
|
||||||
|
onChange={onCaptchaChange}
|
||||||
|
onExpired={onCaptchaExpired}
|
||||||
|
theme={theme}
|
||||||
|
size={size}
|
||||||
|
/>
|
||||||
|
{hasCaptchaError && (
|
||||||
|
<div className="text-red-500 text-sm mt-1">
|
||||||
|
{errorMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<WrappedComponent
|
||||||
|
{...props}
|
||||||
|
submitWithCaptcha={submitWithCaptcha}
|
||||||
|
hasCaptchaError={hasCaptchaError}
|
||||||
|
isVerifyingCaptcha={isVerifyingCaptcha}
|
||||||
|
resetCaptcha={resetCaptcha}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Display name for debugging
|
||||||
|
const wrappedComponentName =
|
||||||
|
WrappedComponent.displayName ||
|
||||||
|
WrappedComponent.name ||
|
||||||
|
'Component';
|
||||||
|
|
||||||
|
WithCaptchaComponent.displayName = `WithCaptcha(${wrappedComponentName})`;
|
||||||
|
|
||||||
|
return WithCaptchaComponent;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withCaptcha;
|
||||||
|
|
@ -10,7 +10,15 @@ metadata:
|
||||||
{{- include "assist.labels" . | nindent 4 }}
|
{{- include "assist.labels" . | nindent 4 }}
|
||||||
annotations:
|
annotations:
|
||||||
nginx.ingress.kubernetes.io/rewrite-target: /$1
|
nginx.ingress.kubernetes.io/rewrite-target: /$1
|
||||||
nginx.ingress.kubernetes.io/upstream-hash-by: $http_x_forwarded_for
|
nginx.ingress.kubernetes.io/configuration-snippet: |
|
||||||
|
# Extract sessionID from peerId using regex
|
||||||
|
if ($arg_peerId ~ ".*-(?<extracted_sid>[^-]+)-.*") {
|
||||||
|
set $session_id $extracted_sid;
|
||||||
|
}
|
||||||
|
add_header X-Debug-Session-ID $session_id;
|
||||||
|
add_header X-Debug-Session-Type "wss";
|
||||||
|
nginx.ingress.kubernetes.io/upstream-hash-by: $session_id
|
||||||
|
|
||||||
{{- with .Values.ingress.annotations }}
|
{{- with .Values.ingress.annotations }}
|
||||||
{{- toYaml . | nindent 4 }}
|
{{- toYaml . | nindent 4 }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
|
||||||
|
|
@ -18,4 +18,4 @@ version: 0.1.10
|
||||||
# incremented each time you make changes to the application. Versions are not expected to
|
# incremented each time you make changes to the application. Versions are not expected to
|
||||||
# follow Semantic Versioning. They should reflect the version the application is using.
|
# follow Semantic Versioning. They should reflect the version the application is using.
|
||||||
# It is recommended to use it with quotes.
|
# It is recommended to use it with quotes.
|
||||||
AppVersion: "v1.22.0"
|
AppVersion: "v1.22.1"
|
||||||
|
|
|
||||||
24
scripts/schema/db/init_dbs/clickhouse/1.23.0/1.23.0.sql
Normal file
24
scripts/schema/db/init_dbs/clickhouse/1.23.0/1.23.0.sql
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
CREATE OR REPLACE FUNCTION openreplay_version AS() -> 'v1.23.0';
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS experimental.user_viewed_sessions
|
||||||
|
(
|
||||||
|
project_id UInt16,
|
||||||
|
user_id UInt32,
|
||||||
|
session_id UInt64,
|
||||||
|
_timestamp DateTime DEFAULT now()
|
||||||
|
) ENGINE = ReplacingMergeTree(_timestamp)
|
||||||
|
PARTITION BY toYYYYMM(_timestamp)
|
||||||
|
ORDER BY (project_id, user_id, session_id)
|
||||||
|
TTL _timestamp + INTERVAL 3 MONTH;
|
||||||
|
|
||||||
|
-- The full list of event-properties (used to tell which property belongs to which event)
|
||||||
|
CREATE TABLE IF NOT EXISTS product_analytics.event_properties
|
||||||
|
(
|
||||||
|
project_id UInt16,
|
||||||
|
event_name String,
|
||||||
|
property_name String,
|
||||||
|
|
||||||
|
_timestamp DateTime DEFAULT now()
|
||||||
|
) ENGINE = ReplacingMergeTree(_timestamp)
|
||||||
|
ORDER BY (project_id, event_name, property_name);
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
CREATE OR REPLACE FUNCTION openreplay_version AS() -> 'v1.22.0';
|
CREATE OR REPLACE FUNCTION openreplay_version AS() -> 'v1.23.0';
|
||||||
CREATE DATABASE IF NOT EXISTS experimental;
|
CREATE DATABASE IF NOT EXISTS experimental;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS experimental.autocomplete
|
CREATE TABLE IF NOT EXISTS experimental.autocomplete
|
||||||
|
|
@ -515,6 +515,17 @@ CREATE TABLE IF NOT EXISTS product_analytics.all_events
|
||||||
) ENGINE = ReplacingMergeTree(_timestamp)
|
) ENGINE = ReplacingMergeTree(_timestamp)
|
||||||
ORDER BY (project_id, event_name);
|
ORDER BY (project_id, event_name);
|
||||||
|
|
||||||
|
-- The full list of event-properties (used to tell which property belongs to which event)
|
||||||
|
CREATE TABLE IF NOT EXISTS product_analytics.event_properties
|
||||||
|
(
|
||||||
|
project_id UInt16,
|
||||||
|
event_name String,
|
||||||
|
property_name String,
|
||||||
|
|
||||||
|
_timestamp DateTime DEFAULT now()
|
||||||
|
) ENGINE = ReplacingMergeTree(_timestamp)
|
||||||
|
ORDER BY (project_id, event_name, property_name);
|
||||||
|
|
||||||
|
|
||||||
-- The full list of properties (events and users)
|
-- The full list of properties (events and users)
|
||||||
CREATE TABLE IF NOT EXISTS product_analytics.all_properties
|
CREATE TABLE IF NOT EXISTS product_analytics.all_properties
|
||||||
|
|
@ -532,3 +543,15 @@ CREATE TABLE IF NOT EXISTS product_analytics.all_properties
|
||||||
_timestamp DateTime DEFAULT now()
|
_timestamp DateTime DEFAULT now()
|
||||||
) ENGINE = ReplacingMergeTree(_timestamp)
|
) ENGINE = ReplacingMergeTree(_timestamp)
|
||||||
ORDER BY (project_id, property_name, is_event_property);
|
ORDER BY (project_id, property_name, is_event_property);
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS experimental.user_viewed_sessions
|
||||||
|
(
|
||||||
|
project_id UInt16,
|
||||||
|
user_id UInt32,
|
||||||
|
session_id UInt64,
|
||||||
|
_timestamp DateTime DEFAULT now()
|
||||||
|
) ENGINE = ReplacingMergeTree(_timestamp)
|
||||||
|
PARTITION BY toYYYYMM(_timestamp)
|
||||||
|
ORDER BY (project_id, user_id, session_id)
|
||||||
|
TTL _timestamp + INTERVAL 3 MONTH;
|
||||||
|
|
|
||||||
30
scripts/schema/db/init_dbs/postgresql/1.23.0/1.23.0.sql
Normal file
30
scripts/schema/db/init_dbs/postgresql/1.23.0/1.23.0.sql
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
\set previous_version 'v1.22.0'
|
||||||
|
\set next_version 'v1.23.0'
|
||||||
|
SELECT openreplay_version() AS current_version,
|
||||||
|
openreplay_version() = :'previous_version' AS valid_previous,
|
||||||
|
openreplay_version() = :'next_version' AS is_next
|
||||||
|
\gset
|
||||||
|
|
||||||
|
\if :valid_previous
|
||||||
|
\echo valid previous DB version :'previous_version', starting DB upgrade to :'next_version'
|
||||||
|
BEGIN;
|
||||||
|
SELECT format($fn_def$
|
||||||
|
CREATE OR REPLACE FUNCTION openreplay_version()
|
||||||
|
RETURNS text AS
|
||||||
|
$$
|
||||||
|
SELECT '%1$s'
|
||||||
|
$$ LANGUAGE sql IMMUTABLE;
|
||||||
|
$fn_def$, :'next_version')
|
||||||
|
\gexec
|
||||||
|
|
||||||
|
--
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
\elif :is_next
|
||||||
|
\echo new version detected :'next_version', nothing to do
|
||||||
|
\else
|
||||||
|
\warn skipping DB upgrade of :'next_version', expected previous version :'previous_version', found :'current_version'
|
||||||
|
\endif
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
\set or_version 'v1.22.0'
|
\set or_version 'v1.23.0'
|
||||||
SET client_min_messages TO NOTICE;
|
SET client_min_messages TO NOTICE;
|
||||||
\set ON_ERROR_STOP true
|
\set ON_ERROR_STOP true
|
||||||
SELECT EXISTS (SELECT 1
|
SELECT EXISTS (SELECT 1
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
CREATE OR REPLACE FUNCTION openreplay_version AS() -> 'v1.22.0';
|
||||||
|
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS experimental.user_viewed_sessions;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS product_analytics.event_properties;
|
||||||
27
scripts/schema/db/rollback_dbs/postgresql/1.23.0/1.23.0.sql
Normal file
27
scripts/schema/db/rollback_dbs/postgresql/1.23.0/1.23.0.sql
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
\set previous_version 'v1.23.0'
|
||||||
|
\set next_version 'v1.22.0'
|
||||||
|
SELECT openreplay_version() AS current_version,
|
||||||
|
openreplay_version() = :'previous_version' AS valid_previous,
|
||||||
|
openreplay_version() = :'next_version' AS is_next
|
||||||
|
\gset
|
||||||
|
|
||||||
|
\if :valid_previous
|
||||||
|
\echo valid previous DB version :'previous_version', starting DB downgrade to :'next_version'
|
||||||
|
BEGIN;
|
||||||
|
SELECT format($fn_def$
|
||||||
|
CREATE OR REPLACE FUNCTION openreplay_version()
|
||||||
|
RETURNS text AS
|
||||||
|
$$
|
||||||
|
SELECT '%1$s'
|
||||||
|
$$ LANGUAGE sql IMMUTABLE;
|
||||||
|
$fn_def$, :'next_version')
|
||||||
|
\gexec
|
||||||
|
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
\elif :is_next
|
||||||
|
\echo new version detected :'next_version', nothing to do
|
||||||
|
\else
|
||||||
|
\warn skipping DB downgrade of :'next_version', expected previous version :'previous_version', found :'current_version'
|
||||||
|
\endif
|
||||||
|
|
@ -42,7 +42,7 @@ up to date with every new library you use.
|
||||||
| elasticsearch-py | Apache2 | Python |
|
| elasticsearch-py | Apache2 | Python |
|
||||||
| jira | BSD2 | Python |
|
| jira | BSD2 | Python |
|
||||||
| redis-py | MIT | Python |
|
| redis-py | MIT | Python |
|
||||||
| clickhouse-driver | MIT | Python |
|
| clickhouse-connect | Apache2 | Python |
|
||||||
| python3-saml | MIT | Python |
|
| python3-saml | MIT | Python |
|
||||||
| kubernetes | Apache2 | Python |
|
| kubernetes | Apache2 | Python |
|
||||||
| chalice | Apache2 | Python |
|
| chalice | Apache2 | Python |
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -1,3 +1,12 @@
|
||||||
|
## 11.0.2
|
||||||
|
|
||||||
|
- add sessionId header on socket.connect for sticky sessions
|
||||||
|
|
||||||
|
## 11.0.1
|
||||||
|
|
||||||
|
- fixed rare issue causing videocam feed to be black during calls
|
||||||
|
- new call widget url to prepare for multi-user calls
|
||||||
|
|
||||||
## 11.0.0
|
## 11.0.0
|
||||||
|
|
||||||
- migrate to native webrtc, remove peerjs
|
- migrate to native webrtc, remove peerjs
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "@openreplay/tracker-assist",
|
"name": "@openreplay/tracker-assist",
|
||||||
"description": "Tracker plugin for screen assistance through the WebRTC",
|
"description": "Tracker plugin for screen assistance through the WebRTC",
|
||||||
"version": "11.0.0",
|
"version": "11.0.2-beta.1",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"WebRTC",
|
"WebRTC",
|
||||||
"assistance",
|
"assistance",
|
||||||
|
|
|
||||||
|
|
@ -238,7 +238,10 @@ export default class Assist {
|
||||||
...this.app.getSessionInfo(),
|
...this.app.getSessionInfo(),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
transports: ['websocket',],
|
extraHeaders: {
|
||||||
|
sessionId,
|
||||||
|
},
|
||||||
|
transports: ['polling', 'websocket',],
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
reconnection: true,
|
reconnection: true,
|
||||||
reconnectionAttempts: 30,
|
reconnectionAttempts: 30,
|
||||||
|
|
@ -548,6 +551,16 @@ export default class Assist {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const renegotiateConnection = async ({ pc, from }: { pc: RTCPeerConnection, from: string }) => {
|
||||||
|
try {
|
||||||
|
const offer = await pc.createOffer();
|
||||||
|
await pc.setLocalDescription(offer);
|
||||||
|
this.emit('webrtc_call_offer', { from, offer });
|
||||||
|
} catch (error) {
|
||||||
|
app.debug.error("Error with renegotiation:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleIncomingCallOffer = async (from: string, offer: RTCSessionDescriptionInit) => {
|
const handleIncomingCallOffer = async (from: string, offer: RTCSessionDescriptionInit) => {
|
||||||
app.debug.log('handleIncomingCallOffer', from)
|
app.debug.log('handleIncomingCallOffer', from)
|
||||||
let confirmAnswer: Promise<boolean>
|
let confirmAnswer: Promise<boolean>
|
||||||
|
|
@ -572,56 +585,59 @@ export default class Assist {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// waiting for a decision on accepting the challenge
|
// waiting for a decision on accepting the challenge
|
||||||
const agreed = await confirmAnswer
|
const agreed = await confirmAnswer;
|
||||||
// if rejected, then terminate the call
|
// if rejected, then terminate the call
|
||||||
if (!agreed) {
|
if (!agreed) {
|
||||||
initiateCallEnd()
|
|
||||||
this.options.onCallDeny?.()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!callUI) {
|
|
||||||
callUI = new CallWindow(app.debug.error, this.options.callUITemplate)
|
|
||||||
callUI.setVideoToggleCallback((args: { enabled: boolean }) =>
|
|
||||||
this.emit('videofeed', { streamId: from, enabled: args.enabled })
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// show buttons in the call window
|
|
||||||
callUI.showControls(initiateCallEnd)
|
|
||||||
if (!annot) {
|
|
||||||
annot = new AnnotationCanvas()
|
|
||||||
annot.mount()
|
|
||||||
}
|
|
||||||
|
|
||||||
// callUI.setLocalStreams(Object.values(lStreams))
|
|
||||||
try {
|
|
||||||
// if there are no local streams in lStrems then we set
|
|
||||||
if (!lStreams[from]) {
|
|
||||||
app.debug.log('starting new stream for', from)
|
|
||||||
// request a local stream, and set it to lStreams
|
|
||||||
lStreams[from] = await RequestLocalStream()
|
|
||||||
}
|
|
||||||
// we pass the received tracks to Call ui
|
|
||||||
callUI.setLocalStreams(Object.values(lStreams))
|
|
||||||
} catch (e) {
|
|
||||||
app.debug.error('Error requesting local stream', e);
|
|
||||||
// if something didn't work out, we terminate the call
|
|
||||||
initiateCallEnd();
|
initiateCallEnd();
|
||||||
|
this.options.onCallDeny?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// create a new RTCPeerConnection with ice server config
|
// create a new RTCPeerConnection with ice server config
|
||||||
const pc = new RTCPeerConnection({
|
const pc = new RTCPeerConnection({
|
||||||
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
|
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
|
||||||
});
|
});
|
||||||
|
|
||||||
// get all local tracks and add them to RTCPeerConnection
|
if (!callUI) {
|
||||||
lStreams[from].stream.getTracks().forEach(track => {
|
callUI = new CallWindow(app.debug.error, this.options.callUITemplate);
|
||||||
pc.addTrack(track, lStreams[from].stream);
|
callUI.setVideoToggleCallback((args: { enabled: boolean }) => {
|
||||||
|
this.emit("videofeed", { streamId: from, enabled: args.enabled })
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
// show buttons in the call window
|
||||||
|
callUI.showControls(initiateCallEnd);
|
||||||
|
if (!annot) {
|
||||||
|
annot = new AnnotationCanvas();
|
||||||
|
annot.mount();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// callUI.setLocalStreams(Object.values(lStreams))
|
||||||
|
try {
|
||||||
|
// if there are no local streams in lStrems then we set
|
||||||
|
if (!lStreams[from]) {
|
||||||
|
app.debug.log("starting new stream for", from);
|
||||||
|
// request a local stream, and set it to lStreams
|
||||||
|
lStreams[from] = await RequestLocalStream(pc, renegotiateConnection.bind(null, { pc, from }));
|
||||||
|
}
|
||||||
|
// we pass the received tracks to Call ui
|
||||||
|
callUI.setLocalStreams(Object.values(lStreams));
|
||||||
|
} catch (e) {
|
||||||
|
app.debug.error("Error requesting local stream", e);
|
||||||
|
// if something didn't work out, we terminate the call
|
||||||
|
initiateCallEnd();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// get all local tracks and add them to RTCPeerConnection
|
||||||
// When we receive local ice candidates, we emit them via socket
|
// When we receive local ice candidates, we emit them via socket
|
||||||
pc.onicecandidate = (event) => {
|
pc.onicecandidate = (event) => {
|
||||||
if (event.candidate) {
|
if (event.candidate) {
|
||||||
socket.emit('webrtc_call_ice_candidate', { from, candidate: event.candidate });
|
socket.emit("webrtc_call_ice_candidate", {
|
||||||
|
from,
|
||||||
|
candidate: event.candidate,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -632,9 +648,9 @@ export default class Assist {
|
||||||
callUI.addRemoteStream(rStream, from);
|
callUI.addRemoteStream(rStream, from);
|
||||||
const onInteraction = () => {
|
const onInteraction = () => {
|
||||||
callUI?.playRemote();
|
callUI?.playRemote();
|
||||||
document.removeEventListener('click', onInteraction);
|
document.removeEventListener("click", onInteraction);
|
||||||
};
|
};
|
||||||
document.addEventListener('click', onInteraction);
|
document.addEventListener("click", onInteraction);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -648,7 +664,7 @@ export default class Assist {
|
||||||
// set answer as local description
|
// set answer as local description
|
||||||
await pc.setLocalDescription(answer);
|
await pc.setLocalDescription(answer);
|
||||||
// set the response as local
|
// set the response as local
|
||||||
socket.emit('webrtc_call_answer', { from, answer });
|
socket.emit("webrtc_call_answer", { from, answer });
|
||||||
|
|
||||||
// If the state changes to an error, we terminate the call
|
// If the state changes to an error, we terminate the call
|
||||||
// pc.onconnectionstatechange = () => {
|
// pc.onconnectionstatechange = () => {
|
||||||
|
|
@ -658,27 +674,35 @@ export default class Assist {
|
||||||
// };
|
// };
|
||||||
|
|
||||||
// Update track when local video changes
|
// Update track when local video changes
|
||||||
lStreams[from].onVideoTrack(vTrack => {
|
lStreams[from].onVideoTrack((vTrack) => {
|
||||||
const sender = pc.getSenders().find(s => s.track?.kind === 'video');
|
const sender = pc.getSenders().find((s) => s.track?.kind === "video");
|
||||||
if (!sender) {
|
if (!sender) {
|
||||||
app.debug.warn('No video sender found')
|
app.debug.warn("No video sender found");
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
sender.replaceTrack(vTrack)
|
sender.replaceTrack(vTrack);
|
||||||
})
|
});
|
||||||
|
|
||||||
// if the user closed the tab or switched, then we end the call
|
// if the user closed the tab or switched, then we end the call
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener("visibilitychange", () => {
|
||||||
initiateCallEnd()
|
initiateCallEnd();
|
||||||
})
|
});
|
||||||
|
|
||||||
// when everything is set, we change the state to true
|
// when everything is set, we change the state to true
|
||||||
this.setCallingState(CallingState.True)
|
this.setCallingState(CallingState.True);
|
||||||
if (!callEndCallback) { callEndCallback = this.options.onCallStart?.() }
|
if (!callEndCallback) {
|
||||||
const callingPeerIdsNow = Array.from(this.calls.keys())
|
callEndCallback = this.options.onCallStart?.();
|
||||||
|
}
|
||||||
|
const callingPeerIdsNow = Array.from(this.calls.keys());
|
||||||
// in session storage we write down everyone with whom the call is established
|
// in session storage we write down everyone with whom the call is established
|
||||||
sessionStorage.setItem(this.options.session_calling_peer_key, JSON.stringify(callingPeerIdsNow))
|
sessionStorage.setItem(
|
||||||
this.emit('UPDATE_SESSION', { agentIds: callingPeerIdsNow, isCallActive: true })
|
this.options.session_calling_peer_key,
|
||||||
|
JSON.stringify(callingPeerIdsNow)
|
||||||
|
);
|
||||||
|
this.emit("UPDATE_SESSION", {
|
||||||
|
agentIds: callingPeerIdsNow,
|
||||||
|
isCallActive: true,
|
||||||
|
});
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
app.debug.log(reason);
|
app.debug.log(reason);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ export default class CallWindow {
|
||||||
}
|
}
|
||||||
|
|
||||||
// const baseHref = "https://static.openreplay.com/tracker-assist/test"
|
// const baseHref = "https://static.openreplay.com/tracker-assist/test"
|
||||||
const baseHref = 'https://static.openreplay.com/tracker-assist/4.0.0'
|
const baseHref = 'https://static.openreplay.com/tracker-assist/widget'
|
||||||
// this.load = fetch(this.callUITemplate || baseHref + '/index2.html')
|
// this.load = fetch(this.callUITemplate || baseHref + '/index2.html')
|
||||||
this.load = fetch(this.callUITemplate || baseHref + '/index.html')
|
this.load = fetch(this.callUITemplate || baseHref + '/index.html')
|
||||||
.then((r) => r.text())
|
.then((r) => r.text())
|
||||||
|
|
@ -152,15 +152,6 @@ export default class CallWindow {
|
||||||
if (this.checkRemoteVideoInterval) {
|
if (this.checkRemoteVideoInterval) {
|
||||||
clearInterval(this.checkRemoteVideoInterval)
|
clearInterval(this.checkRemoteVideoInterval)
|
||||||
} // just in case
|
} // just in case
|
||||||
let enabled = false
|
|
||||||
this.checkRemoteVideoInterval = setInterval(() => {
|
|
||||||
const settings = this.remoteVideo?.getSettings()
|
|
||||||
const isDummyVideoTrack = !this.remoteVideo.enabled || (!!settings && (settings.width === 2 || settings.frameRate === 0))
|
|
||||||
const shouldBeEnabled = !isDummyVideoTrack
|
|
||||||
if (enabled !== shouldBeEnabled) {
|
|
||||||
this.toggleRemoteVideoUI((enabled = shouldBeEnabled))
|
|
||||||
}
|
|
||||||
}, 1000)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Audio
|
// Audio
|
||||||
|
|
|
||||||
|
|
@ -1,88 +1,86 @@
|
||||||
declare global {
|
export default function RequestLocalStream(
|
||||||
interface HTMLCanvasElement {
|
pc: RTCPeerConnection,
|
||||||
captureStream(frameRate?: number): MediaStream;
|
toggleVideoCb?: () => void
|
||||||
|
): Promise<LocalStream> {
|
||||||
|
return navigator.mediaDevices
|
||||||
|
.getUserMedia({ audio: true, video: false })
|
||||||
|
.then((stream) => {
|
||||||
|
const aTrack = stream.getAudioTracks()[0];
|
||||||
|
if (!aTrack) {
|
||||||
|
throw new Error("No audio tracks provided");
|
||||||
}
|
}
|
||||||
}
|
stream.getTracks().forEach((track) => {
|
||||||
|
pc.addTrack(track, stream);
|
||||||
function dummyTrack(): MediaStreamTrack {
|
});
|
||||||
const canvas = document.createElement('canvas')//, { width: 0, height: 0})
|
return new _LocalStream(stream, pc, toggleVideoCb);
|
||||||
canvas.setAttribute('data-openreplay-hidden', '1')
|
});
|
||||||
canvas.width=canvas.height=2 // Doesn't work when 1 (?!)
|
|
||||||
const ctx = canvas.getContext('2d')
|
|
||||||
ctx?.fillRect(0, 0, canvas.width, canvas.height)
|
|
||||||
requestAnimationFrame(function draw(){
|
|
||||||
ctx?.fillRect(0,0, canvas.width, canvas.height)
|
|
||||||
requestAnimationFrame(draw)
|
|
||||||
})
|
|
||||||
// Also works. Probably it should be done once connected.
|
|
||||||
//setTimeout(() => { ctx?.fillRect(0,0, canvas.width, canvas.height) }, 4000)
|
|
||||||
return canvas.captureStream(60).getTracks()[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RequestLocalStream(): Promise<LocalStream> {
|
|
||||||
return navigator.mediaDevices.getUserMedia({ audio:true, })
|
|
||||||
.then(aStream => {
|
|
||||||
const aTrack = aStream.getAudioTracks()[0]
|
|
||||||
|
|
||||||
if (!aTrack) { throw new Error('No audio tracks provided') }
|
|
||||||
return new _LocalStream(aTrack)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _LocalStream {
|
class _LocalStream {
|
||||||
private mediaRequested = false
|
private mediaRequested = false;
|
||||||
readonly stream: MediaStream
|
readonly stream: MediaStream;
|
||||||
private readonly vdTrack: MediaStreamTrack
|
readonly vTrack: MediaStreamTrack;
|
||||||
constructor(aTrack: MediaStreamTrack) {
|
readonly pc: RTCPeerConnection;
|
||||||
this.vdTrack = dummyTrack()
|
readonly toggleVideoCb?: () => void;
|
||||||
this.stream = new MediaStream([ aTrack, this.vdTrack, ])
|
constructor(stream: MediaStream, pc: RTCPeerConnection, toggleVideoCb?: () => void) {
|
||||||
|
this.stream = stream;
|
||||||
|
this.pc = pc;
|
||||||
|
this.toggleVideoCb = toggleVideoCb;
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleVideo(): Promise<boolean> {
|
toggleVideo(): Promise<boolean> {
|
||||||
|
const videoTracks = this.stream.getVideoTracks();
|
||||||
if (!this.mediaRequested) {
|
if (!this.mediaRequested) {
|
||||||
return navigator.mediaDevices.getUserMedia({video:true,})
|
return navigator.mediaDevices
|
||||||
.then(vStream => {
|
.getUserMedia({ video: true })
|
||||||
const vTrack = vStream.getVideoTracks()[0]
|
.then((vStream) => {
|
||||||
|
const vTrack = vStream.getVideoTracks()[0];
|
||||||
if (!vTrack) {
|
if (!vTrack) {
|
||||||
throw new Error('No video track provided')
|
throw new Error("No video track provided");
|
||||||
}
|
}
|
||||||
this.stream.addTrack(vTrack)
|
|
||||||
this.stream.removeTrack(this.vdTrack)
|
this.pc.addTrack(vTrack, this.stream);
|
||||||
this.mediaRequested = true
|
this.stream.addTrack(vTrack);
|
||||||
|
|
||||||
|
if (this.toggleVideoCb) {
|
||||||
|
this.toggleVideoCb();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.mediaRequested = true;
|
||||||
|
|
||||||
if (this.onVideoTrackCb) {
|
if (this.onVideoTrackCb) {
|
||||||
this.onVideoTrackCb(vTrack)
|
this.onVideoTrackCb(vTrack);
|
||||||
}
|
}
|
||||||
return true
|
return true;
|
||||||
})
|
})
|
||||||
.catch(e => {
|
.catch((e) => {
|
||||||
// TODO: log
|
// TODO: log
|
||||||
console.error(e)
|
return false;
|
||||||
return false
|
});
|
||||||
})
|
} else {
|
||||||
|
videoTracks.forEach((track) => {
|
||||||
|
track.enabled = !track.enabled;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
let enabled = true
|
return Promise.resolve(videoTracks[0].enabled);
|
||||||
this.stream.getVideoTracks().forEach(track => {
|
|
||||||
track.enabled = enabled = enabled && !track.enabled
|
|
||||||
})
|
|
||||||
return Promise.resolve(enabled)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleAudio(): boolean {
|
toggleAudio(): boolean {
|
||||||
let enabled = true
|
let enabled = true;
|
||||||
this.stream.getAudioTracks().forEach(track => {
|
this.stream.getAudioTracks().forEach((track) => {
|
||||||
track.enabled = enabled = enabled && !track.enabled
|
track.enabled = enabled = enabled && !track.enabled;
|
||||||
})
|
});
|
||||||
return enabled
|
return enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
private onVideoTrackCb: ((t: MediaStreamTrack) => void) | null = null
|
private onVideoTrackCb: ((t: MediaStreamTrack) => void) | null = null;
|
||||||
onVideoTrack(cb: (t: MediaStreamTrack) => void) {
|
onVideoTrack(cb: (t: MediaStreamTrack) => void) {
|
||||||
this.onVideoTrackCb = cb
|
this.onVideoTrackCb = cb;
|
||||||
}
|
}
|
||||||
|
|
||||||
stop() {
|
stop() {
|
||||||
this.stream.getTracks().forEach(t => t.stop())
|
this.stream.getTracks().forEach((t) => t.stop());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LocalStream = InstanceType<typeof _LocalStream>
|
export type LocalStream = InstanceType<typeof _LocalStream>;
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
export const pkgVersion = "11.0.0";
|
export const pkgVersion = "11.0.2-beta.1";
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -1,3 +1,7 @@
|
||||||
|
## 16.0.2
|
||||||
|
|
||||||
|
- fix attributeSender key generation to prevent calling native methods on objects
|
||||||
|
|
||||||
## 16.0.1
|
## 16.0.1
|
||||||
|
|
||||||
- drop computing ts digits
|
- drop computing ts digits
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "@openreplay/tracker",
|
"name": "@openreplay/tracker",
|
||||||
"description": "The OpenReplay tracker main package",
|
"description": "The OpenReplay tracker main package",
|
||||||
"version": "16.0.1",
|
"version": "16.0.2",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"logging",
|
"logging",
|
||||||
"replay"
|
"replay"
|
||||||
|
|
|
||||||
|
|
@ -848,8 +848,7 @@ export default class App {
|
||||||
* */
|
* */
|
||||||
private _nCommit(): void {
|
private _nCommit(): void {
|
||||||
if (this.socketMode) {
|
if (this.socketMode) {
|
||||||
this.messages.unshift(TabData(this.session.getTabId()))
|
this.messages.unshift(Timestamp(this.timestamp()), TabData(this.session.getTabId()))
|
||||||
this.messages.unshift(Timestamp(this.timestamp()))
|
|
||||||
this.commitCallbacks.forEach((cb) => cb(this.messages))
|
this.commitCallbacks.forEach((cb) => cb(this.messages))
|
||||||
this.messages.length = 0
|
this.messages.length = 0
|
||||||
return
|
return
|
||||||
|
|
@ -874,8 +873,7 @@ export default class App {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
requestIdleCb(() => {
|
requestIdleCb(() => {
|
||||||
this.messages.unshift(TabData(this.session.getTabId()))
|
this.messages.unshift(Timestamp(this.timestamp()), TabData(this.session.getTabId()))
|
||||||
this.messages.unshift(Timestamp(this.timestamp()))
|
|
||||||
this.worker?.postMessage(this.messages)
|
this.worker?.postMessage(this.messages)
|
||||||
this.commitCallbacks.forEach((cb) => cb(this.messages))
|
this.commitCallbacks.forEach((cb) => cb(this.messages))
|
||||||
this.messages.length = 0
|
this.messages.length = 0
|
||||||
|
|
@ -900,10 +898,9 @@ export default class App {
|
||||||
private _cStartCommit(): void {
|
private _cStartCommit(): void {
|
||||||
this.coldStartCommitN += 1
|
this.coldStartCommitN += 1
|
||||||
if (this.coldStartCommitN === 2) {
|
if (this.coldStartCommitN === 2) {
|
||||||
this.bufferedMessages1.push(Timestamp(this.timestamp()))
|
const payload = [Timestamp(this.timestamp()), TabData(this.session.getTabId())]
|
||||||
this.bufferedMessages1.push(TabData(this.session.getTabId()))
|
this.bufferedMessages1.push(...payload)
|
||||||
this.bufferedMessages2.push(Timestamp(this.timestamp()))
|
this.bufferedMessages2.push(...payload)
|
||||||
this.bufferedMessages2.push(TabData(this.session.getTabId()))
|
|
||||||
this.coldStartCommitN = 0
|
this.coldStartCommitN = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,9 @@ export class StringDictionary {
|
||||||
|
|
||||||
getKey = (str: string): [number, boolean] => {
|
getKey = (str: string): [number, boolean] => {
|
||||||
let isNew = false
|
let isNew = false
|
||||||
if (!this.backDict[str]) {
|
// avoiding potential native object properties
|
||||||
|
const safeKey = `__${str}`
|
||||||
|
if (!this.backDict[safeKey]) {
|
||||||
isNew = true
|
isNew = true
|
||||||
// shaving the first 2 digits of the timestamp (since they are irrelevant for next millennia)
|
// shaving the first 2 digits of the timestamp (since they are irrelevant for next millennia)
|
||||||
const shavedTs = Date.now() % 10 ** (13 - 2)
|
const shavedTs = Date.now() % 10 ** (13 - 2)
|
||||||
|
|
@ -26,10 +28,10 @@ export class StringDictionary {
|
||||||
} else {
|
} else {
|
||||||
this.lastSuffix = 1
|
this.lastSuffix = 1
|
||||||
}
|
}
|
||||||
this.backDict[str] = id
|
this.backDict[safeKey] = id
|
||||||
this.lastTs = shavedTs
|
this.lastTs = shavedTs
|
||||||
}
|
}
|
||||||
return [this.backDict[str], isNew]
|
return [this.backDict[safeKey], isNew]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue