diff --git a/LICENSE b/LICENSE index b348b0c8f..a4bbf3abf 100644 --- a/LICENSE +++ b/LICENSE @@ -1,12 +1,21 @@ Copyright (c) 2022 Asayer, Inc. OpenReplay monorepo uses multiple licenses. Portions of this software are licensed as follows: - - All content that resides under the "ee/" directory of this repository, is licensed under the license defined in "ee/LICENSE". -- Content outside of the above mentioned directories or restrictions above is available under the "Elastic License 2.0 (ELv2)" license as defined below. +- Some directories have a specific LICENSE file and are licensed under the "MIT" license, as defined below. +- Content outside of the above mentioned directories or restrictions defaults to the "Elastic License 2.0 (ELv2)" license, as defined below. Reach out (license@openreplay.com) if you have any questions regarding licenses. +------------------------------------------------------------------------------------ +MIT 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: + +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. + ------------------------------------------------------------------------------------ Elastic License 2.0 (ELv2) diff --git a/README.md b/README.md index 0f9f35669..31252da7d 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ Check out our [roadmap](https://www.notion.so/openreplay/Roadmap-889d2c3d968b478 ## License -This repo is under the Elastic License 2.0 (ELv2), with the exception of the `ee` directory. +This monorepo uses several licenses. See [LICENSE](/LICENSE) for more details. ## Contributors diff --git a/api/.gitignore b/api/.gitignore index 6a46fedcb..68797b56a 100644 --- a/api/.gitignore +++ b/api/.gitignore @@ -174,4 +174,5 @@ logs*.txt SUBNETS.json ./chalicelib/.configs -README/* \ No newline at end of file +README/* +.local \ No newline at end of file diff --git a/api/Dockerfile b/api/Dockerfile index 20dfe9b86..5700f9c4d 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -19,7 +19,7 @@ RUN cd /work_tmp && npm install WORKDIR /work COPY . . -RUN mv env.default .env && mv /work_tmp/node_modules sourcemap-reader/. +RUN mv env.default .env && mv /work_tmp/node_modules sourcemap-reader/. && chmod 644 /mappings.wasm RUN adduser -u 1001 openreplay -D USER 1001 diff --git a/api/Dockerfile.alerts b/api/Dockerfile.alerts index dbb0c581d..851ed2dc5 100644 --- a/api/Dockerfile.alerts +++ b/api/Dockerfile.alerts @@ -4,7 +4,7 @@ LABEL Maintainer="KRAIEM Taha Yassine" RUN apk add --no-cache build-base tini ARG envarg ENV APP_NAME=alerts \ - pg_minconn=2 \ + pg_minconn=1 \ pg_maxconn=10 \ ENTERPRISE_BUILD=${envarg} 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/chalicelib/core/alerts.py b/api/chalicelib/core/alerts.py index e5316ba06..f851751ba 100644 --- a/api/chalicelib/core/alerts.py +++ b/api/chalicelib/core/alerts.py @@ -138,7 +138,10 @@ def send_by_email(notification, destination): def send_by_email_batch(notifications_list): + if not helper.has_smtp(): + logging.info("no SMTP configuration for email notifications") if notifications_list is None or len(notifications_list) == 0: + logging.info("no email notifications") return for n in notifications_list: send_by_email(notification=n.get("notification"), destination=n.get("destination")) diff --git a/api/chalicelib/core/assist.py b/api/chalicelib/core/assist.py index 6fc8bcd90..e382fe348 100644 --- a/api/chalicelib/core/assist.py +++ b/api/chalicelib/core/assist.py @@ -1,6 +1,6 @@ import requests from decouple import config - +from os.path import exists import schemas from chalicelib.core import projects @@ -158,3 +158,11 @@ 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): + path_to_file = config("FS_DIR") + "/" + str(session_id) + + if exists(path_to_file): + return path_to_file + return None diff --git a/api/chalicelib/core/projects.py b/api/chalicelib/core/projects.py index 0b0bd963f..0893f6259 100644 --- a/api/chalicelib/core/projects.py +++ b/api/chalicelib/core/projects.py @@ -43,16 +43,24 @@ 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: - cur.execute(f"""\ - SELECT - s.project_id, s.name, s.project_key, s.save_request_payloads - {',s.gdpr' if gdpr else ''} - {',COALESCE((SELECT TRUE FROM public.sessions WHERE sessions.project_id = s.project_id LIMIT 1), FALSE) AS recorded' if recorded else ''} - {',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 ''} - WHERE s.deleted_at IS NULL - ORDER BY s.project_id;""") + recorded_q = "" + 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 ''} + WHERE s.deleted_at IS NULL + ORDER BY s.project_id;""", {"now": TimeUTC.now()}) + cur.execute(query) rows = cur.fetchall() if recording_state: project_ids = [f'({r["project_id"]})' for r in rows] diff --git a/api/chalicelib/core/sessions.py b/api/chalicelib/core/sessions.py index 738a5e3d9..c044a5819 100644 --- a/api/chalicelib/core/sessions.py +++ b/api/chalicelib/core/sessions.py @@ -712,13 +712,13 @@ def search_query_parts(data, error_status, errors_only, favorite_only, issue, pr event.value, value_key=e_k)) elif event_type == events.event_type.ERROR.ui_type: event_from = event_from % f"{events.event_type.ERROR.table} AS main INNER JOIN public.errors AS main1 USING(error_id)" - event.source = tuple(event.source) + event.source = list(set(event.source)) 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)) if event.source[0] not in [None, "*", ""]: - event_where.append(_multiple_conditions(f"main1.source = %({s_k})s", event.value, value_key=s_k)) + event_where.append(_multiple_conditions(f"main1.source = %({s_k})s", event.source, value_key=s_k)) # ----- IOS @@ -877,7 +877,8 @@ def search_query_parts(data, error_status, errors_only, favorite_only, issue, pr apply = True elif f.type == schemas.FetchFilterType._duration: event_where.append( - _multiple_conditions(f"main.duration {f.operator} %({e_k_f})s::integer", f.value, value_key=e_k_f)) + _multiple_conditions(f"main.duration {f.operator} %({e_k_f})s::integer", f.value, + value_key=e_k_f)) apply = True elif f.type == schemas.FetchFilterType._request_body: event_where.append( @@ -885,7 +886,8 @@ def search_query_parts(data, error_status, errors_only, favorite_only, issue, pr apply = True elif f.type == schemas.FetchFilterType._response_body: event_where.append( - _multiple_conditions(f"main.response_body {op} %({e_k_f})s::text", f.value, value_key=e_k_f)) + _multiple_conditions(f"main.response_body {op} %({e_k_f})s::text", f.value, + value_key=e_k_f)) apply = True else: print(f"undefined FETCH filter: {f.type}") diff --git a/api/chalicelib/utils/email_handler.py b/api/chalicelib/utils/email_handler.py index 66b8a3afd..b3c7d9984 100644 --- a/api/chalicelib/utils/email_handler.py +++ b/api/chalicelib/utils/email_handler.py @@ -1,13 +1,15 @@ import base64 +import logging import re from email.header import Header from email.mime.image import MIMEImage from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText -from chalicelib.utils import helper, smtp from decouple import config +from chalicelib.utils import smtp + def __get_subject(subject): return subject @@ -64,11 +66,11 @@ def send_html(BODY_HTML, SUBJECT, recipient, bcc=None): if bcc is not None and len(bcc) > 0: r += [bcc] try: - print(f"Email sending to: {r}") + logging.info(f"Email sending to: {r}") s.sendmail(msg['FROM'], r, msg.as_string().encode('ascii')) except Exception as e: - print("!!! Email error!") - print(e) + logging.error("!!! Email error!") + logging.error(e) def send_text(recipients, text, subject): @@ -82,8 +84,8 @@ def send_text(recipients, text, subject): try: s.sendmail(msg['FROM'], recipients, msg.as_string().encode('ascii')) except Exception as e: - print("!! Text-email failed: " + subject), - print(e) + logging.error("!! Text-email failed: " + subject), + logging.error(e) def __escape_text_html(text): diff --git a/api/chalicelib/utils/email_helper.py b/api/chalicelib/utils/email_helper.py index 72072c924..2c5eb02e2 100644 --- a/api/chalicelib/utils/email_helper.py +++ b/api/chalicelib/utils/email_helper.py @@ -1,5 +1,5 @@ from chalicelib.utils.TimeUTC import TimeUTC -from chalicelib.utils.email_handler import __get_html_from_file, send_html, __escape_text_html +from chalicelib.utils.email_handler import __get_html_from_file, send_html def send_team_invitation(recipient, client_id, sender_name, invitation_link): 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/chalicelib/utils/pg_client.py b/api/chalicelib/utils/pg_client.py index eda7747f8..c4149f49d 100644 --- a/api/chalicelib/utils/pg_client.py +++ b/api/chalicelib/utils/pg_client.py @@ -1,3 +1,4 @@ +import logging import time from threading import Semaphore @@ -6,6 +7,9 @@ import psycopg2.extras from decouple import config from psycopg2 import pool +logging.basicConfig(level=config("LOGLEVEL", default=logging.INFO)) +logging.getLogger('apscheduler').setLevel(config("LOGLEVEL", default=logging.INFO)) + _PG_CONFIG = {"host": config("pg_host"), "database": config("pg_dbname"), "user": config("pg_user"), @@ -44,31 +48,34 @@ RETRY = 0 def make_pool(): + if not config('PG_POOL', cast=bool, default=True): + return global postgreSQL_pool global RETRY if postgreSQL_pool is not None: try: postgreSQL_pool.closeall() except (Exception, psycopg2.DatabaseError) as error: - print("Error while closing all connexions to PostgreSQL", error) + logging.error("Error while closing all connexions to PostgreSQL", error) try: postgreSQL_pool = ORThreadedConnectionPool(config("pg_minconn", cast=int, default=20), config("pg_maxconn", cast=int, default=80), **PG_CONFIG) if (postgreSQL_pool): - print("Connection pool created successfully") + logging.info("Connection pool created successfully") except (Exception, psycopg2.DatabaseError) as error: - print("Error while connecting to PostgreSQL", error) + logging.error("Error while connecting to PostgreSQL", error) if RETRY < RETRY_MAX: RETRY += 1 - print(f"waiting for {RETRY_INTERVAL}s before retry n°{RETRY}") + logging.info(f"waiting for {RETRY_INTERVAL}s before retry n°{RETRY}") time.sleep(RETRY_INTERVAL) make_pool() else: raise error -make_pool() +if config('PG_POOL', cast=bool, default=True): + make_pool() class PostgresClient: @@ -87,8 +94,14 @@ class PostgresClient: elif long_query: long_config = dict(_PG_CONFIG) long_config["application_name"] += "-LONG" - long_config["options"] = f"-c statement_timeout={config('pg_long_timeout', cast=int, default=5 * 60) * 1000}" + long_config["options"] = f"-c statement_timeout=" \ + f"{config('pg_long_timeout', cast=int, default=5 * 60) * 1000}" self.connection = psycopg2.connect(**long_config) + elif not config('PG_POOL', cast=bool, default=True): + single_config = dict(_PG_CONFIG) + single_config["application_name"] += "-NOPOOL" + single_config["options"] = f"-c statement_timeout={config('pg_timeout', cast=int, default=3 * 60) * 1000}" + self.connection = psycopg2.connect(**single_config) else: self.connection = postgreSQL_pool.getconn() @@ -104,14 +117,19 @@ class PostgresClient: if self.long_query or self.unlimited_query: self.connection.close() except Exception as error: - print("Error while committing/closing PG-connection", error) - if str(error) == "connection already closed" and not self.long_query and not self.unlimited_query: - print("Recreating the connexion pool") + logging.error("Error while committing/closing PG-connection", error) + if str(error) == "connection already closed" \ + and not self.long_query \ + and not self.unlimited_query \ + and config('PG_POOL', cast=bool, default=True): + logging.info("Recreating the connexion pool") make_pool() else: raise error finally: - if not self.long_query: + if config('PG_POOL', cast=bool, default=True) \ + and not self.long_query \ + and not self.unlimited_query: postgreSQL_pool.putconn(self.connection) diff --git a/api/chalicelib/utils/smtp.py b/api/chalicelib/utils/smtp.py index 3615ca71a..63e1621fb 100644 --- a/api/chalicelib/utils/smtp.py +++ b/api/chalicelib/utils/smtp.py @@ -1,10 +1,14 @@ +import logging import smtplib +from smtplib import SMTPAuthenticationError + from decouple import config +from starlette.exceptions import HTTPException class EmptySMTP: def sendmail(self, from_addr, to_addrs, msg, mail_options=(), rcpt_options=()): - print("!! CANNOT SEND EMAIL, NO VALID SMTP CONFIGURATION FOUND") + logging.error("!! CANNOT SEND EMAIL, NO VALID SMTP CONFIGURATION FOUND") class SMTPClient: @@ -30,7 +34,11 @@ class SMTPClient: self.server.starttls() # stmplib docs recommend calling ehlo() before & after starttls() self.server.ehlo() - self.server.login(user=config("EMAIL_USER"), password=config("EMAIL_PASSWORD")) + if len(config("EMAIL_USER", default="")) > 0 and len(config("EMAIL_PASSWORD", default="")) > 0: + try: + self.server.login(user=config("EMAIL_USER"), password=config("EMAIL_PASSWORD")) + except SMTPAuthenticationError: + raise HTTPException(401, "SMTP Authentication Error") return self.server def __exit__(self, *args): diff --git a/api/env.default b/api/env.default index aa14fc993..ce67208e6 100644 --- a/api/env.default +++ b/api/env.default @@ -40,6 +40,7 @@ pg_minconn=20 pg_maxconn=50 PG_RETRY_MAX=50 PG_RETRY_INTERVAL=2 +PG_POOL=true put_S3_TTL=20 sentryURL= sessions_bucket=mobs @@ -47,4 +48,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..788c58767 100644 --- a/api/requirements-alerts.txt +++ b/api/requirements-alerts.txt @@ -4,7 +4,7 @@ boto3==1.24.26 pyjwt==2.4.0 psycopg2-binary==2.9.3 elasticsearch==8.3.1 -jira==3.3.0 +jira==3.3.1 diff --git a/api/requirements.txt b/api/requirements.txt index 81198b0f3..788c58767 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -4,7 +4,7 @@ boto3==1.24.26 pyjwt==2.4.0 psycopg2-binary==2.9.3 elasticsearch==8.3.1 -jira==3.3.0 +jira==3.3.1 diff --git a/api/routers/core.py b/api/routers/core.py index f9629d8bb..2bc4a4dd4 100644 --- a/api/routers/core.py +++ b/api/routers/core.py @@ -2,6 +2,7 @@ from typing import Union, Optional from decouple import config from fastapi import Depends, Body, BackgroundTasks, HTTPException +from fastapi.responses import FileResponse from starlette import status import schemas @@ -183,8 +184,8 @@ def session_top_filter_values(projectId: int, context: schemas.CurrentContext = @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) + user_id=context.user_id, + project_id=projectId) return {"data": data} @@ -230,7 +231,7 @@ def delete_sentry(projectId: int, context: schemas.CurrentContext = Depends(OR_c @app.get('/{projectId}/integrations/sentry/events/{eventId}', tags=["integrations"]) -def proxy_sentry(projectId: int, eventId: int, context: schemas.CurrentContext = Depends(OR_context)): +def proxy_sentry(projectId: int, eventId: str, context: schemas.CurrentContext = Depends(OR_context)): return {"data": log_tool_sentry.proxy_get(tenant_id=context.tenant_id, project_id=projectId, event_id=eventId)} @@ -440,29 +441,47 @@ 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)): 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())} @@ -895,6 +914,17 @@ def get_live_session(projectId: int, sessionId: str, background_tasks: Backgroun 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)): @@ -1118,14 +1148,6 @@ def generate_new_user_token(context: schemas.CurrentContext = Depends(OR_context return {"data": users.generate_new_api_key(user_id=context.user_id)} -@app.post('/account', tags=["account"]) -@app.put('/account', tags=["account"]) -def edit_account(data: schemas.EditUserSchema = Body(...), - context: schemas.CurrentContext = Depends(OR_context)): - return users.edit(tenant_id=context.tenant_id, user_id_to_update=context.user_id, changes=data, - editor_id=context.user_id) - - @app.post('/account/password', tags=["account"]) @app.put('/account/password', tags=["account"]) def change_client_password(data: schemas.EditUserPasswordSchema = Body(...), diff --git a/api/routers/core_dynamic.py b/api/routers/core_dynamic.py index 594715bb6..73dad85bb 100644 --- a/api/routers/core_dynamic.py +++ b/api/routers/core_dynamic.py @@ -43,6 +43,14 @@ def get_account(context: schemas.CurrentContext = Depends(OR_context)): } +@app.post('/account', tags=["account"]) +@app.put('/account', tags=["account"]) +def edit_account(data: schemas.EditUserSchema = Body(...), + context: schemas.CurrentContext = Depends(OR_context)): + return users.edit(tenant_id=context.tenant_id, user_id_to_update=context.user_id, changes=data, + editor_id=context.user_id) + + @app.get('/projects/limit', tags=['projects']) def get_projects_limit(context: schemas.CurrentContext = Depends(OR_context)): return {"data": { @@ -87,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 81d2bffab..b87f5e4cd 100644 --- a/api/schemas.py +++ b/api/schemas.py @@ -100,10 +100,12 @@ 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') @@ -560,6 +562,8 @@ class _SessionSearchEventRaw(__MixedSearchFilter): assert len(values["source"]) > 0 and isinstance(values["source"][0], int), \ f"source of type int if 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')}" for c in values["source"]: assert isinstance(c, int), f"source value should be of type int for {values.get('type')}" elif values.get("type") == EventType.error and values.get("source") is None: diff --git a/backend/Dockerfile b/backend/Dockerfile index 4941a47cb..4e0064e9d 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -45,6 +45,7 @@ ENV TZ=UTC \ AWS_REGION_WEB=eu-central-1 \ AWS_REGION_IOS=eu-west-1 \ AWS_REGION_ASSETS=eu-central-1 \ + AWS_SKIP_SSL_VALIDATION=false \ CACHE_ASSETS=true \ ASSETS_SIZE_LIMIT=6291456 \ ASSETS_HEADERS="{ \"Cookie\": \"ABv=3;\" }" \ @@ -56,7 +57,7 @@ ENV TZ=UTC \ 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/cmd/db/main.go b/backend/cmd/db/main.go index 8f2334f21..550aba60a 100644 --- a/backend/cmd/db/main.go +++ b/backend/cmd/db/main.go @@ -63,6 +63,7 @@ func main() { continue } msg := iter.Message().Decode() + log.Printf("process message, type: %d", iter.Type()) // Just save session data into db without additional checks if err := saver.InsertMessage(sessionID, msg); err != nil { diff --git a/backend/cmd/heuristics/main.go b/backend/cmd/heuristics/main.go index bae5b7a40..719e697a0 100644 --- a/backend/cmd/heuristics/main.go +++ b/backend/cmd/heuristics/main.go @@ -59,7 +59,7 @@ func main() { func(sessionID uint64, iter messages.Iterator, meta *types.Meta) { for iter.Next() { statsLogger.Collect(sessionID, meta) - builderMap.HandleMessage(sessionID, iter.Message(), iter.Message().Meta().Index) + builderMap.HandleMessage(sessionID, iter.Message().Decode(), iter.Message().Meta().Index) } }, false, diff --git a/backend/cmd/sink/main.go b/backend/cmd/sink/main.go index 6d52b494c..e5a0afa78 100644 --- a/backend/cmd/sink/main.go +++ b/backend/cmd/sink/main.go @@ -77,7 +77,7 @@ func main() { // Filter message if !IsReplayerType(msg.TypeID()) { - return + continue } // If message timestamp is empty, use at least ts of session start diff --git a/backend/cmd/storage/main.go b/backend/cmd/storage/main.go index 99236495e..b3848c5de 100644 --- a/backend/cmd/storage/main.go +++ b/backend/cmd/storage/main.go @@ -48,6 +48,7 @@ func main() { 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 diff --git a/backend/internal/http/router/handlers-web.go b/backend/internal/http/router/handlers-web.go index 18b3b84a8..2c21ca016 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" @@ -134,7 +135,7 @@ func (e *Router) startSessionHandlerWeb(w http.ResponseWriter, r *http.Request) UserUUID: userUUID, SessionID: strconv.FormatUint(tokenData.ID, 10), BeaconSizeLimit: e.cfg.BeaconSizeLimit, - Timestamp: e.services.Flaker.ExtractTimestamp(tokenData.ID), + StartTimestamp: int64(flakeid.ExtractTimestamp(tokenData.ID)), }) } diff --git a/backend/internal/http/router/model.go b/backend/internal/http/router/model.go index b39c49688..bd9084e7b 100644 --- a/backend/internal/http/router/model.go +++ b/backend/internal/http/router/model.go @@ -16,6 +16,7 @@ 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"` diff --git a/backend/pkg/env/aws.go b/backend/pkg/env/aws.go index cb7445797..e25a3a561 100644 --- a/backend/pkg/env/aws.go +++ b/backend/pkg/env/aws.go @@ -1,7 +1,9 @@ package env import ( + "crypto/tls" "log" + "net/http" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/credentials" @@ -20,6 +22,15 @@ func AWSSessionOnRegion(region string) *_session.Session { config.Endpoint = aws.String(AWS_ENDPOINT) config.DisableSSL = aws.Bool(true) config.S3ForcePathStyle = aws.Bool(true) + + AWS_SKIP_SSL_VALIDATION := Bool("AWS_SKIP_SSL_VALIDATION") + if AWS_SKIP_SSL_VALIDATION { + tr := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + client := &http.Client{Transport: tr} + config.HTTPClient = client + } } aws_session, err := _session.NewSession(config) if err != nil { diff --git a/backend/pkg/messages/batch.go b/backend/pkg/messages/batch.go index f5d25d8a7..f6f1e23ff 100644 --- a/backend/pkg/messages/batch.go +++ b/backend/pkg/messages/batch.go @@ -32,7 +32,6 @@ func NewIterator(data []byte) Iterator { func (i *iteratorImpl) Next() bool { if i.canSkip { - log.Printf("skip message, type: %d, size: %d", i.msgType, i.msgSize) if _, err := i.data.Seek(int64(i.msgSize), io.SeekCurrent); err != nil { log.Printf("seek err: %s", err) return false @@ -49,7 +48,6 @@ func (i *iteratorImpl) Next() bool { log.Printf("can't read message type: %s", err) return false } - log.Printf("message type: %d", i.msgType) if i.version > 0 && messageHasSize(i.msgType) { // Read message size if it is a new protocol version @@ -58,7 +56,6 @@ func (i *iteratorImpl) Next() bool { log.Printf("can't read message size: %s", err) return false } - log.Println("message size:", i.msgSize) i.msg = &RawMessage{ tp: i.msgType, size: i.msgSize, diff --git a/backend/pkg/messages/raw.go b/backend/pkg/messages/raw.go index f580ec33f..b121de8d9 100644 --- a/backend/pkg/messages/raw.go +++ b/backend/pkg/messages/raw.go @@ -22,10 +22,11 @@ func (m *RawMessage) Encode() []byte { if m.encoded { return m.data } - m.data = make([]byte, m.size) + m.data = make([]byte, m.size+1) + m.data[0] = uint8(m.tp) m.encoded = true *m.skipped = false - n, err := io.ReadFull(m.reader, m.data) + n, err := io.ReadFull(m.reader, m.data[1:]) if err != nil { log.Printf("message encode err: %s", err) return nil @@ -51,10 +52,11 @@ func (m *RawMessage) Decode() Message { if !m.encoded { m.Encode() } - msg, err := ReadMessage(m.tp, bytes.NewReader(m.data)) + 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 } diff --git a/backend/pkg/messages/read-message.go b/backend/pkg/messages/read-message.go index 2b12601d9..067d38c6b 100644 --- a/backend/pkg/messages/read-message.go +++ b/backend/pkg/messages/read-message.go @@ -6,1708 +6,1805 @@ import ( "io" ) + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 { diff --git a/ee/api/Dockerfile b/ee/api/Dockerfile index 2e04fa330..577606447 100644 --- a/ee/api/Dockerfile +++ b/ee/api/Dockerfile @@ -17,7 +17,7 @@ RUN cd /work_tmp && npm install WORKDIR /work COPY . . -RUN mv env.default .env && mv /work_tmp/node_modules sourcemap-reader/. +RUN mv env.default .env && mv /work_tmp/node_modules sourcemap-reader/. && chmod 644 /mappings.wasm RUN adduser -u 1001 openreplay -D USER 1001 diff --git a/ee/api/Dockerfile.alerts b/ee/api/Dockerfile.alerts index 351fce661..09315754d 100644 --- a/ee/api/Dockerfile.alerts +++ b/ee/api/Dockerfile.alerts @@ -4,7 +4,7 @@ LABEL Maintainer="KRAIEM Taha Yassine" RUN apk add --no-cache build-base tini ARG envarg ENV APP_NAME=alerts \ - pg_minconn=2 \ + pg_minconn=1 \ pg_maxconn=10 \ ENTERPRISE_BUILD=${envarg} diff --git a/ee/api/Dockerfile.crons b/ee/api/Dockerfile.crons index 96b9e6453..83b3085e0 100644 --- a/ee/api/Dockerfile.crons +++ b/ee/api/Dockerfile.crons @@ -7,7 +7,8 @@ ENV APP_NAME=crons \ pg_minconn=2 \ pg_maxconn=10 \ ENTERPRISE_BUILD=${envarg} \ - ACTION="" + ACTION="" \ + PG_POOL=false WORKDIR /work_tmp COPY requirements-crons.txt /work_tmp/requirements.txt 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/chalicelib/core/projects.py b/ee/api/chalicelib/core/projects.py index e6ef34760..6700173b5 100644 --- a/ee/api/chalicelib/core/projects.py +++ b/ee/api/chalicelib/core/projects.py @@ -52,30 +52,28 @@ 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)""" - pre_select = "" + recorded_q = "" if recorded: - pre_select = """WITH recorded_p AS (SELECT DISTINCT projects.project_id - FROM projects INNER JOIN sessions USING (project_id) - WHERE tenant_id =%(tenant_id)s - AND deleted_at IS NULL - AND duration > 0)""" - cur.execute( - cur.mogrify(f"""\ - {pre_select} + 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 ''} - {',EXISTS(SELECT 1 FROM recorded_p WHERE recorded_p.project_id = s.project_id) AS recorded' if recorded else ''} + {recorded_q} {',stack_integrations.count>0 AS stack_integrations' if stack_integrations else ''} FROM public.projects AS s - {'LEFT JOIN recorded_p USING (project_id)' if recorded else ''} {'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;""", - {"tenant_id": tenant_id, "user_id": user_id}) - ) + {"tenant_id": tenant_id, "user_id": user_id, "now": TimeUTC.now()}) + cur.execute(query) rows = cur.fetchall() if recording_state: project_ids = [f'({r["project_id"]})' for r in rows] diff --git a/ee/api/env.default b/ee/api/env.default index 7687566d7..41d9b6b45 100644 --- a/ee/api/env.default +++ b/ee/api/env.default @@ -49,6 +49,7 @@ pg_minconn=20 pg_maxconn=50 PG_RETRY_MAX=50 PG_RETRY_INTERVAL=2 +PG_POOL=true put_S3_TTL=20 sentryURL= sessions_bucket=mobs @@ -57,3 +58,4 @@ sourcemaps_bucket=sourcemaps sourcemaps_reader=http://127.0.0.1:9000/sourcemaps stage=default-ee version_number=1.0.0 +FS_DIR=/mnt/efs \ No newline at end of file diff --git a/ee/api/requirements-alerts.txt b/ee/api/requirements-alerts.txt index 66fa84713..906189999 100644 --- a/ee/api/requirements-alerts.txt +++ b/ee/api/requirements-alerts.txt @@ -4,7 +4,7 @@ boto3==1.24.26 pyjwt==2.4.0 psycopg2-binary==2.9.3 elasticsearch==8.3.1 -jira==3.3.0 +jira==3.3.1 diff --git a/ee/api/requirements-crons.txt b/ee/api/requirements-crons.txt index 66fa84713..906189999 100644 --- a/ee/api/requirements-crons.txt +++ b/ee/api/requirements-crons.txt @@ -4,7 +4,7 @@ boto3==1.24.26 pyjwt==2.4.0 psycopg2-binary==2.9.3 elasticsearch==8.3.1 -jira==3.3.0 +jira==3.3.1 diff --git a/ee/api/requirements.txt b/ee/api/requirements.txt index 5ce044904..0a8ca819e 100644 --- a/ee/api/requirements.txt +++ b/ee/api/requirements.txt @@ -4,7 +4,7 @@ boto3==1.24.26 pyjwt==2.4.0 psycopg2-binary==2.9.3 elasticsearch==8.3.1 -jira==3.3.0 +jira==3.3.1 diff --git a/ee/api/routers/core_dynamic.py b/ee/api/routers/core_dynamic.py index 3c5c21905..e6675c4f3 100644 --- a/ee/api/routers/core_dynamic.py +++ b/ee/api/routers/core_dynamic.py @@ -46,6 +46,14 @@ def get_account(context: schemas.CurrentContext = Depends(OR_context)): } +@app.post('/account', tags=["account"]) +@app.put('/account', tags=["account"]) +def edit_account(data: schemas_ee.EditUserSchema = Body(...), + context: schemas.CurrentContext = Depends(OR_context)): + return users.edit(tenant_id=context.tenant_id, user_id_to_update=context.user_id, changes=data, + editor_id=context.user_id) + + @app.get('/projects/limit', tags=['projects']) def get_projects_limit(context: schemas.CurrentContext = Depends(OR_context)): return {"data": { @@ -90,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/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..dfe2b5848 100644 --- a/ee/utilities/servers/websocket-cluster.js +++ b/ee/utilities/servers/websocket-cluster.js @@ -9,7 +9,10 @@ const { uniqueAutocomplete } = require('../utils/helper'); const { - extractSessionInfo + IDENTITIES, + EVENTS_DEFINITION, + extractSessionInfo, + socketConnexionTimeout } = require('../utils/assistHelper'); const { extractProjectKeyFromRequest, @@ -19,15 +22,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 +283,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 +315,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 +330,29 @@ 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.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 +361,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 +370,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 +388,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..b9b817f06 100644 --- a/ee/utilities/servers/websocket.js +++ b/ee/utilities/servers/websocket.js @@ -9,7 +9,10 @@ const { uniqueAutocomplete } = require('../utils/helper'); const { - extractSessionInfo + IDENTITIES, + EVENTS_DEFINITION, + extractSessionInfo, + socketConnexionTimeout } = require('../utils/assistHelper'); const { extractProjectKeyFromRequest, @@ -17,15 +20,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 +261,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 +291,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 +306,29 @@ 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.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 +337,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 +346,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 +362,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/.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/app/Router.js b/frontend/app/Router.js index acbdd9cbb..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')); @@ -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/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/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..f5e4ee236 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 ? ( + + ) : ( + - ) : ( - { 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..89962de64 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..19c49e925 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..83319959a 100644 --- a/frontend/app/components/Client/Integrations/AssistDoc/AssistDoc.js +++ b/frontend/app/components/Client/Integrations/AssistDoc/AssistDoc.js @@ -1,59 +1,56 @@ 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'; -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; diff --git a/frontend/app/components/Client/Integrations/AxiosDoc/AxiosDoc.js b/frontend/app/components/Client/Integrations/AxiosDoc/AxiosDoc.js index 8fe32cfd0..9b624bbe4 100644 --- a/frontend/app/components/Client/Integrations/AxiosDoc/AxiosDoc.js +++ b/frontend/app/components/Client/Integrations/AxiosDoc/AxiosDoc.js @@ -1,40 +1,46 @@ 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'; 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 tracker 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 +53,16 @@ function MyApp() { }, []) //... }`} - - } - /> + + } + /> - -
- ) + +
+
+ ); }; -AxiosDoc.displayName = "AxiosDoc"; +AxiosDoc.displayName = 'AxiosDoc'; export default 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..b4b8b537d 100644 --- a/frontend/app/components/Client/Integrations/FetchDoc/FetchDoc.js +++ b/frontend/app/components/Client/Integrations/FetchDoc/FetchDoc.js @@ -1,29 +1,32 @@ 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'; 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 tracker from '@openreplay/tracker'; import trackerFetch from '@openreplay/tracker-fetch'; //... const tracker = new OpenReplay({ @@ -34,11 +37,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 +57,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; 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..36e883f25 100644 --- a/frontend/app/components/Client/Integrations/GraphQLDoc/GraphQLDoc.js +++ b/frontend/app/components/Client/Integrations/GraphQLDoc/GraphQLDoc.js @@ -1,30 +1,36 @@ 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'; 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 +39,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 +57,16 @@ function SomeFunctionalComponent() { } //... export const recordGraphQL = tracker.use(trackerGraphQL());`} - - } - /> + + } + /> - -
- ) + +
+
+ ); }; -GraphQLDoc.displayName = "GraphQLDoc"; +GraphQLDoc.displayName = 'GraphQLDoc'; export default 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..33d0520d4 --- /dev/null +++ b/frontend/app/components/Client/Integrations/Integrations.tsx @@ -0,0 +1,173 @@ +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'; + +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 } +)(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: false, + 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..bbe36d45b 100644 --- a/frontend/app/components/Client/Integrations/MobxDoc/MobxDoc.js +++ b/frontend/app/components/Client/Integrations/MobxDoc/MobxDoc.js @@ -1,29 +1,35 @@ 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'; 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 +37,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 +54,16 @@ function SomeFunctionalComponent() { tracker.start(); }, []) }`} - - } - /> + + } + /> - -
- ) + +
+
+ ); }; -MobxDoc.displayName = "MobxDoc"; +MobxDoc.displayName = 'MobxDoc'; export default 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..956e4f57e 100644 --- a/frontend/app/components/Client/Integrations/NgRxDoc/NgRxDoc.js +++ b/frontend/app/components/Client/Integrations/NgRxDoc/NgRxDoc.js @@ -1,29 +1,32 @@ 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'; 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 +42,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 +67,16 @@ const metaReducers = [tracker.use(trackerNgRx())]; // check list of ava }) export class AppModule {} }`} - - } - /> + + } + /> - -
- ) + +
+
+ ); }; -NgRxDoc.displayName = "NgRxDoc"; +NgRxDoc.displayName = 'NgRxDoc'; export default NgRxDoc; diff --git a/frontend/app/components/Client/Integrations/ProfilerDoc/ProfilerDoc.js b/frontend/app/components/Client/Integrations/ProfilerDoc/ProfilerDoc.js index 9cada092b..f5ffab724 100644 --- a/frontend/app/components/Client/Integrations/ProfilerDoc/ProfilerDoc.js +++ b/frontend/app/components/Client/Integrations/ProfilerDoc/ProfilerDoc.js @@ -1,29 +1,32 @@ 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'; 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 +39,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 +61,16 @@ const fn = profiler('call_name')(() => { //... }, thisArg); // thisArg is optional }`} - - } - /> + + } + /> - -
- ) + +
+
+ ); }; -ProfilerDoc.displayName = "ProfilerDoc"; +ProfilerDoc.displayName = 'ProfilerDoc'; export default ProfilerDoc; diff --git a/frontend/app/components/Client/Integrations/ReduxDoc/ReduxDoc.js b/frontend/app/components/Client/Integrations/ReduxDoc/ReduxDoc.js index 8e3b12432..e16eecbba 100644 --- a/frontend/app/components/Client/Integrations/ReduxDoc/ReduxDoc.js +++ b/frontend/app/components/Client/Integrations/ReduxDoc/ReduxDoc.js @@ -1,28 +1,31 @@ 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'; 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 +38,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 +60,16 @@ const store = createStore( applyMiddleware(tracker.use(trackerRedux())) // check list of available options below ); }`} - - } - /> + + } + /> - -
- ) + +
+
+ ); }; -ReduxDoc.displayName = "ReduxDoc"; +ReduxDoc.displayName = 'ReduxDoc'; export default 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..7d0cdc610 --- /dev/null +++ b/frontend/app/components/Client/Integrations/SlackForm.tsx @@ -0,0 +1,58 @@ +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 { istance } = props; + const { hideModal } = useModal(); + 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..cece7c01e 100644 --- a/frontend/app/components/Client/Integrations/VueDoc/VueDoc.js +++ b/frontend/app/components/Client/Integrations/VueDoc/VueDoc.js @@ -1,29 +1,34 @@ 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'; 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 +41,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 +63,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; 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..d01b12456 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..7e4ec5fb2 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.' }
-
-
-
+
+
+

{'Organization API Key'}

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

{ 'Tenant Key' }

-
{ 'For SSO (SAML) authentication.' }
-
-
-
- - )} + {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.' }
-
-
-
- - )} + {!isEnterprise && ( + <> +
+
+
+

{'Data Collection'}

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

{ 'License' }

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

{'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..f788fe6e2 100644 --- a/frontend/app/components/Client/ProfileSettings/profileSettings.module.css +++ b/frontend/app/components/Client/ProfileSettings/profileSettings.module.css @@ -2,6 +2,7 @@ .left { padding: 40px; width: 320px; + font-weight: 300; & .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..7c978086b 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..391e7ab93 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/Sites.js b/frontend/app/components/Client/Sites/Sites.js index 1c96c0b3c..86bbddd66 100644 --- a/frontend/app/components/Client/Sites/Sites.js +++ b/frontend/app/components/Client/Sites/Sites.js @@ -1,18 +1,19 @@ 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'; const STATUS_MESSAGE_MAP = { [RED]: ' There seems to be an issue (please verify your installation)', @@ -20,11 +21,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 +40,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 +59,74 @@ 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 })} />
- +
+ + +
No matching results.
+
+ } + size="small" + show={!loading && filteredSites.size === 0} + >
-
Name
+
Project Name
Key
{filteredSites.map((_site) => (
- -
- + +
+
+
+ {getInitials(_site.name)} +
{_site.host}
- {_site.projectKey} +
- +
- + this.props.init(_site)} />
))} +
@@ -207,3 +135,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/UserForm/UserForm.tsx b/frontend/app/components/Client/Users/components/UserForm/UserForm.tsx index 477e42d8f..d1799cc6a 100644 --- a/frontend/app/components/Client/Users/components/UserForm/UserForm.tsx +++ b/frontend/app/components/Client/Users/components/UserForm/UserForm.tsx @@ -102,7 +102,7 @@ function UserForm(props: Props) { { 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..42f73d799 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/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 f50859051..e405ba422 100644 --- a/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/ErrorsByOrigin/ErrorsByOrigin.tsx +++ b/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/ErrorsByOrigin/ErrorsByOrigin.tsx @@ -4,19 +4,22 @@ import { NoContent } from 'UI'; import { Styles } from '../../common'; import { BarChart, Bar, CartesianGrid, Tooltip, - LineChart, Line, Legend, ResponsiveContainer, + Legend, ResponsiveContainer, XAxis, YAxis } from 'recharts'; +import { NO_METRIC_DATA } from 'App/constants/messages' interface Props { data: any metric?: any } function ErrorsByOrigin(props: Props) { - const { data, metric } = props; + const { metric } = props; + return ( @@ -49,4 +52,4 @@ function ErrorsByOrigin(props: Props) { ); } -export default ErrorsByOrigin; \ No newline at end of file +export default ErrorsByOrigin; 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..f30cfd7d9 --- /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" + defaultValue={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..3c9254a7d --- /dev/null +++ b/frontend/app/components/Dashboard/components/Alerts/AlertListItem.tsx @@ -0,0 +1,123 @@ +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>) => { + 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; + + 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-5 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-5 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..18019db9d --- /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 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"> + <Icon name="bell" size={80} color="figmaColors-accent-secondary" /> + <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-5">Title</div> + <div className="col-span-2">Type</div> + <div className="col-span-5 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..44dbfa325 --- /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 or type" + 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..ed5fa1769 --- /dev/null +++ b/frontend/app/components/Dashboard/components/Alerts/AlertsView.tsx @@ -0,0 +1,40 @@ +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> + <Link to={withSiteId(alertCreate(), siteId)}><Button variant="primary" onClick={null}>Create</Button></Link> + <div className="ml-auto w-1/4" style={{ minWidth: 300 }}> + <AlertsSearch /> + </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..ecc00a594 --- /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-3 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..9acd0bc20 --- /dev/null +++ b/frontend/app/components/Dashboard/components/DashboardList/DashboardsView.tsx @@ -0,0 +1,43 @@ +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> + <Button variant="primary" onClick={onAddDashboardClick}>Create Dashboard</Button> + <div className="ml-auto w-1/4" style={{ minWidth: 300 }}> + <DashboardSearch /> + </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..72080aa3e 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="grid grid-cols-12 py-4 border-t select-none hover:bg-active-blue cursor-pointer px-6" onClick={onItemClick}> <div className="col-span-3 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-2"> - <DashboardLink dashboards={metric.dashboards} /> - </div> <div className="col-span-3">{metric.owner}</div> - <div> + <div className="col-span-4"> <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..ecf3d4c2c 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-3">Title</div> + <div className="col-span-3">Owner</div> + <div className="col-span-4">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..64d9ab0cb 100644 --- a/frontend/app/components/Dashboard/components/MetricsView/MetricsView.tsx +++ b/frontend/app/components/Dashboard/components/MetricsView/MetricsView.tsx @@ -6,30 +6,31 @@ 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"> + <div className="ml-auto w-1/4" style={{ minWidth: 300 }}> <MetricsSearch /> </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 31e73fda3..67247a2d2 100644 --- a/frontend/app/components/Dashboard/components/WidgetChart/WidgetChart.tsx +++ b/frontend/app/components/Dashboard/components/WidgetChart/WidgetChart.tsx @@ -79,8 +79,8 @@ function WidgetChart(props: Props) { const debounceRequest: any = React.useCallback(debounce(fetchMetricChartData, 500), []); useEffect(() => { if (prevMetricRef.current && prevMetricRef.current.name !== metric.name) { - prevMetricRef.current = metric; - return + prevMetricRef.current = metric; + return }; prevMetricRef.current = metric; const timestmaps = drillDownPeriod.toTimestamps(); @@ -106,10 +106,11 @@ function WidgetChart(props: Props) { } if (metricType === 'predefined') { + const defaultMetric = metric.data.chart.length === 0 ? metricWithData : metric if (isOverviewWidget) { return <CustomMetricOverviewChart data={data} /> } - return <WidgetPredefinedChart isTemplate={isTemplate} metric={metric} data={data} predefinedKey={metric.predefinedKey} /> + return <WidgetPredefinedChart isTemplate={isTemplate} metric={defaultMetric} data={data} predefinedKey={metric.predefinedKey} /> } if (metricType === 'timeseries') { @@ -179,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..685da85a5 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,15 @@ 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)); } else { history.replace(withSiteId(metricDetails(metric.metricId), siteId)); } @@ -94,11 +101,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' && ( @@ -201,31 +212,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..d1e10d10a 100644 --- a/frontend/app/components/Dashboard/components/WidgetSessions/WidgetSessions.tsx +++ b/frontend/app/components/Dashboard/components/WidgetSessions/WidgetSessions.tsx @@ -1,21 +1,21 @@ -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'; 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 +23,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 +34,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 +49,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 +59,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} @@ -134,13 +102,8 @@ function WidgetSessions(props: Props) { <div className="w-full flex items-center justify-center py-6"> <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 +118,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..3180519ac 100644 --- a/frontend/app/components/Dashboard/components/WidgetView/WidgetView.tsx +++ b/frontend/app/components/Dashboard/components/WidgetView/WidgetView.tsx @@ -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/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..4b3745ba7 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>{'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> + </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 398e27f2f..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: 999 }}> - <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 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> ); } -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/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/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 2d9bfa882..ec8b42196 100644 --- a/frontend/app/components/Session/Session.js +++ b/frontend/app/components/Session/Session.js @@ -9,19 +9,21 @@ import { sessions as sessionsRoute } from 'App/routes'; import withPermissions from 'HOCs/withPermissions' import WebPlayer from './WebPlayer'; import IOSPlayer from './IOSPlayer'; +import { useStore } from 'App/mstore'; const SESSIONS_ROUTE = sessionsRoute(); function Session({ sessionId, loading, - hasErrors, + hasErrors, session, fetchSession, - fetchSlackList, + fetchSlackList, }) { usePageTitle("OpenReplay Session Player"); const [ initializing, setInitializing ] = useState(true) + const { sessionStore } = useStore(); useEffect(() => { if (sessionId != null) { fetchSession(sessionId) @@ -31,6 +33,10 @@ function Session({ setInitializing(false) },[ sessionId ]); + useEffect(() => { + sessionStore.resetUserFilter(); + } ,[]) + return ( <NoContent show={ hasErrors } @@ -63,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..cef4d4bae 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', }; 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..1491eef09 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,113 @@ 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 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 9045491ac..a7faa7625 100644 --- a/frontend/app/components/Session_/EventsBlock/UserCard/UserCard.js +++ b/frontend/app/components/Session_/EventsBlock/UserCard/UserCard.js @@ -1,126 +1,138 @@ -import React, { useState } from 'react' -import { connect } from 'react-redux' -import { List } from 'immutable' +import React, { useState } from 'react'; +import { connect } from 'react-redux'; +import { List } from 'immutable'; 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 cn from 'classnames' -import { withRequest } from 'HOCs' -import SessionInfoItem from '../../SessionInfoItem' +import { Avatar, TextEllipsis, SlideModal, Popup, 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 { Tooltip } from 'react-tippy'; +import { useModal } from 'App/components/Modal'; +import UserSessionsModal from 'Shared/UserSessionsModal'; -function UserCard({ - className, - request, - session, - width, - height, - similarSessions, - loading, - }) { - const { settingsStore } = useStore(); - const { timezone } = settingsStore.sessionSettings; +function UserCard({ className, request, session, width, height, similarSessions, loading }) { + const { settingsStore } = useStore(); + const { timezone } = settingsStore.sessionSettings; - const [showUserSessions, setShowUserSessions] = useState(false) - const { - userBrowser, - userDevice, - userCountry, - userBrowserVersion, - userOs, - userOsVersion, - startedAt, - userId, - userAnonymousId, - userNumericHash, - userDisplayName, - userDeviceType, - revId, - } = session; + const [showUserSessions, setShowUserSessions] = useState(false); + const { + userBrowser, + userDevice, + userCountry, + userBrowserVersion, + userOs, + userOsVersion, + startedAt, + userId, + userAnonymousId, + userNumericHash, + userDisplayName, + userDeviceType, + revId, + } = session; - const hasUserDetails = !!userId || !!userAnonymousId; - const showSimilarSessions = () => { - setShowUserSessions(true); - request({ key: !userId ? 'USERANONYMOUSID' : 'USERID', value: userId || userAnonymousId }); - } + const hasUserDetails = !!userId || !!userAnonymousId; + const showSimilarSessions = () => { + setShowUserSessions(true); + request({ key: !userId ? 'USERANONYMOUSID' : 'USERID', value: userId || userAnonymousId }); + }; - const getDimension = (width, height) => { - return width && height ? ( - <div className="flex items-center"> - { width || 'x' } <Icon name="close" size="12" className="mx-1" /> { height || 'x' } - </div> - ) : <span className="">Resolution N/A</span>; - } + const getDimension = (width, height) => { + return width && height ? ( + <div className="flex items-center"> + {width || 'x'} <Icon name="close" size="12" className="mx-1" /> {height || 'x'} + </div> + ) : ( + <span className="">Resolution N/A</span> + ); + }; - const avatarbgSize = '38px' - return ( - <div className={cn("bg-white flex items-center w-full", className)}> - <div className="flex items-center"> - <Avatar iconSize="23" width={avatarbgSize} height={avatarbgSize} seed={ userNumericHash } /> - <div className="ml-3 overflow-hidden leading-tight"> - <TextEllipsis - noHint - className={ cn("font-medium", { 'color-teal cursor-pointer' : hasUserDetails })} - onClick={hasUserDetails ? showSimilarSessions : undefined} - > - { userDisplayName } - </TextEllipsis> + const avatarbgSize = '38px'; + return ( + <div className={cn('bg-white flex items-center w-full', className)}> + <div className="flex items-center"> + <Avatar iconSize="23" width={avatarbgSize} height={avatarbgSize} seed={userNumericHash} /> + <div className="ml-3 overflow-hidden leading-tight"> + <TextEllipsis + noHint + className={cn('font-medium', { 'color-teal cursor-pointer': hasUserDetails })} + // onClick={hasUserDetails ? showSimilarSessions : undefined} + > + <UserName name={userDisplayName} userId={userId} hash={userNumericHash} /> + </TextEllipsis> - <div className="text-sm color-gray-medium flex items-center"> - <span style={{ whiteSpace: 'nowrap' }}>{formatTimeOrDate(startedAt, timezone)}</span> - <span className="mx-1 font-bold text-xl">·</span> - <span>{countries[userCountry]}</span> - <span className="mx-1 font-bold text-xl">·</span> - <span className='capitalize'>{userBrowser}, {userOs}, {userDevice}</span> - <span className="mx-1 font-bold text-xl">·</span> - <Tooltip - theme='light' - delay={0} - hideOnClick="persistent" - arrow - interactive - html={( - <div className='text-left'> - <SessionInfoItem comp={<CountryFlag country={ userCountry } />} label={countries[userCountry]} value={<span style={{ whiteSpace: 'nowrap' }}>{formatTimeOrDate(startedAt)}</span> } /> - <SessionInfoItem icon={browserIcon(userBrowser)} label={userBrowser} value={ `v${ userBrowserVersion }` } /> - <SessionInfoItem icon={osIcon(userOs)} label={userOs} value={ userOsVersion } /> - <SessionInfoItem icon={deviceTypeIcon(userDeviceType)} label={userDeviceType} value={ getDimension(width, height) } isLast={!revId} /> - {revId && <SessionInfoItem icon="info" label="Rev ID:" value={revId} isLast />} - </div> - )} - position="bottom center" - hoverable - disabled={false} - on="hover" - > - <span - className="color-teal cursor-pointer" - > - More - </span> - </Tooltip> - </div> - </div> - </div> + <div className="text-sm color-gray-medium flex items-center"> + <span style={{ whiteSpace: 'nowrap' }}>{formatTimeOrDate(startedAt, timezone)}</span> + <span className="mx-1 font-bold text-xl">·</span> + <span>{countries[userCountry]}</span> + <span className="mx-1 font-bold text-xl">·</span> + <span className="capitalize"> + {userBrowser}, {userOs}, {userDevice} + </span> + <span className="mx-1 font-bold text-xl">·</span> + <Tooltip + theme="light" + delay={0} + hideOnClick="persistent" + arrow + interactive + html={ + <div className="text-left"> + <SessionInfoItem + comp={<CountryFlag country={userCountry} />} + label={countries[userCountry]} + value={<span style={{ whiteSpace: 'nowrap' }}>{formatTimeOrDate(startedAt)}</span>} + /> + <SessionInfoItem icon={browserIcon(userBrowser)} label={userBrowser} value={`v${userBrowserVersion}`} /> + <SessionInfoItem icon={osIcon(userOs)} label={userOs} value={userOsVersion} /> + <SessionInfoItem + icon={deviceTypeIcon(userDeviceType)} + label={userDeviceType} + value={getDimension(width, height)} + isLast={!revId} + /> + {revId && <SessionInfoItem icon="info" label="Rev ID:" value={revId} isLast />} + </div> + } + position="bottom center" + hoverable + disabled={false} + on="hover" + > + <span className="color-teal cursor-pointer">More</span> + </Tooltip> + </div> + </div> + </div> - <SlideModal + {/* <SlideModal title={ <div>User Sessions</div> } isDisplayed={ showUserSessions } content={ showUserSessions && <SessionList similarSessions={ similarSessions } loading={ loading } /> } onClose={ () => showUserSessions ? setShowUserSessions(false) : null } - /> - </div> - ) + /> */} + </div> + ); } -const component = React.memo(connect(state => ({ session: state.getIn([ 'sessions', 'current' ]) }))(UserCard)) +const component = React.memo(connect((state) => ({ session: state.getIn(['sessions', 'current']) }))(UserCard)); export default withRequest({ - initialData: List(), - endpoint: '/metadata/session_search', - dataWrapper: data => Object.values(data), - dataName: 'similarSessions', -})(component) + initialData: List(), + endpoint: '/metadata/session_search', + dataWrapper: (data) => Object.values(data), + dataName: 'similarSessions', +})(component); + +// inner component +function UserName({ name, userId, hash }) { + const { showModal } = useModal(); + const onClick = () => { + showModal(<UserSessionsModal userId={userId} hash={hash} name={name} />, { right: true }); + }; + return <div onClick={userId ? onClick : () => {}}>{name}</div>; +} diff --git a/frontend/app/components/Session_/Exceptions/Exceptions.js b/frontend/app/components/Session_/Exceptions/Exceptions.js index e08145ad7..0a30a025c 100644 --- a/frontend/app/components/Session_/Exceptions/Exceptions.js +++ b/frontend/app/components/Session_/Exceptions/Exceptions.js @@ -1,113 +1,154 @@ 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={'ml-8'} + 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. + </> + } + className="mr-8" + /> </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 +156,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 2dff482c9..d8078e89a 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,155 +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; - 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 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: 90, + 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..6220db015 100644 --- a/frontend/app/components/Session_/GraphQL/GraphQL.js +++ b/frontend/app/components/Session_/GraphQL/GraphQL.js @@ -1,91 +1,138 @@ 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" @@ -93,35 +140,38 @@ export default class GraphQL extends React.PureComponent { 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..17cd0f07b 100644 --- a/frontend/app/components/Session_/Issues/IssueForm.js +++ b/frontend/app/components/Session_/Issues/IssueForm.js @@ -18,9 +18,10 @@ const SelectedValue = ({ icon, text }) => { 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 : '' }); } 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..88e136bf8 100644 --- a/frontend/app/components/Session_/Network/Network.js +++ b/frontend/app/components/Session_/Network/Network.js @@ -18,128 +18,141 @@ 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> + <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> + ); } 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..46beb94cb 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,86 @@ 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 +116,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 +127,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 +145,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 +169,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 +188,147 @@ 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 - className="uppercase" - tabs={ tabs } - active={ activeTab } - onClick={ this.onTabClick } - border={ false } - /> - </div> + <Tabs + className="uppercase" + tabs={tabs} + active={activeTab} + onClick={this.onTabClick} + border={false} + /> <Input - // className="input-small" + 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..e8abe30a2 --- /dev/null +++ b/frontend/app/components/Session_/OverviewPanel/OverviewPanel.tsx @@ -0,0 +1,116 @@ +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 ( + dataLoaded && ( + <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 className={cn('border-b last:border-none', { 'bg-white': index % 2 })}> + <EventRow + isGraph={feature === 'PERFORMANCE'} + key={feature} + 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 <div>{props.children}</div>; +}); 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..e250a13af --- /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">No records 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..985191896 --- /dev/null +++ b/frontend/app/components/Session_/OverviewPanel/components/StackEventModal/components/JsonViewer/JsonViewer.js @@ -0,0 +1,15 @@ +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="30" /> + <h4 className="my-5 capitalize"> {title}</h4> + <JSONTree src={data} collapsed={false} /> + </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/Controls.js b/frontend/app/components/Session_/Player/Controls/Controls.js index e92099393..ead10433c 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', 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,82 +318,70 @@ 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} + /> + {/* <Button variant="text" onClick={() => toggleBottomTools(OVERVIEW)}>X-RAY</Button> */} + <div className={cn('h-14 border-r bg-gray-light mx-6')} /> + <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')} /> } + {/* { !live && <div className={cn(styles.divider, 'h-full')} /> } */} {/* ! TEMP DISABLED ! {!live && ( <ControlButton @@ -377,130 +394,141 @@ export default class Controls extends React.Component { containerClassName="mx-2" /> )} */} + {/* <ControlButton + // disabled={ disabled && !inspectorMode } + onClick={ () => toggleBottomTools(OVERVIEW) } + active={ bottomBlock === OVERVIEW && !inspectorMode} + label="OVERVIEW" + noIcon + labelClassName="!text-base font-semibold" + // count={ logCount } + // hasErrors={ logRedCount > 0 } + 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', + 18, + 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..a1d3dd87c --- /dev/null +++ b/frontend/app/components/Session_/Player/Controls/components/PlayerControls.tsx @@ -0,0 +1,196 @@ +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 ml-4"> + {/* @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> + + <button + className={cn( + styles.skipIntervalButton, + { [styles.withCheckIcon]: skip, [styles.active]: skip }, + 'ml-4' + )} + 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/controls.module.css b/frontend/app/components/Session_/Player/Controls/controls.module.css index 0b377a594..ba04b3396 100644 --- a/frontend/app/components/Session_/Player/Controls/controls.module.css +++ b/frontend/app/components/Session_/Player/Controls/controls.module.css @@ -18,9 +18,6 @@ height: 65px; padding-left: 30px; padding-right: 0; - &[data-is-live=true] { - padding: 0; - } } .buttonsLeft { 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..b067a3dd0 100644 --- a/frontend/app/components/Session_/Player/Overlay.tsx +++ b/frontend/app/components/Session_/Player/Overlay.tsx @@ -44,12 +44,6 @@ function Overlay({ togglePlay, closedLive }: 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; @@ -60,7 +54,7 @@ function Overlay({ { showLiveStatusText && <LiveStatusText text={liveStatusText} concetionStatus={closedLive ? ConnectionStatus.Closed : concetionStatus} /> } - { messagesLoading && <Loader/> } + { messagesLoading && <Loader /> } { showPlayIconLayer && <PlayIconLayer playing={playing} togglePlay={togglePlay} /> } @@ -83,4 +77,4 @@ export default connectPlayer(state => ({ concetionStatus: state.peerConnectionStatus, markedTargets: state.markedTargets, activeTargetIndex: state.activeTargetIndex, -}))(Overlay); \ No newline at end of file +}))(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/Player.js b/frontend/app/components/Session_/Player/Player.js index 4b5006338..9a95121ac 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, @@ -104,6 +106,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..f7afb2bdb 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 && ( diff --git a/frontend/app/components/Session_/Profiler/Profiler.js b/frontend/app/components/Session_/Profiler/Profiler.js index 83b13c89c..9f8fdc284 100644 --- a/frontend/app/components/Session_/Profiler/Profiler.js +++ b/frontend/app/components/Session_/Profiler/Profiler.js @@ -42,7 +42,9 @@ 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" diff --git a/frontend/app/components/Session_/StackEvents/StackEvents.js b/frontend/app/components/Session_/StackEvents/StackEvents.js index 8069cb663..f4a9387d6 100644 --- a/frontend/app/components/Session_/StackEvents/StackEvents.js +++ b/frontend/app/components/Session_/StackEvents/StackEvents.js @@ -1,85 +1,176 @@ +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) { + console.log('event', 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/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_/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 4a6f1140e..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,229 +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() { - 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.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" onClick={this.onPrevClick} /> - <Button variant="text-primary" icon="chevron-down" 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/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..6da744291 100644 --- a/frontend/app/components/shared/AnimatedSVG/AnimatedSVG.tsx +++ b/frontend/app/components/shared/AnimatedSVG/AnimatedSVG.tsx @@ -6,6 +6,15 @@ 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'; export enum ICONS { DASHBOARD_ICON = 'dashboard-icn', @@ -14,7 +23,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_SESSIONS_IN_VAULT = 'ca-no-sessions-in-vault', + NO_ISSUES = 'ca-no-issues', + NO_AUDIT_TRAIL = 'ca-no-audit-trail', + NO_ANNOUNCEMENTS = 'ca-no-announcements', } interface Props { @@ -26,28 +45,42 @@ 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} />; 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/FilterItem/FilterItem.tsx b/frontend/app/components/shared/Filters/FilterItem/FilterItem.tsx index 3d82dae7c..4b27e4a93 100644 --- a/frontend/app/components/shared/Filters/FilterItem/FilterItem.tsx +++ b/frontend/app/components/shared/Filters/FilterItem/FilterItem.tsx @@ -29,11 +29,11 @@ function FilterItem(props: Props) { }; const onOperatorChange = (e: any, { name, value }: any) => { - props.onUpdate({ ...filter, operator: value.value }); + props.onUpdate({ ...filter, operator: value }); }; const onSourceOperatorChange = (e: any, { name, value }: any) => { - props.onUpdate({ ...filter, sourceOperator: value.value }); + props.onUpdate({ ...filter, sourceOperator: value }); }; const onUpdateSubFilter = (subFilter: any, subFilterIndex: any) => { @@ -73,7 +73,7 @@ function FilterItem(props: Props) { )} {/* Filter values */} - {!isSubFilter && ( + {!isSubFilter && filter.operatorOptions && ( <> <FilterOperator options={filter.operatorOptions} 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/Filters/FilterOperator/FilterOperator.tsx b/frontend/app/components/shared/Filters/FilterOperator/FilterOperator.tsx index 1446891e0..af977e975 100644 --- a/frontend/app/components/shared/Filters/FilterOperator/FilterOperator.tsx +++ b/frontend/app/components/shared/Filters/FilterOperator/FilterOperator.tsx @@ -79,7 +79,7 @@ function FilterOperator(props: Props) { placeholder="Select" isDisabled={isDisabled} value={value ? options.find((i: any) => i.value === value) : null} - onChange={({ value }: any) => onChange(null, { name: 'operator', value })} + onChange={({ value }: any) => onChange(null, { name: 'operator', value: value.value })} /> </div> ); diff --git a/frontend/app/components/shared/LiveSessionList/LiveSessionList.tsx b/frontend/app/components/shared/LiveSessionList/LiveSessionList.tsx index fae2e40cf..73b4750e0 100644 --- a/frontend/app/components/shared/LiveSessionList/LiveSessionList.tsx +++ b/frontend/app/components/shared/LiveSessionList/LiveSessionList.tsx @@ -1,6 +1,6 @@ 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; @@ -39,16 +41,19 @@ function LiveSessionList(props: Props) { var timeoutId: any; const { filters } = filter; const hasUserFilter = filters.map((i: any) => i.key).includes(KEYS.USERID); - const sortOptions = [{ label: 'Newest', value: 'timestamp' }].concat(metaList - .map((i: any) => ({ - label: capitalize(i), - value: i, - })).toJS()); + const sortOptions = [{ label: 'Newest', value: 'timestamp' }].concat( + metaList + .map((i: any) => ({ + label: capitalize(i), + value: i, + })) + .toJS() + ); useEffect(() => { if (metaListLoading) return; const _filter = { ...filter }; - if (sortOptions[1]) { + if (sortOptions[1] && !filter.sort) { _filter.sort = sortOptions[1].value; } props.applyFilter(_filter); @@ -79,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"> + <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> @@ -136,18 +152,18 @@ function LiveSessionList(props: Props) { </> ))} </div> + + <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> </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/Select/Select.tsx b/frontend/app/components/shared/Select/Select.tsx index cc9e2cbbc..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, fontWeight: 'bold' }; + 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 fa886ab8b..67e6b772b 100644 --- a/frontend/app/components/shared/SessionItem/SessionItem.tsx +++ b/frontend/app/components/shared/SessionItem/SessionItem.tsx @@ -41,6 +41,8 @@ interface Props { userSessionsCount: number; issueTypes: []; active: boolean; + isCallActive?: boolean; + agentIds?: string[]; }; onUserClick?: (userId: string, userAnonymousId: string) => void; hasUserFilter?: boolean; @@ -50,6 +52,7 @@ interface Props { lastPlayedSessionId?: string; live?: boolean; onClick?: any; + compact?: boolean; } function SessionItem(props: RouteComponentProps & Props) { @@ -65,6 +68,7 @@ function SessionItem(props: RouteComponentProps & Props) { metaList = [], lastPlayedSessionId, onClick = null, + compact = false, } = props; const { @@ -104,27 +108,29 @@ 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')}> - <div className="flex items-center pr-2 shrink-0" style={{ width: '40%' }}> - <div> - <Avatar isActive={active} seed={userNumericHash} isAssist={isAssist} /> - </div> - <div className="flex flex-col overflow-hidden color-gray-medium ml-3 justify-between items-center shrink-0"> - <div - className={cn('text-lg', { - 'color-teal cursor-pointer': !disableUser && hasUserId, - [stl.userName]: !disableUser && hasUserId, - 'color-gray-medium': disableUser || !hasUserId, - })} - onClick={() => !disableUser && !hasUserFilter && onUserClick(userId, userAnonymousId)} - > - <TextEllipsis text={userDisplayName} maxWidth="200px" popupProps={{ inverted: true, size: 'tiny' }} /> + {!compact && ( + <div className="flex items-center pr-2 shrink-0" style={{ width: '40%' }}> + <div> + <Avatar isActive={active} seed={userNumericHash} isAssist={isAssist} /> + </div> + <div className="flex flex-col overflow-hidden color-gray-medium ml-3 justify-between items-center shrink-0"> + <div + className={cn('text-lg', { + 'color-teal cursor-pointer': !disableUser && hasUserId, + [stl.userName]: !disableUser && hasUserId, + 'color-gray-medium': disableUser || !hasUserId, + })} + onClick={() => !disableUser && !hasUserFilter && hasUserId ? onUserClick(userId, userAnonymousId) : null} + > + <TextEllipsis text={userDisplayName} maxWidth="200px" popupProps={{ inverted: true, size: 'tiny' }} /> + </div> </div> </div> - </div> - <div style={{ width: '20%' }} className="px-2 flex flex-col justify-between"> + )} + <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' }} /> </div> @@ -168,6 +174,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 && ( diff --git a/frontend/app/components/shared/SessionListContainer/SessionListContainer.tsx b/frontend/app/components/shared/SessionListContainer/SessionListContainer.tsx index 156f845c4..0b8bc35c6 100644 --- a/frontend/app/components/shared/SessionListContainer/SessionListContainer.tsx +++ b/frontend/app/components/shared/SessionListContainer/SessionListContainer.tsx @@ -2,14 +2,12 @@ import React from 'react'; import SessionList from './components/SessionList'; import SessionHeader from './components/SessionHeader'; -interface Props {} -function SessionListContainer(props: Props) { +function SessionListContainer() { return ( <div className="widget-wrapper"> <SessionHeader /> - <div className="p-4"> - <SessionList /> - </div> + <div className="border-b" /> + <SessionList /> </div> ); } diff --git a/frontend/app/components/shared/SessionListContainer/components/SessionHeader/SessionHeader.tsx b/frontend/app/components/shared/SessionListContainer/components/SessionHeader/SessionHeader.tsx index 9efcf7e6e..3777763ca 100644 --- a/frontend/app/components/shared/SessionListContainer/components/SessionHeader/SessionHeader.tsx +++ b/frontend/app/components/shared/SessionListContainer/components/SessionHeader/SessionHeader.tsx @@ -6,14 +6,25 @@ 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 { listCount, filter: { startDate, endDate, rangeValue } } = props; + const { + listCount, + filter: { startDate, endDate, rangeValue }, + isBookmark, + isEnterprise, + } = props; const period = Period({ start: startDate, end: endDate, rangeName: rangeValue }); const onDateChange = (e: any) => { @@ -22,18 +33,35 @@ function SessionHeader(props: Props) { }; return ( - <div className="flex items-center p-4 justify-between"> - <div className="flex items-center"> - <div className="mr-3 text-lg"> - <span className="font-bold">Sessions</span> <span className="color-gray-medium ml-2">{listCount}</span> + <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> - <SessionTags /> </div> <div className="flex items-center"> + {!isBookmark && <SessionTags />} + <div className="mx-4" /> <SelectDateRange period={period} onChange={onDateChange} right={true} /> <div className="mx-2" /> <SessionSort /> + <SessionSettingButton /> </div> </div> ); @@ -43,6 +71,8 @@ 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 } + { applyFilter, setActiveTab } )(SessionHeader); diff --git a/frontend/app/components/shared/SessionListContainer/components/SessionList/SessionList.tsx b/frontend/app/components/shared/SessionListContainer/components/SessionList/SessionList.tsx index 60d1aec6c..6d7023ca0 100644 --- a/frontend/app/components/shared/SessionListContainer/components/SessionList/SessionList.tsx +++ b/frontend/app/components/shared/SessionListContainer/components/SessionList/SessionList.tsx @@ -2,11 +2,12 @@ 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 } from 'UI'; +import { NoContent, Loader, Pagination, Button } from 'UI'; import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG'; -import NoContentMessage from '../NoContentMessage'; import { fetchSessions, addFilterByKeyAndValue, updateCurrentPage, setScrollPosition } from 'Duck/search'; +import useTimeout from 'App/hooks/useTimeout'; +const AUTOREFRESH_INTERVAL = 5 * 60 * 1000; interface Props { loading: boolean; list: any; @@ -19,20 +20,47 @@ interface Props { addFilterByKeyAndValue: (key: string, value: any, operator?: string) => void; updateCurrentPage: (page: number) => void; setScrollPosition: (scrollPosition: number) => void; - fetchSessions: () => void; + fetchSessions: (filters: any, force: boolean) => void; + activeTab: any; + isEnterprise?: boolean; } function SessionList(props: Props) { - const { loading, list, currentPage, total, filters, lastPlayedSessionId, metaList } = 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() + props.fetchSessions(null, true); } - + return () => { props.setScrollPosition(window.scrollY); }; @@ -51,16 +79,29 @@ function SessionList(props: Props) { <NoContent title={ <div className="flex items-center justify-center flex-col"> - <AnimatedSVG name={ICONS.NO_RESULTS} size={170} /> + <AnimatedSVG name={NO_CONTENT.icon} size={170} /> <div className="mt-2" /> - <NoContentMessage /> + <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> } - subtext={<div>Please try changing your search parameters.</div>} show={!loading && list.size === 0} > {list.map((session: any) => ( - <React.Fragment key={session.sessionId}> + <div key={session.sessionId} className="border-b"> <SessionItem session={session} hasUserFilter={hasUserFilter} @@ -68,13 +109,12 @@ function SessionList(props: Props) { metaList={metaList} lastPlayedSessionId={lastPlayedSessionId} /> - <div className="border-b" /> - </React.Fragment> + </div> ))} </NoContent> {total > 0 && ( - <div className="w-full flex items-center justify-center py-6"> + <div className="w-full flex items-center justify-center py-6 px-4"> <Pagination page={currentPage} totalPages={Math.ceil(total / 10)} @@ -98,6 +138,8 @@ export default connect( 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/SessionSettingButton/SessionSettingButton.tsx b/frontend/app/components/shared/SessionListContainer/components/SessionSettingButton/SessionSettingButton.tsx new file mode 100644 index 000000000..897a59754 --- /dev/null +++ b/frontend/app/components/shared/SessionListContainer/components/SessionSettingButton/SessionSettingButton.tsx @@ -0,0 +1,20 @@ +import { useModal } from 'App/components/Modal'; +import React from 'react'; +import SessionSettings from 'Shared/SessionSettings'; +import { Button } from 'UI'; + +function SessionSettingButton(props: any) { + const { showModal } = useModal(); + + const handleClick = () => { + showModal(<SessionSettings />, { right: true }); + }; + + return ( + <div className="cursor-pointer ml-4" onClick={handleClick}> + <Button icon="sliders" variant="text" /> + </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/SessionTags/SessionTags.tsx b/frontend/app/components/shared/SessionListContainer/components/SessionTags/SessionTags.tsx index 1c8f5c926..22824e6e5 100644 --- a/frontend/app/components/shared/SessionListContainer/components/SessionTags/SessionTags.tsx +++ b/frontend/app/components/shared/SessionListContainer/components/SessionTags/SessionTags.tsx @@ -52,14 +52,14 @@ function TagItem({ isActive, onClick, label, icon = '', disabled = false }: any) <div> <button onClick={onClick} - className={cn('transition group rounded ml-2 px-2 py-1 flex items-center uppercase text-sm hover:bg-teal hover:text-white', { - 'bg-teal text-white': isActive, - 'bg-active-blue color-teal': !isActive, - 'disabled': disabled, + 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="teal" size="14" className={cn('group-hover:fill-white mr-2', { 'fill-white': isActive })} />} - <span className="leading-none font-bold">{label}</span> + {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/SessionSearch/SessionSearch.tsx b/frontend/app/components/shared/SessionSearch/SessionSearch.tsx index 66bd28a1b..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', 'active', '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..66de1a0e9 --- /dev/null +++ b/frontend/app/components/shared/SessionSearchQueryParamHandler/SessionSearchQueryParamHandler.tsx @@ -0,0 +1,91 @@ +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 = () => { + 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/SessionSettings/components/CaptureRate.tsx b/frontend/app/components/shared/SessionSettings/components/CaptureRate.tsx index 630ec49da..ef9568a4e 100644 --- a/frontend/app/components/shared/SessionSettings/components/CaptureRate.tsx +++ b/frontend/app/components/shared/SessionSettings/components/CaptureRate.tsx @@ -1,76 +1,84 @@ import React, { useEffect, useState } from 'react'; -import { Icon, Toggler, Button, Input, Loader } from 'UI'; +import { Icon, Toggler, Button, Input, Loader, Popup } from 'UI'; import { useStore } from 'App/mstore'; import { observer } from 'mobx-react-lite'; +import { connect } from 'react-redux'; +import cn from 'classnames'; -function CaptureRate() { +function CaptureRate({ isAdmin = false }) { const { settingsStore } = useStore(); const [changed, setChanged] = useState(false); - const [sessionSettings] = useState(settingsStore.sessionSettings) - const [loading] = useState(settingsStore.loadingCaptureRate) + const [sessionSettings] = useState(settingsStore.sessionSettings); + const [loading] = useState(settingsStore.loadingCaptureRate); const captureRate = sessionSettings.captureRate; - const setCaptureRate = sessionSettings.changeCaptureRate - const captureAll = sessionSettings.captureAll - const setCaptureAll = sessionSettings.changeCaptureAll + const setCaptureRate = sessionSettings.changeCaptureRate; + const captureAll = sessionSettings.captureAll; + const setCaptureAll = sessionSettings.changeCaptureAll; useEffect(() => { - settingsStore.fetchCaptureRate() - }, []) + settingsStore.fetchCaptureRate(); + }, []); const changeCaptureRate = (input: string) => { setChanged(true); setCaptureRate(input); - } + }; const toggleRate = () => { const newValue = !captureAll; - setChanged(true) - if (newValue === true) { + setChanged(true); + if (newValue === true) { const updateObj = { - rate:"100", + rate: '100', captureAll: true, - } - settingsStore.saveCaptureRate(updateObj) + }; + settingsStore.saveCaptureRate(updateObj); } else { setCaptureAll(newValue); } - } + }; return ( <Loader loading={loading}> <h3 className="text-lg">Recordings</h3> <div className="my-1">The percentage of session you want to capture</div> - <div className="mt-2 mb-4 mr-1 flex items-center"> - <Toggler - checked={captureAll} - name="test" - onChange={toggleRate} - /> - <span className="ml-2" style={{ color: captureAll ? '#000000' : '#999' }}>100%</span> - </div> - {!captureAll && ( - <div className="flex items-center"> - <div className="relative"> - <Input - type="number" - onChange={(e: React.ChangeEvent<HTMLInputElement>) => changeCaptureRate(e.target.value)} - value={captureRate.toString()} - style={{ height: '38px', width: '100px'}} - disabled={captureAll} - min={0} - max={100} - /> - <Icon className="absolute right-0 mr-6 top-0 bottom-0 m-auto" name="percent" color="gray-medium" size="18" /> - </div> + <Popup content="You don't have permission to change." disabled={isAdmin} delay={0}> + <div className={cn('mt-2 mb-4 mr-1 flex items-center', { disabled: !isAdmin })}> + <Toggler checked={captureAll} name="test" onChange={toggleRate} /> + <span className="ml-2" style={{ color: captureAll ? '#000000' : '#999' }}> + 100% + </span> + </div> + </Popup> + {!captureAll && ( + <div className="flex items-center"> + <Popup content="You don't have permission to change." disabled={isAdmin} delay={0}> + <div className={cn("relative", { 'disabled' : !isAdmin })}> + <Input + type="number" + onChange={(e: React.ChangeEvent<HTMLInputElement>) => changeCaptureRate(e.target.value)} + value={captureRate.toString()} + style={{ height: '38px', width: '100px' }} + disabled={captureAll} + min={0} + max={100} + /> + <Icon className="absolute right-0 mr-6 top-0 bottom-0 m-auto" name="percent" color="gray-medium" size="18" /> + </div> + </Popup> <span className="mx-3">of the sessions</span> <Button disabled={!changed} variant="outline" - onClick={() => settingsStore.saveCaptureRate({ - rate: captureRate, - captureAll, - }).finally(() => setChanged(false))} + onClick={() => + settingsStore + .saveCaptureRate({ + rate: captureRate, + captureAll, + }) + .finally(() => setChanged(false)) + } > Update </Button> @@ -80,4 +88,6 @@ function CaptureRate() { ); } -export default observer(CaptureRate); +export default connect((state: any) => ({ + isAdmin: state.getIn(['user', 'account', 'admin']) || state.getIn(['user', 'account', 'superAdmin']), +}))(observer(CaptureRate)); 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..c17602268 --- /dev/null +++ b/frontend/app/components/shared/SupportCallout/SupportCallout.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import SlackIcon from '../../../svg/slack-help.svg'; +import { Popup } from 'UI'; + +function SupportCallout() { + return ( + <a href="https://slack.openreplay.com" target="_blank" className="fixed z-50 right-0 bottom-0"> + <div className="w-12 h-12 cursor-pointer m-4"> + <Popup content="OpenReplay community" delay={0}> + <img src={SlackIcon} /> + </Popup> + </div> + </a> + ); +} + +export default SupportCallout; 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 new file mode 100644 index 000000000..5a2b6f618 --- /dev/null +++ b/frontend/app/components/shared/UserSessionsModal/UserSessionsModal.tsx @@ -0,0 +1,102 @@ +import React, { useEffect } from 'react'; +import { useStore } from 'App/mstore'; +import Filter from 'Types/filter'; +import { filtersMap } from 'Types/filter/newFilter'; +import { FilterKey } from 'App/types/filter/filterType'; +import { NoContent, Pagination, Loader, Avatar } from 'UI'; +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 { + userId: string; + hash: string; + name: string; +} +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); + + const onDateChange = (period: any) => { + filter.update('period', period); + }; + + const fetchData = () => { + setLoading(true); + sessionStore + .getSessions(filter) + .then(setData) + .catch(() => { + console.log('error'); + }) + .finally(() => { + setLoading(false); + }); + }; + + useEffect(() => { + const userFilter = { key: FilterKey.USERID, value: [userId], operator: 'is', isEvent: false }; + filter.update('filters', [userFilter]); + }, []); + useEffect(fetchData, [filter.page, filter.startDate, filter.endDate]); + + return ( + <div className="h-screen overflow-y-auto bg-white" style={{ width: '700px' }}> + <div className="flex items-center justify-between w-full px-5 py-3"> + <div className="text-lg flex items-center"> + <Avatar isActive={false} seed={hash} isAssist={false} className={''} /> + <div className="ml-3"> + {name}'s <span className="color-gray-dark">Sessions</span> + </div> + </div> + <div> + <SelectDateRange period={filter.period} onChange={onDateChange} right={true} /> + </div> + </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} onClick={hideModal} /> + </div> + ))} + </Loader> + + <div className="flex items-center justify-between p-5"> + <div> + {/* showing x to x of total sessions */} + Showing <span className="font-medium">{(filter.page - 1) * PER_PAGE + 1}</span> to{' '} + <span className="font-medium">{(filter.page - 1) * PER_PAGE + data.sessions.length}</span> of{' '} + <span className="font-medium">{data.total}</span> sessions. + </div> + <Pagination + page={filter.page} + totalPages={Math.ceil(data.total / PER_PAGE)} + onPageChange={(page) => filter.update('page', page)} + limit={PER_PAGE} + debounceRequest={1000} + /> + </div> + </div> + </NoContent> + </div> + ); +} + +export default observer(UserSessionsModal); diff --git a/frontend/app/components/shared/UserSessionsModal/index.ts b/frontend/app/components/shared/UserSessionsModal/index.ts new file mode 100644 index 000000000..c48e3ab5a --- /dev/null +++ b/frontend/app/components/shared/UserSessionsModal/index.ts @@ -0,0 +1 @@ +export { default } from './UserSessionsModal'; \ No newline at end of file diff --git a/frontend/app/components/shared/XRayButton/XRayButton.tsx b/frontend/app/components/shared/XRayButton/XRayButton.tsx new file mode 100644 index 000000000..48116e9d2 --- /dev/null +++ b/frontend/app/components/shared/XRayButton/XRayButton.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import stl from './xrayButton.module.css'; +import cn from 'classnames'; +import { Popup } from 'UI'; + +interface Props { + onClick?: () => void; + isActive?: boolean; +} +function XRayButton(props: Props) { + const { isActive } = props; + return ( + <Popup content="Get a quick overview on the issues in this replay." delay={0} disabled={isActive}> + <button className={cn(stl.wrapper, { [stl.default] : !isActive, [stl.active] : isActive})} onClick={props.onClick}> + X-RAY + </button> + </Popup> + ); +} + +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..f602c7a3f --- /dev/null +++ b/frontend/app/components/shared/XRayButton/xrayButton.module.css @@ -0,0 +1,21 @@ +.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; + } +} diff --git a/frontend/app/components/ui/Button/Button.tsx b/frontend/app/components/ui/Button/Button.tsx index bc71be3a0..8e6c62823 100644 --- a/frontend/app/components/ui/Button/Button.tsx +++ b/frontend/app/components/ui/Button/Button.tsx @@ -1,6 +1,6 @@ import React from 'react'; import cn from 'classnames'; -import { CircularLoader, Icon } from 'UI'; +import { CircularLoader, Icon, Popup } from 'UI'; interface Props { className?: string; @@ -8,9 +8,11 @@ interface Props { onClick?: () => void; disabled?: boolean; type?: 'button' | 'submit' | 'reset'; + variant?: 'default' | 'primary' | 'text' | 'text-primary' | 'text-red' | 'outline' loading?: boolean; icon?: string; rounded?: boolean; + tooltip?: any; [x: string]: any; } export default (props: Props) => { @@ -24,13 +26,14 @@ export default (props: Props) => { children, loading = false, rounded = false, + tooltip = null, ...rest } = props; let classes = ['relative flex items-center h-10 px-3 rounded tracking-wide whitespace-nowrap']; 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') { @@ -38,7 +41,7 @@ export default (props: Props) => { } 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') { @@ -69,7 +72,7 @@ export default (props: Props) => { classes = classes.map((c) => c.replace('rounded', 'rounded-full h-10 w-10 justify-center')); } - return ( + const render = () => ( <button {...rest} type={type} className={cn(classes, className)}> {icon && <Icon className={cn({ 'mr-2': children })} name={icon} color={iconColor} size="16" />} {loading && ( @@ -80,4 +83,6 @@ export default (props: Props) => { <div className={cn({ 'opacity-0': loading }, 'flex items-center')}>{children}</div> </button> ); + + return tooltip ? <Popup content={tooltip.title} {...tooltip}>{render()}</Popup> : render(); }; 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/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/NoContent/NoContent.js b/frontend/app/components/ui/NoContent/NoContent.js new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/app/components/ui/NoContent/NoContent.tsx b/frontend/app/components/ui/NoContent/NoContent.tsx index 10a7e72e7..ae26731be 100644 --- a/frontend/app/components/ui/NoContent/NoContent.tsx +++ b/frontend/app/components/ui/NoContent/NoContent.tsx @@ -7,7 +7,7 @@ interface Props { subtext?: any; icon?: string; iconSize?: number; - size?: number; + size?: string; show?: boolean; children?: any; image?: any; diff --git a/frontend/app/components/ui/NoContent/noContent.module.css b/frontend/app/components/ui/NoContent/noContent.module.css index 33c63c916..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; */ } 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..3b447d148 100644 --- a/frontend/app/components/ui/Popup/Popup.tsx +++ b/frontend/app/components/ui/Popup/Popup.tsx @@ -9,7 +9,6 @@ interface Props { className?: string; delay?: number; hideDelay?: number; - duration?: number; disabled?: boolean; arrow?: boolean; open?: boolean; @@ -24,12 +23,11 @@ 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, @@ -37,6 +35,7 @@ export default ({ }: // ...props Props) => ( <Tooltip + animation="fade" position={position} className={className} trigger={trigger} @@ -49,7 +48,6 @@ Props) => ( theme={theme} style={style} interactive={interactive} - duration={0} hideDelay={hideDelay} > {children} diff --git a/frontend/app/components/ui/SVG.tsx b/frontend/app/components/ui/SVG.tsx index 8f7bab660..9aab9f066 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-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' | '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' | '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>; @@ -111,6 +114,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 +138,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 +220,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 +238,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 +257,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 +290,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 +309,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>; @@ -319,6 +331,7 @@ const SVG = (props: Props) => { 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>; @@ -341,6 +354,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 +376,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/dateRange.js b/frontend/app/dateRange.js index 70f674665..77f2c3d2e 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", 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/liveSearch.js b/frontend/app/duck/liveSearch.js index ecb8a720e..7838a740a 100644 --- a/frontend/app/duck/liveSearch.js +++ b/frontend/app/duck/liveSearch.js @@ -20,7 +20,7 @@ const FETCH_SESSION_LIST = fetchListType(`${name}/FETCH_SESSION_LIST`); const initialState = Map({ list: List(), - instance: new Filter({ filters: [], sort: 'timestamp' }), + instance: new Filter({ filters: [], sort: '' }), filterSearchList: {}, currentPage: 1, }); diff --git a/frontend/app/duck/search.js b/frontend/app/duck/search.js index 4acb83313..5876abbb0 100644 --- a/frontend/app/duck/search.js +++ b/frontend/app/duck/search.js @@ -86,6 +86,8 @@ function reducer(state = initialState, action = {}) { return state.set('filterSearchList', groupedList); case APPLY_SAVED_SEARCH: return state.set('savedSearch', action.filter); + case CLEAR_SEARCH: + return state.set('savedSearch', new SavedFilter({})); case EDIT_SAVED_SEARCH: return state.mergeIn(['savedSearch'], action.instance); case UPDATE_CURRENT_PAGE: @@ -150,7 +152,7 @@ export const reduceThenFetchResource = 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)) { @@ -204,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) => ({ @@ -223,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) => ({ @@ -286,7 +288,8 @@ export function fetchFilterSearch(params) { } export const clearSearch = () => (dispatch, getState) => { - dispatch(applySavedSearch(new SavedFilter({}))); + // const filter = getState().getIn(['search', 'instance']); + // dispatch(applySavedSearch(new SavedFilter({}))); dispatch(edit(new Filter({ filters: [] }))); return dispatch({ type: CLEAR_SEARCH, @@ -315,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 54fbe1972..383fb27b5 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 IDashboardSotre { - 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 IDashboardSotre { +export default class DashboardStore { siteId: any = null; - // Dashbaord / Widgets dashboards: Dashboard[] = []; selectedDashboard: Dashboard | null = null; - dashboardInstance: IDashboard = new Dashboard(); - selectedWidgets: IWidget[] = []; + dashboardInstance: Dashboard = new Dashboard(); + 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 IDashboardSotre { sessionsLoading: boolean = false; showAlertModal: boolean = false; + // Pagination + page: number = 1 + pageSize: number = 10 + dashboardsSearch: string = '' + sort: any = {} + constructor() { makeAutoObservable(this); @@ -214,7 +151,7 @@ export default class DashboardStore implements IDashboardSotre { } fetch(dashboardId: string): Promise<any> { - this.fetchingDashboard = true; + this.setFetchingDashboard(true); return dashboardService .getDashboard(dashboardId) .then((response) => { @@ -223,10 +160,14 @@ export default class DashboardStore implements IDashboardSotre { }); }) .finally(() => { - this.fetchingDashboard = false; + this.setFetchingDashboard(false); }); } + setFetchingDashboard(value: boolean) { + this.fetchingDashboard = value; + } + save(dashboard: IDashboard): Promise<any> { this.isSaving = true; const isCreating = !dashboard.dashboardId; @@ -328,7 +269,7 @@ export default class DashboardStore implements IDashboardSotre { ); } - getDashboard(dashboardId: string): IDashboard | null { + getDashboard(dashboardId: string): Dashboard | null { return ( this.dashboards.find((d) => d.dashboardId === dashboardId) || null ); @@ -360,27 +301,23 @@ export default class DashboardStore implements IDashboardSotre { 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) { @@ -445,28 +382,6 @@ export default class DashboardStore implements IDashboardSotre { }); } - 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, @@ -487,7 +402,7 @@ export default class DashboardStore implements IDashboardSotre { 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 }; diff --git a/frontend/app/mstore/index.tsx b/frontend/app/mstore/index.tsx index c6ffeb460..26533ee4f 100644 --- a/frontend/app/mstore/index.tsx +++ b/frontend/app/mstore/index.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import DashboardStore, { IDashboardSotre } 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'; @@ -10,10 +10,11 @@ import SettingsStore from './settingsStore'; import AuditStore from './auditStore'; import NotificationStore from './notificationStore'; import ErrorStore from './errorStore'; +import SessionStore from './sessionStore'; export class RootStore { - dashboardStore: IDashboardSotre; - metricStore: IMetricStore; + dashboardStore: DashboardStore; + metricStore: MetricStore; funnelStore: FunnelStore; settingsStore: SettingsStore; userStore: UserStore; @@ -21,6 +22,7 @@ export class RootStore { auditStore: AuditStore; errorStore: ErrorStore; notificationStore: NotificationStore + sessionStore: SessionStore; constructor() { this.dashboardStore = new DashboardStore(); @@ -32,6 +34,7 @@ export class RootStore { this.auditStore = new AuditStore(); this.errorStore = new ErrorStore(); this.notificationStore = new NotificationStore(); + this.sessionStore = new SessionStore(); } initClient() { 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/notificationStore.ts b/frontend/app/mstore/notificationStore.ts index 13124ca62..76f63ac2e 100644 --- a/frontend/app/mstore/notificationStore.ts +++ b/frontend/app/mstore/notificationStore.ts @@ -18,6 +18,7 @@ export default class NotificationStore { fetchNotifications: action, ignoreAllNotifications: action, ignoreNotification: action, + setNotificationsCount: action, }); } @@ -74,15 +75,19 @@ export default class NotificationStore { }); } + setNotificationsCount(count: number) { + this.notificationsCount = count; + } + fetchNotificationsCount(): Promise<any> { return new Promise((resolve, reject) => { userService.getNotificationsCount() .then((response: any) => { - this.notificationsCount = response.count; + this.setNotificationsCount(response.count); resolve(response); }).catch((error: any) => { reject(error); }); }); } -} \ No newline at end of file +} diff --git a/frontend/app/mstore/sessionStore.ts b/frontend/app/mstore/sessionStore.ts new file mode 100644 index 000000000..98a7061e6 --- /dev/null +++ b/frontend/app/mstore/sessionStore.ts @@ -0,0 +1,79 @@ +import { makeAutoObservable, observable, action } from 'mobx'; +import { sessionService } from 'App/services'; +import { filterMap } from 'Duck/search'; +import Session from './types/session'; +import Record, { LAST_7_DAYS } from 'Types/app/period'; + +class UserFilter { + endDate: number = new Date().getTime(); + startDate: number = new Date().getTime() - 24 * 60 * 60 * 1000; + rangeName: string = LAST_7_DAYS; + filters: any = []; + page: number = 1; + limit: number = 10; + period: any = Record({ rangeName: LAST_7_DAYS }); + + constructor() { + makeAutoObservable(this, { + page: observable, + update: action, + }); + } + + update(key: string, value: any) { + this[key] = value; + + if (key === 'period') { + this.startDate = this.period.start; + this.endDate = this.period.end; + } + } + + setFilters(filters: any[]) { + this.filters = filters; + } + + setPage(page: number) { + this.page = page; + } + + toJson() { + return { + endDate: this.period.end, + startDate: this.period.start, + filters: this.filters.map(filterMap), + page: this.page, + limit: this.limit, + }; + } +} + +export default class SessionStore { + userFilter: UserFilter = new UserFilter(); + + constructor() { + makeAutoObservable(this, { + userFilter: observable, + }); + } + + resetUserFilter() { + this.userFilter = new UserFilter(); + } + + getSessions(filter: any): Promise<any> { + return new Promise((resolve, reject) => { + sessionService + .getSessions(filter.toJson()) + .then((response: any) => { + resolve({ + sessions: response.sessions.map((session: any) => new Session().fromJson(session)), + total: response.total, + }); + }) + .catch((error: any) => { + reject(error); + }); + }); + } +} 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 ce2a1f4db..5a2a8eb6b 100644 --- a/frontend/app/mstore/types/filterItem.ts +++ b/frontend/app/mstore/types/filterItem.ts @@ -1,21 +1,25 @@ -import { makeAutoObservable, runInAction, observable, action, reaction } from "mobx" -import { FilterKey, FilterType } from 'Types/filter/filterType' -import { filtersMap } from 'Types/filter/newFilter' +import { makeAutoObservable, runInAction, observable, action, reaction } from 'mobx'; +import { FilterKey, FilterType, FilterCategory } from 'Types/filter/filterType'; +import { filtersMap } from 'Types/filter/newFilter'; export default class FilterItem { - type: string = '' - key: string = '' - label: string = '' - value: any = [""] - isEvent: boolean = false - operator: string = '' - source: string = '' - filters: FilterItem[] = [] - operatorOptions: any[] = [] - options: any[] = [] - isActive: boolean = true - completed: number = 0 - dropped: number = 0 + type: string = ''; + category: FilterCategory = FilterCategory.METADATA; + key: string = ''; + label: string = ''; + value: any = ['']; + isEvent: boolean = false; + operator: string = ''; + hasSource: boolean = false; + source: string = ''; + sourceOperator: string = ''; + sourceOperatorOptions: any = []; + filters: FilterItem[] = []; + operatorOptions: any[] = []; + options: any[] = []; + isActive: boolean = true; + completed: number = 0; + dropped: number = 0; constructor(data: any = {}) { makeAutoObservable(this, { @@ -26,9 +30,11 @@ export default class FilterItem { source: observable, filters: observable, isActive: observable, + sourceOperator: observable, + category: observable, - merge: action - }) + merge: action, + }); if (Array.isArray(data.filters)) { data.filters = data.filters.map(function (i) { @@ -36,55 +42,64 @@ export default class FilterItem { }); } - this.merge(data) + this.merge(data); } updateKey(key: string, value: any) { - this[key] = value + this[key] = value; } - merge(data) { - Object.keys(data).forEach(key => { - this[key] = data[key] - }) + merge(data: any) { + Object.keys(data).forEach((key) => { + this[key] = data[key]; + }); } - fromJson(json, mainFilterKey = '') { - let _filter = filtersMap[json.type] || {} + fromJson(json: any, mainFilterKey = '') { + const isMetadata = json.type === FilterKey.METADATA; + let _filter: any = (isMetadata ? filtersMap[json.source] : filtersMap[json.type]) || {}; + if (mainFilterKey) { const mainFilter = filtersMap[mainFilterKey]; - const subFilterMap = {} - mainFilter.filters.forEach(option => { - subFilterMap[option.key] = option - }) - _filter = subFilterMap[json.type] + const subFilterMap = {}; + mainFilter.filters.forEach((option: any) => { + subFilterMap[option.key] = option; + }); + _filter = subFilterMap[json.type]; } - this.type = _filter.type - this.key = _filter.key - this.label = _filter.label - this.operatorOptions = _filter.operatorOptions - this.options = _filter.options - this.isEvent = _filter.isEvent + this.type = _filter.type; + this.key = _filter.key; + this.label = _filter.label; + this.operatorOptions = _filter.operatorOptions; + this.hasSource = _filter.hasSource; + this.category = _filter.category; + this.sourceOperatorOptions = _filter.sourceOperatorOptions; + this.options = _filter.options; + this.isEvent = _filter.isEvent; - this.value = json.value.length === 0 || !json.value ? [""] : json.value, - this.operator = json.operator - - this.filters = _filter.type === FilterType.SUB_FILTERS && json.filters ? json.filters.map(i => new FilterItem().fromJson(i, json.type)) : [] + (this.value = json.value.length === 0 || !json.value ? [''] : json.value), (this.operator = json.operator); + this.source = json.source; + this.sourceOperator = json.sourceOperator; - this.completed = json.completed - this.dropped = json.dropped - return this + this.filters = + _filter.type === FilterType.SUB_FILTERS && json.filters ? json.filters.map((i: any) => new FilterItem().fromJson(i, json.type)) : []; + + this.completed = json.completed; + this.dropped = json.dropped; + return this; } - toJson() { + toJson(): any { + const isMetadata = this.category === FilterCategory.METADATA; const json = { - type: this.key, + type: isMetadata ? FilterKey.METADATA : this.key, isEvent: this.isEvent, value: this.value, operator: this.operator, - source: this.source, - filters: Array.isArray(this.filters) ? this.filters.map(i => i.toJson()) : [], - } - return json + source: isMetadata ? this.key : this.source, + sourceOperator: this.sourceOperator, + filters: Array.isArray(this.filters) ? this.filters.map((i) => i.toJson()) : [], + }; + return json; } -} \ No newline at end of file +} 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/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/mstore/userStore.ts b/frontend/app/mstore/userStore.ts index 0a5c47f53..523ae04f7 100644 --- a/frontend/app/mstore/userStore.ts +++ b/frontend/app/mstore/userStore.ts @@ -23,6 +23,7 @@ export default class UserStore { updateUser: action, updateKey: action, initUser: action, + setLimits: action, }) } @@ -30,7 +31,7 @@ export default class UserStore { return new Promise((resolve, reject) => { userService.getLimits() .then((response: any) => { - this.limits = response; + this.setLimits(response); resolve(response); }).catch((error: any) => { reject(error); @@ -38,6 +39,10 @@ export default class UserStore { }); } + setLimits(limits: any) { + this.limits = limits; + } + initUser(user?: any ): Promise<void> { return new Promise((resolve, reject) => { if (user) { @@ -175,4 +180,4 @@ export default class UserStore { return promise; } -} \ No newline at end of file +} 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/Marker.js b/frontend/app/player/MessageDistributor/StatedScreen/Screen/Marker.js index 5461422cf..daf5a67b4 100644 --- a/frontend/app/player/MessageDistributor/StatedScreen/Screen/Marker.js +++ b/frontend/app/player/MessageDistributor/StatedScreen/Screen/Marker.js @@ -7,15 +7,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 +30,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 +54,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 +70,7 @@ export default class Marker { } }); } - } catch(e) { + } catch (e) { console.info(e); } } else { @@ -85,18 +85,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">${'.' + k.value.split(' ').join('.')}</span>`; } if (attribute === 'id') { - str += `<span style="color:#F29766">${'#' + k.value.split(' ').join('#')}</span>` + str += `<span style="color:#F29766">${'#' + k.value.split(' ').join('#')}</span>`; } } @@ -117,8 +117,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 b8682b09b..3feb20873 100644 --- a/frontend/app/player/MessageDistributor/managers/AssistManager.ts +++ b/frontend/app/player/MessageDistributor/managers/AssistManager.ts @@ -56,10 +56,11 @@ export function getStatusText(status: ConnectionStatus): string { } 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,12 +68,16 @@ 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) { @@ -129,6 +134,10 @@ export default class AssistManager { 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 @@ -144,7 +153,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 @@ -154,8 +162,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 @@ -163,12 +170,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) } }) @@ -313,7 +329,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) } @@ -334,6 +350,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() @@ -359,21 +401,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) @@ -387,15 +429,16 @@ export default class AssistManager { onStream: (s: MediaStream)=>void, onCallEnd: () => void, onReject: () => void, - onError?: ()=> void + onError?: ()=> void, } | null = null - call( + public setCallArgs( localStream: LocalStream, onStream: (s: MediaStream)=>void, onCallEnd: () => void, onReject: () => void, - onError?: ()=> void): { end: Function } { + onError?: ()=> void, + ) { this.callArgs = { localStream, onStream, @@ -403,12 +446,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") { @@ -450,44 +547,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() { @@ -510,5 +574,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 index 1d2497332..8233e8a86 100644 --- a/frontend/app/player/MessageDistributor/managers/DOM/DOMManager.ts +++ b/frontend/app/player/MessageDistributor/managers/DOM/DOMManager.ts @@ -126,6 +126,7 @@ export default class DOMManager extends ListWalker<Message> { 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); 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/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 804a67fdd..ede3719ac 100644 --- a/frontend/app/player/MessageDistributor/messages/MStreamReader.ts +++ b/frontend/app/player/MessageDistributor/messages/MStreamReader.ts @@ -25,4 +25,4 @@ export default class MStreamReader { _index: this.idx++, }) } -} \ No newline at end of file +} 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/urlResolve.ts b/frontend/app/player/MessageDistributor/messages/urlResolve.ts index 44298ec08..c445a4cf7 100644 --- a/frontend/app/player/MessageDistributor/messages/urlResolve.ts +++ b/frontend/app/player/MessageDistributor/messages/urlResolve.ts @@ -26,10 +26,10 @@ 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(); + str = 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/SessionService.ts b/frontend/app/services/SessionService.ts index a7940edc1..07c623359 100644 --- a/frontend/app/services/SessionService.ts +++ b/frontend/app/services/SessionService.ts @@ -1,4 +1,5 @@ import APIClient from 'App/api_client'; +import { fetchErrorCheck } from 'App/utils'; export default class SettingsService { private client: APIClient; @@ -16,8 +17,16 @@ export default class SettingsService { } fetchCaptureRate() { - return this.client.get('/sample_rate') - .then(response => response.json()) - .then(response => response.data || 0); + return this.client + .get('/sample_rate') + .then((response) => response.json()) + .then((response) => response.data || 0); } -} \ No newline at end of file + + getSessions(filter: any) { + return this.client + .post('/sessions/search2', filter) + .then(fetchErrorCheck) + .then((response) => response.data || []); + } +} 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/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-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/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/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/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/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..8ab9869e0 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", 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 9a87da2c6..31ad4125f 100644 --- a/frontend/app/types/filter/newFilter.js +++ b/frontend/app/types/filter/newFilter.js @@ -11,7 +11,7 @@ export const filters = [ { key: FilterKey.INPUT, type: FilterType.MULTIPLE, category: FilterCategory.INTERACTIONS, label: 'Input', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/input', isEvent: true }, { key: FilterKey.LOCATION, type: FilterType.MULTIPLE, category: FilterCategory.INTERACTIONS, label: 'Path', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/location', isEvent: true }, { key: FilterKey.CUSTOM, type: FilterType.MULTIPLE, category: FilterCategory.JAVASCRIPT, label: 'Custom Events', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/custom', isEvent: true }, - { key: FilterKey.REQUEST, type: FilterType.MULTIPLE, category: FilterCategory.JAVASCRIPT, label: 'Fetch', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/fetch', isEvent: true }, + // { key: FilterKey.REQUEST, type: FilterType.MULTIPLE, category: FilterCategory.JAVASCRIPT, label: 'Fetch', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/fetch', isEvent: true }, { key: FilterKey.FETCH, type: FilterType.SUB_FILTERS, category: FilterCategory.JAVASCRIPT, operator: 'is', label: 'Network Request', filters: [ { key: FilterKey.FETCH_URL, type: FilterType.MULTIPLE, category: FilterCategory.PERFORMANCE, label: 'with URL', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/fetch' }, { key: FilterKey.FETCH_STATUS_CODE, type: FilterType.NUMBER_MULTIPLE, category: FilterCategory.PERFORMANCE, label: 'with status code', operator: '=', operatorOptions: filterOptions.customOperators, icon: 'filters/fetch' }, @@ -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 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].operator = 'contains'; + obj[filter.key].operatorDisabled = true; + 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 673467fd2..d2afff190 100644 --- a/frontend/app/types/session/issue.js +++ b/frontend/app/types/session/issue.js @@ -8,13 +8,13 @@ export const issues_types = List([ { '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' } + // { '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 = {} 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/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/nginx.conf b/frontend/nginx.conf index 0138027f0..b886096da 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -1,9 +1,11 @@ server { listen 8080 default_server; - listen [::]:8080 default_server; root /var/www/openreplay; index index.html; location / { try_files $uri $uri/ =404; + rewrite ^((?!.(js|css|png|svg|jpg|woff|woff2)).)*$ /index.html break; + 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; } } 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/scripts/dockerfiles/nginx/README.md b/scripts/dockerfiles/nginx/README.md new file mode 100644 index 000000000..03190a7bd --- /dev/null +++ b/scripts/dockerfiles/nginx/README.md @@ -0,0 +1,23 @@ +## Nginx read urls from env + +We're using openresty because of native lua support. + +1. To access the env variable using `os.getenv("MY_ENV")` we need to define `env MY_ENV` in nginx.conf + +2. use ` set_by_lua_block $api_endpoint { return os.getenv("MY_ENV") }` in server directive of nginx. + +Ref: +1. Nginx directives: https://openresty-reference.readthedocs.io/en/latest/Directives/#set_by_lua_block +2. env variable definition: + 1. https://github.com/openresty/lua-nginx-module#system-environment-variable-support + 2. https://nginx.org/en/docs/ngx_core_module.html#env + +## Run the app + +``` +docker run -v ${PWD}/nginx.conf:/usr/local/openresty/nginx/conf/nginx.conf \ +-v ${PWD}/location.list:/etc/nginx/conf.d/location.list --rm -it \ +-e FRONTEND_ENDPOINT="http://10.0.0.55:8000" -e API_ENDPOINT="http://10.0.0.55:9000" \ +-p 80:8080 -p 9145:9145 local/nginx +``` + diff --git a/scripts/dockerfiles/nginx/compression.conf b/scripts/dockerfiles/nginx/compression.conf new file mode 100644 index 000000000..fa0c6df3c --- /dev/null +++ b/scripts/dockerfiles/nginx/compression.conf @@ -0,0 +1,28 @@ +# Compression +gzip on; +gzip_comp_level 5; +gzip_min_length 256; # 256Bytes +gzip_proxied any; +gzip_vary on; +# Content types for compression +gzip_types +application/atom+xml +application/javascript +application/json +application/ld+json +application/manifest+json +application/rss+xml +application/vnd.geo+json +application/vnd.ms-fontobject +application/x-font-ttf +application/x-web-app-manifest+json +application/xhtml+xml +application/xml +font/opentype +image/bmp +image/svg+xml +image/x-icon +text/cache-manifest +text/css +text/plain +; diff --git a/scripts/dockerfiles/nginx/default.conf b/scripts/dockerfiles/nginx/default.conf new file mode 100644 index 000000000..9974ec0e7 --- /dev/null +++ b/scripts/dockerfiles/nginx/default.conf @@ -0,0 +1,26 @@ +# Ref: https://github.com/openresty/openresty/#resolvconf-parsing +resolver local=on; +# Need real ip address for flags in replay. +# Some LBs will forward real ips as x-forwarded-for +# So making that as priority +map $http_x_forwarded_for $real_ip { + ~^(\d+\.\d+\.\d+\.\d+) $1; + default $remote_addr; +} +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +server { + listen 8080 default_server; + # listen [::]:80 default_server; + # server_name _; + + set_by_lua_block $api_endpoint {return os.getenv("API_ENDPOINT")} + set_by_lua_block $frontend_endpoint {return os.getenv("FRONTEND_ENDPOINT")} + + include /etc/nginx/conf.d/location.list; + client_max_body_size 10M; +} + diff --git a/scripts/dockerfiles/nginx/location.list b/scripts/dockerfiles/nginx/location.list new file mode 100644 index 000000000..040dc722a --- /dev/null +++ b/scripts/dockerfiles/nginx/location.list @@ -0,0 +1,38 @@ +location ~* /general_stats { + deny all; +} +location /healthz { + return 200 'OK'; +} +location /api/ { + set $target $api_endpoint; + + 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"; + + proxy_pass $target; +} +location / { + set $target $frontend_endpoint; + include /etc/nginx/conf.d/compression.conf; + + index /index.html; + rewrite ^((?!.(js|css|png|svg|jpg|woff|woff2)).)*$ /index.html break; + 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"; + + proxy_pass $target; +} diff --git a/scripts/dockerfiles/nginx/nginx.conf b/scripts/dockerfiles/nginx/nginx.conf index 007b51ed2..0b920ddfc 100644 --- a/scripts/dockerfiles/nginx/nginx.conf +++ b/scripts/dockerfiles/nginx/nginx.conf @@ -31,6 +31,8 @@ pcre_jit on; #pid logs/nginx.pid; +env API_ENDPOINT; +env FRONTEND_ENDPOINT; events { worker_connections 10000; } diff --git a/scripts/dockerfiles/nginx/nginx.vh.default.conf b/scripts/dockerfiles/nginx/nginx.vh.default.conf deleted file mode 100644 index 3ad957225..000000000 --- a/scripts/dockerfiles/nginx/nginx.vh.default.conf +++ /dev/null @@ -1,58 +0,0 @@ -# nginx.vh.default.conf -- docker-openresty -# -# This file is installed to: -# `/etc/nginx/conf.d/default.conf` -# -# It tracks the `server` section of the upstream OpenResty's `nginx.conf`. -# -# This config (and any other configs in `etc/nginx/conf.d/`) is loaded by -# default by the `include` directive in `/usr/local/openresty/nginx/conf/nginx.conf`. -# -# See https://github.com/openresty/docker-openresty/blob/master/README.md#nginx-config-files -# - - -server { - listen 80; - server_name localhost; - - #charset koi8-r; - #access_log /var/log/nginx/host.access.log main; - - location / { - root /usr/local/openresty/nginx/html; - index index.html index.htm; - } - - #error_page 404 /404.html; - - # redirect server error pages to the static page /50x.html - # - error_page 500 502 503 504 /50x.html; - location = /50x.html { - root /usr/local/openresty/nginx/html; - } - - # proxy the PHP scripts to Apache listening on 127.0.0.1:80 - # - #location ~ \.php$ { - # proxy_pass http://127.0.0.1; - #} - - # pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000 - # - #location ~ \.php$ { - # root /usr/local/openresty/nginx/html; - # fastcgi_pass 127.0.0.1:9000; - # fastcgi_index index.php; - # fastcgi_param SCRIPT_FILENAME /scripts$fastcgi_script_name; - # include fastcgi_params; - #} - - # deny access to .htaccess files, if Apache's document root - # concurs with nginx's one - # - #location ~ /\.ht { - # deny all; - #} -} diff --git a/scripts/helmcharts/openreplay/charts/alerts/templates/deployment.yaml b/scripts/helmcharts/openreplay/charts/alerts/templates/deployment.yaml index eac304df0..afb7aedc5 100644 --- a/scripts/helmcharts/openreplay/charts/alerts/templates/deployment.yaml +++ b/scripts/helmcharts/openreplay/charts/alerts/templates/deployment.yaml @@ -96,8 +96,16 @@ spec: containerPort: {{ $val }} protocol: TCP {{- end }} + {{- with .Values.persistence.mounts }} + volumeMounts: + {{- toYaml . | nindent 12 }} + {{- end }} resources: {{- toYaml .Values.resources | nindent 12 }} + {{- with .Values.persistence.volumes }} + volumes: + {{- toYaml . | nindent 8 }} + {{- end }} {{- with .Values.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} diff --git a/scripts/helmcharts/openreplay/charts/alerts/values.yaml b/scripts/helmcharts/openreplay/charts/alerts/values.yaml index 8efd36a80..4bcc516c8 100644 --- a/scripts/helmcharts/openreplay/charts/alerts/values.yaml +++ b/scripts/helmcharts/openreplay/charts/alerts/values.yaml @@ -99,3 +99,15 @@ nodeSelector: {} tolerations: [] affinity: {} + + +persistence: {} + # # Spec of spec.template.spec.containers[*].volumeMounts + # mounts: + # - name: kafka-ssl + # mountPath: /opt/kafka/ssl + # # Spec of spec.template.spec.volumes + # volumes: + # - name: kafka-ssl + # secret: + # secretName: kafka-ssl diff --git a/scripts/helmcharts/openreplay/charts/assets/templates/deployment.yaml b/scripts/helmcharts/openreplay/charts/assets/templates/deployment.yaml index ce0c41c99..5fbd084c0 100644 --- a/scripts/helmcharts/openreplay/charts/assets/templates/deployment.yaml +++ b/scripts/helmcharts/openreplay/charts/assets/templates/deployment.yaml @@ -90,8 +90,16 @@ spec: containerPort: {{ $val }} protocol: TCP {{- end }} + {{- with .Values.persistence.mounts }} + volumeMounts: + {{- toYaml . | nindent 12 }} + {{- end }} resources: {{- toYaml .Values.resources | nindent 12 }} + {{- with .Values.persistence.volumes }} + volumes: + {{- toYaml . | nindent 8 }} + {{- end }} {{- with .Values.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} diff --git a/scripts/helmcharts/openreplay/charts/assets/values.yaml b/scripts/helmcharts/openreplay/charts/assets/values.yaml index e590f1b3c..2597ed36c 100644 --- a/scripts/helmcharts/openreplay/charts/assets/values.yaml +++ b/scripts/helmcharts/openreplay/charts/assets/values.yaml @@ -96,3 +96,15 @@ nodeSelector: {} tolerations: [] affinity: {} + + +persistence: {} + # # Spec of spec.template.spec.containers[*].volumeMounts + # mounts: + # - name: kafka-ssl + # mountPath: /opt/kafka/ssl + # # Spec of spec.template.spec.volumes + # volumes: + # - name: kafka-ssl + # secret: + # secretName: kafka-ssl diff --git a/scripts/helmcharts/openreplay/charts/assist/templates/deployment.yaml b/scripts/helmcharts/openreplay/charts/assist/templates/deployment.yaml index 2a776ab58..ed4ec5d4a 100644 --- a/scripts/helmcharts/openreplay/charts/assist/templates/deployment.yaml +++ b/scripts/helmcharts/openreplay/charts/assist/templates/deployment.yaml @@ -62,10 +62,18 @@ spec: {{- range $key, $val := .Values.service.ports }} - name: {{ $key }} containerPort: {{ $val }} - protocol: TCP {{- end }} + protocol: TCP + {{- with .Values.persistence.mounts }} + volumeMounts: + {{- toYaml . | nindent 12 }} + {{- end }} resources: {{- toYaml .Values.resources | nindent 12 }} + {{- with .Values.persistence.volumes }} + volumes: + {{- toYaml . | nindent 8 }} + {{- end }} {{- with .Values.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} diff --git a/scripts/helmcharts/openreplay/charts/assist/values.yaml b/scripts/helmcharts/openreplay/charts/assist/values.yaml index 4ffd45a0d..65e341f98 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: 0 nodeSelector: {} @@ -97,3 +98,15 @@ nodeSelector: {} tolerations: [] affinity: {} + + +persistence: {} + # # Spec of spec.template.spec.containers[*].volumeMounts + # mounts: + # - name: kafka-ssl + # mountPath: /opt/kafka/ssl + # # Spec of spec.template.spec.volumes + # volumes: + # - name: kafka-ssl + # secret: + # secretName: kafka-ssl diff --git a/scripts/helmcharts/openreplay/charts/chalice/templates/deployment.yaml b/scripts/helmcharts/openreplay/charts/chalice/templates/deployment.yaml index d2aafc35a..4491a82e4 100644 --- a/scripts/helmcharts/openreplay/charts/chalice/templates/deployment.yaml +++ b/scripts/helmcharts/openreplay/charts/chalice/templates/deployment.yaml @@ -106,8 +106,16 @@ spec: containerPort: {{ $val }} protocol: TCP {{- end }} + {{- with .Values.persistence.mounts }} + volumeMounts: + {{- toYaml . | nindent 12 }} + {{- end }} resources: {{- toYaml .Values.resources | nindent 12 }} + {{- with .Values.persistence.volumes }} + volumes: + {{- toYaml . | nindent 8 }} + {{- 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 99acdbf76..2c9d75040 100644 --- a/scripts/helmcharts/openreplay/charts/chalice/values.yaml +++ b/scripts/helmcharts/openreplay/charts/chalice/values.yaml @@ -120,3 +120,15 @@ healthCheck: initialDelaySeconds: 100 periodSeconds: 15 timeoutSeconds: 10 + + +persistence: {} + # # Spec of spec.template.spec.containers[*].volumeMounts + # mounts: + # - name: kafka-ssl + # mountPath: /opt/kafka/ssl + # # Spec of spec.template.spec.volumes + # volumes: + # - name: kafka-ssl + # secret: + # secretName: kafka-ssl diff --git a/scripts/helmcharts/openreplay/charts/db/templates/deployment.yaml b/scripts/helmcharts/openreplay/charts/db/templates/deployment.yaml index 7afbf0e7d..2c18179df 100644 --- a/scripts/helmcharts/openreplay/charts/db/templates/deployment.yaml +++ b/scripts/helmcharts/openreplay/charts/db/templates/deployment.yaml @@ -62,8 +62,16 @@ spec: containerPort: {{ $val }} protocol: TCP {{- end }} + {{- with .Values.persistence.mounts }} + volumeMounts: + {{- toYaml . | nindent 12 }} + {{- end }} resources: {{- toYaml .Values.resources | nindent 12 }} + {{- with .Values.persistence.volumes }} + volumes: + {{- toYaml . | nindent 8 }} + {{- end }} {{- with .Values.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} diff --git a/scripts/helmcharts/openreplay/charts/db/values.yaml b/scripts/helmcharts/openreplay/charts/db/values.yaml index 0da7ab913..7d375c594 100644 --- a/scripts/helmcharts/openreplay/charts/db/values.yaml +++ b/scripts/helmcharts/openreplay/charts/db/values.yaml @@ -98,3 +98,15 @@ nodeSelector: {} tolerations: [] affinity: {} + + +persistence: {} + # # Spec of spec.template.spec.containers[*].volumeMounts + # mounts: + # - name: kafka-ssl + # mountPath: /opt/kafka/ssl + # # Spec of spec.template.spec.volumes + # volumes: + # - name: kafka-ssl + # secret: + # secretName: kafka-ssl diff --git a/scripts/helmcharts/openreplay/charts/ender/templates/deployment.yaml b/scripts/helmcharts/openreplay/charts/ender/templates/deployment.yaml index 368c3ee29..a313415c8 100644 --- a/scripts/helmcharts/openreplay/charts/ender/templates/deployment.yaml +++ b/scripts/helmcharts/openreplay/charts/ender/templates/deployment.yaml @@ -62,8 +62,16 @@ spec: containerPort: {{ $val }} protocol: TCP {{- end }} + {{- with .Values.persistence.mounts }} + volumeMounts: + {{- toYaml . | nindent 12 }} + {{- end }} resources: {{- toYaml .Values.resources | nindent 12 }} + {{- with .Values.persistence.volumes }} + volumes: + {{- toYaml . | nindent 8 }} + {{- end }} {{- with .Values.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} diff --git a/scripts/helmcharts/openreplay/charts/ender/values.yaml b/scripts/helmcharts/openreplay/charts/ender/values.yaml index 2d3d2b65b..c751680d4 100644 --- a/scripts/helmcharts/openreplay/charts/ender/values.yaml +++ b/scripts/helmcharts/openreplay/charts/ender/values.yaml @@ -97,3 +97,15 @@ nodeSelector: {} tolerations: [] affinity: {} + + +persistence: {} + # # Spec of spec.template.spec.containers[*].volumeMounts + # mounts: + # - name: kafka-ssl + # mountPath: /opt/kafka/ssl + # # Spec of spec.template.spec.volumes + # volumes: + # - name: kafka-ssl + # secret: + # secretName: kafka-ssl diff --git a/scripts/helmcharts/openreplay/charts/heuristics/templates/deployment.yaml b/scripts/helmcharts/openreplay/charts/heuristics/templates/deployment.yaml index 995e8eac2..58059f58d 100644 --- a/scripts/helmcharts/openreplay/charts/heuristics/templates/deployment.yaml +++ b/scripts/helmcharts/openreplay/charts/heuristics/templates/deployment.yaml @@ -60,8 +60,16 @@ spec: containerPort: {{ $val }} protocol: TCP {{- end }} + {{- with .Values.persistence.mounts }} + volumeMounts: + {{- toYaml . | nindent 12 }} + {{- end }} resources: {{- toYaml .Values.resources | nindent 12 }} + {{- with .Values.persistence.volumes }} + volumes: + {{- toYaml . | nindent 8 }} + {{- end }} {{- with .Values.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} diff --git a/scripts/helmcharts/openreplay/charts/heuristics/values.yaml b/scripts/helmcharts/openreplay/charts/heuristics/values.yaml index 12d2346a3..ec8400866 100644 --- a/scripts/helmcharts/openreplay/charts/heuristics/values.yaml +++ b/scripts/helmcharts/openreplay/charts/heuristics/values.yaml @@ -97,3 +97,14 @@ nodeSelector: {} tolerations: [] affinity: {} + +persistence: {} + # # Spec of spec.template.spec.containers[*].volumeMounts + # mounts: + # - name: kafka-ssl + # mountPath: /opt/kafka/ssl + # # Spec of spec.template.spec.volumes + # volumes: + # - name: kafka-ssl + # secret: + # secretName: kafka-ssl diff --git a/scripts/helmcharts/openreplay/charts/http/templates/deployment.yaml b/scripts/helmcharts/openreplay/charts/http/templates/deployment.yaml index 44574d1f8..eaa5d7ed1 100644 --- a/scripts/helmcharts/openreplay/charts/http/templates/deployment.yaml +++ b/scripts/helmcharts/openreplay/charts/http/templates/deployment.yaml @@ -88,8 +88,16 @@ spec: containerPort: {{ $val }} protocol: TCP {{- end }} + {{- with .Values.persistence.mounts }} + volumeMounts: + {{- toYaml . | nindent 12 }} + {{- end }} resources: {{- toYaml .Values.resources | nindent 12 }} + {{- with .Values.persistence.volumes }} + volumes: + {{- toYaml . | nindent 8 }} + {{- end }} {{- with .Values.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} diff --git a/scripts/helmcharts/openreplay/charts/http/values.yaml b/scripts/helmcharts/openreplay/charts/http/values.yaml index 72a8acb7d..7a96d525d 100644 --- a/scripts/helmcharts/openreplay/charts/http/values.yaml +++ b/scripts/helmcharts/openreplay/charts/http/values.yaml @@ -97,3 +97,15 @@ nodeSelector: {} tolerations: [] affinity: {} + + +persistence: {} + # # Spec of spec.template.spec.containers[*].volumeMounts + # mounts: + # - name: kafka-ssl + # mountPath: /opt/kafka/ssl + # # Spec of spec.template.spec.volumes + # volumes: + # - name: kafka-ssl + # secret: + # secretName: kafka-ssl diff --git a/scripts/helmcharts/openreplay/charts/integrations/templates/deployment.yaml b/scripts/helmcharts/openreplay/charts/integrations/templates/deployment.yaml index 5e63e5153..e0f3fff60 100644 --- a/scripts/helmcharts/openreplay/charts/integrations/templates/deployment.yaml +++ b/scripts/helmcharts/openreplay/charts/integrations/templates/deployment.yaml @@ -62,8 +62,16 @@ spec: containerPort: {{ $val }} protocol: TCP {{- end }} + {{- with .Values.persistence.mounts }} + volumeMounts: + {{- toYaml . | nindent 12 }} + {{- end }} resources: {{- toYaml .Values.resources | nindent 12 }} + {{- with .Values.persistence.volumes }} + volumes: + {{- toYaml . | nindent 8 }} + {{- end }} {{- with .Values.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} diff --git a/scripts/helmcharts/openreplay/charts/integrations/values.yaml b/scripts/helmcharts/openreplay/charts/integrations/values.yaml index b9086900b..191ed7047 100644 --- a/scripts/helmcharts/openreplay/charts/integrations/values.yaml +++ b/scripts/helmcharts/openreplay/charts/integrations/values.yaml @@ -98,3 +98,15 @@ nodeSelector: {} tolerations: [] affinity: {} + + +persistence: {} + # # Spec of spec.template.spec.containers[*].volumeMounts + # mounts: + # - name: kafka-ssl + # mountPath: /opt/kafka/ssl + # # Spec of spec.template.spec.volumes + # volumes: + # - name: kafka-ssl + # secret: + # secretName: kafka-ssl diff --git a/scripts/helmcharts/openreplay/charts/peers/templates/deployment.yaml b/scripts/helmcharts/openreplay/charts/peers/templates/deployment.yaml index 6f1a379d8..ac673fd08 100644 --- a/scripts/helmcharts/openreplay/charts/peers/templates/deployment.yaml +++ b/scripts/helmcharts/openreplay/charts/peers/templates/deployment.yaml @@ -54,8 +54,16 @@ spec: containerPort: {{ $val }} protocol: TCP {{- end }} + {{- with .Values.persistence.mounts }} + volumeMounts: + {{- toYaml . | nindent 12 }} + {{- end }} resources: {{- toYaml .Values.resources | nindent 12 }} + {{- with .Values.persistence.volumes }} + volumes: + {{- toYaml . | nindent 8 }} + {{- end }} {{- with .Values.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} diff --git a/scripts/helmcharts/openreplay/charts/peers/values.yaml b/scripts/helmcharts/openreplay/charts/peers/values.yaml index 721c09db9..4643a75a7 100644 --- a/scripts/helmcharts/openreplay/charts/peers/values.yaml +++ b/scripts/helmcharts/openreplay/charts/peers/values.yaml @@ -93,3 +93,15 @@ nodeSelector: {} tolerations: [] affinity: {} + + +persistence: {} + # # Spec of spec.template.spec.containers[*].volumeMounts + # mounts: + # - name: kafka-ssl + # mountPath: /opt/kafka/ssl + # # Spec of spec.template.spec.volumes + # volumes: + # - name: kafka-ssl + # secret: + # secretName: kafka-ssl diff --git a/scripts/helmcharts/openreplay/templates/job.yaml b/scripts/helmcharts/openreplay/templates/job.yaml index 8925b83d8..c0d7f0a45 100644 --- a/scripts/helmcharts/openreplay/templates/job.yaml +++ b/scripts/helmcharts/openreplay/templates/job.yaml @@ -103,6 +103,7 @@ spec: mountPath: /opt/openreplay - name: dbmigrationscript mountPath: /opt/migrations/ + {{- if eq .Values.global.s3.endpoint "http://minio.db.svc.cluster.local:9000" }} - name: minio image: bitnami/minio:2020.10.9-debian-10-r6 env: @@ -128,6 +129,7 @@ spec: mountPath: /opt/openreplay - name: dbmigrationscript mountPath: /opt/migrations/ + {{- end}} {{- if .Values.global.enterpriseEditionLicense }} # Enterprise migration - name: clickhouse diff --git a/tracker/tracker-assist/.eslintrc.cjs b/tracker/tracker-assist/.eslintrc.cjs index 01d5c5bc0..4480aa99f 100644 --- a/tracker/tracker-assist/.eslintrc.cjs +++ b/tracker/tracker-assist/.eslintrc.cjs @@ -24,12 +24,14 @@ 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/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', @@ -38,9 +40,7 @@ module.exports = { '@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', 'semi': ["error", "never"], 'quotes': ["error", "single"], diff --git a/tracker/tracker-assist/package.json b/tracker/tracker-assist/package.json index 4efae850e..5e63e462e 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.5.17", "keywords": [ "WebRTC", "assistance", @@ -34,6 +34,7 @@ "@openreplay/tracker": "^3.5.3" }, "devDependencies": { + "@openreplay/tracker": "file:../tracker", "@typescript-eslint/eslint-plugin": "^5.30.0", "@typescript-eslint/parser": "^5.30.0", "eslint": "^7.8.0", @@ -42,7 +43,6 @@ "husky": "^8.0.1", "lint-staged": "^13.0.3", "prettier": "^2.7.1", - "@openreplay/tracker": "file:../tracker", "replace-in-files-cli": "^1.0.0", "typescript": "^4.6.0-dev.20211126" }, diff --git a/tracker/tracker-assist/src/Assist.ts b/tracker/tracker-assist/src/Assist.ts index eec9a66d7..8d5b6c7fd 100644 --- a/tracker/tracker-assist/src/Assist.ts +++ b/tracker/tracker-assist/src/Assist.ts @@ -1,11 +1,11 @@ /* eslint-disable @typescript-eslint/no-empty-function */ import type { Socket, } from 'socket.io-client' import { connect, } from 'socket.io-client' -import Peer from 'peerjs' +import Peer, { MediaConnection, } from 'peerjs' import type { Properties, } from 'csstype' import { App, } from '@openreplay/tracker' -import RequestLocalStream from './LocalStream.js' +import RequestLocalStream, { LocalStream, } from './LocalStream.js' import RemoteControl from './RemoteControl.js' import CallWindow from './CallWindow.js' import AnnotationCanvas from './AnnotationCanvas.js' @@ -13,7 +13,7 @@ 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 = () => ((()=>Record) | void) @@ -45,7 +45,7 @@ type OptionalCallback = (()=>Record) | void type Agent = { onDisconnect?: OptionalCallback, onControlReleased?: OptionalCallback, - name?: string + //name?: string // } @@ -78,7 +78,7 @@ export default class Assist { ) 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', @@ -97,7 +97,7 @@ export default class Assist { observer && observer.observe(titleNode, { subtree: true, characterData: true, childList: true, }) }) app.attachStopCallback(() => { - if (this.assistDemandedRestart) { return } + if (this.assistDemandedRestart) { return } this.clean() observer && observer.disconnect() }) @@ -111,7 +111,7 @@ export default class Assist { 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) } @@ -119,14 +119,17 @@ 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(), { @@ -134,10 +137,10 @@ export default class Assist { query: { 'peerId': peerID, 'identity': 'session', - 'sessionInfo': JSON.stringify({ + 'sessionInfo': JSON.stringify({ pageTitle: document.title, active: true, - ...this.app.getSessionInfo(), + ...this.app.getSessionInfo(), }), }, transports: ['websocket',], @@ -187,51 +190,65 @@ export default class Assist { 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.start().then(() => { this.assistDemandedRestart = false }).catch(e => app.debug.error(e)) }) 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.start().then(() => { this.assistDemandedRestart = false }).catch(e => app.debug.error(e)) remoteControl.reconnect(ids) }) - let confirmCall:ConfirmWindow | null = null - 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', () => { + 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 = { @@ -244,119 +261,148 @@ export default class Assist { peerOptions['config'] = this.options.config } const peer = this.peer = new Peer(peerID, peerOptions) - // app.debug.log('Peer created: ', peer) - // @ts-ignore + + // @ts-ignore (peerjs typing) 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 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 + // 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) 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) + call.on('stream', (rStream) => { + callUI?.addRemoteStream(rStream) + const onInteraction = () => { // do only if document.hidden ? + callUI?.playRemote() + document.removeEventListener('click', onInteraction) + } + document.addEventListener('click', onInteraction) }) - .catch(e => { - app.debug.warn('Audio mediadevice request error:', e) - initiateCallEnd() + + // 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) }) - }).catch() // in case of Confirm.remove() without any confirmation/decline + + 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) + }) }) } diff --git a/tracker/tracker-assist/src/CallWindow.ts b/tracker/tracker-assist/src/CallWindow.ts index 8804ffa3e..c8f07e0ff 100644 --- a/tracker/tracker-assist/src/CallWindow.ts +++ b/tracker/tracker-assist/src/CallWindow.ts @@ -4,7 +4,7 @@ import attachDND from './dnd.js' 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,9 +16,9 @@ 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', @@ -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,40 +116,46 @@ 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 if (this.vPlaceholder) { 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) { @@ -162,26 +168,27 @@ export default class CallWindow { } 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)) } @@ -195,7 +202,10 @@ export default class CallWindow { } private toggleAudio() { - const enabled = this.localStream?.toggleAudio() || false + let enabled = false + this.localStreams.forEach(stream => { + enabled = stream.toggleAudio() || false + }) this.toggleAudioUI(enabled) } @@ -211,30 +221,32 @@ export default class CallWindow { this.adjustIframeSize() } - private videoRequested = 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 fd7209689..4de359ac9 100644 --- a/tracker/tracker-assist/src/ConfirmWindow/ConfirmWindow.ts +++ b/tracker/tracker-assist/src/ConfirmWindow/ConfirmWindow.ts @@ -110,17 +110,15 @@ export default class ConfirmWindow { this.wrapper = wrapper confirmBtn.onclick = () => { - this._remove() this.resolve(true) } declineBtn.onclick = () => { - this._remove() this.resolve(false) } } private resolve: (result: boolean) => void = () => {}; - private reject: () => void = () => {}; + private reject: (reason: string) => void = () => {}; mount(): Promise { document.body.appendChild(this.wrapper) @@ -135,10 +133,10 @@ export default class ConfirmWindow { if (!this.wrapper.parentElement) { return } - document.body.removeChild(this.wrapper) + this.wrapper.parentElement.removeChild(this.wrapper) } remove() { this._remove() - this.reject() + this.reject('no answer') } } diff --git a/tracker/tracker-assist/src/LocalStream.ts b/tracker/tracker-assist/src/LocalStream.ts index 7f233108a..78c9ccff8 100644 --- a/tracker/tracker-assist/src/LocalStream.ts +++ b/tracker/tracker-assist/src/LocalStream.ts @@ -22,6 +22,7 @@ export default function RequestLocalStream(): Promise { return navigator.mediaDevices.getUserMedia({ audio:true, }) .then(aStream => { const aTrack = aStream.getAudioTracks()[0] + if (!aTrack) { throw new Error('No audio tracks provided') } return new _LocalStream(aTrack) }) @@ -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 a6164e153..afb50a1f1 100644 --- a/tracker/tracker-assist/src/Mouse.ts +++ b/tracker/tracker-assist/src/Mouse.ts @@ -2,7 +2,7 @@ type XY = [number, number] export default class Mouse { - private mouse: HTMLDivElement + private readonly mouse: HTMLDivElement private position: [number,number] = [0,0,] constructor() { this.mouse = document.createElement('div') @@ -52,8 +52,8 @@ 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 readonly resetLastScrEl = () => { this.lastScrEl = null } + private readonly handleWScroll = e => { if (e.target !== this.lastScrEl && this.lastScrEl !== 'window') { this.resetLastScrEl() @@ -111,4 +111,4 @@ export default class Mouse { 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 4cbc785f3..5cad2ac18 100644 --- a/tracker/tracker-assist/src/RemoteControl.ts +++ b/tracker/tracker-assist/src/RemoteControl.ts @@ -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) @@ -56,7 +56,7 @@ export default class RemoteControl { } else { this.releaseControl(id) } - }).catch() + }).catch(e => console.error(e)) } grantControl = (id: string) => { this.agentID = id @@ -99,4 +99,4 @@ export default class RemoteControl { this.focused.innerText = value } } -} \ No newline at end of file +} diff --git a/tracker/tracker-fetch/package.json b/tracker/tracker-fetch/package.json index c13b1a28b..08716fb52 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.5.10-beta.0", "keywords": [ "fetch", "logging", diff --git a/tracker/tracker-fetch/src/index.ts b/tracker/tracker-fetch/src/index.ts index 922913923..7a5ae6016 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, }, 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,7 +82,7 @@ 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 diff --git a/utilities/servers/websocket.js b/utilities/servers/websocket.js index 5636eafcc..037cb74bc 100644 --- a/utilities/servers/websocket.js +++ b/utilities/servers/websocket.js @@ -12,18 +12,12 @@ const { uniqueAutocomplete } = require('../utils/helper'); const { - extractSessionInfo + IDENTITIES, + EVENTS_DEFINITION, + extractSessionInfo, + socketConnexionTimeout } = require('../utils/assistHelper'); 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; @@ -248,26 +242,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)) { @@ -277,13 +272,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); @@ -292,25 +287,29 @@ 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.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]); @@ -319,7 +318,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]); @@ -328,13 +327,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) { @@ -344,13 +343,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/utilities/utils/assistHelper.js b/utilities/utils/assistHelper.js index 63692f4fc..db7a45c0c 100644 --- a/utilities/utils/assistHelper.js +++ b/utilities/utils/assistHelper.js @@ -2,6 +2,20 @@ const uaParser = require('ua-parser-js'); const {geoip} = require('./geoIP'); let debug = process.env.debug === "1" || false; +const IDENTITIES = {agent: 'agent', session: 'session'}; +const EVENTS_DEFINITION = { + listen: {UPDATE_EVENT: "UPDATE_SESSION"} +}; +EVENTS_DEFINITION.emit = { + NEW_AGENT: "NEW_AGENT", + NO_AGENTS: "NO_AGENT", + AGENT_DISCONNECT: "AGENT_DISCONNECTED", + AGENTS_CONNECTED: "AGENTS_CONNECTED", + NO_SESSIONS: "SESSION_DISCONNECTED", + SESSION_ALREADY_CONNECTED: "SESSION_ALREADY_CONNECTED", + SESSION_RECONNECTED: "SESSION_RECONNECTED", + UPDATE_EVENT: EVENTS_DEFINITION.listen.UPDATE_EVENT +}; const BASE_sessionInfo = { "pageTitle": "Page", @@ -55,7 +69,30 @@ const extractSessionInfo = function (socket) { } } +function socketConnexionTimeout(io) { + if (process.env.CLEAR_SOCKET_TIME !== undefined && parseFloat(process.env.CLEAR_SOCKET_TIME) > 0) { + const CLEAR_SOCKET_TIME = parseFloat(process.env.CLEAR_SOCKET_TIME); + console.log(`WS manually disconnecting sockets after ${CLEAR_SOCKET_TIME} min`); + setInterval(async (io) => { + try { + const now = new Date(); + let allSockets = await io.fetchSockets(); + for (let socket of allSockets) { + if (socket._connectedAt !== undefined && ((now - socket._connectedAt) / 1000) / 60 > CLEAR_SOCKET_TIME) { + debug && console.log(`disconnecting ${socket.id} after more than ${CLEAR_SOCKET_TIME} of connexion.`); + socket.disconnect(); + } + } + } catch (e) { + console.error(e); + } + }, 0.5 * 60 * 1000, io); + // }, 2.5 * 60 * 1000, io); + } else { + debug && console.log(`WS no manually disconnecting sockets.`); + } +} module.exports = { - extractSessionInfo + extractSessionInfo, EVENTS_DEFINITION, IDENTITIES, socketConnexionTimeout }; \ No newline at end of file diff --git a/utilities/utils/helper.js b/utilities/utils/helper.js index b012ccf6c..22fcd0fd5 100644 --- a/utilities/utils/helper.js +++ b/utilities/utils/helper.js @@ -174,6 +174,13 @@ const getValue = function (obj, key) { return undefined; } const sortPaginate = function (list, filters) { + if (typeof (list) === "object" && !Array.isArray(list)) { + for (const [key, value] of Object.entries(list)) { + list[key] = sortPaginate(value, filters); + } + return list + } + const total = list.length; list.sort((a, b) => { const tA = getValue(a, "timestamp");