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/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/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/sessions.py b/api/chalicelib/core/sessions.py index ba815d915..1c38aac40 100644 --- a/api/chalicelib/core/sessions.py +++ b/api/chalicelib/core/sessions.py @@ -710,7 +710,7 @@ 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)", 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/pg_client.py b/api/chalicelib/utils/pg_client.py index 014cfaeee..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 and not self.unlimited_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 c99e1dc05..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 diff --git a/api/routers/core.py b/api/routers/core.py index 80dc429cb..5d156fc97 100644 --- a/api/routers/core.py +++ b/api/routers/core.py @@ -221,7 +221,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)} @@ -1138,14 +1138,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 d2f115dd3..d37a56728 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": { diff --git a/api/schemas.py b/api/schemas.py index eae89c513..591d8e905 100644 --- a/api/schemas.py +++ b/api/schemas.py @@ -564,6 +564,8 @@ class _SessionSearchEventRaw(__MixedSearchFilter): assert len(values["source"]) > 0 and isinstance(values["source"][0], int), \ f"source of type int is required for {PerformanceEventType.time_between_events}" else: + assert "source" in values, f"source is required for {values.get('type')}" + assert isinstance(values["source"], list), f"source of type list is required for {values.get('type')}" 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/internal/http/router/handlers-web.go b/backend/internal/http/router/handlers-web.go index 9b0bc1322..e76090112 100644 --- a/backend/internal/http/router/handlers-web.go +++ b/backend/internal/http/router/handlers-web.go @@ -134,6 +134,7 @@ func (e *Router) startSessionHandlerWeb(w http.ResponseWriter, r *http.Request) UserUUID: userUUID, SessionID: strconv.FormatUint(tokenData.ID, 10), BeaconSizeLimit: e.cfg.BeaconSizeLimit, + StartTimestamp: e.services.Flaker.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/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/env.default b/ee/api/env.default index 1629f8f30..0ce3ba237 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 diff --git a/ee/api/routers/core_dynamic.py b/ee/api/routers/core_dynamic.py index 9d09198a6..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": { 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..e8fa64eab 100644 --- a/frontend/app/Router.js +++ b/frontend/app/Router.js @@ -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(); @@ -198,6 +202,9 @@ class Router extends React.Component { {onboarding && } {/* DASHBOARD and Metrics */} + + + 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/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 ? ( + + ) : ( + - ) : ( - - + 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 8cad11ae8..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 e938b391f..5934dfe52 100644 --- a/frontend/app/components/Client/Sites/AddProjectButton/AddUserButton.tsx +++ b/frontend/app/components/Client/Sites/AddProjectButton/AddUserButton.tsx @@ -22,7 +22,7 @@ function AddProjectButton({ isAdmin = false, init = () => {} }: any) { }; return ( - + ); } diff --git a/frontend/app/components/Client/Sites/InstallButton/InstallButton.tsx b/frontend/app/components/Client/Sites/InstallButton/InstallButton.tsx index c6d04f2f4..0fe5fce65 100644 --- a/frontend/app/components/Client/Sites/InstallButton/InstallButton.tsx +++ b/frontend/app/components/Client/Sites/InstallButton/InstallButton.tsx @@ -16,7 +16,7 @@ function InstallButton(props: Props) { ); }; return ( - ); diff --git a/frontend/app/components/Client/Sites/Sites.js b/frontend/app/components/Client/Sites/Sites.js index 4158a57ea..86bbddd66 100644 --- a/frontend/app/components/Client/Sites/Sites.js +++ b/frontend/app/components/Client/Sites/Sites.js @@ -1,7 +1,7 @@ import React from 'react'; import { connect } from 'react-redux'; import withPageTitle from 'HOCs/withPageTitle'; -import { Loader, 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'; @@ -13,6 +13,7 @@ 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)', @@ -74,8 +75,18 @@ class Sites extends React.PureComponent { this.setState({ searchQuery: value })} />
- +
+ + +
No matching results.
+
+ } + size="small" + show={!loading && filteredSites.size === 0} + >
Project Name
Key
@@ -115,6 +126,7 @@ class Sites extends React.PureComponent {
))} +
@@ -130,5 +142,5 @@ function EditButton({ isAdmin, 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) { writeOption(null, { name: 'change', value })} + id="change-dropdown" + /> + + )} + +
+ + + writeQueryOption(null, { name: 'operator', value: value.value }) + } + /> + {unit && ( + <> + + {'test'} + + )} + {!unit && ( + + )} +
+ + +
+ + writeOption(null, { name: 'previousPeriod', value })} + /> +
+ )} + + ); +} + +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..bb8143473 --- /dev/null +++ b/frontend/app/components/Dashboard/components/Alerts/AlertForm/NotifyHooks.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +import { Checkbox } from 'UI'; +import DropdownChips from '../DropdownChips'; + +interface INotifyHooks { + instance: Alert; + onChangeCheck: (e: React.ChangeEvent) => void; + slackChannels: Array; + validateEmail: (value: string) => boolean; + edit: (data: any) => void; + hooks: Array; +} + +function NotifyHooks({ + instance, + onChangeCheck, + slackChannels, + validateEmail, + hooks, + edit, +}: INotifyHooks) { + return ( +
+
+ + + +
+ + {instance.slack && ( +
+ +
+ edit({ slackInput: selected })} + /> +
+
+ )} + + {instance.email && ( +
+ +
+ edit({ emailInput: selected })} + /> +
+
+ )} + + {instance.webhook && ( +
+ + edit({ webhookInput: selected })} + /> +
+ )} +
+ ); +} + +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..b621921f2 --- /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, webhooks: Array) => { + 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; +} + +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 ( +
+
+
+
+
+ +
+
{alert.name}
+
+
+
+
+ {alert.detectionMethod} +
+
+
+ {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' + )} +
+
+
+ {'When the '} + {alert.detectionMethod} + {' of '} + {alert.query.left} + {' is '} + + {alert.query.operator} + {alert.query.right} {alert.metric.unit} + + {' over the past '} + {getThreshold(alert.currentPeriod)} + {alert.detectionMethod === 'change' ? ( + <> + {' compared to the previous '} + {getThreshold(alert.previousPeriod)} + + ) : null} + {', notify me on '} + {getNotifyChannel(alert, webhooks)}. +
+ {alert.description ? ( +
{alert.description}
+ ) : null} +
+ ); +} + +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..2e544399e --- /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 = 20; + +interface Props { + fetchList: () => void; + list: any; + alertsSearch: any; + siteId: string; + webhooks: Array; + 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 ( + + +
+ {alertsSearch !== '' ? 'No matching results' : "You haven't created any alerts yet"} +
+ + } + > +
+
+
Title
+
Type
+
Modified
+
+ + {sliceListPerPage(list, page - 1, pageSize).map((alert: any) => ( + + + + ))} +
+ +
+
+ Showing {Math.min(list.length, pageSize)} out of{' '} + {list.length} Alerts +
+ setPage(page)} + limit={pageSize} + debounceRequest={100} + /> +
+
+ ); +} + +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..547d5fc61 --- /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) => { + setInputValue(value); + debounceUpdate(value); + }; + + return ( +
+ + +
+ ); +} + +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..65c3f2ef8 --- /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 ( +
+
+
+ +
+ +
+ +
+
+
+ + Alerts helps your team stay up to date with the activity on your app. +
+ +
+ ); +} + +// @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 onRemove(val)} outline={true} />; + }; + + return ( +
+ {textFiled ? ( + + ) : ( + +
+ ); +} + +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..c9b98a745 --- /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 ( +
+
+
+ +
+ +
+ +
+
+
+ + A dashboard is a custom visualization using your OpenReplay data. +
+ +
+ ); +} + +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 + onClick: (category: Record) => void + isSelected: boolean + selectedWidgetIds: string[] +} + +const ICONS: Record = { + 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 (
onClick(category)} > -
{category.name}
+
+ {/* @ts-ignore */} + {ICONS[category.name] && } + {category.name} +
{category.description}
{selectedCategoryWidgetsCount > 0 && (
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 ( - - ) +function DashboardViewSelected({ siteId, dashboardId }: { siteId: string; dashboardId: string }) { + return ; } -interface Props { - history: any - match: any +interface Props extends RouteComponentProps { + match: any; } + function DashboardRouter(props: Props) { - const { match: { params: { siteId, dashboardId, metricId } } } = props; - return ( -
- - - - + const { + match: { + params: { siteId, dashboardId }, + }, + history, + } = props; - - - - - - - + return ( +
+ + + + - - - + + + - - - + + + - - - + + + - - - - -
- ); + + + + + + + + + + + + + + + + + + + + + + + +
+
+ ); } 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) { - 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(, { right: true }) - } - - const togglePinned = (dashboard, e) => { - e.stopPropagation(); - dashboardStore.updatePinned(dashboard.dashboardId); - } - - return useObserver(() => ( -
- - <> - - Create - - - } - /> - {dashboardsPicked.map((item: any) => ( - onItemClick(item)} - className="group" - leading = {( -
- {item.isPublic && ( - -
-
- )} - {item.isPinned &&
} - {!item.isPinned && ( - -
togglePinned(item, e)} - > - -
-
- )} -
- )} - /> - ))} -
- {remainingDashboardsCount > 0 && ( -
showModal(, {})} - > - {remainingDashboardsCount} More -
- )} -
-
-
- redirect(withSiteId(metrics(), siteId))} - /> -
-
-
- setShowAlerts(true)} - /> -
-
- )); + return ( +
+ +
+ redirect(withSiteId(dashboard(), siteId))} + /> +
+
+
+ redirect(withSiteId(metrics(), siteId))} + /> +
+
+
+ redirect(withSiteId(alerts(), siteId))} + /> +
+
+ ); } -export default compose( - withRouter, - connect(null, { setShowAlerts }), -)(DashboardSideMenu) as React.FunctionComponent> +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( - , - { right: true } - ); + showModal(, { right: true }); }; - const onAddDashboardClick = () => { - dashboardStore.initDashboard(); - showModal(, { 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 ( - - - - Gather and analyze
important metrics in one - place. -
- - } - size="small" - subtext={ - - } - > -
- setShowEditModal(false)} - focusTitle={focusTitle} - /> -
-
- + setShowEditModal(false)} focusTitle={focusTitle} /> + +
+
+ + {dashboard?.name} + + } + 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 */ +
} > - {dashboard?.name} + - } - onDoubleClick={() => onEdit(true)} - className="mr-3 select-none hover:border-dotted hover:border-b border-gray-medium cursor-pointer" - actionButton={ - - } + } + /> +
+
+
+ dashboardStore.setPeriod(period)} + right={true} />
-
-
- - dashboardStore.setPeriod(period) - } - right={true} - /> -
-
-
- -
+
+
+
-
-

- {dashboard?.description} -

-
- - - dashboardStore.updateKey("showAlertModal", false) - } - />
- +
+ {/* @ts-ignore */} + +

onEdit(false)} + > + {dashboard?.description || 'Describe the purpose of this dashboard'} +

+
+
+ + dashboardStore.updateKey('showAlertModal', false)} /> +
); } - -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..672ccd846 --- /dev/null +++ b/frontend/app/components/Dashboard/components/DashboardWidgetGrid/AddMetric.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { observer } from 'mobx-react-lite'; +import { Button } 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 { + metrics: any[]; + siteId: string; + title: string; + description: string; +} + +function AddMetric({ metrics, history, siteId, title, description }: IProps) { + const { dashboardStore } = useStore(); + const { hideModal } = useModal(); + + 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) => { + 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 ( +
+
+
+
+

{title}

+
{description}
+
+ + + +
+ +
+ {metrics ? metrics.map((metric: any) => ( + dashboardStore.toggleWidgetSelection(metric)} + /> + )) : ( +
No custom metrics created.
+ )} +
+ +
+
+ {'Selected '} + {selectedWidgetIds.length} + {' out of '} + {metrics.length} +
+ +
+
+
+ ); +} + +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..fb1105856 --- /dev/null +++ b/frontend/app/components/Dashboard/components/DashboardWidgetGrid/AddMetricContainer.tsx @@ -0,0 +1,109 @@ +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 ( +
+
+ +
+
+
{title}
+
+ {description} +
+
+
+ ); +} + +function AddMetricContainer({ siteId, isPopup }: any) { + const { showModal } = useModal(); + const [categories, setCategories] = React.useState[]>([]); + const { dashboardStore } = useStore(); + + React.useEffect(() => { + dashboardStore?.fetchTemplates(true).then((cats) => setCategories(cats)); + }, []); + + const onAddCustomMetrics = () => { + dashboardStore.initDashboard(dashboardStore.selectedDashboard); + showModal( + category.name === 'custom')?.widgets} + />, + { right: true } + ); + }; + + const onAddPredefinedMetrics = () => { + dashboardStore.initDashboard(dashboardStore.selectedDashboard); + showModal( + category.name !== 'custom')} + />, + { 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 ( +
+ + +
+ ); +} + +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..8ab3cfeb0 --- /dev/null +++ b/frontend/app/components/Dashboard/components/DashboardWidgetGrid/AddPredefinedMetric.tsx @@ -0,0 +1,138 @@ +import React from 'react'; +import { observer } from 'mobx-react-lite'; +import { Button } 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 { + categories: Record[]; + siteId: string; + title: string; + description: string; +} + +function AddPredefinedMetric({ categories, history, siteId, title, description }: IProps) { + const { dashboardStore } = useStore(); + const { hideModal } = useModal(); + const [activeCategory, setActiveCategory] = React.useState>(); + + const scrollContainer = React.useRef(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) => { + const defaultCategory = categories.filter((category: any) => category.name !== 'custom')[0]; + setActiveCategory(defaultCategory); + }); + }, []); + + 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 ( +
+
+
+
+

{title}

+
{description}
+
+ + +
+ +
+
+
+ {activeCategory && + categories.map((category) => ( + + + + ))} +
+
+ +
+ {activeCategory && + activeCategory.widgets.map((metric: any) => ( + + dashboardStore.toggleWidgetSelection(metric)} + /> + + ))} +
+
+ +
+
+ {'Selected '} + {selectedWidgetIds.length} + {' out of '} + {totalMetricCount} +
+ +
+
+
+ ); +} + +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 Build your dashboard} subtext={ -
-

Metrics helps you visualize trends from sessions captured by OpenReplay

- -
+
} >
@@ -42,6 +41,7 @@ function DashboardWidgetGrid(props: Props) { isWidget={true} /> ))} +
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(() => ( -
-
+
+

Most significant issues identified in this funnel

@@ -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) { show={!loading && filteredIssues.length === 0} title={
- -
No issues found
+ +
No issues found
} > @@ -59,4 +59,4 @@ function FunnelIssuesList(props: RouteComponentProps) { )) } -export default withRouter(FunnelIssuesList) as React.FunctionComponent>; \ No newline at end of file +export default withRouter(FunnelIssuesList) as React.FunctionComponent>; diff --git a/frontend/app/components/Dashboard/components/MetricListItem/MetricListItem.tsx b/frontend/app/components/Dashboard/components/MetricListItem/MetricListItem.tsx index 492a41bd5..094c7a904 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) => ( - - -
-
- -
- {dashboard.name} -
- -
- )) - ); + siteId: string; } function MetricTypeIcon({ type }: any) { - const PopupWrapper = (props: any) => { - return ( - {type}
} - position="top center" - on="hover" - hideOnScroll={true} - > - {props.children} - - ); - } - const getIcon = () => { switch (type) { case 'funnel': @@ -50,45 +23,47 @@ function MetricTypeIcon({ type }: any) { } return ( - -
- + {type}
} + position="top" + arrow + > +
+
-
+ ) } -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 ( -
+
- {/*
- -
*/} - +
{metric.name} - +
- {/*
*/} -
- -
{metric.owner}
-
+
{metric.isPublic ? 'Team' : 'Private'}
-
{metric.lastModified && checkForRecent(metric.lastModified, 'LLL dd, yyyy, hh:mm a')}
+
{metric.lastModified && checkForRecent(metric.lastModified, 'LLL dd, yyyy, hh:mm a')}
); } -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..ef6f8320f 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(() => ( - - -
No data available.
-
- } - > -
-
-
Metric
- {/*
Type
*/} -
Dashboards
-
Owner
-
Visibility
-
Last Modified
-
+ useEffect(() => { + metricStore.updateKey('sessionsPage', 1); + }, []); - {sliceListPerPage(list, metricStore.page - 1, metricStore.pageSize).map((metric: any) => ( - - - - ))} -
+ return useObserver(() => ( + + +
+ {metricsSearch !== '' ? 'No matching results' : "You haven't created any metrics yet"} +
+
+ } + > +
+
+
Title
+
Owner
+
Visibility
+
Last Modified
+
-
- metricStore.updateKey('page', page)} - limit={metricStore.pageSize} - debounceRequest={100} - /> -
- - )); + {sliceListPerPage(list, metricStore.page - 1, metricStore.pageSize).map((metric: any) => ( + + + + ))} +
+ +
+
+ Showing{' '} + {Math.min(list.length, metricStore.pageSize)} out + of {list.length} metrics +
+ metricStore.updateKey('page', page)} + limit={metricStore.pageSize} + debounceRequest={100} + /> +
+ + )); } 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) { @@ -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..47512629f 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(() => ( -
+
- {metricsCount}
-
+
- +
+ + Create custom Metrics to capture key interactions and track KPIs. +
+
)); } 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 } - return + return } if (metricType === 'timeseries') { @@ -179,7 +180,7 @@ function WidgetChart(props: Props) { } return ( - {renderChart()} +
{renderChart()}
); } 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) => 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) {
- + Filter by Series + -
- - Upload Source Maps - and see source code context obtained from stack traces in their original form. - - } - /> +
+
+ +
+ + Upload Source Maps + and see source code context obtained from stack traces in their original form. + + } + /> +
{ filtered.map(e => ( @@ -115,4 +119,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..ab847f3fe 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'; @@ -80,6 +80,7 @@ export default class Fetch extends React.PureComponent { render() { const { listNow } = this.props; const { current, currentIndex, showFetchDetails, filteredList } = this.state; + const hasErrors = filteredList.some((r) => r.status >= 400); return ( -

Fetch

+ Fetch
- - + + + No Data +
+ } + // size="small" + show={filteredList.length === 0} + > + {/* */} + {[ { label: 'Status', diff --git a/frontend/app/components/Session_/GraphQL/GraphQL.js b/frontend/app/components/Session_/GraphQL/GraphQL.js index 2d3a112e4..4b29a497b 100644 --- a/frontend/app/components/Session_/GraphQL/GraphQL.js +++ b/frontend/app/components/Session_/GraphQL/GraphQL.js @@ -85,7 +85,7 @@ export default class GraphQL extends React.PureComponent { /> -

GraphQL

+ GraphQL
{ 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 ( -

Long Tasks

+ Long Tasks
({ - text: tab, - key: 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) { - return ( - { r.type }
} > -
{ r.type }
- - ); +export function renderType(r) { + return ( + {r.type}
}> +
{r.type}
+ + ); } -export function renderName(r) { - return ( - { r.url }
} > -
{ r.name }
- - ); +export function renderName(r) { + return ( + {r.url}
}> +
{r.name}
+ + ); } const renderXHRText = () => ( - - {XHR} - - Use our Fetch plugin - {' to capture HTTP requests and responses, including status codes and bodies.'}
- We also provide support for GraphQL - {' for easy debugging of your queries.'} - - } - className="ml-1" - /> -
+ + {XHR} + + Use our{' '} + + Fetch plugin + + {' to capture HTTP requests and responses, including status codes and bodies.'}
+ We also provide{' '} + + support for GraphQL + + {' for easy debugging of your queries.'} + + } + className="ml-1" + /> +
); function renderSize(r) { - if (r.responseBodySize) return formatBytes(r.responseBodySize); - let triggerText; - let content; - if (r.decodedBodySize == null) { - triggerText = "x"; - content = "Not captured"; - } else { - const headerSize = r.headerSize || 0; - const encodedSize = r.encodedBodySize || 0; - const transferred = headerSize + encodedSize; - const showTransferred = r.headerSize != null; + if (r.responseBodySize) return formatBytes(r.responseBodySize); + let triggerText; + let content; + if (r.decodedBodySize == null) { + triggerText = 'x'; + content = 'Not captured'; + } else { + const headerSize = r.headerSize || 0; + const encodedSize = r.encodedBodySize || 0; + const transferred = headerSize + encodedSize; + const showTransferred = r.headerSize != null; - triggerText = formatBytes(r.decodedBodySize); - content = ( -
    - { showTransferred && -
  • {`${formatBytes( r.encodedBodySize + headerSize )} transfered over network`}
  • - } -
  • {`Resource size: ${formatBytes(r.decodedBodySize)} `}
  • -
+ triggerText = formatBytes(r.decodedBodySize); + content = ( +
    + {showTransferred &&
  • {`${formatBytes(r.encodedBodySize + headerSize)} transfered over network`}
  • } +
  • {`Resource size: ${formatBytes(r.decodedBodySize)} `}
  • +
+ ); + } + + return ( + +
{triggerText}
+
); - } - - return ( - -
{ triggerText }
-
- ); } export function renderDuration(r) { - if (!r.success) return 'x'; + if (!r.success) return 'x'; - const text = `${ Math.floor(r.duration) }ms`; - if (!r.isRed() && !r.isYellow()) return text; + 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 "; - 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 ( - -
{ text }
-
- ); + return ( + +
{text}
+
+ ); } export default class NetworkContent extends React.PureComponent { - state = { - filter: '', - activeTab: ALL, - } + 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 { - location, - resources, - domContentLoadedTime, - loadTime, - domBuildingTime, - fetchPresented, - onRowClick, - isResult = false, - additionalHeight = 0, - resourcesSize, - transferredSize, - time, - 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; + render() { + const { + location, + resources, + domContentLoadedTime, + loadTime, + domBuildingTime, + fetchPresented, + onRowClick, + isResult = false, + additionalHeight = 0, + resourcesSize, + transferredSize, + time, + 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; - const referenceLines = []; - if (domContentLoadedTime != null) { - referenceLines.push({ - time: domContentLoadedTime.time, - color: DOM_LOADED_TIME_COLOR, - }) + 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 + ); + } + + // const resourcesSize = filtered.reduce((sum, { decodedBodySize }) => sum + (decodedBodySize || 0), 0); + // const transferredSize = filtered + // .reduce((sum, { headerSize, encodedBodySize }) => sum + (headerSize || 0) + (encodedBodySize || 0), 0); + + return ( + + + +
+ Network + +
+ +
+ + + + 0} /> + 0} /> + + + + + + + No Data +
+ } + 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, + }, + ]} + + + + + + ); } - 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 - ); - } - - // const resourcesSize = filtered.reduce((sum, { decodedBodySize }) => sum + (decodedBodySize || 0), 0); - // const transferredSize = filtered - // .reduce((sum, { headerSize, encodedBodySize }) => sum + (headerSize || 0) + (encodedBodySize || 0), 0); - - return ( - - - -
- Network - -
- -
- - {/*
*/} - {/* */} - {/*
{ location }
*/} - {/*
*/} - {/*
*/} - - - 0 } - /> - 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, - } - ]} - -
-
-
- ); - } } diff --git a/frontend/app/components/Session_/OverviewPanel/OverviewPanel.tsx b/frontend/app/components/Session_/OverviewPanel/OverviewPanel.tsx new file mode 100644 index 000000000..5582a713f --- /dev/null +++ b/frontend/app/components/Session_/OverviewPanel/OverviewPanel.tsx @@ -0,0 +1,115 @@ +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 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 && ( + + + + X-RAY +
+ +
+
+ + + +
+ + + Select a debug option to visualize on timeline. +
}> + + {selectedFeatures.map((feature: any, index: number) => ( +
+ } + endTime={props.endTime} + /> +
+ ))} + +
+ + + + + ) + ); +} + +export default connect( + (state: any) => ({ + issuesList: state.getIn(['sessions', 'current', 'issues']), + }), + { + toggleBottomBlock, + } +)( + connectPlayer((state: any) => ({ + resourceList: state.resourceList.filter((r: any) => r.isRed() || r.isYellow()), + exceptionsList: state.exceptionsList, + eventsList: state.eventList, + stackEventList: state.stackList, + performanceChartData: state.performanceChartData, + endTime: state.endTime, + // endTime: 30000000, + }))(OverviewPanel) +); + +const Wrapper = React.memo((props: any) => { + return
{props.children}
; +}); diff --git a/frontend/app/components/Session_/OverviewPanel/components/EventRow/EventRow.tsx b/frontend/app/components/Session_/OverviewPanel/components/EventRow/EventRow.tsx new file mode 100644 index 000000000..ee7bcb857 --- /dev/null +++ b/frontend/app/components/Session_/OverviewPanel/components/EventRow/EventRow.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import cn from 'classnames'; +import { getTimelinePosition } from 'App/utils'; +import { connectPlayer } from 'App/player'; +import PerformanceGraph from '../PerformanceGraph'; +interface Props { + list?: any[]; + title: 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 } = 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 ( +
+
{title}
+
+ {isGraph ? ( + + ) : ( + _list.map((item: any, index: number) => { + return ( +
+ {props.renderElement ? props.renderElement(item) : null} +
+ ); + }) + )} +
+
+ ); +}); + +export default EventRow; 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..1f74d20ab --- /dev/null +++ b/frontend/app/components/Session_/OverviewPanel/components/FeatureSelection/FeatureSelection.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { Checkbox } from 'UI'; + +const NETWORK = 'NETWORK'; +const ERRORS = 'ERRORS'; +const EVENTS = 'EVENTS'; +const CLICKRAGE = 'CLICKRAGE'; +const PERFORMANCE = 'PERFORMANCE'; + +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 ( + + {features.map((feature, index) => { + const checked = list.includes(feature); + const _disabled = disabled && !checked; + return ( + { + if (checked) { + props.updateList(list.filter((item: any) => item !== feature)); + } else { + props.updateList([...list, feature]); + } + }} + /> + ); + })} + + ); +} + +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 ( +
setMouseIn(true)} + // onMouseOut={() => setMouseIn(false)} + > + {mouseIn && } +
{props.children}
+
+ ); +}); + +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 ( + + + + + + + + + {/* */} + + + + ); +}); + +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 ; + case DATADOG: + return ; + case STACKDRIVER: + return ; + default: + return ; + } + }; + return ( +
+ {renderPopupContent()} +
+ ); +} + +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 ( +
+ +

{title}

+ +
+ ); + } +} 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: ( + + {filename} + {' in '} + {method} + {' at line '} + {lineNo} + + ), + }, + content: { + content: ( +
    + {context.map(([ctxLineNo, codeText]) => ( +
  1. {codeText}
  2. + ))} +
+ ), + }, + })); + } + + 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 ; + } + return stacktraces.map(({ type, value, stacktrace }) => ( +
+
{type}
+

{value}

+ +
+ )); + } + + render() { + const { open, toggleOpen, loading } = this.props; + return ( +
+ + {this.renderBody()} +
+ ); + } +} 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(, { right: true }); + } + + if (type === 'EVENT') { + showModal(, { right: true }); + } + // props.toggleBottomBlock(type); + }; + + const renderNetworkElement = (item: any) => { + return ( + + {item.success ? 'Slow resource: ' : 'Missing resource:'} +
+ {item.name} +
+ } + delay={0} + position="top" + > +
+
+
+ + ); + }; + + const renderClickRageElement = (item: any) => { + return ( + + {'Click Rage'} +
+ } + delay={0} + position="top" + > +
+ +
+ + ); + }; + + const renderStackEventElement = (item: any) => { + return ( + + {'Stack Event'} +
+ } + delay={0} + position="top" + > +
+ {/* */} +
+ + ); + }; + + const renderPerformanceElement = (item: any) => { + return ( + + {item.type} +
+ } + delay={0} + position="top" + > +
+ {/* */} +
+ + ); + }; + + const renderExceptionElement = (item: any) => { + return ( + + {'Exception'} +
+ {item.message} +
+ } + delay={0} + position="top" + > +
+ +
+ + ); + }; + + 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
{render()}
; +}); + +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(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 ( +
+ {/*
*/} +
+ ); +} + +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
; +} + +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 ; +} + +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 = memo(function Box({ preview }) { +export const Circle: FC = memo(function Box({ preview, isGreen }) { return (
) }) -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" > - +
- ) - } + ); + }; - controlIcon = (icon, size, action, isBackwards, additionalClasses) => + controlIcon = (icon, size, action, isBackwards, additionalClasses) => (
- + onClick={action} + className={cn('py-1 px-2 hover-main cursor-pointer', additionalClasses)} + style={{ transform: isBackwards ? 'rotate(180deg)' : '' }} + > +
+ ); 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 ( -
- { !live && } - { !fullscreen && -
-
- { !live && ( -
- { this.renderPlayBtn() } - { !live && ( -
- - / - -
- )} - -
- - {this.controlIcon("skip-forward-fill", 18, this.backTenSeconds, true, 'hover:bg-active-blue-border color-main h-full flex items-center')} - -
10s
- - {this.controlIcon("skip-forward-fill", 18, this.forthTenSeconds, false, 'hover:bg-active-blue-border color-main h-full flex items-center')} - -
- - {!live && -
- - - - - -
- } -
+
+ {!live || liveTimeTravel ? ( + + ) : null} + {!fullscreen && ( +
+
+ {!live && ( + <> + + {/* */} +
+ toggleBottomTools(OVERVIEW)} + /> + )} - { live && !closedLive && ( -
- - {'Elapsed'} - + {live && !closedLive && ( +
+ (livePlay ? null : jumpToLive())} /> +
+ +
+ + {!liveTimeTravel && ( +
+ See Past Activity +
+ )}
)}
- { !live &&
} + {/* { !live &&
} */} {/* ! TEMP DISABLED ! {!live && ( )} */} + {/* toggleBottomTools(OVERVIEW) } + active={ bottomBlock === OVERVIEW && !inspectorMode} + label="OVERVIEW" + noIcon + labelClassName="!text-base font-semibold" + // count={ logCount } + // hasErrors={ logRedCount > 0 } + containerClassName="mx-2" + /> */} 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 && ( 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 && ( 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 && ( 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 && ( 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 && ( 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 && ( 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 && ( 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 && ( 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 &&
} - { !live && ( - - {this.controlIcon("arrows-angle-extend", 18, this.props.fullscreenOn, false, "rounded hover:bg-gray-light-shade color-gray-medium")} + )} + {!live &&
} + {!live && ( + + {this.controlIcon( + 'arrows-angle-extend', + 18, + this.props.fullscreenOn, + false, + 'rounded hover:bg-gray-light-shade color-gray-medium' + )} - ) - } + )}
- } + )}
); } 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 = 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 = 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 = memo(function DraggableCircle(props) { style={getStyles(left, isDragging)} role="DraggableBox" > - + 99 && live} />
); }) -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', }) => (
@@ -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 ( +
+ {!time ? 'Loading' : duration} +
+ ); +} + +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 ( +
+
+ + {/* custo color is live */} + + + + + {skip && + skipIntervals.map((interval) => ( +
+ ))} +
+ + {events.map((e) => ( +
+ ))} + {/* {issues.map((iss) => ( +
+ + {iss.name} +
+ } + > + + +
+ ))} + {events + .filter((e) => e.type === TYPES.CLICKRAGE) + .map((e) => ( +
+ + {'Click Rage'} +
+ } + > + + +
+ ))} + {typeof clickRageTime === 'number' && ( +
+ + {'Click Rage'} +
+ } + > + + +
+ )} + {exceptionsList.map((e) => ( +
+ + {'Exception'} +
+ {e.message} +
+ } + > + + +
+ ))} + {resourceList + .filter((r) => r.isRed() || r.isYellow()) + .map((r) => ( +
+ + {r.success ? 'Slow resource: ' : 'Missing resource:'} +
+ {r.name} +
+ } + > + + +
+ ))} + {fetchList + .filter((e) => e.isRed()) + .map((e) => ( +
+ + Failed Fetch +
+ {e.name} +
+ } + > + + +
+ ))} + {stackList + .filter((e) => e.isRed()) + .map((e) => ( +
+ + Stack Event +
+ {e.name} +
+ } + > + + +
+ ))} */} +
+
+ ); } - } - - 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 ( -
-
- - - - { skip && skipIntervals.map(interval => - (
)) - } -
- { events.map(e => ( -
- )) - } - { - issues.map(iss => ( -
- - { iss.name } -
- } - > - - -
- )) - } - { events.filter(e => e.type === TYPES.CLICKRAGE).map(e => ( -
- - { "Click Rage" } -
- } - > - - -
- ))} - {typeof clickRageTime === 'number' && -
- - { "Click Rage" } -
- } - > - - -
- } - { exceptionsList - .map(e => ( -
- - { "Exception" } -
- { e.message } -
- } - > - - -
- )) - } - { resourceList - .filter(r => r.isRed() || r.isYellow()) - .map(r => ( -
- - { r.success ? "Slow resource: " : "Missing resource:" } -
- { r.name } -
- } - > - - -
- )) - } - { fetchList - .filter(e => e.isRed()) - .map(e => ( -
- - Failed Fetch -
- { e.name } -
- } - > - - -
- )) - } - { stackList - .filter(e => e.isRed()) - .map(e => ( -
- - Stack Event -
- { e.name } -
- } - > - - -
- )) - } -
-
- ); - } } 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; + 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 ( +
+ {playButton} + {!live && ( +
+ {/* @ts-ignore */} + + / + {/* @ts-ignore */} + +
+ )} + +
+ {/* @ts-ignore */} + + + +
+ showTooltip ? toggleTooltip() : null}> +
+
+ Jump (Secs) +
+ {Object.keys(skipIntervals).map((interval) => ( +
{ + 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} + s +
+ ))} +
+ + } + > +
+ {/* @ts-ignore */} + + {currentInterval}s + +
+
+
+ {/* @ts-ignore */} + + + +
+ + {!live && ( +
+ {/* @ts-ignore */} + + + + + +
+ )} +
+ ); +} + +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 ( + + + + ) +} + +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 && } - { messagesLoading && } + { messagesLoading && } { showPlayIconLayer && } @@ -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 (
@@ -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/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 {
{ !fullscreen && !!bottomBlock &&
+ { bottomBlock === OVERVIEW && + + } { bottomBlock === 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 ( -
+
{!fullscreen && metaList.includes(i)) .map((key) => { @@ -142,7 +142,7 @@ export default class PlayerBlockHeader extends React.PureComponent {
)} - {isAssist && } + {isAssist && }
{!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 { /> -

Profiler

+
+ Profiler +
60 ? `${props.currentLocation.slice(0, 60)}...` : props.currentLocation return ( @@ -39,37 +38,39 @@ function SubHeader(props) {
)} -
-
- {!isAssist && props.jiraConfig && props.jiraConfig.token && } + {!isAssist ? ( +
+
+ {props.jiraConfig && props.jiraConfig.token && } +
+
+ + + Share +
+ } + /> +
+
+ +
+
+ +
+
+
-
- - - Share -
- } - /> -
-
- -
-
- -
-
-
-
+ ) : null}
) } diff --git a/frontend/app/components/Session_/TimeTable/TimeTable.tsx b/frontend/app/components/Session_/TimeTable/TimeTable.tsx index 4a6f1140e..04cdf39d5 100644 --- a/frontend/app/components/Session_/TimeTable/TimeTable.tsx +++ b/frontend/app/components/Session_/TimeTable/TimeTable.tsx @@ -118,7 +118,9 @@ export default class TimeTable extends React.PureComponent { autoScroll = true; componentDidMount() { - this.scroller.current.scrollToRow(this.props.activeIndex); + if (this.scroller.current) { + this.scroller.current.scrollToRow(this.props.activeIndex); + } } componentDidUpdate(prevProps: any, prevState: any) { @@ -135,7 +137,7 @@ export default class TimeTable extends React.PureComponent { ...computeTimeLine(this.props.rows, this.state.firstVisibleRowIndex, this.visibleCount), }); } - if (this.props.activeIndex >= 0 && prevProps.activeIndex !== this.props.activeIndex) { + if (this.props.activeIndex >= 0 && prevProps.activeIndex !== this.props.activeIndex && this.scroller.current) { this.scroller.current.scrollToRow(this.props.activeIndex); } } @@ -227,8 +229,24 @@ export default class TimeTable extends React.PureComponent {
{navigation && (
-
- +
{timeColumns.map((_, index) => ( 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 && (