Merge pull request #346 from openreplay/dev

chore (release): v1.5.1
This commit is contained in:
Mehdi Osman 2022-02-23 14:48:30 +01:00 committed by GitHub
commit a9489ab63d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
107 changed files with 1150 additions and 455 deletions

View file

@ -1,4 +1,5 @@
from abc import ABC, abstractmethod
from chalicelib.utils import pg_client, helper
@ -37,7 +38,7 @@ class BaseIntegration(ABC):
pass
@abstractmethod
def update(self, changes):
def update(self, changes, obfuscate=False):
pass
@abstractmethod

View file

@ -1,6 +1,6 @@
from chalicelib.utils import pg_client, helper
from chalicelib.core.integration_github_issue import GithubIntegrationIssue
from chalicelib.core import integration_base
from chalicelib.core.integration_github_issue import GithubIntegrationIssue
from chalicelib.utils import pg_client, helper
PROVIDER = "GITHUB"
@ -15,8 +15,6 @@ class GitHubIntegration(integration_base.BaseIntegration):
def provider(self):
return PROVIDER
def get_obfuscated(self):
integration = self.get()
if integration is None:
@ -24,7 +22,7 @@ class GitHubIntegration(integration_base.BaseIntegration):
token = "*" * (len(integration["token"]) - 4) + integration["token"][-4:]
return {"token": token, "provider": self.provider.lower()}
def update(self, changes):
def update(self, changes, obfuscate=False):
with pg_client.PostgresClient() as cur:
sub_query = [f"{helper.key_to_snake_case(k)} = %({k})s" for k in changes.keys()]
cur.execute(
@ -71,8 +69,11 @@ class GitHubIntegration(integration_base.BaseIntegration):
if s is not None:
return self.update(
changes={
"token": data["token"]
}
"token": data["token"] \
if data.get("token") and len(data["token"]) > 0 and data["token"].find("***") == -1 \
else s["token"]
},
obfuscate=True
)
else:
return self.add(token=data["token"])

View file

@ -1,10 +1,14 @@
from chalicelib.utils import pg_client, helper
from chalicelib.core.integration_jira_cloud_issue import JIRACloudIntegrationIssue
from chalicelib.core import integration_base
from chalicelib.core.integration_jira_cloud_issue import JIRACloudIntegrationIssue
from chalicelib.utils import pg_client, helper
PROVIDER = "JIRA"
def obfuscate_string(string):
return "*" * (len(string) - 4) + string[-4:]
class JIRAIntegration(integration_base.BaseIntegration):
def __init__(self, tenant_id, user_id):
self.__tenant_id = tenant_id
@ -36,11 +40,11 @@ class JIRAIntegration(integration_base.BaseIntegration):
integration = self.get()
if integration is None:
return None
integration["token"] = "*" * (len(integration["token"]) - 4) + integration["token"][-4:]
integration["token"] = obfuscate_string(integration["token"])
integration["provider"] = self.provider.lower()
return integration
def update(self, changes):
def update(self, changes, obfuscate=False):
with pg_client.PostgresClient() as cur:
sub_query = [f"{helper.key_to_snake_case(k)} = %({k})s" for k in changes.keys()]
cur.execute(
@ -53,6 +57,8 @@ class JIRAIntegration(integration_base.BaseIntegration):
**changes})
)
w = helper.dict_to_camel_case(cur.fetchone())
if obfuscate:
w["token"] = obfuscate_string(w["token"])
return w
# TODO: make this generic for all issue tracking integrations
@ -89,9 +95,12 @@ class JIRAIntegration(integration_base.BaseIntegration):
return self.update(
changes={
"username": data["username"],
"token": data["token"],
"token": data["token"] \
if data.get("token") and len(data["token"]) > 0 and data["token"].find("***") == -1 \
else s["token"],
"url": data["url"]
}
},
obfuscate=True
)
else:
return self.add(

View file

@ -49,16 +49,6 @@ def update(search_id, project_id, user_id, data: schemas.SavedSearchSchema):
def get_all(project_id, user_id, details=False):
with pg_client.PostgresClient() as cur:
print(cur.mogrify(
f"""\
SELECT search_id, project_id, user_id, name, created_at, deleted_at, is_public
{",filter" if details else ""}
FROM public.searches
WHERE project_id = %(project_id)s
AND deleted_at IS NULL
AND (user_id = %(user_id)s OR is_public);""",
{"project_id": project_id, "user_id": user_id}
))
cur.execute(
cur.mogrify(
f"""\

View file

@ -7,7 +7,6 @@ SESSION_PROJECTION_COLS = """s.project_id,
s.session_id::text AS session_id,
s.user_uuid,
s.user_id,
-- s.user_agent,
s.user_os,
s.user_browser,
s.user_device,
@ -30,10 +29,10 @@ COALESCE((SELECT TRUE
def __group_metadata(session, project_metadata):
meta = []
meta = {}
for m in project_metadata.keys():
if project_metadata[m] is not None and session.get(m) is not None:
meta.append({project_metadata[m]: session[m]})
meta[project_metadata[m]] = session[m]
session.pop(m)
return meta
@ -162,12 +161,16 @@ def _isAny_opreator(op: schemas.SearchEventOperator):
return op in [schemas.SearchEventOperator._on_any, schemas.SearchEventOperator._is_any]
def _isUndefined_operator(op: schemas.SearchEventOperator):
return op in [schemas.SearchEventOperator._is_undefined]
@dev.timed
def search2_pg(data: schemas.SessionsSearchPayloadSchema, project_id, user_id, favorite_only=False, errors_only=False,
error_status="ALL", count_only=False, issue=None):
full_args, query_part, sort = search_query_parts(data, error_status, errors_only, favorite_only, issue, project_id,
user_id)
meta_keys = []
with pg_client.PostgresClient() as cur:
if errors_only:
main_query = cur.mogrify(f"""SELECT DISTINCT er.error_id, ser.status, ser.parent_error_id, ser.payload,
@ -186,13 +189,16 @@ def search2_pg(data: schemas.SessionsSearchPayloadSchema, project_id, user_id, f
COUNT(DISTINCT s.user_uuid) AS count_users
{query_part};""", full_args)
elif data.group_by_user:
main_query = cur.mogrify(f"""SELECT COUNT(*) AS count, jsonb_agg(users_sessions) FILTER ( WHERE rn <= 200 ) AS sessions
meta_keys = metadata.get(project_id=project_id)
main_query = cur.mogrify(f"""SELECT COUNT(*) AS count, COALESCE(JSONB_AGG(users_sessions) FILTER ( WHERE rn <= 200 ), '[]'::JSONB) AS sessions
FROM (SELECT user_id,
count(full_sessions) AS user_sessions_count,
jsonb_agg(full_sessions) FILTER (WHERE rn <= 1) AS last_session,
MIN(full_sessions.start_ts) AS first_session_ts,
ROW_NUMBER() OVER (ORDER BY count(full_sessions) DESC) AS rn
FROM (SELECT *, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY start_ts DESC) AS rn
FROM (SELECT DISTINCT ON(s.session_id) {SESSION_PROJECTION_COLS}
FROM (SELECT DISTINCT ON(s.session_id) {SESSION_PROJECTION_COLS},
{",".join([f'metadata_{m["index"]}' for m in meta_keys])}
{query_part}
ORDER BY s.session_id desc) AS filtred_sessions
ORDER BY favorite DESC, issue_score DESC, {sort} {data.order}) AS full_sessions
@ -200,9 +206,11 @@ def search2_pg(data: schemas.SessionsSearchPayloadSchema, project_id, user_id, f
ORDER BY user_sessions_count DESC) AS users_sessions;""",
full_args)
else:
meta_keys = metadata.get(project_id=project_id)
main_query = cur.mogrify(f"""SELECT COUNT(full_sessions) AS count, COALESCE(JSONB_AGG(full_sessions) FILTER (WHERE rn <= 200), '[]'::JSONB) AS sessions
FROM (SELECT *, ROW_NUMBER() OVER (ORDER BY favorite DESC, issue_score DESC, session_id desc, start_ts desc) AS rn
FROM (SELECT DISTINCT ON(s.session_id) {SESSION_PROJECTION_COLS}
FROM (SELECT DISTINCT ON(s.session_id) {SESSION_PROJECTION_COLS},
{",".join([f'metadata_{m["index"]}' for m in meta_keys])}
{query_part}
ORDER BY s.session_id desc) AS filtred_sessions
ORDER BY favorite DESC, issue_score DESC, {sort} {data.order}) AS full_sessions;""",
@ -237,6 +245,16 @@ def search2_pg(data: schemas.SessionsSearchPayloadSchema, project_id, user_id, f
if errors_only:
return sessions
if data.group_by_user:
for i, s in enumerate(sessions):
sessions[i] = {**s.pop("last_session")[0], **s}
sessions[i].pop("rn")
sessions[i]["metadata"] = {k["key"]: sessions[i][f'metadata_{k["index"]}'] for k in meta_keys \
if sessions[i][f'metadata_{k["index"]}'] is not None}
else:
for i, s in enumerate(sessions):
sessions[i]["metadata"] = {k["key"]: sessions[i][f'metadata_{k["index"]}'] for k in meta_keys \
if sessions[i][f'metadata_{k["index"]}'] is not None}
if not data.group_by_user and data.sort is not None and data.sort != "session_id":
sessions = sorted(sessions, key=lambda s: s[helper.key_to_snake_case(data.sort)],
reverse=data.order.upper() == "DESC")
@ -250,7 +268,7 @@ def search2_pg(data: schemas.SessionsSearchPayloadSchema, project_id, user_id, f
def search2_series(data: schemas.SessionsSearchPayloadSchema, project_id: int, density: int,
view_type: schemas.MetricViewType):
step_size = int(metrics_helper.__get_step_size(endTimestamp=data.endDate, startTimestamp=data.startDate,
density=density, factor=1, decimal=True))
density=density, factor=1, decimal=True))
full_args, query_part, sort = search_query_parts(data=data, error_status=None, errors_only=False,
favorite_only=False, issue=None, project_id=project_id,
user_id=None)
@ -310,7 +328,8 @@ def search_query_parts(data, error_status, errors_only, favorite_only, issue, pr
op = __get_sql_operator(f.operator) \
if filter_type not in [schemas.FilterType.events_count] else f.operator
is_any = _isAny_opreator(f.operator)
if not is_any and len(f.value) == 0:
is_undefined = _isUndefined_operator(f.operator)
if not is_any and not is_undefined and len(f.value) == 0:
continue
is_not = False
if __is_negation_operator(f.operator):
@ -359,6 +378,9 @@ def search_query_parts(data, error_status, errors_only, favorite_only, issue, pr
if is_any:
extra_constraints.append('s.utm_source IS NOT NULL')
ss_constraints.append('ms.utm_source IS NOT NULL')
elif is_undefined:
extra_constraints.append('s.utm_source IS NULL')
ss_constraints.append('ms.utm_source IS NULL')
else:
extra_constraints.append(
_multiple_conditions(f's.utm_source {op} %({f_k})s::text', f.value, is_not=is_not,
@ -370,6 +392,9 @@ def search_query_parts(data, error_status, errors_only, favorite_only, issue, pr
if is_any:
extra_constraints.append('s.utm_medium IS NOT NULL')
ss_constraints.append('ms.utm_medium IS NOT NULL')
elif is_undefined:
extra_constraints.append('s.utm_medium IS NULL')
ss_constraints.append('ms.utm_medium IS NULL')
else:
extra_constraints.append(
_multiple_conditions(f's.utm_medium {op} %({f_k})s::text', f.value, is_not=is_not,
@ -381,6 +406,9 @@ def search_query_parts(data, error_status, errors_only, favorite_only, issue, pr
if is_any:
extra_constraints.append('s.utm_campaign IS NOT NULL')
ss_constraints.append('ms.utm_campaign IS NOT NULL')
elif is_undefined:
extra_constraints.append('s.utm_campaign IS NULL')
ss_constraints.append('ms.utm_campaign IS NULL')
else:
extra_constraints.append(
_multiple_conditions(f's.utm_campaign {op} %({f_k})s::text', f.value, is_not=is_not,
@ -414,6 +442,9 @@ def search_query_parts(data, error_status, errors_only, favorite_only, issue, pr
if is_any:
extra_constraints.append(f"s.{metadata.index_to_colname(meta_keys[f.source])} IS NOT NULL")
ss_constraints.append(f"ms.{metadata.index_to_colname(meta_keys[f.source])} IS NOT NULL")
elif is_undefined:
extra_constraints.append(f"s.{metadata.index_to_colname(meta_keys[f.source])} IS NULL")
ss_constraints.append(f"ms.{metadata.index_to_colname(meta_keys[f.source])} IS NULL")
else:
extra_constraints.append(
_multiple_conditions(
@ -427,6 +458,9 @@ def search_query_parts(data, error_status, errors_only, favorite_only, issue, pr
if is_any:
extra_constraints.append('s.user_id IS NOT NULL')
ss_constraints.append('ms.user_id IS NOT NULL')
elif is_undefined:
extra_constraints.append('s.user_id IS NULL')
ss_constraints.append('ms.user_id IS NULL')
else:
extra_constraints.append(
_multiple_conditions(f"s.user_id {op} %({f_k})s::text", f.value, is_not=is_not, value_key=f_k))
@ -437,6 +471,9 @@ def search_query_parts(data, error_status, errors_only, favorite_only, issue, pr
if is_any:
extra_constraints.append('s.user_anonymous_id IS NOT NULL')
ss_constraints.append('ms.user_anonymous_id IS NOT NULL')
elif is_undefined:
extra_constraints.append('s.user_anonymous_id IS NULL')
ss_constraints.append('ms.user_anonymous_id IS NULL')
else:
extra_constraints.append(
_multiple_conditions(f"s.user_anonymous_id {op} %({f_k})s::text", f.value, is_not=is_not,
@ -448,6 +485,9 @@ def search_query_parts(data, error_status, errors_only, favorite_only, issue, pr
if is_any:
extra_constraints.append('s.rev_id IS NOT NULL')
ss_constraints.append('ms.rev_id IS NOT NULL')
elif is_undefined:
extra_constraints.append('s.rev_id IS NULL')
ss_constraints.append('ms.rev_id IS NULL')
else:
extra_constraints.append(
_multiple_conditions(f"s.rev_id {op} %({f_k})s::text", f.value, is_not=is_not, value_key=f_k))
@ -945,7 +985,6 @@ def get_favorite_sessions(project_id, user_id, include_viewed=False):
s.session_id::text AS session_id,
s.user_uuid,
s.user_id,
-- s.user_agent,
s.user_os,
s.user_browser,
s.user_device,
@ -982,7 +1021,6 @@ def get_user_sessions(project_id, user_id, start_date, end_date):
s.session_id::text AS session_id,
s.user_uuid,
s.user_id,
-- s.user_agent,
s.user_os,
s.user_browser,
s.user_device,

View file

@ -1,8 +1,9 @@
from jira import JIRA
from jira.exceptions import JIRAError
import time
from datetime import datetime
import requests
from jira import JIRA
from jira.exceptions import JIRAError
from requests.auth import HTTPBasicAuth
fields = "id, summary, description, creator, reporter, created, assignee, status, updated, comment, issuetype, labels"
@ -15,7 +16,11 @@ class JiraManager:
def __init__(self, url, username, password, project_id=None):
self._config = {"JIRA_PROJECT_ID": project_id, "JIRA_URL": url, "JIRA_USERNAME": username,
"JIRA_PASSWORD": password}
self._jira = JIRA({'server': url}, basic_auth=(username, password), logging=True)
try:
self._jira = JIRA({'server': url}, basic_auth=(username, password), logging=True, max_retries=1)
except Exception as e:
print("!!! JIRA AUTH ERROR")
print(e)
def set_jira_project_id(self, project_id):
self._config["JIRA_PROJECT_ID"] = project_id

View file

@ -8,8 +8,8 @@ jira==2.0.0
fastapi==0.70.1
uvicorn[standard]==0.16.0
python-decouple==3.5
fastapi==0.74.1
uvicorn[standard]==0.17.5
python-decouple==3.6
pydantic[email]==1.8.2
apscheduler==3.8.1

View file

@ -29,7 +29,9 @@ def get_favorite_sessions(projectId: int, context: schemas.CurrentContext = Depe
@app.get('/{projectId}/sessions2/{sessionId}', tags=["sessions"])
def get_session2(projectId: int, sessionId: int, context: schemas.CurrentContext = Depends(OR_context)):
def get_session2(projectId: int, sessionId: Union[int, str], context: schemas.CurrentContext = Depends(OR_context)):
if isinstance(sessionId, str):
return {"errors": ["session not found"]}
data = sessions.get_by_id2_pg(project_id=projectId, session_id=sessionId, full_data=True, user_id=context.user_id,
include_fav_viewed=True, group_metadata=True)
if data is None:

View file

@ -1,7 +1,7 @@
from enum import Enum
from typing import Optional, List, Union, Literal
from pydantic import BaseModel, Field, EmailStr, HttpUrl, root_validator
from pydantic import BaseModel, Field, EmailStr, HttpUrl, root_validator, validator
from chalicelib.utils.TimeUTC import TimeUTC
@ -107,7 +107,11 @@ class JiraGithubSchema(BaseModel):
provider: str = Field(...)
username: str = Field(...)
token: str = Field(...)
url: str = Field(...)
url: HttpUrl = Field(...)
@validator('url')
def transform_url(cls, v: HttpUrl):
return HttpUrl.build(scheme=v.scheme, host=v.host)
class CreateEditWebhookSchema(BaseModel):
@ -435,6 +439,7 @@ class SearchEventOperator(str, Enum):
_on = "on"
_on_any = "onAny"
_is_not = "isNot"
_is_undefined = "isUndefined"
_not_on = "notOn"
_contains = "contains"
_not_contains = "notContains"

View file

@ -65,7 +65,8 @@ func (c *PGCache) InsertMetadata(sessionID uint64, metadata *Metadata) error {
keyNo := project.GetMetadataNo(metadata.Key)
if keyNo == 0 {
// insert project metadata
// TODO: insert project metadata
return nil
}
if err := c.Conn.InsertMetadata(sessionID, keyNo, metadata.Value); err != nil {
return err

View file

@ -29,7 +29,7 @@ func (c *PGCache) InsertWebSessionStart(sessionID uint64, s *SessionStart) error
UserDeviceType: s.UserDeviceType,
UserDeviceMemorySize: s.UserDeviceMemorySize,
UserDeviceHeapSize: s.UserDeviceHeapSize,
UserID: &s.UserID,
UserID: &s.UserID,
}
if err := c.Conn.InsertSessionStart(sessionID, c.sessions[sessionID]); err != nil {
c.sessions[sessionID] = nil

View file

@ -15,7 +15,8 @@ func getTimeoutContext() context.Context {
}
type Conn struct {
c *pgxpool.Pool // TODO: conditional usage of Pool/Conn (use interface?)
c *pgxpool.Pool // TODO: conditional usage of Pool/Conn (use interface?)
batches map[uint64]*pgx.Batch
}
func NewConn(url string) *Conn {
@ -24,7 +25,8 @@ func NewConn(url string) *Conn {
log.Println(err)
log.Fatalln("pgxpool.Connect Error")
}
return &Conn{c}
batches := make(map[uint64]*pgx.Batch)
return &Conn{c, batches}
}
func (conn *Conn) Close() error {
@ -32,6 +34,31 @@ func (conn *Conn) Close() error {
return nil
}
func (conn *Conn) batchQueue(sessionID uint64, sql string, args ...interface{}) error {
batch, ok := conn.batches[sessionID]
if !ok {
conn.batches[sessionID] = &pgx.Batch{}
batch = conn.batches[sessionID]
}
batch.Queue(sql, args...)
return nil
}
func (conn *Conn) CommitBatches() {
for _, b := range conn.batches {
br := conn.c.SendBatch(getTimeoutContext(), b)
l := b.Len()
for i := 0; i < l; i++ {
if ct, err := br.Exec(); err != nil {
// TODO: ct info
log.Printf("Error in PG batch (command tag %v): %v \n", ct.String(), err)
}
}
br.Close() // returns err
}
conn.batches = make(map[uint64]*pgx.Batch)
}
func (conn *Conn) query(sql string, args ...interface{}) (pgx.Rows, error) {
return conn.c.Query(getTimeoutContext(), sql, args...)
}
@ -56,7 +83,7 @@ func (conn *Conn) begin() (_Tx, error) {
func (tx _Tx) exec(sql string, args ...interface{}) error {
_, err := tx.Exec(context.Background(), sql, args...)
return err;
return err
}
func (tx _Tx) rollback() error {
@ -66,5 +93,3 @@ func (tx _Tx) rollback() error {
func (tx _Tx) commit() error {
return tx.Commit(context.Background())
}

View file

@ -1,13 +1,13 @@
package postgres
import (
"fmt"
"log"
"strings"
"fmt"
"openreplay/backend/pkg/db/types"
"openreplay/backend/pkg/hashid"
"openreplay/backend/pkg/messages"
"openreplay/backend/pkg/db/types"
)
func getAutocompleteType(baseType string, platform string) string {
@ -22,7 +22,7 @@ func (conn *Conn) insertAutocompleteValue(sessionID uint64, tp string, value str
if len(value) == 0 {
return
}
if err := conn.exec(`
if err := conn.batchQueue(sessionID, `
INSERT INTO autocomplete (
value,
type,
@ -31,7 +31,7 @@ func (conn *Conn) insertAutocompleteValue(sessionID uint64, tp string, value str
$1, $2, project_id
FROM sessions
WHERE session_id = $3
) ON CONFLICT DO NOTHING`,
) ON CONFLICT DO NOTHING`,
value, tp, sessionID,
); err != nil {
log.Printf("Insert autocomplete error: %v", err)
@ -59,16 +59,16 @@ func (conn *Conn) InsertSessionStart(sessionID uint64, s *types.Session) error {
NULLIF($14, ''), NULLIF($15, ''), NULLIF($16, ''), NULLIF($17, 0), NULLIF($18, 0::bigint),
NULLIF($19, '')
)`,
sessionID, s.ProjectID, s.Timestamp,
sessionID, s.ProjectID, s.Timestamp,
s.UserUUID, s.UserDevice, s.UserDeviceType, s.UserCountry,
s.UserOS, s.UserOSVersion,
s.RevID,
s.RevID,
s.TrackerVersion, s.Timestamp/1000,
s.Platform,
s.UserAgent, s.UserBrowser, s.UserBrowserVersion, s.UserDeviceMemorySize, s.UserDeviceHeapSize,
s.UserID,
); err != nil {
return err;
return err
}
conn.insertAutocompleteValue(sessionID, getAutocompleteType("USEROS", s.Platform), s.UserOS)
conn.insertAutocompleteValue(sessionID, getAutocompleteType("USERDEVICE", s.Platform), s.UserDevice)
@ -81,7 +81,7 @@ func (conn *Conn) InsertSessionStart(sessionID uint64, s *types.Session) error {
func (conn *Conn) InsertSessionEnd(sessionID uint64, timestamp uint64) (uint64, error) {
// Search acceleration
if err := conn.exec(`
if err := conn.batchQueue(sessionID, `
UPDATE sessions
SET issue_types=(SELECT
CASE WHEN errors_count > 0 THEN
@ -96,7 +96,7 @@ func (conn *Conn) InsertSessionEnd(sessionID uint64, timestamp uint64) (uint64,
`,
sessionID,
); err != nil {
log.Printf("Error while updating issue_types %v", sessionID)
log.Printf("Error while updating issue_types: %v. SessionID: %v", err, sessionID)
}
var dur uint64
@ -113,33 +113,33 @@ func (conn *Conn) InsertSessionEnd(sessionID uint64, timestamp uint64) (uint64,
}
func (conn *Conn) InsertRequest(sessionID uint64, timestamp uint64, index uint64, url string, duration uint64, success bool) error {
return conn.exec(`
return conn.batchQueue(sessionID, `
INSERT INTO events_common.requests (
session_id, timestamp, seq_index, url, duration, success
) VALUES (
$1, $2, $3, $4, $5, $6
)`,
sessionID, timestamp,
sessionID, timestamp,
getSqIdx(index),
url, duration, success,
)
}
func (conn *Conn) InsertCustomEvent(sessionID uint64, timestamp uint64, index uint64, name string, payload string) error {
return conn.exec(`
return conn.batchQueue(sessionID, `
INSERT INTO events_common.customs (
session_id, timestamp, seq_index, name, payload
) VALUES (
$1, $2, $3, $4, $5
)`,
sessionID, timestamp,
getSqIdx(index),
sessionID, timestamp,
getSqIdx(index),
name, payload,
)
}
func (conn *Conn) InsertUserID(sessionID uint64, userID string) error {
return conn.exec(`
return conn.batchQueue(sessionID, `
UPDATE sessions SET user_id = $1
WHERE session_id = $2`,
userID, sessionID,
@ -147,16 +147,15 @@ func (conn *Conn) InsertUserID(sessionID uint64, userID string) error {
}
func (conn *Conn) InsertUserAnonymousID(sessionID uint64, userAnonymousID string) error {
return conn.exec(`
return conn.batchQueue(sessionID, `
UPDATE sessions SET user_anonymous_id = $1
WHERE session_id = $2`,
userAnonymousID, sessionID,
)
}
func (conn *Conn) InsertMetadata(sessionID uint64, keyNo uint, value string) error {
return conn.exec(fmt.Sprintf(`
return conn.batchQueue(sessionID, fmt.Sprintf(`
UPDATE sessions SET metadata_%v = $1
WHERE session_id = $2`, keyNo),
value, sessionID,
@ -173,11 +172,11 @@ func (conn *Conn) InsertIssueEvent(sessionID uint64, projectID uint32, e *messag
issueID := hashid.IssueID(projectID, e)
// TEMP. TODO: nullable & json message field type
payload := &e.Payload;
payload := &e.Payload
if *payload == "" || *payload == "{}" {
payload = nil
}
context := &e.Context;
context := &e.Context
if *context == "" || *context == "{}" {
context = nil
}
@ -189,7 +188,7 @@ func (conn *Conn) InsertIssueEvent(sessionID uint64, projectID uint32, e *messag
project_id, $2, $3, $4, CAST($5 AS jsonb)
FROM sessions
WHERE session_id = $1
)ON CONFLICT DO NOTHING`,
)ON CONFLICT DO NOTHING`,
sessionID, issueID, e.Type, e.ContextString, context,
); err != nil {
return err
@ -199,8 +198,8 @@ func (conn *Conn) InsertIssueEvent(sessionID uint64, projectID uint32, e *messag
session_id, issue_id, timestamp, seq_index, payload
) VALUES (
$1, $2, $3, $4, CAST($5 AS jsonb)
)`,
sessionID, issueID, e.Timestamp,
)`,
sessionID, issueID, e.Timestamp,
getSqIdx(e.MessageID),
payload,
); err != nil {
@ -228,5 +227,3 @@ func (conn *Conn) InsertIssueEvent(sessionID uint64, projectID uint32, e *messag
}
return tx.commit()
}

View file

@ -68,16 +68,19 @@ func (conn *Conn) InsertWebPageEvent(sessionID uint64, e *PageEvent) error {
if err := tx.exec(`
INSERT INTO events.pages (
session_id, message_id, timestamp, referrer, base_referrer, host, path, base_path,
dom_content_loaded_time, load_time, response_end, first_paint_time, first_contentful_paint_time, speed_index, visually_complete, time_to_interactive,
dom_content_loaded_time, load_time, response_end, first_paint_time, first_contentful_paint_time,
speed_index, visually_complete, time_to_interactive,
response_time, dom_building_time
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8,
NULLIF($9, 0), NULLIF($10, 0), NULLIF($11, 0), NULLIF($12, 0), NULLIF($13, 0), NULLIF($14, 0), NULLIF($15, 0), NULLIF($16, 0),
NULLIF($9, 0), NULLIF($10, 0), NULLIF($11, 0), NULLIF($12, 0), NULLIF($13, 0),
NULLIF($14, 0), NULLIF($15, 0), NULLIF($16, 0),
NULLIF($17, 0), NULLIF($18, 0)
)
`,
sessionID, e.MessageID, e.Timestamp, e.Referrer, url.DiscardURLQuery(e.Referrer), host, path, url.DiscardURLQuery(path),
e.DomContentLoadedEventEnd, e.LoadEventEnd, e.ResponseEnd, e.FirstPaint, e.FirstContentfulPaint, e.SpeedIndex, e.VisuallyComplete, e.TimeToInteractive,
e.DomContentLoadedEventEnd, e.LoadEventEnd, e.ResponseEnd, e.FirstPaint, e.FirstContentfulPaint,
e.SpeedIndex, e.VisuallyComplete, e.TimeToInteractive,
calcResponseTime(e), calcDomBuildingTime(e),
); err != nil {
return err

View file

@ -1,21 +1,18 @@
package postgres
package postgres
import (
"openreplay/backend/pkg/url"
. "openreplay/backend/pkg/messages"
"openreplay/backend/pkg/url"
)
func (conn *Conn) InsertWebStatsLongtask(sessionID uint64, l *LongTask) error {
return nil // Do we even use them?
// conn.exec(``);
return nil // Do we even use them?
// conn.exec(``);
}
func (conn *Conn) InsertWebStatsPerformance(sessionID uint64, p *PerformanceTrackAggr) error {
timestamp := (p.TimestampEnd + p.TimestampStart) /2
return conn.exec(`
timestamp := (p.TimestampEnd + p.TimestampStart) / 2
return conn.batchQueue(sessionID, `
INSERT INTO events.performance (
session_id, timestamp, message_id,
min_fps, avg_fps, max_fps,
@ -34,7 +31,7 @@ func (conn *Conn) InsertWebStatsPerformance(sessionID uint64, p *PerformanceTrac
p.MinCPU, p.AvgCPU, p.MinCPU,
p.MinTotalJSHeapSize, p.AvgTotalJSHeapSize, p.MaxTotalJSHeapSize,
p.MinUsedJSHeapSize, p.AvgUsedJSHeapSize, p.MaxUsedJSHeapSize,
);
)
}
func (conn *Conn) InsertWebStatsResourceEvent(sessionID uint64, e *ResourceEvent) error {
@ -42,7 +39,7 @@ func (conn *Conn) InsertWebStatsResourceEvent(sessionID uint64, e *ResourceEvent
if err != nil {
return err
}
return conn.exec(`
return conn.batchQueue(sessionID, `
INSERT INTO events.resources (
session_id, timestamp, message_id,
type,
@ -58,11 +55,11 @@ func (conn *Conn) InsertWebStatsResourceEvent(sessionID uint64, e *ResourceEvent
NULLIF($10, '')::events.resource_method,
NULLIF($11, 0), NULLIF($12, 0), NULLIF($13, 0), NULLIF($14, 0), NULLIF($15, 0)
)`,
sessionID, e.Timestamp, e.MessageID,
sessionID, e.Timestamp, e.MessageID,
e.Type,
e.URL, host, url.DiscardURLQuery(e.URL),
e.Success, e.Status,
e.Success, e.Status,
url.EnsureMethod(e.Method),
e.Duration, e.TTFB, e.HeaderSize, e.EncodedBodySize, e.DecodedBodySize,
)
}
}

View file

@ -11,11 +11,11 @@ import (
"openreplay/backend/pkg/db/cache"
"openreplay/backend/pkg/db/postgres"
"openreplay/backend/pkg/env"
logger "openreplay/backend/pkg/log"
"openreplay/backend/pkg/messages"
"openreplay/backend/pkg/queue"
"openreplay/backend/pkg/queue/types"
"openreplay/backend/services/db/heuristics"
logger "openreplay/backend/pkg/log"
)
var pg *cache.PGCache
@ -29,7 +29,6 @@ func main() {
heurFinder := heuristics.NewHandler()
statsLogger := logger.NewQueueStats(env.Int("LOG_QUEUE_STATS_INTERVAL_SEC"))
consumer := queue.NewMessageConsumer(
@ -91,6 +90,7 @@ func main() {
consumer.Close()
os.Exit(0)
case <-tick:
pg.CommitBatches()
if err := commitStats(); err != nil {
log.Printf("Error on stats commit: %v", err)
}

View file

@ -21,7 +21,10 @@ def create_new_member(tenant_id, email, invitation_token, admin, name, owner=Fal
query = cur.mogrify(f"""\
WITH u AS (
INSERT INTO public.users (tenant_id, email, role, name, data, role_id)
VALUES (%(tenantId)s, %(email)s, %(role)s, %(name)s, %(data)s, %(role_id)s)
VALUES (%(tenant_id)s, %(email)s, %(role)s, %(name)s, %(data)s,
(SELECT COALESCE((SELECT role_id FROM roles WHERE tenant_id = %(tenant_id)s AND role_id = %(role_id)s),
(SELECT role_id FROM roles WHERE tenant_id = %(tenant_id)s AND name = 'Member' LIMIT 1),
(SELECT role_id FROM roles WHERE tenant_id = %(tenant_id)s AND name != 'Owner' LIMIT 1))))
RETURNING tenant_id,user_id,email,role,name,appearance, role_id
),
au AS (INSERT INTO public.basic_authentication (user_id, generated_password, invitation_token, invited_at)
@ -42,8 +45,8 @@ def create_new_member(tenant_id, email, invitation_token, admin, name, owner=Fal
roles.name AS role_name,
roles.permissions,
TRUE AS has_password
FROM au,u LEFT JOIN roles USING(tenant_id) WHERE roles.role_id IS NULL OR roles.role_id = %(role_id)s;""",
{"tenantId": tenant_id, "email": email,
FROM au,u LEFT JOIN roles USING(tenant_id) WHERE roles.role_id IS NULL OR roles.role_id = (SELECT u.role_id FROM u);""",
{"tenant_id": tenant_id, "email": email,
"role": "owner" if owner else "admin" if admin else "member", "name": name,
"data": json.dumps({"lastAnnouncementView": TimeUTC.now()}),
"invitation_token": invitation_token, "role_id": role_id})
@ -63,7 +66,9 @@ def restore_member(tenant_id, user_id, email, invitation_token, admin, name, own
created_at = timezone('utc'::text, now()),
tenant_id= %(tenant_id)s,
api_key= generate_api_key(20),
role_id= %(role_id)s
role_id= (SELECT COALESCE((SELECT role_id FROM roles WHERE tenant_id = %(tenant_id)s AND role_id = %(role_id)s),
(SELECT role_id FROM roles WHERE tenant_id = %(tenant_id)s AND name = 'Member' LIMIT 1),
(SELECT role_id FROM roles WHERE tenant_id = %(tenant_id)s AND name != 'Owner' LIMIT 1)))
WHERE user_id=%(user_id)s
RETURNING user_id AS id,
email,
@ -145,6 +150,10 @@ def update(tenant_id, user_id, changes):
if key == "appearance":
sub_query_users.append(f"appearance = %(appearance)s::jsonb")
changes["appearance"] = json.dumps(changes[key])
elif helper.key_to_snake_case(key) == "role_id":
sub_query_users.append("""role_id=(SELECT COALESCE((SELECT role_id FROM roles WHERE tenant_id = %(tenant_id)s AND role_id = %(role_id)s),
(SELECT role_id FROM roles WHERE tenant_id = %(tenant_id)s AND name = 'Member' LIMIT 1),
(SELECT role_id FROM roles WHERE tenant_id = %(tenant_id)s AND name != 'Owner' LIMIT 1)))""")
else:
sub_query_users.append(f"{helper.key_to_snake_case(key)} = %({key})s")
@ -280,11 +289,11 @@ def get(user_id, tenant_id):
LEFT JOIN public.roles USING (role_id)
WHERE
users.user_id = %(userId)s
AND users.tenant_id = %(tenantId)s
AND users.tenant_id = %(tenant_id)s
AND users.deleted_at IS NULL
AND (roles.role_id IS NULL OR roles.deleted_at IS NULL AND roles.tenant_id = %(tenantId)s)
AND (roles.role_id IS NULL OR roles.deleted_at IS NULL AND roles.tenant_id = %(tenant_id)s)
LIMIT 1;""",
{"userId": user_id, "tenantId": tenant_id})
{"userId": user_id, "tenant_id": tenant_id})
)
r = cur.fetchone()
return helper.dict_to_camel_case(r, ignore_keys=["appearance"])
@ -418,9 +427,9 @@ def get_members(tenant_id):
FROM public.users
LEFT JOIN public.basic_authentication ON users.user_id=basic_authentication.user_id
LEFT JOIN public.roles USING (role_id)
WHERE users.tenant_id = %(tenantId)s AND users.deleted_at IS NULL
WHERE users.tenant_id = %(tenant_id)s AND users.deleted_at IS NULL
ORDER BY name, id""",
{"tenantId": tenant_id})
{"tenant_id": tenant_id})
)
r = cur.fetchall()
if len(r):
@ -534,8 +543,8 @@ def count_members(tenant_id):
cur.mogrify(
"""SELECT
COUNT(user_id)
FROM public.users WHERE tenant_id = %(tenantId)s AND deleted_at IS NULL;""",
{"tenantId": tenant_id})
FROM public.users WHERE tenant_id = %(tenant_id)s AND deleted_at IS NULL;""",
{"tenant_id": tenant_id})
)
r = cur.fetchone()
return r["count"]
@ -598,8 +607,8 @@ def auth_exists(user_id, tenant_id, jwt_iat, jwt_aud):
with pg_client.PostgresClient() as cur:
cur.execute(
cur.mogrify(
f"SELECT user_id AS id,jwt_iat, changed_at FROM public.users INNER JOIN public.basic_authentication USING(user_id) WHERE user_id = %(userId)s AND tenant_id = %(tenantId)s AND deleted_at IS NULL LIMIT 1;",
{"userId": user_id, "tenantId": tenant_id})
f"SELECT user_id AS id,jwt_iat, changed_at FROM public.users INNER JOIN public.basic_authentication USING(user_id) WHERE user_id = %(userId)s AND tenant_id = %(tenant_id)s AND deleted_at IS NULL LIMIT 1;",
{"userId": user_id, "tenant_id": tenant_id})
)
r = cur.fetchone()
return r is not None \
@ -716,7 +725,10 @@ def create_sso_user(tenant_id, email, admin, name, origin, role_id, internal_id=
query = cur.mogrify(f"""\
WITH u AS (
INSERT INTO public.users (tenant_id, email, role, name, data, origin, internal_id, role_id)
VALUES (%(tenantId)s, %(email)s, %(role)s, %(name)s, %(data)s, %(origin)s, %(internal_id)s, %(role_id)s)
VALUES (%(tenant_id)s, %(email)s, %(role)s, %(name)s, %(data)s, %(origin)s, %(internal_id)s,
(SELECT COALESCE((SELECT role_id FROM roles WHERE tenant_id = %(tenant_id)s AND role_id = %(role_id)s),
(SELECT role_id FROM roles WHERE tenant_id = %(tenant_id)s AND name = 'Member' LIMIT 1),
(SELECT role_id FROM roles WHERE tenant_id = %(tenant_id)s AND name != 'Owner' LIMIT 1))))
RETURNING *
),
au AS (
@ -734,7 +746,7 @@ def create_sso_user(tenant_id, email, admin, name, origin, role_id, internal_id=
u.appearance,
origin
FROM u;""",
{"tenantId": tenant_id, "email": email, "internal_id": internal_id,
{"tenant_id": tenant_id, "email": email, "internal_id": internal_id,
"role": "admin" if admin else "member", "name": name, "origin": origin,
"role_id": role_id, "data": json.dumps({"lastAnnouncementView": TimeUTC.now()})})
cur.execute(
@ -748,13 +760,15 @@ def restore_sso_user(user_id, tenant_id, email, admin, name, origin, role_id, in
query = cur.mogrify(f"""\
WITH u AS (
UPDATE public.users
SET tenant_id= %(tenantId)s,
SET tenant_id= %(tenant_id)s,
role= %(role)s,
name= %(name)s,
data= %(data)s,
origin= %(origin)s,
internal_id= %(internal_id)s,
role_id= %(role_id)s,
role_id= (SELECT COALESCE((SELECT role_id FROM roles WHERE tenant_id = %(tenant_id)s AND role_id = %(role_id)s),
(SELECT role_id FROM roles WHERE tenant_id = %(tenant_id)s AND name = 'Member' LIMIT 1),
(SELECT role_id FROM roles WHERE tenant_id = %(tenant_id)s AND name != 'Owner' LIMIT 1))),
deleted_at= NULL,
created_at= default,
api_key= default,
@ -787,7 +801,7 @@ def restore_sso_user(user_id, tenant_id, email, admin, name, origin, role_id, in
u.appearance,
origin
FROM u;""",
{"tenantId": tenant_id, "email": email, "internal_id": internal_id,
{"tenant_id": tenant_id, "email": email, "internal_id": internal_id,
"role": "admin" if admin else "member", "name": name, "origin": origin,
"role_id": role_id, "data": json.dumps({"lastAnnouncementView": TimeUTC.now()}),
"user_id": user_id})

View file

@ -8,9 +8,9 @@ jira==2.0.0
clickhouse-driver==0.2.2
python3-saml==1.12.0
fastapi==0.70.1
fastapi==0.74.1
python-multipart==0.0.5
uvicorn[standard]==0.16.0
python-decouple==3.5
uvicorn[standard]==0.17.5
python-decouple==3.6
pydantic[email]==1.8.2
apscheduler==3.8.1

View file

@ -0,0 +1,8 @@
BEGIN;
CREATE OR REPLACE FUNCTION openreplay_version()
RETURNS text AS
$$
SELECT 'v1.5.1-ee'
$$ LANGUAGE sql IMMUTABLE;
COMMIT;

View file

@ -11,6 +11,8 @@ import UpdatePassword from 'Components/UpdatePassword/UpdatePassword';
import ClientPure from 'Components/Client/Client';
import OnboardingPure from 'Components/Onboarding/Onboarding';
import SessionPure from 'Components/Session/Session';
import LiveSessionPure from 'Components/Session/LiveSession';
import AssistPure from 'Components/Assist';
import BugFinderPure from 'Components/BugFinder/BugFinder';
import DashboardPure from 'Components/Dashboard/Dashboard';
import ErrorsPure from 'Components/Errors/Errors';
@ -18,6 +20,7 @@ import Header from 'Components/Header/Header';
// import ResultsModal from 'Shared/Results/ResultsModal';
import FunnelDetails from 'Components/Funnels/FunnelDetails';
import FunnelIssueDetails from 'Components/Funnels/FunnelIssueDetails';
import { fetchList as fetchIntegrationVariables } from 'Duck/customField';
import APIClient from './api_client';
import * as routes from './routes';
@ -29,6 +32,8 @@ import { setSessionPath } from 'Duck/sessions';
const BugFinder = withSiteIdUpdater(BugFinderPure);
const Dashboard = withSiteIdUpdater(DashboardPure);
const Session = withSiteIdUpdater(SessionPure);
const LiveSession = withSiteIdUpdater(LiveSessionPure);
const Assist = withSiteIdUpdater(AssistPure);
const Client = withSiteIdUpdater(ClientPure);
const Onboarding = withSiteIdUpdater(OnboardingPure);
const Errors = withSiteIdUpdater(ErrorsPure);
@ -39,6 +44,7 @@ const withObTab = routes.withObTab;
const DASHBOARD_PATH = routes.dashboard();
const SESSIONS_PATH = routes.sessions();
const ASSIST_PATH = routes.assist();
const ERRORS_PATH = routes.errors();
const ERROR_PATH = routes.error();
const FUNNEL_PATH = routes.funnel();
@ -74,7 +80,7 @@ const ONBOARDING_REDIRECT_PATH = routes.onboarding(OB_DEFAULT_TAB);
onboarding: state.getIn([ 'user', 'onboarding' ])
};
}, {
fetchUserInfo, fetchTenants, setSessionPath
fetchUserInfo, fetchTenants, setSessionPath, fetchIntegrationVariables
})
class Router extends React.Component {
state = {
@ -83,7 +89,11 @@ class Router extends React.Component {
constructor(props) {
super(props);
if (props.isLoggedIn) {
Promise.all([props.fetchUserInfo()])
Promise.all([
props.fetchUserInfo().then(() => {
props.fetchIntegrationVariables()
}),
])
// .then(() => this.onLoginLogout());
}
props.fetchTenants();
@ -111,7 +121,7 @@ class Router extends React.Component {
render() {
const { isLoggedIn, jwt, siteId, sites, loading, changePassword, location, existingTenant, onboarding } = this.props;
const siteIdList = sites.map(({ id }) => id).toJS();
const hideHeader = location.pathname && location.pathname.includes('/session/');
const hideHeader = location.pathname && location.pathname.includes('/session/') || location.pathname.includes('/assist/');
return isLoggedIn ?
<Loader loading={ loading } className="flex-1" >
@ -145,12 +155,14 @@ class Router extends React.Component {
<Redirect to={ routes.client(routes.CLIENT_TABS.SITES) } />
}
<Route exact strict path={ withSiteId(DASHBOARD_PATH, siteIdList) } component={ Dashboard } />
<Route exact strict path={ withSiteId(ASSIST_PATH, siteIdList) } component={ Assist } />
<Route exact strict path={ withSiteId(ERRORS_PATH, siteIdList) } component={ Errors } />
<Route exact strict path={ withSiteId(ERROR_PATH, siteIdList) } component={ Errors } />
<Route exact strict path={ withSiteId(FUNNEL_PATH, siteIdList) } component={ Funnels } />
<Route exact strict path={ withSiteId(FUNNEL_ISSUE_PATH, siteIdList) } component={ FunnelIssue } />
<Route exact strict path={ withSiteId(SESSIONS_PATH, siteIdList) } component={ BugFinder } />
<Route exact strict path={ withSiteId(SESSION_PATH, siteIdList) } component={ Session } />
<Route exact strict path={ withSiteId(LIVE_SESSION_PATH, siteIdList) } component={ LiveSession } />
<Route exact strict path={ withSiteId(LIVE_SESSION_PATH, siteIdList) } render={ (props) => <Session { ...props } live /> } />
{ routes.redirects.map(([ fr, to ]) => (
<Redirect key={ fr } exact strict from={ fr } to={ to } />

View file

@ -1,11 +1,22 @@
import React from 'react';
import ChatWindow from './ChatWindow';
import LiveSessionList from 'Shared/LiveSessionList';
import LiveSessionSearch from 'Shared/LiveSessionSearch';
import cn from 'classnames'
import withPageTitle from 'HOCs/withPageTitle';
import withPermissions from 'HOCs/withPermissions'
export default function Assist() {
function Assist() {
return (
<div className="absolute">
{/* <ChatWindow /> */}
<div className="page-margin container-90 flex relative">
<div className="flex-1 flex">
<div className={cn("w-full mx-auto")} style={{ maxWidth: '1300px'}}>
<LiveSessionSearch />
<div className="my-4" />
<LiveSessionList />
</div>
</div>
</div>
)
}
export default withPageTitle("Assist - OpenReplay")(withPermissions(['ASSIST_LIVE'])(Assist));

View file

@ -15,7 +15,7 @@
&.disabled {
/* background-color: red; */
& svg {
fill: red;
fill: $red;
}
}
}

View file

@ -28,17 +28,17 @@ function ChatControls({ stream, endCall, videoEnabled, setVideoEnabled } : Props
return (
<div className={cn(stl.controls, "flex items-center w-full justify-start bottom-0 px-2")}>
<div className="flex items-center">
<div className={cn(stl.btnWrapper, { [stl.disabled]: !audioEnabled})}>
<Button plain size="small" onClick={toggleAudio} noPadding className="flex items-center">
<div className={cn(stl.btnWrapper, { [stl.disabled]: audioEnabled})}>
<Button plain size="small" onClick={toggleAudio} noPadding className="flex items-center" hover>
<Icon name={audioEnabled ? 'mic' : 'mic-mute'} size="16" />
<span className="ml-2 color-gray-medium text-sm">{audioEnabled ? 'Mute' : 'Unmute'}</span>
<span className={cn("ml-1 color-gray-medium text-sm", { 'color-red' : audioEnabled })}>{audioEnabled ? 'Mute' : 'Unmute'}</span>
</Button>
</div>
<div className={cn(stl.btnWrapper, { [stl.disabled]: !videoEnabled})}>
<Button plain size="small" onClick={toggleVideo} noPadding className="flex items-center">
<div className={cn(stl.btnWrapper, { [stl.disabled]: videoEnabled})}>
<Button plain size="small" onClick={toggleVideo} noPadding className="flex items-center" hover>
<Icon name={ videoEnabled ? 'camera-video' : 'camera-video-off' } size="16" />
<span className="ml-2 color-gray-medium text-sm">{videoEnabled ? 'Stop Video' : 'Start Video'}</span>
<span className={cn("ml-1 color-gray-medium text-sm", { 'color-red' : videoEnabled })}>{videoEnabled ? 'Stop Video' : 'Start Video'}</span>
</Button>
</div>
</div>

View file

@ -20,7 +20,6 @@ const ChatWindow: FC<Props> = function ChatWindow({ userId, incomeStream, localS
const [localVideoEnabled, setLocalVideoEnabled] = useState(false)
const [remoteVideoEnabled, setRemoteVideoEnabled] = useState(false)
useEffect(() => {
if (!incomeStream) { return }
const iid = setInterval(() => {
@ -42,9 +41,9 @@ const ChatWindow: FC<Props> = function ChatWindow({ userId, incomeStream, localS
className={cn(stl.wrapper, "fixed radius bg-white shadow-xl mt-16")}
style={{ width: '280px' }}
>
<div className="handle flex items-center p-2 cursor-move select-none">
<div className={stl.headerTitle}><b>Meeting</b> {userId}</div>
<Counter startTime={new Date().getTime() } className="text-sm ml-auto" />
<div className="handle flex items-center p-2 cursor-move select-none border-b">
<div className={stl.headerTitle}><b>Talking to </b> {userId ? userId : 'Anonymous User'}</div>
<Counter startTime={new Date().getTime() } className="text-sm ml-auto" />
</div>
<div className={cn(stl.videoWrapper, {'hidden' : minimize}, 'relative')}>
<VideoContainer stream={ incomeStream } />

View file

@ -1,9 +1,10 @@
.wrapper {
background-color: white;
border: solid thin #000;
border: solid thin $gray-light;
border-radius: 3px;
position: fixed;
width: 300px;
width: 300px;
box-shadow: 1px 1px 1px rgba(0, 0, 0, 0.1);
}
.headerTitle {

View file

@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'
import { Popup, Icon } from 'UI'
import { Popup, Icon, IconButton } from 'UI'
import { connect } from 'react-redux'
import cn from 'classnames'
import { toggleChatWindow } from 'Duck/sessions';
@ -77,27 +77,48 @@ function AssistActions({ toggleChatWindow, userId, calling, peerConnectionStatus
const onCall = calling === CallingState.OnCall || calling === CallingState.Reconnecting
const cannotCall = (peerConnectionStatus !== ConnectionStatus.Connected) || (isEnterprise && !hasPermission)
const remoteActive = remoteControlStatus === RemoteControlStatus.Enabled
return (
<div className="flex items-center">
<div
className={
cn(
'cursor-pointer p-2 flex items-center',
{[stl.disabled]: cannotCall}
)
}
onClick={ requestReleaseRemoteControl }
role="button"
>
{/* <Icon
name="remote-control"
size="20"
color={ remoteControlStatus === RemoteControlStatus.Enabled ? "green" : "gray-darkest"}
/>
<span className={cn("ml-2", { 'color-green' : remoteControlStatus === RemoteControlStatus.Enabled })}>{ 'Remote Control' }</span> */}
<IconButton label={`${remoteActive ? 'Stop ' : ''} Remote Control`} icon="remote-control" primaryText redText={remoteActive} />
</div>
<Popup
trigger={
<div
className={
cn(
'cursor-pointer p-2 mr-2 flex items-center',
'cursor-pointer p-2 flex items-center',
{[stl.disabled]: cannotCall}
)
}
onClick={ onCall ? callObject?.end : confirmCall}
role="button"
>
<Icon
{/* <Icon
name="headset"
size="20"
color={ onCall ? "red" : "gray-darkest" }
/>
<span className={cn("ml-2", { 'color-red' : onCall })}>{ onCall ? 'End Call' : 'Call' }</span>
<span className={cn("ml-2", { 'color-red' : onCall })}>{ onCall ? 'End Call' : 'Call' }</span> */}
<IconButton size="small" primary={!onCall} red={onCall} label={onCall ? 'End' : 'Call'} icon="headset" />
</div>
}
content={ cannotCall ? "You dont have the permissions to perform this action." : `Call ${userId ? userId : 'User'}` }
@ -105,22 +126,7 @@ function AssistActions({ toggleChatWindow, userId, calling, peerConnectionStatus
inverted
position="top right"
/>
<div
className={
cn(
'cursor-pointer p-2 mr-2 flex items-center',
)
}
onClick={ requestReleaseRemoteControl }
role="button"
>
<Icon
name="remote-control"
size="20"
color={ remoteControlStatus === RemoteControlStatus.Enabled ? "green" : "gray-darkest"}
/>
<span className={cn("ml-2", { 'color-green' : remoteControlStatus === RemoteControlStatus.Enabled })}>{ 'Remote Control' }</span>
</div>
<div className="fixed ml-3 left-0 top-0" style={{ zIndex: 999 }}>
{ onCall && callObject && <ChatWindow endCall={callObject.end} userId={userId} incomeStream={incomeStream} localStream={localStream} /> }
</div>

View file

@ -1,10 +1,11 @@
import React, { useEffect, useState } from 'react';
import { SlideModal, Icon } from 'UI';
import { SlideModal, Avatar, Icon } from 'UI';
import SessionList from '../SessionList';
import stl from './assistTabs.css'
interface Props {
userId: any,
userNumericHash: any,
}
const AssistTabs = (props: Props) => {
@ -15,16 +16,16 @@ const AssistTabs = (props: Props) => {
<div className="flex items-center">
{props.userId && (
<>
<div className="flex items-center mr-3">
{/* <Icon name="user-alt" color="gray-darkest" /> */}
<Avatar iconSize="20" width="30px" height="30px" seed={ props.userNumericHash } />
<div className="ml-2 font-medium">{props.userId}'s</div>
</div>
<div
className={stl.btnLink}
onClick={() => setShowMenu(!showMenu)}
>
More Live Sessions
</div>
<span className="mx-3 color-gray-medium">by</span>
<div className="flex items-center">
<Icon name="user-alt" color="gray-darkest" />
<div className="ml-2">{props.userId}</div>
Active Sessions
</div>
</>
)}

View file

@ -14,7 +14,7 @@ import stl from './bugFinder.css';
import { fetchList as fetchSiteList } from 'Duck/site';
import withLocationHandlers from "HOCs/withLocationHandlers";
import { fetch as fetchFilterVariables } from 'Duck/sources';
import { fetchList as fetchIntegrationVariables, fetchSources } from 'Duck/customField';
import { fetchSources } from 'Duck/customField';
import { RehydrateSlidePanel } from './WatchDogs/components';
import { setActiveTab, setFunnelPage } from 'Duck/sessions';
import SessionsMenu from './SessionsMenu/SessionsMenu';
@ -23,11 +23,8 @@ import { resetFunnel } from 'Duck/funnels';
import { resetFunnelFilters } from 'Duck/funnelFilters'
import NoSessionsMessage from 'Shared/NoSessionsMessage';
import TrackerUpdateMessage from 'Shared/TrackerUpdateMessage';
import LiveSessionList from './LiveSessionList'
import SessionSearch from 'Shared/SessionSearch';
import MainSearchBar from 'Shared/MainSearchBar';
import LiveSearchBar from 'Shared/LiveSearchBar';
import LiveSessionSearch from 'Shared/LiveSessionSearch';
import { clearSearch, fetchSessions } from 'Duck/search';
const weakEqual = (val1, val2) => {
@ -54,7 +51,6 @@ const allowedQueryKeys = [
@withLocationHandlers()
@connect(state => ({
filter: state.getIn([ 'filters', 'appliedFilter' ]),
showLive: state.getIn([ 'user', 'account', 'appearance', 'sessionsLive' ]),
variables: state.getIn([ 'customFields', 'list' ]),
sources: state.getIn([ 'customFields', 'sources' ]),
filterValues: state.get('filterValues'),
@ -68,8 +64,7 @@ const allowedQueryKeys = [
fetchFavoriteSessionList,
applyFilter,
addAttribute,
fetchFilterVariables,
fetchIntegrationVariables,
fetchFilterVariables,
fetchSources,
clearEvents,
setActiveTab,
@ -101,15 +96,6 @@ export default class BugFinder extends React.PureComponent {
// keys: this.props.sources.filter(({type}) => type === 'logTool').map(({ label, key }) => ({ type: 'ERROR', source: key, label: label, key, icon: 'integrations/' + key, isFilter: false })).toJS()
// };
// });
// // TODO should cache the response
props.fetchIntegrationVariables().then(() => {
defaultFilters[5] = {
category: 'Metadata',
type: 'custom',
keys: this.props.variables.map(({ key }) => ({ type: 'METADATA', key, label: key, icon: 'filters/metadata', isFilter: true })).toJS()
};
});
props.fetchSessions();
props.resetFunnel();
props.resetFunnelFilters();
@ -172,28 +158,11 @@ export default class BugFinder extends React.PureComponent {
<div className={cn("side-menu-margined", stl.searchWrapper) }>
<TrackerUpdateMessage />
<NoSessionsMessage />
{/* Recorde Sessions */}
{ activeTab.type !== 'live' && (
<>
<div className="mb-5">
<MainSearchBar />
<SessionSearch />
</div>
{ activeTab.type !== 'live' && <SessionList onMenuItemClick={this.setActiveTab} /> }
</>
)}
{/* Live Sessions */}
{ activeTab.type === 'live' && (
<>
<div className="mb-5">
{/* <LiveSearchBar /> */}
<LiveSessionSearch />
</div>
{ activeTab.type === 'live' && <LiveSessionList /> }
</>
)}
<div className="mb-5">
<MainSearchBar />
<SessionSearch />
</div>
<SessionList onMenuItemClick={this.setActiveTab} />
</div>
</div>
<RehydrateSlidePanel

View file

@ -19,6 +19,7 @@ var timeoutId;
allList: state.getIn([ 'sessions', 'list' ]),
total: state.getIn([ 'sessions', 'total' ]),
filters: state.getIn([ 'search', 'instance', 'filters' ]),
metaList: state.getIn(['customFields', 'list']).map(i => i.key),
}), {
applyFilter,
addAttribute,
@ -47,7 +48,7 @@ export default class SessionList extends React.PureComponent {
if (userId) {
this.props.addFilterByKeyAndValue(FilterKey.USERID, userId);
} else {
this.props.addFilterByKeyAndValue(FilterKey.USERANONYMOUSID, userAnonymousId);
this.props.addFilterByKeyAndValue(FilterKey.USERID, '', 'isUndefined');
}
}
@ -81,7 +82,8 @@ export default class SessionList extends React.PureComponent {
filters,
onMenuItemClick,
allList,
activeTab
activeTab,
metaList,
} = this.props;
const _filterKeys = filters.map(i => i.key);
const hasUserFilter = _filterKeys.includes(FilterKey.USERID) || _filterKeys.includes(FilterKey.USERANONYMOUSID);
@ -118,6 +120,7 @@ export default class SessionList extends React.PureComponent {
session={ session }
hasUserFilter={hasUserFilter}
onUserClick={this.onUserClick}
metaList={metaList}
/>
))}
</Loader>

View file

@ -5,6 +5,7 @@ import SortDropdown from '../Filters/SortDropdown';
import DateRange from '../DateRange';
import { TimezoneDropdown } from 'UI';
import { numberWithCommas } from 'App/utils';
import DropdownPlain from 'Shared/DropdownPlain';
const DEFAULT_SORT = 'startTs';
const DEFAULT_ORDER = 'desc';
@ -38,6 +39,17 @@ function SessionListHeader({
</div>
</div>
<div className="flex items-center">
{/* <div className="flex items-center">
<span className="mr-2 color-gray-medium">Session View</span>
<DropdownPlain
options={[
{ text: 'List', value: 'list' },
{ text: 'Grouped', value: 'grouped' }
]}
onChange={() => {}}
value='list'
/>
</div> */}
<div className="flex items-center">
<span className="mr-2 color-gray-medium">Timezone</span>
<TimezoneDropdown />

View file

@ -73,7 +73,7 @@ function SessionsMenu(props) {
/>
))}
<div className={stl.divider} />
{/* <div className={stl.divider} />
<div className="my-3">
<SideMenuitem
title={
@ -94,7 +94,7 @@ function SessionsMenu(props) {
onClick={() => onMenuItemClick({ name: 'Assist', type: 'live' })}
/>
</div>
</div> */}
<div className={stl.divider} />
<div className="my-3">

View file

@ -26,7 +26,7 @@ export default class ProfileSettings extends React.PureComponent {
<div><Settings /></div>
</div>
<div className="divider" />
<div className="divider-h" />
<div className="flex items-center">
<div className={ styles.left }>
@ -36,7 +36,7 @@ export default class ProfileSettings extends React.PureComponent {
<div><ChangePassword /></div>
</div>
<div className="divider" />
<div className="divider-h" />
<div className="flex items-center">
<div className={ styles.left }>
@ -46,7 +46,7 @@ export default class ProfileSettings extends React.PureComponent {
<div><Api /></div>
</div>
<div className="divider" />
<div className="divider-h" />
<div className="flex items-center">
<div className={ styles.left }>
@ -58,7 +58,7 @@ export default class ProfileSettings extends React.PureComponent {
{ !isEnterprise && (
<>
<div className="divider" />
<div className="divider-h" />
<div className="flex items-center">
<div className={ styles.left }>
<h4 className="text-lg mb-4">{ 'Data Collection' }</h4>
@ -71,7 +71,7 @@ export default class ProfileSettings extends React.PureComponent {
{ account.license && (
<>
<div className="divider" />
<div className="divider-h" />
<div className="flex items-center">
<div className={ styles.left }>

View file

@ -4,6 +4,7 @@ import { NavLink, withRouter } from 'react-router-dom';
import cn from 'classnames';
import {
sessions,
assist,
client,
errors,
dashboard,
@ -27,6 +28,7 @@ import Alerts from '../Alerts/Alerts';
const DASHBOARD_PATH = dashboard();
const SESSIONS_PATH = sessions();
const ASSIST_PATH = assist();
const ERRORS_PATH = errors();
const CLIENT_PATH = client(CLIENT_DEFAULT_TAB);
const AUTOREFRESH_INTERVAL = 30 * 1000;
@ -86,6 +88,13 @@ const Header = (props) => {
>
{ 'Sessions' }
</NavLink>
<NavLink
to={ withSiteId(ASSIST_PATH, siteId) }
className={ styles.nav }
activeClassName={ styles.active }
>
{ 'Assist' }
</NavLink>
<NavLink
to={ withSiteId(ERRORS_PATH, siteId) }
className={ styles.nav }

View file

@ -10,6 +10,7 @@ import styles from './siteDropdown.css';
import cn from 'classnames';
import NewSiteForm from '../Client/Sites/NewSiteForm';
import { clearSearch } from 'Duck/search';
import { fetchList as fetchIntegrationVariables } from 'Duck/customField';
@withRouter
@connect(state => ({
@ -21,10 +22,15 @@ import { clearSearch } from 'Duck/search';
pushNewSite,
init,
clearSearch,
fetchIntegrationVariables,
})
export default class SiteDropdown extends React.PureComponent {
state = { showProductModal: false }
componentDidMount() {
this.props.fetchIntegrationVariables();
}
closeModal = (e, newSite) => {
this.setState({ showProductModal: false })
};
@ -37,6 +43,7 @@ export default class SiteDropdown extends React.PureComponent {
switchSite = (siteId) => {
this.props.setSiteId(siteId);
this.props.clearSearch();
this.props.fetchIntegrationVariables();
}
render() {

View file

@ -9,32 +9,22 @@ import {
init as initPlayer,
clean as cleanPlayer,
} from 'Player';
import withPermissions from 'HOCs/withPermissions'
import Assist from 'Components/Assist'
import withPermissions from 'HOCs/withPermissions';
import PlayerBlockHeader from '../Session_/PlayerBlockHeader';
import EventsBlock from '../Session_/EventsBlock';
import PlayerBlock from '../Session_/PlayerBlock';
import styles from '../Session_/session.css';
const EventsBlockConnected = connectPlayer(state => ({
currentTimeEventIndex: state.eventListNow.length > 0 ? state.eventListNow.length - 1 : 0,
playing: state.playing,
}))(EventsBlock)
const InitLoader = connectPlayer(state => ({
loading: !state.initialized
}))(Loader);
function WebPlayer ({ showAssist, session, toggleFullscreen, closeBottomBlock, live, fullscreen, jwt, loadingCredentials, assistCredendials, request, isEnterprise, hasSessionsPath }) {
function LivePlayer ({ session, toggleFullscreen, closeBottomBlock, fullscreen, jwt, loadingCredentials, assistCredendials, request, isEnterprise, hasErrors }) {
useEffect(() => {
if (!loadingCredentials) {
initPlayer(session, jwt, assistCredendials, !hasSessionsPath && session.live);
initPlayer(session, jwt, assistCredendials, true);
}
return () => cleanPlayer()
}, [ session.sessionId, loadingCredentials, assistCredendials ]);
@ -50,12 +40,10 @@ function WebPlayer ({ showAssist, session, toggleFullscreen, closeBottomBlock, l
}
}, [])
return (
<PlayerProvider>
<InitLoader className="flex-1 p-3">
{ showAssist && <Assist session={session} /> }
<PlayerBlockHeader fullscreen={fullscreen}/>
<PlayerBlockHeader fullscreen={fullscreen} />
<div className={ styles.session } data-fullscreen={fullscreen}>
<PlayerBlock />
</div>
@ -81,7 +69,8 @@ export default withRequest({
fullscreen: state.getIn([ 'components', 'player', 'fullscreen' ]),
hasSessionsPath: hasSessioPath && !isAssist,
isEnterprise: state.getIn([ 'user', 'client', 'edition' ]) === 'ee',
hasErrors: !!state.getIn([ 'sessions', 'errors' ]),
}
},
{ toggleFullscreen, closeBottomBlock },
)(WebPlayer)));
)(LivePlayer)));

View file

@ -0,0 +1,60 @@
import { useEffect } from 'react';
import { connect } from 'react-redux';
import usePageTitle from 'App/hooks/usePageTitle';
import { fetch as fetchSession } from 'Duck/sessions';
import { fetchList as fetchSlackList } from 'Duck/integrations/slack';
import { Link, NoContent, Loader } from 'UI';
import { sessions as sessionsRoute } from 'App/routes';
import withPermissions from 'HOCs/withPermissions'
import LivePlayer from './LivePlayer';
const SESSIONS_ROUTE = sessionsRoute();
function LiveSession({
sessionId,
loading,
hasErrors,
session,
fetchSession,
fetchSlackList,
hasSessionsPath
}) {
usePageTitle("OpenReplay Assist");
useEffect(() => {
fetchSlackList()
}, []);
useEffect(() => {
if (sessionId != null) {
fetchSession(sessionId)
} else {
console.error("No sessionID in route.")
}
return () => {
if (!session.exists()) return;
}
},[ sessionId, hasSessionsPath ]);
return (
<Loader className="flex-1" loading={ loading }>
<LivePlayer />
</Loader>
);
}
export default withPermissions(['ASSIST_LIVE'], '', true)(connect((state, props) => {
const { match: { params: { sessionId } } } = props;
const isAssist = state.getIn(['sessions', 'activeTab']).type === 'live';
const hasSessiosPath = state.getIn([ 'sessions', 'sessionPath' ]).includes('/sessions');
return {
sessionId,
loading: state.getIn([ 'sessions', 'loading' ]),
hasErrors: !!state.getIn([ 'sessions', 'errors' ]),
session: state.getIn([ 'sessions', 'current' ]),
hasSessionsPath: hasSessiosPath && !isAssist,
};
}, {
fetchSession,
fetchSlackList,
})(LiveSession));

View file

@ -106,6 +106,7 @@ function getStorageName(type) {
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' ]),
}
}, {
fullscreenOn,
@ -253,7 +254,8 @@ export default class Controls extends React.Component {
showExceptions,
fullscreen,
skipToIssue,
inspectorMode
inspectorMode,
closedLive,
} = this.props;
// const inspectorMode = bottomBlock === INSPECTOR;
@ -263,30 +265,35 @@ export default class Controls extends React.Component {
{ !live && <Timeline jump={ this.props.jump } /> }
{ !fullscreen &&
<div className={ styles.buttons } data-is-live={ live }>
{ !live ?
<div className={ styles.buttonsLeft }>
{ this.renderPlayBtn() }
<ControlButton
onClick={ this.backTenSeconds }
disabled={ disabled }
label="Back"
icon="replay-10"
/>
<ControlButton
disabled={ disabled }
onClick={ this.props.toggleSkipToIssue }
active={ skipToIssue }
label="Skip to Issue"
icon={skipToIssue ? 'skip-forward-fill' : 'skip-forward'}
/>
</div>
:
<div className={ styles.buttonsLeft }>
<LiveTag isLive={livePlay} />
{'Elapsed'}
<ReduxTime name="time" />
</div>
}
<div>
{ !live && (
<div className={ styles.buttonsLeft }>
{ this.renderPlayBtn() }
<ControlButton
onClick={ this.backTenSeconds }
disabled={ disabled }
label="Back"
icon="replay-10"
/>
<ControlButton
disabled={ disabled }
onClick={ this.props.toggleSkipToIssue }
active={ skipToIssue }
label="Skip to Issue"
icon={skipToIssue ? 'skip-forward-fill' : 'skip-forward'}
/>
</div>
)}
{ live && !closedLive && (
<div className={ styles.buttonsLeft }>
<LiveTag isLive={livePlay} />
{'Elapsed'}
<ReduxTime name="time" />
</div>
)}
</div>
<div className={ styles.butonsRight }>
{!live &&
<React.Fragment>
@ -297,7 +304,7 @@ export default class Controls extends React.Component {
>
<div>{ speed + 'x' }</div>
</button>
<div className={ styles.divider } />
<button
className={ cn(styles.skipIntervalButton, { [styles.withCheckIcon]: skip }) }
onClick={ this.props.toggleSkip }
@ -308,7 +315,9 @@ export default class Controls extends React.Component {
</button>
</React.Fragment>
}
<div className={ styles.divider } />
{ !live && <div className={ styles.divider } /> }
{ !live &&
<ControlButton
disabled={ disabled }
@ -413,7 +422,7 @@ export default class Controls extends React.Component {
icon="business-time"
/>
} */}
<div className={ styles.divider } />
{ !live &&
<React.Fragment>
<ControlButton

View file

@ -25,6 +25,7 @@ interface Props {
nextId: string,
togglePlay: () => void,
closedLive?: boolean
}
function Overlay({
@ -41,6 +42,7 @@ function Overlay({
activeTargetIndex,
nextId,
togglePlay,
closedLive
}: Props) {
// useEffect(() =>{
@ -56,7 +58,7 @@ function Overlay({
<>
{ showAutoplayTimer && <AutoplayTimer /> }
{ showLiveStatusText &&
<LiveStatusText text={liveStatusText} concetionStatus={concetionStatus} />
<LiveStatusText text={liveStatusText} concetionStatus={closedLive ? ConnectionStatus.Closed : concetionStatus} />
}
{ messagesLoading && <Loader/> }
{ showPlayIconLayer &&

View file

@ -12,6 +12,14 @@ interface Props {
export default function LiveStatusText({ text, concetionStatus }: Props) {
const renderView = () => {
switch (concetionStatus) {
case ConnectionStatus.Closed:
return (
<div className="flex flex-col items-center text-center">
<div className="text-lg -mt-8">Session not found</div>
<div className="text-sm">The remote session doesnt exist anymore. <br/> The user may have closed the tab/browser while you were trying to establish a connection.</div>
</div>
)
case ConnectionStatus.Connecting:
return (
<div className="flex flex-col items-center">

View file

@ -1,7 +1,7 @@
import { connect } from 'react-redux';
import { findDOMNode } from 'react-dom';
import cn from 'classnames';
import { Loader, IconButton, EscapeButton } from 'UI';
import { EscapeButton } from 'UI';
import { hide as hideTargetDefiner } from 'Duck/components/targetDefiner';
import { fullscreenOff } from 'Duck/components/player';
import { attach as attachPlayer, Controls as PlayerControls, connectPlayer } from 'Player';
@ -10,14 +10,13 @@ import Overlay from './Overlay';
import stl from './player.css';
import EventsToggleButton from '../../Session/EventsToggleButton';
@connectPlayer(state => ({
live: state.live,
}))
@connect(state => ({
//session: state.getIn([ 'sessions', 'current' ]),
fullscreen: state.getIn([ 'components', 'player', 'fullscreen' ]),
nextId: state.getIn([ 'sessions', 'nextId' ]),
closedLive: !!state.getIn([ 'sessions', 'errors' ]),
}), {
hideTargetDefiner,
fullscreenOff,
@ -26,6 +25,8 @@ export default class Player extends React.PureComponent {
screenWrapper = React.createRef();
componentDidMount() {
if (this.props.closedLive) return;
const parentElement = findDOMNode(this.screenWrapper.current); //TODO: good architecture
attachPlayer(parentElement);
}
@ -38,6 +39,7 @@ export default class Player extends React.PureComponent {
fullscreenOff,
nextId,
live,
closedLive,
} = this.props;
return (
@ -45,12 +47,10 @@ export default class Player extends React.PureComponent {
className={ cn(className, stl.playerBody, "flex flex-col relative") }
data-bottom-block={ bottomBlockIsActive }
>
{ fullscreen &&
<EscapeButton onClose={ fullscreenOff } />
}
{fullscreen && <EscapeButton onClose={ fullscreenOff } />}
{!live && !fullscreen && <EventsToggleButton /> }
<div className="relative flex-1 overflow-hidden">
<Overlay nextId={nextId} togglePlay={PlayerControls.togglePlay} />
<Overlay nextId={nextId} togglePlay={PlayerControls.togglePlay} closedLive={closedLive} />
<div
className={ stl.screenWrapper }
ref={ this.screenWrapper }

View file

@ -22,7 +22,6 @@ import StackEvents from './StackEvents/StackEvents';
import Storage from './Storage';
import Profiler from './Profiler';
import { ConnectedPerformance } from './Performance';
import PlayerBlockHeader from './PlayerBlockHeader';
import GraphQL from './GraphQL';
import Fetch from './Fetch';
import Exceptions from './Exceptions/Exceptions';
@ -34,6 +33,7 @@ import styles from './playerBlock.css';
@connect(state => ({
fullscreen: state.getIn([ 'components', 'player', 'fullscreen' ]),
bottomBlock: state.getIn([ 'components', 'player', 'bottomBlock' ]),
closedLive: !!state.getIn([ 'sessions', 'errors' ]),
}))
export default class PlayerBlock extends React.PureComponent {
componentDidUpdate(prevProps) {
@ -44,13 +44,14 @@ export default class PlayerBlock extends React.PureComponent {
}
render() {
const { fullscreen, bottomBlock } = this.props;
const { fullscreen, bottomBlock, closedLive } = this.props;
return (
<div className={ cn(styles.playerBlock, "flex flex-col") }>
<Player
className="flex-1"
bottomBlockIsActive={ !fullscreen && bottomBlock !== NONE }
closedLive={closedLive}
/>
{ !fullscreen && !!bottomBlock &&
<div className="">

View file

@ -2,26 +2,26 @@ import { connect } from 'react-redux';
import { withRouter } from 'react-router-dom';
import { browserIcon, osIcon, deviceTypeIcon } from 'App/iconNames';
import { formatTimeOrDate } from 'App/date';
import { sessions as sessionsRoute, withSiteId } from 'App/routes';
import { Icon, CountryFlag, IconButton, BackLink } from 'UI';
import { sessions as sessionsRoute, assist as assistRoute, liveSession as liveSessionRoute, withSiteId } from 'App/routes';
import { Icon, CountryFlag, IconButton, BackLink, Popup, Link } from 'UI';
import { toggleFavorite, setSessionPath } from 'Duck/sessions';
import cn from 'classnames';
import { connectPlayer } from 'Player';
import HeaderInfo from './HeaderInfo';
// import HeaderInfo from './HeaderInfo';
import SharePopup from '../shared/SharePopup/SharePopup';
import { fetchList as fetchListIntegration } from 'Duck/integrations/actions';
import { countries } from 'App/constants';
import SessionMetaList from 'Shared/SessionItem/SessionMetaList';
import stl from './playerBlockHeader.css';
import Issues from './Issues/Issues';
import Autoplay from './Autoplay';
import AssistActions from '../Assist/components/AssistActions';
import AssistTabs from '../Assist/components/AssistTabs';
import SessionInfoItem from './SessionInfoItem'
const SESSIONS_ROUTE = sessionsRoute();
function capitalise(str) {
return str[0].toUpperCase() + str.slice(1);
}
const ASSIST_ROUTE = assistRoute();
@connectPlayer(state => ({
width: state.width,
height: state.height,
@ -42,6 +42,8 @@ function capitalise(str) {
funnelRef: state.getIn(['funnels', 'navRef']),
siteId: state.getIn([ 'user', 'siteId' ]),
hasSessionsPath: hasSessioPath && !isAssist,
metaList: state.getIn(['customFields', 'list']).map(i => i.key),
closedLive: !!state.getIn([ 'sessions', 'errors' ]),
}
}, {
toggleFavorite, fetchListIntegration, setSessionPath
@ -53,16 +55,19 @@ export default class PlayerBlockHeader extends React.PureComponent {
this.props.fetchListIntegration('issues')
}
getDimension = (width, height) => (
<div className="flex items-center">
{ width || 'x' } <Icon name="close" size="12" className="mx-1" /> { height || 'x' }
</div>
);
getDimension = (width, height) => {
return width && height ? (
<div className="flex items-center">
{ width || 'x' } <Icon name="close" size="12" className="mx-1" /> { height || 'x' }
</div>
) : <span className="">Resolution N/A</span>;
}
backHandler = () => {
const { history, siteId, sessionPath } = this.props;
if (sessionPath === history.location.pathname || sessionPath.includes("/session/")) {
history.push(withSiteId(SESSIONS_ROUTE), siteId);
const isLiveSession = sessionPath.includes("/assist");
if (sessionPath === history.location.pathname || sessionPath.includes("/session/") || isLiveSession) {
history.push(withSiteId(isLiveSession ? ASSIST_ROUTE: SESSIONS_ROUTE, siteId));
} else {
history.push(sessionPath ? sessionPath : withSiteId(SESSIONS_ROUTE, siteId));
}
@ -81,14 +86,17 @@ export default class PlayerBlockHeader extends React.PureComponent {
sessionId,
userCountry,
userId,
userNumericHash,
favorite,
startedAt,
userBrowser,
userOs,
userOsVersion,
userDevice,
userBrowserVersion,
userDeviceType,
live,
metadata,
},
loading,
// live,
@ -97,42 +105,68 @@ export default class PlayerBlockHeader extends React.PureComponent {
fullscreen,
hasSessionsPath,
sessionPath,
metaList,
closedLive = false,
siteId,
} = this.props;
const _live = live && !hasSessionsPath;
const _metaList = Object.keys(metadata).filter(i => metaList.includes(i)).map(key => {
const value = metadata[key];
return { label: key, value };
});
return (
<div className={ cn(stl.header, "flex justify-between", { "hidden" : fullscreen}) }>
<div className="flex w-full">
<div className="flex w-full items-center">
<BackLink onClick={this.backHandler} label="Back" />
<div className={ stl.divider } />
<div className="mx-4 flex items-center">
<CountryFlag country={ userCountry } />
<div className="ml-2 font-normal color-gray-dark mt-1 text-sm">
{ formatTimeOrDate(startedAt) } <span>{ this.props.local === 'UTC' ? 'UTC' : ''}</span>
</div>
</div>
{ _live && <AssistTabs userId={userId} userNumericHash={userNumericHash} />}
<HeaderInfo icon={ browserIcon(userBrowser) } label={ `v${ userBrowserVersion }` } />
<HeaderInfo icon={ deviceTypeIcon(userDeviceType) } label={ capitalise(userDevice) } />
<HeaderInfo icon="expand-wide" label={ this.getDimension(width, height) } />
<HeaderInfo icon={ osIcon(userOs) } label={ userOs } />
<div className='ml-auto flex items-center'>
<div className={cn("ml-auto flex items-center", { 'hidden' : closedLive })}>
{ live && hasSessionsPath && (
<div className={stl.liveSwitchButton} onClick={() => this.props.setSessionPath('')}>
This Session is Now Continuing Live
</div>
<>
<div className={stl.liveSwitchButton}>
<Link to={withSiteId(liveSessionRoute(sessionId), siteId)}>
This Session is Now Continuing Live
</Link>
</div>
<div className={ stl.divider } />
</>
)}
{ _live && <AssistTabs userId={userId} />}
{ _live && (
<>
<SessionMetaList className="" metaList={_metaList} />
<div className={ stl.divider } />
</>
)}
<Popup
trigger={(
<IconButton icon="info-circle" primaryText label="More Info" disabled={disabled} />
)}
content={(
<div className=''>
<SessionInfoItem comp={<CountryFlag country={ userCountry } />} label={countries[userCountry]} value={ formatTimeOrDate(startedAt) } />
<SessionInfoItem icon={browserIcon(userBrowser)} label={userBrowser} value={ `v${ userBrowserVersion }` } />
<SessionInfoItem icon={osIcon(userOs)} label={userOs} value={ userOsVersion } />
<SessionInfoItem icon={deviceTypeIcon(userDeviceType)} label={userDeviceType} value={ this.getDimension(width, height) } isLast />
</div>
)}
on="click"
position="top center"
hideOnScroll
/>
<div className={ stl.divider } />
{ _live && <AssistActions isLive userId={userId} /> }
{ !_live && (
<>
<Autoplay />
<div className={ stl.divider } />
<IconButton
className="mr-2"
// className="mr-2"
tooltip="Bookmark"
tooltipPosition="top right"
onClick={ this.toggleFavorite }
@ -140,13 +174,14 @@ export default class PlayerBlockHeader extends React.PureComponent {
icon={ favorite ? 'star-solid' : 'star' }
plain
/>
<div className={ stl.divider } />
<SharePopup
entity="sessions"
id={ sessionId }
showCopyLink={true}
trigger={
<IconButton
className="mr-2"
// className="mr-2"
tooltip="Share Session"
tooltipPosition="top right"
disabled={ disabled }
@ -164,3 +199,4 @@ export default class PlayerBlockHeader extends React.PureComponent {
);
}
}

View file

@ -0,0 +1,24 @@
import React from 'react'
import { Icon } from 'UI'
import cn from 'classnames'
interface Props {
label: string,
icon?: string,
comp?: React.ReactNode,
value: string,
isLast?: boolean,
}
export default function SessionInfoItem(props: Props) {
const { label, icon, value, comp, isLast = false } = props
return (
<div className={cn("flex items-center w-full py-2", {'border-b' : !isLast})}>
<div className="px-2 capitalize" style={{ width: '30px' }}>
{ icon && <Icon name={icon} size="16" /> }
{ comp && comp }
</div>
<div className="px-2 capitalize" style={{ minWidth: '160px' }}>{label}</div>
<div className="color-gray-medium px-2" style={{ minWidth: '130px' }}>{value}</div>
</div>
)
}

View file

@ -0,0 +1 @@
export { default } from './SessionInfoItem';

View file

@ -1,26 +1,19 @@
.header {
height: 50px;
border-bottom: solid thin $gray-light;
padding: 10px 15px;
padding: 0px 15px;
background-color: white;
}
.divider {
width: 1px;
height: 100%;
height: 49px;
margin: 0 15px;
background-color: $gray-light;
}
.liveSwitchButton {
cursor: pointer;
padding: 3px 8px;
border: solid thin $green;
color: $green;
border-radius: 3px;
margin-right: 10px;
&:hover {
background-color: $green;
color: white;
}
text-decoration: underline;
}

View file

@ -0,0 +1,23 @@
.dropdown {
display: flex !important;
padding: 4px 6px;
border-radius: 3px;
color: $gray-darkest;
font-weight: 500;
&:hover {
background-color: $gray-light;
}
}
.dropdownTrigger {
padding: 4px 8px;
border-radius: 3px;
&:hover {
background-color: $gray-light;
}
}
.dropdownIcon {
margin-top: 2px;
margin-left: 3px;
}

View file

@ -0,0 +1,30 @@
import React from 'react'
import stl from './DropdownPlain.css';
import { Dropdown, Icon } from 'UI';
interface Props {
options: any[];
onChange: (e, { name, value }) => void;
icon?: string;
direction?: string;
value: any;
}
export default function DropdownPlain(props: Props) {
const { value, options, icon = "chevron-down", direction = "left" } = props;
return (
<div>
<Dropdown
value={value}
name="sort"
className={ stl.dropdown }
direction={direction}
options={ options }
onChange={ props.onChange }
scrolling
// defaultValue={ value }
icon={ icon ? <Icon name="chevron-down" color="gray-dark" size="14" className={stl.dropdownIcon} /> : null }
/>
</div>
)
}

View file

@ -0,0 +1 @@
export { default } from './DropdownPlain';

View file

@ -18,7 +18,7 @@ interface Props {
function FilterAutoCompleteLocal(props: Props) {
const {
showCloseButton = false,
placeholder = 'Type to search',
placeholder = 'Enter',
showOrButton = false,
onRemoveValue = () => null,
onAddValue = () => null,

View file

@ -14,7 +14,7 @@ interface Props {
}
function FilterItem(props: Props) {
const { isFilter = false, filterIndex, filter } = props;
const canShowValues = !(filter.operator === "isAny" || filter.operator === "onAny");
const canShowValues = !(filter.operator === "isAny" || filter.operator === "onAny" || filter.operator === "isUndefined");
const replaceFilter = (filter) => {
props.onUpdate({ ...filter, value: [""]});

View file

@ -4,9 +4,9 @@ import LiveFilterModal from '../LiveFilterModal';
import OutsideClickDetectingDiv from 'Shared/OutsideClickDetectingDiv';
import { Icon } from 'UI';
import { connect } from 'react-redux';
import { dashboard as dashboardRoute, isRoute } from "App/routes";
import { assist as assistRoute, isRoute } from "App/routes";
const DASHBOARD_ROUTE = dashboardRoute();
const ASSIST_ROUTE = assistRoute();
interface Props {
filter?: any; // event/filter
@ -43,7 +43,7 @@ function FilterSelection(props: Props) {
</OutsideClickDetectingDiv>
{showModal && (
<div className="absolute left-0 top-20 border shadow rounded bg-white z-50">
{ (isLive && !isRoute(DASHBOARD_ROUTE, window.location.pathname)) ? <LiveFilterModal onFilterClick={onFilterClick} /> : <FilterModal onFilterClick={onFilterClick} /> }
{ isRoute(ASSIST_ROUTE, window.location.pathname) ? <LiveFilterModal onFilterClick={onFilterClick} /> : <FilterModal onFilterClick={onFilterClick} /> }
</div>
)}
</div>

View file

@ -8,7 +8,11 @@ import withPermissions from 'HOCs/withPermissions'
import { KEYS } from 'Types/filter/customFilter';
import { applyFilter, addAttribute } from 'Duck/filters';
import { FilterCategory, FilterKey } from 'App/types/filter/filterType';
import { addFilterByKeyAndValue, updateCurrentPage } from 'Duck/liveSearch';
import { addFilterByKeyAndValue, updateCurrentPage, toggleSortOrder } from 'Duck/liveSearch';
import DropdownPlain from 'Shared/DropdownPlain';
import SortOrderButton from 'Shared/SortOrderButton';
import { TimezoneDropdown } from 'UI';
import { capitalize } from 'App/utils';
const AUTOREFRESH_INTERVAL = .5 * 60 * 1000
const PER_PAGE = 20;
@ -23,14 +27,21 @@ interface Props {
addFilterByKeyAndValue: (key: FilterKey, value: string) => void,
updateCurrentPage: (page: number) => void,
currentPage: number,
metaList: any,
sortOrder: string,
toggleSortOrder: (sortOrder: string) => void,
}
function LiveSessionList(props: Props) {
const { loading, filters, list, currentPage } = props;
const { loading, filters, list, currentPage, metaList = [], sortOrder } = props;
var timeoutId;
const hasUserFilter = filters.map(i => i.key).includes(KEYS.USERID);
const [sessions, setSessions] = React.useState(list);
const sortOptions = metaList.map(i => ({
text: capitalize(i), value: i
})).toJS();
const [sortBy, setSortBy] = React.useState('');
const displayedCount = Math.min(currentPage * PER_PAGE, sessions.size);
const addPage = () => props.updateCurrentPage(props.currentPage + 1)
@ -41,6 +52,12 @@ function LiveSessionList(props: Props) {
}
}, []);
useEffect(() => {
if (metaList.size === 0 || !!sortBy) return;
setSortBy(sortOptions[0] && sortOptions[0].value)
}, [metaList]);
useEffect(() => {
const filteredSessions = filters.size > 0 ? props.list.filter(session => {
let hasValidFilter = true;
@ -78,6 +95,10 @@ function LiveSessionList(props: Props) {
}
}
const onSortChange = (e, { value }) => {
setSortBy(value);
}
const timeout = () => {
timeoutId = setTimeout(() => {
props.fetchLiveList();
@ -87,6 +108,29 @@ function LiveSessionList(props: Props) {
return (
<div>
<div className="flex mb-6 justify-between items-end">
<div className="flex items-baseline">
<h3 className="text-2xl capitalize">
<span>Live Sessions</span>
<span className="ml-2 font-normal color-gray-medium">{sessions.size}</span>
</h3>
</div>
<div className="flex items-center">
<div className="flex items-center">
<span className="mr-2 color-gray-medium">Timezone</span>
<TimezoneDropdown />
</div>
<div className="flex items-center ml-6 mr-4">
<span className="mr-2 color-gray-medium">Sort By</span>
<DropdownPlain
options={sortOptions}
onChange={onSortChange}
value={sortBy}
/>
</div>
<SortOrderButton onChange={props.toggleSortOrder} sortOrder={sortOrder} />
</div>
</div>
<NoContent
title={"No live sessions."}
subtext={
@ -99,22 +143,25 @@ function LiveSessionList(props: Props) {
show={ !loading && sessions && sessions.size === 0}
>
<Loader loading={ loading }>
{sessions && sessions.take(displayedCount).map(session => (
{sessions && sessions.sortBy(i => i.metadata[sortBy]).update(list => {
return sortOrder === 'desc' ? list.reverse() : list;
}).take(displayedCount).map(session => (
<SessionItem
key={ session.sessionId }
session={ session }
live
hasUserFilter={hasUserFilter}
onUserClick={onUserClick}
metaList={metaList}
/>
))}
<LoadMoreButton
className="mt-3"
displayedCount={displayedCount}
totalCount={sessions.size}
onClick={addPage}
/>
<LoadMoreButton
className="mt-3"
displayedCount={displayedCount}
totalCount={sessions.size}
onClick={addPage}
/>
</Loader>
</NoContent>
</div>
@ -127,6 +174,15 @@ export default withPermissions(['ASSIST_LIVE', 'SESSION_REPLAY'])(connect(
loading: state.getIn([ 'sessions', 'loading' ]),
filters: state.getIn([ 'liveSearch', 'instance', 'filters' ]),
currentPage: state.getIn(["liveSearch", "currentPage"]),
metaList: state.getIn(['customFields', 'list']).map(i => i.key),
sortOrder: state.getIn(['liveSearch', 'sortOrder']),
}),
{ fetchLiveList, applyFilter, addAttribute, addFilterByKeyAndValue, updateCurrentPage }
{
fetchLiveList,
applyFilter,
addAttribute,
addFilterByKeyAndValue,
updateCurrentPage,
toggleSortOrder,
}
)(LiveSessionList));

View file

@ -0,0 +1,3 @@
.bar {
height: 2px;
}

View file

@ -0,0 +1,44 @@
import React from 'react'
import cn from 'classnames'
import stl from './ErrorBars.css'
const GOOD = 'Good'
const LESS_CRITICAL = 'Few Issues'
const CRITICAL = 'Many Issues'
const getErrorState = (count: number) => {
if (count === 0) { return GOOD }
if (count < 3) { return LESS_CRITICAL }
return CRITICAL
}
interface Props {
count?: number
}
export default function ErrorBars(props: Props) {
const { count = 2 } = props
const state = React.useMemo(() => getErrorState(count), [count])
const isGood = state === GOOD
const showFirstBar = (state === LESS_CRITICAL || state === CRITICAL)
const showSecondBar = (state === CRITICAL)
// const showThirdBar = (state === GOOD || state === CRITICAL);
// const bgColor = { 'bg-red' : state === CRITICAL, 'bg-red2' : state === LESS_CRITICAL }
const bgColor = 'bg-red2'
return isGood ? <></> : (
<div>
<div className="relative" style={{ width: '100px' }}>
<div className="grid grid-cols-3 gap-1 absolute inset-0" style={{ opacity: '1'}}>
{ showFirstBar && <div className={cn("rounded-tl rounded-bl", bgColor, stl.bar)}></div> }
{ showSecondBar && <div className={cn("rounded-tl rounded-bl", bgColor, stl.bar)}></div> }
{/* { showThirdBar && <div className={cn("rounded-tl rounded-bl", bgColor, stl.bar)}></div> } */}
</div>
<div className="grid grid-cols-3 gap-1" style={{ opacity: '0.3'}}>
<div className={cn("rounded-tl rounded-bl", bgColor, stl.bar)}></div>
<div className={cn(bgColor, stl.bar)}></div>
{/* <div className={cn("rounded-tr rounded-br", bgColor, stl.bar)}></div> */}
</div>
</div>
<div className="mt-1 color-gray-medium text-sm">{state}</div>
</div>
)
}

View file

@ -0,0 +1 @@
export { default } from './ErrorBars';

View file

@ -0,0 +1,22 @@
import React from 'react'
import cn from 'classnames'
import { TextEllipsis } from 'UI'
interface Props {
className?: string,
label: string,
value?: string,
}
export default function MetaItem(props: Props) {
const { className = '', label, value } = props
return (
<div className={cn("flex items-center rounded", className)}>
<span className="rounded-tl rounded-bl bg-gray-light-shade px-2 color-gray-medium capitalize" style={{ maxWidth: "150px"}}>
<TextEllipsis text={label} className="p-0" popupProps={{ size: 'small', disabled: true }} />
</span>
<span className="rounded-tr rounded-br bg-gray-lightest px-2 color-gray-dark capitalize" style={{ maxWidth: "150px"}}>
<TextEllipsis text={value} className="p-0" popupProps={{ size: 'small', disabled: true }} />
</span>
</div>
)
}

View file

@ -0,0 +1 @@
export { default } from './MetaItem';

View file

@ -0,0 +1,32 @@
import React from 'react'
import { Popup } from 'UI'
import MetaItem from '../MetaItem'
interface Props {
list: any[],
maxLength: number,
}
export default function MetaMoreButton(props: Props) {
const { list, maxLength } = props
return (
<Popup
trigger={ (
<div className="flex items-center">
<span className="rounded bg-active-blue color-teal px-2 color-gray-dark cursor-pointer">
+{list.length - maxLength} More
</span>
</div>
) }
content={
<div className="flex flex-col">
{list.slice(maxLength).map(({ label, value }, index) => (
<MetaItem key={index} label={label} value={value} className="mb-3" />
))}
</div>
}
on="click"
position="center center"
hideOnScroll
/>
)
}

View file

@ -0,0 +1 @@
export { default } from './MetaMoreButton';

View file

@ -11,20 +11,25 @@ import {
} from 'UI';
import { deviceTypeIcon } from 'App/iconNames';
import { toggleFavorite, setSessionPath } from 'Duck/sessions';
import { session as sessionRoute, withSiteId } from 'App/routes';
import { session as sessionRoute, liveSession as liveSessionRoute, withSiteId } from 'App/routes';
import { durationFormatted, formatTimeOrDate } from 'App/date';
import stl from './sessionItem.css';
import LiveTag from 'Shared/LiveTag';
import Bookmark from 'Shared/Bookmark';
import Counter from './Counter'
import { withRouter } from 'react-router-dom';
import SessionMetaList from './SessionMetaList';
import ErrorBars from './ErrorBars';
import { assist as assistRoute, isRoute } from "App/routes";
import { capitalize } from 'App/utils';
const ASSIST_ROUTE = assistRoute();
const Label = ({ label = '', color = 'color-gray-medium'}) => (
<div className={ cn('font-light text-sm', color)}>{label}</div>
)
@connect(state => ({
timezone: state.getIn(['sessions', 'timezone']),
isAssist: state.getIn(['sessions', 'activeTab']).type === 'live',
siteId: state.getIn([ 'user', 'siteId' ]),
}), { toggleFavorite, setSessionPath })
@withRouter
@ -50,75 +55,97 @@ export default class SessionItem extends React.PureComponent {
userDeviceType,
userUuid,
userNumericHash,
live
live,
metadata,
userSessionsCount,
issueTypes,
},
timezone,
onUserClick = () => null,
hasUserFilter = false,
disableUser = false
disableUser = false,
metaList = [],
} = this.props;
const formattedDuration = durationFormatted(duration);
const hasUserId = userId || userAnonymousId;
const isAssist = isRoute(ASSIST_ROUTE, this.props.location.pathname);
const _metaList = Object.keys(metadata).filter(i => metaList.includes(i)).map(key => {
const value = metadata[key];
return { label: key, value };
});
return (
<div className={ stl.sessionItem } id="session-item" >
<div className={ cn('flex items-center mr-auto')}>
<div className="flex items-center mr-6" style={{ width: '200px' }}>
<Avatar seed={ userNumericHash } />
<div className="flex flex-col ml-3 overflow-hidden">
<div
className={cn({'color-teal cursor-pointer': !disableUser && hasUserId, 'color-gray-medium' : disableUser || !hasUserId})}
onClick={() => (!disableUser && !hasUserFilter && hasUserId) && onUserClick(userId, userAnonymousId)}
>
<TextEllipsis text={ userDisplayName } noHint />
<div className={ cn(stl.sessionItem, "flex flex-col bg-white p-3 mb-3") } id="session-item" >
<div className="flex items-start">
<div className={ cn('flex items-center w-full')}>
<div className="flex items-center pr-2" style={{ width: "30%"}}>
<div><Avatar seed={ userNumericHash } isAssist={isAssist} /></div>
{/* <div className="flex flex-col overflow-hidden color-gray-medium ml-3"> */}
<div className="flex flex-col overflow-hidden color-gray-medium ml-3 justify-between items-center">
<div
className={cn('text-lg', {'color-teal cursor-pointer': !disableUser && hasUserId, 'color-gray-medium' : disableUser || !hasUserId})}
onClick={() => (!disableUser && !hasUserFilter) && onUserClick(userId, userAnonymousId)}
>
{userDisplayName}
</div>
{/* <div
className="color-gray-medium text-dotted-underline cursor-pointer"
onClick={() => (!disableUser && !hasUserFilter) && onUserClick(userId, userAnonymousId)}
>
{userSessionsCount} Sessions
</div> */}
</div>
<Label label={ formatTimeOrDate(startedAt, timezone) } />
</div>
</div>
<div className={ cn(stl.iconStack, 'flex-1') }>
<div className={ stl.icons }>
<CountryFlag country={ userCountry } className="mr-6" />
<BrowserIcon browser={ userBrowser } size="16" className="mr-6" />
<OsIcon os={ userOs } size="16" className="mr-6" />
<Icon name={ deviceTypeIcon(userDeviceType) } size="16" className="mr-6" />
<div style={{ width: "20%", height: "38px" }} className="px-2 flex flex-col justify-between">
<div>{formatTimeOrDate(startedAt, timezone) }</div>
<div className="flex items-center color-gray-medium">
{!isAssist && (
<>
<div className="color-gray-medium">
<span className="mr-1">{ eventsCount }</span>
<span>{ eventsCount === 0 || eventsCount > 1 ? 'Events' : 'Event' }</span>
</div>
<div className="mx-2 text-4xl">·</div>
</>
)}
<div>{ live ? <Counter startTime={startedAt} /> : formattedDuration }</div>
</div>
</div>
</div>
<div className="flex flex-col items-center px-4" style={{ width: '150px'}}>
<div className="text-xl">
{ live ? <Counter startTime={startedAt} /> : formattedDuration }
<div style={{ width: "30%", height: "38px" }} className="px-2 flex flex-col justify-between">
<CountryFlag country={ userCountry } className="mr-2" label />
<div className="color-gray-medium flex items-center">
<span className="capitalize" style={{ maxWidth: '70px'}}>
<TextEllipsis text={ capitalize(userBrowser) } popupProps={{ inverted: true, size: "tiny" }} />
</span>
<div className="mx-2 text-4xl">·</div>
<span className="capitalize" style={{ maxWidth: '70px'}}>
<TextEllipsis text={ capitalize(userOs) } popupProps={{ inverted: true, size: "tiny" }} />
</span>
<div className="mx-2 text-4xl">·</div>
<span className="capitalize" style={{ maxWidth: '70px'}}>
<TextEllipsis text={ capitalize(userDeviceType) } popupProps={{ inverted: true, size: "tiny" }} />
</span>
</div>
</div>
<Label label="Duration" />
{ !isAssist && (
<div style={{ width: "10%"}} className="self-center px-2 flex items-center">
<ErrorBars count={issueTypes.length} />
</div>
)}
</div>
{!live && (
<div className="flex flex-col items-center px-4">
<div className={ stl.count }>{ eventsCount }</div>
<Label label={ eventsCount === 0 || eventsCount > 1 ? 'Events' : 'Event' } />
<div className="flex items-center">
<div className={ stl.playLink } id="play-button" data-viewed={ viewed }>
<Link to={ isAssist ? liveSessionRoute(sessionId) : sessionRoute(sessionId) }>
<Icon name={ !viewed && !isAssist ? 'play-fill' : 'play-circle-light' } size="42" color={isAssist ? "tealx" : "teal"} />
</Link>
</div>
)}
</div>
<div className="flex items-center">
{!live && (
<div className="flex flex-col items-center px-4">
<div className={ cn(stl.count, { "color-gray-medium": errorsCount === 0 }) } >{ errorsCount }</div>
<Label label="Errors" color={errorsCount > 0 ? '' : 'color-gray-medium'} />
</div>
)}
{ live && <LiveTag isLive={true} /> }
<div className={ cn(stl.iconDetails, stl.favorite, 'px-4') } data-favourite={favorite} >
<Bookmark sessionId={sessionId} favorite={favorite} />
</div>
<div className={ stl.playLink } id="play-button" data-viewed={ viewed }>
<Link to={ sessionRoute(sessionId) }>
<Icon name={ viewed ? 'play-fill' : 'play-circle-light' } size="30" color="teal" />
</Link>
</div>
</div>
{ _metaList.length > 0 && (
<SessionMetaList className="mt-4" metaList={_metaList} />
)}
</div>
);
}

View file

@ -0,0 +1,25 @@
import React from 'react'
import { Popup } from 'UI'
import cn from 'classnames'
import MetaItem from '../MetaItem';
import MetaMoreButton from '../MetaMoreButton';
interface Props {
className?: string,
metaList: []
}
const MAX_LENGTH = 3;
export default function SessionMetaList(props: Props) {
const { className = '', metaList } = props
return (
<div className={cn("text-sm flex items-start", className)}>
{metaList.slice(0, MAX_LENGTH).map(({ label, value }, index) => (
<MetaItem key={index} label={label} value={''+value} className="mr-3" />
))}
{metaList.length > MAX_LENGTH && (
<MetaMoreButton list={metaList} maxLength={MAX_LENGTH} />
)}
</div>
)
}

View file

@ -0,0 +1 @@
export { default } from './SessionMetaList';

View file

@ -12,12 +12,12 @@
user-select: none;
@mixin defaultHover;
border-radius: 3px;
padding: 10px 10px;
padding-right: 15px;
margin-bottom: 15px;
background-color: white;
display: flex;
align-items: center;
/* padding: 10px 10px; */
/* padding-right: 15px; */
/* margin-bottom: 15px; */
/* background-color: white; */
/* display: flex; */
/* align-items: center; */
border: solid thin #EEEEEE;
& .favorite {

View file

@ -79,8 +79,8 @@ function SessionSearch(props: Props) {
</FilterSelection>
</div>
<div className="ml-auto flex items-center">
<SaveFilterButton />
<SaveFunnelButton />
<SaveFilterButton />
</div>
</div>
</div>

View file

@ -0,0 +1,44 @@
import React from 'react'
import { Icon, Popup } from 'UI'
import cn from 'classnames'
interface Props {
sortOrder: string,
onChange?: (sortOrder: string) => void,
}
export default React.memo(function SortOrderButton(props: Props) {
const { sortOrder, onChange = () => null } = props
const isAscending = sortOrder === 'asc'
return (
<div className="flex items-center border">
<Popup
inverted
size="mini"
trigger={
<div
className={cn("p-1 hover:bg-active-blue", { 'cursor-pointer bg-white' : !isAscending, 'bg-active-blue' : isAscending })}
onClick={() => onChange('asc')}
>
<Icon name="arrow-up" size="14" color={isAscending ? 'teal' : 'gray-medium'} />
</div>
}
content={'Ascending'}
/>
<Popup
inverted
size="mini"
trigger={
<div
className={cn("p-1 hover:bg-active-blue border-l", { 'cursor-pointer bg-white' : isAscending, 'bg-active-blue' : !isAscending })}
onClick={() => onChange('desc')}
>
<Icon name="arrow-down" size="14" color={!isAscending ? 'teal' : 'gray-medium'} />
</div>
}
content={'Descending'}
/>
</div>
)
})

View file

@ -0,0 +1 @@
export { default } from './SortOrderButton';

View file

@ -11,14 +11,15 @@ const ICON_LIST = ['icn_chameleon', 'icn_fox', 'icn_gorilla', 'icn_hippo', 'icn_
'icn_wild1', 'icn_wild_bore']
const Avatar = ({ className, width = "38px", height = "38px", iconSize = 26, seed }) => {
const Avatar = ({ isAssist = false, className, width = "38px", height = "38px", iconSize = 26, seed }) => {
var iconName = avatarIconName(seed);
return (
<div
className={ cn(stl.wrapper, "p-2 border flex items-center justify-center rounded-full")}
className={ cn(stl.wrapper, "p-2 border flex items-center justify-center rounded-full relative")}
style={{ width, height }}
>
<Icon name={iconName} size={iconSize} color="tealx"/>
{isAssist && <div className="w-2 h-2 bg-green rounded-full absolute right-0 bottom-0" style={{ marginRight: '3px', marginBottom: '3px'}} /> }
</div>
);
};

View file

@ -22,7 +22,7 @@ const Confirmation = ({
content={confirmation}
header={header}
className="confirmCustom"
confirmButton={<Button size="small" id="confirm-button" primary>{ confirmButton }</Button>}
confirmButton={<Button size="small" id="confirm-button" className="ml-0" primary>{ confirmButton }</Button>}
cancelButton={<Button size="small" id="cancel-button" plain className={ stl.cancelButton }>{ cancelButton }</Button>}
onCancel={() => proceed(false)}
onConfirm={() => proceed(true)}

View file

@ -1,24 +1,34 @@
import cn from 'classnames';
import { countries } from 'App/constants';
import { Popup } from 'UI';
import { Popup, Icon } from 'UI';
import stl from './countryFlag.css';
const CountryFlag = ({ country, className }) => {
const CountryFlag = React.memo(({ country, className, style = {}, label = false }) => {
const knownCountry = !!country && country !== 'UN';
const countryFlag = knownCountry ? country.toLowerCase() : '';
const countryName = knownCountry ? countries[ country ] : 'Unknown Country';
const countryFlag = knownCountry ? country.toLowerCase() : '';
const countryName = knownCountry ? countries[ country ] : 'Unknown Country';
return (
<Popup
trigger={ knownCountry
? <span className={ cn(`flag flag-${ countryFlag }`, className, stl.default) } />
: <span className={ className } >{ "N/A" }</span>
}
content={ countryName }
inverted
size="tiny"
/>
<div className="flex items-center" style={style}>
<Popup
trigger={ knownCountry
? <div className={ cn(`flag flag-${ countryFlag }`, className, stl.default) } />
: (
<div className="flex items-center w-full">
<Icon name="flag-na" size="22" className="" />
<div className="ml-2 leading-none" style={{ whiteSpace: 'nowrap'}}>Unknown Country</div>
</div>
)
// : <div className={ cn('text-sm', className) }>{ "N/A" }</div>
}
content={ countryName }
inverted
size="tiny"
/>
{ knownCountry && label && <div className={ stl.label }>{ countryName }</div> }
</div>
);
}
})
CountryFlag.displayName = "CountryFlag";

View file

@ -1,4 +1,8 @@
.default {
width: 22px !important;
height: 14px !important;
}
.label {
line-height: 0 !important;
}

View file

@ -9,8 +9,10 @@ const IconButton = React.forwardRef(({
onClick,
plain = false,
shadow = false,
red = false,
primary = false,
primaryText = false,
redText = false,
outline = false,
loading = false,
roundedOutline = false,
@ -40,7 +42,9 @@ const IconButton = React.forwardRef(({
[ stl.active ]: active,
[ stl.shadow ]: shadow,
[ stl.primary ]: primary,
[ stl.red ]: red,
[ stl.primaryText ]: primaryText,
[ stl.redText ]: redText,
[ stl.outline ]: outline,
[ stl.circle ]: circle,
[ stl.roundedOutline ]: roundedOutline,

View file

@ -67,17 +67,47 @@
&.primary {
background-color: $teal;
box-shadow: 0 0 0 1px rgba(62, 170, 175, .8) inset !important;
box-shadow: 0 0 0 1px $teal inset !important;
& .icon {
fill: white;
}
& svg {
fill: white;
}
& .label {
color: white !important;
}
&:hover {
background-color: $teal-dark;
}
}
&.red {
background-color: $red;
box-shadow: 0 0 0 1px $red inset !important;
& .icon {
fill: white;
}
& svg {
fill: white;
}
& .label {
color: white !important;
}
&:hover {
background-color: $red;
filter: brightness(90%);
}
}
&.outline {
box-shadow: 0 0 0 1px $teal inset !important;
& .label {
@ -116,4 +146,14 @@
.primaryText .label {
color: $teal !important;
}
.redText {
& .label {
color: $red !important;
}
& svg {
fill: $red;
}
}

View file

@ -1,7 +1,7 @@
.loader {
display: block;
margin: auto;
background-image: svg-load(openreplay-preloader.svg, fill=#CCC);
background-image: svg-load(openreplay-preloader.svg, fill=#ffffff00);
background-repeat: no-repeat;
background-size: contain;
background-position: center center;

View file

@ -1,7 +1,7 @@
.textEllipsis {
text-overflow: ellipsis;
overflow: hidden;
display: inline-block;
/* display: inline-block; */
white-space: nowrap;
max-width: 100%;
}

View file

@ -15,21 +15,24 @@ const EDIT = editType(name);
const CLEAR_SEARCH = `${name}/CLEAR_SEARCH`;
const APPLY = `${name}/APPLY`;
const UPDATE_CURRENT_PAGE = `${name}/UPDATE_CURRENT_PAGE`;
const TOGGLE_SORT_ORDER = `${name}/TOGGLE_SORT_ORDER`;
const initialState = Map({
list: List(),
instance: new Filter({ filters: [] }),
filterSearchList: {},
currentPage: 1,
sortOrder: 'asc',
});
function reducer(state = initialState, action = {}) {
switch (action.type) {
case EDIT:
return state.mergeIn(['instance'], action.instance);
case UPDATE_CURRENT_PAGE:
return state.set('currentPage', action.page);
case TOGGLE_SORT_ORDER:
return state.set('sortOrder', action.order);
}
return state;
}
@ -98,4 +101,11 @@ export function updateCurrentPage(page) {
type: UPDATE_CURRENT_PAGE,
page,
};
}
export function toggleSortOrder (order) {
return {
type: TOGGLE_SORT_ORDER,
order,
};
}

View file

@ -243,9 +243,12 @@ export const addFilter = (filter) => (dispatch, getState) => {
}
}
export const addFilterByKeyAndValue = (key, value) => (dispatch, getState) => {
export const addFilterByKeyAndValue = (key, value, operator = undefined) => (dispatch, getState) => {
let defaultFilter = filtersMap[key];
defaultFilter.value = value;
if (operator) {
defaultFilter.operator = operator;
}
dispatch(addFilter(defaultFilter));
}

View file

@ -270,12 +270,7 @@ function init(session) {
}
export const fetchList = (params = {}, clear = false, live = false) => (dispatch, getState) => {
const activeTab = getState().getIn([ 'sessions', 'activeTab' ]);
return dispatch((activeTab && activeTab.type === 'live' || live )? {
types: FETCH_LIVE_LIST.toArray(),
call: client => client.post('/assist/sessions', params),
} : {
return dispatch({
types: FETCH_LIST.toArray(),
call: client => client.post('/sessions/search2', params),
clear,
@ -283,13 +278,6 @@ export const fetchList = (params = {}, clear = false, live = false) => (dispatch
})
}
// export const fetchLiveList = (id) => (dispatch, getState) => {
// return dispatch({
// types: FETCH_LIVE_LIST.toArray(),
// call: client => client.get('/assist/sessions'),
// })
// }
export function fetchErrorStackList(sessionId, errorId) {
return {
types: FETCH_ERROR_STACK.toArray(),

View file

@ -26,6 +26,7 @@ export enum ConnectionStatus {
Inactive,
Disconnected,
Error,
Closed,
}
export enum RemoteControlStatus {
@ -37,6 +38,8 @@ export enum RemoteControlStatus {
export function getStatusText(status: ConnectionStatus): string {
switch(status) {
case ConnectionStatus.Closed:
return 'Closed...';
case ConnectionStatus.Connecting:
return "Connecting...";
case ConnectionStatus.Connected:

View file

@ -82,9 +82,11 @@ const routerOBTabString = `:activeTab(${ Object.values(OB_TABS).join('|') })`;
export const onboarding = (tab = routerOBTabString) => `/onboarding/${ tab }`;
export const sessions = params => queried('/sessions', params);
export const assist = params => queried('/assist', params);
export const session = (sessionId = ':sessionId', hash) => hashed(`/session/${ sessionId }`, hash);
export const liveSession = (sessionId = ':sessionId', hash) => hashed(`/live/session/${ sessionId }`, hash);
export const liveSession = (sessionId = ':sessionId', hash) => hashed(`/assist/${ sessionId }`, hash);
// export const liveSession = (sessionId = ':sessionId', hash) => hashed(`/live/session/${ sessionId }`, hash);
export const errors = params => queried('/errors', params);
export const error = (id = ':errorId', hash) => hashed(`/errors/${ id }`, hash);
@ -105,7 +107,7 @@ export const METRICS_QUERY_KEY = 'metrics';
export const SOURCE_QUERY_KEY = 'source';
export const WIDGET_QUERY_KEY = 'widget';
const REQUIRED_SITE_ID_ROUTES = [ liveSession(''), session(''), sessions(), dashboard(''), error(''), errors(), onboarding(''), funnel(''), funnelIssue(''), ];
const REQUIRED_SITE_ID_ROUTES = [ liveSession(''), session(''), sessions(), assist(), dashboard(''), error(''), errors(), onboarding(''), funnel(''), funnelIssue(''), ];
const routeNeedsSiteId = path => REQUIRED_SITE_ID_ROUTES.some(r => path.startsWith(r));
const siteIdToUrl = (siteId = ':siteId') => {
if (Array.isArray(siteId)) {
@ -128,10 +130,9 @@ export function isRoute(route, path){
routeParts.every((p, i) => p.startsWith(':') || p === pathParts[ i ]);
}
const SITE_CHANGE_AVALIABLE_ROUTES = [ sessions(), dashboard(), errors(), onboarding('')];
const SITE_CHANGE_AVALIABLE_ROUTES = [ sessions(), assist(), dashboard(), errors(), onboarding('')];
export const siteChangeAvaliable = path => SITE_CHANGE_AVALIABLE_ROUTES.some(r => isRoute(r, path));
export const redirects = Object.entries({
[ client('custom-fields') ]: client(CLIENT_TABS.CUSTOM_FIELDS),
});

View file

@ -123,4 +123,22 @@
&:hover {
background-color: $active-blue;
}
}
.text-dotted-underline {
text-decoration: underline dotted !important;
}
.divider {
width: 1px;
margin: 0 15px;
background-color: $gray-light;
}
.divider-h {
height: 1px;
width: 100%;
margin: 25px 0;
background-color: $gray-light;
}

View file

@ -336,4 +336,13 @@ a:hover {
overflow: hidden;
text-overflow: ellipsis;
margin-right: 15px;
}
.ui.mini.modal>.header:not(.ui) {
padding: 10px 17px !important;
font-size: 16px !important;
}
.ui.modal>.content {
padding: 10px 17px !important;
}

View file

@ -0,0 +1,3 @@
<svg width="22" height="14" viewBox="0 0 22 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M2 0C0.895431 0 0 0.89543 0 2V12C0 13.1046 0.89543 14 2 14H20C21.1046 14 22 13.1046 22 12V2C22 0.895431 21.1046 0 20 0H2ZM11.0757 10V3.60156H9.98145V8.19385L7.10303 3.60156H6V10H7.10303V5.4165L9.97266 10H11.0757ZM15.0396 3.60156H15.3032L17.7202 10H16.5601L16.0423 8.50146H13.5654L13.0488 10H11.8931L14.3013 3.60156H14.5605H15.0396ZM13.8668 7.62695H15.7402L14.8024 4.91255L13.8668 7.62695Z" fill="#C4C4C4"/>
</svg>

After

Width:  |  Height:  |  Size: 559 B

View file

@ -1 +1,7 @@
<svg id="e8s3e2insbce1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 70 70" shape-rendering="geometricPrecision" text-rendering="geometricPrecision"><style><![CDATA[#e8s3e2insbce2_tr {animation: e8s3e2insbce2_tr__tr 2200ms linear infinite normal forwards}@keyframes e8s3e2insbce2_tr__tr { 0% {transform: translate(35px,35px) rotate(-359deg)} 50% {transform: translate(35px,35px) rotate(0deg)} 100% {transform: translate(35px,35px) rotate(359deg)} }#e8s3e2insbce2_ts {animation: e8s3e2insbce2_ts__ts 2200ms linear infinite normal forwards}@keyframes e8s3e2insbce2_ts__ts { 0% {transform: scale(0.510000,0.510000)} 50% {transform: scale(1,1)} 100% {transform: scale(0.510000,0.510000)} }#e8s3e2insbce3 {animation: e8s3e2insbce3_c_o 2200ms linear infinite normal forwards}@keyframes e8s3e2insbce3_c_o { 0% {opacity: 0} 27.272727% {opacity: 0;animation-timing-function: cubic-bezier(0.420000,0,0.580000,1)} 40.909091% {opacity: 1;animation-timing-function: cubic-bezier(0.420000,0,0.580000,1)} 59.090909% {opacity: 1;animation-timing-function: cubic-bezier(0.420000,0,0.580000,1)} 100% {opacity: 0} }#e8s3e2insbce4 {animation: e8s3e2insbce4_c_o 2200ms linear infinite normal forwards}@keyframes e8s3e2insbce4_c_o { 0% {opacity: 0} 18.181818% {opacity: 0;animation-timing-function: cubic-bezier(0.420000,0,0.580000,1)} 40.909091% {opacity: 1;animation-timing-function: cubic-bezier(0.420000,0,0.580000,1)} 59.090909% {opacity: 1;animation-timing-function: cubic-bezier(0.420000,0,0.580000,1)} 90.909091% {opacity: 0} 100% {opacity: 0} }#e8s3e2insbce5 {animation: e8s3e2insbce5_c_o 2200ms linear infinite normal forwards}@keyframes e8s3e2insbce5_c_o { 0% {opacity: 0} 9.090909% {opacity: 0;animation-timing-function: cubic-bezier(0.420000,0,0.580000,1)} 40.909091% {opacity: 1;animation-timing-function: cubic-bezier(0.420000,0,0.580000,1)} 59.090909% {opacity: 1;animation-timing-function: cubic-bezier(0.420000,0,0.580000,1)} 81.818182% {opacity: 0} 100% {opacity: 0} }#e8s3e2insbce6 {animation: e8s3e2insbce6_c_o 2200ms linear infinite normal forwards}@keyframes e8s3e2insbce6_c_o { 0% {opacity: 0;animation-timing-function: cubic-bezier(0.420000,0,0.580000,1)} 40.909091% {opacity: 1;animation-timing-function: cubic-bezier(0.420000,0,0.580000,1)} 59.090909% {opacity: 1;animation-timing-function: cubic-bezier(0.420000,0,0.580000,1)} 72.727273% {opacity: 0} 100% {opacity: 0} }]]></style><g id="e8s3e2insbce2_tr" transform="translate(35,35) rotate(-359)"><g id="e8s3e2insbce2_ts" transform="scale(0.510000,0.510000)"><g id="e8s3e2insbce2" transform="translate(-35,-35)"><circle id="e8s3e2insbce3" r="10.500000" transform="matrix(1 0 0 1 19.50000000000000 50.50000000000000)" opacity="0" fill="rgb(66,174,94)" fill-rule="evenodd" stroke="none" stroke-width="1"/><circle id="e8s3e2insbce4" r="10.500000" transform="matrix(1 0 0 1 50.50000000000000 50.50000000000000)" opacity="0" fill="rgb(57,177,255)" fill-rule="evenodd" stroke="none" stroke-width="1"/><circle id="e8s3e2insbce5" r="10.500000" transform="matrix(1 0 0 1 50.50000000000000 19.50000000000000)" opacity="0" fill="rgb(57,78,255)" fill-rule="evenodd" stroke="none" stroke-width="1"/><circle id="e8s3e2insbce6" r="10.500000" transform="matrix(1 0 0 1 19.50000000000000 19.50000000000000)" opacity="0" fill="rgb(0,145,147)" fill-rule="evenodd" stroke="none" stroke-width="1"/></g></g></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 55 55" fill="none" height="55" width="55"><style>
@keyframes uro78awc4tzn00dgpjdreb25_t { 0% { transform: translate(27.5px,27.5px) rotate(0deg) scale(1,1) translate(0px,0px); } 100% { transform: translate(27.5px,27.5px) rotate(355deg) scale(1,1) translate(0px,0px); } }
@keyframes uro78awc4tzn00dgpjdreb25_sw { 0% { stroke-width: 3px; } 100% { stroke-width: 3px; } }
@keyframes xsc8rs7q380bevvc8x83et5f_t { 0% { transform: translate(29.7px,27.5px) scale(1,1) translate(-15.2px,-18px); } 50% { transform: translate(29.7px,27.5px) scale(1,1) translate(-15.2px,-18px); } 100% { transform: translate(29.7px,27.5px) scale(1,1) translate(-15.2px,-18px); } }
@keyframes xsc8rs7q380bevvc8x83et5f_o { 0% { opacity: .1; } 50% { opacity: 1; } 100% { opacity: .1; } }
@keyframes xsc8rs7q380bevvc8x83et5f_d { 0% { d: path('M20.2,18L9.8,11.9L9.8,24.1L20.2,18ZM21.7,16.7C21.9,16.9,22.1,17.1,22.2,17.3C22.3,17.5,22.4,17.7,22.4,18C22.4,18.3,22.3,18.5,22.2,18.7C22.1,18.9,21.9,19.1,21.7,19.3L10.2,25.9C9.3,26.4,8,25.8,8,24.6L8,11.4C8,10.2,9.3,9.6,10.2,10.1L21.7,16.7Z'); } 50% { d: path('M23,18L6.9,8.7L6.9,27.3L23,18ZM25.2,16.1C25.5,16.3,25.8,16.5,26,16.9C26.2,17.2,26.3,17.6,26.3,18C26.3,18.4,26.2,18.8,26,19.1C25.8,19.5,25.5,19.7,25.2,19.9L7.5,30.2C6.1,31,4.1,30,4.1,28.2L4.1,7.8C4.1,6,6.1,5,7.5,5.8L25.2,16.1Z'); } 100% { d: path('M20.2,18L9.8,11.9L9.8,24.1L20.2,18ZM21.7,16.7C21.9,16.9,22.1,17.1,22.2,17.3C22.3,17.5,22.4,17.7,22.4,18C22.4,18.3,22.3,18.5,22.2,18.7C22.1,18.9,21.9,19.1,21.7,19.3L10.2,25.9C9.3,26.4,8,25.8,8,24.6L8,11.4C8,10.2,9.3,9.6,10.2,10.1L21.7,16.7Z'); } }
</style><ellipse stroke="#6070f8" stroke-width="3" stroke-dasharray="6 6" rx="25" ry="25" transform="translate(27.5,27.5)" style="animation: 1s linear infinite both uro78awc4tzn00dgpjdreb25_t, 1s linear infinite both uro78awc4tzn00dgpjdreb25_sw;"/><path d="M20.2 18l-10.4-6.1v12.2l10.4-6.1Zm1.5-1.3c.2 .2 .4 .4 .5 .6c.1 .2 .2 .4 .2 .7c0 .3-0.1 .5-0.2 .7c-0.1 .2-0.3 .4-0.5 .6l-11.5 6.6c-0.9 .5-2.2-0.1-2.2-1.3v-13.2c0-1.2 1.3-1.8 2.2-1.3l11.5 6.6Z" fill="#122af5" opacity=".1" transform="translate(29.7,27.5) translate(-15.2,-18)" style="animation: 1s linear infinite both xsc8rs7q380bevvc8x83et5f_t, 1s linear infinite both xsc8rs7q380bevvc8x83et5f_o, 1s linear infinite both xsc8rs7q380bevvc8x83et5f_d;"/></svg>

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

View file

@ -34,6 +34,7 @@ export default Record({
rangeValue,
startDate,
endDate,
groupByUser: true,
sort: 'startTs',
order: 'desc',

View file

@ -44,7 +44,7 @@ export const filtersMap = {
[FilterKey.DURATION]: { key: FilterKey.DURATION, type: FilterType.DURATION, category: FilterCategory.RECORDING_ATTRIBUTES, label: 'Duration', operator: 'is', operatorOptions: filterOptions.getOperatorsByKeys(['is']), icon: 'filters/duration' },
[FilterKey.USER_COUNTRY]: { key: FilterKey.USER_COUNTRY, type: FilterType.MULTIPLE_DROPDOWN, category: FilterCategory.RECORDING_ATTRIBUTES, label: 'User Country', operator: 'is', operatorOptions: filterOptions.getOperatorsByKeys(['is', 'isAny', 'isNot']), icon: 'filters/country', options: countryOptions },
// [FilterKey.CONSOLE]: { key: FilterKey.CONSOLE, type: FilterType.MULTIPLE, category: FilterCategory.JAVASCRIPT, label: 'Console', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/console' },
[FilterKey.USERID]: { key: FilterKey.USERID, type: FilterType.MULTIPLE, category: FilterCategory.USER, label: 'User Id', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/userid' },
[FilterKey.USERID]: { key: FilterKey.USERID, type: FilterType.MULTIPLE, category: FilterCategory.USER, label: 'User Id', operator: 'is', operatorOptions: filterOptions.stringOperators.concat([{ text: 'is undefined', value: 'isUndefined'}]), icon: 'filters/userid' },
[FilterKey.USERANONYMOUSID]: { key: FilterKey.USERANONYMOUSID, type: FilterType.MULTIPLE, category: FilterCategory.USER, label: 'User AnonymousId', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/userid' },
// PERFORMANCE

View file

@ -36,7 +36,7 @@ export default Record({
stackEvents: List(),
resources: List(),
missedResources: List(),
metadata: List(),
metadata: Map(),
favorite: false,
filterId: '',
messagesUrl: '',
@ -76,6 +76,7 @@ export default Record({
socket: null,
isIOS: false,
revId: '',
userSessionsCount: 0,
}, {
fromJS:({
startTs=0,

View file

@ -13,7 +13,7 @@ const oss = {
ORIGIN: () => 'window.location.origin',
API_EDP: () => 'window.location.origin + "/api"',
ASSETS_HOST: () => 'window.location.origin + "/assets"',
VERSION: '1.5.0',
VERSION: '1.5.1',
SOURCEMAP: true,
MINIO_ENDPOINT: process.env.MINIO_ENDPOINT,
MINIO_PORT: process.env.MINIO_PORT,
@ -21,7 +21,7 @@ const oss = {
MINIO_ACCESS_KEY: process.env.MINIO_ACCESS_KEY,
MINIO_SECRET_KEY: process.env.MINIO_SECRET_KEY,
ICE_SERVERS: process.env.ICE_SERVERS,
TRACKER_VERSION: '3.5.0' // trackerInfo.version,
TRACKER_VERSION: '3.5.2' // trackerInfo.version,
}
module.exports = {

View file

@ -13,7 +13,7 @@ module.exports = {
//'appearance',
// 'backgroundAttachment',
'backgroundColor',
// 'backgroundOpacity',
'backgroundOpacity',
// 'backgroundPosition',
// 'backgroundRepeat',
// 'backgroundSize',

View file

@ -0,0 +1,8 @@
BEGIN;
CREATE OR REPLACE FUNCTION openreplay_version()
RETURNS text AS
$$
SELECT 'v1.5.1'
$$ LANGUAGE sql IMMUTABLE;
COMMIT;

View file

@ -15,7 +15,7 @@ fatal()
exit 1
}
version="v1.5.0"
version="v1.5.1"
usr=`whoami`
# Installing k3s

View file

@ -22,4 +22,4 @@ version: 0.1.0
# follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes.
# Ref: https://github.com/helm/helm/issues/7858#issuecomment-608114589
AppVersion: "v1.5.0"
AppVersion: "v1.5.1"

View file

@ -21,4 +21,4 @@ version: 0.1.0
# incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes.
AppVersion: "v1.5.0"
AppVersion: "v1.5.1"

View file

@ -21,4 +21,4 @@ version: 0.1.0
# incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes.
AppVersion: "v1.5.0"
AppVersion: "v1.5.1"

View file

@ -21,4 +21,4 @@ version: 0.1.0
# incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes.
AppVersion: "v1.5.0"
AppVersion: "v1.5.1"

View file

@ -21,4 +21,4 @@ version: 0.1.0
# incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes.
AppVersion: "v1.5.0"
AppVersion: "v1.5.1"

View file

@ -21,4 +21,4 @@ version: 0.1.0
# incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes.
AppVersion: "v1.5.0"
AppVersion: "v1.5.1"

View file

@ -21,4 +21,4 @@ version: 0.1.0
# incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes.
AppVersion: "v1.5.0"
AppVersion: "v1.5.1"

View file

@ -21,4 +21,4 @@ version: 0.1.0
# incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes.
AppVersion: "v1.5.0"
AppVersion: "v1.5.1"

View file

@ -21,4 +21,4 @@ version: 0.1.0
# incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes.
AppVersion: "v1.5.0"
AppVersion: "v1.5.1"

Some files were not shown because too many files have changed in this diff Show more