Merge pull request #312 from openreplay/dev

v1.5.0
This commit is contained in:
Mehdi Osman 2022-02-11 02:13:51 +01:00 committed by GitHub
commit 97df0b3be7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
308 changed files with 15442 additions and 36138 deletions

View file

@ -1,51 +0,0 @@
name: S3 Deploy EE
on:
push:
branches:
- dev
paths:
- ee/frontend/**
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Cache node modules
uses: actions/cache@v1
with:
path: node_modules
key: ${{ runner.OS }}-build-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.OS }}-build-
${{ runner.OS }}-
- uses: azure/k8s-set-context@v1
with:
method: kubeconfig
kubeconfig: ${{ secrets.EE_KUBECONFIG }} # Use content of kubeconfig in secret.
id: setcontext
- name: Install
run: npm install
- name: Build and deploy
run: |
cd frontend
bash build.sh
cp -arl public frontend
minio_pod=$(kubectl get po -n db -l app.kubernetes.io/name=minio -n db --output custom-columns=name:.metadata.name | tail -n+2)
echo $minio_pod
echo copying frontend to container.
kubectl -n db cp frontend $minio_pod:/data/
rm -rf frontend
# - name: Debug Job
# if: ${{ failure() }}
# uses: mxschmitt/action-tmate@v3
# env:
# AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
# AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
# AWS_REGION: eu-central-1
# AWS_S3_BUCKET_NAME: ${{ secrets.AWS_S3_BUCKET_NAME }}

View file

@ -1,4 +1,4 @@
name: S3 Deploy
name: Frontend FOSS Deployment
on:
push:
branches:
@ -27,8 +27,8 @@ jobs:
method: kubeconfig
kubeconfig: ${{ secrets.OSS_KUBECONFIG }} # Use content of kubeconfig in secret.
id: setcontext
- name: Install
run: npm install
# - name: Install
# run: npm install
- name: Build and deploy
run: |

View file

@ -28,7 +28,8 @@ jwt_algorithm=HS512
jwt_exp_delta_seconds=2592000
jwt_issuer=openreplay-default-foss
jwt_secret="SET A RANDOM STRING HERE"
peers=http://utilities-openreplay.app.svc.cluster.local:9000/assist/%s/peers
peersList=http://utilities-openreplay.app.svc.cluster.local:9001/assist/%s/sockets-list
peers=http://utilities-openreplay.app.svc.cluster.local:9001/assist/%s/sockets-live
pg_dbname=postgres
pg_host=postgresql.db.svc.cluster.local
pg_password=asayerPostgres

View file

@ -18,7 +18,7 @@ def get(id):
{"id": id})
)
a = helper.dict_to_camel_case(cur.fetchone())
return __process_circular(a)
return helper.custom_alert_to_front(__process_circular(a))
def get_all(project_id):
@ -31,8 +31,8 @@ def get_all(project_id):
{"project_id": project_id})
cur.execute(query=query)
all = helper.list_to_camel_case(cur.fetchall())
for a in all:
a = __process_circular(a)
for i in range(len(all)):
all[i] = helper.custom_alert_to_front(__process_circular(all[i]))
return all
@ -58,7 +58,7 @@ def create(project_id, data: schemas.AlertSchema):
{"project_id": project_id, **data})
)
a = helper.dict_to_camel_case(cur.fetchone())
return {"data": helper.dict_to_camel_case(__process_circular(a))}
return {"data": helper.custom_alert_to_front(helper.dict_to_camel_case(__process_circular(a)))}
def update(id, data: schemas.AlertSchema):
@ -81,7 +81,7 @@ def update(id, data: schemas.AlertSchema):
{"id": id, **data})
cur.execute(query=query)
a = helper.dict_to_camel_case(cur.fetchone())
return {"data": __process_circular(a)}
return {"data": helper.custom_alert_to_front(__process_circular(a))}
def process_notifications(data):
@ -166,5 +166,5 @@ def get_predefined_values():
"unit": "count" if v.endswith(".count") else "ms",
"predefined": True,
"metricId": None,
"seriesId": None} for v in values]
"seriesId": None} for v in values if v != schemas.AlertColumn.custom]
return values

View file

@ -1,10 +1,8 @@
import schemas
from chalicelib.utils import pg_client, helper
from chalicelib.core import projects, sessions, sessions_metas
import requests
from decouple import config
from chalicelib.core import projects, sessions, sessions_metas
import schemas
from chalicelib.core import projects, sessions
from chalicelib.utils import pg_client, helper
SESSION_PROJECTION_COLS = """s.project_id,
@ -66,10 +64,33 @@ def get_live_sessions(project_id, filters=None):
return helper.list_to_camel_case(results)
def get_live_sessions_ws(project_id):
project_key = projects.get_project_key(project_id)
connected_peers = requests.get(config("peers") % config("S3_KEY") + f"/{project_key}")
if connected_peers.status_code != 200:
print("!! issue with the peer-server")
print(connected_peers.text)
return []
live_peers = connected_peers.json().get("data", [])
for s in live_peers:
s["live"] = True
s["projectId"] = project_id
live_peers = sorted(live_peers, key=lambda l: l.get("timestamp", 0), reverse=True)
return live_peers
def get_live_session_by_id(project_id, session_id):
all_live = get_live_sessions_ws(project_id)
for l in all_live:
if str(l.get("sessionID")) == str(session_id):
return l
return None
def is_live(project_id, session_id, project_key=None):
if project_key is None:
project_key = projects.get_project_key(project_id)
connected_peers = requests.get(config("peers") % config("S3_KEY") + f"/{project_key}")
connected_peers = requests.get(config("peersList") % config("S3_KEY") + f"/{project_key}")
if connected_peers.status_code != 200:
print("!! issue with the peer-server")
print(connected_peers.text)

View file

@ -8,7 +8,7 @@ from chalicelib.utils.TimeUTC import TimeUTC
def try_live(project_id, data: schemas.TryCustomMetricsSchema):
results = []
for s in data.series:
for i, s in enumerate(data.series):
s.filter.startDate = data.startDate
s.filter.endDate = data.endDate
results.append(sessions.search2_series(data=s.filter, project_id=project_id, density=data.density,
@ -21,16 +21,53 @@ def try_live(project_id, data: schemas.TryCustomMetricsSchema):
r["previousCount"] = sessions.search2_series(data=s.filter, project_id=project_id, density=data.density,
view_type=data.viewType)
r["countProgress"] = helper.__progress(old_val=r["previousCount"], new_val=r["count"])
r["seriesName"] = s.name if s.name else i + 1
r["seriesId"] = s.series_id if s.series_id else None
results[-1] = r
return results
def merged_live(project_id, data: schemas.TryCustomMetricsSchema):
series_charts = try_live(project_id=project_id, data=data)
if data.viewType == schemas.MetricViewType.progress:
return series_charts
results = [{}] * len(series_charts[0])
for i in range(len(results)):
for j, series_chart in enumerate(series_charts):
results[i] = {**results[i], "timestamp": series_chart[i]["timestamp"],
data.series[j].name if data.series[j].name else j + 1: series_chart[i]["count"]}
return results
def make_chart(project_id, user_id, metric_id, data: schemas.CustomMetricChartPayloadSchema):
metric = get(metric_id=metric_id, project_id=project_id, user_id=user_id, flatten=False)
if metric is None:
return None
metric: schemas.TryCustomMetricsSchema = schemas.TryCustomMetricsSchema.parse_obj({**data.dict(), **metric})
return try_live(project_id=project_id, data=metric)
series_charts = try_live(project_id=project_id, data=metric)
if data.viewType == schemas.MetricViewType.progress:
return series_charts
results = [{}] * len(series_charts[0])
for i in range(len(results)):
for j, series_chart in enumerate(series_charts):
results[i] = {**results[i], "timestamp": series_chart[i]["timestamp"],
metric.series[j].name: series_chart[i]["count"]}
return results
def get_sessions(project_id, user_id, metric_id, data: schemas.CustomMetricRawPayloadSchema):
metric = get(metric_id=metric_id, project_id=project_id, user_id=user_id, flatten=False)
if metric is None:
return None
metric: schemas.TryCustomMetricsSchema = schemas.TryCustomMetricsSchema.parse_obj({**data.dict(), **metric})
results = []
for s in metric.series:
s.filter.startDate = data.startDate
s.filter.endDate = data.endDate
results.append({"seriesId": s.series_id, "seriesName": s.name,
**sessions.search2_pg(data=s.filter, project_id=project_id, user_id=user_id)})
return results
def create(project_id, user_id, data: schemas.CreateCustomMetricsSchema):
@ -89,6 +126,7 @@ def update(metric_id, user_id, project_id, data: schemas.UpdateCustomMetricsSche
if s.series_id is None:
n_series.append({"i": i, "s": s})
prefix = "n_"
s.index = i
else:
u_series.append({"i": i, "s": s})
u_series_ids.append(s.series_id)
@ -230,3 +268,16 @@ def get_series_for_alert(project_id, user_id):
)
rows = cur.fetchall()
return helper.list_to_camel_case(rows)
def change_state(project_id, metric_id, user_id, status):
with pg_client.PostgresClient() as cur:
cur.execute(
cur.mogrify("""\
UPDATE public.metrics
SET active = %(status)s
WHERE metric_id = %(metric_id)s
AND (user_id = %(user_id)s OR is_public);""",
{"metric_id": metric_id, "status": status, "user_id": user_id})
)
return get(metric_id=metric_id, project_id=project_id, user_id=user_id)

View file

@ -245,7 +245,7 @@ class event_type:
STATEACTION = Event(ui_type=schemas.EventType.state_action, table="events.state_actions", column="name")
ERROR = Event(ui_type=schemas.EventType.error, table="events.errors",
column=None) # column=None because errors are searched by name or message
METADATA = Event(ui_type=schemas.EventType.metadata, table="public.sessions", column=None)
METADATA = Event(ui_type=schemas.FilterType.metadata, table="public.sessions", column=None)
# IOS
CLICK_IOS = Event(ui_type=schemas.EventType.click_ios, table="events_ios.clicks", column="label")
INPUT_IOS = Event(ui_type=schemas.EventType.input_ios, table="events_ios.inputs", column="label")

View file

@ -177,7 +177,8 @@ def get_top_insights(project_id, user_id, funnel_id, range_value=None, start_dat
return {"errors": ["funnel not found"]}
get_start_end_time(filter_d=f["filter"], range_value=range_value, start_date=start_date, end_date=end_date)
insights, total_drop_due_to_issues = significance.get_top_insights(filter_d=f["filter"], project_id=project_id)
insights[-1]["dropDueToIssues"] = total_drop_due_to_issues
if len(insights) > 0:
insights[-1]["dropDueToIssues"] = total_drop_due_to_issues
return {"data": {"stages": helper.list_to_camel_case(insights),
"totalDropDueToIssues": total_drop_due_to_issues}}

View file

@ -53,11 +53,11 @@ def add_edit(tenant_id, project_id, data):
else:
return add(tenant_id=tenant_id,
project_id=project_id,
host=data["host"], api_key=data["apiKeyId"], api_key_id=data["apiKey"], indexes=data["indexes"],
host=data["host"], api_key=data["apiKey"], api_key_id=data["apiKeyId"], indexes=data["indexes"],
port=data["port"])
def __get_es_client(host, port, api_key_id, api_key, use_ssl=False, timeout=29):
def __get_es_client(host, port, api_key_id, api_key, use_ssl=False, timeout=15):
host = host.replace("http://", "").replace("https://", "")
try:
args = {

View file

@ -77,6 +77,8 @@ def get_all(project_id, user_id, details=False):
for row in rows:
row["createdAt"] = TimeUTC.datetime_to_timestamp(row["createdAt"])
if details:
if isinstance(row["filter"], list) and len(row["filter"]) == 0:
row["filter"] = {}
row["filter"] = helper.old_search_payload_to_flat(row["filter"])
return rows

View file

@ -7,7 +7,7 @@ SESSION_PROJECTION_COLS = """s.project_id,
s.session_id::text AS session_id,
s.user_uuid,
s.user_id,
s.user_agent,
-- s.user_agent,
s.user_os,
s.user_browser,
s.user_device,
@ -101,7 +101,8 @@ def get_by_id2_pg(project_id, session_id, user_id, full_data=False, include_fav_
project_key=data["projectKey"])
return data
return None
else:
return assist.get_live_session_by_id(project_id=project_id, session_id=session_id)
def __get_sql_operator(op: schemas.SearchEventOperator):
@ -150,9 +151,10 @@ def _multiple_conditions(condition, values, value_key="value", is_not=False):
def _multiple_values(values, value_key="value"):
query_values = {}
for i in range(len(values)):
k = f"{value_key}_{i}"
query_values[k] = values[i]
if values is not None and isinstance(values, list):
for i in range(len(values)):
k = f"{value_key}_{i}"
query_values[k] = values[i]
return query_values
@ -183,10 +185,24 @@ def search2_pg(data: schemas.SessionsSearchPayloadSchema, project_id, user_id, f
main_query = cur.mogrify(f"""SELECT COUNT(DISTINCT s.session_id) AS count_sessions,
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
FROM (SELECT user_id,
count(full_sessions) AS user_sessions_count,
jsonb_agg(full_sessions) FILTER (WHERE rn <= 1) AS last_session,
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}
{query_part}
ORDER BY s.session_id desc) AS filtred_sessions
ORDER BY favorite DESC, issue_score DESC, {sort} {data.order}) AS full_sessions
GROUP BY user_id
ORDER BY user_sessions_count DESC) AS users_sessions;""",
full_args)
else:
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 *, 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}
{query_part}
ORDER BY s.session_id desc) AS filtred_sessions
ORDER BY favorite DESC, issue_score DESC, {sort} {data.order}) AS full_sessions;""",
@ -221,7 +237,7 @@ def search2_pg(data: schemas.SessionsSearchPayloadSchema, project_id, user_id, f
if errors_only:
return sessions
if data.sort is not None and data.sort != "session_id":
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")
return {
@ -233,8 +249,8 @@ def search2_pg(data: schemas.SessionsSearchPayloadSchema, project_id, user_id, f
@dev.timed
def search2_series(data: schemas.SessionsSearchPayloadSchema, project_id: int, density: int,
view_type: schemas.MetricViewType):
step_size = metrics_helper.__get_step_size(endTimestamp=data.endDate, startTimestamp=data.startDate,
density=density, factor=1)
step_size = int(metrics_helper.__get_step_size(endTimestamp=data.endDate, startTimestamp=data.startDate,
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)
@ -249,7 +265,7 @@ def search2_series(data: schemas.SessionsSearchPayloadSchema, project_id: int, d
LEFT JOIN LATERAL ( SELECT 1 AS s
FROM full_sessions
WHERE start_ts >= generated_timestamp
AND start_ts < generated_timestamp + %(step_size)s) AS sessions ON (TRUE)
AND start_ts <= generated_timestamp + %(step_size)s) AS sessions ON (TRUE)
GROUP BY generated_timestamp
ORDER BY generated_timestamp;""", full_args)
else:
@ -287,47 +303,57 @@ def search_query_parts(data, error_status, errors_only, favorite_only, issue, pr
for i, f in enumerate(data.filters):
if not isinstance(f.value, list):
f.value = [f.value]
if len(f.value) == 0 or f.value[0] is None:
continue
filter_type = f.type
# f.value = __get_sql_value_multiple(f.value)
f.value = helper.values_for_operator(value=f.value, op=f.operator)
f_k = f"f_value{i}"
full_args = {**full_args, **_multiple_values(f.value, value_key=f_k)}
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:
continue
is_not = False
if __is_negation_operator(f.operator):
is_not = True
# op = __reverse_sql_operator(op)
if filter_type == schemas.FilterType.user_browser:
# op = __get_sql_operator_multiple(f.operator)
extra_constraints.append(
_multiple_conditions(f's.user_browser {op} %({f_k})s', f.value, is_not=is_not, value_key=f_k))
ss_constraints.append(
_multiple_conditions(f'ms.user_browser {op} %({f_k})s', f.value, is_not=is_not, value_key=f_k))
if is_any:
extra_constraints.append('s.user_browser IS NOT NULL')
ss_constraints.append('ms.user_browser IS NOT NULL')
else:
extra_constraints.append(
_multiple_conditions(f's.user_browser {op} %({f_k})s', f.value, is_not=is_not, value_key=f_k))
ss_constraints.append(
_multiple_conditions(f'ms.user_browser {op} %({f_k})s', f.value, is_not=is_not, value_key=f_k))
elif filter_type in [schemas.FilterType.user_os, schemas.FilterType.user_os_ios]:
# op = __get_sql_operator_multiple(f.operator)
extra_constraints.append(
_multiple_conditions(f's.user_os {op} %({f_k})s', f.value, is_not=is_not, value_key=f_k))
ss_constraints.append(
_multiple_conditions(f'ms.user_os {op} %({f_k})s', f.value, is_not=is_not, value_key=f_k))
if is_any:
extra_constraints.append('s.user_os IS NOT NULL')
ss_constraints.append('ms.user_os IS NOT NULL')
else:
extra_constraints.append(
_multiple_conditions(f's.user_os {op} %({f_k})s', f.value, is_not=is_not, value_key=f_k))
ss_constraints.append(
_multiple_conditions(f'ms.user_os {op} %({f_k})s', f.value, is_not=is_not, value_key=f_k))
elif filter_type in [schemas.FilterType.user_device, schemas.FilterType.user_device_ios]:
# op = __get_sql_operator_multiple(f.operator)
extra_constraints.append(
_multiple_conditions(f's.user_device {op} %({f_k})s', f.value, is_not=is_not, value_key=f_k))
ss_constraints.append(
_multiple_conditions(f'ms.user_device {op} %({f_k})s', f.value, is_not=is_not, value_key=f_k))
if is_any:
extra_constraints.append('s.user_device IS NOT NULL')
ss_constraints.append('ms.user_device IS NOT NULL')
else:
extra_constraints.append(
_multiple_conditions(f's.user_device {op} %({f_k})s', f.value, is_not=is_not, value_key=f_k))
ss_constraints.append(
_multiple_conditions(f'ms.user_device {op} %({f_k})s', f.value, is_not=is_not, value_key=f_k))
elif filter_type in [schemas.FilterType.user_country, schemas.FilterType.user_country_ios]:
# op = __get_sql_operator_multiple(f.operator)
extra_constraints.append(
_multiple_conditions(f's.user_country {op} %({f_k})s', f.value, is_not=is_not, value_key=f_k))
ss_constraints.append(
_multiple_conditions(f'ms.user_country {op} %({f_k})s', f.value, is_not=is_not, value_key=f_k))
if is_any:
extra_constraints.append('s.user_country IS NOT NULL')
ss_constraints.append('ms.user_country IS NOT NULL')
else:
extra_constraints.append(
_multiple_conditions(f's.user_country {op} %({f_k})s', f.value, is_not=is_not, value_key=f_k))
ss_constraints.append(
_multiple_conditions(f'ms.user_country {op} %({f_k})s', f.value, is_not=is_not, value_key=f_k))
elif filter_type in [schemas.FilterType.utm_source]:
if is_any:
@ -335,9 +361,10 @@ def search_query_parts(data, error_status, errors_only, favorite_only, issue, pr
ss_constraints.append('ms.utm_source IS NOT NULL')
else:
extra_constraints.append(
_multiple_conditions(f's.utm_source {op} %({f_k})s', f.value, is_not=is_not, value_key=f_k))
_multiple_conditions(f's.utm_source {op} %({f_k})s::text', f.value, is_not=is_not,
value_key=f_k))
ss_constraints.append(
_multiple_conditions(f'ms.utm_source {op} %({f_k})s', f.value, is_not=is_not,
_multiple_conditions(f'ms.utm_source {op} %({f_k})s::text', f.value, is_not=is_not,
value_key=f_k))
elif filter_type in [schemas.FilterType.utm_medium]:
if is_any:
@ -345,9 +372,10 @@ def search_query_parts(data, error_status, errors_only, favorite_only, issue, pr
ss_constraints.append('ms.utm_medium IS NOT NULL')
else:
extra_constraints.append(
_multiple_conditions(f's.utm_medium {op} %({f_k})s', f.value, is_not=is_not, value_key=f_k))
_multiple_conditions(f's.utm_medium {op} %({f_k})s::text', f.value, is_not=is_not,
value_key=f_k))
ss_constraints.append(
_multiple_conditions(f'ms.utm_medium {op} %({f_k})s', f.value, is_not=is_not,
_multiple_conditions(f'ms.utm_medium {op} %({f_k})s::text', f.value, is_not=is_not,
value_key=f_k))
elif filter_type in [schemas.FilterType.utm_campaign]:
if is_any:
@ -355,10 +383,10 @@ def search_query_parts(data, error_status, errors_only, favorite_only, issue, pr
ss_constraints.append('ms.utm_campaign IS NOT NULL')
else:
extra_constraints.append(
_multiple_conditions(f's.utm_campaign {op} %({f_k})s', f.value, is_not=is_not,
_multiple_conditions(f's.utm_campaign {op} %({f_k})s::text', f.value, is_not=is_not,
value_key=f_k))
ss_constraints.append(
_multiple_conditions(f'ms.utm_campaign {op} %({f_k})s', f.value, is_not=is_not,
_multiple_conditions(f'ms.utm_campaign {op} %({f_k})s::text', f.value, is_not=is_not,
value_key=f_k))
elif filter_type == schemas.FilterType.duration:
@ -371,45 +399,60 @@ def search_query_parts(data, error_status, errors_only, favorite_only, issue, pr
ss_constraints.append("ms.duration <= %(maxDuration)s")
full_args["maxDuration"] = f.value[1]
elif filter_type == schemas.FilterType.referrer:
# events_query_part = events_query_part + f"INNER JOIN events.pages AS p USING(session_id)"
extra_from += f"INNER JOIN {events.event_type.LOCATION.table} AS p USING(session_id)"
# op = __get_sql_operator_multiple(f.operator)
extra_constraints.append(
_multiple_conditions(f"p.base_referrer {op} %({f_k})s", f.value, is_not=is_not, value_key=f_k))
if is_any:
extra_constraints.append('p.base_referrer IS NOT NULL')
else:
extra_constraints.append(
_multiple_conditions(f"p.base_referrer {op} %({f_k})s", f.value, is_not=is_not, value_key=f_k))
elif filter_type == events.event_type.METADATA.ui_type:
# get metadata list only if you need it
if meta_keys is None:
meta_keys = metadata.get(project_id=project_id)
meta_keys = {m["key"]: m["index"] for m in meta_keys}
# op = __get_sql_operator(f.operator)
if f.key in meta_keys.keys():
extra_constraints.append(
_multiple_conditions(f"s.{metadata.index_to_colname(meta_keys[f.key])} {op} %({f_k})s",
f.value, is_not=is_not, value_key=f_k))
ss_constraints.append(
_multiple_conditions(f"ms.{metadata.index_to_colname(meta_keys[f.key])} {op} %({f_k})s",
f.value, is_not=is_not, value_key=f_k))
if f.source in meta_keys.keys():
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")
else:
extra_constraints.append(
_multiple_conditions(
f"s.{metadata.index_to_colname(meta_keys[f.source])} {op} %({f_k})s::text",
f.value, is_not=is_not, value_key=f_k))
ss_constraints.append(
_multiple_conditions(
f"ms.{metadata.index_to_colname(meta_keys[f.source])} {op} %({f_k})s::text",
f.value, is_not=is_not, value_key=f_k))
elif filter_type in [schemas.FilterType.user_id, schemas.FilterType.user_id_ios]:
# op = __get_sql_operator(f.operator)
extra_constraints.append(
_multiple_conditions(f"s.user_id {op} %({f_k})s", f.value, is_not=is_not, value_key=f_k))
ss_constraints.append(
_multiple_conditions(f"ms.user_id {op} %({f_k})s", f.value, is_not=is_not, value_key=f_k))
if is_any:
extra_constraints.append('s.user_id IS NOT NULL')
ss_constraints.append('ms.user_id IS NOT 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))
ss_constraints.append(
_multiple_conditions(f"ms.user_id {op} %({f_k})s::text", f.value, is_not=is_not, value_key=f_k))
elif filter_type in [schemas.FilterType.user_anonymous_id,
schemas.FilterType.user_anonymous_id_ios]:
# op = __get_sql_operator(f.operator)
extra_constraints.append(
_multiple_conditions(f"s.user_anonymous_id {op} %({f_k})s", f.value, is_not=is_not,
value_key=f_k))
ss_constraints.append(
_multiple_conditions(f"ms.user_anonymous_id {op} %({f_k})s", f.value, is_not=is_not,
value_key=f_k))
if is_any:
extra_constraints.append('s.user_anonymous_id IS NOT NULL')
ss_constraints.append('ms.user_anonymous_id IS NOT NULL')
else:
extra_constraints.append(
_multiple_conditions(f"s.user_anonymous_id {op} %({f_k})s::text", f.value, is_not=is_not,
value_key=f_k))
ss_constraints.append(
_multiple_conditions(f"ms.user_anonymous_id {op} %({f_k})s::text", f.value, is_not=is_not,
value_key=f_k))
elif filter_type in [schemas.FilterType.rev_id, schemas.FilterType.rev_id_ios]:
# op = __get_sql_operator(f.operator)
extra_constraints.append(
_multiple_conditions(f"s.rev_id {op} %({f_k})s", f.value, is_not=is_not, value_key=f_k))
ss_constraints.append(
_multiple_conditions(f"ms.rev_id {op} %({f_k})s", f.value, is_not=is_not, value_key=f_k))
if is_any:
extra_constraints.append('s.rev_id IS NOT NULL')
ss_constraints.append('ms.rev_id IS NOT 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))
ss_constraints.append(
_multiple_conditions(f"ms.rev_id {op} %({f_k})s::text", f.value, is_not=is_not, value_key=f_k))
elif filter_type == schemas.FilterType.platform:
# op = __get_sql_operator(f.operator)
extra_constraints.append(
@ -419,12 +462,16 @@ def search_query_parts(data, error_status, errors_only, favorite_only, issue, pr
_multiple_conditions(f"ms.user_device_type {op} %({f_k})s", f.value, is_not=is_not,
value_key=f_k))
elif filter_type == schemas.FilterType.issue:
extra_constraints.append(
_multiple_conditions(f"%({f_k})s {op} ANY (s.issue_types)", f.value, is_not=is_not,
value_key=f_k))
ss_constraints.append(
_multiple_conditions(f"%({f_k})s {op} ANY (ms.issue_types)", f.value, is_not=is_not,
value_key=f_k))
if is_any:
extra_constraints.append("array_length(s.issue_types, 1) > 0")
ss_constraints.append("array_length(ms.issue_types, 1) > 0")
else:
extra_constraints.append(
_multiple_conditions(f"%({f_k})s {op} ANY (s.issue_types)", f.value, is_not=is_not,
value_key=f_k))
ss_constraints.append(
_multiple_conditions(f"%({f_k})s {op} ANY (ms.issue_types)", f.value, is_not=is_not,
value_key=f_k))
elif filter_type == schemas.FilterType.events_count:
extra_constraints.append(
_multiple_conditions(f"s.events_count {op} %({f_k})s", f.value, is_not=is_not,
@ -445,6 +492,14 @@ def search_query_parts(data, error_status, errors_only, favorite_only, issue, pr
is_any = _isAny_opreator(event.operator)
if not isinstance(event.value, list):
event.value = [event.value]
if not is_any and len(event.value) == 0 \
or event_type in [schemas.PerformanceEventType.location_dom_complete,
schemas.PerformanceEventType.location_largest_contentful_paint_time,
schemas.PerformanceEventType.location_ttfb,
schemas.PerformanceEventType.location_avg_cpu_load,
schemas.PerformanceEventType.location_avg_memory_usage
] and (event.source is None or len(event.source) == 0):
continue
op = __get_sql_operator(event.operator)
is_not = False
if __is_negation_operator(event.operator):
@ -462,9 +517,12 @@ def search_query_parts(data, error_status, errors_only, favorite_only, issue, pr
if data.events_order == schemas.SearchEventOrder._then:
event_where.append(f"event_{event_index - 1}.timestamp <= main.timestamp")
e_k = f"e_value{i}"
s_k = e_k + "_source"
if event.type != schemas.PerformanceEventType.time_between_events:
event.value = helper.values_for_operator(value=event.value, op=event.operator)
full_args = {**full_args, **_multiple_values(event.value, value_key=e_k)}
full_args = {**full_args,
**_multiple_values(event.value, value_key=e_k),
**_multiple_values(event.source, value_key=s_k)}
# if event_type not in list(events.SUPPORTED_TYPES.keys()) \
# or event.value in [None, "", "*"] \
@ -484,10 +542,10 @@ def search_query_parts(data, error_status, errors_only, favorite_only, issue, pr
event_where.append(
_multiple_conditions(f"main.{events.event_type.INPUT.column} {op} %({e_k})s", event.value,
value_key=e_k))
if event.custom is not None and len(event.custom) > 0:
event_where.append(_multiple_conditions(f"main.value ILIKE %(custom{i})s", event.custom,
if event.source is not None and len(event.source) > 0:
event_where.append(_multiple_conditions(f"main.value ILIKE %(custom{i})s", event.source,
value_key=f"custom{i}"))
full_args = {**full_args, **_multiple_values(event.custom, value_key=f"custom{i}")}
full_args = {**full_args, **_multiple_values(event.source, value_key=f"custom{i}")}
elif event_type == events.event_type.LOCATION.ui_type:
event_from = event_from % f"{events.event_type.LOCATION.table} AS main "
@ -520,18 +578,15 @@ def search_query_parts(data, error_status, errors_only, favorite_only, issue, pr
_multiple_conditions(f"main.{events.event_type.STATEACTION.column} {op} %({e_k})s",
event.value, value_key=e_k))
elif event_type == events.event_type.ERROR.ui_type:
# if event.source in [None, "*", ""]:
# event.source = "js_exception"
event_from = event_from % f"{events.event_type.ERROR.table} AS main INNER JOIN public.errors AS main1 USING(error_id)"
if event.value not in [None, "*", ""]:
if not is_any:
event_where.append(f"(main1.message {op} %({e_k})s OR main1.name {op} %({e_k})s)")
if event.source not in [None, "*", ""]:
event_where.append(f"main1.source = %(source)s")
full_args["source"] = event.source
elif event.source not in [None, "*", ""]:
event_where.append(f"main1.source = %(source)s")
full_args["source"] = event.source
event.source = tuple(event.source)
if not is_any and event.value not in [None, "*", ""]:
event_where.append(
_multiple_conditions(f"(main1.message {op} %({e_k})s OR main1.name {op} %({e_k})s)",
event.value, value_key=e_k))
if event.source[0] not in [None, "*", ""]:
event_where.append(_multiple_conditions(f"main1.source = %({s_k})s", event.value, value_key=s_k))
# ----- IOS
elif event_type == events.event_type.CLICK_IOS.ui_type:
@ -547,10 +602,10 @@ def search_query_parts(data, error_status, errors_only, favorite_only, issue, pr
event_where.append(
_multiple_conditions(f"main.{events.event_type.INPUT_IOS.column} {op} %({e_k})s",
event.value, value_key=e_k))
if event.custom is not None and len(event.custom) > 0:
event_where.append(_multiple_conditions(f"main.value ILIKE %(custom{i})s", event.custom,
if event.source is not None and len(event.source) > 0:
event_where.append(_multiple_conditions(f"main.value ILIKE %(custom{i})s", event.source,
value_key="custom{i}"))
full_args = {**full_args, **_multiple_values(event.custom, f"custom{i}")}
full_args = {**full_args, **_multiple_values(event.source, f"custom{i}")}
elif event_type == events.event_type.VIEW_IOS.ui_type:
event_from = event_from % f"{events.event_type.VIEW_IOS.table} AS main "
if not is_any:
@ -594,10 +649,10 @@ def search_query_parts(data, error_status, errors_only, favorite_only, issue, pr
# colname = col["column"]
# tname = "main"
# e_k += "_custom"
# full_args = {**full_args, **_multiple_values(event.custom, value_key=e_k)}
# full_args = {**full_args, **_multiple_values(event.source, value_key=e_k)}
# event_where.append(f"{tname}.{colname} IS NOT NULL AND {tname}.{colname}>0 AND " +
# _multiple_conditions(f"{tname}.{colname} {event.customOperator} %({e_k})s",
# event.custom, value_key=e_k))
# _multiple_conditions(f"{tname}.{colname} {event.sourceOperator} %({e_k})s",
# event.source, value_key=e_k))
elif event_type in [schemas.PerformanceEventType.location_dom_complete,
schemas.PerformanceEventType.location_largest_contentful_paint_time,
schemas.PerformanceEventType.location_ttfb,
@ -618,11 +673,11 @@ def search_query_parts(data, error_status, errors_only, favorite_only, issue, pr
_multiple_conditions(f"main.{events.event_type.LOCATION.column} {op} %({e_k})s",
event.value, value_key=e_k))
e_k += "_custom"
full_args = {**full_args, **_multiple_values(event.custom, value_key=e_k)}
full_args = {**full_args, **_multiple_values(event.source, value_key=e_k)}
event_where.append(f"{tname}.{colname} IS NOT NULL AND {tname}.{colname}>0 AND " +
_multiple_conditions(f"{tname}.{colname} {event.customOperator} %({e_k})s",
event.custom, value_key=e_k))
_multiple_conditions(f"{tname}.{colname} {event.sourceOperator} %({e_k})s",
event.source, value_key=e_k))
elif event_type == schemas.PerformanceEventType.time_between_events:
event_from = event_from % f"{getattr(events.event_type, event.value[0].type).table} AS main INNER JOIN {getattr(events.event_type, event.value[1].type).table} AS main2 USING(session_id) "
if not isinstance(event.value[0].value, list):
@ -653,10 +708,10 @@ def search_query_parts(data, error_status, errors_only, favorite_only, issue, pr
event.value[1].value, value_key=e_k2))
e_k += "_custom"
full_args = {**full_args, **_multiple_values(event.custom, value_key=e_k)}
full_args = {**full_args, **_multiple_values(event.source, value_key=e_k)}
event_where.append(
_multiple_conditions(f"main2.timestamp - main.timestamp {event.customOperator} %({e_k})s",
event.custom, value_key=e_k))
_multiple_conditions(f"main2.timestamp - main.timestamp {event.sourceOperator} %({e_k})s",
event.source, value_key=e_k))
else:
@ -678,7 +733,7 @@ def search_query_parts(data, error_status, errors_only, favorite_only, issue, pr
AND start_ts >= %(startDate)s
AND start_ts <= %(endDate)s
AND duration IS NOT NULL
) {"" if or_events else ("AS event_{event_index}" + ("ON(TRUE)" if event_index > 0 else ""))}\
) {"" if or_events else (f"AS event_{event_index}" + ("ON(TRUE)" if event_index > 0 else ""))}\
""")
else:
events_query_from.append(f"""\
@ -890,7 +945,7 @@ 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_agent,
s.user_os,
s.user_browser,
s.user_device,
@ -927,7 +982,7 @@ 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_agent,
s.user_os,
s.user_browser,
s.user_device,

View file

@ -1,5 +1,5 @@
from chalicelib.utils import pg_client
from chalicelib.core import sessions
from chalicelib.utils import pg_client
def add_favorite_session(project_id, user_id, session_id):
@ -37,7 +37,8 @@ def add_viewed_session(project_id, user_id, session_id):
INSERT INTO public.user_viewed_sessions
(user_id, session_id)
VALUES
(%(userId)s,%(sessionId)s);""",
(%(userId)s,%(sessionId)s)
ON CONFLICT DO NOTHING;""",
{"userId": user_id, "sessionId": session_id})
)
@ -50,8 +51,6 @@ def favorite_session(project_id, user_id, session_id):
def view_session(project_id, user_id, session_id):
if viewed_session_exists(user_id=user_id, session_id=session_id):
return None
return add_viewed_session(project_id=project_id, user_id=user_id, session_id=session_id)
@ -69,21 +68,3 @@ def favorite_session_exists(user_id, session_id):
)
r = cur.fetchone()
return r is not None
def viewed_session_exists(user_id, session_id):
with pg_client.PostgresClient() as cur:
cur.execute(
cur.mogrify(
"""SELECT
session_id
FROM public.user_viewed_sessions
WHERE
user_id = %(userId)s
AND session_id = %(sessionId)s""",
{"userId": user_id, "sessionId": session_id})
)
r = cur.fetchone()
if r:
return True
return False

View file

@ -31,7 +31,7 @@ def get_stages_and_events(filter_d, project_id) -> List[RealDictRow]:
:param filter_d: dict contains events&filters&...
:return:
"""
stages: [dict] = filter_d["events"]
stages: [dict] = filter_d.get("events", [])
filters: [dict] = filter_d.get("filters", [])
filter_issues = filter_d.get("issueTypes")
if filter_issues is None or len(filter_issues) == 0:
@ -130,6 +130,8 @@ def get_stages_and_events(filter_d, project_id) -> List[RealDictRow]:
if not isinstance(s["value"], list):
s["value"] = [s["value"]]
is_any = sessions._isAny_opreator(s["operator"])
if not is_any and isinstance(s["value"], list) and len(s["value"]) == 0:
continue
op = sessions.__get_sql_operator(s["operator"])
event_type = s["type"].upper()
if event_type == events.event_type.CLICK.ui_type:
@ -581,7 +583,7 @@ def get_top_insights(filter_d, project_id):
@dev.timed
def get_issues_list(filter_d, project_id, first_stage=None, last_stage=None):
output = dict({'critical_issues_count': 0})
stages = filter_d["events"]
stages = filter_d.get("events", [])
# The result of the multi-stage query
rows = get_stages_and_events(filter_d=filter_d, project_id=project_id)
# print(json.dumps(rows[0],indent=4))

View file

@ -315,6 +315,11 @@ def edit(user_id_to_update, tenant_id, changes, editor_id):
return {"data": user}
def edit_appearance(user_id, tenant_id, changes):
updated_user = update(tenant_id=tenant_id, user_id=user_id, changes=changes)
return {"data": updated_user}
def get_by_email_only(email):
with pg_client.PostgresClient() as cur:
cur.execute(

View file

@ -216,7 +216,7 @@ def values_for_operator(value: Union[str, list], op: schemas.SearchEventOperator
return value + '%'
elif op == schemas.SearchEventOperator._ends_with:
return '%' + value
elif op == schemas.SearchEventOperator._contains:
elif op == schemas.SearchEventOperator._contains or op == schemas.SearchEventOperator._not_contains:
return '%' + value + '%'
return value
@ -377,3 +377,10 @@ def old_search_payload_to_flat(values):
v["isEvent"] = False
values["filters"] = values.pop("events") + values.get("filters", [])
return values
def custom_alert_to_front(values):
# to support frontend format for payload
if values.get("seriesId") is not None and values["query"]["left"] == schemas.AlertColumn.custom:
values["query"]["left"] = values["seriesId"]
return values

View file

@ -34,8 +34,8 @@ def get_session2(projectId: int, sessionId: int, context: schemas.CurrentContext
include_fav_viewed=True, group_metadata=True)
if data is None:
return {"errors": ["session not found"]}
sessions_favorite_viewed.view_session(project_id=projectId, user_id=context.user_id, session_id=sessionId)
if not data.get("live"):
sessions_favorite_viewed.view_session(project_id=projectId, user_id=context.user_id, session_id=sessionId)
return {
'data': data
}
@ -99,10 +99,25 @@ def comment_assignment(projectId: int, sessionId: int, issueId: str, data: schem
@app.get('/{projectId}/events/search', tags=["events"])
def events_search(projectId: int, q: str, type: Union[schemas.FilterType, schemas.EventType] = None, key: str = None,
def events_search(projectId: int, q: str,
type: Union[schemas.FilterType, schemas.EventType, schemas.PerformanceEventType] = None,
key: str = None,
source: str = None, context: schemas.CurrentContext = Depends(OR_context)):
if len(q) == 0:
return {"data": []}
if isinstance(type, schemas.PerformanceEventType):
if type in [schemas.PerformanceEventType.location_dom_complete,
schemas.PerformanceEventType.location_largest_contentful_paint_time,
schemas.PerformanceEventType.location_ttfb,
schemas.PerformanceEventType.location_avg_cpu_load,
schemas.PerformanceEventType.location_avg_memory_usage
]:
type = schemas.EventType.location
elif type in [schemas.PerformanceEventType.fetch_failed]:
type = schemas.EventType.request
else:
return {"data": []}
result = events.search_pg2(text=q, event_type=type, project_id=projectId, source=source, key=key)
return result
@ -757,7 +772,7 @@ def get_funnel_issue_sessions(projectId: int, funnelId: int, issueId: str,
@app.get('/{projectId}/funnels/{funnelId}', tags=["funnels"])
def get_funnel(projectId: int, funnelId: int, context: schemas.CurrentContext = Depends(OR_context)):
data = funnels.get(funnel_id=funnelId, project_id=projectId, user_id=context.user_id)
data = funnels.get(funnel_id=funnelId, project_id=projectId, user_id=context.user_id, flatten=False)
if data is None:
return {"errors": ["funnel not found"]}
return {"data": data}
@ -815,14 +830,14 @@ def all_issue_types(context: schemas.CurrentContext = Depends(OR_context)):
@app.get('/{projectId}/assist/sessions', tags=["assist"])
def sessions_live(projectId: int, context: schemas.CurrentContext = Depends(OR_context)):
data = assist.get_live_sessions(projectId)
data = assist.get_live_sessions_ws(projectId)
return {'data': data}
@app.post('/{projectId}/assist/sessions', tags=["assist"])
def sessions_live_search(projectId: int, data: schemas.AssistSearchPayloadSchema = Body(...),
context: schemas.CurrentContext = Depends(OR_context)):
data = assist.get_live_sessions(projectId, filters=data.filters)
data = assist.get_live_sessions_ws(projectId)
return {'data': data}
@ -1054,6 +1069,13 @@ def edit_account(data: schemas.EditUserSchema = Body(...),
editor_id=context.user_id)
@app.post('/account/appearance', tags=["account"])
@app.put('/account/appearance', tags=["account"])
def edit_account_appearance(data: schemas.EditUserAppearanceSchema = Body(...),
context: schemas.CurrentContext = Depends(OR_context)):
return users.edit_appearance(tenant_id=context.tenant_id, user_id=context.user_id, changes=data.dict())
@app.post('/account/password', tags=["account"])
@app.put('/account/password', tags=["account"])
def change_client_password(data: schemas.EditUserPasswordSchema = Body(...),
@ -1067,7 +1089,15 @@ def change_client_password(data: schemas.EditUserPasswordSchema = Body(...),
@app.put('/{projectId}/custom_metrics/try', tags=["customMetrics"])
def try_custom_metric(projectId: int, data: schemas.TryCustomMetricsSchema = Body(...),
context: schemas.CurrentContext = Depends(OR_context)):
return {"data": custom_metrics.try_live(project_id=projectId, data=data)}
return {"data": custom_metrics.merged_live
(project_id=projectId, data=data)}
@app.post('/{projectId}/custom_metrics/sessions', tags=["customMetrics"])
def get_custom_metric_sessions(projectId: int, data: schemas.CustomMetricRawPayloadSchema2 = Body(...),
context: schemas.CurrentContext = Depends(OR_context)):
return {"data": custom_metrics.get_sessions(project_id=projectId, user_id=context.user_id, metric_id=data.metric_id,
data=data)}
@app.post('/{projectId}/custom_metrics/chart', tags=["customMetrics"])
@ -1095,6 +1125,13 @@ def get_custom_metric(projectId: int, metric_id: int, context: schemas.CurrentCo
return {"data": custom_metrics.get(project_id=projectId, user_id=context.user_id, metric_id=metric_id)}
@app.post('/{projectId}/custom_metrics/{metric_id}/sessions', tags=["customMetrics"])
def get_custom_metric_sessions(projectId: int, metric_id: int, data: schemas.CustomMetricRawPayloadSchema = Body(...),
context: schemas.CurrentContext = Depends(OR_context)):
return {"data": custom_metrics.get_sessions(project_id=projectId, user_id=context.user_id, metric_id=metric_id,
data=data)}
@app.post('/{projectId}/custom_metrics/{metric_id}/chart', tags=["customMetrics"])
def get_custom_metric_chart(projectId: int, metric_id: int, data: schemas.CustomMetricChartPayloadSchema = Body(...),
context: schemas.CurrentContext = Depends(OR_context)):
@ -1110,6 +1147,16 @@ def update_custom_metric(projectId: int, metric_id: int, data: schemas.UpdateCus
"data": custom_metrics.update(project_id=projectId, user_id=context.user_id, metric_id=metric_id, data=data)}
@app.post('/{projectId}/custom_metrics/{metric_id}/status', tags=["customMetrics"])
@app.put('/{projectId}/custom_metrics/{metric_id}/status', tags=["customMetrics"])
def update_custom_metric_state(projectId: int, metric_id: int,
data: schemas.UpdateCustomMetricsStatusSchema = Body(...),
context: schemas.CurrentContext = Depends(OR_context)):
return {
"data": custom_metrics.change_state(project_id=projectId, user_id=context.user_id, metric_id=metric_id,
status=data.active)}
@app.delete('/{projectId}/custom_metrics/{metric_id}', tags=["customMetrics"])
def delete_custom_metric(projectId: int, metric_id: int, context: schemas.CurrentContext = Depends(OR_context)):
return {"data": custom_metrics.delete(project_id=projectId, user_id=context.user_id, metric_id=metric_id)}
@ -1124,7 +1171,7 @@ def add_saved_search(projectId: int, data: schemas.SavedSearchSchema = Body(...)
@app.get('/{projectId}/saved_search', tags=["savedSearch"])
def get_saved_searches(projectId: int, context: schemas.CurrentContext = Depends(OR_context)):
return {"data": saved_search.get_all(project_id=projectId, user_id=context.user_id)}
return {"data": saved_search.get_all(project_id=projectId, user_id=context.user_id, details=True)}
@app.get('/{projectId}/saved_search/{search_id}', tags=["savedSearch"])
@ -1142,3 +1189,11 @@ def update_saved_search(projectId: int, search_id: int, data: schemas.SavedSearc
@app.delete('/{projectId}/saved_search/{search_id}', tags=["savedSearch"])
def delete_saved_search(projectId: int, search_id: int, context: schemas.CurrentContext = Depends(OR_context)):
return {"data": saved_search.delete(project_id=projectId, user_id=context.user_id, search_id=search_id)}
@public_app.get('/', tags=["health"])
@public_app.post('/', tags=["health"])
@public_app.put('/', tags=["health"])
@public_app.delete('/', tags=["health"])
def health_check():
return {"data": f"live {config('version_number', default='')}"}

View file

@ -36,6 +36,10 @@ class EditUserSchema(BaseModel):
appearance: Optional[dict] = Field({})
class EditUserAppearanceSchema(BaseModel):
appearance: dict = Field(...)
class ForgetPasswordPayloadSchema(_Grecaptcha):
email: str = Field(...)
@ -312,7 +316,7 @@ class MathOperator(str, Enum):
class _AlertQuerySchema(BaseModel):
left: AlertColumn = Field(...)
left: Union[AlertColumn, int] = Field(...)
right: float = Field(...)
# operator: Literal["<", ">", "<=", ">="] = Field(...)
operator: MathOperator = Field(...)
@ -331,6 +335,14 @@ class AlertSchema(BaseModel):
query: _AlertQuerySchema = Field(...)
series_id: Optional[int] = Field(None)
@root_validator(pre=True)
def transform_alert(cls, values):
if values.get("seriesId") is None and isinstance(values["query"]["left"], int):
values["seriesId"] = values["query"]["left"]
values["query"]["left"] = AlertColumn.custom
return values
@root_validator
def alert_validator(cls, values):
if values.get("query") is not None and values["query"].left == AlertColumn.custom:
@ -371,7 +383,6 @@ class EventType(str, Enum):
graphql = "GRAPHQL"
state_action = "STATEACTION"
error = "ERROR"
metadata = "METADATA"
click_ios = "CLICK_IOS"
input_ios = "INPUT_IOS"
view_ios = "VIEW_IOS"
@ -461,37 +472,48 @@ class IssueType(str, Enum):
class __MixedSearchFilter(BaseModel):
is_event: bool = Field(...)
@root_validator(pre=True)
def remove_duplicate_values(cls, values):
if values.get("value") is not None:
if len(values["value"]) > 0 and isinstance(values["value"][0], int):
return values
values["value"] = list(set(values["value"]))
return values
class Config:
alias_generator = attribute_to_camel_case
class _SessionSearchEventRaw(__MixedSearchFilter):
is_event: bool = Field(True, const=True)
custom: Optional[List[Union[int, str]]] = Field(None, min_items=1)
customOperator: Optional[MathOperator] = Field(None)
key: Optional[str] = Field(None)
value: Union[str, List[str]] = Field(...)
is_event: bool = Field(default=True, const=True)
value: List[str] = Field(...)
type: Union[EventType, PerformanceEventType] = Field(...)
operator: SearchEventOperator = Field(...)
source: Optional[ErrorSource] = Field(default=ErrorSource.js_exception)
source: Optional[List[Union[ErrorSource, int, str]]] = Field(None)
sourceOperator: Optional[MathOperator] = Field(None)
@root_validator
def event_validator(cls, values):
if isinstance(values.get("type"), PerformanceEventType):
if values.get("type") == PerformanceEventType.fetch_failed:
return values
assert values.get("custom") is not None, "custom should not be null for PerformanceEventType"
assert values.get("customOperator") is not None \
, "customOperator should not be null for PerformanceEventType"
# assert values.get("source") is not None, "source should not be null for PerformanceEventType"
# assert isinstance(values["source"], list) and len(values["source"]) > 0, \
# "source should not be empty for PerformanceEventType"
assert values.get("sourceOperator") is not None, \
"sourceOperator should not be null for PerformanceEventType"
if values["type"] == PerformanceEventType.time_between_events:
assert len(values.get("value", [])) == 2, \
f"must provide 2 Events as value for {PerformanceEventType.time_between_events}"
assert isinstance(values["value"][0], _SessionSearchEventRaw) \
and isinstance(values["value"][1], _SessionSearchEventRaw) \
, f"event should be of type _SessionSearchEventRaw for {PerformanceEventType.time_between_events}"
and isinstance(values["value"][1], _SessionSearchEventRaw), \
f"event should be of type _SessionSearchEventRaw for {PerformanceEventType.time_between_events}"
else:
for c in values["custom"]:
assert isinstance(c, int), f"custom value should be of type int for {values.get('type')}"
for c in values["source"]:
assert isinstance(c, int), f"source value should be of type int for {values.get('type')}"
elif values.get("type") == EventType.error and values.get("source") is None:
values["source"] = [ErrorSource.js_exception]
return values
@ -501,17 +523,18 @@ class _SessionSearchEventSchema(_SessionSearchEventRaw):
class _SessionSearchFilterSchema(__MixedSearchFilter):
is_event: bool = Field(False, const=False)
custom: Optional[List[str]] = Field(None)
key: Optional[str] = Field(None)
value: Union[Optional[Union[IssueType, PlatformType, int, str]],
Optional[List[Union[IssueType, PlatformType, int, str]]]] = Field(...)
type: FilterType = Field(...)
operator: Union[SearchEventOperator, MathOperator] = Field(...)
source: Optional[ErrorSource] = Field(default=ErrorSource.js_exception)
source: Optional[Union[ErrorSource, str]] = Field(default=ErrorSource.js_exception)
@root_validator
def filter_validator(cls, values):
if values.get("type") == FilterType.issue:
if values.get("type") == FilterType.metadata:
assert values.get("source") is not None and len(values["source"]) > 0, \
"must specify a valid 'source' for metadata filter"
elif values.get("type") == FilterType.issue:
for v in values.get("value"):
assert isinstance(v, IssueType), f"value should be of type IssueType for {values.get('type')} filter"
elif values.get("type") == FilterType.platform:
@ -532,14 +555,12 @@ class _SessionSearchFilterSchema(__MixedSearchFilter):
class SessionsSearchPayloadSchema(BaseModel):
events: List[_SessionSearchEventSchema] = Field([])
filters: List[_SessionSearchFilterSchema] = Field([])
# custom:dict=Field(...)
# rangeValue:str=Field(...)
startDate: int = Field(None)
endDate: int = Field(None)
sort: str = Field(...)
sort: str = Field(default="startTs")
order: str = Field(default="DESC")
# platform: Optional[PlatformType] = Field(None)
events_order: Optional[SearchEventOrder] = Field(default=SearchEventOrder._then)
group_by_user: bool = Field(default=False)
class Config:
alias_generator = attribute_to_camel_case
@ -561,9 +582,10 @@ class FlatSessionsSearchPayloadSchema(SessionsSearchPayloadSchema):
n_filters = []
n_events = []
for v in values.get("filters", []):
if v["isEvent"]:
if v.get("isEvent"):
n_events.append(v)
else:
v["isEvent"] = False
n_filters.append(v)
values["events"] = n_events
values["filters"] = n_filters
@ -581,6 +603,14 @@ class FunnelSearchPayloadSchema(FlatSessionsSearchPayloadSchema):
range_value: Optional[str] = Field(None)
sort: Optional[str] = Field(None)
order: Optional[str] = Field(None)
events_order: Optional[SearchEventOrder] = Field(default=SearchEventOrder._then, const=True)
group_by_user: Optional[bool] = Field(default=False, const=True)
@root_validator(pre=True)
def enforce_default_values(cls, values):
values["eventsOrder"] = SearchEventOrder._then
values["groupByUser"] = False
return values
class Config:
alias_generator = attribute_to_camel_case
@ -605,6 +635,8 @@ class FunnelInsightsPayloadSchema(FlatSessionsSearchPayloadSchema):
# class FunnelInsightsPayloadSchema(SessionsSearchPayloadSchema):
sort: Optional[str] = Field(None)
order: Optional[str] = Field(None)
events_order: Optional[SearchEventOrder] = Field(default=SearchEventOrder._then, const=True)
group_by_user: Optional[bool] = Field(default=False, const=True)
class MetricPayloadSchema(BaseModel):
@ -638,18 +670,23 @@ class CustomMetricSeriesFilterSchema(FlatSessionsSearchPayloadSchema):
endDate: Optional[int] = Field(None)
sort: Optional[str] = Field(None)
order: Optional[str] = Field(None)
group_by_user: Optional[bool] = Field(default=False, const=True)
class CustomMetricCreateSeriesSchema(BaseModel):
series_id: Optional[int] = Field(None)
name: Optional[str] = Field(None)
index: Optional[int] = Field(None)
filter: Optional[CustomMetricSeriesFilterSchema] = Field([])
class Config:
alias_generator = attribute_to_camel_case
class CreateCustomMetricsSchema(BaseModel):
name: str = Field(...)
series: List[CustomMetricCreateSeriesSchema] = Field(..., min_items=1)
is_public: Optional[bool] = Field(False)
is_public: Optional[bool] = Field(True)
class Config:
alias_generator = attribute_to_camel_case
@ -660,15 +697,24 @@ class MetricViewType(str, Enum):
progress = "progress"
class CustomMetricChartPayloadSchema(BaseModel):
class CustomMetricRawPayloadSchema(BaseModel):
startDate: int = Field(TimeUTC.now(-7))
endDate: int = Field(TimeUTC.now())
class Config:
alias_generator = attribute_to_camel_case
class CustomMetricRawPayloadSchema2(CustomMetricRawPayloadSchema):
metric_id: int = Field(...)
class CustomMetricChartPayloadSchema(CustomMetricRawPayloadSchema):
startDate: int = Field(TimeUTC.now(-7))
endDate: int = Field(TimeUTC.now())
density: int = Field(7)
viewType: MetricViewType = Field(MetricViewType.line_chart)
class Config:
alias_generator = attribute_to_camel_case
class CustomMetricChartPayloadSchema2(CustomMetricChartPayloadSchema):
metric_id: int = Field(...)
@ -689,5 +735,9 @@ class UpdateCustomMetricsSchema(CreateCustomMetricsSchema):
series: List[CustomMetricUpdateSeriesSchema] = Field(..., min_items=1)
class UpdateCustomMetricsStatusSchema(BaseModel):
active: bool = Field(...)
class SavedSearchSchema(FunnelSchema):
pass
filter: FlatSessionsSearchPayloadSchema = Field([])

View file

@ -1,11 +1,11 @@
package cache
import (
import (
. "openreplay/backend/pkg/messages"
// . "openreplay/backend/pkg/db/types"
// . "openreplay/backend/pkg/db/types"
)
func (c *PGCache) insertSessionEnd(sessionID uint64, timestamp uint64 ) error {
func (c *PGCache) insertSessionEnd(sessionID uint64, timestamp uint64) error {
//duration, err := c.Conn.InsertSessionEnd(sessionID, timestamp)
_, err := c.Conn.InsertSessionEnd(sessionID, timestamp)
if err != nil {
@ -20,7 +20,6 @@ func (c *PGCache) insertSessionEnd(sessionID uint64, timestamp uint64 ) error {
return nil
}
func (c *PGCache) InsertIssueEvent(sessionID uint64, crash *IssueEvent) error {
session, err := c.GetSession(sessionID)
if err != nil {
@ -29,7 +28,6 @@ func (c *PGCache) InsertIssueEvent(sessionID uint64, crash *IssueEvent) error {
return c.Conn.InsertIssueEvent(sessionID, session.ProjectID, crash)
}
func (c *PGCache) InsertUserID(sessionID uint64, userID *IOSUserID) error {
if err := c.Conn.InsertIOSUserID(sessionID, userID); err != nil {
return err
@ -38,7 +36,7 @@ func (c *PGCache) InsertUserID(sessionID uint64, userID *IOSUserID) error {
if err != nil {
return err
}
session.UserID = userID.Value
session.UserID = &userID.Value
return nil
}
@ -69,11 +67,9 @@ func (c *PGCache) InsertMetadata(sessionID uint64, metadata *Metadata) error {
if keyNo == 0 {
// insert project metadata
}
if err := c.Conn.InsertMetadata(sessionID, keyNo, metadata.Value); err != nil {
return err
}
session.SetMetadata(keyNo, metadata.Value)
return nil
}

View file

@ -1,42 +1,41 @@
package cache
import (
import (
"errors"
. "openreplay/backend/pkg/messages"
. "openreplay/backend/pkg/db/types"
. "openreplay/backend/pkg/messages"
)
func (c *PGCache) InsertWebSessionStart(sessionID uint64, s *SessionStart) error {
if c.sessions[ sessionID ] != nil {
if c.sessions[sessionID] != nil {
return errors.New("This session already in cache!")
}
c.sessions[ sessionID ] = &Session{
SessionID: sessionID,
Platform: "web",
Timestamp: s.Timestamp,
ProjectID: uint32(s.ProjectID),
c.sessions[sessionID] = &Session{
SessionID: sessionID,
Platform: "web",
Timestamp: s.Timestamp,
ProjectID: uint32(s.ProjectID),
TrackerVersion: s.TrackerVersion,
RevID: s.RevID,
UserUUID: s.UserUUID,
UserOS: s.UserOS,
UserOSVersion: s.UserOSVersion,
UserDevice: s.UserDevice,
UserCountry: s.UserCountry,
RevID: s.RevID,
UserUUID: s.UserUUID,
UserOS: s.UserOS,
UserOSVersion: s.UserOSVersion,
UserDevice: s.UserDevice,
UserCountry: s.UserCountry,
// web properties (TODO: unite different platform types)
UserAgent: s.UserAgent,
UserBrowser: s.UserBrowser,
UserBrowserVersion: s.UserBrowserVersion,
UserDeviceType: s.UserDeviceType,
UserAgent: s.UserAgent,
UserBrowser: s.UserBrowser,
UserBrowserVersion: s.UserBrowserVersion,
UserDeviceType: s.UserDeviceType,
UserDeviceMemorySize: s.UserDeviceMemorySize,
UserDeviceHeapSize: s.UserDeviceHeapSize,
UserID: s.UserID,
UserDeviceHeapSize: s.UserDeviceHeapSize,
UserID: &s.UserID,
}
if err := c.Conn.InsertSessionStart(sessionID, c.sessions[ sessionID ]); err != nil {
c.sessions[ sessionID ] = nil
if err := c.Conn.InsertSessionStart(sessionID, c.sessions[sessionID]); err != nil {
c.sessions[sessionID] = nil
return err
}
return nil;
return nil
}
func (c *PGCache) InsertWebSessionEnd(sessionID uint64, e *SessionEnd) error {
@ -54,4 +53,3 @@ func (c *PGCache) InsertWebErrorEvent(sessionID uint64, e *ErrorEvent) error {
session.ErrorsCount += 1
return nil
}

View file

@ -3,11 +3,17 @@ package postgres
import (
"context"
"log"
"time"
"github.com/jackc/pgx/v4"
"github.com/jackc/pgx/v4/pgxpool"
)
func getTimeoutContext() context.Context {
ctx, _ := context.WithTimeout(context.Background(), time.Duration(time.Second*10))
return ctx
}
type Conn struct {
c *pgxpool.Pool // TODO: conditional usage of Pool/Conn (use interface?)
}
@ -15,7 +21,8 @@ type Conn struct {
func NewConn(url string) *Conn {
c, err := pgxpool.Connect(context.Background(), url)
if err != nil {
log.Fatalln(err)
log.Println(err)
log.Fatalln("pgxpool.Connect Error")
}
return &Conn{c}
}
@ -26,15 +33,15 @@ func (conn *Conn) Close() error {
}
func (conn *Conn) query(sql string, args ...interface{}) (pgx.Rows, error) {
return conn.c.Query(context.Background(), sql, args...)
return conn.c.Query(getTimeoutContext(), sql, args...)
}
func (conn *Conn) queryRow(sql string, args ...interface{}) pgx.Row {
return conn.c.QueryRow(context.Background(), sql, args...)
return conn.c.QueryRow(getTimeoutContext(), sql, args...)
}
func (conn *Conn) exec(sql string, args ...interface{}) error {
_, err := conn.c.Exec(context.Background(), sql, args...)
_, err := conn.c.Exec(getTimeoutContext(), sql, args...)
return err
}

View file

@ -1,11 +1,11 @@
package postgres
import (
"math"
"math"
"openreplay/backend/pkg/hashid"
"openreplay/backend/pkg/url"
. "openreplay/backend/pkg/messages"
"openreplay/backend/pkg/url"
)
// TODO: change messages and replace everywhere to e.Index
@ -172,11 +172,12 @@ func (conn *Conn) InsertWebErrorEvent(sessionID uint64, projectID uint32, e *Err
}
defer tx.rollback()
errorID := hashid.WebErrorID(projectID, e)
if err = tx.exec(`
INSERT INTO errors
(error_id, project_id, source, name, message, payload)
VALUES
($1, $2, $3, $4, $5, $6)
($1, $2, $3, $4, $5, $6::jsonb)
ON CONFLICT DO NOTHING`,
errorID, projectID, e.Source, e.Name, e.Message, e.Payload,
); err != nil {

View file

@ -1,11 +1,12 @@
package postgres
//import . "openreplay/backend/pkg/messages"
import . "openreplay/backend/pkg/db/types"
import . "openreplay/backend/pkg/db/types"
//import "log"
func (conn *Conn) GetSession(sessionID uint64) (*Session, error) {
s := &Session{ SessionID: sessionID }
s := &Session{SessionID: sessionID}
var revID, userOSVersion *string
if err := conn.queryRow(`
SELECT platform,
@ -21,13 +22,13 @@ func (conn *Conn) GetSession(sessionID uint64) (*Session, error) {
`,
sessionID,
).Scan(&s.Platform,
&s.Duration, &s.ProjectID, &s.Timestamp,
&s.UserUUID, &s.UserOS, &userOSVersion,
&s.UserDevice, &s.UserDeviceType, &s.UserCountry,
&revID, &s.TrackerVersion,
&s.UserID, &s.UserAnonymousID,
&s.Metadata1, &s.Metadata2, &s.Metadata3, &s.Metadata4, &s.Metadata5,
&s.Metadata6, &s.Metadata7, &s.Metadata8, &s.Metadata9, &s.Metadata10); err != nil {
&s.Duration, &s.ProjectID, &s.Timestamp,
&s.UserUUID, &s.UserOS, &userOSVersion,
&s.UserDevice, &s.UserDeviceType, &s.UserCountry,
&revID, &s.TrackerVersion,
&s.UserID, &s.UserAnonymousID,
&s.Metadata1, &s.Metadata2, &s.Metadata3, &s.Metadata4, &s.Metadata5,
&s.Metadata6, &s.Metadata7, &s.Metadata8, &s.Metadata9, &s.Metadata10); err != nil {
return nil, err
}
if userOSVersion != nil { // TODO: choose format, make f
@ -35,7 +36,7 @@ func (conn *Conn) GetSession(sessionID uint64) (*Session, error) {
}
if revID != nil {
s.RevID = *revID
}
}
return s, nil
}
@ -103,4 +104,4 @@ func (conn *Conn) GetSession(sessionID uint64) (*Session, error) {
// }
// }
// return list
// }
// }

View file

@ -1,46 +1,47 @@
package types
type Session struct {
SessionID uint64
Timestamp uint64
ProjectID uint32
SessionID uint64
Timestamp uint64
ProjectID uint32
TrackerVersion string
RevID string
UserUUID string
UserOS string
UserOSVersion string
UserDevice string
UserCountry string
RevID string
UserUUID string
UserOS string
UserOSVersion string
UserDevice string
UserCountry string
Duration *uint64
PagesCount int
EventsCount int
ErrorsCount int
UserID string // pointer??
Duration *uint64
PagesCount int
EventsCount int
ErrorsCount int
UserID *string // pointer??
UserAnonymousID *string
Metadata1 *string
Metadata2 *string
Metadata3 *string
Metadata4 *string
Metadata5 *string
Metadata6 *string
Metadata7 *string
Metadata8 *string
Metadata9 *string
Metadata10 *string
Metadata1 *string
Metadata2 *string
Metadata3 *string
Metadata4 *string
Metadata5 *string
Metadata6 *string
Metadata7 *string
Metadata8 *string
Metadata9 *string
Metadata10 *string
Platform string
// Only-web properties
UserAgent string
UserBrowser string
UserBrowserVersion string
UserDeviceType string
UserAgent string
UserBrowser string
UserBrowserVersion string
UserDeviceType string
UserDeviceMemorySize uint64
UserDeviceHeapSize uint64
UserDeviceHeapSize uint64
}
func (s *Session) SetMetadata(keyNo uint, value string) {
switch (keyNo) {
switch keyNo {
case 1:
s.Metadata1 = &value
case 2:
@ -62,4 +63,4 @@ func (s *Session) SetMetadata(keyNo uint, value string) {
case 10:
s.Metadata10 = &value
}
}
}

View file

@ -23,7 +23,8 @@ func AWSSessionOnRegion(region string) *_session.Session {
}
aws_session, err := _session.NewSession(config)
if err != nil {
log.Fatalf("AWS session error: %v\n", err)
log.Printf("AWS session error: %v\n", err)
log.Fatal("AWS session error")
}
return aws_session
}

View file

@ -2,7 +2,7 @@ package intervals
const EVENTS_COMMIT_INTERVAL = 30 * 1000
const HEARTBEAT_INTERVAL = 2 * 60 * 1000
const INTEGRATIONS_REQUEST_INTERVAL = 2 * 60 * 1000
const INTEGRATIONS_REQUEST_INTERVAL = 1 * 60 * 1000
const EVENTS_PAGE_EVENT_TIMEOUT = 2 * 60 * 1000
const EVENTS_INPUT_EVENT_TIMEOUT = 2 * 60 * 1000
const EVENTS_PERFORMANCE_AGGREGATION_TIMEOUT = 2 * 60 * 1000

View file

@ -8,14 +8,14 @@ import (
"os/signal"
"syscall"
"openreplay/backend/pkg/db/cache"
"openreplay/backend/pkg/db/postgres"
"openreplay/backend/pkg/env"
"openreplay/backend/pkg/messages"
"openreplay/backend/pkg/queue"
"openreplay/backend/pkg/queue/types"
"openreplay/backend/pkg/messages"
"openreplay/backend/pkg/db/postgres"
"openreplay/backend/pkg/db/cache"
"openreplay/backend/services/db/heuristics"
)
)
var pg *cache.PGCache
@ -23,62 +23,62 @@ func main() {
log.SetFlags(log.LstdFlags | log.LUTC | log.Llongfile)
initStats()
pg = cache.NewPGCache(postgres.NewConn(env.String("POSTGRES_STRING")), 1000 * 60 * 20)
pg = cache.NewPGCache(postgres.NewConn(env.String("POSTGRES_STRING")), 1000*60*20)
defer pg.Close()
heurFinder := heuristics.NewHandler()
consumer := queue.NewMessageConsumer(
env.String("GROUP_DB"),
[]string{
[]string{
env.String("TOPIC_RAW_IOS"),
env.String("TOPIC_TRIGGER"),
},
func(sessionID uint64, msg messages.Message, _ *types.Meta) {
if err := insertMessage(sessionID, msg); err != nil {
if !postgres.IsPkeyViolation(err) {
log.Printf("Message Insertion Error %v, SessionID: %v, Message: %v", err,sessionID, msg)
}
return
}
},
func(sessionID uint64, msg messages.Message, _ *types.Meta) {
if err := insertMessage(sessionID, msg); err != nil {
if !postgres.IsPkeyViolation(err) {
log.Printf("Message Insertion Error %v, SessionID: %v, Message: %v", err, sessionID, msg)
}
return
}
session, err := pg.GetSession(sessionID)
session, err := pg.GetSession(sessionID)
if err != nil {
// Might happen due to the assets-related message TODO: log only if session is necessary for this kind of message
log.Printf("Error on session retrieving from cache: %v, SessionID: %v, Message: %v", err, sessionID, msg)
return;
return
}
err = insertStats(session, msg)
if err != nil {
log.Printf("Stats Insertion Error %v; Session: %v, Message: %v", err, session, msg)
}
err = insertStats(session, msg)
if err != nil {
log.Printf("Stats Insertion Error %v; Session: %v, Message: %v", err, session, msg)
}
heurFinder.HandleMessage(session, msg)
heurFinder.IterateSessionReadyMessages(sessionID, func(msg messages.Message) {
// TODO: DRY code (carefully with the return statement logic)
if err := insertMessage(sessionID, msg); err != nil {
if !postgres.IsPkeyViolation(err) {
log.Printf("Message Insertion Error %v; Session: %v, Message %v", err, session, msg)
}
return
}
if !postgres.IsPkeyViolation(err) {
log.Printf("Message Insertion Error %v; Session: %v, Message %v", err, session, msg)
}
return
}
err = insertStats(session, msg)
if err != nil {
log.Printf("Stats Insertion Error %v; Session: %v, Message %v", err, session, msg)
}
err = insertStats(session, msg)
if err != nil {
log.Printf("Stats Insertion Error %v; Session: %v, Message %v", err, session, msg)
}
})
},
)
consumer.DisableAutoCommit()
sigchan := make(chan os.Signal, 1)
signal.Notify(sigchan, syscall.SIGINT, syscall.SIGTERM)
signal.Notify(sigchan, syscall.SIGINT, syscall.SIGTERM)
tick := time.Tick(15 * time.Second)
tick := time.Tick(15 * time.Second)
log.Printf("Db service started\n")
log.Printf("Db service started\n")
for {
select {
case sig := <-sigchan:
@ -88,11 +88,11 @@ func main() {
case <-tick:
if err := commitStats(); err != nil {
log.Printf("Error on stats commit: %v", err)
}
}
// TODO?: separate stats & regular messages
if err := consumer.Commit(); err != nil {
log.Printf("Error on consumer commit: %v", err)
}
}
default:
err := consumer.ConsumeNext()
if err != nil {
@ -101,4 +101,4 @@ func main() {
}
}
}
}

View file

@ -142,7 +142,8 @@ func main() {
http2.ConfigureServer(server, nil)
go func() {
if err := server.ListenAndServe(); err != nil {
log.Fatalf("Server error: %v\n", err)
log.Printf("Server error: %v\n", err)
log.Fatal("Server error")
}
}()
log.Printf("Server successfully started on port %v\n", HTTP_PORT)

View file

@ -1,12 +1,13 @@
package integration
import (
"sync"
"fmt"
"encoding/json"
"fmt"
"log"
"sync"
"openreplay/backend/pkg/messages"
"openreplay/backend/pkg/db/postgres"
"openreplay/backend/pkg/messages"
"openreplay/backend/pkg/utime"
)
@ -135,6 +136,8 @@ func (c *client) Request() {
c.requestData.LastAttemptTimestamp = utime.CurrentTimestamp()
err := c.requester.Request(c)
if err != nil {
log.Println("ERRROR L139")
log.Println(err)
c.handleError(err)
c.requestData.UnsuccessfullAttemptsCount++;
} else {

View file

@ -1,193 +1,222 @@
package integration
import (
elasticlib "github.com/elastic/go-elasticsearch/v7"
"bytes"
"context"
"time"
b64 "encoding/base64"
"encoding/json"
"fmt"
"bytes"
elasticlib "github.com/elastic/go-elasticsearch/v7"
"log"
"strconv"
"time"
"openreplay/backend/pkg/utime"
"openreplay/backend/pkg/messages"
"openreplay/backend/pkg/utime"
)
type elasticsearch struct {
Host string
Port json.Number
ApiKeyId string //`json:"api_key_id"`
ApiKey string //`json:"api_key"`
Indexes string
Host string
Port json.Number
ApiKeyId string //`json:"api_key_id"`
ApiKey string //`json:"api_key"`
Indexes string
}
type elasticsearchLog struct {
Message string
Time time.Time `json:"utc_time"` // Should be parsed automatically from RFC3339
Time time.Time `json:"utc_time"` // Should be parsed automatically from RFC3339
}
type elasticResponce struct {
Hits struct {
//Total struct {
// Value int
//}
Hits []struct {
Id string `json:"_id"`
Source json.RawMessage `json:"_source"`
}
}
ScrollId string `json:"_scroll_id"`
}
func (es *elasticsearch) Request(c* client) error {
func (es *elasticsearch) Request(c *client) error {
address := es.Host + ":" + es.Port.String()
apiKey := b64.StdEncoding.EncodeToString([]byte(es.ApiKeyId + ":" + es.ApiKey))
cfg := elasticlib.Config{
Addresses: []string{
address,
},
Username: es.ApiKeyId,
Password: es.ApiKey,
Addresses: []string{
address,
},
//Username: es.ApiKeyId,
//Password: es.ApiKey,
APIKey: apiKey,
}
esC, err := elasticlib.NewClient(cfg)
if err != nil {
log.Println("Error while creating new ES client")
log.Println(err)
return err
}
// TODO: ping/versions/ client host check
// res0, err := esC.Info()
// if err != nil {
// log.Printf("ELASTIC Error getting info: %s", err)
// }
// defer res0.Body.Close()
// // Check response status
// if res0.IsError() {
// log.Printf("ELASTIC Error: %s", res0.String())
// }
// log.Printf("ELASTIC Info: %v ", res0.String())
gteTs := c.getLastMessageTimestamp() + 1000 // Sec or millisec to add ?
log.Printf("gteTs: %v ", gteTs)
var buf bytes.Buffer
query := map[string]interface{}{
"query": map[string]interface{}{
"bool": map[string]interface{}{
"filter": []map[string]interface{}{
map[string]interface{}{
"match": map[string]interface{} {
"message": map[string]interface{}{
"query": "openReplaySessionToken=", // asayer_session_id=
},
},
},
map[string]interface{}{
"range": map[string]interface{} {
"utc_time": map[string]interface{}{
"gte": strconv.FormatUint(gteTs, 10),
"lte": "now",
},
},
},
map[string]interface{}{
"term": map[string]interface{}{
"tags": "error",
},
},
},
},
},
}
if err := json.NewEncoder(&buf).Encode(query); err != nil {
return fmt.Errorf("Error encoding the query: %s", err)
}
query := map[string]interface{}{
"query": map[string]interface{}{
"bool": map[string]interface{}{
"filter": []map[string]interface{}{
map[string]interface{}{
"match": map[string]interface{}{
"message": map[string]interface{}{
"query": "openReplaySessionToken=", // asayer_session_id=
},
},
},
map[string]interface{}{
"range": map[string]interface{}{
"utc_time": map[string]interface{}{
"gte": strconv.FormatUint(gteTs, 10),
"lte": "now",
},
},
},
map[string]interface{}{
"term": map[string]interface{}{
"tags": "error",
},
},
},
},
},
}
if err := json.NewEncoder(&buf).Encode(query); err != nil {
return fmt.Errorf("Error encoding the query: %s", err)
}
res, err := esC.Search(
esC.Search.WithContext(context.Background()),
esC.Search.WithIndex(es.Indexes),
esC.Search.WithSize(1000),
esC.Search.WithScroll(time.Minute * 2),
esC.Search.WithBody(&buf),
esC.Search.WithSort("timestamp:asc"),
)
if err != nil {
return fmt.Errorf("Error getting response: %s", err)
}
defer res.Body.Close()
if res.IsError() {
var e map[string]interface{}
if err := json.NewDecoder(res.Body).Decode(&e); err != nil {
return fmt.Errorf("Error parsing the response body: %v", err)
} else {
return fmt.Errorf("Elasticsearch [%s] %s: %s",
res.Status(),
e["error"],//.(map[string]interface{})["type"],
e["error"],//.(map[string]interface{})["reason"],
)
}
}
esC.Search.WithContext(context.Background()),
esC.Search.WithIndex(es.Indexes),
esC.Search.WithSize(1000),
esC.Search.WithScroll(time.Minute*2),
esC.Search.WithBody(&buf),
esC.Search.WithSort("utc_time:asc"),
)
if err != nil {
return fmt.Errorf("Error getting response: %s", err)
}
defer res.Body.Close()
if res.IsError() {
var e map[string]interface{}
if err := json.NewDecoder(res.Body).Decode(&e); err != nil {
log.Printf("Error parsing the Error response body: %v\n", err)
return fmt.Errorf("Error parsing the Error response body: %v", err)
} else {
log.Printf("Elasticsearch Error [%s] %s: %s\n",
res.Status(),
e["error"],
e["error"],
)
return fmt.Errorf("Elasticsearch Error [%s] %s: %s",
res.Status(),
e["error"],
e["error"],
)
}
}
for {
var esResp elasticResponce
if err := json.NewDecoder(res.Body).Decode(&esResp); err != nil {
return fmt.Errorf("Error parsing the response body: %s", err)
}
if len(esResp.Hits.Hits) == 0 {
break
}
for {
var esResp map[string]interface{}
if err := json.NewDecoder(res.Body).Decode(&esResp); err != nil {
return fmt.Errorf("Error parsing the response body: %s", err)
// If no error, then convert response to a map[string]interface
}
for _, hit := range esResp.Hits.Hits {
var esLog elasticsearchLog
if err = json.Unmarshal(hit.Source, &esLog); err != nil {
c.errChan <- err
if _, ok := esResp["hits"]; !ok {
log.Printf("Hits not found in \n%v\n", esResp)
break
}
hits := esResp["hits"].(map[string]interface{})["hits"].([]interface{})
if len(hits) == 0 {
log.Println("No hits found")
break
}
log.Printf("received %d hits", len(hits))
for _, hit := range hits {
// Parse the attributes/fields of the document
doc := hit.(map[string]interface{})
source := doc["_source"].(map[string]interface{})
if _, ok := source["message"]; !ok {
log.Printf("message not found in doc \n%v\n", doc)
c.errChan <- fmt.Errorf("message not found in doc '%v' ", doc)
continue
}
if _, ok := source["utc_time"]; !ok {
log.Printf("utc_time not found in doc \n%v\n", doc)
c.errChan <- fmt.Errorf("utc_time not found in doc '%v' ", doc)
continue
}
parsedTime, err := time.Parse(time.RFC3339, source["utc_time"].(string))
if err != nil {
log.Println("cannot parse time")
c.errChan <- fmt.Errorf("cannot parse RFC3339 time of doc '%v' ", doc)
continue
}
esLog := elasticsearchLog{Message: source["message"].(string), Time: parsedTime}
docID := doc["_id"]
token, err := GetToken(esLog.Message)
if err != nil {
log.Printf("Error generating token: %s\n", err)
c.errChan <- err
continue
}
//parsedTime, err := time.Parse(time.RFC3339, esLog.Timestamp)
//if err != nil {
// c.errChan <- err
// continue
//}
timestamp := uint64(utime.ToMilliseconds(esLog.Time))
c.setLastMessageTimestamp(timestamp)
var sessionID uint64
sessionID, err = strconv.ParseUint(token, 10, 64)
if err != nil {
log.Printf("Error converting token to uint46: %s\n", err)
sessionID = 0
}
payload, err := json.Marshal(source)
if err != nil {
log.Printf("Error converting source to json: %v\n", source)
continue
}
c.evChan <- &SessionErrorEvent{
//SessionID: sessionID,
Token: token,
SessionID: sessionID,
Token: token,
RawErrorEvent: &messages.RawErrorEvent{
Source: "elasticsearch",
Source: "elasticsearch",
Timestamp: timestamp,
Name: hit.Id, // sure?
Payload: string(hit.Source),
Name: fmt.Sprintf("%v", docID),
Payload: string(payload),
},
}
}
res, err = esC.Scroll(
esC.Scroll.WithContext(context.Background()),
esC.Scroll.WithScrollID(esResp.ScrollId),
esC.Scroll.WithScroll(time.Minute * 2),
)
if err != nil {
return fmt.Errorf("Error getting scroll response: %s", err)
}
defer res.Body.Close()
if res.IsError() {
var e map[string]interface{}
if err := json.NewDecoder(res.Body).Decode(&e); err != nil {
return fmt.Errorf("Error parsing the response body: %v", err)
} else {
return fmt.Errorf("Elasticsearch [%s] %s: %s",
res.Status(),
e["error"],//.(map[string]interface{})["type"],
e["error"],//.(map[string]interface{})["reason"],
)
}
}
}
if _, ok := esResp["_scroll_id"]; !ok {
log.Println("_scroll_id not found")
break
}
log.Println("Scrolling...")
scrollId := esResp["_scroll_id"]
res, err = esC.Scroll(
esC.Scroll.WithContext(context.Background()),
esC.Scroll.WithScrollID(fmt.Sprintf("%v", scrollId)),
esC.Scroll.WithScroll(time.Minute*2),
)
if err != nil {
return fmt.Errorf("Error getting scroll response: %s", err)
}
defer res.Body.Close()
if res.IsError() {
var e map[string]interface{}
if err := json.NewDecoder(res.Body).Decode(&e); err != nil {
return fmt.Errorf("Error parsing the response body: %v", err)
} else {
return fmt.Errorf("Elasticsearch [%s] %s: %s",
res.Status(),
e["error"], //.(map[string]interface{})["type"],
e["error"], //.(map[string]interface{})["reason"],
)
}
}
}
return nil
}
}

View file

@ -8,11 +8,11 @@ import (
"os/signal"
"syscall"
"openreplay/backend/pkg/db/postgres"
"openreplay/backend/pkg/env"
"openreplay/backend/pkg/intervals"
"openreplay/backend/pkg/messages"
"openreplay/backend/pkg/queue"
"openreplay/backend/pkg/db/postgres"
"openreplay/backend/pkg/token"
"openreplay/backend/services/integrations/clientManager"
)
@ -42,12 +42,13 @@ func main() {
}
})
producer:= queue.NewProducer()
producer := queue.NewProducer()
defer producer.Close(15000)
listener, err := postgres.NewIntegrationsListener(POSTGRES_STRING)
if err != nil {
log.Fatalf("Postgres listener error: %v\n", err)
log.Printf("Postgres listener error: %v\n", err)
log.Fatalf("Postgres listener error")
}
defer listener.Close()
@ -66,10 +67,10 @@ func main() {
pg.Close()
os.Exit(0)
case <-tick:
// log.Printf("Requesting all...\n")
log.Printf("Requesting all...\n")
manager.RequestAll()
case event := <-manager.Events:
// log.Printf("New integration event: %v\n", *event.RawErrorEvent)
log.Printf("New integration event: %+v\n", *event.RawErrorEvent)
sessionID := event.SessionID
if sessionID == 0 {
sessData, err := tokenizer.Parse(event.Token)
@ -83,13 +84,19 @@ func main() {
producer.Produce(TOPIC_RAW_WEB, sessionID, messages.Encode(event.RawErrorEvent))
case err := <-manager.Errors:
log.Printf("Integration error: %v\n", err)
listener.Close()
pg.Close()
os.Exit(0)
case i := <-manager.RequestDataUpdates:
// log.Printf("Last request integration update: %v || %v\n", i, string(i.RequestData))
if err := pg.UpdateIntegrationRequestData(&i); err != nil {
log.Printf("Postgres Update request_data error: %v\n", err)
}
case err := <-listener.Errors:
log.Printf("Postgres listen error: %v\n", err)
log.Printf("Postgres listen error: %v\n", err)
listener.Close()
pg.Close()
os.Exit(0)
case iPointer := <-listener.Integrations:
log.Printf("Integration update: %v\n", *iPointer)
err := manager.Update(iPointer)

View file

@ -37,7 +37,8 @@ jwt_algorithm=HS512
jwt_exp_delta_seconds=2592000
jwt_issuer=openreplay-default-ee
jwt_secret="SET A RANDOM STRING HERE"
peers=http://utilities-openreplay.app.svc.cluster.local:9000/assist/%s/peers
peersList=http://utilities-openreplay.app.svc.cluster.local:9001/assist/%s/sockets-list
peers=http://utilities-openreplay.app.svc.cluster.local:9001/assist/%s/sockets-live
pg_dbname=postgres
pg_host=postgresql.db.svc.cluster.local
pg_password=asayerPostgres

View file

@ -509,7 +509,8 @@ def search(data, project_id, user_id, flows=False, status="ALL", favorite_only=F
FROM errors
WHERE {" AND ".join(ch_sub_query)}
GROUP BY error_id, name, message
ORDER BY {sort} {order}) AS details INNER JOIN (SELECT error_id AS error_id, toUnixTimestamp(MAX(datetime))*1000 AS last_occurrence, toUnixTimestamp(MIN(datetime))*1000 AS first_occurrence
ORDER BY {sort} {order}
LIMIT 1001) AS details INNER JOIN (SELECT error_id AS error_id, toUnixTimestamp(MAX(datetime))*1000 AS last_occurrence, toUnixTimestamp(MIN(datetime))*1000 AS first_occurrence
FROM errors
GROUP BY error_id) AS time_details
ON details.error_id=time_details.error_id

View file

@ -340,6 +340,11 @@ def edit(user_id_to_update, tenant_id, changes, editor_id):
return {"data": user}
def edit_appearance(user_id, tenant_id, changes):
updated_user = update(tenant_id=tenant_id, user_id=user_id, changes=changes)
return {"data": updated_user}
def get_by_email_only(email):
with pg_client.PostgresClient() as cur:
cur.execute(

View file

@ -23,7 +23,6 @@ type Consumer struct {
pollTimeout uint
lastKafkaEventTs int64
partitions []kafka.TopicPartition
}
func NewConsumer(group string, topics []string, messageHandler types.MessageHandler) *Consumer {
@ -72,13 +71,15 @@ func (consumer *Consumer) Commit() error {
return nil
}
func (consumer *Consumer) CommitBack(gap int64) error {
if consumer.lastKafkaEventTs == 0 || consumer.partitions == nil {
return nil
func (consumer *Consumer) CommitAtTimestamp(commitTs int64) error {
assigned, err := consumer.c.Assignment()
if err != nil {
return err
}
commitTs := consumer.lastKafkaEventTs - gap
logPartitions("Actually assigned:", assigned)
var timestamps []kafka.TopicPartition
for _, p := range consumer.partitions { // p is a copy here sinse partition is not a pointer
for _, p := range assigned { // p is a copy here sinse partition is not a pointer
p.Offset = kafka.Offset(commitTs)
timestamps = append(timestamps, p)
}
@ -86,13 +87,41 @@ func (consumer *Consumer) CommitBack(gap int64) error {
if err != nil {
return errors.Wrap(err, "Kafka Consumer back commit error")
}
// Limiting to already committed
committed, err := consumer.c.Committed(assigned, 2000) // memorise?
logPartitions("Actually committed:",committed)
if err != nil {
return errors.Wrap(err, "Kafka Consumer retrieving committed error")
}
for _, offs := range offsets {
for _, comm := range committed {
if comm.Offset == kafka.OffsetStored ||
comm.Offset == kafka.OffsetInvalid ||
comm.Offset == kafka.OffsetBeginning ||
comm.Offset == kafka.OffsetEnd { continue }
if comm.Partition == offs.Partition &&
(comm.Topic != nil && offs.Topic != nil && *comm.Topic == *offs.Topic) &&
comm.Offset > offs.Offset {
offs.Offset = comm.Offset
}
}
}
// TODO: check per-partition errors: offsets[i].Error
// As an option: can store offsets and enable autocommit instead
_, err = consumer.c.CommitOffsets(offsets)
return errors.Wrap(err, "Kafka Consumer back commit error")
}
func (consumer *Consumer) CommitBack(gap int64) error {
if consumer.lastKafkaEventTs == 0 {
return nil
}
commitTs := consumer.lastKafkaEventTs - gap
return consumer.CommitAtTimestamp(commitTs)
}
func (consumer *Consumer) ConsumeNext() error {
ev := consumer.c.Poll(int(consumer.pollTimeout))
if ev == nil {
@ -117,14 +146,15 @@ func (consumer *Consumer) ConsumeNext() error {
Timestamp: ts,
})
consumer.lastKafkaEventTs = ts
case kafka.AssignedPartitions:
logPartitions("Kafka Consumer: Partitions Assigned", e.Partitions)
consumer.partitions = e.Partitions
consumer.c.Assign(e.Partitions)
case kafka.RevokedPartitions:
log.Println("Kafka Cosumer: Partitions Revoked")
consumer.partitions = nil
consumer.c.Unassign()
// case kafka.AssignedPartitions:
// logPartitions("Kafka Consumer: Partitions Assigned", e.Partitions)
// consumer.partitions = e.Partitions
// consumer.c.Assign(e.Partitions)
// log.Printf("Actually partitions assigned!")
// case kafka.RevokedPartitions:
// log.Println("Kafka Cosumer: Partitions Revoked")
// consumer.partitions = nil
// consumer.c.Unassign()
case kafka.Error:
if e.Code() == kafka.ErrAllBrokersDown {
os.Exit(1)

View file

@ -0,0 +1,4 @@
ALTER TABLE sessions
ADD COLUMN IF NOT EXISTS utm_source Nullable(String),
ADD COLUMN IF NOT EXISTS utm_medium Nullable(String),
ADD COLUMN IF NOT EXISTS utm_campaign Nullable(String);

View file

@ -0,0 +1,173 @@
BEGIN;
CREATE OR REPLACE FUNCTION openreplay_version()
RETURNS text AS
$$
SELECT 'v1.5.0-ee'
$$ LANGUAGE sql IMMUTABLE;
--
CREATE TABLE IF NOT EXISTS traces
(
user_id integer NULL REFERENCES users (user_id) ON DELETE CASCADE,
tenant_id integer NOT NULL REFERENCES tenants (tenant_id) ON DELETE CASCADE,
created_at bigint NOT NULL DEFAULT (EXTRACT(EPOCH FROM now() at time zone 'utc') * 1000)::bigint,
auth text NULL,
action text NOT NULL,
method text NOT NULL,
path_format text NOT NULL,
endpoint text NOT NULL,
payload jsonb NULL,
parameters jsonb NULL,
status int NULL
);
CREATE INDEX IF NOT EXISTS traces_user_id_idx ON traces (user_id);
CREATE INDEX IF NOT EXISTS traces_tenant_id_idx ON traces (tenant_id);
CREATE INDEX IF NOT EXISTS user_favorite_sessions_user_id_session_id_idx ON user_favorite_sessions (user_id, session_id);
CREATE INDEX IF NOT EXISTS pages_first_contentful_paint_time_idx ON events.pages (first_contentful_paint_time) WHERE first_contentful_paint_time > 0;
CREATE INDEX IF NOT EXISTS pages_dom_content_loaded_time_idx ON events.pages (dom_content_loaded_time) WHERE dom_content_loaded_time > 0;
CREATE INDEX IF NOT EXISTS pages_first_paint_time_idx ON events.pages (first_paint_time) WHERE first_paint_time > 0;
CREATE INDEX IF NOT EXISTS pages_ttfb_idx ON events.pages (ttfb) WHERE ttfb > 0;
CREATE INDEX IF NOT EXISTS pages_time_to_interactive_idx ON events.pages (time_to_interactive) WHERE time_to_interactive > 0;
CREATE INDEX IF NOT EXISTS pages_session_id_timestamp_loadgt0NN_idx ON events.pages (session_id, timestamp) WHERE load_time > 0 AND load_time IS NOT NULL;
CREATE INDEX IF NOT EXISTS pages_session_id_timestamp_visualgt0nn_idx ON events.pages (session_id, timestamp) WHERE visually_complete > 0 AND visually_complete IS NOT NULL;
CREATE INDEX IF NOT EXISTS pages_timestamp_metgt0_idx ON events.pages (timestamp) WHERE response_time > 0 OR
first_paint_time > 0 OR
dom_content_loaded_time > 0 OR
ttfb > 0 OR
time_to_interactive > 0;
CREATE INDEX IF NOT EXISTS pages_session_id_speed_indexgt0nn_idx ON events.pages (session_id, speed_index) WHERE speed_index > 0 AND speed_index IS NOT NULL;
CREATE INDEX IF NOT EXISTS pages_session_id_timestamp_dom_building_timegt0nn_idx ON events.pages (session_id, timestamp, dom_building_time) WHERE dom_building_time > 0 AND dom_building_time IS NOT NULL;
CREATE INDEX IF NOT EXISTS issues_project_id_idx ON issues (project_id);
CREATE INDEX IF NOT EXISTS errors_project_id_error_id_js_exception_idx ON public.errors (project_id, error_id) WHERE source = 'js_exception';
CREATE INDEX IF NOT EXISTS errors_project_id_error_id_idx ON public.errors (project_id, error_id);
CREATE INDEX IF NOT EXISTS errors_project_id_error_id_integration_idx ON public.errors (project_id, error_id) WHERE source != 'js_exception';
CREATE INDEX IF NOT EXISTS sessions_start_ts_idx ON public.sessions (start_ts) WHERE duration > 0;
CREATE INDEX IF NOT EXISTS sessions_project_id_idx ON public.sessions (project_id) WHERE duration > 0;
CREATE INDEX IF NOT EXISTS sessions_session_id_project_id_start_ts_idx ON sessions (session_id, project_id, start_ts) WHERE duration > 0;
CREATE INDEX IF NOT EXISTS user_favorite_sessions_user_id_session_id_idx ON user_favorite_sessions (user_id, session_id);
CREATE INDEX IF NOT EXISTS jobs_project_id_idx ON jobs (project_id);
CREATE INDEX IF NOT EXISTS errors_session_id_timestamp_error_id_idx ON events.errors (session_id, timestamp, error_id);
CREATE INDEX IF NOT EXISTS errors_error_id_timestamp_idx ON events.errors (error_id, timestamp);
CREATE INDEX IF NOT EXISTS errors_timestamp_error_id_session_id_idx ON events.errors (timestamp, error_id, session_id);
CREATE INDEX IF NOT EXISTS errors_error_id_timestamp_session_id_idx ON events.errors (error_id, timestamp, session_id);
CREATE INDEX IF NOT EXISTS resources_timestamp_idx ON events.resources (timestamp);
CREATE INDEX IF NOT EXISTS resources_success_idx ON events.resources (success);
CREATE INDEX IF NOT EXISTS projects_project_key_idx ON public.projects (project_key);
CREATE INDEX IF NOT EXISTS resources_timestamp_type_durationgt0NN_idx ON events.resources (timestamp, type) WHERE duration > 0 AND duration IS NOT NULL;
CREATE INDEX IF NOT EXISTS resources_session_id_timestamp_idx ON events.resources (session_id, timestamp);
CREATE INDEX IF NOT EXISTS resources_session_id_timestamp_type_idx ON events.resources (session_id, timestamp, type);
CREATE INDEX IF NOT EXISTS resources_timestamp_type_durationgt0NN_noFetch_idx ON events.resources (timestamp, type) WHERE duration > 0 AND duration IS NOT NULL AND type != 'fetch';
CREATE INDEX IF NOT EXISTS resources_session_id_timestamp_url_host_fail_idx ON events.resources (session_id, timestamp, url_host) WHERE success = FALSE;
CREATE INDEX IF NOT EXISTS resources_session_id_timestamp_url_host_firstparty_idx ON events.resources (session_id, timestamp, url_host) WHERE type IN ('fetch', 'script');
CREATE INDEX IF NOT EXISTS resources_session_id_timestamp_duration_durationgt0NN_img_idx ON events.resources (session_id, timestamp, duration) WHERE duration > 0 AND duration IS NOT NULL AND type = 'img';
CREATE INDEX IF NOT EXISTS resources_timestamp_session_id_idx ON events.resources (timestamp, session_id);
DROP TRIGGER IF EXISTS on_insert_or_update ON projects;
CREATE TRIGGER on_insert_or_update
AFTER INSERT OR UPDATE
ON projects
FOR EACH ROW
EXECUTE PROCEDURE notify_project();
UPDATE tenants
SET name=''
WHERE name ISNULL;
ALTER TABLE tenants
ALTER COLUMN name SET NOT NULL;
ALTER TABLE sessions
ADD COLUMN IF NOT EXISTS utm_source text NULL DEFAULT NULL,
ADD COLUMN IF NOT EXISTS utm_medium text NULL DEFAULT NULL,
ADD COLUMN IF NOT EXISTS utm_campaign text NULL DEFAULT NULL;
CREATE INDEX IF NOT EXISTS sessions_utm_source_gin_idx ON public.sessions USING GIN (utm_source gin_trgm_ops);
CREATE INDEX IF NOT EXISTS sessions_utm_medium_gin_idx ON public.sessions USING GIN (utm_medium gin_trgm_ops);
CREATE INDEX IF NOT EXISTS sessions_utm_campaign_gin_idx ON public.sessions USING GIN (utm_campaign gin_trgm_ops);
CREATE INDEX IF NOT EXISTS requests_timestamp_session_id_failed_idx ON events_common.requests (timestamp, session_id) WHERE success = FALSE;
DROP INDEX IF EXISTS sessions_project_id_user_browser_idx1;
DROP INDEX IF EXISTS sessions_project_id_user_country_idx1;
ALTER INDEX IF EXISTS platform_idx RENAME TO sessions_platform_idx;
ALTER INDEX IF EXISTS events.resources_duration_idx RENAME TO resources_duration_durationgt0_idx;
DROP INDEX IF EXISTS projects_project_key_idx1;
CREATE INDEX IF NOT EXISTS errors_parent_error_id_idx ON errors (parent_error_id);
CREATE INDEX IF NOT EXISTS performance_session_id_idx ON events.performance (session_id);
CREATE INDEX IF NOT EXISTS performance_timestamp_idx ON events.performance (timestamp);
CREATE INDEX IF NOT EXISTS performance_session_id_timestamp_idx ON events.performance (session_id, timestamp);
CREATE INDEX IF NOT EXISTS performance_avg_cpu_gt0_idx ON events.performance (avg_cpu) WHERE avg_cpu > 0;
CREATE INDEX IF NOT EXISTS performance_avg_used_js_heap_size_gt0_idx ON events.performance (avg_used_js_heap_size) WHERE avg_used_js_heap_size > 0;
CREATE TABLE IF NOT EXISTS metrics
(
metric_id integer generated BY DEFAULT AS IDENTITY PRIMARY KEY,
project_id integer NOT NULL REFERENCES projects (project_id) ON DELETE CASCADE,
user_id integer REFERENCES users (user_id) ON DELETE SET NULL,
name text NOT NULL,
is_public boolean NOT NULL DEFAULT FALSE,
created_at timestamp default timezone('utc'::text, now()) not null,
deleted_at timestamp
);
CREATE INDEX IF NOT EXISTS metrics_user_id_is_public_idx ON public.metrics (user_id, is_public);
CREATE TABLE IF NOT EXISTS metric_series
(
series_id integer generated BY DEFAULT AS IDENTITY PRIMARY KEY,
metric_id integer REFERENCES metrics (metric_id) ON DELETE CASCADE,
index integer NOT NULL,
name text NULL,
filter jsonb NOT NULL,
created_at timestamp DEFAULT timezone('utc'::text, now()) NOT NULL,
deleted_at timestamp
);
CREATE INDEX IF NOT EXISTS metric_series_metric_id_idx ON public.metric_series (metric_id);
CREATE INDEX IF NOT EXISTS funnels_project_id_idx ON public.funnels (project_id);
CREATE TABLE IF NOT EXISTS searches
(
search_id integer generated BY DEFAULT AS IDENTITY PRIMARY KEY,
project_id integer NOT NULL REFERENCES projects (project_id) ON DELETE CASCADE,
user_id integer NOT NULL REFERENCES users (user_id) ON DELETE CASCADE,
name text not null,
filter jsonb not null,
created_at timestamp default timezone('utc'::text, now()) not null,
deleted_at timestamp,
is_public boolean NOT NULL DEFAULT False
);
CREATE INDEX IF NOT EXISTS searches_user_id_is_public_idx ON public.searches (user_id, is_public);
CREATE INDEX IF NOT EXISTS searches_project_id_idx ON public.searches (project_id);
CREATE INDEX IF NOT EXISTS alerts_project_id_idx ON alerts (project_id);
ALTER TABLE alerts
ADD COLUMN IF NOT EXISTS series_id integer NULL REFERENCES metric_series (series_id) ON DELETE CASCADE;
CREATE INDEX IF NOT EXISTS alerts_series_id_idx ON alerts (series_id);
UPDATE alerts
SET options=jsonb_set(options, '{change}', '"change"')
WHERE detection_method = 'change'
AND options -> 'change' ISNULL;
ALTER TABLE roles
ADD COLUMN IF NOT EXISTS all_projects bool NOT NULL DEFAULT TRUE;
CREATE TABLE IF NOT EXISTS roles_projects
(
role_id integer NOT NULL REFERENCES roles (role_id) ON DELETE CASCADE,
project_id integer NOT NULL REFERENCES projects (project_id) ON DELETE CASCADE,
CONSTRAINT roles_projects_pkey PRIMARY KEY (role_id, project_id)
);
CREATE INDEX IF NOT EXISTS roles_projects_role_id_idx ON roles_projects (role_id);
CREATE INDEX IF NOT EXISTS roles_projects_project_id_idx ON roles_projects (project_id);
--
ALTER TABLE public.metrics
ADD COLUMN IF NOT EXISTS active boolean NOT NULL DEFAULT TRUE;
CREATE INDEX IF NOT EXISTS resources_timestamp_duration_durationgt0NN_idx ON events.resources (timestamp, duration) WHERE duration > 0 AND duration IS NOT NULL;
COMMIT;
ALTER TYPE public.error_source ADD VALUE IF NOT EXISTS 'elasticsearch'; -- cannot add new value inside a transaction block

File diff suppressed because it is too large Load diff

View file

@ -16,13 +16,15 @@ const siteIdRequiredPaths = [
'/integration/sources',
'/issue_types',
'/sample_rate',
'/flows',
'/saved_search',
'/rehydrations',
'/sourcemaps',
'/errors',
'/funnels',
'/assist',
'/heatmaps'
'/heatmaps',
'/custom_metrics',
// '/custom_metrics/sessions',
];
const noStoringFetchPathStarts = [

View file

@ -1,4 +1,4 @@
import React from 'react'
import React, { useEffect } from 'react'
import { Button, Dropdown, Form, Input, SegmentSelection, Checkbox, Message, Link, Icon } from 'UI';
import { alertMetrics as metrics } from 'App/constants';
import { alertConditions as conditions } from 'App/constants';
@ -8,6 +8,7 @@ import stl from './alertForm.css';
import DropdownChips from './DropdownChips';
import { validateEmail } from 'App/validate';
import cn from 'classnames';
import { fetchTriggerOptions } from 'Duck/alerts';
const thresholdOptions = [
{ text: '15 minutes', value: 15 },
@ -46,11 +47,15 @@ const Section = ({ index, title, description, content }) => (
const integrationsRoute = client(CLIENT_TABS.INTEGRATIONS);
const AlertForm = props => {
const { instance, slackChannels, webhooks, loading, onDelete, deleting } = props;
const { instance, slackChannels, webhooks, loading, onDelete, deleting, triggerOptions, metricId, style={ width: '580px', height: '100vh' } } = props;
const write = ({ target: { value, name } }) => props.edit({ [ name ]: value })
const writeOption = (e, { name, value }) => props.edit({ [ name ]: value });
const onChangeOption = (e, { checked, name }) => props.edit({ [ name ]: checked })
useEffect(() => {
props.fetchTriggerOptions();
}, [])
const writeQueryOption = (e, { name, value }) => {
const { query } = instance;
props.edit({ query: { ...query, [name] : value } });
@ -61,13 +66,12 @@ const AlertForm = props => {
props.edit({ query: { ...query, [name] : value } });
}
const metric = (instance && instance.query.left) ? metrics.find(i => i.value === instance.query.left) : null;
const metric = (instance && instance.query.left) ? triggerOptions.find(i => i.value === instance.query.left) : null;
const unit = metric ? metric.unit : '';
const isThreshold = instance.detectionMethod === 'threshold';
return (
<Form className={ cn("p-6", stl.wrapper)} style={{ width: '580px' }} onSubmit={() => props.onSubmit(instance)} id="alert-form">
<Form className={ cn("p-6", stl.wrapper)} style={style} onSubmit={() => props.onSubmit(instance)} id="alert-form">
<div className={cn(stl.content, '-mx-6 px-6 pb-12')}>
<input
autoFocus={ true }
@ -135,7 +139,7 @@ const AlertForm = props => {
placeholder="Select Metric"
selection
search
options={ metrics }
options={ triggerOptions }
name="left"
value={ instance.query.left }
onChange={ writeQueryOption }
@ -327,6 +331,7 @@ const AlertForm = props => {
export default connect(state => ({
instance: state.getIn(['alerts', 'instance']),
triggerOptions: state.getIn(['alerts', 'triggerOptions']),
loading: state.getIn(['alerts', 'saveRequest', 'loading']),
deleting: state.getIn(['alerts', 'removeRequest', 'loading'])
}))(AlertForm)
}), { fetchTriggerOptions })(AlertForm)

View file

@ -0,0 +1,101 @@
import React, { useEffect, useState } from 'react'
import { SlideModal, IconButton } from 'UI';
import { init, edit, save, remove } from 'Duck/alerts';
import { fetchList as fetchWebhooks } from 'Duck/webhook';
import AlertForm from '../AlertForm';
import { connect } from 'react-redux';
import { setShowAlerts } from 'Duck/dashboard';
import { EMAIL, SLACK, WEBHOOK } from 'App/constants/schedule';
import { confirm } from 'UI/Confirmation';
interface Props {
showModal?: boolean;
metricId?: number;
onClose?: () => void;
webhooks: any;
fetchWebhooks: Function;
save: Function;
remove: Function;
init: Function;
edit: Function;
}
function AlertFormModal(props: Props) {
const { metricId = null, showModal = false, webhooks } = props;
const [showForm, setShowForm] = useState(false);
useEffect(() => {
props.fetchWebhooks();
}, [])
const slackChannels = webhooks.filter(hook => hook.type === SLACK).map(({ webhookId, name }) => ({ value: webhookId, text: name })).toJS();
const hooks = webhooks.filter(hook => hook.type === WEBHOOK).map(({ webhookId, name }) => ({ value: webhookId, text: name })).toJS();
const saveAlert = instance => {
const wasUpdating = instance.exists();
props.save(instance).then(() => {
if (!wasUpdating) {
toggleForm(null, false);
}
if (props.onClose) {
props.onClose();
}
})
}
const onDelete = async (instance) => {
if (await confirm({
header: 'Confirm',
confirmButton: 'Yes, Delete',
confirmation: `Are you sure you want to permanently delete this alert?`
})) {
props.remove(instance.alertId).then(() => {
toggleForm(null, false);
});
}
}
const toggleForm = (instance, state) => {
if (instance) {
props.init(instance)
}
return setShowForm(state ? state : !showForm);
}
return (
<SlideModal
title={
<div className="flex items-center">
<span className="mr-3">{ 'Create Alert' }</span>
{/* <IconButton
circle
size="small"
icon="plus"
outline
id="add-button"
onClick={ () => toggleForm({}, true) }
/> */}
</div>
}
isDisplayed={ showModal }
onClose={props.onClose}
size="medium"
content={ showModal &&
<AlertForm
metricId={ metricId }
edit={props.edit}
slackChannels={slackChannels}
webhooks={hooks}
onSubmit={saveAlert}
onClose={props.onClose}
onDelete={onDelete}
style={{ width: '580px', height: '100vh - 200px' }}
/>
}
/>
);
}
export default connect(state => ({
webhooks: state.getIn(['webhooks', 'list']),
instance: state.getIn(['alerts', 'instance']),
}), { init, edit, save, remove, fetchWebhooks, setShowAlerts })(AlertFormModal)

View file

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

View file

@ -1,5 +1,4 @@
.wrapper {
height: 100vh;
position: relative;
}

View file

@ -5,8 +5,8 @@ import cn from 'classnames'
import { toggleChatWindow } from 'Duck/sessions';
import { connectPlayer } from 'Player/store';
import ChatWindow from '../../ChatWindow';
import { callPeer } from 'Player'
import { CallingState, ConnectionStatus } from 'Player/MessageDistributor/managers/AssistManager';
import { callPeer, requestReleaseRemoteControl } from 'Player'
import { CallingState, ConnectionStatus, RemoteControlStatus } from 'Player/MessageDistributor/managers/AssistManager';
import RequestLocalStream from 'Player/MessageDistributor/managers/LocalStream';
import type { LocalStream } from 'Player/MessageDistributor/managers/LocalStream';
@ -32,15 +32,15 @@ interface Props {
toggleChatWindow: (state) => void,
calling: CallingState,
peerConnectionStatus: ConnectionStatus,
remoteControlEnabled: boolean,
remoteControlStatus: RemoteControlStatus,
hasPermission: boolean,
isEnterprise: boolean,
}
function AssistActions({ toggleChatWindow, userId, calling, peerConnectionStatus, remoteControlEnabled, hasPermission, isEnterprise }: Props) {
function AssistActions({ toggleChatWindow, userId, calling, peerConnectionStatus, remoteControlStatus, hasPermission, isEnterprise }: Props) {
const [ incomeStream, setIncomeStream ] = useState<MediaStream | null>(null);
const [ localStream, setLocalStream ] = useState<LocalStream | null>(null);
const [ callObject, setCallObject ] = useState<{ end: ()=>void, toggleRemoteControl: ()=>void } | null >(null);
const [ callObject, setCallObject ] = useState<{ end: ()=>void } | null >(null);
useEffect(() => {
return callObject?.end()
@ -75,7 +75,7 @@ function AssistActions({ toggleChatWindow, userId, calling, peerConnectionStatus
}
}
const inCall = calling !== CallingState.False;
const onCall = calling === CallingState.OnCall || calling === CallingState.Reconnecting
const cannotCall = (peerConnectionStatus !== ConnectionStatus.Connected) || (isEnterprise && !hasPermission)
return (
@ -86,19 +86,18 @@ function AssistActions({ toggleChatWindow, userId, calling, peerConnectionStatus
className={
cn(
'cursor-pointer p-2 mr-2 flex items-center',
// {[stl.inCall] : inCall },
{[stl.disabled]: cannotCall}
)
}
onClick={ inCall ? callObject?.end : confirmCall}
onClick={ onCall ? callObject?.end : confirmCall}
role="button"
>
<Icon
name="headset"
size="20"
color={ inCall ? "red" : "gray-darkest" }
color={ onCall ? "red" : "gray-darkest" }
/>
<span className={cn("ml-2", { 'color-red' : inCall })}>{ inCall ? 'End Call' : 'Call' }</span>
<span className={cn("ml-2", { 'color-red' : onCall })}>{ onCall ? 'End Call' : 'Call' }</span>
</div>
}
content={ cannotCall ? "You dont have the permissions to perform this action." : `Call ${userId ? userId : 'User'}` }
@ -106,26 +105,24 @@ function AssistActions({ toggleChatWindow, userId, calling, peerConnectionStatus
inverted
position="top right"
/>
{ calling === CallingState.True &&
<div
className={
cn(
'cursor-pointer p-2 mr-2 flex items-center',
)
}
onClick={ callObject?.toggleRemoteControl }
role="button"
>
<Icon
name="remote-control"
size="20"
color={ remoteControlEnabled ? "green" : "gray-darkest"}
/>
<span className={cn("ml-2", { 'color-green' : remoteControlEnabled })}>{ 'Remote Control' }</span>
</div>
}
<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 }}>
{ inCall && callObject && <ChatWindow endCall={callObject.end} userId={userId} incomeStream={incomeStream} localStream={localStream} /> }
{ onCall && callObject && <ChatWindow endCall={callObject.end} userId={userId} incomeStream={incomeStream} localStream={localStream} /> }
</div>
</div>
)
@ -141,6 +138,6 @@ const con = connect(state => {
export default con(connectPlayer(state => ({
calling: state.calling,
remoteControlEnabled: state.remoteControl,
remoteControlStatus: state.remoteControl,
peerConnectionStatus: state.peerConnectionStatus,
}))(AssistActions))

View file

@ -13,17 +13,21 @@ const AssistTabs = (props: Props) => {
return (
<div className="relative mr-4">
<div className="flex items-center">
<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>
</div>
{props.userId && (
<>
<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>
</div>
</>
)}
</div>
<SlideModal
title={ <div>Live Sessions by {props.userId}</div> }

View file

@ -27,9 +27,9 @@ class AttributeItem extends React.PureComponent {
applyFilter = debounce(this.props.applyFilter, 1000)
fetchFilterOptionsDebounce = debounce(this.props.fetchFilterOptions, 500)
onFilterChange = (e, { name, value }) => {
onFilterChange = (name, value, valueIndex) => {
const { index } = this.props;
this.props.editAttribute(index, name, value);
this.props.editAttribute(index, name, value, valueIndex);
this.applyFilter();
}
@ -69,19 +69,20 @@ class AttributeItem extends React.PureComponent {
/>
}
{
!filter.hasNoValue &&
// !filter.hasNoValue &&
<AttributeValueField
filter={ filter }
options={ options }
onChange={ this.onFilterChange }
handleSearchChange={this.handleSearchChange}
loading={loadingFilterOptions}
index={index}
/>
}
<div className={ stl.actions }>
<button className={ stl.button } onClick={ this.removeFilter }>
<Icon name="close" size="16" />
<Icon name="close" size="14" />
</button>
</div>
</div>

View file

@ -7,7 +7,7 @@ import { LinkStyledInput, CircularLoader } from 'UI';
import { KEYS } from 'Types/filter/customFilter';
import Event, { TYPES } from 'Types/filter/event';
import CustomFilter from 'Types/filter/customFilter';
import { setActiveKey, addCustomFilter, removeCustomFilter, applyFilter } from 'Duck/filters';
import { setActiveKey, addCustomFilter, removeCustomFilter, applyFilter, updateValue } from 'Duck/filters';
import DurationFilter from '../DurationFilter/DurationFilter';
import AutoComplete from '../AutoComplete';
@ -24,6 +24,7 @@ const getHeader = (type) => {
addCustomFilter,
removeCustomFilter,
applyFilter,
updateValue,
})
class AttributeValueField extends React.PureComponent {
state = {
@ -134,25 +135,46 @@ class AttributeValueField extends React.PureComponent {
return params;
}
onAddValue = () => {
const { index, filter } = this.props;
this.props.updateValue('filters', index, filter.value.concat(""));
}
onRemoveValue = (valueIndex) => {
const { index, filter } = this.props;
this.props.updateValue('filters', index, filter.value.filter((_, i) => i !== valueIndex));
}
onChange = (name, value, valueIndex) => {
const { index, filter } = this.props;
this.props.updateValue('filters', index, filter.value.map((item, i) => i === valueIndex ? value : item));
}
render() {
const { filter, onChange, onTargetChange } = this.props;
// const { filter, onChange } = this.props;
const { filter } = this.props;
const _showAutoComplete = this.isAutoComplete(filter.type);
const _params = _showAutoComplete ? this.getParams(filter) : {};
let _optionsEndpoint= '/events/search';
let _optionsEndpoint= '/events/search';
return (
<React.Fragment>
{ _showAutoComplete ?
<AutoComplete
{ _showAutoComplete ? filter.value.map((v, i) => (
<AutoComplete
name={ 'value' }
endpoint={ _optionsEndpoint }
value={ filter.value }
value={ v }
index={ i }
params={ _params }
optionMapping={this.optionMapping}
onSelect={ onChange }
onSelect={ (e, { name, value }) => onChange(name, value, i) }
headerText={ <h5 className={ stl.header }>{ getHeader(filter.type) }</h5> }
fullWidth={ (filter.type === TYPES.CONSOLE || filter.type === TYPES.LOCATION || filter.type === TYPES.CUSTOM) && filter.value }
onRemoveValue={() => this.onRemoveValue(i)}
onAddValue={this.onAddValue}
showCloseButton={i !== filter.value.length - 1}
/>
))
: this.renderField()
}
{ filter.type === 'INPUT' &&

View file

@ -1,9 +1,10 @@
import React from 'react';
import APIClient from 'App/api_client';
import cn from 'classnames';
import { Input } from 'UI';
import { Input, Icon } from 'UI';
import { debounce } from 'App/utils';
import OutsideClickDetectingDiv from 'Shared/OutsideClickDetectingDiv';
import EventSearchInput from 'Shared/EventSearchInput';
import stl from './autoComplete.css';
import FilterItem from '../CustomFilters/FilterItem';
@ -78,7 +79,7 @@ class AutoComplete extends React.PureComponent {
})
onInputChange = (e, { name, value }) => {
onInputChange = ({ target: { value } }) => {
changed = true;
this.setState({ query: value, updated: true })
const _value = value.trim();
@ -118,23 +119,53 @@ class AutoComplete extends React.PureComponent {
valueToText = defaultValueToText,
placeholder = 'Type to search...',
headerText = '',
fullWidth = false
fullWidth = false,
onRemoveValue = () => {},
onAddValue = () => {},
showCloseButton = false,
} = this.props;
const options = optionMapping(values, valueToText)
return (
<OutsideClickDetectingDiv
className={ cn("relative", { "flex-1" : fullWidth }) }
className={ cn("relative flex items-center", { "flex-1" : fullWidth }) }
onClickOutside={this.onClickOutside}
>
<Input
{/* <EventSearchInput /> */}
<div className={stl.inputWrapper}>
<input
name="query"
// className={cn(stl.input)}
onFocus={ () => this.setState({ddOpen: true})}
onChange={ this.onInputChange }
onBlur={ this.onBlur }
onFocus={ () => this.setState({ddOpen: true})}
value={ query }
autoFocus={ true }
type="text"
placeholder={ placeholder }
onPaste={(e) => {
const text = e.clipboardData.getData('Text');
this.hiddenInput.value = text;
pasted = true; // to use only the hidden input
} }
/>
<div className={stl.right} onClick={showCloseButton ? onRemoveValue : onAddValue}>
{ showCloseButton ? <Icon name="close" size="14" /> : <span className="px-1">or</span>}
</div>
</div>
{showCloseButton && <div className='ml-2'>or</div>}
{/* <Input
className={ cn(stl.searchInput, { [ stl.fullWidth] : fullWidth }) }
onChange={ this.onInputChange }
onBlur={ this.onBlur }
onFocus={ () => this.setState({ddOpen: true})}
value={ query }
icon="search"
// icon="search"
label={{ basic: true, content: <div>test</div> }}
labelPosition='right'
loading={ loading }
autoFocus={ true }
type="search"
@ -144,7 +175,7 @@ class AutoComplete extends React.PureComponent {
this.hiddenInput.value = text;
pasted = true; // to use only the hidden input
} }
/>
/> */}
<textarea style={hiddenStyle} ref={(ref) => this.hiddenInput = ref }></textarea>
{ ddOpen && options.length > 0 &&
<div className={ stl.menu }>

View file

@ -19,6 +19,13 @@
color: $gray-darkest !important;
font-size: 14px !important;
background-color: rgba(255, 255, 255, 0.8) !important;
& .label {
padding: 0px !important;
display: flex;
align-items: center;
justify-content: center;
}
}
height: 28px !important;
width: 280px;
@ -28,3 +35,30 @@
.fullWidth {
width: 100% !important;
}
.inputWrapper {
border: solid thin $gray-light !important;
border-radius: 3px;
border-radius: 3px;
display: flex;
align-items: center;
& input {
height: 28px;
font-size: 13px !important;
padding: 0 5px !important;
border-top-left-radius: 3px;
border-bottom-left-radius: 3px;
}
& .right {
height: 28px;
display: flex;
align-items: center;
padding: 0 5px;
background-color: $gray-lightest;
border-left: solid thin $gray-light !important;
border-top-right-radius: 3px;
border-bottom-right-radius: 3px;
cursor: pointer;
}
}

View file

@ -9,9 +9,7 @@ import { applyFilter, clearEvents, addAttribute } from 'Duck/filters';
import { fetchList as fetchFunnelsList } from 'Duck/funnels';
import { defaultFilters, preloadedFilters } from 'Types/filter';
import { KEYS } from 'Types/filter/customFilter';
import EventFilter from './EventFilter';
import SessionList from './SessionList';
import FunnelList from 'Components/Funnels/FunnelList';
import stl from './bugFinder.css';
import { fetchList as fetchSiteList } from 'Duck/site';
import withLocationHandlers from "HOCs/withLocationHandlers";
@ -20,13 +18,17 @@ import { fetchList as fetchIntegrationVariables, fetchSources } from 'Duck/custo
import { RehydrateSlidePanel } from './WatchDogs/components';
import { setActiveTab, setFunnelPage } from 'Duck/sessions';
import SessionsMenu from './SessionsMenu/SessionsMenu';
import SessionFlowList from './SessionFlowList/SessionFlowList';
import { LAST_7_DAYS } from 'Types/app/period';
import { resetFunnel } from 'Duck/funnels';
import { resetFunnelFilters } from 'Duck/funnelFilters'
import NoSessionsMessage from '../shared/NoSessionsMessage';
import TrackerUpdateMessage from '../shared/TrackerUpdateMessage';
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 } from 'Duck/search';
const weakEqual = (val1, val2) => {
if (!!val1 === false && !!val2 === false) return true;
@ -75,28 +77,30 @@ const allowedQueryKeys = [
fetchFunnelsList,
resetFunnel,
resetFunnelFilters,
setFunnelPage
setFunnelPage,
clearSearch,
})
@withPageTitle("Sessions - OpenReplay")
export default class BugFinder extends React.PureComponent {
state = {showRehydratePanel: false}
constructor(props) {
super(props);
// props.fetchFavoriteSessionList();
// TODO should cache the response
props.fetchSources().then(() => {
defaultFilters[6] = {
category: 'Collaboration',
type: 'CUSTOM',
keys: this.props.sources.filter(({type}) => type === 'collaborationTool').map(({ label, key }) => ({ type: 'CUSTOM', source: key, label: label, key, icon: 'integrations/' + key, isFilter: false })).toJS()
};
defaultFilters[7] = {
category: 'Logging Tools',
type: 'ERROR',
keys: this.props.sources.filter(({type}) => type === 'logTool').map(({ label, key }) => ({ type: 'ERROR', source: key, label: label, key, icon: 'integrations/' + key, isFilter: false })).toJS()
};
});
// props.fetchFavoriteSessionList();
// TODO should cache the response
// props.fetchSources().then(() => {
// defaultFilters[6] = {
// category: 'Collaboration',
// type: 'CUSTOM',
// keys: this.props.sources.filter(({type}) => type === 'collaborationTool').map(({ label, key }) => ({ type: 'CUSTOM', source: key, label: label, key, icon: 'integrations/' + key, isFilter: false })).toJS()
// };
// defaultFilters[7] = {
// category: 'Logging Tools',
// type: 'ERROR',
// 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',
@ -123,28 +127,28 @@ export default class BugFinder extends React.PureComponent {
this.setState({ showRehydratePanel: !this.state.showRehydratePanel })
}
fetchPreloadedFilters = () => {
this.props.fetchFilterVariables('filterValues').then(function() {
const { filterValues } = this.props;
const keys = [
{key: KEYS.USER_OS, label: 'OS'},
{key: KEYS.USER_BROWSER, label: 'Browser'},
{key: KEYS.USER_DEVICE, label: 'Device'},
{key: KEYS.REFERRER, label: 'Referrer'},
{key: KEYS.USER_COUNTRY, label: 'Country'},
]
if (filterValues && filterValues.size != 0) {
keys.forEach(({key, label}) => {
const _keyFilters = filterValues.get(key)
if (key === KEYS.USER_COUNTRY) {
preloadedFilters.push(_keyFilters.map(item => ({label, type: key, key, value: item, actualValue: countries[item], isFilter: true})));
} else {
preloadedFilters.push(_keyFilters.map(item => ({label, type: key, key, value: item, isFilter: true})));
}
})
}
}.bind(this));
}
// fetchPreloadedFilters = () => {
// this.props.fetchFilterVariables('filterValues').then(function() {
// const { filterValues } = this.props;
// const keys = [
// {key: KEYS.USER_OS, label: 'OS'},
// {key: KEYS.USER_BROWSER, label: 'Browser'},
// {key: KEYS.USER_DEVICE, label: 'Device'},
// {key: KEYS.REFERRER, label: 'Referrer'},
// {key: KEYS.USER_COUNTRY, label: 'Country'},
// ]
// if (filterValues && filterValues.size != 0) {
// keys.forEach(({key, label}) => {
// const _keyFilters = filterValues.get(key)
// if (key === KEYS.USER_COUNTRY) {
// preloadedFilters.push(_keyFilters.map(item => ({label, type: key, key, value: item, actualValue: countries[item], isFilter: true})));
// } else {
// preloadedFilters.push(_keyFilters.map(item => ({label, type: key, key, value: item, isFilter: true})));
// }
// })
// }
// }.bind(this));
// }
setActiveTab = tab => {
this.props.setActiveTab(tab);
@ -166,15 +170,28 @@ export default class BugFinder extends React.PureComponent {
<div className={cn("side-menu-margined", stl.searchWrapper) }>
<TrackerUpdateMessage />
<NoSessionsMessage />
<div
data-hidden={ activeTab === 'live' || activeTab === 'favorite' }
className="mb-5"
>
<EventFilter />
</div>
{ activeFlow && activeFlow.type === 'flows' && <FunnelList /> }
{ activeTab.type !== 'live' && <SessionList onMenuItemClick={this.setActiveTab} /> }
{ activeTab.type === 'live' && <LiveSessionList /> }
{/* 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>
</div>
<RehydrateSlidePanel

View file

@ -68,7 +68,7 @@ export default class FilterModal extends React.PureComponent {
this.props.addAttribute(filter, _in >= 0 ? _in : _index);
} else {
logger.log('Adding Event', filter)
const _index = filterType === 'event' ? index : undefined; // should add new one if coming from fitlers
const _index = filterType === 'event' ? index : undefined; // should add new one if coming from filters
this.props.addEvent(filter, false, _index);
}

View file

@ -1,12 +1,10 @@
import { connect } from 'react-redux';
import { applyFilter } from 'Duck/filters';
import { applyFilter } from 'Duck/search';
import { fetchList as fetchFunnelsList } from 'Duck/funnels';
import DateRangeDropdown from 'Shared/DateRangeDropdown';
@connect(state => ({
rangeValue: state.getIn([ 'filters', 'appliedFilter', 'rangeValue' ]),
startDate: state.getIn([ 'filters', 'appliedFilter', 'startDate' ]),
endDate: state.getIn([ 'filters', 'appliedFilter', 'endDate' ]),
filter: state.getIn([ 'search', 'instance' ]),
}), {
applyFilter, fetchFunnelsList
})
@ -16,7 +14,8 @@ export default class DateRange extends React.PureComponent {
this.props.applyFilter(e)
}
render() {
const { startDate, endDate, rangeValue, className } = this.props;
const { filter: { rangeValue, startDate, endDate }, className } = this.props;
return (
<DateRangeDropdown
button

View file

@ -100,7 +100,7 @@ export default class EventEditor extends React.PureComponent {
<div className={ stl.actions }>
{ dndBtn }
<button className={ stl.button } onClick={ this.remove }>
<Icon name="close" size="16" />
<Icon name="close" size="14" />
</button>
</div>
</div>

View file

@ -2,7 +2,7 @@ import { connect } from 'react-redux';
import { Input } from 'semantic-ui-react';
import { DNDContext } from 'Components/hocs/dnd';
import {
addEvent, applyFilter, moveEvent, clearEvents,
addEvent, applyFilter, moveEvent, clearEvents, edit,
addCustomFilter, addAttribute, setSearchQuery, setActiveFlow, setFilterOption
} from 'Duck/filters';
import { fetchList as fetchEventList } from 'Duck/events';
@ -11,7 +11,7 @@ import OutsideClickDetectingDiv from 'Shared/OutsideClickDetectingDiv';
import EventEditor from './EventEditor';
import ListHeader from '../ListHeader';
import FilterModal from '../CustomFilters/FilterModal';
import { IconButton } from 'UI';
import { IconButton, SegmentSelection } from 'UI';
import stl from './eventFilter.css';
import Attributes from '../Attributes/Attributes';
import RandomPlaceholder from './RandomPlaceholder';
@ -19,6 +19,7 @@ import CustomFilters from '../CustomFilters';
import ManageFilters from '../ManageFilters';
import { blink as setBlink } from 'Duck/funnels';
import cn from 'classnames';
import SaveFilterButton from 'Shared/SaveFilterButton';
@connect(state => ({
events: state.getIn([ 'filters', 'appliedFilter', 'events' ]),
@ -41,7 +42,8 @@ import cn from 'classnames';
setSearchQuery,
setActiveFlow,
setFilterOption,
setBlink
setBlink,
edit,
})
@DNDContext
export default class EventFilter extends React.PureComponent {
@ -109,6 +111,10 @@ export default class EventFilter extends React.PureComponent {
this.props.setActiveFlow(null)
}
changeConditionTab = (e, { name, value }) => {
this.props.edit({ [ 'condition' ]: value })
};
render() {
const {
events,
@ -124,34 +130,6 @@ export default class EventFilter extends React.PureComponent {
return (
<OutsideClickDetectingDiv className={ stl.wrapper } onClickOutside={ this.closeModal } >
{ showPlacehoder && !hasFilters &&
<div
className={ stl.randomElement }
onClick={ this.onPlaceholderClick }
>
{ !searchQuery &&
<div className={ stl.placeholder }>Search for users, clicks, page visits, requests, errors and more</div>
// <RandomPlaceholder onClick={ this.onPlaceholderItemClick } appliedFilterKeys={ appliedFilterKeys } />
}
</div>
}
<Input
inputProps={ { "data-openreplay-label": "Search", "autocomplete": "off" } }
className={stl.searchField}
ref={ this.inputRef }
onChange={ this.onSearchChange }
onKeyUp={this.onKeyUp}
value={searchQuery}
icon="search"
iconPosition="left"
placeholder={ hasFilters ? 'Search sessions using any captured event (click, input, page, error...)' : ''}
fluid
onFocus={ this.onFocus }
onBlur={ this.onBlur }
id="search"
autocomplete="off"
/>
<FilterModal
close={ this.closeModal }
displayed={ showFilterModal }
@ -161,7 +139,24 @@ export default class EventFilter extends React.PureComponent {
/>
{ hasFilters &&
<div className={cn("bg-white rounded border-gray-light mt-2 relative", { 'blink-border' : blink })}>
<div className={cn("bg-white rounded border-gray-light mt-2 relative", { 'blink-border' : blink })}>
<div className="absolute right-0 top-0 m-3 z-10 flex items-center">
<div className="mr-2">Operator</div>
<SegmentSelection
primary
name="condition"
extraSmall={true}
// className="my-3"
onSelect={ this.changeConditionTab }
value={{ value: appliedFilter.condition }}
list={ [
{ name: 'AND', value: 'and' },
{ name: 'OR', value: 'or' },
{ name: 'THEN', value: 'then' },
]}
/>
</div>
{ events.size > 0 &&
<>
<div className="py-1"><ListHeader title="Events" /></div>
@ -189,6 +184,7 @@ export default class EventFilter extends React.PureComponent {
showFilters={ true }
/>
</div>
<SaveFilterButton />
<div className="flex items-center">
<div>
<IconButton plain label="CLEAR STEPS" onClick={ this.clearEvents } />

View file

@ -2,7 +2,7 @@ import { connect } from 'react-redux';
import { Dropdown } from 'semantic-ui-react';
import { Icon } from 'UI';
import { sort } from 'Duck/sessions';
import { applyFilter } from 'Duck/filters';
import { applyFilter } from 'Duck/search';
import stl from './sortDropdown.css';
@connect(null, { sort, applyFilter })

View file

@ -1,32 +1,69 @@
import React, { useEffect } from 'react';
import { fetchList } from 'Duck/sessions';
import { fetchLiveList } from 'Duck/sessions';
import { connect } from 'react-redux';
import { NoContent, Loader } from 'UI';
import { NoContent, Loader, LoadMoreButton } from 'UI';
import { List, Map } from 'immutable';
import SessionItem from 'Shared/SessionItem';
import withPermissions from 'HOCs/withPermissions'
import { KEYS } from 'Types/filter/customFilter';
import { applyFilter, addAttribute } from 'Duck/filters';
import Filter from 'Types/filter';
import { FilterCategory, FilterKey } from 'App/types/filter/filterType';
import { addFilterByKeyAndValue, updateCurrentPage } from 'Duck/liveSearch';
const AUTOREFRESH_INTERVAL = .5 * 60 * 1000
const PER_PAGE = 20;
interface Props {
loading: Boolean,
list?: List<any>,
fetchList: (params) => void,
list: List<any>,
fetchLiveList: () => Promise<void>,
applyFilter: () => void,
filters: Filter
filters: any,
addAttribute: (obj) => void,
addFilterByKeyAndValue: (key: FilterKey, value: string) => void,
updateCurrentPage: (page: number) => void,
currentPage: number,
}
function LiveSessionList(props: Props) {
const { loading, list, filters } = props;
const { loading, filters, list, currentPage } = props;
var timeoutId;
const hasUserFilter = filters && filters.filters.map(i => i.key).includes(KEYS.USERID);
const hasUserFilter = filters.map(i => i.key).includes(KEYS.USERID);
const [sessions, setSessions] = React.useState(list);
const displayedCount = Math.min(currentPage * PER_PAGE, sessions.size);
const addPage = () => props.updateCurrentPage(props.currentPage + 1)
useEffect(() => {
if (filters.size === 0) {
props.addFilterByKeyAndValue(FilterKey.USERID, '');
}
}, []);
useEffect(() => {
const filteredSessions = filters.size > 0 ? props.list.filter(session => {
let hasValidFilter = true;
filters.forEach(filter => {
if (!hasValidFilter) return;
const _values = filter.value.filter(i => i !== '' && i !== null && i !== undefined).map(i => i.toLowerCase());
if (filter.key === FilterKey.USERID) {
const _userId = session.userId ? session.userId.toLowerCase() : '';
hasValidFilter = _values.length > 0 ? (_values.includes(_userId) && hasValidFilter) || _values.some(i => _userId.includes(i)) : hasValidFilter;
}
if (filter.category === FilterCategory.METADATA) {
const _source = session.metadata[filter.key] ? session.metadata[filter.key].toLowerCase() : '';
hasValidFilter = _values.length > 0 ? (_values.includes(_source) && hasValidFilter) || _values.some(i => _source.includes(i)) : hasValidFilter;
}
})
return hasValidFilter;
}) : props.list;
setSessions(filteredSessions);
}, [filters, list]);
useEffect(() => {
props.fetchList(filters.toJS());
props.fetchLiveList();
timeout();
return () => {
clearTimeout(timeoutId)
@ -35,17 +72,15 @@ function LiveSessionList(props: Props) {
const onUserClick = (userId, userAnonymousId) => {
if (userId) {
props.addAttribute({ label: 'User Id', key: KEYS.USERID, type: KEYS.USERID, operator: 'is', value: userId })
props.addFilterByKeyAndValue(FilterKey.USERID, userId);
} else {
props.addAttribute({ label: 'Anonymous ID', key: 'USERANONYMOUSID', type: "USERANONYMOUSID", operator: 'is', value: userAnonymousId })
props.addFilterByKeyAndValue(FilterKey.USERANONYMOUSID, userAnonymousId);
}
props.applyFilter()
}
const timeout = () => {
timeoutId = setTimeout(() => {
props.fetchList(filters.toJS());
props.fetchLiveList();
timeout();
}, AUTOREFRESH_INTERVAL);
}
@ -56,14 +91,15 @@ function LiveSessionList(props: Props) {
title={"No live sessions."}
subtext={
<span>
See how to <a target="_blank" className="link" href="https://docs.openreplay.com/plugins/assist">{'enable Assist'}</a> if you haven't yet done so.
See how to <a target="_blank" className="link" href="https://docs.openreplay.com/plugins/assist">{'enable Assist'}</a> and ensure you're using tracker-assist <span className="font-medium">v3.5.0</span> or higher.
</span>
}
image={<img src="/img/live-sessions.png" style={{ width: '70%', marginBottom: '30px' }}/>}
show={ !loading && list && list.size === 0}
image={<img src="/img/live-sessions.png"
style={{ width: '70%', marginBottom: '30px' }}/>}
show={ !loading && sessions && sessions.size === 0}
>
<Loader loading={ loading }>
{list && list.map(session => (
{sessions && sessions.take(displayedCount).map(session => (
<SessionItem
key={ session.sessionId }
session={ session }
@ -72,6 +108,13 @@ function LiveSessionList(props: Props) {
onUserClick={onUserClick}
/>
))}
<LoadMoreButton
className="mt-3"
displayedCount={displayedCount}
totalCount={sessions.size}
onClick={addPage}
/>
</Loader>
</NoContent>
</div>
@ -82,8 +125,8 @@ export default withPermissions(['ASSIST_LIVE', 'SESSION_REPLAY'])(connect(
(state) => ({
list: state.getIn(['sessions', 'liveSessions']),
loading: state.getIn([ 'sessions', 'loading' ]),
filters: state.getIn([ 'filters', 'appliedFilter' ]),
filters: state.getIn([ 'liveSearch', 'instance', 'filters' ]),
currentPage: state.getIn(["liveSearch", "currentPage"]),
}),
{
fetchList, applyFilter, addAttribute }
{ fetchLiveList, applyFilter, addAttribute, addFilterByKeyAndValue, updateCurrentPage }
)(LiveSessionList));

View file

@ -46,7 +46,7 @@ export default class SaveModal extends React.PureComponent {
role="button"
tabIndex="-1"
color="gray-dark"
size="18"
size="14"
name="close"
onClick={ () => toggleFilterModal(false) }
/>
@ -78,7 +78,7 @@ export default class SaveModal extends React.PureComponent {
/>
<div className="flex items-center cursor-pointer" onClick={ () => this.setState({ 'isPublic' : !isPublic }) }>
<Icon name="user-friends" size="16" />
<span className="ml-2"> Team Funnel</span>
<span className="ml-2"> Team Visible</span>
</div>
</div>
</Form.Field>

View file

@ -1,9 +1,10 @@
import { connect } from 'react-redux';
import { Loader, NoContent, Message, Icon, Button, LoadMoreButton } from 'UI';
import { Loader, NoContent, Button, LoadMoreButton } from 'UI';
import { applyFilter, addAttribute, addEvent } from 'Duck/filters';
import { fetchSessions, addFilterByKeyAndValue } from 'Duck/search';
import SessionItem from 'Shared/SessionItem';
import SessionListHeader from './SessionListHeader';
import { KEYS } from 'Types/filter/customFilter';
import { FilterKey } from 'Types/filter/filterType';
const ALL = 'all';
const PER_PAGE = 10;
@ -17,11 +18,13 @@ var timeoutId;
activeTab: state.getIn([ 'sessions', 'activeTab' ]),
allList: state.getIn([ 'sessions', 'list' ]),
total: state.getIn([ 'sessions', 'total' ]),
filters: state.getIn([ 'filters', 'appliedFilter', 'filters' ]),
filters: state.getIn([ 'search', 'instance', 'filters' ]),
}), {
applyFilter,
addAttribute,
addEvent
addEvent,
fetchSessions,
addFilterByKeyAndValue,
})
export default class SessionList extends React.PureComponent {
state = {
@ -42,18 +45,17 @@ export default class SessionList extends React.PureComponent {
onUserClick = (userId, userAnonymousId) => {
if (userId) {
this.props.addAttribute({ label: 'User Id', key: KEYS.USERID, type: KEYS.USERID, operator: 'is', value: userId })
this.props.addFilterByKeyAndValue(FilterKey.USERID, userId);
} else {
this.props.addAttribute({ label: 'Anonymous ID', key: 'USERANONYMOUSID', type: "USERANONYMOUSID", operator: 'is', value: userAnonymousId })
this.props.addFilterByKeyAndValue(FilterKey.USERANONYMOUSID, userAnonymousId);
}
this.props.applyFilter()
}
timeout = () => {
timeoutId = setTimeout(function () {
if (this.props.shouldAutorefresh) {
this.props.applyFilter();
// this.props.applyFilter();
this.props.fetchSessions();
}
this.timeout();
}.bind(this), AUTOREFRESH_INTERVAL);
@ -81,8 +83,8 @@ export default class SessionList extends React.PureComponent {
allList,
activeTab
} = this.props;
const hasUserFilter = filters.map(i => i.key).includes(KEYS.USERID);
const _filterKeys = filters.map(i => i.key);
const hasUserFilter = _filterKeys.includes(FilterKey.USERID) || _filterKeys.includes(FilterKey.USERANONYMOUSID);
const { showPages } = this.state;
const displayedCount = Math.min(showPages * PER_PAGE, list.size);

View file

@ -10,7 +10,7 @@
width: 150px;
color: $gray-darkest;
cursor: pointer;
background-color: rgba(255, 255, 255, 0.8) !important;
background-color: rgba(0, 0, 0, 0.1) !important;
&:hover {
background-color: white;
}

View file

@ -18,11 +18,11 @@ export default class AddWidgets extends React.PureComponent {
const { appearance } = this.props;
const newAppearance = appearance.setIn([ 'dashboard', widgetKey ], true);
this.props.switchOpen(false);
this.props.updateAppearance(newAppearance)
this.props.updateAppearance(newAppearance)
}
render() {
const { appearance, disabled } = this.props;
const { appearance } = this.props;
const avaliableWidgets = WIDGET_LIST.filter(({ key, type }) => !appearance.dashboard[ key ] && type === this.props.type );
return (
@ -44,46 +44,6 @@ export default class AddWidgets extends React.PureComponent {
</div>
}
</OutsideClickDetectingDiv>
<SlideModal
title="Add Widget"
size="middle"
isDisplayed={ false }
content={ this.props.open &&
<NoContent
title="No Widgets Left"
size="small"
show={ avaliableWidgets.length === 0}
>
{ avaliableWidgets.map(({ key, name, description, thumb }) => (
<div className={ cn(stl.widgetCard) } key={ key } >
<div className="flex justify-between items-center flex-1">
<div className="mr-10">
<h4>{ name }</h4>
</div>
<IconButton
className="flex-shrink-0"
onClick={ this.makeAddHandler(key) }
circle
outline
icon="plus"
style={{ width: '24px', height: '24px'}}
/>
</div>
</div>
))}
</NoContent>
}
onClose={ this.props.switchOpen }
/>
<IconButton
circle
size="small"
icon="plus"
outline
onClick={ this.props.switchOpen }
disabled={ disabled || avaliableWidgets.length === 0 } //TODO disabled after Custom fields filtering
/>
</div>
);
}

View file

@ -3,8 +3,11 @@ import cn from 'classnames';
import withPageTitle from 'HOCs/withPageTitle';
import withPermissions from 'HOCs/withPermissions'
import { setPeriod, setPlatform, fetchMetadataOptions } from 'Duck/dashboard';
import { NoContent } from 'UI';
import { WIDGET_KEYS } from 'Types/dashboard';
import { NoContent, Icon } from 'UI';
import { WIDGET_KEYS, WIDGET_LIST } from 'Types/dashboard';
// import CustomMetrics from 'Shared/CustomMetrics';
import CustomMetricsModal from 'Shared/CustomMetrics/CustomMetricsModal';
import SessionListModal from 'Shared/CustomMetrics/SessionListModal';
import {
MissingResources,
@ -38,14 +41,16 @@ import SideMenuSection from './SideMenu/SideMenuSection';
import styles from './dashboard.css';
import WidgetSection from 'Shared/WidgetSection/WidgetSection';
import OverviewWidgets from './Widgets/OverviewWidgets/OverviewWidgets';
import CustomMetricsWidgets from './Widgets/CustomMetricsWidgets/CustomMetricsWidgets';
import WidgetHolder from './WidgetHolder/WidgetHolder';
import MetricsFilters from 'Shared/MetricsFilters/MetricsFilters';
import { withRouter } from 'react-router';
const OVERVIEW = 'overview';
const PERFORMANCE = 'performance';
const ERRORS_N_CRASHES = 'errors_n_crashes';
const ERRORS_N_CRASHES = 'errors';
const RESOURCES = 'resources';
const CUSTOM_METRICS = 'custom_metrics';
const menuList = [
{
@ -54,6 +59,13 @@ const menuList = [
icon: "info-square",
label: getStatusLabel(OVERVIEW),
active: status === OVERVIEW,
},
{
key: CUSTOM_METRICS,
section: 'metrics',
icon: "sliders",
label: getStatusLabel(CUSTOM_METRICS),
active: status === CUSTOM_METRICS,
},
{
key: ERRORS_N_CRASHES,
@ -83,6 +95,8 @@ function getStatusLabel(status) {
switch(status) {
case OVERVIEW:
return "Overview";
case CUSTOM_METRICS:
return "Custom Metrics";
case PERFORMANCE:
return "Performance";
case ERRORS_N_CRASHES:
@ -110,6 +124,8 @@ function isInViewport(el) {
comparing: state.getIn([ 'dashboard', 'comparing' ]),
platform: state.getIn([ 'dashboard', 'platform' ]),
dashboardAppearance: state.getIn([ 'user', 'account', 'appearance', 'dashboard' ]),
activeWidget: state.getIn(['customMetrics', 'activeWidget']),
appearance: state.getIn([ 'user', 'account', 'appearance' ]),
}), { setPeriod, setPlatform, fetchMetadataOptions })
@withPageTitle('Metrics - OpenReplay')
@withRouter
@ -130,6 +146,10 @@ export default class Dashboard extends React.PureComponent {
pageSection: 'metrics',
};
getWidgetsByKey = (widgetType) => {
return WIDGET_LIST.filter(({ key, type }) => !this.props.appearance.dashboard[ key ] && type === widgetType);
}
componentDidMount() {
const { history, location } = this.props;
// TODO check the hash navigato it
@ -164,7 +184,7 @@ export default class Dashboard extends React.PureComponent {
}
render() {
const { dashboardAppearance, comparing } = this.props;
const { dashboardAppearance, comparing, activeWidget } = this.props;
const { pageSection } = this.state;
const noWidgets = WIDGET_KEYS
@ -184,6 +204,8 @@ export default class Dashboard extends React.PureComponent {
<div>
<div className={ cn(styles.header, "flex items-center w-full") }>
<MetricsFilters />
{ activeWidget && <SessionListModal activeWidget={activeWidget} /> }
</div>
<div className="">
<NoContent
@ -193,13 +215,41 @@ export default class Dashboard extends React.PureComponent {
icon
empty
>
<WidgetSection title="Overview" type="overview" className="mb-4" description="(Average Values)">
<WidgetSection
title="Overview"
type="overview"
className="mb-4"
description="(Average Values)"
widgets={this.getWidgetsByKey(OVERVIEW)}
>
<div className="grid grid-cols-4 gap-4" ref={this.list[OVERVIEW]}>
<OverviewWidgets isOverview />
</div>
</WidgetSection>
<WidgetSection title="Errors" className="mb-4" type="errors">
<WidgetSection
title="Custom Metrics"
type={CUSTOM_METRICS}
className="mb-4"
widgets={[]}
description={
<div className="flex items-center">
{comparing && (
<div className="text-sm flex items-center font-normal">
<Icon name="info" size="12" className="mr-2" />
Custom Metrics are not supported for comparison.
</div>
)}
{/* <CustomMetrics /> */}
</div>
}
>
<div className={cn("gap-4 grid grid-cols-2")} ref={this.list[CUSTOM_METRICS]}>
<CustomMetricsWidgets onClickEdit={(e) => null}/>
</div>
</WidgetSection>
<WidgetSection title="Errors" className="mb-4" type="errors" widgets={this.getWidgetsByKey(ERRORS_N_CRASHES)}>
<div className={ cn("gap-4", { 'grid grid-cols-2' : !comparing })} ref={this.list[ERRORS_N_CRASHES]}>
{ dashboardAppearance.impactedSessionsByJsErrors && <WidgetHolder Component={SessionsAffectedByJSErrors} /> }
{ dashboardAppearance.errorsPerDomains && <WidgetHolder Component={ErrorsPerDomain} /> }
@ -213,7 +263,7 @@ export default class Dashboard extends React.PureComponent {
</div>
</WidgetSection>
<WidgetSection title="Performance" type="performance" className="mb-4">
<WidgetSection title="Performance" type="performance" className="mb-4" widgets={this.getWidgetsByKey(PERFORMANCE)}>
<div className={ cn("gap-4", { 'grid grid-cols-2' : !comparing })} ref={this.list[PERFORMANCE]}>
{ dashboardAppearance.speedLocation && <WidgetHolder Component={SpeedIndexLocation} /> }
{ dashboardAppearance.crashes && <WidgetHolder Component={Crashes} /> }
@ -233,7 +283,7 @@ export default class Dashboard extends React.PureComponent {
</div>
</WidgetSection>
<WidgetSection title="Resources" type="resources" className="mb-4">
<WidgetSection title="Resources" type="resources" className="mb-4" widgets={this.getWidgetsByKey(RESOURCES)}>
<div className={ cn("gap-4", { 'grid grid-cols-2' : !comparing })} ref={this.list[RESOURCES]}>
{ dashboardAppearance.resourcesCountByType && <WidgetHolder Component={BreakdownOfLoadedResources} /> }
{ dashboardAppearance.resourcesLoadingTime && <WidgetHolder Component={ResourceLoadingTime} /> }
@ -248,6 +298,8 @@ export default class Dashboard extends React.PureComponent {
</div>
</div>
</div>
<CustomMetricsModal />
</div>
);
}

View file

@ -5,6 +5,7 @@ import stl from './sideMenuSection.css';
import { connect } from 'react-redux';
import { NavLink } from 'react-router-dom';
import { withSiteId } from 'App/routes';
import CustomMetrics from 'Shared/CustomMetrics';
function SideMenuSection({ title, items, onItemClick, setShowAlerts, siteId }) {
return (
@ -29,6 +30,13 @@ function SideMenuSection({ title, items, onItemClick, setShowAlerts, siteId }) {
onClick={() => setShowAlerts(true)}
/>
</div>
<div className={stl.divider} />
<div className="my-3">
<CustomMetrics />
<div className="color-gray-medium mt-2">
Be proactive by monitoring the metrics you care about the most.
</div>
</div>
</>
);
}

View file

@ -7,8 +7,8 @@ function WidgetSection({ className, title, children, description, type }) {
<div className={cn(className, 'rounded p-4 bg-gray-light-shade')}>
<div className="mb-4 flex items-center">
<div className="flex items-center">
<div className="text-2xl mr-3">{title}</div>
<AddWidgets type={type} />
<div className="text-2xl mr-3">{title}</div>
{/* <AddWidgets type={type} /> */}
</div>
{description && <div className="ml-auto color-gray-darkest font-medium text-sm">{description}</div> }
</div>

View file

@ -0,0 +1,6 @@
.wrapper {
background-color: white;
/* border: solid thin $gray-medium; */
border-radius: 3px;
padding: 10px;
}

View file

@ -0,0 +1,183 @@
import React, { useEffect, useState } from 'react';
import { connect } from 'react-redux';
import { Loader, NoContent, Icon, Popup } from 'UI';
import { Styles } from '../../common';
import { ResponsiveContainer, AreaChart, XAxis, YAxis, CartesianGrid, Area, Tooltip } from 'recharts';
import { LineChart, Line, Legend } from 'recharts';
import { LAST_24_HOURS, LAST_30_MINUTES, YESTERDAY, LAST_7_DAYS } from 'Types/app/period';
import stl from './CustomMetricWidget.css';
import { getChartFormatter, getStartAndEndTimestampsByDensity } from 'Types/dashboard/helper';
import { init, edit, remove, setAlertMetricId, setActiveWidget, updateActiveState } from 'Duck/customMetrics';
import APIClient from 'App/api_client';
import { setShowAlerts } from 'Duck/dashboard';
const customParams = rangeName => {
const params = { density: 70 }
if (rangeName === LAST_24_HOURS) params.density = 70
if (rangeName === LAST_30_MINUTES) params.density = 70
if (rangeName === YESTERDAY) params.density = 70
if (rangeName === LAST_7_DAYS) params.density = 70
return params
}
interface Props {
metric: any;
// loading?: boolean;
data?: any;
showSync?: boolean;
compare?: boolean;
period?: any;
onClickEdit: (e) => void;
remove: (id) => void;
setShowAlerts: (showAlerts) => void;
setAlertMetricId: (id) => void;
onAlertClick: (e) => void;
init: (metric) => void;
edit: (setDefault?) => void;
setActiveWidget: (widget) => void;
updateActiveState: (metricId, state) => void;
}
function CustomMetricWidget(props: Props) {
const { metric, showSync, compare, period } = props;
const [loading, setLoading] = useState(false)
const [data, setData] = useState<any>([]);
const [seriesMap, setSeriesMap] = useState<any>([]);
const colors = Styles.customMetricColors;
const params = customParams(period.rangeName)
const gradientDef = Styles.gradientDef();
const metricParams = { ...params, metricId: metric.metricId, viewType: 'lineChart', startDate: period.start, endDate: period.end }
useEffect(() => {
new APIClient()['post']('/custom_metrics/chart', { ...metricParams, q: metric.name })
.then(response => response.json())
.then(({ errors, data }) => {
if (errors) {
console.log('err', errors)
} else {
const namesMap = data
.map(i => Object.keys(i))
.flat()
.filter(i => i !== 'time' && i !== 'timestamp')
.reduce((unique: any, item: any) => {
if (!unique.includes(item)) {
unique.push(item);
}
return unique;
}, []);
setSeriesMap(namesMap);
setData(getChartFormatter(period)(data));
}
}).finally(() => setLoading(false));
}, [period])
const clickHandler = (event, index) => {
if (event) {
const payload = event.activePayload[0].payload;
const timestamp = payload.timestamp;
const { startTimestamp, endTimestamp } = getStartAndEndTimestampsByDensity(timestamp, period.start, period.end, params.density);
props.setActiveWidget({ widget: metric, startTimestamp, endTimestamp, timestamp: payload.timestamp, index })
}
}
const updateActiveState = (metricId, state) => {
props.updateActiveState(metricId, state);
}
return (
<div className={stl.wrapper}>
<div className="flex items-center mb-10 p-2">
<div className="font-medium">{metric.name}</div>
<div className="ml-auto flex items-center">
<WidgetIcon className="cursor-pointer mr-6" icon="bell-plus" tooltip="Set Alert" onClick={props.onAlertClick} />
<WidgetIcon className="cursor-pointer mr-6" icon="pencil" tooltip="Edit Metric" onClick={() => props.init(metric)} />
<WidgetIcon className="cursor-pointer" icon="close" tooltip="Hide Metric" onClick={() => updateActiveState(metric.metricId, false)} />
</div>
</div>
<div>
<Loader loading={ loading } size="small">
<NoContent
size="small"
show={ data.length === 0 }
>
<ResponsiveContainer height={ 240 } width="100%">
<LineChart
data={ data }
margin={Styles.chartMargins}
syncId={ showSync ? "domainsErrors_4xx" : undefined }
onClick={clickHandler}
>
<defs>
<linearGradient id="colorCount" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={colors[4]} stopOpacity={ 0.9 } />
<stop offset="95%" stopColor={colors[4]} stopOpacity={ 0.2 } />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={ false } stroke="#EEEEEE" />
<XAxis
{...Styles.xaxis}
dataKey="time"
interval={params.density/7}
/>
<YAxis
{...Styles.yaxis}
allowDecimals={false}
label={{
...Styles.axisLabelLeft,
value: "Number of Sessions"
}}
/>
<Legend />
<Tooltip {...Styles.tooltip} />
{ seriesMap.map((key, index) => (
<Line
key={key}
name={key}
type="monotone"
dataKey={key}
stroke={colors[index]}
fillOpacity={ 1 }
strokeWidth={ 2 }
strokeOpacity={ 0.8 }
fill="url(#colorCount)"
dot={false}
/>
))}
</LineChart>
</ResponsiveContainer>
</NoContent>
</Loader>
</div>
</div>
);
}
export default connect(state => ({
period: state.getIn(['dashboard', 'period']),
}), {
remove,
setShowAlerts,
setAlertMetricId,
edit,
setActiveWidget,
updateActiveState,
init,
})(CustomMetricWidget);
const WidgetIcon = ({ className = '', tooltip = '', icon, onClick }) => (
<Popup
size="small"
trigger={
<div className={className} onClick={onClick}>
<Icon name={icon} size="14" />
</div>
}
content={tooltip}
position="top center"
inverted
/>
)

View file

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

View file

@ -0,0 +1,6 @@
.wrapper {
background-color: white;
/* border: solid thin $gray-medium; */
border-radius: 3px;
padding: 10px;
}

View file

@ -0,0 +1,154 @@
import React, { useEffect, useState, useRef } from 'react';
import { connect } from 'react-redux';
import { Loader, NoContent, Icon } from 'UI';
import { Styles } from '../../common';
import { ResponsiveContainer, XAxis, YAxis, CartesianGrid, Tooltip, LineChart, Line, Legend } from 'recharts';
import Period, { LAST_24_HOURS, LAST_30_MINUTES, YESTERDAY, LAST_7_DAYS } from 'Types/app/period';
import stl from './CustomMetricWidgetPreview.css';
import { getChartFormatter } from 'Types/dashboard/helper';
import { remove } from 'Duck/customMetrics';
import DateRange from 'Shared/DateRange';
import { edit } from 'Duck/customMetrics';
import APIClient from 'App/api_client';
const customParams = rangeName => {
const params = { density: 70 }
if (rangeName === LAST_24_HOURS) params.density = 70
if (rangeName === LAST_30_MINUTES) params.density = 70
if (rangeName === YESTERDAY) params.density = 70
if (rangeName === LAST_7_DAYS) params.density = 70
return params
}
interface Props {
metric: any;
data?: any;
showSync?: boolean;
// compare?: boolean;
onClickEdit?: (e) => void;
remove: (id) => void;
edit: (metric) => void;
}
function CustomMetricWidget(props: Props) {
const { metric, showSync } = props;
const [loading, setLoading] = useState(false)
const [data, setData] = useState<any>({ chart: [{}] })
const [seriesMap, setSeriesMap] = useState<any>([]);
const [period, setPeriod] = useState(Period({ rangeName: metric.rangeName, startDate: metric.startDate, endDate: metric.endDate }));
const colors = Styles.customMetricColors;
const params = customParams(period.rangeName)
const gradientDef = Styles.gradientDef();
const metricParams = { ...params, metricId: metric.metricId, viewType: 'lineChart' }
const prevMetricRef = useRef<any>();
useEffect(() => {
// Check for title change
if (prevMetricRef.current && prevMetricRef.current.name !== metric.name) {
prevMetricRef.current = metric;
return
};
prevMetricRef.current = metric;
// fetch new data for the widget preview
new APIClient()['post']('/custom_metrics/try', { ...metricParams, ...metric.toSaveData() })
.then(response => response.json())
.then(({ errors, data }) => {
if (errors) {
console.log('err', errors)
} else {
const namesMap = data
.map(i => Object.keys(i))
.flat()
.filter(i => i !== 'time' && i !== 'timestamp')
.reduce((unique: any, item: any) => {
if (!unique.includes(item)) {
unique.push(item);
}
return unique;
}, []);
setSeriesMap(namesMap);
setData(getChartFormatter(period)(data));
}
}).finally(() => setLoading(false));
}, [metric])
const onDateChange = (changedDates) => {
setPeriod({ ...changedDates, rangeName: changedDates.rangeValue })
props.edit({ ...changedDates, rangeName: changedDates.rangeValue });
}
return (
<div className="mb-10">
<div className="flex items-center mb-4">
<div className="mr-auto font-medium">Preview</div>
<div>
<DateRange
rangeValue={metric.rangeName}
startDate={metric.startDate}
endDate={metric.endDate}
onDateChange={onDateChange}
customRangeRight
direction="left"
/>
</div>
</div>
<div className={stl.wrapper}>
<div>
<Loader loading={ loading } size="small">
<NoContent
size="small"
show={ data.length === 0 }
>
<ResponsiveContainer height={ 240 } width="100%">
<LineChart
data={ data }
margin={Styles.chartMargins}
syncId={ showSync ? "domainsErrors_4xx" : undefined }
>
<CartesianGrid strokeDasharray="3 3" vertical={ false } stroke="#EEEEEE" />
<XAxis
{...Styles.xaxis}
dataKey="time"
interval={params.density/7}
/>
<YAxis
{...Styles.yaxis}
allowDecimals={false}
label={{
...Styles.axisLabelLeft,
value: "Number of Sessions"
}}
/>
<Legend />
<Tooltip {...Styles.tooltip} />
{ seriesMap.map((key, index) => (
<Line
key={key}
name={key}
type="monotone"
dataKey={key}
stroke={colors[index]}
fillOpacity={ 1 }
strokeWidth={ 2 }
strokeOpacity={ 0.6 }
// fill="url(#colorCount)"
dot={false}
/>
))}
</LineChart>
</ResponsiveContainer>
</NoContent>
</Loader>
</div>
</div>
</div>
);
}
export default connect(null, { remove, edit })(CustomMetricWidget);

View file

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

View file

@ -0,0 +1,50 @@
import React, { useEffect, useState } from 'react';
import { connect } from 'react-redux';
import { fetchList } from 'Duck/customMetrics';
import CustomMetricWidget from './CustomMetricWidget';
import AlertFormModal from 'App/components/Alerts/AlertFormModal';
import { init as initAlert } from 'Duck/alerts';
import LazyLoad from 'react-lazyload';
interface Props {
fetchList: Function;
list: any;
onClickEdit: (e) => void;
initAlert: Function;
}
function CustomMetricsWidgets(props: Props) {
const { list } = props;
const [activeMetricId, setActiveMetricId] = useState(null);
useEffect(() => {
props.fetchList()
}, [])
return (
<>
{list.filter(item => item.active).map((item: any) => (
<LazyLoad>
<CustomMetricWidget
key={item.metricId}
metric={item}
onClickEdit={props.onClickEdit}
onAlertClick={(e) => {
setActiveMetricId(item.metricId)
props.initAlert({ query: { left: item.series.first().seriesId }})
}}
/>
</LazyLoad>
))}
<AlertFormModal
showModal={!!activeMetricId}
metricId={activeMetricId}
onClose={() => setActiveMetricId(null)}
/>
</>
);
}
export default connect(state => ({
list: state.getIn(['customMetrics', 'list']),
}), { fetchList, initAlert })(CustomMetricsWidgets);

View file

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

View file

@ -25,7 +25,7 @@ const loadChart = (data, loading, unit, syncId, compare, tooltipLael) => {
<YAxis hide interval={ 0 } />
<Area
name={tooltipLael}
unit={unit && ' ' + unit}
// unit={unit && ' ' + unit}
type="monotone"
dataKey="value"
stroke={compare? Styles.compareColors[0] : Styles.colors[0]}

View file

@ -0,0 +1,6 @@
.wrapper {
background-color: white;
/* border: solid thin $gray-medium; */
border-radius: 3px;
padding: 10px;
}

View file

@ -0,0 +1,23 @@
import React from 'react';
import stl from './CustomMetricWidgetHoc.css';
import { Icon } from 'UI';
interface Props {
}
const CustomMetricWidgetHoc = ({ ...rest }: Props) => BaseComponent => {
return (
<div className={stl.wrapper}>
<div className="flex items-center mb-10 p-2">
<div className="font-medium">Widget Name</div>
<div className="ml-auto">
<div className="cursor-pointer">
<Icon name="bell-plus" size="16" />
</div>
</div>
</div>
{/* <BaseComponent {...rest} /> */}
</div>
);
}
export default CustomMetricWidgetHoc;

View file

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

View file

@ -4,6 +4,7 @@ const colors = ['#3EAAAF', '#5FBABF', '#7BCBCF', '#96DCDF', '#ADDCDF'];
const colorsx = ['#256669', '#38999e', '#3eaaaf', '#51b3b7', '#78c4c7', '#9fd5d7', '#c5e6e7'].reverse();
const compareColors = ['#394EFF', '#4D5FFF', '#808DFF', '#B3BBFF', '#E5E8FF'];
const compareColorsx = ["#222F99", "#2E3ECC", "#394EFF", "#6171FF", "#8895FF", "#B0B8FF", "#D7DCFF"].reverse();
const customMetricColors = ['#3EAAAF', '#394EFF', '#666666'];
const countView = count => {
const isMoreThanK = count >= 1000;
@ -11,6 +12,7 @@ const countView = count => {
}
export default {
customMetricColors,
colors,
colorsx,
compareColors,

View file

@ -4,6 +4,7 @@ import styles from './funnelSaveModal.css';
import { edit, save, fetchList as fetchFunnelsList } from 'Duck/funnels';
@connect(state => ({
filter: state.getIn(['search', 'instance']),
funnel: state.getIn(['funnels', 'instance']),
loading: state.getIn([ 'funnels', 'saveRequest', 'loading' ]) ||
state.getIn([ 'funnels', 'updateRequest', 'loading' ]),
@ -27,7 +28,7 @@ export default class FunnelSaveModal extends React.PureComponent {
onChangeOption = (e, { checked, name }) => this.props.edit({ [ name ]: checked })
onSave = () => {
const { funnel, closeHandler } = this.props;
const { funnel, filter } = this.props;
if (funnel.name.trim() === '') return;
this.props.save(funnel).then(function() {
this.props.fetchFunnelsList();
@ -38,7 +39,6 @@ export default class FunnelSaveModal extends React.PureComponent {
render() {
const {
show,
appliedFilter,
closeHandler,
loading,
funnel
@ -52,7 +52,7 @@ export default class FunnelSaveModal extends React.PureComponent {
role="button"
tabIndex="-1"
color="gray-dark"
size="18"
size="14"
name="close"
onClick={ closeHandler }
/>
@ -72,7 +72,7 @@ export default class FunnelSaveModal extends React.PureComponent {
/>
</Form.Field>
<Form.Field>
<Form.Field>
<div className="flex items-center">
<Checkbox
name="isPublic"
@ -84,9 +84,9 @@ export default class FunnelSaveModal extends React.PureComponent {
/>
<div className="flex items-center cursor-pointer" onClick={ () => this.props.edit({ 'isPublic' : !funnel.isPublic }) }>
<Icon name="user-friends" size="16" />
<span className="ml-2"> Team Funnel</span>
<span className="ml-2"> Team Visible</span>
</div>
</div>
</div>
</Form.Field>
</Form>
</Modal.Content>

View file

@ -9,6 +9,7 @@ import { init } from 'Duck/site';
import styles from './siteDropdown.css';
import cn from 'classnames';
import NewSiteForm from '../Client/Sites/NewSiteForm';
import { clearSearch } from 'Duck/search';
@withRouter
@connect(state => ({
@ -18,7 +19,8 @@ import NewSiteForm from '../Client/Sites/NewSiteForm';
}), {
setSiteId,
pushNewSite,
init
init,
clearSearch,
})
export default class SiteDropdown extends React.PureComponent {
state = { showProductModal: false }
@ -32,6 +34,11 @@ export default class SiteDropdown extends React.PureComponent {
this.setState({showProductModal: true})
}
switchSite = (siteId) => {
this.props.setSiteId(siteId);
this.props.clearSearch();
}
render() {
const { sites, siteId, account, location: { pathname } } = this.props;
const { showProductModal } = this.state;
@ -54,7 +61,7 @@ export default class SiteDropdown extends React.PureComponent {
{ !showCurrent && <li>{ 'Does not require domain selection.' }</li>}
{
sites.map(site => (
<li key={ site.id } onClick={ () => this.props.setSiteId(site.id) }>
<li key={ site.id } onClick={() => this.switchSite(site.id)}>
<Icon
name="circle"
size="8"

View file

@ -7,9 +7,11 @@ import {
connectPlayer,
init as initPlayer,
clean as cleanPlayer,
Controls,
} from 'Player';
import cn from 'classnames'
import RightBlock from './RightBlock'
import withLocationHandlers from "HOCs/withLocationHandlers";
import PlayerBlockHeader from '../Session_/PlayerBlockHeader';
@ -35,9 +37,17 @@ function PlayerContent({ live, fullscreen, showEvents }) {
)
}
function WebPlayer ({ session, toggleFullscreen, closeBottomBlock, live, fullscreen, jwt, config }) {
function WebPlayer (props) {
const { session, toggleFullscreen, closeBottomBlock, live, fullscreen, jwt, config } = props;
useEffect(() => {
initPlayer(session, jwt, config);
const jumptTime = props.query.get('jumpto');
if (jumptTime) {
Controls.jump(parseInt(jumptTime));
}
return () => cleanPlayer()
}, [ session.sessionId ]);
@ -56,7 +66,6 @@ function WebPlayer ({ session, toggleFullscreen, closeBottomBlock, live, fullscr
);
}
export default connect(state => ({
session: state.getIn([ 'sessions', 'current' ]),
jwt: state.get('jwt'),
@ -65,5 +74,4 @@ export default connect(state => ({
}), {
toggleFullscreen,
closeBottomBlock,
})(WebPlayer)
})(withLocationHandlers()(WebPlayer));

View file

@ -2,6 +2,7 @@ import React, {useEffect} from 'react';
import { connectPlayer, markTargets } from 'Player';
import { getStatusText } from 'Player/MessageDistributor/managers/AssistManager';
import type { MarkedTarget } from 'Player/MessageDistributor/StatedScreen/StatedScreen';
import { ConnectionStatus } from 'Player/MessageDistributor/managers/AssistManager';
import AutoplayTimer from './Overlay/AutoplayTimer';
import PlayIconLayer from './Overlay/PlayIconLayer';
@ -17,6 +18,7 @@ interface Props {
loading: boolean,
live: boolean,
liveStatusText: string,
concetionStatus: ConnectionStatus,
autoplay: boolean,
markedTargets: MarkedTarget[] | null,
activeTargetIndex: number,
@ -33,6 +35,7 @@ function Overlay({
loading,
live,
liveStatusText,
concetionStatus,
autoplay,
markedTargets,
activeTargetIndex,
@ -53,7 +56,7 @@ function Overlay({
<>
{ showAutoplayTimer && <AutoplayTimer /> }
{ showLiveStatusText &&
<LiveStatusText text={liveStatusText} />
<LiveStatusText text={liveStatusText} concetionStatus={concetionStatus} />
}
{ messagesLoading && <Loader/> }
{ showPlayIconLayer &&
@ -74,6 +77,7 @@ export default connectPlayer(state => ({
autoplay: state.autoplay,
live: state.live,
liveStatusText: getStatusText(state.peerConnectionStatus),
concetionStatus: state.peerConnectionStatus,
markedTargets: state.markedTargets,
activeTargetIndex: state.activeTargetIndex,
}))(Overlay);

View file

@ -1,11 +1,64 @@
import React from 'react';
import stl from './LiveStatusText.css';
import ovStl from './overlay.css';
import { ConnectionStatus } from 'Player/MessageDistributor/managers/AssistManager';
import { Loader } from 'UI';
interface Props {
text: string;
concetionStatus: ConnectionStatus;
}
export default function LiveStatusText({ text }: Props) {
return <div className={ovStl.overlay}><div className={stl.text}>{text}</div></div>
export default function LiveStatusText({ text, concetionStatus }: Props) {
const renderView = () => {
switch (concetionStatus) {
case ConnectionStatus.Connecting:
return (
<div className="flex flex-col items-center">
<Loader loading={true} size="small" />
<div className="text-lg -mt-8">Connecting...</div>
<div className="text-sm">Establishing a connection with the remote session.</div>
</div>
)
case ConnectionStatus.WaitingMessages:
return (
<div className="flex flex-col items-center">
<Loader loading={true} size="small" />
<div className="text-lg -mt-8">Waiting for the session to become active...</div>
<div className="text-sm">If it's taking too much time, it could mean the user is simply inactive.</div>
</div>
)
case ConnectionStatus.Connected:
return (
<div className="flex flex-col items-center">
<div className="text-lg -mt-8">Connected</div>
</div>
)
case ConnectionStatus.Inactive:
return (
<div className="flex flex-col items-center">
<Loader loading={true} size="small" />
<div className="text-lg -mt-8">Waiting for the session to become active...</div>
<div className="text-sm">If it's taking too much time, it could mean the user is simply inactive.</div>
</div>
)
case ConnectionStatus.Disconnected:
return (
<div className="flex flex-col items-center">
<div className="text-lg -mt-8">Disconnected</div>
<div className="text-sm">The connection was lost with the remote session. The user may have simply closed the tab/browser.</div>
</div>
)
case ConnectionStatus.Error:
return (
<div className="flex flex-col items-center">
<div className="text-lg -mt-8">Error</div>
<div className="text-sm">Something wrong just happened. Try refreshing the page.</div>
</div>
)
}
}
return <div className={ovStl.overlay}>
{ renderView()}
</div>
}

View file

@ -2,7 +2,7 @@ import { connect } from 'react-redux';
import { withRouter } from 'react-router-dom';
import { browserIcon, osIcon, deviceTypeIcon } from 'App/iconNames';
import { formatTimeOrDate } from 'App/date';
import { sessions as sessionsRoute, funnel as funnelRoute, funnelIssue as funnelIssueRoute, withSiteId } from 'App/routes';
import { sessions as sessionsRoute, withSiteId } from 'App/routes';
import { Icon, CountryFlag, IconButton, BackLink } from 'UI';
import { toggleFavorite, setSessionPath } from 'Duck/sessions';
import cn from 'classnames';
@ -41,7 +41,6 @@ function capitalise(str) {
local: state.getIn(['sessions', 'timezone']),
funnelRef: state.getIn(['funnels', 'navRef']),
siteId: state.getIn([ 'user', 'siteId' ]),
funnelPage: state.getIn(['sessions', 'funnelPage']),
hasSessionsPath: hasSessioPath && !isAssist,
}
}, {
@ -61,22 +60,12 @@ export default class PlayerBlockHeader extends React.PureComponent {
);
backHandler = () => {
const { history, siteId, funnelPage, sessionPath } = this.props;
// alert(sessionPath)
if (sessionPath === history.location.pathname) {
const { history, siteId, sessionPath } = this.props;
if (sessionPath === history.location.pathname || sessionPath.includes("/session/")) {
history.push(withSiteId(SESSIONS_ROUTE), siteId);
} else {
history.push(sessionPath ? sessionPath : withSiteId(SESSIONS_ROUTE, siteId));
}
// const funnelId = funnelPage && funnelPage.get('funnelId');
// const issueId = funnelPage && funnelPage.get('issueId');
// if (funnelId || issueId) {
// if (issueId) {
// history.push(withSiteId(funnelIssueRoute(funnelId, issueId), siteId))
// } else
// history.push(withSiteId(funnelRoute(funnelId), siteId));
// } else
// history.push(withSiteId(SESSIONS_ROUTE), siteId);
}
toggleFavorite = () => {
@ -106,9 +95,9 @@ export default class PlayerBlockHeader extends React.PureComponent {
disabled,
jiraConfig,
fullscreen,
hasSessionsPath
hasSessionsPath,
sessionPath,
} = this.props;
// const { history, siteId } = this.props;
const _live = live && !hasSessionsPath;
return (
@ -145,6 +134,7 @@ export default class PlayerBlockHeader extends React.PureComponent {
<IconButton
className="mr-2"
tooltip="Bookmark"
tooltipPosition="top right"
onClick={ this.toggleFavorite }
loading={ loading }
icon={ favorite ? 'star-solid' : 'star' }
@ -153,12 +143,14 @@ export default class PlayerBlockHeader extends React.PureComponent {
<SharePopup
entity="sessions"
id={ sessionId }
showCopyLink={true}
trigger={
<IconButton
className="mr-2"
tooltip="Share Session"
tooltipPosition="top right"
disabled={ disabled }
icon={ 'share-alt' }
icon={ 'share-alt' }
plain
/>
}

View file

@ -1,40 +1,73 @@
import { connect } from 'react-redux';
import cn from 'classnames';
import withToggle from 'HOCs/withToggle';
import { IconButton, SlideModal, NoContent } from 'UI';
import { IconButton, Popup } from 'UI';
import { updateAppearance } from 'Duck/user';
import { WIDGET_LIST } from 'Types/dashboard';
import stl from './addWidgets.css';
import OutsideClickDetectingDiv from 'Shared/OutsideClickDetectingDiv';
import { updateActiveState } from 'Duck/customMetrics';
const CUSTOM_METRICS = 'custom_metrics';
@connect(state => ({
appearance: state.getIn([ 'user', 'account', 'appearance' ]),
customMetrics: state.getIn(['customMetrics', 'list']),
}), {
updateAppearance,
updateAppearance, updateActiveState,
})
@withToggle()
export default class AddWidgets extends React.PureComponent {
makeAddHandler = widgetKey => () => {
const { appearance } = this.props;
const newAppearance = appearance.setIn([ 'dashboard', widgetKey ], true);
if (this.props.type === CUSTOM_METRICS) {
this.props.updateActiveState(widgetKey, true);
} else {
const { appearance } = this.props;
const newAppearance = appearance.setIn([ 'dashboard', widgetKey ], true);
this.props.updateAppearance(newAppearance)
}
this.props.switchOpen(false);
this.props.updateAppearance(newAppearance)
}
getCustomMetricWidgets = () => {
return this.props.customMetrics.filter(i => !i.active).map(item => ({
type: CUSTOM_METRICS,
key: item.metricId,
name: item.name,
})).toJS();
}
render() {
const { appearance, disabled } = this.props;
const avaliableWidgets = WIDGET_LIST.filter(({ key, type }) => !appearance.dashboard[ key ] && type === this.props.type );
const { disabled, widgets, type } = this.props;
const filteredWidgets = type === CUSTOM_METRICS ? this.getCustomMetricWidgets() : widgets;
return (
<div className="relative">
<Popup
trigger={
<IconButton
circle
size="small"
icon="plus"
outline
onClick={ this.props.switchOpen }
disabled={ filteredWidgets.length === 0 }
/>
}
content={ `Add a metric to this section.` }
size="tiny"
inverted
position="top center"
/>
<OutsideClickDetectingDiv onClickOutside={() => this.props.switchOpen(false)}>
{this.props.open &&
<div
className={cn(stl.menuWrapper, 'absolute border rounded z-10 bg-white w-auto')}
style={{ minWidth: '200px', top: '30px'}}
>
{avaliableWidgets.map(w => (
{filteredWidgets.map(w => (
<div
key={w.key}
className={cn(stl.menuItem, 'whitespace-pre cursor-pointer')}
onClick={this.makeAddHandler(w.key)}
>
@ -44,46 +77,6 @@ export default class AddWidgets extends React.PureComponent {
</div>
}
</OutsideClickDetectingDiv>
<SlideModal
title="Add Widget"
size="middle"
isDisplayed={ false }
content={ this.props.open &&
<NoContent
title="No Widgets Left"
size="small"
show={ avaliableWidgets.length === 0}
>
{ avaliableWidgets.map(({ key, name, description, thumb }) => (
<div className={ cn(stl.widgetCard) } key={ key } >
<div className="flex justify-between items-center flex-1">
<div className="mr-10">
<h4>{ name }</h4>
</div>
<IconButton
className="flex-shrink-0"
onClick={ this.makeAddHandler(key) }
circle
outline
icon="plus"
style={{ width: '24px', height: '24px'}}
/>
</div>
</div>
))}
</NoContent>
}
onClose={ this.props.switchOpen }
/>
<IconButton
circle
size="small"
icon="plus"
outline
onClick={ this.props.switchOpen }
disabled={ disabled || avaliableWidgets.length === 0 } //TODO disabled after Custom fields filtering
/>
</div>
);
}

View file

@ -0,0 +1,143 @@
import React from 'react';
import { Form, SegmentSelection, Button, IconButton } from 'UI';
import FilterSeries from '../FilterSeries';
import { connect } from 'react-redux';
import { edit as editMetric, save, addSeries, removeSeries, remove } from 'Duck/customMetrics';
import CustomMetricWidgetPreview from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricWidgetPreview';
import { confirm } from 'UI/Confirmation';
import { toast } from 'react-toastify';
import cn from 'classnames';
interface Props {
metric: any;
editMetric: (metric, shouldFetch?) => void;
save: (metric) => Promise<void>;
loading: boolean;
addSeries: (series?) => void;
onClose: () => void;
remove: (id) => Promise<void>;
removeSeries: (seriesIndex) => void;
}
function CustomMetricForm(props: Props) {
const { metric, loading } = props;
const addSeries = () => {
props.addSeries();
}
const removeSeries = (index) => {
props.removeSeries(index);
}
const write = ({ target: { value, name } }) => props.editMetric({ ...metric, [ name ]: value }, false);
const changeConditionTab = (e, { name, value }) => {
props.editMetric({[ 'viewType' ]: value });
};
const save = () => {
props.save(metric).then(() => {
toast.success(metric.exists() ? 'Updated succesfully.' : 'Created succesfully.');
props.onClose()
});
}
const deleteHandler = async () => {
if (await confirm({
header: 'Custom Metric',
confirmButton: 'Delete',
confirmation: `Are you sure you want to delete ${metric.name}`
})) {
props.remove(metric.metricId).then(() => {
toast.success('Deleted succesfully.');
props.onClose();
});
}
}
return (
<Form
className="relative"
onSubmit={save}
>
<div className="p-5 pb-20" style={{ height: 'calc(100vh - 60px)', overflowY: 'auto' }}>
<div className="form-group">
<label className="font-medium">Metric Title</label>
<input
autoFocus={ true }
className="text-lg"
name="name"
style={{ fontSize: '18px', padding: '10px', fontWeight: '600'}}
value={ metric.name }
onChange={ write }
placeholder="Metric Title"
id="name-field"
/>
</div>
<div className="form-group">
<label className="font-medium">Metric Type</label>
<div className="flex items-center">
<span className="bg-white p-1 px-2 border rounded" style={{ height: '30px'}}>Timeseries</span>
<span className="mx-2 color-gray-medium">of</span>
<div>
<SegmentSelection
primary
name="viewType"
small={true}
// className="my-3"
onSelect={ changeConditionTab }
value={{ value: metric.viewType }}
list={ [
{ name: 'Session Count', value: 'lineChart' },
{ name: 'Session Percentage', value: 'progress', disabled: true },
]}
/>
</div>
</div>
</div>
<div className="form-group">
<label className="font-medium">Chart Series</label>
{metric.series && metric.series.size > 0 && metric.series.map((series: any, index: number) => (
<div className="mb-2">
<FilterSeries
seriesIndex={index}
series={series}
onRemoveSeries={() => removeSeries(index)}
canDelete={metric.series.size > 1}
/>
</div>
))}
</div>
<div className={cn("flex justify-end -my-4", {'disabled' : metric.series.size > 2})}>
<IconButton hover type="button" onClick={addSeries} primaryText label="SERIES" icon="plus" />
</div>
<div className="my-8" />
<CustomMetricWidgetPreview metric={metric} />
</div>
<div className="flex items-center fixed border-t w-full bottom-0 px-5 py-2 bg-white">
<div className="mr-auto">
<Button loading={loading} primary disabled={!metric.validate()}>
{ `${metric.exists() ? 'Update' : 'Create'}` }
</Button>
<Button type="button" className="ml-3" outline hover plain onClick={props.onClose}>Cancel</Button>
</div>
<div>
{ metric.exists() && <Button type="button" className="ml-3" outline hover plain onClick={deleteHandler}>Delete</Button> }
</div>
</div>
</Form>
);
}
export default connect(state => ({
metric: state.getIn(['customMetrics', 'instance']),
loading: state.getIn(['customMetrics', 'saveRequest', 'loading']),
}), { editMetric, save, addSeries, remove, removeSeries })(CustomMetricForm);

View file

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

View file

@ -0,0 +1,17 @@
import React from 'react';
import { IconButton } from 'UI';
import { connect } from 'react-redux';
import { edit, init } from 'Duck/customMetrics';
interface Props {
init: (instance?, setDefault?) => void;
}
function CustomMetrics(props: Props) {
return (
<div className="self-start">
<IconButton plain outline icon="plus" label="CREATE METRIC" onClick={() => props.init()} />
</div>
);
}
export default connect(null, { edit, init })(CustomMetrics);

View file

@ -0,0 +1,38 @@
import React from 'react'
import { IconButton, SlideModal } from 'UI';
import CustomMetricForm from '../CustomMetricForm';
import { connect } from 'react-redux'
import { init } from 'Duck/customMetrics';
interface Props {
metric: any;
init: (instance?, setDefault?) => void;
}
function CustomMetricsModal(props: Props) {
const { metric } = props;
return (
<>
<SlideModal
title={
<div className="flex items-center">
<span className="mr-3">{ metric && metric.exists() ? 'Update Custom Metric' : 'Create Custom Metric' }</span>
</div>
}
isDisplayed={ !!metric }
onClose={ () => props.init(null, true)}
content={ (!!metric) && (
<div style={{ backgroundColor: '#f6f6f6' }}>
<CustomMetricForm metric={metric} onClose={() => props.init(null, true)} />
</div>
)}
/>
</>
)
}
export default connect(state => ({
metric: state.getIn(['customMetrics', 'instance']),
alertInstance: state.getIn(['alerts', 'instance']),
showModal: state.getIn(['customMetrics', 'showModal']),
}), { init })(CustomMetricsModal);

View file

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

View file

@ -0,0 +1,105 @@
import React, { useState } from 'react';
import FilterList from 'Shared/Filters/FilterList';
import {
edit,
updateSeries,
addSeriesFilterFilter,
removeSeriesFilterFilter,
editSeriesFilterFilter,
editSeriesFilter,
} from 'Duck/customMetrics';
import { connect } from 'react-redux';
import { IconButton, Icon } from 'UI';
import FilterSelection from '../../Filters/FilterSelection';
import SeriesName from './SeriesName';
import cn from 'classnames';
interface Props {
seriesIndex: number;
series: any;
edit: typeof edit;
updateSeries: typeof updateSeries;
onRemoveSeries: (seriesIndex) => void;
canDelete?: boolean;
addSeriesFilterFilter: typeof addSeriesFilterFilter;
editSeriesFilterFilter: typeof editSeriesFilterFilter;
editSeriesFilter: typeof editSeriesFilter;
removeSeriesFilterFilter: typeof removeSeriesFilterFilter;
}
function FilterSeries(props: Props) {
const { canDelete } = props;
const [expanded, setExpanded] = useState(true)
const { series, seriesIndex } = props;
const onAddFilter = (filter) => {
filter.value = [""]
props.addSeriesFilterFilter(seriesIndex, filter);
}
const onUpdateFilter = (filterIndex, filter) => {
props.editSeriesFilterFilter(seriesIndex, filterIndex, filter);
}
const onChangeEventsOrder = (e, { name, value }) => {
props.editSeriesFilter(seriesIndex, { eventsOrder: value });
}
const onRemoveFilter = (filterIndex) => {
props.removeSeriesFilterFilter(seriesIndex, filterIndex);
}
return (
<div className="border rounded bg-white">
<div className="border-b px-5 h-12 flex items-center relative">
<div className="mr-auto">
<SeriesName name={series.name} onUpdate={(name) => props.updateSeries(seriesIndex, { name }) } />
</div>
<div className="flex items-center cursor-pointer" >
<div onClick={props.onRemoveSeries} className={cn("ml-3", {'disabled': !canDelete})}>
<Icon name="trash" size="16" />
</div>
<div onClick={() => setExpanded(!expanded)} className="ml-3">
<Icon name="chevron-down" size="16" />
</div>
</div>
</div>
{ expanded && (
<>
<div className="p-5">
{ series.filter.filters.size > 0 ? (
<FilterList
filter={series.filter}
onUpdateFilter={onUpdateFilter}
onRemoveFilter={onRemoveFilter}
onChangeEventsOrder={onChangeEventsOrder}
/>
): (
<div className="color-gray-medium">Add user event or filter to define the series by clicking Add Step.</div>
)}
</div>
<div className="px-5 border-t h-12 flex items-center">
<FilterSelection
filter={undefined}
onFilterClick={onAddFilter}
>
<IconButton primaryText label="ADD STEP" icon="plus" />
</FilterSelection>
</div>
</>
)}
</div>
);
}
export default connect(null, {
edit,
updateSeries,
addSeriesFilterFilter,
editSeriesFilterFilter,
editSeriesFilter,
removeSeriesFilterFilter,
})(FilterSeries);

View file

@ -0,0 +1,55 @@
import React, { useState, useRef, useEffect } from 'react';
import { Icon } from 'UI';
interface Props {
name: string;
onUpdate: (name) => void;
}
function SeriesName(props: Props) {
const [editing, setEditing] = useState(false)
const [name, setName] = useState(props.name)
const ref = useRef<any>(null)
const write = ({ target: { value, name } }) => {
setName(value)
}
const onBlur = () => {
setEditing(false)
props.onUpdate(name)
}
useEffect(() => {
if (editing) {
ref.current.focus()
}
}, [editing])
useEffect(() => {
setName(props.name)
}, [props.name])
// const { name } = props;
return (
<div className="flex items-center">
{ editing ? (
<input
ref={ ref }
name="name"
className="fluid border-0 -mx-2 px-2"
value={name}
// readOnly={!editing}
onChange={write}
onBlur={onBlur}
onFocus={() => setEditing(true)}
/>
) : (
<div className="text-base">{name}</div>
)}
<div className="ml-3 cursor-pointer" onClick={() => setEditing(true)}><Icon name="pencil" size="14" /></div>
</div>
);
}
export default SeriesName;

View file

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

View file

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

View file

@ -0,0 +1,29 @@
.wrapper {
padding: 20px;
background-color: #f6f6f6;
min-height: calc(100vh - 59px);
}
.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,117 @@
import React, { useEffect, useState } from 'react';
import { SlideModal, NoContent, Dropdown, Icon, TimezoneDropdown, Loader } from 'UI';
import SessionItem from 'Shared/SessionItem';
import stl from './SessionListModal.css';
import { connect } from 'react-redux';
import { fetchSessionList, setActiveWidget } from 'Duck/customMetrics';
import { DateTime } from 'luxon';
interface Props {
loading: boolean;
list: any;
fetchSessionList: (params) => void;
activeWidget: any;
setActiveWidget: (widget) => void;
}
function SessionListModal(props: Props) {
const { activeWidget, loading, list } = props;
const [seriesOptions, setSeriesOptions] = useState([
{ text: 'All', value: 'all' },
]);
const [activeSeries, setActiveSeries] = useState('all');
useEffect(() => {
if (!activeWidget || !activeWidget.widget) return;
props.fetchSessionList({
metricId: activeWidget.widget.metricId,
startDate: activeWidget.startTimestamp,
endDate: activeWidget.endTimestamp
});
}, [activeWidget]);
useEffect(() => {
if (!list) return;
const seriesOptions = list.map(item => ({
text: item.seriesName,
value: item.seriesId,
}));
setSeriesOptions([
{ text: 'All', value: 'all' },
...seriesOptions,
]);
}, [list]);
const getListSessionsBySeries = (seriesId) => {
const arr: any = []
list.forEach(element => {
if (seriesId === 'all') {
const sessionIds = arr.map(i => i.sessionId);
arr.push(...element.sessions.filter(i => !sessionIds.includes(i.sessionId)));
} else {
if (element.seriesId === seriesId) {
arr.push(...element.sessions)
}
}
});
return arr;
}
const writeOption = (e, { name, value }) => setActiveSeries(value);
const filteredSessions = getListSessionsBySeries(activeSeries);
const startTime = DateTime.fromMillis(activeWidget.startTimestamp).toFormat('LLL dd, yyyy HH:mm a');
const endTime = DateTime.fromMillis(activeWidget.endTimestamp).toFormat('LLL dd, yyyy HH:mm a');
return (
<SlideModal
title={ activeWidget && (
<div className="flex items-center">
<div className="mr-auto">{ activeWidget.widget.name } </div>
</div>
)}
isDisplayed={ !!activeWidget }
onClose={ () => props.setActiveWidget(null)}
content={ activeWidget && (
<div className={ stl.wrapper }>
<div className="mb-6 flex items-center">
<div className="mr-auto">Showing all sessions between <span className="font-medium">{startTime}</span> and <span className="font-medium">{endTime}</span> </div>
<div className="flex items-center ml-6">
<div className="flex items-center">
<span className="mr-2 color-gray-medium">Timezone</span>
<TimezoneDropdown />
</div>
<div className="flex items-center ml-6">
<span className="mr-2 color-gray-medium">Series</span>
<Dropdown
className={stl.dropdown}
direction="left"
options={ seriesOptions }
name="change"
value={ activeSeries }
onChange={ writeOption }
id="change-dropdown"
// icon={null}
icon={ <Icon name="chevron-down" color="gray-dark" size="14" className={stl.dropdownIcon} /> }
/>
</div>
{/* <span className="mr-2 color-gray-medium">Series</span> */}
</div>
</div>
<Loader loading={loading}>
<NoContent
show={ !loading && (filteredSessions.length === 0 )}
title="No recordings found!"
icon="exclamation-circle"
>
{ filteredSessions.map(session => <SessionItem key={ session.sessionId } session={ session } />) }
</NoContent>
</Loader>
</div>
)}
/>
);
}
export default connect(state => ({
loading: state.getIn(['customMetrics', 'fetchSessionList', 'loading']),
list: state.getIn(['customMetrics', 'sessionList']),
// activeWidget: state.getIn(['customMetrics', 'activeWidget']),
}), { fetchSessionList, setActiveWidget })(SessionListModal);

View file

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

View file

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

View file

@ -2,7 +2,7 @@ import { connect } from 'react-redux';
import DateRangeDropdown from 'Shared/DateRangeDropdown';
function DateRange (props) {
const { startDate, endDate, rangeValue, className, onDateChange, customRangeRight=false, customHidden = false } = props;
const { direction = "left", startDate, endDate, rangeValue, className, onDateChange, customRangeRight=false, customHidden = false } = props;
return (
<DateRangeDropdown
@ -14,6 +14,7 @@ function DateRange (props) {
className={ className }
customRangeRight={customRangeRight}
customHidden={customHidden}
direction={direction}
/>
);
}

View file

@ -88,7 +88,7 @@ export default class DateRangeDropdown extends React.PureComponent {
<Icon name="chevron-down" color="gray-dark" size="14" className={styles.dropdownIcon} />
</div> : null
}
selection={!button}
// selection={!button}
name="sessionDateRange"
direction={ direction }
className={ button ? "" : "customDropdown" }
@ -97,8 +97,9 @@ export default class DateRangeDropdown extends React.PureComponent {
icon={ null }
>
<Dropdown.Menu>
{ options.map(props =>
{ options.map((props, i) =>
<Dropdown.Item
key={i}
{...props}
onClick={this.onItemClick}
active={props.value === value }

View file

@ -81,7 +81,7 @@ class AttributeItem extends React.PureComponent {
<div className={ stl.actions }>
<button className={ stl.button } onClick={ this.removeFilter }>
<Icon name="close" size="16" />
<Icon name="close" size="14" />
</button>
</div>
</div>

View file

@ -137,12 +137,13 @@ class AttributeValueField extends React.PureComponent {
const { filter, onChange } = this.props;
const _showAutoComplete = this.isAutoComplete(filter.type);
const _params = _showAutoComplete ? this.getParams(filter) : {};
let _optionsEndpoint= '/events/search';
let _optionsEndpoint= '/events/search';
console.log('value', filter.value)
return (
<React.Fragment>
{ _showAutoComplete ?
<AutoComplete
<AutoComplete
name={ 'value' }
endpoint={ _optionsEndpoint }
value={ filter.value }
@ -151,6 +152,7 @@ class AttributeValueField extends React.PureComponent {
onSelect={ onChange }
headerText={ <h5 className={ stl.header }>{ getHeader(filter.type) }</h5> }
fullWidth={ (filter.type === TYPES.CONSOLE || filter.type === TYPES.LOCATION || filter.type === TYPES.CUSTOM) && filter.value }
// onAddOrRemove={}
/>
: this.renderField()
}

View file

@ -77,7 +77,7 @@ class AutoComplete extends React.PureComponent {
noResultsMessage: SOME_ERROR_MSG,
})
onInputChange = (e, { name, value }) => {
onInputChange = ({ target: { value } }) => {
changed = true;
this.setState({ query: value, updated: true })
const _value = value.trim();
@ -118,7 +118,8 @@ class AutoComplete extends React.PureComponent {
valueToText = defaultValueToText,
placeholder = 'Type to search...',
headerText = '',
fullWidth = false
fullWidth = false,
onAddOrRemove = () => null,
} = this.props;
const options = optionMapping(values, valueToText)
@ -128,7 +129,7 @@ class AutoComplete extends React.PureComponent {
className={ cn("relative", { "flex-1" : fullWidth }) }
onClickOutside={this.onClickOutside}
>
<Input
{/* <Input
className={ cn(stl.searchInput, { [ stl.fullWidth] : fullWidth }) }
onChange={ this.onInputChange }
onBlur={ this.onBlur }
@ -144,7 +145,30 @@ class AutoComplete extends React.PureComponent {
this.hiddenInput.value = text;
pasted = true; // to use only the hidden input
} }
/>
/> */}
<div className={stl.inputWrapper}>
<input
name="query"
// className={cn(stl.input)}
onFocus={ () => this.setState({ddOpen: true})}
onChange={ this.onInputChange }
onBlur={ this.onBlur }
onFocus={ () => this.setState({ddOpen: true})}
value={ query }
autoFocus={ true }
type="text"
placeholder={ placeholder }
onPaste={(e) => {
const text = e.clipboardData.getData('Text');
this.hiddenInput.value = text;
pasted = true; // to use only the hidden input
} }
/>
<div className={cn(stl.right, 'cursor-pointer')} onLick={onAddOrRemove}>
{/* <Icon name="close" size="18" /> */}
<span className="px-1">or</span>
</div>
</div>
<textarea style={hiddenStyle} ref={(ref) => this.hiddenInput = ref }></textarea>
{ ddOpen && options.length > 0 &&
<div className={ stl.menu }>

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