commit
a9489ab63d
107 changed files with 1150 additions and 455 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"""\
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
3
backend/pkg/db/cache/messages_common.go
vendored
3
backend/pkg/db/cache/messages_common.go
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ func getTimeoutContext() context.Context {
|
|||
|
||||
type Conn struct {
|
||||
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())
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -68,7 +68,7 @@ func (conn *Conn) InsertSessionStart(sessionID uint64, s *types.Session) error {
|
|||
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,7 +113,7 @@ 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 (
|
||||
|
|
@ -126,7 +126,7 @@ func (conn *Conn) InsertRequest(sessionID uint64, timestamp uint64, index uint64
|
|||
}
|
||||
|
||||
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 (
|
||||
|
|
@ -139,7 +139,7 @@ func (conn *Conn) InsertCustomEvent(sessionID uint64, timestamp uint64, index ui
|
|||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
|
@ -228,5 +227,3 @@ func (conn *Conn) InsertIssueEvent(sessionID uint64, projectID uint32, e *messag
|
|||
}
|
||||
return tx.commit()
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,21 +1,18 @@
|
|||
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(``);
|
||||
}
|
||||
|
||||
|
||||
func (conn *Conn) InsertWebStatsPerformance(sessionID uint64, p *PerformanceTrackAggr) error {
|
||||
timestamp := (p.TimestampEnd + p.TimestampStart) / 2
|
||||
return conn.exec(`
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
8
ee/scripts/helm/db/init_dbs/postgresql/1.5.1/1.5.1.sql
Normal file
8
ee/scripts/helm/db/init_dbs/postgresql/1.5.1/1.5.1.sql
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
BEGIN;
|
||||
CREATE OR REPLACE FUNCTION openreplay_version()
|
||||
RETURNS text AS
|
||||
$$
|
||||
SELECT 'v1.5.1-ee'
|
||||
$$ LANGUAGE sql IMMUTABLE;
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -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 } />
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
&.disabled {
|
||||
/* background-color: red; */
|
||||
& svg {
|
||||
fill: red;
|
||||
fill: $red;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,8 +41,8 @@ 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>
|
||||
<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')}>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
.wrapper {
|
||||
background-color: white;
|
||||
border: solid thin #000;
|
||||
border: solid thin $gray-light;
|
||||
border-radius: 3px;
|
||||
position: fixed;
|
||||
width: 300px;
|
||||
box-shadow: 1px 1px 1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.headerTitle {
|
||||
|
|
|
|||
|
|
@ -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 don’t 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
@ -69,7 +65,6 @@ const allowedQueryKeys = [
|
|||
applyFilter,
|
||||
addAttribute,
|
||||
fetchFilterVariables,
|
||||
fetchIntegrationVariables,
|
||||
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 /> }
|
||||
</>
|
||||
)}
|
||||
<SessionList onMenuItemClick={this.setActiveTab} />
|
||||
</div>
|
||||
</div>
|
||||
<RehydrateSlidePanel
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 }>
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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,11 +40,9 @@ function WebPlayer ({ showAssist, session, toggleFullscreen, closeBottomBlock, l
|
|||
}
|
||||
}, [])
|
||||
|
||||
|
||||
return (
|
||||
<PlayerProvider>
|
||||
<InitLoader className="flex-1 p-3">
|
||||
{ showAssist && <Assist session={session} /> }
|
||||
<PlayerBlockHeader fullscreen={fullscreen} />
|
||||
<div className={ styles.session } data-fullscreen={fullscreen}>
|
||||
<PlayerBlock />
|
||||
|
|
@ -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)));
|
||||
|
|
|
|||
60
frontend/app/components/Session/LiveSession.js
Normal file
60
frontend/app/components/Session/LiveSession.js
Normal 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));
|
||||
|
|
@ -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,7 +265,8 @@ export default class Controls extends React.Component {
|
|||
{ !live && <Timeline jump={ this.props.jump } /> }
|
||||
{ !fullscreen &&
|
||||
<div className={ styles.buttons } data-is-live={ live }>
|
||||
{ !live ?
|
||||
<div>
|
||||
{ !live && (
|
||||
<div className={ styles.buttonsLeft }>
|
||||
{ this.renderPlayBtn() }
|
||||
<ControlButton
|
||||
|
|
@ -280,13 +283,17 @@ export default class Controls extends React.Component {
|
|||
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
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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 doesn’t 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">
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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="">
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
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 } />
|
||||
{ _live && <AssistTabs userId={userId} userNumericHash={userNumericHash} />}
|
||||
|
||||
<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>
|
||||
|
||||
<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('')}>
|
||||
<>
|
||||
<div className={stl.liveSwitchButton}>
|
||||
<Link to={withSiteId(liveSessionRoute(sessionId), siteId)}>
|
||||
This Session is Now Continuing Live
|
||||
</Link>
|
||||
</div>
|
||||
<div className={ stl.divider } />
|
||||
</>
|
||||
)}
|
||||
|
||||
{ _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>
|
||||
)}
|
||||
{ _live && <AssistTabs userId={userId} />}
|
||||
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 {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './SessionInfoItem';
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
1
frontend/app/components/shared/DropdownPlain/index.ts
Normal file
1
frontend/app/components/shared/DropdownPlain/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from './DropdownPlain';
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: [""]});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,13 +143,16 @@ 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}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
|
@ -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));
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.bar {
|
||||
height: 2px;
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './ErrorBars';
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './MetaItem';
|
||||
|
|
@ -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
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './MetaMoreButton';
|
||||
|
|
@ -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,76 +55,98 @@ 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(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({'color-teal cursor-pointer': !disableUser && hasUserId, 'color-gray-medium' : disableUser || !hasUserId})}
|
||||
onClick={() => (!disableUser && !hasUserFilter && hasUserId) && onUserClick(userId, userAnonymousId)}
|
||||
className={cn('text-lg', {'color-teal cursor-pointer': !disableUser && hasUserId, 'color-gray-medium' : disableUser || !hasUserId})}
|
||||
onClick={() => (!disableUser && !hasUserFilter) && onUserClick(userId, userAnonymousId)}
|
||||
>
|
||||
<TextEllipsis text={ userDisplayName } noHint />
|
||||
{userDisplayName}
|
||||
</div>
|
||||
<Label label={ formatTimeOrDate(startedAt, timezone) } />
|
||||
{/* <div
|
||||
className="color-gray-medium text-dotted-underline cursor-pointer"
|
||||
onClick={() => (!disableUser && !hasUserFilter) && onUserClick(userId, userAnonymousId)}
|
||||
>
|
||||
{userSessionsCount} Sessions
|
||||
</div> */}
|
||||
</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 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>
|
||||
<Label label="Duration" />
|
||||
</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' } />
|
||||
{ !isAssist && (
|
||||
<div style={{ width: "10%"}} className="self-center px-2 flex items-center">
|
||||
<ErrorBars count={issueTypes.length} />
|
||||
</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 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>
|
||||
{ _metaList.length > 0 && (
|
||||
<SessionMetaList className="mt-4" metaList={_metaList} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './SessionMetaList';
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -79,8 +79,8 @@ function SessionSearch(props: Props) {
|
|||
</FilterSelection>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center">
|
||||
<SaveFilterButton />
|
||||
<SaveFunnelButton />
|
||||
<SaveFilterButton />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
})
|
||||
1
frontend/app/components/shared/SortOrderButton/index.ts
Normal file
1
frontend/app/components/shared/SortOrderButton/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from './SortOrderButton';
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
return (
|
||||
<div className="flex items-center" style={style}>
|
||||
<Popup
|
||||
trigger={ knownCountry
|
||||
? <span className={ cn(`flag flag-${ countryFlag }`, className, stl.default) } />
|
||||
: <span className={ className } >{ "N/A" }</span>
|
||||
? <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";
|
||||
|
||||
|
|
|
|||
|
|
@ -2,3 +2,7 @@
|
|||
width: 22px !important;
|
||||
height: 14px !important;
|
||||
}
|
||||
|
||||
.label {
|
||||
line-height: 0 !important;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -117,3 +147,13 @@
|
|||
.primaryText .label {
|
||||
color: $teal !important;
|
||||
}
|
||||
|
||||
.redText {
|
||||
& .label {
|
||||
color: $red !important;
|
||||
}
|
||||
|
||||
& svg {
|
||||
fill: $red;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
.textEllipsis {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
display: inline-block;
|
||||
/* display: inline-block; */
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -99,3 +102,10 @@ export function updateCurrentPage(page) {
|
|||
page,
|
||||
};
|
||||
}
|
||||
|
||||
export function toggleSortOrder (order) {
|
||||
return {
|
||||
type: TOGGLE_SORT_ORDER,
|
||||
order,
|
||||
};
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
|
|
@ -124,3 +124,21 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -337,3 +337,12 @@ a:hover {
|
|||
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;
|
||||
}
|
||||
3
frontend/app/svg/icons/flag-na.svg
Normal file
3
frontend/app/svg/icons/flag-na.svg
Normal 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 |
|
|
@ -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 |
|
|
@ -34,6 +34,7 @@ export default Record({
|
|||
rangeValue,
|
||||
startDate,
|
||||
endDate,
|
||||
groupByUser: true,
|
||||
|
||||
sort: 'startTs',
|
||||
order: 'desc',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ module.exports = {
|
|||
//'appearance',
|
||||
// 'backgroundAttachment',
|
||||
'backgroundColor',
|
||||
// 'backgroundOpacity',
|
||||
'backgroundOpacity',
|
||||
// 'backgroundPosition',
|
||||
// 'backgroundRepeat',
|
||||
// 'backgroundSize',
|
||||
|
|
|
|||
8
scripts/helm/db/init_dbs/postgresql/1.5.1/1.5.1.sql
Normal file
8
scripts/helm/db/init_dbs/postgresql/1.5.1/1.5.1.sql
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
BEGIN;
|
||||
CREATE OR REPLACE FUNCTION openreplay_version()
|
||||
RETURNS text AS
|
||||
$$
|
||||
SELECT 'v1.5.1'
|
||||
$$ LANGUAGE sql IMMUTABLE;
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -15,7 +15,7 @@ fatal()
|
|||
exit 1
|
||||
}
|
||||
|
||||
version="v1.5.0"
|
||||
version="v1.5.1"
|
||||
usr=`whoami`
|
||||
|
||||
# Installing k3s
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -39,8 +39,8 @@ data:
|
|||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
proxy_set_header X-Forwarded-For $origin_forwarded_ip;
|
||||
proxy_set_header X-Forwarded-Host $real_ip;
|
||||
proxy_set_header X-Real-IP $real_ip;
|
||||
proxy_set_header X-Forwarded-Host $origin_forwarded_ip;
|
||||
proxy_set_header X-Real-IP $origin_forwarded_ip;
|
||||
proxy_set_header Host $host;
|
||||
proxy_pass http://http-openreplay.app.svc.cluster.local;
|
||||
proxy_read_timeout 300;
|
||||
|
|
@ -81,8 +81,8 @@ data:
|
|||
proxy_set_header Connection "Upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $origin_forwarded_ip;
|
||||
proxy_set_header X-Real-IP $real_ip;
|
||||
proxy_pass http://utilities-openreplay.app.svc.cluster.local:9001;
|
||||
proxy_set_header X-Real-IP $origin_forwarded_ip;
|
||||
proxy_pass http://utilities-pool;
|
||||
}
|
||||
location /assets/ {
|
||||
rewrite ^/assets/(.*) /sessions-assets/$1 break;
|
||||
|
|
@ -139,7 +139,7 @@ data:
|
|||
# Need real ip address for flags in replay.
|
||||
# Some LBs will forward real ips as x-forwarded-for
|
||||
# So making that as priority
|
||||
map $http_x_forwarded_for $real_ip {
|
||||
map $http_x_forwarded_for $origin_forwarded_ip {
|
||||
~^(\d+\.\d+\.\d+\.\d+) $1;
|
||||
default $remote_addr;
|
||||
}
|
||||
|
|
@ -151,10 +151,6 @@ data:
|
|||
default $http_x_forwarded_proto;
|
||||
'' $scheme;
|
||||
}
|
||||
map $http_x_forwarded_for $origin_forwarded_ip {
|
||||
default $http_x_forwarded_for;
|
||||
'' $remote_addr;
|
||||
}
|
||||
# Default server for helath check
|
||||
server {
|
||||
listen 80 default_server;
|
||||
|
|
@ -163,6 +159,13 @@ data:
|
|||
return 200 'OK';
|
||||
}
|
||||
}
|
||||
|
||||
upstream utilities-pool {
|
||||
server utilities-openreplay-headless.app:9001;
|
||||
# enable sticky session with either "hash" (uses the complete IP address)
|
||||
hash $origin_forwarded_ip consistent;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue