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:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
|
|
@ -27,8 +27,8 @@ jobs:
|
||||||
method: kubeconfig
|
method: kubeconfig
|
||||||
kubeconfig: ${{ secrets.OSS_KUBECONFIG }} # Use content of kubeconfig in secret.
|
kubeconfig: ${{ secrets.OSS_KUBECONFIG }} # Use content of kubeconfig in secret.
|
||||||
id: setcontext
|
id: setcontext
|
||||||
- name: Install
|
# - name: Install
|
||||||
run: npm install
|
# run: npm install
|
||||||
|
|
||||||
- name: Build and deploy
|
- name: Build and deploy
|
||||||
run: |
|
run: |
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,8 @@ jwt_algorithm=HS512
|
||||||
jwt_exp_delta_seconds=2592000
|
jwt_exp_delta_seconds=2592000
|
||||||
jwt_issuer=openreplay-default-foss
|
jwt_issuer=openreplay-default-foss
|
||||||
jwt_secret="SET A RANDOM STRING HERE"
|
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_dbname=postgres
|
||||||
pg_host=postgresql.db.svc.cluster.local
|
pg_host=postgresql.db.svc.cluster.local
|
||||||
pg_password=asayerPostgres
|
pg_password=asayerPostgres
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ def get(id):
|
||||||
{"id": id})
|
{"id": id})
|
||||||
)
|
)
|
||||||
a = helper.dict_to_camel_case(cur.fetchone())
|
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):
|
def get_all(project_id):
|
||||||
|
|
@ -31,8 +31,8 @@ def get_all(project_id):
|
||||||
{"project_id": project_id})
|
{"project_id": project_id})
|
||||||
cur.execute(query=query)
|
cur.execute(query=query)
|
||||||
all = helper.list_to_camel_case(cur.fetchall())
|
all = helper.list_to_camel_case(cur.fetchall())
|
||||||
for a in all:
|
for i in range(len(all)):
|
||||||
a = __process_circular(a)
|
all[i] = helper.custom_alert_to_front(__process_circular(all[i]))
|
||||||
return all
|
return all
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -58,7 +58,7 @@ def create(project_id, data: schemas.AlertSchema):
|
||||||
{"project_id": project_id, **data})
|
{"project_id": project_id, **data})
|
||||||
)
|
)
|
||||||
a = helper.dict_to_camel_case(cur.fetchone())
|
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):
|
def update(id, data: schemas.AlertSchema):
|
||||||
|
|
@ -81,7 +81,7 @@ def update(id, data: schemas.AlertSchema):
|
||||||
{"id": id, **data})
|
{"id": id, **data})
|
||||||
cur.execute(query=query)
|
cur.execute(query=query)
|
||||||
a = helper.dict_to_camel_case(cur.fetchone())
|
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):
|
def process_notifications(data):
|
||||||
|
|
@ -166,5 +166,5 @@ def get_predefined_values():
|
||||||
"unit": "count" if v.endswith(".count") else "ms",
|
"unit": "count" if v.endswith(".count") else "ms",
|
||||||
"predefined": True,
|
"predefined": True,
|
||||||
"metricId": None,
|
"metricId": None,
|
||||||
"seriesId": None} for v in values]
|
"seriesId": None} for v in values if v != schemas.AlertColumn.custom]
|
||||||
return values
|
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
|
import requests
|
||||||
from decouple import config
|
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
|
from chalicelib.utils import pg_client, helper
|
||||||
|
|
||||||
SESSION_PROJECTION_COLS = """s.project_id,
|
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)
|
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):
|
def is_live(project_id, session_id, project_key=None):
|
||||||
if project_key is None:
|
if project_key is None:
|
||||||
project_key = projects.get_project_key(project_id)
|
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:
|
if connected_peers.status_code != 200:
|
||||||
print("!! issue with the peer-server")
|
print("!! issue with the peer-server")
|
||||||
print(connected_peers.text)
|
print(connected_peers.text)
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ from chalicelib.utils.TimeUTC import TimeUTC
|
||||||
|
|
||||||
def try_live(project_id, data: schemas.TryCustomMetricsSchema):
|
def try_live(project_id, data: schemas.TryCustomMetricsSchema):
|
||||||
results = []
|
results = []
|
||||||
for s in data.series:
|
for i, s in enumerate(data.series):
|
||||||
s.filter.startDate = data.startDate
|
s.filter.startDate = data.startDate
|
||||||
s.filter.endDate = data.endDate
|
s.filter.endDate = data.endDate
|
||||||
results.append(sessions.search2_series(data=s.filter, project_id=project_id, density=data.density,
|
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,
|
r["previousCount"] = sessions.search2_series(data=s.filter, project_id=project_id, density=data.density,
|
||||||
view_type=data.viewType)
|
view_type=data.viewType)
|
||||||
r["countProgress"] = helper.__progress(old_val=r["previousCount"], new_val=r["count"])
|
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
|
results[-1] = r
|
||||||
return results
|
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):
|
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)
|
metric = get(metric_id=metric_id, project_id=project_id, user_id=user_id, flatten=False)
|
||||||
if metric is None:
|
if metric is None:
|
||||||
return None
|
return None
|
||||||
metric: schemas.TryCustomMetricsSchema = schemas.TryCustomMetricsSchema.parse_obj({**data.dict(), **metric})
|
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):
|
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:
|
if s.series_id is None:
|
||||||
n_series.append({"i": i, "s": s})
|
n_series.append({"i": i, "s": s})
|
||||||
prefix = "n_"
|
prefix = "n_"
|
||||||
|
s.index = i
|
||||||
else:
|
else:
|
||||||
u_series.append({"i": i, "s": s})
|
u_series.append({"i": i, "s": s})
|
||||||
u_series_ids.append(s.series_id)
|
u_series_ids.append(s.series_id)
|
||||||
|
|
@ -230,3 +268,16 @@ def get_series_for_alert(project_id, user_id):
|
||||||
)
|
)
|
||||||
rows = cur.fetchall()
|
rows = cur.fetchall()
|
||||||
return helper.list_to_camel_case(rows)
|
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")
|
STATEACTION = Event(ui_type=schemas.EventType.state_action, table="events.state_actions", column="name")
|
||||||
ERROR = Event(ui_type=schemas.EventType.error, table="events.errors",
|
ERROR = Event(ui_type=schemas.EventType.error, table="events.errors",
|
||||||
column=None) # column=None because errors are searched by name or message
|
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
|
# IOS
|
||||||
CLICK_IOS = Event(ui_type=schemas.EventType.click_ios, table="events_ios.clicks", column="label")
|
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")
|
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"]}
|
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)
|
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, 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),
|
return {"data": {"stages": helper.list_to_camel_case(insights),
|
||||||
"totalDropDueToIssues": total_drop_due_to_issues}}
|
"totalDropDueToIssues": total_drop_due_to_issues}}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,11 +53,11 @@ def add_edit(tenant_id, project_id, data):
|
||||||
else:
|
else:
|
||||||
return add(tenant_id=tenant_id,
|
return add(tenant_id=tenant_id,
|
||||||
project_id=project_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"])
|
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://", "")
|
host = host.replace("http://", "").replace("https://", "")
|
||||||
try:
|
try:
|
||||||
args = {
|
args = {
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,8 @@ def get_all(project_id, user_id, details=False):
|
||||||
for row in rows:
|
for row in rows:
|
||||||
row["createdAt"] = TimeUTC.datetime_to_timestamp(row["createdAt"])
|
row["createdAt"] = TimeUTC.datetime_to_timestamp(row["createdAt"])
|
||||||
if details:
|
if details:
|
||||||
|
if isinstance(row["filter"], list) and len(row["filter"]) == 0:
|
||||||
|
row["filter"] = {}
|
||||||
row["filter"] = helper.old_search_payload_to_flat(row["filter"])
|
row["filter"] = helper.old_search_payload_to_flat(row["filter"])
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ SESSION_PROJECTION_COLS = """s.project_id,
|
||||||
s.session_id::text AS session_id,
|
s.session_id::text AS session_id,
|
||||||
s.user_uuid,
|
s.user_uuid,
|
||||||
s.user_id,
|
s.user_id,
|
||||||
s.user_agent,
|
-- s.user_agent,
|
||||||
s.user_os,
|
s.user_os,
|
||||||
s.user_browser,
|
s.user_browser,
|
||||||
s.user_device,
|
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"])
|
project_key=data["projectKey"])
|
||||||
|
|
||||||
return data
|
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):
|
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"):
|
def _multiple_values(values, value_key="value"):
|
||||||
query_values = {}
|
query_values = {}
|
||||||
for i in range(len(values)):
|
if values is not None and isinstance(values, list):
|
||||||
k = f"{value_key}_{i}"
|
for i in range(len(values)):
|
||||||
query_values[k] = values[i]
|
k = f"{value_key}_{i}"
|
||||||
|
query_values[k] = values[i]
|
||||||
return query_values
|
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,
|
main_query = cur.mogrify(f"""SELECT COUNT(DISTINCT s.session_id) AS count_sessions,
|
||||||
COUNT(DISTINCT s.user_uuid) AS count_users
|
COUNT(DISTINCT s.user_uuid) AS count_users
|
||||||
{query_part};""", full_args)
|
{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:
|
else:
|
||||||
main_query = cur.mogrify(f"""SELECT COUNT(full_sessions) AS count, COALESCE(JSONB_AGG(full_sessions) FILTER (WHERE rn <= 200), '[]'::JSONB) AS sessions
|
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
|
FROM (SELECT *, ROW_NUMBER() OVER (ORDER BY favorite DESC, issue_score DESC, session_id desc, start_ts desc) AS rn
|
||||||
(SELECT DISTINCT ON(s.session_id) {SESSION_PROJECTION_COLS}
|
FROM (SELECT DISTINCT ON(s.session_id) {SESSION_PROJECTION_COLS}
|
||||||
{query_part}
|
{query_part}
|
||||||
ORDER BY s.session_id desc) AS filtred_sessions
|
ORDER BY s.session_id desc) AS filtred_sessions
|
||||||
ORDER BY favorite DESC, issue_score DESC, {sort} {data.order}) AS full_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:
|
if errors_only:
|
||||||
return sessions
|
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)],
|
sessions = sorted(sessions, key=lambda s: s[helper.key_to_snake_case(data.sort)],
|
||||||
reverse=data.order.upper() == "DESC")
|
reverse=data.order.upper() == "DESC")
|
||||||
return {
|
return {
|
||||||
|
|
@ -233,8 +249,8 @@ def search2_pg(data: schemas.SessionsSearchPayloadSchema, project_id, user_id, f
|
||||||
@dev.timed
|
@dev.timed
|
||||||
def search2_series(data: schemas.SessionsSearchPayloadSchema, project_id: int, density: int,
|
def search2_series(data: schemas.SessionsSearchPayloadSchema, project_id: int, density: int,
|
||||||
view_type: schemas.MetricViewType):
|
view_type: schemas.MetricViewType):
|
||||||
step_size = metrics_helper.__get_step_size(endTimestamp=data.endDate, startTimestamp=data.startDate,
|
step_size = int(metrics_helper.__get_step_size(endTimestamp=data.endDate, startTimestamp=data.startDate,
|
||||||
density=density, factor=1)
|
density=density, factor=1, decimal=True))
|
||||||
full_args, query_part, sort = search_query_parts(data=data, error_status=None, errors_only=False,
|
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,
|
favorite_only=False, issue=None, project_id=project_id,
|
||||||
user_id=None)
|
user_id=None)
|
||||||
|
|
@ -249,7 +265,7 @@ def search2_series(data: schemas.SessionsSearchPayloadSchema, project_id: int, d
|
||||||
LEFT JOIN LATERAL ( SELECT 1 AS s
|
LEFT JOIN LATERAL ( SELECT 1 AS s
|
||||||
FROM full_sessions
|
FROM full_sessions
|
||||||
WHERE start_ts >= generated_timestamp
|
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
|
GROUP BY generated_timestamp
|
||||||
ORDER BY generated_timestamp;""", full_args)
|
ORDER BY generated_timestamp;""", full_args)
|
||||||
else:
|
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):
|
for i, f in enumerate(data.filters):
|
||||||
if not isinstance(f.value, list):
|
if not isinstance(f.value, list):
|
||||||
f.value = [f.value]
|
f.value = [f.value]
|
||||||
if len(f.value) == 0 or f.value[0] is None:
|
|
||||||
continue
|
|
||||||
filter_type = f.type
|
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.value = helper.values_for_operator(value=f.value, op=f.operator)
|
||||||
f_k = f"f_value{i}"
|
f_k = f"f_value{i}"
|
||||||
full_args = {**full_args, **_multiple_values(f.value, value_key=f_k)}
|
full_args = {**full_args, **_multiple_values(f.value, value_key=f_k)}
|
||||||
op = __get_sql_operator(f.operator) \
|
op = __get_sql_operator(f.operator) \
|
||||||
if filter_type not in [schemas.FilterType.events_count] else f.operator
|
if filter_type not in [schemas.FilterType.events_count] else f.operator
|
||||||
is_any = _isAny_opreator(f.operator)
|
is_any = _isAny_opreator(f.operator)
|
||||||
|
if not is_any and len(f.value) == 0:
|
||||||
|
continue
|
||||||
is_not = False
|
is_not = False
|
||||||
if __is_negation_operator(f.operator):
|
if __is_negation_operator(f.operator):
|
||||||
is_not = True
|
is_not = True
|
||||||
# op = __reverse_sql_operator(op)
|
|
||||||
if filter_type == schemas.FilterType.user_browser:
|
if filter_type == schemas.FilterType.user_browser:
|
||||||
# op = __get_sql_operator_multiple(f.operator)
|
if is_any:
|
||||||
extra_constraints.append(
|
extra_constraints.append('s.user_browser IS NOT NULL')
|
||||||
_multiple_conditions(f's.user_browser {op} %({f_k})s', f.value, is_not=is_not, value_key=f_k))
|
ss_constraints.append('ms.user_browser IS NOT NULL')
|
||||||
ss_constraints.append(
|
else:
|
||||||
_multiple_conditions(f'ms.user_browser {op} %({f_k})s', f.value, is_not=is_not, value_key=f_k))
|
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]:
|
elif filter_type in [schemas.FilterType.user_os, schemas.FilterType.user_os_ios]:
|
||||||
# op = __get_sql_operator_multiple(f.operator)
|
if is_any:
|
||||||
extra_constraints.append(
|
extra_constraints.append('s.user_os IS NOT NULL')
|
||||||
_multiple_conditions(f's.user_os {op} %({f_k})s', f.value, is_not=is_not, value_key=f_k))
|
ss_constraints.append('ms.user_os IS NOT NULL')
|
||||||
ss_constraints.append(
|
else:
|
||||||
_multiple_conditions(f'ms.user_os {op} %({f_k})s', f.value, is_not=is_not, value_key=f_k))
|
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]:
|
elif filter_type in [schemas.FilterType.user_device, schemas.FilterType.user_device_ios]:
|
||||||
# op = __get_sql_operator_multiple(f.operator)
|
if is_any:
|
||||||
extra_constraints.append(
|
extra_constraints.append('s.user_device IS NOT NULL')
|
||||||
_multiple_conditions(f's.user_device {op} %({f_k})s', f.value, is_not=is_not, value_key=f_k))
|
ss_constraints.append('ms.user_device IS NOT NULL')
|
||||||
ss_constraints.append(
|
else:
|
||||||
_multiple_conditions(f'ms.user_device {op} %({f_k})s', f.value, is_not=is_not, value_key=f_k))
|
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]:
|
elif filter_type in [schemas.FilterType.user_country, schemas.FilterType.user_country_ios]:
|
||||||
# op = __get_sql_operator_multiple(f.operator)
|
if is_any:
|
||||||
extra_constraints.append(
|
extra_constraints.append('s.user_country IS NOT NULL')
|
||||||
_multiple_conditions(f's.user_country {op} %({f_k})s', f.value, is_not=is_not, value_key=f_k))
|
ss_constraints.append('ms.user_country IS NOT NULL')
|
||||||
ss_constraints.append(
|
else:
|
||||||
_multiple_conditions(f'ms.user_country {op} %({f_k})s', f.value, is_not=is_not, value_key=f_k))
|
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]:
|
elif filter_type in [schemas.FilterType.utm_source]:
|
||||||
if is_any:
|
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')
|
ss_constraints.append('ms.utm_source IS NOT NULL')
|
||||||
else:
|
else:
|
||||||
extra_constraints.append(
|
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(
|
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))
|
value_key=f_k))
|
||||||
elif filter_type in [schemas.FilterType.utm_medium]:
|
elif filter_type in [schemas.FilterType.utm_medium]:
|
||||||
if is_any:
|
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')
|
ss_constraints.append('ms.utm_medium IS NOT NULL')
|
||||||
else:
|
else:
|
||||||
extra_constraints.append(
|
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(
|
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))
|
value_key=f_k))
|
||||||
elif filter_type in [schemas.FilterType.utm_campaign]:
|
elif filter_type in [schemas.FilterType.utm_campaign]:
|
||||||
if is_any:
|
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')
|
ss_constraints.append('ms.utm_campaign IS NOT NULL')
|
||||||
else:
|
else:
|
||||||
extra_constraints.append(
|
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))
|
value_key=f_k))
|
||||||
ss_constraints.append(
|
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))
|
value_key=f_k))
|
||||||
|
|
||||||
elif filter_type == schemas.FilterType.duration:
|
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")
|
ss_constraints.append("ms.duration <= %(maxDuration)s")
|
||||||
full_args["maxDuration"] = f.value[1]
|
full_args["maxDuration"] = f.value[1]
|
||||||
elif filter_type == schemas.FilterType.referrer:
|
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)"
|
extra_from += f"INNER JOIN {events.event_type.LOCATION.table} AS p USING(session_id)"
|
||||||
# op = __get_sql_operator_multiple(f.operator)
|
if is_any:
|
||||||
extra_constraints.append(
|
extra_constraints.append('p.base_referrer IS NOT NULL')
|
||||||
_multiple_conditions(f"p.base_referrer {op} %({f_k})s", f.value, is_not=is_not, value_key=f_k))
|
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:
|
elif filter_type == events.event_type.METADATA.ui_type:
|
||||||
# get metadata list only if you need it
|
# get metadata list only if you need it
|
||||||
if meta_keys is None:
|
if meta_keys is None:
|
||||||
meta_keys = metadata.get(project_id=project_id)
|
meta_keys = metadata.get(project_id=project_id)
|
||||||
meta_keys = {m["key"]: m["index"] for m in meta_keys}
|
meta_keys = {m["key"]: m["index"] for m in meta_keys}
|
||||||
# op = __get_sql_operator(f.operator)
|
if f.source in meta_keys.keys():
|
||||||
if f.key in meta_keys.keys():
|
if is_any:
|
||||||
extra_constraints.append(
|
extra_constraints.append(f"s.{metadata.index_to_colname(meta_keys[f.source])} IS NOT NULL")
|
||||||
_multiple_conditions(f"s.{metadata.index_to_colname(meta_keys[f.key])} {op} %({f_k})s",
|
ss_constraints.append(f"ms.{metadata.index_to_colname(meta_keys[f.source])} IS NOT NULL")
|
||||||
f.value, is_not=is_not, value_key=f_k))
|
else:
|
||||||
ss_constraints.append(
|
extra_constraints.append(
|
||||||
_multiple_conditions(f"ms.{metadata.index_to_colname(meta_keys[f.key])} {op} %({f_k})s",
|
_multiple_conditions(
|
||||||
f.value, is_not=is_not, value_key=f_k))
|
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]:
|
elif filter_type in [schemas.FilterType.user_id, schemas.FilterType.user_id_ios]:
|
||||||
# op = __get_sql_operator(f.operator)
|
if is_any:
|
||||||
extra_constraints.append(
|
extra_constraints.append('s.user_id IS NOT NULL')
|
||||||
_multiple_conditions(f"s.user_id {op} %({f_k})s", f.value, is_not=is_not, value_key=f_k))
|
ss_constraints.append('ms.user_id IS NOT NULL')
|
||||||
ss_constraints.append(
|
else:
|
||||||
_multiple_conditions(f"ms.user_id {op} %({f_k})s", f.value, is_not=is_not, value_key=f_k))
|
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,
|
elif filter_type in [schemas.FilterType.user_anonymous_id,
|
||||||
schemas.FilterType.user_anonymous_id_ios]:
|
schemas.FilterType.user_anonymous_id_ios]:
|
||||||
# op = __get_sql_operator(f.operator)
|
if is_any:
|
||||||
extra_constraints.append(
|
extra_constraints.append('s.user_anonymous_id IS NOT NULL')
|
||||||
_multiple_conditions(f"s.user_anonymous_id {op} %({f_k})s", f.value, is_not=is_not,
|
ss_constraints.append('ms.user_anonymous_id IS NOT NULL')
|
||||||
value_key=f_k))
|
else:
|
||||||
ss_constraints.append(
|
extra_constraints.append(
|
||||||
_multiple_conditions(f"ms.user_anonymous_id {op} %({f_k})s", f.value, is_not=is_not,
|
_multiple_conditions(f"s.user_anonymous_id {op} %({f_k})s::text", f.value, is_not=is_not,
|
||||||
value_key=f_k))
|
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]:
|
elif filter_type in [schemas.FilterType.rev_id, schemas.FilterType.rev_id_ios]:
|
||||||
# op = __get_sql_operator(f.operator)
|
if is_any:
|
||||||
extra_constraints.append(
|
extra_constraints.append('s.rev_id IS NOT NULL')
|
||||||
_multiple_conditions(f"s.rev_id {op} %({f_k})s", f.value, is_not=is_not, value_key=f_k))
|
ss_constraints.append('ms.rev_id IS NOT NULL')
|
||||||
ss_constraints.append(
|
else:
|
||||||
_multiple_conditions(f"ms.rev_id {op} %({f_k})s", f.value, is_not=is_not, value_key=f_k))
|
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:
|
elif filter_type == schemas.FilterType.platform:
|
||||||
# op = __get_sql_operator(f.operator)
|
# op = __get_sql_operator(f.operator)
|
||||||
extra_constraints.append(
|
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,
|
_multiple_conditions(f"ms.user_device_type {op} %({f_k})s", f.value, is_not=is_not,
|
||||||
value_key=f_k))
|
value_key=f_k))
|
||||||
elif filter_type == schemas.FilterType.issue:
|
elif filter_type == schemas.FilterType.issue:
|
||||||
extra_constraints.append(
|
if is_any:
|
||||||
_multiple_conditions(f"%({f_k})s {op} ANY (s.issue_types)", f.value, is_not=is_not,
|
extra_constraints.append("array_length(s.issue_types, 1) > 0")
|
||||||
value_key=f_k))
|
ss_constraints.append("array_length(ms.issue_types, 1) > 0")
|
||||||
ss_constraints.append(
|
else:
|
||||||
_multiple_conditions(f"%({f_k})s {op} ANY (ms.issue_types)", f.value, is_not=is_not,
|
extra_constraints.append(
|
||||||
value_key=f_k))
|
_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:
|
elif filter_type == schemas.FilterType.events_count:
|
||||||
extra_constraints.append(
|
extra_constraints.append(
|
||||||
_multiple_conditions(f"s.events_count {op} %({f_k})s", f.value, is_not=is_not,
|
_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)
|
is_any = _isAny_opreator(event.operator)
|
||||||
if not isinstance(event.value, list):
|
if not isinstance(event.value, list):
|
||||||
event.value = [event.value]
|
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)
|
op = __get_sql_operator(event.operator)
|
||||||
is_not = False
|
is_not = False
|
||||||
if __is_negation_operator(event.operator):
|
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:
|
if data.events_order == schemas.SearchEventOrder._then:
|
||||||
event_where.append(f"event_{event_index - 1}.timestamp <= main.timestamp")
|
event_where.append(f"event_{event_index - 1}.timestamp <= main.timestamp")
|
||||||
e_k = f"e_value{i}"
|
e_k = f"e_value{i}"
|
||||||
|
s_k = e_k + "_source"
|
||||||
if event.type != schemas.PerformanceEventType.time_between_events:
|
if event.type != schemas.PerformanceEventType.time_between_events:
|
||||||
event.value = helper.values_for_operator(value=event.value, op=event.operator)
|
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()) \
|
# if event_type not in list(events.SUPPORTED_TYPES.keys()) \
|
||||||
# or event.value in [None, "", "*"] \
|
# 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(
|
event_where.append(
|
||||||
_multiple_conditions(f"main.{events.event_type.INPUT.column} {op} %({e_k})s", event.value,
|
_multiple_conditions(f"main.{events.event_type.INPUT.column} {op} %({e_k})s", event.value,
|
||||||
value_key=e_k))
|
value_key=e_k))
|
||||||
if event.custom is not None and len(event.custom) > 0:
|
if event.source is not None and len(event.source) > 0:
|
||||||
event_where.append(_multiple_conditions(f"main.value ILIKE %(custom{i})s", event.custom,
|
event_where.append(_multiple_conditions(f"main.value ILIKE %(custom{i})s", event.source,
|
||||||
value_key=f"custom{i}"))
|
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:
|
elif event_type == events.event_type.LOCATION.ui_type:
|
||||||
event_from = event_from % f"{events.event_type.LOCATION.table} AS main "
|
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",
|
_multiple_conditions(f"main.{events.event_type.STATEACTION.column} {op} %({e_k})s",
|
||||||
event.value, value_key=e_k))
|
event.value, value_key=e_k))
|
||||||
elif event_type == events.event_type.ERROR.ui_type:
|
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)"
|
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, "*", ""]:
|
event.source = tuple(event.source)
|
||||||
if not is_any:
|
if not is_any and event.value not in [None, "*", ""]:
|
||||||
event_where.append(f"(main1.message {op} %({e_k})s OR main1.name {op} %({e_k})s)")
|
event_where.append(
|
||||||
if event.source not in [None, "*", ""]:
|
_multiple_conditions(f"(main1.message {op} %({e_k})s OR main1.name {op} %({e_k})s)",
|
||||||
event_where.append(f"main1.source = %(source)s")
|
event.value, value_key=e_k))
|
||||||
full_args["source"] = event.source
|
if event.source[0] not in [None, "*", ""]:
|
||||||
elif event.source not in [None, "*", ""]:
|
event_where.append(_multiple_conditions(f"main1.source = %({s_k})s", event.value, value_key=s_k))
|
||||||
event_where.append(f"main1.source = %(source)s")
|
|
||||||
full_args["source"] = event.source
|
|
||||||
|
|
||||||
# ----- IOS
|
# ----- IOS
|
||||||
elif event_type == events.event_type.CLICK_IOS.ui_type:
|
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(
|
event_where.append(
|
||||||
_multiple_conditions(f"main.{events.event_type.INPUT_IOS.column} {op} %({e_k})s",
|
_multiple_conditions(f"main.{events.event_type.INPUT_IOS.column} {op} %({e_k})s",
|
||||||
event.value, value_key=e_k))
|
event.value, value_key=e_k))
|
||||||
if event.custom is not None and len(event.custom) > 0:
|
if event.source is not None and len(event.source) > 0:
|
||||||
event_where.append(_multiple_conditions(f"main.value ILIKE %(custom{i})s", event.custom,
|
event_where.append(_multiple_conditions(f"main.value ILIKE %(custom{i})s", event.source,
|
||||||
value_key="custom{i}"))
|
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:
|
elif event_type == events.event_type.VIEW_IOS.ui_type:
|
||||||
event_from = event_from % f"{events.event_type.VIEW_IOS.table} AS main "
|
event_from = event_from % f"{events.event_type.VIEW_IOS.table} AS main "
|
||||||
if not is_any:
|
if not is_any:
|
||||||
|
|
@ -594,10 +649,10 @@ def search_query_parts(data, error_status, errors_only, favorite_only, issue, pr
|
||||||
# colname = col["column"]
|
# colname = col["column"]
|
||||||
# tname = "main"
|
# tname = "main"
|
||||||
# e_k += "_custom"
|
# 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 " +
|
# event_where.append(f"{tname}.{colname} IS NOT NULL AND {tname}.{colname}>0 AND " +
|
||||||
# _multiple_conditions(f"{tname}.{colname} {event.customOperator} %({e_k})s",
|
# _multiple_conditions(f"{tname}.{colname} {event.sourceOperator} %({e_k})s",
|
||||||
# event.custom, value_key=e_k))
|
# event.source, value_key=e_k))
|
||||||
elif event_type in [schemas.PerformanceEventType.location_dom_complete,
|
elif event_type in [schemas.PerformanceEventType.location_dom_complete,
|
||||||
schemas.PerformanceEventType.location_largest_contentful_paint_time,
|
schemas.PerformanceEventType.location_largest_contentful_paint_time,
|
||||||
schemas.PerformanceEventType.location_ttfb,
|
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",
|
_multiple_conditions(f"main.{events.event_type.LOCATION.column} {op} %({e_k})s",
|
||||||
event.value, value_key=e_k))
|
event.value, value_key=e_k))
|
||||||
e_k += "_custom"
|
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 " +
|
event_where.append(f"{tname}.{colname} IS NOT NULL AND {tname}.{colname}>0 AND " +
|
||||||
_multiple_conditions(f"{tname}.{colname} {event.customOperator} %({e_k})s",
|
_multiple_conditions(f"{tname}.{colname} {event.sourceOperator} %({e_k})s",
|
||||||
event.custom, value_key=e_k))
|
event.source, value_key=e_k))
|
||||||
elif event_type == schemas.PerformanceEventType.time_between_events:
|
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) "
|
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):
|
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))
|
event.value[1].value, value_key=e_k2))
|
||||||
|
|
||||||
e_k += "_custom"
|
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(
|
event_where.append(
|
||||||
_multiple_conditions(f"main2.timestamp - main.timestamp {event.customOperator} %({e_k})s",
|
_multiple_conditions(f"main2.timestamp - main.timestamp {event.sourceOperator} %({e_k})s",
|
||||||
event.custom, value_key=e_k))
|
event.source, value_key=e_k))
|
||||||
|
|
||||||
|
|
||||||
else:
|
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 >= %(startDate)s
|
||||||
AND start_ts <= %(endDate)s
|
AND start_ts <= %(endDate)s
|
||||||
AND duration IS NOT NULL
|
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:
|
else:
|
||||||
events_query_from.append(f"""\
|
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.session_id::text AS session_id,
|
||||||
s.user_uuid,
|
s.user_uuid,
|
||||||
s.user_id,
|
s.user_id,
|
||||||
s.user_agent,
|
-- s.user_agent,
|
||||||
s.user_os,
|
s.user_os,
|
||||||
s.user_browser,
|
s.user_browser,
|
||||||
s.user_device,
|
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.session_id::text AS session_id,
|
||||||
s.user_uuid,
|
s.user_uuid,
|
||||||
s.user_id,
|
s.user_id,
|
||||||
s.user_agent,
|
-- s.user_agent,
|
||||||
s.user_os,
|
s.user_os,
|
||||||
s.user_browser,
|
s.user_browser,
|
||||||
s.user_device,
|
s.user_device,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
from chalicelib.utils import pg_client
|
|
||||||
from chalicelib.core import sessions
|
from chalicelib.core import sessions
|
||||||
|
from chalicelib.utils import pg_client
|
||||||
|
|
||||||
|
|
||||||
def add_favorite_session(project_id, user_id, session_id):
|
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
|
INSERT INTO public.user_viewed_sessions
|
||||||
(user_id, session_id)
|
(user_id, session_id)
|
||||||
VALUES
|
VALUES
|
||||||
(%(userId)s,%(sessionId)s);""",
|
(%(userId)s,%(sessionId)s)
|
||||||
|
ON CONFLICT DO NOTHING;""",
|
||||||
{"userId": user_id, "sessionId": session_id})
|
{"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):
|
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)
|
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()
|
r = cur.fetchone()
|
||||||
return r is not None
|
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&...
|
:param filter_d: dict contains events&filters&...
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
stages: [dict] = filter_d["events"]
|
stages: [dict] = filter_d.get("events", [])
|
||||||
filters: [dict] = filter_d.get("filters", [])
|
filters: [dict] = filter_d.get("filters", [])
|
||||||
filter_issues = filter_d.get("issueTypes")
|
filter_issues = filter_d.get("issueTypes")
|
||||||
if filter_issues is None or len(filter_issues) == 0:
|
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):
|
if not isinstance(s["value"], list):
|
||||||
s["value"] = [s["value"]]
|
s["value"] = [s["value"]]
|
||||||
is_any = sessions._isAny_opreator(s["operator"])
|
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"])
|
op = sessions.__get_sql_operator(s["operator"])
|
||||||
event_type = s["type"].upper()
|
event_type = s["type"].upper()
|
||||||
if event_type == events.event_type.CLICK.ui_type:
|
if event_type == events.event_type.CLICK.ui_type:
|
||||||
|
|
@ -581,7 +583,7 @@ def get_top_insights(filter_d, project_id):
|
||||||
@dev.timed
|
@dev.timed
|
||||||
def get_issues_list(filter_d, project_id, first_stage=None, last_stage=None):
|
def get_issues_list(filter_d, project_id, first_stage=None, last_stage=None):
|
||||||
output = dict({'critical_issues_count': 0})
|
output = dict({'critical_issues_count': 0})
|
||||||
stages = filter_d["events"]
|
stages = filter_d.get("events", [])
|
||||||
# The result of the multi-stage query
|
# The result of the multi-stage query
|
||||||
rows = get_stages_and_events(filter_d=filter_d, project_id=project_id)
|
rows = get_stages_and_events(filter_d=filter_d, project_id=project_id)
|
||||||
# print(json.dumps(rows[0],indent=4))
|
# 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}
|
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):
|
def get_by_email_only(email):
|
||||||
with pg_client.PostgresClient() as cur:
|
with pg_client.PostgresClient() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
|
|
|
||||||
|
|
@ -216,7 +216,7 @@ def values_for_operator(value: Union[str, list], op: schemas.SearchEventOperator
|
||||||
return value + '%'
|
return value + '%'
|
||||||
elif op == schemas.SearchEventOperator._ends_with:
|
elif op == schemas.SearchEventOperator._ends_with:
|
||||||
return '%' + value
|
return '%' + value
|
||||||
elif op == schemas.SearchEventOperator._contains:
|
elif op == schemas.SearchEventOperator._contains or op == schemas.SearchEventOperator._not_contains:
|
||||||
return '%' + value + '%'
|
return '%' + value + '%'
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
@ -377,3 +377,10 @@ def old_search_payload_to_flat(values):
|
||||||
v["isEvent"] = False
|
v["isEvent"] = False
|
||||||
values["filters"] = values.pop("events") + values.get("filters", [])
|
values["filters"] = values.pop("events") + values.get("filters", [])
|
||||||
return values
|
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)
|
include_fav_viewed=True, group_metadata=True)
|
||||||
if data is None:
|
if data is None:
|
||||||
return {"errors": ["session not found"]}
|
return {"errors": ["session not found"]}
|
||||||
|
if not data.get("live"):
|
||||||
sessions_favorite_viewed.view_session(project_id=projectId, user_id=context.user_id, session_id=sessionId)
|
sessions_favorite_viewed.view_session(project_id=projectId, user_id=context.user_id, session_id=sessionId)
|
||||||
return {
|
return {
|
||||||
'data': data
|
'data': data
|
||||||
}
|
}
|
||||||
|
|
@ -99,10 +99,25 @@ def comment_assignment(projectId: int, sessionId: int, issueId: str, data: schem
|
||||||
|
|
||||||
|
|
||||||
@app.get('/{projectId}/events/search', tags=["events"])
|
@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)):
|
source: str = None, context: schemas.CurrentContext = Depends(OR_context)):
|
||||||
if len(q) == 0:
|
if len(q) == 0:
|
||||||
return {"data": []}
|
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)
|
result = events.search_pg2(text=q, event_type=type, project_id=projectId, source=source, key=key)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
@ -757,7 +772,7 @@ def get_funnel_issue_sessions(projectId: int, funnelId: int, issueId: str,
|
||||||
|
|
||||||
@app.get('/{projectId}/funnels/{funnelId}', tags=["funnels"])
|
@app.get('/{projectId}/funnels/{funnelId}', tags=["funnels"])
|
||||||
def get_funnel(projectId: int, funnelId: int, context: schemas.CurrentContext = Depends(OR_context)):
|
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:
|
if data is None:
|
||||||
return {"errors": ["funnel not found"]}
|
return {"errors": ["funnel not found"]}
|
||||||
return {"data": data}
|
return {"data": data}
|
||||||
|
|
@ -815,14 +830,14 @@ def all_issue_types(context: schemas.CurrentContext = Depends(OR_context)):
|
||||||
|
|
||||||
@app.get('/{projectId}/assist/sessions', tags=["assist"])
|
@app.get('/{projectId}/assist/sessions', tags=["assist"])
|
||||||
def sessions_live(projectId: int, context: schemas.CurrentContext = Depends(OR_context)):
|
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}
|
return {'data': data}
|
||||||
|
|
||||||
|
|
||||||
@app.post('/{projectId}/assist/sessions', tags=["assist"])
|
@app.post('/{projectId}/assist/sessions', tags=["assist"])
|
||||||
def sessions_live_search(projectId: int, data: schemas.AssistSearchPayloadSchema = Body(...),
|
def sessions_live_search(projectId: int, data: schemas.AssistSearchPayloadSchema = Body(...),
|
||||||
context: schemas.CurrentContext = Depends(OR_context)):
|
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}
|
return {'data': data}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1054,6 +1069,13 @@ def edit_account(data: schemas.EditUserSchema = Body(...),
|
||||||
editor_id=context.user_id)
|
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.post('/account/password', tags=["account"])
|
||||||
@app.put('/account/password', tags=["account"])
|
@app.put('/account/password', tags=["account"])
|
||||||
def change_client_password(data: schemas.EditUserPasswordSchema = Body(...),
|
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"])
|
@app.put('/{projectId}/custom_metrics/try', tags=["customMetrics"])
|
||||||
def try_custom_metric(projectId: int, data: schemas.TryCustomMetricsSchema = Body(...),
|
def try_custom_metric(projectId: int, data: schemas.TryCustomMetricsSchema = Body(...),
|
||||||
context: schemas.CurrentContext = Depends(OR_context)):
|
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"])
|
@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)}
|
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"])
|
@app.post('/{projectId}/custom_metrics/{metric_id}/chart', tags=["customMetrics"])
|
||||||
def get_custom_metric_chart(projectId: int, metric_id: int, data: schemas.CustomMetricChartPayloadSchema = Body(...),
|
def get_custom_metric_chart(projectId: int, metric_id: int, data: schemas.CustomMetricChartPayloadSchema = Body(...),
|
||||||
context: schemas.CurrentContext = Depends(OR_context)):
|
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)}
|
"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"])
|
@app.delete('/{projectId}/custom_metrics/{metric_id}', tags=["customMetrics"])
|
||||||
def delete_custom_metric(projectId: int, metric_id: int, context: schemas.CurrentContext = Depends(OR_context)):
|
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)}
|
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"])
|
@app.get('/{projectId}/saved_search', tags=["savedSearch"])
|
||||||
def get_saved_searches(projectId: int, context: schemas.CurrentContext = Depends(OR_context)):
|
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"])
|
@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"])
|
@app.delete('/{projectId}/saved_search/{search_id}', tags=["savedSearch"])
|
||||||
def delete_saved_search(projectId: int, search_id: int, context: schemas.CurrentContext = Depends(OR_context)):
|
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)}
|
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({})
|
appearance: Optional[dict] = Field({})
|
||||||
|
|
||||||
|
|
||||||
|
class EditUserAppearanceSchema(BaseModel):
|
||||||
|
appearance: dict = Field(...)
|
||||||
|
|
||||||
|
|
||||||
class ForgetPasswordPayloadSchema(_Grecaptcha):
|
class ForgetPasswordPayloadSchema(_Grecaptcha):
|
||||||
email: str = Field(...)
|
email: str = Field(...)
|
||||||
|
|
||||||
|
|
@ -312,7 +316,7 @@ class MathOperator(str, Enum):
|
||||||
|
|
||||||
|
|
||||||
class _AlertQuerySchema(BaseModel):
|
class _AlertQuerySchema(BaseModel):
|
||||||
left: AlertColumn = Field(...)
|
left: Union[AlertColumn, int] = Field(...)
|
||||||
right: float = Field(...)
|
right: float = Field(...)
|
||||||
# operator: Literal["<", ">", "<=", ">="] = Field(...)
|
# operator: Literal["<", ">", "<=", ">="] = Field(...)
|
||||||
operator: MathOperator = Field(...)
|
operator: MathOperator = Field(...)
|
||||||
|
|
@ -331,6 +335,14 @@ class AlertSchema(BaseModel):
|
||||||
query: _AlertQuerySchema = Field(...)
|
query: _AlertQuerySchema = Field(...)
|
||||||
series_id: Optional[int] = Field(None)
|
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
|
@root_validator
|
||||||
def alert_validator(cls, values):
|
def alert_validator(cls, values):
|
||||||
if values.get("query") is not None and values["query"].left == AlertColumn.custom:
|
if values.get("query") is not None and values["query"].left == AlertColumn.custom:
|
||||||
|
|
@ -371,7 +383,6 @@ class EventType(str, Enum):
|
||||||
graphql = "GRAPHQL"
|
graphql = "GRAPHQL"
|
||||||
state_action = "STATEACTION"
|
state_action = "STATEACTION"
|
||||||
error = "ERROR"
|
error = "ERROR"
|
||||||
metadata = "METADATA"
|
|
||||||
click_ios = "CLICK_IOS"
|
click_ios = "CLICK_IOS"
|
||||||
input_ios = "INPUT_IOS"
|
input_ios = "INPUT_IOS"
|
||||||
view_ios = "VIEW_IOS"
|
view_ios = "VIEW_IOS"
|
||||||
|
|
@ -461,37 +472,48 @@ class IssueType(str, Enum):
|
||||||
class __MixedSearchFilter(BaseModel):
|
class __MixedSearchFilter(BaseModel):
|
||||||
is_event: bool = Field(...)
|
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:
|
class Config:
|
||||||
alias_generator = attribute_to_camel_case
|
alias_generator = attribute_to_camel_case
|
||||||
|
|
||||||
|
|
||||||
class _SessionSearchEventRaw(__MixedSearchFilter):
|
class _SessionSearchEventRaw(__MixedSearchFilter):
|
||||||
is_event: bool = Field(True, const=True)
|
is_event: bool = Field(default=True, const=True)
|
||||||
custom: Optional[List[Union[int, str]]] = Field(None, min_items=1)
|
value: List[str] = Field(...)
|
||||||
customOperator: Optional[MathOperator] = Field(None)
|
|
||||||
key: Optional[str] = Field(None)
|
|
||||||
value: Union[str, List[str]] = Field(...)
|
|
||||||
type: Union[EventType, PerformanceEventType] = Field(...)
|
type: Union[EventType, PerformanceEventType] = Field(...)
|
||||||
operator: SearchEventOperator = 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
|
@root_validator
|
||||||
def event_validator(cls, values):
|
def event_validator(cls, values):
|
||||||
if isinstance(values.get("type"), PerformanceEventType):
|
if isinstance(values.get("type"), PerformanceEventType):
|
||||||
if values.get("type") == PerformanceEventType.fetch_failed:
|
if values.get("type") == PerformanceEventType.fetch_failed:
|
||||||
return values
|
return values
|
||||||
assert values.get("custom") is not None, "custom should not be null for PerformanceEventType"
|
# assert values.get("source") is not None, "source should not be null for PerformanceEventType"
|
||||||
assert values.get("customOperator") is not None \
|
# assert isinstance(values["source"], list) and len(values["source"]) > 0, \
|
||||||
, "customOperator should not be null for PerformanceEventType"
|
# "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:
|
if values["type"] == PerformanceEventType.time_between_events:
|
||||||
assert len(values.get("value", [])) == 2, \
|
assert len(values.get("value", [])) == 2, \
|
||||||
f"must provide 2 Events as value for {PerformanceEventType.time_between_events}"
|
f"must provide 2 Events as value for {PerformanceEventType.time_between_events}"
|
||||||
assert isinstance(values["value"][0], _SessionSearchEventRaw) \
|
assert isinstance(values["value"][0], _SessionSearchEventRaw) \
|
||||||
and isinstance(values["value"][1], _SessionSearchEventRaw) \
|
and isinstance(values["value"][1], _SessionSearchEventRaw), \
|
||||||
, f"event should be of type _SessionSearchEventRaw for {PerformanceEventType.time_between_events}"
|
f"event should be of type _SessionSearchEventRaw for {PerformanceEventType.time_between_events}"
|
||||||
else:
|
else:
|
||||||
for c in values["custom"]:
|
for c in values["source"]:
|
||||||
assert isinstance(c, int), f"custom value should be of type int for {values.get('type')}"
|
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
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -501,17 +523,18 @@ class _SessionSearchEventSchema(_SessionSearchEventRaw):
|
||||||
|
|
||||||
class _SessionSearchFilterSchema(__MixedSearchFilter):
|
class _SessionSearchFilterSchema(__MixedSearchFilter):
|
||||||
is_event: bool = Field(False, const=False)
|
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]],
|
value: Union[Optional[Union[IssueType, PlatformType, int, str]],
|
||||||
Optional[List[Union[IssueType, PlatformType, int, str]]]] = Field(...)
|
Optional[List[Union[IssueType, PlatformType, int, str]]]] = Field(...)
|
||||||
type: FilterType = Field(...)
|
type: FilterType = Field(...)
|
||||||
operator: Union[SearchEventOperator, MathOperator] = 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
|
@root_validator
|
||||||
def filter_validator(cls, values):
|
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"):
|
for v in values.get("value"):
|
||||||
assert isinstance(v, IssueType), f"value should be of type IssueType for {values.get('type')} filter"
|
assert isinstance(v, IssueType), f"value should be of type IssueType for {values.get('type')} filter"
|
||||||
elif values.get("type") == FilterType.platform:
|
elif values.get("type") == FilterType.platform:
|
||||||
|
|
@ -532,14 +555,12 @@ class _SessionSearchFilterSchema(__MixedSearchFilter):
|
||||||
class SessionsSearchPayloadSchema(BaseModel):
|
class SessionsSearchPayloadSchema(BaseModel):
|
||||||
events: List[_SessionSearchEventSchema] = Field([])
|
events: List[_SessionSearchEventSchema] = Field([])
|
||||||
filters: List[_SessionSearchFilterSchema] = Field([])
|
filters: List[_SessionSearchFilterSchema] = Field([])
|
||||||
# custom:dict=Field(...)
|
|
||||||
# rangeValue:str=Field(...)
|
|
||||||
startDate: int = Field(None)
|
startDate: int = Field(None)
|
||||||
endDate: int = Field(None)
|
endDate: int = Field(None)
|
||||||
sort: str = Field(...)
|
sort: str = Field(default="startTs")
|
||||||
order: str = Field(default="DESC")
|
order: str = Field(default="DESC")
|
||||||
# platform: Optional[PlatformType] = Field(None)
|
|
||||||
events_order: Optional[SearchEventOrder] = Field(default=SearchEventOrder._then)
|
events_order: Optional[SearchEventOrder] = Field(default=SearchEventOrder._then)
|
||||||
|
group_by_user: bool = Field(default=False)
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
alias_generator = attribute_to_camel_case
|
alias_generator = attribute_to_camel_case
|
||||||
|
|
@ -561,9 +582,10 @@ class FlatSessionsSearchPayloadSchema(SessionsSearchPayloadSchema):
|
||||||
n_filters = []
|
n_filters = []
|
||||||
n_events = []
|
n_events = []
|
||||||
for v in values.get("filters", []):
|
for v in values.get("filters", []):
|
||||||
if v["isEvent"]:
|
if v.get("isEvent"):
|
||||||
n_events.append(v)
|
n_events.append(v)
|
||||||
else:
|
else:
|
||||||
|
v["isEvent"] = False
|
||||||
n_filters.append(v)
|
n_filters.append(v)
|
||||||
values["events"] = n_events
|
values["events"] = n_events
|
||||||
values["filters"] = n_filters
|
values["filters"] = n_filters
|
||||||
|
|
@ -581,6 +603,14 @@ class FunnelSearchPayloadSchema(FlatSessionsSearchPayloadSchema):
|
||||||
range_value: Optional[str] = Field(None)
|
range_value: Optional[str] = Field(None)
|
||||||
sort: Optional[str] = Field(None)
|
sort: Optional[str] = Field(None)
|
||||||
order: 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:
|
class Config:
|
||||||
alias_generator = attribute_to_camel_case
|
alias_generator = attribute_to_camel_case
|
||||||
|
|
@ -605,6 +635,8 @@ class FunnelInsightsPayloadSchema(FlatSessionsSearchPayloadSchema):
|
||||||
# class FunnelInsightsPayloadSchema(SessionsSearchPayloadSchema):
|
# class FunnelInsightsPayloadSchema(SessionsSearchPayloadSchema):
|
||||||
sort: Optional[str] = Field(None)
|
sort: Optional[str] = Field(None)
|
||||||
order: 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):
|
class MetricPayloadSchema(BaseModel):
|
||||||
|
|
@ -638,18 +670,23 @@ class CustomMetricSeriesFilterSchema(FlatSessionsSearchPayloadSchema):
|
||||||
endDate: Optional[int] = Field(None)
|
endDate: Optional[int] = Field(None)
|
||||||
sort: Optional[str] = Field(None)
|
sort: Optional[str] = Field(None)
|
||||||
order: Optional[str] = Field(None)
|
order: Optional[str] = Field(None)
|
||||||
|
group_by_user: Optional[bool] = Field(default=False, const=True)
|
||||||
|
|
||||||
|
|
||||||
class CustomMetricCreateSeriesSchema(BaseModel):
|
class CustomMetricCreateSeriesSchema(BaseModel):
|
||||||
|
series_id: Optional[int] = Field(None)
|
||||||
name: Optional[str] = Field(None)
|
name: Optional[str] = Field(None)
|
||||||
index: Optional[int] = Field(None)
|
index: Optional[int] = Field(None)
|
||||||
filter: Optional[CustomMetricSeriesFilterSchema] = Field([])
|
filter: Optional[CustomMetricSeriesFilterSchema] = Field([])
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
alias_generator = attribute_to_camel_case
|
||||||
|
|
||||||
|
|
||||||
class CreateCustomMetricsSchema(BaseModel):
|
class CreateCustomMetricsSchema(BaseModel):
|
||||||
name: str = Field(...)
|
name: str = Field(...)
|
||||||
series: List[CustomMetricCreateSeriesSchema] = Field(..., min_items=1)
|
series: List[CustomMetricCreateSeriesSchema] = Field(..., min_items=1)
|
||||||
is_public: Optional[bool] = Field(False)
|
is_public: Optional[bool] = Field(True)
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
alias_generator = attribute_to_camel_case
|
alias_generator = attribute_to_camel_case
|
||||||
|
|
@ -660,15 +697,24 @@ class MetricViewType(str, Enum):
|
||||||
progress = "progress"
|
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))
|
startDate: int = Field(TimeUTC.now(-7))
|
||||||
endDate: int = Field(TimeUTC.now())
|
endDate: int = Field(TimeUTC.now())
|
||||||
density: int = Field(7)
|
density: int = Field(7)
|
||||||
viewType: MetricViewType = Field(MetricViewType.line_chart)
|
viewType: MetricViewType = Field(MetricViewType.line_chart)
|
||||||
|
|
||||||
class Config:
|
|
||||||
alias_generator = attribute_to_camel_case
|
|
||||||
|
|
||||||
|
|
||||||
class CustomMetricChartPayloadSchema2(CustomMetricChartPayloadSchema):
|
class CustomMetricChartPayloadSchema2(CustomMetricChartPayloadSchema):
|
||||||
metric_id: int = Field(...)
|
metric_id: int = Field(...)
|
||||||
|
|
@ -689,5 +735,9 @@ class UpdateCustomMetricsSchema(CreateCustomMetricsSchema):
|
||||||
series: List[CustomMetricUpdateSeriesSchema] = Field(..., min_items=1)
|
series: List[CustomMetricUpdateSeriesSchema] = Field(..., min_items=1)
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateCustomMetricsStatusSchema(BaseModel):
|
||||||
|
active: bool = Field(...)
|
||||||
|
|
||||||
|
|
||||||
class SavedSearchSchema(FunnelSchema):
|
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
|
package cache
|
||||||
|
|
||||||
import (
|
import (
|
||||||
. "openreplay/backend/pkg/messages"
|
. "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)
|
//duration, err := c.Conn.InsertSessionEnd(sessionID, timestamp)
|
||||||
_, err := c.Conn.InsertSessionEnd(sessionID, timestamp)
|
_, err := c.Conn.InsertSessionEnd(sessionID, timestamp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -20,7 +20,6 @@ func (c *PGCache) insertSessionEnd(sessionID uint64, timestamp uint64 ) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func (c *PGCache) InsertIssueEvent(sessionID uint64, crash *IssueEvent) error {
|
func (c *PGCache) InsertIssueEvent(sessionID uint64, crash *IssueEvent) error {
|
||||||
session, err := c.GetSession(sessionID)
|
session, err := c.GetSession(sessionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -29,7 +28,6 @@ func (c *PGCache) InsertIssueEvent(sessionID uint64, crash *IssueEvent) error {
|
||||||
return c.Conn.InsertIssueEvent(sessionID, session.ProjectID, crash)
|
return c.Conn.InsertIssueEvent(sessionID, session.ProjectID, crash)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func (c *PGCache) InsertUserID(sessionID uint64, userID *IOSUserID) error {
|
func (c *PGCache) InsertUserID(sessionID uint64, userID *IOSUserID) error {
|
||||||
if err := c.Conn.InsertIOSUserID(sessionID, userID); err != nil {
|
if err := c.Conn.InsertIOSUserID(sessionID, userID); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -38,7 +36,7 @@ func (c *PGCache) InsertUserID(sessionID uint64, userID *IOSUserID) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
session.UserID = userID.Value
|
session.UserID = &userID.Value
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -69,11 +67,9 @@ func (c *PGCache) InsertMetadata(sessionID uint64, metadata *Metadata) error {
|
||||||
if keyNo == 0 {
|
if keyNo == 0 {
|
||||||
// insert project metadata
|
// insert project metadata
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.Conn.InsertMetadata(sessionID, keyNo, metadata.Value); err != nil {
|
if err := c.Conn.InsertMetadata(sessionID, keyNo, metadata.Value); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
session.SetMetadata(keyNo, metadata.Value)
|
session.SetMetadata(keyNo, metadata.Value)
|
||||||
return nil
|
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
|
package cache
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
. "openreplay/backend/pkg/messages"
|
|
||||||
. "openreplay/backend/pkg/db/types"
|
. "openreplay/backend/pkg/db/types"
|
||||||
|
. "openreplay/backend/pkg/messages"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
func (c *PGCache) InsertWebSessionStart(sessionID uint64, s *SessionStart) error {
|
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!")
|
return errors.New("This session already in cache!")
|
||||||
}
|
}
|
||||||
c.sessions[ sessionID ] = &Session{
|
c.sessions[sessionID] = &Session{
|
||||||
SessionID: sessionID,
|
SessionID: sessionID,
|
||||||
Platform: "web",
|
Platform: "web",
|
||||||
Timestamp: s.Timestamp,
|
Timestamp: s.Timestamp,
|
||||||
ProjectID: uint32(s.ProjectID),
|
ProjectID: uint32(s.ProjectID),
|
||||||
TrackerVersion: s.TrackerVersion,
|
TrackerVersion: s.TrackerVersion,
|
||||||
RevID: s.RevID,
|
RevID: s.RevID,
|
||||||
UserUUID: s.UserUUID,
|
UserUUID: s.UserUUID,
|
||||||
UserOS: s.UserOS,
|
UserOS: s.UserOS,
|
||||||
UserOSVersion: s.UserOSVersion,
|
UserOSVersion: s.UserOSVersion,
|
||||||
UserDevice: s.UserDevice,
|
UserDevice: s.UserDevice,
|
||||||
UserCountry: s.UserCountry,
|
UserCountry: s.UserCountry,
|
||||||
// web properties (TODO: unite different platform types)
|
// web properties (TODO: unite different platform types)
|
||||||
UserAgent: s.UserAgent,
|
UserAgent: s.UserAgent,
|
||||||
UserBrowser: s.UserBrowser,
|
UserBrowser: s.UserBrowser,
|
||||||
UserBrowserVersion: s.UserBrowserVersion,
|
UserBrowserVersion: s.UserBrowserVersion,
|
||||||
UserDeviceType: s.UserDeviceType,
|
UserDeviceType: s.UserDeviceType,
|
||||||
UserDeviceMemorySize: s.UserDeviceMemorySize,
|
UserDeviceMemorySize: s.UserDeviceMemorySize,
|
||||||
UserDeviceHeapSize: s.UserDeviceHeapSize,
|
UserDeviceHeapSize: s.UserDeviceHeapSize,
|
||||||
UserID: s.UserID,
|
UserID: &s.UserID,
|
||||||
}
|
}
|
||||||
if err := c.Conn.InsertSessionStart(sessionID, c.sessions[ sessionID ]); err != nil {
|
if err := c.Conn.InsertSessionStart(sessionID, c.sessions[sessionID]); err != nil {
|
||||||
c.sessions[ sessionID ] = nil
|
c.sessions[sessionID] = nil
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil;
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *PGCache) InsertWebSessionEnd(sessionID uint64, e *SessionEnd) error {
|
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
|
session.ErrorsCount += 1
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,17 @@ package postgres
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v4"
|
"github.com/jackc/pgx/v4"
|
||||||
"github.com/jackc/pgx/v4/pgxpool"
|
"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 {
|
type Conn struct {
|
||||||
c *pgxpool.Pool // TODO: conditional usage of Pool/Conn (use interface?)
|
c *pgxpool.Pool // TODO: conditional usage of Pool/Conn (use interface?)
|
||||||
}
|
}
|
||||||
|
|
@ -15,7 +21,8 @@ type Conn struct {
|
||||||
func NewConn(url string) *Conn {
|
func NewConn(url string) *Conn {
|
||||||
c, err := pgxpool.Connect(context.Background(), url)
|
c, err := pgxpool.Connect(context.Background(), url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalln(err)
|
log.Println(err)
|
||||||
|
log.Fatalln("pgxpool.Connect Error")
|
||||||
}
|
}
|
||||||
return &Conn{c}
|
return &Conn{c}
|
||||||
}
|
}
|
||||||
|
|
@ -26,15 +33,15 @@ func (conn *Conn) Close() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (conn *Conn) query(sql string, args ...interface{}) (pgx.Rows, 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 {
|
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 {
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
package postgres
|
package postgres
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"math"
|
"math"
|
||||||
|
|
||||||
"openreplay/backend/pkg/hashid"
|
"openreplay/backend/pkg/hashid"
|
||||||
"openreplay/backend/pkg/url"
|
|
||||||
. "openreplay/backend/pkg/messages"
|
. "openreplay/backend/pkg/messages"
|
||||||
|
"openreplay/backend/pkg/url"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TODO: change messages and replace everywhere to e.Index
|
// 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()
|
defer tx.rollback()
|
||||||
errorID := hashid.WebErrorID(projectID, e)
|
errorID := hashid.WebErrorID(projectID, e)
|
||||||
|
|
||||||
if err = tx.exec(`
|
if err = tx.exec(`
|
||||||
INSERT INTO errors
|
INSERT INTO errors
|
||||||
(error_id, project_id, source, name, message, payload)
|
(error_id, project_id, source, name, message, payload)
|
||||||
VALUES
|
VALUES
|
||||||
($1, $2, $3, $4, $5, $6)
|
($1, $2, $3, $4, $5, $6::jsonb)
|
||||||
ON CONFLICT DO NOTHING`,
|
ON CONFLICT DO NOTHING`,
|
||||||
errorID, projectID, e.Source, e.Name, e.Message, e.Payload,
|
errorID, projectID, e.Source, e.Name, e.Message, e.Payload,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
package postgres
|
package postgres
|
||||||
|
|
||||||
//import . "openreplay/backend/pkg/messages"
|
//import . "openreplay/backend/pkg/messages"
|
||||||
import . "openreplay/backend/pkg/db/types"
|
import . "openreplay/backend/pkg/db/types"
|
||||||
|
|
||||||
//import "log"
|
//import "log"
|
||||||
|
|
||||||
func (conn *Conn) GetSession(sessionID uint64) (*Session, error) {
|
func (conn *Conn) GetSession(sessionID uint64) (*Session, error) {
|
||||||
s := &Session{ SessionID: sessionID }
|
s := &Session{SessionID: sessionID}
|
||||||
var revID, userOSVersion *string
|
var revID, userOSVersion *string
|
||||||
if err := conn.queryRow(`
|
if err := conn.queryRow(`
|
||||||
SELECT platform,
|
SELECT platform,
|
||||||
|
|
@ -21,13 +22,13 @@ func (conn *Conn) GetSession(sessionID uint64) (*Session, error) {
|
||||||
`,
|
`,
|
||||||
sessionID,
|
sessionID,
|
||||||
).Scan(&s.Platform,
|
).Scan(&s.Platform,
|
||||||
&s.Duration, &s.ProjectID, &s.Timestamp,
|
&s.Duration, &s.ProjectID, &s.Timestamp,
|
||||||
&s.UserUUID, &s.UserOS, &userOSVersion,
|
&s.UserUUID, &s.UserOS, &userOSVersion,
|
||||||
&s.UserDevice, &s.UserDeviceType, &s.UserCountry,
|
&s.UserDevice, &s.UserDeviceType, &s.UserCountry,
|
||||||
&revID, &s.TrackerVersion,
|
&revID, &s.TrackerVersion,
|
||||||
&s.UserID, &s.UserAnonymousID,
|
&s.UserID, &s.UserAnonymousID,
|
||||||
&s.Metadata1, &s.Metadata2, &s.Metadata3, &s.Metadata4, &s.Metadata5,
|
&s.Metadata1, &s.Metadata2, &s.Metadata3, &s.Metadata4, &s.Metadata5,
|
||||||
&s.Metadata6, &s.Metadata7, &s.Metadata8, &s.Metadata9, &s.Metadata10); err != nil {
|
&s.Metadata6, &s.Metadata7, &s.Metadata8, &s.Metadata9, &s.Metadata10); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if userOSVersion != nil { // TODO: choose format, make f
|
if userOSVersion != nil { // TODO: choose format, make f
|
||||||
|
|
@ -35,7 +36,7 @@ func (conn *Conn) GetSession(sessionID uint64) (*Session, error) {
|
||||||
}
|
}
|
||||||
if revID != nil {
|
if revID != nil {
|
||||||
s.RevID = *revID
|
s.RevID = *revID
|
||||||
}
|
}
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -103,4 +104,4 @@ func (conn *Conn) GetSession(sessionID uint64) (*Session, error) {
|
||||||
// }
|
// }
|
||||||
// }
|
// }
|
||||||
// return list
|
// return list
|
||||||
// }
|
// }
|
||||||
|
|
|
||||||
|
|
@ -1,46 +1,47 @@
|
||||||
package types
|
package types
|
||||||
|
|
||||||
type Session struct {
|
type Session struct {
|
||||||
SessionID uint64
|
SessionID uint64
|
||||||
Timestamp uint64
|
Timestamp uint64
|
||||||
ProjectID uint32
|
ProjectID uint32
|
||||||
TrackerVersion string
|
TrackerVersion string
|
||||||
RevID string
|
RevID string
|
||||||
UserUUID string
|
UserUUID string
|
||||||
UserOS string
|
UserOS string
|
||||||
UserOSVersion string
|
UserOSVersion string
|
||||||
UserDevice string
|
UserDevice string
|
||||||
UserCountry string
|
UserCountry string
|
||||||
|
|
||||||
Duration *uint64
|
Duration *uint64
|
||||||
PagesCount int
|
PagesCount int
|
||||||
EventsCount int
|
EventsCount int
|
||||||
ErrorsCount int
|
ErrorsCount int
|
||||||
UserID string // pointer??
|
|
||||||
|
UserID *string // pointer??
|
||||||
UserAnonymousID *string
|
UserAnonymousID *string
|
||||||
Metadata1 *string
|
Metadata1 *string
|
||||||
Metadata2 *string
|
Metadata2 *string
|
||||||
Metadata3 *string
|
Metadata3 *string
|
||||||
Metadata4 *string
|
Metadata4 *string
|
||||||
Metadata5 *string
|
Metadata5 *string
|
||||||
Metadata6 *string
|
Metadata6 *string
|
||||||
Metadata7 *string
|
Metadata7 *string
|
||||||
Metadata8 *string
|
Metadata8 *string
|
||||||
Metadata9 *string
|
Metadata9 *string
|
||||||
Metadata10 *string
|
Metadata10 *string
|
||||||
|
|
||||||
Platform string
|
Platform string
|
||||||
// Only-web properties
|
// Only-web properties
|
||||||
UserAgent string
|
UserAgent string
|
||||||
UserBrowser string
|
UserBrowser string
|
||||||
UserBrowserVersion string
|
UserBrowserVersion string
|
||||||
UserDeviceType string
|
UserDeviceType string
|
||||||
UserDeviceMemorySize uint64
|
UserDeviceMemorySize uint64
|
||||||
UserDeviceHeapSize uint64
|
UserDeviceHeapSize uint64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Session) SetMetadata(keyNo uint, value string) {
|
func (s *Session) SetMetadata(keyNo uint, value string) {
|
||||||
switch (keyNo) {
|
switch keyNo {
|
||||||
case 1:
|
case 1:
|
||||||
s.Metadata1 = &value
|
s.Metadata1 = &value
|
||||||
case 2:
|
case 2:
|
||||||
|
|
@ -62,4 +63,4 @@ func (s *Session) SetMetadata(keyNo uint, value string) {
|
||||||
case 10:
|
case 10:
|
||||||
s.Metadata10 = &value
|
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)
|
aws_session, err := _session.NewSession(config)
|
||||||
if err != nil {
|
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
|
return aws_session
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ package intervals
|
||||||
|
|
||||||
const EVENTS_COMMIT_INTERVAL = 30 * 1000
|
const EVENTS_COMMIT_INTERVAL = 30 * 1000
|
||||||
const HEARTBEAT_INTERVAL = 2 * 60 * 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_PAGE_EVENT_TIMEOUT = 2 * 60 * 1000
|
||||||
const EVENTS_INPUT_EVENT_TIMEOUT = 2 * 60 * 1000
|
const EVENTS_INPUT_EVENT_TIMEOUT = 2 * 60 * 1000
|
||||||
const EVENTS_PERFORMANCE_AGGREGATION_TIMEOUT = 2 * 60 * 1000
|
const EVENTS_PERFORMANCE_AGGREGATION_TIMEOUT = 2 * 60 * 1000
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,14 @@ import (
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
|
"openreplay/backend/pkg/db/cache"
|
||||||
|
"openreplay/backend/pkg/db/postgres"
|
||||||
"openreplay/backend/pkg/env"
|
"openreplay/backend/pkg/env"
|
||||||
|
"openreplay/backend/pkg/messages"
|
||||||
"openreplay/backend/pkg/queue"
|
"openreplay/backend/pkg/queue"
|
||||||
"openreplay/backend/pkg/queue/types"
|
"openreplay/backend/pkg/queue/types"
|
||||||
"openreplay/backend/pkg/messages"
|
|
||||||
"openreplay/backend/pkg/db/postgres"
|
|
||||||
"openreplay/backend/pkg/db/cache"
|
|
||||||
"openreplay/backend/services/db/heuristics"
|
"openreplay/backend/services/db/heuristics"
|
||||||
)
|
)
|
||||||
|
|
||||||
var pg *cache.PGCache
|
var pg *cache.PGCache
|
||||||
|
|
||||||
|
|
@ -23,62 +23,62 @@ func main() {
|
||||||
log.SetFlags(log.LstdFlags | log.LUTC | log.Llongfile)
|
log.SetFlags(log.LstdFlags | log.LUTC | log.Llongfile)
|
||||||
|
|
||||||
initStats()
|
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()
|
defer pg.Close()
|
||||||
|
|
||||||
heurFinder := heuristics.NewHandler()
|
heurFinder := heuristics.NewHandler()
|
||||||
|
|
||||||
consumer := queue.NewMessageConsumer(
|
consumer := queue.NewMessageConsumer(
|
||||||
env.String("GROUP_DB"),
|
env.String("GROUP_DB"),
|
||||||
[]string{
|
[]string{
|
||||||
env.String("TOPIC_RAW_IOS"),
|
env.String("TOPIC_RAW_IOS"),
|
||||||
env.String("TOPIC_TRIGGER"),
|
env.String("TOPIC_TRIGGER"),
|
||||||
},
|
},
|
||||||
func(sessionID uint64, msg messages.Message, _ *types.Meta) {
|
func(sessionID uint64, msg messages.Message, _ *types.Meta) {
|
||||||
if err := insertMessage(sessionID, msg); err != nil {
|
if err := insertMessage(sessionID, msg); err != nil {
|
||||||
if !postgres.IsPkeyViolation(err) {
|
if !postgres.IsPkeyViolation(err) {
|
||||||
log.Printf("Message Insertion Error %v, SessionID: %v, Message: %v", err,sessionID, msg)
|
log.Printf("Message Insertion Error %v, SessionID: %v, Message: %v", err, sessionID, msg)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
session, err := pg.GetSession(sessionID)
|
session, err := pg.GetSession(sessionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Might happen due to the assets-related message TODO: log only if session is necessary for this kind of message
|
// 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)
|
log.Printf("Error on session retrieving from cache: %v, SessionID: %v, Message: %v", err, sessionID, msg)
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = insertStats(session, msg)
|
err = insertStats(session, msg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Stats Insertion Error %v; Session: %v, Message: %v", err, session, msg)
|
log.Printf("Stats Insertion Error %v; Session: %v, Message: %v", err, session, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
heurFinder.HandleMessage(session, msg)
|
heurFinder.HandleMessage(session, msg)
|
||||||
heurFinder.IterateSessionReadyMessages(sessionID, func(msg messages.Message) {
|
heurFinder.IterateSessionReadyMessages(sessionID, func(msg messages.Message) {
|
||||||
// TODO: DRY code (carefully with the return statement logic)
|
// TODO: DRY code (carefully with the return statement logic)
|
||||||
if err := insertMessage(sessionID, msg); err != nil {
|
if err := insertMessage(sessionID, msg); err != nil {
|
||||||
if !postgres.IsPkeyViolation(err) {
|
if !postgres.IsPkeyViolation(err) {
|
||||||
log.Printf("Message Insertion Error %v; Session: %v, Message %v", err, session, msg)
|
log.Printf("Message Insertion Error %v; Session: %v, Message %v", err, session, msg)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = insertStats(session, msg)
|
err = insertStats(session, msg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Stats Insertion Error %v; Session: %v, Message %v", err, session, msg)
|
log.Printf("Stats Insertion Error %v; Session: %v, Message %v", err, session, msg)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
consumer.DisableAutoCommit()
|
consumer.DisableAutoCommit()
|
||||||
|
|
||||||
sigchan := make(chan os.Signal, 1)
|
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 {
|
for {
|
||||||
select {
|
select {
|
||||||
case sig := <-sigchan:
|
case sig := <-sigchan:
|
||||||
|
|
@ -88,11 +88,11 @@ func main() {
|
||||||
case <-tick:
|
case <-tick:
|
||||||
if err := commitStats(); err != nil {
|
if err := commitStats(); err != nil {
|
||||||
log.Printf("Error on stats commit: %v", err)
|
log.Printf("Error on stats commit: %v", err)
|
||||||
}
|
}
|
||||||
// TODO?: separate stats & regular messages
|
// TODO?: separate stats & regular messages
|
||||||
if err := consumer.Commit(); err != nil {
|
if err := consumer.Commit(); err != nil {
|
||||||
log.Printf("Error on consumer commit: %v", err)
|
log.Printf("Error on consumer commit: %v", err)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
err := consumer.ConsumeNext()
|
err := consumer.ConsumeNext()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -101,4 +101,4 @@ func main() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -142,7 +142,8 @@ func main() {
|
||||||
http2.ConfigureServer(server, nil)
|
http2.ConfigureServer(server, nil)
|
||||||
go func() {
|
go func() {
|
||||||
if err := server.ListenAndServe(); err != nil {
|
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)
|
log.Printf("Server successfully started on port %v\n", HTTP_PORT)
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
package integration
|
package integration
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sync"
|
|
||||||
"fmt"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"openreplay/backend/pkg/messages"
|
|
||||||
"openreplay/backend/pkg/db/postgres"
|
"openreplay/backend/pkg/db/postgres"
|
||||||
|
"openreplay/backend/pkg/messages"
|
||||||
"openreplay/backend/pkg/utime"
|
"openreplay/backend/pkg/utime"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -135,6 +136,8 @@ func (c *client) Request() {
|
||||||
c.requestData.LastAttemptTimestamp = utime.CurrentTimestamp()
|
c.requestData.LastAttemptTimestamp = utime.CurrentTimestamp()
|
||||||
err := c.requester.Request(c)
|
err := c.requester.Request(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Println("ERRROR L139")
|
||||||
|
log.Println(err)
|
||||||
c.handleError(err)
|
c.handleError(err)
|
||||||
c.requestData.UnsuccessfullAttemptsCount++;
|
c.requestData.UnsuccessfullAttemptsCount++;
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -1,193 +1,222 @@
|
||||||
package integration
|
package integration
|
||||||
|
|
||||||
import (
|
import (
|
||||||
elasticlib "github.com/elastic/go-elasticsearch/v7"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"time"
|
b64 "encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"bytes"
|
elasticlib "github.com/elastic/go-elasticsearch/v7"
|
||||||
|
"log"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"openreplay/backend/pkg/utime"
|
|
||||||
"openreplay/backend/pkg/messages"
|
"openreplay/backend/pkg/messages"
|
||||||
|
"openreplay/backend/pkg/utime"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
type elasticsearch struct {
|
type elasticsearch struct {
|
||||||
Host string
|
Host string
|
||||||
Port json.Number
|
Port json.Number
|
||||||
ApiKeyId string //`json:"api_key_id"`
|
ApiKeyId string //`json:"api_key_id"`
|
||||||
ApiKey string //`json:"api_key"`
|
ApiKey string //`json:"api_key"`
|
||||||
Indexes string
|
Indexes string
|
||||||
}
|
}
|
||||||
|
|
||||||
type elasticsearchLog struct {
|
type elasticsearchLog struct {
|
||||||
Message string
|
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 {
|
func (es *elasticsearch) Request(c *client) error {
|
||||||
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 {
|
|
||||||
address := es.Host + ":" + es.Port.String()
|
address := es.Host + ":" + es.Port.String()
|
||||||
|
apiKey := b64.StdEncoding.EncodeToString([]byte(es.ApiKeyId + ":" + es.ApiKey))
|
||||||
cfg := elasticlib.Config{
|
cfg := elasticlib.Config{
|
||||||
Addresses: []string{
|
Addresses: []string{
|
||||||
address,
|
address,
|
||||||
},
|
},
|
||||||
Username: es.ApiKeyId,
|
//Username: es.ApiKeyId,
|
||||||
Password: es.ApiKey,
|
//Password: es.ApiKey,
|
||||||
|
APIKey: apiKey,
|
||||||
}
|
}
|
||||||
esC, err := elasticlib.NewClient(cfg)
|
esC, err := elasticlib.NewClient(cfg)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Println("Error while creating new ES client")
|
||||||
|
log.Println(err)
|
||||||
return 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 ?
|
gteTs := c.getLastMessageTimestamp() + 1000 // Sec or millisec to add ?
|
||||||
|
log.Printf("gteTs: %v ", gteTs)
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
query := map[string]interface{}{
|
query := map[string]interface{}{
|
||||||
"query": map[string]interface{}{
|
"query": map[string]interface{}{
|
||||||
"bool": map[string]interface{}{
|
"bool": map[string]interface{}{
|
||||||
"filter": []map[string]interface{}{
|
"filter": []map[string]interface{}{
|
||||||
map[string]interface{}{
|
map[string]interface{}{
|
||||||
"match": map[string]interface{} {
|
"match": map[string]interface{}{
|
||||||
"message": map[string]interface{}{
|
"message": map[string]interface{}{
|
||||||
"query": "openReplaySessionToken=", // asayer_session_id=
|
"query": "openReplaySessionToken=", // asayer_session_id=
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
map[string]interface{}{
|
map[string]interface{}{
|
||||||
"range": map[string]interface{} {
|
"range": map[string]interface{}{
|
||||||
"utc_time": map[string]interface{}{
|
"utc_time": map[string]interface{}{
|
||||||
"gte": strconv.FormatUint(gteTs, 10),
|
"gte": strconv.FormatUint(gteTs, 10),
|
||||||
"lte": "now",
|
"lte": "now",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
map[string]interface{}{
|
map[string]interface{}{
|
||||||
"term": map[string]interface{}{
|
"term": map[string]interface{}{
|
||||||
"tags": "error",
|
"tags": "error",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if err := json.NewEncoder(&buf).Encode(query); err != nil {
|
|
||||||
return fmt.Errorf("Error encoding the query: %s", err)
|
if err := json.NewEncoder(&buf).Encode(query); err != nil {
|
||||||
}
|
return fmt.Errorf("Error encoding the query: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
res, err := esC.Search(
|
res, err := esC.Search(
|
||||||
esC.Search.WithContext(context.Background()),
|
esC.Search.WithContext(context.Background()),
|
||||||
esC.Search.WithIndex(es.Indexes),
|
esC.Search.WithIndex(es.Indexes),
|
||||||
esC.Search.WithSize(1000),
|
esC.Search.WithSize(1000),
|
||||||
esC.Search.WithScroll(time.Minute * 2),
|
esC.Search.WithScroll(time.Minute*2),
|
||||||
esC.Search.WithBody(&buf),
|
esC.Search.WithBody(&buf),
|
||||||
esC.Search.WithSort("timestamp:asc"),
|
esC.Search.WithSort("utc_time:asc"),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("Error getting response: %s", err)
|
return fmt.Errorf("Error getting response: %s", err)
|
||||||
}
|
}
|
||||||
defer res.Body.Close()
|
defer res.Body.Close()
|
||||||
if res.IsError() {
|
if res.IsError() {
|
||||||
var e map[string]interface{}
|
var e map[string]interface{}
|
||||||
if err := json.NewDecoder(res.Body).Decode(&e); err != nil {
|
if err := json.NewDecoder(res.Body).Decode(&e); err != nil {
|
||||||
return fmt.Errorf("Error parsing the response body: %v", err)
|
log.Printf("Error parsing the Error response body: %v\n", err)
|
||||||
} else {
|
return fmt.Errorf("Error parsing the Error response body: %v", err)
|
||||||
return fmt.Errorf("Elasticsearch [%s] %s: %s",
|
} else {
|
||||||
res.Status(),
|
log.Printf("Elasticsearch Error [%s] %s: %s\n",
|
||||||
e["error"],//.(map[string]interface{})["type"],
|
res.Status(),
|
||||||
e["error"],//.(map[string]interface{})["reason"],
|
e["error"],
|
||||||
)
|
e["error"],
|
||||||
}
|
)
|
||||||
}
|
return fmt.Errorf("Elasticsearch Error [%s] %s: %s",
|
||||||
|
res.Status(),
|
||||||
|
e["error"],
|
||||||
|
e["error"],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for {
|
for {
|
||||||
var esResp elasticResponce
|
var esResp map[string]interface{}
|
||||||
if err := json.NewDecoder(res.Body).Decode(&esResp); err != nil {
|
if err := json.NewDecoder(res.Body).Decode(&esResp); err != nil {
|
||||||
return fmt.Errorf("Error parsing the response body: %s", err)
|
return fmt.Errorf("Error parsing the response body: %s", err)
|
||||||
}
|
// If no error, then convert response to a map[string]interface
|
||||||
if len(esResp.Hits.Hits) == 0 {
|
}
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, hit := range esResp.Hits.Hits {
|
if _, ok := esResp["hits"]; !ok {
|
||||||
var esLog elasticsearchLog
|
log.Printf("Hits not found in \n%v\n", esResp)
|
||||||
if err = json.Unmarshal(hit.Source, &esLog); err != nil {
|
break
|
||||||
c.errChan <- err
|
}
|
||||||
|
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
|
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)
|
token, err := GetToken(esLog.Message)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("Error generating token: %s\n", err)
|
||||||
c.errChan <- err
|
c.errChan <- err
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
//parsedTime, err := time.Parse(time.RFC3339, esLog.Timestamp)
|
|
||||||
//if err != nil {
|
|
||||||
// c.errChan <- err
|
|
||||||
// continue
|
|
||||||
//}
|
|
||||||
timestamp := uint64(utime.ToMilliseconds(esLog.Time))
|
timestamp := uint64(utime.ToMilliseconds(esLog.Time))
|
||||||
c.setLastMessageTimestamp(timestamp)
|
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{
|
c.evChan <- &SessionErrorEvent{
|
||||||
//SessionID: sessionID,
|
//SessionID: sessionID,
|
||||||
Token: token,
|
SessionID: sessionID,
|
||||||
|
Token: token,
|
||||||
RawErrorEvent: &messages.RawErrorEvent{
|
RawErrorEvent: &messages.RawErrorEvent{
|
||||||
Source: "elasticsearch",
|
Source: "elasticsearch",
|
||||||
Timestamp: timestamp,
|
Timestamp: timestamp,
|
||||||
Name: hit.Id, // sure?
|
Name: fmt.Sprintf("%v", docID),
|
||||||
Payload: string(hit.Source),
|
Payload: string(payload),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if _, ok := esResp["_scroll_id"]; !ok {
|
||||||
res, err = esC.Scroll(
|
log.Println("_scroll_id not found")
|
||||||
esC.Scroll.WithContext(context.Background()),
|
break
|
||||||
esC.Scroll.WithScrollID(esResp.ScrollId),
|
}
|
||||||
esC.Scroll.WithScroll(time.Minute * 2),
|
log.Println("Scrolling...")
|
||||||
)
|
scrollId := esResp["_scroll_id"]
|
||||||
if err != nil {
|
res, err = esC.Scroll(
|
||||||
return fmt.Errorf("Error getting scroll response: %s", err)
|
esC.Scroll.WithContext(context.Background()),
|
||||||
}
|
esC.Scroll.WithScrollID(fmt.Sprintf("%v", scrollId)),
|
||||||
defer res.Body.Close()
|
esC.Scroll.WithScroll(time.Minute*2),
|
||||||
if res.IsError() {
|
)
|
||||||
var e map[string]interface{}
|
if err != nil {
|
||||||
if err := json.NewDecoder(res.Body).Decode(&e); err != nil {
|
return fmt.Errorf("Error getting scroll response: %s", err)
|
||||||
return fmt.Errorf("Error parsing the response body: %v", err)
|
}
|
||||||
} else {
|
defer res.Body.Close()
|
||||||
return fmt.Errorf("Elasticsearch [%s] %s: %s",
|
if res.IsError() {
|
||||||
res.Status(),
|
var e map[string]interface{}
|
||||||
e["error"],//.(map[string]interface{})["type"],
|
if err := json.NewDecoder(res.Body).Decode(&e); err != nil {
|
||||||
e["error"],//.(map[string]interface{})["reason"],
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,11 @@ import (
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
|
"openreplay/backend/pkg/db/postgres"
|
||||||
"openreplay/backend/pkg/env"
|
"openreplay/backend/pkg/env"
|
||||||
"openreplay/backend/pkg/intervals"
|
"openreplay/backend/pkg/intervals"
|
||||||
"openreplay/backend/pkg/messages"
|
"openreplay/backend/pkg/messages"
|
||||||
"openreplay/backend/pkg/queue"
|
"openreplay/backend/pkg/queue"
|
||||||
"openreplay/backend/pkg/db/postgres"
|
|
||||||
"openreplay/backend/pkg/token"
|
"openreplay/backend/pkg/token"
|
||||||
"openreplay/backend/services/integrations/clientManager"
|
"openreplay/backend/services/integrations/clientManager"
|
||||||
)
|
)
|
||||||
|
|
@ -42,12 +42,13 @@ func main() {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
producer:= queue.NewProducer()
|
producer := queue.NewProducer()
|
||||||
defer producer.Close(15000)
|
defer producer.Close(15000)
|
||||||
|
|
||||||
listener, err := postgres.NewIntegrationsListener(POSTGRES_STRING)
|
listener, err := postgres.NewIntegrationsListener(POSTGRES_STRING)
|
||||||
if err != nil {
|
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()
|
defer listener.Close()
|
||||||
|
|
||||||
|
|
@ -66,10 +67,10 @@ func main() {
|
||||||
pg.Close()
|
pg.Close()
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
case <-tick:
|
case <-tick:
|
||||||
// log.Printf("Requesting all...\n")
|
log.Printf("Requesting all...\n")
|
||||||
manager.RequestAll()
|
manager.RequestAll()
|
||||||
case event := <-manager.Events:
|
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
|
sessionID := event.SessionID
|
||||||
if sessionID == 0 {
|
if sessionID == 0 {
|
||||||
sessData, err := tokenizer.Parse(event.Token)
|
sessData, err := tokenizer.Parse(event.Token)
|
||||||
|
|
@ -83,13 +84,19 @@ func main() {
|
||||||
producer.Produce(TOPIC_RAW_WEB, sessionID, messages.Encode(event.RawErrorEvent))
|
producer.Produce(TOPIC_RAW_WEB, sessionID, messages.Encode(event.RawErrorEvent))
|
||||||
case err := <-manager.Errors:
|
case err := <-manager.Errors:
|
||||||
log.Printf("Integration error: %v\n", err)
|
log.Printf("Integration error: %v\n", err)
|
||||||
|
listener.Close()
|
||||||
|
pg.Close()
|
||||||
|
os.Exit(0)
|
||||||
case i := <-manager.RequestDataUpdates:
|
case i := <-manager.RequestDataUpdates:
|
||||||
// log.Printf("Last request integration update: %v || %v\n", i, string(i.RequestData))
|
// log.Printf("Last request integration update: %v || %v\n", i, string(i.RequestData))
|
||||||
if err := pg.UpdateIntegrationRequestData(&i); err != nil {
|
if err := pg.UpdateIntegrationRequestData(&i); err != nil {
|
||||||
log.Printf("Postgres Update request_data error: %v\n", err)
|
log.Printf("Postgres Update request_data error: %v\n", err)
|
||||||
}
|
}
|
||||||
case err := <-listener.Errors:
|
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:
|
case iPointer := <-listener.Integrations:
|
||||||
log.Printf("Integration update: %v\n", *iPointer)
|
log.Printf("Integration update: %v\n", *iPointer)
|
||||||
err := manager.Update(iPointer)
|
err := manager.Update(iPointer)
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,8 @@ jwt_algorithm=HS512
|
||||||
jwt_exp_delta_seconds=2592000
|
jwt_exp_delta_seconds=2592000
|
||||||
jwt_issuer=openreplay-default-ee
|
jwt_issuer=openreplay-default-ee
|
||||||
jwt_secret="SET A RANDOM STRING HERE"
|
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_dbname=postgres
|
||||||
pg_host=postgresql.db.svc.cluster.local
|
pg_host=postgresql.db.svc.cluster.local
|
||||||
pg_password=asayerPostgres
|
pg_password=asayerPostgres
|
||||||
|
|
|
||||||
|
|
@ -509,7 +509,8 @@ def search(data, project_id, user_id, flows=False, status="ALL", favorite_only=F
|
||||||
FROM errors
|
FROM errors
|
||||||
WHERE {" AND ".join(ch_sub_query)}
|
WHERE {" AND ".join(ch_sub_query)}
|
||||||
GROUP BY error_id, name, message
|
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
|
FROM errors
|
||||||
GROUP BY error_id) AS time_details
|
GROUP BY error_id) AS time_details
|
||||||
ON details.error_id=time_details.error_id
|
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}
|
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):
|
def get_by_email_only(email):
|
||||||
with pg_client.PostgresClient() as cur:
|
with pg_client.PostgresClient() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ type Consumer struct {
|
||||||
pollTimeout uint
|
pollTimeout uint
|
||||||
|
|
||||||
lastKafkaEventTs int64
|
lastKafkaEventTs int64
|
||||||
partitions []kafka.TopicPartition
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewConsumer(group string, topics []string, messageHandler types.MessageHandler) *Consumer {
|
func NewConsumer(group string, topics []string, messageHandler types.MessageHandler) *Consumer {
|
||||||
|
|
@ -72,13 +71,15 @@ func (consumer *Consumer) Commit() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (consumer *Consumer) CommitBack(gap int64) error {
|
func (consumer *Consumer) CommitAtTimestamp(commitTs int64) error {
|
||||||
if consumer.lastKafkaEventTs == 0 || consumer.partitions == nil {
|
assigned, err := consumer.c.Assignment()
|
||||||
return nil
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
commitTs := consumer.lastKafkaEventTs - gap
|
logPartitions("Actually assigned:", assigned)
|
||||||
|
|
||||||
var timestamps []kafka.TopicPartition
|
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)
|
p.Offset = kafka.Offset(commitTs)
|
||||||
timestamps = append(timestamps, p)
|
timestamps = append(timestamps, p)
|
||||||
}
|
}
|
||||||
|
|
@ -86,13 +87,41 @@ func (consumer *Consumer) CommitBack(gap int64) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "Kafka Consumer back commit error")
|
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
|
// TODO: check per-partition errors: offsets[i].Error
|
||||||
// As an option: can store offsets and enable autocommit instead
|
|
||||||
_, err = consumer.c.CommitOffsets(offsets)
|
_, err = consumer.c.CommitOffsets(offsets)
|
||||||
return errors.Wrap(err, "Kafka Consumer back commit error")
|
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 {
|
func (consumer *Consumer) ConsumeNext() error {
|
||||||
ev := consumer.c.Poll(int(consumer.pollTimeout))
|
ev := consumer.c.Poll(int(consumer.pollTimeout))
|
||||||
if ev == nil {
|
if ev == nil {
|
||||||
|
|
@ -117,14 +146,15 @@ func (consumer *Consumer) ConsumeNext() error {
|
||||||
Timestamp: ts,
|
Timestamp: ts,
|
||||||
})
|
})
|
||||||
consumer.lastKafkaEventTs = ts
|
consumer.lastKafkaEventTs = ts
|
||||||
case kafka.AssignedPartitions:
|
// case kafka.AssignedPartitions:
|
||||||
logPartitions("Kafka Consumer: Partitions Assigned", e.Partitions)
|
// logPartitions("Kafka Consumer: Partitions Assigned", e.Partitions)
|
||||||
consumer.partitions = e.Partitions
|
// consumer.partitions = e.Partitions
|
||||||
consumer.c.Assign(e.Partitions)
|
// consumer.c.Assign(e.Partitions)
|
||||||
case kafka.RevokedPartitions:
|
// log.Printf("Actually partitions assigned!")
|
||||||
log.Println("Kafka Cosumer: Partitions Revoked")
|
// case kafka.RevokedPartitions:
|
||||||
consumer.partitions = nil
|
// log.Println("Kafka Cosumer: Partitions Revoked")
|
||||||
consumer.c.Unassign()
|
// consumer.partitions = nil
|
||||||
|
// consumer.c.Unassign()
|
||||||
case kafka.Error:
|
case kafka.Error:
|
||||||
if e.Code() == kafka.ErrAllBrokersDown {
|
if e.Code() == kafka.ErrAllBrokersDown {
|
||||||
os.Exit(1)
|
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',
|
'/integration/sources',
|
||||||
'/issue_types',
|
'/issue_types',
|
||||||
'/sample_rate',
|
'/sample_rate',
|
||||||
'/flows',
|
'/saved_search',
|
||||||
'/rehydrations',
|
'/rehydrations',
|
||||||
'/sourcemaps',
|
'/sourcemaps',
|
||||||
'/errors',
|
'/errors',
|
||||||
'/funnels',
|
'/funnels',
|
||||||
'/assist',
|
'/assist',
|
||||||
'/heatmaps'
|
'/heatmaps',
|
||||||
|
'/custom_metrics',
|
||||||
|
// '/custom_metrics/sessions',
|
||||||
];
|
];
|
||||||
|
|
||||||
const noStoringFetchPathStarts = [
|
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 { Button, Dropdown, Form, Input, SegmentSelection, Checkbox, Message, Link, Icon } from 'UI';
|
||||||
import { alertMetrics as metrics } from 'App/constants';
|
import { alertMetrics as metrics } from 'App/constants';
|
||||||
import { alertConditions as conditions } from 'App/constants';
|
import { alertConditions as conditions } from 'App/constants';
|
||||||
|
|
@ -8,6 +8,7 @@ import stl from './alertForm.css';
|
||||||
import DropdownChips from './DropdownChips';
|
import DropdownChips from './DropdownChips';
|
||||||
import { validateEmail } from 'App/validate';
|
import { validateEmail } from 'App/validate';
|
||||||
import cn from 'classnames';
|
import cn from 'classnames';
|
||||||
|
import { fetchTriggerOptions } from 'Duck/alerts';
|
||||||
|
|
||||||
const thresholdOptions = [
|
const thresholdOptions = [
|
||||||
{ text: '15 minutes', value: 15 },
|
{ text: '15 minutes', value: 15 },
|
||||||
|
|
@ -46,11 +47,15 @@ const Section = ({ index, title, description, content }) => (
|
||||||
const integrationsRoute = client(CLIENT_TABS.INTEGRATIONS);
|
const integrationsRoute = client(CLIENT_TABS.INTEGRATIONS);
|
||||||
|
|
||||||
const AlertForm = props => {
|
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 write = ({ target: { value, name } }) => props.edit({ [ name ]: value })
|
||||||
const writeOption = (e, { name, value }) => props.edit({ [ name ]: value });
|
const writeOption = (e, { name, value }) => props.edit({ [ name ]: value });
|
||||||
const onChangeOption = (e, { checked, name }) => props.edit({ [ name ]: checked })
|
const onChangeOption = (e, { checked, name }) => props.edit({ [ name ]: checked })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
props.fetchTriggerOptions();
|
||||||
|
}, [])
|
||||||
|
|
||||||
const writeQueryOption = (e, { name, value }) => {
|
const writeQueryOption = (e, { name, value }) => {
|
||||||
const { query } = instance;
|
const { query } = instance;
|
||||||
props.edit({ query: { ...query, [name] : value } });
|
props.edit({ query: { ...query, [name] : value } });
|
||||||
|
|
@ -61,13 +66,12 @@ const AlertForm = props => {
|
||||||
props.edit({ query: { ...query, [name] : value } });
|
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 unit = metric ? metric.unit : '';
|
||||||
const isThreshold = instance.detectionMethod === 'threshold';
|
const isThreshold = instance.detectionMethod === 'threshold';
|
||||||
|
|
||||||
|
|
||||||
return (
|
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')}>
|
<div className={cn(stl.content, '-mx-6 px-6 pb-12')}>
|
||||||
<input
|
<input
|
||||||
autoFocus={ true }
|
autoFocus={ true }
|
||||||
|
|
@ -135,7 +139,7 @@ const AlertForm = props => {
|
||||||
placeholder="Select Metric"
|
placeholder="Select Metric"
|
||||||
selection
|
selection
|
||||||
search
|
search
|
||||||
options={ metrics }
|
options={ triggerOptions }
|
||||||
name="left"
|
name="left"
|
||||||
value={ instance.query.left }
|
value={ instance.query.left }
|
||||||
onChange={ writeQueryOption }
|
onChange={ writeQueryOption }
|
||||||
|
|
@ -327,6 +331,7 @@ const AlertForm = props => {
|
||||||
|
|
||||||
export default connect(state => ({
|
export default connect(state => ({
|
||||||
instance: state.getIn(['alerts', 'instance']),
|
instance: state.getIn(['alerts', 'instance']),
|
||||||
|
triggerOptions: state.getIn(['alerts', 'triggerOptions']),
|
||||||
loading: state.getIn(['alerts', 'saveRequest', 'loading']),
|
loading: state.getIn(['alerts', 'saveRequest', 'loading']),
|
||||||
deleting: state.getIn(['alerts', 'removeRequest', '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 {
|
.wrapper {
|
||||||
height: 100vh;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@ import cn from 'classnames'
|
||||||
import { toggleChatWindow } from 'Duck/sessions';
|
import { toggleChatWindow } from 'Duck/sessions';
|
||||||
import { connectPlayer } from 'Player/store';
|
import { connectPlayer } from 'Player/store';
|
||||||
import ChatWindow from '../../ChatWindow';
|
import ChatWindow from '../../ChatWindow';
|
||||||
import { callPeer } from 'Player'
|
import { callPeer, requestReleaseRemoteControl } from 'Player'
|
||||||
import { CallingState, ConnectionStatus } from 'Player/MessageDistributor/managers/AssistManager';
|
import { CallingState, ConnectionStatus, RemoteControlStatus } from 'Player/MessageDistributor/managers/AssistManager';
|
||||||
import RequestLocalStream from 'Player/MessageDistributor/managers/LocalStream';
|
import RequestLocalStream from 'Player/MessageDistributor/managers/LocalStream';
|
||||||
import type { LocalStream } from 'Player/MessageDistributor/managers/LocalStream';
|
import type { LocalStream } from 'Player/MessageDistributor/managers/LocalStream';
|
||||||
|
|
||||||
|
|
@ -32,15 +32,15 @@ interface Props {
|
||||||
toggleChatWindow: (state) => void,
|
toggleChatWindow: (state) => void,
|
||||||
calling: CallingState,
|
calling: CallingState,
|
||||||
peerConnectionStatus: ConnectionStatus,
|
peerConnectionStatus: ConnectionStatus,
|
||||||
remoteControlEnabled: boolean,
|
remoteControlStatus: RemoteControlStatus,
|
||||||
hasPermission: boolean,
|
hasPermission: boolean,
|
||||||
isEnterprise: 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 [ incomeStream, setIncomeStream ] = useState<MediaStream | null>(null);
|
||||||
const [ localStream, setLocalStream ] = useState<LocalStream | 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(() => {
|
useEffect(() => {
|
||||||
return callObject?.end()
|
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)
|
const cannotCall = (peerConnectionStatus !== ConnectionStatus.Connected) || (isEnterprise && !hasPermission)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -86,19 +86,18 @@ function AssistActions({ toggleChatWindow, userId, calling, peerConnectionStatus
|
||||||
className={
|
className={
|
||||||
cn(
|
cn(
|
||||||
'cursor-pointer p-2 mr-2 flex items-center',
|
'cursor-pointer p-2 mr-2 flex items-center',
|
||||||
// {[stl.inCall] : inCall },
|
|
||||||
{[stl.disabled]: cannotCall}
|
{[stl.disabled]: cannotCall}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
onClick={ inCall ? callObject?.end : confirmCall}
|
onClick={ onCall ? callObject?.end : confirmCall}
|
||||||
role="button"
|
role="button"
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
name="headset"
|
name="headset"
|
||||||
size="20"
|
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>
|
</div>
|
||||||
}
|
}
|
||||||
content={ cannotCall ? "You don’t have the permissions to perform this action." : `Call ${userId ? userId : 'User'}` }
|
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
|
inverted
|
||||||
position="top right"
|
position="top right"
|
||||||
/>
|
/>
|
||||||
{ calling === CallingState.True &&
|
<div
|
||||||
<div
|
className={
|
||||||
className={
|
cn(
|
||||||
cn(
|
'cursor-pointer p-2 mr-2 flex items-center',
|
||||||
'cursor-pointer p-2 mr-2 flex items-center',
|
)
|
||||||
)
|
}
|
||||||
}
|
onClick={ requestReleaseRemoteControl }
|
||||||
onClick={ callObject?.toggleRemoteControl }
|
role="button"
|
||||||
role="button"
|
>
|
||||||
>
|
<Icon
|
||||||
<Icon
|
name="remote-control"
|
||||||
name="remote-control"
|
size="20"
|
||||||
size="20"
|
color={ remoteControlStatus === RemoteControlStatus.Enabled ? "green" : "gray-darkest"}
|
||||||
color={ remoteControlEnabled ? "green" : "gray-darkest"}
|
/>
|
||||||
/>
|
<span className={cn("ml-2", { 'color-green' : remoteControlStatus === RemoteControlStatus.Enabled })}>{ 'Remote Control' }</span>
|
||||||
<span className={cn("ml-2", { 'color-green' : remoteControlEnabled })}>{ 'Remote Control' }</span>
|
</div>
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<div className="fixed ml-3 left-0 top-0" style={{ zIndex: 999 }}>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -141,6 +138,6 @@ const con = connect(state => {
|
||||||
|
|
||||||
export default con(connectPlayer(state => ({
|
export default con(connectPlayer(state => ({
|
||||||
calling: state.calling,
|
calling: state.calling,
|
||||||
remoteControlEnabled: state.remoteControl,
|
remoteControlStatus: state.remoteControl,
|
||||||
peerConnectionStatus: state.peerConnectionStatus,
|
peerConnectionStatus: state.peerConnectionStatus,
|
||||||
}))(AssistActions))
|
}))(AssistActions))
|
||||||
|
|
|
||||||
|
|
@ -13,17 +13,21 @@ const AssistTabs = (props: Props) => {
|
||||||
return (
|
return (
|
||||||
<div className="relative mr-4">
|
<div className="relative mr-4">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div
|
{props.userId && (
|
||||||
className={stl.btnLink}
|
<>
|
||||||
onClick={() => setShowMenu(!showMenu)}
|
<div
|
||||||
>
|
className={stl.btnLink}
|
||||||
More Live Sessions
|
onClick={() => setShowMenu(!showMenu)}
|
||||||
</div>
|
>
|
||||||
<span className="mx-3 color-gray-medium">by</span>
|
More Live Sessions
|
||||||
<div className="flex items-center">
|
</div>
|
||||||
<Icon name="user-alt" color="gray-darkest" />
|
<span className="mx-3 color-gray-medium">by</span>
|
||||||
<div className="ml-2">{props.userId}</div>
|
<div className="flex items-center">
|
||||||
</div>
|
<Icon name="user-alt" color="gray-darkest" />
|
||||||
|
<div className="ml-2">{props.userId}</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<SlideModal
|
<SlideModal
|
||||||
title={ <div>Live Sessions by {props.userId}</div> }
|
title={ <div>Live Sessions by {props.userId}</div> }
|
||||||
|
|
|
||||||
|
|
@ -27,9 +27,9 @@ class AttributeItem extends React.PureComponent {
|
||||||
applyFilter = debounce(this.props.applyFilter, 1000)
|
applyFilter = debounce(this.props.applyFilter, 1000)
|
||||||
fetchFilterOptionsDebounce = debounce(this.props.fetchFilterOptions, 500)
|
fetchFilterOptionsDebounce = debounce(this.props.fetchFilterOptions, 500)
|
||||||
|
|
||||||
onFilterChange = (e, { name, value }) => {
|
onFilterChange = (name, value, valueIndex) => {
|
||||||
const { index } = this.props;
|
const { index } = this.props;
|
||||||
this.props.editAttribute(index, name, value);
|
this.props.editAttribute(index, name, value, valueIndex);
|
||||||
this.applyFilter();
|
this.applyFilter();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -69,19 +69,20 @@ class AttributeItem extends React.PureComponent {
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
!filter.hasNoValue &&
|
// !filter.hasNoValue &&
|
||||||
<AttributeValueField
|
<AttributeValueField
|
||||||
filter={ filter }
|
filter={ filter }
|
||||||
options={ options }
|
options={ options }
|
||||||
onChange={ this.onFilterChange }
|
onChange={ this.onFilterChange }
|
||||||
handleSearchChange={this.handleSearchChange}
|
handleSearchChange={this.handleSearchChange}
|
||||||
loading={loadingFilterOptions}
|
loading={loadingFilterOptions}
|
||||||
|
index={index}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div className={ stl.actions }>
|
<div className={ stl.actions }>
|
||||||
<button className={ stl.button } onClick={ this.removeFilter }>
|
<button className={ stl.button } onClick={ this.removeFilter }>
|
||||||
<Icon name="close" size="16" />
|
<Icon name="close" size="14" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { LinkStyledInput, CircularLoader } from 'UI';
|
||||||
import { KEYS } from 'Types/filter/customFilter';
|
import { KEYS } from 'Types/filter/customFilter';
|
||||||
import Event, { TYPES } from 'Types/filter/event';
|
import Event, { TYPES } from 'Types/filter/event';
|
||||||
import CustomFilter from 'Types/filter/customFilter';
|
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 DurationFilter from '../DurationFilter/DurationFilter';
|
||||||
import AutoComplete from '../AutoComplete';
|
import AutoComplete from '../AutoComplete';
|
||||||
|
|
||||||
|
|
@ -24,6 +24,7 @@ const getHeader = (type) => {
|
||||||
addCustomFilter,
|
addCustomFilter,
|
||||||
removeCustomFilter,
|
removeCustomFilter,
|
||||||
applyFilter,
|
applyFilter,
|
||||||
|
updateValue,
|
||||||
})
|
})
|
||||||
class AttributeValueField extends React.PureComponent {
|
class AttributeValueField extends React.PureComponent {
|
||||||
state = {
|
state = {
|
||||||
|
|
@ -134,25 +135,46 @@ class AttributeValueField extends React.PureComponent {
|
||||||
return params;
|
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() {
|
render() {
|
||||||
const { filter, onChange, onTargetChange } = this.props;
|
// const { filter, onChange } = this.props;
|
||||||
|
const { filter } = this.props;
|
||||||
const _showAutoComplete = this.isAutoComplete(filter.type);
|
const _showAutoComplete = this.isAutoComplete(filter.type);
|
||||||
const _params = _showAutoComplete ? this.getParams(filter) : {};
|
const _params = _showAutoComplete ? this.getParams(filter) : {};
|
||||||
let _optionsEndpoint= '/events/search';
|
let _optionsEndpoint= '/events/search';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
{ _showAutoComplete ?
|
{ _showAutoComplete ? filter.value.map((v, i) => (
|
||||||
<AutoComplete
|
<AutoComplete
|
||||||
name={ 'value' }
|
name={ 'value' }
|
||||||
endpoint={ _optionsEndpoint }
|
endpoint={ _optionsEndpoint }
|
||||||
value={ filter.value }
|
value={ v }
|
||||||
|
index={ i }
|
||||||
params={ _params }
|
params={ _params }
|
||||||
optionMapping={this.optionMapping}
|
optionMapping={this.optionMapping}
|
||||||
onSelect={ onChange }
|
onSelect={ (e, { name, value }) => onChange(name, value, i) }
|
||||||
headerText={ <h5 className={ stl.header }>{ getHeader(filter.type) }</h5> }
|
headerText={ <h5 className={ stl.header }>{ getHeader(filter.type) }</h5> }
|
||||||
fullWidth={ (filter.type === TYPES.CONSOLE || filter.type === TYPES.LOCATION || filter.type === TYPES.CUSTOM) && filter.value }
|
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()
|
: this.renderField()
|
||||||
}
|
}
|
||||||
{ filter.type === 'INPUT' &&
|
{ filter.type === 'INPUT' &&
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import APIClient from 'App/api_client';
|
import APIClient from 'App/api_client';
|
||||||
import cn from 'classnames';
|
import cn from 'classnames';
|
||||||
import { Input } from 'UI';
|
import { Input, Icon } from 'UI';
|
||||||
import { debounce } from 'App/utils';
|
import { debounce } from 'App/utils';
|
||||||
import OutsideClickDetectingDiv from 'Shared/OutsideClickDetectingDiv';
|
import OutsideClickDetectingDiv from 'Shared/OutsideClickDetectingDiv';
|
||||||
|
import EventSearchInput from 'Shared/EventSearchInput';
|
||||||
import stl from './autoComplete.css';
|
import stl from './autoComplete.css';
|
||||||
import FilterItem from '../CustomFilters/FilterItem';
|
import FilterItem from '../CustomFilters/FilterItem';
|
||||||
|
|
||||||
|
|
@ -78,7 +79,7 @@ class AutoComplete extends React.PureComponent {
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
onInputChange = (e, { name, value }) => {
|
onInputChange = ({ target: { value } }) => {
|
||||||
changed = true;
|
changed = true;
|
||||||
this.setState({ query: value, updated: true })
|
this.setState({ query: value, updated: true })
|
||||||
const _value = value.trim();
|
const _value = value.trim();
|
||||||
|
|
@ -118,23 +119,53 @@ class AutoComplete extends React.PureComponent {
|
||||||
valueToText = defaultValueToText,
|
valueToText = defaultValueToText,
|
||||||
placeholder = 'Type to search...',
|
placeholder = 'Type to search...',
|
||||||
headerText = '',
|
headerText = '',
|
||||||
fullWidth = false
|
fullWidth = false,
|
||||||
|
onRemoveValue = () => {},
|
||||||
|
onAddValue = () => {},
|
||||||
|
showCloseButton = false,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
const options = optionMapping(values, valueToText)
|
const options = optionMapping(values, valueToText)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OutsideClickDetectingDiv
|
<OutsideClickDetectingDiv
|
||||||
className={ cn("relative", { "flex-1" : fullWidth }) }
|
className={ cn("relative flex items-center", { "flex-1" : fullWidth }) }
|
||||||
onClickOutside={this.onClickOutside}
|
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 }) }
|
className={ cn(stl.searchInput, { [ stl.fullWidth] : fullWidth }) }
|
||||||
onChange={ this.onInputChange }
|
onChange={ this.onInputChange }
|
||||||
onBlur={ this.onBlur }
|
onBlur={ this.onBlur }
|
||||||
onFocus={ () => this.setState({ddOpen: true})}
|
onFocus={ () => this.setState({ddOpen: true})}
|
||||||
value={ query }
|
value={ query }
|
||||||
icon="search"
|
// icon="search"
|
||||||
|
label={{ basic: true, content: <div>test</div> }}
|
||||||
|
labelPosition='right'
|
||||||
loading={ loading }
|
loading={ loading }
|
||||||
autoFocus={ true }
|
autoFocus={ true }
|
||||||
type="search"
|
type="search"
|
||||||
|
|
@ -144,7 +175,7 @@ class AutoComplete extends React.PureComponent {
|
||||||
this.hiddenInput.value = text;
|
this.hiddenInput.value = text;
|
||||||
pasted = true; // to use only the hidden input
|
pasted = true; // to use only the hidden input
|
||||||
} }
|
} }
|
||||||
/>
|
/> */}
|
||||||
<textarea style={hiddenStyle} ref={(ref) => this.hiddenInput = ref }></textarea>
|
<textarea style={hiddenStyle} ref={(ref) => this.hiddenInput = ref }></textarea>
|
||||||
{ ddOpen && options.length > 0 &&
|
{ ddOpen && options.length > 0 &&
|
||||||
<div className={ stl.menu }>
|
<div className={ stl.menu }>
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,13 @@
|
||||||
color: $gray-darkest !important;
|
color: $gray-darkest !important;
|
||||||
font-size: 14px !important;
|
font-size: 14px !important;
|
||||||
background-color: rgba(255, 255, 255, 0.8) !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;
|
height: 28px !important;
|
||||||
width: 280px;
|
width: 280px;
|
||||||
|
|
@ -28,3 +35,30 @@
|
||||||
.fullWidth {
|
.fullWidth {
|
||||||
width: 100% !important;
|
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 { fetchList as fetchFunnelsList } from 'Duck/funnels';
|
||||||
import { defaultFilters, preloadedFilters } from 'Types/filter';
|
import { defaultFilters, preloadedFilters } from 'Types/filter';
|
||||||
import { KEYS } from 'Types/filter/customFilter';
|
import { KEYS } from 'Types/filter/customFilter';
|
||||||
import EventFilter from './EventFilter';
|
|
||||||
import SessionList from './SessionList';
|
import SessionList from './SessionList';
|
||||||
import FunnelList from 'Components/Funnels/FunnelList';
|
|
||||||
import stl from './bugFinder.css';
|
import stl from './bugFinder.css';
|
||||||
import { fetchList as fetchSiteList } from 'Duck/site';
|
import { fetchList as fetchSiteList } from 'Duck/site';
|
||||||
import withLocationHandlers from "HOCs/withLocationHandlers";
|
import withLocationHandlers from "HOCs/withLocationHandlers";
|
||||||
|
|
@ -20,13 +18,17 @@ import { fetchList as fetchIntegrationVariables, fetchSources } from 'Duck/custo
|
||||||
import { RehydrateSlidePanel } from './WatchDogs/components';
|
import { RehydrateSlidePanel } from './WatchDogs/components';
|
||||||
import { setActiveTab, setFunnelPage } from 'Duck/sessions';
|
import { setActiveTab, setFunnelPage } from 'Duck/sessions';
|
||||||
import SessionsMenu from './SessionsMenu/SessionsMenu';
|
import SessionsMenu from './SessionsMenu/SessionsMenu';
|
||||||
import SessionFlowList from './SessionFlowList/SessionFlowList';
|
|
||||||
import { LAST_7_DAYS } from 'Types/app/period';
|
import { LAST_7_DAYS } from 'Types/app/period';
|
||||||
import { resetFunnel } from 'Duck/funnels';
|
import { resetFunnel } from 'Duck/funnels';
|
||||||
import { resetFunnelFilters } from 'Duck/funnelFilters'
|
import { resetFunnelFilters } from 'Duck/funnelFilters'
|
||||||
import NoSessionsMessage from '../shared/NoSessionsMessage';
|
import NoSessionsMessage from 'Shared/NoSessionsMessage';
|
||||||
import TrackerUpdateMessage from '../shared/TrackerUpdateMessage';
|
import TrackerUpdateMessage from 'Shared/TrackerUpdateMessage';
|
||||||
import LiveSessionList from './LiveSessionList'
|
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) => {
|
const weakEqual = (val1, val2) => {
|
||||||
if (!!val1 === false && !!val2 === false) return true;
|
if (!!val1 === false && !!val2 === false) return true;
|
||||||
|
|
@ -75,28 +77,30 @@ const allowedQueryKeys = [
|
||||||
fetchFunnelsList,
|
fetchFunnelsList,
|
||||||
resetFunnel,
|
resetFunnel,
|
||||||
resetFunnelFilters,
|
resetFunnelFilters,
|
||||||
setFunnelPage
|
setFunnelPage,
|
||||||
|
clearSearch,
|
||||||
})
|
})
|
||||||
@withPageTitle("Sessions - OpenReplay")
|
@withPageTitle("Sessions - OpenReplay")
|
||||||
export default class BugFinder extends React.PureComponent {
|
export default class BugFinder extends React.PureComponent {
|
||||||
state = {showRehydratePanel: false}
|
state = {showRehydratePanel: false}
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
// props.fetchFavoriteSessionList();
|
// 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
|
// 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(() => {
|
props.fetchIntegrationVariables().then(() => {
|
||||||
defaultFilters[5] = {
|
defaultFilters[5] = {
|
||||||
category: 'Metadata',
|
category: 'Metadata',
|
||||||
|
|
@ -123,28 +127,28 @@ export default class BugFinder extends React.PureComponent {
|
||||||
this.setState({ showRehydratePanel: !this.state.showRehydratePanel })
|
this.setState({ showRehydratePanel: !this.state.showRehydratePanel })
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchPreloadedFilters = () => {
|
// fetchPreloadedFilters = () => {
|
||||||
this.props.fetchFilterVariables('filterValues').then(function() {
|
// this.props.fetchFilterVariables('filterValues').then(function() {
|
||||||
const { filterValues } = this.props;
|
// const { filterValues } = this.props;
|
||||||
const keys = [
|
// const keys = [
|
||||||
{key: KEYS.USER_OS, label: 'OS'},
|
// {key: KEYS.USER_OS, label: 'OS'},
|
||||||
{key: KEYS.USER_BROWSER, label: 'Browser'},
|
// {key: KEYS.USER_BROWSER, label: 'Browser'},
|
||||||
{key: KEYS.USER_DEVICE, label: 'Device'},
|
// {key: KEYS.USER_DEVICE, label: 'Device'},
|
||||||
{key: KEYS.REFERRER, label: 'Referrer'},
|
// {key: KEYS.REFERRER, label: 'Referrer'},
|
||||||
{key: KEYS.USER_COUNTRY, label: 'Country'},
|
// {key: KEYS.USER_COUNTRY, label: 'Country'},
|
||||||
]
|
// ]
|
||||||
if (filterValues && filterValues.size != 0) {
|
// if (filterValues && filterValues.size != 0) {
|
||||||
keys.forEach(({key, label}) => {
|
// keys.forEach(({key, label}) => {
|
||||||
const _keyFilters = filterValues.get(key)
|
// const _keyFilters = filterValues.get(key)
|
||||||
if (key === KEYS.USER_COUNTRY) {
|
// if (key === KEYS.USER_COUNTRY) {
|
||||||
preloadedFilters.push(_keyFilters.map(item => ({label, type: key, key, value: item, actualValue: countries[item], isFilter: true})));
|
// preloadedFilters.push(_keyFilters.map(item => ({label, type: key, key, value: item, actualValue: countries[item], isFilter: true})));
|
||||||
} else {
|
// } else {
|
||||||
preloadedFilters.push(_keyFilters.map(item => ({label, type: key, key, value: item, isFilter: true})));
|
// preloadedFilters.push(_keyFilters.map(item => ({label, type: key, key, value: item, isFilter: true})));
|
||||||
}
|
// }
|
||||||
})
|
// })
|
||||||
}
|
// }
|
||||||
}.bind(this));
|
// }.bind(this));
|
||||||
}
|
// }
|
||||||
|
|
||||||
setActiveTab = tab => {
|
setActiveTab = tab => {
|
||||||
this.props.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) }>
|
<div className={cn("side-menu-margined", stl.searchWrapper) }>
|
||||||
<TrackerUpdateMessage />
|
<TrackerUpdateMessage />
|
||||||
<NoSessionsMessage />
|
<NoSessionsMessage />
|
||||||
<div
|
|
||||||
data-hidden={ activeTab === 'live' || activeTab === 'favorite' }
|
{/* Recorde Sessions */}
|
||||||
className="mb-5"
|
{ activeTab.type !== 'live' && (
|
||||||
>
|
<>
|
||||||
<EventFilter />
|
<div className="mb-5">
|
||||||
</div>
|
<MainSearchBar />
|
||||||
{ activeFlow && activeFlow.type === 'flows' && <FunnelList /> }
|
<SessionSearch />
|
||||||
{ activeTab.type !== 'live' && <SessionList onMenuItemClick={this.setActiveTab} /> }
|
</div>
|
||||||
{ activeTab.type === 'live' && <LiveSessionList /> }
|
{ 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>
|
||||||
</div>
|
</div>
|
||||||
<RehydrateSlidePanel
|
<RehydrateSlidePanel
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ export default class FilterModal extends React.PureComponent {
|
||||||
this.props.addAttribute(filter, _in >= 0 ? _in : _index);
|
this.props.addAttribute(filter, _in >= 0 ? _in : _index);
|
||||||
} else {
|
} else {
|
||||||
logger.log('Adding Event', filter)
|
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);
|
this.props.addEvent(filter, false, _index);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,10 @@
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { applyFilter } from 'Duck/filters';
|
import { applyFilter } from 'Duck/search';
|
||||||
import { fetchList as fetchFunnelsList } from 'Duck/funnels';
|
import { fetchList as fetchFunnelsList } from 'Duck/funnels';
|
||||||
import DateRangeDropdown from 'Shared/DateRangeDropdown';
|
import DateRangeDropdown from 'Shared/DateRangeDropdown';
|
||||||
|
|
||||||
@connect(state => ({
|
@connect(state => ({
|
||||||
rangeValue: state.getIn([ 'filters', 'appliedFilter', 'rangeValue' ]),
|
filter: state.getIn([ 'search', 'instance' ]),
|
||||||
startDate: state.getIn([ 'filters', 'appliedFilter', 'startDate' ]),
|
|
||||||
endDate: state.getIn([ 'filters', 'appliedFilter', 'endDate' ]),
|
|
||||||
}), {
|
}), {
|
||||||
applyFilter, fetchFunnelsList
|
applyFilter, fetchFunnelsList
|
||||||
})
|
})
|
||||||
|
|
@ -16,7 +14,8 @@ export default class DateRange extends React.PureComponent {
|
||||||
this.props.applyFilter(e)
|
this.props.applyFilter(e)
|
||||||
}
|
}
|
||||||
render() {
|
render() {
|
||||||
const { startDate, endDate, rangeValue, className } = this.props;
|
const { filter: { rangeValue, startDate, endDate }, className } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DateRangeDropdown
|
<DateRangeDropdown
|
||||||
button
|
button
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,7 @@ export default class EventEditor extends React.PureComponent {
|
||||||
<div className={ stl.actions }>
|
<div className={ stl.actions }>
|
||||||
{ dndBtn }
|
{ dndBtn }
|
||||||
<button className={ stl.button } onClick={ this.remove }>
|
<button className={ stl.button } onClick={ this.remove }>
|
||||||
<Icon name="close" size="16" />
|
<Icon name="close" size="14" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { connect } from 'react-redux';
|
||||||
import { Input } from 'semantic-ui-react';
|
import { Input } from 'semantic-ui-react';
|
||||||
import { DNDContext } from 'Components/hocs/dnd';
|
import { DNDContext } from 'Components/hocs/dnd';
|
||||||
import {
|
import {
|
||||||
addEvent, applyFilter, moveEvent, clearEvents,
|
addEvent, applyFilter, moveEvent, clearEvents, edit,
|
||||||
addCustomFilter, addAttribute, setSearchQuery, setActiveFlow, setFilterOption
|
addCustomFilter, addAttribute, setSearchQuery, setActiveFlow, setFilterOption
|
||||||
} from 'Duck/filters';
|
} from 'Duck/filters';
|
||||||
import { fetchList as fetchEventList } from 'Duck/events';
|
import { fetchList as fetchEventList } from 'Duck/events';
|
||||||
|
|
@ -11,7 +11,7 @@ import OutsideClickDetectingDiv from 'Shared/OutsideClickDetectingDiv';
|
||||||
import EventEditor from './EventEditor';
|
import EventEditor from './EventEditor';
|
||||||
import ListHeader from '../ListHeader';
|
import ListHeader from '../ListHeader';
|
||||||
import FilterModal from '../CustomFilters/FilterModal';
|
import FilterModal from '../CustomFilters/FilterModal';
|
||||||
import { IconButton } from 'UI';
|
import { IconButton, SegmentSelection } from 'UI';
|
||||||
import stl from './eventFilter.css';
|
import stl from './eventFilter.css';
|
||||||
import Attributes from '../Attributes/Attributes';
|
import Attributes from '../Attributes/Attributes';
|
||||||
import RandomPlaceholder from './RandomPlaceholder';
|
import RandomPlaceholder from './RandomPlaceholder';
|
||||||
|
|
@ -19,6 +19,7 @@ import CustomFilters from '../CustomFilters';
|
||||||
import ManageFilters from '../ManageFilters';
|
import ManageFilters from '../ManageFilters';
|
||||||
import { blink as setBlink } from 'Duck/funnels';
|
import { blink as setBlink } from 'Duck/funnels';
|
||||||
import cn from 'classnames';
|
import cn from 'classnames';
|
||||||
|
import SaveFilterButton from 'Shared/SaveFilterButton';
|
||||||
|
|
||||||
@connect(state => ({
|
@connect(state => ({
|
||||||
events: state.getIn([ 'filters', 'appliedFilter', 'events' ]),
|
events: state.getIn([ 'filters', 'appliedFilter', 'events' ]),
|
||||||
|
|
@ -41,7 +42,8 @@ import cn from 'classnames';
|
||||||
setSearchQuery,
|
setSearchQuery,
|
||||||
setActiveFlow,
|
setActiveFlow,
|
||||||
setFilterOption,
|
setFilterOption,
|
||||||
setBlink
|
setBlink,
|
||||||
|
edit,
|
||||||
})
|
})
|
||||||
@DNDContext
|
@DNDContext
|
||||||
export default class EventFilter extends React.PureComponent {
|
export default class EventFilter extends React.PureComponent {
|
||||||
|
|
@ -109,6 +111,10 @@ export default class EventFilter extends React.PureComponent {
|
||||||
this.props.setActiveFlow(null)
|
this.props.setActiveFlow(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
changeConditionTab = (e, { name, value }) => {
|
||||||
|
this.props.edit({ [ 'condition' ]: value })
|
||||||
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const {
|
const {
|
||||||
events,
|
events,
|
||||||
|
|
@ -124,34 +130,6 @@ export default class EventFilter extends React.PureComponent {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OutsideClickDetectingDiv className={ stl.wrapper } onClickOutside={ this.closeModal } >
|
<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
|
<FilterModal
|
||||||
close={ this.closeModal }
|
close={ this.closeModal }
|
||||||
displayed={ showFilterModal }
|
displayed={ showFilterModal }
|
||||||
|
|
@ -161,7 +139,24 @@ export default class EventFilter extends React.PureComponent {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{ hasFilters &&
|
{ 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 &&
|
{ events.size > 0 &&
|
||||||
<>
|
<>
|
||||||
<div className="py-1"><ListHeader title="Events" /></div>
|
<div className="py-1"><ListHeader title="Events" /></div>
|
||||||
|
|
@ -189,6 +184,7 @@ export default class EventFilter extends React.PureComponent {
|
||||||
showFilters={ true }
|
showFilters={ true }
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<SaveFilterButton />
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div>
|
<div>
|
||||||
<IconButton plain label="CLEAR STEPS" onClick={ this.clearEvents } />
|
<IconButton plain label="CLEAR STEPS" onClick={ this.clearEvents } />
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { connect } from 'react-redux';
|
||||||
import { Dropdown } from 'semantic-ui-react';
|
import { Dropdown } from 'semantic-ui-react';
|
||||||
import { Icon } from 'UI';
|
import { Icon } from 'UI';
|
||||||
import { sort } from 'Duck/sessions';
|
import { sort } from 'Duck/sessions';
|
||||||
import { applyFilter } from 'Duck/filters';
|
import { applyFilter } from 'Duck/search';
|
||||||
import stl from './sortDropdown.css';
|
import stl from './sortDropdown.css';
|
||||||
|
|
||||||
@connect(null, { sort, applyFilter })
|
@connect(null, { sort, applyFilter })
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,69 @@
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { fetchList } from 'Duck/sessions';
|
import { fetchLiveList } from 'Duck/sessions';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { NoContent, Loader } from 'UI';
|
import { NoContent, Loader, LoadMoreButton } from 'UI';
|
||||||
import { List, Map } from 'immutable';
|
import { List, Map } from 'immutable';
|
||||||
import SessionItem from 'Shared/SessionItem';
|
import SessionItem from 'Shared/SessionItem';
|
||||||
import withPermissions from 'HOCs/withPermissions'
|
import withPermissions from 'HOCs/withPermissions'
|
||||||
import { KEYS } from 'Types/filter/customFilter';
|
import { KEYS } from 'Types/filter/customFilter';
|
||||||
import { applyFilter, addAttribute } from 'Duck/filters';
|
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 AUTOREFRESH_INTERVAL = .5 * 60 * 1000
|
||||||
|
const PER_PAGE = 20;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
loading: Boolean,
|
loading: Boolean,
|
||||||
list?: List<any>,
|
list: List<any>,
|
||||||
fetchList: (params) => void,
|
fetchLiveList: () => Promise<void>,
|
||||||
applyFilter: () => void,
|
applyFilter: () => void,
|
||||||
filters: Filter
|
filters: any,
|
||||||
addAttribute: (obj) => void,
|
addAttribute: (obj) => void,
|
||||||
|
addFilterByKeyAndValue: (key: FilterKey, value: string) => void,
|
||||||
|
updateCurrentPage: (page: number) => void,
|
||||||
|
currentPage: number,
|
||||||
}
|
}
|
||||||
|
|
||||||
function LiveSessionList(props: Props) {
|
function LiveSessionList(props: Props) {
|
||||||
const { loading, list, filters } = props;
|
const { loading, filters, list, currentPage } = props;
|
||||||
var timeoutId;
|
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(() => {
|
useEffect(() => {
|
||||||
props.fetchList(filters.toJS());
|
props.fetchLiveList();
|
||||||
timeout();
|
timeout();
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(timeoutId)
|
clearTimeout(timeoutId)
|
||||||
|
|
@ -35,17 +72,15 @@ function LiveSessionList(props: Props) {
|
||||||
|
|
||||||
const onUserClick = (userId, userAnonymousId) => {
|
const onUserClick = (userId, userAnonymousId) => {
|
||||||
if (userId) {
|
if (userId) {
|
||||||
props.addAttribute({ label: 'User Id', key: KEYS.USERID, type: KEYS.USERID, operator: 'is', value: userId })
|
props.addFilterByKeyAndValue(FilterKey.USERID, userId);
|
||||||
} else {
|
} else {
|
||||||
props.addAttribute({ label: 'Anonymous ID', key: 'USERANONYMOUSID', type: "USERANONYMOUSID", operator: 'is', value: userAnonymousId })
|
props.addFilterByKeyAndValue(FilterKey.USERANONYMOUSID, userAnonymousId);
|
||||||
}
|
}
|
||||||
|
|
||||||
props.applyFilter()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeout = () => {
|
const timeout = () => {
|
||||||
timeoutId = setTimeout(() => {
|
timeoutId = setTimeout(() => {
|
||||||
props.fetchList(filters.toJS());
|
props.fetchLiveList();
|
||||||
timeout();
|
timeout();
|
||||||
}, AUTOREFRESH_INTERVAL);
|
}, AUTOREFRESH_INTERVAL);
|
||||||
}
|
}
|
||||||
|
|
@ -56,14 +91,15 @@ function LiveSessionList(props: Props) {
|
||||||
title={"No live sessions."}
|
title={"No live sessions."}
|
||||||
subtext={
|
subtext={
|
||||||
<span>
|
<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>
|
</span>
|
||||||
}
|
}
|
||||||
image={<img src="/img/live-sessions.png" style={{ width: '70%', marginBottom: '30px' }}/>}
|
image={<img src="/img/live-sessions.png"
|
||||||
show={ !loading && list && list.size === 0}
|
style={{ width: '70%', marginBottom: '30px' }}/>}
|
||||||
|
show={ !loading && sessions && sessions.size === 0}
|
||||||
>
|
>
|
||||||
<Loader loading={ loading }>
|
<Loader loading={ loading }>
|
||||||
{list && list.map(session => (
|
{sessions && sessions.take(displayedCount).map(session => (
|
||||||
<SessionItem
|
<SessionItem
|
||||||
key={ session.sessionId }
|
key={ session.sessionId }
|
||||||
session={ session }
|
session={ session }
|
||||||
|
|
@ -72,6 +108,13 @@ function LiveSessionList(props: Props) {
|
||||||
onUserClick={onUserClick}
|
onUserClick={onUserClick}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
<LoadMoreButton
|
||||||
|
className="mt-3"
|
||||||
|
displayedCount={displayedCount}
|
||||||
|
totalCount={sessions.size}
|
||||||
|
onClick={addPage}
|
||||||
|
/>
|
||||||
</Loader>
|
</Loader>
|
||||||
</NoContent>
|
</NoContent>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -82,8 +125,8 @@ export default withPermissions(['ASSIST_LIVE', 'SESSION_REPLAY'])(connect(
|
||||||
(state) => ({
|
(state) => ({
|
||||||
list: state.getIn(['sessions', 'liveSessions']),
|
list: state.getIn(['sessions', 'liveSessions']),
|
||||||
loading: state.getIn([ 'sessions', 'loading' ]),
|
loading: state.getIn([ 'sessions', 'loading' ]),
|
||||||
filters: state.getIn([ 'filters', 'appliedFilter' ]),
|
filters: state.getIn([ 'liveSearch', 'instance', 'filters' ]),
|
||||||
|
currentPage: state.getIn(["liveSearch", "currentPage"]),
|
||||||
}),
|
}),
|
||||||
{
|
{ fetchLiveList, applyFilter, addAttribute, addFilterByKeyAndValue, updateCurrentPage }
|
||||||
fetchList, applyFilter, addAttribute }
|
|
||||||
)(LiveSessionList));
|
)(LiveSessionList));
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ export default class SaveModal extends React.PureComponent {
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex="-1"
|
tabIndex="-1"
|
||||||
color="gray-dark"
|
color="gray-dark"
|
||||||
size="18"
|
size="14"
|
||||||
name="close"
|
name="close"
|
||||||
onClick={ () => toggleFilterModal(false) }
|
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 }) }>
|
<div className="flex items-center cursor-pointer" onClick={ () => this.setState({ 'isPublic' : !isPublic }) }>
|
||||||
<Icon name="user-friends" size="16" />
|
<Icon name="user-friends" size="16" />
|
||||||
<span className="ml-2"> Team Funnel</span>
|
<span className="ml-2"> Team Visible</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Form.Field>
|
</Form.Field>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import { connect } from 'react-redux';
|
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 { applyFilter, addAttribute, addEvent } from 'Duck/filters';
|
||||||
|
import { fetchSessions, addFilterByKeyAndValue } from 'Duck/search';
|
||||||
import SessionItem from 'Shared/SessionItem';
|
import SessionItem from 'Shared/SessionItem';
|
||||||
import SessionListHeader from './SessionListHeader';
|
import SessionListHeader from './SessionListHeader';
|
||||||
import { KEYS } from 'Types/filter/customFilter';
|
import { FilterKey } from 'Types/filter/filterType';
|
||||||
|
|
||||||
const ALL = 'all';
|
const ALL = 'all';
|
||||||
const PER_PAGE = 10;
|
const PER_PAGE = 10;
|
||||||
|
|
@ -17,11 +18,13 @@ var timeoutId;
|
||||||
activeTab: state.getIn([ 'sessions', 'activeTab' ]),
|
activeTab: state.getIn([ 'sessions', 'activeTab' ]),
|
||||||
allList: state.getIn([ 'sessions', 'list' ]),
|
allList: state.getIn([ 'sessions', 'list' ]),
|
||||||
total: state.getIn([ 'sessions', 'total' ]),
|
total: state.getIn([ 'sessions', 'total' ]),
|
||||||
filters: state.getIn([ 'filters', 'appliedFilter', 'filters' ]),
|
filters: state.getIn([ 'search', 'instance', 'filters' ]),
|
||||||
}), {
|
}), {
|
||||||
applyFilter,
|
applyFilter,
|
||||||
addAttribute,
|
addAttribute,
|
||||||
addEvent
|
addEvent,
|
||||||
|
fetchSessions,
|
||||||
|
addFilterByKeyAndValue,
|
||||||
})
|
})
|
||||||
export default class SessionList extends React.PureComponent {
|
export default class SessionList extends React.PureComponent {
|
||||||
state = {
|
state = {
|
||||||
|
|
@ -42,18 +45,17 @@ export default class SessionList extends React.PureComponent {
|
||||||
|
|
||||||
onUserClick = (userId, userAnonymousId) => {
|
onUserClick = (userId, userAnonymousId) => {
|
||||||
if (userId) {
|
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 {
|
} 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 = () => {
|
timeout = () => {
|
||||||
timeoutId = setTimeout(function () {
|
timeoutId = setTimeout(function () {
|
||||||
if (this.props.shouldAutorefresh) {
|
if (this.props.shouldAutorefresh) {
|
||||||
this.props.applyFilter();
|
// this.props.applyFilter();
|
||||||
|
this.props.fetchSessions();
|
||||||
}
|
}
|
||||||
this.timeout();
|
this.timeout();
|
||||||
}.bind(this), AUTOREFRESH_INTERVAL);
|
}.bind(this), AUTOREFRESH_INTERVAL);
|
||||||
|
|
@ -81,8 +83,8 @@ export default class SessionList extends React.PureComponent {
|
||||||
allList,
|
allList,
|
||||||
activeTab
|
activeTab
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
const _filterKeys = filters.map(i => i.key);
|
||||||
const hasUserFilter = filters.map(i => i.key).includes(KEYS.USERID);
|
const hasUserFilter = _filterKeys.includes(FilterKey.USERID) || _filterKeys.includes(FilterKey.USERANONYMOUSID);
|
||||||
const { showPages } = this.state;
|
const { showPages } = this.state;
|
||||||
const displayedCount = Math.min(showPages * PER_PAGE, list.size);
|
const displayedCount = Math.min(showPages * PER_PAGE, list.size);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
width: 150px;
|
width: 150px;
|
||||||
color: $gray-darkest;
|
color: $gray-darkest;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background-color: rgba(255, 255, 255, 0.8) !important;
|
background-color: rgba(0, 0, 0, 0.1) !important;
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: white;
|
background-color: white;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,11 @@ export default class AddWidgets extends React.PureComponent {
|
||||||
const { appearance } = this.props;
|
const { appearance } = this.props;
|
||||||
const newAppearance = appearance.setIn([ 'dashboard', widgetKey ], true);
|
const newAppearance = appearance.setIn([ 'dashboard', widgetKey ], true);
|
||||||
this.props.switchOpen(false);
|
this.props.switchOpen(false);
|
||||||
this.props.updateAppearance(newAppearance)
|
this.props.updateAppearance(newAppearance)
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { appearance, disabled } = this.props;
|
const { appearance } = this.props;
|
||||||
const avaliableWidgets = WIDGET_LIST.filter(({ key, type }) => !appearance.dashboard[ key ] && type === this.props.type );
|
const avaliableWidgets = WIDGET_LIST.filter(({ key, type }) => !appearance.dashboard[ key ] && type === this.props.type );
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -44,46 +44,6 @@ export default class AddWidgets extends React.PureComponent {
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</OutsideClickDetectingDiv>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,11 @@ import cn from 'classnames';
|
||||||
import withPageTitle from 'HOCs/withPageTitle';
|
import withPageTitle from 'HOCs/withPageTitle';
|
||||||
import withPermissions from 'HOCs/withPermissions'
|
import withPermissions from 'HOCs/withPermissions'
|
||||||
import { setPeriod, setPlatform, fetchMetadataOptions } from 'Duck/dashboard';
|
import { setPeriod, setPlatform, fetchMetadataOptions } from 'Duck/dashboard';
|
||||||
import { NoContent } from 'UI';
|
import { NoContent, Icon } from 'UI';
|
||||||
import { WIDGET_KEYS } from 'Types/dashboard';
|
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 {
|
import {
|
||||||
MissingResources,
|
MissingResources,
|
||||||
|
|
@ -38,14 +41,16 @@ import SideMenuSection from './SideMenu/SideMenuSection';
|
||||||
import styles from './dashboard.css';
|
import styles from './dashboard.css';
|
||||||
import WidgetSection from 'Shared/WidgetSection/WidgetSection';
|
import WidgetSection from 'Shared/WidgetSection/WidgetSection';
|
||||||
import OverviewWidgets from './Widgets/OverviewWidgets/OverviewWidgets';
|
import OverviewWidgets from './Widgets/OverviewWidgets/OverviewWidgets';
|
||||||
|
import CustomMetricsWidgets from './Widgets/CustomMetricsWidgets/CustomMetricsWidgets';
|
||||||
import WidgetHolder from './WidgetHolder/WidgetHolder';
|
import WidgetHolder from './WidgetHolder/WidgetHolder';
|
||||||
import MetricsFilters from 'Shared/MetricsFilters/MetricsFilters';
|
import MetricsFilters from 'Shared/MetricsFilters/MetricsFilters';
|
||||||
import { withRouter } from 'react-router';
|
import { withRouter } from 'react-router';
|
||||||
|
|
||||||
const OVERVIEW = 'overview';
|
const OVERVIEW = 'overview';
|
||||||
const PERFORMANCE = 'performance';
|
const PERFORMANCE = 'performance';
|
||||||
const ERRORS_N_CRASHES = 'errors_n_crashes';
|
const ERRORS_N_CRASHES = 'errors';
|
||||||
const RESOURCES = 'resources';
|
const RESOURCES = 'resources';
|
||||||
|
const CUSTOM_METRICS = 'custom_metrics';
|
||||||
|
|
||||||
const menuList = [
|
const menuList = [
|
||||||
{
|
{
|
||||||
|
|
@ -54,6 +59,13 @@ const menuList = [
|
||||||
icon: "info-square",
|
icon: "info-square",
|
||||||
label: getStatusLabel(OVERVIEW),
|
label: getStatusLabel(OVERVIEW),
|
||||||
active: status === OVERVIEW,
|
active: status === OVERVIEW,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: CUSTOM_METRICS,
|
||||||
|
section: 'metrics',
|
||||||
|
icon: "sliders",
|
||||||
|
label: getStatusLabel(CUSTOM_METRICS),
|
||||||
|
active: status === CUSTOM_METRICS,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: ERRORS_N_CRASHES,
|
key: ERRORS_N_CRASHES,
|
||||||
|
|
@ -83,6 +95,8 @@ function getStatusLabel(status) {
|
||||||
switch(status) {
|
switch(status) {
|
||||||
case OVERVIEW:
|
case OVERVIEW:
|
||||||
return "Overview";
|
return "Overview";
|
||||||
|
case CUSTOM_METRICS:
|
||||||
|
return "Custom Metrics";
|
||||||
case PERFORMANCE:
|
case PERFORMANCE:
|
||||||
return "Performance";
|
return "Performance";
|
||||||
case ERRORS_N_CRASHES:
|
case ERRORS_N_CRASHES:
|
||||||
|
|
@ -110,6 +124,8 @@ function isInViewport(el) {
|
||||||
comparing: state.getIn([ 'dashboard', 'comparing' ]),
|
comparing: state.getIn([ 'dashboard', 'comparing' ]),
|
||||||
platform: state.getIn([ 'dashboard', 'platform' ]),
|
platform: state.getIn([ 'dashboard', 'platform' ]),
|
||||||
dashboardAppearance: state.getIn([ 'user', 'account', 'appearance', 'dashboard' ]),
|
dashboardAppearance: state.getIn([ 'user', 'account', 'appearance', 'dashboard' ]),
|
||||||
|
activeWidget: state.getIn(['customMetrics', 'activeWidget']),
|
||||||
|
appearance: state.getIn([ 'user', 'account', 'appearance' ]),
|
||||||
}), { setPeriod, setPlatform, fetchMetadataOptions })
|
}), { setPeriod, setPlatform, fetchMetadataOptions })
|
||||||
@withPageTitle('Metrics - OpenReplay')
|
@withPageTitle('Metrics - OpenReplay')
|
||||||
@withRouter
|
@withRouter
|
||||||
|
|
@ -130,6 +146,10 @@ export default class Dashboard extends React.PureComponent {
|
||||||
pageSection: 'metrics',
|
pageSection: 'metrics',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
getWidgetsByKey = (widgetType) => {
|
||||||
|
return WIDGET_LIST.filter(({ key, type }) => !this.props.appearance.dashboard[ key ] && type === widgetType);
|
||||||
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
const { history, location } = this.props;
|
const { history, location } = this.props;
|
||||||
// TODO check the hash navigato it
|
// TODO check the hash navigato it
|
||||||
|
|
@ -164,7 +184,7 @@ export default class Dashboard extends React.PureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { dashboardAppearance, comparing } = this.props;
|
const { dashboardAppearance, comparing, activeWidget } = this.props;
|
||||||
const { pageSection } = this.state;
|
const { pageSection } = this.state;
|
||||||
|
|
||||||
const noWidgets = WIDGET_KEYS
|
const noWidgets = WIDGET_KEYS
|
||||||
|
|
@ -184,6 +204,8 @@ export default class Dashboard extends React.PureComponent {
|
||||||
<div>
|
<div>
|
||||||
<div className={ cn(styles.header, "flex items-center w-full") }>
|
<div className={ cn(styles.header, "flex items-center w-full") }>
|
||||||
<MetricsFilters />
|
<MetricsFilters />
|
||||||
|
|
||||||
|
{ activeWidget && <SessionListModal activeWidget={activeWidget} /> }
|
||||||
</div>
|
</div>
|
||||||
<div className="">
|
<div className="">
|
||||||
<NoContent
|
<NoContent
|
||||||
|
|
@ -193,13 +215,41 @@ export default class Dashboard extends React.PureComponent {
|
||||||
icon
|
icon
|
||||||
empty
|
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]}>
|
<div className="grid grid-cols-4 gap-4" ref={this.list[OVERVIEW]}>
|
||||||
<OverviewWidgets isOverview />
|
<OverviewWidgets isOverview />
|
||||||
</div>
|
</div>
|
||||||
</WidgetSection>
|
</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]}>
|
<div className={ cn("gap-4", { 'grid grid-cols-2' : !comparing })} ref={this.list[ERRORS_N_CRASHES]}>
|
||||||
{ dashboardAppearance.impactedSessionsByJsErrors && <WidgetHolder Component={SessionsAffectedByJSErrors} /> }
|
{ dashboardAppearance.impactedSessionsByJsErrors && <WidgetHolder Component={SessionsAffectedByJSErrors} /> }
|
||||||
{ dashboardAppearance.errorsPerDomains && <WidgetHolder Component={ErrorsPerDomain} /> }
|
{ dashboardAppearance.errorsPerDomains && <WidgetHolder Component={ErrorsPerDomain} /> }
|
||||||
|
|
@ -213,7 +263,7 @@ export default class Dashboard extends React.PureComponent {
|
||||||
</div>
|
</div>
|
||||||
</WidgetSection>
|
</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]}>
|
<div className={ cn("gap-4", { 'grid grid-cols-2' : !comparing })} ref={this.list[PERFORMANCE]}>
|
||||||
{ dashboardAppearance.speedLocation && <WidgetHolder Component={SpeedIndexLocation} /> }
|
{ dashboardAppearance.speedLocation && <WidgetHolder Component={SpeedIndexLocation} /> }
|
||||||
{ dashboardAppearance.crashes && <WidgetHolder Component={Crashes} /> }
|
{ dashboardAppearance.crashes && <WidgetHolder Component={Crashes} /> }
|
||||||
|
|
@ -233,7 +283,7 @@ export default class Dashboard extends React.PureComponent {
|
||||||
</div>
|
</div>
|
||||||
</WidgetSection>
|
</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]}>
|
<div className={ cn("gap-4", { 'grid grid-cols-2' : !comparing })} ref={this.list[RESOURCES]}>
|
||||||
{ dashboardAppearance.resourcesCountByType && <WidgetHolder Component={BreakdownOfLoadedResources} /> }
|
{ dashboardAppearance.resourcesCountByType && <WidgetHolder Component={BreakdownOfLoadedResources} /> }
|
||||||
{ dashboardAppearance.resourcesLoadingTime && <WidgetHolder Component={ResourceLoadingTime} /> }
|
{ dashboardAppearance.resourcesLoadingTime && <WidgetHolder Component={ResourceLoadingTime} /> }
|
||||||
|
|
@ -248,6 +298,8 @@ export default class Dashboard extends React.PureComponent {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<CustomMetricsModal />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import stl from './sideMenuSection.css';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
import { withSiteId } from 'App/routes';
|
import { withSiteId } from 'App/routes';
|
||||||
|
import CustomMetrics from 'Shared/CustomMetrics';
|
||||||
|
|
||||||
function SideMenuSection({ title, items, onItemClick, setShowAlerts, siteId }) {
|
function SideMenuSection({ title, items, onItemClick, setShowAlerts, siteId }) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -29,6 +30,13 @@ function SideMenuSection({ title, items, onItemClick, setShowAlerts, siteId }) {
|
||||||
onClick={() => setShowAlerts(true)}
|
onClick={() => setShowAlerts(true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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={cn(className, 'rounded p-4 bg-gray-light-shade')}>
|
||||||
<div className="mb-4 flex items-center">
|
<div className="mb-4 flex items-center">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="text-2xl mr-3">{title}</div>
|
<div className="text-2xl mr-3">{title}</div>
|
||||||
<AddWidgets type={type} />
|
{/* <AddWidgets type={type} /> */}
|
||||||
</div>
|
</div>
|
||||||
{description && <div className="ml-auto color-gray-darkest font-medium text-sm">{description}</div> }
|
{description && <div className="ml-auto color-gray-darkest font-medium text-sm">{description}</div> }
|
||||||
</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 } />
|
<YAxis hide interval={ 0 } />
|
||||||
<Area
|
<Area
|
||||||
name={tooltipLael}
|
name={tooltipLael}
|
||||||
unit={unit && ' ' + unit}
|
// unit={unit && ' ' + unit}
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
stroke={compare? Styles.compareColors[0] : Styles.colors[0]}
|
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 colorsx = ['#256669', '#38999e', '#3eaaaf', '#51b3b7', '#78c4c7', '#9fd5d7', '#c5e6e7'].reverse();
|
||||||
const compareColors = ['#394EFF', '#4D5FFF', '#808DFF', '#B3BBFF', '#E5E8FF'];
|
const compareColors = ['#394EFF', '#4D5FFF', '#808DFF', '#B3BBFF', '#E5E8FF'];
|
||||||
const compareColorsx = ["#222F99", "#2E3ECC", "#394EFF", "#6171FF", "#8895FF", "#B0B8FF", "#D7DCFF"].reverse();
|
const compareColorsx = ["#222F99", "#2E3ECC", "#394EFF", "#6171FF", "#8895FF", "#B0B8FF", "#D7DCFF"].reverse();
|
||||||
|
const customMetricColors = ['#3EAAAF', '#394EFF', '#666666'];
|
||||||
|
|
||||||
const countView = count => {
|
const countView = count => {
|
||||||
const isMoreThanK = count >= 1000;
|
const isMoreThanK = count >= 1000;
|
||||||
|
|
@ -11,6 +12,7 @@ const countView = count => {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
customMetricColors,
|
||||||
colors,
|
colors,
|
||||||
colorsx,
|
colorsx,
|
||||||
compareColors,
|
compareColors,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import styles from './funnelSaveModal.css';
|
||||||
import { edit, save, fetchList as fetchFunnelsList } from 'Duck/funnels';
|
import { edit, save, fetchList as fetchFunnelsList } from 'Duck/funnels';
|
||||||
|
|
||||||
@connect(state => ({
|
@connect(state => ({
|
||||||
|
filter: state.getIn(['search', 'instance']),
|
||||||
funnel: state.getIn(['funnels', 'instance']),
|
funnel: state.getIn(['funnels', 'instance']),
|
||||||
loading: state.getIn([ 'funnels', 'saveRequest', 'loading' ]) ||
|
loading: state.getIn([ 'funnels', 'saveRequest', 'loading' ]) ||
|
||||||
state.getIn([ 'funnels', 'updateRequest', '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 })
|
onChangeOption = (e, { checked, name }) => this.props.edit({ [ name ]: checked })
|
||||||
|
|
||||||
onSave = () => {
|
onSave = () => {
|
||||||
const { funnel, closeHandler } = this.props;
|
const { funnel, filter } = this.props;
|
||||||
if (funnel.name.trim() === '') return;
|
if (funnel.name.trim() === '') return;
|
||||||
this.props.save(funnel).then(function() {
|
this.props.save(funnel).then(function() {
|
||||||
this.props.fetchFunnelsList();
|
this.props.fetchFunnelsList();
|
||||||
|
|
@ -38,7 +39,6 @@ export default class FunnelSaveModal extends React.PureComponent {
|
||||||
render() {
|
render() {
|
||||||
const {
|
const {
|
||||||
show,
|
show,
|
||||||
appliedFilter,
|
|
||||||
closeHandler,
|
closeHandler,
|
||||||
loading,
|
loading,
|
||||||
funnel
|
funnel
|
||||||
|
|
@ -52,7 +52,7 @@ export default class FunnelSaveModal extends React.PureComponent {
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex="-1"
|
tabIndex="-1"
|
||||||
color="gray-dark"
|
color="gray-dark"
|
||||||
size="18"
|
size="14"
|
||||||
name="close"
|
name="close"
|
||||||
onClick={ closeHandler }
|
onClick={ closeHandler }
|
||||||
/>
|
/>
|
||||||
|
|
@ -72,7 +72,7 @@ export default class FunnelSaveModal extends React.PureComponent {
|
||||||
/>
|
/>
|
||||||
</Form.Field>
|
</Form.Field>
|
||||||
|
|
||||||
<Form.Field>
|
<Form.Field>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
name="isPublic"
|
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 }) }>
|
<div className="flex items-center cursor-pointer" onClick={ () => this.props.edit({ 'isPublic' : !funnel.isPublic }) }>
|
||||||
<Icon name="user-friends" size="16" />
|
<Icon name="user-friends" size="16" />
|
||||||
<span className="ml-2"> Team Funnel</span>
|
<span className="ml-2"> Team Visible</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Form.Field>
|
</Form.Field>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal.Content>
|
</Modal.Content>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { init } from 'Duck/site';
|
||||||
import styles from './siteDropdown.css';
|
import styles from './siteDropdown.css';
|
||||||
import cn from 'classnames';
|
import cn from 'classnames';
|
||||||
import NewSiteForm from '../Client/Sites/NewSiteForm';
|
import NewSiteForm from '../Client/Sites/NewSiteForm';
|
||||||
|
import { clearSearch } from 'Duck/search';
|
||||||
|
|
||||||
@withRouter
|
@withRouter
|
||||||
@connect(state => ({
|
@connect(state => ({
|
||||||
|
|
@ -18,7 +19,8 @@ import NewSiteForm from '../Client/Sites/NewSiteForm';
|
||||||
}), {
|
}), {
|
||||||
setSiteId,
|
setSiteId,
|
||||||
pushNewSite,
|
pushNewSite,
|
||||||
init
|
init,
|
||||||
|
clearSearch,
|
||||||
})
|
})
|
||||||
export default class SiteDropdown extends React.PureComponent {
|
export default class SiteDropdown extends React.PureComponent {
|
||||||
state = { showProductModal: false }
|
state = { showProductModal: false }
|
||||||
|
|
@ -32,6 +34,11 @@ export default class SiteDropdown extends React.PureComponent {
|
||||||
this.setState({showProductModal: true})
|
this.setState({showProductModal: true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
switchSite = (siteId) => {
|
||||||
|
this.props.setSiteId(siteId);
|
||||||
|
this.props.clearSearch();
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { sites, siteId, account, location: { pathname } } = this.props;
|
const { sites, siteId, account, location: { pathname } } = this.props;
|
||||||
const { showProductModal } = this.state;
|
const { showProductModal } = this.state;
|
||||||
|
|
@ -54,7 +61,7 @@ export default class SiteDropdown extends React.PureComponent {
|
||||||
{ !showCurrent && <li>{ 'Does not require domain selection.' }</li>}
|
{ !showCurrent && <li>{ 'Does not require domain selection.' }</li>}
|
||||||
{
|
{
|
||||||
sites.map(site => (
|
sites.map(site => (
|
||||||
<li key={ site.id } onClick={ () => this.props.setSiteId(site.id) }>
|
<li key={ site.id } onClick={() => this.switchSite(site.id)}>
|
||||||
<Icon
|
<Icon
|
||||||
name="circle"
|
name="circle"
|
||||||
size="8"
|
size="8"
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,11 @@ import {
|
||||||
connectPlayer,
|
connectPlayer,
|
||||||
init as initPlayer,
|
init as initPlayer,
|
||||||
clean as cleanPlayer,
|
clean as cleanPlayer,
|
||||||
|
Controls,
|
||||||
} from 'Player';
|
} from 'Player';
|
||||||
import cn from 'classnames'
|
import cn from 'classnames'
|
||||||
import RightBlock from './RightBlock'
|
import RightBlock from './RightBlock'
|
||||||
|
import withLocationHandlers from "HOCs/withLocationHandlers";
|
||||||
|
|
||||||
|
|
||||||
import PlayerBlockHeader from '../Session_/PlayerBlockHeader';
|
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(() => {
|
useEffect(() => {
|
||||||
initPlayer(session, jwt, config);
|
initPlayer(session, jwt, config);
|
||||||
|
|
||||||
|
const jumptTime = props.query.get('jumpto');
|
||||||
|
if (jumptTime) {
|
||||||
|
Controls.jump(parseInt(jumptTime));
|
||||||
|
}
|
||||||
|
|
||||||
return () => cleanPlayer()
|
return () => cleanPlayer()
|
||||||
}, [ session.sessionId ]);
|
}, [ session.sessionId ]);
|
||||||
|
|
||||||
|
|
@ -56,7 +66,6 @@ function WebPlayer ({ session, toggleFullscreen, closeBottomBlock, live, fullscr
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export default connect(state => ({
|
export default connect(state => ({
|
||||||
session: state.getIn([ 'sessions', 'current' ]),
|
session: state.getIn([ 'sessions', 'current' ]),
|
||||||
jwt: state.get('jwt'),
|
jwt: state.get('jwt'),
|
||||||
|
|
@ -65,5 +74,4 @@ export default connect(state => ({
|
||||||
}), {
|
}), {
|
||||||
toggleFullscreen,
|
toggleFullscreen,
|
||||||
closeBottomBlock,
|
closeBottomBlock,
|
||||||
})(WebPlayer)
|
})(withLocationHandlers()(WebPlayer));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import React, {useEffect} from 'react';
|
||||||
import { connectPlayer, markTargets } from 'Player';
|
import { connectPlayer, markTargets } from 'Player';
|
||||||
import { getStatusText } from 'Player/MessageDistributor/managers/AssistManager';
|
import { getStatusText } from 'Player/MessageDistributor/managers/AssistManager';
|
||||||
import type { MarkedTarget } from 'Player/MessageDistributor/StatedScreen/StatedScreen';
|
import type { MarkedTarget } from 'Player/MessageDistributor/StatedScreen/StatedScreen';
|
||||||
|
import { ConnectionStatus } from 'Player/MessageDistributor/managers/AssistManager';
|
||||||
|
|
||||||
import AutoplayTimer from './Overlay/AutoplayTimer';
|
import AutoplayTimer from './Overlay/AutoplayTimer';
|
||||||
import PlayIconLayer from './Overlay/PlayIconLayer';
|
import PlayIconLayer from './Overlay/PlayIconLayer';
|
||||||
|
|
@ -17,6 +18,7 @@ interface Props {
|
||||||
loading: boolean,
|
loading: boolean,
|
||||||
live: boolean,
|
live: boolean,
|
||||||
liveStatusText: string,
|
liveStatusText: string,
|
||||||
|
concetionStatus: ConnectionStatus,
|
||||||
autoplay: boolean,
|
autoplay: boolean,
|
||||||
markedTargets: MarkedTarget[] | null,
|
markedTargets: MarkedTarget[] | null,
|
||||||
activeTargetIndex: number,
|
activeTargetIndex: number,
|
||||||
|
|
@ -33,6 +35,7 @@ function Overlay({
|
||||||
loading,
|
loading,
|
||||||
live,
|
live,
|
||||||
liveStatusText,
|
liveStatusText,
|
||||||
|
concetionStatus,
|
||||||
autoplay,
|
autoplay,
|
||||||
markedTargets,
|
markedTargets,
|
||||||
activeTargetIndex,
|
activeTargetIndex,
|
||||||
|
|
@ -53,7 +56,7 @@ function Overlay({
|
||||||
<>
|
<>
|
||||||
{ showAutoplayTimer && <AutoplayTimer /> }
|
{ showAutoplayTimer && <AutoplayTimer /> }
|
||||||
{ showLiveStatusText &&
|
{ showLiveStatusText &&
|
||||||
<LiveStatusText text={liveStatusText} />
|
<LiveStatusText text={liveStatusText} concetionStatus={concetionStatus} />
|
||||||
}
|
}
|
||||||
{ messagesLoading && <Loader/> }
|
{ messagesLoading && <Loader/> }
|
||||||
{ showPlayIconLayer &&
|
{ showPlayIconLayer &&
|
||||||
|
|
@ -74,6 +77,7 @@ export default connectPlayer(state => ({
|
||||||
autoplay: state.autoplay,
|
autoplay: state.autoplay,
|
||||||
live: state.live,
|
live: state.live,
|
||||||
liveStatusText: getStatusText(state.peerConnectionStatus),
|
liveStatusText: getStatusText(state.peerConnectionStatus),
|
||||||
|
concetionStatus: state.peerConnectionStatus,
|
||||||
markedTargets: state.markedTargets,
|
markedTargets: state.markedTargets,
|
||||||
activeTargetIndex: state.activeTargetIndex,
|
activeTargetIndex: state.activeTargetIndex,
|
||||||
}))(Overlay);
|
}))(Overlay);
|
||||||
|
|
@ -1,11 +1,64 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import stl from './LiveStatusText.css';
|
import stl from './LiveStatusText.css';
|
||||||
import ovStl from './overlay.css';
|
import ovStl from './overlay.css';
|
||||||
|
import { ConnectionStatus } from 'Player/MessageDistributor/managers/AssistManager';
|
||||||
|
import { Loader } from 'UI';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
text: string;
|
text: string;
|
||||||
|
concetionStatus: ConnectionStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LiveStatusText({ text }: Props) {
|
export default function LiveStatusText({ text, concetionStatus }: Props) {
|
||||||
return <div className={ovStl.overlay}><div className={stl.text}>{text}</div></div>
|
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 { withRouter } from 'react-router-dom';
|
||||||
import { browserIcon, osIcon, deviceTypeIcon } from 'App/iconNames';
|
import { browserIcon, osIcon, deviceTypeIcon } from 'App/iconNames';
|
||||||
import { formatTimeOrDate } from 'App/date';
|
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 { Icon, CountryFlag, IconButton, BackLink } from 'UI';
|
||||||
import { toggleFavorite, setSessionPath } from 'Duck/sessions';
|
import { toggleFavorite, setSessionPath } from 'Duck/sessions';
|
||||||
import cn from 'classnames';
|
import cn from 'classnames';
|
||||||
|
|
@ -41,7 +41,6 @@ function capitalise(str) {
|
||||||
local: state.getIn(['sessions', 'timezone']),
|
local: state.getIn(['sessions', 'timezone']),
|
||||||
funnelRef: state.getIn(['funnels', 'navRef']),
|
funnelRef: state.getIn(['funnels', 'navRef']),
|
||||||
siteId: state.getIn([ 'user', 'siteId' ]),
|
siteId: state.getIn([ 'user', 'siteId' ]),
|
||||||
funnelPage: state.getIn(['sessions', 'funnelPage']),
|
|
||||||
hasSessionsPath: hasSessioPath && !isAssist,
|
hasSessionsPath: hasSessioPath && !isAssist,
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
|
|
@ -61,22 +60,12 @@ export default class PlayerBlockHeader extends React.PureComponent {
|
||||||
);
|
);
|
||||||
|
|
||||||
backHandler = () => {
|
backHandler = () => {
|
||||||
const { history, siteId, funnelPage, sessionPath } = this.props;
|
const { history, siteId, sessionPath } = this.props;
|
||||||
// alert(sessionPath)
|
if (sessionPath === history.location.pathname || sessionPath.includes("/session/")) {
|
||||||
if (sessionPath === history.location.pathname) {
|
|
||||||
history.push(withSiteId(SESSIONS_ROUTE), siteId);
|
history.push(withSiteId(SESSIONS_ROUTE), siteId);
|
||||||
} else {
|
} else {
|
||||||
history.push(sessionPath ? sessionPath : withSiteId(SESSIONS_ROUTE, siteId));
|
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 = () => {
|
toggleFavorite = () => {
|
||||||
|
|
@ -106,9 +95,9 @@ export default class PlayerBlockHeader extends React.PureComponent {
|
||||||
disabled,
|
disabled,
|
||||||
jiraConfig,
|
jiraConfig,
|
||||||
fullscreen,
|
fullscreen,
|
||||||
hasSessionsPath
|
hasSessionsPath,
|
||||||
|
sessionPath,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
// const { history, siteId } = this.props;
|
|
||||||
const _live = live && !hasSessionsPath;
|
const _live = live && !hasSessionsPath;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -145,6 +134,7 @@ export default class PlayerBlockHeader extends React.PureComponent {
|
||||||
<IconButton
|
<IconButton
|
||||||
className="mr-2"
|
className="mr-2"
|
||||||
tooltip="Bookmark"
|
tooltip="Bookmark"
|
||||||
|
tooltipPosition="top right"
|
||||||
onClick={ this.toggleFavorite }
|
onClick={ this.toggleFavorite }
|
||||||
loading={ loading }
|
loading={ loading }
|
||||||
icon={ favorite ? 'star-solid' : 'star' }
|
icon={ favorite ? 'star-solid' : 'star' }
|
||||||
|
|
@ -153,12 +143,14 @@ export default class PlayerBlockHeader extends React.PureComponent {
|
||||||
<SharePopup
|
<SharePopup
|
||||||
entity="sessions"
|
entity="sessions"
|
||||||
id={ sessionId }
|
id={ sessionId }
|
||||||
|
showCopyLink={true}
|
||||||
trigger={
|
trigger={
|
||||||
<IconButton
|
<IconButton
|
||||||
className="mr-2"
|
className="mr-2"
|
||||||
tooltip="Share Session"
|
tooltip="Share Session"
|
||||||
|
tooltipPosition="top right"
|
||||||
disabled={ disabled }
|
disabled={ disabled }
|
||||||
icon={ 'share-alt' }
|
icon={ 'share-alt' }
|
||||||
plain
|
plain
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,73 @@
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import cn from 'classnames';
|
import cn from 'classnames';
|
||||||
import withToggle from 'HOCs/withToggle';
|
import withToggle from 'HOCs/withToggle';
|
||||||
import { IconButton, SlideModal, NoContent } from 'UI';
|
import { IconButton, Popup } from 'UI';
|
||||||
import { updateAppearance } from 'Duck/user';
|
import { updateAppearance } from 'Duck/user';
|
||||||
import { WIDGET_LIST } from 'Types/dashboard';
|
|
||||||
import stl from './addWidgets.css';
|
import stl from './addWidgets.css';
|
||||||
import OutsideClickDetectingDiv from 'Shared/OutsideClickDetectingDiv';
|
import OutsideClickDetectingDiv from 'Shared/OutsideClickDetectingDiv';
|
||||||
|
import { updateActiveState } from 'Duck/customMetrics';
|
||||||
|
|
||||||
|
const CUSTOM_METRICS = 'custom_metrics';
|
||||||
|
|
||||||
@connect(state => ({
|
@connect(state => ({
|
||||||
appearance: state.getIn([ 'user', 'account', 'appearance' ]),
|
appearance: state.getIn([ 'user', 'account', 'appearance' ]),
|
||||||
|
customMetrics: state.getIn(['customMetrics', 'list']),
|
||||||
}), {
|
}), {
|
||||||
updateAppearance,
|
updateAppearance, updateActiveState,
|
||||||
})
|
})
|
||||||
@withToggle()
|
@withToggle()
|
||||||
export default class AddWidgets extends React.PureComponent {
|
export default class AddWidgets extends React.PureComponent {
|
||||||
makeAddHandler = widgetKey => () => {
|
makeAddHandler = widgetKey => () => {
|
||||||
const { appearance } = this.props;
|
if (this.props.type === CUSTOM_METRICS) {
|
||||||
const newAppearance = appearance.setIn([ 'dashboard', widgetKey ], true);
|
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.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() {
|
render() {
|
||||||
const { appearance, disabled } = this.props;
|
const { disabled, widgets, type } = this.props;
|
||||||
const avaliableWidgets = WIDGET_LIST.filter(({ key, type }) => !appearance.dashboard[ key ] && type === this.props.type );
|
const filteredWidgets = type === CUSTOM_METRICS ? this.getCustomMetricWidgets() : widgets;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<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)}>
|
<OutsideClickDetectingDiv onClickOutside={() => this.props.switchOpen(false)}>
|
||||||
{this.props.open &&
|
{this.props.open &&
|
||||||
<div
|
<div
|
||||||
className={cn(stl.menuWrapper, 'absolute border rounded z-10 bg-white w-auto')}
|
className={cn(stl.menuWrapper, 'absolute border rounded z-10 bg-white w-auto')}
|
||||||
style={{ minWidth: '200px', top: '30px'}}
|
style={{ minWidth: '200px', top: '30px'}}
|
||||||
>
|
>
|
||||||
{avaliableWidgets.map(w => (
|
{filteredWidgets.map(w => (
|
||||||
<div
|
<div
|
||||||
|
key={w.key}
|
||||||
className={cn(stl.menuItem, 'whitespace-pre cursor-pointer')}
|
className={cn(stl.menuItem, 'whitespace-pre cursor-pointer')}
|
||||||
onClick={this.makeAddHandler(w.key)}
|
onClick={this.makeAddHandler(w.key)}
|
||||||
>
|
>
|
||||||
|
|
@ -44,46 +77,6 @@ export default class AddWidgets extends React.PureComponent {
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</OutsideClickDetectingDiv>
|
</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>
|
</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';
|
import DateRangeDropdown from 'Shared/DateRangeDropdown';
|
||||||
|
|
||||||
function DateRange (props) {
|
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 (
|
return (
|
||||||
<DateRangeDropdown
|
<DateRangeDropdown
|
||||||
|
|
@ -14,6 +14,7 @@ function DateRange (props) {
|
||||||
className={ className }
|
className={ className }
|
||||||
customRangeRight={customRangeRight}
|
customRangeRight={customRangeRight}
|
||||||
customHidden={customHidden}
|
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} />
|
<Icon name="chevron-down" color="gray-dark" size="14" className={styles.dropdownIcon} />
|
||||||
</div> : null
|
</div> : null
|
||||||
}
|
}
|
||||||
selection={!button}
|
// selection={!button}
|
||||||
name="sessionDateRange"
|
name="sessionDateRange"
|
||||||
direction={ direction }
|
direction={ direction }
|
||||||
className={ button ? "" : "customDropdown" }
|
className={ button ? "" : "customDropdown" }
|
||||||
|
|
@ -97,8 +97,9 @@ export default class DateRangeDropdown extends React.PureComponent {
|
||||||
icon={ null }
|
icon={ null }
|
||||||
>
|
>
|
||||||
<Dropdown.Menu>
|
<Dropdown.Menu>
|
||||||
{ options.map(props =>
|
{ options.map((props, i) =>
|
||||||
<Dropdown.Item
|
<Dropdown.Item
|
||||||
|
key={i}
|
||||||
{...props}
|
{...props}
|
||||||
onClick={this.onItemClick}
|
onClick={this.onItemClick}
|
||||||
active={props.value === value }
|
active={props.value === value }
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,7 @@ class AttributeItem extends React.PureComponent {
|
||||||
|
|
||||||
<div className={ stl.actions }>
|
<div className={ stl.actions }>
|
||||||
<button className={ stl.button } onClick={ this.removeFilter }>
|
<button className={ stl.button } onClick={ this.removeFilter }>
|
||||||
<Icon name="close" size="16" />
|
<Icon name="close" size="14" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -137,12 +137,13 @@ class AttributeValueField extends React.PureComponent {
|
||||||
const { filter, onChange } = this.props;
|
const { filter, onChange } = this.props;
|
||||||
const _showAutoComplete = this.isAutoComplete(filter.type);
|
const _showAutoComplete = this.isAutoComplete(filter.type);
|
||||||
const _params = _showAutoComplete ? this.getParams(filter) : {};
|
const _params = _showAutoComplete ? this.getParams(filter) : {};
|
||||||
let _optionsEndpoint= '/events/search';
|
let _optionsEndpoint= '/events/search';
|
||||||
|
console.log('value', filter.value)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
{ _showAutoComplete ?
|
{ _showAutoComplete ?
|
||||||
<AutoComplete
|
<AutoComplete
|
||||||
name={ 'value' }
|
name={ 'value' }
|
||||||
endpoint={ _optionsEndpoint }
|
endpoint={ _optionsEndpoint }
|
||||||
value={ filter.value }
|
value={ filter.value }
|
||||||
|
|
@ -151,6 +152,7 @@ class AttributeValueField extends React.PureComponent {
|
||||||
onSelect={ onChange }
|
onSelect={ onChange }
|
||||||
headerText={ <h5 className={ stl.header }>{ getHeader(filter.type) }</h5> }
|
headerText={ <h5 className={ stl.header }>{ getHeader(filter.type) }</h5> }
|
||||||
fullWidth={ (filter.type === TYPES.CONSOLE || filter.type === TYPES.LOCATION || filter.type === TYPES.CUSTOM) && filter.value }
|
fullWidth={ (filter.type === TYPES.CONSOLE || filter.type === TYPES.LOCATION || filter.type === TYPES.CUSTOM) && filter.value }
|
||||||
|
// onAddOrRemove={}
|
||||||
/>
|
/>
|
||||||
: this.renderField()
|
: this.renderField()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ class AutoComplete extends React.PureComponent {
|
||||||
noResultsMessage: SOME_ERROR_MSG,
|
noResultsMessage: SOME_ERROR_MSG,
|
||||||
})
|
})
|
||||||
|
|
||||||
onInputChange = (e, { name, value }) => {
|
onInputChange = ({ target: { value } }) => {
|
||||||
changed = true;
|
changed = true;
|
||||||
this.setState({ query: value, updated: true })
|
this.setState({ query: value, updated: true })
|
||||||
const _value = value.trim();
|
const _value = value.trim();
|
||||||
|
|
@ -118,7 +118,8 @@ class AutoComplete extends React.PureComponent {
|
||||||
valueToText = defaultValueToText,
|
valueToText = defaultValueToText,
|
||||||
placeholder = 'Type to search...',
|
placeholder = 'Type to search...',
|
||||||
headerText = '',
|
headerText = '',
|
||||||
fullWidth = false
|
fullWidth = false,
|
||||||
|
onAddOrRemove = () => null,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
const options = optionMapping(values, valueToText)
|
const options = optionMapping(values, valueToText)
|
||||||
|
|
@ -128,7 +129,7 @@ class AutoComplete extends React.PureComponent {
|
||||||
className={ cn("relative", { "flex-1" : fullWidth }) }
|
className={ cn("relative", { "flex-1" : fullWidth }) }
|
||||||
onClickOutside={this.onClickOutside}
|
onClickOutside={this.onClickOutside}
|
||||||
>
|
>
|
||||||
<Input
|
{/* <Input
|
||||||
className={ cn(stl.searchInput, { [ stl.fullWidth] : fullWidth }) }
|
className={ cn(stl.searchInput, { [ stl.fullWidth] : fullWidth }) }
|
||||||
onChange={ this.onInputChange }
|
onChange={ this.onInputChange }
|
||||||
onBlur={ this.onBlur }
|
onBlur={ this.onBlur }
|
||||||
|
|
@ -144,7 +145,30 @@ class AutoComplete extends React.PureComponent {
|
||||||
this.hiddenInput.value = text;
|
this.hiddenInput.value = text;
|
||||||
pasted = true; // to use only the hidden input
|
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>
|
<textarea style={hiddenStyle} ref={(ref) => this.hiddenInput = ref }></textarea>
|
||||||
{ ddOpen && options.length > 0 &&
|
{ ddOpen && options.length > 0 &&
|
||||||
<div className={ stl.menu }>
|
<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