diff --git a/.github/workflows/api.yaml b/.github/workflows/api.yaml index ee2081580..2f11de7e4 100644 --- a/.github/workflows/api.yaml +++ b/.github/workflows/api.yaml @@ -84,9 +84,9 @@ jobs: cat /tmp/image_override.yaml # Deploy command - mv openreplay/charts/{ingress-nginx,chalice} /tmp + mv openreplay/charts/{ingress-nginx,chalice,quickwit} /tmp rm -rf openreplay/charts/* - mv /tmp/{ingress-nginx,chalice} openreplay/charts/ + mv /tmp/{ingress-nginx,chalice,quickwit} openreplay/charts/ helm template openreplay -n app openreplay -f vars.yaml -f /tmp/image_override.yaml --set ingress-nginx.enabled=false --set skipMigration=true --no-hooks | kubectl apply -n app -f - env: DOCKER_REPO: ${{ secrets.OSS_REGISTRY_URL }} diff --git a/.github/workflows/frontend.yaml b/.github/workflows/frontend.yaml index 373e2764c..4b2d9cd93 100644 --- a/.github/workflows/frontend.yaml +++ b/.github/workflows/frontend.yaml @@ -78,9 +78,9 @@ jobs: cat /tmp/image_override.yaml # Deploy command - mv openreplay/charts/{ingress-nginx,frontend} /tmp + mv openreplay/charts/{ingress-nginx,frontend,quickwit} /tmp rm -rf openreplay/charts/* - mv /tmp/{ingress-nginx,frontend} openreplay/charts/ + mv /tmp/{ingress-nginx,frontend,quickwit} openreplay/charts/ helm template openreplay -n app openreplay -f vars.yaml -f /tmp/image_override.yaml --set ingress-nginx.enabled=false --set skipMigration=true --no-hooks | kubectl apply -n app -f - env: DOCKER_REPO: ${{ secrets.OSS_REGISTRY_URL }} @@ -123,9 +123,9 @@ jobs: cat /tmp/image_override.yaml # Deploy command - mv openreplay/charts/{ingress-nginx,frontend} /tmp + mv openreplay/charts/{ingress-nginx,frontend,quickwit} /tmp rm -rf openreplay/charts/* - mv /tmp/{ingress-nginx,frontend} openreplay/charts/ + mv /tmp/{ingress-nginx,frontend,quickwit} openreplay/charts/ helm template openreplay -n app openreplay -f vars.yaml -f /tmp/image_override.yaml --set ingress-nginx.enabled=false --set skipMigration=true --no-hooks | kubectl apply -n app -f - env: DOCKER_REPO: ${{ secrets.EE_REGISTRY_URL }} diff --git a/.github/workflows/workers-ee.yaml b/.github/workflows/workers-ee.yaml index 38d3e4050..d1b06a9fb 100644 --- a/.github/workflows/workers-ee.yaml +++ b/.github/workflows/workers-ee.yaml @@ -117,6 +117,7 @@ jobs: echo > /tmp/image_override.yaml mkdir /tmp/helmcharts mv openreplay/charts/ingress-nginx /tmp/helmcharts/ + mv openreplay/charts/quickwit /tmp/helmcharts/ ## Update images for image in $(cat /tmp/images_to_build.txt); do diff --git a/.github/workflows/workers.yaml b/.github/workflows/workers.yaml index 00d4b0ca3..2f215470b 100644 --- a/.github/workflows/workers.yaml +++ b/.github/workflows/workers.yaml @@ -114,6 +114,7 @@ jobs: echo > /tmp/image_override.yaml mkdir /tmp/helmcharts mv openreplay/charts/ingress-nginx /tmp/helmcharts/ + mv openreplay/charts/quickwit /tmp/helmcharts/ ## Update images for image in $(cat /tmp/images_to_build.txt); do diff --git a/api/.gitignore b/api/.gitignore index 6a46fedcb..773f59916 100644 --- a/api/.gitignore +++ b/api/.gitignore @@ -174,4 +174,6 @@ logs*.txt SUBNETS.json ./chalicelib/.configs -README/* \ No newline at end of file +README/* +.local +build_crons.sh \ No newline at end of file diff --git a/api/Dockerfile b/api/Dockerfile index a7321ec58..8f8691159 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -1,7 +1,6 @@ FROM python:3.10-alpine LABEL Maintainer="Rajesh Rajendran" LABEL Maintainer="KRAIEM Taha Yassine" -RUN apk upgrade busybox --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/main RUN apk add --no-cache build-base nodejs npm tini ARG envarg # Add Tini diff --git a/api/Dockerfile.alerts b/api/Dockerfile.alerts index 4595035c2..ab3732c91 100644 --- a/api/Dockerfile.alerts +++ b/api/Dockerfile.alerts @@ -1,7 +1,6 @@ FROM python:3.10-alpine LABEL Maintainer="Rajesh Rajendran" LABEL Maintainer="KRAIEM Taha Yassine" -RUN apk upgrade busybox --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/main RUN apk add --no-cache build-base tini ARG envarg ENV APP_NAME=alerts \ diff --git a/api/app.py b/api/app.py index 4fd042d1a..cf00c747b 100644 --- a/api/app.py +++ b/api/app.py @@ -4,6 +4,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler from decouple import config from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware +from fastapi.middleware.gzip import GZipMiddleware from starlette.responses import StreamingResponse from chalicelib.utils import helper @@ -14,7 +15,7 @@ from routers.crons import core_dynamic_crons from routers.subs import dashboard, insights, metrics, v1_api app = FastAPI(root_path="/api", docs_url=config("docs_url", default=""), redoc_url=config("redoc_url", default="")) - +app.add_middleware(GZipMiddleware, minimum_size=1000) @app.middleware('http') async def or_middleware(request: Request, call_next): diff --git a/api/auth/auth_project.py b/api/auth/auth_project.py index 98a495bbb..6f842916b 100644 --- a/api/auth/auth_project.py +++ b/api/auth/auth_project.py @@ -15,10 +15,12 @@ class ProjectAuthorizer: if len(request.path_params.keys()) == 0 or request.path_params.get(self.project_identifier) is None: return current_user: schemas.CurrentContext = await OR_context(request) - project_identifier = request.path_params[self.project_identifier] + value = request.path_params[self.project_identifier] if (self.project_identifier == "projectId" \ - and projects.get_project(project_id=project_identifier, tenant_id=current_user.tenant_id) is None) \ - or (self.project_identifier.lower() == "projectKey" \ - and projects.get_internal_project_id(project_key=project_identifier) is None): + and not (isinstance(value, int) or isinstance(value, str) and value.isnumeric()) + and projects.get_project(project_id=value, tenant_id=current_user.tenant_id) is None) \ + or (self.project_identifier == "projectKey" \ + and projects.get_internal_project_id(project_key=value) is None): print("project not found") + print(value) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="project not found.") diff --git a/api/build.sh b/api/build.sh index 2b7f06e5e..46c54ab2e 100644 --- a/api/build.sh +++ b/api/build.sh @@ -18,6 +18,8 @@ check_prereq() { } function build_api(){ + cp -R ../api ../_api + cd ../_api cp -R ../utilities/utils ../sourcemap-reader/. cp -R ../sourcemap-reader . tag="" @@ -28,6 +30,8 @@ function build_api(){ tag="ee-" } docker build -f ./Dockerfile --build-arg envarg=$envarg -t ${DOCKER_REPO:-'local'}/chalice:${git_sha1} . + cd ../api + rm -rf ../_api [[ $PUSH_IMAGE -eq 1 ]] && { docker push ${DOCKER_REPO:-'local'}/chalice:${git_sha1} docker tag ${DOCKER_REPO:-'local'}/chalice:${git_sha1} ${DOCKER_REPO:-'local'}/chalice:${tag}latest @@ -39,9 +43,9 @@ function build_api(){ check_prereq build_api $1 echo buil_complete -source build_alerts.sh $1 +IMAGE_TAG=$IMAGE_TAG PUSH_IMAGE=$PUSH_IMAGE DOCKER_REPO=$DOCKER_REPO bash build_alerts.sh $1 [[ $1 == "ee" ]] && { + cp ../ee/api/build_crons.sh . IMAGE_TAG=$IMAGE_TAG PUSH_IMAGE=$PUSH_IMAGE DOCKER_REPO=$DOCKER_REPO bash build_crons.sh $1 -} -echo "api done" +} \ No newline at end of file diff --git a/api/build_alerts.sh b/api/build_alerts.sh index b5aa759b0..9ab21e6af 100644 --- a/api/build_alerts.sh +++ b/api/build_alerts.sh @@ -17,6 +17,8 @@ check_prereq() { } function build_api(){ + cp -R ../api ../_alerts + cd ../_alerts tag="" # Copy enterprise code [[ $1 == "ee" ]] && { @@ -24,15 +26,15 @@ function build_api(){ envarg="default-ee" tag="ee-" } - cp -R ../api ../_alerts - docker build -f ../_alerts/Dockerfile.alerts --build-arg envarg=$envarg -t ${DOCKER_REPO:-'local'}/alerts:${git_sha1} . + docker build -f ./Dockerfile.alerts --build-arg envarg=$envarg -t ${DOCKER_REPO:-'local'}/alerts:${git_sha1} . + cd ../api rm -rf ../_alerts [[ $PUSH_IMAGE -eq 1 ]] && { docker push ${DOCKER_REPO:-'local'}/alerts:${git_sha1} docker tag ${DOCKER_REPO:-'local'}/alerts:${git_sha1} ${DOCKER_REPO:-'local'}/alerts:${tag}latest docker push ${DOCKER_REPO:-'local'}/alerts:${tag}latest } -echo "completed alerts build" + echo "completed alerts build" } check_prereq diff --git a/api/chalicelib/core/alerts.py b/api/chalicelib/core/alerts.py index f851751ba..da6211687 100644 --- a/api/chalicelib/core/alerts.py +++ b/api/chalicelib/core/alerts.py @@ -52,8 +52,8 @@ def create(project_id, data: schemas.AlertSchema): with pg_client.PostgresClient() as cur: cur.execute( cur.mogrify("""\ - INSERT INTO public.alerts(project_id, name, description, detection_method, query, options, series_id) - VALUES (%(project_id)s, %(name)s, %(description)s, %(detection_method)s, %(query)s, %(options)s::jsonb, %(series_id)s) + INSERT INTO public.alerts(project_id, name, description, detection_method, query, options, series_id, change) + VALUES (%(project_id)s, %(name)s, %(description)s, %(detection_method)s, %(query)s, %(options)s::jsonb, %(series_id)s, %(change)s) RETURNING *;""", {"project_id": project_id, **data}) ) @@ -75,7 +75,8 @@ def update(id, data: schemas.AlertSchema): detection_method = %(detection_method)s, query = %(query)s, options = %(options)s, - series_id = %(series_id)s + series_id = %(series_id)s, + change = %(change)s WHERE alert_id =%(id)s AND deleted_at ISNULL RETURNING *;""", {"id": id, **data}) diff --git a/api/chalicelib/core/alerts_listener.py b/api/chalicelib/core/alerts_listener.py index 419f0326d..0fa193964 100644 --- a/api/chalicelib/core/alerts_listener.py +++ b/api/chalicelib/core/alerts_listener.py @@ -12,7 +12,8 @@ def get_all_alerts(): (EXTRACT(EPOCH FROM alerts.created_at) * 1000)::BIGINT AS created_at, alerts.name, alerts.series_id, - filter + filter, + change FROM public.alerts LEFT JOIN metric_series USING (series_id) INNER JOIN projects USING (project_id) diff --git a/api/chalicelib/core/alerts_processor.py b/api/chalicelib/core/alerts_processor.py index ece75bfe5..dbc4aaf41 100644 --- a/api/chalicelib/core/alerts_processor.py +++ b/api/chalicelib/core/alerts_processor.py @@ -1,12 +1,16 @@ import decimal import logging +from decouple import config + import schemas from chalicelib.core import alerts_listener from chalicelib.core import sessions, alerts from chalicelib.utils import pg_client from chalicelib.utils.TimeUTC import TimeUTC +logging.basicConfig(level=config("LOGLEVEL", default=logging.INFO)) + LeftToDb = { schemas.AlertColumn.performance__dom_content_loaded__average: { "table": "events.pages INNER JOIN public.sessions USING(session_id)", @@ -41,7 +45,7 @@ LeftToDb = { "formula": "AVG(NULLIF(resources.duration,0))"}, schemas.AlertColumn.resources__missing__count: { "table": "events.resources INNER JOIN public.sessions USING(session_id)", - "formula": "COUNT(DISTINCT url_hostpath)", "condition": "success= FALSE"}, + "formula": "COUNT(DISTINCT url_hostpath)", "condition": "success= FALSE AND type='img'"}, schemas.AlertColumn.errors__4xx_5xx__count: { "table": "events.resources INNER JOIN public.sessions USING(session_id)", "formula": "COUNT(session_id)", "condition": "status/100!=2"}, @@ -53,8 +57,9 @@ LeftToDb = { "table": "events.resources INNER JOIN public.sessions USING(session_id)", "formula": "COUNT(DISTINCT session_id)", "condition": "success= FALSE AND type='script'"}, schemas.AlertColumn.performance__crashes__count: { - "table": "(SELECT *, start_ts AS timestamp FROM public.sessions WHERE errors_count > 0) AS sessions", - "formula": "COUNT(DISTINCT session_id)", "condition": "errors_count > 0"}, + "table": "public.sessions", + "formula": "COUNT(DISTINCT session_id)", + "condition": "errors_count > 0 AND duration>0"}, schemas.AlertColumn.errors__javascript__count: { "table": "events.errors INNER JOIN public.errors AS m_errors USING (error_id)", "formula": "COUNT(DISTINCT session_id)", "condition": "source='js_exception'", "joinSessions": False}, @@ -94,7 +99,8 @@ def can_check(a) -> bool: def Build(a): - params = {"project_id": a["projectId"]} + now = TimeUTC.now() + params = {"project_id": a["projectId"], "now": now} full_args = {} j_s = True if a["seriesId"] is not None: @@ -121,11 +127,12 @@ def Build(a): if a["seriesId"] is not None: q += f""" FROM ({subQ}) AS stat""" else: - q += f""" FROM ({subQ} AND timestamp>=%(startDate)s - {"AND sessions.start_ts >= %(startDate)s" if j_s else ""}) AS stat""" + q += f""" FROM ({subQ} AND timestamp>=%(startDate)s AND timestamp<=%(now)s + {"AND sessions.start_ts >= %(startDate)s" if j_s else ""} + {"AND sessions.start_ts <= %(now)s" if j_s else ""}) AS stat""" params = {**params, **full_args, "startDate": TimeUTC.now() - a["options"]["currentPeriod"] * 60 * 1000} else: - if a["options"]["change"] == schemas.AlertDetectionChangeType.change: + if a["change"] == schemas.AlertDetectionType.change: if a["seriesId"] is not None: sub2 = subQ.replace("%(startDate)s", "%(timestamp_sub2)s").replace("%(endDate)s", "%(startDate)s") sub1 = f"SELECT (({subQ})-({sub2})) AS value" @@ -135,7 +142,9 @@ def Build(a): "timestamp_sub2": TimeUTC.now() - 2 * a["options"]["currentPeriod"] * 60 * 1000} else: sub1 = f"""{subQ} AND timestamp>=%(startDate)s - {"AND sessions.start_ts >= %(startDate)s" if j_s else ""}""" + AND datetime<=toDateTime(%(now)s/1000) + {"AND sessions.start_ts >= %(startDate)s" if j_s else ""} + {"AND sessions.start_ts <= %(now)s" if j_s else ""}""" params["startDate"] = TimeUTC.now() - a["options"]["currentPeriod"] * 60 * 1000 sub2 = f"""{subQ} AND timestamp<%(startDate)s AND timestamp>=%(timestamp_sub2)s @@ -155,8 +164,9 @@ def Build(a): - (a["options"]["currentPeriod"] + a["options"]["currentPeriod"]) \ * 60 * 1000} else: - sub1 = f"""{subQ} AND timestamp>=%(startDate)s - {"AND sessions.start_ts >= %(startDate)s" if j_s else ""}""" + sub1 = f"""{subQ} AND timestamp>=%(startDate)s AND timestamp<=%(now)s + {"AND sessions.start_ts >= %(startDate)s" if j_s else ""} + {"AND sessions.start_ts <= %(now)s" if j_s else ""}""" params["startDate"] = TimeUTC.now() - a["options"]["currentPeriod"] * 60 * 1000 sub2 = f"""{subQ} AND timestamp<%(startDate)s AND timestamp>=%(timestamp_sub2)s @@ -185,30 +195,7 @@ def process(): result = cur.fetchone() if result["valid"]: logging.info("Valid alert, notifying users") - notifications.append({ - "alertId": alert["alertId"], - "tenantId": alert["tenantId"], - "title": alert["name"], - "description": f"has been triggered, {alert['query']['left']} = {round(result['value'], 2)} ({alert['query']['operator']} {alert['query']['right']}).", - "buttonText": "Check metrics for more details", - "buttonUrl": f"/{alert['projectId']}/metrics", - "imageUrl": None, - "options": {"source": "ALERT", "sourceId": alert["alertId"], - "sourceMeta": alert["detectionMethod"], - "message": alert["options"]["message"], "projectId": alert["projectId"], - "data": {"title": alert["name"], - "limitValue": alert["query"]["right"], - "actualValue": float(result["value"]) \ - if isinstance(result["value"], decimal.Decimal) \ - else result["value"], - "operator": alert["query"]["operator"], - "trigger": alert["query"]["left"], - "alertId": alert["alertId"], - "detectionMethod": alert["detectionMethod"], - "currentPeriod": alert["options"]["currentPeriod"], - "previousPeriod": alert["options"]["previousPeriod"], - "createdAt": TimeUTC.now()}}, - }) + notifications.append(generate_notification(alert, result)) except Exception as e: logging.error(f"!!!Error while running alert query for alertId:{alert['alertId']}") logging.error(str(e)) @@ -220,3 +207,30 @@ def process(): WHERE alert_id IN %(ids)s;""", {"ids": tuple([n["alertId"] for n in notifications])})) if len(notifications) > 0: alerts.process_notifications(notifications) + + +def generate_notification(alert, result): + return { + "alertId": alert["alertId"], + "tenantId": alert["tenantId"], + "title": alert["name"], + "description": f"has been triggered, {alert['query']['left']} = {round(result['value'], 2)} ({alert['query']['operator']} {alert['query']['right']}).", + "buttonText": "Check metrics for more details", + "buttonUrl": f"/{alert['projectId']}/metrics", + "imageUrl": None, + "options": {"source": "ALERT", "sourceId": alert["alertId"], + "sourceMeta": alert["detectionMethod"], + "message": alert["options"]["message"], "projectId": alert["projectId"], + "data": {"title": alert["name"], + "limitValue": alert["query"]["right"], + "actualValue": float(result["value"]) \ + if isinstance(result["value"], decimal.Decimal) \ + else result["value"], + "operator": alert["query"]["operator"], + "trigger": alert["query"]["left"], + "alertId": alert["alertId"], + "detectionMethod": alert["detectionMethod"], + "currentPeriod": alert["options"]["currentPeriod"], + "previousPeriod": alert["options"]["previousPeriod"], + "createdAt": TimeUTC.now()}}, + } diff --git a/api/chalicelib/core/assist.py b/api/chalicelib/core/assist.py index 39ae101c4..0f2c515cf 100644 --- a/api/chalicelib/core/assist.py +++ b/api/chalicelib/core/assist.py @@ -1,8 +1,10 @@ import requests from decouple import config - +from os.path import exists import schemas from chalicelib.core import projects +from starlette.exceptions import HTTPException +from os import access, R_OK SESSION_PROJECTION_COLS = """s.project_id, s.session_id::text AS session_id, @@ -161,3 +163,23 @@ def autocomplete(project_id, q: str, key: str = None): def get_ice_servers(): return config("iceServers") if config("iceServers", default=None) is not None \ and len(config("iceServers")) > 0 else None + + +def get_raw_mob_by_id(project_id, session_id): + efs_path = config("FS_DIR") + if not exists(efs_path): + raise HTTPException(400, f"EFS not found in path: {efs_path}") + + if not access(efs_path, R_OK): + raise HTTPException(400, f"EFS found under: {efs_path}; but it is not readable, please check permissions") + + path_to_file = efs_path + "/" + str(session_id) + + if exists(path_to_file): + if not access(path_to_file, R_OK): + raise HTTPException(400, f"Replay file found under: {efs_path};" + f" but it is not readable, please check permissions") + + return path_to_file + + return None diff --git a/api/chalicelib/core/autocomplete.py b/api/chalicelib/core/autocomplete.py new file mode 100644 index 000000000..cd908a1b5 --- /dev/null +++ b/api/chalicelib/core/autocomplete.py @@ -0,0 +1,132 @@ +import schemas +from chalicelib.core import countries +from chalicelib.utils import helper +from chalicelib.utils import pg_client +from chalicelib.utils.event_filter_definition import Event + +TABLE = "public.autocomplete" + + +def __get_autocomplete_table(value, project_id): + autocomplete_events = [schemas.FilterType.rev_id, + schemas.EventType.click, + schemas.FilterType.user_device, + schemas.FilterType.user_id, + schemas.FilterType.user_browser, + schemas.FilterType.user_os, + schemas.EventType.custom, + schemas.FilterType.user_country, + schemas.EventType.location, + schemas.EventType.input] + autocomplete_events.sort() + sub_queries = [] + c_list = [] + for e in autocomplete_events: + if e == schemas.FilterType.user_country: + c_list = countries.get_country_code_autocomplete(value) + if len(c_list) > 0: + sub_queries.append(f"""(SELECT DISTINCT ON(value) type, value + FROM {TABLE} + WHERE project_id = %(project_id)s + AND type= '{e}' + AND value IN %(c_list)s)""") + continue + sub_queries.append(f"""(SELECT type, value + FROM {TABLE} + WHERE project_id = %(project_id)s + AND type= '{e}' + AND value ILIKE %(svalue)s + LIMIT 5)""") + if len(value) > 2: + sub_queries.append(f"""(SELECT type, value + FROM {TABLE} + WHERE project_id = %(project_id)s + AND type= '{e}' + AND value ILIKE %(value)s + LIMIT 5)""") + with pg_client.PostgresClient() as cur: + query = cur.mogrify(" UNION DISTINCT ".join(sub_queries) + ";", + {"project_id": project_id, + "value": helper.string_to_sql_like(value), + "svalue": helper.string_to_sql_like("^" + value), + "c_list": tuple(c_list) + }) + try: + print(query) + cur.execute(query) + except Exception as err: + print("--------- AUTOCOMPLETE SEARCH QUERY EXCEPTION -----------") + print(query.decode('UTF-8')) + print("--------- VALUE -----------") + print(value) + print("--------------------") + raise err + results = helper.list_to_camel_case(cur.fetchall()) + return results + + +def __generic_query(typename, value_length=None): + if typename == schemas.FilterType.user_country: + return f"""SELECT DISTINCT value, type + FROM {TABLE} + WHERE + project_id = %(project_id)s + AND type='{typename}' + AND value IN %(value)s + ORDER BY value""" + + if value_length is None or value_length > 2: + return f"""(SELECT DISTINCT value, type + FROM {TABLE} + WHERE + project_id = %(project_id)s + AND type='{typename}' + AND value ILIKE %(svalue)s + ORDER BY value + LIMIT 5) + UNION DISTINCT + (SELECT DISTINCT value, type + FROM {TABLE} + WHERE + project_id = %(project_id)s + AND type='{typename}' + AND value ILIKE %(value)s + ORDER BY value + LIMIT 5);""" + return f"""SELECT DISTINCT value, type + FROM {TABLE} + WHERE + project_id = %(project_id)s + AND type='{typename}' + AND value ILIKE %(svalue)s + ORDER BY value + LIMIT 10;""" + + +def __generic_autocomplete(event: Event): + def f(project_id, value, key=None, source=None): + with pg_client.PostgresClient() as cur: + query = __generic_query(event.ui_type, value_length=len(value)) + params = {"project_id": project_id, "value": helper.string_to_sql_like(value), + "svalue": helper.string_to_sql_like("^" + value)} + cur.execute(cur.mogrify(query, params)) + return helper.list_to_camel_case(cur.fetchall()) + + return f + + +def __generic_autocomplete_metas(typename): + def f(project_id, text): + with pg_client.PostgresClient() as cur: + params = {"project_id": project_id, "value": helper.string_to_sql_like(text), + "svalue": helper.string_to_sql_like("^" + text)} + + if typename == schemas.FilterType.user_country: + params["value"] = tuple(countries.get_country_code_autocomplete(text)) + + query = cur.mogrify(__generic_query(typename, value_length=len(text)), params) + cur.execute(query) + rows = cur.fetchall() + return rows + + return f diff --git a/api/chalicelib/core/countries.py b/api/chalicelib/core/countries.py new file mode 100644 index 000000000..2e9212b9f --- /dev/null +++ b/api/chalicelib/core/countries.py @@ -0,0 +1,295 @@ +COUNTRIES = { + "AC": "Ascension Island", + "AD": "Andorra", + "AE": "United Arab Emirates", + "AF": "Afghanistan", + "AG": "Antigua And Barbuda", + "AI": "Anguilla", + "AL": "Albania", + "AM": "Armenia", + "AN": "Netherlands Antilles", + "AO": "Angola", + "AQ": "Antarctica", + "AR": "Argentina", + "AS": "American Samoa", + "AT": "Austria", + "AU": "Australia", + "AW": "Aruba", + "AX": "Åland Islands", + "AZ": "Azerbaijan", + "BA": "Bosnia & Herzegovina", + "BB": "Barbados", + "BD": "Bangladesh", + "BE": "Belgium", + "BF": "Burkina Faso", + "BG": "Bulgaria", + "BH": "Bahrain", + "BI": "Burundi", + "BJ": "Benin", + "BL": "Saint Barthélemy", + "BM": "Bermuda", + "BN": "Brunei Darussalam", + "BO": "Bolivia", + "BQ": "Bonaire, Saint Eustatius And Saba", + "BR": "Brazil", + "BS": "Bahamas", + "BT": "Bhutan", + "BU": "Burma", + "BV": "Bouvet Island", + "BW": "Botswana", + "BY": "Belarus", + "BZ": "Belize", + "CA": "Canada", + "CC": "Cocos Islands", + "CD": "Congo", + "CF": "Central African Republic", + "CG": "Congo", + "CH": "Switzerland", + "CI": "Côte d'Ivoire", + "CK": "Cook Islands", + "CL": "Chile", + "CM": "Cameroon", + "CN": "China", + "CO": "Colombia", + "CP": "Clipperton Island", + "CR": "Costa Rica", + "CS": "Serbia and Montenegro", + "CT": "Canton and Enderbury Islands", + "CU": "Cuba", + "CV": "Cabo Verde", + "CW": "Curacao", + "CX": "Christmas Island", + "CY": "Cyprus", + "CZ": "Czech Republic", + "DD": "Germany", + "DE": "Germany", + "DG": "Diego Garcia", + "DJ": "Djibouti", + "DK": "Denmark", + "DM": "Dominica", + "DO": "Dominican Republic", + "DY": "Dahomey", + "DZ": "Algeria", + "EA": "Ceuta, Mulilla", + "EC": "Ecuador", + "EE": "Estonia", + "EG": "Egypt", + "EH": "Western Sahara", + "ER": "Eritrea", + "ES": "Spain", + "ET": "Ethiopia", + "FI": "Finland", + "FJ": "Fiji", + "FK": "Falkland Islands", + "FM": "Micronesia", + "FO": "Faroe Islands", + "FQ": "French Southern and Antarctic Territories", + "FR": "France", + "FX": "France, Metropolitan", + "GA": "Gabon", + "GB": "United Kingdom", + "GD": "Grenada", + "GE": "Georgia", + "GF": "French Guiana", + "GG": "Guernsey", + "GH": "Ghana", + "GI": "Gibraltar", + "GL": "Greenland", + "GM": "Gambia", + "GN": "Guinea", + "GP": "Guadeloupe", + "GQ": "Equatorial Guinea", + "GR": "Greece", + "GS": "South Georgia And The South Sandwich Islands", + "GT": "Guatemala", + "GU": "Guam", + "GW": "Guinea-bissau", + "GY": "Guyana", + "HK": "Hong Kong", + "HM": "Heard Island And McDonald Islands", + "HN": "Honduras", + "HR": "Croatia", + "HT": "Haiti", + "HU": "Hungary", + "HV": "Upper Volta", + "IC": "Canary Islands", + "ID": "Indonesia", + "IE": "Ireland", + "IL": "Israel", + "IM": "Isle Of Man", + "IN": "India", + "IO": "British Indian Ocean Territory", + "IQ": "Iraq", + "IR": "Iran", + "IS": "Iceland", + "IT": "Italy", + "JE": "Jersey", + "JM": "Jamaica", + "JO": "Jordan", + "JP": "Japan", + "JT": "Johnston Island", + "KE": "Kenya", + "KG": "Kyrgyzstan", + "KH": "Cambodia", + "KI": "Kiribati", + "KM": "Comoros", + "KN": "Saint Kitts And Nevis", + "KP": "Korea", + "KR": "Korea", + "KW": "Kuwait", + "KY": "Cayman Islands", + "KZ": "Kazakhstan", + "LA": "Laos", + "LB": "Lebanon", + "LC": "Saint Lucia", + "LI": "Liechtenstein", + "LK": "Sri Lanka", + "LR": "Liberia", + "LS": "Lesotho", + "LT": "Lithuania", + "LU": "Luxembourg", + "LV": "Latvia", + "LY": "Libya", + "MA": "Morocco", + "MC": "Monaco", + "MD": "Moldova", + "ME": "Montenegro", + "MF": "Saint Martin", + "MG": "Madagascar", + "MH": "Marshall Islands", + "MI": "Midway Islands", + "MK": "Macedonia", + "ML": "Mali", + "MM": "Myanmar", + "MN": "Mongolia", + "MO": "Macao", + "MP": "Northern Mariana Islands", + "MQ": "Martinique", + "MR": "Mauritania", + "MS": "Montserrat", + "MT": "Malta", + "MU": "Mauritius", + "MV": "Maldives", + "MW": "Malawi", + "MX": "Mexico", + "MY": "Malaysia", + "MZ": "Mozambique", + "NA": "Namibia", + "NC": "New Caledonia", + "NE": "Niger", + "NF": "Norfolk Island", + "NG": "Nigeria", + "NH": "New Hebrides", + "NI": "Nicaragua", + "NL": "Netherlands", + "NO": "Norway", + "NP": "Nepal", + "NQ": "Dronning Maud Land", + "NR": "Nauru", + "NT": "Neutral Zone", + "NU": "Niue", + "NZ": "New Zealand", + "OM": "Oman", + "PA": "Panama", + "PC": "Pacific Islands", + "PE": "Peru", + "PF": "French Polynesia", + "PG": "Papua New Guinea", + "PH": "Philippines", + "PK": "Pakistan", + "PL": "Poland", + "PM": "Saint Pierre And Miquelon", + "PN": "Pitcairn", + "PR": "Puerto Rico", + "PS": "Palestine", + "PT": "Portugal", + "PU": "U.S. Miscellaneous Pacific Islands", + "PW": "Palau", + "PY": "Paraguay", + "PZ": "Panama Canal Zone", + "QA": "Qatar", + "RE": "Reunion", + "RH": "Southern Rhodesia", + "RO": "Romania", + "RS": "Serbia", + "RU": "Russian Federation", + "RW": "Rwanda", + "SA": "Saudi Arabia", + "SB": "Solomon Islands", + "SC": "Seychelles", + "SD": "Sudan", + "SE": "Sweden", + "SG": "Singapore", + "SH": "Saint Helena, Ascension And Tristan Da Cunha", + "SI": "Slovenia", + "SJ": "Svalbard And Jan Mayen", + "SK": "Slovakia", + "SL": "Sierra Leone", + "SM": "San Marino", + "SN": "Senegal", + "SO": "Somalia", + "SR": "Suriname", + "SS": "South Sudan", + "ST": "Sao Tome and Principe", + "SU": "USSR", + "SV": "El Salvador", + "SX": "Sint Maarten", + "SY": "Syrian Arab Republic", + "SZ": "Swaziland", + "TA": "Tristan de Cunha", + "TC": "Turks And Caicos Islands", + "TD": "Chad", + "TF": "French Southern Territories", + "TG": "Togo", + "TH": "Thailand", + "TJ": "Tajikistan", + "TK": "Tokelau", + "TL": "Timor-Leste", + "TM": "Turkmenistan", + "TN": "Tunisia", + "TO": "Tonga", + "TP": "East Timor", + "TR": "Turkey", + "TT": "Trinidad And Tobago", + "TV": "Tuvalu", + "TW": "Taiwan", + "TZ": "Tanzania", + "UA": "Ukraine", + "UG": "Uganda", + "UM": "United States Minor Outlying Islands", + "US": "United States", + "UY": "Uruguay", + "UZ": "Uzbekistan", + "VA": "Vatican City State", + "VC": "Saint Vincent And The Grenadines", + "VD": "VietNam", + "VE": "Venezuela", + "VG": "Virgin Islands (British)", + "VI": "Virgin Islands (US)", + "VN": "VietNam", + "VU": "Vanuatu", + "WF": "Wallis And Futuna", + "WK": "Wake Island", + "WS": "Samoa", + "XK": "Kosovo", + "YD": "Yemen", + "YE": "Yemen", + "YT": "Mayotte", + "YU": "Yugoslavia", + "ZA": "South Africa", + "ZM": "Zambia", + "ZR": "Zaire", + "ZW": "Zimbabwe", +} + + +def get_country_code_autocomplete(text): + if text is None or len(text) == 0: + return [] + results = [] + for code in COUNTRIES: + if text.lower() in code.lower() \ + or text.lower() in COUNTRIES[code].lower(): + results.append(code) + + return results diff --git a/api/chalicelib/core/custom_metrics.py b/api/chalicelib/core/custom_metrics.py index e9e127c4e..29c4b6fa9 100644 --- a/api/chalicelib/core/custom_metrics.py +++ b/api/chalicelib/core/custom_metrics.py @@ -91,7 +91,7 @@ def __get_sessions_list(project_id, user_id, data): data.series[0].filter.endDate = data.endTimestamp data.series[0].filter.page = data.page data.series[0].filter.limit = data.limit - return sessions.search2_pg(data=data.series[0].filter, project_id=project_id, user_id=user_id) + return sessions.search_sessions(data=data.series[0].filter, project_id=project_id, user_id=user_id) def merged_live(project_id, data: schemas.TryCustomMetricsPayloadSchema, user_id=None): @@ -166,7 +166,7 @@ def get_sessions(project_id, user_id, metric_id, data: schemas.CustomMetricSessi s.filter.limit = data.limit s.filter.page = data.page results.append({"seriesId": s.series_id, "seriesName": s.name, - **sessions.search2_pg(data=s.filter, project_id=project_id, user_id=user_id)}) + **sessions.search_sessions(data=s.filter, project_id=project_id, user_id=user_id)}) return results @@ -213,7 +213,7 @@ def try_sessions(project_id, user_id, data: schemas.CustomMetricSessionsPayloadS s.filter.limit = data.limit s.filter.page = data.page results.append({"seriesId": None, "seriesName": s.name, - **sessions.search2_pg(data=s.filter, project_id=project_id, user_id=user_id)}) + **sessions.search_sessions(data=s.filter, project_id=project_id, user_id=user_id)}) return results @@ -532,7 +532,7 @@ def get_funnel_sessions_by_issue(user_id, project_id, metric_id, issue_id, "lostConversions": 0, "unaffectedSessions": 0} return {"seriesId": s.series_id, "seriesName": s.name, - "sessions": sessions.search2_pg(user_id=user_id, project_id=project_id, - issue=issue, data=s.filter) + "sessions": sessions.search_sessions(user_id=user_id, project_id=project_id, + issue=issue, data=s.filter) if issue is not None else {"total": 0, "sessions": []}, "issue": issue} diff --git a/api/chalicelib/core/dashboards.py b/api/chalicelib/core/dashboards.py index bdd0518e0..9d1dc4c81 100644 --- a/api/chalicelib/core/dashboards.py +++ b/api/chalicelib/core/dashboards.py @@ -304,7 +304,9 @@ def make_chart_metrics(project_id, user_id, metric_id, data: schemas.CustomMetri include_dashboard=False) if raw_metric is None: return None - metric = schemas.CustomMetricAndTemplate = schemas.CustomMetricAndTemplate.parse_obj(raw_metric) + metric: schemas.CustomMetricAndTemplate = schemas.CustomMetricAndTemplate.parse_obj(raw_metric) + if metric.is_template and metric.predefined_key is None: + return None if metric.is_template: return get_predefined_metric(key=metric.predefined_key, project_id=project_id, data=data.dict()) else: diff --git a/api/chalicelib/core/errors.py b/api/chalicelib/core/errors.py index bbdea726b..1c7ed94c9 100644 --- a/api/chalicelib/core/errors.py +++ b/api/chalicelib/core/errors.py @@ -251,10 +251,7 @@ def get_details(project_id, error_id, user_id, **data): parent_error_id,session_id, user_anonymous_id, user_id, user_uuid, user_browser, user_browser_version, user_os, user_os_version, user_device, payload, - COALESCE((SELECT TRUE - FROM public.user_favorite_errors AS fe - WHERE pe.error_id = fe.error_id - AND fe.user_id = %(user_id)s), FALSE) AS favorite, + FALSE AS favorite, True AS viewed FROM public.errors AS pe INNER JOIN events.errors AS ee USING (error_id) @@ -424,10 +421,11 @@ def __get_sort_key(key): }.get(key, 'max_datetime') -def search(data: schemas.SearchErrorsSchema, project_id, user_id, flows=False): - empty_response = {'total': 0, - 'errors': [] - } +def search(data: schemas.SearchErrorsSchema, project_id, user_id): + empty_response = { + 'total': 0, + 'errors': [] + } platform = None for f in data.filters: @@ -449,17 +447,12 @@ def search(data: schemas.SearchErrorsSchema, project_id, user_id, flows=False): data.endDate = TimeUTC.now(1) if len(data.events) > 0 or len(data.filters) > 0: print("-- searching for sessions before errors") - # if favorite_only=True search for sessions associated with favorite_error - statuses = sessions.search2_pg(data=data, project_id=project_id, user_id=user_id, errors_only=True, - error_status=data.status) + statuses = sessions.search_sessions(data=data, project_id=project_id, user_id=user_id, errors_only=True, + error_status=data.status) if len(statuses) == 0: return empty_response error_ids = [e["errorId"] for e in statuses] with pg_client.PostgresClient() as cur: - if data.startDate is None: - data.startDate = TimeUTC.now(-7) - if data.endDate is None: - data.endDate = TimeUTC.now() step_size = __get_step_size(data.startDate, data.endDate, data.density, factor=1) sort = __get_sort_key('datetime') if data.sort is not None: @@ -488,9 +481,9 @@ def search(data: schemas.SearchErrorsSchema, project_id, user_id, flows=False): if error_ids is not None: params["error_ids"] = tuple(error_ids) pg_sub_query.append("error_id IN %(error_ids)s") - if data.bookmarked: - pg_sub_query.append("ufe.user_id = %(userId)s") - extra_join += " INNER JOIN public.user_favorite_errors AS ufe USING (error_id)" + # if data.bookmarked: + # pg_sub_query.append("ufe.user_id = %(userId)s") + # extra_join += " INNER JOIN public.user_favorite_errors AS ufe USING (error_id)" if data.query is not None and len(data.query) > 0: pg_sub_query.append("(pe.name ILIKE %(error_query)s OR pe.message ILIKE %(error_query)s)") params["error_query"] = helper.values_for_operator(value=data.query, @@ -509,7 +502,7 @@ def search(data: schemas.SearchErrorsSchema, project_id, user_id, flows=False): FROM (SELECT error_id, name, message, - COUNT(DISTINCT user_uuid) AS users, + COUNT(DISTINCT COALESCE(user_id,user_uuid::text)) AS users, COUNT(DISTINCT session_id) AS sessions, MAX(timestamp) AS max_datetime, MIN(timestamp) AS min_datetime @@ -544,19 +537,13 @@ def search(data: schemas.SearchErrorsSchema, project_id, user_id, flows=False): cur.execute(cur.mogrify(main_pg_query, params)) rows = cur.fetchall() total = 0 if len(rows) == 0 else rows[0]["full_count"] - if flows: - return {"count": total} if total == 0: rows = [] else: if len(statuses) == 0: query = cur.mogrify( - """SELECT error_id, status, parent_error_id, payload, - COALESCE((SELECT TRUE - FROM public.user_favorite_errors AS fe - WHERE errors.error_id = fe.error_id - AND fe.user_id = %(user_id)s LIMIT 1), FALSE) AS favorite, + """SELECT error_id, COALESCE((SELECT TRUE FROM public.user_viewed_errors AS ve WHERE errors.error_id = ve.error_id @@ -574,26 +561,12 @@ def search(data: schemas.SearchErrorsSchema, project_id, user_id, flows=False): for r in rows: r.pop("full_count") if r["error_id"] in statuses: - r["status"] = statuses[r["error_id"]]["status"] - r["parent_error_id"] = statuses[r["error_id"]]["parentErrorId"] - r["favorite"] = statuses[r["error_id"]]["favorite"] r["viewed"] = statuses[r["error_id"]]["viewed"] - r["stack"] = format_first_stack_frame(statuses[r["error_id"]])["stack"] else: - r["status"] = "untracked" - r["parent_error_id"] = None - r["favorite"] = False r["viewed"] = False - r["stack"] = None - offset = len(rows) - rows = [r for r in rows if r["stack"] is None - or (len(r["stack"]) == 0 or len(r["stack"]) > 1 - or len(r["stack"]) > 0 - and (r["message"].lower() != "script error." or len(r["stack"][0]["absPath"]) > 0))] - offset -= len(rows) return { - 'total': total - offset, + 'total': total, 'errors': helper.list_to_camel_case(rows) } diff --git a/api/chalicelib/core/errors_favorite.py b/api/chalicelib/core/errors_favorite.py new file mode 100644 index 000000000..c9be88bcb --- /dev/null +++ b/api/chalicelib/core/errors_favorite.py @@ -0,0 +1,48 @@ +from chalicelib.utils import pg_client + + +def add_favorite_error(project_id, user_id, error_id): + with pg_client.PostgresClient() as cur: + cur.execute( + cur.mogrify(f"""INSERT INTO public.user_favorite_errors(user_id, error_id) + VALUES (%(userId)s,%(error_id)s);""", + {"userId": user_id, "error_id": error_id}) + ) + return {"errorId": error_id, "favorite": True} + + +def remove_favorite_error(project_id, user_id, error_id): + with pg_client.PostgresClient() as cur: + cur.execute( + cur.mogrify(f"""DELETE FROM public.user_favorite_errors + WHERE + user_id = %(userId)s + AND error_id = %(error_id)s;""", + {"userId": user_id, "error_id": error_id}) + ) + return {"errorId": error_id, "favorite": False} + + +def favorite_error(project_id, user_id, error_id): + exists, favorite = error_exists_and_favorite(user_id=user_id, error_id=error_id) + if not exists: + return {"errors": ["cannot bookmark non-rehydrated errors"]} + if favorite: + return remove_favorite_error(project_id=project_id, user_id=user_id, error_id=error_id) + return add_favorite_error(project_id=project_id, user_id=user_id, error_id=error_id) + + +def error_exists_and_favorite(user_id, error_id): + with pg_client.PostgresClient() as cur: + cur.execute( + cur.mogrify( + """SELECT errors.error_id AS exists, ufe.error_id AS favorite + FROM public.errors + LEFT JOIN (SELECT error_id FROM public.user_favorite_errors WHERE user_id = %(userId)s) AS ufe USING (error_id) + WHERE error_id = %(error_id)s;""", + {"userId": user_id, "error_id": error_id}) + ) + r = cur.fetchone() + if r is None: + return False, False + return True, r.get("favorite") is not None diff --git a/api/chalicelib/core/errors_favorite_viewed.py b/api/chalicelib/core/errors_favorite_viewed.py deleted file mode 100644 index 0bbc10b68..000000000 --- a/api/chalicelib/core/errors_favorite_viewed.py +++ /dev/null @@ -1,91 +0,0 @@ -from chalicelib.utils import pg_client - - -def add_favorite_error(project_id, user_id, error_id): - with pg_client.PostgresClient() as cur: - cur.execute( - cur.mogrify(f"""\ - INSERT INTO public.user_favorite_errors - (user_id, error_id) - VALUES - (%(userId)s,%(error_id)s);""", - {"userId": user_id, "error_id": error_id}) - ) - return {"errorId": error_id, "favorite": True} - - -def remove_favorite_error(project_id, user_id, error_id): - with pg_client.PostgresClient() as cur: - cur.execute( - cur.mogrify(f"""\ - DELETE FROM public.user_favorite_errors - WHERE - user_id = %(userId)s - AND error_id = %(error_id)s;""", - {"userId": user_id, "error_id": error_id}) - ) - return {"errorId": error_id, "favorite": False} - - -def favorite_error(project_id, user_id, error_id): - exists, favorite = error_exists_and_favorite(user_id=user_id, error_id=error_id) - if not exists: - return {"errors": ["cannot bookmark non-rehydrated errors"]} - if favorite: - return remove_favorite_error(project_id=project_id, user_id=user_id, error_id=error_id) - return add_favorite_error(project_id=project_id, user_id=user_id, error_id=error_id) - - -def error_exists_and_favorite(user_id, error_id): - with pg_client.PostgresClient() as cur: - cur.execute( - cur.mogrify( - """SELECT errors.error_id AS exists, ufe.error_id AS favorite - FROM public.errors - LEFT JOIN (SELECT error_id FROM public.user_favorite_errors WHERE user_id = %(userId)s) AS ufe USING (error_id) - WHERE error_id = %(error_id)s;""", - {"userId": user_id, "error_id": error_id}) - ) - r = cur.fetchone() - if r is None: - return False, False - return True, r.get("favorite") is not None - - -def add_viewed_error(project_id, user_id, error_id): - with pg_client.PostgresClient() as cur: - cur.execute( - cur.mogrify("""\ - INSERT INTO public.user_viewed_errors - (user_id, error_id) - VALUES - (%(userId)s,%(error_id)s);""", - {"userId": user_id, "error_id": error_id}) - ) - - -def viewed_error_exists(user_id, error_id): - with pg_client.PostgresClient() as cur: - query = cur.mogrify( - """SELECT - errors.error_id AS hydrated, - COALESCE((SELECT TRUE - FROM public.user_viewed_errors AS ve - WHERE ve.error_id = %(error_id)s - AND ve.user_id = %(userId)s LIMIT 1), FALSE) AS viewed - FROM public.errors - WHERE error_id = %(error_id)s""", - {"userId": user_id, "error_id": error_id}) - cur.execute( - query=query - ) - r = cur.fetchone() - if r: - return r.get("viewed") - return True - - -def viewed_error(project_id, user_id, error_id): - if viewed_error_exists(user_id=user_id, error_id=error_id): - return None - return add_viewed_error(project_id=project_id, user_id=user_id, error_id=error_id) diff --git a/api/chalicelib/core/errors_viewed.py b/api/chalicelib/core/errors_viewed.py new file mode 100644 index 000000000..f230358b4 --- /dev/null +++ b/api/chalicelib/core/errors_viewed.py @@ -0,0 +1,37 @@ +from chalicelib.utils import pg_client + + +def add_viewed_error(project_id, user_id, error_id): + with pg_client.PostgresClient() as cur: + cur.execute( + cur.mogrify("""INSERT INTO public.user_viewed_errors(user_id, error_id) + VALUES (%(userId)s,%(error_id)s);""", + {"userId": user_id, "error_id": error_id}) + ) + + +def viewed_error_exists(user_id, error_id): + with pg_client.PostgresClient() as cur: + query = cur.mogrify( + """SELECT + errors.error_id AS hydrated, + COALESCE((SELECT TRUE + FROM public.user_viewed_errors AS ve + WHERE ve.error_id = %(error_id)s + AND ve.user_id = %(userId)s LIMIT 1), FALSE) AS viewed + FROM public.errors + WHERE error_id = %(error_id)s""", + {"userId": user_id, "error_id": error_id}) + cur.execute( + query=query + ) + r = cur.fetchone() + if r: + return r.get("viewed") + return True + + +def viewed_error(project_id, user_id, error_id): + if viewed_error_exists(user_id=user_id, error_id=error_id): + return None + return add_viewed_error(project_id=project_id, user_id=user_id, error_id=error_id) diff --git a/api/chalicelib/core/events.py b/api/chalicelib/core/events.py index dd9562de1..e2c979799 100644 --- a/api/chalicelib/core/events.py +++ b/api/chalicelib/core/events.py @@ -1,10 +1,14 @@ import schemas from chalicelib.core import issues -from chalicelib.core import sessions_metas, metadata +from chalicelib.core import metadata +from chalicelib.core import sessions_metas + from chalicelib.utils import pg_client, helper from chalicelib.utils.TimeUTC import TimeUTC from chalicelib.utils.event_filter_definition import SupportedFilter, Event +from chalicelib.core import autocomplete + def get_customs_by_sessionId2_pg(session_id, project_id): with pg_client.PostgresClient() as cur: @@ -92,11 +96,6 @@ def get_by_sessionId2_pg(session_id, project_id, group_clickrage=False): return rows -def __get_data_for_extend(data): - if "errors" not in data: - return data["data"] - - def __pg_errors_query(source=None, value_length=None): if value_length is None or value_length > 2: return f"""((SELECT DISTINCT ON(lg.message) @@ -110,7 +109,7 @@ def __pg_errors_query(source=None, value_length=None): AND lg.project_id = %(project_id)s {"AND source = %(source)s" if source is not None else ""} LIMIT 5) - UNION ALL + UNION DISTINCT (SELECT DISTINCT ON(lg.name) lg.name AS value, source, @@ -122,7 +121,7 @@ def __pg_errors_query(source=None, value_length=None): AND lg.project_id = %(project_id)s {"AND source = %(source)s" if source is not None else ""} LIMIT 5) - UNION + UNION DISTINCT (SELECT DISTINCT ON(lg.message) lg.message AS value, source, @@ -134,7 +133,7 @@ def __pg_errors_query(source=None, value_length=None): AND lg.project_id = %(project_id)s {"AND source = %(source)s" if source is not None else ""} LIMIT 5) - UNION ALL + UNION DISTINCT (SELECT DISTINCT ON(lg.name) lg.name AS value, source, @@ -157,7 +156,7 @@ def __pg_errors_query(source=None, value_length=None): AND lg.project_id = %(project_id)s {"AND source = %(source)s" if source is not None else ""} LIMIT 5) - UNION ALL + UNION DISTINCT (SELECT DISTINCT ON(lg.name) lg.name AS value, source, @@ -177,8 +176,7 @@ def __search_pg_errors(project_id, value, key=None, source=None): with pg_client.PostgresClient() as cur: cur.execute( cur.mogrify(__pg_errors_query(source, - value_length=len(value) \ - if SUPPORTED_TYPES[event_type.ERROR.ui_type].change_by_length else None), + value_length=len(value)), {"project_id": project_id, "value": helper.string_to_sql_like(value), "svalue": helper.string_to_sql_like("^" + value), "source": source})) @@ -189,7 +187,7 @@ def __search_pg_errors(project_id, value, key=None, source=None): def __search_pg_errors_ios(project_id, value, key=None, source=None): now = TimeUTC.now() - if SUPPORTED_TYPES[event_type.ERROR_IOS.ui_type].change_by_length is False or len(value) > 2: + if len(value) > 2: query = f"""(SELECT DISTINCT ON(lg.reason) lg.reason AS value, '{event_type.ERROR_IOS.ui_type}' AS type @@ -268,7 +266,7 @@ def __search_pg_metadata(project_id, value, key=None, source=None): for k in meta_keys.keys(): colname = metadata.index_to_colname(meta_keys[k]) - if SUPPORTED_TYPES[event_type.METADATA.ui_type].change_by_length is False or len(value) > 2: + if len(value) > 2: sub_from.append(f"""((SELECT DISTINCT ON ({colname}) {colname} AS value, '{k}' AS key FROM public.sessions WHERE project_id = %(project_id)s @@ -294,48 +292,6 @@ def __search_pg_metadata(project_id, value, key=None, source=None): return results -def __generic_query(typename, value_length=None): - if value_length is None or value_length > 2: - return f"""(SELECT DISTINCT value, type - FROM public.autocomplete - WHERE - project_id = %(project_id)s - AND type='{typename}' - AND value ILIKE %(svalue)s - LIMIT 5) - UNION - (SELECT DISTINCT value, type - FROM public.autocomplete - WHERE - project_id = %(project_id)s - AND type='{typename}' - AND value ILIKE %(value)s - LIMIT 5);""" - return f"""SELECT DISTINCT value, type - FROM public.autocomplete - WHERE - project_id = %(project_id)s - AND type='{typename}' - AND value ILIKE %(svalue)s - LIMIT 10;""" - - -def __generic_autocomplete(event: Event): - def f(project_id, value, key=None, source=None): - with pg_client.PostgresClient() as cur: - cur.execute( - cur.mogrify( - __generic_query(event.ui_type, - value_length=len(value) \ - if SUPPORTED_TYPES[event.ui_type].change_by_length \ - else None), - {"project_id": project_id, "value": helper.string_to_sql_like(value), - "svalue": helper.string_to_sql_like("^" + value)})) - return helper.list_to_camel_case(cur.fetchall()) - - return f - - class event_type: CLICK = Event(ui_type=schemas.EventType.click, table="events.clicks", column="label") INPUT = Event(ui_type=schemas.EventType.input, table="events.inputs", column="label") @@ -358,99 +314,65 @@ class event_type: SUPPORTED_TYPES = { - event_type.CLICK.ui_type: SupportedFilter(get=__generic_autocomplete(event_type.CLICK), - query=__generic_query(typename=event_type.CLICK.ui_type), - change_by_length=True), - event_type.INPUT.ui_type: SupportedFilter(get=__generic_autocomplete(event_type.INPUT), - query=__generic_query(typename=event_type.INPUT.ui_type), - change_by_length=True), - event_type.LOCATION.ui_type: SupportedFilter(get=__generic_autocomplete(event_type.LOCATION), - query=__generic_query(typename=event_type.LOCATION.ui_type), - change_by_length=True), - event_type.CUSTOM.ui_type: SupportedFilter(get=__generic_autocomplete(event_type.CUSTOM), - query=__generic_query(typename=event_type.CUSTOM.ui_type), - change_by_length=True), - event_type.REQUEST.ui_type: SupportedFilter(get=__generic_autocomplete(event_type.REQUEST), - query=__generic_query(typename=event_type.REQUEST.ui_type), - change_by_length=True), - event_type.GRAPHQL.ui_type: SupportedFilter(get=__generic_autocomplete(event_type.GRAPHQL), - query=__generic_query(typename=event_type.GRAPHQL.ui_type), - change_by_length=True), - event_type.STATEACTION.ui_type: SupportedFilter(get=__generic_autocomplete(event_type.STATEACTION), - query=__generic_query(typename=event_type.STATEACTION.ui_type), - change_by_length=True), + event_type.CLICK.ui_type: SupportedFilter(get=autocomplete.__generic_autocomplete(event_type.CLICK), + query=autocomplete.__generic_query(typename=event_type.CLICK.ui_type)), + event_type.INPUT.ui_type: SupportedFilter(get=autocomplete.__generic_autocomplete(event_type.INPUT), + query=autocomplete.__generic_query(typename=event_type.INPUT.ui_type)), + event_type.LOCATION.ui_type: SupportedFilter(get=autocomplete.__generic_autocomplete(event_type.LOCATION), + query=autocomplete.__generic_query( + typename=event_type.LOCATION.ui_type)), + event_type.CUSTOM.ui_type: SupportedFilter(get=autocomplete.__generic_autocomplete(event_type.CUSTOM), + query=autocomplete.__generic_query(typename=event_type.CUSTOM.ui_type)), + event_type.REQUEST.ui_type: SupportedFilter(get=autocomplete.__generic_autocomplete(event_type.REQUEST), + query=autocomplete.__generic_query( + typename=event_type.REQUEST.ui_type)), + event_type.GRAPHQL.ui_type: SupportedFilter(get=autocomplete.__generic_autocomplete(event_type.GRAPHQL), + query=autocomplete.__generic_query( + typename=event_type.GRAPHQL.ui_type)), + event_type.STATEACTION.ui_type: SupportedFilter(get=autocomplete.__generic_autocomplete(event_type.STATEACTION), + query=autocomplete.__generic_query( + typename=event_type.STATEACTION.ui_type)), event_type.ERROR.ui_type: SupportedFilter(get=__search_pg_errors, - query=None, change_by_length=True), + query=None), event_type.METADATA.ui_type: SupportedFilter(get=__search_pg_metadata, - query=None, change_by_length=True), + query=None), # IOS - event_type.CLICK_IOS.ui_type: SupportedFilter(get=__generic_autocomplete(event_type.CLICK_IOS), - query=__generic_query(typename=event_type.CLICK_IOS.ui_type), - change_by_length=True), - event_type.INPUT_IOS.ui_type: SupportedFilter(get=__generic_autocomplete(event_type.INPUT_IOS), - query=__generic_query(typename=event_type.INPUT_IOS.ui_type), - change_by_length=True), - event_type.VIEW_IOS.ui_type: SupportedFilter(get=__generic_autocomplete(event_type.VIEW_IOS), - query=__generic_query(typename=event_type.VIEW_IOS.ui_type), - change_by_length=True), - event_type.CUSTOM_IOS.ui_type: SupportedFilter(get=__generic_autocomplete(event_type.CUSTOM_IOS), - query=__generic_query(typename=event_type.CUSTOM_IOS.ui_type), - change_by_length=True), - event_type.REQUEST_IOS.ui_type: SupportedFilter(get=__generic_autocomplete(event_type.REQUEST_IOS), - query=__generic_query(typename=event_type.REQUEST_IOS.ui_type), - change_by_length=True), + event_type.CLICK_IOS.ui_type: SupportedFilter(get=autocomplete.__generic_autocomplete(event_type.CLICK_IOS), + query=autocomplete.__generic_query( + typename=event_type.CLICK_IOS.ui_type)), + event_type.INPUT_IOS.ui_type: SupportedFilter(get=autocomplete.__generic_autocomplete(event_type.INPUT_IOS), + query=autocomplete.__generic_query( + typename=event_type.INPUT_IOS.ui_type)), + event_type.VIEW_IOS.ui_type: SupportedFilter(get=autocomplete.__generic_autocomplete(event_type.VIEW_IOS), + query=autocomplete.__generic_query( + typename=event_type.VIEW_IOS.ui_type)), + event_type.CUSTOM_IOS.ui_type: SupportedFilter(get=autocomplete.__generic_autocomplete(event_type.CUSTOM_IOS), + query=autocomplete.__generic_query( + typename=event_type.CUSTOM_IOS.ui_type)), + event_type.REQUEST_IOS.ui_type: SupportedFilter(get=autocomplete.__generic_autocomplete(event_type.REQUEST_IOS), + query=autocomplete.__generic_query( + typename=event_type.REQUEST_IOS.ui_type)), event_type.ERROR_IOS.ui_type: SupportedFilter(get=__search_pg_errors_ios, - query=None, change_by_length=True), + query=None), } -def __get_autocomplete_table(value, project_id): - autocomplete_events = [schemas.FilterType.rev_id, - schemas.EventType.click, - schemas.FilterType.user_device, - schemas.FilterType.user_id, - schemas.FilterType.user_browser, - schemas.FilterType.user_os, - schemas.EventType.custom, - schemas.FilterType.user_country, - schemas.EventType.location, - schemas.EventType.input] - autocomplete_events.sort() - sub_queries = [] - for e in autocomplete_events: - sub_queries.append(f"""(SELECT type, value - FROM public.autocomplete - WHERE project_id = %(project_id)s - AND type= '{e}' - AND value ILIKE %(svalue)s - LIMIT 5)""") - if len(value) > 2: - sub_queries.append(f"""(SELECT type, value - FROM public.autocomplete - WHERE project_id = %(project_id)s - AND type= '{e}' - AND value ILIKE %(value)s - LIMIT 5)""") +def get_errors_by_session_id(session_id, project_id): with pg_client.PostgresClient() as cur: - query = cur.mogrify(" UNION ".join(sub_queries) + ";", - {"project_id": project_id, "value": helper.string_to_sql_like(value), - "svalue": helper.string_to_sql_like("^" + value)}) - try: - cur.execute(query) - except Exception as err: - print("--------- AUTOCOMPLETE SEARCH QUERY EXCEPTION -----------") - print(query.decode('UTF-8')) - print("--------- VALUE -----------") - print(value) - print("--------------------") - raise err - results = helper.list_to_camel_case(cur.fetchall()) - return results + cur.execute(cur.mogrify(f"""\ + SELECT er.*,ur.*, er.timestamp - s.start_ts AS time + FROM {event_type.ERROR.table} AS er INNER JOIN public.errors AS ur USING (error_id) INNER JOIN public.sessions AS s USING (session_id) + WHERE er.session_id = %(session_id)s AND s.project_id=%(project_id)s + ORDER BY timestamp;""", {"session_id": session_id, "project_id": project_id})) + errors = cur.fetchall() + for e in errors: + e["stacktrace_parsed_at"] = TimeUTC.datetime_to_timestamp(e["stacktrace_parsed_at"]) + return helper.list_to_camel_case(errors) def search(text, event_type, project_id, source, key): if not event_type: - return {"data": __get_autocomplete_table(text, project_id)} + return {"data": autocomplete.__get_autocomplete_table(text, project_id)} if event_type in SUPPORTED_TYPES.keys(): rows = SUPPORTED_TYPES[event_type].get(project_id=project_id, value=text, key=key, source=source) @@ -470,16 +392,3 @@ def search(text, event_type, project_id, source, key): return {"errors": ["unsupported event"]} return {"data": rows} - - -def get_errors_by_session_id(session_id, project_id): - with pg_client.PostgresClient() as cur: - cur.execute(cur.mogrify(f"""\ - SELECT er.*,ur.*, er.timestamp - s.start_ts AS time - FROM {event_type.ERROR.table} AS er INNER JOIN public.errors AS ur USING (error_id) INNER JOIN public.sessions AS s USING (session_id) - WHERE er.session_id = %(session_id)s AND s.project_id=%(project_id)s - ORDER BY timestamp;""", {"session_id": session_id, "project_id": project_id})) - errors = cur.fetchall() - for e in errors: - e["stacktrace_parsed_at"] = TimeUTC.datetime_to_timestamp(e["stacktrace_parsed_at"]) - return helper.list_to_camel_case(errors) diff --git a/api/chalicelib/core/funnels.py b/api/chalicelib/core/funnels.py index 3239f4705..65cb7b09a 100644 --- a/api/chalicelib/core/funnels.py +++ b/api/chalicelib/core/funnels.py @@ -138,8 +138,8 @@ def get_by_user(project_id, user_id, range_value=None, start_date=None, end_date get_start_end_time(filter_d=row["filter"], range_value=range_value, start_date=start_date, end_date=end_date) - counts = sessions.search2_pg(data=schemas.SessionsSearchPayloadSchema.parse_obj(row["filter"]), - project_id=project_id, user_id=None, count_only=True) + counts = sessions.search_sessions(data=schemas.SessionsSearchPayloadSchema.parse_obj(row["filter"]), + project_id=project_id, user_id=None, count_only=True) row["sessionsCount"] = counts["countSessions"] row["usersCount"] = counts["countUsers"] filter_clone = dict(row["filter"]) @@ -193,8 +193,8 @@ def get_sessions(project_id, funnel_id, user_id, range_value=None, start_date=No if f is None: return {"errors": ["funnel not found"]} get_start_end_time(filter_d=f["filter"], range_value=range_value, start_date=start_date, end_date=end_date) - return sessions.search2_pg(data=schemas.SessionsSearchPayloadSchema.parse_obj(f["filter"]), project_id=project_id, - user_id=user_id) + return sessions.search_sessions(data=schemas.SessionsSearchPayloadSchema.parse_obj(f["filter"]), project_id=project_id, + user_id=user_id) def get_sessions_on_the_fly(funnel_id, project_id, user_id, data: schemas.FunnelSearchPayloadSchema): @@ -207,8 +207,8 @@ def get_sessions_on_the_fly(funnel_id, project_id, user_id, data: schemas.Funnel get_start_end_time(filter_d=f["filter"], range_value=data.range_value, start_date=data.startDate, end_date=data.endDate) data = schemas.FunnelSearchPayloadSchema.parse_obj(f["filter"]) - return sessions.search2_pg(data=data, project_id=project_id, - user_id=user_id) + return sessions.search_sessions(data=data, project_id=project_id, + user_id=user_id) def get_top_insights(project_id, user_id, funnel_id, range_value=None, start_date=None, end_date=None): @@ -365,8 +365,8 @@ def search_by_issue(user_id, project_id, funnel_id, issue_id, data: schemas.Funn if i.get("issueId", "") == issue_id: issue = i break - return {"sessions": sessions.search2_pg(user_id=user_id, project_id=project_id, issue=issue, - data=data) if issue is not None else {"total": 0, "sessions": []}, + return {"sessions": sessions.search_sessions(user_id=user_id, project_id=project_id, issue=issue, + data=data) if issue is not None else {"total": 0, "sessions": []}, # "stages": helper.list_to_camel_case(insights), # "totalDropDueToIssues": total_drop_due_to_issues, "issue": issue} diff --git a/api/chalicelib/core/integration_base.py b/api/chalicelib/core/integration_base.py index f8edaad62..6377599af 100644 --- a/api/chalicelib/core/integration_base.py +++ b/api/chalicelib/core/integration_base.py @@ -7,13 +7,18 @@ class BaseIntegration(ABC): def __init__(self, user_id, ISSUE_CLASS): self._user_id = user_id - self.issue_handler = ISSUE_CLASS(self.integration_token) + self.__issue_handler = ISSUE_CLASS(self.integration_token) @property @abstractmethod def provider(self): pass + @property + @abstractmethod + def issue_handler(self): + pass + @property def integration_token(self): integration = self.get() diff --git a/api/chalicelib/core/integration_github.py b/api/chalicelib/core/integration_github.py index a05c946f4..31e715f4a 100644 --- a/api/chalicelib/core/integration_github.py +++ b/api/chalicelib/core/integration_github.py @@ -1,8 +1,9 @@ +import schemas from chalicelib.core import integration_base from chalicelib.core.integration_github_issue import GithubIntegrationIssue from chalicelib.utils import pg_client, helper -PROVIDER = "GITHUB" +PROVIDER = schemas.IntegrationType.github class GitHubIntegration(integration_base.BaseIntegration): @@ -15,6 +16,10 @@ class GitHubIntegration(integration_base.BaseIntegration): def provider(self): return PROVIDER + @property + def issue_handler(self): + return + def get_obfuscated(self): integration = self.get() if integration is None: diff --git a/api/chalicelib/core/integration_jira_cloud.py b/api/chalicelib/core/integration_jira_cloud.py index 7d8c956cf..656a22b14 100644 --- a/api/chalicelib/core/integration_jira_cloud.py +++ b/api/chalicelib/core/integration_jira_cloud.py @@ -1,8 +1,9 @@ +import schemas from chalicelib.core import integration_base from chalicelib.core.integration_jira_cloud_issue import JIRACloudIntegrationIssue from chalicelib.utils import pg_client, helper -PROVIDER = "JIRA" +PROVIDER = schemas.IntegrationType.jira def obfuscate_string(string): @@ -16,21 +17,29 @@ class JIRAIntegration(integration_base.BaseIntegration): # super(JIRAIntegration, self).__init__(jwt, user_id, JIRACloudIntegrationProxy) self._user_id = user_id self.integration = self.get() + if self.integration is None: return self.integration["valid"] = True - try: - self.issue_handler = JIRACloudIntegrationIssue(token=self.integration["token"], - username=self.integration["username"], - url=self.integration["url"]) - except Exception as e: - self.issue_handler = None + if not self.integration["url"].endswith('atlassian.net'): self.integration["valid"] = False @property def provider(self): return PROVIDER + @property + def issue_handler(self): + if self.integration["url"].endswith('atlassian.net') and self.__issue_handler is None: + try: + self.__issue_handler = JIRACloudIntegrationIssue(token=self.integration["token"], + username=self.integration["username"], + url=self.integration["url"]) + except Exception as e: + self.__issue_handler = None + self.integration["valid"] = False + return self.__issue_handler + # TODO: remove this once jira-oauth is done def get(self): with pg_client.PostgresClient() as cur: @@ -41,7 +50,14 @@ class JIRAIntegration(integration_base.BaseIntegration): WHERE user_id=%(user_id)s;""", {"user_id": self._user_id}) ) - return helper.dict_to_camel_case(cur.fetchone()) + data = helper.dict_to_camel_case(cur.fetchone()) + + if data is None: + return + data["valid"] = True + if not data["url"].endswith('atlassian.net'): + data["valid"] = False + return data def get_obfuscated(self): if self.integration is None: @@ -66,7 +82,7 @@ class JIRAIntegration(integration_base.BaseIntegration): w = helper.dict_to_camel_case(cur.fetchone()) if obfuscate: w["token"] = obfuscate_string(w["token"]) - return w + return self.get() # TODO: make this generic for all issue tracking integrations def _add(self, data): @@ -84,7 +100,7 @@ class JIRAIntegration(integration_base.BaseIntegration): "token": token, "url": url}) ) w = helper.dict_to_camel_case(cur.fetchone()) - return w + return self.get() def delete(self): with pg_client.PostgresClient() as cur: diff --git a/api/chalicelib/core/integrations_global.py b/api/chalicelib/core/integrations_global.py new file mode 100644 index 000000000..5b00a28bd --- /dev/null +++ b/api/chalicelib/core/integrations_global.py @@ -0,0 +1,61 @@ +import schemas +from chalicelib.utils import pg_client + + +def get_global_integrations_status(tenant_id, user_id, project_id): + with pg_client.PostgresClient() as cur: + cur.execute( + cur.mogrify(f"""\ + SELECT EXISTS((SELECT 1 + FROM public.oauth_authentication + WHERE user_id = %(user_id)s + AND provider = 'github')) AS {schemas.IntegrationType.github}, + EXISTS((SELECT 1 + FROM public.jira_cloud + WHERE user_id = %(user_id)s)) AS {schemas.IntegrationType.jira}, + EXISTS((SELECT 1 + FROM public.integrations + WHERE project_id=%(project_id)s + AND provider='bugsnag')) AS {schemas.IntegrationType.bugsnag}, + EXISTS((SELECT 1 + FROM public.integrations + WHERE project_id=%(project_id)s + AND provider='cloudwatch')) AS {schemas.IntegrationType.cloudwatch}, + EXISTS((SELECT 1 + FROM public.integrations + WHERE project_id=%(project_id)s + AND provider='datadog')) AS {schemas.IntegrationType.datadog}, + EXISTS((SELECT 1 + FROM public.integrations + WHERE project_id=%(project_id)s + AND provider='newrelic')) AS {schemas.IntegrationType.newrelic}, + EXISTS((SELECT 1 + FROM public.integrations + WHERE project_id=%(project_id)s + AND provider='rollbar')) AS {schemas.IntegrationType.rollbar}, + EXISTS((SELECT 1 + FROM public.integrations + WHERE project_id=%(project_id)s + AND provider='sentry')) AS {schemas.IntegrationType.sentry}, + EXISTS((SELECT 1 + FROM public.integrations + WHERE project_id=%(project_id)s + AND provider='stackdriver')) AS {schemas.IntegrationType.stackdriver}, + EXISTS((SELECT 1 + FROM public.integrations + WHERE project_id=%(project_id)s + AND provider='sumologic')) AS {schemas.IntegrationType.sumologic}, + EXISTS((SELECT 1 + FROM public.integrations + WHERE project_id=%(project_id)s + AND provider='elasticsearch')) AS {schemas.IntegrationType.elasticsearch}, + EXISTS((SELECT 1 + FROM public.webhooks + WHERE type='slack')) AS {schemas.IntegrationType.slack};""", + {"user_id": user_id, "tenant_id": tenant_id, "project_id": project_id}) + ) + current_integrations = cur.fetchone() + result = [] + for k in current_integrations.keys(): + result.append({"name": k, "integrated": current_integrations[k]}) + return result diff --git a/api/chalicelib/core/integrations_manager.py b/api/chalicelib/core/integrations_manager.py index ef63a7d96..5cc15cfba 100644 --- a/api/chalicelib/core/integrations_manager.py +++ b/api/chalicelib/core/integrations_manager.py @@ -27,7 +27,7 @@ def __get_default_integration(user_id): current_integrations["jira"] else None -def get_integration(tenant_id, user_id, tool=None): +def get_integration(tenant_id, user_id, tool=None, for_delete=False): if tool is None: tool = __get_default_integration(user_id=user_id) if tool is None: @@ -37,7 +37,7 @@ def get_integration(tenant_id, user_id, tool=None): return {"errors": [f"issue tracking tool not supported yet, available: {SUPPORTED_TOOLS}"]}, None if tool == integration_jira_cloud.PROVIDER: integration = integration_jira_cloud.JIRAIntegration(tenant_id=tenant_id, user_id=user_id) - if integration.integration is not None and not integration.integration.get("valid", True): + if not for_delete and integration.integration is not None and not integration.integration.get("valid", True): return {"errors": ["JIRA: connexion issue/unauthorized"]}, integration return None, integration elif tool == integration_github.PROVIDER: diff --git a/api/chalicelib/core/metrics.py b/api/chalicelib/core/metrics.py index 2aaaeb1d9..5d987e485 100644 --- a/api/chalicelib/core/metrics.py +++ b/api/chalicelib/core/metrics.py @@ -765,8 +765,8 @@ def get_missing_resources_trend(project_id, startTimestamp=TimeUTC.now(delta_day pg_sub_query_chart = __get_constraints(project_id=project_id, time_constraint=True, chart=True, data=args) pg_sub_query.append("resources.success = FALSE") pg_sub_query_chart.append("resources.success = FALSE") - pg_sub_query.append("resources.type != 'fetch'") - pg_sub_query_chart.append("resources.type != 'fetch'") + pg_sub_query.append("resources.type = 'img'") + pg_sub_query_chart.append("resources.type = 'img'") with pg_client.PostgresClient() as cur: pg_query = f"""SELECT @@ -1580,27 +1580,27 @@ def get_domains_errors(project_id, startTimestamp=TimeUTC.now(delta_days=-1), step_size = __get_step_size(startTimestamp, endTimestamp, density, factor=1) 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, + data=args, main_table="requests", 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") + pg_sub_query_subset.append("requests.timestamp>=%(startTimestamp)s") + pg_sub_query_subset.append("requests.timestamp<%(endTimestamp)s") + pg_sub_query_subset.append("requests.status/100 = %(status_code)s") with pg_client.PostgresClient() as cur: - pg_query = f"""WITH resources AS(SELECT resources.url_host, timestamp - FROM events.resources INNER JOIN public.sessions USING (session_id) + pg_query = f"""WITH requests AS(SELECT requests.host, timestamp + FROM events_common.requests 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 + COALESCE(JSONB_AGG(requests) FILTER ( WHERE requests 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.*) AS count - FROM resources + LEFT JOIN LATERAL ( SELECT requests.host, COUNT(*) AS count + FROM requests WHERE {" AND ".join(pg_sub_query_chart)} - GROUP BY url_host + GROUP BY host ORDER BY count DESC LIMIT 5 - ) AS resources ON (TRUE) + ) AS requests ON (TRUE) GROUP BY generated_timestamp ORDER BY generated_timestamp;""" params = {"project_id": project_id, @@ -1625,37 +1625,37 @@ def get_domains_errors(project_id, startTimestamp=TimeUTC.now(delta_days=-1), return result -def get_domains_errors_4xx(project_id, startTimestamp=TimeUTC.now(delta_days=-1), - endTimestamp=TimeUTC.now(), density=6, **args): +def __get_domains_errors_4xx_and_5xx(status, 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_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, + data=args, main_table="requests", time_column="timestamp", project=False, duration=False) - pg_sub_query_subset.append("resources.status/100 = %(status_code)s") + pg_sub_query_subset.append("requests.status/100 = %(status_code)s") with pg_client.PostgresClient() as cur: - pg_query = f"""WITH resources AS (SELECT resources.url_host, timestamp - FROM events.resources INNER JOIN public.sessions USING (session_id) + pg_query = f"""WITH requests AS (SELECT host, timestamp + FROM events_common.requests 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 + COALESCE(JSONB_AGG(requests) FILTER ( WHERE requests 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.url_host) AS count - FROM resources + LEFT JOIN LATERAL ( SELECT requests.host, COUNT(*) AS count + FROM requests WHERE {" AND ".join(pg_sub_query_chart)} - GROUP BY url_host + GROUP BY host ORDER BY count DESC LIMIT 5 - ) AS resources ON (TRUE) + ) AS requests ON (TRUE) GROUP BY generated_timestamp ORDER BY generated_timestamp;""" params = {"project_id": project_id, "startTimestamp": startTimestamp, "endTimestamp": endTimestamp, "step_size": step_size, - "status_code": 4, **__get_constraint_values(args)} + "status_code": status, **__get_constraint_values(args)} cur.execute(cur.mogrify(pg_query, params)) rows = cur.fetchall() rows = __nested_array_to_dict_array(rows) @@ -1665,44 +1665,16 @@ def get_domains_errors_4xx(project_id, startTimestamp=TimeUTC.now(delta_days=-1) return rows +def get_domains_errors_4xx(project_id, startTimestamp=TimeUTC.now(delta_days=-1), + endTimestamp=TimeUTC.now(), density=6, **args): + return __get_domains_errors_4xx_and_5xx(status=4, project_id=project_id, startTimestamp=startTimestamp, + endTimestamp=endTimestamp, density=density, **args) + + 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_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"""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.url_host) AS count - FROM resources - WHERE {" AND ".join(pg_sub_query_chart)} - GROUP BY url_host - ORDER BY count DESC - LIMIT 5 - ) AS resources ON (TRUE) - GROUP BY generated_timestamp - ORDER BY generated_timestamp;""" - params = {"project_id": project_id, - "startTimestamp": startTimestamp, - "endTimestamp": endTimestamp, - "step_size": step_size, - "status_code": 5, **__get_constraint_values(args)} - cur.execute(cur.mogrify(pg_query, params)) - rows = cur.fetchall() - rows = __nested_array_to_dict_array(rows) - neutral = __get_neutral(rows) - rows = __merge_rows_with_neutral(rows, neutral) - - return rows + return __get_domains_errors_4xx_and_5xx(status=5, project_id=project_id, startTimestamp=startTimestamp, + endTimestamp=endTimestamp, density=density, **args) def __nested_array_to_dict_array(rows, key="url_host", value="count"): @@ -1747,15 +1719,15 @@ def get_slowest_domains(project_id, startTimestamp=TimeUTC.now(delta_days=-1), def get_errors_per_domains(project_id, startTimestamp=TimeUTC.now(delta_days=-1), endTimestamp=TimeUTC.now(), **args): pg_sub_query = __get_constraints(project_id=project_id, data=args) - pg_sub_query.append("resources.success = FALSE") + pg_sub_query.append("requests.success = FALSE") with pg_client.PostgresClient() as cur: pg_query = f"""SELECT - resources.url_host AS domain, - COUNT(resources.session_id) AS errors_count - FROM events.resources INNER JOIN sessions USING (session_id) + requests.host AS domain, + COUNT(requests.session_id) AS errors_count + FROM events_common.requests INNER JOIN sessions USING (session_id) WHERE {" AND ".join(pg_sub_query)} - GROUP BY resources.url_host + GROUP BY requests.host ORDER BY errors_count DESC LIMIT 5;""" cur.execute(cur.mogrify(pg_query, {"project_id": project_id, @@ -1823,7 +1795,7 @@ def get_calls_errors(project_id, startTimestamp=TimeUTC.now(delta_days=-1), endT FROM events.resources INNER JOIN sessions USING (session_id) WHERE {" AND ".join(pg_sub_query)} GROUP BY resources.method, resources.url_hostpath - ORDER BY (4 + 5), 3 DESC + ORDER BY (4 + 5) DESC, 3 DESC LIMIT 50;""" cur.execute(cur.mogrify(pg_query, {"project_id": project_id, "startTimestamp": startTimestamp, @@ -1832,50 +1804,45 @@ def get_calls_errors(project_id, startTimestamp=TimeUTC.now(delta_days=-1), endT return helper.list_to_camel_case(rows) -def get_calls_errors_4xx(project_id, startTimestamp=TimeUTC.now(delta_days=-1), endTimestamp=TimeUTC.now(), - platform=None, **args): +def __get_calls_errors_4xx_or_5xx(status, project_id, startTimestamp=TimeUTC.now(delta_days=-1), + endTimestamp=TimeUTC.now(), + platform=None, **args): pg_sub_query = __get_constraints(project_id=project_id, data=args) - pg_sub_query.append("resources.type = 'fetch'") - pg_sub_query.append("resources.method IS NOT NULL") - pg_sub_query.append("resources.status/100 = 4") + pg_sub_query.append("requests.type = 'fetch'") + pg_sub_query.append("requests.method IS NOT NULL") + pg_sub_query.append(f"requests.status/100 = {status}") with pg_client.PostgresClient() as cur: - pg_query = f"""SELECT resources.method, - resources.url_hostpath, - COUNT(resources.session_id) AS all_requests - FROM events.resources INNER JOIN sessions USING (session_id) + pg_query = f"""SELECT requests.method, + requests.host, + requests.path, + COUNT(requests.session_id) AS all_requests + FROM events_common.requests INNER JOIN sessions USING (session_id) WHERE {" AND ".join(pg_sub_query)} - GROUP BY resources.method, resources.url_hostpath + GROUP BY requests.method, requests.host, requests.path ORDER BY all_requests DESC LIMIT 10;""" cur.execute(cur.mogrify(pg_query, {"project_id": project_id, "startTimestamp": startTimestamp, "endTimestamp": endTimestamp, **__get_constraint_values(args)})) rows = cur.fetchall() + for r in rows: + r["url_hostpath"] = r.pop("host") + r.pop("path") return helper.list_to_camel_case(rows) +def get_calls_errors_4xx(project_id, startTimestamp=TimeUTC.now(delta_days=-1), endTimestamp=TimeUTC.now(), + platform=None, **args): + return __get_calls_errors_4xx_or_5xx(status=4, project_id=project_id, startTimestamp=startTimestamp, + endTimestamp=endTimestamp, + platform=platform, **args) + + def get_calls_errors_5xx(project_id, startTimestamp=TimeUTC.now(delta_days=-1), endTimestamp=TimeUTC.now(), platform=None, **args): - pg_sub_query = __get_constraints(project_id=project_id, data=args) - pg_sub_query.append("resources.type = 'fetch'") - pg_sub_query.append("resources.method IS NOT NULL") - pg_sub_query.append("resources.status/100 = 5") - - with pg_client.PostgresClient() as cur: - pg_query = f"""SELECT resources.method, - resources.url_hostpath, - COUNT(resources.session_id) AS all_requests - FROM events.resources INNER JOIN sessions USING (session_id) - WHERE {" AND ".join(pg_sub_query)} - GROUP BY resources.method, resources.url_hostpath - ORDER BY all_requests DESC - LIMIT 10;""" - cur.execute(cur.mogrify(pg_query, {"project_id": project_id, - "startTimestamp": startTimestamp, - "endTimestamp": endTimestamp, **__get_constraint_values(args)})) - rows = cur.fetchall() - return helper.list_to_camel_case(rows) + return __get_calls_errors_4xx_or_5xx(status=5, project_id=project_id, startTimestamp=startTimestamp, + endTimestamp=endTimestamp, + platform=platform, **args) def get_errors_per_type(project_id, startTimestamp=TimeUTC.now(delta_days=-1), endTimestamp=TimeUTC.now(), @@ -1883,10 +1850,9 @@ def get_errors_per_type(project_id, startTimestamp=TimeUTC.now(delta_days=-1), e step_size = __get_step_size(startTimestamp, endTimestamp, density, factor=1) 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.append("requests.timestamp>=%(startTimestamp)s") + pg_sub_query_subset.append("requests.timestamp<%(endTimestamp)s") + pg_sub_query_subset.append("requests.status_code > 200") pg_sub_query_subset_e = __get_constraints(project_id=project_id, data=args, duration=False, main_table="m_errors", time_constraint=False) @@ -1897,8 +1863,8 @@ def get_errors_per_type(project_id, startTimestamp=TimeUTC.now(delta_days=-1), e pg_sub_query_subset_e.append("timestamp<%(endTimestamp)s") with pg_client.PostgresClient() as cur: - pg_query = f"""WITH resources AS (SELECT status, timestamp - FROM events.resources + pg_query = f"""WITH requests AS (SELECT status_code AS status, timestamp + FROM events_common.requests INNER JOIN public.sessions USING (session_id) WHERE {" AND ".join(pg_sub_query_subset)} ), @@ -1927,7 +1893,7 @@ def get_errors_per_type(project_id, startTimestamp=TimeUTC.now(delta_days=-1), e ), 0) AS integrations FROM generate_series(%(startTimestamp)s, %(endTimestamp)s, %(step_size)s) AS generated_timestamp LEFT JOIN LATERAL (SELECT status - FROM resources + FROM requests WHERE {" AND ".join(pg_sub_query_chart)} ) AS errors_partition ON (TRUE) GROUP BY timestamp @@ -2169,44 +2135,44 @@ def get_resources_by_party(project_id, startTimestamp=TimeUTC.now(delta_days=-1) 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", + chart=True, data=args, main_table="requests", 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") + pg_sub_query_subset.append("requests.timestamp >= %(startTimestamp)s") + pg_sub_query_subset.append("requests.timestamp < %(endTimestamp)s") + # pg_sub_query_subset.append("resources.type IN ('fetch', 'script')") + pg_sub_query_subset.append("requests.success = FALSE") with pg_client.PostgresClient() as cur: - pg_query = f"""WITH resources AS ( - SELECT resources.url_host, timestamp - FROM events.resources + pg_query = f"""WITH requests AS ( + SELECT requests.host, timestamp + FROM events_common.requests 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 + SUM(CASE WHEN first.host = sub_requests.host THEN 1 ELSE 0 END) AS first_party, + SUM(CASE WHEN first.host != sub_requests.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 + SELECT requests.host, + COUNT(requests.session_id) AS count + FROM events_common.requests 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 requests.timestamp > (EXTRACT(EPOCH FROM now() - INTERVAL '31 days') * 1000)::BIGINT + AND requests.timestamp < (EXTRACT(EPOCH FROM now()) * 1000)::BIGINT AND sessions.duration>0 - GROUP BY resources.url_host + GROUP BY requests.host ORDER BY count DESC LIMIT 1 ) AS first ON (TRUE) LEFT JOIN LATERAL ( - SELECT resources.url_host - FROM resources + SELECT requests.host + FROM requests WHERE {" AND ".join(pg_sub_query_chart)} - ) AS sub_resources ON (TRUE) + ) AS sub_requests ON (TRUE) GROUP BY generated_timestamp ORDER BY generated_timestamp;""" cur.execute(cur.mogrify(pg_query, {"step_size": step_size, diff --git a/api/chalicelib/core/projects.py b/api/chalicelib/core/projects.py index 0893f6259..100fe6765 100644 --- a/api/chalicelib/core/projects.py +++ b/api/chalicelib/core/projects.py @@ -43,25 +43,53 @@ def __create(tenant_id, name): def get_projects(tenant_id, recording_state=False, gdpr=None, recorded=False, stack_integrations=False): with pg_client.PostgresClient() as cur: - recorded_q = "" + extra_projection = "" + extra_join = "" + if gdpr: + extra_projection += ',s.gdpr' if recorded: - recorded_q = """, COALESCE((SELECT TRUE - FROM public.sessions - WHERE sessions.project_id = s.project_id - AND sessions.start_ts >= (EXTRACT(EPOCH FROM s.created_at) * 1000 - 24 * 60 * 60 * 1000) - AND sessions.start_ts <= %(now)s - LIMIT 1), FALSE) AS recorded""" - query = cur.mogrify(f"""SELECT - s.project_id, s.name, s.project_key, s.save_request_payloads - {',s.gdpr' if gdpr else ''} - {recorded_q} - {',stack_integrations.count>0 AS stack_integrations' if stack_integrations else ''} + extra_projection += """, COALESCE(nullif(EXTRACT(EPOCH FROM s.first_recorded_session_at) * 1000, NULL)::BIGINT, + (SELECT MIN(sessions.start_ts) + FROM public.sessions + WHERE sessions.project_id = s.project_id + AND sessions.start_ts >= (EXTRACT(EPOCH FROM + COALESCE(s.sessions_last_check_at, s.created_at)) * 1000-24*60*60*1000) + AND sessions.start_ts <= %(now)s + LIMIT 1), NULL) AS first_recorded""" + if stack_integrations: + extra_projection += ',stack_integrations.count>0 AS stack_integrations' + + if stack_integrations: + extra_join = """LEFT JOIN LATERAL (SELECT COUNT(*) AS count + FROM public.integrations + WHERE s.project_id = integrations.project_id + LIMIT 1) AS stack_integrations ON TRUE""" + + query = cur.mogrify(f"""{"SELECT *, first_recorded IS NOT NULL AS recorded FROM (" if recorded else ""} + SELECT s.project_id, s.name, s.project_key, s.save_request_payloads, s.first_recorded_session_at + {extra_projection} FROM public.projects AS s - {'LEFT JOIN LATERAL (SELECT COUNT(*) AS count FROM public.integrations WHERE s.project_id = integrations.project_id LIMIT 1) AS stack_integrations ON TRUE' if stack_integrations else ''} + {extra_join} WHERE s.deleted_at IS NULL - ORDER BY s.project_id;""", {"now": TimeUTC.now()}) + ORDER BY s.project_id {") AS raw" if recorded else ""};""", {"now": TimeUTC.now()}) cur.execute(query) rows = cur.fetchall() + # if recorded is requested, check if it was saved or computed + if recorded: + for r in rows: + if r["first_recorded_session_at"] is None: + extra_update = "" + if r["recorded"]: + extra_update = ", first_recorded_session_at=to_timestamp(%(first_recorded)s/1000)" + query = cur.mogrify(f"""UPDATE public.projects + SET sessions_last_check_at=(now() at time zone 'utc') + {extra_update} + WHERE project_id=%(project_id)s""", + {"project_id": r["project_id"], "first_recorded": r["first_recorded"]}) + cur.execute(query) + r.pop("first_recorded_session_at") + r.pop("first_recorded") + if recording_state: project_ids = [f'({r["project_id"]})' for r in rows] query = cur.mogrify(f"""SELECT projects.project_id, COALESCE(MAX(start_ts), 0) AS last diff --git a/api/chalicelib/core/sessions.py b/api/chalicelib/core/sessions.py index c044a5819..6c846eb42 100644 --- a/api/chalicelib/core/sessions.py +++ b/api/chalicelib/core/sessions.py @@ -2,7 +2,7 @@ from typing import List import schemas from chalicelib.core import events, metadata, events_ios, \ - sessions_mobs, issues, projects, errors, resources, assist, performance_event + sessions_mobs, issues, projects, errors, resources, assist, performance_event, sessions_viewed, sessions_favorite from chalicelib.utils import pg_client, helper, metrics_helper SESSION_PROJECTION_COLS = """s.project_id, @@ -172,8 +172,12 @@ def _isUndefined_operator(op: schemas.SearchEventOperator): return op in [schemas.SearchEventOperator._is_undefined] -def search2_pg(data: schemas.SessionsSearchPayloadSchema, project_id, user_id, errors_only=False, - error_status=schemas.ErrorStatus.all, count_only=False, issue=None): +# This function executes the query and return result +def search_sessions(data: schemas.SessionsSearchPayloadSchema, project_id, user_id, errors_only=False, + error_status=schemas.ErrorStatus.all, count_only=False, issue=None): + if data.bookmarked: + data.startDate, data.endDate = sessions_favorite.get_start_end_timestamp(project_id, user_id) + full_args, query_part = search_query_parts(data=data, error_status=error_status, errors_only=errors_only, favorite_only=data.bookmarked, issue=issue, project_id=project_id, user_id=user_id) @@ -187,16 +191,12 @@ def search2_pg(data: schemas.SessionsSearchPayloadSchema, project_id, user_id, e meta_keys = [] with pg_client.PostgresClient() as cur: if errors_only: - main_query = cur.mogrify(f"""SELECT DISTINCT er.error_id, ser.status, ser.parent_error_id, ser.payload, - COALESCE((SELECT TRUE - FROM public.user_favorite_sessions AS fs - WHERE s.session_id = fs.session_id - AND fs.user_id = %(userId)s), FALSE) AS favorite, - COALESCE((SELECT TRUE + main_query = cur.mogrify(f"""SELECT DISTINCT er.error_id, + COALESCE((SELECT TRUE FROM public.user_viewed_errors AS ve WHERE er.error_id = ve.error_id AND ve.user_id = %(userId)s LIMIT 1), FALSE) AS viewed - {query_part};""", full_args) + {query_part};""", full_args) elif count_only: main_query = cur.mogrify(f"""SELECT COUNT(DISTINCT s.session_id) AS count_sessions, @@ -401,6 +401,7 @@ def __is_valid_event(is_any: bool, event: schemas._SessionSearchEventSchema): event.filters is None or len(event.filters) == 0)) +# this function generates the query and return the generated-query with the dict of query arguments def search_query_parts(data, error_status, errors_only, favorite_only, issue, project_id, user_id, extra_event=None): ss_constraints = [] full_args = {"project_id": project_id, "startDate": data.startDate, "endDate": data.endDate, @@ -522,12 +523,12 @@ def search_query_parts(data, error_status, errors_only, favorite_only, issue, pr ss_constraints.append("ms.duration <= %(maxDuration)s") full_args["maxDuration"] = f.value[1] elif filter_type == schemas.FilterType.referrer: - extra_from += f"INNER JOIN {events.event_type.LOCATION.table} AS p USING(session_id)" + # extra_from += f"INNER JOIN {events.event_type.LOCATION.table} AS p USING(session_id)" if is_any: - extra_constraints.append('p.base_referrer IS NOT NULL') + extra_constraints.append('s.base_referrer IS NOT NULL') else: extra_constraints.append( - _multiple_conditions(f"p.base_referrer {op} %({f_k})s", f.value, is_not=is_not, value_key=f_k)) + _multiple_conditions(f"s.base_referrer {op} %({f_k})s", f.value, is_not=is_not, value_key=f_k)) elif filter_type == events.event_type.METADATA.ui_type: # get metadata list only if you need it if meta_keys is None: @@ -717,7 +718,7 @@ def search_query_parts(data, error_status, errors_only, favorite_only, issue, pr event_where.append( _multiple_conditions(f"(main1.message {op} %({e_k})s OR main1.name {op} %({e_k})s)", event.value, value_key=e_k)) - if event.source[0] not in [None, "*", ""]: + if len(event.source) > 0 and event.source[0] not in [None, "*", ""]: event_where.append(_multiple_conditions(f"main1.source = %({s_k})s", event.source, value_key=s_k)) @@ -989,13 +990,13 @@ def search_query_parts(data, error_status, errors_only, favorite_only, issue, pr extra_from += f" INNER JOIN {events.event_type.ERROR.table} AS er USING (session_id) INNER JOIN public.errors AS ser USING (error_id)" extra_constraints.append("ser.source = 'js_exception'") extra_constraints.append("ser.project_id = %(project_id)s") - if error_status != schemas.ErrorStatus.all: - extra_constraints.append("ser.status = %(error_status)s") - full_args["error_status"] = error_status - if favorite_only: - extra_from += " INNER JOIN public.user_favorite_errors AS ufe USING (error_id)" - extra_constraints.append("ufe.user_id = %(userId)s") - # extra_constraints = [extra.decode('UTF-8') + "\n" for extra in extra_constraints] + # if error_status != schemas.ErrorStatus.all: + # extra_constraints.append("ser.status = %(error_status)s") + # full_args["error_status"] = error_status + # if favorite_only: + # extra_from += " INNER JOIN public.user_favorite_errors AS ufe USING (error_id)" + # extra_constraints.append("ufe.user_id = %(userId)s") + if favorite_only and not errors_only and user_id is not None: extra_from += """INNER JOIN (SELECT user_id, session_id FROM public.user_favorite_sessions diff --git a/api/chalicelib/core/sessions_favorite_viewed.py b/api/chalicelib/core/sessions_favorite.py similarity index 68% rename from api/chalicelib/core/sessions_favorite_viewed.py rename to api/chalicelib/core/sessions_favorite.py index 7f503679c..98d7f18ce 100644 --- a/api/chalicelib/core/sessions_favorite_viewed.py +++ b/api/chalicelib/core/sessions_favorite.py @@ -6,10 +6,8 @@ def add_favorite_session(project_id, user_id, session_id): with pg_client.PostgresClient() as cur: cur.execute( cur.mogrify(f"""\ - INSERT INTO public.user_favorite_sessions - (user_id, session_id) - VALUES - (%(userId)s,%(sessionId)s);""", + INSERT INTO public.user_favorite_sessions(user_id, session_id) + VALUES (%(userId)s,%(sessionId)s);""", {"userId": user_id, "sessionId": session_id}) ) return sessions.get_by_id2_pg(project_id=project_id, session_id=session_id, user_id=user_id, full_data=False, @@ -21,8 +19,7 @@ def remove_favorite_session(project_id, user_id, session_id): cur.execute( cur.mogrify(f"""\ DELETE FROM public.user_favorite_sessions - WHERE - user_id = %(userId)s + WHERE user_id = %(userId)s AND session_id = %(sessionId)s;""", {"userId": user_id, "sessionId": session_id}) ) @@ -30,19 +27,6 @@ def remove_favorite_session(project_id, user_id, session_id): include_fav_viewed=True) -def add_viewed_session(project_id, user_id, session_id): - with pg_client.PostgresClient() as cur: - cur.execute( - cur.mogrify("""\ - INSERT INTO public.user_viewed_sessions - (user_id, session_id) - VALUES - (%(userId)s,%(sessionId)s) - ON CONFLICT DO NOTHING;""", - {"userId": user_id, "sessionId": session_id}) - ) - - def favorite_session(project_id, user_id, session_id): if favorite_session_exists(user_id=user_id, session_id=session_id): return remove_favorite_session(project_id=project_id, user_id=user_id, session_id=session_id) @@ -50,16 +34,11 @@ def favorite_session(project_id, user_id, session_id): return add_favorite_session(project_id=project_id, user_id=user_id, session_id=session_id) -def view_session(project_id, user_id, session_id): - return add_viewed_session(project_id=project_id, user_id=user_id, session_id=session_id) - - def favorite_session_exists(user_id, session_id): with pg_client.PostgresClient() as cur: cur.execute( cur.mogrify( - """SELECT - session_id + """SELECT session_id FROM public.user_favorite_sessions WHERE user_id = %(userId)s @@ -68,3 +47,18 @@ def favorite_session_exists(user_id, session_id): ) r = cur.fetchone() return r is not None + + +def get_start_end_timestamp(project_id, user_id): + with pg_client.PostgresClient() as cur: + cur.execute( + cur.mogrify( + """SELECT max(start_ts) AS max_start_ts, min(start_ts) AS min_start_ts + FROM public.user_favorite_sessions INNER JOIN sessions USING(session_id) + WHERE + user_favorite_sessions.user_id = %(userId)s + AND project_id = %(project_id)s;""", + {"userId": user_id, "project_id": project_id}) + ) + r = cur.fetchone() + return (0, 0) if r is None else (r["max_start_ts"], r["min_start_ts"]) diff --git a/api/chalicelib/core/sessions_metas.py b/api/chalicelib/core/sessions_metas.py index 07aad2ee4..f7e98eb69 100644 --- a/api/chalicelib/core/sessions_metas.py +++ b/api/chalicelib/core/sessions_metas.py @@ -1,206 +1,66 @@ import schemas -from chalicelib.utils import pg_client, helper +from chalicelib.core import autocomplete from chalicelib.utils.event_filter_definition import SupportedFilter - -def get_key_values(project_id): - with pg_client.PostgresClient() as cur: - cur.execute( - cur.mogrify( - f"""\ - SELECT ARRAY_AGG(DISTINCT s.user_os - ORDER BY s.user_os) FILTER ( WHERE s.user_os IS NOT NULL AND s.platform='web') AS {schemas.FilterType.user_os}, - ARRAY_AGG(DISTINCT s.user_browser - ORDER BY s.user_browser) - FILTER ( WHERE s.user_browser IS NOT NULL AND s.platform='web') AS {schemas.FilterType.user_browser}, - ARRAY_AGG(DISTINCT s.user_device - ORDER BY s.user_device) - FILTER ( WHERE s.user_device IS NOT NULL AND s.user_device != '' AND s.platform='web') AS {schemas.FilterType.user_device}, - ARRAY_AGG(DISTINCT s.user_country - ORDER BY s.user_country) - FILTER ( WHERE s.user_country IS NOT NULL AND s.platform='web')::text[] AS {schemas.FilterType.user_country}, - ARRAY_AGG(DISTINCT s.user_id - ORDER BY s.user_id) FILTER ( WHERE s.user_id IS NOT NULL AND s.user_id != 'none' AND s.user_id != '' AND s.platform='web') AS {schemas.FilterType.user_id}, - ARRAY_AGG(DISTINCT s.user_anonymous_id - ORDER BY s.user_anonymous_id) FILTER ( WHERE s.user_anonymous_id IS NOT NULL AND s.user_anonymous_id != 'none' AND s.user_anonymous_id != '' AND s.platform='web') AS {schemas.FilterType.user_anonymous_id}, - ARRAY_AGG(DISTINCT s.rev_id - ORDER BY s.rev_id) FILTER ( WHERE s.rev_id IS NOT NULL AND s.platform='web') AS {schemas.FilterType.rev_id}, - ARRAY_AGG(DISTINCT p.referrer - ORDER BY p.referrer) - FILTER ( WHERE p.referrer != '' ) AS {schemas.FilterType.referrer}, - - ARRAY_AGG(DISTINCT s.utm_source - ORDER BY s.utm_source) FILTER ( WHERE s.utm_source IS NOT NULL AND s.utm_source != 'none' AND s.utm_source != '') AS {schemas.FilterType.utm_source}, - ARRAY_AGG(DISTINCT s.utm_medium - ORDER BY s.utm_medium) FILTER ( WHERE s.utm_medium IS NOT NULL AND s.utm_medium != 'none' AND s.utm_medium != '') AS {schemas.FilterType.utm_medium}, - ARRAY_AGG(DISTINCT s.utm_campaign - ORDER BY s.utm_campaign) FILTER ( WHERE s.utm_campaign IS NOT NULL AND s.utm_campaign != 'none' AND s.utm_campaign != '') AS {schemas.FilterType.utm_campaign}, - - ARRAY_AGG(DISTINCT s.user_os - ORDER BY s.user_os) FILTER ( WHERE s.user_os IS NOT NULL AND s.platform='ios' ) AS {schemas.FilterType.user_os_ios}, - ARRAY_AGG(DISTINCT s.user_device - ORDER BY s.user_device) - FILTER ( WHERE s.user_device IS NOT NULL AND s.user_device != '' AND s.platform='ios') AS {schemas.FilterType.user_device_ios}, - ARRAY_AGG(DISTINCT s.user_country - ORDER BY s.user_country) - FILTER ( WHERE s.user_country IS NOT NULL AND s.platform='ios')::text[] AS {schemas.FilterType.user_country_ios}, - ARRAY_AGG(DISTINCT s.user_id - ORDER BY s.user_id) FILTER ( WHERE s.user_id IS NOT NULL AND s.user_id != 'none' AND s.user_id != '' AND s.platform='ios') AS {schemas.FilterType.user_id_ios}, - ARRAY_AGG(DISTINCT s.user_anonymous_id - ORDER BY s.user_anonymous_id) FILTER ( WHERE s.user_anonymous_id IS NOT NULL AND s.user_anonymous_id != 'none' AND s.user_anonymous_id != '' AND s.platform='ios') AS {schemas.FilterType.user_anonymous_id_ios}, - ARRAY_AGG(DISTINCT s.rev_id - ORDER BY s.rev_id) FILTER ( WHERE s.rev_id IS NOT NULL AND s.platform='ios') AS {schemas.FilterType.rev_id_ios} - FROM public.sessions AS s - LEFT JOIN events.pages AS p USING (session_id) - WHERE s.project_id = %(site_id)s;""", - {"site_id": project_id} - ) - ) - - row = cur.fetchone() - for k in row.keys(): - if row[k] is None: - row[k] = [] - elif len(row[k]) > 500: - row[k] = row[k][:500] - return helper.dict_to_CAPITAL_keys(row) - - -def get_top_key_values(project_id): - with pg_client.PostgresClient() as cur: - cur.execute( - cur.mogrify( - f"""\ - SELECT {",".join([f"ARRAY((SELECT value FROM public.autocomplete WHERE project_id = %(site_id)s AND type='{k}' GROUP BY value ORDER BY COUNT(*) DESC LIMIT %(limit)s)) AS {k}" for k in SUPPORTED_TYPES.keys()])};""", - {"site_id": project_id, "limit": 5} - ) - ) - - row = cur.fetchone() - return helper.dict_to_CAPITAL_keys(row) - - -def __generic_query(typename, value_length=None): - if value_length is None or value_length > 2: - return f""" (SELECT DISTINCT value, type - FROM public.autocomplete - WHERE - project_id = %(project_id)s - AND type ='{typename}' - AND value ILIKE %(svalue)s - ORDER BY value - LIMIT 5) - UNION - (SELECT DISTINCT value, type - FROM public.autocomplete - WHERE - project_id = %(project_id)s - AND type ='{typename}' - AND value ILIKE %(value)s - ORDER BY value - LIMIT 5);""" - return f""" SELECT DISTINCT value, type - FROM public.autocomplete - WHERE - project_id = %(project_id)s - AND type ='{typename}' - AND value ILIKE %(svalue)s - ORDER BY value - LIMIT 10;""" - - -def __generic_autocomplete(typename): - def f(project_id, text): - with pg_client.PostgresClient() as cur: - query = cur.mogrify(__generic_query(typename, - value_length=len(text) \ - if SUPPORTED_TYPES[typename].change_by_length else None), - {"project_id": project_id, "value": helper.string_to_sql_like(text), - "svalue": helper.string_to_sql_like("^" + text)}) - - cur.execute(query) - rows = cur.fetchall() - return rows - - return f - - SUPPORTED_TYPES = { schemas.FilterType.user_os: SupportedFilter( - get=__generic_autocomplete(typename=schemas.FilterType.user_os), - query=__generic_query(typename=schemas.FilterType.user_os), - change_by_length=True), + get=autocomplete.__generic_autocomplete_metas(typename=schemas.FilterType.user_os), + query=autocomplete.__generic_autocomplete_metas(typename=schemas.FilterType.user_os)), schemas.FilterType.user_browser: SupportedFilter( - get=__generic_autocomplete(typename=schemas.FilterType.user_browser), - query=__generic_query(typename=schemas.FilterType.user_browser), - change_by_length=True), + get=autocomplete.__generic_autocomplete_metas(typename=schemas.FilterType.user_browser), + query=autocomplete.__generic_autocomplete_metas(typename=schemas.FilterType.user_browser)), schemas.FilterType.user_device: SupportedFilter( - get=__generic_autocomplete(typename=schemas.FilterType.user_device), - query=__generic_query(typename=schemas.FilterType.user_device), - change_by_length=True), + get=autocomplete.__generic_autocomplete_metas(typename=schemas.FilterType.user_device), + query=autocomplete.__generic_autocomplete_metas(typename=schemas.FilterType.user_device)), schemas.FilterType.user_country: SupportedFilter( - get=__generic_autocomplete(typename=schemas.FilterType.user_country), - query=__generic_query(typename=schemas.FilterType.user_country), - change_by_length=True), + get=autocomplete.__generic_autocomplete_metas(typename=schemas.FilterType.user_country), + query=autocomplete.__generic_autocomplete_metas(typename=schemas.FilterType.user_country)), schemas.FilterType.user_id: SupportedFilter( - get=__generic_autocomplete(typename=schemas.FilterType.user_id), - query=__generic_query(typename=schemas.FilterType.user_id), - change_by_length=True), + get=autocomplete.__generic_autocomplete_metas(typename=schemas.FilterType.user_id), + query=autocomplete.__generic_autocomplete_metas(typename=schemas.FilterType.user_id)), schemas.FilterType.user_anonymous_id: SupportedFilter( - get=__generic_autocomplete(typename=schemas.FilterType.user_anonymous_id), - query=__generic_query(typename=schemas.FilterType.user_anonymous_id), - change_by_length=True), + get=autocomplete.__generic_autocomplete_metas(typename=schemas.FilterType.user_anonymous_id), + query=autocomplete.__generic_autocomplete_metas(typename=schemas.FilterType.user_anonymous_id)), schemas.FilterType.rev_id: SupportedFilter( - get=__generic_autocomplete(typename=schemas.FilterType.rev_id), - query=__generic_query(typename=schemas.FilterType.rev_id), - change_by_length=True), + get=autocomplete.__generic_autocomplete_metas(typename=schemas.FilterType.rev_id), + query=autocomplete.__generic_autocomplete_metas(typename=schemas.FilterType.rev_id)), schemas.FilterType.referrer: SupportedFilter( - get=__generic_autocomplete(typename=schemas.FilterType.referrer), - query=__generic_query(typename=schemas.FilterType.referrer), - change_by_length=True), + get=autocomplete.__generic_autocomplete_metas(typename=schemas.FilterType.referrer), + query=autocomplete.__generic_autocomplete_metas(typename=schemas.FilterType.referrer)), schemas.FilterType.utm_campaign: SupportedFilter( - get=__generic_autocomplete(typename=schemas.FilterType.utm_campaign), - query=__generic_query(typename=schemas.FilterType.utm_campaign), - change_by_length=True), + get=autocomplete.__generic_autocomplete_metas(typename=schemas.FilterType.utm_campaign), + query=autocomplete.__generic_autocomplete_metas(typename=schemas.FilterType.utm_campaign)), schemas.FilterType.utm_medium: SupportedFilter( - get=__generic_autocomplete(typename=schemas.FilterType.utm_medium), - query=__generic_query(typename=schemas.FilterType.utm_medium), - change_by_length=True), + get=autocomplete.__generic_autocomplete_metas(typename=schemas.FilterType.utm_medium), + query=autocomplete.__generic_autocomplete_metas(typename=schemas.FilterType.utm_medium)), schemas.FilterType.utm_source: SupportedFilter( - get=__generic_autocomplete(typename=schemas.FilterType.utm_source), - query=__generic_query(typename=schemas.FilterType.utm_source), - change_by_length=True), + get=autocomplete.__generic_autocomplete_metas(typename=schemas.FilterType.utm_source), + query=autocomplete.__generic_autocomplete_metas(typename=schemas.FilterType.utm_source)), # IOS schemas.FilterType.user_os_ios: SupportedFilter( - get=__generic_autocomplete(typename=schemas.FilterType.user_os_ios), - query=__generic_query(typename=schemas.FilterType.user_os_ios), - change_by_length=True), + get=autocomplete.__generic_autocomplete_metas(typename=schemas.FilterType.user_os_ios), + query=autocomplete.__generic_autocomplete_metas(typename=schemas.FilterType.user_os_ios)), schemas.FilterType.user_device_ios: SupportedFilter( - get=__generic_autocomplete( + get=autocomplete.__generic_autocomplete_metas( typename=schemas.FilterType.user_device_ios), - query=__generic_query(typename=schemas.FilterType.user_device_ios), - change_by_length=True), + query=autocomplete.__generic_autocomplete_metas(typename=schemas.FilterType.user_device_ios)), schemas.FilterType.user_country_ios: SupportedFilter( - get=__generic_autocomplete(typename=schemas.FilterType.user_country_ios), - query=__generic_query(typename=schemas.FilterType.user_country_ios), - change_by_length=True), + get=autocomplete.__generic_autocomplete_metas(typename=schemas.FilterType.user_country_ios), + query=autocomplete.__generic_autocomplete_metas(typename=schemas.FilterType.user_country_ios)), schemas.FilterType.user_id_ios: SupportedFilter( - get=__generic_autocomplete(typename=schemas.FilterType.user_id_ios), - query=__generic_query(typename=schemas.FilterType.user_id_ios), - change_by_length=True), + get=autocomplete.__generic_autocomplete_metas(typename=schemas.FilterType.user_id_ios), + query=autocomplete.__generic_autocomplete_metas(typename=schemas.FilterType.user_id_ios)), schemas.FilterType.user_anonymous_id_ios: SupportedFilter( - get=__generic_autocomplete(typename=schemas.FilterType.user_anonymous_id_ios), - query=__generic_query(typename=schemas.FilterType.user_anonymous_id_ios), - change_by_length=True), + get=autocomplete.__generic_autocomplete_metas(typename=schemas.FilterType.user_anonymous_id_ios), + query=autocomplete.__generic_autocomplete_metas(typename=schemas.FilterType.user_anonymous_id_ios)), schemas.FilterType.rev_id_ios: SupportedFilter( - get=__generic_autocomplete(typename=schemas.FilterType.rev_id_ios), - query=__generic_query(typename=schemas.FilterType.rev_id_ios), - change_by_length=True), + get=autocomplete.__generic_autocomplete_metas(typename=schemas.FilterType.rev_id_ios), + query=autocomplete.__generic_autocomplete_metas(typename=schemas.FilterType.rev_id_ios)), } -def search(text, meta_type, project_id): +def search(text: str, meta_type: schemas.FilterType, project_id: int): rows = [] if meta_type not in list(SUPPORTED_TYPES.keys()): return {"errors": ["unsupported type"]} diff --git a/api/chalicelib/core/sessions_viewed.py b/api/chalicelib/core/sessions_viewed.py new file mode 100644 index 000000000..c9b2c9b46 --- /dev/null +++ b/api/chalicelib/core/sessions_viewed.py @@ -0,0 +1,11 @@ +from chalicelib.utils import pg_client + + +def view_session(project_id, user_id, session_id): + with pg_client.PostgresClient() as cur: + cur.execute( + cur.mogrify("""INSERT INTO public.user_viewed_sessions(user_id, session_id) + VALUES (%(userId)s,%(sessionId)s) + ON CONFLICT DO NOTHING;""", + {"userId": user_id, "sessionId": session_id}) + ) diff --git a/api/chalicelib/core/significance.py b/api/chalicelib/core/significance.py index 9bd0fa966..d6f46da70 100644 --- a/api/chalicelib/core/significance.py +++ b/api/chalicelib/core/significance.py @@ -559,8 +559,8 @@ def get_top_insights(filter_d, project_id): "dropDueToIssues": 0 }] - counts = sessions.search2_pg(data=schemas.SessionsSearchCountSchema.parse_obj(filter_d), project_id=project_id, - user_id=None, count_only=True) + counts = sessions.search_sessions(data=schemas.SessionsSearchCountSchema.parse_obj(filter_d), project_id=project_id, + user_id=None, count_only=True) output[0]["sessionsCount"] = counts["countSessions"] output[0]["usersCount"] = counts["countUsers"] return output, 0 diff --git a/api/chalicelib/core/signup.py b/api/chalicelib/core/signup.py index 146da7305..23c2c8744 100644 --- a/api/chalicelib/core/signup.py +++ b/api/chalicelib/core/signup.py @@ -45,7 +45,7 @@ def create_step1(data: schemas.UserSignupSchema): print("Verifying company's name validity") company_name = data.organizationName - if company_name is None or len(company_name) < 1 or not helper.is_alphanumeric_space(company_name): + if company_name is None or len(company_name) < 1: errors.append("invalid organization's name") print("Verifying project's name validity") diff --git a/api/chalicelib/core/users.py b/api/chalicelib/core/users.py index 1535534c8..0ea8ed594 100644 --- a/api/chalicelib/core/users.py +++ b/api/chalicelib/core/users.py @@ -168,7 +168,7 @@ def update(tenant_id, user_id, changes): {"user_id": user_id, **changes}) ) - return helper.dict_to_camel_case(cur.fetchone()) + return get(user_id=user_id, tenant_id=tenant_id) def create_member(tenant_id, user_id, data, background_tasks: BackgroundTasks): @@ -181,7 +181,7 @@ def create_member(tenant_id, user_id, data, background_tasks: BackgroundTasks): if user: return {"errors": ["user already exists"]} name = data.get("name", None) - if name is not None and not helper.is_alphabet_latin_space(name): + if name is not None and len(name) == 0: return {"errors": ["invalid user name"]} if name is None: name = data["email"] diff --git a/api/chalicelib/utils/event_filter_definition.py b/api/chalicelib/utils/event_filter_definition.py index b21d49b9c..93b1b9d5f 100644 --- a/api/chalicelib/utils/event_filter_definition.py +++ b/api/chalicelib/utils/event_filter_definition.py @@ -6,7 +6,6 @@ class Event: class SupportedFilter: - def __init__(self, get, query, change_by_length): + def __init__(self, get, query): self.get = get self.query = query - self.change_by_length = change_by_length diff --git a/api/chalicelib/utils/jira_client.py b/api/chalicelib/utils/jira_client.py index b1734660c..4306cfab2 100644 --- a/api/chalicelib/utils/jira_client.py +++ b/api/chalicelib/utils/jira_client.py @@ -18,7 +18,7 @@ class JiraManager: self._config = {"JIRA_PROJECT_ID": project_id, "JIRA_URL": url, "JIRA_USERNAME": username, "JIRA_PASSWORD": password} try: - self._jira = JIRA(url, basic_auth=(username, password), logging=True, max_retries=1) + self._jira = JIRA(url, basic_auth=(username, password), logging=True, max_retries=0, timeout=3) except Exception as e: print("!!! JIRA AUTH ERROR") print(e) diff --git a/api/env.default b/api/env.default index 7053e2b13..563514d1c 100644 --- a/api/env.default +++ b/api/env.default @@ -47,4 +47,5 @@ sessions_region=us-east-1 sourcemaps_bucket=sourcemaps sourcemaps_reader=http://127.0.0.1:9000/sourcemaps stage=default-foss -version_number=1.4.0 \ No newline at end of file +version_number=1.4.0 +FS_DIR=/mnt/efs \ No newline at end of file diff --git a/api/requirements-alerts.txt b/api/requirements-alerts.txt index 81198b0f3..fc141eb09 100644 --- a/api/requirements-alerts.txt +++ b/api/requirements-alerts.txt @@ -1,15 +1,15 @@ requests==2.28.1 urllib3==1.26.10 -boto3==1.24.26 +boto3==1.24.53 pyjwt==2.4.0 psycopg2-binary==2.9.3 -elasticsearch==8.3.1 -jira==3.3.0 +elasticsearch==8.3.3 +jira==3.3.1 -fastapi==0.78.0 +fastapi==0.80.0 uvicorn[standard]==0.18.2 python-decouple==3.6 -pydantic[email]==1.9.1 +pydantic[email]==1.9.2 apscheduler==3.9.1 \ No newline at end of file diff --git a/api/requirements.txt b/api/requirements.txt index 81198b0f3..fc141eb09 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -1,15 +1,15 @@ requests==2.28.1 urllib3==1.26.10 -boto3==1.24.26 +boto3==1.24.53 pyjwt==2.4.0 psycopg2-binary==2.9.3 -elasticsearch==8.3.1 -jira==3.3.0 +elasticsearch==8.3.3 +jira==3.3.1 -fastapi==0.78.0 +fastapi==0.80.0 uvicorn[standard]==0.18.2 python-decouple==3.6 -pydantic[email]==1.9.1 +pydantic[email]==1.9.2 apscheduler==3.9.1 \ No newline at end of file diff --git a/api/routers/core.py b/api/routers/core.py index d9a36493c..b3252e34a 100644 --- a/api/routers/core.py +++ b/api/routers/core.py @@ -1,18 +1,19 @@ -from typing import Union, Optional +from typing import Union from decouple import config from fastapi import Depends, Body, BackgroundTasks, HTTPException +from fastapi.responses import FileResponse from starlette import status import schemas from chalicelib.core import log_tool_rollbar, sourcemaps, events, sessions_assignments, projects, \ - sessions_metas, alerts, funnels, issues, integrations_manager, metadata, \ + alerts, funnels, issues, integrations_manager, metadata, \ log_tool_elasticsearch, log_tool_datadog, \ - log_tool_stackdriver, reset_password, sessions_favorite_viewed, \ + log_tool_stackdriver, reset_password, sessions_favorite, \ 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, \ - assist, heatmaps, mobile, signup, tenants, errors_favorite_viewed, boarding, notifications, webhook, users, \ - custom_metrics, saved_search + assist, heatmaps, mobile, signup, tenants, errors_viewed, boarding, notifications, webhook, users, \ + custom_metrics, saved_search, integrations_global, sessions_viewed, errors_favorite from chalicelib.core.collaboration_slack import Slack from chalicelib.utils import email_helper, helper, captcha from chalicelib.utils.TimeUTC import TimeUTC @@ -50,6 +51,14 @@ def login(data: schemas.UserLoginSchema = Body(...)): } +@app.post('/{projectId}/sessions/search', tags=["sessions"]) +@app.post('/{projectId}/sessions/search2', tags=["sessions"]) +def sessions_search(projectId: int, data: schemas.FlatSessionsSearchPayloadSchema = Body(...), + context: schemas.CurrentContext = Depends(OR_context)): + data = sessions.search_sessions(data=data, project_id=projectId, user_id=context.user_id) + return {'data': data} + + @app.get('/{projectId}/sessions/{sessionId}', tags=["sessions"]) @app.get('/{projectId}/sessions2/{sessionId}', tags=["sessions"]) def get_session2(projectId: int, sessionId: Union[int, str], background_tasks: BackgroundTasks, @@ -61,7 +70,7 @@ def get_session2(projectId: int, sessionId: Union[int, str], background_tasks: B if data is None: return {"errors": ["session not found"]} if data.get("inDB"): - background_tasks.add_task(sessions_favorite_viewed.view_session, project_id=projectId, user_id=context.user_id, + background_tasks.add_task(sessions_viewed.view_session, project_id=projectId, user_id=context.user_id, session_id=sessionId) return { 'data': data @@ -73,8 +82,8 @@ def get_session2(projectId: int, sessionId: Union[int, str], background_tasks: B def add_remove_favorite_session2(projectId: int, sessionId: int, context: schemas.CurrentContext = Depends(OR_context)): return { - "data": sessions_favorite_viewed.favorite_session(project_id=projectId, user_id=context.user_id, - session_id=sessionId)} + "data": sessions_favorite.favorite_session(project_id=projectId, user_id=context.user_id, + session_id=sessionId)} @app.get('/{projectId}/sessions/{sessionId}/assign', tags=["sessions"]) @@ -163,21 +172,12 @@ def events_search(projectId: int, q: str, return result -@app.post('/{projectId}/sessions/search2', tags=["sessions"]) -def sessions_search2(projectId: int, data: schemas.FlatSessionsSearchPayloadSchema = Body(...), - context: schemas.CurrentContext = Depends(OR_context)): - data = sessions.search2_pg(data=data, project_id=projectId, user_id=context.user_id) - return {'data': data} - - -@app.get('/{projectId}/sessions/filters', tags=["sessions"]) -def session_filter_values(projectId: int, context: schemas.CurrentContext = Depends(OR_context)): - return {'data': sessions_metas.get_key_values(projectId)} - - -@app.get('/{projectId}/sessions/filters/top', tags=["sessions"]) -def session_top_filter_values(projectId: int, context: schemas.CurrentContext = Depends(OR_context)): - return {'data': sessions_metas.get_top_key_values(projectId)} +@app.get('/{projectId}/integrations', tags=["integrations"]) +def get_integrations_status(projectId: int, context: schemas.CurrentContext = Depends(OR_context)): + data = integrations_global.get_global_integrations_status(tenant_id=context.tenant_id, + user_id=context.user_id, + project_id=projectId) + return {"data": data} @app.post('/{projectId}/integrations/{integration}/notify/{integrationId}/{source}/{sourceId}', tags=["integrations"]) @@ -432,29 +432,49 @@ def get_integration_status(context: schemas.CurrentContext = Depends(OR_context) return {"data": integration.get_obfuscated()} +@app.get('/integrations/jira', tags=["integrations"]) +def get_integration_status_jira(context: schemas.CurrentContext = Depends(OR_context)): + error, integration = integrations_manager.get_integration(tenant_id=context.tenant_id, + user_id=context.user_id, + tool=integration_jira_cloud.PROVIDER) + if error is not None and integration is None: + return error + return {"data": integration.get_obfuscated()} + + +@app.get('/integrations/github', tags=["integrations"]) +def get_integration_status_github(context: schemas.CurrentContext = Depends(OR_context)): + error, integration = integrations_manager.get_integration(tenant_id=context.tenant_id, + user_id=context.user_id, + tool=integration_github.PROVIDER) + if error is not None and integration is None: + return error + return {"data": integration.get_obfuscated()} + + @app.post('/integrations/jira', tags=["integrations"]) @app.put('/integrations/jira', tags=["integrations"]) -def add_edit_jira_cloud(data: schemas.JiraGithubSchema = Body(...), +def add_edit_jira_cloud(data: schemas.JiraSchema = Body(...), context: schemas.CurrentContext = Depends(OR_context)): + if not data.url.endswith('atlassian.net'): + return {"errors": ["url must be a valid JIRA URL (example.atlassian.net)"]} error, integration = integrations_manager.get_integration(tool=integration_jira_cloud.PROVIDER, tenant_id=context.tenant_id, user_id=context.user_id) if error is not None and integration is None: return error - data.provider = integration_jira_cloud.PROVIDER return {"data": integration.add_edit(data=data.dict())} @app.post('/integrations/github', tags=["integrations"]) @app.put('/integrations/github', tags=["integrations"]) -def add_edit_github(data: schemas.JiraGithubSchema = Body(...), +def add_edit_github(data: schemas.GithubSchema = Body(...), context: schemas.CurrentContext = Depends(OR_context)): error, integration = integrations_manager.get_integration(tool=integration_github.PROVIDER, tenant_id=context.tenant_id, user_id=context.user_id) if error is not None: return error - data.provider = integration_github.PROVIDER return {"data": integration.add_edit(data=data.dict())} @@ -471,7 +491,8 @@ def delete_default_issue_tracking_tool(context: schemas.CurrentContext = Depends def delete_jira_cloud(context: schemas.CurrentContext = Depends(OR_context)): error, integration = integrations_manager.get_integration(tool=integration_jira_cloud.PROVIDER, tenant_id=context.tenant_id, - user_id=context.user_id) + user_id=context.user_id, + for_delete=True) if error is not None: return error return {"data": integration.delete()} @@ -481,7 +502,8 @@ def delete_jira_cloud(context: schemas.CurrentContext = Depends(OR_context)): def delete_github(context: schemas.CurrentContext = Depends(OR_context)): error, integration = integrations_manager.get_integration(tool=integration_github.PROVIDER, tenant_id=context.tenant_id, - user_id=context.user_id) + user_id=context.user_id, + for_delete=True) if error is not None: return error return {"data": integration.delete()} @@ -882,11 +904,22 @@ def get_live_session(projectId: int, sessionId: str, background_tasks: Backgroun if data is None: return {"errors": ["session not found"]} if data.get("inDB"): - background_tasks.add_task(sessions_favorite_viewed.view_session, project_id=projectId, + background_tasks.add_task(sessions_viewed.view_session, project_id=projectId, user_id=context.user_id, session_id=sessionId) return {'data': data} +@app.get('/{projectId}/unprocessed/{sessionId}', tags=["assist"]) +@app.get('/{projectId}/assist/sessions/{sessionId}/replay', tags=["assist"]) +def get_live_session_replay_file(projectId: int, sessionId: str, + context: schemas.CurrentContext = Depends(OR_context)): + path = assist.get_raw_mob_by_id(project_id=projectId, session_id=sessionId) + if path is None: + return {"errors": ["Replay file not found"]} + + return FileResponse(path=path, media_type="application/octet-stream") + + @app.post('/{projectId}/heatmaps/url', tags=["heatmaps"]) def get_heatmaps_by_url(projectId: int, data: schemas.GetHeatmapPayloadSchema = Body(...), context: schemas.CurrentContext = Depends(OR_context)): @@ -957,7 +990,7 @@ def errors_get_details(projectId: int, errorId: str, background_tasks: Backgroun data = errors.get_details(project_id=projectId, user_id=context.user_id, error_id=errorId, **{"density24": density24, "density30": density30}) if data.get("data") is not None: - background_tasks.add_task(errors_favorite_viewed.viewed_error, project_id=projectId, user_id=context.user_id, + background_tasks.add_task(errors_viewed.viewed_error, project_id=projectId, user_id=context.user_id, error_id=errorId) return data @@ -986,7 +1019,7 @@ def errors_get_details_sourcemaps(projectId: int, errorId: str, def add_remove_favorite_error(projectId: int, errorId: str, action: str, startDate: int = TimeUTC.now(-7), endDate: int = TimeUTC.now(), context: schemas.CurrentContext = Depends(OR_context)): if action == "favorite": - return errors_favorite_viewed.favorite_error(project_id=projectId, user_id=context.user_id, error_id=errorId) + return errors_favorite.favorite_error(project_id=projectId, user_id=context.user_id, error_id=errorId) elif action == "sessions": start_date = startDate end_date = endDate diff --git a/api/routers/core_dynamic.py b/api/routers/core_dynamic.py index 32eb78e41..d37a56728 100644 --- a/api/routers/core_dynamic.py +++ b/api/routers/core_dynamic.py @@ -7,7 +7,7 @@ from starlette.responses import RedirectResponse import schemas from chalicelib.core import integrations_manager from chalicelib.core import sessions -from chalicelib.core import tenants, users, metadata, projects, license +from chalicelib.core import tenants, users, projects, license from chalicelib.core import webhook from chalicelib.core.collaboration_slack import Slack from chalicelib.utils import helper @@ -95,18 +95,6 @@ def edit_slack_integration(integrationId: int, data: schemas.EditSlackSchema = B changes={"name": data.name, "endpoint": data.url})} -# this endpoint supports both jira & github based on `provider` attribute -@app.post('/integrations/issues', tags=["integrations"]) -def add_edit_jira_cloud_github(data: schemas.JiraGithubSchema, - context: schemas.CurrentContext = Depends(OR_context)): - provider = data.provider.upper() - error, integration = integrations_manager.get_integration(tool=provider, tenant_id=context.tenant_id, - user_id=context.user_id) - if error is not None: - return error - return {"data": integration.add_edit(data=data.dict())} - - @app.post('/client/members', tags=["client"]) @app.put('/client/members', tags=["client"]) def add_member(background_tasks: BackgroundTasks, data: schemas.CreateMemberSchema = Body(...), diff --git a/api/schemas.py b/api/schemas.py index fdea7f439..f6dc8b34b 100644 --- a/api/schemas.py +++ b/api/schemas.py @@ -100,15 +100,17 @@ class NotificationsViewSchema(BaseModel): endTimestamp: Optional[int] = Field(default=None) -class JiraGithubSchema(BaseModel): - provider: str = Field(...) - username: str = Field(...) +class GithubSchema(BaseModel): token: str = Field(...) + + +class JiraSchema(GithubSchema): + username: str = Field(...) url: HttpUrl = Field(...) @validator('url') def transform_url(cls, v: HttpUrl): - return HttpUrl.build(scheme=v.scheme, host=v.host) + return HttpUrl.build(scheme=v.scheme.lower(), host=v.host.lower()) class CreateEditWebhookSchema(BaseModel): @@ -277,7 +279,7 @@ class _AlertMessageSchema(BaseModel): value: str = Field(...) -class AlertDetectionChangeType(str, Enum): +class AlertDetectionType(str, Enum): percent = "percent" change = "change" @@ -288,7 +290,6 @@ class _AlertOptionSchema(BaseModel): previousPeriod: Literal[15, 30, 60, 120, 240, 1440] = Field(15) lastNotification: Optional[int] = Field(None) renotifyInterval: Optional[int] = Field(720) - change: Optional[AlertDetectionChangeType] = Field(None) class AlertColumn(str, Enum): @@ -337,6 +338,7 @@ class AlertDetectionMethod(str, Enum): class AlertSchema(BaseModel): name: str = Field(...) detection_method: AlertDetectionMethod = Field(...) + change: Optional[AlertDetectionType] = Field(default=AlertDetectionType.change) description: Optional[str] = Field(None) options: _AlertOptionSchema = Field(...) query: _AlertQuerySchema = Field(...) @@ -354,11 +356,6 @@ class AlertSchema(BaseModel): def alert_validator(cls, values): if values.get("query") is not None and values["query"].left == AlertColumn.custom: assert values.get("series_id") is not None, "series_id should not be null for CUSTOM alert" - if values.get("detectionMethod") is not None \ - and values["detectionMethod"] == AlertDetectionMethod.change \ - and values.get("options") is not None: - assert values["options"].change is not None, \ - "options.change should not be null for detection method 'change'" return values class Config: @@ -552,13 +549,15 @@ class _SessionSearchEventRaw(__MixedSearchFilter): assert values.get("sourceOperator") is not None, \ "sourceOperator should not be null for PerformanceEventType" if values["type"] == PerformanceEventType.time_between_events: + assert values["sourceOperator"] != MathOperator._equal.value, \ + f"{MathOperator._equal} is not allowed for duration of {PerformanceEventType.time_between_events}" assert len(values.get("value", [])) == 2, \ f"must provide 2 Events as value for {PerformanceEventType.time_between_events}" assert isinstance(values["value"][0], _SessionSearchEventRaw) \ and isinstance(values["value"][1], _SessionSearchEventRaw), \ f"event should be of type _SessionSearchEventRaw for {PerformanceEventType.time_between_events}" assert len(values["source"]) > 0 and isinstance(values["source"][0], int), \ - f"source of type int if required for {PerformanceEventType.time_between_events}" + f"source of type int is required for {PerformanceEventType.time_between_events}" else: assert "source" in values, f"source is required for {values.get('type')}" assert isinstance(values["source"], list), f"source of type list is required for {values.get('type')}" @@ -734,7 +733,7 @@ class ErrorSort(str, Enum): sessions_count = 'sessions' -class SearchErrorsSchema(SessionsSearchPayloadSchema): +class SearchErrorsSchema(FlatSessionsSearchPayloadSchema): sort: ErrorSort = Field(default=ErrorSort.occurrence) density: Optional[int] = Field(7) status: Optional[ErrorStatus] = Field(default=ErrorStatus.all) @@ -766,7 +765,7 @@ class MobileSignPayloadSchema(BaseModel): keys: List[str] = Field(...) -class CustomMetricSeriesFilterSchema(FlatSessionsSearchPayloadSchema, SearchErrorsSchema): +class CustomMetricSeriesFilterSchema(SearchErrorsSchema): startDate: Optional[int] = Field(None) endDate: Optional[int] = Field(None) sort: Optional[str] = Field(None) @@ -1026,7 +1025,7 @@ class LiveFilterType(str, Enum): user_UUID = "USERUUID" tracker_version = "TRACKERVERSION" user_browser_version = "USERBROWSERVERSION" - user_device_type = "USERDEVICETYPE", + user_device_type = "USERDEVICETYPE" class LiveSessionSearchFilterSchema(BaseModel): @@ -1070,3 +1069,18 @@ class LiveSessionsSearchPayloadSchema(_PaginatedSchema): class Config: alias_generator = attribute_to_camel_case + + +class IntegrationType(str, Enum): + github = "GITHUB" + jira = "JIRA" + slack = "SLACK" + sentry = "SENTRY" + bugsnag = "BUGSNAG" + rollbar = "ROLLBAR" + elasticsearch = "ELASTICSEARCH" + datadog = "DATADOG" + sumologic = "SUMOLOGIC" + stackdriver = "STACKDRIVER" + cloudwatch = "CLOUDWATCH" + newrelic = "NEWRELIC" diff --git a/backend/Dockerfile b/backend/Dockerfile index e83ec1802..4e0064e9d 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -19,7 +19,6 @@ RUN CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -o service -tags musl openrep FROM alpine AS entrypoint -RUN apk upgrade busybox --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/main RUN apk add --no-cache ca-certificates RUN adduser -u 1001 openreplay -D @@ -51,14 +50,14 @@ ENV TZ=UTC \ ASSETS_SIZE_LIMIT=6291456 \ ASSETS_HEADERS="{ \"Cookie\": \"ABv=3;\" }" \ FS_CLEAN_HRS=72 \ - FILE_SPLIT_SIZE=500000 \ + FILE_SPLIT_SIZE=1000000 \ LOG_QUEUE_STATS_INTERVAL_SEC=60 \ DB_BATCH_QUEUE_LIMIT=20 \ DB_BATCH_SIZE_LIMIT=10000000 \ PARTITIONS_NUMBER=16 \ QUEUE_MESSAGE_SIZE_LIMIT=1048576 \ BEACON_SIZE_LIMIT=1000000 \ - USE_FAILOVER=false \ + USE_FAILOVER=true \ GROUP_STORAGE_FAILOVER=failover \ TOPIC_STORAGE_FAILOVER=storage-failover diff --git a/backend/build.sh b/backend/build.sh index ef57b0887..d2a919d9a 100755 --- a/backend/build.sh +++ b/backend/build.sh @@ -29,6 +29,8 @@ function build_service() { } function build_api(){ + cp -R ../backend ../_backend + cd ../_backend # Copy enterprise code [[ $1 == "ee" ]] && { cp -r ../ee/backend/* ./ @@ -43,6 +45,8 @@ function build_api(){ build_service $image echo "::set-output name=image::${DOCKER_REPO:-'local'}/$image:${git_sha1}" done + cd ../backend + rm -rf ../_backend echo "backend build completed" } diff --git a/backend/cmd/assets/file b/backend/cmd/assets/file new file mode 100644 index 000000000..f0018a2e8 --- /dev/null +++ b/backend/cmd/assets/file @@ -0,0 +1 @@ +GROUP_CACHE=from_file \ No newline at end of file diff --git a/backend/cmd/assets/main.go b/backend/cmd/assets/main.go index 86eb4865f..629224da7 100644 --- a/backend/cmd/assets/main.go +++ b/backend/cmd/assets/main.go @@ -3,7 +3,7 @@ package main import ( "context" "log" - "openreplay/backend/pkg/monitoring" + "openreplay/backend/pkg/queue/types" "os" "os/signal" "syscall" @@ -13,8 +13,8 @@ import ( "openreplay/backend/internal/assets/cacher" config "openreplay/backend/internal/config/assets" "openreplay/backend/pkg/messages" + "openreplay/backend/pkg/monitoring" "openreplay/backend/pkg/queue" - "openreplay/backend/pkg/queue/types" ) func main() { @@ -34,22 +34,25 @@ func main() { consumer := queue.NewMessageConsumer( cfg.GroupCache, []string{cfg.TopicCache}, - func(sessionID uint64, message messages.Message, e *types.Meta) { - switch msg := message.(type) { - case *messages.AssetCache: - cacher.CacheURL(sessionID, msg.URL) - totalAssets.Add(context.Background(), 1) - case *messages.ErrorEvent: - if msg.Source != "js_exception" { - return - } - sourceList, err := assets.ExtractJSExceptionSources(&msg.Payload) - if err != nil { - log.Printf("Error on source extraction: %v", err) - return - } - for _, source := range sourceList { - cacher.CacheJSFile(source) + func(sessionID uint64, iter messages.Iterator, meta *types.Meta) { + for iter.Next() { + if iter.Type() == messages.MsgAssetCache { + msg := iter.Message().Decode().(*messages.AssetCache) + cacher.CacheURL(sessionID, msg.URL) + totalAssets.Add(context.Background(), 1) + } else if iter.Type() == messages.MsgErrorEvent { + msg := iter.Message().Decode().(*messages.ErrorEvent) + if msg.Source != "js_exception" { + continue + } + sourceList, err := assets.ExtractJSExceptionSources(&msg.Payload) + if err != nil { + log.Printf("Error on source extraction: %v", err) + continue + } + for _, source := range sourceList { + cacher.CacheJSFile(source) + } } } }, diff --git a/backend/cmd/db/main.go b/backend/cmd/db/main.go index 1712b8a3f..2ea57b459 100644 --- a/backend/cmd/db/main.go +++ b/backend/cmd/db/main.go @@ -3,24 +3,23 @@ package main import ( "errors" "log" - "openreplay/backend/internal/config/db" - "openreplay/backend/internal/db/datasaver" - "openreplay/backend/pkg/handlers" - custom2 "openreplay/backend/pkg/handlers/custom" - "openreplay/backend/pkg/monitoring" - "openreplay/backend/pkg/sessions" - "time" - + "openreplay/backend/pkg/queue/types" "os" "os/signal" "syscall" + "time" + "openreplay/backend/internal/config/db" + "openreplay/backend/internal/db/datasaver" "openreplay/backend/pkg/db/cache" "openreplay/backend/pkg/db/postgres" + "openreplay/backend/pkg/handlers" + custom2 "openreplay/backend/pkg/handlers/custom" logger "openreplay/backend/pkg/log" "openreplay/backend/pkg/messages" + "openreplay/backend/pkg/monitoring" "openreplay/backend/pkg/queue" - "openreplay/backend/pkg/queue/types" + "openreplay/backend/pkg/sessions" ) func main() { @@ -46,54 +45,70 @@ func main() { // Create handler's aggregator builderMap := sessions.NewBuilderMap(handlersFabric) + keepMessage := func(tp int) bool { + return tp == messages.MsgMetadata || tp == messages.MsgIssueEvent || tp == messages.MsgSessionStart || tp == messages.MsgSessionEnd || tp == messages.MsgUserID || tp == messages.MsgUserAnonymousID || tp == messages.MsgCustomEvent || tp == messages.MsgClickEvent || tp == messages.MsgInputEvent || tp == messages.MsgPageEvent || tp == messages.MsgErrorEvent || tp == messages.MsgFetchEvent || tp == messages.MsgGraphQLEvent || tp == messages.MsgIntegrationEvent || tp == messages.MsgPerformanceTrackAggr || tp == messages.MsgResourceEvent || tp == messages.MsgLongTask || tp == messages.MsgJSException || tp == messages.MsgResourceTiming || tp == messages.MsgRawCustomEvent || tp == messages.MsgCustomIssue || tp == messages.MsgFetch || tp == messages.MsgGraphQL || tp == messages.MsgStateAction || tp == messages.MsgSetInputTarget || tp == messages.MsgSetInputValue || tp == messages.MsgCreateDocument || tp == messages.MsgMouseClick || tp == messages.MsgSetPageLocation || tp == messages.MsgPageLoadTiming || tp == messages.MsgPageRenderTiming + } + + var producer types.Producer = nil + if cfg.UseQuickwit { + producer = queue.NewProducer(cfg.MessageSizeLimit, true) + defer producer.Close(15000) + } + // Init modules - saver := datasaver.New(pg) + saver := datasaver.New(pg, producer) saver.InitStats() statsLogger := logger.NewQueueStats(cfg.LoggerTimeout) // Handler logic - handler := func(sessionID uint64, msg messages.Message, meta *types.Meta) { + handler := func(sessionID uint64, iter messages.Iterator, meta *types.Meta) { statsLogger.Collect(sessionID, meta) - // Just save session data into db without additional checks - if err := saver.InsertMessage(sessionID, msg); err != nil { - if !postgres.IsPkeyViolation(err) { - log.Printf("Message Insertion Error %v, SessionID: %v, Message: %v", err, sessionID, msg) + for iter.Next() { + if !keepMessage(iter.Type()) { + continue } - return - } + msg := iter.Message().Decode() - session, err := pg.GetSession(sessionID) - if session == nil { - if err != nil && !errors.Is(err, cache.NilSessionInCacheError) { - log.Printf("Error on session retrieving from cache: %v, SessionID: %v, Message: %v", err, sessionID, msg) - } - return - } - - // Save statistics to db - err = saver.InsertStats(session, msg) - if err != nil { - log.Printf("Stats Insertion Error %v; Session: %v, Message: %v", err, session, msg) - } - - // Handle heuristics and save to temporary queue in memory - builderMap.HandleMessage(sessionID, msg, msg.Meta().Index) - - // Process saved heuristics messages as usual messages above in the code - builderMap.IterateSessionReadyMessages(sessionID, func(msg messages.Message) { - // TODO: DRY code (carefully with the return statement logic) + // Just save session data into db without additional checks if err := saver.InsertMessage(sessionID, msg); err != nil { if !postgres.IsPkeyViolation(err) { - log.Printf("Message Insertion Error %v; Session: %v, Message %v", err, session, msg) + log.Printf("Message Insertion Error %v, SessionID: %v, Message: %v", err, sessionID, msg) } return } - if err := saver.InsertStats(session, msg); err != nil { - log.Printf("Stats Insertion Error %v; Session: %v, Message %v", err, session, msg) + session, err := pg.GetSession(sessionID) + if session == nil { + if err != nil && !errors.Is(err, cache.NilSessionInCacheError) { + log.Printf("Error on session retrieving from cache: %v, SessionID: %v, Message: %v", err, sessionID, msg) + } + return } - }) + + // Save statistics to db + err = saver.InsertStats(session, msg) + if err != nil { + log.Printf("Stats Insertion Error %v; Session: %v, Message: %v", err, session, msg) + } + + // Handle heuristics and save to temporary queue in memory + builderMap.HandleMessage(sessionID, msg, msg.Meta().Index) + + // Process saved heuristics messages as usual messages above in the code + builderMap.IterateSessionReadyMessages(sessionID, func(msg messages.Message) { + if err := saver.InsertMessage(sessionID, msg); err != nil { + if !postgres.IsPkeyViolation(err) { + log.Printf("Message Insertion Error %v; Session: %v, Message %v", err, session, msg) + } + return + } + + if err := saver.InsertStats(session, msg); err != nil { + log.Printf("Stats Insertion Error %v; Session: %v, Message %v", err, session, msg) + } + }) + } } // Init consumer diff --git a/backend/cmd/db/values.yaml b/backend/cmd/db/values.yaml new file mode 100644 index 000000000..2c0f0e7f3 --- /dev/null +++ b/backend/cmd/db/values.yaml @@ -0,0 +1,92 @@ +chalice: + env: + jwt_secret: SetARandomStringHere +clickhouse: + enabled: false +fromVersion: v1.6.0 +global: + domainName: openreplay.local + email: + emailFrom: OpenReplay + emailHost: "" + emailPassword: "" + emailPort: "587" + emailSslCert: "" + emailSslKey: "" + emailUseSsl: "false" + emailUseTls: "true" + emailUser: "" + enterpriseEditionLicense: "" + ingress: + controller: + config: + enable-real-ip: true + force-ssl-redirect: false + max-worker-connections: 0 + proxy-body-size: 10m + ssl-redirect: false + extraArgs: + default-ssl-certificate: app/openreplay-ssl + ingressClass: openreplay + ingressClassResource: + name: openreplay + service: + externalTrafficPolicy: Local + kafka: + kafkaHost: kafka.db.svc.cluster.local + kafkaPort: "9092" + kafkaUseSsl: "false" + zookeeperHost: databases-zookeeper.svc.cluster.local + zookeeperNonTLSPort: 2181 + postgresql: + postgresqlDatabase: postgres + postgresqlHost: postgresql.db.svc.cluster.local + postgresqlPassword: changeMePassword + postgresqlPort: "5432" + postgresqlUser: postgres + redis: + redisHost: redis-master.db.svc.cluster.local + redisPort: "6379" + s3: + accessKey: changeMeMinioAccessKey + assetsBucket: sessions-assets + endpoint: http://minio.db.svc.cluster.local:9000 + recordingsBucket: mobs + region: us-east-1 + secretKey: changeMeMinioPassword + sourcemapsBucket: sourcemaps +ingress-nginx: + controller: + config: + enable-real-ip: true + force-ssl-redirect: false + max-worker-connections: 0 + proxy-body-size: 10m + ssl-redirect: false + extraArgs: + default-ssl-certificate: app/openreplay-ssl + ingressClass: openreplay + ingressClassResource: + name: openreplay + service: + externalTrafficPolicy: Local +kafka: + kafkaHost: kafka.db.svc.cluster.local + kafkaPort: "9092" + kafkaUseSsl: "false" + zookeeperHost: databases-zookeeper.svc.cluster.local + zookeeperNonTLSPort: 2181 +minio: + global: + minio: + accessKey: changeMeMinioAccessKey + secretKey: changeMeMinioPassword +postgresql: + postgresqlDatabase: postgres + postgresqlHost: postgresql.db.svc.cluster.local + postgresqlPassword: changeMePassword + postgresqlPort: "5432" + postgresqlUser: postgres +redis: + redisHost: redis-master.db.svc.cluster.local + redisPort: "6379" diff --git a/backend/cmd/ender/main.go b/backend/cmd/ender/main.go index 1fd2f4e64..524af0894 100644 --- a/backend/cmd/ender/main.go +++ b/backend/cmd/ender/main.go @@ -2,25 +2,23 @@ package main import ( "log" + "openreplay/backend/pkg/queue/types" + "os" + "os/signal" + "syscall" + "time" + "openreplay/backend/internal/config/ender" "openreplay/backend/internal/sessionender" "openreplay/backend/pkg/db/cache" "openreplay/backend/pkg/db/postgres" - "openreplay/backend/pkg/monitoring" - "time" - - "os" - "os/signal" - "syscall" - "openreplay/backend/pkg/intervals" logger "openreplay/backend/pkg/log" "openreplay/backend/pkg/messages" + "openreplay/backend/pkg/monitoring" "openreplay/backend/pkg/queue" - "openreplay/backend/pkg/queue/types" ) -// func main() { metrics := monitoring.New("ender") @@ -45,18 +43,17 @@ func main() { []string{ cfg.TopicRawWeb, }, - func(sessionID uint64, msg messages.Message, meta *types.Meta) { - switch msg.(type) { - case *messages.SessionStart, *messages.SessionEnd: - // Skip several message types - return + func(sessionID uint64, iter messages.Iterator, meta *types.Meta) { + for iter.Next() { + if iter.Type() == messages.MsgSessionStart || iter.Type() == messages.MsgSessionEnd { + continue + } + if iter.Message().Meta().Timestamp == 0 { + log.Printf("ZERO TS, sessID: %d, msgType: %d", sessionID, iter.Type()) + } + statsLogger.Collect(sessionID, meta) + sessions.UpdateSession(sessionID, meta.Timestamp, iter.Message().Meta().Timestamp) } - // Test debug - if msg.Meta().Timestamp == 0 { - log.Printf("ZERO TS, sessID: %d, msgType: %d", sessionID, msg.TypeID()) - } - statsLogger.Collect(sessionID, meta) - sessions.UpdateSession(sessionID, meta.Timestamp, msg.Meta().Timestamp) }, false, cfg.MessageSizeLimit, diff --git a/backend/cmd/heuristics/main.go b/backend/cmd/heuristics/main.go index 2163c648b..977cbda9d 100644 --- a/backend/cmd/heuristics/main.go +++ b/backend/cmd/heuristics/main.go @@ -2,6 +2,12 @@ package main import ( "log" + "openreplay/backend/pkg/queue/types" + "os" + "os/signal" + "syscall" + "time" + "openreplay/backend/internal/config/heuristics" "openreplay/backend/pkg/handlers" web2 "openreplay/backend/pkg/handlers/web" @@ -9,12 +15,7 @@ import ( logger "openreplay/backend/pkg/log" "openreplay/backend/pkg/messages" "openreplay/backend/pkg/queue" - "openreplay/backend/pkg/queue/types" "openreplay/backend/pkg/sessions" - "os" - "os/signal" - "syscall" - "time" ) func main() { @@ -33,10 +34,6 @@ func main() { &web2.MemoryIssueDetector{}, &web2.NetworkIssueDetector{}, &web2.PerformanceAggregator{}, - // iOS's handlers - //&ios2.AppNotResponding{}, - //&ios2.ClickRageDetector{}, - //&ios2.PerformanceAggregator{}, // Other handlers (you can add your custom handlers here) //&custom.CustomHandler{}, } @@ -55,9 +52,11 @@ func main() { []string{ cfg.TopicRawWeb, }, - func(sessionID uint64, msg messages.Message, meta *types.Meta) { - statsLogger.Collect(sessionID, meta) - builderMap.HandleMessage(sessionID, msg, msg.Meta().Index) + func(sessionID uint64, iter messages.Iterator, meta *types.Meta) { + for iter.Next() { + statsLogger.Collect(sessionID, meta) + builderMap.HandleMessage(sessionID, iter.Message().Decode(), iter.Message().Meta().Index) + } }, false, cfg.MessageSizeLimit, diff --git a/backend/cmd/sink/main.go b/backend/cmd/sink/main.go index 9dcaa704d..d247d17b2 100644 --- a/backend/cmd/sink/main.go +++ b/backend/cmd/sink/main.go @@ -2,22 +2,20 @@ package main import ( "context" - "encoding/binary" "log" - "openreplay/backend/internal/sink/assetscache" - "openreplay/backend/internal/sink/oswriter" - "openreplay/backend/internal/storage" - "openreplay/backend/pkg/monitoring" - "time" - + "openreplay/backend/pkg/queue/types" "os" "os/signal" "syscall" + "time" "openreplay/backend/internal/config/sink" + "openreplay/backend/internal/sink/assetscache" + "openreplay/backend/internal/sink/oswriter" + "openreplay/backend/internal/storage" . "openreplay/backend/pkg/messages" + "openreplay/backend/pkg/monitoring" "openreplay/backend/pkg/queue" - "openreplay/backend/pkg/queue/types" "openreplay/backend/pkg/url/assets" ) @@ -58,51 +56,53 @@ func main() { []string{ cfg.TopicRawWeb, }, - func(sessionID uint64, message Message, _ *types.Meta) { - // Process assets - message = assetMessageHandler.ParseAssets(sessionID, message) + func(sessionID uint64, iter Iterator, meta *types.Meta) { + for iter.Next() { + // [METRICS] Increase the number of processed messages + totalMessages.Add(context.Background(), 1) - totalMessages.Add(context.Background(), 1) - - // Filter message - typeID := message.TypeID() - - // Send SessionEnd trigger to storage service - switch message.(type) { - case *SessionEnd: - if err := producer.Produce(cfg.TopicTrigger, sessionID, Encode(message)); err != nil { - log.Printf("can't send SessionEnd to trigger topic: %s; sessID: %d", err, sessionID) + // Send SessionEnd trigger to storage service + if iter.Type() == MsgSessionEnd { + if err := producer.Produce(cfg.TopicTrigger, sessionID, iter.Message().Encode()); err != nil { + log.Printf("can't send SessionEnd to trigger topic: %s; sessID: %d", err, sessionID) + } + continue } - return - } - if !IsReplayerType(typeID) { - return - } - // If message timestamp is empty, use at least ts of session start - ts := message.Meta().Timestamp - if ts == 0 { - log.Printf("zero ts; sessID: %d, msg: %+v", sessionID, message) - } else { - // Log ts of last processed message - counter.Update(sessionID, time.UnixMilli(ts)) - } + msg := iter.Message() + // Process assets + if iter.Type() == MsgSetNodeAttributeURLBased || + iter.Type() == MsgSetCSSDataURLBased || + iter.Type() == MsgCSSInsertRuleURLBased || + iter.Type() == MsgAdoptedSSReplaceURLBased || + iter.Type() == MsgAdoptedSSInsertRuleURLBased { + msg = assetMessageHandler.ParseAssets(sessionID, msg.Decode()) // TODO: filter type only once (use iterator inide or bring ParseAssets out here). + } - value := message.Encode() - var data []byte - if IsIOSType(typeID) { - data = value - } else { - data = make([]byte, len(value)+8) - copy(data[8:], value[:]) - binary.LittleEndian.PutUint64(data[0:], message.Meta().Index) - } - if err := writer.Write(sessionID, data); err != nil { - log.Printf("Writer error: %v\n", err) - } + // Filter message + if !IsReplayerType(msg.TypeID()) { + continue + } - messageSize.Record(context.Background(), float64(len(data))) - savedMessages.Add(context.Background(), 1) + // If message timestamp is empty, use at least ts of session start + ts := msg.Meta().Timestamp + if ts == 0 { + log.Printf("zero ts; sessID: %d, msgType: %d", sessionID, iter.Type()) + } else { + // Log ts of last processed message + counter.Update(sessionID, time.UnixMilli(ts)) + } + + // Write encoded message with index to session file + data := msg.EncodeWithIndex() + if err := writer.Write(sessionID, data); err != nil { + log.Printf("Writer error: %v\n", err) + } + + // [METRICS] Increase the number of written to the files messages and the message size + messageSize.Record(context.Background(), float64(len(data))) + savedMessages.Add(context.Background(), 1) + } }, false, cfg.MessageSizeLimit, diff --git a/backend/cmd/storage/main.go b/backend/cmd/storage/main.go index fcd3ec252..b3848c5de 100644 --- a/backend/cmd/storage/main.go +++ b/backend/cmd/storage/main.go @@ -2,8 +2,7 @@ package main import ( "log" - "openreplay/backend/pkg/failover" - "openreplay/backend/pkg/monitoring" + "openreplay/backend/pkg/queue/types" "os" "os/signal" "strconv" @@ -12,9 +11,10 @@ import ( config "openreplay/backend/internal/config/storage" "openreplay/backend/internal/storage" + "openreplay/backend/pkg/failover" "openreplay/backend/pkg/messages" + "openreplay/backend/pkg/monitoring" "openreplay/backend/pkg/queue" - "openreplay/backend/pkg/queue/types" s3storage "openreplay/backend/pkg/storage" ) @@ -43,14 +43,17 @@ func main() { []string{ cfg.TopicTrigger, }, - func(sessionID uint64, msg messages.Message, meta *types.Meta) { - switch m := msg.(type) { - case *messages.SessionEnd: - if err := srv.UploadKey(strconv.FormatUint(sessionID, 10), 5); err != nil { - sessionFinder.Find(sessionID, m.Timestamp) + func(sessionID uint64, iter messages.Iterator, meta *types.Meta) { + for iter.Next() { + if iter.Type() == messages.MsgSessionEnd { + msg := iter.Message().Decode().(*messages.SessionEnd) + if err := srv.UploadKey(strconv.FormatUint(sessionID, 10), 5); err != nil { + log.Printf("can't find session: %d", sessionID) + sessionFinder.Find(sessionID, msg.Timestamp) + } + // Log timestamp of last processed session + counter.Update(sessionID, time.UnixMilli(meta.Timestamp)) } - // Log timestamp of last processed session - counter.Update(sessionID, time.UnixMilli(meta.Timestamp)) } }, true, diff --git a/backend/go.mod b/backend/go.mod index caaf1bf83..5172d7ecb 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -15,6 +15,7 @@ require ( github.com/jackc/pgerrcode v0.0.0-20201024163028-a0d42d470451 github.com/jackc/pgx/v4 v4.6.0 github.com/klauspost/pgzip v1.2.5 + github.com/lib/pq v1.2.0 github.com/oschwald/maxminddb-golang v1.7.0 github.com/pkg/errors v0.9.1 github.com/sethvargo/go-envconfig v0.7.0 diff --git a/backend/internal/assets/cacher/cacher.go b/backend/internal/assets/cacher/cacher.go index fd7fe1e70..619c28c7c 100644 --- a/backend/internal/assets/cacher/cacher.go +++ b/backend/internal/assets/cacher/cacher.go @@ -73,7 +73,8 @@ func (c *cacher) cacheURL(requestURL string, sessionID uint64, depth byte, urlCo return } c.timeoutMap.add(cachePath) - if c.s3.Exists(cachePath) { + crTime := c.s3.GetCreationTime(cachePath) + if crTime != nil && crTime.After(time.Now().Add(-MAX_STORAGE_TIME)) { // recently uploaded return } diff --git a/backend/internal/config/db/config.go b/backend/internal/config/db/config.go index 03c7bc096..715d9ff8e 100644 --- a/backend/internal/config/db/config.go +++ b/backend/internal/config/db/config.go @@ -17,6 +17,7 @@ type Config struct { CommitBatchTimeout time.Duration `env:"COMMIT_BATCH_TIMEOUT,default=15s"` BatchQueueLimit int `env:"DB_BATCH_QUEUE_LIMIT,required"` BatchSizeLimit int `env:"DB_BATCH_SIZE_LIMIT,required"` + UseQuickwit bool `env:"QUICKWIT_ENABLED,default=false"` } func New() *Config { diff --git a/backend/internal/db/datasaver/fts.go b/backend/internal/db/datasaver/fts.go new file mode 100644 index 000000000..c0250c4d2 --- /dev/null +++ b/backend/internal/db/datasaver/fts.go @@ -0,0 +1,123 @@ +package datasaver + +import ( + "encoding/json" + "log" + "openreplay/backend/pkg/messages" +) + +type FetchEventFTS struct { + Method string `json:"method"` + URL string `json:"url"` + Request string `json:"request"` + Response string `json:"response"` + Status uint64 `json:"status"` + Timestamp uint64 `json:"timestamp"` + Duration uint64 `json:"duration"` +} + +type PageEventFTS struct { + MessageID uint64 `json:"message_id"` + Timestamp uint64 `json:"timestamp"` + URL string `json:"url"` + Referrer string `json:"referrer"` + Loaded bool `json:"loaded"` + RequestStart uint64 `json:"request_start"` + ResponseStart uint64 `json:"response_start"` + ResponseEnd uint64 `json:"response_end"` + DomContentLoadedEventStart uint64 `json:"dom_content_loaded_event_start"` + DomContentLoadedEventEnd uint64 `json:"dom_content_loaded_event_end"` + LoadEventStart uint64 `json:"load_event_start"` + LoadEventEnd uint64 `json:"load_event_end"` + FirstPaint uint64 `json:"first_paint"` + FirstContentfulPaint uint64 `json:"first_contentful_paint"` + SpeedIndex uint64 `json:"speed_index"` + VisuallyComplete uint64 `json:"visually_complete"` + TimeToInteractive uint64 `json:"time_to_interactive"` +} + +type GraphQLEventFTS struct { + OperationKind string `json:"operation_kind"` + OperationName string `json:"operation_name"` + Variables string `json:"variables"` + Response string `json:"response"` +} + +func (s *Saver) sendToFTS(msg messages.Message, sessionID uint64) { + // Skip, if FTS is disabled + if s.producer == nil { + return + } + + var ( + event []byte + err error + ) + + switch m := msg.(type) { + // Common + case *messages.Fetch: + event, err = json.Marshal(FetchEventFTS{ + Method: m.Method, + URL: m.URL, + Request: m.Request, + Response: m.Response, + Status: m.Status, + Timestamp: m.Timestamp, + Duration: m.Duration, + }) + case *messages.FetchEvent: + event, err = json.Marshal(FetchEventFTS{ + Method: m.Method, + URL: m.URL, + Request: m.Request, + Response: m.Response, + Status: m.Status, + Timestamp: m.Timestamp, + Duration: m.Duration, + }) + case *messages.PageEvent: + event, err = json.Marshal(PageEventFTS{ + MessageID: m.MessageID, + Timestamp: m.Timestamp, + URL: m.URL, + Referrer: m.Referrer, + Loaded: m.Loaded, + RequestStart: m.RequestStart, + ResponseStart: m.ResponseStart, + ResponseEnd: m.ResponseEnd, + DomContentLoadedEventStart: m.DomContentLoadedEventStart, + DomContentLoadedEventEnd: m.DomContentLoadedEventEnd, + LoadEventStart: m.LoadEventStart, + LoadEventEnd: m.LoadEventEnd, + FirstPaint: m.FirstPaint, + FirstContentfulPaint: m.FirstContentfulPaint, + SpeedIndex: m.SpeedIndex, + VisuallyComplete: m.VisuallyComplete, + TimeToInteractive: m.TimeToInteractive, + }) + case *messages.GraphQL: + event, err = json.Marshal(GraphQLEventFTS{ + OperationKind: m.OperationKind, + OperationName: m.OperationName, + Variables: m.Variables, + Response: m.Response, + }) + case *messages.GraphQLEvent: + event, err = json.Marshal(GraphQLEventFTS{ + OperationKind: m.OperationKind, + OperationName: m.OperationName, + Variables: m.Variables, + Response: m.Response, + }) + } + if err != nil { + log.Printf("can't marshal json for quickwit: %s", err) + } else { + if len(event) > 0 { + if err := s.producer.Produce("quickwit", sessionID, event); err != nil { + log.Printf("can't send event to quickwit: %s", err) + } + } + } +} diff --git a/backend/internal/db/datasaver/messages.go b/backend/internal/db/datasaver/messages.go index 4197ffb77..702c2f210 100644 --- a/backend/internal/db/datasaver/messages.go +++ b/backend/internal/db/datasaver/messages.go @@ -35,12 +35,15 @@ func (mi *Saver) InsertMessage(sessionID uint64, msg Message) error { // Unique Web messages case *PageEvent: + mi.sendToFTS(msg, sessionID) return mi.pg.InsertWebPageEvent(sessionID, m) case *ErrorEvent: return mi.pg.InsertWebErrorEvent(sessionID, m) case *FetchEvent: + mi.sendToFTS(msg, sessionID) return mi.pg.InsertWebFetchEvent(sessionID, m) case *GraphQLEvent: + mi.sendToFTS(msg, sessionID) return mi.pg.InsertWebGraphQLEvent(sessionID, m) case *IntegrationEvent: return mi.pg.InsertWebErrorEvent(sessionID, &ErrorEvent{ diff --git a/backend/internal/db/datasaver/saver.go b/backend/internal/db/datasaver/saver.go index 4cd742718..d41756a4d 100644 --- a/backend/internal/db/datasaver/saver.go +++ b/backend/internal/db/datasaver/saver.go @@ -1,11 +1,15 @@ package datasaver -import "openreplay/backend/pkg/db/cache" +import ( + "openreplay/backend/pkg/db/cache" + "openreplay/backend/pkg/queue/types" +) type Saver struct { - pg *cache.PGCache + pg *cache.PGCache + producer types.Producer } -func New(pg *cache.PGCache) *Saver { - return &Saver{pg: pg} +func New(pg *cache.PGCache, producer types.Producer) *Saver { + return &Saver{pg: pg, producer: producer} } diff --git a/backend/internal/http/router/handlers-web.go b/backend/internal/http/router/handlers-web.go index 9b0bc1322..04728de4e 100644 --- a/backend/internal/http/router/handlers-web.go +++ b/backend/internal/http/router/handlers-web.go @@ -9,6 +9,7 @@ import ( "math/rand" "net/http" "openreplay/backend/internal/http/uuid" + "openreplay/backend/pkg/flakeid" "strconv" "time" @@ -133,7 +134,9 @@ func (e *Router) startSessionHandlerWeb(w http.ResponseWriter, r *http.Request) Token: e.services.Tokenizer.Compose(*tokenData), UserUUID: userUUID, SessionID: strconv.FormatUint(tokenData.ID, 10), + ProjectID: strconv.FormatUint(uint64(p.ProjectID), 10), BeaconSizeLimit: e.cfg.BeaconSizeLimit, + StartTimestamp: int64(flakeid.ExtractTimestamp(tokenData.ID)), }) } diff --git a/backend/internal/http/router/model.go b/backend/internal/http/router/model.go index b39c49688..42a500c06 100644 --- a/backend/internal/http/router/model.go +++ b/backend/internal/http/router/model.go @@ -16,10 +16,12 @@ type StartSessionRequest struct { type StartSessionResponse struct { Timestamp int64 `json:"timestamp"` + StartTimestamp int64 `json:"startTimestamp"` Delay int64 `json:"delay"` Token string `json:"token"` UserUUID string `json:"userUUID"` SessionID string `json:"sessionID"` + ProjectID string `json:"projectID"` BeaconSizeLimit int64 `json:"beaconSizeLimit"` } diff --git a/backend/internal/sink/assetscache/assets.go b/backend/internal/sink/assetscache/assets.go index 7fc9f8257..8218af4e6 100644 --- a/backend/internal/sink/assetscache/assets.go +++ b/backend/internal/sink/assetscache/assets.go @@ -57,6 +57,21 @@ func (e *AssetsCache) ParseAssets(sessID uint64, msg messages.Message) messages. } newMsg.SetMeta(msg.Meta()) return newMsg + case *messages.AdoptedSSReplaceURLBased: + newMsg := &messages.AdoptedSSReplace{ + SheetID: m.SheetID, + Text: e.handleCSS(sessID, m.BaseURL, m.Text), + } + newMsg.SetMeta(msg.Meta()) + return newMsg + case *messages.AdoptedSSInsertRuleURLBased: + newMsg := &messages.AdoptedSSInsertRule{ + SheetID: m.SheetID, + Index: m.Index, + Rule: e.handleCSS(sessID, m.BaseURL, m.Rule), + } + newMsg.SetMeta(msg.Meta()) + return newMsg } return msg } diff --git a/backend/internal/sink/oswriter/oswriter.go b/backend/internal/sink/oswriter/oswriter.go index 839e61eba..4feb3e2aa 100644 --- a/backend/internal/sink/oswriter/oswriter.go +++ b/backend/internal/sink/oswriter/oswriter.go @@ -71,6 +71,7 @@ func (w *Writer) Write(key uint64, data []byte) error { if err != nil { return err } + // TODO: add check for the number of recorded bytes to file _, err = file.Write(data) return err } diff --git a/backend/pkg/db/cache/messages-web.go b/backend/pkg/db/cache/messages-web.go index 0a864e6d3..87098552b 100644 --- a/backend/pkg/db/cache/messages-web.go +++ b/backend/pkg/db/cache/messages-web.go @@ -83,6 +83,14 @@ func (c *PGCache) InsertWebErrorEvent(sessionID uint64, e *ErrorEvent) error { return nil } +func (c *PGCache) InsertSessionReferrer(sessionID uint64, referrer string) error { + _, err := c.GetSession(sessionID) + if err != nil { + return err + } + return c.Conn.InsertSessionReferrer(sessionID, referrer) +} + func (c *PGCache) InsertWebFetchEvent(sessionID uint64, e *FetchEvent) error { session, err := c.GetSession(sessionID) if err != nil { diff --git a/backend/pkg/db/cache/session.go b/backend/pkg/db/cache/session.go index 4ba56ff2a..89b8f89f8 100644 --- a/backend/pkg/db/cache/session.go +++ b/backend/pkg/db/cache/session.go @@ -3,7 +3,6 @@ package cache import ( "errors" "github.com/jackc/pgx/v4" - . "openreplay/backend/pkg/db/types" ) diff --git a/backend/pkg/db/postgres/connector.go b/backend/pkg/db/postgres/connector.go index 1cc537982..63f42e25b 100644 --- a/backend/pkg/db/postgres/connector.go +++ b/backend/pkg/db/postgres/connector.go @@ -5,6 +5,7 @@ import ( "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/metric/instrument/syncfloat64" "log" + "openreplay/backend/pkg/db/types" "openreplay/backend/pkg/monitoring" "strings" "time" @@ -13,6 +14,10 @@ import ( "github.com/jackc/pgx/v4/pgxpool" ) +type CH interface { + InsertAutocomplete(session *types.Session, msgType, msgValue string) error +} + type batchItem struct { query string arguments []interface{} @@ -37,6 +42,11 @@ type Conn struct { batchSizeLines syncfloat64.Histogram sqlRequestTime syncfloat64.Histogram sqlRequestCounter syncfloat64.Counter + chConn CH +} + +func (conn *Conn) SetClickHouse(ch CH) { + conn.chConn = ch } func NewConn(url string, queueLimit, sizeLimit int, metrics *monitoring.Metrics) *Conn { @@ -152,6 +162,13 @@ func (conn *Conn) insertAutocompleteValue(sessionID uint64, projectID uint32, tp if err := conn.autocompletes.Append(value, tp, projectID); err != nil { log.Printf("autocomplete bulk err: %s", err) } + if conn.chConn == nil { + return + } + // Send autocomplete data to clickhouse + if err := conn.chConn.InsertAutocomplete(&types.Session{SessionID: sessionID, ProjectID: projectID}, tp, value); err != nil { + log.Printf("click house autocomplete err: %s", err) + } } func (conn *Conn) batchQueue(sessionID uint64, sql string, args ...interface{}) { diff --git a/backend/pkg/db/postgres/messages-common.go b/backend/pkg/db/postgres/messages-common.go index 2925acde3..dbdedb02d 100644 --- a/backend/pkg/db/postgres/messages-common.go +++ b/backend/pkg/db/postgres/messages-common.go @@ -83,7 +83,6 @@ func (conn *Conn) InsertSessionEnd(sessionID uint64, timestamp uint64) (uint64, } func (conn *Conn) HandleSessionEnd(sessionID uint64) error { - // TODO: search acceleration? sqlRequest := ` UPDATE sessions SET issue_types=(SELECT @@ -96,11 +95,7 @@ func (conn *Conn) HandleSessionEnd(sessionID uint64) error { INNER JOIN issues AS ps USING (issue_id) WHERE session_id = $1) WHERE session_id = $1` - conn.batchQueue(sessionID, sqlRequest, sessionID) - - // Record approximate message size - conn.updateBatchSize(sessionID, len(sqlRequest)+8) - return nil + return conn.c.Exec(sqlRequest, sessionID) } func (conn *Conn) InsertRequest(sessionID uint64, timestamp uint64, index uint64, url string, duration uint64, success bool) error { diff --git a/backend/pkg/db/postgres/messages-web.go b/backend/pkg/db/postgres/messages-web.go index c55344509..a130be2cc 100644 --- a/backend/pkg/db/postgres/messages-web.go +++ b/backend/pkg/db/postgres/messages-web.go @@ -40,7 +40,6 @@ func (conn *Conn) InsertWebUserAnonymousID(sessionID uint64, projectID uint32, u return err } -// TODO: fix column "dom_content_loaded_event_end" of relation "pages" func (conn *Conn) InsertWebPageEvent(sessionID uint64, projectID uint32, e *PageEvent) error { host, path, query, err := url.GetURLParts(e.URL) if err != nil { @@ -79,6 +78,9 @@ func (conn *Conn) InsertWebClickEvent(sessionID uint64, projectID uint32, e *Cli } func (conn *Conn) InsertWebInputEvent(sessionID uint64, projectID uint32, e *InputEvent) error { + if e.Label == "" { + return nil + } value := &e.Value if e.ValueMasked { value = nil @@ -185,3 +187,15 @@ func (conn *Conn) InsertWebGraphQLEvent(sessionID uint64, projectID uint32, save conn.insertAutocompleteValue(sessionID, projectID, "GRAPHQL", e.OperationName) return nil } + +func (conn *Conn) InsertSessionReferrer(sessionID uint64, referrer string) error { + log.Printf("insert referrer, sessID: %d, referrer: %s", sessionID, referrer) + if referrer == "" { + return nil + } + return conn.c.Exec(` + UPDATE sessions + SET referrer = $1, base_referrer = $2 + WHERE session_id = $3 AND referrer IS NULL`, + referrer, url.DiscardURLQuery(referrer), sessionID) +} diff --git a/backend/pkg/db/postgres/session-updates.go b/backend/pkg/db/postgres/session-updates.go index 14260c2c6..47e374355 100644 --- a/backend/pkg/db/postgres/session-updates.go +++ b/backend/pkg/db/postgres/session-updates.go @@ -1,7 +1,7 @@ package postgres // Mechanism of combination several session updates into one -const sessionUpdateReq = `UPDATE sessions SET (pages_count, events_count) = (pages_count + $1, events_count + $2) WHERE session_id = $3` +const sessionUpdateReq = `UPDATE sessions SET pages_count = pages_count + $1, events_count = events_count + $2 WHERE session_id = $3` type sessionUpdates struct { sessionID uint64 diff --git a/backend/pkg/db/postgres/session.go b/backend/pkg/db/postgres/session.go index 9735cdc1a..08145b567 100644 --- a/backend/pkg/db/postgres/session.go +++ b/backend/pkg/db/postgres/session.go @@ -1,17 +1,24 @@ package postgres -import . "openreplay/backend/pkg/db/types" +import ( + "github.com/jackc/pgtype" + "log" + . "openreplay/backend/pkg/db/types" +) func (conn *Conn) GetSession(sessionID uint64) (*Session, error) { s := &Session{SessionID: sessionID} var revID, userOSVersion *string + var issueTypes pgtype.EnumArray if err := conn.c.QueryRow(` SELECT platform, duration, project_id, start_ts, user_uuid, user_os, user_os_version, user_device, user_device_type, user_country, rev_id, tracker_version, - user_id, user_anonymous_id, + user_id, user_anonymous_id, referrer, + pages_count, events_count, errors_count, issue_types, + user_browser, user_browser_version, issue_score, metadata_1, metadata_2, metadata_3, metadata_4, metadata_5, metadata_6, metadata_7, metadata_8, metadata_9, metadata_10 FROM sessions @@ -23,7 +30,9 @@ func (conn *Conn) GetSession(sessionID uint64) (*Session, error) { &s.UserUUID, &s.UserOS, &userOSVersion, &s.UserDevice, &s.UserDeviceType, &s.UserCountry, &revID, &s.TrackerVersion, - &s.UserID, &s.UserAnonymousID, + &s.UserID, &s.UserAnonymousID, &s.Referrer, + &s.PagesCount, &s.EventsCount, &s.ErrorsCount, &issueTypes, + &s.UserBrowser, &s.UserBrowserVersion, &s.IssueScore, &s.Metadata1, &s.Metadata2, &s.Metadata3, &s.Metadata4, &s.Metadata5, &s.Metadata6, &s.Metadata7, &s.Metadata8, &s.Metadata9, &s.Metadata10); err != nil { return nil, err @@ -34,5 +43,8 @@ func (conn *Conn) GetSession(sessionID uint64) (*Session, error) { if revID != nil { s.RevID = *revID } + if err := issueTypes.AssignTo(&s.IssueTypes); err != nil { + log.Printf("can't scan IssueTypes, err: %s", err) + } return s, nil } diff --git a/backend/pkg/db/types/session.go b/backend/pkg/db/types/session.go index 4cf8dd1ea..53fef410c 100644 --- a/backend/pkg/db/types/session.go +++ b/backend/pkg/db/types/session.go @@ -11,11 +11,14 @@ type Session struct { UserOSVersion string UserDevice string UserCountry string + Referrer *string Duration *uint64 PagesCount int EventsCount int ErrorsCount int + IssueTypes []string + IssueScore int UserID *string // pointer?? UserAnonymousID *string diff --git a/backend/pkg/messages/batch.go b/backend/pkg/messages/batch.go index 53d9dd8dd..955d0cfc0 100644 --- a/backend/pkg/messages/batch.go +++ b/backend/pkg/messages/batch.go @@ -1,67 +1,161 @@ package messages import ( - "fmt" - "github.com/pkg/errors" + "bytes" "io" + "log" "strings" ) -func ReadBatchReader(reader io.Reader, messageHandler func(Message)) error { - var index uint64 - var timestamp int64 +type Iterator interface { + Next() bool // Return true if we have next message + Type() int // Return type of the next message + Message() Message // Return raw or decoded message +} - for { - msg, err := ReadMessage(reader) +type iteratorImpl struct { + data *bytes.Reader + index uint64 + timestamp int64 + version uint64 + msgType uint64 + msgSize uint64 + canSkip bool + msg Message + url string +} + +func NewIterator(data []byte) Iterator { + return &iteratorImpl{ + data: bytes.NewReader(data), + } +} + +func (i *iteratorImpl) Next() bool { + if i.canSkip { + if _, err := i.data.Seek(int64(i.msgSize), io.SeekCurrent); err != nil { + log.Printf("seek err: %s", err) + return false + } + } + i.canSkip = false + + var err error + i.msgType, err = ReadUint(i.data) + if err != nil { if err == io.EOF { - return nil + return false + } + log.Printf("can't read message type: %s", err) + return false + } + + if i.version > 0 && messageHasSize(i.msgType) { + // Read message size if it is a new protocol version + i.msgSize, err = ReadSize(i.data) + if err != nil { + log.Printf("can't read message size: %s", err) + return false + } + i.msg = &RawMessage{ + tp: i.msgType, + size: i.msgSize, + meta: &message{}, + reader: i.data, + skipped: &i.canSkip, + } + i.canSkip = true + } else { + i.msg, err = ReadMessage(i.msgType, i.data) + if err == io.EOF { + return false } else if err != nil { if strings.HasPrefix(err.Error(), "Unknown message code:") { code := strings.TrimPrefix(err.Error(), "Unknown message code: ") - msg, err = DecodeExtraMessage(code, reader) + i.msg, err = DecodeExtraMessage(code, i.data) if err != nil { - return fmt.Errorf("can't decode msg: %s", err) + log.Printf("can't decode msg: %s", err) + return false } } else { - return errors.Wrapf(err, "Batch Message decoding error on message with index %v", index) + log.Printf("Batch Message decoding error on message with index %v, err: %s", i.index, err) + return false } } - msg = transformDeprecated(msg) - - isBatchMeta := false - switch m := msg.(type) { - case *BatchMeta: // Is not required to be present in batch since IOS doesn't have it (though we might change it) - if index != 0 { // Might be several 0-0 BatchMeta in a row without a error though - return errors.New("Batch Meta found at the end of the batch") - } - index = m.PageNo<<32 + m.FirstIndex // 2^32 is the maximum count of messages per page (ha-ha) - timestamp = m.Timestamp - isBatchMeta = true - // continue readLoop - case *IOSBatchMeta: - if index != 0 { // Might be several 0-0 BatchMeta in a row without a error though - return errors.New("Batch Meta found at the end of the batch") - } - index = m.FirstIndex - timestamp = int64(m.Timestamp) - isBatchMeta = true - // continue readLoop - case *Timestamp: - timestamp = int64(m.Timestamp) // TODO(?): replace timestamp type to int64 everywhere (including encoding part in tracker) - // No skipping here for making it easy to encode back the same sequence of message - // continue readLoop - case *SessionStart: - timestamp = int64(m.Timestamp) - case *SessionEnd: - timestamp = int64(m.Timestamp) - } - msg.Meta().Index = index - msg.Meta().Timestamp = timestamp - - messageHandler(msg) - if !isBatchMeta { // Without that indexes will be unique anyway, though shifted by 1 because BatchMeta is not counted in tracker - index++ - } + i.msg = transformDeprecated(i.msg) } - return errors.New("Error of the codeflow. (Should return on EOF)") + + // Process meta information + isBatchMeta := false + switch i.msgType { + case MsgBatchMetadata: + if i.index != 0 { // Might be several 0-0 BatchMeta in a row without an error though + log.Printf("Batch Meta found at the end of the batch") + return false + } + m := i.msg.Decode().(*BatchMetadata) + i.index = m.PageNo<<32 + m.FirstIndex // 2^32 is the maximum count of messages per page (ha-ha) + i.timestamp = m.Timestamp + i.version = m.Version + i.url = m.Url + isBatchMeta = true + if i.version > 1 { + log.Printf("incorrect batch version, skip current batch") + return false + } + case MsgBatchMeta: // Is not required to be present in batch since IOS doesn't have it (though we might change it) + if i.index != 0 { // Might be several 0-0 BatchMeta in a row without an error though + log.Printf("Batch Meta found at the end of the batch") + return false + } + m := i.msg.Decode().(*BatchMeta) + i.index = m.PageNo<<32 + m.FirstIndex // 2^32 is the maximum count of messages per page (ha-ha) + i.timestamp = m.Timestamp + isBatchMeta = true + // continue readLoop + case MsgIOSBatchMeta: + if i.index != 0 { // Might be several 0-0 BatchMeta in a row without an error though + log.Printf("Batch Meta found at the end of the batch") + return false + } + m := i.msg.Decode().(*IOSBatchMeta) + i.index = m.FirstIndex + i.timestamp = int64(m.Timestamp) + isBatchMeta = true + // continue readLoop + case MsgTimestamp: + m := i.msg.Decode().(*Timestamp) + i.timestamp = int64(m.Timestamp) + // No skipping here for making it easy to encode back the same sequence of message + // continue readLoop + case MsgSessionStart: + m := i.msg.Decode().(*SessionStart) + i.timestamp = int64(m.Timestamp) + case MsgSessionEnd: + m := i.msg.Decode().(*SessionEnd) + i.timestamp = int64(m.Timestamp) + case MsgSetPageLocation: + m := i.msg.Decode().(*SetPageLocation) + i.url = m.URL + } + i.msg.Meta().Index = i.index + i.msg.Meta().Timestamp = i.timestamp + i.msg.Meta().Url = i.url + + if !isBatchMeta { // Without that indexes will be unique anyway, though shifted by 1 because BatchMeta is not counted in tracker + i.index++ + } + return true +} + +func (i *iteratorImpl) Type() int { + return int(i.msgType) +} + +func (i *iteratorImpl) Message() Message { + return i.msg +} + +func messageHasSize(msgType uint64) bool { + return !(msgType == 80 || msgType == 81 || msgType == 82) } diff --git a/backend/pkg/messages/extra.go b/backend/pkg/messages/extra.go index 1691d905f..b2a57e2ad 100644 --- a/backend/pkg/messages/extra.go +++ b/backend/pkg/messages/extra.go @@ -1,6 +1,7 @@ package messages import ( + "encoding/binary" "fmt" "io" ) @@ -20,6 +21,21 @@ func (msg *SessionSearch) Encode() []byte { return buf[:p] } +func (msg *SessionSearch) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *SessionSearch) Decode() Message { + return msg +} + func (msg *SessionSearch) TypeID() int { return 127 } diff --git a/backend/pkg/messages/filters.go b/backend/pkg/messages/filters.go index a74d49eec..c28e07742 100644 --- a/backend/pkg/messages/filters.go +++ b/backend/pkg/messages/filters.go @@ -2,7 +2,7 @@ package messages func IsReplayerType(id int) bool { - return 0 == id || 2 == id || 4 == id || 5 == id || 6 == id || 7 == id || 8 == id || 9 == id || 10 == id || 11 == id || 12 == id || 13 == id || 14 == id || 15 == id || 16 == id || 18 == id || 19 == id || 20 == id || 22 == id || 37 == id || 38 == id || 39 == id || 40 == id || 41 == id || 44 == id || 45 == id || 46 == id || 47 == id || 48 == id || 49 == id || 54 == id || 55 == id || 59 == id || 69 == id || 70 == id || 90 == id || 93 == id || 96 == id || 100 == id || 102 == id || 103 == id || 105 == id + return 0 == id || 4 == id || 5 == id || 6 == id || 7 == id || 8 == id || 9 == id || 10 == id || 11 == id || 12 == id || 13 == id || 14 == id || 15 == id || 16 == id || 18 == id || 19 == id || 20 == id || 22 == id || 37 == id || 38 == id || 39 == id || 40 == id || 41 == id || 44 == id || 45 == id || 46 == id || 47 == id || 48 == id || 49 == id || 54 == id || 55 == id || 59 == id || 60 == id || 61 == id || 67 == id || 69 == id || 70 == id || 71 == id || 72 == id || 73 == id || 74 == id || 75 == id || 76 == id || 77 == id || 90 == id || 93 == id || 96 == id || 100 == id || 102 == id || 103 == id || 105 == id } func IsIOSType(id int) bool { diff --git a/backend/pkg/messages/message.go b/backend/pkg/messages/message.go index c4066c225..16ab1920d 100644 --- a/backend/pkg/messages/message.go +++ b/backend/pkg/messages/message.go @@ -3,6 +3,7 @@ package messages type message struct { Timestamp int64 Index uint64 + Url string } func (m *message) Meta() *message { @@ -12,10 +13,13 @@ func (m *message) Meta() *message { func (m *message) SetMeta(origin *message) { m.Timestamp = origin.Timestamp m.Index = origin.Index + m.Url = origin.Url } type Message interface { Encode() []byte + EncodeWithIndex() []byte + Decode() Message TypeID() int Meta() *message } diff --git a/backend/pkg/messages/messages.go b/backend/pkg/messages/messages.go index 6c4d75bfc..27712cb1f 100644 --- a/backend/pkg/messages/messages.go +++ b/backend/pkg/messages/messages.go @@ -1,6 +1,200 @@ // Auto-generated, do not edit package messages +import "encoding/binary" + +const ( + MsgBatchMeta = 80 + + MsgBatchMetadata = 81 + + MsgPartitionedMessage = 82 + + MsgTimestamp = 0 + + MsgSessionStart = 1 + + MsgSessionEnd = 3 + + MsgSetPageLocation = 4 + + MsgSetViewportSize = 5 + + MsgSetViewportScroll = 6 + + MsgCreateDocument = 7 + + MsgCreateElementNode = 8 + + MsgCreateTextNode = 9 + + MsgMoveNode = 10 + + MsgRemoveNode = 11 + + MsgSetNodeAttribute = 12 + + MsgRemoveNodeAttribute = 13 + + MsgSetNodeData = 14 + + MsgSetCSSData = 15 + + MsgSetNodeScroll = 16 + + MsgSetInputTarget = 17 + + MsgSetInputValue = 18 + + MsgSetInputChecked = 19 + + MsgMouseMove = 20 + + MsgMouseClickDepricated = 21 + + MsgConsoleLog = 22 + + MsgPageLoadTiming = 23 + + MsgPageRenderTiming = 24 + + MsgJSException = 25 + + MsgIntegrationEvent = 26 + + MsgRawCustomEvent = 27 + + MsgUserID = 28 + + MsgUserAnonymousID = 29 + + MsgMetadata = 30 + + MsgPageEvent = 31 + + MsgInputEvent = 32 + + MsgClickEvent = 33 + + MsgErrorEvent = 34 + + MsgResourceEvent = 35 + + MsgCustomEvent = 36 + + MsgCSSInsertRule = 37 + + MsgCSSDeleteRule = 38 + + MsgFetch = 39 + + MsgProfiler = 40 + + MsgOTable = 41 + + MsgStateAction = 42 + + MsgStateActionEvent = 43 + + MsgRedux = 44 + + MsgVuex = 45 + + MsgMobX = 46 + + MsgNgRx = 47 + + MsgGraphQL = 48 + + MsgPerformanceTrack = 49 + + MsgGraphQLEvent = 50 + + MsgFetchEvent = 51 + + MsgDOMDrop = 52 + + MsgResourceTiming = 53 + + MsgConnectionInformation = 54 + + MsgSetPageVisibility = 55 + + MsgPerformanceTrackAggr = 56 + + MsgLongTask = 59 + + MsgSetNodeAttributeURLBased = 60 + + MsgSetCSSDataURLBased = 61 + + MsgIssueEvent = 62 + + MsgTechnicalInfo = 63 + + MsgCustomIssue = 64 + + MsgAssetCache = 66 + + MsgCSSInsertRuleURLBased = 67 + + MsgMouseClick = 69 + + MsgCreateIFrameDocument = 70 + + MsgAdoptedSSReplaceURLBased = 71 + + MsgAdoptedSSReplace = 72 + + MsgAdoptedSSInsertRuleURLBased = 73 + + MsgAdoptedSSInsertRule = 74 + + MsgAdoptedSSDeleteRule = 75 + + MsgAdoptedSSAddOwner = 76 + + MsgAdoptedSSRemoveOwner = 77 + + MsgIOSBatchMeta = 107 + + MsgIOSSessionStart = 90 + + MsgIOSSessionEnd = 91 + + MsgIOSMetadata = 92 + + MsgIOSCustomEvent = 93 + + MsgIOSUserID = 94 + + MsgIOSUserAnonymousID = 95 + + MsgIOSScreenChanges = 96 + + MsgIOSCrash = 97 + + MsgIOSScreenEnter = 98 + + MsgIOSScreenLeave = 99 + + MsgIOSClickEvent = 100 + + MsgIOSInputEvent = 101 + + MsgIOSPerformanceEvent = 102 + + MsgIOSLog = 103 + + MsgIOSInternalError = 104 + + MsgIOSNetworkCall = 105 + + MsgIOSPerformanceAggregated = 110 + + MsgIOSIssueEvent = 111 +) + type BatchMeta struct { message PageNo uint64 @@ -18,10 +212,99 @@ func (msg *BatchMeta) Encode() []byte { return buf[:p] } +func (msg *BatchMeta) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *BatchMeta) Decode() Message { + return msg +} + func (msg *BatchMeta) TypeID() int { return 80 } +type BatchMetadata struct { + message + Version uint64 + PageNo uint64 + FirstIndex uint64 + Timestamp int64 + Location string +} + +func (msg *BatchMetadata) Encode() []byte { + buf := make([]byte, 51+len(msg.Location)) + buf[0] = 81 + p := 1 + p = WriteUint(msg.Version, buf, p) + p = WriteUint(msg.PageNo, buf, p) + p = WriteUint(msg.FirstIndex, buf, p) + p = WriteInt(msg.Timestamp, buf, p) + p = WriteString(msg.Location, buf, p) + return buf[:p] +} + +func (msg *BatchMetadata) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *BatchMetadata) Decode() Message { + return msg +} + +func (msg *BatchMetadata) TypeID() int { + return 81 +} + +type PartitionedMessage struct { + message + PartNo uint64 + PartTotal uint64 +} + +func (msg *PartitionedMessage) Encode() []byte { + buf := make([]byte, 21) + buf[0] = 82 + p := 1 + p = WriteUint(msg.PartNo, buf, p) + p = WriteUint(msg.PartTotal, buf, p) + return buf[:p] +} + +func (msg *PartitionedMessage) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *PartitionedMessage) Decode() Message { + return msg +} + +func (msg *PartitionedMessage) TypeID() int { + return 82 +} + type Timestamp struct { message Timestamp uint64 @@ -35,6 +318,21 @@ func (msg *Timestamp) Encode() []byte { return buf[:p] } +func (msg *Timestamp) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *Timestamp) Decode() Message { + return msg +} + func (msg *Timestamp) TypeID() int { return 0 } @@ -82,27 +380,25 @@ func (msg *SessionStart) Encode() []byte { return buf[:p] } +func (msg *SessionStart) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *SessionStart) Decode() Message { + return msg +} + func (msg *SessionStart) TypeID() int { return 1 } -type SessionDisconnect struct { - message - Timestamp uint64 -} - -func (msg *SessionDisconnect) Encode() []byte { - buf := make([]byte, 11) - buf[0] = 2 - p := 1 - p = WriteUint(msg.Timestamp, buf, p) - return buf[:p] -} - -func (msg *SessionDisconnect) TypeID() int { - return 2 -} - type SessionEnd struct { message Timestamp uint64 @@ -116,6 +412,21 @@ func (msg *SessionEnd) Encode() []byte { return buf[:p] } +func (msg *SessionEnd) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *SessionEnd) Decode() Message { + return msg +} + func (msg *SessionEnd) TypeID() int { return 3 } @@ -137,6 +448,21 @@ func (msg *SetPageLocation) Encode() []byte { return buf[:p] } +func (msg *SetPageLocation) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *SetPageLocation) Decode() Message { + return msg +} + func (msg *SetPageLocation) TypeID() int { return 4 } @@ -156,6 +482,21 @@ func (msg *SetViewportSize) Encode() []byte { return buf[:p] } +func (msg *SetViewportSize) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *SetViewportSize) Decode() Message { + return msg +} + func (msg *SetViewportSize) TypeID() int { return 5 } @@ -175,6 +516,21 @@ func (msg *SetViewportScroll) Encode() []byte { return buf[:p] } +func (msg *SetViewportScroll) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *SetViewportScroll) Decode() Message { + return msg +} + func (msg *SetViewportScroll) TypeID() int { return 6 } @@ -191,6 +547,21 @@ func (msg *CreateDocument) Encode() []byte { return buf[:p] } +func (msg *CreateDocument) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *CreateDocument) Decode() Message { + return msg +} + func (msg *CreateDocument) TypeID() int { return 7 } @@ -216,6 +587,21 @@ func (msg *CreateElementNode) Encode() []byte { return buf[:p] } +func (msg *CreateElementNode) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *CreateElementNode) Decode() Message { + return msg +} + func (msg *CreateElementNode) TypeID() int { return 8 } @@ -237,6 +623,21 @@ func (msg *CreateTextNode) Encode() []byte { return buf[:p] } +func (msg *CreateTextNode) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *CreateTextNode) Decode() Message { + return msg +} + func (msg *CreateTextNode) TypeID() int { return 9 } @@ -258,6 +659,21 @@ func (msg *MoveNode) Encode() []byte { return buf[:p] } +func (msg *MoveNode) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *MoveNode) Decode() Message { + return msg +} + func (msg *MoveNode) TypeID() int { return 10 } @@ -275,6 +691,21 @@ func (msg *RemoveNode) Encode() []byte { return buf[:p] } +func (msg *RemoveNode) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *RemoveNode) Decode() Message { + return msg +} + func (msg *RemoveNode) TypeID() int { return 11 } @@ -296,6 +727,21 @@ func (msg *SetNodeAttribute) Encode() []byte { return buf[:p] } +func (msg *SetNodeAttribute) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *SetNodeAttribute) Decode() Message { + return msg +} + func (msg *SetNodeAttribute) TypeID() int { return 12 } @@ -315,6 +761,21 @@ func (msg *RemoveNodeAttribute) Encode() []byte { return buf[:p] } +func (msg *RemoveNodeAttribute) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *RemoveNodeAttribute) Decode() Message { + return msg +} + func (msg *RemoveNodeAttribute) TypeID() int { return 13 } @@ -334,6 +795,21 @@ func (msg *SetNodeData) Encode() []byte { return buf[:p] } +func (msg *SetNodeData) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *SetNodeData) Decode() Message { + return msg +} + func (msg *SetNodeData) TypeID() int { return 14 } @@ -353,6 +829,21 @@ func (msg *SetCSSData) Encode() []byte { return buf[:p] } +func (msg *SetCSSData) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *SetCSSData) Decode() Message { + return msg +} + func (msg *SetCSSData) TypeID() int { return 15 } @@ -374,6 +865,21 @@ func (msg *SetNodeScroll) Encode() []byte { return buf[:p] } +func (msg *SetNodeScroll) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *SetNodeScroll) Decode() Message { + return msg +} + func (msg *SetNodeScroll) TypeID() int { return 16 } @@ -393,6 +899,21 @@ func (msg *SetInputTarget) Encode() []byte { return buf[:p] } +func (msg *SetInputTarget) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *SetInputTarget) Decode() Message { + return msg +} + func (msg *SetInputTarget) TypeID() int { return 17 } @@ -414,6 +935,21 @@ func (msg *SetInputValue) Encode() []byte { return buf[:p] } +func (msg *SetInputValue) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *SetInputValue) Decode() Message { + return msg +} + func (msg *SetInputValue) TypeID() int { return 18 } @@ -433,6 +969,21 @@ func (msg *SetInputChecked) Encode() []byte { return buf[:p] } +func (msg *SetInputChecked) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *SetInputChecked) Decode() Message { + return msg +} + func (msg *SetInputChecked) TypeID() int { return 19 } @@ -452,6 +1003,21 @@ func (msg *MouseMove) Encode() []byte { return buf[:p] } +func (msg *MouseMove) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *MouseMove) Decode() Message { + return msg +} + func (msg *MouseMove) TypeID() int { return 20 } @@ -473,6 +1039,21 @@ func (msg *MouseClickDepricated) Encode() []byte { return buf[:p] } +func (msg *MouseClickDepricated) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *MouseClickDepricated) Decode() Message { + return msg +} + func (msg *MouseClickDepricated) TypeID() int { return 21 } @@ -492,6 +1073,21 @@ func (msg *ConsoleLog) Encode() []byte { return buf[:p] } +func (msg *ConsoleLog) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *ConsoleLog) Decode() Message { + return msg +} + func (msg *ConsoleLog) TypeID() int { return 22 } @@ -525,6 +1121,21 @@ func (msg *PageLoadTiming) Encode() []byte { return buf[:p] } +func (msg *PageLoadTiming) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *PageLoadTiming) Decode() Message { + return msg +} + func (msg *PageLoadTiming) TypeID() int { return 23 } @@ -546,6 +1157,21 @@ func (msg *PageRenderTiming) Encode() []byte { return buf[:p] } +func (msg *PageRenderTiming) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *PageRenderTiming) Decode() Message { + return msg +} + func (msg *PageRenderTiming) TypeID() int { return 24 } @@ -567,6 +1193,21 @@ func (msg *JSException) Encode() []byte { return buf[:p] } +func (msg *JSException) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *JSException) Decode() Message { + return msg +} + func (msg *JSException) TypeID() int { return 25 } @@ -592,6 +1233,21 @@ func (msg *IntegrationEvent) Encode() []byte { return buf[:p] } +func (msg *IntegrationEvent) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *IntegrationEvent) Decode() Message { + return msg +} + func (msg *IntegrationEvent) TypeID() int { return 26 } @@ -611,6 +1267,21 @@ func (msg *RawCustomEvent) Encode() []byte { return buf[:p] } +func (msg *RawCustomEvent) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *RawCustomEvent) Decode() Message { + return msg +} + func (msg *RawCustomEvent) TypeID() int { return 27 } @@ -628,6 +1299,21 @@ func (msg *UserID) Encode() []byte { return buf[:p] } +func (msg *UserID) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *UserID) Decode() Message { + return msg +} + func (msg *UserID) TypeID() int { return 28 } @@ -645,6 +1331,21 @@ func (msg *UserAnonymousID) Encode() []byte { return buf[:p] } +func (msg *UserAnonymousID) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *UserAnonymousID) Decode() Message { + return msg +} + func (msg *UserAnonymousID) TypeID() int { return 29 } @@ -664,6 +1365,21 @@ func (msg *Metadata) Encode() []byte { return buf[:p] } +func (msg *Metadata) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *Metadata) Decode() Message { + return msg +} + func (msg *Metadata) TypeID() int { return 30 } @@ -713,6 +1429,21 @@ func (msg *PageEvent) Encode() []byte { return buf[:p] } +func (msg *PageEvent) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *PageEvent) Decode() Message { + return msg +} + func (msg *PageEvent) TypeID() int { return 31 } @@ -738,6 +1469,21 @@ func (msg *InputEvent) Encode() []byte { return buf[:p] } +func (msg *InputEvent) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *InputEvent) Decode() Message { + return msg +} + func (msg *InputEvent) TypeID() int { return 32 } @@ -763,6 +1509,21 @@ func (msg *ClickEvent) Encode() []byte { return buf[:p] } +func (msg *ClickEvent) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *ClickEvent) Decode() Message { + return msg +} + func (msg *ClickEvent) TypeID() int { return 33 } @@ -790,6 +1551,21 @@ func (msg *ErrorEvent) Encode() []byte { return buf[:p] } +func (msg *ErrorEvent) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *ErrorEvent) Decode() Message { + return msg +} + func (msg *ErrorEvent) TypeID() int { return 34 } @@ -829,6 +1605,21 @@ func (msg *ResourceEvent) Encode() []byte { return buf[:p] } +func (msg *ResourceEvent) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *ResourceEvent) Decode() Message { + return msg +} + func (msg *ResourceEvent) TypeID() int { return 35 } @@ -852,6 +1643,21 @@ func (msg *CustomEvent) Encode() []byte { return buf[:p] } +func (msg *CustomEvent) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *CustomEvent) Decode() Message { + return msg +} + func (msg *CustomEvent) TypeID() int { return 36 } @@ -873,6 +1679,21 @@ func (msg *CSSInsertRule) Encode() []byte { return buf[:p] } +func (msg *CSSInsertRule) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *CSSInsertRule) Decode() Message { + return msg +} + func (msg *CSSInsertRule) TypeID() int { return 37 } @@ -892,6 +1713,21 @@ func (msg *CSSDeleteRule) Encode() []byte { return buf[:p] } +func (msg *CSSDeleteRule) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *CSSDeleteRule) Decode() Message { + return msg +} + func (msg *CSSDeleteRule) TypeID() int { return 38 } @@ -921,6 +1757,21 @@ func (msg *Fetch) Encode() []byte { return buf[:p] } +func (msg *Fetch) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *Fetch) Decode() Message { + return msg +} + func (msg *Fetch) TypeID() int { return 39 } @@ -944,6 +1795,21 @@ func (msg *Profiler) Encode() []byte { return buf[:p] } +func (msg *Profiler) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *Profiler) Decode() Message { + return msg +} + func (msg *Profiler) TypeID() int { return 40 } @@ -963,6 +1829,21 @@ func (msg *OTable) Encode() []byte { return buf[:p] } +func (msg *OTable) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *OTable) Decode() Message { + return msg +} + func (msg *OTable) TypeID() int { return 41 } @@ -980,6 +1861,21 @@ func (msg *StateAction) Encode() []byte { return buf[:p] } +func (msg *StateAction) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *StateAction) Decode() Message { + return msg +} + func (msg *StateAction) TypeID() int { return 42 } @@ -1001,6 +1897,21 @@ func (msg *StateActionEvent) Encode() []byte { return buf[:p] } +func (msg *StateActionEvent) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *StateActionEvent) Decode() Message { + return msg +} + func (msg *StateActionEvent) TypeID() int { return 43 } @@ -1022,6 +1933,21 @@ func (msg *Redux) Encode() []byte { return buf[:p] } +func (msg *Redux) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *Redux) Decode() Message { + return msg +} + func (msg *Redux) TypeID() int { return 44 } @@ -1041,6 +1967,21 @@ func (msg *Vuex) Encode() []byte { return buf[:p] } +func (msg *Vuex) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *Vuex) Decode() Message { + return msg +} + func (msg *Vuex) TypeID() int { return 45 } @@ -1060,6 +2001,21 @@ func (msg *MobX) Encode() []byte { return buf[:p] } +func (msg *MobX) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *MobX) Decode() Message { + return msg +} + func (msg *MobX) TypeID() int { return 46 } @@ -1081,6 +2037,21 @@ func (msg *NgRx) Encode() []byte { return buf[:p] } +func (msg *NgRx) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *NgRx) Decode() Message { + return msg +} + func (msg *NgRx) TypeID() int { return 47 } @@ -1104,6 +2075,21 @@ func (msg *GraphQL) Encode() []byte { return buf[:p] } +func (msg *GraphQL) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *GraphQL) Decode() Message { + return msg +} + func (msg *GraphQL) TypeID() int { return 48 } @@ -1127,6 +2113,21 @@ func (msg *PerformanceTrack) Encode() []byte { return buf[:p] } +func (msg *PerformanceTrack) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *PerformanceTrack) Decode() Message { + return msg +} + func (msg *PerformanceTrack) TypeID() int { return 49 } @@ -1154,6 +2155,21 @@ func (msg *GraphQLEvent) Encode() []byte { return buf[:p] } +func (msg *GraphQLEvent) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *GraphQLEvent) Decode() Message { + return msg +} + func (msg *GraphQLEvent) TypeID() int { return 50 } @@ -1185,6 +2201,21 @@ func (msg *FetchEvent) Encode() []byte { return buf[:p] } +func (msg *FetchEvent) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *FetchEvent) Decode() Message { + return msg +} + func (msg *FetchEvent) TypeID() int { return 51 } @@ -1202,6 +2233,21 @@ func (msg *DOMDrop) Encode() []byte { return buf[:p] } +func (msg *DOMDrop) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *DOMDrop) Decode() Message { + return msg +} + func (msg *DOMDrop) TypeID() int { return 52 } @@ -1233,6 +2279,21 @@ func (msg *ResourceTiming) Encode() []byte { return buf[:p] } +func (msg *ResourceTiming) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *ResourceTiming) Decode() Message { + return msg +} + func (msg *ResourceTiming) TypeID() int { return 53 } @@ -1252,6 +2313,21 @@ func (msg *ConnectionInformation) Encode() []byte { return buf[:p] } +func (msg *ConnectionInformation) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *ConnectionInformation) Decode() Message { + return msg +} + func (msg *ConnectionInformation) TypeID() int { return 54 } @@ -1269,6 +2345,21 @@ func (msg *SetPageVisibility) Encode() []byte { return buf[:p] } +func (msg *SetPageVisibility) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *SetPageVisibility) Decode() Message { + return msg +} + func (msg *SetPageVisibility) TypeID() int { return 55 } @@ -1312,6 +2403,21 @@ func (msg *PerformanceTrackAggr) Encode() []byte { return buf[:p] } +func (msg *PerformanceTrackAggr) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *PerformanceTrackAggr) Decode() Message { + return msg +} + func (msg *PerformanceTrackAggr) TypeID() int { return 56 } @@ -1341,6 +2447,21 @@ func (msg *LongTask) Encode() []byte { return buf[:p] } +func (msg *LongTask) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *LongTask) Decode() Message { + return msg +} + func (msg *LongTask) TypeID() int { return 59 } @@ -1364,6 +2485,21 @@ func (msg *SetNodeAttributeURLBased) Encode() []byte { return buf[:p] } +func (msg *SetNodeAttributeURLBased) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *SetNodeAttributeURLBased) Decode() Message { + return msg +} + func (msg *SetNodeAttributeURLBased) TypeID() int { return 60 } @@ -1385,6 +2521,21 @@ func (msg *SetCSSDataURLBased) Encode() []byte { return buf[:p] } +func (msg *SetCSSDataURLBased) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *SetCSSDataURLBased) Decode() Message { + return msg +} + func (msg *SetCSSDataURLBased) TypeID() int { return 61 } @@ -1412,6 +2563,21 @@ func (msg *IssueEvent) Encode() []byte { return buf[:p] } +func (msg *IssueEvent) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *IssueEvent) Decode() Message { + return msg +} + func (msg *IssueEvent) TypeID() int { return 62 } @@ -1431,6 +2597,21 @@ func (msg *TechnicalInfo) Encode() []byte { return buf[:p] } +func (msg *TechnicalInfo) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *TechnicalInfo) Decode() Message { + return msg +} + func (msg *TechnicalInfo) TypeID() int { return 63 } @@ -1450,26 +2631,25 @@ func (msg *CustomIssue) Encode() []byte { return buf[:p] } +func (msg *CustomIssue) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *CustomIssue) Decode() Message { + return msg +} + func (msg *CustomIssue) TypeID() int { return 64 } -type PageClose struct { - message -} - -func (msg *PageClose) Encode() []byte { - buf := make([]byte, 1) - buf[0] = 65 - p := 1 - - return buf[:p] -} - -func (msg *PageClose) TypeID() int { - return 65 -} - type AssetCache struct { message URL string @@ -1483,6 +2663,21 @@ func (msg *AssetCache) Encode() []byte { return buf[:p] } +func (msg *AssetCache) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *AssetCache) Decode() Message { + return msg +} + func (msg *AssetCache) TypeID() int { return 66 } @@ -1506,6 +2701,21 @@ func (msg *CSSInsertRuleURLBased) Encode() []byte { return buf[:p] } +func (msg *CSSInsertRuleURLBased) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *CSSInsertRuleURLBased) Decode() Message { + return msg +} + func (msg *CSSInsertRuleURLBased) TypeID() int { return 67 } @@ -1529,6 +2739,21 @@ func (msg *MouseClick) Encode() []byte { return buf[:p] } +func (msg *MouseClick) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *MouseClick) Decode() Message { + return msg +} + func (msg *MouseClick) TypeID() int { return 69 } @@ -1548,10 +2773,271 @@ func (msg *CreateIFrameDocument) Encode() []byte { return buf[:p] } +func (msg *CreateIFrameDocument) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *CreateIFrameDocument) Decode() Message { + return msg +} + func (msg *CreateIFrameDocument) TypeID() int { return 70 } +type AdoptedSSReplaceURLBased struct { + message + SheetID uint64 + Text string + BaseURL string +} + +func (msg *AdoptedSSReplaceURLBased) Encode() []byte { + buf := make([]byte, 31+len(msg.Text)+len(msg.BaseURL)) + buf[0] = 71 + p := 1 + p = WriteUint(msg.SheetID, buf, p) + p = WriteString(msg.Text, buf, p) + p = WriteString(msg.BaseURL, buf, p) + return buf[:p] +} + +func (msg *AdoptedSSReplaceURLBased) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *AdoptedSSReplaceURLBased) Decode() Message { + return msg +} + +func (msg *AdoptedSSReplaceURLBased) TypeID() int { + return 71 +} + +type AdoptedSSReplace struct { + message + SheetID uint64 + Text string +} + +func (msg *AdoptedSSReplace) Encode() []byte { + buf := make([]byte, 21+len(msg.Text)) + buf[0] = 72 + p := 1 + p = WriteUint(msg.SheetID, buf, p) + p = WriteString(msg.Text, buf, p) + return buf[:p] +} + +func (msg *AdoptedSSReplace) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *AdoptedSSReplace) Decode() Message { + return msg +} + +func (msg *AdoptedSSReplace) TypeID() int { + return 72 +} + +type AdoptedSSInsertRuleURLBased struct { + message + SheetID uint64 + Rule string + Index uint64 + BaseURL string +} + +func (msg *AdoptedSSInsertRuleURLBased) Encode() []byte { + buf := make([]byte, 41+len(msg.Rule)+len(msg.BaseURL)) + buf[0] = 73 + p := 1 + p = WriteUint(msg.SheetID, buf, p) + p = WriteString(msg.Rule, buf, p) + p = WriteUint(msg.Index, buf, p) + p = WriteString(msg.BaseURL, buf, p) + return buf[:p] +} + +func (msg *AdoptedSSInsertRuleURLBased) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *AdoptedSSInsertRuleURLBased) Decode() Message { + return msg +} + +func (msg *AdoptedSSInsertRuleURLBased) TypeID() int { + return 73 +} + +type AdoptedSSInsertRule struct { + message + SheetID uint64 + Rule string + Index uint64 +} + +func (msg *AdoptedSSInsertRule) Encode() []byte { + buf := make([]byte, 31+len(msg.Rule)) + buf[0] = 74 + p := 1 + p = WriteUint(msg.SheetID, buf, p) + p = WriteString(msg.Rule, buf, p) + p = WriteUint(msg.Index, buf, p) + return buf[:p] +} + +func (msg *AdoptedSSInsertRule) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *AdoptedSSInsertRule) Decode() Message { + return msg +} + +func (msg *AdoptedSSInsertRule) TypeID() int { + return 74 +} + +type AdoptedSSDeleteRule struct { + message + SheetID uint64 + Index uint64 +} + +func (msg *AdoptedSSDeleteRule) Encode() []byte { + buf := make([]byte, 21) + buf[0] = 75 + p := 1 + p = WriteUint(msg.SheetID, buf, p) + p = WriteUint(msg.Index, buf, p) + return buf[:p] +} + +func (msg *AdoptedSSDeleteRule) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *AdoptedSSDeleteRule) Decode() Message { + return msg +} + +func (msg *AdoptedSSDeleteRule) TypeID() int { + return 75 +} + +type AdoptedSSAddOwner struct { + message + SheetID uint64 + ID uint64 +} + +func (msg *AdoptedSSAddOwner) Encode() []byte { + buf := make([]byte, 21) + buf[0] = 76 + p := 1 + p = WriteUint(msg.SheetID, buf, p) + p = WriteUint(msg.ID, buf, p) + return buf[:p] +} + +func (msg *AdoptedSSAddOwner) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *AdoptedSSAddOwner) Decode() Message { + return msg +} + +func (msg *AdoptedSSAddOwner) TypeID() int { + return 76 +} + +type AdoptedSSRemoveOwner struct { + message + SheetID uint64 + ID uint64 +} + +func (msg *AdoptedSSRemoveOwner) Encode() []byte { + buf := make([]byte, 21) + buf[0] = 77 + p := 1 + p = WriteUint(msg.SheetID, buf, p) + p = WriteUint(msg.ID, buf, p) + return buf[:p] +} + +func (msg *AdoptedSSRemoveOwner) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *AdoptedSSRemoveOwner) Decode() Message { + return msg +} + +func (msg *AdoptedSSRemoveOwner) TypeID() int { + return 77 +} + type IOSBatchMeta struct { message Timestamp uint64 @@ -1569,6 +3055,21 @@ func (msg *IOSBatchMeta) Encode() []byte { return buf[:p] } +func (msg *IOSBatchMeta) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *IOSBatchMeta) Decode() Message { + return msg +} + func (msg *IOSBatchMeta) TypeID() int { return 107 } @@ -1604,6 +3105,21 @@ func (msg *IOSSessionStart) Encode() []byte { return buf[:p] } +func (msg *IOSSessionStart) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *IOSSessionStart) Decode() Message { + return msg +} + func (msg *IOSSessionStart) TypeID() int { return 90 } @@ -1621,6 +3137,21 @@ func (msg *IOSSessionEnd) Encode() []byte { return buf[:p] } +func (msg *IOSSessionEnd) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *IOSSessionEnd) Decode() Message { + return msg +} + func (msg *IOSSessionEnd) TypeID() int { return 91 } @@ -1644,6 +3175,21 @@ func (msg *IOSMetadata) Encode() []byte { return buf[:p] } +func (msg *IOSMetadata) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *IOSMetadata) Decode() Message { + return msg +} + func (msg *IOSMetadata) TypeID() int { return 92 } @@ -1667,6 +3213,21 @@ func (msg *IOSCustomEvent) Encode() []byte { return buf[:p] } +func (msg *IOSCustomEvent) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *IOSCustomEvent) Decode() Message { + return msg +} + func (msg *IOSCustomEvent) TypeID() int { return 93 } @@ -1688,6 +3249,21 @@ func (msg *IOSUserID) Encode() []byte { return buf[:p] } +func (msg *IOSUserID) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *IOSUserID) Decode() Message { + return msg +} + func (msg *IOSUserID) TypeID() int { return 94 } @@ -1709,6 +3285,21 @@ func (msg *IOSUserAnonymousID) Encode() []byte { return buf[:p] } +func (msg *IOSUserAnonymousID) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *IOSUserAnonymousID) Decode() Message { + return msg +} + func (msg *IOSUserAnonymousID) TypeID() int { return 95 } @@ -1736,6 +3327,21 @@ func (msg *IOSScreenChanges) Encode() []byte { return buf[:p] } +func (msg *IOSScreenChanges) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *IOSScreenChanges) Decode() Message { + return msg +} + func (msg *IOSScreenChanges) TypeID() int { return 96 } @@ -1761,6 +3367,21 @@ func (msg *IOSCrash) Encode() []byte { return buf[:p] } +func (msg *IOSCrash) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *IOSCrash) Decode() Message { + return msg +} + func (msg *IOSCrash) TypeID() int { return 97 } @@ -1784,6 +3405,21 @@ func (msg *IOSScreenEnter) Encode() []byte { return buf[:p] } +func (msg *IOSScreenEnter) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *IOSScreenEnter) Decode() Message { + return msg +} + func (msg *IOSScreenEnter) TypeID() int { return 98 } @@ -1807,6 +3443,21 @@ func (msg *IOSScreenLeave) Encode() []byte { return buf[:p] } +func (msg *IOSScreenLeave) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *IOSScreenLeave) Decode() Message { + return msg +} + func (msg *IOSScreenLeave) TypeID() int { return 99 } @@ -1832,6 +3483,21 @@ func (msg *IOSClickEvent) Encode() []byte { return buf[:p] } +func (msg *IOSClickEvent) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *IOSClickEvent) Decode() Message { + return msg +} + func (msg *IOSClickEvent) TypeID() int { return 100 } @@ -1857,6 +3523,21 @@ func (msg *IOSInputEvent) Encode() []byte { return buf[:p] } +func (msg *IOSInputEvent) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *IOSInputEvent) Decode() Message { + return msg +} + func (msg *IOSInputEvent) TypeID() int { return 101 } @@ -1880,6 +3561,21 @@ func (msg *IOSPerformanceEvent) Encode() []byte { return buf[:p] } +func (msg *IOSPerformanceEvent) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *IOSPerformanceEvent) Decode() Message { + return msg +} + func (msg *IOSPerformanceEvent) TypeID() int { return 102 } @@ -1903,6 +3599,21 @@ func (msg *IOSLog) Encode() []byte { return buf[:p] } +func (msg *IOSLog) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *IOSLog) Decode() Message { + return msg +} + func (msg *IOSLog) TypeID() int { return 103 } @@ -1924,6 +3635,21 @@ func (msg *IOSInternalError) Encode() []byte { return buf[:p] } +func (msg *IOSInternalError) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *IOSInternalError) Decode() Message { + return msg +} + func (msg *IOSInternalError) TypeID() int { return 104 } @@ -1957,6 +3683,21 @@ func (msg *IOSNetworkCall) Encode() []byte { return buf[:p] } +func (msg *IOSNetworkCall) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *IOSNetworkCall) Decode() Message { + return msg +} + func (msg *IOSNetworkCall) TypeID() int { return 105 } @@ -2000,6 +3741,21 @@ func (msg *IOSPerformanceAggregated) Encode() []byte { return buf[:p] } +func (msg *IOSPerformanceAggregated) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *IOSPerformanceAggregated) Decode() Message { + return msg +} + func (msg *IOSPerformanceAggregated) TypeID() int { return 110 } @@ -2025,6 +3781,21 @@ func (msg *IOSIssueEvent) Encode() []byte { return buf[:p] } +func (msg *IOSIssueEvent) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *IOSIssueEvent) Decode() Message { + return msg +} + func (msg *IOSIssueEvent) TypeID() int { return 111 } diff --git a/backend/pkg/messages/primitives.go b/backend/pkg/messages/primitives.go index 8687ef413..eb65ae7b1 100644 --- a/backend/pkg/messages/primitives.go +++ b/backend/pkg/messages/primitives.go @@ -3,6 +3,7 @@ package messages import ( "encoding/json" "errors" + "fmt" "io" "log" ) @@ -16,15 +17,6 @@ func ReadByte(reader io.Reader) (byte, error) { return p[0], nil } -// func SkipBytes(reader io.ReadSeeker) error { -// n, err := ReadUint(reader) -// if err != nil { -// return err -// } -// _, err = reader.Seek(n, io.SeekCurrent); -// return err -// } - func ReadData(reader io.Reader) ([]byte, error) { n, err := ReadUint(reader) if err != nil { @@ -153,3 +145,28 @@ func WriteJson(v interface{}, buf []byte, p int) int { } return WriteData(data, buf, p) } + +func WriteSize(size uint64, buf []byte, p int) { + var m uint64 = 255 + for i := 0; i < 3; i++ { + buf[p+i] = byte(size & m) + size = size >> 8 + } + fmt.Println(buf) +} + +func ReadSize(reader io.Reader) (uint64, error) { + buf := make([]byte, 3) + n, err := io.ReadFull(reader, buf) + if err != nil { + return 0, err + } + if n != 3 { + return 0, fmt.Errorf("read only %d of 3 size bytes", n) + } + var size uint64 + for i, b := range buf { + size += uint64(b) << (8 * i) + } + return size, nil +} diff --git a/backend/pkg/messages/raw.go b/backend/pkg/messages/raw.go new file mode 100644 index 000000000..daa59accd --- /dev/null +++ b/backend/pkg/messages/raw.go @@ -0,0 +1,68 @@ +package messages + +import ( + "bytes" + "encoding/binary" + "io" + "log" +) + +// RawMessage is a not decoded message +type RawMessage struct { + tp uint64 + size uint64 + data []byte + reader *bytes.Reader + meta *message + encoded bool + skipped *bool +} + +func (m *RawMessage) Encode() []byte { + if m.encoded { + return m.data + } + m.data = make([]byte, m.size+1) + m.data[0] = uint8(m.tp) + m.encoded = true + *m.skipped = false + _, err := io.ReadFull(m.reader, m.data[1:]) + if err != nil { + log.Printf("message encode err: %s", err) + return nil + } + return m.data +} + +func (m *RawMessage) EncodeWithIndex() []byte { + if !m.encoded { + m.Encode() + } + if IsIOSType(int(m.tp)) { + return m.data + } + data := make([]byte, len(m.data)+8) + copy(data[8:], m.data[:]) + binary.LittleEndian.PutUint64(data[0:], m.Meta().Index) + return data +} + +func (m *RawMessage) Decode() Message { + if !m.encoded { + m.Encode() + } + msg, err := ReadMessage(m.tp, bytes.NewReader(m.data[1:])) + if err != nil { + log.Printf("decode err: %s", err) + } + msg.Meta().SetMeta(m.meta) + return msg +} + +func (m *RawMessage) TypeID() int { + return int(m.tp) +} + +func (m *RawMessage) Meta() *message { + return m.meta +} diff --git a/backend/pkg/messages/read-message.go b/backend/pkg/messages/read-message.go index 5009994f5..2b12601d9 100644 --- a/backend/pkg/messages/read-message.go +++ b/backend/pkg/messages/read-message.go @@ -6,1425 +6,1995 @@ import ( "io" ) -func ReadMessage(reader io.Reader) (Message, error) { - t, err := ReadUint(reader) - if err != nil { +func DecodeBatchMeta(reader io.Reader) (Message, error) { + var err error = nil + msg := &BatchMeta{} + if msg.PageNo, err = ReadUint(reader); err != nil { return nil, err } + if msg.FirstIndex, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Timestamp, err = ReadInt(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeBatchMetadata(reader io.Reader) (Message, error) { + var err error = nil + msg := &BatchMetadata{} + if msg.Version, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.PageNo, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.FirstIndex, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Timestamp, err = ReadInt(reader); err != nil { + return nil, err + } + if msg.Location, err = ReadString(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodePartitionedMessage(reader io.Reader) (Message, error) { + var err error = nil + msg := &PartitionedMessage{} + if msg.PartNo, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.PartTotal, err = ReadUint(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeTimestamp(reader io.Reader) (Message, error) { + var err error = nil + msg := &Timestamp{} + if msg.Timestamp, err = ReadUint(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeSessionStart(reader io.Reader) (Message, error) { + var err error = nil + msg := &SessionStart{} + if msg.Timestamp, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.ProjectID, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.TrackerVersion, err = ReadString(reader); err != nil { + return nil, err + } + if msg.RevID, err = ReadString(reader); err != nil { + return nil, err + } + if msg.UserUUID, err = ReadString(reader); err != nil { + return nil, err + } + if msg.UserAgent, err = ReadString(reader); err != nil { + return nil, err + } + if msg.UserOS, err = ReadString(reader); err != nil { + return nil, err + } + if msg.UserOSVersion, err = ReadString(reader); err != nil { + return nil, err + } + if msg.UserBrowser, err = ReadString(reader); err != nil { + return nil, err + } + if msg.UserBrowserVersion, err = ReadString(reader); err != nil { + return nil, err + } + if msg.UserDevice, err = ReadString(reader); err != nil { + return nil, err + } + if msg.UserDeviceType, err = ReadString(reader); err != nil { + return nil, err + } + if msg.UserDeviceMemorySize, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.UserDeviceHeapSize, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.UserCountry, err = ReadString(reader); err != nil { + return nil, err + } + if msg.UserID, err = ReadString(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeSessionEnd(reader io.Reader) (Message, error) { + var err error = nil + msg := &SessionEnd{} + if msg.Timestamp, err = ReadUint(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeSetPageLocation(reader io.Reader) (Message, error) { + var err error = nil + msg := &SetPageLocation{} + if msg.URL, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Referrer, err = ReadString(reader); err != nil { + return nil, err + } + if msg.NavigationStart, err = ReadUint(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeSetViewportSize(reader io.Reader) (Message, error) { + var err error = nil + msg := &SetViewportSize{} + if msg.Width, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Height, err = ReadUint(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeSetViewportScroll(reader io.Reader) (Message, error) { + var err error = nil + msg := &SetViewportScroll{} + if msg.X, err = ReadInt(reader); err != nil { + return nil, err + } + if msg.Y, err = ReadInt(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeCreateDocument(reader io.Reader) (Message, error) { + var err error = nil + msg := &CreateDocument{} + + return msg, err +} + +func DecodeCreateElementNode(reader io.Reader) (Message, error) { + var err error = nil + msg := &CreateElementNode{} + if msg.ID, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.ParentID, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.index, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Tag, err = ReadString(reader); err != nil { + return nil, err + } + if msg.SVG, err = ReadBoolean(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeCreateTextNode(reader io.Reader) (Message, error) { + var err error = nil + msg := &CreateTextNode{} + if msg.ID, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.ParentID, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Index, err = ReadUint(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeMoveNode(reader io.Reader) (Message, error) { + var err error = nil + msg := &MoveNode{} + if msg.ID, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.ParentID, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Index, err = ReadUint(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeRemoveNode(reader io.Reader) (Message, error) { + var err error = nil + msg := &RemoveNode{} + if msg.ID, err = ReadUint(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeSetNodeAttribute(reader io.Reader) (Message, error) { + var err error = nil + msg := &SetNodeAttribute{} + if msg.ID, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Name, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Value, err = ReadString(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeRemoveNodeAttribute(reader io.Reader) (Message, error) { + var err error = nil + msg := &RemoveNodeAttribute{} + if msg.ID, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Name, err = ReadString(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeSetNodeData(reader io.Reader) (Message, error) { + var err error = nil + msg := &SetNodeData{} + if msg.ID, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Data, err = ReadString(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeSetCSSData(reader io.Reader) (Message, error) { + var err error = nil + msg := &SetCSSData{} + if msg.ID, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Data, err = ReadString(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeSetNodeScroll(reader io.Reader) (Message, error) { + var err error = nil + msg := &SetNodeScroll{} + if msg.ID, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.X, err = ReadInt(reader); err != nil { + return nil, err + } + if msg.Y, err = ReadInt(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeSetInputTarget(reader io.Reader) (Message, error) { + var err error = nil + msg := &SetInputTarget{} + if msg.ID, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Label, err = ReadString(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeSetInputValue(reader io.Reader) (Message, error) { + var err error = nil + msg := &SetInputValue{} + if msg.ID, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Value, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Mask, err = ReadInt(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeSetInputChecked(reader io.Reader) (Message, error) { + var err error = nil + msg := &SetInputChecked{} + if msg.ID, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Checked, err = ReadBoolean(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeMouseMove(reader io.Reader) (Message, error) { + var err error = nil + msg := &MouseMove{} + if msg.X, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Y, err = ReadUint(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeMouseClickDepricated(reader io.Reader) (Message, error) { + var err error = nil + msg := &MouseClickDepricated{} + if msg.ID, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.HesitationTime, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Label, err = ReadString(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeConsoleLog(reader io.Reader) (Message, error) { + var err error = nil + msg := &ConsoleLog{} + if msg.Level, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Value, err = ReadString(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodePageLoadTiming(reader io.Reader) (Message, error) { + var err error = nil + msg := &PageLoadTiming{} + if msg.RequestStart, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.ResponseStart, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.ResponseEnd, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.DomContentLoadedEventStart, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.DomContentLoadedEventEnd, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.LoadEventStart, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.LoadEventEnd, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.FirstPaint, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.FirstContentfulPaint, err = ReadUint(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodePageRenderTiming(reader io.Reader) (Message, error) { + var err error = nil + msg := &PageRenderTiming{} + if msg.SpeedIndex, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.VisuallyComplete, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.TimeToInteractive, err = ReadUint(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeJSException(reader io.Reader) (Message, error) { + var err error = nil + msg := &JSException{} + if msg.Name, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Message, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Payload, err = ReadString(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeIntegrationEvent(reader io.Reader) (Message, error) { + var err error = nil + msg := &IntegrationEvent{} + if msg.Timestamp, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Source, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Name, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Message, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Payload, err = ReadString(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeRawCustomEvent(reader io.Reader) (Message, error) { + var err error = nil + msg := &RawCustomEvent{} + if msg.Name, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Payload, err = ReadString(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeUserID(reader io.Reader) (Message, error) { + var err error = nil + msg := &UserID{} + if msg.ID, err = ReadString(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeUserAnonymousID(reader io.Reader) (Message, error) { + var err error = nil + msg := &UserAnonymousID{} + if msg.ID, err = ReadString(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeMetadata(reader io.Reader) (Message, error) { + var err error = nil + msg := &Metadata{} + if msg.Key, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Value, err = ReadString(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodePageEvent(reader io.Reader) (Message, error) { + var err error = nil + msg := &PageEvent{} + if msg.MessageID, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Timestamp, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.URL, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Referrer, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Loaded, err = ReadBoolean(reader); err != nil { + return nil, err + } + if msg.RequestStart, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.ResponseStart, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.ResponseEnd, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.DomContentLoadedEventStart, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.DomContentLoadedEventEnd, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.LoadEventStart, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.LoadEventEnd, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.FirstPaint, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.FirstContentfulPaint, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.SpeedIndex, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.VisuallyComplete, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.TimeToInteractive, err = ReadUint(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeInputEvent(reader io.Reader) (Message, error) { + var err error = nil + msg := &InputEvent{} + if msg.MessageID, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Timestamp, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Value, err = ReadString(reader); err != nil { + return nil, err + } + if msg.ValueMasked, err = ReadBoolean(reader); err != nil { + return nil, err + } + if msg.Label, err = ReadString(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeClickEvent(reader io.Reader) (Message, error) { + var err error = nil + msg := &ClickEvent{} + if msg.MessageID, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Timestamp, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.HesitationTime, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Label, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Selector, err = ReadString(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeErrorEvent(reader io.Reader) (Message, error) { + var err error = nil + msg := &ErrorEvent{} + if msg.MessageID, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Timestamp, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Source, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Name, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Message, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Payload, err = ReadString(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeResourceEvent(reader io.Reader) (Message, error) { + var err error = nil + msg := &ResourceEvent{} + if msg.MessageID, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Timestamp, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Duration, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.TTFB, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.HeaderSize, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.EncodedBodySize, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.DecodedBodySize, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.URL, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Type, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Success, err = ReadBoolean(reader); err != nil { + return nil, err + } + if msg.Method, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Status, err = ReadUint(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeCustomEvent(reader io.Reader) (Message, error) { + var err error = nil + msg := &CustomEvent{} + if msg.MessageID, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Timestamp, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Name, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Payload, err = ReadString(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeCSSInsertRule(reader io.Reader) (Message, error) { + var err error = nil + msg := &CSSInsertRule{} + if msg.ID, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Rule, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Index, err = ReadUint(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeCSSDeleteRule(reader io.Reader) (Message, error) { + var err error = nil + msg := &CSSDeleteRule{} + if msg.ID, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Index, err = ReadUint(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeFetch(reader io.Reader) (Message, error) { + var err error = nil + msg := &Fetch{} + if msg.Method, err = ReadString(reader); err != nil { + return nil, err + } + if msg.URL, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Request, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Response, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Status, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Timestamp, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Duration, err = ReadUint(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeProfiler(reader io.Reader) (Message, error) { + var err error = nil + msg := &Profiler{} + if msg.Name, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Duration, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Args, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Result, err = ReadString(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeOTable(reader io.Reader) (Message, error) { + var err error = nil + msg := &OTable{} + if msg.Key, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Value, err = ReadString(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeStateAction(reader io.Reader) (Message, error) { + var err error = nil + msg := &StateAction{} + if msg.Type, err = ReadString(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeStateActionEvent(reader io.Reader) (Message, error) { + var err error = nil + msg := &StateActionEvent{} + if msg.MessageID, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Timestamp, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Type, err = ReadString(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeRedux(reader io.Reader) (Message, error) { + var err error = nil + msg := &Redux{} + if msg.Action, err = ReadString(reader); err != nil { + return nil, err + } + if msg.State, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Duration, err = ReadUint(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeVuex(reader io.Reader) (Message, error) { + var err error = nil + msg := &Vuex{} + if msg.Mutation, err = ReadString(reader); err != nil { + return nil, err + } + if msg.State, err = ReadString(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeMobX(reader io.Reader) (Message, error) { + var err error = nil + msg := &MobX{} + if msg.Type, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Payload, err = ReadString(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeNgRx(reader io.Reader) (Message, error) { + var err error = nil + msg := &NgRx{} + if msg.Action, err = ReadString(reader); err != nil { + return nil, err + } + if msg.State, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Duration, err = ReadUint(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeGraphQL(reader io.Reader) (Message, error) { + var err error = nil + msg := &GraphQL{} + if msg.OperationKind, err = ReadString(reader); err != nil { + return nil, err + } + if msg.OperationName, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Variables, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Response, err = ReadString(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodePerformanceTrack(reader io.Reader) (Message, error) { + var err error = nil + msg := &PerformanceTrack{} + if msg.Frames, err = ReadInt(reader); err != nil { + return nil, err + } + if msg.Ticks, err = ReadInt(reader); err != nil { + return nil, err + } + if msg.TotalJSHeapSize, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.UsedJSHeapSize, err = ReadUint(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeGraphQLEvent(reader io.Reader) (Message, error) { + var err error = nil + msg := &GraphQLEvent{} + if msg.MessageID, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Timestamp, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.OperationKind, err = ReadString(reader); err != nil { + return nil, err + } + if msg.OperationName, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Variables, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Response, err = ReadString(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeFetchEvent(reader io.Reader) (Message, error) { + var err error = nil + msg := &FetchEvent{} + if msg.MessageID, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Timestamp, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Method, err = ReadString(reader); err != nil { + return nil, err + } + if msg.URL, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Request, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Response, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Status, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Duration, err = ReadUint(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeDOMDrop(reader io.Reader) (Message, error) { + var err error = nil + msg := &DOMDrop{} + if msg.Timestamp, err = ReadUint(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeResourceTiming(reader io.Reader) (Message, error) { + var err error = nil + msg := &ResourceTiming{} + if msg.Timestamp, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Duration, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.TTFB, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.HeaderSize, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.EncodedBodySize, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.DecodedBodySize, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.URL, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Initiator, err = ReadString(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeConnectionInformation(reader io.Reader) (Message, error) { + var err error = nil + msg := &ConnectionInformation{} + if msg.Downlink, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Type, err = ReadString(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeSetPageVisibility(reader io.Reader) (Message, error) { + var err error = nil + msg := &SetPageVisibility{} + if msg.hidden, err = ReadBoolean(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodePerformanceTrackAggr(reader io.Reader) (Message, error) { + var err error = nil + msg := &PerformanceTrackAggr{} + if msg.TimestampStart, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.TimestampEnd, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.MinFPS, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.AvgFPS, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.MaxFPS, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.MinCPU, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.AvgCPU, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.MaxCPU, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.MinTotalJSHeapSize, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.AvgTotalJSHeapSize, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.MaxTotalJSHeapSize, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.MinUsedJSHeapSize, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.AvgUsedJSHeapSize, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.MaxUsedJSHeapSize, err = ReadUint(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeLongTask(reader io.Reader) (Message, error) { + var err error = nil + msg := &LongTask{} + if msg.Timestamp, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Duration, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Context, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.ContainerType, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.ContainerSrc, err = ReadString(reader); err != nil { + return nil, err + } + if msg.ContainerId, err = ReadString(reader); err != nil { + return nil, err + } + if msg.ContainerName, err = ReadString(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeSetNodeAttributeURLBased(reader io.Reader) (Message, error) { + var err error = nil + msg := &SetNodeAttributeURLBased{} + if msg.ID, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Name, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Value, err = ReadString(reader); err != nil { + return nil, err + } + if msg.BaseURL, err = ReadString(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeSetCSSDataURLBased(reader io.Reader) (Message, error) { + var err error = nil + msg := &SetCSSDataURLBased{} + if msg.ID, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Data, err = ReadString(reader); err != nil { + return nil, err + } + if msg.BaseURL, err = ReadString(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeIssueEvent(reader io.Reader) (Message, error) { + var err error = nil + msg := &IssueEvent{} + if msg.MessageID, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Timestamp, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Type, err = ReadString(reader); err != nil { + return nil, err + } + if msg.ContextString, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Context, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Payload, err = ReadString(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeTechnicalInfo(reader io.Reader) (Message, error) { + var err error = nil + msg := &TechnicalInfo{} + if msg.Type, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Value, err = ReadString(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeCustomIssue(reader io.Reader) (Message, error) { + var err error = nil + msg := &CustomIssue{} + if msg.Name, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Payload, err = ReadString(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeAssetCache(reader io.Reader) (Message, error) { + var err error = nil + msg := &AssetCache{} + if msg.URL, err = ReadString(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeCSSInsertRuleURLBased(reader io.Reader) (Message, error) { + var err error = nil + msg := &CSSInsertRuleURLBased{} + if msg.ID, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Rule, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Index, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.BaseURL, err = ReadString(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeMouseClick(reader io.Reader) (Message, error) { + var err error = nil + msg := &MouseClick{} + if msg.ID, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.HesitationTime, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Label, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Selector, err = ReadString(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeCreateIFrameDocument(reader io.Reader) (Message, error) { + var err error = nil + msg := &CreateIFrameDocument{} + if msg.FrameID, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.ID, err = ReadUint(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeAdoptedSSReplaceURLBased(reader io.Reader) (Message, error) { + var err error = nil + msg := &AdoptedSSReplaceURLBased{} + if msg.SheetID, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Text, err = ReadString(reader); err != nil { + return nil, err + } + if msg.BaseURL, err = ReadString(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeAdoptedSSReplace(reader io.Reader) (Message, error) { + var err error = nil + msg := &AdoptedSSReplace{} + if msg.SheetID, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Text, err = ReadString(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeAdoptedSSInsertRuleURLBased(reader io.Reader) (Message, error) { + var err error = nil + msg := &AdoptedSSInsertRuleURLBased{} + if msg.SheetID, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Rule, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Index, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.BaseURL, err = ReadString(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeAdoptedSSInsertRule(reader io.Reader) (Message, error) { + var err error = nil + msg := &AdoptedSSInsertRule{} + if msg.SheetID, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Rule, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Index, err = ReadUint(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeAdoptedSSDeleteRule(reader io.Reader) (Message, error) { + var err error = nil + msg := &AdoptedSSDeleteRule{} + if msg.SheetID, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Index, err = ReadUint(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeAdoptedSSAddOwner(reader io.Reader) (Message, error) { + var err error = nil + msg := &AdoptedSSAddOwner{} + if msg.SheetID, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.ID, err = ReadUint(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeAdoptedSSRemoveOwner(reader io.Reader) (Message, error) { + var err error = nil + msg := &AdoptedSSRemoveOwner{} + if msg.SheetID, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.ID, err = ReadUint(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeIOSBatchMeta(reader io.Reader) (Message, error) { + var err error = nil + msg := &IOSBatchMeta{} + if msg.Timestamp, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Length, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.FirstIndex, err = ReadUint(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeIOSSessionStart(reader io.Reader) (Message, error) { + var err error = nil + msg := &IOSSessionStart{} + if msg.Timestamp, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.ProjectID, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.TrackerVersion, err = ReadString(reader); err != nil { + return nil, err + } + if msg.RevID, err = ReadString(reader); err != nil { + return nil, err + } + if msg.UserUUID, err = ReadString(reader); err != nil { + return nil, err + } + if msg.UserOS, err = ReadString(reader); err != nil { + return nil, err + } + if msg.UserOSVersion, err = ReadString(reader); err != nil { + return nil, err + } + if msg.UserDevice, err = ReadString(reader); err != nil { + return nil, err + } + if msg.UserDeviceType, err = ReadString(reader); err != nil { + return nil, err + } + if msg.UserCountry, err = ReadString(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeIOSSessionEnd(reader io.Reader) (Message, error) { + var err error = nil + msg := &IOSSessionEnd{} + if msg.Timestamp, err = ReadUint(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeIOSMetadata(reader io.Reader) (Message, error) { + var err error = nil + msg := &IOSMetadata{} + if msg.Timestamp, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Length, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Key, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Value, err = ReadString(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeIOSCustomEvent(reader io.Reader) (Message, error) { + var err error = nil + msg := &IOSCustomEvent{} + if msg.Timestamp, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Length, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Name, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Payload, err = ReadString(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeIOSUserID(reader io.Reader) (Message, error) { + var err error = nil + msg := &IOSUserID{} + if msg.Timestamp, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Length, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Value, err = ReadString(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeIOSUserAnonymousID(reader io.Reader) (Message, error) { + var err error = nil + msg := &IOSUserAnonymousID{} + if msg.Timestamp, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Length, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Value, err = ReadString(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeIOSScreenChanges(reader io.Reader) (Message, error) { + var err error = nil + msg := &IOSScreenChanges{} + if msg.Timestamp, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Length, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.X, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Y, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Width, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Height, err = ReadUint(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeIOSCrash(reader io.Reader) (Message, error) { + var err error = nil + msg := &IOSCrash{} + if msg.Timestamp, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Length, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Name, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Reason, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Stacktrace, err = ReadString(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeIOSScreenEnter(reader io.Reader) (Message, error) { + var err error = nil + msg := &IOSScreenEnter{} + if msg.Timestamp, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Length, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Title, err = ReadString(reader); err != nil { + return nil, err + } + if msg.ViewName, err = ReadString(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeIOSScreenLeave(reader io.Reader) (Message, error) { + var err error = nil + msg := &IOSScreenLeave{} + if msg.Timestamp, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Length, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Title, err = ReadString(reader); err != nil { + return nil, err + } + if msg.ViewName, err = ReadString(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeIOSClickEvent(reader io.Reader) (Message, error) { + var err error = nil + msg := &IOSClickEvent{} + if msg.Timestamp, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Length, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Label, err = ReadString(reader); err != nil { + return nil, err + } + if msg.X, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Y, err = ReadUint(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeIOSInputEvent(reader io.Reader) (Message, error) { + var err error = nil + msg := &IOSInputEvent{} + if msg.Timestamp, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Length, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Value, err = ReadString(reader); err != nil { + return nil, err + } + if msg.ValueMasked, err = ReadBoolean(reader); err != nil { + return nil, err + } + if msg.Label, err = ReadString(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeIOSPerformanceEvent(reader io.Reader) (Message, error) { + var err error = nil + msg := &IOSPerformanceEvent{} + if msg.Timestamp, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Length, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Name, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Value, err = ReadUint(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeIOSLog(reader io.Reader) (Message, error) { + var err error = nil + msg := &IOSLog{} + if msg.Timestamp, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Length, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Severity, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Content, err = ReadString(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeIOSInternalError(reader io.Reader) (Message, error) { + var err error = nil + msg := &IOSInternalError{} + if msg.Timestamp, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Length, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Content, err = ReadString(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeIOSNetworkCall(reader io.Reader) (Message, error) { + var err error = nil + msg := &IOSNetworkCall{} + if msg.Timestamp, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Length, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Duration, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Headers, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Body, err = ReadString(reader); err != nil { + return nil, err + } + if msg.URL, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Success, err = ReadBoolean(reader); err != nil { + return nil, err + } + if msg.Method, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Status, err = ReadUint(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeIOSPerformanceAggregated(reader io.Reader) (Message, error) { + var err error = nil + msg := &IOSPerformanceAggregated{} + if msg.TimestampStart, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.TimestampEnd, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.MinFPS, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.AvgFPS, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.MaxFPS, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.MinCPU, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.AvgCPU, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.MaxCPU, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.MinMemory, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.AvgMemory, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.MaxMemory, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.MinBattery, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.AvgBattery, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.MaxBattery, err = ReadUint(reader); err != nil { + return nil, err + } + return msg, err +} + +func DecodeIOSIssueEvent(reader io.Reader) (Message, error) { + var err error = nil + msg := &IOSIssueEvent{} + if msg.Timestamp, err = ReadUint(reader); err != nil { + return nil, err + } + if msg.Type, err = ReadString(reader); err != nil { + return nil, err + } + if msg.ContextString, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Context, err = ReadString(reader); err != nil { + return nil, err + } + if msg.Payload, err = ReadString(reader); err != nil { + return nil, err + } + return msg, err +} + +func ReadMessage(t uint64, reader io.Reader) (Message, error) { switch t { case 80: - msg := &BatchMeta{} - if msg.PageNo, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.FirstIndex, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Timestamp, err = ReadInt(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeBatchMeta(reader) + + case 81: + return DecodeBatchMetadata(reader) + + case 82: + return DecodePartitionedMessage(reader) case 0: - msg := &Timestamp{} - if msg.Timestamp, err = ReadUint(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeTimestamp(reader) case 1: - msg := &SessionStart{} - if msg.Timestamp, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.ProjectID, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.TrackerVersion, err = ReadString(reader); err != nil { - return nil, err - } - if msg.RevID, err = ReadString(reader); err != nil { - return nil, err - } - if msg.UserUUID, err = ReadString(reader); err != nil { - return nil, err - } - if msg.UserAgent, err = ReadString(reader); err != nil { - return nil, err - } - if msg.UserOS, err = ReadString(reader); err != nil { - return nil, err - } - if msg.UserOSVersion, err = ReadString(reader); err != nil { - return nil, err - } - if msg.UserBrowser, err = ReadString(reader); err != nil { - return nil, err - } - if msg.UserBrowserVersion, err = ReadString(reader); err != nil { - return nil, err - } - if msg.UserDevice, err = ReadString(reader); err != nil { - return nil, err - } - if msg.UserDeviceType, err = ReadString(reader); err != nil { - return nil, err - } - if msg.UserDeviceMemorySize, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.UserDeviceHeapSize, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.UserCountry, err = ReadString(reader); err != nil { - return nil, err - } - if msg.UserID, err = ReadString(reader); err != nil { - return nil, err - } - return msg, nil - - case 2: - msg := &SessionDisconnect{} - if msg.Timestamp, err = ReadUint(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeSessionStart(reader) case 3: - msg := &SessionEnd{} - if msg.Timestamp, err = ReadUint(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeSessionEnd(reader) case 4: - msg := &SetPageLocation{} - if msg.URL, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Referrer, err = ReadString(reader); err != nil { - return nil, err - } - if msg.NavigationStart, err = ReadUint(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeSetPageLocation(reader) case 5: - msg := &SetViewportSize{} - if msg.Width, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Height, err = ReadUint(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeSetViewportSize(reader) case 6: - msg := &SetViewportScroll{} - if msg.X, err = ReadInt(reader); err != nil { - return nil, err - } - if msg.Y, err = ReadInt(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeSetViewportScroll(reader) case 7: - msg := &CreateDocument{} - - return msg, nil + return DecodeCreateDocument(reader) case 8: - msg := &CreateElementNode{} - if msg.ID, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.ParentID, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.index, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Tag, err = ReadString(reader); err != nil { - return nil, err - } - if msg.SVG, err = ReadBoolean(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeCreateElementNode(reader) case 9: - msg := &CreateTextNode{} - if msg.ID, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.ParentID, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Index, err = ReadUint(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeCreateTextNode(reader) case 10: - msg := &MoveNode{} - if msg.ID, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.ParentID, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Index, err = ReadUint(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeMoveNode(reader) case 11: - msg := &RemoveNode{} - if msg.ID, err = ReadUint(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeRemoveNode(reader) case 12: - msg := &SetNodeAttribute{} - if msg.ID, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Name, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Value, err = ReadString(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeSetNodeAttribute(reader) case 13: - msg := &RemoveNodeAttribute{} - if msg.ID, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Name, err = ReadString(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeRemoveNodeAttribute(reader) case 14: - msg := &SetNodeData{} - if msg.ID, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Data, err = ReadString(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeSetNodeData(reader) case 15: - msg := &SetCSSData{} - if msg.ID, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Data, err = ReadString(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeSetCSSData(reader) case 16: - msg := &SetNodeScroll{} - if msg.ID, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.X, err = ReadInt(reader); err != nil { - return nil, err - } - if msg.Y, err = ReadInt(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeSetNodeScroll(reader) case 17: - msg := &SetInputTarget{} - if msg.ID, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Label, err = ReadString(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeSetInputTarget(reader) case 18: - msg := &SetInputValue{} - if msg.ID, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Value, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Mask, err = ReadInt(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeSetInputValue(reader) case 19: - msg := &SetInputChecked{} - if msg.ID, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Checked, err = ReadBoolean(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeSetInputChecked(reader) case 20: - msg := &MouseMove{} - if msg.X, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Y, err = ReadUint(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeMouseMove(reader) case 21: - msg := &MouseClickDepricated{} - if msg.ID, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.HesitationTime, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Label, err = ReadString(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeMouseClickDepricated(reader) case 22: - msg := &ConsoleLog{} - if msg.Level, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Value, err = ReadString(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeConsoleLog(reader) case 23: - msg := &PageLoadTiming{} - if msg.RequestStart, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.ResponseStart, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.ResponseEnd, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.DomContentLoadedEventStart, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.DomContentLoadedEventEnd, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.LoadEventStart, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.LoadEventEnd, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.FirstPaint, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.FirstContentfulPaint, err = ReadUint(reader); err != nil { - return nil, err - } - return msg, nil + return DecodePageLoadTiming(reader) case 24: - msg := &PageRenderTiming{} - if msg.SpeedIndex, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.VisuallyComplete, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.TimeToInteractive, err = ReadUint(reader); err != nil { - return nil, err - } - return msg, nil + return DecodePageRenderTiming(reader) case 25: - msg := &JSException{} - if msg.Name, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Message, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Payload, err = ReadString(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeJSException(reader) case 26: - msg := &IntegrationEvent{} - if msg.Timestamp, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Source, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Name, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Message, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Payload, err = ReadString(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeIntegrationEvent(reader) case 27: - msg := &RawCustomEvent{} - if msg.Name, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Payload, err = ReadString(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeRawCustomEvent(reader) case 28: - msg := &UserID{} - if msg.ID, err = ReadString(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeUserID(reader) case 29: - msg := &UserAnonymousID{} - if msg.ID, err = ReadString(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeUserAnonymousID(reader) case 30: - msg := &Metadata{} - if msg.Key, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Value, err = ReadString(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeMetadata(reader) case 31: - msg := &PageEvent{} - if msg.MessageID, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Timestamp, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.URL, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Referrer, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Loaded, err = ReadBoolean(reader); err != nil { - return nil, err - } - if msg.RequestStart, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.ResponseStart, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.ResponseEnd, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.DomContentLoadedEventStart, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.DomContentLoadedEventEnd, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.LoadEventStart, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.LoadEventEnd, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.FirstPaint, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.FirstContentfulPaint, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.SpeedIndex, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.VisuallyComplete, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.TimeToInteractive, err = ReadUint(reader); err != nil { - return nil, err - } - return msg, nil + return DecodePageEvent(reader) case 32: - msg := &InputEvent{} - if msg.MessageID, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Timestamp, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Value, err = ReadString(reader); err != nil { - return nil, err - } - if msg.ValueMasked, err = ReadBoolean(reader); err != nil { - return nil, err - } - if msg.Label, err = ReadString(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeInputEvent(reader) case 33: - msg := &ClickEvent{} - if msg.MessageID, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Timestamp, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.HesitationTime, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Label, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Selector, err = ReadString(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeClickEvent(reader) case 34: - msg := &ErrorEvent{} - if msg.MessageID, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Timestamp, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Source, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Name, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Message, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Payload, err = ReadString(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeErrorEvent(reader) case 35: - msg := &ResourceEvent{} - if msg.MessageID, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Timestamp, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Duration, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.TTFB, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.HeaderSize, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.EncodedBodySize, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.DecodedBodySize, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.URL, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Type, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Success, err = ReadBoolean(reader); err != nil { - return nil, err - } - if msg.Method, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Status, err = ReadUint(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeResourceEvent(reader) case 36: - msg := &CustomEvent{} - if msg.MessageID, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Timestamp, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Name, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Payload, err = ReadString(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeCustomEvent(reader) case 37: - msg := &CSSInsertRule{} - if msg.ID, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Rule, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Index, err = ReadUint(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeCSSInsertRule(reader) case 38: - msg := &CSSDeleteRule{} - if msg.ID, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Index, err = ReadUint(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeCSSDeleteRule(reader) case 39: - msg := &Fetch{} - if msg.Method, err = ReadString(reader); err != nil { - return nil, err - } - if msg.URL, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Request, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Response, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Status, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Timestamp, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Duration, err = ReadUint(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeFetch(reader) case 40: - msg := &Profiler{} - if msg.Name, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Duration, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Args, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Result, err = ReadString(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeProfiler(reader) case 41: - msg := &OTable{} - if msg.Key, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Value, err = ReadString(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeOTable(reader) case 42: - msg := &StateAction{} - if msg.Type, err = ReadString(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeStateAction(reader) case 43: - msg := &StateActionEvent{} - if msg.MessageID, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Timestamp, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Type, err = ReadString(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeStateActionEvent(reader) case 44: - msg := &Redux{} - if msg.Action, err = ReadString(reader); err != nil { - return nil, err - } - if msg.State, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Duration, err = ReadUint(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeRedux(reader) case 45: - msg := &Vuex{} - if msg.Mutation, err = ReadString(reader); err != nil { - return nil, err - } - if msg.State, err = ReadString(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeVuex(reader) case 46: - msg := &MobX{} - if msg.Type, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Payload, err = ReadString(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeMobX(reader) case 47: - msg := &NgRx{} - if msg.Action, err = ReadString(reader); err != nil { - return nil, err - } - if msg.State, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Duration, err = ReadUint(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeNgRx(reader) case 48: - msg := &GraphQL{} - if msg.OperationKind, err = ReadString(reader); err != nil { - return nil, err - } - if msg.OperationName, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Variables, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Response, err = ReadString(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeGraphQL(reader) case 49: - msg := &PerformanceTrack{} - if msg.Frames, err = ReadInt(reader); err != nil { - return nil, err - } - if msg.Ticks, err = ReadInt(reader); err != nil { - return nil, err - } - if msg.TotalJSHeapSize, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.UsedJSHeapSize, err = ReadUint(reader); err != nil { - return nil, err - } - return msg, nil + return DecodePerformanceTrack(reader) case 50: - msg := &GraphQLEvent{} - if msg.MessageID, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Timestamp, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.OperationKind, err = ReadString(reader); err != nil { - return nil, err - } - if msg.OperationName, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Variables, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Response, err = ReadString(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeGraphQLEvent(reader) case 51: - msg := &FetchEvent{} - if msg.MessageID, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Timestamp, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Method, err = ReadString(reader); err != nil { - return nil, err - } - if msg.URL, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Request, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Response, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Status, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Duration, err = ReadUint(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeFetchEvent(reader) case 52: - msg := &DOMDrop{} - if msg.Timestamp, err = ReadUint(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeDOMDrop(reader) case 53: - msg := &ResourceTiming{} - if msg.Timestamp, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Duration, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.TTFB, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.HeaderSize, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.EncodedBodySize, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.DecodedBodySize, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.URL, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Initiator, err = ReadString(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeResourceTiming(reader) case 54: - msg := &ConnectionInformation{} - if msg.Downlink, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Type, err = ReadString(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeConnectionInformation(reader) case 55: - msg := &SetPageVisibility{} - if msg.hidden, err = ReadBoolean(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeSetPageVisibility(reader) case 56: - msg := &PerformanceTrackAggr{} - if msg.TimestampStart, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.TimestampEnd, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.MinFPS, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.AvgFPS, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.MaxFPS, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.MinCPU, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.AvgCPU, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.MaxCPU, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.MinTotalJSHeapSize, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.AvgTotalJSHeapSize, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.MaxTotalJSHeapSize, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.MinUsedJSHeapSize, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.AvgUsedJSHeapSize, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.MaxUsedJSHeapSize, err = ReadUint(reader); err != nil { - return nil, err - } - return msg, nil + return DecodePerformanceTrackAggr(reader) case 59: - msg := &LongTask{} - if msg.Timestamp, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Duration, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Context, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.ContainerType, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.ContainerSrc, err = ReadString(reader); err != nil { - return nil, err - } - if msg.ContainerId, err = ReadString(reader); err != nil { - return nil, err - } - if msg.ContainerName, err = ReadString(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeLongTask(reader) case 60: - msg := &SetNodeAttributeURLBased{} - if msg.ID, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Name, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Value, err = ReadString(reader); err != nil { - return nil, err - } - if msg.BaseURL, err = ReadString(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeSetNodeAttributeURLBased(reader) case 61: - msg := &SetCSSDataURLBased{} - if msg.ID, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Data, err = ReadString(reader); err != nil { - return nil, err - } - if msg.BaseURL, err = ReadString(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeSetCSSDataURLBased(reader) case 62: - msg := &IssueEvent{} - if msg.MessageID, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Timestamp, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Type, err = ReadString(reader); err != nil { - return nil, err - } - if msg.ContextString, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Context, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Payload, err = ReadString(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeIssueEvent(reader) case 63: - msg := &TechnicalInfo{} - if msg.Type, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Value, err = ReadString(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeTechnicalInfo(reader) case 64: - msg := &CustomIssue{} - if msg.Name, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Payload, err = ReadString(reader); err != nil { - return nil, err - } - return msg, nil - - case 65: - msg := &PageClose{} - - return msg, nil + return DecodeCustomIssue(reader) case 66: - msg := &AssetCache{} - if msg.URL, err = ReadString(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeAssetCache(reader) case 67: - msg := &CSSInsertRuleURLBased{} - if msg.ID, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Rule, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Index, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.BaseURL, err = ReadString(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeCSSInsertRuleURLBased(reader) case 69: - msg := &MouseClick{} - if msg.ID, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.HesitationTime, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Label, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Selector, err = ReadString(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeMouseClick(reader) case 70: - msg := &CreateIFrameDocument{} - if msg.FrameID, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.ID, err = ReadUint(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeCreateIFrameDocument(reader) + + case 71: + return DecodeAdoptedSSReplaceURLBased(reader) + + case 72: + return DecodeAdoptedSSReplace(reader) + + case 73: + return DecodeAdoptedSSInsertRuleURLBased(reader) + + case 74: + return DecodeAdoptedSSInsertRule(reader) + + case 75: + return DecodeAdoptedSSDeleteRule(reader) + + case 76: + return DecodeAdoptedSSAddOwner(reader) + + case 77: + return DecodeAdoptedSSRemoveOwner(reader) case 107: - msg := &IOSBatchMeta{} - if msg.Timestamp, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Length, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.FirstIndex, err = ReadUint(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeIOSBatchMeta(reader) case 90: - msg := &IOSSessionStart{} - if msg.Timestamp, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.ProjectID, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.TrackerVersion, err = ReadString(reader); err != nil { - return nil, err - } - if msg.RevID, err = ReadString(reader); err != nil { - return nil, err - } - if msg.UserUUID, err = ReadString(reader); err != nil { - return nil, err - } - if msg.UserOS, err = ReadString(reader); err != nil { - return nil, err - } - if msg.UserOSVersion, err = ReadString(reader); err != nil { - return nil, err - } - if msg.UserDevice, err = ReadString(reader); err != nil { - return nil, err - } - if msg.UserDeviceType, err = ReadString(reader); err != nil { - return nil, err - } - if msg.UserCountry, err = ReadString(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeIOSSessionStart(reader) case 91: - msg := &IOSSessionEnd{} - if msg.Timestamp, err = ReadUint(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeIOSSessionEnd(reader) case 92: - msg := &IOSMetadata{} - if msg.Timestamp, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Length, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Key, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Value, err = ReadString(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeIOSMetadata(reader) case 93: - msg := &IOSCustomEvent{} - if msg.Timestamp, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Length, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Name, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Payload, err = ReadString(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeIOSCustomEvent(reader) case 94: - msg := &IOSUserID{} - if msg.Timestamp, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Length, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Value, err = ReadString(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeIOSUserID(reader) case 95: - msg := &IOSUserAnonymousID{} - if msg.Timestamp, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Length, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Value, err = ReadString(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeIOSUserAnonymousID(reader) case 96: - msg := &IOSScreenChanges{} - if msg.Timestamp, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Length, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.X, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Y, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Width, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Height, err = ReadUint(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeIOSScreenChanges(reader) case 97: - msg := &IOSCrash{} - if msg.Timestamp, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Length, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Name, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Reason, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Stacktrace, err = ReadString(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeIOSCrash(reader) case 98: - msg := &IOSScreenEnter{} - if msg.Timestamp, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Length, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Title, err = ReadString(reader); err != nil { - return nil, err - } - if msg.ViewName, err = ReadString(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeIOSScreenEnter(reader) case 99: - msg := &IOSScreenLeave{} - if msg.Timestamp, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Length, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Title, err = ReadString(reader); err != nil { - return nil, err - } - if msg.ViewName, err = ReadString(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeIOSScreenLeave(reader) case 100: - msg := &IOSClickEvent{} - if msg.Timestamp, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Length, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Label, err = ReadString(reader); err != nil { - return nil, err - } - if msg.X, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Y, err = ReadUint(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeIOSClickEvent(reader) case 101: - msg := &IOSInputEvent{} - if msg.Timestamp, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Length, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Value, err = ReadString(reader); err != nil { - return nil, err - } - if msg.ValueMasked, err = ReadBoolean(reader); err != nil { - return nil, err - } - if msg.Label, err = ReadString(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeIOSInputEvent(reader) case 102: - msg := &IOSPerformanceEvent{} - if msg.Timestamp, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Length, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Name, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Value, err = ReadUint(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeIOSPerformanceEvent(reader) case 103: - msg := &IOSLog{} - if msg.Timestamp, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Length, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Severity, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Content, err = ReadString(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeIOSLog(reader) case 104: - msg := &IOSInternalError{} - if msg.Timestamp, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Length, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Content, err = ReadString(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeIOSInternalError(reader) case 105: - msg := &IOSNetworkCall{} - if msg.Timestamp, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Length, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Duration, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Headers, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Body, err = ReadString(reader); err != nil { - return nil, err - } - if msg.URL, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Success, err = ReadBoolean(reader); err != nil { - return nil, err - } - if msg.Method, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Status, err = ReadUint(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeIOSNetworkCall(reader) case 110: - msg := &IOSPerformanceAggregated{} - if msg.TimestampStart, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.TimestampEnd, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.MinFPS, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.AvgFPS, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.MaxFPS, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.MinCPU, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.AvgCPU, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.MaxCPU, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.MinMemory, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.AvgMemory, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.MaxMemory, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.MinBattery, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.AvgBattery, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.MaxBattery, err = ReadUint(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeIOSPerformanceAggregated(reader) case 111: - msg := &IOSIssueEvent{} - if msg.Timestamp, err = ReadUint(reader); err != nil { - return nil, err - } - if msg.Type, err = ReadString(reader); err != nil { - return nil, err - } - if msg.ContextString, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Context, err = ReadString(reader); err != nil { - return nil, err - } - if msg.Payload, err = ReadString(reader); err != nil { - return nil, err - } - return msg, nil + return DecodeIOSIssueEvent(reader) } return nil, fmt.Errorf("Unknown message code: %v", t) diff --git a/backend/pkg/queue/messages.go b/backend/pkg/queue/messages.go index 2da62ac6e..f52813492 100644 --- a/backend/pkg/queue/messages.go +++ b/backend/pkg/queue/messages.go @@ -1,19 +1,12 @@ package queue import ( - "bytes" - "log" - "openreplay/backend/pkg/messages" "openreplay/backend/pkg/queue/types" ) -func NewMessageConsumer(group string, topics []string, handler types.DecodedMessageHandler, autoCommit bool, messageSizeLimit int) types.Consumer { +func NewMessageConsumer(group string, topics []string, handler types.RawMessageHandler, autoCommit bool, messageSizeLimit int) types.Consumer { return NewConsumer(group, topics, func(sessionID uint64, value []byte, meta *types.Meta) { - if err := messages.ReadBatchReader(bytes.NewReader(value), func(msg messages.Message) { - handler(sessionID, msg, meta) - }); err != nil { - log.Printf("Decode error: %v\n", err) - } + handler(sessionID, messages.NewIterator(value), meta) }, autoCommit, messageSizeLimit) } diff --git a/backend/pkg/queue/types/types.go b/backend/pkg/queue/types/types.go index f1e90e184..aaf6f7afa 100644 --- a/backend/pkg/queue/types/types.go +++ b/backend/pkg/queue/types/types.go @@ -26,3 +26,4 @@ type Meta struct { type MessageHandler func(uint64, []byte, *Meta) type DecodedMessageHandler func(uint64, messages.Message, *Meta) +type RawMessageHandler func(uint64, messages.Iterator, *Meta) diff --git a/backend/pkg/storage/s3.go b/backend/pkg/storage/s3.go index 67ebaaf4f..2e97673d3 100644 --- a/backend/pkg/storage/s3.go +++ b/backend/pkg/storage/s3.go @@ -6,6 +6,7 @@ import ( "os" "sort" "strconv" + "time" _s3 "github.com/aws/aws-sdk-go/service/s3" "github.com/aws/aws-sdk-go/service/s3/s3manager" @@ -71,6 +72,17 @@ func (s3 *S3) Exists(key string) bool { return false } +func (s3 *S3) GetCreationTime(key string) *time.Time { + ans, err := s3.svc.HeadObject(&_s3.HeadObjectInput{ + Bucket: s3.bucket, + Key: &key, + }) + if err != nil { + return nil + } + return ans.LastModified +} + const MAX_RETURNING_COUNT = 40 func (s3 *S3) GetFrequentlyUsedKeys(projectID uint64) ([]string, error) { diff --git a/backend/pkg/url/assets/url.go b/backend/pkg/url/assets/url.go index adb26e0aa..83cdd5ac1 100644 --- a/backend/pkg/url/assets/url.go +++ b/backend/pkg/url/assets/url.go @@ -14,7 +14,7 @@ func getSessionKey(sessionID uint64) string { return strconv.FormatUint( uint64(time.UnixMilli( int64(flakeid.ExtractTimestamp(sessionID)), - ).Weekday()), + ).Day()), 10, ) } diff --git a/ee/api/.gitignore b/ee/api/.gitignore index 12a468ef1..1afe8462f 100644 --- a/ee/api/.gitignore +++ b/ee/api/.gitignore @@ -177,11 +177,16 @@ chalicelib/saas README/* Pipfile +.local/* + /chalicelib/core/alerts.py /chalicelib/core/alerts_processor.py /chalicelib/core/announcements.py +/chalicelib/core/autocomplete.py /chalicelib/core/collaboration_slack.py -/chalicelib/core/errors_favorite_viewed.py +/chalicelib/core/countries.py +/chalicelib/core/errors.py +/chalicelib/core/errors_favorite.py /chalicelib/core/events.py /chalicelib/core/events_ios.py /chalicelib/core/funnels.py @@ -257,4 +262,4 @@ Pipfile /build_alerts.sh /routers/subs/metrics.py /routers/subs/v1_api.py -/chalicelib/core/dashboards.py \ No newline at end of file +/chalicelib/core/dashboards.py diff --git a/ee/api/Dockerfile b/ee/api/Dockerfile index c073684c6..dad5fa20d 100644 --- a/ee/api/Dockerfile +++ b/ee/api/Dockerfile @@ -1,7 +1,6 @@ FROM python:3.10-alpine LABEL Maintainer="Rajesh Rajendran" LABEL Maintainer="KRAIEM Taha Yassine" -RUN apk upgrade busybox --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/main RUN apk add --no-cache build-base libressl libffi-dev libressl-dev libxslt-dev libxml2-dev xmlsec-dev xmlsec nodejs npm tini ARG envarg ENV SOURCE_MAP_VERSION=0.7.4 \ diff --git a/ee/api/Dockerfile.alerts b/ee/api/Dockerfile.alerts index 3b16c0d7d..02bac9350 100644 --- a/ee/api/Dockerfile.alerts +++ b/ee/api/Dockerfile.alerts @@ -1,7 +1,6 @@ FROM python:3.10-alpine LABEL Maintainer="Rajesh Rajendran" LABEL Maintainer="KRAIEM Taha Yassine" -RUN apk upgrade busybox --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/main RUN apk add --no-cache build-base tini ARG envarg ENV APP_NAME=alerts \ diff --git a/ee/api/Dockerfile.crons b/ee/api/Dockerfile.crons index a139cea80..83b3085e0 100644 --- a/ee/api/Dockerfile.crons +++ b/ee/api/Dockerfile.crons @@ -1,7 +1,6 @@ FROM python:3.10-alpine LABEL Maintainer="Rajesh Rajendran" LABEL Maintainer="KRAIEM Taha Yassine" -RUN apk upgrade busybox --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/main RUN apk add --no-cache build-base tini ARG envarg ENV APP_NAME=crons \ diff --git a/ee/api/app.py b/ee/api/app.py index 1e12e6015..9f2f9a306 100644 --- a/ee/api/app.py +++ b/ee/api/app.py @@ -5,18 +5,20 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler from decouple import config from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware +from fastapi.middleware.gzip import GZipMiddleware from starlette import status from starlette.responses import StreamingResponse, JSONResponse from chalicelib.utils import helper from chalicelib.utils import pg_client from routers import core, core_dynamic, ee, saml -from routers.subs import v1_api from routers.crons import core_crons from routers.crons import core_dynamic_crons from routers.subs import dashboard, insights, metrics, v1_api_ee +from routers.subs import v1_api app = FastAPI(root_path="/api", docs_url=config("docs_url", default=""), redoc_url=config("redoc_url", default="")) +app.add_middleware(GZipMiddleware, minimum_size=1000) @app.middleware('http') diff --git a/ee/api/auth/auth_project.py b/ee/api/auth/auth_project.py index c1e1d38cd..2c78041e0 100644 --- a/ee/api/auth/auth_project.py +++ b/ee/api/auth/auth_project.py @@ -15,13 +15,15 @@ class ProjectAuthorizer: if len(request.path_params.keys()) == 0 or request.path_params.get(self.project_identifier) is None: return current_user: schemas.CurrentContext = await OR_context(request) - project_identifier = request.path_params[self.project_identifier] + value = request.path_params[self.project_identifier] user_id = current_user.user_id if request.state.authorizer_identity == "jwt" else None if (self.project_identifier == "projectId" \ - and not projects.is_authorized(project_id=project_identifier, tenant_id=current_user.tenant_id, + and not projects.is_authorized(project_id=value, tenant_id=current_user.tenant_id, user_id=user_id)) \ - or (self.project_identifier.lower() == "projectKey" \ - and not projects.is_authorized(project_id=projects.get_internal_project_id(project_identifier), - tenant_id=current_user.tenant_id, user_id=user_id)): + or (self.project_identifier == "projectKey" \ + and not projects.is_authorized( + project_id=projects.get_internal_project_id(value), + tenant_id=current_user.tenant_id, user_id=user_id)): print("unauthorized project") + print(value) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized project.") diff --git a/ee/api/build_crons.sh b/ee/api/build_crons.sh index 32bc7ec7f..810c1b4b5 100644 --- a/ee/api/build_crons.sh +++ b/ee/api/build_crons.sh @@ -18,6 +18,8 @@ check_prereq() { } function build_api(){ + cp -R ../api ../_crons + cd ../_crons tag="" # Copy enterprise code @@ -25,15 +27,15 @@ function build_api(){ envarg="default-ee" tag="ee-" - cp -R ../api ../_crons - docker build -f ../_crons/Dockerfile.crons --build-arg envarg=$envarg -t ${DOCKER_REPO:-'local'}/crons:${git_sha1} . - rm -rf ../crons + docker build -f ./Dockerfile.crons --build-arg envarg=$envarg -t ${DOCKER_REPO:-'local'}/crons:${git_sha1} . + cd ../api + rm -rf ../_crons [[ $PUSH_IMAGE -eq 1 ]] && { docker push ${DOCKER_REPO:-'local'}/crons:${git_sha1} docker tag ${DOCKER_REPO:-'local'}/crons:${git_sha1} ${DOCKER_REPO:-'local'}/crons:${tag}latest docker push ${DOCKER_REPO:-'local'}/crons:${tag}latest } -echo "completed crons build" + echo "completed crons build" } check_prereq diff --git a/ee/api/chalicelib/core/__init__.py b/ee/api/chalicelib/core/__init__.py index e69de29bb..602a54998 100644 --- a/ee/api/chalicelib/core/__init__.py +++ b/ee/api/chalicelib/core/__init__.py @@ -0,0 +1,38 @@ +from decouple import config +import logging + +logging.basicConfig(level=config("LOGLEVEL", default=logging.INFO)) + +if config("EXP_SESSIONS_SEARCH", cast=bool, default=False): + print(">>> Using experimental sessions search") + from . import sessions as sessions_legacy + from . import sessions_exp as sessions +else: + from . import sessions as sessions + +if config("EXP_AUTOCOMPLETE", cast=bool, default=False): + print(">>> Using experimental autocomplete") + from . import autocomplete_exp as autocomplete +else: + from . import autocomplete as autocomplete + +if config("EXP_ERRORS_SEARCH", cast=bool, default=False): + print(">>> Using experimental error search") + from . import errors_exp as errors +else: + from . import errors as errors + +if config("EXP_METRICS", cast=bool, default=False): + print(">>> Using experimental metrics") + from . import metrics_exp as metrics +else: + from . import metrics as metrics + +if config("EXP_ALERTS", cast=bool, default=False): + print(">>> Using experimental alerts") + from . import alerts_processor_exp as alerts_processor +else: + from . import alerts_processor as alerts_processor + + +from . import significance_exp as significance diff --git a/ee/api/chalicelib/core/alerts_listener.py b/ee/api/chalicelib/core/alerts_listener.py index 40241f51e..6a97daf93 100644 --- a/ee/api/chalicelib/core/alerts_listener.py +++ b/ee/api/chalicelib/core/alerts_listener.py @@ -12,7 +12,8 @@ def get_all_alerts(): (EXTRACT(EPOCH FROM alerts.created_at) * 1000)::BIGINT AS created_at, alerts.name, alerts.series_id, - filter + filter, + change FROM public.alerts LEFT JOIN metric_series USING (series_id) INNER JOIN projects USING (project_id) diff --git a/ee/api/chalicelib/core/alerts_processor_exp.py b/ee/api/chalicelib/core/alerts_processor_exp.py new file mode 100644 index 000000000..7a300654c --- /dev/null +++ b/ee/api/chalicelib/core/alerts_processor_exp.py @@ -0,0 +1,224 @@ +import logging + +from decouple import config + +import schemas +from chalicelib.core import alerts_listener, alerts_processor +from chalicelib.core import sessions, alerts +from chalicelib.utils import pg_client, ch_client, exp_ch_helper +from chalicelib.utils.TimeUTC import TimeUTC + +logging.basicConfig(level=config("LOGLEVEL", default=logging.INFO)) + +LeftToDb = { + schemas.AlertColumn.performance__dom_content_loaded__average: { + "table": lambda timestamp: f"{exp_ch_helper.get_main_events_table(timestamp)} AS pages", + "formula": "COALESCE(AVG(NULLIF(dom_content_loaded_event_time ,0)),0)", + "eventType": "LOCATION" + }, + schemas.AlertColumn.performance__first_meaningful_paint__average: { + "table": lambda timestamp: f"{exp_ch_helper.get_main_events_table(timestamp)} AS pages", + "formula": "COALESCE(AVG(NULLIF(first_contentful_paint_time,0)),0)", + "eventType": "LOCATION" + }, + schemas.AlertColumn.performance__page_load_time__average: { + "table": lambda timestamp: f"{exp_ch_helper.get_main_events_table(timestamp)} AS pages", + "formula": "AVG(NULLIF(load_event_time ,0))", + "eventType": "LOCATION" + }, + schemas.AlertColumn.performance__dom_build_time__average: { + "table": lambda timestamp: f"{exp_ch_helper.get_main_events_table(timestamp)} AS pages", + "formula": "AVG(NULLIF(dom_building_time,0))", + "eventType": "LOCATION" + }, + schemas.AlertColumn.performance__speed_index__average: { + "table": lambda timestamp: f"{exp_ch_helper.get_main_events_table(timestamp)} AS pages", + "formula": "AVG(NULLIF(speed_index,0))", + "eventType": "LOCATION" + }, + schemas.AlertColumn.performance__page_response_time__average: { + "table": lambda timestamp: f"{exp_ch_helper.get_main_events_table(timestamp)} AS pages", + "formula": "AVG(NULLIF(response_time,0))", + "eventType": "LOCATION" + }, + schemas.AlertColumn.performance__ttfb__average: { + "table": lambda timestamp: f"{exp_ch_helper.get_main_events_table(timestamp)} AS pages", + "formula": "AVG(NULLIF(first_contentful_paint_time,0))", + "eventType": "LOCATION" + }, + schemas.AlertColumn.performance__time_to_render__average: { + "table": lambda timestamp: f"{exp_ch_helper.get_main_events_table(timestamp)} AS pages", + "formula": "AVG(NULLIF(visually_complete,0))", + "eventType": "LOCATION" + }, + schemas.AlertColumn.performance__image_load_time__average: { + "table": lambda timestamp: f"{exp_ch_helper.get_main_resources_table(timestamp)} AS resources", + "formula": "AVG(NULLIF(resources.duration,0))", + "condition": "type='img'" + }, + schemas.AlertColumn.performance__request_load_time__average: { + "table": lambda timestamp: f"{exp_ch_helper.get_main_resources_table(timestamp)} AS resources", + "formula": "AVG(NULLIF(resources.duration,0))", + "condition": "type='fetch'" + }, + schemas.AlertColumn.resources__load_time__average: { + "table": lambda timestamp: f"{exp_ch_helper.get_main_resources_table(timestamp)} AS resources", + "formula": "AVG(NULLIF(resources.duration,0))" + }, + schemas.AlertColumn.resources__missing__count: { + "table": lambda timestamp: f"{exp_ch_helper.get_main_resources_table(timestamp)} AS resources", + "formula": "COUNT(DISTINCT url_hostpath)", + "condition": "success= FALSE AND type='img'" + }, + schemas.AlertColumn.errors__4xx_5xx__count: { + "table": lambda timestamp: f"{exp_ch_helper.get_main_events_table(timestamp)} AS requests", + "eventType": "REQUEST", + "formula": "COUNT(1)", + "condition": "intDiv(requests.status, 100)!=2" + }, + schemas.AlertColumn.errors__4xx__count: { + "table": lambda timestamp: f"{exp_ch_helper.get_main_events_table(timestamp)} AS requests", + "eventType": "REQUEST", + "formula": "COUNT(1)", + "condition": "intDiv(requests.status, 100)==4" + }, + schemas.AlertColumn.errors__5xx__count: { + "table": lambda timestamp: f"{exp_ch_helper.get_main_events_table(timestamp)} AS requests", + "eventType": "REQUEST", + "formula": "COUNT(1)", + "condition": "intDiv(requests.status, 100)==5" + }, + schemas.AlertColumn.errors__javascript__impacted_sessions__count: { + "table": lambda timestamp: f"{exp_ch_helper.get_main_events_table(timestamp)} AS errors", + "eventType": "ERROR", + "formula": "COUNT(DISTINCT session_id)", + "condition": "source='js_exception'" + }, + schemas.AlertColumn.performance__crashes__count: { + "table": lambda timestamp: f"{exp_ch_helper.get_main_sessions_table(timestamp)} AS sessions", + "formula": "COUNT(DISTINCT session_id)", + "condition": "duration>0 AND errors_count>0" + }, + schemas.AlertColumn.errors__javascript__count: { + "table": lambda timestamp: f"{exp_ch_helper.get_main_events_table(timestamp)} AS errors", + "eventType": "ERROR", + "formula": "COUNT(DISTINCT session_id)", + "condition": "source='js_exception'" + }, + schemas.AlertColumn.errors__backend__count: { + "table": lambda timestamp: f"{exp_ch_helper.get_main_events_table(timestamp)} AS errors", + "eventType": "ERROR", + "formula": "COUNT(DISTINCT session_id)", + "condition": "source!='js_exception'" + }, +} + + +def Build(a): + now = TimeUTC.now() + params = {"project_id": a["projectId"], "now": now} + full_args = {} + if a["seriesId"] is not None: + a["filter"]["sort"] = "session_id" + a["filter"]["order"] = schemas.SortOrderType.desc + a["filter"]["startDate"] = -1 + a["filter"]["endDate"] = TimeUTC.now() + full_args, query_part = sessions.search_query_parts_ch( + data=schemas.SessionsSearchPayloadSchema.parse_obj(a["filter"]), error_status=None, errors_only=False, + issue=None, project_id=a["projectId"], user_id=None, favorite_only=False) + subQ = f"""SELECT COUNT(session_id) AS value + {query_part}""" + else: + colDef = LeftToDb[a["query"]["left"]] + params["event_type"] = LeftToDb[a["query"]["left"]].get("eventType") + subQ = f"""SELECT {colDef["formula"]} AS value + FROM {colDef["table"](now)} + WHERE project_id = %(project_id)s + {"AND event_type=%(event_type)s" if params["event_type"] else ""} + {"AND " + colDef["condition"] if colDef.get("condition") is not None else ""}""" + + q = f"""SELECT coalesce(value,0) AS value, coalesce(value,0) {a["query"]["operator"]} {a["query"]["right"]} AS valid""" + + if a["detectionMethod"] == schemas.AlertDetectionMethod.threshold: + if a["seriesId"] is not None: + q += f""" FROM ({subQ}) AS stat""" + else: + q += f""" FROM ({subQ} + AND datetime>=toDateTime(%(startDate)s/1000) + AND datetime<=toDateTime(%(now)s/1000) ) AS stat""" + params = {**params, **full_args, "startDate": TimeUTC.now() - a["options"]["currentPeriod"] * 60 * 1000} + else: + if a["change"] == schemas.AlertDetectionType.change: + if a["seriesId"] is not None: + sub2 = subQ.replace("%(startDate)s", "%(timestamp_sub2)s").replace("%(endDate)s", "%(startDate)s") + sub1 = f"SELECT (({subQ})-({sub2})) AS value" + q += f" FROM ( {sub1} ) AS stat" + params = {**params, **full_args, + "startDate": TimeUTC.now() - a["options"]["currentPeriod"] * 60 * 1000, + "timestamp_sub2": TimeUTC.now() - 2 * a["options"]["currentPeriod"] * 60 * 1000} + else: + sub1 = f"""{subQ} AND datetime>=toDateTime(%(startDate)s/1000) + AND datetime<=toDateTime(%(now)s/1000)""" + params["startDate"] = TimeUTC.now() - a["options"]["currentPeriod"] * 60 * 1000 + sub2 = f"""{subQ} AND datetime=toDateTime(%(timestamp_sub2)s/1000)""" + params["timestamp_sub2"] = TimeUTC.now() - 2 * a["options"]["currentPeriod"] * 60 * 1000 + sub1 = f"SELECT (( {sub1} )-( {sub2} )) AS value" + q += f" FROM ( {sub1} ) AS stat" + + else: + if a["seriesId"] is not None: + sub2 = subQ.replace("%(startDate)s", "%(timestamp_sub2)s").replace("%(endDate)s", "%(startDate)s") + sub1 = f"SELECT (({subQ})/NULLIF(({sub2}),0)-1)*100 AS value" + q += f" FROM ({sub1}) AS stat" + params = {**params, **full_args, + "startDate": TimeUTC.now() - a["options"]["currentPeriod"] * 60 * 1000, + "timestamp_sub2": TimeUTC.now() \ + - (a["options"]["currentPeriod"] + a["options"]["currentPeriod"]) \ + * 60 * 1000} + else: + sub1 = f"""{subQ} AND datetime>=toDateTime(%(startDate)s/1000) + AND datetime<=toDateTime(%(now)s/1000)""" + params["startDate"] = TimeUTC.now() - a["options"]["currentPeriod"] * 60 * 1000 + sub2 = f"""{subQ} AND datetime=toDateTime(%(timestamp_sub2)s/1000)""" + params["timestamp_sub2"] = TimeUTC.now() \ + - (a["options"]["currentPeriod"] + a["options"]["currentPeriod"]) * 60 * 1000 + sub1 = f"SELECT (({sub1})/NULLIF(({sub2}),0)-1)*100 AS value" + q += f" FROM ({sub1}) AS stat" + + return q, params + + +def process(): + notifications = [] + all_alerts = alerts_listener.get_all_alerts() + with pg_client.PostgresClient() as cur, ch_client.ClickHouseClient() as ch_cur: + for alert in all_alerts: + if alert["query"]["left"] != "CUSTOM": + continue + if alerts_processor.can_check(alert): + logging.info(f"Querying alertId:{alert['alertId']} name: {alert['name']}") + query, params = Build(alert) + query = ch_cur.format(query, params) + logging.debug(alert) + logging.debug(query) + try: + result = ch_cur.execute(query) + if len(result) > 0: + result = result[0] + + if result["valid"]: + logging.info("Valid alert, notifying users") + notifications.append(alerts_processor.generate_notification(alert, result)) + except Exception as e: + logging.error(f"!!!Error while running alert query for alertId:{alert['alertId']}") + logging.error(str(e)) + logging.error(query) + if len(notifications) > 0: + cur.execute( + cur.mogrify(f"""UPDATE public.alerts + SET options = options||'{{"lastNotification":{TimeUTC.now()}}}'::jsonb + WHERE alert_id IN %(ids)s;""", {"ids": tuple([n["alertId"] for n in notifications])})) + if len(notifications) > 0: + alerts.process_notifications(notifications) diff --git a/ee/api/chalicelib/core/autocomplete_exp.py b/ee/api/chalicelib/core/autocomplete_exp.py new file mode 100644 index 000000000..db2ecb95b --- /dev/null +++ b/ee/api/chalicelib/core/autocomplete_exp.py @@ -0,0 +1,107 @@ +import schemas +from chalicelib.utils import ch_client +from chalicelib.utils import helper +from chalicelib.utils.event_filter_definition import Event + +TABLE = "final.autocomplete" + + +def __get_autocomplete_table(value, project_id): + autocomplete_events = [schemas.FilterType.rev_id, + schemas.EventType.click, + schemas.FilterType.user_device, + schemas.FilterType.user_id, + schemas.FilterType.user_browser, + schemas.FilterType.user_os, + schemas.EventType.custom, + schemas.FilterType.user_country, + schemas.EventType.location, + schemas.EventType.input] + autocomplete_events.sort() + sub_queries = [] + for e in autocomplete_events: + sub_queries.append(f"""(SELECT type, value + FROM {TABLE} + WHERE project_id = %(project_id)s + AND type= '{e}' + AND value ILIKE %(svalue)s + ORDER BY value + LIMIT 5)""") + if len(value) > 2: + sub_queries.append(f"""(SELECT type, value + FROM {TABLE} + WHERE project_id = %(project_id)s + AND type= '{e}' + AND value ILIKE %(value)s + ORDER BY value + LIMIT 5)""") + with ch_client.ClickHouseClient() as cur: + query = " UNION DISTINCT ".join(sub_queries) + ";" + params = {"project_id": project_id, "value": helper.string_to_sql_like(value), + "svalue": helper.string_to_sql_like("^" + value)} + results = [] + try: + results = cur.execute(query=query, params=params) + except Exception as err: + print("--------- CH AUTOCOMPLETE SEARCH QUERY EXCEPTION -----------") + print(cur.format(query=query, params=params)) + print("--------- PARAMS -----------") + print(params) + print("--------- VALUE -----------") + print(value) + print("--------------------") + raise err + return results + + +def __generic_query(typename, value_length=None): + if value_length is None or value_length > 2: + return f"""(SELECT DISTINCT value, type + FROM {TABLE} + WHERE + project_id = %(project_id)s + AND type='{typename}' + AND value ILIKE %(svalue)s + ORDER BY value + LIMIT 5) + UNION DISTINCT + (SELECT DISTINCT value, type + FROM {TABLE} + WHERE + project_id = %(project_id)s + AND type='{typename}' + AND value ILIKE %(value)s + ORDER BY value + LIMIT 5);""" + return f"""SELECT DISTINCT value, type + FROM {TABLE} + WHERE + project_id = %(project_id)s + AND type='{typename}' + AND value ILIKE %(svalue)s + ORDER BY value + LIMIT 10;""" + + +def __generic_autocomplete(event: Event): + def f(project_id, value, key=None, source=None): + with ch_client.ClickHouseClient() as cur: + query = __generic_query(event.ui_type, value_length=len(value)) + params = {"project_id": project_id, "value": helper.string_to_sql_like(value), + "svalue": helper.string_to_sql_like("^" + value)} + results = cur.execute(query=query, params=params) + return helper.list_to_camel_case(results) + + return f + + +def __generic_autocomplete_metas(typename): + def f(project_id, text): + with ch_client.ClickHouseClient() as cur: + query = __generic_query(typename, value_length=len(text)) + params = {"project_id": project_id, "value": helper.string_to_sql_like(text), + "svalue": helper.string_to_sql_like("^" + text)} + results = cur.execute(query=query, params=params) + return results + + return f diff --git a/ee/api/chalicelib/core/errors.py b/ee/api/chalicelib/core/errors_exp.py similarity index 68% rename from ee/api/chalicelib/core/errors.py rename to ee/api/chalicelib/core/errors_exp.py index 07a5e10ba..7014a16e0 100644 --- a/ee/api/chalicelib/core/errors.py +++ b/ee/api/chalicelib/core/errors_exp.py @@ -1,13 +1,59 @@ import json import schemas -from chalicelib.core import metrics +from chalicelib.core import metrics, metadata from chalicelib.core import sourcemaps, sessions -from chalicelib.utils import ch_client, metrics_helper +from chalicelib.utils import ch_client, metrics_helper, exp_ch_helper from chalicelib.utils import pg_client, helper from chalicelib.utils.TimeUTC import TimeUTC +def _multiple_values(values, value_key="value"): + query_values = {} + if values is not None and isinstance(values, list): + for i in range(len(values)): + k = f"{value_key}_{i}" + query_values[k] = values[i] + return query_values + + +def __get_sql_operator(op: schemas.SearchEventOperator): + return { + schemas.SearchEventOperator._is: "=", + schemas.SearchEventOperator._is_any: "IN", + schemas.SearchEventOperator._on: "=", + schemas.SearchEventOperator._on_any: "IN", + schemas.SearchEventOperator._is_not: "!=", + schemas.SearchEventOperator._not_on: "!=", + schemas.SearchEventOperator._contains: "ILIKE", + schemas.SearchEventOperator._not_contains: "NOT ILIKE", + schemas.SearchEventOperator._starts_with: "ILIKE", + schemas.SearchEventOperator._ends_with: "ILIKE", + }.get(op, "=") + + +def _isAny_opreator(op: schemas.SearchEventOperator): + return op in [schemas.SearchEventOperator._on_any, schemas.SearchEventOperator._is_any] + + +def _isUndefined_operator(op: schemas.SearchEventOperator): + return op in [schemas.SearchEventOperator._is_undefined] + + +def __is_negation_operator(op: schemas.SearchEventOperator): + return op in [schemas.SearchEventOperator._is_not, + schemas.SearchEventOperator._not_on, + schemas.SearchEventOperator._not_contains] + + +def _multiple_conditions(condition, values, value_key="value", is_not=False): + query = [] + for i in range(len(values)): + k = f"{value_key}_{i}" + query.append(condition.replace(value_key, k)) + return "(" + (" AND " if is_not else " OR ").join(query) + ")" + + def get(error_id, family=False): if family: return get_batch([error_id]) @@ -263,10 +309,7 @@ def get_details(project_id, error_id, user_id, **data): parent_error_id,session_id, user_anonymous_id, user_id, user_uuid, user_browser, user_browser_version, user_os, user_os_version, user_device, payload, - COALESCE((SELECT TRUE - FROM public.user_favorite_errors AS fe - WHERE pe.error_id = fe.error_id - AND fe.user_id = %(userId)s), FALSE) AS favorite, + FALSE AS favorite, True AS viewed FROM public.errors AS pe INNER JOIN events.errors AS ee USING (error_id) @@ -420,8 +463,10 @@ def get_details_chart(project_id, error_id, user_id, **data): def __get_basic_constraints(platform=None, time_constraint=True, startTime_arg_name="startDate", - endTime_arg_name="endDate"): + endTime_arg_name="endDate", type_condition=True): ch_sub_query = ["project_id =toUInt32(%(project_id)s)"] + if type_condition: + ch_sub_query.append("event_type='ERROR'") if time_constraint: ch_sub_query += [f"datetime >= toDateTime(%({startTime_arg_name})s/1000)", f"datetime < toDateTime(%({endTime_arg_name})s/1000)"] @@ -465,214 +510,213 @@ def __get_basic_constraints_pg(platform=None, time_constraint=True, startTime_ar return ch_sub_query -def search(data: schemas.SearchErrorsSchema, project_id, user_id, flows=False): - empty_response = {'total': 0, - 'errors': [] - } +def search(data: schemas.SearchErrorsSchema, project_id, user_id): + MAIN_EVENTS_TABLE = exp_ch_helper.get_main_events_table(data.startDate) + MAIN_SESSIONS_TABLE = exp_ch_helper.get_main_sessions_table(data.startDate) platform = None for f in data.filters: if f.type == schemas.FilterType.platform and len(f.value) > 0: platform = f.value[0] - pg_sub_query = __get_basic_constraints_pg(platform, project_key="sessions.project_id") - pg_sub_query += ["sessions.start_ts>=%(startDate)s", "sessions.start_ts<%(endDate)s", "source ='js_exception'", - "pe.project_id=%(project_id)s"] - # To ignore Script error - pg_sub_query.append("pe.message!='Script error.'") - pg_sub_query_chart = __get_basic_constraints_pg(platform, time_constraint=False, chart=True, project_key=None) - # pg_sub_query_chart.append("source ='js_exception'") - pg_sub_query_chart.append("errors.error_id =details.error_id") - statuses = [] - error_ids = None - if data.startDate is None: - data.startDate = TimeUTC.now(-30) - if data.endDate is None: - data.endDate = TimeUTC.now(1) - if len(data.events) > 0 or len(data.filters) > 0: - print("-- searching for sessions before errors") - # if favorite_only=True search for sessions associated with favorite_error - statuses = sessions.search2_pg(data=data, project_id=project_id, user_id=user_id, errors_only=True, - error_status=data.status) - if len(statuses) == 0: - return empty_response - error_ids = [e["errorId"] for e in statuses] - with pg_client.PostgresClient() as cur: - if data.startDate is None: - data.startDate = TimeUTC.now(-7) - if data.endDate is None: - data.endDate = TimeUTC.now() - step_size = metrics_helper.__get_step_size(data.startDate, data.endDate, data.density, factor=1) - sort = __get_sort_key('datetime') - if data.sort is not None: - sort = __get_sort_key(data.sort) - order = "DESC" - if data.order is not None: - order = data.order - extra_join = "" - - params = { - "startDate": data.startDate, - "endDate": data.endDate, - "project_id": project_id, - "userId": user_id, - "step_size": step_size} - if data.status != schemas.ErrorStatus.all: - pg_sub_query.append("status = %(error_status)s") - params["error_status"] = data.status - if data.limit is not None and data.page is not None: - params["errors_offset"] = (data.page - 1) * data.limit - params["errors_limit"] = data.limit - else: - params["errors_offset"] = 0 - params["errors_limit"] = 200 - - if error_ids is not None: - params["error_ids"] = tuple(error_ids) - pg_sub_query.append("error_id IN %(error_ids)s") - if data.bookmarked: - pg_sub_query.append("ufe.user_id = %(userId)s") - extra_join += " INNER JOIN public.user_favorite_errors AS ufe USING (error_id)" - if data.query is not None and len(data.query) > 0: - pg_sub_query.append("(pe.name ILIKE %(error_query)s OR pe.message ILIKE %(error_query)s)") - params["error_query"] = helper.values_for_operator(value=data.query, - op=schemas.SearchEventOperator._contains) - - main_pg_query = f"""SELECT full_count, - error_id, - name, - message, - users, - sessions, - last_occurrence, - first_occurrence, - chart - FROM (SELECT COUNT(details) OVER () AS full_count, details.* - FROM (SELECT error_id, - name, - message, - COUNT(DISTINCT user_uuid) AS users, - COUNT(DISTINCT session_id) AS sessions, - MAX(timestamp) AS max_datetime, - MIN(timestamp) AS min_datetime - FROM events.errors - INNER JOIN public.errors AS pe USING (error_id) - INNER JOIN public.sessions USING (session_id) - {extra_join} - WHERE {" AND ".join(pg_sub_query)} - GROUP BY error_id, name, message - ORDER BY {sort} {order}) AS details - LIMIT %(errors_limit)s OFFSET %(errors_offset)s - ) AS details - INNER JOIN LATERAL (SELECT MAX(timestamp) AS last_occurrence, - MIN(timestamp) AS first_occurrence - FROM events.errors - WHERE errors.error_id = details.error_id) AS time_details ON (TRUE) - INNER JOIN LATERAL (SELECT jsonb_agg(chart_details) AS chart - FROM (SELECT generated_timestamp AS timestamp, - COUNT(session_id) AS count - FROM generate_series(%(startDate)s, %(endDate)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 timestamp - ORDER BY timestamp) AS chart_details) AS chart_details ON (TRUE);""" - - # print("--------------------") - # print(cur.mogrify(main_pg_query, params)) - # print("--------------------") - - cur.execute(cur.mogrify(main_pg_query, params)) - rows = cur.fetchall() - total = 0 if len(rows) == 0 else rows[0]["full_count"] - if flows: - return {"count": total} - - if total == 0: - rows = [] - else: - if len(statuses) == 0: - query = cur.mogrify( - """SELECT error_id, status, parent_error_id, payload, - COALESCE((SELECT TRUE - FROM public.user_favorite_errors AS fe - WHERE errors.error_id = fe.error_id - AND fe.user_id = %(user_id)s LIMIT 1), FALSE) AS favorite, - COALESCE((SELECT TRUE - FROM public.user_viewed_errors AS ve - WHERE errors.error_id = ve.error_id - AND ve.user_id = %(user_id)s LIMIT 1), FALSE) AS viewed - FROM public.errors - WHERE project_id = %(project_id)s AND error_id IN %(error_ids)s;""", - {"project_id": project_id, "error_ids": tuple([r["error_id"] for r in rows]), - "user_id": user_id}) - cur.execute(query=query) - statuses = helper.list_to_camel_case(cur.fetchall()) - statuses = { - s["errorId"]: s for s in statuses - } - - for r in rows: - r.pop("full_count") - if r["error_id"] in statuses: - r["status"] = statuses[r["error_id"]]["status"] - r["parent_error_id"] = statuses[r["error_id"]]["parentErrorId"] - r["favorite"] = statuses[r["error_id"]]["favorite"] - r["viewed"] = statuses[r["error_id"]]["viewed"] - r["stack"] = format_first_stack_frame(statuses[r["error_id"]])["stack"] - else: - r["status"] = "untracked" - r["parent_error_id"] = None - r["favorite"] = False - r["viewed"] = False - r["stack"] = None - - offset = len(rows) - rows = [r for r in rows if r["stack"] is None - or (len(r["stack"]) == 0 or len(r["stack"]) > 1 - or len(r["stack"]) > 0 - and (r["message"].lower() != "script error." or len(r["stack"][0]["absPath"]) > 0))] - offset -= len(rows) - return { - 'total': total - offset, - 'errors': helper.list_to_camel_case(rows) - } - - -# refactor this function after clickhouse structure changes (missing search by query) -def search_deprecated(data: schemas.SearchErrorsSchema, project_id, user_id, flows=False): - empty_response = {"data": { - 'total': 0, - 'errors': [] - }} - platform = None - for f in data.filters: - if f.type == schemas.FilterType.platform and len(f.value) > 0: - platform = f.value[0] - ch_sub_query = __get_basic_constraints(platform) + ch_sessions_sub_query = __get_basic_constraints(platform, type_condition=False) + ch_sub_query = __get_basic_constraints(platform, type_condition=True) ch_sub_query.append("source ='js_exception'") # To ignore Script error ch_sub_query.append("message!='Script error.'") - statuses = [] error_ids = None - # Clickhouse keeps data for the past month only, so no need to search beyond that - if data.startDate is None or data.startDate < TimeUTC.now(delta_days=-31): - data.startDate = TimeUTC.now(-30) + + if data.startDate is None: + data.startDate = TimeUTC.now(-7) if data.endDate is None: data.endDate = TimeUTC.now(1) - if len(data.events) > 0 or len(data.filters) > 0 or data.status != schemas.ErrorStatus.all: - print("-- searching for sessions before errors") - # if favorite_only=True search for sessions associated with favorite_error - statuses = sessions.search2_pg(data=data, project_id=project_id, user_id=user_id, errors_only=True, - error_status=data.status) - if len(statuses) == 0: - return empty_response - error_ids = [e["errorId"] for e in statuses] - with ch_client.ClickHouseClient() as ch, pg_client.PostgresClient() as cur: - if data.startDate is None: - data.startDate = TimeUTC.now(-7) - if data.endDate is None: - data.endDate = TimeUTC.now() + + subquery_part = "" + params = {} + if len(data.events) > 0: + errors_condition_count = 0 + for i, e in enumerate(data.events): + if e.type == schemas.EventType.error: + errors_condition_count += 1 + is_any = _isAny_opreator(e.operator) + op = __get_sql_operator(e.operator) + e_k = f"e_value{i}" + params = {**params, **_multiple_values(e.value, value_key=e_k)} + if not is_any and len(e.value) > 0 and e.value[1] not in [None, "*", ""]: + ch_sub_query.append( + _multiple_conditions(f"(message {op} %({e_k})s OR name {op} %({e_k})s)", + e.value, value_key=e_k)) + if len(data.events) > errors_condition_count: + subquery_part_args, subquery_part = sessions.search_query_parts_ch(data=data, error_status=data.status, + errors_only=True, + project_id=project_id, user_id=user_id, + issue=None, + favorite_only=False) + subquery_part = f"INNER JOIN {subquery_part} USING(session_id)" + params = {**params, **subquery_part_args} + if len(data.filters) > 0: + meta_keys = None + # to reduce include a sub-query of sessions inside events query, in order to reduce the selected data + for i, f in enumerate(data.filters): + if not isinstance(f.value, list): + f.value = [f.value] + filter_type = f.type + f.value = helper.values_for_operator(value=f.value, op=f.operator) + f_k = f"f_value{i}" + params = {**params, f_k: f.value, **_multiple_values(f.value, value_key=f_k)} + op = __get_sql_operator(f.operator) \ + if filter_type not in [schemas.FilterType.events_count] else f.operator + is_any = _isAny_opreator(f.operator) + is_undefined = _isUndefined_operator(f.operator) + if not is_any and not is_undefined and len(f.value) == 0: + continue + is_not = False + if __is_negation_operator(f.operator): + is_not = True + if filter_type == schemas.FilterType.user_browser: + if is_any: + ch_sessions_sub_query.append('isNotNull(s.user_browser)') + else: + ch_sessions_sub_query.append( + _multiple_conditions(f's.user_browser {op} %({f_k})s', f.value, is_not=is_not, + value_key=f_k)) + + elif filter_type in [schemas.FilterType.user_os, schemas.FilterType.user_os_ios]: + if is_any: + ch_sessions_sub_query.append('isNotNull(s.user_os)') + else: + ch_sessions_sub_query.append( + _multiple_conditions(f's.user_os {op} %({f_k})s', f.value, is_not=is_not, value_key=f_k)) + + elif filter_type in [schemas.FilterType.user_device, schemas.FilterType.user_device_ios]: + if is_any: + ch_sessions_sub_query.append('isNotNull(s.user_device)') + else: + ch_sessions_sub_query.append( + _multiple_conditions(f's.user_device {op} %({f_k})s', f.value, is_not=is_not, + value_key=f_k)) + + elif filter_type in [schemas.FilterType.user_country, schemas.FilterType.user_country_ios]: + if is_any: + ch_sessions_sub_query.append('isNotNull(s.user_country)') + else: + ch_sessions_sub_query.append( + _multiple_conditions(f's.user_country {op} %({f_k})s', f.value, is_not=is_not, + value_key=f_k)) + + + elif filter_type in [schemas.FilterType.utm_source]: + if is_any: + ch_sessions_sub_query.append('isNotNull(s.utm_source)') + elif is_undefined: + ch_sessions_sub_query.append('isNull(s.utm_source)') + else: + ch_sessions_sub_query.append( + _multiple_conditions(f's.utm_source {op} toString(%({f_k})s)', f.value, is_not=is_not, + value_key=f_k)) + + elif filter_type in [schemas.FilterType.utm_medium]: + if is_any: + ch_sessions_sub_query.append('isNotNull(s.utm_medium)') + elif is_undefined: + ch_sessions_sub_query.append('isNull(s.utm_medium)') + else: + ch_sessions_sub_query.append( + _multiple_conditions(f's.utm_medium {op} toString(%({f_k})s)', f.value, is_not=is_not, + value_key=f_k)) + elif filter_type in [schemas.FilterType.utm_campaign]: + if is_any: + ch_sessions_sub_query.append('isNotNull(s.utm_campaign)') + elif is_undefined: + ch_sessions_sub_query.append('isNull(s.utm_campaign)') + else: + ch_sessions_sub_query.append( + _multiple_conditions(f's.utm_campaign {op} toString(%({f_k})s)', f.value, is_not=is_not, + value_key=f_k)) + + elif filter_type == schemas.FilterType.duration: + if len(f.value) > 0 and f.value[0] is not None: + ch_sessions_sub_query.append("s.duration >= %(minDuration)s") + params["minDuration"] = f.value[0] + if len(f.value) > 1 and f.value[1] is not None and int(f.value[1]) > 0: + ch_sessions_sub_query.append("s.duration <= %(maxDuration)s") + params["maxDuration"] = f.value[1] + + elif filter_type == schemas.FilterType.referrer: + # extra_from += f"INNER JOIN {events.event_type.LOCATION.table} AS p USING(session_id)" + if is_any: + referrer_constraint = 'isNotNull(s.base_referrer)' + else: + referrer_constraint = _multiple_conditions(f"s.base_referrer {op} %({f_k})s", f.value, + is_not=is_not, value_key=f_k) + elif filter_type == schemas.FilterType.metadata: + # get metadata list only if you need it + if meta_keys is None: + meta_keys = metadata.get(project_id=project_id) + meta_keys = {m["key"]: m["index"] for m in meta_keys} + if f.source in meta_keys.keys(): + if is_any: + ch_sessions_sub_query.append(f"isNotNull(s.{metadata.index_to_colname(meta_keys[f.source])})") + elif is_undefined: + ch_sessions_sub_query.append(f"isNull(s.{metadata.index_to_colname(meta_keys[f.source])})") + else: + ch_sessions_sub_query.append( + _multiple_conditions( + f"s.{metadata.index_to_colname(meta_keys[f.source])} {op} toString(%({f_k})s)", + f.value, is_not=is_not, value_key=f_k)) + + elif filter_type in [schemas.FilterType.user_id, schemas.FilterType.user_id_ios]: + if is_any: + ch_sessions_sub_query.append('isNotNull(s.user_id)') + elif is_undefined: + ch_sessions_sub_query.append('isNull(s.user_id)') + else: + ch_sessions_sub_query.append( + _multiple_conditions(f"s.user_id {op} toString(%({f_k})s)", f.value, is_not=is_not, + value_key=f_k)) + elif filter_type in [schemas.FilterType.user_anonymous_id, + schemas.FilterType.user_anonymous_id_ios]: + if is_any: + ch_sessions_sub_query.append('isNotNull(s.user_anonymous_id)') + elif is_undefined: + ch_sessions_sub_query.append('isNull(s.user_anonymous_id)') + else: + ch_sessions_sub_query.append( + _multiple_conditions(f"s.user_anonymous_id {op} toString(%({f_k})s)", f.value, + is_not=is_not, + value_key=f_k)) + + elif filter_type in [schemas.FilterType.rev_id, schemas.FilterType.rev_id_ios]: + if is_any: + ch_sessions_sub_query.append('isNotNull(s.rev_id)') + elif is_undefined: + ch_sessions_sub_query.append('isNull(s.rev_id)') + else: + ch_sessions_sub_query.append( + _multiple_conditions(f"s.rev_id {op} toString(%({f_k})s)", f.value, is_not=is_not, + value_key=f_k)) + + elif filter_type == schemas.FilterType.platform: + # op = __get_sql_operator(f.operator) + ch_sessions_sub_query.append( + _multiple_conditions(f"s.user_device_type {op} %({f_k})s", f.value, is_not=is_not, + value_key=f_k)) + # elif filter_type == schemas.FilterType.issue: + # if is_any: + # ch_sessions_sub_query.append("notEmpty(s.issue_types)") + # else: + # ch_sessions_sub_query.append(f"hasAny(s.issue_types,%({f_k})s)") + # # _multiple_conditions(f"%({f_k})s {op} ANY (s.issue_types)", f.value, is_not=is_not, + # # value_key=f_k)) + # + # if is_not: + # extra_constraints[-1] = f"not({extra_constraints[-1]})" + # ss_constraints[-1] = f"not({ss_constraints[-1]})" + elif filter_type == schemas.FilterType.events_count: + ch_sessions_sub_query.append( + _multiple_conditions(f"s.events_count {op} %({f_k})s", f.value, is_not=is_not, + value_key=f_k)) + + with ch_client.ClickHouseClient() as ch: step_size = __get_step_size(data.startDate, data.endDate, data.density) sort = __get_sort_key('datetime') if data.sort is not None: @@ -681,6 +725,7 @@ def search_deprecated(data: schemas.SearchErrorsSchema, project_id, user_id, flo if data.order is not None: order = data.order params = { + **params, "startDate": data.startDate, "endDate": data.endDate, "project_id": project_id, @@ -692,118 +737,82 @@ def search_deprecated(data: schemas.SearchErrorsSchema, project_id, user_id, flo else: params["errors_offset"] = 0 params["errors_limit"] = 200 - if data.bookmarked: - cur.execute(cur.mogrify(f"""SELECT error_id - FROM public.user_favorite_errors - WHERE user_id = %(userId)s - {"" if error_ids is None else "AND error_id IN %(error_ids)s"}""", - {"userId": user_id, "error_ids": tuple(error_ids or [])})) - error_ids = cur.fetchall() - if len(error_ids) == 0: - return empty_response - error_ids = [e["error_id"] for e in error_ids] + # if data.bookmarked: + # cur.execute(cur.mogrify(f"""SELECT error_id + # FROM public.user_favorite_errors + # WHERE user_id = %(userId)s + # {"" if error_ids is None else "AND error_id IN %(error_ids)s"}""", + # {"userId": user_id, "error_ids": tuple(error_ids or [])})) + # error_ids = cur.fetchall() + # if len(error_ids) == 0: + # return empty_response + # error_ids = [e["error_id"] for e in error_ids] if error_ids is not None: params["error_ids"] = tuple(error_ids) ch_sub_query.append("error_id IN %(error_ids)s") main_ch_query = f"""\ - SELECT COUNT(DISTINCT error_id) AS count - FROM errors - WHERE {" AND ".join(ch_sub_query)};""" - # print("------------") - # print(ch.client().substitute_params(main_ch_query, params)) - # print("------------") - total = ch.execute(query=main_ch_query, params=params)[0]["count"] - if flows: - return {"data": {"count": total}} - if total == 0: - rows = [] - else: - main_ch_query = f"""\ - SELECT details.error_id AS error_id, name, message, users, sessions, last_occurrence, first_occurrence, chart - FROM (SELECT error_id, - name, - message, - COUNT(DISTINCT user_uuid) AS users, - COUNT(DISTINCT session_id) AS sessions, - MAX(datetime) AS max_datetime, - MIN(datetime) AS min_datetime - FROM errors - WHERE {" AND ".join(ch_sub_query)} - GROUP BY error_id, name, message - ORDER BY {sort} {order} - LIMIT %(errors_limit)s OFFSET %(errors_offset)s) AS details - INNER JOIN (SELECT error_id AS error_id, toUnixTimestamp(MAX(datetime))*1000 AS last_occurrence, toUnixTimestamp(MIN(datetime))*1000 AS first_occurrence - FROM errors - GROUP BY error_id) AS time_details - ON details.error_id=time_details.error_id - INNER JOIN (SELECT error_id, groupArray([timestamp, count]) AS chart - FROM (SELECT error_id, toUnixTimestamp(toStartOfInterval(datetime, INTERVAL %(step_size)s second)) * 1000 AS timestamp, - COUNT(DISTINCT session_id) AS count - FROM errors - WHERE {" AND ".join(ch_sub_query)} - GROUP BY error_id, timestamp - ORDER BY timestamp) AS sub_table - GROUP BY error_id) AS chart_details ON details.error_id=chart_details.error_id;""" + SELECT details.error_id AS error_id, + name, message, users, total, viewed, + sessions, last_occurrence, first_occurrence, chart + FROM (SELECT error_id, + name, + message, + COUNT(DISTINCT user_id) AS users, + COUNT(DISTINCT events.session_id) AS sessions, + MAX(datetime) AS max_datetime, + MIN(datetime) AS min_datetime, + COUNT(DISTINCT events.error_id) OVER() AS total, + any(isNotNull(viewed_error_id)) AS viewed + FROM {MAIN_EVENTS_TABLE} AS events + LEFT JOIN (SELECT error_id AS viewed_error_id + FROM {exp_ch_helper.get_user_viewed_errors_table()} + WHERE project_id=%(project_id)s + AND user_id=%(userId)s) AS viewed_errors ON(events.error_id=viewed_errors.viewed_error_id) + INNER JOIN (SELECT session_id, coalesce(user_id,toString(user_uuid)) AS user_id + FROM {MAIN_SESSIONS_TABLE} AS s + {subquery_part} + WHERE {" AND ".join(ch_sessions_sub_query)}) AS sessions + ON (events.session_id = sessions.session_id) + WHERE {" AND ".join(ch_sub_query)} + GROUP BY error_id, name, message + ORDER BY {sort} {order} + LIMIT %(errors_limit)s OFFSET %(errors_offset)s) AS details + INNER JOIN (SELECT error_id AS error_id, + toUnixTimestamp(MAX(datetime))*1000 AS last_occurrence, + toUnixTimestamp(MIN(datetime))*1000 AS first_occurrence + FROM {MAIN_EVENTS_TABLE} + WHERE project_id=%(project_id)s + AND event_type='ERROR' + GROUP BY error_id) AS time_details + ON details.error_id=time_details.error_id + INNER JOIN (SELECT error_id, groupArray([timestamp, count]) AS chart + FROM (SELECT error_id, toUnixTimestamp(toStartOfInterval(datetime, INTERVAL %(step_size)s second)) * 1000 AS timestamp, + COUNT(DISTINCT session_id) AS count + FROM {MAIN_EVENTS_TABLE} + WHERE {" AND ".join(ch_sub_query)} + GROUP BY error_id, timestamp + ORDER BY timestamp) AS sub_table + GROUP BY error_id) AS chart_details ON details.error_id=chart_details.error_id;""" - # print("------------") - # print(ch.client().substitute_params(main_ch_query, params)) - # print("------------") + # print("------------") + # print(ch.format(main_ch_query, params)) + # print("------------") - rows = ch.execute(query=main_ch_query, params=params) - if len(statuses) == 0: - query = cur.mogrify( - """SELECT error_id, status, parent_error_id, payload, - COALESCE((SELECT TRUE - FROM public.user_favorite_errors AS fe - WHERE errors.error_id = fe.error_id - AND fe.user_id = %(userId)s LIMIT 1), FALSE) AS favorite, - COALESCE((SELECT TRUE - FROM public.user_viewed_errors AS ve - WHERE errors.error_id = ve.error_id - AND ve.user_id = %(userId)s LIMIT 1), FALSE) AS viewed - FROM public.errors - WHERE project_id = %(project_id)s AND error_id IN %(error_ids)s;""", - {"project_id": project_id, "error_ids": tuple([r["error_id"] for r in rows]), - "userId": user_id}) - cur.execute(query=query) - statuses = helper.list_to_camel_case(cur.fetchall()) - statuses = { - s["errorId"]: s for s in statuses - } + rows = ch.execute(query=main_ch_query, params=params) + total = rows[0]["total"] if len(rows) > 0 else 0 for r in rows: - if r["error_id"] in statuses: - r["status"] = statuses[r["error_id"]]["status"] - r["parent_error_id"] = statuses[r["error_id"]]["parentErrorId"] - r["favorite"] = statuses[r["error_id"]]["favorite"] - r["viewed"] = statuses[r["error_id"]]["viewed"] - r["stack"] = format_first_stack_frame(statuses[r["error_id"]])["stack"] - else: - r["status"] = "untracked" - r["parent_error_id"] = None - r["favorite"] = False - r["viewed"] = False - r["stack"] = None - r["chart"] = list(r["chart"]) for i in range(len(r["chart"])): r["chart"][i] = {"timestamp": r["chart"][i][0], "count": r["chart"][i][1]} r["chart"] = metrics.__complete_missing_steps(rows=r["chart"], start_time=data.startDate, end_time=data.endDate, density=data.density, neutral={"count": 0}) - offset = len(rows) - rows = [r for r in rows if r["stack"] is None - or (len(r["stack"]) == 0 or len(r["stack"]) > 1 - or len(r["stack"]) > 0 - and (r["message"].lower() != "script error." or len(r["stack"][0]["absPath"]) > 0))] - offset -= len(rows) return { - "data": { - 'total': total - offset, - 'errors': helper.list_to_camel_case(rows) - } + 'total': total, + 'errors': helper.list_to_camel_case(rows) } diff --git a/ee/api/chalicelib/core/errors_viewed.py b/ee/api/chalicelib/core/errors_viewed.py new file mode 100644 index 000000000..f66e10d90 --- /dev/null +++ b/ee/api/chalicelib/core/errors_viewed.py @@ -0,0 +1,39 @@ +from chalicelib.utils import pg_client +from chalicelib.core import errors_viewed_exp + + +def add_viewed_error(project_id, user_id, error_id): + with pg_client.PostgresClient() as cur: + cur.execute( + cur.mogrify("""INSERT INTO public.user_viewed_errors(user_id, error_id) + VALUES (%(userId)s,%(error_id)s);""", + {"userId": user_id, "error_id": error_id}) + ) + errors_viewed_exp.add_viewed_error(project_id=project_id, user_id=user_id, error_id=error_id) + + +def viewed_error_exists(user_id, error_id): + with pg_client.PostgresClient() as cur: + query = cur.mogrify( + """SELECT + errors.error_id AS hydrated, + COALESCE((SELECT TRUE + FROM public.user_viewed_errors AS ve + WHERE ve.error_id = %(error_id)s + AND ve.user_id = %(userId)s LIMIT 1), FALSE) AS viewed + FROM public.errors + WHERE error_id = %(error_id)s""", + {"userId": user_id, "error_id": error_id}) + cur.execute( + query=query + ) + r = cur.fetchone() + if r: + return r.get("viewed") + return True + + +def viewed_error(project_id, user_id, error_id): + if viewed_error_exists(user_id=user_id, error_id=error_id): + return None + return add_viewed_error(project_id=project_id, user_id=user_id, error_id=error_id) diff --git a/ee/api/chalicelib/core/errors_viewed_exp.py b/ee/api/chalicelib/core/errors_viewed_exp.py new file mode 100644 index 000000000..7a2a6ddc5 --- /dev/null +++ b/ee/api/chalicelib/core/errors_viewed_exp.py @@ -0,0 +1,15 @@ +import logging + +from decouple import config + +from chalicelib.utils import ch_client, exp_ch_helper + +logging.basicConfig(level=config("LOGLEVEL", default=logging.INFO)) + + +def add_viewed_error(project_id, user_id, error_id): + with ch_client.ClickHouseClient() as cur: + query = f"""INSERT INTO {exp_ch_helper.get_user_viewed_errors_table()}(project_id,user_id, error_id) + VALUES (%(project_id)s,%(userId)s,%(error_id)s);""" + params = {"userId": user_id, "error_id": error_id, "project_id": project_id} + cur.execute(query=query, params=params) diff --git a/ee/api/chalicelib/core/integrations_global.py b/ee/api/chalicelib/core/integrations_global.py new file mode 100644 index 000000000..b923fc5ab --- /dev/null +++ b/ee/api/chalicelib/core/integrations_global.py @@ -0,0 +1,61 @@ +import schemas +from chalicelib.utils import pg_client + + +def get_global_integrations_status(tenant_id, user_id, project_id): + with pg_client.PostgresClient() as cur: + cur.execute( + cur.mogrify(f"""\ + SELECT EXISTS((SELECT 1 + FROM public.oauth_authentication + WHERE user_id = %(user_id)s + AND provider = 'github')) AS {schemas.IntegrationType.github}, + EXISTS((SELECT 1 + FROM public.jira_cloud + WHERE user_id = %(user_id)s)) AS {schemas.IntegrationType.jira}, + EXISTS((SELECT 1 + FROM public.integrations + WHERE project_id=%(project_id)s + AND provider='bugsnag')) AS {schemas.IntegrationType.bugsnag}, + EXISTS((SELECT 1 + FROM public.integrations + WHERE project_id=%(project_id)s + AND provider='cloudwatch')) AS {schemas.IntegrationType.cloudwatch}, + EXISTS((SELECT 1 + FROM public.integrations + WHERE project_id=%(project_id)s + AND provider='datadog')) AS {schemas.IntegrationType.datadog}, + EXISTS((SELECT 1 + FROM public.integrations + WHERE project_id=%(project_id)s + AND provider='newrelic')) AS {schemas.IntegrationType.newrelic}, + EXISTS((SELECT 1 + FROM public.integrations + WHERE project_id=%(project_id)s + AND provider='rollbar')) AS {schemas.IntegrationType.rollbar}, + EXISTS((SELECT 1 + FROM public.integrations + WHERE project_id=%(project_id)s + AND provider='sentry')) AS {schemas.IntegrationType.sentry}, + EXISTS((SELECT 1 + FROM public.integrations + WHERE project_id=%(project_id)s + AND provider='stackdriver')) AS {schemas.IntegrationType.stackdriver}, + EXISTS((SELECT 1 + FROM public.integrations + WHERE project_id=%(project_id)s + AND provider='sumologic')) AS {schemas.IntegrationType.sumologic}, + EXISTS((SELECT 1 + FROM public.integrations + WHERE project_id=%(project_id)s + AND provider='elasticsearch')) AS {schemas.IntegrationType.elasticsearch}, + EXISTS((SELECT 1 + FROM public.webhooks + WHERE type='slack' AND tenant_id=%(tenant_id)s)) AS {schemas.IntegrationType.slack};""", + {"user_id": user_id, "tenant_id": tenant_id, "project_id": project_id}) + ) + current_integrations = cur.fetchone() + result = [] + for k in current_integrations.keys(): + result.append({"name": k, "integrated": current_integrations[k]}) + return result diff --git a/ee/api/chalicelib/core/metrics.py b/ee/api/chalicelib/core/metrics.py index 19977b0bf..62a1fbb27 100644 --- a/ee/api/chalicelib/core/metrics.py +++ b/ee/api/chalicelib/core/metrics.py @@ -167,9 +167,8 @@ def get_processed_sessions(project_id, startTimestamp=TimeUTC.now(delta_days=-1) ch_sub_query_chart += meta_condition with ch_client.ClickHouseClient() as ch: ch_query = f"""\ - SELECT - toUnixTimestamp(toStartOfInterval(sessions.datetime, INTERVAL %(step_size)s second)) * 1000 AS timestamp, - COUNT(sessions.session_id) AS value + SELECT toUnixTimestamp(toStartOfInterval(sessions.datetime, INTERVAL %(step_size)s second)) * 1000 AS timestamp, + COUNT(DISTINCT sessions.session_id) AS value FROM sessions {"INNER JOIN sessions_metadata USING(session_id)" if len(meta_condition) > 0 else ""} WHERE {" AND ".join(ch_sub_query_chart)} GROUP BY timestamp @@ -191,7 +190,7 @@ def get_processed_sessions(project_id, startTimestamp=TimeUTC.now(delta_days=-1) endTimestamp = startTimestamp startTimestamp = endTimestamp - diff - ch_query = f""" SELECT COUNT(sessions.session_id) AS count + ch_query = f""" SELECT COUNT(1) AS count FROM sessions {"INNER JOIN sessions_metadata USING(session_id)" if len(meta_condition) > 0 else ""} WHERE {" AND ".join(ch_sub_query)};""" params = {"project_id": project_id, "startTimestamp": startTimestamp, "endTimestamp": endTimestamp, @@ -278,7 +277,7 @@ def get_errors_trend(project_id, startTimestamp=TimeUTC.now(delta_days=-1), ch_query = f"""SELECT * FROM (SELECT errors.error_id AS error_id, errors.message AS error, - COUNT(errors.session_id) AS count, + COUNT(1) AS count, COUNT(DISTINCT errors.session_id) AS sessions FROM errors {"INNER JOIN sessions_metadata USING(session_id)" if len(meta_condition) > 0 else ""} WHERE {" AND ".join(ch_sub_query)} @@ -293,7 +292,7 @@ def get_errors_trend(project_id, startTimestamp=TimeUTC.now(delta_days=-1), "endTimestamp": endTimestamp, **__get_constraint_values(args)} rows = ch.execute(query=ch_query, params=params) - print(f"got {len(rows)} rows") + # print(f"got {len(rows)} rows") if len(rows) == 0: return [] error_ids = [r["error_id"] for r in rows] @@ -302,7 +301,7 @@ def get_errors_trend(project_id, startTimestamp=TimeUTC.now(delta_days=-1), for error_id in error_ids: ch_query = f"""\ SELECT toUnixTimestamp(toStartOfInterval(errors.datetime, INTERVAL %(step_size)s second)) * 1000 AS timestamp, - COUNT(errors.session_id) AS count + COUNT(1) AS count FROM errors {"INNER JOIN sessions_metadata USING(session_id)" if len(meta_condition) > 0 else ""} WHERE {" AND ".join(ch_sub_query_chart)} GROUP BY timestamp @@ -461,11 +460,11 @@ def get_slowest_images(project_id, startTimestamp=TimeUTC.now(delta_days=-1), with ch_client.ClickHouseClient() as ch: ch_query = f"""SELECT resources.url, COALESCE(avgOrNull(resources.duration),0) AS avg, - COUNT(resources.session_id) AS count + COUNT(1) AS count FROM resources {"INNER JOIN sessions_metadata USING(session_id)" if len(meta_condition) > 0 else ""} WHERE {" AND ".join(ch_sub_query)} AND resources.duration>0 GROUP BY resources.url ORDER BY avg DESC LIMIT 10;""" - params = {"project_id": project_id, "startTimestamp": startTimestamp, + params = {"step_size": step_size, "project_id": project_id, "startTimestamp": startTimestamp, "endTimestamp": endTimestamp, **__get_constraint_values(args)} rows = ch.execute(query=ch_query, params=params) @@ -482,8 +481,7 @@ def get_slowest_images(project_id, startTimestamp=TimeUTC.now(delta_days=-1), WHERE {" AND ".join(ch_sub_query_chart)} AND resources.duration>0 GROUP BY url, timestamp ORDER BY url, timestamp;""" - params = {"step_size": step_size, "project_id": project_id, "startTimestamp": startTimestamp, - "endTimestamp": endTimestamp, "url": urls, **__get_constraint_values(args)} + params["url"] = urls u_rows = ch.execute(query=ch_query, params=params) for url in urls: sub_rows = [] @@ -783,27 +781,28 @@ def get_missing_resources_trend(project_id, startTimestamp=TimeUTC.now(delta_day step_size = __get_step_size(startTimestamp, endTimestamp, density) ch_sub_query = __get_basic_constraints(table_name="resources", data=args) ch_sub_query.append("resources.success = 0") - ch_sub_query.append("resources.type != 'fetch'") + ch_sub_query.append("resources.type = 'img'") meta_condition = __get_meta_constraint(args) ch_sub_query += meta_condition with ch_client.ClickHouseClient() as ch: ch_query = f"""SELECT resources.url_hostpath AS key, - COUNT(resources.session_id) AS doc_count + COUNT(1) AS doc_count FROM resources {"INNER JOIN sessions_metadata USING(session_id)" if len(meta_condition) > 0 else ""} WHERE {" AND ".join(ch_sub_query)} GROUP BY url_hostpath ORDER BY doc_count DESC LIMIT 10;""" - rows = ch.execute(query=ch_query, params={"project_id": project_id, "startTimestamp": startTimestamp, - "endTimestamp": endTimestamp, **__get_constraint_values(args)}) + params = {"project_id": project_id, "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, **__get_constraint_values(args)} + rows = ch.execute(query=ch_query, params=params) rows = [{"url": i["key"], "sessions": i["doc_count"]} for i in rows] if len(rows) == 0: return [] ch_sub_query.append("resources.url_hostpath = %(value)s") ch_query = f"""SELECT toUnixTimestamp(toStartOfInterval(resources.datetime, INTERVAL %(step_size)s second ))*1000 AS timestamp, - COUNT(resources.session_id) AS doc_count, + COUNT(1) AS doc_count, toUnixTimestamp(MAX(resources.datetime))*1000 AS max_datatime FROM resources {"INNER JOIN sessions_metadata USING(session_id)" if len(meta_condition) > 0 else ""} WHERE {" AND ".join(ch_sub_query)} @@ -813,13 +812,8 @@ def get_missing_resources_trend(project_id, startTimestamp=TimeUTC.now(delta_day e["startedAt"] = startTimestamp e["startTimestamp"] = startTimestamp e["endTimestamp"] = endTimestamp - - r = ch.execute(query=ch_query, - params={"step_size": step_size, "project_id": project_id, - "startTimestamp": startTimestamp, - "endTimestamp": endTimestamp, - "value": e["url"], - **__get_constraint_values(args)}) + params["value"] = e["url"] + r = ch.execute(query=ch_query, params=params) e["endedAt"] = r[-1]["max_datatime"] e["chart"] = [{"timestamp": i["timestamp"], "count": i["doc_count"]} for i in @@ -840,15 +834,16 @@ def get_network(project_id, startTimestamp=TimeUTC.now(delta_days=-1), with ch_client.ClickHouseClient() as ch: ch_query = f"""SELECT toUnixTimestamp(toStartOfInterval(resources.datetime, INTERVAL %(step_size)s second ))*1000 AS timestamp, - resources.url_hostpath, COUNT(resources.session_id) AS doc_count + resources.url_hostpath, COUNT(1) AS doc_count FROM resources {"INNER JOIN sessions_metadata USING(session_id)" if len(meta_condition) > 0 else ""} WHERE {" AND ".join(ch_sub_query_chart)} GROUP BY timestamp, resources.url_hostpath - ORDER BY timestamp;""" - r = ch.execute(query=ch_query, - params={"step_size": step_size, "project_id": project_id, - "startTimestamp": startTimestamp, - "endTimestamp": endTimestamp, **__get_constraint_values(args)}) + ORDER BY timestamp, doc_count DESC + LIMIT 10 BY timestamp;""" + params = {"step_size": step_size, "project_id": project_id, + "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, **__get_constraint_values(args)} + r = ch.execute(query=ch_query, params=params) results = [] @@ -956,6 +951,7 @@ 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) ch_sub_query = __get_basic_constraints(table_name="resources", data=args) + ch_sub_query.append("isNotNull(resources.url_hostpath)") ch_sub_query_chart = __get_basic_constraints(table_name="resources", round_start=True, data=args) meta_condition = __get_meta_constraint(args) ch_sub_query += meta_condition @@ -1025,15 +1021,15 @@ def get_sessions_location(project_id, startTimestamp=TimeUTC.now(delta_days=-1), ch_sub_query += meta_condition with ch_client.ClickHouseClient() as ch: - ch_query = f"""SELECT user_country, COUNT(session_id) AS count + ch_query = f"""SELECT user_country, COUNT(1) AS count FROM sessions {"INNER JOIN sessions_metadata USING(session_id)" if len(meta_condition) > 0 else ""} WHERE {" AND ".join(ch_sub_query)} GROUP BY user_country ORDER BY user_country;""" - rows = ch.execute(query=ch_query, - params={"project_id": project_id, - "startTimestamp": startTimestamp, - "endTimestamp": endTimestamp, **__get_constraint_values(args)}) + params = {"project_id": project_id, + "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, **__get_constraint_values(args)} + rows = ch.execute(query=ch_query, params=params) return {"count": sum(i["count"] for i in rows), "chart": helper.list_to_camel_case(rows)} @@ -1108,30 +1104,24 @@ def get_pages_response_time_distribution(project_id, startTimestamp=TimeUTC.now( with ch_client.ClickHouseClient() as ch: ch_query = f"""SELECT pages.response_time AS response_time, - COUNT(pages.session_id) AS count + COUNT(1) AS count FROM pages {"INNER JOIN sessions_metadata USING(session_id)" if len(meta_condition) > 0 else ""} WHERE {" AND ".join(ch_sub_query)} GROUP BY response_time ORDER BY response_time;""" - rows = ch.execute(query=ch_query, - params={"project_id": project_id, - "startTimestamp": startTimestamp, - "endTimestamp": endTimestamp, **__get_constraint_values(args)}) + params = {"project_id": project_id, + "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, **__get_constraint_values(args)} + rows = ch.execute(query=ch_query, params=params) ch_query = f"""SELECT COALESCE(avgOrNull(pages.response_time),0) AS avg FROM pages {"INNER JOIN sessions_metadata USING(session_id)" if len(meta_condition) > 0 else ""} WHERE {" AND ".join(ch_sub_query)};""" - avg = ch.execute(query=ch_query, - params={"project_id": project_id, - "startTimestamp": startTimestamp, - "endTimestamp": endTimestamp, **__get_constraint_values(args)})[0]["avg"] + avg = ch.execute(query=ch_query, params=params)[0]["avg"] quantiles_keys = [50, 90, 95, 99] ch_query = f"""SELECT quantilesExact({",".join([str(i / 100) for i in quantiles_keys])})(pages.response_time) AS values FROM pages {"INNER JOIN sessions_metadata USING(session_id)" if len(meta_condition) > 0 else ""} WHERE {" AND ".join(ch_sub_query)};""" - quantiles = ch.execute(query=ch_query, - params={"project_id": project_id, - "startTimestamp": startTimestamp, - "endTimestamp": endTimestamp, **__get_constraint_values(args)}) + quantiles = ch.execute(query=ch_query, params=params) result = { "value": avg, "total": sum(r["count"] for r in rows), @@ -1228,15 +1218,15 @@ def get_busiest_time_of_day(project_id, startTimestamp=TimeUTC.now(delta_days=-1 with ch_client.ClickHouseClient() as ch: ch_query = f"""SELECT intDiv(toHour(sessions.datetime),2)*2 AS hour, - COUNT(sessions.session_id) AS count + COUNT(1) AS count FROM sessions {"INNER JOIN sessions_metadata USING(session_id)" if len(meta_condition) > 0 else ""} WHERE {" AND ".join(ch_sub_query)} GROUP BY hour ORDER BY hour ASC;""" - rows = ch.execute(query=ch_query, - params={"project_id": project_id, - "startTimestamp": startTimestamp, - "endTimestamp": endTimestamp, **__get_constraint_values(args)}) + params = {"project_id": project_id, + "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, **__get_constraint_values(args)} + rows = ch.execute(query=ch_query, params=params) return __complete_missing_steps(rows=rows, start_time=0, end_time=24000, density=12, neutral={"count": 0}, time_key="hour", time_coefficient=1) @@ -1251,17 +1241,24 @@ def get_top_metrics(project_id, startTimestamp=TimeUTC.now(delta_days=-1), if value is not None: ch_sub_query.append("pages.url_path = %(value)s") with ch_client.ClickHouseClient() as ch: - ch_query = f"""SELECT (SELECT COALESCE(avgOrNull(pages.response_time),0) FROM pages {"INNER JOIN sessions_metadata USING(session_id)" if len(meta_condition) > 0 else ""} WHERE {" AND ".join(ch_sub_query)} AND isNotNull(pages.response_time) AND pages.response_time>0) AS avg_response_time, - (SELECT COUNT(pages.session_id) FROM pages {"INNER JOIN sessions_metadata USING(session_id)" if len(meta_condition) > 0 else ""} WHERE {" AND ".join(ch_sub_query)}) AS count_requests, - (SELECT COALESCE(avgOrNull(pages.first_paint),0) FROM pages {"INNER JOIN sessions_metadata USING(session_id)" if len(meta_condition) > 0 else ""} WHERE {" AND ".join(ch_sub_query)} AND isNotNull(pages.first_paint) AND pages.first_paint>0) AS avg_first_paint, - (SELECT COALESCE(avgOrNull(pages.dom_content_loaded_event_time),0) FROM pages {"INNER JOIN sessions_metadata USING(session_id)" if len(meta_condition) > 0 else ""} WHERE {" AND ".join(ch_sub_query)} AND isNotNull(pages.dom_content_loaded_event_time) AND pages.dom_content_loaded_event_time>0) AS avg_dom_content_loaded, - (SELECT COALESCE(avgOrNull(pages.ttfb),0) FROM pages {"INNER JOIN sessions_metadata USING(session_id)" if len(meta_condition) > 0 else ""} WHERE {" AND ".join(ch_sub_query)} AND isNotNull(pages.ttfb) AND pages.ttfb>0) AS avg_till_first_bit, - (SELECT COALESCE(avgOrNull(pages.time_to_interactive),0) FROM pages {"INNER JOIN sessions_metadata USING(session_id)" if len(meta_condition) > 0 else ""} WHERE {" AND ".join(ch_sub_query)} AND isNotNull(pages.time_to_interactive) AND pages.time_to_interactive >0) AS avg_time_to_interactive;""" - rows = ch.execute(query=ch_query, - params={"project_id": project_id, - "startTimestamp": startTimestamp, - "endTimestamp": endTimestamp, - "value": value, **__get_constraint_values(args)}) + ch_query = f"""SELECT COALESCE(avgOrNull(if(pages.response_time>0,pages.response_time,null)),0) AS avg_response_time, + COALESCE(avgOrNull(if(pages.first_paint>0,pages.first_paint,null)),0) AS avg_first_paint, + COALESCE(avgOrNull(if(pages.dom_content_loaded_event_time>0,pages.dom_content_loaded_event_time,null)),0) AS avg_dom_content_loaded, + COALESCE(avgOrNull(if(pages.ttfb>0,pages.ttfb,null)),0) AS avg_till_first_bit, + COALESCE(avgOrNull(if(pages.time_to_interactive>0,pages.time_to_interactive,null)),0) AS avg_time_to_interactive, + (SELECT COUNT(1) FROM pages {"INNER JOIN sessions_metadata USING(session_id)" if len(meta_condition) > 0 else ""} WHERE {" AND ".join(ch_sub_query)}) AS count_requests + FROM pages {"INNER JOIN sessions_metadata USING(session_id)" if len(meta_condition) > 0 else ""} + WHERE {" AND ".join(ch_sub_query)} + AND (isNotNull(pages.response_time) AND pages.response_time>0 OR + isNotNull(pages.first_paint) AND pages.first_paint>0 OR + isNotNull(pages.dom_content_loaded_event_time) AND pages.dom_content_loaded_event_time>0 OR + isNotNull(pages.ttfb) AND pages.ttfb>0 OR + isNotNull(pages.time_to_interactive) AND pages.time_to_interactive >0);""" + params = {"project_id": project_id, + "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, + "value": value, **__get_constraint_values(args)} + rows = ch.execute(query=ch_query, params=params) return helper.dict_to_camel_case(rows[0]) @@ -1461,17 +1458,17 @@ def get_crashes(project_id, startTimestamp=TimeUTC.now(delta_days=-1), with ch_client.ClickHouseClient() as ch: ch_query = f"""SELECT toUnixTimestamp(toStartOfInterval(sessions.datetime, INTERVAL %(step_size)s second)) * 1000 AS timestamp, - COUNT(sessions.session_id) AS value + COUNT(1) AS value FROM sessions {"INNER JOIN sessions_metadata USING(session_id)" if len(meta_condition) > 0 else ""} WHERE {" AND ".join(ch_sub_query_chart)} GROUP BY timestamp ORDER BY timestamp;""" - rows = ch.execute(query=ch_query, - params={"step_size": step_size, - "project_id": project_id, - "startTimestamp": startTimestamp, - "endTimestamp": endTimestamp, - "session_ids": session_ids, **__get_constraint_values(args)}) + params = {"step_size": step_size, + "project_id": project_id, + "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, + "session_ids": session_ids, **__get_constraint_values(args)} + rows = ch.execute(query=ch_query, params=params) ch_query = f"""SELECT b.user_browser AS browser, sum(bv.count) AS total, groupArray([bv.user_browser_version, toString(bv.count)]) AS versions @@ -1480,14 +1477,14 @@ def get_crashes(project_id, startTimestamp=TimeUTC.now(delta_days=-1), FROM sessions {"INNER JOIN sessions_metadata USING(session_id)" if len(meta_condition) > 0 else ""} WHERE {" AND ".join(ch_sub_query)} GROUP BY sessions.user_browser - ORDER BY COUNT(sessions.session_id) DESC + ORDER BY COUNT(1) DESC LIMIT 3 ) AS b INNER JOIN ( SELECT sessions.user_browser, sessions.user_browser_version, - COUNT(sessions.session_id) AS count + COUNT(1) AS count FROM sessions {"INNER JOIN sessions_metadata USING(session_id)" if len(meta_condition) > 0 else ""} WHERE {" AND ".join(ch_sub_query)} GROUP BY sessions.user_browser, @@ -1496,12 +1493,7 @@ def get_crashes(project_id, startTimestamp=TimeUTC.now(delta_days=-1), ) AS bv USING (user_browser) GROUP BY b.user_browser ORDER BY b.user_browser;""" - browsers = ch.execute(query=ch_query, - params={"step_size": step_size, - "project_id": project_id, - "startTimestamp": startTimestamp, - "endTimestamp": endTimestamp, - "session_ids": session_ids, **__get_constraint_values(args)}) + browsers = ch.execute(query=ch_query, params=params) total = sum(r["total"] for r in browsers) for r in browsers: r["percentage"] = r["total"] / (total / 100) @@ -1546,12 +1538,12 @@ def get_domains_errors(project_id, startTimestamp=TimeUTC.now(delta_days=-1), ch_query = f"""SELECT timestamp, groupArray([domain, toString(count)]) AS keys FROM (SELECT toUnixTimestamp(toStartOfInterval(resources.datetime, INTERVAL %(step_size)s second)) * 1000 AS timestamp, - resources.url_host AS domain, COUNT(resources.session_id) AS count + resources.url_host AS domain, COUNT(1) AS count FROM resources {"INNER JOIN sessions_metadata USING(session_id)" if len(meta_condition) > 0 else ""} WHERE {" AND ".join(ch_sub_query)} GROUP BY timestamp,resources.url_host ORDER BY timestamp, count DESC - LIMIT 5) AS domain_stats + LIMIT 5 BY timestamp) AS domain_stats GROUP BY timestamp;""" params = {"project_id": project_id, "startTimestamp": startTimestamp, @@ -1577,8 +1569,8 @@ def get_domains_errors(project_id, startTimestamp=TimeUTC.now(delta_days=-1), return result -def get_domains_errors_4xx(project_id, startTimestamp=TimeUTC.now(delta_days=-1), - endTimestamp=TimeUTC.now(), density=6, **args): +def __get_domains_errors_4xx_and_5xx(status, project_id, startTimestamp=TimeUTC.now(delta_days=-1), + endTimestamp=TimeUTC.now(), density=6, **args): step_size = __get_step_size(startTimestamp, endTimestamp, density) ch_sub_query = __get_basic_constraints(table_name="resources", round_start=True, data=args) ch_sub_query.append("intDiv(resources.status, 100) == %(status_code)s") @@ -1589,18 +1581,18 @@ def get_domains_errors_4xx(project_id, startTimestamp=TimeUTC.now(delta_days=-1) ch_query = f"""SELECT timestamp, groupArray([domain, toString(count)]) AS keys FROM (SELECT toUnixTimestamp(toStartOfInterval(resources.datetime, INTERVAL %(step_size)s second)) * 1000 AS timestamp, - resources.url_host AS domain, COUNT(resources.session_id) AS count + resources.url_host AS domain, COUNT(1) AS count FROM resources {"INNER JOIN sessions_metadata USING(session_id)" if len(meta_condition) > 0 else ""} WHERE {" AND ".join(ch_sub_query)} GROUP BY timestamp,resources.url_host ORDER BY timestamp, count DESC - LIMIT 5) AS domain_stats + LIMIT 5 BY timestamp) AS domain_stats GROUP BY timestamp;""" params = {"project_id": project_id, "startTimestamp": startTimestamp, "endTimestamp": endTimestamp, "step_size": step_size, - "status_code": 4, **__get_constraint_values(args)} + "status_code": status, **__get_constraint_values(args)} rows = ch.execute(query=ch_query, params=params) rows = __nested_array_to_dict_array(rows) neutral = __get_domains_errors_neutral(rows) @@ -1611,38 +1603,16 @@ def get_domains_errors_4xx(project_id, startTimestamp=TimeUTC.now(delta_days=-1) density=density, neutral=neutral) +def get_domains_errors_4xx(project_id, startTimestamp=TimeUTC.now(delta_days=-1), + endTimestamp=TimeUTC.now(), density=6, **args): + return __get_domains_errors_4xx_and_5xx(status=4, project_id=project_id, startTimestamp=startTimestamp, + endTimestamp=endTimestamp, density=density, **args) + + 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) - ch_sub_query = __get_basic_constraints(table_name="resources", round_start=True, data=args) - ch_sub_query.append("intDiv(resources.status, 100) == %(status_code)s") - meta_condition = __get_meta_constraint(args) - ch_sub_query += meta_condition - - with ch_client.ClickHouseClient() as ch: - ch_query = f"""SELECT timestamp, - groupArray([domain, toString(count)]) AS keys - FROM (SELECT toUnixTimestamp(toStartOfInterval(resources.datetime, INTERVAL %(step_size)s second)) * 1000 AS timestamp, - resources.url_host AS domain, COUNT(resources.session_id) AS count - FROM resources {"INNER JOIN sessions_metadata USING(session_id)" if len(meta_condition) > 0 else ""} - WHERE {" AND ".join(ch_sub_query)} - GROUP BY timestamp,resources.url_host - ORDER BY timestamp, count DESC - LIMIT 5) AS domain_stats - GROUP BY timestamp;""" - params = {"project_id": project_id, - "startTimestamp": startTimestamp, - "endTimestamp": endTimestamp, - "step_size": step_size, - "status_code": 5, **__get_constraint_values(args)} - rows = ch.execute(query=ch_query, params=params) - rows = __nested_array_to_dict_array(rows) - neutral = __get_domains_errors_neutral(rows) - rows = __merge_rows_with_neutral(rows, neutral) - - return __complete_missing_steps(rows=rows, start_time=startTimestamp, - end_time=endTimestamp, - density=density, neutral=neutral) + return __get_domains_errors_4xx_and_5xx(status=5, project_id=project_id, startTimestamp=startTimestamp, + endTimestamp=endTimestamp, density=density, **args) def __nested_array_to_dict_array(rows): @@ -1690,16 +1660,16 @@ def get_errors_per_domains(project_id, startTimestamp=TimeUTC.now(delta_days=-1) with ch_client.ClickHouseClient() as ch: ch_query = f"""SELECT resources.url_host AS domain, - COUNT(resources.session_id) AS errors_count + COUNT(1) AS errors_count FROM resources {"INNER JOIN sessions_metadata USING(session_id)" if len(meta_condition) > 0 else ""} WHERE {" AND ".join(ch_sub_query)} GROUP BY resources.url_host ORDER BY errors_count DESC LIMIT 5;""" - rows = ch.execute(query=ch_query, - params={"project_id": project_id, - "startTimestamp": startTimestamp, - "endTimestamp": endTimestamp, **__get_constraint_values(args)}) + params = {"project_id": project_id, + "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, **__get_constraint_values(args)} + rows = ch.execute(query=ch_query, params=params) return helper.list_to_camel_case(rows) @@ -1716,7 +1686,7 @@ def get_sessions_per_browser(project_id, startTimestamp=TimeUTC.now(delta_days=- FROM ( SELECT sessions.user_browser, - COUNT(sessions.session_id) AS count + COUNT(1) AS count FROM sessions {"INNER JOIN sessions_metadata USING(session_id)" if len(meta_condition) > 0 else ""} WHERE {" AND ".join(ch_sub_query)} GROUP BY sessions.user_browser @@ -1727,7 +1697,7 @@ def get_sessions_per_browser(project_id, startTimestamp=TimeUTC.now(delta_days=- ( SELECT sessions.user_browser, sessions.user_browser_version, - COUNT(sessions.session_id) AS count + COUNT(1) AS count FROM sessions {"INNER JOIN sessions_metadata USING(session_id)" if len(meta_condition) > 0 else ""} WHERE {" AND ".join(ch_sub_query)} GROUP BY @@ -1739,10 +1709,10 @@ def get_sessions_per_browser(project_id, startTimestamp=TimeUTC.now(delta_days=- GROUP BY b.user_browser, b.count ORDER BY b.count DESC;""" - rows = ch.execute(query=ch_query, - params={"project_id": project_id, - "startTimestamp": startTimestamp, - "endTimestamp": endTimestamp, **__get_constraint_values(args)}) + params = {"project_id": project_id, + "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, **__get_constraint_values(args)} + rows = ch.execute(query=ch_query, params=params) for i, r in enumerate(rows): versions = {} for j in range(len(r["versions"])): @@ -1763,67 +1733,58 @@ def get_calls_errors(project_id, startTimestamp=TimeUTC.now(delta_days=-1), endT with ch_client.ClickHouseClient() as ch: ch_query = f"""SELECT resources.method, resources.url_hostpath, - COUNT(resources.session_id) AS all_requests, + COUNT(1) AS all_requests, SUM(if(intDiv(resources.status, 100) == 4, 1, 0)) AS _4xx, SUM(if(intDiv(resources.status, 100) == 5, 1, 0)) AS _5xx FROM resources {"INNER JOIN sessions_metadata USING(session_id)" if len(meta_condition) > 0 else ""} WHERE {" AND ".join(ch_sub_query)} GROUP BY resources.method, resources.url_hostpath - ORDER BY (_4xx + _5xx), all_requests DESC + ORDER BY (_4xx + _5xx) DESC, all_requests DESC LIMIT 50;""" - rows = ch.execute(query=ch_query, - params={"project_id": project_id, - "startTimestamp": startTimestamp, - "endTimestamp": endTimestamp, **__get_constraint_values(args)}) + params = {"project_id": project_id, + "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, **__get_constraint_values(args)} + rows = ch.execute(query=ch_query, params=params) + return helper.list_to_camel_case(rows) + + +def __get_calls_errors_4xx_or_5xx(status, project_id, startTimestamp=TimeUTC.now(delta_days=-1), + endTimestamp=TimeUTC.now(), + platform=None, **args): + ch_sub_query = __get_basic_constraints(table_name="resources", data=args) + ch_sub_query.append("resources.type = 'fetch'") + ch_sub_query.append(f"intDiv(resources.status, 100) == {status}") + meta_condition = __get_meta_constraint(args) + ch_sub_query += meta_condition + + with ch_client.ClickHouseClient() as ch: + ch_query = f"""SELECT resources.method, + resources.url_hostpath, + COUNT(1) AS all_requests + FROM resources {"INNER JOIN sessions_metadata USING(session_id)" if len(meta_condition) > 0 else ""} + WHERE {" AND ".join(ch_sub_query)} + GROUP BY resources.method, resources.url_hostpath + ORDER BY all_requests DESC + LIMIT 10;""" + params = {"project_id": project_id, + "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, **__get_constraint_values(args)} + rows = ch.execute(query=ch_query, params=params) return helper.list_to_camel_case(rows) def get_calls_errors_4xx(project_id, startTimestamp=TimeUTC.now(delta_days=-1), endTimestamp=TimeUTC.now(), platform=None, **args): - ch_sub_query = __get_basic_constraints(table_name="resources", data=args) - ch_sub_query.append("resources.type = 'fetch'") - ch_sub_query.append("intDiv(resources.status, 100) == 4") - meta_condition = __get_meta_constraint(args) - ch_sub_query += meta_condition - - with ch_client.ClickHouseClient() as ch: - ch_query = f"""SELECT resources.method, - resources.url_hostpath, - COUNT(resources.session_id) AS all_requests - FROM resources {"INNER JOIN sessions_metadata USING(session_id)" if len(meta_condition) > 0 else ""} - WHERE {" AND ".join(ch_sub_query)} - GROUP BY resources.method, resources.url_hostpath - ORDER BY all_requests DESC - LIMIT 10;""" - rows = ch.execute(query=ch_query, - params={"project_id": project_id, - "startTimestamp": startTimestamp, - "endTimestamp": endTimestamp, **__get_constraint_values(args)}) - return helper.list_to_camel_case(rows) + return __get_calls_errors_4xx_or_5xx(status=4, project_id=project_id, startTimestamp=startTimestamp, + endTimestamp=endTimestamp, + platform=platform, **args) def get_calls_errors_5xx(project_id, startTimestamp=TimeUTC.now(delta_days=-1), endTimestamp=TimeUTC.now(), platform=None, **args): - ch_sub_query = __get_basic_constraints(table_name="resources", data=args) - ch_sub_query.append("resources.type = 'fetch'") - ch_sub_query.append("intDiv(resources.status, 100) == 5") - meta_condition = __get_meta_constraint(args) - ch_sub_query += meta_condition - - with ch_client.ClickHouseClient() as ch: - ch_query = f"""SELECT resources.method, - resources.url_hostpath, - COUNT(resources.session_id) AS all_requests - FROM resources {"INNER JOIN sessions_metadata USING(session_id)" if len(meta_condition) > 0 else ""} - WHERE {" AND ".join(ch_sub_query)} - GROUP BY resources.method, resources.url_hostpath - ORDER BY all_requests DESC - LIMIT 10;""" - rows = ch.execute(query=ch_query, - params={"project_id": project_id, - "startTimestamp": startTimestamp, - "endTimestamp": endTimestamp, **__get_constraint_values(args)}) - return helper.list_to_camel_case(rows) + return __get_calls_errors_4xx_or_5xx(status=5, project_id=project_id, startTimestamp=startTimestamp, + endTimestamp=endTimestamp, + platform=platform, **args) def get_errors_per_type(project_id, startTimestamp=TimeUTC.now(delta_days=-1), endTimestamp=TimeUTC.now(), @@ -1866,15 +1827,11 @@ def get_errors_per_type(project_id, startTimestamp=TimeUTC.now(delta_days=-1), e "startTimestamp": startTimestamp, "endTimestamp": endTimestamp, **__get_constraint_values(args)} rows = helper.list_to_camel_case(ch.execute(query=ch_query, params=params)) - for r in rows: - print(r) + return __complete_missing_steps(rows=rows, start_time=startTimestamp, end_time=endTimestamp, density=density, - neutral={"4xx": 0, - "5xx": 0, - "js": 0, - "integrations": 0}) + neutral={"4xx": 0, "5xx": 0, "js": 0, "integrations": 0}) def resource_type_vs_response_end(project_id, startTimestamp=TimeUTC.now(delta_days=-1), @@ -1894,7 +1851,7 @@ def resource_type_vs_response_end(project_id, startTimestamp=TimeUTC.now(delta_d "endTimestamp": endTimestamp, **__get_constraint_values(args)} with ch_client.ClickHouseClient() as ch: ch_query = f"""SELECT toUnixTimestamp(toStartOfInterval(resources.datetime, INTERVAL %(step_size)s second)) * 1000 AS timestamp, - COUNT(resources.session_id) AS total, + COUNT(1) AS total, SUM(if(resources.type='fetch',1,0)) AS xhr FROM resources {"INNER JOIN sessions_metadata USING(session_id)" if len(meta_condition) > 0 else ""} WHERE {" AND ".join(ch_sub_query_chart)} @@ -1962,10 +1919,8 @@ def get_resources_vs_visually_complete(project_id, startTimestamp=TimeUTC.now(de endTimestamp=TimeUTC.now(), density=7, **args): step_size = __get_step_size(startTimestamp, endTimestamp, density) ch_sub_query = __get_basic_constraints(table_name="resources", data=args) - ch_sub_query_chart = __get_basic_constraints(table_name="resources", round_start=True, data=args) meta_condition = __get_meta_constraint(args) ch_sub_query += meta_condition - ch_sub_query_chart += meta_condition with ch_client.ClickHouseClient() as ch: ch_query = f"""SELECT toUnixTimestamp(toStartOfInterval(s.base_datetime, toIntervalSecond(%(step_size)s))) * 1000 AS timestamp, @@ -1974,27 +1929,27 @@ def get_resources_vs_visually_complete(project_id, startTimestamp=TimeUTC.now(de FROM ( SELECT resources.session_id, MIN(resources.datetime) AS base_datetime, - COUNT(resources.url) AS count + COUNT(1) AS count FROM resources {"INNER JOIN sessions_metadata USING(session_id)" if len(meta_condition) > 0 else ""} - WHERE {" AND ".join(ch_sub_query_chart)} + WHERE {" AND ".join(ch_sub_query)} GROUP BY resources.session_id ) AS s INNER JOIN (SELECT session_id, type, COALESCE(avgOrNull(NULLIF(count,0)),0) AS xavg - FROM (SELECT resources.session_id, resources.type, COUNT(resources.url) AS count + FROM (SELECT resources.session_id, resources.type, COUNT(1) AS count FROM resources {"INNER JOIN sessions_metadata USING(session_id)" if len(meta_condition) > 0 else ""} WHERE {" AND ".join(ch_sub_query)} GROUP BY resources.session_id, resources.type) AS ss GROUP BY ss.session_id, ss.type) AS t USING (session_id) GROUP BY timestamp ORDER BY timestamp ASC;""" - rows = ch.execute(query=ch_query, - params={"step_size": step_size, - "project_id": project_id, - "startTimestamp": startTimestamp, - "endTimestamp": endTimestamp, **__get_constraint_values(args)}) + params = {"step_size": step_size, + "project_id": project_id, + "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, **__get_constraint_values(args)} + rows = ch.execute(query=ch_query, params=params) for r in rows: types = {} for i in range(len(r["types"])): @@ -2030,17 +1985,17 @@ def get_resources_count_by_type(project_id, startTimestamp=TimeUTC.now(delta_day groupArray([toString(t.type), toString(t.count)]) AS types FROM(SELECT toUnixTimestamp(toStartOfInterval(resources.datetime, INTERVAL %(step_size)s second)) * 1000 AS timestamp, resources.type, - COUNT(resources.session_id) AS count + COUNT(1) AS count FROM resources {"INNER JOIN sessions_metadata USING(session_id)" if len(meta_condition) > 0 else ""} WHERE {" AND ".join(ch_sub_query_chart)} GROUP BY timestamp,resources.type ORDER BY timestamp) AS t GROUP BY timestamp;""" - rows = ch.execute(query=ch_query, - params={"step_size": step_size, - "project_id": project_id, - "startTimestamp": startTimestamp, - "endTimestamp": endTimestamp, **__get_constraint_values(args)}) + params = {"step_size": step_size, + "project_id": project_id, + "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, **__get_constraint_values(args)} + rows = ch.execute(query=ch_query, params=params) for r in rows: for t in r["types"]: r[t[0]] = t[1] @@ -2056,6 +2011,7 @@ def get_resources_by_party(project_id, startTimestamp=TimeUTC.now(delta_days=-1) step_size = __get_step_size(startTimestamp, endTimestamp, density) ch_sub_query = __get_basic_constraints(table_name="resources", round_start=True, data=args) ch_sub_query.append("resources.success = 0") + ch_sub_query.append("resources.type IN ('fetch','script')") sch_sub_query = ["rs.project_id =toUInt32(%(project_id)s)", "rs.type IN ('fetch','script')"] meta_condition = __get_meta_constraint(args) ch_sub_query += meta_condition @@ -2063,8 +2019,8 @@ def get_resources_by_party(project_id, startTimestamp=TimeUTC.now(delta_days=-1) with ch_client.ClickHouseClient() as ch: ch_query = f"""SELECT toUnixTimestamp(toStartOfInterval(sub_resources.datetime, INTERVAL %(step_size)s second)) * 1000 AS timestamp, - SUM(if(first.url_host = sub_resources.url_host, 1, 0)) AS first_party, - SUM(if(first.url_host = sub_resources.url_host, 0, 1)) AS third_party + SUM(first.url_host = sub_resources.url_host) AS first_party, + SUM(first.url_host != sub_resources.url_host) AS third_party FROM ( SELECT resources.datetime, resources.url_host @@ -2075,7 +2031,7 @@ def get_resources_by_party(project_id, startTimestamp=TimeUTC.now(delta_days=-1) ( SELECT rs.url_host, - COUNT(rs.session_id) AS count + COUNT(1) AS count FROM resources AS rs WHERE {" AND ".join(sch_sub_query)} GROUP BY rs.url_host @@ -2084,11 +2040,11 @@ def get_resources_by_party(project_id, startTimestamp=TimeUTC.now(delta_days=-1) ) AS first GROUP BY timestamp ORDER BY timestamp;""" - rows = ch.execute(query=ch_query, - params={"step_size": step_size, - "project_id": project_id, - "startTimestamp": startTimestamp, - "endTimestamp": endTimestamp, **__get_constraint_values(args)}) + params = {"step_size": step_size, + "project_id": project_id, + "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, **__get_constraint_values(args)} + rows = ch.execute(query=ch_query, params=params) return helper.list_to_camel_case(__complete_missing_steps(rows=rows, start_time=startTimestamp, end_time=endTimestamp, density=density, @@ -2476,7 +2432,7 @@ def __get_user_activity_avg_visited_pages(ch, project_id, startTimestamp, endTim ch_sub_query += meta_condition ch_query = f"""SELECT COALESCE(CEIL(avgOrNull(count)),0) AS value - FROM (SELECT COUNT(session_id) AS count + FROM (SELECT COUNT(1) AS count FROM pages {"INNER JOIN sessions_metadata USING(session_id)" if len(meta_condition) > 0 else ""} WHERE {" AND ".join(ch_sub_query)} GROUP BY session_id) AS groupped_data @@ -2496,10 +2452,10 @@ def __get_user_activity_avg_visited_pages_chart(ch, project_id, startTimestamp, ch_sub_query_chart += meta_condition params = {"step_size": step_size, "project_id": project_id, "startTimestamp": startTimestamp, - "endTimestamp": endTimestamp} + "endTimestamp": endTimestamp, **__get_constraint_values(args)} ch_query = f"""SELECT timestamp, COALESCE(avgOrNull(count), 0) AS value FROM (SELECT toUnixTimestamp(toStartOfInterval(pages.datetime, INTERVAL %(step_size)s second ))*1000 AS timestamp, - session_id, COUNT(pages.session_id) AS count + session_id, COUNT(1) AS count FROM pages {"INNER JOIN sessions_metadata USING(session_id)" if len(meta_condition) > 0 else ""} WHERE {" AND ".join(ch_sub_query_chart)} GROUP BY timestamp,session_id @@ -2507,7 +2463,7 @@ def __get_user_activity_avg_visited_pages_chart(ch, project_id, startTimestamp, WHERE count>0 GROUP BY timestamp ORDER BY timestamp;""" - rows = ch.execute(query=ch_query, params={**params, **__get_constraint_values(args)}) + rows = ch.execute(query=ch_query, params=params) rows = __complete_missing_steps(rows=rows, start_time=startTimestamp, end_time=endTimestamp, density=density, neutral={"value": 0}) @@ -2604,11 +2560,11 @@ def get_top_metrics_avg_response_time(project_id, startTimestamp=TimeUTC.now(del rows = ch.execute(query=ch_query, params=params) results = rows[0] ch_query = f"""SELECT toUnixTimestamp(toStartOfInterval(pages.datetime, INTERVAL %(step_size)s second ))*1000 AS timestamp, - COUNT(pages.response_time) AS value - FROM pages {"INNER JOIN sessions_metadata USING(session_id)" if len(meta_condition) > 0 else ""} - WHERE {" AND ".join(ch_sub_query_chart)} AND isNotNull(pages.response_time) AND pages.response_time>0 - GROUP BY timestamp - ORDER BY timestamp;""" + COALESCE(avgOrNull(pages.response_time),0) AS value + FROM pages {"INNER JOIN sessions_metadata USING(session_id)" if len(meta_condition) > 0 else ""} + WHERE {" AND ".join(ch_sub_query_chart)} AND isNotNull(pages.response_time) AND pages.response_time>0 + GROUP BY timestamp + ORDER BY timestamp;""" rows = ch.execute(query=ch_query, params={**params, **__get_constraint_values(args)}) rows = __complete_missing_steps(rows=rows, start_time=startTimestamp, end_time=endTimestamp, @@ -2631,7 +2587,7 @@ def get_top_metrics_count_requests(project_id, startTimestamp=TimeUTC.now(delta_ ch_sub_query.append("pages.url_path = %(value)s") ch_sub_query_chart.append("pages.url_path = %(value)s") with ch_client.ClickHouseClient() as ch: - ch_query = f"""SELECT COUNT(pages.session_id) AS value + ch_query = f"""SELECT COUNT(1) AS value FROM pages {"INNER JOIN sessions_metadata USING(session_id)" if len(meta_condition) > 0 else ""} WHERE {" AND ".join(ch_sub_query)};""" params = {"step_size": step_size, "project_id": project_id, @@ -2641,7 +2597,7 @@ def get_top_metrics_count_requests(project_id, startTimestamp=TimeUTC.now(delta_ rows = ch.execute(query=ch_query, params=params) result = rows[0] ch_query = f"""SELECT toUnixTimestamp(toStartOfInterval(pages.datetime, INTERVAL %(step_size)s second ))*1000 AS timestamp, - COUNT(pages.session_id) AS value + COUNT(1) AS value FROM pages {"INNER JOIN sessions_metadata USING(session_id)" if len(meta_condition) > 0 else ""} WHERE {" AND ".join(ch_sub_query_chart)} GROUP BY timestamp diff --git a/ee/api/chalicelib/core/metrics_exp.py b/ee/api/chalicelib/core/metrics_exp.py new file mode 100644 index 000000000..04e180e93 --- /dev/null +++ b/ee/api/chalicelib/core/metrics_exp.py @@ -0,0 +1,2800 @@ +import math + +import schemas +from chalicelib.utils import pg_client, exp_ch_helper +from chalicelib.utils import args_transformer +from chalicelib.utils import helper +from chalicelib.utils.TimeUTC import TimeUTC +from chalicelib.utils import ch_client +from math import isnan +from chalicelib.utils.metrics_helper import __get_step_size + + +def __get_basic_constraints(table_name=None, time_constraint=True, round_start=False, data={}, identifier="project_id"): + if table_name: + table_name += "." + else: + table_name = "" + ch_sub_query = [f"{table_name}{identifier} =toUInt16(%({identifier})s)"] + if time_constraint: + if round_start: + ch_sub_query.append( + f"toStartOfInterval({table_name}datetime, INTERVAL %(step_size)s second) >= toDateTime(%(startTimestamp)s/1000)") + else: + ch_sub_query.append(f"{table_name}datetime >= toDateTime(%(startTimestamp)s/1000)") + ch_sub_query.append(f"{table_name}datetime < toDateTime(%(endTimestamp)s/1000)") + return ch_sub_query + __get_generic_constraint(data=data, table_name=table_name) + + +def __frange(start, stop, step): + result = [] + i = start + while i < stop: + result.append(i) + i += step + return result + + +def __add_missing_keys(original, complete): + for missing in [key for key in complete.keys() if key not in original.keys()]: + original[missing] = complete[missing] + return original + + +def __complete_missing_steps(start_time, end_time, density, neutral, rows, time_key="timestamp", time_coefficient=1000): + if len(rows) == density: + return rows + step = __get_step_size(start_time, end_time, density, decimal=True) + optimal = [(int(i * time_coefficient), int((i + step) * time_coefficient)) for i in + __frange(start_time // time_coefficient, end_time // time_coefficient, step)] + result = [] + r = 0 + o = 0 + for i in range(density): + neutral_clone = dict(neutral) + for k in neutral_clone.keys(): + if callable(neutral_clone[k]): + neutral_clone[k] = neutral_clone[k]() + if r < len(rows) and len(result) + len(rows) - r == density: + result += rows[r:] + break + if r < len(rows) and o < len(optimal) and rows[r][time_key] < optimal[o][0]: + # complete missing keys in original object + rows[r] = __add_missing_keys(original=rows[r], complete=neutral_clone) + result.append(rows[r]) + r += 1 + elif r < len(rows) and o < len(optimal) and optimal[o][0] <= rows[r][time_key] < optimal[o][1]: + # complete missing keys in original object + rows[r] = __add_missing_keys(original=rows[r], complete=neutral_clone) + result.append(rows[r]) + r += 1 + o += 1 + else: + neutral_clone[time_key] = optimal[o][0] + result.append(neutral_clone) + o += 1 + # elif r < len(rows) and rows[r][time_key] >= optimal[o][1]: + # neutral_clone[time_key] = optimal[o][0] + # result.append(neutral_clone) + # o += 1 + # else: + # neutral_clone[time_key] = optimal[o][0] + # result.append(neutral_clone) + # o += 1 + return result + + +def __merge_charts(list1, list2, time_key="timestamp"): + if len(list1) != len(list2): + raise Exception("cannot merge unequal lists") + result = [] + for i in range(len(list1)): + timestamp = min(list1[i][time_key], list2[i][time_key]) + result.append({**list1[i], **list2[i], time_key: timestamp}) + return result + + +def __get_constraint(data, fields, table_name): + constraints = [] + # for k in fields.keys(): + for i, f in enumerate(data.get("filters", [])): + if f["key"] in fields.keys(): + if f["value"] in ["*", ""]: + constraints.append(f"isNotNull({table_name}{fields[f['key']]})") + else: + constraints.append(f"{table_name}{fields[f['key']]} = %({f['key']}_{i})s") + # TODO: remove this in next release + offset = len(data.get("filters", [])) + for i, f in enumerate(data.keys()): + if f in fields.keys(): + if data[f] in ["*", ""]: + constraints.append(f"isNotNull({table_name}{fields[f]})") + else: + constraints.append(f"{table_name}{fields[f]} = %({f}_{i + offset})s") + return constraints + + +def __get_constraint_values(data): + params = {} + for i, f in enumerate(data.get("filters", [])): + params[f"{f['key']}_{i}"] = f["value"] + + # TODO: remove this in next release + offset = len(data.get("filters", [])) + for i, f in enumerate(data.keys()): + params[f"{f}_{i + offset}"] = data[f] + return params + + +METADATA_FIELDS = {"userId": "user_id", + "userAnonymousId": "user_anonymous_id", + "metadata1": "metadata_1", + "metadata2": "metadata_2", + "metadata3": "metadata_3", + "metadata4": "metadata_4", + "metadata5": "metadata_5", + "metadata6": "metadata_6", + "metadata7": "metadata_7", + "metadata8": "metadata_8", + "metadata9": "metadata_9", + "metadata10": "metadata_10"} + + +def __get_meta_constraint(data): + return __get_constraint(data=data, fields=METADATA_FIELDS, table_name="sessions_metadata.") + + +SESSIONS_META_FIELDS = {"revId": "rev_id", + "country": "user_country", + "os": "user_os", + "platform": "user_device_type", + "device": "user_device", + "browser": "user_browser"} + + +def __get_generic_constraint(data, table_name): + return __get_constraint(data=data, fields=SESSIONS_META_FIELDS, table_name=table_name) + + +def get_processed_sessions(project_id, startTimestamp=TimeUTC.now(delta_days=-1), + endTimestamp=TimeUTC.now(), + density=7, **args): + step_size = __get_step_size(startTimestamp, endTimestamp, density) + ch_sub_query = __get_basic_constraints(table_name="sessions", data=args) + ch_sub_query_chart = __get_basic_constraints(table_name="sessions", round_start=True, data=args) + meta_condition = __get_meta_constraint(args) + ch_sub_query += meta_condition + ch_sub_query_chart += meta_condition + with ch_client.ClickHouseClient() as ch: + ch_query = f"""\ + SELECT toUnixTimestamp(toStartOfInterval(sessions.datetime, INTERVAL %(step_size)s second)) * 1000 AS timestamp, + COUNT(DISTINCT sessions.session_id) AS value + FROM {exp_ch_helper.get_main_sessions_table(startTimestamp)} AS sessions + WHERE {" AND ".join(ch_sub_query_chart)} + GROUP BY timestamp + ORDER BY timestamp;\ + """ + params = {"step_size": step_size, "project_id": project_id, "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, **__get_constraint_values(args)} + + rows = ch.execute(query=ch_query, params=params) + + results = { + "value": sum([r["value"] for r in rows]), + "chart": __complete_missing_steps(rows=rows, start_time=startTimestamp, end_time=endTimestamp, + density=density, + neutral={"value": 0}) + } + + diff = endTimestamp - startTimestamp + endTimestamp = startTimestamp + startTimestamp = endTimestamp - diff + + ch_query = f""" SELECT COUNT(1) AS count + FROM {exp_ch_helper.get_main_sessions_table(startTimestamp)} AS sessions + WHERE {" AND ".join(ch_sub_query)};""" + params = {"project_id": project_id, "startTimestamp": startTimestamp, "endTimestamp": endTimestamp, + **__get_constraint_values(args)} + + count = ch.execute(query=ch_query, params=params) + + count = count[0]["count"] + + results["progress"] = helper.__progress(old_val=count, new_val=results["value"]) + results["unit"] = schemas.TemplatePredefinedUnits.count + return results + + +def get_errors(project_id, startTimestamp=TimeUTC.now(delta_days=-1), endTimestamp=TimeUTC.now(), + density=7, **args): + step_size = __get_step_size(startTimestamp, endTimestamp, density) + + ch_sub_query = __get_basic_constraints(table_name="errors", data=args) + ch_sub_query.append("errors.event_type = 'ERROR'") + ch_sub_query.append("errors.source = 'js_exception'") + ch_sub_query_chart = __get_basic_constraints(table_name="errors", round_start=True, data=args) + ch_sub_query_chart.append("errors.event_type = 'ERROR'") + ch_sub_query_chart.append("errors.source = 'js_exception'") + meta_condition = __get_meta_constraint(args) + ch_sub_query += meta_condition + ch_sub_query_chart += meta_condition + + with ch_client.ClickHouseClient() as ch: + ch_query = f"""\ + SELECT toUnixTimestamp(toStartOfInterval(errors.datetime, INTERVAL %(step_size)s second)) * 1000 AS timestamp, + COUNT(DISTINCT errors.session_id) AS count + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS errors + WHERE {" AND ".join(ch_sub_query_chart)} + GROUP BY timestamp + ORDER BY timestamp;\ + """ + params = {"step_size": step_size, "project_id": project_id, "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, **__get_constraint_values(args)} + rows = ch.execute(query=ch_query, params=params) + results = { + "count": 0 if len(rows) == 0 else __count_distinct_errors(ch, project_id, startTimestamp, endTimestamp, + ch_sub_query), + "impactedSessions": sum([r["count"] for r in rows]), + "chart": __complete_missing_steps(rows=rows, start_time=startTimestamp, end_time=endTimestamp, + density=density, + neutral={"count": 0}) + } + + diff = endTimestamp - startTimestamp + endTimestamp = startTimestamp + startTimestamp = endTimestamp - diff + count = __count_distinct_errors(ch, project_id, startTimestamp, endTimestamp, ch_sub_query, + meta=len(meta_condition) > 0, **args) + results["progress"] = helper.__progress(old_val=count, new_val=results["count"]) + return results + + +def __count_distinct_errors(ch, project_id, startTimestamp, endTimestamp, ch_sub_query, meta=False, **args): + ch_query = f"""\ + SELECT + COUNT(DISTINCT errors.message) AS count + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS errors + WHERE {" AND ".join(ch_sub_query)};""" + count = ch.execute(query=ch_query, + params={"project_id": project_id, "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, **__get_constraint_values(args)}) + + if count is not None and len(count) > 0: + return count[0]["count"] + + return 0 + + +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) + ch_sub_query = __get_basic_constraints(table_name="errors", data=args) + ch_sub_query.append("errors.event_type='ERROR'") + ch_sub_query_chart = __get_basic_constraints(table_name="errors", round_start=True, data=args) + ch_sub_query_chart.append("errors.event_type='ERROR'") + meta_condition = __get_meta_constraint(args) + ch_sub_query += meta_condition + ch_sub_query_chart += meta_condition + + with ch_client.ClickHouseClient() as ch: + ch_query = f"""SELECT * + FROM (SELECT errors.error_id AS error_id, + errors.message AS error, + COUNT(1) AS count, + COUNT(DISTINCT errors.session_id) AS sessions + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS errors + WHERE {" AND ".join(ch_sub_query)} + GROUP BY errors.error_id, errors.message) AS errors_chart + INNER JOIN (SELECT error_id AS error_id, + toUnixTimestamp(MAX(datetime))*1000 AS lastOccurrenceAt, + toUnixTimestamp(MIN(datetime))*1000 AS firstOccurrenceAt + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS errors + WHERE event_type='ERROR' AND project_id=%(project_id)s + GROUP BY error_id) AS errors_time USING(error_id) + ORDER BY sessions DESC, count DESC LIMIT 10;""" + params = {"step_size": step_size, "project_id": project_id, "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, **__get_constraint_values(args)} + rows = ch.execute(query=ch_query, params=params) + + # print(f"got {len(rows)} rows") + if len(rows) == 0: + return [] + error_ids = [r["error_id"] for r in rows] + ch_sub_query.append("error_id = %(error_id)s") + errors = {} + for error_id in error_ids: + ch_query = f"""\ + SELECT toUnixTimestamp(toStartOfInterval(errors.datetime, INTERVAL %(step_size)s second)) * 1000 AS timestamp, + COUNT(1) AS count + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS errors + WHERE {" AND ".join(ch_sub_query_chart)} + GROUP BY timestamp + ORDER BY timestamp;""" + params["error_id"] = error_id + errors[error_id] = ch.execute(query=ch_query, params=params) + + for row in rows: + row["startTimestamp"] = startTimestamp + row["endTimestamp"] = endTimestamp + row["chart"] = __complete_missing_steps(rows=errors[row["error_id"]], start_time=startTimestamp, + end_time=endTimestamp, + density=density, + neutral={"count": 0}) + + return rows + + +def get_page_metrics(project_id, startTimestamp=TimeUTC.now(delta_days=-1), + endTimestamp=TimeUTC.now(), **args): + with ch_client.ClickHouseClient() as ch: + rows = __get_page_metrics(ch, project_id, startTimestamp, endTimestamp, **args) + if len(rows) > 0: + results = helper.dict_to_camel_case(rows[0]) + diff = endTimestamp - startTimestamp + endTimestamp = startTimestamp + startTimestamp = endTimestamp - diff + rows = __get_page_metrics(ch, project_id, startTimestamp, endTimestamp, **args) + if len(rows) > 0: + previous = helper.dict_to_camel_case(rows[0]) + for key in previous.keys(): + results[key + "Progress"] = helper.__progress(old_val=previous[key], new_val=results[key]) + return results + + +def __get_page_metrics(ch, project_id, startTimestamp, endTimestamp, **args): + ch_sub_query = __get_basic_constraints(table_name="pages", data=args) + ch_sub_query.append("pages.event_type='LOCATION'") + meta_condition = __get_meta_constraint(args) + ch_sub_query += meta_condition + ch_sub_query.append("(pages.dom_content_loaded_event_end>0 OR pages.first_contentful_paint_time>0)") + # changed dom_content_loaded_event_start to dom_content_loaded_event_end + ch_query = f"""SELECT COALESCE(avgOrNull(NULLIF(pages.dom_content_loaded_event_end ,0)),0) AS avg_dom_content_load_start, + COALESCE(avgOrNull(NULLIF(pages.first_contentful_paint_time,0)),0) AS avg_first_contentful_pixel + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS pages + WHERE {" AND ".join(ch_sub_query)};""" + params = {"project_id": project_id, "type": 'fetch', "startTimestamp": startTimestamp, "endTimestamp": endTimestamp, + **__get_constraint_values(args)} + rows = ch.execute(query=ch_query, params=params) + return rows + + +def get_application_activity(project_id, startTimestamp=TimeUTC.now(delta_days=-1), + endTimestamp=TimeUTC.now(), **args): + with ch_client.ClickHouseClient() as ch: + row = __get_application_activity(ch, project_id, startTimestamp, endTimestamp, **args) + results = helper.dict_to_camel_case(row) + diff = endTimestamp - startTimestamp + endTimestamp = startTimestamp + startTimestamp = endTimestamp - diff + row = __get_application_activity(ch, project_id, startTimestamp, endTimestamp, **args) + previous = helper.dict_to_camel_case(row) + for key in previous.keys(): + results[key + "Progress"] = helper.__progress(old_val=previous[key], new_val=results[key]) + return results + + +def __get_application_activity(ch, project_id, startTimestamp, endTimestamp, **args): + result = {} + ch_sub_query = __get_basic_constraints(table_name="pages", data=args) + ch_sub_query.append("pages.event_type='LOCATION'") + meta_condition = __get_meta_constraint(args) + ch_sub_query += meta_condition + + ch_query = f"""SELECT COALESCE(avgOrNull(pages.load_event_end),0) AS avg_page_load_time + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS pages + WHERE {" AND ".join(ch_sub_query)} AND pages.load_event_end>0;""" + params = {"project_id": project_id, "startTimestamp": startTimestamp, "endTimestamp": endTimestamp, + **__get_constraint_values(args)} + row = ch.execute(query=ch_query, params=params)[0] + result = {**result, **row} + + ch_sub_query = __get_basic_constraints(table_name="resources", data=args) + # ch_sub_query.append("events.event_type='RESOURCE'") + meta_condition = __get_meta_constraint(args) + ch_sub_query += meta_condition + ch_sub_query.append("resources.type= %(type)s") + ch_query = f"""SELECT COALESCE(avgOrNull(resources.duration),0) AS avg + FROM {exp_ch_helper.get_main_resources_table(startTimestamp)} AS resources + WHERE {" AND ".join(ch_sub_query)} AND resources.duration>0;""" + row = ch.execute(query=ch_query, + params={"project_id": project_id, "type": 'img', "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, **__get_constraint_values(args)})[0] + result = {**result, "avg_image_load_time": row["avg"]} + row = ch.execute(query=ch_query, + params={"project_id": project_id, "type": 'fetch', "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, **__get_constraint_values(args)})[0] + result = {**result, "avg_request_load_time": row["avg"]} + + for k in result: + if result[k] is None: + result[k] = 0 + return result + + +def get_user_activity(project_id, startTimestamp=TimeUTC.now(delta_days=-1), + endTimestamp=TimeUTC.now(), **args): + results = {} + + with ch_client.ClickHouseClient() as ch: + rows = __get_user_activity(ch, project_id, startTimestamp, endTimestamp, **args) + if len(rows) > 0: + results = helper.dict_to_camel_case(rows[0]) + for key in results: + if isnan(results[key]): + results[key] = 0 + diff = endTimestamp - startTimestamp + endTimestamp = startTimestamp + startTimestamp = endTimestamp - diff + rows = __get_user_activity(ch, project_id, startTimestamp, endTimestamp, **args) + + if len(rows) > 0: + previous = helper.dict_to_camel_case(rows[0]) + for key in previous: + results[key + "Progress"] = helper.__progress(old_val=previous[key], new_val=results[key]) + return results + + +def __get_user_activity(ch, project_id, startTimestamp, endTimestamp, **args): + ch_sub_query = __get_basic_constraints(table_name="sessions", data=args) + meta_condition = __get_meta_constraint(args) + ch_sub_query += meta_condition + ch_sub_query.append("(sessions.pages_count>0 OR sessions.duration>0)") + ch_query = f"""SELECT COALESCE(CEIL(avgOrNull(NULLIF(sessions.pages_count,0))),0) AS avg_visited_pages, + COALESCE(avgOrNull(NULLIF(sessions.duration,0)),0) AS avg_session_duration + FROM {exp_ch_helper.get_main_sessions_table(startTimestamp)} AS sessions + WHERE {" AND ".join(ch_sub_query)};""" + params = {"project_id": project_id, "startTimestamp": startTimestamp, "endTimestamp": endTimestamp, + **__get_constraint_values(args)} + + rows = ch.execute(query=ch_query, params=params) + + return rows + + +def get_slowest_images(project_id, startTimestamp=TimeUTC.now(delta_days=-1), + endTimestamp=TimeUTC.now(), + density=7, **args): + step_size = __get_step_size(endTimestamp=endTimestamp, startTimestamp=startTimestamp, density=density) + ch_sub_query = __get_basic_constraints(table_name="resources", data=args) + # ch_sub_query.append("events.event_type='RESOURCE'") + ch_sub_query.append("resources.type = 'img'") + ch_sub_query_chart = __get_basic_constraints(table_name="resources", round_start=True, data=args) + # ch_sub_query_chart.append("events.event_type='RESOURCE'") + ch_sub_query_chart.append("resources.type = 'img'") + ch_sub_query_chart.append("resources.url IN %(url)s") + meta_condition = __get_meta_constraint(args) + ch_sub_query += meta_condition + ch_sub_query_chart += meta_condition + + with ch_client.ClickHouseClient() as ch: + ch_query = f"""SELECT resources.url, + COALESCE(avgOrNull(resources.duration),0) AS avg, + COUNT(1) AS count + FROM {exp_ch_helper.get_main_resources_table(startTimestamp)} AS resources + WHERE {" AND ".join(ch_sub_query)} AND resources.duration>0 + GROUP BY resources.url ORDER BY avg DESC LIMIT 10;""" + params = {"step_size": step_size, "project_id": project_id, "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, **__get_constraint_values(args)} + rows = ch.execute(query=ch_query, params=params) + + rows = [{"url": i["url"], "avgDuration": i["avg"], "sessions": i["count"]} for i in rows] + if len(rows) == 0: + return [] + urls = [row["url"] for row in rows] + + charts = {} + ch_query = f"""SELECT url, + toUnixTimestamp(toStartOfInterval(resources.datetime, INTERVAL %(step_size)s second ))*1000 AS timestamp, + COALESCE(avgOrNull(resources.duration),0) AS avg + FROM {exp_ch_helper.get_main_resources_table(startTimestamp)} AS resources + WHERE {" AND ".join(ch_sub_query_chart)} AND resources.duration>0 + GROUP BY url, timestamp + ORDER BY url, timestamp;""" + params["url"] = urls + # print(ch.format(query=ch_query, params=params)) + u_rows = ch.execute(query=ch_query, params=params) + for url in urls: + sub_rows = [] + for r in u_rows: + if r["url"] == url: + sub_rows.append(r) + elif len(sub_rows) > 0: + break + charts[url] = [{"timestamp": int(i["timestamp"]), + "avgDuration": i["avg"]} + for i in __complete_missing_steps(rows=sub_rows, start_time=startTimestamp, + end_time=endTimestamp, + density=density, neutral={"avg": 0})] + for i in range(len(rows)): + rows[i] = helper.dict_to_camel_case(rows[i]) + rows[i]["chart"] = helper.list_to_camel_case(charts[rows[i]["url"]]) + + return sorted(rows, key=lambda k: k["sessions"], reverse=True) + + +def __get_performance_constraint(l): + if len(l) == 0: + return "" + l = [s.decode('UTF-8').replace("%", "%%") for s in l] + return f"AND ({' OR '.join(l)})" + + +def get_performance(project_id, startTimestamp=TimeUTC.now(delta_days=-1), endTimestamp=TimeUTC.now(), + density=19, resources=None, **args): + step_size = __get_step_size(endTimestamp=endTimestamp, startTimestamp=startTimestamp, density=density) + location_constraints = [] + img_constraints = [] + request_constraints = [] + ch_sub_query_chart = __get_basic_constraints(table_name="resources", round_start=True, data=args) + # ch_sub_query_chart.append("event_type='RESOURCE'") + meta_condition = __get_meta_constraint(args) + ch_sub_query_chart += meta_condition + + img_constraints_vals = {} + location_constraints_vals = {} + request_constraints_vals = {} + + if resources and len(resources) > 0: + for r in resources: + if r["type"] == "IMG": + img_constraints.append(f"resources.url = %(val_{len(img_constraints)})s") + img_constraints_vals["val_" + str(len(img_constraints) - 1)] = r['value'] + elif r["type"] == "LOCATION": + location_constraints.append(f"pages.url_path = %(val_{len(location_constraints)})s") + location_constraints_vals["val_" + str(len(location_constraints) - 1)] = r['value'] + else: + request_constraints.append(f"resources.url = %(val_{len(request_constraints)})s") + request_constraints_vals["val_" + str(len(request_constraints) - 1)] = r['value'] + params = {"step_size": step_size, "project_id": project_id, "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp} + with ch_client.ClickHouseClient() as ch: + ch_query = f"""SELECT toUnixTimestamp(toStartOfInterval(resources.datetime, INTERVAL %(step_size)s second ))*1000 AS timestamp, + COALESCE(avgOrNull(resources.duration),0) AS avg + FROM {exp_ch_helper.get_main_resources_table(startTimestamp)} AS resources + WHERE {" AND ".join(ch_sub_query_chart)} + AND resources.type = 'img' AND resources.duration>0 + {(f' AND ({" OR ".join(img_constraints)})') if len(img_constraints) > 0 else ""} + GROUP BY timestamp + ORDER BY timestamp;""" + rows = ch.execute(query=ch_query, params={**params, **img_constraints_vals, **__get_constraint_values(args)}) + images = [{"timestamp": i["timestamp"], "avgImageLoadTime": i["avg"]} for i in + __complete_missing_steps(rows=rows, start_time=startTimestamp, + end_time=endTimestamp, + density=density, neutral={"avg": 0})] + ch_query = f"""SELECT toUnixTimestamp(toStartOfInterval(resources.datetime, INTERVAL %(step_size)s second ))*1000 AS timestamp, + COALESCE(avgOrNull(resources.duration),0) AS avg + FROM {exp_ch_helper.get_main_resources_table(startTimestamp)} AS resources + WHERE {" AND ".join(ch_sub_query_chart)} + AND resources.type = 'fetch' AND resources.duration>0 + {(f' AND ({" OR ".join(request_constraints)})') if len(request_constraints) > 0 else ""} + GROUP BY timestamp + ORDER BY timestamp;""" + rows = ch.execute(query=ch_query, + params={**params, **request_constraints_vals, **__get_constraint_values(args)}) + requests = [{"timestamp": i["timestamp"], "avgRequestLoadTime": i["avg"]} for i in + __complete_missing_steps(rows=rows, start_time=startTimestamp, + end_time=endTimestamp, density=density, + neutral={"avg": 0})] + ch_sub_query_chart = __get_basic_constraints(table_name="pages", round_start=True, data=args) + ch_sub_query_chart.append("pages.event_type='LOCATION'") + ch_sub_query_chart += meta_condition + + ch_query = f"""SELECT toUnixTimestamp(toStartOfInterval(pages.datetime, INTERVAL %(step_size)s second ))*1000 AS timestamp, + COALESCE(avgOrNull(pages.load_event_end),0) AS avg + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS pages + WHERE {" AND ".join(ch_sub_query_chart)} AND pages.load_event_end>0 + {(f' AND ({" OR ".join(location_constraints)})') if len(location_constraints) > 0 else ""} + GROUP BY timestamp + ORDER BY timestamp;""" + + rows = ch.execute(query=ch_query, + params={**params, **location_constraints_vals, **__get_constraint_values(args)}) + pages = [{"timestamp": i["timestamp"], "avgPageLoadTime": i["avg"]} for i in + __complete_missing_steps(rows=rows, start_time=startTimestamp, + end_time=endTimestamp, + density=density, neutral={"avg": 0})] + + rows = helper.merge_lists_by_key(helper.merge_lists_by_key(pages, requests, "timestamp"), images, "timestamp") + + for s in rows: + for k in s: + if s[k] is None: + s[k] = 0 + return {"chart": helper.list_to_camel_case(__complete_missing_steps(rows=rows, start_time=startTimestamp, + end_time=endTimestamp, + density=density, + neutral={"avgImageLoadTime": 0, + "avgRequestLoadTime": 0, + "avgPageLoadTime": 0}))} + + +RESOURCS_TYPE_TO_DB_TYPE = { + "img": "IMG", + "fetch": "REQUEST", + "stylesheet": "STYLESHEET", + "script": "SCRIPT", + "other": "OTHER", + "media": "MEDIA" +} + + +def __get_resource_type_from_db_type(db_type): + db_type = db_type.lower() + return RESOURCS_TYPE_TO_DB_TYPE.get(db_type, db_type) + + +def __get_resource_db_type_from_type(resource_type): + resource_type = resource_type.upper() + return {v: k for k, v in RESOURCS_TYPE_TO_DB_TYPE.items()}.get(resource_type, resource_type) + + +def search(text, resource_type, project_id, performance=False, pages_only=False, events_only=False, + metadata=False, key=None, platform=None): + if text.startswith("^"): + text = text[1:] + if not resource_type: + data = [] + if metadata: + resource_type = "METADATA" + elif pages_only or performance: + resource_type = "LOCATION" + else: + resource_type = "ALL" + data.extend(search(text=text, resource_type=resource_type, project_id=project_id, + performance=performance, pages_only=pages_only, events_only=events_only, key=key, + platform=platform)) + return data + + ch_sub_query = __get_basic_constraints(time_constraint=False, + data={} if platform is None else {"platform": platform}) + + if resource_type == "ALL" and not pages_only and not events_only: + ch_sub_query.append("positionUTF8(url_path,%(value)s)!=0") + with ch_client.ClickHouseClient() as ch: + ch_query = f"""SELECT arrayJoin(arraySlice(arrayReverseSort(arrayDistinct(groupArray(url_path))), 1, 5)) AS value, + type AS key + FROM {exp_ch_helper.get_main_resources_table(0)} AS resources + WHERE {" AND ".join(ch_sub_query)} + GROUP BY type + ORDER BY type ASC;""" + # print(ch.format(query=ch_query, + # params={"project_id": project_id, + # "value": text})) + rows = ch.execute(query=ch_query, + params={"project_id": project_id, + "value": text}) + rows = [{"value": i["value"], "type": __get_resource_type_from_db_type(i["key"])} for i in rows] + elif resource_type == "ALL" and events_only: + with ch_client.ClickHouseClient() as ch: + ch_query = f"""SELECT DISTINCT value AS value, type AS key + FROM {exp_ch_helper.get_autocomplete_table(0)} autocomplete + WHERE {" AND ".join(ch_sub_query)} + AND positionUTF8(lowerUTF8(value), %(value)s) != 0 + AND type IN ('LOCATION','INPUT','CLICK') + ORDER BY type, value + LIMIT 10 BY type;""" + rows = ch.execute(query=ch_query, + params={"project_id": project_id, + "value": text.lower(), + "platform_0": platform}) + rows = [{"value": i["value"], "type": i["key"]} for i in rows] + elif resource_type in ['IMG', 'REQUEST', 'STYLESHEET', 'OTHER', 'SCRIPT'] and not pages_only: + ch_sub_query.append("positionUTF8(url_path,%(value)s)!=0") + ch_sub_query.append(f"resources.type = '{__get_resource_db_type_from_type(resource_type)}'") + + with ch_client.ClickHouseClient() as ch: + ch_query = f"""SELECT DISTINCT url_path AS value, + %(resource_type)s AS key + FROM {exp_ch_helper.get_main_resources_table(0)} AS resources + WHERE {" AND ".join(ch_sub_query)} + LIMIT 10;""" + rows = ch.execute(query=ch_query, + params={"project_id": project_id, + "value": text, + "resource_type": resource_type, + "platform_0": platform}) + rows = [{"value": i["value"], "type": i["key"]} for i in rows] + elif resource_type == 'LOCATION': + with ch_client.ClickHouseClient() as ch: + ch_sub_query.append("type='LOCATION'") + ch_sub_query.append("positionUTF8(value,%(value)s)!=0") + ch_query = f"""SELECT + DISTINCT value AS value, + 'LOCATION' AS key + FROM {exp_ch_helper.get_autocomplete_table(0)} AS autocomplete + WHERE {" AND ".join(ch_sub_query)} + LIMIT 10;""" + rows = ch.execute(query=ch_query, + params={"project_id": project_id, + "value": text, + "platform_0": platform}) + rows = [{"value": i["value"], "type": i["key"]} for i in rows] + elif resource_type == "INPUT": + with ch_client.ClickHouseClient() as ch: + ch_sub_query.append("positionUTF8(lowerUTF8(value), %(value)s) != 0") + ch_sub_query.append("type='INPUT") + ch_query = f"""SELECT DISTINCT label AS value, 'INPUT' AS key + FROM {exp_ch_helper.get_autocomplete_table(0)} AS autocomplete + WHERE {" AND ".join(ch_sub_query)} + LIMIT 10;""" + rows = ch.execute(query=ch_query, + params={"project_id": project_id, + "value": text.lower(), + "platform_0": platform}) + rows = [{"value": i["value"], "type": i["key"]} for i in rows] + elif resource_type == "CLICK": + with ch_client.ClickHouseClient() as ch: + ch_sub_query.append("positionUTF8(lowerUTF8(value), %(value)s) != 0") + ch_sub_query.append("type='CLICK'") + ch_query = f"""SELECT DISTINCT value AS value, 'CLICK' AS key + FROM {exp_ch_helper.get_autocomplete_table(0)} AS autocomplete + WHERE {" AND ".join(ch_sub_query)} + LIMIT 10;""" + rows = ch.execute(query=ch_query, + params={"project_id": project_id, + "value": text.lower(), + "platform_0": platform}) + rows = [{"value": i["value"], "type": i["key"]} for i in rows] + elif resource_type == "METADATA": + if key and len(key) > 0 and key in {**METADATA_FIELDS, **SESSIONS_META_FIELDS}.keys(): + if key in METADATA_FIELDS.keys(): + ch_sub_query.append( + f"positionCaseInsensitiveUTF8(sessions.{METADATA_FIELDS[key]},%(value)s)!=0") + + with ch_client.ClickHouseClient() as ch: + ch_query = f"""SELECT DISTINCT sessions.{METADATA_FIELDS[key]} AS value, + %(key)s AS key + FROM {exp_ch_helper.get_main_sessions_table(0)} AS sessions + WHERE {" AND ".join(ch_sub_query)} + LIMIT 10;""" + rows = ch.execute(query=ch_query, + params={"project_id": project_id, "value": text, "key": key, + "platform_0": platform}) + else: + ch_sub_query.append(f"positionCaseInsensitiveUTF8(sessions.{SESSIONS_META_FIELDS[key]},%(value)s)>0") + + with ch_client.ClickHouseClient() as ch: + ch_query = f"""SELECT DISTINCT sessions.{SESSIONS_META_FIELDS[key]} AS value, + '{key}' AS key + FROM {exp_ch_helper.get_main_sessions_table(0)} AS sessions + WHERE {" AND ".join(ch_sub_query)} + LIMIT 10;""" + rows = ch.execute(query=ch_query, params={"project_id": project_id, "value": text, "key": key, + "platform_0": platform}) + else: + with ch_client.ClickHouseClient() as ch: + ch_query = f"""SELECT DISTINCT value AS value, + type AS key + FROM {exp_ch_helper.get_autocomplete_table(0)} AS autocomplete + WHERE project_id = toUInt16(2460) + AND positionCaseInsensitiveUTF8(value, %(value)s) != 0 + LIMIT 10 BY type""" + + # print(ch.format(query=ch_query, params={"project_id": project_id, "value": text, "key": key, + # "platform_0": platform})) + rows = ch.execute(query=ch_query, params={"project_id": project_id, "value": text, "key": key, + "platform_0": platform}) + else: + return [] + return [helper.dict_to_camel_case(row) for row in rows] + + +def get_missing_resources_trend(project_id, startTimestamp=TimeUTC.now(delta_days=-1), + endTimestamp=TimeUTC.now(), + density=7, **args): + step_size = __get_step_size(startTimestamp, endTimestamp, density) + ch_sub_query = __get_basic_constraints(table_name="resources", data=args) + ch_sub_query.append("resources.success = 0") + ch_sub_query.append("resources.type = 'img'") + meta_condition = __get_meta_constraint(args) + ch_sub_query += meta_condition + + with ch_client.ClickHouseClient() as ch: + ch_query = f"""SELECT resources.url_path AS key, + COUNT(1) AS doc_count + FROM {exp_ch_helper.get_main_resources_table(startTimestamp)} AS resources + WHERE {" AND ".join(ch_sub_query)} + GROUP BY url_path + ORDER BY doc_count DESC + LIMIT 10;""" + params = {"step_size": step_size, "project_id": project_id, "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, **__get_constraint_values(args)} + # print(ch.format(query=ch_query, params=params)) + rows = ch.execute(query=ch_query, params=params) + + rows = [{"url": i["key"], "sessions": i["doc_count"]} for i in rows] + if len(rows) == 0: + return [] + ch_sub_query.append("resources.url_path = %(value)s") + ch_query = f"""SELECT toUnixTimestamp(toStartOfInterval(resources.datetime, INTERVAL %(step_size)s second ))*1000 AS timestamp, + COUNT(1) AS doc_count, + toUnixTimestamp(MAX(resources.datetime))*1000 AS max_datatime + FROM {exp_ch_helper.get_main_resources_table(startTimestamp)} AS resources + WHERE {" AND ".join(ch_sub_query)} + GROUP BY timestamp + ORDER BY timestamp;""" + for e in rows: + e["startedAt"] = startTimestamp + e["startTimestamp"] = startTimestamp + e["endTimestamp"] = endTimestamp + params["value"] = e["url"] + r = ch.execute(query=ch_query, params=params) + + e["endedAt"] = r[-1]["max_datatime"] + e["chart"] = [{"timestamp": i["timestamp"], "count": i["doc_count"]} for i in + __complete_missing_steps(rows=r, start_time=startTimestamp, + end_time=endTimestamp, + density=density, + neutral={"doc_count": 0})] + return rows + + +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) + ch_sub_query_chart = __get_basic_constraints(table_name="resources", round_start=True, data=args) + # ch_sub_query_chart.append("events.event_type='RESOURCE'") + meta_condition = __get_meta_constraint(args) + ch_sub_query_chart += meta_condition + + with ch_client.ClickHouseClient() as ch: + ch_query = f"""SELECT toUnixTimestamp(toStartOfInterval(resources.datetime, INTERVAL %(step_size)s second ))*1000 AS timestamp, + resources.url_path, COUNT(1) AS doc_count + FROM {exp_ch_helper.get_main_resources_table(startTimestamp)} AS resources + WHERE {" AND ".join(ch_sub_query_chart)} + GROUP BY timestamp, resources.url_path + ORDER BY timestamp, doc_count DESC + LIMIT 10 BY timestamp;""" + params = {"step_size": step_size, "project_id": project_id, + "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, **__get_constraint_values(args)} + r = ch.execute(query=ch_query, params=params) + + results = [] + + i = 0 + while i < len(r): + results.append({"timestamp": r[i]["timestamp"], "domains": []}) + i += 1 + while i < len(r) and r[i]["timestamp"] == results[-1]["timestamp"]: + results[-1]["domains"].append({r[i]["url_path"]: r[i]["doc_count"]}) + i += 1 + + return {"startTimestamp": startTimestamp, "endTimestamp": endTimestamp, "chart": results} + + +KEYS = { + 'startTimestamp': args_transformer.int_arg, + 'endTimestamp': args_transformer.int_arg, + 'density': args_transformer.int_arg, + 'performanceDensity': args_transformer.int_arg, + 'platform': args_transformer.string +} + + +def dashboard_args(params): + args = {} + if params is not None: + for key in params.keys(): + if key in KEYS.keys(): + args[key] = KEYS[key](params.get(key)) + return args + + +def get_resources_loading_time(project_id, startTimestamp=TimeUTC.now(delta_days=-1), + endTimestamp=TimeUTC.now(), + density=19, type=None, url=None, **args): + step_size = __get_step_size(startTimestamp, endTimestamp, density) + ch_sub_query_chart = __get_basic_constraints(table_name="resources", round_start=True, data=args) + if type is not None: + ch_sub_query_chart.append(f"resources.type = '{__get_resource_db_type_from_type(type)}'") + if url is not None: + ch_sub_query_chart.append(f"resources.url = %(value)s") + meta_condition = __get_meta_constraint(args) + ch_sub_query_chart += meta_condition + ch_sub_query_chart.append("resources.duration>0") + + with ch_client.ClickHouseClient() as ch: + ch_query = f"""SELECT toUnixTimestamp(toStartOfInterval(resources.datetime, INTERVAL %(step_size)s second ))*1000 AS timestamp, + COALESCE(avgOrNull(resources.duration),0) AS avg + FROM {exp_ch_helper.get_main_resources_table(startTimestamp)} AS resources + WHERE {" AND ".join(ch_sub_query_chart)} + GROUP BY timestamp + ORDER BY timestamp;""" + params = {"step_size": step_size, "project_id": project_id, + "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, + "value": url, "type": type, **__get_constraint_values(args)} + rows = ch.execute(query=ch_query, params=params) + ch_query = f"""SELECT COALESCE(avgOrNull(resources.duration),0) AS avg + FROM {exp_ch_helper.get_main_resources_table(startTimestamp)} AS resources + WHERE {" AND ".join(ch_sub_query_chart)};""" + avg = ch.execute(query=ch_query, params=params)[0]["avg"] if len(rows) > 0 else 0 + return {"avg": avg, "chart": __complete_missing_steps(rows=rows, start_time=startTimestamp, + end_time=endTimestamp, + density=density, + neutral={"avg": 0})} + + +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) + ch_sub_query_chart = __get_basic_constraints(table_name="pages", round_start=True, data=args) + ch_sub_query_chart.append("pages.event_type='LOCATION'") + if url is not None: + ch_sub_query_chart.append(f"pages.url_path = %(value)s") + ch_sub_query_chart.append("isNotNull(pages.dom_building_time)") + ch_sub_query_chart.append("pages.dom_building_time>0") + meta_condition = __get_meta_constraint(args) + ch_sub_query_chart += meta_condition + + with ch_client.ClickHouseClient() as ch: + ch_query = f"""SELECT toUnixTimestamp(toStartOfInterval(pages.datetime, INTERVAL %(step_size)s second ))*1000 AS timestamp, + COALESCE(avgOrNull(pages.dom_building_time),0) AS value + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS pages + WHERE {" AND ".join(ch_sub_query_chart)} + GROUP BY timestamp + ORDER BY timestamp;""" + params = {"step_size": step_size, "project_id": project_id, + "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, + "value": url, **__get_constraint_values(args)} + rows = ch.execute(query=ch_query, params=params) + ch_query = f"""SELECT COALESCE(avgOrNull(pages.dom_building_time),0) AS avg + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS pages + WHERE {" AND ".join(ch_sub_query_chart)};""" + avg = ch.execute(query=ch_query, params=params)[0]["avg"] if len(rows) > 0 else 0 + + results = {"value": avg, + "chart": __complete_missing_steps(rows=rows, start_time=startTimestamp, + end_time=endTimestamp, + density=density, neutral={"value": 0})} + helper.__time_value(results) + return results + + +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) + ch_sub_query = __get_basic_constraints(table_name="resources", data=args) + ch_sub_query.append("isNotNull(resources.name)") + ch_sub_query_chart = __get_basic_constraints(table_name="resources", round_start=True, data=args) + meta_condition = __get_meta_constraint(args) + ch_sub_query += meta_condition + ch_sub_query_chart += meta_condition + + 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'" + ch_sub_query.append(sq) + ch_sub_query_chart.append(sq) + ch_sub_query_chart.append("isNotNull(resources.duration)") + ch_sub_query_chart.append("resources.duration>0") + with ch_client.ClickHouseClient() as ch: + ch_query = f"""SELECT any(url) AS url, any(type) AS type, name, + COALESCE(avgOrNull(NULLIF(resources.duration,0)),0) AS avg + FROM {exp_ch_helper.get_main_resources_table(startTimestamp)} AS resources + WHERE {" AND ".join(ch_sub_query)} + GROUP BY name + ORDER BY avg DESC + LIMIT 10;""" + params = {"project_id": project_id, + "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, **__get_constraint_values(args)} + # print(ch.format(query=ch_query, params=params)) + rows = ch.execute(query=ch_query, params=params) + if len(rows) == 0: + return [] + ch_sub_query.append(ch_sub_query_chart[-1]) + results = [] + names = [r["name"] for r in rows] + ch_query = f"""SELECT name, + toUnixTimestamp(toStartOfInterval(resources.datetime, INTERVAL %(step_size)s second ))*1000 AS timestamp, + COALESCE(avgOrNull(resources.duration),0) AS avg + FROM {exp_ch_helper.get_main_resources_table(startTimestamp)} AS resources + WHERE {" AND ".join(ch_sub_query_chart)} + AND name IN %(names)s + GROUP BY name,timestamp + ORDER BY name,timestamp;""" + params = {"step_size": step_size, "project_id": project_id, + "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, + "names": names, **__get_constraint_values(args)} + # print(ch.format(query=ch_query, params=params)) + charts = ch.execute(query=ch_query, params=params) + for r in rows: + sub_chart = [] + for c in charts: + if c["name"] == r["name"]: + cc = dict(c) + cc.pop("name") + sub_chart.append(cc) + elif len(sub_chart) > 0: + break + r["chart"] = __complete_missing_steps(rows=sub_chart, start_time=startTimestamp, + end_time=endTimestamp, + density=density, neutral={"avg": 0}) + r["type"] = __get_resource_type_from_db_type(r["type"]) + results.append(r) + + return results + + +def get_sessions_location(project_id, startTimestamp=TimeUTC.now(delta_days=-1), + endTimestamp=TimeUTC.now(), **args): + ch_sub_query = __get_basic_constraints(table_name="sessions", data=args) + meta_condition = __get_meta_constraint(args) + ch_sub_query += meta_condition + + with ch_client.ClickHouseClient() as ch: + ch_query = f"""SELECT user_country, COUNT(1) AS count + FROM {exp_ch_helper.get_main_sessions_table(startTimestamp)} AS sessions + WHERE {" AND ".join(ch_sub_query)} + GROUP BY user_country + ORDER BY user_country;""" + params = {"project_id": project_id, + "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, **__get_constraint_values(args)} + rows = ch.execute(query=ch_query, params=params) + return {"count": sum(i["count"] for i in rows), "chart": helper.list_to_camel_case(rows)} + + +def get_speed_index_location(project_id, startTimestamp=TimeUTC.now(delta_days=-1), + endTimestamp=TimeUTC.now(), **args): + ch_sub_query = __get_basic_constraints(table_name="pages", data=args) + ch_sub_query.append("pages.event_type='LOCATION'") + ch_sub_query.append("isNotNull(pages.speed_index)") + ch_sub_query.append("pages.speed_index>0") + meta_condition = __get_meta_constraint(args) + ch_sub_query += meta_condition + + with ch_client.ClickHouseClient() as ch: + ch_query = f"""SELECT sessions.user_country, COALESCE(avgOrNull(pages.speed_index),0) AS value + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS pages + INNER JOIN {exp_ch_helper.get_main_sessions_table(startTimestamp)} AS sessions USING (session_id) + WHERE {" AND ".join(ch_sub_query)} + GROUP BY sessions.user_country + ORDER BY value ,sessions.user_country;""" + params = {"project_id": project_id, + "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, **__get_constraint_values(args)} + # print(ch.format(query=ch_query, params=params)) + rows = ch.execute(query=ch_query, params=params) + ch_query = f"""SELECT COALESCE(avgOrNull(pages.speed_index),0) AS avg + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS pages + WHERE {" AND ".join(ch_sub_query)};""" + avg = ch.execute(query=ch_query, params=params)[0]["avg"] if len(rows) > 0 else 0 + return {"value": avg, "chart": helper.list_to_camel_case(rows), "unit": schemas.TemplatePredefinedUnits.millisecond} + + +def get_pages_response_time(project_id, startTimestamp=TimeUTC.now(delta_days=-1), + endTimestamp=TimeUTC.now(), density=7, url=None, **args): + step_size = __get_step_size(startTimestamp, endTimestamp, density) + ch_sub_query_chart = __get_basic_constraints(table_name="pages", round_start=True, data=args) + ch_sub_query_chart.append("pages.event_type='LOCATION'") + ch_sub_query_chart.append("isNotNull(pages.response_time)") + ch_sub_query_chart.append("pages.response_time>0") + meta_condition = __get_meta_constraint(args) + ch_sub_query_chart += meta_condition + + if url is not None: + ch_sub_query_chart.append(f"url_path = %(value)s") + with ch_client.ClickHouseClient() as ch: + ch_query = f"""SELECT toUnixTimestamp(toStartOfInterval(pages.datetime, INTERVAL %(step_size)s second)) * 1000 AS timestamp, + COALESCE(avgOrNull(pages.response_time),0) AS value + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS pages + WHERE {" AND ".join(ch_sub_query_chart)} + GROUP BY timestamp + ORDER BY timestamp;""" + params = {"step_size": step_size, + "project_id": project_id, + "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, + "value": url, **__get_constraint_values(args)} + rows = ch.execute(query=ch_query, params=params) + ch_query = f"""SELECT COALESCE(avgOrNull(pages.response_time),0) AS avg + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS pages + WHERE {" AND ".join(ch_sub_query_chart)};""" + avg = ch.execute(query=ch_query, params=params)[0]["avg"] if len(rows) > 0 else 0 + results = {"value": avg, + "chart": __complete_missing_steps(rows=rows, start_time=startTimestamp, + end_time=endTimestamp, + density=density, neutral={"value": 0})} + helper.__time_value(results) + return results + + +def get_pages_response_time_distribution(project_id, startTimestamp=TimeUTC.now(delta_days=-1), + endTimestamp=TimeUTC.now(), density=20, **args): + ch_sub_query = __get_basic_constraints(table_name="pages", data=args) + ch_sub_query.append("pages.event_type='LOCATION'") + ch_sub_query.append("isNotNull(pages.response_time)") + ch_sub_query.append("pages.response_time>0") + meta_condition = __get_meta_constraint(args) + ch_sub_query += meta_condition + + with ch_client.ClickHouseClient() as ch: + ch_query = f"""SELECT pages.response_time AS response_time, + COUNT(1) AS count + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS pages + WHERE {" AND ".join(ch_sub_query)} + GROUP BY response_time + ORDER BY response_time;""" + params = {"project_id": project_id, + "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, **__get_constraint_values(args)} + rows = ch.execute(query=ch_query, params=params) + ch_query = f"""SELECT COALESCE(avgOrNull(pages.response_time),0) AS avg + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS pages + WHERE {" AND ".join(ch_sub_query)};""" + avg = ch.execute(query=ch_query, params=params)[0]["avg"] + quantiles_keys = [50, 90, 95, 99] + ch_query = f"""SELECT quantilesExact({",".join([str(i / 100) for i in quantiles_keys])})(pages.response_time) AS values + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS pages + WHERE {" AND ".join(ch_sub_query)};""" + quantiles = ch.execute(query=ch_query, params=params) + result = { + "value": avg, + "total": sum(r["count"] for r in rows), + "chart": [], + "percentiles": [{ + "percentile": v, + "responseTime": ( + quantiles[0]["values"][i] if quantiles[0]["values"][i] is not None and not math.isnan( + quantiles[0]["values"][i]) else 0)} for i, v in enumerate(quantiles_keys) + ], + "extremeValues": [{"count": 0}], + "unit": schemas.TemplatePredefinedUnits.millisecond + } + if len(rows) > 0: + rows = helper.list_to_camel_case(rows) + _99 = result["percentiles"][-1]["responseTime"] + extreme_values_first_index = -1 + for i, r in enumerate(rows): + if r["responseTime"] > _99: + extreme_values_first_index = i + break + + if extreme_values_first_index >= 0: + extreme_values_first_index += 1 + result["extremeValues"][0]["count"] = sum(r["count"] for r in rows[extreme_values_first_index:]) + # result["extremeValues"][0]["responseTime"] = rows[extreme_values_first_index]["responseTime"] + + rows = rows[:extreme_values_first_index] + + # ------- Merge points to reduce chart length till density + if density < len(quantiles_keys): + density = len(quantiles_keys) + + while len(rows) > density: + true_length = len(rows) + rows_partitions = [] + offset = 0 + for p in result["percentiles"]: + rows_partitions.append([]) + for r in rows[offset:]: + if r["responseTime"] < p["responseTime"]: + rows_partitions[-1].append(r) + offset += 1 + else: + break + rows_partitions.append(rows[offset:]) + # print(f"len rows partition: {len(rows_partitions)}") + # for r in rows_partitions: + # print(f"{r[0]} => {sum(v['count'] for v in r)}") + + largest_partition = 0 + for i in range(len(rows_partitions)): + if len(rows_partitions[i]) > len(rows_partitions[largest_partition]): + largest_partition = i + # print(f"largest partition: {len(rows_partitions[largest_partition])}") + + if len(rows_partitions[largest_partition]) <= 2: + break + # computing lowest merge diff + diff = rows[-1]["responseTime"] + for i in range(1, len(rows_partitions[largest_partition]) - 1, 1): + v1 = rows_partitions[largest_partition][i] + v2 = rows_partitions[largest_partition][i + 1] + if (v2["responseTime"] - v1["responseTime"]) < diff: + diff = v2["responseTime"] - v1["responseTime"] + # print(f"lowest merge diff: {diff}") + i = 1 + while i < len(rows_partitions[largest_partition]) - 1 and true_length > density - 1: + v1 = rows_partitions[largest_partition][i] + v2 = rows_partitions[largest_partition][i + 1] + if (v2["responseTime"] - v1["responseTime"]) == diff: + rows_partitions[largest_partition][i]["count"] += v2["count"] + rows_partitions[largest_partition][i]["responseTime"] = v2["responseTime"] + del rows_partitions[largest_partition][i + 1] + true_length -= 1 + else: + i += 1 + + rows = [r for rp in rows_partitions for r in rp] + + if extreme_values_first_index == len(rows): + rows.append({"count": 0, "responseTime": rows[-1]["responseTime"] + 10}) + + result["chart"] = rows + + return result + + +def get_busiest_time_of_day(project_id, startTimestamp=TimeUTC.now(delta_days=-1), + endTimestamp=TimeUTC.now(), **args): + ch_sub_query = __get_basic_constraints(table_name="sessions", data=args) + meta_condition = __get_meta_constraint(args) + ch_sub_query += meta_condition + + with ch_client.ClickHouseClient() as ch: + ch_query = f"""SELECT intDiv(toHour(sessions.datetime),2)*2 AS hour, + COUNT(1) AS count + FROM {exp_ch_helper.get_main_sessions_table(startTimestamp)} AS sessions + WHERE {" AND ".join(ch_sub_query)} + GROUP BY hour + ORDER BY hour ASC;""" + params = {"project_id": project_id, + "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, **__get_constraint_values(args)} + rows = ch.execute(query=ch_query, params=params) + return __complete_missing_steps(rows=rows, start_time=0, end_time=24000, density=12, + neutral={"count": 0}, + time_key="hour", time_coefficient=1) + + +def get_top_metrics(project_id, startTimestamp=TimeUTC.now(delta_days=-1), + endTimestamp=TimeUTC.now(), value=None, **args): + ch_sub_query = __get_basic_constraints(table_name="pages", data=args) + ch_sub_query.append("pages.event_type='LOCATION'") + meta_condition = __get_meta_constraint(args) + ch_sub_query += meta_condition + + if value is not None: + ch_sub_query.append("pages.url_path = %(value)s") + with ch_client.ClickHouseClient() as ch: + ch_query = f"""SELECT COALESCE(avgOrNull(if(pages.response_time>0,pages.response_time,null)),0) AS avg_response_time, + COALESCE(avgOrNull(if(pages.first_paint>0,pages.first_paint,null)),0) AS avg_first_paint, + COALESCE(avgOrNull(if(pages.dom_content_loaded_event_time>0,pages.dom_content_loaded_event_time,null)),0) AS avg_dom_content_loaded, + COALESCE(avgOrNull(if(pages.ttfb>0,pages.ttfb,null)),0) AS avg_till_first_bit, + COALESCE(avgOrNull(if(pages.time_to_interactive>0,pages.time_to_interactive,null)),0) AS avg_time_to_interactive, + (SELECT COUNT(1) FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS pages WHERE {" AND ".join(ch_sub_query)}) AS count_requests + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS pages + WHERE {" AND ".join(ch_sub_query)} + AND (isNotNull(pages.response_time) AND pages.response_time>0 OR + isNotNull(pages.first_paint) AND pages.first_paint>0 OR + isNotNull(pages.dom_content_loaded_event_time) AND pages.dom_content_loaded_event_time>0 OR + isNotNull(pages.ttfb) AND pages.ttfb>0 OR + isNotNull(pages.time_to_interactive) AND pages.time_to_interactive >0);""" + params = {"project_id": project_id, + "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, + "value": value, **__get_constraint_values(args)} + rows = ch.execute(query=ch_query, params=params) + return helper.dict_to_camel_case(rows[0]) + + +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) + ch_sub_query_chart = __get_basic_constraints(table_name="pages", round_start=True, data=args) + ch_sub_query_chart.append("pages.event_type='LOCATION'") + ch_sub_query_chart.append("isNotNull(pages.visually_complete)") + meta_condition = __get_meta_constraint(args) + ch_sub_query_chart += meta_condition + + if url is not None: + ch_sub_query_chart.append("pages.url_path = %(value)s") + + with ch_client.ClickHouseClient() as ch: + ch_query = f"""SELECT toUnixTimestamp(toStartOfInterval(pages.datetime, INTERVAL %(step_size)s second)) * 1000 AS timestamp, + COALESCE(avgOrNull(pages.visually_complete),0) AS value + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS pages + WHERE {" AND ".join(ch_sub_query_chart)} + GROUP BY timestamp + ORDER BY timestamp;""" + params = {"step_size": step_size, + "project_id": project_id, + "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, "value": url, **__get_constraint_values(args)} + rows = ch.execute(query=ch_query, params=params) + ch_query = f"""SELECT COALESCE(avgOrNull(pages.visually_complete),0) AS avg + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS pages + WHERE {" AND ".join(ch_sub_query_chart)};""" + avg = ch.execute(query=ch_query, params=params)[0]["avg"] if len(rows) > 0 else 0 + results = {"value": avg, "chart": __complete_missing_steps(rows=rows, start_time=startTimestamp, + end_time=endTimestamp, density=density, + neutral={"value": 0})} + helper.__time_value(results) + return results + + +def get_impacted_sessions_by_slow_pages(project_id, startTimestamp=TimeUTC.now(delta_days=-1), + endTimestamp=TimeUTC.now(), value=None, density=7, **args): + step_size = __get_step_size(startTimestamp, endTimestamp, density) + ch_sub_query = __get_basic_constraints(table_name="pages", round_start=True, data=args) + ch_sub_query.append("pages.event_type='LOCATION'") + ch_sub_query.append("isNotNull(pages.response_time)") + ch_sub_query.append("pages.response_time>0") + sch_sub_query = ch_sub_query[:] + if value is not None: + ch_sub_query.append("pages.url_path = %(value)s") + meta_condition = __get_meta_constraint(args) + ch_sub_query += meta_condition + + with ch_client.ClickHouseClient() as ch: + ch_query = f"""SELECT toUnixTimestamp(toStartOfInterval(pages.datetime, INTERVAL %(step_size)s second)) * 1000 AS timestamp, + COUNT(DISTINCT pages.session_id) AS count + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS pages + WHERE {" AND ".join(ch_sub_query)} + AND (pages.response_time)>(SELECT COALESCE(avgOrNull(pages.response_time),0) + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS pages + WHERE {" AND ".join(sch_sub_query)})*2 + GROUP BY timestamp + ORDER BY timestamp;""" + rows = ch.execute(query=ch_query, + params={"step_size": step_size, + "project_id": project_id, + "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, + "value": value, **__get_constraint_values(args)}) + return __complete_missing_steps(rows=rows, start_time=startTimestamp, + end_time=endTimestamp, density=density, + neutral={"count": 0}) + + +def get_memory_consumption(project_id, startTimestamp=TimeUTC.now(delta_days=-1), + endTimestamp=TimeUTC.now(), density=7, **args): + step_size = __get_step_size(startTimestamp, endTimestamp, density) + ch_sub_query_chart = __get_basic_constraints(table_name="performance", round_start=True, + data=args) + ch_sub_query_chart.append("performance.event_type='PERFORMANCE'") + meta_condition = __get_meta_constraint(args) + ch_sub_query_chart += meta_condition + + with ch_client.ClickHouseClient() as ch: + ch_query = f"""SELECT toUnixTimestamp(toStartOfInterval(performance.datetime, INTERVAL %(step_size)s second)) * 1000 AS timestamp, + COALESCE(avgOrNull(performance.avg_used_js_heap_size),0) AS value + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS performance + WHERE {" AND ".join(ch_sub_query_chart)} + GROUP BY timestamp + ORDER BY timestamp ASC;""" + params = {"step_size": step_size, + "project_id": project_id, + "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, **__get_constraint_values(args)} + rows = ch.execute(query=ch_query, params=params) + ch_query = f"""SELECT COALESCE(avgOrNull(performance.avg_used_js_heap_size),0) AS avg + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS performance + WHERE {" AND ".join(ch_sub_query_chart)};""" + avg = ch.execute(query=ch_query, params=params)[0]["avg"] if len(rows) > 0 else 0 + return {"value": avg, + "chart": helper.list_to_camel_case(__complete_missing_steps(rows=rows, start_time=startTimestamp, + end_time=endTimestamp, + density=density, + neutral={"value": 0})), + "unit": schemas.TemplatePredefinedUnits.memory} + + +def get_avg_cpu(project_id, startTimestamp=TimeUTC.now(delta_days=-1), + endTimestamp=TimeUTC.now(), density=7, **args): + step_size = __get_step_size(startTimestamp, endTimestamp, density) + ch_sub_query_chart = __get_basic_constraints(table_name="performance", round_start=True, + data=args) + ch_sub_query_chart.append("performance.event_type='PERFORMANCE'") + meta_condition = __get_meta_constraint(args) + ch_sub_query_chart += meta_condition + + with ch_client.ClickHouseClient() as ch: + ch_query = f"""SELECT toUnixTimestamp(toStartOfInterval(performance.datetime, INTERVAL %(step_size)s second)) * 1000 AS timestamp, + COALESCE(avgOrNull(performance.avg_cpu),0) AS value + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS performance + WHERE {" AND ".join(ch_sub_query_chart)} + GROUP BY timestamp + ORDER BY timestamp ASC;""" + params = {"step_size": step_size, + "project_id": project_id, + "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, **__get_constraint_values(args)} + rows = ch.execute(query=ch_query, params=params) + ch_query = f"""SELECT COALESCE(avgOrNull(performance.avg_cpu),0) AS avg + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS performance + WHERE {" AND ".join(ch_sub_query_chart)};""" + avg = ch.execute(query=ch_query, params=params)[0]["avg"] if len(rows) > 0 else 0 + return {"value": avg, + "chart": helper.list_to_camel_case(__complete_missing_steps(rows=rows, start_time=startTimestamp, + end_time=endTimestamp, + density=density, + neutral={"value": 0})), + "unit": schemas.TemplatePredefinedUnits.percentage} + + +def get_avg_fps(project_id, startTimestamp=TimeUTC.now(delta_days=-1), + endTimestamp=TimeUTC.now(), density=7, **args): + step_size = __get_step_size(startTimestamp, endTimestamp, density) + ch_sub_query_chart = __get_basic_constraints(table_name="performance", round_start=True, + data=args) + ch_sub_query_chart.append("performance.event_type='PERFORMANCE'") + meta_condition = __get_meta_constraint(args) + ch_sub_query_chart += meta_condition + + with ch_client.ClickHouseClient() as ch: + ch_query = f"""SELECT toUnixTimestamp(toStartOfInterval(performance.datetime, INTERVAL %(step_size)s second)) * 1000 AS timestamp, + COALESCE(avgOrNull(performance.avg_fps),0) AS value + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS performance + WHERE {" AND ".join(ch_sub_query_chart)} + GROUP BY timestamp + ORDER BY timestamp ASC;""" + params = {"step_size": step_size, + "project_id": project_id, + "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, **__get_constraint_values(args)} + rows = ch.execute(query=ch_query, params=params) + ch_query = f"""SELECT COALESCE(avgOrNull(performance.avg_fps),0) AS avg + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS performance + WHERE {" AND ".join(ch_sub_query_chart)};""" + avg = ch.execute(query=ch_query, params=params)[0]["avg"] if len(rows) > 0 else 0 + return {"value": avg, + "chart": helper.list_to_camel_case(__complete_missing_steps(rows=rows, start_time=startTimestamp, + end_time=endTimestamp, + density=density, + neutral={"value": 0})), + "unit": schemas.TemplatePredefinedUnits.frame} + + +def get_crashes(project_id, startTimestamp=TimeUTC.now(delta_days=-1), + endTimestamp=TimeUTC.now(), density=7, **args): + step_size = __get_step_size(startTimestamp, endTimestamp, density) + ch_sub_query = __get_basic_constraints(table_name="sessions", round_start=True, data=args) + ch_sub_query.append("has(sessions.issue_types,'crash')") + ch_sub_query_chart = __get_basic_constraints(table_name="sessions", round_start=True, + data=args) + ch_sub_query_chart.append("has(sessions.issue_types,'crash')") + meta_condition = __get_meta_constraint(args) + ch_sub_query += meta_condition + ch_sub_query_chart += meta_condition + + with ch_client.ClickHouseClient() as ch: + ch_query = f"""SELECT toUnixTimestamp(toStartOfInterval(sessions.datetime, INTERVAL %(step_size)s second)) * 1000 AS timestamp, + COUNT(1) AS value + FROM {exp_ch_helper.get_main_sessions_table(startTimestamp)} AS sessions + WHERE {" AND ".join(ch_sub_query_chart)} + GROUP BY timestamp + ORDER BY timestamp;""" + params = {"step_size": step_size, + "project_id": project_id, + "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, + **__get_constraint_values(args)} + rows = ch.execute(query=ch_query, params=params) + if len(rows) == 0: + browsers = [] + else: + ch_query = f"""SELECT b.user_browser AS browser, + sum(bv.count) AS total, + groupArray([bv.user_browser_version, toString(bv.count)]) AS versions + FROM ( + SELECT sessions.user_browser + FROM {exp_ch_helper.get_main_sessions_table(startTimestamp)} AS sessions + WHERE {" AND ".join(ch_sub_query)} + GROUP BY sessions.user_browser + ORDER BY COUNT(1) DESC + LIMIT 3 + ) AS b + INNER JOIN + ( + SELECT sessions.user_browser, + sessions.user_browser_version, + COUNT(1) AS count + FROM {exp_ch_helper.get_main_sessions_table(startTimestamp)} AS sessions + WHERE {" AND ".join(ch_sub_query)} + GROUP BY sessions.user_browser, + sessions.user_browser_version + ORDER BY count DESC + ) AS bv USING (user_browser) + GROUP BY b.user_browser + ORDER BY b.user_browser;""" + browsers = ch.execute(query=ch_query, params=params) + total = sum(r["total"] for r in browsers) + for r in browsers: + r["percentage"] = r["total"] / (total / 100) + versions = [] + for i in range(len(r["versions"][:3])): + versions.append({r["versions"][i][0]: int(r["versions"][i][1]) / (r["total"] / 100)}) + r["versions"] = versions + + result = {"chart": __complete_missing_steps(rows=rows, start_time=startTimestamp, + end_time=endTimestamp, + density=density, + neutral={"value": 0}), + "browsers": browsers, + "unit": schemas.TemplatePredefinedUnits.count} + return result + + +def __get_domains_errors_neutral(rows): + neutral = {l: 0 for l in [i for k in [list(v.keys()) for v in rows] for i in k]} + if len(neutral.keys()) == 0: + neutral = {"All": 0} + return neutral + + +def __merge_rows_with_neutral(rows, neutral): + for i in range(len(rows)): + rows[i] = {**neutral, **rows[i]} + return rows + + +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) + ch_sub_query = __get_basic_constraints(table_name="requests", round_start=True, data=args) + ch_sub_query.append("requests.event_type='REQUEST'") + ch_sub_query.append("intDiv(requests.status, 100) == %(status_code)s") + meta_condition = __get_meta_constraint(args) + ch_sub_query += meta_condition + + with ch_client.ClickHouseClient() as ch: + ch_query = f"""SELECT timestamp, + groupArray([domain, toString(count)]) AS keys + FROM (SELECT toUnixTimestamp(toStartOfInterval(requests.datetime, INTERVAL %(step_size)s second)) * 1000 AS timestamp, + requests.url_host AS domain, COUNT(1) AS count + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS requests + WHERE {" AND ".join(ch_sub_query)} + GROUP BY timestamp,requests.url_host + ORDER BY timestamp, count DESC + LIMIT 5 BY timestamp) AS domain_stats + GROUP BY timestamp;""" + params = {"project_id": project_id, + "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, + "step_size": step_size, + "status_code": 4, **__get_constraint_values(args)} + # print(ch.format(query=ch_query, params=params)) + rows = ch.execute(query=ch_query, params=params) + rows = __nested_array_to_dict_array(rows) + neutral = __get_domains_errors_neutral(rows) + rows = __merge_rows_with_neutral(rows, neutral) + + result = {"4xx": __complete_missing_steps(rows=rows, start_time=startTimestamp, + end_time=endTimestamp, + density=density, neutral=neutral)} + params["status_code"] = 5 + rows = ch.execute(query=ch_query, params=params) + rows = __nested_array_to_dict_array(rows) + neutral = __get_domains_errors_neutral(rows) + rows = __merge_rows_with_neutral(rows, neutral) + result["5xx"] = __complete_missing_steps(rows=rows, start_time=startTimestamp, + end_time=endTimestamp, + density=density, neutral=neutral) + return result + + +def __get_domains_errors_4xx_and_5xx(status, project_id, startTimestamp=TimeUTC.now(delta_days=-1), + endTimestamp=TimeUTC.now(), density=6, **args): + step_size = __get_step_size(startTimestamp, endTimestamp, density) + ch_sub_query = __get_basic_constraints(table_name="requests", round_start=True, data=args) + ch_sub_query.append("requests.event_type='REQUEST'") + ch_sub_query.append("intDiv(requests.status, 100) == %(status_code)s") + meta_condition = __get_meta_constraint(args) + ch_sub_query += meta_condition + + with ch_client.ClickHouseClient() as ch: + ch_query = f"""SELECT timestamp, + groupArray([domain, toString(count)]) AS keys + FROM (SELECT toUnixTimestamp(toStartOfInterval(requests.datetime, INTERVAL %(step_size)s second)) * 1000 AS timestamp, + requests.url_host AS domain, COUNT(1) AS count + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS requests + WHERE {" AND ".join(ch_sub_query)} + GROUP BY timestamp,requests.url_host + ORDER BY timestamp, count DESC + LIMIT 5 BY timestamp) AS domain_stats + GROUP BY timestamp;""" + params = {"project_id": project_id, + "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, + "step_size": step_size, + "status_code": status, **__get_constraint_values(args)} + rows = ch.execute(query=ch_query, params=params) + rows = __nested_array_to_dict_array(rows) + neutral = __get_domains_errors_neutral(rows) + rows = __merge_rows_with_neutral(rows, neutral) + + return __complete_missing_steps(rows=rows, start_time=startTimestamp, + end_time=endTimestamp, + density=density, neutral=neutral) + + +def get_domains_errors_4xx(project_id, startTimestamp=TimeUTC.now(delta_days=-1), + endTimestamp=TimeUTC.now(), density=6, **args): + return __get_domains_errors_4xx_and_5xx(status=4, project_id=project_id, startTimestamp=startTimestamp, + endTimestamp=endTimestamp, density=density, **args) + + +def get_domains_errors_5xx(project_id, startTimestamp=TimeUTC.now(delta_days=-1), + endTimestamp=TimeUTC.now(), density=6, **args): + return __get_domains_errors_4xx_and_5xx(status=5, project_id=project_id, startTimestamp=startTimestamp, + endTimestamp=endTimestamp, density=density, **args) + + +def __nested_array_to_dict_array(rows): + for r in rows: + for i in range(len(r["keys"])): + r[r["keys"][i][0]] = int(r["keys"][i][1]) + r.pop("keys") + return rows + + +def get_slowest_domains(project_id, startTimestamp=TimeUTC.now(delta_days=-1), + endTimestamp=TimeUTC.now(), **args): + ch_sub_query = __get_basic_constraints(table_name="resources", data=args) + ch_sub_query.append("isNotNull(resources.duration)") + ch_sub_query.append("resources.duration>0") + meta_condition = __get_meta_constraint(args) + ch_sub_query += meta_condition + + with ch_client.ClickHouseClient() as ch: + ch_query = f"""SELECT resources.url_host AS domain, + COALESCE(avgOrNull(resources.duration),0) AS value + FROM {exp_ch_helper.get_main_resources_table(startTimestamp)} AS resources + WHERE {" AND ".join(ch_sub_query)} + GROUP BY resources.url_host + ORDER BY value DESC + LIMIT 5;""" + params = {"project_id": project_id, + "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, **__get_constraint_values(args)} + rows = ch.execute(query=ch_query, params=params) + ch_query = f"""SELECT COALESCE(avgOrNull(resources.duration),0) AS avg + FROM {exp_ch_helper.get_main_resources_table(startTimestamp)} AS resources + WHERE {" AND ".join(ch_sub_query)};""" + avg = ch.execute(query=ch_query, params=params)[0]["avg"] if len(rows) > 0 else 0 + return {"value": avg, "chart": rows, "unit": schemas.TemplatePredefinedUnits.millisecond} + + +def get_errors_per_domains(project_id, startTimestamp=TimeUTC.now(delta_days=-1), + endTimestamp=TimeUTC.now(), **args): + ch_sub_query = __get_basic_constraints(table_name="requests", data=args) + ch_sub_query.append("requests.event_type = 'REQUEST'") + ch_sub_query.append("requests.success = 0") + meta_condition = __get_meta_constraint(args) + ch_sub_query += meta_condition + + with ch_client.ClickHouseClient() as ch: + ch_query = f"""SELECT + requests.url_host AS domain, + COUNT(1) AS errors_count + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS requests + WHERE {" AND ".join(ch_sub_query)} + GROUP BY requests.url_host + ORDER BY errors_count DESC + LIMIT 5;""" + params = {"project_id": project_id, + "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, **__get_constraint_values(args)} + rows = ch.execute(query=ch_query, params=params) + return helper.list_to_camel_case(rows) + + +def get_sessions_per_browser(project_id, startTimestamp=TimeUTC.now(delta_days=-1), endTimestamp=TimeUTC.now(), + platform=None, **args): + ch_sub_query = __get_basic_constraints(table_name="sessions", data=args) + meta_condition = __get_meta_constraint(args) + ch_sub_query += meta_condition + + with ch_client.ClickHouseClient() as ch: + ch_query = f"""SELECT b.user_browser AS browser, + b.count, + groupArray([bv.user_browser_version, toString(bv.count)]) AS versions + FROM + ( + SELECT sessions.user_browser, + COUNT(1) AS count + FROM {exp_ch_helper.get_main_sessions_table(startTimestamp)} AS sessions + WHERE {" AND ".join(ch_sub_query)} + GROUP BY sessions.user_browser + ORDER BY count DESC + LIMIT 3 + ) AS b + INNER JOIN + ( + SELECT sessions.user_browser, + sessions.user_browser_version, + COUNT(1) AS count + FROM {exp_ch_helper.get_main_sessions_table(startTimestamp)} AS sessions + WHERE {" AND ".join(ch_sub_query)} + GROUP BY + sessions.user_browser, + sessions.user_browser_version + ORDER BY count DESC + LIMIT 3 + ) AS bv USING (user_browser) + GROUP BY + b.user_browser, b.count + ORDER BY b.count DESC;""" + params = {"project_id": project_id, + "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, **__get_constraint_values(args)} + rows = ch.execute(query=ch_query, params=params) + for i, r in enumerate(rows): + versions = {} + for j in range(len(r["versions"])): + versions[r["versions"][j][0]] = int(r["versions"][j][1]) + r.pop("versions") + rows[i] = {**r, **versions} + return {"count": sum(i["count"] for i in rows), "chart": rows} + + +def get_calls_errors(project_id, startTimestamp=TimeUTC.now(delta_days=-1), endTimestamp=TimeUTC.now(), + platform=None, **args): + ch_sub_query = __get_basic_constraints(table_name="requests", data=args) + ch_sub_query.append("requests.event_type = 'REQUEST'") + ch_sub_query.append("intDiv(requests.status, 100) != 2") + meta_condition = __get_meta_constraint(args) + ch_sub_query += meta_condition + + with ch_client.ClickHouseClient() as ch: + ch_query = f"""SELECT requests.method, + requests.url_hostpath, + COUNT(1) AS all_requests, + SUM(if(intDiv(requests.status, 100) == 4, 1, 0)) AS _4xx, + SUM(if(intDiv(requests.status, 100) == 5, 1, 0)) AS _5xx + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS requests + WHERE {" AND ".join(ch_sub_query)} + GROUP BY requests.method, requests.url_hostpath + ORDER BY (_4xx + _5xx) DESC, all_requests DESC + LIMIT 50;""" + params = {"project_id": project_id, + "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, **__get_constraint_values(args)} + rows = ch.execute(query=ch_query, params=params) + return helper.list_to_camel_case(rows) + + +def __get_calls_errors_4xx_or_5xx(status, project_id, startTimestamp=TimeUTC.now(delta_days=-1), + endTimestamp=TimeUTC.now(), + platform=None, **args): + ch_sub_query = __get_basic_constraints(table_name="requests", data=args) + ch_sub_query.append("requests.event_type = 'REQUEST'") + ch_sub_query.append(f"intDiv(requests.status, 100) == {status}") + meta_condition = __get_meta_constraint(args) + ch_sub_query += meta_condition + + with ch_client.ClickHouseClient() as ch: + ch_query = f"""SELECT requests.method, + requests.url_hostpath, + COUNT(1) AS all_requests + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS requests + WHERE {" AND ".join(ch_sub_query)} + GROUP BY requests.method, requests.url_hostpath + ORDER BY all_requests DESC + LIMIT 10;""" + params = {"project_id": project_id, + "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, **__get_constraint_values(args)} + # print(ch.format(query=ch_query, params=params)) + rows = ch.execute(query=ch_query, params=params) + return helper.list_to_camel_case(rows) + + +def get_calls_errors_4xx(project_id, startTimestamp=TimeUTC.now(delta_days=-1), endTimestamp=TimeUTC.now(), + platform=None, **args): + return __get_calls_errors_4xx_or_5xx(status=4, project_id=project_id, startTimestamp=startTimestamp, + endTimestamp=endTimestamp, + platform=platform, **args) + + +def get_calls_errors_5xx(project_id, startTimestamp=TimeUTC.now(delta_days=-1), endTimestamp=TimeUTC.now(), + platform=None, **args): + return __get_calls_errors_4xx_or_5xx(status=5, project_id=project_id, startTimestamp=startTimestamp, + endTimestamp=endTimestamp, + platform=platform, **args) + + +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) + ch_sub_query_chart = __get_basic_constraints(table_name="events", round_start=True, + data=args) + ch_sub_query_chart.append("(events.event_type = 'REQUEST' OR events.event_type = 'ERROR')") + ch_sub_query_chart.append("(events.status>200 OR events.event_type = 'ERROR')") + meta_condition = __get_meta_constraint(args) + ch_sub_query_chart += meta_condition + + with ch_client.ClickHouseClient() as ch: + ch_query = f"""SELECT toUnixTimestamp(toStartOfInterval(datetime, INTERVAL %(step_size)s second)) * 1000 AS timestamp, + SUM(events.event_type = 'REQUEST' AND intDiv(events.status, 100) == 4) AS _4xx, + SUM(events.event_type = 'REQUEST' AND intDiv(events.status, 100) == 5) AS _5xx, + SUM(events.event_type = 'ERROR' AND events.source == 'js_exception') AS js, + SUM(events.event_type = 'ERROR' AND events.source != 'js_exception') AS integrations + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS events + WHERE {" AND ".join(ch_sub_query_chart)} + GROUP BY timestamp + ORDER BY timestamp;""" + params = {"step_size": step_size, + "project_id": project_id, + "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, **__get_constraint_values(args)} + # print(ch.format(query=ch_query, params=params)) + rows = ch.execute(query=ch_query, params=params) + rows = helper.list_to_camel_case(rows) + + return __complete_missing_steps(rows=rows, start_time=startTimestamp, + end_time=endTimestamp, + density=density, + neutral={"4xx": 0, "5xx": 0, "js": 0, "integrations": 0}) + + +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) + ch_sub_query_chart = __get_basic_constraints(table_name="resources", round_start=True, data=args) + ch_sub_query_chart_response_end = __get_basic_constraints(table_name="pages", round_start=True, + data=args) + ch_sub_query_chart_response_end.append("pages.event_type='LOCATION'") + ch_sub_query_chart_response_end.append("isNotNull(pages.response_end)") + ch_sub_query_chart_response_end.append("pages.response_end>0") + meta_condition = __get_meta_constraint(args) + ch_sub_query_chart += meta_condition + ch_sub_query_chart_response_end += meta_condition + params = {"step_size": step_size, + "project_id": project_id, + "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, **__get_constraint_values(args)} + with ch_client.ClickHouseClient() as ch: + ch_query = f"""SELECT toUnixTimestamp(toStartOfInterval(resources.datetime, INTERVAL %(step_size)s second)) * 1000 AS timestamp, + COUNT(1) AS total, + SUM(if(resources.type='fetch',1,0)) AS xhr + FROM {exp_ch_helper.get_main_resources_table(startTimestamp)} AS resources + WHERE {" AND ".join(ch_sub_query_chart)} + GROUP BY timestamp + ORDER BY timestamp;""" + # print(ch.format(query=ch_query, params=params)) + actions = ch.execute(query=ch_query, params=params) + actions = __complete_missing_steps(rows=actions, start_time=startTimestamp, + end_time=endTimestamp, + density=density, + neutral={"total": 0, "xhr": 0}) + ch_query = f"""SELECT toUnixTimestamp(toStartOfInterval(pages.datetime, INTERVAL %(step_size)s second)) * 1000 AS timestamp, + COALESCE(avgOrNull(pages.response_end),0) AS avg_response_end + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS pages + WHERE {" AND ".join(ch_sub_query_chart_response_end)} + GROUP BY timestamp + ORDER BY timestamp;""" + response_end = ch.execute(query=ch_query, params=params) + response_end = __complete_missing_steps(rows=response_end, start_time=startTimestamp, + end_time=endTimestamp, + density=density, + neutral={"avg_response_end": 0}) + return helper.list_to_camel_case(__merge_charts(response_end, actions)) + + +def get_impacted_sessions_by_js_errors(project_id, startTimestamp=TimeUTC.now(delta_days=-1), + endTimestamp=TimeUTC.now(), density=7, **args): + step_size = __get_step_size(startTimestamp, endTimestamp, density) + ch_sub_query_chart = __get_basic_constraints(table_name="errors", round_start=True, data=args) + ch_sub_query_chart.append("errors.event_type='ERROR'") + ch_sub_query_chart.append("errors.source == 'js_exception'") + meta_condition = __get_meta_constraint(args) + ch_sub_query_chart += meta_condition + + with ch_client.ClickHouseClient() as ch: + ch_query = f"""SELECT toUnixTimestamp(toStartOfInterval(errors.datetime, INTERVAL %(step_size)s second)) * 1000 AS timestamp, + COUNT(DISTINCT errors.session_id) AS sessions_count, + COUNT(DISTINCT errors.error_id) AS errors_count + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS errors + WHERE {" AND ".join(ch_sub_query_chart)} + GROUP BY timestamp + ORDER BY timestamp;;""" + rows = ch.execute(query=ch_query, + params={"step_size": step_size, + "project_id": project_id, + "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, **__get_constraint_values(args)}) + ch_query = f"""SELECT COUNT(DISTINCT errors.session_id) AS sessions_count, + COUNT(DISTINCT errors.error_id) AS errors_count + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS errors + WHERE {" AND ".join(ch_sub_query_chart)};""" + counts = ch.execute(query=ch_query, + params={"step_size": step_size, + "project_id": project_id, + "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, **__get_constraint_values(args)}) + return {"sessionsCount": counts[0]["sessions_count"], + "errorsCount": counts[0]["errors_count"], + "chart": helper.list_to_camel_case(__complete_missing_steps(rows=rows, start_time=startTimestamp, + end_time=endTimestamp, + density=density, + neutral={"sessions_count": 0, + "errors_count": 0}))} + + +# TODO: super slow (try using sampling) +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) + ch_sub_query = __get_basic_constraints(table_name="resources", data=args) + meta_condition = __get_meta_constraint(args) + ch_sub_query += meta_condition + + with ch_client.ClickHouseClient() as ch: + ch_query = f"""SELECT toUnixTimestamp(toStartOfInterval(s.base_datetime, toIntervalSecond(%(step_size)s))) * 1000 AS timestamp, + COALESCE(avgOrNull(NULLIF(s.count,0)),0) AS avg, + groupArray([toString(t.type), toString(t.xavg)]) AS types + FROM + ( SELECT resources.session_id, + MIN(resources.datetime) AS base_datetime, + COUNT(1) AS count + FROM {exp_ch_helper.get_main_resources_table(startTimestamp)} AS resources + WHERE {" AND ".join(ch_sub_query)} + GROUP BY resources.session_id + ) AS s + INNER JOIN + (SELECT session_id, + type, + COALESCE(avgOrNull(NULLIF(count,0)),0) AS xavg + FROM (SELECT resources.session_id, resources.type, COUNT(1) AS count + FROM {exp_ch_helper.get_main_resources_table(startTimestamp)} AS resources + WHERE {" AND ".join(ch_sub_query)} + GROUP BY resources.session_id, resources.type) AS ss + GROUP BY ss.session_id, ss.type) AS t USING (session_id) + GROUP BY timestamp + ORDER BY timestamp ASC;""" + params = {"step_size": step_size, + "project_id": project_id, + "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, **__get_constraint_values(args)} + # print(">>>>>>>>>>>>>>") + # print(ch.format(query=ch_query, params=params)) + # print(">>>>>>>>>>>>>>") + rows = ch.execute(query=ch_query, params=params) + for r in rows: + types = {} + for i in range(len(r["types"])): + if r["types"][i][0] not in types: + types[r["types"][i][0]] = [] + types[r["types"][i][0]].append(float(r["types"][i][1])) + for i in types: + types[i] = sum(types[i]) / len(types[i]) + r["types"] = types + resources = __complete_missing_steps(rows=rows, start_time=startTimestamp, + end_time=endTimestamp, + density=density, + neutral={"avg": 0, "types": {}}) + time_to_render = get_time_to_render(project_id=project_id, startTimestamp=startTimestamp, + endTimestamp=endTimestamp, density=density, + **args) + + return helper.list_to_camel_case( + __merge_charts( + [{"timestamp": i["timestamp"], "avgCountResources": i["avg"], "types": i["types"]} for i in resources], + [{"timestamp": i["timestamp"], "avgTimeToRender": i["value"]} for i in time_to_render["chart"]])) + + +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) + ch_sub_query_chart = __get_basic_constraints(table_name="resources", round_start=True, data=args) + meta_condition = __get_meta_constraint(args) + ch_sub_query_chart += meta_condition + + with ch_client.ClickHouseClient() as ch: + ch_query = f"""SELECT timestamp, + groupArray([toString(t.type), toString(t.count)]) AS types + FROM(SELECT toUnixTimestamp(toStartOfInterval(resources.datetime, INTERVAL %(step_size)s second)) * 1000 AS timestamp, + resources.type, + COUNT(1) AS count + FROM {exp_ch_helper.get_main_resources_table(startTimestamp)} AS resources + WHERE {" AND ".join(ch_sub_query_chart)} + GROUP BY timestamp,resources.type + ORDER BY timestamp) AS t + GROUP BY timestamp;""" + params = {"step_size": step_size, + "project_id": project_id, + "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, **__get_constraint_values(args)} + # print(ch.format(query=ch_query, params=params)) + rows = ch.execute(query=ch_query, params=params) + for r in rows: + for t in r["types"]: + r[t[0]] = t[1] + r.pop("types") + return __complete_missing_steps(rows=rows, start_time=startTimestamp, + end_time=endTimestamp, + density=density, + neutral={k: 0 for k in RESOURCS_TYPE_TO_DB_TYPE.keys()}) + + +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) + ch_sub_query = __get_basic_constraints(table_name="requests", round_start=True, data=args) + ch_sub_query.append("requests.event_type='REQUEST'") + ch_sub_query.append("requests.success = 0") + sch_sub_query = ["rs.project_id =toUInt16(%(project_id)s)", "rs.event_type='REQUEST'"] + meta_condition = __get_meta_constraint(args) + ch_sub_query += meta_condition + # sch_sub_query += meta_condition + + with ch_client.ClickHouseClient() as ch: + ch_query = f"""SELECT toUnixTimestamp(toStartOfInterval(sub_requests.datetime, INTERVAL %(step_size)s second)) * 1000 AS timestamp, + SUM(first.url_host = sub_requests.url_host) AS first_party, + SUM(first.url_host != sub_requests.url_host) AS third_party + FROM + ( + SELECT requests.datetime, requests.url_host + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS requests + WHERE {" AND ".join(ch_sub_query)} + ) AS sub_requests + CROSS JOIN + ( + SELECT + rs.url_host, + COUNT(1) AS count + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS rs + WHERE {" AND ".join(sch_sub_query)} + GROUP BY rs.url_host + ORDER BY count DESC + LIMIT 1 + ) AS first + GROUP BY timestamp + ORDER BY timestamp;""" + params = {"step_size": step_size, + "project_id": project_id, + "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, **__get_constraint_values(args)} + # print(ch.format(query=ch_query, params=params)) + rows = ch.execute(query=ch_query, params=params) + return helper.list_to_camel_case(__complete_missing_steps(rows=rows, start_time=startTimestamp, + end_time=endTimestamp, + density=density, + neutral={"first_party": 0, + "third_party": 0})) + + +def get_application_activity_avg_page_load_time(project_id, startTimestamp=TimeUTC.now(delta_days=-1), + endTimestamp=TimeUTC.now(), **args): + with ch_client.ClickHouseClient() as ch: + row = __get_application_activity_avg_page_load_time(ch, project_id, startTimestamp, endTimestamp, **args) + results = helper.dict_to_camel_case(row) + results["chart"] = get_performance_avg_page_load_time(ch, project_id, startTimestamp, endTimestamp, **args) + diff = endTimestamp - startTimestamp + endTimestamp = startTimestamp + startTimestamp = endTimestamp - diff + row = __get_application_activity_avg_page_load_time(ch, project_id, startTimestamp, endTimestamp, **args) + previous = helper.dict_to_camel_case(row) + results["progress"] = helper.__progress(old_val=previous["value"], new_val=results["value"]) + helper.__time_value(results) + return results + + +def __get_application_activity_avg_page_load_time(ch, project_id, startTimestamp, endTimestamp, **args): + ch_sub_query = __get_basic_constraints(table_name="pages", data=args) + ch_sub_query.append("pages.event_type='LOCATION'") + meta_condition = __get_meta_constraint(args) + ch_sub_query += meta_condition + ch_sub_query.append("pages.load_event_end>0") + ch_query = f"""SELECT COALESCE(avgOrNull(pages.load_event_end),0) AS value + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS pages + WHERE {" AND ".join(ch_sub_query)};""" + params = {"project_id": project_id, "startTimestamp": startTimestamp, "endTimestamp": endTimestamp, + **__get_constraint_values(args)} + # print(ch.format(query=ch_query, params=params)) + row = ch.execute(query=ch_query, params=params)[0] + result = row + for k in result: + if result[k] is None: + result[k] = 0 + return result + + +def get_performance_avg_page_load_time(ch, project_id, startTimestamp=TimeUTC.now(delta_days=-1), + endTimestamp=TimeUTC.now(), + density=19, resources=None, **args): + step_size = __get_step_size(endTimestamp=endTimestamp, startTimestamp=startTimestamp, density=density) + location_constraints = [] + meta_condition = __get_meta_constraint(args) + + location_constraints_vals = {} + + if resources and len(resources) > 0: + for r in resources: + if r["type"] == "LOCATION": + location_constraints.append(f"pages.url_path = %(val_{len(location_constraints)})s") + location_constraints_vals["val_" + str(len(location_constraints) - 1)] = r['value'] + + params = {"step_size": step_size, "project_id": project_id, "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp} + + ch_sub_query_chart = __get_basic_constraints(table_name="pages", round_start=True, + data=args) + ch_sub_query_chart.append("pages.event_type='LOCATION'") + ch_sub_query_chart += meta_condition + ch_sub_query_chart.append("pages.load_event_end>0") + + ch_query = f"""SELECT toUnixTimestamp(toStartOfInterval(pages.datetime, INTERVAL %(step_size)s second ))*1000 AS timestamp, + COALESCE(avgOrNull(pages.load_event_end),0) AS value + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS pages + WHERE {" AND ".join(ch_sub_query_chart)} + {(f' AND ({" OR ".join(location_constraints)})') if len(location_constraints) > 0 else ""} + GROUP BY timestamp + ORDER BY timestamp;""" + + rows = ch.execute(query=ch_query, params={**params, **location_constraints_vals, **__get_constraint_values(args)}) + pages = __complete_missing_steps(rows=rows, start_time=startTimestamp, + end_time=endTimestamp, + density=density, neutral={"value": 0}) + + # for s in pages: + # for k in s: + # if s[k] is None: + # s[k] = 0 + return pages + + +def get_application_activity_avg_image_load_time(project_id, startTimestamp=TimeUTC.now(delta_days=-1), + endTimestamp=TimeUTC.now(), **args): + with ch_client.ClickHouseClient() as ch: + row = __get_application_activity_avg_image_load_time(ch, project_id, startTimestamp, endTimestamp, **args) + results = helper.dict_to_camel_case(row) + results["chart"] = get_performance_avg_image_load_time(ch, project_id, startTimestamp, endTimestamp, **args) + diff = endTimestamp - startTimestamp + endTimestamp = startTimestamp + startTimestamp = endTimestamp - diff + row = __get_application_activity_avg_image_load_time(ch, project_id, startTimestamp, endTimestamp, **args) + previous = helper.dict_to_camel_case(row) + results["progress"] = helper.__progress(old_val=previous["value"], new_val=results["value"]) + helper.__time_value(results) + return results + + +def __get_application_activity_avg_image_load_time(ch, project_id, startTimestamp, endTimestamp, **args): + ch_sub_query = __get_basic_constraints(table_name="resources", data=args) + meta_condition = __get_meta_constraint(args) + ch_sub_query += meta_condition + ch_sub_query.append("resources.type= %(type)s") + ch_sub_query.append("resources.duration>0") + ch_query = f"""\ + SELECT COALESCE(avgOrNull(resources.duration),0) AS value + FROM {exp_ch_helper.get_main_resources_table(startTimestamp)} AS resources + WHERE {" AND ".join(ch_sub_query)};""" + row = ch.execute(query=ch_query, + params={"project_id": project_id, "type": 'img', "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, **__get_constraint_values(args)})[0] + result = row + # for k in result: + # if result[k] is None: + # result[k] = 0 + return result + + +def get_performance_avg_image_load_time(ch, project_id, startTimestamp=TimeUTC.now(delta_days=-1), + endTimestamp=TimeUTC.now(), + density=19, resources=None, **args): + step_size = __get_step_size(endTimestamp=endTimestamp, startTimestamp=startTimestamp, density=density) + img_constraints = [] + ch_sub_query_chart = __get_basic_constraints(table_name="resources", round_start=True, data=args) + meta_condition = __get_meta_constraint(args) + ch_sub_query_chart += meta_condition + + img_constraints_vals = {} + + if resources and len(resources) > 0: + for r in resources: + if r["type"] == "IMG": + img_constraints.append(f"resources.url = %(val_{len(img_constraints)})s") + img_constraints_vals["val_" + str(len(img_constraints) - 1)] = r['value'] + + params = {"step_size": step_size, "project_id": project_id, "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp} + ch_sub_query_chart.append("resources.duration>0") + ch_query = f"""SELECT toUnixTimestamp(toStartOfInterval(resources.datetime, INTERVAL %(step_size)s second ))*1000 AS timestamp, + COALESCE(avgOrNull(resources.duration),0) AS value + FROM {exp_ch_helper.get_main_resources_table(startTimestamp)} AS resources + WHERE {" AND ".join(ch_sub_query_chart)} + AND resources.type = 'img' + {(f' AND ({" OR ".join(img_constraints)})') if len(img_constraints) > 0 else ""} + GROUP BY timestamp + ORDER BY timestamp;""" + rows = ch.execute(query=ch_query, params={**params, **img_constraints_vals, **__get_constraint_values(args)}) + images = __complete_missing_steps(rows=rows, start_time=startTimestamp, + end_time=endTimestamp, + density=density, neutral={"value": 0}) + + # for s in images: + # for k in s: + # if s[k] is None: + # s[k] = 0 + return images + + +def get_application_activity_avg_request_load_time(project_id, startTimestamp=TimeUTC.now(delta_days=-1), + endTimestamp=TimeUTC.now(), **args): + with ch_client.ClickHouseClient() as ch: + row = __get_application_activity_avg_request_load_time(ch, project_id, startTimestamp, endTimestamp, **args) + results = helper.dict_to_camel_case(row) + results["chart"] = get_performance_avg_request_load_time(ch, project_id, startTimestamp, endTimestamp, **args) + diff = endTimestamp - startTimestamp + endTimestamp = startTimestamp + startTimestamp = endTimestamp - diff + row = __get_application_activity_avg_request_load_time(ch, project_id, startTimestamp, endTimestamp, **args) + previous = helper.dict_to_camel_case(row) + results["progress"] = helper.__progress(old_val=previous["value"], new_val=results["value"]) + helper.__time_value(results) + return results + + +def __get_application_activity_avg_request_load_time(ch, project_id, startTimestamp, endTimestamp, **args): + ch_sub_query = __get_basic_constraints(table_name="resources", data=args) + meta_condition = __get_meta_constraint(args) + ch_sub_query += meta_condition + ch_sub_query.append("resources.type= %(type)s") + ch_sub_query.append("resources.duration>0") + ch_query = f"""SELECT COALESCE(avgOrNull(resources.duration),0) AS value + FROM {exp_ch_helper.get_main_resources_table(startTimestamp)} AS resources + WHERE {" AND ".join(ch_sub_query)};""" + row = ch.execute(query=ch_query, + params={"project_id": project_id, "type": 'fetch', "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, **__get_constraint_values(args)})[0] + result = row + # for k in result: + # if result[k] is None: + # result[k] = 0 + return result + + +def get_performance_avg_request_load_time(ch, project_id, startTimestamp=TimeUTC.now(delta_days=-1), + endTimestamp=TimeUTC.now(), + density=19, resources=None, **args): + step_size = __get_step_size(endTimestamp=endTimestamp, startTimestamp=startTimestamp, density=density) + request_constraints = [] + ch_sub_query_chart = __get_basic_constraints(table_name="resources", round_start=True, data=args) + meta_condition = __get_meta_constraint(args) + ch_sub_query_chart += meta_condition + + request_constraints_vals = {} + + if resources and len(resources) > 0: + for r in resources: + if r["type"] != "IMG" and r["type"] == "LOCATION": + request_constraints.append(f"resources.url = %(val_{len(request_constraints)})s") + request_constraints_vals["val_" + str(len(request_constraints) - 1)] = r['value'] + params = {"step_size": step_size, "project_id": project_id, "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp} + ch_sub_query_chart.append("resources.duration>0") + ch_query = f"""SELECT toUnixTimestamp(toStartOfInterval(resources.datetime, INTERVAL %(step_size)s second ))*1000 AS timestamp, + COALESCE(avgOrNull(resources.duration),0) AS value + FROM {exp_ch_helper.get_main_resources_table(startTimestamp)} AS resources + WHERE {" AND ".join(ch_sub_query_chart)} + AND resources.type = 'fetch' + {(f' AND ({" OR ".join(request_constraints)})') if len(request_constraints) > 0 else ""} + GROUP BY timestamp + ORDER BY timestamp;""" + rows = ch.execute(query=ch_query, + params={**params, **request_constraints_vals, **__get_constraint_values(args)}) + requests = __complete_missing_steps(rows=rows, start_time=startTimestamp, + end_time=endTimestamp, density=density, + neutral={"value": 0}) + + # for s in requests: + # for k in s: + # if s[k] is None: + # s[k] = 0 + return requests + + +def get_page_metrics_avg_dom_content_load_start(project_id, startTimestamp=TimeUTC.now(delta_days=-1), + endTimestamp=TimeUTC.now(), **args): + with ch_client.ClickHouseClient() as ch: + results = {} + rows = __get_page_metrics_avg_dom_content_load_start(ch, project_id, startTimestamp, endTimestamp, **args) + if len(rows) > 0: + results = helper.dict_to_camel_case(rows[0]) + results["chart"] = __get_page_metrics_avg_dom_content_load_start_chart(ch, project_id, startTimestamp, + endTimestamp, **args) + diff = endTimestamp - startTimestamp + endTimestamp = startTimestamp + startTimestamp = endTimestamp - diff + rows = __get_page_metrics_avg_dom_content_load_start(ch, project_id, startTimestamp, endTimestamp, **args) + if len(rows) > 0: + previous = helper.dict_to_camel_case(rows[0]) + results["progress"] = helper.__progress(old_val=previous["value"], new_val=results["value"]) + helper.__time_value(results) + return results + + +def __get_page_metrics_avg_dom_content_load_start(ch, project_id, startTimestamp, endTimestamp, **args): + ch_sub_query = __get_basic_constraints(table_name="pages", data=args) + ch_sub_query.append("pages.event_type='LOCATION'") + meta_condition = __get_meta_constraint(args) + ch_sub_query += meta_condition + ch_sub_query.append("pages.dom_content_loaded_event_end>0") + ch_query = f"""SELECT COALESCE(avgOrNull(pages.dom_content_loaded_event_end),0) AS value + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS pages + WHERE {" AND ".join(ch_sub_query)};""" + params = {"project_id": project_id, "type": 'fetch', "startTimestamp": startTimestamp, "endTimestamp": endTimestamp, + **__get_constraint_values(args)} + rows = ch.execute(query=ch_query, params=params) + return rows + + +def __get_page_metrics_avg_dom_content_load_start_chart(ch, project_id, startTimestamp, endTimestamp, density=19, + **args): + step_size = __get_step_size(endTimestamp=endTimestamp, startTimestamp=startTimestamp, density=density) + ch_sub_query_chart = __get_basic_constraints(table_name="pages", round_start=True, data=args) + ch_sub_query_chart.append("pages.event_type='LOCATION'") + meta_condition = __get_meta_constraint(args) + ch_sub_query_chart += meta_condition + + params = {"step_size": step_size, "project_id": project_id, "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp} + ch_sub_query_chart.append("pages.dom_content_loaded_event_end>0") + ch_query = f"""SELECT toUnixTimestamp(toStartOfInterval(pages.datetime, INTERVAL %(step_size)s second ))*1000 AS timestamp, + COALESCE(avgOrNull(pages.dom_content_loaded_event_end),0) AS value + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS pages + WHERE {" AND ".join(ch_sub_query_chart)} + GROUP BY timestamp + ORDER BY timestamp;""" + rows = ch.execute(query=ch_query, params={**params, **__get_constraint_values(args)}) + rows = __complete_missing_steps(rows=rows, start_time=startTimestamp, + end_time=endTimestamp, + density=density, neutral={"value": 0}) + + # for s in rows: + # for k in s: + # if s[k] is None: + # s[k] = 0 + return rows + + +def get_page_metrics_avg_first_contentful_pixel(project_id, startTimestamp=TimeUTC.now(delta_days=-1), + endTimestamp=TimeUTC.now(), **args): + with ch_client.ClickHouseClient() as ch: + rows = __get_page_metrics_avg_first_contentful_pixel(ch, project_id, startTimestamp, endTimestamp, **args) + if len(rows) > 0: + results = helper.dict_to_camel_case(rows[0]) + results["chart"] = __get_page_metrics_avg_first_contentful_pixel_chart(ch, project_id, startTimestamp, + endTimestamp, **args) + diff = endTimestamp - startTimestamp + endTimestamp = startTimestamp + startTimestamp = endTimestamp - diff + rows = __get_page_metrics_avg_first_contentful_pixel(ch, project_id, startTimestamp, endTimestamp, **args) + if len(rows) > 0: + previous = helper.dict_to_camel_case(rows[0]) + results["progress"] = helper.__progress(old_val=previous["value"], new_val=results["value"]) + helper.__time_value(results) + return results + + +def __get_page_metrics_avg_first_contentful_pixel(ch, project_id, startTimestamp, endTimestamp, **args): + ch_sub_query = __get_basic_constraints(table_name="pages", data=args) + ch_sub_query.append("pages.event_type='LOCATION'") + meta_condition = __get_meta_constraint(args) + ch_sub_query += meta_condition + ch_sub_query.append("pages.first_contentful_paint_time>0") + # changed dom_content_loaded_event_start to dom_content_loaded_event_end + ch_query = f"""\ + SELECT COALESCE(avgOrNull(pages.first_contentful_paint_time),0) AS value + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS pages + WHERE {" AND ".join(ch_sub_query)};""" + params = {"project_id": project_id, "type": 'fetch', "startTimestamp": startTimestamp, "endTimestamp": endTimestamp, + **__get_constraint_values(args)} + rows = ch.execute(query=ch_query, params=params) + return rows + + +def __get_page_metrics_avg_first_contentful_pixel_chart(ch, project_id, startTimestamp, endTimestamp, density=20, + **args): + step_size = __get_step_size(endTimestamp=endTimestamp, startTimestamp=startTimestamp, density=density) + ch_sub_query_chart = __get_basic_constraints(table_name="pages", round_start=True, data=args) + ch_sub_query_chart.append("pages.event_type='LOCATION'") + meta_condition = __get_meta_constraint(args) + ch_sub_query_chart += meta_condition + + params = {"step_size": step_size, "project_id": project_id, "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp} + ch_sub_query_chart.append("pages.first_contentful_paint_time>0") + ch_query = f"""SELECT toUnixTimestamp(toStartOfInterval(pages.datetime, INTERVAL %(step_size)s second ))*1000 AS timestamp, + COALESCE(avgOrNull(pages.first_contentful_paint_time),0) AS value + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS pages + WHERE {" AND ".join(ch_sub_query_chart)} + GROUP BY timestamp + ORDER BY timestamp;""" + rows = ch.execute(query=ch_query, params={**params, **__get_constraint_values(args)}) + rows = __complete_missing_steps(rows=rows, start_time=startTimestamp, + end_time=endTimestamp, + density=density, neutral={"value": 0}) + return rows + + +def get_user_activity_avg_visited_pages(project_id, startTimestamp=TimeUTC.now(delta_days=-1), + endTimestamp=TimeUTC.now(), **args): + results = {} + + with ch_client.ClickHouseClient() as ch: + rows = __get_user_activity_avg_visited_pages(ch, project_id, startTimestamp, endTimestamp, **args) + if len(rows) > 0: + results = helper.dict_to_camel_case(rows[0]) + for key in results: + if isnan(results[key]): + results[key] = 0 + results["chart"] = __get_user_activity_avg_visited_pages_chart(ch, project_id, startTimestamp, + endTimestamp, **args) + + diff = endTimestamp - startTimestamp + endTimestamp = startTimestamp + startTimestamp = endTimestamp - diff + rows = __get_user_activity_avg_visited_pages(ch, project_id, startTimestamp, endTimestamp, **args) + + if len(rows) > 0: + previous = helper.dict_to_camel_case(rows[0]) + results["progress"] = helper.__progress(old_val=previous["value"], new_val=results["value"]) + results["unit"] = schemas.TemplatePredefinedUnits.count + return results + + +def __get_user_activity_avg_visited_pages(ch, project_id, startTimestamp, endTimestamp, **args): + ch_sub_query = __get_basic_constraints(table_name="pages", data=args) + ch_sub_query.append("pages.event_type='LOCATION'") + meta_condition = __get_meta_constraint(args) + ch_sub_query += meta_condition + + ch_query = f"""SELECT COALESCE(CEIL(avgOrNull(count)),0) AS value + FROM (SELECT COUNT(1) AS count + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS pages + WHERE {" AND ".join(ch_sub_query)} + GROUP BY session_id) AS groupped_data + WHERE count>0;""" + params = {"project_id": project_id, "startTimestamp": startTimestamp, "endTimestamp": endTimestamp, + **__get_constraint_values(args)} + + rows = ch.execute(query=ch_query, params=params) + + return rows + + +def __get_user_activity_avg_visited_pages_chart(ch, project_id, startTimestamp, endTimestamp, density=20, **args): + step_size = __get_step_size(endTimestamp=endTimestamp, startTimestamp=startTimestamp, density=density) + ch_sub_query_chart = __get_basic_constraints(table_name="pages", round_start=True, data=args) + ch_sub_query_chart.append("pages.event_type='LOCATION'") + meta_condition = __get_meta_constraint(args) + ch_sub_query_chart += meta_condition + + params = {"step_size": step_size, "project_id": project_id, "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, **__get_constraint_values(args)} + ch_query = f"""SELECT timestamp, COALESCE(avgOrNull(count), 0) AS value + FROM (SELECT toUnixTimestamp(toStartOfInterval(pages.datetime, INTERVAL %(step_size)s second ))*1000 AS timestamp, + session_id, COUNT(1) AS count + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS pages + WHERE {" AND ".join(ch_sub_query_chart)} + GROUP BY timestamp,session_id + ORDER BY timestamp) AS groupped_data + WHERE count>0 + GROUP BY timestamp + ORDER BY timestamp;""" + rows = ch.execute(query=ch_query, params=params) + rows = __complete_missing_steps(rows=rows, start_time=startTimestamp, + end_time=endTimestamp, + density=density, neutral={"value": 0}) + return rows + + +def get_user_activity_avg_session_duration(project_id, startTimestamp=TimeUTC.now(delta_days=-1), + endTimestamp=TimeUTC.now(), **args): + results = {} + + with ch_client.ClickHouseClient() as ch: + rows = __get_user_activity_avg_session_duration(ch, project_id, startTimestamp, endTimestamp, **args) + if len(rows) > 0: + results = helper.dict_to_camel_case(rows[0]) + for key in results: + if isnan(results[key]): + results[key] = 0 + results["chart"] = __get_user_activity_avg_session_duration_chart(ch, project_id, startTimestamp, + endTimestamp, **args) + diff = endTimestamp - startTimestamp + endTimestamp = startTimestamp + startTimestamp = endTimestamp - diff + rows = __get_user_activity_avg_session_duration(ch, project_id, startTimestamp, endTimestamp, **args) + + if len(rows) > 0: + previous = helper.dict_to_camel_case(rows[0]) + results["progress"] = helper.__progress(old_val=previous["value"], new_val=results["value"]) + helper.__time_value(results) + return results + + +def __get_user_activity_avg_session_duration(ch, project_id, startTimestamp, endTimestamp, **args): + ch_sub_query = __get_basic_constraints(table_name="sessions", data=args) + meta_condition = __get_meta_constraint(args) + ch_sub_query += meta_condition + ch_sub_query.append("isNotNull(sessions.duration)") + ch_sub_query.append("sessions.duration>0") + + ch_query = f"""SELECT COALESCE(avgOrNull(sessions.duration),0) AS value + FROM {exp_ch_helper.get_main_sessions_table(startTimestamp)} AS sessions + WHERE {" AND ".join(ch_sub_query)};""" + params = {"project_id": project_id, "startTimestamp": startTimestamp, "endTimestamp": endTimestamp, + **__get_constraint_values(args)} + + rows = ch.execute(query=ch_query, params=params) + + return rows + + +def __get_user_activity_avg_session_duration_chart(ch, project_id, startTimestamp, endTimestamp, density=20, **args): + step_size = __get_step_size(endTimestamp=endTimestamp, startTimestamp=startTimestamp, density=density) + ch_sub_query_chart = __get_basic_constraints(table_name="sessions", round_start=True, data=args) + meta_condition = __get_meta_constraint(args) + ch_sub_query_chart += meta_condition + ch_sub_query_chart.append("isNotNull(sessions.duration)") + ch_sub_query_chart.append("sessions.duration>0") + params = {"step_size": step_size, "project_id": project_id, "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp} + + ch_query = f"""SELECT toUnixTimestamp(toStartOfInterval(sessions.datetime, INTERVAL %(step_size)s second ))*1000 AS timestamp, + COALESCE(avgOrNull(sessions.duration),0) AS value + FROM {exp_ch_helper.get_main_sessions_table(startTimestamp)} AS sessions + WHERE {" AND ".join(ch_sub_query_chart)} + GROUP BY timestamp + ORDER BY timestamp;""" + + rows = ch.execute(query=ch_query, params={**params, **__get_constraint_values(args)}) + rows = __complete_missing_steps(rows=rows, start_time=startTimestamp, + end_time=endTimestamp, + density=density, neutral={"value": 0}) + return rows + + +def get_top_metrics_avg_response_time(project_id, startTimestamp=TimeUTC.now(delta_days=-1), + endTimestamp=TimeUTC.now(), value=None, density=20, **args): + step_size = __get_step_size(endTimestamp=endTimestamp, startTimestamp=startTimestamp, density=density) + ch_sub_query_chart = __get_basic_constraints(table_name="pages", round_start=True, data=args) + ch_sub_query_chart.append("pages.event_type='LOCATION'") + meta_condition = __get_meta_constraint(args) + ch_sub_query_chart += meta_condition + ch_sub_query = __get_basic_constraints(table_name="pages", data=args) + ch_sub_query.append("pages.event_type='LOCATION'") + ch_sub_query += meta_condition + + if value is not None: + ch_sub_query.append("pages.url_path = %(value)s") + ch_sub_query_chart.append("pages.url_path = %(value)s") + with ch_client.ClickHouseClient() as ch: + ch_query = f"""SELECT COALESCE(avgOrNull(pages.response_time),0) AS value + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS pages + WHERE {" AND ".join(ch_sub_query)} AND isNotNull(pages.response_time) AND pages.response_time>0;""" + params = {"step_size": step_size, "project_id": project_id, + "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, + "value": value, **__get_constraint_values(args)} + rows = ch.execute(query=ch_query, params=params) + results = rows[0] + ch_query = f"""SELECT toUnixTimestamp(toStartOfInterval(pages.datetime, INTERVAL %(step_size)s second ))*1000 AS timestamp, + COALESCE(avgOrNull(pages.response_time),0) AS value + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS pages + WHERE {" AND ".join(ch_sub_query_chart)} AND isNotNull(pages.response_time) AND pages.response_time>0 + GROUP BY timestamp + ORDER BY timestamp;""" + rows = ch.execute(query=ch_query, params={**params, **__get_constraint_values(args)}) + rows = __complete_missing_steps(rows=rows, start_time=startTimestamp, + end_time=endTimestamp, + density=density, neutral={"value": 0}) + results["chart"] = rows + helper.__time_value(results) + return helper.dict_to_camel_case(results) + + +def get_top_metrics_count_requests(project_id, startTimestamp=TimeUTC.now(delta_days=-1), + endTimestamp=TimeUTC.now(), value=None, density=20, **args): + step_size = __get_step_size(endTimestamp=endTimestamp, startTimestamp=startTimestamp, density=density) + ch_sub_query_chart = __get_basic_constraints(table_name="pages", round_start=True, data=args) + ch_sub_query_chart.append("pages.event_type='LOCATION'") + meta_condition = __get_meta_constraint(args) + ch_sub_query_chart += meta_condition + ch_sub_query = __get_basic_constraints(table_name="pages", data=args) + ch_sub_query.append("pages.event_type='LOCATION'") + ch_sub_query += meta_condition + + if value is not None: + ch_sub_query.append("pages.url_path = %(value)s") + ch_sub_query_chart.append("pages.url_path = %(value)s") + with ch_client.ClickHouseClient() as ch: + ch_query = f"""SELECT COUNT(1) AS value + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS pages + WHERE {" AND ".join(ch_sub_query)};""" + params = {"step_size": step_size, "project_id": project_id, + "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, + "value": value, **__get_constraint_values(args)} + rows = ch.execute(query=ch_query, params=params) + result = rows[0] + ch_query = f"""SELECT toUnixTimestamp(toStartOfInterval(pages.datetime, INTERVAL %(step_size)s second ))*1000 AS timestamp, + COUNT(1) AS value + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS pages + WHERE {" AND ".join(ch_sub_query_chart)} + GROUP BY timestamp + ORDER BY timestamp;""" + rows = ch.execute(query=ch_query, params={**params, **__get_constraint_values(args)}) + rows = __complete_missing_steps(rows=rows, start_time=startTimestamp, + end_time=endTimestamp, + density=density, neutral={"value": 0}) + result["chart"] = rows + result["unit"] = schemas.TemplatePredefinedUnits.count + return helper.dict_to_camel_case(result) + + +def get_top_metrics_avg_first_paint(project_id, startTimestamp=TimeUTC.now(delta_days=-1), + endTimestamp=TimeUTC.now(), value=None, density=20, **args): + step_size = __get_step_size(startTimestamp, endTimestamp, density) + ch_sub_query_chart = __get_basic_constraints(table_name="pages", round_start=True, data=args) + ch_sub_query_chart.append("pages.event_type='LOCATION'") + meta_condition = __get_meta_constraint(args) + ch_sub_query_chart += meta_condition + + ch_sub_query = __get_basic_constraints(table_name="pages", data=args) + ch_sub_query.append("pages.event_type='LOCATION'") + ch_sub_query += meta_condition + + if value is not None: + ch_sub_query.append("pages.url_path = %(value)s") + ch_sub_query_chart.append("pages.url_path = %(value)s") + with ch_client.ClickHouseClient() as ch: + ch_query = f"""SELECT COALESCE(avgOrNull(pages.first_paint),0) AS value + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS pages + WHERE {" AND ".join(ch_sub_query)} AND isNotNull(pages.first_paint) AND pages.first_paint>0;""" + params = {"step_size": step_size, "project_id": project_id, + "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, + "value": value, **__get_constraint_values(args)} + rows = ch.execute(query=ch_query, params=params) + results = rows[0] + ch_query = f"""SELECT toUnixTimestamp(toStartOfInterval(pages.datetime, INTERVAL %(step_size)s second)) * 1000 AS timestamp, + COALESCE(avgOrNull(pages.first_paint),0) AS value + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS pages + WHERE {" AND ".join(ch_sub_query_chart)} AND isNotNull(pages.first_paint) AND pages.first_paint>0 + GROUP BY timestamp + ORDER BY timestamp;;""" + rows = ch.execute(query=ch_query, params=params) + results["chart"] = helper.list_to_camel_case(__complete_missing_steps(rows=rows, start_time=startTimestamp, + end_time=endTimestamp, + density=density, + neutral={"value": 0})) + + helper.__time_value(results) + return helper.dict_to_camel_case(results) + + +def get_top_metrics_avg_dom_content_loaded(project_id, startTimestamp=TimeUTC.now(delta_days=-1), + endTimestamp=TimeUTC.now(), value=None, density=19, **args): + step_size = __get_step_size(startTimestamp, endTimestamp, density) + ch_sub_query_chart = __get_basic_constraints(table_name="pages", round_start=True, data=args) + ch_sub_query_chart.append("pages.event_type='LOCATION'") + meta_condition = __get_meta_constraint(args) + ch_sub_query_chart += meta_condition + + ch_sub_query = __get_basic_constraints(table_name="pages", data=args) + ch_sub_query.append("pages.event_type='LOCATION'") + ch_sub_query += meta_condition + + if value is not None: + ch_sub_query.append("pages.url_path = %(value)s") + ch_sub_query_chart.append("pages.url_path = %(value)s") + ch_sub_query.append("isNotNull(pages.dom_content_loaded_event_time)") + ch_sub_query.append("pages.dom_content_loaded_event_time>0") + ch_sub_query_chart.append("isNotNull(pages.dom_content_loaded_event_time)") + ch_sub_query_chart.append("pages.dom_content_loaded_event_time>0") + with ch_client.ClickHouseClient() as ch: + ch_query = f"""SELECT COALESCE(avgOrNull(pages.dom_content_loaded_event_time),0) AS value + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS pages + WHERE {" AND ".join(ch_sub_query)};""" + params = {"step_size": step_size, "project_id": project_id, + "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, + "value": value, **__get_constraint_values(args)} + rows = ch.execute(query=ch_query, params=params) + results = helper.dict_to_camel_case(rows[0]) + ch_query = f"""SELECT toUnixTimestamp(toStartOfInterval(pages.datetime, INTERVAL %(step_size)s second)) * 1000 AS timestamp, + COALESCE(avgOrNull(pages.dom_content_loaded_event_time),0) AS value + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS pages + WHERE {" AND ".join(ch_sub_query_chart)} + GROUP BY timestamp + ORDER BY timestamp;""" + rows = ch.execute(query=ch_query, params=params) + results["chart"] = helper.list_to_camel_case(__complete_missing_steps(rows=rows, start_time=startTimestamp, + end_time=endTimestamp, + density=density, + neutral={"value": 0})) + helper.__time_value(results) + return results + + +def get_top_metrics_avg_till_first_bit(project_id, startTimestamp=TimeUTC.now(delta_days=-1), + endTimestamp=TimeUTC.now(), value=None, density=20, **args): + step_size = __get_step_size(startTimestamp, endTimestamp, density) + ch_sub_query_chart = __get_basic_constraints(table_name="pages", round_start=True, data=args) + ch_sub_query_chart.append("pages.event_type='LOCATION'") + meta_condition = __get_meta_constraint(args) + ch_sub_query_chart += meta_condition + + ch_sub_query = __get_basic_constraints(table_name="pages", data=args) + ch_sub_query.append("pages.event_type='LOCATION'") + ch_sub_query += meta_condition + + if value is not None: + ch_sub_query.append("pages.url_path = %(value)s") + ch_sub_query_chart.append("pages.url_path = %(value)s") + ch_sub_query.append("isNotNull(pages.ttfb)") + ch_sub_query.append("pages.ttfb>0") + ch_sub_query_chart.append("isNotNull(pages.ttfb)") + ch_sub_query_chart.append("pages.ttfb>0") + with ch_client.ClickHouseClient() as ch: + ch_query = f"""SELECT COALESCE(avgOrNull(pages.ttfb),0) AS value + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS pages + WHERE {" AND ".join(ch_sub_query)};""" + params = {"step_size": step_size, "project_id": project_id, + "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, + "value": value, **__get_constraint_values(args)} + rows = ch.execute(query=ch_query, params=params) + results = rows[0] + ch_query = f"""SELECT toUnixTimestamp(toStartOfInterval(pages.datetime, INTERVAL %(step_size)s second)) * 1000 AS timestamp, + COALESCE(avgOrNull(pages.ttfb),0) AS value + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS pages + WHERE {" AND ".join(ch_sub_query_chart)} + GROUP BY timestamp + ORDER BY timestamp;""" + rows = ch.execute(query=ch_query, params=params) + results["chart"] = helper.list_to_camel_case(__complete_missing_steps(rows=rows, start_time=startTimestamp, + end_time=endTimestamp, + density=density, + neutral={"value": 0})) + helper.__time_value(results) + return helper.dict_to_camel_case(results) + + +def get_top_metrics_avg_time_to_interactive(project_id, startTimestamp=TimeUTC.now(delta_days=-1), + endTimestamp=TimeUTC.now(), value=None, density=20, **args): + step_size = __get_step_size(startTimestamp, endTimestamp, density) + ch_sub_query_chart = __get_basic_constraints(table_name="pages", round_start=True, data=args) + ch_sub_query_chart.append("pages.event_type='LOCATION'") + meta_condition = __get_meta_constraint(args) + ch_sub_query_chart += meta_condition + + ch_sub_query = __get_basic_constraints(table_name="pages", data=args) + ch_sub_query.append("pages.event_type='LOCATION'") + ch_sub_query += meta_condition + + if value is not None: + ch_sub_query.append("pages.url_path = %(value)s") + ch_sub_query_chart.append("pages.url_path = %(value)s") + ch_sub_query.append("isNotNull(pages.time_to_interactive)") + ch_sub_query.append("pages.time_to_interactive >0") + ch_sub_query_chart.append("isNotNull(pages.time_to_interactive)") + ch_sub_query_chart.append("pages.time_to_interactive >0") + with ch_client.ClickHouseClient() as ch: + ch_query = f"""SELECT COALESCE(avgOrNull(pages.time_to_interactive),0) AS value + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS pages + WHERE {" AND ".join(ch_sub_query)};""" + params = {"step_size": step_size, "project_id": project_id, + "startTimestamp": startTimestamp, + "endTimestamp": endTimestamp, + "value": value, **__get_constraint_values(args)} + rows = ch.execute(query=ch_query, params=params) + results = rows[0] + ch_query = f"""SELECT toUnixTimestamp(toStartOfInterval(pages.datetime, INTERVAL %(step_size)s second)) * 1000 AS timestamp, + COALESCE(avgOrNull(pages.time_to_interactive),0) AS value + FROM {exp_ch_helper.get_main_events_table(startTimestamp)} AS pages + WHERE {" AND ".join(ch_sub_query_chart)} + GROUP BY timestamp + ORDER BY timestamp;""" + rows = ch.execute(query=ch_query, params=params) + results["chart"] = helper.list_to_camel_case(__complete_missing_steps(rows=rows, start_time=startTimestamp, + end_time=endTimestamp, + density=density, + neutral={"value": 0})) + helper.__time_value(results) + return helper.dict_to_camel_case(results) diff --git a/ee/api/chalicelib/core/projects.py b/ee/api/chalicelib/core/projects.py index 6700173b5..9e5600865 100644 --- a/ee/api/chalicelib/core/projects.py +++ b/ee/api/chalicelib/core/projects.py @@ -52,29 +52,57 @@ def get_projects(tenant_id, recording_state=False, gdpr=None, recorded=False, st AND users.tenant_id = %(tenant_id)s AND (roles.all_projects OR roles_projects.project_id = s.project_id) ) AS role_project ON (TRUE)""" - recorded_q = "" + extra_projection = "" + extra_join = "" + if gdpr: + extra_projection += ',s.gdpr' if recorded: - recorded_q = """, COALESCE((SELECT TRUE - FROM public.sessions - WHERE sessions.project_id = s.project_id - AND sessions.start_ts >= (EXTRACT(EPOCH FROM s.created_at) * 1000 - 24 * 60 * 60 * 1000) - AND sessions.start_ts <= %(now)s - LIMIT 1), FALSE) AS recorded""" - query = cur.mogrify(f"""\ - SELECT - s.project_id, s.name, s.project_key, s.save_request_payloads - {',s.gdpr' if gdpr else ''} - {recorded_q} - {',stack_integrations.count>0 AS stack_integrations' if stack_integrations else ''} - FROM public.projects AS s - {'LEFT JOIN LATERAL (SELECT COUNT(*) AS count FROM public.integrations WHERE s.project_id = integrations.project_id LIMIT 1) AS stack_integrations ON TRUE' if stack_integrations else ''} - {role_query if user_id is not None else ""} - WHERE s.tenant_id =%(tenant_id)s - AND s.deleted_at IS NULL - ORDER BY s.project_id;""", + extra_projection += """,COALESCE(nullif(EXTRACT(EPOCH FROM s.first_recorded_session_at) * 1000, NULL)::BIGINT , + (SELECT MIN(sessions.start_ts) + FROM public.sessions + WHERE sessions.project_id = s.project_id + AND sessions.start_ts >= (EXTRACT(EPOCH FROM + COALESCE(s.sessions_last_check_at, s.created_at)) * 1000-24*60*60*1000) + AND sessions.start_ts <= %(now)s + LIMIT 1), NULL) AS first_recorded""" + if stack_integrations: + extra_projection += ',stack_integrations.count>0 AS stack_integrations' + + if stack_integrations: + extra_join = """LEFT JOIN LATERAL (SELECT COUNT(*) AS count + FROM public.integrations + WHERE s.project_id = integrations.project_id + LIMIT 1) AS stack_integrations ON TRUE""" + + query = cur.mogrify(f"""{"SELECT *, first_recorded IS NOT NULL AS recorded FROM (" if recorded else ""} + SELECT s.project_id, s.name, s.project_key, s.save_request_payloads, s.first_recorded_session_at + {extra_projection} + FROM public.projects AS s + {extra_join} + {role_query if user_id is not None else ""} + WHERE s.tenant_id =%(tenant_id)s + AND s.deleted_at IS NULL + ORDER BY s.project_id {") AS raw" if recorded else ""};""", {"tenant_id": tenant_id, "user_id": user_id, "now": TimeUTC.now()}) cur.execute(query) rows = cur.fetchall() + + # if recorded is requested, check if it was saved or computed + if recorded: + for r in rows: + if r["first_recorded_session_at"] is None: + extra_update = "" + if r["recorded"]: + extra_update = ", first_recorded_session_at=to_timestamp(%(first_recorded)s/1000)" + query = cur.mogrify(f"""UPDATE public.projects + SET sessions_last_check_at=(now() at time zone 'utc') + {extra_update} + WHERE project_id=%(project_id)s""", + {"project_id": r["project_id"], "first_recorded": r["first_recorded"]}) + cur.execute(query) + r.pop("first_recorded_session_at") + r.pop("first_recorded") + if recording_state: project_ids = [f'({r["project_id"]})' for r in rows] query = cur.mogrify(f"""SELECT projects.project_id, COALESCE(MAX(start_ts), 0) AS last diff --git a/ee/api/chalicelib/core/sessions_exp.py b/ee/api/chalicelib/core/sessions_exp.py new file mode 100644 index 000000000..add1a790d --- /dev/null +++ b/ee/api/chalicelib/core/sessions_exp.py @@ -0,0 +1,1544 @@ +from typing import List, Union + +import schemas +import schemas_ee +from chalicelib.core import events, metadata, events_ios, \ + sessions_mobs, issues, projects, errors, resources, assist, performance_event, metrics +from chalicelib.utils import pg_client, helper, metrics_helper, ch_client, exp_ch_helper + +SESSION_PROJECTION_COLS_CH = """\ +s.project_id, +s.session_id AS session_id, +s.user_uuid AS user_uuid, +s.user_id AS user_id, +s.user_os AS user_os, +s.user_browser AS user_browser, +s.user_device AS user_device, +s.user_device_type AS user_device_type, +s.user_country AS user_country, +toUnixTimestamp(s.datetime)*1000 AS start_ts, +s.duration AS duration, +s.events_count AS events_count, +s.pages_count AS pages_count, +s.errors_count AS errors_count, +s.user_anonymous_id AS user_anonymous_id, +s.platform AS platform, +coalesce(issue_score,0) AS issue_score, +s.issue_types AS issue_types +""" + +SESSION_PROJECTION_COLS_CH_MAP = """\ +'project_id', toString(%(project_id)s), +'session_id', toString(s.session_id), +'user_uuid', toString(s.user_uuid), +'user_id', toString(s.user_id), +'user_os', toString(s.user_os), +'user_browser', toString(s.user_browser), +'user_device', toString(s.user_device), +'user_device_type', toString(s.user_device_type), +'user_country', toString(s.user_country), +'start_ts', toString(toUnixTimestamp(s.datetime)*1000), +'duration', toString(s.duration), +'events_count', toString(s.events_count), +'pages_count', toString(s.pages_count), +'errors_count', toString(s.errors_count), +'user_anonymous_id', toString(s.user_anonymous_id), +'platform', toString(s.platform), +'issue_score', toString(coalesce(issue_score,0)), +'viewed', toString(viewed_sessions.session_id > 0) +""" + + +def __group_metadata(session, project_metadata): + meta = {} + for m in project_metadata.keys(): + if project_metadata[m] is not None and session.get(m) is not None: + meta[project_metadata[m]] = session[m] + session.pop(m) + return meta + + +def get_by_id2_pg(project_id, session_id, user_id, full_data=False, include_fav_viewed=False, group_metadata=False, + live=True): + with pg_client.PostgresClient() as cur: + extra_query = [] + if include_fav_viewed: + extra_query.append("""COALESCE((SELECT TRUE + FROM public.user_favorite_sessions AS fs + WHERE s.session_id = fs.session_id + AND fs.user_id = %(userId)s), FALSE) AS favorite""") + extra_query.append("""COALESCE((SELECT TRUE + FROM public.user_viewed_sessions AS fs + WHERE s.session_id = fs.session_id + AND fs.user_id = %(userId)s), FALSE) AS viewed""") + query = cur.mogrify( + f"""\ + SELECT + s.*, + 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 ""} + WHERE s.project_id = %(project_id)s + AND s.session_id = %(session_id)s;""", + {"project_id": project_id, "session_id": session_id, "userId": user_id} + ) + # print("===============") + # print(query) + cur.execute(query=query) + + data = cur.fetchone() + if data is not None: + data = helper.dict_to_camel_case(data) + if full_data: + if data["platform"] == 'ios': + data['events'] = events_ios.get_by_sessionId(project_id=project_id, session_id=session_id) + for e in data['events']: + if e["type"].endswith("_IOS"): + e["type"] = e["type"][:-len("_IOS")] + data['crashes'] = events_ios.get_crashes_by_session_id(session_id=session_id) + data['userEvents'] = events_ios.get_customs_by_sessionId(project_id=project_id, + session_id=session_id) + data['mobsUrl'] = sessions_mobs.get_ios(sessionId=session_id) + else: + data['events'] = events.get_by_sessionId2_pg(project_id=project_id, session_id=session_id, + group_clickrage=True) + all_errors = events.get_errors_by_session_id(session_id=session_id, project_id=project_id) + data['stackEvents'] = [e for e in all_errors if e['source'] != "js_exception"] + # to keep only the first stack + data['errors'] = [errors.format_first_stack_frame(e) for e in all_errors if + e['source'] == "js_exception"][ + :500] # limit the number of errors to reduce the response-body size + data['userEvents'] = events.get_customs_by_sessionId2_pg(project_id=project_id, + session_id=session_id) + data['mobsUrl'] = sessions_mobs.get_web(sessionId=session_id) + data['resources'] = resources.get_by_session_id(session_id=session_id, project_id=project_id, + start_ts=data["startTs"], + duration=data["duration"]) + + data['metadata'] = __group_metadata(project_metadata=data.pop("projectMetadata"), session=data) + data['issues'] = issues.get_by_session_id(session_id=session_id, project_id=project_id) + data['live'] = live and assist.is_live(project_id=project_id, + session_id=session_id, + project_key=data["projectKey"]) + data["inDB"] = True + return data + elif live: + return assist.get_live_session_by_id(project_id=project_id, session_id=session_id) + else: + return None + + +def __get_sql_operator(op: schemas.SearchEventOperator): + return { + schemas.SearchEventOperator._is: "=", + schemas.SearchEventOperator._is_any: "IN", + schemas.SearchEventOperator._on: "=", + schemas.SearchEventOperator._on_any: "IN", + schemas.SearchEventOperator._is_not: "!=", + schemas.SearchEventOperator._not_on: "!=", + schemas.SearchEventOperator._contains: "ILIKE", + schemas.SearchEventOperator._not_contains: "NOT ILIKE", + schemas.SearchEventOperator._starts_with: "ILIKE", + schemas.SearchEventOperator._ends_with: "ILIKE", + }.get(op, "=") + + +def __is_negation_operator(op: schemas.SearchEventOperator): + return op in [schemas.SearchEventOperator._is_not, + schemas.SearchEventOperator._not_on, + schemas.SearchEventOperator._not_contains] + + +def __reverse_sql_operator(op): + return "=" if op == "!=" else "!=" if op == "=" else "ILIKE" if op == "NOT ILIKE" else "NOT ILIKE" + + +def __get_sql_operator_multiple(op: schemas.SearchEventOperator): + return " IN " if op not in [schemas.SearchEventOperator._is_not, schemas.SearchEventOperator._not_on, + schemas.SearchEventOperator._not_contains] else " NOT IN " + + +def __get_sql_value_multiple(values): + if isinstance(values, tuple): + return values + return tuple(values) if isinstance(values, list) else (values,) + + +def _multiple_conditions(condition, values, value_key="value", is_not=False): + query = [] + for i in range(len(values)): + k = f"{value_key}_{i}" + query.append(condition.replace(value_key, k)) + return "(" + (" AND " if is_not else " OR ").join(query) + ")" + + +def _multiple_values(values, value_key="value"): + query_values = {} + if values is not None and isinstance(values, list): + for i in range(len(values)): + k = f"{value_key}_{i}" + query_values[k] = values[i] + return query_values + + +def _isAny_opreator(op: schemas.SearchEventOperator): + return op in [schemas.SearchEventOperator._on_any, schemas.SearchEventOperator._is_any] + + +def _isUndefined_operator(op: schemas.SearchEventOperator): + return op in [schemas.SearchEventOperator._is_undefined] + + +# This function executes the query and return result +def search_sessions(data: schemas.SessionsSearchPayloadSchema, project_id, user_id, errors_only=False, + error_status=schemas.ErrorStatus.all, count_only=False, issue=None): + full_args, query_part = search_query_parts_ch(data=data, error_status=error_status, errors_only=errors_only, + favorite_only=data.bookmarked, issue=issue, project_id=project_id, + user_id=user_id) + if data.sort == "startTs": + data.sort = "datetime" + if data.limit is not None and data.page is not None: + full_args["sessions_limit_s"] = (data.page - 1) * data.limit + full_args["sessions_limit_e"] = data.page * data.limit + full_args["sessions_limit"] = data.limit + else: + full_args["sessions_limit_s"] = 0 + full_args["sessions_limit_e"] = 200 + full_args["sessions_limit"] = 200 + + meta_keys = [] + with ch_client.ClickHouseClient() as cur: + if errors_only: + # print("--------------------QP") + # print(cur.format(query_part, full_args)) + # print("--------------------") + main_query = cur.format(f"""SELECT DISTINCT er.error_id, + COALESCE((SELECT TRUE + FROM {exp_ch_helper.get_user_viewed_errors_table()} AS ve + WHERE er.error_id = ve.error_id + AND ve.user_id = %(userId)s LIMIT 1), FALSE) AS viewed + {query_part};""", full_args) + + elif count_only: + main_query = cur.mogrify(f"""SELECT COUNT(DISTINCT s.session_id) AS count_sessions, + COUNT(DISTINCT s.user_uuid) AS count_users + {query_part};""", full_args) + elif data.group_by_user: + g_sort = "count(full_sessions)" + if data.order is None: + data.order = schemas.SortOrderType.desc + else: + data.order = data.order.upper() + if data.sort is not None and data.sort != 'sessionsCount': + sort = helper.key_to_snake_case(data.sort) + g_sort = f"{'MIN' if data.order == schemas.SortOrderType.desc else 'MAX'}({sort})" + else: + sort = 'start_ts' + + meta_keys = metadata.get(project_id=project_id) + main_query = cur.mogrify(f"""SELECT COUNT(*) AS count, + COALESCE(JSONB_AGG(users_sessions) + FILTER (WHERE rn>%(sessions_limit_s)s AND rn<=%(sessions_limit_e)s), '[]'::JSONB) AS sessions + FROM (SELECT user_id, + count(full_sessions) AS user_sessions_count, + jsonb_agg(full_sessions) FILTER (WHERE rn <= 1) AS last_session, + MIN(full_sessions.start_ts) AS first_session_ts, + ROW_NUMBER() OVER (ORDER BY {g_sort} {data.order}) AS rn + FROM (SELECT *, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY {sort} {data.order}) AS rn + FROM (SELECT DISTINCT ON(s.session_id) {SESSION_PROJECTION_COLS} + {"," if len(meta_keys) > 0 else ""}{",".join([f'metadata_{m["index"]}' for m in meta_keys])} + {query_part} + ) AS filtred_sessions + ) AS full_sessions + GROUP BY user_id + ) AS users_sessions;""", + full_args) + else: + if data.order is None: + data.order = schemas.SortOrderType.desc + sort = 'session_id' + if data.sort is not None and data.sort != "session_id": + # sort += " " + data.order + "," + helper.key_to_snake_case(data.sort) + sort = helper.key_to_snake_case(data.sort) + + meta_keys = metadata.get(project_id=project_id) + main_query = cur.format(f"""SELECT any(total) AS count, groupArray(%(sessions_limit)s)(details) AS sessions + FROM (SELECT total, details + FROM (SELECT COUNT() OVER () AS total, + s.{sort} AS sort_key, + map({SESSION_PROJECTION_COLS_CH_MAP}) AS details + {query_part} + LEFT JOIN (SELECT session_id + FROM experimental.user_viewed_sessions + WHERE user_id = %(userId)s AND project_id=%(project_id)s + AND _timestamp >= toDateTime(%(startDate)s / 1000)) AS viewed_sessions + ON (viewed_sessions.session_id = s.session_id) + ) AS raw + ORDER BY sort_key {data.order} + LIMIT %(sessions_limit)s OFFSET %(sessions_limit_s)s) AS sorted_sessions;""", + full_args) + # print("--------------------") + # print(main_query) + # print("--------------------") + try: + sessions = cur.execute(main_query) + except Exception as err: + print("--------- SESSIONS-CH SEARCH QUERY EXCEPTION -----------") + print("--------- PAYLOAD -----------") + print(data.json()) + print("--------------------") + raise err + if errors_only: + return helper.list_to_camel_case(cur.fetchall()) + + if len(sessions) > 0: + sessions = sessions[0] + # if count_only: + # return helper.dict_to_camel_case(sessions) + # for s in sessions: + # print(s) + # s["session_id"] = str(s["session_id"]) + total = sessions["count"] + sessions = sessions["sessions"] + + if data.group_by_user: + for i, s in enumerate(sessions): + sessions[i] = {**s.pop("last_session")[0], **s} + sessions[i].pop("rn") + sessions[i]["metadata"] = {k["key"]: sessions[i][f'metadata_{k["index"]}'] for k in meta_keys \ + if sessions[i][f'metadata_{k["index"]}'] is not None} + else: + for i in range(len(sessions)): + sessions[i]["metadata"] = {k["key"]: sessions[i][f'metadata_{k["index"]}'] for k in meta_keys \ + if sessions[i].get(f'metadata_{k["index"]}') is not None} + sessions[i] = schemas_ee.SessionModel.parse_obj(helper.dict_to_camel_case(sessions[i])) + + # if not data.group_by_user and data.sort is not None and data.sort != "session_id": + # sessions = sorted(sessions, key=lambda s: s[helper.key_to_snake_case(data.sort)], + # reverse=data.order.upper() == "DESC") + return { + 'total': total, + 'sessions': sessions + } + + +def search2_series(data: schemas.SessionsSearchPayloadSchema, project_id: int, density: int, + view_type: schemas.MetricTimeseriesViewType, metric_type: schemas.MetricType, + metric_of: schemas.TableMetricOfType, metric_value: List): + step_size = int(metrics_helper.__get_step_size(endTimestamp=data.endDate, startTimestamp=data.startDate, + density=density)) + extra_event = None + if metric_of == schemas.TableMetricOfType.visited_url: + extra_event = f"""SELECT DISTINCT ev.session_id, ev.url_path + FROM {exp_ch_helper.get_main_events_table(data.startDate)} AS ev + WHERE ev.datetime >= toDateTime(%(startDate)s / 1000) + AND ev.datetime <= toDateTime(%(endDate)s / 1000) + AND ev.project_id = %(project_id)s + AND ev.event_type = 'LOCATION'""" + elif metric_of == schemas.TableMetricOfType.issues and len(metric_value) > 0: + data.filters.append(schemas.SessionSearchFilterSchema(value=metric_value, type=schemas.FilterType.issue, + operator=schemas.SearchEventOperator._is)) + full_args, query_part = search_query_parts_ch(data=data, error_status=None, errors_only=False, + favorite_only=False, issue=None, project_id=project_id, + user_id=None, extra_event=extra_event) + full_args["step_size"] = step_size + sessions = [] + with ch_client.ClickHouseClient() as cur: + if metric_type == schemas.MetricType.timeseries: + if view_type == schemas.MetricTimeseriesViewType.line_chart: + query = f"""SELECT toUnixTimestamp( + toStartOfInterval(processed_sessions.datetime, INTERVAL %(step_size)s second) + ) * 1000 AS timestamp, + COUNT(processed_sessions.session_id) AS count + FROM (SELECT DISTINCT ON(s.session_id) s.session_id AS session_id, + s.datetime AS datetime + {query_part}) AS processed_sessions + GROUP BY timestamp + ORDER BY timestamp;""" + main_query = cur.format(query, full_args) + else: + main_query = cur.format(f"""SELECT count(DISTINCT s.session_id) AS count + {query_part};""", full_args) + + # print("--------------------") + # print(main_query) + # print("--------------------") + sessions = cur.execute(main_query) + if view_type == schemas.MetricTimeseriesViewType.line_chart: + sessions = metrics.__complete_missing_steps(start_time=data.startDate, end_time=data.endDate, + density=density, neutral={"count": 0}, rows=sessions) + else: + sessions = sessions[0]["count"] if len(sessions) > 0 else 0 + elif metric_type == schemas.MetricType.table: + full_args["limit_s"] = 0 + full_args["limit_e"] = 200 + if isinstance(metric_of, schemas.TableMetricOfType): + main_col = "user_id" + extra_col = "s.user_id" + extra_where = "" + pre_query = "" + if metric_of == schemas.TableMetricOfType.user_country: + main_col = "user_country" + extra_col = "s.user_country" + elif metric_of == schemas.TableMetricOfType.user_device: + main_col = "user_device" + extra_col = "s.user_device" + elif metric_of == schemas.TableMetricOfType.user_browser: + main_col = "user_browser" + extra_col = "s.user_browser" + elif metric_of == schemas.TableMetricOfType.issues: + main_col = "issue" + extra_col = f"arrayJoin(s.issue_types) AS {main_col}" + if len(metric_value) > 0: + extra_where = [] + for i in range(len(metric_value)): + arg_name = f"selected_issue_{i}" + extra_where.append(f"{main_col} = %({arg_name})s") + full_args[arg_name] = metric_value[i] + extra_where = f"WHERE ({' OR '.join(extra_where)})" + elif metric_of == schemas.TableMetricOfType.visited_url: + main_col = "url_path" + extra_col = "s.url_path" + main_query = cur.format(f"""{pre_query} + SELECT COUNT(DISTINCT {main_col}) OVER () AS main_count, + {main_col} AS name, + count(DISTINCT session_id) AS session_count + FROM (SELECT s.session_id AS session_id, + {extra_col} + {query_part} + ORDER BY s.session_id desc) AS filtred_sessions + {extra_where} + GROUP BY {main_col} + ORDER BY session_count DESC + LIMIT %(limit_e)s OFFSET %(limit_s)s;""", + full_args) + print("--------------------") + print(main_query) + print("--------------------") + sessions = cur.execute(main_query) + # cur.fetchone() + count = 0 + if len(sessions) > 0: + count = sessions[0]["main_count"] + for s in sessions: + s.pop("main_count") + sessions = {"count": count, "values": helper.list_to_camel_case(sessions)} + + return sessions + + +def __is_valid_event(is_any: bool, event: schemas._SessionSearchEventSchema): + return not (not is_any and len(event.value) == 0 and event.type not in [schemas.EventType.request_details, + schemas.EventType.graphql] \ + or event.type in [schemas.PerformanceEventType.location_dom_complete, + schemas.PerformanceEventType.location_largest_contentful_paint_time, + schemas.PerformanceEventType.location_ttfb, + schemas.PerformanceEventType.location_avg_cpu_load, + schemas.PerformanceEventType.location_avg_memory_usage + ] and (event.source is None or len(event.source) == 0) \ + or event.type in [schemas.EventType.request_details, schemas.EventType.graphql] and ( + event.filters is None or len(event.filters) == 0)) + + +def __get_event_type(event_type: Union[schemas.EventType, schemas.PerformanceEventType]): + defs = { + schemas.EventType.click: "CLICK", + schemas.EventType.input: "INPUT", + schemas.EventType.location: "LOCATION", + schemas.PerformanceEventType.location_dom_complete: "LOCATION", + schemas.PerformanceEventType.location_largest_contentful_paint_time: "LOCATION", + schemas.PerformanceEventType.location_ttfb: "LOCATION", + schemas.EventType.custom: "CUSTOM", + schemas.EventType.request: "REQUEST", + schemas.EventType.request_details: "REQUEST", + schemas.PerformanceEventType.fetch_failed: "REQUEST", + schemas.EventType.state_action: "STATEACTION", + schemas.EventType.error: "ERROR", + schemas.PerformanceEventType.location_avg_cpu_load: 'PERFORMANCE', + schemas.PerformanceEventType.location_avg_memory_usage: 'PERFORMANCE', + } + + if event_type not in defs: + raise Exception(f"unsupported event_type:{event_type}") + return defs.get(event_type) + + +# this function generates the query and return the generated-query with the dict of query arguments +def search_query_parts_ch(data, error_status, errors_only, favorite_only, issue, project_id, user_id, extra_event=None): + ss_constraints = [] + full_args = {"project_id": project_id, "startDate": data.startDate, "endDate": data.endDate, + "projectId": project_id, "userId": user_id} + + MAIN_EVENTS_TABLE = exp_ch_helper.get_main_events_table(data.startDate) + MAIN_SESSIONS_TABLE = exp_ch_helper.get_main_sessions_table(data.startDate) + + full_args["MAIN_EVENTS_TABLE"] = MAIN_EVENTS_TABLE + full_args["MAIN_SESSIONS_TABLE"] = MAIN_SESSIONS_TABLE + extra_constraints = [ + "s.project_id = %(project_id)s", + "isNotNull(s.duration)" + ] + if favorite_only: + extra_constraints.append(f"""s.session_id IN (SELECT session_id + FROM {exp_ch_helper.get_user_favorite_sessions_table()} AS user_favorite_sessions + WHERE user_id = %(userId)s)""") + extra_from = "" + events_query_part = "" + __events_where_basic = ["project_id = %(projectId)s", + "datetime >= toDateTime(%(startDate)s/1000)", + "datetime <= toDateTime(%(endDate)s/1000)"] + events_conditions_where = ["main.project_id = %(projectId)s", + "main.datetime >= toDateTime(%(startDate)s/1000)", + "main.datetime <= toDateTime(%(endDate)s/1000)"] + if len(data.filters) > 0: + meta_keys = None + # to reduce include a sub-query of sessions inside events query, in order to reduce the selected data + include_in_events = False + for i, f in enumerate(data.filters): + if not isinstance(f.value, list): + f.value = [f.value] + filter_type = f.type + f.value = helper.values_for_operator(value=f.value, op=f.operator) + f_k = f"f_value{i}" + full_args = {**full_args, f_k: f.value, **_multiple_values(f.value, value_key=f_k)} + op = __get_sql_operator(f.operator) \ + if filter_type not in [schemas.FilterType.events_count] else f.operator + is_any = _isAny_opreator(f.operator) + is_undefined = _isUndefined_operator(f.operator) + if not is_any and not is_undefined and len(f.value) == 0: + continue + is_not = False + if __is_negation_operator(f.operator): + is_not = True + if filter_type == schemas.FilterType.user_browser: + if is_any: + extra_constraints.append('isNotNull(s.user_browser)') + ss_constraints.append('isNotNull(ms.user_browser)') + else: + extra_constraints.append( + _multiple_conditions(f's.user_browser {op} %({f_k})s', f.value, is_not=is_not, value_key=f_k)) + ss_constraints.append( + _multiple_conditions(f'ms.user_browser {op} %({f_k})s', f.value, is_not=is_not, value_key=f_k)) + + elif filter_type in [schemas.FilterType.user_os, schemas.FilterType.user_os_ios]: + if is_any: + extra_constraints.append('isNotNull(s.user_os)') + ss_constraints.append('isNotNull(ms.user_os)') + else: + extra_constraints.append( + _multiple_conditions(f's.user_os {op} %({f_k})s', f.value, is_not=is_not, value_key=f_k)) + ss_constraints.append( + _multiple_conditions(f'ms.user_os {op} %({f_k})s', f.value, is_not=is_not, value_key=f_k)) + + elif filter_type in [schemas.FilterType.user_device, schemas.FilterType.user_device_ios]: + if is_any: + extra_constraints.append('isNotNull(s.user_device)') + ss_constraints.append('isNotNull(ms.user_device)') + else: + extra_constraints.append( + _multiple_conditions(f's.user_device {op} %({f_k})s', f.value, is_not=is_not, value_key=f_k)) + ss_constraints.append( + _multiple_conditions(f'ms.user_device {op} %({f_k})s', f.value, is_not=is_not, value_key=f_k)) + + elif filter_type in [schemas.FilterType.user_country, schemas.FilterType.user_country_ios]: + if is_any: + extra_constraints.append('isNotNull(s.user_country)') + ss_constraints.append('isNotNull(ms.user_country)') + else: + extra_constraints.append( + _multiple_conditions(f's.user_country {op} %({f_k})s', f.value, is_not=is_not, value_key=f_k)) + ss_constraints.append( + _multiple_conditions(f'ms.user_country {op} %({f_k})s', f.value, is_not=is_not, value_key=f_k)) + + elif filter_type in [schemas.FilterType.utm_source]: + if is_any: + extra_constraints.append('isNotNull(s.utm_source)') + ss_constraints.append('isNotNull(ms.utm_source)') + elif is_undefined: + extra_constraints.append('isNull(s.utm_source)') + ss_constraints.append('isNull(ms.utm_source)') + else: + extra_constraints.append( + _multiple_conditions(f's.utm_source {op} toString(%({f_k})s)', f.value, is_not=is_not, + value_key=f_k)) + ss_constraints.append( + _multiple_conditions(f'ms.utm_source {op} toString(%({f_k})s)', f.value, is_not=is_not, + value_key=f_k)) + elif filter_type in [schemas.FilterType.utm_medium]: + if is_any: + extra_constraints.append('isNotNull(s.utm_medium)') + ss_constraints.append('isNotNull(ms.utm_medium)') + elif is_undefined: + extra_constraints.append('isNull(s.utm_medium)') + ss_constraints.append('isNull(ms.utm_medium') + else: + extra_constraints.append( + _multiple_conditions(f's.utm_medium {op} toString(%({f_k})s)', f.value, is_not=is_not, + value_key=f_k)) + ss_constraints.append( + _multiple_conditions(f'ms.utm_medium {op} toString(%({f_k})s)', f.value, is_not=is_not, + value_key=f_k)) + elif filter_type in [schemas.FilterType.utm_campaign]: + if is_any: + extra_constraints.append('isNotNull(s.utm_campaign)') + ss_constraints.append('isNotNull(ms.utm_campaign)') + elif is_undefined: + extra_constraints.append('isNull(s.utm_campaign)') + ss_constraints.append('isNull(ms.utm_campaign)') + else: + extra_constraints.append( + _multiple_conditions(f's.utm_campaign {op} toString(%({f_k})s)', f.value, is_not=is_not, + value_key=f_k)) + ss_constraints.append( + _multiple_conditions(f'ms.utm_campaign {op} toString(%({f_k})s)', f.value, is_not=is_not, + value_key=f_k)) + + elif filter_type == schemas.FilterType.duration: + if len(f.value) > 0 and f.value[0] is not None: + extra_constraints.append("s.duration >= %(minDuration)s") + ss_constraints.append("ms.duration >= %(minDuration)s") + full_args["minDuration"] = f.value[0] + if len(f.value) > 1 and f.value[1] is not None and int(f.value[1]) > 0: + extra_constraints.append("s.duration <= %(maxDuration)s") + ss_constraints.append("ms.duration <= %(maxDuration)s") + full_args["maxDuration"] = f.value[1] + elif filter_type == schemas.FilterType.referrer: + if is_any: + extra_constraints.append('isNotNull(s.base_referrer)') + ss_constraints.append('isNotNull(ms.base_referrer)') + else: + extra_constraints.append( + _multiple_conditions(f"s.base_referrer {op} toString(%({f_k})s)", f.value, is_not=is_not, + value_key=f_k)) + ss_constraints.append( + _multiple_conditions(f"ms.base_referrer {op} toString(%({f_k})s)", f.value, is_not=is_not, + value_key=f_k)) + elif filter_type == events.event_type.METADATA.ui_type: + # get metadata list only if you need it + if meta_keys is None: + meta_keys = metadata.get(project_id=project_id) + meta_keys = {m["key"]: m["index"] for m in meta_keys} + if f.source in meta_keys.keys(): + if is_any: + extra_constraints.append(f"isNotNull(s.{metadata.index_to_colname(meta_keys[f.source])})") + ss_constraints.append(f"isNotNull(ms.{metadata.index_to_colname(meta_keys[f.source])})") + elif is_undefined: + extra_constraints.append(f"isNull(s.{metadata.index_to_colname(meta_keys[f.source])})") + ss_constraints.append(f"isNull(ms.{metadata.index_to_colname(meta_keys[f.source])})") + else: + extra_constraints.append( + _multiple_conditions( + f"s.{metadata.index_to_colname(meta_keys[f.source])} {op} toString(%({f_k})s)", + f.value, is_not=is_not, value_key=f_k)) + ss_constraints.append( + _multiple_conditions( + f"ms.{metadata.index_to_colname(meta_keys[f.source])} {op} toString(%({f_k})s)", + f.value, is_not=is_not, value_key=f_k)) + elif filter_type in [schemas.FilterType.user_id, schemas.FilterType.user_id_ios]: + if is_any: + extra_constraints.append('isNotNull(s.user_id)') + ss_constraints.append('isNotNull(ms.user_id)') + elif is_undefined: + extra_constraints.append('isNull(s.user_id)') + ss_constraints.append('isNull(ms.user_id)') + else: + extra_constraints.append( + _multiple_conditions(f"s.user_id {op} toString(%({f_k})s)", f.value, is_not=is_not, + value_key=f_k)) + ss_constraints.append( + _multiple_conditions(f"ms.user_id {op} toString(%({f_k})s)", f.value, is_not=is_not, + value_key=f_k)) + elif filter_type in [schemas.FilterType.user_anonymous_id, + schemas.FilterType.user_anonymous_id_ios]: + if is_any: + extra_constraints.append('isNotNull(s.user_anonymous_id)') + ss_constraints.append('isNotNull(ms.user_anonymous_id)') + elif is_undefined: + extra_constraints.append('isNull(s.user_anonymous_id)') + ss_constraints.append('isNull(ms.user_anonymous_id)') + else: + extra_constraints.append( + _multiple_conditions(f"s.user_anonymous_id {op} toString(%({f_k})s)", f.value, is_not=is_not, + value_key=f_k)) + ss_constraints.append( + _multiple_conditions(f"ms.user_anonymous_id {op} toString(%({f_k})s)", f.value, is_not=is_not, + value_key=f_k)) + elif filter_type in [schemas.FilterType.rev_id, schemas.FilterType.rev_id_ios]: + if is_any: + extra_constraints.append('isNotNull(s.rev_id)') + ss_constraints.append('isNotNull(ms.rev_id)') + elif is_undefined: + extra_constraints.append('isNull(s.rev_id)') + ss_constraints.append('isNull(ms.rev_id)') + else: + extra_constraints.append( + _multiple_conditions(f"s.rev_id {op} toString(%({f_k})s)", f.value, is_not=is_not, + value_key=f_k)) + ss_constraints.append( + _multiple_conditions(f"ms.rev_id {op} toString(%({f_k})s)", f.value, is_not=is_not, + value_key=f_k)) + elif filter_type == schemas.FilterType.platform: + # op = __get_sql_operator(f.operator) + extra_constraints.append( + _multiple_conditions(f"s.user_device_type {op} %({f_k})s", f.value, is_not=is_not, + value_key=f_k)) + ss_constraints.append( + _multiple_conditions(f"ms.user_device_type {op} %({f_k})s", f.value, is_not=is_not, + value_key=f_k)) + elif filter_type == schemas.FilterType.issue: + if is_any: + extra_constraints.append("notEmpty(s.issue_types)") + ss_constraints.append("notEmpty(ms.issue_types)") + else: + extra_constraints.append(f"hasAny(s.issue_types,%({f_k})s)") + # _multiple_conditions(f"%({f_k})s {op} ANY (s.issue_types)", f.value, is_not=is_not, + # value_key=f_k)) + ss_constraints.append(f"hasAny(ms.issue_types,%({f_k})s)") + # _multiple_conditions(f"%({f_k})s {op} ANY (ms.issue_types)", f.value, is_not=is_not, + # value_key=f_k)) + if is_not: + extra_constraints[-1] = f"not({extra_constraints[-1]})" + ss_constraints[-1] = f"not({ss_constraints[-1]})" + elif filter_type == schemas.FilterType.events_count: + extra_constraints.append( + _multiple_conditions(f"s.events_count {op} %({f_k})s", f.value, is_not=is_not, + value_key=f_k)) + ss_constraints.append( + _multiple_conditions(f"ms.events_count {op} %({f_k})s", f.value, is_not=is_not, + value_key=f_k)) + else: + continue + include_in_events = True + + if include_in_events: + events_conditions_where.append(f"""main.session_id IN (SELECT s.session_id + FROM {MAIN_SESSIONS_TABLE} AS s + WHERE {" AND ".join(extra_constraints)})""") + # --------------------------------------------------------------------------- + events_extra_join = "" + if len(data.events) > 0: + valid_events_count = 0 + for event in data.events: + is_any = _isAny_opreator(event.operator) + if not isinstance(event.value, list): + event.value = [event.value] + if __is_valid_event(is_any=is_any, event=event): + valid_events_count += 1 + events_query_from = [] + events_conditions = [] + events_conditions_not = [] + event_index = 0 + or_events = data.events_order == schemas.SearchEventOrder._or + # events_joiner = " UNION " if or_events else " INNER JOIN LATERAL " + for i, event in enumerate(data.events): + event_type = event.type + is_any = _isAny_opreator(event.operator) + if not isinstance(event.value, list): + event.value = [event.value] + if not __is_valid_event(is_any=is_any, event=event): + continue + op = __get_sql_operator(event.operator) + is_not = False + if __is_negation_operator(event.operator): + is_not = True + op = __reverse_sql_operator(op) + # if event_index == 0 or or_events: + # event_from = f"%s INNER JOIN {MAIN_SESSIONS_TABLE} AS ms USING (session_id)" + event_from = "%s" + event_where = ["main.project_id = %(projectId)s", + "main.datetime >= toDateTime(%(startDate)s/1000)", + "main.datetime <= toDateTime(%(endDate)s/1000)"] + # if favorite_only and not errors_only: + # event_from += f"INNER JOIN {exp_ch_helper.get_user_favorite_sessions_table()} AS fs USING(session_id)" + # event_where.append("fs.user_id = %(userId)s") + # else: + # event_from = "%s" + # event_where = ["main.datetime >= toDateTime(%(startDate)s/1000)", + # "main.datetime <= toDateTime(%(endDate)s/1000)", + # "main.session_id=event_0.session_id"] + # if data.events_order == schemas.SearchEventOrder._then: + # event_where.append(f"event_{event_index - 1}.datetime <= main.datetime") + e_k = f"e_value{i}" + s_k = e_k + "_source" + if event.type != schemas.PerformanceEventType.time_between_events: + event.value = helper.values_for_operator(value=event.value, op=event.operator) + full_args = {**full_args, + **_multiple_values(event.value, value_key=e_k), + **_multiple_values(event.source, value_key=s_k)} + + if event_type == events.event_type.CLICK.ui_type: + event_from = event_from % f"{MAIN_EVENTS_TABLE} AS main " + _column = events.event_type.CLICK.column + event_where.append(f"main.event_type='{__get_event_type(event_type)}'") + events_conditions.append({"type": event_where[-1]}) + if not is_any: + if is_not: + event_where.append(_multiple_conditions(f"sub.{_column} {op} %({e_k})s", event.value, + value_key=e_k)) + events_conditions_not.append({"type": f"sub.event_type='{__get_event_type(event_type)}'"}) + events_conditions_not[-1]["condition"] = event_where[-1] + else: + event_where.append(_multiple_conditions(f"main.{_column} {op} %({e_k})s", event.value, + value_key=e_k)) + events_conditions[-1]["condition"] = event_where[-1] + + elif event_type == events.event_type.INPUT.ui_type: + event_from = event_from % f"{MAIN_EVENTS_TABLE} AS main " + _column = events.event_type.INPUT.column + event_where.append(f"main.event_type='{__get_event_type(event_type)}'") + events_conditions.append({"type": event_where[-1]}) + if not is_any: + if is_not: + event_where.append(_multiple_conditions(f"sub.{_column} {op} %({e_k})s", event.value, + value_key=e_k)) + events_conditions_not.append({"type": f"sub.event_type='{__get_event_type(event_type)}'"}) + events_conditions_not[-1]["condition"] = event_where[-1] + else: + event_where.append(_multiple_conditions(f"main.{_column} {op} %({e_k})s", event.value, + value_key=e_k)) + events_conditions[-1]["condition"] = event_where[-1] + if event.source is not None and len(event.source) > 0: + event_where.append(_multiple_conditions(f"main.value ILIKE %(custom{i})s", event.source, + value_key=f"custom{i}")) + full_args = {**full_args, **_multiple_values(event.source, value_key=f"custom{i}")} + + elif event_type == events.event_type.LOCATION.ui_type: + event_from = event_from % f"{MAIN_EVENTS_TABLE} AS main " + _column = 'url_path' + event_where.append(f"main.event_type='{__get_event_type(event_type)}'") + events_conditions.append({"type": event_where[-1]}) + if not is_any: + if is_not: + event_where.append(_multiple_conditions(f"sub.{_column} {op} %({e_k})s", event.value, + value_key=e_k)) + events_conditions_not.append({"type": f"sub.event_type='{__get_event_type(event_type)}'"}) + events_conditions_not[-1]["condition"] = event_where[-1] + else: + event_where.append(_multiple_conditions(f"main.{_column} {op} %({e_k})s", + event.value, value_key=e_k)) + events_conditions[-1]["condition"] = event_where[-1] + elif event_type == events.event_type.CUSTOM.ui_type: + event_from = event_from % f"{MAIN_EVENTS_TABLE} AS main " + _column = events.event_type.CUSTOM.column + event_where.append(f"main.event_type='{__get_event_type(event_type)}'") + events_conditions.append({"type": event_where[-1]}) + if not is_any: + if is_not: + event_where.append(_multiple_conditions(f"sub.{_column} {op} %({e_k})s", event.value, + value_key=e_k)) + events_conditions_not.append({"type": f"sub.event_type='{__get_event_type(event_type)}'"}) + events_conditions_not[-1]["condition"] = event_where[-1] + else: + event_where.append(_multiple_conditions(f"main.{_column} {op} %({e_k})s", event.value, + value_key=e_k)) + events_conditions[-1]["condition"] = event_where[-1] + elif event_type == events.event_type.REQUEST.ui_type: + event_from = event_from % f"{MAIN_EVENTS_TABLE} AS main " + _column = 'url_path' + event_where.append(f"main.event_type='{__get_event_type(event_type)}'") + events_conditions.append({"type": event_where[-1]}) + if not is_any: + if is_not: + event_where.append(_multiple_conditions(f"sub.{_column} {op} %({e_k})s", event.value, + value_key=e_k)) + events_conditions_not.append({"type": f"sub.event_type='{__get_event_type(event_type)}'"}) + events_conditions_not[-1]["condition"] = event_where[-1] + else: + event_where.append(_multiple_conditions(f"main.{_column} {op} %({e_k})s", event.value, + value_key=e_k)) + events_conditions[-1]["condition"] = event_where[-1] + # elif event_type == events.event_type.GRAPHQL.ui_type: + # event_from = event_from % f"{MAIN_EVENTS_TABLE} AS main" + # event_where.append(f"main.event_type='GRAPHQL'") + # events_conditions.append({"type": event_where[-1]}) + # if not is_any: + # event_where.append( + # _multiple_conditions(f"main.{events.event_type.GRAPHQL.column} {op} %({e_k})s", event.value, + # value_key=e_k)) + # events_conditions[-1]["condition"] = event_where[-1] + elif event_type == events.event_type.STATEACTION.ui_type: + event_from = event_from % f"{MAIN_EVENTS_TABLE} AS main " + _column = events.event_type.STATEACTION.column + event_where.append(f"main.event_type='{__get_event_type(event_type)}'") + events_conditions.append({"type": event_where[-1]}) + if not is_any: + if is_not: + event_where.append(_multiple_conditions(f"sub.{_column} {op} %({e_k})s", event.value, + value_key=e_k)) + events_conditions_not.append({"type": f"sub.event_type='{__get_event_type(event_type)}'"}) + events_conditions_not[-1]["condition"] = event_where[-1] + else: + event_where.append(_multiple_conditions(f"main.{_column} {op} %({e_k})s", + event.value, value_key=e_k)) + events_conditions[-1]["condition"] = event_where[-1] + # TODO: isNot for ERROR + elif event_type == events.event_type.ERROR.ui_type: + event_from = event_from % f"{MAIN_EVENTS_TABLE} AS main" + events_extra_join = f"SELECT * FROM {MAIN_EVENTS_TABLE} AS main1 WHERE main1.project_id=%(project_id)s" + event_where.append(f"main.event_type='{__get_event_type(event_type)}'") + events_conditions.append({"type": event_where[-1]}) + event.source = tuple(event.source) + events_conditions[-1]["condition"] = [] + if not is_any and event.value not in [None, "*", ""]: + event_where.append( + _multiple_conditions(f"(main1.message {op} %({e_k})s OR main1.name {op} %({e_k})s)", + event.value, value_key=e_k)) + events_conditions[-1]["condition"].append(event_where[-1]) + events_extra_join += f" AND {event_where[-1]}" + if len(event.source) > 0 and event.source[0] not in [None, "*", ""]: + event_where.append(_multiple_conditions(f"main1.source = %({s_k})s", event.source, value_key=s_k)) + events_conditions[-1]["condition"].append(event_where[-1]) + events_extra_join += f" AND {event_where[-1]}" + + events_conditions[-1]["condition"] = " AND ".join(events_conditions[-1]["condition"]) + + elif event_type == schemas.PerformanceEventType.fetch_failed: + event_from = event_from % f"{MAIN_EVENTS_TABLE} AS main " + _column = 'url_path' + event_where.append(f"main.event_type='{__get_event_type(event_type)}'") + events_conditions.append({"type": event_where[-1]}) + events_conditions[-1]["condition"] = [] + if not is_any: + if is_not: + event_where.append(_multiple_conditions(f"sub.{_column} {op} %({e_k})s", event.value, + value_key=e_k)) + events_conditions_not.append({"type": f"sub.event_type='{__get_event_type(event_type)}'"}) + events_conditions_not[-1]["condition"] = event_where[-1] + else: + event_where.append(_multiple_conditions(f"main.{_column} {op} %({e_k})s", + event.value, value_key=e_k)) + events_conditions[-1]["condition"].append(event_where[-1]) + col = performance_event.get_col(event_type) + colname = col["column"] + event_where.append(f"main.{colname} = 0") + events_conditions[-1]["condition"].append(event_where[-1]) + events_conditions[-1]["condition"] = " AND ".join(events_conditions[-1]["condition"]) + + # elif event_type == schemas.PerformanceEventType.fetch_duration: + # event_from = event_from % f"{events.event_type.REQUEST.table} AS main " + # if not is_any: + # event_where.append( + # _multiple_conditions(f"main.url_path {op} %({e_k})s", + # event.value, value_key=e_k)) + # col = performance_event.get_col(event_type) + # colname = col["column"] + # tname = "main" + # e_k += "_custom" + # full_args = {**full_args, **_multiple_values(event.source, value_key=e_k)} + # event_where.append(f"{tname}.{colname} IS NOT NULL AND {tname}.{colname}>0 AND " + + # _multiple_conditions(f"{tname}.{colname} {event.sourceOperator} %({e_k})s", + # event.source, value_key=e_k)) + # TODO: isNot for PerformanceEvent + elif event_type in [schemas.PerformanceEventType.location_dom_complete, + schemas.PerformanceEventType.location_largest_contentful_paint_time, + schemas.PerformanceEventType.location_ttfb]: + event_from = event_from % f"{MAIN_EVENTS_TABLE} AS main " + event_where.append(f"main.event_type='{__get_event_type(event_type)}'") + events_conditions.append({"type": event_where[-1]}) + events_conditions[-1]["condition"] = [] + col = performance_event.get_col(event_type) + colname = col["column"] + tname = "main" + if not is_any: + event_where.append( + _multiple_conditions(f"main.url_path {op} %({e_k})s", + event.value, value_key=e_k)) + events_conditions[-1]["condition"].append(event_where[-1]) + e_k += "_custom" + full_args = {**full_args, **_multiple_values(event.source, value_key=e_k)} + + event_where.append(f"isNotNull({tname}.{colname}) AND {tname}.{colname}>0 AND " + + _multiple_conditions(f"{tname}.{colname} {event.sourceOperator} %({e_k})s", + event.source, value_key=e_k)) + events_conditions[-1]["condition"].append(event_where[-1]) + events_conditions[-1]["condition"] = " AND ".join(events_conditions[-1]["condition"]) + # TODO: isNot for PerformanceEvent + elif event_type in [schemas.PerformanceEventType.location_avg_cpu_load, + schemas.PerformanceEventType.location_avg_memory_usage]: + event_from = event_from % f"{MAIN_EVENTS_TABLE} AS main " + event_where.append(f"main.event_type='{__get_event_type(event_type)}'") + events_conditions.append({"type": event_where[-1]}) + events_conditions[-1]["condition"] = [] + col = performance_event.get_col(event_type) + colname = col["column"] + tname = "main" + if not is_any: + event_where.append( + _multiple_conditions(f"main.url_path {op} %({e_k})s", + event.value, value_key=e_k)) + events_conditions[-1]["condition"].append(event_where[-1]) + e_k += "_custom" + full_args = {**full_args, **_multiple_values(event.source, value_key=e_k)} + + event_where.append(f"isNotNull({tname}.{colname}) AND {tname}.{colname}>0 AND " + + _multiple_conditions(f"{tname}.{colname} {event.sourceOperator} %({e_k})s", + event.source, value_key=e_k)) + events_conditions[-1]["condition"].append(event_where[-1]) + events_conditions[-1]["condition"] = " AND ".join(events_conditions[-1]["condition"]) + # TODO: no isNot for TimeBetweenEvents + elif event_type == schemas.PerformanceEventType.time_between_events: + event_from = event_from % f"{MAIN_EVENTS_TABLE} AS main " + # event_from = event_from % f"{getattr(events.event_type, event.value[0].type).table} AS main INNER JOIN {getattr(events.event_type, event.value[1].type).table} AS main2 USING(session_id) " + event_where.append(f"main.event_type='{__get_event_type(event.value[0].type)}'") + events_conditions.append({"type": event_where[-1]}) + event_where.append(f"main.event_type='{__get_event_type(event.value[0].type)}'") + events_conditions.append({"type": event_where[-1]}) + + if not isinstance(event.value[0].value, list): + event.value[0].value = [event.value[0].value] + if not isinstance(event.value[1].value, list): + event.value[1].value = [event.value[1].value] + event.value[0].value = helper.values_for_operator(value=event.value[0].value, + op=event.value[0].operator) + event.value[1].value = helper.values_for_operator(value=event.value[1].value, + op=event.value[0].operator) + e_k1 = e_k + "_e1" + e_k2 = e_k + "_e2" + full_args = {**full_args, + **_multiple_values(event.value[0].value, value_key=e_k1), + **_multiple_values(event.value[1].value, value_key=e_k2)} + s_op = __get_sql_operator(event.value[0].operator) + # event_where += ["main2.timestamp >= %(startDate)s", "main2.timestamp <= %(endDate)s"] + # if event_index > 0 and not or_events: + # event_where.append("main2.session_id=event_0.session_id") + is_any = _isAny_opreator(event.value[0].operator) + if not is_any: + event_where.append( + _multiple_conditions( + f"main.{getattr(events.event_type, event.value[0].type).column} {s_op} %({e_k1})s", + event.value[0].value, value_key=e_k1)) + events_conditions[-2]["condition"] = event_where[-1] + s_op = __get_sql_operator(event.value[1].operator) + is_any = _isAny_opreator(event.value[1].operator) + if not is_any: + event_where.append( + _multiple_conditions( + f"main.{getattr(events.event_type, event.value[1].type).column} {s_op} %({e_k2})s", + event.value[1].value, value_key=e_k2)) + events_conditions[-1]["condition"] = event_where[-1] + + e_k += "_custom" + full_args = {**full_args, **_multiple_values(event.source, value_key=e_k)} + # event_where.append( + # _multiple_conditions(f"main2.timestamp - main.timestamp {event.sourceOperator} %({e_k})s", + # event.source, value_key=e_k)) + # events_conditions[-2]["time"] = f"(?t{event.sourceOperator} %({e_k})s)" + events_conditions[-2]["time"] = _multiple_conditions(f"?t{event.sourceOperator}%({e_k})s", event.source, + value_key=e_k) + event_index += 1 + # TODO: no isNot for RequestDetails + elif event_type == schemas.EventType.request_details: + event_from = event_from % f"{MAIN_EVENTS_TABLE} AS main " + event_where.append(f"main.event_type='{__get_event_type(event_type)}'") + events_conditions.append({"type": event_where[-1]}) + apply = False + events_conditions[-1]["condition"] = [] + for j, f in enumerate(event.filters): + is_any = _isAny_opreator(f.operator) + if is_any or len(f.value) == 0: + continue + f.value = helper.values_for_operator(value=f.value, op=f.operator) + op = __get_sql_operator(f.operator) + e_k_f = e_k + f"_fetch{j}" + full_args = {**full_args, **_multiple_values(f.value, value_key=e_k_f)} + if f.type == schemas.FetchFilterType._url: + event_where.append( + _multiple_conditions(f"main.url_path {op} %({e_k_f})s", f.value, + value_key=e_k_f)) + events_conditions[-1]["condition"].append(event_where[-1]) + apply = True + elif f.type == schemas.FetchFilterType._status_code: + event_where.append( + _multiple_conditions(f"main.status {f.operator} %({e_k_f})s", f.value, + value_key=e_k_f)) + events_conditions[-1]["condition"].append(event_where[-1]) + apply = True + elif f.type == schemas.FetchFilterType._method: + event_where.append( + _multiple_conditions(f"main.method {op} %({e_k_f})s", f.value, value_key=e_k_f)) + events_conditions[-1]["condition"].append(event_where[-1]) + apply = True + elif f.type == schemas.FetchFilterType._duration: + event_where.append( + _multiple_conditions(f"main.duration {f.operator} %({e_k_f})s", f.value, value_key=e_k_f)) + events_conditions[-1]["condition"].append(event_where[-1]) + apply = True + elif f.type == schemas.FetchFilterType._request_body: + event_where.append( + _multiple_conditions(f"main.request_body {op} %({e_k_f})s", f.value, value_key=e_k_f)) + events_conditions[-1]["condition"].append(event_where[-1]) + apply = True + elif f.type == schemas.FetchFilterType._response_body: + event_where.append( + _multiple_conditions(f"main.response_body {op} %({e_k_f})s", f.value, value_key=e_k_f)) + events_conditions[-1]["condition"].append(event_where[-1]) + apply = True + else: + print(f"undefined FETCH filter: {f.type}") + if not apply: + continue + else: + events_conditions[-1]["condition"] = " AND ".join(events_conditions[-1]["condition"]) + # TODO: no isNot for GraphQL + elif event_type == schemas.EventType.graphql: + event_from = event_from % f"{MAIN_EVENTS_TABLE} AS main " + event_where.append(f"main.event_type='GRAPHQL'") + events_conditions.append({"type": event_where[-1]}) + events_conditions[-1]["condition"] = [] + for j, f in enumerate(event.filters): + is_any = _isAny_opreator(f.operator) + if is_any or len(f.value) == 0: + continue + f.value = helper.values_for_operator(value=f.value, op=f.operator) + op = __get_sql_operator(f.operator) + e_k_f = e_k + f"_graphql{j}" + full_args = {**full_args, **_multiple_values(f.value, value_key=e_k_f)} + if f.type == schemas.GraphqlFilterType._name: + event_where.append( + _multiple_conditions(f"main.{events.event_type.GRAPHQL.column} {op} %({e_k_f})s", f.value, + value_key=e_k_f)) + events_conditions[-1]["condition"].append(event_where[-1]) + elif f.type == schemas.GraphqlFilterType._method: + event_where.append( + _multiple_conditions(f"main.method {op} %({e_k_f})s", f.value, value_key=e_k_f)) + events_conditions[-1]["condition"].append(event_where[-1]) + elif f.type == schemas.GraphqlFilterType._request_body: + event_where.append( + _multiple_conditions(f"main.request_body {op} %({e_k_f})s", f.value, value_key=e_k_f)) + events_conditions[-1]["condition"].append(event_where[-1]) + elif f.type == schemas.GraphqlFilterType._response_body: + event_where.append( + _multiple_conditions(f"main.response_body {op} %({e_k_f})s", f.value, value_key=e_k_f)) + events_conditions[-1]["condition"].append(event_where[-1]) + else: + print(f"undefined GRAPHQL filter: {f.type}") + events_conditions[-1]["condition"] = " AND ".join(events_conditions[-1]["condition"]) + else: + continue + if event_index == 0 or or_events: + event_where += ss_constraints + if is_not: + if event_index == 0 or or_events: + events_query_from.append(f"""\ + (SELECT + session_id, + 0 AS timestamp + FROM sessions + WHERE EXISTS(SELECT session_id + FROM {event_from} + WHERE {" AND ".join(event_where)} + AND sessions.session_id=ms.session_id) IS FALSE + AND project_id = %(projectId)s + AND start_ts >= %(startDate)s + AND start_ts <= %(endDate)s + AND duration IS NOT NULL + ) {"" if or_events else (f"AS event_{event_index}" + ("ON(TRUE)" if event_index > 0 else ""))}\ + """) + else: + events_query_from.append(f"""\ + (SELECT + event_0.session_id, + event_{event_index - 1}.timestamp AS timestamp + WHERE EXISTS(SELECT session_id FROM {event_from} WHERE {" AND ".join(event_where)}) IS FALSE + ) AS event_{event_index} {"ON(TRUE)" if event_index > 0 else ""}\ + """) + else: + if data.events_order == schemas.SearchEventOrder._then: + pass + else: + events_query_from.append(f"""\ + (SELECT main.session_id, {"MIN" if event_index < (valid_events_count - 1) else "MAX"}(main.datetime) AS datetime + FROM {event_from} + WHERE {" AND ".join(event_where)} + GROUP BY session_id + ) {"" if or_events else (f"AS event_{event_index} " + ("ON(TRUE)" if event_index > 0 else ""))}\ + """) + event_index += 1 + + if event_index < 2: + data.events_order = schemas.SearchEventOrder._or + if len(events_extra_join) > 0: + if event_index < 2: + events_extra_join = f"INNER JOIN ({events_extra_join}) AS main1 USING(error_id)" + else: + events_extra_join = f"LEFT JOIN ({events_extra_join}) AS main1 USING(error_id)" + if favorite_only and user_id is not None: + events_conditions_where.append(f"""main.session_id IN (SELECT session_id + FROM {exp_ch_helper.get_user_favorite_sessions_table()} AS user_favorite_sessions + WHERE user_id = %(userId)s)""") + + if data.events_order in [schemas.SearchEventOrder._then, schemas.SearchEventOrder._and]: + sequence_pattern = [f'(?{i + 1}){c.get("time", "")}' for i, c in enumerate(events_conditions)] + sub_join = "" + type_conditions = [] + value_conditions = [] + _value_conditions = [] + sequence_conditions = [] + for c in events_conditions: + if c['type'] not in type_conditions: + type_conditions.append(c['type']) + + if c.get('condition') \ + and c['condition'] not in value_conditions \ + and c['condition'] % full_args not in _value_conditions: + value_conditions.append(c['condition']) + _value_conditions.append(c['condition'] % full_args) + + sequence_conditions.append(c['type']) + if c.get('condition'): + sequence_conditions[-1] += " AND " + c["condition"] + + del _value_conditions + if len(events_conditions) > 0: + events_conditions_where.append(f"({' OR '.join([c for c in type_conditions])})") + del type_conditions + if len(value_conditions) > 0: + events_conditions_where.append(f"({' OR '.join([c for c in value_conditions])})") + del value_conditions + if len(events_conditions_not) > 0: + _value_conditions_not = [] + value_conditions_not = [] + for c in events_conditions_not: + p = f"{c['type']} AND {c['condition']}" + _p = p % full_args + if _p not in _value_conditions_not: + _value_conditions_not.append(_p) + value_conditions_not.append(p) + value_conditions_not = [f"sub.{c}" for c in __events_where_basic] + value_conditions_not + sub_join = f"""LEFT ANTI JOIN ( SELECT DISTINCT sub.session_id + FROM {MAIN_EVENTS_TABLE} AS sub + WHERE {' AND '.join([c for c in value_conditions_not])}) AS sub USING(session_id)""" + del _value_conditions_not + del value_conditions_not + + if data.events_order == schemas.SearchEventOrder._then: + having = f"""HAVING sequenceMatch('{''.join(sequence_pattern)}')(main.datetime,{','.join(sequence_conditions)})""" + else: + having = f"""HAVING {" AND ".join([f"countIf({c})>0" for c in list(set(sequence_conditions))])}""" + + events_query_part = f"""SELECT main.session_id, + MIN(main.datetime) AS first_event_ts, + MAX(main.datetime) AS last_event_ts + FROM {MAIN_EVENTS_TABLE} AS main {events_extra_join} + {sub_join} + WHERE {" AND ".join(events_conditions_where)} + GROUP BY session_id + {having}""" + else: + type_conditions = [] + sequence_conditions = [] + has_values = False + for c in events_conditions: + if c['type'] not in type_conditions: + type_conditions.append(c['type']) + + sequence_conditions.append(c['type']) + if c.get('condition'): + has_values = True + sequence_conditions[-1] += " AND " + c["condition"] + if len(events_conditions) > 0: + events_conditions_where.append(f"({' OR '.join([c for c in type_conditions])})") + + if len(events_conditions_not) > 0: + has_values = True + _value_conditions_not = [] + value_conditions_not = [] + for c in events_conditions_not: + p = f"{c['type']} AND not({c['condition']})".replace("sub.", "main.") + _p = p % full_args + if _p not in _value_conditions_not: + _value_conditions_not.append(_p) + value_conditions_not.append(p) + del _value_conditions_not + sequence_conditions += value_conditions_not + + if has_values: + events_conditions = [c for c in list(set(sequence_conditions))] + events_conditions_where.append(f"({' OR '.join(events_conditions)})") + events_query_part = f"""SELECT main.session_id, + MIN(main.datetime) AS first_event_ts, + MAX(main.datetime) AS last_event_ts + FROM {MAIN_EVENTS_TABLE} AS main {events_extra_join} + WHERE {" AND ".join(events_conditions_where)} + GROUP BY session_id""" + else: + data.events = [] + # --------------------------------------------------------------------------- + if data.startDate is not None: + extra_constraints.append("s.datetime >= toDateTime(%(startDate)s/1000)") + if data.endDate is not None: + extra_constraints.append("s.datetime <= toDateTime(%(endDate)s/1000)") + # if data.platform is not None: + # if data.platform == schemas.PlatformType.mobile: + # extra_constraints.append(b"s.user_os in ('Android','BlackBerry OS','iOS','Tizen','Windows Phone')") + # elif data.platform == schemas.PlatformType.desktop: + # extra_constraints.append( + # b"s.user_os in ('Chrome OS','Fedora','Firefox OS','Linux','Mac OS X','Ubuntu','Windows')") + + # if errors_only: + # extra_from += f" INNER JOIN {events.event_type.ERROR.table} AS er USING (session_id) INNER JOIN public.errors AS ser USING (error_id)" + # extra_constraints.append("ser.source = 'js_exception'") + # extra_constraints.append("ser.project_id = %(project_id)s") + # if error_status != schemas.ErrorStatus.all: + # extra_constraints.append("ser.status = %(error_status)s") + # full_args["error_status"] = error_status + # if favorite_only: + # extra_from += " INNER JOIN final.user_favorite_errors AS ufe USING (error_id)" + # extra_constraints.append("ufe.user_id = %(userId)s") + + # if favorite_only and not errors_only and user_id is not None: + # extra_from += f"""INNER JOIN (SELECT session_id + # FROM {exp_ch_helper.get_user_favorite_sessions_table()} + # WHERE user_id=%(userId)s) AS favorite_sessions USING (session_id)""" + # elif not favorite_only and not errors_only and user_id is not None: + # extra_from += f"""LEFT JOIN (SELECT session_id + # FROM {exp_ch_helper.get_user_favorite_sessions_table()} AS user_favorite_sessions + # WHERE user_id = %(userId)s) AS favorite_sessions + # ON (s.session_id=favorite_sessions.session_id)""" + extra_join = "" + if issue is not None: + extra_join = """ + INNER JOIN LATERAL(SELECT TRUE FROM events_common.issues INNER JOIN public.issues AS p_issues USING (issue_id) + WHERE issues.session_id=f.session_id + AND p_issues.type=%(issue_type)s + AND p_issues.context_string=%(issue_contextString)s + AND timestamp >= f.first_event_ts + AND timestamp <= f.last_event_ts) AS issues ON(TRUE) + """ + full_args["issue_contextString"] = issue["contextString"] + full_args["issue_type"] = issue["type"] + + if extra_event: + extra_event = f"INNER JOIN ({extra_event}) AS extra_event USING(session_id)" + # extra_join = f"""INNER JOIN {extra_event} AS ev USING(session_id)""" + # extra_constraints.append("ev.timestamp>=%(startDate)s") + # extra_constraints.append("ev.timestamp<=%(endDate)s") + else: + extra_event = "" + if errors_only: + query_part = f"""{f"({events_query_part}) AS f" if len(events_query_part) > 0 else ""}""" + else: + if len(events_query_part) > 0: + extra_join += f"""INNER JOIN (SELECT * + FROM {MAIN_SESSIONS_TABLE} AS s {extra_event} + WHERE {" AND ".join(extra_constraints)}) AS s ON(s.session_id=f.session_id)""" + else: + extra_join += f"""(SELECT * + FROM {MAIN_SESSIONS_TABLE} AS s {extra_event} + WHERE {" AND ".join(extra_constraints)} + ORDER BY _timestamp DESC + LIMIT 1 BY session_id) AS s""" + query_part = f"""\ + FROM {f"({events_query_part}) AS f" if len(events_query_part) > 0 else ""} + {extra_join} + {extra_from} + """ + return full_args, query_part + + +def search_by_metadata(tenant_id, user_id, m_key, m_value, project_id=None): + if project_id is None: + all_projects = projects.get_projects(tenant_id=tenant_id, recording_state=False) + else: + all_projects = [ + projects.get_project(tenant_id=tenant_id, project_id=int(project_id), include_last_session=False, + include_gdpr=False)] + + all_projects = {int(p["projectId"]): p["name"] for p in all_projects} + project_ids = list(all_projects.keys()) + + available_keys = metadata.get_keys_by_projects(project_ids) + for i in available_keys: + available_keys[i]["user_id"] = schemas.FilterType.user_id + available_keys[i]["user_anonymous_id"] = schemas.FilterType.user_anonymous_id + results = {} + for i in project_ids: + if m_key not in available_keys[i].values(): + available_keys.pop(i) + results[i] = {"total": 0, "sessions": [], "missingMetadata": True} + project_ids = list(available_keys.keys()) + if len(project_ids) > 0: + with pg_client.PostgresClient() as cur: + sub_queries = [] + for i in project_ids: + col_name = list(available_keys[i].keys())[list(available_keys[i].values()).index(m_key)] + sub_queries.append(cur.mogrify( + f"(SELECT COALESCE(COUNT(s.*)) AS count FROM public.sessions AS s WHERE s.project_id = %(id)s AND s.{col_name} = %(value)s) AS \"{i}\"", + {"id": i, "value": m_value}).decode('UTF-8')) + query = f"""SELECT {", ".join(sub_queries)};""" + cur.execute(query=query) + + rows = cur.fetchone() + + sub_queries = [] + for i in rows.keys(): + results[i] = {"total": rows[i], "sessions": [], "missingMetadata": False, "name": all_projects[int(i)]} + if rows[i] > 0: + col_name = list(available_keys[int(i)].keys())[list(available_keys[int(i)].values()).index(m_key)] + sub_queries.append( + cur.mogrify( + f"""( + SELECT * + FROM ( + SELECT DISTINCT ON(favorite_sessions.session_id, s.session_id) {SESSION_PROJECTION_COLS} + FROM public.sessions AS s LEFT JOIN (SELECT session_id + FROM public.user_favorite_sessions + WHERE user_favorite_sessions.user_id = %(userId)s + ) AS favorite_sessions USING (session_id) + WHERE s.project_id = %(id)s AND s.duration IS NOT NULL AND s.{col_name} = %(value)s + ) AS full_sessions + ORDER BY favorite DESC, issue_score DESC + LIMIT 10 + )""", + {"id": i, "value": m_value, "userId": user_id}).decode('UTF-8')) + if len(sub_queries) > 0: + cur.execute("\nUNION\n".join(sub_queries)) + rows = cur.fetchall() + for i in rows: + results[str(i["project_id"])]["sessions"].append(helper.dict_to_camel_case(i)) + return results + + +def search_by_issue(user_id, issue, project_id, start_date, end_date): + constraints = ["s.project_id = %(projectId)s", + "p_issues.context_string = %(issueContextString)s", + "p_issues.type = %(issueType)s"] + if start_date is not None: + constraints.append("start_ts >= %(startDate)s") + if end_date is not None: + constraints.append("start_ts <= %(endDate)s") + with pg_client.PostgresClient() as cur: + cur.execute( + cur.mogrify( + f"""SELECT DISTINCT ON(favorite_sessions.session_id, s.session_id) {SESSION_PROJECTION_COLS} + FROM public.sessions AS s + INNER JOIN events_common.issues USING (session_id) + INNER JOIN public.issues AS p_issues USING (issue_id) + LEFT JOIN (SELECT user_id, session_id + FROM public.user_favorite_sessions + WHERE user_id = %(userId)s) AS favorite_sessions + USING (session_id) + WHERE {" AND ".join(constraints)} + ORDER BY s.session_id DESC;""", + { + "issueContextString": issue["contextString"], + "issueType": issue["type"], "userId": user_id, + "projectId": project_id, + "startDate": start_date, + "endDate": end_date + })) + + rows = cur.fetchall() + return helper.list_to_camel_case(rows) + + +def get_user_sessions(project_id, user_id, start_date, end_date): + with pg_client.PostgresClient() as cur: + constraints = ["s.project_id = %(projectId)s", "s.user_id = %(userId)s"] + if start_date is not None: + constraints.append("s.start_ts >= %(startDate)s") + if end_date is not None: + constraints.append("s.start_ts <= %(endDate)s") + + query_part = f"""\ + FROM public.sessions AS s + WHERE {" AND ".join(constraints)}""" + + cur.execute(cur.mogrify(f"""\ + SELECT s.project_id, + s.session_id::text AS session_id, + s.user_uuid, + s.user_id, + s.user_os, + s.user_browser, + s.user_device, + s.user_country, + s.start_ts, + s.duration, + s.events_count, + s.pages_count, + s.errors_count + {query_part} + ORDER BY s.session_id + LIMIT 50;""", { + "projectId": project_id, + "userId": user_id, + "startDate": start_date, + "endDate": end_date + })) + + sessions = cur.fetchall() + return helper.list_to_camel_case(sessions) + + +def get_session_user(project_id, user_id): + with pg_client.PostgresClient() as cur: + query = cur.mogrify( + """\ + SELECT + user_id, + count(*) as session_count, + max(start_ts) as last_seen, + min(start_ts) as first_seen + FROM + "public".sessions + WHERE + project_id = %(project_id)s + AND user_id = %(userId)s + AND duration is not null + GROUP BY user_id; + """, + {"project_id": project_id, "userId": user_id} + ) + cur.execute(query=query) + data = cur.fetchone() + return helper.dict_to_camel_case(data) + + +def get_session_ids_by_user_ids(project_id, user_ids): + with pg_client.PostgresClient() as cur: + query = cur.mogrify( + """\ + SELECT session_id FROM public.sessions + WHERE + project_id = %(project_id)s AND user_id IN %(userId)s;""", + {"project_id": project_id, "userId": tuple(user_ids)} + ) + ids = cur.execute(query=query) + return ids + + +def delete_sessions_by_session_ids(session_ids): + with pg_client.PostgresClient(unlimited_query=True) as cur: + query = cur.mogrify( + """\ + DELETE FROM public.sessions + WHERE + session_id IN %(session_ids)s;""", + {"session_ids": tuple(session_ids)} + ) + cur.execute(query=query) + + return True + + +def delete_sessions_by_user_ids(project_id, user_ids): + with pg_client.PostgresClient(unlimited_query=True) as cur: + query = cur.mogrify( + """\ + DELETE FROM public.sessions + WHERE + project_id = %(project_id)s AND user_id IN %(userId)s;""", + {"project_id": project_id, "userId": tuple(user_ids)} + ) + cur.execute(query=query) + + return True + + +def count_all(): + with pg_client.PostgresClient(unlimited_query=True) as cur: + row = cur.execute(query="SELECT COUNT(session_id) AS count FROM public.sessions") + return row.get("count", 0) diff --git a/ee/api/chalicelib/core/sessions_favorite_viewed.py b/ee/api/chalicelib/core/sessions_favorite.py similarity index 71% rename from ee/api/chalicelib/core/sessions_favorite_viewed.py rename to ee/api/chalicelib/core/sessions_favorite.py index 896ba4a99..bcb79cfb7 100644 --- a/ee/api/chalicelib/core/sessions_favorite_viewed.py +++ b/ee/api/chalicelib/core/sessions_favorite.py @@ -1,18 +1,19 @@ -from chalicelib.core import sessions -from chalicelib.utils import pg_client, s3_extra from decouple import config +from chalicelib.core import sessions, sessions_favorite_exp +from chalicelib.utils import pg_client, s3_extra + def add_favorite_session(project_id, user_id, session_id): with pg_client.PostgresClient() as cur: cur.execute( cur.mogrify(f"""\ - INSERT INTO public.user_favorite_sessions - (user_id, session_id) - VALUES - (%(userId)s,%(sessionId)s);""", + INSERT INTO public.user_favorite_sessions(user_id, session_id) + VALUES (%(userId)s,%(sessionId)s);""", {"userId": user_id, "sessionId": session_id}) ) + + sessions_favorite_exp.add_favorite_session(project_id=project_id, user_id=user_id, session_id=session_id) return sessions.get_by_id2_pg(project_id=project_id, session_id=session_id, user_id=user_id, full_data=False, include_fav_viewed=True) @@ -22,28 +23,15 @@ def remove_favorite_session(project_id, user_id, session_id): cur.execute( cur.mogrify(f"""\ DELETE FROM public.user_favorite_sessions - WHERE - user_id = %(userId)s + WHERE user_id = %(userId)s AND session_id = %(sessionId)s;""", {"userId": user_id, "sessionId": session_id}) ) + sessions_favorite_exp.remove_favorite_session(project_id=project_id, user_id=user_id, session_id=session_id) return sessions.get_by_id2_pg(project_id=project_id, session_id=session_id, user_id=user_id, full_data=False, include_fav_viewed=True) -def add_viewed_session(project_id, user_id, session_id): - with pg_client.PostgresClient() as cur: - cur.execute( - cur.mogrify("""\ - INSERT INTO public.user_viewed_sessions - (user_id, session_id) - VALUES - (%(userId)s,%(sessionId)s) - ON CONFLICT DO NOTHING;""", - {"userId": user_id, "sessionId": session_id}) - ) - - def favorite_session(project_id, user_id, session_id): if favorite_session_exists(user_id=user_id, session_id=session_id): key = str(session_id) @@ -74,16 +62,11 @@ def favorite_session(project_id, user_id, session_id): return add_favorite_session(project_id=project_id, user_id=user_id, session_id=session_id) -def view_session(project_id, user_id, session_id): - return add_viewed_session(project_id=project_id, user_id=user_id, session_id=session_id) - - def favorite_session_exists(user_id, session_id): with pg_client.PostgresClient() as cur: cur.execute( cur.mogrify( - """SELECT - session_id + """SELECT session_id FROM public.user_favorite_sessions WHERE user_id = %(userId)s @@ -92,3 +75,18 @@ def favorite_session_exists(user_id, session_id): ) r = cur.fetchone() return r is not None + + +def get_start_end_timestamp(project_id, user_id): + with pg_client.PostgresClient() as cur: + cur.execute( + cur.mogrify( + """SELECT max(start_ts) AS max_start_ts, min(start_ts) AS min_start_ts + FROM public.user_favorite_sessions INNER JOIN sessions USING(session_id) + WHERE + user_favorite_sessions.user_id = %(userId)s + AND project_id = %(project_id)s;""", + {"userId": user_id, "project_id": project_id}) + ) + r = cur.fetchone() + return (0, 0) if r is None else (r["max_start_ts"], r["min_start_ts"]) diff --git a/ee/api/chalicelib/core/sessions_favorite_exp.py b/ee/api/chalicelib/core/sessions_favorite_exp.py new file mode 100644 index 000000000..6ee8654b0 --- /dev/null +++ b/ee/api/chalicelib/core/sessions_favorite_exp.py @@ -0,0 +1,24 @@ +import logging + +from decouple import config + +from chalicelib.utils import ch_client, exp_ch_helper + +logging.basicConfig(level=config("LOGLEVEL", default=logging.INFO)) + + +def add_favorite_session(project_id, user_id, session_id, sign=1): + try: + with ch_client.ClickHouseClient() as cur: + query = f"""INSERT INTO {exp_ch_helper.get_user_favorite_sessions_table()}(project_id,user_id, session_id, sign) + VALUES (%(project_id)s,%(userId)s,%(sessionId)s,%(sign)s);""" + params = {"userId": user_id, "sessionId": session_id, "project_id": project_id, "sign": sign} + cur.execute(query=query, params=params) + + except Exception as err: + logging.error("------- Exception while adding favorite session to CH") + logging.error(err) + + +def remove_favorite_session(project_id, user_id, session_id): + add_favorite_session(project_id=project_id, user_id=user_id, session_id=session_id, sign=-1) diff --git a/ee/api/chalicelib/core/sessions_viewed.py b/ee/api/chalicelib/core/sessions_viewed.py new file mode 100644 index 000000000..59bb55c75 --- /dev/null +++ b/ee/api/chalicelib/core/sessions_viewed.py @@ -0,0 +1,13 @@ +from chalicelib.core import sessions_viewed_exp +from chalicelib.utils import pg_client + + +def view_session(project_id, user_id, session_id): + with pg_client.PostgresClient() as cur: + cur.execute( + cur.mogrify("""INSERT INTO public.user_viewed_sessions (user_id, session_id) + VALUES (%(userId)s,%(sessionId)s) + ON CONFLICT DO NOTHING;""", + {"userId": user_id, "sessionId": session_id}) + ) + sessions_viewed_exp.view_session(project_id=project_id, user_id=user_id, session_id=session_id) diff --git a/ee/api/chalicelib/core/sessions_viewed_exp.py b/ee/api/chalicelib/core/sessions_viewed_exp.py new file mode 100644 index 000000000..3b26612cb --- /dev/null +++ b/ee/api/chalicelib/core/sessions_viewed_exp.py @@ -0,0 +1,17 @@ +from chalicelib.utils import ch_client, exp_ch_helper +import logging +from decouple import config + +logging.basicConfig(level=config("LOGLEVEL", default=logging.INFO)) + + +def view_session(project_id, user_id, session_id): + try: + with ch_client.ClickHouseClient() as cur: + query = f"""INSERT INTO {exp_ch_helper.get_user_viewed_sessions_table()}(project_id, user_id, session_id) + VALUES (%(project_id)s,%(userId)s,%(sessionId)s);""" + params = {"userId": user_id, "sessionId": session_id, "project_id": project_id} + cur.execute(query=query, params=params) + except Exception as err: + logging.error("------- Exception while adding viewed session to CH") + logging.error(err) diff --git a/ee/api/chalicelib/core/significance_exp.py b/ee/api/chalicelib/core/significance_exp.py new file mode 100644 index 000000000..1f845ec06 --- /dev/null +++ b/ee/api/chalicelib/core/significance_exp.py @@ -0,0 +1,638 @@ +__author__ = "AZNAUROV David" +__maintainer__ = "KRAIEM Taha Yassine" + +import schemas +from chalicelib.core import events, metadata +from chalicelib.core import sessions_legacy as sessions +from chalicelib.utils import dev + +""" +todo: remove LIMIT from the query +""" + +from typing import List +import math +import warnings +from collections import defaultdict + +from psycopg2.extras import RealDictRow +from chalicelib.utils import pg_client, helper + +SIGNIFICANCE_THRSH = 0.4 + +T_VALUES = {1: 12.706, 2: 4.303, 3: 3.182, 4: 2.776, 5: 2.571, 6: 2.447, 7: 2.365, 8: 2.306, 9: 2.262, 10: 2.228, + 11: 2.201, 12: 2.179, 13: 2.160, 14: 2.145, 15: 2.13, 16: 2.120, 17: 2.110, 18: 2.101, 19: 2.093, 20: 2.086, + 21: 2.080, 22: 2.074, 23: 2.069, 25: 2.064, 26: 2.060, 27: 2.056, 28: 2.052, 29: 2.045, 30: 2.042} + + +def get_stages_and_events(filter_d, project_id) -> List[RealDictRow]: + """ + Add minimal timestamp + :param filter_d: dict contains events&filters&... + :return: + """ + stages: [dict] = filter_d.get("events", []) + filters: [dict] = filter_d.get("filters", []) + filter_issues = filter_d.get("issueTypes") + if filter_issues is None or len(filter_issues) == 0: + filter_issues = [] + stage_constraints = ["main.timestamp <= %(endTimestamp)s"] + first_stage_extra_constraints = ["s.project_id=%(project_id)s", "s.start_ts >= %(startTimestamp)s", + "s.start_ts <= %(endTimestamp)s"] + filter_extra_from = [] + n_stages_query = [] + values = {} + if len(filters) > 0: + meta_keys = None + for i, f in enumerate(filters): + if not isinstance(f["value"], list): + f.value = [f["value"]] + if len(f["value"]) == 0 or f["value"] is None: + continue + f["value"] = helper.values_for_operator(value=f["value"], op=f["operator"]) + # filter_args = _multiple_values(f["value"]) + op = sessions.__get_sql_operator(f["operator"]) + + filter_type = f["type"] + # values[f_k] = sessions.__get_sql_value_multiple(f["value"]) + f_k = f"f_value{i}" + values = {**values, + **sessions._multiple_values(helper.values_for_operator(value=f["value"], op=f["operator"]), + value_key=f_k)} + if filter_type == schemas.FilterType.user_browser: + # op = sessions.__get_sql_operator_multiple(f["operator"]) + first_stage_extra_constraints.append( + sessions._multiple_conditions(f's.user_browser {op} %({f_k})s', f["value"], value_key=f_k)) + + elif filter_type in [schemas.FilterType.user_os, schemas.FilterType.user_os_ios]: + # op = sessions.__get_sql_operator_multiple(f["operator"]) + first_stage_extra_constraints.append( + sessions._multiple_conditions(f's.user_os {op} %({f_k})s', f["value"], value_key=f_k)) + + elif filter_type in [schemas.FilterType.user_device, schemas.FilterType.user_device_ios]: + # op = sessions.__get_sql_operator_multiple(f["operator"]) + first_stage_extra_constraints.append( + sessions._multiple_conditions(f's.user_device {op} %({f_k})s', f["value"], value_key=f_k)) + + elif filter_type in [schemas.FilterType.user_country, schemas.FilterType.user_country_ios]: + # op = sessions.__get_sql_operator_multiple(f["operator"]) + first_stage_extra_constraints.append( + sessions._multiple_conditions(f's.user_country {op} %({f_k})s', f["value"], value_key=f_k)) + elif filter_type == schemas.FilterType.duration: + if len(f["value"]) > 0 and f["value"][0] is not None: + first_stage_extra_constraints.append(f's.duration >= %(minDuration)s') + values["minDuration"] = f["value"][0] + if len(f["value"]) > 1 and f["value"][1] is not None and int(f["value"][1]) > 0: + first_stage_extra_constraints.append('s.duration <= %(maxDuration)s') + values["maxDuration"] = f["value"][1] + elif filter_type == schemas.FilterType.referrer: + # events_query_part = events_query_part + f"INNER JOIN events.pages AS p USING(session_id)" + filter_extra_from = [f"INNER JOIN {events.event_type.LOCATION.table} AS p USING(session_id)"] + # op = sessions.__get_sql_operator_multiple(f["operator"]) + first_stage_extra_constraints.append( + sessions._multiple_conditions(f"p.base_referrer {op} %({f_k})s", f["value"], value_key=f_k)) + elif filter_type == events.event_type.METADATA.ui_type: + if meta_keys is None: + meta_keys = metadata.get(project_id=project_id) + meta_keys = {m["key"]: m["index"] for m in meta_keys} + # op = sessions.__get_sql_operator(f["operator"]) + if f.get("key") in meta_keys.keys(): + first_stage_extra_constraints.append( + sessions._multiple_conditions( + f's.{metadata.index_to_colname(meta_keys[f["key"]])} {op} %({f_k})s', f["value"], + value_key=f_k)) + # values[f_k] = helper.string_to_sql_like_with_op(f["value"][0], op) + elif filter_type in [schemas.FilterType.user_id, schemas.FilterType.user_id_ios]: + # op = sessions.__get_sql_operator(f["operator"]) + first_stage_extra_constraints.append( + sessions._multiple_conditions(f's.user_id {op} %({f_k})s', f["value"], value_key=f_k)) + # values[f_k] = helper.string_to_sql_like_with_op(f["value"][0], op) + elif filter_type in [schemas.FilterType.user_anonymous_id, + schemas.FilterType.user_anonymous_id_ios]: + # op = sessions.__get_sql_operator(f["operator"]) + first_stage_extra_constraints.append( + sessions._multiple_conditions(f's.user_anonymous_id {op} %({f_k})s', f["value"], value_key=f_k)) + # values[f_k] = helper.string_to_sql_like_with_op(f["value"][0], op) + elif filter_type in [schemas.FilterType.rev_id, schemas.FilterType.rev_id_ios]: + # op = sessions.__get_sql_operator(f["operator"]) + first_stage_extra_constraints.append( + sessions._multiple_conditions(f's.rev_id {op} %({f_k})s', f["value"], value_key=f_k)) + # values[f_k] = helper.string_to_sql_like_with_op(f["value"][0], op) + i = -1 + for s in stages: + + if s.get("operator") is None: + s["operator"] = "is" + + if not isinstance(s["value"], list): + s["value"] = [s["value"]] + is_any = sessions._isAny_opreator(s["operator"]) + if not is_any and isinstance(s["value"], list) and len(s["value"]) == 0: + continue + i += 1 + if i == 0: + extra_from = filter_extra_from + ["INNER JOIN public.sessions AS s USING (session_id)"] + else: + extra_from = [] + op = sessions.__get_sql_operator(s["operator"]) + event_type = s["type"].upper() + if event_type == events.event_type.CLICK.ui_type: + next_table = events.event_type.CLICK.table + next_col_name = events.event_type.CLICK.column + elif event_type == events.event_type.INPUT.ui_type: + next_table = events.event_type.INPUT.table + next_col_name = events.event_type.INPUT.column + elif event_type == events.event_type.LOCATION.ui_type: + next_table = events.event_type.LOCATION.table + next_col_name = events.event_type.LOCATION.column + elif event_type == events.event_type.CUSTOM.ui_type: + next_table = events.event_type.CUSTOM.table + next_col_name = events.event_type.CUSTOM.column + # IOS -------------- + elif event_type == events.event_type.CLICK_IOS.ui_type: + next_table = events.event_type.CLICK_IOS.table + next_col_name = events.event_type.CLICK_IOS.column + elif event_type == events.event_type.INPUT_IOS.ui_type: + next_table = events.event_type.INPUT_IOS.table + next_col_name = events.event_type.INPUT_IOS.column + elif event_type == events.event_type.VIEW_IOS.ui_type: + next_table = events.event_type.VIEW_IOS.table + next_col_name = events.event_type.VIEW_IOS.column + elif event_type == events.event_type.CUSTOM_IOS.ui_type: + next_table = events.event_type.CUSTOM_IOS.table + next_col_name = events.event_type.CUSTOM_IOS.column + else: + print("=================UNDEFINED") + continue + + values = {**values, **sessions._multiple_values(helper.values_for_operator(value=s["value"], op=s["operator"]), + value_key=f"value{i + 1}")} + if sessions.__is_negation_operator(op) and i > 0: + op = sessions.__reverse_sql_operator(op) + main_condition = "left_not.session_id ISNULL" + extra_from.append(f"""LEFT JOIN LATERAL (SELECT session_id + FROM {next_table} AS s_main + WHERE s_main.{next_col_name} {op} %(value{i + 1})s + AND s_main.timestamp >= T{i}.stage{i}_timestamp + AND s_main.session_id = T1.session_id) AS left_not ON (TRUE)""") + else: + if is_any: + main_condition = "TRUE" + else: + main_condition = sessions._multiple_conditions(f"main.{next_col_name} {op} %(value{i + 1})s", + values=s["value"], value_key=f"value{i + 1}") + n_stages_query.append(f""" + (SELECT main.session_id, + {"MIN(main.timestamp)" if i + 1 < len(stages) else "MAX(main.timestamp)"} AS stage{i + 1}_timestamp, + '{event_type}' AS type, + '{s["operator"]}' AS operator + FROM {next_table} AS main {" ".join(extra_from)} + WHERE main.timestamp >= {f"T{i}.stage{i}_timestamp" if i > 0 else "%(startTimestamp)s"} + {f"AND main.session_id=T1.session_id" if i > 0 else ""} + AND {main_condition} + {(" AND " + " AND ".join(stage_constraints)) if len(stage_constraints) > 0 else ""} + {(" AND " + " AND ".join(first_stage_extra_constraints)) if len(first_stage_extra_constraints) > 0 and i == 0 else ""} + GROUP BY main.session_id) + AS T{i + 1} {"USING (session_id)" if i > 0 else ""} + """) + if len(n_stages_query) == 0: + return [] + n_stages_query = " LEFT JOIN LATERAL ".join(n_stages_query) + n_stages_query += ") AS stages_t" + + n_stages_query = f""" + SELECT stages_and_issues_t.*,sessions.session_id, sessions.user_uuid FROM ( + SELECT * FROM ( + SELECT * FROM + {n_stages_query} + LEFT JOIN LATERAL + ( + SELECT * FROM + (SELECT ISE.session_id, + ISS.type as issue_type, + ISE.timestamp AS issue_timestamp, + ISS.context_string as issue_context, + ISS.issue_id as issue_id + FROM events_common.issues AS ISE INNER JOIN issues AS ISS USING (issue_id) + WHERE ISE.timestamp >= stages_t.stage1_timestamp + AND ISE.timestamp <= stages_t.stage{i + 1}_timestamp + AND ISS.project_id=%(project_id)s + {"AND ISS.type IN %(issueTypes)s" if len(filter_issues) > 0 else ""}) AS base_t + ) AS issues_t + USING (session_id)) AS stages_and_issues_t + inner join sessions USING(session_id); + """ + + # LIMIT 10000 + params = {"project_id": project_id, "startTimestamp": filter_d["startDate"], "endTimestamp": filter_d["endDate"], + "issueTypes": tuple(filter_issues), **values} + with pg_client.PostgresClient() as cur: + # print("---------------------------------------------------") + # print(cur.mogrify(n_stages_query, params)) + # print("---------------------------------------------------") + cur.execute(cur.mogrify(n_stages_query, params)) + rows = cur.fetchall() + return rows + + +def pearson_corr(x: list, y: list): + n = len(x) + if n != len(y): + raise ValueError(f'x and y must have the same length. Got {len(x)} and {len(y)} instead') + + if n < 2: + warnings.warn(f'x and y must have length at least 2. Got {n} instead') + return None, None, False + + # If an input is constant, the correlation coefficient is not defined. + if all(t == x[0] for t in x) or all(t == y[0] for t in y): + warnings.warn("An input array is constant; the correlation coefficent is not defined.") + return None, None, False + + if n == 2: + return math.copysign(1, x[1] - x[0]) * math.copysign(1, y[1] - y[0]), 1.0 + + xmean = sum(x) / len(x) + ymean = sum(y) / len(y) + + xm = [el - xmean for el in x] + ym = [el - ymean for el in y] + + normxm = math.sqrt((sum([xm[i] * xm[i] for i in range(len(xm))]))) + normym = math.sqrt((sum([ym[i] * ym[i] for i in range(len(ym))]))) + + threshold = 1e-8 + if normxm < threshold * abs(xmean) or normym < threshold * abs(ymean): + # If all the values in x (likewise y) are very close to the mean, + # the loss of precision that occurs in the subtraction xm = x - xmean + # might result in large errors in r. + warnings.warn("An input array is constant; the correlation coefficent is not defined.") + + r = sum( + i[0] * i[1] for i in zip([xm[i] / normxm for i in range(len(xm))], [ym[i] / normym for i in range(len(ym))])) + + # Presumably, if abs(r) > 1, then it is only some small artifact of floating point arithmetic. + # However, if r < 0, we don't care, as our problem is to find only positive correlations + r = max(min(r, 1.0), 0.0) + + # approximated confidence + if n < 31: + t_c = T_VALUES[n] + elif n < 50: + t_c = 2.02 + else: + t_c = 2 + if r >= 0.999: + confidence = 1 + else: + confidence = r * math.sqrt(n - 2) / math.sqrt(1 - r ** 2) + + if confidence > SIGNIFICANCE_THRSH: + return r, confidence, True + else: + return r, confidence, False + + +def get_transitions_and_issues_of_each_type(rows: List[RealDictRow], all_issues_with_context, first_stage, last_stage): + """ + Returns two lists with binary values 0/1: + + transitions ::: if transited from the first stage to the last - 1 + else - 0 + errors ::: a dictionary where the keys are all unique issues (currently context-wise) + the values are lists + if an issue happened between the first stage to the last - 1 + else - 0 + + For a small task of calculating a total drop due to issues, + we need to disregard the issue type when creating the `errors`-like array. + The `all_errors` array can be obtained by logical OR statement applied to all errors by issue + The `transitions` array stays the same + """ + transitions = [] + n_sess_affected = 0 + errors = {} + for issue in all_issues_with_context: + split = issue.split('__^__') + errors[issue] = { + "errors": [], + "issue_type": split[0], + "context": split[1]} + + for row in rows: + t = 0 + first_ts = row[f'stage{first_stage}_timestamp'] + last_ts = row[f'stage{last_stage}_timestamp'] + if first_ts is None: + continue + elif first_ts is not None and last_ts is not None: + t = 1 + transitions.append(t) + + ic_present = False + for issue_type_with_context in errors: + ic = 0 + issue_type = errors[issue_type_with_context]["issue_type"] + context = errors[issue_type_with_context]["context"] + if row['issue_type'] is not None: + if last_ts is None or (first_ts < row['issue_timestamp'] < last_ts): + context_in_row = row['issue_context'] if row['issue_context'] is not None else '' + if issue_type == row['issue_type'] and context == context_in_row: + ic = 1 + ic_present = True + errors[issue_type_with_context]["errors"].append(ic) + + if ic_present and t: + n_sess_affected += 1 + + # def tuple_or(t: tuple): + # x = 0 + # for el in t: + # x |= el + # return x + def tuple_or(t: tuple): + for el in t: + if el > 0: + return 1 + return 0 + + errors = {key: errors[key]["errors"] for key in errors} + all_errors = [tuple_or(t) for t in zip(*errors.values())] + + return transitions, errors, all_errors, n_sess_affected + + +def get_affected_users_for_all_issues(rows, first_stage, last_stage): + """ + + :param rows: + :param first_stage: + :param last_stage: + :return: + """ + affected_users = defaultdict(lambda: set()) + affected_sessions = defaultdict(lambda: set()) + contexts = defaultdict(lambda: None) + n_affected_users_dict = defaultdict(lambda: None) + n_affected_sessions_dict = defaultdict(lambda: None) + all_issues_with_context = set() + n_issues_dict = defaultdict(lambda: 0) + issues_by_session = defaultdict(lambda: 0) + + for row in rows: + + # check that the session has reached the first stage of subfunnel: + if row[f'stage{first_stage}_timestamp'] is None: + continue + + iss = row['issue_type'] + iss_ts = row['issue_timestamp'] + + # check that the issue exists and belongs to subfunnel: + if iss is not None and (row[f'stage{last_stage}_timestamp'] is None or + (row[f'stage{first_stage}_timestamp'] < iss_ts < row[f'stage{last_stage}_timestamp'])): + context_string = row['issue_context'] if row['issue_context'] is not None else '' + issue_with_context = iss + '__^__' + context_string + contexts[issue_with_context] = {"context": context_string, "id": row["issue_id"]} + all_issues_with_context.add(issue_with_context) + n_issues_dict[issue_with_context] += 1 + if row['user_uuid'] is not None: + affected_users[issue_with_context].add(row['user_uuid']) + + affected_sessions[issue_with_context].add(row['session_id']) + issues_by_session[row[f'session_id']] += 1 + + if len(affected_users) > 0: + n_affected_users_dict.update({ + iss: len(affected_users[iss]) for iss in affected_users + }) + if len(affected_sessions) > 0: + n_affected_sessions_dict.update({ + iss: len(affected_sessions[iss]) for iss in affected_sessions + }) + return all_issues_with_context, n_issues_dict, n_affected_users_dict, n_affected_sessions_dict, contexts + + +def count_sessions(rows, n_stages): + session_counts = {i: set() for i in range(1, n_stages + 1)} + for ind, row in enumerate(rows): + for i in range(1, n_stages + 1): + if row[f"stage{i}_timestamp"] is not None: + session_counts[i].add(row[f"session_id"]) + session_counts = {i: len(session_counts[i]) for i in session_counts} + return session_counts + + +def count_users(rows, n_stages): + users_in_stages = defaultdict(lambda: set()) + + for ind, row in enumerate(rows): + for i in range(1, n_stages + 1): + if row[f"stage{i}_timestamp"] is not None: + users_in_stages[i].add(row["user_uuid"]) + + users_count = {i: len(users_in_stages[i]) for i in range(1, n_stages + 1)} + + return users_count + + +def get_stages(stages, rows): + n_stages = len(stages) + session_counts = count_sessions(rows, n_stages) + users_counts = count_users(rows, n_stages) + + stages_list = [] + for i, stage in enumerate(stages): + + drop = None + if i != 0: + if session_counts[i] == 0: + drop = 0 + elif session_counts[i] > 0: + drop = int(100 * (session_counts[i] - session_counts[i + 1]) / session_counts[i]) + + stages_list.append( + {"value": stage["value"], + "type": stage["type"], + "operator": stage["operator"], + "sessionsCount": session_counts[i + 1], + "drop_pct": drop, + "usersCount": users_counts[i + 1], + "dropDueToIssues": 0 + } + ) + return stages_list + + +def get_issues(stages, rows, first_stage=None, last_stage=None, drop_only=False): + """ + + :param stages: + :param rows: + :param first_stage: If it's a part of the initial funnel, provide a number of the first stage (starting from 1) + :param last_stage: If it's a part of the initial funnel, provide a number of the last stage (starting from 1) + :return: + """ + + n_stages = len(stages) + + if first_stage is None: + first_stage = 1 + if last_stage is None: + last_stage = n_stages + if last_stage > n_stages: + print("The number of the last stage provided is greater than the number of stages. Using n_stages instead") + last_stage = n_stages + + n_critical_issues = 0 + issues_dict = dict({"significant": [], + "insignificant": []}) + session_counts = count_sessions(rows, n_stages) + drop = session_counts[first_stage] - session_counts[last_stage] + + all_issues_with_context, n_issues_dict, affected_users_dict, affected_sessions, contexts = get_affected_users_for_all_issues( + rows, first_stage, last_stage) + transitions, errors, all_errors, n_sess_affected = get_transitions_and_issues_of_each_type(rows, + all_issues_with_context, + first_stage, last_stage) + + print("len(transitions) =", len(transitions)) + + if any(all_errors): + total_drop_corr, conf, is_sign = pearson_corr(transitions, all_errors) + if total_drop_corr is not None and drop is not None: + total_drop_due_to_issues = int(total_drop_corr * n_sess_affected) + else: + total_drop_due_to_issues = 0 + else: + total_drop_due_to_issues = 0 + + if drop_only: + return total_drop_due_to_issues + for issue in all_issues_with_context: + + if not any(errors[issue]): + continue + r, confidence, is_sign = pearson_corr(transitions, errors[issue]) + + if r is not None and drop is not None and is_sign: + lost_conversions = int(r * affected_sessions[issue]) + else: + lost_conversions = None + if r is None: + r = 0 + split = issue.split('__^__') + issues_dict['significant' if is_sign else 'insignificant'].append({ + "type": split[0], + "title": helper.get_issue_title(split[0]), + "affected_sessions": affected_sessions[issue], + "unaffected_sessions": session_counts[1] - affected_sessions[issue], + "lost_conversions": lost_conversions, + "affected_users": affected_users_dict[issue], + "conversion_impact": round(r * 100), + "context_string": contexts[issue]["context"], + "issue_id": contexts[issue]["id"] + }) + + if is_sign: + n_critical_issues += n_issues_dict[issue] + + return n_critical_issues, issues_dict, total_drop_due_to_issues + + +def get_top_insights(filter_d, project_id): + output = [] + stages = filter_d.get("events", []) + # TODO: handle 1 stage alone + if len(stages) == 0: + print("no stages found") + return output, 0 + elif len(stages) == 1: + # TODO: count sessions, and users for single stage + output = [{ + "type": stages[0]["type"], + "value": stages[0]["value"], + "dropPercentage": None, + "operator": stages[0]["operator"], + "sessionsCount": 0, + "dropPct": 0, + "usersCount": 0, + "dropDueToIssues": 0 + + }] + counts = sessions.search_sessions(data=schemas.SessionsSearchCountSchema.parse_obj(filter_d), + project_id=project_id, + user_id=None, count_only=True) + output[0]["sessionsCount"] = counts["countSessions"] + output[0]["usersCount"] = counts["countUsers"] + return output, 0 + # The result of the multi-stage query + rows = get_stages_and_events(filter_d=filter_d, project_id=project_id) + if len(rows) == 0: + return get_stages(stages, []), 0 + # Obtain the first part of the output + stages_list = get_stages(stages, rows) + # Obtain the second part of the output + total_drop_due_to_issues = get_issues(stages, rows, first_stage=filter_d.get("firstStage"), + last_stage=filter_d.get("lastStage"), drop_only=True) + return stages_list, total_drop_due_to_issues + + +def get_issues_list(filter_d, project_id, first_stage=None, last_stage=None): + output = dict({"total_drop_due_to_issues": 0, "critical_issues_count": 0, "significant": [], "insignificant": []}) + stages = filter_d.get("events", []) + # The result of the multi-stage query + rows = get_stages_and_events(filter_d=filter_d, project_id=project_id) + # print(json.dumps(rows[0],indent=4)) + # return + if len(rows) == 0: + return output + # Obtain the second part of the output + n_critical_issues, issues_dict, total_drop_due_to_issues = get_issues(stages, rows, first_stage=first_stage, + last_stage=last_stage) + output['total_drop_due_to_issues'] = total_drop_due_to_issues + # output['critical_issues_count'] = n_critical_issues + output = {**output, **issues_dict} + return output + + +def get_overview(filter_d, project_id, first_stage=None, last_stage=None): + output = dict() + stages = filter_d["events"] + # TODO: handle 1 stage alone + if len(stages) == 0: + return {"stages": [], + "criticalIssuesCount": 0} + elif len(stages) == 1: + # TODO: count sessions, and users for single stage + output["stages"] = [{ + "type": stages[0]["type"], + "value": stages[0]["value"], + "sessionsCount": None, + "dropPercentage": None, + "usersCount": None + }] + return output + # The result of the multi-stage query + rows = get_stages_and_events(filter_d=filter_d, project_id=project_id) + if len(rows) == 0: + # PS: not sure what to return if rows are empty + output["stages"] = [{ + "type": stages[0]["type"], + "value": stages[0]["value"], + "sessionsCount": None, + "dropPercentage": None, + "usersCount": None + }] + output['criticalIssuesCount'] = 0 + return output + # Obtain the first part of the output + stages_list = get_stages(stages, rows) + + # Obtain the second part of the output + n_critical_issues, issues_dict, total_drop_due_to_issues = get_issues(stages, rows, first_stage=first_stage, + last_stage=last_stage) + + output['stages'] = stages_list + output['criticalIssuesCount'] = n_critical_issues + return output diff --git a/ee/api/chalicelib/core/signup.py b/ee/api/chalicelib/core/signup.py index b8b0a3e4a..72317859f 100644 --- a/ee/api/chalicelib/core/signup.py +++ b/ee/api/chalicelib/core/signup.py @@ -43,7 +43,7 @@ def create_step1(data: schemas.UserSignupSchema): print("Verifying company's name validity") company_name = data.organizationName - if company_name is None or len(company_name) < 1 or not helper.is_alphanumeric_space(company_name): + if company_name is None or len(company_name) < 1: errors.append("invalid organization's name") print("Verifying project's name validity") diff --git a/ee/api/chalicelib/core/users.py b/ee/api/chalicelib/core/users.py index ff43cca41..ae998f83f 100644 --- a/ee/api/chalicelib/core/users.py +++ b/ee/api/chalicelib/core/users.py @@ -199,7 +199,7 @@ def update(tenant_id, user_id, changes): {"tenant_id": tenant_id, "user_id": user_id, **changes}) ) - return helper.dict_to_camel_case(cur.fetchone()) + return get(user_id=user_id, tenant_id=tenant_id) def create_member(tenant_id, user_id, data, background_tasks: BackgroundTasks): @@ -212,7 +212,7 @@ def create_member(tenant_id, user_id, data, background_tasks: BackgroundTasks): if user: return {"errors": ["user already exists"]} name = data.get("name", None) - if name is not None and not helper.is_alphabet_latin_space(name): + if name is not None and len(name) == 0: return {"errors": ["invalid user name"]} if name is None: name = data["email"] diff --git a/ee/api/chalicelib/utils/ch_client.py b/ee/api/chalicelib/utils/ch_client.py index a51230a19..514820212 100644 --- a/ee/api/chalicelib/utils/ch_client.py +++ b/ee/api/chalicelib/utils/ch_client.py @@ -1,6 +1,19 @@ +import logging + import clickhouse_driver from decouple import config +logging.basicConfig(level=config("LOGLEVEL", default=logging.INFO)) + +settings = {} +if config('ch_timeout', cast=int, default=-1) > 0: + logging.info(f"CH-max_execution_time set to {config('ch_timeout')}s") + settings = {**settings, "max_execution_time": config('ch_timeout', cast=int)} + +if config('ch_receive_timeout', cast=int, default=-1) > 0: + logging.info(f"CH-receive_timeout set to {config('ch_receive_timeout')}s") + settings = {**settings, "receive_timeout": config('ch_receive_timeout', cast=int)} + class ClickHouseClient: __client = None @@ -8,16 +21,23 @@ class ClickHouseClient: def __init__(self): self.__client = clickhouse_driver.Client(host=config("ch_host"), database="default", - port=config("ch_port", cast=int)) \ + port=config("ch_port", cast=int), + settings=settings) \ if self.__client is None else self.__client def __enter__(self): return self def execute(self, query, params=None, **args): - results = self.__client.execute(query=query, params=params, with_column_types=True, **args) - keys = tuple(x for x, y in results[1]) - return [dict(zip(keys, i)) for i in results[0]] + try: + results = self.__client.execute(query=query, params=params, with_column_types=True, **args) + keys = tuple(x for x, y in results[1]) + return [dict(zip(keys, i)) for i in results[0]] + except Exception as err: + logging.error("--------- CH QUERY EXCEPTION -----------") + logging.error(self.format(query=query, params=params)) + logging.error("--------------------") + raise err def insert(self, query, params=None, **args): return self.__client.execute(query=query, params=params, **args) @@ -26,6 +46,8 @@ class ClickHouseClient: return self.__client def format(self, query, params): + if params is None: + return query return self.__client.substitute_params(query, params, self.__client.connection.context) def __exit__(self, *args): diff --git a/ee/api/chalicelib/utils/exp_ch_helper.py b/ee/api/chalicelib/utils/exp_ch_helper.py new file mode 100644 index 000000000..709b5e926 --- /dev/null +++ b/ee/api/chalicelib/utils/exp_ch_helper.py @@ -0,0 +1,42 @@ +from chalicelib.utils.TimeUTC import TimeUTC +from decouple import config +import logging + +logging.basicConfig(level=config("LOGLEVEL", default=logging.INFO)) + +if config("EXP_7D_MV", cast=bool, default=True): + print(">>> Using experimental last 7 days materialized views") + + +def get_main_events_table(timestamp): + return "experimental.events_l7d_mv" \ + if config("EXP_7D_MV", cast=bool, default=True) \ + and timestamp >= TimeUTC.now(delta_days=-7) else "experimental.events" + + +def get_main_sessions_table(timestamp): + return "experimental.sessions_l7d_mv" \ + if config("EXP_7D_MV", cast=bool, default=True) \ + and timestamp >= TimeUTC.now(delta_days=-7) else "experimental.sessions" + + +def get_main_resources_table(timestamp): + return "experimental.resources_l7d_mv" \ + if config("EXP_7D_MV", cast=bool, default=True) \ + and timestamp >= TimeUTC.now(delta_days=-7) else "experimental.resources" + + +def get_autocomplete_table(timestamp=0): + return "experimental.autocomplete" + + +def get_user_favorite_sessions_table(timestamp=0): + return "experimental.user_favorite_sessions" + + +def get_user_viewed_sessions_table(timestamp=0): + return "experimental.user_viewed_sessions" + + +def get_user_viewed_errors_table(timestamp=0): + return "experimental.user_viewed_errors" diff --git a/ee/api/clean.sh b/ee/api/clean.sh index fa1ab8cb5..b05ce1ee4 100755 --- a/ee/api/clean.sh +++ b/ee/api/clean.sh @@ -3,8 +3,11 @@ rm -rf ./chalicelib/core/alerts.py rm -rf ./chalicelib/core/alerts_processor.py rm -rf ./chalicelib/core/announcements.py +rm -rf ./chalicelib/core/autocomplete.py rm -rf ./chalicelib/core/collaboration_slack.py -rm -rf ./chalicelib/core/errors_favorite_viewed.py +rm -rf ./chalicelib/core/countries.py +rm -rf ./chalicelib/core/errors.py +rm -rf ./chalicelib/core/errors_favorite.py rm -rf ./chalicelib/core/events.py rm -rf ./chalicelib/core/events_ios.py rm -rf ./chalicelib/core/dashboards.py diff --git a/ee/api/env.default b/ee/api/env.default index ef04bbc3b..ebd1c8438 100644 --- a/ee/api/env.default +++ b/ee/api/env.default @@ -19,6 +19,8 @@ captcha_key= captcha_server= ch_host= ch_port= +ch_timeout=30 +ch_receive_timeout=10 change_password_link=/reset-password?invitation=%s&&pass=%s email_basic=http://127.0.0.1:8000/async/basic/%s email_plans=http://127.0.0.1:8000/async/plans/%s @@ -57,3 +59,10 @@ sourcemaps_bucket=sourcemaps sourcemaps_reader=http://127.0.0.1:9000/sourcemaps stage=default-ee version_number=1.0.0 +FS_DIR=/mnt/efs +EXP_SESSIONS_SEARCH=true +EXP_AUTOCOMPLETE=true +EXP_ERRORS_SEARCH=true +EXP_METRICS=true +EXP_7D_MV=true +EXP_ALERTS=true \ No newline at end of file diff --git a/ee/api/requirements-alerts.txt b/ee/api/requirements-alerts.txt index 66fa84713..475a39b5e 100644 --- a/ee/api/requirements-alerts.txt +++ b/ee/api/requirements-alerts.txt @@ -1,17 +1,17 @@ requests==2.28.1 urllib3==1.26.10 -boto3==1.24.26 +boto3==1.24.53 pyjwt==2.4.0 psycopg2-binary==2.9.3 -elasticsearch==8.3.1 -jira==3.3.0 +elasticsearch==8.3.3 +jira==3.3.1 -fastapi==0.78.0 +fastapi==0.80.0 uvicorn[standard]==0.18.2 python-decouple==3.6 -pydantic[email]==1.9.1 +pydantic[email]==1.9.2 apscheduler==3.9.1 clickhouse-driver==0.2.4 diff --git a/ee/api/requirements-crons.txt b/ee/api/requirements-crons.txt index 66fa84713..475a39b5e 100644 --- a/ee/api/requirements-crons.txt +++ b/ee/api/requirements-crons.txt @@ -1,17 +1,17 @@ requests==2.28.1 urllib3==1.26.10 -boto3==1.24.26 +boto3==1.24.53 pyjwt==2.4.0 psycopg2-binary==2.9.3 -elasticsearch==8.3.1 -jira==3.3.0 +elasticsearch==8.3.3 +jira==3.3.1 -fastapi==0.78.0 +fastapi==0.80.0 uvicorn[standard]==0.18.2 python-decouple==3.6 -pydantic[email]==1.9.1 +pydantic[email]==1.9.2 apscheduler==3.9.1 clickhouse-driver==0.2.4 diff --git a/ee/api/requirements.txt b/ee/api/requirements.txt index 5ce044904..bdf363b7b 100644 --- a/ee/api/requirements.txt +++ b/ee/api/requirements.txt @@ -1,17 +1,17 @@ requests==2.28.1 urllib3==1.26.10 -boto3==1.24.26 +boto3==1.24.53 pyjwt==2.4.0 psycopg2-binary==2.9.3 -elasticsearch==8.3.1 -jira==3.3.0 +elasticsearch==8.3.3 +jira==3.3.1 -fastapi==0.78.0 +fastapi==0.80.0 uvicorn[standard]==0.18.2 python-decouple==3.6 -pydantic[email]==1.9.1 +pydantic[email]==1.9.2 apscheduler==3.9.1 clickhouse-driver==0.2.4 diff --git a/ee/api/routers/core_dynamic.py b/ee/api/routers/core_dynamic.py index 6d8470444..e6675c4f3 100644 --- a/ee/api/routers/core_dynamic.py +++ b/ee/api/routers/core_dynamic.py @@ -98,18 +98,6 @@ def edit_slack_integration(integrationId: int, data: schemas.EditSlackSchema = B changes={"name": data.name, "endpoint": data.url})} -# this endpoint supports both jira & github based on `provider` attribute -@app.post('/integrations/issues', tags=["integrations"]) -def add_edit_jira_cloud_github(data: schemas.JiraGithubSchema, - context: schemas.CurrentContext = Depends(OR_context)): - provider = data.provider.upper() - error, integration = integrations_manager.get_integration(tool=provider, tenant_id=context.tenant_id, - user_id=context.user_id) - if error is not None: - return error - return {"data": integration.add_edit(data=data.dict())} - - @app.post('/client/members', tags=["client"]) @app.put('/client/members', tags=["client"]) def add_member(background_tasks: BackgroundTasks, data: schemas_ee.CreateMemberSchema = Body(...), diff --git a/ee/api/schemas_ee.py b/ee/api/schemas_ee.py index 0375521ad..458bdc052 100644 --- a/ee/api/schemas_ee.py +++ b/ee/api/schemas_ee.py @@ -43,3 +43,27 @@ class TrailSearchPayloadSchema(schemas._PaginatedSchema): class Config: alias_generator = schemas.attribute_to_camel_case + + +class SessionModel(BaseModel): + viewed: bool = Field(default=False) + userId: Optional[str] + userOs: str + duration: int + favorite: bool = Field(default=False) + platform: str + startTs: int + userUuid: str + projectId: int + sessionId: str + issueScore: int + issueTypes: List[schemas.IssueType] = Field(default=[]) + pagesCount: int + userDevice: Optional[str] + errorsCount: int + eventsCount: int + userBrowser: str + userCountry: str + userDeviceType: str + userAnonymousId: Optional[str] + metadata: dict = Field(default={}) diff --git a/ee/backend/internal/db/datasaver/messages.go b/ee/backend/internal/db/datasaver/messages.go new file mode 100644 index 000000000..3187a0c91 --- /dev/null +++ b/ee/backend/internal/db/datasaver/messages.go @@ -0,0 +1,112 @@ +package datasaver + +import ( + "fmt" + "log" + "openreplay/backend/pkg/messages" +) + +func (mi *Saver) InsertMessage(sessionID uint64, msg messages.Message) error { + switch m := msg.(type) { + // Common + case *messages.Metadata: + if err := mi.pg.InsertMetadata(sessionID, m); err != nil { + return fmt.Errorf("insert metadata err: %s", err) + } + return nil + case *messages.IssueEvent: + return mi.pg.InsertIssueEvent(sessionID, m) + //TODO: message adapter (transformer) (at the level of pkg/message) for types: *IOSMetadata, *IOSIssueEvent and others + + // Web + case *messages.SessionStart: + return mi.pg.HandleWebSessionStart(sessionID, m) + case *messages.SessionEnd: + return mi.pg.HandleWebSessionEnd(sessionID, m) + case *messages.UserID: + return mi.pg.InsertWebUserID(sessionID, m) + case *messages.UserAnonymousID: + return mi.pg.InsertWebUserAnonymousID(sessionID, m) + case *messages.CustomEvent: + session, err := mi.pg.GetSession(sessionID) + if err != nil { + log.Printf("can't get session info for CH: %s", err) + } else { + if err := mi.ch.InsertCustom(session, m); err != nil { + log.Printf("can't insert graphQL event into clickhouse: %s", err) + } + } + return mi.pg.InsertWebCustomEvent(sessionID, m) + case *messages.ClickEvent: + return mi.pg.InsertWebClickEvent(sessionID, m) + case *messages.InputEvent: + return mi.pg.InsertWebInputEvent(sessionID, m) + + // Unique Web messages + case *messages.PageEvent: + return mi.pg.InsertWebPageEvent(sessionID, m) + case *messages.ErrorEvent: + return mi.pg.InsertWebErrorEvent(sessionID, m) + case *messages.FetchEvent: + session, err := mi.pg.GetSession(sessionID) + if err != nil { + log.Printf("can't get session info for CH: %s", err) + } else { + project, err := mi.pg.GetProject(session.ProjectID) + if err != nil { + log.Printf("can't get project: %s", err) + } else { + if err := mi.ch.InsertRequest(session, m, project.SaveRequestPayloads); err != nil { + log.Printf("can't insert request event into clickhouse: %s", err) + } + } + } + return mi.pg.InsertWebFetchEvent(sessionID, m) + case *messages.GraphQLEvent: + session, err := mi.pg.GetSession(sessionID) + if err != nil { + log.Printf("can't get session info for CH: %s", err) + } else { + if err := mi.ch.InsertGraphQL(session, m); err != nil { + log.Printf("can't insert graphQL event into clickhouse: %s", err) + } + } + return mi.pg.InsertWebGraphQLEvent(sessionID, m) + case *messages.IntegrationEvent: + return mi.pg.InsertWebErrorEvent(sessionID, &messages.ErrorEvent{ + MessageID: m.Meta().Index, + Timestamp: m.Timestamp, + Source: m.Source, + Name: m.Name, + Message: m.Message, + Payload: m.Payload, + }) + case *messages.SetPageLocation: + return mi.pg.InsertSessionReferrer(sessionID, m.Referrer) + + // IOS + case *messages.IOSSessionStart: + return mi.pg.InsertIOSSessionStart(sessionID, m) + case *messages.IOSSessionEnd: + return mi.pg.InsertIOSSessionEnd(sessionID, m) + case *messages.IOSUserID: + return mi.pg.InsertIOSUserID(sessionID, m) + case *messages.IOSUserAnonymousID: + return mi.pg.InsertIOSUserAnonymousID(sessionID, m) + case *messages.IOSCustomEvent: + return mi.pg.InsertIOSCustomEvent(sessionID, m) + case *messages.IOSClickEvent: + return mi.pg.InsertIOSClickEvent(sessionID, m) + case *messages.IOSInputEvent: + return mi.pg.InsertIOSInputEvent(sessionID, m) + // Unique IOS messages + case *messages.IOSNetworkCall: + return mi.pg.InsertIOSNetworkCall(sessionID, m) + case *messages.IOSScreenEnter: + return mi.pg.InsertIOSScreenEnter(sessionID, m) + case *messages.IOSCrash: + return mi.pg.InsertIOSCrash(sessionID, m) + + } + return nil // "Not implemented" +} diff --git a/ee/backend/internal/db/datasaver/saver.go b/ee/backend/internal/db/datasaver/saver.go new file mode 100644 index 000000000..7b48fbaa0 --- /dev/null +++ b/ee/backend/internal/db/datasaver/saver.go @@ -0,0 +1,17 @@ +package datasaver + +import ( + "openreplay/backend/pkg/db/cache" + "openreplay/backend/pkg/db/clickhouse" + "openreplay/backend/pkg/queue/types" +) + +type Saver struct { + pg *cache.PGCache + ch clickhouse.Connector + producer types.Producer +} + +func New(pg *cache.PGCache, producer types.Producer) *Saver { + return &Saver{pg: pg, producer: producer} +} diff --git a/ee/backend/internal/db/datasaver/stats.go b/ee/backend/internal/db/datasaver/stats.go index 7fa2fb9d0..ecf418090 100644 --- a/ee/backend/internal/db/datasaver/stats.go +++ b/ee/backend/internal/db/datasaver/stats.go @@ -5,44 +5,40 @@ import ( "time" "openreplay/backend/pkg/db/clickhouse" - . "openreplay/backend/pkg/db/types" + "openreplay/backend/pkg/db/types" "openreplay/backend/pkg/env" - . "openreplay/backend/pkg/messages" + "openreplay/backend/pkg/messages" ) -var ch clickhouse.Connector var finalizeTicker <-chan time.Time func (si *Saver) InitStats() { - ch = clickhouse.NewConnector(env.String("CLICKHOUSE_STRING")) - if err := ch.Prepare(); err != nil { + si.ch = clickhouse.NewConnector(env.String("CLICKHOUSE_STRING")) + if err := si.ch.Prepare(); err != nil { log.Fatalf("Clickhouse prepare error: %v\n", err) } - + si.pg.Conn.SetClickHouse(si.ch) finalizeTicker = time.Tick(20 * time.Minute) - } -func (si *Saver) InsertStats(session *Session, msg Message) error { +func (si *Saver) InsertStats(session *types.Session, msg messages.Message) error { switch m := msg.(type) { // Web - case *SessionEnd: - return ch.InsertWebSession(session) - case *PerformanceTrackAggr: - return ch.InsertWebPerformanceTrackAggr(session, m) - case *ClickEvent: - return ch.InsertWebClickEvent(session, m) - case *InputEvent: - return ch.InsertWebInputEvent(session, m) - // Unique for Web - case *PageEvent: - ch.InsertWebPageEvent(session, m) - case *ResourceEvent: - return ch.InsertWebResourceEvent(session, m) - case *ErrorEvent: - return ch.InsertWebErrorEvent(session, m) - case *LongTask: - return ch.InsertLongtask(session, m) + case *messages.SessionEnd: + return si.ch.InsertWebSession(session) + case *messages.PerformanceTrackAggr: + return si.ch.InsertWebPerformanceTrackAggr(session, m) + case *messages.ClickEvent: + return si.ch.InsertWebClickEvent(session, m) + case *messages.InputEvent: + return si.ch.InsertWebInputEvent(session, m) + // Unique for Web + case *messages.PageEvent: + return si.ch.InsertWebPageEvent(session, m) + case *messages.ResourceEvent: + return si.ch.InsertWebResourceEvent(session, m) + case *messages.ErrorEvent: + return si.ch.InsertWebErrorEvent(session, m) } return nil } @@ -50,15 +46,10 @@ func (si *Saver) InsertStats(session *Session, msg Message) error { func (si *Saver) CommitStats() error { select { case <-finalizeTicker: - if err := ch.FinaliseSessionsTable(); err != nil { + if err := si.ch.FinaliseSessionsTable(); err != nil { log.Printf("Stats: FinaliseSessionsTable returned an error. %v", err) } default: } - errCommit := ch.Commit() - errPrepare := ch.Prepare() - if errCommit != nil { - return errCommit - } - return errPrepare + return si.ch.Commit() } diff --git a/ee/backend/pkg/db/clickhouse/connector.go b/ee/backend/pkg/db/clickhouse/connector.go index 1fd6e5d1e..dcf603055 100644 --- a/ee/backend/pkg/db/clickhouse/connector.go +++ b/ee/backend/pkg/db/clickhouse/connector.go @@ -7,6 +7,7 @@ import ( "github.com/ClickHouse/clickhouse-go/v2" "github.com/ClickHouse/clickhouse-go/v2/lib/driver" "log" + "math" "openreplay/backend/pkg/db/types" "openreplay/backend/pkg/hashid" "openreplay/backend/pkg/messages" @@ -17,6 +18,51 @@ import ( "openreplay/backend/pkg/license" ) +type Bulk interface { + Append(args ...interface{}) error + Send() error +} + +type bulkImpl struct { + conn driver.Conn + query string + values [][]interface{} +} + +func NewBulk(conn driver.Conn, query string) (Bulk, error) { + switch { + case conn == nil: + return nil, errors.New("clickhouse connection is empty") + case query == "": + return nil, errors.New("query is empty") + } + return &bulkImpl{ + conn: conn, + query: query, + values: make([][]interface{}, 0), + }, nil +} + +func (b *bulkImpl) Append(args ...interface{}) error { + b.values = append(b.values, args) + return nil +} + +func (b *bulkImpl) Send() error { + batch, err := b.conn.PrepareBatch(context.Background(), b.query) + if err != nil { + return fmt.Errorf("can't create new batch: %s", err) + } + for _, set := range b.values { + if err := batch.Append(set...); err != nil { + log.Printf("can't append value set to batch, err: %s", err) + log.Printf("failed query: %s", b.query) + } + } + b.values = make([][]interface{}, 0) + return batch.Send() +} + var CONTEXT_MAP = map[uint64]string{0: "unknown", 1: "self", 2: "same-origin-ancestor", 3: "same-origin-descendant", 4: "same-origin", 5: "cross-origin-ancestor", 6: "cross-origin-descendant", 7: "cross-origin-unreachable", 8: "multiple-contexts"} var CONTAINER_TYPE_MAP = map[uint64]string{0: "window", 1: "iframe", 2: "embed", 3: "object"} @@ -31,12 +77,15 @@ type Connector interface { InsertWebInputEvent(session *types.Session, msg *messages.InputEvent) error InsertWebErrorEvent(session *types.Session, msg *messages.ErrorEvent) error InsertWebPerformanceTrackAggr(session *types.Session, msg *messages.PerformanceTrackAggr) error - InsertLongtask(session *types.Session, msg *messages.LongTask) error + InsertAutocomplete(session *types.Session, msgType, msgValue string) error + InsertRequest(session *types.Session, msg *messages.FetchEvent, savePayload bool) error + InsertCustom(session *types.Session, msg *messages.CustomEvent) error + InsertGraphQL(session *types.Session, msg *messages.GraphQLEvent) error } type connectorImpl struct { conn driver.Conn - batches map[string]driver.Batch + batches map[string]Bulk //driver.Batch } func NewConnector(url string) Connector { @@ -62,33 +111,32 @@ func NewConnector(url string) Connector { c := &connectorImpl{ conn: conn, - batches: make(map[string]driver.Batch, 9), + batches: make(map[string]Bulk, 9), } return c } func (c *connectorImpl) newBatch(name, query string) error { - batch, err := c.conn.PrepareBatch(context.Background(), query) + batch, err := NewBulk(c.conn, query) if err != nil { return fmt.Errorf("can't create new batch: %s", err) } - if _, ok := c.batches[name]; ok { - delete(c.batches, name) - } c.batches[name] = batch return nil } var batches = map[string]string{ - "sessions": "INSERT INTO sessions (session_id, project_id, tracker_version, rev_id, user_uuid, user_os, user_os_version, user_device, user_device_type, user_country, datetime, duration, pages_count, events_count, errors_count, user_browser, user_browser_version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - "metadata": "INSERT INTO sessions_metadata (session_id, user_id, user_anonymous_id, metadata_1, metadata_2, metadata_3, metadata_4, metadata_5, metadata_6, metadata_7, metadata_8, metadata_9, metadata_10, datetime) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - "resources": "INSERT INTO resources (session_id, project_id, tracker_version, rev_id, user_uuid, user_os, user_os_version, user_browser, user_browser_version, user_device, user_device_type, user_country, datetime, url, type, duration, ttfb, header_size, encoded_body_size, decoded_body_size, success) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - "pages": "INSERT INTO pages (session_id, project_id, tracker_version, rev_id, user_uuid, user_os, user_os_version, user_browser, user_browser_version, user_device, user_device_type, user_country, datetime, url, request_start, response_start, response_end, dom_content_loaded_event_start, dom_content_loaded_event_end, load_event_start, load_event_end, first_paint, first_contentful_paint, speed_index, visually_complete, time_to_interactive) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - "clicks": "INSERT INTO clicks (session_id, project_id, tracker_version, rev_id, user_uuid, user_os, user_os_version, user_browser, user_browser_version, user_device, user_device_type, user_country, datetime, label, hesitation_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - "inputs": "INSERT INTO inputs (session_id, project_id, tracker_version, rev_id, user_uuid, user_os, user_os_version, user_browser, user_browser_version, user_device, user_device_type, user_country, datetime, label) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - "errors": "INSERT INTO errors (session_id, project_id, tracker_version, rev_id, user_uuid, user_os, user_os_version, user_browser, user_browser_version, user_device, user_device_type, user_country, datetime, source, name, message, error_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - "performance": "INSERT INTO performance (session_id, project_id, tracker_version, rev_id, user_uuid, user_os, user_os_version, user_browser, user_browser_version, user_device, user_device_type, user_country, datetime, min_fps, avg_fps, max_fps, min_cpu, avg_cpu, max_cpu, min_total_js_heap_size, avg_total_js_heap_size, max_total_js_heap_size, min_used_js_heap_size, avg_used_js_heap_size, max_used_js_heap_size) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - "longtasks": "INSERT INTO longtasks (session_id, project_id, tracker_version, rev_id, user_uuid, user_os, user_os_version, user_browser, user_browser_version, user_device, user_device_type, user_country, datetime, context, container_type, container_id, container_name, container_src) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "sessions": "INSERT INTO experimental.sessions (session_id, project_id, user_id, user_uuid, user_os, user_os_version, user_device, user_device_type, user_country, datetime, duration, pages_count, events_count, errors_count, issue_score, referrer, issue_types, tracker_version, user_browser, user_browser_version, metadata_1, metadata_2, metadata_3, metadata_4, metadata_5, metadata_6, metadata_7, metadata_8, metadata_9, metadata_10) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "resources": "INSERT INTO experimental.resources (session_id, project_id, message_id, datetime, url, type, duration, ttfb, header_size, encoded_body_size, decoded_body_size, success) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "autocompletes": "INSERT INTO experimental.autocomplete (project_id, type, value) VALUES (?, ?, ?)", + "pages": "INSERT INTO experimental.events (session_id, project_id, message_id, datetime, url, request_start, response_start, response_end, dom_content_loaded_event_start, dom_content_loaded_event_end, load_event_start, load_event_end, first_paint, first_contentful_paint_time, speed_index, visually_complete, time_to_interactive, event_type) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "clicks": "INSERT INTO experimental.events (session_id, project_id, message_id, datetime, label, hesitation_time, event_type) VALUES (?, ?, ?, ?, ?, ?, ?)", + "inputs": "INSERT INTO experimental.events (session_id, project_id, message_id, datetime, label, event_type) VALUES (?, ?, ?, ?, ?, ?)", + "errors": "INSERT INTO experimental.events (session_id, project_id, message_id, datetime, source, name, message, error_id, event_type) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + "performance": "INSERT INTO experimental.events (session_id, project_id, message_id, datetime, url, min_fps, avg_fps, max_fps, min_cpu, avg_cpu, max_cpu, min_total_js_heap_size, avg_total_js_heap_size, max_total_js_heap_size, min_used_js_heap_size, avg_used_js_heap_size, max_used_js_heap_size, event_type) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "requests": "INSERT INTO experimental.events (session_id, project_id, message_id, datetime, url, request_body, response_body, status, method, duration, success, event_type) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "custom": "INSERT INTO experimental.events (session_id, project_id, message_id, datetime, name, payload, event_type) VALUES (?, ?, ?, ?, ?, ?, ?)", + "graphql": "INSERT INTO experimental.events (session_id, project_id, message_id, datetime, name, request_body, response_body, event_type) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", } func (c *connectorImpl) Prepare() error { @@ -118,9 +166,7 @@ func (c *connectorImpl) FinaliseSessionsTable() error { func (c *connectorImpl) checkError(name string, err error) { if err != clickhouse.ErrBatchAlreadySent { - if batchErr := c.newBatch(name, batches[name]); batchErr != nil { - log.Printf("can't create %s batch after failed append operation: %s", name, batchErr) - } + log.Printf("can't create %s batch after failed append operation: %s", name, err) } } @@ -130,9 +176,8 @@ func (c *connectorImpl) InsertWebSession(session *types.Session) error { } if err := c.batches["sessions"].Append( session.SessionID, - session.ProjectID, - session.TrackerVersion, - nullableString(session.RevID), + uint16(session.ProjectID), + session.UserID, session.UserUUID, session.UserOS, nullableString(session.UserOSVersion), @@ -144,17 +189,12 @@ func (c *connectorImpl) InsertWebSession(session *types.Session) error { uint16(session.PagesCount), uint16(session.EventsCount), uint16(session.ErrorsCount), - // Web unique columns + uint32(session.IssueScore), + session.Referrer, + session.IssueTypes, + session.TrackerVersion, session.UserBrowser, nullableString(session.UserBrowserVersion), - ); err != nil { - c.checkError("sessions", err) - return fmt.Errorf("can't append to sessions batch: %s", err) - } - if err := c.batches["metadata"].Append( - session.SessionID, - session.UserID, - session.UserAnonymousID, session.Metadata1, session.Metadata2, session.Metadata3, @@ -165,10 +205,9 @@ func (c *connectorImpl) InsertWebSession(session *types.Session) error { session.Metadata8, session.Metadata9, session.Metadata10, - datetime(session.Timestamp), ); err != nil { - c.checkError("metadata", err) - return fmt.Errorf("can't append to metadata batch: %s", err) + c.checkError("sessions", err) + return fmt.Errorf("can't append to sessions batch: %s", err) } return nil } @@ -180,17 +219,8 @@ func (c *connectorImpl) InsertWebResourceEvent(session *types.Session, msg *mess } if err := c.batches["resources"].Append( session.SessionID, - session.ProjectID, - session.TrackerVersion, - nullableString(session.RevID), - session.UserUUID, - session.UserOS, - nullableString(session.UserOSVersion), - session.UserBrowser, - nullableString(session.UserBrowserVersion), - nullableString(session.UserDevice), - session.UserDeviceType, - session.UserCountry, + uint16(session.ProjectID), + msg.MessageID, datetime(msg.Timestamp), url.DiscardURLQuery(msg.URL), msg.Type, @@ -210,16 +240,8 @@ func (c *connectorImpl) InsertWebResourceEvent(session *types.Session, msg *mess func (c *connectorImpl) InsertWebPageEvent(session *types.Session, msg *messages.PageEvent) error { if err := c.batches["pages"].Append( session.SessionID, - session.ProjectID, - session.TrackerVersion, nullableString(session.RevID), - session.UserUUID, - session.UserOS, - nullableString(session.UserOSVersion), - session.UserBrowser, - nullableString(session.UserBrowserVersion), - nullableString(session.UserDevice), - session.UserDeviceType, - session.UserCountry, + uint16(session.ProjectID), + msg.MessageID, datetime(msg.Timestamp), url.DiscardURLQuery(msg.URL), nullableUint16(uint16(msg.RequestStart)), @@ -234,6 +256,7 @@ func (c *connectorImpl) InsertWebPageEvent(session *types.Session, msg *messages nullableUint16(uint16(msg.SpeedIndex)), nullableUint16(uint16(msg.VisuallyComplete)), nullableUint16(uint16(msg.TimeToInteractive)), + "LOCATION", ); err != nil { c.checkError("pages", err) return fmt.Errorf("can't append to pages batch: %s", err) @@ -247,20 +270,12 @@ func (c *connectorImpl) InsertWebClickEvent(session *types.Session, msg *message } if err := c.batches["clicks"].Append( session.SessionID, - session.ProjectID, - session.TrackerVersion, - nullableString(session.RevID), - session.UserUUID, - session.UserOS, - nullableString(session.UserOSVersion), - session.UserBrowser, - nullableString(session.UserBrowserVersion), - nullableString(session.UserDevice), - session.UserDeviceType, - session.UserCountry, + uint16(session.ProjectID), + msg.MessageID, datetime(msg.Timestamp), msg.Label, nullableUint32(uint32(msg.HesitationTime)), + "CLICK", ); err != nil { c.checkError("clicks", err) return fmt.Errorf("can't append to clicks batch: %s", err) @@ -274,19 +289,11 @@ func (c *connectorImpl) InsertWebInputEvent(session *types.Session, msg *message } if err := c.batches["inputs"].Append( session.SessionID, - session.ProjectID, - session.TrackerVersion, - nullableString(session.RevID), - session.UserUUID, - session.UserOS, - nullableString(session.UserOSVersion), - session.UserBrowser, - nullableString(session.UserBrowserVersion), - nullableString(session.UserDevice), - session.UserDeviceType, - session.UserCountry, + uint16(session.ProjectID), + msg.MessageID, datetime(msg.Timestamp), msg.Label, + "INPUT", ); err != nil { c.checkError("inputs", err) return fmt.Errorf("can't append to inputs batch: %s", err) @@ -297,22 +304,14 @@ func (c *connectorImpl) InsertWebInputEvent(session *types.Session, msg *message func (c *connectorImpl) InsertWebErrorEvent(session *types.Session, msg *messages.ErrorEvent) error { if err := c.batches["errors"].Append( session.SessionID, - session.ProjectID, - session.TrackerVersion, - nullableString(session.RevID), - session.UserUUID, - session.UserOS, - nullableString(session.UserOSVersion), - session.UserBrowser, - nullableString(session.UserBrowserVersion), - nullableString(session.UserDevice), - session.UserDeviceType, - session.UserCountry, + uint16(session.ProjectID), + msg.MessageID, datetime(msg.Timestamp), msg.Source, nullableString(msg.Name), msg.Message, hashid.WebErrorID(session.ProjectID, msg), + "ERROR", ); err != nil { c.checkError("errors", err) return fmt.Errorf("can't append to errors batch: %s", err) @@ -324,18 +323,10 @@ func (c *connectorImpl) InsertWebPerformanceTrackAggr(session *types.Session, ms var timestamp uint64 = (msg.TimestampStart + msg.TimestampEnd) / 2 if err := c.batches["performance"].Append( session.SessionID, - session.ProjectID, - session.TrackerVersion, - nullableString(session.RevID), - session.UserUUID, - session.UserOS, - nullableString(session.UserOSVersion), - session.UserBrowser, - nullableString(session.UserBrowserVersion), - nullableString(session.UserDevice), - session.UserDeviceType, - session.UserCountry, + uint16(session.ProjectID), + 0, // TODO: find messageID for performance events datetime(timestamp), + nullableString(msg.Meta().Url), uint8(msg.MinFPS), uint8(msg.AvgFPS), uint8(msg.MaxFPS), @@ -348,6 +339,7 @@ func (c *connectorImpl) InsertWebPerformanceTrackAggr(session *types.Session, ms msg.MinUsedJSHeapSize, msg.AvgUsedJSHeapSize, msg.MaxUsedJSHeapSize, + "PERFORMANCE", ); err != nil { c.checkError("performance", err) return fmt.Errorf("can't append to performance batch: %s", err) @@ -355,29 +347,76 @@ func (c *connectorImpl) InsertWebPerformanceTrackAggr(session *types.Session, ms return nil } -func (c *connectorImpl) InsertLongtask(session *types.Session, msg *messages.LongTask) error { - if err := c.batches["longtasks"].Append( - session.SessionID, - session.ProjectID, - session.TrackerVersion, - nullableString(session.RevID), - session.UserUUID, - session.UserOS, - nullableString(session.UserOSVersion), - session.UserBrowser, - nullableString(session.UserBrowserVersion), - nullableString(session.UserDevice), - session.UserDeviceType, - session.UserCountry, - datetime(msg.Timestamp), - CONTEXT_MAP[msg.Context], - CONTAINER_TYPE_MAP[msg.ContainerType], - msg.ContainerId, - msg.ContainerName, - msg.ContainerSrc, +func (c *connectorImpl) InsertAutocomplete(session *types.Session, msgType, msgValue string) error { + if len(msgValue) == 0 { + return nil + } + if err := c.batches["autocompletes"].Append( + uint16(session.ProjectID), + msgType, + msgValue, ); err != nil { - c.checkError("longtasks", err) - return fmt.Errorf("can't append to longtasks batch: %s", err) + c.checkError("autocompletes", err) + return fmt.Errorf("can't append to autocompletes batch: %s", err) + } + return nil +} + +func (c *connectorImpl) InsertRequest(session *types.Session, msg *messages.FetchEvent, savePayload bool) error { + var request, response *string + if savePayload { + request = &msg.Request + response = &msg.Response + } + if err := c.batches["requests"].Append( + session.SessionID, + uint16(session.ProjectID), + msg.MessageID, + datetime(msg.Timestamp), + msg.URL, + request, + response, + uint16(msg.Status), + url.EnsureMethod(msg.Method), + uint16(msg.Duration), + msg.Status < 400, + "REQUEST", + ); err != nil { + c.checkError("requests", err) + return fmt.Errorf("can't append to requests batch: %s", err) + } + return nil +} + +func (c *connectorImpl) InsertCustom(session *types.Session, msg *messages.CustomEvent) error { + if err := c.batches["custom"].Append( + session.SessionID, + uint16(session.ProjectID), + msg.MessageID, + datetime(msg.Timestamp), + msg.Name, + msg.Payload, + "CUSTOM", + ); err != nil { + c.checkError("custom", err) + return fmt.Errorf("can't append to custom batch: %s", err) + } + return nil +} + +func (c *connectorImpl) InsertGraphQL(session *types.Session, msg *messages.GraphQLEvent) error { + if err := c.batches["graphql"].Append( + session.SessionID, + uint16(session.ProjectID), + msg.MessageID, + datetime(msg.Timestamp), + msg.OperationName, + nullableString(msg.Variables), + nullableString(msg.Response), + "GRAPHQL", + ); err != nil { + c.checkError("graphql", err) + return fmt.Errorf("can't append to graphql batch: %s", err) } return nil } @@ -414,3 +453,7 @@ func datetime(timestamp uint64) time.Time { } return t } + +func getSqIdx(messageID uint64) uint { + return uint(messageID % math.MaxInt32) +} diff --git a/ee/backend/pkg/failover/failover.go b/ee/backend/pkg/failover/failover.go index acee0dbbe..d69a2c86a 100644 --- a/ee/backend/pkg/failover/failover.go +++ b/ee/backend/pkg/failover/failover.go @@ -60,10 +60,12 @@ func NewSessionFinder(cfg *config.Config, stg *storage.Storage) (SessionFinder, []string{ cfg.TopicFailover, }, - func(sessionID uint64, msg messages.Message, meta *types.Meta) { - switch m := msg.(type) { - case *messages.SessionSearch: - finder.findSession(sessionID, m.Timestamp, m.Partition) + func(sessionID uint64, iter messages.Iterator, meta *types.Meta) { + for iter.Next() { + if iter.Type() == 127 { + m := iter.Message().Decode().(*messages.SessionSearch) + finder.findSession(sessionID, m.Timestamp, m.Partition) + } } }, true, diff --git a/ee/connectors/consumer.py b/ee/connectors/consumer.py index 99beb605e..c8d61ff7b 100644 --- a/ee/connectors/consumer.py +++ b/ee/connectors/consumer.py @@ -45,6 +45,7 @@ def main(): if messages is None: print('-') continue + for message in messages: if LEVEL == 'detailed': n = handle_message(message) @@ -118,6 +119,15 @@ def attempt_batch_insert(batch): except Exception as e: print(repr(e)) +def decode_key(b) -> int: + """ + Decode the message key (encoded with little endian) + """ + try: + decoded = int.from_bytes(b, "little", signed=False) + except Exception as e: + raise UnicodeDecodeError(f"Error while decoding message key (SessionID) from {b}\n{e}") + return decoded if __name__ == '__main__': main() diff --git a/ee/connectors/main.py b/ee/connectors/main.py index 57349f6e9..ef3a824d9 100644 --- a/ee/connectors/main.py +++ b/ee/connectors/main.py @@ -49,7 +49,7 @@ def main(): elif LEVEL == 'normal': n = handle_normal_message(message) - session_id = codec.decode_key(msg.key) + session_id = decode_key(msg.key) sessions[session_id] = handle_session(sessions[session_id], message) if sessions[session_id]: sessions[session_id].sessionid = session_id @@ -116,6 +116,15 @@ def attempt_batch_insert(batch): except Exception as e: print(repr(e)) +def decode_key(b) -> int: + """ + Decode the message key (encoded with little endian) + """ + try: + decoded = int.from_bytes(b, "little", signed=False) + except Exception as e: + raise UnicodeDecodeError(f"Error while decoding message key (SessionID) from {b}\n{e}") + return decoded if __name__ == '__main__': main() diff --git a/ee/connectors/msgcodec/messages.py b/ee/connectors/msgcodec/messages.py index 83f166ed8..f645e2995 100644 --- a/ee/connectors/msgcodec/messages.py +++ b/ee/connectors/msgcodec/messages.py @@ -692,6 +692,66 @@ class CreateIFrameDocument(Message): self.id = id +class AdoptedSSReplaceURLBased(Message): + __id__ = 71 + + def __init__(self, sheet_id, text, base_url): + self.sheet_id = sheet_id + self.text = text + self.base_url = base_url + + +class AdoptedSSReplace(Message): + __id__ = 72 + + def __init__(self, sheet_id, text): + self.sheet_id = sheet_id + self.text = text + + +class AdoptedSSInsertRuleURLBased(Message): + __id__ = 73 + + def __init__(self, sheet_id, rule, index, base_url): + self.sheet_id = sheet_id + self.rule = rule + self.index = index + self.base_url = base_url + + +class AdoptedSSInsertRule(Message): + __id__ = 74 + + def __init__(self, sheet_id, rule, index): + self.sheet_id = sheet_id + self.rule = rule + self.index = index + + +class AdoptedSSDeleteRule(Message): + __id__ = 75 + + def __init__(self, sheet_id, index): + self.sheet_id = sheet_id + self.index = index + + +class AdoptedSSAddOwner(Message): + __id__ = 76 + + def __init__(self, sheet_id, id): + self.sheet_id = sheet_id + self.id = id + + +class AdoptedSSRemoveOwner(Message): + __id__ = 77 + + def __init__(self, sheet_id, id): + self.sheet_id = sheet_id + self.id = id + + class IOSBatchMeta(Message): __id__ = 107 diff --git a/ee/connectors/msgcodec/msgcodec.py b/ee/connectors/msgcodec/msgcodec.py index b390643ce..76468682a 100644 --- a/ee/connectors/msgcodec/msgcodec.py +++ b/ee/connectors/msgcodec/msgcodec.py @@ -619,6 +619,52 @@ class MessageCodec(Codec): id=self.read_uint(reader) ) + if message_id == 71: + return AdoptedSSReplaceURLBased( + sheet_id=self.read_uint(reader), + text=self.read_string(reader), + base_url=self.read_string(reader) + ) + + if message_id == 72: + return AdoptedSSReplace( + sheet_id=self.read_uint(reader), + text=self.read_string(reader) + ) + + if message_id == 73: + return AdoptedSSInsertRuleURLBased( + sheet_id=self.read_uint(reader), + rule=self.read_string(reader), + index=self.read_uint(reader), + base_url=self.read_string(reader) + ) + + if message_id == 74: + return AdoptedSSInsertRule( + sheet_id=self.read_uint(reader), + rule=self.read_string(reader), + index=self.read_uint(reader) + ) + + if message_id == 75: + return AdoptedSSDeleteRule( + sheet_id=self.read_uint(reader), + index=self.read_uint(reader) + ) + + if message_id == 76: + return AdoptedSSAddOwner( + sheet_id=self.read_uint(reader), + id=self.read_uint(reader) + ) + + if message_id == 77: + return AdoptedSSRemoveOwner( + sheet_id=self.read_uint(reader), + id=self.read_uint(reader) + ) + if message_id == 107: return IOSBatchMeta( timestamp=self.read_uint(reader), diff --git a/ee/scripts/helm/db/init_dbs/clickhouse/1.8.0/1.8.0.sql b/ee/scripts/helm/db/init_dbs/clickhouse/1.8.0/1.8.0.sql new file mode 100644 index 000000000..912b1b7e6 --- /dev/null +++ b/ee/scripts/helm/db/init_dbs/clickhouse/1.8.0/1.8.0.sql @@ -0,0 +1,337 @@ +CREATE DATABASE IF NOT EXISTS experimental; + +CREATE TABLE IF NOT EXISTS experimental.autocomplete +( + project_id UInt16, + type LowCardinality(String), + value String, + _timestamp DateTime DEFAULT now() +) ENGINE = ReplacingMergeTree(_timestamp) + PARTITION BY toYYYYMM(_timestamp) + ORDER BY (project_id, type, value) + TTL _timestamp + INTERVAL 1 MONTH; + +CREATE TABLE IF NOT EXISTS experimental.events +( + session_id UInt64, + project_id UInt16, + event_type Enum8('CLICK'=0, 'INPUT'=1, 'LOCATION'=2,'REQUEST'=3,'PERFORMANCE'=4,'LONGTASK'=5,'ERROR'=6,'CUSTOM'=7, 'GRAPHQL'=8, 'STATEACTION'=9), + datetime DateTime, + label Nullable(String), + hesitation_time Nullable(UInt32), + name Nullable(String), + payload Nullable(String), + level Nullable(Enum8('info'=0, 'error'=1)) DEFAULT if(event_type == 'CUSTOM', 'info', null), + source Nullable(Enum8('js_exception'=0, 'bugsnag'=1, 'cloudwatch'=2, 'datadog'=3, 'elasticsearch'=4, 'newrelic'=5, 'rollbar'=6, 'sentry'=7, 'stackdriver'=8, 'sumologic'=9)), + message Nullable(String), + error_id Nullable(String), + duration Nullable(UInt16), + context Nullable(Enum8('unknown'=0, 'self'=1, 'same-origin-ancestor'=2, 'same-origin-descendant'=3, 'same-origin'=4, 'cross-origin-ancestor'=5, 'cross-origin-descendant'=6, 'cross-origin-unreachable'=7, 'multiple-contexts'=8)), + container_type Nullable(Enum8('window'=0, 'iframe'=1, 'embed'=2, 'object'=3)), + container_id Nullable(String), + container_name Nullable(String), + container_src Nullable(String), + url Nullable(String), + url_host Nullable(String) MATERIALIZED lower(domain(url)), + url_path Nullable(String) MATERIALIZED lower(pathFull(url)), + url_hostpath Nullable(String) MATERIALIZED concat(url_host, url_path), + request_start Nullable(UInt16), + response_start Nullable(UInt16), + response_end Nullable(UInt16), + dom_content_loaded_event_start Nullable(UInt16), + dom_content_loaded_event_end Nullable(UInt16), + load_event_start Nullable(UInt16), + load_event_end Nullable(UInt16), + first_paint Nullable(UInt16), + first_contentful_paint_time Nullable(UInt16), + speed_index Nullable(UInt16), + visually_complete Nullable(UInt16), + time_to_interactive Nullable(UInt16), + ttfb Nullable(UInt16) MATERIALIZED if(greaterOrEquals(response_start, request_start), + minus(response_start, request_start), Null), + ttlb Nullable(UInt16) MATERIALIZED if(greaterOrEquals(response_end, request_start), + minus(response_end, request_start), Null), + response_time Nullable(UInt16) MATERIALIZED if(greaterOrEquals(response_end, response_start), + minus(response_end, response_start), Null), + dom_building_time Nullable(UInt16) MATERIALIZED if( + greaterOrEquals(dom_content_loaded_event_start, response_end), + minus(dom_content_loaded_event_start, response_end), Null), + dom_content_loaded_event_time Nullable(UInt16) MATERIALIZED if( + greaterOrEquals(dom_content_loaded_event_end, dom_content_loaded_event_start), + minus(dom_content_loaded_event_end, dom_content_loaded_event_start), Null), + load_event_time Nullable(UInt16) MATERIALIZED if(greaterOrEquals(load_event_end, load_event_start), + minus(load_event_end, load_event_start), Null), + min_fps Nullable(UInt8), + avg_fps Nullable(UInt8), + max_fps Nullable(UInt8), + min_cpu Nullable(UInt8), + avg_cpu Nullable(UInt8), + max_cpu Nullable(UInt8), + min_total_js_heap_size Nullable(UInt64), + avg_total_js_heap_size Nullable(UInt64), + max_total_js_heap_size Nullable(UInt64), + min_used_js_heap_size Nullable(UInt64), + avg_used_js_heap_size Nullable(UInt64), + max_used_js_heap_size Nullable(UInt64), + method Nullable(Enum8('GET' = 0, 'HEAD' = 1, 'POST' = 2, 'PUT' = 3, 'DELETE' = 4, 'CONNECT' = 5, 'OPTIONS' = 6, 'TRACE' = 7, 'PATCH' = 8)), + status Nullable(UInt16), + success Nullable(UInt8), + request_body Nullable(String), + response_body Nullable(String), + _timestamp DateTime DEFAULT now() +) ENGINE = MergeTree + PARTITION BY toYYYYMM(datetime) + ORDER BY (project_id, datetime, event_type, session_id) + TTL datetime + INTERVAL 3 MONTH; + +CREATE TABLE IF NOT EXISTS experimental.resources +( + session_id UInt64, + project_id UInt16, + datetime DateTime, + url String, + url_host String MATERIALIZED lower(domain(url)), + url_path String MATERIALIZED lower(path(url)), + url_hostpath String MATERIALIZED concat(url_host, url_path), + type Enum8('other'=-1, 'script'=0, 'stylesheet'=1, 'fetch'=2, 'img'=3, 'media'=4), + name Nullable(String) MATERIALIZED if(type = 'fetch', null, + coalesce(nullIf(splitByChar('/', url_path)[-1], ''), + nullIf(splitByChar('/', url_path)[-2], ''))), + duration Nullable(UInt16), + ttfb Nullable(UInt16), + header_size Nullable(UInt16), + encoded_body_size Nullable(UInt32), + decoded_body_size Nullable(UInt32), + compression_ratio Nullable(Float32) MATERIALIZED divide(decoded_body_size, encoded_body_size), + success Nullable(UInt8) COMMENT 'currently available for type=img only', + _timestamp DateTime DEFAULT now() +) ENGINE = MergeTree + PARTITION BY toYYYYMM(datetime) + ORDER BY (project_id, datetime, type, session_id) + TTL datetime + INTERVAL 3 MONTH; + +CREATE TABLE IF NOT EXISTS experimental.sessions +( + session_id UInt64, + project_id UInt16, + tracker_version LowCardinality(String), + rev_id LowCardinality(Nullable(String)), + user_uuid UUID, + user_os LowCardinality(String), + user_os_version LowCardinality(Nullable(String)), + user_browser LowCardinality(String), + user_browser_version LowCardinality(Nullable(String)), + user_device Nullable(String), + user_device_type Enum8('other'=0, 'desktop'=1, 'mobile'=2), + user_country Enum8('UN'=-128, 'RW'=-127, 'SO'=-126, 'YE'=-125, 'IQ'=-124, 'SA'=-123, 'IR'=-122, 'CY'=-121, 'TZ'=-120, 'SY'=-119, 'AM'=-118, 'KE'=-117, 'CD'=-116, 'DJ'=-115, 'UG'=-114, 'CF'=-113, 'SC'=-112, 'JO'=-111, 'LB'=-110, 'KW'=-109, 'OM'=-108, 'QA'=-107, 'BH'=-106, 'AE'=-105, 'IL'=-104, 'TR'=-103, 'ET'=-102, 'ER'=-101, 'EG'=-100, 'SD'=-99, 'GR'=-98, 'BI'=-97, 'EE'=-96, 'LV'=-95, 'AZ'=-94, 'LT'=-93, 'SJ'=-92, 'GE'=-91, 'MD'=-90, 'BY'=-89, 'FI'=-88, 'AX'=-87, 'UA'=-86, 'MK'=-85, 'HU'=-84, 'BG'=-83, 'AL'=-82, 'PL'=-81, 'RO'=-80, 'XK'=-79, 'ZW'=-78, 'ZM'=-77, 'KM'=-76, 'MW'=-75, 'LS'=-74, 'BW'=-73, 'MU'=-72, 'SZ'=-71, 'RE'=-70, 'ZA'=-69, 'YT'=-68, 'MZ'=-67, 'MG'=-66, 'AF'=-65, 'PK'=-64, 'BD'=-63, 'TM'=-62, 'TJ'=-61, 'LK'=-60, 'BT'=-59, 'IN'=-58, 'MV'=-57, 'IO'=-56, 'NP'=-55, 'MM'=-54, 'UZ'=-53, 'KZ'=-52, 'KG'=-51, 'TF'=-50, 'HM'=-49, 'CC'=-48, 'PW'=-47, 'VN'=-46, 'TH'=-45, 'ID'=-44, 'LA'=-43, 'TW'=-42, 'PH'=-41, 'MY'=-40, 'CN'=-39, 'HK'=-38, 'BN'=-37, 'MO'=-36, 'KH'=-35, 'KR'=-34, 'JP'=-33, 'KP'=-32, 'SG'=-31, 'CK'=-30, 'TL'=-29, 'RU'=-28, 'MN'=-27, 'AU'=-26, 'CX'=-25, 'MH'=-24, 'FM'=-23, 'PG'=-22, 'SB'=-21, 'TV'=-20, 'NR'=-19, 'VU'=-18, 'NC'=-17, 'NF'=-16, 'NZ'=-15, 'FJ'=-14, 'LY'=-13, 'CM'=-12, 'SN'=-11, 'CG'=-10, 'PT'=-9, 'LR'=-8, 'CI'=-7, 'GH'=-6, 'GQ'=-5, 'NG'=-4, 'BF'=-3, 'TG'=-2, 'GW'=-1, 'MR'=0, 'BJ'=1, 'GA'=2, 'SL'=3, 'ST'=4, 'GI'=5, 'GM'=6, 'GN'=7, 'TD'=8, 'NE'=9, 'ML'=10, 'EH'=11, 'TN'=12, 'ES'=13, 'MA'=14, 'MT'=15, 'DZ'=16, 'FO'=17, 'DK'=18, 'IS'=19, 'GB'=20, 'CH'=21, 'SE'=22, 'NL'=23, 'AT'=24, 'BE'=25, 'DE'=26, 'LU'=27, 'IE'=28, 'MC'=29, 'FR'=30, 'AD'=31, 'LI'=32, 'JE'=33, 'IM'=34, 'GG'=35, 'SK'=36, 'CZ'=37, 'NO'=38, 'VA'=39, 'SM'=40, 'IT'=41, 'SI'=42, 'ME'=43, 'HR'=44, 'BA'=45, 'AO'=46, 'NA'=47, 'SH'=48, 'BV'=49, 'BB'=50, 'CV'=51, 'GY'=52, 'GF'=53, 'SR'=54, 'PM'=55, 'GL'=56, 'PY'=57, 'UY'=58, 'BR'=59, 'FK'=60, 'GS'=61, 'JM'=62, 'DO'=63, 'CU'=64, 'MQ'=65, 'BS'=66, 'BM'=67, 'AI'=68, 'TT'=69, 'KN'=70, 'DM'=71, 'AG'=72, 'LC'=73, 'TC'=74, 'AW'=75, 'VG'=76, 'VC'=77, 'MS'=78, 'MF'=79, 'BL'=80, 'GP'=81, 'GD'=82, 'KY'=83, 'BZ'=84, 'SV'=85, 'GT'=86, 'HN'=87, 'NI'=88, 'CR'=89, 'VE'=90, 'EC'=91, 'CO'=92, 'PA'=93, 'HT'=94, 'AR'=95, 'CL'=96, 'BO'=97, 'PE'=98, 'MX'=99, 'PF'=100, 'PN'=101, 'KI'=102, 'TK'=103, 'TO'=104, 'WF'=105, 'WS'=106, 'NU'=107, 'MP'=108, 'GU'=109, 'PR'=110, 'VI'=111, 'UM'=112, 'AS'=113, 'CA'=114, 'US'=115, 'PS'=116, 'RS'=117, 'AQ'=118, 'SX'=119, 'CW'=120, 'BQ'=121, 'SS'=122), + platform Enum8('web'=1,'ios'=2,'android'=3) DEFAULT 'web', + datetime DateTime, + duration UInt32, + pages_count UInt16, + events_count UInt16, + errors_count UInt16, + utm_source Nullable(String), + utm_medium Nullable(String), + utm_campaign Nullable(String), + user_id Nullable(String), + user_anonymous_id Nullable(String), + metadata_1 Nullable(String), + metadata_2 Nullable(String), + metadata_3 Nullable(String), + metadata_4 Nullable(String), + metadata_5 Nullable(String), + metadata_6 Nullable(String), + metadata_7 Nullable(String), + metadata_8 Nullable(String), + metadata_9 Nullable(String), + metadata_10 Nullable(String), + issue_types Array(LowCardinality(String)), + referrer Nullable(String), + base_referrer Nullable(String) MATERIALIZED lower(concat(domain(referrer), path(referrer))), + issue_score Nullable(UInt32), + _timestamp DateTime DEFAULT now() +) ENGINE = ReplacingMergeTree(_timestamp) + PARTITION BY toYYYYMMDD(datetime) + ORDER BY (project_id, datetime, session_id) + TTL datetime + INTERVAL 3 MONTH + SETTINGS index_granularity = 512; + +CREATE TABLE IF NOT EXISTS experimental.user_favorite_sessions +( + project_id UInt16, + user_id UInt32, + session_id UInt64, + _timestamp DateTime DEFAULT now(), + sign Int8 +) ENGINE = CollapsingMergeTree(sign) + PARTITION BY toYYYYMM(_timestamp) + ORDER BY (project_id, user_id, session_id) + TTL _timestamp + INTERVAL 3 MONTH; + +CREATE TABLE IF NOT EXISTS experimental.user_viewed_sessions +( + project_id UInt16, + user_id UInt32, + session_id UInt64, + _timestamp DateTime DEFAULT now() +) ENGINE = ReplacingMergeTree(_timestamp) + PARTITION BY toYYYYMM(_timestamp) + ORDER BY (project_id, user_id, session_id) + TTL _timestamp + INTERVAL 3 MONTH; + +CREATE TABLE IF NOT EXISTS experimental.user_viewed_errors +( + project_id UInt16, + user_id UInt32, + error_id String, + _timestamp DateTime DEFAULT now() +) ENGINE = ReplacingMergeTree(_timestamp) + PARTITION BY toYYYYMM(_timestamp) + ORDER BY (project_id, user_id, error_id) + TTL _timestamp + INTERVAL 3 MONTH; + +CREATE MATERIALIZED VIEW IF NOT EXISTS experimental.events_l7d_mv + ENGINE = MergeTree + PARTITION BY toYYYYMM(datetime) + ORDER BY (project_id, datetime, event_type, session_id) + TTL datetime + INTERVAL 7 DAY + POPULATE +AS +SELECT session_id, + project_id, + event_type, + datetime, + label, + hesitation_time, + name, + payload, + level, + source, + message, + error_id, + duration, + context, + container_type, + container_id, + container_name, + container_src, + url, + url_host, + url_path, + url_hostpath, + request_start, + response_start, + response_end, + dom_content_loaded_event_start, + dom_content_loaded_event_end, + load_event_start, + load_event_end, + first_paint, + first_contentful_paint_time, + speed_index, + visually_complete, + time_to_interactive, + ttfb, + ttlb, + response_time, + dom_building_time, + dom_content_loaded_event_time, + load_event_time, + min_fps, + avg_fps, + max_fps, + min_cpu, + avg_cpu, + max_cpu, + min_total_js_heap_size, + avg_total_js_heap_size, + max_total_js_heap_size, + min_used_js_heap_size, + avg_used_js_heap_size, + max_used_js_heap_size, + method, + status, + success, + request_body, + response_body, + _timestamp +FROM experimental.events +WHERE datetime >= now() - INTERVAL 7 DAY; + +CREATE MATERIALIZED VIEW IF NOT EXISTS experimental.resources_l7d_mv + ENGINE = MergeTree + PARTITION BY toYYYYMM(datetime) + ORDER BY (project_id, datetime, type, session_id) + TTL datetime + INTERVAL 7 DAY + POPULATE +AS +SELECT session_id, + project_id, + datetime, + url, + url_host, + url_path, + url_hostpath, + type, + name, + duration, + ttfb, + header_size, + encoded_body_size, + decoded_body_size, + compression_ratio, + success, + _timestamp +FROM experimental.resources +WHERE datetime >= now() - INTERVAL 7 DAY; + +CREATE MATERIALIZED VIEW IF NOT EXISTS experimental.sessions_l7d_mv + ENGINE = ReplacingMergeTree(_timestamp) + PARTITION BY toYYYYMMDD(datetime) + ORDER BY (project_id, datetime, session_id) + TTL datetime + INTERVAL 7 DAY + SETTINGS index_granularity = 512 + POPULATE +AS +SELECT session_id, + project_id, + tracker_version, + rev_id, + user_uuid, + user_os, + user_os_version, + user_browser, + user_browser_version, + user_device, + user_device_type, + user_country, + platform, + datetime, + duration, + pages_count, + events_count, + errors_count, + utm_source, + utm_medium, + utm_campaign, + user_id, + user_anonymous_id, + metadata_1, + metadata_2, + metadata_3, + metadata_4, + metadata_5, + metadata_6, + metadata_7, + metadata_8, + metadata_9, + metadata_10, + issue_types, + referrer, + base_referrer, + issue_score, + _timestamp +FROM experimental.sessions +WHERE datetime >= now() - INTERVAL 7 DAY + AND isNotNull(duration) + AND duration > 0; \ No newline at end of file diff --git a/ee/scripts/helm/db/init_dbs/clickhouse/create/clicks.sql b/ee/scripts/helm/db/init_dbs/clickhouse/create/clicks.sql deleted file mode 100644 index b9322a403..000000000 --- a/ee/scripts/helm/db/init_dbs/clickhouse/create/clicks.sql +++ /dev/null @@ -1,21 +0,0 @@ -CREATE TABLE IF NOT EXISTS clicks -( - session_id UInt64, - project_id UInt32, - tracker_version String, - rev_id Nullable(String), - user_uuid UUID, - user_os String, - user_os_version Nullable(String), - user_browser String, - user_browser_version Nullable(String), - user_device Nullable(String), - user_device_type Enum8('other'=0, 'desktop'=1, 'mobile'=2), - user_country Enum8('UN'=-128, 'RW'=-127, 'SO'=-126, 'YE'=-125, 'IQ'=-124, 'SA'=-123, 'IR'=-122, 'CY'=-121, 'TZ'=-120, 'SY'=-119, 'AM'=-118, 'KE'=-117, 'CD'=-116, 'DJ'=-115, 'UG'=-114, 'CF'=-113, 'SC'=-112, 'JO'=-111, 'LB'=-110, 'KW'=-109, 'OM'=-108, 'QA'=-107, 'BH'=-106, 'AE'=-105, 'IL'=-104, 'TR'=-103, 'ET'=-102, 'ER'=-101, 'EG'=-100, 'SD'=-99, 'GR'=-98, 'BI'=-97, 'EE'=-96, 'LV'=-95, 'AZ'=-94, 'LT'=-93, 'SJ'=-92, 'GE'=-91, 'MD'=-90, 'BY'=-89, 'FI'=-88, 'AX'=-87, 'UA'=-86, 'MK'=-85, 'HU'=-84, 'BG'=-83, 'AL'=-82, 'PL'=-81, 'RO'=-80, 'XK'=-79, 'ZW'=-78, 'ZM'=-77, 'KM'=-76, 'MW'=-75, 'LS'=-74, 'BW'=-73, 'MU'=-72, 'SZ'=-71, 'RE'=-70, 'ZA'=-69, 'YT'=-68, 'MZ'=-67, 'MG'=-66, 'AF'=-65, 'PK'=-64, 'BD'=-63, 'TM'=-62, 'TJ'=-61, 'LK'=-60, 'BT'=-59, 'IN'=-58, 'MV'=-57, 'IO'=-56, 'NP'=-55, 'MM'=-54, 'UZ'=-53, 'KZ'=-52, 'KG'=-51, 'TF'=-50, 'HM'=-49, 'CC'=-48, 'PW'=-47, 'VN'=-46, 'TH'=-45, 'ID'=-44, 'LA'=-43, 'TW'=-42, 'PH'=-41, 'MY'=-40, 'CN'=-39, 'HK'=-38, 'BN'=-37, 'MO'=-36, 'KH'=-35, 'KR'=-34, 'JP'=-33, 'KP'=-32, 'SG'=-31, 'CK'=-30, 'TL'=-29, 'RU'=-28, 'MN'=-27, 'AU'=-26, 'CX'=-25, 'MH'=-24, 'FM'=-23, 'PG'=-22, 'SB'=-21, 'TV'=-20, 'NR'=-19, 'VU'=-18, 'NC'=-17, 'NF'=-16, 'NZ'=-15, 'FJ'=-14, 'LY'=-13, 'CM'=-12, 'SN'=-11, 'CG'=-10, 'PT'=-9, 'LR'=-8, 'CI'=-7, 'GH'=-6, 'GQ'=-5, 'NG'=-4, 'BF'=-3, 'TG'=-2, 'GW'=-1, 'MR'=0, 'BJ'=1, 'GA'=2, 'SL'=3, 'ST'=4, 'GI'=5, 'GM'=6, 'GN'=7, 'TD'=8, 'NE'=9, 'ML'=10, 'EH'=11, 'TN'=12, 'ES'=13, 'MA'=14, 'MT'=15, 'DZ'=16, 'FO'=17, 'DK'=18, 'IS'=19, 'GB'=20, 'CH'=21, 'SE'=22, 'NL'=23, 'AT'=24, 'BE'=25, 'DE'=26, 'LU'=27, 'IE'=28, 'MC'=29, 'FR'=30, 'AD'=31, 'LI'=32, 'JE'=33, 'IM'=34, 'GG'=35, 'SK'=36, 'CZ'=37, 'NO'=38, 'VA'=39, 'SM'=40, 'IT'=41, 'SI'=42, 'ME'=43, 'HR'=44, 'BA'=45, 'AO'=46, 'NA'=47, 'SH'=48, 'BV'=49, 'BB'=50, 'CV'=51, 'GY'=52, 'GF'=53, 'SR'=54, 'PM'=55, 'GL'=56, 'PY'=57, 'UY'=58, 'BR'=59, 'FK'=60, 'GS'=61, 'JM'=62, 'DO'=63, 'CU'=64, 'MQ'=65, 'BS'=66, 'BM'=67, 'AI'=68, 'TT'=69, 'KN'=70, 'DM'=71, 'AG'=72, 'LC'=73, 'TC'=74, 'AW'=75, 'VG'=76, 'VC'=77, 'MS'=78, 'MF'=79, 'BL'=80, 'GP'=81, 'GD'=82, 'KY'=83, 'BZ'=84, 'SV'=85, 'GT'=86, 'HN'=87, 'NI'=88, 'CR'=89, 'VE'=90, 'EC'=91, 'CO'=92, 'PA'=93, 'HT'=94, 'AR'=95, 'CL'=96, 'BO'=97, 'PE'=98, 'MX'=99, 'PF'=100, 'PN'=101, 'KI'=102, 'TK'=103, 'TO'=104, 'WF'=105, 'WS'=106, 'NU'=107, 'MP'=108, 'GU'=109, 'PR'=110, 'VI'=111, 'UM'=112, 'AS'=113, 'CA'=114, 'US'=115, 'PS'=116, 'RS'=117, 'AQ'=118, 'SX'=119, 'CW'=120, 'BQ'=121, 'SS'=122), - datetime DateTime, - label String, - hesitation_time Nullable(UInt32) -) ENGINE = MergeTree - PARTITION BY toStartOfWeek(datetime) - ORDER BY (project_id, datetime) - TTL datetime + INTERVAL 1 MONTH; diff --git a/ee/scripts/helm/db/init_dbs/clickhouse/create/customs.sql b/ee/scripts/helm/db/init_dbs/clickhouse/create/customs.sql deleted file mode 100644 index fb4b2c881..000000000 --- a/ee/scripts/helm/db/init_dbs/clickhouse/create/customs.sql +++ /dev/null @@ -1,22 +0,0 @@ -CREATE TABLE IF NOT EXISTS customs -( - session_id UInt64, - project_id UInt32, - tracker_version String, - rev_id Nullable(String), - user_uuid UUID, - user_os String, - user_os_version Nullable(String), - user_browser String, - user_browser_version Nullable(String), - user_device Nullable(String), - user_device_type Enum8('other'=0, 'desktop'=1, 'mobile'=2), - user_country Enum8('UN'=-128, 'RW'=-127, 'SO'=-126, 'YE'=-125, 'IQ'=-124, 'SA'=-123, 'IR'=-122, 'CY'=-121, 'TZ'=-120, 'SY'=-119, 'AM'=-118, 'KE'=-117, 'CD'=-116, 'DJ'=-115, 'UG'=-114, 'CF'=-113, 'SC'=-112, 'JO'=-111, 'LB'=-110, 'KW'=-109, 'OM'=-108, 'QA'=-107, 'BH'=-106, 'AE'=-105, 'IL'=-104, 'TR'=-103, 'ET'=-102, 'ER'=-101, 'EG'=-100, 'SD'=-99, 'GR'=-98, 'BI'=-97, 'EE'=-96, 'LV'=-95, 'AZ'=-94, 'LT'=-93, 'SJ'=-92, 'GE'=-91, 'MD'=-90, 'BY'=-89, 'FI'=-88, 'AX'=-87, 'UA'=-86, 'MK'=-85, 'HU'=-84, 'BG'=-83, 'AL'=-82, 'PL'=-81, 'RO'=-80, 'XK'=-79, 'ZW'=-78, 'ZM'=-77, 'KM'=-76, 'MW'=-75, 'LS'=-74, 'BW'=-73, 'MU'=-72, 'SZ'=-71, 'RE'=-70, 'ZA'=-69, 'YT'=-68, 'MZ'=-67, 'MG'=-66, 'AF'=-65, 'PK'=-64, 'BD'=-63, 'TM'=-62, 'TJ'=-61, 'LK'=-60, 'BT'=-59, 'IN'=-58, 'MV'=-57, 'IO'=-56, 'NP'=-55, 'MM'=-54, 'UZ'=-53, 'KZ'=-52, 'KG'=-51, 'TF'=-50, 'HM'=-49, 'CC'=-48, 'PW'=-47, 'VN'=-46, 'TH'=-45, 'ID'=-44, 'LA'=-43, 'TW'=-42, 'PH'=-41, 'MY'=-40, 'CN'=-39, 'HK'=-38, 'BN'=-37, 'MO'=-36, 'KH'=-35, 'KR'=-34, 'JP'=-33, 'KP'=-32, 'SG'=-31, 'CK'=-30, 'TL'=-29, 'RU'=-28, 'MN'=-27, 'AU'=-26, 'CX'=-25, 'MH'=-24, 'FM'=-23, 'PG'=-22, 'SB'=-21, 'TV'=-20, 'NR'=-19, 'VU'=-18, 'NC'=-17, 'NF'=-16, 'NZ'=-15, 'FJ'=-14, 'LY'=-13, 'CM'=-12, 'SN'=-11, 'CG'=-10, 'PT'=-9, 'LR'=-8, 'CI'=-7, 'GH'=-6, 'GQ'=-5, 'NG'=-4, 'BF'=-3, 'TG'=-2, 'GW'=-1, 'MR'=0, 'BJ'=1, 'GA'=2, 'SL'=3, 'ST'=4, 'GI'=5, 'GM'=6, 'GN'=7, 'TD'=8, 'NE'=9, 'ML'=10, 'EH'=11, 'TN'=12, 'ES'=13, 'MA'=14, 'MT'=15, 'DZ'=16, 'FO'=17, 'DK'=18, 'IS'=19, 'GB'=20, 'CH'=21, 'SE'=22, 'NL'=23, 'AT'=24, 'BE'=25, 'DE'=26, 'LU'=27, 'IE'=28, 'MC'=29, 'FR'=30, 'AD'=31, 'LI'=32, 'JE'=33, 'IM'=34, 'GG'=35, 'SK'=36, 'CZ'=37, 'NO'=38, 'VA'=39, 'SM'=40, 'IT'=41, 'SI'=42, 'ME'=43, 'HR'=44, 'BA'=45, 'AO'=46, 'NA'=47, 'SH'=48, 'BV'=49, 'BB'=50, 'CV'=51, 'GY'=52, 'GF'=53, 'SR'=54, 'PM'=55, 'GL'=56, 'PY'=57, 'UY'=58, 'BR'=59, 'FK'=60, 'GS'=61, 'JM'=62, 'DO'=63, 'CU'=64, 'MQ'=65, 'BS'=66, 'BM'=67, 'AI'=68, 'TT'=69, 'KN'=70, 'DM'=71, 'AG'=72, 'LC'=73, 'TC'=74, 'AW'=75, 'VG'=76, 'VC'=77, 'MS'=78, 'MF'=79, 'BL'=80, 'GP'=81, 'GD'=82, 'KY'=83, 'BZ'=84, 'SV'=85, 'GT'=86, 'HN'=87, 'NI'=88, 'CR'=89, 'VE'=90, 'EC'=91, 'CO'=92, 'PA'=93, 'HT'=94, 'AR'=95, 'CL'=96, 'BO'=97, 'PE'=98, 'MX'=99, 'PF'=100, 'PN'=101, 'KI'=102, 'TK'=103, 'TO'=104, 'WF'=105, 'WS'=106, 'NU'=107, 'MP'=108, 'GU'=109, 'PR'=110, 'VI'=111, 'UM'=112, 'AS'=113, 'CA'=114, 'US'=115, 'PS'=116, 'RS'=117, 'AQ'=118, 'SX'=119, 'CW'=120, 'BQ'=121, 'SS'=122), - datetime DateTime, - name Nullable(String), - payload Nullable(String), - level Enum8('info'=0, 'error'=1) DEFAULT 'info' -) ENGINE = MergeTree - PARTITION BY toStartOfWeek(datetime) - ORDER BY (project_id, datetime) - TTL datetime + INTERVAL 1 MONTH; \ No newline at end of file diff --git a/ee/scripts/helm/db/init_dbs/clickhouse/create/errors.sql b/ee/scripts/helm/db/init_dbs/clickhouse/create/errors.sql deleted file mode 100644 index 98052071a..000000000 --- a/ee/scripts/helm/db/init_dbs/clickhouse/create/errors.sql +++ /dev/null @@ -1,23 +0,0 @@ -CREATE TABLE IF NOT EXISTS errors -( - session_id UInt64, - project_id UInt32, - tracker_version String, - rev_id Nullable(String), - user_uuid UUID, - user_os String, - user_os_version Nullable(String), - user_browser String, - user_browser_version Nullable(String), - user_device Nullable(String), - user_device_type Enum8('other'=0, 'desktop'=1, 'mobile'=2), - user_country Enum8('UN'=-128, 'RW'=-127, 'SO'=-126, 'YE'=-125, 'IQ'=-124, 'SA'=-123, 'IR'=-122, 'CY'=-121, 'TZ'=-120, 'SY'=-119, 'AM'=-118, 'KE'=-117, 'CD'=-116, 'DJ'=-115, 'UG'=-114, 'CF'=-113, 'SC'=-112, 'JO'=-111, 'LB'=-110, 'KW'=-109, 'OM'=-108, 'QA'=-107, 'BH'=-106, 'AE'=-105, 'IL'=-104, 'TR'=-103, 'ET'=-102, 'ER'=-101, 'EG'=-100, 'SD'=-99, 'GR'=-98, 'BI'=-97, 'EE'=-96, 'LV'=-95, 'AZ'=-94, 'LT'=-93, 'SJ'=-92, 'GE'=-91, 'MD'=-90, 'BY'=-89, 'FI'=-88, 'AX'=-87, 'UA'=-86, 'MK'=-85, 'HU'=-84, 'BG'=-83, 'AL'=-82, 'PL'=-81, 'RO'=-80, 'XK'=-79, 'ZW'=-78, 'ZM'=-77, 'KM'=-76, 'MW'=-75, 'LS'=-74, 'BW'=-73, 'MU'=-72, 'SZ'=-71, 'RE'=-70, 'ZA'=-69, 'YT'=-68, 'MZ'=-67, 'MG'=-66, 'AF'=-65, 'PK'=-64, 'BD'=-63, 'TM'=-62, 'TJ'=-61, 'LK'=-60, 'BT'=-59, 'IN'=-58, 'MV'=-57, 'IO'=-56, 'NP'=-55, 'MM'=-54, 'UZ'=-53, 'KZ'=-52, 'KG'=-51, 'TF'=-50, 'HM'=-49, 'CC'=-48, 'PW'=-47, 'VN'=-46, 'TH'=-45, 'ID'=-44, 'LA'=-43, 'TW'=-42, 'PH'=-41, 'MY'=-40, 'CN'=-39, 'HK'=-38, 'BN'=-37, 'MO'=-36, 'KH'=-35, 'KR'=-34, 'JP'=-33, 'KP'=-32, 'SG'=-31, 'CK'=-30, 'TL'=-29, 'RU'=-28, 'MN'=-27, 'AU'=-26, 'CX'=-25, 'MH'=-24, 'FM'=-23, 'PG'=-22, 'SB'=-21, 'TV'=-20, 'NR'=-19, 'VU'=-18, 'NC'=-17, 'NF'=-16, 'NZ'=-15, 'FJ'=-14, 'LY'=-13, 'CM'=-12, 'SN'=-11, 'CG'=-10, 'PT'=-9, 'LR'=-8, 'CI'=-7, 'GH'=-6, 'GQ'=-5, 'NG'=-4, 'BF'=-3, 'TG'=-2, 'GW'=-1, 'MR'=0, 'BJ'=1, 'GA'=2, 'SL'=3, 'ST'=4, 'GI'=5, 'GM'=6, 'GN'=7, 'TD'=8, 'NE'=9, 'ML'=10, 'EH'=11, 'TN'=12, 'ES'=13, 'MA'=14, 'MT'=15, 'DZ'=16, 'FO'=17, 'DK'=18, 'IS'=19, 'GB'=20, 'CH'=21, 'SE'=22, 'NL'=23, 'AT'=24, 'BE'=25, 'DE'=26, 'LU'=27, 'IE'=28, 'MC'=29, 'FR'=30, 'AD'=31, 'LI'=32, 'JE'=33, 'IM'=34, 'GG'=35, 'SK'=36, 'CZ'=37, 'NO'=38, 'VA'=39, 'SM'=40, 'IT'=41, 'SI'=42, 'ME'=43, 'HR'=44, 'BA'=45, 'AO'=46, 'NA'=47, 'SH'=48, 'BV'=49, 'BB'=50, 'CV'=51, 'GY'=52, 'GF'=53, 'SR'=54, 'PM'=55, 'GL'=56, 'PY'=57, 'UY'=58, 'BR'=59, 'FK'=60, 'GS'=61, 'JM'=62, 'DO'=63, 'CU'=64, 'MQ'=65, 'BS'=66, 'BM'=67, 'AI'=68, 'TT'=69, 'KN'=70, 'DM'=71, 'AG'=72, 'LC'=73, 'TC'=74, 'AW'=75, 'VG'=76, 'VC'=77, 'MS'=78, 'MF'=79, 'BL'=80, 'GP'=81, 'GD'=82, 'KY'=83, 'BZ'=84, 'SV'=85, 'GT'=86, 'HN'=87, 'NI'=88, 'CR'=89, 'VE'=90, 'EC'=91, 'CO'=92, 'PA'=93, 'HT'=94, 'AR'=95, 'CL'=96, 'BO'=97, 'PE'=98, 'MX'=99, 'PF'=100, 'PN'=101, 'KI'=102, 'TK'=103, 'TO'=104, 'WF'=105, 'WS'=106, 'NU'=107, 'MP'=108, 'GU'=109, 'PR'=110, 'VI'=111, 'UM'=112, 'AS'=113, 'CA'=114, 'US'=115, 'PS'=116, 'RS'=117, 'AQ'=118, 'SX'=119, 'CW'=120, 'BQ'=121, 'SS'=122), - datetime DateTime, - source Enum8('js_exception'=0, 'bugsnag'=1, 'cloudwatch'=2, 'datadog'=3, 'elasticsearch'=4, 'newrelic'=5, 'rollbar'=6, 'sentry'=7, 'stackdriver'=8, 'sumologic'=9), - name Nullable(String), - message String, - error_id String -) ENGINE = MergeTree - PARTITION BY toStartOfWeek(datetime) - ORDER BY (project_id, datetime) - TTL datetime + INTERVAL 1 MONTH; diff --git a/ee/scripts/helm/db/init_dbs/clickhouse/create/init_schema.sql b/ee/scripts/helm/db/init_dbs/clickhouse/create/init_schema.sql new file mode 100644 index 000000000..912b1b7e6 --- /dev/null +++ b/ee/scripts/helm/db/init_dbs/clickhouse/create/init_schema.sql @@ -0,0 +1,337 @@ +CREATE DATABASE IF NOT EXISTS experimental; + +CREATE TABLE IF NOT EXISTS experimental.autocomplete +( + project_id UInt16, + type LowCardinality(String), + value String, + _timestamp DateTime DEFAULT now() +) ENGINE = ReplacingMergeTree(_timestamp) + PARTITION BY toYYYYMM(_timestamp) + ORDER BY (project_id, type, value) + TTL _timestamp + INTERVAL 1 MONTH; + +CREATE TABLE IF NOT EXISTS experimental.events +( + session_id UInt64, + project_id UInt16, + event_type Enum8('CLICK'=0, 'INPUT'=1, 'LOCATION'=2,'REQUEST'=3,'PERFORMANCE'=4,'LONGTASK'=5,'ERROR'=6,'CUSTOM'=7, 'GRAPHQL'=8, 'STATEACTION'=9), + datetime DateTime, + label Nullable(String), + hesitation_time Nullable(UInt32), + name Nullable(String), + payload Nullable(String), + level Nullable(Enum8('info'=0, 'error'=1)) DEFAULT if(event_type == 'CUSTOM', 'info', null), + source Nullable(Enum8('js_exception'=0, 'bugsnag'=1, 'cloudwatch'=2, 'datadog'=3, 'elasticsearch'=4, 'newrelic'=5, 'rollbar'=6, 'sentry'=7, 'stackdriver'=8, 'sumologic'=9)), + message Nullable(String), + error_id Nullable(String), + duration Nullable(UInt16), + context Nullable(Enum8('unknown'=0, 'self'=1, 'same-origin-ancestor'=2, 'same-origin-descendant'=3, 'same-origin'=4, 'cross-origin-ancestor'=5, 'cross-origin-descendant'=6, 'cross-origin-unreachable'=7, 'multiple-contexts'=8)), + container_type Nullable(Enum8('window'=0, 'iframe'=1, 'embed'=2, 'object'=3)), + container_id Nullable(String), + container_name Nullable(String), + container_src Nullable(String), + url Nullable(String), + url_host Nullable(String) MATERIALIZED lower(domain(url)), + url_path Nullable(String) MATERIALIZED lower(pathFull(url)), + url_hostpath Nullable(String) MATERIALIZED concat(url_host, url_path), + request_start Nullable(UInt16), + response_start Nullable(UInt16), + response_end Nullable(UInt16), + dom_content_loaded_event_start Nullable(UInt16), + dom_content_loaded_event_end Nullable(UInt16), + load_event_start Nullable(UInt16), + load_event_end Nullable(UInt16), + first_paint Nullable(UInt16), + first_contentful_paint_time Nullable(UInt16), + speed_index Nullable(UInt16), + visually_complete Nullable(UInt16), + time_to_interactive Nullable(UInt16), + ttfb Nullable(UInt16) MATERIALIZED if(greaterOrEquals(response_start, request_start), + minus(response_start, request_start), Null), + ttlb Nullable(UInt16) MATERIALIZED if(greaterOrEquals(response_end, request_start), + minus(response_end, request_start), Null), + response_time Nullable(UInt16) MATERIALIZED if(greaterOrEquals(response_end, response_start), + minus(response_end, response_start), Null), + dom_building_time Nullable(UInt16) MATERIALIZED if( + greaterOrEquals(dom_content_loaded_event_start, response_end), + minus(dom_content_loaded_event_start, response_end), Null), + dom_content_loaded_event_time Nullable(UInt16) MATERIALIZED if( + greaterOrEquals(dom_content_loaded_event_end, dom_content_loaded_event_start), + minus(dom_content_loaded_event_end, dom_content_loaded_event_start), Null), + load_event_time Nullable(UInt16) MATERIALIZED if(greaterOrEquals(load_event_end, load_event_start), + minus(load_event_end, load_event_start), Null), + min_fps Nullable(UInt8), + avg_fps Nullable(UInt8), + max_fps Nullable(UInt8), + min_cpu Nullable(UInt8), + avg_cpu Nullable(UInt8), + max_cpu Nullable(UInt8), + min_total_js_heap_size Nullable(UInt64), + avg_total_js_heap_size Nullable(UInt64), + max_total_js_heap_size Nullable(UInt64), + min_used_js_heap_size Nullable(UInt64), + avg_used_js_heap_size Nullable(UInt64), + max_used_js_heap_size Nullable(UInt64), + method Nullable(Enum8('GET' = 0, 'HEAD' = 1, 'POST' = 2, 'PUT' = 3, 'DELETE' = 4, 'CONNECT' = 5, 'OPTIONS' = 6, 'TRACE' = 7, 'PATCH' = 8)), + status Nullable(UInt16), + success Nullable(UInt8), + request_body Nullable(String), + response_body Nullable(String), + _timestamp DateTime DEFAULT now() +) ENGINE = MergeTree + PARTITION BY toYYYYMM(datetime) + ORDER BY (project_id, datetime, event_type, session_id) + TTL datetime + INTERVAL 3 MONTH; + +CREATE TABLE IF NOT EXISTS experimental.resources +( + session_id UInt64, + project_id UInt16, + datetime DateTime, + url String, + url_host String MATERIALIZED lower(domain(url)), + url_path String MATERIALIZED lower(path(url)), + url_hostpath String MATERIALIZED concat(url_host, url_path), + type Enum8('other'=-1, 'script'=0, 'stylesheet'=1, 'fetch'=2, 'img'=3, 'media'=4), + name Nullable(String) MATERIALIZED if(type = 'fetch', null, + coalesce(nullIf(splitByChar('/', url_path)[-1], ''), + nullIf(splitByChar('/', url_path)[-2], ''))), + duration Nullable(UInt16), + ttfb Nullable(UInt16), + header_size Nullable(UInt16), + encoded_body_size Nullable(UInt32), + decoded_body_size Nullable(UInt32), + compression_ratio Nullable(Float32) MATERIALIZED divide(decoded_body_size, encoded_body_size), + success Nullable(UInt8) COMMENT 'currently available for type=img only', + _timestamp DateTime DEFAULT now() +) ENGINE = MergeTree + PARTITION BY toYYYYMM(datetime) + ORDER BY (project_id, datetime, type, session_id) + TTL datetime + INTERVAL 3 MONTH; + +CREATE TABLE IF NOT EXISTS experimental.sessions +( + session_id UInt64, + project_id UInt16, + tracker_version LowCardinality(String), + rev_id LowCardinality(Nullable(String)), + user_uuid UUID, + user_os LowCardinality(String), + user_os_version LowCardinality(Nullable(String)), + user_browser LowCardinality(String), + user_browser_version LowCardinality(Nullable(String)), + user_device Nullable(String), + user_device_type Enum8('other'=0, 'desktop'=1, 'mobile'=2), + user_country Enum8('UN'=-128, 'RW'=-127, 'SO'=-126, 'YE'=-125, 'IQ'=-124, 'SA'=-123, 'IR'=-122, 'CY'=-121, 'TZ'=-120, 'SY'=-119, 'AM'=-118, 'KE'=-117, 'CD'=-116, 'DJ'=-115, 'UG'=-114, 'CF'=-113, 'SC'=-112, 'JO'=-111, 'LB'=-110, 'KW'=-109, 'OM'=-108, 'QA'=-107, 'BH'=-106, 'AE'=-105, 'IL'=-104, 'TR'=-103, 'ET'=-102, 'ER'=-101, 'EG'=-100, 'SD'=-99, 'GR'=-98, 'BI'=-97, 'EE'=-96, 'LV'=-95, 'AZ'=-94, 'LT'=-93, 'SJ'=-92, 'GE'=-91, 'MD'=-90, 'BY'=-89, 'FI'=-88, 'AX'=-87, 'UA'=-86, 'MK'=-85, 'HU'=-84, 'BG'=-83, 'AL'=-82, 'PL'=-81, 'RO'=-80, 'XK'=-79, 'ZW'=-78, 'ZM'=-77, 'KM'=-76, 'MW'=-75, 'LS'=-74, 'BW'=-73, 'MU'=-72, 'SZ'=-71, 'RE'=-70, 'ZA'=-69, 'YT'=-68, 'MZ'=-67, 'MG'=-66, 'AF'=-65, 'PK'=-64, 'BD'=-63, 'TM'=-62, 'TJ'=-61, 'LK'=-60, 'BT'=-59, 'IN'=-58, 'MV'=-57, 'IO'=-56, 'NP'=-55, 'MM'=-54, 'UZ'=-53, 'KZ'=-52, 'KG'=-51, 'TF'=-50, 'HM'=-49, 'CC'=-48, 'PW'=-47, 'VN'=-46, 'TH'=-45, 'ID'=-44, 'LA'=-43, 'TW'=-42, 'PH'=-41, 'MY'=-40, 'CN'=-39, 'HK'=-38, 'BN'=-37, 'MO'=-36, 'KH'=-35, 'KR'=-34, 'JP'=-33, 'KP'=-32, 'SG'=-31, 'CK'=-30, 'TL'=-29, 'RU'=-28, 'MN'=-27, 'AU'=-26, 'CX'=-25, 'MH'=-24, 'FM'=-23, 'PG'=-22, 'SB'=-21, 'TV'=-20, 'NR'=-19, 'VU'=-18, 'NC'=-17, 'NF'=-16, 'NZ'=-15, 'FJ'=-14, 'LY'=-13, 'CM'=-12, 'SN'=-11, 'CG'=-10, 'PT'=-9, 'LR'=-8, 'CI'=-7, 'GH'=-6, 'GQ'=-5, 'NG'=-4, 'BF'=-3, 'TG'=-2, 'GW'=-1, 'MR'=0, 'BJ'=1, 'GA'=2, 'SL'=3, 'ST'=4, 'GI'=5, 'GM'=6, 'GN'=7, 'TD'=8, 'NE'=9, 'ML'=10, 'EH'=11, 'TN'=12, 'ES'=13, 'MA'=14, 'MT'=15, 'DZ'=16, 'FO'=17, 'DK'=18, 'IS'=19, 'GB'=20, 'CH'=21, 'SE'=22, 'NL'=23, 'AT'=24, 'BE'=25, 'DE'=26, 'LU'=27, 'IE'=28, 'MC'=29, 'FR'=30, 'AD'=31, 'LI'=32, 'JE'=33, 'IM'=34, 'GG'=35, 'SK'=36, 'CZ'=37, 'NO'=38, 'VA'=39, 'SM'=40, 'IT'=41, 'SI'=42, 'ME'=43, 'HR'=44, 'BA'=45, 'AO'=46, 'NA'=47, 'SH'=48, 'BV'=49, 'BB'=50, 'CV'=51, 'GY'=52, 'GF'=53, 'SR'=54, 'PM'=55, 'GL'=56, 'PY'=57, 'UY'=58, 'BR'=59, 'FK'=60, 'GS'=61, 'JM'=62, 'DO'=63, 'CU'=64, 'MQ'=65, 'BS'=66, 'BM'=67, 'AI'=68, 'TT'=69, 'KN'=70, 'DM'=71, 'AG'=72, 'LC'=73, 'TC'=74, 'AW'=75, 'VG'=76, 'VC'=77, 'MS'=78, 'MF'=79, 'BL'=80, 'GP'=81, 'GD'=82, 'KY'=83, 'BZ'=84, 'SV'=85, 'GT'=86, 'HN'=87, 'NI'=88, 'CR'=89, 'VE'=90, 'EC'=91, 'CO'=92, 'PA'=93, 'HT'=94, 'AR'=95, 'CL'=96, 'BO'=97, 'PE'=98, 'MX'=99, 'PF'=100, 'PN'=101, 'KI'=102, 'TK'=103, 'TO'=104, 'WF'=105, 'WS'=106, 'NU'=107, 'MP'=108, 'GU'=109, 'PR'=110, 'VI'=111, 'UM'=112, 'AS'=113, 'CA'=114, 'US'=115, 'PS'=116, 'RS'=117, 'AQ'=118, 'SX'=119, 'CW'=120, 'BQ'=121, 'SS'=122), + platform Enum8('web'=1,'ios'=2,'android'=3) DEFAULT 'web', + datetime DateTime, + duration UInt32, + pages_count UInt16, + events_count UInt16, + errors_count UInt16, + utm_source Nullable(String), + utm_medium Nullable(String), + utm_campaign Nullable(String), + user_id Nullable(String), + user_anonymous_id Nullable(String), + metadata_1 Nullable(String), + metadata_2 Nullable(String), + metadata_3 Nullable(String), + metadata_4 Nullable(String), + metadata_5 Nullable(String), + metadata_6 Nullable(String), + metadata_7 Nullable(String), + metadata_8 Nullable(String), + metadata_9 Nullable(String), + metadata_10 Nullable(String), + issue_types Array(LowCardinality(String)), + referrer Nullable(String), + base_referrer Nullable(String) MATERIALIZED lower(concat(domain(referrer), path(referrer))), + issue_score Nullable(UInt32), + _timestamp DateTime DEFAULT now() +) ENGINE = ReplacingMergeTree(_timestamp) + PARTITION BY toYYYYMMDD(datetime) + ORDER BY (project_id, datetime, session_id) + TTL datetime + INTERVAL 3 MONTH + SETTINGS index_granularity = 512; + +CREATE TABLE IF NOT EXISTS experimental.user_favorite_sessions +( + project_id UInt16, + user_id UInt32, + session_id UInt64, + _timestamp DateTime DEFAULT now(), + sign Int8 +) ENGINE = CollapsingMergeTree(sign) + PARTITION BY toYYYYMM(_timestamp) + ORDER BY (project_id, user_id, session_id) + TTL _timestamp + INTERVAL 3 MONTH; + +CREATE TABLE IF NOT EXISTS experimental.user_viewed_sessions +( + project_id UInt16, + user_id UInt32, + session_id UInt64, + _timestamp DateTime DEFAULT now() +) ENGINE = ReplacingMergeTree(_timestamp) + PARTITION BY toYYYYMM(_timestamp) + ORDER BY (project_id, user_id, session_id) + TTL _timestamp + INTERVAL 3 MONTH; + +CREATE TABLE IF NOT EXISTS experimental.user_viewed_errors +( + project_id UInt16, + user_id UInt32, + error_id String, + _timestamp DateTime DEFAULT now() +) ENGINE = ReplacingMergeTree(_timestamp) + PARTITION BY toYYYYMM(_timestamp) + ORDER BY (project_id, user_id, error_id) + TTL _timestamp + INTERVAL 3 MONTH; + +CREATE MATERIALIZED VIEW IF NOT EXISTS experimental.events_l7d_mv + ENGINE = MergeTree + PARTITION BY toYYYYMM(datetime) + ORDER BY (project_id, datetime, event_type, session_id) + TTL datetime + INTERVAL 7 DAY + POPULATE +AS +SELECT session_id, + project_id, + event_type, + datetime, + label, + hesitation_time, + name, + payload, + level, + source, + message, + error_id, + duration, + context, + container_type, + container_id, + container_name, + container_src, + url, + url_host, + url_path, + url_hostpath, + request_start, + response_start, + response_end, + dom_content_loaded_event_start, + dom_content_loaded_event_end, + load_event_start, + load_event_end, + first_paint, + first_contentful_paint_time, + speed_index, + visually_complete, + time_to_interactive, + ttfb, + ttlb, + response_time, + dom_building_time, + dom_content_loaded_event_time, + load_event_time, + min_fps, + avg_fps, + max_fps, + min_cpu, + avg_cpu, + max_cpu, + min_total_js_heap_size, + avg_total_js_heap_size, + max_total_js_heap_size, + min_used_js_heap_size, + avg_used_js_heap_size, + max_used_js_heap_size, + method, + status, + success, + request_body, + response_body, + _timestamp +FROM experimental.events +WHERE datetime >= now() - INTERVAL 7 DAY; + +CREATE MATERIALIZED VIEW IF NOT EXISTS experimental.resources_l7d_mv + ENGINE = MergeTree + PARTITION BY toYYYYMM(datetime) + ORDER BY (project_id, datetime, type, session_id) + TTL datetime + INTERVAL 7 DAY + POPULATE +AS +SELECT session_id, + project_id, + datetime, + url, + url_host, + url_path, + url_hostpath, + type, + name, + duration, + ttfb, + header_size, + encoded_body_size, + decoded_body_size, + compression_ratio, + success, + _timestamp +FROM experimental.resources +WHERE datetime >= now() - INTERVAL 7 DAY; + +CREATE MATERIALIZED VIEW IF NOT EXISTS experimental.sessions_l7d_mv + ENGINE = ReplacingMergeTree(_timestamp) + PARTITION BY toYYYYMMDD(datetime) + ORDER BY (project_id, datetime, session_id) + TTL datetime + INTERVAL 7 DAY + SETTINGS index_granularity = 512 + POPULATE +AS +SELECT session_id, + project_id, + tracker_version, + rev_id, + user_uuid, + user_os, + user_os_version, + user_browser, + user_browser_version, + user_device, + user_device_type, + user_country, + platform, + datetime, + duration, + pages_count, + events_count, + errors_count, + utm_source, + utm_medium, + utm_campaign, + user_id, + user_anonymous_id, + metadata_1, + metadata_2, + metadata_3, + metadata_4, + metadata_5, + metadata_6, + metadata_7, + metadata_8, + metadata_9, + metadata_10, + issue_types, + referrer, + base_referrer, + issue_score, + _timestamp +FROM experimental.sessions +WHERE datetime >= now() - INTERVAL 7 DAY + AND isNotNull(duration) + AND duration > 0; \ No newline at end of file diff --git a/ee/scripts/helm/db/init_dbs/clickhouse/create/inputs.sql b/ee/scripts/helm/db/init_dbs/clickhouse/create/inputs.sql deleted file mode 100644 index 83b475d0f..000000000 --- a/ee/scripts/helm/db/init_dbs/clickhouse/create/inputs.sql +++ /dev/null @@ -1,20 +0,0 @@ -CREATE TABLE IF NOT EXISTS inputs -( - session_id UInt64, - project_id UInt32, - tracker_version String, - rev_id Nullable(String), - user_uuid UUID, - user_os String, - user_os_version Nullable(String), - user_browser String, - user_browser_version Nullable(String), - user_device Nullable(String), - user_device_type Enum8('other'=0, 'desktop'=1, 'mobile'=2), - user_country Enum8('UN'=-128, 'RW'=-127, 'SO'=-126, 'YE'=-125, 'IQ'=-124, 'SA'=-123, 'IR'=-122, 'CY'=-121, 'TZ'=-120, 'SY'=-119, 'AM'=-118, 'KE'=-117, 'CD'=-116, 'DJ'=-115, 'UG'=-114, 'CF'=-113, 'SC'=-112, 'JO'=-111, 'LB'=-110, 'KW'=-109, 'OM'=-108, 'QA'=-107, 'BH'=-106, 'AE'=-105, 'IL'=-104, 'TR'=-103, 'ET'=-102, 'ER'=-101, 'EG'=-100, 'SD'=-99, 'GR'=-98, 'BI'=-97, 'EE'=-96, 'LV'=-95, 'AZ'=-94, 'LT'=-93, 'SJ'=-92, 'GE'=-91, 'MD'=-90, 'BY'=-89, 'FI'=-88, 'AX'=-87, 'UA'=-86, 'MK'=-85, 'HU'=-84, 'BG'=-83, 'AL'=-82, 'PL'=-81, 'RO'=-80, 'XK'=-79, 'ZW'=-78, 'ZM'=-77, 'KM'=-76, 'MW'=-75, 'LS'=-74, 'BW'=-73, 'MU'=-72, 'SZ'=-71, 'RE'=-70, 'ZA'=-69, 'YT'=-68, 'MZ'=-67, 'MG'=-66, 'AF'=-65, 'PK'=-64, 'BD'=-63, 'TM'=-62, 'TJ'=-61, 'LK'=-60, 'BT'=-59, 'IN'=-58, 'MV'=-57, 'IO'=-56, 'NP'=-55, 'MM'=-54, 'UZ'=-53, 'KZ'=-52, 'KG'=-51, 'TF'=-50, 'HM'=-49, 'CC'=-48, 'PW'=-47, 'VN'=-46, 'TH'=-45, 'ID'=-44, 'LA'=-43, 'TW'=-42, 'PH'=-41, 'MY'=-40, 'CN'=-39, 'HK'=-38, 'BN'=-37, 'MO'=-36, 'KH'=-35, 'KR'=-34, 'JP'=-33, 'KP'=-32, 'SG'=-31, 'CK'=-30, 'TL'=-29, 'RU'=-28, 'MN'=-27, 'AU'=-26, 'CX'=-25, 'MH'=-24, 'FM'=-23, 'PG'=-22, 'SB'=-21, 'TV'=-20, 'NR'=-19, 'VU'=-18, 'NC'=-17, 'NF'=-16, 'NZ'=-15, 'FJ'=-14, 'LY'=-13, 'CM'=-12, 'SN'=-11, 'CG'=-10, 'PT'=-9, 'LR'=-8, 'CI'=-7, 'GH'=-6, 'GQ'=-5, 'NG'=-4, 'BF'=-3, 'TG'=-2, 'GW'=-1, 'MR'=0, 'BJ'=1, 'GA'=2, 'SL'=3, 'ST'=4, 'GI'=5, 'GM'=6, 'GN'=7, 'TD'=8, 'NE'=9, 'ML'=10, 'EH'=11, 'TN'=12, 'ES'=13, 'MA'=14, 'MT'=15, 'DZ'=16, 'FO'=17, 'DK'=18, 'IS'=19, 'GB'=20, 'CH'=21, 'SE'=22, 'NL'=23, 'AT'=24, 'BE'=25, 'DE'=26, 'LU'=27, 'IE'=28, 'MC'=29, 'FR'=30, 'AD'=31, 'LI'=32, 'JE'=33, 'IM'=34, 'GG'=35, 'SK'=36, 'CZ'=37, 'NO'=38, 'VA'=39, 'SM'=40, 'IT'=41, 'SI'=42, 'ME'=43, 'HR'=44, 'BA'=45, 'AO'=46, 'NA'=47, 'SH'=48, 'BV'=49, 'BB'=50, 'CV'=51, 'GY'=52, 'GF'=53, 'SR'=54, 'PM'=55, 'GL'=56, 'PY'=57, 'UY'=58, 'BR'=59, 'FK'=60, 'GS'=61, 'JM'=62, 'DO'=63, 'CU'=64, 'MQ'=65, 'BS'=66, 'BM'=67, 'AI'=68, 'TT'=69, 'KN'=70, 'DM'=71, 'AG'=72, 'LC'=73, 'TC'=74, 'AW'=75, 'VG'=76, 'VC'=77, 'MS'=78, 'MF'=79, 'BL'=80, 'GP'=81, 'GD'=82, 'KY'=83, 'BZ'=84, 'SV'=85, 'GT'=86, 'HN'=87, 'NI'=88, 'CR'=89, 'VE'=90, 'EC'=91, 'CO'=92, 'PA'=93, 'HT'=94, 'AR'=95, 'CL'=96, 'BO'=97, 'PE'=98, 'MX'=99, 'PF'=100, 'PN'=101, 'KI'=102, 'TK'=103, 'TO'=104, 'WF'=105, 'WS'=106, 'NU'=107, 'MP'=108, 'GU'=109, 'PR'=110, 'VI'=111, 'UM'=112, 'AS'=113, 'CA'=114, 'US'=115, 'PS'=116, 'RS'=117, 'AQ'=118, 'SX'=119, 'CW'=120, 'BQ'=121, 'SS'=122), - datetime DateTime, - label String -) ENGINE = MergeTree - PARTITION BY toStartOfWeek(datetime) - ORDER BY (project_id, datetime) - TTL datetime + INTERVAL 1 MONTH; diff --git a/ee/scripts/helm/db/init_dbs/clickhouse/create/longtasks.sql b/ee/scripts/helm/db/init_dbs/clickhouse/create/longtasks.sql deleted file mode 100644 index 90a90a104..000000000 --- a/ee/scripts/helm/db/init_dbs/clickhouse/create/longtasks.sql +++ /dev/null @@ -1,26 +0,0 @@ -CREATE TABLE IF NOT EXISTS longtasks -( - session_id UInt64, - project_id UInt32, - tracker_version String, - rev_id Nullable(String), - user_uuid UUID, - user_os String, - user_os_version Nullable(String), - user_browser String, - user_browser_version Nullable(String), - user_device Nullable(String), - user_device_type Enum8('other'=0, 'desktop'=1, 'mobile'=2), - user_country Enum8('UN'=-128, 'RW'=-127, 'SO'=-126, 'YE'=-125, 'IQ'=-124, 'SA'=-123, 'IR'=-122, 'CY'=-121, 'TZ'=-120, 'SY'=-119, 'AM'=-118, 'KE'=-117, 'CD'=-116, 'DJ'=-115, 'UG'=-114, 'CF'=-113, 'SC'=-112, 'JO'=-111, 'LB'=-110, 'KW'=-109, 'OM'=-108, 'QA'=-107, 'BH'=-106, 'AE'=-105, 'IL'=-104, 'TR'=-103, 'ET'=-102, 'ER'=-101, 'EG'=-100, 'SD'=-99, 'GR'=-98, 'BI'=-97, 'EE'=-96, 'LV'=-95, 'AZ'=-94, 'LT'=-93, 'SJ'=-92, 'GE'=-91, 'MD'=-90, 'BY'=-89, 'FI'=-88, 'AX'=-87, 'UA'=-86, 'MK'=-85, 'HU'=-84, 'BG'=-83, 'AL'=-82, 'PL'=-81, 'RO'=-80, 'XK'=-79, 'ZW'=-78, 'ZM'=-77, 'KM'=-76, 'MW'=-75, 'LS'=-74, 'BW'=-73, 'MU'=-72, 'SZ'=-71, 'RE'=-70, 'ZA'=-69, 'YT'=-68, 'MZ'=-67, 'MG'=-66, 'AF'=-65, 'PK'=-64, 'BD'=-63, 'TM'=-62, 'TJ'=-61, 'LK'=-60, 'BT'=-59, 'IN'=-58, 'MV'=-57, 'IO'=-56, 'NP'=-55, 'MM'=-54, 'UZ'=-53, 'KZ'=-52, 'KG'=-51, 'TF'=-50, 'HM'=-49, 'CC'=-48, 'PW'=-47, 'VN'=-46, 'TH'=-45, 'ID'=-44, 'LA'=-43, 'TW'=-42, 'PH'=-41, 'MY'=-40, 'CN'=-39, 'HK'=-38, 'BN'=-37, 'MO'=-36, 'KH'=-35, 'KR'=-34, 'JP'=-33, 'KP'=-32, 'SG'=-31, 'CK'=-30, 'TL'=-29, 'RU'=-28, 'MN'=-27, 'AU'=-26, 'CX'=-25, 'MH'=-24, 'FM'=-23, 'PG'=-22, 'SB'=-21, 'TV'=-20, 'NR'=-19, 'VU'=-18, 'NC'=-17, 'NF'=-16, 'NZ'=-15, 'FJ'=-14, 'LY'=-13, 'CM'=-12, 'SN'=-11, 'CG'=-10, 'PT'=-9, 'LR'=-8, 'CI'=-7, 'GH'=-6, 'GQ'=-5, 'NG'=-4, 'BF'=-3, 'TG'=-2, 'GW'=-1, 'MR'=0, 'BJ'=1, 'GA'=2, 'SL'=3, 'ST'=4, 'GI'=5, 'GM'=6, 'GN'=7, 'TD'=8, 'NE'=9, 'ML'=10, 'EH'=11, 'TN'=12, 'ES'=13, 'MA'=14, 'MT'=15, 'DZ'=16, 'FO'=17, 'DK'=18, 'IS'=19, 'GB'=20, 'CH'=21, 'SE'=22, 'NL'=23, 'AT'=24, 'BE'=25, 'DE'=26, 'LU'=27, 'IE'=28, 'MC'=29, 'FR'=30, 'AD'=31, 'LI'=32, 'JE'=33, 'IM'=34, 'GG'=35, 'SK'=36, 'CZ'=37, 'NO'=38, 'VA'=39, 'SM'=40, 'IT'=41, 'SI'=42, 'ME'=43, 'HR'=44, 'BA'=45, 'AO'=46, 'NA'=47, 'SH'=48, 'BV'=49, 'BB'=50, 'CV'=51, 'GY'=52, 'GF'=53, 'SR'=54, 'PM'=55, 'GL'=56, 'PY'=57, 'UY'=58, 'BR'=59, 'FK'=60, 'GS'=61, 'JM'=62, 'DO'=63, 'CU'=64, 'MQ'=65, 'BS'=66, 'BM'=67, 'AI'=68, 'TT'=69, 'KN'=70, 'DM'=71, 'AG'=72, 'LC'=73, 'TC'=74, 'AW'=75, 'VG'=76, 'VC'=77, 'MS'=78, 'MF'=79, 'BL'=80, 'GP'=81, 'GD'=82, 'KY'=83, 'BZ'=84, 'SV'=85, 'GT'=86, 'HN'=87, 'NI'=88, 'CR'=89, 'VE'=90, 'EC'=91, 'CO'=92, 'PA'=93, 'HT'=94, 'AR'=95, 'CL'=96, 'BO'=97, 'PE'=98, 'MX'=99, 'PF'=100, 'PN'=101, 'KI'=102, 'TK'=103, 'TO'=104, 'WF'=105, 'WS'=106, 'NU'=107, 'MP'=108, 'GU'=109, 'PR'=110, 'VI'=111, 'UM'=112, 'AS'=113, 'CA'=114, 'US'=115, 'PS'=116, 'RS'=117, 'AQ'=118, 'SX'=119, 'CW'=120, 'BQ'=121, 'SS'=122), - datetime DateTime, - duration UInt16, - context Enum8('unknown'=0, 'self'=1, 'same-origin-ancestor'=2, 'same-origin-descendant'=3, 'same-origin'=4, 'cross-origin-ancestor'=5, 'cross-origin-descendant'=6, 'cross-origin-unreachable'=7, 'multiple-contexts'=8), - container_type Enum8('window'=0, 'iframe'=1, 'embed'=2, 'object'=3), - container_id String, - container_name String, - container_src String -) ENGINE = MergeTree - PARTITION BY toStartOfWeek(datetime) - ORDER BY (project_id, datetime) - TTL datetime + INTERVAL 1 MONTH; - diff --git a/ee/scripts/helm/db/init_dbs/clickhouse/create/negatives_buffer.sql b/ee/scripts/helm/db/init_dbs/clickhouse/create/negatives_buffer.sql deleted file mode 100644 index ac67028b3..000000000 --- a/ee/scripts/helm/db/init_dbs/clickhouse/create/negatives_buffer.sql +++ /dev/null @@ -1,215 +0,0 @@ -CREATE TABLE IF NOT EXISTS negatives_buffer -( - sessionid UInt64, - clickevent_hesitationtime Nullable(UInt64), - clickevent_label Nullable(String), - clickevent_messageid Nullable(UInt64), - clickevent_timestamp Nullable(Datetime), - connectioninformation_downlink Nullable(UInt64), - connectioninformation_type Nullable(String), - consolelog_level Nullable(String), - consolelog_value Nullable(String), - cpuissue_duration Nullable(UInt64), - cpuissue_rate Nullable(UInt64), - cpuissue_timestamp Nullable(Datetime), - createdocument Nullable(UInt8), - createelementnode_id Nullable(UInt64), - createelementnode_parentid Nullable(UInt64), - cssdeleterule_index Nullable(UInt64), - cssdeleterule_stylesheetid Nullable(UInt64), - cssinsertrule_index Nullable(UInt64), - cssinsertrule_rule Nullable(String), - cssinsertrule_stylesheetid Nullable(UInt64), - customevent_messageid Nullable(UInt64), - customevent_name Nullable(String), - customevent_payload Nullable(String), - customevent_timestamp Nullable(Datetime), - domdrop_timestamp Nullable(Datetime), - errorevent_message Nullable(String), - errorevent_messageid Nullable(UInt64), - errorevent_name Nullable(String), - errorevent_payload Nullable(String), - errorevent_source Nullable(String), - errorevent_timestamp Nullable(Datetime), - fetch_duration Nullable(UInt64), - fetch_method Nullable(String), - fetch_request Nullable(String), - fetch_status Nullable(UInt64), - fetch_timestamp Nullable(Datetime), - fetch_url Nullable(String), - graphql_operationkind Nullable(String), - graphql_operationname Nullable(String), - graphql_response Nullable(String), - graphql_variables Nullable(String), - graphqlevent_messageid Nullable(UInt64), - graphqlevent_name Nullable(String), - graphqlevent_timestamp Nullable(Datetime), - inputevent_label Nullable(String), - inputevent_messageid Nullable(UInt64), - inputevent_timestamp Nullable(Datetime), - inputevent_value Nullable(String), - inputevent_valuemasked Nullable(UInt8), - jsexception_message Nullable(String), - jsexception_name Nullable(String), - jsexception_payload Nullable(String), - longtasks_timestamp Nullable(Datetime), - longtasks_duration Nullable(UInt64), - longtasks_containerid Nullable(String), - longtasks_containersrc Nullable(String), - memoryissue_duration Nullable(UInt64), - memoryissue_rate Nullable(UInt64), - memoryissue_timestamp Nullable(Datetime), - metadata_key Nullable(String), - metadata_value Nullable(String), - mobx_payload Nullable(String), - mobx_type Nullable(String), - mouseclick_id Nullable(UInt64), - mouseclick_hesitationtime Nullable(UInt64), - mouseclick_label Nullable(String), - mousemove_x Nullable(UInt64), - mousemove_y Nullable(UInt64), - movenode_id Nullable(UInt64), - movenode_index Nullable(UInt64), - movenode_parentid Nullable(UInt64), - ngrx_action Nullable(String), - ngrx_duration Nullable(UInt64), - ngrx_state Nullable(String), - pageevent_domcontentloadedeventend Nullable(UInt64), - pageevent_domcontentloadedeventstart Nullable(UInt64), - pageevent_firstcontentfulpaint Nullable(UInt64), - pageevent_firstpaint Nullable(UInt64), - pageevent_loaded Nullable(UInt8), - pageevent_loadeventend Nullable(UInt64), - pageevent_loadeventstart Nullable(UInt64), - pageevent_messageid Nullable(UInt64), - pageevent_referrer Nullable(String), - pageevent_requeststart Nullable(UInt64), - pageevent_responseend Nullable(UInt64), - pageevent_responsestart Nullable(UInt64), - pageevent_speedindex Nullable(UInt64), - pageevent_timestamp Nullable(Datetime), - pageevent_url Nullable(String), - pageloadtiming_domcontentloadedeventend Nullable(UInt64), - pageloadtiming_domcontentloadedeventstart Nullable(UInt64), - pageloadtiming_firstcontentfulpaint Nullable(UInt64), - pageloadtiming_firstpaint Nullable(UInt64), - pageloadtiming_loadeventend Nullable(UInt64), - pageloadtiming_loadeventstart Nullable(UInt64), - pageloadtiming_requeststart Nullable(UInt64), - pageloadtiming_responseend Nullable(UInt64), - pageloadtiming_responsestart Nullable(UInt64), - pagerendertiming_speedindex Nullable(UInt64), - pagerendertiming_timetointeractive Nullable(UInt64), - pagerendertiming_visuallycomplete Nullable(UInt64), - performancetrack_frames Nullable(Int64), - performancetrack_ticks Nullable(Int64), - performancetrack_totaljsheapsize Nullable(UInt64), - performancetrack_usedjsheapsize Nullable(UInt64), - performancetrackaggr_avgcpu Nullable(UInt64), - performancetrackaggr_avgfps Nullable(UInt64), - performancetrackaggr_avgtotaljsheapsize Nullable(UInt64), - performancetrackaggr_avgusedjsheapsize Nullable(UInt64), - performancetrackaggr_maxcpu Nullable(UInt64), - performancetrackaggr_maxfps Nullable(UInt64), - performancetrackaggr_maxtotaljsheapsize Nullable(UInt64), - performancetrackaggr_maxusedjsheapsize Nullable(UInt64), - performancetrackaggr_mincpu Nullable(UInt64), - performancetrackaggr_minfps Nullable(UInt64), - performancetrackaggr_mintotaljsheapsize Nullable(UInt64), - performancetrackaggr_minusedjsheapsize Nullable(UInt64), - performancetrackaggr_timestampend Nullable(Datetime), - performancetrackaggr_timestampstart Nullable(Datetime), - profiler_args Nullable(String), - profiler_duration Nullable(UInt64), - profiler_name Nullable(String), - profiler_result Nullable(String), - rawcustomevent_name Nullable(String), - rawcustomevent_payload Nullable(String), - rawerrorevent_message Nullable(String), - rawerrorevent_name Nullable(String), - rawerrorevent_payload Nullable(String), - rawerrorevent_source Nullable(String), - rawerrorevent_timestamp Nullable(Datetime), - redux_action Nullable(String), - redux_duration Nullable(UInt64), - redux_state Nullable(String), - removenode_id Nullable(UInt64), - removenodeattribute_id Nullable(UInt64), - removenodeattribute_name Nullable(String), - resourceevent_decodedbodysize Nullable(UInt64), - resourceevent_duration Nullable(UInt64), - resourceevent_encodedbodysize Nullable(UInt64), - resourceevent_headersize Nullable(UInt64), - resourceevent_messageid Nullable(UInt64), - resourceevent_method Nullable(String), - resourceevent_status Nullable(UInt64), - resourceevent_success Nullable(UInt8), - resourceevent_timestamp Nullable(Datetime), - resourceevent_ttfb Nullable(UInt64), - resourceevent_type Nullable(String), - resourceevent_url Nullable(String), - resourcetiming_decodedbodysize Nullable(UInt64), - resourcetiming_duration Nullable(UInt64), - resourcetiming_encodedbodysize Nullable(UInt64), - resourcetiming_headersize Nullable(UInt64), - resourcetiming_initiator Nullable(String), - resourcetiming_timestamp Nullable(Datetime), - resourcetiming_ttfb Nullable(UInt64), - resourcetiming_url Nullable(String), - sessiondisconnect Nullable(UInt8), - sessiondisconnect_timestamp Nullable(Datetime), - sessionend Nullable(UInt8), - sessionend_timestamp Nullable(Datetime), - sessionstart_projectid Nullable(UInt64), - sessionstart_revid Nullable(String), - sessionstart_timestamp Nullable(Datetime), - sessionstart_trackerversion Nullable(String), - sessionstart_useragent Nullable(String), - sessionstart_userbrowser Nullable(String), - sessionstart_userbrowserversion Nullable(String), - sessionstart_usercountry Nullable(String), - sessionstart_userdevice Nullable(String), - sessionstart_userdeviceheapsize Nullable(UInt64), - sessionstart_userdevicememorysize Nullable(UInt64), - sessionstart_userdevicetype Nullable(String), - sessionstart_useros Nullable(String), - sessionstart_userosversion Nullable(String), - sessionstart_useruuid Nullable(String), - setcssdata_data Nullable(UInt64), - setcssdata_id Nullable(UInt64), - setinputchecked_checked Nullable(UInt64), - setinputchecked_id Nullable(UInt64), - setinputtarget_id Nullable(UInt64), - setinputtarget_label Nullable(UInt64), - setinputvalue_id Nullable(UInt64), - setinputvalue_mask Nullable(UInt64), - setinputvalue_value Nullable(UInt64), - setnodeattribute_id Nullable(UInt64), - setnodeattribute_name Nullable(UInt64), - setnodeattribute_value Nullable(UInt64), - setnodedata_data Nullable(UInt64), - setnodedata_id Nullable(UInt64), - setnodescroll_id Nullable(UInt64), - setnodescroll_x Nullable(Int64), - setnodescroll_y Nullable(Int64), - setpagelocation_navigationstart Nullable(UInt64), - setpagelocation_referrer Nullable(String), - setpagelocation_url Nullable(String), - setpagevisibility_hidden Nullable(UInt8), - setviewportscroll_x Nullable(Int64), - setviewportscroll_y Nullable(Int64), - setviewportsize_height Nullable(UInt64), - setviewportsize_width Nullable(UInt64), - stateaction_type Nullable(String), - stateactionevent_messageid Nullable(UInt64), - stateactionevent_timestamp Nullable(Datetime), - stateactionevent_type Nullable(String), - timestamp_timestamp Nullable(Datetime), - useranonymousid_id Nullable(String), - userid_id Nullable(String), - vuex_mutation Nullable(String), - vuex_state Nullable(String), - received_at Datetime, - batch_order_number Int64 -) - ENGINE = Buffer(default, negatives, 16, 10, 120, 10000, 1000000, 10000, 100000000); diff --git a/ee/scripts/helm/db/init_dbs/clickhouse/create/negatives_creation_clickhouse.sql b/ee/scripts/helm/db/init_dbs/clickhouse/create/negatives_creation_clickhouse.sql deleted file mode 100644 index 361082d7c..000000000 --- a/ee/scripts/helm/db/init_dbs/clickhouse/create/negatives_creation_clickhouse.sql +++ /dev/null @@ -1,218 +0,0 @@ -CREATE TABLE IF NOT EXISTS negatives -( - sessionid UInt64, - clickevent_hesitationtime Nullable(UInt64), - clickevent_label Nullable(String), - clickevent_messageid Nullable(UInt64), - clickevent_timestamp Nullable(Datetime), - connectioninformation_downlink Nullable(UInt64), - connectioninformation_type Nullable(String), - consolelog_level Nullable(String), - consolelog_value Nullable(String), - cpuissue_duration Nullable(UInt64), - cpuissue_rate Nullable(UInt64), - cpuissue_timestamp Nullable(Datetime), - createdocument Nullable(UInt8), - createelementnode_id Nullable(UInt64), - createelementnode_parentid Nullable(UInt64), - cssdeleterule_index Nullable(UInt64), - cssdeleterule_stylesheetid Nullable(UInt64), - cssinsertrule_index Nullable(UInt64), - cssinsertrule_rule Nullable(String), - cssinsertrule_stylesheetid Nullable(UInt64), - customevent_messageid Nullable(UInt64), - customevent_name Nullable(String), - customevent_payload Nullable(String), - customevent_timestamp Nullable(Datetime), - domdrop_timestamp Nullable(Datetime), - errorevent_message Nullable(String), - errorevent_messageid Nullable(UInt64), - errorevent_name Nullable(String), - errorevent_payload Nullable(String), - errorevent_source Nullable(String), - errorevent_timestamp Nullable(Datetime), - fetch_duration Nullable(UInt64), - fetch_method Nullable(String), - fetch_request Nullable(String), - fetch_status Nullable(UInt64), - fetch_timestamp Nullable(Datetime), - fetch_url Nullable(String), - graphql_operationkind Nullable(String), - graphql_operationname Nullable(String), - graphql_response Nullable(String), - graphql_variables Nullable(String), - graphqlevent_messageid Nullable(UInt64), - graphqlevent_name Nullable(String), - graphqlevent_timestamp Nullable(Datetime), - inputevent_label Nullable(String), - inputevent_messageid Nullable(UInt64), - inputevent_timestamp Nullable(Datetime), - inputevent_value Nullable(String), - inputevent_valuemasked Nullable(UInt8), - jsexception_message Nullable(String), - jsexception_name Nullable(String), - jsexception_payload Nullable(String), - longtasks_timestamp Nullable(Datetime), - longtasks_duration Nullable(UInt64), - longtasks_containerid Nullable(String), - longtasks_containersrc Nullable(String), - memoryissue_duration Nullable(UInt64), - memoryissue_rate Nullable(UInt64), - memoryissue_timestamp Nullable(Datetime), - metadata_key Nullable(String), - metadata_value Nullable(String), - mobx_payload Nullable(String), - mobx_type Nullable(String), - mouseclick_id Nullable(UInt64), - mouseclick_hesitationtime Nullable(UInt64), - mouseclick_label Nullable(String), - mousemove_x Nullable(UInt64), - mousemove_y Nullable(UInt64), - movenode_id Nullable(UInt64), - movenode_index Nullable(UInt64), - movenode_parentid Nullable(UInt64), - ngrx_action Nullable(String), - ngrx_duration Nullable(UInt64), - ngrx_state Nullable(String), - pageevent_domcontentloadedeventend Nullable(UInt64), - pageevent_domcontentloadedeventstart Nullable(UInt64), - pageevent_firstcontentfulpaint Nullable(UInt64), - pageevent_firstpaint Nullable(UInt64), - pageevent_loaded Nullable(UInt8), - pageevent_loadeventend Nullable(UInt64), - pageevent_loadeventstart Nullable(UInt64), - pageevent_messageid Nullable(UInt64), - pageevent_referrer Nullable(String), - pageevent_requeststart Nullable(UInt64), - pageevent_responseend Nullable(UInt64), - pageevent_responsestart Nullable(UInt64), - pageevent_speedindex Nullable(UInt64), - pageevent_timestamp Nullable(Datetime), - pageevent_url Nullable(String), - pageloadtiming_domcontentloadedeventend Nullable(UInt64), - pageloadtiming_domcontentloadedeventstart Nullable(UInt64), - pageloadtiming_firstcontentfulpaint Nullable(UInt64), - pageloadtiming_firstpaint Nullable(UInt64), - pageloadtiming_loadeventend Nullable(UInt64), - pageloadtiming_loadeventstart Nullable(UInt64), - pageloadtiming_requeststart Nullable(UInt64), - pageloadtiming_responseend Nullable(UInt64), - pageloadtiming_responsestart Nullable(UInt64), - pagerendertiming_speedindex Nullable(UInt64), - pagerendertiming_timetointeractive Nullable(UInt64), - pagerendertiming_visuallycomplete Nullable(UInt64), - performancetrack_frames Nullable(Int64), - performancetrack_ticks Nullable(Int64), - performancetrack_totaljsheapsize Nullable(UInt64), - performancetrack_usedjsheapsize Nullable(UInt64), - performancetrackaggr_avgcpu Nullable(UInt64), - performancetrackaggr_avgfps Nullable(UInt64), - performancetrackaggr_avgtotaljsheapsize Nullable(UInt64), - performancetrackaggr_avgusedjsheapsize Nullable(UInt64), - performancetrackaggr_maxcpu Nullable(UInt64), - performancetrackaggr_maxfps Nullable(UInt64), - performancetrackaggr_maxtotaljsheapsize Nullable(UInt64), - performancetrackaggr_maxusedjsheapsize Nullable(UInt64), - performancetrackaggr_mincpu Nullable(UInt64), - performancetrackaggr_minfps Nullable(UInt64), - performancetrackaggr_mintotaljsheapsize Nullable(UInt64), - performancetrackaggr_minusedjsheapsize Nullable(UInt64), - performancetrackaggr_timestampend Nullable(Datetime), - performancetrackaggr_timestampstart Nullable(Datetime), - profiler_args Nullable(String), - profiler_duration Nullable(UInt64), - profiler_name Nullable(String), - profiler_result Nullable(String), - rawcustomevent_name Nullable(String), - rawcustomevent_payload Nullable(String), - rawerrorevent_message Nullable(String), - rawerrorevent_name Nullable(String), - rawerrorevent_payload Nullable(String), - rawerrorevent_source Nullable(String), - rawerrorevent_timestamp Nullable(Datetime), - redux_action Nullable(String), - redux_duration Nullable(UInt64), - redux_state Nullable(String), - removenode_id Nullable(UInt64), - removenodeattribute_id Nullable(UInt64), - removenodeattribute_name Nullable(String), - resourceevent_decodedbodysize Nullable(UInt64), - resourceevent_duration Nullable(UInt64), - resourceevent_encodedbodysize Nullable(UInt64), - resourceevent_headersize Nullable(UInt64), - resourceevent_messageid Nullable(UInt64), - resourceevent_method Nullable(String), - resourceevent_status Nullable(UInt64), - resourceevent_success Nullable(UInt8), - resourceevent_timestamp Nullable(Datetime), - resourceevent_ttfb Nullable(UInt64), - resourceevent_type Nullable(String), - resourceevent_url Nullable(String), - resourcetiming_decodedbodysize Nullable(UInt64), - resourcetiming_duration Nullable(UInt64), - resourcetiming_encodedbodysize Nullable(UInt64), - resourcetiming_headersize Nullable(UInt64), - resourcetiming_initiator Nullable(String), - resourcetiming_timestamp Nullable(Datetime), - resourcetiming_ttfb Nullable(UInt64), - resourcetiming_url Nullable(String), - sessiondisconnect Nullable(UInt8), - sessiondisconnect_timestamp Nullable(Datetime), - sessionend Nullable(UInt8), - sessionend_timestamp Nullable(Datetime), - sessionstart_projectid Nullable(UInt64), - sessionstart_revid Nullable(String), - sessionstart_timestamp Nullable(Datetime), - sessionstart_trackerversion Nullable(String), - sessionstart_useragent Nullable(String), - sessionstart_userbrowser Nullable(String), - sessionstart_userbrowserversion Nullable(String), - sessionstart_usercountry Nullable(String), - sessionstart_userdevice Nullable(String), - sessionstart_userdeviceheapsize Nullable(UInt64), - sessionstart_userdevicememorysize Nullable(UInt64), - sessionstart_userdevicetype Nullable(String), - sessionstart_useros Nullable(String), - sessionstart_userosversion Nullable(String), - sessionstart_useruuid Nullable(String), - setcssdata_data Nullable(UInt64), - setcssdata_id Nullable(UInt64), - setinputchecked_checked Nullable(UInt64), - setinputchecked_id Nullable(UInt64), - setinputtarget_id Nullable(UInt64), - setinputtarget_label Nullable(UInt64), - setinputvalue_id Nullable(UInt64), - setinputvalue_mask Nullable(UInt64), - setinputvalue_value Nullable(UInt64), - setnodeattribute_id Nullable(UInt64), - setnodeattribute_name Nullable(UInt64), - setnodeattribute_value Nullable(UInt64), - setnodedata_data Nullable(UInt64), - setnodedata_id Nullable(UInt64), - setnodescroll_id Nullable(UInt64), - setnodescroll_x Nullable(Int64), - setnodescroll_y Nullable(Int64), - setpagelocation_navigationstart Nullable(UInt64), - setpagelocation_referrer Nullable(String), - setpagelocation_url Nullable(String), - setpagevisibility_hidden Nullable(UInt8), - setviewportscroll_x Nullable(Int64), - setviewportscroll_y Nullable(Int64), - setviewportsize_height Nullable(UInt64), - setviewportsize_width Nullable(UInt64), - stateaction_type Nullable(String), - stateactionevent_messageid Nullable(UInt64), - stateactionevent_timestamp Nullable(Datetime), - stateactionevent_type Nullable(String), - timestamp_timestamp Nullable(Datetime), - useranonymousid_id Nullable(String), - userid_id Nullable(String), - vuex_mutation Nullable(String), - vuex_state Nullable(String), - received_at Datetime, - batch_order_number Int64 -) - ENGINE = MergeTree() - PARTITION BY toYYYYMM(received_at) - ORDER BY (received_at, batch_order_number) - SETTINGS min_bytes_for_wide_part = 1, use_minimalistic_part_header_in_zookeeper = 1; \ No newline at end of file diff --git a/ee/scripts/helm/db/init_dbs/clickhouse/create/pages.sql b/ee/scripts/helm/db/init_dbs/clickhouse/create/pages.sql deleted file mode 100644 index 3902abd33..000000000 --- a/ee/scripts/helm/db/init_dbs/clickhouse/create/pages.sql +++ /dev/null @@ -1,40 +0,0 @@ -CREATE TABLE IF NOT EXISTS pages -( - session_id UInt64, - project_id UInt32, - tracker_version String, - rev_id Nullable(String), - user_uuid UUID, - user_os String, - user_os_version Nullable(String), - user_browser String, - user_browser_version Nullable(String), - user_device Nullable(String), - user_device_type Enum8('other'=0, 'desktop'=1, 'mobile'=2), - user_country Enum8('UN'=-128, 'RW'=-127, 'SO'=-126, 'YE'=-125, 'IQ'=-124, 'SA'=-123, 'IR'=-122, 'CY'=-121, 'TZ'=-120, 'SY'=-119, 'AM'=-118, 'KE'=-117, 'CD'=-116, 'DJ'=-115, 'UG'=-114, 'CF'=-113, 'SC'=-112, 'JO'=-111, 'LB'=-110, 'KW'=-109, 'OM'=-108, 'QA'=-107, 'BH'=-106, 'AE'=-105, 'IL'=-104, 'TR'=-103, 'ET'=-102, 'ER'=-101, 'EG'=-100, 'SD'=-99, 'GR'=-98, 'BI'=-97, 'EE'=-96, 'LV'=-95, 'AZ'=-94, 'LT'=-93, 'SJ'=-92, 'GE'=-91, 'MD'=-90, 'BY'=-89, 'FI'=-88, 'AX'=-87, 'UA'=-86, 'MK'=-85, 'HU'=-84, 'BG'=-83, 'AL'=-82, 'PL'=-81, 'RO'=-80, 'XK'=-79, 'ZW'=-78, 'ZM'=-77, 'KM'=-76, 'MW'=-75, 'LS'=-74, 'BW'=-73, 'MU'=-72, 'SZ'=-71, 'RE'=-70, 'ZA'=-69, 'YT'=-68, 'MZ'=-67, 'MG'=-66, 'AF'=-65, 'PK'=-64, 'BD'=-63, 'TM'=-62, 'TJ'=-61, 'LK'=-60, 'BT'=-59, 'IN'=-58, 'MV'=-57, 'IO'=-56, 'NP'=-55, 'MM'=-54, 'UZ'=-53, 'KZ'=-52, 'KG'=-51, 'TF'=-50, 'HM'=-49, 'CC'=-48, 'PW'=-47, 'VN'=-46, 'TH'=-45, 'ID'=-44, 'LA'=-43, 'TW'=-42, 'PH'=-41, 'MY'=-40, 'CN'=-39, 'HK'=-38, 'BN'=-37, 'MO'=-36, 'KH'=-35, 'KR'=-34, 'JP'=-33, 'KP'=-32, 'SG'=-31, 'CK'=-30, 'TL'=-29, 'RU'=-28, 'MN'=-27, 'AU'=-26, 'CX'=-25, 'MH'=-24, 'FM'=-23, 'PG'=-22, 'SB'=-21, 'TV'=-20, 'NR'=-19, 'VU'=-18, 'NC'=-17, 'NF'=-16, 'NZ'=-15, 'FJ'=-14, 'LY'=-13, 'CM'=-12, 'SN'=-11, 'CG'=-10, 'PT'=-9, 'LR'=-8, 'CI'=-7, 'GH'=-6, 'GQ'=-5, 'NG'=-4, 'BF'=-3, 'TG'=-2, 'GW'=-1, 'MR'=0, 'BJ'=1, 'GA'=2, 'SL'=3, 'ST'=4, 'GI'=5, 'GM'=6, 'GN'=7, 'TD'=8, 'NE'=9, 'ML'=10, 'EH'=11, 'TN'=12, 'ES'=13, 'MA'=14, 'MT'=15, 'DZ'=16, 'FO'=17, 'DK'=18, 'IS'=19, 'GB'=20, 'CH'=21, 'SE'=22, 'NL'=23, 'AT'=24, 'BE'=25, 'DE'=26, 'LU'=27, 'IE'=28, 'MC'=29, 'FR'=30, 'AD'=31, 'LI'=32, 'JE'=33, 'IM'=34, 'GG'=35, 'SK'=36, 'CZ'=37, 'NO'=38, 'VA'=39, 'SM'=40, 'IT'=41, 'SI'=42, 'ME'=43, 'HR'=44, 'BA'=45, 'AO'=46, 'NA'=47, 'SH'=48, 'BV'=49, 'BB'=50, 'CV'=51, 'GY'=52, 'GF'=53, 'SR'=54, 'PM'=55, 'GL'=56, 'PY'=57, 'UY'=58, 'BR'=59, 'FK'=60, 'GS'=61, 'JM'=62, 'DO'=63, 'CU'=64, 'MQ'=65, 'BS'=66, 'BM'=67, 'AI'=68, 'TT'=69, 'KN'=70, 'DM'=71, 'AG'=72, 'LC'=73, 'TC'=74, 'AW'=75, 'VG'=76, 'VC'=77, 'MS'=78, 'MF'=79, 'BL'=80, 'GP'=81, 'GD'=82, 'KY'=83, 'BZ'=84, 'SV'=85, 'GT'=86, 'HN'=87, 'NI'=88, 'CR'=89, 'VE'=90, 'EC'=91, 'CO'=92, 'PA'=93, 'HT'=94, 'AR'=95, 'CL'=96, 'BO'=97, 'PE'=98, 'MX'=99, 'PF'=100, 'PN'=101, 'KI'=102, 'TK'=103, 'TO'=104, 'WF'=105, 'WS'=106, 'NU'=107, 'MP'=108, 'GU'=109, 'PR'=110, 'VI'=111, 'UM'=112, 'AS'=113, 'CA'=114, 'US'=115, 'PS'=116, 'RS'=117, 'AQ'=118, 'SX'=119, 'CW'=120, 'BQ'=121, 'SS'=122), - datetime DateTime, - url String, - url_host String MATERIALIZED lower(domain (url)), - url_path String MATERIALIZED lower(pathFull(url)), - request_start Nullable(UInt16), - response_start Nullable(UInt16), - response_end Nullable(UInt16), - dom_content_loaded_event_start Nullable(UInt16), - dom_content_loaded_event_end Nullable(UInt16), - load_event_start Nullable(UInt16), - load_event_end Nullable(UInt16), - first_paint Nullable(UInt16), - first_contentful_paint Nullable(UInt16), - speed_index Nullable(UInt16), - visually_complete Nullable(UInt16), - time_to_interactive Nullable(UInt16), - ttfb Nullable(UInt16) MATERIALIZED if (greaterOrEquals(response_start, request_start), minus(response_start, request_start), Null), - ttlb Nullable(UInt16) MATERIALIZED if (greaterOrEquals(response_end, request_start), minus(response_end, request_start), Null), - response_time Nullable(UInt16) MATERIALIZED if (greaterOrEquals(response_end, response_start), minus(response_end, response_start), Null), - dom_building_time Nullable(UInt16) MATERIALIZED if (greaterOrEquals(dom_content_loaded_event_start, response_end), minus(dom_content_loaded_event_start, response_end), Null), - dom_content_loaded_event_time Nullable(UInt16) MATERIALIZED if (greaterOrEquals(dom_content_loaded_event_end, dom_content_loaded_event_start), minus(dom_content_loaded_event_end, dom_content_loaded_event_start), Null), - load_event_time Nullable(UInt16) MATERIALIZED if (greaterOrEquals(load_event_end, load_event_start), minus(load_event_end, load_event_start), Null) -) ENGINE = MergeTree -PARTITION BY toStartOfWeek(datetime) -ORDER BY (project_id, datetime) -TTL datetime + INTERVAL 1 MONTH; diff --git a/ee/scripts/helm/db/init_dbs/clickhouse/create/performance.sql b/ee/scripts/helm/db/init_dbs/clickhouse/create/performance.sql deleted file mode 100644 index 650895662..000000000 --- a/ee/scripts/helm/db/init_dbs/clickhouse/create/performance.sql +++ /dev/null @@ -1,31 +0,0 @@ -CREATE TABLE IF NOT EXISTS performance -( - session_id UInt64, - project_id UInt32, - tracker_version String, - rev_id Nullable(String), - user_uuid UUID, - user_os String, - user_os_version Nullable(String), - user_browser String, - user_browser_version Nullable(String), - user_device Nullable(String), - user_device_type Enum8('other'=0, 'desktop'=1, 'mobile'=2), - user_country Enum8('UN'=-128, 'RW'=-127, 'SO'=-126, 'YE'=-125, 'IQ'=-124, 'SA'=-123, 'IR'=-122, 'CY'=-121, 'TZ'=-120, 'SY'=-119, 'AM'=-118, 'KE'=-117, 'CD'=-116, 'DJ'=-115, 'UG'=-114, 'CF'=-113, 'SC'=-112, 'JO'=-111, 'LB'=-110, 'KW'=-109, 'OM'=-108, 'QA'=-107, 'BH'=-106, 'AE'=-105, 'IL'=-104, 'TR'=-103, 'ET'=-102, 'ER'=-101, 'EG'=-100, 'SD'=-99, 'GR'=-98, 'BI'=-97, 'EE'=-96, 'LV'=-95, 'AZ'=-94, 'LT'=-93, 'SJ'=-92, 'GE'=-91, 'MD'=-90, 'BY'=-89, 'FI'=-88, 'AX'=-87, 'UA'=-86, 'MK'=-85, 'HU'=-84, 'BG'=-83, 'AL'=-82, 'PL'=-81, 'RO'=-80, 'XK'=-79, 'ZW'=-78, 'ZM'=-77, 'KM'=-76, 'MW'=-75, 'LS'=-74, 'BW'=-73, 'MU'=-72, 'SZ'=-71, 'RE'=-70, 'ZA'=-69, 'YT'=-68, 'MZ'=-67, 'MG'=-66, 'AF'=-65, 'PK'=-64, 'BD'=-63, 'TM'=-62, 'TJ'=-61, 'LK'=-60, 'BT'=-59, 'IN'=-58, 'MV'=-57, 'IO'=-56, 'NP'=-55, 'MM'=-54, 'UZ'=-53, 'KZ'=-52, 'KG'=-51, 'TF'=-50, 'HM'=-49, 'CC'=-48, 'PW'=-47, 'VN'=-46, 'TH'=-45, 'ID'=-44, 'LA'=-43, 'TW'=-42, 'PH'=-41, 'MY'=-40, 'CN'=-39, 'HK'=-38, 'BN'=-37, 'MO'=-36, 'KH'=-35, 'KR'=-34, 'JP'=-33, 'KP'=-32, 'SG'=-31, 'CK'=-30, 'TL'=-29, 'RU'=-28, 'MN'=-27, 'AU'=-26, 'CX'=-25, 'MH'=-24, 'FM'=-23, 'PG'=-22, 'SB'=-21, 'TV'=-20, 'NR'=-19, 'VU'=-18, 'NC'=-17, 'NF'=-16, 'NZ'=-15, 'FJ'=-14, 'LY'=-13, 'CM'=-12, 'SN'=-11, 'CG'=-10, 'PT'=-9, 'LR'=-8, 'CI'=-7, 'GH'=-6, 'GQ'=-5, 'NG'=-4, 'BF'=-3, 'TG'=-2, 'GW'=-1, 'MR'=0, 'BJ'=1, 'GA'=2, 'SL'=3, 'ST'=4, 'GI'=5, 'GM'=6, 'GN'=7, 'TD'=8, 'NE'=9, 'ML'=10, 'EH'=11, 'TN'=12, 'ES'=13, 'MA'=14, 'MT'=15, 'DZ'=16, 'FO'=17, 'DK'=18, 'IS'=19, 'GB'=20, 'CH'=21, 'SE'=22, 'NL'=23, 'AT'=24, 'BE'=25, 'DE'=26, 'LU'=27, 'IE'=28, 'MC'=29, 'FR'=30, 'AD'=31, 'LI'=32, 'JE'=33, 'IM'=34, 'GG'=35, 'SK'=36, 'CZ'=37, 'NO'=38, 'VA'=39, 'SM'=40, 'IT'=41, 'SI'=42, 'ME'=43, 'HR'=44, 'BA'=45, 'AO'=46, 'NA'=47, 'SH'=48, 'BV'=49, 'BB'=50, 'CV'=51, 'GY'=52, 'GF'=53, 'SR'=54, 'PM'=55, 'GL'=56, 'PY'=57, 'UY'=58, 'BR'=59, 'FK'=60, 'GS'=61, 'JM'=62, 'DO'=63, 'CU'=64, 'MQ'=65, 'BS'=66, 'BM'=67, 'AI'=68, 'TT'=69, 'KN'=70, 'DM'=71, 'AG'=72, 'LC'=73, 'TC'=74, 'AW'=75, 'VG'=76, 'VC'=77, 'MS'=78, 'MF'=79, 'BL'=80, 'GP'=81, 'GD'=82, 'KY'=83, 'BZ'=84, 'SV'=85, 'GT'=86, 'HN'=87, 'NI'=88, 'CR'=89, 'VE'=90, 'EC'=91, 'CO'=92, 'PA'=93, 'HT'=94, 'AR'=95, 'CL'=96, 'BO'=97, 'PE'=98, 'MX'=99, 'PF'=100, 'PN'=101, 'KI'=102, 'TK'=103, 'TO'=104, 'WF'=105, 'WS'=106, 'NU'=107, 'MP'=108, 'GU'=109, 'PR'=110, 'VI'=111, 'UM'=112, 'AS'=113, 'CA'=114, 'US'=115, 'PS'=116, 'RS'=117, 'AQ'=118, 'SX'=119, 'CW'=120, 'BQ'=121, 'SS'=122), - datetime DateTime, - min_fps UInt8, - avg_fps UInt8, - max_fps UInt8, - min_cpu UInt8, - avg_cpu UInt8, - max_cpu UInt8, - min_total_js_heap_size UInt64, - avg_total_js_heap_size UInt64, - max_total_js_heap_size UInt64, - min_used_js_heap_size UInt64, - avg_used_js_heap_size UInt64, - max_used_js_heap_size UInt64 -) ENGINE = MergeTree - PARTITION BY toStartOfWeek(datetime) - ORDER BY (project_id, datetime) - TTL datetime + INTERVAL 1 MONTH; diff --git a/ee/scripts/helm/db/init_dbs/clickhouse/create/resources.sql b/ee/scripts/helm/db/init_dbs/clickhouse/create/resources.sql deleted file mode 100644 index bfd4f0ea1..000000000 --- a/ee/scripts/helm/db/init_dbs/clickhouse/create/resources.sql +++ /dev/null @@ -1,32 +0,0 @@ -CREATE TABLE IF NOT EXISTS resources -( - session_id UInt64, - project_id UInt32, - tracker_version String, - rev_id Nullable(String), - user_uuid UUID, - user_os String, - user_os_version Nullable(String), - user_browser String, - user_browser_version Nullable(String), - user_device Nullable(String), - user_device_type Enum8('other'=0, 'desktop'=1, 'mobile'=2), - user_country Enum8('UN'=-128, 'RW'=-127, 'SO'=-126, 'YE'=-125, 'IQ'=-124, 'SA'=-123, 'IR'=-122, 'CY'=-121, 'TZ'=-120, 'SY'=-119, 'AM'=-118, 'KE'=-117, 'CD'=-116, 'DJ'=-115, 'UG'=-114, 'CF'=-113, 'SC'=-112, 'JO'=-111, 'LB'=-110, 'KW'=-109, 'OM'=-108, 'QA'=-107, 'BH'=-106, 'AE'=-105, 'IL'=-104, 'TR'=-103, 'ET'=-102, 'ER'=-101, 'EG'=-100, 'SD'=-99, 'GR'=-98, 'BI'=-97, 'EE'=-96, 'LV'=-95, 'AZ'=-94, 'LT'=-93, 'SJ'=-92, 'GE'=-91, 'MD'=-90, 'BY'=-89, 'FI'=-88, 'AX'=-87, 'UA'=-86, 'MK'=-85, 'HU'=-84, 'BG'=-83, 'AL'=-82, 'PL'=-81, 'RO'=-80, 'XK'=-79, 'ZW'=-78, 'ZM'=-77, 'KM'=-76, 'MW'=-75, 'LS'=-74, 'BW'=-73, 'MU'=-72, 'SZ'=-71, 'RE'=-70, 'ZA'=-69, 'YT'=-68, 'MZ'=-67, 'MG'=-66, 'AF'=-65, 'PK'=-64, 'BD'=-63, 'TM'=-62, 'TJ'=-61, 'LK'=-60, 'BT'=-59, 'IN'=-58, 'MV'=-57, 'IO'=-56, 'NP'=-55, 'MM'=-54, 'UZ'=-53, 'KZ'=-52, 'KG'=-51, 'TF'=-50, 'HM'=-49, 'CC'=-48, 'PW'=-47, 'VN'=-46, 'TH'=-45, 'ID'=-44, 'LA'=-43, 'TW'=-42, 'PH'=-41, 'MY'=-40, 'CN'=-39, 'HK'=-38, 'BN'=-37, 'MO'=-36, 'KH'=-35, 'KR'=-34, 'JP'=-33, 'KP'=-32, 'SG'=-31, 'CK'=-30, 'TL'=-29, 'RU'=-28, 'MN'=-27, 'AU'=-26, 'CX'=-25, 'MH'=-24, 'FM'=-23, 'PG'=-22, 'SB'=-21, 'TV'=-20, 'NR'=-19, 'VU'=-18, 'NC'=-17, 'NF'=-16, 'NZ'=-15, 'FJ'=-14, 'LY'=-13, 'CM'=-12, 'SN'=-11, 'CG'=-10, 'PT'=-9, 'LR'=-8, 'CI'=-7, 'GH'=-6, 'GQ'=-5, 'NG'=-4, 'BF'=-3, 'TG'=-2, 'GW'=-1, 'MR'=0, 'BJ'=1, 'GA'=2, 'SL'=3, 'ST'=4, 'GI'=5, 'GM'=6, 'GN'=7, 'TD'=8, 'NE'=9, 'ML'=10, 'EH'=11, 'TN'=12, 'ES'=13, 'MA'=14, 'MT'=15, 'DZ'=16, 'FO'=17, 'DK'=18, 'IS'=19, 'GB'=20, 'CH'=21, 'SE'=22, 'NL'=23, 'AT'=24, 'BE'=25, 'DE'=26, 'LU'=27, 'IE'=28, 'MC'=29, 'FR'=30, 'AD'=31, 'LI'=32, 'JE'=33, 'IM'=34, 'GG'=35, 'SK'=36, 'CZ'=37, 'NO'=38, 'VA'=39, 'SM'=40, 'IT'=41, 'SI'=42, 'ME'=43, 'HR'=44, 'BA'=45, 'AO'=46, 'NA'=47, 'SH'=48, 'BV'=49, 'BB'=50, 'CV'=51, 'GY'=52, 'GF'=53, 'SR'=54, 'PM'=55, 'GL'=56, 'PY'=57, 'UY'=58, 'BR'=59, 'FK'=60, 'GS'=61, 'JM'=62, 'DO'=63, 'CU'=64, 'MQ'=65, 'BS'=66, 'BM'=67, 'AI'=68, 'TT'=69, 'KN'=70, 'DM'=71, 'AG'=72, 'LC'=73, 'TC'=74, 'AW'=75, 'VG'=76, 'VC'=77, 'MS'=78, 'MF'=79, 'BL'=80, 'GP'=81, 'GD'=82, 'KY'=83, 'BZ'=84, 'SV'=85, 'GT'=86, 'HN'=87, 'NI'=88, 'CR'=89, 'VE'=90, 'EC'=91, 'CO'=92, 'PA'=93, 'HT'=94, 'AR'=95, 'CL'=96, 'BO'=97, 'PE'=98, 'MX'=99, 'PF'=100, 'PN'=101, 'KI'=102, 'TK'=103, 'TO'=104, 'WF'=105, 'WS'=106, 'NU'=107, 'MP'=108, 'GU'=109, 'PR'=110, 'VI'=111, 'UM'=112, 'AS'=113, 'CA'=114, 'US'=115, 'PS'=116, 'RS'=117, 'AQ'=118, 'SX'=119, 'CW'=120, 'BQ'=121, 'SS'=122), - datetime DateTime, - url String, - url_host String MATERIALIZED lower(domain(url)), - url_hostpath String MATERIALIZED concat(url_host, lower(path(url))), - type Enum8('other'=-1, 'script'=0, 'stylesheet'=1, 'fetch'=2, 'img'=3, 'media'=4), - duration Nullable(UInt16), - ttfb Nullable(UInt16), - header_size Nullable(UInt16), - encoded_body_size Nullable(UInt32), - decoded_body_size Nullable(UInt32), - compression_ratio Nullable(Float32) MATERIALIZED divide(decoded_body_size, encoded_body_size), - success UInt8, - method Nullable(Enum8('GET' = 0, 'HEAD' = 1, 'POST' = 2, 'PUT' = 3, 'DELETE' = 4, 'CONNECT' = 5, 'OPTIONS' = 6, 'TRACE' = 7, 'PATCH' = 8)), - status Nullable(UInt16) -) ENGINE = MergeTree - PARTITION BY toStartOfWeek(datetime) - ORDER BY (project_id, datetime) - TTL datetime + INTERVAL 1 MONTH; diff --git a/ee/scripts/helm/db/init_dbs/clickhouse/create/sessions.sql b/ee/scripts/helm/db/init_dbs/clickhouse/create/sessions.sql deleted file mode 100644 index f983496e1..000000000 --- a/ee/scripts/helm/db/init_dbs/clickhouse/create/sessions.sql +++ /dev/null @@ -1,26 +0,0 @@ -CREATE TABLE IF NOT EXISTS sessions -( - session_id UInt64, - project_id UInt32, - tracker_version String, - rev_id Nullable(String), - user_uuid UUID, - user_os String, - user_os_version Nullable(String), - user_browser String, - user_browser_version Nullable(String), - user_device Nullable(String), - user_device_type Enum8('other'=0, 'desktop'=1, 'mobile'=2), - user_country Enum8('UN'=-128, 'RW'=-127, 'SO'=-126, 'YE'=-125, 'IQ'=-124, 'SA'=-123, 'IR'=-122, 'CY'=-121, 'TZ'=-120, 'SY'=-119, 'AM'=-118, 'KE'=-117, 'CD'=-116, 'DJ'=-115, 'UG'=-114, 'CF'=-113, 'SC'=-112, 'JO'=-111, 'LB'=-110, 'KW'=-109, 'OM'=-108, 'QA'=-107, 'BH'=-106, 'AE'=-105, 'IL'=-104, 'TR'=-103, 'ET'=-102, 'ER'=-101, 'EG'=-100, 'SD'=-99, 'GR'=-98, 'BI'=-97, 'EE'=-96, 'LV'=-95, 'AZ'=-94, 'LT'=-93, 'SJ'=-92, 'GE'=-91, 'MD'=-90, 'BY'=-89, 'FI'=-88, 'AX'=-87, 'UA'=-86, 'MK'=-85, 'HU'=-84, 'BG'=-83, 'AL'=-82, 'PL'=-81, 'RO'=-80, 'XK'=-79, 'ZW'=-78, 'ZM'=-77, 'KM'=-76, 'MW'=-75, 'LS'=-74, 'BW'=-73, 'MU'=-72, 'SZ'=-71, 'RE'=-70, 'ZA'=-69, 'YT'=-68, 'MZ'=-67, 'MG'=-66, 'AF'=-65, 'PK'=-64, 'BD'=-63, 'TM'=-62, 'TJ'=-61, 'LK'=-60, 'BT'=-59, 'IN'=-58, 'MV'=-57, 'IO'=-56, 'NP'=-55, 'MM'=-54, 'UZ'=-53, 'KZ'=-52, 'KG'=-51, 'TF'=-50, 'HM'=-49, 'CC'=-48, 'PW'=-47, 'VN'=-46, 'TH'=-45, 'ID'=-44, 'LA'=-43, 'TW'=-42, 'PH'=-41, 'MY'=-40, 'CN'=-39, 'HK'=-38, 'BN'=-37, 'MO'=-36, 'KH'=-35, 'KR'=-34, 'JP'=-33, 'KP'=-32, 'SG'=-31, 'CK'=-30, 'TL'=-29, 'RU'=-28, 'MN'=-27, 'AU'=-26, 'CX'=-25, 'MH'=-24, 'FM'=-23, 'PG'=-22, 'SB'=-21, 'TV'=-20, 'NR'=-19, 'VU'=-18, 'NC'=-17, 'NF'=-16, 'NZ'=-15, 'FJ'=-14, 'LY'=-13, 'CM'=-12, 'SN'=-11, 'CG'=-10, 'PT'=-9, 'LR'=-8, 'CI'=-7, 'GH'=-6, 'GQ'=-5, 'NG'=-4, 'BF'=-3, 'TG'=-2, 'GW'=-1, 'MR'=0, 'BJ'=1, 'GA'=2, 'SL'=3, 'ST'=4, 'GI'=5, 'GM'=6, 'GN'=7, 'TD'=8, 'NE'=9, 'ML'=10, 'EH'=11, 'TN'=12, 'ES'=13, 'MA'=14, 'MT'=15, 'DZ'=16, 'FO'=17, 'DK'=18, 'IS'=19, 'GB'=20, 'CH'=21, 'SE'=22, 'NL'=23, 'AT'=24, 'BE'=25, 'DE'=26, 'LU'=27, 'IE'=28, 'MC'=29, 'FR'=30, 'AD'=31, 'LI'=32, 'JE'=33, 'IM'=34, 'GG'=35, 'SK'=36, 'CZ'=37, 'NO'=38, 'VA'=39, 'SM'=40, 'IT'=41, 'SI'=42, 'ME'=43, 'HR'=44, 'BA'=45, 'AO'=46, 'NA'=47, 'SH'=48, 'BV'=49, 'BB'=50, 'CV'=51, 'GY'=52, 'GF'=53, 'SR'=54, 'PM'=55, 'GL'=56, 'PY'=57, 'UY'=58, 'BR'=59, 'FK'=60, 'GS'=61, 'JM'=62, 'DO'=63, 'CU'=64, 'MQ'=65, 'BS'=66, 'BM'=67, 'AI'=68, 'TT'=69, 'KN'=70, 'DM'=71, 'AG'=72, 'LC'=73, 'TC'=74, 'AW'=75, 'VG'=76, 'VC'=77, 'MS'=78, 'MF'=79, 'BL'=80, 'GP'=81, 'GD'=82, 'KY'=83, 'BZ'=84, 'SV'=85, 'GT'=86, 'HN'=87, 'NI'=88, 'CR'=89, 'VE'=90, 'EC'=91, 'CO'=92, 'PA'=93, 'HT'=94, 'AR'=95, 'CL'=96, 'BO'=97, 'PE'=98, 'MX'=99, 'PF'=100, 'PN'=101, 'KI'=102, 'TK'=103, 'TO'=104, 'WF'=105, 'WS'=106, 'NU'=107, 'MP'=108, 'GU'=109, 'PR'=110, 'VI'=111, 'UM'=112, 'AS'=113, 'CA'=114, 'US'=115, 'PS'=116, 'RS'=117, 'AQ'=118, 'SX'=119, 'CW'=120, 'BQ'=121, 'SS'=122), - datetime DateTime, - duration UInt32, - pages_count UInt16, - events_count UInt16, - errors_count UInt16, - utm_source Nullable(String), - utm_medium Nullable(String), - utm_campaign Nullable(String) -) ENGINE = ReplacingMergeTree(duration) - PARTITION BY toStartOfWeek(datetime) - ORDER BY (project_id, datetime, session_id) - TTL datetime + INTERVAL 1 MONTH; diff --git a/ee/scripts/helm/db/init_dbs/clickhouse/create/sessions_metadata.sql b/ee/scripts/helm/db/init_dbs/clickhouse/create/sessions_metadata.sql deleted file mode 100644 index 2884b4515..000000000 --- a/ee/scripts/helm/db/init_dbs/clickhouse/create/sessions_metadata.sql +++ /dev/null @@ -1,31 +0,0 @@ -CREATE TABLE IF NOT EXISTS sessions_metadata -( - session_id UInt64, - project_id UInt32, - tracker_version String, - rev_id Nullable(String), - user_uuid UUID, - user_os String, - user_os_version Nullable(String), - user_browser String, - user_browser_version Nullable(String), - user_device Nullable(String), - user_device_type Enum8('other'=0, 'desktop'=1, 'mobile'=2), - user_country Enum8('UN'=-128, 'RW'=-127, 'SO'=-126, 'YE'=-125, 'IQ'=-124, 'SA'=-123, 'IR'=-122, 'CY'=-121, 'TZ'=-120, 'SY'=-119, 'AM'=-118, 'KE'=-117, 'CD'=-116, 'DJ'=-115, 'UG'=-114, 'CF'=-113, 'SC'=-112, 'JO'=-111, 'LB'=-110, 'KW'=-109, 'OM'=-108, 'QA'=-107, 'BH'=-106, 'AE'=-105, 'IL'=-104, 'TR'=-103, 'ET'=-102, 'ER'=-101, 'EG'=-100, 'SD'=-99, 'GR'=-98, 'BI'=-97, 'EE'=-96, 'LV'=-95, 'AZ'=-94, 'LT'=-93, 'SJ'=-92, 'GE'=-91, 'MD'=-90, 'BY'=-89, 'FI'=-88, 'AX'=-87, 'UA'=-86, 'MK'=-85, 'HU'=-84, 'BG'=-83, 'AL'=-82, 'PL'=-81, 'RO'=-80, 'XK'=-79, 'ZW'=-78, 'ZM'=-77, 'KM'=-76, 'MW'=-75, 'LS'=-74, 'BW'=-73, 'MU'=-72, 'SZ'=-71, 'RE'=-70, 'ZA'=-69, 'YT'=-68, 'MZ'=-67, 'MG'=-66, 'AF'=-65, 'PK'=-64, 'BD'=-63, 'TM'=-62, 'TJ'=-61, 'LK'=-60, 'BT'=-59, 'IN'=-58, 'MV'=-57, 'IO'=-56, 'NP'=-55, 'MM'=-54, 'UZ'=-53, 'KZ'=-52, 'KG'=-51, 'TF'=-50, 'HM'=-49, 'CC'=-48, 'PW'=-47, 'VN'=-46, 'TH'=-45, 'ID'=-44, 'LA'=-43, 'TW'=-42, 'PH'=-41, 'MY'=-40, 'CN'=-39, 'HK'=-38, 'BN'=-37, 'MO'=-36, 'KH'=-35, 'KR'=-34, 'JP'=-33, 'KP'=-32, 'SG'=-31, 'CK'=-30, 'TL'=-29, 'RU'=-28, 'MN'=-27, 'AU'=-26, 'CX'=-25, 'MH'=-24, 'FM'=-23, 'PG'=-22, 'SB'=-21, 'TV'=-20, 'NR'=-19, 'VU'=-18, 'NC'=-17, 'NF'=-16, 'NZ'=-15, 'FJ'=-14, 'LY'=-13, 'CM'=-12, 'SN'=-11, 'CG'=-10, 'PT'=-9, 'LR'=-8, 'CI'=-7, 'GH'=-6, 'GQ'=-5, 'NG'=-4, 'BF'=-3, 'TG'=-2, 'GW'=-1, 'MR'=0, 'BJ'=1, 'GA'=2, 'SL'=3, 'ST'=4, 'GI'=5, 'GM'=6, 'GN'=7, 'TD'=8, 'NE'=9, 'ML'=10, 'EH'=11, 'TN'=12, 'ES'=13, 'MA'=14, 'MT'=15, 'DZ'=16, 'FO'=17, 'DK'=18, 'IS'=19, 'GB'=20, 'CH'=21, 'SE'=22, 'NL'=23, 'AT'=24, 'BE'=25, 'DE'=26, 'LU'=27, 'IE'=28, 'MC'=29, 'FR'=30, 'AD'=31, 'LI'=32, 'JE'=33, 'IM'=34, 'GG'=35, 'SK'=36, 'CZ'=37, 'NO'=38, 'VA'=39, 'SM'=40, 'IT'=41, 'SI'=42, 'ME'=43, 'HR'=44, 'BA'=45, 'AO'=46, 'NA'=47, 'SH'=48, 'BV'=49, 'BB'=50, 'CV'=51, 'GY'=52, 'GF'=53, 'SR'=54, 'PM'=55, 'GL'=56, 'PY'=57, 'UY'=58, 'BR'=59, 'FK'=60, 'GS'=61, 'JM'=62, 'DO'=63, 'CU'=64, 'MQ'=65, 'BS'=66, 'BM'=67, 'AI'=68, 'TT'=69, 'KN'=70, 'DM'=71, 'AG'=72, 'LC'=73, 'TC'=74, 'AW'=75, 'VG'=76, 'VC'=77, 'MS'=78, 'MF'=79, 'BL'=80, 'GP'=81, 'GD'=82, 'KY'=83, 'BZ'=84, 'SV'=85, 'GT'=86, 'HN'=87, 'NI'=88, 'CR'=89, 'VE'=90, 'EC'=91, 'CO'=92, 'PA'=93, 'HT'=94, 'AR'=95, 'CL'=96, 'BO'=97, 'PE'=98, 'MX'=99, 'PF'=100, 'PN'=101, 'KI'=102, 'TK'=103, 'TO'=104, 'WF'=105, 'WS'=106, 'NU'=107, 'MP'=108, 'GU'=109, 'PR'=110, 'VI'=111, 'UM'=112, 'AS'=113, 'CA'=114, 'US'=115, 'PS'=116, 'RS'=117, 'AQ'=118, 'SX'=119, 'CW'=120, 'BQ'=121, 'SS'=122), - datetime DateTime, - user_id Nullable(String), - user_anonymous_id Nullable(String), - metadata_1 Nullable(String), - metadata_2 Nullable(String), - metadata_3 Nullable(String), - metadata_4 Nullable(String), - metadata_5 Nullable(String), - metadata_6 Nullable(String), - metadata_7 Nullable(String), - metadata_8 Nullable(String), - metadata_9 Nullable(String), - metadata_10 Nullable(String) -) ENGINE = MergeTree - PARTITION BY toStartOfWeek(datetime) - ORDER BY (project_id, datetime) - TTL datetime + INTERVAL 1 MONTH; \ No newline at end of file diff --git a/ee/scripts/helm/db/init_dbs/postgresql/1.8.0/1.8.0.sql b/ee/scripts/helm/db/init_dbs/postgresql/1.8.0/1.8.0.sql new file mode 100644 index 000000000..8347a5c78 --- /dev/null +++ b/ee/scripts/helm/db/init_dbs/postgresql/1.8.0/1.8.0.sql @@ -0,0 +1,51 @@ +BEGIN; +CREATE OR REPLACE FUNCTION openreplay_version() + RETURNS text AS +$$ +SELECT 'v1.8.0-ee' +$$ LANGUAGE sql IMMUTABLE; + +ALTER TABLE IF EXISTS projects + ADD COLUMN IF NOT EXISTS first_recorded_session_at timestamp without time zone NULL DEFAULT NULL, + ADD COLUMN IF NOT EXISTS sessions_last_check_at timestamp without time zone NULL DEFAULT NULL; + + +DO +$$ + BEGIN + IF NOT EXISTS(SELECT * + FROM pg_type typ + INNER JOIN pg_namespace nsp + ON nsp.oid = typ.typnamespace + WHERE nsp.nspname = current_schema() + AND typ.typname = 'alert_change_type') THEN + CREATE TYPE alert_change_type AS ENUM ('percent', 'change'); + END IF; + END; +$$ +LANGUAGE plpgsql; + +ALTER TABLE IF EXISTS alerts + ADD COLUMN IF NOT EXISTS change alert_change_type NOT NULL DEFAULT 'change'; + +ALTER TABLE IF EXISTS sessions + ADD COLUMN IF NOT EXISTS referrer text NULL DEFAULT NULL, + ADD COLUMN IF NOT EXISTS base_referrer text NULL DEFAULT NULL; +CREATE INDEX IF NOT EXISTS sessions_base_referrer_gin_idx ON public.sessions USING GIN (base_referrer gin_trgm_ops); + +ALTER TABLE IF EXISTS events.performance + ADD COLUMN IF NOT EXISTS host text NULL DEFAULT NULL, + ADD COLUMN IF NOT EXISTS path text NULL DEFAULT NULL, + ADD COLUMN IF NOT EXISTS query text NULL DEFAULT NULL; + +COMMIT; + +CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS autocomplete_unique_project_id_md5value_type_idx ON autocomplete (project_id, md5(value), type); + +BEGIN; + +DROP INDEX IF EXISTS autocomplete_unique; +DROP INDEX IF EXISTS events_common.requests_response_body_nn_idx; +DROP INDEX IF EXISTS events_common.requests_request_body_nn_idx; + +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 e236ce90e..91cb307eb 100644 --- a/ee/scripts/helm/db/init_dbs/postgresql/init_schema.sql +++ b/ee/scripts/helm/db/init_dbs/postgresql/init_schema.sql @@ -7,7 +7,7 @@ CREATE EXTENSION IF NOT EXISTS pgcrypto; CREATE OR REPLACE FUNCTION openreplay_version() RETURNS text AS $$ -SELECT 'v1.7.0-ee' +SELECT 'v1.8.0-ee' $$ LANGUAGE sql IMMUTABLE; @@ -228,32 +228,34 @@ $$ CREATE TABLE IF NOT EXISTS projects ( - project_id integer generated BY DEFAULT AS IDENTITY PRIMARY KEY, - project_key varchar(20) NOT NULL UNIQUE DEFAULT generate_api_key(20), - tenant_id integer NOT NULL REFERENCES tenants (tenant_id) ON DELETE CASCADE, - name text NOT NULL, - active boolean NOT NULL, - sample_rate smallint NOT NULL DEFAULT 100 CHECK (sample_rate >= 0 AND sample_rate <= 100), - created_at timestamp without time zone NOT NULL DEFAULT (now() at time zone 'utc'), - deleted_at timestamp without time zone NULL DEFAULT NULL, - max_session_duration integer NOT NULL DEFAULT 7200000, - metadata_1 text DEFAULT NULL, - metadata_2 text DEFAULT NULL, - metadata_3 text DEFAULT NULL, - metadata_4 text DEFAULT NULL, - metadata_5 text DEFAULT NULL, - metadata_6 text DEFAULT NULL, - metadata_7 text DEFAULT NULL, - metadata_8 text DEFAULT NULL, - metadata_9 text DEFAULT NULL, - metadata_10 text DEFAULT NULL, - save_request_payloads boolean NOT NULL DEFAULT FALSE, - gdpr jsonb NOT NULL DEFAULT'{ + project_id integer generated BY DEFAULT AS IDENTITY PRIMARY KEY, + project_key varchar(20) NOT NULL UNIQUE DEFAULT generate_api_key(20), + tenant_id integer NOT NULL REFERENCES tenants (tenant_id) ON DELETE CASCADE, + name text NOT NULL, + active boolean NOT NULL, + sample_rate smallint NOT NULL DEFAULT 100 CHECK (sample_rate >= 0 AND sample_rate <= 100), + created_at timestamp without time zone NOT NULL DEFAULT (now() at time zone 'utc'), + deleted_at timestamp without time zone NULL DEFAULT NULL, + max_session_duration integer NOT NULL DEFAULT 7200000, + metadata_1 text DEFAULT NULL, + metadata_2 text DEFAULT NULL, + metadata_3 text DEFAULT NULL, + metadata_4 text DEFAULT NULL, + metadata_5 text DEFAULT NULL, + metadata_6 text DEFAULT NULL, + metadata_7 text DEFAULT NULL, + metadata_8 text DEFAULT NULL, + metadata_9 text DEFAULT NULL, + metadata_10 text DEFAULT NULL, + save_request_payloads boolean NOT NULL DEFAULT FALSE, + gdpr jsonb NOT NULL DEFAULT'{ "maskEmails": true, "sampleRate": 33, "maskNumbers": false, "defaultInputMode": "plain" - }'::jsonb + }'::jsonb, + first_recorded_session_at timestamp without time zone NULL DEFAULT NULL, + sessions_last_check_at timestamp without time zone NULL DEFAULT NULL ); @@ -545,6 +547,8 @@ $$ utm_source text NULL DEFAULT NULL, utm_medium text NULL DEFAULT NULL, utm_campaign text NULL DEFAULT NULL, + referrer text NULL DEFAULT NULL, + base_referrer text NULL DEFAULT NULL, metadata_1 text DEFAULT NULL, metadata_2 text DEFAULT NULL, metadata_3 text DEFAULT NULL, @@ -600,6 +604,7 @@ $$ CREATE INDEX IF NOT EXISTS sessions_utm_source_gin_idx ON public.sessions USING GIN (utm_source gin_trgm_ops); CREATE INDEX IF NOT EXISTS sessions_utm_medium_gin_idx ON public.sessions USING GIN (utm_medium gin_trgm_ops); CREATE INDEX IF NOT EXISTS sessions_utm_campaign_gin_idx ON public.sessions USING GIN (utm_campaign gin_trgm_ops); + CREATE INDEX IF NOT EXISTS sessions_base_referrer_gin_idx ON public.sessions USING GIN (base_referrer gin_trgm_ops); BEGIN ALTER TABLE public.sessions ADD CONSTRAINT web_browser_constraint CHECK ( @@ -659,24 +664,24 @@ $$ ); CREATE UNIQUE INDEX IF NOT EXISTS autocomplete_unique_project_id_md5value_type_idx ON autocomplete (project_id, md5(value), type); - CREATE index IF NOT EXISTS autocomplete_project_id_idx ON autocomplete (project_id); + CREATE INDEX IF NOT EXISTS autocomplete_project_id_idx ON autocomplete (project_id); CREATE INDEX IF NOT EXISTS autocomplete_type_idx ON public.autocomplete (type); - CREATE INDEX autocomplete_value_clickonly_gin_idx ON public.autocomplete USING GIN (value gin_trgm_ops) WHERE type = 'CLICK'; - CREATE INDEX autocomplete_value_customonly_gin_idx ON public.autocomplete USING GIN (value gin_trgm_ops) WHERE type = 'CUSTOM'; - CREATE INDEX autocomplete_value_graphqlonly_gin_idx ON public.autocomplete USING GIN (value gin_trgm_ops) WHERE type = 'GRAPHQL'; - CREATE INDEX autocomplete_value_inputonly_gin_idx ON public.autocomplete USING GIN (value gin_trgm_ops) WHERE type = 'INPUT'; - CREATE INDEX autocomplete_value_locationonly_gin_idx ON public.autocomplete USING GIN (value gin_trgm_ops) WHERE type = 'LOCATION'; - CREATE INDEX autocomplete_value_referreronly_gin_idx ON public.autocomplete USING GIN (value gin_trgm_ops) WHERE type = 'REFERRER'; - CREATE INDEX autocomplete_value_requestonly_gin_idx ON public.autocomplete USING GIN (value gin_trgm_ops) WHERE type = 'REQUEST'; - CREATE INDEX autocomplete_value_revidonly_gin_idx ON public.autocomplete USING GIN (value gin_trgm_ops) WHERE type = 'REVID'; - CREATE INDEX autocomplete_value_stateactiononly_gin_idx ON public.autocomplete USING GIN (value gin_trgm_ops) WHERE type = 'STATEACTION'; - CREATE INDEX autocomplete_value_useranonymousidonly_gin_idx ON public.autocomplete USING GIN (value gin_trgm_ops) WHERE type = 'USERANONYMOUSID'; - CREATE INDEX autocomplete_value_userbrowseronly_gin_idx ON public.autocomplete USING GIN (value gin_trgm_ops) WHERE type = 'USERBROWSER'; - CREATE INDEX autocomplete_value_usercountryonly_gin_idx ON public.autocomplete USING GIN (value gin_trgm_ops) WHERE type = 'USERCOUNTRY'; - CREATE INDEX autocomplete_value_userdeviceonly_gin_idx ON public.autocomplete USING GIN (value gin_trgm_ops) WHERE type = 'USERDEVICE'; - CREATE INDEX autocomplete_value_useridonly_gin_idx ON public.autocomplete USING GIN (value gin_trgm_ops) WHERE type = 'USERID'; - CREATE INDEX autocomplete_value_userosonly_gin_idx ON public.autocomplete USING GIN (value gin_trgm_ops) WHERE type = 'USEROS'; + CREATE INDEX IF NOT EXISTS autocomplete_value_clickonly_gin_idx ON public.autocomplete USING GIN (value gin_trgm_ops) WHERE type = 'CLICK'; + CREATE INDEX IF NOT EXISTS autocomplete_value_customonly_gin_idx ON public.autocomplete USING GIN (value gin_trgm_ops) WHERE type = 'CUSTOM'; + CREATE INDEX IF NOT EXISTS autocomplete_value_graphqlonly_gin_idx ON public.autocomplete USING GIN (value gin_trgm_ops) WHERE type = 'GRAPHQL'; + CREATE INDEX IF NOT EXISTS autocomplete_value_inputonly_gin_idx ON public.autocomplete USING GIN (value gin_trgm_ops) WHERE type = 'INPUT'; + CREATE INDEX IF NOT EXISTS autocomplete_value_locationonly_gin_idx ON public.autocomplete USING GIN (value gin_trgm_ops) WHERE type = 'LOCATION'; + CREATE INDEX IF NOT EXISTS autocomplete_value_referreronly_gin_idx ON public.autocomplete USING GIN (value gin_trgm_ops) WHERE type = 'REFERRER'; + CREATE INDEX IF NOT EXISTS autocomplete_value_requestonly_gin_idx ON public.autocomplete USING GIN (value gin_trgm_ops) WHERE type = 'REQUEST'; + CREATE INDEX IF NOT EXISTS autocomplete_value_revidonly_gin_idx ON public.autocomplete USING GIN (value gin_trgm_ops) WHERE type = 'REVID'; + CREATE INDEX IF NOT EXISTS autocomplete_value_stateactiononly_gin_idx ON public.autocomplete USING GIN (value gin_trgm_ops) WHERE type = 'STATEACTION'; + CREATE INDEX IF NOT EXISTS autocomplete_value_useranonymousidonly_gin_idx ON public.autocomplete USING GIN (value gin_trgm_ops) WHERE type = 'USERANONYMOUSID'; + CREATE INDEX IF NOT EXISTS autocomplete_value_userbrowseronly_gin_idx ON public.autocomplete USING GIN (value gin_trgm_ops) WHERE type = 'USERBROWSER'; + CREATE INDEX IF NOT EXISTS autocomplete_value_usercountryonly_gin_idx ON public.autocomplete USING GIN (value gin_trgm_ops) WHERE type = 'USERCOUNTRY'; + CREATE INDEX IF NOT EXISTS autocomplete_value_userdeviceonly_gin_idx ON public.autocomplete USING GIN (value gin_trgm_ops) WHERE type = 'USERDEVICE'; + CREATE INDEX IF NOT EXISTS autocomplete_value_useridonly_gin_idx ON public.autocomplete USING GIN (value gin_trgm_ops) WHERE type = 'USERID'; + CREATE INDEX IF NOT EXISTS autocomplete_value_userosonly_gin_idx ON public.autocomplete USING GIN (value gin_trgm_ops) WHERE type = 'USEROS'; BEGIN IF NOT EXISTS(SELECT * @@ -817,6 +822,13 @@ $$ WHERE typ.typname = 'alert_detection_method') THEN CREATE TYPE alert_detection_method AS ENUM ('threshold', 'change'); END IF; + + IF NOT EXISTS(SELECT * + FROM pg_type typ + WHERE typ.typname = 'alert_change_type') THEN + CREATE TYPE alert_change_type AS ENUM ('percent', 'change'); + END IF; + CREATE TABLE IF NOT EXISTS alerts ( alert_id integer generated BY DEFAULT AS IDENTITY PRIMARY KEY, @@ -826,6 +838,7 @@ $$ description text NULL DEFAULT NULL, active boolean NOT NULL DEFAULT TRUE, detection_method alert_detection_method NOT NULL, + change alert_change_type NOT NULL DEFAULT 'change', query jsonb NOT NULL, deleted_at timestamp NULL DEFAULT NULL, created_at timestamp NOT NULL DEFAULT timezone('utc'::text, now()), @@ -1082,6 +1095,9 @@ $$ session_id bigint NOT NULL REFERENCES sessions (session_id) ON DELETE CASCADE, timestamp bigint NOT NULL, message_id bigint NOT NULL, + host text NULL DEFAULT NULL, + path text NULL DEFAULT NULL, + query text NULL DEFAULT NULL, min_fps smallint NOT NULL, avg_fps smallint NOT NULL, max_fps smallint NOT NULL, @@ -1188,9 +1204,7 @@ $$ ELSE 0 END)) gin_trgm_ops); CREATE INDEX IF NOT EXISTS requests_timestamp_session_id_failed_idx ON events_common.requests (timestamp, session_id) WHERE success = FALSE; - CREATE INDEX IF NOT EXISTS requests_request_body_nn_idx ON events_common.requests (request_body) WHERE request_body IS NOT NULL; CREATE INDEX IF NOT EXISTS requests_request_body_nn_gin_idx ON events_common.requests USING GIN (request_body gin_trgm_ops) WHERE request_body IS NOT NULL; - CREATE INDEX IF NOT EXISTS requests_response_body_nn_idx ON events_common.requests (response_body) WHERE response_body IS NOT NULL; CREATE INDEX IF NOT EXISTS requests_response_body_nn_gin_idx ON events_common.requests USING GIN (response_body gin_trgm_ops) WHERE response_body IS NOT NULL; CREATE INDEX IF NOT EXISTS requests_status_code_nn_idx ON events_common.requests (status_code) WHERE status_code IS NOT NULL; CREATE INDEX IF NOT EXISTS requests_host_nn_idx ON events_common.requests (host) WHERE host IS NOT NULL; diff --git a/ee/scripts/helm/roles/openreplay/defaults/main.yaml b/ee/scripts/helm/roles/openreplay/defaults/main.yaml index eb9071ff3..5199a0a19 100644 --- a/ee/scripts/helm/roles/openreplay/defaults/main.yaml +++ b/ee/scripts/helm/roles/openreplay/defaults/main.yaml @@ -10,6 +10,9 @@ db_list: - "kafka" env: + alerts: + ch_host: "clickhouse.db.svc.cluster.local" + ch_port: "9000" chalice: ch_host: "clickhouse.db.svc.cluster.local" ch_port: "9000" diff --git a/ee/utilities/.gitignore b/ee/utilities/.gitignore index 0eaed6d80..a11e6be97 100644 --- a/ee/utilities/.gitignore +++ b/ee/utilities/.gitignore @@ -14,3 +14,4 @@ servers/sourcemaps-server.js /utils/HeapSnapshot.js /utils/helper.js /utils/assistHelper.js +.local diff --git a/ee/utilities/Dockerfile b/ee/utilities/Dockerfile index 9b7b96388..e3d5d4a0a 100644 --- a/ee/utilities/Dockerfile +++ b/ee/utilities/Dockerfile @@ -1,6 +1,5 @@ FROM node:18-alpine LABEL Maintainer="KRAIEM Taha Yassine" -RUN apk upgrade busybox --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/main RUN apk add --no-cache tini git libc6-compat && ln -s /lib/libc.musl-x86_64.so.1 /lib/ld-linux-x86-64.so.2 ARG envarg diff --git a/ee/utilities/package-lock.json b/ee/utilities/package-lock.json index 6b9dbdf1c..1c14c5f25 100644 --- a/ee/utilities/package-lock.json +++ b/ee/utilities/package-lock.json @@ -10,9 +10,9 @@ "license": "Elastic License 2.0 (ELv2)", "dependencies": { "@maxmind/geoip2-node": "^3.4.0", - "@socket.io/redis-adapter": "^7.1.0", - "express": "^4.17.1", - "redis": "^4.0.3", + "@socket.io/redis-adapter": "^7.2.0", + "express": "^4.18.1", + "redis": "^4.2.0", "socket.io": "^4.5.1", "ua-parser-js": "^1.0.2", "uWebSockets.js": "github:uNetworking/uWebSockets.js#v20.10.0" diff --git a/ee/utilities/package.json b/ee/utilities/package.json index bd35ec6a6..ba3997a90 100644 --- a/ee/utilities/package.json +++ b/ee/utilities/package.json @@ -19,9 +19,9 @@ "homepage": "https://github.com/openreplay/openreplay#readme", "dependencies": { "@maxmind/geoip2-node": "^3.4.0", - "@socket.io/redis-adapter": "^7.1.0", - "express": "^4.17.1", - "redis": "^4.0.3", + "@socket.io/redis-adapter": "^7.2.0", + "express": "^4.18.1", + "redis": "^4.2.0", "socket.io": "^4.5.1", "ua-parser-js": "^1.0.2", "uWebSockets.js": "github:uNetworking/uWebSockets.js#v20.10.0" diff --git a/ee/utilities/servers/websocket-cluster.js b/ee/utilities/servers/websocket-cluster.js index b0649127c..734dbfb4e 100644 --- a/ee/utilities/servers/websocket-cluster.js +++ b/ee/utilities/servers/websocket-cluster.js @@ -9,7 +9,11 @@ const { uniqueAutocomplete } = require('../utils/helper'); const { - extractSessionInfo + IDENTITIES, + EVENTS_DEFINITION, + extractSessionInfo, + socketConnexionTimeout, + errorHandler } = require('../utils/assistHelper'); const { extractProjectKeyFromRequest, @@ -19,15 +23,6 @@ const { const {createAdapter} = require("@socket.io/redis-adapter"); const {createClient} = require("redis"); const wsRouter = express.Router(); -const UPDATE_EVENT = "UPDATE_SESSION"; -const IDENTITIES = {agent: 'agent', session: 'session'}; -const NEW_AGENT = "NEW_AGENT"; -const NO_AGENTS = "NO_AGENT"; -const AGENT_DISCONNECT = "AGENT_DISCONNECTED"; -const AGENTS_CONNECTED = "AGENTS_CONNECTED"; -const NO_SESSIONS = "SESSION_DISCONNECTED"; -const SESSION_ALREADY_CONNECTED = "SESSION_ALREADY_CONNECTED"; -const SESSION_RECONNECTED = "SESSION_RECONNECTED"; const REDIS_URL = process.env.REDIS_URL || "redis://localhost:6379"; const pubClient = createClient({url: REDIS_URL}); const subClient = pubClient.duplicate(); @@ -289,26 +284,27 @@ module.exports = { createSocketIOServer(server, prefix); io.on('connection', async (socket) => { debug && console.log(`WS started:${socket.id}, Query:${JSON.stringify(socket.handshake.query)}`); + socket._connectedAt = new Date(); socket.peerId = socket.handshake.query.peerId; socket.identity = socket.handshake.query.identity; let {c_sessions, c_agents} = await sessions_agents_count(io, socket); if (socket.identity === IDENTITIES.session) { if (c_sessions > 0) { debug && console.log(`session already connected, refusing new connexion`); - io.to(socket.id).emit(SESSION_ALREADY_CONNECTED); + io.to(socket.id).emit(EVENTS_DEFINITION.emit.SESSION_ALREADY_CONNECTED); return socket.disconnect(); } extractSessionInfo(socket); if (c_agents > 0) { debug && console.log(`notifying new session about agent-existence`); let agents_ids = await get_all_agents_ids(io, socket); - io.to(socket.id).emit(AGENTS_CONNECTED, agents_ids); - socket.to(socket.peerId).emit(SESSION_RECONNECTED, socket.id); + io.to(socket.id).emit(EVENTS_DEFINITION.emit.AGENTS_CONNECTED, agents_ids); + socket.to(socket.peerId).emit(EVENTS_DEFINITION.emit.SESSION_RECONNECTED, socket.id); } } else if (c_sessions <= 0) { debug && console.log(`notifying new agent about no SESSIONS`); - io.to(socket.id).emit(NO_SESSIONS); + io.to(socket.id).emit(EVENTS_DEFINITION.emit.NO_SESSIONS); } await io.of('/').adapter.remoteJoin(socket.id, socket.peerId); let rooms = await io.of('/').adapter.allRooms(); @@ -320,13 +316,13 @@ module.exports = { if (socket.handshake.query.agentInfo !== undefined) { socket.handshake.query.agentInfo = JSON.parse(socket.handshake.query.agentInfo); } - socket.to(socket.peerId).emit(NEW_AGENT, socket.id, socket.handshake.query.agentInfo); + socket.to(socket.peerId).emit(EVENTS_DEFINITION.emit.NEW_AGENT, socket.id, socket.handshake.query.agentInfo); } socket.on('disconnect', async () => { debug && console.log(`${socket.id} disconnected from ${socket.peerId}`); if (socket.identity === IDENTITIES.agent) { - socket.to(socket.peerId).emit(AGENT_DISCONNECT, socket.id); + socket.to(socket.peerId).emit(EVENTS_DEFINITION.emit.AGENT_DISCONNECT, socket.id); } debug && console.log("checking for number of connected agents and sessions"); let {c_sessions, c_agents} = await sessions_agents_count(io, socket); @@ -335,25 +331,32 @@ module.exports = { } if (c_sessions === 0) { debug && console.log(`notifying everyone in ${socket.peerId} about no SESSIONS`); - socket.to(socket.peerId).emit(NO_SESSIONS); + socket.to(socket.peerId).emit(EVENTS_DEFINITION.emit.NO_SESSIONS); } if (c_agents === 0) { debug && console.log(`notifying everyone in ${socket.peerId} about no AGENTS`); - socket.to(socket.peerId).emit(NO_AGENTS); + socket.to(socket.peerId).emit(EVENTS_DEFINITION.emit.NO_AGENTS); } }); - socket.on(UPDATE_EVENT, async (...args) => { + socket.on(EVENTS_DEFINITION.listen.UPDATE_EVENT, async (...args) => { debug && console.log(`${socket.id} sent update event.`); if (socket.identity !== IDENTITIES.session) { debug && console.log('Ignoring update event.'); return } socket.handshake.query.sessionInfo = {...socket.handshake.query.sessionInfo, ...args[0]}; - socket.to(socket.peerId).emit(UPDATE_EVENT, args[0]); + socket.to(socket.peerId).emit(EVENTS_DEFINITION.emit.UPDATE_EVENT, args[0]); }); + socket.on(EVENTS_DEFINITION.listen.CONNECT_ERROR, err => errorHandler(EVENTS_DEFINITION.listen.CONNECT_ERROR, err)); + socket.on(EVENTS_DEFINITION.listen.CONNECT_FAILED, err => errorHandler(EVENTS_DEFINITION.listen.CONNECT_FAILED, err)); + socket.onAny(async (eventName, ...args) => { + if (Object.values(EVENTS_DEFINITION.listen).indexOf(eventName) >= 0) { + debug && console.log(`received event:${eventName}, should be handled by another listener, stopping onAny.`); + return + } if (socket.identity === IDENTITIES.session) { debug && console.log(`received event:${eventName}, from:${socket.identity}, sending message to room:${socket.peerId}`); socket.to(socket.peerId).emit(eventName, args[0]); @@ -362,7 +365,7 @@ module.exports = { let socketId = await findSessionSocketId(io, socket.peerId); if (socketId === null) { debug && console.log(`session not found for:${socket.peerId}`); - io.to(socket.id).emit(NO_SESSIONS); + io.to(socket.id).emit(EVENTS_DEFINITION.emit.NO_SESSIONS); } else { debug && console.log("message sent"); io.to(socketId).emit(eventName, socket.id, args[0]); @@ -371,7 +374,7 @@ module.exports = { }); }); - console.log("WS server started") + console.log("WS server started"); setInterval(async (io) => { try { let rooms = await io.of('/').adapter.allRooms(); @@ -389,13 +392,16 @@ module.exports = { if (debug) { for (let item of validRooms) { let connectedSockets = await io.in(item).fetchSockets(); - console.log(`Room: ${item} connected: ${connectedSockets.length}`) + console.log(`Room: ${item} connected: ${connectedSockets.length}`); } } } catch (e) { console.error(e); } - }, 20000, io); + }, 30000, io); + + socketConnexionTimeout(io); + Promise.all([pubClient.connect(), subClient.connect()]) .then(() => { io.adapter(createAdapter(pubClient, subClient)); diff --git a/ee/utilities/servers/websocket.js b/ee/utilities/servers/websocket.js index 4fa61aa42..782f70348 100644 --- a/ee/utilities/servers/websocket.js +++ b/ee/utilities/servers/websocket.js @@ -9,7 +9,11 @@ const { uniqueAutocomplete } = require('../utils/helper'); const { - extractSessionInfo + IDENTITIES, + EVENTS_DEFINITION, + extractSessionInfo, + socketConnexionTimeout, + errorHandler } = require('../utils/assistHelper'); const { extractProjectKeyFromRequest, @@ -17,15 +21,6 @@ const { extractPayloadFromRequest, } = require('../utils/helper-ee'); const wsRouter = express.Router(); -const UPDATE_EVENT = "UPDATE_SESSION"; -const IDENTITIES = {agent: 'agent', session: 'session'}; -const NEW_AGENT = "NEW_AGENT"; -const NO_AGENTS = "NO_AGENT"; -const AGENT_DISCONNECT = "AGENT_DISCONNECTED"; -const AGENTS_CONNECTED = "AGENTS_CONNECTED"; -const NO_SESSIONS = "SESSION_DISCONNECTED"; -const SESSION_ALREADY_CONNECTED = "SESSION_ALREADY_CONNECTED"; -const SESSION_RECONNECTED = "SESSION_RECONNECTED"; let io; const debug = process.env.debug === "1" || false; @@ -267,26 +262,27 @@ module.exports = { createSocketIOServer(server, prefix); io.on('connection', async (socket) => { debug && console.log(`WS started:${socket.id}, Query:${JSON.stringify(socket.handshake.query)}`); + socket._connectedAt = new Date(); socket.peerId = socket.handshake.query.peerId; socket.identity = socket.handshake.query.identity; let {c_sessions, c_agents} = await sessions_agents_count(io, socket); if (socket.identity === IDENTITIES.session) { if (c_sessions > 0) { debug && console.log(`session already connected, refusing new connexion`); - io.to(socket.id).emit(SESSION_ALREADY_CONNECTED); + io.to(socket.id).emit(EVENTS_DEFINITION.emit.SESSION_ALREADY_CONNECTED); return socket.disconnect(); } extractSessionInfo(socket); if (c_agents > 0) { debug && console.log(`notifying new session about agent-existence`); let agents_ids = await get_all_agents_ids(io, socket); - io.to(socket.id).emit(AGENTS_CONNECTED, agents_ids); - socket.to(socket.peerId).emit(SESSION_RECONNECTED, socket.id); + io.to(socket.id).emit(EVENTS_DEFINITION.emit.AGENTS_CONNECTED, agents_ids); + socket.to(socket.peerId).emit(EVENTS_DEFINITION.emit.SESSION_RECONNECTED, socket.id); } } else if (c_sessions <= 0) { debug && console.log(`notifying new agent about no SESSIONS`); - io.to(socket.id).emit(NO_SESSIONS); + io.to(socket.id).emit(EVENTS_DEFINITION.emit.NO_SESSIONS); } socket.join(socket.peerId); if (io.sockets.adapter.rooms.get(socket.peerId)) { @@ -296,13 +292,13 @@ module.exports = { if (socket.handshake.query.agentInfo !== undefined) { socket.handshake.query.agentInfo = JSON.parse(socket.handshake.query.agentInfo); } - socket.to(socket.peerId).emit(NEW_AGENT, socket.id, socket.handshake.query.agentInfo); + socket.to(socket.peerId).emit(EVENTS_DEFINITION.emit.NEW_AGENT, socket.id, socket.handshake.query.agentInfo); } socket.on('disconnect', async () => { debug && console.log(`${socket.id} disconnected from ${socket.peerId}`); if (socket.identity === IDENTITIES.agent) { - socket.to(socket.peerId).emit(AGENT_DISCONNECT, socket.id); + socket.to(socket.peerId).emit(EVENTS_DEFINITION.emit.AGENT_DISCONNECT, socket.id); } debug && console.log("checking for number of connected agents and sessions"); let {c_sessions, c_agents} = await sessions_agents_count(io, socket); @@ -311,25 +307,32 @@ module.exports = { } if (c_sessions === 0) { debug && console.log(`notifying everyone in ${socket.peerId} about no SESSIONS`); - socket.to(socket.peerId).emit(NO_SESSIONS); + socket.to(socket.peerId).emit(EVENTS_DEFINITION.emit.NO_SESSIONS); } if (c_agents === 0) { debug && console.log(`notifying everyone in ${socket.peerId} about no AGENTS`); - socket.to(socket.peerId).emit(NO_AGENTS); + socket.to(socket.peerId).emit(EVENTS_DEFINITION.emit.NO_AGENTS); } }); - socket.on(UPDATE_EVENT, async (...args) => { + socket.on(EVENTS_DEFINITION.listen.UPDATE_EVENT, async (...args) => { debug && console.log(`${socket.id} sent update event.`); if (socket.identity !== IDENTITIES.session) { debug && console.log('Ignoring update event.'); return } socket.handshake.query.sessionInfo = {...socket.handshake.query.sessionInfo, ...args[0]}; - socket.to(socket.peerId).emit(UPDATE_EVENT, args[0]); + socket.to(socket.peerId).emit(EVENTS_DEFINITION.emit.UPDATE_EVENT, args[0]); }); + socket.on(EVENTS_DEFINITION.listen.CONNECT_ERROR, err => errorHandler(EVENTS_DEFINITION.listen.CONNECT_ERROR, err)); + socket.on(EVENTS_DEFINITION.listen.CONNECT_FAILED, err => errorHandler(EVENTS_DEFINITION.listen.CONNECT_FAILED, err)); + socket.onAny(async (eventName, ...args) => { + if (Object.values(EVENTS_DEFINITION.listen).indexOf(eventName) >= 0) { + debug && console.log(`received event:${eventName}, should be handled by another listener, stopping onAny.`); + return + } if (socket.identity === IDENTITIES.session) { debug && console.log(`received event:${eventName}, from:${socket.identity}, sending message to room:${socket.peerId}`); socket.to(socket.peerId).emit(eventName, args[0]); @@ -338,7 +341,7 @@ module.exports = { let socketId = await findSessionSocketId(io, socket.peerId); if (socketId === null) { debug && console.log(`session not found for:${socket.peerId}`); - io.to(socket.id).emit(NO_SESSIONS); + io.to(socket.id).emit(EVENTS_DEFINITION.emit.NO_SESSIONS); } else { debug && console.log("message sent"); io.to(socketId).emit(eventName, socket.id, args[0]); @@ -347,13 +350,13 @@ module.exports = { }); }); - console.log("WS server started") + console.log("WS server started"); setInterval(async (io) => { try { let count = 0; console.log(` ====== Rooms: ${io.sockets.adapter.rooms.size} ====== `); - const arr = Array.from(io.sockets.adapter.rooms) - const filtered = arr.filter(room => !room[1].has(room[0])) + const arr = Array.from(io.sockets.adapter.rooms); + const filtered = arr.filter(room => !room[1].has(room[0])); for (let i of filtered) { let {projectKey, sessionId} = extractPeerId(i[0]); if (projectKey !== null && sessionId !== null) { @@ -363,13 +366,15 @@ module.exports = { console.log(` ====== Valid Rooms: ${count} ====== `); if (debug) { for (let item of filtered) { - console.log(`Room: ${item[0]} connected: ${item[1].size}`) + console.log(`Room: ${item[0]} connected: ${item[1].size}`); } } } catch (e) { console.error(e); } - }, 20000, io); + }, 30000, io); + + socketConnexionTimeout(io); }, handlers: { socketsList, diff --git a/frontend/.env.sample b/frontend/.env.sample index 3a1ed67a3..ed1eebcf1 100644 --- a/frontend/.env.sample +++ b/frontend/.env.sample @@ -22,5 +22,5 @@ MINIO_ACCESS_KEY = '' MINIO_SECRET_KEY = '' # APP and TRACKER VERSIONS -VERSION = '1.7.0' -TRACKER_VERSION = '3.5.15' +VERSION = '1.8.0' +TRACKER_VERSION = '3.5.16' diff --git a/frontend/.prettierrc b/frontend/.prettierrc index 761a3e639..4c38cc4c4 100644 --- a/frontend/.prettierrc +++ b/frontend/.prettierrc @@ -1,6 +1,6 @@ { - "tabWidth": 4, + "tabWidth": 2, "useTabs": false, - "printWidth": 150, + "printWidth": 100, "singleQuote": true } diff --git a/frontend/Dockerfile b/frontend/Dockerfile index bfa86857d..5e6c9b3b0 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -14,7 +14,6 @@ COPY nginx.conf /etc/nginx/conf.d/default.conf # Default step in docker build FROM nginx:alpine LABEL maintainer=Rajesh -RUN apk upgrade busybox --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/main COPY --from=builder /work/public /var/www/openreplay COPY nginx.conf /etc/nginx/conf.d/default.conf diff --git a/frontend/app/Router.js b/frontend/app/Router.js index f5dc4c593..8bd3de882 100644 --- a/frontend/app/Router.js +++ b/frontend/app/Router.js @@ -8,7 +8,6 @@ import { fetchUserInfo } from 'Duck/user'; import withSiteIdUpdater from 'HOCs/withSiteIdUpdater'; import WidgetViewPure from 'Components/Dashboard/components/WidgetView'; import Header from 'Components/Header/Header'; -import { fetchList as fetchMetadata } from 'Duck/customField'; import { fetchList as fetchSiteList } from 'Duck/site'; import { fetchList as fetchAnnouncements } from 'Duck/announcements'; import { fetchList as fetchAlerts } from 'Duck/alerts'; @@ -16,12 +15,13 @@ import { withStore } from 'App/mstore'; import APIClient from './api_client'; import * as routes from './routes'; -import { OB_DEFAULT_TAB } from 'App/routes'; +import { OB_DEFAULT_TAB, isRoute } from 'App/routes'; import Signup from './components/Signup/Signup'; import { fetchTenants } from 'Duck/user'; import { setSessionPath } from 'Duck/sessions'; import { ModalProvider } from './components/Modal'; import { GLOBAL_DESTINATION_PATH } from 'App/constants/storageKeys'; +import SupportCallout from 'Shared/SupportCallout'; const Login = lazy(() => import('Components/Login/Login')); const ForgotPassword = lazy(() => import('Components/ForgotPassword/ForgotPassword')); @@ -31,7 +31,7 @@ const LiveSessionPure = lazy(() => import('Components/Session/LiveSession')); const OnboardingPure = lazy(() => import('Components/Onboarding/Onboarding')); const ClientPure = lazy(() => import('Components/Client/Client')); const AssistPure = lazy(() => import('Components/Assist')); -const BugFinderPure = lazy(() => import('Components/BugFinder/BugFinder')); +const BugFinderPure = lazy(() => import('Components/Overview')); const DashboardPure = lazy(() => import('Components/Dashboard/NewDashboard')); const ErrorsPure = lazy(() => import('Components/Errors/Errors')); const FunnelDetailsPure = lazy(() => import('Components/Funnels/FunnelDetails')); @@ -55,6 +55,10 @@ const METRICS_PATH = routes.metrics(); const METRICS_DETAILS = routes.metricDetails(); const METRICS_DETAILS_SUB = routes.metricDetailsSub(); +const ALERTS_PATH = routes.alerts(); +const ALERT_CREATE_PATH = routes.alertCreate(); +const ALERT_EDIT_PATH = routes.alertEdit(); + const DASHBOARD_PATH = routes.dashboard(); const DASHBOARD_SELECT_PATH = routes.dashboardSelected(); const DASHBOARD_METRIC_CREATE_PATH = routes.dashboardMetricCreate(); @@ -99,13 +103,13 @@ const ONBOARDING_REDIRECT_PATH = routes.onboarding(OB_DEFAULT_TAB); tenants: state.getIn(['user', 'tenants']), existingTenant: state.getIn(['user', 'authDetails', 'tenants']), onboarding: state.getIn(['user', 'onboarding']), + isEnterprise: state.getIn(['user', 'account', 'edition']) === 'ee' || state.getIn(['user', 'authDetails', 'edition']) === 'ee', }; }, { fetchUserInfo, fetchTenants, setSessionPath, - fetchMetadata, fetchSiteList, fetchAnnouncements, fetchAlerts, @@ -121,15 +125,11 @@ class Router extends React.Component { } } - fetchInitialData = () => { - Promise.all([ - this.props.fetchUserInfo().then(() => { - this.props.fetchSiteList().then(() => { - const { mstore } = this.props; - mstore.initClient(); - }); - }), - ]); + fetchInitialData = async () => { + await this.props.fetchUserInfo(), + await this.props.fetchSiteList() + const { mstore } = this.props; + mstore.initClient(); }; componentDidMount() { @@ -167,9 +167,10 @@ class Router extends React.Component { } render() { - const { isLoggedIn, jwt, siteId, sites, loading, changePassword, location, existingTenant, onboarding } = this.props; + const { isLoggedIn, jwt, siteId, sites, loading, changePassword, location, existingTenant, onboarding, isEnterprise } = this.props; const siteIdList = sites.map(({ id }) => id).toJS(); const hideHeader = (location.pathname && location.pathname.includes('/session/')) || location.pathname.includes('/assist/'); + const isPlayer = isRoute(SESSION_PATH, location.pathname) || isRoute(LIVE_SESSION_PATH, location.pathname); return isLoggedIn ? ( @@ -198,6 +199,9 @@ class Router extends React.Component { {onboarding && } {/* DASHBOARD and Metrics */} + + + @@ -223,6 +227,7 @@ class Router extends React.Component { + {!isEnterprise && !isPlayer && } ) : ( }> @@ -232,6 +237,7 @@ class Router extends React.Component { {!existingTenant && } + {!isEnterprise && } ); } diff --git a/frontend/app/api_client.js b/frontend/app/api_client.js index 1f85d5af9..33f7ffe66 100644 --- a/frontend/app/api_client.js +++ b/frontend/app/api_client.js @@ -25,6 +25,7 @@ const siteIdRequiredPaths = [ '/custom_metrics', '/dashboards', '/metrics', + '/unprocessed', // '/custom_metrics/sessions', ]; diff --git a/frontend/app/api_middleware.js b/frontend/app/api_middleware.js index a29a22eb6..8f9965ec5 100644 --- a/frontend/app/api_middleware.js +++ b/frontend/app/api_middleware.js @@ -1,3 +1,4 @@ +import logger from 'App/logger'; import APIClient from './api_client'; import { UPDATE, DELETE } from './duck/jwt'; @@ -28,8 +29,9 @@ export default store => next => (action) => { next({ type: UPDATE, data: jwt }); } }) - .catch(() => { - return next({ type: FAILURE, errors: [ 'Connection error' ] }); + .catch((e) => { + logger.error("Error during API request. ", e) + return next({ type: FAILURE, errors: [ "Connection error", String(e) ] }); }); }; diff --git a/frontend/app/assets/index.html b/frontend/app/assets/index.html index b2e0d8dc9..75914f4fb 100644 --- a/frontend/app/assets/index.html +++ b/frontend/app/assets/index.html @@ -1,17 +1,21 @@ - - OpenReplay - - - - - - - - - - -

Loading...

- + + OpenReplay + + + + + + + + + + + + + + +

Loading...

+ diff --git a/frontend/app/assets/integrations/aws.svg b/frontend/app/assets/integrations/aws.svg new file mode 100644 index 000000000..c18fbdab2 --- /dev/null +++ b/frontend/app/assets/integrations/aws.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/app/assets/integrations/bugsnag.svg b/frontend/app/assets/integrations/bugsnag.svg index 26a3a13b8..cc97e195b 100644 --- a/frontend/app/assets/integrations/bugsnag.svg +++ b/frontend/app/assets/integrations/bugsnag.svg @@ -1 +1,4 @@ - \ No newline at end of file + + + + diff --git a/frontend/app/assets/integrations/google-cloud.svg b/frontend/app/assets/integrations/google-cloud.svg new file mode 100644 index 000000000..93f614043 --- /dev/null +++ b/frontend/app/assets/integrations/google-cloud.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/app/assets/integrations/jira.svg b/frontend/app/assets/integrations/jira.svg index 36b328d35..adde0d695 100644 --- a/frontend/app/assets/integrations/jira.svg +++ b/frontend/app/assets/integrations/jira.svg @@ -1,23 +1,20 @@ - - - - Jira Software-blue - Created with Sketch. - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + diff --git a/frontend/app/assets/integrations/newrelic.svg b/frontend/app/assets/integrations/newrelic.svg index cc4aea514..061e7e0a3 100644 --- a/frontend/app/assets/integrations/newrelic.svg +++ b/frontend/app/assets/integrations/newrelic.svg @@ -1 +1,12 @@ -NewRelic-logo-square \ No newline at end of file + + + + + + + + + + + + diff --git a/frontend/app/assets/integrations/rollbar.svg b/frontend/app/assets/integrations/rollbar.svg index 2f6538118..0d183182b 100644 --- a/frontend/app/assets/integrations/rollbar.svg +++ b/frontend/app/assets/integrations/rollbar.svg @@ -1,20 +1,10 @@ - - - - -rollbar-logo-color-vertical - - - - - + + + + + + + + diff --git a/frontend/app/components/Alerts/AlertForm.js b/frontend/app/components/Alerts/AlertForm.js index 1701c8e0a..6604574e0 100644 --- a/frontend/app/components/Alerts/AlertForm.js +++ b/frontend/app/components/Alerts/AlertForm.js @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react' +import React, { useEffect } from 'react'; import { Button, Form, Input, SegmentSelection, Checkbox, Message, Link, Icon } from 'UI'; import { alertMetrics as metrics } from 'App/constants'; import { alertConditions as conditions } from 'App/constants'; @@ -9,333 +9,322 @@ import DropdownChips from './DropdownChips'; import { validateEmail } from 'App/validate'; import cn from 'classnames'; import { fetchTriggerOptions } from 'Duck/alerts'; -import Select from 'Shared/Select' +import Select from 'Shared/Select'; const thresholdOptions = [ - { label: '15 minutes', value: 15 }, - { label: '30 minutes', value: 30 }, - { label: '1 hour', value: 60 }, - { label: '2 hours', value: 120 }, - { label: '4 hours', value: 240 }, - { label: '1 day', value: 1440 }, + { label: '15 minutes', value: 15 }, + { label: '30 minutes', value: 30 }, + { label: '1 hour', value: 60 }, + { label: '2 hours', value: 120 }, + { label: '4 hours', value: 240 }, + { label: '1 day', value: 1440 }, ]; const changeOptions = [ - { label: 'change', value: 'change' }, - { label: '% change', value: 'percent' }, + { label: 'change', value: 'change' }, + { label: '% change', value: 'percent' }, ]; -const Circle = ({ text }) => ( -
{text}
-) +const Circle = ({ text }) =>
{text}
; const Section = ({ index, title, description, content }) => ( -
-
- -
- {title} - { description &&
{description}
} -
-
+
+
+ +
+ {title} + {description &&
{description}
} +
+
-
- {content} +
{content}
-
-) +); const integrationsRoute = client(CLIENT_TABS.INTEGRATIONS); -const AlertForm = props => { - const { instance, slackChannels, webhooks, loading, onDelete, deleting, triggerOptions, metricId, style={ width: '580px', height: '100vh' } } = props; - const write = ({ target: { value, name } }) => props.edit({ [ name ]: value }) - const writeOption = (e, { name, value }) => props.edit({ [ name ]: value.value }); - const onChangeCheck = ({ target: { checked, name }}) => props.edit({ [ name ]: checked }) - // const onChangeOption = ({ checked, name }) => props.edit({ [ name ]: checked }) - // const onChangeCheck = (e) => { console.log(e) } +const AlertForm = (props) => { + const { + instance, + slackChannels, + webhooks, + loading, + onDelete, + deleting, + triggerOptions, + metricId, + style = { width: '580px', height: '100vh' }, + } = props; + const write = ({ target: { value, name } }) => props.edit({ [name]: value }); + const writeOption = (e, { name, value }) => props.edit({ [name]: value.value }); + const onChangeCheck = ({ target: { checked, name } }) => props.edit({ [name]: checked }); + // const onChangeOption = ({ checked, name }) => props.edit({ [ name ]: checked }) + // const onChangeCheck = (e) => { console.log(e) } - useEffect(() => { - props.fetchTriggerOptions(); - }, []) + useEffect(() => { + props.fetchTriggerOptions(); + }, []); - const writeQueryOption = (e, { name, value }) => { - const { query } = instance; - props.edit({ query: { ...query, [name] : value } }); - } + const writeQueryOption = (e, { name, value }) => { + const { query } = instance; + props.edit({ query: { ...query, [name]: value } }); + }; - const writeQuery = ({ target: { value, name } }) => { - const { query } = instance; - props.edit({ query: { ...query, [name] : value } }); - } + const writeQuery = ({ target: { value, name } }) => { + const { query } = instance; + props.edit({ query: { ...query, [name]: value } }); + }; - const metric = (instance && instance.query.left) ? triggerOptions.find(i => i.value === instance.query.left) : null; - const unit = metric ? metric.unit : ''; - const isThreshold = instance.detectionMethod === 'threshold'; + const metric = instance && instance.query.left ? triggerOptions.find((i) => i.value === instance.query.left) : null; + const unit = metric ? metric.unit : ''; + const isThreshold = instance.detectionMethod === 'threshold'; - return ( -
props.onSubmit(instance)} id="alert-form"> -
- -
-
- props.edit({ [ name ]: value }) } - value={{ value: instance.detectionMethod }} - list={ [ - { name: 'Threshold', value: 'threshold' }, - { name: 'Change', value: 'change' }, - ]} - /> -
- {isThreshold && 'Eg. Alert me if memory.avg is greater than 500mb over the past 4 hours.'} - {!isThreshold && 'Eg. Alert me if % change of memory.avg is greater than 10% over the past 4 hours compared to the previous 4 hours.'} -
-
+ return ( + props.onSubmit(instance)} id="alert-form"> +
+ +
+
+ props.edit({ [name]: value })} + value={{ value: instance.detectionMethod }} + list={[ + { name: 'Threshold', value: 'threshold' }, + { name: 'Change', value: 'change' }, + ]} + /> +
+ {isThreshold && 'Eg. Alert me if memory.avg is greater than 500mb over the past 4 hours.'} + {!isThreshold && + 'Eg. Alert me if % change of memory.avg is greater than 10% over the past 4 hours compared to the previous 4 hours.'} +
+
+
+ } + /> + +
+ +
+ {!isThreshold && ( +
+ + i.value === instance.query.left)} + // onChange={ writeQueryOption } + onChange={({ value }) => writeQueryOption(null, { name: 'left', value: value.value })} + /> +
+ +
+ +
+ + {'test'} + + )} + {!unit && ( + + )} +
+
+ +
+ + writeOption(null, { name: 'previousPeriod', value })} + /> +
+ )} +
+ } + /> + +
+ +
+
+ + + +
+ + {instance.slack && ( +
+ +
+ props.edit({ slackInput: selected })} + /> +
+
+ )} + + {instance.email && ( +
+ +
+ props.edit({ emailInput: selected })} + /> +
+
+ )} + + {instance.webhook && ( +
+ + props.edit({ webhookInput: selected })} + /> +
+ )} +
+ } + />
- } - /> -
- -
- {!isThreshold && ( -
- - i.value === instance.query.left) } - // onChange={ writeQueryOption } - onChange={ ({ value }) => writeQueryOption(null, { name: 'left', value: value.value }) } - /> -
- -
- -
- - {'test'} - - )} - { !unit && ( - - )} +
+ {instance.exists() && ( + + )}
-
- -
- - writeOption(null, { name: 'previousPeriod', value }) } - /> -
- )}
- } - /> + + ); +}; -
- -
-
- - - -
- - { instance.slack && ( -
- -
- props.edit({ 'slackInput': selected })} - /> -
-
- )} - - {instance.email && ( -
- -
- props.edit({ 'emailInput': selected })} - /> -
-
- )} - - - {instance.webhook && ( -
- - props.edit({ 'webhookInput': selected })} - /> -
- )} -
- } - /> -
- - -
-
- -
- -
-
- {instance.exists() && ( - - )} -
-
- - ) -} - -export default connect(state => ({ - instance: state.getIn(['alerts', 'instance']), - triggerOptions: state.getIn(['alerts', 'triggerOptions']), - loading: state.getIn(['alerts', 'saveRequest', 'loading']), - deleting: state.getIn(['alerts', 'removeRequest', 'loading']) -}), { fetchTriggerOptions })(AlertForm) +export default connect( + (state) => ({ + instance: state.getIn(['alerts', 'instance']), + triggerOptions: state.getIn(['alerts', 'triggerOptions']), + loading: state.getIn(['alerts', 'saveRequest', 'loading']), + deleting: state.getIn(['alerts', 'removeRequest', 'loading']), + }), + { fetchTriggerOptions } +)(AlertForm); diff --git a/frontend/app/components/Alerts/AlertFormModal/AlertFormModal.tsx b/frontend/app/components/Alerts/AlertFormModal/AlertFormModal.tsx index 8869f3a02..dc4c9db15 100644 --- a/frontend/app/components/Alerts/AlertFormModal/AlertFormModal.tsx +++ b/frontend/app/components/Alerts/AlertFormModal/AlertFormModal.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react' +import React, { useEffect, useState } from 'react'; import { SlideModal, IconButton } from 'UI'; import { init, edit, save, remove } from 'Duck/alerts'; import { fetchList as fetchWebhooks } from 'Duck/webhook'; @@ -9,93 +9,98 @@ import { EMAIL, SLACK, WEBHOOK } from 'App/constants/schedule'; import { confirm } from 'UI'; interface Props { - showModal?: boolean; - metricId?: number; - onClose?: () => void; - webhooks: any; - fetchWebhooks: Function; - save: Function; - remove: Function; - init: Function; - edit: Function; + showModal?: boolean; + metricId?: number; + onClose?: () => void; + webhooks: any; + fetchWebhooks: Function; + save: Function; + remove: Function; + init: Function; + edit: Function; } function AlertFormModal(props: Props) { - const { metricId = null, showModal = false, webhooks } = props; - const [showForm, setShowForm] = useState(false); + const { metricId = null, showModal = false, webhooks } = props; + const [showForm, setShowForm] = useState(false); - useEffect(() => { - props.fetchWebhooks(); - }, []) + useEffect(() => { + props.fetchWebhooks(); + }, []); - const slackChannels = webhooks.filter(hook => hook.type === SLACK).map(({ webhookId, name }) => ({ value: webhookId, text: name })).toJS(); - const hooks = webhooks.filter(hook => hook.type === WEBHOOK).map(({ webhookId, name }) => ({ value: webhookId, text: name })).toJS(); + const slackChannels = webhooks + .filter((hook) => hook.type === SLACK) + .map(({ webhookId, name }) => ({ value: webhookId, text: name })) + .toJS(); + const hooks = webhooks + .filter((hook) => hook.type === WEBHOOK) + .map(({ webhookId, name }) => ({ value: webhookId, text: name })) + .toJS(); - const saveAlert = instance => { - const wasUpdating = instance.exists(); - props.save(instance).then(() => { - if (!wasUpdating) { - toggleForm(null, false); - } - if (props.onClose) { - props.onClose(); - } - }) - } + const saveAlert = (instance) => { + const wasUpdating = instance.exists(); + props.save(instance).then(() => { + if (!wasUpdating) { + toggleForm(null, false); + } + if (props.onClose) { + props.onClose(); + } + }); + }; - const onDelete = async (instance) => { - if (await confirm({ - header: 'Confirm', - confirmButton: 'Yes, delete', - confirmation: `Are you sure you want to permanently delete this alert?` - })) { - props.remove(instance.alertId).then(() => { - toggleForm(null, false); - }); - } - } - - const toggleForm = (instance, state) => { - if (instance) { - props.init(instance) - } - return setShowForm(state ? state : !showForm); - } - - return ( - - { 'Create Alert' } - {/* toggleForm({}, true) } - /> */} -
+ const onDelete = async (instance) => { + if ( + await confirm({ + header: 'Confirm', + confirmButton: 'Yes, delete', + confirmation: `Are you sure you want to permanently delete this alert?`, + }) + ) { + props.remove(instance.alertId).then(() => { + toggleForm(null, false); + }); } - isDisplayed={ showModal } - onClose={props.onClose} - size="medium" - content={ showModal && - { + if (instance) { + props.init(instance); + } + return setShowForm(state ? state : !showForm); + }; + + return ( + + {'Create Alert'} +
+ } + isDisplayed={showModal} onClose={props.onClose} - onDelete={onDelete} - style={{ width: '580px', height: '100vh - 200px' }} - /> - } - /> - ); + size="medium" + content={ + showModal && ( + + ) + } + /> + ); } -export default connect(state => ({ - webhooks: state.getIn(['webhooks', 'list']), - instance: state.getIn(['alerts', 'instance']), -}), { init, edit, save, remove, fetchWebhooks, setShowAlerts })(AlertFormModal) \ No newline at end of file +export default connect( + (state) => ({ + webhooks: state.getIn(['webhooks', 'list']), + instance: state.getIn(['alerts', 'instance']), + }), + { init, edit, save, remove, fetchWebhooks, setShowAlerts } +)(AlertFormModal); diff --git a/frontend/app/components/Alerts/Alerts.js b/frontend/app/components/Alerts/Alerts.js index b24665a68..ed825abaf 100644 --- a/frontend/app/components/Alerts/Alerts.js +++ b/frontend/app/components/Alerts/Alerts.js @@ -10,95 +10,100 @@ import { setShowAlerts } from 'Duck/dashboard'; import { EMAIL, SLACK, WEBHOOK } from 'App/constants/schedule'; import { confirm } from 'UI'; -const Alerts = props => { - const { webhooks, setShowAlerts } = props; - const [showForm, setShowForm] = useState(false); +const Alerts = (props) => { + const { webhooks, setShowAlerts } = props; + const [showForm, setShowForm] = useState(false); - useEffect(() => { - props.fetchWebhooks(); - }, []) + useEffect(() => { + props.fetchWebhooks(); + }, []); - const slackChannels = webhooks.filter(hook => hook.type === SLACK).map(({ webhookId, name }) => ({ value: webhookId, label: name })).toJS(); - const hooks = webhooks.filter(hook => hook.type === WEBHOOK).map(({ webhookId, name }) => ({ value: webhookId, label: name })).toJS(); + const slackChannels = webhooks + .filter((hook) => hook.type === SLACK) + .map(({ webhookId, name }) => ({ value: webhookId, label: name })) + .toJS(); + const hooks = webhooks + .filter((hook) => hook.type === WEBHOOK) + .map(({ webhookId, name }) => ({ value: webhookId, label: name })) + .toJS(); - const saveAlert = instance => { - const wasUpdating = instance.exists(); - props.save(instance).then(() => { - if (!wasUpdating) { - toast.success('New alert saved') - toggleForm(null, false); - } else { - toast.success('Alert updated') - } - }) - } + const saveAlert = (instance) => { + const wasUpdating = instance.exists(); + props.save(instance).then(() => { + if (!wasUpdating) { + toast.success('New alert saved'); + toggleForm(null, false); + } else { + toast.success('Alert updated'); + } + }); + }; - const onDelete = async (instance) => { - if (await confirm({ - header: 'Confirm', - confirmButton: 'Yes, delete', - confirmation: `Are you sure you want to permanently delete this alert?` - })) { - props.remove(instance.alertId).then(() => { - toggleForm(null, false); - }); - } - } + const onDelete = async (instance) => { + if ( + await confirm({ + header: 'Confirm', + confirmButton: 'Yes, delete', + confirmation: `Are you sure you want to permanently delete this alert?`, + }) + ) { + props.remove(instance.alertId).then(() => { + toggleForm(null, false); + }); + } + }; - const toggleForm = (instance, state) => { - if (instance) { - props.init(instance) - } - return setShowForm(state ? state : !showForm); - } + const toggleForm = (instance, state) => { + if (instance) { + props.init(instance); + } + return setShowForm(state ? state : !showForm); + }; - return ( -
- - { 'Alerts' } - toggleForm({}, true) } + return ( +
+ + {'Alerts'} + toggleForm({}, true)} /> +
+ } + isDisplayed={true} + onClose={() => { + toggleForm({}, false); + setShowAlerts(false); + }} + size="small" + content={ + { + toggleForm(alert, true); + }} + onClickCreate={() => toggleForm({}, true)} + /> + } + detailContent={ + showForm && ( + toggleForm({}, false)} + onDelete={onDelete} + /> + ) + } /> -
- } - isDisplayed={ true } - onClose={ () => { - toggleForm({}, false); - setShowAlerts(false); - } } - size="small" - content={ - { - toggleForm(alert, true) - }} - /> - } - detailContent={ - showForm && ( - toggleForm({}, false) } - onDelete={onDelete} - /> - ) - } - /> - - ) -} + + ); +}; -export default connect(state => ({ - webhooks: state.getIn(['webhooks', 'list']), - instance: state.getIn(['alerts', 'instance']), -}), { init, edit, save, remove, fetchWebhooks, setShowAlerts })(Alerts) +export default connect( + (state) => ({ + webhooks: state.getIn(['webhooks', 'list']), + instance: state.getIn(['alerts', 'instance']), + }), + { init, edit, save, remove, fetchWebhooks, setShowAlerts } +)(Alerts); diff --git a/frontend/app/components/Alerts/AlertsList.js b/frontend/app/components/Alerts/AlertsList.js index 21ea6448d..5a874e0fa 100644 --- a/frontend/app/components/Alerts/AlertsList.js +++ b/frontend/app/components/Alerts/AlertsList.js @@ -1,55 +1,58 @@ -import React, { useEffect, useState } from 'react' -import { Loader, NoContent, Input } from 'UI'; -import AlertItem from './AlertItem' +import React, { useEffect, useState } from 'react'; +import { Loader, NoContent, Input, Button } from 'UI'; +import AlertItem from './AlertItem'; import { fetchList, init } from 'Duck/alerts'; import { connect } from 'react-redux'; import { getRE } from 'App/utils'; -const AlertsList = props => { - const { loading, list, instance, onEdit } = props; - const [query, setQuery] = useState('') - - useEffect(() => { - props.fetchList() - }, []) +const AlertsList = (props) => { + const { loading, list, instance, onEdit } = props; + const [query, setQuery] = useState(''); - const filterRE = getRE(query, 'i'); - const _filteredList = list.filter(({ name, query: { left } }) => filterRE.test(name) || filterRE.test(left)); + useEffect(() => { + props.fetchList(); + }, []); - return ( -
-
- setQuery(value)} - /> -
- - -
- {_filteredList.map(a => ( -
- onEdit(a.toData())} - /> -
- ))} -
-
-
-
- ) -} + const filterRE = getRE(query, 'i'); + const _filteredList = list.filter(({ name, query: { left } }) => filterRE.test(name) || filterRE.test(left)); -export default connect(state => ({ - list: state.getIn(['alerts', 'list']).sort((a, b ) => b.createdAt - a.createdAt), - instance: state.getIn(['alerts', 'instance']), - loading: state.getIn(['alerts', 'loading']) -}), { fetchList, init })(AlertsList) + return ( +
+
+ setQuery(value)} /> +
+ + +
Alerts helps your team stay up to date with the activity on your app.
+ +
+ } + size="small" + show={list.size === 0} + > +
+ {_filteredList.map((a) => ( +
+ onEdit(a.toData())} /> +
+ ))} +
+ + + + ); +}; + +export default connect( + (state) => ({ + list: state.getIn(['alerts', 'list']).sort((a, b) => b.createdAt - a.createdAt), + instance: state.getIn(['alerts', 'instance']), + loading: state.getIn(['alerts', 'loading']), + }), + { fetchList, init } +)(AlertsList); diff --git a/frontend/app/components/Alerts/DropdownChips/DropdownChips.js b/frontend/app/components/Alerts/DropdownChips/DropdownChips.js index 7a7e81ada..1f805057d 100644 --- a/frontend/app/components/Alerts/DropdownChips/DropdownChips.js +++ b/frontend/app/components/Alerts/DropdownChips/DropdownChips.js @@ -1,79 +1,66 @@ -import React from 'react' +import React from 'react'; import { Input, TagBadge } from 'UI'; import Select from 'Shared/Select'; -const DropdownChips = ({ - textFiled = false, - validate = null, - placeholder = '', - selected = [], - options = [], - badgeClassName = 'lowercase', - onChange = () => null, - ...props +const DropdownChips = ({ + textFiled = false, + validate = null, + placeholder = '', + selected = [], + options = [], + badgeClassName = 'lowercase', + onChange = () => null, + ...props }) => { - const onRemove = id => { - onChange(selected.filter(i => i !== id)) - } + const onRemove = (id) => { + onChange(selected.filter((i) => i !== id)); + }; - const onSelect = ({ value }) => { - const newSlected = selected.concat(value.value); - onChange(newSlected) - }; + const onSelect = ({ value }) => { + const newSlected = selected.concat(value.value); + onChange(newSlected); + }; - const onKeyPress = e => { - const val = e.target.value; - if (e.key !== 'Enter' || selected.includes(val)) return; - e.preventDefault(); - e.stopPropagation(); - if (validate && !validate(val)) return; + const onKeyPress = (e) => { + const val = e.target.value; + if (e.key !== 'Enter' || selected.includes(val)) return; + e.preventDefault(); + e.stopPropagation(); + if (validate && !validate(val)) return; - const newSlected = selected.concat(val); - e.target.value = ''; - onChange(newSlected); - } + const newSlected = selected.concat(val); + e.target.value = ''; + onChange(newSlected); + }; - const _options = options.filter(item => !selected.includes(item.value)) + const _options = options.filter((item) => !selected.includes(item.value)); + + const renderBadge = (item) => { + const val = typeof item === 'string' ? item : item.value; + const text = typeof item === 'string' ? item : item.label; + return onRemove(val)} outline={true} />; + }; - const renderBadge = item => { - const val = typeof item === 'string' ? item : item.value; - const text = typeof item === 'string' ? item : item.label; return ( - onRemove(val) } - outline={ true } - /> - ) - } +
+ {textFiled ? ( + + ) : ( + - ) : ( - diff --git a/frontend/app/components/Client/Audit/AuditView/AuditView.tsx b/frontend/app/components/Client/Audit/AuditView/AuditView.tsx index 23175b0d8..b93c26d08 100644 --- a/frontend/app/components/Client/Audit/AuditView/AuditView.tsx +++ b/frontend/app/components/Client/Audit/AuditView/AuditView.tsx @@ -23,7 +23,7 @@ function AuditView(props) { return useObserver(() => (
-
+
Audit Trail diff --git a/frontend/app/components/Client/Client.js b/frontend/app/components/Client/Client.js index adae9f536..97d8e5aab 100644 --- a/frontend/app/components/Client/Client.js +++ b/frontend/app/components/Client/Client.js @@ -52,7 +52,7 @@ export default class Client extends React.PureComponent {
-
+
{ activeTab && this.renderActiveTab() }
diff --git a/frontend/app/components/Client/CustomFields/CustomFieldForm.js b/frontend/app/components/Client/CustomFields/CustomFieldForm.js index 76ee849d5..adc9ac884 100644 --- a/frontend/app/components/Client/CustomFields/CustomFieldForm.js +++ b/frontend/app/components/Client/CustomFields/CustomFieldForm.js @@ -4,59 +4,74 @@ import { edit, save } from 'Duck/customField'; import { Form, Input, Button, Message } from 'UI'; import styles from './customFieldForm.module.css'; -@connect(state => ({ - field: state.getIn(['customFields', 'instance']), - saving: state.getIn(['customFields', 'saveRequest', 'loading']), - errors: state.getIn([ 'customFields', 'saveRequest', 'errors' ]), -}), { - edit, - save, -}) +@connect( + (state) => ({ + field: state.getIn(['customFields', 'instance']), + saving: state.getIn(['customFields', 'saveRequest', 'loading']), + errors: state.getIn(['customFields', 'saveRequest', 'errors']), + }), + { + edit, + save, + } +) class CustomFieldForm extends React.PureComponent { - setFocus = () => this.focusElement.focus(); - onChangeSelect = (event, { name, value }) => this.props.edit({ [ name ]: value }); - write = ({ target: { value, name } }) => this.props.edit({ [ name ]: value }); + setFocus = () => this.focusElement.focus(); + onChangeSelect = (event, { name, value }) => this.props.edit({ [name]: value }); + write = ({ target: { value, name } }) => this.props.edit({ [name]: value }); - render() { - const { field, errors} = this.props; - const exists = field.exists(); - return ( -
- - - { this.focusElement = ref; } } - name="key" - value={ field.key } - onChange={ this.write } - placeholder="Field Name" - /> - + render() { + const { field, errors } = this.props; + const exists = field.exists(); + return ( +
+

{exists ? 'Update' : 'Add'} Metadata Field

+ + + + { + this.focusElement = ref; + }} + name="key" + value={field.key} + onChange={this.write} + placeholder="Field Name" + /> + - { errors && -
- { errors.map(error => { error }) } -
- } + {errors && ( +
+ {errors.map((error) => ( + + {error} + + ))} +
+ )} - - - - ); - } +
+
+ + +
+ + +
+ +
+ ); + } } export default CustomFieldForm; diff --git a/frontend/app/components/Client/CustomFields/CustomFields.js b/frontend/app/components/Client/CustomFields/CustomFields.js index 4c3d0bbc8..0964ff7b8 100644 --- a/frontend/app/components/Client/CustomFields/CustomFields.js +++ b/frontend/app/components/Client/CustomFields/CustomFields.js @@ -1,8 +1,8 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import cn from 'classnames'; import { connect } from 'react-redux'; import withPageTitle from 'HOCs/withPageTitle'; -import { IconButton, SlideModal, Loader, NoContent, Icon, TextLink } from 'UI'; +import { Button, Loader, NoContent, Icon } from 'UI'; import { init, fetchList, save, remove } from 'Duck/customField'; import SiteDropdown from 'Shared/SiteDropdown'; import styles from './customFields.module.css'; @@ -10,121 +10,118 @@ import CustomFieldForm from './CustomFieldForm'; import ListItem from './ListItem'; import { confirm } from 'UI'; import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG'; +import { useModal } from 'App/components/Modal'; -@connect(state => ({ - fields: state.getIn(['customFields', 'list']).sortBy(i => i.index), - field: state.getIn(['customFields', 'instance']), - loading: state.getIn(['customFields', 'fetchRequest', 'loading']), - sites: state.getIn([ 'site', 'list' ]), - errors: state.getIn([ 'customFields', 'saveRequest', 'errors' ]), -}), { - init, - fetchList, - save, - remove, -}) -@withPageTitle('Metadata - OpenReplay Preferences') -class CustomFields extends React.Component { - state = { showModal: false, currentSite: this.props.sites.get(0), deletingItem: null }; +function CustomFields(props) { + const [currentSite, setCurrentSite] = React.useState(props.sites.get(0)); + const [deletingItem, setDeletingItem] = React.useState(null); + const { showModal, hideModal } = useModal(); - componentWillMount() { - const activeSite = this.props.sites.get(0); - if (!activeSite) return; - - this.props.fetchList(activeSite.id); - } + useEffect(() => { + const activeSite = props.sites.get(0); + if (!activeSite) return; - save = (field) => { - const { currentSite } = this.state; - this.props.save(currentSite.id, field).then(() => { - const { errors } = this.props; - if (!errors || errors.size === 0) { - return this.closeModal(); - } - }); - }; + props.fetchList(activeSite.id); + }, []); - closeModal = () => this.setState({ showModal: false }); - init = (field) => { - this.props.init(field); - this.setState({ showModal: true }); - } - - onChangeSelect = ({ value }) => { - const site = this.props.sites.find(s => s.id === value.value); - this.setState({ currentSite: site }) - this.props.fetchList(site.id); - } - - removeMetadata = async (field) => { - if (await confirm({ - header: 'Metadata', - confirmation: `Are you sure you want to remove?` - })) { - const { currentSite } = this.state; - this.setState({ deletingItem: field.index }); - this.props.remove(currentSite.id, field.index) - .then(() => this.setState({ deletingItem: null })); - } - } - - render() { - const { fields, field, loading } = this.props; - const { showModal, currentSite, deletingItem } = this.state; - return ( -
- } - onClose={ this.closeModal } - /> -
-

{ 'Metadata' }

-
- -
- this.init() } /> - -
- - - - -
No data available.
-
+ const save = (field) => { + props.save(currentSite.id, field).then(() => { + const { errors } = props; + if (!errors || errors.size === 0) { + hideModal(); } - size="small" - show={ fields.size === 0 } - // animatedIcon="empty-state" - > -
- { fields.filter(i => i.index).map(field => ( - this.removeMetadata(field) } - /> - ))} + }); + }; + + const init = (field) => { + props.init(field); + showModal( removeMetadata(field)} />); + }; + + const onChangeSelect = ({ value }) => { + const site = props.sites.find((s) => s.id === value.value); + setCurrentSite(site); + props.fetchList(site.id); + }; + + const removeMetadata = async (field) => { + if ( + await confirm({ + header: 'Metadata', + confirmation: `Are you sure you want to remove?`, + }) + ) { + setDeletingItem(field.index); + props + .remove(currentSite.id, field.index) + .then(() => { + hideModal(); + }) + .finally(() => { + setDeletingItem(null); + }); + } + }; + + const { fields, loading } = props; + return ( +
+
+

{'Metadata'}

+
+ +
+
- - -
+
+ + See additonal user information in sessions. + Learn more +
+ + + + + {/*
*/} +
None added yet
+
+ } + size="small" + show={fields.size === 0} + > +
+ {fields + .filter((i) => i.index) + .map((field) => ( + removeMetadata(field) } + /> + ))} +
+
+
+
); - } } -export default CustomFields; +export default connect( + (state) => ({ + fields: state.getIn(['customFields', 'list']).sortBy((i) => i.index), + field: state.getIn(['customFields', 'instance']), + loading: state.getIn(['customFields', 'fetchRequest', 'loading']), + sites: state.getIn(['site', 'list']), + errors: state.getIn(['customFields', 'saveRequest', 'errors']), + }), + { + init, + fetchList, + save, + remove, + } +)(withPageTitle('Metadata - OpenReplay Preferences')(CustomFields)); diff --git a/frontend/app/components/Client/CustomFields/ListItem.js b/frontend/app/components/Client/CustomFields/ListItem.js index ef806fc93..c62903f6d 100644 --- a/frontend/app/components/Client/CustomFields/ListItem.js +++ b/frontend/app/components/Client/CustomFields/ListItem.js @@ -1,22 +1,26 @@ import React from 'react'; -import cn from 'classnames' -import { Icon } from 'UI'; +import cn from 'classnames'; +import { Button } from 'UI'; import styles from './listItem.module.css'; -const ListItem = ({ field, onEdit, onDelete, disabled }) => { - return ( -
field.index != 0 && onEdit(field) } > - { field.key } -
-
{ e.stopPropagation(); onDelete(field) } }> - +const ListItem = ({ field, onEdit, disabled }) => { + return ( +
field.index != 0 && onEdit(field)} + > + {field.key} +
+
-
- -
-
-
- ); + ); }; export default ListItem; diff --git a/frontend/app/components/Client/CustomFields/customFields.module.css b/frontend/app/components/Client/CustomFields/customFields.module.css index 8636473a7..89e5e9914 100644 --- a/frontend/app/components/Client/CustomFields/customFields.module.css +++ b/frontend/app/components/Client/CustomFields/customFields.module.css @@ -1,7 +1,7 @@ .tabHeader { display: flex; align-items: center; - margin-bottom: 25px; + /* margin-bottom: 25px; */ & .tabTitle { margin: 0 15px 0 0; diff --git a/frontend/app/components/Client/Integrations/AssistDoc/AssistDoc.js b/frontend/app/components/Client/Integrations/AssistDoc/AssistDoc.js index 4cf2d0e7f..1d0990847 100644 --- a/frontend/app/components/Client/Integrations/AssistDoc/AssistDoc.js +++ b/frontend/app/components/Client/Integrations/AssistDoc/AssistDoc.js @@ -1,59 +1,57 @@ import React from 'react'; -import Highlight from 'react-highlight' +import Highlight from 'react-highlight'; import DocLink from 'Shared/DocLink/DocLink'; -import AssistScript from './AssistScript' -import AssistNpm from './AssistNpm' +import AssistScript from './AssistScript'; +import AssistNpm from './AssistNpm'; import { Tabs } from 'UI'; import { useState } from 'react'; +import { connect } from 'react-redux'; -const NPM = 'NPM' -const SCRIPT = 'SCRIPT' +const NPM = 'NPM'; +const SCRIPT = 'SCRIPT'; const TABS = [ - { key: SCRIPT, text: SCRIPT }, - { key: NPM, text: NPM }, -] + { key: SCRIPT, text: SCRIPT }, + { key: NPM, text: NPM }, +]; const AssistDoc = (props) => { - const { projectKey } = props; - const [activeTab, setActiveTab] = useState(SCRIPT) - + const { projectKey } = props; + const [activeTab, setActiveTab] = useState(SCRIPT); - const renderActiveTab = () => { - switch (activeTab) { - case SCRIPT: - return - case NPM: - return - } - return null; - } + const renderActiveTab = () => { + switch (activeTab) { + case SCRIPT: + return ; + case NPM: + return ; + } + return null; + }; + return ( +
+

Assist

+
+
+ OpenReplay Assist allows you to support your users by seeing their live screen and instantly hopping on call (WebRTC) with them + without requiring any 3rd-party screen sharing software. +
- return ( -
-
OpenReplay Assist allows you to support your users by seeing their live screen and instantly hopping on call (WebRTC) with them without requiring any 3rd-party screen sharing software.
+
Installation
+ {`npm i @openreplay/tracker-assist`} +
-
Installation
- - {`npm i @openreplay/tracker-assist`} - -
+
Usage
+ setActiveTab(tab)} /> -
Usage
- setActiveTab(tab) } - /> +
{renderActiveTab()}
-
- { renderActiveTab() } -
- - -
- ) + +
+
+ ); }; -AssistDoc.displayName = "AssistDoc"; +AssistDoc.displayName = 'AssistDoc'; -export default AssistDoc; +export default connect((state) => ({ projectKey: state.getIn(['site', 'instance', 'projectKey'])}) )(AssistDoc) diff --git a/frontend/app/components/Client/Integrations/AssistDoc/AssistNpm.tsx b/frontend/app/components/Client/Integrations/AssistDoc/AssistNpm.tsx index 28c12bd30..1e017a8a6 100644 --- a/frontend/app/components/Client/Integrations/AssistDoc/AssistNpm.tsx +++ b/frontend/app/components/Client/Integrations/AssistDoc/AssistNpm.tsx @@ -12,9 +12,9 @@ function AssistNpm(props) { label="Server-Side-Rendered (SSR)?" first={ - {`import Tracker from '@openreplay/tracker'; + {`import OpenReplay from '@openreplay/tracker'; import trackerAssist from '@openreplay/tracker-assist'; -const tracker = new Tracker({ +const tracker = new OpenReplay({ projectKey: '${props.projectKey}', }); tracker.start(); @@ -24,7 +24,7 @@ tracker.use(trackerAssist(options)); // check the list of available options belo second={ {`import OpenReplay from '@openreplay/tracker/cjs'; -import trackerFetch from '@openreplay/tracker-assist/cjs'; +import trackerAssist from '@openreplay/tracker-assist/cjs'; const tracker = new OpenReplay({ projectKey: '${props.projectKey}' }); @@ -50,4 +50,4 @@ function MyApp() { ); } -export default AssistNpm; \ No newline at end of file +export default AssistNpm; diff --git a/frontend/app/components/Client/Integrations/AxiosDoc/AxiosDoc.js b/frontend/app/components/Client/Integrations/AxiosDoc/AxiosDoc.js index 8fe32cfd0..80ae5e0a7 100644 --- a/frontend/app/components/Client/Integrations/AxiosDoc/AxiosDoc.js +++ b/frontend/app/components/Client/Integrations/AxiosDoc/AxiosDoc.js @@ -1,40 +1,47 @@ import React from 'react'; -import Highlight from 'react-highlight' -import ToggleContent from 'Shared/ToggleContent' +import Highlight from 'react-highlight'; +import ToggleContent from 'Shared/ToggleContent'; import DocLink from 'Shared/DocLink/DocLink'; +import { connect } from 'react-redux'; const AxiosDoc = (props) => { - const { projectKey } = props; - return ( -
-
This plugin allows you to capture axios requests and inspect them later on while replaying session recordings. This is very useful for understanding and fixing issues.
- -
Installation
- - {`npm i @openreplay/tracker-axios`} - - -
Usage
-

Initialize the @openreplay/tracker package as usual then load the axios plugin. Note that OpenReplay axios plugin requires axios@^0.21.2 as a peer dependency.

-
+ const { projectKey } = props; + return ( +
+

Axios

+
+
+ This plugin allows you to capture axios requests and inspect them later on while replaying session recordings. This is very useful + for understanding and fixing issues. +
-
Usage
- - {`import tracker from '@openreplay/tracker'; +
Installation
+ {`npm i @openreplay/tracker-axios`} + +
Usage
+

+ Initialize the @openreplay/tracker package as usual then load the axios plugin. Note that OpenReplay axios plugin requires + axios@^0.21.2 as a peer dependency. +

+
+ +
Usage
+ + {`import OpenReplay from '@openreplay/tracker'; import trackerAxios from '@openreplay/tracker-axios'; const tracker = new OpenReplay({ projectKey: '${projectKey}' }); tracker.use(trackerAxios(options)); // check list of available options below tracker.start();`} - - } - second={ - - {`import OpenReplay from '@openreplay/tracker/cjs'; + + } + second={ + + {`import OpenReplay from '@openreplay/tracker/cjs'; import trackerAxios from '@openreplay/tracker-axios/cjs'; const tracker = new OpenReplay({ projectKey: '${projectKey}' @@ -47,15 +54,16 @@ function MyApp() { }, []) //... }`} - - } - /> + + } + /> - -
- ) + +
+
+ ); }; -AxiosDoc.displayName = "AxiosDoc"; +AxiosDoc.displayName = 'AxiosDoc'; -export default AxiosDoc; +export default connect((state) => ({ projectKey: state.getIn(['site', 'instance', 'projectKey'])}) )(AxiosDoc) diff --git a/frontend/app/components/Client/Integrations/BugsnagForm/BugsnagForm.js b/frontend/app/components/Client/Integrations/BugsnagForm/BugsnagForm.js index b1aba5a30..15d8ddef1 100644 --- a/frontend/app/components/Client/Integrations/BugsnagForm/BugsnagForm.js +++ b/frontend/app/components/Client/Integrations/BugsnagForm/BugsnagForm.js @@ -1,32 +1,35 @@ import React from 'react'; import { tokenRE } from 'Types/integrations/bugsnagConfig'; -import IntegrationForm from '../IntegrationForm'; +import IntegrationForm from '../IntegrationForm'; import ProjectListDropdown from './ProjectListDropdown'; import DocLink from 'Shared/DocLink/DocLink'; const BugsnagForm = (props) => ( - <> -
-
How to integrate Bugsnag with OpenReplay and see backend errors alongside session recordings.
- +
+

Bugsnag

+
+
How to integrate Bugsnag with OpenReplay and see backend errors alongside session recordings.
+ +
+ tokenRE.test(config.authorizationToken), + component: ProjectListDropdown, + }, + ]} + />
- tokenRE.test(config.authorizationToken), - component: ProjectListDropdown, - } - ]} - /> - ); -BugsnagForm.displayName = "BugsnagForm"; +BugsnagForm.displayName = 'BugsnagForm'; -export default BugsnagForm; \ No newline at end of file +export default BugsnagForm; diff --git a/frontend/app/components/Client/Integrations/CloudwatchForm/CloudwatchForm.js b/frontend/app/components/Client/Integrations/CloudwatchForm/CloudwatchForm.js index 482167c72..bd9604b01 100644 --- a/frontend/app/components/Client/Integrations/CloudwatchForm/CloudwatchForm.js +++ b/frontend/app/components/Client/Integrations/CloudwatchForm/CloudwatchForm.js @@ -1,43 +1,48 @@ import React from 'react'; import { ACCESS_KEY_ID_LENGTH, SECRET_ACCESS_KEY_LENGTH } from 'Types/integrations/cloudwatchConfig'; -import IntegrationForm from '../IntegrationForm'; +import IntegrationForm from '../IntegrationForm'; import LogGroupDropdown from './LogGroupDropdown'; import RegionDropdown from './RegionDropdown'; import DocLink from 'Shared/DocLink/DocLink'; const CloudwatchForm = (props) => ( - <> -
-
How to integrate CloudWatch with OpenReplay and see backend errors alongside session replays.
- +
+

Cloud Watch

+
+
How to integrate CloudWatch with OpenReplay and see backend errors alongside session replays.
+ +
+ + config.awsSecretAccessKey.length === SECRET_ACCESS_KEY_LENGTH && + config.region !== '' && + config.awsAccessKeyId.length === ACCESS_KEY_ID_LENGTH, + }, + ]} + />
- - config.awsSecretAccessKey.length === SECRET_ACCESS_KEY_LENGTH && - config.region !== '' && - config.awsAccessKeyId.length === ACCESS_KEY_ID_LENGTH - } - ]} - /> - ); -CloudwatchForm.displayName = "CloudwatchForm"; +CloudwatchForm.displayName = 'CloudwatchForm'; -export default CloudwatchForm; \ No newline at end of file +export default CloudwatchForm; diff --git a/frontend/app/components/Client/Integrations/DatadogForm.js b/frontend/app/components/Client/Integrations/DatadogForm.js index 76ca0734d..46360259c 100644 --- a/frontend/app/components/Client/Integrations/DatadogForm.js +++ b/frontend/app/components/Client/Integrations/DatadogForm.js @@ -1,29 +1,32 @@ import React from 'react'; -import IntegrationForm from './IntegrationForm'; +import IntegrationForm from './IntegrationForm'; import DocLink from 'Shared/DocLink/DocLink'; const DatadogForm = (props) => ( - <> -
-
How to integrate Datadog with OpenReplay and see backend errors alongside session recordings.
- +
+

Datadog

+
+
How to integrate Datadog with OpenReplay and see backend errors alongside session recordings.
+ +
+
- - ); -DatadogForm.displayName = "DatadogForm"; +DatadogForm.displayName = 'DatadogForm'; export default DatadogForm; diff --git a/frontend/app/components/Client/Integrations/ElasticsearchForm.js b/frontend/app/components/Client/Integrations/ElasticsearchForm.js index 271ccefe1..ad33b6302 100644 --- a/frontend/app/components/Client/Integrations/ElasticsearchForm.js +++ b/frontend/app/components/Client/Integrations/ElasticsearchForm.js @@ -1,75 +1,88 @@ import React from 'react'; import { connect } from 'react-redux'; -import IntegrationForm from './IntegrationForm'; +import IntegrationForm from './IntegrationForm'; import { withRequest } from 'HOCs'; import { edit } from 'Duck/integrations/actions'; import DocLink from 'Shared/DocLink/DocLink'; -@connect(state => ({ - config: state.getIn([ 'elasticsearch', 'instance' ]) -}), { edit }) +@connect( + (state) => ({ + config: state.getIn(['elasticsearch', 'instance']), + }), + { edit } +) @withRequest({ - dataName: "isValid", - initialData: false, - dataWrapper: data => data.state, - requestName: "validateConfig", - endpoint: '/integrations/elasticsearch/test', - method: 'POST', + dataName: 'isValid', + initialData: false, + dataWrapper: (data) => data.state, + requestName: 'validateConfig', + endpoint: '/integrations/elasticsearch/test', + method: 'POST', }) export default class ElasticsearchForm extends React.PureComponent { - componentWillReceiveProps(newProps) { - const { config: { host, port, apiKeyId, apiKey } } = this.props; - const { loading, config } = newProps; - const valuesChanged = host !== config.host || port !== config.port || apiKeyId !== config.apiKeyId || apiKey !== config.apiKey; - if (!loading && valuesChanged && newProps.config.validateKeys() && newProps) { - this.validateConfig(newProps); + componentWillReceiveProps(newProps) { + const { + config: { host, port, apiKeyId, apiKey }, + } = this.props; + const { loading, config } = newProps; + const valuesChanged = host !== config.host || port !== config.port || apiKeyId !== config.apiKeyId || apiKey !== config.apiKey; + if (!loading && valuesChanged && newProps.config.validateKeys() && newProps) { + this.validateConfig(newProps); + } } - } - validateConfig = (newProps) => { - const { config } = newProps; - this.props.validateConfig({ - host: config.host, - port: config.port, - apiKeyId: config.apiKeyId, - apiKey: config.apiKey, - }).then((res) => { - const { isValid } = this.props; - this.props.edit('elasticsearch', { isValid: isValid }) - }); - } + validateConfig = (newProps) => { + const { config } = newProps; + this.props + .validateConfig({ + host: config.host, + port: config.port, + apiKeyId: config.apiKeyId, + apiKey: config.apiKey, + }) + .then((res) => { + const { isValid } = this.props; + this.props.edit('elasticsearch', { isValid: isValid }); + }); + }; - render() { - const props = this.props; - return ( - <> -
-
How to integrate Elasticsearch with OpenReplay and see backend errors alongside session recordings.
- -
- - - ) - } -}; + render() { + const props = this.props; + return ( +
+

Elasticsearch

+
+
How to integrate Elasticsearch with OpenReplay and see backend errors alongside session recordings.
+ +
+ +
+ ); + } +} diff --git a/frontend/app/components/Client/Integrations/FetchDoc/FetchDoc.js b/frontend/app/components/Client/Integrations/FetchDoc/FetchDoc.js index 8d9bbd5b9..761d4160b 100644 --- a/frontend/app/components/Client/Integrations/FetchDoc/FetchDoc.js +++ b/frontend/app/components/Client/Integrations/FetchDoc/FetchDoc.js @@ -1,29 +1,33 @@ import React from 'react'; -import Highlight from 'react-highlight' -import ToggleContent from 'Shared/ToggleContent' +import Highlight from 'react-highlight'; +import ToggleContent from 'Shared/ToggleContent'; import DocLink from 'Shared/DocLink/DocLink'; +import { connect } from 'react-redux'; const FetchDoc = (props) => { - const { projectKey } = props; - return ( -
-
This plugin allows you to capture fetch payloads and inspect them later on while replaying session recordings. This is very useful for understanding and fixing issues.
- -
Installation
- - {`npm i @openreplay/tracker-fetch --save`} - - -
Usage
-

Use the provided fetch method from the plugin instead of the one built-in.

-
+ const { projectKey } = props; + return ( +
+

Fetch

+
+
+ This plugin allows you to capture fetch payloads and inspect them later on while replaying session recordings. This is very useful + for understanding and fixing issues. +
-
Usage
- - {`import tracker from '@openreplay/tracker'; +
Installation
+ {`npm i @openreplay/tracker-fetch --save`} + +
Usage
+

Use the provided fetch method from the plugin instead of the one built-in.

+
+ +
Usage
+ + {`import OpenReplay from '@openreplay/tracker'; import trackerFetch from '@openreplay/tracker-fetch'; //... const tracker = new OpenReplay({ @@ -34,11 +38,11 @@ tracker.start(); export const fetch = tracker.use(trackerFetch()); // check list of available options below //... fetch('https://api.openreplay.com/').then(response => console.log(response.json()));`} - - } - second={ - - {`import OpenReplay from '@openreplay/tracker/cjs'; + + } + second={ + + {`import OpenReplay from '@openreplay/tracker/cjs'; import trackerFetch from '@openreplay/tracker-fetch/cjs'; //... const tracker = new OpenReplay({ @@ -54,15 +58,16 @@ export const fetch = tracker.use(trackerFetch()); // check list of avai //... fetch('https://api.openreplay.com/').then(response => console.log(response.json())); }`} - - } - /> + + } + /> - -
- ) + +
+
+ ); }; -FetchDoc.displayName = "FetchDoc"; +FetchDoc.displayName = 'FetchDoc'; -export default FetchDoc; +export default connect((state) => ({ projectKey: state.getIn(['site', 'instance', 'projectKey'])}) )(FetchDoc) diff --git a/frontend/app/components/Client/Integrations/GithubForm.js b/frontend/app/components/Client/Integrations/GithubForm.js index 586ab3093..7d140732b 100644 --- a/frontend/app/components/Client/Integrations/GithubForm.js +++ b/frontend/app/components/Client/Integrations/GithubForm.js @@ -1,30 +1,31 @@ import React from 'react'; -import IntegrationForm from './IntegrationForm'; +import IntegrationForm from './IntegrationForm'; import DocLink from 'Shared/DocLink/DocLink'; const GithubForm = (props) => ( - <> -
-
Integrate GitHub with OpenReplay and create issues directly from the recording page.
-
- -
+
+

Github

+
+
Integrate GitHub with OpenReplay and create issues directly from the recording page.
+
+ +
+
+
- - ); -GithubForm.displayName = "GithubForm"; +GithubForm.displayName = 'GithubForm'; -export default GithubForm; \ No newline at end of file +export default GithubForm; diff --git a/frontend/app/components/Client/Integrations/GraphQLDoc/GraphQLDoc.js b/frontend/app/components/Client/Integrations/GraphQLDoc/GraphQLDoc.js index a9150bc44..e8779f962 100644 --- a/frontend/app/components/Client/Integrations/GraphQLDoc/GraphQLDoc.js +++ b/frontend/app/components/Client/Integrations/GraphQLDoc/GraphQLDoc.js @@ -1,30 +1,37 @@ import React from 'react'; -import Highlight from 'react-highlight' +import Highlight from 'react-highlight'; import DocLink from 'Shared/DocLink/DocLink'; import ToggleContent from 'Shared/ToggleContent'; +import { connect } from 'react-redux'; const GraphQLDoc = (props) => { - const { projectKey } = props; - return ( -
-

This plugin allows you to capture GraphQL requests and inspect them later on while replaying session recordings. This is very useful for understanding and fixing issues.

-

GraphQL plugin is compatible with Apollo and Relay implementations.

- -
Installation
- - {`npm i @openreplay/tracker-graphql --save`} - - -
Usage
-

The plugin call will return the function, which receives four variables operationKind, operationName, variables and result. It returns result without changes.

- -
+ const { projectKey } = props; + return ( +
+

GraphQL

+
+

+ This plugin allows you to capture GraphQL requests and inspect them later on while replaying session recordings. This is very + useful for understanding and fixing issues. +

+

GraphQL plugin is compatible with Apollo and Relay implementations.

- - {`import OpenReplay from '@openreplay/tracker'; +
Installation
+ {`npm i @openreplay/tracker-graphql --save`} + +
Usage
+

+ The plugin call will return the function, which receives four variables operationKind, operationName, variables and result. It + returns result without changes. +

+ +
+ + + {`import OpenReplay from '@openreplay/tracker'; import trackerGraphQL from '@openreplay/tracker-graphql'; //... const tracker = new OpenReplay({ @@ -33,11 +40,11 @@ const tracker = new OpenReplay({ tracker.start(); //... export const recordGraphQL = tracker.use(trackerGraphQL());`} - - } - second={ - - {`import OpenReplay from '@openreplay/tracker/cjs'; + + } + second={ + + {`import OpenReplay from '@openreplay/tracker/cjs'; import trackerGraphQL from '@openreplay/tracker-graphql/cjs'; //... const tracker = new OpenReplay({ @@ -51,15 +58,16 @@ function SomeFunctionalComponent() { } //... export const recordGraphQL = tracker.use(trackerGraphQL());`} - - } - /> + + } + /> - -
- ) + +
+
+ ); }; -GraphQLDoc.displayName = "GraphQLDoc"; +GraphQLDoc.displayName = 'GraphQLDoc'; -export default GraphQLDoc; +export default connect((state) => ({ projectKey: state.getIn(['site', 'instance', 'projectKey'])}) )(GraphQLDoc) diff --git a/frontend/app/components/Client/Integrations/IntegrationForm.js b/frontend/app/components/Client/Integrations/IntegrationForm.js index aeb28fe31..ad6689f3b 100644 --- a/frontend/app/components/Client/Integrations/IntegrationForm.js +++ b/frontend/app/components/Client/Integrations/IntegrationForm.js @@ -1,144 +1,147 @@ import React from 'react'; import { connect } from 'react-redux'; -import { Input, Form, Button, Checkbox } from 'UI'; +import { Input, Form, Button, Checkbox, Loader } from 'UI'; import SiteDropdown from 'Shared/SiteDropdown'; import { save, init, edit, remove, fetchList } from 'Duck/integrations/actions'; +import { fetchIntegrationList } from 'Duck/integrations/integrations'; -@connect((state, { name, customPath }) => ({ - sites: state.getIn([ 'site', 'list' ]), - initialSiteId: state.getIn([ 'site', 'siteId' ]), - list: state.getIn([ name, 'list' ]), - config: state.getIn([ name, 'instance']), - saving: state.getIn([ customPath || name, 'saveRequest', 'loading']), - removing: state.getIn([ name, 'removeRequest', 'loading']), -}), { - save, - init, - edit, - remove, - fetchList -}) +@connect( + (state, { name, customPath }) => ({ + sites: state.getIn(['site', 'list']), + initialSiteId: state.getIn(['site', 'siteId']), + list: state.getIn([name, 'list']), + config: state.getIn([name, 'instance']), + loading: state.getIn([name, 'fetchRequest', 'loading']), + saving: state.getIn([customPath || name, 'saveRequest', 'loading']), + removing: state.getIn([name, 'removeRequest', 'loading']), + siteId: state.getIn(['integrations', 'siteId']), + }), + { + save, + init, + edit, + remove, + fetchList, + fetchIntegrationList, + } +) export default class IntegrationForm extends React.PureComponent { - constructor(props) { - super(props); - const currentSiteId = this.props.initialSiteId; - this.state = { currentSiteId }; - this.init(currentSiteId); - } - - write = ({ target: { value, name: key, type, checked } }) => { - if (type === 'checkbox') - this.props.edit(this.props.name, { [ key ]: checked }) - else - this.props.edit(this.props.name, { [ key ]: value }) - }; + constructor(props) { + super(props); + // const currentSiteId = this.props.initialSiteId; + // this.state = { currentSiteId }; + // this.init(currentSiteId); + } - onChangeSelect = ({ value }) => { - const { sites, list, name } = this.props; - const site = sites.find(s => s.id === value.value); - this.setState({ currentSiteId: site.id }) - this.init(value.value); - } + write = ({ target: { value, name: key, type, checked } }) => { + if (type === 'checkbox') this.props.edit(this.props.name, { [key]: checked }); + else this.props.edit(this.props.name, { [key]: value }); + }; - init = (siteId) => { - const { list, name } = this.props; - const config = (parseInt(siteId) > 0) ? list.find(s => s.projectId === siteId) : undefined; - this.props.init(name, config ? config : list.first()); - } + // onChangeSelect = ({ value }) => { + // const { sites, list, name } = this.props; + // const site = sites.find((s) => s.id === value.value); + // this.setState({ currentSiteId: site.id }); + // this.init(value.value); + // }; - save = () => { - const { config, name, customPath } = this.props; - const isExists = config.exists(); - const { currentSiteId } = this.state; - const { ignoreProject } = this.props; - this.props.save(customPath || name, (!ignoreProject ? currentSiteId : null), config) - .then(() => { - this.props.fetchList(name) - this.props.onClose(); - if (isExists) return; - }); - } + // init = (siteId) => { + // const { list, name } = this.props; + // const config = parseInt(siteId) > 0 ? list.find((s) => s.projectId === siteId) : undefined; + // this.props.init(name, config ? config : list.first()); + // }; - remove = () => { - const { name, config, ignoreProject } = this.props; - this.props.remove(name, !ignoreProject ? config.projectId : null).then(function() { - this.props.onClose(); - this.props.fetchList(name) - }.bind(this)); - } + save = () => { + const { config, name, customPath, ignoreProject } = this.props; + const isExists = config.exists(); + // const { currentSiteId } = this.state; + this.props.save(customPath || name, !ignoreProject ? this.props.siteId : null, config).then(() => { + // this.props.fetchList(name); + this.props.onClose(); + if (isExists) return; + }); + }; - render() { - const { config, saving, removing, formFields, name, loading, ignoreProject } = this.props; - const { currentSiteId } = this.state; + remove = () => { + const { name, config, ignoreProject } = this.props; + this.props.remove(name, !ignoreProject ? config.projectId : null).then( + function () { + this.props.onClose(); + this.props.fetchList(name); + }.bind(this) + ); + }; - return ( -
-
- {!ignoreProject && - - - - - } + render() { + const { config, saving, removing, formFields, name, loading, ignoreProject } = this.props; + // const { currentSiteId } = this.state; - { formFields.map(({ - key, - label, - placeholder=label, - component: Component = 'input', - type = "text", - checkIfDisplayed, - autoFocus=false - }) => (typeof checkIfDisplayed !== 'function' || checkIfDisplayed(config)) && - ((type === 'checkbox') ? - - - - : - - - - - ) - )} - - + return ( + +
+ + {/* {!ignoreProject && ( + + + + + )} */} - {config.exists() && ( - - )} - -
- ); - } + {formFields.map( + ({ + key, + label, + placeholder = label, + component: Component = 'input', + type = 'text', + checkIfDisplayed, + autoFocus = false, + }) => + (typeof checkIfDisplayed !== 'function' || checkIfDisplayed(config)) && + (type === 'checkbox' ? ( + + + + ) : ( + + + + + )) + )} + + + + {config.exists() && ( + + )} + +
+ + ); + } } diff --git a/frontend/app/components/Client/Integrations/IntegrationItem.js b/frontend/app/components/Client/Integrations/IntegrationItem.js deleted file mode 100644 index b0bfa258a..000000000 --- a/frontend/app/components/Client/Integrations/IntegrationItem.js +++ /dev/null @@ -1,27 +0,0 @@ -import React from 'react'; -import cn from 'classnames'; -import { Icon } from 'UI'; -import stl from './integrationItem.module.css'; - -const onDocLinkClick = (e, link) => { - e.stopPropagation(); - window.open(link, '_blank'); -} - -const IntegrationItem = ({ - deleteHandler = null, icon, url = null, title = '', description = '', onClick = null, dockLink = '', integrated = false -}) => { - return ( -
onClick(e, url) }> - {integrated && ( -
- -
- )} - integration -

{ title }

-
- ) -}; - -export default IntegrationItem; diff --git a/frontend/app/components/Client/Integrations/IntegrationItem.tsx b/frontend/app/components/Client/Integrations/IntegrationItem.tsx new file mode 100644 index 000000000..f1b69c029 --- /dev/null +++ b/frontend/app/components/Client/Integrations/IntegrationItem.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import cn from 'classnames'; +import { Icon, Popup } from 'UI'; +import stl from './integrationItem.module.css'; +import { connect } from 'react-redux'; + +interface Props { + integration: any; + onClick?: (e: React.MouseEvent) => void; + integrated?: boolean; + hide?: boolean; +} + +const IntegrationItem = (props: Props) => { + const { integration, integrated, hide = false } = props; + return hide ? <> : ( +
props.onClick(e)}> + {integrated && ( +
+ + + +
+ )} + integration +
+

{integration.title}

+ {/*

{integration.subtitle && integration.subtitle}

*/} +
+
+ ); +}; + +export default connect((state: any, props: Props) => { + const list = state.getIn([props.integration.slug, 'list']) || []; + return { + // integrated: props.integration.slug === 'issues' ? !!(list.first() && list.first().token) : list.size > 0, + }; +})(IntegrationItem); diff --git a/frontend/app/components/Client/Integrations/Integrations.js b/frontend/app/components/Client/Integrations/Integrations.js_ similarity index 100% rename from frontend/app/components/Client/Integrations/Integrations.js rename to frontend/app/components/Client/Integrations/Integrations.js_ diff --git a/frontend/app/components/Client/Integrations/Integrations.tsx b/frontend/app/components/Client/Integrations/Integrations.tsx new file mode 100644 index 000000000..8e301ac8a --- /dev/null +++ b/frontend/app/components/Client/Integrations/Integrations.tsx @@ -0,0 +1,174 @@ +import { useModal } from 'App/components/Modal'; +import React, { useEffect } from 'react'; +import BugsnagForm from './BugsnagForm'; +import CloudwatchForm from './CloudwatchForm'; +import DatadogForm from './DatadogForm'; +import ElasticsearchForm from './ElasticsearchForm'; +import GithubForm from './GithubForm'; +import IntegrationItem from './IntegrationItem'; +import JiraForm from './JiraForm'; +import NewrelicForm from './NewrelicForm'; +import RollbarForm from './RollbarForm'; +import SentryForm from './SentryForm'; +import SlackForm from './SlackForm'; +import StackdriverForm from './StackdriverForm'; +import SumoLogicForm from './SumoLogicForm'; +import { fetch, init } from 'Duck/integrations/actions'; +import { fetchIntegrationList, setSiteId } from 'Duck/integrations/integrations'; +import { connect } from 'react-redux'; +import SiteDropdown from 'Shared/SiteDropdown'; +import ReduxDoc from './ReduxDoc'; +import VueDoc from './VueDoc'; +import GraphQLDoc from './GraphQLDoc'; +import NgRxDoc from './NgRxDoc'; +import MobxDoc from './MobxDoc'; +import FetchDoc from './FetchDoc'; +import ProfilerDoc from './ProfilerDoc'; +import AxiosDoc from './AxiosDoc'; +import AssistDoc from './AssistDoc'; +import { PageTitle, Loader } from 'UI'; +import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG'; +import withPageTitle from 'HOCs/withPageTitle'; + +interface Props { + fetch: (name: string, siteId: string) => void; + init: () => void; + fetchIntegrationList: (siteId: any) => void; + integratedList: any; + initialSiteId: string; + setSiteId: (siteId: string) => void; + siteId: string; + hideHeader?: boolean; + loading?: boolean; +} +function Integrations(props: Props) { + const { initialSiteId, hideHeader = false, loading = false } = props; + const { showModal } = useModal(); + const [integratedList, setIntegratedList] = React.useState([]); + + useEffect(() => { + const list = props.integratedList.filter((item: any) => item.integrated).map((item: any) => item.name); + setIntegratedList(list); + }, [props.integratedList]); + + useEffect(() => { + if (!props.siteId) { + props.setSiteId(initialSiteId); + props.fetchIntegrationList(initialSiteId); + } else { + props.fetchIntegrationList(props.siteId); + } + }, []); + + const onClick = (integration: any) => { + if (integration.slug) { + props.fetch(integration.slug, props.siteId); + } + showModal(integration.component, { right: true }); + }; + + const onChangeSelect = ({ value }: any) => { + props.setSiteId(value.value); + props.fetchIntegrationList(value.value); + }; + + return ( +
+ {!hideHeader && Integrations
} />} + {integrations.map((cat: any) => ( +
+
+

{cat.title}

+ {cat.isProject && ( +
+
+ +
+ {loading && cat.isProject && } +
+ )} +
+
{cat.description}
+ +
+ {/* */} + {cat.integrations.map((integration: any) => ( + onClick(integration)} + hide={ + (integration.slug === 'github' && integratedList.includes('jira')) || + (integration.slug === 'jira' && integratedList.includes('github')) + } + /> + ))} + {/* */} +
+
+ ))} +
+ ); +} + +export default connect( + (state: any) => ({ + initialSiteId: state.getIn(['site', 'siteId']), + integratedList: state.getIn(['integrations', 'list']) || [], + loading: state.getIn(['integrations', 'fetchRequest', 'loading']), + siteId: state.getIn(['integrations', 'siteId']), + }), + { fetch, init, fetchIntegrationList, setSiteId } +)(withPageTitle('Integrations - OpenReplay Preferences')(Integrations)); + +const integrations = [ + { + title: 'Issue Reporting and Collaborations', + description: 'Seamlessly report issues or share issues with your team right from OpenReplay.', + isProject: false, + integrations: [ + { title: 'Jira', slug: 'jira', category: 'Errors', icon: 'integrations/jira', component: }, + { title: 'Github', slug: 'github', category: 'Errors', icon: 'integrations/github', component: }, + { title: 'Slack', category: 'Errors', icon: 'integrations/slack', component: }, + ], + }, + { + title: 'Backend Logging', + isProject: true, + description: 'Sync your backend errors with sessions replays and see what happened front-to-back.', + integrations: [ + { title: 'Sentry', slug: 'sentry', icon: 'integrations/sentry', component: }, + { title: 'Datadog', slug: 'datadog', icon: 'integrations/datadog', component: }, + { title: 'Rollbar', slug: 'rollbar', icon: 'integrations/rollbar', component: }, + { title: 'Elasticsearch', slug: 'elasticsearch', icon: 'integrations/elasticsearch', component: }, + { title: 'Datadog', slug: 'datadog', icon: 'integrations/datadog', component: }, + { title: 'Sumo Logic', slug: 'sumologic', icon: 'integrations/sumologic', component: }, + { + title: 'Stackdriver', + slug: 'stackdriver', + icon: 'integrations/google-cloud', + component: , + }, + { title: 'CloudWatch', slug: 'cloudwatch', icon: 'integrations/aws', component: }, + { title: 'Newrelic', slug: 'newrelic', icon: 'integrations/newrelic', component: }, + ], + }, + { + title: 'Plugins', + isProject: true, + description: + "Reproduce issues as if they happened in your own browser. Plugins help capture your application's store, HTTP requeets, GraphQL queries, and more.", + integrations: [ + { title: 'Redux', slug: '', icon: 'integrations/redux', component: }, + { title: 'VueX', slug: '', icon: 'integrations/vuejs', component: }, + { title: 'GraphQL', slug: '', icon: 'integrations/graphql', component: }, + { title: 'NgRx', slug: '', icon: 'integrations/ngrx', component: }, + { title: 'MobX', slug: '', icon: 'integrations/mobx', component: }, + { title: 'Fetch', slug: '', icon: 'integrations/openreplay', component: }, + { title: 'Profiler', slug: '', icon: 'integrations/openreplay', component: }, + { title: 'Axios', slug: '', icon: 'integrations/openreplay', component: }, + { title: 'Assist', slug: '', icon: 'integrations/openreplay', component: }, + ], + }, +]; diff --git a/frontend/app/components/Client/Integrations/JiraForm/JiraForm.js b/frontend/app/components/Client/Integrations/JiraForm/JiraForm.js index dc4585872..b17bbc460 100644 --- a/frontend/app/components/Client/Integrations/JiraForm/JiraForm.js +++ b/frontend/app/components/Client/Integrations/JiraForm/JiraForm.js @@ -1,37 +1,41 @@ import React from 'react'; -import IntegrationForm from '../IntegrationForm'; +import IntegrationForm from '../IntegrationForm'; import DocLink from 'Shared/DocLink/DocLink'; const JiraForm = (props) => ( - <> -
-
How to integrate Jira Cloud with OpenReplay.
-
- -
+
+

Jira

+
+
How to integrate Jira Cloud with OpenReplay.
+
+ +
+
+
- - ); -JiraForm.displayName = "JiraForm"; +JiraForm.displayName = 'JiraForm'; -export default JiraForm; \ No newline at end of file +export default JiraForm; diff --git a/frontend/app/components/Client/Integrations/MobxDoc/MobxDoc.js b/frontend/app/components/Client/Integrations/MobxDoc/MobxDoc.js index 320e1a742..127839feb 100644 --- a/frontend/app/components/Client/Integrations/MobxDoc/MobxDoc.js +++ b/frontend/app/components/Client/Integrations/MobxDoc/MobxDoc.js @@ -1,29 +1,36 @@ import React from 'react'; -import Highlight from 'react-highlight' -import ToggleContent from 'Shared/ToggleContent' +import Highlight from 'react-highlight'; +import ToggleContent from 'Shared/ToggleContent'; import DocLink from 'Shared/DocLink/DocLink'; +import { connect } from 'react-redux'; const MobxDoc = (props) => { - const { projectKey } = props; - return ( -
-
This plugin allows you to capture MobX events and inspect them later on while replaying session recordings. This is very useful for understanding and fixing issues.
- -
Installation
- - {`npm i @openreplay/tracker-mobx --save`} - - -
Usage
-

Initialize the @openreplay/tracker package as usual and load the plugin into it. Then put the generated middleware into your Redux chain.

-
+ const { projectKey } = props; + return ( +
+

MobX

+
+
+ This plugin allows you to capture MobX events and inspect them later on while replaying session recordings. This is very useful + for understanding and fixing issues. +
-
Usage
- - {`import OpenReplay from '@openreplay/tracker'; +
Installation
+ {`npm i @openreplay/tracker-mobx --save`} + +
Usage
+

+ Initialize the @openreplay/tracker package as usual and load the plugin into it. Then put the generated middleware into your Redux + chain. +

+
+ +
Usage
+ + {`import OpenReplay from '@openreplay/tracker'; import trackerMobX from '@openreplay/tracker-mobx'; //... const tracker = new OpenReplay({ @@ -31,11 +38,11 @@ const tracker = new OpenReplay({ }); tracker.use(trackerMobX()); // check list of available options below tracker.start();`} - - } - second={ - - {`import OpenReplay from '@openreplay/tracker/cjs'; + + } + second={ + + {`import OpenReplay from '@openreplay/tracker/cjs'; import trackerMobX from '@openreplay/tracker-mobx/cjs'; //... const tracker = new OpenReplay({ @@ -48,15 +55,16 @@ function SomeFunctionalComponent() { tracker.start(); }, []) }`} - - } - /> + + } + /> - -
- ) + +
+
+ ); }; -MobxDoc.displayName = "MobxDoc"; +MobxDoc.displayName = 'MobxDoc'; -export default MobxDoc; +export default connect((state) => ({ projectKey: state.getIn(['site', 'instance', 'projectKey'])}) )(MobxDoc) diff --git a/frontend/app/components/Client/Integrations/NewrelicForm/NewrelicForm.js b/frontend/app/components/Client/Integrations/NewrelicForm/NewrelicForm.js index d7ce557e8..670656583 100644 --- a/frontend/app/components/Client/Integrations/NewrelicForm/NewrelicForm.js +++ b/frontend/app/components/Client/Integrations/NewrelicForm/NewrelicForm.js @@ -1,32 +1,36 @@ import React from 'react'; -import IntegrationForm from '../IntegrationForm'; +import IntegrationForm from '../IntegrationForm'; import DocLink from 'Shared/DocLink/DocLink'; const NewrelicForm = (props) => ( - <> -
-
How to integrate NewRelic with OpenReplay and see backend errors alongside session recordings.
- +
+

New Relic

+
+
How to integrate NewRelic with OpenReplay and see backend errors alongside session recordings.
+ +
+
- - ); -NewrelicForm.displayName = "NewrelicForm"; +NewrelicForm.displayName = 'NewrelicForm'; -export default NewrelicForm; \ No newline at end of file +export default NewrelicForm; diff --git a/frontend/app/components/Client/Integrations/NgRxDoc/NgRxDoc.js b/frontend/app/components/Client/Integrations/NgRxDoc/NgRxDoc.js index 385b0d4e4..0e508af2b 100644 --- a/frontend/app/components/Client/Integrations/NgRxDoc/NgRxDoc.js +++ b/frontend/app/components/Client/Integrations/NgRxDoc/NgRxDoc.js @@ -1,29 +1,33 @@ import React from 'react'; -import Highlight from 'react-highlight' -import ToggleContent from 'Shared/ToggleContent' +import Highlight from 'react-highlight'; +import ToggleContent from 'Shared/ToggleContent'; import DocLink from 'Shared/DocLink/DocLink'; +import { connect } from 'react-redux'; const NgRxDoc = (props) => { - const { projectKey } = props; - return ( -
-
This plugin allows you to capture NgRx actions/state and inspect them later on while replaying session recordings. This is very useful for understanding and fixing issues.
- -
Installation
- - {`npm i @openreplay/tracker-ngrx --save`} - - -
Usage
-

Add the generated meta-reducer into your imports. See NgRx documentation for more details.

-
+ const { projectKey } = props; + return ( +
+

NgRx

+
+
+ This plugin allows you to capture NgRx actions/state and inspect them later on while replaying session recordings. This is very + useful for understanding and fixing issues. +
-
Usage
- - {`import { StoreModule } from '@ngrx/store'; +
Installation
+ {`npm i @openreplay/tracker-ngrx --save`} + +
Usage
+

Add the generated meta-reducer into your imports. See NgRx documentation for more details.

+
+ +
Usage
+ + {`import { StoreModule } from '@ngrx/store'; import { reducers } from './reducers'; import OpenReplay from '@openreplay/tracker'; import trackerNgRx from '@openreplay/tracker-ngrx'; @@ -39,11 +43,11 @@ const metaReducers = [tracker.use(trackerNgRx())]; // check list of ava imports: [StoreModule.forRoot(reducers, { metaReducers })] }) export class AppModule {}`} - - } - second={ - - {`import { StoreModule } from '@ngrx/store'; + + } + second={ + + {`import { StoreModule } from '@ngrx/store'; import { reducers } from './reducers'; import OpenReplay from '@openreplay/tracker/cjs'; import trackerNgRx from '@openreplay/tracker-ngrx/cjs'; @@ -64,15 +68,16 @@ const metaReducers = [tracker.use(trackerNgRx())]; // check list of ava }) export class AppModule {} }`} - - } - /> + + } + /> - -
- ) + +
+
+ ); }; -NgRxDoc.displayName = "NgRxDoc"; +NgRxDoc.displayName = 'NgRxDoc'; -export default NgRxDoc; +export default connect((state) => ({ projectKey: state.getIn(['site', 'instance', 'projectKey'])}) )(NgRxDoc) diff --git a/frontend/app/components/Client/Integrations/ProfilerDoc/ProfilerDoc.js b/frontend/app/components/Client/Integrations/ProfilerDoc/ProfilerDoc.js index 9cada092b..092a0778a 100644 --- a/frontend/app/components/Client/Integrations/ProfilerDoc/ProfilerDoc.js +++ b/frontend/app/components/Client/Integrations/ProfilerDoc/ProfilerDoc.js @@ -1,29 +1,33 @@ import React from 'react'; -import Highlight from 'react-highlight' -import ToggleContent from 'Shared/ToggleContent' +import Highlight from 'react-highlight'; +import ToggleContent from 'Shared/ToggleContent'; import DocLink from 'Shared/DocLink/DocLink'; +import { connect } from 'react-redux'; const ProfilerDoc = (props) => { - const { projectKey } = props; - return ( -
-
The profiler plugin allows you to measure your JS functions' performance and capture both arguments and result for each function call.
- -
Installation
- - {`npm i @openreplay/tracker-profiler --save`} - - -
Usage
-

Initialize the tracker and load the plugin into it. Then decorate any function inside your code with the generated function.

-
+ const { projectKey } = props; + return ( +
+

Profiler

+
+
+ The profiler plugin allows you to measure your JS functions' performance and capture both arguments and result for each function + call. +
-
Usage
- - {`import OpenReplay from '@openreplay/tracker'; +
Installation
+ {`npm i @openreplay/tracker-profiler --save`} + +
Usage
+

Initialize the tracker and load the plugin into it. Then decorate any function inside your code with the generated function.

+
+ +
Usage
+ + {`import OpenReplay from '@openreplay/tracker'; import trackerProfiler from '@openreplay/tracker-profiler'; //... const tracker = new OpenReplay({ @@ -36,11 +40,11 @@ export const profiler = tracker.use(trackerProfiler()); const fn = profiler('call_name')(() => { //... }, thisArg); // thisArg is optional`} - - } - second={ - - {`import OpenReplay from '@openreplay/tracker/cjs'; + + } + second={ + + {`import OpenReplay from '@openreplay/tracker/cjs'; import trackerProfiler from '@openreplay/tracker-profiler/cjs'; //... const tracker = new OpenReplay({ @@ -58,15 +62,16 @@ const fn = profiler('call_name')(() => { //... }, thisArg); // thisArg is optional }`} - - } - /> + + } + /> - -
- ) + +
+
+ ); }; -ProfilerDoc.displayName = "ProfilerDoc"; +ProfilerDoc.displayName = 'ProfilerDoc'; -export default ProfilerDoc; +export default connect((state) => ({ projectKey: state.getIn(['site', 'instance', 'projectKey'])}) )(ProfilerDoc) diff --git a/frontend/app/components/Client/Integrations/ReduxDoc/ReduxDoc.js b/frontend/app/components/Client/Integrations/ReduxDoc/ReduxDoc.js index 8e3b12432..e154c80bd 100644 --- a/frontend/app/components/Client/Integrations/ReduxDoc/ReduxDoc.js +++ b/frontend/app/components/Client/Integrations/ReduxDoc/ReduxDoc.js @@ -1,28 +1,32 @@ import React from 'react'; -import Highlight from 'react-highlight' +import Highlight from 'react-highlight'; import ToggleContent from '../../../shared/ToggleContent'; import DocLink from 'Shared/DocLink/DocLink'; +import { connect } from 'react-redux'; const ReduxDoc = (props) => { - const { projectKey } = props; - return ( -
-
This plugin allows you to capture Redux actions/state and inspect them later on while replaying session recordings. This is very useful for understanding and fixing issues.
- -
Installation
- - {`npm i @openreplay/tracker-redux --save`} - - + const { projectKey } = props; + return ( +
+

Redux

-
Usage
-

Initialize the tracker then put the generated middleware into your Redux chain.

-
- - {`import { applyMiddleware, createStore } from 'redux'; +
+
+ This plugin allows you to capture Redux actions/state and inspect them later on while replaying session recordings. This is very + useful for understanding and fixing issues. +
+ +
Installation
+ {`npm i @openreplay/tracker-redux --save`} + +
Usage
+

Initialize the tracker then put the generated middleware into your Redux chain.

+
+ + {`import { applyMiddleware, createStore } from 'redux'; import OpenReplay from '@openreplay/tracker'; import trackerRedux from '@openreplay/tracker-redux'; //... @@ -35,11 +39,11 @@ const store = createStore( reducer, applyMiddleware(tracker.use(trackerRedux())) // check list of available options below );`} - - } - second={ - - {`import { applyMiddleware, createStore } from 'redux'; + + } + second={ + + {`import { applyMiddleware, createStore } from 'redux'; import OpenReplay from '@openreplay/tracker/cjs'; import trackerRedux from '@openreplay/tracker-redux/cjs'; //... @@ -57,15 +61,16 @@ const store = createStore( applyMiddleware(tracker.use(trackerRedux())) // check list of available options below ); }`} - - } - /> + + } + /> - -
- ) + +
+
+ ); }; -ReduxDoc.displayName = "ReduxDoc"; +ReduxDoc.displayName = 'ReduxDoc'; -export default ReduxDoc; +export default connect((state) => ({ projectKey: state.getIn(['site', 'instance', 'projectKey'])}) )(ReduxDoc) diff --git a/frontend/app/components/Client/Integrations/RollbarForm.js b/frontend/app/components/Client/Integrations/RollbarForm.js index 3b8830423..441819323 100644 --- a/frontend/app/components/Client/Integrations/RollbarForm.js +++ b/frontend/app/components/Client/Integrations/RollbarForm.js @@ -1,25 +1,27 @@ import React from 'react'; -import IntegrationForm from './IntegrationForm'; +import IntegrationForm from './IntegrationForm'; import DocLink from 'Shared/DocLink/DocLink'; const RollbarForm = (props) => ( - <> -
-
How to integrate Rollbar with OpenReplay and see backend errors alongside session replays.
- +
+

Rollbar

+
+
How to integrate Rollbar with OpenReplay and see backend errors alongside session replays.
+ +
+
- - ); -RollbarForm.displayName = "RollbarForm"; +RollbarForm.displayName = 'RollbarForm'; -export default RollbarForm; \ No newline at end of file +export default RollbarForm; diff --git a/frontend/app/components/Client/Integrations/SentryForm.js b/frontend/app/components/Client/Integrations/SentryForm.js index fd7bf1f11..bd119ba31 100644 --- a/frontend/app/components/Client/Integrations/SentryForm.js +++ b/frontend/app/components/Client/Integrations/SentryForm.js @@ -1,31 +1,35 @@ import React from 'react'; -import IntegrationForm from './IntegrationForm'; +import IntegrationForm from './IntegrationForm'; import DocLink from 'Shared/DocLink/DocLink'; const SentryForm = (props) => ( - <> -
-
How to integrate Sentry with OpenReplay and see backend errors alongside session recordings.
- +
+

Sentry

+
+
How to integrate Sentry with OpenReplay and see backend errors alongside session recordings.
+ +
+
- - ); -SentryForm.displayName = "SentryForm"; +SentryForm.displayName = 'SentryForm'; -export default SentryForm; \ No newline at end of file +export default SentryForm; diff --git a/frontend/app/components/Client/Integrations/SlackAddForm/SlackAddForm.js b/frontend/app/components/Client/Integrations/SlackAddForm/SlackAddForm.js index 8e1bb121e..f018da3e5 100644 --- a/frontend/app/components/Client/Integrations/SlackAddForm/SlackAddForm.js +++ b/frontend/app/components/Client/Integrations/SlackAddForm/SlackAddForm.js @@ -1,101 +1,91 @@ -import React from 'react' -import { connect } from 'react-redux' -import { edit, save, init, update } from 'Duck/integrations/slack' -import { Form, Input, Button, Message } from 'UI' +import React from 'react'; +import { connect } from 'react-redux'; +import { edit, save, init, update } from 'Duck/integrations/slack'; +import { Form, Input, Button, Message } from 'UI'; import { confirm } from 'UI'; -import { remove } from 'Duck/integrations/slack' +import { remove } from 'Duck/integrations/slack'; class SlackAddForm extends React.PureComponent { - componentWillUnmount() { - this.props.init({}); - } - - save = () => { - const instance = this.props.instance; - if(instance.exists()) { - this.props.update(this.props.instance) - } else { - this.props.save(this.props.instance) - } - } - - remove = async (id) => { - if (await confirm({ - header: 'Confirm', - confirmButton: 'Yes, delete', - confirmation: `Are you sure you want to permanently delete this channel?` - })) { - this.props.remove(id); + componentWillUnmount() { + this.props.init({}); } - } - write = ({ target: { name, value } }) => this.props.edit({ [ name ]: value }); - - render() { - const { instance, saving, errors, onClose } = this.props; - return ( -
-
- - - - - - - - -
-
- - - -
- - -
-
- - { errors && -
- { errors.map(error => { error }) } -
+ save = () => { + const instance = this.props.instance; + if (instance.exists()) { + this.props.update(this.props.instance); + } else { + this.props.save(this.props.instance); } -
- ) - } + }; + + remove = async (id) => { + if ( + await confirm({ + header: 'Confirm', + confirmButton: 'Yes, delete', + confirmation: `Are you sure you want to permanently delete this channel?`, + }) + ) { + this.props.remove(id); + } + }; + + write = ({ target: { name, value } }) => this.props.edit({ [name]: value }); + + render() { + const { instance, saving, errors, onClose } = this.props; + return ( +
+
+ + + + + + + + +
+
+ + + +
+ + +
+
+ + {errors && ( +
+ {errors.map((error) => ( + + {error} + + ))} +
+ )} +
+ ); + } } -export default connect(state => ({ - instance: state.getIn(['slack', 'instance']), - saving: state.getIn(['slack', 'saveRequest', 'loading']), - errors: state.getIn([ 'slack', 'saveRequest', 'errors' ]), -}), { edit, save, init, remove, update })(SlackAddForm) \ No newline at end of file +export default connect( + (state) => ({ + instance: state.getIn(['slack', 'instance']), + saving: state.getIn(['slack', 'saveRequest', 'loading']), + errors: state.getIn(['slack', 'saveRequest', 'errors']), + }), + { edit, save, init, remove, update } +)(SlackAddForm); diff --git a/frontend/app/components/Client/Integrations/SlackChannelList/SlackChannelList.js b/frontend/app/components/Client/Integrations/SlackChannelList/SlackChannelList.js index f78527204..8d25b4454 100644 --- a/frontend/app/components/Client/Integrations/SlackChannelList/SlackChannelList.js +++ b/frontend/app/components/Client/Integrations/SlackChannelList/SlackChannelList.js @@ -1,49 +1,51 @@ -import React from 'react' -import { connect } from 'react-redux' +import React from 'react'; +import { connect } from 'react-redux'; import { NoContent } from 'UI'; -import { remove, edit } from 'Duck/integrations/slack' +import { remove, edit, init } from 'Duck/integrations/slack'; import DocLink from 'Shared/DocLink/DocLink'; function SlackChannelList(props) { - const { list } = props; + const { list } = props; - const onEdit = (instance) => { - props.edit(instance) - props.onEdit() - } + const onEdit = (instance) => { + props.edit(instance); + props.onEdit(); + }; - return ( -
- -
Integrate Slack with OpenReplay and share insights with the rest of the team, directly from the recording page.
- {/* */} - -
- } - size="small" - show={ list.size === 0 } - > - {list.map(c => ( -
onEdit(c)} - > -
-
{c.name}
-
- {c.endpoint} -
-
-
- ))} - -
- ) + return ( +
+ +
+ Integrate Slack with OpenReplay and share insights with the rest of the team, directly from the recording page. +
+ +
+ } + size="small" + show={list.size === 0} + > + {list.map((c) => ( +
onEdit(c)} + > +
+
{c.name}
+
{c.endpoint}
+
+
+ ))} + +
+ ); } -export default connect(state => ({ - list: state.getIn(['slack', 'list']) -}), { remove, edit })(SlackChannelList) +export default connect( + (state) => ({ + list: state.getIn(['slack', 'list']), + }), + { remove, edit, init } +)(SlackChannelList); diff --git a/frontend/app/components/Client/Integrations/SlackForm.js b/frontend/app/components/Client/Integrations/SlackForm.js deleted file mode 100644 index 986af20ab..000000000 --- a/frontend/app/components/Client/Integrations/SlackForm.js +++ /dev/null @@ -1,15 +0,0 @@ -import React from 'react'; -import SlackChannelList from './SlackChannelList/SlackChannelList'; - -const SlackForm = (props) => { - const { onEdit } = props; - return ( - <> - - - ) -} - -SlackForm.displayName = "SlackForm"; - -export default SlackForm; \ No newline at end of file diff --git a/frontend/app/components/Client/Integrations/SlackForm.tsx b/frontend/app/components/Client/Integrations/SlackForm.tsx new file mode 100644 index 000000000..79c6b2a00 --- /dev/null +++ b/frontend/app/components/Client/Integrations/SlackForm.tsx @@ -0,0 +1,56 @@ +import React, { useEffect } from 'react'; +import SlackChannelList from './SlackChannelList/SlackChannelList'; +import { fetchList, init } from 'Duck/integrations/slack'; +import { connect } from 'react-redux'; +import SlackAddForm from './SlackAddForm'; +import { useModal } from 'App/components/Modal'; +import { Button } from 'UI'; + +interface Props { + onEdit?: (integration: any) => void; + istance: any; + fetchList: any; + init: any; +} +const SlackForm = (props: Props) => { + const [active, setActive] = React.useState(false); + + const onEdit = () => { + setActive(true); + }; + + const onNew = () => { + setActive(true); + props.init({}); + } + + useEffect(() => { + props.fetchList(); + }, []); + + return ( +
+ {active && ( +
+ setActive(false)} /> +
+ )} +
+
+

Slack

+
+ +
+
+ ); +}; + +SlackForm.displayName = 'SlackForm'; + +export default connect( + (state: any) => ({ + istance: state.getIn(['slack', 'instance']), + }), + { fetchList, init } +)(SlackForm); diff --git a/frontend/app/components/Client/Integrations/StackdriverForm.js b/frontend/app/components/Client/Integrations/StackdriverForm.js index b8e29fa3c..ce137bd99 100644 --- a/frontend/app/components/Client/Integrations/StackdriverForm.js +++ b/frontend/app/components/Client/Integrations/StackdriverForm.js @@ -1,29 +1,32 @@ import React from 'react'; -import IntegrationForm from './IntegrationForm'; +import IntegrationForm from './IntegrationForm'; import DocLink from 'Shared/DocLink/DocLink'; const StackdriverForm = (props) => ( - <> -
-
How to integrate Stackdriver with OpenReplay and see backend errors alongside session recordings.
- +
+

Stackdriver

+
+
How to integrate Stackdriver with OpenReplay and see backend errors alongside session recordings.
+ +
+
- - ); -StackdriverForm.displayName = "StackdriverForm"; +StackdriverForm.displayName = 'StackdriverForm'; export default StackdriverForm; diff --git a/frontend/app/components/Client/Integrations/SumoLogicForm/SumoLogicForm.js b/frontend/app/components/Client/Integrations/SumoLogicForm/SumoLogicForm.js index 0a807edb6..6aea9fe6e 100644 --- a/frontend/app/components/Client/Integrations/SumoLogicForm/SumoLogicForm.js +++ b/frontend/app/components/Client/Integrations/SumoLogicForm/SumoLogicForm.js @@ -4,30 +4,34 @@ import RegionDropdown from './RegionDropdown'; import DocLink from 'Shared/DocLink/DocLink'; const SumoLogicForm = (props) => ( - <> -
-
How to integrate SumoLogic with OpenReplay and see backend errors alongside session recordings.
- +
+

Sumologic

+
+
How to integrate SumoLogic with OpenReplay and see backend errors alongside session recordings.
+ +
+
- - ); -SumoLogicForm.displayName = "SumoLogicForm"; +SumoLogicForm.displayName = 'SumoLogicForm'; export default SumoLogicForm; diff --git a/frontend/app/components/Client/Integrations/VueDoc/VueDoc.js b/frontend/app/components/Client/Integrations/VueDoc/VueDoc.js index e00d1c0ad..c2ad189d1 100644 --- a/frontend/app/components/Client/Integrations/VueDoc/VueDoc.js +++ b/frontend/app/components/Client/Integrations/VueDoc/VueDoc.js @@ -1,29 +1,35 @@ import React from 'react'; -import Highlight from 'react-highlight' +import Highlight from 'react-highlight'; import ToggleContent from '../../../shared/ToggleContent'; import DocLink from 'Shared/DocLink/DocLink'; +import { connect } from 'react-redux'; const VueDoc = (props) => { - const { projectKey } = props; - return ( -
-
This plugin allows you to capture VueX mutations/state and inspect them later on while replaying session recordings. This is very useful for understanding and fixing issues.
- -
Installation
- - {`npm i @openreplay/tracker-vuex --save`} - - -
Usage
-

Initialize the @openreplay/tracker package as usual and load the plugin into it. Then put the generated plugin into your plugins field of your store.

-
+ const { projectKey } = props; + return ( +
+

VueX

+
+
+ This plugin allows you to capture VueX mutations/state and inspect them later on while replaying session recordings. This is very + useful for understanding and fixing issues. +
- - - {`import Vuex from 'vuex' +
Installation
+ {`npm i @openreplay/tracker-vuex --save`} + +
Usage
+

+ Initialize the @openreplay/tracker package as usual and load the plugin into it. Then put the generated plugin into your plugins + field of your store. +

+
+ + + {`import Vuex from 'vuex' import OpenReplay from '@openreplay/tracker'; import trackerVuex from '@openreplay/tracker-vuex'; //... @@ -36,11 +42,11 @@ const store = new Vuex.Store({ //... plugins: [tracker.use(trackerVuex())] // check list of available options below });`} - - } - second={ - - {`import Vuex from 'vuex' + + } + second={ + + {`import Vuex from 'vuex' import OpenReplay from '@openreplay/tracker/cjs'; import trackerVuex from '@openreplay/tracker-vuex/cjs'; //... @@ -58,15 +64,16 @@ const store = new Vuex.Store({ plugins: [tracker.use(trackerVuex())] // check list of available options below }); }`} - - } - /> + + } + /> - -
- ) + +
+
+ ); }; -VueDoc.displayName = "VueDoc"; +VueDoc.displayName = 'VueDoc'; -export default VueDoc; +export default connect((state) => ({ projectKey: state.getIn(['site', 'instance', 'projectKey'])}) )(VueDoc) diff --git a/frontend/app/components/Client/Integrations/_IntegrationItem .js_old b/frontend/app/components/Client/Integrations/_IntegrationItem .js_old deleted file mode 100644 index 962135633..000000000 --- a/frontend/app/components/Client/Integrations/_IntegrationItem .js_old +++ /dev/null @@ -1,42 +0,0 @@ -import React from 'react'; -import cn from 'classnames'; -import { Icon } from 'UI'; -import styles from './integrationItem.module.css'; - -const onDocLinkClick = (e, link) => { - e.stopPropagation(); - window.open(link, '_blank'); -} - -const IntegrationItem = ({ - deleteHandler = null, icon, url = null, title = '', description = '', onClick = null, dockLink = '', integrated = false -}) => { - return ( -
onClick(e, url) }> - -

{ title }

-

{ description }

-
-
- {deleteHandler && ( -
- - { 'Remove' } -
- )} - { dockLink && ( -
onDocLinkClick(e, dockLink) }> - - { 'Documentation' } -
- )} -
- - { 'Integrated' } -
-
-
- ) -}; - -export default IntegrationItem; diff --git a/frontend/app/components/Client/Integrations/integrationItem.module.css b/frontend/app/components/Client/Integrations/integrationItem.module.css index 94ab26726..fca162909 100644 --- a/frontend/app/components/Client/Integrations/integrationItem.module.css +++ b/frontend/app/components/Client/Integrations/integrationItem.module.css @@ -9,7 +9,7 @@ display: flex; flex-direction: column; align-items: center; - justify-content: center; + justify-content: flex-start; /* min-height: 250px; */ /* min-width: 260px; */ /* max-width: 300px; */ diff --git a/frontend/app/components/Client/Notifications/Notifications.js b/frontend/app/components/Client/Notifications/Notifications.js index 15d6b9b4d..88855dd45 100644 --- a/frontend/app/components/Client/Notifications/Notifications.js +++ b/frontend/app/components/Client/Notifications/Notifications.js @@ -1,46 +1,50 @@ -import React, { useEffect } from 'react' -import cn from 'classnames' -import stl from './notifications.module.css' -import { Checkbox } from 'UI' -import { connect } from 'react-redux' -import { withRequest } from 'HOCs' -import { fetch as fetchConfig, edit as editConfig, save as saveConfig } from 'Duck/config' +import React, { useEffect } from 'react'; +import cn from 'classnames'; +import stl from './notifications.module.css'; +import { Checkbox, Toggler } from 'UI'; +import { connect } from 'react-redux'; +import { withRequest } from 'HOCs'; +import { fetch as fetchConfig, edit as editConfig, save as saveConfig } from 'Duck/config'; import withPageTitle from 'HOCs/withPageTitle'; function Notifications(props) { - const { config } = props; + const { config } = props; - useEffect(() => { - props.fetchConfig(); - }, []) + useEffect(() => { + props.fetchConfig(); + }, []); - const onChange = () => { - const _config = { 'weeklyReport' : !config.weeklyReport }; - props.editConfig(_config); - props.saveConfig(_config) - } + const onChange = () => { + const _config = { weeklyReport: !config.weeklyReport }; + props.editConfig(_config); + props.saveConfig(_config); + }; - return ( -
-
- {

{ 'Notifications' }

} -
-
- - -
-
- ) + return ( +
+
{

{'Notifications'}

}
+
+
Weekly project summary
+
Receive wekly report for each project on email.
+ + {/* */} + {/* */} +
+
+ ); } -export default connect(state => ({ - config: state.getIn(['config', 'options']) -}), { fetchConfig, editConfig, saveConfig })(withPageTitle('Notifications - OpenReplay Preferences')(Notifications)); +export default connect( + (state) => ({ + config: state.getIn(['config', 'options']), + }), + { fetchConfig, editConfig, saveConfig } +)(withPageTitle('Notifications - OpenReplay Preferences')(Notifications)); diff --git a/frontend/app/components/Client/PreferencesMenu/PreferencesMenu.js b/frontend/app/components/Client/PreferencesMenu/PreferencesMenu.js index 820fe14e4..8314e521a 100644 --- a/frontend/app/components/Client/PreferencesMenu/PreferencesMenu.js +++ b/frontend/app/components/Client/PreferencesMenu/PreferencesMenu.js @@ -13,14 +13,14 @@ function PreferencesMenu({ account, activeTab, history, isEnterprise }) { }; return ( -
+
Preferences
-
+
-
+
-
+
{ -
+
} -
+
{isEnterprise && isAdmin && ( -
+
+
- setTab(CLIENT_TABS.MANAGE_USERS)} - /> -
+
+ setTab(CLIENT_TABS.MANAGE_USERS)} + /> +
)} -
+
newPasswordRepeat.length > 0 && newPasswordRepeat !== newPassword; const defaultState = { - oldPassword: '', - newPassword: '', - newPasswordRepeat: '', - success: false, + oldPassword: '', + newPassword: '', + newPasswordRepeat: '', + success: false, + show: false, }; -@connect(state => ({ - passwordErrors: state.getIn(['user', 'passwordErrors']), - loading: state.getIn(['user', 'updatePasswordRequest', 'loading']) -}), { - updatePassword -}) +@connect( + (state) => ({ + passwordErrors: state.getIn(['user', 'passwordErrors']), + loading: state.getIn(['user', 'updatePasswordRequest', 'loading']), + }), + { + updatePassword, + } +) export default class ChangePassword extends React.PureComponent { - state = defaultState + state = defaultState; - write = ({ target: { name, value } }) => { - this.setState({ - [ name ]: value, - }); - } - - handleSubmit = (e) => { - e.preventDefault(); - if (this.isSubmitDisabled()) return; - - const { oldPassword, newPassword } = this.state; - this.setState({ - success: false, - }); - - this.props.updatePassword({ - oldPassword, - newPassword, - }).then(() => { - if (this.props.passwordErrors.size === 0) { + write = ({ target: { name, value } }) => { this.setState({ - ...defaultState, - success: true, + [name]: value, }); - } - }); - } + }; - isSubmitDisabled() { - const { oldPassword, newPassword, newPasswordRepeat } = this.state; - if (newPassword !== newPasswordRepeat || - newPassword.length < MIN_LENGTH || - oldPassword.length < MIN_LENGTH) return true; - return false; - } + handleSubmit = (e) => { + e.preventDefault(); + if (this.isSubmitDisabled()) return; - render() { - const { - oldPassword, newPassword, newPasswordRepeat, success - } = this.state; - const { loading, passwordErrors } = this.props; + const { oldPassword, newPassword } = this.state; + this.setState({ + success: false, + }); - const doesntMatch = checkDoesntMatch(newPassword, newPasswordRepeat); - return ( -
- - - - - - - -
- { PASSWORD_POLICY } -
-
- - - - - { passwordErrors.map(err => ( - - { err } - - ))} - - - -
- ); - } + this.props + .updatePassword({ + oldPassword, + newPassword, + }) + .then(() => { + if (this.props.passwordErrors.size === 0) { + this.setState({ + ...defaultState, + success: true, + }); + } + }); + }; + + isSubmitDisabled() { + const { oldPassword, newPassword, newPasswordRepeat } = this.state; + if (newPassword !== newPasswordRepeat || newPassword.length < MIN_LENGTH || oldPassword.length < MIN_LENGTH) return true; + return false; + } + + render() { + const { oldPassword, newPassword, newPasswordRepeat, success, show } = this.state; + const { loading, passwordErrors } = this.props; + + const doesntMatch = checkDoesntMatch(newPassword, newPasswordRepeat); + return show ? ( +
+ + + + + + + +
{PASSWORD_POLICY}
+
+ + + + + {passwordErrors.map((err) => ( + {err} + ))} + +
+ + + +
+ +
+ ) : ( +
this.setState({ show: true })}> + +
+ ); + } } diff --git a/frontend/app/components/Client/ProfileSettings/ProfileSettings.js b/frontend/app/components/Client/ProfileSettings/ProfileSettings.js index 375e3ba8e..e8b508072 100644 --- a/frontend/app/components/Client/ProfileSettings/ProfileSettings.js +++ b/frontend/app/components/Client/ProfileSettings/ProfileSettings.js @@ -8,90 +8,105 @@ import TenantKey from './TenantKey'; import OptOut from './OptOut'; import Licenses from './Licenses'; import { connect } from 'react-redux'; +import { PageTitle } from 'UI'; @withPageTitle('Account - OpenReplay Preferences') -@connect(state => ({ - account: state.getIn([ 'user', 'account' ]), - isEnterprise: state.getIn([ 'user', 'account', 'edition' ]) === 'ee', +@connect((state) => ({ + account: state.getIn(['user', 'account']), + isEnterprise: state.getIn(['user', 'account', 'edition']) === 'ee', })) -export default class ProfileSettings extends React.PureComponent { - render() { - const { account, isEnterprise } = this.props; - return ( - -
-
-

{ 'Profile' }

-
{ 'Your email address is your identity on OpenReplay and is used to login.' }
-
-
-
+export default class ProfileSettings extends React.PureComponent { + render() { + const { account, isEnterprise } = this.props; + return ( +
+ Account
} /> +
+
+

{'Profile'}

+
{'Your email address is your identity on OpenReplay and is used to login.'}
+
+
+ +
+
-
+
- { account.hasPassword && ( - <> -
-
-

{ 'Change Password' }

-
{ 'Updating your password from time to time enhances your account’s security.' }
-
-
+ {account.hasPassword && ( + <> +
+
+

{'Change Password'}

+
{'Updating your password from time to time enhances your account’s security.'}
+
+
+ +
+
+ +
+ + )} + +
+
+

{'Organization API Key'}

+
{'Your API key gives you access to an extra set of services.'}
+
+
+ +
+
+ + {isEnterprise && ( + <> +
+
+
+

{'Tenant Key'}

+
{'For SSO (SAML) authentication.'}
+
+
+ +
+
+ + )} + + {!isEnterprise && ( + <> +
+
+
+

{'Data Collection'}

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

{'License'}

+
{'License key and expiration date.'}
+
+
+ +
+
+ + )}
- - -
- - )} - -
-
-

{ 'Organization API Key' }

-
{ 'Your API key gives you access to an extra set of services.' }
-
-
-
- - { isEnterprise && ( - <> -
-
-
-

{ 'Tenant Key' }

-
{ 'For SSO (SAML) authentication.' }
-
-
-
- - )} - - { !isEnterprise && ( - <> -
-
-
-

{ 'Data Collection' }

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

{ 'License' }

-
{ 'License key and expiration date.' }
-
-
-
- - )} - - ); - } + ); + } } diff --git a/frontend/app/components/Client/ProfileSettings/profileSettings.module.css b/frontend/app/components/Client/ProfileSettings/profileSettings.module.css index 30138ee59..536ee468d 100644 --- a/frontend/app/components/Client/ProfileSettings/profileSettings.module.css +++ b/frontend/app/components/Client/ProfileSettings/profileSettings.module.css @@ -4,7 +4,6 @@ width: 320px; & .info { color: $gray-medium; - font-weight: 300; } } diff --git a/frontend/app/components/Client/Roles/Roles.tsx b/frontend/app/components/Client/Roles/Roles.tsx index f9b9ef072..1c2939928 100644 --- a/frontend/app/components/Client/Roles/Roles.tsx +++ b/frontend/app/components/Client/Roles/Roles.tsx @@ -1,156 +1,145 @@ -import React, { useState, useEffect } from 'react' -import cn from 'classnames' -import { Loader, IconButton, Popup, NoContent, SlideModal } from 'UI' -import { connect } from 'react-redux' -import stl from './roles.module.css' -import RoleForm from './components/RoleForm' +import React, { useEffect } from 'react'; +import cn from 'classnames'; +import { Loader, Popup, NoContent, Button } from 'UI'; +import { connect } from 'react-redux'; +import stl from './roles.module.css'; +import RoleForm from './components/RoleForm'; import { init, edit, fetchList, remove as deleteRole, resetErrors } from 'Duck/roles'; -import RoleItem from './components/RoleItem' +import RoleItem from './components/RoleItem'; import { confirm } from 'UI'; import { toast } from 'react-toastify'; import withPageTitle from 'HOCs/withPageTitle'; +import { useModal } from 'App/components/Modal'; interface Props { - loading: boolean - init: (role?: any) => void, - edit: (role: any) => void, - instance: any, - roles: any[], - deleteRole: (id: any) => Promise, - fetchList: () => Promise, - account: any, - permissionsMap: any, - removeErrors: any, - resetErrors: () => void, - projectsMap: any, + loading: boolean; + init: (role?: any) => void; + edit: (role: any) => void; + instance: any; + roles: any[]; + deleteRole: (id: any) => Promise; + fetchList: () => Promise; + account: any; + permissionsMap: any; + removeErrors: any; + resetErrors: () => void; + projectsMap: any; } function Roles(props: Props) { - const { loading, instance, roles, init, edit, deleteRole, account, permissionsMap, projectsMap, removeErrors } = props - const [showModal, setShowmModal] = useState(false) - const isAdmin = account.admin || account.superAdmin; + const { loading, instance, roles, init, edit, deleteRole, account, permissionsMap, projectsMap, removeErrors } = props; + // const [showModal, setShowmModal] = useState(false); + const { showModal, hideModal } = useModal(); + const isAdmin = account.admin || account.superAdmin; - useEffect(() => { - props.fetchList() - }, []) + useEffect(() => { + props.fetchList(); + }, []); - useEffect(() => { - if (removeErrors && removeErrors.size > 0) { - removeErrors.forEach(e => { - toast.error(e) - }) - } - return () => { - props.resetErrors() - } - }, [removeErrors]) + useEffect(() => { + if (removeErrors && removeErrors.size > 0) { + removeErrors.forEach((e) => { + toast.error(e); + }); + } + return () => { + props.resetErrors(); + }; + }, [removeErrors]); - const closeModal = (showToastMessage) => { - if (showToastMessage) { - toast.success(showToastMessage) - props.fetchList() - } - setShowmModal(false) - setTimeout(() => { - init() - }, 100) - } + const closeModal = (showToastMessage) => { + if (showToastMessage) { + toast.success(showToastMessage); + props.fetchList(); + } + setShowmModal(false); + setTimeout(() => { + init(); + }, 100); + }; - const editHandler = role => { - init(role) - setShowmModal(true) - } + const editHandler = (role: any) => { + init(role); + showModal(, { right: true }); + // setShowmModal(true); + }; - const deleteHandler = async (role) => { - if (await confirm({ - header: 'Roles', - confirmation: `Are you sure you want to remove this role?` - })) { - deleteRole(role.roleId) - } - } + const deleteHandler = async (role: any) => { + if ( + await confirm({ + header: 'Roles', + confirmation: `Are you sure you want to remove this role?`, + }) + ) { + deleteRole(role.roleId); + } + }; - return ( - - - } - onClose={ closeModal } - /> -
-
-
-

Roles and Access

- -
- setShowmModal(true) } - /> + return ( + + +
+
+
+

Roles and Access

+ + + +
+
+ + +
+
+
+ Title +
+
+ Project Access +
+
+ Feature Access +
+
+
+ {roles.map((role) => ( + + ))} +
+
- -
-
- - -
-
-
Title
-
Project Access
-
Feature Access
-
-
- {roles.map(role => ( - - ))} -
-
-
- - - ) + + + ); } -export default connect(state => { - const permissions = state.getIn(['roles', 'permissions']) - const permissionsMap = {} - permissions.forEach(p => { - permissionsMap[p.value] = p.text - }); - const projects = state.getIn([ 'site', 'list' ]) - return { - instance: state.getIn(['roles', 'instance']) || null, - permissionsMap: permissionsMap, - roles: state.getIn(['roles', 'list']), - removeErrors: state.getIn(['roles', 'removeRequest', 'errors']), - loading: state.getIn(['roles', 'fetchRequest', 'loading']), - account: state.getIn([ 'user', 'account' ]), - projectsMap: projects.reduce((acc, p) => { - acc[ p.get('id') ] = p.get('name') - return acc - } - , {}), - } -}, { init, edit, fetchList, deleteRole, resetErrors })(withPageTitle('Roles & Access - OpenReplay Preferences')(Roles)) \ No newline at end of file +export default connect( + (state: any) => { + const permissions = state.getIn(['roles', 'permissions']); + const permissionsMap = {}; + permissions.forEach((p: any) => { + permissionsMap[p.value] = p.text; + }); + const projects = state.getIn(['site', 'list']); + return { + instance: state.getIn(['roles', 'instance']) || null, + permissionsMap: permissionsMap, + roles: state.getIn(['roles', 'list']), + removeErrors: state.getIn(['roles', 'removeRequest', 'errors']), + loading: state.getIn(['roles', 'fetchRequest', 'loading']), + account: state.getIn(['user', 'account']), + projectsMap: projects.reduce((acc: any, p: any) => { + acc[p.get('id')] = p.get('name'); + return acc; + }, {}), + }; + }, + { init, edit, fetchList, deleteRole, resetErrors } +)(withPageTitle('Roles & Access - OpenReplay Preferences')(Roles)); diff --git a/frontend/app/components/Client/Roles/components/RoleForm/RoleForm.tsx b/frontend/app/components/Client/Roles/components/RoleForm/RoleForm.tsx index 7aed70131..93a320d54 100644 --- a/frontend/app/components/Client/Roles/components/RoleForm/RoleForm.tsx +++ b/frontend/app/components/Client/Roles/components/RoleForm/RoleForm.tsx @@ -1,203 +1,195 @@ -import React, { useRef, useEffect } from 'react' -import { connect } from 'react-redux' -import stl from './roleForm.module.css' -import { save, edit } from 'Duck/roles' -import { Form, Input, Button, Checkbox, Icon } from 'UI' +import React, { useRef, useEffect } from 'react'; +import { connect } from 'react-redux'; +import stl from './roleForm.module.css'; +import { save, edit } from 'Duck/roles'; +import { Form, Input, Button, Checkbox, Icon } from 'UI'; import Select from 'Shared/Select'; interface Permission { - name: string, - value: string + name: string; + value: string; } interface Props { - role: any, - edit: (role: any) => void, - save: (role: any) => Promise, - closeModal: (toastMessage?: string) => void, - saving: boolean, - permissions: Array[] - projectOptions: Array[], - permissionsMap: any, - projectsMap: any, - deleteHandler: (id: any) => Promise, + role: any; + edit: (role: any) => void; + save: (role: any) => Promise; + closeModal: (toastMessage?: string) => void; + saving: boolean; + permissions: Array[]; + projectOptions: Array[]; + permissionsMap: any; + projectsMap: any; + deleteHandler: (id: any) => Promise; } const RoleForm = (props: Props) => { - const { role, edit, save, closeModal, saving, permissions, projectOptions, permissionsMap, projectsMap } = props - let focusElement = useRef(null) - const _save = () => { - save(role).then(() => { - closeModal(role.exists() ? "Role updated" : "Role created"); - }) - } + const { role, edit, save, closeModal, saving, permissions, projectOptions, permissionsMap, projectsMap } = props; + let focusElement = useRef(null); + const _save = () => { + save(role).then(() => { + closeModal(role.exists() ? 'Role updated' : 'Role created'); + }); + }; - const write = ({ target: { value, name } }) => edit({ [ name ]: value }) + const write = ({ target: { value, name } }) => edit({ [name]: value }); - const onChangePermissions = (e) => { - const { permissions } = role - const index = permissions.indexOf(e) - const _perms = permissions.contains(e) ? permissions.remove(index) : permissions.push(e) - edit({ permissions: _perms }) - } + const onChangePermissions = (e) => { + const { permissions } = role; + const index = permissions.indexOf(e); + const _perms = permissions.contains(e) ? permissions.remove(index) : permissions.push(e); + edit({ permissions: _perms }); + }; - const onChangeProjects = (e) => { - const { projects } = role - const index = projects.indexOf(e) - const _projects = index === -1 ? projects.push(e) : projects.remove(index) - edit({ projects: _projects }) - } + const onChangeProjects = (e) => { + const { projects } = role; + const index = projects.indexOf(e); + const _projects = index === -1 ? projects.push(e) : projects.remove(index); + edit({ projects: _projects }); + }; - const writeOption = ({ name, value }: any) => { - if (name === 'permissions') { - onChangePermissions(value) - } else if (name === 'projects') { - onChangeProjects(value) - } - } + const writeOption = ({ name, value }: any) => { + if (name === 'permissions') { + onChangePermissions(value); + } else if (name === 'projects') { + onChangeProjects(value); + } + }; - const toggleAllProjects = () => { - const { allProjects } = role - edit({ allProjects: !allProjects }) - } + const toggleAllProjects = () => { + const { allProjects } = role; + edit({ allProjects: !allProjects }); + }; - useEffect(() => { - focusElement && focusElement.current && focusElement.current.focus() - }, []) + useEffect(() => { + focusElement && focusElement.current && focusElement.current.focus(); + }, []); - return ( -
-
- - - - + return ( +
+

{role.exists() ? 'Edit Role' : 'Create Role'}

+
+ + + + + - - + + -
- -
-
All Projects
- - (Uncheck to select specific projects) - -
-
- { !role.allProjects && ( - <> - writeOption({ name: 'projects', value: value.value })} + value={null} + /> + {role.projects.size > 0 && ( +
+ {role.projects.map((p) => OptionLabel(projectsMap, p, onChangeProjects))} +
+ )} + + )} +
+ + + + writeOption({ name: 'permissions', value: value.value }) } - value={null} - /> - { role.permissions.size > 0 && ( -
- { role.permissions.map(p => ( - OptionLabel(permissionsMap, p, onChangePermissions) - )) }
- )} -
- - -
-
- - { role.exists() && ( - - )}
- { role.exists() && ( - - )} -
-
- ); -} + ); +}; -export default connect((state: any) => { - const role = state.getIn(['roles', 'instance']) - const projects = state.getIn([ 'site', 'list' ]) - return { - role, - projectOptions: projects.map((p: any) => ({ - key: p.get('id'), - value: p.get('id'), - label: p.get('name'), - // isDisabled: role.projects.includes(p.get('id')), - })).filter(({ value }: any) => !role.projects.includes(value)).toJS(), - permissions: state.getIn(['roles', 'permissions']).filter(({ value }: any) => !role.permissions.includes(value)) - .map(({ text, value }: any) => ({ label: text, value })).toJS(), - saving: state.getIn([ 'roles', 'saveRequest', 'loading' ]), - projectsMap: projects.reduce((acc: any, p: any) => { - acc[ p.get('id') ] = p.get('name') - return acc - } - , {}), - } -}, { edit, save })(RoleForm); +export default connect( + (state: any) => { + const role = state.getIn(['roles', 'instance']); + const projects = state.getIn(['site', 'list']); + return { + role, + projectOptions: projects + .map((p: any) => ({ + key: p.get('id'), + value: p.get('id'), + label: p.get('name'), + // isDisabled: role.projects.includes(p.get('id')), + })) + .filter(({ value }: any) => !role.projects.includes(value)) + .toJS(), + permissions: state + .getIn(['roles', 'permissions']) + .filter(({ value }: any) => !role.permissions.includes(value)) + .map(({ text, value }: any) => ({ label: text, value })) + .toJS(), + saving: state.getIn(['roles', 'saveRequest', 'loading']), + projectsMap: projects.reduce((acc: any, p: any) => { + acc[p.get('id')] = p.get('name'); + return acc; + }, {}), + }; + }, + { edit, save } +)(RoleForm); function OptionLabel(nameMap: any, p: any, onChangeOption: (e: any) => void) { - return
-
{nameMap[p]}
-
onChangeOption(p)}> - -
-
+ return ( +
+
{nameMap[p]}
+
onChangeOption(p)}> + +
+
+ ); } diff --git a/frontend/app/components/Client/Roles/components/RoleItem/RoleItem.tsx b/frontend/app/components/Client/Roles/components/RoleItem/RoleItem.tsx index 845811f77..b3d76b20e 100644 --- a/frontend/app/components/Client/Roles/components/RoleItem/RoleItem.tsx +++ b/frontend/app/components/Client/Roles/components/RoleItem/RoleItem.tsx @@ -1,64 +1,58 @@ -import React from 'react' -import { Icon, Link } from 'UI' -import stl from './roleItem.module.css' -import cn from 'classnames' +import React from 'react'; +import { Icon, Link, Button } from 'UI'; +import stl from './roleItem.module.css'; +import cn from 'classnames'; import { CLIENT_TABS, client as clientRoute } from 'App/routes'; - function PermisionLabel({ label }: any) { - return ( -
{ label }
- ); + return
{label}
; } function PermisionLabelLinked({ label, route }: any) { - return ( -
{ label }
- ); + return ( + +
{label}
+ + ); } interface Props { - role: any, - deleteHandler?: (role: any) => void, - editHandler?: (role: any) => void, - permissions: any, - isAdmin: boolean, - projects: any, + role: any; + deleteHandler?: (role: any) => void; + editHandler?: (role: any) => void; + permissions: any; + isAdmin: boolean; + projects: any; } function RoleItem({ role, deleteHandler, editHandler, isAdmin, permissions, projects }: Props) { - return ( -
-
- - { role.name } -
-
- {role.allProjects ? ( - - ) : ( - role.projects.map(p => ( - - )) - )} -
-
-
- {role.permissions.map((permission: any) => ( - - ))} -
- -
- {isAdmin && !!editHandler && -
editHandler(role) }> - + return ( +
+
+ + {role.name} +
+
+ {role.allProjects ? ( + + ) : ( + role.projects.map((p) => ) + )} +
+
+
+ {role.permissions.map((permission: any) => ( + + ))} +
+ +
+ {isAdmin && !!editHandler && ( +
- }
-
- -
- ); + ); } -export default RoleItem; \ No newline at end of file +export default RoleItem; diff --git a/frontend/app/components/Client/Sites/AddProjectButton/AddUserButton.tsx b/frontend/app/components/Client/Sites/AddProjectButton/AddUserButton.tsx index 7371056fd..5934dfe52 100644 --- a/frontend/app/components/Client/Sites/AddProjectButton/AddUserButton.tsx +++ b/frontend/app/components/Client/Sites/AddProjectButton/AddUserButton.tsx @@ -2,27 +2,29 @@ import React from 'react'; import { Popup, Button, IconButton } from 'UI'; import { useStore } from 'App/mstore'; import { useObserver } from 'mobx-react-lite'; +import { init, remove, fetchGDPR } from 'Duck/site'; +import { connect } from 'react-redux'; +import { useModal } from 'App/components/Modal'; +import NewSiteForm from '../NewSiteForm'; const PERMISSION_WARNING = 'You don’t have the permissions to perform this action.'; const LIMIT_WARNING = 'You have reached site limit.'; -function AddProjectButton({ isAdmin = false, onClick }: any) { +function AddProjectButton({ isAdmin = false, init = () => {} }: any) { const { userStore } = useStore(); + const { showModal, hideModal } = useModal(); const limtis = useObserver(() => userStore.limits); const canAddProject = useObserver(() => isAdmin && (limtis.projects === -1 || limtis.projects > 0)); + + const onClick = () => { + init(); + showModal(, { right: true }); + }; return ( - - {/* */} + ); } -export default AddProjectButton; +export default connect(null, { init, remove, fetchGDPR })(AddProjectButton); diff --git a/frontend/app/components/Client/Sites/InstallButton/InstallButton.tsx b/frontend/app/components/Client/Sites/InstallButton/InstallButton.tsx new file mode 100644 index 000000000..0fe5fce65 --- /dev/null +++ b/frontend/app/components/Client/Sites/InstallButton/InstallButton.tsx @@ -0,0 +1,25 @@ +import { useModal } from 'App/components/Modal'; +import React from 'react'; +import TrackingCodeModal from 'Shared/TrackingCodeModal'; +import { Button } from 'UI'; + +interface Props { + site: any; +} +function InstallButton(props: Props) { + const { site } = props; + const { showModal, hideModal } = useModal(); + const onClick = () => { + showModal( + , + { right: true } + ); + }; + return ( + + ); +} + +export default InstallButton; diff --git a/frontend/app/components/Client/Sites/InstallButton/index.ts b/frontend/app/components/Client/Sites/InstallButton/index.ts new file mode 100644 index 000000000..c64b2ff6c --- /dev/null +++ b/frontend/app/components/Client/Sites/InstallButton/index.ts @@ -0,0 +1 @@ +export { default } from './InstallButton' \ No newline at end of file diff --git a/frontend/app/components/Client/Sites/NewSiteForm.js b/frontend/app/components/Client/Sites/NewSiteForm.js index c6633b73b..0a9dc81c7 100644 --- a/frontend/app/components/Client/Sites/NewSiteForm.js +++ b/frontend/app/components/Client/Sites/NewSiteForm.js @@ -1,121 +1,122 @@ import React from 'react'; import { connect } from 'react-redux'; import { Form, Input, Button, Icon } from 'UI'; -import { save, edit, update , fetchList, remove } from 'Duck/site'; +import { save, edit, update, fetchList, remove } from 'Duck/site'; import { pushNewSite } from 'Duck/user'; import { setSiteId } from 'Duck/site'; import { withRouter } from 'react-router-dom'; import styles from './siteForm.module.css'; import { confirm } from 'UI'; -@connect(state => ({ - site: state.getIn([ 'site', 'instance' ]), - sites: state.getIn([ 'site', 'list' ]), - siteList: state.getIn([ 'site', 'list' ]), - loading: state.getIn([ 'site', 'save', 'loading' ]) || state.getIn([ 'site', 'remove', 'loading' ]), -}), { - save, - remove, - edit, - update, - pushNewSite, - fetchList, - setSiteId -}) +@connect( + (state) => ({ + site: state.getIn(['site', 'instance']), + sites: state.getIn(['site', 'list']), + siteList: state.getIn(['site', 'list']), + loading: state.getIn(['site', 'save', 'loading']) || state.getIn(['site', 'remove', 'loading']), + }), + { + save, + remove, + edit, + update, + pushNewSite, + fetchList, + setSiteId, + } +) @withRouter export default class NewSiteForm extends React.PureComponent { - state = { - existsError: false, - } + state = { + existsError: false, + }; - componentDidMount() { - const { location: { pathname }, match: { params: { siteId } } } = this.props; - if (pathname.includes('onboarding')) { - this.props.setSiteId(siteId); - } - } + componentDidMount() { + const { + location: { pathname }, + match: { + params: { siteId }, + }, + } = this.props; + if (pathname.includes('onboarding')) { + this.props.setSiteId(siteId); + } + } - onSubmit = e => { - e.preventDefault(); - const { site, siteList, location: { pathname } } = this.props; - if (!site.exists() && siteList.some(({ name }) => name === site.name)) { - return this.setState({ existsError: true }); - } - if (site.exists()) { - this.props.update(this.props.site, this.props.site.id).then(() => { - this.props.onClose(null) - this.props.fetchList(); - }) - } else { - this.props.save(this.props.site).then(() => { - this.props.fetchList().then(() => { - const { sites } = this.props; - const site = sites.last(); - if (!pathname.includes('/client')) { - this.props.setSiteId(site.get('id')) - } - this.props.onClose(null, site) - }) - - // this.props.pushNewSite(site) - }); - } - } + onSubmit = (e) => { + e.preventDefault(); + const { + site, + siteList, + location: { pathname }, + } = this.props; + if (!site.exists() && siteList.some(({ name }) => name === site.name)) { + return this.setState({ existsError: true }); + } + if (site.exists()) { + this.props.update(this.props.site, this.props.site.id).then(() => { + this.props.onClose(null); + this.props.fetchList(); + }); + } else { + this.props.save(this.props.site).then(() => { + this.props.fetchList().then(() => { + const { sites } = this.props; + const site = sites.last(); + if (!pathname.includes('/client')) { + this.props.setSiteId(site.get('id')); + } + this.props.onClose(null, site); + }); - remove = async (site) => { - if (await confirm({ - header: 'Projects', - confirmation: `Are you sure you want to delete this Project? We won't be able to record anymore sessions.` - })) { - this.props.remove(site.id).then(() => { - this.props.onClose(null) - }); - } - }; + // this.props.pushNewSite(site) + }); + } + }; - edit = ({ target: { name, value } }) => { - this.setState({ existsError: false }); - this.props.edit({ [ name ]: value }); - } + remove = async (site) => { + if ( + await confirm({ + header: 'Projects', + confirmation: `Are you sure you want to delete this Project? We won't be able to record anymore sessions.`, + }) + ) { + this.props.remove(site.id).then(() => { + this.props.onClose(null); + }); + } + }; - render() { - const { site, loading } = this.props; - return ( -
-
- - - - -
- - {site.exists() && ( - - )} -
- { this.state.existsError && -
- { "Site exists already. Please choose another one." } -
- } -
-
- ); - } -} \ No newline at end of file + edit = ({ target: { name, value } }) => { + this.setState({ existsError: false }); + this.props.edit({ [name]: value }); + }; + + render() { + const { site, loading } = this.props; + return ( +
+

{site.exists() ? 'Edit Project' : 'New Project'}

+
+
+ + + + +
+ + {site.exists() && ( + + )} +
+ {this.state.existsError &&
{'Site exists already. Please choose another one.'}
} +
+
+
+ ); + } +} diff --git a/frontend/app/components/Client/Sites/ProjectKey.tsx b/frontend/app/components/Client/Sites/ProjectKey.tsx new file mode 100644 index 000000000..d53b336f8 --- /dev/null +++ b/frontend/app/components/Client/Sites/ProjectKey.tsx @@ -0,0 +1,8 @@ +import { withCopy } from 'HOCs'; +import React from 'react'; + +function ProjectKey({ value, tooltip }: any) { + return
{value}
; +} + +export default withCopy(ProjectKey); diff --git a/frontend/app/components/Client/Sites/SiteSearch/SiteSearch.tsx b/frontend/app/components/Client/Sites/SiteSearch/SiteSearch.tsx index ed91ed25d..9ff6aa454 100644 --- a/frontend/app/components/Client/Sites/SiteSearch/SiteSearch.tsx +++ b/frontend/app/components/Client/Sites/SiteSearch/SiteSearch.tsx @@ -24,7 +24,7 @@ function SiteSearch(props: Props) { // value={query} name="searchQuery" // className="bg-white p-2 border border-gray-light rounded w-full pl-10" - placeholder="Filter by Name" + placeholder="Filter by name" onChange={write} icon="search" /> diff --git a/frontend/app/components/Client/Sites/Sites.js b/frontend/app/components/Client/Sites/Sites.js index 1c96c0b3c..27e22f638 100644 --- a/frontend/app/components/Client/Sites/Sites.js +++ b/frontend/app/components/Client/Sites/Sites.js @@ -1,18 +1,20 @@ import React from 'react'; import { connect } from 'react-redux'; -import cn from 'classnames'; import withPageTitle from 'HOCs/withPageTitle'; -import { Loader, SlideModal, Icon, Button, Popup, TextLink } from 'UI'; +import { Loader, Button, Popup, TextLink, NoContent } from 'UI'; import { init, remove, fetchGDPR } from 'Duck/site'; import { RED, YELLOW, GREEN, STATUS_COLOR_MAP } from 'Types/site'; import stl from './sites.module.css'; import NewSiteForm from './NewSiteForm'; -import GDPRForm from './GDPRForm'; -import TrackingCodeModal from 'Shared/TrackingCodeModal'; -import BlockedIps from './BlockedIps'; import { confirm, PageTitle } from 'UI'; import SiteSearch from './SiteSearch'; import AddProjectButton from './AddProjectButton'; +import InstallButton from './InstallButton'; +import ProjectKey from './ProjectKey'; +import { useModal } from 'App/components/Modal'; +import { getInitials } from 'App/utils'; +import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG'; +import cn from 'classnames' const STATUS_MESSAGE_MAP = { [RED]: ' There seems to be an issue (please verify your installation)', @@ -20,11 +22,7 @@ const STATUS_MESSAGE_MAP = { [GREEN]: 'All good!', }; -const BLOCKED_IPS = 'BLOCKED_IPS'; -const NONE = 'NONE'; - const NEW_SITE_FORM = 'NEW_SITE_FORM'; -const GDPR_FORM = 'GDPR_FORM'; @connect( (state) => ({ @@ -43,20 +41,9 @@ const GDPR_FORM = 'GDPR_FORM'; @withPageTitle('Projects - OpenReplay Preferences') class Sites extends React.PureComponent { state = { - showTrackingCode: false, - modalContent: NONE, - detailContent: NONE, searchQuery: '', }; - toggleBlockedIp = () => { - this.setState({ - detailContent: this.state.detailContent === BLOCKED_IPS ? NONE : BLOCKED_IPS, - }); - }; - - closeModal = () => this.setState({ modalContent: NONE, detailContent: NONE }); - edit = (site) => { this.props.init(site); this.setState({ modalContent: NEW_SITE_FORM }); @@ -73,132 +60,75 @@ class Sites extends React.PureComponent { } }; - showGDPRForm = (site) => { - this.props.init(site); - this.setState({ modalContent: GDPR_FORM }); - }; - - showNewSiteForm = () => { - this.props.init(); - this.setState({ modalContent: NEW_SITE_FORM }); - }; - - showTrackingCode = (site) => { - this.props.init(site); - this.setState({ showTrackingCode: true }); - }; - - getModalTitle() { - switch (this.state.modalContent) { - case NEW_SITE_FORM: - return this.props.site.exists() ? 'Update Project' : 'New Project'; - case GDPR_FORM: - return 'Project Settings'; - default: - return ''; - } - } - - renderModalContent() { - switch (this.state.modalContent) { - case NEW_SITE_FORM: - return ; - case GDPR_FORM: - return ; - default: - return null; - } - } - - renderModalDetailContent() { - switch (this.state.detailContent) { - case BLOCKED_IPS: - return ; - default: - return null; - } - } - render() { - const { loading, sites, site, user, account } = this.props; - const { modalContent, showTrackingCode } = this.state; + const { loading, sites, user } = this.props; const isAdmin = user.admin || user.superAdmin; const filteredSites = sites.filter((site) => site.name.toLowerCase().includes(this.state.searchQuery.toLowerCase())); return ( - this.setState({ showTrackingCode: false })} - site={site} - /> -
-
- Projects
} - actionButton={} - /> +
+ Projects
} actionButton={} />
- + +
this.setState({ searchQuery: value })} />
-
-
Name
+ + +
No matching results.
+
+ } + size="small" + show={!loading && filteredSites.size === 0} + > +
+
Project Name
Key
{filteredSites.map((_site) => (
- -
- + +
+
+
+ {getInitials(_site.name)} +
{_site.host}
- {_site.projectKey} +
- +
- + this.props.init(_site)} />
))} +
@@ -207,3 +137,12 @@ class Sites extends React.PureComponent { } export default Sites; + +function EditButton({ isAdmin, onClick }) { + const { showModal, hideModal } = useModal(); + const _onClick = () => { + onClick(); + showModal(); + }; + return + {/* + /> */} ); } diff --git a/frontend/app/components/Client/Users/components/UserList/UserList.tsx b/frontend/app/components/Client/Users/components/UserList/UserList.tsx index a86e6d7d7..f18e3586c 100644 --- a/frontend/app/components/Client/Users/components/UserList/UserList.tsx +++ b/frontend/app/components/Client/Users/components/UserList/UserList.tsx @@ -13,7 +13,7 @@ interface Props { isEnterprise?: boolean; } function UserList(props: Props) { - const { isEnterprise = false, isOnboarding = false } = props; + const { isEnterprise = false, isOnboarding = false } = props; const { userStore } = useStore(); const loading = useObserver(() => userStore.loading); const users = useObserver(() => userStore.list); @@ -22,41 +22,42 @@ function UserList(props: Props) { const filterList = (list) => { const filterRE = getRE(searchQuery, 'i'); - let _list = list.filter(w => { + let _list = list.filter((w) => { return filterRE.test(w.email) || filterRE.test(w.roleName); }); - return _list - } - + return _list; + }; + const list: any = searchQuery !== '' ? filterList(users) : users; const length = list.length; - + useEffect(() => { userStore.fetchUsers(); }, []); - const editHandler = (user) => { + const editHandler = (user: any) => { userStore.initUser(user).then(() => { - showModal(, { }); + showModal(, { right: true }); }); - } + }; return useObserver(() => ( - -
No data available.
+ +
No matching results.
} + size="small" + show={!loading && length === 0} >
-
+
Name
Role
- {!isOnboarding &&
Created On
} + {!isOnboarding &&
Created On
}
@@ -65,8 +66,14 @@ function UserList(props: Props) { editHandler(user)} - generateInvite={() => userStore.generateInviteCode(user.userId)} - copyInviteCode={() => userStore.copyInviteCode(user.userId)} + generateInvite={(e: any) => { + e.stopPropagation(); + userStore.generateInviteCode(user.userId); + }} + copyInviteCode={(e) => { + e.stopPropagation(); + userStore.copyInviteCode(user.userId); + }} isEnterprise={isEnterprise} isOnboarding={isOnboarding} /> @@ -88,4 +95,4 @@ function UserList(props: Props) { )); } -export default UserList; \ No newline at end of file +export default UserList; diff --git a/frontend/app/components/Client/Users/components/UserListItem/UserListItem.tsx b/frontend/app/components/Client/Users/components/UserListItem/UserListItem.tsx index d7b7d0d55..1611b6cf2 100644 --- a/frontend/app/components/Client/Users/components/UserListItem/UserListItem.tsx +++ b/frontend/app/components/Client/Users/components/UserListItem/UserListItem.tsx @@ -1,6 +1,6 @@ //@ts-nocheck import React from 'react'; -import { Icon, Popup } from 'UI'; +import { Button, Popup } from 'UI'; import { checkForRecent } from 'App/date'; import cn from 'classnames'; @@ -9,9 +9,10 @@ const AdminPrivilegeLabel = ({ user }) => { <> {user.isAdmin && Admin} {user.isSuperAdmin && Owner} + {!user.isAdmin && !user.isSuperAdmin && Member} - ) -} + ); +}; interface Props { isOnboarding?: boolean; user: any; @@ -21,27 +22,16 @@ interface Props { isEnterprise?: boolean; } function UserListItem(props: Props) { - const { - user, - editHandler = () => {}, - generateInvite = () => {}, - copyInviteCode = () => {}, - isEnterprise = false, - isOnboarding = false - } = props; + const { user, editHandler = () => {}, generateInvite = () => {}, copyInviteCode = () => {}, isEnterprise = false, isOnboarding = false } = props; return ( -
+
{user.name} {isEnterprise && }
{!isEnterprise && } - {isEnterprise && ( - - {user.roleName} - - )} + {isEnterprise && {user.roleName}}
{!isOnboarding && (
@@ -49,41 +39,26 @@ function UserListItem(props: Props) {
)} -
+
{!user.isJoined && user.invitationLink && !user.isExpiredInvite && ( - - + + + +
- +
); } -export default UserListItem; \ No newline at end of file +export default UserListItem; diff --git a/frontend/app/components/Client/Users/components/UserSearch/UserSearch.tsx b/frontend/app/components/Client/Users/components/UserSearch/UserSearch.tsx index d4b439000..71a057a65 100644 --- a/frontend/app/components/Client/Users/components/UserSearch/UserSearch.tsx +++ b/frontend/app/components/Client/Users/components/UserSearch/UserSearch.tsx @@ -25,7 +25,7 @@ function UserSearch(props) { value={query} name="searchQuery" // className="bg-white p-2 border border-gray-light rounded w-full pl-10" - placeholder="Filter by Name, Role" + placeholder="Filter by name, role" onChange={write} icon="search" /> diff --git a/frontend/app/components/Client/Webhooks/ListItem.js b/frontend/app/components/Client/Webhooks/ListItem.js index c493cc176..3e177d47e 100644 --- a/frontend/app/components/Client/Webhooks/ListItem.js +++ b/frontend/app/components/Client/Webhooks/ListItem.js @@ -1,24 +1,18 @@ import React from 'react'; -import { Icon } from 'UI'; -import styles from './listItem.module.css'; +import { Button } from 'UI'; const ListItem = ({ webhook, onEdit, onDelete }) => { - return ( -
-
- { webhook.name } -
{ webhook.endpoint }
-
-
-
{ e.stopPropagation(); onDelete(webhook) } }> - + return ( +
+
+ {webhook.name} +
{webhook.endpoint}
+
+
+
-
- -
-
-
- ); + ); }; -export default ListItem; \ No newline at end of file +export default ListItem; diff --git a/frontend/app/components/Client/Webhooks/WebhookForm.js b/frontend/app/components/Client/Webhooks/WebhookForm.js index 6ea5737ea..b64a63af8 100644 --- a/frontend/app/components/Client/Webhooks/WebhookForm.js +++ b/frontend/app/components/Client/Webhooks/WebhookForm.js @@ -4,80 +4,91 @@ import { edit, save } from 'Duck/webhook'; import { Form, Button, Input } from 'UI'; import styles from './webhookForm.module.css'; -@connect(state => ({ - webhook: state.getIn(['webhooks', 'instance']), - loading: state.getIn(['webhooks', 'saveRequest', 'loading']), -}), { - edit, - save, -}) +@connect( + (state) => ({ + webhook: state.getIn(['webhooks', 'instance']), + loading: state.getIn(['webhooks', 'saveRequest', 'loading']), + }), + { + edit, + save, + } +) class WebhookForm extends React.PureComponent { - setFocus = () => this.focusElement.focus(); - onChangeSelect = (event, { name, value }) => this.props.edit({ [ name ]: value }); - write = ({ target: { value, name } }) => this.props.edit({ [ name ]: value }); + setFocus = () => this.focusElement.focus(); + onChangeSelect = (event, { name, value }) => this.props.edit({ [name]: value }); + write = ({ target: { value, name } }) => this.props.edit({ [name]: value }); - save = () => { - this.props.save(this.props.webhook).then(() => { - this.props.onClose(); - }); - }; + save = () => { + this.props.save(this.props.webhook).then(() => { + this.props.onClose(); + }); + }; - render() { - const { webhook, loading } = this.props; - return ( -
- - - { this.focusElement = ref; } } - name="name" - value={ webhook.name } - onChange={ this.write } - placeholder="Name" - /> - + render() { + const { webhook, loading } = this.props; + return ( +
+

{webhook.exists() ? 'Update' : 'Add'} Webhook

+ + + + { + this.focusElement = ref; + }} + name="name" + value={webhook.name} + onChange={this.write} + placeholder="Name" + /> + - - - { this.focusElement = ref; } } - name="endpoint" - value={ webhook.endpoint } - onChange={ this.write } - placeholder="Endpoint" - /> - + + + { + this.focusElement = ref; + }} + name="endpoint" + value={webhook.endpoint} + onChange={this.write} + placeholder="Endpoint" + /> + - - - { this.focusElement = ref; } } - name="authHeader" - value={ webhook.authHeader } - onChange={ this.write } - placeholder="Auth Header" - /> - + + + { + this.focusElement = ref; + }} + name="authHeader" + value={webhook.authHeader} + onChange={this.write} + placeholder="Auth Header" + /> + - - { webhook.exists() && ( - - )} - - ); - } +
+
+ + {webhook.exists() && } +
+ {webhook.exists() && } +
+ +
+ ); + } } export default WebhookForm; diff --git a/frontend/app/components/Client/Webhooks/Webhooks.js b/frontend/app/components/Client/Webhooks/Webhooks.js index eb5306aa6..4235cd28b 100644 --- a/frontend/app/components/Client/Webhooks/Webhooks.js +++ b/frontend/app/components/Client/Webhooks/Webhooks.js @@ -1,8 +1,8 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { connect } from 'react-redux'; import cn from 'classnames'; import withPageTitle from 'HOCs/withPageTitle'; -import { IconButton, SlideModal, Loader, NoContent } from 'UI'; +import { Button, Loader, NoContent, Icon } from 'UI'; import { init, fetchList, remove } from 'Duck/webhook'; import WebhookForm from './WebhookForm'; import ListItem from './ListItem'; @@ -10,87 +10,80 @@ import styles from './webhooks.module.css'; import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG'; import { confirm } from 'UI'; import { toast } from 'react-toastify'; +import { useModal } from 'App/components/Modal'; -@connect(state => ({ - webhooks: state.getIn(['webhooks', 'list']), - loading: state.getIn(['webhooks', 'loading']), -}), { - init, - fetchList, - remove, -}) -@withPageTitle('Webhooks - OpenReplay Preferences') -class Webhooks extends React.PureComponent { - state = { showModal: false }; +function Webhooks(props) { + const { webhooks, loading } = props; + const { showModal, hideModal } = useModal(); - componentWillMount() { - this.props.fetchList(); - } + const noSlackWebhooks = webhooks.filter((hook) => hook.type !== 'slack'); + useEffect(() => { + props.fetchList(); + }, []); - closeModal = () => this.setState({ showModal: false }); - init = (v) => { - this.props.init(v); - this.setState({ showModal: true }); - } + const init = (v) => { + props.init(v); + showModal(); + }; - removeWebhook = async (id) => { - if (await confirm({ - header: 'Confirm', - confirmButton: 'Yes, delete', - confirmation: `Are you sure you want to remove this webhook?` - })) { - this.props.remove(id).then(() => { + const removeWebhook = async (id) => { + if ( + await confirm({ + header: 'Confirm', + confirmButton: 'Yes, delete', + confirmation: `Are you sure you want to remove this webhook?`, + }) + ) { + props.remove(id).then(() => { toast.success('Webhook removed successfully'); }); + hideModal(); } - } + }; - render() { - const { webhooks, loading } = this.props; - const { showModal } = this.state; - - const noSlackWebhooks = webhooks.filter(hook => hook.type !== 'slack'); - return ( -
- } - onClose={ this.closeModal } - /> -
-

{ 'Webhooks' }

- this.init() } /> -
- - - - -
No webhooks available.
-
- } - size="small" - show={ noSlackWebhooks.size === 0 } - // animatedIcon="no-results" - > -
- { noSlackWebhooks.map(webhook => ( - this.init(webhook) } - onDelete={ () => this.removeWebhook(webhook.webhookId) } - /> - ))} -
- - + return ( +
+
+

{'Webhooks'}

+ {/*
- ); - } + +
+ + Leverage webhooks to push OpenReplay data to other systems. +
+ + + + +
None added yet
+
+ } + size="small" + show={noSlackWebhooks.size === 0} + > +
+ {noSlackWebhooks.map((webhook) => ( + init(webhook)} /> + ))} +
+ + +
+ ); } -export default Webhooks; \ No newline at end of file +export default connect( + (state) => ({ + webhooks: state.getIn(['webhooks', 'list']), + loading: state.getIn(['webhooks', 'loading']), + }), + { + init, + fetchList, + remove, + } +)(withPageTitle('Webhooks - OpenReplay Preferences')(Webhooks)); diff --git a/frontend/app/components/Client/Webhooks/listItem.module.css b/frontend/app/components/Client/Webhooks/listItem.module.css index 33a5c1d2d..88c863de4 100644 --- a/frontend/app/components/Client/Webhooks/listItem.module.css +++ b/frontend/app/components/Client/Webhooks/listItem.module.css @@ -46,7 +46,6 @@ } .endpoint { - font-weight: 300; font-size: 12px; color: $gray-medium; margin-top: 5px; diff --git a/frontend/app/components/Client/Webhooks/webhooks.module.css b/frontend/app/components/Client/Webhooks/webhooks.module.css index 718a256f3..dbd8c241b 100644 --- a/frontend/app/components/Client/Webhooks/webhooks.module.css +++ b/frontend/app/components/Client/Webhooks/webhooks.module.css @@ -3,7 +3,7 @@ .tabHeader { display: flex; align-items: center; - margin-bottom: 25px; + /* margin-bottom: 25px; */ & .tabTitle { margin: 0 15px 0 0; diff --git a/frontend/app/components/Client/client.module.css b/frontend/app/components/Client/client.module.css index 8e69458ef..43d311b31 100644 --- a/frontend/app/components/Client/client.module.css +++ b/frontend/app/components/Client/client.module.css @@ -7,7 +7,7 @@ .main { max-height: 100%; display: flex; - min-height: calc(100vh - 81px); + /* min-height: calc(100vh - 81px); */ & .tabMenu { width: 240px; diff --git a/frontend/app/components/Dashboard/NewDashboard.tsx b/frontend/app/components/Dashboard/NewDashboard.tsx index 89af30897..cf93618b0 100644 --- a/frontend/app/components/Dashboard/NewDashboard.tsx +++ b/frontend/app/components/Dashboard/NewDashboard.tsx @@ -6,43 +6,44 @@ import DashboardSideMenu from './components/DashboardSideMenu'; import { Loader } from 'UI'; import DashboardRouter from './components/DashboardRouter'; import cn from 'classnames'; -import { withSiteId } from 'App/routes'; import withPermissions from 'HOCs/withPermissions' -function NewDashboard(props: RouteComponentProps<{}>) { - const { history, match: { params: { siteId, dashboardId, metricId } } } = props; +interface RouterProps { + siteId: string; + dashboardId: string; + metricId: string; +} + +function NewDashboard(props: RouteComponentProps) { + const { history, match: { params: { siteId, dashboardId } } } = props; const { dashboardStore } = useStore(); const loading = useObserver(() => dashboardStore.isLoading); const isMetricDetails = history.location.pathname.includes('/metrics/') || history.location.pathname.includes('/metric/'); + const isDashboardDetails = history.location.pathname.includes('/dashboard/') + const isAlertsDetails = history.location.pathname.includes('/alert/') + const shouldHideMenu = isMetricDetails || isDashboardDetails || isAlertsDetails; useEffect(() => { dashboardStore.fetchList().then((resp) => { if (parseInt(dashboardId) > 0) { dashboardStore.selectDashboardById(dashboardId); } }); - if (!dashboardId && location.pathname.includes('dashboard')) { - dashboardStore.selectDefaultDashboard().then(({ dashboardId }) => { - props.history.push(withSiteId(`/dashboard/${dashboardId}`, siteId)); - }, () => { - props.history.push(withSiteId('/dashboard', siteId)); - }) - } }, [siteId]); return useObserver(() => ( - +
-
+
- +
diff --git a/frontend/app/components/Dashboard/Widgets/BreakdownOfLoadedResources/BreakdownOfLoadedResources.js b/frontend/app/components/Dashboard/Widgets/BreakdownOfLoadedResources/BreakdownOfLoadedResources.js index 10ab766a7..99ffc931b 100644 --- a/frontend/app/components/Dashboard/Widgets/BreakdownOfLoadedResources/BreakdownOfLoadedResources.js +++ b/frontend/app/components/Dashboard/Widgets/BreakdownOfLoadedResources/BreakdownOfLoadedResources.js @@ -30,6 +30,7 @@ export default class BreakdownOfLoadedResources extends React.PureComponent { diff --git a/frontend/app/components/Dashboard/Widgets/CallWithErrors/CallWithErrors.js b/frontend/app/components/Dashboard/Widgets/CallWithErrors/CallWithErrors.js index 8bf8b90c2..4b3cadef6 100644 --- a/frontend/app/components/Dashboard/Widgets/CallWithErrors/CallWithErrors.js +++ b/frontend/app/components/Dashboard/Widgets/CallWithErrors/CallWithErrors.js @@ -64,6 +64,7 @@ export default class CallWithErrors extends React.PureComponent {
diff --git a/frontend/app/components/Dashboard/Widgets/CallsErrors5xx/CallsErrors5xx.js b/frontend/app/components/Dashboard/Widgets/CallsErrors5xx/CallsErrors5xx.js index a0e0d05a0..3c655da5f 100644 --- a/frontend/app/components/Dashboard/Widgets/CallsErrors5xx/CallsErrors5xx.js +++ b/frontend/app/components/Dashboard/Widgets/CallsErrors5xx/CallsErrors5xx.js @@ -37,6 +37,7 @@ export default class CallsErrors5xx extends React.PureComponent { diff --git a/frontend/app/components/Dashboard/Widgets/CpuLoad/CpuLoad.js b/frontend/app/components/Dashboard/Widgets/CpuLoad/CpuLoad.js index 0579480fb..ee448dac2 100644 --- a/frontend/app/components/Dashboard/Widgets/CpuLoad/CpuLoad.js +++ b/frontend/app/components/Dashboard/Widgets/CpuLoad/CpuLoad.js @@ -27,6 +27,7 @@ export default class CpuLoad extends React.PureComponent {
diff --git a/frontend/app/components/Dashboard/Widgets/Crashes/Crashes.js b/frontend/app/components/Dashboard/Widgets/Crashes/Crashes.js index 16f96a07c..576c9c13f 100644 --- a/frontend/app/components/Dashboard/Widgets/Crashes/Crashes.js +++ b/frontend/app/components/Dashboard/Widgets/Crashes/Crashes.js @@ -30,6 +30,7 @@ export default class Crashes extends React.PureComponent { diff --git a/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricOverviewChart/CustomMetricOverviewChart.tsx b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricOverviewChart/CustomMetricOverviewChart.tsx index 3038813e4..84ab4805a 100644 --- a/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricOverviewChart/CustomMetricOverviewChart.tsx +++ b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricOverviewChart/CustomMetricOverviewChart.tsx @@ -42,7 +42,7 @@ function CustomMetricOverviewChart(props: Props) { // unit={unit && ' ' + unit} type="monotone" dataKey="value" - stroke={Styles.colors[0]} + stroke={Styles.strokeColor} fillOpacity={ 1 } strokeWidth={ 2 } strokeOpacity={ 0.8 } diff --git a/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricPieChart/CustomMetricPieChart.tsx b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricPieChart/CustomMetricPieChart.tsx index 76b8697c1..a453222e5 100644 --- a/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricPieChart/CustomMetricPieChart.tsx +++ b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricPieChart/CustomMetricPieChart.tsx @@ -34,7 +34,7 @@ function CustomMetricPieChart(props: Props) { } } return ( - + - -
- - - ) +
+ + + No data for the selected time period +
+ } + > +
+ + + ); } export default CustomMetricTable; diff --git a/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricTableErrors/CustomMetricTableErrors.tsx b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricTableErrors/CustomMetricTableErrors.tsx index 55fcb29eb..dbc3c5504 100644 --- a/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricTableErrors/CustomMetricTableErrors.tsx +++ b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricTableErrors/CustomMetricTableErrors.tsx @@ -1,11 +1,10 @@ import React, { useEffect } from "react"; -import { Pagination, NoContent } from "UI"; +import { Pagination, NoContent, Icon } from "UI"; import ErrorListItem from "App/components/Dashboard/components/Errors/ErrorListItem"; import { withRouter, RouteComponentProps } from "react-router-dom"; import { useModal } from "App/components/Modal"; import ErrorDetailsModal from "App/components/Dashboard/components/Errors/ErrorDetailsModal"; import { useStore } from "App/mstore"; -import { overPastString } from "App/dateRange"; interface Props { metric: any; data: any; @@ -18,7 +17,6 @@ function CustomMetricTableErrors(props: RouteComponentProps & Props) { const errorId = new URLSearchParams(props.location.search).get("errorId"); const { showModal, hideModal } = useModal(); const { dashboardStore } = useStore(); - const period = dashboardStore.period; const onErrorClick = (e: any, error: any) => { e.stopPropagation(); @@ -46,9 +44,10 @@ function CustomMetricTableErrors(props: RouteComponentProps & Props) { return ( No data for the selected time period} show={!data.errors || data.errors.length === 0} size="small" + style={{ minHeight: 220 }} >
{data.errors && diff --git a/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricTableSessions/CustomMetricTableSessions.tsx b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricTableSessions/CustomMetricTableSessions.tsx index c5aa85e0f..ffb489b11 100644 --- a/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricTableSessions/CustomMetricTableSessions.tsx +++ b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricTableSessions/CustomMetricTableSessions.tsx @@ -3,7 +3,7 @@ import React from "react"; import SessionItem from "Shared/SessionItem"; import { Pagination, NoContent } from "UI"; import { useStore } from "App/mstore"; -import { overPastString } from "App/dateRange"; +import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG'; interface Props { metric: any; @@ -26,7 +26,13 @@ function CustomMetricTableSessions(props: Props) { data.sessions.length === 0 } size="small" - title={`No sessions found ${overPastString(period)}`} + title={ +
+ +
+
No relevant sessions found for the selected time period.
+
+ } >
{data.sessions && diff --git a/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricWidget/CustomMetricWidget.tsx b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricWidget/CustomMetricWidget.tsx index fe83c04b8..62e8dc2e5 100644 --- a/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricWidget/CustomMetricWidget.tsx +++ b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricWidget/CustomMetricWidget.tsx @@ -1,18 +1,17 @@ -import React, { useEffect, useState } from 'react'; +import React, { useState } from 'react'; import { connect } from 'react-redux'; import { Loader, NoContent, Icon, Popup } from 'UI'; import { Styles } from '../../common'; import { ResponsiveContainer } from 'recharts'; -import { LAST_24_HOURS, LAST_30_MINUTES, YESTERDAY, LAST_7_DAYS } from 'Types/app/period'; import stl from './CustomMetricWidget.module.css'; -import { getChartFormatter, getStartAndEndTimestampsByDensity } from 'Types/dashboard/helper'; +import { getStartAndEndTimestampsByDensity } from 'Types/dashboard/helper'; import { init, edit, remove, setAlertMetricId, setActiveWidget, updateActiveState } from 'Duck/customMetrics'; -import APIClient from 'App/api_client'; import { setShowAlerts } from 'Duck/dashboard'; import CustomMetriLineChart from '../CustomMetriLineChart'; import CustomMetricPieChart from '../CustomMetricPieChart'; import CustomMetricPercentage from '../CustomMetricPercentage'; import CustomMetricTable from '../CustomMetricTable'; +import { NO_METRIC_DATA } from 'App/constants/messages' const customParams = rangeName => { const params = { density: 70 } @@ -104,6 +103,7 @@ function CustomMetricWidget(props: Props) { diff --git a/frontend/app/components/Dashboard/Widgets/DomBuildingTime/DomBuildingTime.js b/frontend/app/components/Dashboard/Widgets/DomBuildingTime/DomBuildingTime.js index 27cc682ff..970bfdbad 100644 --- a/frontend/app/components/Dashboard/Widgets/DomBuildingTime/DomBuildingTime.js +++ b/frontend/app/components/Dashboard/Widgets/DomBuildingTime/DomBuildingTime.js @@ -44,6 +44,7 @@ export default class DomBuildingTime extends React.PureComponent { return ( @@ -60,6 +61,7 @@ export default class DomBuildingTime extends React.PureComponent { diff --git a/frontend/app/components/Dashboard/Widgets/ErrorsByOrigin/ErrorsByOrigin.js b/frontend/app/components/Dashboard/Widgets/ErrorsByOrigin/ErrorsByOrigin.js index 399908f74..d77bac5f4 100644 --- a/frontend/app/components/Dashboard/Widgets/ErrorsByOrigin/ErrorsByOrigin.js +++ b/frontend/app/components/Dashboard/Widgets/ErrorsByOrigin/ErrorsByOrigin.js @@ -29,6 +29,7 @@ export default class ErrorsByOrigin extends React.PureComponent { diff --git a/frontend/app/components/Dashboard/Widgets/ErrorsByType/ErrorsByType.js b/frontend/app/components/Dashboard/Widgets/ErrorsByType/ErrorsByType.js index 3bca2406c..4421a3fbb 100644 --- a/frontend/app/components/Dashboard/Widgets/ErrorsByType/ErrorsByType.js +++ b/frontend/app/components/Dashboard/Widgets/ErrorsByType/ErrorsByType.js @@ -31,6 +31,7 @@ export default class ErrorsByType extends React.PureComponent { diff --git a/frontend/app/components/Dashboard/Widgets/ErrorsPerDomain/ErrorsPerDomain.js b/frontend/app/components/Dashboard/Widgets/ErrorsPerDomain/ErrorsPerDomain.js index 68752c46b..11af3f7d7 100644 --- a/frontend/app/components/Dashboard/Widgets/ErrorsPerDomain/ErrorsPerDomain.js +++ b/frontend/app/components/Dashboard/Widgets/ErrorsPerDomain/ErrorsPerDomain.js @@ -15,6 +15,7 @@ export default class ErrorsPerDomain extends React.PureComponent {
diff --git a/frontend/app/components/Dashboard/Widgets/FPS/FPS.js b/frontend/app/components/Dashboard/Widgets/FPS/FPS.js index d91379188..843cea3db 100644 --- a/frontend/app/components/Dashboard/Widgets/FPS/FPS.js +++ b/frontend/app/components/Dashboard/Widgets/FPS/FPS.js @@ -26,6 +26,7 @@ export default class FPS extends React.PureComponent { return ( diff --git a/frontend/app/components/Dashboard/Widgets/LastFrustrations/LastFrustrations.js b/frontend/app/components/Dashboard/Widgets/LastFrustrations/LastFrustrations.js index 23f5731d9..fcd36d98e 100644 --- a/frontend/app/components/Dashboard/Widgets/LastFrustrations/LastFrustrations.js +++ b/frontend/app/components/Dashboard/Widgets/LastFrustrations/LastFrustrations.js @@ -12,6 +12,7 @@ export default class LastFeedbacks extends React.PureComponent { { sessions.map(({ diff --git a/frontend/app/components/Dashboard/Widgets/MemoryConsumption/MemoryConsumption.js b/frontend/app/components/Dashboard/Widgets/MemoryConsumption/MemoryConsumption.js index 839db02bc..14ed08d93 100644 --- a/frontend/app/components/Dashboard/Widgets/MemoryConsumption/MemoryConsumption.js +++ b/frontend/app/components/Dashboard/Widgets/MemoryConsumption/MemoryConsumption.js @@ -26,6 +26,7 @@ export default class MemoryConsumption extends React.PureComponent {
diff --git a/frontend/app/components/Dashboard/Widgets/MostImpactfulErrors/MostImpactfulErrors.js b/frontend/app/components/Dashboard/Widgets/MostImpactfulErrors/MostImpactfulErrors.js index 6f2d300a1..a86e23220 100644 --- a/frontend/app/components/Dashboard/Widgets/MostImpactfulErrors/MostImpactfulErrors.js +++ b/frontend/app/components/Dashboard/Widgets/MostImpactfulErrors/MostImpactfulErrors.js @@ -48,6 +48,7 @@ export default class MostImpactfulErrors extends React.PureComponent {
@@ -46,4 +47,4 @@ function BreakdownOfLoadedResources(props: Props) { ); } -export default BreakdownOfLoadedResources; \ No newline at end of file +export default BreakdownOfLoadedResources; diff --git a/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/CPULoad/CPULoad.tsx b/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/CPULoad/CPULoad.tsx index 53356bf0d..0ddfd0d1d 100644 --- a/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/CPULoad/CPULoad.tsx +++ b/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/CPULoad/CPULoad.tsx @@ -19,6 +19,7 @@ function CPULoad(props: Props) { return ( @@ -42,7 +43,7 @@ function CPULoad(props: Props) { type="monotone" unit="%" dataKey="value" - stroke={Styles.colors[0]} + stroke={Styles.strokeColor} fillOpacity={ 1 } strokeWidth={ 2 } strokeOpacity={ 0.8 } @@ -54,4 +55,4 @@ function CPULoad(props: Props) { ); } -export default CPULoad; \ No newline at end of file +export default CPULoad; diff --git a/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/CallWithErrors/CallWithErrors.tsx b/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/CallWithErrors/CallWithErrors.tsx index 45673614f..47c88c0aa 100644 --- a/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/CallWithErrors/CallWithErrors.tsx +++ b/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/CallWithErrors/CallWithErrors.tsx @@ -6,6 +6,7 @@ import ImageInfo from './ImageInfo'; import MethodType from './MethodType'; import cn from 'classnames'; import stl from './callWithErrors.module.css'; +import { NO_METRIC_DATA } from 'App/constants/messages' const cols = [ { @@ -61,6 +62,7 @@ function CallWithErrors(props: Props) { diff --git a/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/CallsErrors4xx/CallsErrors4xx.tsx b/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/CallsErrors4xx/CallsErrors4xx.tsx index afaaeb37d..cd1bc6716 100644 --- a/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/CallsErrors4xx/CallsErrors4xx.tsx +++ b/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/CallsErrors4xx/CallsErrors4xx.tsx @@ -6,6 +6,7 @@ import { LineChart, Line, Legend, ResponsiveContainer, XAxis, YAxis } from 'recharts'; +import { NO_METRIC_DATA } from 'App/constants/messages' interface Props { data: any @@ -16,6 +17,7 @@ function CallsErrors4xx(props: Props) { return ( @@ -46,4 +48,4 @@ function CallsErrors4xx(props: Props) { ); } -export default CallsErrors4xx; \ No newline at end of file +export default CallsErrors4xx; diff --git a/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/CallsErrors5xx/CallsErrors5xx.tsx b/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/CallsErrors5xx/CallsErrors5xx.tsx index cc87d5c26..09c86b60c 100644 --- a/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/CallsErrors5xx/CallsErrors5xx.tsx +++ b/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/CallsErrors5xx/CallsErrors5xx.tsx @@ -16,6 +16,7 @@ function CallsErrors5xx(props: Props) { return ( @@ -46,4 +47,4 @@ function CallsErrors5xx(props: Props) { ); } -export default CallsErrors5xx; \ No newline at end of file +export default CallsErrors5xx; diff --git a/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/Crashes/Crashes.tsx b/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/Crashes/Crashes.tsx index 0fa472db9..30463860c 100644 --- a/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/Crashes/Crashes.tsx +++ b/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/Crashes/Crashes.tsx @@ -7,6 +7,7 @@ import { LineChart, Line, Legend, ResponsiveContainer, XAxis, YAxis } from 'recharts'; +import { NO_METRIC_DATA } from 'App/constants/messages' interface Props { data: any @@ -18,6 +19,7 @@ function Crashes(props: Props) { return ( @@ -40,7 +42,7 @@ function Crashes(props: Props) { name="Crashes" type="monotone" dataKey="count" - stroke={Styles.colors[0]} + stroke={Styles.strokeColor} fillOpacity={ 1 } strokeWidth={ 2 } strokeOpacity={ 0.8 } @@ -52,4 +54,4 @@ function Crashes(props: Props) { ); } -export default Crashes; \ No newline at end of file +export default Crashes; diff --git a/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/DomBuildingTime/DomBuildingTime.tsx b/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/DomBuildingTime/DomBuildingTime.tsx index f14dc5cd7..0fdf5a97c 100644 --- a/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/DomBuildingTime/DomBuildingTime.tsx +++ b/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/DomBuildingTime/DomBuildingTime.tsx @@ -4,12 +4,12 @@ import { Styles, AvgLabel } from '../../common'; import { withRequest } from 'HOCs' import { AreaChart, Area, - BarChart, Bar, CartesianGrid, Tooltip, - LineChart, Line, Legend, ResponsiveContainer, + CartesianGrid, Tooltip, + ResponsiveContainer, XAxis, YAxis } from 'recharts'; -import WidgetAutoComplete from 'Shared/WidgetAutoComplete'; import { toUnderscore } from 'App/utils'; +import { NO_METRIC_DATA } from 'App/constants/messages' const WIDGET_KEY = 'pagesDomBuildtime'; @@ -21,29 +21,17 @@ interface Props { metric?: any } function DomBuildingTime(props: Props) { - const { data, optionsLoading, metric } = props; + const { data, metric } = props; const gradientDef = Styles.gradientDef(); - const onSelect = (params) => { - // const _params = { density: 70 } - // TODO reload the data with new params; - // this.props.fetchWidget(WIDGET_KEY, dashbaordStore.period, props.platform, { ..._params, url: params.value }) - } - return ( <>
- {/* */}
@@ -66,7 +54,7 @@ function DomBuildingTime(props: Props) { type="monotone" // unit="%" dataKey="value" - stroke={Styles.colors[0]} + stroke={Styles.strokeColor} fillOpacity={ 1 } strokeWidth={ 2 } strokeOpacity={ 0.8 } @@ -87,4 +75,4 @@ export default withRequest({ requestName: "fetchOptions", endpoint: '/dashboard/' + toUnderscore(WIDGET_KEY) + '/search', method: 'GET' -})(DomBuildingTime) \ No newline at end of file +})(DomBuildingTime) diff --git a/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/ErrorsByOrigin/ErrorsByOrigin.tsx b/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/ErrorsByOrigin/ErrorsByOrigin.tsx index e2a80c736..e405ba422 100644 --- a/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/ErrorsByOrigin/ErrorsByOrigin.tsx +++ b/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/ErrorsByOrigin/ErrorsByOrigin.tsx @@ -7,6 +7,7 @@ import { Legend, ResponsiveContainer, XAxis, YAxis } from 'recharts'; +import { NO_METRIC_DATA } from 'App/constants/messages' interface Props { data: any @@ -18,6 +19,7 @@ function ErrorsByOrigin(props: Props) { return ( diff --git a/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/ErrorsByType/ErrorsByType.tsx b/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/ErrorsByType/ErrorsByType.tsx index 8d01941c8..ec952487c 100644 --- a/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/ErrorsByType/ErrorsByType.tsx +++ b/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/ErrorsByType/ErrorsByType.tsx @@ -6,6 +6,7 @@ import { LineChart, Line, Legend, ResponsiveContainer, XAxis, YAxis } from 'recharts'; +import { NO_METRIC_DATA } from 'App/constants/messages' interface Props { data: any @@ -16,6 +17,7 @@ function ErrorsByType(props: Props) { return ( @@ -48,4 +50,4 @@ function ErrorsByType(props: Props) { ); } -export default ErrorsByType; \ No newline at end of file +export default ErrorsByType; diff --git a/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/ErrorsPerDomain/ErrorsPerDomain.tsx b/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/ErrorsPerDomain/ErrorsPerDomain.tsx index fab8ced65..13643c769 100644 --- a/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/ErrorsPerDomain/ErrorsPerDomain.tsx +++ b/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/ErrorsPerDomain/ErrorsPerDomain.tsx @@ -3,6 +3,7 @@ import { NoContent } from 'UI'; import { Styles } from '../../common'; import { numberWithCommas } from 'App/utils'; import Bar from 'App/components/Dashboard/Widgets/ErrorsPerDomain/Bar'; +import { NO_METRIC_DATA } from 'App/constants/messages' interface Props { data: any @@ -17,6 +18,7 @@ function ErrorsPerDomain(props: Props) { size="small" show={ metric.data.chart.length === 0 } style={{ height: '240px'}} + title={NO_METRIC_DATA} >
{metric.data.chart.map((item, i) => @@ -34,4 +36,4 @@ function ErrorsPerDomain(props: Props) { ); } -export default ErrorsPerDomain; \ No newline at end of file +export default ErrorsPerDomain; diff --git a/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/FPS/FPS.tsx b/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/FPS/FPS.tsx index e246d3c3f..5a5efb961 100644 --- a/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/FPS/FPS.tsx +++ b/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/FPS/FPS.tsx @@ -7,6 +7,7 @@ import { LineChart, Line, Legend, ResponsiveContainer, XAxis, YAxis } from 'recharts'; +import { NO_METRIC_DATA } from 'App/constants/messages' interface Props { data: any @@ -19,6 +20,7 @@ function FPS(props: Props) { return ( <> @@ -44,7 +46,7 @@ function FPS(props: Props) { name="Avg" type="monotone" dataKey="value" - stroke={Styles.colors[0]} + stroke={Styles.strokeColor} fillOpacity={ 1 } strokeWidth={ 2 } strokeOpacity={ 0.8 } @@ -57,4 +59,4 @@ function FPS(props: Props) { ); } -export default FPS; \ No newline at end of file +export default FPS; diff --git a/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/MemoryConsumption/MemoryConsumption.tsx b/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/MemoryConsumption/MemoryConsumption.tsx index 80e1f4d9c..6fb22c784 100644 --- a/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/MemoryConsumption/MemoryConsumption.tsx +++ b/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/MemoryConsumption/MemoryConsumption.tsx @@ -7,6 +7,7 @@ import { LineChart, Line, Legend, ResponsiveContainer, XAxis, YAxis } from 'recharts'; +import { NO_METRIC_DATA } from 'App/constants/messages' interface Props { data: any @@ -22,6 +23,7 @@ function MemoryConsumption(props: Props) { <>
@@ -47,7 +49,7 @@ function MemoryConsumption(props: Props) { unit=" mb" type="monotone" dataKey="value" - stroke={Styles.colors[0]} + stroke={Styles.strokeColor} fillOpacity={ 1 } strokeWidth={ 2 } strokeOpacity={ 0.8 } @@ -60,4 +62,4 @@ function MemoryConsumption(props: Props) { ); } -export default MemoryConsumption; \ No newline at end of file +export default MemoryConsumption; diff --git a/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/MissingResources/MissingResources.tsx b/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/MissingResources/MissingResources.tsx index ae2f1d27e..aef9bbec0 100644 --- a/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/MissingResources/MissingResources.tsx +++ b/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/MissingResources/MissingResources.tsx @@ -50,9 +50,10 @@ function MissingResources(props: Props) { return (
- - + + { - // const _params = { density: 70 } - setSutoCompleteSelected(params.value); - // TODO reload the data with new params; - // this.props.fetchWidget(WIDGET_KEY, dashbaordStore.period, props.platform, { ..._params, url: params.value }) - } - - const writeOption = (e, { name, value }) => { - // this.setState({ [name]: value }) - setType(value); - const _params = { density: 70 } // TODO reload the data with new params; - // this.props.fetchWidget(WIDGET_KEY, this.props.period, this.props.platform, { ..._params, [ name ]: value === 'all' ? null : value }) - } return ( <>
- {/* - */}
@@ -98,7 +63,7 @@ function ResourceLoadingTime(props: Props) { unit=" ms" type="monotone" dataKey="avg" - stroke={Styles.colors[0]} + stroke={Styles.strokeColor} fillOpacity={ 1 } strokeWidth={ 2 } strokeOpacity={ 0.8 } @@ -119,4 +84,4 @@ export default withRequest({ requestName: "fetchOptions", endpoint: '/dashboard/' + toUnderscore(WIDGET_KEY) + '/search', method: 'GET' -})(ResourceLoadingTime) \ No newline at end of file +})(ResourceLoadingTime) diff --git a/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/ResponseTime/ResponseTime.tsx b/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/ResponseTime/ResponseTime.tsx index 0d6587386..fabb85787 100644 --- a/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/ResponseTime/ResponseTime.tsx +++ b/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/ResponseTime/ResponseTime.tsx @@ -4,12 +4,11 @@ import { Styles, AvgLabel } from '../../common'; import { withRequest } from 'HOCs' import { AreaChart, Area, - BarChart, Bar, CartesianGrid, Tooltip, - LineChart, Line, Legend, ResponsiveContainer, + CartesianGrid, Tooltip, + ResponsiveContainer, XAxis, YAxis - } from 'recharts'; -import WidgetAutoComplete from 'Shared/WidgetAutoComplete'; -import { toUnderscore } from 'App/utils'; + } from 'recharts';import { toUnderscore } from 'App/utils'; +import { NO_METRIC_DATA } from 'App/constants/messages' const WIDGET_KEY = 'pagesResponseTime'; @@ -21,19 +20,13 @@ interface Props { metric?: any } function ResponseTime(props: Props) { - const { data, optionsLoading, metric } = props; + const { data, metric } = props; const gradientDef = Styles.gradientDef(); - - const onSelect = (params) => { - // const _params = { density: 70 } - // TODO reload the data with new params; - // this.props.fetchWidget(WIDGET_KEY, dashbaordStore.period, props.platform, { ..._params, url: params.value }) - } - return ( <> @@ -67,7 +60,7 @@ function ResponseTime(props: Props) { type="monotone" unit=" ms" dataKey="value" - stroke={Styles.colors[0]} + stroke={Styles.strokeColor} fillOpacity={ 1 } strokeWidth={ 2 } strokeOpacity={ 0.8 } @@ -88,4 +81,4 @@ export default withRequest({ requestName: "fetchOptions", endpoint: '/dashboard/' + toUnderscore(WIDGET_KEY) + '/search', method: 'GET' -})(ResponseTime) \ No newline at end of file +})(ResponseTime) diff --git a/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/ResponseTimeDistribution/ResponseTimeDistribution.tsx b/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/ResponseTimeDistribution/ResponseTimeDistribution.tsx index 5190157ae..548a229ab 100644 --- a/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/ResponseTimeDistribution/ResponseTimeDistribution.tsx +++ b/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/ResponseTimeDistribution/ResponseTimeDistribution.tsx @@ -5,6 +5,7 @@ import { ComposedChart, Bar, BarChart, CartesianGrid, ResponsiveContainer, XAxis, YAxis, ReferenceLine, Tooltip, Legend } from 'recharts'; +import { NO_METRIC_DATA } from 'App/constants/messages' const PercentileLine = props => { @@ -49,6 +50,7 @@ function ResponseTimeDistribution(props: Props) { return ( @@ -125,4 +127,4 @@ function ResponseTimeDistribution(props: Props) { ); } -export default ResponseTimeDistribution; \ No newline at end of file +export default ResponseTimeDistribution; diff --git a/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/SessionsAffectedByJSErrors/SessionsAffectedByJSErrors.tsx b/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/SessionsAffectedByJSErrors/SessionsAffectedByJSErrors.tsx index 55434e2a9..e798d5b4c 100644 --- a/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/SessionsAffectedByJSErrors/SessionsAffectedByJSErrors.tsx +++ b/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/SessionsAffectedByJSErrors/SessionsAffectedByJSErrors.tsx @@ -6,6 +6,7 @@ import { LineChart, Line, Legend, ResponsiveContainer, XAxis, YAxis } from 'recharts'; + import { NO_METRIC_DATA } from 'App/constants/messages' interface Props { data: any @@ -15,6 +16,7 @@ function SessionsAffectedByJSErrors(props: Props) { const { data, metric } = props; return ( @@ -40,7 +42,7 @@ function SessionsImpactedBySlowRequests(props: Props) { name="Sessions" type="monotone" dataKey="count" - stroke={Styles.colors[0]} + stroke={Styles.strokeColor} fillOpacity={ 1 } strokeWidth={ 2 } strokeOpacity={ 0.8 } @@ -52,4 +54,4 @@ function SessionsImpactedBySlowRequests(props: Props) { ); } -export default SessionsImpactedBySlowRequests; \ No newline at end of file +export default SessionsImpactedBySlowRequests; diff --git a/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/SessionsPerBrowser/SessionsPerBrowser.tsx b/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/SessionsPerBrowser/SessionsPerBrowser.tsx index 6b155364d..ca6c836e3 100644 --- a/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/SessionsPerBrowser/SessionsPerBrowser.tsx +++ b/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/SessionsPerBrowser/SessionsPerBrowser.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { NoContent } from 'UI'; import { Styles } from '../../common'; import Bar from './Bar'; +import { NO_METRIC_DATA } from 'App/constants/messages' interface Props { data: any @@ -19,7 +20,9 @@ function SessionsPerBrowser(props: Props) { return (
{metric.data.chart.map((item, i) => @@ -38,4 +41,4 @@ function SessionsPerBrowser(props: Props) { ); } -export default SessionsPerBrowser; \ No newline at end of file +export default SessionsPerBrowser; diff --git a/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/SlowestDomains/SlowestDomains.tsx b/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/SlowestDomains/SlowestDomains.tsx index c6adbeff6..fa4b703f2 100644 --- a/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/SlowestDomains/SlowestDomains.tsx +++ b/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/SlowestDomains/SlowestDomains.tsx @@ -3,6 +3,7 @@ import { NoContent } from 'UI'; import { Styles } from '../../common'; import { numberWithCommas } from 'App/utils'; import Bar from 'App/components/Dashboard/Widgets/SlowestDomains/Bar'; +import { NO_METRIC_DATA } from 'App/constants/messages' interface Props { data: any @@ -15,7 +16,8 @@ function SlowestDomains(props: Props) {
{metric.data.chart.map((item, i) => diff --git a/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/SlowestResources/SlowestResources.tsx b/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/SlowestResources/SlowestResources.tsx index 9cdf60514..97aebc599 100644 --- a/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/SlowestResources/SlowestResources.tsx +++ b/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/SlowestResources/SlowestResources.tsx @@ -8,6 +8,7 @@ import Chart from './Chart'; import ImageInfo from './ImageInfo'; import ResourceType from './ResourceType'; import CopyPath from './CopyPath'; +import { NO_METRIC_DATA } from 'App/constants/messages' export const RESOURCE_OPTIONS = [ { text: 'All', value: 'ALL', }, @@ -68,9 +69,10 @@ function SlowestResources(props: Props) { return (
- {colors.map((c, i) => ( + {Styles.colorsTeal.map((c, i) => (
+
diff --git a/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/TimeToRender/TimeToRender.tsx b/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/TimeToRender/TimeToRender.tsx index 7fceb853d..20cdc1f51 100644 --- a/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/TimeToRender/TimeToRender.tsx +++ b/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/TimeToRender/TimeToRender.tsx @@ -4,12 +4,12 @@ import { Styles, AvgLabel } from '../../common'; import { withRequest } from 'HOCs' import { AreaChart, Area, - BarChart, Bar, CartesianGrid, Tooltip, - LineChart, Line, Legend, ResponsiveContainer, + CartesianGrid, Tooltip, + ResponsiveContainer, XAxis, YAxis } from 'recharts'; -import WidgetAutoComplete from 'Shared/WidgetAutoComplete'; import { toUnderscore } from 'App/utils'; +import { NO_METRIC_DATA } from 'App/constants/messages' const WIDGET_KEY = 'timeToRender'; @@ -35,6 +35,7 @@ function TimeToRender(props: Props) { <>
@@ -67,7 +68,7 @@ function TimeToRender(props: Props) { type="monotone" unit=" ms" dataKey="value" - stroke={Styles.colors[0]} + stroke={Styles.strokeColor} fillOpacity={ 1 } strokeWidth={ 2 } strokeOpacity={ 0.8 } @@ -88,4 +89,4 @@ export default withRequest({ requestName: "fetchOptions", endpoint: '/dashboard/' + toUnderscore(WIDGET_KEY) + '/search', method: 'GET' -})(TimeToRender) \ No newline at end of file +})(TimeToRender) diff --git a/frontend/app/components/Dashboard/Widgets/ProcessedSessions.js b/frontend/app/components/Dashboard/Widgets/ProcessedSessions.js index 7b1a92918..2e6d3e743 100644 --- a/frontend/app/components/Dashboard/Widgets/ProcessedSessions.js +++ b/frontend/app/components/Dashboard/Widgets/ProcessedSessions.js @@ -35,7 +35,7 @@ export default class ProcessedSessions extends React.PureComponent { - + diff --git a/frontend/app/components/Dashboard/Widgets/ResourceLoadedVsResponseEnd/ResourceLoadedVsResponseEnd.js b/frontend/app/components/Dashboard/Widgets/ResourceLoadedVsResponseEnd/ResourceLoadedVsResponseEnd.js index c30375aa7..d04a5cef5 100644 --- a/frontend/app/components/Dashboard/Widgets/ResourceLoadedVsResponseEnd/ResourceLoadedVsResponseEnd.js +++ b/frontend/app/components/Dashboard/Widgets/ResourceLoadedVsResponseEnd/ResourceLoadedVsResponseEnd.js @@ -28,6 +28,7 @@ export default class ResourceLoadedVsResponseEnd extends React.PureComponent { diff --git a/frontend/app/components/Dashboard/Widgets/ResourceLoadingTime/ResourceLoadingTime.js b/frontend/app/components/Dashboard/Widgets/ResourceLoadingTime/ResourceLoadingTime.js index 262312f1b..8f95a3479 100644 --- a/frontend/app/components/Dashboard/Widgets/ResourceLoadingTime/ResourceLoadingTime.js +++ b/frontend/app/components/Dashboard/Widgets/ResourceLoadingTime/ResourceLoadingTime.js @@ -66,6 +66,7 @@ export default class ResourceLoadingTime extends React.PureComponent {
@@ -96,6 +97,7 @@ export default class ResourceLoadingTime extends React.PureComponent {
diff --git a/frontend/app/components/Dashboard/Widgets/SessionsAffectedByJSErrors/SessionsAffectedByJSErrors.js b/frontend/app/components/Dashboard/Widgets/SessionsAffectedByJSErrors/SessionsAffectedByJSErrors.js index 747247872..057122195 100644 --- a/frontend/app/components/Dashboard/Widgets/SessionsAffectedByJSErrors/SessionsAffectedByJSErrors.js +++ b/frontend/app/components/Dashboard/Widgets/SessionsAffectedByJSErrors/SessionsAffectedByJSErrors.js @@ -36,6 +36,7 @@ export default class SessionsAffectedByJSErrors extends React.PureComponent {
{data.chart.map((item, i) => @@ -40,4 +41,4 @@ export default class SessionsPerBrowser extends React.PureComponent { ); } -} \ No newline at end of file +} diff --git a/frontend/app/components/Dashboard/Widgets/SlowestDomains/SlowestDomains.js b/frontend/app/components/Dashboard/Widgets/SlowestDomains/SlowestDomains.js index b31b93891..9f85ae412 100644 --- a/frontend/app/components/Dashboard/Widgets/SlowestDomains/SlowestDomains.js +++ b/frontend/app/components/Dashboard/Widgets/SlowestDomains/SlowestDomains.js @@ -16,6 +16,7 @@ export default class ResponseTime extends React.PureComponent {
{data.partition && data.partition.map((item, i) => diff --git a/frontend/app/components/Dashboard/Widgets/SlowestImages/SlowestImages.js b/frontend/app/components/Dashboard/Widgets/SlowestImages/SlowestImages.js index 87cf5478f..7bfc0cfd9 100644 --- a/frontend/app/components/Dashboard/Widgets/SlowestImages/SlowestImages.js +++ b/frontend/app/components/Dashboard/Widgets/SlowestImages/SlowestImages.js @@ -41,6 +41,7 @@ export default class SlowestImages extends React.PureComponent {
diff --git a/frontend/app/components/Dashboard/Widgets/TimeToRender/TimeToRender.js b/frontend/app/components/Dashboard/Widgets/TimeToRender/TimeToRender.js index 956174cba..1ac489588 100644 --- a/frontend/app/components/Dashboard/Widgets/TimeToRender/TimeToRender.js +++ b/frontend/app/components/Dashboard/Widgets/TimeToRender/TimeToRender.js @@ -59,6 +59,7 @@ export default class TimeToRender extends React.PureComponent { { @@ -15,12 +16,14 @@ const countView = count => { export default { customMetricColors, colors, + colorsTeal, colorsPie, colorsx, compareColors, compareColorsx, lineColor: '#2A7B7F', lineColorCompare: '#394EFF', + strokeColor: colors[2], xaxis: { axisLine: { stroke: '#CCCCCC' }, interval: 0, @@ -74,8 +77,8 @@ export default { gradientDef: () => ( - - + + diff --git a/frontend/app/components/Dashboard/Widgets/common/widgetHOC.js b/frontend/app/components/Dashboard/Widgets/common/widgetHOC.js index 5f91413f1..341d52245 100644 --- a/frontend/app/components/Dashboard/Widgets/common/widgetHOC.js +++ b/frontend/app/components/Dashboard/Widgets/common/widgetHOC.js @@ -8,101 +8,142 @@ import { WIDGET_MAP } from 'Types/dashboard'; import Title from './Title'; import stl from './widgetHOC.module.css'; -export default ( - widgetKey, - panelProps = {}, - wrapped = true, - allowedFilters = [], -) => BaseComponent => - @connect((state, props) => { - const compare = props && props.compare; - const key = compare ? '_' + widgetKey : widgetKey; +export default (widgetKey, panelProps = {}, wrapped = true, allowedFilters = []) => + (BaseComponent) => { + @connect( + (state, props) => { + const compare = props && props.compare; + const key = compare ? '_' + widgetKey : widgetKey; - return { - loading: state.getIn([ 'dashboard', 'fetchWidget', key, 'loading' ]), - data: state.getIn([ 'dashboard', key ]), - comparing: state.getIn([ 'dashboard', 'comparing' ]), - filtersSize: state.getIn([ 'dashboard', 'filters' ]).size, - filters: state.getIn([ 'dashboard', compare ? 'filtersCompare' : 'filters' ]), - period: state.getIn([ 'dashboard', compare ? 'periodCompare' : 'period' ]), //TODO: filters - platform: state.getIn([ 'dashboard', 'platform' ]), - // appearance: state.getIn([ 'user', 'account', 'appearance' ]), + return { + loading: state.getIn(['dashboard', 'fetchWidget', key, 'loading']), + data: state.getIn(['dashboard', key]), + comparing: state.getIn(['dashboard', 'comparing']), + filtersSize: state.getIn(['dashboard', 'filters']).size, + filters: state.getIn(['dashboard', compare ? 'filtersCompare' : 'filters']), + period: state.getIn(['dashboard', compare ? 'periodCompare' : 'period']), //TODO: filters + platform: state.getIn(['dashboard', 'platform']), + // appearance: state.getIn([ 'user', 'account', 'appearance' ]), - dataCompare: state.getIn([ 'dashboard', '_' + widgetKey ]), // only for overview - loadingCompare: state.getIn([ 'dashboard', 'fetchWidget', '_' + widgetKey, 'loading' ]), - filtersCompare: state.getIn([ 'dashboard', 'filtersCompare' ]), - periodCompare: state.getIn([ 'dashboard', 'periodCompare' ]), //TODO: filters - } - }, { - fetchWidget, - // updateAppearance, - }) - class WidgetWrapper extends React.PureComponent { - constructor(props) { - super(props); - const params = panelProps.customParams ? panelProps.customParams(this.props.period.rangeName) : {}; - if(props.testId) { - params.testId = parseInt(props.testId); - } - params.compare = this.props.compare; - const filters = allowedFilters.length > 0 ? props.filters.filter(f => allowedFilters.includes(f.key)) : props.filters; - props.fetchWidget(widgetKey, props.period, props.platform, params, filters); - } + dataCompare: state.getIn(['dashboard', '_' + widgetKey]), // only for overview + loadingCompare: state.getIn(['dashboard', 'fetchWidget', '_' + widgetKey, 'loading']), + filtersCompare: state.getIn(['dashboard', 'filtersCompare']), + periodCompare: state.getIn(['dashboard', 'periodCompare']), //TODO: filters + }; + }, + { + fetchWidget, + // updateAppearance, + } + ) + class WidgetWrapper extends React.PureComponent { + constructor(props) { + super(props); + const params = panelProps.customParams + ? panelProps.customParams(this.props.period.rangeName) + : {}; + if (props.testId) { + params.testId = parseInt(props.testId); + } + params.compare = this.props.compare; + const filters = + allowedFilters.length > 0 + ? props.filters.filter((f) => allowedFilters.includes(f.key)) + : props.filters; + props.fetchWidget(widgetKey, props.period, props.platform, params, filters); + } - componentDidUpdate(prevProps) { - if (prevProps.period !== this.props.period || - prevProps.platform !== this.props.platform || - prevProps.filters.size !== this.props.filters.size) { - const params = panelProps.customParams ? panelProps.customParams(this.props.period.rangeName) : {}; - if(this.props.testId) { - params.testId = parseInt(this.props.testId); - } - params.compare = this.props.compare; - const filters = allowedFilters.length > 0 ? this.props.filters.filter(f => allowedFilters.includes(f.key)) : this.props.filters; - this.props.fetchWidget(widgetKey, this.props.period, this.props.platform, params, filters); - } + componentDidUpdate(prevProps) { + if ( + prevProps.period !== this.props.period || + prevProps.platform !== this.props.platform || + prevProps.filters.size !== this.props.filters.size + ) { + const params = panelProps.customParams + ? panelProps.customParams(this.props.period.rangeName) + : {}; + if (this.props.testId) { + params.testId = parseInt(this.props.testId); + } + params.compare = this.props.compare; + const filters = + allowedFilters.length > 0 + ? this.props.filters.filter((f) => allowedFilters.includes(f.key)) + : this.props.filters; + this.props.fetchWidget( + widgetKey, + this.props.period, + this.props.platform, + params, + filters + ); + } - // handling overview widgets - if ((!prevProps.comparing || prevProps.periodCompare !== this.props.periodCompare || prevProps.filtersCompare.size !== this.props.filtersCompare.size) && - this.props.comparing && this.props.isOverview - ) { - const params = panelProps.customParams ? panelProps.customParams(this.props.period.rangeName) : {}; - params.compare = true; - const filtersCompare = allowedFilters.length > 0 ? this.props.filtersCompare.filter(f => allowedFilters.includes(f.key)) : this.props.filtersCompare; - this.props.fetchWidget(widgetKey, this.props.periodCompare, this.props.platform, params, filtersCompare); - } - } + // handling overview widgets + if ( + (!prevProps.comparing || + prevProps.periodCompare !== this.props.periodCompare || + prevProps.filtersCompare.size !== this.props.filtersCompare.size) && + this.props.comparing && + this.props.isOverview + ) { + const params = panelProps.customParams + ? panelProps.customParams(this.props.period.rangeName) + : {}; + params.compare = true; + const filtersCompare = + allowedFilters.length > 0 + ? this.props.filtersCompare.filter((f) => allowedFilters.includes(f.key)) + : this.props.filtersCompare; + this.props.fetchWidget( + widgetKey, + this.props.periodCompare, + this.props.platform, + params, + filtersCompare + ); + } + } - handleRemove = () => { - // const { appearance } = this.props; - // this.props.updateAppearance(appearance.setIn([ 'dashboard', widgetKey ], false)); - } + handleRemove = () => { + // const { appearance } = this.props; + // this.props.updateAppearance(appearance.setIn([ 'dashboard', widgetKey ], false)); + }; - render() { - const { comparing, compare } = this.props; + render() { + const { comparing, compare } = this.props; - return ( - wrapped ? -
-
-
- {comparing &&
} - - { <CloseButton className={ cn(stl.closeButton, 'ml-auto') } onClick={ this.handleRemove } size="17" /> } - </div> - <div className="flex-1 flex flex-col"> - <BaseComponent { ...this.props } /> - </div> - </div> - </div> - : - <BaseComponent { ...this.props } /> - ) - } - } \ No newline at end of file + return wrapped ? ( + <div className={cn(stl.wrapper, { [stl.comparing]: comparing })}> + <div + className={cn(stl.panel, 'flex flex-col relative', { + [stl.fullwidth]: panelProps.fullwidth, + [stl.fitContent]: panelProps.fitContent, + [stl.minHeight]: !panelProps.fitContent, + })} + > + <div className="flex items-center mb-2"> + {comparing && ( + <div className={cn(stl.circle, { 'bg-tealx': !compare, 'bg-teal': compare })} /> + )} + <Title title={panelProps.name ? panelProps.name : WIDGET_MAP[widgetKey].name} /> + { + <CloseButton + className={cn(stl.closeButton, 'ml-auto')} + onClick={this.handleRemove} + size="17" + /> + } + </div> + <div className="flex-1 flex flex-col"> + <BaseComponent {...this.props} /> + </div> + </div> + </div> + ) : ( + <BaseComponent {...this.props} /> + ); + } + } + return WidgetWrapper; + }; diff --git a/frontend/app/components/Dashboard/components/Alerts/AlertForm/BottomButtons.tsx b/frontend/app/components/Dashboard/components/Alerts/AlertForm/BottomButtons.tsx new file mode 100644 index 000000000..9a5e716f0 --- /dev/null +++ b/frontend/app/components/Dashboard/components/Alerts/AlertForm/BottomButtons.tsx @@ -0,0 +1,44 @@ +import React from 'react' +import { Button, Icon } from 'UI' + +interface IBottomButtons { + loading: boolean + deleting: boolean + instance: Alert + onDelete: (instance: Alert) => void +} + +function BottomButtons({ loading, instance, deleting, onDelete }: IBottomButtons) { + return ( + <> + <div className="flex items-center"> + <Button + loading={loading} + variant="primary" + type="submit" + disabled={loading || !instance.validate()} + id="submit-button" + > + {instance.exists() ? 'Update' : 'Create'} + </Button> + </div> + <div> + {instance.exists() && ( + <Button + hover + variant="text" + loading={deleting} + type="button" + onClick={() => onDelete(instance)} + id="trash-button" + className="!text-teal !fill-teal" + > + <Icon name="trash" color="inherit" className="mr-2" size="18" /> Delete + </Button> + )} + </div> + </> + ) +} + +export default BottomButtons diff --git a/frontend/app/components/Dashboard/components/Alerts/AlertForm/Condition.tsx b/frontend/app/components/Dashboard/components/Alerts/AlertForm/Condition.tsx new file mode 100644 index 000000000..dcb24d6e4 --- /dev/null +++ b/frontend/app/components/Dashboard/components/Alerts/AlertForm/Condition.tsx @@ -0,0 +1,136 @@ +import React from 'react'; +import { Input } from 'UI'; +import Select from 'Shared/Select'; +import { alertConditions as conditions } from 'App/constants'; + +const thresholdOptions = [ + { label: '15 minutes', value: 15 }, + { label: '30 minutes', value: 30 }, + { label: '1 hour', value: 60 }, + { label: '2 hours', value: 120 }, + { label: '4 hours', value: 240 }, + { label: '1 day', value: 1440 }, +]; + +const changeOptions = [ + { label: 'change', value: 'change' }, + { label: '% change', value: 'percent' }, +]; + +interface ICondition { + isThreshold: boolean; + writeOption: (e: any, data: any) => void; + instance: Alert; + triggerOptions: any[]; + writeQuery: (data: any) => void; + writeQueryOption: (e: any, data: any) => void; + unit: any; +} + +function Condition({ + isThreshold, + writeOption, + instance, + triggerOptions, + writeQueryOption, + writeQuery, + unit, +}: ICondition) { + return ( + <div> + {!isThreshold && ( + <div className="flex items-center my-3"> + <label className="w-1/6 flex-shrink-0 font-normal">{'Trigger when'}</label> + <Select + className="w-2/6" + placeholder="change" + options={changeOptions} + name="change" + defaultValue={instance.change} + onChange={({ value }) => writeOption(null, { name: 'change', value })} + id="change-dropdown" + /> + </div> + )} + + <div className="flex items-center my-3"> + <label className="w-1/6 flex-shrink-0 font-normal"> + {isThreshold ? 'Trigger when' : 'of'} + </label> + <Select + className="w-2/6" + placeholder="Select Metric" + isSearchable={true} + options={triggerOptions} + name="left" + value={triggerOptions.find((i) => i.value === instance.query.left)} + onChange={({ value }) => writeQueryOption(null, { name: 'left', value: value.value })} + /> + </div> + + <div className="flex items-center my-3"> + <label className="w-1/6 flex-shrink-0 font-normal">{'is'}</label> + <div className="w-2/6 flex items-center"> + <Select + placeholder="Select Condition" + options={conditions} + name="operator" + value={conditions.find(c => c.value === instance.query.operator)} + onChange={({ value }) => + writeQueryOption(null, { name: 'operator', value: value.value }) + } + /> + {unit && ( + <> + <Input + className="px-4" + style={{ marginRight: '31px' }} + name="right" + value={instance.query.right} + onChange={writeQuery} + placeholder="E.g. 3" + /> + <span className="ml-2">{'test'}</span> + </> + )} + {!unit && ( + <Input + wrapperClassName="ml-2" + name="right" + value={instance.query.right} + onChange={writeQuery} + placeholder="Specify Value" + /> + )} + </div> + </div> + + <div className="flex items-center my-3"> + <label className="w-1/6 flex-shrink-0 font-normal">{'over the past'}</label> + <Select + className="w-2/6" + placeholder="Select timeframe" + options={thresholdOptions} + name="currentPeriod" + defaultValue={instance.currentPeriod} + onChange={({ value }) => writeOption(null, { name: 'currentPeriod', value })} + /> + </div> + {!isThreshold && ( + <div className="flex items-center my-3"> + <label className="w-1/6 flex-shrink-0 font-normal">{'compared to previous'}</label> + <Select + className="w-2/6" + placeholder="Select timeframe" + options={thresholdOptions} + name="previousPeriod" + defaultValue={instance.previousPeriod} + onChange={({ value }) => writeOption(null, { name: 'previousPeriod', value })} + /> + </div> + )} + </div> + ); +} + +export default Condition; diff --git a/frontend/app/components/Dashboard/components/Alerts/AlertForm/NotifyHooks.tsx b/frontend/app/components/Dashboard/components/Alerts/AlertForm/NotifyHooks.tsx new file mode 100644 index 000000000..921c7ba9b --- /dev/null +++ b/frontend/app/components/Dashboard/components/Alerts/AlertForm/NotifyHooks.tsx @@ -0,0 +1,101 @@ +import React from 'react'; +import { Checkbox } from 'UI'; +import DropdownChips from '../DropdownChips'; + +interface INotifyHooks { + instance: Alert; + onChangeCheck: (e: React.ChangeEvent<HTMLInputElement>) => void; + slackChannels: Array<any>; + validateEmail: (value: string) => boolean; + edit: (data: any) => void; + hooks: Array<any>; +} + +function NotifyHooks({ + instance, + onChangeCheck, + slackChannels, + validateEmail, + hooks, + edit, +}: INotifyHooks) { + return ( + <div className="flex flex-col"> + <div className="flex items-center my-4"> + <Checkbox + name="slack" + className="mr-8" + type="checkbox" + checked={instance.slack} + onClick={onChangeCheck} + label="Slack" + /> + <Checkbox + name="email" + type="checkbox" + checked={instance.email} + onClick={onChangeCheck} + className="mr-8" + label="Email" + /> + <Checkbox + name="webhook" + type="checkbox" + checked={instance.webhook} + onClick={onChangeCheck} + label="Webhook" + /> + </div> + + {instance.slack && ( + <div className="flex items-start my-4"> + <label className="w-1/6 flex-shrink-0 font-normal pt-2">{'Slack'}</label> + <div className="w-2/6"> + <DropdownChips + fluid + selected={instance.slackInput} + options={slackChannels} + placeholder="Select Channel" + // @ts-ignore + onChange={(selected) => edit({ slackInput: selected })} + /> + </div> + </div> + )} + + {instance.email && ( + <div className="flex items-start my-4"> + <label className="w-1/6 flex-shrink-0 font-normal pt-2">{'Email'}</label> + <div className="w-2/6"> + <DropdownChips + textFiled + validate={validateEmail} + selected={instance.emailInput} + placeholder="Type and press Enter key" + // @ts-ignore + onChange={(selected) => edit({ emailInput: selected })} + /> + </div> + </div> + )} + + {instance.webhook && ( + <div className="flex items-start my-4"> + <label className="w-1/6 flex-shrink-0 font-normal pt-2">{'Webhook'}</label> + <div className="w-2/6"> + <DropdownChips + fluid + selected={instance.webhookInput} + options={hooks} + placeholder="Select Webhook" + // @ts-ignore + onChange={(selected) => edit({ webhookInput: selected })} + /> + </div> + </div> + )} + </div> + ); +} + +export default NotifyHooks; diff --git a/frontend/app/components/Dashboard/components/Alerts/AlertListItem.tsx b/frontend/app/components/Dashboard/components/Alerts/AlertListItem.tsx new file mode 100644 index 000000000..c1038586b --- /dev/null +++ b/frontend/app/components/Dashboard/components/Alerts/AlertListItem.tsx @@ -0,0 +1,131 @@ +import React from 'react'; +import { Icon } from 'UI'; +import { checkForRecent } from 'App/date'; +import { withSiteId, alertEdit } from 'App/routes'; +// @ts-ignore +import { DateTime } from 'luxon'; +import { withRouter, RouteComponentProps } from 'react-router-dom'; +import cn from 'classnames'; + +const getThreshold = (threshold: number) => { + if (threshold === 15) return '15 Minutes'; + if (threshold === 30) return '30 Minutes'; + if (threshold === 60) return '1 Hour'; + if (threshold === 120) return '2 Hours'; + if (threshold === 240) return '4 Hours'; + if (threshold === 1440) return '1 Day'; +}; + +const getNotifyChannel = (alert: Record<string, any>, webhooks: Array<any>) => { + // @ts-ignore god damn you immutable + if (webhooks.size === 0) { + return 'OpenReplay'; + } + const getSlackChannels = () => { + return ( + ' (' + + alert.slackInput + .map((channelId: number) => { + return ( + '#' + + webhooks.find((hook) => hook.webhookId === channelId && hook.type === 'slack').name + ); + }) + .join(', ') + + ')' + ); + }; + let str = ''; + if (alert.slack) { + str = 'Slack'; + str += alert.slackInput.length > 0 ? getSlackChannels() : ''; + } + if (alert.email) { + str += (str === '' ? '' : ' and ') + (alert.emailInput.length > 1 ? 'Emails' : 'Email'); + str += alert.emailInput.length > 0 ? ' (' + alert.emailInput.join(', ') + ')' : ''; + } + if (alert.webhook) str += (str === '' ? '' : ' and ') + 'Webhook'; + if (str === '') return 'OpenReplay'; + + return str; +}; + +interface Props extends RouteComponentProps { + alert: Alert; + siteId: string; + init: (alert?: Alert) => void; + demo?: boolean; + webhooks: Array<any>; +} + +function AlertListItem(props: Props) { + const { alert, siteId, history, init, demo, webhooks } = props; + + if (!alert) { + return null; + } + + const onItemClick = () => { + if (demo) return; + const path = withSiteId(alertEdit(alert.alertId), siteId); + init(alert); + history.push(path); + }; + + return ( + <div + className={cn('px-6', !demo ? 'hover:bg-active-blue cursor-pointer border-t' : '')} + onClick={onItemClick} + > + <div className="grid grid-cols-12 py-4 select-none"> + <div className="col-span-8 flex items-start"> + <div className="flex items-center capitalize-first"> + <div className="w-9 h-9 rounded-full bg-tealx-lightest flex items-center justify-center mr-2"> + <Icon name="bell" size="16" color="tealx" /> + </div> + <div className="link capitalize-first">{alert.name}</div> + </div> + </div> + <div className="col-span-2"> + <div className="flex items-center uppercase"> + <span>{alert.detectionMethod}</span> + </div> + </div> + <div className="col-span-2 text-right"> + {demo + ? DateTime.fromMillis(+new Date()).toFormat('LLL dd, yyyy, hh:mm a') + : checkForRecent( + DateTime.fromMillis(alert.createdAt || +new Date()), + 'LLL dd, yyyy, hh:mm a' + )} + </div> + </div> + <div className="color-gray-medium px-2 pb-2"> + {'When the '} + <span className="font-semibold" style={{ fontFamily: 'Menlo, Monaco, Consolas' }}>{alert.detectionMethod}</span> + {' of '} + <span className="font-semibold" style={{ fontFamily: 'Menlo, Monaco, Consolas' }}>{alert.query.left}</span> + {' is '} + <span className="font-semibold" style={{ fontFamily: 'Menlo, Monaco, Consolas' }}> + {alert.query.operator} + {alert.query.right} {alert.metric.unit} + </span> + {' over the past '} + <span className="font-semibold" style={{ fontFamily: 'Menlo, Monaco, Consolas' }}>{getThreshold(alert.currentPeriod)}</span> + {alert.detectionMethod === 'change' ? ( + <> + {' compared to the previous '} + <span className="font-semibold" style={{ fontFamily: 'Menlo, Monaco, Consolas ' }}>{getThreshold(alert.previousPeriod)}</span> + </> + ) : null} + {', notify me on '} + <span>{getNotifyChannel(alert, webhooks)}</span>. + </div> + {alert.description ? ( + <div className="color-gray-medium px-2 pb-2">{alert.description}</div> + ) : null} + </div> + ); +} + +export default withRouter(AlertListItem); diff --git a/frontend/app/components/Dashboard/components/Alerts/AlertsList.tsx b/frontend/app/components/Dashboard/components/Alerts/AlertsList.tsx new file mode 100644 index 000000000..54cdf0a4f --- /dev/null +++ b/frontend/app/components/Dashboard/components/Alerts/AlertsList.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { NoContent, Pagination, Icon } from 'UI'; +import { filterList } from 'App/utils'; +import { sliceListPerPage } from 'App/utils'; +import { fetchList } from 'Duck/alerts'; +import { connect } from 'react-redux'; +import { fetchList as fetchWebhooks } from 'Duck/webhook'; +import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG'; +import AlertListItem from './AlertListItem' + +const pageSize = 10; + +interface Props { + fetchList: () => void; + list: any; + alertsSearch: any; + siteId: string; + webhooks: Array<any>; + init: (instance?: Alert) => void + fetchWebhooks: () => void; +} + +function AlertsList({ fetchList, list: alertsList, alertsSearch, siteId, init, fetchWebhooks, webhooks }: Props) { + React.useEffect(() => { fetchList(); fetchWebhooks() }, []); + + const alertsArray = alertsList.toJS(); + const [page, setPage] = React.useState(1); + + const filteredAlerts = filterList(alertsArray, alertsSearch, ['name'], (item, query) => query.test(item.query.left)) + const list = alertsSearch !== '' ? filteredAlerts : alertsArray; + const lenth = list.length; + + return ( + <NoContent + show={lenth === 0} + title={ + <div className="flex flex-col items-center justify-center"> + <AnimatedSVG name={ICONS.NO_ALERTS} size={80} /> + <div className="text-center text-gray-600 my-4"> + {alertsSearch !== '' ? 'No matching results' : "You haven't created any alerts yet"} + </div> + </div> + } + > + <div className="mt-3 border-b"> + <div className="grid grid-cols-12 py-2 font-medium px-6"> + <div className="col-span-8">Title</div> + <div className="col-span-2">Type</div> + <div className="col-span-2 text-right">Modified</div> + </div> + + {sliceListPerPage(list, page - 1, pageSize).map((alert: any) => ( + <React.Fragment key={alert.alertId}> + <AlertListItem alert={alert} siteId={siteId} init={init} webhooks={webhooks} /> + </React.Fragment> + ))} + </div> + + <div className="w-full flex items-center justify-between pt-4 px-6"> + <div className="text-disabled-text"> + Showing <span className="font-semibold">{Math.min(list.length, pageSize)}</span> out of{' '} + <span className="font-semibold">{list.length}</span> Alerts + </div> + <Pagination + page={page} + totalPages={Math.ceil(lenth / pageSize)} + onPageChange={(page) => setPage(page)} + limit={pageSize} + debounceRequest={100} + /> + </div> + </NoContent> + ); +} + +export default connect( + (state) => ({ + // @ts-ignore + list: state.getIn(['alerts', 'list']).sort((a, b) => b.createdAt - a.createdAt), + // @ts-ignore + alertsSearch: state.getIn(['alerts', 'alertsSearch']), + // @ts-ignore + webhooks: state.getIn(['webhooks', 'list']), + }), + { fetchList, fetchWebhooks } +)(AlertsList); diff --git a/frontend/app/components/Dashboard/components/Alerts/AlertsSearch.tsx b/frontend/app/components/Dashboard/components/Alerts/AlertsSearch.tsx new file mode 100644 index 000000000..0e4ffc5ef --- /dev/null +++ b/frontend/app/components/Dashboard/components/Alerts/AlertsSearch.tsx @@ -0,0 +1,45 @@ +import React, { useEffect, useState } from 'react'; +import { Icon } from 'UI'; +import { debounce } from 'App/utils'; +import { changeSearch } from 'Duck/alerts'; +import { connect } from 'react-redux'; + +let debounceUpdate: any = () => {}; + +interface Props { + changeSearch: (value: string) => void; +} + +function AlertsSearch({ changeSearch }: Props) { + const [inputValue, setInputValue] = useState(''); + + useEffect(() => { + debounceUpdate = debounce((value: string) => changeSearch(value), 500); + }, []); + + const write = ({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => { + setInputValue(value); + debounceUpdate(value); + }; + + return ( + <div className="relative"> + <Icon name="search" className="absolute top-0 bottom-0 ml-2 m-auto" size="16" /> + <input + value={inputValue} + name="alertsSearch" + className="bg-white p-2 border border-borderColor-gray-light-shade rounded w-full pl-10" + placeholder="Filter by title" + onChange={write} + /> + </div> + ); +} + +export default connect( + (state) => ({ + // @ts-ignore + alertsSearch: state.getIn(['alerts', 'alertsSearch']), + }), + { changeSearch } +)(AlertsSearch); diff --git a/frontend/app/components/Dashboard/components/Alerts/AlertsView.tsx b/frontend/app/components/Dashboard/components/Alerts/AlertsView.tsx new file mode 100644 index 000000000..277f13ab8 --- /dev/null +++ b/frontend/app/components/Dashboard/components/Alerts/AlertsView.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { Button, PageTitle, Icon, Link } from 'UI'; +import withPageTitle from 'HOCs/withPageTitle'; +import { connect } from 'react-redux'; +import { init } from 'Duck/alerts'; +import { withSiteId, alertCreate } from 'App/routes'; + +import AlertsList from './AlertsList'; +import AlertsSearch from './AlertsSearch'; + +interface IAlertsView { + siteId: string; + init: (instance?: Alert) => any; +} + +function AlertsView({ siteId, init }: IAlertsView) { + return ( + <div style={{ maxWidth: '1300px', margin: 'auto'}} className="bg-white rounded py-4 border"> + <div className="flex items-center mb-4 justify-between px-6"> + <div className="flex items-baseline mr-3"> + <PageTitle title="Alerts" /> + </div> + <div className="ml-auto flex items-center"> + <Link to={withSiteId(alertCreate(), siteId)}><Button variant="primary" onClick={null}>Create</Button></Link> + <div className="ml-4 w-1/4" style={{ minWidth: 300 }}> + <AlertsSearch /> + </div> + </div> + </div> + <div className="text-base text-disabled-text flex items-center px-6"> + <Icon name="info-circle-fill" className="mr-2" size={16} /> + Alerts helps your team stay up to date with the activity on your app. + </div> + <AlertsList siteId={siteId} init={init} /> + </div> + ); +} + +// @ts-ignore +const Container = connect(null, { init })(AlertsView); + +export default withPageTitle('Alerts - OpenReplay')(Container); diff --git a/frontend/app/components/Dashboard/components/Alerts/DropdownChips/DropdownChips.js b/frontend/app/components/Dashboard/components/Alerts/DropdownChips/DropdownChips.js new file mode 100644 index 000000000..1f805057d --- /dev/null +++ b/frontend/app/components/Dashboard/components/Alerts/DropdownChips/DropdownChips.js @@ -0,0 +1,66 @@ +import React from 'react'; +import { Input, TagBadge } from 'UI'; +import Select from 'Shared/Select'; + +const DropdownChips = ({ + textFiled = false, + validate = null, + placeholder = '', + selected = [], + options = [], + badgeClassName = 'lowercase', + onChange = () => null, + ...props +}) => { + const onRemove = (id) => { + onChange(selected.filter((i) => i !== id)); + }; + + const onSelect = ({ value }) => { + const newSlected = selected.concat(value.value); + onChange(newSlected); + }; + + const onKeyPress = (e) => { + const val = e.target.value; + if (e.key !== 'Enter' || selected.includes(val)) return; + e.preventDefault(); + e.stopPropagation(); + if (validate && !validate(val)) return; + + const newSlected = selected.concat(val); + e.target.value = ''; + onChange(newSlected); + }; + + const _options = options.filter((item) => !selected.includes(item.value)); + + const renderBadge = (item) => { + const val = typeof item === 'string' ? item : item.value; + const text = typeof item === 'string' ? item : item.label; + return <TagBadge className={badgeClassName} key={text} text={text} hashed={false} onRemove={() => onRemove(val)} outline={true} />; + }; + + return ( + <div className="w-full"> + {textFiled ? ( + <Input type="text" onKeyPress={onKeyPress} placeholder={placeholder} /> + ) : ( + <Select + placeholder={placeholder} + isSearchable={true} + options={_options} + name="webhookInput" + value={null} + onChange={onSelect} + {...props} + /> + )} + <div className="flex flex-wrap mt-3"> + {textFiled ? selected.map(renderBadge) : options.filter((i) => selected.includes(i.value)).map(renderBadge)} + </div> + </div> + ); +}; + +export default DropdownChips; diff --git a/frontend/app/components/Dashboard/components/Alerts/DropdownChips/index.js b/frontend/app/components/Dashboard/components/Alerts/DropdownChips/index.js new file mode 100644 index 000000000..9b4fbefff --- /dev/null +++ b/frontend/app/components/Dashboard/components/Alerts/DropdownChips/index.js @@ -0,0 +1 @@ +export { default } from './DropdownChips' \ No newline at end of file diff --git a/frontend/app/components/Dashboard/components/Alerts/NewAlert.tsx b/frontend/app/components/Dashboard/components/Alerts/NewAlert.tsx new file mode 100644 index 000000000..c44d1c31b --- /dev/null +++ b/frontend/app/components/Dashboard/components/Alerts/NewAlert.tsx @@ -0,0 +1,296 @@ +import React, { useEffect } from 'react'; +import { Form, SegmentSelection, Icon } from 'UI'; +import { connect } from 'react-redux'; +import { validateEmail } from 'App/validate'; +import { fetchTriggerOptions, init, edit, save, remove, fetchList } from 'Duck/alerts'; +import { confirm } from 'UI'; +import { toast } from 'react-toastify'; +import { SLACK, WEBHOOK } from 'App/constants/schedule'; +import { fetchList as fetchWebhooks } from 'Duck/webhook'; +import Breadcrumb from 'Shared/Breadcrumb'; +import { withSiteId, alerts } from 'App/routes'; +import { withRouter, RouteComponentProps } from 'react-router-dom'; + +import cn from 'classnames'; +import WidgetName from '../WidgetName'; +import BottomButtons from './AlertForm/BottomButtons'; +import NotifyHooks from './AlertForm/NotifyHooks'; +import AlertListItem from './AlertListItem'; +import Condition from './AlertForm/Condition'; + + +const Circle = ({ text }: { text: string }) => ( + <div style={{ left: -14, height: 26, width: 26 }} className="circle rounded-full bg-gray-light flex items-center justify-center absolute top-0"> + {text} + </div> +); + +interface ISection { + index: string; + title: string; + description?: string; + content: React.ReactNode; +} + +const Section = ({ index, title, description, content }: ISection) => ( + <div className="w-full border-l-2 last:border-l-borderColor-transparent"> + <div className="flex items-start relative"> + <Circle text={index} /> + <div className="ml-6"> + <span className="font-medium">{title}</span> + {description && <div className="text-sm color-gray-medium">{description}</div>} + </div> + </div> + + <div className="ml-6">{content}</div> + </div> +); + +interface IProps extends RouteComponentProps { + siteId: string; + instance: Alert; + slackChannels: any[]; + webhooks: any[]; + loading: boolean; + deleting: boolean; + triggerOptions: any[]; + list: any, + fetchTriggerOptions: () => void; + edit: (query: any) => void; + init: (alert?: Alert) => any; + save: (alert: Alert) => Promise<any>; + remove: (alertId: string) => Promise<any>; + onSubmit: (instance: Alert) => void; + fetchWebhooks: () => void; + fetchList: () => void; +} + +const NewAlert = (props: IProps) => { + const { + instance, + siteId, + webhooks, + loading, + deleting, + triggerOptions, + init, + edit, + save, + remove, + fetchWebhooks, + fetchList, + list, + } = props; + + useEffect(() => { + if (list.size === 0) fetchList(); + props.fetchTriggerOptions(); + fetchWebhooks(); + }, []); + + useEffect(() => { + if (list.size > 0) { + const alertId = location.pathname.split('/').pop() + const currentAlert = list.toJS().find((alert: Alert) => alert.alertId === parseInt(alertId, 10)); + init(currentAlert); + } + }, [list]) + + + const write = ({ target: { value, name } }: React.ChangeEvent<HTMLInputElement>) => + props.edit({ [name]: value }); + const writeOption = ( + _: React.ChangeEvent, + { name, value }: { name: string; value: Record<string, any> } + ) => props.edit({ [name]: value.value }); + const onChangeCheck = ({ target: { checked, name } }: React.ChangeEvent<HTMLInputElement>) => + props.edit({ [name]: checked }); + + const onDelete = async (instance: Alert) => { + if ( + await confirm({ + header: 'Confirm', + confirmButton: 'Yes, delete', + confirmation: `Are you sure you want to permanently delete this alert?`, + }) + ) { + remove(instance.alertId).then(() => { + props.history.push(withSiteId(alerts(), siteId)) + }); + } + }; + const onSave = (instance: Alert) => { + const wasUpdating = instance.exists(); + save(instance).then(() => { + if (!wasUpdating) { + toast.success('New alert saved'); + props.history.push(withSiteId(alerts(), siteId)) + } else { + toast.success('Alert updated'); + } + }); + }; + + const onClose = () => { + props.history.push(withSiteId(alerts(), siteId)) + } + + const slackChannels = webhooks + .filter((hook) => hook.type === SLACK) + .map(({ webhookId, name }) => ({ value: webhookId, label: name })) + // @ts-ignore + .toJS(); + const hooks = webhooks + .filter((hook) => hook.type === WEBHOOK) + .map(({ webhookId, name }) => ({ value: webhookId, label: name })) + // @ts-ignore + .toJS(); + + + + const writeQueryOption = ( + e: React.ChangeEvent, + { name, value }: { name: string; value: string } + ) => { + const { query } = instance; + props.edit({ query: { ...query, [name]: value } }); + }; + + const writeQuery = ({ target: { value, name } }: React.ChangeEvent<HTMLInputElement>) => { + const { query } = instance; + props.edit({ query: { ...query, [name]: value } }); + }; + + const metric = + instance && instance.query.left + ? triggerOptions.find((i) => i.value === instance.query.left) + : null; + const unit = metric ? metric.unit : ''; + const isThreshold = instance.detectionMethod === 'threshold'; + + return ( + <> + <Breadcrumb + items={[ + { + label: 'Alerts', + to: withSiteId('/alerts', siteId), + }, + { label: (instance && instance.name) || 'Alert' }, + ]} + /> + <Form + className="relative bg-white rounded border" + onSubmit={() => onSave(instance)} + id="alert-form" + > + <div + className={cn('px-6 py-4 flex justify-between items-center', + )} + > + <h1 className="mb-0 text-2xl mr-4 min-w-fit"> + <WidgetName name={instance.name} onUpdate={(name) => write({ target: { value: name, name: 'name' }} as any)} canEdit /> + </h1> + <div + className="text-gray-600 w-full cursor-pointer" + > + </div> + </div> + + <div className="px-6 pb-3 flex flex-col"> + <Section + index="1" + title={'Alert based on'} + content={ + <div className=""> + <SegmentSelection + outline + name="detectionMethod" + className="my-3 w-1/4" + onSelect={(e: any, { name, value }: any) => props.edit({ [name]: value })} + value={{ value: instance.detectionMethod }} + list={[ + { name: 'Threshold', value: 'threshold' }, + { name: 'Change', value: 'change' }, + ]} + /> + <div className="text-sm color-gray-medium"> + {isThreshold && + 'Eg. When Threshold is above 1ms over the past 15mins, notify me through Slack #foss-notifications.'} + {!isThreshold && + 'Eg. Alert me if % change of memory.avg is greater than 10% over the past 4 hours compared to the previous 4 hours.'} + </div> + <div className="my-4" /> + </div> + } + /> + <Section + index="2" + title="Condition" + content={ + <Condition + isThreshold={isThreshold} + writeOption={writeOption} + instance={instance} + triggerOptions={triggerOptions} + writeQueryOption={writeQueryOption} + writeQuery={writeQuery} + unit={unit} + /> + } + /> + <Section + index="3" + title="Notify Through" + description="You'll be noticed in app notifications. Additionally opt in to receive alerts on:" + content={ + <NotifyHooks + instance={instance} + onChangeCheck={onChangeCheck} + slackChannels={slackChannels} + validateEmail={validateEmail} + hooks={hooks} + edit={edit} + /> + } + /> + </div> + + <div className="flex items-center justify-between p-6 border-t"> + <BottomButtons + loading={loading} + instance={instance} + deleting={deleting} + onDelete={onDelete} + /> + </div> + + </Form> + + <div className="bg-white mt-4 border rounded mb-10"> + {instance && ( + <AlertListItem alert={instance} demo siteId="" init={() => null} webhooks={webhooks} /> + )} + </div> + </> + ); +}; + +export default withRouter(connect( + (state) => ({ + // @ts-ignore + instance: state.getIn(['alerts', 'instance']), + //@ts-ignore + list: state.getIn(['alerts', 'list']), + // @ts-ignore + triggerOptions: state.getIn(['alerts', 'triggerOptions']), + // @ts-ignore + loading: state.getIn(['alerts', 'saveRequest', 'loading']), + // @ts-ignore + deleting: state.getIn(['alerts', 'removeRequest', 'loading']), + // @ts-ignore + webhooks: state.getIn(['webhooks', 'list']), + }), + { fetchTriggerOptions, init, edit, save, remove, fetchWebhooks, fetchList } + // @ts-ignore +)(NewAlert)); diff --git a/frontend/app/components/Dashboard/components/Alerts/alertForm.module.css b/frontend/app/components/Dashboard/components/Alerts/alertForm.module.css new file mode 100644 index 000000000..9e41ffd94 --- /dev/null +++ b/frontend/app/components/Dashboard/components/Alerts/alertForm.module.css @@ -0,0 +1,27 @@ +.wrapper { + position: relative; +} + +.content { + height: calc(100vh - 102px); + overflow-y: auto; + + &::-webkit-scrollbar { + width: 2px; + } + + &::-webkit-scrollbar-thumb { + background: transparent; + } + &::-webkit-scrollbar-track { + background: transparent; + } + &:hover { + &::-webkit-scrollbar-track { + background: #f3f3f3; + } + &::-webkit-scrollbar-thumb { + background: $gray-medium; + } + } +} diff --git a/frontend/app/components/Dashboard/components/Alerts/index.tsx b/frontend/app/components/Dashboard/components/Alerts/index.tsx new file mode 100644 index 000000000..793c47aaf --- /dev/null +++ b/frontend/app/components/Dashboard/components/Alerts/index.tsx @@ -0,0 +1 @@ +export { default } from './AlertsView' diff --git a/frontend/app/components/Dashboard/components/Alerts/type.d.ts b/frontend/app/components/Dashboard/components/Alerts/type.d.ts new file mode 100644 index 000000000..6ac1a8f34 --- /dev/null +++ b/frontend/app/components/Dashboard/components/Alerts/type.d.ts @@ -0,0 +1,2 @@ +// TODO burn the immutable and make typing this possible +type Alert = Record<string, any> diff --git a/frontend/app/components/Dashboard/components/DashbaordListModal/DashbaordListModal.tsx b/frontend/app/components/Dashboard/components/DashbaordListModal/DashbaordListModal.tsx index d91d058b0..9b93b9942 100644 --- a/frontend/app/components/Dashboard/components/DashbaordListModal/DashbaordListModal.tsx +++ b/frontend/app/components/Dashboard/components/DashbaordListModal/DashbaordListModal.tsx @@ -36,7 +36,6 @@ function DashbaordListModal(props: Props) { leading = {( <div className="ml-2 flex items-center"> {item.isPublic && <div className="p-1"><Icon name="user-friends" color="gray-light" size="16" /></div>} - {item.isPinned && <div className="p-1"><Icon name="pin-fill" size="16" /></div>} </div> )} /> @@ -47,4 +46,4 @@ function DashbaordListModal(props: Props) { ); } -export default withRouter(DashbaordListModal); \ No newline at end of file +export default withRouter(DashbaordListModal); diff --git a/frontend/app/components/Dashboard/components/DashboardList/DashboardList.tsx b/frontend/app/components/Dashboard/components/DashboardList/DashboardList.tsx new file mode 100644 index 000000000..bdb9d6b7a --- /dev/null +++ b/frontend/app/components/Dashboard/components/DashboardList/DashboardList.tsx @@ -0,0 +1,70 @@ +import { observer } from 'mobx-react-lite'; +import React from 'react'; +import { NoContent, Pagination, Icon } from 'UI'; +import { useStore } from 'App/mstore'; +import { filterList } from 'App/utils'; +import { sliceListPerPage } from 'App/utils'; +import DashboardListItem from './DashboardListItem'; + +function DashboardList() { + const { dashboardStore } = useStore(); + const [shownDashboards, setDashboards] = React.useState([]); + const dashboards = dashboardStore.dashboards; + const dashboardsSearch = dashboardStore.dashboardsSearch; + + React.useEffect(() => { + setDashboards(filterList(dashboards, dashboardsSearch, ['name', 'owner', 'description'])); + }, [dashboardsSearch]); + + const list = dashboardsSearch !== '' ? shownDashboards : dashboards; + const lenth = list.length; + + return ( + <NoContent + show={lenth === 0} + title={ + <div className="flex flex-col items-center justify-center"> + <Icon name="no-dashboard" size={80} color="figmaColors-accent-secondary" /> + <div className="text-center text-gray-600 my-4"> + {dashboardsSearch !== '' + ? 'No matching results' + : "You haven't created any dashboards yet"} + </div> + </div> + } + > + <div className="mt-3 border-b"> + <div className="grid grid-cols-12 py-2 font-medium px-6"> + <div className="col-span-8">Title</div> + <div className="col-span-2">Visibility</div> + <div className="col-span-2 text-right">Created</div> + </div> + + {sliceListPerPage(list, dashboardStore.page - 1, dashboardStore.pageSize).map( + (dashboard: any) => ( + <React.Fragment key={dashboard.dashboardId}> + <DashboardListItem dashboard={dashboard} /> + </React.Fragment> + ) + )} + </div> + + <div className="w-full flex items-center justify-between pt-4 px-6"> + <div className="text-disabled-text"> + Showing{' '} + <span className="font-semibold">{Math.min(list.length, dashboardStore.pageSize)}</span>{' '} + out of <span className="font-semibold">{list.length}</span> Dashboards + </div> + <Pagination + page={dashboardStore.page} + totalPages={Math.ceil(lenth / dashboardStore.pageSize)} + onPageChange={(page) => dashboardStore.updateKey('page', page)} + limit={dashboardStore.pageSize} + debounceRequest={100} + /> + </div> + </NoContent> + ); +} + +export default observer(DashboardList); diff --git a/frontend/app/components/Dashboard/components/DashboardList/DashboardListItem.tsx b/frontend/app/components/Dashboard/components/DashboardList/DashboardListItem.tsx new file mode 100644 index 000000000..033878399 --- /dev/null +++ b/frontend/app/components/Dashboard/components/DashboardList/DashboardListItem.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { Icon } from 'UI'; +import { connect } from 'react-redux'; +import { IDashboard } from 'App/mstore/types/dashboard'; +import { checkForRecent } from 'App/date'; +import { withSiteId, dashboardSelected } from 'App/routes'; +import { useStore } from 'App/mstore'; +import { withRouter, RouteComponentProps } from 'react-router-dom'; + +interface Props extends RouteComponentProps { + dashboard: IDashboard; + siteId: string; +} + +function DashboardListItem(props: Props) { + const { dashboard, siteId, history } = props; + const { dashboardStore } = useStore(); + + const onItemClick = () => { + dashboardStore.selectDashboardById(dashboard.dashboardId); + const path = withSiteId(dashboardSelected(dashboard.dashboardId), siteId); + history.push(path); + }; + return ( + <div className="hover:bg-active-blue cursor-pointer border-t px-6" onClick={onItemClick}> + <div className="grid grid-cols-12 py-4 select-none"> + <div className="col-span-8 flex items-start"> + <div className="flex items-center capitalize-first"> + <div className="w-9 h-9 rounded-full bg-tealx-lightest flex items-center justify-center mr-2"> + <Icon name="columns-gap" size="16" color="tealx" /> + </div> + <div className="link capitalize-first">{dashboard.name}</div> + </div> + </div> + {/* <div><Label className="capitalize">{metric.metricType}</Label></div> */} + <div className="col-span-2"> + <div className="flex items-center"> + <Icon name={dashboard.isPublic ? 'user-friends' : 'person-fill'} className="mr-2" /> + <span>{dashboard.isPublic ? 'Team' : 'Private'}</span> + </div> + </div> + <div className="col-span-2 text-right">{checkForRecent(dashboard.createdAt, 'LLL dd, yyyy, hh:mm a')}</div> + </div> + {dashboard.description ? <div className="color-gray-medium px-2 pb-2">{dashboard.description}</div> : null} + </div> + ); +} +// @ts-ignore +export default connect((state) => ({ siteId: state.getIn(['site', 'siteId']) }))(withRouter(DashboardListItem)); diff --git a/frontend/app/components/Dashboard/components/DashboardList/DashboardSearch.tsx b/frontend/app/components/Dashboard/components/DashboardList/DashboardSearch.tsx new file mode 100644 index 000000000..a3b13f1d3 --- /dev/null +++ b/frontend/app/components/Dashboard/components/DashboardList/DashboardSearch.tsx @@ -0,0 +1,36 @@ +import React, { useEffect, useState } from 'react'; +import { observer } from 'mobx-react-lite'; +import { useStore } from 'App/mstore'; +import { Icon } from 'UI'; +import { debounce } from 'App/utils'; + +let debounceUpdate: any = () => {} + +function DashboardSearch() { + const { dashboardStore } = useStore(); + const [query, setQuery] = useState(dashboardStore.dashboardsSearch); + useEffect(() => { + debounceUpdate = debounce((key: string, value: any) => dashboardStore.updateKey(key, value), 500); + }, []) + + // @ts-ignore + const write = ({ target: { value } }) => { + setQuery(value); + debounceUpdate('dashboardsSearch', value); + } + + return ( + <div className="relative"> + <Icon name="search" className="absolute top-0 bottom-0 ml-2 m-auto" size="16" /> + <input + value={query} + name="dashboardsSearch" + className="bg-white p-2 border border-borderColor-gray-light-shade rounded w-full pl-10" + placeholder="Filter by title or description" + onChange={write} + /> + </div> + ); +} + +export default observer(DashboardSearch); diff --git a/frontend/app/components/Dashboard/components/DashboardList/DashboardsView.tsx b/frontend/app/components/Dashboard/components/DashboardList/DashboardsView.tsx new file mode 100644 index 000000000..11634632f --- /dev/null +++ b/frontend/app/components/Dashboard/components/DashboardList/DashboardsView.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { Button, PageTitle, Icon } from 'UI'; +import withPageTitle from 'HOCs/withPageTitle'; +import { useStore } from 'App/mstore'; +import { withSiteId } from 'App/routes'; + +import DashboardList from './DashboardList'; +import DashboardSearch from './DashboardSearch'; + +function DashboardsView({ history, siteId }: { history: any, siteId: string }) { + const { dashboardStore } = useStore(); + + const onAddDashboardClick = () => { + dashboardStore.initDashboard(); + dashboardStore + .save(dashboardStore.dashboardInstance) + .then(async (syncedDashboard) => { + dashboardStore.selectDashboardById(syncedDashboard.dashboardId); + history.push(withSiteId(`/dashboard/${syncedDashboard.dashboardId}`, siteId)) + }) + } + + return ( + <div style={{ maxWidth: '1300px', margin: 'auto'}} className="bg-white rounded py-4 border"> + <div className="flex items-center mb-4 justify-between px-6"> + <div className="flex items-baseline mr-3"> + <PageTitle title="Dashboards" /> + </div> + <div className="ml-auto flex items-center"> + <Button variant="primary" onClick={onAddDashboardClick}>Create</Button> + <div className="ml-4 w-1/4" style={{ minWidth: 300 }}> + <DashboardSearch /> + </div> + </div> + </div> + <div className="text-base text-disabled-text flex items-center px-6"> + <Icon name="info-circle-fill" className="mr-2" size={16} /> + A dashboard is a custom visualization using your OpenReplay data. + </div> + <DashboardList /> + </div> + ); +} + +export default withPageTitle('Dashboards - OpenReplay')(DashboardsView); diff --git a/frontend/app/components/Dashboard/components/DashboardList/index.ts b/frontend/app/components/Dashboard/components/DashboardList/index.ts new file mode 100644 index 000000000..61e485dc9 --- /dev/null +++ b/frontend/app/components/Dashboard/components/DashboardList/index.ts @@ -0,0 +1 @@ +export { default } from './DashboardsView'; diff --git a/frontend/app/components/Dashboard/components/DashboardMetricSelection/DashboardMetricSelection.tsx b/frontend/app/components/Dashboard/components/DashboardMetricSelection/DashboardMetricSelection.tsx index d612efe0b..67284b59c 100644 --- a/frontend/app/components/Dashboard/components/DashboardMetricSelection/DashboardMetricSelection.tsx +++ b/frontend/app/components/Dashboard/components/DashboardMetricSelection/DashboardMetricSelection.tsx @@ -6,16 +6,36 @@ import cn from 'classnames'; import { useStore } from 'App/mstore'; import { Loader } from 'UI'; -function WidgetCategoryItem({ category, isSelected, onClick, selectedWidgetIds }) { +interface IWiProps { + category: Record<string, any> + onClick: (category: Record<string, any>) => void + isSelected: boolean + selectedWidgetIds: string[] +} + +const ICONS: Record<string, string | null> = { + errors: 'errors-icon', + performance: 'performance-icon', + resources: 'resources-icon', + overview: null, + custom: null, + 'web vitals': 'web-vitals', +} + +export function WidgetCategoryItem({ category, isSelected, onClick, selectedWidgetIds }: IWiProps) { const selectedCategoryWidgetsCount = useObserver(() => { - return category.widgets.filter(widget => selectedWidgetIds.includes(widget.metricId)).length; + return category.widgets.filter((widget: any) => selectedWidgetIds.includes(widget.metricId)).length; }); return ( <div className={cn("rounded p-4 border cursor-pointer hover:bg-active-blue", { 'bg-active-blue border-blue':isSelected, 'bg-white': !isSelected })} onClick={() => onClick(category)} > - <div className="font-medium text-lg mb-2 capitalize">{category.name}</div> + <div className="font-medium text-lg mb-2 capitalize flex items-center"> + {/* @ts-ignore */} + {ICONS[category.name] && <Icon name={ICONS[category.name]} size={18} className="mr-2" />} + {category.name} + </div> <div className="mb-2 text-sm leading-tight">{category.description}</div> {selectedCategoryWidgetsCount > 0 && ( <div className="flex items-center"> diff --git a/frontend/app/components/Dashboard/components/DashboardModal/DashboardModal.tsx b/frontend/app/components/Dashboard/components/DashboardModal/DashboardModal.tsx index 8976483e2..421a936bb 100644 --- a/frontend/app/components/Dashboard/components/DashboardModal/DashboardModal.tsx +++ b/frontend/app/components/Dashboard/components/DashboardModal/DashboardModal.tsx @@ -3,23 +3,22 @@ import { useObserver } from 'mobx-react-lite'; import DashboardMetricSelection from '../DashboardMetricSelection'; import DashboardForm from '../DashboardForm'; import { Button } from 'UI'; -import { withRouter } from 'react-router-dom'; +import { withRouter, RouteComponentProps } from 'react-router-dom'; import { useStore } from 'App/mstore'; import { useModal } from 'App/components/Modal'; -import { dashboardMetricCreate, withSiteId, dashboardSelected } from 'App/routes'; +import { dashboardMetricCreate, withSiteId } from 'App/routes'; -interface Props { +interface Props extends RouteComponentProps { history: any siteId?: string dashboardId?: string onMetricAdd?: () => void; } -function DashboardModal(props) { +function DashboardModal(props: Props) { const { history, siteId, dashboardId } = props; const { dashboardStore } = useStore(); const selectedWidgetsCount = useObserver(() => dashboardStore.selectedWidgets.length); const { hideModal } = useModal(); - const loadingTemplates = useObserver(() => dashboardStore.loadingTemplates); const dashboard = useObserver(() => dashboardStore.dashboardInstance); const loading = useObserver(() => dashboardStore.isSaving); diff --git a/frontend/app/components/Dashboard/components/DashboardRouter/DashboardRouter.tsx b/frontend/app/components/Dashboard/components/DashboardRouter/DashboardRouter.tsx index 4df856619..a7c2d62fd 100644 --- a/frontend/app/components/Dashboard/components/DashboardRouter/DashboardRouter.tsx +++ b/frontend/app/components/Dashboard/components/DashboardRouter/DashboardRouter.tsx @@ -1,67 +1,89 @@ import React from 'react'; import { Switch, Route } from 'react-router'; -import { withRouter } from 'react-router-dom'; +import { withRouter, RouteComponentProps } from 'react-router-dom'; import { - metrics, - metricDetails, - metricDetailsSub, - dashboardSelected, - dashboardMetricCreate, - dashboardMetricDetails, - withSiteId, - dashboard, + metrics, + metricDetails, + metricDetailsSub, + dashboardSelected, + dashboardMetricCreate, + dashboardMetricDetails, + withSiteId, + dashboard, + alerts, + alertCreate, + alertEdit, } from 'App/routes'; import DashboardView from '../DashboardView'; import MetricsView from '../MetricsView'; import WidgetView from '../WidgetView'; import WidgetSubDetailsView from '../WidgetSubDetailsView'; +import DashboardsView from '../DashboardList'; +import Alerts from '../Alerts'; +import CreateAlert from '../Alerts/NewAlert' -function DashboardViewSelected({ siteId, dashboardId }) { - return ( - <DashboardView siteId={siteId} dashboardId={dashboardId} /> - ) +function DashboardViewSelected({ siteId, dashboardId }: { siteId: string; dashboardId: string }) { + return <DashboardView siteId={siteId} dashboardId={dashboardId} />; } -interface Props { - history: any - match: any +interface Props extends RouteComponentProps { + match: any; } + function DashboardRouter(props: Props) { - const { match: { params: { siteId, dashboardId, metricId } } } = props; - return ( - <div> - <Switch> - <Route exact strict path={withSiteId(metrics(), siteId)}> - <MetricsView siteId={siteId} /> - </Route> + const { + match: { + params: { siteId, dashboardId }, + }, + history, + } = props; - <Route exact strict path={withSiteId(metricDetails(), siteId)}> - <WidgetView siteId={siteId} {...props} /> - </Route> - - <Route exact strict path={withSiteId(metricDetailsSub(), siteId)}> - <WidgetSubDetailsView siteId={siteId} {...props} /> - </Route> + return ( + <div> + <Switch> + <Route exact strict path={withSiteId(metrics(), siteId)}> + <MetricsView siteId={siteId} /> + </Route> - <Route exact strict path={withSiteId(dashboard(), siteId)}> - <DashboardView siteId={siteId} dashboardId={dashboardId} /> - </Route> + <Route exact strict path={withSiteId(metricDetails(), siteId)}> + <WidgetView siteId={siteId} {...props} /> + </Route> - <Route exact strict path={withSiteId(dashboardMetricDetails(dashboardId), siteId)}> - <WidgetView siteId={siteId} {...props} /> - </Route> + <Route exact strict path={withSiteId(metricDetailsSub(), siteId)}> + <WidgetSubDetailsView siteId={siteId} {...props} /> + </Route> - <Route exact strict path={withSiteId(dashboardMetricCreate(dashboardId), siteId)}> - <WidgetView siteId={siteId} {...props} /> - </Route> + <Route exact path={withSiteId(dashboard(), siteId)}> + <DashboardsView siteId={siteId} history={history} /> + </Route> - <Route exact strict path={withSiteId(dashboardSelected(dashboardId), siteId)}> - <DashboardViewSelected siteId={siteId} dashboardId={dashboardId} /> - </Route> - </Switch> - </div> - ); + <Route exact strict path={withSiteId(dashboardMetricDetails(dashboardId), siteId)}> + <WidgetView siteId={siteId} {...props} /> + </Route> + + <Route exact strict path={withSiteId(dashboardMetricCreate(dashboardId), siteId)}> + <WidgetView siteId={siteId} {...props} /> + </Route> + + <Route exact strict path={withSiteId(dashboardSelected(dashboardId), siteId)}> + <DashboardViewSelected siteId={siteId} dashboardId={dashboardId} /> + </Route> + + <Route exact strict path={withSiteId(alerts(), siteId)}> + <Alerts siteId={siteId} /> + </Route> + + <Route exact strict path={withSiteId(alertCreate(), siteId)}> + <CreateAlert siteId={siteId} /> + </Route> + + <Route exact strict path={withSiteId(alertEdit(), siteId)}> + <CreateAlert siteId={siteId} {...props} /> + </Route> + </Switch> + </div> + ); } export default withRouter(DashboardRouter); diff --git a/frontend/app/components/Dashboard/components/DashboardSideMenu/DashboardSideMenu.tsx b/frontend/app/components/Dashboard/components/DashboardSideMenu/DashboardSideMenu.tsx index 00b462bbd..b7748d1a7 100644 --- a/frontend/app/components/Dashboard/components/DashboardSideMenu/DashboardSideMenu.tsx +++ b/frontend/app/components/Dashboard/components/DashboardSideMenu/DashboardSideMenu.tsx @@ -1,137 +1,60 @@ -//@ts-nocheck -import { useObserver } from 'mobx-react-lite'; import React from 'react'; -import { SideMenuitem, SideMenuHeader, Icon, Popup, Button } from 'UI'; -import { useStore } from 'App/mstore'; -import { withRouter } from 'react-router-dom'; -import { withSiteId, dashboardSelected, metrics } from 'App/routes'; -import { useModal } from 'App/components/Modal'; -import DashbaordListModal from '../DashbaordListModal'; -import DashboardModal from '../DashboardModal'; -import cn from 'classnames'; +import { SideMenuitem, SideMenuHeader } from 'UI'; +import { withRouter, RouteComponentProps } from 'react-router-dom'; +import { withSiteId, metrics, dashboard, alerts } from 'App/routes'; import { connect } from 'react-redux'; -import { compose } from 'redux' +import { compose } from 'redux'; import { setShowAlerts } from 'Duck/dashboard'; -// import stl from 'Shared/MainSearchBar/mainSearchBar.module.css'; -const SHOW_COUNT = 8; - -interface Props { - siteId: string - history: any - setShowAlerts: (show: boolean) => void +interface Props extends RouteComponentProps { + siteId: string; + history: any; + setShowAlerts: (show: boolean) => void; } -function DashboardSideMenu(props: RouteComponentProps<Props>) { - const { history, siteId, setShowAlerts } = props; - const { hideModal, showModal } = useModal(); - const { dashboardStore } = useStore(); - const dashboardId = useObserver(() => dashboardStore.selectedDashboard?.dashboardId); - const dashboardsPicked = useObserver(() => dashboardStore.dashboards.slice(0, SHOW_COUNT)); - const remainingDashboardsCount = dashboardStore.dashboards.length - SHOW_COUNT; - const isMetric = history.location.pathname.includes('metrics'); +function DashboardSideMenu(props: Props) { + const { history, siteId, setShowAlerts } = props; + const isMetric = history.location.pathname.includes('metrics'); + const isDashboards = history.location.pathname.includes('dashboard'); + const isAlerts = history.location.pathname.includes('alerts'); - const redirect = (path) => { - history.push(path); - } + const redirect = (path: string) => { + history.push(path); + }; - const onItemClick = (dashboard) => { - dashboardStore.selectDashboardById(dashboard.dashboardId); - const path = withSiteId(dashboardSelected(dashboard.dashboardId), parseInt(siteId)); - history.push(path); - }; - - const onAddDashboardClick = (e) => { - dashboardStore.initDashboard(); - showModal(<DashboardModal siteId={siteId} />, { right: true }) - } - - const togglePinned = (dashboard, e) => { - e.stopPropagation(); - dashboardStore.updatePinned(dashboard.dashboardId); - } - - return useObserver(() => ( - <div> - <SideMenuHeader - className="mb-4 flex items-center" - text="DASHBOARDS" - button={ - <Button onClick={onAddDashboardClick} variant="text-primary"> - <> - <Icon name="plus" size="16" color="main" /> - <span className="ml-1" style={{ textTransform: 'none' }}>Create</span> - </> - </Button> - } - /> - {dashboardsPicked.map((item: any) => ( - <SideMenuitem - key={ item.dashboardId } - active={item.dashboardId === dashboardId && !isMetric} - title={ item.name } - iconName={ item.icon } - onClick={() => onItemClick(item)} - className="group" - leading = {( - <div className="ml-2 flex items-center cursor-default"> - {item.isPublic && ( - <Popup delay={500} content="Visible to the team" hideOnClick> - <div className="p-1"><Icon name="user-friends" color="gray-light" size="16" /></div> - </Popup> - )} - {item.isPinned && <div className="p-1 pointer-events-none"><Icon name="pin-fill" size="16" /></div>} - {!item.isPinned && ( - <Popup - delay={500} - content="Set as default dashboard" - hideOnClick={true} - > - <div - className={cn("p-1 invisible group-hover:visible cursor-pointer")} - onClick={(e) => togglePinned(item, e)} - > - <Icon name="pin-fill" size="16" color="gray-light" /> - </div> - </Popup> - )} - </div> - )} - /> - ))} - <div> - {remainingDashboardsCount > 0 && ( - <div - className="my-2 py-2 color-teal cursor-pointer" - onClick={() => showModal(<DashbaordListModal siteId={siteId} />, {})} - > - {remainingDashboardsCount} More - </div> - )} - </div> - <div className="border-t w-full my-2" /> - <div className="w-full"> - <SideMenuitem - active={isMetric} - id="menu-manage-alerts" - title="Metrics" - iconName="bar-chart-line" - onClick={() => redirect(withSiteId(metrics(), siteId))} - /> - </div> - <div className="border-t w-full my-2" /> - <div className="my-3 w-full"> - <SideMenuitem - id="menu-manage-alerts" - title="Alerts" - iconName="bell-plus" - onClick={() => setShowAlerts(true)} - /> - </div> - </div> - )); + return ( + <div> + <SideMenuHeader className="mb-4 flex items-center" text="Preferences" /> + <div className="w-full"> + <SideMenuitem + active={isDashboards} + id="menu-manage-alerts" + title="Dashboards" + iconName="columns-gap" + onClick={() => redirect(withSiteId(dashboard(), siteId))} + /> + </div> + <div className="border-t w-full my-2" /> + <div className="w-full"> + <SideMenuitem + active={isMetric} + id="menu-manage-alerts" + title="Metrics" + iconName="bar-chart-line" + onClick={() => redirect(withSiteId(metrics(), siteId))} + /> + </div> + <div className="border-t w-full my-2" /> + <div className="w-full"> + <SideMenuitem + active={isAlerts} + id="menu-manage-alerts" + title="Alerts" + iconName="bell-plus" + onClick={() => redirect(withSiteId(alerts(), siteId))} + /> + </div> + </div> + ); } -export default compose( - withRouter, - connect(null, { setShowAlerts }), -)(DashboardSideMenu) as React.FunctionComponent<RouteComponentProps<Props>> +export default compose(withRouter, connect(null, { setShowAlerts }))(DashboardSideMenu); diff --git a/frontend/app/components/Dashboard/components/DashboardView/DashboardView.module.css b/frontend/app/components/Dashboard/components/DashboardView/DashboardView.module.css new file mode 100644 index 000000000..42045607f --- /dev/null +++ b/frontend/app/components/Dashboard/components/DashboardView/DashboardView.module.css @@ -0,0 +1,5 @@ +.tooltipContainer { + & > tippy-popper > tippy-tooltip { + padding: 0!important; + } +} diff --git a/frontend/app/components/Dashboard/components/DashboardView/DashboardView.tsx b/frontend/app/components/Dashboard/components/DashboardView/DashboardView.tsx index 108d961a5..470a43cb0 100644 --- a/frontend/app/components/Dashboard/components/DashboardView/DashboardView.tsx +++ b/frontend/app/components/Dashboard/components/DashboardView/DashboardView.tsx @@ -1,22 +1,23 @@ -import React, { useEffect } from "react"; -import { observer } from "mobx-react-lite"; -import { useStore } from "App/mstore"; -import { Button, PageTitle, Loader, NoContent } from "UI"; -import { withSiteId } from "App/routes"; -import withModal from "App/components/Modal/withModal"; -import DashboardWidgetGrid from "../DashboardWidgetGrid"; -import { confirm } from "UI"; -import { withRouter, RouteComponentProps } from "react-router-dom"; -import { useModal } from "App/components/Modal"; -import DashboardModal from "../DashboardModal"; -import DashboardEditModal from "../DashboardEditModal"; -import AlertFormModal from "App/components/Alerts/AlertFormModal"; -import withPageTitle from "HOCs/withPageTitle"; -import withReport from "App/components/hocs/withReport"; -import DashboardOptions from "../DashboardOptions"; -import SelectDateRange from "Shared/SelectDateRange"; -import DashboardIcon from "../../../../svg/dashboard-icn.svg"; -import { Tooltip } from "react-tippy"; +import React, { useEffect } from 'react'; +import { observer } from 'mobx-react-lite'; +import { useStore } from 'App/mstore'; +import { Button, PageTitle, Loader } from 'UI'; +import { withSiteId } from 'App/routes'; +import withModal from 'App/components/Modal/withModal'; +import DashboardWidgetGrid from '../DashboardWidgetGrid'; +import { confirm } from 'UI'; +import { withRouter, RouteComponentProps } from 'react-router-dom'; +import { useModal } from 'App/components/Modal'; +import DashboardModal from '../DashboardModal'; +import DashboardEditModal from '../DashboardEditModal'; +import AlertFormModal from 'App/components/Alerts/AlertFormModal'; +import withPageTitle from 'HOCs/withPageTitle'; +import withReport from 'App/components/hocs/withReport'; +import DashboardOptions from '../DashboardOptions'; +import SelectDateRange from 'Shared/SelectDateRange'; +import { Tooltip } from 'react-tippy'; +import Breadcrumb from 'Shared/Breadcrumb'; +import AddMetricContainer from '../DashboardWidgetGrid/AddMetricContainer'; interface IProps { siteId: string; @@ -29,63 +30,53 @@ type Props = IProps & RouteComponentProps; function DashboardView(props: Props) { const { siteId, dashboardId } = props; const { dashboardStore } = useStore(); + const { showModal } = useModal(); + const [focusTitle, setFocusedInput] = React.useState(true); const [showEditModal, setShowEditModal] = React.useState(false); - const { showModal } = useModal(); const showAlertModal = dashboardStore.showAlertModal; const loading = dashboardStore.fetchingDashboard; - const dashboards = dashboardStore.dashboards; const dashboard: any = dashboardStore.selectedDashboard; const period = dashboardStore.period; const queryParams = new URLSearchParams(props.location.search); + const trimQuery = () => { + if (!queryParams.has('modal')) return; + queryParams.delete('modal'); + props.history.replace({ + search: queryParams.toString(), + }); + }; + const pushQuery = () => { + if (!queryParams.has('modal')) props.history.push('?modal=addMetric'); + }; + + useEffect(() => { + if (queryParams.has('modal')) { + onAddWidgets(); + trimQuery(); + } + }, []); + + useEffect(() => { + const isExists = dashboardStore.getDashboardById(dashboardId); + if (!isExists) { + props.history.push(withSiteId(`/dashboard`, siteId)); + } + }, [dashboardId]); + useEffect(() => { if (!dashboard || !dashboard.dashboardId) return; dashboardStore.fetch(dashboard.dashboardId); }, [dashboard]); - const trimQuery = () => { - if (!queryParams.has("modal")) return; - queryParams.delete("modal"); - props.history.replace({ - search: queryParams.toString(), - }); - }; - const pushQuery = () => { - if (!queryParams.has("modal")) props.history.push("?modal=addMetric"); - }; - - useEffect(() => { - if (!dashboardId || (!dashboard && dashboardStore.dashboards.length > 0)) dashboardStore.selectDefaultDashboard(); - - if (queryParams.has("modal")) { - onAddWidgets(); - trimQuery(); - } - }, []); - useEffect(() => { - dashboardStore.selectDefaultDashboard(); - }, [siteId]) - const onAddWidgets = () => { dashboardStore.initDashboard(dashboard); - showModal( - <DashboardModal - siteId={siteId} - onMetricAdd={pushQuery} - dashboardId={dashboardId} - />, - { right: true } - ); + showModal(<DashboardModal siteId={siteId} onMetricAdd={pushQuery} dashboardId={dashboardId} />, { right: true }); }; - const onAddDashboardClick = () => { - dashboardStore.initDashboard(); - showModal(<DashboardModal siteId={siteId} />, { right: true }) - } - const onEdit = (isTitle: boolean) => { dashboardStore.initDashboard(dashboard); setFocusedInput(isTitle); @@ -95,141 +86,99 @@ function DashboardView(props: Props) { const onDelete = async () => { if ( await confirm({ - header: "Confirm", - confirmButton: "Yes, delete", + header: 'Confirm', + confirmButton: 'Yes, delete', confirmation: `Are you sure you want to permanently delete this Dashboard?`, }) ) { dashboardStore.deleteDashboard(dashboard).then(() => { - dashboardStore.selectDefaultDashboard().then( - ({ dashboardId }) => { - props.history.push( - withSiteId(`/dashboard/${dashboardId}`, siteId) - ); - }, - () => { - props.history.push(withSiteId("/dashboard", siteId)); - } - ); + props.history.push(withSiteId(`/dashboard`, siteId)); }); } }; + if (!dashboard) return null; + return ( <Loader loading={loading}> - <NoContent - show={ - dashboards.length === 0 || - !dashboard || - !dashboard.dashboardId - } - title={ - <div className="flex items-center justify-center flex-col"> - <object - style={{ width: "180px" }} - type="image/svg+xml" - data={DashboardIcon} - className="no-result-icon" - /> - <span> - Gather and analyze <br /> important metrics in one - place. - </span> - </div> - } - size="small" - subtext={ - <Button - variant="primary" - size="small" - onClick={onAddDashboardClick} - > - + Create Dashboard - </Button> - } - > - <div style={{ maxWidth: "1300px", margin: "auto" }}> - <DashboardEditModal - show={showEditModal} - closeHandler={() => setShowEditModal(false)} - focusTitle={focusTitle} - /> - <div className="flex items-center mb-4 justify-between"> - <div className="flex items-center" style={{ flex: 3 }}> - <PageTitle + <div style={{ maxWidth: '1300px', margin: 'auto' }}> + <DashboardEditModal show={showEditModal} closeHandler={() => setShowEditModal(false)} focusTitle={focusTitle} /> + <Breadcrumb + items={[ + { + label: 'Dashboards', + to: withSiteId('/dashboard', siteId), + }, + { label: (dashboard && dashboard.name) || '' }, + ]} + /> + <div className="flex items-center mb-2 justify-between"> + <div className="flex items-center" style={{ flex: 3 }}> + <PageTitle + title={ // @ts-ignore - title={ - <Tooltip - delay={100} - arrow - title="Double click to rename" + <Tooltip delay={100} arrow title="Double click to rename"> + {dashboard?.name} + </Tooltip> + } + onDoubleClick={() => onEdit(true)} + className="mr-3 select-none border-b border-b-borderColor-transparent hover:border-dotted hover:border-gray-medium cursor-pointer" + actionButton={ + /* @ts-ignore */ + <Tooltip + interactive + useContext + // @ts-ignore + theme="nopadding" + animation="none" + hideDelay={200} + duration={0} + distance={20} + html={<div style={{ padding: 0 }}><AddMetricContainer isPopup siteId={siteId} /></div>} > - {dashboard?.name} + <Button variant="primary"> + Add Metric + </Button> </Tooltip> - } - onDoubleClick={() => onEdit(true)} - className="mr-3 select-none hover:border-dotted hover:border-b border-gray-medium cursor-pointer" - actionButton={ - <Button - variant="primary" - onClick={onAddWidgets} - > - Add Metric - </Button> - } + } + /> + </div> + <div className="flex items-center" style={{ flex: 1, justifyContent: 'end' }}> + <div className="flex items-center flex-shrink-0 justify-end" style={{ width: '300px' }}> + <SelectDateRange + style={{ width: '300px' }} + period={period} + onChange={(period: any) => dashboardStore.setPeriod(period)} + right={true} /> </div> - <div - className="flex items-center" - style={{ flex: 1, justifyContent: "end" }} - > - <div - className="flex items-center flex-shrink-0 justify-end" - style={{ width: "300px" }} - > - <SelectDateRange - style={{ width: "300px" }} - period={period} - onChange={(period: any) => - dashboardStore.setPeriod(period) - } - right={true} - /> - </div> - <div className="mx-4" /> - <div className="flex items-center flex-shrink-0"> - <DashboardOptions - editHandler={onEdit} - deleteHandler={onDelete} - renderReport={props.renderReport} - isTitlePresent={!!dashboard?.description} - /> - </div> + <div className="mx-4" /> + <div className="flex items-center flex-shrink-0"> + <DashboardOptions + editHandler={onEdit} + deleteHandler={onDelete} + renderReport={props.renderReport} + isTitlePresent={!!dashboard?.description} + /> </div> </div> - <div> - <h2 className="my-4 font-normal color-gray-dark"> - {dashboard?.description} - </h2> - </div> - <DashboardWidgetGrid - siteId={siteId} - dashboardId={dashboardId} - onEditHandler={onAddWidgets} - id="report" - /> - <AlertFormModal - showModal={showAlertModal} - onClose={() => - dashboardStore.updateKey("showAlertModal", false) - } - /> </div> - </NoContent> + <div className="pb-4"> + {/* @ts-ignore */} + <Tooltip delay={100} arrow title="Double click to rename" className='w-fit !block'> + <h2 + className="my-2 font-normal w-fit text-disabled-text border-b border-b-borderColor-transparent hover:border-dotted hover:border-gray-medium cursor-pointer" + onDoubleClick={() => onEdit(false)} + > + {dashboard?.description || 'Describe the purpose of this dashboard'} + </h2> + </Tooltip> + </div> + <DashboardWidgetGrid siteId={siteId} dashboardId={dashboardId} onEditHandler={onAddWidgets} id="report" /> + <AlertFormModal showModal={showAlertModal} onClose={() => dashboardStore.updateKey('showAlertModal', false)} /> + </div> </Loader> ); } - -export default withPageTitle("Dashboards - OpenReplay")( - withReport(withRouter(withModal(observer(DashboardView)))) -); +// @ts-ignore +export default withPageTitle('Dashboards - OpenReplay')(withReport(withRouter(withModal(observer(DashboardView))))); diff --git a/frontend/app/components/Dashboard/components/DashboardWidgetGrid/AddMetric.tsx b/frontend/app/components/Dashboard/components/DashboardWidgetGrid/AddMetric.tsx new file mode 100644 index 000000000..b71fb1a81 --- /dev/null +++ b/frontend/app/components/Dashboard/components/DashboardWidgetGrid/AddMetric.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { observer } from 'mobx-react-lite'; +import { Button, Loader } from 'UI'; +import WidgetWrapper from 'App/components/Dashboard/components/WidgetWrapper'; +import { useStore } from 'App/mstore'; +import { useModal } from 'App/components/Modal'; +import { dashboardMetricCreate, withSiteId } from 'App/routes'; +import { withRouter, RouteComponentProps } from 'react-router-dom'; + +interface IProps extends RouteComponentProps { + siteId: string; + title: string; + description: string; +} + +function AddMetric({ history, siteId, title, description }: IProps) { + const [metrics, setMetrics] = React.useState<Record<string, any>[]>([]); + + const { dashboardStore } = useStore(); + const { hideModal } = useModal(); + + React.useEffect(() => { + dashboardStore?.fetchTemplates(true).then((cats: any[]) => { + const customMetrics = cats.find((category) => category.name === 'custom')?.widgets || []; + + setMetrics(customMetrics); + }); + }, []); + + const dashboard = dashboardStore.selectedDashboard; + const selectedWidgetIds = dashboardStore.selectedWidgets.map((widget: any) => widget.metricId); + const queryParams = new URLSearchParams(location.search); + + const onSave = () => { + if (selectedWidgetIds.length === 0) return; + dashboardStore + .save(dashboard) + .then(async (syncedDashboard: Record<string, any>) => { + if (dashboard.exists()) { + await dashboardStore.fetch(dashboard.dashboardId); + } + dashboardStore.selectDashboardById(syncedDashboard.dashboardId); + }) + .then(hideModal); + }; + + const onCreateNew = () => { + const path = withSiteId(dashboardMetricCreate(dashboard.dashboardId), siteId); + if (!queryParams.has('modal')) history.push('?modal=addMetric'); + history.push(path); + hideModal(); + }; + + return ( + <div style={{ maxWidth: '85vw', width: 1200 }}> + <div + className="border-l shadow h-screen" + style={{ backgroundColor: '#FAFAFA', zIndex: 999, width: '100%' }} + > + <div className="mb-6 pt-8 px-8 flex items-start justify-between"> + <div className="flex flex-col"> + <h1 className="text-2xl">{title}</h1> + <div className="text-disabled-text">{description}</div> + </div> + + <Button variant="text-primary" className="font-medium ml-2" onClick={onCreateNew}> + + Create New + </Button> + </div> + <Loader loading={dashboardStore.loadingTemplates}> + <div + className="grid h-full grid-cols-4 gap-4 px-8 items-start py-1" + style={{ + maxHeight: 'calc(100vh - 160px)', + overflowY: 'auto', + gridAutoRows: 'max-content', + }} + > + {metrics ? ( + metrics.map((metric: any) => ( + <WidgetWrapper + key={metric.metricId} + widget={metric} + active={selectedWidgetIds.includes(metric.metricId)} + isTemplate={true} + isWidget={metric.metricType === 'predefined'} + onClick={() => dashboardStore.toggleWidgetSelection(metric)} + /> + )) + ) : ( + <div>No custom metrics created.</div> + )} + </div> + </Loader> + + <div className="py-4 border-t px-8 bg-white w-full flex items-center justify-between"> + <div> + {'Selected '} + <span className="font-semibold">{selectedWidgetIds.length}</span> + {' out of '} + <span className="font-semibold">{metrics ? metrics.length : 0}</span> + </div> + <Button variant="primary" disabled={selectedWidgetIds.length === 0} onClick={onSave}> + Add Selected + </Button> + </div> + </div> + </div> + ); +} + +export default withRouter(observer(AddMetric)); diff --git a/frontend/app/components/Dashboard/components/DashboardWidgetGrid/AddMetricContainer.tsx b/frontend/app/components/Dashboard/components/DashboardWidgetGrid/AddMetricContainer.tsx new file mode 100644 index 000000000..b33cccf76 --- /dev/null +++ b/frontend/app/components/Dashboard/components/DashboardWidgetGrid/AddMetricContainer.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { observer } from 'mobx-react-lite'; +import { Icon } from 'UI'; +import { useModal } from 'App/components/Modal'; +import { useStore } from 'App/mstore'; +import AddMetric from './AddMetric'; +import AddPredefinedMetric from './AddPredefinedMetric'; +import cn from 'classnames'; + +interface AddMetricButtonProps { + iconName: string; + title: string; + description: string; + isPremade?: boolean; + isPopup?: boolean; + onClick: () => void; +} + +function AddMetricButton({ iconName, title, description, onClick, isPremade, isPopup }: AddMetricButtonProps) { + return ( + <div + onClick={onClick} + className={cn( + 'flex items-center hover:bg-gray-lightest group rounded border cursor-pointer', + isPremade ? 'bg-figmaColors-primary-outlined-hover-background hover:!border-tealx' : 'hover:!border-teal bg-figmaColors-secondary-outlined-hover-background', + isPopup ? 'p-4 z-50' : 'px-4 py-8 flex-col' + )} + style={{ borderColor: 'rgb(238, 238, 238)' }} + > + <div + className={cn( + 'p-6 my-3 rounded-full group-hover:bg-gray-light', + isPremade + ? 'bg-figmaColors-primary-outlined-hover-background fill-figmaColors-accent-secondary group-hover:!bg-figmaColors-accent-secondary group-hover:!fill-white' + : 'bg-figmaColors-secondary-outlined-hover-background fill-figmaColors-secondary-outlined-resting-border group-hover:!bg-teal group-hover:!fill-white' + )} + > + <Icon name={iconName} size={26} style={{ fill: 'inherit' }} /> + </div> + <div className={isPopup ? 'flex flex-col text-left ml-4' : 'flex flex-col text-center items-center'}> + <div className="font-bold text-base text-figmaColors-text-primary">{title}</div> + <div className={cn('text-disabled-test text-figmaColors-text-primary text-base', isPopup ? 'w-full' : 'mt-2 w-2/3 text-center')}> + {description} + </div> + </div> + </div> + ); +} + +function AddMetricContainer({ siteId, isPopup }: any) { + const { showModal } = useModal(); + const { dashboardStore } = useStore(); + + const onAddCustomMetrics = () => { + dashboardStore.initDashboard(dashboardStore.selectedDashboard); + showModal( + <AddMetric + siteId={siteId} + title="Custom Metrics" + description="Metrics that are manually created by you or your team." + />, + { right: true } + ); + }; + + const onAddPredefinedMetrics = () => { + dashboardStore.initDashboard(dashboardStore.selectedDashboard); + showModal( + <AddPredefinedMetric + siteId={siteId} + title="Ready-Made Metrics" + description="Curated metrics predfined by OpenReplay." + />, + { right: true } + ); + }; + + const classes = isPopup + ? 'bg-white border rounded p-4 grid grid-rows-2 gap-4' + : 'bg-white border border-dashed hover:!border-gray-medium rounded p-8 grid grid-cols-2 gap-8'; + return ( + <div style={{ borderColor: 'rgb(238, 238, 238)', height: isPopup ? undefined : 300 }} className={classes}> + <AddMetricButton + title="+ Add Custom Metric" + description="Metrics that are manually created by you or your team" + iconName="bar-pencil" + onClick={onAddCustomMetrics} + isPremade + isPopup={isPopup} + /> + <AddMetricButton + title="+ Add Ready-Made Metric" + description="Curated metrics predfined by OpenReplay." + iconName="grid-check" + onClick={onAddPredefinedMetrics} + isPopup={isPopup} + /> + </div> + ); +} + +export default observer(AddMetricContainer); diff --git a/frontend/app/components/Dashboard/components/DashboardWidgetGrid/AddPredefinedMetric.tsx b/frontend/app/components/Dashboard/components/DashboardWidgetGrid/AddPredefinedMetric.tsx new file mode 100644 index 000000000..4e95a2c6e --- /dev/null +++ b/frontend/app/components/Dashboard/components/DashboardWidgetGrid/AddPredefinedMetric.tsx @@ -0,0 +1,153 @@ +import React from 'react'; +import { observer } from 'mobx-react-lite'; +import { Button, Loader } from 'UI'; +import WidgetWrapper from 'App/components/Dashboard/components/WidgetWrapper'; +import { useStore } from 'App/mstore'; +import { useModal } from 'App/components/Modal'; +import { dashboardMetricCreate, withSiteId } from 'App/routes'; +import { withRouter, RouteComponentProps } from 'react-router-dom'; +import { WidgetCategoryItem } from 'App/components/Dashboard/components/DashboardMetricSelection/DashboardMetricSelection'; + +interface IProps extends RouteComponentProps { + siteId: string; + title: string; + description: string; +} + +function AddPredefinedMetric({ history, siteId, title, description }: IProps) { + const [categories, setCategories] = React.useState([]); + const { dashboardStore } = useStore(); + const { hideModal } = useModal(); + const [activeCategory, setActiveCategory] = React.useState<Record<string, any>>(); + + const scrollContainer = React.useRef<HTMLDivElement>(null); + + const dashboard = dashboardStore.selectedDashboard; + const selectedWidgetIds = dashboardStore.selectedWidgets.map((widget: any) => widget.metricId); + const queryParams = new URLSearchParams(location.search); + const totalMetricCount = categories.reduce((acc, category) => acc + category.widgets.length, 0); + + React.useEffect(() => { + dashboardStore?.fetchTemplates(true).then((categories: any[]) => { + const predefinedCategories = categories.filter((category) => category.name !== 'custom'); + const defaultCategory = predefinedCategories[0]; + setActiveCategory(defaultCategory); + setCategories(predefinedCategories); + }); + }, []); + + React.useEffect(() => { + if (scrollContainer.current) { + scrollContainer.current.scrollTop = 0; + } + }, [activeCategory, scrollContainer.current]); + + const handleWidgetCategoryClick = (category: any) => { + setActiveCategory(category); + }; + + const onSave = () => { + if (selectedWidgetIds.length === 0) return; + dashboardStore + .save(dashboard) + .then(async (syncedDashboard) => { + if (dashboard.exists()) { + await dashboardStore.fetch(dashboard.dashboardId); + } + dashboardStore.selectDashboardById(syncedDashboard.dashboardId); + }) + .then(hideModal); + }; + + const onCreateNew = () => { + const path = withSiteId(dashboardMetricCreate(dashboard.dashboardId), siteId); + if (!queryParams.has('modal')) history.push('?modal=addMetric'); + history.push(path); + hideModal(); + }; + + return ( + <div style={{ maxWidth: '85vw', width: 1200 }}> + <div + className="border-l shadow h-screen" + style={{ backgroundColor: '#FAFAFA', zIndex: 999, width: '100%' }} + > + <div className="mb-6 pt-8 px-8 flex items-start justify-between"> + <div className="flex flex-col"> + <h1 className="text-2xl">{title}</h1> + <div className="text-disabled-text">{description}</div> + </div> + + <Button variant="text-primary" className="font-medium ml-2" onClick={onCreateNew}> + + Create Custom Metric + </Button> + </div> + + <div className="flex px-8 h-full" style={{ maxHeight: 'calc(100vh - 160px)' }}> + <div style={{ flex: 3 }}> + <div + className="grid grid-cols-1 gap-4 py-1 pr-2" + style={{ + maxHeight: 'calc(100vh - 160px)', + overflowY: 'auto', + gridAutoRows: 'max-content', + }} + > + {activeCategory && + categories.map((category) => ( + <React.Fragment key={category.name}> + <WidgetCategoryItem + key={category.name} + onClick={handleWidgetCategoryClick} + category={category} + isSelected={activeCategory.name === category.name} + selectedWidgetIds={selectedWidgetIds} + /> + </React.Fragment> + ))} + </div> + </div> + <Loader loading={dashboardStore.loadingTemplates}> + <div + className="grid h-full grid-cols-4 gap-4 p-1 items-start" + style={{ + maxHeight: 'calc(100vh - 160px)', + overflowY: 'auto', + flex: 9, + gridAutoRows: 'max-content', + }} + > + {activeCategory && + activeCategory.widgets.map((metric: any) => ( + <React.Fragment key={metric.metricId}> + <WidgetWrapper + key={metric.metricId} + widget={metric} + active={selectedWidgetIds.includes(metric.metricId)} + isTemplate={true} + isWidget={metric.metricType === 'predefined'} + onClick={() => dashboardStore.toggleWidgetSelection(metric)} + /> + </React.Fragment> + ))} + </div> + </Loader> + </div> + + <div className="py-4 border-t px-8 bg-white w-full flex items-center justify-between"> + <div> + {'Selected '} + <span className="font-semibold">{selectedWidgetIds.length}</span> + {' out of '} + <span className="font-semibold">{totalMetricCount}</span> + </div> + <Button variant="primary" disabled={selectedWidgetIds.length === 0} onClick={onSave}> + Add Selected + </Button> + </div> + </div> + </div> + ); +} + +export default withRouter(observer(AddPredefinedMetric)); diff --git a/frontend/app/components/Dashboard/components/DashboardWidgetGrid/DashboardWidgetGrid.tsx b/frontend/app/components/Dashboard/components/DashboardWidgetGrid/DashboardWidgetGrid.tsx index 442ee46e6..5807e0c3d 100644 --- a/frontend/app/components/Dashboard/components/DashboardWidgetGrid/DashboardWidgetGrid.tsx +++ b/frontend/app/components/Dashboard/components/DashboardWidgetGrid/DashboardWidgetGrid.tsx @@ -1,8 +1,9 @@ import React from 'react'; import { useStore } from 'App/mstore'; import WidgetWrapper from '../WidgetWrapper'; -import { NoContent, Button, Loader } from 'UI'; +import { NoContent, Loader } from 'UI'; import { useObserver } from 'mobx-react-lite'; +import AddMetricContainer from './AddMetricContainer' interface Props { siteId: string, @@ -18,16 +19,14 @@ function DashboardWidgetGrid(props: Props) { const list: any = useObserver(() => dashboard?.widgets); return useObserver(() => ( + // @ts-ignore <Loader loading={loading}> <NoContent show={list.length === 0} icon="no-metrics-chart" - title="No metrics added to this dashboard" + title={<span className="text-2xl capitalize-first text-figmaColors-text-primary">Build your dashboard</span>} subtext={ - <div className="flex items-center justify-center flex-col"> - <p>Metrics helps you visualize trends from sessions captured by OpenReplay</p> - <Button variant="primary" onClick={props.onEditHandler}>Add Metric</Button> - </div> + <div className="w-4/5 m-auto mt-4"><AddMetricContainer siteId={siteId} /></div> } > <div className="grid gap-4 grid-cols-4 items-start pb-10" id={props.id}> @@ -42,6 +41,7 @@ function DashboardWidgetGrid(props: Props) { isWidget={true} /> ))} + <div className="col-span-2"><AddMetricContainer siteId={siteId} /></div> </div> </NoContent> </Loader> diff --git a/frontend/app/components/Dashboard/components/FilterSeries/SeriesName/SeriesName.tsx b/frontend/app/components/Dashboard/components/FilterSeries/SeriesName/SeriesName.tsx index 5d25e9de9..d6a69c73d 100644 --- a/frontend/app/components/Dashboard/components/FilterSeries/SeriesName/SeriesName.tsx +++ b/frontend/app/components/Dashboard/components/FilterSeries/SeriesName/SeriesName.tsx @@ -46,7 +46,7 @@ function SeriesName(props: Props) { onFocus={() => setEditing(true)} /> ) : ( - <div className="text-base h-8 flex items-center border-transparent">{name.trim() === '' ? 'Seriess ' + (seriesIndex + 1) : name }</div> + <div className="text-base h-8 flex items-center border-transparent">{name && name.trim() === '' ? 'Series ' + (seriesIndex + 1) : name }</div> )} <div className="ml-3 cursor-pointer" onClick={() => setEditing(true)}><Icon name="pencil" size="14" /></div> diff --git a/frontend/app/components/Dashboard/components/Funnels/FunnelIssues/FunnelIssues.tsx b/frontend/app/components/Dashboard/components/Funnels/FunnelIssues/FunnelIssues.tsx index 8471b51da..826e9a133 100644 --- a/frontend/app/components/Dashboard/components/Funnels/FunnelIssues/FunnelIssues.tsx +++ b/frontend/app/components/Dashboard/components/Funnels/FunnelIssues/FunnelIssues.tsx @@ -53,8 +53,8 @@ function FunnelIssues() { }, [stages.length, drillDownPeriod, filter.filters, depsString, metricStore.sessionsPage]); return useObserver(() => ( - <div className="my-8"> - <div className="flex justify-between"> + <div className="my-8 bg-white rounded p-4 border"> + <div className="flex"> <h1 className="font-medium text-2xl">Most significant issues <span className="font-normal">identified in this funnel</span></h1> </div> <div className="my-6 flex justify-between items-start"> @@ -70,4 +70,4 @@ function FunnelIssues() { )); } -export default FunnelIssues; \ No newline at end of file +export default FunnelIssues; diff --git a/frontend/app/components/Dashboard/components/Funnels/FunnelIssuesList/FunnelIssuesList.tsx b/frontend/app/components/Dashboard/components/Funnels/FunnelIssuesList/FunnelIssuesList.tsx index e0908c6f4..3894f4671 100644 --- a/frontend/app/components/Dashboard/components/Funnels/FunnelIssuesList/FunnelIssuesList.tsx +++ b/frontend/app/components/Dashboard/components/Funnels/FunnelIssuesList/FunnelIssuesList.tsx @@ -45,8 +45,8 @@ function FunnelIssuesList(props: RouteComponentProps<Props>) { show={!loading && filteredIssues.length === 0} title={ <div className="flex flex-col items-center justify-center"> - <AnimatedSVG name={ICONS.NO_RESULTS} size="170" /> - <div className="mt-6 text-2xl">No issues found</div> + <AnimatedSVG name={ICONS.NO_ISSUES} size="170" /> + <div className="mt-3 text-xl">No issues found</div> </div> } > @@ -59,4 +59,4 @@ function FunnelIssuesList(props: RouteComponentProps<Props>) { )) } -export default withRouter(FunnelIssuesList) as React.FunctionComponent<RouteComponentProps<Props>>; \ No newline at end of file +export default withRouter(FunnelIssuesList) as React.FunctionComponent<RouteComponentProps<Props>>; diff --git a/frontend/app/components/Dashboard/components/MetricListItem/MetricListItem.tsx b/frontend/app/components/Dashboard/components/MetricListItem/MetricListItem.tsx index 492a41bd5..7c8e228c3 100644 --- a/frontend/app/components/Dashboard/components/MetricListItem/MetricListItem.tsx +++ b/frontend/app/components/Dashboard/components/MetricListItem/MetricListItem.tsx @@ -1,43 +1,16 @@ import React from 'react'; -import { Icon, NoContent, Label, Link, Pagination, Popup } from 'UI'; -import { checkForRecent, formatDateTimeDefault, convertTimestampToUtcTimestamp } from 'App/date'; -import { getIcon } from 'react-toastify/dist/components'; +import { Icon, Link } from 'UI'; +import { checkForRecent } from 'App/date'; +import { Tooltip } from 'react-tippy' +import { withRouter, RouteComponentProps } from 'react-router-dom'; +import { withSiteId } from 'App/routes'; -interface Props { +interface Props extends RouteComponentProps { metric: any; -} - -function DashboardLink({ dashboards}: any) { - return ( - dashboards.map((dashboard: any) => ( - <React.Fragment key={dashboard.dashboardId}> - <Link to={`/dashboard/${dashboard.dashboardId}`}> - <div className="flex items-center mb-1 py-1"> - <div className="mr-2"> - <Icon name="circle-fill" size={4} color="gray-medium" /> - </div> - <span className="link leading-4 capitalize-first">{dashboard.name}</span> - </div> - </Link> - </React.Fragment> - )) - ); + siteId: string; } function MetricTypeIcon({ type }: any) { - const PopupWrapper = (props: any) => { - return ( - <Popup - content={<div className="capitalize">{type}</div>} - position="top center" - on="hover" - hideOnScroll={true} - > - {props.children} - </Popup> - ); - } - const getIcon = () => { switch (type) { case 'funnel': @@ -50,45 +23,47 @@ function MetricTypeIcon({ type }: any) { } return ( - <PopupWrapper> - <div className="w-8 h-8 rounded-full bg-tealx-lightest flex items-center justify-center mr-2"> - <Icon name={getIcon()} size="14" color="tealx" /> + <Tooltip + html={<div className="capitalize">{type}</div>} + position="top" + arrow + > + <div className="w-9 h-9 rounded-full bg-tealx-lightest flex items-center justify-center mr-2"> + <Icon name={getIcon()} size="16" color="tealx" /> </div> - </PopupWrapper> + </Tooltip> ) } -function MetricListItem(props: Props) { - const { metric } = props; - + +function MetricListItem(props: Props) { + const { metric, history, siteId } = props; + + const onItemClick = () => { + const path = withSiteId(`/metrics/${metric.metricId}`, siteId); + history.push(path); + }; return ( - <div className="grid grid-cols-12 p-3 border-t select-none"> - <div className="col-span-3 flex items-start"> + <div className="grid grid-cols-12 py-4 border-t select-none hover:bg-active-blue cursor-pointer px-6" onClick={onItemClick}> + <div className="col-span-4 flex items-start"> <div className="flex items-center"> - {/* <div className="w-8 h-8 rounded-full bg-tealx-lightest flex items-center justify-center mr-2"> - <Icon name={getIcon(metric.metricType)} size="14" color="tealx" /> - </div> */} <MetricTypeIcon type={metric.metricType} /> - <Link to={`/metrics/${metric.metricId}`} className="link capitalize-first"> + <div className="link capitalize-first"> {metric.name} - </Link> + </div> </div> </div> - {/* <div><Label className="capitalize">{metric.metricType}</Label></div> */} + <div className="col-span-4">{metric.owner}</div> <div className="col-span-2"> - <DashboardLink dashboards={metric.dashboards} /> - </div> - <div className="col-span-3">{metric.owner}</div> - <div> <div className="flex items-center"> <Icon name={metric.isPublic ? "user-friends" : "person-fill"} className="mr-2" /> <span>{metric.isPublic ? 'Team' : 'Private'}</span> </div> </div> - <div className="col-span-2">{metric.lastModified && checkForRecent(metric.lastModified, 'LLL dd, yyyy, hh:mm a')}</div> + <div className="col-span-2 text-right">{metric.lastModified && checkForRecent(metric.lastModified, 'LLL dd, yyyy, hh:mm a')}</div> </div> ); } -export default MetricListItem; +export default withRouter(MetricListItem); diff --git a/frontend/app/components/Dashboard/components/MetricsList/MetricsList.tsx b/frontend/app/components/Dashboard/components/MetricsList/MetricsList.tsx index 3cc6dff40..e6eb66b97 100644 --- a/frontend/app/components/Dashboard/components/MetricsList/MetricsList.tsx +++ b/frontend/app/components/Dashboard/components/MetricsList/MetricsList.tsx @@ -1,70 +1,74 @@ import { useObserver } from 'mobx-react-lite'; import React, { useEffect } from 'react'; -import { NoContent, Pagination } from 'UI'; +import { NoContent, Pagination, Icon } from 'UI'; import { useStore } from 'App/mstore'; -import { getRE } from 'App/utils'; +import { filterList } from 'App/utils'; import MetricListItem from '../MetricListItem'; import { sliceListPerPage } from 'App/utils'; -import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG'; +import { IWidget } from 'App/mstore/types/widget'; -interface Props { } -function MetricsList(props: Props) { - const { metricStore } = useStore(); - const metrics = useObserver(() => metricStore.metrics); - const metricsSearch = useObserver(() => metricStore.metricsSearch); - const filterList = (list) => { - const filterRE = getRE(metricsSearch, 'i'); - let _list = list.filter(w => { - const dashbaordNames = w.dashboards.map(d => d.name).join(' '); - return filterRE.test(w.name) || filterRE.test(w.metricType) || filterRE.test(w.owner) || filterRE.test(dashbaordNames); - }); - return _list - } - const list: any = metricsSearch !== '' ? filterList(metrics) : metrics; - const lenth = list.length; +function MetricsList({ siteId }: { siteId: string }) { + const { metricStore } = useStore(); + const metrics = useObserver(() => metricStore.metrics); + const metricsSearch = useObserver(() => metricStore.metricsSearch); - useEffect(() => { - metricStore.updateKey('sessionsPage', 1); - }, []) + const filterByDashboard = (item: IWidget, searchRE: RegExp) => { + const dashboardsStr = item.dashboards.map((d: any) => d.name).join(' '); + return searchRE.test(dashboardsStr); + }; + const list = + metricsSearch !== '' + ? filterList(metrics, metricsSearch, ['name', 'metricType', 'owner'], filterByDashboard) + : metrics; + const lenth = list.length; - return useObserver(() => ( - <NoContent - show={lenth === 0} - title={ - <div className="flex flex-col items-center justify-center"> - <AnimatedSVG name={ICONS.NO_RESULTS} size="170" /> - <div className="mt-6 text-2xl">No data available.</div> - </div> - } - > - <div className="mt-3 border rounded bg-white"> - <div className="grid grid-cols-12 p-3 font-medium"> - <div className="col-span-3">Metric</div> - {/* <div>Type</div> */} - <div className="col-span-2">Dashboards</div> - <div className="col-span-3">Owner</div> - <div>Visibility</div> - <div className="col-span-2">Last Modified</div> - </div> + useEffect(() => { + metricStore.updateKey('sessionsPage', 1); + }, []); - {sliceListPerPage(list, metricStore.page - 1, metricStore.pageSize).map((metric: any) => ( - <React.Fragment key={metric.metricId}> - <MetricListItem metric={metric} /> - </React.Fragment> - ))} - </div> + return useObserver(() => ( + <NoContent + show={lenth === 0} + title={ + <div className="flex flex-col items-center justify-center"> + <Icon name="no-metrics" size={80} color="figmaColors-accent-secondary" /> + <div className="text-center text-gray-600 my-4"> + {metricsSearch !== '' ? 'No matching results' : "You haven't created any metrics yet"} + </div> + </div> + } + > + <div className="mt-3 border-b rounded bg-white"> + <div className="grid grid-cols-12 py-2 font-medium px-6"> + <div className="col-span-4">Title</div> + <div className="col-span-4">Owner</div> + <div className="col-span-2">Visibility</div> + <div className="col-span-2 text-right">Last Modified</div> + </div> - <div className="w-full flex items-center justify-center py-6"> - <Pagination - page={metricStore.page} - totalPages={Math.ceil(lenth / metricStore.pageSize)} - onPageChange={(page) => metricStore.updateKey('page', page)} - limit={metricStore.pageSize} - debounceRequest={100} - /> - </div> - </NoContent> - )); + {sliceListPerPage(list, metricStore.page - 1, metricStore.pageSize).map((metric: any) => ( + <React.Fragment key={metric.metricId}> + <MetricListItem metric={metric} siteId={siteId} /> + </React.Fragment> + ))} + </div> + + <div className="w-full flex items-center justify-between pt-4 px-6"> + <div className="text-disabled-text"> + Showing{' '} + <span className="font-semibold">{Math.min(list.length, metricStore.pageSize)}</span> out + of <span className="font-semibold">{list.length}</span> metrics + </div> + <Pagination + page={metricStore.page} + totalPages={Math.ceil(lenth / metricStore.pageSize)} + onPageChange={(page) => metricStore.updateKey('page', page)} + limit={metricStore.pageSize} + debounceRequest={100} + /> + </div> + </NoContent> + )); } export default MetricsList; diff --git a/frontend/app/components/Dashboard/components/MetricsSearch/MetricsSearch.tsx b/frontend/app/components/Dashboard/components/MetricsSearch/MetricsSearch.tsx index 066598c8e..cf27661d9 100644 --- a/frontend/app/components/Dashboard/components/MetricsSearch/MetricsSearch.tsx +++ b/frontend/app/components/Dashboard/components/MetricsSearch/MetricsSearch.tsx @@ -12,7 +12,7 @@ function MetricsSearch(props) { debounceUpdate = debounce((key, value) => metricStore.updateKey(key, value), 500); }, []) - const write = ({ target: { name, value } }) => { + const write = ({ target: { value } }) => { setQuery(value); debounceUpdate('metricsSearch', value); } @@ -23,7 +23,7 @@ function MetricsSearch(props) { <input value={query} name="metricsSearch" - className="bg-white p-2 border rounded w-full pl-10" + className="bg-white p-2 border border-borderColor-gray-light-shade rounded w-full pl-10" placeholder="Filter by title, type, dashboard and owner" onChange={write} /> @@ -31,4 +31,4 @@ function MetricsSearch(props) { )); } -export default MetricsSearch; \ No newline at end of file +export default MetricsSearch; diff --git a/frontend/app/components/Dashboard/components/MetricsView/MetricsView.tsx b/frontend/app/components/Dashboard/components/MetricsView/MetricsView.tsx index a8c1d96c4..d5402fd2c 100644 --- a/frontend/app/components/Dashboard/components/MetricsView/MetricsView.tsx +++ b/frontend/app/components/Dashboard/components/MetricsView/MetricsView.tsx @@ -6,30 +6,33 @@ import MetricsSearch from '../MetricsSearch'; import { useStore } from 'App/mstore'; import { useObserver } from 'mobx-react-lite'; -interface Props{ - siteId: number; +interface Props { + siteId: string; } -function MetricsView(props: Props) { - const { siteId } = props; +function MetricsView({ siteId }: Props) { const { metricStore } = useStore(); - const metricsCount = useObserver(() => metricStore.metrics.length); React.useEffect(() => { metricStore.fetchList(); }, []); return useObserver(() => ( - <div style={{ maxWidth: '1300px', margin: 'auto'}}> - <div className="flex items-center mb-4 justify-between"> + <div style={{ maxWidth: '1300px', margin: 'auto'}} className="bg-white rounded py-4 border"> + <div className="flex items-center mb-4 justify-between px-6"> <div className="flex items-baseline mr-3"> <PageTitle title="Metrics" className="" /> - <span className="text-2xl color-gray-medium ml-2">{metricsCount}</span> </div> - <Link to={'/metrics/create'}><Button variant="primary">Create Metric</Button></Link> - <div className="ml-auto w-1/3"> - <MetricsSearch /> + <div className="ml-auto flex items-center"> + <Link to={'/metrics/create'}><Button variant="primary">Create</Button></Link> + <div className="ml-4 w-1/4" style={{ minWidth: 300 }}> + <MetricsSearch /> + </div> </div> </div> - <MetricsList /> + <div className="text-base text-disabled-text flex items-center px-6"> + <Icon name="info-circle-fill" className="mr-2" size={16} /> + Create custom Metrics to capture key interactions and track KPIs. + </div> + <MetricsList siteId={siteId} /> </div> )); } diff --git a/frontend/app/components/Dashboard/components/WidgetChart/WidgetChart.tsx b/frontend/app/components/Dashboard/components/WidgetChart/WidgetChart.tsx index 0c97b6692..67247a2d2 100644 --- a/frontend/app/components/Dashboard/components/WidgetChart/WidgetChart.tsx +++ b/frontend/app/components/Dashboard/components/WidgetChart/WidgetChart.tsx @@ -180,7 +180,7 @@ function WidgetChart(props: Props) { } return ( <Loader loading={loading} style={{ height: `${isOverviewWidget ? 100 : 240}px` }}> - {renderChart()} + <div style={{ minHeight: isOverviewWidget ? 100 : 240 }}>{renderChart()}</div> </Loader> ); } diff --git a/frontend/app/components/Dashboard/components/WidgetForm/WidgetForm.tsx b/frontend/app/components/Dashboard/components/WidgetForm/WidgetForm.tsx index 88e0a59b4..b613bd370 100644 --- a/frontend/app/components/Dashboard/components/WidgetForm/WidgetForm.tsx +++ b/frontend/app/components/Dashboard/components/WidgetForm/WidgetForm.tsx @@ -1,14 +1,13 @@ -import React, { useEffect, useState } from 'react'; +import React from 'react'; import { metricTypes, metricOf, issueOptions } from 'App/constants/filterOptions'; import { FilterKey } from 'Types/filter/filterType'; import { useStore } from 'App/mstore'; import { useObserver } from 'mobx-react-lite'; -import { Button, Icon } from 'UI' +import { Button, Icon, SegmentSelection } from 'UI' import FilterSeries from '../FilterSeries'; import { confirm, Popup } from 'UI'; import Select from 'Shared/Select' import { withSiteId, dashboardMetricDetails, metricDetails } from 'App/routes' -import DashboardSelectionModal from '../DashboardSelectionModal/DashboardSelectionModal'; interface Props { history: any; @@ -16,9 +15,15 @@ interface Props { onDelete: () => void; } +const metricIcons = { + timeseries: 'graph-up', + table: 'table', + funnel: 'funnel', +} + function WidgetForm(props: Props) { - const [showDashboardSelectionModal, setShowDashboardSelectionModal] = useState(false); - const { history, match: { params: { siteId, dashboardId, metricId } } } = props; + + const { history, match: { params: { siteId, dashboardId } } } = props; const { metricStore, dashboardStore } = useStore(); const dashboards = dashboardStore.dashboards; const isSaving = useObserver(() => metricStore.isSaving); @@ -65,13 +70,17 @@ function WidgetForm(props: Props) { metricStore.merge(obj); }; + const onSelect = (_: any, option: Record<string, any>) => writeOption({ value: { value: option.value }, name: option.name}) + const onSave = () => { const wasCreating = !metric.exists() metricStore.save(metric, dashboardId) .then((metric: any) => { if (wasCreating) { if (parseInt(dashboardId) > 0) { - history.replace(withSiteId(dashboardMetricDetails(parseInt(dashboardId), metric.metricId), siteId)); + history.replace(withSiteId(dashboardMetricDetails(dashboardId, metric.metricId), siteId)); + const dashboard = dashboardStore.getDashboard(parseInt(dashboardId)) + dashboardStore.addWidgetToDashboard(dashboard, [metric.metricId]) } else { history.replace(withSiteId(metricDetails(metric.metricId), siteId)); } @@ -94,11 +103,15 @@ function WidgetForm(props: Props) { <div className="form-group"> <label className="font-medium">Metric Type</label> <div className="flex items-center"> - <Select + <SegmentSelection + icons + outline name="metricType" - options={metricTypes} - value={metricTypes.find((i: any) => i.value === metric.metricType) || metricTypes[0]} - onChange={ writeOption } + className="my-3" + onSelect={ onSelect } + value={metricTypes.find((i) => i.value === metric.metricType) || metricTypes[0]} + // @ts-ignore + list={metricTypes.map((i) => ({ value: i.value, name: i.label, icon: metricIcons[i.value] }))} /> {metric.metricType === 'timeseries' && ( @@ -169,7 +182,7 @@ function WidgetForm(props: Props) { </div> {metric.series.length > 0 && metric.series.slice(0, (isTable || isFunnel) ? 1 : metric.series.length).map((series: any, index: number) => ( - <div className="mb-2"> + <div className="mb-2" key={series.name}> <FilterSeries observeChanges={() => metric.updateKey('hasChanged', true)} hideHeader={ isTable } @@ -201,31 +214,13 @@ function WidgetForm(props: Props) { </Popup> <div className="flex items-center"> {metric.exists() && ( - <> - <Button variant="text-primary" onClick={onDelete}> - <Icon name="trash" size="14" className="mr-2" color="teal"/> - Delete - </Button> - <Button - variant="text-primary" - className="ml-2" - onClick={() => setShowDashboardSelectionModal(true)} - disabled={!canAddToDashboard} - > - <Icon name="columns-gap" size="14" className="mr-2" color="teal"/> - Add to Dashboard - </Button> - </> + <Button variant="text-primary" onClick={onDelete}> + <Icon name="trash" size="14" className="mr-2" color="teal"/> + Delete + </Button> )} </div> </div> - { canAddToDashboard && ( - <DashboardSelectionModal - metricId={metric.metricId} - show={showDashboardSelectionModal} - closeHandler={() => setShowDashboardSelectionModal(false)} - /> - )} </div> )); } diff --git a/frontend/app/components/Dashboard/components/WidgetName/WidgetName.tsx b/frontend/app/components/Dashboard/components/WidgetName/WidgetName.tsx index 67be4930d..998aaece8 100644 --- a/frontend/app/components/Dashboard/components/WidgetName/WidgetName.tsx +++ b/frontend/app/components/Dashboard/components/WidgetName/WidgetName.tsx @@ -22,7 +22,7 @@ function WidgetName(props: Props) { const onBlur = (nameInput?: string) => { setEditing(false) const toUpdate = nameInput || name - props.onUpdate(toUpdate.trim() === '' ? 'New Widget' : toUpdate) + props.onUpdate(toUpdate && toUpdate.trim() === '' ? 'New Widget' : toUpdate) } useEffect(() => { @@ -68,7 +68,12 @@ function WidgetName(props: Props) { <Tooltip delay={100} arrow title="Double click to rename" disabled={!canEdit}> <div onDoubleClick={() => setEditing(true)} - className={cn("text-2xl h-8 flex items-center border-transparent", canEdit && 'cursor-pointer select-none hover:border-dotted hover:border-b border-gray-medium')} + className={ + cn( + "text-2xl h-8 flex items-center border-transparent", + canEdit && 'cursor-pointer select-none border-b border-b-borderColor-transparent hover:border-dotted hover:border-gray-medium' + ) + } > { name } </div> diff --git a/frontend/app/components/Dashboard/components/WidgetPreview/WidgetPreview.tsx b/frontend/app/components/Dashboard/components/WidgetPreview/WidgetPreview.tsx index bde05f398..bea850d11 100644 --- a/frontend/app/components/Dashboard/components/WidgetPreview/WidgetPreview.tsx +++ b/frontend/app/components/Dashboard/components/WidgetPreview/WidgetPreview.tsx @@ -2,19 +2,22 @@ import React from 'react'; import cn from 'classnames'; import WidgetWrapper from '../WidgetWrapper'; import { useStore } from 'App/mstore'; -import { SegmentSelection } from 'UI'; +import { SegmentSelection, Button, Icon } from 'UI'; import { useObserver } from 'mobx-react-lite'; -import SelectDateRange from 'Shared/SelectDateRange'; import { FilterKey } from 'Types/filter/filterType'; import WidgetDateRange from '../WidgetDateRange/WidgetDateRange'; // import Period, { LAST_24_HOURS, LAST_30_DAYS } from 'Types/app/period'; +import DashboardSelectionModal from '../DashboardSelectionModal/DashboardSelectionModal'; interface Props { className?: string; + name: string; } function WidgetPreview(props: Props) { + const [showDashboardSelectionModal, setShowDashboardSelectionModal] = React.useState(false); const { className = '' } = props; const { metricStore, dashboardStore } = useStore(); + const dashboards = dashboardStore.dashboards; const metric: any = useObserver(() => metricStore.instance); const isTimeSeries = metric.metricType === 'timeseries'; const isTable = metric.metricType === 'table'; @@ -35,29 +38,14 @@ function WidgetPreview(props: Props) { // }) // } - const getWidgetTitle = () => { - if (isTimeSeries) { - return 'Time Series'; - } else if (isTable) { - if (metric.metricOf === FilterKey.SESSIONS) { - // return 'Table of Sessions'; - return <div>Sessions <span className="color-gray-medium">{metric.data.total}</span></div>; - } else if (metric.metricOf === FilterKey.ERRORS) { - // return 'Table of Errors'; - return <div>Errors <span className="color-gray-medium">{metric.data.total}</span></div>; - } else { - return 'Table'; - } - } else if (metric.metricType === 'funnel') { - return 'Funnel'; - } - } + const canAddToDashboard = metric.exists() && dashboards.length > 0; return useObserver(() => ( - <div className={cn(className)}> - <div className="flex items-center justify-between mb-2"> + <> + <div className={cn(className, 'bg-white rounded border')}> + <div className="flex items-center justify-between px-4 pt-2"> <h2 className="text-2xl"> - {getWidgetTitle()} + {props.name} </h2> <div className="flex items-center"> {isTimeSeries && ( @@ -78,7 +66,7 @@ function WidgetPreview(props: Props) { </> )} - {isTable && ( + {!disableVisualization && isTable && ( <> <span className="mr-4 color-gray-medium">Visualization</span> <SegmentSelection @@ -92,20 +80,39 @@ function WidgetPreview(props: Props) { { value: 'table', name: 'Table', icon: 'table' }, { value: 'pieChart', name: 'Chart', icon: 'pie-chart-fill' }, ]} - disabled={disableVisualization} disabledMessage="Chart view is not supported" /> </> )} <div className="mx-4" /> <WidgetDateRange /> + {/* add to dashboard */} + {metric.exists() && ( + <Button + variant="text-primary" + className="ml-2 p-0" + onClick={() => setShowDashboardSelectionModal(true)} + disabled={!canAddToDashboard} + > + <Icon name="columns-gap-filled" size="14" className="mr-2" color="teal"/> + Add to Dashboard + </Button> + )} </div> </div> - <div className="bg-white rounded p-4"> - <WidgetWrapper widget={metric} isPreview={true} isWidget={false} /> + <div className="p-4 pt-0"> + <WidgetWrapper widget={metric} isPreview={true} isWidget={false} hideName /> </div> </div> + { canAddToDashboard && ( + <DashboardSelectionModal + metricId={metric.metricId} + show={showDashboardSelectionModal} + closeHandler={() => setShowDashboardSelectionModal(false)} + /> + )} + </> )); } -export default WidgetPreview; \ No newline at end of file +export default WidgetPreview; diff --git a/frontend/app/components/Dashboard/components/WidgetSessions/WidgetSessions.tsx b/frontend/app/components/Dashboard/components/WidgetSessions/WidgetSessions.tsx index 66a4654e3..d3a092b49 100644 --- a/frontend/app/components/Dashboard/components/WidgetSessions/WidgetSessions.tsx +++ b/frontend/app/components/Dashboard/components/WidgetSessions/WidgetSessions.tsx @@ -1,21 +1,22 @@ -import React, { useEffect, useState } from "react"; -import { NoContent, Loader, Pagination } from "UI"; -import Select from "Shared/Select"; -import cn from "classnames"; -import { useStore } from "App/mstore"; -import SessionItem from "Shared/SessionItem"; -import { observer, useObserver } from "mobx-react-lite"; -import { DateTime } from "luxon"; -import { debounce } from "App/utils"; -import useIsMounted from "App/hooks/useIsMounted"; -import AnimatedSVG, { ICONS } from "Shared/AnimatedSVG/AnimatedSVG"; +import React, { useEffect, useState } from 'react'; +import { NoContent, Loader, Pagination } from 'UI'; +import Select from 'Shared/Select'; +import cn from 'classnames'; +import { useStore } from 'App/mstore'; +import SessionItem from 'Shared/SessionItem'; +import { observer, useObserver } from 'mobx-react-lite'; +import { DateTime } from 'luxon'; +import { debounce } from 'App/utils'; +import useIsMounted from 'App/hooks/useIsMounted'; +import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG'; +import { numberWithCommas } from 'App/utils'; interface Props { className?: string; } function WidgetSessions(props: Props) { - const { className = "" } = props; - const [activeSeries, setActiveSeries] = useState("all"); + const { className = '' } = props; + const [activeSeries, setActiveSeries] = useState('all'); const [data, setData] = useState<any>([]); const isMounted = useIsMounted(); const [loading, setLoading] = useState(false); @@ -23,15 +24,9 @@ function WidgetSessions(props: Props) { const { dashboardStore, metricStore } = useStore(); const filter = useObserver(() => dashboardStore.drillDownFilter); const widget: any = useObserver(() => metricStore.instance); - const startTime = DateTime.fromMillis(filter.startTimestamp).toFormat( - "LLL dd, yyyy HH:mm" - ); - const endTime = DateTime.fromMillis(filter.endTimestamp).toFormat( - "LLL dd, yyyy HH:mm" - ); - const [seriesOptions, setSeriesOptions] = useState([ - { label: "All", value: "all" }, - ]); + const startTime = DateTime.fromMillis(filter.startTimestamp).toFormat('LLL dd, yyyy HH:mm'); + const endTime = DateTime.fromMillis(filter.endTimestamp).toFormat('LLL dd, yyyy HH:mm'); + const [seriesOptions, setSeriesOptions] = useState([{ label: 'All', value: 'all' }]); const writeOption = ({ value }: any) => setActiveSeries(value.value); useEffect(() => { @@ -40,7 +35,7 @@ function WidgetSessions(props: Props) { label: item.seriesName, value: item.seriesId, })); - setSeriesOptions([{ label: "All", value: "all" }, ...seriesOptions]); + setSeriesOptions([{ label: 'All', value: 'all' }, ...seriesOptions]); }, [data]); const fetchSessions = (metricId: any, filter: any) => { @@ -55,10 +50,7 @@ function WidgetSessions(props: Props) { setLoading(false); }); }; - const debounceRequest: any = React.useCallback( - debounce(fetchSessions, 1000), - [] - ); + const debounceRequest: any = React.useCallback(debounce(fetchSessions, 1000), []); const depsString = JSON.stringify(widget.series); useEffect(() => { @@ -68,58 +60,35 @@ function WidgetSessions(props: Props) { page: metricStore.sessionsPage, limit: metricStore.sessionsPageSize, }); - }, [ - filter.startTimestamp, - filter.endTimestamp, - filter.filters, - depsString, - metricStore.sessionsPage, - ]); + }, [filter.startTimestamp, filter.endTimestamp, filter.filters, depsString, metricStore.sessionsPage]); return useObserver(() => ( - <div className={cn(className)}> + <div className={cn(className, "bg-white p-3 pb-0 rounded border")}> <div className="flex items-center justify-between"> <div className="flex items-baseline"> <h2 className="text-2xl">Sessions</h2> <div className="ml-2 color-gray-medium"> - between{" "} - <span className="font-medium color-gray-darkest"> - {startTime} - </span>{" "} - and{" "} - <span className="font-medium color-gray-darkest"> - {endTime} - </span>{" "} + between <span className="font-medium color-gray-darkest">{startTime}</span> and{' '} + <span className="font-medium color-gray-darkest">{endTime}</span>{' '} </div> </div> - {widget.metricType !== "table" && ( + {widget.metricType !== 'table' && ( <div className="flex items-center ml-6"> - <span className="mr-2 color-gray-medium"> - Filter by Series - </span> - <Select - options={seriesOptions} - defaultValue={"all"} - onChange={writeOption} - plain - /> + <span className="mr-2 color-gray-medium">Filter by Series</span> + <Select options={seriesOptions} defaultValue={'all'} onChange={writeOption} plain /> </div> )} </div> - <div className="mt-3 bg-white p-3 rounded border"> + <div className="mt-3"> <Loader loading={loading}> <NoContent title={ - <div className="flex flex-col items-center justify-center"> - <AnimatedSVG - name={ICONS.NO_RESULTS} - size="170" - /> - <div className="mt-6 text-2xl"> - No recordings found - </div> + <div className="flex items-center justify-center flex-col"> + <AnimatedSVG name={ICONS.NO_SESSIONS} size={170} /> + <div className="mt-2" /> + <div className="text-center text-gray-600">No relevant sessions found for the selected time period.</div> </div> } show={filteredSessions.sessions.length === 0} @@ -131,16 +100,16 @@ function WidgetSessions(props: Props) { </React.Fragment> ))} - <div className="w-full flex items-center justify-center py-6"> + <div className="flex items-center justify-between p-5"> + <div> + Showing <span className="font-medium">{(metricStore.sessionsPage - 1) * metricStore.sessionsPageSize + 1}</span> to{' '} + <span className="font-medium">{(metricStore.sessionsPage - 1) * metricStore.sessionsPageSize + filteredSessions.sessions.length}</span> of{' '} + <span className="font-medium">{numberWithCommas(filteredSessions.total)}</span> sessions. + </div> <Pagination page={metricStore.sessionsPage} - totalPages={Math.ceil( - filteredSessions.total / - metricStore.sessionsPageSize - )} - onPageChange={(page: any) => - metricStore.updateKey("sessionsPage", page) - } + totalPages={Math.ceil(filteredSessions.total / metricStore.sessionsPageSize)} + onPageChange={(page: any) => metricStore.updateKey('sessionsPage', page)} limit={metricStore.sessionsPageSize} debounceRequest={500} /> @@ -155,13 +124,9 @@ function WidgetSessions(props: Props) { const getListSessionsBySeries = (data: any, seriesId: any) => { const arr: any = { sessions: [], total: 0 }; data.forEach((element: any) => { - if (seriesId === "all") { + if (seriesId === 'all') { const sessionIds = arr.sessions.map((i: any) => i.sessionId); - arr.sessions.push( - ...element.sessions.filter( - (i: any) => !sessionIds.includes(i.sessionId) - ) - ); + arr.sessions.push(...element.sessions.filter((i: any) => !sessionIds.includes(i.sessionId))); arr.total = element.total; } else { if (element.seriesId === seriesId) { diff --git a/frontend/app/components/Dashboard/components/WidgetView/WidgetView.tsx b/frontend/app/components/Dashboard/components/WidgetView/WidgetView.tsx index 3ea5dda5d..3b8d8f246 100644 --- a/frontend/app/components/Dashboard/components/WidgetView/WidgetView.tsx +++ b/frontend/app/components/Dashboard/components/WidgetView/WidgetView.tsx @@ -43,7 +43,7 @@ function WidgetView(props: Props) { setMetricNotFound(true); } }); - } else if (metricId === 'create') { + } else { metricStore.init(); } }, []); @@ -109,7 +109,7 @@ function WidgetView(props: Props) { {expanded && <WidgetForm onDelete={onBackHandler} {...props} />} </div> - <WidgetPreview className="mt-8" /> + <WidgetPreview className="mt-8" name={widget.name} /> {widget.metricOf !== FilterKey.SESSIONS && widget.metricOf !== FilterKey.ERRORS && ( <> {(widget.metricType === 'table' || widget.metricType === 'timeseries') && <WidgetSessions className="mt-8" />} diff --git a/frontend/app/components/Dashboard/components/WidgetWrapper/WidgetWrapper.tsx b/frontend/app/components/Dashboard/components/WidgetWrapper/WidgetWrapper.tsx index a9b6e2046..6354af350 100644 --- a/frontend/app/components/Dashboard/components/WidgetWrapper/WidgetWrapper.tsx +++ b/frontend/app/components/Dashboard/components/WidgetWrapper/WidgetWrapper.tsx @@ -25,6 +25,7 @@ interface Props { history?: any onClick?: () => void; isWidget?: boolean; + hideName?: boolean; } function WidgetWrapper(props: Props & RouteComponentProps) { const { dashboardStore } = useStore(); @@ -112,7 +113,7 @@ function WidgetWrapper(props: Props & RouteComponentProps) { <div className={cn("p-3 pb-4 flex items-center justify-between", { "cursor-move" : !isTemplate && isWidget })} > - <div className="capitalize-first w-full font-medium">{widget.name}</div> + {!props.hideName ? <div className="capitalize-first w-full font-medium">{widget.name}</div> : null} {isWidget && ( <div className="flex items-center" id="no-print"> {!isPredefined && isTimeSeries && ( diff --git a/frontend/app/components/Errors/Error/ErrorInfo.js b/frontend/app/components/Errors/Error/ErrorInfo.js index b1d18ab45..8407826de 100644 --- a/frontend/app/components/Errors/Error/ErrorInfo.js +++ b/frontend/app/components/Errors/Error/ErrorInfo.js @@ -2,82 +2,77 @@ import React from 'react'; import { connect } from 'react-redux'; import withSiteIdRouter from 'HOCs/withSiteIdRouter'; import { errors as errorsRoute, error as errorRoute } from 'App/routes'; -import { NoContent , Loader, IconButton, Icon, Popup, BackLink, } from 'UI'; +import { NoContent, Loader, IconButton, Icon, Popup, BackLink } from 'UI'; import { fetch, fetchTrace } from 'Duck/errors'; import MainSection from './MainSection'; import SideSection from './SideSection'; import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG'; -@connect(state =>({ - errorIdInStore: state.getIn(["errors", "instance"]).errorId, - loading: state.getIn([ "errors", "fetch", "loading" ]) || state.getIn([ "errors", "fetchTrace", "loading" ]), - errorOnFetch: state.getIn(["errors", "fetch", "errors"]) || state.getIn([ "errors", "fetchTrace", "errors" ]), -}), { - fetch, - fetchTrace, -}) +@connect( + (state) => ({ + errorIdInStore: state.getIn(['errors', 'instance']).errorId, + loading: state.getIn(['errors', 'fetch', 'loading']) || state.getIn(['errors', 'fetchTrace', 'loading']), + errorOnFetch: state.getIn(['errors', 'fetch', 'errors']) || state.getIn(['errors', 'fetchTrace', 'errors']), + }), + { + fetch, + fetchTrace, + } +) @withSiteIdRouter export default class ErrorInfo extends React.PureComponent { - ensureInstance() { - const { errorId, loading, errorOnFetch } = this.props; - if (!loading && - this.props.errorIdInStore !== errorId && - errorId != null) { - this.props.fetch(errorId); - this.props.fetchTrace(errorId) - } - } - componentDidMount() { - this.ensureInstance(); - } - componentDidUpdate() { - this.ensureInstance(); - } - next = () => { - const { list, errorId } = this.props; - const curIndex = list.findIndex(e => e.errorId === errorId); - const next = list.get(curIndex + 1); - if (next != null) { - this.props.history.push(errorRoute(next.errorId)) - } - } - prev = () => { - const { list, errorId } = this.props; - const curIndex = list.findIndex(e => e.errorId === errorId); - const prev = list.get(curIndex - 1); - if (prev != null) { - this.props.history.push(errorRoute(prev.errorId)) - } - - } - render() { - const { - loading, - errorIdInStore, - list, - errorId, - } = this.props; + ensureInstance() { + const { errorId, loading, errorOnFetch } = this.props; + if (!loading && this.props.errorIdInStore !== errorId && errorId != null) { + this.props.fetch(errorId); + this.props.fetchTrace(errorId); + } + } + componentDidMount() { + this.ensureInstance(); + } + componentDidUpdate() { + this.ensureInstance(); + } + next = () => { + const { list, errorId } = this.props; + const curIndex = list.findIndex((e) => e.errorId === errorId); + const next = list.get(curIndex + 1); + if (next != null) { + this.props.history.push(errorRoute(next.errorId)); + } + }; + prev = () => { + const { list, errorId } = this.props; + const curIndex = list.findIndex((e) => e.errorId === errorId); + const prev = list.get(curIndex - 1); + if (prev != null) { + this.props.history.push(errorRoute(prev.errorId)); + } + }; + render() { + const { loading, errorIdInStore, list, errorId } = this.props; - let nextDisabled = true, - prevDisabled = true; - if (list.size > 0) { - nextDisabled = loading || list.last().errorId === errorId; - prevDisabled = loading || list.first().errorId === errorId; - } + let nextDisabled = true, + prevDisabled = true; + if (list.size > 0) { + nextDisabled = loading || list.last().errorId === errorId; + prevDisabled = loading || list.first().errorId === errorId; + } - return ( - <NoContent - title={ - <div className="flex flex-col items-center justify-center"> - <AnimatedSVG name={ICONS.EMPTY_STATE} size="170" /> - <div className="mt-6 text-2xl">No Error Found!</div> - </div> - } - subtext="Please try to find existing one." - // animatedIcon="no-results" - show={ !loading && errorIdInStore == null } - > - {/* <div className="w-9/12 mb-4 flex justify-between"> + return ( + <NoContent + title={ + <div className="flex flex-col items-center justify-center"> + <AnimatedSVG name={ICONS.EMPTY_STATE} size="170" /> + <div className="mt-6 text-2xl">No Error Found!</div> + </div> + } + subtext="Please try to find existing one." + // animatedIcon="no-results" + show={!loading && errorIdInStore == null} + > + {/* <div className="w-9/12 mb-4 flex justify-between"> <BackLink to={ errorsRoute() } label="Back" /> <div /> <div className="flex items-center"> @@ -111,13 +106,13 @@ export default class ErrorInfo extends React.PureComponent { </Popup> </div> </div> */} - <div className="flex" > - <Loader loading={ loading } className="w-9/12"> - <MainSection className="w-9/12" /> - <SideSection className="w-3/12" /> - </Loader> - </div> - </NoContent> - ); - } -} \ No newline at end of file + <div className="flex"> + <Loader loading={loading} className="w-9/12"> + <MainSection className="w-9/12" /> + <SideSection className="w-3/12" /> + </Loader> + </div> + </NoContent> + ); + } +} diff --git a/frontend/app/components/Errors/Error/MainSection.js b/frontend/app/components/Errors/Error/MainSection.js index 4a81ca062..534f417f8 100644 --- a/frontend/app/components/Errors/Error/MainSection.js +++ b/frontend/app/components/Errors/Error/MainSection.js @@ -5,105 +5,89 @@ import withSiteIdRouter from 'HOCs/withSiteIdRouter'; import { ErrorDetails, IconButton, Icon, Loader, Button } from 'UI'; import { sessions as sessionsRoute } from 'App/routes'; import { TYPES as EV_FILER_TYPES } from 'Types/filter/event'; -import { UNRESOLVED, RESOLVED, IGNORED } from "Types/errorInfo"; +import { UNRESOLVED, RESOLVED, IGNORED } from 'Types/errorInfo'; import { addFilterByKeyAndValue } from 'Duck/search'; -import { resolve,unresolve,ignore, toggleFavorite } from "Duck/errors"; +import { resolve, unresolve, ignore, toggleFavorite } from 'Duck/errors'; import { resentOrDate } from 'App/date'; import Divider from 'Components/Errors/ui/Divider'; import ErrorName from 'Components/Errors/ui/ErrorName'; import Label from 'Components/Errors/ui/Label'; -import SharePopup from 'Shared/SharePopup' +import SharePopup from 'Shared/SharePopup'; import { FilterKey } from 'Types/filter/filterType'; import SessionBar from './SessionBar'; @withSiteIdRouter -@connect(state => ({ - error: state.getIn([ "errors", "instance" ]), - trace: state.getIn([ "errors", "instanceTrace" ]), - sourcemapUploaded: state.getIn([ "errors", "sourcemapUploaded" ]), - resolveToggleLoading: state.getIn(["errors", "resolve", "loading"]) || - state.getIn(["errors", "unresolve", "loading"]), - ignoreLoading: state.getIn([ "errors", "ignore", "loading" ]), - toggleFavoriteLoading: state.getIn([ "errors", "toggleFavorite", "loading" ]), - traceLoading: state.getIn([ "errors", "fetchTrace", "loading"]), -}),{ - resolve, - unresolve, - ignore, - toggleFavorite, - addFilterByKeyAndValue, -}) +@connect( + (state) => ({ + error: state.getIn(['errors', 'instance']), + trace: state.getIn(['errors', 'instanceTrace']), + sourcemapUploaded: state.getIn(['errors', 'sourcemapUploaded']), + resolveToggleLoading: state.getIn(['errors', 'resolve', 'loading']) || state.getIn(['errors', 'unresolve', 'loading']), + ignoreLoading: state.getIn(['errors', 'ignore', 'loading']), + toggleFavoriteLoading: state.getIn(['errors', 'toggleFavorite', 'loading']), + traceLoading: state.getIn(['errors', 'fetchTrace', 'loading']), + }), + { + resolve, + unresolve, + ignore, + toggleFavorite, + addFilterByKeyAndValue, + } +) export default class MainSection extends React.PureComponent { - resolve = () => { - const { error } = this.props; - this.props.resolve(error.errorId) - } + resolve = () => { + const { error } = this.props; + this.props.resolve(error.errorId); + }; - unresolve = () => { - const { error } = this.props; - this.props.unresolve(error.errorId) - } + unresolve = () => { + const { error } = this.props; + this.props.unresolve(error.errorId); + }; - ignore = () => { - const { error } = this.props; - this.props.ignore(error.errorId) - } - bookmark = () => { - const { error } = this.props; - this.props.toggleFavorite(error.errorId); - } + ignore = () => { + const { error } = this.props; + this.props.ignore(error.errorId); + }; + bookmark = () => { + const { error } = this.props; + this.props.toggleFavorite(error.errorId); + }; - findSessions = () => { - this.props.addFilterByKeyAndValue(FilterKey.ERROR, this.props.error.message); - this.props.history.push(sessionsRoute()); - } + findSessions = () => { + this.props.addFilterByKeyAndValue(FilterKey.ERROR, this.props.error.message); + this.props.history.push(sessionsRoute()); + }; - render() { - const { - error, - trace, - sourcemapUploaded, - ignoreLoading, - resolveToggleLoading, - toggleFavoriteLoading, - className, - traceLoading, - } = this.props; + render() { + const { error, trace, sourcemapUploaded, ignoreLoading, resolveToggleLoading, toggleFavoriteLoading, className, traceLoading } = this.props; - return ( - <div className={cn(className, "bg-white border-radius-3 thin-gray-border mb-6")} > - <div className="m-4"> - <ErrorName - className="text-lg leading-relaxed" - name={ error.name } - message={ error.stack0InfoString } - lineThrough={ error.status === RESOLVED } - /> - <div className="flex justify-between items-center"> - <div className="flex items-center color-gray-dark" style={{ wordBreak: 'break-all'}}> - { error.message } - </div> - <div className="text-center"> - <div className="flex"> - <Label - topValue={ error.sessions } - topValueSize="text-lg" - bottomValue="Sessions" - /> - <Label - topValue={ error.users } - topValueSize="text-lg" - bottomValue="Users" - /> - </div> - <div className="text-xs color-gray-medium">Over the past 30 days</div> - </div> - </div> - - </div> + return ( + <div className={cn(className, 'bg-white border-radius-3 thin-gray-border mb-6')}> + <div className="m-4"> + <ErrorName + className="text-lg leading-relaxed" + name={error.name} + message={error.stack0InfoString} + lineThrough={error.status === RESOLVED} + /> + <div className="flex justify-between items-center"> + <div className="flex items-center color-gray-dark" style={{ wordBreak: 'break-all' }}> + {error.message} + </div> + <div className="text-center"> + <div className="flex"> + <Label topValue={error.sessions} topValueSize="text-lg" bottomValue="Sessions" /> + <Label topValue={error.users} topValueSize="text-lg" bottomValue="Users" /> + </div> + <div className="text-xs color-gray-medium">Over the past 30 days</div> + </div> + </div> + </div> - {/* <Divider /> + {/* <Divider /> <div className="flex m-4"> { error.status === UNRESOLVED ? <IconButton @@ -158,35 +142,29 @@ export default class MainSection extends React.PureComponent { } /> </div> */} - <Divider /> - <div className="m-4"> - <h3 className="text-xl inline-block mr-2">Last session with this error</h3> - <span className="font-thin text-sm">{ resentOrDate(error.lastOccurrence) }</span> - <SessionBar - className="my-4" - session={ error.lastHydratedSession } - /> - <Button - variant="text-primary" - onClick={ this.findSessions } - > - Find all sessions with this error - <Icon className="ml-1" name="next1" color="teal" /> - </Button> - </div> - <Divider /> - <div className="m-4"> - <Loader loading={ traceLoading }> - <ErrorDetails - name={error.name} - message={error.message} - errorStack={trace} - sourcemapUploaded={sourcemapUploaded} - /> - </Loader> - </div> - - </div> - ); - } -} \ No newline at end of file + <Divider /> + <div className="m-4"> + <h3 className="text-xl inline-block mr-2">Last session with this error</h3> + <span className="font-thin text-sm">{resentOrDate(error.lastOccurrence)}</span> + <SessionBar className="my-4" session={error.lastHydratedSession} /> + <Button variant="text-primary" onClick={this.findSessions}> + Find all sessions with this error + <Icon className="ml-1" name="next1" color="teal" /> + </Button> + </div> + <Divider /> + <div className="m-4"> + <Loader loading={traceLoading}> + <ErrorDetails + name={error.name} + message={error.message} + errorStack={trace} + error={error} + sourcemapUploaded={sourcemapUploaded} + /> + </Loader> + </div> + </div> + ); + } +} diff --git a/frontend/app/components/Errors/Error/SideSection.js b/frontend/app/components/Errors/Error/SideSection.js index 6c1702c78..da6e5803b 100644 --- a/frontend/app/components/Errors/Error/SideSection.js +++ b/frontend/app/components/Errors/Error/SideSection.js @@ -87,7 +87,7 @@ export default class SideSection extends React.PureComponent { <h3 className="text-xl mb-2">Overview</h3> <Trend chart={ data.chart24 } - title="Last 24 hours" + title="Past 24 hours" /> <div className="mb-6" /> <Trend @@ -121,5 +121,3 @@ export default class SideSection extends React.PureComponent { ); } } - - diff --git a/frontend/app/components/Errors/List/List.js b/frontend/app/components/Errors/List/List.js index 84828ca8a..9f379319a 100644 --- a/frontend/app/components/Errors/List/List.js +++ b/frontend/app/components/Errors/List/List.js @@ -210,7 +210,7 @@ export default class List extends React.PureComponent { <Input style={{ width: '350px'}} wrapperClassName="ml-3" - placeholder="Filter by Name or Message" + placeholder="Filter by name or message" icon="search" iconPosition="left" name="filter" diff --git a/frontend/app/components/Funnels/FunnelSaveModal/FunnelSaveModal.js b/frontend/app/components/Funnels/FunnelSaveModal/FunnelSaveModal.js index ca35d401d..e0d43d1d7 100644 --- a/frontend/app/components/Funnels/FunnelSaveModal/FunnelSaveModal.js +++ b/frontend/app/components/Funnels/FunnelSaveModal/FunnelSaveModal.js @@ -4,12 +4,16 @@ import { Button, Modal, Form, Icon, Checkbox, Input } from 'UI'; import styles from './funnelSaveModal.module.css'; import { edit, save, fetchList as fetchFunnelsList } from 'Duck/funnels'; -@connect(state => ({ - filter: state.getIn(['search', 'instance']), - funnel: state.getIn(['funnels', 'instance']), - loading: state.getIn([ 'funnels', 'saveRequest', 'loading' ]) || - state.getIn([ 'funnels', 'updateRequest', 'loading' ]), -}), { edit, save, fetchFunnelsList }) +@connect( + (state) => ({ + filter: state.getIn(['search', 'instance']), + funnel: state.getIn(['funnels', 'instance']), + loading: + state.getIn(['funnels', 'saveRequest', 'loading']) || + state.getIn(['funnels', 'updateRequest', 'loading']), + }), + { edit, save, fetchFunnelsList } +) export default class FunnelSaveModal extends React.PureComponent { state = { name: 'Untitled', isPublic: false }; static getDerivedStateFromProps(props) { @@ -26,36 +30,33 @@ export default class FunnelSaveModal extends React.PureComponent { this.props.edit({ name: value }); }; - onChangeOption = (e, { checked, name }) => this.props.edit({ [ name ]: checked }) + onChangeOption = (e, { checked, name }) => this.props.edit({ [name]: checked }); onSave = () => { const { funnel, filter } = this.props; - if (funnel.name.trim() === '') return; - this.props.save(funnel).then(function() { - this.props.fetchFunnelsList(); - this.props.closeHandler(); - }.bind(this)); - } + if (funnel.name && funnel.name.trim() === '') return; + this.props.save(funnel).then( + function () { + this.props.fetchFunnelsList(); + this.props.closeHandler(); + }.bind(this) + ); + }; render() { - const { - show, - closeHandler, - loading, - funnel - } = this.props; - + const { show, closeHandler, loading, funnel } = this.props; + return ( - <Modal size="small" open={ show } onClose={this.props.closeHandler}> - <Modal.Header className={ styles.modalHeader }> - <div>{ 'Save Funnel' }</div> - <Icon + <Modal size="small" open={show} onClose={this.props.closeHandler}> + <Modal.Header className={styles.modalHeader}> + <div>{'Save Funnel'}</div> + <Icon role="button" tabIndex="-1" color="gray-dark" size="14" name="close" - onClick={ closeHandler } + onClick={closeHandler} /> </Modal.Header> @@ -64,11 +65,11 @@ export default class FunnelSaveModal extends React.PureComponent { <Form.Field> <label>{'Title:'}</label> <Input - autoFocus={ true } - className={ styles.name } + autoFocus={true} + className={styles.name} name="name" - value={ funnel.name } - onChange={ this.onNameChange } + value={funnel.name} + onChange={this.onNameChange} placeholder="Title" /> </Form.Field> @@ -79,11 +80,14 @@ export default class FunnelSaveModal extends React.PureComponent { name="isPublic" className="font-medium" type="checkbox" - checked={ funnel.isPublic } - onClick={ this.onChangeOption } - className="mr-3" + checked={funnel.isPublic} + onClick={this.onChangeOption} + className="mr-3" /> - <div className="flex items-center cursor-pointer" onClick={ () => this.props.edit({ 'isPublic' : !funnel.isPublic }) }> + <div + className="flex items-center cursor-pointer" + onClick={() => this.props.edit({ isPublic: !funnel.isPublic })} + > <Icon name="user-friends" size="16" /> <span className="ml-2"> Team Visible</span> </div> @@ -91,16 +95,16 @@ export default class FunnelSaveModal extends React.PureComponent { </Form.Field> </Form> </Modal.Content> - <Modal.Footer className=""> + <Modal.Footer className=""> <Button variant="primary" - onClick={ this.onSave } - loading={ loading } + onClick={this.onSave} + loading={loading} className="float-left mr-2" > - { funnel.exists() ? 'Modify' : 'Save' } + {funnel.exists() ? 'Modify' : 'Save'} </Button> - <Button onClick={ closeHandler }>{ 'Cancel' }</Button> + <Button onClick={closeHandler}>{'Cancel'}</Button> </Modal.Footer> </Modal> ); diff --git a/frontend/app/components/Funnels/FunnelWidget/FunnelWidget.tsx b/frontend/app/components/Funnels/FunnelWidget/FunnelWidget.tsx index 3147b9c78..70e0d1b60 100644 --- a/frontend/app/components/Funnels/FunnelWidget/FunnelWidget.tsx +++ b/frontend/app/components/Funnels/FunnelWidget/FunnelWidget.tsx @@ -30,7 +30,7 @@ function FunnelWidget(props: Props) { }, []); return useObserver(() => ( - <NoContent show={!stages || stages.length === 0}> + <NoContent show={!stages || stages.length === 0} title="No recordings found"> <div className="w-full"> { !isWidget && ( stages.map((filter: any, index: any) => ( diff --git a/frontend/app/components/Header/Header.js b/frontend/app/components/Header/Header.js index 9726e83ee..60536ba24 100644 --- a/frontend/app/components/Header/Header.js +++ b/frontend/app/components/Header/Header.js @@ -4,6 +4,7 @@ import { NavLink, withRouter } from 'react-router-dom'; import cn from 'classnames'; import { sessions, + metrics, assist, client, dashboard, @@ -22,11 +23,12 @@ import { init as initSite } from 'Duck/site'; import ErrorGenPanel from 'App/dev/components'; import Alerts from '../Alerts/Alerts'; import AnimatedSVG, { ICONS } from '../shared/AnimatedSVG/AnimatedSVG'; -import { fetchList as fetchMetadata } from 'Duck/customField'; +import { fetchListActive as fetchMetadata } from 'Duck/customField'; import { useStore } from 'App/mstore'; import { useObserver } from 'mobx-react-lite'; const DASHBOARD_PATH = dashboard(); +const METRICS_PATH = metrics(); const SESSIONS_PATH = sessions(); const ASSIST_PATH = assist(); const CLIENT_PATH = client(CLIENT_DEFAULT_TAB); @@ -44,6 +46,10 @@ const Header = (props) => { const initialDataFetched = useObserver(() => userStore.initialDataFetched); let activeSite = null; + const onAccountClick = () => { + props.history.push(CLIENT_PATH); + } + useEffect(() => { if (!account.id || initialDataFetched) return; @@ -66,8 +72,8 @@ const Header = (props) => { return ( <div className={ cn(styles.header) } style={{ height: '50px'}}> <NavLink to={ withSiteId(SESSIONS_PATH, siteId) }> - <div className="relative"> - <div className="p-2"> + <div className="relative select-none"> + <div className="px-4 py-2"> <AnimatedSVG name={ICONS.LOGO_SMALL} size="30" /> </div> <div className="absolute bottom-0" style={{ fontSize: '7px', right: '5px' }}>v{window.env.VERSION}</div> @@ -94,6 +100,9 @@ const Header = (props) => { to={ withSiteId(DASHBOARD_PATH, siteId) } className={ styles.nav } activeClassName={ styles.active } + isActive={ (_, location) => { + return location.pathname.includes(DASHBOARD_PATH) || location.pathname.includes(METRICS_PATH); + }} > <span>{ 'Dashboards' }</span> </NavLink> @@ -122,6 +131,7 @@ const Header = (props) => { </div> <ul> + <li><button onClick={ onAccountClick }>{ 'Account' }</button></li> <li><button onClick={ onLogoutClick }>{ 'Logout' }</button></li> </ul> </div> diff --git a/frontend/app/components/Header/NewProjectButton/NewProjectButton.tsx b/frontend/app/components/Header/NewProjectButton/NewProjectButton.tsx index 695139fa3..9c438c50f 100644 --- a/frontend/app/components/Header/NewProjectButton/NewProjectButton.tsx +++ b/frontend/app/components/Header/NewProjectButton/NewProjectButton.tsx @@ -3,26 +3,35 @@ import { Icon } from 'UI'; import cn from 'classnames'; import { useStore } from 'App/mstore'; import { useObserver } from 'mobx-react-lite'; - -function NewProjectButton({ onClick, isAdmin = false }: any) { +import { useModal } from 'App/components/Modal'; +import NewSiteForm from 'App/components/Client/Sites/NewSiteForm'; +import { init } from 'Duck/site'; +import { connect } from 'react-redux'; +interface Props { + isAdmin?: boolean; + init?: (data: any) => void; +} +function NewProjectButton(props: Props) { + const { isAdmin = false } = props; const { userStore } = useStore(); const limtis = useObserver(() => userStore.limits); const canAddProject = useObserver(() => isAdmin && (limtis.projects === -1 || limtis.projects > 0)); + const { showModal, hideModal } = useModal(); + + const onClick = () => { + props.init({}); + showModal(<NewSiteForm onClose={hideModal} />, { right: true }); + }; return ( <div - className={cn('flex items-center justify-center py-3 cursor-pointer hover:bg-active-blue ', { 'disabled' : !canAddProject })} + className={cn('flex items-center justify-center py-3 cursor-pointer hover:bg-active-blue ', { disabled: !canAddProject })} onClick={onClick} - > - <Icon - name="plus" - size={12} - className="mr-2" - color="teal" - /> + > + <Icon name="plus" size={12} className="mr-2" color="teal" /> <span className="color-teal">Add New Project</span> </div> ); } -export default NewProjectButton; \ No newline at end of file +export default connect(null, { init })(NewProjectButton); diff --git a/frontend/app/components/Header/SiteDropdown.js b/frontend/app/components/Header/SiteDropdown.js index 228190111..7a0205be3 100644 --- a/frontend/app/components/Header/SiteDropdown.js +++ b/frontend/app/components/Header/SiteDropdown.js @@ -2,104 +2,100 @@ import React from 'react'; import { connect } from 'react-redux'; import { setSiteId } from 'Duck/site'; import { withRouter } from 'react-router-dom'; -import { hasSiteId, siteChangeAvaliable, isRoute } from 'App/routes'; +import { hasSiteId, siteChangeAvaliable } from 'App/routes'; import { STATUS_COLOR_MAP, GREEN } from 'Types/site'; -import { Icon, SlideModal } from 'UI'; -import { pushNewSite } from 'Duck/user' +import { Icon } from 'UI'; +import { pushNewSite } from 'Duck/user'; import { init } from 'Duck/site'; import styles from './siteDropdown.module.css'; import cn from 'classnames'; -import NewSiteForm from '../Client/Sites/NewSiteForm'; import { clearSearch } from 'Duck/search'; import { clearSearch as clearSearchLive } from 'Duck/liveSearch'; -import { fetchList as fetchIntegrationVariables } from 'Duck/customField'; -import { withStore } from 'App/mstore' +import { fetchListActive as fetchIntegrationVariables } from 'Duck/customField'; +import { withStore } from 'App/mstore'; import AnimatedSVG, { ICONS } from '../shared/AnimatedSVG/AnimatedSVG'; import NewProjectButton from './NewProjectButton'; @withStore @withRouter -@connect(state => ({ - sites: state.getIn([ 'site', 'list' ]), - siteId: state.getIn([ 'site', 'siteId' ]), - account: state.getIn([ 'user', 'account' ]), -}), { - setSiteId, - pushNewSite, - init, - clearSearch, - clearSearchLive, - fetchIntegrationVariables, -}) +@connect( + (state) => ({ + sites: state.getIn(['site', 'list']), + siteId: state.getIn(['site', 'siteId']), + account: state.getIn(['user', 'account']), + }), + { + setSiteId, + pushNewSite, + init, + clearSearch, + clearSearchLive, + fetchIntegrationVariables, + } +) export default class SiteDropdown extends React.PureComponent { - state = { showProductModal: false } + state = { showProductModal: false }; - closeModal = (e, newSite) => { - this.setState({ showProductModal: false }) - }; + closeModal = (e, newSite) => { + this.setState({ showProductModal: false }); + }; - newSite = () => { - this.props.init({}) - this.setState({showProductModal: true}) + newSite = () => { + this.props.init({}); + this.setState({ showProductModal: true }); + }; + + switchSite = (siteId) => { + const { mstore, location } = this.props; + + this.props.setSiteId(siteId); + this.props.fetchIntegrationVariables(); + this.props.clearSearch(location.pathname.includes('/sessions')); + this.props.clearSearchLive(); + + mstore.initClient(); } - switchSite = (siteId) => { - const { mstore, location } = this.props + render() { + const { + sites, + siteId, + account, + location: { pathname }, + } = this.props; + const { showProductModal } = this.state; + const isAdmin = account.admin || account.superAdmin; + const activeSite = sites.find((s) => s.id == siteId); + const disabled = !siteChangeAvaliable(pathname); + const showCurrent = hasSiteId(pathname) || siteChangeAvaliable(pathname); + // const canAddSites = isAdmin && account.limits.projects && account.limits.projects.remaining !== 0; - this.props.setSiteId(siteId); - this.props.fetchIntegrationVariables(); - this.props.clearSearch(location.pathname.includes('/sessions')); - this.props.clearSearchLive(); - - mstore.initClient(); - } - - render() { - const { sites, siteId, account, location: { pathname } } = this.props; - const { showProductModal } = this.state; - const isAdmin = account.admin || account.superAdmin; - const activeSite = sites.find(s => s.id == siteId); - const disabled = !siteChangeAvaliable(pathname); - const showCurrent = hasSiteId(pathname) || siteChangeAvaliable(pathname); - // const canAddSites = isAdmin && account.limits.projects && account.limits.projects.remaining !== 0; - - return ( - <div className={ styles.wrapper }> - { - showCurrent ? - (activeSite && activeSite.status === GREEN) ? <AnimatedSVG name={ICONS.SIGNAL_GREEN} size="10" /> : <AnimatedSVG name={ICONS.SIGNAL_RED} size="10" /> : - <Icon name="window-alt" size="14" marginRight="10" /> - } - <div className={ cn(styles.currentSite, 'ml-2')}>{ showCurrent && activeSite ? activeSite.host : 'All Projects' }</div> - <Icon className={ styles.drodownIcon } color="gray-light" name="chevron-down" size="16" /> - <div className={styles.menu}> - <ul data-can-disable={ disabled }> - { !showCurrent && <li>{ 'Does not require domain selection.' }</li>} - { - sites.map(site => ( - <li key={ site.id } onClick={() => this.switchSite(site.id)}> - <Icon - name="circle" - size="8" - marginRight="10" - color={ STATUS_COLOR_MAP[ site.status ] } - /> - { site.host } - </li> - )) - } - </ul> - <NewProjectButton onClick={this.newSite} isAdmin={isAdmin} /> - </div> - - <SlideModal - title="New Project" - size="small" - isDisplayed={ showProductModal } - content={ showProductModal && <NewSiteForm onClose={ this.closeModal } /> } - onClose={ this.closeModal } - /> - </div> - ); - } + return ( + <div className={styles.wrapper}> + {showCurrent ? ( + activeSite && activeSite.status === GREEN ? ( + <AnimatedSVG name={ICONS.SIGNAL_GREEN} size="10" /> + ) : ( + <AnimatedSVG name={ICONS.SIGNAL_RED} size="10" /> + ) + ) : ( + <Icon name="window-alt" size="14" marginRight="10" /> + )} + <div className={cn(styles.currentSite, 'ml-2')}>{showCurrent && activeSite ? activeSite.host : 'All Projects'}</div> + <Icon className={styles.drodownIcon} color="gray-light" name="chevron-down" size="16" /> + <div className={styles.menu}> + <ul data-can-disable={disabled}> + {!showCurrent && <li>{'Project selection is not applicable.'}</li>} + {sites.map((site) => ( + <li key={site.id} onClick={() => this.switchSite(site.id)}> + <div className="w-2 h-2 rounded-full mr-3" style={{ backgroundColor: STATUS_COLOR_MAP[site.status] }} /> + {site.host} + </li> + ))} + </ul> + <NewProjectButton onClick={this.newSite} isAdmin={isAdmin} /> + </div> + </div> + ); + } } diff --git a/frontend/app/components/Header/header.module.css b/frontend/app/components/Header/header.module.css index 8eba021a9..9852b7436 100644 --- a/frontend/app/components/Header/header.module.css +++ b/frontend/app/components/Header/header.module.css @@ -9,7 +9,7 @@ $height: 50px; display: flex; justify-content: space-between; border-bottom: solid thin $gray-light; - padding: 0 15px; + /* padding: 0 15px; */ background: $white; z-index: $header; } diff --git a/frontend/app/components/Modal/Modal.tsx b/frontend/app/components/Modal/Modal.tsx index d14f6411a..9dc622a18 100644 --- a/frontend/app/components/Modal/Modal.tsx +++ b/frontend/app/components/Modal/Modal.tsx @@ -3,14 +3,14 @@ import ReactDOM from 'react-dom'; import ModalOverlay from './ModalOverlay'; export default function Modal({ component, props, hideModal }: any) { - return component ? ReactDOM.createPortal( - <ModalOverlay - hideModal={hideModal} - left={!props.right} - right={props.right} - > - {component} - </ModalOverlay>, - document.querySelector("#modal-root"), - ) : <></>; -} \ No newline at end of file + return component ? ( + ReactDOM.createPortal( + <ModalOverlay hideModal={hideModal} left={!props.right} right={props.right}> + {component} + </ModalOverlay>, + document.querySelector('#modal-root') + ) + ) : ( + <></> + ); +} diff --git a/frontend/app/components/Modal/ModalOverlay.tsx b/frontend/app/components/Modal/ModalOverlay.tsx index 019971565..0a56646b8 100644 --- a/frontend/app/components/Modal/ModalOverlay.tsx +++ b/frontend/app/components/Modal/ModalOverlay.tsx @@ -1,18 +1,14 @@ import React from 'react'; -import stl from './ModalOverlay.module.css' +import stl from './ModalOverlay.module.css'; import cn from 'classnames'; function ModalOverlay({ hideModal, children, left = false, right = false }: any) { return ( <div className="fixed w-full h-screen" style={{ zIndex: 9999 }}> - <div - onClick={hideModal} - className={stl.overlay} - style={{ background: "rgba(0,0,0,0.5)" }} - /> - <div className={cn(stl.slide, { [stl.slideLeft] : left, [stl.slideRight] : right })}>{children}</div> + <div onClick={hideModal} className={stl.overlay} style={{ background: 'rgba(0,0,0,0.5)' }} /> + <div className={cn(stl.slide, { [stl.slideLeft]: left, [stl.slideRight]: right })}>{children}</div> </div> ); } -export default ModalOverlay; \ No newline at end of file +export default ModalOverlay; diff --git a/frontend/app/components/Modal/index.tsx b/frontend/app/components/Modal/index.tsx index 04e2acd91..920cb2d14 100644 --- a/frontend/app/components/Modal/index.tsx +++ b/frontend/app/components/Modal/index.tsx @@ -3,60 +3,59 @@ import React, { Component, createContext } from 'react'; import Modal from './Modal'; const ModalContext = createContext({ - component: null, - props: { - right: false, - onClose: () => {}, - }, - showModal: (component: any, props: any) => {}, - hideModal: () => {} + component: null, + props: { + right: true, + onClose: () => {}, + }, + showModal: (component: any, props: any) => {}, + hideModal: () => {}, }); export class ModalProvider extends Component { - - handleKeyDown = (e: any) => { - if (e.keyCode === 27) { - this.hideModal(); - } - } - - showModal = (component, props = { }) => { - this.setState({ - component, - props - }); - document.addEventListener('keydown', this.handleKeyDown); - document.querySelector("body").style.overflow = 'hidden'; - }; - - hideModal = () => { - document.removeEventListener('keydown', this.handleKeyDown); - document.querySelector("body").style.overflow = 'visible'; - const { props } = this.state; - if (props.onClose) { - props.onClose(); + handleKeyDown = (e: any) => { + if (e.keyCode === 27) { + this.hideModal(); + } }; - this.setState({ - component: null, - props: {} - }); - } - state = { - component: null, - props: {}, - showModal: this.showModal, - hideModal: this.hideModal - }; + showModal = (component, props = { right: true }) => { + this.setState({ + component, + props, + }); + document.addEventListener('keydown', this.handleKeyDown); + document.querySelector('body').style.overflow = 'hidden'; + }; - render() { - return ( - <ModalContext.Provider value={this.state}> - <Modal {...this.state} /> - {this.props.children} - </ModalContext.Provider> - ); - } + hideModal = () => { + document.removeEventListener('keydown', this.handleKeyDown); + document.querySelector('body').style.overflow = 'visible'; + const { props } = this.state; + if (props.onClose) { + props.onClose(); + } + this.setState({ + component: null, + props: {}, + }); + }; + + state = { + component: null, + props: {}, + showModal: this.showModal, + hideModal: this.hideModal, + }; + + render() { + return ( + <ModalContext.Provider value={this.state}> + <Modal {...this.state} /> + {this.props.children} + </ModalContext.Provider> + ); + } } export const ModalConsumer = ModalContext.Consumer; diff --git a/frontend/app/components/Onboarding/components/IntegrationsTab/IntegrationsTab.js b/frontend/app/components/Onboarding/components/IntegrationsTab/IntegrationsTab.js index df05ca807..db679f220 100644 --- a/frontend/app/components/Onboarding/components/IntegrationsTab/IntegrationsTab.js +++ b/frontend/app/components/Onboarding/components/IntegrationsTab/IntegrationsTab.js @@ -17,17 +17,17 @@ function IntegrationsTab() { <div className="w-8/12 px-4"> <h1 className="text-3xl font-bold flex items-center mb-4"> <span>🔌</span> - <div className="ml-3">Plugins</div> + <div className="ml-3">Integrations</div> </h1> - <Integrations hideHeader plugins /> + <Integrations hideHeader={true} /> - <div className="my-4"/> + {/* <div className="my-4"/> <h1 className="text-3xl font-bold flex items-center mb-4"> <span>🔌</span> <div className="ml-3">Integrations</div> </h1> - <Integrations hideHeader /> + <Integrations hideHeader /> */} {/* <div className="mt-6"> <div className="font-bold mb-4">How are you handling store management?</div> diff --git a/frontend/app/components/Overview/Overview.tsx b/frontend/app/components/Overview/Overview.tsx new file mode 100644 index 000000000..78b4bfe2b --- /dev/null +++ b/frontend/app/components/Overview/Overview.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import withPageTitle from 'HOCs/withPageTitle'; +import NoSessionsMessage from 'Shared/NoSessionsMessage'; +import MainSearchBar from 'Shared/MainSearchBar'; +import SessionSearch from 'Shared/SessionSearch'; +import SessionListContainer from 'Shared/SessionListContainer/SessionListContainer'; + +function Overview() { + return ( + <div className="page-margin container-90 flex relative"> + <div className="flex-1 flex"> + <div className={'w-full mx-auto'} style={{ maxWidth: '1300px' }}> + <NoSessionsMessage /> + + <div className="mb-5"> + <MainSearchBar /> + <SessionSearch /> + + <div className="my-4" /> + <SessionListContainer /> + </div> + </div> + </div> + </div> + ); +} + +export default withPageTitle('Sessions - OpenReplay')(Overview); diff --git a/frontend/app/components/Overview/index.ts b/frontend/app/components/Overview/index.ts new file mode 100644 index 000000000..44bcc2216 --- /dev/null +++ b/frontend/app/components/Overview/index.ts @@ -0,0 +1 @@ +export { default } from './Overview'; \ No newline at end of file diff --git a/frontend/app/components/Session/IOSPlayer/Crashes.js b/frontend/app/components/Session/IOSPlayer/Crashes.js index 9fe3d9ad0..015dece96 100644 --- a/frontend/app/components/Session/IOSPlayer/Crashes.js +++ b/frontend/app/components/Session/IOSPlayer/Crashes.js @@ -31,6 +31,7 @@ function Crashes({ player }) { <PanelLayout.Body> <NoContent size="small" + title="No recordings found" show={ filtered.length === 0} > <Autoscroll> @@ -48,4 +49,4 @@ function Crashes({ player }) { ); } -export default observer(Crashes); \ No newline at end of file +export default observer(Crashes); diff --git a/frontend/app/components/Session/IOSPlayer/Logs.js b/frontend/app/components/Session/IOSPlayer/Logs.js index 2469a7e1a..e9fe033d7 100644 --- a/frontend/app/components/Session/IOSPlayer/Logs.js +++ b/frontend/app/components/Session/IOSPlayer/Logs.js @@ -45,6 +45,7 @@ function Logs({ player }) { <NoContent size="small" show={ filtered.length === 0 } + title="No recordings found" > <Autoscroll> { filtered.map(log => @@ -57,4 +58,4 @@ function Logs({ player }) { ); } -export default observer(Logs); \ No newline at end of file +export default observer(Logs); diff --git a/frontend/app/components/Session/IOSPlayer/Network.js b/frontend/app/components/Session/IOSPlayer/Network.js index ab42a61fa..3956b7031 100644 --- a/frontend/app/components/Session/IOSPlayer/Network.js +++ b/frontend/app/components/Session/IOSPlayer/Network.js @@ -10,82 +10,85 @@ import TimeTable from 'Components/Session_/TimeTable'; import FetchDetails from 'Components/Session_/Fetch/FetchDetails'; const COLUMNS = [ - { - label: "Status", - dataKey: 'status', - width: 70, - }, { - label: "Method", - dataKey: 'method', - width: 60, - }, { - label: "url", - width: 130, - render: (r) => - <Popup - content={ <div className={ cls.popupNameContent }>{ r.url }</div> } - size="mini" - position="right center" - > - <div className={ cls.popupNameTrigger }>{ r.url }</div> - </Popup> - }, - { - label: "Size", - width: 60, - render: (r) => `${r.body.length}`, - }, - { - label: "Time", - width: 80, - render: (r) => `${r.duration}ms`, - } + { + label: 'Status', + dataKey: 'status', + width: 70, + }, + { + label: 'Method', + dataKey: 'method', + width: 60, + }, + { + label: 'url', + width: 130, + render: (r) => ( + <Popup + content={<div className={cls.popupNameContent}>{r.url}</div>} + size="mini" + position="right center" + > + <div className={cls.popupNameTrigger}>{r.url}</div> + </Popup> + ), + }, + { + label: 'Size', + width: 60, + render: (r) => `${r.body.length}`, + }, + { + label: 'Time', + width: 80, + render: (r) => `${r.duration}ms`, + }, ]; - - function Network({ player }) { - const [ current, setCurrent ] = useState(null); - const [ currentIndex, setCurrentIndex ] = useState(0); - const onRowClick = useCallback((raw, index) => { - setCurrent(raw); - setCurrentIndex(index); - }); - const onNextClick = useCallback(() => { - onRowClick(player.lists[NETWORK].list[currentIndex+1], currentIndex+1) - }); - const onPrevClick = useCallback(() => { - onRowClick(player.lists[NETWORK].list[currentIndex-1], currentIndex-1) - }); - const closeModal = useCallback(() => setCurrent(null)); // TODO: handle in modal + const [current, setCurrent] = useState(null); + const [currentIndex, setCurrentIndex] = useState(0); + const onRowClick = useCallback((raw, index) => { + setCurrent(raw); + setCurrentIndex(index); + }); + const onNextClick = useCallback(() => { + onRowClick(player.lists[NETWORK].list[currentIndex + 1], currentIndex + 1); + }); + const onPrevClick = useCallback(() => { + onRowClick(player.lists[NETWORK].list[currentIndex - 1], currentIndex - 1); + }); + const closeModal = useCallback(() => setCurrent(null)); // TODO: handle in modal - return ( - <> - <SlideModal + return ( + <> + <SlideModal size="middle" title="Network Request" - isDisplayed={ current != null } - content={ current && - <FetchDetails - resource={ current } - nextClick={ onNextClick } - prevClick={ onPrevClick } - first={ currentIndex === 0 } - last={ currentIndex === player.lists[NETWORK].countNow - 1 } - /> + isDisplayed={current != null} + content={ + current && ( + <FetchDetails + resource={current} + nextClick={onNextClick} + prevClick={onPrevClick} + first={currentIndex === 0} + last={currentIndex === player.lists[NETWORK].countNow - 1} + /> + ) } - onClose={ closeModal } + onClose={closeModal} /> - <TimeTable - rows={ player.lists[NETWORK].listNow } - hoverable - tableHeight={270} - onRowClick={ onRowClick } - > - { COLUMNS } - </TimeTable> - </> - ); + <TimeTable + rows={player.lists[NETWORK].listNow} + hoverable + tableHeight={270} + onRowClick={onRowClick} + > + {COLUMNS} + </TimeTable> + </> + ); } -export default observer(Network); \ No newline at end of file +export default observer(Network); diff --git a/frontend/app/components/Session/IOSPlayer/StackEvents.js b/frontend/app/components/Session/IOSPlayer/StackEvents.js index 92470b358..f1abef414 100644 --- a/frontend/app/components/Session/IOSPlayer/StackEvents.js +++ b/frontend/app/components/Session/IOSPlayer/StackEvents.js @@ -1,11 +1,6 @@ import { observer } from 'mobx-react-lite'; -import { CUSTOM } from 'Player/ios/state'; +import { CUSTOM } from 'Player/ios/state'; import StackEvents from '../Layout/ToolPanel/StackEvents'; - -export default observer(({ player }) => - <StackEvents - stackEvents={ player.lists[CUSTOM].listNow } - /> -); \ No newline at end of file +export default observer(({ player }) => <StackEvents stackEvents={player.lists[CUSTOM].listNow} />); diff --git a/frontend/app/components/Session/Layout/Layout.js b/frontend/app/components/Session/Layout/Layout.js index eafcbac56..93b75c826 100644 --- a/frontend/app/components/Session/Layout/Layout.js +++ b/frontend/app/components/Session/Layout/Layout.js @@ -1,41 +1,33 @@ import React from 'react'; import { observer } from 'mobx-react-lite'; -import { useCallback } from 'react'; -import { EscapeButton, Loader } from 'UI'; +import { EscapeButton } from 'UI'; import Header from './Header'; -import ToolPanel from'./ToolPanel'; -import Events from './Events'; +import ToolPanel from './ToolPanel'; import PlayOverlay from './PlayOverlay'; import Controls from './Player/Controls'; - function Layout({ children, player, toolbar }) { - return ( - <div className="flex flex-col h-screen"> - { !player.fullscreen.enabled && <Header player={player} /> } - <div className="flex-1 flex"> - <div className="flex flex-col" style={{ width: player.fullscreen.enabled ? "100vw" : "calc(100vw - 270px)" }}> - <div - className="flex-1 flex flex-col relative bg-white border-gray-light" - > - { player.fullscreen.enabled && - <EscapeButton onClose={ player.toggleFullscreen } /> - } - <div className="flex-1 relative overflow-hidden" > - {/* <Loader loading={ player.loading }> */} - { children } - {/* </Loader> */} - <PlayOverlay player={player} /> - </div> - <Controls player={ player } toolbar={ toolbar } /> - </div> - { !player.fullscreen.enabled && <ToolPanel player={ player } toolbar={ toolbar }/> } - </div> - - </div> - </div> - ); + return ( + <div className="flex flex-col h-screen"> + {!player.fullscreen.enabled && <Header player={player} />} + <div className="flex-1 flex"> + <div + className="flex flex-col" + > + <div className="flex-1 flex flex-col relative bg-white border-gray-light"> + {player.fullscreen.enabled && <EscapeButton onClose={player.toggleFullscreen} />} + <div className="flex-1 relative overflow-hidden"> + {children} + <PlayOverlay player={player} /> + </div> + <Controls player={player} toolbar={toolbar} /> + </div> + {!player.fullscreen.enabled && <ToolPanel player={player} toolbar={toolbar} />} + </div> + </div> + </div> + ); } -export default observer(Layout); \ No newline at end of file +export default observer(Layout); diff --git a/frontend/app/components/Session/LivePlayer.js b/frontend/app/components/Session/LivePlayer.js index 5793e2d52..0c07b134b 100644 --- a/frontend/app/components/Session/LivePlayer.js +++ b/frontend/app/components/Session/LivePlayer.js @@ -16,13 +16,20 @@ import PlayerBlockHeader from '../Session_/PlayerBlockHeader'; import PlayerBlock from '../Session_/PlayerBlock'; import styles from '../Session_/session.module.css'; - const InitLoader = connectPlayer(state => ({ loading: !state.initialized }))(Loader); - -function LivePlayer ({ session, toggleFullscreen, closeBottomBlock, fullscreen, jwt, loadingCredentials, assistCredendials, request, isEnterprise, hasErrors }) { +function LivePlayer ({ + session, + toggleFullscreen, + closeBottomBlock, + fullscreen, + loadingCredentials, + assistCredendials, + request, + isEnterprise, +}) { useEffect(() => { if (!loadingCredentials) { initPlayer(session, assistCredendials, true); @@ -42,16 +49,15 @@ function LivePlayer ({ session, toggleFullscreen, closeBottomBlock, fullscreen, }, []) const TABS = { - EVENTS: 'Events', + EVENTS: 'User Actions', HEATMAPS: 'Click Map', } const [activeTab, setActiveTab] = useState(''); - return ( <PlayerProvider> <InitLoader className="flex-1 p-3"> - <PlayerBlockHeader activeTab={activeTab} setActiveTab={setActiveTab} tabs={TABS} fullscreen={fullscreen}/> + <PlayerBlockHeader activeTab={activeTab} setActiveTab={setActiveTab} tabs={TABS} fullscreen={fullscreen}/> <div className={ styles.session } data-fullscreen={fullscreen}> <PlayerBlock /> </div> @@ -62,19 +68,17 @@ function LivePlayer ({ session, toggleFullscreen, closeBottomBlock, fullscreen, export default withRequest({ initialData: null, - endpoint: '/assist/credentials', - dataWrapper: data => data, - dataName: 'assistCredendials', + endpoint: '/assist/credentials', + dataWrapper: data => data, + dataName: 'assistCredendials', loadingName: 'loadingCredentials', })(withPermissions(['ASSIST_LIVE'], '', true)(connect( state => { return { session: state.getIn([ 'sessions', 'current' ]), showAssist: state.getIn([ 'sessions', 'showChatWindow' ]), - jwt: state.get('jwt'), fullscreen: state.getIn([ 'components', 'player', 'fullscreen' ]), isEnterprise: state.getIn([ 'user', 'account', 'edition' ]) === 'ee', - hasErrors: !!state.getIn([ 'sessions', 'errors' ]), } }, { toggleFullscreen, closeBottomBlock }, diff --git a/frontend/app/components/Session/Session.js b/frontend/app/components/Session/Session.js index b96bf892c..ec8b42196 100644 --- a/frontend/app/components/Session/Session.js +++ b/frontend/app/components/Session/Session.js @@ -16,10 +16,10 @@ const SESSIONS_ROUTE = sessionsRoute(); function Session({ sessionId, loading, - hasErrors, + hasErrors, session, fetchSession, - fetchSlackList, + fetchSlackList, }) { usePageTitle("OpenReplay Session Player"); const [ initializing, setInitializing ] = useState(true) @@ -69,4 +69,4 @@ export default withPermissions(['SESSION_REPLAY'], '', true)(connect((state, pro }, { fetchSession, fetchSlackList, -})(Session)); \ No newline at end of file +})(Session)); diff --git a/frontend/app/components/Session/WebPlayer.js b/frontend/app/components/Session/WebPlayer.js index 1285ac9b1..b5b1f86af 100644 --- a/frontend/app/components/Session/WebPlayer.js +++ b/frontend/app/components/Session/WebPlayer.js @@ -14,7 +14,7 @@ import styles from '../Session_/session.module.css'; import { countDaysFrom } from 'App/date'; const TABS = { - EVENTS: 'Events', + EVENTS: 'User Actions', HEATMAPS: 'Click Map', }; @@ -44,7 +44,7 @@ function PlayerContent({ session, live, fullscreen, activeTab, setActiveTab, has </div> ) : ( <div className={cn('flex', { 'pointer-events-none': hasError })}> - <div className="w-full" style={activeTab ? { maxWidth: 'calc(100% - 270px)'} : undefined}> + <div className="w-full" style={activeTab && !fullscreen ? { maxWidth: 'calc(100% - 270px)'} : undefined}> <div className={cn(styles.session, 'relative')} data-fullscreen={fullscreen}> <PlayerBlock activeTab={activeTab} /> </div> diff --git a/frontend/app/components/Session_/Autoscroll.js b/frontend/app/components/Session_/Autoscroll.js deleted file mode 100644 index 02af15417..000000000 --- a/frontend/app/components/Session_/Autoscroll.js +++ /dev/null @@ -1,85 +0,0 @@ -import React from 'react'; -import { IconButton } from 'UI'; -import cn from 'classnames'; -import stl from './autoscroll.module.css'; - -export default class Autoscroll extends React.PureComponent { - static defaultProps = { - bottomOffset: 10, - }; - state = { - autoScroll: true, - }; - - componentDidMount() { - if (!this.scrollableElement) return; // is necessary ? - this.scrollableElement.addEventListener('scroll', this.scrollHandler); - this.scrollableElement.scrollTop = this.scrollableElement.scrollHeight; - } - - componentDidUpdate() { - if (!this.scrollableElement) return; // is necessary ? - if (this.state.autoScroll) { - this.scrollableElement.scrollTop = this.scrollableElement.scrollHeight; - } - } - - scrollHandler = (e) => { - if (!this.scrollableElement) return; - this.setState({ - autoScroll: - this.scrollableElement.scrollHeight - this.scrollableElement.clientHeight - this.scrollableElement.scrollTop < - this.props.bottomOffset, - }); - }; - - onPrevClick = () => { - if (!this.scrollableElement) return; - const scEl = this.scrollableElement; - let prevItem; - for (let i = scEl.children.length - 1; i >= 0; i--) { - const child = scEl.children[i]; - const isScrollable = child.getAttribute('data-scroll-item') === 'true'; - if (isScrollable && child.offsetTop < scEl.scrollTop) { - prevItem = child; - break; - } - } - if (!prevItem) return; - scEl.scrollTop = prevItem.offsetTop; - }; - - onNextClick = () => { - if (!this.scrollableElement) return; - const scEl = this.scrollableElement; - let nextItem; - for (let i = 0; i < scEl.children.length; i++) { - const child = scEl.children[i]; - const isScrollable = child.getAttribute('data-scroll-item') === 'true'; - if (isScrollable && child.offsetTop > scEl.scrollTop + 20) { - // ? - nextItem = child; - break; - } - } - if (!nextItem) return; - scEl.scrollTop = nextItem.offsetTop; - }; - - render() { - const { className, navigation = false, children, ...props } = this.props; - return ( - <div className={cn('relative w-full h-full', stl.wrapper)}> - <div {...props} className={cn('relative scroll-y h-full', className)} ref={(ref) => (this.scrollableElement = ref)}> - {children} - </div> - {navigation && ( - <div className={stl.navButtons}> - <IconButton size="small" icon="chevron-up" onClick={this.onPrevClick} /> - <IconButton size="small" icon="chevron-down" onClick={this.onNextClick} className="mt-5" /> - </div> - )} - </div> - ); - } -} diff --git a/frontend/app/components/Session_/Autoscroll.tsx b/frontend/app/components/Session_/Autoscroll.tsx new file mode 100644 index 000000000..305b12dad --- /dev/null +++ b/frontend/app/components/Session_/Autoscroll.tsx @@ -0,0 +1,128 @@ +import React, { ReactNode } from 'react'; +import { IconButton } from 'UI'; +import cn from 'classnames'; +import stl from './autoscroll.module.css'; + +interface Props { + autoScrollTo?: number + children: ReactNode[] + className?: string + navigation?: boolean +} + +export default class Autoscroll extends React.PureComponent<Props, { + autoScroll: boolean + currentIndex?: number +}> { + state = { + autoScroll: true, + currentIndex: 0, + }; + scrollableElement = React.createRef<HTMLDivElement>() + + autoScroll(hard = false) { + if (this.props.autoScrollTo !== undefined && this.props.autoScrollTo !== null && this.props.autoScrollTo >= 0) { + // we have an element to scroll to + this.scrollToElement(this.props.autoScrollTo, hard) + } else if (this.scrollableElement.current) { + // no element to scroll to, scroll to bottom + this.scrollableElement.current.scrollTop = this.scrollableElement.current.scrollHeight; + } + } + + scrollToElement(elementIndex: number, hard = false) { + if (!this.scrollableElement.current) { + return; + } + + if (this.scrollableElement.current.children.length < elementIndex || elementIndex < 0) { + return; + } + + const element = this.scrollableElement.current.children[elementIndex] as (HTMLElement | undefined) + + if (element) { + if (this.scrollableElement.current.scrollTo && !hard) { + this.scrollableElement.current.scrollTo({ + left: 0, + top: element.offsetTop, + behavior: 'smooth' + }) + } else { + this.scrollableElement.current.scrollTop = element.offsetTop; + } + } + } + + componentDidMount() { + if (!this.scrollableElement.current) return; // is necessary ? + + this.scrollableElement.current.addEventListener('scroll', this.scrollHandler); + if (this.state.autoScroll) { + this.setState({ + currentIndex: this.props.autoScrollTo + }) + this.autoScroll(true) + } + } + + componentDidUpdate(nextProps: Props) { + if (!this.scrollableElement) return; // is necessary ? + + if (this.state.autoScroll) { + this.setState({ + currentIndex: this.props.autoScrollTo + }) + this.autoScroll() + } + } + + scrollHandler = (e) => { + if (!this.scrollableElement) return; + }; + + // TODO: Maybe make this handlers that allow the parent element to set a new autoscroll index + onPrevClick = () => { + if (!this.scrollableElement) return; + + const newIndex = Math.max(this.state.currentIndex - 1, 0) + this.setState({ + autoScroll: false, + currentIndex: newIndex + }) + this.scrollToElement(newIndex) + }; + + onNextClick = () => { + if (!this.scrollableElement) return; + + const newIndex = Math.min(this.state.currentIndex + 1, this.props.children.length - 1) + this.setState({ + autoScroll: false, + currentIndex: newIndex + }) + this.scrollToElement(newIndex) + }; + + render() { + const { className, navigation = false, children, ...props } = this.props; + return ( + <div className={cn('relative w-full h-full', stl.wrapper)}> + <div {...props} className={cn('relative scroll-y h-full', className)} ref={this.scrollableElement}> + {children} + </div> + + <div className={stl.navButtons}> + <label><input type={'checkbox'} checked={this.state.autoScroll} onChange={(e) => this.setState({ autoScroll: !this.state.autoScroll })} /> Autoscroll</label> + {navigation && ( + <> + <IconButton size="small" icon="chevron-up" onClick={this.onPrevClick} /> + <IconButton size="small" icon="chevron-down" onClick={this.onNextClick} className="mt-5" /> + </> + )} + </div> + + </div> + ); + } +} diff --git a/frontend/app/components/Session_/BottomBlock/BottomBlock.js b/frontend/app/components/Session_/BottomBlock/BottomBlock.js index 39983c0c1..069757e60 100644 --- a/frontend/app/components/Session_/BottomBlock/BottomBlock.js +++ b/frontend/app/components/Session_/BottomBlock/BottomBlock.js @@ -3,9 +3,9 @@ import cn from 'classnames'; import stl from './bottomBlock.module.css'; const BottomBlock = ({ - children, - className, - additionalHeight, + children = null, + className = '', + additionalHeight = 0, ...props }) => ( <div className={ cn(stl.wrapper, "flex flex-col mb-2") } { ...props } > diff --git a/frontend/app/components/Session_/BottomBlock/Header.js b/frontend/app/components/Session_/BottomBlock/Header.js index 976456332..15dd7a0c9 100644 --- a/frontend/app/components/Session_/BottomBlock/Header.js +++ b/frontend/app/components/Session_/BottomBlock/Header.js @@ -13,7 +13,7 @@ const Header = ({ showClose = true, ...props }) => ( - <div className={ cn("relative border-r border-l", stl.header) } > + <div className={ cn("relative border-r border-l py-1", stl.header) } > <div className={ cn("w-full h-full flex justify-between items-center", className) } > <div className="w-full flex items-center justify-between">{ children }</div> { showClose && <CloseButton onClick={ closeBottomBlock } size="18" className="ml-2" /> } diff --git a/frontend/app/components/Session_/BottomBlock/InfoLine.js b/frontend/app/components/Session_/BottomBlock/InfoLine.js index 8872be906..d4607a887 100644 --- a/frontend/app/components/Session_/BottomBlock/InfoLine.js +++ b/frontend/app/components/Session_/BottomBlock/InfoLine.js @@ -11,7 +11,7 @@ const InfoLine = ({ children }) => ( const Point = ({ label, value, display=true, color, dotColor }) => display ? <div className={ cls.infoPoint } style={{ color }}> { dotColor != null && <div className={ cn(cls.dot, `bg-${dotColor}`) } /> } - <span className={cls.label}>{ `${label}:` }</span> { value } + <span className={cls.label}>{ `${label}` }</span> { value } </div> : null; diff --git a/frontend/app/components/Session_/BottomBlock/bottomBlock.module.css b/frontend/app/components/Session_/BottomBlock/bottomBlock.module.css index 41cf7e5e1..99bdd42b4 100644 --- a/frontend/app/components/Session_/BottomBlock/bottomBlock.module.css +++ b/frontend/app/components/Session_/BottomBlock/bottomBlock.module.css @@ -4,6 +4,6 @@ /* padding-right: 10px; */ /* border: solid thin $gray-light; */ height: 300px; - padding-top: 2px; - border-top: thin dashed #cccccc + + border-top: thin dashed #cccccc; } diff --git a/frontend/app/components/Session_/BottomBlock/infoLine.module.css b/frontend/app/components/Session_/BottomBlock/infoLine.module.css index d03a53439..b6798d1bf 100644 --- a/frontend/app/components/Session_/BottomBlock/infoLine.module.css +++ b/frontend/app/components/Session_/BottomBlock/infoLine.module.css @@ -11,13 +11,13 @@ align-items: center; &:not(:last-child):after { content: ''; - margin: 0 10px; + margin: 0 12px; height: 30px; border-right: 1px solid $gray-light-shade; } & .label { font-weight: 500; - margin-right: 3px; + margin-right: 6px; } } } diff --git a/frontend/app/components/Session_/Console/Console.js b/frontend/app/components/Session_/Console/Console.js index 5534439fb..3c4a3752c 100644 --- a/frontend/app/components/Session_/Console/Console.js +++ b/frontend/app/components/Session_/Console/Console.js @@ -12,7 +12,7 @@ export default class Console extends React.PureComponent { render() { const { logs, time, listNow } = this.props; return ( - <ConsoleContent jump={!this.props.livePlay && jump} logs={logs} lastIndex={listNow.length - 1} /> + <ConsoleContent jump={!this.props.livePlay && jump} logs={logs} lastIndex={listNow.length - 1} logsNow={listNow} /> ); } } diff --git a/frontend/app/components/Session_/Console/ConsoleContent.js b/frontend/app/components/Session_/Console/ConsoleContent.js index a2c084abd..54a9745d0 100644 --- a/frontend/app/components/Session_/Console/ConsoleContent.js +++ b/frontend/app/components/Session_/Console/ConsoleContent.js @@ -7,6 +7,7 @@ import { LEVEL } from 'Types/session/log'; import Autoscroll from '../Autoscroll'; import BottomBlock from '../BottomBlock'; import stl from './console.module.css'; +import { Duration } from 'luxon'; const ALL = 'ALL'; const INFO = 'INFO'; @@ -14,102 +15,118 @@ const WARNINGS = 'WARNINGS'; const ERRORS = 'ERRORS'; const LEVEL_TAB = { - [LEVEL.INFO]: INFO, - [LEVEL.LOG]: INFO, - [LEVEL.WARNING]: WARNINGS, - [LEVEL.ERROR]: ERRORS, - [LEVEL.EXCEPTION]: ERRORS, + [LEVEL.INFO]: INFO, + [LEVEL.LOG]: INFO, + [LEVEL.WARNING]: WARNINGS, + [LEVEL.ERROR]: ERRORS, + [LEVEL.EXCEPTION]: ERRORS, }; const TABS = [ALL, ERRORS, WARNINGS, INFO].map((tab) => ({ text: tab, key: tab })); // eslint-disable-next-line complexity const getIconProps = (level) => { - switch (level) { - case LEVEL.INFO: - case LEVEL.LOG: - return { - name: 'console/info', - color: 'blue2', - }; - case LEVEL.WARN: - case LEVEL.WARNING: - return { - name: 'console/warning', - color: 'red2', - }; - case LEVEL.ERROR: - return { - name: 'console/error', - color: 'red', - }; - } - return null; + switch (level) { + case LEVEL.INFO: + case LEVEL.LOG: + return { + name: 'console/info', + color: 'blue2', + }; + case LEVEL.WARN: + case LEVEL.WARNING: + return { + name: 'console/warning', + color: 'red2', + }; + case LEVEL.ERROR: + return { + name: 'console/error', + color: 'red', + }; + } + return null; }; function renderWithNL(s = '') { - if (typeof s !== 'string') return ''; - return s.split('\n').map((line, i) => <div className={cn({ 'ml-20': i !== 0 })}>{line}</div>); + if (typeof s !== 'string') return ''; + return s.split('\n').map((line, i) => <div className={cn({ 'ml-20': i !== 0 })}>{line}</div>); } export default class ConsoleContent extends React.PureComponent { - state = { - filter: '', - activeTab: ALL, - }; - onTabClick = (activeTab) => this.setState({ activeTab }); - onFilterChange = ({ target: { value } }) => this.setState({ filter: value }); + state = { + filter: '', + activeTab: ALL, + }; + onTabClick = (activeTab) => this.setState({ activeTab }); + onFilterChange = ({ target: { value } }) => this.setState({ filter: value }); - render() { - const { logs, isResult, additionalHeight, lastIndex } = this.props; - const { filter, activeTab } = this.state; - const filterRE = getRE(filter, 'i'); - const filtered = logs.filter(({ level, value }) => - activeTab === ALL ? filterRE.test(value) : filterRE.test(value) && LEVEL_TAB[level] === activeTab - ); + render() { + const { logs, isResult, additionalHeight, logsNow } = this.props; + const time = logsNow.length > 0 ? logsNow[logsNow.length - 1].time : undefined; + const { filter, activeTab, currentError } = this.state; + const filterRE = getRE(filter, 'i'); + const filtered = logs.filter(({ level, value }) => + activeTab === ALL + ? filterRE.test(value) + : filterRE.test(value) && LEVEL_TAB[level] === activeTab + ); - return ( - <> - <BottomBlock style={{ height: 300 + additionalHeight + 'px' }}> - <BottomBlock.Header showClose={!isResult}> - <div className="flex items-center"> - <span className="font-semibold color-gray-medium mr-4">Console</span> - <Tabs tabs={TABS} active={activeTab} onClick={this.onTabClick} border={false} /> - </div> - <Input - className="input-small" - placeholder="Filter by keyword" - icon="search" - iconPosition="left" - name="filter" - onChange={this.onFilterChange} - /> - </BottomBlock.Header> - <BottomBlock.Content> - <NoContent size="small" show={filtered.length === 0}> - <Autoscroll> - {filtered.map((l, index) => ( - <div - key={l.key} - className={cn(stl.line, { - info: !l.isYellow() && !l.isRed(), - warn: l.isYellow(), - error: l.isRed(), - 'cursor-pointer': !isResult, - [stl.activeRow]: lastIndex === index, - })} - data-scroll-item={l.isRed()} - onClick={() => !isResult && jump(l.time)} - > - <Icon size="14" className={stl.icon} {...getIconProps(l.level)} /> - <div className={stl.message}>{renderWithNL(l.value)}</div> - </div> - ))} - </Autoscroll> - </NoContent> - </BottomBlock.Content> - </BottomBlock> - </> - ); - } + const lastIndex = filtered.filter((item) => item.time <= time).length - 1; + + return ( + <> + <BottomBlock style={{ height: 300 + additionalHeight + 'px' }}> + <BottomBlock.Header showClose={!isResult}> + <div className="flex items-center"> + <span className="font-semibold color-gray-medium mr-4">Console</span> + <Tabs tabs={TABS} active={activeTab} onClick={this.onTabClick} border={false} /> + </div> + <Input + className="input-small" + placeholder="Filter by keyword" + icon="search" + iconPosition="left" + name="filter" + onChange={this.onFilterChange} + /> + </BottomBlock.Header> + <BottomBlock.Content> + <NoContent title={ + <div className="capitalize flex items-center mt-16"> + <Icon name="info-circle" className="mr-2" size="18" /> + No Data + </div> + } size="small" show={filtered.length === 0}> + <Autoscroll autoScrollTo={Math.max(lastIndex, 0)}> + {filtered.map((l, index) => ( + <div + className={cn('flex py-2 px-4', { + info: !l.isYellow() && !l.isRed(), + warn: l.isYellow(), + error: l.isRed(), + [stl.activeRow]: lastIndex === index, + // [stl.inactiveRow]: index > lastIndex, + 'cursor-pointer': !isResult, + })} + onClick={() => !isResult && jump(l.time)} + > + <div className={cn(stl.timestamp)}> + <Icon size="14" className={stl.icon} {...getIconProps(l.level)} /> + </div> + <div className={cn(stl.timestamp, {})}> + {Duration.fromMillis(l.time).toFormat('mm:ss.SSS')} + </div> + <div key={l.key} className={cn(stl.line)} data-scroll-item={l.isRed()}> + <div className={stl.message}>{renderWithNL(l.value)}</div> + </div> + </div> + ))} + </Autoscroll> + </NoContent> + </BottomBlock.Content> + </BottomBlock> + </> + ); + } } diff --git a/frontend/app/components/Session_/Console/console.module.css b/frontend/app/components/Session_/Console/console.module.css index 55d19c7bd..6f7079d94 100644 --- a/frontend/app/components/Session_/Console/console.module.css +++ b/frontend/app/components/Session_/Console/console.module.css @@ -11,18 +11,25 @@ .line { font-family: 'Menlo', 'monaco', 'consolas', monospace; - padding: 7px 0 7px 15px; /* margin-top: -1px; ??? */ display: flex; align-items: flex-start; border-bottom: solid thin $gray-light-shade; } +.timestamp { + +} + .activeRow { - background-color: $teal !important; - color: white !important; + background-color: $teal-light !important; } .icon { padding-top: 4px; + margin-right: 7px; +} + +.inactiveRow { + opacity: 0.5; } \ No newline at end of file diff --git a/frontend/app/components/Session_/EventsBlock/EventsBlock.js b/frontend/app/components/Session_/EventsBlock/EventsBlock.js index 1bc4419d4..e690ce3cc 100644 --- a/frontend/app/components/Session_/EventsBlock/EventsBlock.js +++ b/frontend/app/components/Session_/EventsBlock/EventsBlock.js @@ -197,7 +197,7 @@ export default class EventsBlock extends React.PureComponent { setActiveTab={setActiveTab} value={query} header={ - <div className="text-xl">User Events <span className="color-gray-medium">{ events.size }</span></div> + <div className="text-xl">User Actions <span className="color-gray-medium">{ events.size }</span></div> } /> </div> diff --git a/frontend/app/components/Session_/EventsBlock/Metadata/SessionList.js b/frontend/app/components/Session_/EventsBlock/Metadata/SessionList.js index bc0868933..fe414a30d 100644 --- a/frontend/app/components/Session_/EventsBlock/Metadata/SessionList.js +++ b/frontend/app/components/Session_/EventsBlock/Metadata/SessionList.js @@ -4,52 +4,57 @@ import { NoContent, Icon, Loader } from 'UI'; import Session from 'Types/session'; import SessionItem from 'Shared/SessionItem'; import stl from './sessionList.module.css'; +import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG'; -@connect(state => ({ - currentSessionId: state.getIn([ 'sessions', 'current', 'sessionId' ]) +@connect((state) => ({ + currentSessionId: state.getIn(['sessions', 'current', 'sessionId']), })) class SessionList extends React.PureComponent { - render() { - const { - similarSessions, - loading, - currentSessionId, - } = this.props; + render() { + const { similarSessions, loading, currentSessionId } = this.props; - const similarSessionWithoutCurrent = similarSessions.map(({sessions, ...rest}) => { - return { - ...rest, - sessions: sessions.map(Session).filter(({ sessionId }) => sessionId !== currentSessionId) - } - }).filter(site => site.sessions.length > 0); - - return ( - <Loader loading={ loading }> - <NoContent - show={ !loading && (similarSessionWithoutCurrent.length === 0 || similarSessionWithoutCurrent.size === 0 )} - title="No recordings found." - > - <div className={ stl.sessionList }> - { similarSessionWithoutCurrent.map(site => ( - <div className={ stl.siteWrapper } key={ site.host }> - <div className={ stl.siteHeader }> - <Icon name="window" size="14" color="gray-medium" marginRight="10" /> - <span>{ site.name }</span> - </div> - <div className="bg-white p-3 rounded border"> - { site.sessions.map(session => ( - <div className="border-b last:border-none"> - <SessionItem key={ session.sessionId } session={ session } /> + const similarSessionWithoutCurrent = similarSessions + .map(({ sessions, ...rest }) => { + return { + ...rest, + sessions: sessions.map(Session).filter(({ sessionId }) => sessionId !== currentSessionId), + }; + }) + .filter((site) => site.sessions.length > 0); + + return ( + <Loader loading={loading}> + <NoContent + show={!loading && (similarSessionWithoutCurrent.length === 0 || similarSessionWithoutCurrent.size === 0)} + title={ + <div className="flex items-center justify-center flex-col"> + <AnimatedSVG name={ICONS.NO_SESSIONS} size={170} /> + <div className="mt-2" /> + <div className="text-center text-gray-600">No sessions found.</div> + </div> + } + > + <div className={stl.sessionList}> + {similarSessionWithoutCurrent.map((site) => ( + <div className={stl.siteWrapper} key={site.host}> + <div className={stl.siteHeader}> + <Icon name="window" size="14" color="gray-medium" marginRight="10" /> + <span>{site.name}</span> + </div> + <div className="bg-white p-3 rounded border"> + {site.sessions.map((session) => ( + <div className="border-b last:border-none"> + <SessionItem key={session.sessionId} session={session} /> + </div> + ))} + </div> + </div> + ))} </div> - )) } - </div> - </div> - )) } - </div> - </NoContent> - </Loader> - ); - } + </NoContent> + </Loader> + ); + } } export default SessionList; diff --git a/frontend/app/components/Session_/EventsBlock/UserCard/UserCard.js b/frontend/app/components/Session_/EventsBlock/UserCard/UserCard.js index a7faa7625..480cd4e67 100644 --- a/frontend/app/components/Session_/EventsBlock/UserCard/UserCard.js +++ b/frontend/app/components/Session_/EventsBlock/UserCard/UserCard.js @@ -5,11 +5,10 @@ import { countries } from 'App/constants'; import { useStore } from 'App/mstore'; import { browserIcon, osIcon, deviceTypeIcon } from 'App/iconNames'; import { formatTimeOrDate } from 'App/date'; -import { Avatar, TextEllipsis, SlideModal, Popup, CountryFlag, Icon } from 'UI'; +import { Avatar, TextEllipsis, CountryFlag, Icon } from 'UI'; import cn from 'classnames'; import { withRequest } from 'HOCs'; import SessionInfoItem from '../../SessionInfoItem'; -import SessionList from '../Metadata/SessionList'; import { Tooltip } from 'react-tippy'; import { useModal } from 'App/components/Modal'; import UserSessionsModal from 'Shared/UserSessionsModal'; @@ -18,7 +17,6 @@ function UserCard({ className, request, session, width, height, similarSessions, const { settingsStore } = useStore(); const { timezone } = settingsStore.sessionSettings; - const [showUserSessions, setShowUserSessions] = useState(false); const { userBrowser, userDevice, @@ -36,10 +34,6 @@ function UserCard({ className, request, session, width, height, similarSessions, } = session; const hasUserDetails = !!userId || !!userAnonymousId; - const showSimilarSessions = () => { - setShowUserSessions(true); - request({ key: !userId ? 'USERANONYMOUSID' : 'USERID', value: userId || userAnonymousId }); - }; const getDimension = (width, height) => { return width && height ? ( @@ -66,7 +60,15 @@ function UserCard({ className, request, session, width, height, similarSessions, </TextEllipsis> <div className="text-sm color-gray-medium flex items-center"> - <span style={{ whiteSpace: 'nowrap' }}>{formatTimeOrDate(startedAt, timezone)}</span> + <span style={{ whiteSpace: 'nowrap' }}> + <Tooltip + title={`${formatTimeOrDate(startedAt, timezone, true)} ${timezone.label}`} + className="w-fit !block" + > + {formatTimeOrDate(startedAt, timezone)} + </Tooltip> + + </span> <span className="mx-1 font-bold text-xl">·</span> <span>{countries[userCountry]}</span> <span className="mx-1 font-bold text-xl">·</span> diff --git a/frontend/app/components/Session_/Exceptions/Exceptions.js b/frontend/app/components/Session_/Exceptions/Exceptions.js index e08145ad7..16371d110 100644 --- a/frontend/app/components/Session_/Exceptions/Exceptions.js +++ b/frontend/app/components/Session_/Exceptions/Exceptions.js @@ -1,113 +1,153 @@ import React from 'react'; import { connect } from 'react-redux'; import { getRE } from 'App/utils'; -import { NoContent, Loader, Input, ErrorItem, SlideModal, ErrorDetails, ErrorHeader,Link, QuestionMarkHint } from 'UI'; -import { fetchErrorStackList } from 'Duck/sessions' +import { + NoContent, + Loader, + Input, + ErrorItem, + SlideModal, + ErrorDetails, + ErrorHeader, + Link, + QuestionMarkHint, + Tabs, +} from 'UI'; +import { fetchErrorStackList } from 'Duck/sessions'; import { connectPlayer, jump } from 'Player'; import { error as errorRoute } from 'App/routes'; import Autoscroll from '../Autoscroll'; import BottomBlock from '../BottomBlock'; -@connectPlayer(state => ({ +@connectPlayer((state) => ({ logs: state.logListNow, - exceptions: state.exceptionsListNow, + exceptions: state.exceptionsList, + exceptionsNow: state.exceptionsListNow, })) -@connect(state => ({ - session: state.getIn([ 'sessions', 'current' ]), - errorStack: state.getIn([ 'sessions', 'errorStack' ]), - sourcemapUploaded: state.getIn([ 'sessions', 'sourcemapUploaded' ]), - loading: state.getIn([ 'sessions', 'fetchErrorStackList', 'loading' ]) -}), { fetchErrorStackList }) +@connect( + (state) => ({ + session: state.getIn(['sessions', 'current']), + errorStack: state.getIn(['sessions', 'errorStack']), + sourcemapUploaded: state.getIn(['sessions', 'sourcemapUploaded']), + loading: state.getIn(['sessions', 'fetchErrorStackList', 'loading']), + }), + { fetchErrorStackList } +) export default class Exceptions extends React.PureComponent { state = { filter: '', - currentError: null - } + currentError: null, + }; - onFilterChange = ({ target: { value } }) => this.setState({ filter: value }) + onFilterChange = ({ target: { value } }) => this.setState({ filter: value }); setCurrentError = (err) => { const { session } = this.props; - this.props.fetchErrorStackList(session.sessionId, err.errorId) - this.setState({ currentError: err}) - } - closeModal = () => this.setState({ currentError: null}) + this.props.fetchErrorStackList(session.sessionId, err.errorId); + this.setState({ currentError: err }); + }; + closeModal = () => this.setState({ currentError: null }); render() { const { exceptions, loading, errorStack, sourcemapUploaded } = this.props; const { filter, currentError } = this.state; const filterRE = getRE(filter, 'i'); - const filtered = exceptions.filter(e => filterRE.test(e.name) || filterRE.test(e.message)); + const filtered = exceptions.filter((e) => filterRE.test(e.name) || filterRE.test(e.message)); + + let lastIndex = -1; + filtered.forEach((item, index) => { + if ( + this.props.exceptionsNow.length > 0 && + item.time <= this.props.exceptionsNow[this.props.exceptionsNow.length - 1].time + ) { + lastIndex = index; + } + }); return ( <> - <SlideModal - title={ currentError && - <div className="mb-4"> - <div className="text-xl mb-2"> - <Link to={errorRoute(currentError.errorId)}> - <span className="font-bold">{currentError.name}</span> - </Link> - <span className="ml-2 text-sm color-gray-medium"> - {currentError.function} - </span> + <SlideModal + title={ + currentError && ( + <div className="mb-4"> + <div className="text-xl mb-2"> + <Link to={errorRoute(currentError.errorId)}> + <span className="font-bold">{currentError.name}</span> + </Link> + <span className="ml-2 text-sm color-gray-medium">{currentError.function}</span> + </div> + <div>{currentError.message}</div> </div> - <div>{currentError.message}</div> - </div> + ) } - isDisplayed={ currentError != null } - content={ currentError && - <div className="px-4"> - <Loader loading={ loading }> - <NoContent - show={ !loading && errorStack.size === 0 } - title="Nothing found!" - > - <ErrorDetails error={ currentError.name } errorStack={errorStack} sourcemapUploaded={sourcemapUploaded} /> - </NoContent> - </Loader> - </div> + isDisplayed={currentError != null} + content={ + currentError && ( + <div className="px-4"> + <Loader loading={loading}> + <NoContent show={!loading && errorStack.size === 0} title="Nothing found!"> + <ErrorDetails + error={currentError} + errorStack={errorStack} + sourcemapUploaded={sourcemapUploaded} + /> + </NoContent> + </Loader> + </div> + ) } - onClose={ this.closeModal } + onClose={this.closeModal} /> <BottomBlock> <BottomBlock.Header> - <Input - // className="input-small" - placeholder="Filter by name or message" - icon="search" - iconPosition="left" - name="filter" - onChange={ this.onFilterChange } - /> - <div className="mr-8"> - <QuestionMarkHint - onHover={true} - content={ - <> - <a className="color-teal underline" target="_blank" href="https://docs.openreplay.com/installation/upload-sourcemaps">Upload Source Maps </a> - and see source code context obtained from stack traces in their original form. - </> - } - /> + <div className="flex items-center"> + <span className="font-semibold color-gray-medium mr-4">Exceptions</span> + </div> + + <div className={'flex items-center justify-between'}> + <Input + className="input-small" + placeholder="Filter by name or message" + icon="search" + iconPosition="left" + name="filter" + onChange={this.onFilterChange} + /> + <QuestionMarkHint + className={'mx-4'} + content={ + <> + <a + className="color-teal underline" + target="_blank" + href="https://docs.openreplay.com/installation/upload-sourcemaps" + > + Upload Source Maps{' '} + </a> + and see source code context obtained from stack traces in their original form. + </> + } + /> </div> </BottomBlock.Header> <BottomBlock.Content> - <NoContent - size="small" - show={ filtered.length === 0} - > - <Autoscroll> - { filtered.map(e => ( - <ErrorItem - onJump={ () => jump(e.time) } - error={e} - key={e.key} - onErrorClick={() => this.setCurrentError(e)} - /> - )) - } + <NoContent size="small" show={filtered.length === 0} title="No recordings found"> + <Autoscroll autoScrollTo={Math.max(lastIndex, 0)}> + {filtered.map((e, index) => ( + <ErrorItem + onJump={() => jump(e.time)} + error={e} + key={e.key} + selected={lastIndex === index} + inactive={index > lastIndex} + onErrorClick={(jsEvent) => { + jsEvent.stopPropagation(); + jsEvent.preventDefault(); + this.setCurrentError(e); + }} + /> + ))} </Autoscroll> </NoContent> </BottomBlock.Content> @@ -115,4 +155,4 @@ export default class Exceptions extends React.PureComponent { </> ); } -} \ No newline at end of file +} diff --git a/frontend/app/components/Session_/Fetch/Fetch.js b/frontend/app/components/Session_/Fetch/Fetch.js index be2bcde2a..df0c44864 100644 --- a/frontend/app/components/Session_/Fetch/Fetch.js +++ b/frontend/app/components/Session_/Fetch/Fetch.js @@ -1,6 +1,6 @@ import React from 'react'; import { getRE } from 'App/utils'; -import { Label, NoContent, Input, SlideModal, CloseButton } from 'UI'; +import { Label, NoContent, Input, SlideModal, CloseButton, Icon } from 'UI'; import { connectPlayer, pause, jump } from 'Player'; // import Autoscroll from '../Autoscroll'; import BottomBlock from '../BottomBlock'; @@ -9,156 +9,183 @@ import FetchDetails from './FetchDetails'; import { renderName, renderDuration } from '../Network'; import { connect } from 'react-redux'; import { setTimelinePointer } from 'Duck/sessions'; +import { renderStart } from 'Components/Session_/Network/NetworkContent'; @connectPlayer((state) => ({ - list: state.fetchList, - listNow: state.fetchListNow, - livePlay: state.livePlay, + list: state.fetchList, + listNow: state.fetchListNow, + livePlay: state.livePlay, })) @connect( - (state) => ({ - timelinePointer: state.getIn(['sessions', 'timelinePointer']), - }), - { setTimelinePointer } + (state) => ({ + timelinePointer: state.getIn(['sessions', 'timelinePointer']), + }), + { setTimelinePointer } ) export default class Fetch extends React.PureComponent { - state = { - filter: '', - filteredList: this.props.list, - current: null, - currentIndex: 0, - showFetchDetails: false, - hasNextError: false, - hasPreviousError: false, - }; + state = { + filter: '', + filteredList: this.props.list, + current: null, + currentIndex: 0, + showFetchDetails: false, + hasNextError: false, + hasPreviousError: false, + }; - onFilterChange = ({ target: { value } }) => { - const { list } = this.props; - const filterRE = getRE(value, 'i'); - const filtered = list.filter((r) => filterRE.test(r.name) || filterRE.test(r.url) || filterRE.test(r.method) || filterRE.test(r.status)); - this.setState({ filter: value, filteredList: value ? filtered : list, currentIndex: 0 }); - }; + onFilterChange = (e, { value }) => { + const { list } = this.props; + const filterRE = getRE(value, 'i'); + const filtered = list.filter( + (r) => + filterRE.test(r.name) || + filterRE.test(r.url) || + filterRE.test(r.method) || + filterRE.test(r.status) + ); + this.setState({ filter: value, filteredList: value ? filtered : list, currentIndex: 0 }); + }; - setCurrent = (item, index) => { - if (!this.props.livePlay) { - pause(); - jump(item.time); - } - this.setState({ current: item, currentIndex: index }); - }; - - onRowClick = (item, index) => { - if (!this.props.livePlay) { - pause(); - } - this.setState({ current: item, currentIndex: index, showFetchDetails: true }); - this.props.setTimelinePointer(null); - }; - - closeModal = () => this.setState({ current: null, showFetchDetails: false }); - - nextClickHander = () => { - // const { list } = this.props; - const { currentIndex, filteredList } = this.state; - - if (currentIndex === filteredList.length - 1) return; - const newIndex = currentIndex + 1; - this.setCurrent(filteredList[newIndex], newIndex); - this.setState({ showFetchDetails: true }); - }; - - prevClickHander = () => { - // const { list } = this.props; - const { currentIndex, filteredList } = this.state; - - if (currentIndex === 0) return; - const newIndex = currentIndex - 1; - this.setCurrent(filteredList[newIndex], newIndex); - this.setState({ showFetchDetails: true }); - }; - - render() { - const { listNow } = this.props; - const { current, currentIndex, showFetchDetails, filteredList } = this.state; - const hasErrors = filteredList.some((r) => r.status >= 400); - return ( - <React.Fragment> - <SlideModal - right - size="middle" - title={ - <div className="flex justify-between"> - <h1>Fetch Request</h1> - <div className="flex items-center"> - <div className="flex items-center"> - <span className="mr-2 color-gray-medium uppercase text-base">Status</span> - <Label data-red={current && current.status >= 400} data-green={current && current.status < 400}> - <div className="uppercase w-16 justify-center code-font text-lg">{current && current.status}</div> - </Label> - </div> - <CloseButton onClick={this.closeModal} size="18" className="ml-2" /> - </div> - </div> - } - isDisplayed={current != null && showFetchDetails} - content={ - current && - showFetchDetails && ( - <FetchDetails - resource={current} - nextClick={this.nextClickHander} - prevClick={this.prevClickHander} - first={currentIndex === 0} - last={currentIndex === filteredList.length - 1} - /> - ) - } - onClose={this.closeModal} - /> - <BottomBlock> - <BottomBlock.Header> - <h4 className="text-lg">Fetch</h4> - <div className="flex items-center"> - <Input - className="input-small" - placeholder="Filter" - icon="search" - iconPosition="left" - name="filter" - onChange={this.onFilterChange} - /> - </div> - </BottomBlock.Header> - <BottomBlock.Content> - <NoContent size="small" show={filteredList.length === 0}> - <TimeTable rows={filteredList} onRowClick={this.onRowClick} hoverable navigation={hasErrors} activeIndex={listNow.length - 1}> - {[ - { - label: 'Status', - dataKey: 'status', - width: 70, - }, - { - label: 'Method', - dataKey: 'method', - width: 60, - }, - { - label: 'Name', - width: 240, - render: renderName, - }, - { - label: 'Time', - width: 80, - render: renderDuration, - }, - ]} - </TimeTable> - </NoContent> - </BottomBlock.Content> - </BottomBlock> - </React.Fragment> - ); + setCurrent = (item, index) => { + if (!this.props.livePlay) { + pause(); + jump(item.time); } + this.setState({ current: item, currentIndex: index }); + }; + + onRowClick = (item, index) => { + if (!this.props.livePlay) { + pause(); + } + this.setState({ current: item, currentIndex: index, showFetchDetails: true }); + this.props.setTimelinePointer(null); + }; + + closeModal = () => this.setState({ current: null, showFetchDetails: false }); + + nextClickHander = () => { + // const { list } = this.props; + const { currentIndex, filteredList } = this.state; + + if (currentIndex === filteredList.length - 1) return; + const newIndex = currentIndex + 1; + this.setCurrent(filteredList[newIndex], newIndex); + this.setState({ showFetchDetails: true }); + }; + + prevClickHander = () => { + // const { list } = this.props; + const { currentIndex, filteredList } = this.state; + + if (currentIndex === 0) return; + const newIndex = currentIndex - 1; + this.setCurrent(filteredList[newIndex], newIndex); + this.setState({ showFetchDetails: true }); + }; + + render() { + const { listNow } = this.props; + const { current, currentIndex, showFetchDetails, filteredList } = this.state; + const hasErrors = filteredList.some((r) => r.status >= 400); + return ( + <React.Fragment> + <SlideModal + right + size="middle" + title={ + <div className="flex justify-between"> + <h1>Fetch Request</h1> + <div className="flex items-center"> + <div className="flex items-center"> + <span className="mr-2 color-gray-medium uppercase text-base">Status</span> + <Label + data-red={current && current.status >= 400} + data-green={current && current.status < 400} + > + <div className="uppercase w-16 justify-center code-font text-lg"> + {current && current.status} + </div> + </Label> + </div> + <CloseButton onClick={this.closeModal} size="18" className="ml-2" /> + </div> + </div> + } + isDisplayed={current != null && showFetchDetails} + content={ + current && + showFetchDetails && ( + <FetchDetails + resource={current} + nextClick={this.nextClickHander} + prevClick={this.prevClickHander} + first={currentIndex === 0} + last={currentIndex === filteredList.length - 1} + /> + ) + } + onClose={this.closeModal} + /> + <BottomBlock> + <BottomBlock.Header> + <span className="font-semibold color-gray-medium mr-4">Fetch</span> + <div className="flex items-center"> + <Input + className="input-small" + placeholder="Filter" + icon="search" + iconPosition="left" + name="filter" + onChange={this.onFilterChange} + /> + </div> + </BottomBlock.Header> + <BottomBlock.Content> + <NoContent title={ + <div className="capitalize flex items-center mt-16"> + <Icon name="info-circle" className="mr-2" size="18" /> + No Data + </div> + } show={filteredList.length === 0}> + <TimeTable + rows={filteredList} + onRowClick={this.onRowClick} + hoverable + activeIndex={listNow.length - 1} + > + {[ + { + label: 'Start', + width: 120, + render: renderStart, + }, + { + label: 'Status', + dataKey: 'status', + width: 70, + }, + { + label: 'Method', + dataKey: 'method', + width: 60, + }, + { + label: 'Name', + width: 240, + render: renderName, + }, + { + label: 'Time', + width: 80, + render: renderDuration, + }, + ]} + </TimeTable> + </NoContent> + </BottomBlock.Content> + </BottomBlock> + </React.Fragment> + ); + } } diff --git a/frontend/app/components/Session_/GraphQL/GQLDetails.js b/frontend/app/components/Session_/GraphQL/GQLDetails.js index 47ec43239..4caba50a7 100644 --- a/frontend/app/components/Session_/GraphQL/GQLDetails.js +++ b/frontend/app/components/Session_/GraphQL/GQLDetails.js @@ -1,80 +1,72 @@ import React from 'react'; -import { JSONTree, Button } from 'UI' +import { JSONTree, Button } from 'UI'; import cn from 'classnames'; export default class GQLDetails extends React.PureComponent { - render() { - const { - gql: { - variables, - response, - duration, - operationKind, - operationName, - }, - nextClick, - prevClick, - first = false, - last = false, - } = this.props; + render() { + const { + gql: { variables, response, duration, operationKind, operationName }, + nextClick, + prevClick, + first = false, + last = false, + } = this.props; - let jsonVars = undefined; - let jsonResponse = undefined; - try { - jsonVars = JSON.parse(payload); - } catch (e) {} - try { - jsonResponse = JSON.parse(response); - } catch (e) {} - return ( - <div className="px-4 pb-16"> - <h5 className="mb-2">{ 'Operation Name'}</h5> - <div className={ cn('p-2 bg-gray-lightest rounded color-gray-darkest')}>{ operationName }</div> + let jsonVars = undefined; + let jsonResponse = undefined; + try { + jsonVars = JSON.parse(variables); + } catch (e) {} + try { + jsonResponse = JSON.parse(response); + } catch (e) {} + const dataClass = cn('p-2 bg-gray-lightest rounded color-gray-darkest'); + return ( + <div className="px-4 pb-16"> + <h5 className="mb-2">{'Operation Name'}</h5> + <div className={dataClass}>{operationName}</div> - <div className="flex items-center mt-4"> - <div className="w-4/12"> - <div className="font-medium mb-2">Operation Kind</div> - <div>{operationKind}</div> - </div> - <div className="w-4/12"> - <div className="font-medium mb-2">Duration</div> - <div>{parseInt(duration)} ms</div> - </div> - </div> + <div className="flex items-center gap-4 mt-4"> + <div className="w-6/12"> + <div className="mb-2">Operation Kind</div> + <div className={dataClass}>{operationKind}</div> + </div> + <div className="w-6/12"> + <div className="mb-2">Duration</div> + <div className={dataClass}>{duration ? parseInt(duration) : '???'} ms</div> + </div> + </div> - <div className="flex justify-between items-start mt-6"> - <h5 className="mt-1 mr-1">{ 'Response' }</h5> - </div> - <div style={{ height: 'calc(100vh - 314px)', overflowY: 'auto' }}> - { variables && variables !== "{}" && - <div> - <div className="mt-2"> - <h5>{ 'Variables'}</h5> - { jsonVars === undefined - ? <div className="ml-3"> { variables } </div> - : <JSONTree src={ jsonVars } /> - } - </div> - <div className="divider"/> - </div> - } - <div className="mt-3"> - { jsonResponse === undefined - ? <div className="ml-3"> { response } </div> - : <JSONTree src={ jsonResponse } /> - } - </div> - </div> + <div style={{ height: 'calc(100vh - 314px)', overflowY: 'auto' }}> + <div> + <div className="flex justify-between items-start mt-6 mb-2"> + <h5 className="mt-1 mr-1">{'Variables'}</h5> + </div> + <div className={dataClass}> + {jsonVars === undefined ? variables : <JSONTree src={jsonVars} />} + </div> + <div className="divider" /> + </div> - <div className="flex justify-between absolute bottom-0 left-0 right-0 p-3 border-t bg-white"> - <Button variant="outline" onClick={prevClick} disabled={first}> - Prev - </Button> - <Button variant="outline" onClick={nextClick} disabled={last}> - Next - </Button> - </div> - </div> - ); - } -} \ No newline at end of file + <div> + <div className="flex justify-between items-start mt-6 mb-2"> + <h5 className="mt-1 mr-1">{'Response'}</h5> + </div> + <div className={dataClass}> + {jsonResponse === undefined ? response : <JSONTree src={jsonResponse} />} + </div> + </div> + </div> + + <div className="flex justify-between absolute bottom-0 left-0 right-0 p-3 border-t bg-white"> + <Button variant="outline" onClick={prevClick} disabled={first}> + Prev + </Button> + <Button variant="outline" onClick={nextClick} disabled={last}> + Next + </Button> + </div> + </div> + ); + } +} diff --git a/frontend/app/components/Session_/GraphQL/GraphQL.js b/frontend/app/components/Session_/GraphQL/GraphQL.js index 2d3a112e4..a5420584f 100644 --- a/frontend/app/components/Session_/GraphQL/GraphQL.js +++ b/frontend/app/components/Session_/GraphQL/GraphQL.js @@ -1,127 +1,177 @@ import React from 'react'; -import { NoContent, Input, SlideModal, CloseButton } from 'UI'; +import { NoContent, Input, SlideModal, CloseButton, Button } from 'UI'; import { getRE } from 'App/utils'; import { connectPlayer, pause, jump } from 'Player'; import BottomBlock from '../BottomBlock'; import TimeTable from '../TimeTable'; import GQLDetails from './GQLDetails'; +import { renderStart } from 'Components/Session_/Network/NetworkContent'; function renderDefaultStatus() { - return "2xx-3xx"; + return '2xx-3xx'; } -@connectPlayer(state => ({ - list: state.graphqlListNow, + +export function renderName(r) { + return ( + <div className="flex justify-between items-center grow-0 w-full"> + <div>{r.operationName}</div> + <Button + variant="text" + className="right-0 text-xs uppercase p-2 color-gray-500 hover:color-teal" + onClick={(e) => { + e.stopPropagation(); + jump(r.time); + }} + > + Jump + </Button> + </div> + ); +} + +@connectPlayer((state) => ({ + list: state.graphqlList, + listNow: state.graphqlListNow, + time: state.time, livePlay: state.livePlay, })) export default class GraphQL extends React.PureComponent { - state = { - filter: "", + state = { + filter: '', filteredList: this.props.list, - current: null, + filteredListNow: this.props.listNow, + current: null, currentIndex: 0, showFetchDetails: false, hasNextError: false, hasPreviousError: false, - } + lastActiveItem: 0, + }; + + static filterList(list, value) { + const filterRE = getRE(value, 'i'); + + return value + ? list.filter( + (r) => + filterRE.test(r.operationKind) || + filterRE.test(r.operationName) || + filterRE.test(r.variables) + ) + : list; + } onFilterChange = ({ target: { value } }) => { const { list } = this.props; - const filterRE = getRE(value, 'i'); - const filtered = list - .filter((r) => filterRE.test(r.name) || filterRE.test(r.url) || filterRE.test(r.method) || filterRE.test(r.status)); - this.setState({ filter: value, filteredList: value ? filtered : list, currentIndex: 0 }); - } + const filtered = GraphQL.filterList(list, value); + this.setState({ filter: value, filteredList: filtered, currentIndex: 0 }); + }; setCurrent = (item, index) => { if (!this.props.livePlay) { pause(); - jump(item.time) + jump(item.time); } this.setState({ current: item, currentIndex: index }); - } + }; closeModal = () => this.setState({ current: null, showFetchDetails: false }); static getDerivedStateFromProps(nextProps, prevState) { - const { filteredList } = prevState; - if (nextProps.timelinePointer) { - let activeItem = filteredList.find((r) => r.time >= nextProps.timelinePointer.time); - activeItem = activeItem || filteredList[filteredList.length - 1]; + const { list } = nextProps; + if (nextProps.time) { + const filtered = GraphQL.filterList(list, prevState.filter); + console.log({ + list, + filtered, + time: nextProps.time, + }); + + let i = 0; + filtered.forEach((item, index) => { + if (item.time <= nextProps.time) { + i = index; + } + }); + return { - current: activeItem, - currentIndex: filteredList.indexOf(activeItem), + lastActiveItem: i, }; } } render() { - const { list } = this.props; - const { current, currentIndex, filteredList } = this.state; - + const { current, currentIndex, filteredList, lastActiveItem } = this.state; + return ( <React.Fragment> - <SlideModal + <SlideModal size="middle" right - title = { + title={ <div className="flex justify-between"> <h1>GraphQL</h1> <div className="flex items-center"> - <CloseButton onClick={ this.closeModal } size="18" className="ml-2" /> + <CloseButton onClick={this.closeModal} size="18" className="ml-2" /> </div> </div> } - isDisplayed={ current != null } - content={ current && - <GQLDetails - gql={ current } - nextClick={this.nextClickHander} - prevClick={this.prevClickHander} - first={currentIndex === 0} - last={currentIndex === filteredList.length - 1} - /> + isDisplayed={current != null} + content={ + current && ( + <GQLDetails + gql={current} + nextClick={this.nextClickHander} + prevClick={this.prevClickHander} + first={currentIndex === 0} + last={currentIndex === filteredList.length - 1} + /> + ) } - onClose={ this.closeModal } + onClose={this.closeModal} /> <BottomBlock> <BottomBlock.Header> - <h4 className="text-lg">GraphQL</h4> + <span className="font-semibold color-gray-medium mr-4">GraphQL</span> <div className="flex items-center"> <Input // className="input-small" - placeholder="Filter by Name or Type" + placeholder="Filter by name or type" icon="search" iconPosition="left" name="filter" - onChange={ this.onFilterChange } + onChange={this.onFilterChange} /> </div> </BottomBlock.Header> <BottomBlock.Content> - <NoContent - size="small" - show={ filteredList.length === 0} - > + <NoContent size="small" title="No recordings found" show={filteredList.length === 0}> <TimeTable - rows={ filteredList } - onRowClick={ this.setCurrent } + rows={filteredList} + onRowClick={this.setCurrent} hoverable - navigation - activeIndex={currentIndex} + activeIndex={lastActiveItem} > {[ { - label: "Status", + label: 'Start', + width: 90, + render: renderStart, + }, + { + label: 'Status', width: 70, render: renderDefaultStatus, - }, { - label: "Type", - dataKey: "operationKind", + }, + { + label: 'Type', + dataKey: 'operationKind', width: 60, - }, { - label: "Name", - width: 130, - dataKey: "operationName", + }, + { + label: 'Name', + width: 240, + render: renderName, }, ]} </TimeTable> diff --git a/frontend/app/components/Session_/Issues/IssueForm.js b/frontend/app/components/Session_/Issues/IssueForm.js index 991a227ec..bedbb2860 100644 --- a/frontend/app/components/Session_/Issues/IssueForm.js +++ b/frontend/app/components/Session_/Issues/IssueForm.js @@ -14,13 +14,14 @@ const SelectedValue = ({ icon, text }) => { </div> ) } - -class IssueForm extends React.PureComponent { + +class IssueForm extends React.PureComponent { componentDidMount() { const { projects, issueTypes } = this.props; + this.props.init({ - projectId: projects.first() ? projects.first().id : '', - issueType: issueTypes.first() ? issueTypes.first().id : '' + projectId: projects[0] ? projects[0].id : '', + issueType: issueTypes[0] ? issueTypes[0].id : '' }); } @@ -57,15 +58,15 @@ class IssueForm extends React.PureComponent { const { creating, projects, users, issueTypes, instance, closeHandler, metaLoading } = this.props; const projectOptions = projects.map(({name, id}) => ({label: name, value: id })).toArray(); const userOptions = users.map(({name, id}) => ({label: name, value: id })).toArray(); - + const issueTypeOptions = issueTypes.map(({name, id, iconUrl, color }) => { return { label: name, value: id, iconUrl, color } }); const selectedIssueType = issueTypes.filter(issue => issue.id == instance.issueType)[0]; - + return ( - <Form onSubmit={ this.onSubmit }> + <Form onSubmit={ this.onSubmit } className="text-left"> <Form.Field className="mb-15-imp"> <label htmlFor="issueType"> <span className="mr-2">Project</span> @@ -120,7 +121,7 @@ class IssueForm extends React.PureComponent { <Form.Field className="mb-15-imp"> <label htmlFor="description"> - Description + Description {/* <span className="text-sm text-gray-500">(Optional)</span> */} </label> <textarea diff --git a/frontend/app/components/Session_/Issues/Issues.js b/frontend/app/components/Session_/Issues/Issues.js index a5c7e1a61..e47ea8fc9 100644 --- a/frontend/app/components/Session_/Issues/Issues.js +++ b/frontend/app/components/Session_/Issues/Issues.js @@ -61,8 +61,14 @@ class Issues extends React.Component { <div className={ stl.buttonWrapper} onClick={this.handleOpen}> <Popup open={this.state.showModal} - position="top right" + position="bottom" interactive + animation="shift" + trigger="click" + unmountHTMLWhenHide + // @ts-ignore + theme='light' + arrow content={ <OutsideClickDetectingDiv onClickOutside={this.closeModal}> <IssuesModal @@ -72,7 +78,6 @@ class Issues extends React.Component { /> </OutsideClickDetectingDiv> } - theme="tippy-light" > <div className="flex items-center" onClick={this.handleOpen} disabled={!isModalDisplayed && (metaLoading || fetchIssuesLoading || projectsLoading)}> <Icon name={ `integrations/${ provider === 'jira' ? 'jira' : 'github'}` } size="16" /> diff --git a/frontend/app/components/Session_/Issues/issuesModal.module.css b/frontend/app/components/Session_/Issues/issuesModal.module.css index d5768976a..67ed66435 100644 --- a/frontend/app/components/Session_/Issues/issuesModal.module.css +++ b/frontend/app/components/Session_/Issues/issuesModal.module.css @@ -3,4 +3,5 @@ width: 350px; border-radius: 3px; padding: 10px 8px; -} \ No newline at end of file + color: black; +} diff --git a/frontend/app/components/Session_/LongTasks/LongTasks.js b/frontend/app/components/Session_/LongTasks/LongTasks.js index 55f204ea4..fd3b4cc17 100644 --- a/frontend/app/components/Session_/LongTasks/LongTasks.js +++ b/frontend/app/components/Session_/LongTasks/LongTasks.js @@ -50,7 +50,7 @@ export default class GraphQL extends React.PureComponent { return ( <BottomBlock> <BottomBlock.Header> - <h4 className="text-lg">Long Tasks</h4> + <span className="font-semibold color-gray-medium mr-4">Long Tasks</span> <div className="flex items-center"> <Input className="input-small mr-3" @@ -75,6 +75,7 @@ export default class GraphQL extends React.PureComponent { <BottomBlock.Content> <NoContent size="small" + title="No recordings found" show={ filtered.length === 0} > <TimeTable diff --git a/frontend/app/components/Session_/Network/Network.js b/frontend/app/components/Session_/Network/Network.js index 887cc1148..08779abd6 100644 --- a/frontend/app/components/Session_/Network/Network.js +++ b/frontend/app/components/Session_/Network/Network.js @@ -18,128 +18,131 @@ const MEDIA = 'media'; const OTHER = 'other'; const TAB_TO_TYPE_MAP = { - [XHR]: TYPES.XHR, - [JS]: TYPES.JS, - [CSS]: TYPES.CSS, - [IMG]: TYPES.IMG, - [MEDIA]: TYPES.MEDIA, - [OTHER]: TYPES.OTHER, + [XHR]: TYPES.XHR, + [JS]: TYPES.JS, + [CSS]: TYPES.CSS, + [IMG]: TYPES.IMG, + [MEDIA]: TYPES.MEDIA, + [OTHER]: TYPES.OTHER, }; export function renderName(r) { - return ( - <div className="flex justify-between items-center grow-0 w-full"> - <Popup style={{ maxWidth: '75%' }} content={<div className={stl.popupNameContent}>{r.url}</div>}> - <TextEllipsis>{r.name}</TextEllipsis> - </Popup> - <Button - variant="text" - className="right-0 text-xs uppercase p-2 color-gray-500 hover:color-teal" - onClick={(e) => { - e.stopPropagation(); - jump(r.time); - }} - > - Jump - </Button> - </div> - ); + return ( + <div className="flex justify-between items-center grow-0 w-full"> + <Popup + style={{ maxWidth: '75%' }} + content={<div className={stl.popupNameContent}>{r.url}</div>} + > + <TextEllipsis>{r.name}</TextEllipsis> + </Popup> + </div> + ); } export function renderDuration(r) { - if (!r.success) return 'x'; + if (!r.success) return 'x'; - const text = `${Math.round(r.duration)}ms`; - if (!r.isRed() && !r.isYellow()) return text; + const text = `${Math.round(r.duration)}ms`; + if (!r.isRed() && !r.isYellow()) return text; - let tooltipText; - let className = 'w-full h-full flex items-center '; - if (r.isYellow()) { - tooltipText = 'Slower than average'; - className += 'warn color-orange'; - } else { - tooltipText = 'Much slower than average'; - className += 'error color-red'; - } + let tooltipText; + let className = 'w-full h-full flex items-center '; + if (r.isYellow()) { + tooltipText = 'Slower than average'; + className += 'warn color-orange'; + } else { + tooltipText = 'Much slower than average'; + className += 'error color-red'; + } - return ( - <Popup content={tooltipText}> - <div className={cn(className, stl.duration)}> {text} </div> - </Popup> - ); + return ( + <Popup content={tooltipText}> + <div className={cn(className, stl.duration)}> {text} </div> + </Popup> + ); } @connectPlayer((state) => ({ - location: state.location, - resources: state.resourceList, - domContentLoadedTime: state.domContentLoadedTime, - loadTime: state.loadTime, - // time: state.time, - playing: state.playing, - domBuildingTime: state.domBuildingTime, - fetchPresented: state.fetchList.length > 0, - listNow: state.resourceListNow, + location: state.location, + resources: state.resourceList, + domContentLoadedTime: state.domContentLoadedTime, + loadTime: state.loadTime, + // time: state.time, + playing: state.playing, + domBuildingTime: state.domBuildingTime, + fetchPresented: state.fetchList.length > 0, + listNow: state.resourceListNow, })) @connect( - (state) => ({ - timelinePointer: state.getIn(['sessions', 'timelinePointer']), - }), - { setTimelinePointer } + (state) => ({ + timelinePointer: state.getIn(['sessions', 'timelinePointer']), + }), + { setTimelinePointer } ) export default class Network extends React.PureComponent { - state = { - filter: '', - filteredList: this.props.resources, - activeTab: ALL, - currentIndex: 0, - }; + state = { + filter: '', + filteredList: this.props.resources, + activeTab: ALL, + currentIndex: 0, + }; - onRowClick = (e, index) => { - pause(); - jump(e.time); - this.setState({ currentIndex: index }); - this.props.setTimelinePointer(null); - }; + onRowClick = (e, index) => { + // no action for direct click on network requests (so far), there is a jump button, and we don't have more information for than is already displayed in the table + }; - onTabClick = (activeTab) => this.setState({ activeTab }); + onTabClick = (activeTab) => this.setState({ activeTab }); - onFilterChange = (e, { value }) => { - const { resources } = this.props; - const filterRE = getRE(value, 'i'); - const filtered = resources.filter(({ type, name }) => filterRE.test(name) && (activeTab === ALL || type === TAB_TO_TYPE_MAP[activeTab])); + onFilterChange = (e, { value }) => { + const { resources } = this.props; + const filterRE = getRE(value, 'i'); + const filtered = resources.filter( + ({ type, name }) => + filterRE.test(name) && (activeTab === ALL || type === TAB_TO_TYPE_MAP[activeTab]) + ); - this.setState({ filter: value, filteredList: value ? filtered : resources, currentIndex: 0 }); - }; + this.setState({ filter: value, filteredList: value ? filtered : resources, currentIndex: 0 }); + }; - render() { - const { - location, - domContentLoadedTime, - loadTime, - domBuildingTime, - fetchPresented, - listNow, - } = this.props; - const { filteredList } = this.state; - const resourcesSize = filteredList.reduce((sum, { decodedBodySize }) => sum + (decodedBodySize || 0), 0); - const transferredSize = filteredList.reduce((sum, { headerSize, encodedBodySize }) => sum + (headerSize || 0) + (encodedBodySize || 0), 0); - - return ( - <React.Fragment> - <NetworkContent - // time = { time } - location={location} - resources={filteredList} - domContentLoadedTime={domContentLoadedTime} - loadTime={loadTime} - domBuildingTime={domBuildingTime} - fetchPresented={fetchPresented} - resourcesSize={resourcesSize} - transferredSize={transferredSize} - onRowClick={this.onRowClick} - currentIndex={listNow.length - 0} - /> - </React.Fragment> - ); + static getDerivedStateFromProps(nextProps, prevState) { + const { filteredList } = prevState; + if (nextProps.timelinePointer) { + const activeItem = filteredList.find((r) => r.time >= nextProps.timelinePointer.time); + return { + currentIndex: activeItem ? filteredList.indexOf(activeItem) : filteredList.length - 1, + }; } + } + + render() { + const { location, domContentLoadedTime, loadTime, domBuildingTime, fetchPresented, listNow } = + this.props; + const { filteredList } = this.state; + const resourcesSize = filteredList.reduce( + (sum, { decodedBodySize }) => sum + (decodedBodySize || 0), + 0 + ); + const transferredSize = filteredList.reduce( + (sum, { headerSize, encodedBodySize }) => sum + (headerSize || 0) + (encodedBodySize || 0), + 0 + ); + + return ( + <React.Fragment> + <NetworkContent + // time = { time } + location={location} + resources={filteredList} + domContentLoadedTime={domContentLoadedTime} + loadTime={loadTime} + domBuildingTime={domBuildingTime} + fetchPresented={fetchPresented} + resourcesSize={resourcesSize} + transferredSize={transferredSize} + onRowClick={this.onRowClick} + currentIndex={listNow.length - 1} + /> + </React.Fragment> + ); + } } diff --git a/frontend/app/components/Session_/Network/NetworkContent.js b/frontend/app/components/Session_/Network/NetworkContent.js index 8e0183324..082c87aa0 100644 --- a/frontend/app/components/Session_/Network/NetworkContent.js +++ b/frontend/app/components/Session_/Network/NetworkContent.js @@ -1,7 +1,7 @@ import React from 'react'; import cn from 'classnames'; // import { connectPlayer } from 'Player'; -import { QuestionMarkHint, Popup, Tabs, Input } from 'UI'; +import { QuestionMarkHint, Popup, Tabs, Input, NoContent, Icon, Button } from 'UI'; import { getRE } from 'App/utils'; import { TYPES } from 'Types/session/resource'; import { formatBytes } from 'App/utils'; @@ -11,6 +11,8 @@ import TimeTable from '../TimeTable'; import BottomBlock from '../BottomBlock'; import InfoLine from '../BottomBlock/InfoLine'; import stl from './network.module.css'; +import { Duration } from 'luxon'; +import { jump } from 'Player'; const ALL = 'ALL'; const XHR = 'xhr'; @@ -21,47 +23,84 @@ const MEDIA = 'media'; const OTHER = 'other'; const TAB_TO_TYPE_MAP = { - [ XHR ]: TYPES.XHR, - [ JS ]: TYPES.JS, - [ CSS ]: TYPES.CSS, - [ IMG ]: TYPES.IMG, - [ MEDIA ]: TYPES.MEDIA, - [ OTHER ]: TYPES.OTHER -} -const TABS = [ ALL, XHR, JS, CSS, IMG, MEDIA, OTHER ].map(tab => ({ + [XHR]: TYPES.XHR, + [JS]: TYPES.JS, + [CSS]: TYPES.CSS, + [IMG]: TYPES.IMG, + [MEDIA]: TYPES.MEDIA, + [OTHER]: TYPES.OTHER, +}; +const TABS = [ALL, XHR, JS, CSS, IMG, MEDIA, OTHER].map((tab) => ({ text: tab, key: tab, })); -const DOM_LOADED_TIME_COLOR = "teal"; -const LOAD_TIME_COLOR = "red"; +const DOM_LOADED_TIME_COLOR = 'teal'; +const LOAD_TIME_COLOR = 'red'; -export function renderType(r) { +export function renderType(r) { return ( - <Popup style={{width: '100%'}} content={ <div className={ stl.popupNameContent }>{ r.type }</div> } > - <div className={ stl.popupNameTrigger }>{ r.type }</div> + <Popup style={{ width: '100%' }} content={<div className={stl.popupNameContent}>{r.type}</div>}> + <div className={stl.popupNameTrigger}>{r.type}</div> </Popup> ); } -export function renderName(r) { +export function renderName(r) { return ( - <Popup style={{width: '100%'}} content={ <div className={ stl.popupNameContent }>{ r.url }</div> } > - <div className={ stl.popupNameTrigger }>{ r.name }</div> - </Popup> + <Popup + style={{ width: '100%' }} + content={<div className={stl.popupNameContent}>{r.url}</div>} + > + <div className={stl.popupNameTrigger}>{r.name}</div> + </Popup> ); } +export function renderStart(r) { + return ( + <div className="flex justify-between items-center grow-0 w-full"> + <span> + {Duration.fromMillis(r.time).toFormat('mm:ss.SSS')} + </span> + <Button + variant="text" + className="right-0 text-xs uppercase p-2 color-gray-500 hover:color-teal" + onClick={(e) => { + e.stopPropagation(); + jump(r.time); + }} + > + Jump + </Button> + </div> + ) +} + const renderXHRText = () => ( <span className="flex items-center"> {XHR} <QuestionMarkHint onHover={true} - content={ + content={ <> - Use our <a className="color-teal underline" target="_blank" href="https://docs.openreplay.com/plugins/fetch">Fetch plugin</a> - {' to capture HTTP requests and responses, including status codes and bodies.'} <br/> - We also provide <a className="color-teal underline" target="_blank" href="https://docs.openreplay.com/plugins/graphql">support for GraphQL</a> + Use our{' '} + <a + className="color-teal underline" + target="_blank" + href="https://docs.openreplay.com/plugins/fetch" + > + Fetch plugin + </a> + {' to capture HTTP requests and responses, including status codes and bodies.'} <br /> + We also provide{' '} + <a + className="color-teal underline" + target="_blank" + href="https://docs.openreplay.com/plugins/graphql" + > + support for GraphQL + </a> {' for easy debugging of your queries.'} </> } @@ -75,8 +114,8 @@ function renderSize(r) { let triggerText; let content; if (r.decodedBodySize == null) { - triggerText = "x"; - content = "Not captured"; + triggerText = 'x'; + content = 'Not captured'; } else { const headerSize = r.headerSize || 0; const encodedSize = r.encodedBodySize || 0; @@ -86,17 +125,17 @@ function renderSize(r) { triggerText = formatBytes(r.decodedBodySize); content = ( <ul> - { showTransferred && - <li>{`${formatBytes( r.encodedBodySize + headerSize )} transfered over network`}</li> - } + {showTransferred && ( + <li>{`${formatBytes(r.encodedBodySize + headerSize)} transfered over network`}</li> + )} <li>{`Resource size: ${formatBytes(r.decodedBodySize)} `}</li> </ul> ); } return ( - <Popup style={{width: '100%'}} content={ content } > - <div>{ triggerText }</div> + <Popup style={{ width: '100%' }} content={content}> + <div>{triggerText}</div> </Popup> ); } @@ -104,25 +143,22 @@ function renderSize(r) { export function renderDuration(r) { if (!r.success) return 'x'; - const text = `${ Math.floor(r.duration) }ms`; + const text = `${Math.floor(r.duration)}ms`; if (!r.isRed() && !r.isYellow()) return text; let tooltipText; - let className = "w-full h-full flex items-center "; + let className = 'w-full h-full flex items-center '; if (r.isYellow()) { - tooltipText = "Slower than average"; - className += "warn color-orange"; + tooltipText = 'Slower than average'; + className += 'warn color-orange'; } else { - tooltipText = "Much slower than average"; - className += "error color-red"; + tooltipText = 'Much slower than average'; + className += 'error color-red'; } return ( - <Popup - style={{width: '100%'}} - content={ tooltipText } - > - <div className={ cn(className, stl.duration) } > { text } </div> + <Popup style={{ width: '100%' }} content={tooltipText}> + <div className={cn(className, stl.duration)}> {text} </div> </Popup> ); } @@ -131,13 +167,13 @@ export default class NetworkContent extends React.PureComponent { state = { filter: '', activeTab: ALL, - } + }; - onTabClick = activeTab => this.setState({ activeTab }) - onFilterChange = ({ target: { value } }) => this.setState({ filter: value }) + onTabClick = (activeTab) => this.setState({ activeTab }); + onFilterChange = ({ target: { value } }) => this.setState({ filter: value }); render() { - const { + const { location, resources, domContentLoadedTime, @@ -150,138 +186,150 @@ export default class NetworkContent extends React.PureComponent { resourcesSize, transferredSize, time, - currentIndex + currentIndex, } = this.props; const { filter, activeTab } = this.state; const filterRE = getRE(filter, 'i'); - let filtered = resources.filter(({ type, name }) => - filterRE.test(name) && (activeTab === ALL || type === TAB_TO_TYPE_MAP[ activeTab ])); - const lastIndex = currentIndex || filtered.filter(item => item.time <= time).length - 1; + let filtered = resources.filter( + ({ type, name }) => + filterRE.test(name) && (activeTab === ALL || type === TAB_TO_TYPE_MAP[activeTab]) + ); + const lastIndex = currentIndex || filtered.filter((item) => item.time <= time).length - 1; const referenceLines = []; if (domContentLoadedTime != null) { referenceLines.push({ time: domContentLoadedTime.time, color: DOM_LOADED_TIME_COLOR, - }) + }); } if (loadTime != null) { referenceLines.push({ time: loadTime.time, color: LOAD_TIME_COLOR, - }) + }); } let tabs = TABS; if (!fetchPresented) { - tabs = TABS.map(tab => !isResult && tab.key === XHR - ? { - text: renderXHRText(), - key: XHR, - } - : tab + tabs = TABS.map((tab) => + !isResult && tab.key === XHR + ? { + text: renderXHRText(), + key: XHR, + } + : tab ); } - // const resourcesSize = filtered.reduce((sum, { decodedBodySize }) => sum + (decodedBodySize || 0), 0); - // const transferredSize = filtered - // .reduce((sum, { headerSize, encodedBodySize }) => sum + (headerSize || 0) + (encodedBodySize || 0), 0); - return ( <React.Fragment> <BottomBlock style={{ height: 300 + additionalHeight + 'px' }} className="border"> <BottomBlock.Header showClose={!isResult}> <div className="flex items-center"> <span className="font-semibold color-gray-medium mr-4">Network</span> - <Tabs + <Tabs className="uppercase" - tabs={ tabs } - active={ activeTab } - onClick={ this.onTabClick } - border={ false } + tabs={tabs} + active={activeTab} + onClick={this.onTabClick} + border={false} /> </div> <Input - // className="input-small" - placeholder="Filter by Name" + className="input-small" + placeholder="Filter by name" icon="search" iconPosition="left" name="filter" - onChange={ this.onFilterChange } + onChange={this.onFilterChange} /> </BottomBlock.Header> <BottomBlock.Content> - {/* <div className={ stl.location }> */} - {/* <Icon name="window" marginRight="8" /> */} - {/* <div>{ location }</div> */} - {/* <div></div> */} - {/* </div> */} <InfoLine> - <InfoLine.Point label={ filtered.length } value=" requests" /> - <InfoLine.Point - label={ formatBytes(transferredSize) } + <InfoLine.Point label={filtered.length} value=" requests" /> + <InfoLine.Point + label={formatBytes(transferredSize)} value="transferred" - display={ transferredSize > 0 } + display={transferredSize > 0} /> - <InfoLine.Point - label={ formatBytes(resourcesSize) } + <InfoLine.Point + label={formatBytes(resourcesSize)} value="resources" - display={ resourcesSize > 0 } + display={resourcesSize > 0} /> - <InfoLine.Point - label="DOM Building Time" - value={ formatMs(domBuildingTime)} - display={ domBuildingTime != null } + <InfoLine.Point + label={formatMs(domBuildingTime)} + value="DOM Building Time" + display={domBuildingTime != null} /> - <InfoLine.Point - label="DOMContentLoaded" - value={ domContentLoadedTime && formatMs(domContentLoadedTime.value)} - display={ domContentLoadedTime != null } - dotColor={ DOM_LOADED_TIME_COLOR } + <InfoLine.Point + label={domContentLoadedTime && formatMs(domContentLoadedTime.value)} + value="DOMContentLoaded" + display={domContentLoadedTime != null} + dotColor={DOM_LOADED_TIME_COLOR} /> - <InfoLine.Point - label="Load" - value={ loadTime && formatMs(loadTime.value)} - display={ loadTime != null } - dotColor={ LOAD_TIME_COLOR } + <InfoLine.Point + label={loadTime && formatMs(loadTime.value)} + value="Load" + display={loadTime != null} + dotColor={LOAD_TIME_COLOR} /> </InfoLine> - <TimeTable - rows={ filtered } - referenceLines={referenceLines} - renderPopup - // navigation - onRowClick={onRowClick} - additionalHeight={additionalHeight} - activeIndex={lastIndex} + <NoContent + title={ + <div className="capitalize flex items-center mt-16"> + <Icon name="info-circle" className="mr-2" size="18" /> + No Data + </div> + } + size="small" + show={filtered.length === 0} > - {[ - { - label: "Status", - dataKey: 'status', - width: 70, - }, { - label: "Type", - dataKey: 'type', - width: 90, - render: renderType, - }, { - label: "Name", - width: 200, - render: renderName, - }, - { - label: "Size", - width: 60, - render: renderSize, - }, - { - label: "Time", - width: 80, - render: renderDuration, - } - ]} - </TimeTable> + <TimeTable + rows={filtered} + referenceLines={referenceLines} + renderPopup + // navigation + onRowClick={onRowClick} + additionalHeight={additionalHeight} + activeIndex={lastIndex} + > + {[ + { + label: 'Start', + width: 120, + render: renderStart, + }, + { + label: 'Status', + dataKey: 'status', + width: 70, + }, + { + label: 'Type', + dataKey: 'type', + width: 90, + render: renderType, + }, + { + label: 'Name', + width: 240, + render: renderName, + }, + { + label: 'Size', + width: 60, + render: renderSize, + }, + { + label: 'Time', + width: 80, + render: renderDuration, + }, + ]} + </TimeTable> + </NoContent> </BottomBlock.Content> </BottomBlock> </React.Fragment> diff --git a/frontend/app/components/Session_/OverviewPanel/OverviewPanel.tsx b/frontend/app/components/Session_/OverviewPanel/OverviewPanel.tsx new file mode 100644 index 000000000..0f2e99437 --- /dev/null +++ b/frontend/app/components/Session_/OverviewPanel/OverviewPanel.tsx @@ -0,0 +1,146 @@ +import { connectPlayer } from 'App/player'; +import { toggleBottomBlock } from 'Duck/components/player'; +import React, { useEffect } from 'react'; +import BottomBlock from '../BottomBlock'; +import EventRow from './components/EventRow'; +import { TYPES } from 'Types/session/event'; +import { connect } from 'react-redux'; +import TimelineScale from './components/TimelineScale'; +import FeatureSelection, { HELP_MESSAGE } from './components/FeatureSelection/FeatureSelection'; +import TimelinePointer from './components/TimelinePointer'; +import VerticalPointerLine from './components/VerticalPointerLine'; +import cn from 'classnames'; +// import VerticalLine from './components/VerticalLine'; +import OverviewPanelContainer from './components/OverviewPanelContainer'; +import { NoContent, Icon } from 'UI'; + +interface Props { + resourceList: any[]; + exceptionsList: any[]; + eventsList: any[]; + toggleBottomBlock: any; + stackEventList: any[]; + issuesList: any[]; + performanceChartData: any; + endTime: number; +} +function OverviewPanel(props: Props) { + const [dataLoaded, setDataLoaded] = React.useState(false); + const [selectedFeatures, setSelectedFeatures] = React.useState([ + 'PERFORMANCE', + 'ERRORS', + 'EVENTS', + ]); + + const resources: any = React.useMemo(() => { + const { + resourceList, + exceptionsList, + eventsList, + stackEventList, + issuesList, + performanceChartData, + } = props; + return { + NETWORK: resourceList, + ERRORS: exceptionsList, + EVENTS: stackEventList, + CLICKRAGE: eventsList.filter((item: any) => item.type === TYPES.CLICKRAGE), + PERFORMANCE: performanceChartData, + }; + }, [dataLoaded]); + + useEffect(() => { + if (dataLoaded) { + return; + } + + if ( + props.resourceList.length > 0 || + props.exceptionsList.length > 0 || + props.eventsList.length > 0 || + props.stackEventList.length > 0 || + props.issuesList.length > 0 || + props.performanceChartData.length > 0 + ) { + setDataLoaded(true); + } + }, [ + props.resourceList, + props.exceptionsList, + props.eventsList, + props.stackEventList, + props.performanceChartData, + ]); + + return ( + <Wrapper {...props}> + <BottomBlock style={{ height: '245px' }}> + <BottomBlock.Header> + <span className="font-semibold color-gray-medium mr-4">X-RAY</span> + <div className="flex items-center h-20"> + <FeatureSelection list={selectedFeatures} updateList={setSelectedFeatures} /> + </div> + </BottomBlock.Header> + <BottomBlock.Content> + <OverviewPanelContainer endTime={props.endTime}> + <TimelineScale endTime={props.endTime} /> + <div style={{ width: '100%', height: '187px' }} className="transition relative"> + <NoContent + show={selectedFeatures.length === 0} + title={ + <div className="flex items-center mt-16"> + <Icon name="info-circle" className="mr-2" size="18" /> + Select a debug option to visualize on timeline. + </div> + } + > + <VerticalPointerLine /> + {selectedFeatures.map((feature: any, index: number) => ( + <div + key={feature} + className={cn('border-b last:border-none', { 'bg-white': index % 2 })} + > + <EventRow + isGraph={feature === 'PERFORMANCE'} + title={feature} + list={resources[feature]} + renderElement={(pointer: any) => ( + <TimelinePointer pointer={pointer} type={feature} /> + )} + endTime={props.endTime} + message={HELP_MESSAGE[feature]} + /> + </div> + ))} + </NoContent> + </div> + </OverviewPanelContainer> + </BottomBlock.Content> + </BottomBlock> + </Wrapper> + ); +} + +export default connect( + (state: any) => ({ + issuesList: state.getIn(['sessions', 'current', 'issues']), + }), + { + toggleBottomBlock, + } +)( + connectPlayer((state: any) => ({ + resourceList: state.resourceList.filter((r: any) => r.isRed() || r.isYellow()), + exceptionsList: state.exceptionsList, + eventsList: state.eventList, + stackEventList: state.stackList, + performanceChartData: state.performanceChartData, + endTime: state.endTime, + // endTime: 30000000, + }))(OverviewPanel) +); + +const Wrapper = React.memo((props: any) => { + return <>{props.children}</>; +}); diff --git a/frontend/app/components/Session_/OverviewPanel/components/EventRow/EventRow.tsx b/frontend/app/components/Session_/OverviewPanel/components/EventRow/EventRow.tsx new file mode 100644 index 000000000..cf8aece3c --- /dev/null +++ b/frontend/app/components/Session_/OverviewPanel/components/EventRow/EventRow.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import cn from 'classnames'; +import { getTimelinePosition } from 'App/utils'; +import { Icon, Popup } from 'UI'; +import PerformanceGraph from '../PerformanceGraph'; +interface Props { + list?: any[]; + title: string; + message?: string; + className?: string; + endTime?: number; + renderElement?: (item: any) => React.ReactNode; + isGraph?: boolean; +} +const EventRow = React.memo((props: Props) => { + const { title, className, list = [], endTime = 0, isGraph = false, message = '' } = props; + const scale = 100 / endTime; + const _list = + !isGraph && + React.useMemo(() => { + return list.map((item: any, _index: number) => { + return { + ...item.toJS(), + left: getTimelinePosition(item.time, scale), + }; + }); + }, [list]); + + return ( + <div className={cn('w-full flex flex-col py-2', className)} style={{ height: '60px' }}> + <div className="uppercase color-gray-medium ml-4 text-sm flex items-center py-1"> + <div className="mr-2 leading-none">{title}</div> + <RowInfo message={message} /> + </div> + <div className="relative w-full"> + {isGraph ? ( + <PerformanceGraph list={list} /> + ) : ( + _list.length > 0 ? _list.map((item: any, index: number) => { + return ( + <div key={index} className="absolute" style={{ left: item.left + '%' }}> + {props.renderElement ? props.renderElement(item) : null} + </div> + ); + }) : ( + <div className="ml-4 color-gray-medium text-sm pt-2">None captured.</div> + ) + )} + </div> + </div> + ); +}); + +export default EventRow; + +function RowInfo({ message} : any) { + return ( + <Popup content={message} delay={0}> + <Icon name="info-circle" color="gray-medium"/> + </Popup> + ) +} diff --git a/frontend/app/components/Session_/OverviewPanel/components/EventRow/index.ts b/frontend/app/components/Session_/OverviewPanel/components/EventRow/index.ts new file mode 100644 index 000000000..ec0281d5a --- /dev/null +++ b/frontend/app/components/Session_/OverviewPanel/components/EventRow/index.ts @@ -0,0 +1 @@ +export { default } from './EventRow'; \ No newline at end of file diff --git a/frontend/app/components/Session_/OverviewPanel/components/FeatureSelection/FeatureSelection.tsx b/frontend/app/components/Session_/OverviewPanel/components/FeatureSelection/FeatureSelection.tsx new file mode 100644 index 000000000..1f1c35912 --- /dev/null +++ b/frontend/app/components/Session_/OverviewPanel/components/FeatureSelection/FeatureSelection.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { Checkbox, Popup } from 'UI'; + +const NETWORK = 'NETWORK'; +const ERRORS = 'ERRORS'; +const EVENTS = 'EVENTS'; +const CLICKRAGE = 'CLICKRAGE'; +const PERFORMANCE = 'PERFORMANCE'; + +export const HELP_MESSAGE: any = { + NETWORK: 'Network requests made in this session', + EVENTS: 'Visualizes the events that takes place in the DOM', + ERRORS: 'Visualizes native JS errors like Type, URI, Syntax etc.', + CLICKRAGE: 'Indicates user frustration when repeated clicks are recorded', + PERFORMANCE: 'Summary of this session’s memory, and CPU consumption on the timeline', +} + +interface Props { + list: any[]; + updateList: any; +} +function FeatureSelection(props: Props) { + const { list } = props; + const features = [NETWORK, ERRORS, EVENTS, CLICKRAGE, PERFORMANCE]; + const disabled = list.length >= 3; + + return ( + <React.Fragment> + {features.map((feature, index) => { + const checked = list.includes(feature); + const _disabled = disabled && !checked; + return ( + <Popup content="X-RAY supports up to 3 views" disabled={!_disabled} delay={0}> + <Checkbox + key={index} + label={feature} + checked={checked} + className="mx-4" + disabled={_disabled} + onClick={() => { + if (checked) { + props.updateList(list.filter((item: any) => item !== feature)); + } else { + props.updateList([...list, feature]); + } + }} + /> + </Popup> + ); + })} + </React.Fragment> + ); +} + +export default FeatureSelection; diff --git a/frontend/app/components/Session_/OverviewPanel/components/OverviewPanelContainer/OverviewPanelContainer.tsx b/frontend/app/components/Session_/OverviewPanel/components/OverviewPanelContainer/OverviewPanelContainer.tsx new file mode 100644 index 000000000..5a898c67e --- /dev/null +++ b/frontend/app/components/Session_/OverviewPanel/components/OverviewPanelContainer/OverviewPanelContainer.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import VerticalLine from '../VerticalLine'; +import { connectPlayer, Controls } from 'App/player'; + +interface Props { + children: React.ReactNode; + endTime: number; +} + +const OverviewPanelContainer = React.memo((props: Props) => { + const { endTime } = props; + const [mouseX, setMouseX] = React.useState(0); + const [mouseIn, setMouseIn] = React.useState(false); + const onClickTrack = (e: any) => { + const p = e.nativeEvent.offsetX / e.target.offsetWidth; + const time = Math.max(Math.round(p * endTime), 0); + if (time) { + Controls.jump(time); + } + }; + + // const onMouseMoveCapture = (e: any) => { + // if (!mouseIn) { + // return; + // } + // const p = e.nativeEvent.offsetX / e.target.offsetWidth; + // setMouseX(p * 100); + // }; + + return ( + <div + className="overflow-x-auto overflow-y-hidden bg-gray-lightest" + onClick={onClickTrack} + // onMouseMoveCapture={onMouseMoveCapture} + // onMouseOver={() => setMouseIn(true)} + // onMouseOut={() => setMouseIn(false)} + > + {mouseIn && <VerticalLine left={mouseX} className="border-gray-medium" />} + <div className="">{props.children}</div> + </div> + ); +}); + +export default OverviewPanelContainer; + +// export default connectPlayer((state: any) => ({ +// endTime: state.endTime, +// }))(OverviewPanelContainer); diff --git a/frontend/app/components/Session_/OverviewPanel/components/OverviewPanelContainer/index.ts b/frontend/app/components/Session_/OverviewPanel/components/OverviewPanelContainer/index.ts new file mode 100644 index 000000000..788665588 --- /dev/null +++ b/frontend/app/components/Session_/OverviewPanel/components/OverviewPanelContainer/index.ts @@ -0,0 +1 @@ +export { default } from './OverviewPanelContainer'; \ No newline at end of file diff --git a/frontend/app/components/Session_/OverviewPanel/components/PerformanceGraph/PerformanceGraph.tsx b/frontend/app/components/Session_/OverviewPanel/components/PerformanceGraph/PerformanceGraph.tsx new file mode 100644 index 000000000..28193cd10 --- /dev/null +++ b/frontend/app/components/Session_/OverviewPanel/components/PerformanceGraph/PerformanceGraph.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import { connectPlayer } from 'App/player'; +import { AreaChart, Area, Tooltip, ResponsiveContainer } from 'recharts'; + +interface Props { + list: any; +} +const PerformanceGraph = React.memo((props: Props) => { + const { list } = props; + + const finalValues = React.useMemo(() => { + const cpuMax = list.reduce((acc: number, item: any) => { + return Math.max(acc, item.cpu); + }, 0); + const cpuMin = list.reduce((acc: number, item: any) => { + return Math.min(acc, item.cpu); + }, Infinity); + + const memoryMin = list.reduce((acc: number, item: any) => { + return Math.min(acc, item.usedHeap); + }, Infinity); + const memoryMax = list.reduce((acc: number, item: any) => { + return Math.max(acc, item.usedHeap); + }, 0); + + const convertToPercentage = (val: number, max: number, min: number) => { + return ((val - min) / (max - min)) * 100; + }; + const cpuValues = list.map((item: any) => convertToPercentage(item.cpu, cpuMax, cpuMin)); + const memoryValues = list.map((item: any) => convertToPercentage(item.usedHeap, memoryMax, memoryMin)); + const mergeArraysWithMaxNumber = (arr1: any[], arr2: any[]) => { + const maxLength = Math.max(arr1.length, arr2.length); + const result = []; + for (let i = 0; i < maxLength; i++) { + const num = Math.round(Math.max(arr1[i] || 0, arr2[i] || 0)); + result.push(num > 60 ? num : 1); + } + return result; + }; + const finalValues = mergeArraysWithMaxNumber(cpuValues, memoryValues); + return finalValues; + }, []); + + const data = list.map((item: any, index: number) => { + return { + time: item.time, + cpu: finalValues[index], + }; + }); + + return ( + <ResponsiveContainer height={35}> + <AreaChart + data={data} + margin={{ + top: 0, + right: 0, + left: 0, + bottom: 0, + }} + > + <defs> + <linearGradient id="cpuGradientTimeline" x1="0" y1="0" x2="0" y2="1"> + <stop offset="30%" stopColor="#CC0000" stopOpacity={0.5} /> + <stop offset="95%" stopColor="#3EAAAF" stopOpacity={0.8} /> + </linearGradient> + </defs> + {/* <Tooltip filterNull={false} /> */} + <Area + dataKey="cpu" + baseValue={5} + type="monotone" + stroke="none" + activeDot={false} + fill="url(#cpuGradientTimeline)" + isAnimationActive={false} + /> + </AreaChart> + </ResponsiveContainer> + ); +}); + +export default PerformanceGraph; diff --git a/frontend/app/components/Session_/OverviewPanel/components/PerformanceGraph/index.ts b/frontend/app/components/Session_/OverviewPanel/components/PerformanceGraph/index.ts new file mode 100644 index 000000000..2c5c88675 --- /dev/null +++ b/frontend/app/components/Session_/OverviewPanel/components/PerformanceGraph/index.ts @@ -0,0 +1 @@ +export { default } from './PerformanceGraph'; \ No newline at end of file diff --git a/frontend/app/components/Session_/OverviewPanel/components/StackEventModal/StackEventModal.tsx b/frontend/app/components/Session_/OverviewPanel/components/StackEventModal/StackEventModal.tsx new file mode 100644 index 000000000..76490900a --- /dev/null +++ b/frontend/app/components/Session_/OverviewPanel/components/StackEventModal/StackEventModal.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import JsonViewer from './components/JsonViewer'; +import Sentry from './components/Sentry'; +import { OPENREPLAY, SENTRY, DATADOG, STACKDRIVER } from 'Types/session/stackEvent'; + +interface Props { + event: any; +} +function StackEventModal(props: Props) { + const renderPopupContent = () => { + const { + event: { source, payload, name }, + } = props; + switch (source) { + case SENTRY: + return <Sentry event={payload} />; + case DATADOG: + return <JsonViewer title={name} data={payload} icon="integrations/datadog" />; + case STACKDRIVER: + return <JsonViewer title={name} data={payload} icon="integrations/stackdriver" />; + default: + return <JsonViewer title={name} data={payload} icon={`integrations/${source}`} />; + } + }; + return ( + <div className="bg-white h-screen overflow-y-auto" style={{ width: '450px' }}> + {renderPopupContent()} + </div> + ); +} + +export default StackEventModal; diff --git a/frontend/app/components/Session_/OverviewPanel/components/StackEventModal/components/JsonViewer/JsonViewer.js b/frontend/app/components/Session_/OverviewPanel/components/StackEventModal/components/JsonViewer/JsonViewer.js new file mode 100644 index 000000000..e20b9ba8d --- /dev/null +++ b/frontend/app/components/Session_/OverviewPanel/components/StackEventModal/components/JsonViewer/JsonViewer.js @@ -0,0 +1,16 @@ +import React from 'react'; +import { Icon, JSONTree } from 'UI'; + +export default class JsonViewer extends React.PureComponent { + render() { + const { data, title, icon } = this.props; + const isObjectData = typeof data === 'object' && !Array.isArray(data) && data !== null + return ( + <div className="p-5"> + <Icon name={icon} size="30" /> + <h4 className="my-5 capitalize"> {title}</h4> + {isObjectData ? <JSONTree src={data} collapsed={false} /> : data} + </div> + ); + } +} diff --git a/frontend/app/components/Session_/OverviewPanel/components/StackEventModal/components/JsonViewer/index.ts b/frontend/app/components/Session_/OverviewPanel/components/StackEventModal/components/JsonViewer/index.ts new file mode 100644 index 000000000..155729246 --- /dev/null +++ b/frontend/app/components/Session_/OverviewPanel/components/StackEventModal/components/JsonViewer/index.ts @@ -0,0 +1 @@ +export { default } from './JsonViewer'; \ No newline at end of file diff --git a/frontend/app/components/Session_/OverviewPanel/components/StackEventModal/components/Sentry/Sentry.js b/frontend/app/components/Session_/OverviewPanel/components/StackEventModal/components/Sentry/Sentry.js new file mode 100644 index 000000000..0e1ea0747 --- /dev/null +++ b/frontend/app/components/Session_/OverviewPanel/components/StackEventModal/components/Sentry/Sentry.js @@ -0,0 +1,67 @@ +import React from 'react'; +import { getIn, get } from 'immutable'; +import cn from 'classnames'; +import { withRequest } from 'HOCs'; +import { Loader, Icon, JSONTree } from 'UI'; +import { Accordion } from 'semantic-ui-react'; +import stl from './sentry.module.css'; + +@withRequest({ + endpoint: (props) => `/integrations/sentry/events/${props.event.id}`, + dataName: 'detailedEvent', + loadOnInitialize: true, +}) +export default class SentryEventInfo extends React.PureComponent { + makePanelsFromStackTrace(stacktrace) { + return get(stacktrace, 'frames', []).map(({ filename, function: method, lineNo, context = [] }) => ({ + key: `${filename}_${method}_${lineNo}`, + title: { + content: ( + <span className={stl.accordionTitle}> + <b>{filename}</b> + {' in '} + <b>{method}</b> + {' at line '} + <b>{lineNo}</b> + </span> + ), + }, + content: { + content: ( + <ol start={getIn(context, [0, 0], 0)} className={stl.lineList}> + {context.map(([ctxLineNo, codeText]) => ( + <li className={cn(stl.codeLine, { [stl.highlighted]: ctxLineNo === lineNo })}>{codeText}</li> + ))} + </ol> + ), + }, + })); + } + + renderBody() { + const { detailedEvent, requestError, event } = this.props; + + const exceptionEntry = get(detailedEvent, ['entries'], []).find(({ type }) => type === 'exception'); + const stacktraces = getIn(exceptionEntry, ['data', 'values']); + if (!stacktraces) { + return <JSONTree src={requestError ? event : detailedEvent} sortKeys={false} enableClipboard />; + } + return stacktraces.map(({ type, value, stacktrace }) => ( + <div key={type} className={stl.stacktrace}> + <h6>{type}</h6> + <p>{value}</p> + <Accordion styled panels={this.makePanelsFromStackTrace(stacktrace)} /> + </div> + )); + } + + render() { + const { open, toggleOpen, loading } = this.props; + return ( + <div className={stl.wrapper}> + <Icon name="integrations/sentry-text" size="30" color="gray-medium" /> + <Loader loading={loading}>{this.renderBody()}</Loader> + </div> + ); + } +} diff --git a/frontend/app/components/Session_/OverviewPanel/components/StackEventModal/components/Sentry/index.ts b/frontend/app/components/Session_/OverviewPanel/components/StackEventModal/components/Sentry/index.ts new file mode 100644 index 000000000..534162c8b --- /dev/null +++ b/frontend/app/components/Session_/OverviewPanel/components/StackEventModal/components/Sentry/index.ts @@ -0,0 +1 @@ +export { default } from './Sentry'; \ No newline at end of file diff --git a/frontend/app/components/Session_/OverviewPanel/components/StackEventModal/components/Sentry/sentry.module.css b/frontend/app/components/Session_/OverviewPanel/components/StackEventModal/components/Sentry/sentry.module.css new file mode 100644 index 000000000..75956a074 --- /dev/null +++ b/frontend/app/components/Session_/OverviewPanel/components/StackEventModal/components/Sentry/sentry.module.css @@ -0,0 +1,47 @@ + +.wrapper { + padding: 20px 40px 30px; +} +.icon { + margin-left: -5px; +} +.stacktrace { + & h6 { + display: flex; + align-items: center; + font-size: 17px; + padding-top: 7px; + margin-bottom: 10px; + } + & p { + font-family: 'Menlo', 'monaco', 'consolas', monospace; + } +} + + +.accordionTitle { + font-weight: 100; + & > b { + font-weight: 700; + } +} + +.lineList { + list-style-position: inside; + list-style-type: decimal-leading-zero; + background: $gray-lightest; +} + +.codeLine { + font-family: 'Menlo', 'monaco', 'consolas', monospace; + line-height: 24px; + font-size: 12px; + white-space: pre-wrap; + word-wrap: break-word; + min-height: 24px; + padding: 0 25px; + &.highlighted { + background: $red; + color: $white; + } +} \ No newline at end of file diff --git a/frontend/app/components/Session_/OverviewPanel/components/StackEventModal/index.ts b/frontend/app/components/Session_/OverviewPanel/components/StackEventModal/index.ts new file mode 100644 index 000000000..93a084d28 --- /dev/null +++ b/frontend/app/components/Session_/OverviewPanel/components/StackEventModal/index.ts @@ -0,0 +1 @@ +export { default } from './StackEventModal'; diff --git a/frontend/app/components/Session_/OverviewPanel/components/TimelinePointer/TimelinePointer.tsx b/frontend/app/components/Session_/OverviewPanel/components/TimelinePointer/TimelinePointer.tsx new file mode 100644 index 000000000..6e45b5e99 --- /dev/null +++ b/frontend/app/components/Session_/OverviewPanel/components/TimelinePointer/TimelinePointer.tsx @@ -0,0 +1,150 @@ +import React from 'react'; +import { connectPlayer, Controls } from 'App/player'; +import { toggleBottomBlock, NETWORK, EXCEPTIONS, PERFORMANCE } from 'Duck/components/player'; +import { useModal } from 'App/components/Modal'; +import { Icon, ErrorDetails, Popup } from 'UI'; +import { Tooltip } from 'react-tippy'; +import { TYPES as EVENT_TYPES } from 'Types/session/event'; +import StackEventModal from '../StackEventModal'; +import ErrorDetailsModal from 'App/components/Dashboard/components/Errors/ErrorDetailsModal'; + +interface Props { + pointer: any; + type: any; +} +const TimelinePointer = React.memo((props: Props) => { + const { showModal, hideModal } = useModal(); + const createEventClickHandler = (pointer: any, type: any) => (e: any) => { + e.stopPropagation(); + Controls.jump(pointer.time); + if (!type) { + return; + } + + if (type === 'ERRORS') { + showModal(<ErrorDetailsModal errorId={pointer.errorId} />, { right: true }); + } + + if (type === 'EVENT') { + showModal(<StackEventModal event={pointer} />, { right: true }); + } + // props.toggleBottomBlock(type); + }; + + const renderNetworkElement = (item: any) => { + return ( + <Popup + content={ + <div className=""> + <b>{item.success ? 'Slow resource: ' : 'Missing resource:'}</b> + <br /> + {item.name} + </div> + } + delay={0} + position="top" + > + <div onClick={createEventClickHandler(item, NETWORK)} className="cursor-pointer"> + <div className="h-3 w-3 rounded-full bg-red" /> + </div> + </Popup> + ); + }; + + const renderClickRageElement = (item: any) => { + return ( + <Popup + content={ + <div className=""> + <b>{'Click Rage'}</b> + </div> + } + delay={0} + position="top" + > + <div onClick={createEventClickHandler(item, null)} className="cursor-pointer"> + <Icon className="bg-white" name="funnel/emoji-angry" color="red" size="16" /> + </div> + </Popup> + ); + }; + + const renderStackEventElement = (item: any) => { + return ( + <Popup + content={ + <div className=""> + <b>{'Stack Event'}</b> + </div> + } + delay={0} + position="top" + > + <div onClick={createEventClickHandler(item, 'EVENT')} className="cursor-pointer w-1 h-4 bg-red"> + {/* <Icon className="rounded-full bg-white" name="funnel/exclamation-circle-fill" color="red" size="16" /> */} + </div> + </Popup> + ); + }; + + const renderPerformanceElement = (item: any) => { + return ( + <Popup + content={ + <div className=""> + <b>{item.type}</b> + </div> + } + delay={0} + position="top" + > + <div onClick={createEventClickHandler(item, EXCEPTIONS)} className="cursor-pointer w-1 h-4 bg-red"> + {/* <Icon className="rounded-full bg-white" name="funnel/exclamation-circle-fill" color="red" size="16" /> */} + </div> + </Popup> + ); + }; + + const renderExceptionElement = (item: any) => { + return ( + <Popup + content={ + <div className=""> + <b>{'Exception'}</b> + <br /> + <span>{item.message}</span> + </div> + } + delay={0} + position="top" + > + <div onClick={createEventClickHandler(item, 'ERRORS')} className="cursor-pointer"> + <Icon className="rounded-full bg-white" name="funnel/exclamation-circle-fill" color="red" size="16" /> + </div> + </Popup> + ); + }; + + const render = () => { + const { pointer, type } = props; + if (type === 'NETWORK') { + return renderNetworkElement(pointer); + } + if (type === 'CLICKRAGE') { + return renderClickRageElement(pointer); + } + if (type === 'ERRORS') { + return renderExceptionElement(pointer); + } + if (type === 'EVENTS') { + return renderStackEventElement(pointer); + } + + if (type === 'PERFORMANCE') { + return renderPerformanceElement(pointer); + } + }; + return <div>{render()}</div>; +}); + +export default TimelinePointer; diff --git a/frontend/app/components/Session_/OverviewPanel/components/TimelinePointer/index.ts b/frontend/app/components/Session_/OverviewPanel/components/TimelinePointer/index.ts new file mode 100644 index 000000000..e0f9399ff --- /dev/null +++ b/frontend/app/components/Session_/OverviewPanel/components/TimelinePointer/index.ts @@ -0,0 +1 @@ +export { default } from './TimelinePointer' \ No newline at end of file diff --git a/frontend/app/components/Session_/OverviewPanel/components/TimelineScale/TimelineScale.tsx b/frontend/app/components/Session_/OverviewPanel/components/TimelineScale/TimelineScale.tsx new file mode 100644 index 000000000..3b7fc453e --- /dev/null +++ b/frontend/app/components/Session_/OverviewPanel/components/TimelineScale/TimelineScale.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { connectPlayer } from 'App/player'; +import { millisToMinutesAndSeconds } from 'App/utils'; + +interface Props { + endTime: number; +} +function TimelineScale(props: Props) { + const { endTime } = props; + const scaleRef = React.useRef<HTMLDivElement>(null); + const gap = 60; + + const drawScale = (container: any) => { + const width = container.offsetWidth; + const part = Math.round(width / gap); + container.replaceChildren(); + for (var i = 0; i < part; i++) { + const txt = millisToMinutesAndSeconds(i * (endTime / part)); + const el = document.createElement('div'); + // el.style.height = '10px'; + // el.style.width = '1px'; + // el.style.backgroundColor = '#ccc'; + el.style.position = 'absolute'; + el.style.left = `${i * gap}px`; + el.style.paddingTop = '1px'; + el.style.opacity = '0.8'; + el.innerHTML = txt + ''; + el.style.fontSize = '12px'; + el.style.color = 'white'; + + container.appendChild(el); + } + }; + + React.useEffect(() => { + if (!scaleRef.current) { + return; + } + + drawScale(scaleRef.current); + + // const resize = () => drawScale(scaleRef.current); + + // window.addEventListener('resize', resize); + // return () => { + // window.removeEventListener('resize', resize); + // }; + }, [scaleRef]); + return ( + <div className="h-6 bg-gray-darkest w-full" ref={scaleRef}> + {/* <div ref={scaleRef} className="w-full h-10 bg-gray-300 relative"></div> */} + </div> + ); +} + +export default TimelineScale; + +// export default connectPlayer((state: any) => ({ +// endTime: state.endTime, +// }))(TimelineScale); diff --git a/frontend/app/components/Session_/OverviewPanel/components/TimelineScale/index.ts b/frontend/app/components/Session_/OverviewPanel/components/TimelineScale/index.ts new file mode 100644 index 000000000..9a2302a32 --- /dev/null +++ b/frontend/app/components/Session_/OverviewPanel/components/TimelineScale/index.ts @@ -0,0 +1 @@ +export { default } from './TimelineScale'; \ No newline at end of file diff --git a/frontend/app/components/Session_/OverviewPanel/components/VerticalLine/VerticalLine.tsx b/frontend/app/components/Session_/OverviewPanel/components/VerticalLine/VerticalLine.tsx new file mode 100644 index 000000000..43a536f13 --- /dev/null +++ b/frontend/app/components/Session_/OverviewPanel/components/VerticalLine/VerticalLine.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import cn from 'classnames'; + +interface Props { + left: number; + className?: string; + height?: string; + width?: string; +} +function VerticalLine(props: Props) { + const { left, className = 'border-gray-dark', height = '221px', width = '1px' } = props; + return <div className={cn('absolute border-r border-dashed z-10', className)} style={{ left: `${left}%`, height, width }} />; +} + +export default VerticalLine; diff --git a/frontend/app/components/Session_/OverviewPanel/components/VerticalLine/index.ts b/frontend/app/components/Session_/OverviewPanel/components/VerticalLine/index.ts new file mode 100644 index 000000000..423077b49 --- /dev/null +++ b/frontend/app/components/Session_/OverviewPanel/components/VerticalLine/index.ts @@ -0,0 +1 @@ +export { default } from './VerticalLine' \ No newline at end of file diff --git a/frontend/app/components/Session_/OverviewPanel/components/VerticalPointerLine/VerticalPointerLine.tsx b/frontend/app/components/Session_/OverviewPanel/components/VerticalPointerLine/VerticalPointerLine.tsx new file mode 100644 index 000000000..8db015447 --- /dev/null +++ b/frontend/app/components/Session_/OverviewPanel/components/VerticalPointerLine/VerticalPointerLine.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { connectPlayer } from 'App/player'; +import VerticalLine from '../VerticalLine'; + +interface Props { + time: number; + scale: number; +} +function VerticalPointerLine(props: Props) { + const { time, scale } = props; + const left = time * scale; + return <VerticalLine left={left} className="border-teal" />; +} + +export default connectPlayer((state: any) => ({ + time: state.time, + scale: 100 / state.endTime, +}))(VerticalPointerLine); diff --git a/frontend/app/components/Session_/OverviewPanel/components/VerticalPointerLine/index.ts b/frontend/app/components/Session_/OverviewPanel/components/VerticalPointerLine/index.ts new file mode 100644 index 000000000..4a75fc048 --- /dev/null +++ b/frontend/app/components/Session_/OverviewPanel/components/VerticalPointerLine/index.ts @@ -0,0 +1 @@ +export { default } from './VerticalPointerLine' \ No newline at end of file diff --git a/frontend/app/components/Session_/OverviewPanel/index.ts b/frontend/app/components/Session_/OverviewPanel/index.ts new file mode 100644 index 000000000..328795cd7 --- /dev/null +++ b/frontend/app/components/Session_/OverviewPanel/index.ts @@ -0,0 +1 @@ +export { default } from './OverviewPanel'; \ No newline at end of file diff --git a/frontend/app/components/Session_/OverviewPanel/overviewPanel.module.css b/frontend/app/components/Session_/OverviewPanel/overviewPanel.module.css new file mode 100644 index 000000000..979eebb13 --- /dev/null +++ b/frontend/app/components/Session_/OverviewPanel/overviewPanel.module.css @@ -0,0 +1,13 @@ +.popup { + max-width: 300px !important; + /* max-height: 300px !important; */ + overflow: hidden; + text-overflow: ellipsis; + & span { + display: block; + max-height: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} diff --git a/frontend/app/components/Session_/Player/Controls/Circle.tsx b/frontend/app/components/Session_/Player/Controls/Circle.tsx index 274b38f8a..73e1e1bb1 100644 --- a/frontend/app/components/Session_/Player/Controls/Circle.tsx +++ b/frontend/app/components/Session_/Player/Controls/Circle.tsx @@ -1,16 +1,18 @@ import React, { memo, FC } from 'react'; import styles from './timeline.module.css'; +import cn from 'classnames'; interface Props { preview?: boolean; + isGreen?: boolean; } -export const Circle: FC<Props> = memo(function Box({ preview }) { +export const Circle: FC<Props> = memo(function Box({ preview, isGreen }) { return ( <div - className={ styles.positionTracker } + className={ cn(styles.positionTracker, { [styles.greenTracker]: isGreen }) } role={preview ? 'BoxPreview' : 'Box'} /> ) }) -export default Circle; \ No newline at end of file +export default Circle; diff --git a/frontend/app/components/Session_/Player/Controls/ControlButton.js b/frontend/app/components/Session_/Player/Controls/ControlButton.js index d438de32e..31672c301 100644 --- a/frontend/app/components/Session_/Player/Controls/ControlButton.js +++ b/frontend/app/components/Session_/Player/Controls/ControlButton.js @@ -3,32 +3,42 @@ import cn from 'classnames'; import { Icon } from 'UI'; import stl from './controlButton.module.css'; -const ControlButton = ({ - label, - icon = '', - disabled=false, - onClick, - count = 0, - hasErrors=false, - active=false, - size = 20, - noLabel, +const ControlButton = ({ + label, + icon = '', + disabled = false, + onClick, + count = 0, + hasErrors = false, + active = false, + size = 20, + noLabel, labelClassName, containerClassName, noIcon, - }) => ( - <button - className={ cn(stl.controlButton, { [stl.disabled]: disabled }, "relative", active ? 'border-b-2 border-main' : 'rounded',containerClassName) } - onClick={ onClick } - id={"control-button-" + label.toLowerCase()} +}) => ( + <button + className={cn( + stl.controlButton, + { [stl.disabled]: disabled }, + 'relative', + active ? 'border-b-2 border-main' : 'rounded', + containerClassName + )} + onClick={onClick} + id={'control-button-' + label.toLowerCase()} disabled={disabled} > - <div className={stl.labels}> - { hasErrors && <div className={ stl.errorSymbol } /> } - { count > 0 && <div className={ stl.countLabel }>{ count }</div>} - </div> - {!noIcon && <Icon name={ icon } size={size} color="gray-dark"/>} - {!noLabel && <span className={ cn(stl.label, labelClassName, active ? 'color-main' : 'color-gray-darkest') }>{ label }</span>} + <div className={stl.labels}> + {hasErrors && <div className={stl.errorSymbol} />} + {count > 0 && <div className={stl.countLabel}>{count}</div>} + </div> + {!noIcon && <Icon name={icon} size={size} color="gray-dark" />} + {!noLabel && ( + <span className={cn(stl.label, labelClassName, active ? 'color-main' : 'color-gray-darkest')}> + {label} + </span> + )} </button> ); diff --git a/frontend/app/components/Session_/Player/Controls/Controls.js b/frontend/app/components/Session_/Player/Controls/Controls.js index e92099393..0601e092f 100644 --- a/frontend/app/components/Session_/Player/Controls/Controls.js +++ b/frontend/app/components/Session_/Player/Controls/Controls.js @@ -8,13 +8,16 @@ import { selectStorageListNow, } from 'Player/store'; import LiveTag from 'Shared/LiveTag'; +import { toggleTimetravel, jumpToLive } from 'Player'; -import { Icon } from 'UI'; +import { Icon, Button } from 'UI'; import { toggleInspectorMode } from 'Player'; import { fullscreenOn, fullscreenOff, toggleBottomBlock, + changeSkipInterval, + OVERVIEW, CONSOLE, NETWORK, STACKEVENTS, @@ -26,45 +29,56 @@ import { EXCEPTIONS, INSPECTOR, } from 'Duck/components/player'; -import { ReduxTime } from './Time'; +import { AssistDuration } from './Time'; import Timeline from './Timeline'; import ControlButton from './ControlButton'; +import PlayerControls from './components/PlayerControls'; import styles from './controls.module.css'; import { Tooltip } from 'react-tippy'; - +import XRayButton from 'Shared/XRayButton'; function getStorageIconName(type) { - switch(type) { + switch (type) { case STORAGE_TYPES.REDUX: - return "vendors/redux"; + return 'vendors/redux'; case STORAGE_TYPES.MOBX: - return "vendors/mobx" + return 'vendors/mobx'; case STORAGE_TYPES.VUEX: - return "vendors/vuex"; + return 'vendors/vuex'; case STORAGE_TYPES.NGRX: - return "vendors/ngrx"; + return 'vendors/ngrx'; case STORAGE_TYPES.NONE: - return "store" + return 'store'; } } +const SKIP_INTERVALS = { + 2: 2e3, + 5: 5e3, + 10: 1e4, + 15: 15e3, + 20: 2e4, + 30: 3e4, + 60: 6e4, +}; + function getStorageName(type) { - switch(type) { + switch (type) { case STORAGE_TYPES.REDUX: - return "REDUX"; + return 'REDUX'; case STORAGE_TYPES.MOBX: - return "MOBX"; + return 'MOBX'; case STORAGE_TYPES.VUEX: - return "VUEX"; + return 'VUEX'; case STORAGE_TYPES.NGRX: - return "NGRX"; + return 'NGRX'; case STORAGE_TYPES.NONE: - return "STATE"; + return 'STATE'; } } -@connectPlayer(state => ({ +@connectPlayer((state) => ({ time: state.time, endTime: state.endTime, live: state.live, @@ -79,7 +93,6 @@ function getStorageName(type) { fullscreenDisabled: state.messagesLoading, logCount: state.logListNow.length, logRedCount: state.logRedCountNow, - // resourceCount: state.resourceCountNow, resourceRedCount: state.resourceRedCountNow, fetchRedCount: state.fetchRedCountNow, showStack: state.stackList.length > 0, @@ -97,25 +110,32 @@ function getStorageName(type) { exceptionsCount: state.exceptionsListNow.length, showExceptions: state.exceptionsList.length > 0, showLongtasks: state.longtasksList.length > 0, + liveTimeTravel: state.liveTimeTravel, })) -@connect((state, props) => { - const permissions = state.getIn([ 'user', 'account', 'permissions' ]) || []; - const isEnterprise = state.getIn([ 'user', 'account', 'edition' ]) === 'ee'; - return { - disabled: props.disabled || (isEnterprise && !permissions.includes('DEV_TOOLS')), - fullscreen: state.getIn([ 'components', 'player', 'fullscreen' ]), - bottomBlock: state.getIn([ 'components', 'player', 'bottomBlock' ]), - showStorage: props.showStorage || !state.getIn(['components', 'player', 'hiddenHints', 'storage']), - showStack: props.showStack || !state.getIn(['components', 'player', 'hiddenHints', 'stack']), - closedLive: !!state.getIn([ 'sessions', 'errors' ]) || !state.getIn([ 'sessions', 'current', 'live' ]), +@connect( + (state, props) => { + const permissions = state.getIn(['user', 'account', 'permissions']) || []; + const isEnterprise = state.getIn(['user', 'account', 'edition']) === 'ee'; + return { + disabled: props.disabled || (isEnterprise && !permissions.includes('DEV_TOOLS')), + fullscreen: state.getIn(['components', 'player', 'fullscreen']), + bottomBlock: state.getIn(['components', 'player', 'bottomBlock']), + showStorage: + props.showStorage || !state.getIn(['components', 'player', 'hiddenHints', 'storage']), + showStack: props.showStack || !state.getIn(['components', 'player', 'hiddenHints', 'stack']), + closedLive: + !!state.getIn(['sessions', 'errors']) || !state.getIn(['sessions', 'current', 'live']), + skipInterval: state.getIn(['components', 'player', 'skipInterval']), + }; + }, + { + fullscreenOn, + fullscreenOff, + toggleBottomBlock, + changeSkipInterval, } -}, { - fullscreenOn, - fullscreenOff, - toggleBottomBlock, -}) +) export default class Controls extends React.Component { - componentDidMount() { document.addEventListener('keydown', this.onKeyDown); } @@ -129,7 +149,6 @@ export default class Controls extends React.Component { if ( nextProps.fullscreen !== this.props.fullscreen || nextProps.bottomBlock !== this.props.bottomBlock || - nextProps.endTime !== this.props.endTime || nextProps.live !== this.props.live || nextProps.livePlay !== this.props.livePlay || nextProps.playing !== this.props.playing || @@ -158,8 +177,11 @@ export default class Controls extends React.Component { nextProps.graphqlCount !== this.props.graphqlCount || nextProps.showExceptions !== this.props.showExceptions || nextProps.exceptionsCount !== this.props.exceptionsCount || - nextProps.showLongtasks !== this.props.showLongtasks - ) return true; + nextProps.showLongtasks !== this.props.showLongtasks || + nextProps.liveTimeTravel !== this.props.liveTimeTravel || + nextProps.skipInterval !== this.props.skipInterval + ) + return true; return false; } @@ -171,7 +193,7 @@ export default class Controls extends React.Component { if (e.key === 'Esc' || e.key === 'Escape') { toggleInspectorMode(false); } - }; + } // if (e.key === ' ') { // document.activeElement.blur(); // this.props.togglePlay(); @@ -179,46 +201,47 @@ export default class Controls extends React.Component { if (e.key === 'Esc' || e.key === 'Escape') { this.props.fullscreenOff(); } - if (e.key === "ArrowRight") { + if (e.key === 'ArrowRight') { this.forthTenSeconds(); } - if (e.key === "ArrowLeft") { + if (e.key === 'ArrowLeft') { this.backTenSeconds(); } - if (e.key === "ArrowDown") { + if (e.key === 'ArrowDown') { this.props.speedDown(); } - if (e.key === "ArrowUp") { + if (e.key === 'ArrowUp') { this.props.speedUp(); } - } + }; forthTenSeconds = () => { - const { time, endTime, jump } = this.props; - jump(Math.min(endTime, time + 1e4)) - } + const { time, endTime, jump, skipInterval } = this.props; + jump(Math.min(endTime, time + SKIP_INTERVALS[skipInterval])); + }; - backTenSeconds = () => { //shouldComponentUpdate - const { time, jump } = this.props; - jump(Math.max(0, time - 1e4)); - } + backTenSeconds = () => { + //shouldComponentUpdate + const { time, jump, skipInterval } = this.props; + jump(Math.max(0, time - SKIP_INTERVALS[skipInterval])); + }; - goLive =() => this.props.jump(this.props.endTime) + goLive = () => this.props.jump(this.props.endTime); renderPlayBtn = () => { - const { completed, playing, disabled } = this.props; + const { completed, playing } = this.props; let label; let icon; if (completed) { icon = 'arrow-clockwise'; - label = 'Replay this session' + label = 'Replay this session'; } else if (playing) { icon = 'pause-fill'; label = 'Pause'; } else { icon = 'play-fill-new'; label = 'Pause'; - label = 'Play' + label = 'Play'; } return ( @@ -234,20 +257,21 @@ export default class Controls extends React.Component { onClick={this.props.togglePlay} className="hover-main color-main cursor-pointer rounded hover:bg-gray-light-shade" > - <Icon name={icon} size="36" color="inherit" /> + <Icon name={icon} size="36" color="inherit" /> </div> </Tooltip> - ) - } + ); + }; - controlIcon = (icon, size, action, isBackwards, additionalClasses) => + controlIcon = (icon, size, action, isBackwards, additionalClasses) => ( <div - onClick={ action } - className={cn("py-1 px-2 hover-main cursor-pointer", additionalClasses)} - style={{ transform: isBackwards ? 'rotate(180deg)' : '' }} - > - <Icon name={icon} size={size} color="inherit" /> + onClick={action} + className={cn('py-1 px-2 hover-main cursor-pointer bg-gray-lightest', additionalClasses)} + style={{ transform: isBackwards ? 'rotate(180deg)' : '' }} + > + <Icon name={icon} size={size} color="inherit" /> </div> + ); render() { const { @@ -279,6 +303,11 @@ export default class Controls extends React.Component { fullscreen, inspectorMode, closedLive, + toggleSpeed, + toggleSkip, + liveTimeTravel, + changeSkipInterval, + skipInterval, } = this.props; const toggleBottomTools = (blockName) => { @@ -289,218 +318,192 @@ export default class Controls extends React.Component { toggleInspectorMode(false); toggleBottomBlock(blockName); } - } + }; + return ( - <div className={ cn(styles.controls, {'px-5 pt-0' : live}) }> - { !live && <Timeline jump={ this.props.jump } pause={this.props.pause} togglePlay={this.props.togglePlay} /> } - { !fullscreen && - <div className={ styles.buttons } data-is-live={ live }> - <div> - { !live && ( - <div className="flex items-center"> - { this.renderPlayBtn() } - { !live && ( - <div className="flex items-center font-semibold text-center" style={{ minWidth: 85 }}> - <ReduxTime isCustom name="time" format="mm:ss" /> - <span className="px-1">/</span> - <ReduxTime isCustom name="endTime" format="mm:ss" /> - </div> - )} - - <div className="rounded ml-4 bg-active-blue border border-active-blue-border flex items-stretch"> - <Tooltip - title='Rewind 10s' - delay={0} - position="top" - > - {this.controlIcon("skip-forward-fill", 18, this.backTenSeconds, true, 'hover:bg-active-blue-border color-main h-full flex items-center')} - </Tooltip> - <div className='p-1 border-l border-r bg-active-blue-border border-active-blue-border'>10s</div> - <Tooltip - title='Forward 10s' - delay={0} - position="top" - > - {this.controlIcon("skip-forward-fill", 18, this.forthTenSeconds, false, 'hover:bg-active-blue-border color-main h-full flex items-center')} - </Tooltip> - </div> - - {!live && - <div className='flex items-center mx-4'> - <Tooltip - title='Playback speed' - delay={0} - position="top" - > - <button - className={ styles.speedButton } - onClick={ this.props.toggleSpeed } - data-disabled={ disabled } - > - <div>{ speed + 'x' }</div> - </button> - </Tooltip> - - <button - className={ cn(styles.skipIntervalButton, { [styles.withCheckIcon]: skip, [styles.active]: skip }, 'ml-4') } - onClick={ this.props.toggleSkip } - data-disabled={ disabled } - > - {skip && <Icon name="check" size="24" className="mr-1" />} - { 'Skip Inactivity' } - </button> - </div> - } - </div> + <div className={styles.controls}> + {!live || liveTimeTravel ? ( + <Timeline + live={live} + jump={this.props.jump} + liveTimeTravel={liveTimeTravel} + pause={this.props.pause} + togglePlay={this.props.togglePlay} + /> + ) : null} + {!fullscreen && ( + <div className={cn(styles.buttons, { '!px-5 !pt-0': live })} data-is-live={live}> + <div className="flex items-center"> + {!live && ( + <> + <PlayerControls + live={live} + skip={skip} + speed={speed} + disabled={disabled} + backTenSeconds={this.backTenSeconds} + forthTenSeconds={this.forthTenSeconds} + toggleSpeed={toggleSpeed} + toggleSkip={toggleSkip} + playButton={this.renderPlayBtn()} + controlIcon={this.controlIcon} + ref={this.speedRef} + skipIntervals={SKIP_INTERVALS} + setSkipInterval={changeSkipInterval} + currentInterval={skipInterval} + /> + <div className={cn('mx-2')} /> + <XRayButton + isActive={bottomBlock === OVERVIEW && !inspectorMode} + onClick={() => toggleBottomTools(OVERVIEW)} + /> + </> )} - { live && !closedLive && ( - <div className={ styles.buttonsLeft }> - <LiveTag isLive={livePlay} /> - {'Elapsed'} - <ReduxTime name="time" /> + {live && !closedLive && ( + <div className={styles.buttonsLeft}> + <LiveTag isLive={livePlay} onClick={() => (livePlay ? null : jumpToLive())} /> + <div className="font-semibold px-2"> + <AssistDuration isLivePlay={livePlay} /> + </div> + + {!liveTimeTravel && ( + <div + onClick={toggleTimetravel} + className="p-2 ml-2 rounded hover:bg-teal-light bg-gray-lightest cursor-pointer" + > + See Past Activity + </div> + )} </div> )} </div> <div className="flex items-center h-full"> - { !live && <div className={cn(styles.divider, 'h-full')} /> } - {/* ! TEMP DISABLED ! - {!live && ( - <ControlButton - disabled={ disabled && !inspectorMode } - active={ inspectorMode } - onClick={ () => toggleBottomTools(INSPECTOR) } - noIcon - labelClassName="!text-base font-semibold" - label="INSPECT" - containerClassName="mx-2" - /> - )} */} <ControlButton - disabled={ disabled && !inspectorMode } - onClick={ () => toggleBottomTools(CONSOLE) } - active={ bottomBlock === CONSOLE && !inspectorMode} + disabled={disabled && !inspectorMode} + onClick={() => toggleBottomTools(CONSOLE)} + active={bottomBlock === CONSOLE && !inspectorMode} label="CONSOLE" noIcon labelClassName="!text-base font-semibold" - count={ logCount } - hasErrors={ logRedCount > 0 } + count={logCount} + hasErrors={logRedCount > 0} containerClassName="mx-2" /> - { !live && + {!live && ( <ControlButton - disabled={ disabled && !inspectorMode } - onClick={ () => toggleBottomTools(NETWORK) } - active={ bottomBlock === NETWORK && !inspectorMode } + disabled={disabled && !inspectorMode} + onClick={() => toggleBottomTools(NETWORK)} + active={bottomBlock === NETWORK && !inspectorMode} label="NETWORK" - hasErrors={ resourceRedCount > 0 } + hasErrors={resourceRedCount > 0} noIcon labelClassName="!text-base font-semibold" containerClassName="mx-2" /> - } - {!live && + )} + {!live && ( <ControlButton - disabled={ disabled && !inspectorMode } - onClick={ () => toggleBottomTools(PERFORMANCE) } - active={ bottomBlock === PERFORMANCE && !inspectorMode } + disabled={disabled && !inspectorMode} + onClick={() => toggleBottomTools(PERFORMANCE)} + active={bottomBlock === PERFORMANCE && !inspectorMode} label="PERFORMANCE" noIcon labelClassName="!text-base font-semibold" containerClassName="mx-2" /> - } - {showFetch && + )} + {showFetch && ( <ControlButton disabled={disabled && !inspectorMode} - onClick={ ()=> toggleBottomTools(FETCH) } - active={ bottomBlock === FETCH && !inspectorMode } - hasErrors={ fetchRedCount > 0 } - count={ fetchCount } + onClick={() => toggleBottomTools(FETCH)} + active={bottomBlock === FETCH && !inspectorMode} + hasErrors={fetchRedCount > 0} + count={fetchCount} label="FETCH" noIcon labelClassName="!text-base font-semibold" containerClassName="mx-2" /> - } - { !live && showGraphql && + )} + {!live && showGraphql && ( <ControlButton disabled={disabled && !inspectorMode} - onClick={ ()=> toggleBottomTools(GRAPHQL) } - active={ bottomBlock === GRAPHQL && !inspectorMode } - count={ graphqlCount } + onClick={() => toggleBottomTools(GRAPHQL)} + active={bottomBlock === GRAPHQL && !inspectorMode} + count={graphqlCount} label="GRAPHQL" noIcon labelClassName="!text-base font-semibold" containerClassName="mx-2" /> - } - { !live && showStorage && + )} + {!live && showStorage && ( <ControlButton - disabled={ disabled && !inspectorMode } - onClick={ () => toggleBottomTools(STORAGE) } - active={ bottomBlock === STORAGE && !inspectorMode } - count={ storageCount } - label={ getStorageName(storageType) } + disabled={disabled && !inspectorMode} + onClick={() => toggleBottomTools(STORAGE)} + active={bottomBlock === STORAGE && !inspectorMode} + count={storageCount} + label={getStorageName(storageType)} noIcon labelClassName="!text-base font-semibold" containerClassName="mx-2" /> - } - { showExceptions && + )} + {showExceptions && ( <ControlButton - disabled={ disabled && !inspectorMode } - onClick={ () => toggleBottomTools(EXCEPTIONS) } - active={ bottomBlock === EXCEPTIONS && !inspectorMode } + disabled={disabled && !inspectorMode} + onClick={() => toggleBottomTools(EXCEPTIONS)} + active={bottomBlock === EXCEPTIONS && !inspectorMode} label="EXCEPTIONS" noIcon labelClassName="!text-base font-semibold" containerClassName="mx-2" - count={ exceptionsCount } - hasErrors={ exceptionsCount > 0 } + count={exceptionsCount} + hasErrors={exceptionsCount > 0} /> - } - { !live && showStack && + )} + {!live && showStack && ( <ControlButton - disabled={ disabled && !inspectorMode } - onClick={ () => toggleBottomTools(STACKEVENTS) } - active={ bottomBlock === STACKEVENTS && !inspectorMode } + disabled={disabled && !inspectorMode} + onClick={() => toggleBottomTools(STACKEVENTS)} + active={bottomBlock === STACKEVENTS && !inspectorMode} label="EVENTS" noIcon labelClassName="!text-base font-semibold" containerClassName="mx-2" - count={ stackCount } - hasErrors={ stackRedCount > 0 } + count={stackCount} + hasErrors={stackRedCount > 0} /> - } - { !live && showProfiler && + )} + {!live && showProfiler && ( <ControlButton - disabled={ disabled && !inspectorMode } - onClick={ () => toggleBottomTools(PROFILER) } - active={ bottomBlock === PROFILER && !inspectorMode } - count={ profilesCount } + disabled={disabled && !inspectorMode} + onClick={() => toggleBottomTools(PROFILER)} + active={bottomBlock === PROFILER && !inspectorMode} + count={profilesCount} label="PROFILER" noIcon labelClassName="!text-base font-semibold" containerClassName="mx-2" /> - } - { !live && <div className={cn(styles.divider, 'h-full')} /> } - { !live && ( - <Tooltip - title="Fullscreen" - delay={0} - position="top-end" - className="mx-4" - > - {this.controlIcon("arrows-angle-extend", 18, this.props.fullscreenOn, false, "rounded hover:bg-gray-light-shade color-gray-medium")} + )} + {/* {!live && <div className={cn('h-14 border-r bg-gray-light ml-6')} />} */} + {!live && ( + <Tooltip title="Fullscreen" delay={0} position="top-end" className="mx-4"> + {this.controlIcon( + 'arrows-angle-extend', + 16, + this.props.fullscreenOn, + false, + 'rounded hover:bg-gray-light-shade color-gray-medium' + )} </Tooltip> - ) - } + )} </div> </div> - } + )} </div> ); } diff --git a/frontend/app/components/Session_/Player/Controls/CustomDragLayer.tsx b/frontend/app/components/Session_/Player/Controls/CustomDragLayer.tsx index c72f03ce2..200c1c79f 100644 --- a/frontend/app/components/Session_/Player/Controls/CustomDragLayer.tsx +++ b/frontend/app/components/Session_/Player/Controls/CustomDragLayer.tsx @@ -95,4 +95,4 @@ const CustomDragLayer: FC<Props> = memo(function CustomDragLayer(props) { ); }) -export default CustomDragLayer; \ No newline at end of file +export default CustomDragLayer; diff --git a/frontend/app/components/Session_/Player/Controls/DraggableCircle.tsx b/frontend/app/components/Session_/Player/Controls/DraggableCircle.tsx index 385707879..fb51318c0 100644 --- a/frontend/app/components/Session_/Player/Controls/DraggableCircle.tsx +++ b/frontend/app/components/Session_/Player/Controls/DraggableCircle.tsx @@ -9,10 +9,12 @@ function getStyles( isDragging: boolean, ): CSSProperties { // const transform = `translate3d(${(left * 1161) / 100}px, -8px, 0)` + const leftPosition = left > 100 ? 100 : left + return { position: 'absolute', top: '-3px', - left: `${left}%`, + left: `${leftPosition}%`, // transform, // WebkitTransform: transform, // IE fallback: hide the real node using CSS when dragging @@ -35,7 +37,7 @@ interface Props { } const DraggableCircle: FC<Props> = memo(function DraggableCircle(props) { - const { left, top } = props + const { left, top, live } = props const [{ isDragging, item }, dragRef, preview] = useDrag( () => ({ type: ItemTypes.BOX, @@ -59,9 +61,9 @@ const DraggableCircle: FC<Props> = memo(function DraggableCircle(props) { style={getStyles(left, isDragging)} role="DraggableBox" > - <Circle /> + <Circle isGreen={left > 99 && live} /> </div> ); }) -export default DraggableCircle \ No newline at end of file +export default DraggableCircle diff --git a/frontend/app/components/Session_/Player/Controls/Time.js b/frontend/app/components/Session_/Player/Controls/Time.js index b0e95c6f0..ca3c6ce4c 100644 --- a/frontend/app/components/Session_/Player/Controls/Time.js +++ b/frontend/app/components/Session_/Player/Controls/Time.js @@ -2,6 +2,7 @@ import React from 'react'; import { Duration } from 'luxon'; import { connectPlayer } from 'Player'; import styles from './time.module.css'; +import { Tooltip } from 'react-tippy'; const Time = ({ time, isCustom, format = 'm:ss', }) => ( <div className={ !isCustom ? styles.time : undefined }> @@ -11,13 +12,37 @@ const Time = ({ time, isCustom, format = 'm:ss', }) => ( Time.displayName = "Time"; - const ReduxTime = connectPlayer((state, { name, format }) => ({ time: state[ name ], format, }))(Time); +const AssistDurationCont = connectPlayer( + state => { + const assistStart = state.assistStart; + return { + assistStart, + } + } +)(({ assistStart }) => { + const [assistDuration, setAssistDuration] = React.useState('00:00'); + React.useEffect(() => { + const interval = setInterval(() => { + setAssistDuration(Duration.fromMillis(+new Date() - assistStart).toFormat('mm:ss')); + } + , 1000); + return () => clearInterval(interval); + }, []) + return ( + <> + Elapsed {assistDuration} + </> + ) +}) + +const AssistDuration = React.memo(AssistDurationCont); + ReduxTime.displayName = "ReduxTime"; -export default Time; -export { ReduxTime }; +export default React.memo(Time); +export { ReduxTime, AssistDuration }; diff --git a/frontend/app/components/Session_/Player/Controls/TimeTooltip.tsx b/frontend/app/components/Session_/Player/Controls/TimeTooltip.tsx new file mode 100644 index 000000000..fe22c4ea9 --- /dev/null +++ b/frontend/app/components/Session_/Player/Controls/TimeTooltip.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +// @ts-ignore +import { Duration } from 'luxon'; +import { connect } from 'react-redux'; +// @ts-ignore +import stl from './timeline.module.css'; + +function TimeTooltip({ time, offset, isVisible, liveTimeTravel }: { time: number; offset: number; isVisible: boolean, liveTimeTravel: boolean }) { + const duration = Duration.fromMillis(time).toFormat(`${liveTimeTravel ? '-' : ''}mm:ss`); + return ( + <div + className={stl.timeTooltip} + style={{ + top: -30, + left: offset - 20, + display: isVisible ? 'block' : 'none' } + } + > + {!time ? 'Loading' : duration} + </div> + ); +} + +export default connect((state) => { + const { time = 0, offset = 0, isVisible } = state.getIn(['sessions', 'timeLineTooltip']); + return { time, offset, isVisible }; +})(TimeTooltip); diff --git a/frontend/app/components/Session_/Player/Controls/Timeline.js b/frontend/app/components/Session_/Player/Controls/Timeline.js index 3acdb4c11..c53018362 100644 --- a/frontend/app/components/Session_/Player/Controls/Timeline.js +++ b/frontend/app/components/Session_/Player/Controls/Timeline.js @@ -6,361 +6,399 @@ import { TimelinePointer, Icon } from 'UI'; import TimeTracker from './TimeTracker'; import stl from './timeline.module.css'; import { TYPES } from 'Types/session/event'; -import { setTimelinePointer } from 'Duck/sessions'; +import { setTimelinePointer, setTimelineHoverTime } from 'Duck/sessions'; import DraggableCircle from './DraggableCircle'; import CustomDragLayer from './CustomDragLayer'; import { debounce } from 'App/utils'; import { Tooltip } from 'react-tippy'; +import TooltipContainer from './components/TooltipContainer'; -const BOUNDRY = 15 +const BOUNDRY = 0; function getTimelinePosition(value, scale) { - const pos = value * scale; + const pos = value * scale; - return pos > 100 ? 100 : pos; + return pos > 100 ? 99 : pos; } const getPointerIcon = (type) => { - // exception, - switch(type) { - case 'fetch': - return 'funnel/file-earmark-minus-fill'; - case 'exception': - return 'funnel/exclamation-circle-fill'; - case 'log': - return 'funnel/exclamation-circle-fill'; - case 'stack': - return 'funnel/patch-exclamation-fill'; - case 'resource': - return 'funnel/file-earmark-minus-fill'; + // exception, + switch (type) { + case 'fetch': + return 'funnel/file-earmark-minus-fill'; + case 'exception': + return 'funnel/exclamation-circle-fill'; + case 'log': + return 'funnel/exclamation-circle-fill'; + case 'stack': + return 'funnel/patch-exclamation-fill'; + case 'resource': + return 'funnel/file-earmark-minus-fill'; - case 'dead_click': - return 'funnel/dizzy'; - case 'click_rage': - return 'funnel/dizzy'; - case 'excessive_scrolling': - return 'funnel/mouse'; - case 'bad_request': - return 'funnel/file-medical-alt'; - case 'missing_resource': - return 'funnel/file-earmark-minus-fill'; - case 'memory': - return 'funnel/sd-card'; - case 'cpu': - return 'funnel/microchip'; - case 'slow_resource': - return 'funnel/hourglass-top'; - case 'slow_page_load': - return 'funnel/hourglass-top'; - case 'crash': - return 'funnel/file-exclamation'; - case 'js_exception': - return 'funnel/exclamation-circle-fill'; - } - - return 'info'; -} + case 'dead_click': + return 'funnel/dizzy'; + case 'click_rage': + return 'funnel/dizzy'; + case 'excessive_scrolling': + return 'funnel/mouse'; + case 'bad_request': + return 'funnel/file-medical-alt'; + case 'missing_resource': + return 'funnel/file-earmark-minus-fill'; + case 'memory': + return 'funnel/sd-card'; + case 'cpu': + return 'funnel/microchip'; + case 'slow_resource': + return 'funnel/hourglass-top'; + case 'slow_page_load': + return 'funnel/hourglass-top'; + case 'crash': + return 'funnel/file-exclamation'; + case 'js_exception': + return 'funnel/exclamation-circle-fill'; + } + return 'info'; +}; let deboucneJump = () => null; -@connectPlayer(state => ({ - playing: state.playing, - time: state.time, - skipIntervals: state.skipIntervals, - events: state.eventList, - skip: state.skip, - // not updating properly rn - // skipToIssue: state.skipToIssue, - disabled: state.cssLoading || state.messagesLoading || state.markedTargets, - endTime: state.endTime, - live: state.live, - logList: state.logList, - exceptionsList: state.exceptionsList, - resourceList: state.resourceList, - stackList: state.stackList, - fetchList: state.fetchList, +let debounceTooltipChange = () => null; +@connectPlayer((state) => ({ + playing: state.playing, + time: state.time, + skipIntervals: state.skipIntervals, + events: state.eventList, + skip: state.skip, + // not updating properly rn + // skipToIssue: state.skipToIssue, + disabled: state.cssLoading || state.messagesLoading || state.markedTargets, + endTime: state.endTime, + live: state.live, + logList: state.logList, + exceptionsList: state.exceptionsList, + resourceList: state.resourceList, + stackList: state.stackList, + fetchList: state.fetchList, })) -@connect(state => ({ - issues: state.getIn([ 'sessions', 'current', 'issues' ]), - clickRageTime: state.getIn([ 'sessions', 'current', 'clickRage' ]) && - state.getIn([ 'sessions', 'current', 'clickRageTime' ]), - returningLocationTime: state.getIn([ 'sessions', 'current', 'returningLocation' ]) && - state.getIn([ 'sessions', 'current', 'returningLocationTime' ]), -}), { setTimelinePointer }) +@connect( + (state) => ({ + issues: state.getIn(['sessions', 'current', 'issues']), + clickRageTime: state.getIn(['sessions', 'current', 'clickRage']) && state.getIn(['sessions', 'current', 'clickRageTime']), + returningLocationTime: + state.getIn(['sessions', 'current', 'returningLocation']) && state.getIn(['sessions', 'current', 'returningLocationTime']), + tooltipVisible: state.getIn(['sessions', 'timeLineTooltip', 'isVisible']), + }), + { setTimelinePointer, setTimelineHoverTime } +) export default class Timeline extends React.PureComponent { - progressRef = React.createRef() - wasPlaying = false + progressRef = React.createRef(); + timelineRef = React.createRef(); + wasPlaying = false; - seekProgress = (e) => { - const { endTime } = this.props; - const p = e.nativeEvent.offsetX / e.target.offsetWidth; - const time = Math.max(Math.round(p * endTime), 0); - this.props.jump(time); - } + seekProgress = (e) => { + const time = this.getTime(e); + this.props.jump(time); + this.hideTimeTooltip(); + }; - createEventClickHandler = pointer => (e) => { - e.stopPropagation(); - this.props.jump(pointer.time); - this.props.setTimelinePointer(pointer); - } + getTime = (e) => { + const { endTime } = this.props; + const p = e.nativeEvent.offsetX / e.target.offsetWidth; + const time = Math.max(Math.round(p * endTime), 0); - componentDidMount() { - const { issues } = this.props; - const skipToIssue = Controls.updateSkipToIssue(); - const firstIssue = issues.get(0); - deboucneJump = debounce(this.props.jump, 500); + return time; + }; - if (firstIssue && skipToIssue) { - this.props.jump(firstIssue.time); + createEventClickHandler = (pointer) => (e) => { + e.stopPropagation(); + this.props.jump(pointer.time); + this.props.setTimelinePointer(pointer); + }; + + componentDidMount() { + const { issues } = this.props; + const skipToIssue = Controls.updateSkipToIssue(); + const firstIssue = issues.get(0); + deboucneJump = debounce(this.props.jump, 500); + debounceTooltipChange = debounce(this.props.setTimelineHoverTime, 50); + + if (firstIssue && skipToIssue) { + this.props.jump(firstIssue.time); + } } - } - onDragEnd = () => { - if (this.wasPlaying) { - this.props.togglePlay(); + onDragEnd = () => { + if (this.wasPlaying) { + this.props.togglePlay(); + } + }; + + onDrag = (offset) => { + const { endTime } = this.props; + + const p = (offset.x - BOUNDRY) / this.progressRef.current.offsetWidth; + const time = Math.max(Math.round(p * endTime), 0); + deboucneJump(time); + this.hideTimeTooltip(); + if (this.props.playing) { + this.wasPlaying = true; + this.props.pause(); + } + }; + + showTimeTooltip = (e) => { + if (e.target !== this.progressRef.current && e.target !== this.timelineRef.current) { + return this.props.tooltipVisible && this.hideTimeTooltip(); + } + const time = this.getTime(e); + const { endTime, liveTimeTravel } = this.props; + + const timeLineTooltip = { + time: liveTimeTravel ? endTime - time : time, + offset: e.nativeEvent.offsetX, + isVisible: true, + }; + debounceTooltipChange(timeLineTooltip); + }; + + hideTimeTooltip = () => { + const timeLineTooltip = { isVisible: false }; + debounceTooltipChange(timeLineTooltip); + }; + + render() { + const { + events, + skip, + skipIntervals, + disabled, + endTime, + exceptionsList, + resourceList, + clickRageTime, + stackList, + fetchList, + issues, + liveTimeTravel, + } = this.props; + + const scale = 100 / endTime; + + return ( + <div className="flex items-center absolute w-full" style={{ top: '-4px', zIndex: 100, padding: `0 ${BOUNDRY}px`, maxWidth: '100%' }}> + <div + className={stl.progress} + onClick={disabled ? null : this.seekProgress} + ref={this.progressRef} + role="button" + onMouseMoveCapture={this.showTimeTooltip} + onMouseEnter={this.showTimeTooltip} + onMouseLeave={this.hideTimeTooltip} + > + <TooltipContainer liveTimeTravel={liveTimeTravel} /> + {/* custo color is live */} + <DraggableCircle left={this.props.time * scale} onDrop={this.onDragEnd} live={this.props.live} /> + <CustomDragLayer + onDrag={this.onDrag} + minX={BOUNDRY} + maxX={this.progressRef.current && this.progressRef.current.offsetWidth + BOUNDRY} + /> + <TimeTracker scale={scale} /> + + {skip && + skipIntervals.map((interval) => ( + <div + key={interval.start} + className={stl.skipInterval} + style={{ + left: `${getTimelinePosition(interval.start, scale)}%`, + width: `${(interval.end - interval.start) * scale}%`, + }} + /> + ))} + <div className={stl.timeline} ref={this.timelineRef} /> + + {events.map((e) => ( + <div key={e.key} className={stl.event} style={{ left: `${getTimelinePosition(e.time, scale)}%` }} /> + ))} + {/* {issues.map((iss) => ( + <div + style={{ + left: `${getTimelinePosition(iss.time, scale)}%`, + top: '0px', + zIndex: 11, + width: 16, + height: 16, + }} + key={iss.key} + className={stl.clickRage} + onClick={this.createEventClickHandler(iss)} + > + <Tooltip + delay={0} + position="top" + html={ + <div className={stl.popup}> + <b>{iss.name}</b> + </div> + } + > + <Icon className="rounded-full bg-white" name={iss.icon} size="16" /> + </Tooltip> + </div> + ))} + {events + .filter((e) => e.type === TYPES.CLICKRAGE) + .map((e) => ( + <div + style={{ + left: `${getTimelinePosition(e.time, scale)}%`, + top: '0px', + zIndex: 11, + width: 16, + height: 16, + }} + key={e.key} + className={stl.clickRage} + onClick={this.createEventClickHandler(e)} + > + <Tooltip + delay={0} + position="top" + html={ + <div className={stl.popup}> + <b>{'Click Rage'}</b> + </div> + } + > + <Icon className="bg-white" name={getPointerIcon('click_rage')} color="red" size="16" /> + </Tooltip> + </div> + ))} + {typeof clickRageTime === 'number' && ( + <div + style={{ + left: `${getTimelinePosition(clickRageTime, scale)}%`, + top: '-0px', + zIndex: 11, + width: 16, + height: 16, + }} + className={stl.clickRage} + > + <Tooltip + delay={0} + position="top" + html={ + <div className={stl.popup}> + <b>{'Click Rage'}</b> + </div> + } + > + <Icon className="rounded-full bg-white" name={getPointerIcon('click_rage')} color="red" size="16" /> + </Tooltip> + </div> + )} + {exceptionsList.map((e) => ( + <div + key={e.key} + className={cn(stl.markup, stl.error)} + style={{ left: `${getTimelinePosition(e.time, scale)}%`, top: '0px', zIndex: 10, width: 16, height: 16 }} + onClick={this.createEventClickHandler(e)} + > + <Tooltip + delay={0} + position="top" + html={ + <div className={stl.popup}> + <b>{'Exception'}</b> + <br /> + <span>{e.message}</span> + </div> + } + > + <Icon className=" rounded-full bg-white" name={getPointerIcon('exception')} color="red" size="16" /> + </Tooltip> + </div> + ))} + {resourceList + .filter((r) => r.isRed() || r.isYellow()) + .map((r) => ( + <div + key={r.key} + className={cn(stl.markup, { + [stl.error]: r.isRed(), + [stl.warning]: r.isYellow(), + })} + style={{ left: `${getTimelinePosition(r.time, scale)}%`, top: '0px', zIndex: 10, width: 16, height: 16 }} + onClick={this.createEventClickHandler(r)} + > + <Tooltip + delay={0} + position="top" + html={ + <div className={stl.popup}> + <b>{r.success ? 'Slow resource: ' : 'Missing resource:'}</b> + <br /> + {r.name} + </div> + } + > + <Icon className=" rounded-full bg-white" name={getPointerIcon('resource')} size="16" /> + </Tooltip> + </div> + ))} + {fetchList + .filter((e) => e.isRed()) + .map((e) => ( + <div + key={e.key} + className={cn(stl.markup, stl.error)} + style={{ left: `${getTimelinePosition(e.time, scale)}%`, top: '0px' }} + onClick={this.createEventClickHandler(e)} + > + <Tooltip + delay={0} + position="top" + html={ + <div className={stl.popup}> + <b>Failed Fetch</b> + <br /> + {e.name} + </div> + } + > + <Icon className=" rounded-full bg-white" name={getPointerIcon('fetch')} color="red" size="16" /> + </Tooltip> + </div> + ))} + {stackList + .filter((e) => e.isRed()) + .map((e) => ( + <div + key={e.key} + className={cn(stl.markup, stl.error)} + style={{ left: `${getTimelinePosition(e.time, scale)}%`, top: '0px' }} + onClick={this.createEventClickHandler(e)} + > + <Tooltip + delay={0} + position="top" + html={ + <div className={stl.popup}> + <b>Stack Event</b> + <br /> + {e.name} + </div> + } + > + <Icon className=" rounded-full bg-white" name={getPointerIcon('stack')} size="16" /> + </Tooltip> + </div> + ))} */} + </div> + </div> + ); } - } - - onDrag = (offset) => { - const { endTime } = this.props; - - const p = (offset.x - BOUNDRY) / this.progressRef.current.offsetWidth; - const time = Math.max(Math.round(p * endTime), 0); - deboucneJump(time); - if (this.props.playing) { - this.wasPlaying = true; - this.props.pause(); - } - } - - render() { - const { - events, - skip, - skipIntervals, - disabled, - endTime, - live, - logList, - exceptionsList, - resourceList, - clickRageTime, - stackList, - fetchList, - issues, - } = this.props; - - const scale = 100 / endTime; - - return ( - <div - className="flex items-center absolute w-full" - style={{ top: '-4px', zIndex: 100, padding: `0 ${BOUNDRY}px`}} - > - <div - className={ stl.progress } - onClick={ disabled ? null : this.seekProgress } - ref={ this.progressRef } - role="button" - > - <DraggableCircle left={this.props.time * scale} onDrop={this.onDragEnd} /> - <CustomDragLayer onDrag={this.onDrag} minX={BOUNDRY} maxX={this.progressRef.current && this.progressRef.current.offsetWidth + BOUNDRY} /> - <TimeTracker scale={ scale } /> - { skip && skipIntervals.map(interval => - (<div - key={ interval.start } - className={ stl.skipInterval } - style={ { - left: `${getTimelinePosition(interval.start, scale)}%`, - width: `${ (interval.end - interval.start) * scale }%`, - } } - />)) - } - <div className={ stl.timeline }/> - { events.map(e => ( - <div - key={ e.key } - className={ stl.event } - style={ { left: `${ getTimelinePosition(e.time,scale)}%` } } - /> - )) - } - { - issues.map(iss => ( - <div - style={ { - left: `${ getTimelinePosition(iss.time, scale) }%`, - top: '0px', - zIndex: 11, width: 16, height: 16 - } } - key={iss.key} - className={ stl.clickRage } - onClick={ this.createEventClickHandler(iss) } - > - <Tooltip - delay={0} - position="top" - html={ - <div className={ stl.popup }> - <b>{ iss.name }</b> - </div> - } - > - <Icon className="rounded-full bg-white" name={iss.icon} size="16" /> - </Tooltip> - </div> - )) - } - { events.filter(e => e.type === TYPES.CLICKRAGE).map(e => ( - <div - style={ { - left: `${ getTimelinePosition(e.time, scale) }%`, - top: '0px', - zIndex: 11, width: 16, height: 16 - } } - key={e.key} - className={ stl.clickRage } - onClick={ this.createEventClickHandler(e) } - > - <Tooltip - delay={0} - position="top" - html={ - <div className={ stl.popup }> - <b>{ "Click Rage" }</b> - </div> - } - > - <Icon className="bg-white" name={getPointerIcon('click_rage')} color="red" size="16" /> - </Tooltip> - </div> - ))} - {typeof clickRageTime === 'number' && - <div - style={{ - left: `${ getTimelinePosition(clickRageTime, scale) }%`, - top: '-0px', - zIndex: 11, width: 16, height: 16 - }} - className={stl.clickRage} - > - <Tooltip - delay={0} - position="top" - html={ - <div className={ stl.popup }> - <b>{ "Click Rage" }</b> - </div> - } - > - <Icon className="rounded-full bg-white" name={getPointerIcon('click_rage')} color="red" size="16" /> - </Tooltip> - </div> - } - { exceptionsList - .map(e => ( - <div - key={ e.key } - className={ cn(stl.markup, stl.error) } - style={ { left: `${ getTimelinePosition(e.time, scale) }%`, top: '0px', zIndex: 10, width: 16, height: 16 } } - onClick={ this.createEventClickHandler(e) } - > - <Tooltip - delay={0} - position="top" - html={ - <div className={ stl.popup } > - <b>{ "Exception" }</b> - <br/> - <span>{ e.message }</span> - </div> - } - > - <Icon className=" rounded-full bg-white" name={getPointerIcon('exception')} color="red" size="16" /> - </Tooltip> - </div> - )) - } - { resourceList - .filter(r => r.isRed() || r.isYellow()) - .map(r => ( - <div - key={ r.key } - className={ cn(stl.markup, { - [ stl.error ]: r.isRed(), - [ stl.warning ]: r.isYellow(), - }) } - style={ { left: `${ getTimelinePosition(r.time, scale) }%`, top: '0px', zIndex: 10, width: 16, height: 16 } } - onClick={ this.createEventClickHandler(r) } - > - <Tooltip - delay={0} - position="top" - html={ - <div className={ stl.popup }> - <b>{ r.success ? "Slow resource: " : "Missing resource:" }</b> - <br/> - { r.name } - </div> - } - > - <Icon className=" rounded-full bg-white" name={getPointerIcon('resource')} size="16" /> - </Tooltip> - </div> - )) - } - { fetchList - .filter(e => e.isRed()) - .map(e => ( - <div - key={ e.key } - className={ cn(stl.markup, stl.error) } - style={ { left: `${ getTimelinePosition(e.time, scale) }%`, top: '0px' } } - onClick={ this.createEventClickHandler(e) } - > - <Tooltip - delay={0} - position="top" - html={ - <div className={ stl.popup }> - <b>Failed Fetch</b> - <br/> - { e.name } - </div> - } - > - <Icon className=" rounded-full bg-white" name={getPointerIcon('fetch')} color="red" size="16" /> - </Tooltip> - </div> - )) - } - { stackList - .filter(e => e.isRed()) - .map(e => ( - <div - key={ e.key } - className={ cn(stl.markup, stl.error) } - style={ { left: `${ getTimelinePosition(e.time, scale) }%`, top: '0px' } } - onClick={ this.createEventClickHandler(e) } - > - <Tooltip - delay={0} - position="top" - html={ - <div className={ stl.popup }> - <b>Stack Event</b> - <br/> - { e.name } - </div> - } - > - <Icon className=" rounded-full bg-white" name={getPointerIcon('stack')} size="16" /> - </Tooltip> - </div> - )) - } - </div> - </div> - ); - } } diff --git a/frontend/app/components/Session_/Player/Controls/components/PlayerControls.tsx b/frontend/app/components/Session_/Player/Controls/components/PlayerControls.tsx new file mode 100644 index 000000000..6ad43c2b5 --- /dev/null +++ b/frontend/app/components/Session_/Player/Controls/components/PlayerControls.tsx @@ -0,0 +1,198 @@ +import React from 'react'; +import { Tooltip } from 'react-tippy'; +import { Icon } from 'UI'; +import cn from 'classnames'; +import OutsideClickDetectingDiv from 'Shared/OutsideClickDetectingDiv'; +import { ReduxTime } from '../Time'; +// @ts-ignore +import styles from '../controls.module.css'; + +interface Props { + live: boolean; + skip: boolean; + speed: number; + disabled: boolean; + playButton: JSX.Element; + skipIntervals: Record<number, number>; + currentInterval: number; + setSkipInterval: (interval: number) => void; + backTenSeconds: () => void; + forthTenSeconds: () => void; + toggleSpeed: () => void; + toggleSkip: () => void; + controlIcon: ( + icon: string, + size: number, + action: () => void, + isBackwards: boolean, + additionalClasses: string + ) => JSX.Element; +} + +function PlayerControls(props: Props) { + const { + live, + skip, + speed, + disabled, + playButton, + backTenSeconds, + forthTenSeconds, + toggleSpeed, + toggleSkip, + skipIntervals, + setSkipInterval, + currentInterval, + controlIcon, + } = props; + const [showTooltip, setShowTooltip] = React.useState(false); + const speedRef = React.useRef(null); + const arrowBackRef = React.useRef(null); + const arrowForwardRef = React.useRef(null); + + React.useEffect(() => { + const handleKeyboard = (e: KeyboardEvent) => { + if (e.key === 'ArrowRight') { + arrowForwardRef.current.focus(); + } + if (e.key === 'ArrowLeft') { + arrowBackRef.current.focus(); + } + if (e.key === 'ArrowDown') { + speedRef.current.focus(); + } + if (e.key === 'ArrowUp') { + speedRef.current.focus(); + } + }; + document.addEventListener('keydown', handleKeyboard); + return () => document.removeEventListener('keydown', handleKeyboard); + }, [speedRef, arrowBackRef, arrowForwardRef]); + + const toggleTooltip = () => { + setShowTooltip(!showTooltip); + }; + return ( + <div className="flex items-center"> + {playButton} + {!live && ( + <div className="flex items-center font-semibold text-center" style={{ minWidth: 85 }}> + {/* @ts-ignore */} + <ReduxTime isCustom name="time" format="mm:ss" /> + <span className="px-1">/</span> + {/* @ts-ignore */} + <ReduxTime isCustom name="endTime" format="mm:ss" /> + </div> + )} + + <div className="rounded ml-4 bg-active-blue border border-active-blue-border flex items-stretch"> + {/* @ts-ignore */} + <Tooltip title="Rewind 10s" delay={0} position="top"> + <button + ref={arrowBackRef} + className="h-full hover:border-active-blue-border focus:border focus:border-blue border-borderColor-transparent" + > + {controlIcon( + 'skip-forward-fill', + 18, + backTenSeconds, + true, + 'hover:bg-active-blue-border color-main h-full flex items-center' + )} + </button> + </Tooltip> + <div className="p-1 border-l border-r bg-active-blue-border border-active-blue-border"> + <Tooltip + open={showTooltip} + interactive + // @ts-ignore + theme="nopadding" + animation="none" + duration={0} + className="cursor-pointer select-none" + distance={20} + html={ + <OutsideClickDetectingDiv + onClickOutside={() => (showTooltip ? toggleTooltip() : null)} + > + <div className="flex flex-col bg-white border border-borderColor-gray-light-shade text-figmaColors-text-primary rounded"> + <div className="font-semibold py-2 px-4 w-full text-left"> + Jump <span className="text-disabled-text">(Secs)</span> + </div> + {Object.keys(skipIntervals).map((interval) => ( + <div + onClick={() => { + toggleTooltip(); + setSkipInterval(parseInt(interval, 10)); + }} + className={cn( + 'py-2 px-4 cursor-pointer w-full text-left font-semibold', + 'hover:bg-active-blue border-t border-borderColor-gray-light-shade' + )} + > + {interval} + <span className="text-disabled-text">s</span> + </div> + ))} + </div> + </OutsideClickDetectingDiv> + } + > + <div onClick={toggleTooltip}> + {/* @ts-ignore */} + <Tooltip disabled={showTooltip} title="Set default skip duration"> + {currentInterval}s + </Tooltip> + </div> + </Tooltip> + </div> + {/* @ts-ignore */} + <Tooltip title="Forward 10s" delay={0} position="top"> + <button + ref={arrowForwardRef} + className="h-full hover:border-active-blue-border focus:border focus:border-blue border-borderColor-transparent" + > + {controlIcon( + 'skip-forward-fill', + 18, + forthTenSeconds, + false, + 'hover:bg-active-blue-border color-main h-full flex items-center' + )} + </button> + </Tooltip> + </div> + + {!live && ( + <div className="flex items-center"> + <div className="mx-2" /> + {/* @ts-ignore */} + <Tooltip title="Control play back speed (↑↓)" delay={0} position="top"> + <button + ref={speedRef} + className={cn(styles.speedButton, 'focus:border focus:border-blue')} + onClick={toggleSpeed} + data-disabled={disabled} + > + <div>{speed + 'x'}</div> + </button> + </Tooltip> + <div className="mx-2" /> + <button + className={cn( + styles.skipIntervalButton, + { [styles.withCheckIcon]: skip, [styles.active]: skip }, + )} + onClick={toggleSkip} + data-disabled={disabled} + > + {skip && <Icon name="check" size="24" className="mr-1" />} + {'Skip Inactivity'} + </button> + </div> + )} + </div> + ); +} + +export default PlayerControls; diff --git a/frontend/app/components/Session_/Player/Controls/components/TooltipContainer.tsx b/frontend/app/components/Session_/Player/Controls/components/TooltipContainer.tsx new file mode 100644 index 000000000..2c90fcc1d --- /dev/null +++ b/frontend/app/components/Session_/Player/Controls/components/TooltipContainer.tsx @@ -0,0 +1,15 @@ +import React from 'react' +import TimeTooltip from '../TimeTooltip'; +import store from 'App/store'; +import { Provider } from 'react-redux'; + +function TooltipContainer({ liveTimeTravel }: { liveTimeTravel: boolean }) { + + return ( + <Provider store={store}> + <TimeTooltip liveTimeTravel={liveTimeTravel} /> + </Provider> + ) +} + +export default React.memo(TooltipContainer); diff --git a/frontend/app/components/Session_/Player/Controls/controlButton.module.css b/frontend/app/components/Session_/Player/Controls/controlButton.module.css index b72ec0bd7..ff9bb2a19 100644 --- a/frontend/app/components/Session_/Player/Controls/controlButton.module.css +++ b/frontend/app/components/Session_/Player/Controls/controlButton.module.css @@ -43,8 +43,7 @@ } & .label { - /* padding-top: 5px; */ - font-size: 10px; + font-size: 14px !important; height: 16px; &:hover { diff --git a/frontend/app/components/Session_/Player/Controls/controls.module.css b/frontend/app/components/Session_/Player/Controls/controls.module.css index 0b377a594..c27cb74da 100644 --- a/frontend/app/components/Session_/Player/Controls/controls.module.css +++ b/frontend/app/components/Session_/Player/Controls/controls.module.css @@ -16,11 +16,8 @@ justify-content: space-between; align-items: center; height: 65px; - padding-left: 30px; + padding-left: 10px; padding-right: 0; - &[data-is-live=true] { - padding: 0; - } } .buttonsLeft { @@ -41,9 +38,12 @@ padding: 0 10px; height: 30px; border-radius: 3px; + background-color: $gray-lightest; + &:hover { - background-color: $gray-lightest; + background-color: $active-blue; + color: $teal; transition: all 0.2s; } } @@ -54,16 +54,17 @@ display: flex; align-items: center; cursor: pointer; - font-size: 12px; + font-size: 13px; color: $gray-darkest; - /* margin-right: 5px; */ + background-color: $gray-lightest; padding: 0 10px; height: 30px; border-radius: 3px; /* margin: 0 5px; */ &:hover { - background-color: $gray-lightest; + background-color: $active-blue; transition: all 0.2s; + color: $teal; } &.active { background: repeating-linear-gradient( 125deg, #efefef, #efefef 3px, #ddd 3px, #efefef 5px ); diff --git a/frontend/app/components/Session_/Player/Controls/timeline.module.css b/frontend/app/components/Session_/Player/Controls/timeline.module.css index a5676d6b1..48217119d 100644 --- a/frontend/app/components/Session_/Player/Controls/timeline.module.css +++ b/frontend/app/components/Session_/Player/Controls/timeline.module.css @@ -21,14 +21,21 @@ } +.greenTracker { + background-color: #42AE5E!important; + box-shadow: 0 0 0 1px #42AE5E; +} + .progress { height: 10px; padding: 8px 0; cursor: pointer; width: 100%; + max-width: 100%; position: relative; display: flex; align-items: center; + } @@ -163,3 +170,28 @@ } } } + +.timeTooltip { + position: absolute; + padding: 0.25rem; + transition-property: all; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; + background: black; + top: -35px; + color: white; + + &:after { + content:''; + position: absolute; + top: 100%; + left: 0; + right: 0; + margin: 0 auto; + width: 0; + height: 0; + border-top: solid 5px black; + border-left: solid 5px transparent; + border-right: solid 5px transparent; + } +} diff --git a/frontend/app/components/Session_/Player/Overlay.tsx b/frontend/app/components/Session_/Player/Overlay.tsx index 994608108..812eb5d88 100644 --- a/frontend/app/components/Session_/Player/Overlay.tsx +++ b/frontend/app/components/Session_/Player/Overlay.tsx @@ -14,7 +14,6 @@ interface Props { playing: boolean, completed: boolean, inspectorMode: boolean, - messagesLoading: boolean, loading: boolean, live: boolean, liveStatusText: string, @@ -25,14 +24,14 @@ interface Props { nextId: string, togglePlay: () => void, - closedLive?: boolean + closedLive?: boolean, + livePlay?: boolean, } function Overlay({ playing, completed, inspectorMode, - messagesLoading, loading, live, liveStatusText, @@ -42,26 +41,21 @@ function Overlay({ activeTargetIndex, nextId, togglePlay, - closedLive + closedLive, + livePlay, }: Props) { - - // useEffect(() =>{ - // setTimeout(() => markTargets([{ selector: 'div', count:6}]), 5000) - // setTimeout(() => markTargets(null), 8000) - // },[]) - const showAutoplayTimer = !live && completed && autoplay && nextId const showPlayIconLayer = !live && !markedTargets && !inspectorMode && !loading && !showAutoplayTimer; - const showLiveStatusText = live && liveStatusText && !loading; + const showLiveStatusText = live && livePlay && liveStatusText && !loading; return ( <> { showAutoplayTimer && <AutoplayTimer /> } - { showLiveStatusText && + { showLiveStatusText && <LiveStatusText text={liveStatusText} concetionStatus={closedLive ? ConnectionStatus.Closed : concetionStatus} /> } - { messagesLoading && <Loader/> } - { showPlayIconLayer && + { loading ? <Loader /> : null } + { showPlayIconLayer && <PlayIconLayer playing={playing} togglePlay={togglePlay} /> } { markedTargets && <ElementsMarker targets={ markedTargets } activeIndex={activeTargetIndex}/> @@ -73,7 +67,6 @@ function Overlay({ export default connectPlayer(state => ({ playing: state.playing, - messagesLoading: state.messagesLoading, loading: state.messagesLoading || state.cssLoading, completed: state.completed, autoplay: state.autoplay, @@ -83,4 +76,5 @@ export default connectPlayer(state => ({ concetionStatus: state.peerConnectionStatus, markedTargets: state.markedTargets, activeTargetIndex: state.activeTargetIndex, -}))(Overlay); \ No newline at end of file + livePlay: state.livePlay, +}))(Overlay); diff --git a/frontend/app/components/Session_/Player/Overlay/AutoplayTimer.tsx b/frontend/app/components/Session_/Player/Overlay/AutoplayTimer.tsx index ecf1cb7f0..a99633bb4 100644 --- a/frontend/app/components/Session_/Player/Overlay/AutoplayTimer.tsx +++ b/frontend/app/components/Session_/Player/Overlay/AutoplayTimer.tsx @@ -1,14 +1,19 @@ import React, { useEffect, useState } from 'react' import cn from 'classnames'; import { connect } from 'react-redux' -import { withRouter } from 'react-router-dom'; +import { withRouter, RouteComponentProps } from 'react-router-dom'; import { Button, Link } from 'UI' import { session as sessionRoute, withSiteId } from 'App/routes' import stl from './AutoplayTimer.module.css'; import clsOv from './overlay.module.css'; -function AutoplayTimer({ nextId, siteId, history }) { - let timer +interface IProps extends RouteComponentProps { + nextId: number; + siteId: string; +} + +function AutoplayTimer({ nextId, siteId, history }: IProps) { + let timer: NodeJS.Timer const [cancelled, setCancelled] = useState(false); const [counter, setCounter] = useState(5); @@ -32,7 +37,7 @@ function AutoplayTimer({ nextId, siteId, history }) { } if (cancelled) - return '' + return null return ( <div className={ cn(clsOv.overlay, stl.overlayBg) } > @@ -50,7 +55,6 @@ function AutoplayTimer({ nextId, siteId, history }) { ) } - export default withRouter(connect(state => ({ siteId: state.getIn([ 'site', 'siteId' ]), nextId: parseInt(state.getIn([ 'sessions', 'nextId' ])), diff --git a/frontend/app/components/Session_/Player/Overlay/ElementsMarker/Marker.tsx b/frontend/app/components/Session_/Player/Overlay/ElementsMarker/Marker.tsx index d5afa65a2..cc8f3fd1f 100644 --- a/frontend/app/components/Session_/Player/Overlay/ElementsMarker/Marker.tsx +++ b/frontend/app/components/Session_/Player/Overlay/ElementsMarker/Marker.tsx @@ -11,27 +11,29 @@ interface Props { active: boolean; } -export default function Marker({ target, active }: Props) { +export default function Marker({ target, active }: Props) { const style = { - top: `${ target.boundingRect.top }px`, - left: `${ target.boundingRect.left }px`, - width: `${ target.boundingRect.width }px`, - height: `${ target.boundingRect.height }px`, - } + top: `${target.boundingRect.top}px`, + left: `${target.boundingRect.left}px`, + width: `${target.boundingRect.width}px`, + height: `${target.boundingRect.height}px`, + } return ( - <div className={ cn(stl.marker, { [stl.active] : active }) } style={ style } onClick={() => activeTarget(target.index)}> - <div className={stl.index}>{target.index + 1}</div> - <Tooltip - open={active} - arrow - sticky - distance={15} - html={( - <div>{target.count} Clicks</div> - )} - > - <div className="absolute inset-0"></div> - </Tooltip> - </div> - ) + <div className={cn(stl.marker, { [stl.active]: active })} style={style} onClick={() => activeTarget(target.index)}> + <div className={stl.index}>{target.index + 1}</div> + {/* @ts-expect-error Tooltip doesn't have children property */} + <Tooltip + open={active} + arrow + sticky + distance={15} + html={( + <div>{target.count} Clicks</div> + )} + trigger="mouseenter" + > + <div className="absolute inset-0"></div> + </Tooltip> + </div> + ) } \ No newline at end of file diff --git a/frontend/app/components/Session_/Player/Overlay/LiveStatusText.tsx b/frontend/app/components/Session_/Player/Overlay/LiveStatusText.tsx index 10a079ff3..a416bf529 100644 --- a/frontend/app/components/Session_/Player/Overlay/LiveStatusText.tsx +++ b/frontend/app/components/Session_/Player/Overlay/LiveStatusText.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import stl from './LiveStatusText.module.css'; import ovStl from './overlay.module.css'; import { ConnectionStatus } from 'Player/MessageDistributor/managers/AssistManager'; import { Loader } from 'UI'; @@ -64,9 +63,9 @@ export default function LiveStatusText({ text, concetionStatus }: Props) { <div className="text-sm">Something wrong just happened. Try refreshing the page.</div> </div> ) - } + } } return <div className={ovStl.overlay}> { renderView()} </div> -} \ No newline at end of file +} diff --git a/frontend/app/components/Session_/Player/Overlay/overlay.module.css b/frontend/app/components/Session_/Player/Overlay/overlay.module.css index 2c5cab1bd..6efb79620 100644 --- a/frontend/app/components/Session_/Player/Overlay/overlay.module.css +++ b/frontend/app/components/Session_/Player/Overlay/overlay.module.css @@ -8,4 +8,5 @@ display: flex; align-items: center; justify-content: center; + text-shadow:1px 0 0 white,0 1px 0 white,-1px 0 0 white,0 -1px 0 white; } diff --git a/frontend/app/components/Session_/Player/Player.js b/frontend/app/components/Session_/Player/Player.js index 4b5006338..babe4f2b0 100644 --- a/frontend/app/components/Session_/Player/Player.js +++ b/frontend/app/components/Session_/Player/Player.js @@ -18,6 +18,7 @@ import { EXCEPTIONS, LONGTASKS, INSPECTOR, + OVERVIEW, } from 'Duck/components/player'; import Network from '../Network'; import Console from '../Console/Console'; @@ -40,6 +41,7 @@ import Controls from './Controls'; import Overlay from './Overlay'; import stl from './player.module.css'; import { updateLastPlayedSession } from 'Duck/sessions'; +import OverviewPanel from '../OverviewPanel'; @connectPlayer(state => ({ live: state.live, @@ -82,7 +84,6 @@ export default class Player extends React.PureComponent { fullscreen, fullscreenOff, nextId, - live, closedLive, bottomBlock, activeTab @@ -104,6 +105,9 @@ export default class Player extends React.PureComponent { </div> { !fullscreen && !!bottomBlock && <div style={{ maxWidth, width: '100%' }}> + { bottomBlock === OVERVIEW && + <OverviewPanel /> + } { bottomBlock === CONSOLE && <Console /> } diff --git a/frontend/app/components/Session_/PlayerBlock.js b/frontend/app/components/Session_/PlayerBlock.js index e6bcbaa33..562ea8958 100644 --- a/frontend/app/components/Session_/PlayerBlock.js +++ b/frontend/app/components/Session_/PlayerBlock.js @@ -3,7 +3,7 @@ import cn from "classnames"; import { connect } from 'react-redux'; import { } from 'Player'; import { - NONE, + NONE, OVERVIEW, } from 'Duck/components/player'; import Player from './Player'; import SubHeader from './Subheader'; @@ -29,7 +29,7 @@ export default class PlayerBlock extends React.PureComponent { } = this.props; return ( - <div className={ cn(styles.playerBlock, "flex flex-col") }> + <div className={ cn(styles.playerBlock, "flex flex-col overflow-x-hidden") }> {!fullscreen && <SubHeader sessionId={sessionId} disabled={disabled} @@ -38,6 +38,7 @@ export default class PlayerBlock extends React.PureComponent { <Player className="flex-1" bottomBlockIsActive={ !fullscreen && bottomBlock !== NONE } + // bottomBlockIsActive={ true } bottomBlock={bottomBlock} fullscreen={fullscreen} activeTab={activeTab} diff --git a/frontend/app/components/Session_/PlayerBlockHeader.js b/frontend/app/components/Session_/PlayerBlockHeader.js index f0576e419..f8eb6b05b 100644 --- a/frontend/app/components/Session_/PlayerBlockHeader.js +++ b/frontend/app/components/Session_/PlayerBlockHeader.js @@ -105,7 +105,7 @@ export default class PlayerBlockHeader extends React.PureComponent { const { hideBack } = this.state; - const { sessionId, userId, userNumericHash, live, metadata } = session; + const { sessionId, userId, userNumericHash, live, metadata, isCallActive, agentIds } = session; let _metaList = Object.keys(metadata) .filter((i) => metaList.includes(i)) .map((key) => { @@ -142,7 +142,7 @@ export default class PlayerBlockHeader extends React.PureComponent { </div> )} - {isAssist && <AssistActions userId={userId} />} + {isAssist && <AssistActions userId={userId} isCallActive={isCallActive} agentIds={agentIds} />} </div> </div> {!isAssist && ( @@ -151,8 +151,13 @@ export default class PlayerBlockHeader extends React.PureComponent { tabs={TABS} active={activeTab} onClick={(tab) => { - setActiveTab(tab); - !showEvents && toggleEvents(true); + if (activeTab === tab) { + setActiveTab(''); + toggleEvents(); + } else { + setActiveTab(tab); + !showEvents && toggleEvents(true); + } }} border={false} /> diff --git a/frontend/app/components/Session_/Profiler/Profiler.js b/frontend/app/components/Session_/Profiler/Profiler.js index 83b13c89c..1d9c8a5a3 100644 --- a/frontend/app/components/Session_/Profiler/Profiler.js +++ b/frontend/app/components/Session_/Profiler/Profiler.js @@ -42,10 +42,12 @@ export default class Profiler extends React.PureComponent { /> <BottomBlock> <BottomBlock.Header> - <h4 className="text-lg">Profiler</h4> + <div className="flex items-center"> + <span className="font-semibold color-gray-medium mr-4">Profiler</span> + </div> <Input // className="input-small" - placeholder="Filter by Name" + placeholder="Filter by name" icon="search" name="filter" onChange={ this.onFilterChange } diff --git a/frontend/app/components/Session_/StackEvents/StackEvents.js b/frontend/app/components/Session_/StackEvents/StackEvents.js index 8069cb663..4ce3ce86c 100644 --- a/frontend/app/components/Session_/StackEvents/StackEvents.js +++ b/frontend/app/components/Session_/StackEvents/StackEvents.js @@ -1,85 +1,175 @@ +import { error as errorRoute } from 'App/routes'; +import JsonViewer from 'Components/Session_/StackEvents/UserEvent/JsonViewer'; +import Sentry from 'Components/Session_/StackEvents/UserEvent/Sentry'; +import { hideHint } from 'Duck/components/player'; +import withEnumToggle from 'HOCs/withEnumToggle'; +import { connectPlayer, jump } from 'Player'; import React from 'react'; import { connect } from 'react-redux'; -import { connectPlayer, jump } from 'Player'; -import { NoContent, Tabs } from 'UI'; -import withEnumToggle from 'HOCs/withEnumToggle'; -import { hideHint } from 'Duck/components/player'; -import { typeList } from 'Types/session/stackEvent'; -import UserEvent from './UserEvent'; +import { DATADOG, SENTRY, STACKDRIVER, typeList } from 'Types/session/stackEvent'; +import { NoContent, SlideModal, Tabs, Link } from 'UI'; import Autoscroll from '../Autoscroll'; import BottomBlock from '../BottomBlock'; +import UserEvent from './UserEvent'; const ALL = 'ALL'; -const TABS = [ ALL, ...typeList ].map(tab =>({ text: tab, key: tab })); +const TABS = [ALL, ...typeList].map((tab) => ({ text: tab, key: tab })); @withEnumToggle('activeTab', 'setActiveTab', ALL) -@connectPlayer(state => ({ +@connectPlayer((state) => ({ stackEvents: state.stackList, + stackEventsNow: state.stackListNow, })) -@connect(state => ({ - hintIsHidden: state.getIn(['components', 'player', 'hiddenHints', 'stack']) || - !state.getIn([ 'site', 'list' ]).some(s => s.stackIntegrations), -}), { - hideHint -}) +@connect( + (state) => ({ + hintIsHidden: + state.getIn(['components', 'player', 'hiddenHints', 'stack']) || + !state.getIn(['site', 'list']).some((s) => s.stackIntegrations), + }), + { + hideHint, + } +) export default class StackEvents extends React.PureComponent { -// onFilterChange = (e, { value }) => this.setState({ filter: value }) + // onFilterChange = (e, { value }) => this.setState({ filter: value }) + + state = { + currentEvent: null, + }; + + onDetailsClick(userEvent) { + this.setState({ currentEvent: userEvent }); + } + + closeModal() { + this.setState({ currentEvent: undefined }); + } + + renderPopupContent(userEvent) { + const { source, payload, name } = userEvent; + switch (source) { + case SENTRY: + return <Sentry event={payload} />; + case DATADOG: + return <JsonViewer title={name} data={payload} icon="integrations/datadog" />; + case STACKDRIVER: + return <JsonViewer title={name} data={payload} icon="integrations/stackdriver" />; + default: + return <JsonViewer title={name} data={payload} icon={`integrations/${source}`} />; + } + } render() { const { stackEvents, activeTab, setActiveTab, hintIsHidden } = this.props; //const filterRE = new RegExp(filter, 'i'); + const { currentEvent } = this.state; - const tabs = TABS.filter(({ key }) => key === ALL || stackEvents.some(({ source }) => key === source)); + const tabs = TABS.filter( + ({ key }) => key === ALL || stackEvents.some(({ source }) => key === source) + ); const filteredStackEvents = stackEvents -// .filter(({ data }) => data.includes(filter)) + // .filter(({ data }) => data.includes(filter)) .filter(({ source }) => activeTab === ALL || activeTab === source); + let lastIndex = -1; + // TODO: Need to do filtering in store, or preferably in a selector + filteredStackEvents.forEach((item, index) => { + if ( + this.props.stackEventsNow.length > 0 && + item.time <= this.props.stackEventsNow[this.props.stackEventsNow.length - 1].time + ) { + lastIndex = index; + } + }); + return ( - <BottomBlock> - <BottomBlock.Header> - <div className="flex items-center"> - <span className="font-semibold color-gray-medium mr-4">Events</span> - <Tabs - className="uppercase" - tabs={ tabs } - active={ activeTab } - onClick={ setActiveTab } - border={ false } - /> - </div> - </BottomBlock.Header> - <BottomBlock.Content> - <NoContent - title="Nothing to display yet." - subtext={ !hintIsHidden - ? - <> - <a className="underline color-teal" href="https://docs.openreplay.com/integrations" target="_blank">Integrations</a> - {' and '} - <a className="underline color-teal" href="https://docs.openreplay.com/api#event" target="_blank">Events</a> - { ' make debugging easier. Sync your backend logs and custom events with session replay.' } - <br/><br/> - <button className="color-teal" onClick={() => this.props.hideHint("stack")}>Got It!</button> - </> - : null - } - size="small" - show={ filteredStackEvents.length === 0 } - > - <Autoscroll> - { filteredStackEvents.map(userEvent => ( - <UserEvent - key={ userEvent.key } - userEvent={ userEvent } - onJump={ () => jump(userEvent.time) } - /> - ))} - </Autoscroll> - </NoContent> - </BottomBlock.Content> - </BottomBlock> + <> + <SlideModal + title={ + currentEvent && ( + <div className="mb-4"> + <div className="text-xl mb-2"> + <Link to={errorRoute(currentEvent.errorId)}> + <span className="font-bold">{currentEvent.name}</span> + </Link> + <span className="ml-2 text-sm color-gray-medium">{currentEvent.function}</span> + </div> + <div>{currentEvent.message}</div> + </div> + ) + } + isDisplayed={currentEvent != null} + content={ + currentEvent && <div className="px-4">{this.renderPopupContent(currentEvent)}</div> + } + onClose={this.closeModal.bind(this)} + /> + <BottomBlock> + <BottomBlock.Header> + <div className="flex items-center"> + <span className="font-semibold color-gray-medium mr-4">Events</span> + <Tabs + className="uppercase" + tabs={tabs} + active={activeTab} + onClick={setActiveTab} + border={false} + /> + </div> + </BottomBlock.Header> + <BottomBlock.Content> + <NoContent + title="Nothing to display yet." + subtext={ + !hintIsHidden ? ( + <> + <a + className="underline color-teal" + href="https://docs.openreplay.com/integrations" + target="_blank" + > + Integrations + </a> + {' and '} + <a + className="underline color-teal" + href="https://docs.openreplay.com/api#event" + target="_blank" + > + Events + </a> + { + ' make debugging easier. Sync your backend logs and custom events with session replay.' + } + <br /> + <br /> + <button className="color-teal" onClick={() => this.props.hideHint('stack')}> + Got It! + </button> + </> + ) : null + } + size="small" + show={filteredStackEvents.length === 0} + > + <Autoscroll autoScrollTo={Math.max(lastIndex, 0)}> + {filteredStackEvents.map((userEvent, index) => ( + <UserEvent + key={userEvent.key} + onDetailsClick={this.onDetailsClick.bind(this)} + inactive={index > lastIndex} + selected={lastIndex === index} + userEvent={userEvent} + onJump={() => jump(userEvent.time)} + /> + ))} + </Autoscroll> + </NoContent> + </BottomBlock.Content> + </BottomBlock> + </> ); } } diff --git a/frontend/app/components/Session_/StackEvents/UserEvent/JsonViewer.js b/frontend/app/components/Session_/StackEvents/UserEvent/JsonViewer.js index ec4a5288d..bb22ebb37 100644 --- a/frontend/app/components/Session_/StackEvents/UserEvent/JsonViewer.js +++ b/frontend/app/components/Session_/StackEvents/UserEvent/JsonViewer.js @@ -2,14 +2,24 @@ import React from 'react'; import { Icon, JSONTree } from 'UI'; export default class JsonViewer extends React.PureComponent { - render() { - const { data, title, icon } = this.props; - return ( - <div className="p-5"> - <Icon name={ icon } size="54" /> - <h4 className="my-5"> { title }</h4> - <JSONTree src={ data } collapsed={ false } /> - </div> - ); - } -} \ No newline at end of file + render() { + const { data, title, icon } = this.props; + const isObjectData = typeof data === 'object' && !Array.isArray(data) && data !== null; + return ( + <div> + <div className="flex items-center"> + <Icon name={icon} size="24" /> + <h4 className="my-5 mx-2 font-semibold text-xl"> {title}</h4> + </div> + {isObjectData ? ( + <JSONTree src={data} collapsed={false} /> + ) : ( + <> + <div className="-ml-2 text-disabled-text">Payload: </div> + <div className="mx-2">{data}</div> + </> + )} + </div> + ); + } +} diff --git a/frontend/app/components/Session_/StackEvents/UserEvent/UserEvent.js b/frontend/app/components/Session_/StackEvents/UserEvent/UserEvent.js index a40da51f8..c0cda02a3 100644 --- a/frontend/app/components/Session_/StackEvents/UserEvent/UserEvent.js +++ b/frontend/app/components/Session_/StackEvents/UserEvent/UserEvent.js @@ -1,122 +1,68 @@ import React from 'react'; import cn from 'classnames'; -import { OPENREPLAY, SENTRY, DATADOG, STACKDRIVER } from 'Types/session/stackEvent'; -import { Icon, SlideModal, IconButton } from 'UI'; +import { OPENREPLAY, SENTRY, DATADOG, STACKDRIVER } from 'Types/session/stackEvent'; +import { Icon, IconButton } from 'UI'; import withToggle from 'HOCs/withToggle'; import Sentry from './Sentry'; import JsonViewer from './JsonViewer'; import stl from './userEvent.module.css'; +import { Duration } from 'luxon'; // const modalSources = [ SENTRY, DATADOG ]; -@withToggle() // +@withToggle() // export default class UserEvent extends React.PureComponent { - getIconProps() { - const { source } = this.props.userEvent; - return { - name: `integrations/${ source }`, - size: 18, - marginRight: source === OPENREPLAY ? 11 : 10 - } - } + getIconProps() { + const { source } = this.props.userEvent; + return { + name: `integrations/${source}`, + size: 18, + marginRight: source === OPENREPLAY ? 11 : 10, + }; + } - getLevelClassname() { - const { userEvent } = this.props; - if (userEvent.isRed()) return "error color-red"; - return ''; - } + getLevelClassname() { + const { userEvent } = this.props; + if (userEvent.isRed()) return 'error color-red'; + return ''; + } - // getEventMessage() { - // const { userEvent } = this.props; - // switch(userEvent.source) { - // case SENTRY: - // case DATADOG: - // return null; - // default: - // return JSON.stringify(userEvent.data); - // } - // } + onClickDetails = (e) => { + e.stopPropagation(); + this.props.onDetailsClick(this.props.userEvent); + }; - renderPopupContent() { - const { userEvent: { source, payload, name} } = this.props; - switch(source) { - case SENTRY: - return <Sentry event={ payload } />; - case DATADOG: - return <JsonViewer title={ name } data={ payload } icon="integrations/datadog" />; - case STACKDRIVER: - return <JsonViewer title={ name } data={ payload } icon="integrations/stackdriver" />; - default: - return <JsonViewer title={ name } data={ payload } icon={ `integrations/${ source }` } />; - } - } - - ifNeedModal() { - return !!this.props.userEvent.payload; - } - - onClickDetails = (e) => { - e.stopPropagation(); - this.props.switchOpen(); - } - - renderContent(modalTrigger) { - const { userEvent } = this.props; - //const message = this.getEventMessage(); - return ( - <div - data-scroll-item={ userEvent.isRed() } - // onClick={ this.props.switchOpen } // - onClick={ this.props.onJump } // - className={ - cn( - "group", - stl.userEvent, - this.getLevelClassname(), - { [ stl.modalTrigger ]: modalTrigger } - ) - } - > - <div className={ stl.infoWrapper }> - <div className={ stl.title } > - <Icon { ...this.getIconProps() } /> - { userEvent.name } - </div> - { /* message && - <div className={ stl.message }> - { message } - </div> */ - } - <div className="invisible self-end ml-auto group-hover:visible"> - <IconButton size="small" plain onClick={this.onClickDetails} label="DETAILS" /> - </div> - </div> - </div> - ); - } - - render() { - const { userEvent } = this.props; - if (this.ifNeedModal()) { - return ( - <React.Fragment> - <SlideModal - //title="Add Custom Field" - size="middle" - isDisplayed={ this.props.open } - content={ this.props.open && this.renderPopupContent() } - onClose={ this.props.switchOpen } - /> - { this.renderContent(true) } - </React.Fragment> - //<Modal - // trigger={ this.renderContent(true) } - // content={ this.renderPopupContent() } - // centered={ false } - // size="small" - // /> - ); - } - return this.renderContent(); - } + render() { + const { userEvent, inactive, selected } = this.props; + //const message = this.getEventMessage(); + return ( + <div + data-scroll-item={userEvent.isRed()} + // onClick={ this.props.switchOpen } // + onClick={this.props.onJump} // + className={cn('group flex py-2 px-4 ', stl.userEvent, this.getLevelClassname(), { + [stl.inactive]: inactive, + [stl.selected]: selected, + })} + > + <div className={'self-start pr-4'}> + {Duration.fromMillis(userEvent.time).toFormat('mm:ss.SSS')} + </div> + <div className={cn('mr-auto', stl.infoWrapper)}> + <div className={stl.title}> + <Icon {...this.getIconProps()} /> + {userEvent.name} + </div> + </div> + <div className="self-center"> + <IconButton + outline={!userEvent.isRed()} + red={userEvent.isRed()} + onClick={this.onClickDetails} + label="DETAILS" + /> + </div> + </div> + ); + } } diff --git a/frontend/app/components/Session_/StackEvents/UserEvent/userEvent.module.css b/frontend/app/components/Session_/StackEvents/UserEvent/userEvent.module.css index 57388ffe5..53ef61da9 100644 --- a/frontend/app/components/Session_/StackEvents/UserEvent/userEvent.module.css +++ b/frontend/app/components/Session_/StackEvents/UserEvent/userEvent.module.css @@ -2,9 +2,6 @@ .userEvent { border-radius: 3px; background-color: rgba(0, 118, 255, 0.05); - font-family: 'Menlo', 'monaco', 'consolas', monospace; - padding: 8px 10px; - margin: 3px 0; &.modalTrigger { cursor: pointer; @@ -15,6 +12,7 @@ overflow: hidden; display: flex; align-items: flex-start; + font-family: 'Menlo', 'monaco', 'consolas', monospace; } .title { @@ -35,4 +33,12 @@ &::-webkit-scrollbar { height: 1px; } +} + +.inactive { + opacity: 0.5; +} + +.selected { + background-color: $teal-light; } \ No newline at end of file diff --git a/frontend/app/components/Session_/Storage/Storage.js b/frontend/app/components/Session_/Storage/Storage.js index 6e51bc36a..b1cf53dfc 100644 --- a/frontend/app/components/Session_/Storage/Storage.js +++ b/frontend/app/components/Session_/Storage/Storage.js @@ -1,7 +1,7 @@ import React from 'react'; import { connect } from 'react-redux'; import { hideHint } from 'Duck/components/player'; -import { +import { connectPlayer, selectStorageType, STORAGE_TYPES, @@ -21,14 +21,13 @@ import stl from './storage.module.css'; // const DIFF = 'DIFF'; // const TABS = [ DIFF, STATE ].map(tab => ({ text: tab, key: tab })); - function getActionsName(type) { switch(type) { case STORAGE_TYPES.MOBX: return "MUTATIONS"; case STORAGE_TYPES.VUEX: return "MUTATIONS"; - default: + default: return "ACTIONS"; } } @@ -50,7 +49,7 @@ export default class Storage extends React.PureComponent { focusNextButton() { if (this.lastBtnRef.current) { this.lastBtnRef.current.focus(); - } + } } componentDidMount() { @@ -75,7 +74,7 @@ export default class Storage extends React.PureComponent { // const yellowPaths = []; // const greenPaths = []; // lastRAction.diff.forEach(d => { - // try { + // try { // let { path, kind, rhs: value } = d; // if (kind === 'A') { // path.slice().push(d.index); @@ -86,11 +85,11 @@ export default class Storage extends React.PureComponent { // if (kind === 'N') greenPaths.push(d.path.slice().reverse()); // if (kind === 'D') redPaths.push(d.path.slice().reverse()); // if (kind === 'E') yellowPaths.push(d.path.slice().reverse()); - // } catch (e) { + // } catch (e) { // } // }); // return ( - // <DiffTree + // <DiffTree // data={ df } // redPaths={ redPaths } // yellowPaths={ yellowPaths } @@ -101,7 +100,7 @@ export default class Storage extends React.PureComponent { ensureString(actionType) { if (typeof actionType === 'string') return actionType; - return "UNKNOWN"; + return "UNKNOWN"; } goNext = () => { @@ -111,17 +110,11 @@ export default class Storage extends React.PureComponent { renderTab () { - // switch(this.props.activeTab) { - // case DIFF: - // return this.renderDiff(); - // case STATE: - const { listNow } = this.props; - if (listNow.length === 0) { - return "Not initialized"; //? - } - return <JSONTree src={ listNow[ listNow.length - 1 ].state } />; - // } - // return null; + const { listNow } = this.props; + if (listNow.length === 0) { + return "Not initialized"; //? + } + return <JSONTree src={ listNow[ listNow.length - 1 ].state } />; } renderItem(item, i) { @@ -146,7 +139,7 @@ export default class Storage extends React.PureComponent { return ( <div className="flex justify-between items-start" key={ `store-${i}` }> - <JSONTree + <JSONTree name={ this.ensureString(name) } src={ src } collapsed @@ -154,22 +147,22 @@ export default class Storage extends React.PureComponent { /> <div className="flex items-center"> { i + 1 < listNow.length && - <button + <button className={ stl.button } - onClick={ () => jump(item.time, item._index) } + onClick={ () => jump(item.time, item._index) } > - {"JUMP"} + {"JUMP"} </button> } { i + 1 === listNow.length && i + 1 < list.length && <button className={ stl.button } ref={ this.lastBtnRef } - onClick={ this.goNext } + onClick={ this.goNext } > - {"NEXT"} + {"NEXT"} </button> - } + } { typeof item.duration === 'number' && <div className="font-size-12 color-gray-medium"> { formatMs(item.duration) } @@ -177,15 +170,13 @@ export default class Storage extends React.PureComponent { } </div> </div> - ); + ); } render() { const { - type, + type, listNow, - activeTab, - setActiveTab, list, hintIsHidden, } = this.props; @@ -194,6 +185,7 @@ export default class Storage extends React.PureComponent { return ( <BottomBlock> <BottomBlock.Header> + <span className="font-semibold color-gray-medium mr-4">State</span> { list.length > 0 && <div className="flex w-full"> { showStore && @@ -211,12 +203,12 @@ export default class Storage extends React.PureComponent { <NoContent title="Nothing to display yet." subtext={ !hintIsHidden - ? + ? <> {'Inspect your application state while you’re replaying your users sessions. OpenReplay supports '} - <a className="underline color-teal" href="https://docs.openreplay.com/plugins/redux" target="_blank">Redux</a>{', '} - <a className="underline color-teal" href="https://docs.openreplay.com/plugins/vuex" target="_blank">VueX</a>{', '} - <a className="underline color-teal" href="https://docs.openreplay.com/plugins/mobx" target="_blank">MobX</a>{' and '} + <a className="underline color-teal" href="https://docs.openreplay.com/plugins/redux" target="_blank">Redux</a>{', '} + <a className="underline color-teal" href="https://docs.openreplay.com/plugins/vuex" target="_blank">VueX</a>{', '} + <a className="underline color-teal" href="https://docs.openreplay.com/plugins/mobx" target="_blank">MobX</a>{' and '} <a className="underline color-teal" href="https://docs.openreplay.com/plugins/ngrx" target="_blank">NgRx</a>. <br/><br/> <button className="color-teal" onClick={() => this.props.hideHint("storage")}>Got It!</button> @@ -228,8 +220,8 @@ export default class Storage extends React.PureComponent { > { showStore && <div className="ph-10 scroll-y" style={{ width: "40%" }} > - { listNow.length === 0 - ? <div className="color-gray-light font-size-16 mt-20 text-center" >{ "Empty state." }</div> + { listNow.length === 0 + ? <div className="color-gray-light font-size-16 mt-20 text-center" >{ "Empty state." }</div> : this.renderTab() } </div> diff --git a/frontend/app/components/Session_/Subheader.js b/frontend/app/components/Session_/Subheader.js index 7dbde4535..c378386ee 100644 --- a/frontend/app/components/Session_/Subheader.js +++ b/frontend/app/components/Session_/Subheader.js @@ -12,7 +12,6 @@ function SubHeader(props) { const [isCopied, setCopied] = React.useState(false); const isAssist = window.location.pathname.includes('/assist/'); - if (isAssist) return null; const location = props.currentLocation && props.currentLocation.length > 60 ? `${props.currentLocation.slice(0, 60)}...` : props.currentLocation return ( @@ -39,37 +38,39 @@ function SubHeader(props) { </Tooltip> </div> )} - <div className="ml-auto text-sm flex items-center color-gray-medium" style={{ width: 'max-content' }}> - <div className="cursor-pointer mr-4 hover:bg-gray-light-shade rounded-md p-1"> - {!isAssist && props.jiraConfig && props.jiraConfig.token && <Issues sessionId={props.sessionId} />} + {!isAssist ? ( + <div className="ml-auto text-sm flex items-center color-gray-medium" style={{ width: 'max-content' }}> + <div className="cursor-pointer mr-4 hover:bg-gray-light-shade rounded-md p-1"> + {props.jiraConfig && props.jiraConfig.token && <Issues sessionId={props.sessionId} />} + </div> + <div className="cursor-pointer"> + <SharePopup + entity="sessions" + id={ props.sessionId } + showCopyLink={true} + trigger={ + <div className="flex items-center hover:bg-gray-light-shade rounded-md p-1"> + <Icon + className="mr-2" + disabled={ props.disabled } + name="share-alt" + size="16" + /> + <span>Share</span> + </div> + } + /> + </div> + <div className="mx-4 hover:bg-gray-light-shade rounded-md p-1"> + <Bookmark noMargin sessionId={props.sessionId} /> + </div> + <div> + <Autoplay /> + </div> + <div> + </div> </div> - <div className="cursor-pointer"> - <SharePopup - entity="sessions" - id={ props.sessionId } - showCopyLink={true} - trigger={ - <div className="flex items-center hover:bg-gray-light-shade rounded-md p-1"> - <Icon - className="mr-2" - disabled={ props.disabled } - name="share-alt" - size="16" - /> - <span>Share</span> - </div> - } - /> - </div> - <div className="mx-4 hover:bg-gray-light-shade rounded-md p-1"> - <Bookmark noMargin sessionId={props.sessionId} /> - </div> - <div> - <Autoplay /> - </div> - <div> - </div> - </div> + ) : null} </div> ) } diff --git a/frontend/app/components/Session_/TimeTable/BarRow.js b/frontend/app/components/Session_/TimeTable/BarRow.js deleted file mode 100644 index b53661403..000000000 --- a/frontend/app/components/Session_/TimeTable/BarRow.js +++ /dev/null @@ -1,85 +0,0 @@ -import React from 'react'; -import { Popup } from 'UI'; -import { percentOf } from 'App/utils'; -import styles from './barRow.module.css' -import tableStyles from './timeTable.module.css'; - -const formatTime = time => time < 1000 ? `${ time.toFixed(2) }ms` : `${ time / 1000 }s`; - -const BarRow = ({ resource: { time, ttfb = 0, duration, key }, popup=false, timestart = 0, timewidth }) => { - const timeOffset = time - timestart; - ttfb = ttfb || 0; - const trigger = ( - <div - className={ styles.barWrapper } - style={ { - left: `${ percentOf(timeOffset, timewidth) }%`, - right: `${ 100 - percentOf(timeOffset + duration, timewidth) }%`, - minWidth: '5px' - } } - > - <div - className={ styles.ttfbBar } - style={ { - width: `${ percentOf(ttfb, duration) }%`, - } } - /> - <div - className={ styles.downloadBar } - style={ { - width: `${ percentOf(duration - ttfb, duration) }%`, - minWidth: '5px' - } } - /> - </div> - ); - if (!popup) return <div key={ key } className={ tableStyles.row } > { trigger } </div>; - - return ( - <div key={ key } className={ tableStyles.row } > - <Popup - basic - style={{ width: '100%' }} - unmountHTMLWhenHide - content={ - <React.Fragment> - { ttfb != null && - <div className={ styles.popupRow }> - <div className={ styles.title }>{ 'Waiting (TTFB)' }</div> - <div className={ styles.popupBarWrapper} > - <div - className={ styles.ttfbBar } - style={{ - left: 0, - width: `${ percentOf(ttfb, duration) }%`, - }} - /> - </div> - <div className={ styles.time } >{ formatTime(ttfb) }</div> - </div> - } - <div className={ styles.popupRow }> - <div className={ styles.title } >{ 'Content Download' }</div> - <div className= { styles.popupBarWrapper }> - <div - className={ styles.downloadBar } - style={{ - left: `${ percentOf(ttfb, duration) }%`, - width: `${ percentOf(duration - ttfb, duration) }%`, - }} - /> - </div> - <div className={ styles.time }>{ formatTime(duration - ttfb) }</div> - </div> - </React.Fragment> - } - > - {trigger} - </Popup> - </div> - ); -} - -BarRow.displayName = "BarRow"; - -export default BarRow; diff --git a/frontend/app/components/Session_/TimeTable/BarRow.tsx b/frontend/app/components/Session_/TimeTable/BarRow.tsx new file mode 100644 index 000000000..9de1a8279 --- /dev/null +++ b/frontend/app/components/Session_/TimeTable/BarRow.tsx @@ -0,0 +1,96 @@ +import { Popup } from 'UI'; +import { percentOf } from 'App/utils'; +import styles from './barRow.module.css' +import tableStyles from './timeTable.module.css'; +import React from 'react'; + +const formatTime = time => time < 1000 ? `${time.toFixed(2)}ms` : `${time / 1000}s`; + +interface Props { + resource: { + time: number + ttfb?: number + duration?: number + key: string + } + popup?: boolean + timestart: number + timewidth: number +} + +// TODO: If request has no duration, set duration to 0.2s. Enforce existence of duration in the future. +const BarRow = ({ resource: { time, ttfb = 0, duration = 200, key }, popup = false, timestart = 0, timewidth }: Props) => { + const timeOffset = time - timestart; + ttfb = ttfb || 0; + const trigger = ( + <div + className={styles.barWrapper} + style={{ + left: `${percentOf(timeOffset, timewidth)}%`, + right: `${100 - percentOf(timeOffset + duration, timewidth)}%`, + minWidth: '5px' + }} + > + <div + className={styles.ttfbBar} + style={{ + width: `${percentOf(ttfb, duration)}%`, + }} + /> + <div + className={styles.downloadBar} + style={{ + width: `${percentOf(duration - ttfb, duration)}%`, + minWidth: '5px' + }} + /> + </div> + ); + if (!popup) return <div key={key} className={tableStyles.row} > {trigger} </div>; + + return ( + <div key={key} className={tableStyles.row} > + <Popup + basic + content={ + <React.Fragment> + {ttfb != null && + <div className={styles.popupRow}> + <div className={styles.title}>{'Waiting (TTFB)'}</div> + <div className={styles.popupBarWrapper} > + <div + className={styles.ttfbBar} + style={{ + left: 0, + width: `${percentOf(ttfb, duration)}%`, + }} + /> + </div> + <div className={styles.time} >{formatTime(ttfb)}</div> + </div> + } + <div className={styles.popupRow}> + <div className={styles.title} >{'Content Download'}</div> + <div className={styles.popupBarWrapper}> + <div + className={styles.downloadBar} + style={{ + left: `${percentOf(ttfb, duration)}%`, + width: `${percentOf(duration - ttfb, duration)}%`, + }} + /> + </div> + <div className={styles.time}>{formatTime(duration - ttfb)}</div> + </div> + </React.Fragment> + } + size="mini" + position="top center" + /> + </div> + ); +} + +BarRow.displayName = "BarRow"; + +export default BarRow; \ No newline at end of file diff --git a/frontend/app/components/Session_/TimeTable/TimeTable.tsx b/frontend/app/components/Session_/TimeTable/TimeTable.tsx index 9ea553dc1..7b2fab76c 100644 --- a/frontend/app/components/Session_/TimeTable/TimeTable.tsx +++ b/frontend/app/components/Session_/TimeTable/TimeTable.tsx @@ -1,9 +1,9 @@ import React from 'react'; import { List, AutoSizer } from 'react-virtualized'; import cn from 'classnames'; +import { Duration } from "luxon"; import { NoContent, IconButton, Button } from 'UI'; import { percentOf } from 'App/utils'; -import { formatMs } from 'App/date'; import BarRow from './BarRow'; import stl from './timeTable.module.css'; @@ -11,31 +11,35 @@ import stl from './timeTable.module.css'; import autoscrollStl from '../autoscroll.module.css'; //aaa type Timed = { - time: number; + time: number; }; type Durationed = { - duration: number; + duration: number; }; type CanBeRed = { - //+isRed: boolean, - isRed: () => boolean; + //+isRed: boolean, + isRed: () => boolean; }; -type Row = Timed & Durationed & CanBeRed; +interface Row extends Timed, Durationed, CanBeRed { + [key: string]: any, key: string +} type Line = { - color: string; // Maybe use typescript? - hint?: string; - onClick?: any; + color: string; // Maybe use typescript? + hint?: string; + onClick?: any; } & Timed; type Column = { - label: string; - width: number; - referenceLines?: Array<Line>; - style?: Object; + label: string; + width: number; + dataKey?: string; + render?: (row: any) => void + referenceLines?: Array<Line>; + style?: React.CSSProperties; } & RenderOrKey; // type RenderOrKey = { // Disjoint? @@ -44,23 +48,31 @@ type Column = { // dataKey: string, // } type RenderOrKey = - | { - render?: (row: Row) => React.ReactNode; - key?: string; - } - | { - dataKey: string; - }; + | { + render?: (row: Row) => React.ReactNode; + key?: string; + } + | { + dataKey: string; + }; type Props = { - className?: string; - rows: Array<Row>; - children: Array<Column>; + className?: string; + rows: Array<Row>; + children: Array<Column>; + tableHeight?: number + activeIndex?: number + renderPopup?: boolean + navigation?: boolean + referenceLines?: any[] + additionalHeight?: number + hoverable?: boolean + onRowClick?: (row: any, index: number) => void }; type TimeLineInfo = { - timestart: number; - timewidth: number; + timestart: number; + timewidth: number; }; type State = TimeLineInfo & typeof initialState; @@ -72,247 +84,235 @@ const ROW_HEIGHT = 32; const TIME_SECTIONS_COUNT = 8; const ZERO_TIMEWIDTH = 1000; -function formatTime(ms) { - if (ms < 0) return ''; - return formatMs(ms); +function formatTime(ms: number) { + if (ms < 0) return ''; + if (ms < 1000) return Duration.fromMillis(ms).toFormat('0.SSS') + return Duration.fromMillis(ms).toFormat('mm:ss'); } -function computeTimeLine(rows: Array<Row>, firstVisibleRowIndex: number, visibleCount): TimeLineInfo { - const visibleRows = rows.slice(firstVisibleRowIndex, firstVisibleRowIndex + visibleCount + _additionalHeight); - let timestart = visibleRows.length > 0 ? Math.min(...visibleRows.map((r) => r.time)) : 0; - const timeend = visibleRows.length > 0 ? Math.max(...visibleRows.map((r) => r.time + r.duration)) : 0; - let timewidth = timeend - timestart; - const offset = timewidth / 70; - if (timestart >= offset) { - timestart -= offset; - } - timewidth *= 1.5; // += offset; - if (timewidth === 0) { - timewidth = ZERO_TIMEWIDTH; - } - return { - timestart, - timewidth, - }; +function computeTimeLine(rows: Array<Row>, firstVisibleRowIndex: number, visibleCount: number): TimeLineInfo { + const visibleRows = rows.slice(firstVisibleRowIndex, firstVisibleRowIndex + visibleCount + _additionalHeight); + let timestart = visibleRows.length > 0 ? Math.min(...visibleRows.map((r) => r.time)) : 0; + // TODO: GraphQL requests do not have a duration, so their timeline is borked. Assume a duration of 0.2s for every GraphQL request + const timeend = visibleRows.length > 0 ? Math.max(...visibleRows.map((r) => r.time + (r.duration ?? 200))) : 0; + let timewidth = timeend - timestart; + const offset = timewidth / 70; + if (timestart >= offset) { + timestart -= offset; + } + timewidth *= 1.5; // += offset; + if (timewidth === 0) { + timewidth = ZERO_TIMEWIDTH; + } + return { + timestart, + timewidth, + }; } const initialState = { - firstVisibleRowIndex: 0, + firstVisibleRowIndex: 0, }; export default class TimeTable extends React.PureComponent<Props, State> { - state = { - ...computeTimeLine(this.props.rows, initialState.firstVisibleRowIndex, this.visibleCount), - ...initialState, - }; + state = { + ...computeTimeLine(this.props.rows, initialState.firstVisibleRowIndex, this.visibleCount), + ...initialState, + }; - get tableHeight() { - return this.props.tableHeight || 195; + get tableHeight() { + return this.props.tableHeight || 195; + } + + get visibleCount() { + return Math.ceil(this.tableHeight / ROW_HEIGHT); + } + + scroller = React.createRef<List>(); + autoScroll = true; + + componentDidMount() { + if (this.scroller.current) { + this.scroller.current.scrollToRow(this.props.activeIndex); + } + } + + componentDidUpdate(prevProps: any, prevState: any) { + if ( + prevState.firstVisibleRowIndex !== this.state.firstVisibleRowIndex || + (this.props.rows.length <= this.visibleCount + _additionalHeight && prevProps.rows.length !== this.props.rows.length) + ) { + this.setState({ + ...computeTimeLine(this.props.rows, this.state.firstVisibleRowIndex, this.visibleCount), + }); + } + if (this.props.activeIndex && this.props.activeIndex >= 0 && prevProps.activeIndex !== this.props.activeIndex && this.scroller.current) { + this.scroller.current.scrollToRow(this.props.activeIndex); + } + } + + onScroll = ({ scrollTop, scrollHeight, clientHeight }: { scrollTop: number; scrollHeight: number; clientHeight: number }): void => { + const firstVisibleRowIndex = Math.floor(scrollTop / ROW_HEIGHT + 0.33); + + if (this.state.firstVisibleRowIndex !== firstVisibleRowIndex) { + this.autoScroll = scrollHeight - clientHeight - scrollTop < ROW_HEIGHT / 2; + this.setState({ firstVisibleRowIndex }); + } + }; + + renderRow = ({ index, key, style: rowStyle }: any) => { + const { activeIndex } = this.props; + const { children: columns, rows, renderPopup, hoverable, onRowClick } = this.props; + const { timestart, timewidth } = this.state; + const row = rows[index]; + return ( + <div + style={rowStyle} + key={key} + className={cn('border-b border-color-gray-light-shade', stl.row, { + [stl.hoverable]: hoverable, + 'error color-red': !!row.isRed && row.isRed(), + 'cursor-pointer': typeof onRowClick === 'function', + [stl.activeRow]: activeIndex === index, + [stl.inactiveRow]: !activeIndex || index > activeIndex, + })} + onClick={typeof onRowClick === 'function' ? () => onRowClick(row, index) : undefined} + id="table-row" + > + {columns.map(({ dataKey, render, width }) => ( + <div className={stl.cell} style={{ width: `${width}px` }}> + {render ? render(row) : row[dataKey || ''] || <i className="color-gray-light">{'empty'}</i>} + </div> + ))} + <div className={cn('relative flex-1 flex', stl.timeBarWrapper)}> + <BarRow resource={row} timestart={timestart} timewidth={timewidth} popup={renderPopup} /> + </div> + </div> + ); + }; + + onPrevClick = () => { + let prevRedIndex = -1; + for (let i = this.state.firstVisibleRowIndex - 1; i >= 0; i--) { + if (this.props.rows[i].isRed()) { + prevRedIndex = i; + break; + } + } + if (this.scroller.current != null) { + this.scroller.current.scrollToRow(prevRedIndex); + } + }; + + onNextClick = () => { + let prevRedIndex = -1; + for (let i = this.state.firstVisibleRowIndex + 1; i < this.props.rows.length; i++) { + if (this.props.rows[i].isRed()) { + prevRedIndex = i; + break; + } + } + if (this.scroller.current != null) { + this.scroller.current.scrollToRow(prevRedIndex); + } + }; + + render() { + const { className, rows, children: columns, navigation = false, referenceLines = [], additionalHeight = 0, activeIndex } = this.props; + const { timewidth, timestart } = this.state; + + _additionalHeight = additionalHeight; + + const sectionDuration = Math.round(timewidth / TIME_SECTIONS_COUNT); + const timeColumns: number[] = []; + if (timewidth > 0) { + for (let i = 0; i < TIME_SECTIONS_COUNT; i++) { + timeColumns.push(timestart + i * sectionDuration); + } } - get visibleCount() { - return Math.ceil(this.tableHeight / ROW_HEIGHT); - } + const visibleRefLines = referenceLines.filter(({ time }) => time > timestart && time < timestart + timewidth); - scroller = React.createRef(); - autoScroll = true; + const columnsSumWidth = columns.reduce((sum, { width }) => sum + width, 0); - componentDidMount() { - if (this.scroller.current) { - this.scroller.current.scrollToRow(this.props.activeIndex); - } - } - - componentDidUpdate(prevProps: any, prevState: any) { - // if (prevProps.rows.length !== this.props.rows.length && - // this.autoScroll && - // this.scroller.current != null) { - // this.scroller.current.scrollToRow(this.props.rows.length); - // } - if ( - prevState.firstVisibleRowIndex !== this.state.firstVisibleRowIndex || - (this.props.rows.length <= this.visibleCount + _additionalHeight && prevProps.rows.length !== this.props.rows.length) - ) { - this.setState({ - ...computeTimeLine(this.props.rows, this.state.firstVisibleRowIndex, this.visibleCount), - }); - } - if (this.props.activeIndex >= 0 && prevProps.activeIndex !== this.props.activeIndex && this.scroller.current) { - this.scroller.current.scrollToRow(this.props.activeIndex); - } - } - - onScroll = ({ scrollTop, scrollHeight, clientHeight }: { scrollTop: number; scrollHeight: number; clientHeight: number }): void => { - const firstVisibleRowIndex = Math.floor(scrollTop / ROW_HEIGHT + 0.33); - - if (this.state.firstVisibleRowIndex !== firstVisibleRowIndex) { - this.autoScroll = scrollHeight - clientHeight - scrollTop < ROW_HEIGHT / 2; - this.setState({ firstVisibleRowIndex }); - } - }; - - renderRow = ({ index, key, style: rowStyle }: any) => { - const { activeIndex } = this.props; - const { children: columns, rows, renderPopup, hoverable, onRowClick } = this.props; - const { timestart, timewidth } = this.state; - const row = rows[index]; - return ( - <div - style={rowStyle} - key={key} - className={cn('border-b border-color-gray-light-shade', stl.row, { - [stl.hoverable]: hoverable, - 'error color-red': !!row.isRed && row.isRed(), - 'cursor-pointer': typeof onRowClick === 'function', - [stl.activeRow]: activeIndex === index, - })} - onClick={typeof onRowClick === 'function' ? () => onRowClick(row, index) : null} - id="table-row" - > - {columns.map(({ dataKey, render, width }) => ( - <div className={stl.cell} style={{ width: `${width}px` }}> - {render ? render(row) : row[dataKey] || <i className="color-gray-light">{'empty'}</i>} - </div> - ))} - <div className={cn('relative flex-1 flex', stl.timeBarWrapper)}> - <BarRow resource={row} timestart={timestart} timewidth={timewidth} popup={renderPopup} /> - </div> - </div> - ); - }; - - onPrevClick = () => { - let prevRedIndex = -1; - for (let i = this.state.firstVisibleRowIndex - 1; i >= 0; i--) { - if (this.props.rows[i].isRed()) { - prevRedIndex = i; - break; - } - } - if (this.scroller.current != null) { - this.scroller.current.scrollToRow(prevRedIndex); - } - }; - - onNextClick = () => { - let prevRedIndex = -1; - for (let i = this.state.firstVisibleRowIndex + 1; i < this.props.rows.length; i++) { - if (this.props.rows[i].isRed()) { - prevRedIndex = i; - break; - } - } - if (this.scroller.current != null) { - this.scroller.current.scrollToRow(prevRedIndex); - } - }; - - render() { - const { className, rows, children: columns, navigation = false, referenceLines = [], additionalHeight = 0, activeIndex } = this.props; - const { timewidth, timestart } = this.state; - - _additionalHeight = additionalHeight; - - const sectionDuration = Math.round(timewidth / TIME_SECTIONS_COUNT); - const timeColumns = []; - if (timewidth > 0) { - for (let i = 0; i < TIME_SECTIONS_COUNT; i++) { - timeColumns.push(timestart + i * sectionDuration); - } - } - - const visibleRefLines = referenceLines.filter(({ time }) => time > timestart && time < timestart + timewidth); - - const columnsSumWidth = columns.reduce((sum, { width }) => sum + width, 0); - - return ( - <div className={cn(className, 'relative')}> - {navigation && ( - <div className={cn(autoscrollStl.navButtons, 'flex items-center')}> - <Button - variant="text-primary" - icon="chevron-up" - tooltip={{ - title: 'Previous Error', - delay: 0, - }} - onClick={this.onPrevClick} - /> - <Button - variant="text-primary" - icon="chevron-down" - tooltip={{ - title: 'Next Error', - delay: 0, - }} - onClick={this.onNextClick} - /> - {/* <IconButton - size="small" - icon="chevron-up" + return ( + <div className={cn(className, 'relative')}> + {navigation && ( + <div className={cn(autoscrollStl.navButtons, 'flex items-center')}> + <Button + variant="text-primary" + icon="chevron-up" + tooltip={{ + title: 'Previous Error', + delay: 0, + }} onClick={this.onPrevClick} - /> */} - {/* <IconButton - size="small" + /> + <Button + variant="text-primary" icon="chevron-down" + tooltip={{ + title: 'Next Error', + delay: 0, + }} onClick={this.onNextClick} - /> */} - </div> - )} - <div className={stl.headers}> - <div className={stl.infoHeaders}> - {columns.map(({ label, width }) => ( - <div className={stl.headerCell} style={{ width: `${width}px` }}> - {label} - </div> - ))} - </div> - <div className={stl.waterfallHeaders}> - {timeColumns.map((time, i) => ( - <div className={stl.timeCell} key={`tc-${i}`}> - {formatTime(time)} - </div> - ))} - </div> - </div> + /> + </div> + )} + <div className={stl.headers}> + <div className={stl.infoHeaders}> + {columns.map(({ label, width }) => ( + <div className={stl.headerCell} style={{ width: `${width}px` }}> + {label} + </div> + ))} + </div> + <div className={stl.waterfallHeaders}> + {timeColumns.map((time, i) => ( + <div className={stl.timeCell} key={`tc-${i}`}> + {formatTime(time)} + </div> + ))} + </div> + </div> - <NoContent size="small" show={rows.length === 0}> - <div className="relative"> - <div className={stl.timePart} style={{ left: `${columnsSumWidth}px` }}> - {timeColumns.map((_, index) => ( - <div key={`tc-${index}`} className={stl.timeCell} /> - ))} - {visibleRefLines.map(({ time, color, onClick }) => ( - <div - className={cn(stl.refLine, `bg-${color}`)} - style={{ - left: `${percentOf(time - timestart, timewidth)}%`, - cursor: typeof onClick === 'function' ? 'click' : 'auto', - }} - onClick={onClick} - /> - ))} - </div> - <AutoSizer disableHeight> - {({ width }) => ( - <List - ref={this.scroller} - className={stl.list} - height={this.tableHeight + additionalHeight} - width={width} - overscanRowCount={20} - rowCount={rows.length} - rowHeight={ROW_HEIGHT} - rowRenderer={this.renderRow} - onScroll={this.onScroll} - scrollToAlignment="start" - forceUpdateProp={timestart | timewidth | activeIndex} - /> - )} - </AutoSizer> - </div> - </NoContent> + <NoContent size="small" show={rows.length === 0}> + <div className="relative"> + <div className={stl.timePart} style={{ left: `${columnsSumWidth}px` }}> + {timeColumns.map((_, index) => ( + <div key={`tc-${index}`} className={stl.timeCell} /> + ))} + {visibleRefLines.map(({ time, color, onClick }) => ( + <div + className={cn(stl.refLine, `bg-${color}`)} + style={{ + left: `${percentOf(time - timestart, timewidth)}%`, + cursor: typeof onClick === 'function' ? 'click' : 'auto', + }} + onClick={onClick} + /> + ))} </div> - ); - } -} + <AutoSizer disableHeight> + {({ width }: { width: number }) => ( + <List + ref={this.scroller} + className={stl.list} + height={this.tableHeight + additionalHeight} + width={width} + overscanRowCount={20} + rowCount={rows.length} + rowHeight={ROW_HEIGHT} + rowRenderer={this.renderRow} + onScroll={this.onScroll} + scrollToAlignment="start" + forceUpdateProp={timestart | timewidth | (activeIndex || 0)} + /> + )} + </AutoSizer> + </div> + </NoContent> + </div> + ); + } +} \ No newline at end of file diff --git a/frontend/app/components/Session_/TimeTable/timeTable.module.css b/frontend/app/components/Session_/TimeTable/timeTable.module.css index 643f02012..17feaf459 100644 --- a/frontend/app/components/Session_/TimeTable/timeTable.module.css +++ b/frontend/app/components/Session_/TimeTable/timeTable.module.css @@ -100,5 +100,9 @@ $offset: 10px; } .activeRow { - background-color: rgba(54, 108, 217, 0.1); + background-color: $teal-light; +} + +.inactiveRow { + opacity: 0.5; } \ No newline at end of file diff --git a/frontend/app/components/Session_/autoscroll.module.css b/frontend/app/components/Session_/autoscroll.module.css index 42c5d980a..209badfb2 100644 --- a/frontend/app/components/Session_/autoscroll.module.css +++ b/frontend/app/components/Session_/autoscroll.module.css @@ -1,19 +1,12 @@ -.wrapper { - & .navButtons { - opacity: 0; - transition: opacity .3s - } - &:hover { - & .navButtons { - opacity: .7; - } - } -} - .navButtons { position: absolute; - right: 260px; - top: -39px; + + background: rgba(255, 255, 255, 0.5); + padding: 4px; + + right: 24px; + top: 8px; + z-index: 1; } diff --git a/frontend/app/components/Session_/tabs.js b/frontend/app/components/Session_/tabs.js deleted file mode 100644 index 4eddc27e2..000000000 --- a/frontend/app/components/Session_/tabs.js +++ /dev/null @@ -1,49 +0,0 @@ -import { - NONE, - CONSOLE, - NETWORK, - STACKEVENTS, - STORAGE, - PROFILER, - PERFORMANCE, - GRAPHQL, - FETCH, - EXCEPTIONS, - LONGTASKS, -} from 'Duck/components/player'; - -import Network from './Network'; -import Console from './Console/Console'; -import StackEvents from './StackEvents/StackEvents'; -import Storage from './Storage'; -import Profiler from './Profiler'; -import Performance from './Performance'; -import PlayerBlockHeader from './PlayerBlockHeader'; -import GraphQL from './GraphQL'; -import Fetch from './Fetch'; -import Exceptions from './Exceptions/Exceptions'; -import LongTasks from './LongTasks'; - - -// const tabs = [ -// { -// key: CONSOLE, -// Component: Console, -// }, -// { -// key: NETWORK, -// Component: Network, -// }, -// { -// key: STORAGE, -// Component: -// } -// ] - -const tabsByKey = {}; -// tabs.map() - - -export function switchTab(tabKey) { - tabKey -} diff --git a/frontend/app/components/hocs/index.js b/frontend/app/components/hocs/index.js index 444ad0180..5f08b86f0 100644 --- a/frontend/app/components/hocs/index.js +++ b/frontend/app/components/hocs/index.js @@ -1,2 +1,3 @@ export { default as withRequest } from './withRequest'; -export { default as withToggle } from './withToggle'; \ No newline at end of file +export { default as withToggle } from './withToggle'; +export { default as withCopy } from './withCopy' \ No newline at end of file diff --git a/frontend/app/components/hocs/withCopy.tsx b/frontend/app/components/hocs/withCopy.tsx new file mode 100644 index 000000000..2b3a9d541 --- /dev/null +++ b/frontend/app/components/hocs/withCopy.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import copy from 'copy-to-clipboard'; +import { Tooltip } from 'react-tippy'; + +const withCopy = (WrappedComponent: React.ComponentType) => { + const ComponentWithCopy = (props: any) => { + const [copied, setCopied] = React.useState(false); + const { value, tooltip } = props; + const copyToClipboard = (text: string) => { + copy(text); + setCopied(true); + setTimeout(() => { + setCopied(false); + }, 1000); + }; + return ( + <div onClick={() => copyToClipboard(value)} className="w-fit"> + <Tooltip delay={0} arrow animation="fade" hideOnClick={false} title={copied ? tooltip : 'Click to copy'}> + <WrappedComponent {...props} copyToClipboard={copyToClipboard} /> + </Tooltip> + </div> + ); + }; + return ComponentWithCopy; +}; + +export default withCopy; diff --git a/frontend/app/components/hocs/withLocationHandlers.js b/frontend/app/components/hocs/withLocationHandlers.js index a202f178e..b386690b8 100644 --- a/frontend/app/components/hocs/withLocationHandlers.js +++ b/frontend/app/components/hocs/withLocationHandlers.js @@ -1,60 +1,55 @@ import React from 'react'; import { withRouter } from 'react-router-dom'; -import { - removeQueryParams, - addQueryParams, - setQueryParams, - parseQuery, -} from 'App/routes'; +import { removeQueryParams, addQueryParams, setQueryParams, parseQuery } from 'App/routes'; /* eslint-disable react/sort-comp */ -const withLocationHandlers = propNames => BaseComponent => +const withLocationHandlers = (propNames) => (BaseComponent) => { @withRouter - class extends React.Component { - getQuery = names => parseQuery(this.props.location, names) - getParam = name => parseQuery(this.props.location)[ name ] + class WrapperClass extends React.Component { + getQuery = (names) => parseQuery(this.props.location, names); + getParam = (name) => parseQuery(this.props.location)[name]; addQuery = (params) => { const { location, history } = this.props; history.push(addQueryParams(location, params)); - } - removeQuery = (names = [], replace=false) => { + }; + removeQuery = (names = [], replace = false) => { const { location, history } = this.props; - const namesArray = Array.isArray(names) ? names : [ names ]; + const namesArray = Array.isArray(names) ? names : [names]; /* to avoid update stack overflow */ const actualNames = Object.keys(this.getQuery(namesArray)); if (actualNames.length > 0) { - history[ replace ? 'replace' : 'push' ](removeQueryParams(location, actualNames)); + history[replace ? 'replace' : 'push'](removeQueryParams(location, actualNames)); } - } - setQuery = (params, replace=false) => { + }; + setQuery = (params, replace = false) => { const { location, history } = this.props; - history[ replace ? 'replace' : 'push' ](setQueryParams(location, params)); - } + history[replace ? 'replace' : 'push'](setQueryParams(location, params)); + }; query = { all: this.getQuery, get: this.getParam, add: this.addQuery, remove: this.removeQuery, - set: this.setQuery, // TODO: use namespaces - } + set: this.setQuery, // TODO: use namespaces + }; - getHash = () => this.props.location.hash.substring(1) + getHash = () => this.props.location.hash.substring(1); setHash = (hash) => { const { location, history } = this.props; - history.push({ ...location, hash: `#${ hash }` }); - } + history.push({ ...location, hash: `#${hash}` }); + }; removeHash = () => { const { location, history } = this.props; history.push({ ...location, hash: '' }); - } + }; hash = { get: this.getHash, set: this.setHash, remove: this.removeHash, - } + }; getQueryProps() { if (Array.isArray(propNames)) return this.getQuery(propNames); @@ -62,7 +57,9 @@ const withLocationHandlers = propNames => BaseComponent => const values = Object.values(propNames); const query = this.getQuery(values); const queryProps = {}; - Object.keys(propNames).map((key) => { queryProps[ key ] = query[ propNames[ key ] ]; }); + Object.keys(propNames).map((key) => { + queryProps[key] = query[propNames[key]]; + }); return queryProps; } return {}; @@ -70,15 +67,9 @@ const withLocationHandlers = propNames => BaseComponent => render() { const queryProps = this.getQueryProps(); - return ( - <BaseComponent - query={ this.query } - hash={ this.hash } - { ...queryProps } - { ...this.props } - /> - ); + return <BaseComponent query={this.query} hash={this.hash} {...queryProps} {...this.props} />; } - }; - + } + return WrapperClass; +}; export default withLocationHandlers; diff --git a/frontend/app/components/hocs/withPermissions.js b/frontend/app/components/hocs/withPermissions.js index 1f4e6ade8..f31730553 100644 --- a/frontend/app/components/hocs/withPermissions.js +++ b/frontend/app/components/hocs/withPermissions.js @@ -2,33 +2,32 @@ import React from "react"; import { connect } from "react-redux"; import { NoPermission, NoSessionPermission } from "UI"; -export default (requiredPermissions, className, isReplay = false) => - (BaseComponent) => - ( - @connect((state, props) => ({ - permissions: - state.getIn(["user", "account", "permissions"]) || [], - isEnterprise: - state.getIn(["user", "account", "edition"]) === "ee", - })) - class extends React.PureComponent { - render() { - const hasPermission = requiredPermissions.every( - (permission) => - this.props.permissions.includes(permission) - ); +export default (requiredPermissions, className, isReplay = false) => (BaseComponent) => { + @connect((state, props) => ({ + permissions: + state.getIn(["user", "account", "permissions"]) || [], + isEnterprise: + state.getIn(["user", "account", "edition"]) === "ee", + })) + class WrapperClass extends React.PureComponent { + render() { + const hasPermission = requiredPermissions.every( + (permission) => + this.props.permissions.includes(permission) + ); - return !this.props.isEnterprise || hasPermission ? ( - <BaseComponent {...this.props} /> - ) : ( - <div className={className}> - {isReplay ? ( - <NoSessionPermission /> - ) : ( - <NoPermission /> - )} - </div> - ); - } - } - ); + return !this.props.isEnterprise || hasPermission ? ( + <BaseComponent {...this.props} /> + ) : ( + <div className={className}> + {isReplay ? ( + <NoSessionPermission /> + ) : ( + <NoPermission /> + )} + </div> + ); + } + } + return WrapperClass +} \ No newline at end of file diff --git a/frontend/app/components/hocs/withRequest.js b/frontend/app/components/hocs/withRequest.js index 80dfaccf3..992b0ce4e 100644 --- a/frontend/app/components/hocs/withRequest.js +++ b/frontend/app/components/hocs/withRequest.js @@ -2,66 +2,66 @@ import React from 'react'; import APIClient from 'App/api_client'; export default ({ - initialData = null, - endpoint = '', - method = 'GET', - requestName = "request", - loadingName = "loading", - errorName = "requestError", - dataName = "data", - dataWrapper = data => data, - loadOnInitialize = false, - resetBeforeRequest = false, // Probably use handler? -}) => BaseComponent => class extends React.PureComponent { - constructor(props) { - super(props); - this.state = { - data: typeof initialData === 'function' ? initialData(props) : initialData, - loading: loadOnInitialize, - error: false, - }; - if (loadOnInitialize) { - this.request(); - } - } + initialData = null, + endpoint = '', + method = 'GET', + requestName = 'request', + loadingName = 'loading', + errorName = 'requestError', + dataName = 'data', + dataWrapper = (data) => data, + loadOnInitialize = false, + resetBeforeRequest = false, // Probably use handler? + }) => + (BaseComponent) => + class extends React.PureComponent { + constructor(props) { + super(props); + this.state = { + data: typeof initialData === 'function' ? initialData(props) : initialData, + loading: loadOnInitialize, + error: false, + }; + if (loadOnInitialize) { + this.request(); + } + } - request = (params, edpParams) => { - this.setState({ - loading: true, - error: false, - data: resetBeforeRequest - ? (typeof initialData === 'function' ? initialData(this.props) : initialData) - : this.state.data, - }); - const edp = typeof endpoint === 'function' - ? endpoint(this.props, edpParams) - : endpoint; - return new APIClient()[ method.toLowerCase() ](edp, params) - .then(response => response.json()) - .then(({ errors, data }) => { - if (errors) { - return this.setError(); - } - this.setState({ - data: dataWrapper(data, this.state.data), - loading: false, - }); - }) - .catch(this.setError); - } + request = (params, edpParams) => { + this.setState({ + loading: true, + error: false, + data: resetBeforeRequest ? (typeof initialData === 'function' ? initialData(this.props) : initialData) : this.state.data, + }); + const edp = typeof endpoint === 'function' ? endpoint(this.props, edpParams) : endpoint; + return new APIClient() + [method.toLowerCase()](edp, params) + .then((response) => response.json()) + .then(({ errors, data }) => { + if (errors) { + return this.setError(); + } + this.setState({ + data: dataWrapper(data, this.state.data), + loading: false, + }); + }) + .catch(this.setError); + }; - setError = () => this.setState({ - loading: false, - error: true, - }) + setError = () => + this.setState({ + loading: false, + error: true, + }); - render() { - const ownProps = { - [ requestName ]: this.request, - [ loadingName ]: this.state.loading, - [ dataName ]: this.state.data, - [ errorName ]: this.state.error, - }; - return <BaseComponent { ...this.props } { ...ownProps } /> - } -} \ No newline at end of file + render() { + const ownProps = { + [requestName]: this.request, + [loadingName]: this.state.loading, + [dataName]: this.state.data, + [errorName]: this.state.error, + }; + return <BaseComponent {...this.props} {...ownProps} />; + } + }; diff --git a/frontend/app/components/hocs/withSiteIdRouter.js b/frontend/app/components/hocs/withSiteIdRouter.js index 4dbaf623c..ee41610ce 100644 --- a/frontend/app/components/hocs/withSiteIdRouter.js +++ b/frontend/app/components/hocs/withSiteIdRouter.js @@ -1,30 +1,32 @@ import React from 'react'; import { withRouter } from 'react-router-dom'; import { connect } from 'react-redux'; -import { withSiteId } from 'App/routes'; +import { withSiteId } from 'App/routes'; import { setSiteId } from 'Duck/site'; -export default BaseComponent => -@withRouter -@connect((state, props) => ({ - urlSiteId: props.match.params.siteId, - siteId: state.getIn([ 'site', 'siteId' ]), -}), { - setSiteId, -}) -class extends React.PureComponent { - push = (location) => { - const { history, siteId } = this.props; - if (typeof location === 'string') { - history.push(withSiteId(location, siteId)); - } else if (typeof location === 'object'){ - history.push({ ...location, pathname: withSiteId(location.pathname, siteId) }); +export default BaseComponent => { + @withRouter + @connect((state, props) => ({ + urlSiteId: props.match.params.siteId, + siteId: state.getIn(['site', 'siteId']), + }), { + setSiteId, + }) + class WrappedClass extends React.PureComponent { + push = (location) => { + const { history, siteId } = this.props; + if (typeof location === 'string') { + history.push(withSiteId(location, siteId)); + } else if (typeof location === 'object') { + history.push({ ...location, pathname: withSiteId(location.pathname, siteId) }); + } + } + + render() { + const { history, ...other } = this.props + + return <BaseComponent {...other} history={{ ...history, push: this.push }} /> } } - - render() { - const { history, ...other } = this.props - - return <BaseComponent { ...other } history={ { ...history, push: this.push } } /> - } -} \ No newline at end of file + return WrappedClass +} \ No newline at end of file diff --git a/frontend/app/components/hocs/withSiteIdUpdater.js b/frontend/app/components/hocs/withSiteIdUpdater.js index 3abb48c09..1c4e038ae 100644 --- a/frontend/app/components/hocs/withSiteIdUpdater.js +++ b/frontend/app/components/hocs/withSiteIdUpdater.js @@ -2,36 +2,39 @@ import React from 'react'; import { connect } from 'react-redux'; import { setSiteId } from 'Duck/site'; -export default BaseComponent => -@connect((state, props) => ({ - urlSiteId: props.match.params.siteId, - siteId: state.getIn([ 'site', 'siteId' ]), -}), { - setSiteId, -}) -class extends React.PureComponent { - state = { load: false } - constructor(props) { - super(props); - if (props.urlSiteId && props.urlSiteId !== props.siteId) { - props.setSiteId(props.urlSiteId); +export default (BaseComponent) => { + @connect((state, props) => ({ + urlSiteId: props.match.params.siteId, + siteId: state.getIn(['site', 'siteId']), + }), { + setSiteId, + }) + class WrapperClass extends React.PureComponent { + state = { load: false } + constructor(props) { + super(props); + if (props.urlSiteId && props.urlSiteId !== props.siteId) { + props.setSiteId(props.urlSiteId); + } } - } - componentDidUpdate(prevProps) { - const { urlSiteId, siteId, location: { pathname }, history } = this.props; - const shouldUrlUpdate = urlSiteId && urlSiteId !== siteId; - if (shouldUrlUpdate) { - const path = [ '', siteId ].concat(pathname.split('/').slice(2)).join('/'); - history.push(path); + componentDidUpdate(prevProps) { + const { urlSiteId, siteId, location: { pathname }, history } = this.props; + const shouldUrlUpdate = urlSiteId && urlSiteId !== siteId; + if (shouldUrlUpdate) { + const path = ['', siteId].concat(pathname.split('/').slice(2)).join('/'); + history.push(path); + } + const shouldBaseComponentReload = shouldUrlUpdate || siteId !== prevProps.siteId; + if (shouldBaseComponentReload) { + this.setState({ load: true }); + setTimeout(() => this.setState({ load: false }), 0); + } } - const shouldBaseComponentReload = shouldUrlUpdate || siteId !== prevProps.siteId; - if (shouldBaseComponentReload) { - this.setState({ load: true }); - setTimeout(() => this.setState({ load: false }), 0); + + render() { + return this.state.load ? null : <BaseComponent {...this.props} />; } } - render() { - return this.state.load ? null : <BaseComponent { ...this.props } />; - } -} \ No newline at end of file + return WrapperClass +} \ No newline at end of file diff --git a/frontend/app/components/shared/AlertTriggersModal/AlertTriggersModal.tsx b/frontend/app/components/shared/AlertTriggersModal/AlertTriggersModal.tsx index 4d748687d..b76666962 100644 --- a/frontend/app/components/shared/AlertTriggersModal/AlertTriggersModal.tsx +++ b/frontend/app/components/shared/AlertTriggersModal/AlertTriggersModal.tsx @@ -37,7 +37,7 @@ function AlertTriggersModal(props: Props) { { count > 0 && ( <div className=""> <Button - loading={loading} + // loading={loading} // TODO should use the different loading state for this variant="text" onClick={onClearAll} disabled={count === 0} diff --git a/frontend/app/components/shared/AnimatedSVG/AnimatedSVG.tsx b/frontend/app/components/shared/AnimatedSVG/AnimatedSVG.tsx index 45f4d701d..65545ca99 100644 --- a/frontend/app/components/shared/AnimatedSVG/AnimatedSVG.tsx +++ b/frontend/app/components/shared/AnimatedSVG/AnimatedSVG.tsx @@ -6,6 +6,16 @@ import DashboardSvg from '../../../svg/dashboard-icn.svg'; import LoaderSVG from '../../../svg/openreplay-preloader.svg'; import SignalGreenSvg from '../../../svg/signal-green.svg'; import SignalRedSvg from '../../../svg/signal-red.svg'; +import NoBookmarks from '../../../svg/ca-no-bookmarked-session.svg'; +import NoLiveSessions from '../../../svg/ca-no-live-sessions.svg'; +import NoSessions from '../../../svg/ca-no-sessions.svg'; +import NoSessionsInVault from '../../../svg/ca-no-sessions-in-vault.svg'; +import NoWebhooks from '../../../svg/ca-no-webhooks.svg'; +import NoMetadata from '../../../svg/ca-no-metadata.svg'; +import NoIssues from '../../../svg/ca-no-issues.svg'; +import NoAuditTrail from '../../../svg/ca-no-audit-trail.svg'; +import NoAnnouncements from '../../../svg/ca-no-announcements.svg'; +import NoAlerts from '../../../svg/ca-no-alerts.svg'; export enum ICONS { DASHBOARD_ICON = 'dashboard-icn', @@ -14,7 +24,17 @@ export enum ICONS { NO_RESULTS = 'no-results', LOADER = 'openreplay-preloader', SIGNAL_GREEN = 'signal-green', - SIGNAL_RED = 'signal-red' + SIGNAL_RED = 'signal-red', + NO_BOOKMARKS = 'ca-no-bookmarked-session', + NO_LIVE_SESSIONS = 'ca-no-live-sessions', + NO_SESSIONS = 'ca-no-sessions', + NO_SESSIONS_IN_VAULT = 'ca-no-sessions-in-vault', + NO_WEBHOOKS = 'ca-no-webhooks', + NO_METADATA = 'ca-no-metadata', + NO_ISSUES = 'ca-no-issues', + NO_AUDIT_TRAIL = 'ca-no-audit-trail', + NO_ANNOUNCEMENTS = 'ca-no-announcements', + NO_ALERTS = 'ca-no-alerts', } interface Props { @@ -26,28 +46,44 @@ function AnimatedSVG(props: Props) { const renderSvg = () => { switch (name) { case ICONS.LOADER: - return <object style={{ width: size + 'px' }} type="image/svg+xml" data={LoaderSVG} /> + return <object style={{ width: size + 'px' }} type="image/svg+xml" data={LoaderSVG} />; case ICONS.DASHBOARD_ICON: - return <object style={{ width: size + 'px' }} type="image/svg+xml" data={DashboardSvg} /> + return <object style={{ width: size + 'px' }} type="image/svg+xml" data={DashboardSvg} />; case ICONS.EMPTY_STATE: - return <object style={{ width: size + 'px' }} type="image/svg+xml" data={EmptyStateSvg} /> + return <object style={{ width: size + 'px' }} type="image/svg+xml" data={EmptyStateSvg} />; case ICONS.LOGO_SMALL: - return <object style={{ width: size + 'px' }} type="image/svg+xml" data={LogoSmall} /> + return <img style={{ width: size + 'px' }} src={LogoSmall} />; case ICONS.NO_RESULTS: - return <object style={{ width: size + 'px' }} type="image/svg+xml" data={NoResultsSVG} /> + return <object style={{ width: size + 'px' }} type="image/svg+xml" data={NoResultsSVG} />; case ICONS.SIGNAL_GREEN: - return <object style={{ width: size + 'px' }} type="image/svg+xml" data={SignalGreenSvg} /> + return <object style={{ width: size + 'px' }} type="image/svg+xml" data={SignalGreenSvg} />; case ICONS.SIGNAL_RED: - return <object style={{ width: size + 'px' }} type="image/svg+xml" data={SignalRedSvg} /> + return <object style={{ width: size + 'px' }} type="image/svg+xml" data={SignalRedSvg} />; + case ICONS.NO_BOOKMARKS: + return <object style={{ width: size + 'px' }} type="image/svg+xml" data={NoBookmarks} />; + case ICONS.NO_LIVE_SESSIONS: + return <object style={{ width: size + 'px' }} type="image/svg+xml" data={NoLiveSessions} />; + case ICONS.NO_SESSIONS: + return <object style={{ width: size + 'px' }} type="image/svg+xml" data={NoSessions} />; + case ICONS.NO_SESSIONS_IN_VAULT: + return <object style={{ width: size + 'px' }} type="image/svg+xml" data={NoSessionsInVault} />; + case ICONS.NO_WEBHOOKS: + return <object style={{ width: size + 'px' }} type="image/svg+xml" data={NoWebhooks} />; + case ICONS.NO_METADATA: + return <object style={{ width: size + 'px' }} type="image/svg+xml" data={NoMetadata} />; + case ICONS.NO_ISSUES: + return <object style={{ width: size + 'px' }} type="image/svg+xml" data={NoIssues} />; + case ICONS.NO_AUDIT_TRAIL: + return <object style={{ width: size + 'px' }} type="image/svg+xml" data={NoAuditTrail} />; + case ICONS.NO_ANNOUNCEMENTS: + return <object style={{ width: size + 'px' }} type="image/svg+xml" data={NoAnnouncements} />; + case ICONS.NO_ALERTS: + return <object style={{ width: size + 'px' }} type="image/svg+xml" data={NoAlerts} />; default: return null; } - } - return ( - <div> - {renderSvg()} - </div> - ); + }; + return <div>{renderSvg()}</div>; } -export default AnimatedSVG; \ No newline at end of file +export default AnimatedSVG; diff --git a/frontend/app/components/shared/Breadcrumb/Breadcrumb.tsx b/frontend/app/components/shared/Breadcrumb/Breadcrumb.tsx index 35c0ea45f..7a48dedf5 100644 --- a/frontend/app/components/shared/Breadcrumb/Breadcrumb.tsx +++ b/frontend/app/components/shared/Breadcrumb/Breadcrumb.tsx @@ -16,7 +16,7 @@ function Breadcrumb(props: Props) { ); } return ( - <div key={index} className="color-gray-darkest hover:color-teal group flex items-center"> + <div key={index} className="color-gray-darkest hover:text-teal group flex items-center"> <Link to={item.to} className="flex items-center"> {index === 0 && <Icon name="chevron-left" size={16} className="mr-1 group-hover:fill-teal" />} <span className="capitalize-first">{item.label}</span> diff --git a/frontend/app/components/shared/CustomMetrics/FilterSeries/SeriesName/SeriesName.tsx b/frontend/app/components/shared/CustomMetrics/FilterSeries/SeriesName/SeriesName.tsx index 5d25e9de9..d6a69c73d 100644 --- a/frontend/app/components/shared/CustomMetrics/FilterSeries/SeriesName/SeriesName.tsx +++ b/frontend/app/components/shared/CustomMetrics/FilterSeries/SeriesName/SeriesName.tsx @@ -46,7 +46,7 @@ function SeriesName(props: Props) { onFocus={() => setEditing(true)} /> ) : ( - <div className="text-base h-8 flex items-center border-transparent">{name.trim() === '' ? 'Seriess ' + (seriesIndex + 1) : name }</div> + <div className="text-base h-8 flex items-center border-transparent">{name && name.trim() === '' ? 'Series ' + (seriesIndex + 1) : name }</div> )} <div className="ml-3 cursor-pointer" onClick={() => setEditing(true)}><Icon name="pencil" size="14" /></div> diff --git a/frontend/app/components/shared/Filters/FilterModal/FilterModal.tsx b/frontend/app/components/shared/Filters/FilterModal/FilterModal.tsx index 770b66adc..c833583f7 100644 --- a/frontend/app/components/shared/Filters/FilterModal/FilterModal.tsx +++ b/frontend/app/components/shared/Filters/FilterModal/FilterModal.tsx @@ -34,7 +34,7 @@ interface Props { filters: any, onFilterClick?: (filter) => void, filterSearchList: any, - metaOptions: any, + // metaOptions: any, isMainSearch?: boolean, fetchingFilterSearchList: boolean, searchQuery?: string, @@ -127,7 +127,7 @@ export default connect((state: any, props: any) => { filterSearchList: props.isLive ? state.getIn([ 'liveSearch', 'filterSearchList' ]) : state.getIn([ 'search', 'filterSearchList' ]), // filterSearchList: state.getIn([ 'search', 'filterSearchList' ]), // liveFilterSearchList: state.getIn([ 'liveSearch', 'filterSearchList' ]), - metaOptions: state.getIn([ 'customFields', 'list' ]), + // metaOptions: state.getIn([ 'customFields', 'list' ]), fetchingFilterSearchList: props.isLive ? state.getIn(['liveSearch', 'fetchFilterSearch', 'loading']) : state.getIn(['search', 'fetchFilterSearch', 'loading']), diff --git a/frontend/app/components/shared/GuidePopup/GuidePopup.tsx b/frontend/app/components/shared/GuidePopup/GuidePopup.tsx new file mode 100644 index 000000000..4f6e4907c --- /dev/null +++ b/frontend/app/components/shared/GuidePopup/GuidePopup.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { Tooltip } from 'react-tippy'; + +export const FEATURE_KEYS = { + XRAY: 'featureViewed' +} + +interface IProps { + children: React.ReactNode + title: React.ReactNode + description: React.ReactNode + key?: keyof typeof FEATURE_KEYS +} + +export default function GuidePopup({ children, title, description }: IProps) { + return ( + // @ts-ignore + <Tooltip + html={ + <div> + <div className="font-bold"> + {title} + </div> + <div className="color-gray-medium"> + {description} + </div> + </div> + } + distance={30} + theme={'light'} + open={true} + arrow={true} + > + {children} + </Tooltip> + ); +} diff --git a/frontend/app/components/shared/GuidePopup/index.ts b/frontend/app/components/shared/GuidePopup/index.ts new file mode 100644 index 000000000..e2b0a7819 --- /dev/null +++ b/frontend/app/components/shared/GuidePopup/index.ts @@ -0,0 +1 @@ +export { default, FEATURE_KEYS } from './GuidePopup' diff --git a/frontend/app/components/shared/LiveSessionList/LiveSessionList.tsx b/frontend/app/components/shared/LiveSessionList/LiveSessionList.tsx index 89aa0d9eb..97ffb4000 100644 --- a/frontend/app/components/shared/LiveSessionList/LiveSessionList.tsx +++ b/frontend/app/components/shared/LiveSessionList/LiveSessionList.tsx @@ -1,6 +1,6 @@ -import React, { useEffect } from 'react'; +import React, { Fragment, useEffect } from 'react'; import { connect } from 'react-redux'; -import { NoContent, Loader, Pagination } from 'UI'; +import { NoContent, Loader, Pagination, Button } from 'UI'; import { List } from 'immutable'; import SessionItem from 'Shared/SessionItem'; import withPermissions from 'HOCs/withPermissions'; @@ -13,6 +13,8 @@ import SortOrderButton from 'Shared/SortOrderButton'; import { capitalize } from 'App/utils'; import LiveSessionReloadButton from 'Shared/LiveSessionReloadButton'; import cn from 'classnames'; +import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG'; +import { numberWithCommas } from 'App/utils'; const AUTOREFRESH_INTERVAL = 0.5 * 60 * 1000; const PER_PAGE = 10; @@ -82,46 +84,57 @@ function LiveSessionList(props: Props) { return ( <div> - <div className="flex mb-6 justify-between items-end"> - <div className="flex items-baseline"> - <h3 className="text-2xl capitalize"> - <span>Live Sessions</span> - <span className="ml-2 font-normal color-gray-medium">{total}</span> - </h3> + <div className="bg-white p-3 rounded border"> + <div className="flex mb-6 justify-between items-center"> + <div className="flex items-baseline"> + <h3 className="text-2xl capitalize mr-4"> + <span>Live Sessions</span> + {/* <span className="ml-2 font-normal color-gray-medium">{numberWithCommas(total)}</span> */} + </h3> - <LiveSessionReloadButton onClick={() => props.applyFilter({ ...filter })} /> - </div> - <div className="flex items-center"> - <div className="flex items-center ml-6 mr-4"> - <span className="mr-2 color-gray-medium">Sort By</span> - <div className={cn('flex items-center', { disabled: sortOptions.length === 0 })}> - <Select - plain - right - options={sortOptions} - onChange={onSortChange} - value={sortOptions.find((i: any) => i.value === filter.sort) || sortOptions[0]} - /> - <div className="mx-2" /> - <SortOrderButton onChange={(state: any) => props.applyFilter({ order: state })} sortOrder={filter.order} /> + <LiveSessionReloadButton onClick={() => props.applyFilter({ ...filter })} /> + </div> + <div className="flex items-center"> + <div className="flex items-center ml-6"> + <span className="mr-2 color-gray-medium">Sort By</span> + <div className={cn('flex items-center', { disabled: sortOptions.length === 0 })}> + <Select + plain + right + options={sortOptions} + onChange={onSortChange} + value={sortOptions.find((i: any) => i.value === filter.sort) || sortOptions[0]} + /> + <div className="mx-2" /> + <SortOrderButton onChange={(state: any) => props.applyFilter({ order: state })} sortOrder={filter.order} /> + </div> </div> </div> </div> - </div> - <div className="bg-white p-3 rounded border"> <Loader loading={loading}> <NoContent - title={'No live sessions.'} - subtext={ - <span> - See how to setup the{' '} - <a target="_blank" className="link" href="https://docs.openreplay.com/plugins/assist"> - {'Assist'} - </a>{' '} - plugin, if you haven’t done that already. - </span> + title={ + <div className="flex items-center justify-center flex-col"> + <AnimatedSVG name={ICONS.NO_LIVE_SESSIONS} size={170} /> + <div className="mt-2" /> + <div className="text-center text-gray-600">No live sessions found.</div> + </div> } - image={<img src="/assets/img/live-sessions.png" style={{ width: '70%', marginBottom: '30px' }} />} + subtext={ + <div className="text-center flex justify-center items-center flex-col"> + <span> + Assist allows you to support your users through live screen viewing and audio/video calls.{' '} + <a target="_blank" className="link" href="https://docs.openreplay.com/plugins/assist"> + {'Learn More'} + </a> + </span> + + <Button variant="text-primary" className="mt-4" icon="sync-alt" onClick={() => props.applyFilter({ ...filter })}> + Refresh + </Button> + </div> + } + // image={<img src="/assets/img/live-sessions.png" style={{ width: '70%', marginBottom: '30px' }} />} show={!loading && list.size === 0} > <div> @@ -139,18 +152,22 @@ function LiveSessionList(props: Props) { </> ))} </div> + <div className={cn("flex items-center justify-between p-5", { disabled: loading })}> + <div> + Showing <span className="font-medium">{(currentPage - 1) * PER_PAGE + 1}</span> to{' '} + <span className="font-medium">{(currentPage - 1) * PER_PAGE + list.size}</span> of{' '} + <span className="font-medium">{numberWithCommas(total)}</span> sessions. + </div> + <Pagination + page={currentPage} + totalPages={Math.ceil(total / PER_PAGE)} + onPageChange={(page: any) => props.updateCurrentPage(page)} + limit={PER_PAGE} + debounceRequest={500} + /> + </div> </NoContent> </Loader> - - <div className={cn('w-full flex items-center justify-center py-6', { disabled: loading })}> - <Pagination - page={currentPage} - totalPages={Math.ceil(total / PER_PAGE)} - onPageChange={(page: any) => props.updateCurrentPage(page)} - limit={PER_PAGE} - debounceRequest={500} - /> - </div> </div> </div> ); diff --git a/frontend/app/components/shared/LiveTag/LiveTag.module.css b/frontend/app/components/shared/LiveTag/LiveTag.module.css index cecf45bad..2914b0b76 100644 --- a/frontend/app/components/shared/LiveTag/LiveTag.module.css +++ b/frontend/app/components/shared/LiveTag/LiveTag.module.css @@ -8,26 +8,26 @@ cursor: pointer; user-select: none; height: 26px; - width: 56px; + padding: 4px 8px; border-radius: 3px; - background-color: $gray-light; + background-color: $main; display: flex; align-items: center; justify-content: center; - color: $gray-dark; + color: white; text-transform: uppercase; font-size: 10px; + font-weight: 600; letter-spacing: 1px; margin-right: 10px; & svg { - fill: $gray-dark; + fill: white; + opacity: .5; } &[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 index 36275783a..c29ae3d34 100644 --- a/frontend/app/components/shared/LiveTag/LiveTag.tsx +++ b/frontend/app/components/shared/LiveTag/LiveTag.tsx @@ -10,8 +10,8 @@ interface Props { function LiveTag({ isLive, onClick }: Props) { return ( <button onClick={ onClick } className={ stl.liveTag } data-is-live={ isLive }> - <Icon name="circle" size="8" marginRight="5" color="white" /> - <div>{'Live'}</div> + <Icon name="circle" size="8" marginRight={5} color="white" /> + <div>{isLive ? 'Live' : 'Go live'}</div> </button> ) } diff --git a/frontend/app/components/shared/NoSessionsMessage/NoSessionsMessage.js b/frontend/app/components/shared/NoSessionsMessage/NoSessionsMessage.js index 01f8a66b2..79dc8e436 100644 --- a/frontend/app/components/shared/NoSessionsMessage/NoSessionsMessage.js +++ b/frontend/app/components/shared/NoSessionsMessage/NoSessionsMessage.js @@ -1,40 +1,55 @@ -import React from 'react' -import { Icon, Button } from 'UI' -import { connect } from 'react-redux' -import { onboarding as onboardingRoute } from 'App/routes' +import React from 'react'; +import { Icon, Button } from 'UI'; +import { connect } from 'react-redux'; +import { onboarding as onboardingRoute } from 'App/routes'; import { withRouter } from 'react-router-dom'; import * as routes from '../../../routes'; const withSiteId = routes.withSiteId; -const NoSessionsMessage= (props) => { - const { sites, match: { params: { siteId } } } = props; - const activeSite = sites.find(s => s.id == siteId); - const showNoSessions = !!activeSite && !activeSite.recorded; - return ( - <> - {showNoSessions && ( - <div> - <div - className="rounded text-sm flex items-center p-2 justify-between mb-4" - style={{ backgroundColor: 'rgba(255, 239, 239, 1)', border: 'solid thin rgba(221, 181, 181, 1)'}} - > - <div className="flex items-center w-full"> - <div className="flex-shrink-0 w-8 flex justify-center"> - <Icon name="info-circle" size="14" color="gray-darkest" /> - </div> - <div className="ml-2color-gray-darkest mr-auto"> - It takes a few minutes for first recordings to appear. All set but they are still not showing up? Check our <a href="https://docs.openreplay.com/troubleshooting" className="link">troubleshooting</a> section. - </div> - <Button variant="outline" className="bg-white h-8 hover:bg-gray-light" onClick={() => props.history.push(withSiteId(onboardingRoute('installing'), siteId))}>Go to project setup</Button> - </div> - </div> - </div> - )} - </> - ) -} +const NoSessionsMessage = (props) => { + const { + sites, + match: { + params: { siteId }, + }, + } = props; + const activeSite = sites.find((s) => s.id == siteId); + const showNoSessions = !!activeSite && !activeSite.recorded; + return ( + <> + {showNoSessions && ( + <div> + <div + className="rounded text-sm flex items-center p-2 justify-between mb-4" + style={{ backgroundColor: 'rgba(255, 239, 239, 1)', border: 'solid thin rgba(221, 181, 181, 1)' }} + > + <div className="flex items-center w-full"> + <div className="flex-shrink-0 w-8 flex justify-center"> + <Icon name="info-circle" size="14" color="gray-darkest" /> + </div> + <div className="ml-2 color-gray-darkest mr-auto text-base"> + It might take a few minutes for first recording to appear. + <a href="https://docs.openreplay.com/troubleshooting" className="link ml-2"> + Troubleshoot + </a> + . + </div> + <Button + variant="primary" + className="bg-white h-8 hover:bg-gray-light text-base" + onClick={() => props.history.push(withSiteId(onboardingRoute('installing'), siteId))} + > + Complete Project Setup + </Button> + </div> + </div> + </div> + )} + </> + ); +}; -export default connect(state => ({ - site: state.getIn([ 'site', 'siteId' ]), - sites: state.getIn([ 'site', 'list' ]) -}))(withRouter(NoSessionsMessage)) \ No newline at end of file +export default connect((state) => ({ + site: state.getIn(['site', 'siteId']), + sites: state.getIn(['site', 'list']), +}))(withRouter(NoSessionsMessage)); diff --git a/frontend/app/components/shared/OutsideClickDetectingDiv/OutsideClickDetectingDiv.js b/frontend/app/components/shared/OutsideClickDetectingDiv/OutsideClickDetectingDiv.js index 954566480..a6ec97c40 100644 --- a/frontend/app/components/shared/OutsideClickDetectingDiv/OutsideClickDetectingDiv.js +++ b/frontend/app/components/shared/OutsideClickDetectingDiv/OutsideClickDetectingDiv.js @@ -29,8 +29,7 @@ function handleClickOutside(e) { document.addEventListener('click', handleClickOutside); - -export default React.memo(function OutsideClickDetectingDiv({ onClickOutside, children, ...props}) { +function OutsideClickDetectingDiv({ onClickOutside, children, ...props}) { const ref = useRef(null); useLayoutEffect(() => { function handleClickOutside(event) { @@ -44,7 +43,6 @@ export default React.memo(function OutsideClickDetectingDiv({ onClickOutside, ch }, [ ref ]); return <div ref={ref} {...props}>{children}</div>; -}); - - +} +export default React.memo(OutsideClickDetectingDiv); diff --git a/frontend/app/components/shared/ReloadButton/ReloadButton.tsx b/frontend/app/components/shared/ReloadButton/ReloadButton.tsx index 8ae36a8f1..3e7cf90a6 100644 --- a/frontend/app/components/shared/ReloadButton/ReloadButton.tsx +++ b/frontend/app/components/shared/ReloadButton/ReloadButton.tsx @@ -1,22 +1,24 @@ -import React from 'react' -import { CircularLoader, Icon } from 'UI' -import cn from 'classnames' +import React from 'react'; +import { CircularLoader, Icon, Popup } from 'UI'; +import cn from 'classnames'; interface Props { - loading?: boolean - onClick: () => void - iconSize?: number - iconName?: string - className?: string + loading?: boolean; + onClick: () => void; + iconSize?: number; + iconName?: string; + className?: string; } export default function ReloadButton(props: Props) { - const { loading, onClick, iconSize = "14", iconName = "sync-alt", className = '' } = props - return ( - <div - className={cn("ml-4 h-5 w-6 flex items-center justify-center", className)} - onClick={onClick} - > - { loading ? <CircularLoader className="ml-1" /> : <Icon name={iconName} size={iconSize} />} - </div> - ) + const { loading, onClick, iconSize = '14', iconName = 'sync-alt', className = '' } = props; + return ( + <Popup content="Refresh"> + <div + className={cn('h-5 w-6 flex items-center justify-center', className)} + onClick={onClick} + > + {loading ? <CircularLoader className="ml-1" /> : <Icon name={iconName} size={iconSize} />} + </div> + </Popup> + ); } diff --git a/frontend/app/components/shared/Select/Select.tsx b/frontend/app/components/shared/Select/Select.tsx index b209eddc8..3ef9383ca 100644 --- a/frontend/app/components/shared/Select/Select.tsx +++ b/frontend/app/components/shared/Select/Select.tsx @@ -5,8 +5,8 @@ import colors from 'App/theme/colors'; const { ValueContainer } = components; type ValueObject = { - value: string, - label: string + value: string | number, + label: string, } interface Props<Value extends ValueObject> { @@ -104,7 +104,7 @@ export default function<Value extends ValueObject>({ placeholder='Select', name const opacity = state.isDisabled ? 0.5 : 1; const transition = 'opacity 300ms'; - return { ...provided, opacity, transition }; + return { ...provided, opacity, transition, fontWeight: '500' }; }, input: (provided: any) => ({ ...provided, diff --git a/frontend/app/components/shared/SessionItem/SessionItem.tsx b/frontend/app/components/shared/SessionItem/SessionItem.tsx index fb99dd33e..7b66a6bd9 100644 --- a/frontend/app/components/shared/SessionItem/SessionItem.tsx +++ b/frontend/app/components/shared/SessionItem/SessionItem.tsx @@ -12,6 +12,7 @@ import PlayLink from './PlayLink'; import ErrorBars from './ErrorBars'; import { assist as assistRoute, liveSession, sessions as sessionsRoute, isRoute } from 'App/routes'; import { capitalize } from 'App/utils'; +import { Tooltip } from 'react-tippy'; const ASSIST_ROUTE = assistRoute(); const ASSIST_LIVE_SESSION = liveSession(); @@ -41,6 +42,8 @@ interface Props { userSessionsCount: number; issueTypes: []; active: boolean; + isCallActive?: boolean; + agentIds?: string[]; }; onUserClick?: (userId: string, userAnonymousId: string) => void; hasUserFilter?: boolean; @@ -56,7 +59,6 @@ interface Props { function SessionItem(props: RouteComponentProps & Props) { const { settingsStore } = useStore(); const { timezone } = settingsStore.sessionSettings; - const [isIframe, setIsIframe] = React.useState(false); const { session, @@ -106,7 +108,7 @@ function SessionItem(props: RouteComponentProps & Props) { }); return ( - <div className={cn(stl.sessionItem, 'flex flex-col p-2')} id="session-item" onClick={(e) => e.stopPropagation()}> + <div className={cn(stl.sessionItem, 'flex flex-col py-2 px-4')} id="session-item" onClick={(e) => e.stopPropagation()}> <div className="flex items-start"> <div className={cn('flex items-center w-full')}> {!compact && ( @@ -121,7 +123,7 @@ function SessionItem(props: RouteComponentProps & Props) { [stl.userName]: !disableUser && hasUserId, 'color-gray-medium': disableUser || !hasUserId, })} - onClick={() => !disableUser && !hasUserFilter && onUserClick(userId, userAnonymousId)} + onClick={() => !disableUser && !hasUserFilter && hasUserId ? onUserClick(userId, userAnonymousId) : null} > <TextEllipsis text={userDisplayName} maxWidth="200px" popupProps={{ inverted: true, size: 'tiny' }} /> </div> @@ -130,7 +132,13 @@ function SessionItem(props: RouteComponentProps & Props) { )} <div style={{ width: compact ? '40%' : '20%' }} className="px-2 flex flex-col justify-between"> <div> - <TextEllipsis text={formatTimeOrDate(startedAt, timezone)} popupProps={{ inverted: true, size: 'tiny' }} /> + {/* @ts-ignore */} + <Tooltip + title={`${formatTimeOrDate(startedAt, timezone, true)} ${timezone.label}`} + className="w-fit !block" + > + <TextEllipsis text={formatTimeOrDate(startedAt, timezone)} popupProps={{ inverted: true, size: 'tiny' }} /> + </Tooltip> </div> <div className="flex items-center color-gray-medium py-1"> {!isAssist && ( @@ -172,6 +180,15 @@ function SessionItem(props: RouteComponentProps & Props) { <div className="flex items-center"> <div className={stl.playLink} id="play-button" data-viewed={viewed}> + {live && session.isCallActive && session.agentIds.length > 0 ? ( + <div className="mr-4"> + <Label className="bg-gray-lightest p-1 px-2 rounded-lg"> + <span className="color-gray-medium text-xs" style={{ whiteSpace: 'nowrap' }}> + CALL IN PROGRESS + </span> + </Label> + </div> + ) : null} {isSessions && ( <div className="mr-4 flex-shrink-0 w-24"> {isLastPlayed && ( @@ -192,4 +209,4 @@ function SessionItem(props: RouteComponentProps & Props) { ); } -export default withRouter<Props>(observer<Props>(SessionItem)); +export default withRouter(observer(SessionItem)); diff --git a/frontend/app/components/shared/SessionListContainer/SessionListContainer.tsx b/frontend/app/components/shared/SessionListContainer/SessionListContainer.tsx new file mode 100644 index 000000000..0b8bc35c6 --- /dev/null +++ b/frontend/app/components/shared/SessionListContainer/SessionListContainer.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import SessionList from './components/SessionList'; +import SessionHeader from './components/SessionHeader'; + +function SessionListContainer() { + return ( + <div className="widget-wrapper"> + <SessionHeader /> + <div className="border-b" /> + <SessionList /> + </div> + ); +} + +export default SessionListContainer; diff --git a/frontend/app/components/shared/SessionListContainer/components/NoContentMessage.tsx b/frontend/app/components/shared/SessionListContainer/components/NoContentMessage.tsx new file mode 100644 index 000000000..c80ec6555 --- /dev/null +++ b/frontend/app/components/shared/SessionListContainer/components/NoContentMessage.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { connect } from 'react-redux'; + +function NoContentMessage({ activeTab }: any) { + return <div>{getNoContentMessage(activeTab)}</div>; +} + +export default connect((state: any) => ({ + activeTab: state.getIn(['search', 'activeTab']), +}))(NoContentMessage); + +function getNoContentMessage(activeTab: any) { + let str = 'No recordings found'; + if (activeTab.type !== 'all') { + str += ' with ' + activeTab.name; + return str; + } + + return str + '!'; +} diff --git a/frontend/app/components/shared/SessionListContainer/components/SessionHeader/SessionHeader.tsx b/frontend/app/components/shared/SessionListContainer/components/SessionHeader/SessionHeader.tsx new file mode 100644 index 000000000..e991c8c31 --- /dev/null +++ b/frontend/app/components/shared/SessionListContainer/components/SessionHeader/SessionHeader.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { numberWithCommas } from 'App/utils'; +import { applyFilter } from 'Duck/search'; +import Period from 'Types/app/period'; +import SelectDateRange from 'Shared/SelectDateRange'; +import SessionTags from '../SessionTags'; +import { connect } from 'react-redux'; +import SessionSort from '../SessionSort'; +import cn from 'classnames'; +import { setActiveTab } from 'Duck/search'; +import SessionSettingButton from '../SessionSettingButton'; + +interface Props { + listCount: number; + filter: any; + isBookmark: any; + isEnterprise: boolean; + applyFilter: (filter: any) => void; + setActiveTab: (tab: any) => void; +} +function SessionHeader(props: Props) { + const { + filter: { startDate, endDate, rangeValue }, + isBookmark, + isEnterprise, + } = props; + + const period = Period({ start: startDate, end: endDate, rangeName: rangeValue }); + + const onDateChange = (e: any) => { + const dateValues = e.toJSON(); + props.applyFilter(dateValues); + }; + + return ( + <div className="flex items-center px-4 justify-between"> + <div className="flex items-center justify-between"> + <div className="mr-3 text-lg flex items-center"> + <div + className={cn('py-3 cursor-pointer mr-4', { + 'border-b color-teal border-teal': !isBookmark, + })} + onClick={() => props.setActiveTab({ type: 'all' })} + > + <span className="font-bold">SESSIONS</span> + </div> + <div + className={cn('py-3 cursor-pointer', { + 'border-b color-teal border-teal': isBookmark, + })} + onClick={() => props.setActiveTab({ type: 'bookmark' })} + > + <span className="font-bold">{`${isEnterprise ? 'VAULT' : 'BOOKMARKS'}`}</span> + </div> + </div> + </div> + + {!isBookmark && <div className="flex items-center"> + <SessionTags /> + <div className="mx-4" /> + <SelectDateRange period={period} onChange={onDateChange} right={true} /> + <div className="mx-2" /> + <SessionSort /> + <SessionSettingButton /> + </div>} + </div> + ); +} + +export default connect( + (state: any) => ({ + filter: state.getIn(['search', 'instance']), + listCount: numberWithCommas(state.getIn(['sessions', 'total'])), + isBookmark: state.getIn(['search', 'activeTab', 'type']) === 'bookmark', + isEnterprise: state.getIn(['user', 'account', 'edition']) === 'ee', + }), + { applyFilter, setActiveTab } +)(SessionHeader); diff --git a/frontend/app/components/shared/SessionListContainer/components/SessionHeader/index.ts b/frontend/app/components/shared/SessionListContainer/components/SessionHeader/index.ts new file mode 100644 index 000000000..ad3beb4fd --- /dev/null +++ b/frontend/app/components/shared/SessionListContainer/components/SessionHeader/index.ts @@ -0,0 +1 @@ +export { default } from './SessionHeader'; \ No newline at end of file diff --git a/frontend/app/components/shared/SessionListContainer/components/SessionList/SessionList.tsx b/frontend/app/components/shared/SessionListContainer/components/SessionList/SessionList.tsx new file mode 100644 index 000000000..ec4c0cec9 --- /dev/null +++ b/frontend/app/components/shared/SessionListContainer/components/SessionList/SessionList.tsx @@ -0,0 +1,152 @@ +import React, { useEffect } from 'react'; +import { connect } from 'react-redux'; +import { FilterKey } from 'Types/filter/filterType'; +import SessionItem from 'Shared/SessionItem'; +import { NoContent, Loader, Pagination, Button } from 'UI'; +import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG'; +import { fetchSessions, addFilterByKeyAndValue, updateCurrentPage, setScrollPosition } from 'Duck/search'; +import useTimeout from 'App/hooks/useTimeout'; +import { numberWithCommas } from 'App/utils'; + +const AUTOREFRESH_INTERVAL = 5 * 60 * 1000; +const PER_PAGE = 10; +interface Props { + loading: boolean; + list: any; + currentPage: number; + total: number; + filters: any; + lastPlayedSessionId: string; + metaList: any; + scrollY: number; + addFilterByKeyAndValue: (key: string, value: any, operator?: string) => void; + updateCurrentPage: (page: number) => void; + setScrollPosition: (scrollPosition: number) => void; + fetchSessions: (filters: any, force: boolean) => void; + activeTab: any; + isEnterprise?: boolean; +} +function SessionList(props: Props) { + const { loading, list, currentPage, total, filters, lastPlayedSessionId, metaList, activeTab, isEnterprise = false } = props; + const _filterKeys = filters.map((i: any) => i.key); + const hasUserFilter = _filterKeys.includes(FilterKey.USERID) || _filterKeys.includes(FilterKey.USERANONYMOUSID); + const isBookmark = activeTab.type === 'bookmark'; + const isVault = isBookmark && isEnterprise; + const NO_CONTENT = React.useMemo(() => { + if (isBookmark && !isEnterprise) { + return { + icon: ICONS.NO_BOOKMARKS, + message: 'No sessions bookmarked.', + }; + } else if (isVault) { + return { + icon: ICONS.NO_SESSIONS_IN_VAULT, + message: 'No sessions found in vault.', + }; + } + return { + icon: ICONS.NO_SESSIONS, + message: 'No relevant sessions found for the selected time period.', + }; + }, [isBookmark, isVault, activeTab]); + + useTimeout(() => { + props.fetchSessions(null, true); + }, AUTOREFRESH_INTERVAL); + + + useEffect(() => { + // handle scroll position + const { scrollY } = props; + window.scrollTo(0, scrollY); + if (total === 0) { + props.fetchSessions(null, true); + } + + return () => { + props.setScrollPosition(window.scrollY); + }; + }, []); + + const onUserClick = (userId: any) => { + if (userId) { + props.addFilterByKeyAndValue(FilterKey.USERID, userId); + } else { + props.addFilterByKeyAndValue(FilterKey.USERID, '', 'isUndefined'); + } + }; + + return ( + <Loader loading={loading}> + <NoContent + title={ + <div className="flex items-center justify-center flex-col"> + <AnimatedSVG name={NO_CONTENT.icon} size={170} /> + <div className="mt-2" /> + <div className="text-center text-gray-600">{NO_CONTENT.message}</div> + </div> + } + subtext={ + <div className="flex flex-col items-center"> + {(isVault || isBookmark) && ( + <div> + {isVault + ? 'Add a session to your vault from player screen to retain it for ever.' + : 'Bookmark important sessions in player screen and quickly find them here.'} + </div> + )} + <Button variant="text-primary" className="mt-4" icon="sync-alt" onClick={() => props.fetchSessions(null, true)}> + Refresh + </Button> + </div> + } + show={!loading && list.size === 0} + > + {list.map((session: any) => ( + <div key={session.sessionId} className="border-b"> + <SessionItem + session={session} + hasUserFilter={hasUserFilter} + onUserClick={onUserClick} + metaList={metaList} + lastPlayedSessionId={lastPlayedSessionId} + /> + </div> + ))} + </NoContent> + + {total > 0 && ( + <div className="flex items-center justify-between p-5"> + <div> + Showing <span className="font-medium">{(currentPage - 1) * PER_PAGE + 1}</span> to{' '} + <span className="font-medium">{(currentPage - 1) * PER_PAGE + list.size}</span> of{' '} + <span className="font-medium">{numberWithCommas(total)}</span> sessions. + </div> + <Pagination + page={currentPage} + totalPages={Math.ceil(total / PER_PAGE)} + onPageChange={(page) => props.updateCurrentPage(page)} + limit={PER_PAGE} + debounceRequest={1000} + /> + </div> + )} + </Loader> + ); +} + +export default connect( + (state: any) => ({ + list: state.getIn(['sessions', 'list']), + filters: state.getIn(['search', 'instance', 'filters']), + lastPlayedSessionId: state.getIn(['sessions', 'lastPlayedSessionId']), + metaList: state.getIn(['customFields', 'list']).map((i: any) => i.key), + loading: state.getIn(['sessions', 'loading']), + currentPage: state.getIn(['search', 'currentPage']) || 1, + total: state.getIn(['sessions', 'total']) || 0, + scrollY: state.getIn(['search', 'scrollY']), + activeTab: state.getIn(['search', 'activeTab']), + isEnterprise: state.getIn(['user', 'account', 'edition']) === 'ee', + }), + { updateCurrentPage, addFilterByKeyAndValue, setScrollPosition, fetchSessions } +)(SessionList); diff --git a/frontend/app/components/shared/SessionListContainer/components/SessionList/index.ts b/frontend/app/components/shared/SessionListContainer/components/SessionList/index.ts new file mode 100644 index 000000000..779c9df2a --- /dev/null +++ b/frontend/app/components/shared/SessionListContainer/components/SessionList/index.ts @@ -0,0 +1 @@ +export { default } from './SessionList'; \ No newline at end of file diff --git a/frontend/app/components/shared/SessionListContainer/components/SessionSettingButton/SessionSettingButton.tsx b/frontend/app/components/shared/SessionListContainer/components/SessionSettingButton/SessionSettingButton.tsx new file mode 100644 index 000000000..5d76f8979 --- /dev/null +++ b/frontend/app/components/shared/SessionListContainer/components/SessionSettingButton/SessionSettingButton.tsx @@ -0,0 +1,24 @@ +import { useModal } from 'App/components/Modal'; +import React from 'react'; +import SessionSettings from 'Shared/SessionSettings'; +import { Button } from 'UI'; +import { Tooltip } from 'react-tippy'; + +function SessionSettingButton(props: any) { + const { showModal } = useModal(); + + const handleClick = () => { + showModal(<SessionSettings />, { right: true }); + }; + + return ( + <div className="cursor-pointer ml-4" onClick={handleClick}> + {/* @ts-ignore */} + <Tooltip title="Session Settings"> + <Button icon="sliders" variant="text" /> + </Tooltip> + </div> + ); +} + +export default SessionSettingButton; diff --git a/frontend/app/components/shared/SessionListContainer/components/SessionSettingButton/index.ts b/frontend/app/components/shared/SessionListContainer/components/SessionSettingButton/index.ts new file mode 100644 index 000000000..dffed3cc0 --- /dev/null +++ b/frontend/app/components/shared/SessionListContainer/components/SessionSettingButton/index.ts @@ -0,0 +1 @@ +export { default } from './SessionSettingButton'; \ No newline at end of file diff --git a/frontend/app/components/shared/SessionListContainer/components/SessionSort/SessionSort.tsx b/frontend/app/components/shared/SessionListContainer/components/SessionSort/SessionSort.tsx new file mode 100644 index 000000000..f253f8651 --- /dev/null +++ b/frontend/app/components/shared/SessionListContainer/components/SessionSort/SessionSort.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import Select from 'Shared/Select'; +import { sort } from 'Duck/sessions'; +import { applyFilter } from 'Duck/search'; + +const sortOptionsMap = { + 'startTs-desc': 'Newest', + 'startTs-asc': 'Oldest', + 'eventsCount-asc': 'Events Ascending', + 'eventsCount-desc': 'Events Descending', +}; + +const sortOptions = Object.entries(sortOptionsMap).map(([value, label]) => ({ value, label })); + +interface Props { + filter: any; + options?: any; + applyFilter: (filter: any) => void; + sort: (sort: string, sign: number) => void; +} + +function SessionSort(props: Props) { + const { sort, order } = props.filter; + const onSort = ({ value }: any) => { + value = value.value; + const [sort, order] = value.split('-'); + const sign = order === 'desc' ? -1 : 1; + props.applyFilter({ order, sort }); + props.sort(sort, sign); + }; + + const defaultOption = `${sort}-${order}`; + return <Select name="sortSessions" plain right options={sortOptions} onChange={onSort} defaultValue={defaultOption} />; +} + +export default connect( + (state: any) => ({ + filter: state.getIn(['search', 'instance']), + }), + { sort, applyFilter } +)(SessionSort); diff --git a/frontend/app/components/shared/SessionListContainer/components/SessionSort/index.ts b/frontend/app/components/shared/SessionListContainer/components/SessionSort/index.ts new file mode 100644 index 000000000..b0c0489be --- /dev/null +++ b/frontend/app/components/shared/SessionListContainer/components/SessionSort/index.ts @@ -0,0 +1 @@ +export { default } from './SessionSort'; \ No newline at end of file diff --git a/frontend/app/components/shared/SessionListContainer/components/SessionSort/sortDropdown.module.css b/frontend/app/components/shared/SessionListContainer/components/SessionSort/sortDropdown.module.css new file mode 100644 index 000000000..87e26bc68 --- /dev/null +++ b/frontend/app/components/shared/SessionListContainer/components/SessionSort/sortDropdown.module.css @@ -0,0 +1,23 @@ +.dropdown { + display: flex !important; + padding: 4px 6px; + border-radius: 3px; + color: $gray-darkest; + font-weight: 500; + &:hover { + background-color: $gray-light; + } +} + +.dropdownTrigger { + padding: 4px 8px; + border-radius: 3px; + &:hover { + background-color: $gray-light; + } +} + +.dropdownIcon { + margin-top: 2px; + margin-left: 3px; +} \ No newline at end of file diff --git a/frontend/app/components/shared/SessionListContainer/components/SessionTags/SessionTags.tsx b/frontend/app/components/shared/SessionListContainer/components/SessionTags/SessionTags.tsx new file mode 100644 index 000000000..22824e6e5 --- /dev/null +++ b/frontend/app/components/shared/SessionListContainer/components/SessionTags/SessionTags.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { setActiveTab } from 'Duck/search'; +import { connect } from 'react-redux'; +import { issues_types } from 'Types/session/issue'; +import { Icon } from 'UI'; +import cn from 'classnames'; + +interface Props { + setActiveTab: typeof setActiveTab; + activeTab: any; + tags: any; + total: number; +} +function SessionTags(props: Props) { + const { activeTab, tags, total } = props; + const disable = activeTab.type === 'all' && total === 0; + + return ( + <div className="flex items-center"> + {tags && + tags.map((tag: any, index: any) => ( + <div key={index}> + <TagItem + onClick={() => props.setActiveTab(tag)} + label={tag.name} + isActive={activeTab.type === tag.type} + icon={tag.icon} + disabled={disable && tag.type !== 'all'} + /> + </div> + ))} + </div> + ); +} + +export default connect( + (state: any) => { + const isEnterprise = state.getIn(['user', 'account', 'edition']) === 'ee'; + return { + activeTab: state.getIn(['search', 'activeTab']), + tags: issues_types.filter((tag: any) => (isEnterprise ? tag.type !== 'bookmark' : tag.type !== 'vault')), + total: state.getIn(['sessions', 'total']) || 0, + }; + }, + { + setActiveTab, + } +)(SessionTags); + +function TagItem({ isActive, onClick, label, icon = '', disabled = false }: any) { + return ( + <div> + <button + onClick={onClick} + className={cn('transition group rounded ml-2 px-2 py-1 flex items-center uppercase text-sm hover:bg-active-blue hover:text-teal', { + 'bg-active-blue text-teal': isActive, + disabled: disabled, + })} + style={{ height: '36px' }} + > + {icon && <Icon name={icon} color={isActive ? 'teal' : 'gray-medium'} size="14" className={cn('group-hover:fill-teal mr-2')} />} + <span className="leading-none font-medium">{label}</span> + </button> + </div> + ); +} diff --git a/frontend/app/components/shared/SessionListContainer/components/SessionTags/index.ts b/frontend/app/components/shared/SessionListContainer/components/SessionTags/index.ts new file mode 100644 index 000000000..4f5e62f6c --- /dev/null +++ b/frontend/app/components/shared/SessionListContainer/components/SessionTags/index.ts @@ -0,0 +1 @@ +export { default } from './SessionTags'; \ No newline at end of file diff --git a/frontend/app/components/shared/SessionListContainer/index.ts b/frontend/app/components/shared/SessionListContainer/index.ts new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/app/components/shared/SessionSearch/SessionSearch.tsx b/frontend/app/components/shared/SessionSearch/SessionSearch.tsx index bf0153057..48856d929 100644 --- a/frontend/app/components/shared/SessionSearch/SessionSearch.tsx +++ b/frontend/app/components/shared/SessionSearch/SessionSearch.tsx @@ -5,21 +5,24 @@ import SaveFilterButton from 'Shared/SaveFilterButton'; import { connect } from 'react-redux'; import { Button } from 'UI'; import { edit, addFilter } from 'Duck/search'; +import SessionSearchQueryParamHandler from 'Shared/SessionSearchQueryParamHandler'; interface Props { appliedFilter: any; edit: typeof edit; addFilter: typeof addFilter; saveRequestPayloads: boolean; + metaLoading?: boolean } function SessionSearch(props: Props) { - const { appliedFilter, saveRequestPayloads = false } = props; + const { appliedFilter, saveRequestPayloads = false, metaLoading } = props; const hasEvents = appliedFilter.filters.filter((i: any) => i.isEvent).size > 0; const hasFilters = appliedFilter.filters.filter((i: any) => !i.isEvent).size > 0; + const onAddFilter = (filter: any) => { props.addFilter(filter); - } + }; const onUpdateFilter = (filterIndex: any, filter: any) => { const newFilters = appliedFilter.filters.map((_filter: any, i: any) => { @@ -31,10 +34,10 @@ function SessionSearch(props: Props) { }); props.edit({ - ...appliedFilter, - filters: newFilters, + ...appliedFilter, + filters: newFilters, }); - } + }; const onRemoveFilter = (filterIndex: any) => { const newFilters = appliedFilter.filters.filter((_filter: any, i: any) => { @@ -44,51 +47,60 @@ function SessionSearch(props: Props) { props.edit({ filters: newFilters, }); - } + }; const onChangeEventsOrder = (e: any, { value }: any) => { props.edit({ eventsOrder: value, }); - } + }; - return (hasEvents || hasFilters) ? ( - <div className="border bg-white rounded mt-4"> - <div className="p-5"> - <FilterList - filter={appliedFilter} - onUpdateFilter={onUpdateFilter} - onRemoveFilter={onRemoveFilter} - onChangeEventsOrder={onChangeEventsOrder} - saveRequestPayloads={saveRequestPayloads} - /> - </div> + return !metaLoading && ( + <> + <SessionSearchQueryParamHandler /> + {hasEvents || hasFilters ? ( + <div className="border bg-white rounded mt-4"> + <div className="p-5"> + <FilterList + filter={appliedFilter} + onUpdateFilter={onUpdateFilter} + onRemoveFilter={onRemoveFilter} + onChangeEventsOrder={onChangeEventsOrder} + saveRequestPayloads={saveRequestPayloads} + /> + </div> - <div className="border-t px-5 py-1 flex items-center -mx-2"> - <div> - <FilterSelection - filter={undefined} - onFilterClick={onAddFilter} - > - {/* <IconButton primaryText label="ADD STEP" icon="plus" /> */} - <Button - variant="text-primary" - className="mr-2" - // onClick={() => setshowModal(true)} - icon="plus"> - ADD STEP - </Button> - </FilterSelection> + <div className="border-t px-5 py-1 flex items-center -mx-2"> + <div> + <FilterSelection filter={undefined} onFilterClick={onAddFilter}> + {/* <IconButton primaryText label="ADD STEP" icon="plus" /> */} + <Button + variant="text-primary" + className="mr-2" + // onClick={() => setshowModal(true)} + icon="plus" + > + ADD STEP + </Button> + </FilterSelection> + </div> + <div className="ml-auto flex items-center"> + <SaveFilterButton /> + </div> + </div> </div> - <div className="ml-auto flex items-center"> - <SaveFilterButton /> - </div> - </div> - </div> - ) : <></>; + ) : ( + <></> + )} + </> + ); } -export default connect((state: any) => ({ - saveRequestPayloads: state.getIn(['site', 'instance', 'saveRequestPayloads']), - appliedFilter: state.getIn([ 'search', 'instance' ]), -}), { edit, addFilter })(SessionSearch); +export default connect( + (state: any) => ({ + saveRequestPayloads: state.getIn(['site', 'instance', 'saveRequestPayloads']), + appliedFilter: state.getIn(['search', 'instance']), + metaLoading: state.getIn(['customFields', 'fetchRequestActive', 'loading']) + }), + { edit, addFilter } +)(SessionSearch); diff --git a/frontend/app/components/shared/SessionSearchField/SessionSearchField.tsx b/frontend/app/components/shared/SessionSearchField/SessionSearchField.tsx index 9796c441c..4f3c3d121 100644 --- a/frontend/app/components/shared/SessionSearchField/SessionSearchField.tsx +++ b/frontend/app/components/shared/SessionSearchField/SessionSearchField.tsx @@ -3,64 +3,67 @@ import { connect } from 'react-redux'; import { Input } from 'UI'; import FilterModal from 'Shared/Filters/FilterModal'; import { debounce } from 'App/utils'; -import { assist as assistRoute, isRoute } from "App/routes"; +import { assist as assistRoute, isRoute } from 'App/routes'; const ASSIST_ROUTE = assistRoute(); interface Props { - fetchFilterSearch: (query: any) => void; - addFilterByKeyAndValue: (key: string, value: string) => void; - filterList: any; - filterListLive: any; - filterSearchListLive: any; - filterSearchList: any; + fetchFilterSearch: (query: any) => void; + addFilterByKeyAndValue: (key: string, value: string) => void; + filterList: any; + filterListLive: any; + filterSearchListLive: any; + filterSearchList: any; } function SessionSearchField(props: Props) { - const debounceFetchFilterSearch = React.useCallback(debounce(props.fetchFilterSearch, 1000), []); - const [showModal, setShowModal] = useState(false) - const [searchQuery, setSearchQuery] = useState('') + const debounceFetchFilterSearch = React.useCallback(debounce(props.fetchFilterSearch, 1000), []); + const [showModal, setShowModal] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); - const onSearchChange = ({ target: { value } }: any) => { - setSearchQuery(value) - debounceFetchFilterSearch({ q: value }); - } + const onSearchChange = ({ target: { value } }: any) => { + setSearchQuery(value); + debounceFetchFilterSearch({ q: value }); + }; - const onAddFilter = (filter: any) => { - props.addFilterByKeyAndValue(filter.key, filter.value) - } + const onAddFilter = (filter: any) => { + props.addFilterByKeyAndValue(filter.key, filter.value); + }; - return ( - <div className="relative"> - <Input - icon="search" - onFocus={ () => setShowModal(true) } - onBlur={ () => setTimeout(setShowModal, 200, false) } - onChange={ onSearchChange } - placeholder={ 'Search sessions using any captured event (click, input, page, error...)'} - id="search" - type="search" - autoComplete="off" - className="hover:border-gray-medium" - /> + return ( + <div className="relative"> + <Input + icon="search" + onFocus={() => setShowModal(true)} + onBlur={() => setTimeout(setShowModal, 200, false)} + onChange={onSearchChange} + placeholder={'Search sessions using any captured event (click, input, page, error...)'} + id="search" + type="search" + autoComplete="off" + className="hover:border-gray-medium text-lg placeholder-lg" + /> - { showModal && ( - <div className="absolute left-0 border shadow rounded bg-white z-50"> - <FilterModal - searchQuery={searchQuery} - isMainSearch={true} - onFilterClick={onAddFilter} - isLive={isRoute(ASSIST_ROUTE, window.location.pathname)} - // filters={isRoute(ASSIST_ROUTE, window.location.pathname) ? props.filterListLive : props.filterList } - // filterSearchList={isRoute(ASSIST_ROUTE, window.location.pathname) ? props.filterSearchListLive : props.filterSearchList } - /> + {showModal && ( + <div className="absolute left-0 border shadow rounded bg-white z-50"> + <FilterModal + searchQuery={searchQuery} + isMainSearch={true} + onFilterClick={onAddFilter} + isLive={isRoute(ASSIST_ROUTE, window.location.pathname)} + // filters={isRoute(ASSIST_ROUTE, window.location.pathname) ? props.filterListLive : props.filterList } + // filterSearchList={isRoute(ASSIST_ROUTE, window.location.pathname) ? props.filterSearchListLive : props.filterSearchList } + /> + </div> + )} </div> - )} - </div> - ); + ); } -export default connect((state: any) => ({ - filterSearchList: state.getIn([ 'search', 'filterSearchList' ]), - filterSearchListLive: state.getIn([ 'liveSearch', 'filterSearchList' ]), - filterList: state.getIn([ 'search', 'filterList' ]), - filterListLive: state.getIn([ 'search', 'filterListLive' ]), -}), { })(SessionSearchField); +export default connect( + (state: any) => ({ + filterSearchList: state.getIn(['search', 'filterSearchList']), + filterSearchListLive: state.getIn(['liveSearch', 'filterSearchList']), + filterList: state.getIn(['search', 'filterList']), + filterListLive: state.getIn(['search', 'filterListLive']), + }), + {} +)(SessionSearchField); diff --git a/frontend/app/components/shared/SessionSearchQueryParamHandler/SessionSearchQueryParamHandler.tsx b/frontend/app/components/shared/SessionSearchQueryParamHandler/SessionSearchQueryParamHandler.tsx new file mode 100644 index 000000000..647ee68bd --- /dev/null +++ b/frontend/app/components/shared/SessionSearchQueryParamHandler/SessionSearchQueryParamHandler.tsx @@ -0,0 +1,94 @@ +import React, { useEffect } from 'react'; +import { useHistory } from 'react-router'; +import { connect } from 'react-redux'; +import { addFilterByKeyAndValue, addFilter } from 'Duck/search'; +import { getFilterKeyTypeByKey, setQueryParamKeyFromFilterkey } from 'Types/filter/filterType'; +import { filtersMap } from 'App/types/filter/newFilter'; + +interface Props { + appliedFilter: any; + addFilterByKeyAndValue: typeof addFilterByKeyAndValue; + addFilter: typeof addFilter; +} +const SessionSearchQueryParamHandler = React.memo((props: Props) => { + const { appliedFilter } = props; + const history = useHistory(); + + const createUrlQuery = (filters: any) => { + const query: any = {}; + filters.forEach((filter: any) => { + if (filter.value.length > 0) { + const _key = setQueryParamKeyFromFilterkey(filter.key); + if (_key) { + let str = `${filter.operator}|${filter.value.join('|')}`; + if (filter.hasSource) { + str = `${str}^${filter.sourceOperator}|${filter.source.join('|')}`; + } + query[_key] = str; + } else { + let str = `${filter.operator}|${filter.value.join('|')}`; + query[filter.key] = str; + } + } + }); + return query; + }; + + const addFilter = ([key, value]: [any, any]): void => { + if (value !== '') { + const filterKey = getFilterKeyTypeByKey(key); + const tmp = value.split('^'); + const valueArr = tmp[0].split('|'); + const operator = valueArr.shift(); + + const sourceArr = tmp[1] ? tmp[1].split('|') : []; + const sourceOperator = sourceArr.shift(); + // TODO validate operator + if (filterKey) { + props.addFilterByKeyAndValue(filterKey, valueArr, operator, sourceOperator, sourceArr); + } else { + const _filters: any = { ...filtersMap }; + const _filter = _filters[key]; + _filter.value = valueArr; + _filter.operator = operator; + _filter.source = sourceArr; + props.addFilter(_filter); + } + } + }; + + const applyFilterFromQuery = () => { + if (appliedFilter.filters.size > 0) { + return; + } + const entires = getQueryObject(history.location.search); + if (entires.length > 0) { + entires.forEach(addFilter); + } + }; + + const generateUrlQuery = () => { + const query: any = createUrlQuery(appliedFilter.filters); + // const queryString = Object.entries(query).map(([key, value]) => `${key}=${value}`).join('&'); + const queryString = new URLSearchParams(query).toString(); + history.replace({ search: queryString }); + }; + + useEffect(applyFilterFromQuery, []); + useEffect(generateUrlQuery, [appliedFilter]); + return <></>; +}); + +export default connect( + (state: any) => ({ + appliedFilter: state.getIn(['search', 'instance']), + }), + { addFilterByKeyAndValue, addFilter } +)(SessionSearchQueryParamHandler); + +function getQueryObject(search: any) { + const queryParams = Object.fromEntries( + Object.entries(Object.fromEntries(new URLSearchParams(search))) + ); + return Object.entries(queryParams); +} diff --git a/frontend/app/components/shared/SessionSearchQueryParamHandler/index.ts b/frontend/app/components/shared/SessionSearchQueryParamHandler/index.ts new file mode 100644 index 000000000..c13bb493d --- /dev/null +++ b/frontend/app/components/shared/SessionSearchQueryParamHandler/index.ts @@ -0,0 +1 @@ +export { default } from './SessionSearchQueryParamHandler'; \ No newline at end of file diff --git a/frontend/app/components/shared/SharePopup/SharePopup.js b/frontend/app/components/shared/SharePopup/SharePopup.js index 05229acf5..fc379deb7 100644 --- a/frontend/app/components/shared/SharePopup/SharePopup.js +++ b/frontend/app/components/shared/SharePopup/SharePopup.js @@ -3,13 +3,14 @@ import { connect } from 'react-redux'; import { toast } from 'react-toastify'; import { connectPlayer } from 'Player' import withRequest from 'HOCs/withRequest'; -import { Popup, Dropdown, Icon, Button } from 'UI'; +import { Icon, Button } from 'UI'; import styles from './sharePopup.module.css'; import IntegrateSlackButton from '../IntegrateSlackButton/IntegrateSlackButton'; import SessionCopyLink from './SessionCopyLink'; import Select from 'Shared/Select'; import { Tooltip } from 'react-tippy'; import cn from 'classnames'; +import { fetchList, init } from 'Duck/integrations/slack'; @connectPlayer(state => ({ time: state.time, @@ -17,7 +18,7 @@ import cn from 'classnames'; @connect(state => ({ channels: state.getIn([ 'slack', 'list' ]), tenantId: state.getIn([ 'user', 'account', 'tenantId' ]), -})) +}), { fetchList }) @withRequest({ endpoint: ({ id, entity }, integrationId) => `/integrations/slack/notify/${ integrationId }/${entity}/${ id }`, @@ -30,6 +31,12 @@ export default class SharePopup extends React.PureComponent { channelId: this.props.channels.getIn([ 0, 'webhookId' ]), } + componentDidMount() { + if (this.props.channels.size === 0) { + this.props.fetchList(); + } + } + editMessage = e => this.setState({ comment: e.target.value }) share = () => this.props.request({ comment: this.state.comment }, this.state.channelId) .then(this.handleSuccess) diff --git a/frontend/app/components/shared/SortOrderButton/SortOrderButton.tsx b/frontend/app/components/shared/SortOrderButton/SortOrderButton.tsx index 7d7901783..e693864fe 100644 --- a/frontend/app/components/shared/SortOrderButton/SortOrderButton.tsx +++ b/frontend/app/components/shared/SortOrderButton/SortOrderButton.tsx @@ -14,7 +14,7 @@ export default React.memo(function SortOrderButton(props: Props) { <div className="flex items-center border"> <Popup content={'Ascending'} > <div - className={cn("p-1 hover:bg-active-blue", { 'cursor-pointer bg-white' : !isAscending, 'bg-active-blue pointer-events-none' : isAscending })} + className={cn("p-2 hover:bg-active-blue", { 'cursor-pointer bg-white' : !isAscending, 'bg-active-blue pointer-events-none' : isAscending })} onClick={() => onChange('asc')} > <Icon name="arrow-up" size="14" color={isAscending ? 'teal' : 'gray-medium'} /> @@ -23,7 +23,7 @@ export default React.memo(function SortOrderButton(props: Props) { <Popup content={'Descending'} > <div - className={cn("p-1 hover:bg-active-blue border-l", { 'cursor-pointer bg-white' : isAscending, 'bg-active-blue pointer-events-none' : !isAscending })} + className={cn("p-2 hover:bg-active-blue border-l", { 'cursor-pointer bg-white' : isAscending, 'bg-active-blue pointer-events-none' : !isAscending })} onClick={() => onChange('desc')} > <Icon name="arrow-down" size="14" color={!isAscending ? 'teal' : 'gray-medium'} /> diff --git a/frontend/app/components/shared/SupportCallout/SupportCallout.tsx b/frontend/app/components/shared/SupportCallout/SupportCallout.tsx new file mode 100644 index 000000000..dbcdd5f95 --- /dev/null +++ b/frontend/app/components/shared/SupportCallout/SupportCallout.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +// import SlackIcon from '../../../svg/slack-help.svg'; +import { Popup, Icon } from 'UI'; +import SupportList from './components/SupportList'; + +function SupportCallout() { + return ( + <div className="group transition-all"> + <div className="invisible fixed bottom-0 left-0 pb-20 ml-4 group-hover:visible"> + <SupportList /> + </div> + <div className="fixed z-50 left-0 bottom-0 m-4"> + {/* <Popup content="OpenReplay community" delay={0}> */} + <div className="w-12 h-12 cursor-pointer bg-white border rounded-full flex items-center justify-center group-hover:shadow-lg group-hover:!bg-active-blue"> + <Icon name="question-lg" size={30} color="teal" /> + </div> + {/* </Popup> */} + </div> + </div> + ); +} + +export default SupportCallout; diff --git a/frontend/app/components/shared/SupportCallout/components/SupportList/SupportList.tsx b/frontend/app/components/shared/SupportCallout/components/SupportList/SupportList.tsx new file mode 100644 index 000000000..19222ff31 --- /dev/null +++ b/frontend/app/components/shared/SupportCallout/components/SupportList/SupportList.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { Icon } from 'UI'; + +function SupportList() { + return ( + <div className="rounded bg-white border shadow"> + <a href="https://docs.openreplay.com" target="_blank"> + <div className="flex items-center px-4 py-3 cursor-pointer hover:bg-active-blue"> + <Icon name="book" size={15} /> + <div className="ml-2">Docs</div> + </div> + </a> + <a href="https://github.com/openreplay/openreplay/issues/new/choose" target="_blank"> + <div className="flex items-center px-4 py-3 cursor-pointer hover:bg-active-blue"> + <Icon name="github" size={15} /> + <div className="ml-2">Report Issues</div> + </div> + </a> + <a href="https://slack.openreplay.com" target="_blank"> + <div className="flex items-center px-4 py-3 cursor-pointer hover:bg-active-blue"> + <Icon name="slack" size={15} /> + <div className="ml-2">Community Support</div> + </div> + </a> + </div> + ); +} + +export default SupportList; diff --git a/frontend/app/components/shared/SupportCallout/components/SupportList/index.ts b/frontend/app/components/shared/SupportCallout/components/SupportList/index.ts new file mode 100644 index 000000000..5351ca8b0 --- /dev/null +++ b/frontend/app/components/shared/SupportCallout/components/SupportList/index.ts @@ -0,0 +1 @@ +export { default } from './SupportList'; \ No newline at end of file diff --git a/frontend/app/components/shared/SupportCallout/index.ts b/frontend/app/components/shared/SupportCallout/index.ts new file mode 100644 index 000000000..76db66d7a --- /dev/null +++ b/frontend/app/components/shared/SupportCallout/index.ts @@ -0,0 +1 @@ +export { default } from './SupportCallout'; \ No newline at end of file diff --git a/frontend/app/components/shared/TrackingCodeModal/TrackingCodeModal.js b/frontend/app/components/shared/TrackingCodeModal/TrackingCodeModal.js index cd8c23707..586cc8742 100644 --- a/frontend/app/components/shared/TrackingCodeModal/TrackingCodeModal.js +++ b/frontend/app/components/shared/TrackingCodeModal/TrackingCodeModal.js @@ -3,65 +3,79 @@ import { Modal, Icon, Tabs } from 'UI'; import styles from './trackingCodeModal.module.css'; import { editGDPR, saveGDPR } from 'Duck/site'; import { connect } from 'react-redux'; -import ProjectCodeSnippet from './ProjectCodeSnippet'; +import ProjectCodeSnippet from './ProjectCodeSnippet'; import InstallDocs from './InstallDocs'; import cn from 'classnames'; const PROJECT = 'Using Script'; const DOCUMENTATION = 'Using NPM'; const TABS = [ - { key: DOCUMENTATION, text: DOCUMENTATION }, - { key: PROJECT, text: PROJECT }, + { key: DOCUMENTATION, text: DOCUMENTATION }, + { key: PROJECT, text: PROJECT }, ]; class TrackingCodeModal extends React.PureComponent { - state = { copied: false, changed: false, activeTab: DOCUMENTATION }; + state = { copied: false, changed: false, activeTab: DOCUMENTATION }; - setActiveTab = (tab) => { - this.setState({ activeTab: tab }); - } + setActiveTab = (tab) => { + this.setState({ activeTab: tab }); + }; - renderActiveTab = () => { - const { site } = this.props; - switch (this.state.activeTab) { - case PROJECT: - return <ProjectCodeSnippet />; - case DOCUMENTATION: - return <InstallDocs site={site} />; + renderActiveTab = () => { + const { site } = this.props; + switch (this.state.activeTab) { + case PROJECT: + return <ProjectCodeSnippet />; + case DOCUMENTATION: + return <InstallDocs site={site} />; + } + return null; + }; + + render() { + const { site, displayed, onClose, title = '', subTitle } = this.props; + const { activeTab } = this.state; + return ( + <div className="bg-white h-screen overflow-y-auto" style={{ width: '700px' }}> + <h3 className="p-5 text-2xl"> + {title} {subTitle && <span className="text-sm color-gray-dark">{subTitle}</span>} + </h3> + + <div> + <Tabs className="px-5" tabs={TABS} active={activeTab} onClick={this.setActiveTab} /> + <div className="p-5">{this.renderActiveTab()}</div> + </div> + </div> + // displayed && + // <Modal size="large" onClose={ onClose } open={ displayed } style={{ top: "85px" }} > + // <Modal.Header className={ styles.modalHeader }> + // <div>{ title } { subTitle && <span className="text-sm color-gray-dark">{subTitle}</span>}</div> + // <div className={ cn(styles.closeButton, { 'hidden' : !onClose }) } role="button" tabIndex="-1" onClick={ onClose }> + // <Icon name="close" size="14" /> + // </div> + // </Modal.Header> + // <Modal.Content className={ cn(styles.content, 'overflow-y-auto') }> + // <Tabs + // className="px-5" + // tabs={ TABS } + // active={ activeTab } onClick={ this.setActiveTab } /> + // <div className="p-5"> + // { this.renderActiveTab() } + // </div> + // </Modal.Content> + // </Modal> + ); } - return null; - } - - render() { - const { site, displayed, onClose, title = '', subTitle } = this.props; - const { activeTab } = this.state; - return ( - displayed && - <Modal size="large" onClose={ onClose } open={ displayed } style={{ top: "85px" }} > - <Modal.Header className={ styles.modalHeader }> - <div>{ title } { subTitle && <span className="text-sm color-gray-dark">{subTitle}</span>}</div> - <div className={ cn(styles.closeButton, { 'hidden' : !onClose }) } role="button" tabIndex="-1" onClick={ onClose }> - <Icon name="close" size="14" /> - </div> - </Modal.Header> - <Modal.Content className={ cn(styles.content, 'overflow-y-auto') }> - <Tabs - className="px-5" - tabs={ TABS } - active={ activeTab } onClick={ this.setActiveTab } /> - <div className="p-5"> - { this.renderActiveTab() } - </div> - </Modal.Content> - </Modal> - ); - } } -export default connect(state => ({ - site: state.getIn([ 'site', 'instance' ]), - gdpr: state.getIn([ 'site', 'instance', 'gdpr' ]), - saving: state.getIn([ 'site', 'saveGDPR', 'loading' ]), -}), { - editGDPR, saveGDPR -})(TrackingCodeModal); \ No newline at end of file +export default connect( + (state) => ({ + site: state.getIn(['site', 'instance']), + gdpr: state.getIn(['site', 'instance', 'gdpr']), + saving: state.getIn(['site', 'saveGDPR', 'loading']), + }), + { + editGDPR, + saveGDPR, + } +)(TrackingCodeModal); diff --git a/frontend/app/components/shared/UserSessionsModal/UserSessionsModal.tsx b/frontend/app/components/shared/UserSessionsModal/UserSessionsModal.tsx index fac7d1b41..5a2b6f618 100644 --- a/frontend/app/components/shared/UserSessionsModal/UserSessionsModal.tsx +++ b/frontend/app/components/shared/UserSessionsModal/UserSessionsModal.tsx @@ -8,6 +8,8 @@ import SessionItem from 'Shared/SessionItem'; import SelectDateRange from 'Shared/SelectDateRange'; import Period from 'Types/app/period'; import { useObserver, observer } from 'mobx-react-lite'; +import { useModal } from 'App/components/Modal'; +import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG'; const PER_PAGE = 10; interface Props { @@ -18,6 +20,7 @@ interface Props { function UserSessionsModal(props: Props) { const { userId, hash, name } = props; const { sessionStore } = useStore(); + const { hideModal } = useModal(); const [loading, setLoading] = React.useState(false); const [data, setData] = React.useState<any>({ sessions: [], total: 0 }); const filter = useObserver(() => sessionStore.userFilter); @@ -59,12 +62,18 @@ function UserSessionsModal(props: Props) { </div> </div> - <NoContent show={data.sessions.length === 0} title={<div>No recordings found.</div>}> + <NoContent show={data.sessions.length === 0} title={ + <div> + <AnimatedSVG name={ICONS.NO_SESSIONS} size={170} /> + <div className="mt-2" /> + <div className="text-center text-gray-600">No recordings found.</div> + </div> + }> <div className="border rounded m-5"> <Loader loading={loading}> {data.sessions.map((session: any) => ( <div className="border-b last:border-none"> - <SessionItem key={session.sessionId} session={session} compact={true} /> + <SessionItem key={session.sessionId} session={session} compact={true} onClick={hideModal} /> </div> ))} </Loader> diff --git a/frontend/app/components/shared/XRayButton/XRayButton.tsx b/frontend/app/components/shared/XRayButton/XRayButton.tsx new file mode 100644 index 000000000..87aee92e0 --- /dev/null +++ b/frontend/app/components/shared/XRayButton/XRayButton.tsx @@ -0,0 +1,80 @@ +import React, { useEffect, useState } from 'react'; +import stl from './xrayButton.module.css'; +import cn from 'classnames'; +import { Popup } from 'UI'; +import GuidePopup, { FEATURE_KEYS } from 'Shared/GuidePopup'; +import { Controls as Player } from 'Player'; +import { INDEXES } from 'App/constants/zindex'; + +interface Props { + onClick?: () => void; + isActive?: boolean; +} +function XRayButton(props: Props) { + const { isActive } = props; + const [showGuide, setShowGuide] = useState(!localStorage.getItem(FEATURE_KEYS.XRAY)); + useEffect(() => { + if (!showGuide) { + return; + } + Player.pause(); + }, []); + + const onClick = () => { + setShowGuide(false); + localStorage.setItem('featureViewed', 'true'); + props.onClick(); + }; + return ( + <> + {showGuide && ( + <div + onClick={() => { + setShowGuide(false); + localStorage.setItem('featureViewed', 'true'); + }} + className="bg-gray-darkest fixed inset-0 z-10 w-full h-screen" + style={{ zIndex: 9999, opacity: '0.7' }} + ></div> + )} + <div className="relative"> + {showGuide ? ( + <GuidePopup + title={<>Introducing <span className={stl.text}>X-Ray</span></>} + description={"Get a quick overview on the issues in this session."} + > + <button + className={cn(stl.wrapper, { [stl.default]: !isActive, [stl.active]: isActive })} + onClick={onClick} + style={{ zIndex: INDEXES.POPUP_GUIDE_BTN, position: 'relative' }} + > + <span className="z-1">X-RAY</span> + </button> + + <div + className="absolute bg-white top-0 left-0 z-0" + style={{ + zIndex: INDEXES.POPUP_GUIDE_BG, + width: '100px', + height: '50px', + borderRadius: '30px', + margin: '-10px -16px', + }} + ></div> + </GuidePopup> + ) : ( + <Popup content="Get a quick overview on the issues in this session." disabled={isActive}> + <button + className={cn(stl.wrapper, { [stl.default]: !isActive, [stl.active]: isActive })} + onClick={onClick} + > + <span className="z-1">X-RAY</span> + </button> + </Popup> + )} + </div> + </> + ); +} + +export default XRayButton; diff --git a/frontend/app/components/shared/XRayButton/index.ts b/frontend/app/components/shared/XRayButton/index.ts new file mode 100644 index 000000000..45f067067 --- /dev/null +++ b/frontend/app/components/shared/XRayButton/index.ts @@ -0,0 +1 @@ +export { default } from './XRayButton'; \ No newline at end of file diff --git a/frontend/app/components/shared/XRayButton/xrayButton.module.css b/frontend/app/components/shared/XRayButton/xrayButton.module.css new file mode 100644 index 000000000..c94b9c2f1 --- /dev/null +++ b/frontend/app/components/shared/XRayButton/xrayButton.module.css @@ -0,0 +1,27 @@ +.wrapper { + text-align: center; + padding: 4px 14px; + border: none; + border-radius: 6px; + font-weight: 500; + + &.default { + color: white; + background: linear-gradient(90deg, rgba(57, 78, 255, 0.87) 0%, rgba(62, 170, 175, 0.87) 100%); + &:hover { + /* color: $teal; */ + background: linear-gradient(90deg, rgba(57, 78, 255, 0.87) 100%, rgba(62, 170, 175, 0.87) 100%); + } + } + + &.active { + background: rgba(63, 81, 181, 0.08); + color: $gray-darkest; + } +} + +.text { + background: linear-gradient(90deg, rgba(57, 78, 255, 0.87) 0%, rgba(62, 170, 175, 0.87) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + } diff --git a/frontend/app/components/ui/Avatar/Avatar.js b/frontend/app/components/ui/Avatar/Avatar.js index be4efdbb9..71327fafa 100644 --- a/frontend/app/components/ui/Avatar/Avatar.js +++ b/frontend/app/components/ui/Avatar/Avatar.js @@ -4,7 +4,7 @@ import { avatarIconName } from 'App/iconNames'; import stl from './avatar.module.css'; import { Icon, Popup } from 'UI'; -const Avatar = ({ isActive = false, isAssist = false, className, width = '38px', height = '38px', iconSize = 26, seed }) => { +const Avatar = ({ isActive = false, isAssist = false, width = '38px', height = '38px', iconSize = 26, seed }) => { var iconName = avatarIconName(seed); return ( <Popup content={isActive ? 'Active user' : 'User might be inactive'} disabled={!isAssist}> diff --git a/frontend/app/components/ui/Button/Button.tsx b/frontend/app/components/ui/Button/Button.tsx index 54e166b47..e7aa306dc 100644 --- a/frontend/app/components/ui/Button/Button.tsx +++ b/frontend/app/components/ui/Button/Button.tsx @@ -8,6 +8,7 @@ interface Props { onClick?: () => void; disabled?: boolean; type?: 'button' | 'submit' | 'reset'; + variant?: 'default' | 'primary' | 'text' | 'text-primary' | 'text-red' | 'outline' | 'green' loading?: boolean; icon?: string; rounded?: boolean; @@ -30,17 +31,23 @@ export default (props: Props) => { } = props; let classes = ['relative flex items-center h-10 px-3 rounded tracking-wide whitespace-nowrap']; + let iconColor = variant === 'text' || variant === 'default' ? 'gray-dark' : 'teal'; if (variant === 'default') { - classes.push('bg-white hover:bg-gray-lightest border border-gray-light'); + classes.push('bg-white hover:bg-gray-light border border-gray-light'); } if (variant === 'primary') { classes.push('bg-teal color-white hover:bg-teal-dark'); } + if (variant === 'green') { + classes.push('bg-green color-white hover:bg-green-dark'); + iconColor = 'white'; + } + if (variant === 'text') { - classes.push('bg-transparent color-gray-dark hover:bg-gray-lightest hover:color-gray-dark'); + classes.push('bg-transparent color-gray-dark hover:bg-gray-light hover:color-gray-dark'); } if (variant === 'text-primary') { @@ -59,7 +66,6 @@ export default (props: Props) => { classes.push('opacity-40 pointer-events-none'); } - let iconColor = variant === 'text' || variant === 'default' ? 'gray-dark' : 'teal'; if (variant === 'primary') { iconColor = 'white'; } diff --git a/frontend/app/components/ui/Checkbox/Checkbox.tsx b/frontend/app/components/ui/Checkbox/Checkbox.tsx index 0781183b1..2b68ccc97 100644 --- a/frontend/app/components/ui/Checkbox/Checkbox.tsx +++ b/frontend/app/components/ui/Checkbox/Checkbox.tsx @@ -2,19 +2,16 @@ import React from 'react'; import cn from 'classnames'; interface Props { - classNam?: string; - label?: string; - [x: string]: any; + classNam?: string; + label?: string; + [x: string]: any; } export default (props: Props) => { - const { className = '', label = '', ...rest } = props; - return ( - <label className={ cn("flex items-center cursor-pointer", className)}> - <input - type="checkbox" - { ...rest } - /> - {label && <span className="ml-2 select-none mb-0">{label}</span>} - </label> - ) -}; \ No newline at end of file + const { className = '', label = '', ...rest } = props; + return ( + <label className={cn('flex items-center cursor-pointer', className)}> + <input type="checkbox" {...rest} /> + {label && <span className="ml-2 select-none mb-0">{label}</span>} + </label> + ); +}; diff --git a/frontend/app/components/ui/CountryFlag/CountryFlag.js b/frontend/app/components/ui/CountryFlag/CountryFlag.js index 632775859..09192f070 100644 --- a/frontend/app/components/ui/CountryFlag/CountryFlag.js +++ b/frontend/app/components/ui/CountryFlag/CountryFlag.js @@ -4,7 +4,7 @@ import { countries } from 'App/constants'; import { Icon } from 'UI'; import stl from './countryFlag.module.css'; -const CountryFlag = React.memo(({ country, className, style = {}, label = false }) => { +const CountryFlag = ({ country = '', className = '', style = {}, label = false }) => { const knownCountry = !!country && country !== 'UN'; const countryFlag = knownCountry ? country.toLowerCase() : ''; const countryName = knownCountry ? countries[ country ] : 'Unknown Country'; @@ -22,8 +22,8 @@ const CountryFlag = React.memo(({ country, className, style = {}, label = false { knownCountry && label && <div className={ cn(stl.label, 'ml-1') }>{ countryName }</div> } </div> ); -}) +} CountryFlag.displayName = "CountryFlag"; -export default CountryFlag; +export default React.memo(CountryFlag); diff --git a/frontend/app/components/ui/ErrorDetails/ErrorDetails.js b/frontend/app/components/ui/ErrorDetails/ErrorDetails.js deleted file mode 100644 index 2a6afdd1e..000000000 --- a/frontend/app/components/ui/ErrorDetails/ErrorDetails.js +++ /dev/null @@ -1,65 +0,0 @@ -import React, { useState } from 'react' -import ErrorFrame from '../ErrorFrame/ErrorFrame' -import cn from 'classnames'; -import { IconButton, Icon } from 'UI'; -import { connect } from 'react-redux'; - -const docLink = 'https://docs.openreplay.com/installation/upload-sourcemaps'; - -function ErrorDetails({ className, name = "Error", message, errorStack, sourcemapUploaded }) { - const [showRaw, setShowRaw] = useState(false) - const firstFunc = errorStack.first() && errorStack.first().function - - const openDocs = () => { - window.open(docLink, '_blank'); - } - - return ( - <div className={className} > - { !sourcemapUploaded && ( - <div - style={{ backgroundColor: 'rgba(204, 0, 0, 0.1)' }} - className="font-normal flex items-center text-sm font-regular color-red border p-2 rounded" - > - <Icon name="info" size="16" color="red" /> - <div className="ml-2">Source maps must be uploaded to OpenReplay to be able to see stack traces. <a href="#" className="color-red font-medium underline" style={{ textDecoration: 'underline' }} onClick={openDocs}>Learn more.</a></div> - </div> - ) } - <div className="flex items-center my-3"> - <h3 className="text-xl mr-auto"> - Stacktrace - </h3> - <div className="flex justify-end mr-2"> - <IconButton - onClick={() => setShowRaw(false) } - label="FULL" - plain={!showRaw} - primaryText={!showRaw} - /> - <IconButton - primaryText={showRaw} - onClick={() => setShowRaw(true) } - plain={showRaw} - label="RAW" - /> - </div> - </div> - <div className="mb-6 code-font" data-hidden={showRaw}> - <div className="leading-relaxed font-weight-bold">{ name }</div> - <div style={{ wordBreak: 'break-all'}}>{message}</div> - </div> - { showRaw && - <div className="mb-3 code-font">{name} : {firstFunc ? firstFunc : '?' }</div> - } - { errorStack.map((frame, i) => ( - <div className="mb-3" key={frame.key}> - <ErrorFrame frame={frame} showRaw={showRaw} isFirst={i == 0} /> - </div> - )) - } - </div> - ) -} - -ErrorDetails.displayName = "ErrorDetails"; -export default ErrorDetails; diff --git a/frontend/app/components/ui/ErrorDetails/ErrorDetails.tsx b/frontend/app/components/ui/ErrorDetails/ErrorDetails.tsx new file mode 100644 index 000000000..fe2467f0a --- /dev/null +++ b/frontend/app/components/ui/ErrorDetails/ErrorDetails.tsx @@ -0,0 +1,82 @@ +import React, { useEffect, useState } from 'react'; +import ErrorFrame from '../ErrorFrame/ErrorFrame'; +import { fetchErrorStackList } from 'Duck/sessions'; +import { Button, Icon } from 'UI'; +import { connect } from 'react-redux'; + +const docLink = 'https://docs.openreplay.com/installation/upload-sourcemaps'; + +interface Props { + fetchErrorStackList: any; + sourcemapUploaded?: boolean; + errorStack?: any; + message?: string; + sessionId: string; + error: any; +} +function ErrorDetails(props: Props) { + const { error, sessionId, message = '', errorStack = [], sourcemapUploaded = false } = props; + const [showRaw, setShowRaw] = useState(false); + const firstFunc = errorStack.first() && errorStack.first().function; + + const openDocs = () => { + window.open(docLink, '_blank'); + }; + + useEffect(() => { + props.fetchErrorStackList(sessionId, error.errorId); + }, []); + + return ( + <div className="bg-white p-5 h-screen"> + {!sourcemapUploaded && ( + <div + style={{ backgroundColor: 'rgba(204, 0, 0, 0.1)' }} + className="font-normal flex items-center text-sm font-regular color-red border p-2 rounded" + > + <Icon name="info" size="16" color="red" /> + <div className="ml-2"> + Source maps must be uploaded to OpenReplay to be able to see stack traces.{' '} + <a href="#" className="color-red font-medium underline" style={{ textDecoration: 'underline' }} onClick={openDocs}> + Learn more. + </a> + </div> + </div> + )} + <div className="flex items-center my-3"> + <h3 className="text-xl mr-auto">Stacktrace</h3> + <div className="flex justify-end mr-2"> + <Button variant={!showRaw ? 'text-primary' : 'text'} onClick={() => setShowRaw(false)}> + FULL + </Button> + <Button variant={showRaw ? 'text-primary' : 'text'} onClick={() => setShowRaw(true)}> + RAW + </Button> + </div> + </div> + <div className="mb-6 code-font" data-hidden={showRaw}> + <div className="leading-relaxed font-weight-bold">{error.name}</div> + <div style={{ wordBreak: 'break-all' }}>{message}</div> + </div> + {showRaw && ( + <div className="mb-3 code-font"> + {error.name} : {firstFunc ? firstFunc : '?'} + </div> + )} + {errorStack.map((frame: any, i: any) => ( + <div className="mb-3" key={frame.key}> + <ErrorFrame frame={frame} showRaw={showRaw} isFirst={i == 0} /> + </div> + ))} + </div> + ); +} + +ErrorDetails.displayName = 'ErrorDetails'; +export default connect( + (state: any) => ({ + errorStack: state.getIn(['sessions', 'errorStack']), + sessionId: state.getIn(['sessions', 'current', 'sessionId']), + }), + { fetchErrorStackList } +)(ErrorDetails); diff --git a/frontend/app/components/ui/ErrorItem/ErrorItem.js b/frontend/app/components/ui/ErrorItem/ErrorItem.js index f1145ac71..c74dfbc36 100644 --- a/frontend/app/components/ui/ErrorItem/ErrorItem.js +++ b/frontend/app/components/ui/ErrorItem/ErrorItem.js @@ -1,23 +1,33 @@ -import React from 'react' -import cn from 'classnames' -import { IconButton } from 'UI' +import React from 'react'; +import cn from 'classnames'; +import { IconButton } from 'UI'; import stl from './errorItem.module.css'; +import { Duration } from 'luxon'; -function ErrorItem({ error = {}, onErrorClick, onJump }) { +function ErrorItem({ error = {}, onErrorClick, onJump, inactive, selected }) { return ( - <div className={ cn(stl.wrapper, 'py-3 px-4 flex cursor-pointer') } onClick={onJump}> - <div className="mr-auto"> + <div + className={cn(stl.wrapper, 'py-2 px-4 flex cursor-pointer', { + [stl.inactive]: inactive, + [stl.selected]: selected, + })} + onClick={onJump} + > + <div className={'self-start pr-4 color-red'}> + {Duration.fromMillis(error.time).toFormat('mm:ss.SSS')} + </div> + <div className="mr-auto overflow-hidden"> <div className="color-red mb-1 cursor-pointer code-font"> {error.name} - <span className="color-gray-darkest ml-2">{ error.stack0InfoString }</span> + <span className="color-gray-darkest ml-2">{error.stack0InfoString}</span> </div> <div className="text-sm color-gray-medium">{error.message}</div> </div> - <div className="self-end"> - <IconButton plain onClick={onErrorClick} label="DETAILS" /> + <div className="self-center"> + <IconButton red onClick={onErrorClick} label="DETAILS" /> </div> </div> - ) + ); } -export default ErrorItem +export default ErrorItem; diff --git a/frontend/app/components/ui/ErrorItem/errorItem.module.css b/frontend/app/components/ui/ErrorItem/errorItem.module.css index 5a185ed5c..3fcd482d5 100644 --- a/frontend/app/components/ui/ErrorItem/errorItem.module.css +++ b/frontend/app/components/ui/ErrorItem/errorItem.module.css @@ -1,3 +1,11 @@ .wrapper { border-bottom: solid thin $gray-light-shade; +} + +.inactive { + opacity: 0.5; +} + +.selected { + background-color: $teal-light; } \ No newline at end of file diff --git a/frontend/app/components/ui/Form/Form.tsx b/frontend/app/components/ui/Form/Form.tsx index c9ab7c036..a85af0b23 100644 --- a/frontend/app/components/ui/Form/Form.tsx +++ b/frontend/app/components/ui/Form/Form.tsx @@ -2,16 +2,15 @@ import React from 'react'; interface Props { children: React.ReactNode; - onSubmit?: any - [x: string]: any + onSubmit?: any; + [x: string]: any; } - interface FormFieldProps { children: React.ReactNode; - [x: string]: any + [x: string]: any; } -function FormField (props: FormFieldProps) { +function FormField(props: FormFieldProps) { const { children, ...rest } = props; return ( <div {...rest} className="flex flex-col mb-4 form-field"> @@ -20,16 +19,18 @@ function FormField (props: FormFieldProps) { ); } - function Form(props: Props) { const { children, ...rest } = props; return ( - <form {...rest} onSubmit={(e) => { - e.preventDefault(); - if (props.onSubmit) { - props.onSubmit(e); - } - }}> + <form + {...rest} + onSubmit={(e) => { + e.preventDefault(); + if (props.onSubmit) { + props.onSubmit(e); + } + }} + > {children} </form> ); @@ -37,4 +38,4 @@ function Form(props: Props) { Form.Field = FormField; -export default Form; \ No newline at end of file +export default Form; diff --git a/frontend/app/components/ui/Icon/Icon.tsx b/frontend/app/components/ui/Icon/Icon.tsx index 745d6412d..74e91e1ed 100644 --- a/frontend/app/components/ui/Icon/Icon.tsx +++ b/frontend/app/components/ui/Icon/Icon.tsx @@ -1,10 +1,10 @@ import React from 'react'; import cn from 'classnames'; -import SVG from 'UI/SVG'; +import SVG, { IconNames } from 'UI/SVG'; import styles from './icon.module.css'; -interface IProps { - name: string +interface IProps { + name: IconNames size?: number | string height?: number width?: number diff --git a/frontend/app/components/ui/Input/Input.tsx b/frontend/app/components/ui/Input/Input.tsx index 1897ece13..1c36f7a8a 100644 --- a/frontend/app/components/ui/Input/Input.tsx +++ b/frontend/app/components/ui/Input/Input.tsx @@ -11,13 +11,14 @@ interface Props { rows?: number; [x: string]: any; } -function Input(props: Props) { +const Input = React.forwardRef((props: Props, ref: any) => { const { className = '', leadingButton = '', wrapperClassName = '', icon = '', type = 'text', rows = 4, ...rest } = props; return ( <div className={cn({ relative: icon || leadingButton }, wrapperClassName)}> {icon && <Icon name={icon} className="absolute top-0 bottom-0 my-auto ml-4" size="14" />} {type === 'textarea' ? ( <textarea + ref={ref} rows={rows} style={{ resize: 'none' }} maxLength={500} @@ -26,6 +27,7 @@ function Input(props: Props) { /> ) : ( <input + ref={ref} type={type} style={{ height: '36px' }} className={cn('p-2 border border-gray-light bg-white w-full rounded', className, { 'pl-10': icon })} @@ -36,6 +38,6 @@ function Input(props: Props) { {leadingButton && <div className="absolute top-0 bottom-0 right-0">{leadingButton}</div>} </div> ); -} +}); export default Input; diff --git a/frontend/app/components/ui/LinkStyledInput/LinkStyledInput.js b/frontend/app/components/ui/LinkStyledInput/LinkStyledInput.js index eb579507e..110d0e6d7 100644 --- a/frontend/app/components/ui/LinkStyledInput/LinkStyledInput.js +++ b/frontend/app/components/ui/LinkStyledInput/LinkStyledInput.js @@ -46,7 +46,7 @@ export default class LinkStyledInput extends React.PureComponent { document.removeEventListener('click', this.onEndChange, false); this.setState({ changing: false, - value: this.state.value.trim(), + value: this.state.value ? this.state.value.trim() : undefined, }); } diff --git a/frontend/app/components/ui/Loader/Loader.js b/frontend/app/components/ui/Loader/Loader.js deleted file mode 100644 index d3b23eb72..000000000 --- a/frontend/app/components/ui/Loader/Loader.js +++ /dev/null @@ -1,15 +0,0 @@ -import React from 'react'; -import cn from 'classnames'; -import styles from './loader.module.css'; -import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG'; - -const Loader = React.memo(({ className = '', loading = true, children = null, size = 30, style = { minHeight: '150px' } }) => (!loading ? children : - <div className={ cn(styles.wrapper, className) } style={style}> - {/* <div className={ styles.loader } data-size={ size } /> */} - <AnimatedSVG name={ICONS.LOADER} size={size} /> - </div> -)); - -Loader.displayName = 'Loader'; - -export default Loader; diff --git a/frontend/app/components/ui/Loader/Loader.tsx b/frontend/app/components/ui/Loader/Loader.tsx new file mode 100644 index 000000000..50a4eeb46 --- /dev/null +++ b/frontend/app/components/ui/Loader/Loader.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import cn from 'classnames'; +import styles from './loader.module.css'; +import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG'; + +interface Props { + className?: string + loading?: boolean + children?: React.ReactNode + size?: number + style?: Record<string, any> +} + +const Loader = React.memo<Props>( + ({ + className = '', + loading = true, + children = null, + size = 50, + style = { minHeight: '150px' }, + }) => + !loading ? ( + <> + {children} + </> + ) : ( + <div className={cn(styles.wrapper, className)} style={style}> + {/* <div className={ styles.loader } data-size={ size } /> */} + <AnimatedSVG name={ICONS.LOADER} size={size} /> + </div> + ) +); + +Loader.displayName = 'Loader'; + +export default Loader; diff --git a/frontend/app/components/ui/Message/Message.js b/frontend/app/components/ui/Message/Message.js index ec85e7a96..f8417c25f 100644 --- a/frontend/app/components/ui/Message/Message.js +++ b/frontend/app/components/ui/Message/Message.js @@ -1,16 +1,31 @@ import React from 'react'; import styles from './message.module.css'; import { Icon } from 'UI'; +import cn from 'classnames'; -const Message = ({ hidden = false, visible = false, children, inline=false, success=false, info=true, text }) => (visible || !hidden) ? ( - <div className={ styles.message } data-inline={ inline }> - <Icon name="check" color='green' /> - { text - ? text - : children - } - </div>) : null; +// TODO this has to be improved +const Message = ({ + icon = 'check', + hidden = false, + visible = false, + children, + inline = false, + success = false, + info = true, + text, +}) => + visible || !hidden ? ( + <div className={cn(styles.message, 'flex items-center')} data-inline={inline}> + <Icon + name={success ? 'check' : 'close'} + color={success ? 'green' : 'red'} + className="mr-2" + size={success ? 20 : 14} + /> + {text ? text : children} + </div> + ) : null; -Message.displayName = "Message"; +Message.displayName = 'Message'; -export default Message; \ No newline at end of file +export default Message; diff --git a/frontend/app/components/ui/Modal/Modal.tsx b/frontend/app/components/ui/Modal/Modal.tsx index 2e4812400..89ba9f5d9 100644 --- a/frontend/app/components/ui/Modal/Modal.tsx +++ b/frontend/app/components/ui/Modal/Modal.tsx @@ -38,7 +38,7 @@ function Modal(props: Props) { return open ? ( <div className="fixed inset-0 flex items-center justify-center box-shadow animate-fade-in" - style={{ zIndex: '999', backgroundColor: 'rgba(0, 0, 0, 0.2)'}} + style={{ zIndex: '9999', backgroundColor: 'rgba(0, 0, 0, 0.2)'}} onClick={handleClose} > <div className="absolute z-10 bg-white rounded border" style={style}> diff --git a/frontend/app/components/ui/NoContent/NoContent.js b/frontend/app/components/ui/NoContent/NoContent.js index a82b8b99b..e69de29bb 100644 --- a/frontend/app/components/ui/NoContent/NoContent.js +++ b/frontend/app/components/ui/NoContent/NoContent.js @@ -1,30 +0,0 @@ -import React from 'react'; -import { Icon } from 'UI'; -import styles from './noContent.module.css'; - -export default ({ - title = <div>No data available.</div>, - subtext, - icon, - iconSize = 100, - size, - show = true, - children = null, - empty = false, - image = null, - style = {}, -}) => (!show ? children : -<div className={ `${ styles.wrapper } ${ size && styles[ size ] }` } style={style}> - { - icon && <Icon name={icon} size={iconSize} /> - } - { title && <div className={ styles.title }>{ title }</div> } - { - subtext && - <div className={ styles.subtext }>{ subtext }</div> - } - { - image && <div className="mt-4 flex justify-center">{ image } </div> - } -</div> -); diff --git a/frontend/app/components/ui/NoContent/NoContent.tsx b/frontend/app/components/ui/NoContent/NoContent.tsx new file mode 100644 index 000000000..ae26731be --- /dev/null +++ b/frontend/app/components/ui/NoContent/NoContent.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { Icon } from 'UI'; +import styles from './noContent.module.css'; + +interface Props { + title?: any; + subtext?: any; + icon?: string; + iconSize?: number; + size?: string; + show?: boolean; + children?: any; + image?: any; + style?: any; +} +export default function NoContent(props: Props) { + const { title = '', subtext = '', icon, iconSize, size, show, children, image, style } = props; + + return !show ? ( + children + ) : ( + <div className={`${styles.wrapper} ${size && styles[size]}`} style={style}> + {icon && <Icon name={icon} size={iconSize} />} + {title && <div className={styles.title}>{title}</div>} + {subtext && <div className={styles.subtext}>{subtext}</div>} + {image && <div className="mt-4 flex justify-center">{image} </div>} + </div> + ); +} diff --git a/frontend/app/components/ui/NoContent/index.js b/frontend/app/components/ui/NoContent/index.ts similarity index 100% rename from frontend/app/components/ui/NoContent/index.js rename to frontend/app/components/ui/NoContent/index.ts diff --git a/frontend/app/components/ui/NoContent/noContent.module.css b/frontend/app/components/ui/NoContent/noContent.module.css index 5cf7a0d24..91c29e579 100644 --- a/frontend/app/components/ui/NoContent/noContent.module.css +++ b/frontend/app/components/ui/NoContent/noContent.module.css @@ -7,12 +7,12 @@ align-items: center; flex-direction: column; justify-content: center; - color: $gray-medium; - font-weight: 300; + color: $gray-dark; + /* font-weight: 500; */ transition: all 0.2s; - padding-top: 40px; + padding: 40px; - &.small { + /* &.small { & .title { font-size: 20px !important; } @@ -20,17 +20,18 @@ & .subtext { font-size: 16px; } - } + } */ } .title { - font-size: 32px; - margin-bottom: 15px; + font-size: 16px; + font-weight: 500; + /* margin-bottom: 15px; */ } .subtext { - font-size: 16px; - margin-bottom: 20px; + font-size: 14px; + /* margin-bottom: 20px; */ } @@ -45,15 +46,3 @@ height: 166px; margin-bottom: 20px; } - -.empty-state { - display: block; - margin: auto; - background-image: svg-load(empty-state.svg, fill=#CCC); - background-repeat: no-repeat; - background-size: contain; - background-position: center center; - width: 166px; - height: 166px; - margin-bottom: 20px; -} diff --git a/frontend/app/components/ui/PageTitle/PageTitle.tsx b/frontend/app/components/ui/PageTitle/PageTitle.tsx index c1500a5af..50047ac03 100644 --- a/frontend/app/components/ui/PageTitle/PageTitle.tsx +++ b/frontend/app/components/ui/PageTitle/PageTitle.tsx @@ -17,7 +17,7 @@ function PageTitle({ title, actionButton = null, subTitle = '', className = '', <h1 className={cn("text-2xl capitalize-first", className)} onDoubleClick={onDoubleClick} onClick={onClick}> {title} </h1> - { actionButton && actionButton} + { actionButton && <div className="ml-2">{actionButton}</div> } </div> {subTitle && <h2 className={cn("my-4 font-normal color-gray-dark", subTitleClass)}>{subTitle}</h2>} </div> diff --git a/frontend/app/components/ui/Pagination/Pagination.tsx b/frontend/app/components/ui/Pagination/Pagination.tsx index b36d2b397..6d131fa96 100644 --- a/frontend/app/components/ui/Pagination/Pagination.tsx +++ b/frontend/app/components/ui/Pagination/Pagination.tsx @@ -5,21 +5,21 @@ import cn from 'classnames' import { debounce } from 'App/utils'; import { numberWithCommas } from 'App/utils'; interface Props { - page: number - totalPages: number - onPageChange: (page: number) => void - limit?: number - debounceRequest?: number + page: number + totalPages: number + onPageChange: (page: number) => void + limit?: number + debounceRequest?: number } export default function Pagination(props: Props) { - const { page, totalPages, onPageChange, limit = 5, debounceRequest = 0 } = props; - const [currentPage, setCurrentPage] = React.useState(page); - React.useMemo( - () => setCurrentPage(page), - [page], - ); + const { page, totalPages, onPageChange, limit = 5, debounceRequest = 0 } = props; + const [currentPage, setCurrentPage] = React.useState(page); + React.useMemo( + () => setCurrentPage(page), + [page], + ); - const debounceChange = React.useCallback(debounce(onPageChange, debounceRequest), []); + const debounceChange = React.useCallback(debounce(onPageChange, debounceRequest), []); const changePage = (page: number) => { if (page > 0 && page <= totalPages) { @@ -33,7 +33,7 @@ export default function Pagination(props: Props) { return ( <div className="flex items-center"> <Popup - content="Previous Page" + content="Previous Page" // hideOnClick={true} animation="none" delay={1500} diff --git a/frontend/app/components/ui/Popup/Popup.tsx b/frontend/app/components/ui/Popup/Popup.tsx index fba21fd4e..0bf9258af 100644 --- a/frontend/app/components/ui/Popup/Popup.tsx +++ b/frontend/app/components/ui/Popup/Popup.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Tooltip, Theme, Trigger, Position } from 'react-tippy'; +import { Tooltip, Theme, Trigger, Position, Animation } from 'react-tippy'; interface Props { content?: any; @@ -9,14 +9,13 @@ interface Props { className?: string; delay?: number; hideDelay?: number; - duration?: number; disabled?: boolean; arrow?: boolean; - open?: boolean; style?: any; theme?: Theme; interactive?: boolean; children?: any; + animation?: Animation; // [x:string]: any; } export default ({ @@ -24,19 +23,21 @@ export default ({ title = '', className = '', trigger = 'mouseenter', - delay = 1000, + delay = 0, hideDelay = 0, content = '', - duration = 0, disabled = false, - arrow = true, + arrow = false, theme = 'dark', style = {}, interactive = false, children, + animation = 'fade', }: // ...props Props) => ( + // @ts-ignore <Tooltip + animation={animation} position={position} className={className} trigger={trigger} @@ -49,7 +50,6 @@ Props) => ( theme={theme} style={style} interactive={interactive} - duration={0} hideDelay={hideDelay} > {children} diff --git a/frontend/app/components/ui/QuestionMarkHint/QuestionMarkHint.js b/frontend/app/components/ui/QuestionMarkHint/QuestionMarkHint.js index 5a974d44c..793edf95d 100644 --- a/frontend/app/components/ui/QuestionMarkHint/QuestionMarkHint.js +++ b/frontend/app/components/ui/QuestionMarkHint/QuestionMarkHint.js @@ -2,7 +2,7 @@ import React from 'react'; import cn from "classnames"; import { Icon, Popup } from 'UI'; -export default function QuestionMarkHint({ onHover = false, content, className, ...props }) { +export default function QuestionMarkHint({ onHover = false, content, ...props }) { return ( <Popup trigger={ onHover ? 'mouseenter' : 'click'} @@ -10,7 +10,7 @@ export default function QuestionMarkHint({ onHover = false, content, className, interactive { ...props } > - <Icon name="question-circle" size="18" className={ cn("cursor-pointer", className) }/> + <Icon name="question-circle" size="18" className={ cn("cursor-pointer") }/> </Popup> ); } \ No newline at end of file diff --git a/frontend/app/components/ui/SVG.tsx b/frontend/app/components/ui/SVG.tsx index 8f7bab660..4b179f855 100644 --- a/frontend/app/components/ui/SVG.tsx +++ b/frontend/app/components/ui/SVG.tsx @@ -1,8 +1,10 @@ import React from 'react'; +export type IconNames = 'alarm-clock' | 'alarm-plus' | 'all-sessions' | 'analytics' | 'anchor' | 'arrow-alt-square-right' | 'arrow-clockwise' | 'arrow-down' | 'arrow-right-short' | 'arrow-square-left' | 'arrow-square-right' | 'arrow-up' | 'arrows-angle-extend' | 'avatar/icn_bear' | 'avatar/icn_beaver' | 'avatar/icn_bird' | 'avatar/icn_bison' | 'avatar/icn_camel' | 'avatar/icn_chameleon' | 'avatar/icn_deer' | 'avatar/icn_dog' | 'avatar/icn_dolphin' | 'avatar/icn_elephant' | 'avatar/icn_fish' | 'avatar/icn_fox' | 'avatar/icn_gorilla' | 'avatar/icn_hippo' | 'avatar/icn_horse' | 'avatar/icn_hyena' | 'avatar/icn_kangaroo' | 'avatar/icn_lemur' | 'avatar/icn_mammel' | 'avatar/icn_monkey' | 'avatar/icn_moose' | 'avatar/icn_panda' | 'avatar/icn_penguin' | 'avatar/icn_porcupine' | 'avatar/icn_quail' | 'avatar/icn_rabbit' | 'avatar/icn_rhino' | 'avatar/icn_sea_horse' | 'avatar/icn_sheep' | 'avatar/icn_snake' | 'avatar/icn_squirrel' | 'avatar/icn_tapir' | 'avatar/icn_turtle' | 'avatar/icn_vulture' | 'avatar/icn_wild1' | 'avatar/icn_wild_bore' | 'ban' | 'bar-chart-line' | 'bar-pencil' | 'bell-plus' | 'bell' | 'binoculars' | 'book' | 'browser/browser' | 'browser/chrome' | 'browser/edge' | 'browser/electron' | 'browser/facebook' | 'browser/firefox' | 'browser/ie' | 'browser/opera' | 'browser/safari' | 'bullhorn' | 'business-time' | 'calendar-alt' | 'calendar-check' | 'calendar-day' | 'calendar' | 'camera-alt' | 'camera-video-off' | 'camera-video' | 'camera' | 'caret-down-fill' | 'caret-left-fill' | 'caret-right-fill' | 'caret-up-fill' | 'chat-dots' | 'chat-right-text' | 'chat-square-quote' | 'check-circle' | 'check' | 'chevron-double-left' | 'chevron-double-right' | 'chevron-down' | 'chevron-left' | 'chevron-right' | 'chevron-up' | 'circle-fill' | 'circle' | 'clipboard-list-check' | 'clock' | 'close' | 'cloud-fog2-fill' | 'code' | 'cog' | 'cogs' | 'collection' | 'columns-gap-filled' | 'columns-gap' | 'console/error' | 'console/exception' | 'console/info' | 'console/warning' | 'console' | 'controller' | 'cookies' | 'copy' | 'credit-card-front' | 'cubes' | 'dashboard-icn' | 'desktop' | 'device' | 'diagram-3' | 'dizzy' | 'doublecheck' | 'download' | 'drag' | 'edit' | 'ellipsis-v' | 'enter' | 'envelope' | 'errors-icon' | 'event/click' | 'event/clickrage' | 'event/code' | 'event/i-cursor' | 'event/input' | 'event/link' | 'event/location' | 'event/resize' | 'event/view' | 'exclamation-circle' | 'expand-wide' | 'explosion' | 'external-link-alt' | 'eye-slash-fill' | 'eye-slash' | 'eye' | 'fetch' | 'file-code' | 'file-medical-alt' | 'file' | 'filter' | 'filters/arrow-return-right' | 'filters/browser' | 'filters/click' | 'filters/clickrage' | 'filters/code' | 'filters/console' | 'filters/country' | 'filters/cpu-load' | 'filters/custom' | 'filters/device' | 'filters/dom-complete' | 'filters/duration' | 'filters/error' | 'filters/fetch-failed' | 'filters/fetch' | 'filters/file-code' | 'filters/graphql' | 'filters/i-cursor' | 'filters/input' | 'filters/lcpt' | 'filters/link' | 'filters/location' | 'filters/memory-load' | 'filters/metadata' | 'filters/os' | 'filters/perfromance-network-request' | 'filters/platform' | 'filters/referrer' | 'filters/resize' | 'filters/rev-id' | 'filters/state-action' | 'filters/ttfb' | 'filters/user-alt' | 'filters/userid' | 'filters/view' | 'flag-na' | 'fullscreen' | 'funnel/cpu-fill' | 'funnel/cpu' | 'funnel/dizzy' | 'funnel/emoji-angry-fill' | 'funnel/emoji-angry' | 'funnel/emoji-dizzy-fill' | 'funnel/exclamation-circle-fill' | 'funnel/exclamation-circle' | 'funnel/file-earmark-break-fill' | 'funnel/file-earmark-break' | 'funnel/file-earmark-minus-fill' | 'funnel/file-earmark-minus' | 'funnel/file-medical-alt' | 'funnel/file-x' | 'funnel/hdd-fill' | 'funnel/hourglass-top' | 'funnel/image-fill' | 'funnel/image' | 'funnel/microchip' | 'funnel/mouse' | 'funnel/patch-exclamation-fill' | 'funnel/sd-card' | 'funnel-fill' | 'funnel-new' | 'funnel' | 'geo-alt-fill-custom' | 'github' | 'graph-up-arrow' | 'graph-up' | 'grid-3x3' | 'grid-check' | 'grip-horizontal' | 'hash' | 'hdd-stack' | 'headset' | 'heart-rate' | 'high-engagement' | 'history' | 'hourglass-start' | 'id-card' | 'image' | 'info-circle-fill' | 'info-circle' | 'info-square' | 'info' | 'inspect' | 'integrations/assist' | 'integrations/bugsnag-text' | 'integrations/bugsnag' | 'integrations/cloudwatch-text' | 'integrations/cloudwatch' | 'integrations/datadog' | 'integrations/elasticsearch-text' | 'integrations/elasticsearch' | 'integrations/github' | 'integrations/graphql' | 'integrations/jira-text' | 'integrations/jira' | 'integrations/mobx' | 'integrations/newrelic-text' | 'integrations/newrelic' | 'integrations/ngrx' | 'integrations/openreplay-text' | 'integrations/openreplay' | 'integrations/redux' | 'integrations/rollbar-text' | 'integrations/rollbar' | 'integrations/segment' | 'integrations/sentry-text' | 'integrations/sentry' | 'integrations/slack-bw' | 'integrations/slack' | 'integrations/stackdriver' | 'integrations/sumologic-text' | 'integrations/sumologic' | 'integrations/vuejs' | 'journal-code' | 'layer-group' | 'lightbulb-on' | 'lightbulb' | 'link-45deg' | 'list-alt' | 'list-ul' | 'list' | 'lock-alt' | 'map-marker-alt' | 'memory' | 'mic-mute' | 'mic' | 'minus' | 'mobile' | 'mouse-alt' | 'next1' | 'no-dashboard' | 'no-metrics-chart' | 'no-metrics' | 'os/android' | 'os/chrome_os' | 'os/fedora' | 'os/ios' | 'os/linux' | 'os/mac_os_x' | 'os/other' | 'os/ubuntu' | 'os/windows' | 'os' | 'pause-fill' | 'pause' | 'pdf-download' | 'pencil-stop' | 'pencil' | 'percent' | 'performance-icon' | 'person-fill' | 'person' | 'pie-chart-fill' | 'pin-fill' | 'play-circle-light' | 'play-circle' | 'play-fill-new' | 'play-fill' | 'play-hover' | 'play' | 'plus-circle' | 'plus' | 'prev1' | 'puzzle-piece' | 'question-circle' | 'question-lg' | 'quote-left' | 'quote-right' | 'redo-back' | 'redo' | 'remote-control' | 'replay-10' | 'resources-icon' | 'safe-fill' | 'safe' | 'sandglass' | 'search' | 'search_notification' | 'server' | 'share-alt' | 'shield-lock' | 'signup' | 'skip-forward-fill' | 'skip-forward' | 'slack' | 'slash-circle' | 'sliders' | 'social/slack' | 'social/trello' | 'spinner' | 'star-solid' | 'star' | 'step-forward' | 'stopwatch' | 'store' | 'sync-alt' | 'table-new' | 'table' | 'tablet-android' | 'tachometer-slow' | 'tachometer-slowest' | 'tags' | 'team-funnel' | 'telephone-fill' | 'telephone' | 'text-paragraph' | 'tools' | 'trash' | 'turtle' | 'user-alt' | 'user-circle' | 'user-friends' | 'users' | 'vendors/graphql' | 'vendors/mobx' | 'vendors/ngrx' | 'vendors/redux' | 'vendors/vuex' | 'web-vitals' | 'wifi' | 'window-alt' | 'window-restore' | 'window-x' | 'window' | 'zoom-in'; + interface Props { - name: string; + name: IconNames; size?: number | string; width?: number | string; height?: number | string; @@ -64,6 +66,7 @@ const SVG = (props: Props) => { case 'avatar/icn_wild_bore': return <svg viewBox="0 0 100 100" width={ `${ width }px` } height={ `${ height }px` } ><path d="M92.9 46.6c-1.5-.5-3.1.3-3.6 1.8-.5 1.6-1.5 2.6-3.1 3.1l-1.4-1.9-.8-7.4c-.4-3.8-2.9-7.1-6.3-8.6l-2.4-1.1c-1.2-2.7-3.1-7.1-5.9-8.2-2.7-1.1-4.9.8-6.3 3.4-4.8-1.1-9.8-.9-14.6.7-4.2 1.4-5.9 1.1-17.9 1.1-6.2 0-11.9 3.4-15 8.7-6.7 4-10.1 9.4-10.2 9.6-1.2 1.9.2 4.3 2.4 4.3.9 0 1.8-.5 2.4-1.3 0 0 1.1-1.7 3.2-3.8 0 2.2.4 3.5 1.2 6.8l-5.9 5.9c-.5.5-.8 1.2-.8 2v11.2c0 1.6 1.3 2.8 2.8 2.8h2.8c1.6 0 2.8-1.3 2.8-2.8s-1.3-2.8-2.8-2.8v-1.5c11.4-3 12.5-3.4 14.4-4.6 0 0-.1-.2 3.4 5.1l-.7 1.4c-.7 1.4-.1 3.1 1.3 3.8 1.4.7 3.1.1 3.8-1.3l1.4-2.8c.5-.9.4-2-.2-2.8L32 60.1c.2-.2.3-.5.5-.7 4.9 4 11 6.6 17.3 5.9l-1 2.6c-.3.7-.2.7-.2 5.3 0 1.6 1.3 2.8 2.8 2.8h2.8c1.6 0 2.8-1.3 2.8-2.8s-1.3-2.8-2.8-2.8v-.9l2-5c.2.1-.8-.6 2.6 2l2.5 7.6c.4 1.1 1.5 1.9 2.7 1.9h1.4c1.6 0 2.8-1.3 2.8-2.8 0-1.3-.9-2.4-2.2-2.7l-2-6.1v-1.6c.8.6 1.6 1.1 2.5 1.6 3.3 1.6 4.5 2.4 6.7 2.9 2.7.6 3.6.3 5.4.6 8 1.3 7.5 1.3 7.8 1.3 1 0 2-.6 2.5-1.6l2.8-5.6c.2-.4.4-1.2.2-2-.2-.6-.2-.6-2.3-3.5 2-1 4-2.9 5.1-6 .5-1.8-.3-3.4-1.8-3.9z"/></svg>; case 'ban': return <svg viewBox="0 0 512 512" width={ `${ width }px` } height={ `${ height }px` } ><path d="M256 8C119.033 8 8 119.033 8 256s111.033 248 248 248 248-111.033 248-248S392.967 8 256 8zM103.265 408.735c-80.622-80.622-84.149-208.957-10.9-293.743l304.644 304.643c-84.804 73.264-213.138 69.706-293.744-10.9zm316.37-11.727L114.992 92.365c84.804-73.263 213.137-69.705 293.743 10.9 80.622 80.621 84.149 208.957 10.9 293.743z"/></svg>; case 'bar-chart-line': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M11 2a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v12h.5a.5.5 0 0 1 0 1H.5a.5.5 0 0 1 0-1H1v-3a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v3h1V7a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v7h1V2zm1 12h2V2h-2v12zm-3 0V7H7v7h2zm-5 0v-3H2v3h2z"/></svg>; + case 'bar-pencil': return <svg viewBox="0 0 59 66" width={ `${ width }px` } height={ `${ height }px` } ><g clipPath="url(#a)"><path d="M40.27 15.736a3.59 3.59 0 0 1 3.591-3.59h7.18a3.59 3.59 0 0 1 3.591 3.59V58.82h1.795a1.796 1.796 0 0 1 0 3.59H2.573a1.795 1.795 0 1 1 0-3.59h1.795V48.05a3.59 3.59 0 0 1 3.59-3.59h7.18a3.59 3.59 0 0 1 3.591 3.59v10.77h3.59V33.687a3.59 3.59 0 0 1 3.59-3.59h7.181a3.59 3.59 0 0 1 3.59 3.59V58.82h3.59V15.736Zm3.591 43.083h7.18V15.736h-7.18V58.82Zm-10.77 0V33.687H25.91V58.82h7.18Zm-17.952 0V48.05h-7.18v10.77h7.18Z"/></g><g clipPath="url(#b)"><path d="M28.613.335a1.145 1.145 0 0 1 1.622 0L37.11 7.21a1.146 1.146 0 0 1 0 1.622L14.193 31.749c-.11.109-.24.195-.385.252L2.35 36.584a1.147 1.147 0 0 1-1.49-1.49l4.584-11.458c.057-.144.143-.275.252-.385L28.613.335ZM26.46 5.729l5.254 5.255 2.963-2.963-5.254-5.255-2.963 2.963Zm3.634 6.875L24.84 7.35 9.945 22.245v.672h1.145a1.146 1.146 0 0 1 1.146 1.145v1.146h1.146a1.146 1.146 0 0 1 1.146 1.146V27.5h.671l14.896-14.896ZM7.726 24.464l-.243.242-3.501 8.757 8.756-3.502.243-.243a1.146 1.146 0 0 1-.745-1.072V27.5H11.09a1.146 1.146 0 0 1-1.145-1.146v-1.146H8.799a1.146 1.146 0 0 1-1.073-.744Z"/></g><defs><clipPath id="a"><path fill="#fff" transform="translate(.778 8.556)" d="M0 0h57.444v57.444H0z"/></clipPath><clipPath id="b"><path fill="#fff" transform="translate(.778)" d="M0 0h36.667v36.667H0z"/></clipPath></defs></svg>; case 'bell-plus': return <svg viewBox="0 0 448 512" width={ `${ width }px` } height={ `${ height }px` } ><path d="M224 480a32 32 0 0 1-32-32h-32a64 64 0 1 0 128 0h-32a32 32 0 0 1-32 32zm209.37-145.19c-28-26.62-49.34-54.48-49.34-148.9 0-79.6-63.37-144.5-144-152.36V16a16 16 0 0 0-32 0v17.56C127.35 41.41 64 106.31 64 185.91c0 94.4-21.41 122.28-49.35 148.9a46.47 46.47 0 0 0-11.27 51.24A47.68 47.68 0 0 0 48 416h352a47.67 47.67 0 0 0 44.62-30 46.47 46.47 0 0 0-11.25-51.19zM400 384H48c-14.22 0-21.35-16.47-11.32-26C71.54 324.8 96 287.66 96 185.91 96 118.53 153.22 64 224 64s128 54.52 128 121.91c0 101.34 24.22 138.68 59.28 172.07C421.37 367.56 414.16 384 400 384zM296 224h-56v-56a8 8 0 0 0-8-8h-16a8 8 0 0 0-8 8v56h-56a8 8 0 0 0-8 8v16a8 8 0 0 0 8 8h56v56a8 8 0 0 0 8 8h16a8 8 0 0 0 8-8v-56h56a8 8 0 0 0 8-8v-16a8 8 0 0 0-8-8z"/></svg>; case 'bell': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M8 16a2 2 0 0 0 2-2H6a2 2 0 0 0 2 2zM8 1.918l-.797.161A4.002 4.002 0 0 0 4 6c0 .628-.134 2.197-.459 3.742-.16.767-.376 1.566-.663 2.258h10.244c-.287-.692-.502-1.49-.663-2.258C12.134 8.197 12 6.628 12 6a4.002 4.002 0 0 0-3.203-3.92L8 1.917zM14.22 12c.223.447.481.801.78 1H1c.299-.199.557-.553.78-1C2.68 10.2 3 6.88 3 6c0-2.42 1.72-4.44 4.005-4.901a1 1 0 1 1 1.99 0A5.002 5.002 0 0 1 13 6c0 .88.32 4.2 1.22 6z"/></svg>; case 'binoculars': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M3 2.5A1.5 1.5 0 0 1 4.5 1h1A1.5 1.5 0 0 1 7 2.5V5h2V2.5A1.5 1.5 0 0 1 10.5 1h1A1.5 1.5 0 0 1 13 2.5v2.382a.5.5 0 0 0 .276.447l.895.447A1.5 1.5 0 0 1 15 7.118V14.5a1.5 1.5 0 0 1-1.5 1.5h-3A1.5 1.5 0 0 1 9 14.5v-3a.5.5 0 0 1 .146-.354l.854-.853V9.5a.5.5 0 0 0-.5-.5h-3a.5.5 0 0 0-.5.5v.793l.854.853A.5.5 0 0 1 7 11.5v3A1.5 1.5 0 0 1 5.5 16h-3A1.5 1.5 0 0 1 1 14.5V7.118a1.5 1.5 0 0 1 .83-1.342l.894-.447A.5.5 0 0 0 3 4.882V2.5zM4.5 2a.5.5 0 0 0-.5.5V3h2v-.5a.5.5 0 0 0-.5-.5h-1zM6 4H4v.882a1.5 1.5 0 0 1-.83 1.342l-.894.447A.5.5 0 0 0 2 7.118V13h4v-1.293l-.854-.853A.5.5 0 0 1 5 10.5v-1A1.5 1.5 0 0 1 6.5 8h3A1.5 1.5 0 0 1 11 9.5v1a.5.5 0 0 1-.146.354l-.854.853V13h4V7.118a.5.5 0 0 0-.276-.447l-.895-.447A1.5 1.5 0 0 1 12 4.882V4h-2v1.5a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5V4zm4-1h2v-.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5V3zm4 11h-4v.5a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5V14zm-8 0H2v.5a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5V14z"/></svg>; @@ -92,6 +95,7 @@ const SVG = (props: Props) => { case 'caret-right-fill': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="m12.14 8.753-5.482 4.796c-.646.566-1.658.106-1.658-.753V3.204a1 1 0 0 1 1.659-.753l5.48 4.796a1 1 0 0 1 0 1.506z"/></svg>; case 'caret-up-fill': return <svg viewBox="0 0 320 512" width={ `${ width }px` } height={ `${ height }px` } ><path d="M288.662 352H31.338c-17.818 0-26.741-21.543-14.142-34.142l128.662-128.662c7.81-7.81 20.474-7.81 28.284 0l128.662 128.662c12.6 12.599 3.676 34.142-14.142 34.142z"/></svg>; case 'chat-dots': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M5 8a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm4 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm3 1a1 1 0 1 0 0-2 1 1 0 0 0 0 2z"/><path d="m2.165 15.803.02-.004c1.83-.363 2.948-.842 3.468-1.105A9.06 9.06 0 0 0 8 15c4.418 0 8-3.134 8-7s-3.582-7-8-7-8 3.134-8 7c0 1.76.743 3.37 1.97 4.6a10.437 10.437 0 0 1-.524 2.318l-.003.011a10.722 10.722 0 0 1-.244.637c-.079.186.074.394.273.362a21.673 21.673 0 0 0 .693-.125zm.8-3.108a1 1 0 0 0-.287-.801C1.618 10.83 1 9.468 1 8c0-3.192 3.004-6 7-6s7 2.808 7 6c0 3.193-3.004 6-7 6a8.06 8.06 0 0 1-2.088-.272 1 1 0 0 0-.711.074c-.387.196-1.24.57-2.634.893a10.97 10.97 0 0 0 .398-2z"/></svg>; + case 'chat-right-text': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M2 1a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h9.586a2 2 0 0 1 1.414.586l2 2V2a1 1 0 0 0-1-1H2zm12-1a2 2 0 0 1 2 2v12.793a.5.5 0 0 1-.854.353l-2.853-2.853a1 1 0 0 0-.707-.293H2a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h12z"/><path d="M3 3.5a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5zM3 6a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9A.5.5 0 0 1 3 6zm0 2.5a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5z"/></svg>; case 'chat-square-quote': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M14 1a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1h-2.5a2 2 0 0 0-1.6.8L8 14.333 6.1 11.8a2 2 0 0 0-1.6-.8H2a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h12zM2 0a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2.5a1 1 0 0 1 .8.4l1.9 2.533a1 1 0 0 0 1.6 0l1.9-2.533a1 1 0 0 1 .8-.4H14a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2z"/><path d="M7.066 4.76A1.665 1.665 0 0 0 4 5.668a1.667 1.667 0 0 0 2.561 1.406c-.131.389-.375.804-.777 1.22a.417.417 0 1 0 .6.58c1.486-1.54 1.293-3.214.682-4.112zm4 0A1.665 1.665 0 0 0 8 5.668a1.667 1.667 0 0 0 2.561 1.406c-.131.389-.375.804-.777 1.22a.417.417 0 1 0 .6.58c1.486-1.54 1.293-3.214.682-4.112z"/></svg>; case 'check-circle': return <svg viewBox="0 0 512 512" width={ `${ width }px` } height={ `${ height }px` } ><path d="M256 8C119.033 8 8 119.033 8 256s111.033 248 248 248 248-111.033 248-248S392.967 8 256 8zm0 464c-118.664 0-216-96.055-216-216 0-118.663 96.055-216 216-216 118.664 0 216 96.055 216 216 0 118.663-96.055 216-216 216zm141.63-274.961L217.15 376.071c-4.705 4.667-12.303 4.637-16.97-.068l-85.878-86.572c-4.667-4.705-4.637-12.303.068-16.97l8.52-8.451c4.705-4.667 12.303-4.637 16.97.068l68.976 69.533 163.441-162.13c4.705-4.667 12.303-4.637 16.97.068l8.451 8.52c4.668 4.705 4.637 12.303-.068 16.97z"/></svg>; case 'check': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425a.267.267 0 0 1 .02-.022z"/></svg>; @@ -111,6 +115,7 @@ const SVG = (props: Props) => { case 'cog': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M8 4.754a3.246 3.246 0 1 0 0 6.492 3.246 3.246 0 0 0 0-6.492zM5.754 8a2.246 2.246 0 1 1 4.492 0 2.246 2.246 0 0 1-4.492 0z"/><path d="M9.796 1.343c-.527-1.79-3.065-1.79-3.592 0l-.094.319a.873.873 0 0 1-1.255.52l-.292-.16c-1.64-.892-3.433.902-2.54 2.541l.159.292a.873.873 0 0 1-.52 1.255l-.319.094c-1.79.527-1.79 3.065 0 3.592l.319.094a.873.873 0 0 1 .52 1.255l-.16.292c-.892 1.64.901 3.434 2.541 2.54l.292-.159a.873.873 0 0 1 1.255.52l.094.319c.527 1.79 3.065 1.79 3.592 0l.094-.319a.873.873 0 0 1 1.255-.52l.292.16c1.64.893 3.434-.902 2.54-2.541l-.159-.292a.873.873 0 0 1 .52-1.255l.319-.094c1.79-.527 1.79-3.065 0-3.592l-.319-.094a.873.873 0 0 1-.52-1.255l.16-.292c.893-1.64-.902-3.433-2.541-2.54l-.292.159a.873.873 0 0 1-1.255-.52l-.094-.319zm-2.633.283c.246-.835 1.428-.835 1.674 0l.094.319a1.873 1.873 0 0 0 2.693 1.115l.291-.16c.764-.415 1.6.42 1.184 1.185l-.159.292a1.873 1.873 0 0 0 1.116 2.692l.318.094c.835.246.835 1.428 0 1.674l-.319.094a1.873 1.873 0 0 0-1.115 2.693l.16.291c.415.764-.42 1.6-1.185 1.184l-.291-.159a1.873 1.873 0 0 0-2.693 1.116l-.094.318c-.246.835-1.428.835-1.674 0l-.094-.319a1.873 1.873 0 0 0-2.692-1.115l-.292.16c-.764.415-1.6-.42-1.184-1.185l.159-.291A1.873 1.873 0 0 0 1.945 8.93l-.319-.094c-.835-.246-.835-1.428 0-1.674l.319-.094A1.873 1.873 0 0 0 3.06 4.377l-.16-.292c-.415-.764.42-1.6 1.185-1.184l.292.159a1.873 1.873 0 0 0 2.692-1.115l.094-.319z"/></svg>; case 'cogs': return <svg viewBox="0 0 640 512" width={ `${ width }px` } height={ `${ height }px` } ><path d="m538.6 196.4-2.5-3.9c-4.1.3-8.1.3-12.2 0l-2.5 4c-5.8 9.2-17.1 13.4-27.5 10.1-13.8-4.3-23-8.8-34.3-18.1-9-7.4-11.2-20.3-5.4-30.4l2.5-4.3c-2.3-3.4-4.3-6.9-6.1-10.6h-9.1c-11.6 0-21.4-8.2-23.6-19.6-2.6-13.7-2.7-24.2.1-38.5 2.1-11.3 12.1-19.5 23.6-19.5h9c1.8-3.7 3.8-7.2 6.1-10.6l-2.6-4.5c-5.8-10-3.6-22.7 5.2-30.3 10.6-9.1 19.7-14.3 33.5-19 10.8-3.7 22.7.7 28.5 10.6l2.6 4.4c4.1-.3 8.1-.3 12.2 0l2.6-4.4c5.8-9.9 17.7-14.3 28.6-10.5 13.3 4.5 22.3 9.6 33.5 19.1 8.8 7.5 10.9 20.2 5.1 30.2l-2.6 4.4c2.3 3.4 4.3 6.9 6.1 10.6h5.1c11.6 0 21.4 8.2 23.6 19.6 2.6 13.7 2.7 24.2-.1 38.5-2.1 11.3-12.1 19.5-23.6 19.5h-5c-1.8 3.7-3.8 7.2-6.1 10.6l2.5 4.3c5.9 10.2 3.5 23.1-5.5 30.5-10.7 8.8-19.9 13.4-34 17.9-10.5 3.3-21.9-.8-27.7-10.1zm12.2-34.5 10.6 18.3c6.7-2.8 12.9-6.4 18.7-10.8l-10.6-18.3 6.4-7.5c4.8-5.7 8.6-12.1 11-19.1l3.3-9.3h21.1c.9-7.1.9-14.4 0-21.5h-21.1l-3.3-9.3c-2.5-7-6.2-13.4-11-19.1l-6.4-7.5L580 39.4c-5.7-4.4-12-8-18.7-10.8l-10.6 18.3-9.7-1.8c-7.3-1.4-14.8-1.4-22.1 0l-9.7 1.8-10.6-18.3C492 31.3 485.7 35 480 39.4l10.6 18.3-6.4 7.5c-4.8 5.7-8.6 12.1-11 19.1l-3.3 9.3h-21.1c-.9 7.1-.9 14.4 0 21.5h21.1l3.3 9.3c2.5 7 6.2 13.4 11 19.1l6.4 7.5-10.6 18.4c5.7 4.4 12 8 18.7 10.8l10.6-18.3 9.7 1.8c7.3 1.4 14.8 1.4 22.1 0l9.7-1.8zM145.3 454.4v-31.6c-12.9-5.5-25.1-12.6-36.4-21.1l-27.5 15.9c-9.8 5.6-22.1 3.7-29.7-4.6-24.2-26.3-38.5-49.5-50.6-88.1-3.4-10.7 1.1-22.3 10.8-28L39.2 281c-1.7-14-1.7-28.1 0-42.1l-27.3-15.8c-9.7-5.6-14.2-17.3-10.8-28 12.1-38.4 26.2-61.6 50.6-88.1 7.6-8.3 20-10.2 29.7-4.6l27.4 15.9c11.3-8.5 23.5-15.5 36.4-21.1V65.6c0-11.3 7.8-21 18.8-23.4 34.7-7.8 62-8.7 101.7 0 11 2.4 18.9 12.2 18.9 23.4v31.6c12.9 5.5 25.1 12.6 36.4 21l27.4-15.8c9.8-5.6 22.2-3.7 29.8 4.6 26.9 29.6 41.5 55.9 52.1 88.5 3.4 10.5-.8 21.9-10.2 27.7l-25 15.8c1.7 14 1.7 28.1 0 42.1l28.1 17.5c8.6 5.4 13 15.6 10.8 25.5-6.9 31.3-33 64.6-55.9 89.2-7.6 8.2-19.9 10-29.6 4.4L321 401.8c-11.3 8.5-23.5 15.5-36.4 21.1v31.6c0 11.2-7.8 21-18.8 23.4-37.5 8.3-64.9 8.2-101.9 0-10.8-2.5-18.6-12.3-18.6-23.5zm32-6.2c24.8 5 50.5 5 75.3 0v-47.7l10.7-3.8c16.8-5.9 32.3-14.9 45.9-26.5l8.6-7.4 41.4 23.9c16.8-19.1 34-41.3 42.1-65.2l-41.4-23.9 2.1-11.1c3.2-17.6 3.2-35.5 0-53.1l-2.1-11.1 41.4-23.9c-8.1-23.9-25.3-46.2-42.1-65.2l-41.4 23.9-8.6-7.4c-13.6-11.7-29-20.6-45.9-26.5l-10.7-3.8V71.8c-24.8-5-50.5-5-75.3 0v47.7l-10.7 3.8c-16.8 5.9-32.3 14.9-45.9 26.5l-8.6 7.4-41.4-23.9A192.19 192.19 0 0 0 33 198.5l41.4 23.9-2.1 11.1c-3.2 17.6-3.2 35.5 0 53.1l2.1 11.1L33 321.6c8.1 23.9 20.9 46.2 37.7 65.2l41.4-23.9 8.6 7.4c13.6 11.7 29 20.6 45.9 26.5l10.7 3.8v47.6zm38.4-105.3c-45.7 0-82.9-37.2-82.9-82.9s37.2-82.9 82.9-82.9 82.9 37.2 82.9 82.9-37.2 82.9-82.9 82.9zm0-133.8c-28 0-50.9 22.8-50.9 50.9s22.8 50.9 50.9 50.9c28 0 50.9-22.8 50.9-50.9s-22.8-50.9-50.9-50.9zm322.9 291.7-2.5-3.9c-4.1.3-8.1.3-12.2 0l-2.5 4c-5.8 9.2-17.1 13.4-27.5 10.1-13.8-4.3-23-8.8-34.3-18.1-9-7.4-11.2-20.3-5.4-30.4l2.5-4.3c-2.3-3.4-4.3-6.9-6.1-10.6h-9.1c-11.6 0-21.4-8.2-23.6-19.6-2.6-13.7-2.7-24.2.1-38.5 2.1-11.3 12.1-19.5 23.6-19.5h9c1.8-3.7 3.8-7.2 6.1-10.6l-2.6-4.5c-5.8-10-3.6-22.7 5.2-30.3 10.6-9.1 19.7-14.3 33.5-19 10.8-3.7 22.7.7 28.5 10.6l2.6 4.4c4.1-.3 8.1-.3 12.2 0l2.6-4.4c5.8-9.9 17.7-14.3 28.6-10.5 13.3 4.5 22.3 9.6 33.5 19.1 8.8 7.5 10.9 20.2 5.1 30.2l-2.6 4.4c2.3 3.4 4.3 6.9 6.1 10.6h5.1c11.6 0 21.4 8.2 23.6 19.6 2.6 13.7 2.7 24.2-.1 38.5-2.1 11.3-12.1 19.5-23.6 19.5h-5c-1.8 3.7-3.8 7.2-6.1 10.6l2.5 4.3c5.9 10.2 3.5 23.1-5.5 30.5-10.7 8.8-19.9 13.4-34 17.9-10.5 3.2-21.9-.9-27.7-10.1zm12.2-34.6 10.6 18.3c6.7-2.8 12.9-6.4 18.7-10.8l-10.6-18.3 6.4-7.5c4.8-5.7 8.6-12.1 11-19.1l3.3-9.3h21.1c.9-7.1.9-14.4 0-21.5h-21.1l-3.3-9.3c-2.5-7-6.2-13.4-11-19.1l-6.4-7.5 10.6-18.3c-5.7-4.4-12-8-18.7-10.8l-10.6 18.3-9.7-1.8c-7.3-1.4-14.8-1.4-22.1 0l-9.7 1.8-10.6-18.3c-6.7 2.8-12.9 6.4-18.7 10.8l10.6 18.3-6.4 7.5c-4.8 5.7-8.6 12.1-11 19.1l-3.3 9.3h-21.1c-.9 7.1-.9 14.4 0 21.5h21.1l3.3 9.3c2.5 7 6.2 13.4 11 19.1l6.4 7.5-10.6 18.3c5.7 4.4 12 8 18.7 10.8l10.6-18.3 9.7 1.8c7.3 1.4 14.8 1.4 22.1 0l9.7-1.8zM560 408c0-17.7-14.3-32-32-32s-32 14.3-32 32 14.3 32 32 32 32-14.3 32-32zm0-304.3c0-17.7-14.3-32-32-32s-32 14.3-32 32 14.3 32 32 32 32-14.4 32-32z"/></svg>; case 'collection': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M2.5 3.5a.5.5 0 0 1 0-1h11a.5.5 0 0 1 0 1h-11zm2-2a.5.5 0 0 1 0-1h7a.5.5 0 0 1 0 1h-7zM0 13a1.5 1.5 0 0 0 1.5 1.5h13A1.5 1.5 0 0 0 16 13V6a1.5 1.5 0 0 0-1.5-1.5h-13A1.5 1.5 0 0 0 0 6v7zm1.5.5A.5.5 0 0 1 1 13V6a.5.5 0 0 1 .5-.5h13a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-.5.5h-13z"/></svg>; + case 'columns-gap-filled': return <svg viewBox="0 0 25 26" width={ `${ width }px` } height={ `${ height }px` } ><path d="M.282 14.472h10.805V.966H.282v13.506Zm0 10.804h10.805v-8.103H.282v8.103Zm13.506 0h10.805V11.771H13.788v13.505Zm0-24.31v8.103h10.805V.966H13.788Z"/></svg>; case 'columns-gap': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M6 1v3H1V1h5zM1 0a1 1 0 0 0-1 1v3a1 1 0 0 0 1 1h5a1 1 0 0 0 1-1V1a1 1 0 0 0-1-1H1zm14 12v3h-5v-3h5zm-5-1a1 1 0 0 0-1 1v3a1 1 0 0 0 1 1h5a1 1 0 0 0 1-1v-3a1 1 0 0 0-1-1h-5zM6 8v7H1V8h5zM1 7a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h5a1 1 0 0 0 1-1V8a1 1 0 0 0-1-1H1zm14-6v7h-5V1h5zm-5-1a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h5a1 1 0 0 0 1-1V1a1 1 0 0 0-1-1h-5z"/></svg>; case 'console/error': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/><path d="M7.002 11a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 4.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995z"/></svg>; case 'console/exception': return <svg viewBox="0 0 448 512" width={ `${ width }px` } height={ `${ height }px` } ><path d="M400 32H48C21.49 32 0 53.49 0 80v352c0 26.51 21.49 48 48 48h352c26.51 0 48-21.49 48-48V80c0-26.51-21.49-48-48-48zm16 400c0 8.822-7.178 16-16 16H48c-8.822 0-16-7.178-16-16V80c0-8.822 7.178-16 16-16h352c8.822 0 16 7.178 16 16v352zm-192-92c-15.464 0-28 12.536-28 28s12.536 28 28 28 28-12.536 28-28-12.536-28-28-28zm-11.49-212h22.979c6.823 0 12.274 5.682 11.99 12.5l-7 168c-.268 6.428-5.557 11.5-11.99 11.5h-8.979c-6.433 0-11.722-5.073-11.99-11.5l-7-168c-.283-6.818 5.167-12.5 11.99-12.5zM224 340c-15.464 0-28 12.536-28 28s12.536 28 28 28 28-12.536 28-28-12.536-28-28-28z"/></svg>; @@ -134,6 +139,7 @@ const SVG = (props: Props) => { case 'ellipsis-v': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M9.5 13a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm0-5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm0-5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0z"/></svg>; case 'enter': return <svg viewBox="0 0 484.5 484.5" width={ `${ width }px` } height={ `${ height }px` } ><path d="M433.5 114.75v102H96.9l91.8-91.8-35.7-35.7-153 153 153 153 35.7-35.7-91.8-91.8h387.6v-153z"/></svg>; case 'envelope': return <svg viewBox="0 0 512 512" width={ `${ width }px` } height={ `${ height }px` } ><path d="M464 64H48C21.5 64 0 85.5 0 112v288c0 26.5 21.5 48 48 48h416c26.5 0 48-21.5 48-48V112c0-26.5-21.5-48-48-48zM48 96h416c8.8 0 16 7.2 16 16v41.4c-21.9 18.5-53.2 44-150.6 121.3-16.9 13.4-50.2 45.7-73.4 45.3-23.2.4-56.6-31.9-73.4-45.3C85.2 197.4 53.9 171.9 32 153.4V112c0-8.8 7.2-16 16-16zm416 320H48c-8.8 0-16-7.2-16-16V195c22.8 18.7 58.8 47.6 130.7 104.7 20.5 16.4 56.7 52.5 93.3 52.3 36.4.3 72.3-35.5 93.3-52.3 71.9-57.1 107.9-86 130.7-104.7v205c0 8.8-7.2 16-16 16z"/></svg>; + case 'errors-icon': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/><path d="M7.002 11a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 4.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995z"/></svg>; case 'event/click': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M6.75 1a.75.75 0 0 1 .75.75V8a.5.5 0 0 0 1 0V5.467l.086-.004c.317-.012.637-.008.816.027.134.027.294.096.448.182.077.042.15.147.15.314V8a.5.5 0 1 0 1 0V6.435a4.9 4.9 0 0 1 .106-.01c.316-.024.584-.01.708.04.118.046.3.207.486.43.081.096.15.19.2.259V8.5a.5.5 0 0 0 1 0v-1h.342a1 1 0 0 1 .995 1.1l-.271 2.715a2.5 2.5 0 0 1-.317.991l-1.395 2.442a.5.5 0 0 1-.434.252H6.035a.5.5 0 0 1-.416-.223l-1.433-2.15a1.5 1.5 0 0 1-.243-.666l-.345-3.105a.5.5 0 0 1 .399-.546L5 8.11V9a.5.5 0 0 0 1 0V1.75A.75.75 0 0 1 6.75 1zM8.5 4.466V1.75a1.75 1.75 0 1 0-3.5 0v5.34l-1.2.24a1.5 1.5 0 0 0-1.196 1.636l.345 3.106a2.5 2.5 0 0 0 .405 1.11l1.433 2.15A1.5 1.5 0 0 0 6.035 16h6.385a1.5 1.5 0 0 0 1.302-.756l1.395-2.441a3.5 3.5 0 0 0 .444-1.389l.271-2.715a2 2 0 0 0-1.99-2.199h-.581a5.114 5.114 0 0 0-.195-.248c-.191-.229-.51-.568-.88-.716-.364-.146-.846-.132-1.158-.108l-.132.012a1.26 1.26 0 0 0-.56-.642 2.632 2.632 0 0 0-.738-.288c-.31-.062-.739-.058-1.05-.046l-.048.002zm2.094 2.025z"/></svg>; case 'event/clickrage': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/><path d="M4.285 12.433a.5.5 0 0 0 .683-.183A3.498 3.498 0 0 1 8 10.5c1.295 0 2.426.703 3.032 1.75a.5.5 0 0 0 .866-.5A4.498 4.498 0 0 0 8 9.5a4.5 4.5 0 0 0-3.898 2.25.5.5 0 0 0 .183.683zm6.991-8.38a.5.5 0 1 1 .448.894l-1.009.504c.176.27.285.64.285 1.049 0 .828-.448 1.5-1 1.5s-1-.672-1-1.5c0-.247.04-.48.11-.686a.502.502 0 0 1 .166-.761l2-1zm-6.552 0a.5.5 0 0 0-.448.894l1.009.504A1.94 1.94 0 0 0 5 6.5C5 7.328 5.448 8 6 8s1-.672 1-1.5c0-.247-.04-.48-.11-.686a.502.502 0 0 0-.166-.761l-2-1z"/></svg>; case 'event/code': return <svg viewBox="0 0 576 512" width={ `${ width }px` } height={ `${ height }px` } ><path d="m228.5 511.8-25-7.1c-3.2-.9-5-4.2-4.1-7.4L340.1 4.4c.9-3.2 4.2-5 7.4-4.1l25 7.1c3.2.9 5 4.2 4.1 7.4L235.9 507.6c-.9 3.2-4.3 5.1-7.4 4.2zm-75.6-125.3 18.5-20.9c1.9-2.1 1.6-5.3-.5-7.1L49.9 256l121-102.5c2.1-1.8 2.4-5 .5-7.1l-18.5-20.9c-1.8-2.1-5-2.3-7.1-.4L1.7 252.3c-2.3 2-2.3 5.5 0 7.5L145.8 387c2.1 1.8 5.3 1.6 7.1-.5zm277.3.4 144.1-127.2c2.3-2 2.3-5.5 0-7.5L430.2 125.1c-2.1-1.8-5.2-1.6-7.1.4l-18.5 20.9c-1.9 2.1-1.6 5.3.5 7.1l121 102.5-121 102.5c-2.1 1.8-2.4 5-.5 7.1l18.5 20.9c1.8 2.1 5 2.3 7.1.4z"/></svg>; @@ -215,11 +221,14 @@ const SVG = (props: Props) => { case 'funnel/patch-exclamation-fill': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M10.067.87a2.89 2.89 0 0 0-4.134 0l-.622.638-.89-.011a2.89 2.89 0 0 0-2.924 2.924l.01.89-.636.622a2.89 2.89 0 0 0 0 4.134l.637.622-.011.89a2.89 2.89 0 0 0 2.924 2.924l.89-.01.622.636a2.89 2.89 0 0 0 4.134 0l.622-.637.89.011a2.89 2.89 0 0 0 2.924-2.924l-.01-.89.636-.622a2.89 2.89 0 0 0 0-4.134l-.637-.622.011-.89a2.89 2.89 0 0 0-2.924-2.924l-.89.01-.622-.636zM8 4c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995A.905.905 0 0 1 8 4zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/></svg>; case 'funnel/sd-card': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M6.25 3.5a.75.75 0 0 0-1.5 0v2a.75.75 0 0 0 1.5 0v-2zm2 0a.75.75 0 0 0-1.5 0v2a.75.75 0 0 0 1.5 0v-2zm2 0a.75.75 0 0 0-1.5 0v2a.75.75 0 0 0 1.5 0v-2zm2 0a.75.75 0 0 0-1.5 0v2a.75.75 0 0 0 1.5 0v-2z"/><path d="M5.914 0H12.5A1.5 1.5 0 0 1 14 1.5v13a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 2 14.5V3.914c0-.398.158-.78.44-1.06L4.853.439A1.5 1.5 0 0 1 5.914 0zM13 1.5a.5.5 0 0 0-.5-.5H5.914a.5.5 0 0 0-.353.146L3.146 3.561A.5.5 0 0 0 3 3.914V14.5a.5.5 0 0 0 .5.5h9a.5.5 0 0 0 .5-.5v-13z"/></svg>; case 'funnel-fill': return <svg width={ `${ width }px` } height={ `${ height }px` } ><path d="M1.5 1.5A.5.5 0 0 1 2 1h12a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.128.334L10 8.692V13.5a.5.5 0 0 1-.342.474l-3 1A.5.5 0 0 1 6 14.5V8.692L1.628 3.834A.5.5 0 0 1 1.5 3.5v-2z"/></svg>; + case 'funnel-new': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M1.5 1.5A.5.5 0 0 1 2 1h12a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.128.334L10 8.692V13.5a.5.5 0 0 1-.342.474l-3 1A.5.5 0 0 1 6 14.5V8.692L1.628 3.834A.5.5 0 0 1 1.5 3.5v-2zm1 .5v1.308l4.372 4.858A.5.5 0 0 1 7 8.5v5.306l2-.666V8.5a.5.5 0 0 1 .128-.334L13.5 3.308V2h-11z"/></svg>; case 'funnel': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M1.5 1.5A.5.5 0 0 1 2 1h12a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.128.334L10 8.692V13.5a.5.5 0 0 1-.342.474l-3 1A.5.5 0 0 1 6 14.5V8.692L1.628 3.834A.5.5 0 0 1 1.5 3.5v-2zm1 .5v1.308l4.372 4.858A.5.5 0 0 1 7 8.5v5.306l2-.666V8.5a.5.5 0 0 1 .128-.334L13.5 3.308V2h-11z"/></svg>; case 'geo-alt-fill-custom': return <svg viewBox="0 0 12 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M6 16s6-5.686 6-10A6 6 0 1 0 0 6c0 4.314 6 10 6 10Z"/></svg>; case 'github': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>; case 'graph-up-arrow': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M0 0h1v15h15v1H0V0Zm10 3.5a.5.5 0 0 1 .5-.5h4a.5.5 0 0 1 .5.5v4a.5.5 0 0 1-1 0V4.9l-3.613 4.417a.5.5 0 0 1-.74.037L7.06 6.767l-3.656 5.027a.5.5 0 0 1-.808-.588l4-5.5a.5.5 0 0 1 .758-.06l2.609 2.61L13.445 4H10.5a.5.5 0 0 1-.5-.5Z"/></svg>; + case 'graph-up': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M0 0h1v15h15v1H0V0Zm14.817 3.113a.5.5 0 0 1 .07.704l-4.5 5.5a.5.5 0 0 1-.74.037L7.06 6.767l-3.656 5.027a.5.5 0 0 1-.808-.588l4-5.5a.5.5 0 0 1 .758-.06l2.609 2.61 4.15-5.073a.5.5 0 0 1 .704-.07Z"/></svg>; case 'grid-3x3': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M0 1.5A1.5 1.5 0 0 1 1.5 0h13A1.5 1.5 0 0 1 16 1.5v13a1.5 1.5 0 0 1-1.5 1.5h-13A1.5 1.5 0 0 1 0 14.5v-13zM1.5 1a.5.5 0 0 0-.5.5V5h4V1H1.5zM5 6H1v4h4V6zm1 4h4V6H6v4zm-1 1H1v3.5a.5.5 0 0 0 .5.5H5v-4zm1 0v4h4v-4H6zm5 0v4h3.5a.5.5 0 0 0 .5-.5V11h-4zm0-1h4V6h-4v4zm0-5h4V1.5a.5.5 0 0 0-.5-.5H11v4zm-1 0V1H6v4h4z"/></svg>; + case 'grid-check': return <svg viewBox="0 0 52 52" width={ `${ width }px` } height={ `${ height }px` } ><path d="M6.5 32.5h9.75a3.25 3.25 0 0 1 3.25 3.25v9.75a3.25 3.25 0 0 1-3.25 3.25H6.5a3.25 3.25 0 0 1-3.25-3.25v-9.75A3.25 3.25 0 0 1 6.5 32.5ZM35.75 3.25h9.75a3.25 3.25 0 0 1 3.25 3.25v9.75a3.25 3.25 0 0 1-3.25 3.25h-9.75a3.25 3.25 0 0 1-3.25-3.25V6.5a3.25 3.25 0 0 1 3.25-3.25Zm0 29.25a3.25 3.25 0 0 0-3.25 3.25v9.75a3.25 3.25 0 0 0 3.25 3.25h9.75a3.25 3.25 0 0 0 3.25-3.25v-9.75a3.25 3.25 0 0 0-3.25-3.25h-9.75Zm0-32.5a6.5 6.5 0 0 0-6.5 6.5v9.75a6.5 6.5 0 0 0 6.5 6.5h9.75a6.5 6.5 0 0 0 6.5-6.5V6.5A6.5 6.5 0 0 0 45.5 0h-9.75ZM6.5 29.25a6.5 6.5 0 0 0-6.5 6.5v9.75A6.5 6.5 0 0 0 6.5 52h9.75a6.5 6.5 0 0 0 6.5-6.5v-9.75a6.5 6.5 0 0 0-6.5-6.5H6.5Zm22.75 6.5a6.5 6.5 0 0 1 6.5-6.5h9.75a6.5 6.5 0 0 1 6.5 6.5v9.75a6.5 6.5 0 0 1-6.5 6.5h-9.75a6.5 6.5 0 0 1-6.5-6.5v-9.75ZM0 6.5A6.5 6.5 0 0 1 6.5 0h9.75a6.5 6.5 0 0 1 6.5 6.5v9.75a6.5 6.5 0 0 1-6.5 6.5H6.5a6.5 6.5 0 0 1-6.5-6.5V6.5Zm17.4 2.775a1.627 1.627 0 0 0-2.3-2.3l-5.35 5.352-2.1-2.102a1.627 1.627 0 1 0-2.3 2.3l3.25 3.25a1.625 1.625 0 0 0 2.3 0l6.5-6.5Z"/></svg>; case 'grip-horizontal': return <svg viewBox="0 0 448 512" width={ `${ width }px` } height={ `${ height }px` } ><path d="M424 96h-80c-13.22 0-24 10.77-24 24v80c0 13.23 10.78 24 24 24h80c13.22 0 24-10.77 24-24v-80c0-13.23-10.78-24-24-24zm-8 96h-64v-64h64v64zM264 96h-80c-13.22 0-24 10.77-24 24v80c0 13.23 10.78 24 24 24h80c13.22 0 24-10.77 24-24v-80c0-13.23-10.78-24-24-24zm-8 96h-64v-64h64v64zM104 96H24c-13.22 0-24 10.77-24 24v80c0 13.23 10.78 24 24 24h80c13.22 0 24-10.77 24-24v-80c0-13.23-10.78-24-24-24zm-8 96H32v-64h64v64zm328 96h-80c-13.22 0-24 10.77-24 24v80c0 13.23 10.78 24 24 24h80c13.22 0 24-10.77 24-24v-80c0-13.23-10.78-24-24-24zm-8 96h-64v-64h64v64zm-152-96h-80c-13.22 0-24 10.77-24 24v80c0 13.23 10.78 24 24 24h80c13.22 0 24-10.77 24-24v-80c0-13.23-10.78-24-24-24zm-8 96h-64v-64h64v64zm-152-96H24c-13.22 0-24 10.77-24 24v80c0 13.23 10.78 24 24 24h80c13.22 0 24-10.77 24-24v-80c0-13.23-10.78-24-24-24zm-8 96H32v-64h64v64z"/></svg>; case 'hash': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M8.39 12.648a1.32 1.32 0 0 0-.015.18c0 .305.21.508.5.508.266 0 .492-.172.555-.477l.554-2.703h1.204c.421 0 .617-.234.617-.547 0-.312-.188-.53-.617-.53h-.985l.516-2.524h1.265c.43 0 .618-.227.618-.547 0-.313-.188-.524-.618-.524h-1.046l.476-2.304a1.06 1.06 0 0 0 .016-.164.51.51 0 0 0-.516-.516.54.54 0 0 0-.539.43l-.523 2.554H7.617l.477-2.304c.008-.04.015-.118.015-.164a.512.512 0 0 0-.523-.516.539.539 0 0 0-.531.43L6.53 5.484H5.414c-.43 0-.617.22-.617.532 0 .312.187.539.617.539h.906l-.515 2.523H4.609c-.421 0-.609.219-.609.531 0 .313.188.547.61.547h.976l-.516 2.492c-.008.04-.015.125-.015.18 0 .305.21.508.5.508.265 0 .492-.172.554-.477l.555-2.703h2.242l-.515 2.492zm-1-6.109h2.266l-.515 2.563H6.859l.532-2.563z"/></svg>; case 'hdd-stack': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M14 10a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h12zM2 9a2 2 0 0 0-2 2v1a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-1a2 2 0 0 0-2-2H2z"/><path d="M5 11.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0zm-2 0a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0zM14 3a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h12zM2 2a2 2 0 0 0-2 2v1a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H2z"/><path d="M5 4.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0zm-2 0a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0z"/></svg>; @@ -230,13 +239,14 @@ const SVG = (props: Props) => { case 'hourglass-start': return <svg viewBox="0 0 384 512" width={ `${ width }px` } height={ `${ height }px` } ><path d="M368 32h4c6.627 0 12-5.373 12-12v-8c0-6.627-5.373-12-12-12H12C5.373 0 0 5.373 0 12v8c0 6.627 5.373 12 12 12h4c0 91.821 44.108 193.657 129.646 224C59.832 286.441 16 388.477 16 480h-4c-6.627 0-12 5.373-12 12v8c0 6.627 5.373 12 12 12h360c6.627 0 12-5.373 12-12v-8c0-6.627-5.373-12-12-12h-4c0-91.821-44.108-193.657-129.646-224C324.168 225.559 368 123.523 368 32zM48 32h288c0 110.457-64.471 200-144 200S48 142.457 48 32zm288 448H48c0-110.457 64.471-200 144-200s144 89.543 144 200zM285.621 96H98.379a12.01 12.01 0 0 1-11.602-8.903 199.464 199.464 0 0 1-2.059-8.43C83.054 71.145 88.718 64 96.422 64h191.157c7.704 0 13.368 7.145 11.704 14.667a199.464 199.464 0 0 1-2.059 8.43A12.013 12.013 0 0 1 285.621 96zm-15.961 50.912a141.625 141.625 0 0 1-6.774 8.739c-2.301 2.738-5.671 4.348-9.248 4.348H130.362c-3.576 0-6.947-1.61-9.248-4.348a142.319 142.319 0 0 1-6.774-8.739c-5.657-7.91.088-18.912 9.813-18.912h135.694c9.725 0 15.469 11.003 9.813 18.912z"/></svg>; case 'id-card': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M14.5 3a.5.5 0 0 1 .5.5v9a.5.5 0 0 1-.5.5h-13a.5.5 0 0 1-.5-.5v-9a.5.5 0 0 1 .5-.5h13zm-13-1A1.5 1.5 0 0 0 0 3.5v9A1.5 1.5 0 0 0 1.5 14h13a1.5 1.5 0 0 0 1.5-1.5v-9A1.5 1.5 0 0 0 14.5 2h-13z"/><path d="M3 8.5a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5zm0 2a.5.5 0 0 1 .5-.5h6a.5.5 0 0 1 0 1h-6a.5.5 0 0 1-.5-.5zm0-5a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-9a.5.5 0 0 1-.5-.5v-1z"/></svg>; case 'image': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M4.502 9a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z"/><path d="M14.002 13a2 2 0 0 1-2 2h-10a2 2 0 0 1-2-2V5A2 2 0 0 1 2 3a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v8a2 2 0 0 1-1.998 2zM14 2H4a1 1 0 0 0-1 1h9.002a2 2 0 0 1 2 2v7A1 1 0 0 0 15 11V3a1 1 0 0 0-1-1zM2.002 4a1 1 0 0 0-1 1v8l2.646-2.354a.5.5 0 0 1 .63-.062l2.66 1.773 3.71-3.71a.5.5 0 0 1 .577-.094l1.777 1.947V5a1 1 0 0 0-1-1h-10z"/></svg>; - case 'info-circle': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/><path d="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533L8.93 6.588zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0z"/></svg>; + case 'info-circle-fill': return <svg viewBox="0 0 36 36" width={ `${ width }px` } height={ `${ height }px` } ><path d="M17.75 35.5a17.75 17.75 0 1 0 0-35.5 17.75 17.75 0 0 0 0 35.5Zm2.064-20.883-2.22 10.44c-.155.754.065 1.182.675 1.182.43 0 1.08-.155 1.522-.546l-.195.923c-.637.768-2.041 1.327-3.25 1.327-1.56 0-2.224-.937-1.793-2.927l1.637-7.694c.142-.65.014-.886-.637-1.043l-1-.18.182-.845 5.08-.637h-.002Zm-2.064-2.414a2.219 2.219 0 1 1 0-4.437 2.219 2.219 0 0 1 0 4.437Z"/></svg>; + case 'info-circle': return <svg viewBox="0 0 35 35" width={ `${ width }px` } height={ `${ height }px` } ><g clipPath="url(#a)"><path d="M17.5 32.813a15.313 15.313 0 1 1 0-30.626 15.313 15.313 0 0 1 0 30.625Zm0 2.187a17.5 17.5 0 1 0 0-35 17.5 17.5 0 0 0 0 35Z"/><path clipRule="evenodd" d="M17.5 13a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Zm1.5 2.877a1.5 1.5 0 1 0-3 0V24.5a1.5 1.5 0 0 0 3 0v-8.623Z"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h35v35H0z"/></clipPath></defs></svg>; case 'info-square': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M14 1a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h12zM2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2z"/><path d="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533L8.93 6.588zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0z"/></svg>; case 'info': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M14 1a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h12zM2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2z"/><path d="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533L8.93 6.588zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0z"/></svg>; case 'inspect': return <svg viewBox="0 0 512 512" width={ `${ width }px` } height={ `${ height }px` } ><path d="M506 240h-34.591C463.608 133.462 378.538 48.392 272 40.591V6a6 6 0 0 0-6-6h-20a6 6 0 0 0-6 6v34.591C133.462 48.392 48.392 133.462 40.591 240H6a6 6 0 0 0-6 6v20a6 6 0 0 0 6 6h34.591C48.392 378.538 133.462 463.608 240 471.409V506a6 6 0 0 0 6 6h20a6 6 0 0 0 6-6v-34.591C378.538 463.608 463.608 378.538 471.409 272H506a6 6 0 0 0 6-6v-20a6 6 0 0 0-6-6zM272 439.305V374a6 6 0 0 0-6-6h-20a6 6 0 0 0-6 6v65.305C151.282 431.711 80.315 361.031 72.695 272H138a6 6 0 0 0 6-6v-20a6 6 0 0 0-6-6H72.695C80.289 151.282 150.969 80.316 240 72.695V138a6 6 0 0 0 6 6h20a6 6 0 0 0 6-6V72.695C360.718 80.289 431.685 150.969 439.305 240H374a6 6 0 0 0-6 6v20a6 6 0 0 0 6 6h65.305C431.711 360.718 361.031 431.684 272 439.305zM280 256c0 13.255-10.745 24-24 24s-24-10.745-24-24 10.745-24 24-24 24 10.745 24 24z"/></svg>; case 'integrations/assist': return <svg viewBox="0 0 120 120" width={ `${ width }px` } height={ `${ height }px` } fill={ `${ fill }` }><g><g><path d="M114 0H6a6 6 0 0 0-6 6v108a6 6 0 0 0 6 6h108a6 6 0 0 0 6-6V6a6 6 0 0 0-6-6Z"/><path d="M28 108.75h63a8 8 0 0 0 8-8v-64a8 8 0 0 0-8-8H55.67L35 12v16.81h-7a8 8 0 0 0-8 8v64a8 8 0 0 0 8 8Z"/><path d="M54.11 79.63h10.78a13.25 13.25 0 0 1 13.26 13.25v7.2h-37.3v-7.2a13.25 13.25 0 0 1 13.26-13.25Z"/><path d="M46.18 53.82h26.64V66.3a13.32 13.32 0 1 1-26.64 0Z"/><path d="M76.15 55v6.93a65 65 0 0 1-22.58-4.94 14.93 14.93 0 0 1-10.72 5V55a16.67 16.67 0 0 1 33.33 0Z"/><path d="M59.67 41.83a13.55 13.55 0 0 0-13.56 13.56v2.71h2.71a2.72 2.72 0 0 1 1.92.8 2.75 2.75 0 0 1 .79 1.91V69a2.75 2.75 0 0 1-.79 1.92 2.71 2.71 0 0 1-1.92.79h-2.71A2.71 2.71 0 0 1 43.39 69V55.39a16.23 16.23 0 0 1 4.77-11.5 16.26 16.26 0 0 1 23 0 16.23 16.23 0 0 1 4.77 11.5v16.27a6.78 6.78 0 0 1-6.78 6.78h-5.78A2.68 2.68 0 0 1 61 79.8h-2.69a2.72 2.72 0 0 1-1.92-.8 2.67 2.67 0 0 1-.79-1.91 2.71 2.71 0 0 1 2.71-2.72H61a2.7 2.7 0 0 1 1.36.37 2.76 2.76 0 0 1 1 1h5.79a4.08 4.08 0 0 0 4.07-4.07h-2.71a2.67 2.67 0 0 1-1.91-.79 2.72 2.72 0 0 1-.8-1.88v-8.19a2.71 2.71 0 0 1 .8-1.91 2.68 2.68 0 0 1 1.91-.8h2.72v-2.71a13.61 13.61 0 0 0-4-9.59 13.44 13.44 0 0 0-4.39-2.94 13.61 13.61 0 0 0-5.19-1Z"/></g></g></svg>; case 'integrations/bugsnag-text': return <svg viewBox="0 0 800 219.6" width={ `${ width }px` } height={ `${ height }px` } fill={ `${ fill }` }><path d="M504.2 46.9c-25.5.8-45.5 22.4-45.5 47.9v58.3c0 1.4 1.1 2.5 2.5 2.5h14.1c1.4 0 2.5-1.1 2.5-2.5V94.6c0-15 11.5-27.8 26.4-28.6 15.3-.8 28.4 11.1 29.2 26.4v60.7c0 1.4 1.1 2.5 2.5 2.5H550c1.4 0 2.5-1.1 2.5-2.5V93.8c0-26-21.1-47-47.1-46.9h-1.2zm119.2.5c-29.9-1.3-55.3 21.9-56.6 51.8-1.3 29.9 21.9 55.3 51.8 56.6 13.7.6 27.2-4 37.6-12.9V153c0 1.4 1.1 2.5 2.5 2.5h14.1c1.4 0 2.5-1.1 2.5-2.5v-50.3c0-29.2-22.7-54-51.9-55.3zm-2.3 89.4c-19.4 0-35.2-15.7-35.2-35.2 0-19.4 15.7-35.2 35.2-35.2 19.4 0 35.2 15.7 35.2 35.2-.1 19.5-15.8 35.2-35.2 35.2zm127-89.4c-29.9-1.3-55.3 21.9-56.6 51.8-1.3 29.9 21.9 55.3 51.8 56.6 13.7.6 27.2-4 37.6-12.9v21.5c0 19.7-16.1 36.5-35.8 36.1-11.2-.2-21.6-5.7-28-14.8-.8-1-2.2-1.3-3.3-.6l-12 7.4c-1.2.7-1.5 2.3-.8 3.5 0 0 0 .1.1.1 9.9 14.5 26.2 23.3 43.7 23.6 30.4.6 55.3-24.9 55.3-55.4v-61.5c-.1-29.3-22.8-54.1-52-55.4zm-2.4 89.4c-19.4 0-35.2-15.7-35.2-35.2 0-19.4 15.7-35.2 35.2-35.2 19.4 0 35.2 15.7 35.2 35.2 0 19.5-15.8 35.2-35.2 35.2zM292.5 47.4c-29.9-1.3-55.3 21.9-56.6 51.8-1.3 29.9 21.9 55.3 51.8 56.6 13.7.6 27.2-4 37.6-12.9v21.5c0 19.7-16.1 36.5-35.9 36.1-11.2-.2-21.6-5.7-28-14.8-.8-1-2.2-1.3-3.3-.6l-12 7.4c-1.2.7-1.5 2.3-.8 3.5 0 0 0 .1.1.1 9.8 14.5 26.1 23.2 43.6 23.6 30.4.6 55.3-24.9 55.3-55.4v-61.5c.1-29.3-22.6-54.1-51.8-55.4zm-2.4 89.4c-19.4 0-35.2-15.7-35.2-35.2 0-19.4 15.7-35.2 35.2-35.2s35.2 15.7 35.2 35.2c0 19.5-15.8 35.2-35.2 35.2zm-74.5-88.7-14.1.2c-1.4 0-2.4 1.1-2.4 2.5v56.4c0 15.4-12.5 27.8-27.8 27.8H170c-14.9-.7-26.4-13.6-26.4-28.6V50.9c0-1.4-1.1-2.5-2.5-2.5H127c-1.4 0-2.5 1.1-2.5 2.5v55.4c0 25.5 20 47.1 45.5 47.9 25.9.7 47.6-19.7 48.3-45.7V50.6c-.2-1.4-1.3-2.5-2.7-2.5zm189.5 44.4c-13.9-3.8-22.3-6.7-22.3-14.2 0-10.8 11.9-14.8 19.9-14.8 9.1 0 14.6 2.1 21.8 6.5 1.2.7 2.6.4 3.4-.8l6.6-10.2c.7-1.2.4-2.7-.7-3.4h-.1c-9.5-5.7-20.4-8.8-31.5-8.7-18 0-36.5 11.8-36.5 31.7 0 20.2 18 25.1 33.8 29.4 15 4.1 24.1 7.2 24.1 16.6 0 8.7-8.9 15.2-20.7 15.2-11.4 0-20.7-5.1-27-10-1.1-.8-2.6-.7-3.4.4l-7.8 9.5c-.9 1.1-.7 2.6.4 3.5 10.2 8.6 23.9 13.3 38.7 13.3 21.5 0 37.2-13.7 37.2-32.6-.2-21.4-20-26.9-35.9-31.4zM60.6 47.7c-15-1.8-30 2.8-41.5 12.6V2.5C19.1 1.1 18 0 16.6 0c-.5 0-.9.1-1.3.4L1.2 9C.4 9.4 0 10.2 0 11.1v89.5c0 29.3 22.7 54.1 51.9 55.3 30 1.3 55.3-22 56.5-51.9 1.2-28.4-19.6-52.9-47.8-56.3zm-6.3 89.1c-19.4 0-35.2-15.7-35.2-35.2s15.7-35.2 35.2-35.2 35.2 15.7 35.2 35.2c-.1 19.5-15.8 35.2-35.2 35.2z"/></svg>; - case 'integrations/bugsnag': return <svg viewBox="0 0 256 176" preserveAspectRatio="xMidYMid" width={ `${ width }px` } height={ `${ height }px` } fill={ `${ fill }` }><path d="M57.838 170.017c.151 1.663-.051 3.789-.14 5.436h56.864c.053-1.654.091-3.311.091-4.974 0-39.942-15.768-76.266-44.011-104.51C56.704 52.032 40.885 41.31 23.246 33.898L0 86.328c33.989 15.82 54.211 43.783 57.838 83.689zm69.197-1.644c.108 2.371-.062 4.732-.167 7.08h58.177c.077-2.355.13-4.714.13-7.08 0-28.826-5.66-56.82-16.82-83.207-10.767-25.456-26.169-48.306-45.778-67.915a216.421 216.421 0 0 0-15.686-14.218l-37.68 44.315c37.293 33.313 55.304 65.858 57.824 121.025zM235.263 64.39C226.595 41.785 213.935 19.521 198.727 0l-46.95 34.442c27.495 35.099 44.442 79.71 46.058 127.612.152 4.502-.164 8.969-.457 13.399h58.252c.226-4.448.447-8.916.344-13.399-.805-34.945-8.23-65.12-20.71-97.665z" fill="#3676A1"/></svg>; + case 'integrations/bugsnag': return <svg fill="none" width={ `${ width }px` } height={ `${ height }px` } fill={ `${ fill }` }><path d="M28.943 55.123a3.228 3.228 0 1 0 0-6.455 3.228 3.228 0 0 0 0 6.456Z" fill="#303F9F"/><path d="M28.943 78.961A27.096 27.096 0 0 1 1.878 51.896V38.474A2.015 2.015 0 0 1 3.89 36.46h9.6l-.032-31.072-7.555 4.649v17.693a2.013 2.013 0 1 1-4.025 0V9.806A3.634 3.634 0 0 1 3.6 6.725l8.368-5.15a3.618 3.618 0 0 1 5.514 3.081l.035 31.803h11.425A15.436 15.436 0 1 1 13.51 51.896l-.014-11.409H5.903v11.409a23.04 23.04 0 1 0 23.04-23.04h-3.492a2.013 2.013 0 0 1 0-4.026h3.492a27.065 27.065 0 1 1 0 54.131Zm-11.42-38.474v11.406a11.41 11.41 0 1 0 11.409-11.406h-11.41Z" fill="#303F9F"/></svg>; case 'integrations/cloudwatch-text': return <svg viewBox="3.62 8.78 120 60" width={ `${ width }px` } height={ `${ height }px` } fill={ `${ fill }` }><path d="M47.558 39.07 63.6 50.4l16.07-11.346L63.63 36.7z" fill="#b7ca9d"/><path d="m50.765 38.795 2.724.8 6.38-9.777-6.38-9.794-2.724 1.052z" fill="#4c622c"/><path d="m53.5 20.035 9.604 2.173V37.95l-9.604 1.7z" fill="#759c3f"/><path d="m58.818 41.244-3.76-1.138V15.93l3.76-1.88 11.087 14.312z" fill="#4c622c"/><path d="m58.818 14.05 12.83 5v19.2l-12.83 3z" fill="#759c3f"/><path d="M47.558 42.364 63.6 50.4v-5.64l-16.053-5.7z" fill="#4c622c"/><path d="m63.6 44.76 16.07-5.707v3.3L63.6 50.4z" fill="#759c3f"/><path d="m60.18 34.346 3.43 8.328 11.553-9.087h-3.897z" fill="#b7ca9d"/><path d="m63.6 34.7-3.43-.362v7.294l3.43 1.035z" fill="#4c622c"/><path d="m63.6 42.675 11.553-3.466v-5.62L63.6 34.7z" fill="#759c3f"/><path d="m19.35 56.302 2.643 7.06H20.4l-.543-1.557h-2.643l-.543 1.557h-1.593l2.68-7.06zm.072 4.3-.905-2.57h-.036l-.905 2.57zm4.383-2.355v.688c.18-.253.398-.47.652-.616s.543-.217.905-.217a2.19 2.19 0 0 1 .869.181c.253.1.47.362.616.652a1.9 1.9 0 0 1 .616-.58c.254-.145.58-.253.905-.253a3.1 3.1 0 0 1 .76.1c.253.064.435.18.58.326.18.145.3.326.398.58s.145.507.145.833v3.404h-1.376v-2.897c0-.18 0-.326-.036-.47s-.036-.3-.1-.398-.145-.217-.253-.253c-.1-.072-.253-.1-.47-.1s-.362.036-.47.1a1.1 1.1 0 0 0-.29.29c-.072.1-.1.253-.145.398s-.036.326-.036.47V63.3H25.65v-3.295c0-.145-.036-.3-.072-.398s-.145-.217-.253-.3-.3-.1-.507-.1c-.072 0-.145 0-.253.036s-.217.072-.326.18c-.1.072-.18.217-.253.362s-.1.362-.1.616v2.97h-1.412v-5.106h1.34zm7.75.735a1.46 1.46 0 0 1 .543-.507c.217-.145.47-.217.76-.3s.543-.072.833-.072l.797.036c.253.036.507.1.724.217s.398.253.543.435.217.435.217.76v2.643c0 .217 0 .435.036.652s.072.362.145.47H34.74c-.036-.072-.036-.145-.072-.253 0-.072-.036-.18-.036-.253-.217.217-.47.398-.797.47s-.616.145-.942.145c-.253 0-.47-.036-.688-.072-.217-.072-.398-.145-.543-.3-.145-.1-.3-.3-.362-.47a1.93 1.93 0 0 1-.145-.688c0-.3.036-.543.145-.724s.217-.326.398-.435c.145-.1.362-.18.543-.253s.398-.1.616-.145l.616-.072.543-.072a.85.85 0 0 0 .362-.181c.1-.072.145-.18.145-.326a.91.91 0 0 0-.072-.362.784.784 0 0 0-.18-.217 2.3 2.3 0 0 0-.3-.11c-.1 0-.217-.036-.362-.036-.3 0-.507.072-.652.18s-.253.326-.3.58h-1.412c.072-.253.145-.543.3-.76zm2.788 1.992a.69.69 0 0 1-.3.072c-.1.036-.217.036-.326.036s-.217.036-.326.036c-.1.036-.217.036-.326.072s-.18.072-.253.145-.145.1-.18.217a.72.72 0 0 0-.072.326c0 .1.036.217.072.326.036.072.1.145.18.217a2.3 2.3 0 0 0 .3.11c.12.038.217.036.326.036.3 0 .507-.036.652-.145s.253-.217.326-.326c.072-.145.1-.253.145-.398 0-.145.036-.253.036-.326v-.507a1.52 1.52 0 0 0-.253.11zm5.034-1.666h-2.462v-1.05h4.3v1.05l-2.643 3.006h2.825v1.05h-4.67v-1.05zm2.823.398a1.96 1.96 0 0 1 .543-.833c.217-.217.507-.398.833-.543s.688-.18 1.086-.18a3.43 3.43 0 0 1 1.086.18c.326.145.616.326.833.543s.398.507.543.833a3.47 3.47 0 0 1 .181 1.123c0 .398-.072.76-.18 1.123a1.96 1.96 0 0 1-.543.833 2.768 2.768 0 0 1-.833.543c-.326.1-.688.18-1.086.18a3.43 3.43 0 0 1-1.086-.18 1.94 1.94 0 0 1-.833-.543 3.138 3.138 0 0 1-.543-.833 3.47 3.47 0 0 1-.181-1.123 2.23 2.23 0 0 1 .181-1.123zm1.267 1.702a1.27 1.27 0 0 0 .217.507c.1.145.217.3.362.362.145.1.362.145.58.145.253 0 .435-.036.58-.145s.3-.217.398-.362.145-.326.217-.507a3.17 3.17 0 0 0 .072-.579c0-.217-.036-.398-.072-.616a1.27 1.27 0 0 0-.217-.507c-.1-.145-.217-.3-.398-.362-.145-.1-.362-.145-.58-.145-.253 0-.435.036-.58.145s-.3.217-.362.362c-.1.145-.145.326-.217.507s-.072.398-.072.616c0 .18.036.362.072.58zm6.048-3.15v.724h.036c.18-.3.398-.507.688-.652a1.93 1.93 0 0 1 .87-.217c.362 0 .688.036.905.145a1.44 1.44 0 0 1 .579.435c.145.18.217.398.3.652s.072.543.072.87v3.15h-1.412v-2.897c0-.435-.072-.724-.18-.942-.145-.217-.362-.326-.688-.326-.398 0-.652.1-.833.326s-.253.616-.253 1.123v2.68h-1.412v-5.1h1.34zm11.95-.073c-.1-.145-.217-.3-.362-.398a1.95 1.95 0 0 0-.471-.253 1.46 1.46 0 0 0-.543-.11 1.76 1.76 0 0 0-.905.217c-.253.145-.435.326-.58.543s-.253.47-.326.76a3.65 3.65 0 0 0-.109.905 3.57 3.57 0 0 0 .109.869c.072.3.18.543.326.76s.362.398.58.543a1.76 1.76 0 0 0 .905.217c.47 0 .833-.145 1.123-.435a1.98 1.98 0 0 0 .471-1.159h1.485a4.13 4.13 0 0 1-.29 1.195 3.37 3.37 0 0 1-.652.905c-.254.253-.58.435-.942.58s-.76.18-1.195.18a3.69 3.69 0 0 1-1.448-.29 2.93 2.93 0 0 1-1.086-.76c-.3-.325-.507-.724-.688-1.16-.145-.435-.253-.905-.253-1.448s.072-1.014.253-1.448.398-.833.688-1.195c.3-.326.652-.616 1.086-.797a3.69 3.69 0 0 1 1.448-.29 3.43 3.43 0 0 1 1.086.18c.362.1.652.3.942.47a2.44 2.44 0 0 1 .688.797 2.58 2.58 0 0 1 .326 1.086h-1.485a2.044 2.044 0 0 0-.18-.47zm4.055-1.883v7.06h-1.4v-7.06zm1.16 3.404a1.96 1.96 0 0 1 .543-.833c.217-.217.507-.398.833-.543s.688-.18 1.086-.18a3.43 3.43 0 0 1 1.086.18c.326.145.616.326.833.543s.398.507.543.833.18.688.18 1.123c0 .398-.072.76-.18 1.123a1.96 1.96 0 0 1-.543.833 3.138 3.138 0 0 1-.833.543c-.326.1-.688.18-1.086.18a3.43 3.43 0 0 1-1.086-.18 1.94 1.94 0 0 1-.833-.543 3.138 3.138 0 0 1-.543-.833 3.47 3.47 0 0 1-.181-1.123c0-.435.036-.797.18-1.123zm1.267 1.702a1.27 1.27 0 0 0 .217.507c.1.145.217.3.362.362.145.1.362.145.58.145.253 0 .435-.036.58-.145s.3-.217.398-.362.145-.326.217-.507a3.17 3.17 0 0 0 .072-.579c0-.217-.036-.398-.072-.616a1.27 1.27 0 0 0-.217-.507c-.1-.145-.217-.3-.398-.362-.145-.1-.362-.145-.58-.145-.253 0-.435.036-.58.145s-.3.217-.362.362c-.1.145-.145.326-.217.507s-.072.398-.072.616c0 .18.036.362.072.58zm8.183 1.955v-.724h-.036c-.18.3-.398.507-.688.652s-.58.18-.87.18c-.362 0-.688-.036-.905-.145s-.435-.253-.58-.435-.217-.398-.3-.652-.072-.543-.072-.87v-3.15h1.412v2.897c0 .435.072.724.18.942s.362.326.688.326c.398 0 .652-.1.833-.326s.253-.616.253-1.123v-2.68h1.412v5.106zm6.013-.65c-.18.3-.362.47-.652.58-.253.1-.58.18-.905.18-.398 0-.724-.072-1.014-.217a1.99 1.99 0 0 1-.724-.616c-.18-.254-.326-.543-.435-.87s-.145-.688-.145-1.014.036-.688.145-.978c.1-.326.253-.616.435-.833a2.66 2.66 0 0 1 .688-.579c.3-.145.616-.217.978-.217.3 0 .58.072.87.18.3.145.47.326.652.58h.036v-2.57h1.412v7.06h-1.34zm-.072-2.535a1.27 1.27 0 0 0-.217-.507 1.75 1.75 0 0 0-.362-.362c-.145-.1-.326-.145-.58-.145s-.435.036-.58.145-.3.217-.362.362a1.27 1.27 0 0 0-.217.507 3.19 3.19 0 0 0-.072.616 3.17 3.17 0 0 0 .072.579 1.75 1.75 0 0 0 .217.543c.107.18.217.3.398.362.145.1.326.145.543.145s.435-.036.58-.145.3-.217.362-.362c.1-.145.145-.326.18-.543a3.19 3.19 0 0 0 .072-.616c.036-.18 0-.398-.036-.58zm6.88 3.186-.905-3.44H88l-.87 3.44H85.7l-1.63-5.106h1.485l.942 3.476h.036l.833-3.476h1.376l.87 3.44h.036l.942-3.44h1.448l-1.593 5.106zm3.802-4.382a1.46 1.46 0 0 1 .543-.507c.217-.145.47-.217.76-.3s.543-.072.833-.072l.797.036a1.89 1.89 0 0 1 .724.217c.217.117.398.253.543.435s.217.435.217.76v2.643c0 .217 0 .435.036.652s.072.362.145.47h-1.4c-.036-.072-.036-.145-.072-.253 0-.072-.036-.18-.036-.253-.217.217-.47.398-.797.47-.3.1-.616.145-.942.145-.253 0-.47-.036-.688-.072-.217-.072-.398-.145-.543-.3-.145-.1-.3-.3-.362-.47a1.93 1.93 0 0 1-.145-.688c0-.3.036-.543.145-.724s.217-.326.398-.435c.145-.1.362-.18.543-.253s.398-.1.616-.145l.616-.072.543-.072a.85.85 0 0 0 .362-.181c.1-.072.145-.18.145-.326a.91.91 0 0 0-.072-.362.784.784 0 0 0-.18-.217 2.3 2.3 0 0 0-.3-.11c-.1 0-.217-.036-.362-.036-.3 0-.507.072-.652.18s-.253.326-.3.58h-1.412c.072-.253.145-.543.3-.76zm2.788 1.992a.69.69 0 0 1-.3.072c-.1.036-.217.036-.326.036s-.217.036-.326.036c-.1.036-.217.036-.326.072s-.18.072-.253.145-.145.1-.18.217a.72.72 0 0 0-.072.326c0 .1.036.217.072.326a.96.96 0 0 0 .181.217 2.3 2.3 0 0 0 .3.11c.12.038.217.036.326.036.3 0 .507-.036.652-.145s.253-.217.326-.326c.072-.145.1-.253.145-.398 0-.145.036-.253.036-.326v-.507a1.52 1.52 0 0 0-.253.11zm5.504-2.717v.942h-1.014v2.535c0 .253.036.398.1.47s.253.1.47.1h.217c.072 0 .145 0 .217-.036v1.086c-.1.036-.253.036-.398.036h-.435c-.217 0-.435 0-.616-.036s-.362-.072-.507-.18c-.145-.072-.253-.217-.362-.362a1.4 1.4 0 0 1-.145-.616V59.2h-.833v-.942h.833v-1.52h1.412v1.52zm3.115.906c-.217 0-.398.036-.58.145-.145.1-.3.217-.362.398-.1.145-.145.326-.217.543a3.17 3.17 0 0 0-.072.579c0 .18.036.362.072.58.036.18.1.362.18.507s.217.3.362.362c.145.1.326.145.543.145.326 0 .58-.1.76-.3s.3-.435.326-.76h1.34c-.1.688-.362 1.195-.797 1.557s-.978.543-1.666.543a3.19 3.19 0 0 1-1.05-.181c-.326-.145-.58-.3-.797-.543a2.15 2.15 0 0 1-.507-.833 3.19 3.19 0 0 1-.181-1.05 3.43 3.43 0 0 1 .18-1.086c.108-.326.3-.616.507-.87s.507-.435.833-.58.688-.217 1.123-.217a3.57 3.57 0 0 1 .869.109c.3.072.543.18.76.362.217.145.398.362.543.616s.217.507.253.87H105.2c-.1-.616-.47-.905-1.05-.905zm4.635-2.86v2.643h.036c.18-.3.398-.507.688-.652s.543-.217.797-.217c.362 0 .688.036.905.145a1.44 1.44 0 0 1 .579.435c.145.18.217.398.3.652s.072.543.072.87v3.15h-1.412v-2.9c0-.435-.072-.724-.18-.942s-.362-.326-.688-.326c-.398 0-.652.1-.833.326s-.253.616-.253 1.123v2.68h-1.412v-7.06h1.412z" fill="#779d3f"/></svg>; case 'integrations/cloudwatch': return <svg viewBox="3.62 8.78 64 64" width={ `${ width }px` } height={ `${ height }px` } fill={ `${ fill }` }><path d="M35.605 72.78 63.9 52.803l-28.266-4.13-28.296 4.16z" fill="#b7ca9d"/><path d="M12.986 21.167v31.18l4.797 1.427L29.017 36.56 17.783 19.315z" fill="#4c622c"/><path d="M34.694 23.14v27.72l-16.9 2.975v-34.52z" fill="#759c3f"/><path d="m46.686 33.98-19.52 22.68-6.62-2.004V12.1l6.62-3.3z" fill="#4c622c"/><path d="M49.753 17.585v33.822L27.165 56.7V8.78z" fill="#759c3f"/><path d="M35.605 72.78v-9.928L7.34 52.833v5.8z" fill="#4c622c"/><path d="M63.9 52.803v5.83L35.605 72.78v-9.928z" fill="#759c3f"/><path d="m35.605 59.178 20.342-16h-6.86l-19.522 1.336z" fill="#b7ca9d"/><path d="M29.563 44.514v12.842l6.042 1.822V45.152z" fill="#4c622c"/><path d="M55.946 53.076v-9.898L35.604 45.15v14.027z" fill="#759c3f"/></svg>; case 'integrations/datadog': return <svg viewBox="0 0 500 500" width={ `${ width }px` } height={ `${ height }px` } fill={ `${ fill }` }><path clipRule="evenodd" fill="#774AA4" d="m350.2 268.3-27.3-18.1-22.8 38.1-26.5-7.8-23.3 35.7 1.2 11.2L378.2 304l-7.4-79.4-20.6 43.7zM232 234l20.3-2.8c3.3 1.5 5.6 2 9.5 3.1 6.1 1.6 13.3 3.1 23.8-2.2 2.5-1.2 7.6-5.9 9.6-8.6l83.3-15.2 8.5 103.2-142.7 25.8L232 234zm154.7-37.2-8.2 1.6-15.8-163.7L93.5 66l33.2 270 31.5-4.6c-2.5-3.6-6.4-8-13.1-13.5-9.3-7.7-6-20.9-.5-29.2 7.2-14 44.6-31.8 42.4-54.2-.8-8.1-2-18.7-9.6-26-.3 3 .2 5.9.2 5.9s-3.1-4-4.6-9.4c-1.5-2.1-2.7-2.7-4.4-5.5-1.2 3.2-1 6.9-1 6.9s-2.5-6-2.9-11.1c-1.5 2.3-1.9 6.6-1.9 6.6s-3.3-9.5-2.5-14.6c-1.5-4.4-6-13.2-4.7-33.2 8.2 5.8 26.3 4.4 33.4-6 2.3-3.5 3.9-12.9-1.2-31.4-3.3-11.9-11.4-29.6-14.6-36.4l-.4.3c1.7 5.4 5.1 16.8 6.4 22.3 4 16.7 5.1 22.5 3.2 30.2-1.6 6.7-5.4 11.1-15.1 16s-22.6-7-23.4-7.7c-9.4-7.5-16.7-19.8-17.5-25.8-.8-6.5 3.8-10.5 6.1-15.8-3.3 1-7 2.6-7 2.6s4.4-4.6 9.9-8.6c2.3-1.5 3.6-2.5 6-4.4-3.4-.1-6.2 0-6.2 0s5.7-3.1 11.7-5.4c-4.4-.2-8.5 0-8.5 0s12.8-5.7 22.9-10c7-2.9 13.8-2 17.6 3.5 5 7.3 10.3 11.2 21.4 13.6 6.9-3 8.9-4.6 17.5-7 7.6-8.4 13.5-9.4 13.5-9.4s-3 2.7-3.7 7c4.3-3.4 9-6.2 9-6.2s-1.8 2.3-3.5 5.8l.4.6c5-3 10.9-5.4 10.9-5.4s-1.7 2.1-3.7 4.9c3.8 0 11.5.2 14.4.5 17.6.4 21.2-18.8 28-21.2 8.4-3 12.2-4.9 26.6 9.3 12.3 12.2 22 33.9 17.2 38.8-4 4-11.9-1.6-20.7-12.6-4.6-5.8-8.1-12.7-9.8-21.4-1.4-7.4-6.8-11.6-6.8-11.6s3.1 7 3.1 13.2c0 3.4.4 16 5.8 23-.5 1-.8 5.1-1.4 5.9-6.3-7.6-19.7-13-21.9-14.6 7.4 6.1 24.5 20.1 31.1 33.6 6.2 12.7 2.5 24.4 5.7 27.4.9.9 13.3 16.4 15.7 24.2 4.2 13.6.2 27.9-5.2 36.8l-15.3 2.4c-2.2-.6-3.7-.9-5.7-2.1 1.1-2 3.3-6.8 3.3-7.9l-.9-1.5c-4.7 6.7-12.7 13.3-19.3 17.1-8.7 4.9-18.6 4.2-25.1 2.1-18.4-5.7-35.9-18.2-40.1-21.5 0 0-.1 2.6.7 3.2 4.6 5.3 15.3 14.8 25.6 21.4l-21.9 2.4 10.4 81c-4.6.7-5.3 1-10.3 1.7-4.4-15.7-12.9-26-22.2-32-8.2-5.3-19.5-6.5-30.3-4.3l-.7.8c7.5-.8 16.4.3 25.5 6.1 8.9 5.7 16.1 20.3 18.8 29.1 3.4 11.3 5.7 23.3-3.4 36.1-6.5 9.1-25.5 14.1-40.8 3.2 4.1 6.6 9.6 12 17.1 13 11.1 1.5 21.6-.4 28.8-7.9 6.2-6.4 9.4-19.7 8.6-33.7l9.8-1.4 3.5 25.2 161.6-19.5-13.5-128.9zm-98.3-68.3c-.5 1-1.2 1.7-.1 5.1l.1.2.2.4.4 1c1.9 3.9 4 7.6 7.5 9.5.9-.2 1.9-.3 2.8-.3 3.3-.1 5.4.4 6.7 1.1.1-.7.1-1.6.1-3.1-.3-5 1-13.5-8.6-17.9-3.6-1.7-8.7-1.2-10.3.9.3 0 .6.1.8.2 2.6 1 .8 1.9.4 2.9m26.8 46.5c-1.3-.7-7.1-.4-11.2.1-7.8.9-16.3 3.7-18.2 5.1-3.4 2.6-1.8 7.2.7 9 7 5.2 13.1 8.7 19.6 7.9 4-.5 7.5-6.8 9.9-12.5 1.6-3.9 1.6-8.2-.8-9.6m-69.4-40.3c2.2-2.1-11-4.9-21.3 2.1-7.6 5.2-7.8 16.3-.6 22.6.7.6 1.3 1.1 1.9 1.4 2.1-1 4.5-2 7.3-2.9 4.7-1.5 8.6-2.3 11.8-2.7 1.5-1.7 3.3-4.7 2.9-10.2-.6-7.4-6.2-6.3-2-10.3M69.9 435.7H43.7v-60.4h26.2c18.9 0 28.4 9.5 28.4 28.6 0 21.2-9.4 31.8-28.4 31.8m-15-9.7h13.3c12.6 0 18.8-7.4 18.8-22.1 0-12.6-6.3-18.9-18.8-18.9H54.9v41zm55.2 9.7H98.6l25.7-60.4h12.1l26.3 60.4h-12.1l-7.6-16.5h-19.4l3.9-9.7h12.6l-9.9-22.7-20.1 48.9zm46.1-60.4h45.9v9.7h-17.4v50.7h-11.2V385h-17.4v-9.7zm51.7 60.4h-11.5l25.7-60.4h12.1l26.3 60.4h-12.1l-7.6-16.5h-19.4l3.8-9.7h12.6l-9.9-22.7-20 48.9zm86.2 0h-26.2v-60.4h26.2c18.9 0 28.4 9.5 28.4 28.6 0 21.2-9.4 31.8-28.4 31.8m-15-9.7h13.3c12.6 0 18.8-7.4 18.8-22.1 0-12.6-6.3-18.9-18.8-18.9h-13.3v41zm51-20.4c0-20.5 10.1-30.7 30.4-30.7 20 0 29.9 10.2 29.9 30.7 0 20.4-10 30.6-29.9 30.6-19.3-.1-29.5-10.2-30.4-30.6m30.4 20.8c12.2 0 18.3-7 18.3-21.1 0-13.8-6.1-20.8-18.3-20.8-12.5 0-18.8 6.9-18.8 20.8.1 14.1 6.3 21.1 18.8 21.1m76.8-15.1v14.1c-2.6.7-4.9 1-6.9 1-13.7 0-20.6-7.2-20.6-21.8 0-13.4 7.3-20.1 21.8-20.1 6.1 0 11.7 1.1 16.9 3.4v-10.1c-5.2-2-11.1-3-17.8-3-21.7 0-32.6 9.9-32.6 29.8 0 21 10.7 31.5 32.1 31.5 7.4 0 13.5-1.1 18.3-3.2v-31.6h-18.1l-3.8 9.9h10.7z"/></svg>; @@ -248,13 +258,13 @@ const SVG = (props: Props) => { case 'integrations/jira': return <svg viewBox="0 0 74 76" width={ `${ width }px` } height={ `${ height }px` } fill={ `${ fill }` }><defs><linearGradient x1="67.68%" y1="40.328%" x2="40.821%" y2="81.66%" id="a"><stop stop-color="#777" offset="18%"/><stop stop-color="#999" offset="100%"/></linearGradient><linearGradient x1="32.656%" y1="59.166%" x2="59.343%" y2="17.99%" id="b"><stop stop-color="#777" offset="18%"/><stop stop-color="#999" offset="100%"/></linearGradient></defs><g fill="none"><path d="M72.4 35.76 39.8 3.16 36.64 0 12.1 24.54.88 35.76a3 3 0 0 0 0 4.24L23.3 62.42l13.34 13.34 24.54-24.54.38-.38L72.4 40a3 3 0 0 0 0-4.24ZM36.64 49.08l-11.2-11.2 11.2-11.2 11.2 11.2-11.2 11.2Z" fill="#999"/><path d="M36.64 26.68c-7.333-7.334-7.369-19.212-.08-26.59l-24.51 24.5 13.34 13.34 11.25-11.25Z" fill="url(#a)"/><path d="M47.87 37.85 36.64 49.08a18.86 18.86 0 0 1 0 26.68l24.57-24.57-13.34-13.34Z" fill="url(#b)"/></g></svg>; case 'integrations/mobx': return <svg viewBox="0 0 256 256" preserveAspectRatio="xMidYMid" width={ `${ width }px` } height={ `${ height }px` } fill={ `${ fill }` }><path d="M256 236.394V19.607c0-8.894-5.923-16.4-14.037-18.8l-9.215 5.514-102.265 109.037-3.206 10.021-1.873 9.62 31.89 119.18 4.933 1.82h74.167c10.828 0 19.606-8.777 19.606-19.605" fill="#EA6618"/><path d="M0 19.606v216.787c0 6.705 3.367 12.62 8.5 16.155l6.287-3.01 108.246-115.894 4.244-8.265.159-7.99L97.976 5.306 93.513 0H19.606C8.778 0 0 8.778 0 19.606" fill="#d65813"/><path d="M127.277 125.38 241.963.806a19.595 19.595 0 0 0-5.57-.807H93.515l33.763 125.38z" fill="#e05e11"/><path d="M19.606 256h142.622l-34.951-130.621L8.499 252.549A19.511 19.511 0 0 0 19.606 256" fill="#de5c16"/><path d="M94.918 97.03h14.225c5.668 21.386 12.119 40.152 19.316 57.085 8.152-19.05 14.127-37.83 19.185-57.086h13.442c-6.02 23.926-15.868 48.04-27.132 72.93h-11.89c-10.82-23.586-20.03-47.837-27.146-72.93zm-46.92-37.055h31.63v135.637h-31.77v-10.456H67.33V70.152H47.998V59.975zm160.169 10.177h-19.332v115.004h19.47v10.456h-31.769V59.975h31.63v10.177z" fill="#FFF"/></svg>; case 'integrations/newrelic-text': return <svg viewBox="0 0 737.94 132.03" width={ `${ width }px` } height={ `${ height }px` } fill={ `${ fill }` }><path d="m257.96 103.98-20.19-42.32c-4.82-10-9.77-21.36-11.46-26.7l-.39.39c.65 7.55.78 17.06.91 25l.52 43.63h-14.71V13.86h16.93l21.88 44a164.12 164.12 0 0 1 9.25 23.18l.39-.39c-.39-4.56-1.3-17.45-1.3-25.66l-.26-41.15h14.2v90.12ZM300.54 74.94v1c0 9.12 3.39 18.75 16.28 18.75 6.12 0 11.46-2.21 16.41-6.51l5.6 8.73a35.6 35.6 0 0 1-23.7 8.73c-18.62 0-30.34-13.41-30.34-34.51 0-11.59 2.47-19.28 8.21-25.79 5.34-6.12 11.85-8.86 20.19-8.86a25.45 25.45 0 0 1 18.1 6.77c5.73 5.21 8.6 13.28 8.6 28.65v3Zm12.63-27.61c-8.07 0-12.5 6.38-12.5 17.06h24.35c0-10.66-4.68-17.06-11.85-17.06ZM416.1 104.24h-13.46l-8.07-30.34c-2.08-7.81-4.3-18-4.3-18h-.26s-1 6.51-4.3 18.62l-7.94 29.69h-13.42l-18-65.25 14.2-2 7.16 31.91c1.82 8.2 3.39 17.32 3.39 17.32h.39a178.91 178.91 0 0 1 3.78-17.71l8.47-30.47h14.07l7.43 29.72c2.74 10.68 4.17 18.75 4.17 18.75h.39s1.56-10 3.26-17.71l6.77-30.74h14.85ZM518.15 103.98l-7.81-13.94c-6.24-11.06-10.42-17.31-15.37-22.31a7.64 7.64 0 0 0-5.87-2.69v38.94h-14.71V13.86h27.48c20.19 0 29.3 11.72 29.3 25.79 0 12.89-8.33 24.75-22.4 24.75 3.26 1.69 9.25 10.42 13.93 18l13.28 21.62Zm-20.84-78h-8.21V54.5h7.68c7.81 0 12-1 14.72-3.78 2.47-2.47 4-6.25 4-10.94.05-9.12-4.9-13.81-18.19-13.81ZM555.65 74.94v1c0 9.12 3.39 18.75 16.28 18.75 6.12 0 11.46-2.21 16.41-6.51l5.6 8.73a35.6 35.6 0 0 1-23.7 8.73c-18.62 0-30.34-13.41-30.34-34.51 0-11.59 2.47-19.28 8.21-25.79 5.34-6.12 11.85-8.86 20.19-8.86a25.45 25.45 0 0 1 18.1 6.77c5.73 5.21 8.6 13.28 8.6 28.65v3Zm12.64-27.61c-8.07 0-12.5 6.38-12.5 17.06h24.31c0-10.66-4.65-17.06-11.81-17.06ZM621.81 105.42c-14.46 0-14.46-13-14.46-18.62V30.66a106.73 106.73 0 0 0-1.25-19.27l14.72-3.26c1 4 1.17 9.51 1.17 18.1V82.1c0 8.86.39 10.29 1.43 11.85a4 4 0 0 0 4.69 1l2.34 8.86a22.44 22.44 0 0 1-8.64 1.61ZM646.68 28.32a9.34 9.34 0 0 1-9.25-9.51 9.44 9.44 0 1 1 9.25 9.51Zm-7.16 75.67V39.13l14.46-2.61v67.46ZM695 105.68c-18 0-28-12.63-28-33.86 0-24 14.33-35.42 29-35.42 7.16 0 12.37 1.69 18.23 7.16l-7.13 9.5c-3.91-3.52-7.29-5.08-11.07-5.08a11.2 11.2 0 0 0-10.42 6.64c-2 4-2.73 10.16-2.73 18.36 0 9 1.43 14.72 4.43 18a11.58 11.58 0 0 0 8.73 3.78c4.56 0 9-2.21 13.28-6.51l6.77 8.73c-5.99 5.96-12.24 8.7-21.09 8.7ZM728.31 105.43a9.67 9.67 0 1 1 9.62-9.67 9.63 9.63 0 0 1-9.62 9.67Zm0-17.42a7.78 7.78 0 1 0 7.44 7.75 7.55 7.55 0 0 0-7.44-7.76Zm1.9 13.11c-.42-.73-.6-1-1-1.8-1.07-1.95-1.4-2.5-1.79-2.65a.74.74 0 0 0-.34-.08v4.53h-2.13V90.27h4a3 3 0 0 1 3.2 3.17 2.78 2.78 0 0 1-2.42 3 2.49 2.49 0 0 1 .44.47c.62.78 2.6 4.21 2.6 4.21Zm-1.11-8.95a4.35 4.35 0 0 0-1.22-.16h-.78v2.94h.73c.94 0 1.35-.11 1.64-.37a1.53 1.53 0 0 0 .42-1.09 1.28 1.28 0 0 0-.79-1.32Z"/><path d="M168.72 55.82C161.07 20.67 118.92 0 74.56 9.64S.45 55.6 8.09 90.74s49.8 55.83 94.15 46.18 74.12-45.92 66.48-81.1Zm-80.31 49.86a32.4 32.4 0 1 1 32.4-32.4 32.4 32.4 0 0 1-32.4 32.4Z" transform="translate(-6.9 -7.27)"/><path d="M95.57 27.92a46.52 46.52 0 1 0 46.53 46.52 46.52 46.52 0 0 0-46.53-46.52Zm-7.17 73.66a28.3 28.3 0 1 1 28.3-28.3 28.3 28.3 0 0 1-28.29 28.3Z" transform="translate(-6.9 -7.27)"/></svg>; - case 'integrations/newrelic': return <svg viewBox="0 0 681.02 551.55" width={ `${ width }px` } height={ `${ height }px` } fill={ `${ fill }` }><path d="M692.8 220.54C660.86 73.7 484.77-12.68 299.47 27.61s-309.63 192-277.7 338.83 208 233.22 393.32 192.93 309.63-192 277.71-338.83ZM344.87 476.79c-103.41 0-187.2-83.82-187.2-187.22s83.8-187.19 187.2-187.19 187.2 83.81 187.2 187.19-83.82 187.22-187.2 187.22Z" transform="translate(-16.78 -17.71)"/><path d="M391.53 57.56c-132.32 0-239.61 107.28-239.61 239.6s107.29 239.62 239.61 239.62 239.62-107.29 239.62-239.62-107.3-239.6-239.62-239.6Zm-46.66 416.22c-101.75 0-184.19-82.47-184.19-184.21S243.12 105.4 344.87 105.4 529 187.85 529 289.57s-82.42 184.21-184.13 184.21Z" transform="translate(-16.78 -17.71)"/><path d="m278.93 271.2-20.19-42.33c-4.82-10-9.77-21.36-11.46-26.7l-.39.39c.65 7.55.78 17.06.91 25l.52 43.63h-14.71v-90.11h16.93l21.88 44a164.17 164.17 0 0 1 9.25 23.18l.39-.39c-.39-4.56-1.3-17.45-1.3-25.66l-.26-41.15h14.2v90.14ZM321.51 242.16v1c0 9.12 3.39 18.75 16.28 18.75 6.12 0 11.46-2.21 16.41-6.51l5.6 8.73a35.59 35.59 0 0 1-23.7 8.73c-18.62 0-30.34-13.41-30.34-34.51 0-11.59 2.47-19.27 8.21-25.79 5.34-6.12 11.85-8.86 20.19-8.86a25.45 25.45 0 0 1 18.1 6.77c5.73 5.21 8.6 13.28 8.6 28.65v3Zm12.63-27.61c-8.07 0-12.5 6.38-12.5 17.06H346c0-10.68-4.69-17.06-11.85-17.06ZM437 271.46h-13.39l-8.07-30.34c-2.08-7.81-4.3-18-4.3-18H411s-1 6.51-4.3 18.62l-7.94 29.69h-13.44l-18-65.25 14.2-2 7.16 31.91c1.82 8.2 3.39 17.32 3.39 17.32h.39a178.91 178.91 0 0 1 3.78-17.71l8.47-30.47h14.07l7.44 29.77c2.74 10.68 4.17 18.75 4.17 18.75h.39s1.56-10 3.26-17.71l6.77-30.74h14.85ZM267.62 387.2l-7.81-13.94c-6.25-11.07-10.42-17.32-15.37-22.27a7.64 7.64 0 0 0-5.86-2.73v38.94h-14.72v-90.12h27.48c20.19 0 29.3 11.72 29.3 25.79 0 12.89-8.33 24.75-22.4 24.75 3.26 1.69 9.25 10.42 13.93 18l13.28 21.62Zm-20.84-78h-8.21v28.52h7.68c7.81 0 12-1 14.72-3.78 2.47-2.47 4-6.25 4-10.94.03-9.12-4.91-13.81-18.19-13.81ZM305.12 358.16v1c0 9.12 3.39 18.75 16.28 18.75 6.12 0 11.46-2.21 16.41-6.51l5.6 8.72a35.59 35.59 0 0 1-23.7 8.73c-18.62 0-30.34-13.41-30.34-34.51 0-11.59 2.47-19.28 8.21-25.79 5.34-6.12 11.85-8.86 20.19-8.86a25.45 25.45 0 0 1 18.1 6.77c5.73 5.21 8.6 13.28 8.6 28.65v3Zm12.63-27.61c-8.07 0-12.5 6.38-12.5 17.06h24.35c0-10.68-4.68-17.06-11.85-17.06ZM371.28 388.63c-14.46 0-14.46-13-14.46-18.62v-56.13a106.72 106.72 0 0 0-1.3-19.27l14.72-3.26c1 4 1.17 9.51 1.17 18.1v55.87c0 8.86.39 10.29 1.43 11.85a4 4 0 0 0 4.69 1l2.34 8.86a22.44 22.44 0 0 1-8.59 1.6ZM396.15 311.53a9.34 9.34 0 0 1-9.25-9.53 9.44 9.44 0 1 1 9.25 9.51ZM389 387.2v-64.86l14.46-2.6v67.46ZM444.46 388.89c-18 0-28-12.63-28-33.86 0-24 14.33-35.42 29-35.42 7.16 0 12.37 1.69 18.23 7.16l-7.16 9.51c-3.91-3.52-7.29-5.08-11.07-5.08a11.2 11.2 0 0 0-10.42 6.64c-2 4-2.73 10.16-2.73 18.36 0 9 1.43 14.72 4.43 18a11.58 11.58 0 0 0 8.76 3.8c4.56 0 9-2.21 13.28-6.51l6.77 8.72c-5.98 5.95-12.23 8.68-21.09 8.68ZM477.78 388.64a9.67 9.67 0 1 1 9.62-9.64 9.63 9.63 0 0 1-9.62 9.64Zm0-17.42a7.78 7.78 0 1 0 7.44 7.75 7.55 7.55 0 0 0-7.44-7.75Zm1.9 13.1c-.42-.73-.6-1-1-1.79-1.07-2-1.4-2.5-1.79-2.65a.72.72 0 0 0-.34-.08v4.52h-2.15v-10.84h4a3 3 0 0 1 3.2 3.17 2.78 2.78 0 0 1-2.42 3 2.47 2.47 0 0 1 .44.47c.62.78 2.6 4.21 2.6 4.21Zm-1.14-8.94a4.35 4.35 0 0 0-1.22-.16h-.78v2.94h.73c.94 0 1.35-.1 1.64-.36a1.53 1.53 0 0 0 .42-1.09 1.28 1.28 0 0 0-.8-1.33Z" transform="translate(-16.78 -17.71)"/></svg>; + case 'integrations/newrelic': return <svg fill="none" width={ `${ width }px` } height={ `${ height }px` } fill={ `${ fill }` }><g clipPath="url(#a)"><path d="M57.048 28.04v24.921l-21.73 12.463V81L70.64 60.752V20.25l-13.592 7.79Z" fill="#00AC69"/><path d="m35.322 15.581 21.73 12.458 13.591-7.79L35.321 0 0 20.248l13.587 7.791 21.735-12.458Z" fill="#1CE783"/><path d="M21.735 48.294v24.92L35.322 81V40.503L0 20.25v15.58l21.735 12.464Z" fill="#000" fill-opacity=".87"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h70.874v81H0z"/></clipPath></defs></svg>; case 'integrations/ngrx': return <svg viewBox="0 0 120 120" width={ `${ width }px` } height={ `${ height }px` } fill={ `${ fill }` }><rect fill="#fff" height="120" rx="6.01" width="120"/><g><path d="M60.08 20.79 22.71 33.76l5.35 49.58 32.02 17.45z" fill="#412846"/><path d="m59.92 20.79 37.37 12.97-5.35 49.58-32.02 17.45z" fill="#4b314f"/><path d="M78.63 48.16a10.08 10.08 0 0 1 2.63 6.77 15 15 0 0 1-2.65 8.25c1.36-1.06 2.93-3.34 4.71-6.82q1.16 10.59-8.58 16.08c2.07-.19 4.83-1.55 8.24-4.1q-5.46 13.17-20.1 13.89a24.42 24.42 0 0 1-15.53-5.67 22.92 22.92 0 0 1-8-11.39C37 62.62 37 62.35 36.76 61.34s.15-1.3.83-2.29a3.7 3.7 0 0 0 .33-2.83 7.12 7.12 0 0 1-1-3.76 3.68 3.68 0 0 1 1.65-2.61 8.47 8.47 0 0 0 2-2.11 10.37 10.37 0 0 0 .21-3.43c0-2 1.1-3.08 3.32-3.26 3.33-.26 5.22-2.77 6.26-3.91a4 4 0 0 1 3-1.13 6.34 6.34 0 0 1 4.94 2.07 20.12 20.12 0 0 1 11 2.87q7.97 4.71 8.7 10.18-.85 7.2-19.29-.37-9.65 2.73-9.49 11.84 0 8.35 8.07 12.14c-2.62-2.58-3.74-4.74-3.36-6.53q8.18 9.69 18.62 7.24a8.78 8.78 0 0 1-7.32-3c4.7-.12 9.14-2.3 13.32-6.58a9.29 9.29 0 0 1-7.61 2.19q10.86-8.51 7.69-19.9zm-13.15-.87a1.07 1.07 0 1 0-1.06-1.07 1.06 1.06 0 0 0 1.06 1.07z" fill="#ba2bd2"/></g></svg>; case 'integrations/openreplay-text': return <svg viewBox="0 0 179 30" width={ `${ width }px` } height={ `${ height }px` } fill={ `${ fill }` }><g fill="none"><path d="M22.426 15 3.296 3.773v22.454L22.426 15Zm2.61-2.32a2.68 2.68 0 0 1 1.33 2.32 2.68 2.68 0 0 1-1.33 2.32L4.065 29.633C2.35 30.639 0 29.488 0 27.31V2.689C0 .512 2.35-.639 4.064.37l20.973 12.31Z" fill="#394EFF"/><path d="M13.752 14.381a.713.713 0 0 1 0 1.238l-5.656 3.283C7.634 19.17 7 18.864 7 18.282v-6.565c0-.58.634-.887 1.096-.619l5.656 3.283Z" fill="#3EAAAF"/><path d="M60.027 8.769c1.076 0 2.064.241 2.965.725.9.484 1.62 1.26 2.157 2.325.538 1.067.807 2.478.807 4.234 0 1.72-.293 3.165-.88 4.334a5.865 5.865 0 0 1-2.615 2.648c-1.157.596-2.586.894-4.289.894-.332 0-.665-.014-1.002-.04a21.19 21.19 0 0 1-.934-.094v5.779H52.7V8.997h2.985l.215 1.586h.175l.167-.2a4.8 4.8 0 0 1 1.48-1.117c.667-.332 1.436-.497 2.305-.497Zm77.203-.003c1.076 0 2.064.242 2.965.726.9.483 1.62 1.258 2.157 2.325.538 1.066.807 2.477.807 4.233 0 1.72-.294 3.165-.88 4.335a5.865 5.865 0 0 1-2.615 2.647c-1.157.596-2.586.894-4.29.894-.33 0-.665-.013-1-.04a21.19 21.19 0 0 1-.935-.094v5.779h-3.536V8.994h2.985l.215 1.586h.175l.167-.2a4.8 4.8 0 0 1 1.48-1.117c.667-.331 1.436-.497 2.305-.497Zm29.642.228 3.873 11.604 3.965-11.604h3.495l-7.434 20.577h-3.482l2.15-5.914h-.98l-5.284-14.663h3.697ZM73.829 8.77c1.317 0 2.431.284 3.34.853.91.569 1.598 1.422 2.064 2.56.466 1.138.7 2.554.7 4.247v.955h-9.065l.002.04c.042.822.188 1.496.438 2.023.3.631.787 1.084 1.459 1.357.672.273 1.573.41 2.702.41.565 0 1.156-.045 1.775-.134.618-.09 1.254-.215 1.909-.377v2.702l-.447.106a16.661 16.661 0 0 1-3.72.418c-1.73 0-3.162-.262-4.296-.786a5.176 5.176 0 0 1-2.534-2.467c-.556-1.12-.834-2.571-.834-4.354 0-1.658.258-3.047.773-4.167.516-1.12 1.257-1.964 2.225-2.533.968-.569 2.138-.853 3.509-.853Zm48.255-.003c1.318 0 2.431.284 3.34.853.91.57 1.598 1.423 2.065 2.56.466 1.138.699 2.554.699 4.248v.954h-9.064l.002.04c.041.822.187 1.496.437 2.023.3.632.787 1.084 1.459 1.357.672.274 1.573.41 2.702.41.565 0 1.156-.045 1.775-.134.618-.09 1.255-.215 1.909-.376v2.701l-.447.106a16.661 16.661 0 0 1-3.72.418c-1.73 0-3.162-.262-4.296-.786a5.176 5.176 0 0 1-2.534-2.466c-.556-1.12-.834-2.572-.834-4.355 0-1.658.258-3.046.773-4.166.516-1.12 1.257-1.965 2.225-2.534.968-.569 2.138-.853 3.51-.853ZM41.641 3.513c1.81 0 3.388.363 4.732 1.09 1.345.725 2.389 1.843 3.133 3.352.744 1.51 1.116 3.43 1.116 5.76 0 2.33-.372 4.249-1.116 5.759-.744 1.51-1.79 2.627-3.14 3.353-1.348.726-2.923 1.088-4.725 1.088-1.801 0-3.374-.362-4.719-1.088-1.344-.726-2.39-1.841-3.139-3.347-.748-1.505-1.122-3.427-1.122-5.766 0-2.338.374-4.26 1.122-5.765.749-1.506 1.795-2.621 3.14-3.347 1.344-.726 2.917-1.089 4.718-1.089Zm114.414 5.253c1.443 0 2.633.181 3.57.544.936.363 1.633.972 2.09 1.828.457.856.685 2.018.685 3.488v9.031h-2.917l-.255-1.518h-.175l-.154.194a4.046 4.046 0 0 1-1.574 1.143 5.9 5.9 0 0 1-2.279.437c-1.46 0-2.6-.379-3.421-1.136-.82-.757-1.23-1.763-1.23-3.017 0-1.344.46-2.395 1.378-3.152l.032-.026c.92-.742 2.391-1.188 4.411-1.338l2.73-.26v-.573l-.005-.242c-.024-.63-.141-1.134-.352-1.512-.238-.426-.614-.726-1.13-.9-.515-.176-1.185-.263-2.01-.263-.555 0-1.187.05-1.895.148-.708.098-1.398.25-2.07.457V9.397l.422-.117c.573-.15 1.187-.267 1.843-.353a17.79 17.79 0 0 1 2.306-.161Zm-66.65.003c.95 0 1.795.183 2.534.55.74.368 1.32.957 1.741 1.768.422.81.632 1.893.632 3.246v9.327h-3.535v-9.126l-.004-.228c-.03-.89-.245-1.518-.642-1.882-.43-.394-1.021-.591-1.774-.591-.359 0-.73.05-1.116.148a4.172 4.172 0 0 0-1.116.47 3.371 3.371 0 0 0-.941.847V23.66h-3.536V8.997h2.93l.243 1.546h.175l.216-.21a5.794 5.794 0 0 1 1.76-1.107 6.425 6.425 0 0 1 2.433-.457Zm16.525-5.231c2.778 0 4.86.504 6.244 1.512 1.385 1.008 2.077 2.578 2.077 4.71 0 1.38-.329 2.536-.988 3.468-.658.932-1.608 1.633-2.85 2.103l-.065.025a8.939 8.939 0 0 1-.404.138h-.001l5.922 8.163h-3.98l-5.456-7.627-.056.001a24.77 24.77 0 0 1-1.68-.021l-1.116-.054v7.701h-3.536V4.17l.43-.085c.437-.084.895-.163 1.372-.237a26.486 26.486 0 0 1 4.087-.31Zm42.642-1.6v21.72h-3.536V1.937h3.536Zm10.373 15.12-2.393.215-.25.028c-.812.104-1.405.318-1.78.644-.412.359-.618.852-.618 1.479 0 .636.188 1.124.564 1.465.377.34.932.51 1.667.51.484 0 .973-.094 1.466-.282l.049-.02c.474-.189.905-.496 1.295-.92v-3.12Zm-99.9-5.373c-.519 0-1.03.1-1.532.302a3.037 3.037 0 0 0-1.277.975v8.037l.21.04c.216.037.456.07.718.101.35.04.69.06 1.021.06 1.399 0 2.45-.376 3.153-1.128.704-.753 1.055-1.998 1.055-3.737 0-1.21-.143-2.15-.43-2.822-.287-.672-.681-1.145-1.183-1.418-.502-.273-1.08-.41-1.734-.41Zm77.204-.003c-.52 0-1.03.101-1.533.303a3.037 3.037 0 0 0-1.277.974v8.037l.21.04c.216.037.455.071.718.101.35.04.69.06 1.021.06 1.399 0 2.45-.376 3.153-1.128.704-.753 1.055-1.998 1.055-3.736 0-1.21-.143-2.15-.43-2.823-.287-.672-.681-1.145-1.183-1.418-.502-.273-1.08-.41-1.734-.41ZM41.64 6.457c-1.075 0-2.01.235-2.803.705-.793.47-1.405 1.232-1.835 2.285-.43 1.053-.645 2.449-.645 4.187 0 1.792.215 3.225.645 4.3.43 1.076 1.04 1.85 1.828 2.326.79.475 1.726.712 2.81.712 1.094 0 2.035-.237 2.823-.712.79-.475 1.396-1.239 1.822-2.292.426-1.053.639-2.444.639-4.173 0-1.783-.215-3.212-.646-4.287-.43-1.075-1.04-1.853-1.828-2.332-.789-.48-1.725-.72-2.81-.72ZM73.79 11.08c-.619 0-1.148.14-1.587.417-.439.278-.773.744-1.001 1.398l-.015.042c-.195.579-.303 1.332-.324 2.259l-.002.064h5.763l-.001-.051c-.02-.95-.127-1.716-.32-2.3-.214-.655-.533-1.123-.954-1.405-.421-.283-.94-.424-1.56-.424Zm48.255-.003c-.619 0-1.147.14-1.587.417-.439.278-.773.744-1.001 1.398l-.015.042c-.195.579-.303 1.332-.324 2.259l-.001.064h5.762l-.001-.05c-.02-.95-.127-1.717-.32-2.302-.214-.654-.532-1.122-.954-1.404-.421-.282-.94-.424-1.56-.424Zm-15.765-4.851c-.538 0-1.02.022-1.445.067-.426.045-.845.103-1.257.175V13.2l.28.025a20.1 20.1 0 0 0 2.113.096c1.685 0 2.922-.285 3.71-.854.79-.568 1.184-1.471 1.184-2.708 0-.824-.164-1.496-.491-2.016-.327-.52-.83-.902-1.506-1.149-.676-.246-1.54-.37-2.588-.37Z" fill="#000"/></g></svg>; case 'integrations/openreplay': return <svg viewBox="0 0 52 59" width={ `${ width }px` } height={ `${ height }px` } fill={ `${ fill }` }><g fill="none"><path d="M44.229 29.5 6.5 7.42v44.16L44.23 29.5Zm5.148-4.564A5.268 5.268 0 0 1 52 29.5c0 1.886-1 3.627-2.623 4.564L8.015 58.275C4.635 60.255 0 57.993 0 53.711V5.29C0 1.007 4.635-1.256 8.015.725l41.362 24.21Z" fill="#394EFF"/><path d="m29.416 28.457-14.623-8.312A1.2 1.2 0 0 0 13 21.19v16.623a1.2 1.2 0 0 0 1.793 1.043l14.623-8.312a1.2 1.2 0 0 0 0-2.086Z" fill="#27A2A8"/></g></svg>; case 'integrations/redux': return <svg viewBox="0 0 120 120" width={ `${ width }px` } height={ `${ height }px` } fill={ `${ fill }` }><rect fill="#fff" height="120" rx="6.01" width="120"/><g fill="#764abc"><path d="M76.26 75.87a6 6 0 0 0-.65-12h-.21a6 6 0 0 0-5.79 6.22 6.16 6.16 0 0 0 1.71 4c-3.64 7.18-9.22 12.44-17.58 16.83a29 29 0 0 1-17.48 3.33c-4.83-.64-8.58-2.79-10.94-6.33a15.74 15.74 0 0 1-.86-16.62 25.18 25.18 0 0 1 7.29-8.58c-.43-1.39-1.07-3.75-1.39-5.47-15.55 11.22-13.94 26.45-9.23 33.63 3.54 5.37 10.73 8.69 18.66 8.69a26.22 26.22 0 0 0 6.44-.75 41.15 41.15 0 0 0 30.03-22.95z"/><path d="M95.13 62.57C87 53 75 47.77 61.24 47.77h-1.71a5.9 5.9 0 0 0-5.26-3.21h-.21a6 6 0 0 0 .21 12h.22a6 6 0 0 0 5.25-3.65h1.93a40.88 40.88 0 0 1 22.84 7 28.71 28.71 0 0 1 11.37 13.71 14.87 14.87 0 0 1-.21 12.65A15.76 15.76 0 0 1 81 95.07a27.55 27.55 0 0 1-10.51-2.25c-1.18 1.07-3.32 2.78-4.82 3.86a33.16 33.16 0 0 0 13.8 3.32c10.3 0 17.91-5.68 20.81-11.37 3.11-6.22 2.89-16.94-5.15-26.06z"/><path d="M40.65 77.69a6 6 0 0 0 6 5.8h.21a6 6 0 0 0-.21-12h-.22a1.8 1.8 0 0 0-.75.11 39.29 39.29 0 0 1-5.57-23.81 28.73 28.73 0 0 1 6.32-16.62c3.11-4 9.12-5.9 13.19-6 11.38-.24 16.21 13.92 16.53 19.6 1.39.32 3.75 1.07 5.36 1.61C80.22 29 69.5 20 59.2 20c-9.65 0-18.55 7-22.09 17.27C32.18 51 35.4 64.18 41.4 74.58a4.85 4.85 0 0 0-.75 3.11z"/></g></svg>; case 'integrations/rollbar-text': return <svg viewBox="0 0 665.93 117.72" width={ `${ width }px` } height={ `${ height }px` } fill={ `${ fill }` }><path d="M249.69 9.76Q260 18.17 260 33.86a28.78 28.78 0 0 1-5.66 17.95A28.41 28.41 0 0 1 239 62v.32a21.17 21.17 0 0 1 9.7 6.87q3.56 4.61 7.6 15.44l11.48 31.86h-25.86l-9.22-27.33q-3.24-9.38-7.52-13t-12.69-3.64H199.4v44h-23.78V1.36H218q21.34 0 31.69 8.4ZM199.4 53.59h16.82q19.89 0 19.89-17 0-16-21.18-16H199.4ZM333.21 37.09a36.54 36.54 0 0 1 14.63 15.2Q353 62.16 353 75.42t-5.17 23.12a36.53 36.53 0 0 1-14.63 15.2q-9.46 5.34-22.23 5.34t-22.24-5.34a36.52 36.52 0 0 1-14.63-15.2q-5.18-9.86-5.17-23.12t5.17-23.12a36.53 36.53 0 0 1 14.63-15.2q9.46-5.34 22.24-5.34t22.24 5.33Zm-35.58 19.25q-4.93 7.12-4.93 19.08t4.93 19.08a16.06 16.06 0 0 0 26.68 0q4.93-7.11 4.93-19.08t-4.93-19.08a16.06 16.06 0 0 0-26.68 0ZM364.34 116.49V1.36H387v115.13ZM402.66 116.49V1.36h22.64v115.13ZM463.78 44.21a25.4 25.4 0 0 1 9.95-8.81 29.11 29.11 0 0 1 13.34-3.15 30.54 30.54 0 0 1 17.87 5.34 33.88 33.88 0 0 1 11.8 15.12 58.14 58.14 0 0 1 4.12 22.72 57.16 57.16 0 0 1-4.2 22.72 34.16 34.16 0 0 1-12 15.12 30.91 30.91 0 0 1-18 5.34 28.21 28.21 0 0 1-13.58-3.4 25.82 25.82 0 0 1-10-9.54h-.32v10.83h-22V1.36h22.64v42.85ZM467 56.42q-4.69 6.88-4.69 19t4.69 19q4.69 6.87 12.45 6.87 8.08 0 12.94-7t4.85-18.84q0-11.8-4.85-18.84t-12.94-7q-7.74-.07-12.45 6.81ZM593.8 39.92q8.89 8.17 8.89 24.34V94q0 13.91 2.26 22.48h-20.37a47.18 47.18 0 0 1-1.13-11h-.32q-9.06 12.94-26.68 12.94-12.78 0-20.38-7a23.28 23.28 0 0 1-7.6-17.87q0-10.51 7.36-17t24.5-9.22q10.35-1.61 20.54-2.26v-2.91q0-7.44-3.56-11t-9.86-3.56q-6.31 0-9.78 3.31a13.08 13.08 0 0 0-3.8 9.14h-22a27.88 27.88 0 0 1 9.7-20.38q9.06-7.92 25.87-7.92 17.46.01 26.36 8.17Zm-29.43 40.67q-7.12 1.29-10.19 3.88t-3.07 7.44a9.61 9.61 0 0 0 3.15 7.52q3.15 2.83 9 2.83a21.7 21.7 0 0 0 7.2-1.21 14.92 14.92 0 0 0 5.74-3.48 14.06 14.06 0 0 0 3.72-6.23 36.56 36.56 0 0 0 1-9.46v-3.07q-9.76.65-16.55 1.78ZM668.34 34v19.59a40.91 40.91 0 0 0-7.12-.81q-10.35 0-15.85 6.39t-5.5 18.52v38.81h-22.63V34.34h20.86V46h.32a27 27 0 0 1 9.58-9.8 25.68 25.68 0 0 1 12.86-3.15 27 27 0 0 1 7.48.95ZM145.48 116.33V15.85c-.26-6.41-.48-16.16-14.76-10.33C106.81 14.27 82.77 22.69 59.09 32c-17.95 7.05-37.61 14.79-47.73 52.56-2.65 9.9-6.29 21.88-8.94 31.78h23.24c2.14-8 4.82-18.09 7-26.09 7.25-27 21.48-32.64 34.47-37.75 18.64-7.33 37.54-14 56.35-20.9v84.75Z" transform="translate(-2.42 -1.36)"/><path d="M58.46 62.37A36.58 36.58 0 0 0 51.83 67c-7 6.25-10.82 15.17-13.21 24.07L32 115.53h26.46Z" transform="translate(-2.42 -1.36)"/><path d="M87.24 50.68q-9 3.34-17.88 6.83c-1.59.62-3.16 1.24-4.69 1.89v56.13h22.57Z" transform="translate(-2.42 -1.36)"/><path d="M117.23 115.53V39.7c-7.93 2.88-15.87 5.76-23.79 8.68v67.15Z" transform="translate(-2.42 -1.36)"/></svg>; - case 'integrations/rollbar': return <svg viewBox="0 0 304 240" width={ `${ width }px` } height={ `${ height }px` } fill={ `${ fill }` }><path d="M303.8 239.1V25.7c-.5-13.6-.9-34.3-31.4-21.9C221.7 22.4 170.6 40.2 120.3 60 82.2 75 40.5 91.4 19 171.6c-5.6 21-13.4 46.4-19 67.5h49.4c4.6-17 10.2-38.4 14.8-55.4 15.4-57.4 45.6-69.3 73.2-80.1C176.9 88 217 73.8 257 59.1V239h46.8z"/><path d="M119 124.5c-5 2.8-9.8 6.1-14.1 9.9-14.9 13.3-23 32.2-28 51.1l-14.1 51.9H119V124.5z"/><path d="M180.1 99.7c-12.7 4.7-25.3 9.6-38 14.5-3.4 1.3-6.7 2.6-10 4v119.2H180l.1-137.7z"/><path d="M243.8 237.4v-161c-16.8 6.1-33.7 12.2-50.5 18.4v142.6h50.5z"/></svg>; + case 'integrations/rollbar': return <svg fill="none" width={ `${ width }px` } height={ `${ height }px` } fill={ `${ fill }` }><g clipPath="url(#a)"><path clipRule="evenodd" d="M84.94 2.095a2.231 2.231 0 0 0-.041-.404c0-.04-.02-.077-.033-.118-.012-.04-.052-.182-.085-.27l-.06-.13a1.767 1.767 0 0 0-.13-.222c-.028-.045-.056-.09-.088-.134L84.446.74c-.032-.036-.069-.064-.101-.097l-.081-.097-.065-.044a2.032 2.032 0 0 0-.186-.142l-.157-.1a2.042 2.042 0 0 0-.215-.098l-.17-.068c-.076-.025-.157-.037-.234-.053l-.174-.036a2.258 2.258 0 0 0-.287 0h-.162c-1.083.097-15.4 1.463-31.492 8.64-9.663 4.298-17.143 10.896-21.85 18.906l-1.212.526C10.83 35.767.537 50.714.537 68.064v.29a2.099 2.099 0 0 0 2.099 2.095h57.19c.112 0 .223-.009.332-.028l.146-.036c.069-.02.138-.033.206-.057.069-.024.101-.049.154-.073.052-.024.121-.048.178-.08.107-.064.208-.135.303-.215L84.191 50.53a2.085 2.085 0 0 0 .744-1.618V2.095h.004ZM64.1 61.979l-2.211 1.864V22.5L80.747 6.607V47.95L64.1 61.979ZM26.45 51.018h31.246v15.239H8.369l18.081-15.24Zm26.374-38.54a108.046 108.046 0 0 1 22.97-7.185L58.938 19.505c-7.781.95-15.434 2.751-22.82 5.373 4.172-5.094 9.8-9.32 16.706-12.4ZM32.111 30.907a108.216 108.216 0 0 1 25.585-7.015v22.933H27.825a37.058 37.058 0 0 1 4.286-15.918Zm-5.754 2.677a41.523 41.523 0 0 0-2.773 14.357l-18.6 15.695c1.408-12.752 8.98-23.418 21.373-30.053Z" fill="#3569F3"/></g><defs><clipPath id="a"><path fill="#fff" transform="translate(.537)" d="M0 0h86.778v71H0z"/></clipPath></defs></svg>; case 'integrations/segment': return <svg viewBox="0 0 256 238" preserveAspectRatio="xMidYMid" width={ `${ width }px` } height={ `${ height }px` } fill={ `${ fill }` }><path d="M159.58 162.377H12.294C5.5 162.377 0 156.876 0 150.084c0-6.792 5.501-12.293 12.293-12.293H159.58c6.791 0 12.292 5.501 12.292 12.293 0 6.792-5.5 12.293-12.292 12.293Zm84.127-62.454H96.426c-6.792 0-12.293-5.501-12.293-12.293 0-6.792 5.5-12.293 12.293-12.293h147.281c6.792 0 12.293 5.5 12.293 12.293 0 6.792-5.501 12.293-12.293 12.293Zm-30.45-59.139c0 6.79-5.503 12.293-12.292 12.293-6.79 0-12.293-5.503-12.293-12.293 0-6.789 5.503-12.292 12.293-12.292 6.789 0 12.293 5.503 12.293 12.292ZM67.326 196.948c0 6.789-5.503 12.292-12.293 12.292-6.789 0-12.293-5.503-12.293-12.292 0-6.79 5.504-12.293 12.293-12.293 6.79 0 12.293 5.503 12.293 12.293Z" fill="#93C8A2"/><path d="M127.933 237.522c-11.992 0-23.836-1.782-35.2-5.305-6.482-2.008-10.108-8.89-8.1-15.37 2.008-6.512 8.907-10.136 15.373-8.11 9.006 2.796 18.399 4.209 27.927 4.209 41.69 0 77.89-26.754 90.081-66.593a12.271 12.271 0 0 1 15.343-8.14c6.487 1.966 10.136 8.847 8.152 15.328-15.37 50.224-61.015 83.98-113.576 83.98ZM26.109 99.84a12.286 12.286 0 0 1-11.75-15.887C29.734 33.733 75.378 0 127.934 0c12 0 23.845 1.782 35.2 5.308a12.286 12.286 0 0 1 8.1 15.373 12.271 12.271 0 0 1-15.373 8.097 95.04 95.04 0 0 0-27.927-4.177c-41.682 0-77.887 26.753-90.078 66.592a12.296 12.296 0 0 1-11.743 8.693l-.003-.046Z" fill="#43AF79"/></svg>; case 'integrations/sentry-text': return <svg viewBox="0 0 717.11 249.68" width={ `${ width }px` } height={ `${ height }px` } fill={ `${ fill }` }><path d="m430.56 143.76-44.49-57.43H375v77h11.22v-59l45.74 59h9.82v-77h-11.22Zm-112-14.27h39.84v-10h-39.88V96.31h45v-10h-56.45v77h57v-10h-45.55Zm-46.84-9.78c-15.57-3.72-19.83-6.69-19.83-13.84 0-6.46 5.71-10.81 14.22-10.81 7.09 0 14.07 2.51 21.3 7.67l6.06-8.54c-8-6.13-16.65-9-27.13-9-15.25 0-25.89 9-25.89 21.92 0 13.84 9 18.63 25.5 22.63 14.51 3.35 18.93 6.5 18.93 13.5s-6 11.38-15.35 11.38c-9.07 0-16.81-3-25-9.82l-6.79 8.08a47.82 47.82 0 0 0 31.41 11.6c16.49 0 27.14-8.87 27.14-22.6-.02-11.65-6.91-17.88-24.61-22.17Zm373.9-33.37-23.19 36.31-23-36.31H586l30.51 46.54v30.47h11.56v-30.82l30.5-46.19ZM450.87 96.76h25.23v66.58h11.57V96.76h25.23V86.33h-62Zm115.53 36.52c11.64-3.21 18-11.37 18-23 0-14.78-10.84-24-28.28-24H522v77h11.45v-27.66h19.42l19.54 27.72h13.37l-21.1-29.58Zm-33-7.52V96.53H555c11.27 0 17.74 5.31 17.74 14.56 0 8.91-6.92 14.67-17.62 14.67ZM144.9 65.43a13.75 13.75 0 0 0-23.81 0l-19.6 33.95 5 2.87a96.14 96.14 0 0 1 47.83 77.4h-13.76a82.4 82.4 0 0 0-41-65.54l-5-2.86L76.3 143l5 2.87a46.35 46.35 0 0 1 22.46 33.78H72.33a2.27 2.27 0 0 1-2-3.41l8.76-15.17a31.87 31.87 0 0 0-10-5.71l-8.67 15.14a13.75 13.75 0 0 0 11.91 20.62h43.25v-5.73A57.16 57.16 0 0 0 91.84 139l6.88-11.92a70.93 70.93 0 0 1 30.56 58.26v5.74h36.65v-5.73a107.62 107.62 0 0 0-48.84-90.05L131 71.17a2.27 2.27 0 0 1 3.93 0l60.66 105.07a2.27 2.27 0 0 1-2 3.41H179.4c.18 3.83.2 7.66 0 11.48h14.24a13.75 13.75 0 0 0 11.91-20.62Z"/></svg>; case 'integrations/sentry': return <svg viewBox="0 0 150 134" width={ `${ width }px` } height={ `${ height }px` } fill={ `${ fill }` }><path d="M86.9 7.43a13.75 13.75 0 0 0-23.81 0l-19.6 33.95 5 2.87a96.14 96.14 0 0 1 47.83 77.4H82.56a82.4 82.4 0 0 0-41-65.54l-5-2.86L18.3 85l5 2.87a46.35 46.35 0 0 1 22.46 33.78H14.33a2.27 2.27 0 0 1-2-3.41l8.76-15.17a31.87 31.87 0 0 0-10-5.71L2.42 112.5a13.75 13.75 0 0 0 11.91 20.62h43.25v-5.73A57.16 57.16 0 0 0 33.84 81l6.88-11.92a70.93 70.93 0 0 1 30.56 58.26v5.74h36.65v-5.73A107.62 107.62 0 0 0 59.09 37.3L73 13.17a2.27 2.27 0 0 1 3.93 0l60.66 105.07a2.27 2.27 0 0 1-2 3.41H121.4c.18 3.83.2 7.66 0 11.48h14.24a13.75 13.75 0 0 0 11.91-20.62L86.9 7.43Z"/></svg>; @@ -281,7 +291,9 @@ const SVG = (props: Props) => { case 'mobile': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M11 1a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h6zM5 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H5z"/><path d="M8 14a1 1 0 1 0 0-2 1 1 0 0 0 0 2z"/></svg>; case 'mouse-alt': return <svg viewBox="0 0 384 512" width={ `${ width }px` } height={ `${ height }px` } ><path d="M224 0h-64A160 160 0 0 0 0 160v192a160 160 0 0 0 160 160h64a160 160 0 0 0 160-160V160A160 160 0 0 0 224 0zm128 352a128.14 128.14 0 0 1-128 128h-64A128.14 128.14 0 0 1 32 352V160A128.14 128.14 0 0 1 160 32h64a128.14 128.14 0 0 1 128 128zM192 80a48.05 48.05 0 0 0-48 48v32a48 48 0 0 0 96 0v-32a48.05 48.05 0 0 0-48-48zm16 80a16 16 0 0 1-32 0v-32a16 16 0 0 1 32 0z"/></svg>; case 'next1': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z"/></svg>; + case 'no-dashboard': return <svg viewBox="0 0 100 100" width={ `${ width }px` } height={ `${ height }px` } ><rect width="100" height="100" rx="13.158" fill-opacity=".08"/><g clipPath="url(#a)" fill-opacity=".5"><path d="M27.417 33.333a2.083 2.083 0 1 0 0-4.166 2.083 2.083 0 0 0 0 4.166Zm8.333-2.083a2.083 2.083 0 1 1-4.167 0 2.083 2.083 0 0 1 4.167 0Zm4.167 2.083a2.083 2.083 0 1 0 0-4.166 2.083 2.083 0 0 0 0 4.166Z"/><path d="M25.333 20.833A8.333 8.333 0 0 0 17 29.167v41.666a8.334 8.334 0 0 0 8.333 8.334h50a8.333 8.333 0 0 0 8.334-8.334V29.167a8.333 8.333 0 0 0-8.334-8.334h-50ZM79.5 29.167V37.5H21.167v-8.333A4.167 4.167 0 0 1 25.333 25h50a4.167 4.167 0 0 1 4.167 4.167ZM25.333 75a4.167 4.167 0 0 1-4.166-4.167V41.667H79.5v29.166A4.167 4.167 0 0 1 75.333 75h-50Z"/></g><defs><clipPath id="a"><path fill="#fff" transform="translate(17 20)" d="M0 0h66.667v60H0z"/></clipPath></defs></svg>; case 'no-metrics-chart': return <svg viewBox="0 0 250 78" width={ `${ width }px` } height={ `${ height }px` } ><path clipRule="evenodd" d="m239.854 9.853-58.513 58.8L93.005 29.55 9.44 68.51l-.422-.906L92.995 28.45l88.122 39.01 58.029-58.314.708.706Z" fill="#C2C2C2"/><path d="M9.66 77.694c5.334 0 9.659-4.325 9.659-9.66 0-5.334-4.325-9.66-9.66-9.66C4.325 58.375 0 62.7 0 68.035c0 5.335 4.325 9.66 9.66 9.66Z" fill="#C7CCF9"/><path d="M92.985 38.907c5.334 0 9.659-4.325 9.659-9.66 0-5.334-4.325-9.659-9.66-9.659a9.66 9.66 0 0 0-9.659 9.66 9.66 9.66 0 0 0 9.66 9.66Z" fill="#B4E4E7"/><path d="M180.31 77.694c5.335 0 9.659-4.325 9.659-9.66 0-5.334-4.324-9.66-9.659-9.66-5.335 0-9.66 4.326-9.66 9.66 0 5.335 4.325 9.66 9.66 9.66Z" fill="#C7CCF9"/><path d="M239.659 19.319c5.335 0 9.66-4.325 9.66-9.66 0-5.334-4.325-9.659-9.66-9.659C234.325 0 230 4.325 230 9.66c0 5.334 4.325 9.659 9.659 9.659Z" fill="#B4E4E7"/></svg>; + case 'no-metrics': return <svg viewBox="0 0 100 100" width={ `${ width }px` } height={ `${ height }px` } ><rect width="100" height="100" rx="13.158" fill-opacity=".08"/><g clipPath="url(#a)" fill-opacity=".5"><path d="M36.875 65A1.875 1.875 0 0 1 35 63.125v-7.5a1.875 1.875 0 0 1 1.875-1.875h3.75a1.875 1.875 0 0 1 1.875 1.875v7.5A1.875 1.875 0 0 1 40.625 65h-3.75Zm11.25 0a1.875 1.875 0 0 1-1.875-1.875v-15a1.875 1.875 0 0 1 1.875-1.875h3.75a1.875 1.875 0 0 1 1.875 1.875v15A1.875 1.875 0 0 1 51.875 65h-3.75Zm11.25 0a1.875 1.875 0 0 1-1.875-1.875v-22.5a1.875 1.875 0 0 1 1.875-1.875h3.75A1.875 1.875 0 0 1 65 40.625v22.5A1.875 1.875 0 0 1 63.125 65h-3.75Z"/><path d="M35 20a7.5 7.5 0 0 0-7.5 7.5v45A7.5 7.5 0 0 0 35 80h30a7.5 7.5 0 0 0 7.5-7.5v-45A7.5 7.5 0 0 0 65 20H35Zm0 3.75h30a3.75 3.75 0 0 1 3.75 3.75v45A3.75 3.75 0 0 1 65 76.25H35a3.75 3.75 0 0 1-3.75-3.75v-45A3.75 3.75 0 0 1 35 23.75Z"/></g><defs><clipPath id="a"><path fill="#fff" transform="translate(20 20)" d="M0 0h60v60H0z"/></clipPath></defs></svg>; case 'os/android': return <svg viewBox="0 0 419 519" width={ `${ width }px` } height={ `${ height }px` } ><path d="M271.926 51.66C315.852 74.373 345.991 120.142 346 172.9c0 5.033-4.077 9.1-9.1 9.1H82.1a9.097 9.097 0 0 1-9.1-9.1c0-52.767 30.148-98.537 74.074-121.24L124.351 13.79c-2.584-4.313-1.192-9.9 3.122-12.484 4.313-2.585 9.9-1.202 12.485 3.12l23.978 39.965c14.278-5.077 29.566-7.99 45.564-7.99 15.998 0 31.286 2.913 45.564 7.99l23.978-39.964a9.08 9.08 0 0 1 12.485-3.121c4.314 2.584 5.706 8.17 3.122 12.484L271.926 51.66ZM91.546 163.801h235.908C322.795 102.808 271.671 54.61 209.5 54.61c-62.171 0-113.295 48.2-117.954 109.192ZM273.993 104a6.006 6.006 0 0 1 6.007 6v12c0 3.314-2.685 6-5.998 6h-12.004a5.998 5.998 0 0 1-5.998-6v-12c0-3.314 2.685-6 5.998-6h11.995Zm-116.99 0a5.998 5.998 0 0 1 5.997 6v12c0 3.314-2.685 6-5.998 6h-12.004a5.998 5.998 0 0 1-5.998-6v-12c0-3.314 2.685-6 5.998-6h12.004ZM336.9 191c5.032 0 9.1 4.073 9.1 9.111v183.78c0 24.263-19.729 43.998-43.99 43.998H291.4v54.721c0 20.063-16.325 36.39-36.4 36.39s-36.4-16.327-36.4-36.39V427.89h-18.2v54.721c0 20.063-16.325 36.39-36.4 36.39s-36.4-16.327-36.4-36.39V427.89h-10.61c-24.252 0-43.99-19.735-43.99-43.998v-183.78c0-5.038 4.077-9.111 9.1-9.111h254.8Zm-9.1 192.891V209.222H91.2v174.67c0 14.204 11.575 25.775 25.799 25.775H136.7c5.023 0 9.1 4.072 9.1 9.11v63.833c0 10.013 8.163 18.168 18.2 18.168 10.037 0 18.2-8.146 18.2-18.168v-63.832c0-5.039 4.077-9.111 9.1-9.111h36.4c5.023 0 9.1 4.072 9.1 9.11v63.833c0 10.013 8.163 18.168 18.2 18.168 10.037 0 18.2-8.146 18.2-18.168v-63.832c0-5.039 4.077-9.111 9.1-9.111h19.71c14.224 0 25.79-11.562 25.79-25.776ZM387.5 191c17.37 0 31.5 14.298 31.5 31.87v127.26c0 17.572-14.13 31.87-31.5 31.87-17.37 0-31.5-14.298-31.5-31.87V222.87c0-17.572 14.13-31.87 31.5-31.87ZM401 350.13V222.87c0-7.54-6.057-13.68-13.5-13.68s-13.5 6.14-13.5 13.68v127.26c0 7.54 6.057 13.68 13.5 13.68s13.5-6.14 13.5-13.68ZM31.5 191c17.37 0 31.5 14.298 31.5 31.87v127.26C63 367.702 48.87 382 31.5 382 14.13 382 0 367.702 0 350.13V222.87C0 205.298 14.13 191 31.5 191ZM45 350.13V222.87c0-7.54-6.057-13.68-13.5-13.68S18 215.33 18 222.87v127.26c0 7.54 6.057 13.68 13.5 13.68S45 357.67 45 350.13Z"/></svg>; case 'os/chrome_os': return <svg viewBox="0 0 517 517" width={ `${ width }px` } height={ `${ height }px` } ><path d="M148.978 223c8.725-25.136 24.616-44.93 47.674-59.383 23.058-14.453 48.297-21.05 75.718-19.794L464 154.191c-20.565-41.474-50.79-73.835-90.674-97.086C337.181 36.368 298.232 26 256.478 26c-34.275 0-67.304 7.54-99.087 22.622C125.61 63.703 98.811 85.068 77 112.718L148.978 223Zm61.483-37.353c-18.07 11.327-30.15 26.373-36.92 45.879l-17.375 50.057-111.15-170.3 11.571-14.668C77 67 110.845 41.93 146.245 25.132 181.488 8.41 218.321 0 256.478 0c46.292 0 89.702 11.556 129.787 34.553l.155.09c44.282 25.814 78.05 61.971 100.874 107.998l19.794 39.92-236.01-12.77c-22.108-.994-42.08 4.237-60.617 15.856ZM179 258.5c0 21.806 7.583 40.34 22.75 55.604C216.917 329.368 235.333 337 257 337s40.083-7.632 55.25-22.896C327.417 298.84 335 280.306 335 258.5c0-21.806-7.583-40.34-22.75-55.604C297.083 187.632 278.667 180 257 180s-40.083 7.632-55.25 22.896C186.583 218.16 179 236.694 179 258.5Zm-26 0c0-28.647 10.282-53.777 30.307-73.93C203.363 164.384 228.422 154 257 154c28.578 0 53.637 10.384 73.693 30.57C350.718 204.723 361 229.853 361 258.5c0 28.647-10.282 53.777-30.307 73.93C310.637 352.616 285.578 363 257 363c-28.578 0-53.637-10.384-73.693-30.57C163.282 312.277 153 287.147 153 258.5ZM474.853 175l-129.839 6.535c17.437 20.54 26.466 44.348 27.089 71.424.623 27.075-6.539 52.128-21.484 75.157L246 489.636c46.082 2.49 89.05-7.78 128.905-30.81 33.005-19.296 59.47-44.504 79.398-75.625 19.928-31.121 31.76-65.043 35.496-101.766 3.736-36.724-1.245-72.201-14.946-106.435Zm24.14-11.66c15.26 38.132 20.834 79.828 16.672 120.726-4.149 40.78-17.341 78.602-39.466 113.155-22.164 34.616-51.648 62.698-88.172 84.05l-.113.067c-44.226 25.555-92.201 37.021-143.317 34.26-10.485-.58-18.184-1.113-23.097-1.598-5.048-.498-12.29-1.44-21.725-2.824l24.403-35.675 104.63-161.538c12.111-18.661 17.804-38.576 17.302-60.406-.486-21.128-7.342-39.204-20.917-55.195l-34.08-40.146 200.98-10.117 6.9 15.24ZM151.164 304.41 63.363 132C38.454 170.73 26 213.207 26 259.433c0 38.105 8.562 73.555 25.687 106.35 17.124 32.796 40.632 60.125 70.522 81.989 29.89 21.863 63.205 35.606 99.945 41.228L281 371.874c-26.154 4.997-51.218 1.093-75.192-11.713-23.975-12.805-42.189-31.39-54.643-55.752Zm66.892 32.818c18.76 10.02 37.782 12.983 58.063 9.108l51.778-9.894L239.5 516.5l-21.279-1.8c-40.865-6.253-78.097-21.61-111.362-45.943-33.108-24.217-59.249-54.608-78.22-90.94C9.56 341.28 0 301.698 0 259.434c0-51.208 13.886-98.568 41.495-141.497L70.5 79.5l103.824 213.092c10.042 19.637 24.454 34.338 43.733 44.636Z"/></svg>; case 'os/fedora': return <svg viewBox="0 0 204.7 200.9" width={ `${ width }px` } height={ `${ height }px` } ><path d="M102.7 1.987c-55.41 0-100.3 44.21-100.4 98.79h-.018v76.47H2.3c.027 12.38 10.22 22.4 22.8 22.4h77.58c55.42-.035 100.3-44.24 100.3-98.79 0-54.58-44.91-98.79-100.4-98.79zm20.39 40.68c16.85 0 32.76 12.7 32.76 30.23 0 1.625.01 3.252-.26 5.095-.467 4.662-4.794 8.012-9.505 7.355-4.711-.665-7.909-5.07-7.037-9.679.08-.526.108-1.352.108-2.772 0-9.938-8.257-13.77-16.06-13.77-7.805 0-14.84 6.462-14.85 13.77.135 8.455 0 16.84 0 25.29l14.49-.107c11.31-.23 11.44 16.54.13 16.46l-14.61.107c-.035 6.801.054 5.571.019 8.996 0 0 .122 8.318-.13 14.62-1.749 18.52-17.76 33.32-37 33.32-20.4 0-37.2-16.41-37.2-36.54.612-20.7 17.38-36.99 38.5-36.8l11.78-.087v16.43l-11.78.106h-.062c-11.6.338-21.55 8.1-21.74 20.34 0 11.15 9.148 20.08 20.5 20.08 11.34 0 20.42-8.124 20.42-20.06l-.018-62.23c.006-1.155.044-2.073.173-3.347 1.914-15.22 15.74-26.82 31.39-26.82z"/></svg>; @@ -298,6 +310,7 @@ const SVG = (props: Props) => { case 'pencil-stop': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><g clipPath="url(#a)"><path clipRule="evenodd" d="M12.5-.207 16.207 3.5 5.781 13.926l-6.179 2.472 2.472-6.179L12.5-.207ZM2.926 10.78l-1.529 3.821 3.822-1.528L14.793 3.5 12.5 1.207l-9.574 9.574Z"/><path d="M9 4.5a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0ZM3.012 2.613a.282.282 0 0 0-.399.399L4.103 4.5l-1.49 1.488a.282.282 0 0 0 .399.399L4.5 4.897l1.488 1.49a.282.282 0 0 0 .399-.399L4.897 4.5l1.49-1.488a.282.282 0 0 0-.399-.399L4.5 4.103l-1.488-1.49Z"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>; case 'pencil': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/></svg>; case 'percent': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M13.442 2.558a.625.625 0 0 1 0 .884l-10 10a.625.625 0 1 1-.884-.884l10-10a.625.625 0 0 1 .884 0zM4.5 6a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm0 1a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zm7 6a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm0 1a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z"/></svg>; + case 'performance-icon': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M8 4a.5.5 0 0 1 .5.5V6a.5.5 0 0 1-1 0V4.5A.5.5 0 0 1 8 4zM3.732 5.732a.5.5 0 0 1 .707 0l.915.914a.5.5 0 1 1-.708.708l-.914-.915a.5.5 0 0 1 0-.707zM2 10a.5.5 0 0 1 .5-.5h1.586a.5.5 0 0 1 0 1H2.5A.5.5 0 0 1 2 10zm9.5 0a.5.5 0 0 1 .5-.5h1.5a.5.5 0 0 1 0 1H12a.5.5 0 0 1-.5-.5zm.754-4.246a.389.389 0 0 0-.527-.02L7.547 9.31a.91.91 0 1 0 1.302 1.258l3.434-4.297a.389.389 0 0 0-.029-.518z"/><path d="M0 10a8 8 0 1 1 15.547 2.661c-.442 1.253-1.845 1.602-2.932 1.25C11.309 13.488 9.475 13 8 13c-1.474 0-3.31.488-4.615.911-1.087.352-2.49.003-2.932-1.25A7.988 7.988 0 0 1 0 10zm8-7a7 7 0 0 0-6.603 9.329c.203.575.923.876 1.68.63C4.397 12.533 6.358 12 8 12s3.604.532 4.923.96c.757.245 1.477-.056 1.68-.631A7 7 0 0 0 8 3z"/></svg>; case 'person-fill': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M3 14s-1 0-1-1 1-4 6-4 6 3 6 4-1 1-1 1H3zm5-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"/></svg>; case 'person': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm2-3a2 2 0 1 1-4 0 2 2 0 0 1 4 0zm4 8c0 1-1 1-1 1H3s-1 0-1-1 1-4 6-4 6 3 6 4zm-1-.004c-.001-.246-.154-.986-.832-1.664C11.516 10.68 10.289 10 8 10c-2.29 0-3.516.68-4.168 1.332-.678.678-.83 1.418-.832 1.664h10z"/></svg>; case 'pie-chart-fill': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M15.985 8.5H8.207l-5.5 5.5a8 8 0 0 0 13.277-5.5zM2 13.292A8 8 0 0 1 7.5.015v7.778l-5.5 5.5zM8.5.015V7.5h7.485A8.001 8.001 0 0 0 8.5.015z"/></svg>; @@ -313,12 +326,14 @@ const SVG = (props: Props) => { case 'prev1': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z"/></svg>; case 'puzzle-piece': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M6.75 1a.75.75 0 0 1 .75.75V8a.5.5 0 0 0 1 0V5.467l.086-.004c.317-.012.637-.008.816.027.134.027.294.096.448.182.077.042.15.147.15.314V8a.5.5 0 1 0 1 0V6.435a4.9 4.9 0 0 1 .106-.01c.316-.024.584-.01.708.04.118.046.3.207.486.43.081.096.15.19.2.259V8.5a.5.5 0 0 0 1 0v-1h.342a1 1 0 0 1 .995 1.1l-.271 2.715a2.5 2.5 0 0 1-.317.991l-1.395 2.442a.5.5 0 0 1-.434.252H6.035a.5.5 0 0 1-.416-.223l-1.433-2.15a1.5 1.5 0 0 1-.243-.666l-.345-3.105a.5.5 0 0 1 .399-.546L5 8.11V9a.5.5 0 0 0 1 0V1.75A.75.75 0 0 1 6.75 1zM8.5 4.466V1.75a1.75 1.75 0 1 0-3.5 0v5.34l-1.2.24a1.5 1.5 0 0 0-1.196 1.636l.345 3.106a2.5 2.5 0 0 0 .405 1.11l1.433 2.15A1.5 1.5 0 0 0 6.035 16h6.385a1.5 1.5 0 0 0 1.302-.756l1.395-2.441a3.5 3.5 0 0 0 .444-1.389l.271-2.715a2 2 0 0 0-1.99-2.199h-.581a5.114 5.114 0 0 0-.195-.248c-.191-.229-.51-.568-.88-.716-.364-.146-.846-.132-1.158-.108l-.132.012a1.26 1.26 0 0 0-.56-.642 2.632 2.632 0 0 0-.738-.288c-.31-.062-.739-.058-1.05-.046l-.048.002zm2.094 2.025z"/></svg>; case 'question-circle': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/><path d="M5.255 5.786a.237.237 0 0 0 .241.247h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286zm1.557 5.763c0 .533.425.927 1.01.927.609 0 1.028-.394 1.028-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94z"/></svg>; + case 'question-lg': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M4.475 5.458c-.284 0-.514-.237-.47-.517C4.28 3.24 5.576 2 7.825 2c2.25 0 3.767 1.36 3.767 3.215 0 1.344-.665 2.288-1.79 2.973-1.1.659-1.414 1.118-1.414 2.01v.03a.5.5 0 0 1-.5.5h-.77a.5.5 0 0 1-.5-.495l-.003-.2c-.043-1.221.477-2.001 1.645-2.712 1.03-.632 1.397-1.135 1.397-2.028 0-.979-.758-1.698-1.926-1.698-1.009 0-1.71.529-1.938 1.402-.066.254-.278.461-.54.461h-.777ZM7.496 14c.622 0 1.095-.474 1.095-1.09 0-.618-.473-1.092-1.095-1.092-.606 0-1.087.474-1.087 1.091S6.89 14 7.496 14Z"/></svg>; case 'quote-left': return <svg viewBox="0 0 512 512" width={ `${ width }px` } height={ `${ height }px` } ><path d="M464 256h-80v-64c0-35.3 28.7-64 64-64h8c13.3 0 24-10.7 24-24V56c0-13.3-10.7-24-24-24h-8c-88.4 0-160 71.6-160 160v240c0 26.5 21.5 48 48 48h128c26.5 0 48-21.5 48-48V304c0-26.5-21.5-48-48-48zm-288 0H96v-64c0-35.3 28.7-64 64-64h8c13.3 0 24-10.7 24-24V56c0-13.3-10.7-24-24-24h-8C71.6 32 0 103.6 0 192v240c0 26.5 21.5 48 48 48h128c26.5 0 48-21.5 48-48V304c0-26.5-21.5-48-48-48z"/></svg>; case 'quote-right': return <svg viewBox="0 0 512 512" width={ `${ width }px` } height={ `${ height }px` } ><path d="M464 32H336c-26.5 0-48 21.5-48 48v128c0 26.5 21.5 48 48 48h80v64c0 35.3-28.7 64-64 64h-8c-13.3 0-24 10.7-24 24v48c0 13.3 10.7 24 24 24h8c88.4 0 160-71.6 160-160V80c0-26.5-21.5-48-48-48zm-288 0H48C21.5 32 0 53.5 0 80v128c0 26.5 21.5 48 48 48h80v64c0 35.3-28.7 64-64 64h-8c-13.3 0-24 10.7-24 24v48c0 13.3 10.7 24 24 24h8c88.4 0 160-71.6 160-160V80c0-26.5-21.5-48-48-48z"/></svg>; case 'redo-back': return <svg viewBox="0 0 496 496" width={ `${ width }px` } height={ `${ height }px` } ><path d="M12 0h10c6.627 0 12 5.373 12 12v110.625C77.196 49.047 157.239-.285 248.793.001 385.18.428 496.213 112.009 496 248.396 495.786 385.181 384.834 496 248 496c-63.926 0-122.202-24.187-166.178-63.908-5.113-4.618-5.354-12.561-.482-17.433l7.069-7.069c4.503-4.503 11.749-4.714 16.482-.454C142.782 441.238 192.935 462 248 462c117.744 0 214-95.331 214-214 0-117.744-95.331-214-214-214-82.862 0-154.737 47.077-190.289 116H172c6.627 0 12 5.373 12 12v10c0 6.627-5.373 12-12 12H12c-6.627 0-12-5.373-12-12V12C0 5.373 5.373 0 12 0Z"/></svg>; case 'redo': return <svg viewBox="0 0 512 512" width={ `${ width }px` } height={ `${ height }px` } ><path d="M492 8h-10c-6.627 0-12 5.373-12 12v110.625C426.804 57.047 346.761 7.715 255.207 8.001 118.82 8.428 7.787 120.009 8 256.396 8.214 393.181 119.166 504 256 504c63.926 0 122.202-24.187 166.178-63.908 5.113-4.618 5.354-12.561.482-17.433l-7.069-7.069c-4.503-4.503-11.749-4.714-16.482-.454C361.218 449.238 311.065 470 256 470c-117.744 0-214-95.331-214-214 0-117.744 95.331-214 214-214 82.862 0 154.737 47.077 190.289 116H332c-6.627 0-12 5.373-12 12v10c0 6.627 5.373 12 12 12h160c6.627 0 12-5.373 12-12V20c0-6.627-5.373-12-12-12z"/></svg>; case 'remote-control': return <svg viewBox="0 0 16 14" width={ `${ width }px` } height={ `${ height }px` } ><path d="M.59 2.59A2 2 0 0 0 0 4v8a2 2 0 0 0 .59 1.41A2 2 0 0 0 2 14h5.5a.5.5 0 0 0 0-1H2a1 1 0 0 1-.71-.29A1 1 0 0 1 1 12V7h13v1a.5.5 0 0 0 1 0V4a2 2 0 0 0-2-2H2a2 2 0 0 0-1.41.59ZM14 6H1V4a1 1 0 0 1 .29-.71A1 1 0 0 1 2 3h11a1 1 0 0 1 1 1ZM2.85 4.85a.48.48 0 0 1-.7 0 .48.48 0 0 1 0-.7.48.48 0 0 1 .7 0 .48.48 0 0 1 0 .7Zm1.5 0a.5.5 0 1 1-.7-.7.5.5 0 1 1 .7.7Zm1.5 0a.5.5 0 1 0-.7-.7.5.5 0 1 0 .7.7ZM15 15a3.48 3.48 0 1 0-2.47 1A3.46 3.46 0 0 0 15 15Zm-4.2-4.46a.31.31 0 0 1 .19 0l3.33 1.34a.33.33 0 0 1 .15.11.34.34 0 0 1 0 .37.3.3 0 0 1-.13.12l-.92.46 1 1a.35.35 0 0 1 .1.24.34.34 0 0 1-.1.23.33.33 0 0 1-.23.1.35.35 0 0 1-.24-.1l-1-1-.46.92a.3.3 0 0 1-.12.13.34.34 0 0 1-.37 0 .33.33 0 0 1-.11-.15L10.52 11a.31.31 0 0 1 0-.19.33.33 0 0 1 .26-.26Z" transform="translate(0 -2)"/></svg>; case 'replay-10': return <svg viewBox="0 0 496 496" width={ `${ width }px` } height={ `${ height }px` } ><path d="M12 0h10c6.627 0 12 5.373 12 12v110.625C77.196 49.047 157.24-.285 248.793.001 385.18.428 496.213 112.009 496 248.396 495.786 385.181 384.834 496 248 496c-63.926 0-122.202-24.187-166.178-63.908-5.113-4.618-5.354-12.561-.482-17.433l7.07-7.069c4.502-4.503 11.748-4.714 16.481-.454C142.782 441.238 192.935 462 248.001 462c117.743 0 214-95.331 214-214 0-117.744-95.332-214-214-214-82.863 0-154.738 47.077-190.29 116h114.29c6.626 0 12 5.373 12 12v10c0 6.627-5.374 12-12 12H12c-6.628 0-12-5.373-12-12V12C0 5.373 5.372 0 12 0Zm217.454 351.492h-18.886V230.315L182 230.7v-13.613l47.454-5.177v139.583Zm127.264-53.207c0 17.832-3.947 31.493-11.84 40.983-7.893 9.491-18.71 14.237-32.45 14.237-13.742 0-24.607-4.762-32.596-14.284-7.989-9.523-11.983-23.168-11.983-40.936v-33.074c0-17.768 3.978-31.429 11.935-40.983 7.957-9.555 18.774-14.332 32.451-14.332 13.741 0 24.59 4.777 32.547 14.332 7.957 9.554 11.936 23.215 11.936 40.983v33.074Zm-18.886-37.1c0-12.08-2.189-21.171-6.567-27.275-4.378-6.103-10.721-9.155-19.03-9.155-8.308 0-14.635 3.052-18.981 9.155-4.346 6.104-6.52 15.195-6.52 27.275v40.935c0 12.08 2.206 21.203 6.615 27.37 4.41 6.167 10.77 9.251 19.078 9.251 8.309 0 14.62-3.068 18.934-9.203 4.314-6.136 6.47-15.275 6.47-27.418v-40.935Z"/></svg>; + case 'resources-icon': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M13 0H6a2 2 0 0 0-2 2 2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h7a2 2 0 0 0 2-2 2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zm0 13V4a2 2 0 0 0-2-2H5a1 1 0 0 1 1-1h7a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1zM3 4a1 1 0 0 1 1-1h7a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4z"/></svg>; case 'safe-fill': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M9.778 9.414A2 2 0 1 1 6.95 6.586a2 2 0 0 1 2.828 2.828z"/><path d="M2.5 0A1.5 1.5 0 0 0 1 1.5V3H.5a.5.5 0 0 0 0 1H1v3.5H.5a.5.5 0 0 0 0 1H1V12H.5a.5.5 0 0 0 0 1H1v1.5A1.5 1.5 0 0 0 2.5 16h12a1.5 1.5 0 0 0 1.5-1.5v-13A1.5 1.5 0 0 0 14.5 0h-12zm3.036 4.464 1.09 1.09a3.003 3.003 0 0 1 3.476 0l1.09-1.09a.5.5 0 1 1 .707.708l-1.09 1.09c.74 1.037.74 2.44 0 3.476l1.09 1.09a.5.5 0 1 1-.707.708l-1.09-1.09a3.002 3.002 0 0 1-3.476 0l-1.09 1.09a.5.5 0 1 1-.708-.708l1.09-1.09a3.003 3.003 0 0 1 0-3.476l-1.09-1.09a.5.5 0 1 1 .708-.708zM14 6.5v3a.5.5 0 0 1-1 0v-3a.5.5 0 0 1 1 0z"/></svg>; case 'safe': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M1 1.5A1.5 1.5 0 0 1 2.5 0h12A1.5 1.5 0 0 1 16 1.5v13a1.5 1.5 0 0 1-1.5 1.5h-12A1.5 1.5 0 0 1 1 14.5V13H.5a.5.5 0 0 1 0-1H1V8.5H.5a.5.5 0 0 1 0-1H1V4H.5a.5.5 0 0 1 0-1H1V1.5zM2.5 1a.5.5 0 0 0-.5.5v13a.5.5 0 0 0 .5.5h12a.5.5 0 0 0 .5-.5v-13a.5.5 0 0 0-.5-.5h-12z"/><path d="M13.5 6a.5.5 0 0 1 .5.5v3a.5.5 0 0 1-1 0v-3a.5.5 0 0 1 .5-.5zM4.828 4.464a.5.5 0 0 1 .708 0l1.09 1.09a3.003 3.003 0 0 1 3.476 0l1.09-1.09a.5.5 0 1 1 .707.708l-1.09 1.09c.74 1.037.74 2.44 0 3.476l1.09 1.09a.5.5 0 1 1-.707.708l-1.09-1.09a3.002 3.002 0 0 1-3.476 0l-1.09 1.09a.5.5 0 1 1-.708-.708l1.09-1.09a3.003 3.003 0 0 1 0-3.476l-1.09-1.09a.5.5 0 0 1 0-.708zM6.95 6.586a2 2 0 1 0 2.828 2.828A2 2 0 0 0 6.95 6.586z"/></svg>; case 'sandglass': return <svg viewBox="0 0 384 512" width={ `${ width }px` } height={ `${ height }px` } ><path d="M368 32h4c6.627 0 12-5.373 12-12v-8c0-6.627-5.373-12-12-12H12C5.373 0 0 5.373 0 12v8c0 6.627 5.373 12 12 12h4c0 91.821 44.108 193.657 129.646 224C59.832 286.441 16 388.477 16 480h-4c-6.627 0-12 5.373-12 12v8c0 6.627 5.373 12 12 12h360c6.627 0 12-5.373 12-12v-8c0-6.627-5.373-12-12-12h-4c0-91.821-44.108-193.657-129.646-224C324.168 225.559 368 123.523 368 32zM48 32h288c0 110.457-64.471 200-144 200S48 142.457 48 32zm288 448H48c0-110.457 64.471-200 144-200s144 89.543 144 200z"/></svg>; @@ -330,6 +345,7 @@ const SVG = (props: Props) => { case 'signup': return <svg viewBox="0 0 25 42" width={ `${ width }px` } height={ `${ height }px` } ><path d="M3.5 35A3.501 3.501 0 1 1 0 38.5C0 36.568 1.568 35 3.5 35Zm9-9A3.501 3.501 0 1 1 9 29.5c0-1.932 1.568-3.5 3.5-3.5Zm-9 0A3.501 3.501 0 1 1 0 29.5C0 27.568 1.568 26 3.5 26Zm18-8a3.5 3.5 0 1 1-.002 6.998A3.5 3.5 0 0 1 21.5 18Zm-9 0a3.5 3.5 0 1 1-.002 6.998A3.5 3.5 0 0 1 12.5 18Zm-9 0a3.5 3.5 0 1 1-.002 6.998A3.5 3.5 0 0 1 3.5 18Zm9-9a3.501 3.501 0 1 1-.002 6.998A3.501 3.501 0 0 1 12.5 9Zm-9 0a3.501 3.501 0 1 1-.002 6.998A3.501 3.501 0 0 1 3.5 9Zm0-9C5.432 0 7 1.568 7 3.5S5.432 7 3.5 7A3.501 3.501 0 0 1 0 3.5C0 1.568 1.568 0 3.5 0Z" fill="#FFF"/></svg>; case 'skip-forward-fill': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M15.5 3.5a.5.5 0 0 1 .5.5v8a.5.5 0 0 1-1 0V8.753l-6.267 3.636c-.54.313-1.233-.066-1.233-.697v-2.94l-6.267 3.636C.693 12.703 0 12.324 0 11.693V4.308c0-.63.693-1.01 1.233-.696L7.5 7.248v-2.94c0-.63.693-1.01 1.233-.696L15 7.248V4a.5.5 0 0 1 .5-.5z"/></svg>; case 'skip-forward': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M15.5 3.5a.5.5 0 0 1 .5.5v8a.5.5 0 0 1-1 0V8.752l-6.267 3.636c-.52.302-1.233-.043-1.233-.696v-2.94l-6.267 3.636C.713 12.69 0 12.345 0 11.692V4.308c0-.653.713-.998 1.233-.696L7.5 7.248v-2.94c0-.653.713-.998 1.233-.696L15 7.248V4a.5.5 0 0 1 .5-.5zM1 4.633v6.734L6.804 8 1 4.633zm7.5 0v6.734L14.304 8 8.5 4.633z"/></svg>; + case 'slack': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M3.362 10.11c0 .926-.756 1.681-1.681 1.681S0 11.036 0 10.111C0 9.186.756 8.43 1.68 8.43h1.682v1.68zm.846 0c0-.924.756-1.68 1.681-1.68s1.681.756 1.681 1.68v4.21c0 .924-.756 1.68-1.68 1.68a1.685 1.685 0 0 1-1.682-1.68v-4.21zM5.89 3.362c-.926 0-1.682-.756-1.682-1.681S4.964 0 5.89 0s1.68.756 1.68 1.68v1.682H5.89zm0 .846c.924 0 1.68.756 1.68 1.681S6.814 7.57 5.89 7.57H1.68C.757 7.57 0 6.814 0 5.89c0-.926.756-1.682 1.68-1.682h4.21zm6.749 1.682c0-.926.755-1.682 1.68-1.682.925 0 1.681.756 1.681 1.681s-.756 1.681-1.68 1.681h-1.681V5.89zm-.848 0c0 .924-.755 1.68-1.68 1.68A1.685 1.685 0 0 1 8.43 5.89V1.68C8.43.757 9.186 0 10.11 0c.926 0 1.681.756 1.681 1.68v4.21zm-1.681 6.748c.926 0 1.682.756 1.682 1.681S11.036 16 10.11 16s-1.681-.756-1.681-1.68v-1.682h1.68zm0-.847c-.924 0-1.68-.755-1.68-1.68 0-.925.756-1.681 1.68-1.681h4.21c.924 0 1.68.756 1.68 1.68 0 .926-.756 1.681-1.68 1.681h-4.21z"/></svg>; case 'slash-circle': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/><path d="M11.354 4.646a.5.5 0 0 0-.708 0l-6 6a.5.5 0 0 0 .708.708l6-6a.5.5 0 0 0 0-.708z"/></svg>; case 'sliders': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M11.5 2a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zM9.05 3a2.5 2.5 0 0 1 4.9 0H16v1h-2.05a2.5 2.5 0 0 1-4.9 0H0V3h9.05zM4.5 7a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zM2.05 8a2.5 2.5 0 0 1 4.9 0H16v1H6.95a2.5 2.5 0 0 1-4.9 0H0V8h2.05zm9.45 4a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zm-2.45 1a2.5 2.5 0 0 1 4.9 0H16v1h-2.05a2.5 2.5 0 0 1-4.9 0H0v-1h9.05z"/></svg>; case 'social/slack': return <svg aria-labelledby="simpleicons-slack-icon" viewBox="0 0 24 24" width={ `${ width }px` } height={ `${ height }px` } ><path d="m9.879 10.995 1.035 3.085 3.205-1.074-1.035-3.074-3.205 1.08v-.017z"/><path d="m18.824 14.055-1.555.521.54 1.61a1.246 1.246 0 0 1-1.221 1.637 1.26 1.26 0 0 1-1.155-.849l-.54-1.607-3.21 1.073.539 1.608a1.249 1.249 0 0 1-1.229 1.639 1.266 1.266 0 0 1-1.156-.85l-.539-1.619-1.561.524c-.15.045-.285.061-.435.061a1.269 1.269 0 0 1-1.155-.855 1.235 1.235 0 0 1 .78-1.575l1.56-.525L7.5 11.76l-1.551.525a1.264 1.264 0 0 1-.428.064 1.247 1.247 0 0 1-1.141-.848 1.25 1.25 0 0 1 .796-1.574l1.56-.52-.54-1.605a1.248 1.248 0 0 1 .796-1.575 1.239 1.239 0 0 1 1.574.783l.539 1.608L12.3 7.544l-.54-1.605a1.256 1.256 0 0 1 .789-1.574 1.249 1.249 0 0 1 1.575.791l.54 1.621 1.555-.51a1.247 1.247 0 0 1 1.575.779 1.244 1.244 0 0 1-.784 1.575l-1.557.524 1.035 3.086 1.551-.516a1.248 1.248 0 0 1 1.575.795c.22.66-.135 1.365-.779 1.574l-.011-.029zm4.171-5.356C20.52.456 16.946-1.471 8.699 1.005.456 3.479-1.471 7.051 1.005 15.301c2.475 8.245 6.046 10.17 14.296 7.694 8.245-2.475 10.17-6.046 7.694-14.296z"/></svg>; @@ -341,6 +357,7 @@ const SVG = (props: Props) => { case 'stopwatch': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M8.5 5.6a.5.5 0 1 0-1 0v2.9h-3a.5.5 0 0 0 0 1H8a.5.5 0 0 0 .5-.5V5.6z"/><path d="M6.5 1A.5.5 0 0 1 7 .5h2a.5.5 0 0 1 0 1v.57c1.36.196 2.594.78 3.584 1.64a.715.715 0 0 1 .012-.013l.354-.354-.354-.353a.5.5 0 0 1 .707-.708l1.414 1.415a.5.5 0 1 1-.707.707l-.353-.354-.354.354a.512.512 0 0 1-.013.012A7 7 0 1 1 7 2.071V1.5a.5.5 0 0 1-.5-.5zM8 3a6 6 0 1 0 .001 12A6 6 0 0 0 8 3z"/></svg>; case 'store': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M4 4v2H2V4h2zm1 7V9a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1zm0-5V4a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1zm5 5V9a1 1 0 0 0-1-1H7a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1zm0-5V4a1 1 0 0 0-1-1H7a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1zM9 4v2H7V4h2zm5 0h-2v2h2V4zM4 9v2H2V9h2zm5 0v2H7V9h2zm5 0v2h-2V9h2zm-3-5a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V4zm1 4a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V9a1 1 0 0 0-1-1h-2z"/></svg>; case 'sync-alt': return <svg viewBox="0 0 512 512" width={ `${ width }px` } height={ `${ height }px` } ><path d="M8 454.06V320a24 24 0 0 1 24-24h134.06c21.38 0 32.09 25.85 17 41l-41.75 41.75A166.82 166.82 0 0 0 256.16 424c77.41-.07 144.31-53.14 162.78-126.85a12 12 0 0 1 11.65-9.15h57.31a12 12 0 0 1 11.81 14.18C478.07 417.08 377.19 504 256 504a247.14 247.14 0 0 1-171.31-68.69L49 471c-15.15 15.15-41 4.44-41-16.94z"/><path d="M12.3 209.82C33.93 94.92 134.81 8 256 8a247.14 247.14 0 0 1 171.31 68.69L463 41c15.12-15.12 41-4.41 41 17v134a24 24 0 0 1-24 24H345.94c-21.38 0-32.09-25.85-17-41l41.75-41.75A166.8 166.8 0 0 0 255.85 88c-77.46.07-144.33 53.18-162.79 126.85A12 12 0 0 1 81.41 224H24.1a12 12 0 0 1-11.8-14.18z"/></svg>; + case 'table-new': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M0 2a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2zm15 2h-4v3h4V4zm0 4h-4v3h4V8zm0 4h-4v3h3a1 1 0 0 0 1-1v-2zm-5 3v-3H6v3h4zm-5 0v-3H1v2a1 1 0 0 0 1 1h3zm-4-4h4V8H1v3zm0-4h4V4H1v3zm5-3v3h4V4H6zm4 4H6v3h4V8z"/></svg>; case 'table': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M0 2a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2zm15 2h-4v3h4V4zm0 4h-4v3h4V8zm0 4h-4v3h3a1 1 0 0 0 1-1v-2zm-5 3v-3H6v3h4zm-5 0v-3H1v2a1 1 0 0 0 1 1h3zm-4-4h4V8H1v3zm0-4h4V4H1v3zm5-3v3h4V4H6zm4 4H6v3h4V8z"/></svg>; case 'tablet-android': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M1 4a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V4zm-1 8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H2a2 2 0 0 0-2 2v8z"/><path d="M14 8a1 1 0 1 0-2 0 1 1 0 0 0 2 0z"/></svg>; case 'tachometer-slow': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M8 4a.5.5 0 0 1 .5.5V6a.5.5 0 0 1-1 0V4.5A.5.5 0 0 1 8 4zM3.732 5.732a.5.5 0 0 1 .707 0l.915.914a.5.5 0 1 1-.708.708l-.914-.915a.5.5 0 0 1 0-.707zM2 10a.5.5 0 0 1 .5-.5h1.586a.5.5 0 0 1 0 1H2.5A.5.5 0 0 1 2 10zm9.5 0a.5.5 0 0 1 .5-.5h1.5a.5.5 0 0 1 0 1H12a.5.5 0 0 1-.5-.5zm.754-4.246a.389.389 0 0 0-.527-.02L7.547 9.31a.91.91 0 1 0 1.302 1.258l3.434-4.297a.389.389 0 0 0-.029-.518z"/><path d="M0 10a8 8 0 1 1 15.547 2.661c-.442 1.253-1.845 1.602-2.932 1.25C11.309 13.488 9.475 13 8 13c-1.474 0-3.31.488-4.615.911-1.087.352-2.49.003-2.932-1.25A7.988 7.988 0 0 1 0 10zm8-7a7 7 0 0 0-6.603 9.329c.203.575.923.876 1.68.63C4.397 12.533 6.358 12 8 12s3.604.532 4.923.96c.757.245 1.477-.056 1.68-.631A7 7 0 0 0 8 3z"/></svg>; @@ -362,6 +379,7 @@ const SVG = (props: Props) => { case 'vendors/ngrx': return <svg viewBox="0 0 911.93 978.15" width={ `${ width }px` } height={ `${ height }px` } ><circle cx="522.96" cy="311" r="13"/><path d="M501 11.7V11l-1 .35-1-.35v.7L44 169.62l65.42 606.25L499 988.06v1.09l1-.54 1 .54v-1.09l389.55-212.19L956 169.62Zm279.86 580.52Q714 753.14 535.07 762c-115.29 0-190-69.32-189.92-69.27q-71.1-55.34-97.73-139.29c-28.35-31-28.63-34.28-31.55-46.65S217.71 491 226 478.88c5.54-8.07 6.84-19.68 4-34.62q-10.59-14.68-12.2-46 0-15.12 20.2-31.92c13.46-11.2 21.72-19.81 24.71-25.74q3.42-4.85 2.65-42-.21-36.52 40.56-39.75c40.77-3.22 63.77-33.88 76.58-47.83 8.54-9.3 21.18-13.81 37.12-13.9 22.44-1.05 42.86 7.55 60.45 25.3 43.82-2.26 88.7 9.55 134.18 35.15q96.93 57.58 105.8 124.51-10.4 87.95-235.91-4.61-117.93 33.4-116 144.77-.09 102.18 98.69 148.46c-32.06-31.47-45.71-57.92-41.11-79.83q100 118.45 227.72 88.55C616 680.7 586.25 668.63 563.89 643q86.26-2.09 162.94-80.37c-29.51 23.49-60.4 32.38-93.1 26.79q132.85-104.44 94-243.69l-.08-.26q31.29 34.83 32.15 82.72c.57 31.93-10.15 65.61-32.4 100.94 16.63-12.93 35.84-40.84 57.55-83.4q14.09 129.39-104.89 196.64 38.06-3.52 100.8-50.15Z" transform="translate(-44.04 -11)"/></svg>; case 'vendors/redux': return <svg viewBox="0 0 100 100" width={ `${ width }px` } height={ `${ height }px` } ><path d="M65.6 65.4c2.9-.3 5.1-2.8 5-5.8-.1-3-2.6-5.4-5.6-5.4h-.2c-3.1.1-5.5 2.7-5.4 5.8.1 1.5.7 2.8 1.6 3.7-3.4 6.7-8.6 11.6-16.4 15.7-5.3 2.8-10.8 3.8-16.3 3.1-4.5-.6-8-2.6-10.2-5.9-3.2-4.9-3.5-10.2-.8-15.5 1.9-3.8 4.9-6.6 6.8-8-.4-1.3-1-3.5-1.3-5.1-14.5 10.5-13 24.7-8.6 31.4 3.3 5 10 8.1 17.4 8.1 2 0 4-.2 6-.7 12.8-2.5 22.5-10.1 28-21.4z"/><path d="M83.2 53c-7.6-8.9-18.8-13.8-31.6-13.8H50c-.9-1.8-2.8-3-4.9-3h-.2c-3.1.1-5.5 2.7-5.4 5.8.1 3 2.6 5.4 5.6 5.4h.2c2.2-.1 4.1-1.5 4.9-3.4H52c7.6 0 14.8 2.2 21.3 6.5 5 3.3 8.6 7.6 10.6 12.8 1.7 4.2 1.6 8.3-.2 11.8-2.8 5.3-7.5 8.2-13.7 8.2-4 0-7.8-1.2-9.8-2.1-1.1 1-3.1 2.6-4.5 3.6 4.3 2 8.7 3.1 12.9 3.1 9.6 0 16.7-5.3 19.4-10.6 2.9-5.8 2.7-15.8-4.8-24.3z"/><path d="M32.4 67.1c.1 3 2.6 5.4 5.6 5.4h.2c3.1-.1 5.5-2.7 5.4-5.8-.1-3-2.6-5.4-5.6-5.4h-.2c-.2 0-.5 0-.7.1-4.1-6.8-5.8-14.2-5.2-22.2.4-6 2.4-11.2 5.9-15.5 2.9-3.7 8.5-5.5 12.3-5.6 10.6-.2 15.1 13 15.4 18.3 1.3.3 3.5 1 5 1.5-1.2-16.2-11.2-24.6-20.8-24.6-9 0-17.3 6.5-20.6 16.1-4.6 12.8-1.6 25.1 4 34.8-.5.7-.8 1.8-.7 2.9z"/></svg>; case 'vendors/vuex': return <svg viewBox="0 0 2499.76 2156.05" width={ `${ width }px` } height={ `${ height }px` } ><path d="m0 .02 1249.89 2156L2499.77.02h-500l-749.88 1293.6L493.7.02Z"/><path d="m549.69 6.59 702 1206.69L1947.83 6.59h-429.31L1251.66 470.7 978.99 6.59Z"/></svg>; + case 'web-vitals': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M6 2a.5.5 0 0 1 .47.33L10 12.036l1.53-4.208A.5.5 0 0 1 12 7.5h3.5a.5.5 0 0 1 0 1h-3.15l-1.88 5.17a.5.5 0 0 1-.94 0L6 3.964 4.47 8.171A.5.5 0 0 1 4 8.5H.5a.5.5 0 0 1 0-1h3.15l1.88-5.17A.5.5 0 0 1 6 2Z"/></svg>; case 'wifi': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M7.646 10.854a.5.5 0 0 0 .708 0l2-2a.5.5 0 0 0-.708-.708L8.5 9.293V5.5a.5.5 0 0 0-1 0v3.793L6.354 8.146a.5.5 0 1 0-.708.708l2 2z"/><path d="M4.406 3.342A5.53 5.53 0 0 1 8 2c2.69 0 4.923 2 5.166 4.579C14.758 6.804 16 8.137 16 9.773 16 11.569 14.502 13 12.687 13H3.781C1.708 13 0 11.366 0 9.318c0-1.763 1.266-3.223 2.942-3.593.143-.863.698-1.723 1.464-2.383zm.653.757c-.757.653-1.153 1.44-1.153 2.056v.448l-.445.049C2.064 6.805 1 7.952 1 9.318 1 10.785 2.23 12 3.781 12h8.906C13.98 12 15 10.988 15 9.773c0-1.216-1.02-2.228-2.313-2.228h-.5v-.5C12.188 4.825 10.328 3 8 3a4.53 4.53 0 0 0-2.941 1.1z"/></svg>; case 'window-alt': return <svg viewBox="0 0 512 512" width={ `${ width }px` } height={ `${ height }px` } ><path d="M224 160c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm128-32c0-17.7-14.3-32-32-32s-32 14.3-32 32 14.3 32 32 32 32-14.3 32-32zm96 0c0-17.7-14.3-32-32-32s-32 14.3-32 32 14.3 32 32 32 32-14.3 32-32zm64-48v352c0 26.5-21.5 48-48 48H48c-26.5 0-48-21.5-48-48V80c0-26.5 21.5-48 48-48h416c26.5 0 48 21.5 48 48zm-32 144H32v208c0 8.8 7.2 16 16 16h416c8.8 0 16-7.2 16-16V224zm0-32V80c0-8.8-7.2-16-16-16H48c-8.8 0-16 7.2-16 16v112h448z"/></svg>; case 'window-restore': return <svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M.54 3.87.5 3a2 2 0 0 1 2-2h3.672a2 2 0 0 1 1.414.586l.828.828A2 2 0 0 0 9.828 3h3.982a2 2 0 0 1 1.992 2.181l-.637 7A2 2 0 0 1 13.174 14H2.826a2 2 0 0 1-1.991-1.819l-.637-7a1.99 1.99 0 0 1 .342-1.31zM2.19 4a1 1 0 0 0-.996 1.09l.637 7a1 1 0 0 0 .995.91h10.348a1 1 0 0 0 .995-.91l.637-7A1 1 0 0 0 13.81 4H2.19zm4.69-1.707A1 1 0 0 0 6.172 2H2.5a1 1 0 0 0-1 .981l.006.139C1.72 3.042 1.95 3 2.19 3h5.396l-.707-.707z"/></svg>; diff --git a/frontend/app/components/ui/SegmentSelection/SegmentSelection.js b/frontend/app/components/ui/SegmentSelection/SegmentSelection.js index dde4d6b7c..97db6cef1 100644 --- a/frontend/app/components/ui/SegmentSelection/SegmentSelection.js +++ b/frontend/app/components/ui/SegmentSelection/SegmentSelection.js @@ -9,7 +9,7 @@ class SegmentSelection extends React.Component { } render() { - const { className, list, small = false, extraSmall = false, primary = false, size = "normal", icons = false, disabled = false, disabledMessage = 'Not Allowed' } = this.props; + const { className, list, small = false, extraSmall = false, primary = false, size = "normal", icons = false, disabled = false, disabledMessage = 'Not Allowed', outline } = this.props; return ( <Popup @@ -22,6 +22,7 @@ class SegmentSelection extends React.Component { [styles.extraSmall] : size === 'extraSmall' || extraSmall, [styles.icons] : icons === true, [styles.disabled] : disabled, + [styles.outline]: outline, }, className) } > { list.map(item => ( diff --git a/frontend/app/components/ui/SegmentSelection/segmentSelection.module.css b/frontend/app/components/ui/SegmentSelection/segmentSelection.module.css index 082e675c9..6d4db35b4 100644 --- a/frontend/app/components/ui/SegmentSelection/segmentSelection.module.css +++ b/frontend/app/components/ui/SegmentSelection/segmentSelection.module.css @@ -86,4 +86,31 @@ opacity: 0.5; cursor: not-allowed; pointer-events: none; -} \ No newline at end of file +} + +.outline { + border: 1px solid transparent; + border-radius: 3px; + & .item { + padding: 10px!important; + font-size: 14px!important; + border: solid thin $gray-light!important; + + &:hover { + background: $teal-light!important; + } + + &:first-child { + border-right: none!important; + border-radius: 3px 0 0 3px!important; + } + &:last-child { + border-left: none!important; + border-radius: 0 3px 3px 0!important; + } + + &[data-active=true] { + border: solid thin $teal!important; + } + } +} diff --git a/frontend/app/components/ui/SideMenuitem/SideMenuitem.js b/frontend/app/components/ui/SideMenuitem/SideMenuitem.js index ffbd31cc4..c9dd32b46 100644 --- a/frontend/app/components/ui/SideMenuitem/SideMenuitem.js +++ b/frontend/app/components/ui/SideMenuitem/SideMenuitem.js @@ -7,13 +7,13 @@ function SideMenuitem({ iconBg = false, iconColor = "gray-dark", iconSize = 18, - className, + className = '', iconName = null, title, active = false, disabled = false, onClick, - deleteHandler, + deleteHandler = null, leading = null, ...props }) { diff --git a/frontend/app/components/ui/TagBadge/TagBadge.js b/frontend/app/components/ui/TagBadge/TagBadge.js index 44f5b63a9..27e2364dc 100644 --- a/frontend/app/components/ui/TagBadge/TagBadge.js +++ b/frontend/app/components/ui/TagBadge/TagBadge.js @@ -4,32 +4,28 @@ import styles from './tagBadge.module.css'; import { Icon } from 'UI'; export default class TagBadge extends React.PureComponent { - - onClick = () => { - if (this.props.onClick) { - this.props.onClick(this.props.text); - } - } - - render() { - const { - className, text, onRemove, onClick, hashed = true, outline = false, - } = this.props; - return ( - <div - className={ cn(styles.badge, { "cursor-pointer": !!onClick }, className) } - onClick={ this.onClick } - data-hashed={ hashed } - data-outline={ outline } - > - <span>{ text }</span> - { onRemove && - <button type="button" onClick={ onRemove }> - <Icon name="close" size="12" /> - {/* <i className={ styles.closeIcon } /> */} - </button> + onClick = () => { + if (this.props.onClick) { + this.props.onClick(this.props.text); } - </div> - ); - } + }; + + render() { + const { className, text, onRemove, onClick, hashed = true, outline = false } = this.props; + return ( + <div + className={cn(styles.badge, { 'cursor-pointer': !!onClick }, className)} + onClick={this.onClick} + data-hashed={hashed} + data-outline={outline} + > + <span>{text}</span> + {onRemove && ( + <button type="button" onClick={onRemove}> + <Icon name="close" size="12" /> + </button> + )} + </div> + ); + } } diff --git a/frontend/app/components/ui/TagBadge/tagBadge.module.css b/frontend/app/components/ui/TagBadge/tagBadge.module.css index 46aad3cf6..eb5eccac3 100644 --- a/frontend/app/components/ui/TagBadge/tagBadge.module.css +++ b/frontend/app/components/ui/TagBadge/tagBadge.module.css @@ -12,7 +12,7 @@ margin-right: 8px; font-weight: 300; user-select: none; - text-transform: capitalize; + /* text-transform: capitalize; */ color: $gray-dark !important; &[data-outline=true] { diff --git a/frontend/app/components/ui/TextLink/TextLink.js b/frontend/app/components/ui/TextLink/TextLink.js index 4738e2152..089122ed4 100644 --- a/frontend/app/components/ui/TextLink/TextLink.js +++ b/frontend/app/components/ui/TextLink/TextLink.js @@ -1,24 +1,14 @@ -import React from 'react' -import cn from 'classnames' +import React from 'react'; +import cn from 'classnames'; import { Icon } from 'UI'; -function TextLink({ - target = '_blank', - href = '', - icon = '', - label='', - className = '' -}) { - return ( - <a - target={target} - className={cn('cursor-pointer flex items-center default-hover', className)} - href={href} - > - { icon && <Icon name={icon} size="16" color="gray-medium" marginRight="5" /> } - {label} - </a> - ) +function TextLink({ target = '_blank', href = '', icon = '', label = '', className = '' }) { + return ( + <a target={target} className={cn('link cursor-pointer flex items-center default-hover', className)} href={href}> + {icon && <Icon name={icon} size="16" color="teal" marginRight="5" />} + {label} + </a> + ); } -export default TextLink +export default TextLink; diff --git a/frontend/app/components/ui/Toggler/Toggler.js b/frontend/app/components/ui/Toggler/Toggler.js index f4db7cb7a..7aa670743 100644 --- a/frontend/app/components/ui/Toggler/Toggler.js +++ b/frontend/app/components/ui/Toggler/Toggler.js @@ -1,26 +1,14 @@ import React from 'react'; import styles from './toggler.module.css'; -export default ({ - onChange, - name, - className = '', - checked, - label = '', - plain = false, -}) => ( - <div className={ className }> - <label className={styles.label}> - <div className={ plain ? styles.switchPlain : styles.switch }> - <input - type={ styles.checkbox } - onClick={ onChange } - name={ name } - checked={ checked } - /> - <span className={ `${ plain ? styles.sliderPlain : styles.slider } ${ checked ? styles.checked : '' }` } /> - </div> - { label && <span>{ label }</span> } - </label> - </div> +export default ({ onChange, name, className = '', checked, label = '', plain = false }) => ( + <div className={className}> + <label className={styles.label}> + <div className={plain ? styles.switchPlain : styles.switch}> + <input type={styles.checkbox} onClick={onChange} name={name} checked={checked} /> + <span className={`${plain ? styles.sliderPlain : styles.slider} ${checked ? styles.checked : ''}`} /> + </div> + {label && <span>{label}</span>} + </label> + </div> ); diff --git a/frontend/app/components/ui/Tooltip/Tooltip.js b/frontend/app/components/ui/Tooltip/Tooltip.js deleted file mode 100644 index 4e891f511..000000000 --- a/frontend/app/components/ui/Tooltip/Tooltip.js +++ /dev/null @@ -1,44 +0,0 @@ -import React from 'react'; -import { Popup } from 'UI'; - -export default class Tooltip extends React.PureComponent { - static defaultProps = { - timeout: 500, - } - state = { - open: false, - } - mouseOver = false - onMouseEnter = () => { - this.mouseOver = true; - setTimeout(() => { - if (this.mouseOver) this.setState({ open: true }); - }, this.props.timeout) - } - onMouseLeave = () => { - this.mouseOver = false; - this.setState({ - open: false, - }); - } - - render() { - const { trigger, tooltip, position } = this.props; - const { open } = this.state; - return ( - <Popup - open={ open } - content={ tooltip } - disabled={ !tooltip } - position={position} - > - <span //TODO: no wrap component around - onMouseEnter={ this.onMouseEnter } - onMouseLeave={ this.onMouseLeave } - > - { trigger } - </span> - </Popup> - ); - } -} \ No newline at end of file diff --git a/frontend/app/components/ui/Tooltip/Tooltip.tsx b/frontend/app/components/ui/Tooltip/Tooltip.tsx new file mode 100644 index 000000000..6a14ce3f7 --- /dev/null +++ b/frontend/app/components/ui/Tooltip/Tooltip.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { Popup } from 'UI'; + +interface Props { + timeout: number + position: string + tooltip: string + trigger: React.ReactNode +} + +export default class Tooltip extends React.PureComponent<Props> { + static defaultProps = { + timeout: 500, + } + state = { + open: false, + } + mouseOver = false + onMouseEnter = () => { + this.mouseOver = true; + setTimeout(() => { + if (this.mouseOver) this.setState({ open: true }); + }, this.props.timeout) + } + onMouseLeave = () => { + this.mouseOver = false; + this.setState({ + open: false, + }); + } + + render() { + const { trigger, tooltip, position } = this.props; + const { open } = this.state; + return ( + <Popup + open={open} + content={tooltip} + disabled={!tooltip} + position={position} + > + <span //TODO: no wrap component around + onMouseEnter={ this.onMouseEnter } + onMouseLeave={ this.onMouseLeave } + > + { trigger } + </span> + </Popup> + ); + } +} \ No newline at end of file diff --git a/frontend/app/components/ui/Tooltip/index.js b/frontend/app/components/ui/Tooltip/index.ts similarity index 100% rename from frontend/app/components/ui/Tooltip/index.js rename to frontend/app/components/ui/Tooltip/index.ts diff --git a/frontend/app/constants/messages.ts b/frontend/app/constants/messages.ts new file mode 100644 index 000000000..32817b99c --- /dev/null +++ b/frontend/app/constants/messages.ts @@ -0,0 +1 @@ +export const NO_METRIC_DATA = 'No data available' diff --git a/frontend/app/constants/zindex.ts b/frontend/app/constants/zindex.ts new file mode 100644 index 000000000..114871b83 --- /dev/null +++ b/frontend/app/constants/zindex.ts @@ -0,0 +1,9 @@ +export const INDEXES = { + POPUP_GUIDE_BG: 99998, + POPUP_GUIDE_BTN: 99999, +} + +export const getHighest = () => { + const allIndexes = Object.values(INDEXES) + return allIndexes[allIndexes.length - 1] + 1 +} diff --git a/frontend/app/date.ts b/frontend/app/date.ts index e043f33fd..59704ad2d 100644 --- a/frontend/app/date.ts +++ b/frontend/app/date.ts @@ -13,7 +13,7 @@ export const durationFormatted = (duration: Duration):string => { duration = duration.toFormat('h\'h\'m\'m'); } else if (duration.as('months') < 1) { // show in days and hours duration = duration.toFormat('d\'d\'h\'h'); - } else { // + } else { duration = duration.toFormat('m\'m\'s\'s\''); } @@ -64,7 +64,7 @@ export const getDateFromMill = date => * @return {Boolean} */ export const isToday = (date: DateTime):boolean => date.hasSame(new Date(), 'day'); - +export const isSameYear = (date: DateTime):boolean => date.hasSame(new Date(), 'year'); export function formatDateTimeDefault(timestamp: number): string { const date = DateTime.fromMillis(timestamp); @@ -77,14 +77,30 @@ export function formatDateTimeDefault(timestamp: number): string { * @param {Object} timezone fixed offset like UTC+6 * @returns {String} formatted date (or time if its today) */ -export function formatTimeOrDate(timestamp: number, timezone: Timezone): string { +export function formatTimeOrDate(timestamp: number, timezone: Timezone, isFull = false): string { var date = DateTime.fromMillis(timestamp) if (timezone) { if (timezone.value === 'UTC') date = date.toUTC(); date = date.setZone(timezone.value) } - return isToday(date) ? date.toFormat('hh:mm a') : date.toFormat('LLL dd, yyyy, hh:mm a'); + if (isFull) { + const strHead = date.toFormat('LLL dd, yyyy, ') + const strTail = date.toFormat('hh:mma').toLowerCase() + return strHead + strTail; + } + + if (isToday(date)) { + return date.toFormat('hh:mma').toLowerCase() + } + if (isSameYear(date)) { + const strHead = date.toFormat('LLL dd, ') + const strTail = date.toFormat('hh:mma').toLowerCase() + return strHead + strTail; + } + const strHead = date.toFormat('LLL dd, yyyy, ') + const strTail = date.toFormat('hh:mma').toLowerCase() + return strHead + strTail; } /** @@ -133,4 +149,4 @@ export const countDaysFrom = (timestamp: number): number => { const date = DateTime.fromMillis(timestamp); const d = new Date(); return Math.round(Math.abs(d.getTime() - date.toJSDate().getTime()) / (1000 * 3600 * 24)); -} \ No newline at end of file +} diff --git a/frontend/app/dateRange.js b/frontend/app/dateRange.js index 70f674665..73c6f10ff 100644 --- a/frontend/app/dateRange.js +++ b/frontend/app/dateRange.js @@ -9,7 +9,7 @@ export const CUSTOM_RANGE = "CUSTOM_RANGE"; const DATE_RANGE_LABELS = { // LAST_30_MINUTES: '30 Minutes', // TODAY: 'Today', - LAST_24_HOURS: "Last 24 Hours", + LAST_24_HOURS: "Past 24 Hours", // YESTERDAY: 'Yesterday', LAST_7_DAYS: "Past 7 Days", LAST_30_DAYS: "Past 30 Days", @@ -45,40 +45,40 @@ export function getDateRangeLabel(value) { export function getDateRangeFromValue(value) { const tz = JSON.parse(localStorage.getItem(TIMEZONE)); const offset = tz ? tz.label.slice(-6) : 0; - + switch (value) { case DATE_RANGE_VALUES.LAST_30_MINUTES: return moment.range( - moment().utcOffset(offset, true).startOf("hour").subtract(30, "minutes"), - moment().utcOffset(offset, true).startOf("hour") + moment().utcOffset(offset).startOf("hour").subtract(30, "minutes"), + moment().utcOffset(offset).startOf("hour") ); case DATE_RANGE_VALUES.YESTERDAY: return moment.range( - moment().utcOffset(offset, true).subtract(1, "days").startOf("day"), - moment().utcOffset(offset, true).subtract(1, "days").endOf("day") + moment().utcOffset(offset).subtract(1, "days").startOf("day"), + moment().utcOffset(offset).subtract(1, "days").endOf("day") ); case DATE_RANGE_VALUES.TODAY: - return moment.range(moment().utcOffset(offset, true).startOf("day"), moment().utcOffset(offset, true).endOf("day")); + return moment.range(moment().utcOffset(offset).startOf("day"), moment().utcOffset(offset).endOf("day")); case DATE_RANGE_VALUES.LAST_24_HOURS: - return moment.range(moment().utcOffset(offset, true).subtract(24, "hours"), moment().utcOffset(offset, true)); + return moment.range(moment().utcOffset(offset).subtract(24, "hours"), moment().utcOffset(offset)); case DATE_RANGE_VALUES.LAST_7_DAYS: return moment.range( - moment().utcOffset(offset, true).subtract(7, "days").startOf("day"), - moment().utcOffset(offset, true).endOf("day") + moment().utcOffset(offset).subtract(7, "days").startOf("day"), + moment().utcOffset(offset).endOf("day") ); case DATE_RANGE_VALUES.LAST_30_DAYS: return moment.range( - moment().utcOffset(offset, true).subtract(30, "days").startOf("day"), - moment().utcOffset(offset, true).endOf("day") + moment().utcOffset(offset).subtract(30, "days").startOf("day"), + moment().utcOffset(offset).endOf("day") ); case DATE_RANGE_VALUES.THIS_MONTH: - return moment().utcOffset(offset, true).range("month"); + return moment().utcOffset(offset).range("month"); case DATE_RANGE_VALUES.LAST_MONTH: - return moment().utcOffset(offset, true).subtract(1, "months").range("month"); + return moment().utcOffset(offset).subtract(1, "months").range("month"); case DATE_RANGE_VALUES.THIS_YEAR: - return moment().utcOffset(offset, true).range("year"); + return moment().utcOffset(offset).range("year"); case DATE_RANGE_VALUES.CUSTOM_RANGE: - return moment.range(moment().utcOffset(offset, true), moment().utcOffset(offset, true)); + return moment.range(moment().utcOffset(offset), moment().utcOffset(offset)); } return null; } diff --git a/frontend/app/declaration.d.ts b/frontend/app/declaration.d.ts new file mode 100644 index 000000000..0463954c3 --- /dev/null +++ b/frontend/app/declaration.d.ts @@ -0,0 +1,9 @@ +declare module '*.scss' { + const content: Record<string, string>; + export default content; +} + +declare module '*.css' { + const content: Record<string, string>; + export default content; +} \ No newline at end of file diff --git a/frontend/app/duck/alerts.js b/frontend/app/duck/alerts.js index 623b34301..dcc3c1633 100644 --- a/frontend/app/duck/alerts.js +++ b/frontend/app/duck/alerts.js @@ -9,10 +9,12 @@ const idKey = 'alertId'; const crudDuck = crudDuckGenerator(name, Alert, { idKey: idKey }); export const { fetchList, init, edit, remove } = crudDuck.actions; const FETCH_TRIGGER_OPTIONS = new RequestTypes(`${name}/FETCH_TRIGGER_OPTIONS`); +const CHANGE_SEARCH = `${name}/CHANGE_SEARCH` const initialState = Map({ definedPercent: 0, triggerOptions: [], + alertsSearch: '', }); const reducer = (state = initialState, action = {}) => { @@ -28,6 +30,8 @@ const reducer = (state = initialState, action = {}) => { // return member // }) // ); + case CHANGE_SEARCH: + return state.set('alertsSearch', action.search); case FETCH_TRIGGER_OPTIONS.SUCCESS: return state.set('triggerOptions', action.data.map(({ name, value }) => ({ label: name, value }))); } @@ -41,6 +45,13 @@ export function save(instance) { }; } +export function changeSearch(search) { + return { + type: CHANGE_SEARCH, + search, + }; +} + export function fetchTriggerOptions() { return { types: FETCH_TRIGGER_OPTIONS.toArray(), diff --git a/frontend/app/duck/components/player.js b/frontend/app/duck/components/player.js index 1a34bcd95..550f6e4df 100644 --- a/frontend/app/duck/components/player.js +++ b/frontend/app/duck/components/player.js @@ -12,10 +12,12 @@ export const FETCH = 8; export const EXCEPTIONS = 9; export const LONGTASKS = 10; export const INSPECTOR = 11; +export const OVERVIEW = 12; const TOGGLE_FULLSCREEN = 'player/TOGGLE_FS'; const TOGGLE_BOTTOM_BLOCK = 'player/SET_BOTTOM_BLOCK'; const HIDE_HINT = 'player/HIDE_HINT'; +const CHANGE_INTERVAL = 'player/CHANGE_SKIP_INTERVAL' const initialState = Map({ fullscreen: false, @@ -24,6 +26,7 @@ const initialState = Map({ storage: localStorage.getItem('storageHideHint'), stack: localStorage.getItem('stackHideHint') }), + skipInterval: localStorage.getItem(CHANGE_INTERVAL) || 10, }); const reducer = (state = initialState, action = {}) => { @@ -36,6 +39,10 @@ const reducer = (state = initialState, action = {}) => { if (state.get('bottomBlock') !== bottomBlock && bottomBlock !== NONE) { } return state.update('bottomBlock', bb => bb === bottomBlock ? NONE : bottomBlock); + case CHANGE_INTERVAL: + const { skipInterval } = action; + localStorage.setItem(CHANGE_INTERVAL, skipInterval); + return state.update('skipInterval', () => skipInterval); case HIDE_HINT: const { name } = action; localStorage.setItem(`${name}HideHint`, true); @@ -73,9 +80,16 @@ export function closeBottomBlock() { return toggleBottomBlock(); } +export function changeSkipInterval(skipInterval) { + return { + skipInterval, + type: CHANGE_INTERVAL, + }; +} + export function hideHint(name) { return { name, type: HIDE_HINT, } -} \ No newline at end of file +} diff --git a/frontend/app/duck/customField.js b/frontend/app/duck/customField.js index 76378a2b3..d77987d05 100644 --- a/frontend/app/duck/customField.js +++ b/frontend/app/duck/customField.js @@ -4,15 +4,16 @@ import { fetchListType, saveType, editType, initType, removeType } from './funcT import { createItemInListUpdater, mergeReducers, success, array } from './funcTools/tools'; import { createEdit, createInit } from './funcTools/crud'; import { createRequestReducer } from './funcTools/request'; -import { addElementToFiltersMap, addElementToLiveFiltersMap } from 'Types/filter/newFilter'; +import { addElementToFiltersMap, addElementToLiveFiltersMap, clearMetaFilters } from 'Types/filter/newFilter'; import { FilterCategory } from '../types/filter/filterType'; -import { refreshFilterOptions } from './search' +import { refreshFilterOptions } from './search'; -const name = "integration/variable"; +const name = 'integration/variable'; const idKey = 'index'; const itemInListUpdater = createItemInListUpdater(idKey); const FETCH_LIST = fetchListType(name); +const FETCH_LIST_ACTIVE = fetchListType(name + '_ACTIVE'); const SAVE = saveType(name); const UPDATE = saveType(name); const EDIT = editType(name); @@ -21,6 +22,7 @@ const INIT = initType(name); const FETCH_SOURCES = fetchListType('integration/sources'); const FETCH_SUCCESS = success(FETCH_LIST); +const FETCH_LIST_ACTIVE_SUCCESS = success(FETCH_LIST_ACTIVE); const SAVE_SUCCESS = success(SAVE); const UPDATE_SUCCESS = success(UPDATE); const REMOVE_SUCCESS = success(REMOVE); @@ -31,33 +33,41 @@ const initialState = Map({ list: List(), instance: CustomField(), sources: List(), - optionsReady: false + optionsReady: false, }); const reducer = (state = initialState, action = {}) => { - switch(action.type) { - case FETCH_SUCCESS: - action.data.forEach(item => { + switch (action.type) { + case FETCH_SUCCESS: + return state.set('list', List(action.data).map(CustomField)) + case FETCH_LIST_ACTIVE_SUCCESS: + clearMetaFilters(); + action.data.forEach((item) => { addElementToFiltersMap(FilterCategory.METADATA, item.key); addElementToLiveFiltersMap(FilterCategory.METADATA, item.key); }); - return state.set('list', List(action.data).map(CustomField)) - .set('optionsReady', true) //.concat(defaultMeta)) + return state; + case FETCH_SOURCES_SUCCESS: - return state.set('sources', List(action.data.map(({ value, ...item}) => ({label: value, key: value, ...item}))).map(CustomField)) + return state.set( + 'sources', + List(action.data.map(({ value, ...item }) => ({ label: value, key: value, ...item }))).map( + CustomField + ) + ); case SAVE_SUCCESS: case UPDATE_SUCCESS: - return state.update('list', itemInListUpdater(CustomField(action.data))) + return state.update('list', itemInListUpdater(CustomField(action.data))); case REMOVE_SUCCESS: - return state.update('list', list => list.filter(item => item.index !== action.index)); + return state.update('list', (list) => list.filter((item) => item.index !== action.index)); case INIT: return state.set('instance', CustomField(action.instance)); case EDIT: - return state.mergeIn([ 'instance' ], action.instance); - default: - return state; - } -} + return state.mergeIn(['instance'], action.instance); + default: + return state; + } +}; export const edit = createEdit(name); export const init = createInit(name); @@ -65,41 +75,47 @@ export const init = createInit(name); export const fetchList = (siteId) => (dispatch, getState) => { return dispatch({ types: array(FETCH_LIST), - call: client => client.get(siteId ? `/${siteId}/metadata` : '/metadata'), + call: (client) => client.get(siteId ? `/${siteId}/metadata` : '/metadata'), + }) +}; + +export const fetchListActive = (siteId) => (dispatch, getState) => { + return dispatch({ + types: array(FETCH_LIST_ACTIVE), + call: (client) => client.get(siteId ? `/${siteId}/metadata` : '/metadata'), }).then(() => { dispatch(refreshFilterOptions()); }); -} +}; export const fetchSources = () => { return { types: array(FETCH_SOURCES), - call: client => client.get('/integration/sources'), - } -} + call: (client) => client.get('/integration/sources'), + }; +}; export const save = (siteId, instance) => { - const url = instance.exists() - ? `/${siteId}/metadata/${instance.index}` - : `/${siteId}/metadata`; + const url = instance.exists() ? `/${siteId}/metadata/${instance.index}` : `/${siteId}/metadata`; return { types: array(instance.exists() ? SAVE : UPDATE), - call: client => client.post(url, instance.toData()), - } -} + call: (client) => client.post(url, instance.toData()), + }; +}; export const remove = (siteId, index) => { return { types: array(REMOVE), - call: client => client.delete(`/${siteId}/metadata/${index}`), + call: (client) => client.delete(`/${siteId}/metadata/${index}`), index, - } -} + }; +}; export default mergeReducers( - reducer, - createRequestReducer({ + reducer, + createRequestReducer({ fetchRequest: FETCH_LIST, + fetchRequestActive: FETCH_LIST_ACTIVE, saveRequest: SAVE, - }), -) \ No newline at end of file + }) +); diff --git a/frontend/app/duck/integrations/actions.js b/frontend/app/duck/integrations/actions.js index 4bffda55c..9ab831c41 100644 --- a/frontend/app/duck/integrations/actions.js +++ b/frontend/app/duck/integrations/actions.js @@ -1,39 +1,47 @@ import { array } from '../funcTools/tools'; -import { fetchListType, saveType, editType, initType, removeType } from '../funcTools/types'; +import { fetchListType, fetchType, saveType, editType, initType, removeType } from '../funcTools/types'; export function fetchList(name) { - return { - types: fetchListType(name).array, - call: client => client.get(`/integrations/${ name }`), - name - }; + return { + types: fetchListType(name).array, + call: (client) => client.get(`/integrations/${name}`), + name, + }; +} + +export function fetch(name, siteId) { + return { + types: fetchType(name).array, + call: (client) => client.get(siteId && name !== 'github' && name !== 'jira' ? `/${siteId}/integrations/${name}` : `/integrations/${name}`), + name, + }; } export function save(name, siteId, instance) { - return { - types: saveType(name).array, - call: client => client.post( (siteId ? `/${siteId}` : '') + `/integrations/${ name }`, instance.toData()), - }; + return { + types: saveType(name).array, + call: (client) => client.post((siteId ? `/${siteId}` : '') + `/integrations/${name}`, instance.toData()), + }; } export function edit(name, instance) { - return { - type: editType(name), - instance, - }; + return { + type: editType(name), + instance, + }; } export function init(name, instance) { - return { - type: initType(name), - instance, - }; + return { + type: initType(name), + instance, + }; } export function remove(name, siteId) { - return { - types: removeType(name).array, - call: client => client.delete((siteId ? `/${siteId}` : '') + `/integrations/${ name }`), - siteId, - }; -} \ No newline at end of file + return { + types: removeType(name).array, + call: (client) => client.delete((siteId ? `/${siteId}` : '') + `/integrations/${name}`), + siteId, + }; +} diff --git a/frontend/app/duck/integrations/index.js b/frontend/app/duck/integrations/index.js index 5e439675d..0274f7c80 100644 --- a/frontend/app/duck/integrations/index.js +++ b/frontend/app/duck/integrations/index.js @@ -11,23 +11,25 @@ import JiraConfig from 'Types/integrations/jiraConfig'; import GithubConfig from 'Types/integrations/githubConfig'; import IssueTracker from 'Types/integrations/issueTracker'; import slack from './slack'; +import integrations from './integrations'; -import { createIntegrationReducer } from './reducer' +import { createIntegrationReducer } from './reducer'; -export default { - sentry: createIntegrationReducer("sentry", SentryConfig), - datadog: createIntegrationReducer("datadog", DatadogConfig), - stackdriver: createIntegrationReducer("stackdriver", StackdriverConfig), - rollbar: createIntegrationReducer("rollbar", RollbarConfig), - newrelic: createIntegrationReducer("newrelic", NewrelicConfig), - bugsnag: createIntegrationReducer("bugsnag", BugsnagConfig), - cloudwatch: createIntegrationReducer("cloudwatch", CloudWatch), - elasticsearch: createIntegrationReducer("elasticsearch", ElasticsearchConfig), - sumologic: createIntegrationReducer("sumologic", SumoLogicConfig), - jira: createIntegrationReducer("jira", JiraConfig), - issues: createIntegrationReducer("issues", IssueTracker), - github: createIntegrationReducer("github", GithubConfig), - slack, +export default { + sentry: createIntegrationReducer('sentry', SentryConfig), + datadog: createIntegrationReducer('datadog', DatadogConfig), + stackdriver: createIntegrationReducer('stackdriver', StackdriverConfig), + rollbar: createIntegrationReducer('rollbar', RollbarConfig), + newrelic: createIntegrationReducer('newrelic', NewrelicConfig), + bugsnag: createIntegrationReducer('bugsnag', BugsnagConfig), + cloudwatch: createIntegrationReducer('cloudwatch', CloudWatch), + elasticsearch: createIntegrationReducer('elasticsearch', ElasticsearchConfig), + sumologic: createIntegrationReducer('sumologic', SumoLogicConfig), + jira: createIntegrationReducer('jira', JiraConfig), + github: createIntegrationReducer('github', GithubConfig), + issues: createIntegrationReducer('issues', IssueTracker), + slack, + integrations, }; export * from './actions'; diff --git a/frontend/app/duck/integrations/integrations.js b/frontend/app/duck/integrations/integrations.js new file mode 100644 index 000000000..7f6999ea8 --- /dev/null +++ b/frontend/app/duck/integrations/integrations.js @@ -0,0 +1,41 @@ +import { Map } from 'immutable'; +import withRequestState from 'Duck/requestStateCreator'; +import { fetchListType } from '../funcTools/types'; +import { createRequestReducer } from '../funcTools/request'; + +const FETCH_LIST = fetchListType('integrations/FETCH_LIST'); +const SET_SITE_ID = 'integrations/SET_SITE_ID'; +const initialState = Map({ + list: [], + siteId: null, +}); +const reducer = (state = initialState, action = {}) => { + switch (action.type) { + case FETCH_LIST.success: + return state.set('list', action.data); + case SET_SITE_ID: + return state.set('siteId', action.siteId); + } + return state; +}; + +export default createRequestReducer( + { + fetchRequest: FETCH_LIST, + }, + reducer +); + +export function fetchIntegrationList(siteID) { + return { + types: FETCH_LIST.array, + call: (client) => client.get(`/${siteID}/integrations`), + }; +} + +export function setSiteId(siteId) { + return { + type: SET_SITE_ID, + siteId, + }; +} diff --git a/frontend/app/duck/integrations/reducer.js b/frontend/app/duck/integrations/reducer.js index 166bb661e..56c531610 100644 --- a/frontend/app/duck/integrations/reducer.js +++ b/frontend/app/duck/integrations/reducer.js @@ -1,48 +1,52 @@ import { List, Map } from 'immutable'; import { createRequestReducer } from '../funcTools/request'; -import { fetchListType, saveType, removeType, editType, initType } from '../funcTools/types'; +import { fetchListType, saveType, removeType, editType, initType, fetchType } from '../funcTools/types'; import { createItemInListUpdater } from '../funcTools/tools'; const idKey = 'siteId'; const itemInListUpdater = createItemInListUpdater(idKey); export const createIntegrationReducer = (name, Config) => { - const FETCH_LIST = fetchListType(name); - const SAVE = saveType(name); - const REMOVE = removeType(name); - const EDIT = editType(name); - const INIT = initType(name); + const FETCH_LIST = fetchListType(name); + const SAVE = saveType(name); + const REMOVE = removeType(name); + const EDIT = editType(name); + const INIT = initType(name); + const FETCH = fetchType(name); - const initialState = Map({ - instance: Config(), - list: List(), - fetched: false, - issuesFetched: false - }); - const reducer = (state = initialState, action = {}) => { - switch (action.type) { - case FETCH_LIST.success: - return state.set('list', Array.isArray(action.data) ? - List(action.data).map(Config) : List([new Config(action.data)])).set(action.name + 'Fetched', true); - case SAVE.success: - const config = Config(action.data); - return state - .update('list', itemInListUpdater(config)) - .set('instance', config); - case REMOVE.success: - return state - .update('list', list => list.filter(site => site.siteId !== action.siteId)) - .set('instance', Config()) - case EDIT: - return state.mergeIn([ 'instance' ], action.instance); - case INIT: - return state.set('instance', Config(action.instance)); - } - return state; - }; - return createRequestReducer({ - fetchRequest: FETCH_LIST, - saveRequest: SAVE, - removeRequest: REMOVE, - }, reducer); -} + const initialState = Map({ + instance: Config(), + list: List(), + fetched: false, + issuesFetched: false, + }); + const reducer = (state = initialState, action = {}) => { + switch (action.type) { + case FETCH_LIST.success: + return state + .set('list', Array.isArray(action.data) ? List(action.data).map(Config) : List([new Config(action.data)])) + .set(action.name + 'Fetched', true); + case FETCH.success: + return state.set('instance', Config(action.data)); + case SAVE.success: + const config = Config(action.data); + return state.update('list', itemInListUpdater(config)).set('instance', config); + case REMOVE.success: + return state.update('list', (list) => list.filter((site) => site.siteId !== action.siteId)).set('instance', Config()); + case EDIT: + return state.mergeIn(['instance'], action.instance); + case INIT: + return state.set('instance', Config(action.instance)); + } + return state; + }; + return createRequestReducer( + { + // fetchRequest: FETCH_LIST, + fetchRequest: FETCH, + saveRequest: SAVE, + removeRequest: REMOVE, + }, + reducer + ); +}; diff --git a/frontend/app/duck/integrations/slack.js b/frontend/app/duck/integrations/slack.js index e4c2803ff..192bdd0cf 100644 --- a/frontend/app/duck/integrations/slack.js +++ b/frontend/app/duck/integrations/slack.js @@ -13,77 +13,76 @@ const idKey = 'webhookId'; const itemInListUpdater = createItemInListUpdater(idKey); const initialState = Map({ - instance: Config(), - list: List(), + instance: Config(), + list: List(), }); const reducer = (state = initialState, action = {}) => { - switch (action.type) { - case FETCH_LIST.SUCCESS: - return state.set('list', List(action.data).map(Config)); - case UPDATE.SUCCESS: - case SAVE.SUCCESS: - const config = Config(action.data); - return state - .update('list', itemInListUpdater(config)) - .set('instance', config); - case REMOVE.SUCCESS: - return state - .update('list', list => list.filter(item => item.webhookId !== action.id)) - .set('instance', Config()) - case EDIT: - return state.mergeIn([ 'instance' ], action.instance); - case INIT: - return state.set('instance', Config(action.instance)); - } - return state; + switch (action.type) { + case FETCH_LIST.SUCCESS: + return state.set('list', List(action.data).map(Config)); + case UPDATE.SUCCESS: + case SAVE.SUCCESS: + const config = Config(action.data); + return state.update('list', itemInListUpdater(config)).set('instance', config); + case REMOVE.SUCCESS: + return state.update('list', (list) => list.filter((item) => item.webhookId !== action.id)).set('instance', Config()); + case EDIT: + return state.mergeIn(['instance'], action.instance); + case INIT: + return state.set('instance', Config(action.instance)); + } + return state; }; -export default withRequestState({ - fetchRequest: FETCH_LIST, - saveRequest: SAVE, - removeRequest: REMOVE, -}, reducer); +export default withRequestState( + { + fetchRequest: FETCH_LIST, + saveRequest: SAVE, + removeRequest: REMOVE, + }, + reducer +); export function fetchList() { - return { - types: FETCH_LIST.toArray(), - call: client => client.get('/integrations/slack/channels'), - }; + return { + types: FETCH_LIST.toArray(), + call: (client) => client.get('/integrations/slack/channels'), + }; } export function save(instance) { - return { - types: SAVE.toArray(), - call: client => client.post(`/integrations/slack`, instance.toData()), - }; + return { + types: SAVE.toArray(), + call: (client) => client.post(`/integrations/slack`, instance.toData()), + }; } export function update(instance) { - return { - types: UPDATE.toArray(), - call: client => client.put(`/integrations/slack/${instance.webhookId}`, instance.toData()), - }; + return { + types: UPDATE.toArray(), + call: (client) => client.put(`/integrations/slack/${instance.webhookId}`, instance.toData()), + }; } export function edit(instance) { - return { - type: EDIT, - instance, - }; + return { + type: EDIT, + instance, + }; } export function init(instance) { - return { - type: INIT, - instance, - }; + return { + type: INIT, + instance, + }; } export function remove(id) { - return { - types: REMOVE.toArray(), - call: client => client.delete(`/integrations/slack/${id}`), - id, - }; -} \ No newline at end of file + return { + types: REMOVE.toArray(), + call: (client) => client.delete(`/integrations/slack/${id}`), + id, + }; +} diff --git a/frontend/app/duck/search.js b/frontend/app/duck/search.js index 15cfa1a4f..5876abbb0 100644 --- a/frontend/app/duck/search.js +++ b/frontend/app/duck/search.js @@ -139,20 +139,20 @@ export const reduceThenFetchResource = const filter = getState().getIn(['search', 'instance']).toData(); const activeTab = getState().getIn(['search', 'activeTab']); - if (activeTab.type !== 'all' && activeTab.type !== 'bookmark') { + if (activeTab.type !== 'all' && activeTab.type !== 'bookmark' && activeTab.type !== 'vault') { const tmpFilter = filtersMap[FilterKey.ISSUE]; tmpFilter.value = [activeTab.type]; filter.filters = filter.filters.concat(tmpFilter); } - if (activeTab.type === 'bookmark') { + if (activeTab.type === 'bookmark' || activeTab.type === 'vault') { filter.bookmarked = true; } filter.filters = filter.filters.map(filterMap); filter.limit = 10; filter.page = getState().getIn(['search', 'currentPage']); - const forceFetch = filter.filters.length === 0; + const forceFetch = filter.filters.length === 0 || args[1] === true; // duration filter from local storage if (!filter.filters.find((f) => f.type === FilterKey.DURATION)) { @@ -206,10 +206,10 @@ export const remove = (id) => (dispatch, getState) => { // export const remove = createRemove(name, (id) => `/saved_search/${id}`); -export const applyFilter = reduceThenFetchResource((filter, fromUrl = false) => ({ +export const applyFilter = reduceThenFetchResource((filter, force = false) => ({ type: APPLY, filter, - fromUrl, + force, })); export const updateCurrentPage = reduceThenFetchResource((page) => ({ @@ -225,9 +225,9 @@ export const applySavedSearch = (filter) => (dispatch, getState) => { }); }; -export const fetchSessions = (filter) => (dispatch, getState) => { +export const fetchSessions = (filter, force = false) => (dispatch, getState) => { const _filter = filter ? filter : getState().getIn(['search', 'instance']); - return dispatch(applyFilter(_filter)); + return dispatch(applyFilter(_filter, force)); }; export const updateSeries = (index, series) => ({ @@ -318,13 +318,17 @@ export const addFilter = (filter) => (dispatch, getState) => { }; export const addFilterByKeyAndValue = - (key, value, operator = undefined) => + (key, value, operator = undefined, sourceOperator = undefined, source = undefined) => (dispatch, getState) => { let defaultFilter = filtersMap[key]; defaultFilter.value = value; if (operator) { defaultFilter.operator = operator; } + if (defaultFilter.hasSource && source && sourceOperator) { + defaultFilter.sourceOperator = sourceOperator; + defaultFilter.source = source; + } dispatch(addFilter(defaultFilter)); }; diff --git a/frontend/app/duck/sessions.js b/frontend/app/duck/sessions.js index e4a4ff7bd..d2556fce3 100644 --- a/frontend/app/duck/sessions.js +++ b/frontend/app/duck/sessions.js @@ -25,6 +25,8 @@ const SET_AUTOPLAY_VALUES = 'sessions/SET_AUTOPLAY_VALUES'; const TOGGLE_CHAT_WINDOW = 'sessions/TOGGLE_CHAT_WINDOW'; const SET_FUNNEL_PAGE_FLAG = 'sessions/SET_FUNNEL_PAGE_FLAG'; const SET_TIMELINE_POINTER = 'sessions/SET_TIMELINE_POINTER'; +const SET_TIMELINE_HOVER_POINTER = 'sessions/SET_TIMELINE_HOVER_POINTER'; + const SET_SESSION_PATH = 'sessions/SET_SESSION_PATH'; const LAST_PLAYED_SESSION_ID = `${name}/LAST_PLAYED_SESSION_ID`; const SET_ACTIVE_TAB = 'sessions/SET_ACTIVE_TAB'; @@ -61,6 +63,7 @@ const initialState = Map({ timelinePointer: null, sessionPath: {}, lastPlayedSessionId: null, + timeLineTooltip: { time: 0, offset: 0, isVisible: false } }); const reducer = (state = initialState, action = {}) => { @@ -187,6 +190,8 @@ const reducer = (state = initialState, action = {}) => { return state.set('funnelPage', action.funnelPage ? Map(action.funnelPage) : false); case SET_TIMELINE_POINTER: return state.set('timelinePointer', action.pointer); + case SET_TIMELINE_HOVER_POINTER: + return state.set('timeLineTooltip', action.timeLineTooltip); case SET_SESSION_PATH: return state.set('sessionPath', action.path); case LAST_PLAYED_SESSION_ID: @@ -350,6 +355,13 @@ export function setTimelinePointer(pointer) { }; } +export function setTimelineHoverTime(timeLineTooltip) { + return { + type: SET_TIMELINE_HOVER_POINTER, + timeLineTooltip + }; +} + export function setSessionPath(path) { return { type: SET_SESSION_PATH, diff --git a/frontend/app/hooks/useTimeout.ts b/frontend/app/hooks/useTimeout.ts new file mode 100644 index 000000000..ea6dd2ba3 --- /dev/null +++ b/frontend/app/hooks/useTimeout.ts @@ -0,0 +1,21 @@ +import { useRef, useEffect } from 'react'; + +const useTimeout = (callback: () => void, delay: number) => { + const savedCallback = useRef<() => void>(); + + useEffect(() => { + savedCallback.current = callback; + }, [callback]); + + useEffect(() => { + function tick() { + savedCallback.current && savedCallback.current(); + } + if (delay !== null) { + const id = setInterval(tick, delay); + return () => clearInterval(id); + } + }, [delay]); +}; + +export default useTimeout; diff --git a/frontend/app/mstore/dashboardStore.ts b/frontend/app/mstore/dashboardStore.ts index 5fe8c9eb3..6eebcb04e 100644 --- a/frontend/app/mstore/dashboardStore.ts +++ b/frontend/app/mstore/dashboardStore.ts @@ -1,11 +1,9 @@ import { makeAutoObservable, runInAction, - observable, - action, } from "mobx"; -import Dashboard, { IDashboard } from "./types/dashboard"; -import Widget, { IWidget } from "./types/widget"; +import Dashboard from "./types/dashboard"; +import Widget from "./types/widget"; import { dashboardService, metricService } from "App/services"; import { toast } from "react-toastify"; import Period, { @@ -13,91 +11,24 @@ import Period, { LAST_7_DAYS, } from "Types/app/period"; import { getChartFormatter } from "Types/dashboard/helper"; -import Filter, { IFilter } from "./types/filter"; +import Filter from "./types/filter"; import Funnel from "./types/funnel"; import Session from "./types/session"; import Error from "./types/error"; import { FilterKey } from "Types/filter/filterType"; -export interface IDashboardStore { - dashboards: IDashboard[]; - selectedDashboard: IDashboard | null; - dashboardInstance: IDashboard; - selectedWidgets: IWidget[]; - startTimestamp: number; - endTimestamp: number; - period: Period; - drillDownFilter: IFilter; - drillDownPeriod: Period; - - siteId: any; - currentWidget: Widget; - widgetCategories: any[]; - widgets: Widget[]; - metricsPage: number; - metricsPageSize: number; - metricsSearch: string; - - isLoading: boolean; - isSaving: boolean; - isDeleting: boolean; - fetchingDashboard: boolean; - sessionsLoading: boolean; - - showAlertModal: boolean; - - selectWidgetsByCategory: (category: string) => void; - toggleAllSelectedWidgets: (isSelected: boolean) => void; - removeSelectedWidgetByCategory(category: string): void; - toggleWidgetSelection(widget: IWidget): void; - - initDashboard(dashboard?: IDashboard): void; - updateKey(key: string, value: any): void; - resetCurrentWidget(): void; - editWidget(widget: any): void; - fetchList(): Promise<any>; - fetch(dashboardId: string): Promise<any>; - save(dashboard: IDashboard): Promise<any>; - deleteDashboard(dashboard: IDashboard): Promise<any>; - toJson(): void; - fromJson(json: any): void; - addDashboard(dashboard: IDashboard): void; - removeDashboard(dashboard: IDashboard): void; - getDashboard(dashboardId: string): IDashboard | null; - getDashboardCount(): void; - updateDashboard(dashboard: IDashboard): void; - selectDashboardById(dashboardId: string): void; - setSiteId(siteId: any): void; - selectDefaultDashboard(): Promise<IDashboard>; - - saveMetric(metric: IWidget, dashboardId?: string): Promise<any>; - fetchTemplates(hardRefresh: boolean): Promise<any>; - deleteDashboardWidget(dashboardId: string, widgetId: string): Promise<any>; - addWidgetToDashboard(dashboard: IDashboard, metricIds: any): Promise<any>; - setDrillDownPeriod(period: any): void; - - updatePinned(dashboardId: string): Promise<any>; - fetchMetricChartData( - metric: IWidget, - data: any, - isWidget: boolean, - period: Period - ): Promise<any>; - setPeriod(period: any): void; -} -export default class DashboardStore implements IDashboardStore { +export default class DashboardStore { siteId: any = null; - // Dashbaord / Widgets dashboards: Dashboard[] = []; selectedDashboard: Dashboard | null = null; dashboardInstance: Dashboard = new Dashboard(); - selectedWidgets: IWidget[] = []; + selectedWidgets: Widget[] = []; currentWidget: Widget = new Widget(); widgetCategories: any[] = []; widgets: Widget[] = []; - period: Period = Period({ rangeName: LAST_24_HOURS }); + period: Record<string, any> = Period({ rangeName: LAST_24_HOURS }); drillDownFilter: Filter = new Filter(); - drillDownPeriod: Period = Period({ rangeName: LAST_7_DAYS }); + drillDownPeriod: Record<string, any> = Period({ rangeName: LAST_7_DAYS }); startTimestamp: number = 0; endTimestamp: number = 0; @@ -115,6 +46,12 @@ export default class DashboardStore implements IDashboardStore { sessionsLoading: boolean = false; showAlertModal: boolean = false; + // Pagination + page: number = 1 + pageSize: number = 10 + dashboardsSearch: string = '' + sort: any = {} + constructor() { makeAutoObservable(this); @@ -332,7 +269,7 @@ export default class DashboardStore implements IDashboardStore { ); } - getDashboard(dashboardId: string): IDashboard | null { + getDashboard(dashboardId: string): Dashboard | null { return ( this.dashboards.find((d) => d.dashboardId === dashboardId) || null ); @@ -364,27 +301,23 @@ export default class DashboardStore implements IDashboardStore { new Dashboard(); }; + getDashboardById = (dashboardId: string) => { + const dashboard = this.dashboards.find((d) => d.dashboardId == dashboardId) + + if (dashboard) { + this.selectedDashboard = dashboard + return true; + } else { + this.selectedDashboard = null + return false; + } + } + setSiteId = (siteId: any) => { this.siteId = siteId; }; - selectDefaultDashboard = (): Promise<Dashboard> => { - return new Promise((resolve, reject) => { - if (this.dashboards.length > 0) { - const pinnedDashboard = this.dashboards.find((d) => d.isPinned); - if (pinnedDashboard) { - this.selectedDashboard = pinnedDashboard; - } else { - this.selectedDashboard = this.dashboards[0]; - } - - resolve(this.selectedDashboard); - } - reject(new Error("No dashboards found")); - }); - }; - - fetchTemplates(hardRefresh): Promise<any> { + fetchTemplates(hardRefresh: boolean): Promise<any> { this.loadingTemplates = true return new Promise((resolve, reject) => { if (this.widgetCategories.length > 0 && !hardRefresh) { @@ -439,38 +372,16 @@ export default class DashboardStore implements IDashboardStore { return dashboardService .addWidget(dashboard, metricIds) .then((response) => { - toast.success("Widget added successfully"); + toast.success("Metric added to dashboard."); }) .catch(() => { - toast.error("Widget could not be added"); + toast.error("Metric could not be added."); }) .finally(() => { this.isSaving = false; }); } - updatePinned(dashboardId: string): Promise<any> { - // this.isSaving = true - return dashboardService - .updatePinned(dashboardId) - .then(() => { - toast.success("Dashboard pinned successfully"); - this.dashboards.forEach((d) => { - if (d.dashboardId === dashboardId) { - d.isPinned = true; - } else { - d.isPinned = false; - } - }); - }) - .catch(() => { - toast.error("Dashboard could not be pinned"); - }) - .finally(() => { - // this.isSaving = false - }); - } - setPeriod(period: any) { this.period = Period({ start: period.start, @@ -491,11 +402,11 @@ export default class DashboardStore implements IDashboardStore { metric: IWidget, data: any, isWidget: boolean = false, - period: Period + period: Record<string, any> ): Promise<any> { period = period.toTimestamps(); const params = { ...period, ...data, key: metric.predefinedKey }; - + if (metric.page && metric.limit) { params["page"] = metric.page; diff --git a/frontend/app/mstore/index.tsx b/frontend/app/mstore/index.tsx index 0d7c5ce5d..26533ee4f 100644 --- a/frontend/app/mstore/index.tsx +++ b/frontend/app/mstore/index.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import DashboardStore, { IDashboardStore } from './dashboardStore'; -import MetricStore, { IMetricStore } from './metricStore'; +import DashboardStore from './dashboardStore'; +import MetricStore from './metricStore'; import UserStore from './userStore'; import RoleStore from './roleStore'; import APIClient from 'App/api_client'; @@ -13,8 +13,8 @@ import ErrorStore from './errorStore'; import SessionStore from './sessionStore'; export class RootStore { - dashboardStore: IDashboardStore; - metricStore: IMetricStore; + dashboardStore: DashboardStore; + metricStore: MetricStore; funnelStore: FunnelStore; settingsStore: SettingsStore; userStore: UserStore; diff --git a/frontend/app/mstore/metricStore.ts b/frontend/app/mstore/metricStore.ts index 6a8a9f63a..9baa8e274 100644 --- a/frontend/app/mstore/metricStore.ts +++ b/frontend/app/mstore/metricStore.ts @@ -4,42 +4,7 @@ import { metricService, errorService } from "App/services"; import { toast } from 'react-toastify'; import Error from "./types/error"; -export interface IMetricStore { - paginatedList: any; - - isLoading: boolean - isSaving: boolean - - metrics: IWidget[] - instance: IWidget - - page: number - pageSize: number - metricsSearch: string - sort: any - - sessionsPage: number - sessionsPageSize: number - - // State Actions - init(metric?: IWidget|null): void - updateKey(key: string, value: any): void - merge(object: any): void - reset(meitricId: string): void - addToList(metric: IWidget): void - updateInList(metric: IWidget): void - findById(metricId: string): void - removeById(metricId: string): void - fetchError(errorId: string): Promise<any> - - // API - save(metric: IWidget, dashboardId?: string): Promise<any> - fetchList(): void - fetch(metricId: string, period?: any): Promise<any> - delete(metric: IWidget): Promise<any> -} - -export default class MetricStore implements IMetricStore { +export default class MetricStore { isLoading: boolean = false isSaving: boolean = false @@ -47,7 +12,7 @@ export default class MetricStore implements IMetricStore { instance: IWidget = new Widget() page: number = 1 - pageSize: number = 15 + pageSize: number = 10 metricsSearch: string = "" sort: any = {} diff --git a/frontend/app/mstore/types/dashboard.ts b/frontend/app/mstore/types/dashboard.ts index 4c7ea801e..cb631ad9c 100644 --- a/frontend/app/mstore/types/dashboard.ts +++ b/frontend/app/mstore/types/dashboard.ts @@ -2,48 +2,20 @@ import { makeAutoObservable, observable, action, runInAction } from "mobx" import Widget, { IWidget } from "./widget" import { dashboardService } from "App/services" import { toast } from 'react-toastify'; +import { DateTime } from 'luxon'; -export interface IDashboard { - dashboardId: any - name: string - description: string - isPublic: boolean - widgets: IWidget[] - metrics: any[] - isValid: boolean - isPinned: boolean - currentWidget: IWidget - config: any - - update(data: any): void - toJson(): any - fromJson(json: any): void - validate(): void - addWidget(widget: IWidget): void - removeWidget(widgetId: string): void - updateWidget(widget: IWidget): void - getWidget(widgetId: string): void - getWidgetIndex(widgetId: string): IWidget - getWidgetByIndex(index: number): void - getWidgetCount(): void - getWidgetIndexByWidgetId(widgetId: string): void - swapWidgetPosition(positionA: number, positionB: number): Promise<any> - sortWidgets(): void - exists(): boolean - toggleMetrics(metricId: string): void -} -export default class Dashboard implements IDashboard { +export default class Dashboard { public static get ID_KEY():string { return "dashboardId" } dashboardId: any = undefined - name: string = "New Dashboard" + name: string = "Untitled Dashboard" description: string = "" isPublic: boolean = true widgets: IWidget[] = [] metrics: any[] = [] isValid: boolean = false - isPinned: boolean = false currentWidget: IWidget = new Widget() config: any = {} + createdAt: Date = new Date() constructor() { makeAutoObservable(this) @@ -63,8 +35,7 @@ export default class Dashboard implements IDashboard { dashboardId: this.dashboardId, name: this.name, isPublic: this.isPublic, - // widgets: this.widgets.map(w => w.toJson()) - // widgets: this.widgets + createdAt: this.createdAt, metrics: this.metrics, description: this.description, } @@ -76,8 +47,8 @@ export default class Dashboard implements IDashboard { this.name = json.name this.description = json.description this.isPublic = json.isPublic - this.isPinned = json.isPinned - this.widgets = json.widgets ? json.widgets.map(w => new Widget().fromJson(w)).sort((a, b) => a.position - b.position) : [] + this.createdAt = DateTime.fromMillis(new Date(json.createdAt).getTime()) + this.widgets = json.widgets ? json.widgets.map((w: Widget) => new Widget().fromJson(w)).sort((a: Widget, b: Widget) => a.position - b.position) : [] }) return this } @@ -121,7 +92,7 @@ export default class Dashboard implements IDashboard { return this.widgets.findIndex(w => w.widgetId === widgetId) } - swapWidgetPosition(positionA, positionB): Promise<any> { + swapWidgetPosition(positionA: number, positionB: number): Promise<any> { const widgetA = this.widgets[positionA] const widgetB = this.widgets[positionB] this.widgets[positionA] = widgetB diff --git a/frontend/app/mstore/types/filter.ts b/frontend/app/mstore/types/filter.ts index 8d6576909..549a0ad29 100644 --- a/frontend/app/mstore/types/filter.ts +++ b/frontend/app/mstore/types/filter.ts @@ -1,24 +1,7 @@ import { makeAutoObservable, runInAction, observable, action } from "mobx" import FilterItem from "./filterItem" -export interface IFilter { - filterId: string - name: string - filters: FilterItem[] - eventsOrder: string - startTimestamp: number - endTimestamp: number - - merge: (filter: any) => void - addFilter: (filter: FilterItem) => void - updateFilter: (index:number, filter: any) => void - updateKey: (key: any, value: any) => void - removeFilter: (index: number) => void - fromJson: (json: any) => void - toJson: () => any - toJsonDrilldown: () => any -} -export default class Filter implements IFilter { +export default class Filter { public static get ID_KEY():string { return "filterId" } filterId: string = '' name: string = '' diff --git a/frontend/app/mstore/types/filterItem.ts b/frontend/app/mstore/types/filterItem.ts index 5a2a8eb6b..9214fb9bd 100644 --- a/frontend/app/mstore/types/filterItem.ts +++ b/frontend/app/mstore/types/filterItem.ts @@ -77,7 +77,8 @@ export default class FilterItem { this.options = _filter.options; this.isEvent = _filter.isEvent; - (this.value = json.value.length === 0 || !json.value ? [''] : json.value), (this.operator = json.operator); + (this.value = json.value.length === 0 || !json.value ? [''] : json.value); + (this.operator = json.operator); this.source = json.source; this.sourceOperator = json.sourceOperator; @@ -100,6 +101,9 @@ export default class FilterItem { sourceOperator: this.sourceOperator, filters: Array.isArray(this.filters) ? this.filters.map((i) => i.toJson()) : [], }; + if (this.type === FilterKey.DURATION) { + json.value = this.value.map((i: any) => !i ? 0 : i) + } return json; } } diff --git a/frontend/app/mstore/types/funnel.ts b/frontend/app/mstore/types/funnel.ts index 3678f43d1..1b9cf9a71 100644 --- a/frontend/app/mstore/types/funnel.ts +++ b/frontend/app/mstore/types/funnel.ts @@ -1,17 +1,6 @@ import FunnelStage from './funnelStage' -export interface IFunnel { - affectedUsers: number; - totalConversions: number; - totalConversionsPercentage: number; - conversionImpact: number - lostConversions: number - lostConversionsPercentage: number - isPublic: boolean - fromJSON: (json: any) => void -} - -export default class Funnel implements IFunnel { +export default class Funnel { affectedUsers: number = 0 totalConversions: number = 0 conversionImpact: number = 0 @@ -48,4 +37,4 @@ export default class Funnel implements IFunnel { return this } -} \ No newline at end of file +} diff --git a/frontend/app/mstore/types/role.ts b/frontend/app/mstore/types/role.ts index 5d8da871a..041648192 100644 --- a/frontend/app/mstore/types/role.ts +++ b/frontend/app/mstore/types/role.ts @@ -1,16 +1,7 @@ import { makeAutoObservable, observable, runInAction } from "mobx"; -export interface IRole { - roleId: string; - name: string; - description: string; - isProtected: boolean; - fromJson(json: any); - toJson(): any; -} - -export default class Role implements IRole { +export default class Role { roleId: string = ''; name: string = ''; description: string = ''; @@ -42,4 +33,4 @@ export default class Role implements IRole { description: this.description, } } -} \ No newline at end of file +} diff --git a/frontend/app/mstore/types/session.ts b/frontend/app/mstore/types/session.ts index 337e7009b..12b031d8a 100644 --- a/frontend/app/mstore/types/session.ts +++ b/frontend/app/mstore/types/session.ts @@ -14,23 +14,7 @@ function hashString(s: string): number { return hash; } -export interface ISession { - sessionId: string - viewed: boolean - duration: number - metadata: any, - startedAt: number - userBrowser: string - userOs: string - userId: string - userDeviceType: string - userCountry: string - eventsCount: number - userNumericHash: number - userDisplayName: string -} - -export default class Session implements ISession { +export default class Session { sessionId: string = ""; viewed: boolean = false duration: number = 0 @@ -76,4 +60,4 @@ export default class Session implements ISession { }) return this } -} \ No newline at end of file +} diff --git a/frontend/app/mstore/types/sessionSettings.ts b/frontend/app/mstore/types/sessionSettings.ts index 493edd523..bcb57a9f2 100644 --- a/frontend/app/mstore/types/sessionSettings.ts +++ b/frontend/app/mstore/types/sessionSettings.ts @@ -19,10 +19,9 @@ export const generateGMTZones = (): Timezone[] => { for (let i = 0; i < combinedArray.length; i++) { let symbol = i < 11 ? '-' : '+'; let isUTC = i === 11; - let prefix = isUTC ? 'UTC / GMT' : 'GMT'; let value = String(combinedArray[i]).padStart(2, '0'); - let tz = `${prefix} ${symbol}${String(combinedArray[i]).padStart(2, '0')}:00`; + let tz = `UTC ${symbol}${String(combinedArray[i]).padStart(2, '0')}:00`; let dropdownValue = `UTC${symbol}${value}`; timezones.push({ label: tz, value: isUTC ? 'UTC' : dropdownValue }); diff --git a/frontend/app/mstore/types/user.ts b/frontend/app/mstore/types/user.ts index 3c005f0e6..9b5a06b43 100644 --- a/frontend/app/mstore/types/user.ts +++ b/frontend/app/mstore/types/user.ts @@ -2,26 +2,7 @@ import { runInAction, makeAutoObservable, observable } from 'mobx' import { DateTime } from 'luxon'; import { validateEmail, validateName } from 'App/validate'; -export interface IUser { - userId: string - email: string - createdAt: string - isAdmin: boolean - isSuperAdmin: boolean - isJoined: boolean - isExpiredInvite: boolean - roleId: string - roleName: string - invitationLink: string - - - updateKey(key: string, value: any): void - fromJson(json: any): IUser - toJson(): any - toSave(): any -} - -export default class User implements IUser { +export default class User { userId: string = ''; name: string = ''; email: string = ''; @@ -102,4 +83,4 @@ export default class User implements IUser { exists() { return !!this.userId; } -} \ No newline at end of file +} diff --git a/frontend/app/mstore/types/widget.ts b/frontend/app/mstore/types/widget.ts index 10e1e68f4..d0a50800f 100644 --- a/frontend/app/mstore/types/widget.ts +++ b/frontend/app/mstore/types/widget.ts @@ -8,59 +8,11 @@ import { issueOptions } from 'App/constants/filterOptions'; import { FilterKey } from 'Types/filter/filterType'; import Period, { LAST_24_HOURS, LAST_30_DAYS } from 'Types/app/period'; -export interface IWidget { - metricId: any - widgetId: any - name: string - metricType: string - metricOf: string - metricValue: string - metricFormat: string - viewType: string - series: FilterSeries[] - sessions: [] - isPublic: boolean - owner: string - lastModified: Date - dashboards: any[] - dashboardIds: any[] - config: any - - sessionsLoading: boolean - - position: number - data: any - isLoading: boolean - isValid: boolean - dashboardId: any - colSpan: number - predefinedKey: string - - page: number - limit: number - params: any - period: any - hasChanged: boolean - - updateKey(key: string, value: any): void - removeSeries(index: number): void - addSeries(): void - fromJson(json: any): void - toJsonDrilldown(): void - toJson(): any - validate(): void - update(data: any): void - exists(): boolean - toWidget(): any - setData(data: any): void - fetchSessions(metricId: any, filter: any): Promise<any> - setPeriod(period: any): void -} -export default class Widget implements IWidget { +export default class Widget { public static get ID_KEY():string { return "metricId" } metricId: any = undefined widgetId: any = undefined - name: string = "New Metric" + name: string = "Untitled Metric" // metricType: string = "timeseries" metricType: string = "timeseries" metricOf: string = "sessionCount" @@ -79,7 +31,7 @@ export default class Widget implements IWidget { limit: number = 5 params: any = { density: 70 } - period: any = Period({ rangeName: LAST_24_HOURS }) // temp value in detail view + period: Record<string, any> = Period({ rangeName: LAST_24_HOURS }) // temp value in detail view hasChanged: boolean = false sessionsLoading: boolean = false diff --git a/frontend/app/player/MessageDistributor/MessageDistributor.ts b/frontend/app/player/MessageDistributor/MessageDistributor.ts index d9f2aac2b..2d73081fb 100644 --- a/frontend/app/player/MessageDistributor/MessageDistributor.ts +++ b/frontend/app/player/MessageDistributor/MessageDistributor.ts @@ -1,3 +1,4 @@ +// @ts-ignore import { Decoder } from "syncod"; import logger from 'App/logger'; @@ -5,7 +6,9 @@ import Resource, { TYPES } from 'Types/session/resource'; // MBTODO: player type import { TYPES as EVENT_TYPES } from 'Types/session/event'; import Log from 'Types/session/log'; -import { update } from '../store'; +import { update, getState } from '../store'; +import { toast } from 'react-toastify'; + import { init as initListsDepr, append as listAppend, @@ -24,7 +27,7 @@ import ActivityManager from './managers/ActivityManager'; import AssistManager from './managers/AssistManager'; import MFileReader from './messages/MFileReader'; -import loadFiles from './network/loadFiles'; +import { loadFiles, checkUnprocessedMobs } from './network/loadFiles'; import { INITIAL_STATE as SUPER_INITIAL_STATE, State as SuperState } from './StatedScreen/StatedScreen'; import { INITIAL_STATE as ASSIST_INITIAL_STATE, State as AssistState } from './managers/AssistManager'; @@ -70,29 +73,31 @@ import type { Timed } from './messages/timed'; export default class MessageDistributor extends StatedScreen { // TODO: consistent with the other data-lists - private readonly locationEventManager: ListWalker<any>/*<LocationEvent>*/ = new ListWalker(); - private readonly locationManager: ListWalker<SetPageLocation> = new ListWalker(); - private readonly loadedLocationManager: ListWalker<SetPageLocation> = new ListWalker(); - private readonly connectionInfoManger: ListWalker<ConnectionInformation> = new ListWalker(); - private readonly performanceTrackManager: PerformanceTrackManager = new PerformanceTrackManager(); - private readonly windowNodeCounter: WindowNodeCounter = new WindowNodeCounter(); - private readonly clickManager: ListWalker<MouseClick> = new ListWalker(); + private locationEventManager: ListWalker<any>/*<LocationEvent>*/ = new ListWalker(); + private locationManager: ListWalker<SetPageLocation> = new ListWalker(); + private loadedLocationManager: ListWalker<SetPageLocation> = new ListWalker(); + private connectionInfoManger: ListWalker<ConnectionInformation> = new ListWalker(); + private performanceTrackManager: PerformanceTrackManager = new PerformanceTrackManager(); + private windowNodeCounter: WindowNodeCounter = new WindowNodeCounter(); + private clickManager: ListWalker<MouseClick> = new ListWalker(); - private readonly resizeManager: ListWalker<SetViewportSize> = new ListWalker([]); - private readonly pagesManager: PagesManager; - private readonly mouseMoveManager: MouseMoveManager; - private readonly assistManager: AssistManager; + private resizeManager: ListWalker<SetViewportSize> = new ListWalker([]); + private pagesManager: PagesManager; + private mouseMoveManager: MouseMoveManager; + private assistManager: AssistManager; - private readonly scrollManager: ListWalker<SetViewportScroll> = new ListWalker(); + private scrollManager: ListWalker<SetViewportScroll> = new ListWalker(); private readonly decoder = new Decoder(); private readonly lists = initLists(); - private activirtManager: ActivityManager | null = null; + private activityManager: ActivityManager | null = null; + private fileReader: MFileReader; - private readonly sessionStart: number; + private sessionStart: number; private navigationStartOffset: number = 0; private lastMessageTime: number = 0; + private lastRecordedMessageTime: number = 0; constructor(private readonly session: any /*Session*/, config: any, live: boolean) { super(); @@ -106,7 +111,7 @@ export default class MessageDistributor extends StatedScreen { initListsDepr({}) this.assistManager.connect(); } else { - this.activirtManager = new ActivityManager(this.session.duration.milliseconds); + this.activityManager = new ActivityManager(this.session.duration.milliseconds); /* == REFACTOR_ME == */ const eventList = this.session.events.toJSON(); initListsDepr({ @@ -115,12 +120,13 @@ export default class MessageDistributor extends StatedScreen { resource: this.session.resources.toJSON(), }); - eventList.forEach(e => { + // TODO: fix types for events, remove immutable js + eventList.forEach((e: Record<string, string>) => { if (e.type === EVENT_TYPES.LOCATION) { //TODO type system this.locationEventManager.append(e); } }); - this.session.errors.forEach(e => { + this.session.errors.forEach((e: Record<string, string>) => { this.lists.exceptions.append(e); }); /* === */ @@ -129,77 +135,162 @@ export default class MessageDistributor extends StatedScreen { } private waitingForFiles: boolean = false - private loadMessages(): void { + + private onFileSuccessRead() { + this.windowNodeCounter.reset() + + if (this.activityManager) { + this.activityManager.end() + update({ + skipIntervals: this.activityManager.list + }) + } + + this.waitingForFiles = false + this.setMessagesLoading(false) + } + + private readAndDistributeMessages(byteArray: Uint8Array, onReadCb?: (msg: Message) => void) { + const msgs: Array<Message> = [] + if (!this.fileReader) { + this.fileReader = new MFileReader(new Uint8Array(), this.sessionStart) + } + + this.fileReader.append(byteArray) + let next: ReturnType<MFileReader['next']> + while (next = this.fileReader.next()) { + const [msg, index] = next + this.distributeMessage(msg, index) + msgs.push(msg) + onReadCb?.(msg) + } + + logger.info("Messages count: ", msgs.length, msgs) + + return msgs + } + + private processStateUpdates(msgs: Message[]) { + // @ts-ignore Hack for upet (TODO: fix ordering in one mutation in tracker(removes first)) + const headChildrenIds = msgs.filter(m => m.parentID === 1).map(m => m.id); + this.pagesManager.sortPages((m1, m2) => { + if (m1.time === m2.time) { + if (m1.tp === "remove_node" && m2.tp !== "remove_node") { + if (headChildrenIds.includes(m1.id)) { + return -1; + } + } else if (m2.tp === "remove_node" && m1.tp !== "remove_node") { + if (headChildrenIds.includes(m2.id)) { + return 1; + } + } else if (m2.tp === "remove_node" && m1.tp === "remove_node") { + const m1FromHead = headChildrenIds.includes(m1.id); + const m2FromHead = headChildrenIds.includes(m2.id); + if (m1FromHead && !m2FromHead) { + return -1; + } else if (m2FromHead && !m1FromHead) { + return 1; + } + } + } + return 0; + }) + + const stateToUpdate: {[key:string]: any} = { + performanceChartData: this.performanceTrackManager.chartData, + performanceAvaliability: this.performanceTrackManager.avaliability, + } + LIST_NAMES.forEach(key => { + stateToUpdate[ `${ key }List` ] = this.lists[ key ].list + }) + update(stateToUpdate) + this.setMessagesLoading(false) + } + + private loadMessages() { this.setMessagesLoading(true) this.waitingForFiles = true - const r = new MFileReader(new Uint8Array(), this.sessionStart) - const msgs: Array<Message> = [] + const onData = (byteArray: Uint8Array) => { + const msgs = this.readAndDistributeMessages(byteArray) + this.processStateUpdates(msgs) + } + loadFiles(this.session.mobsUrl, - b => { - r.append(b) - let next: ReturnType<MFileReader['next']> - while (next = r.next()) { - const [msg, index] = next - this.distributeMessage(msg, index) - msgs.push(msg) - } - - logger.info("Messages count: ", msgs.length, msgs) - - // @ts-ignore Hack for upet (TODO: fix ordering in one mutation in tracker(removes first)) - const headChildrenIds = msgs.filter(m => m.parentID === 1).map(m => m.id); - this.pagesManager.sortPages((m1, m2) => { - if (m1.time === m2.time) { - if (m1.tp === "remove_node" && m2.tp !== "remove_node") { - if (headChildrenIds.includes(m1.id)) { - return -1; - } - } else if (m2.tp === "remove_node" && m1.tp !== "remove_node") { - if (headChildrenIds.includes(m2.id)) { - return 1; - } - } else if (m2.tp === "remove_node" && m1.tp === "remove_node") { - const m1FromHead = headChildrenIds.includes(m1.id); - const m2FromHead = headChildrenIds.includes(m2.id); - if (m1FromHead && !m2FromHead) { - return -1; - } else if (m2FromHead && !m1FromHead) { - return 1; - } - } - } - return 0; - }) - - const stateToUpdate: {[key:string]: any} = { - performanceChartData: this.performanceTrackManager.chartData, - performanceAvaliability: this.performanceTrackManager.avaliability, - } - LIST_NAMES.forEach(key => { - stateToUpdate[ `${ key }List` ] = this.lists[ key ].list - }) - update(stateToUpdate) - this.setMessagesLoading(false) - } + onData ) - .then(() => { - this.windowNodeCounter.reset() - if (this.activirtManager) { - this.activirtManager.end() - update({ - skipIntervals: this.activirtManager.list + .then(() => this.onFileSuccessRead()) + .catch(async () => { + checkUnprocessedMobs(this.session.sessionId) + .then(file => file ? onData(file) : Promise.reject('No session file')) + .then(() => this.onFileSuccessRead()) + .catch((e) => { + logger.error(e) + update({ error: true }) + toast.error('Error getting a session replay file') + }) + .finally(() => { + this.waitingForFiles = false + this.setMessagesLoading(false) }) - } - this.waitingForFiles = false - this.setMessagesLoading(false) + }) - .catch(e => { - logger.error(e) - this.waitingForFiles = false - this.setMessagesLoading(false) + } + + public async reloadWithUnprocessedFile() { + // assist will pause and skip messages to prevent timestamp related errors + this.assistManager.toggleTimeTravelJump() + this.reloadMessageManagers() + + this.setMessagesLoading(true) + this.waitingForFiles = true + + const onData = (byteArray: Uint8Array) => { + const onReadCallback = () => this.setLastRecordedMessageTime(this.lastMessageTime) + const msgs = this.readAndDistributeMessages(byteArray, onReadCallback) + this.sessionStart = msgs[0].time + this.processStateUpdates(msgs) + } + + // unpausing assist + const unpauseAssist = () => { + this.assistManager.toggleTimeTravelJump() + update({ + liveTimeTravel: true, + }); + } + + try { + const unprocessedFile = await checkUnprocessedMobs(this.session.sessionId) + + Promise.resolve(onData(unprocessedFile)) + .then(() => this.onFileSuccessRead()) + .then(unpauseAssist) + } catch (unprocessedFilesError) { + logger.error(unprocessedFilesError) update({ error: true }) - }) + toast.error('Error getting a session replay file') + this.assistManager.toggleTimeTravelJump() + } finally { + this.waitingForFiles = false + this.setMessagesLoading(false) + } + } + + private reloadMessageManagers() { + this.locationEventManager = new ListWalker(); + this.locationManager = new ListWalker(); + this.loadedLocationManager = new ListWalker(); + this.connectionInfoManger = new ListWalker(); + this.clickManager = new ListWalker(); + this.scrollManager = new ListWalker(); + this.resizeManager = new ListWalker([]); + + this.performanceTrackManager = new PerformanceTrackManager() + this.windowNodeCounter = new WindowNodeCounter(); + this.pagesManager = new PagesManager(this, this.session.isMobile) + this.mouseMoveManager = new MouseMoveManager(this); + this.activityManager = new ActivityManager(this.session.duration.milliseconds); } move(t: number, index?: number): void { @@ -246,6 +337,7 @@ export default class MessageDistributor extends StatedScreen { LIST_NAMES.forEach(key => { const lastMsg = this.lists[key].moveGetLast(t, key === 'exceptions' ? undefined : index); if (lastMsg != null) { + // @ts-ignore TODO: fix types stateToUpdate[`${key}ListNow`] = this.lists[key].listNow; } }); @@ -279,10 +371,11 @@ export default class MessageDistributor extends StatedScreen { } } - private decodeMessage(msg, keys: Array<string>) { + private decodeMessage(msg: any, keys: Array<string>) { const decoded = {}; try { keys.forEach(key => { + // @ts-ignore TODO: types for decoder decoded[key] = this.decoder.decode(msg[key]); }); } catch (e) { @@ -294,7 +387,8 @@ export default class MessageDistributor extends StatedScreen { /* Binded */ distributeMessage(msg: Message, index: number): void { - this.lastMessageTime = Math.max(msg.time, this.lastMessageTime) + const lastMessageTime = Math.max(msg.time, this.lastMessageTime) + this.lastMessageTime = lastMessageTime if ([ "mouse_move", "mouse_click", @@ -304,7 +398,7 @@ export default class MessageDistributor extends StatedScreen { "set_viewport_size", "set_viewport_scroll", ].includes(msg.tp)) { - this.activirtManager?.updateAcctivity(msg.time); + this.activityManager?.updateAcctivity(msg.time); } //const index = i + index; //? let decoded; @@ -444,4 +538,12 @@ export default class MessageDistributor extends StatedScreen { update(INITIAL_STATE); this.assistManager.clear(); } + + public setLastRecordedMessageTime(time: number) { + this.lastRecordedMessageTime = time; + } + + public getLastRecordedMessageTime(): number { + return this.lastRecordedMessageTime; + } } diff --git a/frontend/app/player/MessageDistributor/StatedScreen/Screen/BaseScreen.ts b/frontend/app/player/MessageDistributor/StatedScreen/Screen/BaseScreen.ts index 81ced7774..39dda161d 100644 --- a/frontend/app/player/MessageDistributor/StatedScreen/Screen/BaseScreen.ts +++ b/frontend/app/player/MessageDistributor/StatedScreen/Screen/BaseScreen.ts @@ -15,6 +15,43 @@ export const INITIAL_STATE: State = { } +function getElementsFromInternalPoint(doc: Document, { x, y }: Point): Element[] { + // @ts-ignore (IE, Edge) + if (typeof doc.msElementsFromRect === 'function') { + // @ts-ignore + return Array.prototype.slice.call(doc.msElementsFromRect(x,y)) || [] + } + + if (typeof doc.elementsFromPoint === 'function') { + return doc.elementsFromPoint(x, y) + } + const el = doc.elementFromPoint(x, y) + return el ? [ el ] : [] +} + +function getElementsFromInternalPointDeep(doc: Document, point: Point): Element[] { + const elements = getElementsFromInternalPoint(doc, point) + // is it performant though?? + for (let i = 0; i < elements.length; i++) { + const el = elements[i] + if (isIframe(el)){ + const iDoc = el.contentDocument + if (iDoc) { + const iPoint: Point = { + x: point.x - el.clientLeft, + y: point.y - el.clientTop, + } + elements.push(...getElementsFromInternalPointDeep(iDoc, iPoint)) + } + } + } + return elements +} + +function isIframe(el: Element): el is HTMLIFrameElement { + return el.tagName === "IFRAME" +} + export default abstract class BaseScreen { public readonly overlay: HTMLDivElement; private readonly iframe: HTMLIFrameElement; @@ -113,18 +150,10 @@ export default abstract class BaseScreen { return this.document?.elementFromPoint(x, y) || null; } - getElementsFromInternalPoint({ x, y }: Point): Element[] { - // @ts-ignore (IE, Edge) - if (typeof this.document?.msElementsFromRect === 'function') { - // @ts-ignore - return Array.prototype.slice.call(this.document?.msElementsFromRect(x,y)) || []; - } - - if (typeof this.document?.elementsFromPoint === 'function') { - return this.document?.elementsFromPoint(x, y) || []; - } - const el = this.document?.elementFromPoint(x, y); - return el ? [ el ] : []; + getElementsFromInternalPoint(point: Point): Element[] { + const doc = this.document + if (!doc) { return [] } + return getElementsFromInternalPointDeep(doc, point) } getElementFromPoint(point: Point): Element | null { diff --git a/frontend/app/player/MessageDistributor/StatedScreen/Screen/Marker.js b/frontend/app/player/MessageDistributor/StatedScreen/Screen/Marker.js index 5461422cf..ff1476ac3 100644 --- a/frontend/app/player/MessageDistributor/StatedScreen/Screen/Marker.js +++ b/frontend/app/player/MessageDistributor/StatedScreen/Screen/Marker.js @@ -1,5 +1,17 @@ import styles from './marker.module.css'; +function escapeRegExp(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function escapeHtml(string) { + return string.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); +} + +function safeString(string) { + return (escapeHtml(escapeRegExp(string))) +} + export default class Marker { _target = null; _selector = null; @@ -7,15 +19,14 @@ export default class Marker { 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 = "<b>Right-click \> Inspect</b> for more details." - this._tooltip.appendChild(htmlStr) + this._tooltip = document.createElement('div'); + this._tooltip.className = styles.tooltip; + this._tooltip.appendChild(document.createElement('div')); + + const htmlStr = document.createElement('div'); + htmlStr.innerHTML = '<b>Right-click > Inspect</b> for more details.'; + this._tooltip.appendChild(htmlStr); const marker = document.createElement('div'); marker.className = styles.marker; @@ -31,8 +42,8 @@ export default class Marker { marker.appendChild(markerR); marker.appendChild(markerT); marker.appendChild(markerB); - - marker.appendChild(this._tooltip) + + marker.appendChild(this._tooltip); overlay.appendChild(marker); this._marker = marker; @@ -55,14 +66,15 @@ export default class Marker { this.mark(null); } - _autodefineTarget() { // TODO: put to Screen + _autodefineTarget() { + // TODO: put to Screen if (this._selector) { try { const fitTargets = this.screen.document.querySelectorAll(this._selector); if (fitTargets.length === 0) { this._target = null; } else { - this._target = fitTargets[ 0 ]; + this._target = fitTargets[0]; const cursorTarget = this.screen.getCursorTarget(); fitTargets.forEach((target) => { if (target.contains(cursorTarget)) { @@ -70,7 +82,7 @@ export default class Marker { } }); } - } catch(e) { + } catch (e) { console.info(e); } } else { @@ -85,18 +97,18 @@ export default class Marker { } getTagString(tag) { - const attrs = tag.attributes - let str = `<span style="color:#9BBBDC">${tag.tagName.toLowerCase()}</span>` + const attrs = tag.attributes; + let str = `<span style="color:#9BBBDC">${tag.tagName.toLowerCase()}</span>`; for (let i = 0; i < attrs.length; i++) { - let k = attrs[i] - const attribute = k.name + let k = attrs[i]; + const attribute = k.name; if (attribute === 'class') { - str += `<span style="color:#F29766">${'.' + k.value.split(' ').join('.')}</span>` + str += `<span style="color:#F29766">${'.' + safeString(k.value).split(' ').join('.')}</span>`; } if (attribute === 'id') { - str += `<span style="color:#F29766">${'#' + k.value.split(' ').join('#')}</span>` + str += `<span style="color:#F29766">${'#' + safeString(k.value).split(' ').join('#')}</span>`; } } @@ -117,8 +129,7 @@ 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/cursor.module.css b/frontend/app/player/MessageDistributor/StatedScreen/Screen/cursor.module.css index 2e21512b4..f6ffc1852 100644 --- a/frontend/app/player/MessageDistributor/StatedScreen/Screen/cursor.module.css +++ b/frontend/app/player/MessageDistributor/StatedScreen/Screen/cursor.module.css @@ -5,7 +5,7 @@ height: 20px; background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path d="M302.189 329.126H196.105l55.831 135.993c3.889 9.428-.555 19.999-9.444 23.999l-49.165 21.427c-9.165 4-19.443-.571-23.332-9.714l-53.053-129.136-86.664 89.138C18.729 472.71 0 463.554 0 447.977V18.299C0 1.899 19.921-6.096 30.277 5.443l284.412 292.542c11.472 11.179 3.007 31.141-12.5 31.141z"/></svg>'); background-repeat: no-repeat; - transition: top .2s linear, left .2s linear; + transition: top .125s linear, left .125s linear; pointer-events: none; user-select: none; diff --git a/frontend/app/player/MessageDistributor/StatedScreen/StatedScreen.ts b/frontend/app/player/MessageDistributor/StatedScreen/StatedScreen.ts index 177419f35..29fead989 100644 --- a/frontend/app/player/MessageDistributor/StatedScreen/StatedScreen.ts +++ b/frontend/app/player/MessageDistributor/StatedScreen/StatedScreen.ts @@ -81,7 +81,7 @@ export default class StatedScreen extends Screen { const { markedTargets } = getState(); if (markedTargets) { update({ - markedTargets: markedTargets.map(mt => ({ + markedTargets: markedTargets.map((mt: any) => ({ ...mt, boundingRect: this.calculateRelativeBoundingRect(mt.el), })), diff --git a/frontend/app/player/MessageDistributor/managers/AssistManager.ts b/frontend/app/player/MessageDistributor/managers/AssistManager.ts index 2ba90311e..f08d70775 100644 --- a/frontend/app/player/MessageDistributor/managers/AssistManager.ts +++ b/frontend/app/player/MessageDistributor/managers/AssistManager.ts @@ -2,11 +2,10 @@ import type { Socket } from 'socket.io-client'; import type Peer from 'peerjs'; import type { MediaConnection } from 'peerjs'; import type MessageDistributor from '../MessageDistributor'; -import type { Message } from '../messages' import store from 'App/store'; import type { LocalStream } from './LocalStream'; import { update, getState } from '../../store'; -import { iceServerConfigFromString } from 'App/utils' +// import { iceServerConfigFromString } from 'App/utils' import AnnotationCanvas from './AnnotationCanvas'; import MStreamReader from '../messages/MStreamReader'; import JSONRawMessageReader from '../messages/JSONRawMessageReader' @@ -54,12 +53,13 @@ export function getStatusText(status: ConnectionStatus): string { return "Connected. Waiting for the data... (The tab might be inactive)" } } - + export interface State { - calling: CallingState, - peerConnectionStatus: ConnectionStatus, - remoteControl: RemoteControlStatus, - annotating: boolean, + calling: CallingState; + peerConnectionStatus: ConnectionStatus; + remoteControl: RemoteControlStatus; + annotating: boolean; + assistStart: number; } export const INITIAL_STATE: State = { @@ -67,16 +67,20 @@ export const INITIAL_STATE: State = { peerConnectionStatus: ConnectionStatus.Connecting, remoteControl: RemoteControlStatus.Disabled, annotating: false, + assistStart: 0, } const MAX_RECONNECTION_COUNT = 4; export default class AssistManager { + private timeTravelJump = false; + private jumped = false; + constructor(private session: any, private md: MessageDistributor, private config: any) {} private setStatus(status: ConnectionStatus) { - if (getState().peerConnectionStatus === ConnectionStatus.Disconnected && + if (getState().peerConnectionStatus === ConnectionStatus.Disconnected && status !== ConnectionStatus.Connected) { return } @@ -104,7 +108,7 @@ export default class AssistManager { if (document.hidden) { this.socketCloseTimeout = setTimeout(() => { const state = getState() - if (document.hidden && + if (document.hidden && (state.calling === CallingState.NoCall && state.remoteControl === RemoteControlStatus.Enabled)) { this.socket?.close() } @@ -119,8 +123,20 @@ export default class AssistManager { const jmr = new JSONRawMessageReader() const reader = new MStreamReader(jmr) let waitingForMessages = true - let showDisconnectTimeout: ReturnType<typeof setTimeout> | undefined + let disconnectTimeout: ReturnType<typeof setTimeout> | undefined let inactiveTimeout: ReturnType<typeof setTimeout> | undefined + function clearDisconnectTimeout() { + disconnectTimeout && clearTimeout(disconnectTimeout) + disconnectTimeout = undefined + } + function clearInactiveTimeout() { + inactiveTimeout && clearTimeout(inactiveTimeout) + inactiveTimeout = undefined + } + + const now = +new Date() + update({ assistStart: now }) + import('socket.io-client').then(({ default: io }) => { if (this.cleaned) { return } if (this.socket) { this.socket.close() } // TODO: single socket connection @@ -136,7 +152,6 @@ export default class AssistManager { //agentInfo: JSON.stringify({}) } }) - //socket.onAny((...args) => console.log(...args)) socket.on("connect", () => { waitingForMessages = true this.setStatus(ConnectionStatus.WaitingMessages) // TODO: happens frequently on bad network @@ -146,8 +161,7 @@ export default class AssistManager { update({ calling: CallingState.NoCall }) }) socket.on('messages', messages => { - //console.log(messages.filter(m => m._id === 41 || m._id === 44)) - jmr.append(messages) // as RawMessage[] + !this.timeTravelJump && jmr.append(messages) // as RawMessage[] if (waitingForMessages) { waitingForMessages = false // TODO: more explicit @@ -155,12 +169,21 @@ export default class AssistManager { // Call State if (getState().calling === CallingState.Reconnecting) { - this._call() // reconnecting call (todo improve code separation) + this._callSessionPeer() // reconnecting call (todo improve code separation) } } + if (this.timeTravelJump) { + return; + } + for (let msg = reader.readNext();msg !== null;msg = reader.readNext()) { //@ts-ignore + if (this.jumped) { + // @ts-ignore + msg.time = this.md.getLastRecordedMessageTime() + msg.time + } + // @ts-ignore TODO: fix msg types in generator this.md.distributeMessage(msg, msg._index) } }) @@ -171,17 +194,17 @@ export default class AssistManager { id === socket.id && this.toggleRemoteControl(false) }) socket.on('SESSION_RECONNECTED', () => { - showDisconnectTimeout && clearTimeout(showDisconnectTimeout) - inactiveTimeout && clearTimeout(inactiveTimeout) + clearDisconnectTimeout() + clearInactiveTimeout() this.setStatus(ConnectionStatus.Connected) }) socket.on('UPDATE_SESSION', ({ active }) => { - showDisconnectTimeout && clearTimeout(showDisconnectTimeout) + clearDisconnectTimeout() !inactiveTimeout && this.setStatus(ConnectionStatus.Connected) if (typeof active === "boolean") { + clearInactiveTimeout() if (active) { - inactiveTimeout && clearTimeout(inactiveTimeout) this.setStatus(ConnectionStatus.Connected) } else { inactiveTimeout = setTimeout(() => this.setStatus(ConnectionStatus.Inactive), 5000) @@ -190,8 +213,8 @@ export default class AssistManager { }) socket.on('SESSION_DISCONNECTED', e => { waitingForMessages = true - showDisconnectTimeout && clearTimeout(showDisconnectTimeout) - showDisconnectTimeout = setTimeout(() => { + clearDisconnectTimeout() + disconnectTimeout = setTimeout(() => { if (this.cleaned) { return } this.setStatus(ConnectionStatus.Disconnected) }, 30000) @@ -214,7 +237,7 @@ export default class AssistManager { }) socket.on('call_end', this.onRemoteCallEnd) - document.addEventListener('visibilitychange', this.onVisChange) + document.addEventListener('visibilitychange', this.onVisChange) }) } @@ -238,14 +261,14 @@ export default class AssistManager { private onMouseClick = (e: MouseEvent): void => { if (!this.socket) { return; } if (getState().annotating) { return; } // ignore clicks while annotating - + const data = this.md.getInternalViewportCoordinates(e) // const el = this.md.getElementFromPoint(e); // requires requestiong node_id from domManager const el = this.md.getElementFromInternalPoint(data) if (el instanceof HTMLElement) { el.focus() el.oninput = e => { - if (el instanceof HTMLTextAreaElement + if (el instanceof HTMLTextAreaElement || el instanceof HTMLInputElement ) { this.socket && this.socket.emit("input", el.value) @@ -305,7 +328,7 @@ export default class AssistManager { private _peer: Peer | null = null private connectionAttempts: number = 0 - private callConnection: MediaConnection | null = null + private callConnection: MediaConnection[] = [] private getPeer(): Promise<Peer> { if (this._peer && !this._peer.disconnected) { return Promise.resolve(this._peer) } @@ -326,6 +349,32 @@ export default class AssistManager { }; } const peer = this._peer = new Peer(peerOpts) + peer.on('call', call => { + console.log('getting call from', call.peer) + call.answer(this.callArgs.localStream.stream) + this.callConnection.push(call) + + this.callArgs.localStream.onVideoTrack(vTrack => { + const sender = call.peerConnection.getSenders().find(s => s.track?.kind === "video") + if (!sender) { + console.warn("No video sender found") + return + } + sender.replaceTrack(vTrack) + }) + + call.on('stream', stream => { + this.callArgs && this.callArgs.onStream(stream) + }); + // call.peerConnection.addEventListener("track", e => console.log('newtrack',e.track)) + + call.on("close", this.onRemoteCallEnd) + call.on("error", (e) => { + console.error("PeerJS error (on call):", e) + this.initiateCallEnd(); + this.callArgs && this.callArgs.onError && this.callArgs.onError(); + }); + }) peer.on('error', e => { if (e.type === 'disconnected') { return peer.reconnect() @@ -335,7 +384,7 @@ export default class AssistManager { //call-reconnection connected // if (['peer-unavailable', 'network', 'webrtc'].includes(e.type)) { - // this.setStatus(this.connectionAttempts++ < MAX_RECONNECTION_COUNT + // this.setStatus(this.connectionAttempts++ < MAX_RECONNECTION_COUNT // ? ConnectionStatus.Connecting // : ConnectionStatus.Disconnected); // Reconnect... @@ -351,21 +400,21 @@ export default class AssistManager { private handleCallEnd() { this.callArgs && this.callArgs.onCallEnd() - this.callConnection && this.callConnection.close() + this.callConnection[0] && this.callConnection[0].close() update({ calling: CallingState.NoCall }) this.callArgs = null this.toggleAnnotation(false) } - private initiateCallEnd = () => { - this.socket?.emit("call_end") + private initiateCallEnd = async () => { + this.socket?.emit("call_end", store.getState().getIn([ 'user', 'account', 'name'])) this.handleCallEnd() } private onRemoteCallEnd = () => { if (getState().calling === CallingState.Requesting) { this.callArgs && this.callArgs.onReject() - this.callConnection && this.callConnection.close() + this.callConnection[0] && this.callConnection[0].close() update({ calling: CallingState.NoCall }) this.callArgs = null this.toggleAnnotation(false) @@ -378,16 +427,17 @@ export default class AssistManager { localStream: LocalStream, onStream: (s: MediaStream)=>void, onCallEnd: () => void, - onReject: () => void, - onError?: ()=> void + onReject: () => void, + onError?: ()=> void, } | null = null - call( - localStream: LocalStream, - onStream: (s: MediaStream)=>void, - onCallEnd: () => void, - onReject: () => void, - onError?: ()=> void): { end: Function } { + public setCallArgs( + localStream: LocalStream, + onStream: (s: MediaStream)=>void, + onCallEnd: () => void, + onReject: () => void, + onError?: ()=> void, + ) { this.callArgs = { localStream, onStream, @@ -395,12 +445,66 @@ export default class AssistManager { onReject, onError, } - this._call() + } + + public call(thirdPartyPeers?: string[]): { end: Function } { + if (thirdPartyPeers && thirdPartyPeers.length > 0) { + this.addPeerCall(thirdPartyPeers) + } else { + this._callSessionPeer() + } return { end: this.initiateCallEnd, } } + /** Connecting to the other agents that are already + * in the call with the user + */ + public addPeerCall(thirdPartyPeers: string[]) { + thirdPartyPeers.forEach(peer => this._peerConnection(peer)) + } + + /** Connecting to the app user */ + private _callSessionPeer() { + if (![CallingState.NoCall, CallingState.Reconnecting].includes(getState().calling)) { return } + update({ calling: CallingState.Connecting }) + this._peerConnection(this.peerID); + this.socket && this.socket.emit("_agent_name", store.getState().getIn([ 'user', 'account', 'name'])) + } + + private async _peerConnection(remotePeerId: string) { + try { + const peer = await this.getPeer(); + const call = peer.call(remotePeerId, this.callArgs.localStream.stream) + this.callConnection.push(call) + + this.callArgs.localStream.onVideoTrack(vTrack => { + const sender = call.peerConnection.getSenders().find(s => s.track?.kind === "video") + if (!sender) { + console.warn("No video sender found") + return + } + sender.replaceTrack(vTrack) + }) + + call.on('stream', stream => { + getState().calling !== CallingState.OnCall && update({ calling: CallingState.OnCall }) + this.callArgs && this.callArgs.onStream(stream) + }); + // call.peerConnection.addEventListener("track", e => console.log('newtrack',e.track)) + + call.on("close", this.onRemoteCallEnd) + call.on("error", (e) => { + console.error("PeerJS error (on call):", e) + this.initiateCallEnd(); + this.callArgs && this.callArgs.onError && this.callArgs.onError(); + }); + } catch (e) { + console.error(e) + } + } + toggleAnnotation(enable?: boolean) { // if (getState().calling !== CallingState.OnCall) { return } if (typeof enable !== "boolean") { @@ -442,44 +546,11 @@ export default class AssistManager { private annot: AnnotationCanvas | null = null - private _call() { - if (![CallingState.NoCall, CallingState.Reconnecting].includes(getState().calling)) { return } - update({ calling: CallingState.Connecting }) - this.getPeer().then(peer => { - if (!this.callArgs) { return console.log("No call Args. Must not happen.") } - update({ calling: CallingState.Requesting }) - - // TODO: in a proper way - this.socket && this.socket.emit("_agent_name", store.getState().getIn([ 'user', 'account', 'name'])) - - const call = this.callConnection = peer.call(this.peerID, this.callArgs.localStream.stream) - this.callArgs.localStream.onVideoTrack(vTrack => { - const sender = call.peerConnection.getSenders().find(s => s.track?.kind === "video") - if (!sender) { - console.warn("No video sender found") - return - } - //logger.log("sender found:", sender) - sender.replaceTrack(vTrack) - }) - - call.on('stream', stream => { - update({ calling: CallingState.OnCall }) - this.callArgs && this.callArgs.onStream(stream) - }); - //call.peerConnection.addEventListener("track", e => console.log('newtrack',e.track)) - - call.on("close", this.onRemoteCallEnd) - call.on("error", (e) => { - console.error("PeerJS error (on call):", e) - this.initiateCallEnd(); - this.callArgs && this.callArgs.onError && this.callArgs.onError(); - }); - - }) + toggleTimeTravelJump() { + this.jumped = true; + this.timeTravelJump = !this.timeTravelJump; } - /* ==== Cleaning ==== */ private cleaned: boolean = false clear() { @@ -502,5 +573,3 @@ export default class AssistManager { } } } - - diff --git a/frontend/app/player/MessageDistributor/managers/DOM/DOMManager.ts b/frontend/app/player/MessageDistributor/managers/DOM/DOMManager.ts new file mode 100644 index 000000000..6ce7762e2 --- /dev/null +++ b/frontend/app/player/MessageDistributor/managers/DOM/DOMManager.ts @@ -0,0 +1,394 @@ +import logger from 'App/logger'; + +import type StatedScreen from '../../StatedScreen'; +import type { Message, SetNodeScroll, CreateElementNode } from '../../messages'; + +import ListWalker from '../ListWalker'; +import StylesManager, { rewriteNodeStyleSheet } from './StylesManager'; +import { VElement, VText, VShadowRoot, VDocument, VNode, VStyleElement } from './VirtualDOM'; +import type { StyleElement } from './VirtualDOM'; + + +type HTMLElementWithValue = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement + +const IGNORED_ATTRS = [ "autocomplete" ]; +const ATTR_NAME_REGEXP = /([^\t\n\f \/>"'=]+)/; // regexp costs ~ + + +// TODO: filter out non-relevant prefixes +// function replaceCSSPrefixes(css: string) { +// return css +// .replace(/\-ms\-/g, "") +// .replace(/\-webkit\-/g, "") +// .replace(/\-moz\-/g, "") +// .replace(/\-webkit\-/g, "") +// } + +function insertRule(sheet: CSSStyleSheet, msg: { rule: string, index: number }) { + try { + sheet.insertRule(msg.rule, msg.index) + } catch (e) { + logger.warn(e, msg) + try { + sheet.insertRule(msg.rule) + } catch (e) { + logger.warn("Cannot insert rule.", e, msg) + } + } +} + +function deleteRule(sheet: CSSStyleSheet, msg: { index: number }) { + try { + sheet.deleteRule(msg.index) + } catch (e) { + logger.warn(e, msg) + } +} + +export default class DOMManager extends ListWalker<Message> { + private vTexts: Map<number, VText> = new Map() // map vs object here? + private vElements: Map<number, VElement> = new Map() + private vRoots: Map<number, VShadowRoot | VDocument> = new Map() + private styleSheets: Map<number, CSSStyleSheet> = new Map() + + + private upperBodyId: number = -1; + private nodeScrollManagers: Map<number, ListWalker<SetNodeScroll>> = new Map() + private stylesManager: StylesManager + + + constructor( + private readonly screen: StatedScreen, + private readonly isMobile: boolean, + public readonly time: number + ) { + super() + this.stylesManager = new StylesManager(screen) + logger.log(this.vElements) + } + + append(m: Message): void { + if (m.tp === "set_node_scroll") { + let scrollManager = this.nodeScrollManagers.get(m.id) + if (!scrollManager) { + scrollManager = new ListWalker() + this.nodeScrollManagers.set(m.id, scrollManager) + } + scrollManager.append(m) + return + } + if (m.tp === "create_element_node") { + if(m.tag === "BODY" && this.upperBodyId === -1) { + this.upperBodyId = m.id + } + } 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.append(m) + } + + private removeBodyScroll(id: number, vn: VElement): void { + if (this.isMobile && this.upperBodyId === id) { // Need more type safety! + (vn.node as HTMLBodyElement).style.overflow = "hidden" + } + } + + // May be make it as a message on message add? + private removeAutocomplete(node: Element): boolean { + const tag = node.tagName + if ([ "FORM", "TEXTAREA", "SELECT" ].includes(tag)) { + node.setAttribute("autocomplete", "off"); + return true; + } + if (tag === "INPUT") { + node.setAttribute("autocomplete", "new-password"); + return true; + } + return false; + } + + private insertNode({ parentID, id, index }: { parentID: number, id: number, index: number }): void { + const child = this.vElements.get(id) || this.vTexts.get(id) + if (!child) { + logger.error("Insert error. Node not found", id); + return; + } + const parent = this.vElements.get(parentID) || this.vRoots.get(parentID) + if (!parent) { + logger.error("Insert error. Parent node not found", parentID); + return; + } + + const pNode = parent.node + if ((pNode instanceof HTMLStyleElement) && // TODO: correct ordering OR filter in tracker + pNode.sheet && + pNode.sheet.cssRules && + pNode.sheet.cssRules.length > 0 && + pNode.innerText && + pNode.innerText.trim().length === 0 + ) { + logger.log("Trying to insert child to a style tag with virtual rules: ", parent, child); + return; + } + + parent.insertChildAt(child, index) + } + + private applyMessage = (msg: Message): void => { + let node: Node | undefined + let vn: VNode | undefined + let doc: Document | null + let styleSheet: CSSStyleSheet | undefined + switch (msg.tp) { + case "create_document": + doc = this.screen.document; + if (!doc) { + logger.error("No iframe document found", msg) + return; + } + doc.open(); + doc.write("<!DOCTYPE html><html></html>"); + doc.close(); + const fRoot = doc.documentElement; + fRoot.innerText = ''; + + vn = new VElement(fRoot) + this.vElements = new Map([[0, vn]]) + const vDoc = new VDocument(doc) + vDoc.insertChildAt(vn, 0) + this.vRoots = new Map([[0, vDoc]]) // watchout: id==0 for both Document and documentElement + // this is done for the AdoptedCSS logic + // todo: start from 0 (sync logic with tracker) + this.stylesManager.reset() + return + case "create_text_node": + vn = new VText() + this.vTexts.set(msg.id, vn) + this.insertNode(msg) + return + case "create_element_node": + let element: Element + if (msg.svg) { + element = document.createElementNS('http://www.w3.org/2000/svg', msg.tag) + } else { + element = document.createElement(msg.tag) + } + if (msg.tag === "STYLE" || msg.tag === "style") { + vn = new VStyleElement(element as StyleElement) + } else { + vn = new VElement(element) + } + this.vElements.set(msg.id, vn) + this.insertNode(msg) + this.removeBodyScroll(msg.id, vn) + this.removeAutocomplete(element) + if (['STYLE', 'style', 'LINK'].includes(msg.tag)) { // Styles in priority + vn.enforceInsertion() + } + return + case "move_node": + this.insertNode(msg); + return + case "remove_node": + vn = this.vElements.get(msg.id) || this.vTexts.get(msg.id) + if (!vn) { logger.error("Node not found", msg); return } + if (!vn.parentNode) { logger.error("Parent node not found", msg); return } + vn.parentNode.removeChild(vn) + return + case "set_node_attribute": + let { name, value } = msg; + vn = this.vElements.get(msg.id) + if (!vn) { logger.error("Node not found", msg); return } + if (vn.node.tagName === "INPUT" && name === "name") { + // Otherwise binds local autocomplete values (maybe should ignore on the tracker level) + return + } + if (name === "href" && vn.node.tagName === "LINK") { + // @ts-ignore ?global ENV type // It've been done on backend (remove after testing in saas) + // if (value.startsWith(window.env.ASSETS_HOST || window.location.origin + '/assets')) { + // value = value.replace("?", "%3F"); + // } + if (!value.startsWith("http")) { return } + // blob:... value happened here. https://foss.openreplay.com/3/session/7013553567419137 + // that resulted in that link being unable to load and having 4sec timeout in the below function. + this.stylesManager.setStyleHandlers(vn.node as HTMLLinkElement, value); + } + if (vn.node.namespaceURI === 'http://www.w3.org/2000/svg' && value.startsWith("url(")) { + value = "url(#" + (value.split("#")[1] ||")") + } + vn.setAttribute(name, value) + this.removeBodyScroll(msg.id, vn) + return + case "remove_node_attribute": + vn = this.vElements.get(msg.id) + if (!vn) { logger.error("Node not found", msg); return } + vn.removeAttribute(msg.name) + return + case "set_input_value": + vn = this.vElements.get(msg.id) + if (!vn) { logger.error("Node not found", msg); return } + const nodeWithValue = vn.node + if (!(nodeWithValue instanceof HTMLInputElement + || nodeWithValue instanceof HTMLTextAreaElement + || nodeWithValue instanceof HTMLSelectElement) + ) { + logger.error("Trying to set value of non-Input element", msg) + return + } + const val = msg.mask > 0 ? '*'.repeat(msg.mask) : msg.value + doc = this.screen.document + if (doc && nodeWithValue === doc.activeElement) { + // For the case of Remote Control + nodeWithValue.onblur = () => { nodeWithValue.value = val } + return + } + nodeWithValue.value = val + return + case "set_input_checked": + vn = this.vElements.get(msg.id) + if (!vn) { logger.error("Node not found", msg); return } + (vn.node as HTMLInputElement).checked = msg.checked + return + case "set_node_data": + case "set_css_data": // mbtodo: remove css transitions when timeflow is not natural (on jumps) + vn = this.vTexts.get(msg.id) + if (!vn) { logger.error("Node not found", msg); return } + vn.setData(msg.data) + if (vn.node instanceof HTMLStyleElement) { + doc = this.screen.document + // TODO: move to message parsing + doc && rewriteNodeStyleSheet(doc, vn.node) + } + if (msg.tp === "set_css_data") { // Styles in priority (do we need inlines as well?) + vn.applyChanges() + } + return + case "css_insert_rule": + vn = this.vElements.get(msg.id) + if (!vn) { logger.error("Node not found", msg); return } + if (!(vn instanceof VStyleElement)) { + logger.warn("Non-style node in CSS rules message (or sheet is null)", msg, vn); + return + } + vn.onStyleSheet(sheet => insertRule(sheet, msg)) + return + case "css_delete_rule": + vn = this.vElements.get(msg.id) + if (!vn) { logger.error("Node not found", msg); return } + if (!(vn instanceof VStyleElement)) { + logger.warn("Non-style node in CSS rules message (or sheet is null)", msg, vn); + return + } + vn.onStyleSheet(sheet => deleteRule(sheet, msg)) + return + case "create_i_frame_document": + vn = this.vElements.get(msg.frameID) + if (!vn) { logger.error("Node not found", msg); return } + vn.enforceInsertion() + const host = vn.node + if (host instanceof HTMLIFrameElement) { + const vDoc = new VDocument() + this.vRoots.set(msg.id, vDoc) + const doc = host.contentDocument + if (!doc) { + logger.warn("No iframe doc onload", msg, host) + return + } + vDoc.setDocument(doc) + return; + } else if (host instanceof Element) { // shadow DOM + try { + const shadowRoot = host.attachShadow({ mode: 'open' }) + vn = new VShadowRoot(shadowRoot) + this.vRoots.set(msg.id, vn) + } catch(e) { + logger.warn("Can not attach shadow dom", e, msg) + } + } else { + logger.warn("Context message host is not Element", msg) + } + return + case "adopted_ss_insert_rule": + styleSheet = this.styleSheets.get(msg.sheetID) + if (!styleSheet) { + logger.warn("No stylesheet was created for ", msg) + return + } + insertRule(styleSheet, msg) + return + case "adopted_ss_delete_rule": + styleSheet = this.styleSheets.get(msg.sheetID) + if (!styleSheet) { + logger.warn("No stylesheet was created for ", msg) + return + } + deleteRule(styleSheet, msg) + return + case "adopted_ss_replace": + styleSheet = this.styleSheets.get(msg.sheetID) + if (!styleSheet) { + logger.warn("No stylesheet was created for ", msg) + return + } + // @ts-ignore + styleSheet.replaceSync(msg.text) + return + case "adopted_ss_add_owner": + vn = this.vRoots.get(msg.id) + if (!vn) { logger.error("Node not found", msg); return } + styleSheet = this.styleSheets.get(msg.sheetID) + if (!styleSheet) { + let context: typeof globalThis + const rootNode = vn.node + if (rootNode.nodeType === Node.DOCUMENT_NODE) { + context = (rootNode as Document).defaultView + } else { + context = (rootNode as ShadowRoot).ownerDocument.defaultView + } + styleSheet = new context.CSSStyleSheet() + this.styleSheets.set(msg.sheetID, styleSheet) + } + //@ts-ignore + vn.node.adoptedStyleSheets = [...vn.node.adoptedStyleSheets, styleSheet] + return + case "adopted_ss_remove_owner": + styleSheet = this.styleSheets.get(msg.sheetID) + if (!styleSheet) { + logger.warn("No stylesheet was created for ", msg) + return + } + vn = this.vRoots.get(msg.id) + if (!vn) { logger.error("Node not found", msg); return } + //@ts-ignore + vn.node.adoptedStyleSheets = [...vn.node.adoptedStyleSheets].filter(s => s !== styleSheet) + return + } + } + + moveReady(t: number): Promise<void> { + // MBTODO (back jump optimisation): + // - store intemediate virtual dom state + // - cancel previous moveReady tasks (is it possible?) if new timestamp is less + this.moveApply(t, this.applyMessage) // This function autoresets pointer if necessary (better name?) + + this.vRoots.forEach(rt => rt.applyChanges()) // MBTODO (optimisation): affected set + + // Thinkabout (read): css preload + // What if we go back before it is ready? We'll have two handlres? + return this.stylesManager.moveReady(t).then(() => { + // Apply all scrolls after the styles got applied + this.nodeScrollManagers.forEach(manager => { + const msg = manager.moveGetLast(t) + if (msg) { + const vElm = this.vElements.get(msg.id) + if (vElm) { + vElm.node.scrollLeft = msg.x + vElm.node.scrollTop = msg.y + } + } + }) + }) + } +} \ No newline at end of file diff --git a/frontend/app/player/MessageDistributor/managers/StylesManager.ts b/frontend/app/player/MessageDistributor/managers/DOM/StylesManager.ts similarity index 89% rename from frontend/app/player/MessageDistributor/managers/StylesManager.ts rename to frontend/app/player/MessageDistributor/managers/DOM/StylesManager.ts index 3f5ee1b86..c6b674c4e 100644 --- a/frontend/app/player/MessageDistributor/managers/StylesManager.ts +++ b/frontend/app/player/MessageDistributor/managers/DOM/StylesManager.ts @@ -1,10 +1,10 @@ -import type StatedScreen from '../StatedScreen'; -import type { CssInsertRule, CssDeleteRule } from '../messages'; +import type StatedScreen from '../../StatedScreen'; +import type { CssInsertRule, CssDeleteRule } from '../../messages'; type CSSRuleMessage = CssInsertRule | CssDeleteRule; import logger from 'App/logger'; -import ListWalker from './ListWalker'; +import ListWalker from '../ListWalker'; const HOVER_CN = "-openreplay-hover"; @@ -40,21 +40,21 @@ export default class StylesManager extends ListWalker<CSSRuleMessage> { } setStyleHandlers(node: HTMLLinkElement, value: string): void { - let timeoutId; - const promise = new Promise((resolve) => { - if (this.skipCSSLinks.includes(value)) resolve(null); + let timeoutId: ReturnType<typeof setTimeout> | undefined; + const promise = new Promise<void>((resolve) => { + if (this.skipCSSLinks.includes(value)) resolve(); this.linkLoadingCount++; this.screen.setCSSLoading(true); const addSkipAndResolve = () => { this.skipCSSLinks.push(value); // watch out - resolve(null); + resolve() } timeoutId = setTimeout(addSkipAndResolve, 4000); node.onload = () => { const doc = this.screen.document; doc && rewriteNodeStyleSheet(doc, node); - resolve(null); + resolve(); } node.onerror = addSkipAndResolve; }).then(() => { diff --git a/frontend/app/player/MessageDistributor/managers/DOM/VirtualDOM.ts b/frontend/app/player/MessageDistributor/managers/DOM/VirtualDOM.ts new file mode 100644 index 000000000..d1c31b13a --- /dev/null +++ b/frontend/app/player/MessageDistributor/managers/DOM/VirtualDOM.ts @@ -0,0 +1,171 @@ +type VChild = VElement | VText + +export type VNode = VDocument | VShadowRoot | VElement | VText + +abstract class VParent { + abstract node: Node | null + protected children: VChild[] = [] + private insertedChildren: Set<VChild> = new Set() + + insertChildAt(child: VChild, index: number) { + if (child.parentNode) { + child.parentNode.removeChild(child) + } + this.children.splice(index, 0, child) + this.insertedChildren.add(child) + child.parentNode = this + } + + removeChild(child: VChild) { + this.children = this.children.filter(ch => ch !== child) + this.insertedChildren.delete(child) + child.parentNode = null + } + + applyChanges() { + const node = this.node + if (!node) { + // log err + console.error("No node found", this) + return + } + // inserting + for (let i = this.children.length-1; i >= 0; i--) { + const child = this.children[i] + child.applyChanges() + if (this.insertedChildren.has(child)) { + const nextVSibling = this.children[i+1] + node.insertBefore(child.node, nextVSibling ? nextVSibling.node : null) + } + } + this.insertedChildren.clear() + // removing + const realChildren = node.childNodes + for(let j = 0; j < this.children.length; j++) { + while (realChildren[j] !== this.children[j].node) { + node.removeChild(realChildren[j]) + } + } + // removing rest + while(realChildren.length > this.children.length) { + node.removeChild(node.lastChild) + } + } +} + +export class VDocument extends VParent { + constructor(public node: Document | null = null) { super() } + setDocument(doc: Document) { + this.node = doc + } + applyChanges() { + if (this.children.length > 1) { + // log err + } + if (!this.node) { + // iframe not mounted yet + return + } + const child = this.children[0] + if (!child) { return } + child.applyChanges() + const htmlNode = child.node + if (htmlNode.parentNode !== this.node) { + this.node.replaceChild(htmlNode, this.node.documentElement) + } + } +} + +export class VShadowRoot extends VParent { + constructor(public readonly node: ShadowRoot) { super() } +} + +export class VElement extends VParent { + parentNode: VParent | null = null + private newAttributes: Map<string, string | false> = new Map() + constructor(public readonly node: Element) { super() } + setAttribute(name: string, value: string) { + this.newAttributes.set(name, value) + } + removeAttribute(name: string) { + this.newAttributes.set(name, false) + } + + // mbtodo: priority insertion instead. + // rn this is for styles that should be inserted as prior, + // otherwise it will show visual styling lag if there is a transition CSS property) + enforceInsertion() { + let vNode: VElement = this + while (vNode.parentNode instanceof VElement) { + vNode = vNode.parentNode + } + (vNode.parentNode || vNode).applyChanges() + } + + applyChanges() { + this.newAttributes.forEach((value, key) => { + if (value === false) { + this.node.removeAttribute(key) + } else { + try { + this.node.setAttribute(key, value) + } catch { + // log err + } + } + }) + this.newAttributes.clear() + super.applyChanges() + } +} + + +type StyleSheetCallback = (s: CSSStyleSheet) => void +export type StyleElement = HTMLStyleElement | SVGStyleElement +export class VStyleElement extends VElement { + private loaded = false + private stylesheetCallbacks: StyleSheetCallback[] = [] + constructor(public readonly node: StyleElement) { + super(node) // Is it compiled correctly or with 2 node assignments? + node.onload = () => { + const sheet = node.sheet + if (sheet) { + this.stylesheetCallbacks.forEach(cb => cb(sheet)) + this.stylesheetCallbacks = [] + } else { + console.warn("Style onload: sheet is null") + } + this.loaded = true + } + } + + onStyleSheet(cb: StyleSheetCallback) { + if (this.loaded) { + if (!this.node.sheet) { + console.warn("Style tag is loaded, but sheet is null") + return + } + cb(this.node.sheet) + } else { + this.stylesheetCallbacks.push(cb) + } + } +} + +export class VText { + parentNode: VParent | null = null + constructor(public readonly node: Text = new Text()) {} + private data: string = "" + private changed: boolean = false + setData(data: string) { + this.data = data + this.changed = true + } + applyChanges() { + if (this.changed) { + this.node.data = this.data + this.changed = false + } + } +} + diff --git a/frontend/app/player/MessageDistributor/managers/DOMManager.ts b/frontend/app/player/MessageDistributor/managers/DOMManager.ts deleted file mode 100644 index 43c7a274c..000000000 --- a/frontend/app/player/MessageDistributor/managers/DOMManager.ts +++ /dev/null @@ -1,322 +0,0 @@ -import type StatedScreen from '../StatedScreen'; -import type { Message, SetNodeScroll, CreateElementNode } from '../messages'; - -import logger from 'App/logger'; -import StylesManager, { rewriteNodeStyleSheet } 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<Message> { - private isMobile: boolean; - private screen: StatedScreen; - private nl: Array<Node> = []; - private isLink: Array<boolean> = []; // Optimisations - private bodyId: number = -1; - private postponedBodyMessage: CreateElementNode | null = null; - private nodeScrollManagers: Array<ListWalker<SetNodeScroll>> = []; - - 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; - } - - append(m: Message): void { - switch (m.tp) { - case "set_node_scroll": - if (!this.nodeScrollManagers[ m.id ]) { - this.nodeScrollManagers[ m.id ] = new ListWalker(); - } - this.nodeScrollManagers[ m.id ].append(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.append(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.append(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 && - el.innerText.trim().length === 0) { - logger.log("Trying to insert child to a 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; - } - - if (this.nl[ id ] instanceof HTMLHtmlElement) { - // What if some exotic cases? - this.nl[ parentID ].replaceChild(this.nl[ id ], childNodes[childNodes.length-1]) - return - } - - this.nl[ parentID ] - .insertBefore(this.nl[ id ], childNodes[ index ]) - } - - private applyMessage = (msg: Message): void => { - let node; - let doc: Document | null; - switch (msg.tp) { - case "create_document": - doc = this.screen.document; - if (!doc) { - logger.error("No iframe document found", msg) - return; - } - doc.open(); - doc.write("<!DOCTYPE html><html></html>"); - doc.close(); - const fRoot = doc.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(); - return - case "create_text_node": - this.nl[ msg.id ] = document.createTextNode(''); - this.insertNode(msg); - return - 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) { // there are several bodies in iframes TODO: optimise & cache prebuild - this.postponedBodyMessage = msg; - } else { - this.insertNode(msg); - } - this.removeBodyScroll(msg.id); - this.removeAutocomplete(msg); - return - case "move_node": - this.insertNode(msg); - return - case "remove_node": - node = this.nl[ msg.id ] - if (!node) { logger.error("Node not found", msg); return } - if (!node.parentElement) { logger.error("Parent node not found", msg); return } - node.parentElement.removeChild(node); - return - case "set_node_attribute": - let { id, name, value } = msg; - node = this.nl[ id ]; - if (!node) { logger.error("Node not found", msg); return } - if (this.isLink[ id ] && name === "href") { - // @ts-ignore TODO: global ENV type - if (value.startsWith(window.env.ASSETS_HOST || window.location.origin + '/assets')) { // Hack for queries in rewrited urls - value = value.replace("?", "%3F"); - } - this.stylesManager.setStyleHandlers(node, value); - } - if (node.namespaceURI === 'http://www.w3.org/2000/svg' && value.startsWith("url(")) { - value = "url(#" + (value.split("#")[1] ||")") - } - try { - node.setAttribute(name, value); - } catch(e) { - logger.error(e, msg); - } - this.removeBodyScroll(msg.id); - return - case "remove_node_attribute": - if (!this.nl[ msg.id ]) { logger.error("Node not found", msg); return } - try { - (this.nl[ msg.id ] as HTMLElement).removeAttribute(msg.name); - } catch(e) { - logger.error(e, msg); - } - return - case "set_input_value": - node = this.nl[ msg.id ] - if (!node) { logger.error("Node not found", msg); return } - if (!(node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement)) { - logger.error("Trying to set value of non-Input element", msg) - return - } - const val = msg.mask > 0 ? '*'.repeat(msg.mask) : msg.value - doc = this.screen.document - if (doc && node === doc.activeElement) { - // For the case of Remote Control - node.onblur = () => { node.value = val } - return - } - node.value = val - return - case "set_input_checked": - node = this.nl[ msg.id ]; - if (!node) { logger.error("Node not found", msg); return } - (node as HTMLInputElement).checked = msg.checked; - return - case "set_node_data": - case "set_css_data": - node = this.nl[ msg.id ] - if (!node) { logger.error("Node not found", msg); return } - // @ts-ignore - node.data = msg.data; - if (node instanceof HTMLStyleElement) { - doc = this.screen.document - doc && rewriteNodeStyleSheet(doc, node) - } - return - case "css_insert_rule": - node = this.nl[ msg.id ]; - if (!node) { logger.error("Node not found", msg); return } - if (!(node instanceof HTMLStyleElement) // link or null - || node.sheet == null) { - logger.warn("Non-style node in CSS rules message (or sheet is null)", msg); - return - } - try { - node.sheet.insertRule(msg.rule, msg.index) - } catch (e) { - logger.warn(e, msg) - try { - node.sheet.insertRule(msg.rule) - } catch (e) { - logger.warn("Cannot insert rule.", e, msg) - } - } - return - case "css_delete_rule": - node = this.nl[ msg.id ]; - if (!node) { logger.error("Node not found", msg); return } - if (!(node instanceof HTMLStyleElement) // link or null - || node.sheet == null) { - logger.warn("Non-style node in CSS rules message (or sheet is null)", msg); - return - } - try { - node.sheet.deleteRule(msg.index) - } catch (e) { - logger.warn(e, msg) - } - return - case "create_i_frame_document": - node = this.nl[ msg.frameID ]; - // console.log('ifr', msg, node) - - if (node instanceof HTMLIFrameElement) { - doc = node.contentDocument; - if (!doc) { - logger.warn("No iframe doc", msg, node, node.contentDocument); - return; - } - this.nl[ msg.id ] = doc.documentElement - return; - } else if (node instanceof Element) { // shadow DOM - try { - this.nl[ msg.id ] = node.attachShadow({ mode: 'open' }) - } catch(e) { - logger.warn("Can not attach shadow dom", e, msg) - } - } else { - logger.warn("Context message host is not Element", msg) - } - return - } - } - - moveReady(t: number): Promise<void> { - this.moveApply(t, this.applyMessage) // This function autoresets pointer if necessary (better name?) - - /* 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).then(() => { - // Apply all scrolls after the styles got applied - this.nodeScrollManagers.forEach(manager => { - const msg = manager.moveGetLast(t) - if (!!msg && !!this.nl[msg.id]) { - const node = this.nl[msg.id] as HTMLElement - node.scrollLeft = msg.x - node.scrollTop = msg.y - } - }) - }) - } -} \ No newline at end of file diff --git a/frontend/app/player/MessageDistributor/managers/ListWalker.ts b/frontend/app/player/MessageDistributor/managers/ListWalker.ts index 9bae8203e..acf7b70aa 100644 --- a/frontend/app/player/MessageDistributor/managers/ListWalker.ts +++ b/frontend/app/player/MessageDistributor/managers/ListWalker.ts @@ -118,4 +118,4 @@ export default class ListWalker<T extends Timed> { } } -} \ No newline at end of file +} diff --git a/frontend/app/player/MessageDistributor/managers/LocalStream.ts b/frontend/app/player/MessageDistributor/managers/LocalStream.ts index 63f01ad58..360033c7f 100644 --- a/frontend/app/player/MessageDistributor/managers/LocalStream.ts +++ b/frontend/app/player/MessageDistributor/managers/LocalStream.ts @@ -54,6 +54,7 @@ class _LocalStream { }) .catch(e => { // TODO: log + console.error(e) return false }) } diff --git a/frontend/app/player/MessageDistributor/managers/MouseMoveManager.ts b/frontend/app/player/MessageDistributor/managers/MouseMoveManager.ts index f92d53ee9..ca9e3b740 100644 --- a/frontend/app/player/MessageDistributor/managers/MouseMoveManager.ts +++ b/frontend/app/player/MessageDistributor/managers/MouseMoveManager.ts @@ -12,7 +12,6 @@ export default class MouseMoveManager extends ListWalker<MouseMove> { constructor(private screen: StatedScreen) {super()} 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)); @@ -39,6 +38,4 @@ export default class MouseMoveManager extends ListWalker<MouseMove> { this.updateHover(); } } - - -} \ No newline at end of file +} diff --git a/frontend/app/player/MessageDistributor/managers/PagesManager.ts b/frontend/app/player/MessageDistributor/managers/PagesManager.ts index 0a463fe97..9a4398246 100644 --- a/frontend/app/player/MessageDistributor/managers/PagesManager.ts +++ b/frontend/app/player/MessageDistributor/managers/PagesManager.ts @@ -2,7 +2,7 @@ import type StatedScreen from '../StatedScreen'; import type { Message } from '../messages'; import ListWalker from './ListWalker'; -import DOMManager from './DOMManager'; +import DOMManager from './DOM/DOMManager'; export default class PagesManager extends ListWalker<DOMManager> { diff --git a/frontend/app/player/MessageDistributor/managers/WindowNodeCounter.ts b/frontend/app/player/MessageDistributor/managers/WindowNodeCounter.ts index 7f827f6bd..dffaae2de 100644 --- a/frontend/app/player/MessageDistributor/managers/WindowNodeCounter.ts +++ b/frontend/app/player/MessageDistributor/managers/WindowNodeCounter.ts @@ -79,11 +79,11 @@ export default class WindowNodeCounter { moveNode(id: number, parentId: number) { if (!this.nodes[ id ]) { - console.error(`Wrong! Node with id ${ id } not found.`) + console.warn(`Node Counter: Node with id ${ id } not found.`) return } if (!this.nodes[ parentId ]) { - console.error(`Wrong! Node with id ${ parentId } (parentId) not found.`) + console.warn(`Node Counter: Node with id ${ parentId } (parentId) not found.`) return } this.nodes[ id ].moveNode(this.nodes[ parentId ]) diff --git a/frontend/app/player/MessageDistributor/messages/JSONRawMessageReader.ts b/frontend/app/player/MessageDistributor/messages/JSONRawMessageReader.ts index 8143ae17c..0a5677824 100644 --- a/frontend/app/player/MessageDistributor/messages/JSONRawMessageReader.ts +++ b/frontend/app/player/MessageDistributor/messages/JSONRawMessageReader.ts @@ -1,18 +1,100 @@ -import type { RawMessage } from './raw' +import type { + RawMessage, + RawSetNodeAttributeURLBased, + RawSetNodeAttribute, + RawSetCssDataURLBased, + RawSetCssData, + RawCssInsertRuleURLBased, + RawCssInsertRule, + RawAdoptedSsInsertRuleURLBased, + RawAdoptedSsInsertRule, + RawAdoptedSsReplaceURLBased, + RawAdoptedSsReplace, +} from './raw' +import type { TrackerMessage } from './tracker' +import translate from './tracker' +import { TP_MAP } from './tracker-legacy' +import { resolveURL, resolveCSS } from './urlResolve' + + +function legacyTranslate(msg: any): RawMessage | null { + const type = TP_MAP[msg._id as keyof typeof TP_MAP] + if (!type) { + return null + } + msg.tp = type + delete msg._id + return msg as RawMessage +} + + +// TODO: commonURLBased logic for feilds +const resolvers = { + "set_node_attribute_url_based": (msg: RawSetNodeAttributeURLBased): RawSetNodeAttribute => + ({ + ...msg, + value: msg.name === 'src' || msg.name === 'href' + ? resolveURL(msg.baseURL, msg.value) + : (msg.name === 'style' + ? resolveCSS(msg.baseURL, msg.value) + : msg.value + ), + tp: "set_node_attribute", + }), + "set_css_data_url_based": (msg: RawSetCssDataURLBased): RawSetCssData => + ({ + ...msg, + data: resolveCSS(msg.baseURL, msg.data), + tp: "set_css_data", + }), + "css_insert_rule_url_based": (msg: RawCssInsertRuleURLBased): RawCssInsertRule => + ({ + ...msg, + rule: resolveCSS(msg.baseURL, msg.rule), + tp: "css_insert_rule", + }), + "adopted_ss_insert_rule_url_based": (msg: RawAdoptedSsInsertRuleURLBased): RawAdoptedSsInsertRule => + ({ + ...msg, + rule: resolveCSS(msg.baseURL, msg.rule), + tp: "adopted_ss_insert_rule", + }), + "adopted_ss_replace_url_based": (msg: RawAdoptedSsReplaceURLBased): RawAdoptedSsReplace => + ({ + ...msg, + text: resolveCSS(msg.baseURL, msg.text), + tp: "adopted_ss_replace" + }) +} as const + +type ResolvableType = keyof typeof resolvers +type ResolvableRawMessage = RawMessage & { tp: ResolvableType } + +function isResolvable(msg: RawMessage): msg is ResolvableRawMessage { + //@ts-ignore + return resolvers[msg.tp] !== undefined +} -import { TP_MAP } from './raw' export default class JSONRawMessageReader { - constructor(private messages: any[] = []){} - append(messages: any[]) { + constructor(private messages: TrackerMessage[] = []){} + append(messages: TrackerMessage[]) { this.messages = this.messages.concat(messages) } readMessage(): RawMessage | null { - const msg = this.messages.shift() + let msg = this.messages.shift() if (!msg) { return null } - msg.tp = TP_MAP[msg._id] - delete msg._id - return msg as RawMessage + const rawMsg = Array.isArray(msg) + ? translate(msg) + : legacyTranslate(msg) + if (!rawMsg) { + return this.readMessage() + } + if (isResolvable(rawMsg)) { + //@ts-ignore ??? too complex typscript... + return resolvers[rawMsg.tp](rawMsg) + } + return rawMsg } } \ No newline at end of file diff --git a/frontend/app/player/MessageDistributor/messages/MFileReader.ts b/frontend/app/player/MessageDistributor/messages/MFileReader.ts index 82d505716..9db9c2cff 100644 --- a/frontend/app/player/MessageDistributor/messages/MFileReader.ts +++ b/frontend/app/player/MessageDistributor/messages/MFileReader.ts @@ -8,9 +8,9 @@ import RawMessageReader from './RawMessageReader'; // which should be probably somehow incapsulated export default class MFileReader extends RawMessageReader { private pLastMessageID: number = 0 - private currentTime: number = 0 + private currentTime: number public error: boolean = false - constructor(data: Uint8Array, private readonly startTime: number) { + constructor(data: Uint8Array, private startTime?: number) { super(data) } @@ -60,6 +60,9 @@ export default class MFileReader extends RawMessageReader { } if (rMsg.tp === "timestamp") { + if (!this.startTime) { + this.startTime = rMsg.timestamp + } this.currentTime = rMsg.timestamp - this.startTime return this.next() } @@ -68,6 +71,7 @@ export default class MFileReader extends RawMessageReader { time: this.currentTime, _index: this.pLastMessageID, }) + return [msg, this.pLastMessageID] } } diff --git a/frontend/app/player/MessageDistributor/messages/MStreamReader.ts b/frontend/app/player/MessageDistributor/messages/MStreamReader.ts index 1cc30dcec..ede3719ac 100644 --- a/frontend/app/player/MessageDistributor/messages/MStreamReader.ts +++ b/frontend/app/player/MessageDistributor/messages/MStreamReader.ts @@ -1,69 +1,28 @@ import type { Message } from './message' -import type { - RawMessage, - RawSetNodeAttributeURLBased, - RawSetNodeAttribute, - RawSetCssDataURLBased, - RawSetCssData, - RawCssInsertRuleURLBased, - RawCssInsertRule, -} from './raw' +import type { RawMessage } from './raw' import RawMessageReader from './RawMessageReader' -import type { RawMessageReaderI } from './RawMessageReader' -import { resolveURL, resolveCSS } from './urlResolve' - -const resolveMsg = { - "set_node_attribute_url_based": (msg: RawSetNodeAttributeURLBased): RawSetNodeAttribute => - ({ - ...msg, - value: msg.name === 'src' || msg.name === 'href' - ? resolveURL(msg.baseURL, msg.value) - : (msg.name === 'style' - ? resolveCSS(msg.baseURL, msg.value) - : msg.value - ), - tp: "set_node_attribute", - }), - "set_css_data_url_based": (msg: RawSetCssDataURLBased): RawSetCssData => - ({ - ...msg, - data: resolveCSS(msg.baseURL, msg.data), - tp: "set_css_data", - }), - "css_insert_rule_url_based": (msg: RawCssInsertRuleURLBased): RawCssInsertRule => - ({ - ...msg, - rule: resolveCSS(msg.baseURL, msg.rule), - tp: "css_insert_rule", - }) +interface RawMessageReaderI { + readMessage(): RawMessage | null } export default class MStreamReader { - constructor(private readonly r: RawMessageReaderI = new RawMessageReader()){} + constructor(private readonly r: RawMessageReaderI = new RawMessageReader(), private startTs: number = 0){} - // append(buf: Uint8Array) { - // this.r.append(buf) - // } - - private t0: number = 0 private t: number = 0 private idx: number = 0 readNext(): Message | null { let msg = this.r.readMessage() if (msg === null) { return null } - if (msg.tp === "timestamp" || msg.tp === "batch_meta") { - this.t0 = this.t0 || msg.timestamp - this.t = msg.timestamp - this.t0 + if (msg.tp === "timestamp") { + this.startTs = this.startTs || msg.timestamp + this.t = msg.timestamp - this.startTs return this.readNext() } - // why typescript doesn't work here? - msg = (resolveMsg[msg.tp] || ((m:RawMessage)=>m))(msg) - return Object.assign(msg, { time: this.t, _index: this.idx++, }) } -} \ No newline at end of file +} diff --git a/frontend/app/player/MessageDistributor/messages/RawMessageReader.ts b/frontend/app/player/MessageDistributor/messages/RawMessageReader.ts index 501a85a7b..4536a7c0e 100644 --- a/frontend/app/player/MessageDistributor/messages/RawMessageReader.ts +++ b/frontend/app/player/MessageDistributor/messages/RawMessageReader.ts @@ -3,9 +3,6 @@ import PrimitiveReader from './PrimitiveReader' import type { RawMessage } from './raw' -export interface RawMessageReaderI { - readMessage(): RawMessage | null -} export default class RawMessageReader extends PrimitiveReader { readMessage(): RawMessage | null { @@ -20,18 +17,6 @@ export default class RawMessageReader extends PrimitiveReader { switch (tp) { - case 80: { - const pageNo = this.readUint(); if (pageNo === null) { return resetPointer() } - const firstIndex = this.readUint(); if (firstIndex === null) { return resetPointer() } - const timestamp = this.readInt(); if (timestamp === null) { return resetPointer() } - return { - tp: "batch_meta", - pageNo, - firstIndex, - timestamp, - }; - } - case 0: { const timestamp = this.readUint(); if (timestamp === null) { return resetPointer() } return { @@ -40,14 +25,6 @@ export default class RawMessageReader extends PrimitiveReader { }; } - case 2: { - const timestamp = this.readUint(); if (timestamp === null) { return resetPointer() } - return { - tp: "session_disconnect", - timestamp, - }; - } - case 4: { const url = this.readString(); if (url === null) { return resetPointer() } const referrer = this.readString(); if (referrer === null) { return resetPointer() } @@ -190,16 +167,6 @@ export default class RawMessageReader extends PrimitiveReader { }; } - case 17: { - const id = this.readUint(); if (id === null) { return resetPointer() } - const label = this.readString(); if (label === null) { return resetPointer() } - return { - tp: "set_input_target", - id, - label, - }; - } - case 18: { const id = this.readUint(); if (id === null) { return resetPointer() } const value = this.readString(); if (value === null) { return resetPointer() } @@ -242,90 +209,6 @@ export default class RawMessageReader extends PrimitiveReader { }; } - case 23: { - const requestStart = this.readUint(); if (requestStart === null) { return resetPointer() } - const responseStart = this.readUint(); if (responseStart === null) { return resetPointer() } - const responseEnd = this.readUint(); if (responseEnd === null) { return resetPointer() } - const domContentLoadedEventStart = this.readUint(); if (domContentLoadedEventStart === null) { return resetPointer() } - const domContentLoadedEventEnd = this.readUint(); if (domContentLoadedEventEnd === null) { return resetPointer() } - const loadEventStart = this.readUint(); if (loadEventStart === null) { return resetPointer() } - const loadEventEnd = this.readUint(); if (loadEventEnd === null) { return resetPointer() } - const firstPaint = this.readUint(); if (firstPaint === null) { return resetPointer() } - const firstContentfulPaint = this.readUint(); if (firstContentfulPaint === null) { return resetPointer() } - return { - tp: "page_load_timing", - requestStart, - responseStart, - responseEnd, - domContentLoadedEventStart, - domContentLoadedEventEnd, - loadEventStart, - loadEventEnd, - firstPaint, - firstContentfulPaint, - }; - } - - case 24: { - const speedIndex = this.readUint(); if (speedIndex === null) { return resetPointer() } - const visuallyComplete = this.readUint(); if (visuallyComplete === null) { return resetPointer() } - const timeToInteractive = this.readUint(); if (timeToInteractive === null) { return resetPointer() } - return { - tp: "page_render_timing", - speedIndex, - visuallyComplete, - timeToInteractive, - }; - } - - case 25: { - const name = this.readString(); if (name === null) { return resetPointer() } - const message = this.readString(); if (message === null) { return resetPointer() } - const payload = this.readString(); if (payload === null) { return resetPointer() } - return { - tp: "js_exception", - name, - message, - payload, - }; - } - - case 27: { - const name = this.readString(); if (name === null) { return resetPointer() } - const payload = this.readString(); if (payload === null) { return resetPointer() } - return { - tp: "raw_custom_event", - name, - payload, - }; - } - - case 28: { - const id = this.readString(); if (id === null) { return resetPointer() } - return { - tp: "user_id", - id, - }; - } - - case 29: { - const id = this.readString(); if (id === null) { return resetPointer() } - return { - tp: "user_anonymous_id", - id, - }; - } - - case 30: { - const key = this.readString(); if (key === null) { return resetPointer() } - const value = this.readString(); if (value === null) { return resetPointer() } - return { - tp: "metadata", - key, - value, - }; - } - case 37: { const id = this.readUint(); if (id === null) { return resetPointer() } const rule = this.readString(); if (rule === null) { return resetPointer() } @@ -392,14 +275,6 @@ export default class RawMessageReader extends PrimitiveReader { }; } - case 42: { - const type = this.readString(); if (type === null) { return resetPointer() } - return { - tp: "state_action", - type, - }; - } - case 44: { const action = this.readString(); if (action === null) { return resetPointer() } const state = this.readString(); if (state === null) { return resetPointer() } @@ -472,28 +347,6 @@ export default class RawMessageReader extends PrimitiveReader { }; } - case 53: { - const timestamp = this.readUint(); if (timestamp === null) { return resetPointer() } - const duration = this.readUint(); if (duration === null) { return resetPointer() } - const ttfb = this.readUint(); if (ttfb === null) { return resetPointer() } - const headerSize = this.readUint(); if (headerSize === null) { return resetPointer() } - const encodedBodySize = this.readUint(); if (encodedBodySize === null) { return resetPointer() } - const decodedBodySize = this.readUint(); if (decodedBodySize === null) { return resetPointer() } - const url = this.readString(); if (url === null) { return resetPointer() } - const initiator = this.readString(); if (initiator === null) { return resetPointer() } - return { - tp: "resource_timing", - timestamp, - duration, - ttfb, - headerSize, - encodedBodySize, - decodedBodySize, - url, - initiator, - }; - } - case 54: { const downlink = this.readUint(); if (downlink === null) { return resetPointer() } const type = this.readString(); if (type === null) { return resetPointer() } @@ -558,34 +411,6 @@ export default class RawMessageReader extends PrimitiveReader { }; } - case 63: { - const type = this.readString(); if (type === null) { return resetPointer() } - const value = this.readString(); if (value === null) { return resetPointer() } - return { - tp: "technical_info", - type, - value, - }; - } - - case 64: { - const name = this.readString(); if (name === null) { return resetPointer() } - const payload = this.readString(); if (payload === null) { return resetPointer() } - return { - tp: "custom_issue", - name, - payload, - }; - } - - case 65: { - - return { - tp: "page_close", - - }; - } - case 67: { const id = this.readUint(); if (id === null) { return resetPointer() } const rule = this.readString(); if (rule === null) { return resetPointer() } @@ -624,6 +449,84 @@ export default class RawMessageReader extends PrimitiveReader { }; } + case 71: { + const sheetID = this.readUint(); if (sheetID === null) { return resetPointer() } + const text = this.readString(); if (text === null) { return resetPointer() } + const baseURL = this.readString(); if (baseURL === null) { return resetPointer() } + return { + tp: "adopted_ss_replace_url_based", + sheetID, + text, + baseURL, + }; + } + + case 72: { + const sheetID = this.readUint(); if (sheetID === null) { return resetPointer() } + const text = this.readString(); if (text === null) { return resetPointer() } + return { + tp: "adopted_ss_replace", + sheetID, + text, + }; + } + + case 73: { + const sheetID = this.readUint(); if (sheetID === null) { return resetPointer() } + const rule = this.readString(); if (rule === null) { return resetPointer() } + const index = this.readUint(); if (index === null) { return resetPointer() } + const baseURL = this.readString(); if (baseURL === null) { return resetPointer() } + return { + tp: "adopted_ss_insert_rule_url_based", + sheetID, + rule, + index, + baseURL, + }; + } + + case 74: { + const sheetID = this.readUint(); if (sheetID === null) { return resetPointer() } + const rule = this.readString(); if (rule === null) { return resetPointer() } + const index = this.readUint(); if (index === null) { return resetPointer() } + return { + tp: "adopted_ss_insert_rule", + sheetID, + rule, + index, + }; + } + + case 75: { + const sheetID = this.readUint(); if (sheetID === null) { return resetPointer() } + const index = this.readUint(); if (index === null) { return resetPointer() } + return { + tp: "adopted_ss_delete_rule", + sheetID, + index, + }; + } + + case 76: { + const sheetID = this.readUint(); if (sheetID === null) { return resetPointer() } + const id = this.readUint(); if (id === null) { return resetPointer() } + return { + tp: "adopted_ss_add_owner", + sheetID, + id, + }; + } + + case 77: { + const sheetID = this.readUint(); if (sheetID === null) { return resetPointer() } + const id = this.readUint(); if (id === null) { return resetPointer() } + return { + tp: "adopted_ss_remove_owner", + sheetID, + id, + }; + } + case 90: { const timestamp = this.readUint(); if (timestamp === null) { return resetPointer() } const projectID = this.readUint(); if (projectID === null) { return resetPointer() } diff --git a/frontend/app/player/MessageDistributor/messages/message.ts b/frontend/app/player/MessageDistributor/messages/message.ts index 1c21bbfd2..490f817ea 100644 --- a/frontend/app/player/MessageDistributor/messages/message.ts +++ b/frontend/app/player/MessageDistributor/messages/message.ts @@ -2,9 +2,8 @@ import type { Timed } from './timed' import type { RawMessage } from './raw' -import type { RawBatchMeta, +import type { RawTimestamp, - RawSessionDisconnect, RawSetPageLocation, RawSetViewportSize, RawSetViewportScroll, @@ -18,59 +17,50 @@ import type { RawBatchMeta, RawSetNodeData, RawSetCssData, RawSetNodeScroll, - RawSetInputTarget, RawSetInputValue, RawSetInputChecked, RawMouseMove, RawConsoleLog, - RawPageLoadTiming, - RawPageRenderTiming, - RawJsException, - RawRawCustomEvent, - RawUserID, - RawUserAnonymousID, - RawMetadata, RawCssInsertRule, RawCssDeleteRule, RawFetch, RawProfiler, RawOTable, - RawStateAction, RawRedux, RawVuex, RawMobX, RawNgRx, RawGraphQl, RawPerformanceTrack, - RawResourceTiming, RawConnectionInformation, RawSetPageVisibility, RawLongTask, RawSetNodeAttributeURLBased, RawSetCssDataURLBased, - RawTechnicalInfo, - RawCustomIssue, - RawPageClose, RawCssInsertRuleURLBased, RawMouseClick, RawCreateIFrameDocument, + RawAdoptedSsReplaceURLBased, + RawAdoptedSsReplace, + RawAdoptedSsInsertRuleURLBased, + RawAdoptedSsInsertRule, + RawAdoptedSsDeleteRule, + RawAdoptedSsAddOwner, + RawAdoptedSsRemoveOwner, RawIosSessionStart, RawIosCustomEvent, RawIosScreenChanges, RawIosClickEvent, RawIosPerformanceEvent, RawIosLog, - RawIosNetworkCall, } from './raw' + RawIosNetworkCall, +} from './raw' export type Message = RawMessage & Timed -export type BatchMeta = RawBatchMeta & Timed - export type Timestamp = RawTimestamp & Timed -export type SessionDisconnect = RawSessionDisconnect & Timed - export type SetPageLocation = RawSetPageLocation & Timed export type SetViewportSize = RawSetViewportSize & Timed @@ -97,8 +87,6 @@ export type SetCssData = RawSetCssData & Timed export type SetNodeScroll = RawSetNodeScroll & Timed -export type SetInputTarget = RawSetInputTarget & Timed - export type SetInputValue = RawSetInputValue & Timed export type SetInputChecked = RawSetInputChecked & Timed @@ -107,20 +95,6 @@ export type MouseMove = RawMouseMove & Timed export type ConsoleLog = RawConsoleLog & Timed -export type PageLoadTiming = RawPageLoadTiming & Timed - -export type PageRenderTiming = RawPageRenderTiming & Timed - -export type JsException = RawJsException & Timed - -export type RawCustomEvent = RawRawCustomEvent & Timed - -export type UserID = RawUserID & Timed - -export type UserAnonymousID = RawUserAnonymousID & Timed - -export type Metadata = RawMetadata & Timed - export type CssInsertRule = RawCssInsertRule & Timed export type CssDeleteRule = RawCssDeleteRule & Timed @@ -131,8 +105,6 @@ export type Profiler = RawProfiler & Timed export type OTable = RawOTable & Timed -export type StateAction = RawStateAction & Timed - export type Redux = RawRedux & Timed export type Vuex = RawVuex & Timed @@ -145,8 +117,6 @@ export type GraphQl = RawGraphQl & Timed export type PerformanceTrack = RawPerformanceTrack & Timed -export type ResourceTiming = RawResourceTiming & Timed - export type ConnectionInformation = RawConnectionInformation & Timed export type SetPageVisibility = RawSetPageVisibility & Timed @@ -157,18 +127,26 @@ export type SetNodeAttributeURLBased = RawSetNodeAttributeURLBased & Timed export type SetCssDataURLBased = RawSetCssDataURLBased & Timed -export type TechnicalInfo = RawTechnicalInfo & Timed - -export type CustomIssue = RawCustomIssue & Timed - -export type PageClose = RawPageClose & Timed - export type CssInsertRuleURLBased = RawCssInsertRuleURLBased & Timed export type MouseClick = RawMouseClick & Timed export type CreateIFrameDocument = RawCreateIFrameDocument & Timed +export type AdoptedSsReplaceURLBased = RawAdoptedSsReplaceURLBased & Timed + +export type AdoptedSsReplace = RawAdoptedSsReplace & Timed + +export type AdoptedSsInsertRuleURLBased = RawAdoptedSsInsertRuleURLBased & Timed + +export type AdoptedSsInsertRule = RawAdoptedSsInsertRule & Timed + +export type AdoptedSsDeleteRule = RawAdoptedSsDeleteRule & Timed + +export type AdoptedSsAddOwner = RawAdoptedSsAddOwner & Timed + +export type AdoptedSsRemoveOwner = RawAdoptedSsRemoveOwner & Timed + export type IosSessionStart = RawIosSessionStart & Timed export type IosCustomEvent = RawIosCustomEvent & Timed diff --git a/frontend/app/player/MessageDistributor/messages/raw.ts b/frontend/app/player/MessageDistributor/messages/raw.ts index e86181b2d..a546ca799 100644 --- a/frontend/app/player/MessageDistributor/messages/raw.ts +++ b/frontend/app/player/MessageDistributor/messages/raw.ts @@ -1,85 +1,11 @@ // Auto-generated, do not edit -export const TP_MAP = { - 80: "batch_meta", - 0: "timestamp", - 2: "session_disconnect", - 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", - 17: "set_input_target", - 18: "set_input_value", - 19: "set_input_checked", - 20: "mouse_move", - 22: "console_log", - 23: "page_load_timing", - 24: "page_render_timing", - 25: "js_exception", - 27: "raw_custom_event", - 28: "user_id", - 29: "user_anonymous_id", - 30: "metadata", - 37: "css_insert_rule", - 38: "css_delete_rule", - 39: "fetch", - 40: "profiler", - 41: "o_table", - 42: "state_action", - 44: "redux", - 45: "vuex", - 46: "mob_x", - 47: "ng_rx", - 48: "graph_ql", - 49: "performance_track", - 53: "resource_timing", - 54: "connection_information", - 55: "set_page_visibility", - 59: "long_task", - 60: "set_node_attribute_url_based", - 61: "set_css_data_url_based", - 63: "technical_info", - 64: "custom_issue", - 65: "page_close", - 67: "css_insert_rule_url_based", - 69: "mouse_click", - 70: "create_i_frame_document", - 90: "ios_session_start", - 93: "ios_custom_event", - 96: "ios_screen_changes", - 100: "ios_click_event", - 102: "ios_performance_event", - 103: "ios_log", - 105: "ios_network_call", -} - - -export interface RawBatchMeta { - tp: "batch_meta", - pageNo: number, - firstIndex: number, - timestamp: number, -} export interface RawTimestamp { tp: "timestamp", timestamp: number, } -export interface RawSessionDisconnect { - tp: "session_disconnect", - timestamp: number, -} - export interface RawSetPageLocation { tp: "set_page_location", url: string, @@ -164,12 +90,6 @@ export interface RawSetNodeScroll { y: number, } -export interface RawSetInputTarget { - tp: "set_input_target", - id: number, - label: string, -} - export interface RawSetInputValue { tp: "set_input_value", id: number, @@ -195,55 +115,6 @@ export interface RawConsoleLog { value: string, } -export interface RawPageLoadTiming { - tp: "page_load_timing", - requestStart: number, - responseStart: number, - responseEnd: number, - domContentLoadedEventStart: number, - domContentLoadedEventEnd: number, - loadEventStart: number, - loadEventEnd: number, - firstPaint: number, - firstContentfulPaint: number, -} - -export interface RawPageRenderTiming { - tp: "page_render_timing", - speedIndex: number, - visuallyComplete: number, - timeToInteractive: number, -} - -export interface RawJsException { - tp: "js_exception", - name: string, - message: string, - payload: string, -} - -export interface RawRawCustomEvent { - tp: "raw_custom_event", - name: string, - payload: string, -} - -export interface RawUserID { - tp: "user_id", - id: string, -} - -export interface RawUserAnonymousID { - tp: "user_anonymous_id", - id: string, -} - -export interface RawMetadata { - tp: "metadata", - key: string, - value: string, -} - export interface RawCssInsertRule { tp: "css_insert_rule", id: number, @@ -282,11 +153,6 @@ export interface RawOTable { value: string, } -export interface RawStateAction { - tp: "state_action", - type: string, -} - export interface RawRedux { tp: "redux", action: string, @@ -329,18 +195,6 @@ export interface RawPerformanceTrack { usedJSHeapSize: number, } -export interface RawResourceTiming { - tp: "resource_timing", - timestamp: number, - duration: number, - ttfb: number, - headerSize: number, - encodedBodySize: number, - decodedBodySize: number, - url: string, - initiator: string, -} - export interface RawConnectionInformation { tp: "connection_information", downlink: number, @@ -378,23 +232,6 @@ export interface RawSetCssDataURLBased { baseURL: string, } -export interface RawTechnicalInfo { - tp: "technical_info", - type: string, - value: string, -} - -export interface RawCustomIssue { - tp: "custom_issue", - name: string, - payload: string, -} - -export interface RawPageClose { - tp: "page_close", - -} - export interface RawCssInsertRuleURLBased { tp: "css_insert_rule_url_based", id: number, @@ -417,6 +254,52 @@ export interface RawCreateIFrameDocument { id: number, } +export interface RawAdoptedSsReplaceURLBased { + tp: "adopted_ss_replace_url_based", + sheetID: number, + text: string, + baseURL: string, +} + +export interface RawAdoptedSsReplace { + tp: "adopted_ss_replace", + sheetID: number, + text: string, +} + +export interface RawAdoptedSsInsertRuleURLBased { + tp: "adopted_ss_insert_rule_url_based", + sheetID: number, + rule: string, + index: number, + baseURL: string, +} + +export interface RawAdoptedSsInsertRule { + tp: "adopted_ss_insert_rule", + sheetID: number, + rule: string, + index: number, +} + +export interface RawAdoptedSsDeleteRule { + tp: "adopted_ss_delete_rule", + sheetID: number, + index: number, +} + +export interface RawAdoptedSsAddOwner { + tp: "adopted_ss_add_owner", + sheetID: number, + id: number, +} + +export interface RawAdoptedSsRemoveOwner { + tp: "adopted_ss_remove_owner", + sheetID: number, + id: number, +} + export interface RawIosSessionStart { tp: "ios_session_start", timestamp: number, @@ -488,4 +371,4 @@ export interface RawIosNetworkCall { } -export type RawMessage = RawBatchMeta | RawTimestamp | RawSessionDisconnect | RawSetPageLocation | RawSetViewportSize | RawSetViewportScroll | RawCreateDocument | RawCreateElementNode | RawCreateTextNode | RawMoveNode | RawRemoveNode | RawSetNodeAttribute | RawRemoveNodeAttribute | RawSetNodeData | RawSetCssData | RawSetNodeScroll | RawSetInputTarget | RawSetInputValue | RawSetInputChecked | RawMouseMove | RawConsoleLog | RawPageLoadTiming | RawPageRenderTiming | RawJsException | RawRawCustomEvent | RawUserID | RawUserAnonymousID | RawMetadata | RawCssInsertRule | RawCssDeleteRule | RawFetch | RawProfiler | RawOTable | RawStateAction | RawRedux | RawVuex | RawMobX | RawNgRx | RawGraphQl | RawPerformanceTrack | RawResourceTiming | RawConnectionInformation | RawSetPageVisibility | RawLongTask | RawSetNodeAttributeURLBased | RawSetCssDataURLBased | RawTechnicalInfo | RawCustomIssue | RawPageClose | RawCssInsertRuleURLBased | RawMouseClick | RawCreateIFrameDocument | RawIosSessionStart | RawIosCustomEvent | RawIosScreenChanges | RawIosClickEvent | RawIosPerformanceEvent | RawIosLog | RawIosNetworkCall; +export type RawMessage = RawTimestamp | RawSetPageLocation | RawSetViewportSize | RawSetViewportScroll | RawCreateDocument | RawCreateElementNode | RawCreateTextNode | RawMoveNode | RawRemoveNode | RawSetNodeAttribute | RawRemoveNodeAttribute | RawSetNodeData | RawSetCssData | RawSetNodeScroll | RawSetInputValue | RawSetInputChecked | RawMouseMove | RawConsoleLog | RawCssInsertRule | RawCssDeleteRule | RawFetch | RawProfiler | RawOTable | RawRedux | RawVuex | RawMobX | RawNgRx | RawGraphQl | RawPerformanceTrack | RawConnectionInformation | RawSetPageVisibility | RawLongTask | RawSetNodeAttributeURLBased | RawSetCssDataURLBased | RawCssInsertRuleURLBased | RawMouseClick | RawCreateIFrameDocument | RawAdoptedSsReplaceURLBased | RawAdoptedSsReplace | RawAdoptedSsInsertRuleURLBased | RawAdoptedSsInsertRule | RawAdoptedSsDeleteRule | RawAdoptedSsAddOwner | RawAdoptedSsRemoveOwner | RawIosSessionStart | RawIosCustomEvent | RawIosScreenChanges | RawIosClickEvent | RawIosPerformanceEvent | RawIosLog | RawIosNetworkCall; diff --git a/frontend/app/player/MessageDistributor/messages/timed.ts b/frontend/app/player/MessageDistributor/messages/timed.ts index 2dd4cc707..143f6baec 100644 --- a/frontend/app/player/MessageDistributor/messages/timed.ts +++ b/frontend/app/player/MessageDistributor/messages/timed.ts @@ -1 +1 @@ -export interface Timed { readonly time: number }; +export interface Timed { time: number }; diff --git a/frontend/app/player/MessageDistributor/messages/tracker-legacy.ts b/frontend/app/player/MessageDistributor/messages/tracker-legacy.ts new file mode 100644 index 000000000..c89f8a47c --- /dev/null +++ b/frontend/app/player/MessageDistributor/messages/tracker-legacy.ts @@ -0,0 +1,72 @@ +// @ts-nocheck +// Auto-generated, do not edit + +export const TP_MAP = { + 81: "batch_metadata", + 82: "partitioned_message", + 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", + 17: "set_input_target", + 18: "set_input_value", + 19: "set_input_checked", + 20: "mouse_move", + 22: "console_log", + 23: "page_load_timing", + 24: "page_render_timing", + 25: "js_exception", + 27: "raw_custom_event", + 28: "user_id", + 29: "user_anonymous_id", + 30: "metadata", + 37: "css_insert_rule", + 38: "css_delete_rule", + 39: "fetch", + 40: "profiler", + 41: "o_table", + 42: "state_action", + 44: "redux", + 45: "vuex", + 46: "mob_x", + 47: "ng_rx", + 48: "graph_ql", + 49: "performance_track", + 53: "resource_timing", + 54: "connection_information", + 55: "set_page_visibility", + 59: "long_task", + 60: "set_node_attribute_url_based", + 61: "set_css_data_url_based", + 63: "technical_info", + 64: "custom_issue", + 67: "css_insert_rule_url_based", + 69: "mouse_click", + 70: "create_i_frame_document", + 71: "adopted_ss_replace_url_based", + 72: "adopted_ss_replace", + 73: "adopted_ss_insert_rule_url_based", + 74: "adopted_ss_insert_rule", + 75: "adopted_ss_delete_rule", + 76: "adopted_ss_add_owner", + 77: "adopted_ss_remove_owner", + 90: "ios_session_start", + 93: "ios_custom_event", + 96: "ios_screen_changes", + 100: "ios_click_event", + 102: "ios_performance_event", + 103: "ios_log", + 105: "ios_network_call", +} as const + + diff --git a/frontend/app/player/MessageDistributor/messages/tracker.ts b/frontend/app/player/MessageDistributor/messages/tracker.ts new file mode 100644 index 000000000..34493f32c --- /dev/null +++ b/frontend/app/player/MessageDistributor/messages/tracker.ts @@ -0,0 +1,757 @@ +// Auto-generated, do not edit + +import type { RawMessage } from './raw' + + +type TrBatchMetadata = [ + type: 81, + version: number, + pageNo: number, + firstIndex: number, + timestamp: number, + location: string, +] + +type TrPartitionedMessage = [ + type: 82, + partNo: number, + partTotal: number, +] + +type TrTimestamp = [ + type: 0, + timestamp: number, +] + +type TrSetPageLocation = [ + type: 4, + url: string, + referrer: string, + navigationStart: number, +] + +type TrSetViewportSize = [ + type: 5, + width: number, + height: number, +] + +type TrSetViewportScroll = [ + type: 6, + x: number, + y: number, +] + +type TrCreateDocument = [ + type: 7, + +] + +type TrCreateElementNode = [ + type: 8, + id: number, + parentID: number, + index: number, + tag: string, + svg: boolean, +] + +type TrCreateTextNode = [ + type: 9, + id: number, + parentID: number, + index: number, +] + +type TrMoveNode = [ + type: 10, + id: number, + parentID: number, + index: number, +] + +type TrRemoveNode = [ + type: 11, + id: number, +] + +type TrSetNodeAttribute = [ + type: 12, + id: number, + name: string, + value: string, +] + +type TrRemoveNodeAttribute = [ + type: 13, + id: number, + name: string, +] + +type TrSetNodeData = [ + type: 14, + id: number, + data: string, +] + +type TrSetNodeScroll = [ + type: 16, + id: number, + x: number, + y: number, +] + +type TrSetInputTarget = [ + type: 17, + id: number, + label: string, +] + +type TrSetInputValue = [ + type: 18, + id: number, + value: string, + mask: number, +] + +type TrSetInputChecked = [ + type: 19, + id: number, + checked: boolean, +] + +type TrMouseMove = [ + type: 20, + x: number, + y: number, +] + +type TrConsoleLog = [ + type: 22, + level: string, + value: string, +] + +type TrPageLoadTiming = [ + type: 23, + requestStart: number, + responseStart: number, + responseEnd: number, + domContentLoadedEventStart: number, + domContentLoadedEventEnd: number, + loadEventStart: number, + loadEventEnd: number, + firstPaint: number, + firstContentfulPaint: number, +] + +type TrPageRenderTiming = [ + type: 24, + speedIndex: number, + visuallyComplete: number, + timeToInteractive: number, +] + +type TrJSException = [ + type: 25, + name: string, + message: string, + payload: string, +] + +type TrRawCustomEvent = [ + type: 27, + name: string, + payload: string, +] + +type TrUserID = [ + type: 28, + id: string, +] + +type TrUserAnonymousID = [ + type: 29, + id: string, +] + +type TrMetadata = [ + type: 30, + key: string, + value: string, +] + +type TrCSSInsertRule = [ + type: 37, + id: number, + rule: string, + index: number, +] + +type TrCSSDeleteRule = [ + type: 38, + id: number, + index: number, +] + +type TrFetch = [ + type: 39, + method: string, + url: string, + request: string, + response: string, + status: number, + timestamp: number, + duration: number, +] + +type TrProfiler = [ + type: 40, + name: string, + duration: number, + args: string, + result: string, +] + +type TrOTable = [ + type: 41, + key: string, + value: string, +] + +type TrStateAction = [ + type: 42, + type: string, +] + +type TrRedux = [ + type: 44, + action: string, + state: string, + duration: number, +] + +type TrVuex = [ + type: 45, + mutation: string, + state: string, +] + +type TrMobX = [ + type: 46, + type: string, + payload: string, +] + +type TrNgRx = [ + type: 47, + action: string, + state: string, + duration: number, +] + +type TrGraphQL = [ + type: 48, + operationKind: string, + operationName: string, + variables: string, + response: string, +] + +type TrPerformanceTrack = [ + type: 49, + frames: number, + ticks: number, + totalJSHeapSize: number, + usedJSHeapSize: number, +] + +type TrResourceTiming = [ + type: 53, + timestamp: number, + duration: number, + ttfb: number, + headerSize: number, + encodedBodySize: number, + decodedBodySize: number, + url: string, + initiator: string, +] + +type TrConnectionInformation = [ + type: 54, + downlink: number, + type: string, +] + +type TrSetPageVisibility = [ + type: 55, + hidden: boolean, +] + +type TrLongTask = [ + type: 59, + timestamp: number, + duration: number, + context: number, + containerType: number, + containerSrc: string, + containerId: string, + containerName: string, +] + +type TrSetNodeAttributeURLBased = [ + type: 60, + id: number, + name: string, + value: string, + baseURL: string, +] + +type TrSetCSSDataURLBased = [ + type: 61, + id: number, + data: string, + baseURL: string, +] + +type TrTechnicalInfo = [ + type: 63, + type: string, + value: string, +] + +type TrCustomIssue = [ + type: 64, + name: string, + payload: string, +] + +type TrCSSInsertRuleURLBased = [ + type: 67, + id: number, + rule: string, + index: number, + baseURL: string, +] + +type TrMouseClick = [ + type: 69, + id: number, + hesitationTime: number, + label: string, + selector: string, +] + +type TrCreateIFrameDocument = [ + type: 70, + frameID: number, + id: number, +] + +type TrAdoptedSSReplaceURLBased = [ + type: 71, + sheetID: number, + text: string, + baseURL: string, +] + +type TrAdoptedSSInsertRuleURLBased = [ + type: 73, + sheetID: number, + rule: string, + index: number, + baseURL: string, +] + +type TrAdoptedSSDeleteRule = [ + type: 75, + sheetID: number, + index: number, +] + +type TrAdoptedSSAddOwner = [ + type: 76, + sheetID: number, + id: number, +] + +type TrAdoptedSSRemoveOwner = [ + type: 77, + sheetID: number, + id: number, +] + + +export type TrackerMessage = TrBatchMetadata | TrPartitionedMessage | TrTimestamp | TrSetPageLocation | TrSetViewportSize | TrSetViewportScroll | TrCreateDocument | TrCreateElementNode | TrCreateTextNode | TrMoveNode | TrRemoveNode | TrSetNodeAttribute | TrRemoveNodeAttribute | TrSetNodeData | TrSetNodeScroll | TrSetInputTarget | TrSetInputValue | TrSetInputChecked | TrMouseMove | TrConsoleLog | TrPageLoadTiming | TrPageRenderTiming | TrJSException | TrRawCustomEvent | TrUserID | TrUserAnonymousID | TrMetadata | TrCSSInsertRule | TrCSSDeleteRule | TrFetch | TrProfiler | TrOTable | TrStateAction | TrRedux | TrVuex | TrMobX | TrNgRx | TrGraphQL | TrPerformanceTrack | TrResourceTiming | TrConnectionInformation | TrSetPageVisibility | TrLongTask | TrSetNodeAttributeURLBased | TrSetCSSDataURLBased | TrTechnicalInfo | TrCustomIssue | TrCSSInsertRuleURLBased | TrMouseClick | TrCreateIFrameDocument | TrAdoptedSSReplaceURLBased | TrAdoptedSSInsertRuleURLBased | TrAdoptedSSDeleteRule | TrAdoptedSSAddOwner | TrAdoptedSSRemoveOwner + +export default function translate(tMsg: TrackerMessage): RawMessage | null { + switch(tMsg[0]) { + + case 0: { + return { + tp: "timestamp", + timestamp: tMsg[1], + } + } + + case 4: { + return { + tp: "set_page_location", + url: tMsg[1], + referrer: tMsg[2], + navigationStart: tMsg[3], + } + } + + case 5: { + return { + tp: "set_viewport_size", + width: tMsg[1], + height: tMsg[2], + } + } + + case 6: { + return { + tp: "set_viewport_scroll", + x: tMsg[1], + y: tMsg[2], + } + } + + case 7: { + return { + tp: "create_document", + + } + } + + case 8: { + return { + tp: "create_element_node", + id: tMsg[1], + parentID: tMsg[2], + index: tMsg[3], + tag: tMsg[4], + svg: tMsg[5], + } + } + + case 9: { + return { + tp: "create_text_node", + id: tMsg[1], + parentID: tMsg[2], + index: tMsg[3], + } + } + + case 10: { + return { + tp: "move_node", + id: tMsg[1], + parentID: tMsg[2], + index: tMsg[3], + } + } + + case 11: { + return { + tp: "remove_node", + id: tMsg[1], + } + } + + case 12: { + return { + tp: "set_node_attribute", + id: tMsg[1], + name: tMsg[2], + value: tMsg[3], + } + } + + case 13: { + return { + tp: "remove_node_attribute", + id: tMsg[1], + name: tMsg[2], + } + } + + case 14: { + return { + tp: "set_node_data", + id: tMsg[1], + data: tMsg[2], + } + } + + case 16: { + return { + tp: "set_node_scroll", + id: tMsg[1], + x: tMsg[2], + y: tMsg[3], + } + } + + case 18: { + return { + tp: "set_input_value", + id: tMsg[1], + value: tMsg[2], + mask: tMsg[3], + } + } + + case 19: { + return { + tp: "set_input_checked", + id: tMsg[1], + checked: tMsg[2], + } + } + + case 20: { + return { + tp: "mouse_move", + x: tMsg[1], + y: tMsg[2], + } + } + + case 22: { + return { + tp: "console_log", + level: tMsg[1], + value: tMsg[2], + } + } + + case 37: { + return { + tp: "css_insert_rule", + id: tMsg[1], + rule: tMsg[2], + index: tMsg[3], + } + } + + case 38: { + return { + tp: "css_delete_rule", + id: tMsg[1], + index: tMsg[2], + } + } + + case 39: { + return { + tp: "fetch", + method: tMsg[1], + url: tMsg[2], + request: tMsg[3], + response: tMsg[4], + status: tMsg[5], + timestamp: tMsg[6], + duration: tMsg[7], + } + } + + case 40: { + return { + tp: "profiler", + name: tMsg[1], + duration: tMsg[2], + args: tMsg[3], + result: tMsg[4], + } + } + + case 41: { + return { + tp: "o_table", + key: tMsg[1], + value: tMsg[2], + } + } + + case 44: { + return { + tp: "redux", + action: tMsg[1], + state: tMsg[2], + duration: tMsg[3], + } + } + + case 45: { + return { + tp: "vuex", + mutation: tMsg[1], + state: tMsg[2], + } + } + + case 46: { + return { + tp: "mob_x", + type: tMsg[1], + payload: tMsg[2], + } + } + + case 47: { + return { + tp: "ng_rx", + action: tMsg[1], + state: tMsg[2], + duration: tMsg[3], + } + } + + case 48: { + return { + tp: "graph_ql", + operationKind: tMsg[1], + operationName: tMsg[2], + variables: tMsg[3], + response: tMsg[4], + } + } + + case 49: { + return { + tp: "performance_track", + frames: tMsg[1], + ticks: tMsg[2], + totalJSHeapSize: tMsg[3], + usedJSHeapSize: tMsg[4], + } + } + + case 54: { + return { + tp: "connection_information", + downlink: tMsg[1], + type: tMsg[2], + } + } + + case 55: { + return { + tp: "set_page_visibility", + hidden: tMsg[1], + } + } + + case 59: { + return { + tp: "long_task", + timestamp: tMsg[1], + duration: tMsg[2], + context: tMsg[3], + containerType: tMsg[4], + containerSrc: tMsg[5], + containerId: tMsg[6], + containerName: tMsg[7], + } + } + + case 60: { + return { + tp: "set_node_attribute_url_based", + id: tMsg[1], + name: tMsg[2], + value: tMsg[3], + baseURL: tMsg[4], + } + } + + case 61: { + return { + tp: "set_css_data_url_based", + id: tMsg[1], + data: tMsg[2], + baseURL: tMsg[3], + } + } + + case 67: { + return { + tp: "css_insert_rule_url_based", + id: tMsg[1], + rule: tMsg[2], + index: tMsg[3], + baseURL: tMsg[4], + } + } + + case 69: { + return { + tp: "mouse_click", + id: tMsg[1], + hesitationTime: tMsg[2], + label: tMsg[3], + selector: tMsg[4], + } + } + + case 70: { + return { + tp: "create_i_frame_document", + frameID: tMsg[1], + id: tMsg[2], + } + } + + case 71: { + return { + tp: "adopted_ss_replace_url_based", + sheetID: tMsg[1], + text: tMsg[2], + baseURL: tMsg[3], + } + } + + case 73: { + return { + tp: "adopted_ss_insert_rule_url_based", + sheetID: tMsg[1], + rule: tMsg[2], + index: tMsg[3], + baseURL: tMsg[4], + } + } + + case 75: { + return { + tp: "adopted_ss_delete_rule", + sheetID: tMsg[1], + index: tMsg[2], + } + } + + case 76: { + return { + tp: "adopted_ss_add_owner", + sheetID: tMsg[1], + id: tMsg[2], + } + } + + case 77: { + return { + tp: "adopted_ss_remove_owner", + sheetID: tMsg[1], + id: tMsg[2], + } + } + + default: + return null + } + +} \ No newline at end of file diff --git a/frontend/app/player/MessageDistributor/messages/urlResolve.ts b/frontend/app/player/MessageDistributor/messages/urlResolve.ts index 44298ec08..71d1cbda8 100644 --- a/frontend/app/player/MessageDistributor/messages/urlResolve.ts +++ b/frontend/app/player/MessageDistributor/messages/urlResolve.ts @@ -26,10 +26,9 @@ function cssUrlsIndex(css: string): Array<[number, number]> { const e = s + m[1].length; idxs.push([s, e]) } - return idxs; + return idxs.reverse() } function unquote(str: string): [string, string] { - str = str.trim(); if (str.length <= 2) { return [str, ""] } diff --git a/frontend/app/player/MessageDistributor/network/loadFiles.ts b/frontend/app/player/MessageDistributor/network/loadFiles.ts index 5bc8c580d..ff9e62de0 100644 --- a/frontend/app/player/MessageDistributor/network/loadFiles.ts +++ b/frontend/app/player/MessageDistributor/network/loadFiles.ts @@ -1,9 +1,16 @@ -const NO_NTH_FILE = "nnf" +import APIClient from 'App/api_client'; -export default function load( +const NO_NTH_FILE = "nnf" +const NO_UNPROCESSED_FILES = "nuf" + +const getUnprocessedFileLink = (sessionId: string) => '/unprocessed/' + sessionId + +type onDataCb = (data: Uint8Array) => void + +export const loadFiles = ( urls: string[], - onData: (ba: Uint8Array) => void, -): Promise<void> { + onData: onDataCb, +): Promise<void> => { const firstFileURL = urls[0] urls = urls.slice(1) if (!firstFileURL) { @@ -11,31 +18,16 @@ export default function load( } return window.fetch(firstFileURL) .then(r => { - if (r.status >= 400) { - throw new Error(`no start file. status code ${ r.status }`) - } - return r.arrayBuffer() + return processAPIStreamResponse(r, true) }) - .then(b => new Uint8Array(b)) .then(onData) .then(() => urls.reduce((p, url) => p.then(() => window.fetch(url) .then(r => { - return new Promise<ArrayBuffer>((res, rej) => { - if (r.status == 404) { - rej(NO_NTH_FILE) - return - } - if (r.status >= 400) { - rej(`Bad endfile status code ${r.status}`) - return - } - res(r.arrayBuffer()) - }) + return processAPIStreamResponse(r, false) }) - .then(b => new Uint8Array(b)) .then(onData) ), Promise.resolve(), @@ -48,3 +40,32 @@ export default function load( throw e }) } + +export const checkUnprocessedMobs = async (sessionId: string) => { + try { + const api = new APIClient() + const res = await api.fetch(getUnprocessedFileLink(sessionId)) + if (res.status >= 400) { + throw NO_UNPROCESSED_FILES + } + const byteArray = await processAPIStreamResponse(res, false) + return byteArray + } catch (e) { + throw e + } +} + +const processAPIStreamResponse = (response: Response, isFirstFile: boolean) => { + return new Promise<ArrayBuffer>((res, rej) => { + if (response.status === 404 && !isFirstFile) { + return rej(NO_NTH_FILE) + } + if (response.status >= 400) { + return rej( + isFirstFile ? `no start file. status code ${ response.status }` + : `Bad endfile status code ${response.status}` + ) + } + res(response.arrayBuffer()) + }).then(buffer => new Uint8Array(buffer)) +} diff --git a/frontend/app/player/Player.ts b/frontend/app/player/Player.ts index 2b1d0b405..4d4f40ed4 100644 --- a/frontend/app/player/Player.ts +++ b/frontend/app/player/Player.ts @@ -1,11 +1,12 @@ import { goTo as listsGoTo } from './lists'; import { update, getState } from './store'; -import MessageDistributor, { INITIAL_STATE as SUPER_INITIAL_STATE, State as SuperState } from './MessageDistributor/MessageDistributor'; +import MessageDistributor, { INITIAL_STATE as SUPER_INITIAL_STATE } from './MessageDistributor/MessageDistributor'; const fps = 60; const performance = window.performance || { now: Date.now.bind(Date) }; const requestAnimationFrame = window.requestAnimationFrame || + // @ts-ignore window.webkitRequestAnimationFrame || // @ts-ignore window.mozRequestAnimationFrame || @@ -13,7 +14,7 @@ const requestAnimationFrame = window.oRequestAnimationFrame || // @ts-ignore window.msRequestAnimationFrame || - (callback => window.setTimeout(() => { callback(performance.now()); }, 1000 / fps)); + ((callback: (args: any) => void) => window.setTimeout(() => { callback(performance.now()); }, 1000 / fps)); const cancelAnimationFrame = window.cancelAnimationFrame || // @ts-ignore @@ -44,6 +45,7 @@ export const INITIAL_STATE = { inspectorMode: false, live: false, livePlay: false, + liveTimeTravel: false, } as const; @@ -52,7 +54,7 @@ export const INITIAL_NON_RESETABLE_STATE = { skipToIssue: initialSkipToIssue, autoplay: initialAutoplay, speed: initialSpeed, - showEvents: initialShowEvents + showEvents: initialShowEvents, } export default class Player extends MessageDistributor { @@ -71,7 +73,7 @@ export default class Player extends MessageDistributor { let prevTime = getState().time; let animationPrevTime = performance.now(); - const nextFrame = (animationCurrentTime) => { + const nextFrame = (animationCurrentTime: number) => { const { speed, skip, @@ -91,7 +93,7 @@ export default class Player extends MessageDistributor { let time = prevTime + diffTime; - const skipInterval = skip && skipIntervals.find(si => si.contains(time)); // TODO: good skip by messages + const skipInterval = skip && skipIntervals.find((si: Node) => si.contains(time)); // TODO: good skip by messages if (skipInterval) time = skipInterval.end; const fmt = super.getFirstMessageTime(); @@ -117,9 +119,12 @@ export default class Player extends MessageDistributor { }); } - if (live && time > endTime) { + // throttle store updates + // TODO: make it possible to change frame rate + if (live && time - endTime > 100) { update({ endTime: time, + livePlay: endTime - time < 900 }); } this._setTime(time); @@ -151,21 +156,23 @@ export default class Player extends MessageDistributor { } } - jump(time = getState().time, index) { - const { live } = getState(); - if (live) return; + jump(time = getState().time, index: number) { + const { live, liveTimeTravel, endTime } = getState(); + if (live && !liveTimeTravel) return; if (getState().playing) { cancelAnimationFrame(this._animationFrameRequestId); // this._animationFrameRequestId = requestAnimationFrame(() => { this._setTime(time, index); this._startAnimation(); - update({ livePlay: time === getState().endTime }); + // throttilg the redux state update from each frame to nearly half a second + // which is better for performance and component rerenders + update({ livePlay: Math.abs(time - endTime) < 500 }); //}); } else { //this._animationFrameRequestId = requestAnimationFrame(() => { this._setTime(time, index); - update({ livePlay: time === getState().endTime }); + update({ livePlay: Math.abs(time - endTime) < 500 }); //}); } } @@ -176,7 +183,7 @@ export default class Player extends MessageDistributor { update({ skip }); } - toggleInspectorMode(flag, clickCallback) { + toggleInspectorMode(flag: boolean, clickCallback?: (args: any) => void) { if (typeof flag !== 'boolean') { const { inspectorMode } = getState(); flag = !inspectorMode; @@ -197,7 +204,7 @@ export default class Player extends MessageDistributor { this.setMarkedTargets(targets); } - activeTarget(index) { + activeTarget(index: number) { this.setActiveTarget(index); } @@ -219,7 +226,7 @@ export default class Player extends MessageDistributor { update({ autoplay }); } - toggleEvents(shouldShow = undefined) { + toggleEvents(shouldShow?: boolean) { const showEvents = shouldShow || !getState().showEvents; localStorage.setItem(SHOW_EVENTS_STORAGE_KEY, `${showEvents}`); update({ showEvents }); @@ -245,6 +252,20 @@ export default class Player extends MessageDistributor { this._updateSpeed(Math.max(1, speed/2)); } + toggleTimetravel() { + if (!getState().liveTimeTravel) { + this.reloadWithUnprocessedFile() + this.play() + } + } + + jumpToLive() { + cancelAnimationFrame(this._animationFrameRequestId); + this._setTime(getState().endTime); + this._startAnimation(); + update({ livePlay: true }); +} + clean() { this.pause(); super.clean(); diff --git a/frontend/app/player/singletone.js b/frontend/app/player/singletone.js index a0fe6ff26..81d6a6138 100644 --- a/frontend/app/player/singletone.js +++ b/frontend/app/player/singletone.js @@ -2,7 +2,7 @@ import Player from './Player'; import { update, clean as cleanStore, getState } from './store'; import { clean as cleanLists } from './lists'; - +/** @type {Player} */ let instance = null; const initCheck = method => (...args) => { @@ -69,11 +69,16 @@ 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)); +/** @type {Player.assistManager.call} */ export const callPeer = initCheck((...args) => instance.assistManager.call(...args)) +/** @type {Player.assistManager.setCallArgs} */ +export const setCallArgs = initCheck((...args) => instance.assistManager.setCallArgs(...args)) export const requestReleaseRemoteControl = initCheck((...args) => instance.assistManager.requestReleaseRemoteControl(...args)) export const markTargets = initCheck((...args) => instance.markTargets(...args)) export const activeTarget = initCheck((...args) => instance.activeTarget(...args)) export const toggleAnnotation = initCheck((...args) => instance.assistManager.toggleAnnotation(...args)) +export const toggleTimetravel = initCheck((...args) => instance.toggleTimetravel(...args)) +export const jumpToLive = initCheck((...args) => instance.jumpToLive(...args)) export const Controls = { jump, diff --git a/frontend/app/routes.js b/frontend/app/routes.js index 627095f86..90de53012 100644 --- a/frontend/app/routes.js +++ b/frontend/app/routes.js @@ -114,6 +114,10 @@ export const metricCreate = () => `/metrics/create`; export const metricDetails = (id = ':metricId', hash) => hashed(`/metrics/${ id }`, hash); export const metricDetailsSub = (id = ':metricId', subId = ':subId', hash) => hashed(`/metrics/${ id }/details/${subId}`, hash); +export const alerts = () => '/alerts'; +export const alertCreate = () => '/alert/create'; +export const alertEdit = (id = ':alertId', hash) => hashed(`/alert/${id}`, hash); + const REQUIRED_SITE_ID_ROUTES = [ liveSession(''), session(''), @@ -130,6 +134,10 @@ const REQUIRED_SITE_ID_ROUTES = [ dashboardMetricCreate(''), dashboardMetricDetails(''), + alerts(), + alertCreate(), + alertEdit(''), + error(''), errors(), onboarding(''), @@ -167,6 +175,7 @@ const SITE_CHANGE_AVALIABLE_ROUTES = [ dashboard(), dashboardSelected(), metrics(), + alerts(), errors(), onboarding('') ]; diff --git a/frontend/app/services/DashboardService.ts b/frontend/app/services/DashboardService.ts index b75a059fa..4c16c4e76 100644 --- a/frontend/app/services/DashboardService.ts +++ b/frontend/app/services/DashboardService.ts @@ -1,28 +1,8 @@ -import { IDashboard } from "App/mstore/types/dashboard"; +import Dashboard from "App/mstore/types/dashboard"; import APIClient from 'App/api_client'; -import { IWidget } from "App/mstore/types/widget"; +import Widget from "App/mstore/types/widget"; -export interface IDashboardService { - initClient(client?: APIClient) - getWidgets(dashboardId: string): Promise<any> - - getDashboards(): Promise<any[]> - getDashboard(dashboardId: string): Promise<any> - - saveDashboard(dashboard: IDashboard): Promise<any> - deleteDashboard(dashboardId: string): Promise<any> - - saveMetric(metric: IWidget, dashboardId?: string): Promise<any> - - addWidget(dashboard: IDashboard, metricIds: []): Promise<any> - saveWidget(dashboardId: string, widget: IWidget): Promise<any> - deleteWidget(dashboardId: string, widgetId: string): Promise<any> - - updatePinned(dashboardId: string): Promise<any> -} - - -export default class DashboardService implements IDashboardService { +export default class DashboardService { private client: APIClient; constructor(client?: APIClient) { @@ -71,7 +51,7 @@ export default class DashboardService implements IDashboardService { * @param dashboard Required * @returns {Promise<any>} */ - saveDashboard(dashboard: IDashboard): Promise<any> { + saveDashboard(dashboard: Dashboard): Promise<any> { const data = dashboard.toJson(); if (dashboard.dashboardId) { return this.client.put(`/dashboards/${dashboard.dashboardId}`, data) @@ -90,7 +70,7 @@ export default class DashboardService implements IDashboardService { * @param metricIds * @returns */ - addWidget(dashboard: IDashboard, metricIds: any): Promise<any> { + addWidget(dashboard: Dashboard, metricIds: any): Promise<any> { const data = dashboard.toJson() data.metrics = metricIds return this.client.put(`/dashboards/${dashboard.dashboardId}`, data) @@ -115,7 +95,7 @@ export default class DashboardService implements IDashboardService { * @param dashboardId Optional * @returns {Promise<any>} */ - saveMetric(metric: IWidget, dashboardId?: string): Promise<any> { + saveMetric(metric: Widget, dashboardId?: string): Promise<any> { const data = metric.toJson(); const path = dashboardId ? `/dashboards/${dashboardId}/metrics` : '/metrics'; if (metric.widgetId) { @@ -141,7 +121,7 @@ export default class DashboardService implements IDashboardService { * @param widget Required * @returns {Promise<any>} */ - saveWidget(dashboardId: string, widget: IWidget): Promise<any> { + saveWidget(dashboardId: string, widget: Widget): Promise<any> { if (widget.widgetId) { return this.client.put(`/dashboards/${dashboardId}/widgets/${widget.widgetId}`, widget.toWidget()) .then(response => response.json()) @@ -151,14 +131,4 @@ export default class DashboardService implements IDashboardService { .then(response => response.json()) .then(response => response.data || {}); } - - /** - * Update the pinned status of a dashboard. - * @param dashboardId - * @returns - */ - updatePinned(dashboardId: string): Promise<any> { - return this.client.get(`/dashboards/${dashboardId}/pin`, {}) - .then(response => response.json()) - } -} \ No newline at end of file +} diff --git a/frontend/app/services/FunnelService.ts b/frontend/app/services/FunnelService.ts index 676d7fb3f..7387c0aab 100644 --- a/frontend/app/services/FunnelService.ts +++ b/frontend/app/services/FunnelService.ts @@ -1,19 +1,7 @@ -import { IFunnel } from "App/mstore/types/funnel" +import IFunnel from "App/mstore/types/funnel" import APIClient from 'App/api_client'; -export interface IFunnelService { - initClient(client?: APIClient) - all(): Promise<any[]> - one(funnelId: string): Promise<any> - save(funnel: IFunnel): Promise<any> - delete(funnelId: string): Promise<any> - - fetchInsights(funnelId: string, payload: any): Promise<any> - fetchIssues(funnelId?: string, payload?: any): Promise<any> - fetchIssue(funnelId: string, issueId: string): Promise<any> -} - -export default class FunnelService implements IFunnelService { +export default class FunnelService { private client: APIClient; constructor(client?: APIClient) { @@ -62,4 +50,4 @@ export default class FunnelService implements IFunnelService { .then(response => response.json()) .then(response => response.data || {}); } -} \ No newline at end of file +} diff --git a/frontend/app/services/MetricService.ts b/frontend/app/services/MetricService.ts index 991418c62..a5734aff0 100644 --- a/frontend/app/services/MetricService.ts +++ b/frontend/app/services/MetricService.ts @@ -1,23 +1,8 @@ -import Widget, { IWidget } from "App/mstore/types/widget"; +import Widget from "App/mstore/types/widget"; import APIClient from 'App/api_client'; -import { IFilter } from "App/mstore/types/filter"; import { fetchErrorCheck } from "App/utils"; -export interface IMetricService { - initClient(client?: APIClient): void; - - getMetrics(): Promise<any>; - getMetric(metricId: string): Promise<any>; - saveMetric(metric: IWidget, dashboardId?: string): Promise<any>; - deleteMetric(metricId: string): Promise<any>; - - getTemplates(): Promise<any>; - getMetricChartData(metric: IWidget, data: any, isWidget: boolean): Promise<any>; - fetchSessions(metricId: string, filter: any): Promise<any> - fetchIssues(filter: string): Promise<any>; -} - -export default class MetricService implements IMetricService { +export default class MetricService { private client: APIClient; constructor(client?: APIClient) { @@ -54,7 +39,7 @@ export default class MetricService implements IMetricService { * @param metric * @returns */ - saveMetric(metric: IWidget, dashboardId?: string): Promise<any> { + saveMetric(metric: Widget, dashboardId?: string): Promise<any> { const data = metric.toJson() const isCreating = !data[Widget.ID_KEY]; const method = isCreating ? 'post' : 'put'; @@ -86,7 +71,7 @@ export default class MetricService implements IMetricService { .then((response: { data: any; }) => response.data || []); } - getMetricChartData(metric: IWidget, data: any, isWidget: boolean = false): Promise<any> { + getMetricChartData(metric: Widget, data: any, isWidget: boolean = false): Promise<any> { const path = isWidget ? `/metrics/${metric.metricId}/chart` : `/metrics/try`; return this.client.post(path, data) .then(fetchErrorCheck) @@ -115,4 +100,4 @@ export default class MetricService implements IMetricService { .then((response: { json: () => any; }) => response.json()) .then((response: { data: any; }) => response.data || {}); } -} \ No newline at end of file +} diff --git a/frontend/app/services/index.ts b/frontend/app/services/index.ts index 78328b6ff..add371643 100644 --- a/frontend/app/services/index.ts +++ b/frontend/app/services/index.ts @@ -6,10 +6,10 @@ import UserService from "./UserService"; import AuditService from './AuditService'; import ErrorService from "./ErrorService"; -export const dashboardService: IDashboardService = new DashboardService(); -export const metricService: IMetricService = new MetricService(); -export const sessionService: SessionSerivce = new SessionSerivce(); -export const userService: UserService = new UserService(); -export const funnelService: IFunnelService = new FunnelService(); -export const auditService: AuditService = new AuditService(); -export const errorService: ErrorService = new ErrorService(); +export const dashboardService = new DashboardService(); +export const metricService = new MetricService(); +export const sessionService = new SessionSerivce(); +export const userService = new UserService(); +export const funnelService = new FunnelService(); +export const auditService = new AuditService(); +export const errorService = new ErrorService(); diff --git a/frontend/app/styles/colors-autogen.css b/frontend/app/styles/colors-autogen.css index d1fd5a0a9..42ae94dab 100644 --- a/frontend/app/styles/colors-autogen.css +++ b/frontend/app/styles/colors-autogen.css @@ -35,6 +35,7 @@ .fill-light-blue-bg { fill: $light-blue-bg } .fill-white { fill: $white } .fill-borderColor { fill: $borderColor } +.fill-figmaColors { fill: $figmaColors } /* color */ .color-main { color: $main } @@ -71,6 +72,7 @@ .color-light-blue-bg { color: $light-blue-bg } .color-white { color: $white } .color-borderColor { color: $borderColor } +.color-figmaColors { color: $figmaColors } /* hover color */ .hover-main:hover { color: $main } @@ -107,6 +109,7 @@ .hover-light-blue-bg:hover { color: $light-blue-bg } .hover-white:hover { color: $white } .hover-borderColor:hover { color: $borderColor } +.hover-figmaColors:hover { color: $figmaColors } .border-main { border-color: $main } .border-gray-light-shade { border-color: $gray-light-shade } @@ -142,3 +145,4 @@ .border-light-blue-bg { border-color: $light-blue-bg } .border-white { border-color: $white } .border-borderColor { border-color: $borderColor } +.border-figmaColors { border-color: $figmaColors } diff --git a/frontend/app/styles/general.css b/frontend/app/styles/general.css index 180abc3b0..e5b7731b1 100644 --- a/frontend/app/styles/general.css +++ b/frontend/app/styles/general.css @@ -143,6 +143,11 @@ font-size: 14px; } +.placeholder-lg::placeholder { + color: $gray-medium !important; + font-size: 16px; +} + .ui[class*="top fixed"].menu { background-color: white !important; border-bottom: solid thin #ddd !important; @@ -254,7 +259,7 @@ p { } .link { - color: $blue !important; + color: $teal !important; cursor: pointer; &:hover { text-decoration: underline !important; @@ -273,6 +278,13 @@ p { .tippy-tooltip.openreplay-theme .tippy-backdrop { background-color: $tealx; } +.tippy-tooltip[data-theme~='nopadding'], .nopadding-theme { + padding: 0!important; + transition: none!important; +} +.tippy-notransition { + transition: none!important; +} @media print { .no-print { @@ -297,4 +309,4 @@ p { .recharts-legend-item-text { white-space: nowrap !important; -} \ No newline at end of file +} diff --git a/frontend/app/styles/global.scss b/frontend/app/styles/global.scss index 040831c9e..014cfce5f 100644 --- a/frontend/app/styles/global.scss +++ b/frontend/app/styles/global.scss @@ -11,4 +11,8 @@ input.no-focus:focus { outline: none !important; border: solid thin transparent !important; +} + +.widget-wrapper { + @apply rounded border bg-white; } \ No newline at end of file diff --git a/frontend/app/styles/react-daterange-picker.css b/frontend/app/styles/react-daterange-picker.css index cc4b18fac..389b81870 100644 --- a/frontend/app/styles/react-daterange-picker.css +++ b/frontend/app/styles/react-daterange-picker.css @@ -5,4 +5,9 @@ .DateRangePicker__CalendarSelection { background-color: $teal !important; border-color: $teal !important; +} + + +.DateRangePicker__Date .DateRangePicker__CalendarHighlight--single { + border-color: $teal !important; } \ No newline at end of file diff --git a/frontend/app/svg/ca-no-alerts.svg b/frontend/app/svg/ca-no-alerts.svg new file mode 100644 index 000000000..998ba4e05 --- /dev/null +++ b/frontend/app/svg/ca-no-alerts.svg @@ -0,0 +1,4 @@ +<svg width="100" height="100" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="100" height="100" rx="13.1579" fill="#3EAAAF" fill-opacity="0.08"/> +<path d="M39.7195 69.75H75.375C74.2911 69.0286 73.3559 67.7454 72.5475 66.125C69.285 59.6 68.125 47.565 68.125 44.375C68.125 43.418 68.0525 42.4791 67.9075 41.562L64.5072 44.9623C64.5797 47.5288 65.0764 52.7705 66.1639 57.9398C66.7475 60.7201 67.5305 63.6165 68.5672 66.125H43.3445L39.7195 69.75ZM59.9506 33.8263C57.9857 31.9692 55.5391 30.7008 52.8891 30.165L50 29.5741L47.1109 30.1578C43.8331 30.8256 40.8869 32.6057 38.771 35.1966C36.6551 37.7874 35.4995 41.0299 35.5 44.375C35.5 46.6515 35.0143 52.3391 33.8361 57.9398C33.6549 58.8025 33.4555 59.6761 33.2344 60.5425L27.2423 66.5346C27.3148 66.4005 27.3873 66.2664 27.4561 66.125C30.7114 59.6 31.875 47.565 31.875 44.375C31.875 35.6025 38.11 28.28 46.3931 26.6089C46.3425 26.1049 46.3981 25.5958 46.5564 25.1146C46.7146 24.6333 46.9719 24.1906 47.3118 23.815C47.6516 23.4393 48.0664 23.139 48.5295 22.9335C48.9925 22.728 49.4934 22.6219 50 22.6219C50.5066 22.6219 51.0075 22.728 51.4705 22.9335C51.9336 23.139 52.3484 23.4393 52.6882 23.815C53.0281 24.1906 53.2854 24.6333 53.4436 25.1146C53.6019 25.5958 53.6575 26.1049 53.6069 26.6089C57.0216 27.2976 60.0884 28.947 62.5135 31.2634L59.9506 33.8263V33.8263ZM57.25 73.375C57.25 75.2978 56.4862 77.1419 55.1265 78.5015C53.7669 79.8612 51.9228 80.625 50 80.625C48.0772 80.625 46.2331 79.8612 44.8735 78.5015C43.5138 77.1419 42.75 75.2978 42.75 73.375H57.25ZM23.2656 75.6406C22.9051 76.0012 22.7026 76.4902 22.7026 77C22.7026 77.5099 22.9051 77.9989 23.2656 78.3594C23.6262 78.7199 24.1151 78.9225 24.625 78.9225C25.1349 78.9225 25.6238 78.7199 25.9844 78.3594L79.4531 24.8906C79.6316 24.7121 79.7732 24.5002 79.8699 24.267C79.9665 24.0337 80.0162 23.7837 80.0162 23.5313C80.0162 23.2788 79.9665 23.0288 79.8699 22.7956C79.7732 22.5623 79.6316 22.3504 79.4531 22.1719C79.2746 21.9934 79.0627 21.8518 78.8294 21.7552C78.5962 21.6586 78.3462 21.6088 78.0937 21.6088C77.8413 21.6088 77.5913 21.6586 77.3581 21.7552C77.1248 21.8518 76.9129 21.9934 76.7344 22.1719L23.2656 75.6406Z" fill="#3EAAAF" fill-opacity="0.5"/> +</svg> diff --git a/frontend/app/svg/ca-no-announcements.svg b/frontend/app/svg/ca-no-announcements.svg new file mode 100644 index 000000000..5666c98a8 --- /dev/null +++ b/frontend/app/svg/ca-no-announcements.svg @@ -0,0 +1,11 @@ +<svg width="100" height="100" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="100" height="100" rx="13.1579" fill="#3EAAAF" fill-opacity="0.08"/> +<g clip-path="url(#clip0_25_22)"> +<path d="M66.25 32.125C66.25 30.8321 66.7636 29.5921 67.6779 28.6779C68.5921 27.7636 69.8321 27.25 71.125 27.25C72.4179 27.25 73.6579 27.7636 74.5721 28.6779C75.4864 29.5921 76 30.8321 76 32.125V67.875C76 69.1679 75.4864 70.4079 74.5721 71.3221C73.6579 72.2364 72.4179 72.75 71.125 72.75C69.8321 72.75 68.5921 72.2364 67.6779 71.3221C66.7636 70.4079 66.25 69.1679 66.25 67.875V32.125ZM63 34.478C56.2823 37.5655 48.2482 39.2912 40.25 39.86V60.1302C41.4305 60.1968 42.6095 60.2889 43.786 60.4065C50.4582 61.0695 56.9095 62.5775 63 65.4765V34.478ZM37 59.9612V40.0355C34.79 40.1232 32.4533 40.1752 30.474 40.2077C28.7526 40.2316 27.1095 40.9307 25.8985 42.1544C24.6876 43.3781 24.0058 45.0284 24 46.75V53.25C24 56.8445 26.912 59.737 30.4805 59.7792C31.0179 59.786 31.5552 59.7946 32.0925 59.8052C33.7289 59.8379 35.3648 59.8899 37 59.9612V59.9612ZM41.5175 63.4745C42.4437 63.5427 43.3667 63.6272 44.28 63.728L45.1023 69.2205C45.1917 69.6905 45.1763 70.1745 45.0571 70.6379C44.9379 71.1013 44.7179 71.5326 44.4127 71.9012C44.1075 72.2697 43.7247 72.5663 43.2917 72.7697C42.8586 72.9732 42.386 73.0786 41.9075 73.0782H40.1265C39.4948 73.0782 38.8768 72.894 38.348 72.5483C37.8193 72.2026 37.4028 71.7102 37.1495 71.1315L32.879 63.0715C34.7128 63.1172 36.546 63.1866 38.378 63.2795C39.4407 63.3347 40.4905 63.3997 41.5175 63.4745V63.4745Z" fill="#3EAAAF" fill-opacity="0.5"/> +</g> +<defs> +<clipPath id="clip0_25_22"> +<rect width="52" height="52" fill="white" transform="translate(24 24)"/> +</clipPath> +</defs> +</svg> diff --git a/frontend/app/svg/ca-no-audit-trail.svg b/frontend/app/svg/ca-no-audit-trail.svg new file mode 100644 index 000000000..52a701f25 --- /dev/null +++ b/frontend/app/svg/ca-no-audit-trail.svg @@ -0,0 +1,4 @@ +<svg width="100" height="100" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="100" height="100" rx="13.1579" fill="#3EAAAF" fill-opacity="0.08"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M38.8125 61.9688C38.8125 61.4963 39.0002 61.0433 39.3342 60.7092C39.6683 60.3752 40.1213 60.1875 40.5938 60.1875H72.6562C73.1287 60.1875 73.5817 60.3752 73.9158 60.7092C74.2498 61.0433 74.4375 61.4963 74.4375 61.9688C74.4375 62.4412 74.2498 62.8942 73.9158 63.2283C73.5817 63.5623 73.1287 63.75 72.6562 63.75H40.5938C40.1213 63.75 39.6683 63.5623 39.3342 63.2283C39.0002 62.8942 38.8125 62.4412 38.8125 61.9688ZM38.8125 47.7188C38.8125 47.2463 39.0002 46.7933 39.3342 46.4592C39.6683 46.1252 40.1213 45.9375 40.5938 45.9375H72.6562C73.1287 45.9375 73.5817 46.1252 73.9158 46.4592C74.2498 46.7933 74.4375 47.2463 74.4375 47.7188C74.4375 48.1912 74.2498 48.6442 73.9158 48.9783C73.5817 49.3123 73.1287 49.5 72.6562 49.5H40.5938C40.1213 49.5 39.6683 49.3123 39.3342 48.9783C39.0002 48.6442 38.8125 48.1912 38.8125 47.7188ZM38.8125 33.4688C38.8125 32.9963 39.0002 32.5433 39.3342 32.2092C39.6683 31.8752 40.1213 31.6875 40.5938 31.6875H72.6562C73.1287 31.6875 73.5817 31.8752 73.9158 32.2092C74.2498 32.5433 74.4375 32.9963 74.4375 33.4688C74.4375 33.9412 74.2498 34.3942 73.9158 34.7283C73.5817 35.0623 73.1287 35.25 72.6562 35.25H40.5938C40.1213 35.25 39.6683 35.0623 39.3342 34.7283C39.0002 34.3942 38.8125 33.9412 38.8125 33.4688ZM28.125 37.0312C29.0698 37.0312 29.976 36.6559 30.6441 35.9878C31.3122 35.3197 31.6875 34.4136 31.6875 33.4688C31.6875 32.5239 31.3122 31.6178 30.6441 30.9497C29.976 30.2816 29.0698 29.9063 28.125 29.9062C27.1802 29.9063 26.274 30.2816 25.6059 30.9497C24.9378 31.6178 24.5625 32.5239 24.5625 33.4688C24.5625 34.4136 24.9378 35.3197 25.6059 35.9878C26.274 36.6559 27.1802 37.0312 28.125 37.0312V37.0312ZM28.125 51.2812C29.0698 51.2812 29.976 50.9059 30.6441 50.2378C31.3122 49.5697 31.6875 48.6636 31.6875 47.7188C31.6875 46.7739 31.3122 45.8678 30.6441 45.1997C29.976 44.5316 29.0698 44.1562 28.125 44.1562C27.1802 44.1562 26.274 44.5316 25.6059 45.1997C24.9378 45.8678 24.5625 46.7739 24.5625 47.7188C24.5625 48.6636 24.9378 49.5697 25.6059 50.2378C26.274 50.9059 27.1802 51.2812 28.125 51.2812V51.2812ZM28.125 65.5312C29.0698 65.5312 29.976 65.1559 30.6441 64.4878C31.3122 63.8197 31.6875 62.9136 31.6875 61.9688C31.6875 61.0239 31.3122 60.1178 30.6441 59.4497C29.976 58.7816 29.0698 58.4062 28.125 58.4062C27.1802 58.4062 26.274 58.7816 25.6059 59.4497C24.9378 60.1178 24.5625 61.0239 24.5625 61.9688C24.5625 62.9136 24.9378 63.8197 25.6059 64.4878C26.274 65.1559 27.1802 65.5312 28.125 65.5312V65.5312Z" fill="#3EAAAF" fill-opacity="0.5"/> +</svg> diff --git a/frontend/app/svg/ca-no-bookmarked-session.svg b/frontend/app/svg/ca-no-bookmarked-session.svg new file mode 100644 index 000000000..f3d84d404 --- /dev/null +++ b/frontend/app/svg/ca-no-bookmarked-session.svg @@ -0,0 +1,14 @@ +<svg viewBox="0 0 250 100" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="250" height="100" rx="13.1579" fill="#3EAAAF" fill-opacity="0.08"/> +<rect opacity="0.6" x="85.8947" y="28.579" width="138.158" height="14.4737" rx="6.57895" fill="#3EAAAF" fill-opacity="0.5"/> +<rect opacity="0.3" x="85.8947" y="54.8948" width="46.0526" height="14.4737" rx="6.57895" fill="#3EAAAF" fill-opacity="0.5"/> +<g clip-path="url(#clip0_2_27)"> +<path d="M52.5 58C55.8152 58 58.9946 56.683 61.3388 54.3388C63.683 51.9946 65 48.8152 65 45.5C65 42.1848 63.683 39.0054 61.3388 36.6612C58.9946 34.317 55.8152 33 52.5 33C49.1848 33 46.0054 34.317 43.6612 36.6612C41.317 39.0054 40 42.1848 40 45.5C40 48.8152 41.317 51.9946 43.6612 54.3388C46.0054 56.683 49.1848 58 52.5 58V58ZM50.9375 43.1562C50.9375 44.45 50.2375 45.5 49.375 45.5C48.5125 45.5 47.8125 44.45 47.8125 43.1562C47.8125 41.8625 48.5125 40.8125 49.375 40.8125C50.2375 40.8125 50.9375 41.8625 50.9375 43.1562ZM46.6953 52.4266C46.5159 52.323 46.385 52.1523 46.3313 51.9522C46.2777 51.7521 46.3058 51.5388 46.4094 51.3594C47.0264 50.2901 47.9141 49.4022 48.9833 48.785C50.0526 48.1678 51.2655 47.8432 52.5 47.8438C53.7345 47.8435 54.9473 48.1683 56.0164 48.7854C57.0856 49.4025 57.9734 50.2903 58.5906 51.3594C58.6926 51.5387 58.7195 51.7511 58.6654 51.9502C58.6114 52.1493 58.4808 52.3189 58.3021 52.4221C58.1234 52.5252 57.9112 52.5535 57.7118 52.5008C57.5123 52.4481 57.3418 52.3186 57.2375 52.1406C56.7576 51.3088 56.0671 50.6182 55.2354 50.1381C54.4037 49.6581 53.4603 49.4057 52.5 49.4062C51.5397 49.4057 50.5963 49.6581 49.7646 50.1381C48.9329 50.6182 48.2424 51.3088 47.7625 52.1406C47.6589 52.3201 47.4883 52.451 47.2881 52.5046C47.088 52.5582 46.8747 52.5302 46.6953 52.4266ZM55.625 45.5C54.7625 45.5 54.0625 44.45 54.0625 43.1562C54.0625 41.8625 54.7625 40.8125 55.625 40.8125C56.4875 40.8125 57.1875 41.8625 57.1875 43.1562C57.1875 44.45 56.4875 45.5 55.625 45.5Z" fill="#3EAAAF" fill-opacity="0.5"/> +</g> +<path d="M31.875 28.875C31.875 27.0516 32.5993 25.303 33.8886 24.0136C35.178 22.7243 36.9266 22 38.75 22H66.25C68.0734 22 69.822 22.7243 71.1114 24.0136C72.4007 25.303 73.125 27.0516 73.125 28.875V75.2812C73.1248 75.5921 73.0404 75.8972 72.8805 76.1639C72.7207 76.4305 72.4916 76.6489 72.2175 76.7956C71.9434 76.9424 71.6347 77.012 71.3241 76.9971C71.0136 76.9823 70.7129 76.8835 70.4541 76.7113L52.5 67.0347L34.5459 76.7113C34.2871 76.8835 33.9864 76.9823 33.6759 76.9971C33.3653 77.012 33.0566 76.9424 32.7825 76.7956C32.5084 76.6489 32.2793 76.4305 32.1195 76.1639C31.9596 75.8972 31.8752 75.5921 31.875 75.2812V28.875ZM38.75 25.4375C37.8383 25.4375 36.964 25.7997 36.3193 26.4443C35.6747 27.089 35.3125 27.9633 35.3125 28.875V72.0706L51.5478 63.5387C51.8299 63.351 52.1612 63.2509 52.5 63.2509C52.8388 63.2509 53.1701 63.351 53.4522 63.5387L69.6875 72.0706V28.875C69.6875 27.9633 69.3253 27.089 68.6807 26.4443C68.036 25.7997 67.1617 25.4375 66.25 25.4375H38.75Z" fill="#3EAAAF" fill-opacity="0.5"/> +<defs> +<clipPath id="clip0_2_27"> +<rect width="25" height="25" fill="white" transform="translate(40 33)"/> +</clipPath> +</defs> +</svg> diff --git a/frontend/app/svg/ca-no-issues.svg b/frontend/app/svg/ca-no-issues.svg new file mode 100644 index 000000000..5be1cbd22 --- /dev/null +++ b/frontend/app/svg/ca-no-issues.svg @@ -0,0 +1,7 @@ +<svg viewBox="0 0 250 100" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="250" height="100" rx="13.1579" fill="#3EAAAF" fill-opacity="0.08"/> +<rect opacity="0.6" x="86.8421" y="28.579" width="138.158" height="14.4737" rx="6.57895" fill="#3EAAAF" fill-opacity="0.5"/> +<rect opacity="0.3" x="86.8421" y="55.579" width="38.1579" height="14.4737" rx="6.57895" fill="#3EAAAF" fill-opacity="0.5"/> +<rect opacity="0.3" x="129.842" y="55.579" width="18.1579" height="14.4737" rx="6.57895" fill="#3EAAAF" fill-opacity="0.5"/> +<path d="M73 50C73 57.1608 70.1554 64.0284 65.0919 69.0919C60.0284 74.1554 53.1608 77 46 77C38.8392 77 31.9716 74.1554 26.9081 69.0919C21.8446 64.0284 19 57.1608 19 50C19 42.8392 21.8446 35.9716 26.9081 30.9081C31.9716 25.8446 38.8392 23 46 23C53.1608 23 60.0284 25.8446 65.0919 30.9081C70.1554 35.9716 73 42.8392 73 50ZM46 36.5C45.5734 36.5002 45.1516 36.5898 44.7618 36.763C44.3719 36.9362 44.0227 37.1891 43.7365 37.5055C43.4504 37.8218 43.2337 38.1946 43.1004 38.5998C42.967 39.005 42.9201 39.4337 42.9625 39.8581L44.1437 51.6943C44.1834 52.1592 44.3962 52.5924 44.7399 52.908C45.0837 53.2237 45.5333 53.3988 46 53.3988C46.4667 53.3988 46.9163 53.2237 47.2601 52.908C47.6038 52.5924 47.8166 52.1592 47.8563 51.6943L49.0375 39.8581C49.0799 39.4337 49.033 39.005 48.8996 38.5998C48.7663 38.1946 48.5496 37.8218 48.2635 37.5055C47.9773 37.1891 47.6281 36.9362 47.2382 36.763C46.8484 36.5898 46.4266 36.5002 46 36.5ZM46.0068 56.75C45.1116 56.75 44.2532 57.1056 43.6203 57.7385C42.9873 58.3715 42.6318 59.2299 42.6318 60.125C42.6318 61.0201 42.9873 61.8785 43.6203 62.5115C44.2532 63.1444 45.1116 63.5 46.0068 63.5C46.9019 63.5 47.7603 63.1444 48.3932 62.5115C49.0262 61.8785 49.3818 61.0201 49.3818 60.125C49.3818 59.2299 49.0262 58.3715 48.3932 57.7385C47.7603 57.1056 46.9019 56.75 46.0068 56.75Z" fill="#3EAAAF" fill-opacity="0.5"/> +</svg> diff --git a/frontend/app/svg/ca-no-live-sessions.svg b/frontend/app/svg/ca-no-live-sessions.svg new file mode 100644 index 000000000..979060448 --- /dev/null +++ b/frontend/app/svg/ca-no-live-sessions.svg @@ -0,0 +1,15 @@ +<svg viewBox="0 0 250 100" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="250" height="100" rx="13.1579" fill="#3EAAAF" fill-opacity="0.08"/> +<rect opacity="0.6" x="86.8421" y="28.579" width="138.158" height="14.4737" rx="6.57895" fill="#3EAAAF" fill-opacity="0.5"/> +<rect opacity="0.3" x="86.8421" y="54.8948" width="46.0526" height="14.4737" rx="6.57895" fill="#3EAAAF" fill-opacity="0.5"/> +<g clip-path="url(#clip0_2_44)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M71.047 68.047C65.9884 73.1055 59.1276 75.9474 51.9737 75.9474C44.8198 75.9474 37.959 73.1055 32.9004 68.047C27.8419 62.9884 25 56.1276 25 48.9737C25 41.8198 27.8419 34.959 32.9004 29.9004C37.959 24.8419 44.8198 22 51.9737 22C57.1003 22 62.0765 23.4594 66.3467 26.1483C65.4733 27.1904 64.9474 28.5338 64.9474 30C64.9474 33.3137 67.6336 36 70.9474 36C72.4136 36 73.7569 35.4741 74.7991 34.6006C77.4879 38.8709 78.9474 43.847 78.9474 48.9737C78.9474 56.1276 76.1055 62.9884 71.047 68.047ZM45.2303 48.9737C47.0914 48.9737 48.602 46.7079 48.602 43.9161C48.602 41.1243 47.0914 38.8586 45.2303 38.8586C43.3691 38.8586 41.8586 41.1243 41.8586 43.9161C41.8586 46.7079 43.3691 48.9737 45.2303 48.9737ZM38.6623 62.8968C38.7781 63.3287 39.0606 63.6969 39.4478 63.9205C39.835 64.144 40.2951 64.2046 40.727 64.0889C41.1589 63.9732 41.5271 63.6907 41.7507 63.3035C42.7861 61.5086 44.2762 60.0182 46.0709 58.9823C47.8656 57.9465 49.9015 57.4017 51.9737 57.403C54.0458 57.4017 56.0818 57.9465 57.8764 58.9823C59.6711 60.0182 61.1612 61.5086 62.1967 63.3035C62.4219 63.6875 62.7898 63.9669 63.2202 64.0807C63.6506 64.1945 64.1085 64.1334 64.494 63.9108C64.8796 63.6882 65.1614 63.3222 65.278 62.8926C65.3947 62.4629 65.3366 62.0046 65.1166 61.6176C63.7847 59.3106 61.8688 57.395 59.5617 56.0633C57.2546 54.7315 54.6376 54.0307 51.9737 54.0312C49.3097 54.03 46.6924 54.7306 44.3851 56.0624C42.0779 57.3941 40.1622 59.3102 38.8308 61.6176C38.6072 62.0048 38.5466 62.465 38.6623 62.8968ZM55.3454 43.9161C55.3454 46.7079 56.8559 48.9737 58.7171 48.9737C60.5783 48.9737 62.0888 46.7079 62.0888 43.9161C62.0888 41.1243 60.5783 38.8586 58.7171 38.8586C56.8559 38.8586 55.3454 41.1243 55.3454 43.9161Z" fill="#3EAAAF" fill-opacity="0.5"/> +</g> +<circle opacity="0.7" cx="70.9474" cy="30" r="5" fill="#3EAAAF" fill-opacity="0.5"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M145.872 57.1005C145.617 56.9984 145.337 56.9734 145.068 57.0286C144.799 57.0838 144.552 57.2167 144.358 57.4109C144.164 57.6051 144.031 57.852 143.976 58.121C143.921 58.39 143.946 58.6693 144.048 58.9242L149.659 72.9529C149.76 73.204 149.931 73.4207 150.152 73.5769C150.372 73.733 150.634 73.822 150.904 73.8331C151.174 73.8443 151.442 73.777 151.675 73.6395C151.908 73.502 152.096 73.3001 152.217 73.0582L154.153 69.1876L158.387 73.4243C158.65 73.6874 159.007 73.8351 159.379 73.8349C159.751 73.8348 160.108 73.6869 160.371 73.4236C160.634 73.1604 160.782 72.8034 160.782 72.4313C160.781 72.0591 160.633 71.7023 160.37 71.4392L156.135 67.2026L160.007 65.268C160.248 65.1469 160.45 64.9586 160.587 64.7258C160.724 64.4929 160.791 64.2255 160.78 63.9555C160.768 63.6856 160.679 63.4246 160.523 63.204C160.367 62.9834 160.151 62.8126 159.9 62.712L145.872 57.1005Z" fill="#3EAAAF" fill-opacity="0.5"/> +<defs> +<clipPath id="clip0_2_44"> +<rect width="53.9474" height="53.9474" fill="white" transform="translate(25 22)"/> +</clipPath> +</defs> +</svg> diff --git a/frontend/app/svg/ca-no-metadata.svg b/frontend/app/svg/ca-no-metadata.svg new file mode 100644 index 000000000..b5a99b3ed --- /dev/null +++ b/frontend/app/svg/ca-no-metadata.svg @@ -0,0 +1,4 @@ +<svg viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="100" height="100" rx="13.1579" fill="#3EAAAF" fill-opacity="0.08"/> +<path d="M42.5 50C45.4837 50 48.3452 48.8147 50.455 46.705C52.5647 44.5952 53.75 41.7337 53.75 38.75C53.75 35.7663 52.5647 32.9048 50.455 30.795C48.3452 28.6853 45.4837 27.5 42.5 27.5C39.5163 27.5 36.6548 28.6853 34.545 30.795C32.4353 32.9048 31.25 35.7663 31.25 38.75C31.25 41.7337 32.4353 44.5952 34.545 46.705C36.6548 48.8147 39.5163 50 42.5 50ZM23.75 72.5C23.75 72.5 20 72.5 20 68.75C20 65 23.75 53.75 42.5 53.75C61.25 53.75 65 65 65 68.75C65 72.5 61.25 72.5 61.25 72.5H23.75ZM61.25 33.125C61.25 32.6277 61.4475 32.1508 61.7992 31.7992C62.1508 31.4475 62.6277 31.25 63.125 31.25H78.125C78.6223 31.25 79.0992 31.4475 79.4508 31.7992C79.8025 32.1508 80 32.6277 80 33.125C80 33.6223 79.8025 34.0992 79.4508 34.4508C79.0992 34.8025 78.6223 35 78.125 35H63.125C62.6277 35 62.1508 34.8025 61.7992 34.4508C61.4475 34.0992 61.25 33.6223 61.25 33.125ZM63.125 42.5C62.6277 42.5 62.1508 42.6975 61.7992 43.0492C61.4475 43.4008 61.25 43.8777 61.25 44.375C61.25 44.8723 61.4475 45.3492 61.7992 45.7008C62.1508 46.0525 62.6277 46.25 63.125 46.25H78.125C78.6223 46.25 79.0992 46.0525 79.4508 45.7008C79.8025 45.3492 80 44.8723 80 44.375C80 43.8777 79.8025 43.4008 79.4508 43.0492C79.0992 42.6975 78.6223 42.5 78.125 42.5H63.125ZM70.625 53.75C70.1277 53.75 69.6508 53.9475 69.2992 54.2992C68.9475 54.6508 68.75 55.1277 68.75 55.625C68.75 56.1223 68.9475 56.5992 69.2992 56.9508C69.6508 57.3025 70.1277 57.5 70.625 57.5H78.125C78.6223 57.5 79.0992 57.3025 79.4508 56.9508C79.8025 56.5992 80 56.1223 80 55.625C80 55.1277 79.8025 54.6508 79.4508 54.2992C79.0992 53.9475 78.6223 53.75 78.125 53.75H70.625ZM70.625 65C70.1277 65 69.6508 65.1975 69.2992 65.5492C68.9475 65.9008 68.75 66.3777 68.75 66.875C68.75 67.3723 68.9475 67.8492 69.2992 68.2008C69.6508 68.5525 70.1277 68.75 70.625 68.75H78.125C78.6223 68.75 79.0992 68.5525 79.4508 68.2008C79.8025 67.8492 80 67.3723 80 66.875C80 66.3777 79.8025 65.9008 79.4508 65.5492C79.0992 65.1975 78.6223 65 78.125 65H70.625Z" fill="#3EAAAF" fill-opacity="0.5"/> +</svg> diff --git a/frontend/app/svg/ca-no-sessions-in-vault.svg b/frontend/app/svg/ca-no-sessions-in-vault.svg new file mode 100644 index 000000000..ca9bbc9c7 --- /dev/null +++ b/frontend/app/svg/ca-no-sessions-in-vault.svg @@ -0,0 +1,14 @@ +<svg viewBox="0 0 250 100" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="250" height="100" rx="13.1579" fill="#3EAAAF" fill-opacity="0.08"/> +<rect opacity="0.6" x="86.8947" y="29.579" width="138.158" height="14.4737" rx="6.57895" fill="#3EAAAF" fill-opacity="0.5"/> +<rect opacity="0.3" x="86.8947" y="55.8948" width="46.0526" height="14.4737" rx="6.57895" fill="#3EAAAF" fill-opacity="0.5"/> +<g clip-path="url(#clip0_2_20)"> +<path d="M54 67C58.5087 67 62.8327 65.2089 66.0208 62.0208C69.2089 58.8327 71 54.5087 71 50C71 45.4913 69.2089 41.1673 66.0208 37.9792C62.8327 34.7911 58.5087 33 54 33C49.4913 33 45.1673 34.7911 41.9792 37.9792C38.7911 41.1673 37 45.4913 37 50C37 54.5087 38.7911 58.8327 41.9792 62.0208C45.1673 65.2089 49.4913 67 54 67V67ZM51.875 46.8125C51.875 48.572 50.923 50 49.75 50C48.577 50 47.625 48.572 47.625 46.8125C47.625 45.053 48.577 43.625 49.75 43.625C50.923 43.625 51.875 45.053 51.875 46.8125ZM46.1056 59.4201C45.8616 59.2792 45.6835 59.0472 45.6106 58.775C45.5377 58.5028 45.5759 58.2128 45.7168 57.9688C46.5559 56.5145 47.7632 55.3069 49.2174 54.4676C50.6715 53.6282 52.321 53.1867 54 53.1875C55.6789 53.1872 57.3283 53.6289 58.7823 54.4682C60.2364 55.3075 61.4438 56.5148 62.2832 57.9688C62.4219 58.2127 62.4585 58.5015 62.385 58.7723C62.3115 59.0431 62.1338 59.2738 61.8909 59.414C61.6479 59.5543 61.3593 59.5928 61.088 59.5211C60.8168 59.4494 60.5849 59.2733 60.443 59.0312C59.7904 57.9 58.8513 56.9607 57.7202 56.3079C56.5891 55.655 55.306 55.3117 54 55.3125C52.694 55.3117 51.4109 55.655 50.2798 56.3079C49.1487 56.9607 48.2096 57.9 47.557 59.0312C47.4161 59.2753 47.184 59.4533 46.9118 59.5263C46.6397 59.5992 46.3497 59.561 46.1056 59.4201ZM58.25 50C57.077 50 56.125 48.572 56.125 46.8125C56.125 45.053 57.077 43.625 58.25 43.625C59.423 43.625 60.375 45.053 60.375 46.8125C60.375 48.572 59.423 50 58.25 50Z" fill="#3EAAAF" fill-opacity="0.5"/> +</g> +<path d="M28.375 28.0625C28.375 26.7198 28.9084 25.4322 29.8578 24.4828C30.8072 23.5334 32.0948 23 33.4375 23H73.9375C75.2802 23 76.5678 23.5334 77.5172 24.4828C78.4666 25.4322 79 26.7198 79 28.0625V71.9375C79 73.2802 78.4666 74.5678 77.5172 75.5172C76.5678 76.4666 75.2802 77 73.9375 77H33.4375C32.0948 77 30.8072 76.4666 29.8578 75.5172C28.9084 74.5678 28.375 73.2802 28.375 71.9375V66.875H26.6875C26.2399 66.875 25.8107 66.6972 25.4943 66.3807C25.1778 66.0643 25 65.6351 25 65.1875C25 64.7399 25.1778 64.3107 25.4943 63.9943C25.8107 63.6778 26.2399 63.5 26.6875 63.5H28.375V51.6875H26.6875C26.2399 51.6875 25.8107 51.5097 25.4943 51.1932C25.1778 50.8768 25 50.4476 25 50C25 49.5524 25.1778 49.1232 25.4943 48.8068C25.8107 48.4903 26.2399 48.3125 26.6875 48.3125H28.375V36.5H26.6875C26.2399 36.5 25.8107 36.3222 25.4943 36.0057C25.1778 35.6893 25 35.2601 25 34.8125C25 34.3649 25.1778 33.9357 25.4943 33.6193C25.8107 33.3028 26.2399 33.125 26.6875 33.125H28.375V28.0625ZM33.4375 26.375C32.9899 26.375 32.5607 26.5528 32.2443 26.8693C31.9278 27.1857 31.75 27.6149 31.75 28.0625V71.9375C31.75 72.3851 31.9278 72.8143 32.2443 73.1307C32.5607 73.4472 32.9899 73.625 33.4375 73.625H73.9375C74.3851 73.625 74.8143 73.4472 75.1307 73.1307C75.4472 72.8143 75.625 72.3851 75.625 71.9375V28.0625C75.625 27.6149 75.4472 27.1857 75.1307 26.8693C74.8143 26.5528 74.3851 26.375 73.9375 26.375H33.4375Z" fill="#3EAAAF" fill-opacity="0.5"/> +<defs> +<clipPath id="clip0_2_20"> +<rect width="34" height="34" fill="white" transform="translate(37 33)"/> +</clipPath> +</defs> +</svg> diff --git a/frontend/app/svg/ca-no-sessions.svg b/frontend/app/svg/ca-no-sessions.svg new file mode 100644 index 000000000..e38cd449a --- /dev/null +++ b/frontend/app/svg/ca-no-sessions.svg @@ -0,0 +1,13 @@ +<svg viewBox="0 0 250 100" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="250" height="100" rx="13.1579" fill="#3EAAAF" fill-opacity="0.08"/> +<rect opacity="0.6" x="86.8421" y="29.579" width="138.158" height="14.4737" rx="6.57895" fill="#3EAAAF" fill-opacity="0.5"/> +<rect opacity="0.3" x="86.8421" y="55.8948" width="46.0526" height="14.4737" rx="6.57895" fill="#3EAAAF" fill-opacity="0.5"/> +<g clip-path="url(#clip0_1_2)"> +<path d="M51.9737 76.9474C59.1276 76.9474 65.9884 74.1055 71.047 69.047C76.1055 63.9884 78.9474 57.1276 78.9474 49.9737C78.9474 42.8198 76.1055 35.959 71.047 30.9004C65.9884 25.8419 59.1276 23 51.9737 23C44.8198 23 37.959 25.8419 32.9004 30.9004C27.8419 35.959 25 42.8198 25 49.9737C25 57.1276 27.8419 63.9884 32.9004 69.047C37.959 74.1055 44.8198 76.9474 51.9737 76.9474V76.9474ZM48.602 44.9161C48.602 47.7079 47.0914 49.9737 45.2303 49.9737C43.3691 49.9737 41.8586 47.7079 41.8586 44.9161C41.8586 42.1243 43.3691 39.8586 45.2303 39.8586C47.0914 39.8586 48.602 42.1243 48.602 44.9161ZM39.4478 64.9205C39.0606 64.6969 38.7781 64.3287 38.6623 63.8968C38.5466 63.465 38.6072 63.0048 38.8308 62.6176C40.1622 60.3102 42.0779 58.3941 44.3851 57.0624C46.6923 55.7306 49.3097 55.03 51.9737 55.0313C54.6376 55.0307 57.2546 55.7316 59.5617 57.0633C61.8688 58.395 63.7847 60.3106 65.1166 62.6176C65.3366 63.0046 65.3947 63.4629 65.278 63.8926C65.1614 64.3222 64.8796 64.6882 64.494 64.9108C64.1085 65.1334 63.6506 65.1945 63.2202 65.0807C62.7898 64.9669 62.4219 64.6875 62.1967 64.3035C61.1612 62.5086 59.6711 61.0182 57.8764 59.9823C56.0818 58.9465 54.0458 58.4017 51.9737 58.403C49.9015 58.4017 47.8656 58.9465 46.0709 59.9823C44.2762 61.0182 42.7861 62.5086 41.7507 64.3035C41.5271 64.6906 41.1589 64.9732 40.727 65.0889C40.2951 65.2046 39.835 65.144 39.4478 64.9205ZM58.7171 49.9737C56.8559 49.9737 55.3454 47.7079 55.3454 44.9161C55.3454 42.1243 56.8559 39.8586 58.7171 39.8586C60.5783 39.8586 62.0888 42.1243 62.0888 44.9161C62.0888 47.7079 60.5783 49.9737 58.7171 49.9737Z" fill="#3EAAAF" fill-opacity="0.5"/> +</g> +<defs> +<clipPath id="clip0_1_2"> +<rect width="53.9474" height="53.9474" fill="white" transform="translate(25 23)"/> +</clipPath> +</defs> +</svg> diff --git a/frontend/app/svg/ca-no-webhooks.svg b/frontend/app/svg/ca-no-webhooks.svg new file mode 100644 index 000000000..a3b281300 --- /dev/null +++ b/frontend/app/svg/ca-no-webhooks.svg @@ -0,0 +1,11 @@ +<svg viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="100" height="100" rx="13.1579" fill="#3EAAAF" fill-opacity="0.08"/> +<g clip-path="url(#clip0_3_7)"> +<path d="M48.9575 23H48.89L48.215 23.135C46.73 23.315 45.2788 23.7312 43.8613 24.3838C42.4438 25.0363 41.195 25.9025 40.115 26.9825C38.27 28.6475 36.9875 30.695 36.2675 33.125C35.8625 34.475 35.6825 35.8925 35.7275 37.3775C35.7725 38.8625 36.0425 40.28 36.5375 41.63C37.3025 43.655 38.4725 45.41 40.0475 46.895L40.925 47.705L35.3225 56.8175C34.1525 56.7725 33.14 56.93 32.285 57.29C30.935 57.875 29.9225 58.775 29.2475 59.99C28.9325 60.665 28.73 61.3625 28.64 62.0825C28.55 62.8025 28.595 63.5225 28.775 64.2425C29.135 65.5925 29.8775 66.6725 31.0025 67.4825C31.6775 67.9325 32.42 68.2588 33.23 68.4613C34.04 68.6638 34.85 68.7088 35.66 68.5963C36.47 68.4838 37.2463 68.2025 37.9888 67.7525C38.7313 67.3025 39.35 66.74 39.845 66.065C40.385 65.3 40.7113 64.4225 40.8238 63.4325C40.9363 62.4425 40.8125 61.52 40.4525 60.665C40.4075 60.44 40.25 60.125 39.98 59.72L39.7775 59.315L47.7425 46.355C48.0125 45.95 48.1925 45.635 48.2825 45.41L47.6075 45.2075C47.2025 45.1175 46.91 45.0275 46.73 44.9375C45.695 44.4875 44.7613 43.8688 43.9288 43.0813C43.0963 42.2938 42.455 41.405 42.005 40.415C41.24 38.795 41.0375 37.1075 41.3975 35.3525C41.5775 34.3175 41.96 33.35 42.545 32.45C43.13 31.55 43.8275 30.785 44.6375 30.155C46.3025 28.94 48.1475 28.3325 50.1725 28.3325C51.3875 28.2875 52.5688 28.5013 53.7163 28.9738C54.8638 29.4463 55.865 30.1325 56.72 31.0325C57.485 31.7525 58.0813 32.6188 58.5088 33.6313C58.9363 34.6438 59.1725 35.6675 59.2175 36.7025C59.2175 37.4675 59.0825 38.4125 58.8125 39.5375L63.5375 40.82L64.0775 40.8875C64.7075 38.9525 64.865 37.0175 64.55 35.0825C64.37 33.5975 63.9313 32.1688 63.2338 30.7963C62.5363 29.4238 61.6475 28.22 60.5675 27.185C58.6775 25.295 56.36 24.035 53.615 23.405C53.345 23.315 52.94 23.225 52.4 23.135L51.455 23H48.9575ZM50.1725 31.37C49.4075 31.37 48.6425 31.5275 47.8775 31.8425C47.1125 32.1575 46.4263 32.6188 45.8188 33.2263C45.2113 33.8338 44.7725 34.5425 44.5025 35.3525C44.1425 36.2525 44.0525 37.2425 44.2325 38.3225C44.3675 39.1325 44.6825 39.8975 45.1775 40.6175C45.6725 41.3375 46.3025 41.9 47.0675 42.305C48.1475 42.98 49.4075 43.2725 50.8475 43.1825C50.9825 43.3625 51.095 43.5425 51.185 43.7225L58.745 56.615C58.88 56.885 59.015 57.0875 59.15 57.2225C60.365 56.0525 61.6813 55.2537 63.0988 54.8263C64.5163 54.3988 65.9788 54.32 67.4863 54.59C68.9938 54.86 70.31 55.445 71.435 56.345C72.875 57.47 73.8538 58.8538 74.3713 60.4963C74.8888 62.1388 74.9 63.7925 74.405 65.4575C74.045 66.7175 73.4038 67.8425 72.4813 68.8325C71.5588 69.8225 70.4675 70.565 69.2075 71.06C67.8125 71.645 66.3163 71.8363 64.7188 71.6338C63.1213 71.4313 61.6925 70.88 60.4325 69.98C60.2525 69.845 59.96 69.62 59.555 69.305L59.2175 68.9C59.0375 69.035 58.79 69.26 58.475 69.575L55.3025 72.68C56.3375 73.715 57.5075 74.5813 58.8125 75.2788C60.1175 75.9763 61.49 76.46 62.93 76.73L64.415 77H67.0475L68.9375 76.6625C71.8175 76.0775 74.2925 74.7275 76.3625 72.6125C77.3525 71.6225 78.1738 70.4863 78.8263 69.2038C79.4788 67.9213 79.9175 66.5825 80.1425 65.1875C80.3675 63.5675 80.3225 61.9588 80.0075 60.3613C79.6925 58.7638 79.13 57.3125 78.32 56.0075C77.51 54.7025 76.52 53.555 75.35 52.565C74.18 51.575 72.8975 50.7875 71.5025 50.2025C69.9725 49.5725 68.3863 49.2125 66.7438 49.1225C65.1013 49.0325 63.4925 49.19 61.9175 49.595L60.77 49.9325L55.3025 40.685C55.8875 39.83 56.2475 38.9975 56.3825 38.1875C56.6075 36.7475 56.315 35.3975 55.505 34.1375C54.785 33.0125 53.795 32.225 52.535 31.775C51.77 31.505 50.9825 31.37 50.1725 31.37ZM31.1375 49.19C28.5275 49.775 26.2775 50.9675 24.3875 52.7675C22.2725 54.7475 20.9 57.11 20.27 59.855L20 61.475V63.9725L20.135 64.715C20.225 65.3 20.315 65.75 20.405 66.065C20.72 67.46 21.2713 68.7875 22.0588 70.0475C22.8463 71.3075 23.8025 72.41 24.9275 73.355C27.1775 75.2 29.765 76.3025 32.69 76.6625C34.4 76.8425 36.11 76.7413 37.82 76.3588C39.53 75.9763 41.105 75.335 42.545 74.435C44.93 72.905 46.685 70.8575 47.81 68.2925C48.125 67.5275 48.44 66.515 48.755 65.255C49.025 65.21 49.43 65.21 49.97 65.255L58.4075 65.39C59.0375 65.39 59.4875 65.4125 59.7575 65.4575C60.2525 66.3575 60.815 67.055 61.445 67.55C62.075 68.045 62.7838 68.405 63.5713 68.63C64.3588 68.855 65.1575 68.945 65.9675 68.9C67.2275 68.765 68.3525 68.315 69.3425 67.55C70.5575 66.605 71.2775 65.39 71.5025 63.905C71.5925 63.14 71.5588 62.3975 71.4013 61.6775C71.2438 60.9575 70.94 60.2825 70.49 59.6525C69.77 58.6625 68.8475 57.9425 67.7225 57.4925C66.9575 57.1775 66.1588 57.0312 65.3263 57.0538C64.4938 57.0763 63.695 57.245 62.93 57.56C61.67 58.1 60.7025 58.9325 60.0275 60.0575L59.825 60.4625C59.195 60.4625 58.25 60.44 56.99 60.395L49.3625 60.3275C48.6875 60.2825 47.72 60.26 46.46 60.26L43.085 60.1925L43.2875 61.34C43.6475 63.275 43.355 65.0975 42.41 66.8075C41.78 68.0225 40.88 69.035 39.71 69.845C38.54 70.655 37.2575 71.15 35.8625 71.33C34.2875 71.6 32.7125 71.4313 31.1375 70.8238C29.5625 70.2163 28.28 69.26 27.29 67.955C26.03 66.38 25.4225 64.6025 25.4675 62.6225C25.5125 60.9575 26.03 59.405 27.02 57.965C27.605 57.11 28.325 56.39 29.18 55.805C30.035 55.22 30.9575 54.7925 31.9475 54.5225L32.555 54.32L31.1375 49.19Z" fill="#3EAAAF" fill-opacity="0.5"/> +</g> +<defs> +<clipPath id="clip0_3_7"> +<rect width="60.345" height="54" fill="white" transform="translate(20 23)"/> +</clipPath> +</defs> +</svg> diff --git a/frontend/app/svg/icons/bar-pencil.svg b/frontend/app/svg/icons/bar-pencil.svg new file mode 100644 index 000000000..1b9916e25 --- /dev/null +++ b/frontend/app/svg/icons/bar-pencil.svg @@ -0,0 +1,16 @@ +<svg viewBox="0 0 59 66" xmlns="http://www.w3.org/2000/svg"> +<g clip-path="url(#clip0_1102_8386)"> +<path d="M40.2709 15.7361C40.2709 14.7839 40.6491 13.8707 41.3225 13.1974C41.9958 12.5241 42.909 12.1458 43.8612 12.1458H51.0417C51.9939 12.1458 52.9071 12.5241 53.5804 13.1974C54.2537 13.8707 54.632 14.7839 54.632 15.7361V58.8194H56.4271C56.9032 58.8194 57.3598 59.0086 57.6965 59.3452C58.0331 59.6819 58.2223 60.1385 58.2223 60.6146C58.2223 61.0907 58.0331 61.5473 57.6965 61.8839C57.3598 62.2206 56.9032 62.4097 56.4271 62.4097H2.57297C2.09687 62.4097 1.64027 62.2206 1.30362 61.8839C0.966962 61.5473 0.777832 61.0907 0.777832 60.6146C0.777832 60.1385 0.966962 59.6819 1.30362 59.3452C1.64027 59.0086 2.09687 58.8194 2.57297 58.8194H4.36811V48.0486C4.36811 47.0964 4.74637 46.1832 5.41968 45.5099C6.09299 44.8366 7.00619 44.4583 7.95839 44.4583H15.1389C16.0911 44.4583 17.0043 44.8366 17.6777 45.5099C18.351 46.1832 18.7292 47.0964 18.7292 48.0486V58.8194H22.3195V33.6875C22.3195 32.7353 22.6978 31.8221 23.3711 31.1488C24.0444 30.4755 24.9576 30.0972 25.9098 30.0972H33.0903C34.0425 30.0972 34.9557 30.4755 35.629 31.1488C36.3023 31.8221 36.6806 32.7353 36.6806 33.6875V58.8194H40.2709V15.7361ZM43.8612 58.8194H51.0417V15.7361H43.8612V58.8194ZM33.0903 58.8194V33.6875H25.9098V58.8194H33.0903ZM15.1389 58.8194V48.0486H7.95839V58.8194H15.1389Z" /> +</g> +<g clip-path="url(#clip1_1102_8386)"> +<path d="M28.6125 0.334511C28.7189 0.227803 28.8453 0.143142 28.9845 0.0853777C29.1238 0.0276129 29.273 -0.00212097 29.4237 -0.00212097C29.5744 -0.00212097 29.7237 0.0276129 29.8629 0.0853777C30.0021 0.143142 30.1285 0.227803 30.235 0.334511L37.11 7.20951C37.2167 7.31595 37.3013 7.44239 37.3591 7.5816C37.4169 7.72081 37.4466 7.87004 37.4466 8.02076C37.4466 8.17148 37.4169 8.32071 37.3591 8.45992C37.3013 8.59913 37.2167 8.72557 37.11 8.83201L14.1933 31.7487C14.0833 31.8579 13.9524 31.9436 13.8083 32.0008L2.34996 36.5841C2.14173 36.6674 1.91362 36.6878 1.6939 36.6428C1.47418 36.5977 1.27253 36.4891 1.11393 36.3305C0.955329 36.1719 0.846765 35.9703 0.801694 35.7506C0.756623 35.5308 0.777026 35.3027 0.860376 35.0945L5.44371 23.6362C5.50084 23.4921 5.58659 23.3611 5.69579 23.2512L28.6125 0.334511ZM26.4606 5.72909L31.7154 10.9839L34.6785 8.02076L29.4237 2.76597L26.4606 5.72909ZM30.0952 12.6041L24.8404 7.3493L9.94454 22.2451V22.9166H11.0904C11.3943 22.9166 11.6857 23.0373 11.9006 23.2522C12.1155 23.4671 12.2362 23.7585 12.2362 24.0624V25.2083H13.382C13.6859 25.2083 13.9774 25.329 14.1923 25.5439C14.4072 25.7587 14.5279 26.0502 14.5279 26.3541V27.4999H15.1993L30.0952 12.6041ZM7.72621 24.4635L7.48329 24.7064L3.98163 33.4628L12.7381 29.9612L12.981 29.7183C12.7624 29.6366 12.574 29.4901 12.4409 29.2985C12.3078 29.1068 12.2364 28.8791 12.2362 28.6458V27.4999H11.0904C10.7865 27.4999 10.495 27.3792 10.2801 27.1643C10.0653 26.9494 9.94454 26.658 9.94454 26.3541V25.2083H8.79871C8.56537 25.2081 8.33765 25.1367 8.14599 25.0036C7.95433 24.8705 7.80788 24.682 7.72621 24.4635Z" /> +</g> +<defs> +<clipPath id="clip0_1102_8386"> +<rect width="57.4444" height="57.4444" fill="white" transform="translate(0.777832 8.55556)"/> +</clipPath> +<clipPath id="clip1_1102_8386"> +<rect width="36.6667" height="36.6667" fill="white" transform="translate(0.777832)"/> +</clipPath> +</defs> +</svg> diff --git a/frontend/app/svg/icons/chat-right-text.svg b/frontend/app/svg/icons/chat-right-text.svg new file mode 100644 index 000000000..283ae48fd --- /dev/null +++ b/frontend/app/svg/icons/chat-right-text.svg @@ -0,0 +1,4 @@ +<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" class="bi bi-chat-right-text" viewBox="0 0 16 16"> + <path d="M2 1a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h9.586a2 2 0 0 1 1.414.586l2 2V2a1 1 0 0 0-1-1H2zm12-1a2 2 0 0 1 2 2v12.793a.5.5 0 0 1-.854.353l-2.853-2.853a1 1 0 0 0-.707-.293H2a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h12z"/> + <path d="M3 3.5a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5zM3 6a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9A.5.5 0 0 1 3 6zm0 2.5a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5z"/> +</svg> \ No newline at end of file diff --git a/frontend/app/svg/icons/columns-gap-filled.svg b/frontend/app/svg/icons/columns-gap-filled.svg new file mode 100644 index 000000000..4bb29842d --- /dev/null +++ b/frontend/app/svg/icons/columns-gap-filled.svg @@ -0,0 +1,3 @@ +<svg viewBox="0 0 25 26" xmlns="http://www.w3.org/2000/svg"> +<path d="M0.282227 14.4717H11.0869V0.96582H0.282227V14.4717ZM0.282227 25.2764H11.0869V17.1729H0.282227V25.2764ZM13.7881 25.2764H24.5928V11.7705H13.7881V25.2764ZM13.7881 0.96582V9.06934H24.5928V0.96582H13.7881Z"/> +</svg> diff --git a/frontend/app/svg/icons/errors-icon.svg b/frontend/app/svg/icons/errors-icon.svg new file mode 100644 index 000000000..07e508159 --- /dev/null +++ b/frontend/app/svg/icons/errors-icon.svg @@ -0,0 +1,4 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> + <path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/> + <path d="M7.002 11a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 4.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995z"/> +</svg> diff --git a/frontend/app/svg/icons/funnel-new.svg b/frontend/app/svg/icons/funnel-new.svg new file mode 100644 index 000000000..36cb3c15c --- /dev/null +++ b/frontend/app/svg/icons/funnel-new.svg @@ -0,0 +1,3 @@ +<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" class="bi bi-funnel" viewBox="0 0 16 16"> + <path d="M1.5 1.5A.5.5 0 0 1 2 1h12a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.128.334L10 8.692V13.5a.5.5 0 0 1-.342.474l-3 1A.5.5 0 0 1 6 14.5V8.692L1.628 3.834A.5.5 0 0 1 1.5 3.5v-2zm1 .5v1.308l4.372 4.858A.5.5 0 0 1 7 8.5v5.306l2-.666V8.5a.5.5 0 0 1 .128-.334L13.5 3.308V2h-11z"/> +</svg> diff --git a/frontend/app/svg/icons/graph-up.svg b/frontend/app/svg/icons/graph-up.svg new file mode 100644 index 000000000..7b12d457e --- /dev/null +++ b/frontend/app/svg/icons/graph-up.svg @@ -0,0 +1,3 @@ +<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" class="bi bi-graph-up" viewBox="0 0 16 16"> + <path fill-rule="evenodd" d="M0 0h1v15h15v1H0V0Zm14.817 3.113a.5.5 0 0 1 .07.704l-4.5 5.5a.5.5 0 0 1-.74.037L7.06 6.767l-3.656 5.027a.5.5 0 0 1-.808-.588l4-5.5a.5.5 0 0 1 .758-.06l2.609 2.61 4.15-5.073a.5.5 0 0 1 .704-.07Z"/> +</svg> diff --git a/frontend/app/svg/icons/grid-check.svg b/frontend/app/svg/icons/grid-check.svg new file mode 100644 index 000000000..3e899f840 --- /dev/null +++ b/frontend/app/svg/icons/grid-check.svg @@ -0,0 +1,3 @@ +<svg viewBox="0 0 52 52" xmlns="http://www.w3.org/2000/svg"> +<path d="M6.5 32.5H16.25C17.112 32.5 17.9386 32.8424 18.5481 33.4519C19.1576 34.0614 19.5 34.888 19.5 35.75V45.5C19.5 46.362 19.1576 47.1886 18.5481 47.7981C17.9386 48.4076 17.112 48.75 16.25 48.75H6.5C5.63805 48.75 4.8114 48.4076 4.2019 47.7981C3.59241 47.1886 3.25 46.362 3.25 45.5V35.75C3.25 34.888 3.59241 34.0614 4.2019 33.4519C4.8114 32.8424 5.63805 32.5 6.5 32.5ZM35.75 3.25H45.5C46.362 3.25 47.1886 3.59241 47.7981 4.2019C48.4076 4.8114 48.75 5.63805 48.75 6.5V16.25C48.75 17.112 48.4076 17.9386 47.7981 18.5481C47.1886 19.1576 46.362 19.5 45.5 19.5H35.75C34.888 19.5 34.0614 19.1576 33.4519 18.5481C32.8424 17.9386 32.5 17.112 32.5 16.25V6.5C32.5 5.63805 32.8424 4.8114 33.4519 4.2019C34.0614 3.59241 34.888 3.25 35.75 3.25ZM35.75 32.5C34.888 32.5 34.0614 32.8424 33.4519 33.4519C32.8424 34.0614 32.5 34.888 32.5 35.75V45.5C32.5 46.362 32.8424 47.1886 33.4519 47.7981C34.0614 48.4076 34.888 48.75 35.75 48.75H45.5C46.362 48.75 47.1886 48.4076 47.7981 47.7981C48.4076 47.1886 48.75 46.362 48.75 45.5V35.75C48.75 34.888 48.4076 34.0614 47.7981 33.4519C47.1886 32.8424 46.362 32.5 45.5 32.5H35.75ZM35.75 0C34.0261 0 32.3728 0.68482 31.1538 1.90381C29.9348 3.12279 29.25 4.77609 29.25 6.5V16.25C29.25 17.9739 29.9348 19.6272 31.1538 20.8462C32.3728 22.0652 34.0261 22.75 35.75 22.75H45.5C47.2239 22.75 48.8772 22.0652 50.0962 20.8462C51.3152 19.6272 52 17.9739 52 16.25V6.5C52 4.77609 51.3152 3.12279 50.0962 1.90381C48.8772 0.68482 47.2239 0 45.5 0L35.75 0ZM6.5 29.25C4.77609 29.25 3.12279 29.9348 1.90381 31.1538C0.68482 32.3728 0 34.0261 0 35.75L0 45.5C0 47.2239 0.68482 48.8772 1.90381 50.0962C3.12279 51.3152 4.77609 52 6.5 52H16.25C17.9739 52 19.6272 51.3152 20.8462 50.0962C22.0652 48.8772 22.75 47.2239 22.75 45.5V35.75C22.75 34.0261 22.0652 32.3728 20.8462 31.1538C19.6272 29.9348 17.9739 29.25 16.25 29.25H6.5ZM29.25 35.75C29.25 34.0261 29.9348 32.3728 31.1538 31.1538C32.3728 29.9348 34.0261 29.25 35.75 29.25H45.5C47.2239 29.25 48.8772 29.9348 50.0962 31.1538C51.3152 32.3728 52 34.0261 52 35.75V45.5C52 47.2239 51.3152 48.8772 50.0962 50.0962C48.8772 51.3152 47.2239 52 45.5 52H35.75C34.0261 52 32.3728 51.3152 31.1538 50.0962C29.9348 48.8772 29.25 47.2239 29.25 45.5V35.75ZM0 6.5C0 4.77609 0.68482 3.12279 1.90381 1.90381C3.12279 0.68482 4.77609 0 6.5 0L16.25 0C17.9739 0 19.6272 0.68482 20.8462 1.90381C22.0652 3.12279 22.75 4.77609 22.75 6.5V16.25C22.75 17.9739 22.0652 19.6272 20.8462 20.8462C19.6272 22.0652 17.9739 22.75 16.25 22.75H6.5C4.77609 22.75 3.12279 22.0652 1.90381 20.8462C0.68482 19.6272 0 17.9739 0 16.25V6.5ZM17.4005 9.2755C17.5516 9.12441 17.6714 8.94505 17.7532 8.74765C17.835 8.55024 17.8771 8.33867 17.8771 8.125C17.8771 7.91133 17.835 7.69976 17.7532 7.50235C17.6714 7.30495 17.5516 7.12559 17.4005 6.9745C17.2494 6.82341 17.07 6.70357 16.8726 6.6218C16.6752 6.54003 16.4637 6.49795 16.25 6.49795C16.0363 6.49795 15.8248 6.54003 15.6274 6.6218C15.4299 6.70357 15.2506 6.82341 15.0995 6.9745L9.75 12.3272L7.6505 10.2245C7.49941 10.0734 7.32005 9.95357 7.12265 9.8718C6.92524 9.79003 6.71367 9.74795 6.5 9.74795C6.28633 9.74795 6.07476 9.79003 5.87735 9.8718C5.67995 9.95357 5.50059 10.0734 5.3495 10.2245C5.19841 10.3756 5.07857 10.555 4.9968 10.7524C4.91503 10.9498 4.87295 11.1613 4.87295 11.375C4.87295 11.5887 4.91503 11.8002 4.9968 11.9976C5.07857 12.195 5.19841 12.3744 5.3495 12.5255L8.5995 15.7755C8.75045 15.9268 8.92977 16.0469 9.12719 16.1288C9.32461 16.2107 9.53626 16.2529 9.75 16.2529C9.96374 16.2529 10.1754 16.2107 10.3728 16.1288C10.5702 16.0469 10.7496 15.9268 10.9005 15.7755L17.4005 9.2755Z" /> +</svg> diff --git a/frontend/app/svg/icons/info-circle-fill.svg b/frontend/app/svg/icons/info-circle-fill.svg new file mode 100644 index 000000000..9af7ae43b --- /dev/null +++ b/frontend/app/svg/icons/info-circle-fill.svg @@ -0,0 +1,3 @@ +<svg viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg"> +<path d="M17.7501 35.4999C22.4576 35.4999 26.9724 33.6298 30.3012 30.301C33.6299 26.9723 35.5 22.4575 35.5 17.7499C35.5 13.0424 33.6299 8.5276 30.3012 5.19884C26.9724 1.87008 22.4576 0 17.7501 0C13.0425 0 8.52772 1.87008 5.19896 5.19884C1.8702 8.5276 0.00012207 13.0424 0.00012207 17.7499C0.00012207 22.4575 1.8702 26.9723 5.19896 30.301C8.52772 33.6298 13.0425 35.4999 17.7501 35.4999ZM19.8135 14.6171L17.5947 25.0563C17.4394 25.8106 17.6591 26.2388 18.2692 26.2388C18.6997 26.2388 19.3498 26.0835 19.7913 25.693L19.5961 26.616C18.9593 27.3837 17.5548 27.9428 16.3456 27.9428C14.7858 27.9428 14.1224 27.0065 14.5529 25.0163L16.1903 17.3217C16.3323 16.6716 16.2036 16.4364 15.5535 16.2789L14.5529 16.0992L14.7348 15.2539L19.8157 14.6171H19.8135ZM17.7501 12.2031C17.1616 12.2031 16.5973 11.9693 16.1812 11.5532C15.7651 11.1371 15.5313 10.5728 15.5313 9.98434C15.5313 9.39589 15.7651 8.83155 16.1812 8.41545C16.5973 7.99936 17.1616 7.7656 17.7501 7.7656C18.3385 7.7656 18.9029 7.99936 19.3189 8.41545C19.735 8.83155 19.9688 9.39589 19.9688 9.98434C19.9688 10.5728 19.735 11.1371 19.3189 11.5532C18.9029 11.9693 18.3385 12.2031 17.7501 12.2031Z" /> +</svg> diff --git a/frontend/app/svg/icons/info-circle.svg b/frontend/app/svg/icons/info-circle.svg index dfb82474d..c5ddfebc6 100644 --- a/frontend/app/svg/icons/info-circle.svg +++ b/frontend/app/svg/icons/info-circle.svg @@ -1,4 +1,11 @@ -<svg xmlns="http://www.w3.org/2000/svg" class="bi bi-info-circle" viewBox="0 0 16 16"> - <path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/> - <path d="M8.93 6.588l-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533L8.93 6.588zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0z"/> +<svg viewBox="0 0 35 35" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g clip-path="url(#clip0_10_16)"> +<path d="M17.5 32.8125C13.4389 32.8125 9.54408 31.1992 6.67243 28.3276C3.80078 25.4559 2.1875 21.5611 2.1875 17.5C2.1875 13.4389 3.80078 9.54408 6.67243 6.67243C9.54408 3.80078 13.4389 2.1875 17.5 2.1875C21.5611 2.1875 25.4559 3.80078 28.3276 6.67243C31.1992 9.54408 32.8125 13.4389 32.8125 17.5C32.8125 21.5611 31.1992 25.4559 28.3276 28.3276C25.4559 31.1992 21.5611 32.8125 17.5 32.8125ZM17.5 35C22.1413 35 26.5925 33.1563 29.8744 29.8744C33.1563 26.5925 35 22.1413 35 17.5C35 12.8587 33.1563 8.40752 29.8744 5.12563C26.5925 1.84375 22.1413 0 17.5 0C12.8587 0 8.40752 1.84375 5.12563 5.12563C1.84375 8.40752 0 12.8587 0 17.5C0 22.1413 1.84375 26.5925 5.12563 29.8744C8.40752 33.1563 12.8587 35 17.5 35V35Z" /> +<path fill-rule="evenodd" clip-rule="evenodd" d="M17.5 13C18.3284 13 19 12.3284 19 11.5C19 10.6716 18.3284 10 17.5 10C16.6716 10 16 10.6716 16 11.5C16 12.3284 16.6716 13 17.5 13ZM19 15.877C19 15.0485 18.3284 14.377 17.5 14.377C16.6716 14.377 16 15.0485 16 15.877V24.5C16 25.3284 16.6716 26 17.5 26C18.3284 26 19 25.3284 19 24.5V15.877Z" /> +</g> +<defs> +<clipPath id="clip0_10_16"> +<rect width="35" height="35" fill="white"/> +</clipPath> +</defs> </svg> \ No newline at end of file diff --git a/frontend/app/svg/icons/integrations/bugsnag.svg b/frontend/app/svg/icons/integrations/bugsnag.svg index 26a3a13b8..cc97e195b 100644 --- a/frontend/app/svg/icons/integrations/bugsnag.svg +++ b/frontend/app/svg/icons/integrations/bugsnag.svg @@ -1 +1,4 @@ -<svg width="2500" height="1719" viewBox="0 0 256 176" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid"><path d="M57.838 170.017c.151 1.663-.051 3.789-.14 5.436h56.864c.053-1.654.091-3.311.091-4.974 0-39.942-15.768-76.266-44.011-104.51C56.704 52.032 40.885 41.31 23.246 33.898L0 86.328c33.989 15.82 54.211 43.783 57.838 83.689zm69.197-1.644c.108 2.371-.062 4.732-.167 7.08h58.177c.077-2.355.13-4.714.13-7.08 0-28.826-5.66-56.82-16.82-83.207-10.767-25.456-26.169-48.306-45.778-67.915a216.421 216.421 0 0 0-15.686-14.218l-37.68 44.315c37.293 33.313 55.304 65.858 57.824 121.025zM235.263 64.39C226.595 41.785 213.935 19.521 198.727 0l-46.95 34.442c27.495 35.099 44.442 79.71 46.058 127.612.152 4.502-.164 8.969-.457 13.399h58.252c.226-4.448.447-8.916.344-13.399-.805-34.945-8.23-65.12-20.71-97.665z" fill="#3676A1"/></svg> \ No newline at end of file +<svg width="58" height="80" viewBox="0 0 58 80" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M28.9431 55.1235C30.7258 55.1235 32.1709 53.6784 32.1709 51.8957C32.1709 50.113 30.7258 48.6678 28.9431 48.6678C27.1604 48.6678 25.7153 50.113 25.7153 51.8957C25.7153 53.6784 27.1604 55.1235 28.9431 55.1235Z" fill="#303F9F"/> +<path d="M28.9431 78.9612C21.7674 78.9532 14.8878 76.0991 9.81374 71.0251C4.7397 65.9511 1.8856 59.0715 1.87762 51.8957V38.4743C1.87762 37.9402 2.08961 37.428 2.46701 37.0502C2.8444 36.6724 3.35635 36.4598 3.89038 36.4592H13.4904L13.4579 5.38672L5.90313 10.036V27.7287C5.90313 28.2626 5.69107 28.7745 5.31361 29.152C4.93615 29.5294 4.42419 29.7415 3.89038 29.7415C3.35656 29.7415 2.84461 29.5294 2.46715 29.152C2.08968 28.7745 1.87762 28.2626 1.87762 27.7287V9.80643C1.87915 9.18865 2.03815 8.58147 2.33962 8.04224C2.64108 7.50301 3.07505 7.04955 3.60052 6.72469L11.9692 1.57455C12.5174 1.23696 13.1458 1.05179 13.7895 1.03816C14.4331 1.02454 15.0688 1.18295 15.6308 1.49703C16.1928 1.81112 16.6608 2.26951 16.9865 2.82488C17.3121 3.38025 17.4837 4.01247 17.4834 4.65629L17.5182 36.4592H28.9431C31.9963 36.4587 34.981 37.3637 37.5198 39.0596C40.0587 40.7555 42.0376 43.1662 43.2063 45.9868C44.375 48.8074 44.681 51.9113 44.0856 54.9058C43.4903 57.9003 42.0203 60.6511 39.8615 62.8102C37.7028 64.9692 34.9523 66.4396 31.9578 67.0355C28.9634 67.6313 25.8595 67.3257 23.0387 66.1574C20.2179 64.9891 17.8069 63.0106 16.1106 60.472C14.4143 57.9334 13.5089 54.9489 13.5089 51.8957L13.495 40.487H5.90313V51.8957C5.90313 56.4526 7.2544 60.9071 9.78607 64.696C12.3177 68.485 15.9161 71.4381 20.1261 73.1819C24.3361 74.9257 28.9687 75.382 33.438 74.493C37.9073 73.604 42.0127 71.4097 45.2349 68.1874C48.4571 64.9652 50.6514 60.8599 51.5404 56.3906C52.4294 51.9213 51.9732 47.2887 50.2293 43.0787C48.4855 38.8687 45.5324 35.2703 41.7435 32.7386C37.9546 30.207 33.5 28.8557 28.9431 28.8557H25.451C24.9171 28.8557 24.4052 28.6436 24.0277 28.2662C23.6503 27.8887 23.4382 27.3768 23.4382 26.843C23.4382 26.3091 23.6503 25.7972 24.0277 25.4197C24.4052 25.0423 24.9171 24.8302 25.451 24.8302H28.9431C36.1214 24.8302 43.0056 27.6817 48.0813 32.7575C53.1571 37.8333 56.0086 44.7175 56.0086 51.8957C56.0086 59.0739 53.1571 65.9581 48.0813 71.0339C43.0056 76.1097 36.1214 78.9612 28.9431 78.9612V78.9612ZM17.5228 40.487V51.8934C17.5224 54.1499 18.1911 56.3559 19.4444 58.2324C20.6978 60.1088 22.4794 61.5715 24.564 62.4353C26.6486 63.2992 28.9426 63.5254 31.1558 63.0855C33.3691 62.6455 35.4021 61.5591 36.9979 59.9637C38.5937 58.3683 39.6805 56.3354 40.1208 54.1223C40.5612 51.9092 40.3354 49.6151 39.472 47.5303C38.6086 45.4455 37.1463 43.6636 35.2701 42.4099C33.3939 41.1562 31.1881 40.487 28.9315 40.487H17.5228Z" fill="#303F9F"/> +</svg> diff --git a/frontend/app/svg/icons/integrations/newrelic.svg b/frontend/app/svg/icons/integrations/newrelic.svg index cc4aea514..061e7e0a3 100644 --- a/frontend/app/svg/icons/integrations/newrelic.svg +++ b/frontend/app/svg/icons/integrations/newrelic.svg @@ -1 +1,12 @@ -<svg id="CMYK_-_square" data-name="CMYK - square" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 681.02 551.55"><defs><style>.cls-1{fill:#0097a0;}.cls-2{fill:#5bc6cc;}.cls-3{fill:#231f20;}</style></defs><title>NewRelic-logo-square \ No newline at end of file + + + + + + + + + + + + diff --git a/frontend/app/svg/icons/integrations/rollbar.svg b/frontend/app/svg/icons/integrations/rollbar.svg index 2f6538118..0d183182b 100644 --- a/frontend/app/svg/icons/integrations/rollbar.svg +++ b/frontend/app/svg/icons/integrations/rollbar.svg @@ -1,20 +1,10 @@ - - - - -rollbar-logo-color-vertical - - - - - + + + + + + + + diff --git a/frontend/app/svg/icons/no-dashboard.svg b/frontend/app/svg/icons/no-dashboard.svg new file mode 100644 index 000000000..2e849b192 --- /dev/null +++ b/frontend/app/svg/icons/no-dashboard.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/app/svg/icons/no-metrics.svg b/frontend/app/svg/icons/no-metrics.svg new file mode 100644 index 000000000..6809e0f79 --- /dev/null +++ b/frontend/app/svg/icons/no-metrics.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/app/svg/icons/performance-icon.svg b/frontend/app/svg/icons/performance-icon.svg new file mode 100644 index 000000000..cbebb97fb --- /dev/null +++ b/frontend/app/svg/icons/performance-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/app/svg/icons/question-lg.svg b/frontend/app/svg/icons/question-lg.svg new file mode 100644 index 000000000..8a6e3c642 --- /dev/null +++ b/frontend/app/svg/icons/question-lg.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/app/svg/icons/resources-icon.svg b/frontend/app/svg/icons/resources-icon.svg new file mode 100644 index 000000000..6d726ea0a --- /dev/null +++ b/frontend/app/svg/icons/resources-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/svg/icons/slack.svg b/frontend/app/svg/icons/slack.svg new file mode 100644 index 000000000..dbbd239a7 --- /dev/null +++ b/frontend/app/svg/icons/slack.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/app/svg/icons/table-new.svg b/frontend/app/svg/icons/table-new.svg new file mode 100644 index 000000000..702e7a05b --- /dev/null +++ b/frontend/app/svg/icons/table-new.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/svg/icons/web-vitals.svg b/frontend/app/svg/icons/web-vitals.svg new file mode 100644 index 000000000..a10a828fd --- /dev/null +++ b/frontend/app/svg/icons/web-vitals.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app/svg/integrations/jira.svg b/frontend/app/svg/integrations/jira.svg index 36b328d35..adde0d695 100644 --- a/frontend/app/svg/integrations/jira.svg +++ b/frontend/app/svg/integrations/jira.svg @@ -1,23 +1,20 @@ - - - - Jira Software-blue - Created with Sketch. - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + diff --git a/frontend/app/svg/slack-help.svg b/frontend/app/svg/slack-help.svg new file mode 100644 index 000000000..d2e3c0382 --- /dev/null +++ b/frontend/app/svg/slack-help.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/app/theme/colors.js b/frontend/app/theme/colors.js index 97c65d0c5..297e044bd 100644 --- a/frontend/app/theme/colors.js +++ b/frontend/app/theme/colors.js @@ -42,5 +42,19 @@ module.exports = { default: '#DDDDDD', 'gray-light-shade': '#EEEEEE', 'primary': '#3490dc', + 'transparent': 'transparent', + }, + + // actual theme colors - use this for new components + figmaColors: { + 'accent-secondary': 'rgba(62, 170, 175, 1)', + 'main': 'rgba(57, 78, 255, 1)', + 'primary-outlined-hover-background': 'rgba(62, 170, 175, 0.08)', + 'primary-outlined-resting-border': 'rgba(62, 170, 175, 0.5)', + 'secondary-outlined-hover-background': 'rgba(63, 81, 181, 0.08)', + 'secondary-outlined-resting-border': 'rgba(63, 81, 181, 0.5)', + 'text-disabled': 'rgba(0,0,0, 0.38)', + 'text-primary': 'rgba(0,0,0, 0.87)', + 'outlined-border': 'rgba(0,0,0, 0.23)', } } diff --git a/frontend/app/types/alert.js b/frontend/app/types/alert.js index c16f6a87e..244047a45 100644 --- a/frontend/app/types/alert.js +++ b/frontend/app/types/alert.js @@ -12,7 +12,7 @@ conditions.forEach(c => { conditionsMap[c.value] = c }); export default Record({ alertId: '', projectId: undefined, - name: 'New Alert', + name: 'Untitled Alert', description: '', active: true, currentPeriod: 15, diff --git a/frontend/app/types/app/period.js b/frontend/app/types/app/period.js index da69519b0..c75a979eb 100644 --- a/frontend/app/types/app/period.js +++ b/frontend/app/types/app/period.js @@ -18,7 +18,7 @@ const RANGE_LABELS = { [LAST_30_MINUTES]: "Last 30 Minutes", [TODAY]: "Today", [YESTERDAY]: "Yesterday", - [LAST_24_HOURS]: "Last 24 Hours", + [LAST_24_HOURS]: "Past 24 Hours", [LAST_7_DAYS]: "Last 7 Days", [LAST_30_DAYS]: "Last 30 Days", [THIS_MONTH]: "This Month", @@ -80,8 +80,8 @@ export default Record( const offset = period.timezoneOffset || 0 if (!period.rangeName || period.rangeName === CUSTOM_RANGE) { const range = moment.range( - moment(period.start || 0).utcOffset(offset), - moment(period.end || 0).utcOffset(offset) + moment(period.start || 0), + moment(period.end || 0) ); return { ...period, diff --git a/frontend/app/types/filter/filterType.ts b/frontend/app/types/filter/filterType.ts index 772bea55e..9d2748e2e 100644 --- a/frontend/app/types/filter/filterType.ts +++ b/frontend/app/types/filter/filterType.ts @@ -1,97 +1,223 @@ export enum FilterCategory { - INTERACTIONS = "Interactions", - GEAR = "Gear", - RECORDING_ATTRIBUTES = "Recording Attributes", - JAVASCRIPT = "Javascript", - USER = "User Identification", - METADATA = "Session & User Metadata", - PERFORMANCE = "Performance", + INTERACTIONS = 'Interactions', + GEAR = 'Gear', + RECORDING_ATTRIBUTES = 'Recording Attributes', + JAVASCRIPT = 'Javascript', + USER = 'User Identification', + METADATA = 'Session & User Metadata', + PERFORMANCE = 'Performance', +} + +export const setQueryParamKeyFromFilterkey = (filterKey: string) => { + switch (filterKey) { + case FilterKey.USERID: + return 'uid'; + case FilterKey.USERANONYMOUSID: + return 'usera'; + case FilterKey.CLICK: + return 'clk'; + case FilterKey.INPUT: + return 'inp'; + case FilterKey.LOCATION: + return 'loc'; + case FilterKey.USER_OS: + return 'os'; + case FilterKey.USER_BROWSER: + return 'browser'; + case FilterKey.USER_DEVICE: + return 'device'; + case FilterKey.PLATFORM: + return 'platform'; + case FilterKey.REVID: + return 'revid'; + case FilterKey.USER_COUNTRY: + return 'country'; + case FilterKey.REFERRER: + return 'ref'; + case FilterKey.CUSTOM: + return 'ce'; + case FilterKey.STATEACTION: + return 'sa'; + case FilterKey.ERROR: + return 'err'; + case FilterKey.ISSUE: + return 'iss'; + + // PERFORMANCE + case FilterKey.DOM_COMPLETE: + return 'domc'; + case FilterKey.LARGEST_CONTENTFUL_PAINT_TIME: + return 'lcp'; + case FilterKey.TTFB: + return 'ttfb'; + case FilterKey.AVG_CPU_LOAD: + return 'acpu'; + case FilterKey.AVG_MEMORY_USAGE: + return 'amem'; + case FilterKey.FETCH_FAILED: + return 'ff'; + } +}; + +export const getFilterKeyTypeByKey = (key: string) => { + switch (key) { + case 'userId': + case 'uid': + case 'userid': + return FilterKey.USERID; + case 'usera': + case 'userAnonymousId': + return FilterKey.USERANONYMOUSID; + case 'clk': + case 'click': + return FilterKey.CLICK; + case 'inp': + case 'input': + return FilterKey.INPUT; + case 'loc': + case 'location': + return FilterKey.LOCATION; + case 'os': + case 'userOs': + return FilterKey.USER_OS; + case 'browser': + case 'userBrowser': + return FilterKey.USER_BROWSER; + case 'device': + case 'userDevice': + return FilterKey.USER_DEVICE; + case 'platform': + return FilterKey.PLATFORM; + case 'revid': + case 'revisionId': + case 'revId': + return FilterKey.REVID; + case 'country': + case 'userCountry': + return FilterKey.USER_COUNTRY; + case 'ref': + case 'referrer': + return FilterKey.REFERRER; + case 'ce': + case 'custom': + case 'customEvent': + return FilterKey.CUSTOM; + case 'sa': + case 'stateAction': + return FilterKey.STATEACTION; + case 'err': + case 'error': + return FilterKey.ERROR; + case 'iss': + case 'issue': + return FilterKey.ISSUE; + + // PERFORMANCE + case 'domc': + case 'domComplete': + return FilterKey.DOM_COMPLETE; + case 'lcp': + case 'largestContentfulPaintTime': + return FilterKey.LARGEST_CONTENTFUL_PAINT_TIME; + case 'ttfb': + case 'timeToFirstByte': + return FilterKey.TTFB; + case 'acpu': + case 'avgCpuLoad': + return FilterKey.AVG_CPU_LOAD; + case 'amem': + case 'avgMemoryUsage': + return FilterKey.AVG_MEMORY_USAGE; + case 'ff': + case 'fetchFailed': + return FilterKey.FETCH_FAILED; + } }; export enum IssueType { - CLICK_RAGE = "click_rage", - DEAD_CLICK = "dead_click", - EXCESSIVE_SCROLLING = "excessive_scrolling", - BAD_REQUEST = "bad_request", - MISSING_RESOURCE = "missing_resource", - MEMORY = "memory", - CPU = "cpu", - SLOW_RESOURCE = "slow_resource", - SLOW_PAGE_LOAD = "slow_page_load", - CRASH = "crash", - CUSTOM = "custom", - JS_EXCEPTION = "js_exception", + CLICK_RAGE = 'click_rage', + DEAD_CLICK = 'dead_click', + EXCESSIVE_SCROLLING = 'excessive_scrolling', + BAD_REQUEST = 'bad_request', + MISSING_RESOURCE = 'missing_resource', + MEMORY = 'memory', + CPU = 'cpu', + SLOW_RESOURCE = 'slow_resource', + SLOW_PAGE_LOAD = 'slow_page_load', + CRASH = 'crash', + CUSTOM = 'custom', + JS_EXCEPTION = 'js_exception', } export enum FilterType { - STRING = "STRING", - ISSUE = "ISSUE", - BOOLEAN = "BOOLEAN", - NUMBER = "NUMBER", - NUMBER_MULTIPLE = "NUMBER_MULTIPLE", - DURATION = "DURATION", - MULTIPLE = "MULTIPLE", - SUB_FILTERS = "SUB_FILTERS", - COUNTRY = "COUNTRY", - DROPDOWN = "DROPDOWN", - MULTIPLE_DROPDOWN = "MULTIPLE_DROPDOWN", - AUTOCOMPLETE_LOCAL = "AUTOCOMPLETE_LOCAL", -}; + STRING = 'STRING', + ISSUE = 'ISSUE', + BOOLEAN = 'BOOLEAN', + NUMBER = 'NUMBER', + NUMBER_MULTIPLE = 'NUMBER_MULTIPLE', + DURATION = 'DURATION', + MULTIPLE = 'MULTIPLE', + SUB_FILTERS = 'SUB_FILTERS', + COUNTRY = 'COUNTRY', + DROPDOWN = 'DROPDOWN', + MULTIPLE_DROPDOWN = 'MULTIPLE_DROPDOWN', + AUTOCOMPLETE_LOCAL = 'AUTOCOMPLETE_LOCAL', +} export enum FilterKey { - ERROR = "ERROR", - MISSING_RESOURCE = "MISSING_RESOURCE", - SLOW_SESSION = "SLOW_SESSION", - CLICK_RAGE = "CLICK_RAGE", - CLICK = "CLICK", - INPUT = "INPUT", - LOCATION = "LOCATION", - VIEW = "VIEW", - CONSOLE = "CONSOLE", - METADATA = "METADATA", - CUSTOM = "CUSTOM", - URL = "URL", - USER_BROWSER = "USERBROWSER", - USER_OS = "USEROS", - USER_DEVICE = "USERDEVICE", - PLATFORM = "PLATFORM", - DURATION = "DURATION", - REFERRER = "REFERRER", - USER_COUNTRY = "USERCOUNTRY", - JOURNEY = "JOURNEY", - REQUEST = "REQUEST", - GRAPHQL = "GRAPHQL", - STATEACTION = "STATEACTION", - REVID = "REVID", - USERANONYMOUSID = "USERANONYMOUSID", - USERID = "USERID", - ISSUE = "ISSUE", - EVENTS_COUNT = "EVENTS_COUNT", - UTM_SOURCE = "UTM_SOURCE", - UTM_MEDIUM = "UTM_MEDIUM", - UTM_CAMPAIGN = "UTM_CAMPAIGN", - - DOM_COMPLETE = "DOM_COMPLETE", - LARGEST_CONTENTFUL_PAINT_TIME = "LARGEST_CONTENTFUL_PAINT_TIME", - TIME_BETWEEN_EVENTS = "TIME_BETWEEN_EVENTS", - TTFB = "TTFB", - AVG_CPU_LOAD = "AVG_CPU_LOAD", - AVG_MEMORY_USAGE = "AVG_MEMORY_USAGE", - FETCH_FAILED = "FETCH_FAILED", - - FETCH = "FETCH", - FETCH_URL = "FETCH_URL", - FETCH_STATUS_CODE = "FETCH_STATUS_CODE", - FETCH_METHOD = "FETCH_METHOD", - FETCH_DURATION = "FETCH_DURATION", - FETCH_REQUEST_BODY = "FETCH_REQUEST_BODY", - FETCH_RESPONSE_BODY = "FETCH_RESPONSE_BODY", + ERROR = 'ERROR', + MISSING_RESOURCE = 'MISSING_RESOURCE', + SLOW_SESSION = 'SLOW_SESSION', + CLICK_RAGE = 'CLICK_RAGE', + CLICK = 'CLICK', + INPUT = 'INPUT', + LOCATION = 'LOCATION', + VIEW = 'VIEW', + CONSOLE = 'CONSOLE', + METADATA = 'METADATA', + CUSTOM = 'CUSTOM', + URL = 'URL', + USER_BROWSER = 'USERBROWSER', + USER_OS = 'USEROS', + USER_DEVICE = 'USERDEVICE', + PLATFORM = 'PLATFORM', + DURATION = 'DURATION', + REFERRER = 'REFERRER', + USER_COUNTRY = 'USERCOUNTRY', + JOURNEY = 'JOURNEY', + REQUEST = 'REQUEST', + GRAPHQL = 'GRAPHQL', + STATEACTION = 'STATEACTION', + REVID = 'REVID', + USERANONYMOUSID = 'USERANONYMOUSID', + USERID = 'USERID', + ISSUE = 'ISSUE', + EVENTS_COUNT = 'EVENTS_COUNT', + UTM_SOURCE = 'UTM_SOURCE', + UTM_MEDIUM = 'UTM_MEDIUM', + UTM_CAMPAIGN = 'UTM_CAMPAIGN', - GRAPHQL_NAME = "GRAPHQL_NAME", - GRAPHQL_METHOD = "GRAPHQL_METHOD", - GRAPHQL_REQUEST_BODY = "GRAPHQL_REQUEST_BODY", - GRAPHQL_RESPONSE_BODY = "GRAPHQL_RESPONSE_BODY", + DOM_COMPLETE = 'DOM_COMPLETE', + LARGEST_CONTENTFUL_PAINT_TIME = 'LARGEST_CONTENTFUL_PAINT_TIME', + TIME_BETWEEN_EVENTS = 'TIME_BETWEEN_EVENTS', + TTFB = 'TTFB', + AVG_CPU_LOAD = 'AVG_CPU_LOAD', + AVG_MEMORY_USAGE = 'AVG_MEMORY_USAGE', + FETCH_FAILED = 'FETCH_FAILED', + + FETCH = 'FETCH', + FETCH_URL = 'FETCH_URL', + FETCH_STATUS_CODE = 'FETCH_STATUS_CODE', + FETCH_METHOD = 'FETCH_METHOD', + FETCH_DURATION = 'FETCH_DURATION', + FETCH_REQUEST_BODY = 'FETCH_REQUEST_BODY', + FETCH_RESPONSE_BODY = 'FETCH_RESPONSE_BODY', + + GRAPHQL_NAME = 'GRAPHQL_NAME', + GRAPHQL_METHOD = 'GRAPHQL_METHOD', + GRAPHQL_REQUEST_BODY = 'GRAPHQL_REQUEST_BODY', + GRAPHQL_RESPONSE_BODY = 'GRAPHQL_RESPONSE_BODY', SESSIONS = 'SESSIONS', - ERRORS = 'js_exception' -} \ No newline at end of file + ERRORS = 'js_exception', +} diff --git a/frontend/app/types/filter/newFilter.js b/frontend/app/types/filter/newFilter.js index 452c4f1c9..2c49df433 100644 --- a/frontend/app/types/filter/newFilter.js +++ b/frontend/app/types/filter/newFilter.js @@ -53,35 +53,48 @@ export const filters = [ { key: FilterKey.ISSUE, type: FilterType.ISSUE, category: FilterCategory.JAVASCRIPT, label: 'Issue', operator: 'is', operatorOptions: filterOptions.getOperatorsByKeys(['is', 'isAny', 'isNot']), icon: 'filters/click', options: filterOptions.issueOptions }, ]; -export const filtersMap = filters.reduce((acc, filter) => { +const mapFilters = (list) => { + return list.reduce((acc, filter) => { acc[filter.key] = filter; return acc; -}, {}); + }, {}); +} -export const liveFiltersMap = {} -filters.forEach(filter => { - if ( - filter.category !== FilterCategory.INTERACTIONS && - filter.category !== FilterCategory.JAVASCRIPT && - filter.category !== FilterCategory.PERFORMANCE && - filter.key !== FilterKey.DURATION && - filter.key !== FilterKey.REFERRER - ) { - liveFiltersMap[filter.key] = {...filter}; - liveFiltersMap[filter.key].operator = 'contains'; - liveFiltersMap[filter.key].operatorDisabled = true; - if (filter.key === FilterKey.PLATFORM) { - liveFiltersMap[filter.key].operator = 'is'; +const liveFilterSupportedOperators = ['is', 'contains']; +const mapLiveFilters = (list) => { + const obj = {}; + list.forEach(filter => { + if ( + filter.category !== FilterCategory.INTERACTIONS && + filter.category !== FilterCategory.JAVASCRIPT && + filter.category !== FilterCategory.PERFORMANCE && + filter.key !== FilterKey.DURATION && + filter.key !== FilterKey.REFERRER + ) { + obj[filter.key] = {...filter}; + obj[filter.key].operatorOptions = filter.operatorOptions.filter(operator => liveFilterSupportedOperators.includes(operator.value)); + if (filter.key === FilterKey.PLATFORM) { + obj[filter.key].operator = 'is'; + } } - } -}) + }) + return obj; +} export const filterLabelMap = filters.reduce((acc, filter) => { acc[filter.key] = filter.label return acc }, {}) +export let filtersMap = mapFilters(filters) +export let liveFiltersMap = mapLiveFilters(filters) + +export const clearMetaFilters = () => { + filtersMap = mapFilters(filters); + liveFiltersMap = mapLiveFilters(filters); +}; + /** * Add a new filter to the filter list * @param {*} category diff --git a/frontend/app/types/integrations/githubConfig.js b/frontend/app/types/integrations/githubConfig.js index 5407f17d8..fe5810dd1 100644 --- a/frontend/app/types/integrations/githubConfig.js +++ b/frontend/app/types/integrations/githubConfig.js @@ -4,44 +4,47 @@ import Record from 'Types/Record'; export const SECRET_ACCESS_KEY_LENGTH = 40; export const ACCESS_KEY_ID_LENGTH = 20; -export default Record({ - projectId: undefined, - provider: 'github', - token: '' -}, { - idKey: 'projectId', - fromJS: ({ projectId, ...config }) => ({ - ...config, - projectId: projectId === undefined ? projectId : `${ projectId }`, - }), - methods: { - validate() { - // return this.jiraProjectId !== '' && this.username !== '' && this.token !== '' && validateURL(this.url); - return this.token !== ''; +export default Record( + { + projectId: undefined, + provider: 'github', + token: '', }, - exists() { - return this.projectId !== undefined; + { + idKey: 'projectId', + fromJS: ({ projectId, ...config }) => ({ + ...config, + projectId: projectId === undefined ? projectId : `${projectId}`, + }), + methods: { + validate() { + // return this.jiraProjectId !== '' && this.username !== '' && this.token !== '' && validateURL(this.url); + return this.token !== ''; + }, + exists() { + return !!this.token; + }, + }, } - } -}); +); export const regionLabels = { - "us-east-1": "US East (N. Virginia)", - "us-east-2": "US East (Ohio)", - "us-west-1": "US West (N. California)", - "us-west-2": "US West (Oregon)", - "ap-east-1": "Asia Pacific (Hong Kong)", - "ap-south-1": "Asia Pacific (Mumbai)", - "ap-northeast-2": "Asia Pacific (Seoul)", - "ap-southeast-1": "Asia Pacific (Singapore)", - "ap-southeast-2": "Asia Pacific (Sydney)", - "ap-northeast-1": "Asia Pacific (Tokyo)", - "ca-central-1": "Canada (Central)", - "eu-central-1": "EU (Frankfurt)", - "eu-west-1": "EU (Ireland)", - "eu-west-2": "EU (London)", - "eu-west-3": "EU (Paris)", - "eu-north-1": "EU (Stockholm)", - "me-south-1": "Middle East (Bahrain)", - "sa-east-1": "South America (São Paulo)" + 'us-east-1': 'US East (N. Virginia)', + 'us-east-2': 'US East (Ohio)', + 'us-west-1': 'US West (N. California)', + 'us-west-2': 'US West (Oregon)', + 'ap-east-1': 'Asia Pacific (Hong Kong)', + 'ap-south-1': 'Asia Pacific (Mumbai)', + 'ap-northeast-2': 'Asia Pacific (Seoul)', + 'ap-southeast-1': 'Asia Pacific (Singapore)', + 'ap-southeast-2': 'Asia Pacific (Sydney)', + 'ap-northeast-1': 'Asia Pacific (Tokyo)', + 'ca-central-1': 'Canada (Central)', + 'eu-central-1': 'EU (Frankfurt)', + 'eu-west-1': 'EU (Ireland)', + 'eu-west-2': 'EU (London)', + 'eu-west-3': 'EU (Paris)', + 'eu-north-1': 'EU (Stockholm)', + 'me-south-1': 'Middle East (Bahrain)', + 'sa-east-1': 'South America (São Paulo)', }; diff --git a/frontend/app/types/integrations/jiraConfig.js b/frontend/app/types/integrations/jiraConfig.js index 60968745b..0f5b54d42 100644 --- a/frontend/app/types/integrations/jiraConfig.js +++ b/frontend/app/types/integrations/jiraConfig.js @@ -4,48 +4,51 @@ import { validateURL } from 'App/validate'; export const SECRET_ACCESS_KEY_LENGTH = 40; export const ACCESS_KEY_ID_LENGTH = 20; -export default Record({ - projectId: undefined, - username: '', - token: '', - url: '', - // jiraProjectId: '', -}, { - idKey: 'projectId', - fromJS: ({ projectId, ...config }) => ({ - ...config, - projectId: projectId === undefined ? projectId : `${ projectId }`, - }), - methods: { - validateFetchProjects() { - return this.username !== '' && this.token !== '' && validateURL(this.url); +export default Record( + { + projectId: undefined, + username: '', + token: '', + url: '', + // jiraProjectId: '', }, - validate() { - return this.username !== '' && this.token !== '' && validateURL(this.url); - }, - exists() { - return this.projectId !== undefined; + { + idKey: 'projectId', + fromJS: ({ projectId, ...config }) => ({ + ...config, + projectId: projectId === undefined ? projectId : `${projectId}`, + }), + methods: { + validateFetchProjects() { + return this.username !== '' && this.token !== '' && validateURL(this.url); + }, + validate() { + return this.username !== '' && this.token !== '' && validateURL(this.url); + }, + exists() { + return !!this.token; + }, + }, } - } -}); +); export const regionLabels = { - "us-east-1": "US East (N. Virginia)", - "us-east-2": "US East (Ohio)", - "us-west-1": "US West (N. California)", - "us-west-2": "US West (Oregon)", - "ap-east-1": "Asia Pacific (Hong Kong)", - "ap-south-1": "Asia Pacific (Mumbai)", - "ap-northeast-2": "Asia Pacific (Seoul)", - "ap-southeast-1": "Asia Pacific (Singapore)", - "ap-southeast-2": "Asia Pacific (Sydney)", - "ap-northeast-1": "Asia Pacific (Tokyo)", - "ca-central-1": "Canada (Central)", - "eu-central-1": "EU (Frankfurt)", - "eu-west-1": "EU (Ireland)", - "eu-west-2": "EU (London)", - "eu-west-3": "EU (Paris)", - "eu-north-1": "EU (Stockholm)", - "me-south-1": "Middle East (Bahrain)", - "sa-east-1": "South America (São Paulo)" + 'us-east-1': 'US East (N. Virginia)', + 'us-east-2': 'US East (Ohio)', + 'us-west-1': 'US West (N. California)', + 'us-west-2': 'US West (Oregon)', + 'ap-east-1': 'Asia Pacific (Hong Kong)', + 'ap-south-1': 'Asia Pacific (Mumbai)', + 'ap-northeast-2': 'Asia Pacific (Seoul)', + 'ap-southeast-1': 'Asia Pacific (Singapore)', + 'ap-southeast-2': 'Asia Pacific (Sydney)', + 'ap-northeast-1': 'Asia Pacific (Tokyo)', + 'ca-central-1': 'Canada (Central)', + 'eu-central-1': 'EU (Frankfurt)', + 'eu-west-1': 'EU (Ireland)', + 'eu-west-2': 'EU (London)', + 'eu-west-3': 'EU (Paris)', + 'eu-north-1': 'EU (Stockholm)', + 'me-south-1': 'Middle East (Bahrain)', + 'sa-east-1': 'South America (São Paulo)', }; diff --git a/frontend/app/types/session/issue.js b/frontend/app/types/session/issue.js index 31214ae3e..d2afff190 100644 --- a/frontend/app/types/session/issue.js +++ b/frontend/app/types/session/issue.js @@ -3,15 +3,18 @@ import { List } from 'immutable'; import Watchdog from 'Types/watchdog' export const issues_types = List([ - { 'type': 'js_exception', 'visible': true, 'order': 0, 'name': 'Errors', 'icon': 'funnel/exclamation-circle' }, - { 'type': 'bad_request', 'visible': true, 'order': 1, 'name': 'Bad Requests', 'icon': 'funnel/file-medical-alt' }, - { 'type': 'missing_resource', 'visible': true, 'order': 2, 'name': 'Missing Images', 'icon': 'funnel/image' }, - { 'type': 'click_rage', 'visible': true, 'order': 3, 'name': 'Click Rage', 'icon': 'funnel/emoji-angry' }, - { 'type': 'dead_click', 'visible': true, 'order': 4, 'name': 'Dead Clicks', 'icon': 'funnel/dizzy' }, - { 'type': 'memory', 'visible': true, 'order': 5, 'name': 'High Memory', 'icon': 'funnel/sd-card' }, - { 'type': 'cpu', 'visible': true, 'order': 6, 'name': 'High CPU', 'icon': 'funnel/cpu' }, - { 'type': 'crash', 'visible': true, 'order': 7, 'name': 'Crashes', 'icon': 'funnel/file-earmark-break' }, - { 'type': 'custom', 'visible': false, 'order': 8, 'name': 'Custom', 'icon': 'funnel/exclamation-circle' } + { 'type': 'all', 'visible': true, 'order': 0, 'name': 'All', 'icon': '' }, + { 'type': 'js_exception', 'visible': true, 'order': 1, 'name': 'Errors', 'icon': 'funnel/exclamation-circle' }, + { 'type': 'click_rage', 'visible': true, 'order': 2, 'name': 'Click Rage', 'icon': 'funnel/emoji-angry' }, + { 'type': 'crash', 'visible': true, 'order': 3, 'name': 'Crashes', 'icon': 'funnel/file-earmark-break' }, + { 'type': 'memory', 'visible': true, 'order': 4, 'name': 'High Memory', 'icon': 'funnel/sd-card' }, + // { 'type': 'vault', 'visible': true, 'order': 5, 'name': 'Vault', 'icon': 'safe' }, + // { 'type': 'bookmark', 'visible': true, 'order': 5, 'name': 'Bookmarks', 'icon': 'safe' }, + // { 'type': 'bad_request', 'visible': true, 'order': 1, 'name': 'Bad Requests', 'icon': 'funnel/file-medical-alt' }, + // { 'type': 'missing_resource', 'visible': true, 'order': 2, 'name': 'Missing Images', 'icon': 'funnel/image' }, + // { 'type': 'dead_click', 'visible': true, 'order': 4, 'name': 'Dead Clicks', 'icon': 'funnel/dizzy' }, + // { 'type': 'cpu', 'visible': true, 'order': 6, 'name': 'High CPU', 'icon': 'funnel/cpu' }, + // { 'type': 'custom', 'visible': false, 'order': 8, 'name': 'Custom', 'icon': 'funnel/exclamation-circle' } ]).map(Watchdog) export const issues_types_map = {} @@ -37,7 +40,7 @@ export default Record({ fromJS: ({ type, ...rest }) => ({ ...rest, type, - icon: issues_types_map[type].icon, - name: issues_types_map[type].name, + icon: issues_types_map[type]?.icon, + name: issues_types_map[type]?.name, }), }); diff --git a/frontend/app/types/session/session.ts b/frontend/app/types/session/session.ts index a4ed48fe6..5eadadf4b 100644 --- a/frontend/app/types/session/session.ts +++ b/frontend/app/types/session/session.ts @@ -79,6 +79,8 @@ export default Record({ isIOS: false, revId: '', userSessionsCount: 0, + agentIds: [], + isCallActive: false }, { fromJS:({ startTs=0, diff --git a/frontend/app/types/site/site.js b/frontend/app/types/site/site.js index 38f8666a1..158855bbc 100644 --- a/frontend/app/types/site/site.js +++ b/frontend/app/types/site/site.js @@ -6,7 +6,7 @@ export const YELLOW = 'yellow'; export const GREEN = 'green'; export const STATUS_COLOR_MAP = { - [ RED ]: 'red', + [ RED ]: '#CC0000', [ YELLOW ]: 'orange', [ GREEN ]: 'green', } diff --git a/frontend/app/utils.ts b/frontend/app/utils.ts index 9765d69c3..52bf7c6ad 100644 --- a/frontend/app/utils.ts +++ b/frontend/app/utils.ts @@ -53,7 +53,7 @@ export const cutURL = (url, prefix = '.../') => `${prefix + url.split('/').slice export const escapeRegExp = (string) => string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -export function getRE(string, options) { +export function getRE(string: string, options: string) { let re; try { re = new RegExp(string, options); @@ -63,6 +63,20 @@ export function getRE(string, options) { return re; } +export const filterList = >( + list: T[], + searchQuery: string, + testKeys: string[], + searchCb?: (listItem: T, query: RegExp +) => boolean): T[] => { + if (searchQuery === '') return list; + const filterRE = getRE(searchQuery, 'i'); + let _list = list.filter((listItem: T) => { + return testKeys.some((key) => filterRE.test(listItem[key]) || searchCb?.(listItem, filterRE)); + }); + return _list; + } + export const getStateColor = (state) => { switch (state) { case 'passed': @@ -144,11 +158,15 @@ export function percentOf(part: number, whole: number): number { return whole > 0 ? (part * 100) / whole : 0; } -export function fileType(url) { - return url.split(/[#?]/)[0].split('.').pop().trim(); +export function fileType(url: string) { + const filename = url.split(/[#?]/) + if (!filename || filename.length == 0) return '' + const parts = filename[0].split('.') + if (!parts || parts.length == 0) return '' + return parts.pop().trim(); } -export function fileName(url) { +export function fileName(url: string) { if (url) { var m = url.toString().match(/.*\/(.+?)\./); if (m && m.length > 1) { @@ -239,10 +257,10 @@ export const isGreaterOrEqualVersion = (version, compareTo) => { return major > majorC || (major === majorC && minor > minorC) || (major === majorC && minor === minorC && patch >= patchC); }; -export const sliceListPerPage = (list, page, perPage = 10) => { +export const sliceListPerPage = >(list: T, page: number, perPage = 10): T => { const start = page * perPage; const end = start + perPage; - return list.slice(start, end); + return list.slice(start, end) as T; }; export const positionOfTheNumber = (min, max, value, length) => { @@ -324,8 +342,12 @@ export const fetchErrorCheck = async (response: any) => { export const cleanSessionFilters = (data: any) => { const { filters, ...rest } = data; const _fitlers = filters.filter((f: any) => { - if (f.operator === 'isAny' || f.operator === 'onAny') { return true } // ignore filter with isAny/onAny operator - if (Array.isArray(f.filters) && f.filters.length > 0) { return true } // ignore subfilters + if (f.operator === 'isAny' || f.operator === 'onAny') { + return true; + } // ignore filter with isAny/onAny operator + if (Array.isArray(f.filters) && f.filters.length > 0) { + return true; + } // ignore subfilters return f.value !== '' && Array.isArray(f.value) && f.value.length > 0; }); @@ -343,3 +365,18 @@ export const setSessionFilter = (filter: any) => { export const compareJsonObjects = (obj1: any, obj2: any) => { return JSON.stringify(obj1) === JSON.stringify(obj2); }; + +export const getInitials = (name: any) => { + const names = name.split(' '); + return names.slice(0, 2).map((n: any) => n[0]).join(''); +} +export function getTimelinePosition(value: any, scale: any) { + const pos = value * scale; + return pos > 100 ? 100 : pos; +} + +export function millisToMinutesAndSeconds(millis: any) { + const minutes = Math.floor(millis / 60000); + const seconds: any = ((millis % 60000) / 1000).toFixed(0); + return minutes + 'm' + (seconds < 10 ? '0' : '') + seconds + 's'; +} diff --git a/frontend/app/validate.js b/frontend/app/validate.js index 687091003..76d588ac9 100644 --- a/frontend/app/validate.js +++ b/frontend/app/validate.js @@ -36,7 +36,7 @@ export function validateName(value, options) { } = Object.assign({}, defaultOptions, options); if (typeof value !== 'string') return false; // throw Error? - if (!empty && value.trim() === '') return false; + if (!empty && value && value.trim() === '') return false; const charsRegex = admissibleChars ? `|${ admissibleChars.split('').map(escapeRegexp).join('|') }` diff --git a/frontend/env.js b/frontend/env.js deleted file mode 100644 index e8332bd82..000000000 --- a/frontend/env.js +++ /dev/null @@ -1,29 +0,0 @@ -require('dotenv').config() - -// TODO: (the problem is during the build time the frontend is isolated) -//const trackerInfo = require('../tracker/tracker/package.json'); - -const oss = { - name: 'oss', - PRODUCTION: true, - SENTRY_ENABLED: false, - SENTRY_URL: "", - CAPTCHA_ENABLED: process.env.CAPTCHA_ENABLED === 'true', - CAPTCHA_SITE_KEY: process.env.CAPTCHA_SITE_KEY, - ORIGIN: () => 'window.location.origin', - API_EDP: () => 'window.location.origin + "/api"', - ASSETS_HOST: () => 'window.location.origin + "/assets"', - VERSION: '1.7.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, - ICE_SERVERS: process.env.ICE_SERVERS, - TRACKER_VERSION: '3.5.15' // trackerInfo.version, -} - -module.exports = { - oss, -}; diff --git a/frontend/package.json b/frontend/package.json index bce5abbad..8fb7f651f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,6 +8,7 @@ "upload:minio": "node ./scripts/upload-minio.js", "deploy:minio": "yarn build:minio && yarn upload:minio", "lint": "eslint --fix app; exit 0", + "tsc": "tsc --noEmit --w --incremental false", "gen:constants": "node ./scripts/constants.js", "gen:icons": "node ./scripts/icons.ts", "gen:colors": "node ./scripts/colors.js", @@ -81,8 +82,10 @@ "@babel/preset-typescript": "^7.17.12", "@babel/runtime": "^7.17.9", "@openreplay/sourcemap-uploader": "^3.0.0", + "@types/luxon": "^3.0.0", "@types/react": "^18.0.9", "@types/react-dom": "^18.0.4", + "@types/react-redux": "^7.1.24", "@types/react-router-dom": "^5.3.3", "@typescript-eslint/eslint-plugin": "^5.24.0", "@typescript-eslint/parser": "^5.24.0", diff --git a/frontend/scripts/icons.ts b/frontend/scripts/icons.ts index a89a8c3a3..4ad35692d 100644 --- a/frontend/scripts/icons.ts +++ b/frontend/scripts/icons.ts @@ -68,8 +68,10 @@ const plugins = (removeFill = true) => { fs.writeFileSync(`${UI_DIRNAME}/SVG.tsx`, ` import React from 'react'; +export type IconNames = ${icons.map(icon => "'"+ icon.slice(0, -4) + "'").join(' | ')}; + interface Props { - name: string; + name: IconNames; size?: number | string; width?: number | string; height?: number | string; diff --git a/frontend/webpack.config.ts b/frontend/webpack.config.ts index 4972b3213..404531b55 100644 --- a/frontend/webpack.config.ts +++ b/frontend/webpack.config.ts @@ -55,30 +55,30 @@ const config: Configuration = { test: /\.css$/i, exclude: /node_modules/, use: [ - stylesHandler, - { - loader: "css-loader", - options: { - modules: { - mode: "local", - auto: true, - localIdentName: "[name]__[local]--[hash:base64:5]", - } - // url: { - // filter: (url: string) => { - // // Semantic-UI-CSS has an extra semi colon in one of the URL due to which CSS loader along - // // with webpack 5 fails to generate a build. - // // Below if condition is a hack. After Semantic-UI-CSS fixes this, one can replace use clause with just - // // use: ['style-loader', 'css-loader'] - // if (url.includes('charset=utf-8;;')) { - // return false; - // } - // return true; - // }, - // } - }, + stylesHandler, + { + loader: "css-loader", + options: { + modules: { + mode: "local", + auto: true, + localIdentName: "[name]__[local]--[hash:base64:5]", + } + // url: { + // filter: (url: string) => { + // // Semantic-UI-CSS has an extra semi colon in one of the URL due to which CSS loader along + // // with webpack 5 fails to generate a build. + // // Below if condition is a hack. After Semantic-UI-CSS fixes this, one can replace use clause with just + // // use: ['style-loader', 'css-loader'] + // if (url.includes('charset=utf-8;;')) { + // return false; + // } + // return true; + // }, + // } }, - 'postcss-loader' + }, + 'postcss-loader' ], }, // { @@ -116,7 +116,7 @@ const config: Configuration = { 'window.env.PRODUCTION': isDevelopment ? false : true, }), new HtmlWebpackPlugin({ - template: 'app/assets/index.html' + template: 'app/assets/index.html' }), new CopyWebpackPlugin({ patterns: [ diff --git a/mobs/LICENSE b/mobs/LICENSE new file mode 100644 index 000000000..15669f768 --- /dev/null +++ b/mobs/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2022 Asayer, Inc + +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/mobs/README.md b/mobs/README.md new file mode 100644 index 000000000..7c2cb17ac --- /dev/null +++ b/mobs/README.md @@ -0,0 +1,14 @@ +# Message Object Binary Schema and Code Generator from Templates + + +To generate all necessary files for the project: + +```sh +ruby run.rb +``` + +In order format generated files run: +```sh +sh format.sh +``` +(Otherwise there will be changes in stage) diff --git a/mobs/format.sh b/mobs/format.sh new file mode 100644 index 000000000..b91c013fa --- /dev/null +++ b/mobs/format.sh @@ -0,0 +1 @@ +gofmt -w ../backend/pkg/messages \ No newline at end of file diff --git a/mobs/ios_messages.rb b/mobs/ios_messages.rb new file mode 100644 index 000000000..57e737714 --- /dev/null +++ b/mobs/ios_messages.rb @@ -0,0 +1,172 @@ +message 107, 'IOSBatchMeta', :replayer => false do + uint 'Timestamp' + uint 'Length' + uint 'FirstIndex' +end + +message 90, 'IOSSessionStart', :replayer => true do + uint 'Timestamp' + # uint 'Length' + + uint 'ProjectID' + string 'TrackerVersion' + string 'RevID' + string 'UserUUID' + # string 'UserAgent' + string 'UserOS' + string 'UserOSVersion' + # string 'UserBrowser' + # string 'UserBrowserVersion' + string 'UserDevice' + string 'UserDeviceType' + # uint 'UserDeviceMemorySize' + # uint 'UserDeviceHeapSize' + string 'UserCountry' +end + +message 91, 'IOSSessionEnd' do + uint 'Timestamp' +end + +message 92, 'IOSMetadata' do + uint 'Timestamp' + uint 'Length' + string 'Key' + string 'Value' +end + +message 93, 'IOSCustomEvent', :seq_index => true, :replayer => true do + uint 'Timestamp' + uint 'Length' + string 'Name' + string 'Payload' +end + +message 94, 'IOSUserID' do + uint 'Timestamp' + uint 'Length' + string 'Value' +end + +message 95, 'IOSUserAnonymousID' do + uint 'Timestamp' + uint 'Length' + string 'Value' +end + +message 96, 'IOSScreenChanges', :replayer => true do + uint 'Timestamp' + uint 'Length' + uint 'X' + uint 'Y' + uint 'Width' + uint 'Height' +end + +message 97, 'IOSCrash', :seq_index => true do + uint 'Timestamp' + uint 'Length' + string 'Name' + string 'Reason' + string 'Stacktrace' +end + +message 98, 'IOSScreenEnter', :seq_index => true do + uint 'Timestamp' + uint 'Length' + string 'Title' + string 'ViewName' +end + +message 99, 'IOSScreenLeave' do + uint 'Timestamp' + uint 'Length' + string 'Title' + string 'ViewName' +end + +message 100, 'IOSClickEvent', :seq_index => true, :replayer => true do + uint 'Timestamp' + uint 'Length' + string 'Label' + uint 'X' + uint 'Y' +end + +message 101, 'IOSInputEvent', :seq_index => true do + uint 'Timestamp' + uint 'Length' + string 'Value' + boolean 'ValueMasked' + string 'Label' +end + +=begin +Name/Value may be : +"physicalMemory": Total memory in bytes +"processorCount": Total processors in device +?"activeProcessorCount": Number of currently used processors +"systemUptime": Elapsed time (in seconds) since last boot +?"isLowPowerModeEnabled": Possible values (1 or 0) +2/3!"thermalState": Possible values (0:nominal 1:fair 2:serious 3:critical) +!"batteryLevel": Possible values (0 .. 100) +"batteryState": Possible values (0:unknown 1:unplugged 2:charging 3:full) +"orientation": Possible values (0unknown 1:portrait 2:portraitUpsideDown 3:landscapeLeft 4:landscapeRight 5:faceUp 6:faceDown) +"mainThreadCPU": Possible values (0 .. 100) +"memoryUsage": Used memory in bytes +=end +message 102, 'IOSPerformanceEvent', :replayer => true, :seq_index => true do + uint 'Timestamp' + uint 'Length' + string 'Name' + uint 'Value' +end + +message 103, 'IOSLog', :replayer => true do + uint 'Timestamp' + uint 'Length' + string 'Severity' # Possible values ("info", "error") + string 'Content' +end + +message 104, 'IOSInternalError' do + uint 'Timestamp' + uint 'Length' + string 'Content' +end + +message 105, 'IOSNetworkCall', :replayer => true, :seq_index => true do + uint 'Timestamp' + uint 'Length' + uint 'Duration' + string 'Headers' + string 'Body' + string 'URL' + boolean 'Success' + string 'Method' + uint 'Status' +end +message 110, 'IOSPerformanceAggregated', :swift => false do + uint 'TimestampStart' + uint 'TimestampEnd' + uint 'MinFPS' + uint 'AvgFPS' + uint 'MaxFPS' + uint 'MinCPU' + uint 'AvgCPU' + uint 'MaxCPU' + uint 'MinMemory' + uint 'AvgMemory' + uint 'MaxMemory' + uint 'MinBattery' + uint 'AvgBattery' + uint 'MaxBattery' +end + +message 111, 'IOSIssueEvent', :seq_index => true do + uint 'Timestamp' + string 'Type' + string 'ContextString' + string 'Context' + string 'Payload' +end diff --git a/mobs/messages.rb b/mobs/messages.rb new file mode 100644 index 000000000..61f141121 --- /dev/null +++ b/mobs/messages.rb @@ -0,0 +1,457 @@ +# Special one for Batch Metadata. Message id could define the version + +# Depricated since tracker 3.6.0 in favor of BatchMetadata +message 80, 'BatchMeta', :replayer => false, :tracker => false do + uint 'PageNo' + uint 'FirstIndex' + int 'Timestamp' +end + +# since tracker 3.6.0 TODO: for webworker only +message 81, 'BatchMetadata', :replayer => false do + uint 'Version' + uint 'PageNo' + uint 'FirstIndex' + int 'Timestamp' + string 'Location' +end + +# since tracker 3.6.0 +message 82, 'PartitionedMessage', :replayer => false do + uint 'PartNo' + uint 'PartTotal' +end + + +message 0, 'Timestamp' do + uint 'Timestamp' +end +message 1, 'SessionStart', :tracker => false, :replayer => false do + uint 'Timestamp' + uint 'ProjectID' + string 'TrackerVersion' + string 'RevID' + string 'UserUUID' + string 'UserAgent' + string 'UserOS' + string 'UserOSVersion' + string 'UserBrowser' + string 'UserBrowserVersion' + string 'UserDevice' + string 'UserDeviceType' + uint 'UserDeviceMemorySize' + uint 'UserDeviceHeapSize' + string 'UserCountry' + string 'UserID' +end +# message 2, 'CreateDocument', do +# end +message 3, 'SessionEnd', :tracker => false, :replayer => false do + uint 'Timestamp' +end +message 4, 'SetPageLocation' do + string 'URL' + string 'Referrer' + uint 'NavigationStart' +end +message 5, 'SetViewportSize' do + uint 'Width' + uint 'Height' +end +message 6, 'SetViewportScroll' do + int 'X' + int 'Y' +end +# Depricated sinse tracker 3.6.0 in favor of CreateDocument(id=2) +# in order to use Document as a default root node instead of the documentElement +message 7, 'CreateDocument' do +end +message 8, 'CreateElementNode' do + uint 'ID' + uint 'ParentID' + uint 'index' + string 'Tag' + boolean 'SVG' +end +message 9, 'CreateTextNode' do + uint 'ID' + uint 'ParentID' + uint 'Index' +end +message 10, 'MoveNode' do + uint 'ID' + uint 'ParentID' + uint 'Index' +end +message 11, 'RemoveNode' do + uint 'ID' +end +message 12, 'SetNodeAttribute' do + uint 'ID' + string 'Name' + string 'Value' +end +message 13, 'RemoveNodeAttribute' do + uint 'ID' + string 'Name' +end +message 14, 'SetNodeData' do + uint 'ID' + string 'Data' +end +message 15, 'SetCSSData', :tracker => false do + uint 'ID' + string 'Data' +end +message 16, 'SetNodeScroll' do + uint 'ID' + int 'X' + int 'Y' +end +message 17, 'SetInputTarget', :replayer => false do + uint 'ID' + string 'Label' +end +message 18, 'SetInputValue' do + uint 'ID' + string 'Value' + int 'Mask' +end +message 19, 'SetInputChecked' do + uint 'ID' + boolean 'Checked' +end +message 20, 'MouseMove' do + uint 'X' + uint 'Y' +end +# Depricated since OpenReplay 1.2.0 (tracker version?) +message 21, 'MouseClickDepricated', :tracker => false, :replayer => false do + uint 'ID' + uint 'HesitationTime' + string 'Label' +end +message 22, 'ConsoleLog' do + string 'Level' + string 'Value' +end +message 23, 'PageLoadTiming', :replayer => false do + uint 'RequestStart' + uint 'ResponseStart' + uint 'ResponseEnd' + uint 'DomContentLoadedEventStart' + uint 'DomContentLoadedEventEnd' + uint 'LoadEventStart' + uint 'LoadEventEnd' + uint 'FirstPaint' + uint 'FirstContentfulPaint' +end +message 24, 'PageRenderTiming', :replayer => false do + uint 'SpeedIndex' + uint 'VisuallyComplete' + uint 'TimeToInteractive' +end +message 25, 'JSException', :replayer => false do + string 'Name' + string 'Message' + string 'Payload' +end +message 26, 'IntegrationEvent', :tracker => false, :replayer => false do + uint 'Timestamp' + string 'Source' + string 'Name' + string 'Message' + string 'Payload' +end +message 27, 'RawCustomEvent', :replayer => false do + string 'Name' + string 'Payload' +end +message 28, 'UserID', :replayer => false do + string 'ID' +end +message 29, 'UserAnonymousID', :replayer => false do + string 'ID' +end +message 30, 'Metadata', :replayer => false do + string 'Key' + string 'Value' +end +message 31, 'PageEvent', :tracker => false, :replayer => false do + uint 'MessageID' + uint 'Timestamp' + string 'URL' + string 'Referrer' + boolean 'Loaded' + uint 'RequestStart' + uint 'ResponseStart' + uint 'ResponseEnd' + uint 'DomContentLoadedEventStart' + uint 'DomContentLoadedEventEnd' + uint 'LoadEventStart' + uint 'LoadEventEnd' + uint 'FirstPaint' + uint 'FirstContentfulPaint' + uint 'SpeedIndex' + uint 'VisuallyComplete' + uint 'TimeToInteractive' +end +message 32, 'InputEvent', :tracker => false, :replayer => false do + uint 'MessageID' + uint 'Timestamp' + string 'Value' + boolean 'ValueMasked' + string 'Label' +end +message 33, 'ClickEvent', :tracker => false, :replayer => false do + uint 'MessageID' + uint 'Timestamp' + uint 'HesitationTime' + string 'Label' + string 'Selector' +end +message 34, 'ErrorEvent', :tracker => false, :replayer => false do + uint 'MessageID' + uint 'Timestamp' + string 'Source' + string 'Name' + string 'Message' + string 'Payload' +end +message 35, 'ResourceEvent', :tracker => false, :replayer => false do + uint 'MessageID' + uint 'Timestamp' + uint 'Duration' + uint 'TTFB' + uint 'HeaderSize' + uint 'EncodedBodySize' + uint 'DecodedBodySize' + string 'URL' + string 'Type' + boolean 'Success' + string 'Method' + uint 'Status' +end +message 36, 'CustomEvent', :tracker => false, :replayer => false do + uint 'MessageID' + uint 'Timestamp' + string 'Name' + string 'Payload' +end + + +message 37, 'CSSInsertRule' do + uint 'ID' + string 'Rule' + uint 'Index' +end +message 38, 'CSSDeleteRule' do + uint 'ID' + uint 'Index' +end + +message 39, 'Fetch' do + string 'Method' + string 'URL' + string 'Request' + string 'Response' + uint 'Status' + uint 'Timestamp' + uint 'Duration' +end +message 40, 'Profiler' do + string 'Name' + uint 'Duration' + string 'Args' + string 'Result' +end + +message 41, 'OTable' do + string 'Key' + string 'Value' +end +message 42, 'StateAction', :replayer => false do + string 'Type' +end +message 43, 'StateActionEvent', :tracker => false, :replayer => false do + uint 'MessageID' + uint 'Timestamp' + string 'Type' +end + +message 44, 'Redux' do + string 'Action' + string 'State' + uint 'Duration' +end +message 45, 'Vuex' do + string 'Mutation' + string 'State' +end +message 46, 'MobX' do + string 'Type' + string 'Payload' +end +message 47, 'NgRx' do + string 'Action' + string 'State' + uint 'Duration' +end +message 48, 'GraphQL' do + string 'OperationKind' + string 'OperationName' + string 'Variables' + string 'Response' +end +message 49, 'PerformanceTrack' do + int 'Frames' + int 'Ticks' + uint 'TotalJSHeapSize' + uint 'UsedJSHeapSize' +end +message 50, 'GraphQLEvent', :tracker => false, :replayer => false do + uint 'MessageID' + uint 'Timestamp' + string 'OperationKind' + string 'OperationName' + string 'Variables' + string 'Response' +end +message 51, 'FetchEvent', :tracker => false, :replayer => false do + uint 'MessageID' + uint 'Timestamp' + string 'Method' + string 'URL' + string 'Request' + string 'Response' + uint 'Status' + uint 'Duration' +end +message 52, 'DOMDrop', :tracker => false, :replayer => false do + uint 'Timestamp' +end +message 53, 'ResourceTiming', :replayer => false do + uint 'Timestamp' + uint 'Duration' + uint 'TTFB' + uint 'HeaderSize' + uint 'EncodedBodySize' + uint 'DecodedBodySize' + string 'URL' + string 'Initiator' +end +message 54, 'ConnectionInformation' do + uint 'Downlink' + string 'Type' +end +message 55, 'SetPageVisibility' do + boolean 'hidden' +end +message 56, 'PerformanceTrackAggr', :tracker => false, :replayer => false do + uint 'TimestampStart' + uint 'TimestampEnd' + uint 'MinFPS' + uint 'AvgFPS' + uint 'MaxFPS' + uint 'MinCPU' + uint 'AvgCPU' + uint 'MaxCPU' + uint 'MinTotalJSHeapSize' + uint 'AvgTotalJSHeapSize' + uint 'MaxTotalJSHeapSize' + uint 'MinUsedJSHeapSize' + uint 'AvgUsedJSHeapSize' + uint 'MaxUsedJSHeapSize' +end +message 59, 'LongTask' do + uint 'Timestamp' + uint 'Duration' + uint 'Context' + uint 'ContainerType' + string 'ContainerSrc' + string 'ContainerId' + string 'ContainerName' +end +message 60, 'SetNodeAttributeURLBased' do + uint 'ID' + string 'Name' + string 'Value' + string 'BaseURL' +end +# Might replace SetCSSData (although BaseURL is useless after rewriting) +message 61, 'SetCSSDataURLBased' do + uint 'ID' + string 'Data' + string 'BaseURL' +end +message 62, 'IssueEvent', :replayer => false, :tracker => false do + uint 'MessageID' + uint 'Timestamp' + string 'Type' + string 'ContextString' + string 'Context' + string 'Payload' +end +message 63, 'TechnicalInfo', :replayer => false do + string 'Type' + string 'Value' +end +message 64, 'CustomIssue', :replayer => false do + string 'Name' + string 'Payload' +end +message 66, 'AssetCache', :replayer => false, :tracker => false do + string 'URL' +end +message 67, 'CSSInsertRuleURLBased' do + uint 'ID' + string 'Rule' + uint 'Index' + string 'BaseURL' +end +message 69, 'MouseClick' do + uint 'ID' + uint 'HesitationTime' + string 'Label' + string 'Selector' +end + +# Since 3.4.0 +message 70, 'CreateIFrameDocument' do + uint 'FrameID' + uint 'ID' +end + +#Since 3.6.0 AdoptedStyleSheets +message 71, 'AdoptedSSReplaceURLBased' do + uint 'SheetID' + string 'Text' + string 'BaseURL' +end +message 72, 'AdoptedSSReplace', :tracker => false do + uint 'SheetID' + string 'Text' +end +message 73, 'AdoptedSSInsertRuleURLBased' do + uint 'SheetID' + string 'Rule' + uint 'Index' + string 'BaseURL' +end +message 74, 'AdoptedSSInsertRule', :tracker => false do + uint 'SheetID' + string 'Rule' + uint 'Index' +end +message 75, 'AdoptedSSDeleteRule' do + uint 'SheetID' + uint 'Index' +end +message 76, 'AdoptedSSAddOwner' do + uint 'SheetID' + uint 'ID' +end +message 77, 'AdoptedSSRemoveOwner' do + uint 'SheetID' + uint 'ID' +end diff --git a/mobs/primitives/primitives.go b/mobs/primitives/primitives.go new file mode 100644 index 000000000..939fbda9c --- /dev/null +++ b/mobs/primitives/primitives.go @@ -0,0 +1,124 @@ +package messages + +import ( + "errors" + "io" +) + +func ReadByte(reader io.Reader) (byte, error) { + p := make([]byte, 1) + _, err := io.ReadFull(reader, p) + if err != nil { + return 0, err + } + return p[0], nil +} + +func SkipBytes(reader io.ReadSeeker) error { + n, err := ReadUint(reader) + if err != nil { + return err + } + _, err = reader.Seek(int64(n), io.SeekCurrent) + return err +} + +func ReadData(reader io.Reader) ([]byte, error) { + n, err := ReadUint(reader) + if err != nil { + return nil, err + } + p := make([]byte, n) + _, err = io.ReadFull(reader, p) + if err != nil { + return nil, err + } + return p, nil +} + +func ReadUint(reader io.Reader) (uint64, error) { + var x uint64 + var s uint + i := 0 + for { + b, err := ReadByte(reader) + if err != nil { + return x, err + } + if b < 0x80 { + if i > 9 || i == 9 && b > 1 { + return x, errors.New("overflow") + } + return x | uint64(b)<> 1) + if err != nil { + return x, err + } + if ux&1 != 0 { + x = ^x + } + return x, err +} + +func ReadBoolean(reader io.Reader) (bool, error) { + p := make([]byte, 1) + _, err := io.ReadFull(reader, p) + if err != nil { + return false, err + } + return p[0] == 1, nil +} + +func ReadString(reader io.Reader) (string, error) { + l, err := ReadUint(reader) + if err != nil { + return "", err + } + buf := make([]byte, l) + _, err = io.ReadFull(reader, buf) + if err != nil { + return "", err + } + return string(buf), nil +} + +func WriteUint(v uint64, buf []byte, p int) int { + for v >= 0x80 { + buf[p] = byte(v) | 0x80 + v >>= 7 + p++ + } + buf[p] = byte(v) + return p + 1 +} + +func WriteInt(v int64, buf []byte, p int) int { + uv := uint64(v) << 1 + if v < 0 { + uv = ^uv + } + return WriteUint(uv, buf, p) +} + +func WriteBoolean(v bool, buf []byte, p int) int { + if v { + buf[p] = 1 + } else { + buf[p] = 0 + } + return p + 1 +} + +func WriteString(str string, buf []byte, p int) int { + p = WriteUint(uint64(len(str)), buf, p) + return p + copy(buf[p:], str) +} diff --git a/mobs/primitives/primitives.py b/mobs/primitives/primitives.py new file mode 100644 index 000000000..5aeb0e4ed --- /dev/null +++ b/mobs/primitives/primitives.py @@ -0,0 +1,62 @@ +import io + +class Codec: + """ + Implements encode/decode primitives + """ + + @staticmethod + def read_boolean(reader: io.BytesIO): + b = reader.read(1) + return b == 1 + + @staticmethod + def read_uint(reader: io.BytesIO): + """ + The ending "big" doesn't play any role here, + since we're dealing with data per one byte + """ + x = 0 # the result + s = 0 # the shift (our result is big-ending) + i = 0 # n of byte (max 9 for uint64) + while True: + b = reader.read(1) + num = int.from_bytes(b, "big", signed=False) + # print(i, x) + + if num < 0x80: + if i > 9 | i == 9 & num > 1: + raise OverflowError() + return int(x | num << s) + x |= (num & 0x7f) << s + s += 7 + i += 1 + + @staticmethod + def read_int(reader: io.BytesIO) -> int: + """ + ux, err := ReadUint(reader) + x := int64(ux >> 1) + if err != nil { + return x, err + } + if ux&1 != 0 { + x = ^x + } + return x, err + """ + ux = Codec.read_uint(reader) + x = int(ux >> 1) + + if ux & 1 != 0: + x = - x - 1 + return x + + @staticmethod + def read_string(reader: io.BytesIO) -> str: + length = Codec.read_uint(reader) + s = reader.read(length) + try: + return s.decode("utf-8", errors="replace").replace("\x00", "\uFFFD") + except UnicodeDecodeError: + return None diff --git a/mobs/primitives/primitives.swift b/mobs/primitives/primitives.swift new file mode 100644 index 000000000..b7d66d0f9 --- /dev/null +++ b/mobs/primitives/primitives.swift @@ -0,0 +1,60 @@ +extension Data { + func readByte(offset: inout Int) -> UInt8 { + if offset >= self.count { + fatalError(">>> Error reading Byte") + } + let b = self[offset] + offset += 1 + return b + } + func readUint(offset: inout Int) -> UInt64 { + var x: UInt64 = 0 + var s: Int = 0 + var i: Int = 0 + while true { + let b = readByte(offset: &offset) + if b < 0x80 { + if i > 9 || i == 9 && b > 1 { + fatalError(">>> Error reading UInt") + } + return x | UInt64(b)< Int64 { + let ux = readUint(offset: &offset) + var x = Int64(ux >> 1) + if ux&1 != 0 { + x = ~x + } + return x + } + func readBoolean(offset: inout Int) -> Bool { + return readByte(offset: &offset) == 1 + } + mutating func writeUint(_ input: UInt64) { + var v = input + while v >= 0x80 { + append(UInt8(v.littleEndian & 0x7F) | 0x80) // v.littleEndian ? + v >>= 7 + } + append(UInt8(v)) + } + mutating func writeInt(_ v: Int64) { + var uv = UInt64(v) << 1 + if v < 0 { + uv = ~uv + } + writeUint(uv) + } + mutating func writeBoolean(_ v: Bool) { + if v { + append(1) + } else { + append(0) + } + } +} \ No newline at end of file diff --git a/mobs/run.rb b/mobs/run.rb new file mode 100644 index 000000000..67a9b4eea --- /dev/null +++ b/mobs/run.rb @@ -0,0 +1,137 @@ +require 'erb' + + +# TODO: change method names to correct (CapitalCase and camelCase, not CamalCase and firstLower) +class String + def upperize_abbreviations + self.sub('Id', 'ID').sub('Url', 'URL') + end + + # pascal_case + def pascal_case + return self if self !~ /_/ && self =~ /[A-Z]+.*/ + split('_').map{|e| e.capitalize}.join.upperize_abbreviations + end + + # camelCase + def camel_case + self.sub(/^[A-Z]+/) {|f| f.downcase } + end + + # snake_case + def snake_case + self.gsub(/::/, '/'). + gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2'). + gsub(/([a-z\d])([A-Z])/,'\1_\2'). + tr("-", "_"). + downcase + end +end + +class Attribute + attr_reader :name, :type + def initialize(name:, type:) + @name = name + @type = type + end + + def type_js + case @type + when :int + "number" + when :uint + "number" + when :json + # TODO + # raise "Unexpected attribute type: data type attribute #{@name} found in JS template" + "string" + when :data + raise "Unexpected attribute type: data type attribute #{@name} found in JS template" + else + @type + end + end + + def type_go + case @type + when :int + 'int64' + when :uint + 'uint64' + when :string + 'string' + when :data + '[]byte' + when :boolean + 'bool' + when :json + 'interface{}' + end + end + + def lengh_encoded + case @type + when :string, :data + true + else + false + end + end + +end + + +$context = :web + +class Message + attr_reader :id, :name, :tracker, :replayer, :swift, :seq_index, :attributes, :context + def initialize(name:, id:, tracker: $context == :web, replayer: $context == :web, swift: $context == :ios, seq_index: false, &block) + @id = id + @name = name + @tracker = tracker + @replayer = replayer + @swift = swift + @seq_index = seq_index + @context = $context + @attributes = [] + # opts.each { |key, value| send "#{key}=", value } + instance_eval &block + end + + %i(int uint boolean string data).each do |type| + define_method type do |name, opts = {}| + opts.merge!( + name: name, + type: type, + ) + @attributes << Attribute.new(opts) + end + end +end + +$ids = [] +$messages = [] +def message(id, name, opts = {}, &block) + raise "id duplicated #{name}" if $ids.include? id + raise "id is too big #{name}" if id > 120 + $ids << id + opts[:id] = id + opts[:name] = name + msg = Message.new(opts, &block) + $messages << msg +end + +require './messages.rb' + +$context = :ios +require './ios_messages.rb' + +Dir["templates/*.erb"].each do |tpl| + e = ERB.new(File.read(tpl)) + path = tpl.split '/' + t = '../' + path[1].gsub('~', '/') + t = t[0..-5] + # TODO: .gen subextention + File.write(t, e.result) + puts tpl + ' --> ' + t +end diff --git a/mobs/templates/backend~pkg~messages~filters.go.erb b/mobs/templates/backend~pkg~messages~filters.go.erb new file mode 100644 index 000000000..ac4ba9cba --- /dev/null +++ b/mobs/templates/backend~pkg~messages~filters.go.erb @@ -0,0 +1,10 @@ +// Auto-generated, do not edit +package messages + +func IsReplayerType(id int) bool { + return <%= $messages.select { |msg| msg.replayer }.map{ |msg| "#{msg.id} == id" }.join(' || ') %> +} + +func IsIOSType(id int) bool { + return <%= $messages.select { |msg| msg.context == :ios }.map{ |msg| "#{msg.id} == id"}.join(' || ') %> +} diff --git a/mobs/templates/backend~pkg~messages~get-timestamp.go.erb b/mobs/templates/backend~pkg~messages~get-timestamp.go.erb new file mode 100644 index 000000000..f4a634dea --- /dev/null +++ b/mobs/templates/backend~pkg~messages~get-timestamp.go.erb @@ -0,0 +1,12 @@ +// Auto-generated, do not edit +package messages + +func GetTimestamp(message Message) uint64 { + switch msg := message.(type) { +<% $messages.select { |msg| msg.swift }.each do |msg| %> + case *<%= msg.name %>: + return msg.Timestamp +<% end %> + } + return uint64(message.Meta().Timestamp) +} diff --git a/mobs/templates/backend~pkg~messages~messages.go.erb b/mobs/templates/backend~pkg~messages~messages.go.erb new file mode 100644 index 000000000..26a3ec4c0 --- /dev/null +++ b/mobs/templates/backend~pkg~messages~messages.go.erb @@ -0,0 +1,46 @@ +// Auto-generated, do not edit +package messages + +import "encoding/binary" + +const ( +<% $messages.each do |msg| %> + Msg<%= msg.name %> = <%= msg.id %> +<% end %> +) + +<% $messages.each do |msg| %> +type <%= msg.name %> struct { + message +<%= msg.attributes.map { |attr| +" #{attr.name} #{attr.type_go}" }.join "\n" %> +} + +func (msg *<%= msg.name %>) Encode() []byte { + buf := make([]byte, <%= msg.attributes.count * 10 + 1 %><%= msg.attributes.map { |attr| "+len(msg.#{attr.name})" if attr.lengh_encoded }.compact.join %>) + buf[0] = <%= msg.id %> + p := 1 +<%= msg.attributes.map { |attr| +" p = Write#{attr.type.to_s.pascal_case}(msg.#{attr.name}, buf, p)" }.join "\n" %> + return buf[:p] +} + +func (msg *<%= msg.name %>) EncodeWithIndex() []byte { + encoded := msg.Encode() + if IsIOSType(msg.TypeID()) { + return encoded + } + data := make([]byte, len(encoded)+8) + copy(data[8:], encoded[:]) + binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index) + return data +} + +func (msg *<%= msg.name %>) Decode() Message { + return msg +} + +func (msg *<%= msg.name %>) TypeID() int { + return <%= msg.id %> +} +<% end %> diff --git a/mobs/templates/backend~pkg~messages~read-message.go.erb b/mobs/templates/backend~pkg~messages~read-message.go.erb new file mode 100644 index 000000000..b123f87a7 --- /dev/null +++ b/mobs/templates/backend~pkg~messages~read-message.go.erb @@ -0,0 +1,30 @@ +// Auto-generated, do not edit +package messages + +import ( + "fmt" + "io" +) + +<% $messages.each do |msg| %> +func Decode<%= msg.name %>(reader io.Reader) (Message, error) { + var err error = nil + msg := &<%= msg.name %>{} + <%= msg.attributes.map { |attr| + " if msg.#{attr.name}, err = Read#{attr.type.to_s.pascal_case}(reader); err != nil { + return nil, err + }" }.join "\n" %> + return msg, err +} + +<% end %> + +func ReadMessage(t uint64, reader io.Reader) (Message, error) { + switch t { +<% $messages.each do |msg| %> + case <%= msg.id %>: + return Decode<%= msg.name %>(reader) +<% end %> + } + return nil, fmt.Errorf("Unknown message code: %v", t) +} diff --git a/mobs/templates/ee~connectors~msgcodec~messages.py.erb b/mobs/templates/ee~connectors~msgcodec~messages.py.erb new file mode 100644 index 000000000..c346acab9 --- /dev/null +++ b/mobs/templates/ee~connectors~msgcodec~messages.py.erb @@ -0,0 +1,16 @@ +# Auto-generated, do not edit + +from abc import ABC + +class Message(ABC): + pass + +<% $messages.each do |msg| %> +class <%= msg.name %>(Message): + __id__ = <%= msg.id %> + + def __init__(self, <%= msg.attributes.map { |attr| "#{attr.name.snake_case}" }.join ", " %>): + <%= msg.attributes.map { |attr| "self.#{attr.name.snake_case} = #{attr.name.snake_case}" }.join "\n " + %> + +<% end %> diff --git a/mobs/templates/ee~connectors~msgcodec~msgcodec.py.erb b/mobs/templates/ee~connectors~msgcodec~msgcodec.py.erb new file mode 100644 index 000000000..b55764ee9 --- /dev/null +++ b/mobs/templates/ee~connectors~msgcodec~msgcodec.py.erb @@ -0,0 +1,86 @@ +# Auto-generated, do not edit + +from msgcodec.codec import Codec +from msgcodec.messages import * +from typing import List +import io + +class MessageCodec(Codec): + + def read_message_id(self, reader: io.BytesIO) -> int: + """ + Read and return the first byte where the message id is encoded + """ + id_ = self.read_uint(reader) + return id_ + + def encode(self, m: Message) -> bytes: + ... + + def decode(self, b: bytes) -> Message: + reader = io.BytesIO(b) + return self.read_head_message(reader) + + @staticmethod + def check_message_id(b: bytes) -> int: + """ + todo: make it static and without reader. It's just the first byte + Read and return the first byte where the message id is encoded + """ + reader = io.BytesIO(b) + id_ = Codec.read_uint(reader) + + return id_ + + @staticmethod + def decode_key(b) -> int: + """ + Decode the message key (encoded with little endian) + """ + try: + decoded = int.from_bytes(b, "little", signed=False) + except Exception as e: + raise UnicodeDecodeError(f"Error while decoding message key (SessionID) from {b}\n{e}") + return decoded + + def decode_detailed(self, b: bytes) -> List[Message]: + reader = io.BytesIO(b) + messages_list = list() + messages_list.append(self.handler(reader, 0)) + if isinstance(messages_list[0], BatchMeta): + # Old BatchMeta + mode = 0 + elif isinstance(messages_list[0], BatchMetadata): + # New BatchMeta + mode = 1 + else: + return messages_list + while True: + try: + messages_list.append(self.handler(reader, mode)) + except IndexError: + break + return messages_list + + def handler(self, reader: io.BytesIO, mode=0) -> Message: + message_id = self.read_message_id(reader) + if mode == 1: + # We skip the three bytes representing the length of message. It can be used to skip unwanted messages + reader.read(3) + return self.read_head_message(reader, message_id) + elif mode == 0: + # Old format with no bytes for message length + return self.read_head_message(reader, message_id) + else: + raise IOError() + + def read_head_message(self, reader: io.BytesIO, message_id) -> Message: +<% $messages.each do |msg| %> + if message_id == <%= msg.id %>: + return <%= msg.name %>( + <%= msg.attributes.map { |attr| + "#{attr.name.snake_case}=self.read_#{attr.type.to_s}(reader)" } + .join ",\n " + %> + ) +<% end %> diff --git a/mobs/templates/frontend~app~player~MessageDistributor~messages~RawMessageReader.ts.erb b/mobs/templates/frontend~app~player~MessageDistributor~messages~RawMessageReader.ts.erb new file mode 100644 index 000000000..d1e533550 --- /dev/null +++ b/mobs/templates/frontend~app~player~MessageDistributor~messages~RawMessageReader.ts.erb @@ -0,0 +1,35 @@ +// Auto-generated, do not edit + +import PrimitiveReader from './PrimitiveReader' +import type { RawMessage } from './raw' + + +export default class RawMessageReader extends PrimitiveReader { + readMessage(): RawMessage | null { + const p = this.p + const resetPointer = () => { + this.p = p + return null + } + + const tp = this.readUint() + if (tp === null) { return resetPointer() } + + switch (tp) { + <% $messages.select { |msg| msg.replayer }.each do |msg| %> + case <%= msg.id %>: { +<%= msg.attributes.map { |attr| +" const #{attr.name.camel_case} = this.read#{attr.type.to_s.pascal_case}(); if (#{attr.name.camel_case} === null) { return resetPointer() }" }.join "\n" %> + return { + tp: "<%= msg.name.snake_case %>", +<%= msg.attributes.map { |attr| +" #{attr.name.camel_case}," }.join "\n" %> + }; + } + <% end %> + default: + throw new Error(`Unrecognizable message type: ${ tp }; Pointer at the position ${this.p} of ${this.buf.length}`) + return null; + } + } +} diff --git a/mobs/templates/frontend~app~player~MessageDistributor~messages~message.ts.erb b/mobs/templates/frontend~app~player~MessageDistributor~messages~message.ts.erb new file mode 100644 index 000000000..91c2cb9a5 --- /dev/null +++ b/mobs/templates/frontend~app~player~MessageDistributor~messages~message.ts.erb @@ -0,0 +1,13 @@ +// Auto-generated, do not edit + +import type { Timed } from './timed' +import type { RawMessage } from './raw' +import type { +<%= $messages.select { |msg| msg.replayer }.map { |msg| " Raw#{msg.name.snake_case.pascal_case}," }.join "\n" %> +} from './raw' + +export type Message = RawMessage & Timed + +<% $messages.select { |msg| msg.replayer }.each do |msg| %> +export type <%= msg.name.snake_case.pascal_case %> = Raw<%= msg.name.snake_case.pascal_case %> & Timed +<% end %> \ No newline at end of file diff --git a/mobs/templates/frontend~app~player~MessageDistributor~messages~raw.ts.erb b/mobs/templates/frontend~app~player~MessageDistributor~messages~raw.ts.erb new file mode 100644 index 000000000..b94ced335 --- /dev/null +++ b/mobs/templates/frontend~app~player~MessageDistributor~messages~raw.ts.erb @@ -0,0 +1,10 @@ +// Auto-generated, do not edit + +<% $messages.select { |msg| msg.replayer }.each do |msg| %> +export interface Raw<%= msg.name.snake_case.pascal_case %> { + tp: "<%= msg.name.snake_case %>", +<%= msg.attributes.map { |attr| " #{attr.name.camel_case}: #{attr.type_js}," }.join "\n" %> +} +<% end %> + +export type RawMessage = <%= $messages.select { |msg| msg.replayer }.map { |msg| "Raw#{msg.name.snake_case.pascal_case}" }.join " | " %>; diff --git a/mobs/templates/frontend~app~player~MessageDistributor~messages~tracker-legacy.ts.erb b/mobs/templates/frontend~app~player~MessageDistributor~messages~tracker-legacy.ts.erb new file mode 100644 index 000000000..586ee8cf3 --- /dev/null +++ b/mobs/templates/frontend~app~player~MessageDistributor~messages~tracker-legacy.ts.erb @@ -0,0 +1,8 @@ +// @ts-nocheck +// Auto-generated, do not edit + +export const TP_MAP = { +<%= $messages.select { |msg| msg.tracker || msg.replayer }.map { |msg| " #{msg.id}: \"#{msg.name.snake_case}\"," }.join "\n" %> +} as const + + diff --git a/mobs/templates/frontend~app~player~MessageDistributor~messages~tracker.ts.erb b/mobs/templates/frontend~app~player~MessageDistributor~messages~tracker.ts.erb new file mode 100644 index 000000000..0b3452b3b --- /dev/null +++ b/mobs/templates/frontend~app~player~MessageDistributor~messages~tracker.ts.erb @@ -0,0 +1,28 @@ +// Auto-generated, do not edit + +import type { RawMessage } from './raw' + +<% $messages.select { |msg| msg.tracker }.each do |msg| %> +type Tr<%= msg.name %> = [ + type: <%= msg.id %>, + <%= msg.attributes.map { |attr| "#{attr.name.camel_case}: #{attr.type_js}," }.join "\n " %> +] +<% end %> + +export type TrackerMessage = <%= $messages.select { |msg| msg.tracker }.map { |msg| "Tr#{msg.name}" }.join " | " %> + +export default function translate(tMsg: TrackerMessage): RawMessage | null { + switch(tMsg[0]) { + <% $messages.select { |msg| msg.replayer & msg.tracker }.each do |msg| %> + case <%= msg.id %>: { + return { + tp: "<%= msg.name.snake_case %>", + <%= msg.attributes.map.with_index { |attr, i| "#{attr.name.camel_case}: tMsg[#{i+1}]," }.join "\n " %> + } + } + <% end %> + default: + return null + } + +} \ No newline at end of file diff --git a/mobs/templates/ios/ASMessage.swift b/mobs/templates/ios/ASMessage.swift new file mode 100644 index 000000000..c0cababc8 --- /dev/null +++ b/mobs/templates/ios/ASMessage.swift @@ -0,0 +1,36 @@ +// Auto-generated, do not edit +import UIKit + +enum ASMessageType: UInt64 { +<%= $messages.map { |msg| " case #{msg.name.camel_case} = #{msg.id}" }.join "\n" %> +} +<% $messages.each do |msg| %> +class AS<%= msg.name.to_s.pascal_case %>: ASMessage { +<%= msg.attributes[2..-1].map { |attr| " let #{attr.property}: #{attr.type_swift}" }.join "\n" %> + + init(<%= msg.attributes[2..-1].map { |attr| "#{attr.property}: #{attr.type_swift}" }.join ", " %>) { +<%= msg.attributes[2..-1].map { |attr| " self.#{attr.property} = #{attr.property}" }.join "\n" %> + super.init(messageType: .<%= "#{msg.name.camel_case}" %>) + } + + override init?(genericMessage: GenericMessage) { + <% if msg.attributes.length > 2 %> do { + var offset = 0 +<%= msg.attributes[2..-1].map { |attr| " self.#{attr.property} = try genericMessage.body.read#{attr.type_swift_read}(offset: &offset)" }.join "\n" %> + super.init(genericMessage: genericMessage) + } catch { + return nil + } + <% else %> + super.init(genericMessage: genericMessage) + <% end %>} + + override func contentData() -> Data { + return Data(values: UInt64(<%= "#{msg.id}"%>), timestamp<% if msg.attributes.length > 2 %>, Data(values: <%= msg.attributes[2..-1].map { |attr| attr.property }.join ", "%>)<% end %>) + } + + override var description: String { + return "-->> <%= msg.name.to_s.pascal_case %>(<%= "#{msg.id}"%>): timestamp:\(timestamp) <%= msg.attributes[2..-1].map { |attr| "#{attr.property}:\\(#{attr.property})" }.join " "%>"; + } +} +<% end %> diff --git a/mobs/templates/tracker~tracker~src~common~messages.gen.ts.erb b/mobs/templates/tracker~tracker~src~common~messages.gen.ts.erb new file mode 100644 index 000000000..893e3878f --- /dev/null +++ b/mobs/templates/tracker~tracker~src~common~messages.gen.ts.erb @@ -0,0 +1,15 @@ +// Auto-generated, do not edit + +export declare const enum Type { + <%= $messages.select { |msg| msg.tracker }.map { |msg| "#{ msg.name } = #{ msg.id }," }.join "\n " %> +} + +<% $messages.select { |msg| msg.tracker }.each do |msg| %> +export type <%= msg.name %> = [ + /*type:*/ Type.<%= msg.name %>, + <%= msg.attributes.map { |attr| "/*#{attr.name.camel_case}:*/ #{attr.type_js}," }.join "\n " %> +] +<% end %> + +type Message = <%= $messages.select { |msg| msg.tracker }.map { |msg| "#{msg.name}" }.join " | " %> +export default Message diff --git a/mobs/templates/tracker~tracker~src~main~app~messages.gen.ts.erb b/mobs/templates/tracker~tracker~src~main~app~messages.gen.ts.erb new file mode 100644 index 000000000..1143bc5f4 --- /dev/null +++ b/mobs/templates/tracker~tracker~src~main~app~messages.gen.ts.erb @@ -0,0 +1,15 @@ +// Auto-generated, do not edit + +import * as Messages from '../../common/messages.gen.js' +export { default } from '../../common/messages.gen.js' + +<% $messages.select { |msg| msg.tracker }.each do |msg| %> +export function <%= msg.name %>( + <%= msg.attributes.map { |attr| "#{attr.name.camel_case}: #{attr.type_js}," }.join "\n " %> +): Messages.<%= msg.name %> { + return [ + Messages.Type.<%= msg.name %>, + <%= msg.attributes.map { |attr| "#{attr.name.camel_case}," }.join "\n " %> + ] +} +<% end %> diff --git a/mobs/templates/tracker~tracker~src~webworker~MessageEncoder.gen.ts.erb b/mobs/templates/tracker~tracker~src~webworker~MessageEncoder.gen.ts.erb new file mode 100644 index 000000000..e65bbe2f2 --- /dev/null +++ b/mobs/templates/tracker~tracker~src~webworker~MessageEncoder.gen.ts.erb @@ -0,0 +1,20 @@ +// Auto-generated, do not edit + +import * as Messages from '../common/messages.gen.js' +import Message from '../common/messages.gen.js' +import PrimitiveEncoder from './PrimitiveEncoder.js' + + +export default class MessageEncoder extends PrimitiveEncoder { + encode(msg: Message): boolean { + switch(msg[0]) { + <% $messages.select { |msg| msg.tracker }.each do |msg| %> + case Messages.Type.<%= msg.name %>: + return <% if msg.attributes.size == 0 %> true <% else %> <%= msg.attributes.map.with_index { |attr, index| "this.#{attr.type}(msg[#{index+1}])" }.join " && " %> <% end %> + break + <% end %> + } + } + +} + diff --git a/peers/Dockerfile b/peers/Dockerfile index b05fdee3a..c22e33f37 100644 --- a/peers/Dockerfile +++ b/peers/Dockerfile @@ -1,7 +1,6 @@ FROM node:18-alpine LABEL Maintainer="KRAIEM Taha Yassine" RUN apk add --no-cache tini -RUN apk upgrade busybox --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/main ARG envarg ENV ENTERPRISE_BUILD=${envarg} diff --git a/scripts/dockerfiles/alpine/Dockerfile b/scripts/dockerfiles/alpine/Dockerfile new file mode 100644 index 000000000..f3f03a617 --- /dev/null +++ b/scripts/dockerfiles/alpine/Dockerfile @@ -0,0 +1,2 @@ +FROM alpine +# Fix busybox vulnerability diff --git a/scripts/dockerfiles/ingress-controller/Dockerfile b/scripts/dockerfiles/ingress-controller/Dockerfile index 85f58d272..8572bca26 100644 --- a/scripts/dockerfiles/ingress-controller/Dockerfile +++ b/scripts/dockerfiles/ingress-controller/Dockerfile @@ -1,5 +1,4 @@ from k8s.gcr.io/ingress-nginx/controller:v1.3.0 # Fix critical vulnerability user 0 -RUN apk upgrade busybox --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/main user 101 diff --git a/scripts/dockerfiles/nginx/Dockerfile b/scripts/dockerfiles/nginx/Dockerfile index 8b24db9b7..45fef3550 100644 --- a/scripts/dockerfiles/nginx/Dockerfile +++ b/scripts/dockerfiles/nginx/Dockerfile @@ -1,5 +1,7 @@ FROM openresty/openresty:1.21.4.1-alpine +RUN apk add --no-cache curl + # Adding prometheus monitoring support ADD https://raw.githubusercontent.com/knyar/nginx-lua-prometheus/master/prometheus.lua /usr/local/openresty/lualib/ ADD https://raw.githubusercontent.com/knyar/nginx-lua-prometheus/master/prometheus_keys.lua /usr/local/openresty/lualib/ @@ -12,7 +14,7 @@ COPY nginx.conf /usr/local/openresty${RESTY_DEB_FLAVOR}/nginx/conf/nginx.conf COPY default.conf /etc/nginx/conf.d/default.conf COPY compression.conf /etc/nginx/conf.d/compression.conf COPY location.list /etc/nginx/conf.d/location.list -RUN chmod 0644 /usr/local/openresty${RESTY_DEB_FLAVOR}/nginx/conf/nginx.conf +RUN chmod 0644 /usr/local/openresty${RESTY_DEB_FLAVOR}/nginx/conf/nginx.conf RUN chown -R 1001 /var/run/openresty /usr/local/openresty USER 1001 diff --git a/scripts/dockerfiles/nginx/location.list b/scripts/dockerfiles/nginx/location.list index ef6b7841f..040dc722a 100644 --- a/scripts/dockerfiles/nginx/location.list +++ b/scripts/dockerfiles/nginx/location.list @@ -9,6 +9,9 @@ location /api/ { rewrite ^/api/(.*) /$1 break; + proxy_ssl_server_name on; + proxy_ssl_session_reuse on; + proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "Upgrade"; @@ -24,6 +27,9 @@ location / { proxy_intercept_errors on; # see frontend://nginx.org/en/docs/frontend/ngx_frontend_proxy_module.html#proxy_intercept_errors error_page 404 =200 /index.html; + proxy_ssl_server_name on; + proxy_ssl_session_reuse on; + proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "Upgrade"; diff --git a/scripts/helm/app/alerts.yaml b/scripts/helm/app/alerts.yaml index 59e6bc18b..8998f3a1f 100644 --- a/scripts/helm/app/alerts.yaml +++ b/scripts/helm/app/alerts.yaml @@ -27,6 +27,8 @@ env: pg_dbname: postgres pg_user: postgres pg_password: asayerPostgres + ch_host: clickhouse.db.svc.cluster.local + ch_port: 9000 EMAIL_HOST: '' EMAIL_PORT: '587' EMAIL_USER: '' diff --git a/scripts/helm/db/init_dbs/postgresql/1.8.0/1.8.0.sql b/scripts/helm/db/init_dbs/postgresql/1.8.0/1.8.0.sql new file mode 100644 index 000000000..b14b14f91 --- /dev/null +++ b/scripts/helm/db/init_dbs/postgresql/1.8.0/1.8.0.sql @@ -0,0 +1,50 @@ +BEGIN; +CREATE OR REPLACE FUNCTION openreplay_version() + RETURNS text AS +$$ +SELECT 'v1.8.0' +$$ LANGUAGE sql IMMUTABLE; + +ALTER TABLE IF EXISTS projects + ADD COLUMN IF NOT EXISTS first_recorded_session_at timestamp without time zone NULL DEFAULT NULL, + ADD COLUMN IF NOT EXISTS sessions_last_check_at timestamp without time zone NULL DEFAULT NULL; + + +DO +$$ + BEGIN + IF NOT EXISTS(SELECT * + FROM pg_type typ + INNER JOIN pg_namespace nsp + ON nsp.oid = typ.typnamespace + WHERE nsp.nspname = current_schema() + AND typ.typname = 'alert_change_type') THEN + CREATE TYPE alert_change_type AS ENUM ('percent', 'change'); + END IF; + END; +$$ +LANGUAGE plpgsql; + +ALTER TABLE IF EXISTS alerts + ADD COLUMN IF NOT EXISTS change alert_change_type NOT NULL DEFAULT 'change'; + +ALTER TABLE IF EXISTS sessions + ADD COLUMN IF NOT EXISTS referrer text NULL DEFAULT NULL, + ADD COLUMN IF NOT EXISTS base_referrer text NULL DEFAULT NULL; +CREATE INDEX IF NOT EXISTS sessions_base_referrer_gin_idx ON public.sessions USING GIN (base_referrer gin_trgm_ops); + +ALTER TABLE IF EXISTS events.performance + ADD COLUMN IF NOT EXISTS host text NULL DEFAULT NULL, + ADD COLUMN IF NOT EXISTS path text NULL DEFAULT NULL, + ADD COLUMN IF NOT EXISTS query text NULL DEFAULT NULL; + +COMMIT; + +CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS autocomplete_unique_project_id_md5value_type_idx ON autocomplete (project_id, md5(value), type); + +BEGIN; + +DROP INDEX IF EXISTS autocomplete_unique; +DROP INDEX IF EXISTS events_common.requests_response_body_nn_idx; +DROP INDEX IF EXISTS events_common.requests_request_body_nn_idx; +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 5f23d7a79..c172c1d76 100644 --- a/scripts/helm/db/init_dbs/postgresql/init_schema.sql +++ b/scripts/helm/db/init_dbs/postgresql/init_schema.sql @@ -6,7 +6,7 @@ CREATE SCHEMA IF NOT EXISTS events; CREATE OR REPLACE FUNCTION openreplay_version() RETURNS text AS $$ -SELECT 'v1.7.0' +SELECT 'v1.8.0' $$ LANGUAGE sql IMMUTABLE; -- --- accounts.sql --- @@ -173,31 +173,33 @@ $$ CREATE TABLE projects ( - project_id integer generated BY DEFAULT AS IDENTITY PRIMARY KEY, - project_key varchar(20) NOT NULL UNIQUE DEFAULT generate_api_key(20), - name text NOT NULL, - active boolean NOT NULL, - sample_rate smallint NOT NULL DEFAULT 100 CHECK (sample_rate >= 0 AND sample_rate <= 100), - created_at timestamp without time zone NOT NULL DEFAULT (now() at time zone 'utc'), - deleted_at timestamp without time zone NULL DEFAULT NULL, - max_session_duration integer NOT NULL DEFAULT 7200000, - metadata_1 text DEFAULT NULL, - metadata_2 text DEFAULT NULL, - metadata_3 text DEFAULT NULL, - metadata_4 text DEFAULT NULL, - metadata_5 text DEFAULT NULL, - metadata_6 text DEFAULT NULL, - metadata_7 text DEFAULT NULL, - metadata_8 text DEFAULT NULL, - metadata_9 text DEFAULT NULL, - metadata_10 text DEFAULT NULL, - save_request_payloads boolean NOT NULL DEFAULT FALSE, - gdpr jsonb NOT NULL DEFAULT '{ + project_id integer generated BY DEFAULT AS IDENTITY PRIMARY KEY, + project_key varchar(20) NOT NULL UNIQUE DEFAULT generate_api_key(20), + name text NOT NULL, + active boolean NOT NULL, + sample_rate smallint NOT NULL DEFAULT 100 CHECK (sample_rate >= 0 AND sample_rate <= 100), + created_at timestamp without time zone NOT NULL DEFAULT (now() at time zone 'utc'), + deleted_at timestamp without time zone NULL DEFAULT NULL, + max_session_duration integer NOT NULL DEFAULT 7200000, + metadata_1 text DEFAULT NULL, + metadata_2 text DEFAULT NULL, + metadata_3 text DEFAULT NULL, + metadata_4 text DEFAULT NULL, + metadata_5 text DEFAULT NULL, + metadata_6 text DEFAULT NULL, + metadata_7 text DEFAULT NULL, + metadata_8 text DEFAULT NULL, + metadata_9 text DEFAULT NULL, + metadata_10 text DEFAULT NULL, + save_request_payloads boolean NOT NULL DEFAULT FALSE, + gdpr jsonb NOT NULL DEFAULT '{ "maskEmails": true, "sampleRate": 33, "maskNumbers": false, "defaultInputMode": "plain" - }'::jsonb -- ?????? + }'::jsonb, + first_recorded_session_at timestamp without time zone NULL DEFAULT NULL, + sessions_last_check_at timestamp without time zone NULL DEFAULT NULL ); CREATE INDEX projects_project_key_idx ON public.projects (project_key); @@ -439,6 +441,8 @@ $$ utm_source text NULL DEFAULT NULL, utm_medium text NULL DEFAULT NULL, utm_campaign text NULL DEFAULT NULL, + referrer text NULL DEFAULT NULL, + base_referrer text NULL DEFAULT NULL, metadata_1 text DEFAULT NULL, metadata_2 text DEFAULT NULL, metadata_3 text DEFAULT NULL, @@ -494,6 +498,8 @@ $$ CREATE INDEX sessions_utm_source_gin_idx ON public.sessions USING GIN (utm_source gin_trgm_ops); CREATE INDEX sessions_utm_medium_gin_idx ON public.sessions USING GIN (utm_medium gin_trgm_ops); CREATE INDEX sessions_utm_campaign_gin_idx ON public.sessions USING GIN (utm_campaign gin_trgm_ops); + CREATE INDEX sessions_base_referrer_gin_idx ON public.sessions USING GIN (base_referrer gin_trgm_ops); + ALTER TABLE public.sessions ADD CONSTRAINT web_browser_constraint CHECK ( @@ -600,9 +606,7 @@ $$ ELSE 0 END)) gin_trgm_ops); CREATE INDEX requests_timestamp_session_id_failed_idx ON events_common.requests (timestamp, session_id) WHERE success = FALSE; - CREATE INDEX requests_request_body_nn_idx ON events_common.requests (request_body) WHERE request_body IS NOT NULL; CREATE INDEX requests_request_body_nn_gin_idx ON events_common.requests USING GIN (request_body gin_trgm_ops) WHERE request_body IS NOT NULL; - CREATE INDEX requests_response_body_nn_idx ON events_common.requests (response_body) WHERE response_body IS NOT NULL; CREATE INDEX requests_response_body_nn_gin_idx ON events_common.requests USING GIN (response_body gin_trgm_ops) WHERE response_body IS NOT NULL; CREATE INDEX requests_status_code_nn_idx ON events_common.requests (status_code) WHERE status_code IS NOT NULL; CREATE INDEX requests_host_nn_idx ON events_common.requests (host) WHERE host IS NOT NULL; @@ -809,6 +813,9 @@ $$ session_id bigint NOT NULL REFERENCES sessions (session_id) ON DELETE CASCADE, timestamp bigint NOT NULL, message_id bigint NOT NULL, + host text NULL DEFAULT NULL, + path text NULL DEFAULT NULL, + query text NULL DEFAULT NULL, min_fps smallint NOT NULL, avg_fps smallint NOT NULL, max_fps smallint NOT NULL, @@ -965,6 +972,7 @@ $$ CREATE INDEX searches_project_id_idx ON public.searches (project_id); CREATE TYPE alert_detection_method AS ENUM ('threshold', 'change'); + CREATE TYPE alert_change_type AS ENUM ('percent', 'change'); CREATE TABLE alerts ( @@ -975,6 +983,7 @@ $$ description text NULL DEFAULT NULL, active boolean NOT NULL DEFAULT TRUE, detection_method alert_detection_method NOT NULL, + change alert_change_type NOT NULL DEFAULT 'change', query jsonb NOT NULL, deleted_at timestamp NULL DEFAULT NULL, created_at timestamp NOT NULL DEFAULT timezone('utc'::text, now()), diff --git a/scripts/helmcharts/init.sh b/scripts/helmcharts/init.sh index 5fe454f90..c204d5799 100644 --- a/scripts/helmcharts/init.sh +++ b/scripts/helmcharts/init.sh @@ -15,7 +15,7 @@ fatal() exit 1 } -version="v1.7.0" +version="v1.8.0" usr=`whoami` # Installing k3s @@ -82,8 +82,8 @@ fatal 'DOMAIN_NAME variable is empty. Rerun the script `DOMAIN_NAME=openreplay.m } # Mac os doesn't have gnu sed, which will cause compatibility issues. -# This wrapper will help to check the sed, and use the correct version="v1.7.0" -# Ref: https://stackoverflow.com/questions/37639496/how-can-i-check-the-version="v1.7.0" +# This wrapper will help to check the sed, and use the correct version="v1.8.0" +# Ref: https://stackoverflow.com/questions/37639496/how-can-i-check-the-version="v1.8.0" function is_gnu_sed(){ sed --version >/dev/null 2>&1 } diff --git a/scripts/helmcharts/openreplay/Chart.yaml b/scripts/helmcharts/openreplay/Chart.yaml index 0fdda046d..fbeb3b453 100644 --- a/scripts/helmcharts/openreplay/Chart.yaml +++ b/scripts/helmcharts/openreplay/Chart.yaml @@ -22,10 +22,14 @@ version: 0.1.0 # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. # Ref: https://github.com/helm/helm/issues/7858#issuecomment-608114589 -AppVersion: "v1.7.0" +AppVersion: "v1.8.0" dependencies: - name: ingress-nginx version: "4.x.x" repository: "https://kubernetes.github.io/ingress-nginx" condition: ingress-nginx.enabled + - name: quickwit + version: "0.3.1" + repository: "file://charts/quickwit" + condition: quickwit.enabled diff --git a/scripts/helmcharts/openreplay/charts/alerts/Chart.yaml b/scripts/helmcharts/openreplay/charts/alerts/Chart.yaml index e2b96ade4..54730a15d 100644 --- a/scripts/helmcharts/openreplay/charts/alerts/Chart.yaml +++ b/scripts/helmcharts/openreplay/charts/alerts/Chart.yaml @@ -21,4 +21,4 @@ version: 0.1.0 # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -AppVersion: "v1.7.0" +AppVersion: "v1.8.0" diff --git a/scripts/helmcharts/openreplay/charts/alerts/templates/deployment.yaml b/scripts/helmcharts/openreplay/charts/alerts/templates/deployment.yaml index afb7aedc5..992f67b4d 100644 --- a/scripts/helmcharts/openreplay/charts/alerts/templates/deployment.yaml +++ b/scripts/helmcharts/openreplay/charts/alerts/templates/deployment.yaml @@ -58,10 +58,10 @@ spec: value: 'https://{{ .Values.global.domainName }}' - name: S3_HOST {{- if eq .Values.global.s3.endpoint "http://minio.db.svc.cluster.local:9000" }} - value: 'https://{{ .Values.global.domainName }}' - {{- else }} + value: 'https://{{ .Values.global.domainName }}:{{ .Values.global.ingress.controller.service.ports.https}}' + {{- else}} value: '{{ .Values.global.s3.endpoint }}' - {{- end }} + {{- end}} - name: S3_KEY value: {{ .Values.global.s3.accessKey }} - name: S3_SECRET diff --git a/scripts/helmcharts/openreplay/charts/alerts/values.yaml b/scripts/helmcharts/openreplay/charts/alerts/values.yaml index 4bcc516c8..6562c3a46 100644 --- a/scripts/helmcharts/openreplay/charts/alerts/values.yaml +++ b/scripts/helmcharts/openreplay/charts/alerts/values.yaml @@ -90,6 +90,8 @@ autoscaling: # targetMemoryUtilizationPercentage: 80 env: + ch_host: clickhouse-openreplay-clickhouse.db.svc.cluster.local + ch_port: 9000 PYTHONUNBUFFERED: '0' diff --git a/scripts/helmcharts/openreplay/charts/assets/Chart.yaml b/scripts/helmcharts/openreplay/charts/assets/Chart.yaml index c6d875b08..dab6e61fb 100644 --- a/scripts/helmcharts/openreplay/charts/assets/Chart.yaml +++ b/scripts/helmcharts/openreplay/charts/assets/Chart.yaml @@ -21,4 +21,4 @@ version: 0.1.0 # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -AppVersion: "v1.7.0" +AppVersion: "v1.8.0" diff --git a/scripts/helmcharts/openreplay/charts/assist/Chart.yaml b/scripts/helmcharts/openreplay/charts/assist/Chart.yaml index 40cc29c5a..75465880f 100644 --- a/scripts/helmcharts/openreplay/charts/assist/Chart.yaml +++ b/scripts/helmcharts/openreplay/charts/assist/Chart.yaml @@ -21,4 +21,4 @@ version: 0.1.0 # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -AppVersion: "v1.7.0" +AppVersion: "v1.8.0" diff --git a/scripts/helmcharts/openreplay/charts/assist/templates/deployment.yaml b/scripts/helmcharts/openreplay/charts/assist/templates/deployment.yaml index ed4ec5d4a..08fb70ece 100644 --- a/scripts/helmcharts/openreplay/charts/assist/templates/deployment.yaml +++ b/scripts/helmcharts/openreplay/charts/assist/templates/deployment.yaml @@ -46,7 +46,7 @@ spec: value: "{{ .Values.global.s3.region }}" - name: S3_HOST {{- if eq .Values.global.s3.endpoint "http://minio.db.svc.cluster.local:9000" }} - value: 'https://{{ .Values.global.domainName }}' + value: 'https://{{ .Values.global.domainName }}:{{ .Values.global.ingress.controller.service.ports.https}}' {{- else}} value: '{{ .Values.global.s3.endpoint }}' {{- end}} diff --git a/scripts/helmcharts/openreplay/charts/assist/values.yaml b/scripts/helmcharts/openreplay/charts/assist/values.yaml index 4ffaf88e1..eb9016cae 100644 --- a/scripts/helmcharts/openreplay/charts/assist/values.yaml +++ b/scripts/helmcharts/openreplay/charts/assist/values.yaml @@ -90,6 +90,7 @@ env: debug: 0 uws: false redis: false + CLEAR_SOCKET_TIME: 720 nodeSelector: {} diff --git a/scripts/helmcharts/openreplay/charts/chalice/Chart.yaml b/scripts/helmcharts/openreplay/charts/chalice/Chart.yaml index 183590316..95c3c126a 100644 --- a/scripts/helmcharts/openreplay/charts/chalice/Chart.yaml +++ b/scripts/helmcharts/openreplay/charts/chalice/Chart.yaml @@ -21,4 +21,4 @@ version: 0.1.0 # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -AppVersion: "v1.7.0" +AppVersion: "v1.8.0" diff --git a/scripts/helmcharts/openreplay/charts/chalice/templates/deployment.yaml b/scripts/helmcharts/openreplay/charts/chalice/templates/deployment.yaml index 4491a82e4..6c9f2beaf 100644 --- a/scripts/helmcharts/openreplay/charts/chalice/templates/deployment.yaml +++ b/scripts/helmcharts/openreplay/charts/chalice/templates/deployment.yaml @@ -60,7 +60,7 @@ spec: value: 'https://{{ .Values.global.domainName }}' - name: S3_HOST {{- if eq .Values.global.s3.endpoint "http://minio.db.svc.cluster.local:9000" }} - value: 'https://{{ .Values.global.domainName }}' + value: 'https://{{ .Values.global.domainName }}:{{ .Values.global.ingress.controller.service.ports.https}}' {{- else}} value: '{{ .Values.global.s3.endpoint }}' {{- end}} @@ -106,16 +106,33 @@ spec: containerPort: {{ $val }} protocol: TCP {{- end }} - {{- with .Values.persistence.mounts }} volumeMounts: + - name: datadir + mountPath: /mnt/efs + {{- with .Values.persistence.mounts }} {{- toYaml . | nindent 12 }} {{- end }} resources: {{- toYaml .Values.resources | nindent 12 }} - {{- with .Values.persistence.volumes }} + {{- if eq .Values.pvc.name "hostPath" }} volumes: + - name: datadir + hostPath: + # Ensure the file directory is created. + path: {{ .Values.pvc.hostMountPath }} + type: DirectoryOrCreate + {{- with .Values.persistence.volumes }} + {{- toYaml . | nindent 6 }} + {{- end }} + {{- else }} + volumes: + - name: datadir + persistentVolumeClaim: + claimName: {{ .Values.pvc.name }} + {{- with .Values.persistence.volumes }} {{- toYaml . | nindent 8 }} {{- end }} + {{- end }} {{- with .Values.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} diff --git a/scripts/helmcharts/openreplay/charts/chalice/values.yaml b/scripts/helmcharts/openreplay/charts/chalice/values.yaml index 2c9d75040..98a01ab8b 100644 --- a/scripts/helmcharts/openreplay/charts/chalice/values.yaml +++ b/scripts/helmcharts/openreplay/charts/chalice/values.yaml @@ -122,6 +122,14 @@ healthCheck: timeoutSeconds: 10 +pvc: + # This can be either persistentVolumeClaim or hostPath. + # In case of pvc, you'll have to provide the pvc name. + # For example + # name: openreplay-efs + name: hostPath + hostMountPath: /openreplay/storage/nfs + persistence: {} # # Spec of spec.template.spec.containers[*].volumeMounts # mounts: diff --git a/scripts/helmcharts/openreplay/charts/db/Chart.yaml b/scripts/helmcharts/openreplay/charts/db/Chart.yaml index 345961e16..5b437c1c3 100644 --- a/scripts/helmcharts/openreplay/charts/db/Chart.yaml +++ b/scripts/helmcharts/openreplay/charts/db/Chart.yaml @@ -21,4 +21,4 @@ version: 0.1.0 # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -AppVersion: "v1.7.0" +AppVersion: "v1.8.0" diff --git a/scripts/helmcharts/openreplay/charts/db/templates/deployment.yaml b/scripts/helmcharts/openreplay/charts/db/templates/deployment.yaml index 2c18179df..f05bdd42b 100644 --- a/scripts/helmcharts/openreplay/charts/db/templates/deployment.yaml +++ b/scripts/helmcharts/openreplay/charts/db/templates/deployment.yaml @@ -52,6 +52,8 @@ spec: value: '{{ .Values.global.kafka.kafkaUseSsl }}' - name: POSTGRES_STRING value: 'postgres://{{ .Values.global.postgresql.postgresqlUser }}:{{ .Values.global.postgresql.postgresqlPassword }}@{{ .Values.global.postgresql.postgresqlHost }}:{{ .Values.global.postgresql.postgresqlPort }}/{{ .Values.global.postgresql.postgresqlDatabase }}' + - name: QUICKWIT_ENABLED + value: '{{ .Values.global.quickwit.enabled }}' {{- range $key, $val := .Values.env }} - name: {{ $key }} value: '{{ $val }}' diff --git a/scripts/helmcharts/openreplay/charts/ender/Chart.yaml b/scripts/helmcharts/openreplay/charts/ender/Chart.yaml index 05c3a2f34..80b4efdd4 100644 --- a/scripts/helmcharts/openreplay/charts/ender/Chart.yaml +++ b/scripts/helmcharts/openreplay/charts/ender/Chart.yaml @@ -21,4 +21,4 @@ version: 0.1.0 # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -AppVersion: "v1.7.0" +AppVersion: "v1.8.0" diff --git a/scripts/helmcharts/openreplay/charts/frontend/Chart.yaml b/scripts/helmcharts/openreplay/charts/frontend/Chart.yaml index deaaf8223..4064a7322 100644 --- a/scripts/helmcharts/openreplay/charts/frontend/Chart.yaml +++ b/scripts/helmcharts/openreplay/charts/frontend/Chart.yaml @@ -21,4 +21,4 @@ version: 0.1.0 # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -AppVersion: "v1.7.0" +AppVersion: "v1.8.0" diff --git a/scripts/helmcharts/openreplay/charts/heuristics/Chart.yaml b/scripts/helmcharts/openreplay/charts/heuristics/Chart.yaml index 2adda975f..86026bc3f 100644 --- a/scripts/helmcharts/openreplay/charts/heuristics/Chart.yaml +++ b/scripts/helmcharts/openreplay/charts/heuristics/Chart.yaml @@ -21,4 +21,4 @@ version: 0.1.0 # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -AppVersion: "v1.7.0" +AppVersion: "v1.8.0" diff --git a/scripts/helmcharts/openreplay/charts/http/Chart.yaml b/scripts/helmcharts/openreplay/charts/http/Chart.yaml index 563228009..820c2ae7c 100644 --- a/scripts/helmcharts/openreplay/charts/http/Chart.yaml +++ b/scripts/helmcharts/openreplay/charts/http/Chart.yaml @@ -21,4 +21,4 @@ version: 0.1.0 # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -AppVersion: "v1.7.0" +AppVersion: "v1.8.0" diff --git a/scripts/helmcharts/openreplay/charts/ingress-nginx/CHANGELOG.md b/scripts/helmcharts/openreplay/charts/ingress-nginx/CHANGELOG.md index f3f44c336..bedb9f720 100644 --- a/scripts/helmcharts/openreplay/charts/ingress-nginx/CHANGELOG.md +++ b/scripts/helmcharts/openreplay/charts/ingress-nginx/CHANGELOG.md @@ -2,39 +2,104 @@ This file documents all notable changes to [ingress-nginx](https://github.com/kubernetes/ingress-nginx) Helm Chart. The release numbering uses [semantic versioning](http://semver.org). +### 4.2.0 + +- Support for Kubernetes v1.19.0 was removed +- "[8810](https://github.com/kubernetes/ingress-nginx/pull/8810) Prepare for v1.3.0" +- "[8808](https://github.com/kubernetes/ingress-nginx/pull/8808) revert arch var name" +- "[8805](https://github.com/kubernetes/ingress-nginx/pull/8805) Bump k8s.io/klog/v2 from 2.60.1 to 2.70.1" +- "[8803](https://github.com/kubernetes/ingress-nginx/pull/8803) Update to nginx base with alpine v3.16" +- "[8802](https://github.com/kubernetes/ingress-nginx/pull/8802) chore: start v1.3.0 release process" +- "[8798](https://github.com/kubernetes/ingress-nginx/pull/8798) Add v1.24.0 to test matrix" +- "[8796](https://github.com/kubernetes/ingress-nginx/pull/8796) fix: add MAC_OS variable for static-check" +- "[8793](https://github.com/kubernetes/ingress-nginx/pull/8793) changed to alpine-v3.16" +- "[8781](https://github.com/kubernetes/ingress-nginx/pull/8781) Bump github.com/stretchr/testify from 1.7.5 to 1.8.0" +- "[8778](https://github.com/kubernetes/ingress-nginx/pull/8778) chore: remove stable.txt from release process" +- "[8775](https://github.com/kubernetes/ingress-nginx/pull/8775) Remove stable" +- "[8773](https://github.com/kubernetes/ingress-nginx/pull/8773) Bump github/codeql-action from 2.1.14 to 2.1.15" +- "[8772](https://github.com/kubernetes/ingress-nginx/pull/8772) Bump ossf/scorecard-action from 1.1.1 to 1.1.2" +- "[8771](https://github.com/kubernetes/ingress-nginx/pull/8771) fix bullet md format" +- "[8770](https://github.com/kubernetes/ingress-nginx/pull/8770) Add condition for monitoring.coreos.com/v1 API" +- "[8769](https://github.com/kubernetes/ingress-nginx/pull/8769) Fix typos and add links to developer guide" +- "[8767](https://github.com/kubernetes/ingress-nginx/pull/8767) change v1.2.0 to v1.2.1 in deploy doc URLs" +- "[8765](https://github.com/kubernetes/ingress-nginx/pull/8765) Bump github/codeql-action from 1.0.26 to 2.1.14" +- "[8752](https://github.com/kubernetes/ingress-nginx/pull/8752) Bump github.com/spf13/cobra from 1.4.0 to 1.5.0" +- "[8751](https://github.com/kubernetes/ingress-nginx/pull/8751) Bump github.com/stretchr/testify from 1.7.2 to 1.7.5" +- "[8750](https://github.com/kubernetes/ingress-nginx/pull/8750) added announcement" +- "[8740](https://github.com/kubernetes/ingress-nginx/pull/8740) change sha e2etestrunner and echoserver" +- "[8738](https://github.com/kubernetes/ingress-nginx/pull/8738) Update docs to make it easier for noobs to follow step by step" +- "[8737](https://github.com/kubernetes/ingress-nginx/pull/8737) updated baseimage sha" +- "[8736](https://github.com/kubernetes/ingress-nginx/pull/8736) set ld-musl-path" +- "[8733](https://github.com/kubernetes/ingress-nginx/pull/8733) feat: migrate leaderelection lock to leases" +- "[8726](https://github.com/kubernetes/ingress-nginx/pull/8726) prometheus metric: upstream_latency_seconds" +- "[8720](https://github.com/kubernetes/ingress-nginx/pull/8720) Ci pin deps" +- "[8719](https://github.com/kubernetes/ingress-nginx/pull/8719) Working OpenTelemetry sidecar (base nginx image)" +- "[8714](https://github.com/kubernetes/ingress-nginx/pull/8714) Create Openssf scorecard" +- "[8708](https://github.com/kubernetes/ingress-nginx/pull/8708) Bump github.com/prometheus/common from 0.34.0 to 0.35.0" +- "[8703](https://github.com/kubernetes/ingress-nginx/pull/8703) Bump actions/dependency-review-action from 1 to 2" +- "[8701](https://github.com/kubernetes/ingress-nginx/pull/8701) Fix several typos" +- "[8699](https://github.com/kubernetes/ingress-nginx/pull/8699) fix the gosec test and a make target for it" +- "[8698](https://github.com/kubernetes/ingress-nginx/pull/8698) Bump actions/upload-artifact from 2.3.1 to 3.1.0" +- "[8697](https://github.com/kubernetes/ingress-nginx/pull/8697) Bump actions/setup-go from 2.2.0 to 3.2.0" +- "[8695](https://github.com/kubernetes/ingress-nginx/pull/8695) Bump actions/download-artifact from 2 to 3" +- "[8694](https://github.com/kubernetes/ingress-nginx/pull/8694) Bump crazy-max/ghaction-docker-buildx from 1.6.2 to 3.3.1" + +### 4.1.2 + +- "[8587](https://github.com/kubernetes/ingress-nginx/pull/8587) Add CAP_SYS_CHROOT to DS/PSP when needed" +- "[8458](https://github.com/kubernetes/ingress-nginx/pull/8458) Add portNamePreffix Helm chart parameter" +- "[8522](https://github.com/kubernetes/ingress-nginx/pull/8522) Add documentation for controller.service.loadBalancerIP in Helm chart" + +### 4.1.0 + +- "[8481](https://github.com/kubernetes/ingress-nginx/pull/8481) Fix log creation in chroot script" +- "[8479](https://github.com/kubernetes/ingress-nginx/pull/8479) changed nginx base img tag to img built with alpine3.14.6" +- "[8478](https://github.com/kubernetes/ingress-nginx/pull/8478) update base images and protobuf gomod" +- "[8468](https://github.com/kubernetes/ingress-nginx/pull/8468) Fallback to ngx.var.scheme for redirectScheme with use-forward-headers when X-Forwarded-Proto is empty" +- "[8456](https://github.com/kubernetes/ingress-nginx/pull/8456) Implement object deep inspector" +- "[8455](https://github.com/kubernetes/ingress-nginx/pull/8455) Update dependencies" +- "[8454](https://github.com/kubernetes/ingress-nginx/pull/8454) Update index.md" +- "[8447](https://github.com/kubernetes/ingress-nginx/pull/8447) typo fixing" +- "[8446](https://github.com/kubernetes/ingress-nginx/pull/8446) Fix suggested annotation-value-word-blocklist" +- "[8444](https://github.com/kubernetes/ingress-nginx/pull/8444) replace deprecated topology key in example with current one" +- "[8443](https://github.com/kubernetes/ingress-nginx/pull/8443) Add dependency review enforcement" +- "[8434](https://github.com/kubernetes/ingress-nginx/pull/8434) added new auth-tls-match-cn annotation" +- "[8426](https://github.com/kubernetes/ingress-nginx/pull/8426) Bump github.com/prometheus/common from 0.32.1 to 0.33.0" + ### 4.0.18 -"[8291](https://github.com/kubernetes/ingress-nginx/pull/8291) remove git tag env from cloud build" -"[8286](https://github.com/kubernetes/ingress-nginx/pull/8286) Fix OpenTelemetry sidecar image build" -"[8277](https://github.com/kubernetes/ingress-nginx/pull/8277) Add OpenSSF Best practices badge" -"[8273](https://github.com/kubernetes/ingress-nginx/pull/8273) Issue#8241" -"[8267](https://github.com/kubernetes/ingress-nginx/pull/8267) Add fsGroup value to admission-webhooks/job-patch charts" -"[8262](https://github.com/kubernetes/ingress-nginx/pull/8262) Updated confusing error" -"[8256](https://github.com/kubernetes/ingress-nginx/pull/8256) fix: deny locations with invalid auth-url annotation" -"[8253](https://github.com/kubernetes/ingress-nginx/pull/8253) Add a certificate info metric" -"[8236](https://github.com/kubernetes/ingress-nginx/pull/8236) webhook: remove useless code." -"[8227](https://github.com/kubernetes/ingress-nginx/pull/8227) Update libraries in webhook image" -"[8225](https://github.com/kubernetes/ingress-nginx/pull/8225) fix inconsistent-label-cardinality for prometheus metrics: nginx_ingress_controller_requests" -"[8221](https://github.com/kubernetes/ingress-nginx/pull/8221) Do not validate ingresses with unknown ingress class in admission webhook endpoint" -"[8210](https://github.com/kubernetes/ingress-nginx/pull/8210) Bump github.com/prometheus/client_golang from 1.11.0 to 1.12.1" -"[8209](https://github.com/kubernetes/ingress-nginx/pull/8209) Bump google.golang.org/grpc from 1.43.0 to 1.44.0" -"[8204](https://github.com/kubernetes/ingress-nginx/pull/8204) Add Artifact Hub lint" -"[8203](https://github.com/kubernetes/ingress-nginx/pull/8203) Fix Indentation of example and link to cert-manager tutorial" -"[8201](https://github.com/kubernetes/ingress-nginx/pull/8201) feat(metrics): add path and method labels to requests countera" -"[8199](https://github.com/kubernetes/ingress-nginx/pull/8199) use functional options to reduce number of methods creating an EchoDeployment" -"[8196](https://github.com/kubernetes/ingress-nginx/pull/8196) docs: fix inconsistent controller annotation" -"[8191](https://github.com/kubernetes/ingress-nginx/pull/8191) Using Go install for misspell" -"[8186](https://github.com/kubernetes/ingress-nginx/pull/8186) prometheus+grafana using servicemonitor" -"[8185](https://github.com/kubernetes/ingress-nginx/pull/8185) Append elements on match, instead of removing for cors-annotations" -"[8179](https://github.com/kubernetes/ingress-nginx/pull/8179) Bump github.com/opencontainers/runc from 1.0.3 to 1.1.0" -"[8173](https://github.com/kubernetes/ingress-nginx/pull/8173) Adding annotations to the controller service account" -"[8163](https://github.com/kubernetes/ingress-nginx/pull/8163) Update the $req_id placeholder description" -"[8162](https://github.com/kubernetes/ingress-nginx/pull/8162) Versioned static manifests" -"[8159](https://github.com/kubernetes/ingress-nginx/pull/8159) Adding some geoip variables and default values" -"[8155](https://github.com/kubernetes/ingress-nginx/pull/8155) #7271 feat: avoid-pdb-creation-when-default-backend-disabled-and-replicas-gt-1" -"[8151](https://github.com/kubernetes/ingress-nginx/pull/8151) Automatically generate helm docs" -"[8143](https://github.com/kubernetes/ingress-nginx/pull/8143) Allow to configure delay before controller exits" -"[8136](https://github.com/kubernetes/ingress-nginx/pull/8136) add ingressClass option to helm chart - back compatibility with ingress.class annotations" -"[8126](https://github.com/kubernetes/ingress-nginx/pull/8126) Example for JWT" + +- "[8291](https://github.com/kubernetes/ingress-nginx/pull/8291) remove git tag env from cloud build" +- "[8286](https://github.com/kubernetes/ingress-nginx/pull/8286) Fix OpenTelemetry sidecar image build" +- "[8277](https://github.com/kubernetes/ingress-nginx/pull/8277) Add OpenSSF Best practices badge" +- "[8273](https://github.com/kubernetes/ingress-nginx/pull/8273) Issue#8241" +- "[8267](https://github.com/kubernetes/ingress-nginx/pull/8267) Add fsGroup value to admission-webhooks/job-patch charts" +- "[8262](https://github.com/kubernetes/ingress-nginx/pull/8262) Updated confusing error" +- "[8256](https://github.com/kubernetes/ingress-nginx/pull/8256) fix: deny locations with invalid auth-url annotation" +- "[8253](https://github.com/kubernetes/ingress-nginx/pull/8253) Add a certificate info metric" +- "[8236](https://github.com/kubernetes/ingress-nginx/pull/8236) webhook: remove useless code." +- "[8227](https://github.com/kubernetes/ingress-nginx/pull/8227) Update libraries in webhook image" +- "[8225](https://github.com/kubernetes/ingress-nginx/pull/8225) fix inconsistent-label-cardinality for prometheus metrics: nginx_ingress_controller_requests" +- "[8221](https://github.com/kubernetes/ingress-nginx/pull/8221) Do not validate ingresses with unknown ingress class in admission webhook endpoint" +- "[8210](https://github.com/kubernetes/ingress-nginx/pull/8210) Bump github.com/prometheus/client_golang from 1.11.0 to 1.12.1" +- "[8209](https://github.com/kubernetes/ingress-nginx/pull/8209) Bump google.golang.org/grpc from 1.43.0 to 1.44.0" +- "[8204](https://github.com/kubernetes/ingress-nginx/pull/8204) Add Artifact Hub lint" +- "[8203](https://github.com/kubernetes/ingress-nginx/pull/8203) Fix Indentation of example and link to cert-manager tutorial" +- "[8201](https://github.com/kubernetes/ingress-nginx/pull/8201) feat(metrics): add path and method labels to requests countera" +- "[8199](https://github.com/kubernetes/ingress-nginx/pull/8199) use functional options to reduce number of methods creating an EchoDeployment" +- "[8196](https://github.com/kubernetes/ingress-nginx/pull/8196) docs: fix inconsistent controller annotation" +- "[8191](https://github.com/kubernetes/ingress-nginx/pull/8191) Using Go install for misspell" +- "[8186](https://github.com/kubernetes/ingress-nginx/pull/8186) prometheus+grafana using servicemonitor" +- "[8185](https://github.com/kubernetes/ingress-nginx/pull/8185) Append elements on match, instead of removing for cors-annotations" +- "[8179](https://github.com/kubernetes/ingress-nginx/pull/8179) Bump github.com/opencontainers/runc from 1.0.3 to 1.1.0" +- "[8173](https://github.com/kubernetes/ingress-nginx/pull/8173) Adding annotations to the controller service account" +- "[8163](https://github.com/kubernetes/ingress-nginx/pull/8163) Update the $req_id placeholder description" +- "[8162](https://github.com/kubernetes/ingress-nginx/pull/8162) Versioned static manifests" +- "[8159](https://github.com/kubernetes/ingress-nginx/pull/8159) Adding some geoip variables and default values" +- "[8155](https://github.com/kubernetes/ingress-nginx/pull/8155) #7271 feat: avoid-pdb-creation-when-default-backend-disabled-and-replicas-gt-1" +- "[8151](https://github.com/kubernetes/ingress-nginx/pull/8151) Automatically generate helm docs" +- "[8143](https://github.com/kubernetes/ingress-nginx/pull/8143) Allow to configure delay before controller exits" +- "[8136](https://github.com/kubernetes/ingress-nginx/pull/8136) add ingressClass option to helm chart - back compatibility with ingress.class annotations" +- "[8126](https://github.com/kubernetes/ingress-nginx/pull/8126) Example for JWT" ### 4.0.15 @@ -119,11 +184,11 @@ This file documents all notable changes to [ingress-nginx](https://github.com/ku - [7707] https://github.com/kubernetes/ingress-nginx/pull/7707 Release v1.0.2 of ingress-nginx -### 4.0.2 +### 4.0.2 - [7681] https://github.com/kubernetes/ingress-nginx/pull/7681 Release v1.0.1 of ingress-nginx -### 4.0.1 +### 4.0.1 - [7535] https://github.com/kubernetes/ingress-nginx/pull/7535 Release v1.0.0 ingress-nginx diff --git a/scripts/helmcharts/openreplay/charts/ingress-nginx/Chart.yaml b/scripts/helmcharts/openreplay/charts/ingress-nginx/Chart.yaml index 6445231d5..7040a29cd 100644 --- a/scripts/helmcharts/openreplay/charts/ingress-nginx/Chart.yaml +++ b/scripts/helmcharts/openreplay/charts/ingress-nginx/Chart.yaml @@ -1,13 +1,46 @@ annotations: artifacthub.io/changes: | - - "#8253 Add a certificate info metric" - - "#8225 fix inconsistent-label-cardinality for prometheus metrics: nginx_ingress_controller_requests" - - "#8162 Versioned static manifests" - - "#8159 Adding some geoip variables and default values" - - "#8221 Do not validate ingresses with unknown ingress class in admission webhook endpoint" + - "[8810](https://github.com/kubernetes/ingress-nginx/pull/8810) Prepare for v1.3.0" + - "[8808](https://github.com/kubernetes/ingress-nginx/pull/8808) revert arch var name" + - "[8805](https://github.com/kubernetes/ingress-nginx/pull/8805) Bump k8s.io/klog/v2 from 2.60.1 to 2.70.1" + - "[8803](https://github.com/kubernetes/ingress-nginx/pull/8803) Update to nginx base with alpine v3.16" + - "[8802](https://github.com/kubernetes/ingress-nginx/pull/8802) chore: start v1.3.0 release process" + - "[8798](https://github.com/kubernetes/ingress-nginx/pull/8798) Add v1.24.0 to test matrix" + - "[8796](https://github.com/kubernetes/ingress-nginx/pull/8796) fix: add MAC_OS variable for static-check" + - "[8793](https://github.com/kubernetes/ingress-nginx/pull/8793) changed to alpine-v3.16" + - "[8781](https://github.com/kubernetes/ingress-nginx/pull/8781) Bump github.com/stretchr/testify from 1.7.5 to 1.8.0" + - "[8778](https://github.com/kubernetes/ingress-nginx/pull/8778) chore: remove stable.txt from release process" + - "[8775](https://github.com/kubernetes/ingress-nginx/pull/8775) Remove stable" + - "[8773](https://github.com/kubernetes/ingress-nginx/pull/8773) Bump github/codeql-action from 2.1.14 to 2.1.15" + - "[8772](https://github.com/kubernetes/ingress-nginx/pull/8772) Bump ossf/scorecard-action from 1.1.1 to 1.1.2" + - "[8771](https://github.com/kubernetes/ingress-nginx/pull/8771) fix bullet md format" + - "[8770](https://github.com/kubernetes/ingress-nginx/pull/8770) Add condition for monitoring.coreos.com/v1 API" + - "[8769](https://github.com/kubernetes/ingress-nginx/pull/8769) Fix typos and add links to developer guide" + - "[8767](https://github.com/kubernetes/ingress-nginx/pull/8767) change v1.2.0 to v1.2.1 in deploy doc URLs" + - "[8765](https://github.com/kubernetes/ingress-nginx/pull/8765) Bump github/codeql-action from 1.0.26 to 2.1.14" + - "[8752](https://github.com/kubernetes/ingress-nginx/pull/8752) Bump github.com/spf13/cobra from 1.4.0 to 1.5.0" + - "[8751](https://github.com/kubernetes/ingress-nginx/pull/8751) Bump github.com/stretchr/testify from 1.7.2 to 1.7.5" + - "[8750](https://github.com/kubernetes/ingress-nginx/pull/8750) added announcement" + - "[8740](https://github.com/kubernetes/ingress-nginx/pull/8740) change sha e2etestrunner and echoserver" + - "[8738](https://github.com/kubernetes/ingress-nginx/pull/8738) Update docs to make it easier for noobs to follow step by step" + - "[8737](https://github.com/kubernetes/ingress-nginx/pull/8737) updated baseimage sha" + - "[8736](https://github.com/kubernetes/ingress-nginx/pull/8736) set ld-musl-path" + - "[8733](https://github.com/kubernetes/ingress-nginx/pull/8733) feat: migrate leaderelection lock to leases" + - "[8726](https://github.com/kubernetes/ingress-nginx/pull/8726) prometheus metric: upstream_latency_seconds" + - "[8720](https://github.com/kubernetes/ingress-nginx/pull/8720) Ci pin deps" + - "[8719](https://github.com/kubernetes/ingress-nginx/pull/8719) Working OpenTelemetry sidecar (base nginx image)" + - "[8714](https://github.com/kubernetes/ingress-nginx/pull/8714) Create Openssf scorecard" + - "[8708](https://github.com/kubernetes/ingress-nginx/pull/8708) Bump github.com/prometheus/common from 0.34.0 to 0.35.0" + - "[8703](https://github.com/kubernetes/ingress-nginx/pull/8703) Bump actions/dependency-review-action from 1 to 2" + - "[8701](https://github.com/kubernetes/ingress-nginx/pull/8701) Fix several typos" + - "[8699](https://github.com/kubernetes/ingress-nginx/pull/8699) fix the gosec test and a make target for it" + - "[8698](https://github.com/kubernetes/ingress-nginx/pull/8698) Bump actions/upload-artifact from 2.3.1 to 3.1.0" + - "[8697](https://github.com/kubernetes/ingress-nginx/pull/8697) Bump actions/setup-go from 2.2.0 to 3.2.0" + - "[8695](https://github.com/kubernetes/ingress-nginx/pull/8695) Bump actions/download-artifact from 2 to 3" + - "[8694](https://github.com/kubernetes/ingress-nginx/pull/8694) Bump crazy-max/ghaction-docker-buildx from 1.6.2 to 3.3.1" artifacthub.io/prerelease: "false" apiVersion: v2 -appVersion: 1.1.2 +appVersion: 1.3.0 description: Ingress controller for Kubernetes using NGINX as a reverse proxy and load balancer home: https://github.com/kubernetes/ingress-nginx @@ -15,11 +48,13 @@ icon: https://upload.wikimedia.org/wikipedia/commons/thumb/c/c5/Nginx_logo.svg/5 keywords: - ingress - nginx -kubeVersion: '>=1.19.0-0' +kubeVersion: '>=1.20.0-0' maintainers: -- name: ChiefAlexander +- name: rikatz +- name: strongjz +- name: tao12345666333 name: ingress-nginx sources: - https://github.com/kubernetes/ingress-nginx type: application -version: 4.0.18 +version: 4.2.0 diff --git a/scripts/helmcharts/openreplay/charts/ingress-nginx/README.md b/scripts/helmcharts/openreplay/charts/ingress-nginx/README.md index ad641f726..942cf0467 100644 --- a/scripts/helmcharts/openreplay/charts/ingress-nginx/README.md +++ b/scripts/helmcharts/openreplay/charts/ingress-nginx/README.md @@ -2,7 +2,7 @@ [ingress-nginx](https://github.com/kubernetes/ingress-nginx) Ingress controller for Kubernetes using NGINX as a reverse proxy and load balancer -![Version: 4.0.18](https://img.shields.io/badge/Version-4.0.18-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 1.1.2](https://img.shields.io/badge/AppVersion-1.1.2-informational?style=flat-square) +![Version: 4.2.0](https://img.shields.io/badge/Version-4.2.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 1.3.0](https://img.shields.io/badge/AppVersion-1.3.0-informational?style=flat-square) To use, add `ingressClassName: nginx` spec field or the `kubernetes.io/ingress.class: nginx` annotation to your Ingress resources. @@ -111,7 +111,7 @@ controller: ### AWS L7 ELB with SSL Termination -Annotate the controller as shown in the [nginx-ingress l7 patch](https://github.com/kubernetes/ingress-nginx/blob/main/deploy/aws/l7/service-l7.yaml): +Annotate the controller as shown in the [nginx-ingress l7 patch](https://github.com/kubernetes/ingress-nginx/blob/ab3a789caae65eec4ad6e3b46b19750b481b6bce/deploy/aws/l7/service-l7.yaml): ```yaml controller: @@ -128,7 +128,7 @@ controller: ### AWS route53-mapper -To configure the LoadBalancer service with the [route53-mapper addon](https://github.com/kubernetes/kops/tree/master/addons/route53-mapper), add the `domainName` annotation and `dns` label: +To configure the LoadBalancer service with the [route53-mapper addon](https://github.com/kubernetes/kops/blob/be63d4f1a7a46daaf1c4c482527328236850f111/addons/route53-mapper/README.md), add the `domainName` annotation and `dns` label: ```yaml controller: @@ -231,7 +231,7 @@ As of version `1.26.0` of this chart, by simply not providing any clusterIP valu ## Requirements -Kubernetes: `>=1.19.0-0` +Kubernetes: `>=1.20.0-0` ## Values @@ -244,7 +244,8 @@ Kubernetes: `>=1.19.0-0` | controller.admissionWebhooks.createSecretJob.resources | object | `{}` | | | controller.admissionWebhooks.enabled | bool | `true` | | | controller.admissionWebhooks.existingPsp | string | `""` | Use an existing PSP instead of creating one | -| controller.admissionWebhooks.failurePolicy | string | `"Fail"` | | +| controller.admissionWebhooks.extraEnvs | list | `[]` | Additional environment variables to set | +| controller.admissionWebhooks.failurePolicy | string | `"Fail"` | Admission Webhook failure policy to use | | controller.admissionWebhooks.key | string | `"/usr/local/certificates/key"` | | | controller.admissionWebhooks.labels | object | `{}` | Labels to be added to admission webhooks | | controller.admissionWebhooks.namespaceSelector | object | `{}` | | @@ -254,7 +255,7 @@ Kubernetes: `>=1.19.0-0` | controller.admissionWebhooks.patch.image.digest | string | `"sha256:64d8c73dca984af206adf9d6d7e46aa550362b1d7a01f3a0a91b20cc67868660"` | | | controller.admissionWebhooks.patch.image.image | string | `"ingress-nginx/kube-webhook-certgen"` | | | controller.admissionWebhooks.patch.image.pullPolicy | string | `"IfNotPresent"` | | -| controller.admissionWebhooks.patch.image.registry | string | `"k8s.gcr.io"` | | +| controller.admissionWebhooks.patch.image.registry | string | `"registry.k8s.io"` | | | controller.admissionWebhooks.patch.image.tag | string | `"v1.1.1"` | | | controller.admissionWebhooks.patch.labels | object | `{}` | Labels to be added to patch job resources | | controller.admissionWebhooks.patch.nodeSelector."kubernetes.io/os" | string | `"linux"` | | @@ -306,12 +307,14 @@ Kubernetes: `>=1.19.0-0` | controller.hostPort.ports.https | int | `443` | 'hostPort' https port | | controller.hostname | object | `{}` | Optionally customize the pod hostname. | | controller.image.allowPrivilegeEscalation | bool | `true` | | -| controller.image.digest | string | `"sha256:28b11ce69e57843de44e3db6413e98d09de0f6688e33d4bd384002a44f78405c"` | | +| controller.image.chroot | bool | `false` | | +| controller.image.digest | string | `"sha256:d1707ca76d3b044ab8a28277a2466a02100ee9f58a86af1535a3edf9323ea1b5"` | | +| controller.image.digestChroot | string | `"sha256:0fcb91216a22aae43b374fc2e6a03b8afe9e8c78cbf07a09d75636dc4ea3c191"` | | | controller.image.image | string | `"ingress-nginx/controller"` | | | controller.image.pullPolicy | string | `"IfNotPresent"` | | -| controller.image.registry | string | `"k8s.gcr.io"` | | +| controller.image.registry | string | `"registry.k8s.io"` | | | controller.image.runAsUser | int | `101` | | -| controller.image.tag | string | `"v1.1.2"` | | +| controller.image.tag | string | `"v1.3.0"` | | | controller.ingressClass | string | `"nginx"` | For backwards compatibility with ingress.class annotation, use ingressClass. Algorithm is as follows, first ingressClassName is considered, if not present, controller looks for ingress.class annotation | | controller.ingressClassByName | bool | `false` | Process IngressClass per name (additionally as per spec.controller). | | controller.ingressClassResource.controllerValue | string | `"k8s.io/ingress-nginx"` | Controller-value of the controller that is processing this ingressClass | @@ -399,6 +402,7 @@ Kubernetes: `>=1.19.0-0` | controller.service.ipFamilies | list | `["IPv4"]` | List of IP families (e.g. IPv4, IPv6) assigned to the service. This field is usually assigned automatically based on cluster configuration and the ipFamilyPolicy field. | | controller.service.ipFamilyPolicy | string | `"SingleStack"` | Represents the dual-stack-ness requested or required by this Service. Possible values are SingleStack, PreferDualStack or RequireDualStack. The ipFamilies and clusterIPs fields depend on the value of this field. | | controller.service.labels | object | `{}` | | +| controller.service.loadBalancerIP | string | `""` | Used by cloud providers to connect the resulting `LoadBalancer` to a pre-existing static IP according to https://kubernetes.io/docs/concepts/services-networking/service/#loadbalancer | | controller.service.loadBalancerSourceRanges | list | `[]` | | | controller.service.nodePorts.http | string | `""` | | | controller.service.nodePorts.https | string | `""` | | @@ -409,6 +413,7 @@ Kubernetes: `>=1.19.0-0` | controller.service.targetPorts.http | string | `"http"` | | | controller.service.targetPorts.https | string | `"https"` | | | controller.service.type | string | `"LoadBalancer"` | | +| controller.shareProcessNamespace | bool | `false` | | | controller.sysctls | object | `{}` | See https://kubernetes.io/docs/tasks/administer-cluster/sysctl-cluster/ for notes on enabling and using sysctls | | controller.tcp.annotations | object | `{}` | Annotations to be added to the tcp config configmap | | controller.tcp.configMapNamespace | string | `""` | Allows customization of the tcp-services-configmap; defaults to $(POD_NAMESPACE) | @@ -437,7 +442,7 @@ Kubernetes: `>=1.19.0-0` | defaultBackend.image.image | string | `"defaultbackend-amd64"` | | | defaultBackend.image.pullPolicy | string | `"IfNotPresent"` | | | defaultBackend.image.readOnlyRootFilesystem | bool | `true` | | -| defaultBackend.image.registry | string | `"k8s.gcr.io"` | | +| defaultBackend.image.registry | string | `"registry.k8s.io"` | | | defaultBackend.image.runAsNonRoot | bool | `true` | | | defaultBackend.image.runAsUser | int | `65534` | | | defaultBackend.image.tag | string | `"1.5"` | | @@ -474,6 +479,7 @@ Kubernetes: `>=1.19.0-0` | dhParam | string | `nil` | A base64-encoded Diffie-Hellman parameter. This can be generated with: `openssl dhparam 4096 2> /dev/null | base64` | | imagePullSecrets | list | `[]` | Optional array of imagePullSecrets containing private registry credentials | | podSecurityPolicy.enabled | bool | `false` | | +| portNamePrefix | string | `""` | Prefix for TCP and UDP ports names in ingress controller service | | rbac.create | bool | `true` | | | rbac.scope | bool | `false` | | | revisionHistoryLimit | int | `10` | Rollback limit | @@ -481,6 +487,6 @@ Kubernetes: `>=1.19.0-0` | serviceAccount.automountServiceAccountToken | bool | `true` | | | serviceAccount.create | bool | `true` | | | serviceAccount.name | string | `""` | | -| tcp | object | `{}` | TCP service key:value pairs | -| udp | object | `{}` | UDP service key:value pairs | +| tcp | object | `{}` | TCP service key-value pairs | +| udp | object | `{}` | UDP service key-value pairs | diff --git a/scripts/helmcharts/openreplay/charts/ingress-nginx/README.md.gotmpl b/scripts/helmcharts/openreplay/charts/ingress-nginx/README.md.gotmpl index 5cd9e59e1..895996111 100644 --- a/scripts/helmcharts/openreplay/charts/ingress-nginx/README.md.gotmpl +++ b/scripts/helmcharts/openreplay/charts/ingress-nginx/README.md.gotmpl @@ -110,7 +110,7 @@ controller: ### AWS L7 ELB with SSL Termination -Annotate the controller as shown in the [nginx-ingress l7 patch](https://github.com/kubernetes/ingress-nginx/blob/main/deploy/aws/l7/service-l7.yaml): +Annotate the controller as shown in the [nginx-ingress l7 patch](https://github.com/kubernetes/ingress-nginx/blob/ab3a789caae65eec4ad6e3b46b19750b481b6bce/deploy/aws/l7/service-l7.yaml): ```yaml controller: @@ -127,7 +127,7 @@ controller: ### AWS route53-mapper -To configure the LoadBalancer service with the [route53-mapper addon](https://github.com/kubernetes/kops/tree/master/addons/route53-mapper), add the `domainName` annotation and `dns` label: +To configure the LoadBalancer service with the [route53-mapper addon](https://github.com/kubernetes/kops/blob/be63d4f1a7a46daaf1c4c482527328236850f111/addons/route53-mapper/README.md), add the `domainName` annotation and `dns` label: ```yaml controller: diff --git a/scripts/helmcharts/openreplay/charts/ingress-nginx/ci/daemonset-tcp-udp-portNamePrefix-values.yaml b/scripts/helmcharts/openreplay/charts/ingress-nginx/ci/daemonset-tcp-udp-portNamePrefix-values.yaml new file mode 100644 index 000000000..90b0f57a5 --- /dev/null +++ b/scripts/helmcharts/openreplay/charts/ingress-nginx/ci/daemonset-tcp-udp-portNamePrefix-values.yaml @@ -0,0 +1,18 @@ +controller: + kind: DaemonSet + image: + repository: ingress-controller/controller + tag: 1.0.0-dev + digest: null + admissionWebhooks: + enabled: false + service: + type: ClusterIP + +tcp: + 9000: "default/test:8080" + +udp: + 9001: "default/test:8080" + +portNamePrefix: "port" diff --git a/scripts/helmcharts/openreplay/charts/ingress-nginx/ci/deployment-tcp-udp-portNamePrefix-values.yaml b/scripts/helmcharts/openreplay/charts/ingress-nginx/ci/deployment-tcp-udp-portNamePrefix-values.yaml new file mode 100644 index 000000000..56323c5ee --- /dev/null +++ b/scripts/helmcharts/openreplay/charts/ingress-nginx/ci/deployment-tcp-udp-portNamePrefix-values.yaml @@ -0,0 +1,17 @@ +controller: + image: + repository: ingress-controller/controller + tag: 1.0.0-dev + digest: null + admissionWebhooks: + enabled: false + service: + type: ClusterIP + +tcp: + 9000: "default/test:8080" + +udp: + 9001: "default/test:8080" + +portNamePrefix: "port" diff --git a/scripts/helmcharts/openreplay/charts/ingress-nginx/ci/deployment-webhook-extraEnvs-values.yaml b/scripts/helmcharts/openreplay/charts/ingress-nginx/ci/deployment-webhook-extraEnvs-values.yaml new file mode 100644 index 000000000..95487b071 --- /dev/null +++ b/scripts/helmcharts/openreplay/charts/ingress-nginx/ci/deployment-webhook-extraEnvs-values.yaml @@ -0,0 +1,12 @@ +controller: + service: + type: ClusterIP + admissionWebhooks: + enabled: true + extraEnvs: + - name: FOO + value: foo + - name: TEST + value: test + patch: + enabled: true diff --git a/scripts/helmcharts/openreplay/charts/ingress-nginx/templates/_helpers.tpl b/scripts/helmcharts/openreplay/charts/ingress-nginx/templates/_helpers.tpl index a72af5d9d..e69de0c41 100644 --- a/scripts/helmcharts/openreplay/charts/ingress-nginx/templates/_helpers.tpl +++ b/scripts/helmcharts/openreplay/charts/ingress-nginx/templates/_helpers.tpl @@ -43,11 +43,40 @@ capabilities: - ALL add: - NET_BIND_SERVICE + {{- if .Values.controller.image.chroot }} + - SYS_CHROOT + {{- end }} runAsUser: {{ .Values.controller.image.runAsUser }} allowPrivilegeEscalation: {{ .Values.controller.image.allowPrivilegeEscalation }} {{- end }} {{- end -}} +{{/* +Get specific image +*/}} +{{- define "ingress-nginx.image" -}} +{{- if .chroot -}} +{{- printf "%s-chroot" .image -}} +{{- else -}} +{{- printf "%s" .image -}} +{{- end }} +{{- end -}} + +{{/* +Get specific image digest +*/}} +{{- define "ingress-nginx.imageDigest" -}} +{{- if .chroot -}} +{{- if .digestChroot -}} +{{- printf "@%s" .digestChroot -}} +{{- end }} +{{- else -}} +{{ if .digest -}} +{{- printf "@%s" .digest -}} +{{- end -}} +{{- end -}} +{{- end -}} + {{/* Create a default fully qualified controller name. We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). diff --git a/scripts/helmcharts/openreplay/charts/ingress-nginx/templates/admission-webhooks/job-patch/job-createSecret.yaml b/scripts/helmcharts/openreplay/charts/ingress-nginx/templates/admission-webhooks/job-patch/job-createSecret.yaml index f20e247f9..72c17eae4 100644 --- a/scripts/helmcharts/openreplay/charts/ingress-nginx/templates/admission-webhooks/job-patch/job-createSecret.yaml +++ b/scripts/helmcharts/openreplay/charts/ingress-nginx/templates/admission-webhooks/job-patch/job-createSecret.yaml @@ -56,6 +56,9 @@ spec: valueFrom: fieldRef: fieldPath: metadata.namespace + {{- if .Values.controller.admissionWebhooks.extraEnvs }} + {{- toYaml .Values.controller.admissionWebhooks.extraEnvs | nindent 12 }} + {{- end }} securityContext: allowPrivilegeEscalation: false {{- if .Values.controller.admissionWebhooks.createSecretJob.resources }} diff --git a/scripts/helmcharts/openreplay/charts/ingress-nginx/templates/admission-webhooks/job-patch/job-patchWebhook.yaml b/scripts/helmcharts/openreplay/charts/ingress-nginx/templates/admission-webhooks/job-patch/job-patchWebhook.yaml index 8583685fa..3a1637a64 100644 --- a/scripts/helmcharts/openreplay/charts/ingress-nginx/templates/admission-webhooks/job-patch/job-patchWebhook.yaml +++ b/scripts/helmcharts/openreplay/charts/ingress-nginx/templates/admission-webhooks/job-patch/job-patchWebhook.yaml @@ -58,6 +58,9 @@ spec: valueFrom: fieldRef: fieldPath: metadata.namespace + {{- if .Values.controller.admissionWebhooks.extraEnvs }} + {{- toYaml .Values.controller.admissionWebhooks.extraEnvs | nindent 12 }} + {{- end }} securityContext: allowPrivilegeEscalation: false {{- if .Values.controller.admissionWebhooks.patchWebhookJob.resources }} diff --git a/scripts/helmcharts/openreplay/charts/ingress-nginx/templates/clusterrole.yaml b/scripts/helmcharts/openreplay/charts/ingress-nginx/templates/clusterrole.yaml index c093f048a..0e725ec06 100644 --- a/scripts/helmcharts/openreplay/charts/ingress-nginx/templates/clusterrole.yaml +++ b/scripts/helmcharts/openreplay/charts/ingress-nginx/templates/clusterrole.yaml @@ -29,6 +29,13 @@ rules: verbs: - list - watch + - apiGroups: + - coordination.k8s.io + resources: + - leases + verbs: + - list + - watch {{- if and .Values.controller.scope.enabled .Values.controller.scope.namespace }} - apiGroups: - "" diff --git a/scripts/helmcharts/openreplay/charts/ingress-nginx/templates/controller-daemonset.yaml b/scripts/helmcharts/openreplay/charts/ingress-nginx/templates/controller-daemonset.yaml index 72811fbe4..2dca8e5c1 100644 --- a/scripts/helmcharts/openreplay/charts/ingress-nginx/templates/controller-daemonset.yaml +++ b/scripts/helmcharts/openreplay/charts/ingress-nginx/templates/controller-daemonset.yaml @@ -67,11 +67,14 @@ spec: - name: {{ $sysctl | quote }} value: {{ $value | quote }} {{- end }} + {{- end }} + {{- if .Values.controller.shareProcessNamespace }} + shareProcessNamespace: {{ .Values.controller.shareProcessNamespace }} {{- end }} containers: - name: {{ .Values.controller.containerName }} {{- with .Values.controller.image }} - image: "{{- if .repository -}}{{ .repository }}{{ else }}{{ .registry }}/{{ .image }}{{- end -}}:{{ .tag }}{{- if (.digest) -}} @{{.digest}} {{- end -}}" + image: "{{- if .repository -}}{{ .repository }}{{ else }}{{ .registry }}/{{ include "ingress-nginx.image" . }}{{- end -}}:{{ .tag }}{{ include "ingress-nginx.imageDigest" . }}" {{- end }} imagePullPolicy: {{ .Values.controller.image.pullPolicy }} {{- if .Values.controller.lifecycle }} @@ -79,14 +82,7 @@ spec: {{- end }} args: {{- include "ingress-nginx.params" . | nindent 12 }} - securityContext: - capabilities: - drop: - - ALL - add: - - NET_BIND_SERVICE - runAsUser: {{ .Values.controller.image.runAsUser }} - allowPrivilegeEscalation: {{ .Values.controller.image.allowPrivilegeEscalation }} + securityContext: {{ include "controller.containerSecurityContext" . | nindent 12 }} env: - name: POD_NAME valueFrom: @@ -128,7 +124,7 @@ spec: protocol: TCP {{- end }} {{- range $key, $value := .Values.tcp }} - - name: {{ $key }}-tcp + - name: {{ if $.Values.portNamePrefix }}{{ $.Values.portNamePrefix }}-{{ end }}{{ $key }}-tcp containerPort: {{ $key }} protocol: TCP {{- if $.Values.controller.hostPort.enabled }} @@ -136,7 +132,7 @@ spec: {{- end }} {{- end }} {{- range $key, $value := .Values.udp }} - - name: {{ $key }}-udp + - name: {{ if $.Values.portNamePrefix }}{{ $.Values.portNamePrefix }}-{{ end }}{{ $key }}-udp containerPort: {{ $key }} protocol: UDP {{- if $.Values.controller.hostPort.enabled }} diff --git a/scripts/helmcharts/openreplay/charts/ingress-nginx/templates/controller-deployment.yaml b/scripts/helmcharts/openreplay/charts/ingress-nginx/templates/controller-deployment.yaml index a1943cd91..5b781f8de 100644 --- a/scripts/helmcharts/openreplay/charts/ingress-nginx/templates/controller-deployment.yaml +++ b/scripts/helmcharts/openreplay/charts/ingress-nginx/templates/controller-deployment.yaml @@ -71,11 +71,14 @@ spec: - name: {{ $sysctl | quote }} value: {{ $value | quote }} {{- end }} + {{- end }} + {{- if .Values.controller.shareProcessNamespace }} + shareProcessNamespace: {{ .Values.controller.shareProcessNamespace }} {{- end }} containers: - name: {{ .Values.controller.containerName }} {{- with .Values.controller.image }} - image: "{{- if .repository -}}{{ .repository }}{{ else }}{{ .registry }}/{{ .image }}{{- end -}}:{{ .tag }}{{- if (.digest) -}} @{{.digest}} {{- end -}}" + image: "{{- if .repository -}}{{ .repository }}{{ else }}{{ .registry }}/{{ include "ingress-nginx.image" . }}{{- end -}}:{{ .tag }}{{ include "ingress-nginx.imageDigest" . }}" {{- end }} imagePullPolicy: {{ .Values.controller.image.pullPolicy }} {{- if .Values.controller.lifecycle }} @@ -125,7 +128,7 @@ spec: protocol: TCP {{- end }} {{- range $key, $value := .Values.tcp }} - - name: {{ $key }}-tcp + - name: {{ if $.Values.portNamePrefix }}{{ $.Values.portNamePrefix }}-{{ end }}{{ $key }}-tcp containerPort: {{ $key }} protocol: TCP {{- if $.Values.controller.hostPort.enabled }} @@ -133,7 +136,7 @@ spec: {{- end }} {{- end }} {{- range $key, $value := .Values.udp }} - - name: {{ $key }}-udp + - name: {{ if $.Values.portNamePrefix }}{{ $.Values.portNamePrefix }}-{{ end }}{{ $key }}-udp containerPort: {{ $key }} protocol: UDP {{- if $.Values.controller.hostPort.enabled }} diff --git a/scripts/helmcharts/openreplay/charts/ingress-nginx/templates/controller-prometheusrules.yaml b/scripts/helmcharts/openreplay/charts/ingress-nginx/templates/controller-prometheusrules.yaml index ca5427523..78b5362e8 100644 --- a/scripts/helmcharts/openreplay/charts/ingress-nginx/templates/controller-prometheusrules.yaml +++ b/scripts/helmcharts/openreplay/charts/ingress-nginx/templates/controller-prometheusrules.yaml @@ -1,4 +1,4 @@ -{{- if and .Values.controller.metrics.enabled .Values.controller.metrics.prometheusRule.enabled -}} +{{- if and ( .Values.controller.metrics.enabled ) ( .Values.controller.metrics.prometheusRule.enabled ) ( .Capabilities.APIVersions.Has "monitoring.coreos.com/v1" ) -}} apiVersion: monitoring.coreos.com/v1 kind: PrometheusRule metadata: diff --git a/scripts/helmcharts/openreplay/charts/ingress-nginx/templates/controller-psp.yaml b/scripts/helmcharts/openreplay/charts/ingress-nginx/templates/controller-psp.yaml index a859594d1..fe34408c8 100644 --- a/scripts/helmcharts/openreplay/charts/ingress-nginx/templates/controller-psp.yaml +++ b/scripts/helmcharts/openreplay/charts/ingress-nginx/templates/controller-psp.yaml @@ -12,6 +12,9 @@ metadata: spec: allowedCapabilities: - NET_BIND_SERVICE + {{- if .Values.controller.image.chroot }} + - SYS_CHROOT + {{- end }} {{- if .Values.controller.sysctls }} allowedUnsafeSysctls: {{- range $sysctl, $value := .Values.controller.sysctls }} diff --git a/scripts/helmcharts/openreplay/charts/ingress-nginx/templates/controller-role.yaml b/scripts/helmcharts/openreplay/charts/ingress-nginx/templates/controller-role.yaml index 47bbc32d0..8e5f8a0d7 100644 --- a/scripts/helmcharts/openreplay/charts/ingress-nginx/templates/controller-role.yaml +++ b/scripts/helmcharts/openreplay/charts/ingress-nginx/templates/controller-role.yaml @@ -73,6 +73,21 @@ rules: - configmaps verbs: - create + - apiGroups: + - coordination.k8s.io + resources: + - leases + resourceNames: + - {{ .Values.controller.electionID }} + verbs: + - get + - update + - apiGroups: + - coordination.k8s.io + resources: + - leases + verbs: + - create - apiGroups: - "" resources: diff --git a/scripts/helmcharts/openreplay/charts/ingress-nginx/templates/controller-service-internal.yaml b/scripts/helmcharts/openreplay/charts/ingress-nginx/templates/controller-service-internal.yaml index 599449836..aae3e155e 100644 --- a/scripts/helmcharts/openreplay/charts/ingress-nginx/templates/controller-service-internal.yaml +++ b/scripts/helmcharts/openreplay/charts/ingress-nginx/templates/controller-service-internal.yaml @@ -52,10 +52,10 @@ spec: {{- end }} {{- end }} {{- range $key, $value := .Values.tcp }} - - name: {{ $key }}-tcp + - name: {{ if $.Values.portNamePrefix }}{{ $.Values.portNamePrefix }}-{{ end }}{{ $key }}-tcp port: {{ $key }} protocol: TCP - targetPort: {{ $key }}-tcp + targetPort: {{ if $.Values.portNamePrefix }}{{ $.Values.portNamePrefix }}-{{ end }}{{ $key }}-tcp {{- if $.Values.controller.service.nodePorts.tcp }} {{- if index $.Values.controller.service.nodePorts.tcp $key }} nodePort: {{ index $.Values.controller.service.nodePorts.tcp $key }} @@ -63,10 +63,10 @@ spec: {{- end }} {{- end }} {{- range $key, $value := .Values.udp }} - - name: {{ $key }}-udp + - name: {{ if $.Values.portNamePrefix }}{{ $.Values.portNamePrefix }}-{{ end }}{{ $key }}-udp port: {{ $key }} protocol: UDP - targetPort: {{ $key }}-udp + targetPort: {{ if $.Values.portNamePrefix }}{{ $.Values.portNamePrefix }}-{{ end }}{{ $key }}-udp {{- if $.Values.controller.service.nodePorts.udp }} {{- if index $.Values.controller.service.nodePorts.udp $key }} nodePort: {{ index $.Values.controller.service.nodePorts.udp $key }} diff --git a/scripts/helmcharts/openreplay/charts/ingress-nginx/templates/controller-service.yaml b/scripts/helmcharts/openreplay/charts/ingress-nginx/templates/controller-service.yaml index 05fb2041e..2b28196de 100644 --- a/scripts/helmcharts/openreplay/charts/ingress-nginx/templates/controller-service.yaml +++ b/scripts/helmcharts/openreplay/charts/ingress-nginx/templates/controller-service.yaml @@ -37,12 +37,12 @@ spec: {{- if .Values.controller.service.healthCheckNodePort }} healthCheckNodePort: {{ .Values.controller.service.healthCheckNodePort }} {{- end }} -{{- if semverCompare ">=1.20.0-0" .Capabilities.KubeVersion.Version -}} +{{- if semverCompare ">=1.21.0-0" .Capabilities.KubeVersion.Version -}} {{- if .Values.controller.service.ipFamilyPolicy }} ipFamilyPolicy: {{ .Values.controller.service.ipFamilyPolicy }} {{- end }} {{- end }} -{{- if semverCompare ">=1.20.0-0" .Capabilities.KubeVersion.Version -}} +{{- if semverCompare ">=1.21.0-0" .Capabilities.KubeVersion.Version -}} {{- if .Values.controller.service.ipFamilies }} ipFamilies: {{ toYaml .Values.controller.service.ipFamilies | nindent 4 }} {{- end }} @@ -74,10 +74,10 @@ spec: {{- end }} {{- end }} {{- range $key, $value := .Values.tcp }} - - name: {{ $key }}-tcp + - name: {{ if $.Values.portNamePrefix }}{{ $.Values.portNamePrefix }}-{{ end }}{{ $key }}-tcp port: {{ $key }} protocol: TCP - targetPort: {{ $key }}-tcp + targetPort: {{ if $.Values.portNamePrefix }}{{ $.Values.portNamePrefix }}-{{ end }}{{ $key }}-tcp {{- if $.Values.controller.service.nodePorts.tcp }} {{- if index $.Values.controller.service.nodePorts.tcp $key }} nodePort: {{ index $.Values.controller.service.nodePorts.tcp $key }} @@ -85,10 +85,10 @@ spec: {{- end }} {{- end }} {{- range $key, $value := .Values.udp }} - - name: {{ $key }}-udp + - name: {{ if $.Values.portNamePrefix }}{{ $.Values.portNamePrefix }}-{{ end }}{{ $key }}-udp port: {{ $key }} protocol: UDP - targetPort: {{ $key }}-udp + targetPort: {{ if $.Values.portNamePrefix }}{{ $.Values.portNamePrefix }}-{{ end }}{{ $key }}-udp {{- if $.Values.controller.service.nodePorts.udp }} {{- if index $.Values.controller.service.nodePorts.udp $key }} nodePort: {{ index $.Values.controller.service.nodePorts.udp $key }} diff --git a/scripts/helmcharts/openreplay/charts/ingress-nginx/values.yaml b/scripts/helmcharts/openreplay/charts/ingress-nginx/values.yaml index c887dc051..cddc2c742 100644 --- a/scripts/helmcharts/openreplay/charts/ingress-nginx/values.yaml +++ b/scripts/helmcharts/openreplay/charts/ingress-nginx/values.yaml @@ -16,13 +16,16 @@ commonLabels: {} controller: name: controller image: - registry: k8s.gcr.io + ## Keep false as default for now! + chroot: false + registry: registry.k8s.io image: ingress-nginx/controller ## for backwards compatibility consider setting the full image url via the repository value below ## use *either* current default registry/image or repository format or installing chart by providing the values.yaml will fail ## repository: - tag: "v1.1.2" - digest: sha256:28b11ce69e57843de44e3db6413e98d09de0f6688e33d4bd384002a44f78405c + tag: "v1.3.0" + digest: sha256:d1707ca76d3b044ab8a28277a2466a02100ee9f58a86af1535a3edf9323ea1b5 + digestChroot: sha256:0fcb91216a22aae43b374fc2e6a03b8afe9e8c78cbf07a09d75636dc4ea3c191 pullPolicy: IfNotPresent # www-data -> uid 101 runAsUser: 101 @@ -272,7 +275,7 @@ controller: ## topologySpreadConstraints: [] # - maxSkew: 1 - # topologyKey: failure-domain.beta.kubernetes.io/zone + # topologyKey: topology.kubernetes.io/zone # whenUnsatisfiable: DoNotSchedule # labelSelector: # matchLabels: @@ -457,7 +460,8 @@ controller: ## externalIPs: [] - # loadBalancerIP: "" + # -- Used by cloud providers to connect the resulting `LoadBalancer` to a pre-existing static IP according to https://kubernetes.io/docs/concepts/services-networking/service/#loadbalancer + loadBalancerIP: "" loadBalancerSourceRanges: [] enableHttp: true @@ -529,6 +533,10 @@ controller: ## Ref: https://kubernetes.io/docs/tutorials/services/source-ip/#source-ip-for-services-with-typeloadbalancer # externalTrafficPolicy: "" + # shareProcessNamespace enables process namespace sharing within the pod. + # This can be used for example to signal log rotation using `kill -USR1` from a sidecar. + shareProcessNamespace: false + # -- Additional containers to be added to the controller pod. # See https://github.com/lemonldap-ng-controller/lemonldap-ng-controller as example. extraContainers: [] @@ -572,7 +580,7 @@ controller: extraModules: [] ## Modules, which are mounted into the core nginx image # - name: opentelemetry - # image: busybox + # image: registry.k8s.io/ingress-nginx/opentelemetry:v20220415-controller-v1.2.0-beta.0-2-g81c2afd97@sha256:ce61e2cf0b347dffebb2dcbf57c33891d2217c1bad9c0959c878e5be671ef941 # # The image must contain a `/usr/local/bin/init_module.sh` executable, which # will be executed as initContainers, to move its config files within the @@ -586,6 +594,15 @@ controller: ## These annotations will be added to the ValidatingWebhookConfiguration and ## the Jobs Spec of the admission webhooks. enabled: true + # -- Additional environment variables to set + extraEnvs: [] + # extraEnvs: + # - name: FOO + # valueFrom: + # secretKeyRef: + # key: FOO + # name: secret-resource + # -- Admission Webhook failure policy to use failurePolicy: Fail # timeoutSeconds: 10 port: 8443 @@ -623,7 +640,7 @@ controller: patch: enabled: true image: - registry: k8s.gcr.io + registry: registry.k8s.io image: ingress-nginx/kube-webhook-certgen ## for backwards compatibility consider setting the full image url via the repository value below ## use *either* current default registry/image or repository format or installing chart by providing the values.yaml will fail @@ -750,7 +767,7 @@ defaultBackend: name: defaultbackend image: - registry: k8s.gcr.io + registry: registry.k8s.io image: defaultbackend-amd64 ## for backwards compatibility consider setting the full image url via the repository value below ## use *either* current default registry/image or repository format or installing chart by providing the values.yaml will fail @@ -901,18 +918,22 @@ serviceAccount: imagePullSecrets: [] # - name: secretName -# -- TCP service key:value pairs +# -- TCP service key-value pairs ## Ref: https://github.com/kubernetes/ingress-nginx/blob/main/docs/user-guide/exposing-tcp-udp-services.md ## tcp: {} # 8080: "default/example-tcp-svc:9000" -# -- UDP service key:value pairs +# -- UDP service key-value pairs ## Ref: https://github.com/kubernetes/ingress-nginx/blob/main/docs/user-guide/exposing-tcp-udp-services.md ## udp: {} # 53: "kube-system/kube-dns:53" +# -- Prefix for TCP and UDP ports names in ingress controller service +## Some cloud providers, like Yandex Cloud may have a requirements for a port name regex to support cloud load balancer integration +portNamePrefix: "" + # -- (string) A base64-encoded Diffie-Hellman parameter. # This can be generated with: `openssl dhparam 4096 2> /dev/null | base64` ## Ref: https://github.com/kubernetes/ingress-nginx/tree/main/docs/examples/customization/ssl-dh-param diff --git a/scripts/helmcharts/openreplay/charts/integrations/Chart.yaml b/scripts/helmcharts/openreplay/charts/integrations/Chart.yaml index 6ddfc7ee9..ed13e0a03 100644 --- a/scripts/helmcharts/openreplay/charts/integrations/Chart.yaml +++ b/scripts/helmcharts/openreplay/charts/integrations/Chart.yaml @@ -21,4 +21,4 @@ version: 0.1.0 # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -AppVersion: "v1.7.0" +AppVersion: "v1.8.0" diff --git a/scripts/helmcharts/openreplay/charts/peers/Chart.yaml b/scripts/helmcharts/openreplay/charts/peers/Chart.yaml index 1cd3e9033..7fc77f776 100644 --- a/scripts/helmcharts/openreplay/charts/peers/Chart.yaml +++ b/scripts/helmcharts/openreplay/charts/peers/Chart.yaml @@ -21,4 +21,4 @@ version: 0.1.0 # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -AppVersion: "v1.7.0" +AppVersion: "v1.8.0" diff --git a/scripts/helmcharts/openreplay/charts/quickwit/.helmignore b/scripts/helmcharts/openreplay/charts/quickwit/.helmignore new file mode 100644 index 000000000..0e8a0eb36 --- /dev/null +++ b/scripts/helmcharts/openreplay/charts/quickwit/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/scripts/helmcharts/openreplay/charts/quickwit/Chart.yaml b/scripts/helmcharts/openreplay/charts/quickwit/Chart.yaml new file mode 100644 index 000000000..a43bf2224 --- /dev/null +++ b/scripts/helmcharts/openreplay/charts/quickwit/Chart.yaml @@ -0,0 +1,24 @@ +apiVersion: v2 +name: quickwit +description: A Helm chart for Kubernetes + +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful quickwit or functions for the chart developer. They're included as +# a dependency of application charts to inject those quickwit and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +# Versions are expected to follow Semantic Versioning (https://semver.org/) +version: 0.3.1 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application. Versions are not expected to +# follow Semantic Versioning. They should reflect the version the application is using. +# It is recommended to use it with quotes. +AppVersion: "v1.8.0" diff --git a/scripts/helmcharts/openreplay/charts/quickwit/files/index-config-fetch.yaml b/scripts/helmcharts/openreplay/charts/quickwit/files/index-config-fetch.yaml new file mode 100644 index 000000000..1d89f72c9 --- /dev/null +++ b/scripts/helmcharts/openreplay/charts/quickwit/files/index-config-fetch.yaml @@ -0,0 +1,40 @@ +# +# Index config file for gh-archive dataset. +# + +version: 0 + +index_id: fetchevent + +doc_mapping: + mode: strict + field_mappings: + - name: method + type: text + tokenizer: default + record: position + - name: url + type: text + tokenizer: default + record: position + - name: request + type: text + tokenizer: default + record: position + - name: response + type: text + tokenizer: default + record: position + - name: status + type: i64 + indexed: true + fast: true + - name: timestamp + type: i64 + fast: true + - name: duration + type: i64 + fast: true + +search_settings: + default_search_fields: [url, request, response] diff --git a/scripts/helmcharts/openreplay/charts/quickwit/files/index-config-fetchevent.yaml b/scripts/helmcharts/openreplay/charts/quickwit/files/index-config-fetchevent.yaml new file mode 100644 index 000000000..1d89f72c9 --- /dev/null +++ b/scripts/helmcharts/openreplay/charts/quickwit/files/index-config-fetchevent.yaml @@ -0,0 +1,40 @@ +# +# Index config file for gh-archive dataset. +# + +version: 0 + +index_id: fetchevent + +doc_mapping: + mode: strict + field_mappings: + - name: method + type: text + tokenizer: default + record: position + - name: url + type: text + tokenizer: default + record: position + - name: request + type: text + tokenizer: default + record: position + - name: response + type: text + tokenizer: default + record: position + - name: status + type: i64 + indexed: true + fast: true + - name: timestamp + type: i64 + fast: true + - name: duration + type: i64 + fast: true + +search_settings: + default_search_fields: [url, request, response] diff --git a/scripts/helmcharts/openreplay/charts/quickwit/files/index-config-graphql.yaml b/scripts/helmcharts/openreplay/charts/quickwit/files/index-config-graphql.yaml new file mode 100644 index 000000000..bac1d8406 --- /dev/null +++ b/scripts/helmcharts/openreplay/charts/quickwit/files/index-config-graphql.yaml @@ -0,0 +1,30 @@ +# +# Index config file for gh-archive dataset. +# + +version: 0 + +index_id: graphql + +doc_mapping: + mode: strict + field_mappings: + - name: operation_kind + type: text + tokenizer: default + record: position + - name: operation_name + type: text + tokenizer: default + record: position + - name: variables + type: text + tokenizer: default + record: position + - name: response + type: text + tokenizer: default + record: position + +search_settings: + default_search_fields: [operation_kind, operation_name, variables] diff --git a/scripts/helmcharts/openreplay/charts/quickwit/files/index-config-pageevent.yaml b/scripts/helmcharts/openreplay/charts/quickwit/files/index-config-pageevent.yaml new file mode 100644 index 000000000..e47dd6a1d --- /dev/null +++ b/scripts/helmcharts/openreplay/charts/quickwit/files/index-config-pageevent.yaml @@ -0,0 +1,68 @@ +# +# Index config file for gh-archive dataset. +# + +version: 0 + +index_id: pageevent + +doc_mapping: + mode: strict + field_mappings: + - name: message_id + type: i64 + indexed: true + fast: true + - name: timestamp + type: i64 + fast: true + - name: url + type: text + tokenizer: default + record: position + - name: referrer + type: text + tokenizer: default + record: position + - name: loaded + type: bool + fast: true + - name: request_start + type: i64 + fast: true + - name: response_start + type: i64 + fast: true + - name: response_end + type: i64 + fast: true + - name: dom_content_loaded_event_start + type: i64 + fast: true + - name: dom_content_loaded_event_end + type: i64 + fast: true + - name: load_event_start + type: i64 + fast: true + - name: load_event_end + type: i64 + fast: true + - name: first_paint + type: i64 + fast: true + - name: first_contentful_paint + type: i64 + fast: true + - name: speed_index + type: i64 + fast: true + - name: visually_complete + type: i64 + fast: true + - name: time_to_interactive + type: i64 + fast: true + +search_settings: + default_search_fields: [url, referrer, visually_complete] diff --git a/scripts/helmcharts/openreplay/charts/quickwit/files/s3-config-listen.yaml b/scripts/helmcharts/openreplay/charts/quickwit/files/s3-config-listen.yaml new file mode 100644 index 000000000..d86d1c077 --- /dev/null +++ b/scripts/helmcharts/openreplay/charts/quickwit/files/s3-config-listen.yaml @@ -0,0 +1,6 @@ +## In order to save data into S3 +# metastore also accepts s3://{bucket/path}#pooling_interval={seconds}s +version: 0 +metastore_uri: s3://{{.Values.global.s3.quickwitBucket}}/quickwit-indexes +default_index_root_uri: s3://{{.Values.global.s3.quickwitBucket}}/quickwit-indexes +listen_address: 0.0.0.0 diff --git a/scripts/helmcharts/openreplay/charts/quickwit/files/s3-config.yaml b/scripts/helmcharts/openreplay/charts/quickwit/files/s3-config.yaml new file mode 100644 index 000000000..5de3de7c5 --- /dev/null +++ b/scripts/helmcharts/openreplay/charts/quickwit/files/s3-config.yaml @@ -0,0 +1,5 @@ +## In order to save data into S3 +# metastore also accepts s3://{bucket/path}#pooling_interval={seconds}s +version: 0 +metastore_uri: s3://{{.Values.global.s3.quickwitBucket}}/quickwit-indexes +default_index_root_uri: s3://{{.Values.global.s3.quickwitBucket}}/quickwit-indexes diff --git a/scripts/helmcharts/openreplay/charts/quickwit/files/source-fetch.yaml b/scripts/helmcharts/openreplay/charts/quickwit/files/source-fetch.yaml new file mode 100644 index 000000000..f562461b0 --- /dev/null +++ b/scripts/helmcharts/openreplay/charts/quickwit/files/source-fetch.yaml @@ -0,0 +1,14 @@ +# +# Source config file. +# + +source_id: fetch-kafka +source_type: kafka +params: + topic: quickwit + client_params: + group.id: fetch-consumer + bootstrap.servers: '{{ .Values.global.kafka.kafkaHost }}:{{ .Values.global.kafka.kafkaPort }}' + {{- if eq .Values.global.kafka.kafkaUseSsl "true" }} + security.protocol: SSL + {{- end}} diff --git a/scripts/helmcharts/openreplay/charts/quickwit/files/source-fetchevent.yaml b/scripts/helmcharts/openreplay/charts/quickwit/files/source-fetchevent.yaml new file mode 100644 index 000000000..f562461b0 --- /dev/null +++ b/scripts/helmcharts/openreplay/charts/quickwit/files/source-fetchevent.yaml @@ -0,0 +1,14 @@ +# +# Source config file. +# + +source_id: fetch-kafka +source_type: kafka +params: + topic: quickwit + client_params: + group.id: fetch-consumer + bootstrap.servers: '{{ .Values.global.kafka.kafkaHost }}:{{ .Values.global.kafka.kafkaPort }}' + {{- if eq .Values.global.kafka.kafkaUseSsl "true" }} + security.protocol: SSL + {{- end}} diff --git a/scripts/helmcharts/openreplay/charts/quickwit/files/source-graphql.yaml b/scripts/helmcharts/openreplay/charts/quickwit/files/source-graphql.yaml new file mode 100644 index 000000000..0c01e348d --- /dev/null +++ b/scripts/helmcharts/openreplay/charts/quickwit/files/source-graphql.yaml @@ -0,0 +1,14 @@ +# +# Source config file. +# + +source_id: graphql-kafka +source_type: kafka +params: + topic: quickwit + client_params: + group.id: graphql-consumer + bootstrap.servers: '{{ .Values.global.kafka.kafkaHost }}:{{ .Values.global.kafka.kafkaPort }}' + {{- if eq .Values.global.kafka.kafkaUseSsl "true" }} + security.protocol: SSL + {{- end}} diff --git a/scripts/helmcharts/openreplay/charts/quickwit/files/source-pageevent.yaml b/scripts/helmcharts/openreplay/charts/quickwit/files/source-pageevent.yaml new file mode 100644 index 000000000..fcdced5a9 --- /dev/null +++ b/scripts/helmcharts/openreplay/charts/quickwit/files/source-pageevent.yaml @@ -0,0 +1,14 @@ +# +# Source config file. +# + +source_id: pageevent-kafka +source_type: kafka +params: + topic: quickwit + client_params: + group.id: pageevent-consumer + bootstrap.servers: '{{ .Values.global.kafka.kafkaHost }}:{{ .Values.global.kafka.kafkaPort }}' + {{- if eq .Values.global.kafka.kafkaUseSsl "true" }} + security.protocol: SSL + {{- end}} diff --git a/scripts/helmcharts/openreplay/charts/quickwit/templates/NOTES.txt b/scripts/helmcharts/openreplay/charts/quickwit/templates/NOTES.txt new file mode 100644 index 000000000..cf0d94521 --- /dev/null +++ b/scripts/helmcharts/openreplay/charts/quickwit/templates/NOTES.txt @@ -0,0 +1,22 @@ +1. Get the application URL by running these commands: +{{- if .Values.ingress.enabled }} +{{- range $host := .Values.ingress.hosts }} + {{- range .paths }} + http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }} + {{- end }} +{{- end }} +{{- else if contains "NodePort" .Values.service.type }} + export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "quickwit.fullname" . }}) + export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") + echo http://$NODE_IP:$NODE_PORT +{{- else if contains "LoadBalancer" .Values.service.type }} + NOTE: It may take a few minutes for the LoadBalancer IP to be available. + You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "quickwit.fullname" . }}' + export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "quickwit.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") + echo http://$SERVICE_IP:{{ .Values.service.port }} +{{- else if contains "ClusterIP" .Values.service.type }} + export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "quickwit.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") + export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}") + echo "Visit http://127.0.0.1:8080 to use your application" + kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT +{{- end }} diff --git a/scripts/helmcharts/openreplay/charts/quickwit/templates/_helpers.tpl b/scripts/helmcharts/openreplay/charts/quickwit/templates/_helpers.tpl new file mode 100644 index 000000000..e0dcda017 --- /dev/null +++ b/scripts/helmcharts/openreplay/charts/quickwit/templates/_helpers.tpl @@ -0,0 +1,62 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "quickwit.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "quickwit.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "quickwit.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "quickwit.labels" -}} +helm.sh/chart: {{ include "quickwit.chart" . }} +{{ include "quickwit.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "quickwit.selectorLabels" -}} +app.kubernetes.io/name: {{ include "quickwit.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "quickwit.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "quickwit.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/scripts/helmcharts/openreplay/charts/quickwit/templates/configMap.yaml b/scripts/helmcharts/openreplay/charts/quickwit/templates/configMap.yaml new file mode 100644 index 000000000..d6bce5f28 --- /dev/null +++ b/scripts/helmcharts/openreplay/charts/quickwit/templates/configMap.yaml @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "quickwit.fullname" . }} + labels: + {{- include "quickwit.labels" . | nindent 4 }} + annotations: + "helm.sh/hook": pre-install, pre-upgrade + "helm.sh/hook-weight": "-4" # Higher precidence, so the first the config map will get created. +data: +{{/* https://helm.sh/docs/chart_template_guide/accessing_files/ */}} +{{ $currentScope := .}} +{{- range $path, $_ := .Files.Glob (printf "files/**.yaml") }} + {{- with $currentScope}} + {{- base $path | nindent 2}}: |- + {{- tpl (.Files.Get $path) . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/scripts/helmcharts/openreplay/charts/quickwit/templates/deployment.yaml b/scripts/helmcharts/openreplay/charts/quickwit/templates/deployment.yaml new file mode 100644 index 000000000..df6a3181e --- /dev/null +++ b/scripts/helmcharts/openreplay/charts/quickwit/templates/deployment.yaml @@ -0,0 +1,84 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "quickwit.fullname" . }} + labels: + {{- include "quickwit.labels" . | nindent 4 }} +spec: + {{- if not .Values.autoscaling.enabled }} + replicas: {{ .Values.replicaCount }} + {{- end }} + selector: + matchLabels: + {{- include "quickwit.selectorLabels" . | nindent 6 }} + template: + metadata: + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "quickwit.selectorLabels" . | nindent 8 }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "quickwit.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: {{ .Chart.Name }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- if .Values.healthCheck}} + {{- .Values.healthCheck | toYaml | nindent 10}} + {{- end}} + command: + - /bin/sh + - -c + args: + - | + set -x + ls -laR /quickwit/ + quickwit run --config config/s3-config-listen.yaml + env: + - name: AWS_DEFAULT_REGION + value: "{{ .Values.global.s3.region }}" + {{- if eq .Values.global.s3.endpoint "http://minio.db.svc.cluster.local:9000" }} + - name: QW_S3_ENDPOINT + value: 'http://minio.db.svc.cluster.local:9000' + {{- end}} + - name: AWS_ACCESS_KEY_ID + value: {{ .Values.global.s3.accessKey }} + - name: AWS_SECRET_ACCESS_KEY + value: {{ .Values.global.s3.secretKey }} + ports: + {{- range $key, $val := .Values.service.ports }} + - name: {{ $key }} + containerPort: {{ $val }} + protocol: TCP + {{- end }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + volumeMounts: + - name: configs + mountPath: /quickwit/config/ + volumes: + - name: configs + configMap: + name: {{ include "quickwit.fullname" . }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/scripts/helmcharts/openreplay/charts/quickwit/templates/hpa.yaml b/scripts/helmcharts/openreplay/charts/quickwit/templates/hpa.yaml new file mode 100644 index 000000000..48479c138 --- /dev/null +++ b/scripts/helmcharts/openreplay/charts/quickwit/templates/hpa.yaml @@ -0,0 +1,28 @@ +{{- if .Values.autoscaling.enabled }} +apiVersion: autoscaling/v2beta1 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "quickwit.fullname" . }} + labels: + {{- include "quickwit.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "quickwit.fullname" . }} + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + metrics: + {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + targetAverageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} + {{- end }} + {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + targetAverageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} + {{- end }} +{{- end }} diff --git a/scripts/helmcharts/openreplay/charts/quickwit/templates/ingress.yaml b/scripts/helmcharts/openreplay/charts/quickwit/templates/ingress.yaml new file mode 100644 index 000000000..8d95c0dd1 --- /dev/null +++ b/scripts/helmcharts/openreplay/charts/quickwit/templates/ingress.yaml @@ -0,0 +1,35 @@ +{{- if .Values.ingress.enabled }} +{{- $fullName := include "quickwit.fullname" . -}} +{{- $socketioSvcPort := .Values.service.ports.socketio -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ $fullName }} + labels: + {{- include "quickwit.labels" . | nindent 4 }} + annotations: + nginx.ingress.kubernetes.io/rewrite-target: /$1 + nginx.ingress.kubernetes.io/upstream-hash-by: $http_x_forwarded_for + {{- with .Values.ingress.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + ingressClassName: "{{ tpl .Values.ingress.className . }}" + tls: + - hosts: + - {{ .Values.global.domainName }} + {{- if .Values.ingress.tls.secretName}} + secretName: {{ .Values.ingress.tls.secretName }} + {{- end}} + rules: + - host: {{ .Values.global.domainName }} + http: + paths: + - pathType: Prefix + backend: + service: + name: {{ $fullName }} + port: + number: {{ $socketioSvcPort }} + path: /ws-quickwit/(.*) +{{- end }} diff --git a/scripts/helmcharts/openreplay/charts/quickwit/templates/init.yaml b/scripts/helmcharts/openreplay/charts/quickwit/templates/init.yaml new file mode 100644 index 000000000..6739de1af --- /dev/null +++ b/scripts/helmcharts/openreplay/charts/quickwit/templates/init.yaml @@ -0,0 +1,52 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ include "quickwit.fullname" . }} + labels: + {{- include "quickwit.labels" . | nindent 4 }} + annotations: + "helm.sh/hook": pre-install, pre-upgrade + "helm.sh/hook-weight": "-3" +spec: + backoffLimit: 0 # Don't restart failing containers + template: + metadata: + name: quickwitInitialization + spec: + containers: + - name: quickwit + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + env: + - name: AWS_DEFAULT_REGION + value: "{{ .Values.global.s3.region }}" + {{- if eq .Values.global.s3.endpoint "http://minio.db.svc.cluster.local:9000" }} + - name: QW_S3_ENDPOINT + value: 'http://minio.db.svc.cluster.local:9000' + {{- end}} + - name: AWS_ACCESS_KEY_ID + value: {{ .Values.global.s3.accessKey }} + - name: AWS_SECRET_ACCESS_KEY + value: {{ .Values.global.s3.secretKey }} + command: + - /bin/sh + - -c + args: + - | + set -x + + configs="" + configs="${configs} fetchevent" + configs="${configs} graphql" + configs="${configs} pageevent" + for config in ${configs};do + quickwit index create --index-config config/index-config-${config}.yaml --config config/s3-config.yaml || true + quickwit source create --index ${config} --source-config config/source-${config}.yaml --config config/s3-config.yaml || true + done + volumeMounts: + - name: dbmigrationscript + mountPath: /quickwit/config/ + restartPolicy: Never + volumes: + - name: dbmigrationscript + configMap: + name: {{ include "quickwit.fullname" . }} diff --git a/scripts/helmcharts/openreplay/charts/quickwit/templates/service.yaml b/scripts/helmcharts/openreplay/charts/quickwit/templates/service.yaml new file mode 100644 index 000000000..bb4eb5d44 --- /dev/null +++ b/scripts/helmcharts/openreplay/charts/quickwit/templates/service.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "quickwit.fullname" . }} + labels: + {{- include "quickwit.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + {{- range $key, $val := .Values.service.ports }} + - port: {{ $val }} + targetPort: {{ $key }} + protocol: TCP + name: {{ $key }} + {{- end}} + selector: + {{- include "quickwit.selectorLabels" . | nindent 4 }} diff --git a/scripts/helmcharts/openreplay/charts/quickwit/templates/serviceaccount.yaml b/scripts/helmcharts/openreplay/charts/quickwit/templates/serviceaccount.yaml new file mode 100644 index 000000000..5f9d6a7cc --- /dev/null +++ b/scripts/helmcharts/openreplay/charts/quickwit/templates/serviceaccount.yaml @@ -0,0 +1,12 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "quickwit.serviceAccountName" . }} + labels: + {{- include "quickwit.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/scripts/helmcharts/openreplay/charts/quickwit/values.yaml b/scripts/helmcharts/openreplay/charts/quickwit/values.yaml new file mode 100644 index 000000000..eaea505f8 --- /dev/null +++ b/scripts/helmcharts/openreplay/charts/quickwit/values.yaml @@ -0,0 +1,101 @@ +# Default values for openreplay. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +replicaCount: 1 + +image: + repository: quickwit/quickwit + pullPolicy: IfNotPresent + # Overrides the image tag whose default is the chart appVersion. + tag: "latest" + # tag: "v0.2.1" + +imagePullSecrets: [] +nameOverride: "quickwit" +fullnameOverride: "quickwit-openreplay" + + +indexConfig: |- + # Version of the index config file format + version: 0 + + # Sources + sources: + - source_id: openreplay-kafka-source + source_type: kafka + params: + topic: quickwit-indexer + client_params: + bootstrap.servers: '{{ .Values.global.kafka.kafkaHost }}:{{ .Values.global.kafka.kafkaPort }}' + {{- if eq .Values.global.kafka.kafkaUseSsl "true" }} + security.protocol: SSL + {{- end}} + +serviceAccount: + # Specifies whether a service account should be created + create: true + # Annotations to add to the service account + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: "" + +podAnnotations: {} + +podSecurityContext: {} + # fsGroup: 2000 + +securityContext: {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 + +service: + type: ClusterIP + ports: + search: 7280 + +ingress: + enabled: false + className: "{{ .Values.global.ingress.controller.ingressClassResource.name }}" + annotations: {} + # kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: "true" + tls: + secretName: openreplay-ssl + +resources: {} + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + +autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 5 + targetCPUUtilizationPercentage: 80 + # targetMemoryUtilizationPercentage: 80 + +env: + REDIS_URL: "redis://redis-master.db.svc.cluster.local:6379" + debug: 0 + uws: false + redis: false + + +nodeSelector: {} + +tolerations: [] + +affinity: {} diff --git a/scripts/helmcharts/openreplay/charts/sink/Chart.yaml b/scripts/helmcharts/openreplay/charts/sink/Chart.yaml index ba127a7b5..4faedee18 100644 --- a/scripts/helmcharts/openreplay/charts/sink/Chart.yaml +++ b/scripts/helmcharts/openreplay/charts/sink/Chart.yaml @@ -21,4 +21,4 @@ version: 0.1.0 # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -AppVersion: "v1.7.0" +AppVersion: "v1.8.0" diff --git a/scripts/helmcharts/openreplay/charts/storage/Chart.yaml b/scripts/helmcharts/openreplay/charts/storage/Chart.yaml index c54cb5508..17eec19e2 100644 --- a/scripts/helmcharts/openreplay/charts/storage/Chart.yaml +++ b/scripts/helmcharts/openreplay/charts/storage/Chart.yaml @@ -21,4 +21,4 @@ version: 0.1.0 # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -AppVersion: "v1.7.0" +AppVersion: "v1.8.0" diff --git a/scripts/helmcharts/openreplay/charts/utilities/Chart.yaml b/scripts/helmcharts/openreplay/charts/utilities/Chart.yaml index c1563cb9f..9a0796c4a 100644 --- a/scripts/helmcharts/openreplay/charts/utilities/Chart.yaml +++ b/scripts/helmcharts/openreplay/charts/utilities/Chart.yaml @@ -21,4 +21,4 @@ version: 0.1.0 # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -AppVersion: "v1.7.0" +AppVersion: "v1.8.0" diff --git a/scripts/helmcharts/openreplay/charts/utilities/templates/report-cron.yaml b/scripts/helmcharts/openreplay/charts/utilities/templates/report-cron.yaml index 678c15111..0685c7ad2 100644 --- a/scripts/helmcharts/openreplay/charts/utilities/templates/report-cron.yaml +++ b/scripts/helmcharts/openreplay/charts/utilities/templates/report-cron.yaml @@ -36,7 +36,7 @@ spec: value: 'https://{{ .Values.global.domainName }}' - name: S3_HOST {{- if eq .Values.global.s3.endpoint "http://minio.db.svc.cluster.local:9000" }} - value: 'https://{{ .Values.global.domainName }}' + value: 'https://{{ .Values.global.domainName }}:{{ .Values.global.ingress.controller.service.ports.https}}' {{- else}} value: '{{ .Values.global.s3.endpoint }}' {{- end}} diff --git a/scripts/helmcharts/openreplay/charts/utilities/templates/sessions-cleaner-cron.yaml b/scripts/helmcharts/openreplay/charts/utilities/templates/sessions-cleaner-cron.yaml index 2d625e97f..7441ca106 100644 --- a/scripts/helmcharts/openreplay/charts/utilities/templates/sessions-cleaner-cron.yaml +++ b/scripts/helmcharts/openreplay/charts/utilities/templates/sessions-cleaner-cron.yaml @@ -36,7 +36,7 @@ spec: value: 'https://{{ .Values.global.domainName }}' - name: S3_HOST {{- if eq .Values.global.s3.endpoint "http://minio.db.svc.cluster.local:9000" }} - value: 'https://{{ .Values.global.domainName }}' + value: 'https://{{ .Values.global.domainName }}:{{ .Values.global.ingress.controller.service.ports.https}}' {{- else}} value: '{{ .Values.global.s3.endpoint }}' {{- end}} diff --git a/scripts/helmcharts/openreplay/charts/utilities/templates/telemetry-cron.yaml b/scripts/helmcharts/openreplay/charts/utilities/templates/telemetry-cron.yaml index 57de6ce90..92ed861ff 100644 --- a/scripts/helmcharts/openreplay/charts/utilities/templates/telemetry-cron.yaml +++ b/scripts/helmcharts/openreplay/charts/utilities/templates/telemetry-cron.yaml @@ -36,7 +36,7 @@ spec: value: 'https://{{ .Values.global.domainName }}' - name: S3_HOST {{- if eq .Values.global.s3.endpoint "http://minio.db.svc.cluster.local:9000" }} - value: 'https://{{ .Values.global.domainName }}' + value: 'https://{{ .Values.global.domainName }}:{{ .Values.global.ingress.controller.service.ports.https}}' {{- else}} value: '{{ .Values.global.s3.endpoint }}' {{- end}} diff --git a/scripts/helmcharts/openreplay/files/clickhouse.sh b/scripts/helmcharts/openreplay/files/clickhouse.sh index 3cf48cc93..3e29cf2df 100644 --- a/scripts/helmcharts/openreplay/files/clickhouse.sh +++ b/scripts/helmcharts/openreplay/files/clickhouse.sh @@ -11,7 +11,7 @@ function migrate() { echo "Migrating clickhouse version $version" # For now, we can ignore the clickhouse db inject errors. # TODO: Better error handling in script - clickhouse-client -h clickhouse-openreplay-clickhouse.db.svc.cluster.local --port 9000 < ${clickhousedir}/${version}/${version}.sql || true + clickhouse-client -h clickhouse-openreplay-clickhouse.db.svc.cluster.local --port 9000 --multiquery < ${clickhousedir}/${version}/${version}.sql || true done } @@ -19,7 +19,7 @@ function init() { echo "Initializing clickhouse" for file in `ls ${clickhousedir}/create/*.sql`; do echo "Injecting $file" - clickhouse-client -h clickhouse-openreplay-clickhouse.db.svc.cluster.local --port 9000 < $file || true + clickhouse-client -h clickhouse-openreplay-clickhouse.db.svc.cluster.local --port 9000 --multiquery < $file || true done } diff --git a/scripts/helmcharts/openreplay/files/kafka.sh b/scripts/helmcharts/openreplay/files/kafka.sh index 5b1496837..0b58753b4 100644 --- a/scripts/helmcharts/openreplay/files/kafka.sh +++ b/scripts/helmcharts/openreplay/files/kafka.sh @@ -12,6 +12,7 @@ topics=( "cache" "analytics" "storage-failover" + "quickwit" ) touch /tmp/config.txt diff --git a/scripts/helmcharts/openreplay/files/minio.sh b/scripts/helmcharts/openreplay/files/minio.sh index 055bfd83e..28d04a28f 100644 --- a/scripts/helmcharts/openreplay/files/minio.sh +++ b/scripts/helmcharts/openreplay/files/minio.sh @@ -5,7 +5,7 @@ set -e cd /tmp -buckets=("mobs" "sessions-assets" "static" "sourcemaps" "sessions-mobile-assets") +buckets=("mobs" "sessions-assets" "static" "sourcemaps" "sessions-mobile-assets" "quickwit") mc alias set minio http://minio.db.svc.cluster.local:9000 $MINIO_ACCESS_KEY $MINIO_SECRET_KEY diff --git a/scripts/helmcharts/vars.yaml b/scripts/helmcharts/vars.yaml index 94d1fe50c..f8b6b2f8b 100644 --- a/scripts/helmcharts/vars.yaml +++ b/scripts/helmcharts/vars.yaml @@ -1,4 +1,4 @@ -fromVersion: "v1.7.0" +fromVersion: "v1.8.0" # Databases specific variables postgresql: &postgres # For generating passwords @@ -21,6 +21,10 @@ clickhouse: # For enterpriseEdition enabled: false +quickwit: &quickwit + # For enterpriseEdition + enabled: false + kafka: &kafka # For enterpriseEdition # enabled: true @@ -67,6 +71,9 @@ ingress-nginx: &ingress-nginx ingressClass: openreplay service: externalTrafficPolicy: "Local" + ports: + http: 80 + https: 443 extraArgs: default-ssl-certificate: "app/openreplay-ssl" config: @@ -90,6 +97,7 @@ global: postgresql: *postgres kafka: *kafka redis: *redis + quickwit: *quickwit openReplayContainerRegistry: "public.ecr.aws/p1t3u8a3" s3: region: "us-east-1" @@ -97,6 +105,8 @@ global: assetsBucket: "sessions-assets" recordingsBucket: "mobs" sourcemapsBucket: "sourcemaps" + # This is only for enterpriseEdition + quickwitBucket: "quickwit" # if you're using one node installation, where # you're using local s3, make sure these variables # are same as minio.global.minio.accesskey and secretKey diff --git a/sourcemap-uploader/LICENSE b/sourcemap-uploader/LICENSE index b57f138e0..15669f768 100644 --- a/sourcemap-uploader/LICENSE +++ b/sourcemap-uploader/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2021 OpenReplay.com +Copyright (c) 2022 Asayer, Inc Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/tracker/.husky/pre-commit b/tracker/.husky/pre-commit new file mode 100755 index 000000000..9471928e2 --- /dev/null +++ b/tracker/.husky/pre-commit @@ -0,0 +1,25 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +if git diff --cached --name-only | grep --quiet '^tracker/tracker/' +then + echo "tracker" + pwd + cd tracker/tracker + + npm run lint-front + + cd ../../ +fi + +if git diff --cached --name-only | grep --quiet '^tracker/tracker-assist/' +then + echo "tracker-assist" + cd tracker/tracker-assist + + npm run lint-front + + cd ../../ +fi + +exit 0 diff --git a/tracker/tracker-assist/.eslintignore b/tracker/tracker-assist/.eslintignore new file mode 100644 index 000000000..94b2f339c --- /dev/null +++ b/tracker/tracker-assist/.eslintignore @@ -0,0 +1,8 @@ +node_modules +npm-debug.log +lib +cjs +build +.cache +.eslintrc.cjs +src/common/messages.ts diff --git a/tracker/tracker-assist/.eslintrc.cjs b/tracker/tracker-assist/.eslintrc.cjs new file mode 100644 index 000000000..4480aa99f --- /dev/null +++ b/tracker/tracker-assist/.eslintrc.cjs @@ -0,0 +1,49 @@ +/* eslint-disable */ +module.exports = { + root: true, + parser: '@typescript-eslint/parser', + parserOptions: { + project: ['./tsconfig.json'], + tsconfigRootDir: __dirname, + }, + plugins: ['@typescript-eslint', 'prettier'], + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/eslint-recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:@typescript-eslint/recommended-requiring-type-checking', + 'prettier', + ], + rules: { + 'no-empty': [ + 'error', + { + allowEmptyCatch: true, + }, + ], + '@typescript-eslint/camelcase': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/unbound-method': 'off', + '@typescript-eslint/prefer-readonly': 'warn', + '@typescript-eslint/ban-ts-comment': 'off', + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unused-expressions': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/no-unsafe-argument': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/restrict-plus-operands': 'warn', + '@typescript-eslint/no-unsafe-return': 'warn', + 'no-useless-escape': 'warn', + 'no-control-regex': 'warn', + '@typescript-eslint/restrict-template-expressions': 'warn', + '@typescript-eslint/no-useless-constructor': 'warn', + '@typescript-eslint/no-this-alias': 'off', + '@typescript-eslint/no-floating-promises': 'warn', + 'no-unused-expressions': 'off', + '@typescript-eslint/no-useless-constructor': 'warn', + 'semi': ["error", "never"], + 'quotes': ["error", "single"], + 'comma-dangle': ["error", "always"] + }, +}; diff --git a/tracker/tracker-assist/LICENSE b/tracker/tracker-assist/LICENSE index b57f138e0..15669f768 100644 --- a/tracker/tracker-assist/LICENSE +++ b/tracker/tracker-assist/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2021 OpenReplay.com +Copyright (c) 2022 Asayer, Inc Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/tracker/tracker-assist/package.json b/tracker/tracker-assist/package.json index d04faef0d..f7f20cc39 100644 --- a/tracker/tracker-assist/package.json +++ b/tracker/tracker-assist/package.json @@ -1,7 +1,7 @@ { "name": "@openreplay/tracker-assist", "description": "Tracker plugin for screen assistance through the WebRTC", - "version": "3.5.16", + "version": "3.6.0", "keywords": [ "WebRTC", "assistance", @@ -13,7 +13,7 @@ "type": "module", "main": "./lib/index.js", "scripts": { - "lint": "prettier --write 'src/**/*.ts' README.md && tsc --noEmit", + "lint": "eslint src --ext .ts,.js --fix --quiet", "build": "npm run build-es && npm run build-cjs", "build-es": "rm -Rf lib && tsc && npm run replace-versions", "build-cjs": "rm -Rf cjs && tsc --project tsconfig-cjs.json && echo '{ \"type\": \"commonjs\" }' > cjs/package.json && npm run replace-paths && npm run replace-versions", @@ -21,7 +21,9 @@ "replace-versions": "npm run replace-pkg-version && npm run replace-req-version", "replace-pkg-version": "replace-in-files lib/* cjs/* --string='PACKAGE_VERSION' --replacement=$npm_package_version", "replace-req-version": "replace-in-files lib/* cjs/* --string='REQUIRED_TRACKER_VERSION' --replacement='3.5.14'", - "prepublishOnly": "npm run build" + "prepublishOnly": "npm run build", + "prepare": "cd ../../ && husky install tracker/.husky/", + "lint-front": "lint-staged" }, "dependencies": { "csstype": "^3.0.10", @@ -29,12 +31,29 @@ "socket.io-client": "^4.4.1" }, "peerDependencies": { - "@openreplay/tracker": "^3.5.3" + "@openreplay/tracker": "^3.6.0" }, "devDependencies": { "@openreplay/tracker": "file:../tracker", - "prettier": "^2.7.0", + "@typescript-eslint/eslint-plugin": "^5.30.0", + "@typescript-eslint/parser": "^5.30.0", + "eslint": "^7.8.0", + "eslint-config-prettier": "^8.5.0", + "eslint-plugin-prettier": "^4.2.1", + "husky": "^8.0.1", + "lint-staged": "^13.0.3", + "prettier": "^2.7.1", "replace-in-files-cli": "^1.0.0", "typescript": "^4.6.0-dev.20211126" + }, + "husky": { + "hooks": { + "pre-commit": "sh lint.sh" + } + }, + "lint-staged": { + "*.{js,mjs,cjs,jsx,ts,tsx}": [ + "eslint --fix --quiet" + ] } } diff --git a/tracker/tracker-assist/src/AnnotationCanvas.ts b/tracker/tracker-assist/src/AnnotationCanvas.ts index 11a06a781..1341045f6 100644 --- a/tracker/tracker-assist/src/AnnotationCanvas.ts +++ b/tracker/tracker-assist/src/AnnotationCanvas.ts @@ -1,14 +1,14 @@ export default class AnnotationCanvas { private canvas: HTMLCanvasElement private ctx: CanvasRenderingContext2D | null = null - private painting: boolean = false + private painting = false constructor() { this.canvas = document.createElement('canvas') Object.assign(this.canvas.style, { - position: "fixed", + position: 'fixed', left: 0, top: 0, - pointerEvents: "none", + pointerEvents: 'none', zIndex: 2147483647 - 2, }) } @@ -18,7 +18,7 @@ export default class AnnotationCanvas { this.canvas.height = window.innerHeight } - private lastPosition: [number, number] = [0,0] + private lastPosition: [number, number] = [0,0,] start = (p: [number, number]) => { this.painting = true this.clrTmID && clearTimeout(this.clrTmID) @@ -38,9 +38,9 @@ export default class AnnotationCanvas { this.ctx.moveTo(this.lastPosition[0], this.lastPosition[1]) this.ctx.lineTo(p[0], p[1]) this.ctx.lineWidth = 8 - this.ctx.lineCap = "round" - this.ctx.lineJoin = "round" - this.ctx.strokeStyle = "red" + this.ctx.lineCap = 'round' + this.ctx.lineJoin = 'round' + this.ctx.strokeStyle = 'red' this.ctx.stroke() this.lastPosition = p } @@ -51,7 +51,7 @@ export default class AnnotationCanvas { const fadeStep = () => { if (!this.ctx || this.painting ) { return } this.ctx.globalCompositeOperation = 'destination-out' - this.ctx.fillStyle = "rgba(255, 255, 255, 0.1)" + this.ctx.fillStyle = 'rgba(255, 255, 255, 0.1)' this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height) this.ctx.globalCompositeOperation = 'source-over' timeoutID = setTimeout(fadeStep,100) @@ -67,8 +67,8 @@ export default class AnnotationCanvas { mount() { document.body.appendChild(this.canvas) - this.ctx = this.canvas.getContext("2d") - window.addEventListener("resize", this.resizeCanvas) + this.ctx = this.canvas.getContext('2d') + window.addEventListener('resize', this.resizeCanvas) this.resizeCanvas() } @@ -76,6 +76,6 @@ export default class AnnotationCanvas { if (this.canvas.parentNode){ this.canvas.parentNode.removeChild(this.canvas) } - window.removeEventListener("resize", this.resizeCanvas) + window.removeEventListener('resize', this.resizeCanvas) } } \ No newline at end of file diff --git a/tracker/tracker-assist/src/Assist.ts b/tracker/tracker-assist/src/Assist.ts index 43f342481..02ecc50e0 100644 --- a/tracker/tracker-assist/src/Assist.ts +++ b/tracker/tracker-assist/src/Assist.ts @@ -1,20 +1,21 @@ -import type { Socket } from 'socket.io-client'; -import { connect } from 'socket.io-client'; -import Peer from 'peerjs'; -import type { Properties } from 'csstype'; -import { App } from '@openreplay/tracker'; +/* eslint-disable @typescript-eslint/no-empty-function */ +import type { Socket, } from 'socket.io-client' +import { connect, } from 'socket.io-client' +import Peer, { MediaConnection, } from 'peerjs' +import type { Properties, } from 'csstype' +import { App, } from '@openreplay/tracker' -import RequestLocalStream from './LocalStream.js'; -import RemoteControl from './RemoteControl.js'; -import CallWindow from './CallWindow.js'; -import AnnotationCanvas from './AnnotationCanvas.js'; -import ConfirmWindow from './ConfirmWindow/ConfirmWindow.js'; -import { callConfirmDefault } from './ConfirmWindow/defaults.js'; -import type { Options as ConfirmOptions } from './ConfirmWindow/defaults.js'; +import RequestLocalStream, { LocalStream, } from './LocalStream.js' +import RemoteControl from './RemoteControl.js' +import CallWindow from './CallWindow.js' +import AnnotationCanvas from './AnnotationCanvas.js' +import ConfirmWindow from './ConfirmWindow/ConfirmWindow.js' +import { callConfirmDefault, } from './ConfirmWindow/defaults.js' +import type { Options as ConfirmOptions, } from './ConfirmWindow/defaults.js' -// TODO: fully specified strict check (everywhere) +// TODO: fully specified strict check with no-any (everywhere) -type StartEndCallback = () => ((() => void) | void) +type StartEndCallback = () => ((()=>Record) | void) export interface Options { onAgentConnect: StartEndCallback, @@ -40,32 +41,32 @@ enum CallingState { // TODO typing???? -type OptionalCallback = (() => void) | void +type OptionalCallback = (()=>Record) | void type Agent = { onDisconnect?: OptionalCallback, onControlReleased?: OptionalCallback, - name?: string + //name?: string // } export default class Assist { - readonly version = "PACKAGE_VERSION" + readonly version = 'PACKAGE_VERSION' private socket: Socket | null = null private peer: Peer | null = null - private assistDemandedRestart: boolean = false + private assistDemandedRestart = false private callingState: CallingState = CallingState.False private agents: Record = {} private readonly options: Options constructor( - private readonly app: App, - options?: Partial, + private readonly app: App, + options?: Partial, private readonly noSecureMode: boolean = false, ) { - this.options = Object.assign({ - session_calling_peer_key: "__openreplay_calling_peer", - session_control_peer_key: "__openreplay_control_peer", + this.options = Object.assign({ + session_calling_peer_key: '__openreplay_calling_peer', + session_control_peer_key: '__openreplay_control_peer', config: null, onCallStart: ()=>{}, onAgentConnect: ()=>{}, @@ -74,10 +75,10 @@ export default class Assist { controlConfirm: {}, // TODO: clear options passing/merging/overriting }, options, - ); + ) if (document.hidden !== undefined) { - const sendActivityState = () => this.emit("UPDATE_SESSION", { active: !document.hidden }) + const sendActivityState = (): void => this.emit('UPDATE_SESSION', { active: !document.hidden, }) app.attachEventListener( document, 'visibilitychange', @@ -88,15 +89,15 @@ export default class Assist { } const titleNode = document.querySelector('title') const observer = titleNode && new MutationObserver(() => { - this.emit("UPDATE_SESSION", { pageTitle: document.title }) + this.emit('UPDATE_SESSION', { pageTitle: document.title, }) }) - app.attachStartCallback(() => { - if (this.assistDemandedRestart) { return; } + app.attachStartCallback(() => { + if (this.assistDemandedRestart) { return } this.onStart() - observer && observer.observe(titleNode, { subtree: true, characterData: true, childList: true }) + observer && observer.observe(titleNode, { subtree: true, characterData: true, childList: true, }) }) - app.attachStopCallback(() => { - if (this.assistDemandedRestart) { return; } + app.attachStopCallback(() => { + if (this.assistDemandedRestart) { return } this.clean() observer && observer.disconnect() }) @@ -104,13 +105,13 @@ export default class Assist { if (this.agentsConnected) { // @ts-ignore No need in statistics messages. TODO proper filter if (messages.length === 2 && messages[0]._id === 0 && messages[1]._id === 49) { return } - this.emit("messages", messages) + this.emit('messages', messages) } }) - app.session.attachUpdateCallback(sessInfo => this.emit("UPDATE_SESSION", sessInfo)) + app.session.attachUpdateCallback(sessInfo => this.emit('UPDATE_SESSION', sessInfo)) } - private emit(ev: string, ...args) { + private emit(ev: string, ...args): void { this.socket && this.socket.emit(ev, ...args) } @@ -118,30 +119,33 @@ export default class Assist { return Object.keys(this.agents).length > 0 } - private notifyCallEnd() { - this.emit("call_end"); + private readonly setCallingState = (newState: CallingState): void => { + this.callingState = newState } - private onRemoteCallEnd = () => {} private onStart() { const app = this.app - const peerID = `${app.getProjectKey()}-${app.getSessionID()}` + const sessionId = app.getSessionID() + if (!sessionId) { + return app.debug.error('No session ID') + } + const peerID = `${app.getProjectKey()}-${sessionId}` // SocketIO const socket = this.socket = connect(app.getHost(), { path: '/ws-assist/socket', query: { - "peerId": peerID, - "identity": "session", - "sessionInfo": JSON.stringify({ + 'peerId': peerID, + 'identity': 'session', + 'sessionInfo': JSON.stringify({ pageTitle: document.title, active: true, - ...this.app.getSessionInfo() + ...this.app.getSessionInfo(), }), }, - transports: ["websocket"], + transports: ['websocket',], }) - socket.onAny((...args) => app.debug.log("Socket:", ...args)) + socket.onAny((...args) => app.debug.log('Socket:', ...args)) @@ -149,15 +153,15 @@ export default class Assist { this.options, id => { this.agents[id].onControlReleased = this.options.onRemoteControlStart() - this.emit("control_granted", id) + this.emit('control_granted', id) annot = new AnnotationCanvas() annot.mount() }, id => { const cb = this.agents[id].onControlReleased delete this.agents[id].onControlReleased - typeof cb === "function" && cb() - this.emit("control_rejected", id) + typeof cb === 'function' && cb() + this.emit('control_rejected', id) if (annot != null) { annot.remove() annot = null @@ -166,71 +170,85 @@ export default class Assist { ) // TODO: check incoming args - socket.on("request_control", remoteControl.requestControl) - socket.on("release_control", remoteControl.releaseControl) - socket.on("scroll", remoteControl.scroll) - socket.on("click", remoteControl.click) - socket.on("move", remoteControl.move) - socket.on("focus", (clientID, nodeID) => { + socket.on('request_control', remoteControl.requestControl) + socket.on('release_control', remoteControl.releaseControl) + socket.on('scroll', remoteControl.scroll) + socket.on('click', remoteControl.click) + socket.on('move', remoteControl.move) + socket.on('focus', (clientID, nodeID) => { const el = app.nodes.getNode(nodeID) if (el instanceof HTMLElement) { remoteControl.focus(clientID, el) } }) - socket.on("input", remoteControl.input) + socket.on('input', remoteControl.input) let annot: AnnotationCanvas | null = null - socket.on("moveAnnotation", (_, p) => annot && annot.move(p)) // TODO: restrict by id - socket.on("startAnnotation", (_, p) => annot && annot.start(p)) - socket.on("stopAnnotation", () => annot && annot.stop()) + socket.on('moveAnnotation', (_, p) => annot && annot.move(p)) // TODO: restrict by id + socket.on('startAnnotation', (_, p) => annot && annot.start(p)) + socket.on('stopAnnotation', () => annot && annot.stop()) - socket.on("NEW_AGENT", (id: string, info) => { + socket.on('NEW_AGENT', (id: string, info) => { this.agents[id] = { - onDisconnect: this.options.onAgentConnect && this.options.onAgentConnect(), + onDisconnect: this.options.onAgentConnect?.(), ...info, // TODO } this.assistDemandedRestart = true - this.app.stop(); - this.app.start().then(() => { this.assistDemandedRestart = false }) + this.app.stop() + this.app.start().then(() => { this.assistDemandedRestart = false }).catch(e => app.debug.error(e)) }) - socket.on("AGENTS_CONNECTED", (ids: string[]) => { + socket.on('AGENTS_CONNECTED', (ids: string[]) => { ids.forEach(id =>{ this.agents[id] = { - onDisconnect: this.options.onAgentConnect && this.options.onAgentConnect(), + onDisconnect: this.options.onAgentConnect?.(), } }) this.assistDemandedRestart = true - this.app.stop(); - this.app.start().then(() => { this.assistDemandedRestart = false }) + this.app.stop() + this.app.start().then(() => { this.assistDemandedRestart = false }).catch(e => app.debug.error(e)) remoteControl.reconnect(ids) }) - let confirmCall:ConfirmWindow | null = null - - socket.on("AGENT_DISCONNECTED", (id) => { + socket.on('AGENT_DISCONNECTED', (id) => { remoteControl.releaseControl(id) - // close the call also - if (callingAgent === id) { - confirmCall?.remove() - this.onRemoteCallEnd() - } - - // @ts-ignore (wtf, typescript?!) - this.agents[id] && this.agents[id].onDisconnect != null && this.agents[id].onDisconnect() + this.agents[id]?.onDisconnect?.() delete this.agents[id] + + endAgentCall(id) }) - socket.on("NO_AGENT", () => { + socket.on('NO_AGENT', () => { + Object.values(this.agents).forEach(a => a.onDisconnect?.()) this.agents = {} }) - socket.on("call_end", () => this.onRemoteCallEnd()) // TODO: check if agent calling id + socket.on('call_end', (id) => { + if (!callingAgents.has(id)) { + app.debug.warn('Received call_end from unknown agent', id) + return + } + endAgentCall(id) + }) - // TODO: fix the code - let agentName = "" - let callingAgent = "" - socket.on("_agent_name",(id, name) => { agentName = name; callingAgent = id }) + socket.on('_agent_name', (id, name) => { + callingAgents.set(id, name) + updateCallerNames() + }) + const callingAgents: Map = new Map() // !! uses socket.io ID + // TODO: merge peerId & socket.io id (simplest way - send peerId with the name) + const calls: Record = {} // !! uses peerJS ID + const lStreams: Record = {} + // const callingPeers: Map = new Map() // Maybe + function endAgentCall(id: string) { + callingAgents.delete(id) + if (callingAgents.size === 0) { + handleCallEnd() + } else { + updateCallerNames() + //TODO: close() specific call and corresponding lStreams (after connecting peerId & socket.io id) + } + } // PeerJS call (todo: use native WebRTC) const peerOptions = { @@ -242,126 +260,155 @@ export default class Assist { if (this.options.config) { peerOptions['config'] = this.options.config } - const peer = this.peer = new Peer(peerID, peerOptions); - // app.debug.log('Peer created: ', peer) - // @ts-ignore - peer.on('error', e => app.debug.warn("Peer error: ", e.type, e)) - peer.on('disconnected', () => peer.reconnect()) - peer.on('call', (call) => { - app.debug.log("Call: ", call) - if (this.callingState !== CallingState.False) { - call.close() - //this.notifyCallEnd() // TODO: strictly connect calling peer with agent socket.id - app.debug.warn("Call closed instantly bacause line is busy. CallingState: ", this.callingState) - return; - } + const peer = this.peer = new Peer(peerID, peerOptions) - const setCallingState = (newState: CallingState) => { - if (newState === CallingState.True) { - sessionStorage.setItem(this.options.session_calling_peer_key, call.peer); - } else if (newState === CallingState.False) { - sessionStorage.removeItem(this.options.session_calling_peer_key); - } - this.callingState = newState; + // @ts-ignore (peerjs typing) + peer.on('error', e => app.debug.warn('Peer error: ', e.type, e)) + peer.on('disconnected', () => peer.reconnect()) + + // Common for all incoming call requests + let callUI: CallWindow | null = null + function updateCallerNames() { + callUI?.setAssistentName(callingAgents) + } + // TODO: incapsulate + let callConfirmWindow: ConfirmWindow | null = null + let callConfirmAnswer: Promise | null = null + const closeCallConfirmWindow = () => { + if (callConfirmWindow) { + callConfirmWindow.remove() + callConfirmWindow = null + callConfirmAnswer = null } - + } + const requestCallConfirm = () => { + if (callConfirmAnswer) { // Already asking + return callConfirmAnswer + } + callConfirmWindow = new ConfirmWindow(callConfirmDefault(this.options.callConfirm || { + text: this.options.confirmText, + style: this.options.confirmStyle, + })) // TODO: reuse ? + return callConfirmAnswer = callConfirmWindow.mount().then(answer => { + closeCallConfirmWindow() + return answer + }) + } + let callEndCallback: ReturnType | null = null + const handleCallEnd = () => { // Completle stop and clear all calls + // Streams + Object.values(calls).forEach(call => call.close()) + Object.keys(calls).forEach(peerId => delete calls[peerId]) + Object.values(lStreams).forEach((stream) => { stream.stop() }) + Object.keys(lStreams).forEach((peerId: string) => { delete lStreams[peerId] }) + + // UI + closeCallConfirmWindow() + callUI?.remove() + annot?.remove() + callUI = null + annot = null + + this.emit('UPDATE_SESSION', { agentIds: [], isCallActive: false, }) + this.setCallingState(CallingState.False) + sessionStorage.removeItem(this.options.session_calling_peer_key) + callEndCallback?.() + } + const initiateCallEnd = () => { + this.emit('call_end') + handleCallEnd() + } + + peer.on('call', (call) => { + app.debug.log('Incoming call: ', call) let confirmAnswer: Promise - const callingPeer = sessionStorage.getItem(this.options.session_calling_peer_key) - if (callingPeer === call.peer) { + const callingPeerIds = JSON.parse(sessionStorage.getItem(this.options.session_calling_peer_key) || '[]') + if (callingPeerIds.includes(call.peer) || this.callingState === CallingState.True) { confirmAnswer = Promise.resolve(true) } else { - setCallingState(CallingState.Requesting) - confirmCall = new ConfirmWindow(callConfirmDefault(this.options.callConfirm || { - text: this.options.confirmText, - style: this.options.confirmStyle, - })) - confirmAnswer = confirmCall.mount() - this.playNotificationSound() - this.onRemoteCallEnd = () => { // if call cancelled by a caller before confirmation - app.debug.log("Received call_end during confirm window opened") - confirmCall?.remove() - setCallingState(CallingState.False) - call.close() - } + this.setCallingState(CallingState.Requesting) + confirmAnswer = requestCallConfirm() + this.playNotificationSound() // For every new agent during confirmation here + + // TODO: only one (latest) timeout setTimeout(() => { if (this.callingState !== CallingState.Requesting) { return } - call.close() - confirmCall?.remove() - this.notifyCallEnd() - setCallingState(CallingState.False) + initiateCallEnd() }, 30000) } - confirmAnswer.then(agreed => { + confirmAnswer.then(async agreed => { if (!agreed) { - call.close() - this.notifyCallEnd() - setCallingState(CallingState.False) + initiateCallEnd() + return + } + // Request local stream for the new connection + try { + // lStreams are reusable so fare we don't delete them in the `endAgentCall` + if (!lStreams[call.peer]) { + app.debug.log('starting new stream for', call.peer) + lStreams[call.peer] = await RequestLocalStream() + } + calls[call.peer] = call + } catch (e) { + app.debug.error('Audio mediadevice request error:', e) + initiateCallEnd() return } - const callUI = new CallWindow() - annot = new AnnotationCanvas() - annot.mount() - callUI.setAssistentName(agentName) - - const onCallEnd = this.options.onCallStart() - const handleCallEnd = () => { - app.debug.log("Handle Call End") - call.close() - callUI.remove() - annot && annot.remove() - annot = null - setCallingState(CallingState.False) - onCallEnd && onCallEnd() + // UI + if (!callUI) { + callUI = new CallWindow(app.debug.error) + // TODO: as constructor options + callUI.setCallEndAction(initiateCallEnd) } - const initiateCallEnd = () => { - this.notifyCallEnd() - handleCallEnd() + if (!annot) { + annot = new AnnotationCanvas() + annot.mount() } - this.onRemoteCallEnd = handleCallEnd + // have to be updated + callUI.setLocalStreams(Object.values(lStreams)) call.on('error', e => { - app.debug.warn("Call error:", e) + app.debug.warn('Call error:', e) initiateCallEnd() - }); - - RequestLocalStream().then(lStream => { - call.on('stream', function(rStream) { - callUI.setRemoteStream(rStream); - const onInteraction = () => { // only if hidden? - callUI.playRemote() - document.removeEventListener("click", onInteraction) - } - document.addEventListener("click", onInteraction) - }); - - lStream.onVideoTrack(vTrack => { - const sender = call.peerConnection.getSenders().find(s => s.track?.kind === "video") - if (!sender) { - app.debug.warn("No video sender found") - return - } - app.debug.log("sender found:", sender) - sender.replaceTrack(vTrack) - }) - - callUI.setCallEndAction(initiateCallEnd) - callUI.setLocalStream(lStream) - call.answer(lStream.stream) - setCallingState(CallingState.True) }) - .catch(e => { - app.debug.warn("Audio mediadevice request error:", e) - initiateCallEnd() - }); - }).catch(); // in case of Confirm.remove() without any confirmation/decline - }); + call.on('stream', (rStream) => { + callUI?.addRemoteStream(rStream) + const onInteraction = () => { // do only if document.hidden ? + callUI?.playRemote() + document.removeEventListener('click', onInteraction) + } + document.addEventListener('click', onInteraction) + }) + + // remote video on/off/camera change + lStreams[call.peer].onVideoTrack(vTrack => { + const sender = call.peerConnection.getSenders().find(s => s.track?.kind === 'video') + if (!sender) { + app.debug.warn('No video sender found') + return + } + app.debug.log('sender found:', sender) + void sender.replaceTrack(vTrack) + }) + + call.answer(lStreams[call.peer].stream) + this.setCallingState(CallingState.True) + if (!callEndCallback) { callEndCallback = this.options.onCallStart?.() } + + const callingPeerIds = Object.keys(calls) + sessionStorage.setItem(this.options.session_calling_peer_key, JSON.stringify(callingPeerIds)) + this.emit('UPDATE_SESSION', { agentIds: callingPeerIds, isCallActive: true, }) + }).catch(reason => { // in case of Confirm.remove() without user answer (not a error) + app.debug.log(reason) + }) + }) } private playNotificationSound() { if ('Audio' in window) { - new Audio("https://static.openreplay.com/tracker-assist/notification.mp3") + new Audio('https://static.openreplay.com/tracker-assist/notification.mp3') .play() .catch(e => { this.app.debug.warn(e) @@ -372,11 +419,11 @@ export default class Assist { private clean() { if (this.peer) { this.peer.destroy() - this.app.debug.log("Peer destroyed") + this.app.debug.log('Peer destroyed') } if (this.socket) { this.socket.disconnect() - this.app.debug.log("Socket disconnected") + this.app.debug.log('Socket disconnected') } } } diff --git a/tracker/tracker-assist/src/CallWindow.ts b/tracker/tracker-assist/src/CallWindow.ts index ae2bdd3fa..c8f07e0ff 100644 --- a/tracker/tracker-assist/src/CallWindow.ts +++ b/tracker/tracker-assist/src/CallWindow.ts @@ -1,10 +1,10 @@ -import type { LocalStream } from './LocalStream.js'; -import attachDND from './dnd.js'; +import type { LocalStream, } from './LocalStream.js' +import attachDND from './dnd.js' -const SS_START_TS_KEY = "__openreplay_assist_call_start_ts" +const SS_START_TS_KEY = '__openreplay_assist_call_start_ts' export default class CallWindow { - private iframe: HTMLIFrameElement + private readonly iframe: HTMLIFrameElement private vRemote: HTMLVideoElement | null = null private vLocal: HTMLVideoElement | null = null private audioBtn: HTMLElement | null = null @@ -16,71 +16,71 @@ export default class CallWindow { private tsInterval: ReturnType - private load: Promise + private readonly load: Promise - constructor() { + constructor(private readonly logError: (...args: any[]) => void) { const iframe = this.iframe = document.createElement('iframe') Object.assign(iframe.style, { - position: "fixed", + position: 'fixed', zIndex: 2147483647 - 1, - border: "none", - bottom: "10px", - right: "10px", - height: "200px", - width: "200px", + border: 'none', + bottom: '10px', + right: '10px', + height: '200px', + width: '200px', }) // TODO: find the best attribute name for the ignoring iframes - iframe.setAttribute("data-openreplay-obscured", "") - iframe.setAttribute("data-openreplay-hidden", "") - iframe.setAttribute("data-openreplay-ignore", "") + iframe.setAttribute('data-openreplay-obscured', '') + iframe.setAttribute('data-openreplay-hidden', '') + iframe.setAttribute('data-openreplay-ignore', '') document.body.appendChild(iframe) - const doc = iframe.contentDocument; + const doc = iframe.contentDocument if (!doc) { - console.error("OpenReplay: CallWindow iframe document is not reachable.") - return; + console.error('OpenReplay: CallWindow iframe document is not reachable.') + return } //const baseHref = "https://static.openreplay.com/tracker-assist/test" - const baseHref = "https://static.openreplay.com/tracker-assist/3.4.4" - this.load = fetch(baseHref + "/index.html") + const baseHref = 'https://static.openreplay.com/tracker-assist/3.4.4' + this.load = fetch(baseHref + '/index.html') .then(r => r.text()) .then((text) => { iframe.onload = () => { - const assistSection = doc.getElementById("or-assist") - assistSection?.classList.remove("status-connecting") + const assistSection = doc.getElementById('or-assist') + assistSection?.classList.remove('status-connecting') //iframe.style.height = doc.body.scrollHeight + 'px'; //iframe.style.width = doc.body.scrollWidth + 'px'; this.adjustIframeSize() - iframe.onload = null; + iframe.onload = null } // ? text = text.replace(/href="css/g, `href="${baseHref}/css`) - doc.open(); - doc.write(text); - doc.close(); + doc.open() + doc.write(text) + doc.close() - this.vLocal = doc.getElementById("video-local") as (HTMLVideoElement | null); - this.vRemote = doc.getElementById("video-remote") as (HTMLVideoElement | null); - this.videoContainer = doc.getElementById("video-container"); + this.vLocal = doc.getElementById('video-local') as (HTMLVideoElement | null) + this.vRemote = doc.getElementById('video-remote') as (HTMLVideoElement | null) + this.videoContainer = doc.getElementById('video-container') - this.audioBtn = doc.getElementById("audio-btn"); + this.audioBtn = doc.getElementById('audio-btn') if (this.audioBtn) { - this.audioBtn.onclick = () => this.toggleAudio(); + this.audioBtn.onclick = () => this.toggleAudio() } - this.videoBtn = doc.getElementById("video-btn"); + this.videoBtn = doc.getElementById('video-btn') if (this.videoBtn) { - this.videoBtn.onclick = () => this.toggleVideo(); + this.videoBtn.onclick = () => this.toggleVideo() } - this.endCallBtn = doc.getElementById("end-call-btn"); + this.endCallBtn = doc.getElementById('end-call-btn') - this.agentNameElem = doc.getElementById("agent-name"); - this.vPlaceholder = doc.querySelector("#remote-stream p") + this.agentNameElem = doc.getElementById('agent-name') + this.vPlaceholder = doc.querySelector('#remote-stream p') - const tsElem = doc.getElementById("duration"); + const tsElem = doc.getElementById('duration') if (tsElem) { const startTs = Number(sessionStorage.getItem(SS_START_TS_KEY)) || Date.now() sessionStorage.setItem(SS_START_TS_KEY, startTs.toString()) @@ -90,15 +90,15 @@ export default class CallWindow { const mins = ~~(secsFull / 60) const secs = secsFull - mins * 60 tsElem.innerText = `${mins}:${secs < 10 ? 0 : ''}${secs}` - }, 500); + }, 500) } - const dragArea = doc.querySelector(".drag-area") + const dragArea = doc.querySelector('.drag-area') if (dragArea) { // TODO: save coordinates on the new page attachDND(iframe, dragArea, doc.documentElement) } - }); + }) //this.toggleVideoUI(false) //this.toggleRemoteVideoUI(false) @@ -107,8 +107,8 @@ export default class CallWindow { private adjustIframeSize() { const doc = this.iframe.contentDocument if (!doc) { return } - this.iframe.style.height = doc.body.scrollHeight + 'px'; - this.iframe.style.width = doc.body.scrollWidth + 'px'; + this.iframe.style.height = `${doc.body.scrollHeight}px` + this.iframe.style.width = `${doc.body.scrollWidth}px` } setCallEndAction(endCall: () => void) { @@ -116,125 +116,137 @@ export default class CallWindow { if (this.endCallBtn) { this.endCallBtn.onclick = endCall } - }) + }).catch(e => this.logError(e)) } - private aRemote: HTMLAudioElement | null = null; private checkRemoteVideoInterval: ReturnType - setRemoteStream(rStream: MediaStream) { + private audioContainer: HTMLDivElement | null = null + addRemoteStream(rStream: MediaStream) { this.load.then(() => { + // Video if (this.vRemote && !this.vRemote.srcObject) { - this.vRemote.srcObject = rStream; + this.vRemote.srcObject = rStream if (this.vPlaceholder) { - this.vPlaceholder.innerText = "Video has been paused. Click anywhere to resume."; + this.vPlaceholder.innerText = 'Video has been paused. Click anywhere to resume.' } - - // Hack for audio. Doesen't work inside the iframe because of some magical reasons (check if it is connected to autoplay?) - this.aRemote = document.createElement("audio"); - this.aRemote.autoplay = true; - this.aRemote.style.display = "none" - this.aRemote.srcObject = rStream; - document.body.appendChild(this.aRemote) + // Hack to determine if the remote video is enabled + // TODO: pass this info through socket + if (this.checkRemoteVideoInterval) { clearInterval(this.checkRemoteVideoInterval) } // just in case + let enabled = false + this.checkRemoteVideoInterval = setInterval(() => { + const settings = rStream.getVideoTracks()[0]?.getSettings() + const isDummyVideoTrack = !!settings && (settings.width === 2 || settings.frameRate === 0) + const shouldBeEnabled = !isDummyVideoTrack + if (enabled !== shouldBeEnabled) { + this.toggleRemoteVideoUI(enabled=shouldBeEnabled) + } + }, 1000) } - // Hack to determine if the remote video is enabled - if (this.checkRemoteVideoInterval) { clearInterval(this.checkRemoteVideoInterval) } // just in case - let enabled = false - this.checkRemoteVideoInterval = setInterval(() => { - const settings = rStream.getVideoTracks()[0]?.getSettings() - //console.log(settings) - const isDummyVideoTrack = !!settings && (settings.width === 2 || settings.frameRate === 0) - const shouldBeEnabled = !isDummyVideoTrack - if (enabled !== shouldBeEnabled) { - this.toggleRemoteVideoUI(enabled=shouldBeEnabled) - } - }, 1000) - }) + // Audio + if (!this.audioContainer) { + this.audioContainer = document.createElement('div') + document.body.appendChild(this.audioContainer) + } + // Hack for audio. Doesen't work inside the iframe + // because of some magical reasons (check if it is connected to autoplay?) + const audioEl = document.createElement('audio') + audioEl.autoplay = true + audioEl.style.display = 'none' + audioEl.srcObject = rStream + this.audioContainer.appendChild(audioEl) + }).catch(e => this.logError(e)) } toggleRemoteVideoUI(enable: boolean) { this.load.then(() => { if (this.videoContainer) { if (enable) { - this.videoContainer.classList.add("remote") + this.videoContainer.classList.add('remote') } else { - this.videoContainer.classList.remove("remote") + this.videoContainer.classList.remove('remote') } this.adjustIframeSize() } - }) + }).catch(e => this.logError(e)) } - private localStream: LocalStream | null = null; - - // TODO: on construction? - setLocalStream(lStream: LocalStream) { - this.localStream = lStream + private localStreams: LocalStream[] = [] + // !TODO: separate streams manipulation from ui + setLocalStreams(streams: LocalStream[]) { + this.localStreams = streams } playRemote() { this.vRemote && this.vRemote.play() } - setAssistentName(name: string) { + setAssistentName(callingAgents: Map) { this.load.then(() => { if (this.agentNameElem) { - this.agentNameElem.innerText = name + const nameString = Array.from(callingAgents.values()).join(', ') + const safeNames = nameString.length > 20 ? nameString.substring(0, 20) + '...' : nameString + this.agentNameElem.innerText = safeNames } - }) + }).catch(e => this.logError(e)) } private toggleAudioUI(enabled: boolean) { - if (!this.audioBtn) { return; } + if (!this.audioBtn) { return } if (enabled) { - this.audioBtn.classList.remove("muted") + this.audioBtn.classList.remove('muted') } else { - this.audioBtn.classList.add("muted") + this.audioBtn.classList.add('muted') } } private toggleAudio() { - const enabled = this.localStream?.toggleAudio() || false + let enabled = false + this.localStreams.forEach(stream => { + enabled = stream.toggleAudio() || false + }) this.toggleAudioUI(enabled) } private toggleVideoUI(enabled: boolean) { - if (!this.videoBtn || !this.videoContainer) { return; } + if (!this.videoBtn || !this.videoContainer) { return } if (enabled) { - this.videoContainer.classList.add("local") - this.videoBtn.classList.remove("off"); + this.videoContainer.classList.add('local') + this.videoBtn.classList.remove('off') } else { - this.videoContainer.classList.remove("local") - this.videoBtn.classList.add("off"); + this.videoContainer.classList.remove('local') + this.videoBtn.classList.add('off') } this.adjustIframeSize() } - private videoRequested: boolean = false private toggleVideo() { - this.localStream?.toggleVideo() - .then(enabled => { - this.toggleVideoUI(enabled) - this.load.then(() => { - if (this.vLocal && this.localStream && !this.vLocal.srcObject) { - this.vLocal.srcObject = this.localStream.stream - } - }) + this.localStreams.forEach(stream => { + stream.toggleVideo() + .then(enabled => { + this.toggleVideoUI(enabled) + this.load.then(() => { + if (this.vLocal && stream && !this.vLocal.srcObject) { + this.vLocal.srcObject = stream.stream + } + }).catch(e => this.logError(e)) + }).catch(e => this.logError(e)) }) } remove() { - this.localStream?.stop() clearInterval(this.tsInterval) clearInterval(this.checkRemoteVideoInterval) - if (this.iframe.parentElement) { - document.body.removeChild(this.iframe) + if (this.audioContainer && this.audioContainer.parentElement) { + this.audioContainer.parentElement.removeChild(this.audioContainer) + this.audioContainer = null } - if (this.aRemote && this.aRemote.parentElement) { - document.body.removeChild(this.aRemote) + if (this.iframe.parentElement) { + this.iframe.parentElement.removeChild(this.iframe) } sessionStorage.removeItem(SS_START_TS_KEY) + this.localStreams = [] } -} \ No newline at end of file +} diff --git a/tracker/tracker-assist/src/ConfirmWindow/ConfirmWindow.ts b/tracker/tracker-assist/src/ConfirmWindow/ConfirmWindow.ts index 02d9dd9c6..4de359ac9 100644 --- a/tracker/tracker-assist/src/ConfirmWindow/ConfirmWindow.ts +++ b/tracker/tracker-assist/src/ConfirmWindow/ConfirmWindow.ts @@ -1,4 +1,5 @@ -import type { Properties } from 'csstype'; +/* eslint-disable @typescript-eslint/no-empty-function */ +import type { Properties, } from 'csstype' export type ButtonOptions = | HTMLButtonElement @@ -19,45 +20,45 @@ export interface ConfirmWindowOptions { function makeButton(options: ButtonOptions, defaultStyle?: Properties): HTMLButtonElement { if (options instanceof HTMLButtonElement) { - return options; + return options } - const btn = document.createElement("button"); + const btn = document.createElement('button') Object.assign(btn.style, { - padding: "10px 14px", - fontSize: "14px", - borderRadius: "3px", - border: "none", - cursor: "pointer", - display: "flex", - alignItems: "center", - textTransform: "uppercase", - marginRight: "10px" - }, defaultStyle); - if (typeof options === "string") { - btn.innerHTML = options; + padding: '10px 14px', + fontSize: '14px', + borderRadius: '3px', + border: 'none', + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + textTransform: 'uppercase', + marginRight: '10px', + }, defaultStyle) + if (typeof options === 'string') { + btn.innerHTML = options } else { - btn.innerHTML = options.innerHTML; - Object.assign(btn.style, options.style); + btn.innerHTML = options.innerHTML + Object.assign(btn.style, options.style) } - return btn; + return btn } export default class ConfirmWindow { private wrapper: HTMLDivElement; constructor(options: ConfirmWindowOptions) { - const wrapper = document.createElement("div"); - const popup = document.createElement("div"); - const p = document.createElement("p"); - p.innerText = options.text; - const buttons = document.createElement("div"); + const wrapper = document.createElement('div') + const popup = document.createElement('div') + const p = document.createElement('p') + p.innerText = options.text + const buttons = document.createElement('div') const confirmBtn = makeButton(options.confirmBtn, { - background: "rgba(0, 167, 47, 1)", - color: "white" + background: 'rgba(0, 167, 47, 1)', + color: 'white', }) const declineBtn = makeButton(options.declineBtn, { - background: "#FFE9E9", - color: "#CC0000" + background: '#FFE9E9', + color: '#CC0000', }) buttons.appendChild(confirmBtn) buttons.appendChild(declineBtn) @@ -66,78 +67,76 @@ export default class ConfirmWindow { Object.assign(buttons.style, { - marginTop: "10px", - display: "flex", - alignItems: "center", + marginTop: '10px', + display: 'flex', + alignItems: 'center', // justifyContent: "space-evenly", - backgroundColor: "white", - padding: "10px", - boxShadow: "0px 0px 3.99778px 1.99889px rgba(0, 0, 0, 0.1)", - borderRadius: "6px" - }); + backgroundColor: 'white', + padding: '10px', + boxShadow: '0px 0px 3.99778px 1.99889px rgba(0, 0, 0, 0.1)', + borderRadius: '6px', + }) Object.assign( popup.style, { - font: "14px 'Roboto', sans-serif", - position: "relative", - pointerEvents: "auto", - margin: "4em auto", - width: "90%", - maxWidth: "fit-content", - padding: "20px", - background: "#F3F3F3", + font: '14px \'Roboto\', sans-serif', + position: 'relative', + pointerEvents: 'auto', + margin: '4em auto', + width: '90%', + maxWidth: 'fit-content', + padding: '20px', + background: '#F3F3F3', //opacity: ".75", - color: "black", - borderRadius: "3px", - boxShadow: "0px 0px 3.99778px 1.99889px rgba(0, 0, 0, 0.1)" + color: 'black', + borderRadius: '3px', + boxShadow: '0px 0px 3.99778px 1.99889px rgba(0, 0, 0, 0.1)', }, options.style - ); + ) Object.assign(wrapper.style, { - position: "fixed", + position: 'fixed', left: 0, top: 0, - height: "100%", - width: "100%", - pointerEvents: "none", - zIndex: 2147483647 - 1 - }); + height: '100%', + width: '100%', + pointerEvents: 'none', + zIndex: 2147483647 - 1, + }) - wrapper.appendChild(popup); - this.wrapper = wrapper; + wrapper.appendChild(popup) + this.wrapper = wrapper confirmBtn.onclick = () => { - this._remove(); - this.resolve(true); - }; + this.resolve(true) + } declineBtn.onclick = () => { - this._remove(); - this.resolve(false); - }; + this.resolve(false) + } } private resolve: (result: boolean) => void = () => {}; - private reject: () => void = () => {}; + private reject: (reason: string) => void = () => {}; mount(): Promise { - document.body.appendChild(this.wrapper); + document.body.appendChild(this.wrapper) return new Promise((resolve, reject) => { - this.resolve = resolve; - this.reject = reject; - }); + this.resolve = resolve + this.reject = reject + }) } private _remove() { if (!this.wrapper.parentElement) { - return; + return } - document.body.removeChild(this.wrapper); + this.wrapper.parentElement.removeChild(this.wrapper) } remove() { - this._remove(); - this.reject(); + this._remove() + this.reject('no answer') } } diff --git a/tracker/tracker-assist/src/ConfirmWindow/defaults.ts b/tracker/tracker-assist/src/ConfirmWindow/defaults.ts index 8f84cbe89..d6d5430c4 100644 --- a/tracker/tracker-assist/src/ConfirmWindow/defaults.ts +++ b/tracker/tracker-assist/src/ConfirmWindow/defaults.ts @@ -1,10 +1,10 @@ -import { declineCall, acceptCall, cross, remoteControl } from '../icons.js' -import type { ButtonOptions, ConfirmWindowOptions } from './ConfirmWindow.js' +import { declineCall, acceptCall, cross, remoteControl, } from '../icons.js' +import type { ButtonOptions, ConfirmWindowOptions, } from './ConfirmWindow.js' -const TEXT_GRANT_REMORTE_ACCESS = "Grant Remote Control"; -const TEXT_REJECT = "Reject"; -const TEXT_ANSWER_CALL = `${acceptCall}   Answer`; +const TEXT_GRANT_REMORTE_ACCESS = 'Grant Remote Control' +const TEXT_REJECT = 'Reject' +const TEXT_ANSWER_CALL = `${acceptCall}   Answer` export type Options = string | Partial; @@ -14,15 +14,15 @@ function confirmDefault( declineBtn: ButtonOptions, text: string ): ConfirmWindowOptions { - const isStr = typeof opts === "string"; + const isStr = typeof opts === 'string' return Object.assign( { text: isStr ? opts : text, confirmBtn, - declineBtn + declineBtn, }, isStr ? undefined : opts - ); + ) } export const callConfirmDefault = (opts: Options) => @@ -30,7 +30,7 @@ export const callConfirmDefault = (opts: Options) => opts, TEXT_ANSWER_CALL, TEXT_REJECT, - "You have an incoming call. Do you want to answer?" + 'You have an incoming call. Do you want to answer?' ) export const controlConfirmDefault = (opts: Options) => @@ -38,5 +38,5 @@ export const controlConfirmDefault = (opts: Options) => opts, TEXT_GRANT_REMORTE_ACCESS, TEXT_REJECT, - "Agent requested remote control. Allow?" + 'Agent requested remote control. Allow?' ) diff --git a/tracker/tracker-assist/src/LocalStream.ts b/tracker/tracker-assist/src/LocalStream.ts index 63f01ad58..78c9ccff8 100644 --- a/tracker/tracker-assist/src/LocalStream.ts +++ b/tracker/tracker-assist/src/LocalStream.ts @@ -5,44 +5,45 @@ declare global { } function dummyTrack(): MediaStreamTrack { - const canvas = document.createElement("canvas")//, { width: 0, height: 0}) + const canvas = document.createElement('canvas')//, { width: 0, height: 0}) canvas.width=canvas.height=2 // Doesn't work when 1 (?!) - const ctx = canvas.getContext('2d'); - ctx?.fillRect(0, 0, canvas.width, canvas.height); + const ctx = canvas.getContext('2d') + ctx?.fillRect(0, 0, canvas.width, canvas.height) requestAnimationFrame(function draw(){ ctx?.fillRect(0,0, canvas.width, canvas.height) - requestAnimationFrame(draw); - }); + requestAnimationFrame(draw) + }) // Also works. Probably it should be done once connected. //setTimeout(() => { ctx?.fillRect(0,0, canvas.width, canvas.height) }, 4000) - return canvas.captureStream(60).getTracks()[0]; + return canvas.captureStream(60).getTracks()[0] } export default function RequestLocalStream(): Promise { - return navigator.mediaDevices.getUserMedia({ audio:true }) + return navigator.mediaDevices.getUserMedia({ audio:true, }) .then(aStream => { const aTrack = aStream.getAudioTracks()[0] - if (!aTrack) { throw new Error("No audio tracks provided") } + + if (!aTrack) { throw new Error('No audio tracks provided') } return new _LocalStream(aTrack) }) } class _LocalStream { - private mediaRequested: boolean = false + private mediaRequested = false readonly stream: MediaStream private readonly vdTrack: MediaStreamTrack constructor(aTrack: MediaStreamTrack) { this.vdTrack = dummyTrack() - this.stream = new MediaStream([ aTrack, this.vdTrack ]) + this.stream = new MediaStream([ aTrack, this.vdTrack, ]) } toggleVideo(): Promise { if (!this.mediaRequested) { - return navigator.mediaDevices.getUserMedia({video:true}) + return navigator.mediaDevices.getUserMedia({video:true,}) .then(vStream => { const vTrack = vStream.getVideoTracks()[0] if (!vTrack) { - throw new Error("No video track provided") + throw new Error('No video track provided') } this.stream.addTrack(vTrack) this.stream.removeTrack(this.vdTrack) @@ -54,6 +55,7 @@ class _LocalStream { }) .catch(e => { // TODO: log + console.error(e) return false }) } diff --git a/tracker/tracker-assist/src/Mouse.ts b/tracker/tracker-assist/src/Mouse.ts index 52858ccf6..afb50a1f1 100644 --- a/tracker/tracker-assist/src/Mouse.ts +++ b/tracker/tracker-assist/src/Mouse.ts @@ -2,25 +2,25 @@ type XY = [number, number] export default class Mouse { - private mouse: HTMLDivElement - private position: [number,number] = [0,0] + private readonly mouse: HTMLDivElement + private position: [number,number] = [0,0,] constructor() { - this.mouse = document.createElement('div'); + 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)", - }); + width: '20px', + height: '20px', + opacity: '.4', + borderRadius: '50%', + position: 'absolute', + zIndex: '999998', + background: 'radial-gradient(red, transparent)', + }) } mount() { document.body.appendChild(this.mouse) - window.addEventListener("scroll", this.handleWScroll) - window.addEventListener("resize", this.resetLastScrEl) + window.addEventListener('scroll', this.handleWScroll) + window.addEventListener('resize', this.resetLastScrEl) } move(pos: XY) { @@ -28,16 +28,16 @@ export default class Mouse { this.resetLastScrEl() } - this.position = pos; + this.position = pos Object.assign(this.mouse.style, { left: `${pos[0] || 0}px`, - top: `${pos[1] || 0}px` + top: `${pos[1] || 0}px`, }) } getPosition(): XY { - return this.position; + return this.position } click(pos: XY) { @@ -51,18 +51,18 @@ export default class Mouse { } private readonly pScrEl = document.scrollingElement || document.documentElement // Is it always correct - private lastScrEl: Element | "window" | null = null - private resetLastScrEl = () => { this.lastScrEl = null } - private handleWScroll = e => { + private lastScrEl: Element | 'window' | null = null + private readonly resetLastScrEl = () => { this.lastScrEl = null } + private readonly handleWScroll = e => { if (e.target !== this.lastScrEl && - this.lastScrEl !== "window") { + this.lastScrEl !== 'window') { this.resetLastScrEl() } } scroll(delta: XY) { // what would be the browser-like logic? - const [mouseX, mouseY] = this.position - const [dX, dY] = delta + const [mouseX, mouseY,] = this.position + const [dX, dY,] = delta let el = this.lastScrEl @@ -72,7 +72,7 @@ export default class Mouse { el.scrollTop += dY return // TODO: if not scrolled } - if (el === "window") { + if (el === 'window') { window.scroll(this.pScrEl.scrollLeft + dX, this.pScrEl.scrollTop + dY) return } @@ -85,7 +85,7 @@ export default class Mouse { // el.scrollTopMax > 0 // available in firefox if (el.scrollHeight > el.clientHeight || el.scrollWidth > el.clientWidth) { const styles = getComputedStyle(el) - if (styles.overflow.indexOf("scroll") >= 0 || styles.overflow.indexOf("auto") >= 0) { // returns true for body in habr.com but it's not scrollable + if (styles.overflow.indexOf('scroll') >= 0 || styles.overflow.indexOf('auto') >= 0) { // returns true for body in habr.com but it's not scrollable const esl = el.scrollLeft const est = el.scrollTop el.scrollLeft += dX @@ -101,14 +101,14 @@ export default class Mouse { // If not scrolled window.scroll(this.pScrEl.scrollLeft + dX, this.pScrEl.scrollTop + dY) - this.lastScrEl = "window" + this.lastScrEl = 'window' } remove() { if (this.mouse.parentElement) { - document.body.removeChild(this.mouse); + document.body.removeChild(this.mouse) } - window.removeEventListener("scroll", this.handleWScroll) - window.removeEventListener("resize", this.resetLastScrEl) + window.removeEventListener('scroll', this.handleWScroll) + window.removeEventListener('resize', this.resetLastScrEl) } -} \ No newline at end of file +} diff --git a/tracker/tracker-assist/src/RemoteControl.ts b/tracker/tracker-assist/src/RemoteControl.ts index 50cb717c8..5a122d837 100644 --- a/tracker/tracker-assist/src/RemoteControl.ts +++ b/tracker/tracker-assist/src/RemoteControl.ts @@ -1,7 +1,7 @@ -import Mouse from './Mouse.js'; -import ConfirmWindow from './ConfirmWindow/ConfirmWindow.js'; -import { controlConfirmDefault } from './ConfirmWindow/defaults.js'; -import type { Options as AssistOptions } from './Assist'; +import Mouse from './Mouse.js' +import ConfirmWindow from './ConfirmWindow/ConfirmWindow.js' +import { controlConfirmDefault, } from './ConfirmWindow/defaults.js' +import type { Options as AssistOptions, } from './Assist' enum RCStatus { Disabled, @@ -11,7 +11,7 @@ enum RCStatus { let setInputValue = function(this: HTMLInputElement | HTMLTextAreaElement, value: string) { this.value = value } -const nativeInputValueDescriptor = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value") +const nativeInputValueDescriptor = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value') if (nativeInputValueDescriptor && nativeInputValueDescriptor.set) { setInputValue = nativeInputValueDescriptor.set } @@ -23,9 +23,9 @@ export default class RemoteControl { private agentID: string | null = null constructor( - private options: AssistOptions, - private onGrand: (sting?) => void, - private onRelease: (sting?) => void) {} + private readonly options: AssistOptions, + private readonly onGrand: (sting?) => void, + private readonly onRelease: (sting?) => void) {} reconnect(ids: string[]) { const storedID = sessionStorage.getItem(this.options.session_control_peer_key) @@ -40,7 +40,7 @@ export default class RemoteControl { requestControl = (id: string) => { if (this.agentID !== null) { this.releaseControl(id) - return + return } setTimeout(() =>{ if (this.status === RCStatus.Requesting) { @@ -56,7 +56,14 @@ export default class RemoteControl { } else { this.releaseControl(id) } - }).catch() + }) + .then(() => { + this.confirm?.remove() + }) + .catch(e => { + this.confirm?.remove() + console.error(e) + }) } grantControl = (id: string) => { this.agentID = id @@ -69,7 +76,6 @@ export default class RemoteControl { releaseControl = (id: string) => { if (this.agentID !== id) { return } - this.confirm?.remove() this.mouse?.remove() this.mouse = null this.status = RCStatus.Disabled @@ -81,22 +87,22 @@ export default class RemoteControl { scroll = (id, d) => { id === this.agentID && this.mouse?.scroll(d) } move = (id, xy) => { id === this.agentID && this.mouse?.move(xy) } private focused: HTMLElement | null = null - click = (id, xy) => { + click = (id, xy) => { if (id !== this.agentID || !this.mouse) { return } - this.focused = this.mouse.click(xy) + this.focused = this.mouse.click(xy) } focus = (id, el: HTMLElement) => { this.focused = el } input = (id, value: string) => { if (id !== this.agentID || !this.mouse || !this.focused) { return } - if (this.focused instanceof HTMLTextAreaElement + if (this.focused instanceof HTMLTextAreaElement || this.focused instanceof HTMLInputElement) { setInputValue.call(this.focused, value) - const ev = new Event('input', { bubbles: true}) + const ev = new Event('input', { bubbles: true,}) this.focused.dispatchEvent(ev) } else if (this.focused.isContentEditable) { this.focused.innerText = value } } -} \ No newline at end of file +} diff --git a/tracker/tracker-assist/src/_slim.ts b/tracker/tracker-assist/src/_slim.ts index ce86863be..760a9b8d2 100644 --- a/tracker/tracker-assist/src/_slim.ts +++ b/tracker/tracker-assist/src/_slim.ts @@ -5,4 +5,4 @@ */ // @ts-ignore -typeof window !== "undefined" && (window.parcelRequire = window.parcelRequire || undefined); +typeof window !== 'undefined' && (window.parcelRequire = window.parcelRequire || undefined) diff --git a/tracker/tracker-assist/src/dnd.ts b/tracker/tracker-assist/src/dnd.ts index 818bb0b89..622d86b94 100644 --- a/tracker/tracker-assist/src/dnd.ts +++ b/tracker/tracker-assist/src/dnd.ts @@ -9,7 +9,7 @@ export default function attachDND( dropArea: Element, ) { - dragArea.addEventListener('pointerdown', userPressed, { passive: true }) + dragArea.addEventListener('pointerdown', userPressed, { passive: true, }) let bbox, startX, startY, @@ -20,9 +20,9 @@ export default function attachDND( startX = event.clientX startY = event.clientY bbox = movingEl.getBoundingClientRect() - dropArea.addEventListener('pointermove', userMoved, { passive: true }) - dropArea.addEventListener('pointerup', userReleased, { passive: true }) - dropArea.addEventListener('pointercancel', userReleased, { passive: true }) + dropArea.addEventListener('pointermove', userMoved, { passive: true, }) + dropArea.addEventListener('pointerup', userReleased, { passive: true, }) + dropArea.addEventListener('pointercancel', userReleased, { passive: true, }) }; /* @@ -46,8 +46,8 @@ export default function attachDND( } function userMovedRaf() { - movingEl.style.transform = "translate3d("+deltaX+"px,"+deltaY+"px, 0px)"; - raf = null; + movingEl.style.transform = 'translate3d('+deltaX+'px,'+deltaY+'px, 0px)' + raf = null } function userReleased() { @@ -58,9 +58,9 @@ export default function attachDND( cancelAnimationFrame(raf) raf = null } - movingEl.style.left = bbox.left + deltaX + "px" - movingEl.style.top = bbox.top + deltaY + "px" - movingEl.style.transform = "translate3d(0px,0px,0px)" + movingEl.style.left = bbox.left + deltaX + 'px' + movingEl.style.top = bbox.top + deltaY + 'px' + movingEl.style.transform = 'translate3d(0px,0px,0px)' deltaX = deltaY = 0 } } \ No newline at end of file diff --git a/tracker/tracker-assist/src/icons.ts b/tracker/tracker-assist/src/icons.ts index 763b015b9..c844812f4 100644 --- a/tracker/tracker-assist/src/icons.ts +++ b/tracker/tracker-assist/src/icons.ts @@ -4,7 +4,7 @@ export const declineCall = ` -`; +` export const acceptCall = declineCall.replace('fill="#ef5261"', 'fill="green"') @@ -13,4 +13,4 @@ export const cross = ` ` -export const remoteControl = `` +export const remoteControl = '' diff --git a/tracker/tracker-assist/src/index.ts b/tracker/tracker-assist/src/index.ts index 2a3161089..32af38d3f 100644 --- a/tracker/tracker-assist/src/index.ts +++ b/tracker/tracker-assist/src/index.ts @@ -1,7 +1,7 @@ -import './_slim.js'; +import './_slim.js' -import type { App } from '@openreplay/tracker'; -import type { Options } from './Assist.js' +import type { App, } from '@openreplay/tracker' +import type { Options, } from './Assist.js' import Assist from './Assist.js' @@ -9,13 +9,13 @@ export default function(opts?: Partial) { return function(app: App | null, appOptions: { __DISABLE_SECURE_MODE?: boolean } = {}) { // @ts-ignore if (app === null || !navigator?.mediaDevices?.getUserMedia) { // 93.04% browsers - return; - } - if (!app.checkRequiredVersion || !app.checkRequiredVersion("REQUIRED_TRACKER_VERSION")) { - console.warn("OpenReplay Assist: couldn't load. The minimum required version of @openreplay/tracker@REQUIRED_TRACKER_VERSION is not met") return } - app.notify.log("OpenReplay Assist initializing.") + if (!app.checkRequiredVersion || !app.checkRequiredVersion('REQUIRED_TRACKER_VERSION')) { + console.warn('OpenReplay Assist: couldn\'t load. The minimum required version of @openreplay/tracker@REQUIRED_TRACKER_VERSION is not met') + return + } + app.notify.log('OpenReplay Assist initializing.') const assist = new Assist(app, opts, appOptions.__DISABLE_SECURE_MODE) app.debug.log(assist) return assist diff --git a/tracker/tracker-axios/LICENSE b/tracker/tracker-axios/LICENSE index b57f138e0..15669f768 100644 --- a/tracker/tracker-axios/LICENSE +++ b/tracker/tracker-axios/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2021 OpenReplay.com +Copyright (c) 2022 Asayer, Inc Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/tracker/tracker-axios/package.json b/tracker/tracker-axios/package.json index 346b8d7bb..1db18cd8b 100644 --- a/tracker/tracker-axios/package.json +++ b/tracker/tracker-axios/package.json @@ -1,7 +1,7 @@ { "name": "@openreplay/tracker-axios", "description": "Tracker plugin for axios requests recording", - "version": "3.5.1", + "version": "3.6.0", "keywords": [ "axios", "logging", @@ -20,11 +20,11 @@ }, "dependencies": {}, "peerDependencies": { - "@openreplay/tracker": "^3.4.8", + "@openreplay/tracker": "^3.6.0", "axios": "0.x" }, "devDependencies": { - "@openreplay/tracker": "^3.4.9", + "@openreplay/tracker": "file:../tracker", "axios": "^0.26.0", "prettier": "^1.18.2", "replace-in-files-cli": "^1.0.0", diff --git a/tracker/tracker-axios/src/index.ts b/tracker/tracker-axios/src/index.ts index 14d3387dc..2b2049818 100644 --- a/tracker/tracker-axios/src/index.ts +++ b/tracker/tracker-axios/src/index.ts @@ -61,7 +61,7 @@ export default function(opts: Partial = {}) { ? name => ihOpt.includes(name) : () => ihOpt - const sendFetchMessage = (res: AxiosResponse) => { + const sendFetchMessage = async (res: AxiosResponse) => { // ?? TODO: why config is undeined sometimes? if (!isAxiosResponse(res)) { return } // @ts-ignore @@ -105,7 +105,7 @@ export default function(opts: Partial = {}) { // TODO: type safe axios headers if (typeof res.headers === 'object') { - Object.entries(res.headers as Record).forEach(([v, n]) => { if (!isHIgnoring(n)) resHs[n] = v }) + Object.entries(res.headers as Record).forEach(([n, v]) => { if (!isHIgnoring(n)) resHs[n] = v }) } } diff --git a/tracker/tracker-fetch/LICENSE b/tracker/tracker-fetch/LICENSE index c5956c893..15669f768 100644 --- a/tracker/tracker-fetch/LICENSE +++ b/tracker/tracker-fetch/LICENSE @@ -1,51 +1,19 @@ -Copyright (c) 2022 Asayer, Inc. +Copyright (c) 2022 Asayer, Inc -Reach out (license@openreplay.com) if you have any questions regarding the license. +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: ------------------------------------------------------------------------------------- - -Elastic License 2.0 (ELv2) - -**Acceptance** -By using the software, you agree to all of the terms and conditions below. - -**Copyright License** -The licensor grants you a non-exclusive, royalty-free, worldwide, non-sublicensable, non-transferable license to use, copy, distribute, make available, and prepare derivative works of the software, in each case subject to the limitations and conditions below - -**Limitations** -You may not provide the software to third parties as a hosted or managed service, where the service provides users with access to any substantial set of the features or functionality of the software. - -You may not move, change, disable, or circumvent the license key functionality in the software, and you may not remove or obscure any functionality in the software that is protected by the license key. - -You may not alter, remove, or obscure any licensing, copyright, or other notices of the licensor in the software. Any use of the licensor’s trademarks is subject to applicable law. - -**Patents** -The licensor grants you a license, under any patent claims the licensor can license, or becomes able to license, to make, have made, use, sell, offer for sale, import and have imported the software, in each case subject to the limitations and conditions in this license. This license does not cover any patent claims that you cause to be infringed by modifications or additions to the software. If you or your company make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company. - -**Notices** -You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms. - -If you modify the software, you must include in any modified copies of the software prominent notices stating that you have modified the software. - -**No Other Rights** -These terms do not imply any licenses other than those expressly granted in these terms. - -**Termination** -If you use the software in violation of these terms, such use is not licensed, and your licenses will automatically terminate. If the licensor provides you with a notice of your violation, and you cease all violation of this license no later than 30 days after you receive that notice, your licenses will be reinstated retroactively. However, if you violate these terms after such reinstatement, any additional violation of these terms will cause your licenses to terminate automatically and permanently. - -**No Liability** -As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim. - -**Definitions** -The *licensor* is the entity offering these terms, and the *software* is the software the licensor makes available under these terms, including any portion of it. - -*you* refers to the individual or entity agreeing to these terms. - -*your company* is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization. *control* means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect. - -*your licenses* are all the licenses granted to you for the software under these terms. - -*use* means anything you do with the software requiring one of your licenses. - -*trademark* means trademarks, service marks, and similar rights. +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/tracker/tracker-fetch/package.json b/tracker/tracker-fetch/package.json index c13b1a28b..bdda7911f 100644 --- a/tracker/tracker-fetch/package.json +++ b/tracker/tracker-fetch/package.json @@ -1,7 +1,7 @@ { "name": "@openreplay/tracker-fetch", "description": "Tracker plugin for fetch requests recording ", - "version": "3.5.3", + "version": "3.6.0", "keywords": [ "fetch", "logging", @@ -23,10 +23,10 @@ }, "dependencies": {}, "peerDependencies": { - "@openreplay/tracker": "^3.0.0" + "@openreplay/tracker": "^3.6.0" }, "devDependencies": { - "@openreplay/tracker": "^3.0.0", + "@openreplay/tracker": "file:../tracker", "prettier": "^1.18.2", "replace-in-files-cli": "^1.0.0", "typescript": "^3.6.4" diff --git a/tracker/tracker-fetch/src/index.ts b/tracker/tracker-fetch/src/index.ts index 922913923..94aa573b2 100644 --- a/tracker/tracker-fetch/src/index.ts +++ b/tracker/tracker-fetch/src/index.ts @@ -18,24 +18,33 @@ interface RequestResponseData { response: ResponseData } +type WindowFetch = typeof fetch export interface Options { + fetch: WindowFetch, sessionTokenHeader?: string failuresOnly: boolean overrideGlobal: boolean ignoreHeaders: Array | boolean sanitiser?: (RequestResponseData) => RequestResponseData | null + // Depricated requestSanitizer?: any responseSanitizer?: any } -export default function(opts: Partial = {}) { +export default function(opts: Partial = {}): (app: App | null) => WindowFetch | null { + if (typeof window === 'undefined') { + // not in browser (SSR) + return () => opts.fetch || null + } + const options: Options = Object.assign( { overrideGlobal: false, failuresOnly: false, ignoreHeaders: [ 'Cookie', 'Set-Cookie', 'Authorization' ], + fetch: window.fetch.bind(window), }, opts, ); @@ -43,10 +52,9 @@ export default function(opts: Partial = {}) { console.warn("OpenReplay fetch plugin: `requestSanitizer` and `responseSanitizer` options are depricated. Please, use `sanitiser` instead (check out documentation at https://docs.openreplay.com/plugins/fetch).") } - const origFetch = window.fetch return (app: App | null) => { if (app === null) { - return origFetch + return options.fetch } const ihOpt = options.ignoreHeaders @@ -56,7 +64,7 @@ export default function(opts: Partial = {}) { const fetch = async (input: RequestInfo, init: RequestInit = {}) => { if (typeof input !== 'string') { - return origFetch(input, init); + return options.fetch(input, init); } if (options.sessionTokenHeader) { const sessionToken = app.getSessionToken(); @@ -74,87 +82,89 @@ export default function(opts: Partial = {}) { } } const startTime = performance.now(); - const response = await origFetch(input, init); + const response = await options.fetch(input, init); const duration = performance.now() - startTime; if (options.failuresOnly && response.status < 400) { return response } - const r = response.clone(); + (async () => { + const r = response.clone(); - r.text().then(text => { - // Headers prepearing - const reqHs: Record = {} - const resHs: Record = {} - if (ihOpt !== true) { - function writeReqHeader([n, v]) { - if (!isHIgnoring(n)) { reqHs[n] = v } - } - if (init.headers instanceof Headers) { - init.headers.forEach((v, n) => writeReqHeader([n, v])) - } else if (Array.isArray(init.headers)) { - init.headers.forEach(writeReqHeader); - } else if (typeof init.headers === 'object') { - Object.entries(init.headers).forEach(writeReqHeader) + r.text().then(text => { + // Headers prepearing + const reqHs: Record = {} + const resHs: Record = {} + if (ihOpt !== true) { + function writeReqHeader([n, v]) { + if (!isHIgnoring(n)) { reqHs[n] = v } + } + if (init.headers instanceof Headers) { + init.headers.forEach((v, n) => writeReqHeader([n, v])) + } else if (Array.isArray(init.headers)) { + init.headers.forEach(writeReqHeader); + } else if (typeof init.headers === 'object') { + Object.entries(init.headers).forEach(writeReqHeader) + } + + r.headers.forEach((v, n) => { if (!isHIgnoring(n)) resHs[n] = v }) } - r.headers.forEach((v, n) => { if (!isHIgnoring(n)) resHs[n] = v }) - } - - const req: RequestData = { - headers: reqHs, - body: init.body, - } - - // Response forming - const res: ResponseData = { - headers: resHs, - body: text, - } - - const method = typeof init.method === 'string' - ? init.method.toUpperCase() - : 'GET' - let reqResInfo: RequestResponseData | null = { - url: input, - method, - status: r.status, - request: req, - response: res, - } - if (options.sanitiser) { - try { - reqResInfo.response.body = JSON.parse(text) as Object // Why the returning type is "any"? - } catch {} - reqResInfo = options.sanitiser(reqResInfo) - if (!reqResInfo) { - return + const req: RequestData = { + headers: reqHs, + body: init.body, } - } - const getStj = (r: RequestData | ResponseData): string => { - if (r && typeof r.body !== 'string') { + // Response forming + const res: ResponseData = { + headers: resHs, + body: text, + } + + const method = typeof init.method === 'string' + ? init.method.toUpperCase() + : 'GET' + let reqResInfo: RequestResponseData | null = { + url: input, + method, + status: r.status, + request: req, + response: res, + } + if (options.sanitiser) { try { - r.body = JSON.stringify(r.body) - } catch { - r.body = "" - //app.log.warn("Openreplay fetch") // TODO: version check + reqResInfo.response.body = JSON.parse(text) as Object // Why the returning type is "any"? + } catch {} + reqResInfo = options.sanitiser(reqResInfo) + if (!reqResInfo) { + return } } - return JSON.stringify(r) - } - app.send( - Messages.Fetch( - method, - String(reqResInfo.url), - getStj(reqResInfo.request), - getStj(reqResInfo.response), - r.status, - startTime + performance.timing.navigationStart, - duration, - ), - ) - }); + const getStj = (r: RequestData | ResponseData): string => { + if (r && typeof r.body !== 'string') { + try { + r.body = JSON.stringify(r.body) + } catch { + r.body = "" + //app.log.warn("Openreplay fetch") // TODO: version check + } + } + return JSON.stringify(r) + } + + app.send( + Messages.Fetch( + method, + String(reqResInfo.url), + getStj(reqResInfo.request), + getStj(reqResInfo.response), + r.status, + startTime + performance.timing.navigationStart, + duration, + ), + ) + }) + })() return response; } diff --git a/tracker/tracker-graphql/LICENSE b/tracker/tracker-graphql/LICENSE index b57f138e0..15669f768 100644 --- a/tracker/tracker-graphql/LICENSE +++ b/tracker/tracker-graphql/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2021 OpenReplay.com +Copyright (c) 2022 Asayer, Inc Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/tracker/tracker-mobx/LICENSE b/tracker/tracker-mobx/LICENSE index b57f138e0..15669f768 100644 --- a/tracker/tracker-mobx/LICENSE +++ b/tracker/tracker-mobx/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2021 OpenReplay.com +Copyright (c) 2022 Asayer, Inc Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/tracker/tracker-ngrx/LICENSE b/tracker/tracker-ngrx/LICENSE index b57f138e0..15669f768 100644 --- a/tracker/tracker-ngrx/LICENSE +++ b/tracker/tracker-ngrx/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2021 OpenReplay.com +Copyright (c) 2022 Asayer, Inc Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/tracker/tracker-profiler/LICENSE b/tracker/tracker-profiler/LICENSE index b57f138e0..15669f768 100644 --- a/tracker/tracker-profiler/LICENSE +++ b/tracker/tracker-profiler/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2021 OpenReplay.com +Copyright (c) 2022 Asayer, Inc Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/tracker/tracker-redux/LICENSE b/tracker/tracker-redux/LICENSE index b57f138e0..15669f768 100644 --- a/tracker/tracker-redux/LICENSE +++ b/tracker/tracker-redux/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2021 OpenReplay.com +Copyright (c) 2022 Asayer, Inc Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/tracker/tracker-vuex/LICENSE b/tracker/tracker-vuex/LICENSE index b57f138e0..15669f768 100644 --- a/tracker/tracker-vuex/LICENSE +++ b/tracker/tracker-vuex/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2021 OpenReplay.com +Copyright (c) 2022 Asayer, Inc Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/tracker/tracker/.eslintignore b/tracker/tracker/.eslintignore new file mode 100644 index 000000000..688a8d852 --- /dev/null +++ b/tracker/tracker/.eslintignore @@ -0,0 +1,8 @@ +node_modules +npm-debug.log +lib +cjs +build +.cache +.eslintrc.cjs +*.gen.ts diff --git a/tracker/tracker/.eslintrc.cjs b/tracker/tracker/.eslintrc.cjs index b0c6b42eb..a5a2267bd 100644 --- a/tracker/tracker/.eslintrc.cjs +++ b/tracker/tracker/.eslintrc.cjs @@ -1,8 +1,10 @@ +/* eslint-disable */ module.exports = { root: true, parser: '@typescript-eslint/parser', parserOptions: { - project: ['./tsconfig.json'], + project: ['./tsconfig-base.json', './src/main/tsconfig-cjs.json'], + tsconfigRootDir: __dirname, }, plugins: ['prettier', '@typescript-eslint'], extends: [ @@ -11,7 +13,7 @@ module.exports = { 'plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended', 'plugin:@typescript-eslint/recommended-requiring-type-checking', - 'prettier/@typescript-eslint', + 'prettier', ], rules: { 'prettier/prettier': ['error', require('./.prettierrc.json')], @@ -25,8 +27,21 @@ module.exports = { '@typescript-eslint/camelcase': 'off', '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/unbound-method': 'off', - '@typescript-eslint/explicit-function-return-type': 'warn', + '@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/prefer-readonly': 'warn', + '@typescript-eslint/ban-ts-comment': 'off', + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/restrict-plus-operands': 'warn', + '@typescript-eslint/no-unsafe-return': 'warn', + 'no-useless-escape': 'warn', + 'no-control-regex': 'warn', + '@typescript-eslint/restrict-template-expressions': 'warn', + '@typescript-eslint/no-useless-constructor': 'warn', + '@typescript-eslint/no-this-alias': 'off', + '@typescript-eslint/no-floating-promises': 'warn', + '@typescript-eslint/no-unsafe-argument': 'warn', 'no-unused-expressions': 'off', '@typescript-eslint/no-unused-expressions': 'warn', '@typescript-eslint/no-useless-constructor': 'warn', diff --git a/tracker/tracker/.prettierignore b/tracker/tracker/.prettierignore new file mode 100644 index 000000000..89421c20a --- /dev/null +++ b/tracker/tracker/.prettierignore @@ -0,0 +1 @@ +*.gen.ts diff --git a/tracker/tracker/.prettierrc.json b/tracker/tracker/.prettierrc.json index a20502b7f..9806a4dc3 100644 --- a/tracker/tracker/.prettierrc.json +++ b/tracker/tracker/.prettierrc.json @@ -1,4 +1,6 @@ { + "printWidth": 100, "singleQuote": true, - "trailingComma": "all" + "trailingComma": "all", + "semi": false } diff --git a/tracker/tracker/LICENSE b/tracker/tracker/LICENSE index b57f138e0..15669f768 100644 --- a/tracker/tracker/LICENSE +++ b/tracker/tracker/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2021 OpenReplay.com +Copyright (c) 2022 Asayer, Inc Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/tracker/tracker/package.json b/tracker/tracker/package.json index 7a6fdbe01..67511049e 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.5.16", + "version": "3.6.0", "keywords": [ "logging", "replay" @@ -14,33 +14,50 @@ "type": "module", "main": "./lib/index.js", "scripts": { - "lint": "eslint src --ext .ts,.js --fix && tsc --noEmit", + "lint": "eslint src --ext .ts,.js --fix --quiet", "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", + "tscRun": "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 scripts/compile.cjs", - "build": "npm run clean && npm run tsc && npm run rollup && npm run compile", - "prepare": "node scripts/checkver.cjs && npm run build" + "build": "npm run clean && npm run tscRun && npm run rollup && npm run compile", + "prepare": "cd ../../ && husky install tracker/.husky/", + "lint-front": "lint-staged" }, "devDependencies": { "@babel/core": "^7.10.2", "@rollup/plugin-babel": "^5.0.3", "@rollup/plugin-node-resolve": "^10.0.0", - "@typescript-eslint/eslint-plugin": "^4.33.0", - "@typescript-eslint/parser": "^4.33.0", + "@typescript-eslint/eslint-plugin": "^5.30.0", + "@typescript-eslint/parser": "^5.30.0", "eslint": "^7.8.0", - "eslint-plugin-prettier": "^4.1.4", - "prettier": "^2.0.0", + "eslint-config-prettier": "^8.5.0", + "eslint-plugin-prettier": "^4.2.1", + "husky": "^8.0.1", + "lint-staged": "^13.0.3", + "prettier": "^2.7.1", "replace-in-files": "^2.0.3", "rollup": "^2.17.0", "rollup-plugin-terser": "^6.1.0", "semver": "^6.3.0", - "typescript": "^4.6.0-dev.20211126" + "typescript": "4.6.0-dev.20211126" }, "dependencies": { "error-stack-parser": "^2.0.6" }, "engines": { "node": ">=14.0" + }, + "husky": { + "hooks": { + "pre-commit": "sh lint.sh" + } + }, + "lint-staged": { + "*.{js,mjs,jsx,ts,tsx}": [ + "eslint --fix --quiet" + ], + "*.{json,md,html,js,jsx,ts,tsx}": [ + "prettier --write" + ] } } diff --git a/tracker/tracker/scripts/compile.cjs b/tracker/tracker/scripts/compile.cjs index ec30ac21e..998ef3eca 100644 --- a/tracker/tracker/scripts/compile.cjs +++ b/tracker/tracker/scripts/compile.cjs @@ -12,7 +12,7 @@ async function main() { await replaceInFiles({ files: 'build/**/*', from: 'WEBWORKER_BODY', - to: webworker.replace(/'/g, "\\'"), + to: webworker.replace(/'/g, "\\'").replace(/\n/g, ""), }); await fs.rename('build/main', 'lib'); await fs.rename('build/common', 'lib/common'); diff --git a/tracker/tracker/src/common/interaction.ts b/tracker/tracker/src/common/interaction.ts new file mode 100644 index 000000000..19d8fa906 --- /dev/null +++ b/tracker/tracker/src/common/interaction.ts @@ -0,0 +1,22 @@ +import Message from './messages.gen.js' + +export interface Options { + connAttemptCount?: number + connAttemptGap?: number +} + +type Start = { + type: 'start' + ingestPoint: string + pageNo: number + timestamp: number + url: string +} & Options + +type Auth = { + type: 'auth' + token: string + beaconSizeLimit?: number +} + +export type WorkerMessageData = null | 'stop' | Start | Auth | Array diff --git a/tracker/tracker/src/common/messages.gen.ts b/tracker/tracker/src/common/messages.gen.ts new file mode 100644 index 000000000..3b45ec023 --- /dev/null +++ b/tracker/tracker/src/common/messages.gen.ts @@ -0,0 +1,443 @@ +// Auto-generated, do not edit + +export declare const enum Type { + BatchMetadata = 81, + PartitionedMessage = 82, + Timestamp = 0, + SetPageLocation = 4, + SetViewportSize = 5, + SetViewportScroll = 6, + CreateDocument = 7, + CreateElementNode = 8, + CreateTextNode = 9, + MoveNode = 10, + RemoveNode = 11, + SetNodeAttribute = 12, + RemoveNodeAttribute = 13, + SetNodeData = 14, + SetNodeScroll = 16, + SetInputTarget = 17, + SetInputValue = 18, + SetInputChecked = 19, + MouseMove = 20, + ConsoleLog = 22, + PageLoadTiming = 23, + PageRenderTiming = 24, + JSException = 25, + RawCustomEvent = 27, + UserID = 28, + UserAnonymousID = 29, + Metadata = 30, + CSSInsertRule = 37, + CSSDeleteRule = 38, + Fetch = 39, + Profiler = 40, + OTable = 41, + StateAction = 42, + Redux = 44, + Vuex = 45, + MobX = 46, + NgRx = 47, + GraphQL = 48, + PerformanceTrack = 49, + ResourceTiming = 53, + ConnectionInformation = 54, + SetPageVisibility = 55, + LongTask = 59, + SetNodeAttributeURLBased = 60, + SetCSSDataURLBased = 61, + TechnicalInfo = 63, + CustomIssue = 64, + CSSInsertRuleURLBased = 67, + MouseClick = 69, + CreateIFrameDocument = 70, + AdoptedSSReplaceURLBased = 71, + AdoptedSSInsertRuleURLBased = 73, + AdoptedSSDeleteRule = 75, + AdoptedSSAddOwner = 76, + AdoptedSSRemoveOwner = 77, +} + + +export type BatchMetadata = [ + /*type:*/ Type.BatchMetadata, + /*version:*/ number, + /*pageNo:*/ number, + /*firstIndex:*/ number, + /*timestamp:*/ number, + /*location:*/ string, +] + +export type PartitionedMessage = [ + /*type:*/ Type.PartitionedMessage, + /*partNo:*/ number, + /*partTotal:*/ number, +] + +export type Timestamp = [ + /*type:*/ Type.Timestamp, + /*timestamp:*/ number, +] + +export type SetPageLocation = [ + /*type:*/ Type.SetPageLocation, + /*url:*/ string, + /*referrer:*/ string, + /*navigationStart:*/ number, +] + +export type SetViewportSize = [ + /*type:*/ Type.SetViewportSize, + /*width:*/ number, + /*height:*/ number, +] + +export type SetViewportScroll = [ + /*type:*/ Type.SetViewportScroll, + /*x:*/ number, + /*y:*/ number, +] + +export type CreateDocument = [ + /*type:*/ Type.CreateDocument, + +] + +export type CreateElementNode = [ + /*type:*/ Type.CreateElementNode, + /*id:*/ number, + /*parentID:*/ number, + /*index:*/ number, + /*tag:*/ string, + /*svg:*/ boolean, +] + +export type CreateTextNode = [ + /*type:*/ Type.CreateTextNode, + /*id:*/ number, + /*parentID:*/ number, + /*index:*/ number, +] + +export type MoveNode = [ + /*type:*/ Type.MoveNode, + /*id:*/ number, + /*parentID:*/ number, + /*index:*/ number, +] + +export type RemoveNode = [ + /*type:*/ Type.RemoveNode, + /*id:*/ number, +] + +export type SetNodeAttribute = [ + /*type:*/ Type.SetNodeAttribute, + /*id:*/ number, + /*name:*/ string, + /*value:*/ string, +] + +export type RemoveNodeAttribute = [ + /*type:*/ Type.RemoveNodeAttribute, + /*id:*/ number, + /*name:*/ string, +] + +export type SetNodeData = [ + /*type:*/ Type.SetNodeData, + /*id:*/ number, + /*data:*/ string, +] + +export type SetNodeScroll = [ + /*type:*/ Type.SetNodeScroll, + /*id:*/ number, + /*x:*/ number, + /*y:*/ number, +] + +export type SetInputTarget = [ + /*type:*/ Type.SetInputTarget, + /*id:*/ number, + /*label:*/ string, +] + +export type SetInputValue = [ + /*type:*/ Type.SetInputValue, + /*id:*/ number, + /*value:*/ string, + /*mask:*/ number, +] + +export type SetInputChecked = [ + /*type:*/ Type.SetInputChecked, + /*id:*/ number, + /*checked:*/ boolean, +] + +export type MouseMove = [ + /*type:*/ Type.MouseMove, + /*x:*/ number, + /*y:*/ number, +] + +export type ConsoleLog = [ + /*type:*/ Type.ConsoleLog, + /*level:*/ string, + /*value:*/ string, +] + +export type PageLoadTiming = [ + /*type:*/ Type.PageLoadTiming, + /*requestStart:*/ number, + /*responseStart:*/ number, + /*responseEnd:*/ number, + /*domContentLoadedEventStart:*/ number, + /*domContentLoadedEventEnd:*/ number, + /*loadEventStart:*/ number, + /*loadEventEnd:*/ number, + /*firstPaint:*/ number, + /*firstContentfulPaint:*/ number, +] + +export type PageRenderTiming = [ + /*type:*/ Type.PageRenderTiming, + /*speedIndex:*/ number, + /*visuallyComplete:*/ number, + /*timeToInteractive:*/ number, +] + +export type JSException = [ + /*type:*/ Type.JSException, + /*name:*/ string, + /*message:*/ string, + /*payload:*/ string, +] + +export type RawCustomEvent = [ + /*type:*/ Type.RawCustomEvent, + /*name:*/ string, + /*payload:*/ string, +] + +export type UserID = [ + /*type:*/ Type.UserID, + /*id:*/ string, +] + +export type UserAnonymousID = [ + /*type:*/ Type.UserAnonymousID, + /*id:*/ string, +] + +export type Metadata = [ + /*type:*/ Type.Metadata, + /*key:*/ string, + /*value:*/ string, +] + +export type CSSInsertRule = [ + /*type:*/ Type.CSSInsertRule, + /*id:*/ number, + /*rule:*/ string, + /*index:*/ number, +] + +export type CSSDeleteRule = [ + /*type:*/ Type.CSSDeleteRule, + /*id:*/ number, + /*index:*/ number, +] + +export type Fetch = [ + /*type:*/ Type.Fetch, + /*method:*/ string, + /*url:*/ string, + /*request:*/ string, + /*response:*/ string, + /*status:*/ number, + /*timestamp:*/ number, + /*duration:*/ number, +] + +export type Profiler = [ + /*type:*/ Type.Profiler, + /*name:*/ string, + /*duration:*/ number, + /*args:*/ string, + /*result:*/ string, +] + +export type OTable = [ + /*type:*/ Type.OTable, + /*key:*/ string, + /*value:*/ string, +] + +export type StateAction = [ + /*type:*/ Type.StateAction, + /*type:*/ string, +] + +export type Redux = [ + /*type:*/ Type.Redux, + /*action:*/ string, + /*state:*/ string, + /*duration:*/ number, +] + +export type Vuex = [ + /*type:*/ Type.Vuex, + /*mutation:*/ string, + /*state:*/ string, +] + +export type MobX = [ + /*type:*/ Type.MobX, + /*type:*/ string, + /*payload:*/ string, +] + +export type NgRx = [ + /*type:*/ Type.NgRx, + /*action:*/ string, + /*state:*/ string, + /*duration:*/ number, +] + +export type GraphQL = [ + /*type:*/ Type.GraphQL, + /*operationKind:*/ string, + /*operationName:*/ string, + /*variables:*/ string, + /*response:*/ string, +] + +export type PerformanceTrack = [ + /*type:*/ Type.PerformanceTrack, + /*frames:*/ number, + /*ticks:*/ number, + /*totalJSHeapSize:*/ number, + /*usedJSHeapSize:*/ number, +] + +export type ResourceTiming = [ + /*type:*/ Type.ResourceTiming, + /*timestamp:*/ number, + /*duration:*/ number, + /*ttfb:*/ number, + /*headerSize:*/ number, + /*encodedBodySize:*/ number, + /*decodedBodySize:*/ number, + /*url:*/ string, + /*initiator:*/ string, +] + +export type ConnectionInformation = [ + /*type:*/ Type.ConnectionInformation, + /*downlink:*/ number, + /*type:*/ string, +] + +export type SetPageVisibility = [ + /*type:*/ Type.SetPageVisibility, + /*hidden:*/ boolean, +] + +export type LongTask = [ + /*type:*/ Type.LongTask, + /*timestamp:*/ number, + /*duration:*/ number, + /*context:*/ number, + /*containerType:*/ number, + /*containerSrc:*/ string, + /*containerId:*/ string, + /*containerName:*/ string, +] + +export type SetNodeAttributeURLBased = [ + /*type:*/ Type.SetNodeAttributeURLBased, + /*id:*/ number, + /*name:*/ string, + /*value:*/ string, + /*baseURL:*/ string, +] + +export type SetCSSDataURLBased = [ + /*type:*/ Type.SetCSSDataURLBased, + /*id:*/ number, + /*data:*/ string, + /*baseURL:*/ string, +] + +export type TechnicalInfo = [ + /*type:*/ Type.TechnicalInfo, + /*type:*/ string, + /*value:*/ string, +] + +export type CustomIssue = [ + /*type:*/ Type.CustomIssue, + /*name:*/ string, + /*payload:*/ string, +] + +export type CSSInsertRuleURLBased = [ + /*type:*/ Type.CSSInsertRuleURLBased, + /*id:*/ number, + /*rule:*/ string, + /*index:*/ number, + /*baseURL:*/ string, +] + +export type MouseClick = [ + /*type:*/ Type.MouseClick, + /*id:*/ number, + /*hesitationTime:*/ number, + /*label:*/ string, + /*selector:*/ string, +] + +export type CreateIFrameDocument = [ + /*type:*/ Type.CreateIFrameDocument, + /*frameID:*/ number, + /*id:*/ number, +] + +export type AdoptedSSReplaceURLBased = [ + /*type:*/ Type.AdoptedSSReplaceURLBased, + /*sheetID:*/ number, + /*text:*/ string, + /*baseURL:*/ string, +] + +export type AdoptedSSInsertRuleURLBased = [ + /*type:*/ Type.AdoptedSSInsertRuleURLBased, + /*sheetID:*/ number, + /*rule:*/ string, + /*index:*/ number, + /*baseURL:*/ string, +] + +export type AdoptedSSDeleteRule = [ + /*type:*/ Type.AdoptedSSDeleteRule, + /*sheetID:*/ number, + /*index:*/ number, +] + +export type AdoptedSSAddOwner = [ + /*type:*/ Type.AdoptedSSAddOwner, + /*sheetID:*/ number, + /*id:*/ number, +] + +export type AdoptedSSRemoveOwner = [ + /*type:*/ Type.AdoptedSSRemoveOwner, + /*sheetID:*/ number, + /*id:*/ number, +] + + +type Message = BatchMetadata | PartitionedMessage | Timestamp | SetPageLocation | SetViewportSize | SetViewportScroll | CreateDocument | CreateElementNode | CreateTextNode | MoveNode | RemoveNode | SetNodeAttribute | RemoveNodeAttribute | SetNodeData | SetNodeScroll | SetInputTarget | SetInputValue | SetInputChecked | MouseMove | ConsoleLog | PageLoadTiming | PageRenderTiming | JSException | RawCustomEvent | UserID | UserAnonymousID | Metadata | CSSInsertRule | CSSDeleteRule | Fetch | Profiler | OTable | StateAction | Redux | Vuex | MobX | NgRx | GraphQL | PerformanceTrack | ResourceTiming | ConnectionInformation | SetPageVisibility | LongTask | SetNodeAttributeURLBased | SetCSSDataURLBased | TechnicalInfo | CustomIssue | CSSInsertRuleURLBased | MouseClick | CreateIFrameDocument | AdoptedSSReplaceURLBased | AdoptedSSInsertRuleURLBased | AdoptedSSDeleteRule | AdoptedSSAddOwner | AdoptedSSRemoveOwner +export default Message diff --git a/tracker/tracker/src/common/messages.ts b/tracker/tracker/src/common/messages.ts deleted file mode 100644 index 140bcaac6..000000000 --- a/tracker/tracker/src/common/messages.ts +++ /dev/null @@ -1,903 +0,0 @@ -// Auto-generated, do not edit -import type { Writer, Message }from "./types.js"; -export default Message - -function bindNew( - Class: C & { new(...args: A): T } -): C & ((...args: A) => T) { - function _Class(...args: A) { - return new Class(...args); - } - _Class.prototype = Class.prototype; - return T)>_Class; -} - -export const classes: Map = new Map(); - - -class _BatchMeta implements Message { - readonly _id: number = 80; - constructor( - public pageNo: number, - public firstIndex: number, - public timestamp: number - ) {} - encode(writer: Writer): boolean { - return writer.uint(80) && - writer.uint(this.pageNo) && - writer.uint(this.firstIndex) && - writer.int(this.timestamp); - } -} -export const BatchMeta = bindNew(_BatchMeta); -classes.set(80, BatchMeta); - - -class _Timestamp implements Message { - readonly _id: number = 0; - constructor( - public timestamp: number - ) {} - encode(writer: Writer): boolean { - return writer.uint(0) && - writer.uint(this.timestamp); - } -} -export const Timestamp = bindNew(_Timestamp); -classes.set(0, Timestamp); - - -class _SetPageLocation implements Message { - readonly _id: number = 4; - constructor( - public url: string, - public referrer: string, - public navigationStart: number - ) {} - encode(writer: Writer): boolean { - return writer.uint(4) && - writer.string(this.url) && - writer.string(this.referrer) && - writer.uint(this.navigationStart); - } -} -export const SetPageLocation = bindNew(_SetPageLocation); -classes.set(4, SetPageLocation); - - -class _SetViewportSize implements Message { - readonly _id: number = 5; - constructor( - public width: number, - public height: number - ) {} - encode(writer: Writer): boolean { - return writer.uint(5) && - writer.uint(this.width) && - writer.uint(this.height); - } -} -export const SetViewportSize = bindNew(_SetViewportSize); -classes.set(5, SetViewportSize); - - -class _SetViewportScroll implements Message { - readonly _id: number = 6; - constructor( - public x: number, - public y: number - ) {} - encode(writer: Writer): boolean { - return writer.uint(6) && - writer.int(this.x) && - writer.int(this.y); - } -} -export const SetViewportScroll = bindNew(_SetViewportScroll); -classes.set(6, SetViewportScroll); - - -class _CreateDocument implements Message { - readonly _id: number = 7; - constructor( - - ) {} - encode(writer: Writer): boolean { - return writer.uint(7) - ; - } -} -export const CreateDocument = bindNew(_CreateDocument); -classes.set(7, CreateDocument); - - -class _CreateElementNode implements Message { - readonly _id: number = 8; - constructor( - public id: number, - public parentID: number, - public index: number, - public tag: string, - public svg: boolean - ) {} - encode(writer: Writer): boolean { - return writer.uint(8) && - writer.uint(this.id) && - writer.uint(this.parentID) && - writer.uint(this.index) && - writer.string(this.tag) && - writer.boolean(this.svg); - } -} -export const CreateElementNode = bindNew(_CreateElementNode); -classes.set(8, CreateElementNode); - - -class _CreateTextNode implements Message { - readonly _id: number = 9; - constructor( - public id: number, - public parentID: number, - public index: number - ) {} - encode(writer: Writer): boolean { - return writer.uint(9) && - writer.uint(this.id) && - writer.uint(this.parentID) && - writer.uint(this.index); - } -} -export const CreateTextNode = bindNew(_CreateTextNode); -classes.set(9, CreateTextNode); - - -class _MoveNode implements Message { - readonly _id: number = 10; - constructor( - public id: number, - public parentID: number, - public index: number - ) {} - encode(writer: Writer): boolean { - return writer.uint(10) && - writer.uint(this.id) && - writer.uint(this.parentID) && - writer.uint(this.index); - } -} -export const MoveNode = bindNew(_MoveNode); -classes.set(10, MoveNode); - - -class _RemoveNode implements Message { - readonly _id: number = 11; - constructor( - public id: number - ) {} - encode(writer: Writer): boolean { - return writer.uint(11) && - writer.uint(this.id); - } -} -export const RemoveNode = bindNew(_RemoveNode); -classes.set(11, RemoveNode); - - -class _SetNodeAttribute implements Message { - readonly _id: number = 12; - constructor( - public id: number, - public name: string, - public value: string - ) {} - encode(writer: Writer): boolean { - return writer.uint(12) && - writer.uint(this.id) && - writer.string(this.name) && - writer.string(this.value); - } -} -export const SetNodeAttribute = bindNew(_SetNodeAttribute); -classes.set(12, SetNodeAttribute); - - -class _RemoveNodeAttribute implements Message { - readonly _id: number = 13; - constructor( - public id: number, - public name: string - ) {} - encode(writer: Writer): boolean { - return writer.uint(13) && - writer.uint(this.id) && - writer.string(this.name); - } -} -export const RemoveNodeAttribute = bindNew(_RemoveNodeAttribute); -classes.set(13, RemoveNodeAttribute); - - -class _SetNodeData implements Message { - readonly _id: number = 14; - constructor( - public id: number, - public data: string - ) {} - encode(writer: Writer): boolean { - return writer.uint(14) && - writer.uint(this.id) && - writer.string(this.data); - } -} -export const SetNodeData = bindNew(_SetNodeData); -classes.set(14, SetNodeData); - - -class _SetNodeScroll implements Message { - readonly _id: number = 16; - constructor( - public id: number, - public x: number, - public y: number - ) {} - encode(writer: Writer): boolean { - return writer.uint(16) && - writer.uint(this.id) && - writer.int(this.x) && - writer.int(this.y); - } -} -export const SetNodeScroll = bindNew(_SetNodeScroll); -classes.set(16, SetNodeScroll); - - -class _SetInputTarget implements Message { - readonly _id: number = 17; - constructor( - public id: number, - public label: string - ) {} - encode(writer: Writer): boolean { - return writer.uint(17) && - writer.uint(this.id) && - writer.string(this.label); - } -} -export const SetInputTarget = bindNew(_SetInputTarget); -classes.set(17, SetInputTarget); - - -class _SetInputValue implements Message { - readonly _id: number = 18; - constructor( - public id: number, - public value: string, - public mask: number - ) {} - encode(writer: Writer): boolean { - return writer.uint(18) && - writer.uint(this.id) && - writer.string(this.value) && - writer.int(this.mask); - } -} -export const SetInputValue = bindNew(_SetInputValue); -classes.set(18, SetInputValue); - - -class _SetInputChecked implements Message { - readonly _id: number = 19; - constructor( - public id: number, - public checked: boolean - ) {} - encode(writer: Writer): boolean { - return writer.uint(19) && - writer.uint(this.id) && - writer.boolean(this.checked); - } -} -export const SetInputChecked = bindNew(_SetInputChecked); -classes.set(19, SetInputChecked); - - -class _MouseMove implements Message { - readonly _id: number = 20; - constructor( - public x: number, - public y: number - ) {} - encode(writer: Writer): boolean { - return writer.uint(20) && - writer.uint(this.x) && - writer.uint(this.y); - } -} -export const MouseMove = bindNew(_MouseMove); -classes.set(20, MouseMove); - - -class _ConsoleLog implements Message { - readonly _id: number = 22; - constructor( - public level: string, - public value: string - ) {} - encode(writer: Writer): boolean { - return writer.uint(22) && - writer.string(this.level) && - writer.string(this.value); - } -} -export const ConsoleLog = bindNew(_ConsoleLog); -classes.set(22, ConsoleLog); - - -class _PageLoadTiming implements Message { - readonly _id: number = 23; - constructor( - public requestStart: number, - public responseStart: number, - public responseEnd: number, - public domContentLoadedEventStart: number, - public domContentLoadedEventEnd: number, - public loadEventStart: number, - public loadEventEnd: number, - public firstPaint: number, - public firstContentfulPaint: number - ) {} - encode(writer: Writer): boolean { - return writer.uint(23) && - writer.uint(this.requestStart) && - writer.uint(this.responseStart) && - writer.uint(this.responseEnd) && - writer.uint(this.domContentLoadedEventStart) && - writer.uint(this.domContentLoadedEventEnd) && - writer.uint(this.loadEventStart) && - writer.uint(this.loadEventEnd) && - writer.uint(this.firstPaint) && - writer.uint(this.firstContentfulPaint); - } -} -export const PageLoadTiming = bindNew(_PageLoadTiming); -classes.set(23, PageLoadTiming); - - -class _PageRenderTiming implements Message { - readonly _id: number = 24; - constructor( - public speedIndex: number, - public visuallyComplete: number, - public timeToInteractive: number - ) {} - encode(writer: Writer): boolean { - return writer.uint(24) && - writer.uint(this.speedIndex) && - writer.uint(this.visuallyComplete) && - writer.uint(this.timeToInteractive); - } -} -export const PageRenderTiming = bindNew(_PageRenderTiming); -classes.set(24, PageRenderTiming); - - -class _JSException implements Message { - readonly _id: number = 25; - constructor( - public name: string, - public message: string, - public payload: string - ) {} - encode(writer: Writer): boolean { - return writer.uint(25) && - writer.string(this.name) && - writer.string(this.message) && - writer.string(this.payload); - } -} -export const JSException = bindNew(_JSException); -classes.set(25, JSException); - - -class _RawCustomEvent implements Message { - readonly _id: number = 27; - constructor( - public name: string, - public payload: string - ) {} - encode(writer: Writer): boolean { - return writer.uint(27) && - writer.string(this.name) && - writer.string(this.payload); - } -} -export const RawCustomEvent = bindNew(_RawCustomEvent); -classes.set(27, RawCustomEvent); - - -class _UserID implements Message { - readonly _id: number = 28; - constructor( - public id: string - ) {} - encode(writer: Writer): boolean { - return writer.uint(28) && - writer.string(this.id); - } -} -export const UserID = bindNew(_UserID); -classes.set(28, UserID); - - -class _UserAnonymousID implements Message { - readonly _id: number = 29; - constructor( - public id: string - ) {} - encode(writer: Writer): boolean { - return writer.uint(29) && - writer.string(this.id); - } -} -export const UserAnonymousID = bindNew(_UserAnonymousID); -classes.set(29, UserAnonymousID); - - -class _Metadata implements Message { - readonly _id: number = 30; - constructor( - public key: string, - public value: string - ) {} - encode(writer: Writer): boolean { - return writer.uint(30) && - writer.string(this.key) && - writer.string(this.value); - } -} -export const Metadata = bindNew(_Metadata); -classes.set(30, Metadata); - - -class _CSSInsertRule implements Message { - readonly _id: number = 37; - constructor( - public id: number, - public rule: string, - public index: number - ) {} - encode(writer: Writer): boolean { - return writer.uint(37) && - writer.uint(this.id) && - writer.string(this.rule) && - writer.uint(this.index); - } -} -export const CSSInsertRule = bindNew(_CSSInsertRule); -classes.set(37, CSSInsertRule); - - -class _CSSDeleteRule implements Message { - readonly _id: number = 38; - constructor( - public id: number, - public index: number - ) {} - encode(writer: Writer): boolean { - return writer.uint(38) && - writer.uint(this.id) && - writer.uint(this.index); - } -} -export const CSSDeleteRule = bindNew(_CSSDeleteRule); -classes.set(38, CSSDeleteRule); - - -class _Fetch implements Message { - readonly _id: number = 39; - constructor( - public method: string, - public url: string, - public request: string, - public response: string, - public status: number, - public timestamp: number, - public duration: number - ) {} - encode(writer: Writer): boolean { - return writer.uint(39) && - writer.string(this.method) && - writer.string(this.url) && - writer.string(this.request) && - writer.string(this.response) && - writer.uint(this.status) && - writer.uint(this.timestamp) && - writer.uint(this.duration); - } -} -export const Fetch = bindNew(_Fetch); -classes.set(39, Fetch); - - -class _Profiler implements Message { - readonly _id: number = 40; - constructor( - public name: string, - public duration: number, - public args: string, - public result: string - ) {} - encode(writer: Writer): boolean { - return writer.uint(40) && - writer.string(this.name) && - writer.uint(this.duration) && - writer.string(this.args) && - writer.string(this.result); - } -} -export const Profiler = bindNew(_Profiler); -classes.set(40, Profiler); - - -class _OTable implements Message { - readonly _id: number = 41; - constructor( - public key: string, - public value: string - ) {} - encode(writer: Writer): boolean { - return writer.uint(41) && - writer.string(this.key) && - writer.string(this.value); - } -} -export const OTable = bindNew(_OTable); -classes.set(41, OTable); - - -class _StateAction implements Message { - readonly _id: number = 42; - constructor( - public type: string - ) {} - encode(writer: Writer): boolean { - return writer.uint(42) && - writer.string(this.type); - } -} -export const StateAction = bindNew(_StateAction); -classes.set(42, StateAction); - - -class _Redux implements Message { - readonly _id: number = 44; - constructor( - public action: string, - public state: string, - public duration: number - ) {} - encode(writer: Writer): boolean { - return writer.uint(44) && - writer.string(this.action) && - writer.string(this.state) && - writer.uint(this.duration); - } -} -export const Redux = bindNew(_Redux); -classes.set(44, Redux); - - -class _Vuex implements Message { - readonly _id: number = 45; - constructor( - public mutation: string, - public state: string - ) {} - encode(writer: Writer): boolean { - return writer.uint(45) && - writer.string(this.mutation) && - writer.string(this.state); - } -} -export const Vuex = bindNew(_Vuex); -classes.set(45, Vuex); - - -class _MobX implements Message { - readonly _id: number = 46; - constructor( - public type: string, - public payload: string - ) {} - encode(writer: Writer): boolean { - return writer.uint(46) && - writer.string(this.type) && - writer.string(this.payload); - } -} -export const MobX = bindNew(_MobX); -classes.set(46, MobX); - - -class _NgRx implements Message { - readonly _id: number = 47; - constructor( - public action: string, - public state: string, - public duration: number - ) {} - encode(writer: Writer): boolean { - return writer.uint(47) && - writer.string(this.action) && - writer.string(this.state) && - writer.uint(this.duration); - } -} -export const NgRx = bindNew(_NgRx); -classes.set(47, NgRx); - - -class _GraphQL implements Message { - readonly _id: number = 48; - constructor( - public operationKind: string, - public operationName: string, - public variables: string, - public response: string - ) {} - encode(writer: Writer): boolean { - return writer.uint(48) && - writer.string(this.operationKind) && - writer.string(this.operationName) && - writer.string(this.variables) && - writer.string(this.response); - } -} -export const GraphQL = bindNew(_GraphQL); -classes.set(48, GraphQL); - - -class _PerformanceTrack implements Message { - readonly _id: number = 49; - constructor( - public frames: number, - public ticks: number, - public totalJSHeapSize: number, - public usedJSHeapSize: number - ) {} - encode(writer: Writer): boolean { - return writer.uint(49) && - writer.int(this.frames) && - writer.int(this.ticks) && - writer.uint(this.totalJSHeapSize) && - writer.uint(this.usedJSHeapSize); - } -} -export const PerformanceTrack = bindNew(_PerformanceTrack); -classes.set(49, PerformanceTrack); - - -class _ResourceTiming implements Message { - readonly _id: number = 53; - constructor( - public timestamp: number, - public duration: number, - public ttfb: number, - public headerSize: number, - public encodedBodySize: number, - public decodedBodySize: number, - public url: string, - public initiator: string - ) {} - encode(writer: Writer): boolean { - return writer.uint(53) && - writer.uint(this.timestamp) && - writer.uint(this.duration) && - writer.uint(this.ttfb) && - writer.uint(this.headerSize) && - writer.uint(this.encodedBodySize) && - writer.uint(this.decodedBodySize) && - writer.string(this.url) && - writer.string(this.initiator); - } -} -export const ResourceTiming = bindNew(_ResourceTiming); -classes.set(53, ResourceTiming); - - -class _ConnectionInformation implements Message { - readonly _id: number = 54; - constructor( - public downlink: number, - public type: string - ) {} - encode(writer: Writer): boolean { - return writer.uint(54) && - writer.uint(this.downlink) && - writer.string(this.type); - } -} -export const ConnectionInformation = bindNew(_ConnectionInformation); -classes.set(54, ConnectionInformation); - - -class _SetPageVisibility implements Message { - readonly _id: number = 55; - constructor( - public hidden: boolean - ) {} - encode(writer: Writer): boolean { - return writer.uint(55) && - writer.boolean(this.hidden); - } -} -export const SetPageVisibility = bindNew(_SetPageVisibility); -classes.set(55, SetPageVisibility); - - -class _LongTask implements Message { - readonly _id: number = 59; - constructor( - public timestamp: number, - public duration: number, - public context: number, - public containerType: number, - public containerSrc: string, - public containerId: string, - public containerName: string - ) {} - encode(writer: Writer): boolean { - return writer.uint(59) && - writer.uint(this.timestamp) && - writer.uint(this.duration) && - writer.uint(this.context) && - writer.uint(this.containerType) && - writer.string(this.containerSrc) && - writer.string(this.containerId) && - writer.string(this.containerName); - } -} -export const LongTask = bindNew(_LongTask); -classes.set(59, LongTask); - - -class _SetNodeAttributeURLBased implements Message { - readonly _id: number = 60; - constructor( - public id: number, - public name: string, - public value: string, - public baseURL: string - ) {} - encode(writer: Writer): boolean { - return writer.uint(60) && - writer.uint(this.id) && - writer.string(this.name) && - writer.string(this.value) && - writer.string(this.baseURL); - } -} -export const SetNodeAttributeURLBased = bindNew(_SetNodeAttributeURLBased); -classes.set(60, SetNodeAttributeURLBased); - - -class _SetCSSDataURLBased implements Message { - readonly _id: number = 61; - constructor( - public id: number, - public data: string, - public baseURL: string - ) {} - encode(writer: Writer): boolean { - return writer.uint(61) && - writer.uint(this.id) && - writer.string(this.data) && - writer.string(this.baseURL); - } -} -export const SetCSSDataURLBased = bindNew(_SetCSSDataURLBased); -classes.set(61, SetCSSDataURLBased); - - -class _TechnicalInfo implements Message { - readonly _id: number = 63; - constructor( - public type: string, - public value: string - ) {} - encode(writer: Writer): boolean { - return writer.uint(63) && - writer.string(this.type) && - writer.string(this.value); - } -} -export const TechnicalInfo = bindNew(_TechnicalInfo); -classes.set(63, TechnicalInfo); - - -class _CustomIssue implements Message { - readonly _id: number = 64; - constructor( - public name: string, - public payload: string - ) {} - encode(writer: Writer): boolean { - return writer.uint(64) && - writer.string(this.name) && - writer.string(this.payload); - } -} -export const CustomIssue = bindNew(_CustomIssue); -classes.set(64, CustomIssue); - - -class _PageClose implements Message { - readonly _id: number = 65; - constructor( - - ) {} - encode(writer: Writer): boolean { - return writer.uint(65) - ; - } -} -export const PageClose = bindNew(_PageClose); -classes.set(65, PageClose); - - -class _CSSInsertRuleURLBased implements Message { - readonly _id: number = 67; - constructor( - public id: number, - public rule: string, - public index: number, - public baseURL: string - ) {} - encode(writer: Writer): boolean { - return writer.uint(67) && - writer.uint(this.id) && - writer.string(this.rule) && - writer.uint(this.index) && - writer.string(this.baseURL); - } -} -export const CSSInsertRuleURLBased = bindNew(_CSSInsertRuleURLBased); -classes.set(67, CSSInsertRuleURLBased); - - -class _MouseClick implements Message { - readonly _id: number = 69; - constructor( - public id: number, - public hesitationTime: number, - public label: string, - public selector: string - ) {} - encode(writer: Writer): boolean { - return writer.uint(69) && - writer.uint(this.id) && - writer.uint(this.hesitationTime) && - writer.string(this.label) && - writer.string(this.selector); - } -} -export const MouseClick = bindNew(_MouseClick); -classes.set(69, MouseClick); - - -class _CreateIFrameDocument implements Message { - readonly _id: number = 70; - constructor( - public frameID: number, - public id: number - ) {} - encode(writer: Writer): boolean { - return writer.uint(70) && - writer.uint(this.frameID) && - writer.uint(this.id); - } -} -export const CreateIFrameDocument = bindNew(_CreateIFrameDocument); -classes.set(70, CreateIFrameDocument); - - diff --git a/tracker/tracker/src/common/types.ts b/tracker/tracker/src/common/types.ts deleted file mode 100644 index 6717eb8bc..000000000 --- a/tracker/tracker/src/common/types.ts +++ /dev/null @@ -1,10 +0,0 @@ -export interface Writer { - uint(n: number): boolean - int(n: number): boolean - string(s: string): boolean - boolean(b: boolean): boolean -} - -export interface Message { - encode(w: Writer): boolean; -} diff --git a/tracker/tracker/src/common/webworker.ts b/tracker/tracker/src/common/webworker.ts deleted file mode 100644 index 3f6de9c35..000000000 --- a/tracker/tracker/src/common/webworker.ts +++ /dev/null @@ -1,19 +0,0 @@ -export interface Options { - connAttemptCount?: number - connAttemptGap?: number -} - -type Start = { - type: "start", - ingestPoint: string - pageNo: number - timestamp: number -} & Options - -type Auth = { - type: "auth" - token: string - beaconSizeLimit?: number -} - -export type WorkerMessageData = null | "stop" | Start | Auth | Array<{ _id: number }> diff --git a/tracker/tracker/src/main/app/guards.ts b/tracker/tracker/src/main/app/guards.ts index c3f36d398..f88abc293 100644 --- a/tracker/tracker/src/main/app/guards.ts +++ b/tracker/tracker/src/main/app/guards.ts @@ -1,5 +1,5 @@ export function isSVGElement(node: Element): node is SVGElement { - return node.namespaceURI === 'http://www.w3.org/2000/svg'; + return node.namespaceURI === 'http://www.w3.org/2000/svg' } export function isElementNode(node: Node): node is Element { @@ -10,11 +10,13 @@ export function isTextNode(node: Node): node is Text { return node.nodeType === Node.TEXT_NODE } -export function isRootNode(node: Node): boolean { - return node.nodeType === Node.DOCUMENT_NODE || - node.nodeType === Node.DOCUMENT_FRAGMENT_NODE +export function isDocument(node: Node): node is Document { + return node.nodeType === Node.DOCUMENT_NODE } +export function isRootNode(node: Node): node is Document | DocumentFragment { + return node.nodeType === Node.DOCUMENT_NODE || node.nodeType === Node.DOCUMENT_FRAGMENT_NODE +} type TagTypeMap = { HTML: HTMLHtmlElement @@ -28,6 +30,9 @@ type TagTypeMap = { style: SVGStyleElement LINK: HTMLLinkElement } -export function hasTag(el: Node, tagName: T): el is TagTypeMap[typeof tagName] { +export function hasTag( + el: Node, + tagName: T, +): el is TagTypeMap[typeof tagName] { return el.nodeName === tagName } diff --git a/tracker/tracker/src/main/app/index.ts b/tracker/tracker/src/main/app/index.ts index 16d472c90..2f9525164 100644 --- a/tracker/tracker/src/main/app/index.ts +++ b/tracker/tracker/src/main/app/index.ts @@ -1,41 +1,43 @@ -import type Message from "../../common/messages.js"; -import { Timestamp, Metadata, UserID } from "../../common/messages.js"; -import { timestamp, deprecationWarn } from "../utils.js"; -import Nodes from "./nodes.js"; -import Observer from "./observer/top_observer.js"; -import Sanitizer from "./sanitizer.js"; -import Ticker from "./ticker.js"; -import Logger, { LogLevel } from "./logger.js"; -import Session from "./session.js"; +import type Message from './messages.gen.js' +import { Timestamp, Metadata, UserID } from './messages.gen.js' +import { timestamp as now, deprecationWarn } from '../utils.js' +import Nodes from './nodes.js' +import Observer from './observer/top_observer.js' +import Sanitizer from './sanitizer.js' +import Ticker from './ticker.js' +import Logger, { LogLevel } from './logger.js' +import Session from './session.js' -import { deviceMemory, jsHeapSizeLimit } from "../modules/performance.js"; +import { deviceMemory, jsHeapSizeLimit } from '../modules/performance.js' -import type { Options as ObserverOptions } from "./observer/top_observer.js"; -import type { Options as SanitizerOptions } from "./sanitizer.js"; -import type { Options as LoggerOptions } from "./logger.js" -import type { Options as WebworkerOptions, WorkerMessageData } from "../../common/webworker.js"; +import type { Options as ObserverOptions } from './observer/top_observer.js' +import type { Options as SanitizerOptions } from './sanitizer.js' +import type { Options as LoggerOptions } from './logger.js' +import type { Options as SessOptions } from './session.js' +import type { Options as WebworkerOptions, WorkerMessageData } from '../../common/interaction.js' // TODO: Unify and clearly describe options logic export interface StartOptions { - userID?: string, - metadata?: Record, - forceNew?: boolean, + userID?: string + metadata?: Record + forceNew?: boolean + sessionHash?: string } interface OnStartInfo { - sessionID: string, - sessionToken: string, - userUUID: string, + sessionID: string + sessionToken: string + userUUID: string } -const CANCELED = "canceled" as const -const START_ERROR = ":(" as const +const CANCELED = 'canceled' as const +const START_ERROR = ':(' as const type SuccessfulStart = OnStartInfo & { success: true } type UnsuccessfulStart = { reason: typeof CANCELED | string success: false } -const UnsuccessfulStart = (reason: string): UnsuccessfulStart => ({ reason, success: false}) -const SuccessfulStart = (body: OnStartInfo): SuccessfulStart => ({ ...body, success: true}) +const UnsuccessfulStart = (reason: string): UnsuccessfulStart => ({ reason, success: false }) +const SuccessfulStart = (body: OnStartInfo): SuccessfulStart => ({ ...body, success: true }) export type StartPromiseReturn = SuccessfulStart | UnsuccessfulStart type StartCallback = (i: OnStartInfo) => void @@ -47,63 +49,58 @@ enum ActivityState { } type AppOptions = { - revID: string; - node_id: string; - session_token_key: string; - session_pageno_key: string; - session_reset_key: string; - local_uuid_key: string; - ingestPoint: string; - resourceBaseHref: string | null, // resourceHref? + revID: string + node_id: string + session_reset_key: string + session_token_key: string + session_pageno_key: string + local_uuid_key: string + ingestPoint: string + resourceBaseHref: string | null // resourceHref? //resourceURLRewriter: (url: string) => string | boolean, - verbose: boolean; - __is_snippet: boolean; - __debug_report_edp: string | null; - __debug__?: LoggerOptions; - localStorage: Storage; - sessionStorage: Storage; + verbose: boolean + __is_snippet: boolean + __debug_report_edp: string | null + __debug__?: LoggerOptions + localStorage: Storage | null + sessionStorage: Storage | null // @deprecated - onStart?: StartCallback; -} & WebworkerOptions; + onStart?: StartCallback +} & WebworkerOptions & + SessOptions export type Options = AppOptions & ObserverOptions & SanitizerOptions - // TODO: use backendHost only -export const DEFAULT_INGEST_POINT = 'https://api.openreplay.com/ingest'; +export const DEFAULT_INGEST_POINT = 'https://api.openreplay.com/ingest' export default class App { - readonly nodes: Nodes; - readonly ticker: Ticker; - readonly projectKey: string; - readonly sanitizer: Sanitizer; - readonly debug: Logger; - readonly notify: Logger; - readonly session: Session; - readonly localStorage: Storage; - readonly sessionStorage: Storage; - private readonly messages: Array = []; - private readonly observer: Observer; - private readonly startCallbacks: Array = []; - private readonly stopCallbacks: Array = []; - private readonly commitCallbacks: Array = []; - private readonly options: AppOptions; - private readonly revID: string; - private activityState: ActivityState = ActivityState.NotActive; - private version = 'TRACKER_VERSION'; // TODO: version compatability check inside each plugin. - private readonly worker?: Worker; - constructor( - projectKey: string, - sessionToken: string | null | undefined, - options: Partial, - ) { - + readonly nodes: Nodes + readonly ticker: Ticker + readonly projectKey: string + readonly sanitizer: Sanitizer + readonly debug: Logger + readonly notify: Logger + readonly session: Session + readonly localStorage: Storage + readonly sessionStorage: Storage + private readonly messages: Array = [] + /* private */ readonly observer: Observer // non-privat for attachContextCallback + private readonly startCallbacks: Array = [] + private readonly stopCallbacks: Array<() => any> = [] + private readonly commitCallbacks: Array = [] + private readonly options: AppOptions + private readonly revID: string + private activityState: ActivityState = ActivityState.NotActive + private readonly version = 'TRACKER_VERSION' // TODO: version compatability check inside each plugin. + private readonly worker?: Worker + constructor(projectKey: string, sessionToken: string | undefined, options: Partial) { // if (options.onStart !== undefined) { // deprecationWarn("'onStart' option", "tracker.start().then(/* handle session info */)") // } ?? maybe onStart is good - this.projectKey = projectKey; + this.projectKey = projectKey this.options = Object.assign( { revID: '', @@ -117,111 +114,113 @@ export default class App { verbose: false, __is_snippet: false, __debug_report_edp: null, - localStorage: window.localStorage, - sessionStorage: window.sessionStorage, + localStorage: window?.localStorage, + sessionStorage: window?.sessionStorage, }, options, - ); + ) - this.revID = this.options.revID; - this.sanitizer = new Sanitizer(this, options); - this.nodes = new Nodes(this.options.node_id); - this.observer = new Observer(this, options); - this.ticker = new Ticker(this); - this.ticker.attach(() => this.commit()); - this.debug = new Logger(this.options.__debug__); - this.notify = new Logger(this.options.verbose ? LogLevel.Warnings : LogLevel.Silent); - this.session = new Session(); + this.revID = this.options.revID + this.sanitizer = new Sanitizer(this, options) + this.nodes = new Nodes(this.options.node_id) + this.observer = new Observer(this, options) + this.ticker = new Ticker(this) + this.ticker.attach(() => this.commit()) + this.debug = new Logger(this.options.__debug__) + this.notify = new Logger(this.options.verbose ? LogLevel.Warnings : LogLevel.Silent) + this.localStorage = this.options.localStorage || window.localStorage + this.sessionStorage = this.options.sessionStorage || window.sessionStorage + this.session = new Session(this, this.options) this.session.attachUpdateCallback(({ userID, metadata }) => { - if (userID != null) { // TODO: nullable userID - this.send(new UserID(userID)) + if (userID != null) { + // TODO: nullable userID + this.send(UserID(userID)) } if (metadata != null) { - Object.entries(metadata).forEach(([key, value]) => this.send(new Metadata(key, value))) + Object.entries(metadata).forEach(([key, value]) => this.send(Metadata(key, value))) } }) - this.localStorage = this.options.localStorage; - this.sessionStorage = this.options.sessionStorage; + // @depricated (use sessionHash on start instead) if (sessionToken != null) { - this.sessionStorage.setItem(this.options.session_token_key, sessionToken); + this.session.applySessionHash(sessionToken) } try { this.worker = new Worker( - URL.createObjectURL( - new Blob([`WEBWORKER_BODY`], { type: 'text/javascript' }), - ), - ); - this.worker.onerror = e => { - this._debug("webworker_error", e) + URL.createObjectURL(new Blob(['WEBWORKER_BODY'], { type: 'text/javascript' })), + ) + this.worker.onerror = (e) => { + this._debug('webworker_error', e) } this.worker.onmessage = ({ data }: MessageEvent) => { - if (data === "failed") { - this.stop(); - this._debug("worker_failed", {}) // add context (from worker) - } else if (data === "restart") { - this.stop(); - this.start({ forceNew: true }); + if (data === 'failed') { + this.stop() + this._debug('worker_failed', {}) // add context (from worker) + } else if (data === 'restart') { + this.stop() + this.start({ forceNew: true }) } } const alertWorker = () => { if (this.worker) { - this.worker.postMessage(null); + this.worker.postMessage(null) } } // keep better tactics, discard others? - this.attachEventListener(window, 'beforeunload', alertWorker, false); - this.attachEventListener(document.body, 'mouseleave', alertWorker, false, false); + this.attachEventListener(window, 'beforeunload', alertWorker, false) + this.attachEventListener(document.body, 'mouseleave', alertWorker, false, false) // TODO: stop session after inactivity timeout (make configurable) - this.attachEventListener(document, 'visibilitychange', alertWorker, false); - } catch (e) { - this._debug("worker_start", e); + this.attachEventListener(document, 'visibilitychange', alertWorker, false) + } catch (e) { + this._debug('worker_start', e) } } private _debug(context: string, e: any) { - if(this.options.__debug_report_edp !== null) { + if (this.options.__debug_report_edp !== null) { fetch(this.options.__debug_report_edp, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ context, - error: `${e}` - }) - }); + error: `${e}`, + }), + }) } - this.debug.error("OpenReplay error: ", context, e) + this.debug.error('OpenReplay error: ', context, e) } send(message: Message, urgent = false): void { - if (this.activityState === ActivityState.NotActive) { return } - this.messages.push(message); - // TODO: commit on start if there were `urgent` sends; + if (this.activityState === ActivityState.NotActive) { + return + } + this.messages.push(message) + // TODO: commit on start if there were `urgent` sends; // Clearify where urgent can be used for; - // Clearify workflow for each type of message in case it was sent before start + // Clearify workflow for each type of message in case it was sent before start // (like Fetch before start; maybe add an option "preCapture: boolean" or sth alike) if (this.activityState === ActivityState.Active && urgent) { - this.commit(); + this.commit() } } private commit(): void { 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; + this.messages.unshift(Timestamp(now())) + this.worker.postMessage(this.messages) + this.commitCallbacks.forEach((cb) => cb(this.messages)) + this.messages.length = 0 } } safe void>(fn: T): T { - const app = this; + const app = this return function (this: any, ...args: any) { try { - fn.apply(this, args); + fn.apply(this, args) } catch (e) { - app._debug("safe_fn_call", e) - // time: timestamp(), + app._debug('safe_fn_call', e) + // time: now(), // name: e.name, // message: e.message, // stack: e.stack @@ -230,13 +229,21 @@ export default class App { } attachCommitCallback(cb: CommitCallback): void { + // TODO!: what if start callback added when activityState === Active ? + // For example - attachEventListener() called during dynamic