Merge remote-tracking branch 'origin/dev' into assist-redis

This commit is contained in:
Taha Yassine Kraiem 2022-02-23 13:05:04 +01:00
commit af2feb9476
99 changed files with 1009 additions and 379 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -11,6 +11,7 @@ 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 AssistPure from 'Components/Assist';
import BugFinderPure from 'Components/BugFinder/BugFinder';
import DashboardPure from 'Components/Dashboard/Dashboard';
import ErrorsPure from 'Components/Errors/Errors';
@ -18,6 +19,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 +31,7 @@ import { setSessionPath } from 'Duck/sessions';
const BugFinder = withSiteIdUpdater(BugFinderPure);
const Dashboard = withSiteIdUpdater(DashboardPure);
const Session = withSiteIdUpdater(SessionPure);
const Assist = withSiteIdUpdater(AssistPure);
const Client = withSiteIdUpdater(ClientPure);
const Onboarding = withSiteIdUpdater(OnboardingPure);
const Errors = withSiteIdUpdater(ErrorsPure);
@ -39,6 +42,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 +78,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 +87,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();
@ -145,6 +153,7 @@ 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 } />

View file

@ -1,11 +1,25 @@
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() {
// @withPageTitle("Assist - OpenReplay")
function Assist() {
return (
<div className="absolute">
{/* <ChatWindow /> */}
<div className="page-margin container-90 flex relative">
<div className="flex-1 flex">
{/* <div className="side-menu">
</div> */}
<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', 'SESSION_REPLAY'])(Assist));

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -9,9 +9,7 @@ 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';
@ -54,7 +52,6 @@ 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 />

View file

@ -297,7 +297,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 +308,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 +415,7 @@ export default class Controls extends React.Component {
icon="business-time"
/>
} */}
<div className={ styles.divider } />
{ !live &&
<React.Fragment>
<ControlButton

View file

@ -3,19 +3,22 @@ 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 { Icon, CountryFlag, IconButton, BackLink, Popup } from 'UI';
import { toggleFavorite, setSessionPath } from 'Duck/sessions';
import cn from 'classnames';
import { connectPlayer } from 'Player';
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();
@ -42,6 +45,7 @@ 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),
}
}, {
toggleFavorite, fetchListIntegration, setSessionPath
@ -53,11 +57,13 @@ export default class PlayerBlockHeader extends React.PureComponent {
this.props.fetchListIntegration('issues')
}
getDimension = (width, height) => (
<div className="flex items-center">
{ width || 'x' } <Icon name="close" size="12" className="mx-1" /> { height || 'x' }
</div>
);
getDimension = (width, height) => {
return width && height ? (
<div className="flex items-center">
{ width || 'x' } <Icon name="close" size="12" className="mx-1" /> { height || 'x' }
</div>
) : <span className="">Resolution N/A</span>;
}
backHandler = () => {
const { history, siteId, sessionPath } = this.props;
@ -81,14 +87,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,17 +106,24 @@ export default class PlayerBlockHeader extends React.PureComponent {
fullscreen,
hasSessionsPath,
sessionPath,
metaList,
} = 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">
{/* <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>
@ -117,22 +133,49 @@ export default class PlayerBlockHeader extends React.PureComponent {
<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 } />
<HeaderInfo icon={ osIcon(userOs) } label={ userOs } /> */}
<div className='ml-auto flex items-center'>
{ live && hasSessionsPath && (
<div className={stl.liveSwitchButton} onClick={() => this.props.setSessionPath('')}>
This Session is Now Continuing Live
</div>
<>
<div className={stl.liveSwitchButton} onClick={() => this.props.setSessionPath('')}>
This Session is Now Continuing Live
</div>
<div className={ stl.divider } />
</>
)}
{ _live && <AssistTabs userId={userId} />}
{ _live && (
<>
<SessionMetaList className="" metaList={_metaList} />
<div className={ stl.divider } />
</>
)}
<Popup
trigger={(
<IconButton icon="info-circle" primaryText label="More Info" disabled={disabled} />
)}
content={(
<div className=''>
<SessionInfoItem comp={<CountryFlag country={ userCountry } />} label={countries[userCountry]} value={ formatTimeOrDate(startedAt) } />
<SessionInfoItem icon={browserIcon(userBrowser)} label={userBrowser} value={ `v${ userBrowserVersion }` } />
<SessionInfoItem icon={osIcon(userOs)} label={userOs} value={ userOsVersion } />
<SessionInfoItem icon={deviceTypeIcon(userDeviceType)} label={userDeviceType} value={ this.getDimension(width, height) } isLast />
</div>
)}
on="click"
position="top center"
hideOnScroll
/>
<div className={ stl.divider } />
{ _live && <AssistActions isLive userId={userId} /> }
{ !_live && (
<>
<Autoplay />
<div className={ stl.divider } />
<IconButton
className="mr-2"
// className="mr-2"
tooltip="Bookmark"
tooltipPosition="top right"
onClick={ this.toggleFavorite }
@ -140,13 +183,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 +208,4 @@ export default class PlayerBlockHeader extends React.PureComponent {
);
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -82,6 +82,7 @@ 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);
@ -105,7 +106,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,7 +129,7 @@ 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));

View file

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

View file

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

View file

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

After

Width:  |  Height:  |  Size: 559 B

View file

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

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -82,7 +82,7 @@ data:
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_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;

View file

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

View file

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

View file

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

View file

@ -15,3 +15,23 @@ spec:
{{- end}}
selector:
{{- include "utilities.selectorLabels" . | nindent 4 }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ include "utilities.fullname" . }}-headless
labels:
{{- include "utilities.labels" . | nindent 4 }}
spec:
type: ClusterIP
clusterIP: None
ports:
{{- range $key, $val := .Values.service.ports }}
- port: {{ $val }}
targetPort: {{ $key }}
protocol: TCP
name: {{ $key }}
{{- end}}
selector:
{{- include "utilities.selectorLabels" . | nindent 4 }}

View file

@ -1,4 +1,4 @@
fromVersion: "v1.5.0"
fromVersion: "v1.5.1"
# Databases specific variables
postgresql: &postgres
# For generating passwords

View file

@ -1,4 +1,4 @@
## Licenses (as of February 8, 2022)
## Licenses (as of February 23, 2022)
Below is the list of dependencies used in OpenReplay software. Licenses may change between versions, so please keep this up to date with every new library you use.
@ -86,6 +86,7 @@ Below is the list of dependencies used in OpenReplay software. Licenses may chan
| semantic-ui-react | MIT | JavaScript |
| socket.io | MIT | JavaScript |
| socket.io-client | MIT | JavaScript |
| uWebSockets.js | Apache2 | JavaScript |
| source-map | BSD3 | JavaScript |
| aws-sdk | Apache2 | JavaScript |
| serverless | MIT | JavaScript |