commit
97df0b3be7
308 changed files with 15442 additions and 36138 deletions
51
.github/workflows/frontend-ee.yaml
vendored
51
.github/workflows/frontend-ee.yaml
vendored
|
|
@ -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 }}
|
||||
6
.github/workflows/frontend.yaml
vendored
6
.github/workflows/frontend.yaml
vendored
|
|
@ -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: |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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='')}"}
|
||||
|
|
|
|||
110
api/schemas.py
110
api/schemas.py
|
|
@ -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([])
|
||||
|
|
|
|||
12
backend/pkg/db/cache/messages_common.go
vendored
12
backend/pkg/db/cache/messages_common.go
vendored
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
48
backend/pkg/db/cache/messages_web.go
vendored
48
backend/pkg/db/cache/messages_web.go
vendored
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// }
|
||||
// }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3
backend/pkg/env/aws.go
vendored
3
backend/pkg/env/aws.go
vendored
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
4
ee/scripts/helm/db/init_dbs/clickhouse/1.5.0/1.5.0.sql
Normal file
4
ee/scripts/helm/db/init_dbs/clickhouse/1.5.0/1.5.0.sql
Normal 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);
|
||||
173
ee/scripts/helm/db/init_dbs/postgresql/1.5.0/1.5.0.sql
Normal file
173
ee/scripts/helm/db/init_dbs/postgresql/1.5.0/1.5.0.sql
Normal 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
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
101
frontend/app/components/Alerts/AlertFormModal/AlertFormModal.tsx
Normal file
101
frontend/app/components/Alerts/AlertFormModal/AlertFormModal.tsx
Normal 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)
|
||||
1
frontend/app/components/Alerts/AlertFormModal/index.ts
Normal file
1
frontend/app/components/Alerts/AlertFormModal/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from './AlertFormModal';
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
.wrapper {
|
||||
height: 100vh;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 don’t 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))
|
||||
|
|
|
|||
|
|
@ -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> }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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' &&
|
||||
|
|
|
|||
|
|
@ -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 }>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 } />
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
.wrapper {
|
||||
background-color: white;
|
||||
/* border: solid thin $gray-medium; */
|
||||
border-radius: 3px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
|
@ -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
|
||||
/>
|
||||
)
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './CustomMetricWidget';
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
.wrapper {
|
||||
background-color: white;
|
||||
/* border: solid thin $gray-medium; */
|
||||
border-radius: 3px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './CustomMetricWidgetPreview';
|
||||
|
|
@ -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);
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './CustomMetricsWidgets';
|
||||
|
|
@ -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]}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
.wrapper {
|
||||
background-color: white;
|
||||
/* border: solid thin $gray-medium; */
|
||||
border-radius: 3px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './CustomMetricWidgetHoc';
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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
|
||||
/>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './CustomMetricForm';
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './CustomMetricsModal';
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './SeriesName';
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './FilterSeries'
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './SessionListModal';
|
||||
1
frontend/app/components/shared/CustomMetrics/index.ts
Normal file
1
frontend/app/components/shared/CustomMetrics/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from './CustomMetrics';
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue