diff --git a/.github/workflows/api-ee.yaml b/.github/workflows/api-ee.yaml index 58559aa8b..cfd3ca07e 100644 --- a/.github/workflows/api-ee.yaml +++ b/.github/workflows/api-ee.yaml @@ -47,7 +47,7 @@ jobs: sed -i "s#minio_secret_key.*#minio_secret_key: \"${{ secrets.EE_MINIO_SECRET_KEY }}\" #g" vars.yaml sed -i "s#domain_name.*#domain_name: \"foss.openreplay.com\" #g" vars.yaml sed -i "s#kubeconfig.*#kubeconfig_path: ${KUBECONFIG}#g" vars.yaml - sed -i "s/tag:.*/tag: \"$IMAGE_TAG\"/g" vars.yaml + sed -i "s/image_tag:.*/image_tag: \"$IMAGE_TAG\"/g" vars.yaml bash kube-install.sh --app chalice env: DOCKER_REPO: ${{ secrets.EE_REGISTRY_URL }} diff --git a/.github/workflows/api.yaml b/.github/workflows/api.yaml index ca8a5dc8c..0d467295f 100644 --- a/.github/workflows/api.yaml +++ b/.github/workflows/api.yaml @@ -47,7 +47,7 @@ jobs: sed -i "s#minio_secret_key.*#minio_secret_key: \"${{ secrets.OSS_MINIO_SECRET_KEY }}\" #g" vars.yaml sed -i "s#domain_name.*#domain_name: \"foss.openreplay.com\" #g" vars.yaml sed -i "s#kubeconfig.*#kubeconfig_path: ${KUBECONFIG}#g" vars.yaml - sed -i "s/tag:.*/tag: \"$IMAGE_TAG\"/g" app/chalice.yaml + sed -i "s/image_tag:.*/image_tag: \"$IMAGE_TAG\"/g" vars.yaml bash kube-install.sh --app chalice env: DOCKER_REPO: ${{ secrets.OSS_REGISTRY_URL }} diff --git a/.github/workflows/utilities.yaml b/.github/workflows/utilities.yaml new file mode 100644 index 000000000..603c09126 --- /dev/null +++ b/.github/workflows/utilities.yaml @@ -0,0 +1,64 @@ +# This action will push the utilities changes to aws +on: + push: + branches: + - dev + paths: + - utilities/** + +name: Build and Deploy Utilities + +jobs: + deploy: + name: Deploy + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + # We need to diff with old commit + # to see which workers got changed. + fetch-depth: 2 + + - name: Docker login + run: | + docker login ${{ secrets.OSS_REGISTRY_URL }} -u ${{ secrets.OSS_DOCKER_USERNAME }} -p "${{ secrets.OSS_REGISTRY_TOKEN }}" + + - uses: azure/k8s-set-context@v1 + with: + method: kubeconfig + kubeconfig: ${{ secrets.OSS_KUBECONFIG }} # Use content of kubeconfig in secret. + id: setcontext + + - name: Building and Pusing api image + id: build-image + env: + DOCKER_REPO: ${{ secrets.OSS_REGISTRY_URL }} + IMAGE_TAG: ${{ github.sha }} + ENVIRONMENT: staging + run: | + cd utilities + PUSH_IMAGE=1 bash build.sh + - name: Deploy to kubernetes + run: | + cd scripts/helm/ + sed -i "s#minio_access_key.*#minio_access_key: \"${{ secrets.OSS_MINIO_ACCESS_KEY }}\" #g" vars.yaml + sed -i "s#minio_secret_key.*#minio_secret_key: \"${{ secrets.OSS_MINIO_SECRET_KEY }}\" #g" vars.yaml + sed -i "s#domain_name.*#domain_name: \"foss.openreplay.com\" #g" vars.yaml + sed -i "s#kubeconfig.*#kubeconfig_path: ${KUBECONFIG}#g" vars.yaml + sed -i "s/image_tag:.*/image_tag: \"$IMAGE_TAG\"/g" vars.yaml + bash kube-install.sh --app utilities + env: + DOCKER_REPO: ${{ secrets.OSS_REGISTRY_URL }} + IMAGE_TAG: ${{ github.sha }} + ENVIRONMENT: staging + + # - name: Debug Job + # if: ${{ failure() }} + # uses: mxschmitt/action-tmate@v3 + # env: + # DOCKER_REPO: ${{ secrets.OSS_REGISTRY_URL }} + # IMAGE_TAG: ${{ github.sha }} + # ENVIRONMENT: staging + # diff --git a/.github/workflows/workers.yaml b/.github/workflows/workers.yaml index f9316a7a8..37d87bfb6 100644 --- a/.github/workflows/workers.yaml +++ b/.github/workflows/workers.yaml @@ -70,7 +70,7 @@ jobs: sed -i "s#kubeconfig.*#kubeconfig_path: ${KUBECONFIG}#g" vars.yaml for image in $(cat ../../backend/images_to_build.txt); do - sed -i "s/tag:.*/tag: \"$IMAGE_TAG\"/g" app/${image}.yaml + sed -i "s/image_tag:.*/image_tag: \"$IMAGE_TAG\"/g" vars.yaml # Deploy command bash kube-install.sh --app $image done diff --git a/api/.chalice/config.json b/api/.chalice/config.json index 7c91eda84..8dd829be6 100644 --- a/api/.chalice/config.json +++ b/api/.chalice/config.json @@ -30,9 +30,10 @@ "sessions_bucket": "mobs", "sessions_region": "us-east-1", "put_S3_TTL": "20", - "sourcemaps_reader": "http://127.0.0.1:3000/", + "sourcemaps_reader": "http://utilities-openreplay.app.svc.cluster.local:9000/sourcemaps", "sourcemaps_bucket": "sourcemaps", "js_cache_bucket": "sessions-assets", + "peers": "http://utilities-openreplay.app.svc.cluster.local:9000/assist/peers", "async_Token": "", "EMAIL_HOST": "", "EMAIL_PORT": "587", @@ -51,7 +52,7 @@ "S3_HOST": "", "S3_KEY": "", "S3_SECRET": "", - "version_number": "1.0.0" + "version_number": "1.2.0" }, "lambda_timeout": 150, "lambda_memory_size": 400, diff --git a/api/Dockerfile b/api/Dockerfile index 84d1b88f5..4dbfe4f7f 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -4,14 +4,6 @@ WORKDIR /work COPY . . RUN pip install -r requirements.txt -t ./vendor --upgrade RUN pip install chalice==1.22.2 -# Installing Nodejs -RUN apt update && apt install -y curl && \ - curl -fsSL https://deb.nodesource.com/setup_12.x | bash - && \ - apt install -y nodejs && \ - apt remove --purge -y curl && \ - rm -rf /var/lib/apt/lists/* && \ - cd sourcemaps_reader && \ - npm install # Add Tini # Startup daemon diff --git a/api/build.sh b/api/build.sh index af75324b0..3f1504342 100644 --- a/api/build.sh +++ b/api/build.sh @@ -27,6 +27,8 @@ function build_api(){ docker build -f ./Dockerfile --build-arg envarg=$envarg -t ${DOCKER_REPO:-'local'}/chalice:${git_sha1} . [[ $PUSH_IMAGE -eq 1 ]] && { docker push ${DOCKER_REPO:-'local'}/chalice:${git_sha1} + docker tag ${DOCKER_REPO:-'local'}/chalice:${git_sha1} ${DOCKER_REPO:-'local'}/chalice:latest + docker push ${DOCKER_REPO:-'local'}/chalice:latest } } diff --git a/api/chalicelib/blueprints/bp_core.py b/api/chalicelib/blueprints/bp_core.py index c12a2f412..83502b637 100644 --- a/api/chalicelib/blueprints/bp_core.py +++ b/api/chalicelib/blueprints/bp_core.py @@ -10,7 +10,8 @@ from chalicelib.core import log_tool_rollbar, sourcemaps, events, sessions_assig log_tool_elasticsearch, log_tool_datadog, \ log_tool_stackdriver, reset_password, sessions_favorite_viewed, \ log_tool_cloudwatch, log_tool_sentry, log_tool_sumologic, log_tools, errors, sessions, \ - log_tool_newrelic, announcements, log_tool_bugsnag, weekly_report, integration_jira_cloud, integration_github + log_tool_newrelic, announcements, log_tool_bugsnag, weekly_report, integration_jira_cloud, integration_github, \ + assist from chalicelib.core.collaboration_slack import Slack from chalicelib.utils import email_helper @@ -875,3 +876,9 @@ def all_issue_types(context): @app.route('/{projectId}/flows', methods=['GET', 'PUT', 'POST', 'DELETE']) def removed_endpoints(projectId=None, context=None): return Response(body={"errors": ["Endpoint no longer available"]}, status_code=410) + + +@app.route('/{projectId}/assist/sessions', methods=['GET']) +def sessions_live(projectId, context): + data = assist.get_live_sessions(projectId) + return {'data': data} diff --git a/api/chalicelib/blueprints/subs/bp_dashboard.py b/api/chalicelib/blueprints/subs/bp_dashboard.py index 51bdce092..00b3c0ed4 100644 --- a/api/chalicelib/blueprints/subs/bp_dashboard.py +++ b/api/chalicelib/blueprints/subs/bp_dashboard.py @@ -130,8 +130,9 @@ def get_network_widget(projectId, context): @app.route('/{projectId}/dashboard/{widget}/search', methods=['GET']) def get_dashboard_autocomplete(projectId, widget, context): params = app.current_request.query_params - if params is None: + if params is None or params.get('q') is None or len(params.get('q')) == 0: return {"data": []} + params['q'] = '^' + params['q'] if widget in ['performance']: data = dashboard.search(params.get('q', ''), params.get('type', ''), project_id=projectId, @@ -547,59 +548,3 @@ def get_dashboard_group(projectId, context): *helper.explode_widget(dashboard.get_avg_cpu(project_id=projectId, **{**data, **args})), *helper.explode_widget(dashboard.get_avg_fps(project_id=projectId, **{**data, **args})), ]} - - -@app.route('/{projectId}/dashboard/errors_crashes', methods=['GET', 'POST']) -def get_dashboard_group(projectId, context): - data = app.current_request.json_body - if data is None: - data = {} - params = app.current_request.query_params - args = dashboard.dashboard_args(params) - - return {"data": [ - {"key": "errors", - "data": dashboard.get_errors(project_id=projectId, **{**data, **args})}, - {"key": "errors_trend", - "data": dashboard.get_errors_trend(project_id=projectId, **{**data, **args})}, - {"key": "crashes", - "data": dashboard.get_crashes(project_id=projectId, **{**data, **args})}, - {"key": "domains_errors", - "data": dashboard.get_domains_errors(project_id=projectId, **{**data, **args})}, - {"key": "errors_per_domains", - "data": dashboard.get_errors_per_domains(project_id=projectId, **{**data, **args})}, - {"key": "calls_errors", - "data": dashboard.get_calls_errors(project_id=projectId, **{**data, **args})}, - {"key": "errors_per_type", - "data": dashboard.get_errors_per_type(project_id=projectId, **{**data, **args})}, - {"key": "impacted_sessions_by_js_errors", - "data": dashboard.get_impacted_sessions_by_js_errors(project_id=projectId, **{**data, **args})} - ]} - - -@app.route('/{projectId}/dashboard/resources', methods=['GET', 'POST']) -def get_dashboard_group(projectId, context): - data = app.current_request.json_body - if data is None: - data = {} - params = app.current_request.query_params - args = dashboard.dashboard_args(params) - - return {"data": [ - {"key": "slowest_images", - "data": dashboard.get_slowest_images(project_id=projectId, **{**data, **args})}, - {"key": "missing_resources", - "data": dashboard.get_missing_resources_trend(project_id=projectId, **{**data, **args})}, - {"key": "slowest_resources", - "data": dashboard.get_slowest_resources(project_id=projectId, type='all', **{**data, **args})}, - {"key": "resources_loading_time", - "data": dashboard.get_resources_loading_time(project_id=projectId, **{**data, **args})}, - {"key": "resources_by_party", - "data": dashboard.get_resources_by_party(project_id=projectId, **{**data, **args})}, - {"key": "resource_type_vs_response_end", - "data": dashboard.resource_type_vs_response_end(project_id=projectId, **{**data, **args})}, - {"key": "resources_vs_visually_complete", - "data": dashboard.get_resources_vs_visually_complete(project_id=projectId, **{**data, **args})}, - {"key": "resources_count_by_type", - "data": dashboard.get_resources_count_by_type(project_id=projectId, **{**data, **args})} - ]} diff --git a/api/chalicelib/core/assist.py b/api/chalicelib/core/assist.py new file mode 100644 index 000000000..2e17bd516 --- /dev/null +++ b/api/chalicelib/core/assist.py @@ -0,0 +1,55 @@ +from chalicelib.utils import pg_client, helper +from chalicelib.core import projects +import requests +from chalicelib.utils.helper import environ + +SESSION_PROJECTION_COLS = """s.project_id, + s.session_id::text AS session_id, + s.user_uuid, + s.user_id, + s.user_agent, + s.user_os, + s.user_browser, + s.user_device, + s.user_device_type, + s.user_country, + s.start_ts, + s.user_anonymous_id, + s.platform + """ + + +def get_live_sessions(project_id): + project_key = projects.get_project_key(project_id) + connected_peers = requests.get(environ["peers"] + f"/{project_key}") + if connected_peers.status_code != 200: + print("!! issue with the peer-server") + print(connected_peers.text) + return [] + connected_peers = connected_peers.json().get("data", []) + + if len(connected_peers) == 0: + return [] + connected_peers = tuple(connected_peers) + with pg_client.PostgresClient() as cur: + query = cur.mogrify(f"""\ + SELECT {SESSION_PROJECTION_COLS}, %(project_key)s||'-'|| session_id AS peer_id + FROM public.sessions AS s + WHERE s.project_id = %(project_id)s + AND session_id IN %(connected_peers)s;""", + {"project_id": project_id, "connected_peers": connected_peers, "project_key": project_key}) + cur.execute(query) + results = cur.fetchall() + return helper.list_to_camel_case(results) + + +def is_live(project_id, session_id, project_key=None): + if project_key is None: + project_key = projects.get_project_key(project_id) + connected_peers = requests.get(environ["peers"] + f"/{project_key}") + if connected_peers.status_code != 200: + print("!! issue with the peer-server") + print(connected_peers.text) + return False + connected_peers = connected_peers.json().get("data", []) + return session_id in connected_peers diff --git a/api/chalicelib/core/dashboard.py b/api/chalicelib/core/dashboard.py index 6cfbad461..919d6aa5a 100644 --- a/api/chalicelib/core/dashboard.py +++ b/api/chalicelib/core/dashboard.py @@ -30,16 +30,17 @@ def __get_constraints(project_id, time_constraint=True, chart=False, duration=Tr project_identifier="project_id", main_table="sessions", time_column="start_ts", data={}): pg_sub_query = [] + main_table = main_table + "." if main_table is not None and len(main_table) > 0 else "" if project: - pg_sub_query.append(f"{main_table}.{project_identifier} =%({project_identifier})s") + pg_sub_query.append(f"{main_table}{project_identifier} =%({project_identifier})s") if duration: - pg_sub_query.append(f"{main_table}.duration>0") + pg_sub_query.append(f"{main_table}duration>0") if time_constraint: - pg_sub_query.append(f"{main_table}.{time_column} >= %(startTimestamp)s") - pg_sub_query.append(f"{main_table}.{time_column} < %(endTimestamp)s") + pg_sub_query.append(f"{main_table}{time_column} >= %(startTimestamp)s") + pg_sub_query.append(f"{main_table}{time_column} < %(endTimestamp)s") if chart: - pg_sub_query.append(f"{main_table}.{time_column} >= generated_timestamp") - pg_sub_query.append(f"{main_table}.{time_column} < generated_timestamp + %(step_size)s") + pg_sub_query.append(f"{main_table}{time_column} >= generated_timestamp") + pg_sub_query.append(f"{main_table}{time_column} < generated_timestamp + %(step_size)s") return pg_sub_query + __get_meta_constraint(project_id=project_id, data=data) @@ -178,32 +179,36 @@ def get_errors(project_id, startTimestamp=TimeUTC.now(delta_days=-1), endTimesta density=7, **args): step_size = __get_step_size(startTimestamp, endTimestamp, density, factor=1) - pg_sub_query = __get_constraints(project_id=project_id, data=args) - pg_sub_query.append("m_errors.source = 'js_exception'") - pg_sub_query_chart = __get_constraints(project_id=project_id, time_constraint=True, - chart=True, data=args) - pg_sub_query_chart.append("m_errors.source = 'js_exception'") - + pg_sub_query_subset = __get_constraints(project_id=project_id, data=args, duration=False, main_table="m_errors", + time_constraint=False) + pg_sub_query_chart = __get_constraints(project_id=project_id, time_constraint=False, + chart=True, data=args, main_table="errors", time_column="timestamp", + project=False, duration=False) + pg_sub_query_subset.append("m_errors.source = 'js_exception'") + pg_sub_query_subset.append("errors.timestamp>=%(startTimestamp)s") + pg_sub_query_subset.append("errors.timestamp<%(endTimestamp)s") with pg_client.PostgresClient() as cur: - pg_query = f"""\ - SELECT generated_timestamp AS timestamp, - COALESCE(COUNT(sessions), 0) AS count - FROM generate_series(%(startTimestamp)s, %(endTimestamp)s, %(step_size)s) AS generated_timestamp - LEFT JOIN LATERAL ( SELECT DISTINCT session_id - FROM events.errors - INNER JOIN public.errors AS m_errors USING(error_id) - INNER JOIN public.sessions USING(session_id) - WHERE {" AND ".join(pg_sub_query_chart)} - ) AS sessions ON (TRUE) - GROUP BY generated_timestamp - ORDER BY generated_timestamp;""" + pg_query = f"""WITH errors AS (SELECT DISTINCT session_id, timestamp + FROM events.errors + INNER JOIN public.errors AS m_errors USING (error_id) + WHERE {" AND ".join(pg_sub_query_subset)} + ) + SELECT generated_timestamp AS timestamp, + COALESCE(COUNT(sessions), 0) AS count + FROM generate_series(%(startTimestamp)s, %(endTimestamp)s, %(step_size)s) AS generated_timestamp + LEFT JOIN LATERAL ( SELECT session_id + FROM errors + WHERE {" AND ".join(pg_sub_query_chart)} + ) AS sessions ON (TRUE) + GROUP BY generated_timestamp + ORDER BY generated_timestamp;""" params = {"step_size": step_size, "project_id": project_id, "startTimestamp": startTimestamp, "endTimestamp": endTimestamp, **__get_constraint_values(args)} cur.execute(cur.mogrify(pg_query, params)) rows = cur.fetchall() results = { "count": 0 if len(rows) == 0 else __count_distinct_errors(cur, project_id, startTimestamp, endTimestamp, - pg_sub_query), + pg_sub_query_subset), "impactedSessions": sum([r["count"] for r in rows]), "chart": rows } @@ -211,18 +216,18 @@ def get_errors(project_id, startTimestamp=TimeUTC.now(delta_days=-1), endTimesta diff = endTimestamp - startTimestamp endTimestamp = startTimestamp startTimestamp = endTimestamp - diff - count = __count_distinct_errors(cur, project_id, startTimestamp, endTimestamp, pg_sub_query, **args) + count = __count_distinct_errors(cur, project_id, startTimestamp, endTimestamp, pg_sub_query_subset, **args) results["progress"] = helper.__progress(old_val=count, new_val=results["count"]) return results def __count_distinct_errors(cur, project_id, startTimestamp, endTimestamp, pg_sub_query, **args): - pg_query = f"""\ - SELECT COALESCE(COUNT(DISTINCT errors.error_id),0) AS count - FROM events.errors - INNER JOIN public.errors AS m_errors USING(error_id) - INNER JOIN public.sessions USING(session_id) - WHERE {" AND ".join(pg_sub_query)};""" + pg_query = f"""WITH errors AS (SELECT DISTINCT error_id + FROM events.errors + INNER JOIN public.errors AS m_errors USING (error_id) + WHERE {" AND ".join(pg_sub_query)}) + SELECT COALESCE(COUNT(*), 0) AS count + FROM errors;""" cur.execute(cur.mogrify(pg_query, {"project_id": project_id, "startTimestamp": startTimestamp, "endTimestamp": endTimestamp, **__get_constraint_values(args)})) return cur.fetchone()["count"] @@ -233,40 +238,50 @@ def get_errors_trend(project_id, startTimestamp=TimeUTC.now(delta_days=-1), endTimestamp=TimeUTC.now(), density=7, **args): step_size = __get_step_size(startTimestamp, endTimestamp, density, factor=1) - pg_sub_query = __get_constraints(project_id=project_id, data=args) - pg_sub_query.append("m_errors.project_id = %(project_id)s") - pg_sub_query_chart = __get_constraints(project_id=project_id, time_constraint=True, chart=True, duration=False, - project=False, main_table="errors", time_column="timestamp", data=args) - pg_sub_query_chart.append("error_id = errors_details.error_id") + + pg_sub_query_subset = __get_constraints(project_id=project_id, time_constraint=False, + chart=False, data=args, main_table="m_errors", duration=False) + pg_sub_query_chart = __get_constraints(project_id=project_id, time_constraint=False, project=False, + chart=True, data=args, main_table="errors_subsest", time_column="timestamp", + duration=False) + pg_sub_query_subset.append("errors.timestamp >= %(startTimestamp)s") + pg_sub_query_subset.append("errors.timestamp < %(endTimestamp)s") + + pg_sub_query_chart.append("errors_subsest.error_id = top_errors.error_id") with pg_client.PostgresClient() as cur: - pg_query = f""" - SELECT * - FROM (SELECT errors.error_id AS error_id, - m_errors.message AS error, - COUNT(errors.session_id) AS count, - COUNT(DISTINCT errors.session_id) AS sessions_count - FROM events.errors - INNER JOIN public.errors AS m_errors USING (error_id) - INNER JOIN public.sessions USING (session_id) - WHERE {" AND ".join(pg_sub_query)} - GROUP BY errors.error_id, m_errors.message - ORDER BY sessions_count DESC, count DESC - LIMIT 10) AS errors_details - INNER JOIN LATERAL (SELECT MAX(timestamp) AS last_occurrence_at, - MIN(timestamp) AS first_occurrence_at - FROM events.errors - WHERE error_id = errors_details.error_id - GROUP BY error_id) AS errors_time ON (TRUE) - INNER JOIN LATERAL (SELECT jsonb_agg(chart) AS chart - FROM (SELECT generated_timestamp AS timestamp, COALESCE(COUNT(sessions), 0) AS count - FROM generate_series(%(startTimestamp)s, %(endTimestamp)s, %(step_size)s) AS generated_timestamp - LEFT JOIN LATERAL ( SELECT DISTINCT session_id - FROM events.errors - WHERE {" AND ".join(pg_sub_query_chart)}) AS sessions - ON (TRUE) - GROUP BY generated_timestamp - ORDER BY generated_timestamp) AS chart) AS chart ON (TRUE);""" + pg_query = f"""WITH errors_subsest AS (SELECT session_id, error_id, timestamp + FROM events.errors + INNER JOIN public.errors AS m_errors USING (error_id) + WHERE {" AND ".join(pg_sub_query_subset)} + ) + SELECT * + FROM (SELECT error_id, COUNT(sub_errors) AS count, count(DISTINCT session_id) AS sessions_count + FROM (SELECT error_id, session_id + FROM events.errors + INNER JOIN public.errors AS m_errors USING (error_id) + WHERE {" AND ".join(pg_sub_query_subset)}) AS sub_errors + GROUP BY error_id + ORDER BY sessions_count DESC, count DESC + LIMIT 10) AS top_errors + INNER JOIN LATERAL (SELECT message AS error + FROM public.errors + WHERE project_id = %(project_id)s + AND errors.error_id = top_errors.error_id) AS errors_details ON(TRUE) + INNER JOIN LATERAL (SELECT MAX(timestamp) AS last_occurrence_at, + MIN(timestamp) AS first_occurrence_at + FROM events.errors + WHERE error_id = top_errors.error_id + GROUP BY error_id) AS errors_time ON (TRUE) + INNER JOIN LATERAL (SELECT jsonb_agg(chart) AS chart + FROM (SELECT generated_timestamp AS timestamp, COALESCE(COUNT(sessions), 0) AS count + FROM generate_series(%(startTimestamp)s, %(endTimestamp)s, %(step_size)s) AS generated_timestamp + LEFT JOIN LATERAL ( SELECT DISTINCT session_id + FROM errors_subsest + WHERE {" AND ".join(pg_sub_query_chart)} + ) AS sessions ON (TRUE) + GROUP BY generated_timestamp + ORDER BY generated_timestamp) AS chart) AS chart ON (TRUE);""" params = {"step_size": step_size, "project_id": project_id, "startTimestamp": startTimestamp, "endTimestamp": endTimestamp, **__get_constraint_values(args)} cur.execute(cur.mogrify(pg_query, params)) @@ -300,15 +315,19 @@ def get_page_metrics(project_id, startTimestamp=TimeUTC.now(delta_days=-1), return results +@dev.timed def __get_page_metrics(cur, project_id, startTimestamp, endTimestamp, **args): pg_sub_query = __get_constraints(project_id=project_id, data=args) - - pg_query = f"""\ - SELECT - COALESCE(AVG(NULLIF(pages.dom_content_loaded_time ,0)),0) AS avg_dom_content_load_start, - COALESCE(AVG(NULLIF(pages.first_contentful_paint_time,0)),0) AS avg_first_contentful_pixel - FROM events.pages INNER JOIN public.sessions USING (session_id) - WHERE {" AND ".join(pg_sub_query)};""" + pg_sub_query.append("pages.timestamp>=%(startTimestamp)s") + pg_sub_query.append("pages.timestamp<%(endTimestamp)s") + pg_sub_query.append("(pages.dom_content_loaded_time > 0 OR pages.first_contentful_paint_time > 0)") + pg_query = f"""SELECT COALESCE(AVG(NULLIF(pages.dom_content_loaded_time, 0)), 0) AS avg_dom_content_load_start, + COALESCE(AVG(NULLIF(pages.first_contentful_paint_time, 0)), 0) AS avg_first_contentful_pixel + FROM (SELECT pages.dom_content_loaded_time, pages.first_contentful_paint_time + FROM events.pages + INNER JOIN public.sessions USING (session_id) + WHERE {" AND ".join(pg_sub_query)} + ) AS pages;""" params = {"project_id": project_id, "startTimestamp": startTimestamp, "endTimestamp": endTimestamp, **__get_constraint_values(args)} cur.execute(cur.mogrify(pg_query, params)) @@ -335,7 +354,10 @@ def get_application_activity(project_id, startTimestamp=TimeUTC.now(delta_days=- def __get_application_activity(cur, project_id, startTimestamp, endTimestamp, **args): result = {} pg_sub_query = __get_constraints(project_id=project_id, data=args) + pg_sub_query.append("pages.timestamp >= %(startTimestamp)s") + pg_sub_query.append("pages.timestamp > %(endTimestamp)s") pg_sub_query.append("pages.load_time > 0") + pg_sub_query.append("pages.load_time IS NOT NULL") pg_query = f"""\ SELECT COALESCE(AVG(pages.load_time) ,0) AS avg_page_load_time FROM events.pages INNER JOIN public.sessions USING (session_id) @@ -346,8 +368,8 @@ def __get_application_activity(cur, project_id, startTimestamp, endTimestamp, ** cur.execute(cur.mogrify(pg_query, params)) row = cur.fetchone() result = {**result, **row} - - pg_sub_query[-1] = "resources.duration > 0" + pg_sub_query = __get_constraints(project_id=project_id, data=args) + pg_sub_query.append("resources.duration > 0") pg_sub_query.append("resources.type= %(type)s") pg_query = f"""\ SELECT COALESCE(AVG(resources.duration),0) AS avg @@ -410,42 +432,47 @@ def get_slowest_images(project_id, startTimestamp=TimeUTC.now(delta_days=-1), pg_sub_query_chart = __get_constraints(project_id=project_id, time_constraint=True, chart=True, data=args) pg_sub_query_chart.append("resources.type = 'img'") - pg_sub_query_chart.append("resources.url = %(url)s") + pg_sub_query_chart.append("resources.url = top_img.url") + + pg_sub_query_subset = __get_constraints(project_id=project_id, time_constraint=True, + chart=False, data=args) + pg_sub_query_subset.append("resources.timestamp >= %(startTimestamp)s") + pg_sub_query_subset.append("resources.timestamp < %(endTimestamp)s") + pg_sub_query_subset.append("resources.duration >0") + pg_sub_query_subset.append("resources.duration IS NOT NULL") + pg_sub_query_subset.append("resources.type='img'") with pg_client.PostgresClient() as cur: - pg_query = f"""SELECT resources.url, - COALESCE(AVG(NULLIF(resources.duration,0)),0) AS avg_duration, - COUNT(resources.session_id) AS sessions_count - FROM events.resources INNER JOIN sessions USING (session_id) - WHERE {" AND ".join(pg_sub_query)} - GROUP BY resources.url - ORDER BY avg_duration DESC LIMIT 10;""" + pg_query = f"""SELECT * + FROM (SELECT resources.url, + COALESCE(AVG(resources.duration), 0) AS avg_duration, + COUNT(resources.session_id) AS sessions_count + FROM events.resources + INNER JOIN sessions USING (session_id) + WHERE {" AND ".join(pg_sub_query_subset)} + GROUP BY resources.url + ORDER BY avg_duration DESC + LIMIT 10) AS top_img + LEFT JOIN LATERAL ( + SELECT jsonb_agg(chart) AS chart + FROM (SELECT generated_timestamp AS timestamp, + COALESCE(AVG(duration), 0) AS avg_duration + FROM generate_series(%(startTimestamp)s, %(endTimestamp)s, %(step_size)s) AS generated_timestamp + LEFT JOIN LATERAL ( SELECT resources.duration + FROM events.resources + INNER JOIN public.sessions USING (session_id) + WHERE {" AND ".join(pg_sub_query_chart)} + ) AS sessions ON (TRUE) + GROUP BY generated_timestamp + ORDER BY generated_timestamp) AS chart + ) AS chart ON (TRUE);""" - cur.execute(cur.mogrify(pg_query, {"project_id": project_id, "startTimestamp": startTimestamp, + cur.execute(cur.mogrify(pg_query, {"step_size": step_size,"project_id": project_id, "startTimestamp": startTimestamp, "endTimestamp": endTimestamp, **__get_constraint_values(args)})) rows = cur.fetchall() - urls = [row["url"] for row in rows] - - charts = {} - for url in urls: - pg_query = f"""SELECT generated_timestamp AS timestamp, - COALESCE(AVG(duration), 0) AS avg_duration - FROM generate_series(%(startTimestamp)s, %(endTimestamp)s, %(step_size)s) AS generated_timestamp - LEFT JOIN LATERAL ( SELECT resources.duration - FROM events.resources INNER JOIN public.sessions USING (session_id) - WHERE {" AND ".join(pg_sub_query_chart)} - ) AS sessions ON (TRUE) - GROUP BY generated_timestamp - ORDER BY generated_timestamp;""" - params = {"step_size": step_size, "project_id": project_id, "startTimestamp": startTimestamp, - "endTimestamp": endTimestamp, "url": url, **__get_constraint_values(args)} - cur.execute(cur.mogrify(pg_query, params)) - r = cur.fetchall() - charts[url] = helper.list_to_camel_case(r) - for i in range(len(rows)): - rows[i]["sessions"] = rows[i].pop("sessions_count") - rows[i] = helper.dict_to_camel_case(rows[i]) - rows[i]["chart"] = charts[rows[i]["url"]] + for i in range(len(rows)): + rows[i]["sessions"] = rows[i].pop("sessions_count") + rows[i] = helper.dict_to_camel_case(rows[i]) return sorted(rows, key=lambda k: k["sessions"], reverse=True) @@ -465,8 +492,6 @@ def get_performance(project_id, startTimestamp=TimeUTC.now(delta_days=-1), endTi location_constraints = [] img_constraints = [] request_constraints = [] - pg_sub_query_chart = __get_constraints(project_id=project_id, time_constraint=True, - chart=True, data=args) img_constraints_vals = {} location_constraints_vals = {} @@ -486,51 +511,86 @@ def get_performance(project_id, startTimestamp=TimeUTC.now(delta_days=-1), endTi params = {"step_size": step_size, "project_id": project_id, "startTimestamp": startTimestamp, "endTimestamp": endTimestamp} with pg_client.PostgresClient() as cur: - pg_query = f"""SELECT + pg_sub_query_subset = __get_constraints(project_id=project_id, time_constraint=True, + chart=False, data=args) + pg_sub_query_chart = __get_constraints(project_id=project_id, time_constraint=False, project=False, + chart=True, data=args, main_table="resources", time_column="timestamp", + duration=False) + pg_sub_query_subset.append("resources.timestamp >= %(startTimestamp)s") + pg_sub_query_subset.append("resources.timestamp < %(endTimestamp)s") + + pg_query = f"""WITH resources AS (SELECT resources.duration, resources.timestamp + FROM events.resources INNER JOIN public.sessions USING (session_id) + WHERE {" AND ".join(pg_sub_query_subset)} + AND resources.type = 'img' AND resources.duration>0 + {(f' AND ({" OR ".join(img_constraints)})') if len(img_constraints) > 0 else ""} + ) + SELECT generated_timestamp AS timestamp, - COALESCE(AVG(NULLIF(resources.duration,0)),0) AS avg_image_load_time + COALESCE(AVG(resources.duration),0) AS avg_image_load_time FROM generate_series(%(startTimestamp)s, %(endTimestamp)s, %(step_size)s) AS generated_timestamp LEFT JOIN LATERAL ( SELECT resources.duration - FROM events.resources INNER JOIN public.sessions USING (session_id) + FROM resources WHERE {" AND ".join(pg_sub_query_chart)} - AND resources.type = 'img' - {(f' AND ({" OR ".join(img_constraints)})') if len(img_constraints) > 0 else ""} ) AS resources ON (TRUE) GROUP BY timestamp ORDER BY timestamp;""" cur.execute(cur.mogrify(pg_query, {**params, **img_constraints_vals, **__get_constraint_values(args)})) rows = cur.fetchall() images = helper.list_to_camel_case(rows) - pg_query = f"""SELECT + + pg_sub_query_subset = __get_constraints(project_id=project_id, time_constraint=True, + chart=False, data=args) + pg_sub_query_chart = __get_constraints(project_id=project_id, time_constraint=False, project=False, + chart=True, data=args, main_table="resources", time_column="timestamp", + duration=False) + pg_sub_query_subset.append("resources.timestamp >= %(startTimestamp)s") + pg_sub_query_subset.append("resources.timestamp < %(endTimestamp)s") + + pg_query = f"""WITH resources AS(SELECT resources.duration, resources.timestamp + FROM events.resources INNER JOIN public.sessions USING (session_id) + WHERE {" AND ".join(pg_sub_query_subset)} + AND resources.type = 'fetch' AND resources.duration>0 + {(f' AND ({" OR ".join(request_constraints)})') if len(request_constraints) > 0 else ""} + ) + SELECT generated_timestamp AS timestamp, - COALESCE(AVG(NULLIF(resources.duration,0)),0) AS avg_request_load_time + COALESCE(AVG(resources.duration),0) AS avg_request_load_time FROM generate_series(%(startTimestamp)s, %(endTimestamp)s, %(step_size)s) AS generated_timestamp LEFT JOIN LATERAL ( SELECT resources.duration - FROM events.resources INNER JOIN public.sessions USING (session_id) + FROM resources WHERE {" AND ".join(pg_sub_query_chart)} - AND resources.type = 'fetch' - {(f' AND ({" OR ".join(request_constraints)})') if len(request_constraints) > 0 else ""} ) AS resources ON (TRUE) GROUP BY generated_timestamp ORDER BY generated_timestamp;""" cur.execute(cur.mogrify(pg_query, {**params, **request_constraints_vals, **__get_constraint_values(args)})) rows = cur.fetchall() requests = helper.list_to_camel_case(rows) - - pg_query = f"""SELECT + pg_sub_query_subset = __get_constraints(project_id=project_id, time_constraint=True, + chart=False, data=args) + pg_sub_query_chart = __get_constraints(project_id=project_id, time_constraint=False, project=False, + chart=True, data=args, main_table="pages", time_column="timestamp", + duration=False) + pg_sub_query_subset.append("pages.timestamp >= %(startTimestamp)s") + pg_sub_query_subset.append("pages.timestamp < %(endTimestamp)s") + pg_query = f"""WITH pages AS(SELECT pages.load_time, timestamp + FROM events.pages INNER JOIN public.sessions USING (session_id) + WHERE {" AND ".join(pg_sub_query_subset)} AND pages.load_time>0 AND pages.load_time IS NOT NULL + {(f' AND ({" OR ".join(location_constraints)})') if len(location_constraints) > 0 else ""} + ) + SELECT generated_timestamp AS timestamp, - COALESCE(AVG(NULLIF(pages.load_time ,0)),0) AS avg_page_load_time + COALESCE(AVG(pages.load_time),0) AS avg_page_load_time FROM generate_series(%(startTimestamp)s, %(endTimestamp)s, %(step_size)s) AS generated_timestamp LEFT JOIN LATERAL ( SELECT pages.load_time - FROM events.pages INNER JOIN public.sessions USING (session_id) - WHERE {" AND ".join(pg_sub_query_chart)} - {(f' AND ({" OR ".join(location_constraints)})') if len(location_constraints) > 0 else ""} - ) AS pages ON (TRUE) + FROM pages + WHERE {" AND ".join(pg_sub_query_chart)} + {(f' AND ({" OR ".join(location_constraints)})') if len(location_constraints) > 0 else ""} + ) AS pages ON (TRUE) GROUP BY generated_timestamp ORDER BY generated_timestamp;""" - cur.execute(cur.mogrify(pg_query, {**params, **location_constraints_vals, **__get_constraint_values(args)})) rows = cur.fetchall() pages = helper.list_to_camel_case(rows) @@ -590,6 +650,7 @@ def search(text, resource_type, project_id, performance=False, pages_only=False, WHERE {" AND ".join(pg_sub_query)} ORDER BY url, type ASC) AS ranked_values WHERE ranked_values.r<=5;""" + print(cur.mogrify(pg_query, {"project_id": project_id, "value": helper.string_to_sql_like(text)})) cur.execute(cur.mogrify(pg_query, {"project_id": project_id, "value": helper.string_to_sql_like(text)})) rows = cur.fetchall() rows = [{"value": i["value"], "type": __get_resource_type_from_db_type(i["key"])} for i in rows] @@ -609,6 +670,9 @@ def search(text, resource_type, project_id, performance=False, pages_only=False, FROM events.pages INNER JOIN public.sessions USING(session_id) WHERE {" AND ".join(pg_sub_query)} AND positionUTF8(url_path, %(value)s) != 0 LIMIT 10);""" + print(cur.mogrify(pg_query, {"project_id": project_id, + "value": helper.string_to_sql_like(text.lower()), + "platform_0": platform})) cur.execute(cur.mogrify(pg_query, {"project_id": project_id, "value": helper.string_to_sql_like(text.lower()), "platform_0": platform})) @@ -625,6 +689,10 @@ def search(text, resource_type, project_id, performance=False, pages_only=False, FROM events.resources INNER JOIN public.sessions USING (session_id) WHERE {" AND ".join(pg_sub_query)} LIMIT 10;""" + print(cur.mogrify(pg_query, {"project_id": project_id, + "value": helper.string_to_sql_like(text), + "resource_type": resource_type, + "platform_0": platform})) cur.execute(cur.mogrify(pg_query, {"project_id": project_id, "value": helper.string_to_sql_like(text), "resource_type": resource_type, @@ -639,6 +707,9 @@ def search(text, resource_type, project_id, performance=False, pages_only=False, FROM events.pages INNER JOIN public.sessions USING (session_id) WHERE {" AND ".join(pg_sub_query)} LIMIT 10;""" + print(cur.mogrify(pg_query, {"project_id": project_id, + "value": helper.string_to_sql_like(text), + "platform_0": platform})) cur.execute(cur.mogrify(pg_query, {"project_id": project_id, "value": helper.string_to_sql_like(text), "platform_0": platform})) @@ -650,6 +721,9 @@ def search(text, resource_type, project_id, performance=False, pages_only=False, FROM events.inputs INNER JOIN public.sessions USING (session_id) WHERE {" AND ".join(pg_sub_query)} LIMIT 10;""" + print(cur.mogrify(pg_query, {"project_id": project_id, + "value": helper.string_to_sql_like(text), + "platform_0": platform})) cur.execute(cur.mogrify(pg_query, {"project_id": project_id, "value": helper.string_to_sql_like(text), "platform_0": platform})) @@ -661,6 +735,9 @@ def search(text, resource_type, project_id, performance=False, pages_only=False, FROM events.clicks INNER JOIN public.sessions USING (session_id) WHERE {" AND ".join(pg_sub_query)} LIMIT 10;""" + print(cur.mogrify(pg_query, {"project_id": project_id, + "value": helper.string_to_sql_like(text), + "platform_0": platform})) cur.execute(cur.mogrify(pg_query, {"project_id": project_id, "value": helper.string_to_sql_like(text), "platform_0": platform})) @@ -679,6 +756,9 @@ def search(text, resource_type, project_id, performance=False, pages_only=False, FROM sessions WHERE {" AND ".join(pg_sub_query)} LIMIT 10;""" + print(cur.mogrify(pg_query, + {"project_id": project_id, "value": helper.string_to_sql_like(text), "key": key, + "platform_0": platform})) cur.execute(cur.mogrify(pg_query, {"project_id": project_id, "value": helper.string_to_sql_like(text), "key": key, "platform_0": platform})) @@ -703,6 +783,10 @@ def search(text, resource_type, project_id, performance=False, pages_only=False, AND sessions.{SESSIONS_META_FIELDS[k]} ILIKE %(value)s LIMIT 10)""") pg_query = " UNION ALL ".join(pg_query) + print(cur.mogrify(pg_query, + {"project_id": project_id, "value": helper.string_to_sql_like(text), + "key": key, + "platform_0": platform})) cur.execute(cur.mogrify(pg_query, {"project_id": project_id, "value": helper.string_to_sql_like(text), "key": key, @@ -774,19 +858,32 @@ def get_network(project_id, startTimestamp=TimeUTC.now(delta_days=-1), endTimestamp=TimeUTC.now(), density=7, **args): step_size = __get_step_size(startTimestamp, endTimestamp, density, factor=1) - pg_sub_query_chart = __get_constraints(project_id=project_id, time_constraint=True, - chart=True, data=args) + pg_sub_query_subset = __get_constraints(project_id=project_id, data=args) + pg_sub_query_chart = __get_constraints(project_id=project_id, time_constraint=False, + chart=True, data=args, main_table="resources", time_column="timestamp", + project=False, duration=False) + pg_sub_query_subset.append("resources.timestamp>=%(startTimestamp)s") + pg_sub_query_subset.append("resources.timestamp<%(endTimestamp)s") + + with pg_client.PostgresClient() as cur: - pg_query = f"""SELECT generated_timestamp AS timestamp, - resources.url_hostpath, - COUNT(resources.session_id) AS doc_count + pg_query = f"""WITH resources AS (SELECT resources.session_id, + resources.url_hostpath, + timestamp + FROM events.resources + INNER JOIN public.sessions USING (session_id) + WHERE {" AND ".join(pg_sub_query_subset)} + ) + SELECT generated_timestamp AS timestamp, + resources.url_hostpath, + COUNT(resources.session_id) AS doc_count FROM generate_series(%(startTimestamp)s, %(endTimestamp)s, %(step_size)s) AS generated_timestamp - LEFT JOIN LATERAL ( SELECT resources.session_id, - resources.url_hostpath - FROM events.resources INNER JOIN public.sessions USING (session_id) - WHERE {" AND ".join(pg_sub_query_chart)} - ) AS resources ON (TRUE) + LEFT JOIN LATERAL ( SELECT resources.session_id, + resources.url_hostpath + FROM resources + WHERE {" AND ".join(pg_sub_query_chart)} + ) AS resources ON (TRUE) GROUP BY generated_timestamp, resources.url_hostpath ORDER BY generated_timestamp;""" cur.execute(cur.mogrify(pg_query, {"step_size": step_size, "project_id": project_id, @@ -829,24 +926,33 @@ def get_resources_loading_time(project_id, startTimestamp=TimeUTC.now(delta_days endTimestamp=TimeUTC.now(), density=19, type=None, url=None, **args): step_size = __get_step_size(startTimestamp, endTimestamp, density, factor=1) - pg_sub_query = __get_constraints(project_id=project_id, data=args) - pg_sub_query_chart = __get_constraints(project_id=project_id, time_constraint=True, - chart=True, data=args) + pg_sub_query_subset = __get_constraints(project_id=project_id, data=args) + pg_sub_query_chart = __get_constraints(project_id=project_id, time_constraint=False, + chart=True, data=args, main_table="resources", time_column="timestamp", + project=False, duration=False) + pg_sub_query_subset.append("resources.timestamp>=%(startTimestamp)s") + pg_sub_query_subset.append("resources.timestamp<%(endTimestamp)s") + pg_sub_query_subset.append("resources.duration>0") + pg_sub_query_subset.append("resources.duration IS NOT NULL") + if type is not None: - pg_sub_query.append(f"resources.type = '{__get_resource_db_type_from_type(type)}'") - pg_sub_query_chart.append(f"resources.type = '{__get_resource_db_type_from_type(type)}'") + pg_sub_query_subset.append(f"resources.type = '{__get_resource_db_type_from_type(type)}'") if url is not None: - pg_sub_query.append(f"resources.url = %(value)s") - pg_sub_query_chart.append(f"resources.url = %(value)s") + pg_sub_query_subset.append(f"resources.url = %(value)s") with pg_client.PostgresClient() as cur: - pg_query = f"""SELECT generated_timestamp AS timestamp, - COALESCE(AVG(NULLIF(resources.duration,0)),0) AS avg + pg_query = f"""WITH resources AS (SELECT resources.duration, timestamp + FROM events.resources + INNER JOIN public.sessions USING (session_id) + WHERE {" AND ".join(pg_sub_query_subset)} + ) + SELECT generated_timestamp AS timestamp, + COALESCE(AVG(resources.duration), 0) AS avg FROM generate_series(%(startTimestamp)s, %(endTimestamp)s, %(step_size)s) AS generated_timestamp - LEFT JOIN LATERAL ( SELECT resources.duration - FROM events.resources INNER JOIN public.sessions USING (session_id) - WHERE {" AND ".join(pg_sub_query_chart)} - ) AS resources ON (TRUE) + LEFT JOIN LATERAL ( SELECT resources.duration + FROM resources + WHERE {" AND ".join(pg_sub_query_chart)} + ) AS resources ON (TRUE) GROUP BY generated_timestamp ORDER BY generated_timestamp;""" params = {"step_size": step_size, "project_id": project_id, @@ -855,14 +961,12 @@ def get_resources_loading_time(project_id, startTimestamp=TimeUTC.now(delta_days "value": url, "type": type, **__get_constraint_values(args)} cur.execute(cur.mogrify(pg_query, params)) rows = cur.fetchall() - if len(rows) > 0: - pg_query = f"""SELECT COALESCE(AVG(NULLIF(resources.duration,0)),0) AS avg - FROM events.resources INNER JOIN sessions USING(session_id) - WHERE {" AND ".join(pg_sub_query)};""" - cur.execute(cur.mogrify(pg_query, params)) - avg = cur.fetchone()["avg"] - else: - avg = 0 + pg_query = f"""SELECT COALESCE(AVG(resources.duration),0) AS avg + FROM events.resources INNER JOIN sessions USING(session_id) + WHERE {" AND ".join(pg_sub_query_subset)};""" + cur.execute(cur.mogrify(pg_query, params)) + avg = cur.fetchone()["avg"] + return {"avg": avg, "chart": rows} @@ -870,21 +974,26 @@ def get_resources_loading_time(project_id, startTimestamp=TimeUTC.now(delta_days def get_pages_dom_build_time(project_id, startTimestamp=TimeUTC.now(delta_days=-1), endTimestamp=TimeUTC.now(), density=19, url=None, **args): step_size = __get_step_size(startTimestamp, endTimestamp, density, factor=1) - pg_sub_query = __get_constraints(project_id=project_id, data=args) - pg_sub_query_chart = __get_constraints(project_id=project_id, time_constraint=True, - chart=True, data=args) + pg_sub_query_subset = __get_constraints(project_id=project_id, data=args) + pg_sub_query_chart = __get_constraints(project_id=project_id, time_constraint=False, + chart=True, data=args, main_table="pages", time_column="timestamp", + project=False, duration=False) + if url is not None: - pg_sub_query.append(f"pages.path = %(value)s") - pg_sub_query_chart.append(f"pages.path = %(value)s") - pg_sub_query.append("pages.dom_building_time>0") - pg_sub_query_chart.append("pages.dom_building_time>0") + pg_sub_query_subset.append(f"pages.path = %(value)s") + + pg_sub_query_subset.append("pages.timestamp>=%(startTimestamp)s") + pg_sub_query_subset.append("pages.timestamp<%(endTimestamp)s") + pg_sub_query_subset.append("pages.dom_building_time>0") + pg_sub_query_subset.append("pages.dom_building_time IS NOT NULL") with pg_client.PostgresClient() as cur: - pg_query = f"""SELECT COALESCE(avg, 0) AS avg, chart - FROM (SELECT AVG(dom_building_time) - FROM public.sessions - INNER JOIN events.pages USING (session_id) - WHERE {" AND ".join(pg_sub_query)}) AS avg + pg_query = f"""WITH pages AS ( SELECT pages.dom_building_time, timestamp + FROM public.sessions + INNER JOIN events.pages USING (session_id) + WHERE {" AND ".join(pg_sub_query_subset)}) + SELECT COALESCE(avg, 0) AS avg, chart + FROM (SELECT AVG(dom_building_time) FROM pages) AS avg LEFT JOIN (SELECT jsonb_agg(chart) AS chart FROM ( @@ -892,8 +1001,7 @@ def get_pages_dom_build_time(project_id, startTimestamp=TimeUTC.now(delta_days=- COALESCE(AVG(dom_building_time), 0) AS avg FROM generate_series(%(startTimestamp)s, %(endTimestamp)s, %(step_size)s) AS generated_timestamp LEFT JOIN LATERAL ( SELECT pages.dom_building_time - FROM public.sessions - INNER JOIN events.pages USING (session_id) + FROM pages WHERE {" AND ".join(pg_sub_query_chart)} ) AS sessionsBD ON (TRUE) GROUP BY generated_timestamp @@ -913,24 +1021,36 @@ def get_slowest_resources(project_id, startTimestamp=TimeUTC.now(delta_days=-1), endTimestamp=TimeUTC.now(), type="all", density=19, **args): step_size = __get_step_size(startTimestamp, endTimestamp, density, factor=1) pg_sub_query = __get_constraints(project_id=project_id, data=args) - pg_sub_query_chart = __get_constraints(project_id=project_id, time_constraint=True, - chart=True, data=args) + pg_sub_query_subset = __get_constraints(project_id=project_id, data=args) + pg_sub_query_chart = __get_constraints(project_id=project_id, time_constraint=False, + chart=True, data=args, main_table="resources", time_column="timestamp", + project=False, duration=False) + pg_sub_query_subset.append("resources.timestamp>=%(startTimestamp)s") + pg_sub_query_subset.append("resources.timestamp<%(endTimestamp)s") + pg_sub_query_subset.append("resources.duration>0") + pg_sub_query_subset.append("resources.duration IS NOT NULL") if type is not None and type.upper() != "ALL": sq = f"resources.type = '{__get_resource_db_type_from_type(type.upper())}'" else: sq = "resources.type != 'fetch'" pg_sub_query.append(sq) - pg_sub_query_chart.append(sq) - pg_sub_query_chart.append("resources.duration IS NOT NULL") - pg_sub_query_chart.append("resources.duration>0") + pg_sub_query_subset.append(sq) + pg_sub_query_chart.append("resources.url_hostpath ILIKE '%%' || main_list.name") + with pg_client.PostgresClient() as cur: - pg_query = f"""SELECT * + pg_query = f"""WITH resources AS ( + SELECT resources.duration, url_hostpath, timestamp + FROM events.resources + INNER JOIN public.sessions USING (session_id) + WHERE {" AND ".join(pg_sub_query_subset)} + ) + SELECT * FROM (SELECT regexp_replace(resources.url_hostpath, '^.*/', '') AS name, - AVG(NULLIF(resources.duration, 0)) AS avg + AVG(resources.duration) AS avg FROM events.resources INNER JOIN public.sessions USING (session_id) - WHERE {" AND ".join(pg_sub_query)} + WHERE {" AND ".join(pg_sub_query_subset)} GROUP BY name ORDER BY avg DESC LIMIT 10) AS main_list @@ -951,15 +1071,14 @@ def get_slowest_resources(project_id, startTimestamp=TimeUTC.now(delta_days=-1), FROM generate_series(%(startTimestamp)s, %(endTimestamp)s, %(step_size)s) AS generated_timestamp LEFT JOIN LATERAL ( SELECT resources.duration - FROM events.resources - INNER JOIN public.sessions USING (session_id) + FROM resources WHERE {" AND ".join(pg_sub_query_chart)} - AND resources.url_hostpath ILIKE '%%' || main_list.name ) AS resources ON (TRUE) GROUP BY generated_timestamp ORDER BY generated_timestamp ) AS chart_details ) AS chart_details ON (TRUE);""" + cur.execute(cur.mogrify(pg_query, {"project_id": project_id, "startTimestamp": startTimestamp, "endTimestamp": endTimestamp, @@ -1205,12 +1324,41 @@ def get_top_metrics(project_id, startTimestamp=TimeUTC.now(delta_days=-1), if value is not None: pg_sub_query.append("pages.path = %(value)s") with pg_client.PostgresClient() as cur: - pg_query = f"""SELECT (SELECT COALESCE(AVG(pages.response_time),0) FROM events.pages INNER JOIN public.sessions USING (session_id) WHERE {" AND ".join(pg_sub_query)} AND pages.response_time IS NOT NULL AND pages.response_time>0) AS avg_response_time, - (SELECT COUNT(pages.session_id) FROM events.pages INNER JOIN public.sessions USING (session_id) WHERE {" AND ".join(pg_sub_query)}) AS count_requests, - (SELECT COALESCE(AVG(pages.first_paint_time),0) FROM events.pages INNER JOIN public.sessions USING (session_id) WHERE {" AND ".join(pg_sub_query)} AND pages.first_paint_time IS NOT NULL AND pages.first_paint_time>0) AS avg_first_paint, - (SELECT COALESCE(AVG(pages.dom_content_loaded_time),0) FROM events.pages INNER JOIN public.sessions USING (session_id) WHERE {" AND ".join(pg_sub_query)} AND pages.dom_content_loaded_time IS NOT NULL AND pages.dom_content_loaded_time>0) AS avg_dom_content_loaded, - (SELECT COALESCE(AVG(pages.ttfb),0) FROM events.pages INNER JOIN public.sessions USING (session_id) WHERE {" AND ".join(pg_sub_query)} AND pages.ttfb IS NOT NULL AND pages.ttfb>0) AS avg_till_first_bit, - (SELECT COALESCE(AVG(pages.time_to_interactive),0) FROM events.pages INNER JOIN public.sessions USING (session_id) WHERE {" AND ".join(pg_sub_query)} AND pages.time_to_interactive IS NOT NULL AND pages.time_to_interactive >0) AS avg_time_to_interactive;""" + pg_query = f"""WITH pages AS (SELECT pages.response_time, + pages.first_paint_time, + pages.dom_content_loaded_time, + pages.ttfb, + pages.time_to_interactive + FROM events.pages + INNER JOIN public.sessions USING (session_id) + WHERE {" AND ".join(pg_sub_query)} + AND pages.timestamp >= %(startTimestamp)s + AND pages.timestamp < %(endTimestamp)s + AND (pages.response_time > 0 + OR pages.first_paint_time > 0 + OR pages.dom_content_loaded_time > 0 + OR pages.ttfb > 0 + OR pages.time_to_interactive > 0 + )) + SELECT (SELECT COALESCE(AVG(pages.response_time), 0) + FROM pages + WHERE pages.response_time > 0) AS avg_response_time, + (SELECT COALESCE(AVG(pages.first_paint_time), 0) + FROM pages + WHERE pages.first_paint_time > 0) AS avg_first_paint, + (SELECT COALESCE(AVG(pages.dom_content_loaded_time), 0) + FROM pages + WHERE pages.dom_content_loaded_time > 0) AS avg_dom_content_loaded, + (SELECT COALESCE(AVG(pages.ttfb), 0) + FROM pages + WHERE pages.ttfb > 0) AS avg_till_first_bit, + (SELECT COALESCE(AVG(pages.time_to_interactive), 0) + FROM pages + WHERE pages.time_to_interactive > 0) AS avg_time_to_interactive, + (SELECT COUNT(pages.session_id) + FROM events.pages + INNER JOIN public.sessions USING (session_id) + WHERE {" AND ".join(pg_sub_query)}) AS count_requests;""" cur.execute(cur.mogrify(pg_query, {"project_id": project_id, "startTimestamp": startTimestamp, "endTimestamp": endTimestamp, @@ -1223,31 +1371,30 @@ def get_top_metrics(project_id, startTimestamp=TimeUTC.now(delta_days=-1), def get_time_to_render(project_id, startTimestamp=TimeUTC.now(delta_days=-1), endTimestamp=TimeUTC.now(), density=7, url=None, **args): step_size = __get_step_size(startTimestamp, endTimestamp, density, factor=1) - pg_sub_query = __get_constraints(project_id=project_id, data=args) - pg_sub_query_chart = __get_constraints(project_id=project_id, time_constraint=True, - chart=True, - data=args) - pg_sub_query.append("pages.visually_complete>0") - pg_sub_query_chart.append("pages.visually_complete>0") + pg_sub_query_subset = __get_constraints(project_id=project_id, data=args) + pg_sub_query_chart = __get_constraints(project_id=project_id, time_constraint=False, + chart=True, data=args, main_table="pages", time_column="timestamp", + project=False, duration=False) + pg_sub_query_subset.append("pages.visually_complete>0") if url is not None: - pg_sub_query_chart.append("pages.path = %(value)s") + pg_sub_query_subset.append("pages.path = %(value)s") with pg_client.PostgresClient() as cur: - pg_query = f"""SELECT COALESCE(avg,0) AS avg, chart - FROM (SELECT AVG(pages.visually_complete) - FROM events.pages INNER JOIN public.sessions USING (session_id) - WHERE {" AND ".join(pg_sub_query)}) AS avg - LEFT JOIN - (SELECT jsonb_agg(chart) AS chart - FROM (SELECT generated_timestamp AS timestamp, - COALESCE(AVG(visually_complete), 0) AS avg - FROM generate_series(%(startTimestamp)s, %(endTimestamp)s, %(step_size)s) AS generated_timestamp + pg_query = f"""WITH pages AS(SELECT pages.visually_complete,pages.timestamp + FROM events.pages INNER JOIN public.sessions USING (session_id) + WHERE {" AND ".join(pg_sub_query_subset)}) + SELECT COALESCE((SELECT AVG(pages.visually_complete) FROM pages),0) AS avg, + jsonb_agg(chart) AS chart + FROM + (SELECT generated_timestamp AS timestamp, + COALESCE(AVG(visually_complete), 0) AS avg + FROM generate_series(%(startTimestamp)s, %(endTimestamp)s, %(step_size)s) AS generated_timestamp LEFT JOIN LATERAL ( SELECT pages.visually_complete - FROM events.pages INNER JOIN public.sessions USING (session_id) + FROM pages WHERE {" AND ".join(pg_sub_query_chart)} ) AS pages ON (TRUE) GROUP BY generated_timestamp - ORDER BY generated_timestamp) AS chart) AS chart ON(TRUE);""" + ORDER BY generated_timestamp) AS chart;""" params = {"step_size": step_size, "project_id": project_id, "startTimestamp": startTimestamp, @@ -1484,16 +1631,24 @@ def __merge_rows_with_neutral(rows, neutral): def get_domains_errors(project_id, startTimestamp=TimeUTC.now(delta_days=-1), endTimestamp=TimeUTC.now(), density=6, **args): step_size = __get_step_size(startTimestamp, endTimestamp, density, factor=1) - pg_sub_query_chart = __get_constraints(project_id=project_id, time_constraint=True, chart=True, - data=args) - pg_sub_query_chart.append("resources.status/100 = %(status_code)s") + pg_sub_query_subset = __get_constraints(project_id=project_id, time_constraint=True, chart=False, data=args) + pg_sub_query_chart = __get_constraints(project_id=project_id, time_constraint=False, chart=True, + data=args, main_table="resources", time_column="timestamp", project=False, + duration=False) + pg_sub_query_subset.append("resources.timestamp>=%(startTimestamp)s") + pg_sub_query_subset.append("resources.timestamp<%(endTimestamp)s") + pg_sub_query_subset.append("resources.status/100 = %(status_code)s") with pg_client.PostgresClient() as cur: - pg_query = f"""SELECT generated_timestamp AS timestamp, + pg_query = f"""WITH resources AS(SELECT resources.url_host, timestamp + FROM events.resources INNER JOIN public.sessions USING (session_id) + WHERE {" AND ".join(pg_sub_query_subset)} + ) + SELECT generated_timestamp AS timestamp, COALESCE(JSONB_AGG(resources) FILTER ( WHERE resources IS NOT NULL ), '[]'::JSONB) AS keys FROM generate_series(%(startTimestamp)s, %(endTimestamp)s, %(step_size)s) AS generated_timestamp - LEFT JOIN LATERAL ( SELECT resources.url_host, COUNT(resources.session_id) AS count - FROM events.resources INNER JOIN public.sessions USING (session_id) + LEFT JOIN LATERAL ( SELECT resources.url_host, COUNT(resources.*) AS count + FROM resources WHERE {" AND ".join(pg_sub_query_chart)} GROUP BY url_host ORDER BY count DESC @@ -1527,16 +1682,22 @@ def get_domains_errors(project_id, startTimestamp=TimeUTC.now(delta_days=-1), def get_domains_errors_4xx(project_id, startTimestamp=TimeUTC.now(delta_days=-1), endTimestamp=TimeUTC.now(), density=6, **args): step_size = __get_step_size(startTimestamp, endTimestamp, density, factor=1) - pg_sub_query_chart = __get_constraints(project_id=project_id, time_constraint=True, chart=True, - data=args) - pg_sub_query_chart.append("resources.status/100 = %(status_code)s") + pg_sub_query_subset = __get_constraints(project_id=project_id, time_constraint=True, chart=False, data=args) + pg_sub_query_chart = __get_constraints(project_id=project_id, time_constraint=False, chart=True, + data=args, main_table="resources", time_column="timestamp", project=False, + duration=False) + pg_sub_query_subset.append("resources.status/100 = %(status_code)s") with pg_client.PostgresClient() as cur: - pg_query = f"""SELECT generated_timestamp AS timestamp, + pg_query = f"""WITH resources AS (SELECT resources.url_host, timestamp + FROM events.resources INNER JOIN public.sessions USING (session_id) + WHERE {" AND ".join(pg_sub_query_subset)} + ) + SELECT generated_timestamp AS timestamp, COALESCE(JSONB_AGG(resources) FILTER ( WHERE resources IS NOT NULL ), '[]'::JSONB) AS keys FROM generate_series(%(startTimestamp)s, %(endTimestamp)s, %(step_size)s) AS generated_timestamp - LEFT JOIN LATERAL ( SELECT resources.url_host, COUNT(resources.session_id) AS count - FROM events.resources INNER JOIN public.sessions USING (session_id) + LEFT JOIN LATERAL ( SELECT resources.url_host, COUNT(resources.url_host) AS count + FROM resources WHERE {" AND ".join(pg_sub_query_chart)} GROUP BY url_host ORDER BY count DESC @@ -1562,16 +1723,22 @@ def get_domains_errors_4xx(project_id, startTimestamp=TimeUTC.now(delta_days=-1) def get_domains_errors_5xx(project_id, startTimestamp=TimeUTC.now(delta_days=-1), endTimestamp=TimeUTC.now(), density=6, **args): step_size = __get_step_size(startTimestamp, endTimestamp, density, factor=1) - pg_sub_query_chart = __get_constraints(project_id=project_id, time_constraint=True, chart=True, - data=args) - pg_sub_query_chart.append("resources.status/100 = %(status_code)s") + pg_sub_query_subset = __get_constraints(project_id=project_id, time_constraint=True, chart=False, data=args) + pg_sub_query_chart = __get_constraints(project_id=project_id, time_constraint=False, chart=True, + data=args, main_table="resources", time_column="timestamp", project=False, + duration=False) + pg_sub_query_subset.append("resources.status/100 = %(status_code)s") with pg_client.PostgresClient() as cur: - pg_query = f"""SELECT generated_timestamp AS timestamp, + pg_query = f"""WITH resources AS (SELECT resources.url_host, timestamp + FROM events.resources INNER JOIN public.sessions USING (session_id) + WHERE {" AND ".join(pg_sub_query_subset)} + ) + SELECT generated_timestamp AS timestamp, COALESCE(JSONB_AGG(resources) FILTER ( WHERE resources IS NOT NULL ), '[]'::JSONB) AS keys FROM generate_series(%(startTimestamp)s, %(endTimestamp)s, %(step_size)s) AS generated_timestamp - LEFT JOIN LATERAL ( SELECT resources.url_host, COUNT(resources.session_id) AS count - FROM events.resources INNER JOIN public.sessions USING (session_id) + LEFT JOIN LATERAL ( SELECT resources.url_host, COUNT(resources.url_host) AS count + FROM resources WHERE {" AND ".join(pg_sub_query_chart)} GROUP BY url_host ORDER BY count DESC @@ -1776,33 +1943,55 @@ def get_calls_errors_5xx(project_id, startTimestamp=TimeUTC.now(delta_days=-1), def get_errors_per_type(project_id, startTimestamp=TimeUTC.now(delta_days=-1), endTimestamp=TimeUTC.now(), platform=None, density=7, **args): step_size = __get_step_size(startTimestamp, endTimestamp, density, factor=1) - pg_sub_query_chart = __get_constraints(project_id=project_id, time_constraint=True, - chart=True, data=args) + + pg_sub_query_subset = __get_constraints(project_id=project_id, data=args) + pg_sub_query_subset.append("resources.timestamp>=%(startTimestamp)s") + pg_sub_query_subset.append("resources.timestamp<%(endTimestamp)s") + pg_sub_query_subset.append("resources.type != 'fetch'") + pg_sub_query_subset.append("resources.status > 200") + + pg_sub_query_subset_e = __get_constraints(project_id=project_id, data=args, duration=False, main_table="m_errors", + time_constraint=False) + pg_sub_query_chart = __get_constraints(project_id=project_id, time_constraint=False, + chart=True, data=args, main_table="", time_column="timestamp", + project=False, duration=False) + pg_sub_query_subset_e.append("timestamp>=%(startTimestamp)s") + pg_sub_query_subset_e.append("timestamp<%(endTimestamp)s") with pg_client.PostgresClient() as cur: - pg_query = f"""SELECT generated_timestamp AS timestamp, - COALESCE(SUM(CASE WHEN type = 'fetch' AND status / 100 = 4 THEN 1 ELSE 0 END), 0) AS _4xx, - COALESCE(SUM(CASE WHEN type = 'fetch' AND status / 100 = 5 THEN 1 ELSE 0 END), 0) AS _5xx, - COALESCE(SUM(CASE WHEN type = 'js_exception' THEN 1 ELSE 0 END), 0) AS js, - COALESCE(SUM(CASE WHEN type != 'fetch' AND type != 'js_exception' THEN 1 ELSE 0 END), 0) AS integrations - FROM generate_series(%(startTimestamp)s, %(endTimestamp)s, %(step_size)s) AS generated_timestamp - LEFT JOIN LATERAL ((SELECT status, 'fetch' AS type - FROM events.resources - INNER JOIN public.sessions USING (session_id) - WHERE {" AND ".join(pg_sub_query_chart)} - AND resources.timestamp >= %(startTimestamp)s - %(step_size)s - AND resources.timestamp < %(endTimestamp)s + %(step_size)s - AND resources.type = 'fetch' - AND resources.status > 200) - UNION ALL - (SELECT 0 AS status, m_errors.source::text AS type - FROM events.errors - INNER JOIN public.errors AS m_errors USING (error_id) - INNER JOIN public.sessions USING (session_id) - WHERE {" AND ".join(pg_sub_query_chart)} - AND errors.timestamp >= %(startTimestamp)s - %(step_size)s - AND errors.timestamp < %(endTimestamp)s + %(step_size)s) - ) AS errors_partition ON (TRUE) + pg_query = f"""WITH resources AS (SELECT status, timestamp + FROM events.resources + INNER JOIN public.sessions USING (session_id) + WHERE {" AND ".join(pg_sub_query_subset)} + ), + errors_integ AS (SELECT timestamp + FROM events.errors + INNER JOIN public.errors AS m_errors USING (error_id) + WHERE {" AND ".join(pg_sub_query_subset_e)} + AND source != 'js_exception' + ), + errors_js AS (SELECT timestamp + FROM events.errors + INNER JOIN public.errors AS m_errors USING (error_id) + WHERE {" AND ".join(pg_sub_query_subset_e)} + AND source = 'js_exception' + ) + SELECT generated_timestamp AS timestamp, + COALESCE(SUM(CASE WHEN status / 100 = 4 THEN 1 ELSE 0 END), 0) AS _4xx, + COALESCE(SUM(CASE WHEN status / 100 = 5 THEN 1 ELSE 0 END), 0) AS _5xx, + COALESCE((SELECT COUNT(*) + FROM errors_js + WHERE {" AND ".join(pg_sub_query_chart)} + ), 0) AS js, + COALESCE((SELECT COUNT(*) + FROM errors_integ + WHERE {" AND ".join(pg_sub_query_chart)} + ), 0) AS integrations + FROM generate_series(%(startTimestamp)s, %(endTimestamp)s, %(step_size)s) AS generated_timestamp + LEFT JOIN LATERAL (SELECT status + FROM resources + WHERE {" AND ".join(pg_sub_query_chart)} + ) AS errors_partition ON (TRUE) GROUP BY timestamp ORDER BY timestamp;""" params = {"step_size": step_size, @@ -1819,32 +2008,49 @@ def get_errors_per_type(project_id, startTimestamp=TimeUTC.now(delta_days=-1), e def resource_type_vs_response_end(project_id, startTimestamp=TimeUTC.now(delta_days=-1), endTimestamp=TimeUTC.now(), density=7, **args): step_size = __get_step_size(startTimestamp, endTimestamp, density, factor=1) - pg_sub_query_chart = __get_constraints(project_id=project_id, time_constraint=True, - chart=True, data=args) + pg_sub_query_subset = __get_constraints(project_id=project_id, time_constraint=True, chart=False, data=args) + pg_sub_query_chart = __get_constraints(project_id=project_id, time_constraint=False, chart=True, + data=args, main_table="resources", time_column="timestamp", project=False, + duration=False) + pg_sub_query_subset.append("resources.timestamp>=%(startTimestamp)s") + pg_sub_query_subset.append("resources.timestamp<%(endTimestamp)s") params = {"step_size": step_size, "project_id": project_id, "startTimestamp": startTimestamp, "endTimestamp": endTimestamp, **__get_constraint_values(args)} with pg_client.PostgresClient() as cur: - pg_query = f"""SELECT generated_timestamp AS timestamp, - COUNT(resources.session_id) AS total, + pg_query = f"""WITH resources AS(SELECT resources.type, resources.timestamp + FROM events.resources INNER JOIN public.sessions USING (session_id) + WHERE {" AND ".join(pg_sub_query_subset)}) + SELECT generated_timestamp AS timestamp, + COUNT(resources.*) AS total, SUM(CASE WHEN resources.type='fetch' THEN 1 ELSE 0 END) AS xhr FROM generate_series(%(startTimestamp)s, %(endTimestamp)s, %(step_size)s) AS generated_timestamp - LEFT JOIN LATERAL (SELECT resources.session_id, resources.type - FROM events.resources INNER JOIN public.sessions USING (session_id) + LEFT JOIN LATERAL (SELECT resources.type + FROM resources WHERE {" AND ".join(pg_sub_query_chart)}) AS resources ON (TRUE) GROUP BY generated_timestamp ORDER BY generated_timestamp;""" cur.execute(cur.mogrify(pg_query, params)) actions = cur.fetchall() - pg_sub_query_chart.append("pages.response_end IS NOT NULL") - pg_sub_query_chart.append("pages.response_end>0") - pg_query = f"""SELECT generated_timestamp AS timestamp, + pg_sub_query_subset = __get_constraints(project_id=project_id, time_constraint=True, chart=False, data=args) + pg_sub_query_chart = __get_constraints(project_id=project_id, time_constraint=False, chart=True, + data=args, main_table="pages", time_column="timestamp", + project=False, + duration=False) + pg_sub_query_subset.append("pages.timestamp>=%(startTimestamp)s") + pg_sub_query_subset.append("pages.timestamp<%(endTimestamp)s") + pg_sub_query_subset.append("pages.response_end IS NOT NULL") + pg_sub_query_subset.append("pages.response_end>0") + pg_query = f"""WITH pages AS(SELECT pages.response_end, timestamp + FROM events.pages INNER JOIN public.sessions USING (session_id) + WHERE {" AND ".join(pg_sub_query_subset)}) + SELECT generated_timestamp AS timestamp, COALESCE(AVG(pages.response_end),0) AS avg_response_end FROM generate_series(%(startTimestamp)s, %(endTimestamp)s, %(step_size)s) AS generated_timestamp LEFT JOIN LATERAL (SELECT pages.response_end - FROM events.pages INNER JOIN public.sessions USING (session_id) + FROM pages WHERE {" AND ".join(pg_sub_query_chart)}) AS pages ON(TRUE) GROUP BY generated_timestamp ORDER BY generated_timestamp;""" @@ -1869,51 +2075,60 @@ def get_impacted_sessions_by_js_errors(project_id, startTimestamp=TimeUTC.now(de pg_sub_query_chart.append("errors.timestamp >= generated_timestamp") pg_sub_query_chart.append("errors.timestamp < generated_timestamp+ %(step_size)s") + pg_sub_query_subset = __get_constraints(project_id=project_id, data=args, duration=False, main_table="m_errors", + time_constraint=False) + pg_sub_query_chart = __get_constraints(project_id=project_id, time_constraint=False, + chart=True, data=args, main_table="errors", time_column="timestamp", + project=False, duration=False) + pg_sub_query_subset.append("m_errors.source = 'js_exception'") + pg_sub_query_subset.append("errors.timestamp>=%(startTimestamp)s") + pg_sub_query_subset.append("errors.timestamp<%(endTimestamp)s") + with pg_client.PostgresClient() as cur: - pg_query = f"""SELECT * - FROM (SELECT COUNT(DISTINCT errors.session_id) AS sessions_count - FROM events.errors - INNER JOIN public.errors AS m_errors USING (error_id) - INNER JOIN public.sessions USING(session_id) - WHERE {" AND ".join(pg_sub_query)}) AS counts - LEFT JOIN - (SELECT jsonb_agg(chart) AS chart - FROM(SELECT generated_timestamp AS timestamp, - COALESCE(COUNT(session_id), 0) AS sessions_count - FROM generate_series(%(startTimestamp)s, %(endTimestamp)s, %(step_size)s) AS generated_timestamp - LEFT JOIN LATERAL ( SELECT DISTINCT errors.session_id - FROM events.errors - INNER JOIN public.errors AS m_errors USING (error_id) - INNER JOIN public.sessions USING (session_id) + pg_query = f"""WITH errors AS (SELECT DISTINCT ON (session_id,timestamp) session_id, timestamp + FROM events.errors + INNER JOIN public.errors AS m_errors USING (error_id) + WHERE {" AND ".join(pg_sub_query_subset)} + ) + SELECT * + FROM (SELECT COUNT(DISTINCT session_id) AS sessions_count + FROM errors) AS counts + LEFT JOIN + (SELECT jsonb_agg(chart) AS chart + FROM (SELECT generated_timestamp AS timestamp, + COALESCE(COUNT(session_id), 0) AS sessions_count + FROM generate_series(%(startTimestamp)s, %(endTimestamp)s, %(step_size)s) AS generated_timestamp + LEFT JOIN LATERAL ( SELECT DISTINCT session_id + FROM errors WHERE {" AND ".join(pg_sub_query_chart)} - ) AS sessions ON (TRUE) - GROUP BY generated_timestamp - ORDER BY generated_timestamp) AS chart) AS chart ON(TRUE);""" + ) AS sessions ON (TRUE) + GROUP BY generated_timestamp + ORDER BY generated_timestamp) AS chart) AS chart ON (TRUE);""" cur.execute(cur.mogrify(pg_query, {"step_size": step_size, "project_id": project_id, "startTimestamp": startTimestamp, "endTimestamp": endTimestamp, **__get_constraint_values(args)})) row_sessions = cur.fetchone() - pg_query = f"""SELECT * - FROM (SELECT COUNT(DISTINCT errors.error_id) AS errors_count - FROM events.errors - INNER JOIN public.errors AS m_errors USING (error_id) - INNER JOIN public.sessions USING(session_id) - WHERE {" AND ".join(pg_sub_query)}) AS counts - LEFT JOIN - (SELECT jsonb_agg(chart) AS chart - FROM(SELECT generated_timestamp AS timestamp, - COALESCE(COUNT(error_id), 0) AS errors_count - FROM generate_series(%(startTimestamp)s, %(endTimestamp)s, %(step_size)s) AS generated_timestamp - LEFT JOIN LATERAL ( SELECT DISTINCT errors.error_id - FROM events.errors - INNER JOIN public.errors AS m_errors USING (error_id) - INNER JOIN public.sessions USING (session_id) - WHERE {" AND ".join(pg_sub_query_chart)} - ) AS errors ON (TRUE) - GROUP BY generated_timestamp - ORDER BY generated_timestamp) AS chart) AS chart ON(TRUE);""" + pg_query = f"""WITH errors AS ( SELECT DISTINCT ON(errors.error_id,timestamp) errors.error_id,timestamp + FROM events.errors + INNER JOIN public.errors AS m_errors USING (error_id) + WHERE {" AND ".join(pg_sub_query_subset)} + ) + SELECT * + FROM (SELECT COUNT(DISTINCT errors.error_id) AS errors_count + FROM errors) AS counts + LEFT JOIN + (SELECT jsonb_agg(chart) AS chart + FROM (SELECT generated_timestamp AS timestamp, + COALESCE(COUNT(error_id), 0) AS errors_count + FROM generate_series(%(startTimestamp)s, %(endTimestamp)s, %(step_size)s) AS generated_timestamp + LEFT JOIN LATERAL ( SELECT DISTINCT errors.error_id + FROM errors + WHERE {" AND ".join(pg_sub_query_chart)} + ) AS errors ON (TRUE) + GROUP BY generated_timestamp + ORDER BY generated_timestamp) AS chart) AS chart ON (TRUE);""" cur.execute(cur.mogrify(pg_query, {"step_size": step_size, "project_id": project_id, "startTimestamp": startTimestamp, @@ -1930,10 +2145,23 @@ def get_impacted_sessions_by_js_errors(project_id, startTimestamp=TimeUTC.now(de def get_resources_vs_visually_complete(project_id, startTimestamp=TimeUTC.now(delta_days=-1), endTimestamp=TimeUTC.now(), density=7, **args): step_size = __get_step_size(startTimestamp, endTimestamp, density, factor=1) - pg_sub_query_chart = __get_constraints(project_id=project_id, time_constraint=True, - chart=True, data=args) + pg_sub_query_subset = __get_constraints(project_id=project_id, time_constraint=True, chart=False, data=args) + pg_sub_query_chart = __get_constraints(project_id=project_id, time_constraint=False, chart=True, + data=args, main_table="", time_column="timestamp", project=False, + duration=False) + pg_sub_query_subset.append("timestamp>=%(startTimestamp)s") + pg_sub_query_subset.append("timestamp<%(endTimestamp)s") with pg_client.PostgresClient() as cur: - pg_query = f"""SELECT generated_timestamp AS timestamp, + pg_query = f"""WITH resources AS (SELECT resources.type, timestamp, session_id + FROM events.resources + INNER JOIN public.sessions USING (session_id) + WHERE {" AND ".join(pg_sub_query_subset)} + ), + pages AS (SELECT visually_complete, timestamp + FROM events.pages + INNER JOIN public.sessions USING (session_id) + WHERE {" AND ".join(pg_sub_query_subset)} AND pages.visually_complete > 0) + SELECT generated_timestamp AS timestamp, COALESCE(jsonb_agg(resources_avg_count_by_type) FILTER ( WHERE resources_avg_count_by_type IS NOT NULL ), '[]'::jsonb) AS types, COALESCE(AVG(total_count), 0) AS avg_count_resources, @@ -1942,17 +2170,15 @@ def get_resources_vs_visually_complete(project_id, startTimestamp=TimeUTC.now(de LEFT JOIN LATERAL (SELECT resources_count_by_session_by_type.type, avg(resources_count_by_session_by_type.count) AS avg_count, sum(resources_count_by_session_by_type.count) AS total_count - FROM (SELECT resources.type, COUNT(resources.url) AS count - FROM events.resources - INNER JOIN public.sessions USING (session_id) - WHERE {" AND ".join(pg_sub_query_chart)} - GROUP BY resources.session_id, resources.type) AS resources_count_by_session_by_type + FROM (SELECT resources.type, COUNT(*) AS count + FROM resources + WHERE {" AND ".join(pg_sub_query_chart)} + GROUP BY resources.session_id, resources.type) AS resources_count_by_session_by_type GROUP BY resources_count_by_session_by_type.type) AS resources_avg_count_by_type ON (TRUE) LEFT JOIN LATERAL (SELECT AVG(visually_complete) AS avg_time_to_render - FROM events.pages - INNER JOIN public.sessions USING (session_id) + FROM pages WHERE {" AND ".join(pg_sub_query_chart)} - AND pages.visually_complete > 0) AS time_to_render ON (TRUE) + ) AS time_to_render ON (TRUE) GROUP BY generated_timestamp ORDER BY generated_timestamp;""" cur.execute(cur.mogrify(pg_query, {"step_size": step_size, @@ -1970,15 +2196,21 @@ def get_resources_vs_visually_complete(project_id, startTimestamp=TimeUTC.now(de def get_resources_count_by_type(project_id, startTimestamp=TimeUTC.now(delta_days=-1), endTimestamp=TimeUTC.now(), density=7, **args): step_size = __get_step_size(startTimestamp, endTimestamp, density, factor=1) - pg_sub_query_chart = __get_constraints(project_id=project_id, time_constraint=True, - chart=True, data=args) + pg_sub_query_subset = __get_constraints(project_id=project_id, time_constraint=True, chart=False, data=args) + pg_sub_query_chart = __get_constraints(project_id=project_id, time_constraint=False, chart=True, + data=args, main_table="resources", time_column="timestamp", project=False, + duration=False) with pg_client.PostgresClient() as cur: - pg_query = f"""SELECT generated_timestamp AS timestamp, + pg_query = f"""WITH resources AS (SELECT resources.type, timestamp + FROM events.resources INNER JOIN public.sessions USING (session_id) + WHERE {" AND ".join(pg_sub_query_subset)} + ) + SELECT generated_timestamp AS timestamp, COALESCE(JSONB_AGG(t) FILTER (WHERE t IS NOT NULL), '[]'::JSONB) AS types FROM generate_series(%(startTimestamp)s, %(endTimestamp)s, %(step_size)s) AS generated_timestamp - LEFT JOIN LATERAL (SELECT resources.type, COUNT(resources.session_id) AS count - FROM events.resources INNER JOIN public.sessions USING (session_id) + LEFT JOIN LATERAL (SELECT resources.type, COUNT(*) AS count + FROM resources WHERE {" AND ".join(pg_sub_query_chart)} GROUP BY resources.type ) AS t ON(TRUE) @@ -2001,33 +2233,45 @@ def get_resources_count_by_type(project_id, startTimestamp=TimeUTC.now(delta_day def get_resources_by_party(project_id, startTimestamp=TimeUTC.now(delta_days=-1), endTimestamp=TimeUTC.now(), density=7, **args): step_size = __get_step_size(startTimestamp, endTimestamp, density, factor=1) - pg_sub_query_chart = __get_constraints(project_id=project_id, time_constraint=True, chart=True, - data=args) - pg_sub_query_chart.append("resources.success = FALSE") - pg_sub_query = ["sessions.project_id =%(project_id)s", "rs.type IN ('fetch','script')"] + pg_sub_query_subset = __get_constraints(project_id=project_id, time_constraint=True, + chart=False, data=args) + pg_sub_query_chart = __get_constraints(project_id=project_id, time_constraint=False, project=False, + chart=True, data=args, main_table="resources", time_column="timestamp", + duration=False) + pg_sub_query_subset.append("resources.timestamp >= %(startTimestamp)s") + pg_sub_query_subset.append("resources.timestamp < %(endTimestamp)s") + pg_sub_query_subset.append("resources.success = FALSE") with pg_client.PostgresClient() as cur: - pg_query = f"""SELECT generated_timestamp AS timestamp, + pg_query = f"""WITH resources AS ( + SELECT resources.url_host, timestamp + FROM events.resources + INNER JOIN public.sessions USING (session_id) + WHERE {" AND ".join(pg_sub_query_subset)} + ) + SELECT generated_timestamp AS timestamp, SUM(CASE WHEN first.url_host = sub_resources.url_host THEN 1 ELSE 0 END) AS first_party, SUM(CASE WHEN first.url_host != sub_resources.url_host THEN 1 ELSE 0 END) AS third_party FROM generate_series(%(startTimestamp)s, %(endTimestamp)s, %(step_size)s) AS generated_timestamp LEFT JOIN ( - SELECT resources.url_host, - COUNT(resources.session_id) AS count - FROM events.resources - INNER JOIN public.sessions USING (session_id) - WHERE sessions.project_id = '1' - AND resources.type IN ('fetch', 'script') - AND sessions.start_ts > (EXTRACT(EPOCH FROM now() - INTERVAL '31 days') * 1000)::BIGINT - AND resources.timestamp > (EXTRACT(EPOCH FROM now() - INTERVAL '31 days') * 1000)::BIGINT - GROUP BY resources.url_host - ORDER BY count DESC - LIMIT 1 - ) AS first ON (TRUE) - LEFT JOIN LATERAL ( - SELECT resources.url_host + SELECT resources.url_host, + COUNT(resources.session_id) AS count FROM events.resources INNER JOIN public.sessions USING (session_id) + WHERE sessions.project_id = '1' + AND resources.type IN ('fetch', 'script') + AND sessions.start_ts > (EXTRACT(EPOCH FROM now() - INTERVAL '31 days') * 1000)::BIGINT + AND sessions.start_ts < (EXTRACT(EPOCH FROM now()) * 1000)::BIGINT + AND resources.timestamp > (EXTRACT(EPOCH FROM now() - INTERVAL '31 days') * 1000)::BIGINT + AND resources.timestamp < (EXTRACT(EPOCH FROM now()) * 1000)::BIGINT + AND sessions.duration>0 + GROUP BY resources.url_host + ORDER BY count DESC + LIMIT 1 + ) AS first ON (TRUE) + LEFT JOIN LATERAL ( + SELECT resources.url_host + FROM resources WHERE {" AND ".join(pg_sub_query_chart)} ) AS sub_resources ON (TRUE) GROUP BY generated_timestamp @@ -2036,5 +2280,6 @@ def get_resources_by_party(project_id, startTimestamp=TimeUTC.now(delta_days=-1) "project_id": project_id, "startTimestamp": startTimestamp, "endTimestamp": endTimestamp, **__get_constraint_values(args)})) + rows = cur.fetchall() return rows diff --git a/api/chalicelib/core/reset_password.py b/api/chalicelib/core/reset_password.py index a792bfbc1..4d4c3939e 100644 --- a/api/chalicelib/core/reset_password.py +++ b/api/chalicelib/core/reset_password.py @@ -38,8 +38,7 @@ def step2(data): print("error: wrong email or reset code") return {"errors": ["wrong email or reset code"]} users.update(tenant_id=user["tenantId"], user_id=user["id"], - changes={"token": None, "password": data["password"], "generatedPassword": False, - "verifiedEmail": True}) + changes={"token": None, "password": data["password"], "generatedPassword": False}) return {"data": {"state": "success"}} diff --git a/api/chalicelib/core/sessions.py b/api/chalicelib/core/sessions.py index 03d80bfa5..6dc0edc29 100644 --- a/api/chalicelib/core/sessions.py +++ b/api/chalicelib/core/sessions.py @@ -1,6 +1,6 @@ from chalicelib.utils import pg_client, helper, dev from chalicelib.core import events, sessions_metas, socket_ios, metadata, events_ios, \ - sessions_mobs, issues, projects, errors, resources + sessions_mobs, issues, projects, errors, resources, assist SESSION_PROJECTION_COLS = """s.project_id, s.session_id::text AS session_id, @@ -54,7 +54,8 @@ def get_by_id2_pg(project_id, session_id, user_id, full_data=False, include_fav_ f"""\ SELECT s.*, - s.session_id::text AS session_id + s.session_id::text AS session_id, + (SELECT project_key FROM public.projects WHERE project_id = %(project_id)s LIMIT 1) AS project_key {"," if len(extra_query) > 0 else ""}{",".join(extra_query)} {(",json_build_object(" + ",".join([f"'{m}',p.{m}" for m in metadata._get_column_names()]) + ") AS project_metadata") if group_metadata else ''} FROM public.sessions AS s {"INNER JOIN public.projects AS p USING (project_id)" if group_metadata else ""} @@ -99,6 +100,8 @@ def get_by_id2_pg(project_id, session_id, user_id, full_data=False, include_fav_ data['metadata'] = __group_metadata(project_metadata=data.pop("projectMetadata"), session=data) data['issues'] = issues.get_by_session_id(session_id=session_id) + data['live'] = assist.is_live(project_id=project_id, session_id=session_id, + project_key=data["projectKey"]) return data return None diff --git a/api/chalicelib/utils/email_handler.py b/api/chalicelib/utils/email_handler.py index 2de35e616..f7a7fd61b 100644 --- a/api/chalicelib/utils/email_handler.py +++ b/api/chalicelib/utils/email_handler.py @@ -10,7 +10,7 @@ from chalicelib.utils.helper import environ def __get_subject(subject): - return subject if helper.is_production() else f"{helper.get_stage_name()}: {subject}" + return subject def __get_html_from_file(source, formatting_variables): diff --git a/api/chalicelib/utils/pg_client.py b/api/chalicelib/utils/pg_client.py index 89a9dc8fa..ba72868d6 100644 --- a/api/chalicelib/utils/pg_client.py +++ b/api/chalicelib/utils/pg_client.py @@ -27,7 +27,7 @@ class ORThreadedConnectionPool(psycopg2.pool.ThreadedConnectionPool): try: - postgreSQL_pool = ORThreadedConnectionPool(20, 100, **PG_CONFIG) + postgreSQL_pool = ORThreadedConnectionPool(50, 100, **PG_CONFIG) if (postgreSQL_pool): print("Connection pool created successfully") except (Exception, psycopg2.DatabaseError) as error: diff --git a/api/db_changes.sql b/api/db_changes.sql new file mode 100644 index 000000000..d9acf06fa --- /dev/null +++ b/api/db_changes.sql @@ -0,0 +1,7 @@ +BEGIN; +CREATE INDEX pages_first_contentful_paint_time_idx ON events.pages (first_contentful_paint_time) WHERE first_contentful_paint_time>0; +CREATE INDEX pages_dom_content_loaded_time_idx ON events.pages (dom_content_loaded_time) WHERE dom_content_loaded_time>0; +CREATE INDEX pages_first_paint_time_idx ON events.pages (first_paint_time) WHERE first_paint_time > 0; +CREATE INDEX pages_ttfb_idx ON events.pages (ttfb) WHERE ttfb > 0; +CREATE INDEX pages_time_to_interactive_idx ON events.pages (time_to_interactive) WHERE time_to_interactive > 0; +COMMIT; \ No newline at end of file diff --git a/api/entrypoint.sh b/api/entrypoint.sh index 3c3d12fd5..4a8c790c8 100755 --- a/api/entrypoint.sh +++ b/api/entrypoint.sh @@ -1,6 +1,3 @@ #!/bin/bash -cd sourcemaps_reader -nohup node server.js &> /tmp/sourcemaps_reader.log & -cd .. python env_handler.py chalice local --no-autoreload --host 0.0.0.0 --stage ${ENTERPRISE_BUILD} diff --git a/api/sourcemaps_reader/.gitignore b/api/sourcemaps_reader/.gitignore deleted file mode 100644 index f642462b1..000000000 --- a/api/sourcemaps_reader/.gitignore +++ /dev/null @@ -1,11 +0,0 @@ -# package directories -node_modules -jspm_packages - -# Serverless directories -.serverless/*.zip - - -node_modules/ -.idea -test.js \ No newline at end of file diff --git a/api/sourcemaps_reader/README.md b/api/sourcemaps_reader/README.md deleted file mode 100644 index d543da338..000000000 --- a/api/sourcemaps_reader/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# sourcemap-reader -Source Map Reader - -# For SAAS: -to run local; put your test values in handler then run `node handler.js` -to deploy `sls deploy --stage [staging|prod|dev]` - -# Requirements: -- nodeJS 12 or greater - -# Install for OS: -``` -npm install -node server.js -``` \ No newline at end of file diff --git a/api/sourcemaps_reader/package-lock.json b/api/sourcemaps_reader/package-lock.json deleted file mode 100644 index 719a55bb3..000000000 --- a/api/sourcemaps_reader/package-lock.json +++ /dev/null @@ -1,109 +0,0 @@ -{ - "name": "sourcemap-reader", - "version": "1.0.0", - "lockfileVersion": 1, - "requires": true, - "dependencies": { - "aws-sdk": { - "version": "2.654.0", - "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.654.0.tgz", - "integrity": "sha512-RAx/SJ74zAqBW1wyRxiHNflmrv50i35pu8kPxfMIJ418TJzIMt+LKgn55rTJgyUdUzKi+MC9XMY4n7IDtwj3HA==", - "requires": { - "buffer": "4.9.1", - "events": "1.1.1", - "ieee754": "1.1.13", - "jmespath": "0.15.0", - "querystring": "0.2.0", - "sax": "1.2.1", - "url": "0.10.3", - "uuid": "3.3.2", - "xml2js": "0.4.19" - }, - "dependencies": { - "buffer": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.1.tgz", - "integrity": "sha1-bRu2AbB6TvztlwlBMgkwJ8lbwpg=", - "requires": { - "base64-js": "^1.0.2", - "ieee754": "^1.1.4", - "isarray": "^1.0.0" - } - }, - "events": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz", - "integrity": "sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ=" - }, - "punycode": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", - "integrity": "sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0=" - }, - "url": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/url/-/url-0.10.3.tgz", - "integrity": "sha1-Ah5NnHcF8hu/N9A861h2dAJ3TGQ=", - "requires": { - "punycode": "1.3.2", - "querystring": "0.2.0" - } - } - } - }, - "base64-js": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz", - "integrity": "sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g==" - }, - "ieee754": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz", - "integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==" - }, - "isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" - }, - "jmespath": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.15.0.tgz", - "integrity": "sha1-o/Iiqarp+Wb10nx5ZRDigJF2Qhc=" - }, - "querystring": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", - "integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=" - }, - "sax": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz", - "integrity": "sha1-e45lYZCyKOgaZq6nSEgNgozS03o=" - }, - "source-map": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", - "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==" - }, - "uuid": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz", - "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==" - }, - "xml2js": { - "version": "0.4.19", - "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.19.tgz", - "integrity": "sha512-esZnJZJOiJR9wWKMyuvSE1y6Dq5LCuJanqhxslH2bxM6duahNZ+HMpCLhBQGZkbX6xRf8x1Y2eJlgt2q3qo49Q==", - "requires": { - "sax": ">=0.6.0", - "xmlbuilder": "~9.0.1" - } - }, - "xmlbuilder": { - "version": "9.0.7", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-9.0.7.tgz", - "integrity": "sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0=" - } - } -} diff --git a/api/sourcemaps_reader/package.json b/api/sourcemaps_reader/package.json deleted file mode 100644 index ed169326e..000000000 --- a/api/sourcemaps_reader/package.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "sourcemap-reader", - "version": "1.0.0", - "description": "", - "main": "handler.js", - "dependencies": { - "aws-sdk": "^2.654.0", - "source-map": "^0.7.3" - }, - "devDependencies": {}, - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" - }, - "author": "Kraiem taha yassine ", - "license": "ISC" -} diff --git a/api/sourcemaps_reader/server.js b/api/sourcemaps_reader/server.js deleted file mode 100644 index 2a1c4dcf6..000000000 --- a/api/sourcemaps_reader/server.js +++ /dev/null @@ -1,38 +0,0 @@ -const http = require('http'); -const handler = require('./handler'); -const hostname = '127.0.0.1'; -const port = 3000; - -const server = http.createServer((req, res) => { - if (req.method === 'POST') { - let data = ''; - req.on('data', chunk => { - data += chunk; - }); - req.on('end', function () { - data = JSON.parse(data); - console.log("Starting parser for: " + data.key); - // process.env = {...process.env, ...data.bucket_config}; - handler.sourcemapReader(data) - .then((results) => { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify(results)); - }) - .catch((e) => { - console.error("Something went wrong"); - console.error(e); - res.statusCode(500); - res.end(e); - }); - }) - } else { - res.statusCode = 405; - res.setHeader('Content-Type', 'text/plain'); - res.end('Method Not Allowed'); - } -}); - -server.listen(port, hostname, () => { - console.log(`Server running at http://${hostname}:${port}/`); -}); \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile index af8d4ff71..45b435398 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -4,15 +4,11 @@ RUN apk add --no-cache git openssh openssl-dev pkgconf gcc g++ make libc-dev bas WORKDIR /root -COPY go.mod . -COPY go.sum . +COPY . . RUN go mod download -COPY . . - ARG SERVICE_NAME - RUN CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -o service -tags musl openreplay/backend/services/$SERVICE_NAME FROM alpine @@ -39,6 +35,7 @@ ENV TZ=UTC \ AWS_REGION_WEB=eu-central-1 \ AWS_REGION_IOS=eu-west-1 \ AWS_REGION_ASSETS=eu-central-1 \ + CACHE_ASSETS=false \ ASSETS_SIZE_LIMIT=6291456 diff --git a/backend/Dockerfile-all b/backend/Dockerfile-all index f9e451fb6..d56dcbf23 100644 --- a/backend/Dockerfile-all +++ b/backend/Dockerfile-all @@ -35,6 +35,7 @@ ENV TZ=UTC \ AWS_REGION_WEB=eu-central-1 \ AWS_REGION_IOS=eu-west-1 \ AWS_REGION_ASSETS=eu-central-1 \ + CACHE_ASSETS=true \ ASSETS_SIZE_LIMIT=6291456 RUN mkdir $FS_DIR diff --git a/backend/pkg/intervals/intervals.go b/backend/pkg/intervals/intervals.go index d7e6bfad5..5cc603ad6 100644 --- a/backend/pkg/intervals/intervals.go +++ b/backend/pkg/intervals/intervals.go @@ -1,6 +1,6 @@ package intervals -const EVENTS_COMMIT_INTERVAL = 1 * 60 * 1000 +const EVENTS_COMMIT_INTERVAL = 30 * 1000 const HEARTBEAT_INTERVAL = 2 * 60 * 1000 const INTEGRATIONS_REQUEST_INTERVAL = 2 * 60 * 1000 const EVENTS_PAGE_EVENT_TIMEOUT = 2 * 60 * 1000 diff --git a/backend/pkg/url/assets/css.go b/backend/pkg/url/assets/css.go index 728239837..d06d62797 100644 --- a/backend/pkg/url/assets/css.go +++ b/backend/pkg/url/assets/css.go @@ -51,13 +51,27 @@ func ExtractURLsFromCSS(css string) []string { return urls } -func (r *Rewriter) RewriteCSS(sessionID uint64, baseurl string, css string) string { +func rewriteLinks(css string, rewrite func(rawurl string) string) string { for _, idx := range cssUrlsIndex(css) { f := idx[0] t := idx[1] rawurl, q := unquote(css[f:t]) // why exactly quote back? - css = css[:f] + q + r.RewriteURL(sessionID, baseurl, rawurl) + q + css[t:] + css = css[:f] + q + rewrite(rawurl) + q + css[t:] } + return css +} + +func ResolveCSS(baseURL string, css string) string { + css = rewriteLinks(css, func(rawurl string) string { + return ResolveURL(baseURL, rawurl) + }) + return strings.Replace(css, ":hover", ".-asayer-hover", -1) +} + +func (r *Rewriter) RewriteCSS(sessionID uint64, baseurl string, css string) string { + css = rewriteLinks(css, func(rawurl string) string { + return r.RewriteURL(sessionID, baseurl, rawurl) + }) return strings.Replace(css, ":hover", ".-asayer-hover", -1) } diff --git a/backend/pkg/url/assets/url.go b/backend/pkg/url/assets/url.go index 9f6c2c267..66402ab28 100644 --- a/backend/pkg/url/assets/url.go +++ b/backend/pkg/url/assets/url.go @@ -12,6 +12,9 @@ func getSessionKey(sessionID uint64) string { } func ResolveURL(baseurl string, rawurl string) string { + if !isRelativeCachable(rawurl) { + return rawurl + } base, _ := url.ParseRequestURI(baseurl) // fn Only for base urls u, _ := url.Parse(rawurl) // TODO: handle errors ? if base == nil || u == nil { @@ -50,7 +53,7 @@ func GetFullCachableURL(baseURL string, relativeURL string) (string, bool) { } -const ASAYER_QUERY_START = "ASAYER_QUERY_ESCtRT" +const OPENREPLAY_QUERY_START = "OPENREPLAY_QUERY" func getCachePath(rawurl string) string { u, _ := url.Parse(rawurl) @@ -59,7 +62,7 @@ func getCachePath(rawurl string) string { if (s[len(s) - 1] != '/') { s += "/" } - s += ASAYER_QUERY_START + url.PathEscape(u.RawQuery) + s += OPENREPLAY_QUERY_START + url.PathEscape(u.RawQuery) } return s } diff --git a/backend/services/assets/cacher/cacher.go b/backend/services/assets/cacher/cacher.go index 85a8a9d61..59b09449f 100644 --- a/backend/services/assets/cacher/cacher.go +++ b/backend/services/assets/cacher/cacher.go @@ -62,6 +62,7 @@ func (c *cacher) cacheURL(requestURL string, sessionID uint64, depth byte, conte req, _ := http.NewRequest("GET", requestURL, nil) req.Header.Set("Cookie", "ABv=3;") // Hack for rueducommerce + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 6.1; rv:31.0) Gecko/20100101 Firefox/31.0") res, err := c.httpClient.Do(req) if err != nil { c.Errors <- errors.Wrap(err, context) diff --git a/backend/services/assets/main.go b/backend/services/assets/main.go index 0193be7bb..da7a92bbe 100644 --- a/backend/services/assets/main.go +++ b/backend/services/assets/main.go @@ -64,6 +64,8 @@ func main() { log.Printf("Caught signal %v: terminating\n", sig) consumer.Close() os.Exit(0) + case err := <-cacher.Errors: + log.Printf("Error while caching: %v", err) case <-tick: cacher.UpdateTimeouts() default: diff --git a/backend/services/http/assets.go b/backend/services/http/assets.go index 637aa5a5b..6a1e57243 100644 --- a/backend/services/http/assets.go +++ b/backend/services/http/assets.go @@ -6,7 +6,7 @@ import ( ) func sendAssetForCache(sessionID uint64, baseURL string, relativeURL string) { - if fullURL, cachable := assets.GetFullCachableURL(baseURL, relativeURL); cachable { + if fullURL, cacheable := assets.GetFullCachableURL(baseURL, relativeURL); cacheable { producer.Produce(topicTrigger, sessionID, messages.Encode(&messages.AssetCache{ URL: fullURL, })) @@ -17,4 +17,20 @@ func sendAssetsForCacheFromCSS(sessionID uint64, baseURL string, css string) { for _, u := range assets.ExtractURLsFromCSS(css) { // TODO: in one shot with rewriting sendAssetForCache(sessionID, baseURL, u) } +} + +func handleURL(sessionID uint64, baseURL string, url string) string { + if cacheAssets { + sendAssetForCache(sessionID, baseURL, url) + return rewriter.RewriteURL(sessionID, baseURL, url) + } + return assets.ResolveURL(baseURL, url) +} + +func handleCSS(sessionID uint64, baseURL string, css string) string { + if cacheAssets { + sendAssetsForCacheFromCSS(sessionID, baseURL, css) + return rewriter.RewriteCSS(sessionID, baseURL, css) + } + return assets.ResolveCSS(baseURL, css) } \ No newline at end of file diff --git a/backend/services/http/handlers.go b/backend/services/http/handlers.go index c17a46286..b3527ac3f 100644 --- a/backend/services/http/handlers.go +++ b/backend/services/http/handlers.go @@ -174,32 +174,28 @@ func pushMessagesSeparatelyHandler(w http.ResponseWriter, r *http.Request) { switch m := msg.(type) { case *SetNodeAttributeURLBased: if m.Name == "src" || m.Name == "href" { - sendAssetForCache(sessionData.ID, m.BaseURL, m.Value) msg = &SetNodeAttribute{ ID: m.ID, Name: m.Name, - Value: rewriter.RewriteURL(sessionData.ID, m.BaseURL, m.Value), + Value: handleURL(sessionData.ID, m.BaseURL, m.Value), } } else if m.Name == "style" { - sendAssetsForCacheFromCSS(sessionData.ID, m.BaseURL, m.Value) msg = &SetNodeAttribute{ ID: m.ID, Name: m.Name, - Value: rewriter.RewriteCSS(sessionData.ID, m.BaseURL, m.Value), + Value: handleCSS(sessionData.ID, m.BaseURL, m.Value), } } case *SetCSSDataURLBased: - sendAssetsForCacheFromCSS(sessionData.ID, m.BaseURL, m.Data) msg = &SetCSSData{ ID: m.ID, - Data: rewriter.RewriteCSS(sessionData.ID, m.BaseURL, m.Data), + Data: handleCSS(sessionData.ID, m.BaseURL, m.Data), } case *CSSInsertRuleURLBased: - sendAssetsForCacheFromCSS(sessionData.ID, m.BaseURL, m.Rule) msg = &CSSInsertRule{ ID: m.ID, Index: m.Index, - Rule: rewriter.RewriteCSS(sessionData.ID, m.BaseURL, m.Rule), + Rule: handleCSS(sessionData.ID, m.BaseURL, m.Rule), } } diff --git a/backend/services/http/main.go b/backend/services/http/main.go index b3e3b6d9d..d978bca80 100644 --- a/backend/services/http/main.go +++ b/backend/services/http/main.go @@ -35,6 +35,7 @@ var topicRaw string var topicTrigger string var topicAnalytics string // var kafkaTopicEvents string +var cacheAssets bool func main() { log.SetFlags(log.LstdFlags | log.LUTC | log.Llongfile) @@ -52,6 +53,8 @@ func main() { uaParser = uaparser.NewUAParser(env.String("UAPARSER_FILE")) geoIP = geoip.NewGeoIP(env.String("MAXMINDDB_FILE")) flaker = flakeid.NewFlaker(env.WorkerID()) + cacheAssets = env.Bool("CACHE_ASSETS") + HTTP_PORT := env.String("HTTP_PORT") server := &http.Server{ diff --git a/ee/api/.chalice/config.json b/ee/api/.chalice/config.json index 6b4d2cc20..761a8cfa7 100644 --- a/ee/api/.chalice/config.json +++ b/ee/api/.chalice/config.json @@ -33,8 +33,9 @@ "sessions_bucket": "mobs", "sessions_region": "us-east-1", "put_S3_TTL": "20", - "sourcemaps_reader": "http://127.0.0.1:3000/", + "sourcemaps_reader": "http://utilities-openreplay.app.svc.cluster.local:9000/sourcemaps", "sourcemaps_bucket": "sourcemaps", + "peers": "http://utilities-openreplay.app.svc.cluster.local:9000/assist/peers", "js_cache_bucket": "sessions-assets", "async_Token": "", "EMAIL_HOST": "", @@ -54,7 +55,13 @@ "S3_HOST": "", "S3_KEY": "", "S3_SECRET": "", - "version_number": "1.0.0" + "version_number": "1.0.0", + "LICENSE_KEY": "", + "SAML2_MD_URL": "", + "idp_entityId": "", + "idp_sso_url": "", + "idp_x509cert": "", + "idp_sls_url": "" }, "lambda_timeout": 150, "lambda_memory_size": 400, diff --git a/ee/api/.gitignore b/ee/api/.gitignore index 3f3e5d811..a526e1c21 100644 --- a/ee/api/.gitignore +++ b/ee/api/.gitignore @@ -175,4 +175,60 @@ SUBNETS.json chalicelib/.config chalicelib/saas README/* -Pipfile \ No newline at end of file +Pipfile + +/chalicelib/core/alerts.py +/chalicelib/core/announcements.py +/chalicelib/blueprints/bp_app_api.py +/chalicelib/blueprints/bp_core.py +/chalicelib/blueprints/bp_core_crons.py +/chalicelib/core/collaboration_slack.py +/chalicelib/core/errors_favorite_viewed.py +/chalicelib/core/events.py +/chalicelib/core/events_ios.py +/chalicelib/core/integration_base.py +/chalicelib/core/integration_base_issue.py +/chalicelib/core/integration_github.py +/chalicelib/core/integration_github_issue.py +/chalicelib/core/integration_jira_cloud.py +/chalicelib/core/integration_jira_cloud_issue.py +/chalicelib/core/integrations_manager.py +/chalicelib/core/issues.py +/chalicelib/core/jobs.py +/chalicelib/core/log_tool_bugsnag.py +/chalicelib/core/log_tool_cloudwatch.py +/chalicelib/core/log_tool_datadog.py +/chalicelib/core/log_tool_elasticsearch.py +/chalicelib/core/log_tool_newrelic.py +/chalicelib/core/log_tool_rollbar.py +/chalicelib/core/log_tool_sentry.py +/chalicelib/core/log_tool_stackdriver.py +/chalicelib/core/log_tool_sumologic.py +/chalicelib/core/sessions_assignments.py +/chalicelib/core/sessions_favorite_viewed.py +/chalicelib/core/sessions_metas.py +/chalicelib/core/sessions_mobs.py +/chalicelib/core/significance.py +/chalicelib/core/slack.py +/chalicelib/core/socket_ios.py +/chalicelib/core/sourcemaps.py +/chalicelib/core/sourcemaps_parser.py +/chalicelib/core/weekly_report.py +/chalicelib/saml +/chalicelib/utils/html/ +/chalicelib/utils/__init__.py +/chalicelib/utils/args_transformer.py +/chalicelib/utils/captcha.py +/chalicelib/utils/dev.py +/chalicelib/utils/email_handler.py +/chalicelib/utils/email_helper.py +/chalicelib/utils/event_filter_definition.py +/chalicelib/utils/github_client_v3.py +/chalicelib/utils/helper.py +/chalicelib/utils/jira_client.py +/chalicelib/utils/metrics_helper.py +/chalicelib/utils/pg_client.py +/chalicelib/utils/s3.py +/chalicelib/utils/smtp.py +/chalicelib/utils/strings.py +/chalicelib/utils/TimeUTC.py diff --git a/ee/api/app.py b/ee/api/app.py index 9a181e8e7..186da273c 100644 --- a/ee/api/app.py +++ b/ee/api/app.py @@ -11,7 +11,7 @@ from chalicelib.utils import helper from chalicelib.utils import pg_client from chalicelib.utils.helper import environ -from chalicelib.blueprints import bp_ee, bp_ee_crons +from chalicelib.blueprints import bp_ee, bp_ee_crons, bp_saml app = Chalice(app_name='parrot') app.debug = not helper.is_production() or helper.is_local() @@ -59,17 +59,11 @@ _overrides.chalice_app(app) @app.middleware('http') def or_middleware(event, get_response): - from chalicelib.ee import unlock + from chalicelib.core import unlock if not unlock.is_valid(): return Response(body={"errors": ["expired license"]}, status_code=403) if "{projectid}" in event.path.lower(): - from chalicelib.ee import projects - print("==================================") - print(event.context["authorizer"].get("authorizer_identity")) - print(event.uri_params["projectId"]) - print(projects.get_internal_project_id(event.uri_params["projectId"])) - print(event.context["authorizer"]["tenantId"]) - print("==================================") + from chalicelib.core import projects if event.context["authorizer"].get("authorizer_identity") == "api_key" \ and not projects.is_authorized( project_id=projects.get_internal_project_id(event.uri_params["projectId"]), @@ -126,3 +120,4 @@ app.register_blueprint(bp_dashboard.app) # Enterprise app.register_blueprint(bp_ee.app) app.register_blueprint(bp_ee_crons.app) +app.register_blueprint(bp_saml.app) diff --git a/ee/api/chalicelib/blueprints/bp_authorizers.py b/ee/api/chalicelib/blueprints/bp_authorizers.py index 9fcb8e475..14abd3988 100644 --- a/ee/api/chalicelib/blueprints/bp_authorizers.py +++ b/ee/api/chalicelib/blueprints/bp_authorizers.py @@ -2,7 +2,7 @@ from chalice import Blueprint, AuthResponse from chalicelib.utils import helper from chalicelib.core import authorizers -from chalicelib.ee import users +from chalicelib.core import users app = Blueprint(__name__) diff --git a/ee/api/chalicelib/blueprints/bp_core_dynamic.py b/ee/api/chalicelib/blueprints/bp_core_dynamic.py index b8bb7fc87..98a1b8b29 100644 --- a/ee/api/chalicelib/blueprints/bp_core_dynamic.py +++ b/ee/api/chalicelib/blueprints/bp_core_dynamic.py @@ -3,19 +3,19 @@ from chalice import Blueprint, Response from chalicelib import _overrides from chalicelib.core import metadata, errors_favorite_viewed, slack, alerts, sessions, integration_github, \ integrations_manager -from chalicelib.utils import captcha +from chalicelib.utils import captcha, SAML2_helper from chalicelib.utils import helper from chalicelib.utils.helper import environ -from chalicelib.ee import tenants -from chalicelib.ee import signup -from chalicelib.ee import users -from chalicelib.ee import projects -from chalicelib.ee import errors -from chalicelib.ee import notifications -from chalicelib.ee import boarding -from chalicelib.ee import webhook -from chalicelib.ee import license +from chalicelib.core import tenants +from chalicelib.core import signup +from chalicelib.core import users +from chalicelib.core import projects +from chalicelib.core import errors +from chalicelib.core import notifications +from chalicelib.core import boarding +from chalicelib.core import webhook +from chalicelib.core import license from chalicelib.core.collaboration_slack import Slack app = Blueprint(__name__) @@ -41,6 +41,8 @@ def login(): return { 'errors': ['You’ve entered invalid Email or Password.'] } + elif "errors" in r: + return r tenant_id = r.pop("tenantId") # change this in open-source @@ -74,7 +76,8 @@ def get_account(context): "metadata": metadata.get_remaining_metadata_with_count(context['tenantId']) }, **license.get_status(context["tenantId"]), - "smtp": environ["EMAIL_HOST"] is not None and len(environ["EMAIL_HOST"]) > 0 + "smtp": environ["EMAIL_HOST"] is not None and len(environ["EMAIL_HOST"]) > 0, + "saml2": SAML2_helper.is_saml2_available() } } @@ -350,6 +353,8 @@ def get_members(context): @app.route('/client/members', methods=['PUT', 'POST']) def add_member(context): + if SAML2_helper.is_saml2_available(): + return {"errors": ["please use your SSO server to add teammates"]} data = app.current_request.json_body return users.create_member(tenant_id=context['tenantId'], user_id=context['userId'], data=data) diff --git a/ee/api/chalicelib/blueprints/bp_core_dynamic_crons.py b/ee/api/chalicelib/blueprints/bp_core_dynamic_crons.py index 855af25df..b149c8807 100644 --- a/ee/api/chalicelib/blueprints/bp_core_dynamic_crons.py +++ b/ee/api/chalicelib/blueprints/bp_core_dynamic_crons.py @@ -4,8 +4,8 @@ from chalicelib.utils import helper app = Blueprint(__name__) _overrides.chalice_app(app) -from chalicelib.ee import telemetry -from chalicelib.ee import unlock +from chalicelib.core import telemetry +from chalicelib.core import unlock # Run every day. diff --git a/ee/api/chalicelib/blueprints/bp_ee.py b/ee/api/chalicelib/blueprints/bp_ee.py index 38ad071f4..a0fa0aa8c 100644 --- a/ee/api/chalicelib/blueprints/bp_ee.py +++ b/ee/api/chalicelib/blueprints/bp_ee.py @@ -1,7 +1,7 @@ from chalice import Blueprint from chalicelib import _overrides -from chalicelib.ee import unlock +from chalicelib.core import unlock app = Blueprint(__name__) _overrides.chalice_app(app) diff --git a/ee/api/chalicelib/blueprints/bp_saml.py b/ee/api/chalicelib/blueprints/bp_saml.py new file mode 100644 index 000000000..d5a964211 --- /dev/null +++ b/ee/api/chalicelib/blueprints/bp_saml.py @@ -0,0 +1,188 @@ +from chalice import Blueprint + +from chalicelib import _overrides +from chalicelib.utils.SAML2_helper import prepare_request, init_saml_auth + +app = Blueprint(__name__) +_overrides.chalice_app(app) + +from chalicelib.utils.helper import environ + +from onelogin.saml2.auth import OneLogin_Saml2_Logout_Request +from onelogin.saml2.utils import OneLogin_Saml2_Utils + +from chalice import Response +from chalicelib.core import users, tenants + + +@app.route("/saml2", methods=['GET'], authorizer=None) +def start_sso(): + app.current_request.path = '' + req = prepare_request(request=app.current_request) + auth = init_saml_auth(req) + sso_built_url = auth.login() + return Response( + # status_code=301, + status_code=307, + body='', + headers={'Location': sso_built_url, 'Content-Type': 'text/plain'}) + + +@app.route('/saml2/acs', methods=['POST'], content_types=['application/x-www-form-urlencoded'], authorizer=None) +def process_sso_assertion(): + req = prepare_request(request=app.current_request) + session = req["cookie"]["session"] + request = req['request'] + auth = init_saml_auth(req) + + request_id = None + if 'AuthNRequestID' in session: + request_id = session['AuthNRequestID'] + + auth.process_response(request_id=request_id) + errors = auth.get_errors() + user_data = {} + if len(errors) == 0: + if 'AuthNRequestID' in session: + del session['AuthNRequestID'] + user_data = auth.get_attributes() + # session['samlUserdata'] = user_data + # session['samlNameId'] = auth.get_nameid() + # session['samlNameIdFormat'] = auth.get_nameid_format() + # session['samlNameIdNameQualifier'] = auth.get_nameid_nq() + # session['samlNameIdSPNameQualifier'] = auth.get_nameid_spnq() + # session['samlSessionIndex'] = auth.get_session_index() + # session['samlSessionExpiration'] = auth.get_session_expiration() + # print('>>>>') + # print(session) + self_url = OneLogin_Saml2_Utils.get_self_url(req) + if 'RelayState' in request.form and self_url != request.form['RelayState']: + print("====>redirect") + return Response( + status_code=307, + body='', + headers={'Location': auth.redirect_to(request.form['RelayState']), 'Content-Type': 'text/plain'}) + elif auth.get_settings().is_debug_active(): + error_reason = auth.get_last_error_reason() + return {"errors": [error_reason]} + + email = auth.get_nameid() + existing = users.get_by_email_only(auth.get_nameid()) + + internal_id = next(iter(user_data.get("internalId", [])), None) + if len(existing) == 0 or existing[0].get("origin") != 'saml': + tenant_key = user_data.get("tenantKey", []) + if len(tenant_key) == 0: + print("tenantKey not present in assertion") + return Response( + status_code=307, + body={"errors": ["tenantKey not present in assertion"]}, + headers={'Location': auth.redirect_to(request.form['RelayState']), 'Content-Type': 'text/plain'}) + else: + t = tenants.get_by_tenant_key(tenant_key[0]) + if t is None: + return Response( + status_code=307, + body={"errors": ["Unknown tenantKey"]}, + headers={'Location': auth.redirect_to(request.form['RelayState']), 'Content-Type': 'text/plain'}) + if len(existing) == 0: + print("== new user ==") + users.create_sso_user(tenant_id=t['tenantId'], email=email, admin=True, origin='saml', + name=" ".join(user_data.get("firstName", []) + user_data.get("lastName", [])), + internal_id=internal_id) + else: + existing = existing[0] + if existing.get("origin") != 'saml': + print("== migrating user to SAML ==") + users.update(tenant_id=t['tenantId'], user_id=existing["id"], + changes={"origin": 'saml', "internal_id": internal_id}) + + return users.authenticate_sso(email=email, internal_id=internal_id, exp=auth.get_session_expiration()) + + +@app.route('/saml2/slo', methods=['GET']) +def process_slo_request(context): + req = prepare_request(request=app.current_request) + session = req["cookie"]["session"] + request = req['request'] + auth = init_saml_auth(req) + + name_id = session_index = name_id_format = name_id_nq = name_id_spnq = None + if 'samlNameId' in session: + name_id = session['samlNameId'] + if 'samlSessionIndex' in session: + session_index = session['samlSessionIndex'] + if 'samlNameIdFormat' in session: + name_id_format = session['samlNameIdFormat'] + if 'samlNameIdNameQualifier' in session: + name_id_nq = session['samlNameIdNameQualifier'] + if 'samlNameIdSPNameQualifier' in session: + name_id_spnq = session['samlNameIdSPNameQualifier'] + users.change_jwt_iat(context["userId"]) + return Response( + status_code=307, + body='', + headers={'Location': auth.logout(name_id=name_id, session_index=session_index, nq=name_id_nq, + name_id_format=name_id_format, + spnq=name_id_spnq), 'Content-Type': 'text/plain'}) + + +@app.route('/saml2/sls', methods=['GET'], authorizer=None) +def process_sls_assertion(): + req = prepare_request(request=app.current_request) + session = req["cookie"]["session"] + request = req['request'] + auth = init_saml_auth(req) + request_id = None + if 'LogoutRequestID' in session: + request_id = session['LogoutRequestID'] + + def dscb(): + session.clear() + + url = auth.process_slo(request_id=request_id, delete_session_cb=dscb) + + errors = auth.get_errors() + if len(errors) == 0: + if 'SAMLRequest' in req['get_data']: + logout_request = OneLogin_Saml2_Logout_Request(auth.get_settings(), req['get_data']['SAMLRequest']) + user_email = logout_request.get_nameid(auth.get_last_request_xml()) + to_logout = users.get_by_email_only(user_email) + + if len(to_logout) > 0: + to_logout = to_logout[0]['id'] + users.change_jwt_iat(to_logout) + else: + print("Unknown user SLS-Request By IdP") + else: + print("Preprocessed SLS-Request by SP") + + if url is not None: + return Response( + status_code=307, + body='', + headers={'Location': url, 'Content-Type': 'text/plain'}) + + return Response( + status_code=307, + body='', + headers={'Location': environ["SITE_URL"], 'Content-Type': 'text/plain'}) + + +@app.route('/saml2/metadata', methods=['GET'], authorizer=None) +def saml2_metadata(): + req = prepare_request(request=app.current_request) + auth = init_saml_auth(req) + settings = auth.get_settings() + metadata = settings.get_sp_metadata() + errors = settings.validate_metadata(metadata) + + if len(errors) == 0: + return Response( + status_code=200, + body=metadata, + headers={'Content-Type': 'text/xml'}) + else: + return Response( + status_code=500, + body=', '.join(errors)) diff --git a/ee/api/chalicelib/blueprints/subs/bp_dashboard.py b/ee/api/chalicelib/blueprints/subs/bp_dashboard.py index e14dd1b94..b868f7c64 100644 --- a/ee/api/chalicelib/blueprints/subs/bp_dashboard.py +++ b/ee/api/chalicelib/blueprints/subs/bp_dashboard.py @@ -2,7 +2,7 @@ from chalice import Blueprint from chalicelib.utils import helper from chalicelib import _overrides -from chalicelib.ee import dashboard +from chalicelib.core import dashboard from chalicelib.core import metadata diff --git a/ee/api/chalicelib/core/authorizers.py b/ee/api/chalicelib/core/authorizers.py index 8d6d69dfb..f7f50f52b 100644 --- a/ee/api/chalicelib/core/authorizers.py +++ b/ee/api/chalicelib/core/authorizers.py @@ -3,8 +3,8 @@ import jwt from chalicelib.utils import helper from chalicelib.utils.TimeUTC import TimeUTC -from chalicelib.ee import tenants -from chalicelib.ee import users +from chalicelib.core import tenants +from chalicelib.core import users def jwt_authorizer(token): @@ -38,12 +38,13 @@ def jwt_context(context): } -def generate_jwt(id, tenant_id, iat, aud): +def generate_jwt(id, tenant_id, iat, aud, exp=None): token = jwt.encode( payload={ "userId": id, "tenantId": tenant_id, - "exp": iat // 1000 + int(environ["jwt_exp_delta_seconds"]) + TimeUTC.get_utc_offset() // 1000, + "exp": iat // 1000 + int(environ["jwt_exp_delta_seconds"]) + TimeUTC.get_utc_offset() // 1000 \ + if exp is None else exp, "iss": environ["jwt_issuer"], "iat": iat // 1000, "aud": aud @@ -58,4 +59,4 @@ def api_key_authorizer(token): t = tenants.get_by_api_key(token) if t is not None: t["createdAt"] = TimeUTC.datetime_to_timestamp(t["createdAt"]) - return t \ No newline at end of file + return t diff --git a/ee/api/chalicelib/ee/boarding.py b/ee/api/chalicelib/core/boarding.py similarity index 98% rename from ee/api/chalicelib/ee/boarding.py rename to ee/api/chalicelib/core/boarding.py index 08917e4b6..6690e59f2 100644 --- a/ee/api/chalicelib/ee/boarding.py +++ b/ee/api/chalicelib/core/boarding.py @@ -1,8 +1,8 @@ from chalicelib.utils import pg_client from chalicelib.core import log_tool_datadog, log_tool_stackdriver, log_tool_sentry -from chalicelib.ee import users -from chalicelib.ee import projects +from chalicelib.core import users +from chalicelib.core import projects def get_state(tenant_id): diff --git a/ee/api/chalicelib/ee/dashboard.py b/ee/api/chalicelib/core/dashboard.py similarity index 99% rename from ee/api/chalicelib/ee/dashboard.py rename to ee/api/chalicelib/core/dashboard.py index 878b5119a..c5c373c78 100644 --- a/ee/api/chalicelib/ee/dashboard.py +++ b/ee/api/chalicelib/core/dashboard.py @@ -5,7 +5,7 @@ from chalicelib.utils import pg_client from chalicelib.utils import args_transformer from chalicelib.utils import helper from chalicelib.utils.TimeUTC import TimeUTC -from chalicelib.ee.utils import ch_client +from chalicelib.utils import ch_client from math import isnan from chalicelib.utils.metrics_helper import __get_step_size diff --git a/ee/api/chalicelib/ee/errors.py b/ee/api/chalicelib/core/errors.py similarity index 99% rename from ee/api/chalicelib/ee/errors.py rename to ee/api/chalicelib/core/errors.py index 1faa55a77..7c2a08447 100644 --- a/ee/api/chalicelib/ee/errors.py +++ b/ee/api/chalicelib/core/errors.py @@ -1,9 +1,9 @@ import json from chalicelib.utils import pg_client, helper -from chalicelib.ee.utils import ch_client +from chalicelib.utils import ch_client from chalicelib.core import sourcemaps, sessions -from chalicelib.ee import dashboard +from chalicelib.core import dashboard from chalicelib.utils.TimeUTC import TimeUTC diff --git a/ee/api/chalicelib/ee/license.py b/ee/api/chalicelib/core/license.py similarity index 95% rename from ee/api/chalicelib/ee/license.py rename to ee/api/chalicelib/core/license.py index d1e90b809..caf107dc7 100644 --- a/ee/api/chalicelib/ee/license.py +++ b/ee/api/chalicelib/core/license.py @@ -1,5 +1,5 @@ from chalicelib.utils import pg_client -from chalicelib.ee import unlock +from chalicelib.core import unlock def get_status(tenant_id): diff --git a/ee/api/chalicelib/core/metadata.py b/ee/api/chalicelib/core/metadata.py index 47729c30b..84bc39dec 100644 --- a/ee/api/chalicelib/core/metadata.py +++ b/ee/api/chalicelib/core/metadata.py @@ -1,6 +1,6 @@ from chalicelib.utils import pg_client, helper, dev -from chalicelib.ee import projects +from chalicelib.core import projects import re diff --git a/ee/api/chalicelib/ee/notifications.py b/ee/api/chalicelib/core/notifications.py similarity index 100% rename from ee/api/chalicelib/ee/notifications.py rename to ee/api/chalicelib/core/notifications.py diff --git a/ee/api/chalicelib/ee/projects.py b/ee/api/chalicelib/core/projects.py similarity index 99% rename from ee/api/chalicelib/ee/projects.py rename to ee/api/chalicelib/core/projects.py index b90350530..dc9cbfd23 100644 --- a/ee/api/chalicelib/ee/projects.py +++ b/ee/api/chalicelib/core/projects.py @@ -1,6 +1,6 @@ import json -from chalicelib.ee import users +from chalicelib.core import users from chalicelib.utils import pg_client, helper from chalicelib.utils.TimeUTC import TimeUTC diff --git a/ee/api/chalicelib/core/reset_password.py b/ee/api/chalicelib/core/reset_password.py index 144d22469..85588bf73 100644 --- a/ee/api/chalicelib/core/reset_password.py +++ b/ee/api/chalicelib/core/reset_password.py @@ -3,7 +3,7 @@ from chalicelib.utils import email_helper, captcha, helper import secrets from chalicelib.utils import pg_client -from chalicelib.ee import users +from chalicelib.core import users def step1(data): diff --git a/ee/api/chalicelib/ee/resources.py b/ee/api/chalicelib/core/resources.py similarity index 95% rename from ee/api/chalicelib/ee/resources.py rename to ee/api/chalicelib/core/resources.py index 575c604a5..557135804 100644 --- a/ee/api/chalicelib/ee/resources.py +++ b/ee/api/chalicelib/core/resources.py @@ -1,5 +1,5 @@ from chalicelib.utils import helper -from chalicelib.ee.utils import ch_client +from chalicelib.utils import ch_client from chalicelib.utils.TimeUTC import TimeUTC diff --git a/ee/api/chalicelib/core/sessions.py b/ee/api/chalicelib/core/sessions.py index aab67f31c..53d1d9383 100644 --- a/ee/api/chalicelib/core/sessions.py +++ b/ee/api/chalicelib/core/sessions.py @@ -2,9 +2,9 @@ from chalicelib.utils import pg_client, helper from chalicelib.utils import dev from chalicelib.core import events, sessions_metas, socket_ios, metadata, events_ios, sessions_mobs, issues -from chalicelib.ee import projects, errors +from chalicelib.core import projects, errors -from chalicelib.ee import resources +from chalicelib.core import resources SESSION_PROJECTION_COLS = """s.project_id, s.session_id::text AS session_id, diff --git a/ee/api/chalicelib/ee/signup.py b/ee/api/chalicelib/core/signup.py similarity index 96% rename from ee/api/chalicelib/ee/signup.py rename to ee/api/chalicelib/core/signup.py index 01fbee68a..7606c8b0a 100644 --- a/ee/api/chalicelib/ee/signup.py +++ b/ee/api/chalicelib/core/signup.py @@ -1,6 +1,6 @@ from chalicelib.utils import helper from chalicelib.utils import pg_client -from chalicelib.ee import users, telemetry +from chalicelib.core import users, telemetry from chalicelib.utils import captcha import json from chalicelib.utils.TimeUTC import TimeUTC @@ -57,7 +57,8 @@ def create_step1(data): signed_ups = get_signed_ups() if len(signed_ups) == 0 and data.get("tenantId") is not None \ - or len(signed_ups) > 0 and data.get("tenantId") not in [t['tenantId'] for t in signed_ups]: + or len(signed_ups) > 0 and data.get("tenantId") is not None\ + and data.get("tenantId") not in [t['tenantId'] for t in signed_ups]: errors.append("Tenant does not exist") if len(errors) > 0: print("==> error") @@ -156,4 +157,4 @@ def create_step1(data): "user": r, "client": c, } - } + } \ No newline at end of file diff --git a/ee/api/chalicelib/ee/telemetry.py b/ee/api/chalicelib/core/telemetry.py similarity index 99% rename from ee/api/chalicelib/ee/telemetry.py rename to ee/api/chalicelib/core/telemetry.py index a45ab789b..d9843e37d 100644 --- a/ee/api/chalicelib/ee/telemetry.py +++ b/ee/api/chalicelib/core/telemetry.py @@ -65,4 +65,4 @@ def new_client(tenant_id): FROM public.tenants WHERE tenant_id=%(tenant_id)s;""", {"tenant_id": tenant_id})) data = cur.fetchone() - requests.post('https://parrot.asayer.io/os/signup', json=process_data(data, edition='ee')) + requests.post('https://parrot.asayer.io/os/signup', json=process_data(data, edition='ee')) \ No newline at end of file diff --git a/ee/api/chalicelib/ee/tenants.py b/ee/api/chalicelib/core/tenants.py similarity index 80% rename from ee/api/chalicelib/ee/tenants.py rename to ee/api/chalicelib/core/tenants.py index 38db9e653..7855e2e81 100644 --- a/ee/api/chalicelib/ee/tenants.py +++ b/ee/api/chalicelib/core/tenants.py @@ -1,6 +1,26 @@ from chalicelib.utils import pg_client from chalicelib.utils import helper -from chalicelib.ee import users +from chalicelib.core import users + + +def get_by_tenant_key(tenant_key): + with pg_client.PostgresClient() as cur: + cur.execute( + cur.mogrify( + f"""SELECT + t.tenant_id, + t.name, + t.api_key, + t.created_at, + t.edition, + t.version_number, + t.opt_out + FROM public.tenants AS t + WHERE t.user_id = %(user_id)s AND t.deleted_at ISNULL + LIMIT 1;""", + {"user_id": tenant_key}) + ) + return helper.dict_to_camel_case(cur.fetchone()) def get_by_tenant_id(tenant_id): @@ -14,7 +34,8 @@ def get_by_tenant_id(tenant_id): t.created_at, t.edition, t.version_number, - t.opt_out + t.opt_out, + t.user_id AS tenant_key FROM public.tenants AS t WHERE t.tenant_id = %(tenantId)s AND t.deleted_at ISNULL LIMIT 1;""", diff --git a/ee/api/chalicelib/ee/unlock.py b/ee/api/chalicelib/core/unlock.py similarity index 100% rename from ee/api/chalicelib/ee/unlock.py rename to ee/api/chalicelib/core/unlock.py diff --git a/ee/api/chalicelib/ee/users.py b/ee/api/chalicelib/core/users.py similarity index 83% rename from ee/api/chalicelib/ee/users.py rename to ee/api/chalicelib/core/users.py index 13fe68e54..3331245a8 100644 --- a/ee/api/chalicelib/ee/users.py +++ b/ee/api/chalicelib/core/users.py @@ -1,15 +1,12 @@ import json -import time from chalicelib.core import authorizers - +from chalicelib.core import tenants from chalicelib.utils import helper from chalicelib.utils import pg_client from chalicelib.utils.TimeUTC import TimeUTC from chalicelib.utils.helper import environ -from chalicelib.ee import tenants - def create_new_member(tenant_id, email, password, admin, name, owner=False): with pg_client.PostgresClient() as cur: @@ -203,7 +200,8 @@ def get(user_id, tenant_id): (CASE WHEN role = 'admin' THEN TRUE ELSE FALSE END) AS admin, (CASE WHEN role = 'member' THEN TRUE ELSE FALSE END) AS member, appearance, - api_key + api_key, + origin FROM public.users LEFT JOIN public.basic_authentication ON users.user_id=basic_authentication.user_id WHERE users.user_id = %(userId)s @@ -274,7 +272,8 @@ def get_by_email_only(email): basic_authentication.generated_password, (CASE WHEN users.role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin, (CASE WHEN users.role = 'admin' THEN TRUE ELSE FALSE END) AS admin, - (CASE WHEN users.role = 'member' THEN TRUE ELSE FALSE END) AS member + (CASE WHEN users.role = 'member' THEN TRUE ELSE FALSE END) AS member, + origin FROM public.users LEFT JOIN public.basic_authentication ON users.user_id=basic_authentication.user_id WHERE users.email = %(email)s @@ -363,6 +362,8 @@ def change_password(tenant_id, user_id, email, old_password, new_password): item = get(tenant_id=tenant_id, user_id=user_id) if item is None: return {"errors": ["access denied"]} + if item["origin"] is not None: + return {"errors": ["cannot change your password because you are logged-in form an SSO service"]} if old_password == new_password: return {"errors": ["old and new password are the same"]} auth = authenticate(email, old_password, for_change_password=True) @@ -437,9 +438,19 @@ def auth_exists(user_id, tenant_id, jwt_iat, jwt_aud): ) +def change_jwt_iat(user_id): + with pg_client.PostgresClient() as cur: + query = cur.mogrify( + f"""UPDATE public.users + SET jwt_iat = timezone('utc'::text, now()) + WHERE user_id = %(user_id)s + RETURNING jwt_iat;""", + {"user_id": user_id}) + cur.execute(query) + return cur.fetchone().get("jwt_iat") + + def authenticate(email, password, for_change_password=False, for_plugin=False): - if helper.TRACK_TIME: - now = int(time.time() * 1000) with pg_client.PostgresClient() as cur: query = cur.mogrify( f"""SELECT @@ -451,7 +462,8 @@ def authenticate(email, password, for_change_password=False, for_plugin=False): (CASE WHEN users.role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin, (CASE WHEN users.role = 'admin' THEN TRUE ELSE FALSE END) AS admin, (CASE WHEN users.role = 'member' THEN TRUE ELSE FALSE END) AS member, - users.appearance + users.appearance, + users.origin FROM public.users AS users INNER JOIN public.basic_authentication USING(user_id) WHERE users.email = %(email)s AND basic_authentication.password = crypt(%(password)s, basic_authentication.password) @@ -461,13 +473,45 @@ def authenticate(email, password, for_change_password=False, for_plugin=False): cur.execute(query) r = cur.fetchone() - if helper.TRACK_TIME: - now2 = int(time.time() * 1000) - print(f"=====> authentication query&fetch in: {now2 - now} ms") - now = now2 + if r is not None: + if r["origin"] is not None: + return {"errors": ["must sign-in with SSO"]} + if for_change_password: + return True + r = helper.dict_to_camel_case(r, ignore_keys=["appearance"]) + jwt_iat = change_jwt_iat(r['id']) + return { + "jwt": authorizers.generate_jwt(r['id'], r['tenantId'], + TimeUTC.datetime_to_timestamp(jwt_iat), + aud=f"plugin:{helper.get_stage_name()}" if for_plugin else f"front:{helper.get_stage_name()}"), + "email": email, + **r + } + return None + + +def authenticate_sso(email, internal_id, exp=None): + with pg_client.PostgresClient() as cur: + query = cur.mogrify( + f"""SELECT + users.user_id AS id, + users.tenant_id, + users.role, + users.name, + False AS change_password, + (CASE WHEN users.role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin, + (CASE WHEN users.role = 'admin' THEN TRUE ELSE FALSE END) AS admin, + (CASE WHEN users.role = 'member' THEN TRUE ELSE FALSE END) AS member, + users.appearance, + origin + FROM public.users AS users + WHERE users.email = %(email)s AND internal_id = %(internal_id)s;""", + {"email": email, "internal_id": internal_id}) + + cur.execute(query) + r = cur.fetchone() + if r is not None: - if for_change_password: - return True r = helper.dict_to_camel_case(r, ignore_keys=["appearance"]) query = cur.mogrify( f"""UPDATE public.users @@ -479,8 +523,37 @@ def authenticate(email, password, for_change_password=False, for_plugin=False): return { "jwt": authorizers.generate_jwt(r['id'], r['tenantId'], TimeUTC.datetime_to_timestamp(cur.fetchone()["jwt_iat"]), - aud=f"plugin:{helper.get_stage_name()}" if for_plugin else f"front:{helper.get_stage_name()}"), + aud=f"front:{helper.get_stage_name()}", + exp=exp), "email": email, **r } return None + + +def create_sso_user(tenant_id, email, admin, name, origin, internal_id=None): + with pg_client.PostgresClient() as cur: + query = cur.mogrify(f"""\ + WITH u AS ( + INSERT INTO public.users (tenant_id, email, role, name, data, origin, internal_id) + VALUES (%(tenantId)s, %(email)s, %(role)s, %(name)s, %(data)s, %(origin)s, %(internal_id)s) + RETURNING * + ) + SELECT u.user_id AS id, + u.email, + u.role, + u.name, + TRUE AS change_password, + (CASE WHEN u.role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin, + (CASE WHEN u.role = 'admin' THEN TRUE ELSE FALSE END) AS admin, + (CASE WHEN u.role = 'member' THEN TRUE ELSE FALSE END) AS member, + u.appearance, + origin + FROM u;""", + {"tenantId": tenant_id, "email": email, "internal_id": internal_id, + "role": "admin" if admin else "member", "name": name, "origin": origin, + "data": json.dumps({"lastAnnouncementView": TimeUTC.now()})}) + cur.execute( + query + ) + return helper.dict_to_camel_case(cur.fetchone()) diff --git a/ee/api/chalicelib/ee/webhook.py b/ee/api/chalicelib/core/webhook.py similarity index 100% rename from ee/api/chalicelib/ee/webhook.py rename to ee/api/chalicelib/core/webhook.py diff --git a/ee/api/chalicelib/utils/SAML2_helper.py b/ee/api/chalicelib/utils/SAML2_helper.py new file mode 100644 index 000000000..af4612005 --- /dev/null +++ b/ee/api/chalicelib/utils/SAML2_helper.py @@ -0,0 +1,104 @@ +from http import cookies +from urllib.parse import urlparse, parse_qsl + +from onelogin.saml2.auth import OneLogin_Saml2_Auth + +from chalicelib.utils.helper import environ + +SAML2 = { + "strict": True, + "debug": True, + "sp": { + "entityId": environ["SITE_URL"] + "/api/saml2/metadata/", + "assertionConsumerService": { + "url": environ["SITE_URL"] + "/api/saml2/acs", + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" + }, + "singleLogoutService": { + "url": environ["SITE_URL"] + "/api/saml2/sls", + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" + }, + "NameIDFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + "x509cert": "", + "privateKey": "" + }, + "idp": None +} +idp = None +# SAML2 config handler +if len(environ.get("SAML2_MD_URL")) > 0: + print("SAML2_MD_URL provided, getting IdP metadata config") + from onelogin.saml2.idp_metadata_parser import OneLogin_Saml2_IdPMetadataParser + + idp_data = OneLogin_Saml2_IdPMetadataParser.parse_remote(environ.get("SAML2_MD_URL")) + idp = idp_data.get("idp") + +if SAML2["idp"] is None: + if len(environ.get("idp_entityId", "")) > 0 \ + and len(environ.get("idp_sso_url", "")) > 0 \ + and len(environ.get("idp_x509cert", "")) > 0: + idp = { + "entityId": environ["idp_entityId"], + "singleSignOnService": { + "url": environ["idp_sso_url"], + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" + }, + "x509cert": environ["idp_x509cert"] + } + if len(environ.get("idp_sls_url", "")) > 0: + idp["singleLogoutService"] = { + "url": environ["idp_sls_url"], + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" + } + +if idp is None: + print("No SAML2 IdP configuration found") +else: + SAML2["idp"] = idp + + +def init_saml_auth(req): + # auth = OneLogin_Saml2_Auth(req, custom_base_path=environ['SAML_PATH']) + + if idp is None: + raise Exception("No SAML2 config provided") + auth = OneLogin_Saml2_Auth(req, old_settings=SAML2) + + return auth + + +def prepare_request(request): + request.args = dict(request.query_params).copy() if request.query_params else {} + request.form = dict(request.json_body).copy() if request.json_body else dict( + parse_qsl(request.raw_body.decode())) if request.raw_body else {} + cookie_str = request.headers.get("cookie", "") + if "session" in cookie_str: + cookie = cookies.SimpleCookie() + cookie.load(cookie_str) + # Even though SimpleCookie is dictionary-like, it internally uses a Morsel object + # which is incompatible with requests. Manually construct a dictionary instead. + extracted_cookies = {} + for key, morsel in cookie.items(): + extracted_cookies[key] = morsel.value + session = extracted_cookies["session"] + else: + session = {} + # If server is behind proxys or balancers use the HTTP_X_FORWARDED fields + headers = request.headers + url_data = urlparse('%s://%s' % (headers.get('x-forwarded-proto', 'http'), headers['host'])) + return { + 'https': 'on' if request.headers.get('x-forwarded-proto', 'http') == 'https' else 'off', + 'http_host': request.headers['host'], + 'server_port': url_data.port, + 'script_name': request.path, + 'get_data': request.args.copy(), + # Uncomment if using ADFS as IdP, https://github.com/onelogin/python-saml/pull/144 + # 'lowercase_urlencoding': True, + 'post_data': request.form.copy(), + 'cookie': {"session": session}, + 'request': request + } + + +def is_saml2_available(): + return idp is not None diff --git a/ee/api/chalicelib/ee/utils/ch_client.py b/ee/api/chalicelib/utils/ch_client.py similarity index 100% rename from ee/api/chalicelib/ee/utils/ch_client.py rename to ee/api/chalicelib/utils/ch_client.py diff --git a/ee/api/requirements.txt b/ee/api/requirements.txt index 4fa698105..2a31fc27a 100644 --- a/ee/api/requirements.txt +++ b/ee/api/requirements.txt @@ -9,4 +9,5 @@ elasticsearch==7.9.1 jira==2.0.0 schedule==1.1.0 croniter==1.0.12 -clickhouse-driver==0.1.5 \ No newline at end of file +clickhouse-driver==0.1.5 +python3-saml==1.10.1 \ No newline at end of file diff --git a/ee/connectors/data_analysis_cookbook/buying_clients.ipynb b/ee/connectors/data_analysis_cookbook/buying_clients.ipynb new file mode 100644 index 000000000..350d3ea97 --- /dev/null +++ b/ee/connectors/data_analysis_cookbook/buying_clients.ipynb @@ -0,0 +1,1109 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "source": [ + "# Identifying bying clients\n", + "\n", + "In this notebook we will create a decision tree based model to identify clients who pay (buyers) and understand what makes a user a client (most relevant features).\n", + "\n", + "We divide our notebook into four stages: data preparation, feature engineering, model building, feature importance analysis" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%% md\n" + } + } + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": { + "collapsed": true + }, + "outputs": [], + "source": [ + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "import seaborn as sns\n", + "import psycopg2\n", + "from IPython.display import display\n", + "import yaml\n" + ] + }, + { + "cell_type": "markdown", + "source": [ + "## 1. Data preparation\n", + "\n", + "In this step we load from database (PostgreSQL in this example) data and keep it locally as a CSV file. '\n", + "The main reason for that is to be able to reproduce results quickly." + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%% md\n" + } + } + }, + { + "cell_type": "code", + "execution_count": 2, + "outputs": [], + "source": [ + "# Create a connection to the database\n", + "\n", + "# Load a config file with credentials\n", + "conf = yaml.load(\n", + " open(\"credentials.yml\"), Loader=yaml.FullLoader)['pg']\n", + "# Create a connection\n", + "conn = psycopg2.connect(\n", + " host=conf['host'],\n", + " port=conf['port'],\n", + " database=conf['database'],\n", + " user=conf['user'],\n", + " password=conf['password']\n", + ")" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "markdown", + "source": [ + "Here we prepare two functions to obtain data from the databases (or .csv files if they were pre-downloaded)" + ], + "metadata": { + "collapsed": false + } + }, + { + "cell_type": "code", + "execution_count": 3, + "outputs": [], + "source": [ + "def load_events(source='csv') -> pd.DataFrame:\n", + " \"\"\" Obtains session events from database or csv file\"\"\"\n", + " if source == 'db':\n", + " q = f'select * from connector_events where sessionid IN {sessions}'\n", + " all_events = pd.read_sql(q, conn)\n", + " all_events.to_csv('all_events_1454.csv', index=False)\n", + " elif source == 'csv':\n", + " all_events = pd.read_csv('all_events_1454_sep.csv', sep='|')\n", + " else:\n", + " raise ValueError(\"source parameter should be either 'csv' or 'db'\")\n", + " return all_events\n", + "\n", + "def load_sessions(source='csv') -> pd.DataFrame:\n", + " \"\"\" Obtains sessions information from database or csv file\"\"\"\n", + " if source == 'db':\n", + " q = f\"select * from connector_user_sessions where sessionid in {sessions}\"\n", + " all_sessions = pd.read_sql(q, conn)\n", + " # Saving as a CSV file is optional\n", + " all_sessions.to_csv(\"all_sessions.csv\", sep='|', index=False)\n", + " elif source == 'csv':\n", + " all_sessions = pd.read_csv(\"all_sessions.csv\", sep='|')\n", + " else:\n", + " raise ValueError(\"source parameter should be either 'csv' or 'db'\")\n", + " return all_sessions\n" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "markdown", + "source": [ + "In the connector's events table we found a client who had a button with a label \"PAY\" by performing a simple query\n", + "\n", + "`q = \"select session_id from connector_events where mouseclick_label = 'PAY\";`\n", + "\n", + "We went on to find all session ids of this client from our internal tables and saved it in `all_sessions_1454.csv`.\n", + "This step is unnecessary for clients and only explained for general clarity.\n", + "**The most important takeaway here, is that we have prepared a list of sessions for which we know\n", + "whether a click on \"PAY\" button has been made or not.**" + ], + "metadata": { + "collapsed": false + } + }, + { + "cell_type": "code", + "execution_count": 4, + "outputs": [], + "source": [ + "sessions_info = pd.read_csv(\"all_sessions_1454.csv\")\n", + "sessions = tuple(sessions_info['session_id'])" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "markdown", + "source": [ + "First off, let's see that the dataset is highly imbalanced, because the number of buyers is much less than the number of ordinary visitors.\n", + "In fact, the percentage of buying clients (0.04%) is so small, it's not event seen on the pie chart.\n", + "Hence we're going to use special techniques for imbalanced datasets." + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%% md\n" + } + } + }, + { + "cell_type": "code", + "execution_count": 5, + "outputs": [ + { + "data": { + "text/plain": "
", + "image/png": "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\n" + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "buyers_count = sessions_info[sessions_info.paid == 1].shape[0]\n", + "regular_count = sessions_info.shape[0] - buyers_count\n", + "ax = plt.subplot(111)\n", + "wedges, texts, _ = ax.pie(x=(buyers_count, regular_count),\n", + " shadow=False,\n", + " labels=['Paying visitors', 'Regular visitors'],\n", + " autopct='%1.2f%%',\n", + " explode=(0, 0.8))\n", + "\n", + "for w in wedges:\n", + " w.set_linewidth(1)\n", + " w.set_edgecolor('white')\n", + "\n", + "plt.show()\n" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "code", + "execution_count": 6, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "c:\\users\\david\\appdata\\local\\programs\\python\\python38\\lib\\site-packages\\IPython\\core\\interactiveshell.py:3361: DtypeWarning: Columns (9,11,12,13,26) have mixed types.Specify dtype option on import or set low_memory=False.\n", + " if (await self.run_code(code, result, async_=asy)):\n" + ] + } + ], + "source": [ + "all_events = load_events()" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "markdown", + "source": [ + "Let's take a look at the events dataset by printing one session" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%% md\n" + } + } + }, + { + "cell_type": "code", + "execution_count": 7, + "outputs": [ + { + "data": { + "text/plain": " sessionid connectioninformation_downlink \\\n0 4207534060820504 NaN \n2 4207534060820504 NaN \n3 4207534060820504 NaN \n4 4207534060820504 NaN \n5 4207534060820504 NaN \n6 4207534060820504 NaN \n7 4207534060820504 NaN \n\n connectioninformation_type consolelog_level consolelog_value \\\n0 NaN NaN NaN \n2 NaN NaN NaN \n3 NaN NaN NaN \n4 NaN NaN NaN \n5 NaN NaN NaN \n6 NaN NaN NaN \n7 NaN NaN NaN \n\n customevent_messageid customevent_name customevent_payload \\\n0 NaN NaN NaN \n2 NaN NaN NaN \n3 NaN NaN NaN \n4 NaN NaN NaN \n5 NaN NaN NaN \n6 NaN NaN NaN \n7 NaN NaN NaN \n\n customevent_timestamp errorevent_message ... issueevent_messageid \\\n0 NaN NaN ... 5.680858e+09 \n2 NaN NaN ... 5.680913e+09 \n3 NaN NaN ... NaN \n4 NaN NaN ... 5.680859e+09 \n5 NaN NaN ... NaN \n6 NaN NaN ... NaN \n7 NaN NaN ... 5.680898e+09 \n\n issueevent_timestamp issueevent_type \\\n0 1.614202e+12 click_rage \n2 1.614202e+12 click_rage \n3 NaN NaN \n4 1.614202e+12 click_rage \n5 NaN NaN \n6 NaN NaN \n7 1.614202e+12 cpu \n\n issueevent_contextstring issueevent_context \\\n0 SIGN OUT Triston Armstrong DEVELOPER Join GitS... NaN \n2 SAVE & NEXT NaN \n3 NaN NaN \n4 SIGN OUT Triston Armstrong DEVELOPER Join GitS... NaN \n5 NaN NaN \n6 NaN NaN \n7 https://app.gitstart.com/ NaN \n\n issueevent_payload customissue_name customissue_payload \\\n0 NaN NaN NaN \n2 NaN NaN NaN \n3 NaN NaN NaN \n4 NaN NaN NaN \n5 NaN NaN NaN \n6 NaN NaN NaN \n7 {\"Duration\":10581,\"Rate\":94} NaN NaN \n\n received_at batch_order_number \n0 1616761976450 2962 \n2 1616761976855 3003 \n3 1616761976460 2965 \n4 1616761976460 2966 \n5 1616761976464 2968 \n6 1616761976539 2972 \n7 1616761976661 2988 \n\n[7 rows x 49 columns]", + "text/html": "
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
sessionidconnectioninformation_downlinkconnectioninformation_typeconsolelog_levelconsolelog_valuecustomevent_messageidcustomevent_namecustomevent_payloadcustomevent_timestamperrorevent_message...issueevent_messageidissueevent_timestampissueevent_typeissueevent_contextstringissueevent_contextissueevent_payloadcustomissue_namecustomissue_payloadreceived_atbatch_order_number
04207534060820504NaNNaNNaNNaNNaNNaNNaNNaNNaN...5.680858e+091.614202e+12click_rageSIGN OUT Triston Armstrong DEVELOPER Join GitS...NaNNaNNaNNaN16167619764502962
24207534060820504NaNNaNNaNNaNNaNNaNNaNNaNNaN...5.680913e+091.614202e+12click_rageSAVE & NEXTNaNNaNNaNNaN16167619768553003
34207534060820504NaNNaNNaNNaNNaNNaNNaNNaNNaN...NaNNaNNaNNaNNaNNaNNaNNaN16167619764602965
44207534060820504NaNNaNNaNNaNNaNNaNNaNNaNNaN...5.680859e+091.614202e+12click_rageSIGN OUT Triston Armstrong DEVELOPER Join GitS...NaNNaNNaNNaN16167619764602966
54207534060820504NaNNaNNaNNaNNaNNaNNaNNaNNaN...NaNNaNNaNNaNNaNNaNNaNNaN16167619764642968
64207534060820504NaNNaNNaNNaNNaNNaNNaNNaNNaN...NaNNaNNaNNaNNaNNaNNaNNaN16167619765392972
74207534060820504NaNNaNNaNNaNNaNNaNNaNNaNNaN...5.680898e+091.614202e+12cpuhttps://app.gitstart.com/NaN{\"Duration\":10581,\"Rate\":94}NaNNaN16167619766612988
\n

7 rows × 49 columns

\n
" + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "all_events[all_events.sessionid == all_events.iloc[0].sessionid].head(10)" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "markdown", + "source": [ + "We mostly see NaNs and that's OK.\n", + "One row in session events files contains user's actions grouped by timestamp.\n", + "For example, if there was a mouse click event at a time $t$, only the columns corresponding to that click\n", + "(such as mouseclick_label, mouseclick_hesitationtime etc) will be filled and the rest will be NaNs.\n", + "The columns sessionid, received_at, batch_order_number will always be filled as the contain information about the\n", + "session unique identifier, the time at which the event was received by connectors worker and the order number\n", + "to ensure the chronological ordering is preserved when inserting in database in batches." + ], + "metadata": { + "collapsed": false + } + }, + { + "cell_type": "code", + "execution_count": 8, + "outputs": [ + { + "data": { + "text/plain": "['sessionid',\n 'connectioninformation_downlink',\n 'connectioninformation_type',\n 'consolelog_level',\n 'consolelog_value',\n 'customevent_messageid',\n 'customevent_name',\n 'customevent_payload',\n 'customevent_timestamp',\n 'errorevent_message',\n 'errorevent_messageid',\n 'errorevent_name',\n 'errorevent_payload',\n 'errorevent_source',\n 'errorevent_timestamp',\n 'jsexception_message',\n 'jsexception_name',\n 'jsexception_payload',\n 'metadata_key',\n 'metadata_value',\n 'mouseclick_id',\n 'mouseclick_hesitationtime',\n 'mouseclick_label',\n 'pageevent_firstcontentfulpaint',\n 'pageevent_firstpaint',\n 'pageevent_messageid',\n 'pageevent_referrer',\n 'pageevent_speedindex',\n 'pageevent_timestamp',\n 'pageevent_url',\n 'pagerendertiming_timetointeractive',\n 'pagerendertiming_visuallycomplete',\n 'rawcustomevent_name',\n 'rawcustomevent_payload',\n 'setviewportsize_height',\n 'setviewportsize_width',\n 'timestamp_timestamp',\n 'user_anonymous_id',\n 'user_id',\n 'issueevent_messageid',\n 'issueevent_timestamp',\n 'issueevent_type',\n 'issueevent_contextstring',\n 'issueevent_context',\n 'issueevent_payload',\n 'customissue_name',\n 'customissue_payload',\n 'received_at',\n 'batch_order_number']" + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Let's take a glance at all available features\n", + "list(all_events.columns)\n" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "markdown", + "source": [ + "## 2. Feature engineering\n", + "\n", + "How are we going to predict buying users?\n", + "What kind of features identify them?\n", + "\n", + "- Did the user visit the website earlier?\n", + "- Was the website not illustrating images?\n", + "- Did users experience many issues on the website?\n", + "- What pages did he visit?\n", + "\n", + "All of those questions sound important.\n", + "In these section we will extract the answers to this questions from the datasets of events and sessions for each user.\n", + "We'll create a vector of numerical features and assign them to each user who visited the site.\n", + "Our goal is to see if the feature sets or buyers and is separable by some nonlinear function with a good precision. We'll be looking for this function using decision trees model.\n", + "Of course one can experiment further by applying any other algorithm." + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%% md\n" + } + } + }, + { + "cell_type": "markdown", + "source": [ + "Unfortunately, none of the paying clients received ids.\n", + "Hence we'll only be looking into the session features." + ], + "metadata": { + "collapsed": false + } + }, + { + "cell_type": "code", + "execution_count": 9, + "outputs": [], + "source": [ + "# Create a DataFrame for session features\n", + "labels = sessions_info['paid']\n", + "session_features = sessions_info.drop(['paid'], axis=1)\n", + "session_features.rename({'session_id': 'sessionid'}, axis=1, inplace=True)" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "markdown", + "source": [ + "Let's get to some meaningful features. For example, let's create a function\n", + "that will tell if a specific event happened during the session." + ], + "metadata": { + "collapsed": false + } + }, + { + "cell_type": "code", + "execution_count": 10, + "outputs": [], + "source": [ + "def add_feature_about_event_presence(session_features, column, feature_name, dtype='int'):\n", + " temp_sessions = all_events[['sessionid', column]].dropna()\n", + " temp_sessions = temp_sessions.drop_duplicates(subset='sessionid', keep='last')\n", + " if temp_sessions.shape[0] == 0:\n", + " return session_features\n", + " session_features = session_features.merge(temp_sessions, how='left', on='sessionid')\n", + "\n", + " if dtype == 'int':\n", + " session_features.loc[session_features[column] > 0, feature_name] = 1\n", + " elif dtype == 'str':\n", + " session_features.loc[session_features[column] != '', feature_name] = 1\n", + " session_features[feature_name] = session_features[feature_name].fillna(0)\n", + " session_features = session_features.drop([column], axis=1)\n", + " return session_features" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "code", + "execution_count": 11, + "outputs": [], + "source": [ + "for column, feature_name in [('errorevent_messageid', 'error_event'),\n", + " ('customevent_messageid', 'custom_event'),\n", + " ('jsexception_message', 'js_exception'),\n", + " ('customissue_name', 'custom_issue')\n", + " ]:\n", + " session_features = add_feature_about_event_presence(session_features, column, feature_name)\n" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "markdown", + "source": [ + "At some point it's interesting to take a look\n", + "at the maximum values of some parameters during the session.\n", + "These features can be added with the function below:" + ], + "metadata": { + "collapsed": false + } + }, + { + "cell_type": "code", + "execution_count": 12, + "outputs": [], + "source": [ + "def add_max_val(session_features, column):\n", + " feature_df = all_events[['sessionid', column]].dropna()\n", + " feature_df_agg = feature_df.groupby('sessionid').agg('max').reset_index()\n", + " if feature_df_agg.shape[0] > 0:\n", + " session_features = session_features.merge(feature_df_agg, how='left', on='sessionid')\n", + " session_features[col] = session_features[col].fillna(0)\n", + " return session_features" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "code", + "execution_count": 13, + "outputs": [], + "source": [ + "max_feature_columns = ['pageevent_firstcontentfulpaint',\n", + " 'pageevent_firstpaint',\n", + " 'pageevent_speedindex',\n", + " 'pagerendertiming_timetointeractive',\n", + " 'pagerendertiming_visuallycomplete',\n", + " 'rawcustomevent_name',\n", + " 'rawcustomevent_payload',\n", + " 'setviewportsize_height',\n", + " 'setviewportsize_width']\n", + "\n", + "for col in max_feature_columns:\n", + " session_features = add_max_val(session_features, col)\n", + "\n" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "markdown", + "source": [ + "We also should handle categorical variables, the ones that can take on one of a limited,\n", + "and usually fixed, number of possible values (such us user's browser)." + ], + "metadata": { + "collapsed": false + } + }, + { + "cell_type": "code", + "execution_count": 14, + "outputs": [], + "source": [ + "def add_categorial_feature(session_features, column, feature_name):\n", + " categories = list(session_features[column].unique())\n", + " session_features[feature_name] = pd.Categorical(session_features[column], categories=categories).codes" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "code", + "execution_count": 15, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "c:\\users\\david\\appdata\\local\\programs\\python\\python38\\lib\\site-packages\\IPython\\core\\interactiveshell.py:3361: DtypeWarning: Columns (1,2,3,4,5,8,9,10,11) have mixed types.Specify dtype option on import or set low_memory=False.\n", + " if (await self.run_code(code, result, async_=asy)):\n" + ] + } + ], + "source": [ + "# Get additional information from sessions table\n", + "sessions_table = load_sessions()" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "code", + "execution_count": 16, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "54057\n" + ] + } + ], + "source": [ + "sessions_table = sessions_table.drop_duplicates(subset=['sessionid'], keep='last')\n", + "print(sessions_table.shape[0])" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "code", + "execution_count": 17, + "outputs": [], + "source": [ + "for iss in ['click_rage', 'missing_resource', 'dead_click', 'js_exception', 'bad_request', 'cpu', 'memory']:\n", + " session_features[iss] = session_features['issue_types'].apply(lambda x: 1 if iss in x else 0)\n", + "session_features = session_features.drop(['issue_types'], axis=1)" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "code", + "execution_count": 18, + "outputs": [ + { + "data": { + "text/plain": " sessionid events_count pages_count error_event \\\n0 4323603410944837 2 1 0.0 \n1 4323604627948361 11 1 0.0 \n2 4323603204776517 1 1 0.0 \n3 4323548146402182 37 7 0.0 \n4 4323554393301661 10 3 0.0 \n5 4323594072990251 17 5 0.0 \n6 4323655897228014 1 1 0.0 \n7 4323630712718240 7 1 0.0 \n8 4323592422793765 11 1 0.0 \n9 4323618781102971 7 2 0.0 \n\n pageevent_firstcontentfulpaint pageevent_firstpaint pageevent_speedindex \\\n0 0.0 0.0 0.0 \n1 0.0 0.0 0.0 \n2 0.0 0.0 0.0 \n3 3940.0 3850.0 3825.0 \n4 0.0 0.0 0.0 \n5 0.0 0.0 166.0 \n6 0.0 0.0 0.0 \n7 0.0 0.0 0.0 \n8 2312.0 2251.0 2312.0 \n9 0.0 0.0 0.0 \n\n pagerendertiming_timetointeractive pagerendertiming_visuallycomplete \\\n0 0.0 0.0 \n1 0.0 0.0 \n2 0.0 0.0 \n3 0.0 0.0 \n4 0.0 0.0 \n5 0.0 0.0 \n6 0.0 0.0 \n7 0.0 0.0 \n8 0.0 0.0 \n9 0.0 0.0 \n\n setviewportsize_height setviewportsize_width click_rage \\\n0 0.0 0.0 0 \n1 0.0 0.0 1 \n2 0.0 0.0 0 \n3 0.0 0.0 1 \n4 0.0 0.0 0 \n5 0.0 0.0 0 \n6 0.0 0.0 0 \n7 0.0 0.0 0 \n8 0.0 0.0 0 \n9 0.0 0.0 1 \n\n missing_resource dead_click js_exception bad_request cpu memory \n0 0 0 0 0 0 0 \n1 0 0 0 0 0 0 \n2 0 0 0 0 1 0 \n3 1 0 0 0 1 0 \n4 0 0 0 0 0 0 \n5 0 0 0 0 1 0 \n6 0 0 0 0 0 0 \n7 0 0 0 0 0 0 \n8 0 1 0 0 0 0 \n9 0 0 0 0 0 0 ", + "text/html": "
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
sessionidevents_countpages_counterror_eventpageevent_firstcontentfulpaintpageevent_firstpaintpageevent_speedindexpagerendertiming_timetointeractivepagerendertiming_visuallycompletesetviewportsize_heightsetviewportsize_widthclick_ragemissing_resourcedead_clickjs_exceptionbad_requestcpumemory
04323603410944837210.00.00.00.00.00.00.00.00000000
143236046279483611110.00.00.00.00.00.00.00.01000000
24323603204776517110.00.00.00.00.00.00.00.00000010
343235481464021823770.03940.03850.03825.00.00.00.00.01100010
443235543933016611030.00.00.00.00.00.00.00.00000000
543235940729902511750.00.00.0166.00.00.00.00.00000010
64323655897228014110.00.00.00.00.00.00.00.00000000
74323630712718240710.00.00.00.00.00.00.00.00000000
843235924227937651110.02312.02251.02312.00.00.00.00.00010000
94323618781102971720.00.00.00.00.00.00.00.01000000
\n
" + }, + "execution_count": 18, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "session_features.head(10)" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "code", + "execution_count": 19, + "outputs": [ + { + "data": { + "text/plain": "(57794, 18)" + }, + "execution_count": 19, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "session_features.shape" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "code", + "execution_count": 20, + "outputs": [], + "source": [ + "def add_one_hot_encoded_feature(origin_df, session_features, column):\n", + " df = origin_df[['sessionid', column]]\n", + " dummies = pd.get_dummies(df[column], prefix=column, dummy_na=True)\n", + " df = pd.concat([df, dummies], axis=1)\n", + " session_features = session_features.merge(df, how='left', on='sessionid')\n", + " session_features = session_features.drop([column], axis=1)\n", + " return session_features" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "code", + "execution_count": 21, + "outputs": [], + "source": [ + "for col in ['user_browser', 'user_country', 'user_device', 'connection_type']:\n", + " session_features = add_one_hot_encoded_feature(sessions_table, session_features, col)" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "code", + "execution_count": 22, + "outputs": [ + { + "data": { + "text/plain": "(57794, 34)" + }, + "execution_count": 22, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "session_features.shape" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "code", + "execution_count": 23, + "outputs": [], + "source": [ + "columns_to_merge = ['connection_effective_bandwidth', 'session_start_timestamp',\n", + " 'session_duration', 'user_device_heap_size',\n", + " 'user_device_memory_size', 'avg_cpu', 'avg_fps', 'max_cpu',\n", + " 'max_fps', 'max_total_js_heap_size', 'max_used_js_heap_size',\n", + " 'js_exceptions_count', 'long_tasks_total_duration', 'long_tasks_max_duration',\n", + " 'long_tasks_count', 'inputs_count', 'clicks_count', 'sessionid'\n", + " ]\n", + "session_features = session_features.merge(sessions_table[columns_to_merge],\n", + " how='left',\n", + " on='sessionid')" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "code", + "execution_count": 24, + "outputs": [ + { + "data": { + "text/plain": " sessionid events_count pages_count error_event \\\n0 4323603410944837 2 1 0.0 \n1 4323604627948361 11 1 0.0 \n2 4323603204776517 1 1 0.0 \n3 4323548146402182 37 7 0.0 \n4 4323554393301661 10 3 0.0 \n\n pageevent_firstcontentfulpaint pageevent_firstpaint pageevent_speedindex \\\n0 0.0 0.0 0.0 \n1 0.0 0.0 0.0 \n2 0.0 0.0 0.0 \n3 3940.0 3850.0 3825.0 \n4 0.0 0.0 0.0 \n\n pagerendertiming_timetointeractive pagerendertiming_visuallycomplete \\\n0 0.0 0.0 \n1 0.0 0.0 \n2 0.0 0.0 \n3 0.0 0.0 \n4 0.0 0.0 \n\n setviewportsize_height ... max_cpu max_fps max_total_js_heap_size \\\n0 0.0 ... 89.0 120.0 51399294.0 \n1 0.0 ... 0.0 0.0 0.0 \n2 0.0 ... 90.0 190.0 49323074.0 \n3 0.0 ... 68.0 60.0 140152925.0 \n4 0.0 ... 38.0 61.0 86162824.0 \n\n max_used_js_heap_size js_exceptions_count long_tasks_total_duration \\\n0 46629158.0 0.0 9161.0 \n1 0.0 0.0 0.0 \n2 47140794.0 0.0 407.0 \n3 121761837.0 0.0 0.0 \n4 82145777.0 0.0 0.0 \n\n long_tasks_max_duration long_tasks_count inputs_count clicks_count \n0 68.0 7.0 0.0 0.0 \n1 0.0 0.0 1.0 0.0 \n2 73.0 3.0 1.0 0.0 \n3 0.0 0.0 0.0 0.0 \n4 0.0 0.0 0.0 0.0 \n\n[5 rows x 51 columns]", + "text/html": "
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
sessionidevents_countpages_counterror_eventpageevent_firstcontentfulpaintpageevent_firstpaintpageevent_speedindexpagerendertiming_timetointeractivepagerendertiming_visuallycompletesetviewportsize_height...max_cpumax_fpsmax_total_js_heap_sizemax_used_js_heap_sizejs_exceptions_countlong_tasks_total_durationlong_tasks_max_durationlong_tasks_countinputs_countclicks_count
04323603410944837210.00.00.00.00.00.00.0...89.0120.051399294.046629158.00.09161.068.07.00.00.0
143236046279483611110.00.00.00.00.00.00.0...0.00.00.00.00.00.00.00.01.00.0
24323603204776517110.00.00.00.00.00.00.0...90.0190.049323074.047140794.00.0407.073.03.01.00.0
343235481464021823770.03940.03850.03825.00.00.00.0...68.060.0140152925.0121761837.00.00.00.00.00.00.0
443235543933016611030.00.00.00.00.00.00.0...38.061.086162824.082145777.00.00.00.00.00.00.0
\n

5 rows × 51 columns

\n
" + }, + "execution_count": 24, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "session_features = session_features.dropna(how='all', axis=0)\n", + "session_features = session_features.fillna(0)\n", + "session_features.head()\n" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "code", + "execution_count": 25, + "outputs": [ + { + "data": { + "text/plain": "(57794, 51)" + }, + "execution_count": 25, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "session_features.shape" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "code", + "execution_count": 25, + "outputs": [], + "source": [], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "markdown", + "source": [ + "## 3. Build model" + ], + "metadata": { + "collapsed": false + } + }, + { + "cell_type": "markdown", + "source": [ + "Decision tree model is chosen because it is known to work great with heterogenous datasets and correlated features" + ], + "metadata": { + "collapsed": false + } + }, + { + "cell_type": "code", + "execution_count": 26, + "outputs": [], + "source": [ + "import xgboost as xgb\n", + "import sklearn\n", + "from sklearn.model_selection import train_test_split" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "code", + "execution_count": 27, + "outputs": [], + "source": [ + "x_train, x_test, y_train, y_test = train_test_split(session_features.drop(['sessionid'], axis=1),\n", + " labels,\n", + " test_size=0.15,\n", + " random_state=42)\n" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "code", + "execution_count": 28, + "outputs": [ + { + "data": { + "text/plain": "(6, 17)" + }, + "execution_count": 28, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Ensure that test set has paying clients\n", + "sum(y_test), sum(y_train)" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "code", + "execution_count": 29, + "outputs": [ + { + "data": { + "text/plain": "0.0003460630241836984" + }, + "execution_count": 29, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Ratio of paying sessions will be denoted by EPSILON\n", + "EPSILON = y_train[y_train == 1].shape[0]/y_train.shape[0]\n", + "EPSILON" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "code", + "execution_count": 30, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "c:\\users\\david\\appdata\\local\\programs\\python\\python38\\lib\\site-packages\\xgboost\\sklearn.py:888: UserWarning: The use of label encoder in XGBClassifier is deprecated and will be removed in a future release. To remove this warning, do the following: 1) Pass option use_label_encoder=False when constructing XGBClassifier object; and 2) Encode your labels (y) as integers starting with 0, i.e. 0, 1, 2, ..., [num_class - 1].\n", + " warnings.warn(label_encoder_deprecation_msg, UserWarning)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[17:33:08] WARNING: C:/Users/Administrator/workspace/xgboost-win64_release_1.3.0/src/learner.cc:1061: Starting in XGBoost 1.3.0, the default evaluation metric used with the objective 'binary:logistic' was changed from 'error' to 'logloss'. Explicitly set eval_metric if you'd like to restore the old behavior.\n" + ] + }, + { + "data": { + "text/plain": "XGBClassifier(base_score=0.5, booster='gbtree', colsample_bylevel=1,\n colsample_bynode=1, colsample_bytree=1, gamma=0, gpu_id=-1,\n importance_type='gain', interaction_constraints='',\n learning_rate=0.300000012, max_delta_step=0, max_depth=6,\n min_child_weight=1, missing=nan, monotone_constraints='()',\n n_estimators=100, n_jobs=8, num_parallel_tree=1, random_state=0,\n reg_alpha=0, reg_lambda=1, scale_pos_weight=1, subsample=1,\n tree_method='exact', validate_parameters=1, verbosity=None)" + }, + "execution_count": 30, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "xgc0 = xgb.XGBClassifier()\n", + "xgc0.fit(x_train, y_train)" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "code", + "execution_count": 31, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[17:33:09] WARNING: C:/Users/Administrator/workspace/xgboost-win64_release_1.3.0/src/learner.cc:1061: Starting in XGBoost 1.3.0, the default evaluation metric used with the objective 'binary:logistic' was changed from 'error' to 'logloss'. Explicitly set eval_metric if you'd like to restore the old behavior.\n" + ] + }, + { + "data": { + "text/plain": "XGBClassifier(base_score=0.5, booster='gbtree', colsample_bylevel=1,\n colsample_bynode=1, colsample_bytree=1, gamma=0, gpu_id=-1,\n importance_type='gain', interaction_constraints='',\n learning_rate=0.300000012, max_delta_step=0, max_depth=6,\n min_child_weight=1, missing=nan, monotone_constraints='()',\n n_estimators=100, n_jobs=8, num_parallel_tree=1, random_state=0,\n reg_alpha=0, reg_lambda=1, scale_pos_weight=2889.6470588235293,\n subsample=1, tree_method='exact', validate_parameters=1,\n verbosity=None)" + }, + "execution_count": 31, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "xgc = xgb.XGBClassifier(scale_pos_weight=1/EPSILON)\n", + "xgc.fit(x_train, y_train)\n" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "markdown", + "source": [ + "## 4. Evaluate and choose the best model\n", + "\n", + "In this section we will built two models\n", + "and see at their performances using different metrics" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%% md\n" + } + } + }, + { + "cell_type": "code", + "execution_count": 32, + "outputs": [], + "source": [ + "from datetime import datetime\n", + "from xgboost import plot_importance\n", + "from sklearn.metrics import plot_roc_curve, recall_score, precision_score, accuracy_score, confusion_matrix\n", + "from sklearn.metrics import plot_confusion_matrix, precision_recall_curve, plot_precision_recall_curve\n" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "code", + "execution_count": 33, + "outputs": [], + "source": [ + "def report(models, x_test, y_test, y_pred=None, model_names=None):\n", + "\n", + " fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 14))\n", + "\n", + " for model, name in zip(models, model_names):\n", + "\n", + " y_out = model.predict(x_test)\n", + "\n", + " precision = precision_score(y_test, y_out)\n", + " recall = recall_score(y_test, y_out)\n", + " accuracy = accuracy_score(y_test, y_out)\n", + "\n", + " print(f'Model: {name}')\n", + " print(f'Precision: {precision}')\n", + " print(f'Recall: {recall}')\n", + " print(f'Accuracy: {accuracy}')\n", + " print('-------------------------')\n", + " print()\n", + "\n", + " roc_auc = plot_roc_curve(model, x_test, y_test, ax=ax1)\n", + " precision_recall = plot_precision_recall_curve(model, x_test, y_test, ax=ax2)\n", + "\n", + " date_time = datetime.now().strftime(\"%m_%d_%H_%M_%S\")\n", + " plt.savefig(f'report_{date_time}.png', dpi=300)\n", + " plt.show()\n" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "code", + "execution_count": 34, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Model: XGBClassifier\n", + "Precision: 0.8\n", + "Recall: 0.6666666666666666\n", + "Accuracy: 0.9996539792387543\n", + "-------------------------\n", + "\n", + "Model: Weighted XGBClassifier\n", + "Precision: 0.6666666666666666\n", + "Recall: 1.0\n", + "Accuracy: 0.9996539792387543\n", + "-------------------------\n", + "\n" + ] + }, + { + "data": { + "text/plain": "
", + "image/png": "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OPaYMWPYaqutGDJkCDvttBM/+9nPANhrr72YOnVqIfVPnTqVr3/96wAsXryYT3/60wwZMoQbb7yRL33pS9TX11d1/LFjxzbUDbBkyRJ69OjBaaedtly7Pn368M9//rNhecqUKRx44IENy3fccQd1dXUMGjSIHXbYgW9961tV1QXw3e9+l80335z1119/pe3+93//l/79+7PlllsyefLkhvV33nknW265Jf379+f8889vWH/UUUcxY8aMqusDw7UkSVrLbL755pxwwgkNYfG0007j+OOPp0+fPixatIgDDjiA448/nhdffJEnnniCH/3oR7z00ksN+48aNYpp06Yxffp01llnHW688UYArrzySu6++24ee+wxpk2bxr333ktmFl5/XV0dl156KQBPPvkkANOmTWPUqFH85Cc/YdCgQRUfa+nSpcstL1myhHHjxnH00Uc3rLv77rsZOHAgN998c8Xv55lnnuGrX/0qv/jFL6ivr2fq1Kn079+/4rpW5KCDDuKxxx5baZv6+npuuOEGpk+fzp133smJJ57I0qVLWbp0KSeddBJ33HEH9fX1TJgwoeEXkRNOOIELL7yw6vrA2UIkSVIHOvu26dT/bWGhxxz0se6cedDglbb5xje+wdChQxk7diwPPfQQl112GQDXX389w4YN4+CDD25ou80227DNNtt84BhLlizhrbfeYqONNgLgvPPOY8qUKXTv3h2A7t2784UvfOED+51wwgk8/vjjvPPOOxxxxBGcffbZQCnkT5w4kc6dO7PPPvswZswYbr75Zs4++2w6derEhhtuyIMPPsiUKVMYM2YM48aN45hjjmHu3LkMGTKEX//61xx33HGMGTOGuro67rrrLs4880wWL15Mv379uPbaa1l//fXp06cPo0aN4u677+bUU0/lqKOOaqjtvvvuY8cdd6Rz539FxAkTJnDyySdzxRVX8Mgjj7Dbbru1eA0uvPBCvvvd77LVVlsB0KlTJ0444YQW92vJrrvu2mKbW2+9laOOOop1112Xvn370r9//4ZA3r9/f7bYYgug1Ft96623MmjQID75yU9y7LHHsmTJkuXee2sYriVJ0lqnS5cuXHTRRYwcOZK77rqrYR7j6dOns+OOO6503xtvvJGHHnqIOXPmMHDgQA466CAWLlzIG2+80RDcVuYHP/gBG2+8MUuXLmXvvffm6aefpmfPntxyyy0899xzRASvv/46AOeccw6TJ0+mZ8+eDeuW2WyzzfjJT37CmDFjmDRp0nLb/vnPf3Luuedyzz33sN5663HBBRdw8cUXc8YZZwCwySab8Kc//ekDtT388MMMHTq0YXnRokXcc889XHXVVbz++utMmDChonD9zDPPVDQM5P777+cb3/jGB9Z/+MMf5g9/+EOL+zfn1VdfXS6E9+rVi1dffRUo/dWi8fo//vGPAHzoQx+if//+PPXUU8u9/9YwXEuSpA7TUg9zW7rjjjv46Ec/yjPPPMOIESOabXPooYcyY8YMBg4cyG9+8xugNCzksssuIzM56aSTuOiiizjxxBMrPu9NN93E1VdfzZIlS5gzZw719fUMGjSIrl27ctxxx3HggQc2jF3efffdOfbYYznyyCM57LDDKj7Ho48+Sn19PbvvvjsA7777LsOGDWvYPmrUqGb3mzNnDltvvXXD8qRJkxg+fDjdunXj8MMP5/vf/z5jx46lU6dOzc6ksaqzawwfPpxp06at0j5tZbPNNuNvf/tb1eHaMdeSJGmtM23aNO6++24effRRLrnkEubMmQPA4MGDl+vRveWWWxg/fnzDzY+NRQQHHXQQDz74IN27d2f99ddfbmx2c15++WXGjBnDvffey9NPP80BBxzAokWL6Ny5M4899hhHHHEEkyZNYuTIkUBpHPe5557LrFmzGDp0KPPmzavo/WUmI0aMYNq0aUybNo36+np++tOfNmxfb731mt2vW7duy83rPGHCBO655x769OnTcP777rsPKPV+z58/v6Hta6+9xqabbgqUPscnnniixTrvv/9+hgwZ8oGvSnrHV6Rnz57MmjWrYXn27Nn07NlzheuXWbRoEd26dWv1eZcxXEuSpLVKZnLCCScwduxYevfuzSmnnMK3v/1tAI4++mgefvhhJk6c2NC+8WwgTT300EP069cPgO985zucdNJJLFxYGkP+5ptvLjfrBsDChQtZb7312HDDDfn73//OHXfc0dB2wYIF7L///lxyySU89dRTALz44ovssssunHPOOfTo0WO5cLgyu+66Kw8//DAzZ84E4K233uKFF15ocb+tt966YZ+FCxfy+9//nr/+9a+88sorvPLKK1x++eVMmDABKM2A8vOf/xwo3Rj5i1/8guHDhwNwyimncN555zWc8/333+fKK6/8wPmW9Vw3/WrtkBCAgw8+mBtuuIHFixfz8ssvM2PGDHbeeWd22mknZsyYwcsvv8y7777LDTfcsNzY+hdeeKHZsfWrynAtSZLWKtdccw29e/duGApy4okn8uyzz/LAAw/QrVs3Jk2axJVXXskWW2zBsGHDOPfcc/ne977XsP+NN97IkCFD2G677XjyySc5/fTTgdKNisOHD2ennXZim2224ZOf/CQf+tDyUWv77bdnhx12YKuttuLoo49uGLbxxhtvcOCBB7LddtvxiU98gosvvhgohdRtt92WbbbZht12243tt9++ovfYo0cPxo8fz+jRo9luu+0YNmwYzz33XIv77bfffg3TDt5yyy186lOfYt11123Yfsghh3DbbbexePFiTj/9dGbOnNnwnvr3788xxxwDwHbbbcfYsWMZPXo0W2+9Ndtss02LvfqVOPXUU+nVqxdvv/02vXr14qyzzgJg4sSJDePJBw8ezJFHHsmgQYMYOXIkl19+OZ06daJz585cdtll7Lvvvmy99dYceeSRDB5cGpb097//nW7duvHv//7vVdcYbTFFTEeoq6vLouaPXBXTz/sEAIP/56F2P7ckSbXo2WefXW5cr1Yvhx56KBdeeCEDBgzo6FLazSWXXEL37t057rjjPrCtuZ/XiHgiM+uaO5Y915IkSWpw/vnnN4xBX1t85CMfaXbaxNZwthBJkiQ12HLLLdlyyy07uox29cUvfrGwY9lzLUmSJBXEcC1JkiQVpEPCdUSMjIjnI2JmRJzWzPZ1I+LG8vY/RkSfDihTkiRJWiXtHq4johNwObAfMAgYHRGDmjQ7Dpifmf2BS4AL2rdKSZIkadV1RM/1zsDMzHwpM98FbgAOadLmEOC68utfAXvHqj5PU5IkqRmzZs2ib9++DU9dnD9/Pn379uWVV14BYMaMGRx44IH069ePoUOHMnz48Ia5n8ePH0+PHj0YMmQIgwcP5ogjjljuITNjxoxhq622YsiQIey0004ND5HZa6+9KGrK4KlTp/L1r38dgMWLF/PpT3+aIUOGcOONN/KlL32J+vr6qo4/duzY5R5+s2TJEnr06MFppy0/2KBPnz7885//bFieMmVKw2PbofR4+bq6OgYNGsQOO+zAt771rarqAvjud7/L5ptvzvrrr7/Sdv/7v/9L//792XLLLZk8eXLD+jvvvJMtt9yS/v37c/755zesP+qoo5gxY0bV9UHHhOueQOPHC80ur2u2TWYuARYAm7RLdZIkaY22+eabc8IJJzSExdNOO43jjz+ePn36sGjRIg444ACOP/54XnzxRZ544gl+9KMfLfcAlFGjRjFt2jSmT5/OOuusw4033giUHlV+991389hjjzFt2jTuvfde2uJ5InV1dVx66aUAPPnkk0Dpce6jRo3iJz/5CYMGNR0QsGJLly5dbnnJkiWMGzeOo48+umHd3XffzcCBA7n55psrfj/PPPMMX/3qV/nFL35BfX09U6dOpX///hXXtSIHHXQQjz322Erb1NfXc8MNNzB9+nTuvPNOTjzxRJYuXcrSpUs56aSTuOOOO6ivr2fChAkNv4iccMIJXHjhhVXXBzU+FV9EHA8cD9C7d+8OqeGNjzgJviRJrXbHafB/fy72mP++Lex3/kqbfOMb32Do0KGMHTuWhx56iMsuuwyA66+/nmHDhi33WOxtttmm2cdiL1myhLfeeouNNtoIgPPOO48pU6bQvXt3ALp3797s3MknnHACjz/+OO+88w5HHHEEZ599NlAK+RMnTqRz587ss88+jBkzhptvvpmzzz6bTp06seGGG/Lggw8yZcoUxowZw7hx4zjmmGOYO3cuQ4YM4de//jXHHXccY8aMoa6ujrvuuoszzzyTxYsX069fP6699lrWX399+vTpw6hRo7j77rs59dRTOeqooxpqu++++9hxxx3p3PlfEXHChAmcfPLJXHHFFTzyyCPstttuLV6CCy+8kO9+97tstdVWAHTq1IkTTjihxf1asuuuu7bY5tZbb+Woo45i3XXXpW/fvvTv378hkPfv358tttgCKPVW33rrrQwaNIhPfvKTHHvssSxZsmS5994aHRGuXwU2b7Tcq7yuuTazI6IzsCEwr+mBMvNq4GooPaGxTaptwa4nXtMRp5UkSVXo0qULF110ESNHjuSuu+6iS5cuAEyfPp0dd9xxpfveeOONPPTQQ8yZM4eBAwdy0EEHsXDhQt54442G4LYyP/jBD9h4441ZunQpe++9N08//TQ9e/bklltu4bnnniMieP311wE455xzmDx5Mj179mxYt8xmm23GT37yE8aMGcOkSZOW2/bPf/6Tc889l3vuuYf11luPCy64gIsvvrjhEeGbbLIJf/rTnz5Q28MPP8zQoUMblhctWsQ999zDVVddxeuvv86ECRMqCtfPPPNMRcNA7r//fr7xjW98YP2HP/xh/vCHP7S4f3NeffXV5UJ4r169ePXVUtTcfPPNl1v/xz/+EYAPfehD9O/fn6eeemq5998aHRGuHwcGRERfSiH6KODoJm0mAl8AHgGOAO7LNeU57ZIk6V9a6GFuS3fccQcf/ehHeeaZZxgxYkSzbQ499FBmzJjBwIED+c1vfgOUhoVcdtllZCYnnXQSF110ESeeeGLF573pppu4+uqrWbJkCXPmzKG+vp5BgwbRtWtXjjvuOA488MCGscu77747xx57LEceeSSHHXZYxed49NFHqa+vZ/fddwfg3XffZdiwYQ3bR40a1ex+c+bMWe5R35MmTWL48OF069aNww8/nO9///uMHTuWTp060dztcKt6i9zw4cOZNm3aKu3TVjbbbDP+9re/VR2u233MdXkM9VeBycCzwE2ZOT0izomIZX+D+SmwSUTMBL4JfGC6PkmSpNaaNm0ad999N48++iiXXHJJw+O+Bw8evFyP7i233ML48eMbbn5sLCI46KCDePDBB+nevTvrr7/+cmOzm/Pyyy8zZswY7r33Xp5++mkOOOAAFi1aROfOnXnsscc44ogjmDRpEiNHjgRK47jPPfdcZs2axdChQ5k37wN/yG9WZjJixAimTZvGtGnTqK+v56c//WnD9vXWW6/Z/bp168aiRYsalidMmMA999xDnz59Gs5/3333AaXe7/nz5ze0fe2119h0002B0uf4xBNPtFjn/fffz5AhQz7wVUnv+Ir07NmTWbP+dXvf7Nmz6dmz5wrXL7No0SK6devW6vMu0yHzXGfm7Zk5MDP7ZeYPyuvOyMyJ5deLMvOzmdk/M3fOzJX/pEqSJFUoMznhhBMYO3YsvXv35pRTTuHb3/42AEcffTQPP/wwEydObGjfeDaQph566CH69esHwHe+8x1OOukkFi5cCMCbb7653KwbAAsXLmS99dZjww035O9//zt33HFHQ9sFCxaw//77c8kll/DUU08B8OKLL7LLLrtwzjnn0KNHj+XC4crsuuuuPPzww8ycOROAt956ixdeeKHF/bbeeuuGfRYuXMjvf/97/vrXv/LKK6/wyiuvcPnllzNhwgSgNAPKz3/+c6B0Y+QvfvELhg8fDsApp5zCeeed13DO999/nyuvvPID51vWc930q7VDQgAOPvhg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qXz9618HYPHixXz6059myJAh3HjjjXzpS1+ivr66SzJ27NiGugGWLFlCjx49OO205Wdt3Guvvdhyyy3Zfvvt2X333Xn++eerOi/Addddx4ABAxgwYADXXXdds22mTZvGrrvuypAhQ6irq+Oxxx4DYMGCBRx00EFsv/32DB48mGuvvRaAuXPnMnLkyKprkyRJNSwz14ivoUOHZlP19fUfWNfeLrjggvzyl7+cmZnHH398nnfeeZmZ+c477+SAAQPy1ltvbWj75z//Oa+99trMzLz22mvzpJNOatg2evToHDduXGZmXnHFFbnPPvvkggULMjNzwYIFOX78+MzM3HPPPfPxxx8v/H088sgjuffee7d6/yVLliy3/N577+W2226b7733XsO622+/PXfbbbfcYost8v33329Y3/g9XXXVVXnQQQe1uo7MzHnz5mXfvn1z3rx5+dprr2Xfvn3ztdde+0C7ESNG5O23356Zmb/73e9yzz33zMzMH/zgB3nqqadmZuY//vGP3GijjXLx4sWZmXnsscfmQw891Ox5V4efR0lSK5zZvfQllQFTcwWZtC1nC1mtnH3bdOr/trDQYw76WHfOPGjwStt84xvfYOjQoYwdO5aHHnqIyy67DIDrr7+eYcOGcfDBBze03Wabbdhmm20+cIwlS5bw1ltvsdFGGwFw3nnnMWXKFLp37w5A9+7d+cIXvvCB/U444QQef/xx3nnnHY444gjOPvtsAE477TQmTpxI586d2WeffRgzZgw333wzZ599Np06dWLDDTfkwQcfZMqUKYwZM4Zx48ZxzDHHMHfuXIYMGcKvf/1rjjvuOMaMGUNdXR133XUXZ555JosXL6Zfv35ce+21rL/++vTp04dRo0Zx9913c+qpp3LUUUc11Hbfffex44470rnzv34EJ0yYwMknn8wVV1zBI488wm677faB97THHnswduzYlX7mLZk8eTIjRoxg4403BmDEiBHceeedjB49erl2EcHChaWfmQULFvCxj32sYf0bb7xBZvLmm2+y8cYbN7yPz3zmM1x//fXsvvvuVdUoSZJq01oTrjtKly5duOiiixg5ciR33XVXwzzH06dPZ8cdd1zpvjfeeCMPPfQQc+bMYeDAgRx00EEsXLiQN954gy222KLFc//gBz9g4403ZunSpey99948/fTT9OzZk1tuuYXnnnuOiOD1118H4JxzzmHy5Mn07NmzYd0ym222GT/5yU8YM2YMkyZNWm7bP//5T84991zuuece1ltvPS644AIuvvhizjjjDAA22WQT/vSnP32gtocffpihQ4c2LC9atIh77rmHq666itdff50JEyY0G65vu+02tt122w+sv+iii7j++us/sH6PPfbg0ksvXW7dq6++yuab/+te2169evHqq03vtS0NW9l333359re/zfvvv88f/vAHAL761a9y8MEH87GPfYw33niDG2+8kQ99qDTCqq6uju9973sfOJYkSVo7rDXhuqUe5rZ0xx138NGPfpRnnnmGESNGNNvm0EMPZcaMGQwcOJDf/OY3AIwaNYrLLruMzOSkk07ioosu4sQTT6z4vDfddBNXX301S5YsYc6cOdTX1zNo0CC6du3Kcccdx4EHHsiBBx4IwO67786xxx7LkUceyWGHHVbxOR599FHq6+sbemrfffddhg0b1rB91KhRze43Z84ctt5664blSZMmMXz4cLp168bhhx/O97//fcaOHUunTp0A+NznPke3bt3o06cPP/rRjz5wvFNOOYVTTjml4rorccUVV3DJJZdw+OGHc9NNN3Hcccdxzz33MHnyZIYMGcJ9993Hiy++yIgRI/jkJz9J9+7d2Wyzzfjb3/5WaB2SJKl2dPQ812u8adOmcffdd/Poo49yySWXMGfOHAAGDx68XI/uLbfcwvjx4xtufmwsIjjooIN48MEH6d69O+uvvz4vvfTSSs/78ssvM2bMGO69916efvppDjjgABYtWkTnzp157LHHOOKII5g0aVLDDXhXXnkl5557LrNmzWLo0KHMmzevoveXmYwYMYJp06Yxbdo06uvr+elPf9qwfb311mt2v27dui037/OECRO455576NOnT8P577vvvobt119/PdOmTeO3v/3tcr3Oy1x00UUMGTLkA1/LbshsrGfPnsya9a/nG82ePZuePXt+oN11113X8IvGZz/72YYbGq+99loOO+wwIoL+/fvTt29fnnvuOaDUA9+tW7eVfmaSJGnNZbhuQ5nJCSecwNixY+nduzennHIK3/72twE4+uijefjhh5k48V+zEzaeDaSphx56iH79Ss/R+c53vsNJJ53UMB74zTffXG7WDYCFCxey3nrrseGGG/L3v/+dO+64o6HtggUL2H///bnkkkt46qmnAHjxxRfZZZddOOecc+jRo8dy4XNldt11Vx5++GFmzpwJwFtvvcULL7zQ4n5bb711wz4LFy7k97//PX/961955ZVXeOWVV7j88suZMGFCRTVAqed6WcBv/NV0SAjAvvvuy1133cX8+fOZP38+d911F/vuu+8H2n3sYx/jgQceAEpjxAcMGACUngZ67733AvD3v/+d559/vmGYzgsvvNDsuHlJkrR2WGuGhXSEa665ht69ezcMBTnxxBO59tpreeCBB9hzzz2ZNGkS3/zmN/nv//5v/u3f/o0NNthgufG6y8Zcv//++/Tq1Yvx48cDpRsV33zzTXbaaSe6dOlCly5d+Na3vrXcubfffnt22GEHttpqKzbffPOGYRtvvPEGhxxyCIsWLSIzufjii4FSOJ0xYwaZyd57783222/fECxXpkePHowfP57Ro0ezePFiAM4991wGDhy40v32228/Pv/5zwOlXvtPfepTrLvuug3bDznkEE499dSGYxZp44035vTTT2ennXYC4Iwzzmi4ufFLX/oSX/nKV6irq+Oaa67h5JNPZsmSJXTt2pWrr74agNNPP51jjz2WbbfdlszkggsuYNNNNwXg/vvv54ADDii8ZkmSVBuiNJtI7aurq8um8zs/++yzy43r1erl0EMP5cILL2zoEV4T7LHHHtx6660NM7s05s+jJNWoszYsf1/QsXVotRERT2RmXXPbHBaiDnP++ec3jEFfE8ydO5dvfvObzQZrSZK0dnBYiDrMlltuyZZbbtnRZRSmR48efOYzn+noMiRJUgey51qSJEkqiOFakiRJKojhWpIkSSqI4VqSJEkqiOG6Dc2aNYu+ffs2PHVx/vz59O3bl1deeQWAGTNmcOCBB9KvXz+GDh3K8OHDefDBBwEYP348PXr0YMiQIQwePJgjjjhiuYfMjBkzhq222oohQ4aw0047NTxEZq+99qLplIStNXXq1IYnHC5evJhPf/rTDBkyhBtvvJEvfelL1NfXV3X8sWPHLvfwmyVLltCjRw9OO+205drttddebLnllmy//fbsvvvuPP/881WdF0pPXxwwYAADBgzguuuua7bNtGnT2HXXXRkyZAh1dXUNT2gEmDJlSsO12XPPPYHSo9/32GMPlixZUnV9kiSpRmXmGvE1dOjQbKq+vv4D69rbBRdckF/+8pczM/P444/P8847LzMz33nnnRwwYEDeeuutDW3//Oc/57XXXpuZmddee22edNJJDdtGjx6d48aNy8zMK664IvfZZ59csGBBZmYuWLAgx48fn5mZe+65Zz7++OOFv49HHnkk995771bvv2TJkuWW33vvvdx2223zvffea1h3++2352677ZZbbLFFvv/++w3rG7+nq666Kg866KBW15GZOW/evOzbt2/OmzcvX3vttezbt2++9tprH2g3YsSIvP322zMz83e/+13uueeemZk5f/783HrrrfMvf/lLZmb+/e9/b9jnrLPOyl/84hfNnnd1+HmUJLXCmd1LX1IZMDVXkEnXnqn47jgN/u/PxR7z37eF/c5faZNvfOMbDB06lLFjx/LQQw9x2WWXAXD99dczbNgwDj744Ia222yzTbOPzl6yZAlvvfVWw/zJ5513HlOmTKF79+4AdO/enS984Qsf2O+EE07g8ccf55133uGII47g7LPPBuC0005j4sSJdO7cmX322YcxY8Zw8803c/bZZ9OpUyc23HBDHnzwQaZMmcKYMWMYN24cxxxzDHPnzmXIkCH8+te/5rjjjmPMmDHU1dVx1113ceaZZ7J48WL69evHtddey/rrr0+fPn0YNWoUd999N6eeeipHHXVUQ2333XcfO+64I507/+tHcMKECZx88slcccUVPPLII+y2224feE977LEHY8eOXeln3pLJkyczYsSIhqcyjhgxgjvvvJPRo0cv1y4iGh4xv2DBAj72sY8B8Mtf/pLDDjuM3r17A7DZZps17POZz3yG73znO3zuc5+rqkZJ0urjz+vuAMC2HVyHasPaE647SJcuXbjooosYOXIkd911F126dAFg+vTp7Ljjjivdd9njz+fMmcPAgQM56KCDWLhwIW+88QZbbLFFi+f+wQ9+wMYbb8zSpUvZe++9efrpp+nZsye33HILzz33HBHB66+/DsA555zD5MmT6dmzZ8O6ZTbbbDN+8pOfMGbMGCZNmrTctn/+85+ce+653HPPPay33npccMEFXHzxxZxxxhkAbLLJJvzpT3/6QG0PP/wwQ4cObVhetGgR99xzD1dddRWvv/46EyZMaDZc33bbbWy77Qf/83bRRRdx/fXXf2D9HnvswaWXXrrculdffZXNN9+8YblXr168+uqrH9h37Nix7Lvvvnz729/m/fff5w9/+AMAL7zwAu+99x577bUXb7zxBieffDL/8R//AZR+QXr88cc/cCxJUu3a9jtTOroE1ZC1J1y30MPclu644w4++tGP8swzzzBixIhm2xx66KHMmDGDgQMH8pvf/AaAUaNGcdlll5GZnHTSSVx00UWceOKJFZ/3pptu4uqrr2bJkiXMmTOH+vp6Bg0aRNeuXTnuuOM48MADOfDAAwHYfffdOfbYYznyyCM57LDDKj7Ho48+Sn19PbvvvjtQGnc8bNiwhu2jRo1qdr85c+Ys9yjwSZMmMXz4cLp168bhhx/O97//fcaOHUunTp0A+NznPke3bt3o06cPP/rRjz5wvFNOOYVTTjml4rorccUVV3DJJZdw+OGHc9NNN3Hcccdxzz33sGTJEp544gnuvfde3nnnHYYNG8auu+7KwIED6dSpE+ussw5vvPEGG2ywQaH1SJKk1Z83NLaxadOmcffdd/Poo49yySWXNDzue/Dgwcv16N5yyy2MHz++4ebHxiKCgw46iAcffJDu3buz/vrr89JLL630vC+//DJjxozh3nvv5emnn+aAAw5g0aJFdO7cmccee4wjjjiCSZMmMXLkSACuvPJKzj33XGbNmsXQoUOZN29eRe8vMxkxYgTTpk1j2rRp1NfX89Of/rRh+3rrrdfsft26dWPRokUNyxMmTOCee+6hT58+Dee/7777GrZff/31TJs2jd/+9rfL9Tovc9FFFzFkyJAPfC27IbOxnj17MmvWrIbl2bNn07Nnzw+0u+666xp+0fjsZz/bcENjr1692HfffVlvvfXYdNNN2WOPPXjqqaca9lu8eDFdu3Zd4WcmSZLWXIbrNpSZnHDCCYwdO5bevXtzyimn8O1vfxuAo48+mocffpiJEyc2tG88G0hTDz30EP369QPgO9/5DieddFLDeOA333xzuVk3ABYuXMh6663HhhtuyN///nfuuOOOhrYLFixg//3355JLLmkIhS+++CK77LIL55xzDj169FgufK7MrrvuysMPP8zMmTMBeOutt3jhhRda3G/rrbdu2GfhwoX8/ve/569//SuvvPIKr7zyCpdffjkTJkyoqAYo9VwvC/iNv5oOCQHYd999ueuuu5g/fz7z58/nrrvuYt999/1Au4997GM88MADQGmM+IABAwA45JBDeOihh1iyZAlvv/02f/zjHxt64efNm8emm27aMPxHkiStXdaeYSEd4JprrqF3794NQ0FOPPFErr32Wh544AH23HNPJk2axDe/+U3++7//m3/7t39jgw024Hvf+17D/svGXL///vv06tWL8ePHA6UbFd9880122mknunTpQpcuXfjWt7613Lm33357dthhB7baais233zzhmEbb7zxBocccgiLFi0iM7n44ouBUjidMWMGmcnee+/N9ttv3xAsV6ZHjx6MHz+e0aNHs3jxYgDOPfdcBg4cuNL99ttvPz7/+c8DpV77T33qU6y77roN2w855BBOPfXUhmMWaeONN+b0009np512AuCMM85ouLnxS1/6El/5yleoq6vjmmuu4eSTT2bJkiV07dqVq6++Gij9YjBy5Ei22247PvShD/GlL32p4UbU+++/nwMOOKDwmiVJUm2I0mwita+uri6bzu/87LPPLjeuV6uXQw89lAsvvLChR3hNcNhhh3H++ec3+8uFP4+SJK0ZIuKJzKxrbpvDQtRhzj///IYx6GuCd999l8985jMt9tpLkqQ1l8NC1GG23HJLttxyy44uozDrrLNOw5R8kiRp7bTG91yvKcNeVNv8OZQkae2wRofrrl27Mm/ePIONOlRmMm/ePKfnkyRpLbBGDwvp1asXs2fPZu7cuR1ditZyXbt2pVevXh1dhiRJamNrdLju0qULffv27egyJEmStJZYo4eFSJIkSe3JcC1JkiQVxHAtSZIkFWSNeUJjRMwF/tJBp98U+GcHnVvtx+u85vMarx28zmsHr/PaoaOu88czs0dzG9aYcN2RImLqih6BqTWH13nN5zVeO3id1w5e57XD6nidHRYiSZIkFcRwLUmSJBXEcF2Mqzu6ALULr/Oaz2u8dvA6rx28zmuH1e46O+ZakiRJKog915IkSVJBDNerICJGRsTzETEzIk5rZvu6EXFjefsfI6JPB5SpKlRwjb8ZEfUR8XRE3BsRH++IOlWdlq5zo3aHR0RGxGp1J7oqU8l1jogjy/+mp0fEL9u7RlWvgv9u946I+yPiyfJ/u/fviDrVehExLiL+ERHPrGB7RMSl5Z+BpyNix/ausTHDdYUiohNwObAfMAgYHRGDmjQ7Dpifmf2BS4AL2rdKVaPCa/wkUJeZ2wG/Ai5s3ypVrQqvMxGxAXAy8Mf2rVBFqOQ6R8QA4DvA7pk5GPjv9q5T1anw3/P3gJsycwfgKODH7VulCjAeGLmS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\n" + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + } + ], + "source": [ + "report([xgc0, xgc], x_test, y_test, model_names=['XGBClassifier', 'Weighted XGBClassifier'])" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "markdown", + "source": [ + "For the best model let's see the features that influenced the decision the most:\n" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%% md\n" + } + } + }, + { + "cell_type": "code", + "execution_count": 35, + "outputs": [ + { + "data": { + "text/plain": "" + }, + "execution_count": 35, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "text/plain": "
", + "image/png": "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\n" + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + } + ], + "source": [ + "plot_importance(xgc0)" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "code", + "execution_count": 35, + "outputs": [], + "source": [ + "\n" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 2 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython2", + "version": "2.7.6" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file diff --git a/ee/connectors/data_analysis_cookbook/credentials.yml.example b/ee/connectors/data_analysis_cookbook/credentials.yml.example new file mode 100644 index 000000000..e9b7caedd --- /dev/null +++ b/ee/connectors/data_analysis_cookbook/credentials.yml.example @@ -0,0 +1,6 @@ +pg: + user: user + password: ****** + database: db_name + host: '127.0.0.1' + port: 8080 \ No newline at end of file diff --git a/ee/connectors/data_analysis_cookbook/explorational_analysis.ipynb b/ee/connectors/data_analysis_cookbook/explorational_analysis.ipynb new file mode 100644 index 000000000..6a118e458 --- /dev/null +++ b/ee/connectors/data_analysis_cookbook/explorational_analysis.ipynb @@ -0,0 +1,560 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": { + "collapsed": true, + "pycharm": { + "name": "#%%\n" + } + }, + "outputs": [], + "source": [ + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "import seaborn as sns\n", + "import psycopg2\n", + "from IPython.display import display\n", + "import yaml" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": { + "pycharm": { + "name": "#%%\n" + } + }, + "outputs": [], + "source": [ + "conf = yaml.load(\n", + " open(\"credentials.yml\"), Loader=yaml.FullLoader)['pg']\n", + "\n", + "# Create a connection to the database\n", + "conn = psycopg2.connect(\n", + " host=conf['host'],\n", + " port=conf['port'],\n", + " database=conf['database'],\n", + " user=conf['user'],\n", + " password=conf['password']\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "A simple query can be executed either with native psycopg's framework or instanvia pandas.\n", + "As an example let's get a total number of sessions in the database" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": { + "pycharm": { + "name": "#%%\n" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " n_sessions\n", + "0 8961277\n" + ] + } + ], + "source": [ + "q = \"select count(*) as n_sessions from connector_user_sessions\"\n", + "df = pd.read_sql(q, conn)\n", + "print(df)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Distributions\n", + "\n", + "One of the basic usages of the data would be to view the data distributions\n", + "and to learn the main statistical properties such as mean, median, variance etc\n", + "which eventually lead to understanding your users better.\n", + "Visualization examples in this document include:\n", + "- Session durations\n", + "- User locations\n", + "- Website load (seasonality)\n", + "- Histogram of issue counts\n", + "- Hesitation time distribution\n", + "- URL visits graph" + ] + }, + { + "cell_type": "markdown", + "source": [ + "##### Session duration" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%% md\n" + } + } + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": { + "pycharm": { + "name": "#%%\n" + } + }, + "outputs": [ + { + "data": { + "text/plain": "
", + "image/png": "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zwFG1G03vGqTpHVWVwfwLcBx1fxxwM117ROqqyF2A96o66qQpHVSp74Tbk1ExI+BMcCHI2I28G3g+8D1EXEC8BxwRLX5bcA44GngLeBvGlWXJDWLhgVwZh7dwap929k2gUmNqkWSmpF3wklSIQawJBViAEtSIQawJBViAEtSIQawJBViAEtSIQawJBViAEtSIQawpNUyatQopk6dWrqMdYIBLDWZp556ir59+3LMMcd0uE1mcvrpp7PJJpuwySabcPrpp1O7ox/uu+8+WlpalpsightvvHGt1Ddr1izGjBmzVvbV0xnAUpOZNGkSn/rUp1a6zSWXXMLPf/5zZsyYwcyZM5kyZQoXX3wxAHvvvTcLFy5sm2699VZaWlo44IAVvyFMpRnA6rZGjhzJueeey/bbb0///v054YQTmDt3LmPHjmXAgAF87nOf47XXXmvb/sEHH2SPPfZg4MCB7LDDDsv9GX355ZfzyU9+kgEDBvCxj32sLcwApk6dyvDhwznvvPPYdNNNGTp0KJdffnlD3tN1113HwIED2Xff941ZtZzJkydz2mmnMXz4cIYNG8Zpp53GFVdc0eG2hx9+OP379293/fHHH8/JJ5/M2LFjaWlpYc899+Tll1/m1FNPZdCgQXziE5/g97//fdv2I0eO5K677gLgrLPO4ogjjmDChAkMGDCAUaNGMW3atDV78z2QAaxu7cYbb+TOO+/kySefZMqUKYwdO5bvfe97zJs3j2XLlnHBBRcAMGfOHMaPH883v/lNXn31VX7wgx9w2GGH0fq1Vptuuim33norCxYs4PLLL+erX/0qDz/8cNvrvPzyy7zxxhvMmTOHyy67jEmTJi0X7vVOPvlkBg4c2O60/fbbd/heFixYwJlnnsn555+/yvc9a9Ysdthhh7blHXbYgVmzZr1vu0WLFnHDDTdw3HHHvW9dveuvv57vfOc7zJ8/nw022IDdd9+dnXfemfnz53P44Yfzta99rcPn3nLLLRx11FG8/vrrHHTQQZxyyimrrF81BrC6tS996UsMGTKEYcOGsffee/PpT3+anXbaib59+3LooYe2nbldffXVjBs3jnHjxrHeeuux3377MXr0aG677TYAxo8fz5ZbbklEsM8++7D//vtz3333tb1Onz59OPPMM+nTpw/jxo2jpaWFJ554ot2aLrzwQl5//fV2p5kzZ3b4Xr71rW9xwgknMHz48FW+74ULF7LRRhu1LW+00UYsXLiwrR+41U033cSHP/xh9tlnn5Xu79BDD2WXXXZpO259+/ZlwoQJ9OrViyOPPHK5M+AV7bXXXowbN45evXpx7LHHMmPGjFXWr5qGjQcsdYUhQ4a0zffr1+99ywsXLgTgueee46c//SlTpkxpW//uu+/y2c9+FoDbb7+ds88+myeffJJly5bx1ltvsd1227Vtu8kmm9C793u/LhtuuGHbvteG6dOnc9ddd6006Oq1tLSwYMGCtuUFCxa0fdhWb/LkyUyYMOF97Svq7HFsz0c+8pG2+Q033JC3336bJUuWLHe81D6PkHqEESNGcOyxx3LppZe+b90777zDYYcdxpVXXsnBBx9Mnz59OOSQQ953NtlZJ510EldffXW76zbffPN2uwqmTp3Ks88+y2abbQbUznCXLl3KY489tlxXSKtRo0YxY8YMdt11VwBmzJjBqFGjltvmhRdeYOrUqcv1Z6u52AWhHuGYY45hypQp3HHHHSxdupS3336bqVOnMnv2bBYvXsw777zD4MGD6d27N7fffju//OUv1/i1fvjDHy53FUL91F74AkycOJE//vGPTJ8+nenTp3PSSScxfvx47rjjjna3nzBhAueffz5z5szhxRdf5LzzzuP4449fbpurrrqKPfbYgy233HKN34saywBWjzBixAhuvvlmvve97zF48GBGjBjBueeey7JlyxgwYAAXXHABRxxxBIMGDeLaa6/loIMO6tL6NtxwQz7ykY+0TS0tLfTt25fBgwcD713b2+rEE0/kwAMPZLvttmPbbbdl/PjxnHjiicvt88orr1zlh28qK9b0z6xmMHr06FztS17GjGHqvffyw4sf6HCTM/vVvp7uf//pXwH4yYl7rPGfo5IEtNsJ7xmwJBViAEvtOOuss9puBX7++edpaWlh6dKlhavqfhw3YuUMYGkVNttsMxYuXEivXr1Kl9Lm1Vdf5dBDD6V///5svvnmXHvttR1uu7JxI6D2AeDHP/5x1ltvvQ7vpltTjhuxcgaw1A1NmjSJ9ddfn7lz53LNNdfwxS9+scMrLFY2bgTU7qK78MIL2XnnnbuqfFUMYHVba3MsiGeeeYZ99tmHAQMGsN9++zF//vy2dc8++ywRwZIlS4Dy40YsWrSIG2+8kXPOOYeWlhb22msvDjro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\n" + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + } + ], + "source": [ + "q = 'select session_duration from connector_user_sessions limit 10000'\n", + "durations = pd.read_sql(q, conn)\n", + "\n", + "# translate duration to seconds\n", + "durations['session_duration'] = durations['session_duration'] / (1000 * 60)\n", + "sns.displot(durations, x=\"session_duration\", bins=23)\n", + "x_mean =durations['session_duration'].mean()\n", + "x_median =durations['session_duration'].median()\n", + "\n", + "plt.axvline(x_mean, c='orange')\n", + "plt.axvline(x_median, c='red')\n", + "plt.text(50, 80, f\"mean = {x_mean:.2f} min\", size=12)\n", + "plt.text(50, 70, f\"median = {x_median:.2f} min\", size=12)\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "source": [ + "##### User locations" + ], + "metadata": { + "collapsed": false + } + }, + { + "cell_type": "code", + "execution_count": 5, + "outputs": [], + "source": [ + "q = 'select count(*) as n_users, user_country from connector_user_sessions group by user_country '\n", + "countries = pd.read_sql(q, conn)\n", + "countries = countries[countries['n_users'] > 900]" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "code", + "execution_count": 6, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " n_users user_country latitude longitude\n", + "0 1567 AU -27.0 133.0\n", + "1 31726 BR -10.0 -55.0\n", + "2 5181 CA 60.0 -95.0\n", + "3 1183 CH 47.0 8.0\n", + "4 1013 CO 4.0 -72.0\n" + ] + } + ], + "source": [ + "coordinates = pd.read_csv('coordinates_and_codes.csv')\n", + "countries_with_coords = pd.merge(countries, coordinates, left_on='user_country', right_on='alpha-2_code')\n", + "countries_with_coords.drop(['alpha-2_code'], axis=1, inplace=True)\n", + "print(countries_with_coords.head())" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "code", + "execution_count": 7, + "outputs": [ + { + "data": { + "text/plain": "", + "text/html": "
Make this Notebook Trusted to load map: File -> Trust Notebook
" + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "import folium\n", + "\n", + "m = folium.Map(location=[40.18130, 44.5089], zoom_start=1, prefer_canvas=True)\n", + "\n", + "def plotDot(point):\n", + " '''input: series that contains a numeric named latitude and a numeric named longitude\n", + " this function creates a CircleMarker and adds it to your this_map'''\n", + " folium.Marker(location=(point.latitude, point.longitude),\n", + " # radius=point.n_users,\n", + " color=\"#3186cc\",\n", + " popup=point.n_users,\n", + " fill=True,\n", + " fill_color=\"#3186cc\").add_to(m)\n", + "countries_with_coords.apply(plotDot, axis=1)\n", + "m.save('users_map.html')\n", + "\n", + "display(m)\n", + "\n" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "markdown", + "source": [ + "##### Website load" + ], + "metadata": { + "collapsed": false + } + }, + { + "cell_type": "code", + "execution_count": 8, + "outputs": [ + { + "data": { + "text/plain": "
", + "image/png": "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\n" + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + } + ], + "source": [ + "day = 86400 * 1000\n", + "\n", + "q = 'select session_start_timestamp from connector_user_sessions where session_start_timestamp is not null limit 10000'\n", + "starts = pd.read_sql(q, conn)\n", + "starts['session_start_timestamp'] = starts['session_start_timestamp'].apply(lambda x: (x % day)* 24 / day)\n", + "sns.displot(starts, x=\"session_start_timestamp\", kind=\"kde\", bw_adjust=.2, fill=True)\n", + "plt.xlim(0, 24)\n", + "plt.title(\"Website load distribution during the day\")\n", + "plt.show()" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "markdown", + "source": [ + "##### Issue counts" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%% md\n" + } + } + }, + { + "cell_type": "code", + "execution_count": 9, + "outputs": [ + { + "data": { + "text/plain": "
", + "image/png": "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\n" + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + } + ], + "source": [ + "q = 'select issues_count from connector_user_sessions limit 10000'\n", + "issues = pd.read_sql(q, conn)\n", + "issues = issues.fillna(0)\n", + "sns.histplot(issues[issues > 2])\n", + "plt.title(\"Distribution of the number of issues across sessions\")\n", + "plt.show()\n", + "\n" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "markdown", + "source": [ + "##### Hesitation time distribution" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%% md\n" + } + } + }, + { + "cell_type": "code", + "execution_count": 10, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "(43, 1)\n" + ] + } + ], + "source": [ + "q = \"select mouseclick_hesitationtime from connector_events where mouseclick_label = 'PAY' \" \\\n", + " \"and mouseclick_hesitationtime is not null limit 10000\"\n", + "\n", + "hesitation = pd.read_sql(q, conn)\n", + "print(hesitation.shape)\n" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "code", + "execution_count": 11, + "outputs": [ + { + "data": { + "text/plain": "
", + "image/png": "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\n" + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + } + ], + "source": [ + "sns.displot(hesitation, x=\"mouseclick_hesitationtime\", kind=\"kde\", bw_adjust=.2, fill=True)\n", + "plt.title(\"Hesitation time distribution for a click on the button PAY\")\n", + "plt.show()" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "##### Path illustration" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "outputs": [ + { + "data": { + "text/plain": " sessionid pageevent_url\n9036 4169875804784252 231\n134 4167791269614996 229\n7115 4169433581028977 136\n24174 4176629796934961 107\n18942 4172534204991174 104", + "text/html": "
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
sessionidpageevent_url
90364169875804784252231
1344167791269614996229
71154169433581028977136
241744176629796934961107
189424172534204991174104
\n
" + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Select some session events. For illustration purposes, we stick to a fixed number of rows,\n", + "# however it would be more accurate to load all events for each session\n", + "q = 'select pageevent_url, sessionid from connector_events ' \\\n", + " 'where pageevent_url is not null limit 100000'\n", + "urls = pd.read_sql(q, conn)\n", + "\n", + "# Calculate the number of UNIQUE urls per session\n", + "urls_count = urls.groupby('sessionid').agg('nunique').reset_index()\n", + "\n", + "# Select the session with the maximum number of UNIQUE urls\n", + "urls_count = urls_count.sort_values(by='pageevent_url', ascending=False)\n", + "urls_count.head()" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "code", + "execution_count": 16, + "outputs": [], + "source": [ + "# Get all url visits in correct order\n", + "# (to visualize the full path, not only unique values matter)\n", + "sess_id = urls_count.iloc[0].sessionid\n", + "sess_id = 4592792577630589\n", + "q = f'select pageevent_url from connector_events ' \\\n", + " f'where sessionid = {sess_id} ' \\\n", + " f'and pageevent_url is not null limit 1000'\n", + "\n", + "session_urls = pd.read_sql(q, conn)" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "code", + "execution_count": 17, + "outputs": [ + { + "data": { + "text/plain": " pageevent_url\n0 https://atlas.cradle.global/briefs/list\n1 https://atlas.cradle.global/briefs/view/6adf82...\n2 https://atlas.cradle.global/briefs/view/6adf82...\n3 https://atlas.cradle.global/briefs/list\n4 https://atlas.cradle.global/briefs/view/e2b0f0...", + "text/html": "
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
pageevent_url
0https://atlas.cradle.global/briefs/list
1https://atlas.cradle.global/briefs/view/6adf82...
2https://atlas.cradle.global/briefs/view/6adf82...
3https://atlas.cradle.global/briefs/list
4https://atlas.cradle.global/briefs/view/e2b0f0...
\n
" + }, + "execution_count": 17, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# We consider URLs with different query parameters to be the same\n", + "# by cutting off the part after question mark:\n", + "\n", + "session_urls['pageevent_url'] = session_urls['pageevent_url'].apply(lambda x: x.split('?')[0])\n", + "session_urls.head()" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "code", + "execution_count": 18, + "outputs": [ + { + "data": { + "text/plain": "
", + "image/png": "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\n" + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# For the \"largest\" session draw a graph of page visits\n", + "import networkx as nx\n", + "\n", + "G = nx.DiGraph(directed=True)\n", + "\n", + "for ind in range(session_urls.pageevent_url.shape[0] - 1):\n", + " G.add_edges_from([(session_urls.pageevent_url[ind], session_urls.pageevent_url[ind + 1])])\n", + "\n", + "options = {\n", + " 'node_color': 'blue',\n", + " 'node_size': 50,\n", + " 'width': 1,\n", + " 'alpha': 0.5,\n", + " 'arrowstyle': '-|>',\n", + " 'arrowsize': 12,\n", + "}\n", + "\n", + "nx.draw_networkx(G, arrows=True, with_labels=False, **options)\n" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "markdown", + "source": [ + "Analysis of such graphs may be useful in user profiling.\n", + "The graph of page visits can provide essential information to clusterize users by their behavior\n", + "even if they don't actions on the website.\n", + "\n", + "\n", + "\n" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%% md\n" + } + } + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.8.6" + } + }, + "nbformat": 4, + "nbformat_minor": 1 +} \ No newline at end of file diff --git a/ee/connectors/deploy/requirements_clickhouse.txt b/ee/connectors/deploy/requirements_clickhouse.txt index 1002963e3..15381bdad 100644 --- a/ee/connectors/deploy/requirements_clickhouse.txt +++ b/ee/connectors/deploy/requirements_clickhouse.txt @@ -9,6 +9,6 @@ pytz==2021.1 requests==2.25.1 SQLAlchemy==1.3.23 tzlocal==2.1 -urllib3==1.26.3 +urllib3==1.26.5 PyYAML==5.4.1 diff --git a/ee/connectors/deploy/requirements_pg.txt b/ee/connectors/deploy/requirements_pg.txt index 2f11bc087..12f72ec7d 100644 --- a/ee/connectors/deploy/requirements_pg.txt +++ b/ee/connectors/deploy/requirements_pg.txt @@ -8,5 +8,5 @@ pytz==2021.1 requests==2.25.1 SQLAlchemy==1.3.23 tzlocal==2.1 -urllib3==1.26.3 +urllib3==1.26.5 PyYAML==5.4.1 diff --git a/ee/connectors/deploy/requirements_redshift.txt b/ee/connectors/deploy/requirements_redshift.txt index 3f6a75d42..42080cd4a 100644 --- a/ee/connectors/deploy/requirements_redshift.txt +++ b/ee/connectors/deploy/requirements_redshift.txt @@ -9,7 +9,7 @@ pytz==2021.1 requests==2.25.1 SQLAlchemy==1.3.23 tzlocal==2.1 -urllib3==1.26.3 +urllib3==1.26.5 pandas-redshift PyYAML awswrangler diff --git a/ee/connectors/deploy/requirements_snowflake.txt b/ee/connectors/deploy/requirements_snowflake.txt index 816ba6cfa..cc81ec16c 100644 --- a/ee/connectors/deploy/requirements_snowflake.txt +++ b/ee/connectors/deploy/requirements_snowflake.txt @@ -13,7 +13,7 @@ botocore==1.18.18 certifi==2020.6.20 cffi==1.14.3 chardet==3.0.4 -cryptography==2.9.2 +cryptography==3.4.7 idna==2.10 isodate==0.6.0 jmespath==0.10.0 @@ -30,5 +30,5 @@ requests==2.23.0 requests-oauthlib==1.3.0 s3transfer==0.3.3 six==1.15.0 -urllib3==1.25.11 +urllib3==1.26.5 diff --git a/ee/scripts/helm/db/init_dbs/postgresql/1.2.0/1.2.0.sql b/ee/scripts/helm/db/init_dbs/postgresql/1.2.0/1.2.0.sql new file mode 100644 index 000000000..08e5c5293 --- /dev/null +++ b/ee/scripts/helm/db/init_dbs/postgresql/1.2.0/1.2.0.sql @@ -0,0 +1,6 @@ +BEGIN; +CREATE TYPE user_origin AS ENUM ('saml'); +ALTER TABLE public.users + ADD COLUMN origin user_origin NULL DEFAULT NULL, + ADD COLUMN internal_id text NULL DEFAULT NULL; +COMMIT; \ No newline at end of file diff --git a/ee/scripts/helm/db/init_dbs/postgresql/init_schema.sql b/ee/scripts/helm/db/init_dbs/postgresql/init_schema.sql index fe36c6012..323774ed1 100644 --- a/ee/scripts/helm/db/init_dbs/postgresql/init_schema.sql +++ b/ee/scripts/helm/db/init_dbs/postgresql/init_schema.sql @@ -46,6 +46,7 @@ CREATE TABLE tenants ); CREATE TYPE user_role AS ENUM ('owner', 'admin', 'member'); +CREATE TYPE user_origin AS ENUM ('saml'); CREATE TABLE users ( user_id integer generated BY DEFAULT AS IDENTITY PRIMARY KEY, @@ -119,7 +120,9 @@ CREATE TABLE users api_key text UNIQUE default generate_api_key(20) not null, jwt_iat timestamp without time zone NULL DEFAULT NULL, data jsonb NOT NULL DEFAULT '{}'::jsonb, - weekly_report boolean NOT NULL DEFAULT TRUE + weekly_report boolean NOT NULL DEFAULT TRUE, + origin user_origin NULL DEFAULT NULL, + ); diff --git a/frontend/.storybook/webpack.config.js b/frontend/.storybook/webpack.config.js index afd395809..1a123fa52 100644 --- a/frontend/.storybook/webpack.config.js +++ b/frontend/.storybook/webpack.config.js @@ -3,7 +3,8 @@ const mainConfig = require('../webpack.config.js'); module.exports = async ({ config }) => { var conf = mainConfig(); - config.resolve.alias = Object.assign(pathAlias, config.resolve.alias); // Path Alias + config.resolve.alias = Object.assign(conf.resolve.alias, config.resolve.alias); // Path Alias + config.resolve.extensions = conf.resolve.extensions config.module.rules = conf.module.rules; config.module.rules[0].use[0] = 'style-loader'; // instead of separated css config.module.rules[1].use[0] = 'style-loader'; diff --git a/frontend/app/api_client.js b/frontend/app/api_client.js index bbba3a246..760123199 100644 --- a/frontend/app/api_client.js +++ b/frontend/app/api_client.js @@ -20,7 +20,8 @@ const siteIdRequiredPaths = [ '/rehydrations', '/sourcemaps', '/errors', - '/funnels' + '/funnels', + '/assist' ]; const noStoringFetchPathStarts = [ diff --git a/frontend/app/components/Assist/Assist.tsx b/frontend/app/components/Assist/Assist.tsx new file mode 100644 index 000000000..74f2095f8 --- /dev/null +++ b/frontend/app/components/Assist/Assist.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import ChatWindow from './ChatWindow'; + + +export default function Assist() { + return ( +
+ {/* */} +
+ ) +} diff --git a/frontend/app/components/Assist/ChatControls/ChatControls.css b/frontend/app/components/Assist/ChatControls/ChatControls.css new file mode 100644 index 000000000..7ec77f758 --- /dev/null +++ b/frontend/app/components/Assist/ChatControls/ChatControls.css @@ -0,0 +1,29 @@ +.controls { + height: 38px; + /* margin-top: 5px; */ + /* background-color: white; */ + /* border-top: solid thin #CCC; */ +} + +.btnWrapper { + display: flex; + align-items: center; + height: 24px; + font-size: 12px; + color: $gray-medium; + + &.disabled { + /* background-color: red; */ + & svg { + fill: red; + } + } +} + +.endButton { + background-color: $red; + border-radius: 3px; + padding: 2px 8px; + color: white; + font-size: 12px; +} \ No newline at end of file diff --git a/frontend/app/components/Assist/ChatControls/ChatControls.tsx b/frontend/app/components/Assist/ChatControls/ChatControls.tsx new file mode 100644 index 000000000..6ca747455 --- /dev/null +++ b/frontend/app/components/Assist/ChatControls/ChatControls.tsx @@ -0,0 +1,54 @@ +import React, { useState } from 'react' +import stl from './ChatControls.css' +import cn from 'classnames' +import { Button, Icon } from 'UI' + +interface Props { + stream: MediaStream | null, + endCall: () => void +} +function ChatControls({ stream, endCall } : Props) { + const [audioEnabled, setAudioEnabled] = useState(true) + const [videoEnabled, setVideoEnabled] = useState(true) + + const toggleAudio = () => { + if (!stream) { return; } + const aEn = !audioEnabled + stream.getAudioTracks().forEach(track => track.enabled = aEn); + setAudioEnabled(aEn); + } + + const toggleVideo = () => { + if (!stream) { return; } + const vEn = !videoEnabled; + stream.getVideoTracks().forEach(track => track.enabled = vEn); + setVideoEnabled(vEn) + } + + return ( +
+
+
+ +
+ +
+ +
+
+
+ +
+
+ ) +} + +export default ChatControls diff --git a/frontend/app/components/Assist/ChatControls/index.js b/frontend/app/components/Assist/ChatControls/index.js new file mode 100644 index 000000000..0b52c7325 --- /dev/null +++ b/frontend/app/components/Assist/ChatControls/index.js @@ -0,0 +1 @@ +export { default } from './ChatControls' \ No newline at end of file diff --git a/frontend/app/components/Assist/ChatWindow/ChatWindow.tsx b/frontend/app/components/Assist/ChatWindow/ChatWindow.tsx new file mode 100644 index 000000000..e1e5ba1a6 --- /dev/null +++ b/frontend/app/components/Assist/ChatWindow/ChatWindow.tsx @@ -0,0 +1,42 @@ +import React, { useState, FC } from 'react' +import VideoContainer from '../components/VideoContainer' +import { Icon, Popup, Button } from 'UI' +import cn from 'classnames' +import Counter from 'App/components/shared/SessionItem/Counter' +import stl from './chatWindow.css' +import ChatControls from '../ChatControls/ChatControls' +import Draggable from 'react-draggable'; + +export interface Props { + incomeStream: MediaStream | null, + localStream: MediaStream | null, + userId: String, + endCall: () => void +} + +const ChatWindow: FC = function ChatWindow({ userId, incomeStream, localStream, endCall }) { + const [minimize, setMinimize] = useState(false) + + return ( + +
+
+
Meeting {userId}
+ +
+
+ +
+ +
+
+ +
+
+ ) +} + +export default ChatWindow diff --git a/frontend/app/components/Assist/ChatWindow/chatWindow.css b/frontend/app/components/Assist/ChatWindow/chatWindow.css new file mode 100644 index 000000000..0f1f7694b --- /dev/null +++ b/frontend/app/components/Assist/ChatWindow/chatWindow.css @@ -0,0 +1,21 @@ +.wrapper { + background-color: white; + border: solid thin #000; + border-radius: 3px; + position: fixed; + width: 300px; +} + +.headerTitle { + font-size: 12px; + max-width: 180px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.videoWrapper { + height: 180px; + overflow: hidden; + background-color: #000; +} \ No newline at end of file diff --git a/frontend/app/components/Assist/ChatWindow/index.ts b/frontend/app/components/Assist/ChatWindow/index.ts new file mode 100644 index 000000000..fbbd1c4c5 --- /dev/null +++ b/frontend/app/components/Assist/ChatWindow/index.ts @@ -0,0 +1 @@ +export { default } from './ChatWindow' \ No newline at end of file diff --git a/frontend/app/components/Assist/ScreenSharing/ScreenSharing.tsx b/frontend/app/components/Assist/ScreenSharing/ScreenSharing.tsx new file mode 100644 index 000000000..35d67f91b --- /dev/null +++ b/frontend/app/components/Assist/ScreenSharing/ScreenSharing.tsx @@ -0,0 +1,45 @@ +import React from 'react' +import { Button } from 'UI' + +function ScreenSharing() { + const videoRef = React.createRef() + + function handleSuccess(stream) { + // startButton.disabled = true; + //videoRef.current?.srcObject = stream; + // @ts-ignore + window.stream = stream; // make variable available to browser console + + stream.getVideoTracks()[0].addEventListener('ended', () => { + console.log('The user has ended sharing the screen'); + }); + } + + function handleError(error) { + console.log(`getDisplayMedia error: ${error.name}`, error); + } + + const startScreenSharing = () => { + // @ts-ignore + navigator.mediaDevices.getDisplayMedia({video: true}) + .then(handleSuccess, handleError); + } + + const stopScreenSharing = () => { + // @ts-ignore + window.stream.stop() + console.log('Stop screen sharing') + } + + return ( +
+ +
+ + +
+
+ ) +} + +export default ScreenSharing diff --git a/frontend/app/components/Assist/ScreenSharing/index.js b/frontend/app/components/Assist/ScreenSharing/index.js new file mode 100644 index 000000000..f2bc82ab6 --- /dev/null +++ b/frontend/app/components/Assist/ScreenSharing/index.js @@ -0,0 +1 @@ +export { default } from './ScreenSharing' \ No newline at end of file diff --git a/frontend/app/components/Assist/assist.stories.js b/frontend/app/components/Assist/assist.stories.js new file mode 100644 index 000000000..6259d45bd --- /dev/null +++ b/frontend/app/components/Assist/assist.stories.js @@ -0,0 +1,8 @@ +import { storiesOf } from '@storybook/react'; +import ChatWindow from './ChatWindow'; + +storiesOf('Assist', module) + .add('ChatWindow', () => ( + + )) + diff --git a/frontend/app/components/Assist/components/AssistActions/AassistActions.css b/frontend/app/components/Assist/components/AssistActions/AassistActions.css new file mode 100644 index 000000000..85f5867c6 --- /dev/null +++ b/frontend/app/components/Assist/components/AssistActions/AassistActions.css @@ -0,0 +1,11 @@ +.inCall { + & svg { + fill: $red + } + color: $red; +} + +.disabled { + opacity: 0.5; + pointer-events: none; +} \ No newline at end of file diff --git a/frontend/app/components/Assist/components/AssistActions/AssistActions.tsx b/frontend/app/components/Assist/components/AssistActions/AssistActions.tsx new file mode 100644 index 000000000..8ac60e4ba --- /dev/null +++ b/frontend/app/components/Assist/components/AssistActions/AssistActions.tsx @@ -0,0 +1,103 @@ +import React, { useState, useEffect } from 'react' +import { Popup, Icon } from 'UI' +import { connect } from 'react-redux' +import cn from 'classnames' +import { toggleChatWindow } from 'Duck/sessions'; +import { connectPlayer } from 'Player/store'; +import ChatWindow from '../../ChatWindow'; +import { callPeer } from 'Player' +import { CallingState, ConnectionStatus } from 'Player/MessageDistributor/managers/AssistManager'; +import { toast } from 'react-toastify'; +import stl from './AassistActions.css' + +interface Props { + userId: String, + toggleChatWindow: (state) => void, + calling: CallingState, + peerConnectionStatus: ConnectionStatus +} + +function AssistActions({ toggleChatWindow, userId, calling, peerConnectionStatus }: Props) { + const [ incomeStream, setIncomeStream ] = useState(null); + const [ localStream, setLocalStream ] = useState(null); + const [ endCall, setEndCall ] = useState<()=>void>(()=>{}); + + useEffect(() => { + return endCall + }, []) + + useEffect(() => { + if (peerConnectionStatus == ConnectionStatus.Disconnected) { + toast.info(`Live session was closed.`); + } + }, [peerConnectionStatus]) + + function onClose(stream) { + stream.getTracks().forEach(t=>t.stop()); + } + + function onReject() { + toast.info(`Call was rejected.`); + } + + function onError() { + toast.error(`Something went wrong!`); + } + + function call() { + navigator.mediaDevices.getUserMedia({video:true, audio:true}) + .then(lStream => { + setLocalStream(lStream); + setEndCall(() => callPeer( + lStream, + setIncomeStream, + onClose.bind(null, lStream), + onReject, + onError + )); + }).catch(onError); + } + + const inCall = calling !== CallingState.False; + + return ( +
+ + + { inCall ? 'End Call' : 'Call' } +
+ } + content={ `Call ${userId}` } + size="tiny" + inverted + position="top right" + /> +
+ { inCall && } +
+ + ) +} + +const con = connect(null, { toggleChatWindow }) + +export default con(connectPlayer(state => ({ + calling: state.calling, + peerConnectionStatus: state.peerConnectionStatus, +}))(AssistActions)) diff --git a/frontend/app/components/Assist/components/AssistActions/index.ts b/frontend/app/components/Assist/components/AssistActions/index.ts new file mode 100644 index 000000000..3e5108198 --- /dev/null +++ b/frontend/app/components/Assist/components/AssistActions/index.ts @@ -0,0 +1 @@ +export { default } from './AssistActions' \ No newline at end of file diff --git a/ee/api/chalicelib/ee/__init__.py b/frontend/app/components/Assist/components/VideoContainer/VideoContainer.css similarity index 100% rename from ee/api/chalicelib/ee/__init__.py rename to frontend/app/components/Assist/components/VideoContainer/VideoContainer.css diff --git a/frontend/app/components/Assist/components/VideoContainer/VideoContainer.tsx b/frontend/app/components/Assist/components/VideoContainer/VideoContainer.tsx new file mode 100644 index 000000000..b0b600cac --- /dev/null +++ b/frontend/app/components/Assist/components/VideoContainer/VideoContainer.tsx @@ -0,0 +1,25 @@ +import React, { useEffect, useRef } from 'react' + +interface Props { + stream: MediaStream | null + muted?: boolean, + width?: number +} + +function VideoContainer({ stream, muted = false, width = 280 }: Props) { + const ref = useRef(null); + + useEffect(() => { + if (ref.current) { + ref.current.srcObject = stream; + } + }, [ ref.current, stream ]) + + return ( +
+
+ ) +} + +export default VideoContainer diff --git a/frontend/app/components/Assist/components/VideoContainer/index.ts b/frontend/app/components/Assist/components/VideoContainer/index.ts new file mode 100644 index 000000000..546964d7a --- /dev/null +++ b/frontend/app/components/Assist/components/VideoContainer/index.ts @@ -0,0 +1 @@ +export { default } from './VideoContainer' \ No newline at end of file diff --git a/frontend/app/components/Assist/index.ts b/frontend/app/components/Assist/index.ts new file mode 100644 index 000000000..1c61fa2d8 --- /dev/null +++ b/frontend/app/components/Assist/index.ts @@ -0,0 +1 @@ +export { default } from './Assist' \ No newline at end of file diff --git a/frontend/app/components/BugFinder/BugFinder.js b/frontend/app/components/BugFinder/BugFinder.js index b9b784ab6..3aa234b26 100644 --- a/frontend/app/components/BugFinder/BugFinder.js +++ b/frontend/app/components/BugFinder/BugFinder.js @@ -25,6 +25,7 @@ import { LAST_7_DAYS } from 'Types/app/period'; import { resetFunnel } from 'Duck/funnels'; import { resetFunnelFilters } from 'Duck/funnelFilters' import NoSessionsMessage from '../shared/NoSessionsMessage'; +import LiveSessionList from './LiveSessionList' const AUTOREFRESH_INTERVAL = 10 * 60 * 1000; @@ -134,7 +135,6 @@ export default class BugFinder extends React.PureComponent { setActiveTab = tab => { this.props.setActiveTab(tab); - } render() { @@ -157,12 +157,10 @@ export default class BugFinder extends React.PureComponent { className="mb-5" > - - {activeFlow && activeFlow.type === 'flows' ? - - : - - } + + { activeFlow && activeFlow.type === 'flows' && } + { activeTab.type !== 'live' && } + { activeTab.type === 'live' && } +
{ list[ j ] && this.renderFilterItem(type, list[ j ]) }
); @@ -136,6 +138,7 @@ export default class FilterModal extends React.PureComponent { loading = false, searchedEvents, searchQuery = '', + activeTab, } = this.props; const { query } = this.state; const reg = getRE(query, 'i'); @@ -158,6 +161,8 @@ export default class FilterModal extends React.PureComponent { const staticFilters = preloadedFilters .filter(({ value, actualValue }) => !this.props.loading && this.test(actualValue || value)) + // console.log('filteredList', filteredList); + return (!displayed ? null :
{ loading && @@ -173,22 +178,26 @@ export default class FilterModal extends React.PureComponent { { searchQuery && {this.renderEventDropdownPart(TYPES.USERID, 'User Id')} - {this.renderEventDropdownPart(TYPES.METADATA, 'Metadata')} - {this.renderEventDropdownPart(TYPES.CONSOLE, 'Errors')} - {this.renderEventDropdownPart(TYPES.CUSTOM, 'Custom Events')} - {this.renderEventDropdownPart(KEYS.USER_COUNTRY, 'Country', _appliedFilterKeys)} - {this.renderEventDropdownPart(KEYS.USER_BROWSER, 'Browser', _appliedFilterKeys)} - {this.renderEventDropdownPart(KEYS.USER_DEVICE, 'Device', _appliedFilterKeys)} - {this.renderEventDropdownPart(TYPES.LOCATION, 'Page')} - {this.renderEventDropdownPart(TYPES.CLICK, 'Click')} - {this.renderEventDropdownPart(TYPES.FETCH, 'Fetch')} - {this.renderEventDropdownPart(TYPES.INPUT, 'Input')} - - {this.renderEventDropdownPart(KEYS.USER_OS, 'Operating System', _appliedFilterKeys)} - {this.renderEventDropdownPart(KEYS.REFERRER, 'Referrer', _appliedFilterKeys)} - {this.renderEventDropdownPart(TYPES.GRAPHQL, 'GraphQL')} - {this.renderEventDropdownPart(TYPES.STATEACTION, 'Store Action')} - {this.renderEventDropdownPart(TYPES.REVID, 'Rev ID')} + {activeTab !== 'live' && ( + <> + {this.renderEventDropdownPart(TYPES.METADATA, 'Metadata')} + {this.renderEventDropdownPart(TYPES.CONSOLE, 'Errors')} + {this.renderEventDropdownPart(TYPES.CUSTOM, 'Custom Events')} + {this.renderEventDropdownPart(KEYS.USER_COUNTRY, 'Country', _appliedFilterKeys)} + {this.renderEventDropdownPart(KEYS.USER_BROWSER, 'Browser', _appliedFilterKeys)} + {this.renderEventDropdownPart(KEYS.USER_DEVICE, 'Device', _appliedFilterKeys)} + {this.renderEventDropdownPart(TYPES.LOCATION, 'Page')} + {this.renderEventDropdownPart(TYPES.CLICK, 'Click')} + {this.renderEventDropdownPart(TYPES.FETCH, 'Fetch')} + {this.renderEventDropdownPart(TYPES.INPUT, 'Input')} + + {this.renderEventDropdownPart(KEYS.USER_OS, 'Operating System', _appliedFilterKeys)} + {this.renderEventDropdownPart(KEYS.REFERRER, 'Referrer', _appliedFilterKeys)} + {this.renderEventDropdownPart(TYPES.GRAPHQL, 'GraphQL')} + {this.renderEventDropdownPart(TYPES.STATEACTION, 'Store Action')} + {this.renderEventDropdownPart(TYPES.REVID, 'Rev ID')} + + )} }
@@ -201,7 +210,7 @@ export default class FilterModal extends React.PureComponent {
{ this.renderList(category.type, category.keys) }
- + )) } diff --git a/frontend/app/components/BugFinder/CustomFilters/filterModal.css b/frontend/app/components/BugFinder/CustomFilters/filterModal.css index 526016764..5b84dd732 100644 --- a/frontend/app/components/BugFinder/CustomFilters/filterModal.css +++ b/frontend/app/components/BugFinder/CustomFilters/filterModal.css @@ -88,4 +88,9 @@ h5.title { & .filterGroup { width: 205px; } +} + +.disabled { + opacity: 0.5; + pointer-events: none; } \ No newline at end of file diff --git a/frontend/app/components/BugFinder/LiveSessionList/LiveSessionList.tsx b/frontend/app/components/BugFinder/LiveSessionList/LiveSessionList.tsx new file mode 100644 index 000000000..b82e80139 --- /dev/null +++ b/frontend/app/components/BugFinder/LiveSessionList/LiveSessionList.tsx @@ -0,0 +1,61 @@ +import React, { useEffect, useState } from 'react'; +import { fetchLiveList } from 'Duck/sessions'; +import { connect } from 'react-redux'; +import { NoContent, Loader } from 'UI'; +import { List, Map } from 'immutable'; +import SessionItem from 'Shared/SessionItem'; + +interface Props { + loading: Boolean, + list?: List, + fetchLiveList: () => void, + filters: List +} + +function LiveSessionList(props: Props) { + const { loading, list, filters } = props; + const [userId, setUserId] = useState(undefined) + + useEffect(() => { + props.fetchLiveList(); + }, []) + + useEffect(() => { + if (filters) { + const userIdFilter = filters.filter(i => i.key === 'USERID').first() + if (userIdFilter) + setUserId(userIdFilter.value[0]) + else + setUserId(undefined) + } + }, [filters]) + + + return ( +
+ + + {list && (userId ? list.filter(i => i.userId === userId) : list).map(session => ( + + ))} + + +
+ ) +} + +export default connect(state => ({ + list: state.getIn(['sessions', 'liveSessions']), + loading: state.getIn([ 'sessions', 'loading' ]), + filters: state.getIn([ 'filters', 'appliedFilter', 'filters' ]), +}), { fetchLiveList })(LiveSessionList) diff --git a/frontend/app/components/BugFinder/LiveSessionList/index.js b/frontend/app/components/BugFinder/LiveSessionList/index.js new file mode 100644 index 000000000..eb38fa3e7 --- /dev/null +++ b/frontend/app/components/BugFinder/LiveSessionList/index.js @@ -0,0 +1 @@ +export { default } from './LiveSessionList' \ No newline at end of file diff --git a/frontend/app/components/BugFinder/SessionList/SessionList.js b/frontend/app/components/BugFinder/SessionList/SessionList.js index 13ddf9baa..41375a6af 100644 --- a/frontend/app/components/BugFinder/SessionList/SessionList.js +++ b/frontend/app/components/BugFinder/SessionList/SessionList.js @@ -48,7 +48,7 @@ export default class SessionList extends React.PureComponent { } getNoContentMessage = activeTab => { - let str = "No Sessions Found"; + let str = "No recordings found"; if (activeTab.type !== 'all') { str += ' with ' + activeTab.name; return str; @@ -123,7 +123,7 @@ export default class SessionList extends React.PureComponent { const { activeTab, allList, total } = this.props; var filteredList; - if (activeTab.type !== ALL && activeTab.type !== 'bookmark') { // Watchdog sessions + if (activeTab.type !== ALL && activeTab.type !== 'bookmark' && activeTab.type !== 'live') { // Watchdog sessions filteredList = allList.filter(session => activeTab.fits(session)) } else { filteredList = allList diff --git a/frontend/app/components/BugFinder/SessionsMenu/SessionsMenu.js b/frontend/app/components/BugFinder/SessionsMenu/SessionsMenu.js index af5adf937..129862b29 100644 --- a/frontend/app/components/BugFinder/SessionsMenu/SessionsMenu.js +++ b/frontend/app/components/BugFinder/SessionsMenu/SessionsMenu.js @@ -72,6 +72,16 @@ function SessionsMenu(props) { /> ))} +
+
+ onMenuItemClick({ name: 'Assist', type: 'live' })} + /> +
+
onMenuItemClick({ name: 'Bookmarks', type: 'bookmark' })} /> -
+
+
diff --git a/frontend/app/components/Client/ProfileSettings/ProfileSettings.js b/frontend/app/components/Client/ProfileSettings/ProfileSettings.js index 1e6a31c59..c6fbda5ec 100644 --- a/frontend/app/components/Client/ProfileSettings/ProfileSettings.js +++ b/frontend/app/components/Client/ProfileSettings/ProfileSettings.js @@ -3,12 +3,18 @@ import Settings from './Settings'; import ChangePassword from './ChangePassword'; import styles from './profileSettings.css'; import Api from './Api'; +import TenantKey from './TenantKey'; import OptOut from './OptOut'; import Licenses from './Licenses'; +import { connect } from 'react-redux'; @withPageTitle('Account - OpenReplay Preferences') +@connect(state => ({ + account: state.getIn([ 'user', 'account' ]), +})) export default class ProfileSettings extends React.PureComponent { - render() { + render() { + const { account } = this.props; return (
@@ -43,20 +49,33 @@ export default class ProfileSettings extends React.PureComponent {
-

{ 'Data Collection' }

-
{ 'Enables you to control how OpenReplay captures data on your organization’s usage to improve our product.' }
+

{ 'Tenant Key' }

+
{ 'For SSO (SAML) authentication.' }
-
+
-

{ 'License' }

+

{ 'Data Collection' }

+
{ 'Enables you to control how OpenReplay captures data on your organization’s usage to improve our product.' }
-
+
+ { account.license && ( + <> +
+ +
+
+

{ 'License' }

+
+
+
+ + )} ); } diff --git a/frontend/app/components/Client/ProfileSettings/TenantKey.js b/frontend/app/components/Client/ProfileSettings/TenantKey.js new file mode 100644 index 000000000..8e76bdb43 --- /dev/null +++ b/frontend/app/components/Client/ProfileSettings/TenantKey.js @@ -0,0 +1,51 @@ +// TODO this can be deleted +import copy from 'copy-to-clipboard'; +import { connect } from 'react-redux'; +import styles from './profileSettings.css'; + +@connect(state => ({ + key: state.getIn([ 'user', 'client', 'tenantKey' ]), + loading: state.getIn([ 'user', 'updateAccountRequest', 'loading' ]) || + state.getIn([ 'user', 'putClientRequest', 'loading' ]), +})) +export default class TenantKey extends React.PureComponent { + state = { copied: false } + + copyHandler = () => { + const { key } = this.props; + this.setState({ copied: true }); + copy(key); + setTimeout(() => { + this.setState({ copied: false }); + }, 1000); + }; + + render() { + const { key } = this.props; + const { copied } = this.state; + + return ( +
+
+ +
+ +
+ { copied ? 'copied' : 'copy' } +
+
+
+
+ ); + } +} diff --git a/frontend/app/components/Client/Sites/NewSiteForm.js b/frontend/app/components/Client/Sites/NewSiteForm.js index dfb8087fe..a9306048c 100644 --- a/frontend/app/components/Client/Sites/NewSiteForm.js +++ b/frontend/app/components/Client/Sites/NewSiteForm.js @@ -1,7 +1,7 @@ import { connect } from 'react-redux'; import { Input, Button, Label } from 'UI'; import { save, edit, update , fetchList } from 'Duck/site'; -import { pushNewSite } from 'Duck/user'; +import { pushNewSite, setSiteId } from 'Duck/user'; import styles from './siteForm.css'; @connect(state => ({ @@ -14,7 +14,8 @@ import styles from './siteForm.css'; edit, update, pushNewSite, - fetchList + fetchList, + setSiteId }) export default class NewSiteForm extends React.PureComponent { state = { @@ -34,8 +35,12 @@ export default class NewSiteForm extends React.PureComponent { }) } else { this.props.save(this.props.site).then(() => { - const { sites } = this.props; - this.props.onClose(null, sites.last()) + const { sites } = this.props; + const site = sites.last(); + + this.props.pushNewSite(site) + this.props.setSiteId(site.id) + this.props.onClose(null, site) }); } } diff --git a/frontend/app/components/Funnels/FunnelSessionList/FunnelSessionList.js b/frontend/app/components/Funnels/FunnelSessionList/FunnelSessionList.js index 191c7821b..3aef18003 100644 --- a/frontend/app/components/Funnels/FunnelSessionList/FunnelSessionList.js +++ b/frontend/app/components/Funnels/FunnelSessionList/FunnelSessionList.js @@ -20,7 +20,7 @@ function FunnelSessionList(props) {
{ - this.setState({ showProductModal: false }) - if (newSite) { - this.props.pushNewSite(newSite) - this.props.setSiteId(newSite.id) - } + this.setState({ showProductModal: false }) }; newSite = () => { diff --git a/frontend/app/components/Session/EventsToggleButton/EventsToggleButton.css b/frontend/app/components/Session/EventsToggleButton/EventsToggleButton.css new file mode 100644 index 000000000..19eda2568 --- /dev/null +++ b/frontend/app/components/Session/EventsToggleButton/EventsToggleButton.css @@ -0,0 +1,7 @@ +.wrapper { + background-color: rgba(255, 255, 255, 1); + border-top-left-radius: 20px; + border-bottom-left-radius: 20px; + padding: 5px; + box-shadow: -1px 1px 1px rgba(0, 0, 0, 0.5); +} \ No newline at end of file diff --git a/frontend/app/components/Session/EventsToggleButton/EventsToggleButton.tsx b/frontend/app/components/Session/EventsToggleButton/EventsToggleButton.tsx new file mode 100644 index 000000000..55ebf9523 --- /dev/null +++ b/frontend/app/components/Session/EventsToggleButton/EventsToggleButton.tsx @@ -0,0 +1,32 @@ +import React from 'react' +import { Icon, Popup } from 'UI' +import { connectPlayer, toggleEvents } from 'Player'; +import cn from 'classnames' +import stl from './EventsToggleButton.css' + +function EventsToggleButton({ showEvents, toggleEvents }) { + return ( + + + + } + content={ showEvents ? 'Hide Events' : 'Show Events' } + size="tiny" + inverted + position="bottom right" + /> + ) +} + +export default connectPlayer(state => ({ + showEvents: !state.showEvents +}), { toggleEvents })(EventsToggleButton) + diff --git a/frontend/app/components/Session/EventsToggleButton/index.js b/frontend/app/components/Session/EventsToggleButton/index.js new file mode 100644 index 000000000..b391f169d --- /dev/null +++ b/frontend/app/components/Session/EventsToggleButton/index.js @@ -0,0 +1 @@ +export { default } from './EventsToggleButton' \ No newline at end of file diff --git a/frontend/app/components/Session/LivePlayer.js b/frontend/app/components/Session/LivePlayer.js new file mode 100644 index 000000000..cf10dfbea --- /dev/null +++ b/frontend/app/components/Session/LivePlayer.js @@ -0,0 +1,67 @@ +import { useEffect } from 'react'; +import { connect } from 'react-redux'; +import { Loader } from 'UI'; +import { toggleFullscreen, closeBottomBlock } from 'Duck/components/player'; +import { + PlayerProvider, + connectPlayer, + init as initPlayer, + clean as cleanPlayer, +} from 'Player'; +import { Controls as PlayerControls } from 'Player'; +import Assist from 'Components/Assist' + + +import PlayerBlockHeader from '../Session_/PlayerBlockHeader'; +import EventsBlock from '../Session_/EventsBlock'; +import PlayerBlock from '../Session_/PlayerBlock'; +import styles from '../Session_/session.css'; + + + +const EventsBlockConnected = connectPlayer(state => ({ + currentTimeEventIndex: state.eventListNow.length > 0 ? state.eventListNow.length - 1 : 0, + playing: state.playing, +}))(EventsBlock) + + +const InitLoader = connectPlayer(state => ({ + loading: !state.initialized +}))(Loader); + + +function WebPlayer ({ showAssist, session, toggleFullscreen, closeBottomBlock, live, fullscreen, jwt }) { + useEffect(() => { + initPlayer(session, jwt); + return () => cleanPlayer() + }, [ session.sessionId ]); + + // LAYOUT (TODO: local layout state - useContext or something..) + useEffect(() => () => { + toggleFullscreen(false); + closeBottomBlock(); + }, []) + return ( + + + { showAssist && } + +
+ +
+
+
+ ); +} + + +export default connect(state => ({ + session: state.getIn([ 'sessions', 'current' ]), + showAssist: state.getIn([ 'sessions', 'showChatWindow' ]), + jwt: state.get('jwt'), + fullscreen: state.getIn([ 'components', 'player', 'fullscreen' ]), +}), { + toggleFullscreen, + closeBottomBlock, +})(WebPlayer) + diff --git a/frontend/app/components/Session/Session.js b/frontend/app/components/Session/Session.js index 27c87875c..36fa66d7f 100644 --- a/frontend/app/components/Session/Session.js +++ b/frontend/app/components/Session/Session.js @@ -6,6 +6,7 @@ import { fetchList as fetchSlackList } from 'Duck/integrations/slack'; import { Link, NoContent, Loader } from 'UI'; import { sessions as sessionsRoute } from 'App/routes'; +import LivePlayer from './LivePlayer'; import WebPlayer from './WebPlayer'; import IOSPlayer from './IOSPlayer'; @@ -48,7 +49,7 @@ function Session({ { session.isIOS ? - : + : (session.live ? : ) }
diff --git a/frontend/app/components/Session/WebPlayer.js b/frontend/app/components/Session/WebPlayer.js index 9cfa607a0..28183d9b4 100644 --- a/frontend/app/components/Session/WebPlayer.js +++ b/frontend/app/components/Session/WebPlayer.js @@ -8,13 +8,15 @@ import { init as initPlayer, clean as cleanPlayer, } from 'Player'; -import { Controls as PlayerControls } from 'Player'; +import { Controls as PlayerControls, toggleEvents } from 'Player'; +import cn from 'classnames' import PlayerBlockHeader from '../Session_/PlayerBlockHeader'; import EventsBlock from '../Session_/EventsBlock'; import PlayerBlock from '../Session_/PlayerBlock'; import styles from '../Session_/session.css'; +import EventsToggleButton from './EventsToggleButton'; @@ -28,6 +30,19 @@ const InitLoader = connectPlayer(state => ({ loading: !state.initialized }))(Loader); +const PlayerContentConnected = connectPlayer(state => ({ + showEvents: !state.showEvents +}), { toggleEvents })(PlayerContent); + + +function PlayerContent({ live, fullscreen, showEvents, toggleEvents }) { + return ( +
+ + { showEvents && !live && !fullscreen && } +
+ ) +} function WebPlayer ({ session, toggleFullscreen, closeBottomBlock, live, fullscreen, jwt }) { useEffect(() => { @@ -44,10 +59,7 @@ function WebPlayer ({ session, toggleFullscreen, closeBottomBlock, live, fullscr -
- - { !live && !fullscreen && } -
+
); @@ -61,5 +73,5 @@ export default connect(state => ({ }), { toggleFullscreen, closeBottomBlock, -})(WebPlayer) +})(WebPlayer) diff --git a/frontend/app/components/Session_/EventsBlock/Metadata/SessionList.js b/frontend/app/components/Session_/EventsBlock/Metadata/SessionList.js index 2f1a5c9fa..a91272b17 100644 --- a/frontend/app/components/Session_/EventsBlock/Metadata/SessionList.js +++ b/frontend/app/components/Session_/EventsBlock/Metadata/SessionList.js @@ -27,7 +27,7 @@ class SessionList extends React.PureComponent {
{ similarSessionWithoutCurrent.map(site => ( diff --git a/frontend/app/components/Session_/Player/Controls/Controls.js b/frontend/app/components/Session_/Player/Controls/Controls.js index 880a05402..0c88f729e 100644 --- a/frontend/app/components/Session_/Player/Controls/Controls.js +++ b/frontend/app/components/Session_/Player/Controls/Controls.js @@ -6,8 +6,10 @@ import { selectStorageType, selectStorageListNow, } from 'Player/store'; +import LiveTag from 'Shared/LiveTag'; import { Popup, Icon } from 'UI'; +import { toggleInspectorMode } from 'Player'; import { fullscreenOn, fullscreenOff, @@ -72,6 +74,7 @@ function getStorageName(type) { skipToIssue: state.skipToIssue, speed: state.speed, disabled: state.cssLoading || state.messagesLoading || state.inspectorMode, + inspectorMode: state.inspectorMode, fullscreenDisabled: state.messagesLoading, logCount: state.logListNow.length, logRedCount: state.logRedCountNow, @@ -94,8 +97,7 @@ function getStorageName(type) { showExceptions: state.exceptionsList.length > 0, showLongtasks: state.longtasksList.length > 0, })) -@connect((state, props) => ({ - showDevTools: state.getIn([ 'user', 'account', 'appearance', 'sessionsDevtools' ]), +@connect((state, props) => ({ fullscreen: state.getIn([ 'components', 'player', 'fullscreen' ]), bottomBlock: state.getIn([ 'components', 'player', 'bottomBlock' ]), showStorage: props.showStorage || !state.getIn(['components', 'player', 'hiddenHints', 'storage']), @@ -116,7 +118,7 @@ export default class Controls extends React.Component { } shouldComponentUpdate(nextProps) { - if (nextProps.showDevTools !== this.props.showDevTools || + if ( nextProps.fullscreen !== this.props.fullscreen || nextProps.bottomBlock !== this.props.bottomBlock || nextProps.endTime !== this.props.endTime || @@ -216,8 +218,7 @@ export default class Controls extends React.Component { } render() { - const { - showDevTools, + const { bottomBlock, toggleBottomBlock, live, @@ -245,15 +246,15 @@ export default class Controls extends React.Component { showLongtasks, exceptionsCount, showExceptions, - fullscreen, + fullscreen, skipToIssue } = this.props; const inspectorMode = bottomBlock === INSPECTOR; return ( -
- +
+ { !live && } { !fullscreen &&
{ !live ? @@ -275,10 +276,7 @@ export default class Controls extends React.Component {
:
- + {'Elapsed'}
@@ -305,7 +303,7 @@ export default class Controls extends React.Component { }
- { !live && showDevTools && + { !live && toggleBottomBlock(NETWORK) } @@ -327,7 +325,7 @@ export default class Controls extends React.Component { icon="fetch" /> } - { showGraphql && + { !live && showGraphql && toggleBottomBlock(GRAPHQL) } @@ -337,7 +335,7 @@ export default class Controls extends React.Component { icon="vendors/graphql" /> } - { showStorage && showDevTools && + { !live && showStorage && toggleBottomBlock(STORAGE) } @@ -347,7 +345,7 @@ export default class Controls extends React.Component { icon={ getStorageIconName(storageType) } /> } - { showDevTools && + { toggleBottomBlock(CONSOLE) } @@ -358,7 +356,7 @@ export default class Controls extends React.Component { hasErrors={ logRedCount > 0 } /> } - { showExceptions && showDevTools && + { showExceptions && toggleBottomBlock(EXCEPTIONS) } @@ -369,7 +367,7 @@ export default class Controls extends React.Component { hasErrors={ exceptionsCount > 0 } /> } - { !live && showDevTools && showStack && + { !live && showStack && toggleBottomBlock(STACKEVENTS) } @@ -380,7 +378,7 @@ export default class Controls extends React.Component { hasErrors={ stackRedCount > 0 } /> } - { showProfiler && showDevTools && + { !live && showProfiler && toggleBottomBlock(PROFILER) } @@ -389,15 +387,18 @@ export default class Controls extends React.Component { label="Profiler" icon="code" /> + } + { + !live && + toggleBottomBlock(PERFORMANCE) } + active={ bottomBlock === PERFORMANCE } + label="Performance" + icon="tachometer-slow" + /> } - toggleBottomBlock(PERFORMANCE) } - active={ bottomBlock === PERFORMANCE } - label="Performance" - icon="tachometer-slow" - /> - { showLongtasks && + { !live && showLongtasks && toggleBottomBlock(LONGTASKS) } @@ -418,13 +419,15 @@ export default class Controls extends React.Component { } - toggleBottomBlock(INSPECTOR) } - icon={ inspectorMode ? 'close' : 'inspect' } - label="Inspect" - /> + {!live && ( + toggleBottomBlock(INSPECTOR) } + icon={ inspectorMode ? 'close' : 'inspect' } + label="Inspect" + /> + )}
} diff --git a/frontend/app/components/Session_/Player/Controls/Timeline.js b/frontend/app/components/Session_/Player/Controls/Timeline.js index 0b6a76cf1..d3af75d4f 100644 --- a/frontend/app/components/Session_/Player/Controls/Timeline.js +++ b/frontend/app/components/Session_/Player/Controls/Timeline.js @@ -64,8 +64,7 @@ const getPointerIcon = (type) => { fetchList: state.fetchList, })) @connect(state => ({ - issues: state.getIn([ 'sessions', 'current', 'issues' ]), - showDevTools: state.getIn([ 'user', 'account', 'appearance', 'sessionsDevtools' ]), + issues: state.getIn([ 'sessions', 'current', 'issues' ]), clickRageTime: state.getIn([ 'sessions', 'current', 'clickRage' ]) && state.getIn([ 'sessions', 'current', 'clickRageTime' ]), returningLocationTime: state.getIn([ 'sessions', 'current', 'returningLocation' ]) && @@ -102,8 +101,7 @@ export default class Timeline extends React.PureComponent { live, logList, exceptionsList, - resourceList, - showDevTools, + resourceList, clickRageTime, stackList, fetchList, @@ -255,7 +253,7 @@ export default class Timeline extends React.PureComponent { } /> */ } - { showDevTools && exceptionsList + { exceptionsList .map(e => (
)) } - { showDevTools && logList + { logList .map(l => l.isRed() && (
)) } - { showDevTools && resourceList + { resourceList .filter(r => r.isRed() || r.isYellow()) .map(r => (
)) } - { showDevTools && fetchList + { fetchList .filter(e => e.isRed()) .map(e => (
)) } - { showDevTools && stackList + { stackList .filter(e => e.isRed()) .map(e => (
)); @@ -18,9 +20,11 @@ const ScreenWrapper = withOverlay()(React.memo(() =>
({ //session: state.getIn([ 'sessions', 'current' ]), @@ -96,7 +100,7 @@ export default class Player extends React.PureComponent { className, playing, disabled, - inspectorMode, + removeOverlay, bottomBlockIsActive, loading, disconnected, @@ -105,6 +109,8 @@ export default class Player extends React.PureComponent { completed, autoplay, nextId, + live, + liveStatusText, } = this.props; return ( @@ -124,13 +130,17 @@ export default class Player extends React.PureComponent { // label="Esc" // /> } -
- { !inspectorMode && + {!live && !fullscreen && } +
+ { !removeOverlay &&
- + { live && liveStatusText + ? {liveStatusText} + : + }
- -
- - } + { !live && ( + <> + +
- } - /> + + } + /> + + )} { !live && jiraConfig && jiraConfig.token && }
diff --git a/frontend/app/components/shared/LiveTag/LiveTag.css b/frontend/app/components/shared/LiveTag/LiveTag.css new file mode 100644 index 000000000..cecf45bad --- /dev/null +++ b/frontend/app/components/shared/LiveTag/LiveTag.css @@ -0,0 +1,33 @@ +@keyframes fade { + 0% { opacity: 1} + 50% { opacity: 0} + 100% { opacity: 1} +} + +.liveTag { + cursor: pointer; + user-select: none; + height: 26px; + width: 56px; + border-radius: 3px; + background-color: $gray-light; + display: flex; + align-items: center; + justify-content: center; + color: $gray-dark; + text-transform: uppercase; + font-size: 10px; + letter-spacing: 1px; + margin-right: 10px; + & svg { + fill: $gray-dark; + } + &[data-is-live=true] { + background-color: #42AE5E; + color: white; + & svg { + fill: white; + animation: fade 1s infinite; + } + } +} \ No newline at end of file diff --git a/frontend/app/components/shared/LiveTag/LiveTag.tsx b/frontend/app/components/shared/LiveTag/LiveTag.tsx new file mode 100644 index 000000000..8adfa398d --- /dev/null +++ b/frontend/app/components/shared/LiveTag/LiveTag.tsx @@ -0,0 +1,19 @@ +import React from 'react' +import { Icon } from 'UI' +import stl from './LiveTag.css' + +interface Props { + onClick: () => void, + isLive: Boolean +} + +function LiveTag({ isLive, onClick }: Props) { + return ( + + ) +} + +export default LiveTag diff --git a/frontend/app/components/shared/LiveTag/index.js b/frontend/app/components/shared/LiveTag/index.js new file mode 100644 index 000000000..3e91f2d0e --- /dev/null +++ b/frontend/app/components/shared/LiveTag/index.js @@ -0,0 +1 @@ +export { default } from './LiveTag' \ No newline at end of file diff --git a/frontend/app/components/shared/SessionItem/Counter.tsx b/frontend/app/components/shared/SessionItem/Counter.tsx new file mode 100644 index 000000000..a91f13d09 --- /dev/null +++ b/frontend/app/components/shared/SessionItem/Counter.tsx @@ -0,0 +1,29 @@ +import React, { useState, useEffect } from 'react' +import { Duration } from 'luxon'; + +interface Props { + startTime: any, + className: string +} + +function Counter({ startTime, className }: Props) { + let intervalId; + const [duration, setDuration] = useState(new Date().getTime() - startTime) + + useEffect(() => { + if (!intervalId) { + intervalId = setInterval(() => { + setDuration(duration + 1000) + }, 1000) + } + return () => clearInterval(intervalId) + }, [duration]) + + return ( +
+ {startTime && Duration.fromMillis(duration).toFormat('m:ss')} +
+ ) +} + +export default Counter diff --git a/frontend/app/components/shared/SessionItem/SessionItem.js b/frontend/app/components/shared/SessionItem/SessionItem.js index f93aeefcf..36a7f186a 100644 --- a/frontend/app/components/shared/SessionItem/SessionItem.js +++ b/frontend/app/components/shared/SessionItem/SessionItem.js @@ -14,6 +14,9 @@ import { toggleFavorite } from 'Duck/sessions'; import { session as sessionRoute } from 'App/routes'; import { durationFormatted, formatTimeOrDate } from 'App/date'; import stl from './sessionItem.css'; +import LiveTag from 'Shared/LiveTag'; +import { session } from '../../../routes'; +import Counter from './Counter' const Label = ({ label = '', color = 'color-gray-medium'}) => (
{label}
@@ -51,7 +54,8 @@ export default class SessionItem extends React.PureComponent { favorite, userDeviceType, userUuid, - userNumericHash, + userNumericHash, + live }, timezone, onUserClick, @@ -85,22 +89,30 @@ export default class SessionItem extends React.PureComponent {
-
{ formattedDuration }
+
+ { live ? : formattedDuration } +
-
-
{ eventsCount }
-
+ {!live && ( +
+
{ eventsCount }
+
+ )}
-
-
{ errorsCount }
-
+ {!live && ( +
+
{ errorsCount }
+
+ )} + + { live && }
} disabled={ !disabled } - content={ 'No Sessions' } + content={ 'No recordings' } size="tiny" inverted position="left center" diff --git a/frontend/app/duck/sessions.js b/frontend/app/duck/sessions.js index 475f98177..0c63941e3 100644 --- a/frontend/app/duck/sessions.js +++ b/frontend/app/duck/sessions.js @@ -12,6 +12,7 @@ const INIT = 'sessions/INIT'; const FETCH_LIST = new RequestTypes('sessions/FETCH_LIST'); const FETCH = new RequestTypes('sessions/FETCH'); const FETCH_FAVORITE_LIST = new RequestTypes('sessions/FETCH_FAVORITE_LIST'); +const FETCH_LIVE_LIST = new RequestTypes('sessions/FETCH_LIVE_LIST'); const TOGGLE_FAVORITE = new RequestTypes('sessions/TOGGLE_FAVORITE'); const FETCH_ERROR_STACK = new RequestTypes('sessions/FETCH_ERROR_STACK'); const SORT = 'sessions/SORT'; @@ -19,6 +20,7 @@ const REDEFINE_TARGET = 'sessions/REDEFINE_TARGET'; const SET_TIMEZONE = 'sessions/SET_TIMEZONE'; const SET_EVENT_QUERY = 'sessions/SET_EVENT_QUERY'; const SET_AUTOPLAY_VALUES = 'sessions/SET_AUTOPLAY_VALUES'; +const TOGGLE_CHAT_WINDOW = 'sessions/TOGGLE_CHAT_WINDOW'; const SET_ACTIVE_TAB = 'sessions/SET_ACTIVE_TAB'; @@ -35,7 +37,9 @@ const initialState = Map({ errorStack: List(), eventsIndex: [], sourcemapUploaded: true, - filteredEvents: null + filteredEvents: null, + showChatWindow: false, + liveSessions: List() }); const reducer = (state = initialState, action = {}) => { @@ -49,6 +53,11 @@ const reducer = (state = initialState, action = {}) => { : state; case FETCH_ERROR_STACK.SUCCESS: return state.set('errorStack', List(action.data.trace).map(ErrorStack)).set('sourcemapUploaded', action.data.sourcemapUploaded) + case FETCH_LIVE_LIST.SUCCESS: + // const { sessions, total } = action.data; + const liveList = List(action.data).map(s => new Session({...s, live: true})); + return state + .set('liveSessions', liveList) case FETCH_LIST.SUCCESS: const { sessions, total } = action.data; const list = List(sessions).map(Session); @@ -98,8 +107,7 @@ const reducer = (state = initialState, action = {}) => { .set('sessionIds', list.map(({ sessionId }) => sessionId ).toJS()) .set('total', total) .set('keyMap', keyMap) - .set('wdTypeCount', wdTypeCount); - + .set('wdTypeCount', wdTypeCount); case SET_AUTOPLAY_VALUES: { const sessionIds = state.get('sessionIds') const currentSessionId = state.get('current').sessionId @@ -194,6 +202,9 @@ const reducer = (state = initialState, action = {}) => { .set('sessionIds', allList.map(({ sessionId }) => sessionId ).toJS()) case SET_TIMEZONE: return state.set('timezone', action.timezone) + case TOGGLE_CHAT_WINDOW: + console.log(action) + return state.set('showChatWindow', action.state) default: return state; } @@ -252,6 +263,20 @@ export function fetchFavoriteList() { }; } +export function fetchLiveList() { + return { + types: FETCH_LIVE_LIST.toArray(), + call: client => client.get('/assist/sessions'), + }; +} + +export function toggleChatWindow(state) { + return { + type: TOGGLE_CHAT_WINDOW, + state + }; +} + export function sort(sortKey, sign = 1, listName = 'list') { return { type: SORT, diff --git a/frontend/app/player/MessageDistributor/MessageDistributor.js b/frontend/app/player/MessageDistributor/MessageDistributor.ts similarity index 50% rename from frontend/app/player/MessageDistributor/MessageDistributor.js rename to frontend/app/player/MessageDistributor/MessageDistributor.ts index 925e7ccbf..b65e4aa40 100644 --- a/frontend/app/player/MessageDistributor/MessageDistributor.js +++ b/frontend/app/player/MessageDistributor/MessageDistributor.ts @@ -1,4 +1,3 @@ -//@flow import { Decoder } from "syncod"; import logger from 'App/logger'; @@ -24,43 +23,68 @@ import MouseManager from './managers/MouseManager'; import PerformanceTrackManager from './managers/PerformanceTrackManager'; import WindowNodeCounter from './managers/WindowNodeCounter'; import ActivityManager from './managers/ActivityManager'; +import AssistManager from './managers/AssistManager'; -import MessageGenerator from './MessageGenerator'; +import MessageReader from './MessageReader'; -import { INITIAL_STATE as PARENT_INITIAL_STATE } from './StatedScreen'; +import { INITIAL_STATE as SUPER_INITIAL_STATE, State as SuperState } from './StatedScreen'; +import { INITIAL_STATE as ASSIST_INITIAL_STATE, State as AssistState } from './managers/AssistManager'; +import type { TimedMessage } from './Timed'; +import type { PerformanceChartPoint } from './managers/PerformanceTrackManager'; +import type { SkipInterval } from './managers/ActivityManager'; -const LIST_NAMES = [ "redux", "mobx", "vuex", "ngrx", "graphql", "exceptions", "profiles", "longtasks" ] +const LIST_NAMES = [ "redux", "mobx", "vuex", "ngrx", "graphql", "exceptions", "profiles", "longtasks" ] as const; const LISTS_INITIAL_STATE = {}; LIST_NAMES.forEach(name => { LISTS_INITIAL_STATE[`${name}ListNow`] = []; LISTS_INITIAL_STATE[`${name}List`] = []; }) -export const INITIAL_STATE = { - ...PARENT_INITIAL_STATE, + +export interface State extends SuperState, AssistState { + performanceChartData: PerformanceChartPoint[], + skipIntervals: SkipInterval[], + connType?: string, + connBandwidth?: number, + location?: string, + performanceChartTime?: number, + + domContentLoadedTime?: any, + domBuildingTime?: any, + loadTime?: any, +} +export const INITIAL_STATE: State = { + ...SUPER_INITIAL_STATE, ...LISTS_INITIAL_STATE, + ...ASSIST_INITIAL_STATE, performanceChartData: [], skipIntervals: [], +}; + +type ListsObject = { + [key in typeof LIST_NAMES[number]]: ListWalker // } -function initLists() { - const lists = {}; +function initLists(): ListsObject { + const lists: Partial = {} ; for (var i = 0; i < LIST_NAMES.length; i++) { lists[ LIST_NAMES[i] ] = new ListWalker(); } - return lists; + return lists as ListsObject; } import type { Message, - SetLocation, - SetTitle, + SetPageLocation, ConnectionInformation, SetViewportSize, SetViewportScroll, } from './messages'; +interface Timed { //TODO: to common space + time: number; +} type ReduxDecoded = Timed & { action: {}, @@ -70,71 +94,71 @@ type ReduxDecoded = Timed & { export default class MessageDistributor extends StatedScreen { // TODO: consistent with the other data-lists - #locationEventManager: ListWalker<> = new ListWalker(); - #locationManager: ListWalker = new ListWalker(); - #loadedLocationManager: ListWalker = new ListWalker(); - #titleManager: ListWalker = new ListWalker(); - #connectionInfoManger: ListWalker = new ListWalker(); - #performanceTrackManager: PerformanceTrackManager = new PerformanceTrackManager(); - #windowNodeCounter: WindowNodeCounter = new WindowNodeCounter(); - #clickManager: ListWalker = new ListWalker(); + private readonly locationEventManager: ListWalker/**/ = new ListWalker(); + private readonly locationManager: ListWalker = new ListWalker(); + private readonly loadedLocationManager: ListWalker = new ListWalker(); + private readonly connectionInfoManger: ListWalker = new ListWalker(); + private readonly performanceTrackManager: PerformanceTrackManager = new PerformanceTrackManager(); + private readonly windowNodeCounter: WindowNodeCounter = new WindowNodeCounter(); + private readonly clickManager: ListWalker = new ListWalker(); - #resizeManager: ListWalker = new ListWalker(); - #pagesManager: PagesManager; - #mouseManager: MouseManager; + private readonly resizeManager: ListWalker = new ListWalker([]); + private readonly pagesManager: PagesManager; + private readonly mouseManager: MouseManager; + private readonly assistManager: AssistManager; - #scrollManager: ListWalker = new ListWalker(); + private readonly scrollManager: ListWalker = new ListWalker(); - #decoder = new Decoder(); - #lists = initLists(); + private readonly decoder = new Decoder(); + private readonly lists = initLists(); - #activirtManager: ActivityManager; + private activirtManager: ActivityManager | null = null; - #sessionStart: number; - #navigationStartOffset: number = 0; - #lastMessageTime: number = 0; + private readonly sessionStart: number; + private navigationStartOffset: number = 0; + private lastMessageTime: number = 0; - constructor(sess: any /*Session*/, jwt: string) { + constructor(private readonly session: any /*Session*/, jwt: string) { super(); - this.#pagesManager = new PagesManager(this, sess.isMobile) - this.#mouseManager = new MouseManager(this); + this.pagesManager = new PagesManager(this, this.session.isMobile) + this.mouseManager = new MouseManager(this); + this.assistManager = new AssistManager(session, this); - this.#activirtManager = new ActivityManager(sess.duration.milliseconds); + this.sessionStart = this.session.startedAt; - this.#sessionStart = sess.startedAt; - - /* == REFACTOR_ME == */ - const eventList = sess.events.toJSON(); - initListsDepr({ - event: eventList, - stack: sess.stackEvents.toJSON(), - resource: sess.resources.toJSON(), - }); - - eventList.forEach(e => { - if (e.type === EVENT_TYPES.LOCATION) { //TODO type system - this.#locationEventManager.add(e); - } - if (e.type === EVENT_TYPES.CLICK) { - this.#clickManager.add(e); - } - }); - sess.errors.forEach(e => { - this.#lists.exceptions.add(e); - }); - /* === */ - - - if (sess.live) { - // const sockUrl = `wss://live.openreplay.com/1/${ sess.siteId }/${ sess.sessionId }/${ jwt }`; - // this.#subscribeOnMessages(sockUrl); + if (this.session.live) { + // const sockUrl = `wss://live.openreplay.com/1/${ this.session.siteId }/${ this.session.sessionId }/${ jwt }`; + // this.subscribeOnMessages(sockUrl); + initListsDepr({}) + this.assistManager.connect(); } else { - this._loadMessages(sess.mobsUrl); + this.activirtManager = new ActivityManager(this.session.duration.milliseconds); + /* == REFACTOR_ME == */ + const eventList = this.session.events.toJSON(); + initListsDepr({ + event: eventList, + stack: this.session.stackEvents.toJSON(), + resource: this.session.resources.toJSON(), + }); + + eventList.forEach(e => { + if (e.type === EVENT_TYPES.LOCATION) { //TODO type system + this.locationEventManager.add(e); + } + if (e.type === EVENT_TYPES.CLICK) { + this.clickManager.add(e); + } + }); + this.session.errors.forEach(e => { + this.lists.exceptions.add(e); + }); + /* === */ + this.loadMessages(); } } - // #subscribeOnMessages(sockUrl) { + // subscribeOnMessages(sockUrl) { // this.setMessagesLoading(true); // const socket = new WebSocket(sockUrl); // socket.binaryType = 'arraybuffer'; @@ -147,36 +171,35 @@ export default class MessageDistributor extends StatedScreen { // const msgs = []; // messageGenerator // parseBuffer(msgs, data); // // TODO: count indexes. Now will not work due to wrong indexes - // //msgs.forEach(this.#distributeMessage); + // //msgs.forEach(this.distributeMessage); // this.setMessagesLoading(false); // this.setDisconnected(false); // } // this._socket = socket; // } - _loadMessages(fileUrl): void { + private loadMessages(): void { + const fileUrl: string = this.session.mobsUrl; this.setMessagesLoading(true); window.fetch(fileUrl) .then(r => r.arrayBuffer()) .then(b => { - const mGen = new MessageGenerator(new Uint8Array(b), this.#sessionStart); - let mCount = 0; - const msgs = []; + const r = new MessageReader(new Uint8Array(b), this.sessionStart); + const msgs: Array = []; - while (mGen.hasNext()) { - mCount++; - const next = mGen.next(); + while (r.hasNext()) { + const next = r.next(); if (next != null) { - this.#lastMessageTime = next[0].time; - this.#distributeMessage(next[0], next[1]); + this.lastMessageTime = next[0].time; + this.distributeMessage(next[0], next[1]); msgs.push(next[0]); } } - // Hack for upet (TODO: fix ordering in one mutation (removes first)) + // @ts-ignore Hack for upet (TODO: fix ordering in one mutation (removes first)) const headChildrenIds = msgs.filter(m => m.parentID === 1).map(m => m.id); //const createNodeTypes = ["create_text_node", "create_element_node"]; - this.#pagesManager.sort((m1, m2) =>{ + this.pagesManager.sort((m1, m2) =>{ if (m1.time === m2.time) { if (m1.tp === "remove_node" && m2.tp !== "remove_node") { if (headChildrenIds.includes(m1.id)) { @@ -198,22 +221,22 @@ export default class MessageDistributor extends StatedScreen { } return 0; }) - // + - logger.info("Messages count: ", mCount, msgs); + logger.info("Messages count: ", msgs.length, msgs); - const stateToUpdate = { - performanceChartData: this.#performanceTrackManager.chartData, - performanceAvaliability: this.#performanceTrackManager.avaliability, + const stateToUpdate: {[key:string]: any} = { + performanceChartData: this.performanceTrackManager.chartData, + performanceAvaliability: this.performanceTrackManager.avaliability, }; - this.#activirtManager.end(); - stateToUpdate.skipIntervals = this.#activirtManager.list; + this.activirtManager?.end(); + stateToUpdate.skipIntervals = this.activirtManager?.list || []; LIST_NAMES.forEach(key => { - stateToUpdate[ `${ key }List` ] = this.#lists[ key ].list; + stateToUpdate[ `${ key }List` ] = this.lists[ key ].list; }); update(stateToUpdate); - this.#windowNodeCounter.reset(); + this.windowNodeCounter.reset(); this.setMessagesLoading(false); }) @@ -224,25 +247,25 @@ export default class MessageDistributor extends StatedScreen { }); } - move(t: number, index: ?number):void { - const stateToUpdate = {}; + move(t: number, index?: number):void { + const stateToUpdate: Partial = {}; /* == REFACTOR_ME == */ - const lastLoadedLocationMsg = this.#loadedLocationManager.moveToLast(t, index); + const lastLoadedLocationMsg = this.loadedLocationManager.moveToLast(t, index); if (!!lastLoadedLocationMsg) { setListsStartTime(lastLoadedLocationMsg.time) - this.#navigationStartOffset = lastLoadedLocationMsg.navigationStart - this.#sessionStart; + this.navigationStartOffset = lastLoadedLocationMsg.navigationStart - this.sessionStart; } - const llEvent = this.#locationEventManager.moveToLast(t, index); + const llEvent = this.locationEventManager.moveToLast(t, index); if (!!llEvent) { if (llEvent.domContentLoadedTime != null) { stateToUpdate.domContentLoadedTime = { - time: llEvent.domContentLoadedTime + this.#navigationStartOffset, //TODO: predefined list of load event for the network tab (merge events & setLocation: add navigationStart to db) + time: llEvent.domContentLoadedTime + this.navigationStartOffset, //TODO: predefined list of load event for the network tab (merge events & SetPageLocation: add navigationStart to db) value: llEvent.domContentLoadedTime, } } if (llEvent.loadTime != null) { stateToUpdate.loadTime = { - time: llEvent.loadTime + this.#navigationStartOffset, + time: llEvent.loadTime + this.navigationStartOffset, value: llEvent.loadTime, } } @@ -251,28 +274,24 @@ export default class MessageDistributor extends StatedScreen { } } /* === */ - const lastLocationMsg = this.#locationManager.moveToLast(t, index); + const lastLocationMsg = this.locationManager.moveToLast(t, index); if (!!lastLocationMsg) { stateToUpdate.location = lastLocationMsg.url; } - const lastTitleMsg = this.#titleManager.moveToLast(t, index); - if (!!lastTitleMsg) { - stateToUpdate.title = lastTitleMsg.title; - } - const lastConnectionInfoMsg = this.#connectionInfoManger.moveToLast(t, index); + const lastConnectionInfoMsg = this.connectionInfoManger.moveToLast(t, index); if (!!lastConnectionInfoMsg) { stateToUpdate.connType = lastConnectionInfoMsg.type; stateToUpdate.connBandwidth = lastConnectionInfoMsg.downlink; } - const lastPerformanceTrackMessage = this.#performanceTrackManager.moveToLast(t, index); + const lastPerformanceTrackMessage = this.performanceTrackManager.moveToLast(t, index); if (!!lastPerformanceTrackMessage) { stateToUpdate.performanceChartTime = lastPerformanceTrackMessage.time; } LIST_NAMES.forEach(key => { - const lastMsg = this.#lists[ key ].moveToLast(t, key === 'exceptions' ? null : index); + const lastMsg = this.lists[ key ].moveToLast(t, key === 'exceptions' ? undefined : index); if (lastMsg != null) { - stateToUpdate[`${key}ListNow`] = this.#lists[ key ].listNow; + stateToUpdate[`${key}ListNow`] = this.lists[ key ].listNow; } }); @@ -280,19 +299,21 @@ export default class MessageDistributor extends StatedScreen { /* Sequence of the managers is important here */ // Preparing the size of "screen" - const lastResize = this.#resizeManager.moveToLast(t, index); + const lastResize = this.resizeManager.moveToLast(t, index); if (!!lastResize) { this.setSize(lastResize) } - this.#pagesManager.moveReady(t).then(() => { + this.pagesManager.moveReady(t).then(() => { - const lastScroll = this.#scrollManager.moveToLast(t, index); + const lastScroll = this.scrollManager.moveToLast(t, index); + // @ts-ignore ??can't see double inheritance if (!!lastScroll && this.window) { + // @ts-ignore this.window.scrollTo(lastScroll.x, lastScroll.y); } // Moving mouse and setting :hover classes on ready view - this.#mouseManager.move(t); - const lastClick = this.#clickManager.moveToLast(t); + this.mouseManager.move(t); + const lastClick = this.clickManager.moveToLast(t); // if (!!lastClick) { // this.cursor.click(); // } @@ -305,7 +326,7 @@ export default class MessageDistributor extends StatedScreen { const decoded = {}; try { keys.forEach(key => { - decoded[ key ] = this.#decoder.decode(msg[ key ]); + decoded[ key ] = this.decoder.decode(msg[ key ]); }); } catch (e) { logger.error("Error on message decoding: ", e, msg); @@ -315,7 +336,7 @@ export default class MessageDistributor extends StatedScreen { } /* Binded */ - #distributeMessage = (msg: Message, index: number): void => { + distributeMessage = (msg: TimedMessage, index: number): void => { if ([ "mouse_move", "set_input_value", @@ -323,24 +344,13 @@ export default class MessageDistributor extends StatedScreen { "set_viewport_size", "set_viewport_scroll", ].includes(msg.tp)) { - this.#activirtManager.updateAcctivity(msg.time); + this.activirtManager?.updateAcctivity(msg.time); } - //const index = #i + index; //? + //const index = i + index; //? let decoded; const time = msg.time; switch (msg.tp) { /* Lists: */ - case "resource_timing": - logger.log(msg) - listAppend("resource", Resource({ - time, - duration: msg.duration, - ttfb: msg.ttfb, - url: msg.url, - initiator: msg.initiator, - index, - })); - break; case "console_log": if (msg.level === 'debug') break; listAppend("log", Log({ @@ -359,60 +369,57 @@ export default class MessageDistributor extends StatedScreen { status: msg.status, duration: msg.duration, type: TYPES.FETCH, - time: msg.timestamp - this.#sessionStart, //~ + time: msg.timestamp - this.sessionStart, //~ index, })); break; /* */ case "set_page_location": - this.#locationManager.add(msg); + this.locationManager.add(msg); if (msg.navigationStart > 0) { - this.#loadedLocationManager.add(msg); + this.loadedLocationManager.add(msg); } break; - case "set_title": - this.#titleManager.add(msg); - break; case "set_viewport_size": - this.#resizeManager.add(msg); + this.resizeManager.add(msg); break; case "mouse_move": - this.#mouseManager.add(msg); + this.mouseManager.add(msg); break; case "set_viewport_scroll": - this.#scrollManager.add(msg); + this.scrollManager.add(msg); break; case "performance_track": - this.#performanceTrackManager.add(msg); + this.performanceTrackManager.add(msg); break; case "set_page_visibility": - this.#performanceTrackManager.handleVisibility(msg) + this.performanceTrackManager.handleVisibility(msg) break; case "connection_information": - this.#connectionInfoManger.add(msg); + this.connectionInfoManger.add(msg); break; case "o_table": - this.#decoder.set(msg.key, msg.value); + this.decoder.set(msg.key, msg.value); break; case "redux": decoded = this._decodeMessage(msg, ["state", "action"]); logger.log(decoded) if (decoded != null) { - this.#lists.redux.add(decoded); + this.lists.redux.add(decoded); } break; case "ng_rx": decoded = this._decodeMessage(msg, ["state", "action"]); logger.log(decoded) if (decoded != null) { - this.#lists.ngrx.add(decoded); + this.lists.ngrx.add(decoded); } break; case "vuex": decoded = this._decodeMessage(msg, ["state", "mutation"]); logger.log(decoded) if (decoded != null) { - this.#lists.vuex.add(decoded); + this.lists.vuex.add(decoded); } break; case "mob_x": @@ -420,59 +427,62 @@ export default class MessageDistributor extends StatedScreen { logger.log(decoded) if (decoded != null) { - this.#lists.mobx.add(decoded); + this.lists.mobx.add(decoded); } break; case "graph_ql": + // @ts-ignore some hack? TODO: remove msg.duration = 0; - this.#lists.graphql.add(msg); + this.lists.graphql.add(msg); break; case "profiler": - this.#lists.profiles.add(msg); + this.lists.profiles.add(msg); break; case "long_task": - this.#lists.longtasks.add({ + this.lists.longtasks.add({ ...msg, - time: msg.timestamp - this.#sessionStart, + time: msg.timestamp - this.sessionStart, }); break; default: switch (msg.tp){ case "create_document": - this.#windowNodeCounter.reset(); - this.#performanceTrackManager.setCurrentNodesCount(this.#windowNodeCounter.count); + this.windowNodeCounter.reset(); + this.performanceTrackManager.setCurrentNodesCount(this.windowNodeCounter.count); break; case "create_text_node": case "create_element_node": - this.#windowNodeCounter.addNode(msg.id, msg.parentID); - this.#performanceTrackManager.setCurrentNodesCount(this.#windowNodeCounter.count); + this.windowNodeCounter.addNode(msg.id, msg.parentID); + this.performanceTrackManager.setCurrentNodesCount(this.windowNodeCounter.count); break; case "move_node": - this.#windowNodeCounter.moveNode(msg.id, msg.parentID); - this.#performanceTrackManager.setCurrentNodesCount(this.#windowNodeCounter.count); + this.windowNodeCounter.moveNode(msg.id, msg.parentID); + this.performanceTrackManager.setCurrentNodesCount(this.windowNodeCounter.count); break; case "remove_node": - this.#windowNodeCounter.removeNode(msg.id); - this.#performanceTrackManager.setCurrentNodesCount(this.#windowNodeCounter.count); + this.windowNodeCounter.removeNode(msg.id); + this.performanceTrackManager.setCurrentNodesCount(this.windowNodeCounter.count); break; } - this.#pagesManager.add(msg); + this.pagesManager.add(msg); break; } } getLastMessageTime():number { - return this.#lastMessageTime; + return this.lastMessageTime; } getFirstMessageTime():number { - return 0; //this.#pagesManager.minTime; + return 0; //this.pagesManager.minTime; } // TODO: clean managers? clean() { + // @ts-ignore super.clean(); - if (this._socket) this._socket.close(); + //if (this._socket) this._socket.close(); update(INITIAL_STATE); + this.assistManager.clear(); } } \ No newline at end of file diff --git a/frontend/app/player/MessageDistributor/MessageGenerator.js b/frontend/app/player/MessageDistributor/MessageGenerator.js deleted file mode 100644 index 7b1bce463..000000000 --- a/frontend/app/player/MessageDistributor/MessageGenerator.js +++ /dev/null @@ -1,82 +0,0 @@ -import type { TimedMessage } from './Timed'; - -import logger from 'App/logger'; -import readMessage from './messages'; - -function needSkipMessage(data: Uint8Array, p: number, pLast: number): boolean { - for (let i = 7; i >= 0; i--) { - if (data[ p + i ] !== data[ pLast + i ]) { - return data[ p + i ] - data[ pLast + i ] < 0 - } - } - return true -} - -export default class MessageGenerator { - #data: Uint8Array; - #p: number = 0; - #pLastMessageID: number = 0; - #startTime: number; - #currentTime: ?number; - - #error: boolean = false; - constructor(data: Uint8Array, startTime: number) { - this.#startTime = startTime; - this.#data = data; - } - - _needSkipMessage():boolean { - if (this.#p === 0) return false; - for (let i = 7; i >= 0; i--) { - if (this.#data[ this.#p + i ] !== this.#data[ this.#pLastMessageID + i ]) { - return this.#data[ this.#p + i ] - this.#data[ this.#pLastMessageID + i ] < 0; - } - } - return true; - } - - _readMessage(): ?Message { - this.#p += 8; - try { - let msg - [ msg, this.#p ] = readMessage(this.#data, this.#p); - return msg; - } catch (e) { - this.#error = true; - logger.error("Read message error:", e); - return null; - } - } - - hasNext():boolean { - return !this.#error && this.#data.length > this.#p; - } - - next(): ?[ TimedMessage, number] { - if (!this.hasNext()) { - return null; - } - - while (this._needSkipMessage()) { - this._readMessage(); - } - this.#pLastMessageID = this.#p; - - const msg = this._readMessage(); - if (!msg) { - return null; - } - - - if (msg.tp === "timestamp") { - // if (this.#startTime == null) { - // this.#startTime = msg.timestamp - // } - this.#currentTime = msg.timestamp - this.#startTime; - } else { - msg.time = this.#currentTime; - msg._index = this.#pLastMessageID; - return [msg, this.#pLastMessageID]; - } - } -} \ No newline at end of file diff --git a/frontend/app/player/MessageDistributor/MessageReader.ts b/frontend/app/player/MessageDistributor/MessageReader.ts new file mode 100644 index 000000000..dea8759c9 --- /dev/null +++ b/frontend/app/player/MessageDistributor/MessageReader.ts @@ -0,0 +1,80 @@ +import type { TimedMessage, Indexed } from './Timed'; + +import logger from 'App/logger'; +import readMessage, { Message } from './messages'; +import PrimitiveReader from './PrimitiveReader'; + +// function needSkipMessage(data: Uint8Array, p: number, pLast: number): boolean { +// for (let i = 7; i >= 0; i--) { +// if (data[ p + i ] !== data[ pLast + i ]) { +// return data[ p + i ] - data[ pLast + i ] < 0 +// } +// } +// return true +// } + +export default class MessageReader extends PrimitiveReader { + private pLastMessageID: number = 0; + private currentTime: number = 0; + public error: boolean = false; + constructor(data: Uint8Array, private readonly startTime: number) { + super(data); + } + + private needSkipMessage(): boolean { + if (this.p === 0) return false; + for (let i = 7; i >= 0; i--) { + if (this.buf[ this.p + i ] !== this.buf[ this.pLastMessageID + i ]) { + return this.buf[ this.p + i ] - this.buf[ this.pLastMessageID + i ] < 0; + } + } + return true; + } + + private readMessage(): Message | null { + this.skip(8); + try { + let msg + msg = readMessage(this); + return msg; + } catch (e) { + this.error = true; + logger.error("Read message error:", e); + return null; + } + } + + hasNext():boolean { + return !this.error && this.buf.length > this.p; + } + + next(): [ TimedMessage, number] | null { + if (!this.hasNext()) { + return null; + } + + while (this.needSkipMessage()) { + this.readMessage(); + } + this.pLastMessageID = this.p; + + const msg = this.readMessage(); + if (!msg) { + return null; + } + + if (msg.tp === "timestamp") { + // if (this.startTime == null) { + // this.startTime = msg.timestamp + // } + this.currentTime = msg.timestamp - this.startTime; + } else { + const tMsg = Object.assign(msg, { + time: this.currentTime, + _index: this.pLastMessageID, + }) + return [tMsg, this.pLastMessageID]; + } + return null; + } +} \ No newline at end of file diff --git a/frontend/app/player/MessageDistributor/PrimitiveReader.ts b/frontend/app/player/MessageDistributor/PrimitiveReader.ts new file mode 100644 index 000000000..6ee5ade4e --- /dev/null +++ b/frontend/app/player/MessageDistributor/PrimitiveReader.ts @@ -0,0 +1,36 @@ +export default class PrimitiveReader { + protected p = 0 + constructor(protected readonly buf: Uint8Array) {} + + readUint() { + var r = 0, s = 1, b; + do { + b = this.buf[this.p++]; + r += (b & 0x7F) * s; + s *= 128; + } while (b >= 0x80) + return r; + } + + readInt() { + let u = this.readUint(); + if (u % 2) { + u = (u + 1) / -2; + } else { + u = u / 2; + } + return u; + } + + readString() { + var l = this.readUint(); + return new TextDecoder().decode(this.buf.subarray(this.p, this.p+=l)); + } + + readBoolean() { + return !!this.buf[this.p++]; + } + skip(n: number) { + this.p += n; + } +} diff --git a/frontend/app/player/MessageDistributor/StatedScreen/Screen/BaseScreen.ts b/frontend/app/player/MessageDistributor/StatedScreen/Screen/BaseScreen.ts index 6801e0c42..e48416cf2 100644 --- a/frontend/app/player/MessageDistributor/StatedScreen/Screen/BaseScreen.ts +++ b/frontend/app/player/MessageDistributor/StatedScreen/Screen/BaseScreen.ts @@ -1,23 +1,23 @@ -import Marker from './Marker'; -import Cursor from './Cursor'; -import Inspector from './Inspector'; import styles from './screen.css'; import { getState } from '../../../store'; import type { Point } from './types'; -export const INITIAL_STATE: { + +export interface State { width: number; height: number; -} = { +} + +export const INITIAL_STATE: State = { width: 0, height: 0, } -export default class BaseScreen { +export default abstract class BaseScreen { + public readonly overlay: HTMLDivElement; private readonly iframe: HTMLIFrameElement; - public readonly overlay: HTMLDivElement; private readonly _screen: HTMLDivElement; protected parentElement: HTMLElement | null = null; constructor() { @@ -30,11 +30,21 @@ export default class BaseScreen { this.overlay = overlay; const screen = document.createElement('div'); + + setTimeout(function() { + iframe.contentDocument?.addEventListener('mousemove', function() { + overlay.style.display = 'block'; + }) + + overlay.addEventListener('contextmenu', function() { + overlay.style.display = 'none'; + }) + }, 10) + screen.className = styles.screen; screen.appendChild(iframe); screen.appendChild(overlay); this._screen = screen; - } attach(parentElement: HTMLElement) { @@ -58,8 +68,18 @@ export default class BaseScreen { return this.iframe.contentDocument; } - _getInternalCoordinates({ x, y }: Point): Point { - const { x: overlayX, y: overlayY, width } = this.overlay.getBoundingClientRect(); + private boundingRect: DOMRect | null = null; + private getBoundingClientRect(): DOMRect { + //if (this.boundingRect === null) { + return this.boundingRect = this.overlay.getBoundingClientRect(); // expensive operation? + //} + //return this.boundingRect; + } + + getInternalCoordinates({ x, y }: Point): Point { + const { x: overlayX, y: overlayY, width } = this.getBoundingClientRect(); + //console.log("x y ", x,y,'ovx y', overlayX, overlayY, width) + const screenWidth = this.overlay.offsetWidth; const scale = screenWidth / width; @@ -88,11 +108,11 @@ export default class BaseScreen { } getElementFromPoint(point: Point): Element | null { - return this.getElementFromInternalPoint(this._getInternalCoordinates(point)); + return this.getElementFromInternalPoint(this.getInternalCoordinates(point)); } getElementsFromPoint(point: Point): Element[] { - return this.getElementsFromInternalPoint(this._getInternalCoordinates(point)); + return this.getElementsFromInternalPoint(this.getInternalCoordinates(point)); } display(flag: boolean = true) { @@ -120,8 +140,13 @@ export default class BaseScreen { this._screen.style.height = height + 'px'; this.iframe.style.width = width + 'px'; this.iframe.style.height = height + 'px'; + + this.boundingRect = this.overlay.getBoundingClientRect(); + } + + scale = () => { // TODO: solve classes inheritance issues in typescript + this._scale(); } - scale = () => this._scale() clean() { diff --git a/frontend/app/player/MessageDistributor/StatedScreen/Screen/Inspector.js b/frontend/app/player/MessageDistributor/StatedScreen/Screen/Inspector.js index e6014853f..98ba5ec0f 100644 --- a/frontend/app/player/MessageDistributor/StatedScreen/Screen/Inspector.js +++ b/frontend/app/player/MessageDistributor/StatedScreen/Screen/Inspector.js @@ -9,7 +9,7 @@ export default class Inspector { this.marker = marker; } - _onMouseMove = (e) => { + _onMouseMove = (e) => { // const { overlay } = this.screen; // if (!overlay.contains(e.target)) { // return; @@ -21,7 +21,8 @@ export default class Inspector { if (target === this.marker.target) { return; } - this.marker.mark(target); + + this.marker.mark(target); } _onOverlayLeave = () => { diff --git a/frontend/app/player/MessageDistributor/StatedScreen/Screen/Marker.js b/frontend/app/player/MessageDistributor/StatedScreen/Screen/Marker.js index 3670021ed..68e0489a8 100644 --- a/frontend/app/player/MessageDistributor/StatedScreen/Screen/Marker.js +++ b/frontend/app/player/MessageDistributor/StatedScreen/Screen/Marker.js @@ -3,10 +3,20 @@ import styles from './marker.css'; export default class Marker { _target = null; _selector = null; + _tooltip = null; constructor(overlay, screen) { this.screen = screen; + this._tooltip = document.createElement('div') + this._tooltip.className = styles.tooltip; + this._tooltip.appendChild(document.createElement('div')) + + const htmlStr = document.createElement('div') + htmlStr.innerHTML = "Right-click \> Inspect for more details." + this._tooltip.appendChild(htmlStr) + + const marker = document.createElement('div'); marker.className = styles.marker; const markerL = document.createElement('div'); @@ -21,6 +31,8 @@ export default class Marker { marker.appendChild(markerR); marker.appendChild(markerT); marker.appendChild(markerB); + + marker.appendChild(this._tooltip) overlay.appendChild(marker); this._marker = marker; @@ -72,6 +84,25 @@ export default class Marker { this.redraw(); } + getTagString(tag) { + const attrs = tag.attributes + let str = `${tag.tagName.toLowerCase()}` + + for (let i = 0; i < attrs.length; i++) { + let k = attrs[i] + const attribute = k.name + if (attribute === 'class') { + str += `${'.' + k.value.split(' ').join('.')}` + } + + if (attribute === 'id') { + str += `${'#' + k.value.split(' ').join('#')}` + } + } + + return str; + } + redraw() { if (this._selector) { this._autodefineTarget(); @@ -86,6 +117,8 @@ export default class Marker { this._marker.style.top = rect.top + 'px'; this._marker.style.width = rect.width + 'px'; this._marker.style.height = rect.height + 'px'; + + this._tooltip.firstChild.innerHTML = this.getTagString(this._target); } } \ No newline at end of file diff --git a/frontend/app/player/MessageDistributor/StatedScreen/Screen/Screen.ts b/frontend/app/player/MessageDistributor/StatedScreen/Screen/Screen.ts index 91b789ac1..321dc21b0 100644 --- a/frontend/app/player/MessageDistributor/StatedScreen/Screen/Screen.ts +++ b/frontend/app/player/MessageDistributor/StatedScreen/Screen/Screen.ts @@ -5,7 +5,7 @@ import styles from './screen.css'; import { getState } from '../../../store'; import BaseScreen from './BaseScreen'; -export { INITIAL_STATE } from './BaseScreen'; +export { INITIAL_STATE, State } from './BaseScreen'; export default class Screen extends BaseScreen { private cursor: Cursor; @@ -39,8 +39,8 @@ export default class Screen extends BaseScreen { this.marker = new Marker(this.substitutor.overlay, this.substitutor); this.inspector = new Inspector(this.substitutor, this.marker); //this.inspector.addClickListener(clickCallback, true); - this.substitutor.attach(this.parentElement); - } + this.substitutor.attach(this.parentElement); + } this.substitutor.display(false); @@ -67,7 +67,7 @@ export default class Screen extends BaseScreen { this.substitutor.display(true); return doc; } - + disableInspector() { if (this.substitutor) { const doc = this.substitutor.document; diff --git a/frontend/app/player/MessageDistributor/StatedScreen/Screen/marker.css b/frontend/app/player/MessageDistributor/StatedScreen/Screen/marker.css index 31843c212..fe88efc9e 100644 --- a/frontend/app/player/MessageDistributor/StatedScreen/Screen/marker.css +++ b/frontend/app/player/MessageDistributor/StatedScreen/Screen/marker.css @@ -2,8 +2,9 @@ display: none; position: absolute; background: rgba(40, 40, 100, .3); + pointer-events: none; } -.marker div { +.marker div:not(.tooltip, .tooltip > div) { position: absolute; background-image: linear-gradient(45deg, #00d 25%, #fff 25%, #fff 75%, #00d 75%, #00d), linear-gradient(45deg, #00d 25%, #fff 25%, #fff 75%, #00d 75%, #00d); @@ -32,4 +33,30 @@ left: -100vw; right: -100vw; height: 1px; +} + +.tooltip { + position: absolute; + left: 0; + bottom: 100%; + padding: 15px; + box-shadow: 2px 2px 1px rgba(40, 40, 100, .3); + z-index: 999; + border-radius: 3px; + background-color: #202124; + min-width: 400px; + font-size: 20px !important; + + & div:first-child { + max-width: 600px; + height: 22px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + & div:last-child { + font-size: 18px; + margin-top: 10px; + color: $tealx; + } } \ No newline at end of file diff --git a/frontend/app/player/MessageDistributor/StatedScreen/Screen/screen.css b/frontend/app/player/MessageDistributor/StatedScreen/Screen/screen.css index c4ff803b7..6f5da4549 100644 --- a/frontend/app/player/MessageDistributor/StatedScreen/Screen/screen.css +++ b/frontend/app/player/MessageDistributor/StatedScreen/Screen/screen.css @@ -9,6 +9,7 @@ .iframe { position: absolute; border: none; + background: whilte; } .overlay { position: absolute; @@ -16,5 +17,5 @@ left: 0; right: 0; bottom: 0; - z-index: 10; + z-index: 10; } \ No newline at end of file diff --git a/frontend/app/player/MessageDistributor/StatedScreen/StatedScreen.js b/frontend/app/player/MessageDistributor/StatedScreen/StatedScreen.js deleted file mode 100644 index f80a89db1..000000000 --- a/frontend/app/player/MessageDistributor/StatedScreen/StatedScreen.js +++ /dev/null @@ -1,39 +0,0 @@ -import Screen, { INITIAL_STATE as SUPER_INITIAL_STATE } from './Screen'; -import { update, getState } from '../../store'; - -export const INITIAL_STATE = { - ...SUPER_INITIAL_STATE, - messagesLoading: false, - cssLoading: false, - disconnected: false, - userPageLoading: false, -} - -export default class StatedScreen extends Screen { - - setMessagesLoading(messagesLoading) { - this.display(!messagesLoading); - update({ messagesLoading }); - } - - setCSSLoading(cssLoading) { - this.displayFrame(!cssLoading); - update({ cssLoading }); - } - - setDisconnected(disconnected) { - if (!getState().live) return; //? - this.display(!disconnected); - update({ disconnected }); - } - - setUserPageLoading(userPageLoading) { - this.display(!userPageLoading); - update({ userPageLoading }); - } - - setSize({ height, width }) { - update({ width, height }); - this.scale(); - } -} \ No newline at end of file diff --git a/frontend/app/player/MessageDistributor/StatedScreen/StatedScreen.ts b/frontend/app/player/MessageDistributor/StatedScreen/StatedScreen.ts new file mode 100644 index 000000000..50147f90a --- /dev/null +++ b/frontend/app/player/MessageDistributor/StatedScreen/StatedScreen.ts @@ -0,0 +1,54 @@ +import Screen, { INITIAL_STATE as SUPER_INITIAL_STATE, State as SuperState } from './Screen'; +import { update, getState } from '../../store'; + + +export interface State extends SuperState { + messagesLoading: boolean, + cssLoading: boolean, + disconnected: boolean, + userPageLoading: boolean, +} + +export const INITIAL_STATE: State = { + ...SUPER_INITIAL_STATE, + messagesLoading: false, + cssLoading: false, + disconnected: false, + userPageLoading: false, +} + +export default class StatedScreen extends Screen { + constructor() { super(); } + + setMessagesLoading(messagesLoading: boolean) { + // @ts-ignore + this.display(!messagesLoading); + update({ messagesLoading }); + } + + setCSSLoading(cssLoading: boolean) { + // @ts-ignore + + this.displayFrame(!cssLoading); + update({ cssLoading }); + } + + setDisconnected(disconnected: boolean) { + if (!getState().live) return; //? + // @ts-ignore + this.display(!disconnected); + update({ disconnected }); + } + + setUserPageLoading(userPageLoading: boolean) { + // @ts-ignore + this.display(!userPageLoading); + update({ userPageLoading }); + } + + setSize({ height, width }: { height: number, width: number }) { + update({ width, height }); + // @ts-ignore + this.scale(); + } +} \ No newline at end of file diff --git a/frontend/app/player/MessageDistributor/StatedScreen/index.js b/frontend/app/player/MessageDistributor/StatedScreen/index.ts similarity index 100% rename from frontend/app/player/MessageDistributor/StatedScreen/index.js rename to frontend/app/player/MessageDistributor/StatedScreen/index.ts diff --git a/frontend/app/player/MessageDistributor/Timed.js b/frontend/app/player/MessageDistributor/Timed.js deleted file mode 100644 index 9c0c28b52..000000000 --- a/frontend/app/player/MessageDistributor/Timed.js +++ /dev/null @@ -1,5 +0,0 @@ -// @flow -import type { Message } from './messages'; - -export type Timed = { +time: number }; -export type TimedMessage = Timed & Message; diff --git a/frontend/app/player/MessageDistributor/Timed.ts b/frontend/app/player/MessageDistributor/Timed.ts new file mode 100644 index 000000000..e0a1d6a82 --- /dev/null +++ b/frontend/app/player/MessageDistributor/Timed.ts @@ -0,0 +1,5 @@ +import type { Message } from './messages'; + +export interface Timed { readonly time: number }; +export interface Indexed { readonly _index: number }; // TODO: remove dash (evwrywhere) +export type TimedMessage = Timed & Message; diff --git a/frontend/app/player/MessageDistributor/managers/ActivityManager.js b/frontend/app/player/MessageDistributor/managers/ActivityManager.js deleted file mode 100644 index 9881ecac1..000000000 --- a/frontend/app/player/MessageDistributor/managers/ActivityManager.js +++ /dev/null @@ -1,43 +0,0 @@ -import ListWalker from './ListWalker'; - - -class SkipInterval { - constructor({ start = 0, end = 0 }) { - this.start = start; - this.end = end; - } - get time(): number { - return this.start; - } - contains(ts) { - return ts > this.start && ts < this.end; - } -} - - -export default class ActivityManager extends ListWalker { - #endTime: number = 0; - #minInterval: number = 0; - #lastActivity: number = 0; - constructor(duration: number) { - super(); - this.#endTime = duration; - this.#minInterval = duration * 0.1; - } - - updateAcctivity(time: number) { - if (time - this.#lastActivity >= this.#minInterval) { - this.add(new SkipInterval({ start: this.#lastActivity, end: time })); - } - this.#lastActivity = time; - } - - end() { - if (this.#endTime - this.#lastActivity >= this.#minInterval) { - this.add(new SkipInterval({ start: this.#lastActivity, end: this.#endTime })); - } - - } - - -} \ No newline at end of file diff --git a/frontend/app/player/MessageDistributor/managers/ActivityManager.ts b/frontend/app/player/MessageDistributor/managers/ActivityManager.ts new file mode 100644 index 000000000..ba17d295e --- /dev/null +++ b/frontend/app/player/MessageDistributor/managers/ActivityManager.ts @@ -0,0 +1,42 @@ +import ListWalker from './ListWalker'; + + +class SkipIntervalCls { + constructor(private readonly start = 0, private readonly end = 0) {} + + get time(): number { + return this.start; + } + contains(ts) { + return ts > this.start && ts < this.end; + } +} + +export type SkipInterval = InstanceType; + + +export default class ActivityManager extends ListWalker { + private endTime: number = 0; + private minInterval: number = 0; + private lastActivity: number = 0; + constructor(duration: number) { + super(); + this.endTime = duration; + this.minInterval = duration * 0.1; + } + + updateAcctivity(time: number) { + if (time - this.lastActivity >= this.minInterval) { + this.add(new SkipIntervalCls(this.lastActivity, time)); + } + this.lastActivity = time; + } + + end() { + if (this.endTime - this.lastActivity >= this.minInterval) { + this.add(new SkipIntervalCls(this.lastActivity, this.endTime)); + } + + } + +} \ No newline at end of file diff --git a/frontend/app/player/MessageDistributor/managers/AssistManager.ts b/frontend/app/player/MessageDistributor/managers/AssistManager.ts new file mode 100644 index 000000000..c6bd8c46f --- /dev/null +++ b/frontend/app/player/MessageDistributor/managers/AssistManager.ts @@ -0,0 +1,365 @@ +import type Peer from 'peerjs'; +import type { DataConnection, MediaConnection } from 'peerjs'; +import type MessageDistributor from '../MessageDistributor'; +import type { TimedMessage } from '../Timed'; +import type { Message } from '../messages' +import { ID_TP_MAP } from '../messages'; +import store from 'App/store'; + +import { update, getState } from '../../store'; + + +export enum CallingState { + Requesting, + True, + False, +}; + +export enum ConnectionStatus { + Connecting, + Connected, + Inactive, + Disconnected, + Error, +}; + + +export function getStatusText(status: ConnectionStatus): string { + switch(status) { + case ConnectionStatus.Connecting: + return "Connecting..."; + case ConnectionStatus.Connected: + return ""; + case ConnectionStatus.Inactive: + return "Client tab is inactive"; + case ConnectionStatus.Disconnected: + return "Disconnected"; + case ConnectionStatus.Error: + return "Something went wrong. Try to reload the page."; + } +} + +export interface State { + calling: CallingState, + peerConnectionStatus: ConnectionStatus, +} + +export const INITIAL_STATE: State = { + calling: CallingState.False, + peerConnectionStatus: ConnectionStatus.Connecting, +} + +const MAX_RECONNECTION_COUNT = 4; + + +function resolveURL(baseURL: string, relURL: string): string { + if (relURL.startsWith('#') || relURL === "") { + return relURL; + } + return new URL(relURL, baseURL).toString(); +} + + +var match = /bar/.exec("foobar"); +const re1 = /url\(("[^"]*"|'[^']*'|[^)]*)\)/g +const re2 = /@import "(.*?)"/g +function cssUrlsIndex(css: string): Array<[number, number]> { + const idxs: Array<[number, number]> = []; + const i1 = css.matchAll(re1); + // @ts-ignore + for (let m of i1) { + // @ts-ignore + const s: number = m.index + m[0].indexOf(m[1]); + const e: number = s + m[1].length; + idxs.push([s, e]); + } + const i2 = css.matchAll(re2); + // @ts-ignore + for (let m of i2) { + // @ts-ignore + const s = m.index + m[0].indexOf(m[1]); + const e = s + m[1].length; + idxs.push([s, e]) + } + return idxs; +} +function unquote(str: string): [string, string] { + str = str.trim(); + if (str.length <= 2) { + return [str, ""] + } + if (str[0] == '"' && str[str.length-1] == '"') { + return [ str.substring(1, str.length-1), "\""]; + } + if (str[0] == '\'' && str[str.length-1] == '\'') { + return [ str.substring(1, str.length-1), "'" ]; + } + return [str, ""] +} +function rewriteCSSLinks(css: string, rewriter: (rawurl: string) => string): string { + for (let idx of cssUrlsIndex(css)) { + const f = idx[0] + const t = idx[1] + const [ rawurl, q ] = unquote(css.substring(f, t)); + css = css.substring(0,f) + q + rewriter(rawurl) + q + css.substring(t); + } + return css +} + +function resolveCSS(baseURL: string, css: string): string { + return rewriteCSSLinks(css, rawurl => resolveURL(baseURL, rawurl)); +} + + +export default class AssistManager { + constructor(private session, private md: MessageDistributor) {} + + private get peerID(): string { + return `${this.session.projectKey}-${this.session.sessionId}` + } + + private peer: Peer | null = null; + connectionAttempts: number = 0; + connect() { + if (this.peer != null) { + console.error("AssistManager: trying to connect more than once"); + return; + } + this.md.setMessagesLoading(true); + import('peerjs').then(({ default: Peer }) => { + // @ts-ignore + const peer = new Peer({ + // @ts-ignore + host: new URL(window.ENV.API_EDP).host, + path: '/assist', + port: location.protocol === 'https:' ? 443 : 80, + }); + this.peer = peer; + peer.on('error', e => { + if (e.type === 'peer-unavailable') { + if (this.peer && this.connectionAttempts++ < MAX_RECONNECTION_COUNT) { + update({ peerConnectionStatus: ConnectionStatus.Connecting }) + this.connectToPeer(); + } else { + update({ peerConnectionStatus: ConnectionStatus.Disconnected }); + this.dataCheckIntervalID && clearInterval(this.dataCheckIntervalID); + } + } else { + console.error(`PeerJS error (on peer). Type ${e.type}`, e); + update({ peerConnectionStatus: ConnectionStatus.Error }) + } + }) + peer.on("open", me => { + this.connectToPeer(); + }); + }); + } + + private dataCheckIntervalID: ReturnType | undefined; + private connectToPeer() { + if (!this.peer) { return; } + update({ peerConnectionStatus: ConnectionStatus.Connecting }) + const id = this.peerID; + console.log("trying to connect to", id) + const conn = this.peer.connect(id, { serialization: 'json', reliable: true}); + + conn.on('open', () => { + console.log("peer connected") + + let i = 0; + let firstMessage = true; + conn.on('data', (data) => { + if (!Array.isArray(data)) { return this.handleCommand(data); } + if (firstMessage) { + firstMessage = false; + this.md.setMessagesLoading(false); + update({ peerConnectionStatus: ConnectionStatus.Connected }) + } + + let time = 0; + let ts0 = 0; + (data as Array).forEach(msg => { + + // TODO: more appropriate way to do it. + if (msg._id === 60) { + // @ts-ignore + if (msg.name === 'src' || msg.name === 'href') { + // @ts-ignore + msg.value = resolveURL(msg.baseURL, msg.value); + // @ts-ignore + } else if (msg.name === 'style') { + // @ts-ignore + msg.value = resolveCSS(msg.baseURL, msg.value); + } + msg._id = 12; + } else if (msg._id === 61) { // "SetCSSDataURLBased" + // @ts-ignore + msg.data = resolveCSS(msg.baseURL, msg.data); + msg._id = 15; + } else if (msg._id === 67) { // "insert_rule" + // @ts-ignore + msg.rule = resolveCSS(msg.baseURL, msg.rule); + msg._id = 37; + } + + + msg.tp = ID_TP_MAP[msg._id]; // _id goes from tracker + + if (msg.tp === "timestamp") { + ts0 = ts0 || msg.timestamp + time = msg.timestamp - ts0; + return; + } + const tMsg: TimedMessage = Object.assign(msg, { + time, + _index: i, + }); + this.md.distributeMessage(tMsg, i++); + }); + }); + }); + + + const onDataClose = () => { + this.initiateCallEnd(); + this.md.setMessagesLoading(true); + update({ peerConnectionStatus: ConnectionStatus.Connecting }); + console.log('closed peer conn. Reconnecting...') + this.connectToPeer(); + } + + this.dataCheckIntervalID = setInterval(() => { + if (!this.dataConnection && getState().peerConnectionStatus === ConnectionStatus.Connected) { + onDataClose(); + } + }, 3000); + conn.on('close', onDataClose);// Does it work ? + conn.on("error", (e) => { + console.log("PeerJS connection error", e); + update({ peerConnectionStatus: ConnectionStatus.Error }); + }) + } + + + private get dataConnection(): DataConnection | undefined { + return this.peer?.connections[this.peerID]?.find(c => c.type === 'data' && c.open); + } + + private get callConnection(): MediaConnection | undefined { + return this.peer?.connections[this.peerID]?.find(c => c.type === 'media' && c.open); + } + + private send(data: any) { + this.dataConnection?.send(data); + } + + + private onCallEnd: null | (()=>void) = null; + private onReject: null | (()=>void) = null; + private forceCallEnd() { + this.callConnection?.close(); + } + private notifyCallEnd() { + const dataConn = this.dataConnection; + if (dataConn) { + console.log("notifyCallEnd send") + dataConn.send("call_end"); + } + } + private initiateCallEnd = () => { + console.log('initiateCallEnd') + this.forceCallEnd(); + this.notifyCallEnd(); + this.onCallEnd?.(); + } + + private onTrackerCallEnd = () => { + this.forceCallEnd(); + if (getState().calling === CallingState.Requesting) { + this.onReject?.(); + } + this.onCallEnd?.(); + } + + + private handleCommand(command: string) { + switch (command) { + case "unload": + this.onTrackerCallEnd(); + this.dataConnection?.close(); + return; + case "call_end": + this.onTrackerCallEnd(); + return; + case "call_error": + this.onTrackerCallEnd(); + update({ peerConnectionStatus: ConnectionStatus.Error }); + return; + } + } + + private onMouseMove = (e: MouseEvent ): void => { + const conn = this.dataConnection; + if (!conn) { return; } + // @ts-ignore ??? + const data = this.md.getInternalCoordinates(e); + conn.send({ x: Math.round(data.x), y: Math.round(data.y) }); + } + + call(localStream: MediaStream, onStream: (s: MediaStream)=>void, onCallEnd: () => void, onReject: () => void, onError?: ()=> void): null | Function { + if (!this.peer || getState().calling !== CallingState.False) { return null; } + + update({ calling: CallingState.Requesting }); + console.log('calling...') + + const call = this.peer.call(this.peerID, localStream); + call.on('stream', stream => { + update({ calling: CallingState.True }); + onStream(stream); + this.send({ + name: store.getState().getIn([ 'user', 'account', 'name']), + }); + + // @ts-ignore ?? + this.md.overlay.addEventListener("mousemove", this.onMouseMove) + }); + + this.onCallEnd = () => { + onCallEnd(); + // @ts-ignore ?? + this.md.overlay.removeEventListener("mousemove", this.onMouseMove); + update({ calling: CallingState.False }); + this.onCallEnd = null; + } + + call.on("close", this.onCallEnd); + call.on("error", (e) => { + console.error("PeerJS error (on call):", e) + this.initiateCallEnd?.(); + onError?.(); + }); + + // const intervalID = setInterval(() => { + // if (!call.open && getState().calling === CallingState.True) { + // this.onCallEnd?.(); + // clearInterval(intervalID); + // } + // }, 5000); + + window.addEventListener("beforeunload", this.initiateCallEnd) + + return this.initiateCallEnd; + } + + clear() { + console.log('clearing', this.peerID) + this.initiateCallEnd(); + this.dataCheckIntervalID && clearInterval(this.dataCheckIntervalID); + this.dataConnection?.close(); + console.log("destroying peer...") + this.peer?.destroy(); + this.peer = null; + } +} + + diff --git a/frontend/app/player/MessageDistributor/managers/DOMManager.js b/frontend/app/player/MessageDistributor/managers/DOMManager.js deleted file mode 100644 index d2faf4b68..000000000 --- a/frontend/app/player/MessageDistributor/managers/DOMManager.js +++ /dev/null @@ -1,283 +0,0 @@ -//@flow -import type StatedScreen from '../StatedScreen'; -import type { Message, SetNodeScroll, CreateElementNode } from '../messages'; -import type { TimedMessage } from '../Timed'; - -import logger from 'App/logger'; -import StylesManager from './StylesManager'; -import ListWalker from './ListWalker'; - -const IGNORED_ATTRS = [ "autocomplete", "name" ]; - -const ATTR_NAME_REGEXP = /([^\t\n\f \/>"'=]+)/; // regexp costs ~ - -export default class DOMManager extends ListWalker { - #isMobile: boolean; - #screen: StatedScreen; - // #prop compiles to method that costs mor than strict property call. - _nl: Array = []; - _isLink: Array = []; // Optimisations - _bodyId: number = -1; - _postponedBodyMessage: ?CreateElementNode = null; - #nodeScrollManagers: Array> = []; - - #stylesManager: StylesManager; - - #startTime: number; - - constructor(screen: StatedScreen, isMobile: boolean, startTime: number) { - super(); - this.#startTime = startTime; - this.#isMobile = isMobile; - this.#screen = screen; - this.#stylesManager = new StylesManager(screen); - } - - get time(): number { - return this.#startTime; - } - - add(m: TimedMessage): void { - switch (m.tp) { - case "set_node_scroll": - if (!this.#nodeScrollManagers[ m.id ]) { - this.#nodeScrollManagers[ m.id ] = new ListWalker(); - } - this.#nodeScrollManagers[ m.id ].add(m); - return; - //case "css_insert_rule": // || //set_css_data ??? - //case "css_delete_rule": - // (m.tp === "set_node_attribute" && this._isLink[ m.id ] && m.key === "href")) { - // this.#stylesManager.add(m); - // return; - default: - if (m.tp === "create_element_node") { - switch(m.tag) { - case "LINK": - this._isLink[ m.id ] = true; - break; - case "BODY": - this._bodyId = m.id; // Can be several body nodes at one document session? - break; - } - } else if (m.tp === "set_node_attribute" && - (IGNORED_ATTRS.includes(m.key) || !ATTR_NAME_REGEXP.test(m.key))) { - logger.log("Ignorring message: ", m) - return; // Ignoring... - } - super.add(m); - } - - } - - _removeBodyScroll(id: number): void { - if (this.#isMobile && this._bodyId === id) { - this._nl[ id ].style.overflow = "hidden"; - } - } - - // May be make it as a message on message add? - _removeAutocomplete({ id, tag }: { id: number, tag: string }): boolean { - const node = this._nl[ id ]; - if ([ "FORM", "TEXTAREA", "SELECT" ].includes(tag)) { - node.setAttribute("autocomplete", "off"); - return true; - } - if (tag === "INPUT") { - node.setAttribute("autocomplete", "new-password"); - return true; - } - return false; - } - - // type = NodeMessage ? - _insertNode({ parentID, id, index }: { parentID: number, id: number, index: number }): void { - if (!this._nl[ id ]) { - logger.error("Insert error. Node not found", id); - return; - } - if (!this._nl[ parentID ]) { - logger.error("Insert error. Parent node not found", parentID); - return; - } - // WHAT if text info contains some rules and the ordering is just wrong??? - if ((this._nl[ parentID ] instanceof HTMLStyleElement) && // TODO: correct ordering OR filter in tracker - this._nl[ parentID ].sheet && - this._nl[ parentID ].sheet.cssRules && - this._nl[ parentID ].sheet.cssRules.length > 0) { - logger.log("Trying to insert child to style tag with virtual rules: ", this._nl[ parentID ], this._nl[ id ]); - return; - } - - const childNodes = this._nl[ parentID ].childNodes; - if (!childNodes) { - logger.error("Node has no childNodes", this._nl[ parentID ]); - return; - } - this._nl[ parentID ] - .insertBefore(this._nl[ id ], childNodes[ index ]); - } - - #applyMessage: (Message => void) = msg => { - let node; - switch (msg.tp) { - case "create_document": - this.#screen.document.open(); - this.#screen.document.write(`${ msg.doctype || "" }`); - this.#screen.document.close(); - const fRoot = this.#screen.document.documentElement; - fRoot.innerText = ''; - //this._nl[ 0 ] = fRoot; // vs - this._nl = [ fRoot ]; - - // the last load event I can control - //if (this.document.fonts) { - // this.document.fonts.onloadingerror = () => this.marker.redraw(); - // this.document.fonts.onloadingdone = () => this.marker.redraw(); - //} - - //this.#screen.setDisconnected(false); - this.#stylesManager.reset(); - break; - case "create_text_node": - this._nl[ msg.id ] = document.createTextNode(''); - this._insertNode(msg); - break; - case "create_element_node": - if (msg.svg) { - this._nl[ msg.id ] = document.createElementNS('http://www.w3.org/2000/svg', msg.tag); - } else { - this._nl[ msg.id ] = document.createElement(msg.tag); - } - if (this._bodyId === msg.id) { - this._postponedBodyMessage = msg; - } else { - this._insertNode(msg); - } - this._removeBodyScroll(msg.id); - this._removeAutocomplete(msg); - break; - case "move_node": - this._insertNode(msg); - break; - case "remove_node": - if (!this._nl[ msg.id ]) { logger.error("Node not found", msg); break; } - if (!this._nl[ msg.id ].parentElement) { logger.error("Parent node not found", msg); break; } - this._nl[ msg.id ].parentElement.removeChild(this._nl[ msg.id ]); - break; - case "set_node_attribute": - let { id, name, value } = msg; - node = this._nl[ id ]; - if (!node) { logger.error("Node not found", msg); break; } - if (this._isLink[ id ] && name === "href") { - if (value.startsWith(window.ENV.ASSETS_HOST)) { // Hack for queries in rewrited urls - value = value.replace("?", "%3F"); - } - this.#stylesManager.setStyleHandlers(node, value); - } - try { - node.setAttribute(name, value); - } catch(e) { - logger.error(e, msg); - } - this._removeBodyScroll(msg.id); - break; - case "remove_node_attribute": - if (!this._nl[ msg.id ]) { logger.error("Node not found", msg); break; } - try { - this._nl[ msg.id ].removeAttribute(msg.name); - } catch(e) { - logger.error(e, msg); - } - break; - case "set_input_value": - if (!this._nl[ msg.id ]) { logger.error("Node not found", msg); break; } - const val = msg.mask > 0 ? '*'.repeat(msg.mask) : msg.value; - this._nl[ msg.id ].value = val; - break; - case "set_input_checked": - if (!this._nl[ msg.id ]) { logger.error("Node not found", msg); break; } - this._nl[ msg.id ].checked = msg.checked; - break; - case "set_node_data": - case "set_css_data": - if (!this._nl[ msg.id ]) { logger.error("Node not found", msg); break; } - this._nl[ msg.id ].data = msg.data; - break; - case "css_insert_rule": - if (!this._nl[ msg.id ]) { logger.error("Node not found", msg); break; } - if (!(this._nl[ msg.id ] instanceof HTMLStyleElement) // link or null - || this._nl[ msg.id ].sheet == null) { - logger.warn("Non-style node in CSS rules message (or sheet is null)", msg); - - // prev version fallback (TODO: delete on 30.10.20) - let styleSheet = this.#screen.document.styleSheets[ msg.id ]; - if (!styleSheet) { - styleSheet = this.#screen.document.styleSheets[0]; - } - if (!styleSheet) { - logger.log("Old-fasion insert rule: No stylesheet found;", msg); - break; - } - try { - styleSheet.insertRule(msg.rule, msg.index); - } catch(e) { - logger.log("Old-fasion insert rule:", e, msg); - styleSheet.insertRule(msg.rule); - } - // - - break; - } - try { - this._nl[ msg.id ].sheet.insertRule(msg.rule, msg.index) - } catch (e) { - logger.warn(e, msg) - this._nl[ msg.id ].sheet.insertRule(msg.rule) - } - break; - case "css_delete_rule": - if (!this._nl[ msg.id ]) { logger.error("Node not found", msg); break; } - if (!this._nl[ msg.id ] instanceof HTMLStyleElement) { // link or null - logger.warn("Non-style node in CSS rules message", msg); - break; - } - try { - this._nl[ msg.id ].sheet.deleteRule(msg.rule, msg.index) - } catch (e) { - logger.warn(e, msg) - } - break; - //not sure what to do with this one - //case "disconnected": - //setTimeout(() => { - // if last one - //if (this.msgs[ this.msgs.length - 1 ] === msg) { - // this.setDisconnected(true); - // } - //}, 10000); - //break; - } - } - - moveReady(t: number): Promise { - this.moveApply(t, this.#applyMessage); // This function autoresets pointer if necessary (better name?) - this.#nodeScrollManagers.forEach(manager => { - const msg = manager.moveToLast(t); // TODO: reset (?) - if (!!msg && !!this._nl[msg.id]) { - this._nl[msg.id].scrollLeft = msg.x; - this._nl[msg.id].scrollTop = msg.y; - } - }); - - /* Mount body as late as possible */ - if (this._postponedBodyMessage != null) { - this._insertNode(this._postponedBodyMessage) - this._postponedBodyMessage = null; - } - - // Thinkabout (read): css preload - // What if we go back before it is ready? We'll have two handlres? - return this.#stylesManager.moveReady(t); - } -} \ No newline at end of file diff --git a/frontend/app/player/MessageDistributor/managers/DOMManager.ts b/frontend/app/player/MessageDistributor/managers/DOMManager.ts new file mode 100644 index 000000000..1d723a923 --- /dev/null +++ b/frontend/app/player/MessageDistributor/managers/DOMManager.ts @@ -0,0 +1,277 @@ +import type StatedScreen from '../StatedScreen'; +import type { Message, SetNodeScroll, CreateElementNode } from '../messages'; +import type { TimedMessage } from '../Timed'; + +import logger from 'App/logger'; +import StylesManager from './StylesManager'; +import ListWalker from './ListWalker'; +import type { Timed }from '../Timed'; + +const IGNORED_ATTRS = [ "autocomplete", "name" ]; + +const ATTR_NAME_REGEXP = /([^\t\n\f \/>"'=]+)/; // regexp costs ~ + +export default class DOMManager extends ListWalker { + private isMobile: boolean; + private screen: StatedScreen; + private nl: Array = []; + private isLink: Array = []; // Optimisations + private bodyId: number = -1; + private postponedBodyMessage: CreateElementNode | null = null; + private nodeScrollManagers: Array> = []; + + private stylesManager: StylesManager; + + private startTime: number; + + constructor(screen: StatedScreen, isMobile: boolean, startTime: number) { + super(); + this.startTime = startTime; + this.isMobile = isMobile; + this.screen = screen; + this.stylesManager = new StylesManager(screen); + } + + get time(): number { + return this.startTime; + } + + add(m: TimedMessage): void { + switch (m.tp) { + case "set_node_scroll": + if (!this.nodeScrollManagers[ m.id ]) { + this.nodeScrollManagers[ m.id ] = new ListWalker(); + } + this.nodeScrollManagers[ m.id ].add(m); + return; + //case "css_insert_rule": // || //set_css_data ??? + //case "css_delete_rule": + // (m.tp === "set_node_attribute" && this.isLink[ m.id ] && m.key === "href")) { + // this.stylesManager.add(m); + // return; + default: + if (m.tp === "create_element_node") { + switch(m.tag) { + case "LINK": + this.isLink[ m.id ] = true; + break; + case "BODY": + this.bodyId = m.id; // Can be several body nodes at one document session? + break; + } + } else if (m.tp === "set_node_attribute" && + (IGNORED_ATTRS.includes(m.name) || !ATTR_NAME_REGEXP.test(m.name))) { + logger.log("Ignorring message: ", m) + return; // Ignoring... + } + super.add(m); + } + + } + + private removeBodyScroll(id: number): void { + if (this.isMobile && this.bodyId === id) { + (this.nl[ id ] as HTMLBodyElement).style.overflow = "hidden"; + } + } + + // May be make it as a message on message add? + private removeAutocomplete({ id, tag }: CreateElementNode): boolean { + const node = this.nl[ id ] as HTMLElement; + if ([ "FORM", "TEXTAREA", "SELECT" ].includes(tag)) { + node.setAttribute("autocomplete", "off"); + return true; + } + if (tag === "INPUT") { + node.setAttribute("autocomplete", "new-password"); + return true; + } + return false; + } + + // type = NodeMessage ? + private insertNode({ parentID, id, index }: { parentID: number, id: number, index: number }): void { + if (!this.nl[ id ]) { + logger.error("Insert error. Node not found", id); + return; + } + if (!this.nl[ parentID ]) { + logger.error("Insert error. Parent node not found", parentID); + return; + } + // WHAT if text info contains some rules and the ordering is just wrong??? + const el = this.nl[ parentID ] + if ((el instanceof HTMLStyleElement) && // TODO: correct ordering OR filter in tracker + el.sheet && + el.sheet.cssRules && + el.sheet.cssRules.length > 0) { + logger.log("Trying to insert child to style tag with virtual rules: ", this.nl[ parentID ], this.nl[ id ]); + return; + } + + const childNodes = this.nl[ parentID ].childNodes; + if (!childNodes) { + logger.error("Node has no childNodes", this.nl[ parentID ]); + return; + } + this.nl[ parentID ] + .insertBefore(this.nl[ id ], childNodes[ index ]); + } + + private applyMessage = (msg: Message): void => { + let node; + switch (msg.tp) { + case "create_document": + // @ts-ignore ?? + this.screen.document.open(); + // @ts-ignore ?? + this.screen.document.write(`${ msg.doctype || "" }`); + // @ts-ignore ?? + this.screen.document.close(); + // @ts-ignore ?? + const fRoot = this.screen.document.documentElement; + fRoot.innerText = ''; + this.nl = [ fRoot ]; + + // the last load event I can control + //if (this.document.fonts) { + // this.document.fonts.onloadingerror = () => this.marker.redraw(); + // this.document.fonts.onloadingdone = () => this.marker.redraw(); + //} + + //this.screen.setDisconnected(false); + this.stylesManager.reset(); + break; + case "create_text_node": + this.nl[ msg.id ] = document.createTextNode(''); + this.insertNode(msg); + break; + case "create_element_node": + if (msg.svg) { + this.nl[ msg.id ] = document.createElementNS('http://www.w3.org/2000/svg', msg.tag); + } else { + this.nl[ msg.id ] = document.createElement(msg.tag); + } + if (this.bodyId === msg.id) { + this.postponedBodyMessage = msg; + } else { + this.insertNode(msg); + } + this.removeBodyScroll(msg.id); + this.removeAutocomplete(msg); + break; + case "move_node": + this.insertNode(msg); + break; + case "remove_node": + node = this.nl[ msg.id ] + if (!node) { logger.error("Node not found", msg); break; } + if (!node.parentElement) { logger.error("Parent node not found", msg); break; } + node.parentElement.removeChild(node); + break; + case "set_node_attribute": + let { id, name, value } = msg; + node = this.nl[ id ]; + if (!node) { logger.error("Node not found", msg); break; } + if (this.isLink[ id ] && name === "href") { + // @ts-ignore TODO: global ENV type + if (value.startsWith(window.ENV.ASSETS_HOST)) { // Hack for queries in rewrited urls + value = value.replace("?", "%3F"); + } + this.stylesManager.setStyleHandlers(node, value); + } + try { + node.setAttribute(name, value); + } catch(e) { + logger.error(e, msg); + } + this.removeBodyScroll(msg.id); + break; + case "remove_node_attribute": + if (!this.nl[ msg.id ]) { logger.error("Node not found", msg); break; } + try { + (this.nl[ msg.id ] as HTMLElement).removeAttribute(msg.name); + } catch(e) { + logger.error(e, msg); + } + break; + case "set_input_value": + if (!this.nl[ msg.id ]) { logger.error("Node not found", msg); break; } + const val = msg.mask > 0 ? '*'.repeat(msg.mask) : msg.value; + (this.nl[ msg.id ] as HTMLInputElement).value = val; + break; + case "set_input_checked": + node = this.nl[ msg.id ]; + if (!node) { logger.error("Node not found", msg); break; } + (node as HTMLInputElement).checked = msg.checked; + break; + case "set_node_data": + case "set_css_data": + if (!this.nl[ msg.id ]) { logger.error("Node not found", msg); break; } + // @ts-ignore + this.nl[ msg.id ].data = msg.data; + break; + case "css_insert_rule": + node = this.nl[ msg.id ]; + if (!node) { logger.error("Node not found", msg); break; } + if (!(node instanceof HTMLStyleElement) // link or null + || node.sheet == null) { + logger.warn("Non-style node in CSS rules message (or sheet is null)", msg); + break; + } + try { + node.sheet.insertRule(msg.rule, msg.index) + } catch (e) { + logger.warn(e, msg) + node.sheet.insertRule(msg.rule) + } + break; + case "css_delete_rule": + node = this.nl[ msg.id ]; + if (!node) { logger.error("Node not found", msg); break; } + if (!(node instanceof HTMLStyleElement) // link or null + || node.sheet == null) { + logger.warn("Non-style node in CSS rules message (or sheet is null)", msg); + break; + } + try { + node.sheet.deleteRule(msg.index) + } catch (e) { + logger.warn(e, msg) + } + break; + //not sure what to do with this one + //case "disconnected": + //setTimeout(() => { + // if last one + //if (this.msgs[ this.msgs.length - 1 ] === msg) { + // this.setDisconnected(true); + // } + //}, 10000); + //break; + } + } + + moveReady(t: number): Promise { + this.moveApply(t, this.applyMessage); // This function autoresets pointer if necessary (better name?) + this.nodeScrollManagers.forEach(manager => { + const msg = manager.moveToLast(t); // TODO: reset (?) + + if (!!msg && !!this.nl[msg.id]) { + const node = this.nl[msg.id] as HTMLElement; + node.scrollLeft = msg.x; + node.scrollTop = msg.y; + } + }); + + /* Mount body as late as possible */ + if (this.postponedBodyMessage != null) { + this.insertNode(this.postponedBodyMessage) + this.postponedBodyMessage = null; + } + + // Thinkabout (read): css preload + // What if we go back before it is ready? We'll have two handlres? + return this.stylesManager.moveReady(t); + } +} \ No newline at end of file diff --git a/frontend/app/player/MessageDistributor/managers/ListWalker.js b/frontend/app/player/MessageDistributor/managers/ListWalker.ts similarity index 89% rename from frontend/app/player/MessageDistributor/managers/ListWalker.js rename to frontend/app/player/MessageDistributor/managers/ListWalker.ts index 0354a365c..6283ff3ab 100644 --- a/frontend/app/player/MessageDistributor/managers/ListWalker.js +++ b/frontend/app/player/MessageDistributor/managers/ListWalker.ts @@ -1,8 +1,6 @@ -//@flow - import type { Timed } from '../Timed'; -export default class ListWalker { +export default class ListWalker { // Optimisation: #prop compiles to method that costs mor than strict property call. _p = 0; _list: Array; @@ -15,7 +13,7 @@ export default class ListWalker { } append(m: T): void { - if (this.length > 0 && m.time < this.last.time) { + if (this.length > 0 && this.last && m.time < this.last.time) { console.error("Trying to append message with the less time then the list tail: ", m); } this._list.push(m); @@ -26,6 +24,7 @@ export default class ListWalker { } sort(comparator): void { + // @ts-ignore this._list.sort((m1,m2) => comparator(m1,m2) || (m1._index - m2._index) ); // indexes for sort stability (TODO: fix types???) } @@ -100,10 +99,10 @@ export default class ListWalker { Assumed that the current message is already handled so if pointer doesn't cahnge is returned. */ - moveToLast(t: number, index: ?number): ?T { - let key = "time"; //TODO + moveToLast(t: number, index?: number): T | null { + let key: string = "time"; //TODO let val = t; - if (index != null) { + if (index) { key = "_index"; val = index; } @@ -117,7 +116,7 @@ export default class ListWalker { this._p--; changed = true; } - return changed ? this._list[ this._p - 1 ] : undefined; + return changed ? this._list[ this._p - 1 ] : null; } // moveToLastByIndex(i: number): ?T { @@ -133,7 +132,7 @@ export default class ListWalker { // return changed ? this._list[ this._p - 1 ] : undefined; // } - moveApply(t: number, fn: T => void): void { + moveApply(t: number, fn: (T) => void): void { // Applying only in increment order for now if (t < this.timeNow) { this.reset(); diff --git a/frontend/app/player/MessageDistributor/managers/MouseManager.js b/frontend/app/player/MessageDistributor/managers/MouseManager.ts similarity index 50% rename from frontend/app/player/MessageDistributor/managers/MouseManager.js rename to frontend/app/player/MessageDistributor/managers/MouseManager.ts index 7352043c1..a86408823 100644 --- a/frontend/app/player/MessageDistributor/managers/MouseManager.js +++ b/frontend/app/player/MessageDistributor/managers/MouseManager.ts @@ -1,4 +1,3 @@ -//@flow import type StatedScreen from '../StatedScreen'; import type { MouseMove } from '../messages'; import type { Timed } from '../Timed'; @@ -10,32 +9,30 @@ type MouseMoveTimed = MouseMove & Timed; const HOVER_CLASS = "-openreplay-hover"; export default class MouseManager extends ListWalker { - #screen: StatedScreen; - #hoverElements: Array = []; + private hoverElements: Array = []; - constructor(screen: StatedScreen): void { - super(); - this.#screen = screen; - } + constructor(private screen: StatedScreen) {super();} - _updateHover(): void { - const curHoverElements = this.#screen.getCursorTargets(); - const diffAdd = curHoverElements.filter(elem => !this.#hoverElements.includes(elem)); - const diffRemove = this.#hoverElements.filter(elem => !curHoverElements.includes(elem)); - this.#hoverElements = curHoverElements; + private updateHover(): void { + // @ts-ignore TODO + const curHoverElements = this.screen.getCursorTargets(); + const diffAdd = curHoverElements.filter(elem => !this.hoverElements.includes(elem)); + const diffRemove = this.hoverElements.filter(elem => !curHoverElements.includes(elem)); + this.hoverElements = curHoverElements; diffAdd.forEach(elem => elem.classList.add(HOVER_CLASS)); diffRemove.forEach(elem => elem.classList.remove(HOVER_CLASS)); } reset(): void { - this.#hoverElements = []; + this.hoverElements = []; } move(t: number) { const lastMouseMove = this.moveToLast(t); if (!!lastMouseMove){ - this.#screen.cursor.move(lastMouseMove); - this._updateHover(); + // @ts-ignore TODO + this.screen.cursor.move(lastMouseMove); + this.updateHover(); } } diff --git a/frontend/app/player/MessageDistributor/managers/PerformanceTrackManager.js b/frontend/app/player/MessageDistributor/managers/PerformanceTrackManager.js deleted file mode 100644 index 005b7004a..000000000 --- a/frontend/app/player/MessageDistributor/managers/PerformanceTrackManager.js +++ /dev/null @@ -1,104 +0,0 @@ -// @flow - -import type { PerformanceTrack, SetPageVisibility } from '../messages'; -import type { Timed } from '../Timed'; - -import ListWalker from './ListWalker'; - -type TimedPerformanceTrack = Timed & PerformanceTrack; -type TimedSetPageVisibility = Timed & SetPageVisibility; - -type PerformanceChartPoint = { - time: number, - usedHeap: number, - totalHeap: number, - fps: ?number, - cpu: ?number, - nodesCount: number, -} - -export default class PerformanceTrackManager extends ListWalker { - #chart: Array = []; - #isHidden: boolean = false; - #timeCorrection: number = 0; - #heapAvaliable: boolean = false; - #fpsAvaliable: boolean = false; - #cpuAvaliable: boolean = false; - #prevTime: ?number = null; - #prevNodesCount: number = 0; - - - add(msg: TimedPerformanceTrack):void { - let fps = null; - let cpu = null; - if (!this.#isHidden && this.#prevTime != null) { - let timePassed = msg.time - this.#prevTime + this.#timeCorrection; - - if (timePassed > 0 && msg.frames >= 0) { - if (msg.frames > 0) { this.#fpsAvaliable = true; } - fps = msg.frames*1e3/timePassed; // Multiply by 1e3 as time in ms; - fps = Math.min(fps,60); // What if 120? TODO: alert if more than 60 - if (this.#chart.length === 1) { - this.#chart[0].fps = fps; - } - } - - if (timePassed > 0 && msg.ticks >= 0) { - this.#cpuAvaliable = true; - let tickRate = msg.ticks * 30 / timePassed; - if (tickRate > 1) { - tickRate = 1; - } - cpu = Math.round(100 - tickRate*100); - if (this.#chart.length === 1) { - this.#chart[0].cpu = cpu; - } - } - } - - this.#prevTime = msg.time; - this.#timeCorrection = 0 - - this.#heapAvaliable = this.#heapAvaliable || msg.usedJSHeapSize > 0; - this.#chart.push({ - usedHeap: msg.usedJSHeapSize, - totalHeap: msg.totalJSHeapSize, - fps, - cpu, - time: msg.time, - nodesCount: this.#prevNodesCount, - }); - super.add(msg); - } - - setCurrentNodesCount(count: number) { - this.#prevNodesCount = count; - if (this.#chart.length > 0) { - this.#chart[ this.#chart.length - 1 ].nodesCount = count; - } - } - - handleVisibility(msg: TimedSetPageVisibility):void { - if (!this.#isHidden && msg.hidden && this.#prevTime != null) { - this.#timeCorrection = msg.time - this.#prevTime; - } - if (this.#isHidden && !msg.hidden) { - this.#prevTime = msg.time; - } - this.#isHidden = msg.hidden; - } - - get chartData(): Array { - return this.#chart; - } - - get avaliability(): { cpu: boolean, fps: boolean, heap: boolean } { - return { - cpu: this.#cpuAvaliable, - fps: this.#fpsAvaliable, - heap: this.#heapAvaliable, - nodes: true, - } - } - -} \ No newline at end of file diff --git a/frontend/app/player/MessageDistributor/managers/PerformanceTrackManager.ts b/frontend/app/player/MessageDistributor/managers/PerformanceTrackManager.ts new file mode 100644 index 000000000..1b11813b2 --- /dev/null +++ b/frontend/app/player/MessageDistributor/managers/PerformanceTrackManager.ts @@ -0,0 +1,102 @@ +import type { PerformanceTrack, SetPageVisibility } from '../messages'; +import type { Timed } from '../Timed'; + +import ListWalker from './ListWalker'; + +type TimedPerformanceTrack = Timed & PerformanceTrack; +type TimedSetPageVisibility = Timed & SetPageVisibility; + +export type PerformanceChartPoint = { + time: number, + usedHeap: number, + totalHeap: number, + fps?: number, + cpu?: number, + nodesCount: number, +} + +export default class PerformanceTrackManager extends ListWalker { + private chart: Array = []; + private isHidden: boolean = false; + private timeCorrection: number = 0; + private heapAvaliable: boolean = false; + private fpsAvaliable: boolean = false; + private cpuAvaliable: boolean = false; + private prevTime: number | null = null; + private prevNodesCount: number = 0; + + + add(msg: TimedPerformanceTrack):void { + let fps: number | undefined; + let cpu: number | undefined; + if (!this.isHidden && this.prevTime != null) { + let timePassed = msg.time - this.prevTime + this.timeCorrection; + + if (timePassed > 0 && msg.frames >= 0) { + if (msg.frames > 0) { this.fpsAvaliable = true; } + fps = msg.frames*1e3/timePassed; // Multiply by 1e3 as time in ms; + fps = Math.min(fps,60); // What if 120? TODO: alert if more than 60 + if (this.chart.length === 1) { + this.chart[0].fps = fps; + } + } + + if (timePassed > 0 && msg.ticks >= 0) { + this.cpuAvaliable = true; + let tickRate = msg.ticks * 30 / timePassed; + if (tickRate > 1) { + tickRate = 1; + } + cpu = Math.round(100 - tickRate*100); + if (this.chart.length === 1) { + this.chart[0].cpu = cpu; + } + } + } + + this.prevTime = msg.time; + this.timeCorrection = 0 + + this.heapAvaliable = this.heapAvaliable || msg.usedJSHeapSize > 0; + this.chart.push({ + usedHeap: msg.usedJSHeapSize, + totalHeap: msg.totalJSHeapSize, + fps, + cpu, + time: msg.time, + nodesCount: this.prevNodesCount, + }); + super.add(msg); + } + + setCurrentNodesCount(count: number) { + this.prevNodesCount = count; + if (this.chart.length > 0) { + this.chart[ this.chart.length - 1 ].nodesCount = count; + } + } + + handleVisibility(msg: TimedSetPageVisibility):void { + if (!this.isHidden && msg.hidden && this.prevTime != null) { + this.timeCorrection = msg.time - this.prevTime; + } + if (this.isHidden && !msg.hidden) { + this.prevTime = msg.time; + } + this.isHidden = msg.hidden; + } + + get chartData(): Array { + return this.chart; + } + + get avaliability(): { cpu: boolean, fps: boolean, heap: boolean, nodes: boolean } { + return { + cpu: this.cpuAvaliable, + fps: this.fpsAvaliable, + heap: this.heapAvaliable, + nodes: true, + } + } + +} \ No newline at end of file diff --git a/frontend/app/player/MessageDistributor/messages.js b/frontend/app/player/MessageDistributor/messages.js deleted file mode 100644 index 6ff087eaf..000000000 --- a/frontend/app/player/MessageDistributor/messages.js +++ /dev/null @@ -1,596 +0,0 @@ -// Auto-generated, do not edit - -import { readUint, readInt, readString, readBoolean } from './readPrimitives' - - -export type Timestamp = { - tp: "timestamp", - timestamp: number, -} - -export type SessionDisconnect = { - tp: "session_disconnect", - timestamp: number, -} - -export type SetPageLocation = { - tp: "set_page_location", - url: string, - referrer: string, - navigationStart: number, -} - -export type SetViewportSize = { - tp: "set_viewport_size", - width: number, - height: number, -} - -export type SetViewportScroll = { - tp: "set_viewport_scroll", - x: number, - y: number, -} - -export type CreateDocument = { - tp: "create_document", - -} - -export type CreateElementNode = { - tp: "create_element_node", - id: number, - parentID: number, - index: number, - tag: string, - svg: boolean, -} - -export type CreateTextNode = { - tp: "create_text_node", - id: number, - parentID: number, - index: number, -} - -export type MoveNode = { - tp: "move_node", - id: number, - parentID: number, - index: number, -} - -export type RemoveNode = { - tp: "remove_node", - id: number, -} - -export type SetNodeAttribute = { - tp: "set_node_attribute", - id: number, - name: string, - value: string, -} - -export type RemoveNodeAttribute = { - tp: "remove_node_attribute", - id: number, - name: string, -} - -export type SetNodeData = { - tp: "set_node_data", - id: number, - data: string, -} - -export type SetCssData = { - tp: "set_css_data", - id: number, - data: string, -} - -export type SetNodeScroll = { - tp: "set_node_scroll", - id: number, - x: number, - y: number, -} - -export type SetInputValue = { - tp: "set_input_value", - id: number, - value: string, - mask: number, -} - -export type SetInputChecked = { - tp: "set_input_checked", - id: number, - checked: boolean, -} - -export type MouseMove = { - tp: "mouse_move", - x: number, - y: number, -} - -export type ConsoleLog = { - tp: "console_log", - level: string, - value: string, -} - -export type PerformanceTrack = { - tp: "performance_track", - frames: number, - ticks: number, - totalJSHeapSize: number, - usedJSHeapSize: number, -} - -export type ConnectionInformation = { - tp: "connection_information", - downlink: number, - type: string, -} - -export type SetPageVisibility = { - tp: "set_page_visibility", - hidden: boolean, -} - -export type CssInsertRule = { - tp: "css_insert_rule", - id: number, - rule: string, - index: number, -} - -export type CssDeleteRule = { - tp: "css_delete_rule", - id: number, - index: number, -} - -export type Fetch = { - tp: "fetch", - method: string, - url: string, - request: string, - response: string, - status: number, - timestamp: number, - duration: number, -} - -export type Profiler = { - tp: "profiler", - name: string, - duration: number, - args: string, - result: string, -} - -export type OTable = { - tp: "o_table", - key: string, - value: string, -} - -export type Redux = { - tp: "redux", - action: string, - state: string, - duration: number, -} - -export type Vuex = { - tp: "vuex", - mutation: string, - state: string, -} - -export type MobX = { - tp: "mob_x", - type: string, - payload: string, -} - -export type NgRx = { - tp: "ng_rx", - action: string, - state: string, - duration: number, -} - -export type GraphQl = { - tp: "graph_ql", - operationKind: string, - operationName: string, - variables: string, - response: string, -} - -export type LongTask = { - tp: "long_task", - timestamp: number, - duration: number, - context: number, - containerType: number, - containerSrc: string, - containerId: string, - containerName: string, -} - -export type TechnicalInfo = { - tp: "technical_info", - type: string, - value: string, -} - -export type IosSessionStart = { - tp: "ios_session_start", - timestamp: number, - projectID: number, - trackerVersion: string, - revID: string, - userUUID: string, - userOS: string, - userOSVersion: string, - userDevice: string, - userDeviceType: string, - userCountry: string, -} - -export type IosCustomEvent = { - tp: "ios_custom_event", - timestamp: number, - length: number, - name: string, - payload: string, -} - -export type IosClickEvent = { - tp: "ios_click_event", - timestamp: number, - length: number, - label: string, - x: number, - y: number, -} - -export type IosPerformanceEvent = { - tp: "ios_performance_event", - timestamp: number, - length: number, - name: string, - value: number, -} - -export type IosLog = { - tp: "ios_log", - timestamp: number, - length: number, - severity: string, - content: string, -} - -export type IosNetworkCall = { - tp: "ios_network_call", - timestamp: number, - length: number, - duration: number, - headers: string, - body: string, - url: string, - success: boolean, - method: string, - status: number, -} - - -export type Message = Timestamp | SessionDisconnect | SetPageLocation | SetViewportSize | SetViewportScroll | CreateDocument | CreateElementNode | CreateTextNode | MoveNode | RemoveNode | SetNodeAttribute | RemoveNodeAttribute | SetNodeData | SetCssData | SetNodeScroll | SetInputValue | SetInputChecked | MouseMove | ConsoleLog | PerformanceTrack | ConnectionInformation | SetPageVisibility | CssInsertRule | CssDeleteRule | Fetch | Profiler | OTable | Redux | Vuex | MobX | NgRx | GraphQl | LongTask | TechnicalInfo | IosSessionStart | IosCustomEvent | IosClickEvent | IosPerformanceEvent | IosLog | IosNetworkCall; - -export default function (buf: Uint8Array, p: number): [Message, number] { - const msg = {}; - let r; - switch (buf[p++]) { - - case 0: - (msg:Timestamp).tp = "timestamp"; - r = readUint(buf, p); msg.timestamp = r[0]; p = r[1]; - break; - - case 2: - (msg:SessionDisconnect).tp = "session_disconnect"; - r = readUint(buf, p); msg.timestamp = r[0]; p = r[1]; - break; - - case 4: - (msg:SetPageLocation).tp = "set_page_location"; - r = readString(buf, p); msg.url = r[0]; p = r[1]; -r = readString(buf, p); msg.referrer = r[0]; p = r[1]; -r = readUint(buf, p); msg.navigationStart = r[0]; p = r[1]; - break; - - case 5: - (msg:SetViewportSize).tp = "set_viewport_size"; - r = readUint(buf, p); msg.width = r[0]; p = r[1]; -r = readUint(buf, p); msg.height = r[0]; p = r[1]; - break; - - case 6: - (msg:SetViewportScroll).tp = "set_viewport_scroll"; - r = readInt(buf, p); msg.x = r[0]; p = r[1]; -r = readInt(buf, p); msg.y = r[0]; p = r[1]; - break; - - case 7: - (msg:CreateDocument).tp = "create_document"; - - break; - - case 8: - (msg:CreateElementNode).tp = "create_element_node"; - r = readUint(buf, p); msg.id = r[0]; p = r[1]; -r = readUint(buf, p); msg.parentID = r[0]; p = r[1]; -r = readUint(buf, p); msg.index = r[0]; p = r[1]; -r = readString(buf, p); msg.tag = r[0]; p = r[1]; -r = readBoolean(buf, p); msg.svg = r[0]; p = r[1]; - break; - - case 9: - (msg:CreateTextNode).tp = "create_text_node"; - r = readUint(buf, p); msg.id = r[0]; p = r[1]; -r = readUint(buf, p); msg.parentID = r[0]; p = r[1]; -r = readUint(buf, p); msg.index = r[0]; p = r[1]; - break; - - case 10: - (msg:MoveNode).tp = "move_node"; - r = readUint(buf, p); msg.id = r[0]; p = r[1]; -r = readUint(buf, p); msg.parentID = r[0]; p = r[1]; -r = readUint(buf, p); msg.index = r[0]; p = r[1]; - break; - - case 11: - (msg:RemoveNode).tp = "remove_node"; - r = readUint(buf, p); msg.id = r[0]; p = r[1]; - break; - - case 12: - (msg:SetNodeAttribute).tp = "set_node_attribute"; - r = readUint(buf, p); msg.id = r[0]; p = r[1]; -r = readString(buf, p); msg.name = r[0]; p = r[1]; -r = readString(buf, p); msg.value = r[0]; p = r[1]; - break; - - case 13: - (msg:RemoveNodeAttribute).tp = "remove_node_attribute"; - r = readUint(buf, p); msg.id = r[0]; p = r[1]; -r = readString(buf, p); msg.name = r[0]; p = r[1]; - break; - - case 14: - (msg:SetNodeData).tp = "set_node_data"; - r = readUint(buf, p); msg.id = r[0]; p = r[1]; -r = readString(buf, p); msg.data = r[0]; p = r[1]; - break; - - case 15: - (msg:SetCssData).tp = "set_css_data"; - r = readUint(buf, p); msg.id = r[0]; p = r[1]; -r = readString(buf, p); msg.data = r[0]; p = r[1]; - break; - - case 16: - (msg:SetNodeScroll).tp = "set_node_scroll"; - r = readUint(buf, p); msg.id = r[0]; p = r[1]; -r = readInt(buf, p); msg.x = r[0]; p = r[1]; -r = readInt(buf, p); msg.y = r[0]; p = r[1]; - break; - - case 18: - (msg:SetInputValue).tp = "set_input_value"; - r = readUint(buf, p); msg.id = r[0]; p = r[1]; -r = readString(buf, p); msg.value = r[0]; p = r[1]; -r = readInt(buf, p); msg.mask = r[0]; p = r[1]; - break; - - case 19: - (msg:SetInputChecked).tp = "set_input_checked"; - r = readUint(buf, p); msg.id = r[0]; p = r[1]; -r = readBoolean(buf, p); msg.checked = r[0]; p = r[1]; - break; - - case 20: - (msg:MouseMove).tp = "mouse_move"; - r = readUint(buf, p); msg.x = r[0]; p = r[1]; -r = readUint(buf, p); msg.y = r[0]; p = r[1]; - break; - - case 22: - (msg:ConsoleLog).tp = "console_log"; - r = readString(buf, p); msg.level = r[0]; p = r[1]; -r = readString(buf, p); msg.value = r[0]; p = r[1]; - break; - - case 49: - (msg:PerformanceTrack).tp = "performance_track"; - r = readInt(buf, p); msg.frames = r[0]; p = r[1]; -r = readInt(buf, p); msg.ticks = r[0]; p = r[1]; -r = readUint(buf, p); msg.totalJSHeapSize = r[0]; p = r[1]; -r = readUint(buf, p); msg.usedJSHeapSize = r[0]; p = r[1]; - break; - - case 54: - (msg:ConnectionInformation).tp = "connection_information"; - r = readUint(buf, p); msg.downlink = r[0]; p = r[1]; -r = readString(buf, p); msg.type = r[0]; p = r[1]; - break; - - case 55: - (msg:SetPageVisibility).tp = "set_page_visibility"; - r = readBoolean(buf, p); msg.hidden = r[0]; p = r[1]; - break; - - case 37: - (msg:CssInsertRule).tp = "css_insert_rule"; - r = readUint(buf, p); msg.id = r[0]; p = r[1]; -r = readString(buf, p); msg.rule = r[0]; p = r[1]; -r = readUint(buf, p); msg.index = r[0]; p = r[1]; - break; - - case 38: - (msg:CssDeleteRule).tp = "css_delete_rule"; - r = readUint(buf, p); msg.id = r[0]; p = r[1]; -r = readUint(buf, p); msg.index = r[0]; p = r[1]; - break; - - case 39: - (msg:Fetch).tp = "fetch"; - r = readString(buf, p); msg.method = r[0]; p = r[1]; -r = readString(buf, p); msg.url = r[0]; p = r[1]; -r = readString(buf, p); msg.request = r[0]; p = r[1]; -r = readString(buf, p); msg.response = r[0]; p = r[1]; -r = readUint(buf, p); msg.status = r[0]; p = r[1]; -r = readUint(buf, p); msg.timestamp = r[0]; p = r[1]; -r = readUint(buf, p); msg.duration = r[0]; p = r[1]; - break; - - case 40: - (msg:Profiler).tp = "profiler"; - r = readString(buf, p); msg.name = r[0]; p = r[1]; -r = readUint(buf, p); msg.duration = r[0]; p = r[1]; -r = readString(buf, p); msg.args = r[0]; p = r[1]; -r = readString(buf, p); msg.result = r[0]; p = r[1]; - break; - - case 41: - (msg:OTable).tp = "o_table"; - r = readString(buf, p); msg.key = r[0]; p = r[1]; -r = readString(buf, p); msg.value = r[0]; p = r[1]; - break; - - case 44: - (msg:Redux).tp = "redux"; - r = readString(buf, p); msg.action = r[0]; p = r[1]; -r = readString(buf, p); msg.state = r[0]; p = r[1]; -r = readUint(buf, p); msg.duration = r[0]; p = r[1]; - break; - - case 45: - (msg:Vuex).tp = "vuex"; - r = readString(buf, p); msg.mutation = r[0]; p = r[1]; -r = readString(buf, p); msg.state = r[0]; p = r[1]; - break; - - case 46: - (msg:MobX).tp = "mob_x"; - r = readString(buf, p); msg.type = r[0]; p = r[1]; -r = readString(buf, p); msg.payload = r[0]; p = r[1]; - break; - - case 47: - (msg:NgRx).tp = "ng_rx"; - r = readString(buf, p); msg.action = r[0]; p = r[1]; -r = readString(buf, p); msg.state = r[0]; p = r[1]; -r = readUint(buf, p); msg.duration = r[0]; p = r[1]; - break; - - case 48: - (msg:GraphQl).tp = "graph_ql"; - r = readString(buf, p); msg.operationKind = r[0]; p = r[1]; -r = readString(buf, p); msg.operationName = r[0]; p = r[1]; -r = readString(buf, p); msg.variables = r[0]; p = r[1]; -r = readString(buf, p); msg.response = r[0]; p = r[1]; - break; - - case 59: - (msg:LongTask).tp = "long_task"; - r = readUint(buf, p); msg.timestamp = r[0]; p = r[1]; -r = readUint(buf, p); msg.duration = r[0]; p = r[1]; -r = readUint(buf, p); msg.context = r[0]; p = r[1]; -r = readUint(buf, p); msg.containerType = r[0]; p = r[1]; -r = readString(buf, p); msg.containerSrc = r[0]; p = r[1]; -r = readString(buf, p); msg.containerId = r[0]; p = r[1]; -r = readString(buf, p); msg.containerName = r[0]; p = r[1]; - break; - - case 63: - (msg:TechnicalInfo).tp = "technical_info"; - r = readString(buf, p); msg.type = r[0]; p = r[1]; -r = readString(buf, p); msg.value = r[0]; p = r[1]; - break; - - case 90: - (msg:IosSessionStart).tp = "ios_session_start"; - r = readUint(buf, p); msg.timestamp = r[0]; p = r[1]; -r = readUint(buf, p); msg.projectID = r[0]; p = r[1]; -r = readString(buf, p); msg.trackerVersion = r[0]; p = r[1]; -r = readString(buf, p); msg.revID = r[0]; p = r[1]; -r = readString(buf, p); msg.userUUID = r[0]; p = r[1]; -r = readString(buf, p); msg.userOS = r[0]; p = r[1]; -r = readString(buf, p); msg.userOSVersion = r[0]; p = r[1]; -r = readString(buf, p); msg.userDevice = r[0]; p = r[1]; -r = readString(buf, p); msg.userDeviceType = r[0]; p = r[1]; -r = readString(buf, p); msg.userCountry = r[0]; p = r[1]; - break; - - case 93: - (msg:IosCustomEvent).tp = "ios_custom_event"; - r = readUint(buf, p); msg.timestamp = r[0]; p = r[1]; -r = readUint(buf, p); msg.length = r[0]; p = r[1]; -r = readString(buf, p); msg.name = r[0]; p = r[1]; -r = readString(buf, p); msg.payload = r[0]; p = r[1]; - break; - - case 100: - (msg:IosClickEvent).tp = "ios_click_event"; - r = readUint(buf, p); msg.timestamp = r[0]; p = r[1]; -r = readUint(buf, p); msg.length = r[0]; p = r[1]; -r = readString(buf, p); msg.label = r[0]; p = r[1]; -r = readUint(buf, p); msg.x = r[0]; p = r[1]; -r = readUint(buf, p); msg.y = r[0]; p = r[1]; - break; - - case 102: - (msg:IosPerformanceEvent).tp = "ios_performance_event"; - r = readUint(buf, p); msg.timestamp = r[0]; p = r[1]; -r = readUint(buf, p); msg.length = r[0]; p = r[1]; -r = readString(buf, p); msg.name = r[0]; p = r[1]; -r = readUint(buf, p); msg.value = r[0]; p = r[1]; - break; - - case 103: - (msg:IosLog).tp = "ios_log"; - r = readUint(buf, p); msg.timestamp = r[0]; p = r[1]; -r = readUint(buf, p); msg.length = r[0]; p = r[1]; -r = readString(buf, p); msg.severity = r[0]; p = r[1]; -r = readString(buf, p); msg.content = r[0]; p = r[1]; - break; - - case 105: - (msg:IosNetworkCall).tp = "ios_network_call"; - r = readUint(buf, p); msg.timestamp = r[0]; p = r[1]; -r = readUint(buf, p); msg.length = r[0]; p = r[1]; -r = readUint(buf, p); msg.duration = r[0]; p = r[1]; -r = readString(buf, p); msg.headers = r[0]; p = r[1]; -r = readString(buf, p); msg.body = r[0]; p = r[1]; -r = readString(buf, p); msg.url = r[0]; p = r[1]; -r = readBoolean(buf, p); msg.success = r[0]; p = r[1]; -r = readString(buf, p); msg.method = r[0]; p = r[1]; -r = readUint(buf, p); msg.status = r[0]; p = r[1]; - break; - - default: - let len; - [ _, p ] = readUint(buf, p); - [ len, p ] = readUint(buf, p); - return [null, p + len] // skip - //throw `Unknown type (${buf[p-1]})`; - } - return [msg, p]; -} diff --git a/frontend/app/player/MessageDistributor/messages.ts b/frontend/app/player/MessageDistributor/messages.ts new file mode 100644 index 000000000..286ec024e --- /dev/null +++ b/frontend/app/player/MessageDistributor/messages.ts @@ -0,0 +1,543 @@ +// Auto-generated, do not edit + +import PrimitiveReader from './PrimitiveReader'; + +export const ID_TP_MAP = { + + 0: "timestamp", + 4: "set_page_location", + 5: "set_viewport_size", + 6: "set_viewport_scroll", + 7: "create_document", + 8: "create_element_node", + 9: "create_text_node", + 10: "move_node", + 11: "remove_node", + 12: "set_node_attribute", + 13: "remove_node_attribute", + 14: "set_node_data", + 15: "set_css_data", + 16: "set_node_scroll", + 18: "set_input_value", + 19: "set_input_checked", + 20: "mouse_move", + 22: "console_log", + 37: "css_insert_rule", + 38: "css_delete_rule", + 39: "fetch_depricated", + 40: "profiler", + 41: "o_table", + 44: "redux", + 45: "vuex", + 46: "mob_x", + 47: "ng_rx", + 48: "graph_ql", + 49: "performance_track", + 54: "connection_information", + 55: "set_page_visibility", + 59: "long_task", + 68: "fetch", +} as const; + + +export interface Timestamp { + tp: "timestamp", + timestamp: number, +} + +export interface SetPageLocation { + tp: "set_page_location", + url: string, + referrer: string, + navigationStart: number, +} + +export interface SetViewportSize { + tp: "set_viewport_size", + width: number, + height: number, +} + +export interface SetViewportScroll { + tp: "set_viewport_scroll", + x: number, + y: number, +} + +export interface CreateDocument { + tp: "create_document", + +} + +export interface CreateElementNode { + tp: "create_element_node", + id: number, + parentID: number, + index: number, + tag: string, + svg: boolean, +} + +export interface CreateTextNode { + tp: "create_text_node", + id: number, + parentID: number, + index: number, +} + +export interface MoveNode { + tp: "move_node", + id: number, + parentID: number, + index: number, +} + +export interface RemoveNode { + tp: "remove_node", + id: number, +} + +export interface SetNodeAttribute { + tp: "set_node_attribute", + id: number, + name: string, + value: string, +} + +export interface RemoveNodeAttribute { + tp: "remove_node_attribute", + id: number, + name: string, +} + +export interface SetNodeData { + tp: "set_node_data", + id: number, + data: string, +} + +export interface SetCssData { + tp: "set_css_data", + id: number, + data: string, +} + +export interface SetNodeScroll { + tp: "set_node_scroll", + id: number, + x: number, + y: number, +} + +export interface SetInputValue { + tp: "set_input_value", + id: number, + value: string, + mask: number, +} + +export interface SetInputChecked { + tp: "set_input_checked", + id: number, + checked: boolean, +} + +export interface MouseMove { + tp: "mouse_move", + x: number, + y: number, +} + +export interface ConsoleLog { + tp: "console_log", + level: string, + value: string, +} + +export interface CssInsertRule { + tp: "css_insert_rule", + id: number, + rule: string, + index: number, +} + +export interface CssDeleteRule { + tp: "css_delete_rule", + id: number, + index: number, +} + +export interface FetchDepricated { + tp: "fetch_depricated", + method: string, + url: string, + request: string, + response: string, + status: number, + timestamp: number, + duration: number, +} + +export interface Profiler { + tp: "profiler", + name: string, + duration: number, + args: string, + result: string, +} + +export interface OTable { + tp: "o_table", + key: string, + value: string, +} + +export interface Redux { + tp: "redux", + action: string, + state: string, + duration: number, +} + +export interface Vuex { + tp: "vuex", + mutation: string, + state: string, +} + +export interface MobX { + tp: "mob_x", + type: string, + payload: string, +} + +export interface NgRx { + tp: "ng_rx", + action: string, + state: string, + duration: number, +} + +export interface GraphQl { + tp: "graph_ql", + operationKind: string, + operationName: string, + variables: string, + response: string, +} + +export interface PerformanceTrack { + tp: "performance_track", + frames: number, + ticks: number, + totalJSHeapSize: number, + usedJSHeapSize: number, +} + +export interface ConnectionInformation { + tp: "connection_information", + downlink: number, + type: string, +} + +export interface SetPageVisibility { + tp: "set_page_visibility", + hidden: boolean, +} + +export interface LongTask { + tp: "long_task", + timestamp: number, + duration: number, + context: number, + containerType: number, + containerSrc: string, + containerId: string, + containerName: string, +} + +export interface Fetch { + tp: "fetch", + method: string, + url: string, + request: string, + response: string, + status: number, + timestamp: number, + duration: number, + headers: string, +} + + +export type Message = Timestamp | SetPageLocation | SetViewportSize | SetViewportScroll | CreateDocument | CreateElementNode | CreateTextNode | MoveNode | RemoveNode | SetNodeAttribute | RemoveNodeAttribute | SetNodeData | SetCssData | SetNodeScroll | SetInputValue | SetInputChecked | MouseMove | ConsoleLog | CssInsertRule | CssDeleteRule | FetchDepricated | Profiler | OTable | Redux | Vuex | MobX | NgRx | GraphQl | PerformanceTrack | ConnectionInformation | SetPageVisibility | LongTask | Fetch; + +export default function (r: PrimitiveReader): Message | null { + switch (r.readUint()) { + + case 0: + return { + tp: ID_TP_MAP[0], + timestamp: r.readUint(), + }; + + case 4: + return { + tp: ID_TP_MAP[4], + url: r.readString(), + referrer: r.readString(), + navigationStart: r.readUint(), + }; + + case 5: + return { + tp: ID_TP_MAP[5], + width: r.readUint(), + height: r.readUint(), + }; + + case 6: + return { + tp: ID_TP_MAP[6], + x: r.readInt(), + y: r.readInt(), + }; + + case 7: + return { + tp: ID_TP_MAP[7], + + }; + + case 8: + return { + tp: ID_TP_MAP[8], + id: r.readUint(), + parentID: r.readUint(), + index: r.readUint(), + tag: r.readString(), + svg: r.readBoolean(), + }; + + case 9: + return { + tp: ID_TP_MAP[9], + id: r.readUint(), + parentID: r.readUint(), + index: r.readUint(), + }; + + case 10: + return { + tp: ID_TP_MAP[10], + id: r.readUint(), + parentID: r.readUint(), + index: r.readUint(), + }; + + case 11: + return { + tp: ID_TP_MAP[11], + id: r.readUint(), + }; + + case 12: + return { + tp: ID_TP_MAP[12], + id: r.readUint(), + name: r.readString(), + value: r.readString(), + }; + + case 13: + return { + tp: ID_TP_MAP[13], + id: r.readUint(), + name: r.readString(), + }; + + case 14: + return { + tp: ID_TP_MAP[14], + id: r.readUint(), + data: r.readString(), + }; + + case 15: + return { + tp: ID_TP_MAP[15], + id: r.readUint(), + data: r.readString(), + }; + + case 16: + return { + tp: ID_TP_MAP[16], + id: r.readUint(), + x: r.readInt(), + y: r.readInt(), + }; + + case 18: + return { + tp: ID_TP_MAP[18], + id: r.readUint(), + value: r.readString(), + mask: r.readInt(), + }; + + case 19: + return { + tp: ID_TP_MAP[19], + id: r.readUint(), + checked: r.readBoolean(), + }; + + case 20: + return { + tp: ID_TP_MAP[20], + x: r.readUint(), + y: r.readUint(), + }; + + case 22: + return { + tp: ID_TP_MAP[22], + level: r.readString(), + value: r.readString(), + }; + + case 37: + return { + tp: ID_TP_MAP[37], + id: r.readUint(), + rule: r.readString(), + index: r.readUint(), + }; + + case 38: + return { + tp: ID_TP_MAP[38], + id: r.readUint(), + index: r.readUint(), + }; + + case 39: + return { + tp: ID_TP_MAP[39], + method: r.readString(), + url: r.readString(), + request: r.readString(), + response: r.readString(), + status: r.readUint(), + timestamp: r.readUint(), + duration: r.readUint(), + }; + + case 40: + return { + tp: ID_TP_MAP[40], + name: r.readString(), + duration: r.readUint(), + args: r.readString(), + result: r.readString(), + }; + + case 41: + return { + tp: ID_TP_MAP[41], + key: r.readString(), + value: r.readString(), + }; + + case 44: + return { + tp: ID_TP_MAP[44], + action: r.readString(), + state: r.readString(), + duration: r.readUint(), + }; + + case 45: + return { + tp: ID_TP_MAP[45], + mutation: r.readString(), + state: r.readString(), + }; + + case 46: + return { + tp: ID_TP_MAP[46], + type: r.readString(), + payload: r.readString(), + }; + + case 47: + return { + tp: ID_TP_MAP[47], + action: r.readString(), + state: r.readString(), + duration: r.readUint(), + }; + + case 48: + return { + tp: ID_TP_MAP[48], + operationKind: r.readString(), + operationName: r.readString(), + variables: r.readString(), + response: r.readString(), + }; + + case 49: + return { + tp: ID_TP_MAP[49], + frames: r.readInt(), + ticks: r.readInt(), + totalJSHeapSize: r.readUint(), + usedJSHeapSize: r.readUint(), + }; + + case 54: + return { + tp: ID_TP_MAP[54], + downlink: r.readUint(), + type: r.readString(), + }; + + case 55: + return { + tp: ID_TP_MAP[55], + hidden: r.readBoolean(), + }; + + case 59: + return { + tp: ID_TP_MAP[59], + timestamp: r.readUint(), + duration: r.readUint(), + context: r.readUint(), + containerType: r.readUint(), + containerSrc: r.readString(), + containerId: r.readString(), + containerName: r.readString(), + }; + + case 68: + return { + tp: ID_TP_MAP[68], + method: r.readString(), + url: r.readString(), + request: r.readString(), + response: r.readString(), + status: r.readUint(), + timestamp: r.readUint(), + duration: r.readUint(), + headers: r.readString(), + }; + + default: + r.readUint(); // IOS skip timestamp + r.skip(r.readUint()); + return null; + } +} diff --git a/frontend/app/player/MessageDistributor/readPrimitives.js b/frontend/app/player/MessageDistributor/readPrimitives.js deleted file mode 100644 index 3279c08f3..000000000 --- a/frontend/app/player/MessageDistributor/readPrimitives.js +++ /dev/null @@ -1,31 +0,0 @@ -export function readUint(buf, p) { - var r = 0, s = 1, b; - do { - b = buf[p++]; - r += (b & 0x7F) * s; - s *= 128; - } while (b >= 0x80) - return [r, p]; -} - -export function readInt(buf, p) { - var r = readUint(buf, p); - if (r[0] % 2) { - r[0] = (r[0] + 1) / -2; - } else { - r[0] = r[0] / 2; - } - return r; -} - -export function readString(buf, p) { - var r = readUint(buf, p); - var f = r[1]; - r[1] += r[0]; - r[0] = new TextDecoder().decode(buf.subarray(f, r[1])); - return r; -} - -export function readBoolean(buf, p) { - return [!!buf[p], p+1]; -} diff --git a/frontend/app/player/Player.js b/frontend/app/player/Player.ts similarity index 75% rename from frontend/app/player/Player.js rename to frontend/app/player/Player.ts index e6a90fd2c..ffc36c022 100644 --- a/frontend/app/player/Player.js +++ b/frontend/app/player/Player.ts @@ -1,35 +1,41 @@ import { goTo as listsGoTo } from './lists'; import { update, getState } from './store'; -import MessageDistributor, { INITIAL_STATE as SUPER_INITIAL_STATE } from './MessageDistributor'; +import MessageDistributor, { INITIAL_STATE as SUPER_INITIAL_STATE, State as SuperState } from './MessageDistributor'; const fps = 60; const performance = window.performance || { now: Date.now.bind(Date) }; const requestAnimationFrame = window.requestAnimationFrame || window.webkitRequestAnimationFrame || + // @ts-ignore window.mozRequestAnimationFrame || + // @ts-ignore window.oRequestAnimationFrame || + // @ts-ignore window.msRequestAnimationFrame || (callback => window.setTimeout(() => { callback(performance.now()); }, 1000 / fps)); const cancelAnimationFrame = window.cancelAnimationFrame || + // @ts-ignore window.mozCancelAnimationFrame || window.clearTimeout; -const HIGHEST_SPEED = 3; +const HIGHEST_SPEED = 16; const SPEED_STORAGE_KEY = "__$player-speed$__"; const SKIP_STORAGE_KEY = "__$player-skip$__"; const SKIP_TO_ISSUE_STORAGE_KEY = "__$player-skip-to-issue$__"; const AUTOPLAY_STORAGE_KEY = "__$player-autoplay$__"; -const storedSpeed = +localStorage.getItem(SPEED_STORAGE_KEY); -const initialSpeed = [1,2,3].includes(storedSpeed) ? storedSpeed : 1; +const SHOW_EVENTS_STORAGE_KEY = "__$player-show-events$__"; +const storedSpeed: number = parseInt(localStorage.getItem(SPEED_STORAGE_KEY) || "") ; +const initialSpeed = [1,2,4,8,16].includes(storedSpeed) ? storedSpeed : 1; const initialSkip = !!localStorage.getItem(SKIP_STORAGE_KEY); const initialSkipToIssue = !!localStorage.getItem(SKIP_TO_ISSUE_STORAGE_KEY); const initialAutoplay = !!localStorage.getItem(AUTOPLAY_STORAGE_KEY); +const initialShowEvents = !!localStorage.getItem(SHOW_EVENTS_STORAGE_KEY); -export const INITIAL_STATE = { +export const INITIAL_STATE: SuperState = { ...SUPER_INITIAL_STATE, time: 0, playing: false, @@ -38,28 +44,30 @@ export const INITIAL_STATE = { inspectorMode: false, live: false, livePlay: false, -} +} as const; + export const INITIAL_NON_RESETABLE_STATE = { skip: initialSkip, skipToIssue: initialSkipToIssue, autoplay: initialAutoplay, speed: initialSpeed, + showEvents: initialShowEvents } export default class Player extends MessageDistributor { - _animationFrameRequestId = null; + private _animationFrameRequestId: number = 0; - _setTime(time, index) { + private _setTime(time: number, index?: number) { update({ time, completed: false, }); - this.move(time, index); + super.move(time, index); listsGoTo(time, index); } - _startAnimation() { + private _startAnimation() { let prevTime = getState().time; let animationPrevTime = performance.now(); @@ -86,10 +94,10 @@ export default class Player extends MessageDistributor { const skipInterval = skip && skipIntervals.find(si => si.contains(time)); // TODO: good skip by messages if (skipInterval) time = skipInterval.end; - const fmt = this.getFirstMessageTime(); + const fmt = super.getFirstMessageTime(); if (time < fmt) time = fmt; // ? - const lmt = this.getLastMessageTime(); + const lmt = super.getLastMessageTime(); if (livePlay && time < lmt) time = lmt; if (endTime < lmt) { update({ @@ -144,6 +152,9 @@ export default class Player extends MessageDistributor { } jump(time = getState().time, index) { + const { live } = getState(); + if (live) return; + if (getState().playing) { cancelAnimationFrame(this._animationFrameRequestId); // this._animationFrameRequestId = requestAnimationFrame(() => { @@ -161,7 +172,7 @@ export default class Player extends MessageDistributor { toggleSkip() { const skip = !getState().skip; - localStorage.setItem(SKIP_STORAGE_KEY, skip); + localStorage.setItem(SKIP_STORAGE_KEY, `${skip}`); update({ skip }); } @@ -174,43 +185,49 @@ export default class Player extends MessageDistributor { if (flag) { this.pause(); update({ inspectorMode: true }); - return this.enableInspector(clickCallback); + return super.enableInspector(clickCallback); } else { - this.disableInspector(); + super.disableInspector(); update({ inspectorMode: false }); } } toggleSkipToIssue() { const skipToIssue = !getState().skipToIssue; - localStorage.setItem(SKIP_TO_ISSUE_STORAGE_KEY, skipToIssue); + localStorage.setItem(SKIP_TO_ISSUE_STORAGE_KEY, `${skipToIssue}`); update({ skipToIssue }); } toggleAutoplay() { const autoplay = !getState().autoplay; - localStorage.setItem(AUTOPLAY_STORAGE_KEY, autoplay); + localStorage.setItem(AUTOPLAY_STORAGE_KEY, `${autoplay}`); update({ autoplay }); } + + toggleEvents() { + const showEvents = !getState().showEvents; + localStorage.setItem(SHOW_EVENTS_STORAGE_KEY, `${showEvents}`); + update({ showEvents }); + } - _updateSpeed(speed) { - localStorage.setItem(SPEED_STORAGE_KEY, speed); + _updateSpeed(speed: number) { + localStorage.setItem(SPEED_STORAGE_KEY, `${speed}`); update({ speed }); } toggleSpeed() { const { speed } = getState(); - this._updateSpeed(speed < HIGHEST_SPEED ? speed + 1 : 1); + this._updateSpeed(speed < HIGHEST_SPEED ? speed * 2 : 1); } speedUp() { const { speed } = getState(); - this._updateSpeed(Math.min(HIGHEST_SPEED, speed + 1)); + this._updateSpeed(Math.min(HIGHEST_SPEED, speed * 2)); } speedDown() { const { speed } = getState(); - this._updateSpeed(Math.max(1, speed - 1)); + this._updateSpeed(Math.max(1, speed/2)); } clean() { diff --git a/frontend/app/player/singletone.js b/frontend/app/player/singletone.js index 1e1f34234..63865de91 100644 --- a/frontend/app/player/singletone.js +++ b/frontend/app/player/singletone.js @@ -61,12 +61,14 @@ export const toggleSkip = initCheck((...args) => instance.toggleSkip(...args)); export const toggleSkipToIssue = initCheck((...args) => instance.toggleSkipToIssue(...args)); export const toggleAutoplay = initCheck((...args) => instance.toggleAutoplay(...args)); export const toggleSpeed = initCheck((...args) => instance.toggleSpeed(...args)); +export const toggleEvents = initCheck((...args) => instance.toggleEvents(...args)); export const speedUp = initCheck((...args) => instance.speedUp(...args)); export const speedDown = initCheck((...args) => instance.speedDown(...args)); export const attach = initCheck((...args) => instance.attach(...args)); export const markElement = initCheck((...args) => instance.marker && instance.marker.mark(...args)); export const scale = initCheck(() => instance.scale()); export const toggleInspectorMode = initCheck((...args) => instance.toggleInspectorMode(...args)); +export const callPeer = initCheck((...args) => instance.assistManager.call(...args)) export const Controls = { jump, @@ -75,7 +77,9 @@ export const Controls = { toggleSkip, toggleSkipToIssue, toggleAutoplay, + toggleEvents, toggleSpeed, speedUp, speedDown, + callPeer } diff --git a/frontend/app/svg/icons/camera-video-off.svg b/frontend/app/svg/icons/camera-video-off.svg new file mode 100644 index 000000000..17dcd971a --- /dev/null +++ b/frontend/app/svg/icons/camera-video-off.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/app/svg/icons/camera-video.svg b/frontend/app/svg/icons/camera-video.svg new file mode 100644 index 000000000..568193ff7 --- /dev/null +++ b/frontend/app/svg/icons/camera-video.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/app/svg/icons/chevron-double-left.svg b/frontend/app/svg/icons/chevron-double-left.svg new file mode 100644 index 000000000..7181fd111 --- /dev/null +++ b/frontend/app/svg/icons/chevron-double-left.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/app/svg/icons/chevron-double-right.svg b/frontend/app/svg/icons/chevron-double-right.svg new file mode 100644 index 000000000..73e1b352d --- /dev/null +++ b/frontend/app/svg/icons/chevron-double-right.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/app/svg/icons/controller.svg b/frontend/app/svg/icons/controller.svg new file mode 100644 index 000000000..15e777456 --- /dev/null +++ b/frontend/app/svg/icons/controller.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/app/svg/icons/headset.svg b/frontend/app/svg/icons/headset.svg new file mode 100644 index 000000000..c0b56fe8b --- /dev/null +++ b/frontend/app/svg/icons/headset.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/app/svg/icons/mic-mute.svg b/frontend/app/svg/icons/mic-mute.svg new file mode 100644 index 000000000..b88c405d2 --- /dev/null +++ b/frontend/app/svg/icons/mic-mute.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/app/svg/icons/mic.svg b/frontend/app/svg/icons/mic.svg new file mode 100644 index 000000000..ff4015706 --- /dev/null +++ b/frontend/app/svg/icons/mic.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/app/svg/icons/person.svg b/frontend/app/svg/icons/person.svg new file mode 100644 index 000000000..98de1b339 --- /dev/null +++ b/frontend/app/svg/icons/person.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/app/svg/icons/telephone-fill.svg b/frontend/app/svg/icons/telephone-fill.svg new file mode 100644 index 000000000..3eb871ed5 --- /dev/null +++ b/frontend/app/svg/icons/telephone-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/app/svg/icons/telephone.svg b/frontend/app/svg/icons/telephone.svg new file mode 100644 index 000000000..0ec6550a4 --- /dev/null +++ b/frontend/app/svg/icons/telephone.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/app/types/session/session.js b/frontend/app/types/session/session.js index 132afcc7d..3926c1901 100644 --- a/frontend/app/types/session/session.js +++ b/frontend/app/types/session/session.js @@ -26,6 +26,8 @@ function hashString(s: string): number { export default Record({ sessionId: '', siteId: '', + projectKey: '', + peerId: '', live: false, startedAt: 0, duration: 0, diff --git a/frontend/env.js b/frontend/env.js index bcac9228d..356a76427 100644 --- a/frontend/env.js +++ b/frontend/env.js @@ -13,15 +13,16 @@ const oss = { ORIGIN: () => 'window.location.origin', API_EDP: () => 'window.location.origin + "/api"', ASSETS_HOST: () => 'window.location.origin + "/assets"', - VERSION: '1.1.0', + VERSION: '1.2.0', SOURCEMAP: true, MINIO_ENDPOINT: process.env.MINIO_ENDPOINT, MINIO_PORT: process.env.MINIO_PORT, MINIO_USE_SSL: process.env.MINIO_USE_SSL, MINIO_ACCESS_KEY: process.env.MINIO_ACCESS_KEY, MINIO_SECRET_KEY: process.env.MINIO_SECRET_KEY, - TRACKER_VERSION: '3.0.3', // trackerInfo.version, + TRACKER_VERSION: '3.0.5', // trackerInfo.version, } + module.exports = { - oss, + oss, }; \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b16583dcc..6b75658ca 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15664,11 +15664,6 @@ } } }, - "optimal-select": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/optimal-select/-/optimal-select-4.0.1.tgz", - "integrity": "sha1-R959p6ObsJSf2a9UxvA1cVSPBMk=" - }, "optimist": { "version": "0.3.7", "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.3.7.tgz", @@ -16089,6 +16084,34 @@ "sha.js": "^2.4.8" } }, + "peerjs": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/peerjs/-/peerjs-1.3.2.tgz", + "integrity": "sha512-+PHfmsC7QGUU8Ye3OLi6tKQZGPCNy7QatUVNw4JtE8alkguF3+DdO5W0bzepqP2OtE9FqH/ltXt37qyvHw2CqA==", + "requires": { + "@types/node": "^10.14.33", + "eventemitter3": "^3.1.2", + "peerjs-js-binarypack": "1.0.1", + "webrtc-adapter": "^7.7.1" + }, + "dependencies": { + "@types/node": { + "version": "10.17.60", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz", + "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==" + }, + "eventemitter3": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz", + "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==" + } + } + }, + "peerjs-js-binarypack": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/peerjs-js-binarypack/-/peerjs-js-binarypack-1.0.1.tgz", + "integrity": "sha512-N6aeia3NhdpV7kiGxJV5xQiZZCVEEVjRz2T2C6UZQiBkHWHzUv/oWA4myQLcwBwO8LUoR1KWW5oStvwVesmfCg==" + }, "performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", @@ -21695,6 +21718,14 @@ "integrity": "sha512-nfMOlASu9OnRJo1mbEk2cz0D56a1MBNrJ7orjRZQG10XDyuvwksKbuXNp6qa+kbn839HwjwhBzhFmdsaEAfauA==", "dev": true }, + "rtcpeerconnection-shim": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/rtcpeerconnection-shim/-/rtcpeerconnection-shim-1.2.15.tgz", + "integrity": "sha512-C6DxhXt7bssQ1nHb154lqeL0SXz5Dx4RczXZu2Aa/L1NJFnEVDxFwCBo3fqtuljhHIGceg5JKBV4XJ0gW5JKyw==", + "requires": { + "sdp": "^2.6.0" + } + }, "run-async": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", @@ -21876,6 +21907,11 @@ "ajv-keywords": "^3.5.2" } }, + "sdp": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/sdp/-/sdp-2.12.0.tgz", + "integrity": "sha512-jhXqQAQVM+8Xj5EjJGVweuEzgtGWb3tmEEpl3CLP3cStInSbVHSg0QWOGQzNq8pSID4JkpeV2mPqlMDLrm0/Vw==" + }, "select": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/select/-/select-1.1.2.tgz", @@ -25876,6 +25912,15 @@ } } }, + "webrtc-adapter": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-7.7.1.tgz", + "integrity": "sha512-TbrbBmiQBL9n0/5bvDdORc6ZfRY/Z7JnEj+EYOD1ghseZdpJ+nF2yx14k3LgQKc7JZnG7HAcL+zHnY25So9d7A==", + "requires": { + "rtcpeerconnection-shim": "^1.2.15", + "sdp": "^2.12.0" + } + }, "websocket-driver": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index c7ed2b612..d9816a716 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,7 +29,7 @@ "mobx-react-lite": "^3.1.6", "moment": "^2.27.0", "moment-range": "^4.0.2", - "optimal-select": "^4.0.1", + "peerjs": "^1.3.2", "rc-time-picker": "^3.7.3", "react": "^16.13.1", "react-circular-progressbar": "^2.0.3", diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 53a66aa44..2058dcc16 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -1,6 +1,7 @@ const colors = require('./app/theme/colors'); module.exports = { + important: true, purge: [], corePlugins: [ 'preflight', diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 3a86bade4..b5d3a9688 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -1,10 +1,11 @@ { "compilerOptions": { "target": "es5", - "module": "es2015", + "module": "es2020", "moduleResolution": "node", //? //"allowJs": true, "allowSyntheticDefaultImports": true, + "downlevelIteration": true, //"sourceMap": false, "lib": [ "es2020", "dom" ], "jsx": "react", @@ -14,7 +15,16 @@ "baseUrl": ".", "paths": { // TODO: one-source truth "App": ["./app"], - "UI": ["./app/components/ui"] + "App/*": ["./app/*"], + "Types": ["./app/types" ], + "Types/*": ["./app/types/*"], // Sublime hack + "UI": ["./app/components/ui"], + "Duck": ["./app/duck"], + "Duck/*": ["./app/duck/*"], + "Shared": ["./app/components/shared"], + "Shared/*": ["./app/components/shared/*"], + "Player": ["./app/player"], + "Player/*": ["./app/player/*"], } }, "exclude": [ diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js index d0e5b6ca6..70958ce58 100644 --- a/frontend/webpack.config.js +++ b/frontend/webpack.config.js @@ -129,7 +129,7 @@ module.exports = (envName = 'local') => { }, { test: /\.css$/, - include: [ /node_modules/, /app\/styles/ ], + include: [ path.join(__dirname, "node_modules"), path.join(__dirname, "app/styles") ], use: [ cssFileLoader, { @@ -148,7 +148,7 @@ module.exports = (envName = 'local') => { }, { test: /\.js$/, - include: path.join(__dirname, "app"), + include: [ path.join(__dirname, "app"), path.join(__dirname, ".storybook") ], use: babelLoader, }, { diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..f85b56483 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,114 @@ +{ + "name": "openreplay", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "dependencies": { + "react-draggable": "^4.4.3" + } + }, + "node_modules/classnames": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.1.tgz", + "integrity": "sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA==" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/prop-types": { + "version": "15.7.2", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", + "integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.8.1" + } + }, + "node_modules/react-draggable": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.4.3.tgz", + "integrity": "sha512-jV4TE59MBuWm7gb6Ns3Q1mxX8Azffb7oTtDtBgFkxRvhDp38YAARmRplrj0+XGkhOJB5XziArX+4HUUABtyZ0w==", + "dependencies": { + "classnames": "^2.2.5", + "prop-types": "^15.6.0" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + } + }, + "dependencies": { + "classnames": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.1.tgz", + "integrity": "sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA==" + }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "requires": { + "js-tokens": "^3.0.0 || ^4.0.0" + } + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" + }, + "prop-types": { + "version": "15.7.2", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", + "integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==", + "requires": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.8.1" + } + }, + "react-draggable": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.4.3.tgz", + "integrity": "sha512-jV4TE59MBuWm7gb6Ns3Q1mxX8Azffb7oTtDtBgFkxRvhDp38YAARmRplrj0+XGkhOJB5XziArX+4HUUABtyZ0w==", + "requires": { + "classnames": "^2.2.5", + "prop-types": "^15.6.0" + } + }, + "react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 000000000..92fa0e3d7 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "react-draggable": "^4.4.3" + } +} diff --git a/scripts/helm/app/chalice.yaml b/scripts/helm/app/chalice.yaml index a53846acd..e98f0baf6 100644 --- a/scripts/helm/app/chalice.yaml +++ b/scripts/helm/app/chalice.yaml @@ -61,7 +61,14 @@ env: S3_HOST: 'http://minio.db.svc.cluster.local:9000' S3_KEY: minios3AccessKeyS3cr3t S3_SECRET: m1n10s3CretK3yPassw0rd + sourcemaps_reader: 'http://utilities-openreplay.app.svc.cluster.local:9000/assist/sourcemaps' + peers: 'http://utilities-openreplay.app.svc.cluster.local:9000/assist/peers' # Enable logging for python app # Ref: https://stackoverflow.com/questions/43969743/logs-in-kubernetes-pod-not-showing-up PYTHONUNBUFFERED: '0' - version_number: '1.0.0' + version_number: '1.2.0' + SAML2_MD_URL: '' + idp_entityId: '' + idp_sso_url: '' + idp_x509cert: '' + idp_sls_url: '' diff --git a/scripts/helm/app/utilities.yaml b/scripts/helm/app/utilities.yaml new file mode 100644 index 000000000..e229fae2c --- /dev/null +++ b/scripts/helm/app/utilities.yaml @@ -0,0 +1,28 @@ +namespace: app +image: + repository: rg.fr-par.scw.cloud/foss + name: utilities + pullPolicy: IfNotPresent + tag: latest + +imagePullSecrets: + - name: aws-registry + +service: + type: ClusterIP + port: 9000 + +resources: + limits: + cpu: 1000m + memory: 1Gi + requests: + cpu: 1m + memory: 1Mi +env: + AWS_DEFAULT_REGION: us-east-1 + # Override with your https://domain_name + # eg: https://openreplay.mycompany.com + S3_HOST: 'http://minio.db.svc.cluster.local:9000' + S3_KEY: minios3AccessKeyS3cr3t + S3_SECRET: m1n10s3CretK3yPassw0rd \ No newline at end of file diff --git a/scripts/helm/build_deploy.sh b/scripts/helm/build_deploy.sh index 81626f203..86925ebe8 100644 --- a/scripts/helm/build_deploy.sh +++ b/scripts/helm/build_deploy.sh @@ -15,4 +15,6 @@ echo $DOCKER_REPO PUSH_IMAGE=1 bash build.sh $@ cd ../backend PUSH_IMAGE=1 bash build.sh $@ + cd ../utilities + PUSH_IMAGE=1 bash build.sh $@ } diff --git a/scripts/helm/db/init_dbs/postgresql/1.2.0/1.2.0.sql b/scripts/helm/db/init_dbs/postgresql/1.2.0/1.2.0.sql new file mode 100644 index 000000000..321227acf --- /dev/null +++ b/scripts/helm/db/init_dbs/postgresql/1.2.0/1.2.0.sql @@ -0,0 +1,34 @@ +BEGIN; +CREATE INDEX pages_first_contentful_paint_time_idx ON events.pages (first_contentful_paint_time) WHERE first_contentful_paint_time > 0; +CREATE INDEX pages_dom_content_loaded_time_idx ON events.pages (dom_content_loaded_time) WHERE dom_content_loaded_time > 0; +CREATE INDEX pages_first_paint_time_idx ON events.pages (first_paint_time) WHERE first_paint_time > 0; +CREATE INDEX pages_ttfb_idx ON events.pages (ttfb) WHERE ttfb > 0; +CREATE INDEX pages_time_to_interactive_idx ON events.pages (time_to_interactive) WHERE time_to_interactive > 0; +CREATE INDEX sessions_session_id_project_id_start_ts_idx ON sessions (session_id, project_id, start_ts) WHERE duration > 0; +CREATE INDEX pages_session_id_timestamp_loadgt0NN_idx ON events.pages (session_id, timestamp) WHERE load_time > 0 AND load_time IS NOT NULL; +DROP INDEX events.resources_type_idx; +CREATE INDEX resources_timestamp_type_durationgt0NN_idx ON events.resources (timestamp, type) WHERE duration > 0 AND duration IS NOT NULL; +CREATE INDEX pages_session_id_timestamp_visualgt0nn_idx ON events.pages (session_id, timestamp) WHERE visually_complete > 0 AND visually_complete IS NOT NULL; +CREATE INDEX pages_timestamp_metgt0_idx ON events.pages (timestamp) WHERE response_time > 0 OR first_paint_time > 0 OR + dom_content_loaded_time > 0 OR ttfb > 0 OR + time_to_interactive > 0; +CREATE INDEX resources_session_id_timestamp_idx ON events.resources (session_id, timestamp); +DROP INDEX events.resources_timestamp_idx; +CREATE INDEX resources_session_id_timestamp_type_idx ON events.resources (session_id, timestamp, type); +CREATE INDEX pages_session_id_speed_indexgt0nn_idx ON events.pages (session_id, speed_index) WHERE speed_index > 0 AND speed_index IS NOT NULL; +CREATE INDEX pages_session_id_timestamp_dom_building_timegt0nn_idx ON events.pages (session_id, timestamp, dom_building_time) WHERE dom_building_time > 0 AND dom_building_time IS NOT NULL; +CREATE INDEX resources_timestamp_type_durationgt0NN_noFetch_idx ON events.resources (timestamp, type) WHERE duration > 0 AND duration IS NOT NULL AND type != 'fetch'; +CREATE INDEX resources_session_id_timestamp_url_host_fail_idx ON events.resources (session_id, timestamp, url_host) WHERE success = FALSE; +DROP INDEX events.resources_success_idx; +CREATE INDEX resources_session_id_timestamp_url_host_firstparty_idx ON events.resources (session_id, timestamp, url_host) WHERE type IN ('fetch', 'script'); +DROP INDEX events.errors_timestamp_idx; +CREATE INDEX errors_session_id_timestamp_error_id_idx ON events.errors (session_id, timestamp, error_id); +CREATE INDEX errors_project_id_error_id_js_exception_idx ON public.errors (project_id, error_id) WHERE source = 'js_exception'; +CREATE INDEX errors_error_id_timestamp_idx ON events.errors (error_id, timestamp); +CREATE INDEX errors_project_id_error_id_idx ON public.errors (project_id, error_id); +CREATE INDEX errors_timestamp_error_id_session_id_idx ON events.errors (timestamp, error_id, session_id); +CREATE INDEX resources_session_id_timestamp_duration_durationgt0NN_img_idx ON events.resources (session_id, timestamp, duration) WHERE duration > 0 AND duration IS NOT NULL AND type = 'img'; +CREATE INDEX resources_timestamp_session_id_idx ON events.resources (timestamp, session_id); +CREATE INDEX errors_project_id_error_id_integration_idx ON public.errors (project_id, error_id) WHERE source != 'js_exception'; +CREATE INDEX errors_error_id_timestamp_session_id_idx ON events.errors (error_id, timestamp, session_id); +COMMIT; \ No newline at end of file diff --git a/scripts/helm/db/init_dbs/postgresql/init_schema.sql b/scripts/helm/db/init_dbs/postgresql/init_schema.sql index af1f70f99..18c6838ce 100644 --- a/scripts/helm/db/init_dbs/postgresql/init_schema.sql +++ b/scripts/helm/db/init_dbs/postgresql/init_schema.sql @@ -57,7 +57,7 @@ CREATE TABLE users "role": "dev", "dashboard": { "cpu": true, - "fps": false, + "fps": false, "avgCpu": true, "avgFps": true, "errors": true, @@ -408,6 +408,9 @@ CREATE INDEX errors_message_gin_idx ON public.errors USING GIN (message gin_trgm CREATE INDEX errors_name_gin_idx ON public.errors USING GIN (name gin_trgm_ops); CREATE INDEX errors_project_id_idx ON public.errors (project_id); CREATE INDEX errors_project_id_status_idx ON public.errors (project_id, status); +CREATE INDEX errors_project_id_error_id_js_exception_idx ON public.errors (project_id, error_id) WHERE source = 'js_exception'; +CREATE INDEX errors_project_id_error_id_idx ON public.errors (project_id, error_id); +CREATE INDEX errors_project_id_error_id_integration_idx ON public.errors (project_id, error_id) WHERE source != 'js_exception'; CREATE TABLE user_favorite_errors ( @@ -513,6 +516,7 @@ CREATE INDEX ON sessions (project_id, user_country); CREATE INDEX ON sessions (project_id, user_browser); CREATE INDEX sessions_start_ts_idx ON public.sessions (start_ts) WHERE duration > 0; CREATE INDEX sessions_project_id_idx ON public.sessions (project_id) WHERE duration > 0; +CREATE INDEX sessions_session_id_project_id_start_ts_idx ON sessions (session_id, project_id, start_ts) WHERE duration > 0; ALTER TABLE public.sessions ADD CONSTRAINT web_browser_constraint CHECK ( (sessions.platform = 'web' AND sessions.user_browser NOTNULL) OR @@ -656,6 +660,18 @@ CREATE INDEX pages_path_idx ON events.pages (path); CREATE INDEX pages_visually_complete_idx ON events.pages (visually_complete) WHERE visually_complete > 0; CREATE INDEX pages_dom_building_time_idx ON events.pages (dom_building_time) WHERE dom_building_time > 0; CREATE INDEX pages_load_time_idx ON events.pages (load_time) WHERE load_time > 0; +CREATE INDEX pages_first_contentful_paint_time_idx ON events.pages (first_contentful_paint_time) WHERE first_contentful_paint_time > 0; +CREATE INDEX pages_dom_content_loaded_time_idx ON events.pages (dom_content_loaded_time) WHERE dom_content_loaded_time > 0; +CREATE INDEX pages_first_paint_time_idx ON events.pages (first_paint_time) WHERE first_paint_time > 0; +CREATE INDEX pages_ttfb_idx ON events.pages (ttfb) WHERE ttfb > 0; +CREATE INDEX pages_time_to_interactive_idx ON events.pages (time_to_interactive) WHERE time_to_interactive > 0; +CREATE INDEX pages_session_id_timestamp_loadgt0NN_idx ON events.pages (session_id, timestamp) WHERE load_time > 0 AND load_time IS NOT NULL; +CREATE INDEX pages_session_id_timestamp_visualgt0nn_idx ON events.pages (session_id, timestamp) WHERE visually_complete > 0 AND visually_complete IS NOT NULL; +CREATE INDEX pages_timestamp_metgt0_idx ON events.pages (timestamp) WHERE response_time > 0 OR first_paint_time > 0 OR + dom_content_loaded_time > 0 OR ttfb > 0 OR + time_to_interactive > 0; +CREATE INDEX pages_session_id_speed_indexgt0nn_idx ON events.pages (session_id, speed_index) WHERE speed_index > 0 AND speed_index IS NOT NULL; +CREATE INDEX pages_session_id_timestamp_dom_building_timegt0nn_idx ON events.pages (session_id, timestamp, dom_building_time) WHERE dom_building_time > 0 AND dom_building_time IS NOT NULL; CREATE TABLE events.clicks @@ -695,8 +711,10 @@ CREATE TABLE events.errors PRIMARY KEY (session_id, message_id) ); CREATE INDEX ON events.errors (session_id); -CREATE INDEX ON events.errors (timestamp); - +CREATE INDEX errors_session_id_timestamp_error_id_idx ON events.errors (session_id, timestamp, error_id); +CREATE INDEX errors_error_id_timestamp_idx ON events.errors (error_id, timestamp); +CREATE INDEX errors_timestamp_error_id_session_id_idx ON events.errors (timestamp, error_id, session_id); +CREATE INDEX errors_error_id_timestamp_session_id_idx ON events.errors (error_id, timestamp, session_id); CREATE TABLE events.graphql ( @@ -744,8 +762,6 @@ CREATE TABLE events.resources PRIMARY KEY (session_id, message_id) ); CREATE INDEX ON events.resources (session_id); -CREATE INDEX ON events.resources (timestamp); -CREATE INDEX ON events.resources (success); CREATE INDEX ON events.resources (status); CREATE INDEX ON events.resources (type); CREATE INDEX ON events.resources (duration) WHERE duration > 0; @@ -755,8 +771,14 @@ CREATE INDEX resources_url_gin_idx ON events.resources USING GIN (url gin_trgm_o CREATE INDEX resources_url_idx ON events.resources (url); CREATE INDEX resources_url_hostpath_gin_idx ON events.resources USING GIN (url_hostpath gin_trgm_ops); CREATE INDEX resources_url_hostpath_idx ON events.resources (url_hostpath); - - +CREATE INDEX resources_timestamp_type_durationgt0NN_idx ON events.resources (timestamp, type) WHERE duration > 0 AND duration IS NOT NULL; +CREATE INDEX resources_session_id_timestamp_idx ON events.resources (session_id, timestamp); +CREATE INDEX resources_session_id_timestamp_type_idx ON events.resources (session_id, timestamp, type); +CREATE INDEX resources_timestamp_type_durationgt0NN_noFetch_idx ON events.resources (timestamp, type) WHERE duration > 0 AND duration IS NOT NULL AND type != 'fetch'; +CREATE INDEX resources_session_id_timestamp_url_host_fail_idx ON events.resources (session_id, timestamp, url_host) WHERE success = FALSE; +CREATE INDEX resources_session_id_timestamp_url_host_firstparty_idx ON events.resources (session_id, timestamp, url_host) WHERE type IN ('fetch', 'script'); +CREATE INDEX resources_session_id_timestamp_duration_durationgt0NN_img_idx ON events.resources (session_id, timestamp, duration) WHERE duration > 0 AND duration IS NOT NULL AND type = 'img'; +CREATE INDEX resources_timestamp_session_id_idx ON events.resources (timestamp, session_id); CREATE TABLE events.performance ( diff --git a/scripts/helm/kube-install.sh b/scripts/helm/kube-install.sh index 6e8102e5e..0a42416bf 100755 --- a/scripts/helm/kube-install.sh +++ b/scripts/helm/kube-install.sh @@ -96,8 +96,8 @@ EOF medium: 4core 16G machine ideal: 8core 32G machine -apps can specifically be installed/reinstalled: - alerts assets chalice ender http integrations ios-proxy pg redis sink storage frontend +apps can specifically be installed/reinstalled: + alerts assets chalice ender http integrations ios-proxy pg redis sink storage frontend postgresql redis clickhouse ${reset}" echo type value: $installation_type exit 0 @@ -110,7 +110,7 @@ type() { small) installation_type=1 ;; medium) installation_type=1.5 ;; ideal) installation_type=2 ;; - *) + *) echo -e ${red}${bold}'ERROR!!!\nwrong value for `type`'${reset} usage ;; esac @@ -127,6 +127,10 @@ function app(){ ansible-playbook -c local setup.yaml -e @vars.yaml -e scale=$installation_type --tags nginx -v exit 0 ;; + postgresql|redis|clickhouse) + ansible-playbook -c local setup.yaml -e @vars.yaml -e scale=$installation_type -e db_name=$1 --tags template --tags db -v + exit 0 + ;; frontend) ansible-playbook -c local setup.yaml -e @vars.yaml -e scale=$installation_type --tags frontend -v exit 0 diff --git a/scripts/helm/local_run.md b/scripts/helm/local_run.md new file mode 100644 index 000000000..3c2ed9751 --- /dev/null +++ b/scripts/helm/local_run.md @@ -0,0 +1,56 @@ +## How to build and run an application from local + +### For workers + +Workers are the application which handle core functionalities. + +- List of workers are + - alerts + - assets + - db + - ender + - http + - integrations + - sink + - storage + +- Build: + ``` + cd openreplay/backend + # IMAGE_TAG= DOCKER_REPO=rg.fr-par.scw.cloud/foss bash build.sh + # For example, + IMAGE_TAG=v1.0.0 DOCKER_REPO=rg.fr-par.scw.cloud/foss bash build.sh assets + ``` +- Deploy: + ``` + cd openreplay/scripts/helm + bash openreplay-cli --install + ``` +## For api + +All apis are handled by application called, chalice, which is a python3 application. + +- Build: + ``` + cd openreplay/api/ + # IMAGE_TAG= DOCKER_REPO=rg.fr-par.scw.cloud/foss bash build.sh + # For example, + IMAGE_TAG=v1.0.0 DOCKER_REPO=rg.fr-par.scw.cloud/foss bash build.sh + ``` +- Deploy: + ``` + cd openreplay/scripts/helm + bash openreplay-cli --install chalice + ``` + +## For frontend + +Frontend is mainly JS components. When we're installing it, it's built and then installed. So you don't have to run a separate build for frontend. + +Note: if you want to see how it gets build, please refer, `openreplay/frontend/build.sh` + +- Build and Deploy: + ``` + cd openreplay/scripts/helm + bash openreplay-cli --install frontend + ``` diff --git a/scripts/helm/migration.yaml b/scripts/helm/migration.yaml new file mode 100644 index 000000000..41637d7c5 --- /dev/null +++ b/scripts/helm/migration.yaml @@ -0,0 +1,46 @@ +--- +- hosts: localhost + gather_facts: false + environment: + KUBECONFIG: "{{ kubeconfig_path }}" + tasks: + - debug: + var: migration_versions + - block: + - name: generating migration db paths + set_fact: + db_path: "{{dst_list | default([])}} + [ '{{playbook_dir}}/db/init_dbs/postgresql/{{ item }}/*.sql' ]" + with_items: "{{ migration_versions.split(',') }}" + - name: Migrate postgresql + shell: | + file="{{ item|basename }}" + kubectl exec -n db postgresql-postgresql-0 -- /bin/bash -c "rm -rf /tmp/$file" + kubectl cp -n db {{ item }} postgresql-postgresql-0:/tmp/ + kubectl exec -n db postgresql-postgresql-0 -- /bin/bash -c "PGPASSWORD=asayerPostgres psql -U postgres -f /tmp/$file" &> "{{ playbook_dir }}"/postgresql_init.log + args: + chdir: db/init_dbs/postgresql + with_fileglob: "{{ db_path }}" + tags: + - postgresql + - block: + - name: generating migration db paths + set_fact: + db_path: "{{dst_list | default([])}} + [ '{{ item[0] }}/*.sql' ]" + with_items: "{{ migration_versions.split(',') }}" + - name: Restoring clickhouse data + shell: | + file="{{ item|basename }}" + kubectl exec -n db clickhouse-0 -- /bin/bash -c "rm -rf /tmp/$file" + kubectl cp -n db $file clickhouse-0:/tmp/ + kubectl exec -n db clickhouse-0 -- /bin/bash -c "clickhouse-client < /tmp/$file" 2>&1 | tee -a "{{ playbook_dir }}"/clickhouse_init.log + args: + chdir: db/init_dbs/clickhouse/create + with_fileglob: + - "{{ db_path }}" + retries: 3 + delay: 60 + register: result + until: result.rc == 0 + tags: + - clickhouse + when: enterprise_edition_license|length > 0 diff --git a/scripts/helm/nginx-ingress/nginx-ingress/templates/configmap.yaml b/scripts/helm/nginx-ingress/nginx-ingress/templates/configmap.yaml index fad59aa89..fff5ac641 100644 --- a/scripts/helm/nginx-ingress/nginx-ingress/templates/configmap.yaml +++ b/scripts/helm/nginx-ingress/nginx-ingress/templates/configmap.yaml @@ -68,6 +68,14 @@ data: proxy_set_header Host $host; proxy_pass http://chalice-openreplay.app.svc.cluster.local:8000; } + location /assist/ { + rewrite ^/assist/(.*) /$1 break; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + proxy_set_header Host $host; + proxy_pass http://utilities-openreplay.app.svc.cluster.local:9000; + } location /assets/ { rewrite ^/assets/(.*) /sessions-assets/$1 break; proxy_http_version 1.1; diff --git a/scripts/helm/openreplay-cli b/scripts/helm/openreplay-cli index 19392c1ce..35f865b32 100755 --- a/scripts/helm/openreplay-cli +++ b/scripts/helm/openreplay-cli @@ -58,7 +58,7 @@ EOF echo -e "${reset}${blue}services: ${services[*]}${reset}" exit 0 } -services=( alerts assets chalice clickhouse ender sink storage http integrations ios-proxy db pg redis ) +services=( alerts assets chalice clickhouse ender sink storage http integrations ios-proxy db pg redis postgresql ) check() { if ! command -v kubectl &> /dev/null diff --git a/scripts/helm/roles/openreplay/tasks/install-dbs.yaml b/scripts/helm/roles/openreplay/tasks/install-dbs.yaml index e5b0cc3c2..0f8b2b7b2 100644 --- a/scripts/helm/roles/openreplay/tasks/install-dbs.yaml +++ b/scripts/helm/roles/openreplay/tasks/install-dbs.yaml @@ -1,5 +1,12 @@ # vim: set ft=yaml.ansible : --- +- name: Installing specific db + shell: | + helm upgrade --install -n db {{ db_name }} -f /tmp/{{ db_name }}.yaml ./db/{{ db_name }} --wait --create-namespace + args: + executable: /bin/bash + when: db_name|length > 0 + tags: db - name: installing dbs shell: | helm upgrade --install -n db "{{ item }}" "./db/{{ item }}" -f "/tmp/{{ item }}.yaml" --wait --create-namespace &>> "{{ playbook_dir }}"/db_helm.log @@ -67,16 +74,16 @@ register: result until: result.rc == 0 when: enterprise_edition_license|length > 0 -- name: initializing frontend - shell: | - user=`whoami` - sudo chown $user /var/run/docker.sock - bash build.sh - cp -arl public frontend - minio_pod=$(kubectl get po -n db -l app.kubernetes.io/name=minio -n db --output custom-columns=name:.metadata.name | tail -n+2) - kubectl -n db cp frontend $minio_pod:/data/ - rm -rf frontend - args: - chdir: ../../frontend +- name: Downloading frontend archive + unarchive: + url: "https://github.com/openreplay/openreplay/releases/download/{{ openreplay_version }}/frontend.tar.gz" + dest: "frontend" + tags: + - frontend +- name: initializing frontend + shell: | + # Download frontend archive + minio_pod=$(kubectl get po -n db -l app.kubernetes.io/name=minio -n db --output custom-columns=name:.metadata.name | tail -n+2) + kubectl -n db cp frontend $minio_pod:/data/ tags: - frontend diff --git a/scripts/helm/roles/openreplay/tasks/main.yml b/scripts/helm/roles/openreplay/tasks/main.yml index 66d31cf4a..873aa771d 100644 --- a/scripts/helm/roles/openreplay/tasks/main.yml +++ b/scripts/helm/roles/openreplay/tasks/main.yml @@ -29,6 +29,7 @@ - templates/*.yaml tags: - app + - template # Installing and initializing dbs - import_tasks: install-dbs.yaml diff --git a/scripts/helm/roles/openreplay/templates/clickhouse.yaml b/scripts/helm/roles/openreplay/templates/clickhouse.yaml index 1b6929bff..b8631a749 100644 --- a/scripts/helm/roles/openreplay/templates/clickhouse.yaml +++ b/scripts/helm/roles/openreplay/templates/clickhouse.yaml @@ -1,7 +1,11 @@ +{% if db_resource_override.clickhouse %}{ +{{ db_resource_override.clickhouse|to_nice_yaml(indent=2) }} +{% else %} resources: limits: - cpu: {{ ( 100 * scale|float ) | int }}m - memory: {{ ( 512 * scale|float ) | int }}Mi + cpu: {{ ( 2000 * scale|float ) | int }}m + memory: {{ ( 4096 * scale|float ) | int }}Mi requests: cpu: 100m memory: 128Mi +{% endif %} diff --git a/scripts/helm/roles/openreplay/templates/postgresql.yaml b/scripts/helm/roles/openreplay/templates/postgresql.yaml index f9e208797..bb5dbacb3 100644 --- a/scripts/helm/roles/openreplay/templates/postgresql.yaml +++ b/scripts/helm/roles/openreplay/templates/postgresql.yaml @@ -1,8 +1,13 @@ +image: + tag: 13.3.0-debian-10-r53 +{% if db_resource_override.postgresql %} +{{ db_resource_override.postgresql|to_nice_yaml(indent=2) }} +{% else %} resources: limits: - cpu: {{ ( 250 * scale|float ) | int }}m - memory: {{ ( 512 * scale|float ) | int }}Mi + cpu: {{ ( 1000 * scale|float ) | int }}m + memory: {{ ( 2048 * scale|float ) | int }}Mi requests: cpu: 250m memory: 256Mi - +{% endif %} diff --git a/scripts/helm/roles/openreplay/templates/redis.yaml b/scripts/helm/roles/openreplay/templates/redis.yaml index 589caa57a..64f3e1059 100644 --- a/scripts/helm/roles/openreplay/templates/redis.yaml +++ b/scripts/helm/roles/openreplay/templates/redis.yaml @@ -1,12 +1,16 @@ fullnameOverride: redis +usePassword: false cluster: enabled: false redis: +{% if db_resource_override.redis %} + {{ db_resource_override.redis|to_nice_yaml(indent=2) }} +{% else %} resources: limits: - cpu: {{ ( 100 * scale|float ) | int }}m - memory: {{ ( 512 * scale|float ) | int }}Mi + cpu: {{ ( 500 * scale|float ) | int }}m + memory: {{ ( 1024 * scale|float ) | int }}Mi requests: cpu: 100m memory: 128Mi -usePassword: false +{% endif %} diff --git a/scripts/helm/roles/openreplay/templates/utilities.yaml b/scripts/helm/roles/openreplay/templates/utilities.yaml new file mode 100644 index 000000000..3ae1efca8 --- /dev/null +++ b/scripts/helm/roles/openreplay/templates/utilities.yaml @@ -0,0 +1,17 @@ +{% if docker_registry_url is defined and docker_registry_url %} +image: + repository: {{ docker_registry_url }} + tag: {{ image_tag }} +{% endif %} + +{% if not (docker_registry_username is defined and docker_registry_username and docker_registry_password is defined and docker_registry_password) %} +imagePullSecrets: [] +{% endif %} +env: + S3_KEY: "{{ minio_access_key }}" + S3_SECRET: "{{ minio_secret_key }}" + S3_HOST: "https://{{ domain_name }}" + jwt_secret: "{{ jwt_secret_key }}" +{% if env is defined and env.chalice is defined and env.chalice%} + {{ env.chalice | to_nice_yaml | trim | indent(2) }} +{% endif %} diff --git a/scripts/helm/upgrade.sh b/scripts/helm/upgrade.sh new file mode 100644 index 000000000..1bdf3d6cd --- /dev/null +++ b/scripts/helm/upgrade.sh @@ -0,0 +1,66 @@ +#!/bin/bash + +# upgrade.sh v1.10 + +cwd=$PWD +vars_file_path=$1/scripts/helm/vars.yaml + +[[ $# == 1 ]] || { + echo -e "OpenReplay previous version path not given.\nUsage: bash $0 /path/to/previous_openreplay_code_path" + exit 1 +} +[[ -d $1 ]] || { + echo -e "$1 doesn't exist. Please check the path and run\n \`bash upgrade.sh \`" +} +which ansible &> /dev/null || { + echo "ansible not found. Are you sure, this is the same machine in which openreplay installed ?" + exit 100; +} + +echo -e"Updating vars.yaml\n" +{ + ansible localhost -m template -a "src=vars_template.yaml dest=vars.yaml" -e @${vars_file_path} + ansible localhost -m debug -a "var=openreplay_version" -e @${vars_file_path} +} || { + echo -e "variable file update failed. Update the value from old $vars_file_path to ./vars.yaml by hand" +} + +old_version=`grep openreplay_version ${vars_file_path} | cut -d "v" -f 3 | cut -d '"' -f 1` +[[ -z $old_version ]] && { + old_version=`grep image_tag ${vars_file_path} | cut -d "v" -f 2 | cut -d '"' -f 1` +} +enterprise_edition=`grep enterprise_edition_license ${vars_file_path} | cut -d ":" -f 2 | xargs` +migration(){ + # Ref: https://stackoverflow.com/questions/1527049/how-can-i-join-elements-of-an-array-in-bash + # Creating an array of versions to migrate. + db=$1 + migration_versions=(`ls -l db/init_dbs/$db | grep -E ^d | awk -v number=${old_version} '$NF > number {print $NF}'`) + # Can't pass the space seperated array to ansible for migration. So joining them with , + joined_migration_versions=$(IFS=, ; echo "${migration_versions[*]}") + + [[ $joined_migration_versions == "" ]] || + { + echo -e "Starting db migrations" + echo -e "Migrating versions $migration_versions" + + ansible-playbook -c local migration.yaml -e @vars.yaml -e migration_versions=${joined_migration_versions} --tags $db -v + } +} +installation_type=1 +echo -e "Migrating postgresql" +migration postgresql + +# Re installing apps. +apps=($(ls app/*.yaml|cut -d "." -f1|cut -d '/' -f2)) +ansible-playbook -c local setup.yaml -e @vars.yaml -e scale=$installation_type --tags template -v +for app in ${apps[@]}; do + ansible-playbook -c local setup.yaml -e @vars.yaml -e scale=$installation_type -e app_name=$app --tags app --skip-tags template -v +done +# Installing frontend +ansible-playbook -c local setup.yaml -e @vars.yaml -e scale=$installation_type --tags frontend -v +# Installing nginx +sed -i 's/.* return 301 .*/ # return 301 https:\/\/$host$request_uri;/g' nginx-ingress/nginx-ingress/templates/configmap.yaml +[[ NGINX_REDIRECT_HTTPS -eq 1 ]] && { +sed -i "s/# return 301/return 301/g" nginx-ingress/nginx-ingress/templates/configmap.yaml +} +ansible-playbook -c local setup.yaml -e @vars.yaml -e scale=$installation_type --tags nginx -v diff --git a/scripts/helm/vars.yaml b/scripts/helm/vars.yaml index 69e77defa..d264bc5fc 100644 --- a/scripts/helm/vars.yaml +++ b/scripts/helm/vars.yaml @@ -24,7 +24,8 @@ domain_name: "" docker_registry_username: "" docker_registry_password: "" docker_registry_url: "rg.fr-par.scw.cloud/foss" -image_tag: "v1.1.0" +image_tag: "v1.2.0" +openreplay_version: "v1.2.0" # Nginx ssl certificates. # in cert format @@ -71,3 +72,17 @@ enable_monitoring: "false" # # Username: admin grafana_password: "" + +## Advanced +# If you need to override the default cpu/memory allocation of databases. +db_resource_override: + postgresql: {} + # resources: + # limits: + # cpu: 1000m + # memory: 1024Mi + # requests: + # cpu: 250m + # memory: 256Mi + redis: {} + clickhouse: {} diff --git a/scripts/helm/vars_template.yaml b/scripts/helm/vars_template.yaml new file mode 100644 index 000000000..e49892a21 --- /dev/null +++ b/scripts/helm/vars_template.yaml @@ -0,0 +1,88 @@ +################### +## Mandatory Fields. +################### + +# Give the path of the kubeconfig_path: /home/user/.kube/config +# we can access the kubernetes cluster. +# Give absolute file path. +# Use following command to get the full file path +# `readlink -f ` +kubeconfig_path: "{{ kubeconfig_path }}" + +# Using which domain name, you'll be accessing OpenReplay +# for example: domain_name: "openreplay.mycompany.com" +# +# Without domain name session replay is not possible, because we've to +# create signed url for s3 objects. +domain_name: "{{ domain_name }}" + +################### +## Optional Fields. +################### + +# If you've private registry, please update the details here. +docker_registry_username: "{{ docker_registry_username }}" +docker_registry_password: "{{ docker_registry_password }}" +docker_registry_url: "{{ docker_registry_url }}" +image_tag: "v1.2.0" +openreplay_version: "v1.2.0" + +# Nginx ssl certificates. +# in cert format +# Give absolute file path. +# Use following command to get the full file path +# `readlink -f ` +# For example: +# nginx_ssl_cert_file_path: "/home/openreplay/nginx-cert.crt" +# nginx_ssl_key_file_path: "/home/openreplay/nginx-key.pem" +# +# By Default, we'll create a self signed certificate for nginx, and populate the values here. +# Once you've proper domain name, and ssl certificate +# Change the following variables accordingly. +nginx_ssl_cert_file_path: "{{ nginx_ssl_cert_file_path }}" +nginx_ssl_key_file_path: "{{ nginx_ssl_key_file_path }}" + +# This key is used to create password for chalice api requests. +# Create a strong password. +# By default, a default key will be generated and will update the value here. +jwt_secret_key: "{{ jwt_secret_key }}" + +# Random password for minio, +# If not defined, will generate at runtime. +# Use following command to generate password +# `openssl rand -base64 30` +minio_access_key: "{{ minio_access_key }}" +minio_secret_key: "{{ minio_secret_key }}" + +# If you're using enterprise edition. +# Insert the enterprise_edition_License key which you got. +enterprise_edition_license: "{{ enterprise_edition_license }}" + +# Enable monitoring +# If set, monitoring stack will be installed +# including, prometheus, grafana and other core components, +# to scrape the metrics. But this will cost, additional resources (cpu and memory). +# Monitoring won't be installed on base installation. +enable_monitoring: "{{ enable_monitoring }}" +# Password for grafana. +# If password is not given, it'll be generated, and updated here. +# +# Use following command to generate password +# `openssl rand -base64 30` +# +# Username: admin +grafana_password: "{{ grafana_password }}" + +## Advanced +# If you need to override the default cpu/memory allocation of databases. +db_resource_override: + postgresql: {{ db_resource_override.postgresql|default({}) }} + # resources: + # limits: + # cpu: 1000m + # memory: 1024Mi + # requests: + # cpu: 250m + # memory: 256Mi + redis: {{ db_resource_override.redis|default({}) }} + clickhouse: {{ db_resource_override.clickhouse|default({}) }} diff --git a/sourcemap-uploader/cli.js b/sourcemap-uploader/cli.js index f465d1711..fa284f1a4 100755 --- a/sourcemap-uploader/cli.js +++ b/sourcemap-uploader/cli.js @@ -21,7 +21,8 @@ parser.addArgument(['-p', '-i', '--project-key'], { // -i is depricated parser.addArgument(['-s', '--server'], { help: 'OpenReplay API server URL for upload', }); -parser.addArgument(['-v', '--verbose'], { +// Should be verbose, but conflicting on npm compilation into bin +parser.addArgument(['-l', '--logs'], { help: 'Log requests information', action: 'storeTrue', }); diff --git a/sourcemap-uploader/package.json b/sourcemap-uploader/package.json index 76b9470bb..439ef44d8 100644 --- a/sourcemap-uploader/package.json +++ b/sourcemap-uploader/package.json @@ -1,6 +1,6 @@ { "name": "@openreplay/sourcemap-uploader", - "version": "3.0.3", + "version": "3.0.4", "description": "NPM module to upload your JS sourcemaps files to OpenReplay", "bin": "cli.js", "main": "index.js", diff --git a/tracker/tracker-assist/.gitignore b/tracker/tracker-assist/.gitignore new file mode 100644 index 000000000..6ddaccbb5 --- /dev/null +++ b/tracker/tracker-assist/.gitignore @@ -0,0 +1,6 @@ +node_modules +npm-debug.log +lib +cjs +.cache +*.DS_Store \ No newline at end of file diff --git a/tracker/tracker-assist/.npmignore b/tracker/tracker-assist/.npmignore new file mode 100644 index 000000000..a3e81897a --- /dev/null +++ b/tracker/tracker-assist/.npmignore @@ -0,0 +1,5 @@ +src +tsconfig-cjs.json +tsconfig.json +.prettierrc.json +.cache diff --git a/tracker/tracker-assist/LICENSE b/tracker/tracker-assist/LICENSE new file mode 100644 index 000000000..b57f138e0 --- /dev/null +++ b/tracker/tracker-assist/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2021 OpenReplay.com + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/ee/api/chalicelib/ee/utils/__init__.py b/tracker/tracker-assist/README.md similarity index 100% rename from ee/api/chalicelib/ee/utils/__init__.py rename to tracker/tracker-assist/README.md diff --git a/tracker/tracker-assist/package-lock.json b/tracker/tracker-assist/package-lock.json new file mode 100644 index 000000000..4ccf9ba06 --- /dev/null +++ b/tracker/tracker-assist/package-lock.json @@ -0,0 +1,861 @@ +{ + "name": "@openreplay/tracker-assist", + "version": "3.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@babel/code-frame": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.14.5.tgz", + "integrity": "sha512-9pzDqyc6OLDaqe+zbACgFkb6fKMNG6CObKpnYXChRsvYGyEdc7CA2BaqeOM+vOtCS5ndmJicPJhKAwYRI6UfFw==", + "dev": true, + "requires": { + "@babel/highlight": "^7.14.5" + } + }, + "@babel/helper-validator-identifier": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.14.5.tgz", + "integrity": "sha512-5lsetuxCLilmVGyiLEfoHBRX8UCFD+1m2x3Rj97WrW3V7H3u4RWRXA4evMjImCsin2J2YT0QaVDGf+z8ondbAg==", + "dev": true + }, + "@babel/highlight": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.14.5.tgz", + "integrity": "sha512-qf9u2WFWVV0MppaL877j2dBtQIDgmidgjGk5VIMw3OadXvYaXn66U1BFlH2t4+t3i+8PhedppRv+i40ABzd+gg==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.14.5", + "chalk": "^2.0.0", + "js-tokens": "^4.0.0" + } + }, + "@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "requires": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + } + }, + "@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true + }, + "@nodelib/fs.walk": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.7.tgz", + "integrity": "sha512-BTIhocbPBSrRmHxOAJFtR18oLhxTtAFDAvL8hY1S3iU8k+E60W/YFs4jrixGzQjMpF4qPXxIQHcjVD9dz1C2QA==", + "dev": true, + "requires": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + } + }, + "@openreplay/tracker": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@openreplay/tracker/-/tracker-3.0.5.tgz", + "integrity": "sha512-hIY7DnQmm7bCe6v+e257WD7OdNuBOWUZ15Q3yUEdyxu7xDNG7brbak9pS97qCt3VY9xGK0RvW/j3ANlRPk8aVg==", + "dev": true, + "requires": { + "error-stack-parser": "^2.0.6" + } + }, + "@types/minimist": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.1.tgz", + "integrity": "sha512-fZQQafSREFyuZcdWFAExYjBiCL7AUCdgsk80iO0q4yihYYdcIiH28CcuPTGFgLOCC8RlW49GSQxdHwZP+I7CNg==", + "dev": true + }, + "@types/node": { + "version": "10.17.60", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz", + "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==" + }, + "@types/normalize-package-data": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz", + "integrity": "sha512-f5j5b/Gf71L+dbqxIpQ4Z2WlmI/mPJ0fOkGGmFgtb6sAu97EPczzbS3/tJKxmcYDj55OX6ssqwDAWOHIYDRDGA==", + "dev": true + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true + }, + "arrify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", + "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", + "dev": true + }, + "braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "requires": { + "fill-range": "^7.0.1" + } + }, + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true + }, + "camelcase-keys": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-6.2.2.tgz", + "integrity": "sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg==", + "dev": true, + "requires": { + "camelcase": "^5.3.1", + "map-obj": "^4.0.0", + "quick-lru": "^4.0.1" + } + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "dependencies": { + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true + } + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", + "dev": true + }, + "decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=", + "dev": true + }, + "decamelize-keys": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/decamelize-keys/-/decamelize-keys-1.1.0.tgz", + "integrity": "sha1-0XGoeTMlKAfrPLYdwcFEXQeN8tk=", + "dev": true, + "requires": { + "decamelize": "^1.1.0", + "map-obj": "^1.0.0" + }, + "dependencies": { + "map-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz", + "integrity": "sha1-2TPOuSBdgr3PSIb2dCvcK03qFG0=", + "dev": true + } + } + }, + "dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "requires": { + "path-type": "^4.0.0" + } + }, + "error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "requires": { + "is-arrayish": "^0.2.1" + } + }, + "error-stack-parser": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.0.6.tgz", + "integrity": "sha512-d51brTeqC+BHlwF0BhPtcYgF5nlzf9ZZ0ZIUQNZpc9ZB9qw5IJ2diTrBY9jlCJkTLITYPjmiX6OWCwH+fuyNgQ==", + "dev": true, + "requires": { + "stackframe": "^1.1.1" + } + }, + "escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true + }, + "eventemitter3": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz", + "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==" + }, + "fast-glob": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.5.tgz", + "integrity": "sha512-2DtFcgT68wiTTiwZ2hNdJfcHNke9XOfnwmBRWXhmeKM8rF0TGwmC/Qto3S7RoZKp5cilZbxzO5iTNTQsJ+EeDg==", + "dev": true, + "requires": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.0", + "merge2": "^1.3.0", + "micromatch": "^4.0.2", + "picomatch": "^2.2.1" + } + }, + "fastq": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.11.0.tgz", + "integrity": "sha512-7Eczs8gIPDrVzT+EksYBcupqMyxSHXXrHOLRRxU2/DicV8789MRBRR8+Hc2uWzUupOs4YS4JzBmBxjjCVBxD/g==", + "dev": true, + "requires": { + "reusify": "^1.0.4" + } + }, + "fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "requires": { + "to-regex-range": "^5.0.1" + } + }, + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", + "dev": true + }, + "glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "requires": { + "is-glob": "^4.0.1" + } + }, + "globby": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.0.4.tgz", + "integrity": "sha512-9O4MVG9ioZJ08ffbcyVYyLOJLk5JQ688pJ4eMGLpdWLHq/Wr1D9BlriLQyL0E+jbkuePVZXYFj47QM/v093wHg==", + "dev": true, + "requires": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.1.1", + "ignore": "^5.1.4", + "merge2": "^1.3.0", + "slash": "^3.0.0" + } + }, + "hard-rejection": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz", + "integrity": "sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==", + "dev": true + }, + "has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dev": true, + "requires": { + "function-bind": "^1.1.1" + } + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, + "hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", + "dev": true + }, + "ignore": { + "version": "5.1.8", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.8.tgz", + "integrity": "sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw==", + "dev": true + }, + "imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", + "dev": true + }, + "indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true + }, + "is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=", + "dev": true + }, + "is-core-module": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.4.0.tgz", + "integrity": "sha512-6A2fkfq1rfeQZjxrZJGerpLCTHRNEBiSgnu0+obeJpEPZRUooHgsizvzv0ZjJwOz3iWIHdJtVWJ/tmPr3D21/A==", + "dev": true, + "requires": { + "has": "^1.0.3" + } + }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", + "dev": true + }, + "is-glob": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz", + "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==", + "dev": true, + "requires": { + "is-extglob": "^2.1.1" + } + }, + "is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true + }, + "is-plain-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", + "integrity": "sha1-caUMhCnfync8kqOQpKA7OfzVHT4=", + "dev": true + }, + "is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=", + "dev": true + }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true + }, + "kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true + }, + "lines-and-columns": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.1.6.tgz", + "integrity": "sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=", + "dev": true + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } + }, + "map-obj": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.2.1.tgz", + "integrity": "sha512-+WA2/1sPmDj1dlvvJmB5G6JKfY9dpn7EVBUL06+y6PoljPkh+6V1QihwxNkbcGxCRjt2b0F9K0taiCuo7MbdFQ==", + "dev": true + }, + "meow": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/meow/-/meow-7.1.1.tgz", + "integrity": "sha512-GWHvA5QOcS412WCo8vwKDlTelGLsCGBVevQB5Kva961rmNfun0PCbv5+xta2kUMFJyR8/oWnn7ddeKdosbAPbA==", + "dev": true, + "requires": { + "@types/minimist": "^1.2.0", + "camelcase-keys": "^6.2.2", + "decamelize-keys": "^1.1.0", + "hard-rejection": "^2.1.0", + "minimist-options": "4.1.0", + "normalize-package-data": "^2.5.0", + "read-pkg-up": "^7.0.1", + "redent": "^3.0.0", + "trim-newlines": "^3.0.0", + "type-fest": "^0.13.1", + "yargs-parser": "^18.1.3" + } + }, + "merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true + }, + "micromatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.4.tgz", + "integrity": "sha512-pRmzw/XUcwXGpD9aI9q/0XOwLNygjETJ8y0ao0wdqprrzDa4YnxLcz7fQRZr8voh8V10kGhABbNcHVk5wHgWwg==", + "dev": true, + "requires": { + "braces": "^3.0.1", + "picomatch": "^2.2.3" + } + }, + "min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true + }, + "minimist-options": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz", + "integrity": "sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==", + "dev": true, + "requires": { + "arrify": "^1.0.1", + "is-plain-obj": "^1.1.0", + "kind-of": "^6.0.3" + }, + "dependencies": { + "arrify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", + "integrity": "sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0=", + "dev": true + } + } + }, + "normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "requires": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true + }, + "npm-dragndrop": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/npm-dragndrop/-/npm-dragndrop-1.2.0.tgz", + "integrity": "sha1-bgUkAP7Yay8eP0csU4EPkjcRu7U=" + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + } + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true + }, + "parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + } + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true + }, + "path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true + }, + "peerjs": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/peerjs/-/peerjs-1.3.2.tgz", + "integrity": "sha512-+PHfmsC7QGUU8Ye3OLi6tKQZGPCNy7QatUVNw4JtE8alkguF3+DdO5W0bzepqP2OtE9FqH/ltXt37qyvHw2CqA==", + "requires": { + "@types/node": "^10.14.33", + "eventemitter3": "^3.1.2", + "peerjs-js-binarypack": "1.0.1", + "webrtc-adapter": "^7.7.1" + } + }, + "peerjs-js-binarypack": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/peerjs-js-binarypack/-/peerjs-js-binarypack-1.0.1.tgz", + "integrity": "sha512-N6aeia3NhdpV7kiGxJV5xQiZZCVEEVjRz2T2C6UZQiBkHWHzUv/oWA4myQLcwBwO8LUoR1KWW5oStvwVesmfCg==" + }, + "picomatch": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.0.tgz", + "integrity": "sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw==", + "dev": true + }, + "prettier": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-1.19.1.tgz", + "integrity": "sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew==", + "dev": true + }, + "queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true + }, + "quick-lru": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz", + "integrity": "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==", + "dev": true + }, + "read-pkg": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", + "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==", + "dev": true, + "requires": { + "@types/normalize-package-data": "^2.4.0", + "normalize-package-data": "^2.5.0", + "parse-json": "^5.0.0", + "type-fest": "^0.6.0" + }, + "dependencies": { + "type-fest": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz", + "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==", + "dev": true + } + } + }, + "read-pkg-up": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz", + "integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==", + "dev": true, + "requires": { + "find-up": "^4.1.0", + "read-pkg": "^5.2.0", + "type-fest": "^0.8.1" + }, + "dependencies": { + "type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "dev": true + } + } + }, + "redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "requires": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + } + }, + "replace-in-files-cli": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/replace-in-files-cli/-/replace-in-files-cli-1.0.0.tgz", + "integrity": "sha512-/HMPLZeCA24CBUQ59ymHji6LyMKM+gEgDZlYsiPvXW6+3PdfOw6SsMCVd9KC2B+KlAEe/8vkJA6gfnexVdF15A==", + "dev": true, + "requires": { + "arrify": "^2.0.1", + "escape-string-regexp": "^4.0.0", + "globby": "^11.0.1", + "meow": "^7.1.1", + "normalize-path": "^3.0.0", + "write-file-atomic": "^3.0.0" + } + }, + "resolve": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz", + "integrity": "sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==", + "dev": true, + "requires": { + "is-core-module": "^2.2.0", + "path-parse": "^1.0.6" + } + }, + "reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true + }, + "rtcpeerconnection-shim": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/rtcpeerconnection-shim/-/rtcpeerconnection-shim-1.2.15.tgz", + "integrity": "sha512-C6DxhXt7bssQ1nHb154lqeL0SXz5Dx4RczXZu2Aa/L1NJFnEVDxFwCBo3fqtuljhHIGceg5JKBV4XJ0gW5JKyw==", + "requires": { + "sdp": "^2.6.0" + } + }, + "run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "requires": { + "queue-microtask": "^1.2.2" + } + }, + "sdp": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/sdp/-/sdp-2.12.0.tgz", + "integrity": "sha512-jhXqQAQVM+8Xj5EjJGVweuEzgtGWb3tmEEpl3CLP3cStInSbVHSg0QWOGQzNq8pSID4JkpeV2mPqlMDLrm0/Vw==" + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + }, + "signal-exit": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz", + "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==", + "dev": true + }, + "slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true + }, + "spdx-correct": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.1.tgz", + "integrity": "sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w==", + "dev": true, + "requires": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-exceptions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz", + "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==", + "dev": true + }, + "spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "requires": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-license-ids": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.9.tgz", + "integrity": "sha512-Ki212dKK4ogX+xDo4CtOZBVIwhsKBEfsEEcwmJfLQzirgc2jIWdzg40Unxz/HzEUqM1WFzVlQSMF9kZZ2HboLQ==", + "dev": true + }, + "stackframe": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.2.0.tgz", + "integrity": "sha512-GrdeshiRmS1YLMYgzF16olf2jJ/IzxXY9lhKOskuVziubpTYcYqyOwYeJKzQkwy7uN0fYSsbsC4RQaXf9LCrYA==", + "dev": true + }, + "strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "requires": { + "min-indent": "^1.0.0" + } + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + }, + "to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "requires": { + "is-number": "^7.0.0" + } + }, + "trim-newlines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-3.0.1.tgz", + "integrity": "sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==", + "dev": true + }, + "type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true + }, + "typedarray-to-buffer": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", + "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "dev": true, + "requires": { + "is-typedarray": "^1.0.0" + } + }, + "typescript": { + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.10.tgz", + "integrity": "sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q==", + "dev": true + }, + "validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "requires": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "webrtc-adapter": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-7.7.1.tgz", + "integrity": "sha512-TbrbBmiQBL9n0/5bvDdORc6ZfRY/Z7JnEj+EYOD1ghseZdpJ+nF2yx14k3LgQKc7JZnG7HAcL+zHnY25So9d7A==", + "requires": { + "rtcpeerconnection-shim": "^1.2.15", + "sdp": "^2.12.0" + } + }, + "write-file-atomic": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", + "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", + "dev": true, + "requires": { + "imurmurhash": "^0.1.4", + "is-typedarray": "^1.0.0", + "signal-exit": "^3.0.2", + "typedarray-to-buffer": "^3.1.5" + } + }, + "yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "dev": true, + "requires": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } + } + } +} diff --git a/tracker/tracker-assist/package.json b/tracker/tracker-assist/package.json new file mode 100644 index 000000000..581eb7cd4 --- /dev/null +++ b/tracker/tracker-assist/package.json @@ -0,0 +1,35 @@ +{ + "name": "@openreplay/tracker-assist", + "description": "Tracker plugin for screen assistance through the WebRTC", + "version": "3.0.0", + "keywords": [ + "WebRTC", + "assistance", + "logging", + "replay" + ], + "author": "Aleksandr K ", + "license": "MIT", + "type": "module", + "main": "./lib/index.js", + "scripts": { + "lint": "prettier --write 'src/**/*.ts' README.md && tsc --noEmit", + "build": "npm run build-es && npm run build-cjs", + "build-es": "rm -Rf lib && tsc", + "build-cjs": "rm -Rf cjs && tsc --project tsconfig-cjs.json && echo '{ \"type\": \"commonjs\" }' > cjs/package.json && replace-in-files cjs/* --string='@openreplay/tracker' --replacement='@openreplay/tracker/cjs' && replace-in-files cjs/* --string='/lib/' --replacement='/'", + "prepublishOnly": "npm run build" + }, + "dependencies": { + "npm-dragndrop": "^1.2.0", + "peerjs": "^1.3.2" + }, + "peerDependencies": { + "@openreplay/tracker": "^3.1.0" + }, + "devDependencies": { + "@openreplay/tracker": "^3.0.5", + "prettier": "^1.18.2", + "replace-in-files-cli": "^1.0.0", + "typescript": "^3.6.4" + } +} diff --git a/tracker/tracker-assist/src/CallWindow.ts b/tracker/tracker-assist/src/CallWindow.ts new file mode 100644 index 000000000..273138be5 --- /dev/null +++ b/tracker/tracker-assist/src/CallWindow.ts @@ -0,0 +1,172 @@ + + +export default class CallWindow { + private iframe: HTMLIFrameElement; + private vRemote: HTMLVideoElement | null = null; + private vLocal: HTMLVideoElement | null = null; + private audioBtn: HTMLAnchorElement | null = null; + private videoBtn: HTMLAnchorElement | null = null; + private userNameSpan: HTMLSpanElement | null = null; + + private tsInterval: ReturnType; + constructor(endCall: () => void) { + const iframe = this.iframe = document.createElement('iframe'); + Object.assign(iframe.style, { + position: "absolute", + zIndex: 2147483647 - 1, + //borderRadius: ".25em .25em .4em .4em", + //border: "4px rgba(0, 0, 0, .7)", + border: "none", + bottom: "10px", + right: "10px", + display: "none", + }); + //iframe.src = "//static.openreplay.com/tracker-assist/index.html"; + iframe.onload = () => { + const doc = iframe.contentDocument; + if (!doc) { + console.error("OpenReplay: CallWindow iframe document is not reachable.") + return; + } + fetch("https://static.openreplay.com/tracker-assist/index.html") + //fetch("file:///Users/shikhu/work/asayer-tester/dist/assist/index.html") + .then(r => r.text()) + .then((text) => { + iframe.onload = () => { + iframe.style.display = "block"; + iframe.style.height = doc.body.scrollHeight + 'px'; + iframe.style.width = doc.body.scrollWidth + 'px'; + } + + text = text.replace(/href="css/g, "href=\"https://static.openreplay.com/tracker-assist/css") + doc.open(); + doc.write(text); + doc.close(); + + + this.vLocal = doc.getElementById("video-local") as HTMLVideoElement; + this.vRemote = doc.getElementById("video-remote") as HTMLVideoElement; + this._trySetStreams(); + this.vLocal.parentElement && this.vLocal.parentElement.classList.add("d-none"); + + this.audioBtn = doc.getElementById("audio-btn") as HTMLAnchorElement; + this.audioBtn.onclick = () => this.toggleAudio(); + this.videoBtn = doc.getElementById("video-btn") as HTMLAnchorElement; + this.videoBtn.onclick = () => this.toggleVideo(); + + this.userNameSpan = doc.getElementById("username") as HTMLSpanElement; + this._trySetAssistentName(); + + const endCallBtn = doc.getElementById("end-call-btn") as HTMLAnchorElement; + endCallBtn.onclick = endCall; + + const tsText = doc.getElementById("time-stamp"); + const startTs = Date.now(); + if (tsText) { + this.tsInterval = setInterval(() => { + const ellapsed = Date.now() - startTs; + const secsFull = ~~(ellapsed / 1000); + const mins = ~~(secsFull / 60); + const secs = secsFull - mins * 60 + tsText.innerText = `${mins}:${secs < 10 ? 0 : ''}${secs}`; + }, 500); + } + + // TODO: better D'n'D + doc.body.setAttribute("draggable", "true"); + doc.body.ondragstart = (e) => { + if (!e.dataTransfer || !e.target) { return; } + e.dataTransfer.setDragImage(doc.body, e.clientX, e.clientY); + }; + doc.body.ondragend = e => { + Object.assign(iframe.style, { + left: `${e.clientX}px`, + top: `${e.clientY}px`, + bottom: 'auto', + right: 'auto', + }) + } + }); + } + + document.body.appendChild(iframe); + + } + + private localStream: MediaStream | null = null; + private remoteStream: MediaStream | null = null; + private _trySetStreams() { + if (this.vRemote && this.remoteStream) { + this.vRemote.srcObject = this.remoteStream; + } + if (this.vLocal && this.localStream) { + this.vLocal.srcObject = this.localStream; + } + } + setRemoteStream(rStream: MediaStream) { + this.remoteStream = rStream; + this._trySetStreams(); + } + setLocalStream(lStream: MediaStream) { + this.localStream = lStream; + lStream.getVideoTracks().forEach(track => { + track.enabled = false; + }); + this._trySetStreams(); + } + + + // TODO: determined workflow + _trySetAssistentName() { + if (this.userNameSpan && this.assistentName) { + this.userNameSpan.innerText = this.assistentName; + } + } + private assistentName: string = ""; + setAssistentName(name: string) { + this.assistentName = name; + this._trySetAssistentName(); + } + + toggleAudio() { + let enabled = true; + this.localStream?.getAudioTracks().forEach(track => { + enabled = enabled && !track.enabled; + track.enabled = enabled; + }); + const cList = this.audioBtn?.classList; + if (!this.audioBtn) { return; } + if (enabled) { + this.audioBtn.classList.remove("muted"); + this.audioBtn.childNodes[1].textContent = "Mute"; + } else { + this.audioBtn.classList.add("muted"); + this.audioBtn.childNodes[1].textContent = "Unmute"; + } + } + toggleVideo() { + let enabled = true; + this.localStream?.getVideoTracks().forEach(track => { + enabled = enabled && !track.enabled; + track.enabled = enabled; + }); + if (!this.videoBtn || !this.vLocal || !this.vLocal.parentElement) { return; } + if (enabled) { + this.vLocal.parentElement.classList.remove("d-none"); + this.videoBtn.classList.remove("off"); + this.videoBtn.childNodes[1].textContent = "Stop Video"; + } else { + this.vLocal.parentElement.classList.add("d-none"); + this.videoBtn.classList.add("off"); + this.videoBtn.childNodes[1].textContent = "Start Video"; + } + } + + remove() { + clearInterval(this.tsInterval); + if (this.iframe.parentElement) { + document.body.removeChild(this.iframe); + } + } + +} \ No newline at end of file diff --git a/tracker/tracker-assist/src/Confirm.ts b/tracker/tracker-assist/src/Confirm.ts new file mode 100644 index 000000000..fd1075789 --- /dev/null +++ b/tracker/tracker-assist/src/Confirm.ts @@ -0,0 +1,92 @@ + +const declineIcon = ``; + +export default class Confirm { + private wrapper: HTMLDivElement; + + constructor(text: string, styles?: Object) { + const wrapper = document.createElement('div'); + const popup = document.createElement('div'); + const p = document.createElement('p'); + p.innerText = text; + const buttons = document.createElement('div'); + const answerBtn = document.createElement('button'); + answerBtn.innerHTML = declineIcon.replace('fill="#ef5261"', 'fill="green"'); + const declineBtn = document.createElement('button'); + declineBtn.innerHTML = declineIcon; + buttons.appendChild(answerBtn); + buttons.appendChild(declineBtn); + popup.appendChild(p); + popup.appendChild(buttons); + + const btnStyles = { + borderRadius: "50%", + width: "22px", + height: "22px", + background: "transparent", + padding: 0, + margin: 0, + border: 0, + cursor: "pointer", + } + Object.assign(answerBtn.style, btnStyles); + Object.assign(declineBtn.style, btnStyles); + Object.assign(buttons.style, { + marginTop: "10px", + display: "flex", + alignItems: "center", + justifyContent: "space-evenly", + }); + + Object.assign(popup.style, { + position: "relative", + pointerEvents: "auto", + margin: "4em auto", + width: "90%", + maxWidth: "400px", + padding: "25px 30px", + background: "black", + opacity: ".75", + color: "white", + textAlign: "center", + borderRadius: ".25em .25em .4em .4em", + boxShadow: "0 0 20px rgb(0 0 0 / 20%)", + }, styles); + + Object.assign(wrapper.style, { + position: "fixed", + left: 0, + top: 0, + height: "100%", + width: "100%", + pointerEvents: "none", + zIndex: 2147483647 - 1, + }) + + wrapper.appendChild(popup); + this.wrapper = wrapper; + + answerBtn.onclick = () => { + this.remove(); + this.callback(true); + } + declineBtn.onclick = () => { + this.remove(); + this.callback(false); + } + } + + mount() { + document.body.appendChild(this.wrapper); + } + + private callback: (result: boolean) => void = ()=>{}; + onAnswer(callback: (result: boolean) => void) { + this.callback = callback; + } + + remove() { + if (!this.wrapper.parentElement) { return; } + document.body.removeChild(this.wrapper); + } +} diff --git a/tracker/tracker-assist/src/Mouse.ts b/tracker/tracker-assist/src/Mouse.ts new file mode 100644 index 000000000..03558ce1c --- /dev/null +++ b/tracker/tracker-assist/src/Mouse.ts @@ -0,0 +1,32 @@ + + + +export default class Mouse { + private mouse: HTMLDivElement + constructor() { + this.mouse = document.createElement('div'); + Object.assign(this.mouse.style, { + width: "20px", + height: "20px", + opacity: ".4", + borderRadius: "50%", + position: "absolute", + zIndex: "999998", + background: "radial-gradient(red, transparent)", + }); + document.body.appendChild(this.mouse); + } + + move({x, y}: {x?: number, y?: number}) { + Object.assign(this.mouse.style, { + left: `${x || 0}px`, + top: `${y || 0}px` + }) + } + + remove() { + if (this.mouse.parentElement) { + document.body.removeChild(this.mouse); + } + } +} \ No newline at end of file diff --git a/tracker/tracker-assist/src/confirm.ts b/tracker/tracker-assist/src/confirm.ts new file mode 100644 index 000000000..fd1075789 --- /dev/null +++ b/tracker/tracker-assist/src/confirm.ts @@ -0,0 +1,92 @@ + +const declineIcon = ``; + +export default class Confirm { + private wrapper: HTMLDivElement; + + constructor(text: string, styles?: Object) { + const wrapper = document.createElement('div'); + const popup = document.createElement('div'); + const p = document.createElement('p'); + p.innerText = text; + const buttons = document.createElement('div'); + const answerBtn = document.createElement('button'); + answerBtn.innerHTML = declineIcon.replace('fill="#ef5261"', 'fill="green"'); + const declineBtn = document.createElement('button'); + declineBtn.innerHTML = declineIcon; + buttons.appendChild(answerBtn); + buttons.appendChild(declineBtn); + popup.appendChild(p); + popup.appendChild(buttons); + + const btnStyles = { + borderRadius: "50%", + width: "22px", + height: "22px", + background: "transparent", + padding: 0, + margin: 0, + border: 0, + cursor: "pointer", + } + Object.assign(answerBtn.style, btnStyles); + Object.assign(declineBtn.style, btnStyles); + Object.assign(buttons.style, { + marginTop: "10px", + display: "flex", + alignItems: "center", + justifyContent: "space-evenly", + }); + + Object.assign(popup.style, { + position: "relative", + pointerEvents: "auto", + margin: "4em auto", + width: "90%", + maxWidth: "400px", + padding: "25px 30px", + background: "black", + opacity: ".75", + color: "white", + textAlign: "center", + borderRadius: ".25em .25em .4em .4em", + boxShadow: "0 0 20px rgb(0 0 0 / 20%)", + }, styles); + + Object.assign(wrapper.style, { + position: "fixed", + left: 0, + top: 0, + height: "100%", + width: "100%", + pointerEvents: "none", + zIndex: 2147483647 - 1, + }) + + wrapper.appendChild(popup); + this.wrapper = wrapper; + + answerBtn.onclick = () => { + this.remove(); + this.callback(true); + } + declineBtn.onclick = () => { + this.remove(); + this.callback(false); + } + } + + mount() { + document.body.appendChild(this.wrapper); + } + + private callback: (result: boolean) => void = ()=>{}; + onAnswer(callback: (result: boolean) => void) { + this.callback = callback; + } + + remove() { + if (!this.wrapper.parentElement) { return; } + document.body.removeChild(this.wrapper); + } +} diff --git a/tracker/tracker-assist/src/index.ts b/tracker/tracker-assist/src/index.ts new file mode 100644 index 000000000..a9a632cb2 --- /dev/null +++ b/tracker/tracker-assist/src/index.ts @@ -0,0 +1,176 @@ +import Peer, { MediaConnection } from 'peerjs'; +import type { DataConnection } from 'peerjs'; +import { App, Messages } from '@openreplay/tracker'; +import type Message from '@openreplay/tracker'; + +import Mouse from './Mouse'; +import CallWindow from './CallWindow'; +import Confirm from './Confirm'; + + +export interface Options { + confirmText: string, + confirmStyle: Object, // Styles object +} + + +enum CallingState { + Requesting, + True, + False, +}; + +export default function(opts: Partial = {}) { + const options: Options = Object.assign( + { + confirmText: "You have a call. Do you want to answer?", + confirmStyle: {}, + }, + opts, + ); + return function(app: App | null, appOptions: { __DISABLE_SECURE_MODE?: boolean } = {}) { + // @ts-ignore + if (app === null || !navigator?.mediaDevices?.getUserMedia) { // 93.04% browsers + return; + } + + app.attachStartCallback(function() { + // @ts-ignore + const peerID = `${app.projectKey}-${app.getSessionID()}` + const peer = new Peer(peerID, { + // @ts-ignore + host: app.getHost(), + path: '/assist', + port: location.protocol === 'http:' && appOptions.__DISABLE_SECURE_MODE ? 80 : 443, + }); + console.log(peerID) + peer.on('connection', function(conn) { + window.addEventListener("beforeunload", () => conn.open && conn.send("unload")); + + console.log('connection') + conn.on('open', function() { + + console.log('connection open') + + // TODO: onClose + const buffer: Message[][] = []; + let buffering = false; + function sendNext() { + if (buffer.length) { + setTimeout(() => { + conn.send(buffer.shift()); + sendNext(); + }, 50); + } else { + buffering = false; + } + } + app.stop(); + //@ts-ignore (should update tracker dependency) + app.addCommitCallback((messages: Array): void => { + let i = 0; + while (i < messages.length) { + buffer.push(messages.slice(i, i+=1000)); + } + if (!buffering) { + buffering = true; + sendNext(); + } + }); + app.start(); + }); + }); + + + let calling: CallingState = CallingState.False; + peer.on('call', function(call) { + const dataConn: DataConnection | undefined = peer + .connections[call.peer].find(c => c.type === 'data'); + if (calling !== CallingState.False || !dataConn) { + call.close(); + return; + } + + calling = CallingState.Requesting; + const notifyCallEnd = () => { + dataConn.open && dataConn.send("call_end"); + } + + const confirm = new Confirm(options.confirmText, options.confirmStyle); + dataConn.on('data', (data) => { // if call closed by a caller before confirm + if (data === "call_end") { + console.log('receiving callend onconfirm') + calling = CallingState.False; + confirm.remove(); + } + }); + confirm.mount(); + confirm.onAnswer(agreed => { + if (!agreed || !dataConn.open) { + call.close(); + notifyCallEnd(); + calling = CallingState.False; + return; + } + + const mouse = new Mouse(); + let callUI; + + navigator.mediaDevices.getUserMedia({video:true, audio:true}) + .then(lStream => { + const onCallEnd = () => { + console.log("on callend", call.open) + mouse.remove(); + callUI?.remove(); + lStream.getTracks().forEach(t => t.stop()); + calling = CallingState.False; + } + const initiateCallEnd = () => { + console.log("callend initiated") + call.close() + notifyCallEnd(); + onCallEnd(); + } + + call.answer(lStream); + + dataConn.on("close", onCallEnd); + + //call.on('close', onClose); // Works from time to time (peerjs bug) + const intervalID = setInterval(() => { + if (!dataConn.open) { + initiateCallEnd(); + clearInterval(intervalID); + } + if (!call.open) { + onCallEnd(); + clearInterval(intervalID); + } + }, 3000); + call.on('error', initiateCallEnd); + + callUI = new CallWindow(initiateCallEnd); + callUI.setLocalStream(lStream); + call.on('stream', function(rStream) { + callUI.setRemoteStream(rStream); + dataConn.on('data', (data: any) => { + if (data === "call_end") { + console.log('receiving callend on call') + onCallEnd(); + return; + } + if (data && typeof data.name === 'string') { + console.log("name",data) + callUI.setAssistentName(data.name); + } + if (data && typeof data.x === 'number' && typeof data.y === 'number') { + mouse.move(data); + } + }); + }); + }); + }); + }); + }); + } +} diff --git a/tracker/tracker-assist/tsconfig-cjs.json b/tracker/tracker-assist/tsconfig-cjs.json new file mode 100644 index 000000000..72d985654 --- /dev/null +++ b/tracker/tracker-assist/tsconfig-cjs.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "CommonJS", + "outDir": "./cjs" + }, +} \ No newline at end of file diff --git a/tracker/tracker-assist/tsconfig.json b/tracker/tracker-assist/tsconfig.json new file mode 100644 index 000000000..bb8f6a4c4 --- /dev/null +++ b/tracker/tracker-assist/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "noImplicitThis": true, + "strictNullChecks": true, + "alwaysStrict": true, + "target": "es6", + "module": "es6", + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "declaration": true, + "outDir": "./lib" + } +} diff --git a/tracker/tracker-axios/package.json b/tracker/tracker-axios/package.json index fa986799e..f013633ef 100644 --- a/tracker/tracker-axios/package.json +++ b/tracker/tracker-axios/package.json @@ -2,7 +2,7 @@ "name": "@openreplay/tracker-axios", "description": "Tracker plugin for axios requests recording", "version": "3.0.1", - "axios": [ + "keywords": [ "axios", "logging", "replay" diff --git a/tracker/tracker/package-lock.json b/tracker/tracker/package-lock.json index e08453206..e478e35c6 100644 --- a/tracker/tracker/package-lock.json +++ b/tracker/tracker/package-lock.json @@ -1,6 +1,6 @@ { - "name": "@asayerio/tracker", - "version": "5.6.5", + "name": "@openreplay/tracker", + "version": "3.1.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -4513,9 +4513,9 @@ "dev": true }, "typescript": { - "version": "3.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.5.tgz", - "integrity": "sha512-hSAifV3k+i6lEoCJ2k6R2Z/rp/H3+8sdmcn5NrS3/3kE7+RyZXm9aqvxWqjEXHAd8b0pShatpcdMTvEdvAJltQ==", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.3.4.tgz", + "integrity": "sha512-uauPG7XZn9F/mo+7MrsRjyvbxFpzemRjKEZXS4AK83oP2KKOJPvb+9cO/gmnv8arWZvhnjVOXz7B49m1l0e9Ew==", "dev": true }, "unc-path-regex": { diff --git a/tracker/tracker/package.json b/tracker/tracker/package.json index 001789f90..3692baa66 100644 --- a/tracker/tracker/package.json +++ b/tracker/tracker/package.json @@ -1,7 +1,7 @@ { "name": "@openreplay/tracker", "description": "The OpenReplay tracker main package", - "version": "3.0.4", + "version": "3.1.0", "keywords": [ "logging", "replay" @@ -18,9 +18,9 @@ "clean": "rm -Rf build && rm -Rf lib && rm -Rf cjs", "tsc": "tsc -b src/main && tsc -b src/webworker && tsc --project src/main/tsconfig-cjs.json", "rollup": "rollup --config rollup.config.js", - "compile": "node --experimental-modules --experimental-json-modules compile.js", + "compile": "node --experimental-modules --experimental-json-modules scripts/compile.js", "build": "npm run clean && npm run tsc && npm run rollup && npm run compile", - "prepare": "node checkver.cjs && npm run build" + "prepare": "node scripts/checkver.cjs && npm run build" }, "devDependencies": { "@babel/core": "^7.10.2", @@ -38,7 +38,7 @@ "rollup": "^2.17.0", "rollup-plugin-terser": "^6.1.0", "semver": "^6.3.0", - "typescript": "^3.9.5" + "typescript": "^4.3.4" }, "dependencies": { "error-stack-parser": "^2.0.6" diff --git a/tracker/tracker/checkver.cjs b/tracker/tracker/scripts/checkver.cjs similarity index 83% rename from tracker/tracker/checkver.cjs rename to tracker/tracker/scripts/checkver.cjs index 7ffdc5d91..b636857f4 100644 --- a/tracker/tracker/checkver.cjs +++ b/tracker/tracker/scripts/checkver.cjs @@ -1,5 +1,5 @@ const semver = require("semver"); -const { engines } = require("./package"); +const { engines } = require("../package"); const version = engines.node; if (!semver.satisfies(process.version, version)) { console.error(`Required node version ${version}, got ${process.version}.`) diff --git a/tracker/tracker/compile.js b/tracker/tracker/scripts/compile.js similarity index 96% rename from tracker/tracker/compile.js rename to tracker/tracker/scripts/compile.js index 2b6dfa215..6e7bd43a1 100644 --- a/tracker/tracker/compile.js +++ b/tracker/tracker/scripts/compile.js @@ -1,6 +1,6 @@ import { promises as fs } from 'fs'; import replaceInFiles from 'replace-in-files'; -import packageConfig from './package.json'; +import packageConfig from '../package.json'; async function main() { const webworker = await fs.readFile('build/webworker.js', 'utf8'); diff --git a/tracker/tracker/src/main/app/index.ts b/tracker/tracker/src/main/app/index.ts index 44efd9f84..6e862e0bb 100644 --- a/tracker/tracker/src/main/app/index.ts +++ b/tracker/tracker/src/main/app/index.ts @@ -9,7 +9,7 @@ import { deviceMemory, jsHeapSizeLimit } from '../modules/performance'; import type { Options as ObserverOptions } from './observer'; -import type { Options as WebworkerOptions, MessageData } from '../../webworker/types'; +import type { Options as WebworkerOptions, WorkerMessageData } from '../../messages/webworker'; export type Options = { revID: string; @@ -23,19 +23,22 @@ export type Options = { } & ObserverOptions & WebworkerOptions; type Callback = () => void; +type CommitCallback = (messages: Array) => void; export const DEFAULT_INGEST_POINT = 'https://ingest.openreplay.com'; export default class App { readonly nodes: Nodes; readonly ticker: Ticker; + readonly projectKey: string; private readonly messages: Array = []; private readonly observer: Observer; - private readonly startCallbacks: Array; - private readonly stopCallbacks: Array; + private readonly startCallbacks: Array = []; + private readonly stopCallbacks: Array = []; + private readonly commitCallbacks: Array = []; private readonly options: Options; - private readonly projectKey: string; private readonly revID: string; + private _sessionID: string | null = null; private isActive = false; private version = 'TRACKER_VERSION'; private readonly worker?: Worker; @@ -67,8 +70,6 @@ export default class App { this.observer = new Observer(this, this.options); this.ticker = new Ticker(this); this.ticker.attach(() => this.commit()); - this.startCallbacks = []; - this.stopCallbacks = []; try { this.worker = new Worker( URL.createObjectURL( @@ -94,8 +95,10 @@ export default class App { this.worker.postMessage(null); } } + // TODO: keep better tactics, discard others (look https://developer.mozilla.org/en-US/docs/Web/API/Navigator/sendBeacon) this.attachEventListener(window, 'beforeunload', alertWorker, false); this.attachEventListener(document, 'mouseleave', alertWorker, false, false); + this.attachEventListener(document, 'visibilitychange', alertWorker, false); } catch (e) { /* TODO: send report */} } send(message: Message, urgent = false): void { @@ -111,10 +114,16 @@ export default class App { if (this.worker && this.messages.length) { this.messages.unshift(new Timestamp(timestamp())); this.worker.postMessage(this.messages); + this.commitCallbacks.forEach(cb => cb(this.messages)); this.messages.length = 0; } } + addCommitCallback(cb: CommitCallback): void { + this.commitCallbacks.push(cb) + } + + safe void>(fn: T): T { const app = this; return function (this: any, ...args: any) { @@ -161,9 +170,11 @@ export default class App { return token; } } - // @Depricated; for the old fetch-plugin versions - sessionID(): string | undefined { - return this.getSessionToken(); + getSessionID(): string | undefined { + return this._sessionID || undefined; + } + getHost(): string { + return new URL(this.options.ingestPoint).host; } isServiceURL(url: string): boolean { @@ -189,7 +200,7 @@ export default class App { sessionStorage.setItem(this.options.session_pageno_key, pageNo.toString()); const startTimestamp = timestamp(); - const messageData: MessageData = { + const messageData: WorkerMessageData = { ingestPoint: this.options.ingestPoint, pageNo, startTimestamp, @@ -197,10 +208,6 @@ export default class App { connAttemptGap: this.options.connAttemptGap, } this.worker.postMessage(messageData); // brings delay of 10th ms? - this.observer.observe(); - this.startCallbacks.forEach((cb) => cb()); - this.ticker.start(); - window.fetch(this.options.ingestPoint + '/v1/web/start', { method: 'POST', headers: { @@ -227,21 +234,27 @@ export default class App { } }) .then(r => { - const { token, userUUID } = r; + const { token, userUUID, sessionID } = r; if (typeof token !== 'string' || typeof userUUID !== 'string') { throw new Error("Incorrect server responce"); } sessionStorage.setItem(this.options.session_token_key, token); localStorage.setItem(this.options.local_uuid_key, userUUID); + if (typeof sessionID === 'string') { + this._sessionID = sessionID; + } if (!this.worker) { throw new Error("Stranger things: no worker found after start request"); } this.worker.postMessage({ token }); + this.observer.observe(); + this.startCallbacks.forEach((cb) => cb()); + this.ticker.start(); log("OpenReplay tracking started."); if (typeof this.options.onStart === 'function') { - this.options.onStart({ sessionToken: token, userUUID, sessionID: token /* back compat (depricated) */ }); + this.options.onStart({ sessionToken: token, userUUID, sessionID }); } }) .catch(e => { diff --git a/tracker/tracker/src/main/index.ts b/tracker/tracker/src/main/index.ts index d6c8481df..ff90b51e5 100644 --- a/tracker/tracker/src/main/index.ts +++ b/tracker/tracker/src/main/index.ts @@ -64,12 +64,12 @@ function processOptions(obj: any): obj is Options { export default class API { private readonly app: App | null = null; - constructor(options: Options) { + constructor(private readonly options: Options) { if (!IN_BROWSER || !processOptions(options)) { return; } if (!options.__DISABLE_SECURE_MODE && location.protocol !== 'https:') { - console.error("OpenReplay: Your website must be publicly accessible and running on SSL in order for OpenReplay to properly capture and replay the user session.") + console.error("OpenReplay: Your website must be publicly accessible and running on SSL in order for OpenReplay to properly capture and replay the user session. You can disable this check by setting `__DISABLE_SECURE_MODE` option to `true` if you are testing in localhost. Keep in mind, that asset files on a local machine are not available to the outside world. This might affect tracking if you use css files.") return; } const doNotTrack = options.respectDoNotTrack && (navigator.doNotTrack == '1' || window.doNotTrack == '1'); @@ -114,8 +114,8 @@ export default class API { } } - use(fn: (app: App | null) => T): T { - return fn(this.app); + use(fn: (app: App | null, options?: Options) => T): T { + return fn(this.app, this.options); } isActive(): boolean { @@ -152,9 +152,15 @@ export default class API { } return this.app.getSessionToken(); } + getSessionID(): string | null | undefined { + if (this.app === null) { + return null; + } + return this.app.getSessionID(); + } sessionID(): string | null | undefined { - depricationWarn("'sessionID' method", "'getSessionToken' method", "/") - return this.getSessionToken(); + depricationWarn("'sessionID' method", "'getSessionID' method", "/"); + return this.getSessionID(); } setUserID(id: string): void { diff --git a/tracker/tracker/src/main/modules/cssrules.ts b/tracker/tracker/src/main/modules/cssrules.ts index 50c6fde39..366a7d3fe 100644 --- a/tracker/tracker/src/main/modules/cssrules.ts +++ b/tracker/tracker/src/main/modules/cssrules.ts @@ -1,5 +1,6 @@ import App from '../app'; -import { CSSInsertRule, CSSDeleteRule, TechnicalInfo } from '../../messages'; +import { CSSInsertRuleURLBased, CSSDeleteRule, TechnicalInfo } from '../../messages'; +import { getBaseURI } from '../utils'; export default function(app: App | null) { if (app === null) { @@ -13,7 +14,7 @@ export default function(app: App | null) { const processOperation = app.safe( (stylesheet: CSSStyleSheet, index: number, rule?: string) => { const sendMessage = typeof rule === 'string' - ? (nodeID: number) => app.send(new CSSInsertRule(nodeID, rule, index)) + ? (nodeID: number) => app.send(new CSSInsertRuleURLBased(nodeID, rule, index, getBaseURI())) : (nodeID: number) => app.send(new CSSDeleteRule(nodeID, index)); // TODO: Extend messages to maintain nested rules (CSSGroupingRule prototype, as well as CSSKeyframesRule) if (stylesheet.ownerNode == null) { diff --git a/tracker/tracker/src/main/modules/performance.ts b/tracker/tracker/src/main/modules/performance.ts index cefa6044b..2a4edd149 100644 --- a/tracker/tracker/src/main/modules/performance.ts +++ b/tracker/tracker/src/main/modules/performance.ts @@ -2,13 +2,19 @@ import App from '../app'; import { IN_BROWSER } from '../utils'; import { PerformanceTrack } from '../../messages'; -const perf: { - memory: { - jsHeapSizeLimit?: number; - totalJSHeapSize?: number; - usedJSHeapSize?: number; - }; -} = IN_BROWSER && 'memory' in performance ? performance : { memory: {} }; + +type Perf = { + memory: { + totalJSHeapSize?: number, + usedJSHeapSize?: number, + jsHeapSizeLimit?: number, + } +} + +const perf: Perf = IN_BROWSER && 'memory' in performance // works in Chrome only + ? performance as any + : { memory: {} } + export const deviceMemory = IN_BROWSER ? ((navigator as any).deviceMemory || 0) * 1024 : 0; export const jsHeapSizeLimit = perf.memory.jsHeapSizeLimit || 0; diff --git a/tracker/tracker/src/webworker/types.ts b/tracker/tracker/src/messages/webworker.ts similarity index 57% rename from tracker/tracker/src/webworker/types.ts rename to tracker/tracker/src/messages/webworker.ts index 22f55fad1..e5eb8eef7 100644 --- a/tracker/tracker/src/webworker/types.ts +++ b/tracker/tracker/src/messages/webworker.ts @@ -1,6 +1,8 @@ +// TODO: "common" folder instead of "messages". (better file structure) export interface Options { connAttemptCount?: number; connAttemptGap?: number; + beaconSize?: number; } type Settings = { @@ -11,4 +13,4 @@ type Settings = { timeAdjustment?: number; } & Partial; -export type MessageData = null | "stop" | Settings | Array<{ _id: number }>; \ No newline at end of file +export type WorkerMessageData = null | "stop" | Settings | Array<{ _id: number }>; \ No newline at end of file diff --git a/tracker/tracker/src/webworker/index.ts b/tracker/tracker/src/webworker/index.ts index 409a44b4e..271c13f2b 100644 --- a/tracker/tracker/src/webworker/index.ts +++ b/tracker/tracker/src/webworker/index.ts @@ -2,13 +2,15 @@ import { classes, BatchMeta, Timestamp, SetPageVisibility, CreateDocument } from import Message from '../messages/message'; import Writer from '../messages/writer'; -import type { MessageData } from './types'; +import type { WorkerMessageData } from '../messages/webworker'; + -// TODO: what if on message overflows? (maybe one option) -const MAX_BATCH_SIZE = 4 * 1e5; // Max 400kB const SEND_INTERVAL = 20 * 1000; +const BEACON_SIZE_LIMIT = 1e6 // Limit is set in the backend/services/http +let beaconSize = 4 * 1e5; // Default 400kB -const writer: Writer = new Writer(MAX_BATCH_SIZE); + +let writer: Writer = new Writer(beaconSize); let ingestPoint: string = ""; let token: string = ""; @@ -31,9 +33,12 @@ let busy = false; let attemptsCount = 0; let ATTEMPT_TIMEOUT = 8000; let MAX_ATTEMPTS_COUNT = 10; + +// TODO?: exploit https://developer.mozilla.org/en-US/docs/Web/API/Navigator/sendBeacon function sendBatch(batch: Uint8Array):void { const req = new XMLHttpRequest(); - req.open("POST", ingestPoint + "/v1/web/i"); // TODO opaque request? + // TODO: async=false (3d param) instead of sendQueue array ? + req.open("POST", ingestPoint + "/v1/web/i", false); // TODO opaque request? req.setRequestHeader("Authorization", "Bearer " + token); // req.setRequestHeader("Content-Type", ""); req.onreadystatechange = function() { @@ -41,7 +46,7 @@ function sendBatch(batch: Uint8Array):void { if (this.status == 0) { return; // happens simultaneously with onerror TODO: clear codeflow } - if (this.status >= 400) { + if (this.status >= 400) { // TODO: test workflow. After 400+ it calls /start for some reason reset(); sendQueue.length = 0; if (this.status === 403) { // Unauthorised (Token expired) @@ -69,7 +74,7 @@ function sendBatch(batch: Uint8Array):void { attemptsCount++; setTimeout(() => sendBatch(batch), ATTEMPT_TIMEOUT); } - req.send(batch); + req.send(batch.buffer); } function send(): void { @@ -100,7 +105,7 @@ function hasTimestamp(msg: any): msg is { timestamp: number } { return typeof msg === 'object' && typeof msg.timestamp === 'number'; } -self.onmessage = ({ data }: MessageEvent) => { +self.onmessage = ({ data }: MessageEvent) => { if (data === null) { send(); return; @@ -118,6 +123,7 @@ self.onmessage = ({ data }: MessageEvent) => { timeAdjustment = data.timeAdjustment || timeAdjustment; MAX_ATTEMPTS_COUNT = data.connAttemptCount || MAX_ATTEMPTS_COUNT; ATTEMPT_TIMEOUT = data.connAttemptGap || ATTEMPT_TIMEOUT; + beaconSize = Math.min(BEACON_SIZE_LIMIT, data.beaconSize || beaconSize); if (writer.isEmpty()) { writeBatchMeta(); } @@ -126,7 +132,7 @@ self.onmessage = ({ data }: MessageEvent) => { } return; } - data.forEach((data: any) => { + data.forEach((data) => { const message: Message = new (classes.get(data._id))(); Object.assign(message, data); @@ -140,20 +146,26 @@ self.onmessage = ({ data }: MessageEvent) => { } } - writer.checkpoint(); - nextIndex++; - if (message.encode(writer)) { - isEmpty = false; - } else { + writer.checkpoint(); // TODO: incapsulate in writer + if (!message.encode(writer)) { send(); - if (message.encode(writer)) { - isEmpty = false; - } else { - // MAX_BATCH_SIZE overflow by one message - // TODO: correct handle - nextIndex--; - return; - } + // writer.reset(); // TODO: sematically clear code + if (!message.encode(writer)) { // Try to encode within empty state + // MBTODO: tempWriter for one message? + while (!message.encode(writer)) { + if (beaconSize === BEACON_SIZE_LIMIT) { + console.warn("OpenReplay: beacon size overflow."); + writer.reset(); + writeBatchMeta(); + return + } + beaconSize = Math.min(beaconSize*2, BEACON_SIZE_LIMIT); + writer = new Writer(beaconSize); + writeBatchMeta(); + } + } }; + nextIndex++; // TODO: incapsulate in writer + isEmpty = false; }); }; diff --git a/tracker/tracker/src/webworker/transformer.js b/tracker/tracker/src/webworker/transformer.js.temp similarity index 100% rename from tracker/tracker/src/webworker/transformer.js rename to tracker/tracker/src/webworker/transformer.js.temp diff --git a/utilities/.gitignore b/utilities/.gitignore new file mode 100644 index 000000000..fffbe974b --- /dev/null +++ b/utilities/.gitignore @@ -0,0 +1,5 @@ +.idea +node_modules +npm-debug.log +.cache +test.html \ No newline at end of file diff --git a/utilities/Dockerfile b/utilities/Dockerfile new file mode 100644 index 000000000..3867ed94a --- /dev/null +++ b/utilities/Dockerfile @@ -0,0 +1,15 @@ +FROM node:12.22-stretch +WORKDIR /work +COPY . . +RUN npm install + + +# Add Tini +# Startup daemon +ENV TINI_VERSION v0.19.0 +ARG envarg +ENV ENTERPRISE_BUILD ${envarg} +ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /tini +RUN chmod +x /tini +ENTRYPOINT ["/tini", "--"] +CMD npm start \ No newline at end of file diff --git a/utilities/build.sh b/utilities/build.sh new file mode 100644 index 000000000..99be144fe --- /dev/null +++ b/utilities/build.sh @@ -0,0 +1,32 @@ +#!/bin/bash + +# Script to build api module +# flags to accept: +# Default will be OSS build. + +# Usage: IMAGE_TAG=latest DOCKER_REPO=myDockerHubID bash build.sh + +git_sha1=${IMAGE_TAG:-$(git rev-parse HEAD)} +check_prereq() { + which docker || { + echo "Docker not installed, please install docker." + exit=1 + } + [[ exit -eq 1 ]] && exit 1 +} + +function build_api(){ + # Copy enterprise code + [[ $1 == "ee" ]] && { + cp -rf ../ee/utilities/* ./ + } + docker build -f ./Dockerfile -t ${DOCKER_REPO:-'local'}/utilities:${git_sha1} . + [[ $PUSH_IMAGE -eq 1 ]] && { + docker push ${DOCKER_REPO:-'local'}/utilities:${git_sha1} + docker tag ${DOCKER_REPO:-'local'}/utilities:${git_sha1} ${DOCKER_REPO:-'local'}/utilities:latest + docker push ${DOCKER_REPO:-'local'}/utilities:latest + } +} + +check_prereq +build_api $1 diff --git a/utilities/package-lock.json b/utilities/package-lock.json new file mode 100644 index 000000000..3de98ac06 --- /dev/null +++ b/utilities/package-lock.json @@ -0,0 +1,779 @@ +{ + "name": "utilities_server", + "version": "1.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@types/body-parser": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.0.tgz", + "integrity": "sha512-W98JrE0j2K78swW4ukqMleo8R7h/pFETjM2DQ90MF6XK2i4LO4W3gQ71Lt4w3bfm2EvVSyWHplECvB5sK22yFQ==", + "requires": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "@types/connect": { + "version": "3.4.34", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.34.tgz", + "integrity": "sha512-ePPA/JuI+X0vb+gSWlPKOY0NdNAie/rPUqX2GUPpbZwiKTkSPhjXWuee47E4MtE54QVzGCQMQkAL6JhV2E1+cQ==", + "requires": { + "@types/node": "*" + } + }, + "@types/cors": { + "version": "2.8.10", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.10.tgz", + "integrity": "sha512-C7srjHiVG3Ey1nR6d511dtDkCEjxuN9W1HWAEjGq8kpcwmNM6JJkpC0xvabM7BXTG2wDq8Eu33iH9aQKa7IvLQ==" + }, + "@types/express": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.12.tgz", + "integrity": "sha512-pTYas6FrP15B1Oa0bkN5tQMNqOcVXa9j4FTFtO8DWI9kppKib+6NJtfTOOLcwxuuYvcX2+dVG6et1SxW/Kc17Q==", + "requires": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.18", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "@types/express-serve-static-core": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.21.tgz", + "integrity": "sha512-gwCiEZqW6f7EoR8TTEfalyEhb1zA5jQJnRngr97+3pzMaO1RKoI1w2bw07TK72renMUVWcWS5mLI6rk1NqN0nA==", + "requires": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*" + } + }, + "@types/mime": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", + "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==" + }, + "@types/node": { + "version": "15.12.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-15.12.4.tgz", + "integrity": "sha512-zrNj1+yqYF4WskCMOHwN+w9iuD12+dGm0rQ35HLl9/Ouuq52cEtd0CH9qMgrdNmi5ejC1/V7vKEXYubB+65DkA==" + }, + "@types/qs": { + "version": "6.9.6", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.6.tgz", + "integrity": "sha512-0/HnwIfW4ki2D8L8c9GVcG5I72s9jP5GSLVF0VIXDW00kmIpA6O33G7a8n59Tmh7Nz0WUC3rSb7PTY/sdW2JzA==" + }, + "@types/range-parser": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.3.tgz", + "integrity": "sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA==" + }, + "@types/serve-static": { + "version": "1.13.9", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.9.tgz", + "integrity": "sha512-ZFqF6qa48XsPdjXV5Gsz0Zqmux2PerNd3a/ktL45mHpa19cuMi/cL8tcxdAx497yRh+QtYPuofjT9oWw9P7nkA==", + "requires": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "@types/ws": { + "version": "7.4.5", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-7.4.5.tgz", + "integrity": "sha512-8mbDgtc8xpxDDem5Gwj76stBDJX35KQ3YBoayxlqUQcL5BZUthiqP/VQ4PQnLHqM4PmlbyO74t98eJpURO+gPA==", + "requires": { + "@types/node": "*" + } + }, + "accepts": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz", + "integrity": "sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==", + "requires": { + "mime-types": "~2.1.24", + "negotiator": "0.6.2" + } + }, + "ansi-regex": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", + "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==" + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "requires": { + "color-convert": "^2.0.1" + } + }, + "array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=" + }, + "aws-sdk": { + "version": "2.932.0", + "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.932.0.tgz", + "integrity": "sha512-U6MWUtFD0npWa+ReVEgm0fCIM0fMOYahFp14GLv8fC+BWOTvh5Iwt/gF8NrLomx42bBjA1Abaw6yhmiaSJDQHQ==", + "requires": { + "buffer": "4.9.2", + "events": "1.1.1", + "ieee754": "1.1.13", + "jmespath": "0.15.0", + "querystring": "0.2.0", + "sax": "1.2.1", + "url": "0.10.3", + "uuid": "3.3.2", + "xml2js": "0.4.19" + }, + "dependencies": { + "uuid": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz", + "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==" + } + } + }, + "base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" + }, + "body-parser": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.19.0.tgz", + "integrity": "sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==", + "requires": { + "bytes": "3.1.0", + "content-type": "~1.0.4", + "debug": "2.6.9", + "depd": "~1.1.2", + "http-errors": "1.7.2", + "iconv-lite": "0.4.24", + "on-finished": "~2.3.0", + "qs": "6.7.0", + "raw-body": "2.4.0", + "type-is": "~1.6.17" + } + }, + "buffer": { + "version": "4.9.2", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz", + "integrity": "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==", + "requires": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4", + "isarray": "^1.0.0" + } + }, + "bytes": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz", + "integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==" + }, + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==" + }, + "cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "content-disposition": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.3.tgz", + "integrity": "sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==", + "requires": { + "safe-buffer": "5.1.2" + } + }, + "content-type": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", + "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==" + }, + "cookie": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz", + "integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==" + }, + "cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=" + }, + "cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "requires": { + "object-assign": "^4", + "vary": "^1" + } + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=" + }, + "depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=" + }, + "destroy": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", + "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=" + }, + "ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" + }, + "emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=" + }, + "escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=" + }, + "etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=" + }, + "events": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz", + "integrity": "sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ=" + }, + "express": { + "version": "4.17.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.17.1.tgz", + "integrity": "sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g==", + "requires": { + "accepts": "~1.3.7", + "array-flatten": "1.1.1", + "body-parser": "1.19.0", + "content-disposition": "0.5.3", + "content-type": "~1.0.4", + "cookie": "0.4.0", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "~1.1.2", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.1.2", + "fresh": "0.5.2", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.5", + "qs": "6.7.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.1.2", + "send": "0.17.1", + "serve-static": "1.14.1", + "setprototypeof": "1.1.1", + "statuses": "~1.5.0", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + } + }, + "finalhandler": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", + "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "requires": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "statuses": "~1.5.0", + "unpipe": "~1.0.0" + } + }, + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==" + }, + "fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=" + }, + "get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==" + }, + "http-errors": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.2.tgz", + "integrity": "sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg==", + "requires": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.1", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.0" + } + }, + "iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, + "ieee754": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz", + "integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==" + }, + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" + }, + "ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" + }, + "is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" + }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" + }, + "jmespath": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.15.0.tgz", + "integrity": "sha1-o/Iiqarp+Wb10nx5ZRDigJF2Qhc=" + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "requires": { + "p-locate": "^4.1.0" + } + }, + "media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=" + }, + "merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=" + }, + "methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=" + }, + "mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==" + }, + "mime-db": { + "version": "1.48.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.48.0.tgz", + "integrity": "sha512-FM3QwxV+TnZYQ2aRqhlKBMHxk10lTbMt3bBkMAp54ddrNeVSfcQYOOKuGuy3Ddrm38I04If834fOUSq1yzslJQ==" + }, + "mime-types": { + "version": "2.1.31", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.31.tgz", + "integrity": "sha512-XGZnNzm3QvgKxa8dpzyhFTHmpP3l5YNusmne07VUOXxou9CqUqYa/HBy124RqtVh/O2pECas/MOcsDgpilPOPg==", + "requires": { + "mime-db": "1.48.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + }, + "negotiator": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz", + "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==" + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" + }, + "on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=", + "requires": { + "ee-first": "1.1.1" + } + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "requires": { + "p-limit": "^2.2.0" + } + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==" + }, + "parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==" + }, + "path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=" + }, + "peer": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/peer/-/peer-0.6.1.tgz", + "integrity": "sha512-zPJSPoZvo+83sPJNrW8o93QTktx7dKk67965RRDDNAIelWw1ZwE6ZmmhsvRrdNRlK0knQb3rR8GBdZlbWzCYJw==", + "requires": { + "@types/cors": "^2.8.6", + "@types/express": "^4.17.3", + "@types/ws": "^7.2.3", + "body-parser": "^1.19.0", + "cors": "^2.8.5", + "express": "^4.17.1", + "uuid": "^3.4.0", + "ws": "^7.2.3", + "yargs": "^15.3.1" + } + }, + "proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "requires": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + } + }, + "punycode": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", + "integrity": "sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0=" + }, + "qs": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz", + "integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==" + }, + "querystring": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", + "integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=" + }, + "range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" + }, + "raw-body": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.0.tgz", + "integrity": "sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==", + "requires": { + "bytes": "3.1.0", + "http-errors": "1.7.2", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + } + }, + "require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=" + }, + "require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==" + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "sax": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz", + "integrity": "sha1-e45lYZCyKOgaZq6nSEgNgozS03o=" + }, + "send": { + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/send/-/send-0.17.1.tgz", + "integrity": "sha512-BsVKsiGcQMFwT8UxypobUKyv7irCNRHk1T0G680vk88yf6LBByGcZJOTJCrTP2xVN6yI+XjPJcNuE3V4fT9sAg==", + "requires": { + "debug": "2.6.9", + "depd": "~1.1.2", + "destroy": "~1.0.4", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "~1.7.2", + "mime": "1.6.0", + "ms": "2.1.1", + "on-finished": "~2.3.0", + "range-parser": "~1.2.1", + "statuses": "~1.5.0" + }, + "dependencies": { + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==" + } + } + }, + "serve-static": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.14.1.tgz", + "integrity": "sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg==", + "requires": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.17.1" + } + }, + "set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=" + }, + "setprototypeof": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz", + "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==" + }, + "source-map": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", + "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==" + }, + "statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=" + }, + "string-width": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.2.tgz", + "integrity": "sha512-XBJbT3N4JhVumXE0eoLU9DCjcaF92KLNqTmFCnG1pf8duUxFGwtP6AD6nkjw9a3IdiRtL3E2w3JDiE/xi3vOeA==", + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.0" + } + }, + "strip-ansi": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", + "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", + "requires": { + "ansi-regex": "^5.0.0" + } + }, + "toidentifier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", + "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==" + }, + "type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "requires": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + } + }, + "unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=" + }, + "url": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/url/-/url-0.10.3.tgz", + "integrity": "sha1-Ah5NnHcF8hu/N9A861h2dAJ3TGQ=", + "requires": { + "punycode": "1.3.2", + "querystring": "0.2.0" + } + }, + "utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=" + }, + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" + }, + "vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=" + }, + "which-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", + "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=" + }, + "wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + } + }, + "ws": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.0.tgz", + "integrity": "sha512-6ezXvzOZupqKj4jUqbQ9tXuJNo+BR2gU8fFRk3XCP3e0G6WT414u5ELe6Y0vtp7kmSJ3F7YWObSNr1ESsgi4vw==" + }, + "xml2js": { + "version": "0.4.19", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.19.tgz", + "integrity": "sha512-esZnJZJOiJR9wWKMyuvSE1y6Dq5LCuJanqhxslH2bxM6duahNZ+HMpCLhBQGZkbX6xRf8x1Y2eJlgt2q3qo49Q==", + "requires": { + "sax": ">=0.6.0", + "xmlbuilder": "~9.0.1" + } + }, + "xmlbuilder": { + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-9.0.7.tgz", + "integrity": "sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0=" + }, + "y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==" + }, + "yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "requires": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + } + }, + "yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "requires": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } + } + } +} diff --git a/utilities/package.json b/utilities/package.json new file mode 100644 index 000000000..d0cfcdbcc --- /dev/null +++ b/utilities/package.json @@ -0,0 +1,26 @@ +{ + "name": "utilities_server", + "version": "1.0.0", + "description": "assist server to get live sessions & sourcemaps reader to get stack trace", + "main": "peerjs-server.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "start": "node server.js" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/openreplay/openreplay.git" + }, + "author": "KRAIEM Taha Yassine ", + "license": "MIT", + "bugs": { + "url": "https://github.com/openreplay/openreplay/issues" + }, + "homepage": "https://github.com/openreplay/openreplay#readme", + "dependencies": { + "aws-sdk": "^2.654.0", + "express": "^4.17.1", + "peer": "^0.6.1", + "source-map": "^0.7.3" + } +} diff --git a/utilities/server.js b/utilities/server.js new file mode 100644 index 000000000..81a751d03 --- /dev/null +++ b/utilities/server.js @@ -0,0 +1,33 @@ +var sourcemapsReaderServer = require('./servers/sourcemaps-server'); +var {peerRouter, peerConnection, peerDisconnect} = require('./servers/peerjs-server'); +var express = require('express'); +const {ExpressPeerServer} = require('peer'); + +const HOST = '0.0.0.0'; +const PORT = 9000; + + +var app = express(); +app.use((req, res, next) => { + console.log(new Date().toTimeString(), req.method, req.originalUrl); + next(); +}); + +app.use('/sourcemaps', sourcemapsReaderServer); +app.use('/assist', peerRouter); + +const server = app.listen(PORT, HOST, () => { + console.log(`App listening on http://${HOST}:${PORT}`); + console.log('Press Ctrl+C to quit.'); +}); +const peerServer = ExpressPeerServer(server, { + debug: true, + path: '/', + proxied: true, + allow_discovery: true +}); +peerServer.on('connection', peerConnection); +peerServer.on('disconnect', peerDisconnect); +app.use('/', peerServer); +app.enable('trust proxy'); +module.exports = server; diff --git a/utilities/servers/peerjs-server.js b/utilities/servers/peerjs-server.js new file mode 100644 index 000000000..20c18f998 --- /dev/null +++ b/utilities/servers/peerjs-server.js @@ -0,0 +1,64 @@ +var express = require('express'); +var peerRouter = express.Router(); + + +const extractPeerId = (peerId) => { + let splited = peerId.split("-"); + if (splited.length !== 2) { + console.error(`cannot split peerId: ${peerId}`); + return {}; + } + return {projectKey: splited[0], sessionId: splited[1]}; +}; +const connectedPeers = {}; + +const peerConnection = (client) => { + console.log(`initiating ${client.id}`); + const {projectKey, sessionId} = extractPeerId(client.id); + if (projectKey === undefined || sessionId === undefined) { + return; + } + connectedPeers[projectKey] = connectedPeers[projectKey] || []; + if (connectedPeers[projectKey].indexOf(sessionId) === -1) { + console.log(`new connexion ${client.id}`); + connectedPeers[projectKey].push(sessionId); + } else { + console.log(`reconnecting peer ${client.id}`); + } + + +}; +const peerDisconnect = (client) => { + console.log(`disconnect ${client.id}`); + const {projectKey, sessionId} = extractPeerId(client.id); + if (projectKey === undefined || sessionId === undefined) { + return; + } + const i = (connectedPeers[projectKey] || []).indexOf(sessionId); + if (i === -1) { + console.log(`session not found ${client.id}`); + } else { + connectedPeers[projectKey].splice(i, 1); + } +} + + +peerRouter.get('/peers', function (req, res) { + console.log("looking for all available sessions"); + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({"data": connectedPeers})); +}); +peerRouter.get('/peers/:projectKey', function (req, res) { + console.log(`looking for available sessions for ${req.params.projectKey}`); + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({"data": connectedPeers[req.params.projectKey] || []})); +}); + + +module.exports = { + peerRouter, + peerConnection, + peerDisconnect +}; \ No newline at end of file diff --git a/api/sourcemaps_reader/handler.js b/utilities/servers/sourcemaps-handler.js similarity index 85% rename from api/sourcemaps_reader/handler.js rename to utilities/servers/sourcemaps-handler.js index 117808cae..98c2186dc 100644 --- a/api/sourcemaps_reader/handler.js +++ b/utilities/servers/sourcemaps-handler.js @@ -1,7 +1,7 @@ 'use strict'; const sourceMap = require('source-map'); const AWS = require('aws-sdk'); -const sourceMapVersion = require('./package.json').dependencies["source-map"]; +const sourceMapVersion = require('../package.json').dependencies["source-map"]; const URL = require('url'); const getVersion = version => version.replace(/[\^\$\=\~]/, ""); @@ -88,24 +88,4 @@ module.exports.sourcemapReader = async event => { }); }); }); -}; - - -// let v = { -// 'key': '1725/99f96f044fa7e941dbb15d7d68b20549', -// 'positions': [{'line': 1, 'column': 943}], -// 'padding': 5, -// 'bucket': 'asayer-sourcemaps' -// }; -// let v = { -// 'key': '1/65d8d3866bb8c92f3db612cb330f270c', -// 'positions': [{'line': 1, 'column': 0}], -// 'padding': 5, -// 'bucket': 'asayer-sourcemaps-staging' -// }; -// module.exports.sourcemapReader(v).then((r) => { -// // console.log(r); -// const fs = require('fs'); -// let data = JSON.stringify(r); -// fs.writeFileSync('results.json', data); -// }); \ No newline at end of file +}; \ No newline at end of file diff --git a/utilities/servers/sourcemaps-server.js b/utilities/servers/sourcemaps-server.js new file mode 100644 index 000000000..a1dd501a0 --- /dev/null +++ b/utilities/servers/sourcemaps-server.js @@ -0,0 +1,30 @@ +var express = require('express'); +var handler = require('./sourcemaps-handler'); +var router = express.Router(); + +router.post('/', (req, res) => { + let data = ''; + req.on('data', chunk => { + data += chunk; + }); + req.on('end', function () { + data = JSON.parse(data); + console.log("Starting parser for: " + data.key); + // process.env = {...process.env, ...data.bucket_config}; + handler.sourcemapReader(data) + .then((results) => { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify(results)); + }) + .catch((e) => { + console.error("Something went wrong"); + console.error(e); + res.statusCode(500); + res.end(e); + }); + }) + +}); + +module.exports = router; \ No newline at end of file