commit
fad85d0997
139 changed files with 12751 additions and 2690 deletions
|
|
@ -1,4 +1,4 @@
|
|||
FROM python:3.6-slim
|
||||
FROM python:3.9.7-slim
|
||||
LABEL Maintainer="Rajesh Rajendran<rjshrjndrn@gmail.com>"
|
||||
WORKDIR /work
|
||||
COPY . .
|
||||
|
|
|
|||
|
|
@ -119,12 +119,6 @@ def Build(a):
|
|||
|
||||
q = f"""SELECT coalesce(value,0) AS value, coalesce(value,0) {a["query"]["operator"]} {a["query"]["right"]} AS valid"""
|
||||
|
||||
# if len(colDef.group) > 0 {
|
||||
# subQ = subQ.Column(colDef.group + " AS group_value")
|
||||
# subQ = subQ.GroupBy(colDef.group)
|
||||
# q = q.Column("group_value")
|
||||
# }
|
||||
|
||||
if a["detectionMethod"] == schemas.AlertDetectionMethod.threshold:
|
||||
if a["seriesId"] is not None:
|
||||
q += f""" FROM ({subQ}) AS stat"""
|
||||
|
|
@ -134,16 +128,6 @@ def Build(a):
|
|||
params = {**params, **full_args, "startDate": TimeUTC.now() - a["options"]["currentPeriod"] * 60 * 1000}
|
||||
else:
|
||||
if a["options"]["change"] == schemas.AlertDetectionChangeType.change:
|
||||
# if len(colDef.group) > 0:
|
||||
# subq1 := subQ.Where(sq.Expr("timestamp>=$2 ", time.Now().Unix()-a.Options.CurrentPeriod * 60))
|
||||
# sub2, args2, _ := subQ.Where(
|
||||
# sq.And{
|
||||
# sq.Expr("timestamp<$3 ", time.Now().Unix()-a.Options.CurrentPeriod * 60),
|
||||
# sq.Expr("timestamp>=$4 ", time.Now().Unix()-2 * a.Options.CurrentPeriod * 60),
|
||||
# }).ToSql()
|
||||
# sub1 := sq.Select("group_value", "(stat1.value-stat2.value) AS value").FromSelect(subq1, "stat1").JoinClause("INNER JOIN ("+sub2+") AS stat2 USING(group_value)", args2...)
|
||||
# q = q.FromSelect(sub1, "stat")
|
||||
# else:
|
||||
if a["seriesId"] is not None:
|
||||
sub2 = subQ.replace("%(startDate)s", "%(timestamp_sub2)s").replace("%(endDate)s", "%(startDate)s")
|
||||
sub1 = f"SELECT (({subQ})-({sub2})) AS value"
|
||||
|
|
@ -163,16 +147,6 @@ def Build(a):
|
|||
q += f" FROM ( {sub1} ) AS stat"
|
||||
|
||||
else:
|
||||
# if len(colDef.group) >0 {
|
||||
# subq1 := subQ.Where(sq.Expr("timestamp>=$2 ", time.Now().Unix()-a.Options.CurrentPeriod * 60))
|
||||
# sub2, args2, _ := subQ.Where(
|
||||
# sq.And{
|
||||
# sq.Expr("timestamp<$3 ", time.Now().Unix()-a.Options.CurrentPeriod * 60),
|
||||
# sq.Expr("timestamp>=$4 ", time.Now().Unix()-a.Options.PreviousPeriod * 60-a.Options.CurrentPeriod * 60),
|
||||
# }).ToSql()
|
||||
# sub1 := sq.Select("group_value", "(stat1.value/stat2.value-1)*100 AS value").FromSelect(subq1, "stat1").JoinClause("INNER JOIN ("+sub2+") AS stat2 USING(group_value)", args2...)
|
||||
# q = q.FromSelect(sub1, "stat")
|
||||
# } else {
|
||||
if a["seriesId"] is not None:
|
||||
sub2 = subQ.replace("%(startDate)s", "%(timestamp_sub2)s").replace("%(endDate)s", "%(startDate)s")
|
||||
sub1 = f"SELECT (({subQ})/NULLIF(({sub2}),0)-1)*100 AS value"
|
||||
|
|
|
|||
|
|
@ -1,35 +1,50 @@
|
|||
import json
|
||||
from typing import Union
|
||||
|
||||
import schemas
|
||||
from chalicelib.core import sessions
|
||||
from chalicelib.utils import helper, pg_client
|
||||
from chalicelib.utils.TimeUTC import TimeUTC
|
||||
|
||||
PIE_CHART_GROUP = 5
|
||||
|
||||
def try_live(project_id, data: schemas.TryCustomMetricsSchema):
|
||||
|
||||
def __try_live(project_id, data: schemas.CreateCustomMetricsSchema):
|
||||
results = []
|
||||
for i, s in enumerate(data.series):
|
||||
s.filter.startDate = data.startDate
|
||||
s.filter.endDate = data.endDate
|
||||
results.append(sessions.search2_series(data=s.filter, project_id=project_id, density=data.density,
|
||||
view_type=data.viewType))
|
||||
if data.viewType == schemas.MetricViewType.progress:
|
||||
view_type=data.view_type, metric_type=data.metric_type,
|
||||
metric_of=data.metric_of, metric_value=data.metric_value))
|
||||
if data.view_type == schemas.MetricTimeseriesViewType.progress:
|
||||
r = {"count": results[-1]}
|
||||
diff = s.filter.endDate - s.filter.startDate
|
||||
s.filter.startDate = data.endDate
|
||||
s.filter.endDate = data.endDate - diff
|
||||
r["previousCount"] = sessions.search2_series(data=s.filter, project_id=project_id, density=data.density,
|
||||
view_type=data.viewType)
|
||||
view_type=data.view_type, metric_type=data.metric_type,
|
||||
metric_of=data.metric_of, metric_value=data.metric_value)
|
||||
r["countProgress"] = helper.__progress(old_val=r["previousCount"], new_val=r["count"])
|
||||
# r["countProgress"] = ((r["count"] - r["previousCount"]) / r["previousCount"]) * 100 \
|
||||
# if r["previousCount"] > 0 else 0
|
||||
r["seriesName"] = s.name if s.name else i + 1
|
||||
r["seriesId"] = s.series_id if s.series_id else None
|
||||
results[-1] = r
|
||||
elif data.view_type == schemas.MetricTableViewType.pie_chart:
|
||||
if len(results[i].get("values", [])) > PIE_CHART_GROUP:
|
||||
results[i]["values"] = results[i]["values"][:PIE_CHART_GROUP] \
|
||||
+ [{
|
||||
"name": "Others", "group": True,
|
||||
"sessionCount": sum(r["sessionCount"] for r in results[i]["values"][PIE_CHART_GROUP:])
|
||||
}]
|
||||
|
||||
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:
|
||||
def merged_live(project_id, data: schemas.CreateCustomMetricsSchema):
|
||||
series_charts = __try_live(project_id=project_id, data=data)
|
||||
if data.view_type == schemas.MetricTimeseriesViewType.progress or data.metric_type == schemas.MetricType.table:
|
||||
return series_charts
|
||||
results = [{}] * len(series_charts[0])
|
||||
for i in range(len(results)):
|
||||
|
|
@ -39,13 +54,30 @@ def merged_live(project_id, data: schemas.TryCustomMetricsSchema):
|
|||
return results
|
||||
|
||||
|
||||
def make_chart(project_id, user_id, metric_id, data: schemas.CustomMetricChartPayloadSchema):
|
||||
def __get_merged_metric(project_id, user_id, metric_id,
|
||||
data: Union[schemas.CustomMetricChartPayloadSchema,
|
||||
schemas.CustomMetricSessionsPayloadSchema]) \
|
||||
-> Union[schemas.CreateCustomMetricsSchema, None]:
|
||||
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})
|
||||
series_charts = try_live(project_id=project_id, data=metric)
|
||||
if data.viewType == schemas.MetricViewType.progress:
|
||||
metric: schemas.CreateCustomMetricsSchema = schemas.CreateCustomMetricsSchema.parse_obj({**data.dict(), **metric})
|
||||
if len(data.filters) > 0 or len(data.events) > 0:
|
||||
for s in metric.series:
|
||||
if len(data.filters) > 0:
|
||||
s.filter.filters += data.filters
|
||||
if len(data.events) > 0:
|
||||
s.filter.events += data.events
|
||||
return metric
|
||||
|
||||
|
||||
def make_chart(project_id, user_id, metric_id, data: schemas.CustomMetricChartPayloadSchema):
|
||||
metric: schemas.CreateCustomMetricsSchema = __get_merged_metric(project_id=project_id, user_id=user_id,
|
||||
metric_id=metric_id, data=data)
|
||||
if metric is None:
|
||||
return None
|
||||
series_charts = __try_live(project_id=project_id, data=metric)
|
||||
if metric.view_type == schemas.MetricTimeseriesViewType.progress or metric.metric_type == schemas.MetricType.table:
|
||||
return series_charts
|
||||
results = [{}] * len(series_charts[0])
|
||||
for i in range(len(results)):
|
||||
|
|
@ -55,11 +87,11 @@ def make_chart(project_id, user_id, metric_id, data: schemas.CustomMetricChartPa
|
|||
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)
|
||||
def get_sessions(project_id, user_id, metric_id, data: schemas.CustomMetricSessionsPayloadSchema):
|
||||
metric: schemas.CreateCustomMetricsSchema = __get_merged_metric(project_id=project_id, user_id=user_id,
|
||||
metric_id=metric_id, data=data)
|
||||
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
|
||||
|
|
@ -82,8 +114,10 @@ def create(project_id, user_id, data: schemas.CreateCustomMetricsSchema):
|
|||
data.series = None
|
||||
params = {"user_id": user_id, "project_id": project_id, **data.dict(), **_data}
|
||||
query = cur.mogrify(f"""\
|
||||
WITH m AS (INSERT INTO metrics (project_id, user_id, name)
|
||||
VALUES (%(project_id)s, %(user_id)s, %(name)s)
|
||||
WITH m AS (INSERT INTO metrics (project_id, user_id, name, is_public,
|
||||
view_type, metric_type, metric_of, metric_value, metric_format)
|
||||
VALUES (%(project_id)s, %(user_id)s, %(name)s, %(is_public)s,
|
||||
%(view_type)s, %(metric_type)s, %(metric_of)s, %(metric_value)s, %(metric_format)s)
|
||||
RETURNING *)
|
||||
INSERT
|
||||
INTO metric_series(metric_id, index, name, filter)
|
||||
|
|
@ -98,32 +132,22 @@ def create(project_id, user_id, data: schemas.CreateCustomMetricsSchema):
|
|||
return {"data": get(metric_id=r["metric_id"], project_id=project_id, user_id=user_id)}
|
||||
|
||||
|
||||
def __get_series_id(metric_id):
|
||||
with pg_client.PostgresClient() as cur:
|
||||
cur.execute(
|
||||
cur.mogrify(
|
||||
"""SELECT series_id
|
||||
FROM metric_series
|
||||
WHERE metric_series.metric_id = %(metric_id)s
|
||||
AND metric_series.deleted_at ISNULL;""",
|
||||
{"metric_id": metric_id}
|
||||
)
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
return [r["series_id"] for r in rows]
|
||||
|
||||
|
||||
def update(metric_id, user_id, project_id, data: schemas.UpdateCustomMetricsSchema):
|
||||
series_ids = __get_series_id(metric_id)
|
||||
metric = get(metric_id=metric_id, project_id=project_id, user_id=user_id, flatten=False)
|
||||
if metric is None:
|
||||
return None
|
||||
series_ids = [r["seriesId"] for r in metric["series"]]
|
||||
n_series = []
|
||||
d_series_ids = []
|
||||
u_series = []
|
||||
u_series_ids = []
|
||||
params = {"metric_id": metric_id, "is_public": data.is_public, "name": data.name,
|
||||
"user_id": user_id, "project_id": project_id}
|
||||
"user_id": user_id, "project_id": project_id, "view_type": data.view_type,
|
||||
"metric_type": data.metric_type, "metric_of": data.metric_of,
|
||||
"metric_value": data.metric_value, "metric_format": data.metric_format}
|
||||
for i, s in enumerate(data.series):
|
||||
prefix = "u_"
|
||||
if s.series_id is None:
|
||||
if s.series_id is None or s.series_id not in series_ids:
|
||||
n_series.append({"i": i, "s": s})
|
||||
prefix = "n_"
|
||||
s.index = i
|
||||
|
|
@ -165,7 +189,10 @@ def update(metric_id, user_id, project_id, data: schemas.UpdateCustomMetricsSche
|
|||
query = cur.mogrify(f"""\
|
||||
{"WITH " if len(sub_queries) > 0 else ""}{",".join(sub_queries)}
|
||||
UPDATE metrics
|
||||
SET name = %(name)s, is_public= %(is_public)s
|
||||
SET name = %(name)s, is_public= %(is_public)s,
|
||||
view_type= %(view_type)s, metric_type= %(metric_type)s,
|
||||
metric_of= %(metric_of)s, metric_value= %(metric_value)s,
|
||||
metric_format= %(metric_format)s
|
||||
WHERE metric_id = %(metric_id)s
|
||||
AND project_id = %(project_id)s
|
||||
AND (user_id = %(user_id)s OR is_public)
|
||||
|
|
@ -224,7 +251,7 @@ def get(metric_id, project_id, user_id, flatten=True):
|
|||
cur.mogrify(
|
||||
"""SELECT *
|
||||
FROM metrics
|
||||
LEFT JOIN LATERAL (SELECT jsonb_agg(metric_series.* ORDER BY index) AS series
|
||||
LEFT JOIN LATERAL (SELECT COALESCE(jsonb_agg(metric_series.* ORDER BY index),'[]'::jsonb) AS series
|
||||
FROM metric_series
|
||||
WHERE metric_series.metric_id = metrics.metric_id
|
||||
AND metric_series.deleted_at ISNULL
|
||||
|
|
@ -261,6 +288,7 @@ def get_series_for_alert(project_id, user_id):
|
|||
INNER JOIN metrics USING (metric_id)
|
||||
WHERE metrics.deleted_at ISNULL
|
||||
AND metrics.project_id = %(project_id)s
|
||||
AND metrics.metric_type = 'timeseries'
|
||||
AND (user_id = %(user_id)s OR is_public)
|
||||
ORDER BY name;""",
|
||||
{"project_id": project_id, "user_id": user_id}
|
||||
|
|
|
|||
|
|
@ -69,20 +69,25 @@ def get_projects(tenant_id, recording_state=False, gdpr=None, recorded=False, st
|
|||
)
|
||||
rows = cur.fetchall()
|
||||
if recording_state:
|
||||
project_ids = [f'({r["project_id"]})' for r in rows]
|
||||
query = f"""SELECT projects.project_id, COALESCE(MAX(start_ts), 0) AS last
|
||||
FROM (VALUES {",".join(project_ids)}) AS projects(project_id)
|
||||
LEFT JOIN sessions USING (project_id)
|
||||
GROUP BY project_id;"""
|
||||
cur.execute(
|
||||
query=query
|
||||
)
|
||||
status = cur.fetchall()
|
||||
for r in rows:
|
||||
query = cur.mogrify(
|
||||
"select COALESCE(MAX(start_ts),0) AS last from public.sessions where project_id=%(project_id)s;",
|
||||
{"project_id": r["project_id"]})
|
||||
cur.execute(
|
||||
query=query
|
||||
)
|
||||
status = cur.fetchone()
|
||||
if status["last"] < TimeUTC.now(-2):
|
||||
r["status"] = "red"
|
||||
elif status["last"] < TimeUTC.now(-1):
|
||||
r["status"] = "yellow"
|
||||
else:
|
||||
r["status"] = "green"
|
||||
for s in status:
|
||||
if s["project_id"] == r["project_id"]:
|
||||
if s["last"] < TimeUTC.now(-2):
|
||||
r["status"] = "red"
|
||||
elif s["last"] < TimeUTC.now(-1):
|
||||
r["status"] = "yellow"
|
||||
else:
|
||||
r["status"] = "green"
|
||||
break
|
||||
|
||||
return helper.list_to_camel_case(rows)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
from typing import List
|
||||
|
||||
import schemas
|
||||
from chalicelib.core import events, metadata, events_ios, \
|
||||
sessions_mobs, issues, projects, errors, resources, assist, performance_event
|
||||
|
|
@ -198,7 +200,7 @@ def search2_pg(data: schemas.SessionsSearchPayloadSchema, project_id, user_id, f
|
|||
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}
|
||||
{"," if len(meta_keys)>0 else ""}{",".join([f'metadata_{m["index"]}' for m in meta_keys])}
|
||||
{"," if len(meta_keys) > 0 else ""}{",".join([f'metadata_{m["index"]}' for m in meta_keys])}
|
||||
{query_part}
|
||||
ORDER BY s.session_id desc) AS filtred_sessions
|
||||
ORDER BY favorite DESC, issue_score DESC, {sort} {data.order}) AS full_sessions
|
||||
|
|
@ -210,7 +212,7 @@ def search2_pg(data: schemas.SessionsSearchPayloadSchema, project_id, user_id, f
|
|||
main_query = cur.mogrify(f"""SELECT COUNT(full_sessions) AS count, COALESCE(JSONB_AGG(full_sessions) FILTER (WHERE rn <= 200), '[]'::JSONB) AS sessions
|
||||
FROM (SELECT *, ROW_NUMBER() OVER (ORDER BY favorite DESC, issue_score DESC, session_id desc, start_ts desc) AS rn
|
||||
FROM (SELECT DISTINCT ON(s.session_id) {SESSION_PROJECTION_COLS}
|
||||
{"," if len(meta_keys)>0 else ""}{",".join([f'metadata_{m["index"]}' for m in meta_keys])}
|
||||
{"," if len(meta_keys) > 0 else ""}{",".join([f'metadata_{m["index"]}' for m in meta_keys])}
|
||||
{query_part}
|
||||
ORDER BY s.session_id desc) AS filtred_sessions
|
||||
ORDER BY favorite DESC, issue_score DESC, {sort} {data.order}) AS full_sessions;""",
|
||||
|
|
@ -225,9 +227,9 @@ def search2_pg(data: schemas.SessionsSearchPayloadSchema, project_id, user_id, f
|
|||
|
||||
# print("--------------------")
|
||||
# print(main_query)
|
||||
|
||||
cur.execute(main_query)
|
||||
# print("--------------------")
|
||||
cur.execute(main_query)
|
||||
|
||||
if count_only:
|
||||
return helper.dict_to_camel_case(cur.fetchone())
|
||||
sessions = cur.fetchone()
|
||||
|
|
@ -264,44 +266,103 @@ def search2_pg(data: schemas.SessionsSearchPayloadSchema, project_id, user_id, f
|
|||
}
|
||||
|
||||
|
||||
@dev.timed
|
||||
def search2_series(data: schemas.SessionsSearchPayloadSchema, project_id: int, density: int,
|
||||
view_type: schemas.MetricViewType):
|
||||
view_type: schemas.MetricTimeseriesViewType, metric_type: schemas.MetricType,
|
||||
metric_of: schemas.TableMetricOfType, metric_value: List):
|
||||
step_size = int(metrics_helper.__get_step_size(endTimestamp=data.endDate, startTimestamp=data.startDate,
|
||||
density=density, factor=1, decimal=True))
|
||||
extra_event = None
|
||||
if metric_of == schemas.TableMetricOfType.visited_url:
|
||||
extra_event = "events.pages"
|
||||
elif metric_of == schemas.TableMetricOfType.issues and len(metric_value) > 0:
|
||||
data.filters.append(schemas.SessionSearchFilterSchema(value=metric_value, type=schemas.FilterType.issue,
|
||||
operator=schemas.SearchEventOperator._is))
|
||||
full_args, query_part, sort = search_query_parts(data=data, error_status=None, errors_only=False,
|
||||
favorite_only=False, issue=None, project_id=project_id,
|
||||
user_id=None)
|
||||
user_id=None, extra_event=extra_event)
|
||||
full_args["step_size"] = step_size
|
||||
sessions = []
|
||||
with pg_client.PostgresClient() as cur:
|
||||
if view_type == schemas.MetricViewType.line_chart:
|
||||
main_query = cur.mogrify(f"""WITH full_sessions AS (SELECT DISTINCT ON(s.session_id) s.session_id, s.start_ts
|
||||
{query_part})
|
||||
SELECT generated_timestamp AS timestamp,
|
||||
COUNT(s) AS count
|
||||
FROM generate_series(%(startDate)s, %(endDate)s, %(step_size)s) AS generated_timestamp
|
||||
LEFT JOIN LATERAL ( SELECT 1 AS s
|
||||
FROM full_sessions
|
||||
WHERE start_ts >= generated_timestamp
|
||||
AND start_ts <= generated_timestamp + %(step_size)s) AS sessions ON (TRUE)
|
||||
GROUP BY generated_timestamp
|
||||
ORDER BY generated_timestamp;""", full_args)
|
||||
else:
|
||||
main_query = cur.mogrify(f"""SELECT count(DISTINCT s.session_id) AS count
|
||||
{query_part};""", full_args)
|
||||
if metric_type == schemas.MetricType.timeseries:
|
||||
if view_type == schemas.MetricTimeseriesViewType.line_chart:
|
||||
main_query = cur.mogrify(f"""WITH full_sessions AS (SELECT DISTINCT ON(s.session_id) s.session_id, s.start_ts
|
||||
{query_part})
|
||||
SELECT generated_timestamp AS timestamp,
|
||||
COUNT(s) AS count
|
||||
FROM generate_series(%(startDate)s, %(endDate)s, %(step_size)s) AS generated_timestamp
|
||||
LEFT JOIN LATERAL ( SELECT 1 AS s
|
||||
FROM full_sessions
|
||||
WHERE start_ts >= generated_timestamp
|
||||
AND start_ts <= generated_timestamp + %(step_size)s) AS sessions ON (TRUE)
|
||||
GROUP BY generated_timestamp
|
||||
ORDER BY generated_timestamp;""", full_args)
|
||||
else:
|
||||
main_query = cur.mogrify(f"""SELECT count(DISTINCT s.session_id) AS count
|
||||
{query_part};""", full_args)
|
||||
|
||||
# print("--------------------")
|
||||
# print(main_query)
|
||||
# print("--------------------")
|
||||
cur.execute(main_query)
|
||||
if view_type == schemas.MetricTimeseriesViewType.line_chart:
|
||||
sessions = cur.fetchall()
|
||||
else:
|
||||
sessions = cur.fetchone()["count"]
|
||||
elif metric_type == schemas.MetricType.table:
|
||||
if isinstance(metric_of, schemas.TableMetricOfType):
|
||||
main_col = "user_id"
|
||||
extra_col = ""
|
||||
extra_where = ""
|
||||
pre_query = ""
|
||||
if metric_of == schemas.TableMetricOfType.user_country:
|
||||
main_col = "user_country"
|
||||
elif metric_of == schemas.TableMetricOfType.user_device:
|
||||
main_col = "user_device"
|
||||
elif metric_of == schemas.TableMetricOfType.user_browser:
|
||||
main_col = "user_browser"
|
||||
elif metric_of == schemas.TableMetricOfType.issues:
|
||||
main_col = "issue"
|
||||
extra_col = f", UNNEST(s.issue_types) AS {main_col}"
|
||||
if len(metric_value) > 0:
|
||||
extra_where = []
|
||||
for i in range(len(metric_value)):
|
||||
arg_name = f"selected_issue_{i}"
|
||||
extra_where.append(f"{main_col} = %({arg_name})s")
|
||||
full_args[arg_name] = metric_value[i]
|
||||
extra_where = f"WHERE ({' OR '.join(extra_where)})"
|
||||
elif metric_of == schemas.TableMetricOfType.visited_url:
|
||||
main_col = "base_path"
|
||||
extra_col = ", base_path"
|
||||
main_query = cur.mogrify(f"""{pre_query}
|
||||
SELECT COUNT(*) AS count, COALESCE(JSONB_AGG(users_sessions) FILTER ( WHERE rn <= 200 ), '[]'::JSONB) AS values
|
||||
FROM (SELECT {main_col} AS name,
|
||||
count(full_sessions) AS session_count,
|
||||
ROW_NUMBER() OVER (ORDER BY count(full_sessions) DESC) AS rn
|
||||
FROM (SELECT *
|
||||
FROM (SELECT DISTINCT ON(s.session_id) s.session_id, s.user_uuid,
|
||||
s.user_id, s.user_os,
|
||||
s.user_browser, s.user_device,
|
||||
s.user_device_type, s.user_country, s.issue_types{extra_col}
|
||||
{query_part}
|
||||
ORDER BY s.session_id desc) AS filtred_sessions
|
||||
) AS full_sessions
|
||||
{extra_where}
|
||||
GROUP BY {main_col}
|
||||
ORDER BY session_count DESC) AS users_sessions;""",
|
||||
full_args)
|
||||
# print("--------------------")
|
||||
# print(main_query)
|
||||
# print("--------------------")
|
||||
cur.execute(main_query)
|
||||
sessions = cur.fetchone()
|
||||
for s in sessions["values"]:
|
||||
s.pop("rn")
|
||||
sessions["values"] = helper.list_to_camel_case(sessions["values"])
|
||||
|
||||
# print("--------------------")
|
||||
# print(main_query)
|
||||
cur.execute(main_query)
|
||||
# print("--------------------")
|
||||
if view_type == schemas.MetricViewType.line_chart:
|
||||
sessions = cur.fetchall()
|
||||
else:
|
||||
sessions = cur.fetchone()["count"]
|
||||
return sessions
|
||||
|
||||
|
||||
def search_query_parts(data, error_status, errors_only, favorite_only, issue, project_id, user_id):
|
||||
def search_query_parts(data, error_status, errors_only, favorite_only, issue, project_id, user_id, extra_event=None):
|
||||
ss_constraints = []
|
||||
full_args = {"project_id": project_id, "startDate": data.startDate, "endDate": data.endDate,
|
||||
"projectId": project_id, "userId": user_id}
|
||||
|
|
@ -521,7 +582,6 @@ def search_query_parts(data, error_status, errors_only, favorite_only, issue, pr
|
|||
value_key=f_k))
|
||||
# ---------------------------------------------------------------------------
|
||||
if len(data.events) > 0:
|
||||
# ss_constraints = [s.decode('UTF-8') for s in ss_constraints]
|
||||
events_query_from = []
|
||||
event_index = 0
|
||||
or_events = data.events_order == schemas.SearchEventOrder._or
|
||||
|
|
@ -532,13 +592,16 @@ def search_query_parts(data, error_status, errors_only, favorite_only, issue, pr
|
|||
is_any = _isAny_opreator(event.operator)
|
||||
if not isinstance(event.value, list):
|
||||
event.value = [event.value]
|
||||
if not is_any and len(event.value) == 0 \
|
||||
if not is_any and len(event.value) == 0 and event_type not in [schemas.EventType.request_details,
|
||||
schemas.EventType.graphql_details] \
|
||||
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):
|
||||
] and (event.source is None or len(event.source) == 0) \
|
||||
or event_type in [schemas.EventType.request_details, schemas.EventType.graphql_details] and (
|
||||
event.filters is None or len(event.filters) == 0):
|
||||
continue
|
||||
op = __get_sql_operator(event.operator)
|
||||
is_not = False
|
||||
|
|
@ -737,15 +800,19 @@ def search_query_parts(data, error_status, errors_only, favorite_only, issue, pr
|
|||
event_where += ["main2.timestamp >= %(startDate)s", "main2.timestamp <= %(endDate)s"]
|
||||
if event_index > 0 and not or_events:
|
||||
event_where.append("main2.session_id=event_0.session_id")
|
||||
event_where.append(
|
||||
_multiple_conditions(
|
||||
f"main.{getattr(events.event_type, event.value[0].type).column} {s_op} %({e_k1})s",
|
||||
event.value[0].value, value_key=e_k1))
|
||||
is_any = _isAny_opreator(event.value[0].operator)
|
||||
if not is_any:
|
||||
event_where.append(
|
||||
_multiple_conditions(
|
||||
f"main.{getattr(events.event_type, event.value[0].type).column} {s_op} %({e_k1})s",
|
||||
event.value[0].value, value_key=e_k1))
|
||||
s_op = __get_sql_operator(event.value[1].operator)
|
||||
event_where.append(
|
||||
_multiple_conditions(
|
||||
f"main2.{getattr(events.event_type, event.value[1].type).column} {s_op} %({e_k2})s",
|
||||
event.value[1].value, value_key=e_k2))
|
||||
is_any = _isAny_opreator(event.value[1].operator)
|
||||
if not is_any:
|
||||
event_where.append(
|
||||
_multiple_conditions(
|
||||
f"main2.{getattr(events.event_type, event.value[1].type).column} {s_op} %({e_k2})s",
|
||||
event.value[1].value, value_key=e_k2))
|
||||
|
||||
e_k += "_custom"
|
||||
full_args = {**full_args, **_multiple_values(event.source, value_key=e_k)}
|
||||
|
|
@ -753,7 +820,66 @@ def search_query_parts(data, error_status, errors_only, favorite_only, issue, pr
|
|||
_multiple_conditions(f"main2.timestamp - main.timestamp {event.sourceOperator} %({e_k})s",
|
||||
event.source, value_key=e_k))
|
||||
|
||||
|
||||
elif event_type == schemas.EventType.request_details:
|
||||
event_from = event_from % f"{events.event_type.REQUEST.table} AS main "
|
||||
for j, f in enumerate(event.filters):
|
||||
is_any = _isAny_opreator(f.operator)
|
||||
if is_any or len(f.value) == 0:
|
||||
continue
|
||||
op = __get_sql_operator(f.operator)
|
||||
e_k_f = e_k + f"_fetch{j}"
|
||||
full_args = {**full_args, **_multiple_values(f.value, value_key=e_k_f)}
|
||||
if f.type == schemas.FetchFilterType._url:
|
||||
event_where.append(
|
||||
_multiple_conditions(f"main.{events.event_type.REQUEST.column} {op} %({e_k_f})s", f.value,
|
||||
value_key=e_k_f))
|
||||
elif f.type == schemas.FetchFilterType._status_code:
|
||||
event_where.append(
|
||||
_multiple_conditions(f"main.status_code {op} %({e_k_f})s", f.value, value_key=e_k_f))
|
||||
elif f.type == schemas.FetchFilterType._method:
|
||||
event_where.append(
|
||||
_multiple_conditions(f"main.method {op} %({e_k_f})s", f.value, value_key=e_k_f))
|
||||
elif f.type == schemas.FetchFilterType._duration:
|
||||
event_where.append(
|
||||
_multiple_conditions(f"main.duration {op} %({e_k_f})s", f.value, value_key=e_k_f))
|
||||
elif f.type == schemas.FetchFilterType._request_body:
|
||||
event_where.append(
|
||||
_multiple_conditions(f"main.request_body {op} %({e_k_f})s", f.value, value_key=e_k_f))
|
||||
elif f.type == schemas.FetchFilterType._response_body:
|
||||
event_where.append(
|
||||
_multiple_conditions(f"main.response_body {op} %({e_k_f})s", f.value, value_key=e_k_f))
|
||||
else:
|
||||
print(f"undefined FETCH filter: {f.type}")
|
||||
elif event_type == schemas.EventType.graphql_details:
|
||||
event_from = event_from % f"{events.event_type.GRAPHQL.table} AS main "
|
||||
for j, f in enumerate(event.filters):
|
||||
is_any = _isAny_opreator(f.operator)
|
||||
if is_any or len(f.value) == 0:
|
||||
continue
|
||||
op = __get_sql_operator(f.operator)
|
||||
e_k_f = e_k + f"_graphql{j}"
|
||||
full_args = {**full_args, **_multiple_values(f.value, value_key=e_k_f)}
|
||||
if f.type == schemas.GraphqlFilterType._name:
|
||||
event_where.append(
|
||||
_multiple_conditions(f"main.{events.event_type.GRAPHQL.column} {op} %({e_k_f})s", f.value,
|
||||
value_key=e_k_f))
|
||||
elif f.type == schemas.GraphqlFilterType._status_code:
|
||||
event_where.append(
|
||||
_multiple_conditions(f"main.status_code {op} %({e_k_f})s", f.value, value_key=e_k_f))
|
||||
elif f.type == schemas.GraphqlFilterType._method:
|
||||
event_where.append(
|
||||
_multiple_conditions(f"main.method {op} %({e_k_f})s", f.value, value_key=e_k_f))
|
||||
elif f.type == schemas.GraphqlFilterType._duration:
|
||||
event_where.append(
|
||||
_multiple_conditions(f"main.duration {op} %({e_k_f})s", f.value, value_key=e_k_f))
|
||||
elif f.type == schemas.GraphqlFilterType._request_body:
|
||||
event_where.append(
|
||||
_multiple_conditions(f"main.request_body {op} %({e_k_f})s", f.value, value_key=e_k_f))
|
||||
elif f.type == schemas.GraphqlFilterType._response_body:
|
||||
event_where.append(
|
||||
_multiple_conditions(f"main.response_body {op} %({e_k_f})s", f.value, value_key=e_k_f))
|
||||
else:
|
||||
print(f"undefined GRAPHQL filter: {f.type}")
|
||||
else:
|
||||
continue
|
||||
if event_index == 0 or or_events:
|
||||
|
|
@ -856,6 +982,10 @@ def search_query_parts(data, error_status, errors_only, favorite_only, issue, pr
|
|||
"""
|
||||
full_args["issue_contextString"] = issue["contextString"]
|
||||
full_args["issue_type"] = issue["type"]
|
||||
if extra_event:
|
||||
extra_join += f"""INNER JOIN {extra_event} AS ev USING(session_id)"""
|
||||
extra_constraints.append("ev.timestamp>=%(startDate)s")
|
||||
extra_constraints.append("ev.timestamp<=%(endDate)s")
|
||||
query_part = f"""\
|
||||
FROM {f"({events_query_part}) AS f" if len(events_query_part) > 0 else "public.sessions AS s"}
|
||||
{extra_join}
|
||||
|
|
|
|||
|
|
@ -63,13 +63,12 @@ def create_step1(data: schemas.UserSignupSchema):
|
|||
"fullname": fullname,
|
||||
"projectName": project_name,
|
||||
"data": json.dumps({"lastAnnouncementView": TimeUTC.now()}),
|
||||
"organizationName": company_name,
|
||||
"versionNumber": config("version_number")
|
||||
"organizationName": company_name
|
||||
}
|
||||
query = f"""\
|
||||
WITH t AS (
|
||||
INSERT INTO public.tenants (name, version_number, edition)
|
||||
VALUES (%(organizationName)s, %(versionNumber)s, 'fos')
|
||||
VALUES (%(organizationName)s, (SELECT openreplay_version()), 'fos')
|
||||
RETURNING api_key
|
||||
),
|
||||
u AS (
|
||||
|
|
|
|||
|
|
@ -101,12 +101,15 @@ def comment_assignment(projectId: int, sessionId: int, issueId: str, data: schem
|
|||
|
||||
@app.get('/{projectId}/events/search', tags=["events"])
|
||||
def events_search(projectId: int, q: str,
|
||||
type: Union[schemas.FilterType, schemas.EventType, schemas.PerformanceEventType] = None,
|
||||
type: Union[schemas.FilterType, schemas.EventType,
|
||||
schemas.PerformanceEventType, schemas.FetchFilterType] = None,
|
||||
key: str = None,
|
||||
source: str = None, context: schemas.CurrentContext = Depends(OR_context)):
|
||||
if len(q) == 0:
|
||||
return {"data": []}
|
||||
if isinstance(type, schemas.PerformanceEventType):
|
||||
if type in [schemas.FetchFilterType._url]:
|
||||
type = schemas.EventType.request
|
||||
elif isinstance(type, schemas.PerformanceEventType):
|
||||
if type in [schemas.PerformanceEventType.location_dom_complete,
|
||||
schemas.PerformanceEventType.location_largest_contentful_paint_time,
|
||||
schemas.PerformanceEventType.location_ttfb,
|
||||
|
|
@ -1088,25 +1091,9 @@ def change_client_password(data: schemas.EditUserPasswordSchema = Body(...),
|
|||
|
||||
@app.post('/{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.CreateCustomMetricsSchema = Body(...),
|
||||
context: schemas.CurrentContext = Depends(OR_context)):
|
||||
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.put('/{projectId}/custom_metrics/chart', tags=["customMetrics"])
|
||||
def get_custom_metric_chart(projectId: int, data: schemas.CustomMetricChartPayloadSchema2 = Body(...),
|
||||
context: schemas.CurrentContext = Depends(OR_context)):
|
||||
return {"data": custom_metrics.make_chart(project_id=projectId, user_id=context.user_id, metric_id=data.metric_id,
|
||||
data=data)}
|
||||
return {"data": custom_metrics.merged_live(project_id=projectId, data=data)}
|
||||
|
||||
|
||||
@app.post('/{projectId}/custom_metrics', tags=["customMetrics"])
|
||||
|
|
@ -1123,29 +1110,40 @@ def get_custom_metrics(projectId: int, context: schemas.CurrentContext = Depends
|
|||
|
||||
@app.get('/{projectId}/custom_metrics/{metric_id}', tags=["customMetrics"])
|
||||
def get_custom_metric(projectId: int, metric_id: int, context: schemas.CurrentContext = Depends(OR_context)):
|
||||
return {"data": custom_metrics.get(project_id=projectId, user_id=context.user_id, metric_id=metric_id)}
|
||||
data = custom_metrics.get(project_id=projectId, user_id=context.user_id, metric_id=metric_id)
|
||||
if data is None:
|
||||
return {"errors": ["custom metric not found"]}
|
||||
return {"data": data}
|
||||
|
||||
|
||||
@app.post('/{projectId}/custom_metrics/{metric_id}/sessions', tags=["customMetrics"])
|
||||
def get_custom_metric_sessions(projectId: int, metric_id: int, data: schemas.CustomMetricRawPayloadSchema = Body(...),
|
||||
def get_custom_metric_sessions(projectId: int, metric_id: int,
|
||||
data: schemas.CustomMetricSessionsPayloadSchema = 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)}
|
||||
data = custom_metrics.get_sessions(project_id=projectId, user_id=context.user_id, metric_id=metric_id, data=data)
|
||||
if data is None:
|
||||
return {"errors": ["custom metric not found"]}
|
||||
return {"data": data}
|
||||
|
||||
|
||||
@app.post('/{projectId}/custom_metrics/{metric_id}/chart', tags=["customMetrics"])
|
||||
def get_custom_metric_chart(projectId: int, metric_id: int, data: schemas.CustomMetricChartPayloadSchema = Body(...),
|
||||
context: schemas.CurrentContext = Depends(OR_context)):
|
||||
return {"data": custom_metrics.make_chart(project_id=projectId, user_id=context.user_id, metric_id=metric_id,
|
||||
data=data)}
|
||||
data = custom_metrics.make_chart(project_id=projectId, user_id=context.user_id, metric_id=metric_id,
|
||||
data=data)
|
||||
if data is None:
|
||||
return {"errors": ["custom metric not found"]}
|
||||
return {"data": data}
|
||||
|
||||
|
||||
@app.post('/{projectId}/custom_metrics/{metric_id}', tags=["customMetrics"])
|
||||
@app.put('/{projectId}/custom_metrics/{metric_id}', tags=["customMetrics"])
|
||||
def update_custom_metric(projectId: int, metric_id: int, data: schemas.UpdateCustomMetricsSchema = Body(...),
|
||||
context: schemas.CurrentContext = Depends(OR_context)):
|
||||
return {
|
||||
"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)
|
||||
if data is None:
|
||||
return {"errors": ["custom metric not found"]}
|
||||
return {"data": data}
|
||||
|
||||
|
||||
@app.post('/{projectId}/custom_metrics/{metric_id}/status', tags=["customMetrics"])
|
||||
|
|
|
|||
154
api/schemas.py
154
api/schemas.py
|
|
@ -384,7 +384,9 @@ class EventType(str, Enum):
|
|||
location = "LOCATION"
|
||||
custom = "CUSTOM"
|
||||
request = "REQUEST"
|
||||
request_details = "FETCH"
|
||||
graphql = "GRAPHQL"
|
||||
graphql_details = "GRAPHQL_DETAILS"
|
||||
state_action = "STATEACTION"
|
||||
error = "ERROR"
|
||||
click_ios = "CLICK_IOS"
|
||||
|
|
@ -480,7 +482,8 @@ class __MixedSearchFilter(BaseModel):
|
|||
@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):
|
||||
if len(values["value"]) > 0 \
|
||||
and (isinstance(values["value"][0], int) or isinstance(values["value"][0], dict)):
|
||||
return values
|
||||
values["value"] = list(set(values["value"]))
|
||||
return values
|
||||
|
|
@ -489,6 +492,42 @@ class __MixedSearchFilter(BaseModel):
|
|||
alias_generator = attribute_to_camel_case
|
||||
|
||||
|
||||
class HttpMethod(str, Enum):
|
||||
_get = 'GET'
|
||||
_head = 'HEAD'
|
||||
_post = 'POST'
|
||||
_put = 'PUT'
|
||||
_delete = 'DELETE'
|
||||
_connect = 'CONNECT'
|
||||
_option = 'OPTIONS'
|
||||
_trace = 'TRACE'
|
||||
_patch = 'PATCH'
|
||||
|
||||
|
||||
class FetchFilterType(str, Enum):
|
||||
_url = "FETCH_URL"
|
||||
_status_code = "FETCH_STATUS_CODE"
|
||||
_method = "FETCH_METHOD"
|
||||
_duration = "FETCH_DURATION"
|
||||
_request_body = "FETCH_REQUEST_BODY"
|
||||
_response_body = "FETCH_RESPONSE_BODY"
|
||||
|
||||
|
||||
class GraphqlFilterType(str, Enum):
|
||||
_name = "GRAPHQL_NAME"
|
||||
_status_code = "GRAPHQL_STATUS_CODE"
|
||||
_method = "GRAPHQL_METHOD"
|
||||
_duration = "GRAPHQL_DURATION"
|
||||
_request_body = "GRAPHQL_REQUEST_BODY"
|
||||
_response_body = "GRAPHQL_RESPONSE_BODY"
|
||||
|
||||
|
||||
class RequestGraphqlFilterSchema(BaseModel):
|
||||
type: Union[FetchFilterType, GraphqlFilterType] = Field(...)
|
||||
value: List[Union[int, str]] = Field(...)
|
||||
operator: Union[SearchEventOperator, MathOperator] = Field(...)
|
||||
|
||||
|
||||
class _SessionSearchEventRaw(__MixedSearchFilter):
|
||||
is_event: bool = Field(default=True, const=True)
|
||||
value: List[str] = Field(...)
|
||||
|
|
@ -496,6 +535,7 @@ class _SessionSearchEventRaw(__MixedSearchFilter):
|
|||
operator: SearchEventOperator = Field(...)
|
||||
source: Optional[List[Union[ErrorSource, int, str]]] = Field(None)
|
||||
sourceOperator: Optional[MathOperator] = Field(None)
|
||||
filters: Optional[List[RequestGraphqlFilterSchema]] = Field(None)
|
||||
|
||||
@root_validator
|
||||
def event_validator(cls, values):
|
||||
|
|
@ -513,20 +553,28 @@ class _SessionSearchEventRaw(__MixedSearchFilter):
|
|||
assert isinstance(values["value"][0], _SessionSearchEventRaw) \
|
||||
and isinstance(values["value"][1], _SessionSearchEventRaw), \
|
||||
f"event should be of type _SessionSearchEventRaw for {PerformanceEventType.time_between_events}"
|
||||
assert len(values["source"]) > 0 and isinstance(values["source"][0], int), \
|
||||
f"source of type int if required for {PerformanceEventType.time_between_events}"
|
||||
else:
|
||||
for c in values["source"]:
|
||||
assert isinstance(c, int), f"source value should be of type int for {values.get('type')}"
|
||||
elif values.get("type") == EventType.error and values.get("source") is None:
|
||||
values["source"] = [ErrorSource.js_exception]
|
||||
elif values.get("type") == EventType.request_details:
|
||||
assert isinstance(values.get("filters"), List) and len(values.get("filters", [])) > 0, \
|
||||
f"filters should be defined for {EventType.request_details.value}"
|
||||
elif values.get("type") == EventType.graphql_details:
|
||||
assert isinstance(values.get("filters"), List) and len(values.get("filters", [])) > 0, \
|
||||
f"filters should be defined for {EventType.graphql_details.value}"
|
||||
|
||||
return values
|
||||
|
||||
|
||||
class _SessionSearchEventSchema(_SessionSearchEventRaw):
|
||||
value: Union[List[_SessionSearchEventRaw], str, List[str]] = Field(...)
|
||||
value: Union[List[Union[_SessionSearchEventRaw, str]], str] = Field(...)
|
||||
|
||||
|
||||
class _SessionSearchFilterSchema(__MixedSearchFilter):
|
||||
class SessionSearchFilterSchema(__MixedSearchFilter):
|
||||
is_event: bool = Field(False, const=False)
|
||||
value: Union[Optional[Union[IssueType, PlatformType, int, str]],
|
||||
Optional[List[Union[IssueType, PlatformType, int, str]]]] = Field(...)
|
||||
|
|
@ -559,7 +607,7 @@ class _SessionSearchFilterSchema(__MixedSearchFilter):
|
|||
|
||||
class SessionsSearchPayloadSchema(BaseModel):
|
||||
events: List[_SessionSearchEventSchema] = Field([])
|
||||
filters: List[_SessionSearchFilterSchema] = Field([])
|
||||
filters: List[SessionSearchFilterSchema] = Field([])
|
||||
startDate: int = Field(None)
|
||||
endDate: int = Field(None)
|
||||
sort: str = Field(default="startTs")
|
||||
|
|
@ -571,9 +619,9 @@ class SessionsSearchPayloadSchema(BaseModel):
|
|||
alias_generator = attribute_to_camel_case
|
||||
|
||||
|
||||
class FlatSessionsSearchPayloadSchema(SessionsSearchPayloadSchema):
|
||||
class FlatSessionsSearch(BaseModel):
|
||||
events: Optional[List[_SessionSearchEventSchema]] = Field([])
|
||||
filters: List[Union[_SessionSearchFilterSchema, _SessionSearchEventSchema]] = Field([])
|
||||
filters: List[Union[SessionSearchFilterSchema, _SessionSearchEventSchema]] = Field([])
|
||||
|
||||
@root_validator(pre=True)
|
||||
def flat_to_original(cls, values):
|
||||
|
|
@ -597,6 +645,10 @@ class FlatSessionsSearchPayloadSchema(SessionsSearchPayloadSchema):
|
|||
return values
|
||||
|
||||
|
||||
class FlatSessionsSearchPayloadSchema(FlatSessionsSearch, SessionsSearchPayloadSchema):
|
||||
pass
|
||||
|
||||
|
||||
class SessionsSearchCountSchema(FlatSessionsSearchPayloadSchema):
|
||||
# class SessionsSearchCountSchema(SessionsSearchPayloadSchema):
|
||||
sort: Optional[str] = Field(default=None)
|
||||
|
|
@ -688,21 +740,36 @@ class CustomMetricCreateSeriesSchema(BaseModel):
|
|||
alias_generator = attribute_to_camel_case
|
||||
|
||||
|
||||
class CreateCustomMetricsSchema(BaseModel):
|
||||
name: str = Field(...)
|
||||
series: List[CustomMetricCreateSeriesSchema] = Field(..., min_items=1)
|
||||
is_public: Optional[bool] = Field(True)
|
||||
|
||||
class Config:
|
||||
alias_generator = attribute_to_camel_case
|
||||
|
||||
|
||||
class MetricViewType(str, Enum):
|
||||
class MetricTimeseriesViewType(str, Enum):
|
||||
line_chart = "lineChart"
|
||||
progress = "progress"
|
||||
|
||||
|
||||
class CustomMetricRawPayloadSchema(BaseModel):
|
||||
class MetricTableViewType(str, Enum):
|
||||
table = "table"
|
||||
pie_chart = "pieChart"
|
||||
|
||||
|
||||
class MetricType(str, Enum):
|
||||
timeseries = "timeseries"
|
||||
table = "table"
|
||||
|
||||
|
||||
class TableMetricOfType(str, Enum):
|
||||
user_os = FilterType.user_os.value
|
||||
user_browser = FilterType.user_browser.value
|
||||
user_device = FilterType.user_device.value
|
||||
user_country = FilterType.user_country.value
|
||||
user_id = FilterType.user_id.value
|
||||
issues = FilterType.issue.value
|
||||
visited_url = EventType.location.value
|
||||
|
||||
|
||||
class TimeseriesMetricOfType(str, Enum):
|
||||
session_count = "sessionCount"
|
||||
|
||||
|
||||
class CustomMetricSessionsPayloadSchema(FlatSessionsSearch):
|
||||
startDate: int = Field(TimeUTC.now(-7))
|
||||
endDate: int = Field(TimeUTC.now())
|
||||
|
||||
|
|
@ -710,23 +777,52 @@ class CustomMetricRawPayloadSchema(BaseModel):
|
|||
alias_generator = attribute_to_camel_case
|
||||
|
||||
|
||||
class CustomMetricRawPayloadSchema2(CustomMetricRawPayloadSchema):
|
||||
metric_id: int = Field(...)
|
||||
|
||||
|
||||
class CustomMetricChartPayloadSchema(CustomMetricRawPayloadSchema):
|
||||
startDate: int = Field(TimeUTC.now(-7))
|
||||
endDate: int = Field(TimeUTC.now())
|
||||
class CustomMetricChartPayloadSchema(CustomMetricSessionsPayloadSchema):
|
||||
density: int = Field(7)
|
||||
viewType: MetricViewType = Field(MetricViewType.line_chart)
|
||||
|
||||
class Config:
|
||||
alias_generator = attribute_to_camel_case
|
||||
|
||||
|
||||
class CustomMetricChartPayloadSchema2(CustomMetricChartPayloadSchema):
|
||||
metric_id: int = Field(...)
|
||||
class CreateCustomMetricsSchema(CustomMetricChartPayloadSchema):
|
||||
name: str = Field(...)
|
||||
series: List[CustomMetricCreateSeriesSchema] = Field(..., min_items=1)
|
||||
is_public: bool = Field(default=True, const=True)
|
||||
view_type: Union[MetricTimeseriesViewType, MetricTableViewType] = Field(MetricTimeseriesViewType.line_chart)
|
||||
metric_type: MetricType = Field(MetricType.timeseries)
|
||||
metric_of: Union[TableMetricOfType, TimeseriesMetricOfType] = Field(TableMetricOfType.user_id)
|
||||
metric_value: List[IssueType] = Field([])
|
||||
metric_format: Optional[str] = Field(None)
|
||||
|
||||
# metricFraction: float = Field(None, gt=0, lt=1)
|
||||
# This is used to handle wrong values sent by the UI
|
||||
@root_validator(pre=True)
|
||||
def remove_metric_value(cls, values):
|
||||
if values.get("metricType") == MetricType.timeseries \
|
||||
or values.get("metricType") == MetricType.table \
|
||||
and values.get("metricOf") != TableMetricOfType.issues:
|
||||
values["metricValue"] = []
|
||||
return values
|
||||
|
||||
class TryCustomMetricsSchema(CreateCustomMetricsSchema, CustomMetricChartPayloadSchema):
|
||||
name: Optional[str] = Field(None)
|
||||
@root_validator
|
||||
def validator(cls, values):
|
||||
if values.get("metric_type") == MetricType.table:
|
||||
assert isinstance(values.get("view_type"), MetricTableViewType), \
|
||||
f"viewType must be of type {MetricTableViewType} for metricType:{MetricType.table.value}"
|
||||
assert isinstance(values.get("metric_of"), TableMetricOfType), \
|
||||
f"metricOf must be of type {TableMetricOfType} for metricType:{MetricType.table.value}"
|
||||
if values.get("metric_of") != TableMetricOfType.issues:
|
||||
assert values.get("metric_value") is None or len(values.get("metric_value")) == 0, \
|
||||
f"metricValue is only available for metricOf:{TableMetricOfType.issues.value}"
|
||||
elif values.get("metric_type") == MetricType.timeseries:
|
||||
assert isinstance(values.get("view_type"), MetricTimeseriesViewType), \
|
||||
f"viewType must be of type {MetricTimeseriesViewType} for metricType:{MetricType.timeseries.value}"
|
||||
assert isinstance(values.get("metric_of"), TimeseriesMetricOfType), \
|
||||
f"metricOf must be of type {TimeseriesMetricOfType} for metricType:{MetricType.timeseries.value}"
|
||||
return values
|
||||
|
||||
class Config:
|
||||
alias_generator = attribute_to_camel_case
|
||||
|
||||
|
||||
class CustomMetricUpdateSeriesSchema(CustomMetricCreateSeriesSchema):
|
||||
|
|
|
|||
36
backend/pkg/db/cache/messages_ios.go
vendored
36
backend/pkg/db/cache/messages_ios.go
vendored
|
|
@ -1,41 +1,40 @@
|
|||
package cache
|
||||
|
||||
import (
|
||||
import (
|
||||
"errors"
|
||||
. "openreplay/backend/pkg/messages"
|
||||
. "openreplay/backend/pkg/db/types"
|
||||
. "openreplay/backend/pkg/messages"
|
||||
)
|
||||
|
||||
func (c *PGCache) InsertIOSSessionStart(sessionID uint64, s *IOSSessionStart) error {
|
||||
if c.sessions[ sessionID ] != nil {
|
||||
if c.sessions[sessionID] != nil {
|
||||
return errors.New("This session already in cache!")
|
||||
}
|
||||
c.sessions[ sessionID ] = &Session{
|
||||
SessionID: sessionID,
|
||||
Platform: "ios",
|
||||
Timestamp: s.Timestamp,
|
||||
ProjectID: uint32(s.ProjectID),
|
||||
c.sessions[sessionID] = &Session{
|
||||
SessionID: sessionID,
|
||||
Platform: "ios",
|
||||
Timestamp: s.Timestamp,
|
||||
ProjectID: uint32(s.ProjectID),
|
||||
TrackerVersion: s.TrackerVersion,
|
||||
RevID: s.RevID,
|
||||
UserUUID: s.UserUUID,
|
||||
UserOS: s.UserOS,
|
||||
UserOSVersion: s.UserOSVersion,
|
||||
UserDevice: s.UserDevice,
|
||||
UserCountry: s.UserCountry,
|
||||
RevID: s.RevID,
|
||||
UserUUID: s.UserUUID,
|
||||
UserOS: s.UserOS,
|
||||
UserOSVersion: s.UserOSVersion,
|
||||
UserDevice: s.UserDevice,
|
||||
UserCountry: s.UserCountry,
|
||||
UserDeviceType: s.UserDeviceType,
|
||||
}
|
||||
if err := c.Conn.InsertSessionStart(sessionID, c.sessions[ sessionID ]); err != nil {
|
||||
c.sessions[ sessionID ] = nil
|
||||
if err := c.Conn.InsertSessionStart(sessionID, c.sessions[sessionID]); err != nil {
|
||||
c.sessions[sessionID] = nil
|
||||
return err
|
||||
}
|
||||
return nil;
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *PGCache) InsertIOSSessionEnd(sessionID uint64, e *IOSSessionEnd) error {
|
||||
return c.insertSessionEnd(sessionID, e.Timestamp)
|
||||
}
|
||||
|
||||
|
||||
func (c *PGCache) InsertIOSScreenEnter(sessionID uint64, screenEnter *IOSScreenEnter) error {
|
||||
if err := c.Conn.InsertIOSScreenEnter(sessionID, screenEnter); err != nil {
|
||||
return err
|
||||
|
|
@ -95,4 +94,3 @@ func (c *PGCache) InsertIOSIssueEvent(sessionID uint64, issueEvent *IOSIssueEven
|
|||
// }
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
24
backend/pkg/db/cache/messages_web.go
vendored
24
backend/pkg/db/cache/messages_web.go
vendored
|
|
@ -53,3 +53,27 @@ func (c *PGCache) InsertWebErrorEvent(sessionID uint64, e *ErrorEvent) error {
|
|||
session.ErrorsCount += 1
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *PGCache) InsertWebFetchEvent(sessionID uint64, e *FetchEvent) error {
|
||||
session, err := c.GetSession(sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
project, err := c.GetProject(session.ProjectID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Conn.InsertWebFetchEvent(sessionID, project.SaveRequestPayloads, e)
|
||||
}
|
||||
|
||||
func (c *PGCache) InsertWebGraphQLEvent(sessionID uint64, e *GraphQLEvent) error {
|
||||
session, err := c.GetSession(sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
project, err := c.GetProject(session.ProjectID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Conn.InsertWebGraphQLEvent(sessionID, project.SaveRequestPayloads, e)
|
||||
}
|
||||
|
|
|
|||
19
backend/pkg/db/cache/pg_cache.go
vendored
19
backend/pkg/db/cache/pg_cache.go
vendored
|
|
@ -1,8 +1,8 @@
|
|||
package cache
|
||||
|
||||
import (
|
||||
"time"
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"openreplay/backend/pkg/db/postgres"
|
||||
. "openreplay/backend/pkg/db/types"
|
||||
|
|
@ -10,32 +10,29 @@ import (
|
|||
|
||||
type ProjectMeta struct {
|
||||
*Project
|
||||
expirationTime time.Time
|
||||
expirationTime time.Time
|
||||
}
|
||||
|
||||
// !TODO: remove old sessions by timeout to avoid memleaks
|
||||
|
||||
/*
|
||||
/*
|
||||
* Cache layer around the stateless PG adapter
|
||||
**/
|
||||
type PGCache struct {
|
||||
*postgres.Conn
|
||||
sessions map[uint64]*Session
|
||||
projects map[uint32]*ProjectMeta
|
||||
projectsByKeys sync.Map // map[string]*ProjectMeta
|
||||
sessions map[uint64]*Session
|
||||
projects map[uint32]*ProjectMeta
|
||||
projectsByKeys sync.Map // map[string]*ProjectMeta
|
||||
projectExpirationTimeout time.Duration
|
||||
}
|
||||
|
||||
// TODO: create conn automatically
|
||||
func NewPGCache(pgConn *postgres.Conn, projectExpirationTimeoutMs int64) *PGCache {
|
||||
return &PGCache{
|
||||
Conn: pgConn,
|
||||
Conn: pgConn,
|
||||
sessions: make(map[uint64]*Session),
|
||||
projects: make(map[uint32]*ProjectMeta),
|
||||
//projectsByKeys: make(map[string]*ProjectMeta),
|
||||
projectExpirationTimeout: time.Duration(1000 * projectExpirationTimeoutMs),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
15
backend/pkg/db/cache/project.go
vendored
15
backend/pkg/db/cache/project.go
vendored
|
|
@ -1,8 +1,8 @@
|
|||
package cache
|
||||
|
||||
import (
|
||||
"time"
|
||||
import (
|
||||
. "openreplay/backend/pkg/db/types"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (c *PGCache) GetProjectByKey(projectKey string) (*Project, error) {
|
||||
|
|
@ -24,19 +24,16 @@ func (c *PGCache) GetProjectByKey(projectKey string) (*Project, error) {
|
|||
return p, nil
|
||||
}
|
||||
|
||||
|
||||
|
||||
func (c *PGCache) GetProject(projectID uint32) (*Project, error) {
|
||||
if c.projects[ projectID ] != nil &&
|
||||
time.Now().Before(c.projects[ projectID ].expirationTime) {
|
||||
return c.projects[ projectID ].Project, nil
|
||||
if c.projects[projectID] != nil &&
|
||||
time.Now().Before(c.projects[projectID].expirationTime) {
|
||||
return c.projects[projectID].Project, nil
|
||||
}
|
||||
p, err := c.Conn.GetProject(projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.projects[ projectID ] = &ProjectMeta{ p, time.Now().Add(c.projectExpirationTimeout) }
|
||||
c.projects[projectID] = &ProjectMeta{p, time.Now().Add(c.projectExpirationTimeout)}
|
||||
//c.projectsByKeys.Store(p.ProjectKey, c.projects[ projectID ])
|
||||
return p, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
8
backend/pkg/db/cache/session.go
vendored
8
backend/pkg/db/cache/session.go
vendored
|
|
@ -1,13 +1,13 @@
|
|||
package cache
|
||||
|
||||
import (
|
||||
import (
|
||||
"github.com/jackc/pgx/v4"
|
||||
|
||||
. "openreplay/backend/pkg/db/types"
|
||||
)
|
||||
|
||||
func (c *PGCache) GetSession(sessionID uint64) (*Session, error) {
|
||||
if s, inCache := c.sessions[ sessionID ]; inCache {
|
||||
if s, inCache := c.sessions[sessionID]; inCache {
|
||||
// TODO: review. Might cause bugs in case of multiple instances
|
||||
if s == nil {
|
||||
return nil, pgx.ErrNoRows
|
||||
|
|
@ -16,12 +16,12 @@ func (c *PGCache) GetSession(sessionID uint64) (*Session, error) {
|
|||
}
|
||||
s, err := c.Conn.GetSession(sessionID)
|
||||
if err == pgx.ErrNoRows {
|
||||
c.sessions[ sessionID ] = nil
|
||||
c.sessions[sessionID] = nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.sessions[ sessionID ] = s
|
||||
c.sessions[sessionID] = s
|
||||
return s, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,14 +3,14 @@ package postgres
|
|||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v4"
|
||||
"github.com/jackc/pgconn"
|
||||
"github.com/jackc/pgerrcode"
|
||||
"github.com/jackc/pgx/v4"
|
||||
)
|
||||
|
||||
func IsPkeyViolation(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
return errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation
|
||||
return errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation
|
||||
}
|
||||
|
||||
func IsNoRowsErr(err error) bool {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ func getIssueScore(issueEvent *messages.IssueEvent) int {
|
|||
switch issueEvent.Type {
|
||||
case "crash", "dead_click", "memory", "cpu":
|
||||
return 1000
|
||||
case "bad_request", "excessive_scrolling", "click_rage", "missing_resource" :
|
||||
case "bad_request", "excessive_scrolling", "click_rage", "missing_resource":
|
||||
return 500
|
||||
case "slow_resource", "slow_page_load":
|
||||
return 100
|
||||
|
|
@ -32,4 +32,4 @@ func calcResponseTime(pe *messages.PageEvent) uint64 {
|
|||
return pe.ResponseEnd - pe.ResponseStart
|
||||
}
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@ import (
|
|||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v4"
|
||||
|
||||
|
||||
)
|
||||
|
||||
type Listener struct {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"openreplay/backend/pkg/messages"
|
||||
"openreplay/backend/pkg/hashid"
|
||||
"openreplay/backend/pkg/messages"
|
||||
"openreplay/backend/pkg/url"
|
||||
)
|
||||
|
||||
|
|
@ -33,7 +33,7 @@ func (conn *Conn) InsertIOSUserAnonymousID(sessionID uint64, userAnonymousID *me
|
|||
func (conn *Conn) InsertIOSNetworkCall(sessionID uint64, e *messages.IOSNetworkCall) error {
|
||||
err := conn.InsertRequest(sessionID, e.Timestamp, e.Index, e.URL, e.Duration, e.Success)
|
||||
if err == nil {
|
||||
conn.insertAutocompleteValue(sessionID, "REQUEST_IOS", url.DiscardURLQuery(e.URL))
|
||||
conn.insertAutocompleteValue(sessionID, "REQUEST_IOS", url.DiscardURLQuery(e.URL))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
|
@ -65,7 +65,7 @@ func (conn *Conn) InsertIOSScreenEnter(sessionID uint64, screenEnter *messages.I
|
|||
if err = tx.commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
conn.insertAutocompleteValue(sessionID, "VIEW_IOS", screenEnter.ViewName)
|
||||
conn.insertAutocompleteValue(sessionID, "VIEW_IOS", screenEnter.ViewName)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -81,7 +81,7 @@ func (conn *Conn) InsertIOSClickEvent(sessionID uint64, clickEvent *messages.IOS
|
|||
session_id, timestamp, seq_index, label
|
||||
) VALUES (
|
||||
$1, $2, $3, $4
|
||||
)`,
|
||||
)`,
|
||||
sessionID, clickEvent.Timestamp, clickEvent.Index, clickEvent.Label,
|
||||
); err != nil {
|
||||
return err
|
||||
|
|
@ -153,7 +153,7 @@ func (conn *Conn) InsertIOSCrash(sessionID uint64, projectID uint32, crash *mess
|
|||
project_id, $2, $3, $4, $5
|
||||
FROM sessions
|
||||
WHERE session_id = $1
|
||||
)ON CONFLICT DO NOTHING`,
|
||||
)ON CONFLICT DO NOTHING`,
|
||||
sessionID, crashID, crash.Name, crash.Reason, crash.Stacktrace,
|
||||
); err != nil {
|
||||
return err
|
||||
|
|
@ -163,7 +163,7 @@ func (conn *Conn) InsertIOSCrash(sessionID uint64, projectID uint32, crash *mess
|
|||
session_id, timestamp, seq_index, crash_id
|
||||
) VALUES (
|
||||
$1, $2, $3, $4
|
||||
)`,
|
||||
)`,
|
||||
sessionID, crash.Timestamp, crash.Index, crashID,
|
||||
); err != nil {
|
||||
return err
|
||||
|
|
@ -177,5 +177,3 @@ func (conn *Conn) InsertIOSCrash(sessionID uint64, projectID uint32, crash *mess
|
|||
}
|
||||
return tx.commit()
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -13,9 +13,8 @@ func getSqIdx(messageID uint64) uint {
|
|||
return uint(messageID % math.MaxInt32)
|
||||
}
|
||||
|
||||
|
||||
func (conn *Conn) InsertWebCustomEvent(sessionID uint64, e *CustomEvent) error {
|
||||
err := conn.InsertCustomEvent(sessionID, e.Timestamp,
|
||||
err := conn.InsertCustomEvent(sessionID, e.Timestamp,
|
||||
e.MessageID,
|
||||
e.Name, e.Payload)
|
||||
if err == nil {
|
||||
|
|
@ -40,19 +39,19 @@ func (conn *Conn) InsertWebUserAnonymousID(sessionID uint64, userAnonymousID *Us
|
|||
return err
|
||||
}
|
||||
|
||||
func (conn *Conn) InsertWebResourceEvent(sessionID uint64, e *ResourceEvent) error {
|
||||
if e.Type != "fetch" {
|
||||
return nil
|
||||
}
|
||||
err := conn.InsertRequest(sessionID, e.Timestamp,
|
||||
e.MessageID,
|
||||
e.URL, e.Duration, e.Success,
|
||||
)
|
||||
if err == nil {
|
||||
conn.insertAutocompleteValue(sessionID, "REQUEST", url.DiscardURLQuery(e.URL))
|
||||
}
|
||||
return err
|
||||
}
|
||||
// func (conn *Conn) InsertWebResourceEvent(sessionID uint64, e *ResourceEvent) error {
|
||||
// if e.Type != "fetch" {
|
||||
// return nil
|
||||
// }
|
||||
// err := conn.InsertRequest(sessionID, e.Timestamp,
|
||||
// e.MessageID,
|
||||
// e.URL, e.Duration, e.Success,
|
||||
// )
|
||||
// if err == nil {
|
||||
// conn.insertAutocompleteValue(sessionID, "REQUEST", url.DiscardURLQuery(e.URL))
|
||||
// }
|
||||
// return err
|
||||
// }
|
||||
|
||||
// TODO: fix column "dom_content_loaded_event_end" of relation "pages"
|
||||
func (conn *Conn) InsertWebPageEvent(sessionID uint64, e *PageEvent) error {
|
||||
|
|
@ -62,7 +61,7 @@ func (conn *Conn) InsertWebPageEvent(sessionID uint64, e *PageEvent) error {
|
|||
}
|
||||
tx, err := conn.begin()
|
||||
if err != nil {
|
||||
return err
|
||||
return err
|
||||
}
|
||||
defer tx.rollback()
|
||||
if err := tx.exec(`
|
||||
|
|
@ -79,7 +78,7 @@ func (conn *Conn) InsertWebPageEvent(sessionID uint64, e *PageEvent) error {
|
|||
)
|
||||
`,
|
||||
sessionID, e.MessageID, e.Timestamp, e.Referrer, url.DiscardURLQuery(e.Referrer), host, path, url.DiscardURLQuery(path),
|
||||
e.DomContentLoadedEventEnd, e.LoadEventEnd, e.ResponseEnd, e.FirstPaint, e.FirstContentfulPaint,
|
||||
e.DomContentLoadedEventEnd, e.LoadEventEnd, e.ResponseEnd, e.FirstPaint, e.FirstContentfulPaint,
|
||||
e.SpeedIndex, e.VisuallyComplete, e.TimeToInteractive,
|
||||
calcResponseTime(e), calcDomBuildingTime(e),
|
||||
); err != nil {
|
||||
|
|
@ -133,7 +132,6 @@ func (conn *Conn) InsertWebClickEvent(sessionID uint64, e *ClickEvent) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
|
||||
func (conn *Conn) InsertWebInputEvent(sessionID uint64, e *InputEvent) error {
|
||||
tx, err := conn.begin()
|
||||
if err != nil {
|
||||
|
|
@ -205,3 +203,50 @@ func (conn *Conn) InsertWebErrorEvent(sessionID uint64, projectID uint32, e *Err
|
|||
}
|
||||
return tx.commit()
|
||||
}
|
||||
|
||||
func (conn *Conn) InsertWebFetchEvent(sessionID uint64, savePayload bool, e *FetchEvent) error {
|
||||
var request, response *string
|
||||
if savePayload {
|
||||
request = &e.Request
|
||||
response = &e.Response
|
||||
}
|
||||
conn.insertAutocompleteValue(sessionID, "REQUEST", url.DiscardURLQuery(e.URL))
|
||||
return conn.batchQueue(sessionID, `
|
||||
INSERT INTO events_common.requests (
|
||||
session_id, timestamp,
|
||||
seq_index, url, duration, success,
|
||||
request_body, response_body, status_code, method
|
||||
) VALUES (
|
||||
$1, $2,
|
||||
$3, $4, $5, $6,
|
||||
$7, $8, $9::smallint, NULLIF($10, '')::events_common.http_method
|
||||
) ON CONFLICT DO NOTHING`,
|
||||
sessionID, e.Timestamp,
|
||||
getSqIdx(e.MessageID), e.URL, e.Duration, e.Status < 400,
|
||||
request, response, e.Status, url.EnsureMethod(e.Method),
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
func (conn *Conn) InsertWebGraphQLEvent(sessionID uint64, savePayload bool, e *GraphQLEvent) error {
|
||||
var request, response *string
|
||||
if savePayload {
|
||||
request = &e.Variables
|
||||
response = &e.Response
|
||||
}
|
||||
conn.insertAutocompleteValue(sessionID, "GRAPHQL", e.OperationName)
|
||||
return conn.batchQueue(sessionID, `
|
||||
INSERT INTO events.graphql (
|
||||
session_id, timestamp, message_id,
|
||||
name,
|
||||
request_body, response_body
|
||||
) VALUES (
|
||||
$1, $2, $3,
|
||||
$4,
|
||||
$5, $6
|
||||
) ON CONFLICT DO NOTHING`,
|
||||
sessionID, e.Timestamp, e.MessageID,
|
||||
e.OperationName,
|
||||
request, response,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ type TenantNotification struct {
|
|||
Title string `db:"title" json:"title"`
|
||||
Description string `db:"description" json:"description"`
|
||||
ButtonText string `db:"button_text" json:"buttonText"`
|
||||
ButtonUrl string `db:"button_url" json:"buttonUrl"`
|
||||
ButtonUrl string `db:"button_url" json:"buttonUrl"`
|
||||
ImageUrl *string `db:"image_url" json:"imageUrl"`
|
||||
Options map[string]interface{} `db:"options" json:"options"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import (
|
|||
)
|
||||
|
||||
func (conn *Conn) GetProjectByKey(projectKey string) (*Project, error) {
|
||||
p := &Project{ ProjectKey: projectKey }
|
||||
p := &Project{ProjectKey: projectKey}
|
||||
if err := conn.queryRow(`
|
||||
SELECT max_session_duration, sample_rate, project_id
|
||||
FROM projects
|
||||
|
|
@ -20,19 +20,19 @@ func (conn *Conn) GetProjectByKey(projectKey string) (*Project, error) {
|
|||
|
||||
// TODO: logical separation of metadata
|
||||
func (conn *Conn) GetProject(projectID uint32) (*Project, error) {
|
||||
p := &Project{ ProjectID: projectID }
|
||||
p := &Project{ProjectID: projectID}
|
||||
if err := conn.queryRow(`
|
||||
SELECT project_key, max_session_duration,
|
||||
SELECT project_key, max_session_duration, save_request_payloads,
|
||||
metadata_1, metadata_2, metadata_3, metadata_4, metadata_5,
|
||||
metadata_6, metadata_7, metadata_8, metadata_9, metadata_10
|
||||
FROM projects
|
||||
WHERE project_id=$1 AND active = true
|
||||
`,
|
||||
projectID,
|
||||
).Scan(&p.ProjectKey,&p.MaxSessionDuration,
|
||||
&p.Metadata1, &p.Metadata2, &p.Metadata3, &p.Metadata4, &p.Metadata5,
|
||||
&p.Metadata6, &p.Metadata7, &p.Metadata8, &p.Metadata9, &p.Metadata10); err != nil {
|
||||
).Scan(&p.ProjectKey, &p.MaxSessionDuration, &p.SaveRequestPayloads,
|
||||
&p.Metadata1, &p.Metadata2, &p.Metadata3, &p.Metadata4, &p.Metadata5,
|
||||
&p.Metadata6, &p.Metadata7, &p.Metadata8, &p.Metadata9, &p.Metadata10); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
package postgres
|
||||
|
||||
type UnstartedSession struct {
|
||||
ProjectKey string
|
||||
TrackerVersion string
|
||||
DoNotTrack bool
|
||||
Platform string
|
||||
UserAgent string
|
||||
UserOS string
|
||||
UserOSVersion string
|
||||
UserBrowser string
|
||||
ProjectKey string
|
||||
TrackerVersion string
|
||||
DoNotTrack bool
|
||||
Platform string
|
||||
UserAgent string
|
||||
UserOS string
|
||||
UserOSVersion string
|
||||
UserBrowser string
|
||||
UserBrowserVersion string
|
||||
UserDevice string
|
||||
UserDeviceType string
|
||||
UserCountry string
|
||||
UserDevice string
|
||||
UserDeviceType string
|
||||
UserCountry string
|
||||
}
|
||||
|
||||
func (conn *Conn) InsertUnstartedSession(s UnstartedSession) error {
|
||||
|
|
@ -34,12 +34,12 @@ func (conn *Conn) InsertUnstartedSession(s UnstartedSession) error {
|
|||
$10, $11,
|
||||
$12
|
||||
)`,
|
||||
s.ProjectKey,
|
||||
s.ProjectKey,
|
||||
s.TrackerVersion, s.DoNotTrack,
|
||||
s.Platform, s.UserAgent,
|
||||
s.UserOS, s.UserOSVersion,
|
||||
s.UserBrowser, s.UserBrowserVersion,
|
||||
s.UserDevice, s.UserDeviceType,
|
||||
s.UserDevice, s.UserDeviceType,
|
||||
s.UserCountry,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,23 +3,23 @@ package types
|
|||
import "log"
|
||||
|
||||
type Project struct {
|
||||
ProjectID uint32
|
||||
ProjectKey string
|
||||
MaxSessionDuration int64
|
||||
SampleRate byte
|
||||
Metadata1 *string
|
||||
Metadata2 *string
|
||||
Metadata3 *string
|
||||
Metadata4 *string
|
||||
Metadata5 *string
|
||||
Metadata6 *string
|
||||
Metadata7 *string
|
||||
Metadata8 *string
|
||||
Metadata9 *string
|
||||
Metadata10 *string
|
||||
ProjectID uint32
|
||||
ProjectKey string
|
||||
MaxSessionDuration int64
|
||||
SampleRate byte
|
||||
SaveRequestPayloads bool
|
||||
Metadata1 *string
|
||||
Metadata2 *string
|
||||
Metadata3 *string
|
||||
Metadata4 *string
|
||||
Metadata5 *string
|
||||
Metadata6 *string
|
||||
Metadata7 *string
|
||||
Metadata8 *string
|
||||
Metadata9 *string
|
||||
Metadata10 *string
|
||||
}
|
||||
|
||||
|
||||
func (p *Project) GetMetadataNo(key string) uint {
|
||||
if p == nil {
|
||||
log.Printf("GetMetadataNo: Project is nil")
|
||||
|
|
|
|||
|
|
@ -12,10 +12,10 @@ type Session struct {
|
|||
UserDevice string
|
||||
UserCountry string
|
||||
|
||||
Duration *uint64
|
||||
PagesCount int
|
||||
EventsCount int
|
||||
ErrorsCount int
|
||||
Duration *uint64
|
||||
PagesCount int
|
||||
EventsCount int
|
||||
ErrorsCount int
|
||||
|
||||
UserID *string // pointer??
|
||||
UserAnonymousID *string
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -11,7 +11,7 @@ func insertMessage(sessionID uint64, msg Message) error {
|
|||
return pg.InsertMetadata(sessionID, m)
|
||||
case *IssueEvent:
|
||||
return pg.InsertIssueEvent(sessionID, m)
|
||||
//TODO: message adapter (transformer) (at the level of pkg/message) for types:
|
||||
//TODO: message adapter (transformer) (at the level of pkg/message) for types:
|
||||
// case *IOSMetadata, *IOSIssueEvent and others
|
||||
|
||||
// Web
|
||||
|
|
@ -30,14 +30,18 @@ func insertMessage(sessionID uint64, msg Message) error {
|
|||
case *InputEvent:
|
||||
return pg.InsertWebInputEvent(sessionID, m)
|
||||
// Unique Web messages
|
||||
case *ResourceEvent:
|
||||
return pg.InsertWebResourceEvent(sessionID, m)
|
||||
// case *ResourceEvent:
|
||||
// return pg.InsertWebResourceEvent(sessionID, m)
|
||||
case *PageEvent:
|
||||
return pg.InsertWebPageEvent(sessionID, m)
|
||||
case *ErrorEvent:
|
||||
case *ErrorEvent:
|
||||
return pg.InsertWebErrorEvent(sessionID, m)
|
||||
case *FetchEvent:
|
||||
return pg.InsertWebFetchEvent(sessionID, m)
|
||||
case *GraphQLEvent:
|
||||
return pg.InsertWebGraphQLEvent(sessionID, m)
|
||||
|
||||
// IOS
|
||||
// IOS
|
||||
case *IOSSessionStart:
|
||||
return pg.InsertIOSSessionStart(sessionID, m)
|
||||
case *IOSSessionEnd:
|
||||
|
|
@ -57,8 +61,8 @@ func insertMessage(sessionID uint64, msg Message) error {
|
|||
return pg.InsertIOSNetworkCall(sessionID, m)
|
||||
case *IOSScreenEnter:
|
||||
return pg.InsertIOSScreenEnter(sessionID, m)
|
||||
case *IOSCrash:
|
||||
case *IOSCrash:
|
||||
return pg.InsertIOSCrash(sessionID, m)
|
||||
}
|
||||
return nil // "Not implemented"
|
||||
}
|
||||
return nil // "Not implemented"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,33 +41,32 @@ func getResourceType(initiator string, URL string) string {
|
|||
}
|
||||
|
||||
type builder struct {
|
||||
readyMsgs []Message
|
||||
timestamp uint64
|
||||
lastProcessedTimestamp int64
|
||||
peBuilder *pageEventBuilder
|
||||
ptaBuilder *performanceTrackAggrBuilder
|
||||
ieBuilder *inputEventBuilder
|
||||
ciFinder *cpuIssueFinder
|
||||
miFinder *memoryIssueFinder
|
||||
ddDetector *domDropDetector
|
||||
crDetector *clickRageDetector
|
||||
dcDetector *deadClickDetector
|
||||
integrationsWaiting bool
|
||||
|
||||
readyMsgs []Message
|
||||
timestamp uint64
|
||||
lastProcessedTimestamp int64
|
||||
peBuilder *pageEventBuilder
|
||||
ptaBuilder *performanceTrackAggrBuilder
|
||||
ieBuilder *inputEventBuilder
|
||||
ciFinder *cpuIssueFinder
|
||||
miFinder *memoryIssueFinder
|
||||
ddDetector *domDropDetector
|
||||
crDetector *clickRageDetector
|
||||
dcDetector *deadClickDetector
|
||||
integrationsWaiting bool
|
||||
|
||||
sid uint64
|
||||
}
|
||||
|
||||
func NewBuilder() *builder {
|
||||
return &builder{
|
||||
peBuilder: &pageEventBuilder{},
|
||||
ptaBuilder: &performanceTrackAggrBuilder{},
|
||||
ieBuilder: NewInputEventBuilder(),
|
||||
ciFinder: &cpuIssueFinder{},
|
||||
miFinder: &memoryIssueFinder{},
|
||||
ddDetector: &domDropDetector{},
|
||||
crDetector: &clickRageDetector{},
|
||||
dcDetector: &deadClickDetector{},
|
||||
peBuilder: &pageEventBuilder{},
|
||||
ptaBuilder: &performanceTrackAggrBuilder{},
|
||||
ieBuilder: NewInputEventBuilder(),
|
||||
ciFinder: &cpuIssueFinder{},
|
||||
miFinder: &memoryIssueFinder{},
|
||||
ddDetector: &domDropDetector{},
|
||||
crDetector: &clickRageDetector{},
|
||||
dcDetector: &deadClickDetector{},
|
||||
integrationsWaiting: true,
|
||||
}
|
||||
}
|
||||
|
|
@ -115,15 +114,14 @@ func (b *builder) handleMessage(message Message, messageID uint64) {
|
|||
b.timestamp = timestamp
|
||||
}
|
||||
|
||||
b.lastProcessedTimestamp = time.Now().UnixNano()/1e6
|
||||
|
||||
b.lastProcessedTimestamp = time.Now().UnixNano() / 1e6
|
||||
|
||||
// Might happen before the first timestamp.
|
||||
switch msg := message.(type) {
|
||||
case *SessionStart,
|
||||
*Metadata,
|
||||
*UserID,
|
||||
*UserAnonymousID:
|
||||
*Metadata,
|
||||
*UserID,
|
||||
*UserAnonymousID:
|
||||
b.appendReadyMessage(msg)
|
||||
case *RawErrorEvent:
|
||||
b.appendReadyMessage(&ErrorEvent{
|
||||
|
|
@ -220,14 +218,14 @@ func (b *builder) handleMessage(message Message, messageID uint64) {
|
|||
Type: tp,
|
||||
Success: success,
|
||||
})
|
||||
if !success && tp == "fetch" {
|
||||
b.appendReadyMessage(&IssueEvent{
|
||||
Type: "bad_request",
|
||||
MessageID: messageID,
|
||||
Timestamp: msg.Timestamp,
|
||||
if !success && tp == "fetch" {
|
||||
b.appendReadyMessage(&IssueEvent{
|
||||
Type: "bad_request",
|
||||
MessageID: messageID,
|
||||
Timestamp: msg.Timestamp,
|
||||
ContextString: msg.URL,
|
||||
Context: "",
|
||||
Payload: "",
|
||||
Context: "",
|
||||
Payload: "",
|
||||
})
|
||||
}
|
||||
case *RawCustomEvent:
|
||||
|
|
@ -239,22 +237,31 @@ func (b *builder) handleMessage(message Message, messageID uint64) {
|
|||
})
|
||||
case *CustomIssue:
|
||||
b.appendReadyMessage(&IssueEvent{
|
||||
Type: "custom",
|
||||
Timestamp: b.timestamp,
|
||||
MessageID: messageID,
|
||||
Type: "custom",
|
||||
Timestamp: b.timestamp,
|
||||
MessageID: messageID,
|
||||
ContextString: msg.Name,
|
||||
Payload: msg.Payload,
|
||||
Payload: msg.Payload,
|
||||
})
|
||||
case *Fetch:
|
||||
b.appendReadyMessage(&ResourceEvent{
|
||||
b.appendReadyMessage(&FetchEvent{
|
||||
MessageID: messageID,
|
||||
Timestamp: msg.Timestamp,
|
||||
Duration: msg.Duration,
|
||||
URL: msg.URL,
|
||||
Type: "fetch",
|
||||
Success: msg.Status < 300,
|
||||
Method: msg.Method,
|
||||
URL: msg.URL,
|
||||
Request: msg.Request,
|
||||
Response: msg.Response,
|
||||
Status: msg.Status,
|
||||
Duration: msg.Duration,
|
||||
})
|
||||
case *GraphQL:
|
||||
b.appendReadyMessage(&GraphQLEvent{
|
||||
MessageID: messageID,
|
||||
Timestamp: b.timestamp,
|
||||
OperationKind: msg.OperationKind,
|
||||
OperationName: msg.OperationName,
|
||||
Variables: msg.Variables,
|
||||
Response: msg.Response,
|
||||
})
|
||||
case *StateAction:
|
||||
b.appendReadyMessage(&StateActionEvent{
|
||||
|
|
@ -262,12 +269,6 @@ func (b *builder) handleMessage(message Message, messageID uint64) {
|
|||
Timestamp: b.timestamp,
|
||||
Type: msg.Type,
|
||||
})
|
||||
case *GraphQL:
|
||||
b.appendReadyMessage(&GraphQLEvent{
|
||||
MessageID: messageID,
|
||||
Timestamp: b.timestamp,
|
||||
Name: msg.OperationName,
|
||||
})
|
||||
case *CreateElementNode,
|
||||
*CreateTextNode:
|
||||
b.ddDetector.HandleNodeCreation()
|
||||
|
|
@ -283,11 +284,10 @@ func (b *builder) handleMessage(message Message, messageID uint64) {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
func (b *builder) checkTimeouts(ts int64) bool {
|
||||
if b.timestamp == 0 {
|
||||
if b.timestamp == 0 {
|
||||
return false // There was no timestamp events yet
|
||||
}
|
||||
}
|
||||
|
||||
if b.peBuilder.HasInstance() && int64(b.peBuilder.GetTimestamp())+intervals.EVENTS_PAGE_EVENT_TIMEOUT < ts {
|
||||
b.buildPageEvent()
|
||||
|
|
|
|||
|
|
@ -81,20 +81,25 @@ def get_projects(tenant_id, recording_state=False, gdpr=None, recorded=False, st
|
|||
)
|
||||
rows = cur.fetchall()
|
||||
if recording_state:
|
||||
project_ids = [f'({r["project_id"]})' for r in rows]
|
||||
query = f"""SELECT projects.project_id, COALESCE(MAX(start_ts), 0) AS last
|
||||
FROM (VALUES {",".join(project_ids)}) AS projects(project_id)
|
||||
LEFT JOIN sessions USING (project_id)
|
||||
GROUP BY project_id;"""
|
||||
cur.execute(
|
||||
query=query
|
||||
)
|
||||
status = cur.fetchall()
|
||||
for r in rows:
|
||||
query = cur.mogrify(
|
||||
"select COALESCE(MAX(start_ts),0) AS last from public.sessions where project_id=%(project_id)s;",
|
||||
{"project_id": r["project_id"]})
|
||||
cur.execute(
|
||||
query=query
|
||||
)
|
||||
status = cur.fetchone()
|
||||
if status["last"] < TimeUTC.now(-2):
|
||||
r["status"] = "red"
|
||||
elif status["last"] < TimeUTC.now(-1):
|
||||
r["status"] = "yellow"
|
||||
else:
|
||||
r["status"] = "green"
|
||||
for s in status:
|
||||
if s["project_id"] == r["project_id"]:
|
||||
if s["last"] < TimeUTC.now(-2):
|
||||
r["status"] = "red"
|
||||
elif s["last"] < TimeUTC.now(-1):
|
||||
r["status"] = "yellow"
|
||||
else:
|
||||
r["status"] = "green"
|
||||
break
|
||||
|
||||
return helper.list_to_camel_case(rows)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import json
|
||||
|
||||
from decouple import config
|
||||
|
||||
import schemas
|
||||
from chalicelib.core import users, telemetry, tenants
|
||||
from chalicelib.utils import captcha
|
||||
|
|
@ -63,12 +61,11 @@ def create_step1(data: schemas.UserSignupSchema):
|
|||
params = {"email": email, "password": password,
|
||||
"fullname": fullname, "companyName": company_name,
|
||||
"projectName": project_name,
|
||||
"versionNumber": config("version_number"),
|
||||
"data": json.dumps({"lastAnnouncementView": TimeUTC.now()})}
|
||||
query = """\
|
||||
WITH t AS (
|
||||
INSERT INTO public.tenants (name, version_number, edition)
|
||||
VALUES (%(companyName)s, %(versionNumber)s, 'ee')
|
||||
VALUES (%(companyName)s, (SELECT openreplay_version()), 'ee')
|
||||
RETURNING tenant_id, api_key
|
||||
),
|
||||
r AS (
|
||||
|
|
|
|||
100
ee/scripts/helm/db/init_dbs/postgresql/1.5.3/1.5.3.sql
Normal file
100
ee/scripts/helm/db/init_dbs/postgresql/1.5.3/1.5.3.sql
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
BEGIN;
|
||||
CREATE OR REPLACE FUNCTION openreplay_version()
|
||||
RETURNS text AS
|
||||
$$
|
||||
SELECT 'v1.5.3-ee'
|
||||
$$ LANGUAGE sql IMMUTABLE;
|
||||
|
||||
UPDATE metrics
|
||||
SET is_public= TRUE;
|
||||
|
||||
|
||||
DO
|
||||
$$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT *
|
||||
FROM pg_type typ
|
||||
INNER JOIN pg_namespace nsp
|
||||
ON nsp.oid = typ.typnamespace
|
||||
WHERE nsp.nspname = current_schema()
|
||||
AND typ.typname = 'metric_type') THEN
|
||||
CREATE TYPE metric_type AS ENUM ('timeseries','table');
|
||||
END IF;
|
||||
END;
|
||||
$$
|
||||
LANGUAGE plpgsql;
|
||||
|
||||
DO
|
||||
$$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT *
|
||||
FROM pg_type typ
|
||||
INNER JOIN pg_namespace nsp
|
||||
ON nsp.oid = typ.typnamespace
|
||||
WHERE nsp.nspname = current_schema()
|
||||
AND typ.typname = 'metric_view_type') THEN
|
||||
CREATE TYPE metric_view_type AS ENUM ('lineChart','progress','table','pieChart');
|
||||
END IF;
|
||||
END;
|
||||
$$
|
||||
LANGUAGE plpgsql;
|
||||
|
||||
ALTER TABLE metrics
|
||||
ADD COLUMN IF NOT EXISTS
|
||||
metric_type metric_type NOT NULL DEFAULT 'timeseries',
|
||||
ADD COLUMN IF NOT EXISTS
|
||||
view_type metric_view_type NOT NULL DEFAULT 'lineChart',
|
||||
ADD COLUMN IF NOT EXISTS
|
||||
metric_of text NOT NULL DEFAULT 'sessionCount',
|
||||
ADD COLUMN IF NOT EXISTS
|
||||
metric_value text[] NOT NULL DEFAULT '{}'::text[],
|
||||
ADD COLUMN IF NOT EXISTS
|
||||
metric_format text;
|
||||
|
||||
|
||||
DO
|
||||
$$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT *
|
||||
FROM pg_type typ
|
||||
INNER JOIN pg_namespace nsp
|
||||
ON nsp.oid = typ.typnamespace
|
||||
WHERE typ.typname = 'http_method') THEN
|
||||
CREATE TYPE http_method AS ENUM ('GET','HEAD','POST','PUT','DELETE','CONNECT','OPTIONS','TRACE','PATCH');
|
||||
END IF;
|
||||
END;
|
||||
$$
|
||||
LANGUAGE plpgsql;
|
||||
|
||||
|
||||
ALTER TABLE events.graphql
|
||||
ADD COLUMN IF NOT EXISTS request_body text NULL,
|
||||
ADD COLUMN IF NOT EXISTS response_body text NULL,
|
||||
ADD COLUMN IF NOT EXISTS status_code smallint NULL,
|
||||
ADD COLUMN IF NOT EXISTS method http_method NULL,
|
||||
ADD COLUMN IF NOT EXISTS duration integer NULL;
|
||||
|
||||
ALTER TABLE events_common.requests
|
||||
ADD COLUMN IF NOT EXISTS request_body text NULL,
|
||||
ADD COLUMN IF NOT EXISTS response_body text NULL,
|
||||
ADD COLUMN IF NOT EXISTS status_code smallint NULL,
|
||||
ADD COLUMN IF NOT EXISTS method http_method NULL;
|
||||
|
||||
UPDATE tenants
|
||||
SET version_number= openreplay_version();
|
||||
|
||||
COMMIT;
|
||||
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS requests_request_body_nn_idx ON events_common.requests (request_body) WHERE request_body IS NOT NULL;
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS requests_request_body_nn_gin_idx ON events_common.requests USING GIN (request_body gin_trgm_ops) WHERE request_body IS NOT NULL;
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS requests_response_body_nn_idx ON events_common.requests (response_body) WHERE response_body IS NOT NULL;
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS requests_response_body_nn_gin_idx ON events_common.requests USING GIN (response_body gin_trgm_ops) WHERE response_body IS NOT NULL;
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS requests_status_code_nn_idx ON events_common.requests (status_code) WHERE status_code IS NOT NULL;
|
||||
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS graphql_request_body_nn_idx ON events.graphql (request_body) WHERE request_body IS NOT NULL;
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS graphql_request_body_nn_gin_idx ON events.graphql USING GIN (request_body gin_trgm_ops) WHERE request_body IS NOT NULL;
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS graphql_response_body_nn_idx ON events.graphql (response_body) WHERE response_body IS NOT NULL;
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS graphql_response_body_nn_gin_idx ON events.graphql USING GIN (response_body gin_trgm_ops) WHERE response_body IS NOT NULL;
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS graphql_status_code_nn_idx ON events.graphql (status_code) WHERE status_code IS NOT NULL;
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS graphql_duration_nn_gt0_idx ON events.graphql (duration) WHERE duration IS NOT NULL AND duration > 0;
|
||||
|
||||
|
|
@ -7,7 +7,7 @@ CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
|||
CREATE OR REPLACE FUNCTION openreplay_version()
|
||||
RETURNS text AS
|
||||
$$
|
||||
SELECT 'v1.5.2-ee'
|
||||
SELECT 'v1.5.3-ee'
|
||||
$$ LANGUAGE sql IMMUTABLE;
|
||||
|
||||
|
||||
|
|
@ -770,16 +770,23 @@ $$
|
|||
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 TYPE metric_type AS ENUM ('timeseries','table');
|
||||
CREATE TYPE metric_view_type AS ENUM ('lineChart','progress','table','pieChart');
|
||||
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,
|
||||
active boolean NOT NULL DEFAULT TRUE,
|
||||
created_at timestamp default timezone('utc'::text, now()) not null,
|
||||
deleted_at timestamp
|
||||
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,
|
||||
active boolean NOT NULL DEFAULT TRUE,
|
||||
created_at timestamp DEFAULT timezone('utc'::text, now()) not null,
|
||||
deleted_at timestamp,
|
||||
metric_type metric_type NOT NULL DEFAULT 'timeseries',
|
||||
view_type metric_view_type NOT NULL DEFAULT 'lineChart',
|
||||
metric_of text NOT NULL DEFAULT 'sessionCount',
|
||||
metric_value text[] NOT NULL DEFAULT '{}'::text[],
|
||||
metric_format text
|
||||
);
|
||||
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
|
||||
|
|
@ -987,7 +994,11 @@ $$
|
|||
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 errors_error_id_idx ON events.errors (error_id);
|
||||
|
||||
|
||||
IF NOT EXISTS(SELECT *
|
||||
FROM pg_type typ
|
||||
WHERE typ.typname = 'http_method') THEN
|
||||
CREATE TYPE http_method AS ENUM ('GET','HEAD','POST','PUT','DELETE','CONNECT','OPTIONS','TRACE','PATCH');
|
||||
END IF;
|
||||
CREATE TABLE IF NOT EXISTS events.graphql
|
||||
(
|
||||
session_id bigint NOT NULL REFERENCES sessions (session_id) ON DELETE CASCADE,
|
||||
|
|
@ -999,6 +1010,12 @@ $$
|
|||
CREATE INDEX IF NOT EXISTS graphql_name_idx ON events.graphql (name);
|
||||
CREATE INDEX IF NOT EXISTS graphql_name_gin_idx ON events.graphql USING GIN (name gin_trgm_ops);
|
||||
CREATE INDEX IF NOT EXISTS graphql_timestamp_idx ON events.graphql (timestamp);
|
||||
CREATE INDEX IF NOT EXISTS graphql_request_body_nn_idx ON events.graphql (request_body) WHERE request_body IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS graphql_request_body_nn_gin_idx ON events.graphql USING GIN (request_body gin_trgm_ops) WHERE request_body IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS graphql_response_body_nn_idx ON events.graphql (response_body) WHERE response_body IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS graphql_response_body_nn_gin_idx ON events.graphql USING GIN (response_body gin_trgm_ops) WHERE response_body IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS graphql_status_code_nn_idx ON events.graphql (status_code) WHERE status_code IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS graphql_duration_nn_gt0_idx ON events.graphql (duration) WHERE duration IS NOT NULL AND duration > 0;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events.state_actions
|
||||
(
|
||||
|
|
@ -1140,15 +1157,23 @@ $$
|
|||
CREATE INDEX IF NOT EXISTS issues_issue_id_timestamp_idx ON events_common.issues (issue_id, timestamp);
|
||||
CREATE INDEX IF NOT EXISTS issues_timestamp_idx ON events_common.issues (timestamp);
|
||||
|
||||
|
||||
IF NOT EXISTS(SELECT *
|
||||
FROM pg_type typ
|
||||
WHERE typ.typname = 'http_method') THEN
|
||||
CREATE TYPE http_method AS ENUM ('GET','HEAD','POST','PUT','DELETE','CONNECT','OPTIONS','TRACE','PATCH');
|
||||
END IF;
|
||||
CREATE TABLE IF NOT EXISTS events_common.requests
|
||||
(
|
||||
session_id bigint NOT NULL REFERENCES sessions (session_id) ON DELETE CASCADE,
|
||||
timestamp bigint NOT NULL,
|
||||
seq_index integer NOT NULL,
|
||||
url text NOT NULL,
|
||||
duration integer NOT NULL,
|
||||
success boolean NOT NULL,
|
||||
session_id bigint NOT NULL REFERENCES sessions (session_id) ON DELETE CASCADE,
|
||||
timestamp bigint NOT NULL,
|
||||
seq_index integer NOT NULL,
|
||||
url text NOT NULL,
|
||||
duration integer NOT NULL,
|
||||
success boolean NOT NULL,
|
||||
request_body text NULL,
|
||||
response_body text NULL,
|
||||
status_code smallint NULL,
|
||||
method http_method NULL,
|
||||
PRIMARY KEY (session_id, timestamp, seq_index)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS requests_url_idx ON events_common.requests (url);
|
||||
|
|
@ -1165,6 +1190,13 @@ $$
|
|||
ELSE 0 END))
|
||||
gin_trgm_ops);
|
||||
CREATE INDEX IF NOT EXISTS requests_timestamp_session_id_failed_idx ON events_common.requests (timestamp, session_id) WHERE success = FALSE;
|
||||
CREATE INDEX IF NOT EXISTS requests_request_body_nn_idx ON events_common.requests (request_body) WHERE request_body IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS requests_request_body_nn_gin_idx ON events_common.requests USING GIN (request_body gin_trgm_ops) WHERE request_body IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS requests_response_body_nn_idx ON events_common.requests (response_body) WHERE response_body IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS requests_response_body_nn_gin_idx ON events_common.requests USING GIN (response_body gin_trgm_ops) WHERE response_body IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS requests_status_code_nn_idx ON events_common.requests (status_code) WHERE status_code IS NOT NULL;
|
||||
|
||||
|
||||
END IF;
|
||||
END;
|
||||
$$
|
||||
|
|
|
|||
13
ee/utilities/.gitignore
vendored
Normal file
13
ee/utilities/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
.idea
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.cache
|
||||
test.html
|
||||
build.sh
|
||||
|
||||
|
||||
|
||||
servers/peerjs-server.js
|
||||
servers/sourcemaps-handler.js
|
||||
servers/sourcemaps-server.js
|
||||
#servers/websocket.js
|
||||
2699
ee/utilities/package-lock.json
generated
Normal file
2699
ee/utilities/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
32
ee/utilities/package.json
Normal file
32
ee/utilities/package.json
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"name": "utilities_server",
|
||||
"version": "1.0.0",
|
||||
"description": "assist server to get live sessions & sourcemaps reader to get stack trace",
|
||||
"main": "peerjs-server.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"start": "node server.js"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/openreplay/openreplay.git"
|
||||
},
|
||||
"author": "KRAIEM Taha Yassine <tahayk2@gmail.com>",
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://github.com/openreplay/openreplay/issues"
|
||||
},
|
||||
"homepage": "https://github.com/openreplay/openreplay#readme",
|
||||
"dependencies": {
|
||||
"@maxmind/geoip2-node": "^3.4.0",
|
||||
"@socket.io/redis-adapter": "^7.1.0",
|
||||
"aws-sdk": "^2.992.0",
|
||||
"express": "^4.17.1",
|
||||
"peer": "^0.6.1",
|
||||
"redis": "^4.0.3",
|
||||
"socket.io": "^4.4.1",
|
||||
"source-map": "^0.7.3",
|
||||
"ua-parser-js": "^1.0.2",
|
||||
"uWebSockets.js": "github:uNetworking/uWebSockets.js#v20.6.0"
|
||||
}
|
||||
}
|
||||
128
ee/utilities/server.js
Normal file
128
ee/utilities/server.js
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
var sourcemapsReaderServer = require('./servers/sourcemaps-server');
|
||||
var {peerRouter, peerConnection, peerDisconnect, peerError} = require('./servers/peerjs-server');
|
||||
var express = require('express');
|
||||
const {ExpressPeerServer} = require('peer');
|
||||
var socket;
|
||||
if (process.env.cluster === "true") {
|
||||
console.log("Using Redis");
|
||||
socket = require("./servers/websocket-cluster");
|
||||
} else {
|
||||
socket = require("./servers/websocket");
|
||||
}
|
||||
|
||||
const HOST = '0.0.0.0';
|
||||
const PORT = 9000;
|
||||
|
||||
var app = express();
|
||||
|
||||
let debug = process.env.debug === "1" || false;
|
||||
const request_logger = (identity) => {
|
||||
return (req, res, next) => {
|
||||
debug && console.log(identity, new Date().toTimeString(), 'REQUEST', req.method, req.originalUrl);
|
||||
res.on('finish', function () {
|
||||
if (this.statusCode !== 200 || debug) {
|
||||
console.log(new Date().toTimeString(), 'RESPONSE', req.method, req.originalUrl, this.statusCode);
|
||||
}
|
||||
})
|
||||
|
||||
next();
|
||||
}
|
||||
};
|
||||
app.use(request_logger("[app]"));
|
||||
|
||||
|
||||
app.use('/sourcemaps', sourcemapsReaderServer);
|
||||
app.use('/assist', peerRouter);
|
||||
|
||||
const server = app.listen(PORT, HOST, () => {
|
||||
console.log(`App listening on http://${HOST}:${PORT}`);
|
||||
console.log('Press Ctrl+C to quit.');
|
||||
});
|
||||
|
||||
const peerServer = ExpressPeerServer(server, {
|
||||
debug: true,
|
||||
path: '/',
|
||||
proxied: true,
|
||||
allow_discovery: false
|
||||
});
|
||||
peerServer.on('connection', peerConnection);
|
||||
peerServer.on('disconnect', peerDisconnect);
|
||||
peerServer.on('error', peerError);
|
||||
app.use('/', peerServer);
|
||||
app.enable('trust proxy');
|
||||
|
||||
if (process.env.uws !== "true") {
|
||||
var wsapp = express();
|
||||
wsapp.use(request_logger("[wsapp]"));
|
||||
wsapp.use('/assist', socket.wsRouter);
|
||||
|
||||
const wsserver = wsapp.listen(PORT + 1, HOST, () => {
|
||||
console.log(`WS App listening on http://${HOST}:${PORT + 1}`);
|
||||
console.log('Press Ctrl+C to quit.');
|
||||
});
|
||||
wsapp.enable('trust proxy');
|
||||
socket.start(wsserver);
|
||||
module.exports = {wsserver, server};
|
||||
} else {
|
||||
console.log("Using uWebSocket");
|
||||
const {App} = require("uWebSockets.js");
|
||||
const PREFIX = process.env.prefix || '/assist'
|
||||
|
||||
const uapp = new App();
|
||||
|
||||
const healthFn = (res, req) => {
|
||||
res.writeStatus('200 OK').end('ok!');
|
||||
}
|
||||
uapp.get(PREFIX, healthFn);
|
||||
uapp.get(`${PREFIX}/`, healthFn);
|
||||
|
||||
|
||||
/* Either onAborted or simply finished request */
|
||||
const onAbortedOrFinishedResponse = function (res, readStream) {
|
||||
|
||||
if (res.id === -1) {
|
||||
debug && console.log("ERROR! onAbortedOrFinishedResponse called twice for the same res!");
|
||||
} else {
|
||||
debug && console.log('Stream was closed');
|
||||
console.timeEnd(res.id);
|
||||
readStream.destroy();
|
||||
}
|
||||
|
||||
/* Mark this response already accounted for */
|
||||
res.id = -1;
|
||||
}
|
||||
|
||||
const uWrapper = function (fn) {
|
||||
return (res, req) => {
|
||||
res.id = 1;
|
||||
res.onAborted(() => {
|
||||
onAbortedOrFinishedResponse(res, readStream);
|
||||
});
|
||||
return fn(req, res);
|
||||
}
|
||||
}
|
||||
uapp.get(`${PREFIX}/${process.env.S3_KEY}/sockets-list`, uWrapper(socket.handlers.socketsList));
|
||||
uapp.get(`${PREFIX}/${process.env.S3_KEY}/sockets-list/:projectKey`, uWrapper(socket.handlers.socketsListByProject));
|
||||
|
||||
uapp.get(`${PREFIX}/${process.env.S3_KEY}/sockets-live`, uWrapper(socket.handlers.socketsLive));
|
||||
uapp.get(`${PREFIX}/${process.env.S3_KEY}/sockets-live/:projectKey`, uWrapper(socket.handlers.socketsLiveByProject));
|
||||
|
||||
|
||||
socket.start(uapp);
|
||||
|
||||
uapp.listen(HOST, PORT + 1, (token) => {
|
||||
if (!token) {
|
||||
console.warn("port already in use");
|
||||
}
|
||||
console.log(`WS App listening on http://${HOST}:${PORT + 1}`);
|
||||
console.log('Press Ctrl+C to quit.');
|
||||
});
|
||||
|
||||
|
||||
process.on('uncaughtException', err => {
|
||||
console.log(`Uncaught Exception: ${err.message}`);
|
||||
debug && console.log(err.stack);
|
||||
// process.exit(1);
|
||||
});
|
||||
module.exports = {uapp, server};
|
||||
}
|
||||
371
ee/utilities/servers/websocket-cluster.js
Normal file
371
ee/utilities/servers/websocket-cluster.js
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
const _io = require('socket.io');
|
||||
const express = require('express');
|
||||
const uaParser = require('ua-parser-js');
|
||||
const geoip2Reader = require('@maxmind/geoip2-node').Reader;
|
||||
const {extractPeerId} = require('./peerjs-server');
|
||||
const {createAdapter} = require("@socket.io/redis-adapter");
|
||||
const {createClient} = require("redis");
|
||||
|
||||
var wsRouter = express.Router();
|
||||
const UPDATE_EVENT = "UPDATE_SESSION";
|
||||
const IDENTITIES = {agent: 'agent', session: 'session'};
|
||||
const NEW_AGENT = "NEW_AGENT";
|
||||
const NO_AGENTS = "NO_AGENT";
|
||||
const AGENT_DISCONNECT = "AGENT_DISCONNECTED";
|
||||
const AGENTS_CONNECTED = "AGENTS_CONNECTED";
|
||||
const NO_SESSIONS = "SESSION_DISCONNECTED";
|
||||
const SESSION_ALREADY_CONNECTED = "SESSION_ALREADY_CONNECTED";
|
||||
// const wsReconnectionTimeout = process.env.wsReconnectionTimeout | 10 * 1000;
|
||||
|
||||
let io;
|
||||
const debug = process.env.debug === "1" || false;
|
||||
const REDIS_URL = process.env.REDIS_URL || "redis://localhost:6379";
|
||||
|
||||
const pubClient = createClient({url: REDIS_URL});
|
||||
const subClient = pubClient.duplicate();
|
||||
|
||||
const uniqueSessions = function (data) {
|
||||
let resArr = [];
|
||||
let resArrIDS = [];
|
||||
for (let item of data) {
|
||||
if (resArrIDS.indexOf(item.sessionID) < 0) {
|
||||
resArr.push(item);
|
||||
resArrIDS.push(item.sessionID);
|
||||
}
|
||||
}
|
||||
return resArr;
|
||||
}
|
||||
|
||||
const socketsList = async function (req, res) {
|
||||
debug && console.log("[WS]looking for all available sessions");
|
||||
let liveSessions = {};
|
||||
let rooms = await io.of('/').adapter.allRooms();
|
||||
for (let peerId of rooms) {
|
||||
let {projectKey, sessionId} = extractPeerId(peerId);
|
||||
if (projectKey !== undefined) {
|
||||
liveSessions[projectKey] = liveSessions[projectKey] || [];
|
||||
liveSessions[projectKey].push(sessionId);
|
||||
}
|
||||
}
|
||||
let result = {"data": liveSessions};
|
||||
if (process.env.uws !== "true") {
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify(result));
|
||||
} else {
|
||||
res.writeStatus('200 OK').writeHeader('Content-Type', 'application/json').end(JSON.stringify(result));
|
||||
}
|
||||
}
|
||||
wsRouter.get(`/${process.env.S3_KEY}/sockets-list`, socketsList);
|
||||
|
||||
const socketsListByProject = async function (req, res) {
|
||||
if (process.env.uws === "true") {
|
||||
req.params = {projectKey: req.getParameter(0)};
|
||||
}
|
||||
debug && console.log(`[WS]looking for available sessions for ${req.params.projectKey}`);
|
||||
let liveSessions = {};
|
||||
let rooms = await io.of('/').adapter.allRooms();
|
||||
for (let peerId of rooms) {
|
||||
let {projectKey, sessionId} = extractPeerId(peerId);
|
||||
if (projectKey === req.params.projectKey) {
|
||||
liveSessions[projectKey] = liveSessions[projectKey] || [];
|
||||
liveSessions[projectKey].push(sessionId);
|
||||
}
|
||||
}
|
||||
let result = {"data": liveSessions[req.params.projectKey] || []};
|
||||
if (process.env.uws !== "true") {
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify(result));
|
||||
} else {
|
||||
res.writeStatus('200 OK').writeHeader('Content-Type', 'application/json').end(JSON.stringify(result));
|
||||
}
|
||||
}
|
||||
wsRouter.get(`/${process.env.S3_KEY}/sockets-list/:projectKey`, socketsListByProject);
|
||||
|
||||
const socketsLive = async function (req, res) {
|
||||
debug && console.log("[WS]looking for all available LIVE sessions");
|
||||
let liveSessions = {};
|
||||
let rooms = await io.of('/').adapter.allRooms();
|
||||
for (let peerId of rooms) {
|
||||
let {projectKey, sessionId} = extractPeerId(peerId);
|
||||
if (projectKey !== undefined) {
|
||||
let connected_sockets = await io.in(peerId).fetchSockets();
|
||||
for (let item of connected_sockets) {
|
||||
if (item.handshake.query.identity === IDENTITIES.session) {
|
||||
liveSessions[projectKey] = liveSessions[projectKey] || [];
|
||||
liveSessions[projectKey].push(item.handshake.query.sessionInfo);
|
||||
}
|
||||
}
|
||||
liveSessions[projectKey] = uniqueSessions(liveSessions[projectKey]);
|
||||
}
|
||||
}
|
||||
let result = {"data": liveSessions};
|
||||
if (process.env.uws !== "true") {
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify(result));
|
||||
} else {
|
||||
res.writeStatus('200 OK').writeHeader('Content-Type', 'application/json').end(JSON.stringify(result));
|
||||
}
|
||||
}
|
||||
wsRouter.get(`/${process.env.S3_KEY}/sockets-live`, socketsLive);
|
||||
|
||||
const socketsLiveByProject = async function (req, res) {
|
||||
if (process.env.uws === "true") {
|
||||
req.params = {projectKey: req.getParameter(0)};
|
||||
}
|
||||
debug && console.log(`[WS]looking for available LIVE sessions for ${req.params.projectKey}`);
|
||||
let liveSessions = {};
|
||||
let rooms = await io.of('/').adapter.allRooms();
|
||||
for (let peerId of rooms) {
|
||||
let {projectKey, sessionId} = extractPeerId(peerId);
|
||||
if (projectKey === req.params.projectKey) {
|
||||
let connected_sockets = await io.in(peerId).fetchSockets();
|
||||
for (let item of connected_sockets) {
|
||||
if (item.handshake.query.identity === IDENTITIES.session) {
|
||||
liveSessions[projectKey] = liveSessions[projectKey] || [];
|
||||
liveSessions[projectKey].push(item.handshake.query.sessionInfo);
|
||||
}
|
||||
}
|
||||
liveSessions[projectKey] = uniqueSessions(liveSessions[projectKey]);
|
||||
}
|
||||
}
|
||||
let result = {"data": liveSessions[req.params.projectKey] || []};
|
||||
if (process.env.uws !== "true") {
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify(result));
|
||||
} else {
|
||||
res.writeStatus('200 OK').writeHeader('Content-Type', 'application/json').end(JSON.stringify(result));
|
||||
}
|
||||
}
|
||||
wsRouter.get(`/${process.env.S3_KEY}/sockets-live/:projectKey`, socketsLiveByProject);
|
||||
|
||||
const findSessionSocketId = async (io, peerId) => {
|
||||
const connected_sockets = await io.in(peerId).fetchSockets();
|
||||
for (let item of connected_sockets) {
|
||||
if (item.handshake.query.identity === IDENTITIES.session) {
|
||||
return item.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
async function sessions_agents_count(io, socket) {
|
||||
let c_sessions = 0, c_agents = 0;
|
||||
let rooms = await io.of('/').adapter.allRooms();
|
||||
if (rooms.has(socket.peerId)) {
|
||||
const connected_sockets = await io.in(socket.peerId).fetchSockets();
|
||||
|
||||
for (let item of connected_sockets) {
|
||||
if (item.handshake.query.identity === IDENTITIES.session) {
|
||||
c_sessions++;
|
||||
} else {
|
||||
c_agents++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
c_agents = -1;
|
||||
c_sessions = -1;
|
||||
}
|
||||
return {c_sessions, c_agents};
|
||||
}
|
||||
|
||||
async function get_all_agents_ids(io, socket) {
|
||||
let agents = [];
|
||||
let rooms = await io.of('/').adapter.allRooms();
|
||||
if (rooms.has(socket.peerId)) {
|
||||
const connected_sockets = await io.in(socket.peerId).fetchSockets();
|
||||
for (let item of connected_sockets) {
|
||||
if (item.handshake.query.identity === IDENTITIES.agent) {
|
||||
agents.push(item.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return agents;
|
||||
}
|
||||
|
||||
function extractSessionInfo(socket) {
|
||||
if (socket.handshake.query.sessionInfo !== undefined) {
|
||||
debug && console.log("received headers");
|
||||
debug && console.log(socket.handshake.headers);
|
||||
socket.handshake.query.sessionInfo = JSON.parse(socket.handshake.query.sessionInfo);
|
||||
|
||||
let ua = uaParser(socket.handshake.headers['user-agent']);
|
||||
socket.handshake.query.sessionInfo.userOs = ua.os.name || null;
|
||||
socket.handshake.query.sessionInfo.userBrowser = ua.browser.name || null;
|
||||
socket.handshake.query.sessionInfo.userBrowserVersion = ua.browser.version || null;
|
||||
socket.handshake.query.sessionInfo.userDevice = ua.device.model || null;
|
||||
socket.handshake.query.sessionInfo.userDeviceType = ua.device.type || 'desktop';
|
||||
socket.handshake.query.sessionInfo.userCountry = null;
|
||||
|
||||
const options = {
|
||||
// you can use options like `cache` or `watchForUpdates`
|
||||
};
|
||||
// console.log("Looking for MMDB file in " + process.env.MAXMINDDB_FILE);
|
||||
geoip2Reader.open(process.env.MAXMINDDB_FILE, options)
|
||||
.then(reader => {
|
||||
debug && console.log("looking for location of ");
|
||||
debug && console.log(socket.handshake.headers['x-forwarded-for'] || socket.handshake.address);
|
||||
let country = reader.country(socket.handshake.headers['x-forwarded-for'] || socket.handshake.address);
|
||||
socket.handshake.query.sessionInfo.userCountry = country.country.isoCode;
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
wsRouter,
|
||||
start: (server) => {
|
||||
if (process.env.uws !== "true") {
|
||||
io = _io(server, {
|
||||
maxHttpBufferSize: (parseInt(process.env.maxHttpBufferSize) || 5) * 1e6,
|
||||
cors: {
|
||||
origin: "*",
|
||||
methods: ["GET", "POST", "PUT"]
|
||||
},
|
||||
path: '/socket'
|
||||
});
|
||||
} else {
|
||||
io = new _io.Server({
|
||||
maxHttpBufferSize: (parseInt(process.env.maxHttpBufferSize) || 5) * 1e6,
|
||||
cors: {
|
||||
origin: "*",
|
||||
methods: ["GET", "POST", "PUT"]
|
||||
},
|
||||
path: '/socket',
|
||||
// transports: ['websocket'],
|
||||
// upgrade: false
|
||||
});
|
||||
io.attachApp(server);
|
||||
}
|
||||
|
||||
io.on('connection', async (socket) => {
|
||||
debug && console.log(`WS started:${socket.id}, Query:${JSON.stringify(socket.handshake.query)}`);
|
||||
socket.peerId = socket.handshake.query.peerId;
|
||||
socket.identity = socket.handshake.query.identity;
|
||||
let {projectKey, sessionId} = extractPeerId(socket.peerId);
|
||||
socket.sessionId = sessionId;
|
||||
socket.projectKey = projectKey;
|
||||
socket.lastMessageReceivedAt = Date.now();
|
||||
let {c_sessions, c_agents} = await sessions_agents_count(io, socket);
|
||||
if (socket.identity === IDENTITIES.session) {
|
||||
if (c_sessions > 0) {
|
||||
debug && console.log(`session already connected, refusing new connexion`);
|
||||
io.to(socket.id).emit(SESSION_ALREADY_CONNECTED);
|
||||
return socket.disconnect();
|
||||
}
|
||||
extractSessionInfo(socket);
|
||||
if (c_agents > 0) {
|
||||
debug && console.log(`notifying new session about agent-existence`);
|
||||
let agents_ids = await get_all_agents_ids(io, socket);
|
||||
io.to(socket.id).emit(AGENTS_CONNECTED, agents_ids);
|
||||
}
|
||||
|
||||
} else if (c_sessions <= 0) {
|
||||
debug && console.log(`notifying new agent about no SESSIONS`);
|
||||
io.to(socket.id).emit(NO_SESSIONS);
|
||||
}
|
||||
await io.of('/').adapter.remoteJoin(socket.id, socket.peerId);
|
||||
let rooms = await io.of('/').adapter.allRooms();
|
||||
if (rooms.has(socket.peerId)) {
|
||||
let connectedSockets = await io.in(socket.peerId).fetchSockets();
|
||||
debug && console.log(`${socket.id} joined room:${socket.peerId}, as:${socket.identity}, members:${connectedSockets.length}`);
|
||||
}
|
||||
if (socket.identity === IDENTITIES.agent) {
|
||||
if (socket.handshake.query.agentInfo !== undefined) {
|
||||
socket.handshake.query.agentInfo = JSON.parse(socket.handshake.query.agentInfo);
|
||||
}
|
||||
socket.to(socket.peerId).emit(NEW_AGENT, socket.id, socket.handshake.query.agentInfo);
|
||||
}
|
||||
|
||||
socket.on('disconnect', async () => {
|
||||
debug && console.log(`${socket.id} disconnected from ${socket.peerId}`);
|
||||
if (socket.identity === IDENTITIES.agent) {
|
||||
socket.to(socket.peerId).emit(AGENT_DISCONNECT, socket.id);
|
||||
}
|
||||
debug && console.log("checking for number of connected agents and sessions");
|
||||
let {c_sessions, c_agents} = await sessions_agents_count(io, socket);
|
||||
if (c_sessions === -1 && c_agents === -1) {
|
||||
debug && console.log(`room not found: ${socket.peerId}`);
|
||||
}
|
||||
if (c_sessions === 0) {
|
||||
debug && console.log(`notifying everyone in ${socket.peerId} about no SESSIONS`);
|
||||
socket.to(socket.peerId).emit(NO_SESSIONS);
|
||||
}
|
||||
if (c_agents === 0) {
|
||||
debug && console.log(`notifying everyone in ${socket.peerId} about no AGENTS`);
|
||||
socket.to(socket.peerId).emit(NO_AGENTS);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on(UPDATE_EVENT, async (...args) => {
|
||||
debug && console.log(`${socket.id} sent update event.`);
|
||||
if (socket.identity !== IDENTITIES.session) {
|
||||
debug && console.log('Ignoring update event.');
|
||||
return
|
||||
}
|
||||
socket.handshake.query.sessionInfo = {...socket.handshake.query.sessionInfo, ...args[0]};
|
||||
socket.to(socket.peerId).emit(UPDATE_EVENT, args[0]);
|
||||
});
|
||||
|
||||
socket.onAny(async (eventName, ...args) => {
|
||||
socket.lastMessageReceivedAt = Date.now();
|
||||
if (socket.identity === IDENTITIES.session) {
|
||||
debug && console.log(`received event:${eventName}, from:${socket.identity}, sending message to room:${socket.peerId}`);
|
||||
socket.to(socket.peerId).emit(eventName, args[0]);
|
||||
} else {
|
||||
debug && console.log(`received event:${eventName}, from:${socket.identity}, sending message to session of room:${socket.peerId}`);
|
||||
let socketId = await findSessionSocketId(io, socket.peerId);
|
||||
if (socketId === null) {
|
||||
debug && console.log(`session not found for:${socket.peerId}`);
|
||||
io.to(socket.id).emit(NO_SESSIONS);
|
||||
} else {
|
||||
debug && console.log("message sent");
|
||||
io.to(socketId).emit(eventName, socket.id, args[0]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
console.log("WS server started")
|
||||
setInterval(async (io) => {
|
||||
try {
|
||||
let rooms = await io.of('/').adapter.allRooms();
|
||||
let validRooms = [];
|
||||
console.log(` ====== Rooms: ${rooms.size} ====== `);
|
||||
const arr = Array.from(rooms)
|
||||
// const filtered = arr.filter(room => !room[1].has(room[0]))
|
||||
for (let i of rooms) {
|
||||
let {projectKey, sessionId} = extractPeerId(i);
|
||||
if (projectKey !== undefined && sessionId !== undefined) {
|
||||
validRooms.push(i);
|
||||
}
|
||||
}
|
||||
console.log(` ====== Valid Rooms: ${validRooms.length} ====== `);
|
||||
if (debug) {
|
||||
for (let item of validRooms) {
|
||||
let connectedSockets = await io.in(item).fetchSockets();
|
||||
console.log(`Room: ${item} connected: ${connectedSockets.length}`)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}, 20000, io);
|
||||
Promise.all([pubClient.connect(), subClient.connect()]).then(() => {
|
||||
io.adapter(createAdapter(pubClient, subClient));
|
||||
console.log("> redis connected.");
|
||||
// io.listen(3000);
|
||||
});
|
||||
},
|
||||
handlers: {
|
||||
socketsList,
|
||||
socketsListByProject,
|
||||
socketsLive,
|
||||
socketsLiveByProject
|
||||
}
|
||||
};
|
||||
334
ee/utilities/servers/websocket.js
Normal file
334
ee/utilities/servers/websocket.js
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
const _io = require('socket.io');
|
||||
const express = require('express');
|
||||
const uaParser = require('ua-parser-js');
|
||||
const geoip2Reader = require('@maxmind/geoip2-node').Reader;
|
||||
var {extractPeerId} = require('./peerjs-server');
|
||||
var wsRouter = express.Router();
|
||||
const UPDATE_EVENT = "UPDATE_SESSION";
|
||||
const IDENTITIES = {agent: 'agent', session: 'session'};
|
||||
const NEW_AGENT = "NEW_AGENT";
|
||||
const NO_AGENTS = "NO_AGENT";
|
||||
const AGENT_DISCONNECT = "AGENT_DISCONNECTED";
|
||||
const AGENTS_CONNECTED = "AGENTS_CONNECTED";
|
||||
const NO_SESSIONS = "SESSION_DISCONNECTED";
|
||||
const SESSION_ALREADY_CONNECTED = "SESSION_ALREADY_CONNECTED";
|
||||
// const wsReconnectionTimeout = process.env.wsReconnectionTimeout | 10 * 1000;
|
||||
|
||||
let io;
|
||||
let debug = process.env.debug === "1" || false;
|
||||
|
||||
const socketsList = function (req, res) {
|
||||
debug && console.log("[WS]looking for all available sessions");
|
||||
let liveSessions = {};
|
||||
for (let peerId of io.sockets.adapter.rooms.keys()) {
|
||||
let {projectKey, sessionId} = extractPeerId(peerId);
|
||||
if (projectKey !== undefined) {
|
||||
liveSessions[projectKey] = liveSessions[projectKey] || [];
|
||||
liveSessions[projectKey].push(sessionId);
|
||||
}
|
||||
}
|
||||
let result = {"data": liveSessions};
|
||||
if (process.env.uws !== "true") {
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify(result));
|
||||
} else {
|
||||
res.writeStatus('200 OK').writeHeader('Content-Type', 'application/json').end(JSON.stringify(result));
|
||||
}
|
||||
}
|
||||
wsRouter.get(`/${process.env.S3_KEY}/sockets-list`, socketsList);
|
||||
|
||||
const socketsListByProject = function (req, res) {
|
||||
if (process.env.uws === "true") {
|
||||
req.params = {projectKey: req.getParameter(0)};
|
||||
}
|
||||
debug && console.log(`[WS]looking for available sessions for ${req.params.projectKey}`);
|
||||
let liveSessions = {};
|
||||
for (let peerId of io.sockets.adapter.rooms.keys()) {
|
||||
let {projectKey, sessionId} = extractPeerId(peerId);
|
||||
if (projectKey === req.params.projectKey) {
|
||||
liveSessions[projectKey] = liveSessions[projectKey] || [];
|
||||
liveSessions[projectKey].push(sessionId);
|
||||
}
|
||||
}
|
||||
let result = {"data": liveSessions[req.params.projectKey] || []};
|
||||
if (process.env.uws !== "true") {
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify());
|
||||
} else {
|
||||
res.writeStatus('200 OK').writeHeader('Content-Type', 'application/json').end(JSON.stringify(result));
|
||||
}
|
||||
}
|
||||
wsRouter.get(`/${process.env.S3_KEY}/sockets-list/:projectKey`, socketsListByProject);
|
||||
|
||||
const socketsLive = async function (req, res) {
|
||||
debug && console.log("[WS]looking for all available LIVE sessions");
|
||||
let liveSessions = {};
|
||||
for (let peerId of io.sockets.adapter.rooms.keys()) {
|
||||
let {projectKey, sessionId} = extractPeerId(peerId);
|
||||
if (projectKey !== undefined) {
|
||||
let connected_sockets = await io.in(peerId).fetchSockets();
|
||||
for (let item of connected_sockets) {
|
||||
if (item.handshake.query.identity === IDENTITIES.session) {
|
||||
liveSessions[projectKey] = liveSessions[projectKey] || [];
|
||||
liveSessions[projectKey].push(item.handshake.query.sessionInfo);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let result = {"data": liveSessions};
|
||||
if (process.env.uws !== "true") {
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify(result));
|
||||
} else {
|
||||
res.writeStatus('200 OK').writeHeader('Content-Type', 'application/json').end(JSON.stringify(result));
|
||||
}
|
||||
}
|
||||
wsRouter.get(`/${process.env.S3_KEY}/sockets-live`, socketsLive);
|
||||
|
||||
const socketsLiveByProject = async function (req, res) {
|
||||
if (process.env.uws === "true") {
|
||||
req.params = {projectKey: req.getParameter(0)};
|
||||
}
|
||||
debug && console.log(`[WS]looking for available LIVE sessions for ${req.params.projectKey}`);
|
||||
let liveSessions = {};
|
||||
for (let peerId of io.sockets.adapter.rooms.keys()) {
|
||||
let {projectKey, sessionId} = extractPeerId(peerId);
|
||||
if (projectKey === req.params.projectKey) {
|
||||
let connected_sockets = await io.in(peerId).fetchSockets();
|
||||
for (let item of connected_sockets) {
|
||||
if (item.handshake.query.identity === IDENTITIES.session) {
|
||||
liveSessions[projectKey] = liveSessions[projectKey] || [];
|
||||
liveSessions[projectKey].push(item.handshake.query.sessionInfo);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let result = {"data": liveSessions[req.params.projectKey] || []};
|
||||
if (process.env.uws !== "true") {
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify(result));
|
||||
} else {
|
||||
res.writeStatus('200 OK').writeHeader('Content-Type', 'application/json').end(JSON.stringify(result));
|
||||
}
|
||||
}
|
||||
wsRouter.get(`/${process.env.S3_KEY}/sockets-live/:projectKey`, socketsLiveByProject);
|
||||
|
||||
const findSessionSocketId = async (io, peerId) => {
|
||||
const connected_sockets = await io.in(peerId).fetchSockets();
|
||||
for (let item of connected_sockets) {
|
||||
if (item.handshake.query.identity === IDENTITIES.session) {
|
||||
return item.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
async function sessions_agents_count(io, socket) {
|
||||
let c_sessions = 0, c_agents = 0;
|
||||
if (io.sockets.adapter.rooms.get(socket.peerId)) {
|
||||
const connected_sockets = await io.in(socket.peerId).fetchSockets();
|
||||
|
||||
for (let item of connected_sockets) {
|
||||
if (item.handshake.query.identity === IDENTITIES.session) {
|
||||
c_sessions++;
|
||||
} else {
|
||||
c_agents++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
c_agents = -1;
|
||||
c_sessions = -1;
|
||||
}
|
||||
return {c_sessions, c_agents};
|
||||
}
|
||||
|
||||
async function get_all_agents_ids(io, socket) {
|
||||
let agents = [];
|
||||
if (io.sockets.adapter.rooms.get(socket.peerId)) {
|
||||
const connected_sockets = await io.in(socket.peerId).fetchSockets();
|
||||
for (let item of connected_sockets) {
|
||||
if (item.handshake.query.identity === IDENTITIES.agent) {
|
||||
agents.push(item.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return agents;
|
||||
}
|
||||
|
||||
function extractSessionInfo(socket) {
|
||||
if (socket.handshake.query.sessionInfo !== undefined) {
|
||||
debug && console.log("received headers");
|
||||
debug && console.log(socket.handshake.headers);
|
||||
socket.handshake.query.sessionInfo = JSON.parse(socket.handshake.query.sessionInfo);
|
||||
|
||||
let ua = uaParser(socket.handshake.headers['user-agent']);
|
||||
socket.handshake.query.sessionInfo.userOs = ua.os.name || null;
|
||||
socket.handshake.query.sessionInfo.userBrowser = ua.browser.name || null;
|
||||
socket.handshake.query.sessionInfo.userBrowserVersion = ua.browser.version || null;
|
||||
socket.handshake.query.sessionInfo.userDevice = ua.device.model || null;
|
||||
socket.handshake.query.sessionInfo.userDeviceType = ua.device.type || 'desktop';
|
||||
socket.handshake.query.sessionInfo.userCountry = null;
|
||||
|
||||
const options = {
|
||||
// you can use options like `cache` or `watchForUpdates`
|
||||
};
|
||||
// console.log("Looking for MMDB file in " + process.env.MAXMINDDB_FILE);
|
||||
geoip2Reader.open(process.env.MAXMINDDB_FILE, options)
|
||||
.then(reader => {
|
||||
debug && console.log("looking for location of ");
|
||||
debug && console.log(socket.handshake.headers['x-forwarded-for'] || socket.handshake.address);
|
||||
let country = reader.country(socket.handshake.headers['x-forwarded-for'] || socket.handshake.address);
|
||||
socket.handshake.query.sessionInfo.userCountry = country.country.isoCode;
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
wsRouter,
|
||||
start: (server) => {
|
||||
if (process.env.uws !== "true") {
|
||||
io = _io(server, {
|
||||
maxHttpBufferSize: (parseInt(process.env.maxHttpBufferSize) || 5) * 1e6,
|
||||
cors: {
|
||||
origin: "*",
|
||||
methods: ["GET", "POST", "PUT"]
|
||||
},
|
||||
path: '/socket'
|
||||
});
|
||||
} else {
|
||||
io = new _io.Server({
|
||||
maxHttpBufferSize: (parseInt(process.env.maxHttpBufferSize) || 5) * 1e6,
|
||||
cors: {
|
||||
origin: "*",
|
||||
methods: ["GET", "POST", "PUT"]
|
||||
},
|
||||
path: '/socket',
|
||||
// transports: ['websocket'],
|
||||
// upgrade: false
|
||||
});
|
||||
io.attachApp(server);
|
||||
}
|
||||
io.on('connection', async (socket) => {
|
||||
debug && console.log(`WS started:${socket.id}, Query:${JSON.stringify(socket.handshake.query)}`);
|
||||
socket.peerId = socket.handshake.query.peerId;
|
||||
socket.identity = socket.handshake.query.identity;
|
||||
const {projectKey, sessionId} = extractPeerId(socket.peerId);
|
||||
socket.sessionId = sessionId;
|
||||
socket.projectKey = projectKey;
|
||||
socket.lastMessageReceivedAt = Date.now();
|
||||
let {c_sessions, c_agents} = await sessions_agents_count(io, socket);
|
||||
if (socket.identity === IDENTITIES.session) {
|
||||
if (c_sessions > 0) {
|
||||
debug && console.log(`session already connected, refusing new connexion`);
|
||||
io.to(socket.id).emit(SESSION_ALREADY_CONNECTED);
|
||||
return socket.disconnect();
|
||||
}
|
||||
extractSessionInfo(socket);
|
||||
if (c_agents > 0) {
|
||||
debug && console.log(`notifying new session about agent-existence`);
|
||||
let agents_ids = await get_all_agents_ids(io, socket);
|
||||
io.to(socket.id).emit(AGENTS_CONNECTED, agents_ids);
|
||||
}
|
||||
|
||||
} else if (c_sessions <= 0) {
|
||||
debug && console.log(`notifying new agent about no SESSIONS`);
|
||||
io.to(socket.id).emit(NO_SESSIONS);
|
||||
}
|
||||
socket.join(socket.peerId);
|
||||
if (io.sockets.adapter.rooms.get(socket.peerId)) {
|
||||
debug && console.log(`${socket.id} joined room:${socket.peerId}, as:${socket.identity}, members:${io.sockets.adapter.rooms.get(socket.peerId).size}`);
|
||||
}
|
||||
if (socket.identity === IDENTITIES.agent) {
|
||||
if (socket.handshake.query.agentInfo !== undefined) {
|
||||
socket.handshake.query.agentInfo = JSON.parse(socket.handshake.query.agentInfo);
|
||||
}
|
||||
socket.to(socket.peerId).emit(NEW_AGENT, socket.id, socket.handshake.query.agentInfo);
|
||||
}
|
||||
|
||||
socket.on('disconnect', async () => {
|
||||
debug && console.log(`${socket.id} disconnected from ${socket.peerId}`);
|
||||
if (socket.identity === IDENTITIES.agent) {
|
||||
socket.to(socket.peerId).emit(AGENT_DISCONNECT, socket.id);
|
||||
}
|
||||
debug && console.log("checking for number of connected agents and sessions");
|
||||
let {c_sessions, c_agents} = await sessions_agents_count(io, socket);
|
||||
if (c_sessions === -1 && c_agents === -1) {
|
||||
debug && console.log(`room not found: ${socket.peerId}`);
|
||||
}
|
||||
if (c_sessions === 0) {
|
||||
debug && console.log(`notifying everyone in ${socket.peerId} about no SESSIONS`);
|
||||
socket.to(socket.peerId).emit(NO_SESSIONS);
|
||||
}
|
||||
if (c_agents === 0) {
|
||||
debug && console.log(`notifying everyone in ${socket.peerId} about no AGENTS`);
|
||||
socket.to(socket.peerId).emit(NO_AGENTS);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on(UPDATE_EVENT, async (...args) => {
|
||||
debug && console.log(`${socket.id} sent update event.`);
|
||||
if (socket.identity !== IDENTITIES.session) {
|
||||
debug && console.log('Ignoring update event.');
|
||||
return
|
||||
}
|
||||
socket.handshake.query.sessionInfo = {...socket.handshake.query.sessionInfo, ...args[0]};
|
||||
socket.to(socket.peerId).emit(UPDATE_EVENT, args[0]);
|
||||
});
|
||||
|
||||
socket.onAny(async (eventName, ...args) => {
|
||||
socket.lastMessageReceivedAt = Date.now();
|
||||
if (socket.identity === IDENTITIES.session) {
|
||||
debug && console.log(`received event:${eventName}, from:${socket.identity}, sending message to room:${socket.peerId}, members: ${io.sockets.adapter.rooms.get(socket.peerId).size}`);
|
||||
socket.to(socket.peerId).emit(eventName, args[0]);
|
||||
} else {
|
||||
debug && console.log(`received event:${eventName}, from:${socket.identity}, sending message to session of room:${socket.peerId}, members:${io.sockets.adapter.rooms.get(socket.peerId).size}`);
|
||||
let socketId = await findSessionSocketId(io, socket.peerId);
|
||||
if (socketId === null) {
|
||||
debug && console.log(`session not found for:${socket.peerId}`);
|
||||
io.to(socket.id).emit(NO_SESSIONS);
|
||||
} else {
|
||||
debug && console.log("message sent");
|
||||
io.to(socketId).emit(eventName, socket.id, args[0]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
console.log("WS server started")
|
||||
setInterval((io) => {
|
||||
try {
|
||||
let count = 0;
|
||||
console.log(` ====== Rooms: ${io.sockets.adapter.rooms.size} ====== `);
|
||||
const arr = Array.from(io.sockets.adapter.rooms)
|
||||
const filtered = arr.filter(room => !room[1].has(room[0]))
|
||||
for (let i of filtered) {
|
||||
let {projectKey, sessionId} = extractPeerId(i[0]);
|
||||
if (projectKey !== null && sessionId !== null) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
console.log(` ====== Valid Rooms: ${count} ====== `);
|
||||
if (debug) {
|
||||
for (let item of filtered) {
|
||||
console.log(`Room: ${item[0]} connected: ${item[1].size}`)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}, 20000, io);
|
||||
},
|
||||
handlers: {
|
||||
socketsList,
|
||||
socketsListByProject,
|
||||
socketsLive,
|
||||
socketsLiveByProject
|
||||
}
|
||||
};
|
||||
|
|
@ -21,7 +21,7 @@ const AssistTabs = (props: Props) => {
|
|||
<Avatar iconSize="20" width="30px" height="30px" seed={ props.userNumericHash } />
|
||||
<div className="ml-2 font-medium">
|
||||
<TextEllipsis maxWidth={120} inverted popupProps={{ inverted: true, size: 'tiny' }}>
|
||||
{props.userId}'s asdasd asdasdasdasd
|
||||
{props.userId}'s
|
||||
</TextEllipsis>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -35,7 +35,7 @@ const AssistTabs = (props: Props) => {
|
|||
)}
|
||||
</div>
|
||||
<SlideModal
|
||||
title={ <div>Live Sessions by {props.userId}</div> }
|
||||
title={ <div>{props.userId}'s <span className="color-gray-medium">Live Sessions</span> </div> }
|
||||
isDisplayed={ showMenu }
|
||||
content={ showMenu && <SessionList /> }
|
||||
onClose={ () => setShowMenu(false) }
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { fetchLiveList } from 'Duck/sessions';
|
||||
import { Loader, NoContent } from 'UI';
|
||||
import { Loader, NoContent, Label } from 'UI';
|
||||
import SessionItem from 'Shared/SessionItem';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -22,7 +22,17 @@ function SessionList(props: Props) {
|
|||
title="No live sessions."
|
||||
>
|
||||
<div className="p-4">
|
||||
{ props.list.map(session => <SessionItem key={ session.sessionId } session={ session } />) }
|
||||
{ props.list.map(session => (
|
||||
<div className="mb-6">
|
||||
{session.pageTitle && session.pageTitle !== '' && (
|
||||
<div className="flex items-center mb-2">
|
||||
<Label size="small" className="p-1"><span className="color-gray-medium">TAB</span></Label>
|
||||
<span className="ml-2 font-medium">{session.pageTitle}</span>
|
||||
</div>
|
||||
)}
|
||||
<SessionItem key={ session.sessionId } session={ session } showActive={true} />
|
||||
</div>
|
||||
)) }
|
||||
</div>
|
||||
</NoContent>
|
||||
</Loader>
|
||||
|
|
|
|||
|
|
@ -240,11 +240,10 @@ export default class Dashboard extends React.PureComponent {
|
|||
Custom Metrics are not supported for comparison.
|
||||
</div>
|
||||
)}
|
||||
{/* <CustomMetrics /> */}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className={cn("gap-4 grid grid-cols-2")} ref={this.list[CUSTOM_METRICS]}>
|
||||
<div className={cn("")} ref={this.list[CUSTOM_METRICS]}>
|
||||
<CustomMetricsWidgets onClickEdit={(e) => null}/>
|
||||
</div>
|
||||
</WidgetSection>
|
||||
|
|
|
|||
|
|
@ -33,9 +33,6 @@ function SideMenuSection({ title, items, onItemClick, setShowAlerts, siteId }) {
|
|||
<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>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
import React from 'react'
|
||||
import { Styles } from '../../common';
|
||||
import { ResponsiveContainer, XAxis, YAxis, CartesianGrid, Area, Tooltip } from 'recharts';
|
||||
import { LineChart, Line, Legend } from 'recharts';
|
||||
|
||||
interface Props {
|
||||
data: any;
|
||||
params: any;
|
||||
seriesMap: any;
|
||||
colors: any;
|
||||
onClick?: (event, index) => void;
|
||||
}
|
||||
function CustomMetriLineChart(props: Props) {
|
||||
const { data, params, seriesMap, colors, onClick = () => null } = props;
|
||||
return (
|
||||
<ResponsiveContainer height={ 240 } width="100%">
|
||||
<LineChart
|
||||
data={ data }
|
||||
margin={Styles.chartMargins}
|
||||
// syncId={ showSync ? "domainsErrors_4xx" : undefined }
|
||||
onClick={onClick}
|
||||
isAnimationActive={ false }
|
||||
>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
export default CustomMetriLineChart
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './CustomMetriLineChart';
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import React from 'react'
|
||||
|
||||
interface Props {
|
||||
data: any;
|
||||
params: any;
|
||||
colors: any;
|
||||
onClick?: (event, index) => void;
|
||||
}
|
||||
function CustomMetriPercentage(props: Props) {
|
||||
const { data = {} } = props;
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center" style={{ height: '240px'}}>
|
||||
<div className="text-6xl">{data.count}</div>
|
||||
<div className="text-lg mt-6">{`${data.previousCount} ( ${data.countProgress}% )`}</div>
|
||||
<div className="color-gray-medium">from previous period.</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CustomMetriPercentage;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './CustomMetricPercentage';
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
.wrapper {
|
||||
background-color: white;
|
||||
/* border: solid thin $gray-medium; */
|
||||
border-radius: 3px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
import React from 'react'
|
||||
import { ResponsiveContainer, Tooltip } from 'recharts';
|
||||
import { PieChart, Pie, Cell } from 'recharts';
|
||||
import { Styles } from '../../common';
|
||||
import { NoContent } from 'UI';
|
||||
import { filtersMap } from 'Types/filter/newFilter';
|
||||
interface Props {
|
||||
metric: any,
|
||||
data: any;
|
||||
params: any;
|
||||
// seriesMap: any;
|
||||
colors: any;
|
||||
onClick?: (filters) => void;
|
||||
}
|
||||
|
||||
function CustomMetricPieChart(props: Props) {
|
||||
const { metric, data = { values: [] }, onClick = () => null } = props;
|
||||
|
||||
const onClickHandler = (event) => {
|
||||
if (event && !event.payload.group) {
|
||||
const filters = Array<any>();
|
||||
let filter = { ...filtersMap[metric.metricOf] }
|
||||
filter.value = [event.payload.name]
|
||||
filter.type = filter.key
|
||||
delete filter.key
|
||||
delete filter.operatorOptions
|
||||
delete filter.category
|
||||
delete filter.icon
|
||||
delete filter.label
|
||||
delete filter.options
|
||||
|
||||
filters.push(filter);
|
||||
onClick(filters);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<NoContent size="small" show={data.values && data.values.length === 0} >
|
||||
<ResponsiveContainer height={ 220 } width="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
isAnimationActive={ false }
|
||||
data={data.values}
|
||||
dataKey="sessionCount"
|
||||
nameKey="name"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
// innerRadius={40}
|
||||
outerRadius={70}
|
||||
// fill={colors[0]}
|
||||
activeIndex={1}
|
||||
onClick={onClickHandler}
|
||||
labelLine={({
|
||||
cx,
|
||||
cy,
|
||||
midAngle,
|
||||
innerRadius,
|
||||
outerRadius,
|
||||
value,
|
||||
index
|
||||
}) => {
|
||||
const RADIAN = Math.PI / 180;
|
||||
let radius1 = 15 + innerRadius + (outerRadius - innerRadius);
|
||||
let radius2 = innerRadius + (outerRadius - innerRadius);
|
||||
let x2 = cx + radius1 * Math.cos(-midAngle * RADIAN);
|
||||
let y2 = cy + radius1 * Math.sin(-midAngle * RADIAN);
|
||||
let x1 = cx + radius2 * Math.cos(-midAngle * RADIAN);
|
||||
let y1 = cy + radius2 * Math.sin(-midAngle * RADIAN);
|
||||
|
||||
const percentage = value * 100 / data.values.reduce((a, b) => a + b.sessionCount, 0);
|
||||
|
||||
if (percentage<3){
|
||||
return null;
|
||||
}
|
||||
|
||||
return(
|
||||
<line x1={x1} y1={y1} x2={x2} y2={y2} stroke="#3EAAAF" strokeWidth={1} />
|
||||
)
|
||||
}}
|
||||
label={({
|
||||
cx,
|
||||
cy,
|
||||
midAngle,
|
||||
innerRadius,
|
||||
outerRadius,
|
||||
value,
|
||||
index
|
||||
}) => {
|
||||
const RADIAN = Math.PI / 180;
|
||||
let radius = 20 + innerRadius + (outerRadius - innerRadius);
|
||||
let x = cx + radius * Math.cos(-midAngle * RADIAN);
|
||||
let y = cy + radius * Math.sin(-midAngle * RADIAN);
|
||||
const percentage = (value / data.values.reduce((a, b) => a + b.sessionCount, 0)) * 100;
|
||||
let name = data.values[index].name || 'Unidentified';
|
||||
name = name.length > 20 ? name.substring(0, 20) + '...' : name;
|
||||
if (percentage<3){
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<text
|
||||
x={x}
|
||||
y={y}
|
||||
fontWeight="400"
|
||||
fontSize="12px"
|
||||
// fontFamily="'Source Sans Pro', 'Roboto', 'Helvetica Neue', 'Helvetica', 'Arial', 'sans-serif'"
|
||||
textAnchor={x > cx ? "start" : "end"}
|
||||
dominantBaseline="central"
|
||||
fill='#666'
|
||||
>
|
||||
{name || 'Unidentified'} {value}
|
||||
</text>
|
||||
);
|
||||
}}
|
||||
// label={({
|
||||
// cx,
|
||||
// cy,
|
||||
// midAngle,
|
||||
// innerRadius,
|
||||
// outerRadius,
|
||||
// value,
|
||||
// index
|
||||
// }) => {
|
||||
// const RADIAN = Math.PI / 180;
|
||||
// const radius = 30 + innerRadius + (outerRadius - innerRadius);
|
||||
// const x = cx + radius * Math.cos(-midAngle * RADIAN);
|
||||
// const y = cy + radius * Math.sin(-midAngle * RADIAN);
|
||||
|
||||
// return (
|
||||
// <text
|
||||
// x={x}
|
||||
// y={y}
|
||||
// fill="#3EAAAF"
|
||||
// textAnchor={x > cx ? "start" : "end"}
|
||||
// dominantBaseline="top"
|
||||
// fontSize={10}
|
||||
// >
|
||||
// {data.values[index].name} ({value})
|
||||
// </text>
|
||||
// );
|
||||
// }}
|
||||
>
|
||||
{data.values.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={Styles.colorsPie[index % Styles.colorsPie.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip {...Styles.tooltip} />
|
||||
</PieChart>
|
||||
|
||||
</ResponsiveContainer>
|
||||
<div className="text-sm color-gray-medium">Top 5 </div>
|
||||
</NoContent>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CustomMetricPieChart;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './CustomMetricPieChart';
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
.wrapper {
|
||||
background-color: white;
|
||||
/* border: solid thin $gray-medium; */
|
||||
border-radius: 3px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import React from 'react'
|
||||
import { Table } from '../../common';
|
||||
import { List } from 'immutable';
|
||||
import { filtersMap } from 'Types/filter/newFilter';
|
||||
import { NoContent } from 'UI';
|
||||
import { tableColumnName } from 'App/constants/filterOptions';
|
||||
|
||||
const getColumns = (metric) => {
|
||||
return [
|
||||
{
|
||||
key: 'name',
|
||||
title: tableColumnName[metric.metricOf],
|
||||
toText: name => name || 'Unidentified',
|
||||
width: '70%',
|
||||
},
|
||||
{
|
||||
key: 'sessionCount',
|
||||
title: 'Sessions',
|
||||
toText: sessions => sessions,
|
||||
width: '30%',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
interface Props {
|
||||
metric?: any,
|
||||
data: any;
|
||||
onClick?: (filters) => void;
|
||||
}
|
||||
function CustomMetriTable(props: Props) {
|
||||
const { metric = {}, data = { values: [] }, onClick = () => null } = props;
|
||||
const rows = List(data.values);
|
||||
|
||||
const onClickHandler = (event, data) => {
|
||||
const filters = Array<any>();
|
||||
let filter = { ...filtersMap[metric.metricOf] }
|
||||
filter.value = [data.name]
|
||||
filter.type = filter.key
|
||||
delete filter.key
|
||||
delete filter.operatorOptions
|
||||
delete filter.category
|
||||
delete filter.icon
|
||||
delete filter.label
|
||||
delete filter.options
|
||||
|
||||
filters.push(filter);
|
||||
onClick(filters);
|
||||
}
|
||||
return (
|
||||
<div className="" style={{ height: '240px'}}>
|
||||
<NoContent show={data.values && data.values.length === 0} size="small">
|
||||
<Table
|
||||
small
|
||||
cols={ getColumns(metric) }
|
||||
rows={ rows }
|
||||
rowClass="group"
|
||||
onRowClick={ onClickHandler }
|
||||
/>
|
||||
</NoContent>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CustomMetriTable;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './CustomMetricTable';
|
||||
|
|
@ -2,22 +2,25 @@ 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 { ResponsiveContainer } 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';
|
||||
import CustomMetriLineChart from '../CustomMetriLineChart';
|
||||
import CustomMetricPieChart from '../CustomMetricPieChart';
|
||||
import CustomMetricPercentage from '../CustomMetricPercentage';
|
||||
import CustomMetricTable from '../CustomMetricTable';
|
||||
|
||||
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
|
||||
// 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
|
||||
}
|
||||
|
|
@ -47,11 +50,14 @@ function CustomMetricWidget(props: Props) {
|
|||
|
||||
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 }
|
||||
const isLineChart = metric.viewType === 'lineChart';
|
||||
const isProgress = metric.viewType === 'progress';
|
||||
const isTable = metric.viewType === 'table';
|
||||
const isPieChart = metric.viewType === 'pieChart';
|
||||
|
||||
useEffect(() => {
|
||||
new APIClient()['post']('/custom_metrics/chart', { ...metricParams, q: metric.name })
|
||||
new APIClient()['post'](`/custom_metrics/${metricParams.metricId}/chart`, { ...metricParams, q: metric.name })
|
||||
.then(response => response.json())
|
||||
.then(({ errors, data }) => {
|
||||
if (errors) {
|
||||
|
|
@ -74,12 +80,33 @@ function CustomMetricWidget(props: Props) {
|
|||
}).finally(() => setLoading(false));
|
||||
}, [period])
|
||||
|
||||
const clickHandlerTable = (filters) => {
|
||||
const activeWidget = {
|
||||
widget: metric,
|
||||
period: period,
|
||||
...period.toTimestamps(),
|
||||
filters,
|
||||
}
|
||||
props.setActiveWidget(activeWidget);
|
||||
}
|
||||
|
||||
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 periodTimestamps = metric.metricType === 'timeseries' ?
|
||||
getStartAndEndTimestampsByDensity(timestamp, period.start, period.end, params.density) :
|
||||
period.toTimestamps();
|
||||
|
||||
const activeWidget = {
|
||||
widget: metric,
|
||||
period: period,
|
||||
...periodTimestamps,
|
||||
timestamp: payload.timestamp,
|
||||
index,
|
||||
}
|
||||
|
||||
props.setActiveWidget(activeWidget);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -89,64 +116,59 @@ function CustomMetricWidget(props: Props) {
|
|||
|
||||
return (
|
||||
<div className={stl.wrapper}>
|
||||
<div className="flex items-center mb-10 p-2">
|
||||
<div className="flex items-center 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} />
|
||||
{!isTable && !isPieChart && <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>
|
||||
<div className="px-3">
|
||||
<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}
|
||||
<>
|
||||
{isLineChart && (
|
||||
<CustomMetriLineChart
|
||||
data={ data }
|
||||
params={ params }
|
||||
seriesMap={ seriesMap }
|
||||
colors={ colors }
|
||||
onClick={ clickHandler }
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
)}
|
||||
|
||||
{isPieChart && (
|
||||
<CustomMetricPieChart
|
||||
metric={metric}
|
||||
data={ data[0] }
|
||||
params={ params }
|
||||
colors={ colors }
|
||||
onClick={ clickHandlerTable }
|
||||
/>
|
||||
)}
|
||||
|
||||
{isProgress && (
|
||||
<CustomMetricPercentage
|
||||
data={ data[0] }
|
||||
params={ params }
|
||||
colors={ colors }
|
||||
onClick={ clickHandler }
|
||||
/>
|
||||
)}
|
||||
|
||||
{isTable && (
|
||||
<CustomMetricTable
|
||||
metric={ metric }
|
||||
data={ data[0] }
|
||||
onClick={ clickHandlerTable }
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</ResponsiveContainer>
|
||||
</NoContent>
|
||||
</Loader>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,14 @@
|
|||
.wrapper {
|
||||
background-color: white;
|
||||
background-color: $gray-light;
|
||||
/* border: solid thin $gray-medium; */
|
||||
border-radius: 3px;
|
||||
padding: 10px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.innerWapper {
|
||||
border-radius: 3px;
|
||||
width: 70%;
|
||||
margin: 0 auto;
|
||||
background-color: white;
|
||||
min-height: 220px;
|
||||
}
|
||||
|
|
@ -1,16 +1,20 @@
|
|||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Loader, NoContent, Icon } from 'UI';
|
||||
import { Loader, NoContent, SegmentSelection, Icon } from 'UI';
|
||||
import { Styles } from '../../common';
|
||||
import { ResponsiveContainer, XAxis, YAxis, CartesianGrid, Tooltip, LineChart, Line, Legend } from 'recharts';
|
||||
// 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 CustomMetriLineChart from '../CustomMetriLineChart';
|
||||
import CustomMetricPercentage from '../CustomMetricPercentage';
|
||||
import CustomMetricTable from '../CustomMetricTable';
|
||||
|
||||
import APIClient from 'App/api_client';
|
||||
import CustomMetricPieChart from '../CustomMetricPieChart';
|
||||
|
||||
const customParams = rangeName => {
|
||||
const params = { density: 70 }
|
||||
|
|
@ -43,8 +47,9 @@ function CustomMetricWidget(props: Props) {
|
|||
const params = customParams(period.rangeName)
|
||||
const gradientDef = Styles.gradientDef();
|
||||
const metricParams = { ...params, metricId: metric.metricId, viewType: 'lineChart' }
|
||||
|
||||
const prevMetricRef = useRef<any>();
|
||||
const isTimeSeries = metric.metricType === 'timeseries';
|
||||
const isTable = metric.metricType === 'table';
|
||||
|
||||
useEffect(() => {
|
||||
// Check for title change
|
||||
|
|
@ -83,11 +88,52 @@ function CustomMetricWidget(props: Props) {
|
|||
props.edit({ ...changedDates, rangeName: changedDates.rangeValue });
|
||||
}
|
||||
|
||||
const chagneViewType = (e, { name, value }) => {
|
||||
props.edit({ [ name ]: value });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-10">
|
||||
<div className="flex items-center mb-4">
|
||||
<div className="flex items-center">
|
||||
<div className="mr-auto font-medium">Preview</div>
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
{isTimeSeries && (
|
||||
<>
|
||||
<span className="color-gray-medium mr-2">Visualization</span>
|
||||
<SegmentSelection
|
||||
name="viewType"
|
||||
className="my-3"
|
||||
primary
|
||||
icons={true}
|
||||
onSelect={ chagneViewType }
|
||||
value={{ value: metric.viewType }}
|
||||
list={ [
|
||||
{ value: 'lineChart', name: 'Chart', icon: 'graph-up-arrow' },
|
||||
{ value: 'progress', name: 'Progress', icon: 'hash' },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isTable && (
|
||||
<>
|
||||
<span className="mr-1 color-gray-medium">Visualization</span>
|
||||
<SegmentSelection
|
||||
name="viewType"
|
||||
className="my-3"
|
||||
primary={true}
|
||||
icons={true}
|
||||
onSelect={ chagneViewType }
|
||||
value={{ value: metric.viewType }}
|
||||
list={[
|
||||
{ value: 'table', name: 'Table', icon: 'table' },
|
||||
{ value: 'pieChart', name: 'Chart', icon: 'pie-chart-fill' },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className="mx-4" />
|
||||
<span className="mr-1 color-gray-medium">Time Range</span>
|
||||
<DateRange
|
||||
rangeValue={metric.rangeName}
|
||||
startDate={metric.startDate}
|
||||
|
|
@ -99,50 +145,51 @@ function CustomMetricWidget(props: Props) {
|
|||
</div>
|
||||
</div>
|
||||
<div className={stl.wrapper}>
|
||||
<div>
|
||||
<div className={stl.innerWapper}>
|
||||
<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>
|
||||
<div className="p-4 font-medium">
|
||||
{metric.name}
|
||||
</div>
|
||||
<div className="px-4 pb-4">
|
||||
{ isTimeSeries && (
|
||||
<>
|
||||
{ metric.viewType === 'progress' && (
|
||||
<CustomMetricPercentage
|
||||
data={data[0]}
|
||||
colors={colors}
|
||||
params={params}
|
||||
/>
|
||||
)}
|
||||
{ metric.viewType === 'lineChart' && (
|
||||
<CustomMetriLineChart
|
||||
data={data}
|
||||
seriesMap={seriesMap}
|
||||
colors={colors}
|
||||
params={params}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{ isTable && (
|
||||
<>
|
||||
{ metric.viewType === 'table' ? (
|
||||
<CustomMetricTable metric={metric} data={data[0]} />
|
||||
) : (
|
||||
<CustomMetricPieChart
|
||||
metric={metric}
|
||||
data={data[0]}
|
||||
colors={colors}
|
||||
params={params}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</NoContent>
|
||||
</Loader>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import CustomMetricWidget from './CustomMetricWidget';
|
|||
import AlertFormModal from 'App/components/Alerts/AlertFormModal';
|
||||
import { init as initAlert } from 'Duck/alerts';
|
||||
import LazyLoad from 'react-lazyload';
|
||||
import CustomMetrics from 'App/components/shared/CustomMetrics';
|
||||
|
||||
interface Props {
|
||||
fetchList: Function;
|
||||
|
|
@ -15,6 +16,7 @@ interface Props {
|
|||
function CustomMetricsWidgets(props: Props) {
|
||||
const { list } = props;
|
||||
const [activeMetricId, setActiveMetricId] = useState(null);
|
||||
const activeList = list.filter(item => item.active);
|
||||
|
||||
useEffect(() => {
|
||||
props.fetchList()
|
||||
|
|
@ -22,19 +24,34 @@ function CustomMetricsWidgets(props: Props) {
|
|||
|
||||
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>
|
||||
))}
|
||||
<div className="gap-4 grid grid-cols-2">
|
||||
{activeList.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>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{list.size === 0 && (
|
||||
<div className="flex items-center py-2">
|
||||
<div className="mr-2 color-gray-medium">Be proactive by monitoring the metrics you care about the most.</div>
|
||||
<CustomMetrics />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{list.size > 0 && activeList && activeList.size === 0 && (
|
||||
<div className="flex items-center py-2">
|
||||
<div className="mr-2 color-gray-medium">It's blank here, add a metric to this section.</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AlertFormModal
|
||||
showModal={!!activeMetricId}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ const colorsx = ['#256669', '#38999e', '#3eaaaf', '#51b3b7', '#78c4c7', '#9fd5d7
|
|||
const compareColors = ['#394EFF', '#4D5FFF', '#808DFF', '#B3BBFF', '#E5E8FF'];
|
||||
const compareColorsx = ["#222F99", "#2E3ECC", "#394EFF", "#6171FF", "#8895FF", "#B0B8FF", "#D7DCFF"].reverse();
|
||||
const customMetricColors = ['#3EAAAF', '#394EFF', '#666666'];
|
||||
const colorsPie = colors.concat(["#DDDDDD"]);
|
||||
|
||||
const countView = count => {
|
||||
const isMoreThanK = count >= 1000;
|
||||
|
|
@ -14,6 +15,7 @@ const countView = count => {
|
|||
export default {
|
||||
customMetricColors,
|
||||
colors,
|
||||
colorsPie,
|
||||
colorsx,
|
||||
compareColors,
|
||||
compareColorsx,
|
||||
|
|
|
|||
|
|
@ -16,7 +16,9 @@ export default class Table extends React.PureComponent {
|
|||
rowProps,
|
||||
rowClass = '',
|
||||
small = false,
|
||||
compare = false
|
||||
compare = false,
|
||||
maxHeight = 200,
|
||||
onRowClick = null,
|
||||
} = this.props;
|
||||
const { showAll } = this.state;
|
||||
|
||||
|
|
@ -30,9 +32,13 @@ export default class Table extends React.PureComponent {
|
|||
<div key={ key } style={ { width } } className={ stl.header }>{ title }</div>)
|
||||
}
|
||||
</div>
|
||||
<div className={ cn(stl.content, "thin-scrollbar") }>
|
||||
<div className={ cn(stl.content, "thin-scrollbar") } style={{ maxHeight: maxHeight + 'px'}}>
|
||||
{ rows.take(showAll ? 10 : (small ? 3 : 5)).map(row => (
|
||||
<div className={ cn(rowClass, stl.row, { [stl.small]: small}) } key={ row.key }>
|
||||
<div
|
||||
className={ cn(rowClass, stl.row, { [stl.small]: small, 'cursor-pointer' : !!onRowClick}) }
|
||||
key={ row.key }
|
||||
onClick={onRowClick ? (e) => onRowClick(e, row) : () => null}
|
||||
>
|
||||
{ cols.map(({ cellClass = '', className = '', Component, key, toText = t => t, width }) => (
|
||||
<div className={ cn(stl.cell, cellClass) } style={{ width }} key={ key }> { Component
|
||||
? <Component compare={compare} data={ row } { ...rowProps } />
|
||||
|
|
@ -41,21 +47,20 @@ export default class Table extends React.PureComponent {
|
|||
</div>
|
||||
)) }
|
||||
</div>
|
||||
)) }
|
||||
|
||||
{ rows.size > (small ? 3 : 5) && !showAll &&
|
||||
<div className="w-full flex justify-center mt-3">
|
||||
)) }
|
||||
</div>
|
||||
{ rows.size > (small ? 3 : 5) && !showAll &&
|
||||
<div className="w-full flex justify-center">
|
||||
<Button
|
||||
onClick={ this.onLoadMoreClick }
|
||||
plain
|
||||
small
|
||||
className="text-center"
|
||||
>
|
||||
{ 'Load More' }
|
||||
{ rows.size + ' More' }
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,9 +17,11 @@ const inputModeOptions = [
|
|||
|
||||
const codeSnippet = `<!-- OpenReplay Tracking Code for HOST -->
|
||||
<script>
|
||||
var initOpts = { projectKey: "PROJECT_KEY", ingestPoint: "https://${window.location.hostname}/ingest"};
|
||||
var startOpts = { userID: "" };
|
||||
(function(A,s,a,y,e,r){
|
||||
r=window.OpenReplay=[s,r,e,[y-1]];
|
||||
s=document.createElement('script');s.src=a;s.async=!A;
|
||||
r=window.OpenReplay=[e,r,y,[s-1, e]];
|
||||
s=document.createElement('script');s.src=A;s.async=!a;
|
||||
document.getElementsByTagName('head')[0].appendChild(s);
|
||||
r.start=function(v){r.push([0])};
|
||||
r.stop=function(v){r.push([1])};
|
||||
|
|
@ -30,8 +32,7 @@ const codeSnippet = `<!-- OpenReplay Tracking Code for HOST -->
|
|||
r.issue=function(k,p){r.push([6,k,p])};
|
||||
r.isActive=function(){return false};
|
||||
r.getSessionToken=function(){};
|
||||
r.i="https://${window.location.hostname}/ingest";
|
||||
})(0, "PROJECT_KEY", "//static.openreplay.com/${window.ENV.TRACKER_VERSION}/openreplay.js",1,XXX);
|
||||
})("//static.openreplay.com/${window.ENV.TRACKER_VERSION}/openreplay.js",XXX,0,initOpts,startOpts);
|
||||
</script>`;
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React from 'react';
|
||||
import { Form, SegmentSelection, Button, IconButton } from 'UI';
|
||||
import { Form, Button, IconButton, HelpText } from 'UI';
|
||||
import FilterSeries from '../FilterSeries';
|
||||
import { connect } from 'react-redux';
|
||||
import { edit as editMetric, save, addSeries, removeSeries, remove } from 'Duck/customMetrics';
|
||||
|
|
@ -7,7 +7,9 @@ import CustomMetricWidgetPreview from 'App/components/Dashboard/Widgets/CustomMe
|
|||
import { confirm } from 'UI/Confirmation';
|
||||
import { toast } from 'react-toastify';
|
||||
import cn from 'classnames';
|
||||
|
||||
import DropdownPlain from '../../DropdownPlain';
|
||||
import { metricTypes, metricOf, issueOptions } from 'App/constants/filterOptions';
|
||||
import { FilterKey } from 'Types/filter/filterType';
|
||||
interface Props {
|
||||
metric: any;
|
||||
editMetric: (metric, shouldFetch?) => void;
|
||||
|
|
@ -21,6 +23,13 @@ interface Props {
|
|||
|
||||
function CustomMetricForm(props: Props) {
|
||||
const { metric, loading } = props;
|
||||
// const metricOfOptions = metricOf.filter(i => i.key === metric.metricType);
|
||||
const timeseriesOptions = metricOf.filter(i => i.type === 'timeseries');
|
||||
const tableOptions = metricOf.filter(i => i.type === 'table');
|
||||
const isTable = metric.metricType === 'table';
|
||||
const isTimeSeries = metric.metricType === 'timeseries';
|
||||
const _issueOptions = [{ text: 'All', value: 'all' }].concat(issueOptions);
|
||||
|
||||
|
||||
const addSeries = () => {
|
||||
props.addSeries();
|
||||
|
|
@ -30,12 +39,33 @@ function CustomMetricForm(props: Props) {
|
|||
props.removeSeries(index);
|
||||
}
|
||||
|
||||
const write = ({ target: { value, name } }) => props.editMetric({ ...metric, [ name ]: value }, false);
|
||||
const write = ({ target: { value, name } }) => props.editMetric({ [ name ]: value }, false);
|
||||
const writeOption = (e, { value, name }) => {
|
||||
props.editMetric({ [ name ]: value }, false);
|
||||
|
||||
const changeConditionTab = (e, { name, value }) => {
|
||||
props.editMetric({[ 'viewType' ]: value });
|
||||
if (name === 'metricValue') {
|
||||
props.editMetric({ metricValue: [value] }, false);
|
||||
}
|
||||
|
||||
if (name === 'metricOf') {
|
||||
if (value === FilterKey.ISSUE) {
|
||||
props.editMetric({ metricValue: ['all'] }, false);
|
||||
}
|
||||
}
|
||||
|
||||
if (name === 'metricType') {
|
||||
if (value === 'timeseries') {
|
||||
props.editMetric({ metricOf: timeseriesOptions[0].value, viewType: 'lineChart' }, false);
|
||||
} else if (value === 'table') {
|
||||
props.editMetric({ metricOf: tableOptions[0].value, viewType: 'table' }, false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// const changeConditionTab = (e, { name, value }) => {
|
||||
// props.editMetric({[ 'viewType' ]: value });
|
||||
// };
|
||||
|
||||
const save = () => {
|
||||
props.save(metric).then(() => {
|
||||
toast.success(metric.exists() ? 'Updated succesfully.' : 'Created succesfully.');
|
||||
|
|
@ -79,42 +109,92 @@ function CustomMetricForm(props: Props) {
|
|||
<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>
|
||||
<DropdownPlain
|
||||
name="metricType"
|
||||
options={metricTypes}
|
||||
value={ metric.metricType }
|
||||
onChange={ writeOption }
|
||||
/>
|
||||
|
||||
{metric.metricType === 'timeseries' && (
|
||||
<>
|
||||
<span className="mx-3">of</span>
|
||||
<DropdownPlain
|
||||
name="metricOf"
|
||||
options={timeseriesOptions}
|
||||
value={ metric.metricOf }
|
||||
onChange={ writeOption }
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{metric.metricType === 'table' && (
|
||||
<>
|
||||
<span className="mx-3">of</span>
|
||||
<DropdownPlain
|
||||
name="metricOf"
|
||||
options={tableOptions}
|
||||
value={ metric.metricOf }
|
||||
onChange={ writeOption }
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{metric.metricOf === FilterKey.ISSUE && (
|
||||
<>
|
||||
<span className="mx-3">issue type</span>
|
||||
<DropdownPlain
|
||||
name="metricValue"
|
||||
options={_issueOptions}
|
||||
value={ metric.metricValue[0] }
|
||||
onChange={ writeOption }
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{metric.metricType === 'table' && (
|
||||
<>
|
||||
<span className="mx-3">showing</span>
|
||||
<DropdownPlain
|
||||
name="metricFormat"
|
||||
options={[
|
||||
{ value: 'sessionCount', text: 'Session Count' },
|
||||
]}
|
||||
value={ metric.metricFormat }
|
||||
onChange={ writeOption }
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</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) => (
|
||||
<label className="font-medium flex items-center">
|
||||
{`${isTable ? 'Filter by' : 'Chart Series'}`}
|
||||
{!isTable && <HelpText position="top left" text="Defines a series of data for the line in chart." className="pl-3" />}
|
||||
</label>
|
||||
{metric.series && metric.series.size > 0 && metric.series.take(isTable ? 1 : metric.series.size).map((series: any, index: number) => (
|
||||
<div className="mb-2">
|
||||
<FilterSeries
|
||||
hideHeader={ isTable }
|
||||
seriesIndex={index}
|
||||
series={series}
|
||||
onRemoveSeries={() => removeSeries(index)}
|
||||
canDelete={metric.series.size > 1}
|
||||
emptyMessage={isTable ?
|
||||
'Filter data using any event or attribute. Use Add Step button below to do so.' :
|
||||
'Add user event or filter to define the series by clicking Add Step.'
|
||||
}
|
||||
/>
|
||||
</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>
|
||||
{ isTimeSeries && (
|
||||
<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" />
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { IconButton } from 'UI';
|
||||
import { connect } from 'react-redux';
|
||||
import { edit, init } from 'Duck/customMetrics';
|
||||
|
|
|
|||
|
|
@ -25,15 +25,20 @@ interface Props {
|
|||
editSeriesFilterFilter: typeof editSeriesFilterFilter;
|
||||
editSeriesFilter: typeof editSeriesFilter;
|
||||
removeSeriesFilterFilter: typeof removeSeriesFilterFilter;
|
||||
hideHeader?: boolean;
|
||||
emptyMessage?: any;
|
||||
}
|
||||
|
||||
function FilterSeries(props: Props) {
|
||||
const { canDelete } = props;
|
||||
const { canDelete, hideHeader = false, emptyMessage = 'Add user event or filter to define the series by clicking Add Step.' } = props;
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
const { series, seriesIndex } = props;
|
||||
|
||||
const onAddFilter = (filter) => {
|
||||
filter.value = [""]
|
||||
if (filter.hasOwnProperty('filters')) {
|
||||
filter.filters = filter.filters.map(i => ({ ...i, value: [""] }))
|
||||
}
|
||||
props.addSeriesFilterFilter(seriesIndex, filter);
|
||||
}
|
||||
|
||||
|
|
@ -51,9 +56,9 @@ function FilterSeries(props: Props) {
|
|||
|
||||
return (
|
||||
<div className="border rounded bg-white">
|
||||
<div className="border-b px-5 h-12 flex items-center relative">
|
||||
<div className={cn("border-b px-5 h-12 flex items-center relative", { 'hidden': hideHeader })}>
|
||||
<div className="mr-auto">
|
||||
<SeriesName name={series.name} onUpdate={(name) => props.updateSeries(seriesIndex, { name }) } />
|
||||
<SeriesName seriesIndex={seriesIndex} name={series.name} onUpdate={(name) => props.updateSeries(seriesIndex, { name }) } />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center cursor-pointer" >
|
||||
|
|
@ -78,10 +83,10 @@ function FilterSeries(props: Props) {
|
|||
onChangeEventsOrder={onChangeEventsOrder}
|
||||
/>
|
||||
): (
|
||||
<div className="color-gray-medium">Add user event or filter to define the series by clicking Add Step.</div>
|
||||
<div className="color-gray-medium">{emptyMessage}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-5 border-t h-12 flex items-center">
|
||||
<div className="px-6 border-t h-12 flex items-center -mx-4">
|
||||
<FilterSelection
|
||||
filter={undefined}
|
||||
onFilterClick={onAddFilter}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@ import { Icon } from 'UI';
|
|||
interface Props {
|
||||
name: string;
|
||||
onUpdate: (name) => void;
|
||||
seriesIndex?: number;
|
||||
}
|
||||
function SeriesName(props: Props) {
|
||||
const { seriesIndex = 1 } = props;
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [name, setName] = useState(props.name)
|
||||
const ref = useRef<any>(null)
|
||||
|
|
@ -36,7 +38,7 @@ function SeriesName(props: Props) {
|
|||
<input
|
||||
ref={ ref }
|
||||
name="name"
|
||||
className="fluid border-0 -mx-2 px-2"
|
||||
className="fluid border-0 -mx-2 px-2 h-8"
|
||||
value={name}
|
||||
// readOnly={!editing}
|
||||
onChange={write}
|
||||
|
|
@ -44,7 +46,7 @@ function SeriesName(props: Props) {
|
|||
onFocus={() => setEditing(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-base">{name}</div>
|
||||
<div className="text-base h-8 flex items-center border-transparent">{name.trim() === '' ? 'Seriess ' + (seriesIndex + 1) : name }</div>
|
||||
)}
|
||||
|
||||
<div className="ml-3 cursor-pointer" onClick={() => setEditing(true)}><Icon name="pencil" size="14" /></div>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ 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;
|
||||
|
|
@ -24,7 +23,8 @@ function SessionListModal(props: Props) {
|
|||
props.fetchSessionList({
|
||||
metricId: activeWidget.widget.metricId,
|
||||
startDate: activeWidget.startTimestamp,
|
||||
endDate: activeWidget.endTimestamp
|
||||
endDate: activeWidget.endTimestamp,
|
||||
filters: activeWidget.filters || [],
|
||||
});
|
||||
}, [activeWidget]);
|
||||
|
||||
|
|
@ -57,9 +57,9 @@ function SessionListModal(props: Props) {
|
|||
|
||||
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 && (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
.button {
|
||||
padding: 0 8px;
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
color: $teal;
|
||||
cursor: pointer;
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
}
|
||||
|
||||
.dropdownTrigger {
|
||||
padding: 4px 6px;
|
||||
padding: 4px;
|
||||
&:hover {
|
||||
background-color: $gray-light;
|
||||
}
|
||||
|
|
@ -42,7 +42,7 @@
|
|||
|
||||
.dropdown {
|
||||
display: flex !important;
|
||||
padding: 4px 6px;
|
||||
padding: 4px 4px;
|
||||
border-radius: 3px;
|
||||
color: $gray-darkest;
|
||||
font-weight: 500;
|
||||
|
|
@ -52,7 +52,7 @@
|
|||
}
|
||||
|
||||
.dropdownTrigger {
|
||||
padding: 4px 8px;
|
||||
padding: 4px 4px;
|
||||
border-radius: 3px;
|
||||
&:hover {
|
||||
background-color: $gray-light;
|
||||
|
|
|
|||
|
|
@ -1,16 +1,18 @@
|
|||
.dropdown {
|
||||
display: flex !important;
|
||||
padding: 4px 6px;
|
||||
padding: 4px;
|
||||
border-radius: 3px;
|
||||
color: $gray-darkest;
|
||||
font-weight: 500;
|
||||
background-color: white;
|
||||
border: solid thin $gray-light;
|
||||
&:hover {
|
||||
background-color: $gray-light;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdownTrigger {
|
||||
padding: 4px 8px;
|
||||
padding: 4px;
|
||||
border-radius: 3px;
|
||||
&:hover {
|
||||
background-color: $gray-light;
|
||||
|
|
|
|||
|
|
@ -3,25 +3,30 @@ import stl from './DropdownPlain.css';
|
|||
import { Dropdown, Icon } from 'UI';
|
||||
|
||||
interface Props {
|
||||
name?: string;
|
||||
options: any[];
|
||||
onChange: (e, { name, value }) => void;
|
||||
icon?: string;
|
||||
direction?: string;
|
||||
value: any;
|
||||
multiple?: boolean;
|
||||
}
|
||||
|
||||
export default function DropdownPlain(props: Props) {
|
||||
const { value, options, icon = "chevron-down", direction = "left" } = props;
|
||||
const { name = "sort", value, options, icon = "chevron-down", direction = "right", multiple = false } = props;
|
||||
return (
|
||||
<div>
|
||||
<Dropdown
|
||||
value={value}
|
||||
name="sort"
|
||||
name={name}
|
||||
className={ stl.dropdown }
|
||||
direction={direction}
|
||||
options={ options }
|
||||
onChange={ props.onChange }
|
||||
// floating
|
||||
scrolling
|
||||
multiple={ multiple }
|
||||
selectOnBlur={ false }
|
||||
// defaultValue={ value }
|
||||
icon={ icon ? <Icon name="chevron-down" color="gray-dark" size="14" className={stl.dropdownIcon} /> : null }
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -100,18 +100,20 @@ const FilterDropdown = props => {
|
|||
</div>
|
||||
)}
|
||||
{showDropdown && (
|
||||
<div className="absolute mt-2 bg-white rounded border p-3 z-20" id="filter-dropdown" style={{ width: '200px'}}>
|
||||
<div className="font-medium mb-2 tracking-widest color-gray-dark">SELECT FILTER</div>
|
||||
{filterKeys.filter(f => !filterKeyMaps.includes(f.key)).map(f => (
|
||||
<div
|
||||
key={f.key}
|
||||
onClick={() => onFilterKeySelect(f.key)}
|
||||
className={cn(stl.filterItem, 'py-3 -mx-3 px-3 flex items-center cursor-pointer')}
|
||||
>
|
||||
<Icon name={f.icon} size="16" />
|
||||
<span className="ml-3 capitalize">{f.name}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="absolute mt-2 bg-white rounded border z-20" id="filter-dropdown" style={{ width: '200px'}}>
|
||||
<div className="font-medium mb-2 tracking-widest color-gray-dark p-3">SELECT FILTER</div>
|
||||
<div className="px-3" style={{ maxHeight: '200px', overflowY: 'auto'}} >
|
||||
{filterKeys.filter(f => !filterKeyMaps.includes(f.key)).map(f => (
|
||||
<div
|
||||
key={f.key}
|
||||
onClick={() => onFilterKeySelect(f.key)}
|
||||
className={cn(stl.filterItem, 'py-3 -mx-3 px-3 flex items-center cursor-pointer')}
|
||||
>
|
||||
<Icon name={f.icon} size="16" />
|
||||
<span className="ml-3 capitalize">{f.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{filterKey && (
|
||||
|
|
|
|||
|
|
@ -47,15 +47,17 @@ function FilterAutoComplete(props: Props) {
|
|||
const requestValues = (q) => {
|
||||
setLoading(true);
|
||||
|
||||
return new APIClient()[method?.toLowerCase()](endpoint, { ...params, q })
|
||||
.then(response => response.json())
|
||||
.then(({ errors, data }) => {
|
||||
if (errors) {
|
||||
// this.setError();
|
||||
} else {
|
||||
setOptions(data);
|
||||
}
|
||||
}).finally(() => setLoading(false));
|
||||
return new APIClient()[method?.toLocaleLowerCase()](endpoint, { ...params, q })
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
}
|
||||
throw new Error(response.statusText);
|
||||
})
|
||||
.then(({ data }) => {
|
||||
setOptions(data);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
const debouncedRequestValues = React.useCallback(debounce(requestValues, 300), []);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ interface Props {
|
|||
onSelect: (e, item) => void;
|
||||
value: any;
|
||||
icon?: string;
|
||||
type?: string;
|
||||
isMultilple?: boolean;
|
||||
}
|
||||
|
||||
function FilterAutoCompleteLocal(props: Props) {
|
||||
|
|
@ -24,6 +26,8 @@ function FilterAutoCompleteLocal(props: Props) {
|
|||
onAddValue = () => null,
|
||||
value = '',
|
||||
icon = null,
|
||||
type = "text",
|
||||
isMultilple = true,
|
||||
} = props;
|
||||
const [showModal, setShowModal] = useState(true)
|
||||
const [query, setQuery] = useState(value);
|
||||
|
|
@ -59,7 +63,7 @@ function FilterAutoCompleteLocal(props: Props) {
|
|||
onFocus={ () => setShowModal(true)}
|
||||
value={ query }
|
||||
autoFocus={ true }
|
||||
type="text"
|
||||
type={ type }
|
||||
placeholder={ placeholder }
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
|
|
@ -71,7 +75,7 @@ function FilterAutoCompleteLocal(props: Props) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{ !showOrButton && <div className="ml-3">or</div> }
|
||||
{ !showOrButton && isMultilple && <div className="ml-3">or</div> }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import FilterSelection from '../FilterSelection';
|
|||
import FilterValue from '../FilterValue';
|
||||
import { Icon } from 'UI';
|
||||
import FilterSource from '../FilterSource';
|
||||
import { FilterType } from 'App/types/filter/filterType';
|
||||
import SubFilterItem from '../SubFilterItem';
|
||||
|
||||
interface Props {
|
||||
filterIndex: number;
|
||||
|
|
@ -15,9 +17,14 @@ interface Props {
|
|||
function FilterItem(props: Props) {
|
||||
const { isFilter = false, filterIndex, filter } = props;
|
||||
const canShowValues = !(filter.operator === "isAny" || filter.operator === "onAny" || filter.operator === "isUndefined");
|
||||
const isSubFilter = filter.type === FilterType.SUB_FILTERS;
|
||||
|
||||
const replaceFilter = (filter) => {
|
||||
props.onUpdate({ ...filter, value: [""]});
|
||||
props.onUpdate({
|
||||
...filter,
|
||||
value: [""],
|
||||
filters: filter.filters ? filter.filters.map(i => ({ ...i, value: [""] })) : []
|
||||
});
|
||||
};
|
||||
|
||||
const onOperatorChange = (e, { name, value }) => {
|
||||
|
|
@ -28,6 +35,19 @@ function FilterItem(props: Props) {
|
|||
props.onUpdate({ ...filter, sourceOperator: value })
|
||||
}
|
||||
|
||||
const onUpdateSubFilter = (subFilter, subFilterIndex) => {
|
||||
props.onUpdate({
|
||||
...filter,
|
||||
filters: filter.filters.map((i, index) => {
|
||||
if (index === subFilterIndex) {
|
||||
return subFilter;
|
||||
}
|
||||
return i;
|
||||
})
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex items-center hover:bg-active-blue -mx-5 px-5 py-2">
|
||||
<div className="flex items-start w-full">
|
||||
|
|
@ -48,14 +68,31 @@ function FilterItem(props: Props) {
|
|||
)}
|
||||
|
||||
{/* Filter values */}
|
||||
<FilterOperator
|
||||
options={filter.operatorOptions}
|
||||
onChange={onOperatorChange}
|
||||
className="mx-2 flex-shrink-0"
|
||||
value={filter.operator}
|
||||
/>
|
||||
{ canShowValues && (<FilterValue filter={filter} onUpdate={props.onUpdate} />) }
|
||||
|
||||
{ !isSubFilter && (
|
||||
<>
|
||||
<FilterOperator
|
||||
options={filter.operatorOptions}
|
||||
onChange={onOperatorChange}
|
||||
className="mx-2 flex-shrink-0"
|
||||
value={filter.operator}
|
||||
/>
|
||||
{ canShowValues && (<FilterValue filter={filter} onUpdate={props.onUpdate} />) }
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* filters */}
|
||||
{isSubFilter && (
|
||||
<div className="grid grid-col ml-3 w-full">
|
||||
{filter.filters.map((subFilter, subFilterIndex) => (
|
||||
<SubFilterItem
|
||||
filterIndex={subFilterIndex}
|
||||
filter={subFilter}
|
||||
onUpdate={(f) => onUpdateSubFilter(f, subFilterIndex)}
|
||||
onRemoveFilter={props.onRemoveFilter}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 self-start mt-1 ml-auto px-2">
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ function FilterList(props: Props) {
|
|||
<div className="mr-2 color-gray-medium text-sm" style={{ textDecoration: 'underline dotted'}}>
|
||||
<Popup
|
||||
trigger={<div>Events Order</div>}
|
||||
content={ `Events Order` }
|
||||
content={ `Select the operator to be applied between events in your search.` }
|
||||
size="tiny"
|
||||
inverted
|
||||
position="top center"
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ function FilterValue(props: Props) {
|
|||
}
|
||||
|
||||
const renderValueFiled = (value, valueIndex) => {
|
||||
const showOrButton = valueIndex === lastIndex;
|
||||
const showOrButton = valueIndex === lastIndex && filter.type !== FilterType.NUMBER;
|
||||
switch(filter.type) {
|
||||
case FilterType.STRING:
|
||||
return (
|
||||
|
|
@ -113,15 +113,40 @@ function FilterValue(props: Props) {
|
|||
maxDuration={ durationValues.maxDuration }
|
||||
/>
|
||||
)
|
||||
case FilterType.NUMBER_MULTIPLE:
|
||||
return (
|
||||
<FilterAutoCompleteLocal
|
||||
value={value}
|
||||
showCloseButton={showCloseButton}
|
||||
showOrButton={showOrButton}
|
||||
onAddValue={onAddValue}
|
||||
onRemoveValue={() => onRemoveValue(valueIndex)}
|
||||
onSelect={(e, item) => debounceOnSelect(e, item, valueIndex)}
|
||||
icon={filter.icon}
|
||||
type="number"
|
||||
/>
|
||||
)
|
||||
case FilterType.NUMBER:
|
||||
return (
|
||||
<input
|
||||
className="w-full px-2 py-1 text-sm leading-tight text-gray-700 rounded-lg"
|
||||
type="number"
|
||||
name={`${filter.key}-${valueIndex}`}
|
||||
<FilterAutoCompleteLocal
|
||||
value={value}
|
||||
onChange={(e) => onChange(e, { value: e.target.value }, valueIndex)}
|
||||
showCloseButton={showCloseButton}
|
||||
showOrButton={showOrButton}
|
||||
onAddValue={onAddValue}
|
||||
onRemoveValue={() => onRemoveValue(valueIndex)}
|
||||
onSelect={(e, item) => debounceOnSelect(e, item, valueIndex)}
|
||||
icon={filter.icon}
|
||||
type="number"
|
||||
isMultilple={false}
|
||||
/>
|
||||
// <input
|
||||
// className="w-full px-2 py-1 text-sm leading-tight text-gray-700 rounded bg-white border"
|
||||
// type="number"
|
||||
// name={`${filter.key}-${valueIndex}`}
|
||||
// value={value}
|
||||
// placeholder="Enter"
|
||||
// onChange={(e) => onChange(e, { value: e.target.value }, valueIndex)}
|
||||
// />
|
||||
)
|
||||
case FilterType.MULTIPLE:
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
height: 26px;
|
||||
width: 100%;
|
||||
|
||||
& .right {
|
||||
height: 24px;
|
||||
|
|
|
|||
|
|
@ -16,31 +16,36 @@ interface Props {
|
|||
showOrButton?: boolean;
|
||||
onRemoveValue?: () => void;
|
||||
onAddValue?: () => void;
|
||||
isMultilple?: boolean;
|
||||
}
|
||||
function FilterValueDropdown(props: Props) {
|
||||
const { filter, multiple = false, search = false, options, onChange, value, className = '', showCloseButton = true, showOrButton = true } = props;
|
||||
const { filter, multiple = false, isMultilple = true, search = false, options, onChange, value, className = '', showCloseButton = true, showOrButton = true } = props;
|
||||
// const options = []
|
||||
|
||||
return (
|
||||
<div className={stl.wrapper}>
|
||||
<Dropdown
|
||||
search={search}
|
||||
className={ cn(stl.operatorDropdown, className, "filterDropdown") }
|
||||
options={ options }
|
||||
name="issue_type"
|
||||
value={ value }
|
||||
onChange={ onChange }
|
||||
placeholder="Select"
|
||||
fluid
|
||||
icon={ <Icon className="absolute right-0 mr-2" name="chevron-down" size="12" /> }
|
||||
/>
|
||||
<div
|
||||
className={stl.right}
|
||||
// onClick={showOrButton ? onRemoveValue : onAddValue}
|
||||
>
|
||||
{ showCloseButton && <div onClick={props.onRemoveValue}><Icon name="close" size="12" /></div> }
|
||||
{ showOrButton && <div onClick={props.onAddValue} className="color-teal"><span className="px-1">or</span></div> }
|
||||
<div className="relative flex items-center w-full">
|
||||
<div className={stl.wrapper}>
|
||||
<Dropdown
|
||||
search={search}
|
||||
className={ cn(stl.operatorDropdown, className, "filterDropdown") }
|
||||
options={ options }
|
||||
name="issue_type"
|
||||
value={ value }
|
||||
onChange={ onChange }
|
||||
placeholder="Select"
|
||||
fluid
|
||||
icon={ <Icon className="absolute right-0 mr-2" name="chevron-down" size="12" /> }
|
||||
/>
|
||||
<div
|
||||
className={stl.right}
|
||||
// onClick={showOrButton ? onRemoveValue : onAddValue}
|
||||
>
|
||||
{ showCloseButton && <div onClick={props.onRemoveValue}><Icon name="close" size="12" /></div> }
|
||||
{ showOrButton && <div onClick={props.onAddValue} className="color-teal"><span className="px-1">or</span></div> }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ !showOrButton && isMultilple && <div className="ml-3">or</div> }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
import { filter } from 'App/components/BugFinder/ManageFilters/savedFilterList.css'
|
||||
import React from 'react'
|
||||
import FilterOperator from '../FilterOperator';
|
||||
import FilterValue from '../FilterValue';
|
||||
|
||||
interface Props {
|
||||
filterIndex: number;
|
||||
filter: any; // event/filter
|
||||
onUpdate: (filter) => void;
|
||||
onRemoveFilter: () => void;
|
||||
isFilter?: boolean;
|
||||
}
|
||||
export default function SubFilterItem(props: Props) {
|
||||
const { isFilter = false, filterIndex, filter } = props;
|
||||
const canShowValues = !(filter.operator === "isAny" || filter.operator === "onAny" || filter.operator === "isUndefined");
|
||||
|
||||
const onOperatorChange = (e, { name, value }) => {
|
||||
props.onUpdate({ ...filter, operator: value })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center hover:bg-active-blue pb-4">
|
||||
<div className="flex-shrink-0 py-1">{filter.label}</div>
|
||||
<FilterOperator
|
||||
options={filter.operatorOptions}
|
||||
onChange={onOperatorChange}
|
||||
className="mx-2 flex-shrink-0"
|
||||
value={filter.operator}
|
||||
/>
|
||||
|
||||
{ canShowValues && (<FilterValue filter={filter} onUpdate={props.onUpdate} />) }
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './SubFilterItem';
|
||||
|
|
@ -18,12 +18,6 @@ function FunnelSearch(props: Props) {
|
|||
|
||||
const onAddFilter = (filter) => {
|
||||
props.addFilter(filter);
|
||||
// filter.value = [""]
|
||||
// const newFilters = appliedFilter.filters.concat(filter);
|
||||
// props.edit({
|
||||
// ...appliedFilter.filter,
|
||||
// filters: newFilters,
|
||||
// });
|
||||
}
|
||||
|
||||
const onUpdateFilter = (filterIndex, filter) => {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ import {
|
|||
BrowserIcon,
|
||||
CountryFlag,
|
||||
Avatar,
|
||||
TextEllipsis
|
||||
TextEllipsis,
|
||||
Label,
|
||||
} from 'UI';
|
||||
import { deviceTypeIcon } from 'App/iconNames';
|
||||
import { toggleFavorite, setSessionPath } from 'Duck/sessions';
|
||||
|
|
@ -20,14 +21,15 @@ import Counter from './Counter'
|
|||
import { withRouter } from 'react-router-dom';
|
||||
import SessionMetaList from './SessionMetaList';
|
||||
import ErrorBars from './ErrorBars';
|
||||
import { assist as assistRoute, isRoute } from "App/routes";
|
||||
import { assist as assistRoute, liveSession, isRoute } from "App/routes";
|
||||
import { capitalize } from 'App/utils';
|
||||
|
||||
const ASSIST_ROUTE = assistRoute();
|
||||
const ASSIST_LIVE_SESSION = liveSession()
|
||||
|
||||
const Label = ({ label = '', color = 'color-gray-medium'}) => (
|
||||
<div className={ cn('font-light text-sm', color)}>{label}</div>
|
||||
)
|
||||
// const Label = ({ label = '', color = 'color-gray-medium'}) => (
|
||||
// <div className={ cn('font-light text-sm', color)}>{label}</div>
|
||||
// )
|
||||
@connect(state => ({
|
||||
timezone: state.getIn(['sessions', 'timezone']),
|
||||
siteId: state.getIn([ 'user', 'siteId' ]),
|
||||
|
|
@ -59,16 +61,18 @@ export default class SessionItem extends React.PureComponent {
|
|||
metadata,
|
||||
userSessionsCount,
|
||||
issueTypes,
|
||||
active,
|
||||
},
|
||||
timezone,
|
||||
onUserClick = () => null,
|
||||
hasUserFilter = false,
|
||||
disableUser = false,
|
||||
metaList = [],
|
||||
showActive = false,
|
||||
} = this.props;
|
||||
const formattedDuration = durationFormatted(duration);
|
||||
const hasUserId = userId || userAnonymousId;
|
||||
const isAssist = isRoute(ASSIST_ROUTE, this.props.location.pathname);
|
||||
const isAssist = isRoute(ASSIST_ROUTE, this.props.location.pathname) || isRoute(ASSIST_LIVE_SESSION, this.props.location.pathname);
|
||||
|
||||
const _metaList = Object.keys(metadata).filter(i => metaList.includes(i)).map(key => {
|
||||
const value = metadata[key];
|
||||
|
|
@ -129,6 +133,11 @@ export default class SessionItem extends React.PureComponent {
|
|||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
{ isAssist && showActive && (
|
||||
<Label success className={cn("bg-green color-white text-right mr-4", { 'opacity-0' : !active})}>
|
||||
<span className="color-white">ACTIVE</span>
|
||||
</Label>
|
||||
)}
|
||||
<div className={ stl.playLink } id="play-button" data-viewed={ viewed }>
|
||||
<Link to={ isAssist ? liveSessionRoute(sessionId) : sessionRoute(sessionId) }>
|
||||
<Icon name={ !viewed && !isAssist ? 'play-fill' : 'play-circle-light' } size="42" color={isAssist ? "tealx" : "teal"} />
|
||||
|
|
|
|||
|
|
@ -19,12 +19,6 @@ function SessionSearch(props: Props) {
|
|||
|
||||
const onAddFilter = (filter) => {
|
||||
props.addFilter(filter);
|
||||
// filter.value = [""]
|
||||
// const newFilters = appliedFilter.filters.concat(filter);
|
||||
// props.edit({
|
||||
// ...appliedFilter.filter,
|
||||
// filters: newFilters,
|
||||
// });
|
||||
}
|
||||
|
||||
const onUpdateFilter = (filterIndex, filter) => {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import React, { useState } from 'react'
|
||||
import { connect } from 'react-redux';
|
||||
import { editGDPR, saveGDPR } from 'Duck/site';
|
||||
import { Controlled as CodeMirror } from 'react-codemirror2';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import { Select, Checkbox } from 'UI';
|
||||
import GDPR from 'Types/site/gdpr';
|
||||
import cn from 'classnames'
|
||||
import styles from './projectCodeSnippet.css'
|
||||
import Highlight from 'react-highlight'
|
||||
|
||||
const inputModeOptions = [
|
||||
{ text: 'Record all inputs', value: 'plain' },
|
||||
|
|
@ -16,9 +16,11 @@ const inputModeOptions = [
|
|||
|
||||
const codeSnippet = `<!-- OpenReplay Tracking Code for HOST -->
|
||||
<script>
|
||||
var initOpts = { projectKey: "PROJECT_KEY", ingestPoint: "https://${window.location.hostname}/ingest"};
|
||||
var startOpts = { userID: "" };
|
||||
(function(A,s,a,y,e,r){
|
||||
r=window.OpenReplay=[s,r,e,[y-1]];
|
||||
s=document.createElement('script');s.src=a;s.async=!A;
|
||||
r=window.OpenReplay=[e,r,y,[s-1, e]];
|
||||
s=document.createElement('script');s.src=A;s.async=!a;
|
||||
document.getElementsByTagName('head')[0].appendChild(s);
|
||||
r.start=function(v){r.push([0])};
|
||||
r.stop=function(v){r.push([1])};
|
||||
|
|
@ -29,8 +31,7 @@ const codeSnippet = `<!-- OpenReplay Tracking Code for HOST -->
|
|||
r.issue=function(k,p){r.push([6,k,p])};
|
||||
r.isActive=function(){return false};
|
||||
r.getSessionToken=function(){};
|
||||
r.i="https://${window.location.hostname}/ingest";
|
||||
})(0, "PROJECT_KEY", "//static.openreplay.com/${window.ENV.TRACKER_VERSION}/openreplay.js",1,XXX);
|
||||
})("//static.openreplay.com/${window.ENV.TRACKER_VERSION}/openreplay.js",XXX,0,initOpts,startOpts);
|
||||
</script>`;
|
||||
|
||||
|
||||
|
|
@ -132,17 +133,9 @@ const ProjectCodeSnippet = props => {
|
|||
</div>
|
||||
<div className={ styles.snippetsWrapper }>
|
||||
<button className={ styles.codeCopy } onClick={ () => copyHandler(_snippet) }>{ copied ? 'copied' : 'copy' }</button>
|
||||
<CodeMirror
|
||||
value={ _snippet }
|
||||
className={ styles.snippet }
|
||||
options={{
|
||||
height: 340,
|
||||
mode: 'html',
|
||||
readOnly: true,
|
||||
showCursorWhenSelecting: false,
|
||||
scroll: false
|
||||
}}
|
||||
/>
|
||||
<Highlight className="html">
|
||||
{_snippet}
|
||||
</Highlight>
|
||||
</div>
|
||||
<div className="my-4">You can also setup OpenReplay using <a className="link" href="https://docs.openreplay.com/integrations/google-tag-manager" target="_blank">Google Tag Manager (GTM)</a>. </div>
|
||||
</div>
|
||||
|
|
|
|||
22
frontend/app/components/ui/HelpText/HelpText.tsx
Normal file
22
frontend/app/components/ui/HelpText/HelpText.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
|
||||
import React from 'react'
|
||||
import { Icon, Popup } from 'UI'
|
||||
|
||||
interface Props {
|
||||
text: string,
|
||||
className?: string,
|
||||
position?: string,
|
||||
}
|
||||
export default function HelpText(props: Props) {
|
||||
const { text, className = '', position = 'top center' } = props
|
||||
return (
|
||||
<div>
|
||||
<Popup
|
||||
trigger={<div className={className}><Icon name="question-circle" size={16} /></div>}
|
||||
content={text}
|
||||
inverted
|
||||
position={position}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1
frontend/app/components/ui/HelpText/index.ts
Normal file
1
frontend/app/components/ui/HelpText/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from './HelpText';
|
||||
|
|
@ -9,13 +9,14 @@ class SegmentSelection extends React.Component {
|
|||
}
|
||||
|
||||
render() {
|
||||
const { className, list, small = false, extraSmall = false, primary = false, size = "normal" } = this.props;
|
||||
const { className, list, small = false, extraSmall = false, primary = false, size = "normal", icons = false } = this.props;
|
||||
|
||||
return (
|
||||
<div className={ cn(styles.wrapper, {
|
||||
[styles.primary] : primary,
|
||||
[styles.small] : size === 'small' || small,
|
||||
[styles.extraSmall] : extraSmall,
|
||||
[styles.extraSmall] : size === 'extraSmall' || extraSmall,
|
||||
[styles.icons] : icons === true,
|
||||
}, className) }
|
||||
>
|
||||
{ list.map(item => (
|
||||
|
|
@ -27,8 +28,8 @@ class SegmentSelection extends React.Component {
|
|||
data-active={ this.props.value && this.props.value.value === item.value }
|
||||
onClick={ () => !item.disabled && this.setActiveItem(item) }
|
||||
>
|
||||
{ item.icon && <Icon name={ item.icon } size="20" marginRight="10" /> }
|
||||
<div>{ item.name }</div>
|
||||
{ item.icon && <Icon name={ item.icon } size={(size === "extraSmall" || icons) ? 14 : 20} marginRight={ item.name ? "6" : "" } /> }
|
||||
<div className="leading-none">{ item.name }</div>
|
||||
</div>
|
||||
}
|
||||
disabled={!item.disabled}
|
||||
|
|
|
|||
|
|
@ -12,13 +12,13 @@
|
|||
padding: 10px;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
border-right: solid thin $teal;
|
||||
cursor: pointer;
|
||||
background-color: $gray-lightest;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
white-space: nowrap;
|
||||
border-right: solid thin $gray-light;
|
||||
|
||||
& span svg {
|
||||
fill: $gray-medium;
|
||||
|
|
@ -53,9 +53,16 @@
|
|||
& .item {
|
||||
color: $teal;
|
||||
background-color: white;
|
||||
border-right: solid thin $teal;
|
||||
& svg {
|
||||
fill: $teal !important;
|
||||
}
|
||||
&[data-active=true] {
|
||||
background-color: $teal;
|
||||
color: white;
|
||||
& svg {
|
||||
fill: white !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -65,6 +72,11 @@
|
|||
}
|
||||
|
||||
.extraSmall .item {
|
||||
padding: 0 4px;
|
||||
padding: 2px 4px !important;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.icons .item {
|
||||
padding: 4px !important;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
|
@ -1,12 +1,24 @@
|
|||
import styles from './slideModal.css';
|
||||
import cn from 'classnames';
|
||||
export default class SlideModal extends React.PureComponent {
|
||||
componentDidMount() {
|
||||
document.addEventListener('keydown', this.keyPressHandler);
|
||||
}
|
||||
// componentDidMount() {
|
||||
// document.addEventListener('keydown', this.keyPressHandler);
|
||||
// }
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener('keydown', this.keyPressHandler);
|
||||
// componentWillUnmount() {
|
||||
// document.removeEventListener('keydown', this.keyPressHandler);
|
||||
// }
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.isDisplayed !== this.props.isDisplayed) {
|
||||
if (this.props.isDisplayed) {
|
||||
document.addEventListener('keydown', this.keyPressHandler);
|
||||
document.body.classList.add('no-scroll');
|
||||
} else {
|
||||
document.removeEventListener('keydown', this.keyPressHandler);
|
||||
document.body.classList.remove('no-scroll');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
keyPressHandler = (e) => {
|
||||
|
|
|
|||
|
|
@ -54,5 +54,6 @@ export { default as CopyButton } from './CopyButton';
|
|||
export { default as HighlightCode } from './HighlightCode';
|
||||
export { default as NoPermission } from './NoPermission';
|
||||
export { default as NoSessionPermission } from './NoSessionPermission';
|
||||
export { default as HelpText } from './HelpText';
|
||||
|
||||
export { Input, Modal, Form, Message, Card } from 'semantic-ui-react';
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { FilterKey } from 'Types/filter/filterType';
|
||||
|
||||
export const options = [
|
||||
{ key: 'on', text: 'on', value: 'on' },
|
||||
{ key: 'notOn', text: 'not on', value: 'notOn' },
|
||||
|
|
@ -54,6 +56,57 @@ export const customOperators = [
|
|||
{ key: '>=', text: '>=', value: '>=' },
|
||||
]
|
||||
|
||||
export const metricTypes = [
|
||||
{ text: 'Timeseries', value: 'timeseries' },
|
||||
{ text: 'Table', value: 'table' },
|
||||
];
|
||||
|
||||
export const tableColumnName = {
|
||||
[FilterKey.USERID]: 'User',
|
||||
[FilterKey.ISSUE]: 'Issue',
|
||||
[FilterKey.USER_BROWSER]: 'Browser',
|
||||
[FilterKey.USER_DEVICE]: 'Device',
|
||||
[FilterKey.USER_COUNTRY]: 'Country',
|
||||
[FilterKey.LOCATION]: 'URL',
|
||||
}
|
||||
|
||||
export const metricOf = [
|
||||
{ text: 'Session Count', value: 'sessionCount', type: 'timeseries' },
|
||||
{ text: 'Users', value: FilterKey.USERID, type: 'table' },
|
||||
{ text: 'Issues', value: FilterKey.ISSUE, type: 'table' },
|
||||
{ text: 'Browser', value: FilterKey.USER_BROWSER, type: 'table' },
|
||||
{ text: 'Device', value: FilterKey.USER_DEVICE, type: 'table' },
|
||||
{ text: 'Country', value: FilterKey.USER_COUNTRY, type: 'table' },
|
||||
{ text: 'URL', value: FilterKey.LOCATION, type: 'table' },
|
||||
]
|
||||
|
||||
export const methodOptions = [
|
||||
{ text: 'GET', value: 'GET' },
|
||||
{ text: 'POST', value: 'POST' },
|
||||
{ text: 'PUT', value: 'PUT' },
|
||||
{ text: 'DELETE', value: 'DELETE' },
|
||||
{ text: 'PATCH', value: 'PATCH' },
|
||||
{ text: 'HEAD', value: 'HEAD' },
|
||||
{ text: 'OPTIONS', value: 'OPTIONS' },
|
||||
{ text: 'TRACE', value: 'TRACE' },
|
||||
{ text: 'CONNECT', value: 'CONNECT' },
|
||||
]
|
||||
|
||||
export const issueOptions = [
|
||||
{ text: 'Click Rage', value: 'click_rage' },
|
||||
{ text: 'Dead Click', value: 'dead_click' },
|
||||
{ text: 'Excessive Scrolling', value: 'excessive_scrolling' },
|
||||
{ text: 'Bad Request', value: 'bad_request' },
|
||||
{ text: 'Missing Resource', value: 'missing_resource' },
|
||||
{ text: 'Memory', value: 'memory' },
|
||||
{ text: 'CPU', value: 'cpu' },
|
||||
{ text: 'Slow Resource', value: 'slow_resource' },
|
||||
{ text: 'Slow Page Load', value: 'slow_page_load' },
|
||||
{ text: 'Crash', value: 'crash' },
|
||||
{ text: 'Custom', value: 'custom' },
|
||||
{ text: 'JS Exception', value: 'js_exception' },
|
||||
]
|
||||
|
||||
export default {
|
||||
options,
|
||||
baseOperators,
|
||||
|
|
@ -62,4 +115,8 @@ export default {
|
|||
booleanOperators,
|
||||
customOperators,
|
||||
getOperatorsByKeys,
|
||||
metricTypes,
|
||||
metricOf,
|
||||
issueOptions,
|
||||
methodOptions,
|
||||
}
|
||||
|
|
@ -187,7 +187,7 @@ export const init = (instance = null, forceNull = false) => (dispatch, getState)
|
|||
export const fetchSessionList = (params) => (dispatch, getState) => {
|
||||
dispatch({
|
||||
types: array(FETCH_SESSION_LIST),
|
||||
call: client => client.post(`/custom_metrics/sessions`, { ...params }),
|
||||
call: client => client.post(`/custom_metrics/${params.metricId}/sessions`, { ...params }),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { fromJS, List, Map, Set } from 'immutable';
|
||||
import { List, Map, Set } from 'immutable';
|
||||
import { errors as errorsRoute, isRoute } from "App/routes";
|
||||
import Filter from 'Types/filter';
|
||||
import SavedFilter from 'Types/filter/savedFilter';
|
||||
|
|
@ -8,15 +8,6 @@ import withRequestState, { RequestTypes } from './requestStateCreator';
|
|||
import { fetchList as fetchSessionList } from './sessions';
|
||||
import { fetchList as fetchErrorsList } from './errors';
|
||||
import { fetchListType, fetchType, saveType, editType, initType, removeType } from './funcTools/crud/types';
|
||||
import logger from 'App/logger';
|
||||
|
||||
import { newFiltersList } from 'Types/filter'
|
||||
import NewFilter, { filtersMap } from 'Types/filter/newFilter';
|
||||
|
||||
|
||||
// for (var i = 0; i < newFiltersList.length; i++) {
|
||||
// filterOptions[newFiltersList[i].category] = newFiltersList.filter(filter => filter.category === newFiltersList[i].category)
|
||||
// }
|
||||
|
||||
const ERRORS_ROUTE = errorsRoute();
|
||||
|
||||
|
|
@ -44,11 +35,8 @@ const ADD_ATTRIBUTE = 'filters/ADD_ATTRIBUTE';
|
|||
const EDIT_ATTRIBUTE = 'filters/EDIT_ATTRIBUTE';
|
||||
const REMOVE_ATTRIBUTE = 'filters/REMOVE_ATTRIBUTE';
|
||||
const SET_ACTIVE_FLOW = 'filters/SET_ACTIVE_FLOW';
|
||||
|
||||
const UPDATE_VALUE = 'filters/UPDATE_VALUE';
|
||||
|
||||
const REFRESH_FILTER_OPTIONS = 'filters/REFRESH_FILTER_OPTIONS';
|
||||
|
||||
const initialState = Map({
|
||||
instance: Filter(),
|
||||
activeFilter: null,
|
||||
|
|
|
|||
|
|
@ -107,14 +107,15 @@ export const checkFilterValue = (value) => {
|
|||
return Array.isArray(value) ? (value.length === 0 ? [""] : value) : [value];
|
||||
}
|
||||
|
||||
export const filterMap = ({category, value, key, operator, sourceOperator, source, custom, isEvent }) => ({
|
||||
export const filterMap = ({category, value, key, operator, sourceOperator, source, custom, isEvent, filters }) => ({
|
||||
value: checkValues(key, value),
|
||||
custom,
|
||||
type: category === FilterCategory.METADATA ? FilterKey.METADATA : key,
|
||||
operator,
|
||||
source: category === FilterCategory.METADATA ? key : source,
|
||||
sourceOperator,
|
||||
isEvent
|
||||
isEvent,
|
||||
filters: filters ? filters.map(filterMap) : [],
|
||||
});
|
||||
|
||||
const reduceThenFetchResource = actionCreator => (...args) => (dispatch, getState) => {
|
||||
|
|
@ -233,6 +234,10 @@ export const hasFilterApplied = (filters, filter) => {
|
|||
|
||||
export const addFilter = (filter) => (dispatch, getState) => {
|
||||
filter.value = checkFilterValue(filter.value);
|
||||
filter.filters = filter.filters ? filter.filters.map(subFilter => ({
|
||||
...subFilter,
|
||||
value: checkFilterValue(subFilter.value),
|
||||
})) : null;
|
||||
const instance = getState().getIn([ 'search', 'instance']);
|
||||
|
||||
if (hasFilterApplied(instance.filters, filter)) {
|
||||
|
|
|
|||
|
|
@ -141,4 +141,10 @@
|
|||
|
||||
margin: 25px 0;
|
||||
background-color: $gray-light;
|
||||
}
|
||||
|
||||
.no-scroll {
|
||||
height: 100vh;
|
||||
overflow-y: hidden;
|
||||
padding-right: 15px;
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<svg viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M1.17157 3.17157C1.92172 2.42143 2.93913 2 4 2H28C29.0609 2 30.0783 2.42143 30.8284 3.17157C31.5786 3.92172 32 4.93913 32 6V26C32 27.0609 31.5786 28.0783 30.8284 28.8284C30.0783 29.5786 29.0609 30 28 30H4C2.93913 30 1.92172 29.5786 1.17157 28.8284C0.421427 28.0783 0 27.0609 0 26V6C0 4.93913 0.421427 3.92172 1.17157 3.17157ZM30 9V6C30 5.46957 29.7893 4.96086 29.4142 4.58579C29.0391 4.21071 28.5304 4 28 4H4C3.46957 4 2.96086 4.21071 2.58579 4.58579C2.21071 4.96086 2 5.46957 2 6V9V10V12V13V26C2 26.5304 2.21071 27.0391 2.58579 27.4142C2.96086 27.7893 3.46957 28 4 28H28C28.5304 28 29.0391 27.7893 29.4142 27.4142C29.7893 27.0391 30 26.5304 30 26V13V12V10V9Z" fill="black"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.5575 19.5675C15.6156 19.6257 15.6845 19.6719 15.7605 19.7034C15.8364 19.7349 15.9178 19.7511 16 19.7511C16.0822 19.7511 16.1636 19.7349 16.2395 19.7034C16.3155 19.6719 16.3844 19.6257 16.4425 19.5675L18.9425 17.0675C19.0006 17.0094 19.0467 16.9404 19.0782 16.8645C19.1096 16.7886 19.1258 16.7072 19.1258 16.625C19.1258 16.5428 19.1096 16.4614 19.0782 16.3855C19.0467 16.3096 19.0006 16.2406 18.9425 16.1825C18.8844 16.1244 18.8154 16.0783 18.7395 16.0468C18.6636 16.0154 18.5822 15.9992 18.5 15.9992C18.4178 15.9992 18.3364 16.0154 18.2605 16.0468C18.1846 16.0783 18.1156 16.1244 18.0575 16.1825L16.625 17.6163V12.875C16.625 12.7092 16.5591 12.5503 16.4419 12.4331C16.3247 12.3158 16.1658 12.25 16 12.25C15.8342 12.25 15.6753 12.3158 15.5581 12.4331C15.4408 12.5503 15.375 12.7092 15.375 12.875V17.6163L13.9425 16.1825C13.8251 16.0651 13.666 15.9992 13.5 15.9992C13.334 15.9992 13.1749 16.0651 13.0575 16.1825C12.9401 16.2999 12.8742 16.459 12.8742 16.625C12.8742 16.791 12.9401 16.9501 13.0575 17.0675L15.5575 19.5675V19.5675Z" fill="black"/>
|
||||
<path d="M11.5075 10.1775C12.7569 9.10017 14.3503 8.50517 16 8.5C19.3625 8.5 22.1538 11 22.4575 14.2237C24.4475 14.505 26 16.1712 26 18.2162C26 20.4612 24.1275 22.25 21.8588 22.25H10.7262C8.135 22.25 6 20.2075 6 17.6475C6 15.4437 7.5825 13.6187 9.6775 13.1562C9.85625 12.0775 10.55 11.0025 11.5075 10.1775V10.1775ZM12.3238 11.1237C11.3775 11.94 10.8825 12.9238 10.8825 13.6938V14.2538L10.3262 14.315C8.58 14.5062 7.25 15.94 7.25 17.6475C7.25 19.4812 8.7875 21 10.7262 21H21.8588C23.475 21 24.75 19.735 24.75 18.2162C24.75 16.6962 23.475 15.4313 21.8588 15.4313H21.2338V14.8063C21.235 12.0313 18.91 9.75 16 9.75C14.6498 9.75539 13.3461 10.243 12.3238 11.125V11.1237Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
3
frontend/app/svg/icons/graph-up-arrow.svg
Normal file
3
frontend/app/svg/icons/graph-up-arrow.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-graph-up-arrow" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M0 0h1v15h15v1H0V0Zm10 3.5a.5.5 0 0 1 .5-.5h4a.5.5 0 0 1 .5.5v4a.5.5 0 0 1-1 0V4.9l-3.613 4.417a.5.5 0 0 1-.74.037L7.06 6.767l-3.656 5.027a.5.5 0 0 1-.808-.588l4-5.5a.5.5 0 0 1 .758-.06l2.609 2.61L13.445 4H10.5a.5.5 0 0 1-.5-.5Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 402 B |
3
frontend/app/svg/icons/hash.svg
Normal file
3
frontend/app/svg/icons/hash.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-hash" viewBox="0 0 16 16">
|
||||
<path d="M8.39 12.648a1.32 1.32 0 0 0-.015.18c0 .305.21.508.5.508.266 0 .492-.172.555-.477l.554-2.703h1.204c.421 0 .617-.234.617-.547 0-.312-.188-.53-.617-.53h-.985l.516-2.524h1.265c.43 0 .618-.227.618-.547 0-.313-.188-.524-.618-.524h-1.046l.476-2.304a1.06 1.06 0 0 0 .016-.164.51.51 0 0 0-.516-.516.54.54 0 0 0-.539.43l-.523 2.554H7.617l.477-2.304c.008-.04.015-.118.015-.164a.512.512 0 0 0-.523-.516.539.539 0 0 0-.531.43L6.53 5.484H5.414c-.43 0-.617.22-.617.532 0 .312.187.539.617.539h.906l-.515 2.523H4.609c-.421 0-.609.219-.609.531 0 .313.188.547.61.547h.976l-.516 2.492c-.008.04-.015.125-.015.18 0 .305.21.508.5.508.265 0 .492-.172.554-.477l.555-2.703h2.242l-.515 2.492zm-1-6.109h2.266l-.515 2.563H6.859l.532-2.563z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 855 B |
3
frontend/app/svg/icons/pie-chart-fill.svg
Normal file
3
frontend/app/svg/icons/pie-chart-fill.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-pie-chart-fill" viewBox="0 0 16 16">
|
||||
<path d="M15.985 8.5H8.207l-5.5 5.5a8 8 0 0 0 13.277-5.5zM2 13.292A8 8 0 0 1 7.5.015v7.778l-5.5 5.5zM8.5.015V7.5h7.485A8.001 8.001 0 0 0 8.5.015z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 290 B |
3
frontend/app/svg/icons/table.svg
Normal file
3
frontend/app/svg/icons/table.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-table" viewBox="0 0 16 16">
|
||||
<path d="M0 2a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2zm15 2h-4v3h4V4zm0 4h-4v3h4V8zm0 4h-4v3h3a1 1 0 0 0 1-1v-2zm-5 3v-3H6v3h4zm-5 0v-3H1v2a1 1 0 0 0 1 1h3zm-4-4h4V8H1v3zm0-4h4V4H1v3zm5-3v3h4V4H6zm4 4H6v3h4V8z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 371 B |
|
|
@ -103,5 +103,11 @@ export default Record({
|
|||
endTimestamp: this.end,
|
||||
};
|
||||
},
|
||||
toTimestampstwo() {
|
||||
return {
|
||||
startTimestamp: this.start / 1000,
|
||||
endTimestamp: this.end / 1000,
|
||||
};
|
||||
},
|
||||
}
|
||||
});
|
||||
|
|
@ -3,6 +3,7 @@ import { List } from 'immutable';
|
|||
import Filter from 'Types/filter';
|
||||
import { validateName } from 'App/validate';
|
||||
import { LAST_7_DAYS } from 'Types/app/period';
|
||||
import { FilterKey } from 'Types/filter/filterType';
|
||||
import { filterMap } from 'Duck/search';
|
||||
|
||||
export const FilterSeries = Record({
|
||||
|
|
@ -27,6 +28,10 @@ export const FilterSeries = Record({
|
|||
export default Record({
|
||||
metricId: undefined,
|
||||
name: 'Series',
|
||||
metricType: 'timeseries',
|
||||
metricOf: 'sessionCount',
|
||||
metricValue: ['sessionCount'],
|
||||
metricFormat: 'sessionCount',
|
||||
viewType: 'lineChart',
|
||||
series: List(),
|
||||
isPublic: true,
|
||||
|
|
@ -43,11 +48,13 @@ export default Record({
|
|||
|
||||
toSaveData() {
|
||||
const js = this.toJS();
|
||||
|
||||
js.metricValue = js.metricValue.map(value => value === 'all' ? '' : value);
|
||||
|
||||
js.series = js.series.map(series => {
|
||||
series.filter.filters = series.filter.filters.map(filterMap);
|
||||
// delete series._key
|
||||
// delete series.key
|
||||
delete series.key
|
||||
return series;
|
||||
});
|
||||
|
||||
|
|
@ -61,8 +68,10 @@ export default Record({
|
|||
return js;
|
||||
},
|
||||
},
|
||||
fromJS: ({ series, ...rest }) => ({
|
||||
fromJS: ({ metricOf, metricValue, series, ...rest }) => ({
|
||||
...rest,
|
||||
series: List(series).map(FilterSeries),
|
||||
metricOf,
|
||||
metricValue: metricOf === FilterKey.ISSUE && metricValue.length === 0 ? ['all'] : metricValue,
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -96,8 +96,14 @@ export default Record({
|
|||
startDate,
|
||||
endDate,
|
||||
events: List(events).map(Event),
|
||||
filters: List(filters).map(i => NewFilter(i).toData()).concat(List(events).map(i => NewFilter(i).toData())),
|
||||
custom: Map(custom),
|
||||
filters: List(filters)
|
||||
.map(i => {
|
||||
const filter = NewFilter(i).toData();
|
||||
if (i.hasOwnProperty('filters')) {
|
||||
filter.filters = i.filters.map(f => NewFilter({...f, subFilter: i.type}).toData());
|
||||
}
|
||||
return filter;
|
||||
}),
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,8 +13,10 @@ export enum FilterType {
|
|||
ISSUE = "ISSUE",
|
||||
BOOLEAN = "BOOLEAN",
|
||||
NUMBER = "NUMBER",
|
||||
NUMBER_MULTIPLE = "NUMBER_MULTIPLE",
|
||||
DURATION = "DURATION",
|
||||
MULTIPLE = "MULTIPLE",
|
||||
SUB_FILTERS = "SUB_FILTERS",
|
||||
COUNTRY = "COUNTRY",
|
||||
DROPDOWN = "DROPDOWN",
|
||||
MULTIPLE_DROPDOWN = "MULTIPLE_DROPDOWN",
|
||||
|
|
@ -61,4 +63,11 @@ export enum FilterKey {
|
|||
AVG_CPU_LOAD = "AVG_CPU_LOAD",
|
||||
AVG_MEMORY_USAGE = "AVG_MEMORY_USAGE",
|
||||
FETCH_FAILED = "FETCH_FAILED",
|
||||
FETCH = "FETCH",
|
||||
FETCH_URL = "FETCH_URL",
|
||||
FETCH_STATUS_CODE = "FETCH_STATUS_CODE",
|
||||
FETCH_METHOD = "FETCH_METHOD",
|
||||
FETCH_DURATION = "FETCH_DURATION",
|
||||
FETCH_REQUEST_BODY = "FETCH_REQUEST_BODY",
|
||||
FETCH_RESPONSE_BODY = "FETCH_RESPONSE_BODY",
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue