Merge remote-tracking branch 'origin/dev' into assist-redis
# Conflicts: # utilities/servers/websocket.js
This commit is contained in:
commit
ae9d53e94c
139 changed files with 6393 additions and 882 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 . .
|
||||
|
|
|
|||
|
|
@ -23,9 +23,26 @@ class JWTAuth(HTTPBearer):
|
|||
or jwt_payload.get("iat") is None or jwt_payload.get("aud") is None \
|
||||
or not users.auth_exists(user_id=jwt_payload["userId"], tenant_id=jwt_payload["tenantId"],
|
||||
jwt_iat=jwt_payload["iat"], jwt_aud=jwt_payload["aud"]):
|
||||
print("JWTAuth: Token issue")
|
||||
if jwt_payload is not None:
|
||||
print(jwt_payload)
|
||||
print(f"JWTAuth: user_id={jwt_payload.get('userId')} tenant_id={jwt_payload.get('tenantId')}")
|
||||
if jwt_payload is None:
|
||||
print("JWTAuth: jwt_payload is None")
|
||||
print(credentials.scheme + " " + credentials.credentials)
|
||||
if jwt_payload is not None and jwt_payload.get("iat") is None:
|
||||
print("JWTAuth: iat is None")
|
||||
if jwt_payload is not None and jwt_payload.get("aud") is None:
|
||||
print("JWTAuth: aud is None")
|
||||
if jwt_payload is not None and \
|
||||
not users.auth_exists(user_id=jwt_payload["userId"], tenant_id=jwt_payload["tenantId"],
|
||||
jwt_iat=jwt_payload["iat"], jwt_aud=jwt_payload["aud"]):
|
||||
print("JWTAuth: not users.auth_exists")
|
||||
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid token or expired token.")
|
||||
user = users.get(user_id=jwt_payload["userId"], tenant_id=jwt_payload["tenantId"])
|
||||
if user is None:
|
||||
print("JWTAuth: User not found.")
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User not found.")
|
||||
jwt_payload["authorizer_identity"] = "jwt"
|
||||
print(jwt_payload)
|
||||
|
|
@ -36,4 +53,5 @@ class JWTAuth(HTTPBearer):
|
|||
return request.state.currentContext
|
||||
|
||||
else:
|
||||
print("JWTAuth: Invalid authorization code.")
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid authorization code.")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -197,8 +199,8 @@ def search2_pg(data: schemas.SessionsSearchPayloadSchema, project_id, user_id, f
|
|||
MIN(full_sessions.start_ts) AS first_session_ts,
|
||||
ROW_NUMBER() OVER (ORDER BY count(full_sessions) DESC) AS rn
|
||||
FROM (SELECT *, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY start_ts DESC) AS rn
|
||||
FROM (SELECT DISTINCT ON(s.session_id) {SESSION_PROJECTION_COLS},
|
||||
{",".join([f'metadata_{m["index"]}' for m in meta_keys])}
|
||||
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])}
|
||||
{query_part}
|
||||
ORDER BY s.session_id desc) AS filtred_sessions
|
||||
ORDER BY favorite DESC, issue_score DESC, {sort} {data.order}) AS full_sessions
|
||||
|
|
@ -209,8 +211,8 @@ def search2_pg(data: schemas.SessionsSearchPayloadSchema, project_id, user_id, f
|
|||
meta_keys = metadata.get(project_id=project_id)
|
||||
main_query = cur.mogrify(f"""SELECT COUNT(full_sessions) AS count, COALESCE(JSONB_AGG(full_sessions) FILTER (WHERE rn <= 200), '[]'::JSONB) AS sessions
|
||||
FROM (SELECT *, ROW_NUMBER() OVER (ORDER BY favorite DESC, issue_score DESC, session_id desc, start_ts desc) AS rn
|
||||
FROM (SELECT DISTINCT ON(s.session_id) {SESSION_PROJECTION_COLS},
|
||||
{",".join([f'metadata_{m["index"]}' for m in meta_keys])}
|
||||
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])}
|
||||
{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 (
|
||||
|
|
|
|||
|
|
@ -36,8 +36,7 @@ def get_session2(projectId: int, sessionId: Union[int, str], context: schemas.Cu
|
|||
include_fav_viewed=True, group_metadata=True)
|
||||
if data is None:
|
||||
return {"errors": ["session not found"]}
|
||||
if not data.get("live"):
|
||||
sessions_favorite_viewed.view_session(project_id=projectId, user_id=context.user_id, session_id=sessionId)
|
||||
sessions_favorite_viewed.view_session(project_id=projectId, user_id=context.user_id, session_id=sessionId)
|
||||
return {
|
||||
'data': data
|
||||
}
|
||||
|
|
@ -102,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,
|
||||
|
|
@ -1089,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"])
|
||||
|
|
@ -1124,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):
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import (
|
|||
)
|
||||
|
||||
func getTimeoutContext() context.Context {
|
||||
ctx, _ := context.WithTimeout(context.Background(), time.Duration(time.Second*10))
|
||||
ctx, _ := context.WithTimeout(context.Background(), time.Duration(time.Second*30))
|
||||
return ctx
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -156,7 +156,7 @@ def update(tenant_id, user_id, changes):
|
|||
(SELECT role_id FROM roles WHERE tenant_id = %(tenant_id)s AND name != 'Owner' LIMIT 1)))""")
|
||||
else:
|
||||
sub_query_users.append(f"{helper.key_to_snake_case(key)} = %({key})s")
|
||||
|
||||
changes["role_id"] = changes.get("roleId", changes.get("role_id"))
|
||||
with pg_client.PostgresClient() as cur:
|
||||
if len(sub_query_users) > 0:
|
||||
cur.execute(
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ $$
|
|||
SELECT 'v1.5.1-ee'
|
||||
$$ LANGUAGE sql IMMUTABLE;
|
||||
|
||||
COMMIT;
|
||||
|
||||
ALTER TYPE country ADD VALUE IF NOT EXISTS 'AC';
|
||||
ALTER TYPE country ADD VALUE IF NOT EXISTS 'AN';
|
||||
ALTER TYPE country ADD VALUE IF NOT EXISTS 'BU';
|
||||
|
|
@ -36,5 +38,3 @@ ALTER TYPE country ADD VALUE IF NOT EXISTS 'WK';
|
|||
ALTER TYPE country ADD VALUE IF NOT EXISTS 'YD';
|
||||
ALTER TYPE country ADD VALUE IF NOT EXISTS 'YU';
|
||||
ALTER TYPE country ADD VALUE IF NOT EXISTS 'ZR';
|
||||
|
||||
COMMIT;
|
||||
8
ee/scripts/helm/db/init_dbs/postgresql/1.5.2/1.5.2.sql
Normal file
8
ee/scripts/helm/db/init_dbs/postgresql/1.5.2/1.5.2.sql
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
BEGIN;
|
||||
CREATE OR REPLACE FUNCTION openreplay_version()
|
||||
RETURNS text AS
|
||||
$$
|
||||
SELECT 'v1.5.2-ee'
|
||||
$$ LANGUAGE sql IMMUTABLE;
|
||||
|
||||
COMMIT;
|
||||
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.1-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;
|
||||
$$
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@ import Header from 'Components/Header/Header';
|
|||
import FunnelDetails from 'Components/Funnels/FunnelDetails';
|
||||
import FunnelIssueDetails from 'Components/Funnels/FunnelIssueDetails';
|
||||
import { fetchList as fetchIntegrationVariables } from 'Duck/customField';
|
||||
import { fetchList as fetchSiteList } from 'Duck/site';
|
||||
import { fetchList as fetchAnnouncements } from 'Duck/announcements';
|
||||
import { fetchList as fetchAlerts } from 'Duck/alerts';
|
||||
import { fetchWatchdogStatus } from 'Duck/watchdogs';
|
||||
|
||||
import APIClient from './api_client';
|
||||
import * as routes from './routes';
|
||||
|
|
@ -80,7 +84,14 @@ const ONBOARDING_REDIRECT_PATH = routes.onboarding(OB_DEFAULT_TAB);
|
|||
onboarding: state.getIn([ 'user', 'onboarding' ])
|
||||
};
|
||||
}, {
|
||||
fetchUserInfo, fetchTenants, setSessionPath, fetchIntegrationVariables
|
||||
fetchUserInfo,
|
||||
fetchTenants,
|
||||
setSessionPath,
|
||||
fetchIntegrationVariables,
|
||||
fetchSiteList,
|
||||
fetchAnnouncements,
|
||||
fetchAlerts,
|
||||
fetchWatchdogStatus,
|
||||
})
|
||||
class Router extends React.Component {
|
||||
state = {
|
||||
|
|
@ -93,6 +104,14 @@ class Router extends React.Component {
|
|||
props.fetchUserInfo().then(() => {
|
||||
props.fetchIntegrationVariables()
|
||||
}),
|
||||
props.fetchSiteList().then(() => {
|
||||
setTimeout(() => {
|
||||
props.fetchAnnouncements();
|
||||
props.fetchAlerts();
|
||||
props.fetchWatchdogStatus();
|
||||
}, 100);
|
||||
}),
|
||||
// props.fetchAnnouncements(),
|
||||
])
|
||||
// .then(() => this.onLoginLogout());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,10 @@ class Notifications extends React.Component {
|
|||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
props.fetchList();
|
||||
// setTimeout(() => {
|
||||
// props.fetchList();
|
||||
// }, 1000);
|
||||
|
||||
setInterval(() => {
|
||||
props.fetchList();
|
||||
}, AUTOREFRESH_INTERVAL);
|
||||
|
|
|
|||
|
|
@ -10,11 +10,7 @@ import { withRouter } from 'react-router-dom';
|
|||
@withToggle('visible', 'toggleVisisble')
|
||||
@withRouter
|
||||
class Announcements extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
props.fetchList();
|
||||
}
|
||||
|
||||
|
||||
navigateToUrl = url => {
|
||||
if (url) {
|
||||
if (url.startsWith(window.ENV.ORIGIN)) {
|
||||
|
|
|
|||
|
|
@ -91,12 +91,6 @@ function AssistActions({ toggleChatWindow, userId, calling, peerConnectionStatus
|
|||
onClick={ requestReleaseRemoteControl }
|
||||
role="button"
|
||||
>
|
||||
{/* <Icon
|
||||
name="remote-control"
|
||||
size="20"
|
||||
color={ remoteControlStatus === RemoteControlStatus.Enabled ? "green" : "gray-darkest"}
|
||||
/>
|
||||
<span className={cn("ml-2", { 'color-green' : remoteControlStatus === RemoteControlStatus.Enabled })}>{ 'Remote Control' }</span> */}
|
||||
<IconButton label={`${remoteActive ? 'Stop ' : ''} Remote Control`} icon="remote-control" primaryText redText={remoteActive} />
|
||||
</div>
|
||||
|
||||
|
|
@ -112,12 +106,6 @@ function AssistActions({ toggleChatWindow, userId, calling, peerConnectionStatus
|
|||
onClick={ onCall ? callObject?.end : confirmCall}
|
||||
role="button"
|
||||
>
|
||||
{/* <Icon
|
||||
name="headset"
|
||||
size="20"
|
||||
color={ onCall ? "red" : "gray-darkest" }
|
||||
/>
|
||||
<span className={cn("ml-2", { 'color-red' : onCall })}>{ onCall ? 'End Call' : 'Call' }</span> */}
|
||||
<IconButton size="small" primary={!onCall} red={onCall} label={onCall ? 'End' : 'Call'} icon="headset" />
|
||||
</div>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { SlideModal, Avatar, Icon } from 'UI';
|
||||
import { SlideModal, Avatar, TextEllipsis, Icon } from 'UI';
|
||||
import SessionList from '../SessionList';
|
||||
import stl from './assistTabs.css'
|
||||
|
||||
|
|
@ -19,7 +19,11 @@ const AssistTabs = (props: Props) => {
|
|||
<div className="flex items-center mr-3">
|
||||
{/* <Icon name="user-alt" color="gray-darkest" /> */}
|
||||
<Avatar iconSize="20" width="30px" height="30px" seed={ props.userNumericHash } />
|
||||
<div className="ml-2 font-medium">{props.userId}'s</div>
|
||||
<div className="ml-2 font-medium">
|
||||
<TextEllipsis maxWidth={120} inverted popupProps={{ inverted: true, size: 'tiny' }}>
|
||||
{props.userId}'s asdasd asdasdasdasd
|
||||
</TextEllipsis>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={stl.btnLink}
|
||||
|
|
|
|||
|
|
@ -4,14 +4,11 @@ import withPageTitle from 'HOCs/withPageTitle';
|
|||
import {
|
||||
fetchFavoriteList as fetchFavoriteSessionList
|
||||
} from 'Duck/sessions';
|
||||
import { countries } from 'App/constants';
|
||||
import { applyFilter, clearEvents, addAttribute } from 'Duck/filters';
|
||||
import { fetchList as fetchFunnelsList } from 'Duck/funnels';
|
||||
import { defaultFilters, preloadedFilters } from 'Types/filter';
|
||||
import { KEYS } from 'Types/filter/customFilter';
|
||||
import SessionList from './SessionList';
|
||||
import stl from './bugFinder.css';
|
||||
import { fetchList as fetchSiteList } from 'Duck/site';
|
||||
import withLocationHandlers from "HOCs/withLocationHandlers";
|
||||
import { fetch as fetchFilterVariables } from 'Duck/sources';
|
||||
import { fetchSources } from 'Duck/customField';
|
||||
|
|
@ -68,7 +65,6 @@ const allowedQueryKeys = [
|
|||
fetchSources,
|
||||
clearEvents,
|
||||
setActiveTab,
|
||||
fetchSiteList,
|
||||
fetchFunnelsList,
|
||||
resetFunnel,
|
||||
resetFunnelFilters,
|
||||
|
|
@ -81,7 +77,6 @@ export default class BugFinder extends React.PureComponent {
|
|||
state = {showRehydratePanel: false}
|
||||
constructor(props) {
|
||||
super(props);
|
||||
// props.fetchFavoriteSessionList();
|
||||
|
||||
// TODO should cache the response
|
||||
// props.fetchSources().then(() => {
|
||||
|
|
@ -115,29 +110,6 @@ export default class BugFinder extends React.PureComponent {
|
|||
this.setState({ showRehydratePanel: !this.state.showRehydratePanel })
|
||||
}
|
||||
|
||||
// fetchPreloadedFilters = () => {
|
||||
// this.props.fetchFilterVariables('filterValues').then(function() {
|
||||
// const { filterValues } = this.props;
|
||||
// const keys = [
|
||||
// {key: KEYS.USER_OS, label: 'OS'},
|
||||
// {key: KEYS.USER_BROWSER, label: 'Browser'},
|
||||
// {key: KEYS.USER_DEVICE, label: 'Device'},
|
||||
// {key: KEYS.REFERRER, label: 'Referrer'},
|
||||
// {key: KEYS.USER_COUNTRY, label: 'Country'},
|
||||
// ]
|
||||
// if (filterValues && filterValues.size != 0) {
|
||||
// keys.forEach(({key, label}) => {
|
||||
// const _keyFilters = filterValues.get(key)
|
||||
// if (key === KEYS.USER_COUNTRY) {
|
||||
// preloadedFilters.push(_keyFilters.map(item => ({label, type: key, key, value: item, actualValue: countries[item], isFilter: true})));
|
||||
// } else {
|
||||
// preloadedFilters.push(_keyFilters.map(item => ({label, type: key, key, value: item, isFilter: true})));
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
// }.bind(this));
|
||||
// }
|
||||
|
||||
setActiveTab = tab => {
|
||||
this.props.setActiveTab(tab);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import DateRangeDropdown from 'Shared/DateRangeDropdown';
|
|||
})
|
||||
export default class DateRange extends React.PureComponent {
|
||||
onDateChange = (e) => {
|
||||
this.props.fetchFunnelsList(e.rangeValue)
|
||||
// this.props.fetchFunnelsList(e.rangeValue)
|
||||
this.props.applyFilter(e)
|
||||
}
|
||||
render() {
|
||||
|
|
|
|||
|
|
@ -22,9 +22,9 @@ function SessionsMenu(props) {
|
|||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchWatchdogStatus()
|
||||
}, [])
|
||||
// useEffect(() => {
|
||||
// fetchWatchdogStatus()
|
||||
// }, [])
|
||||
|
||||
const capturingAll = props.captureRate && props.captureRate.get('captureAll');
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { withRouter } from 'react-router-dom';
|
|||
import { Switch, Route, Redirect } from 'react-router';
|
||||
import { CLIENT_TABS, client as clientRoute } from 'App/routes';
|
||||
import { fetchList as fetchMemberList } from 'Duck/member';
|
||||
import { fetchList as fetchSiteList } from 'Duck/site';
|
||||
|
||||
import ProfileSettings from './ProfileSettings';
|
||||
import Integrations from './Integrations';
|
||||
|
|
@ -21,7 +20,6 @@ import Roles from './Roles';
|
|||
appearance: state.getIn([ 'user', 'account', 'appearance' ]),
|
||||
}), {
|
||||
fetchMemberList,
|
||||
fetchSiteList,
|
||||
})
|
||||
@withRouter
|
||||
export default class Client extends React.PureComponent {
|
||||
|
|
|
|||
|
|
@ -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,10 @@ 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 }
|
||||
|
||||
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 +76,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,7 +112,7 @@ 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} />
|
||||
|
|
@ -97,56 +120,53 @@ function CustomMetricWidget(props: Props) {
|
|||
<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}
|
||||
<>
|
||||
{metric.viewType === 'lineChart' && (
|
||||
<CustomMetriLineChart
|
||||
data={ data }
|
||||
params={ params }
|
||||
seriesMap={ seriesMap }
|
||||
colors={ colors }
|
||||
onClick={ clickHandler }
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
)}
|
||||
|
||||
{metric.viewType === 'pieChart' && (
|
||||
<CustomMetricPieChart
|
||||
metric={metric}
|
||||
data={ data[0] }
|
||||
params={ params }
|
||||
colors={ colors }
|
||||
onClick={ clickHandlerTable }
|
||||
/>
|
||||
)}
|
||||
|
||||
{metric.viewType === 'progress' && (
|
||||
<CustomMetricPercentage
|
||||
data={ data[0] }
|
||||
params={ params }
|
||||
colors={ colors }
|
||||
onClick={ clickHandler }
|
||||
/>
|
||||
)}
|
||||
|
||||
{metric.viewType === 'table' && (
|
||||
<CustomMetricTable
|
||||
metric={ metric }
|
||||
data={ data[0] }
|
||||
// params={ params }
|
||||
// colors={ colors }
|
||||
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 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,10 +66,6 @@ const Header = (props) => {
|
|||
}
|
||||
}, [showTrackingModal])
|
||||
|
||||
useEffect(() => {
|
||||
fetchSiteList()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={ cn(styles.header, showTrackingModal ? styles.placeOnTop : '') }>
|
||||
<NavLink to={ withSiteId(SESSIONS_PATH, siteId) }>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ import cn from 'classnames';
|
|||
import NewSiteForm from '../Client/Sites/NewSiteForm';
|
||||
import { clearSearch } from 'Duck/search';
|
||||
import { fetchList as fetchIntegrationVariables } from 'Duck/customField';
|
||||
import { fetchList as fetchAlerts } from 'Duck/alerts';
|
||||
import { fetchWatchdogStatus } from 'Duck/watchdogs';
|
||||
|
||||
@withRouter
|
||||
@connect(state => ({
|
||||
|
|
@ -23,13 +25,15 @@ import { fetchList as fetchIntegrationVariables } from 'Duck/customField';
|
|||
init,
|
||||
clearSearch,
|
||||
fetchIntegrationVariables,
|
||||
fetchAlerts,
|
||||
fetchWatchdogStatus,
|
||||
})
|
||||
export default class SiteDropdown extends React.PureComponent {
|
||||
state = { showProductModal: false }
|
||||
|
||||
componentDidMount() {
|
||||
this.props.fetchIntegrationVariables();
|
||||
}
|
||||
// componentDidMount() {
|
||||
// this.props.fetchIntegrationVariables();
|
||||
// }
|
||||
|
||||
closeModal = (e, newSite) => {
|
||||
this.setState({ showProductModal: false })
|
||||
|
|
@ -44,6 +48,8 @@ export default class SiteDropdown extends React.PureComponent {
|
|||
this.props.setSiteId(siteId);
|
||||
this.props.clearSearch();
|
||||
this.props.fetchIntegrationVariables();
|
||||
this.props.fetchAlerts();
|
||||
this.props.fetchWatchdogStatus();
|
||||
}
|
||||
|
||||
render() {
|
||||
|
|
|
|||
|
|
@ -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>`;
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -58,16 +58,16 @@ export default withRequest({
|
|||
dataWrapper: data => data,
|
||||
dataName: 'assistCredendials',
|
||||
loadingName: 'loadingCredentials',
|
||||
})(withPermissions(['SESSION_REPLAY', 'ASSIST_LIVE'], '', true)(connect(
|
||||
})(withPermissions(['ASSIST_LIVE'], '', true)(connect(
|
||||
state => {
|
||||
const isAssist = state.getIn(['sessions', 'activeTab']).type === 'live';
|
||||
const hasSessioPath = state.getIn([ 'sessions', 'sessionPath' ]).includes('/sessions');
|
||||
// const isAssist = state.getIn(['sessions', 'activeTab']).type === 'live';
|
||||
// const hasSessioPath = state.getIn([ 'sessions', 'sessionPath' ]).includes('/sessions');
|
||||
return {
|
||||
session: state.getIn([ 'sessions', 'current' ]),
|
||||
showAssist: state.getIn([ 'sessions', 'showChatWindow' ]),
|
||||
jwt: state.get('jwt'),
|
||||
fullscreen: state.getIn([ 'components', 'player', 'fullscreen' ]),
|
||||
hasSessionsPath: hasSessioPath && !isAssist,
|
||||
// hasSessionsPath: hasSessioPath && !isAssist,
|
||||
isEnterprise: state.getIn([ 'user', 'client', 'edition' ]) === 'ee',
|
||||
hasErrors: !!state.getIn([ 'sessions', 'errors' ]),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,11 +16,11 @@ const SESSIONS_ROUTE = sessionsRoute();
|
|||
function Session({
|
||||
sessionId,
|
||||
loading,
|
||||
hasErrors,
|
||||
hasErrors,
|
||||
session,
|
||||
fetchSession,
|
||||
fetchSlackList,
|
||||
hasSessionsPath
|
||||
fetchSlackList,
|
||||
hasSessionsPath
|
||||
}) {
|
||||
usePageTitle("OpenReplay Session Player");
|
||||
useEffect(() => {
|
||||
|
|
@ -51,7 +51,7 @@ function Session({
|
|||
<Loader className="flex-1" loading={ loading || sessionId !== session.sessionId }>
|
||||
{ session.isIOS
|
||||
? <IOSPlayer session={session} />
|
||||
: (session.live && !hasSessionsPath ? <LivePlayer /> : <WebPlayer />)
|
||||
: <WebPlayer />
|
||||
}
|
||||
</Loader>
|
||||
</NoContent>
|
||||
|
|
|
|||
|
|
@ -10,13 +10,15 @@ export default connect(state => ({
|
|||
metadata: state.getIn([ 'sessions', 'current', 'metadata' ]),
|
||||
}))(function Metadata ({ metadata }) {
|
||||
const [ visible, setVisible ] = useState(false);
|
||||
const toggle = useCallback(() => metadata.length > 0 && setVisible(v => !v), []);
|
||||
const metaLenth = Object.keys(metadata).length;
|
||||
const toggle = useCallback(() => metaLenth > 0 && setVisible(v => !v), []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popup
|
||||
trigger={
|
||||
<IconButton
|
||||
className={cn("w-full", { 'opacity-25' : metadata.length === 0 })}
|
||||
className={cn("w-full", { 'opacity-25' : metaLenth === 0 })}
|
||||
onClick={ toggle }
|
||||
icon="id-card"
|
||||
plain
|
||||
|
|
@ -33,17 +35,17 @@ export default connect(state => ({
|
|||
</div>
|
||||
}
|
||||
on="click"
|
||||
disabled={metadata.length > 0}
|
||||
disabled={metaLenth > 0}
|
||||
size="tiny"
|
||||
inverted
|
||||
position="top center"
|
||||
/>
|
||||
{ visible &&
|
||||
<div className={ stl.modal } >
|
||||
<NoContent show={ metadata.size === 0 } size="small">
|
||||
{ metadata.map((i) => {
|
||||
const key = Object.keys(i)[0]
|
||||
const value = i[key]
|
||||
<NoContent show={ metaLenth === 0 } size="small">
|
||||
{ Object.keys(metadata).map((key) => {
|
||||
// const key = Object.keys(i)[0]
|
||||
const value = metadata[key]
|
||||
return <MetadataItem item={ { value, key } } key={ key } />
|
||||
}) }
|
||||
</NoContent>
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ export default class extends React.PureComponent {
|
|||
content={ open && <SessionList similarSessions={ similarSessions } loading={ loading } /> }
|
||||
onClose={ open ? this.switchOpen : () => null }
|
||||
/>
|
||||
<div className={ cn("flex justify-between items-center p-3", stl.field) } >
|
||||
<div className={ cn("flex justify-between items-center p-3 capitalize", stl.field) } >
|
||||
<div>
|
||||
<div className={ stl.key }>{ item.key }</div>
|
||||
<TextEllipsis
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ const HeapTooltip = ({ active, payload}) => {
|
|||
}
|
||||
|
||||
const NodesCountTooltip = ({ active, payload} ) => {
|
||||
if (!active || payload.length === 0) return null;
|
||||
if (!active || !payload || payload.length === 0) return null;
|
||||
return (
|
||||
<div className={ stl.tooltipWrapper } >
|
||||
<p>
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ function getStorageName(type) {
|
|||
bottomBlock: state.getIn([ 'components', 'player', 'bottomBlock' ]),
|
||||
showStorage: props.showStorage || !state.getIn(['components', 'player', 'hiddenHints', 'storage']),
|
||||
showStack: props.showStack || !state.getIn(['components', 'player', 'hiddenHints', 'stack']),
|
||||
closedLive: !!state.getIn([ 'sessions', 'errors' ]),
|
||||
closedLive: !!state.getIn([ 'sessions', 'errors' ]) || !state.getIn([ 'sessions', 'current', 'live' ]),
|
||||
}
|
||||
}, {
|
||||
fullscreenOn,
|
||||
|
|
|
|||
|
|
@ -13,11 +13,14 @@ import EventsToggleButton from '../../Session/EventsToggleButton';
|
|||
@connectPlayer(state => ({
|
||||
live: state.live,
|
||||
}))
|
||||
@connect(state => ({
|
||||
fullscreen: state.getIn([ 'components', 'player', 'fullscreen' ]),
|
||||
nextId: state.getIn([ 'sessions', 'nextId' ]),
|
||||
closedLive: !!state.getIn([ 'sessions', 'errors' ]),
|
||||
}), {
|
||||
@connect(state => {
|
||||
const isAssist = window.location.pathname.includes('/assist/');
|
||||
return {
|
||||
fullscreen: state.getIn([ 'components', 'player', 'fullscreen' ]),
|
||||
nextId: state.getIn([ 'sessions', 'nextId' ]),
|
||||
closedLive: !!state.getIn([ 'sessions', 'errors' ]) || (isAssist && !state.getIn([ 'sessions', 'current', 'live' ])),
|
||||
}
|
||||
}, {
|
||||
hideTargetDefiner,
|
||||
fullscreenOff,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -33,7 +33,6 @@ import styles from './playerBlock.css';
|
|||
@connect(state => ({
|
||||
fullscreen: state.getIn([ 'components', 'player', 'fullscreen' ]),
|
||||
bottomBlock: state.getIn([ 'components', 'player', 'bottomBlock' ]),
|
||||
closedLive: !!state.getIn([ 'sessions', 'errors' ]),
|
||||
}))
|
||||
export default class PlayerBlock extends React.PureComponent {
|
||||
componentDidUpdate(prevProps) {
|
||||
|
|
@ -44,14 +43,13 @@ export default class PlayerBlock extends React.PureComponent {
|
|||
}
|
||||
|
||||
render() {
|
||||
const { fullscreen, bottomBlock, closedLive } = this.props;
|
||||
const { fullscreen, bottomBlock } = this.props;
|
||||
|
||||
return (
|
||||
<div className={ cn(styles.playerBlock, "flex flex-col") }>
|
||||
<Player
|
||||
className="flex-1"
|
||||
bottomBlockIsActive={ !fullscreen && bottomBlock !== NONE }
|
||||
closedLive={closedLive}
|
||||
/>
|
||||
{ !fullscreen && !!bottomBlock &&
|
||||
<div className="">
|
||||
|
|
|
|||
|
|
@ -29,10 +29,11 @@ const ASSIST_ROUTE = assistRoute();
|
|||
loading: state.cssLoading || state.messagesLoading,
|
||||
}))
|
||||
@connect((state, props) => {
|
||||
const isAssist = state.getIn(['sessions', 'activeTab']).type === 'live';
|
||||
const isAssist = window.location.pathname.includes('/assist/');
|
||||
const hasSessioPath = state.getIn([ 'sessions', 'sessionPath' ]).includes('/sessions');
|
||||
const session = state.getIn([ 'sessions', 'current' ]);
|
||||
return {
|
||||
session: state.getIn([ 'sessions', 'current' ]),
|
||||
session,
|
||||
sessionPath: state.getIn([ 'sessions', 'sessionPath' ]),
|
||||
loading: state.getIn([ 'sessions', 'toggleFavoriteRequest', 'loading' ]),
|
||||
disabled: state.getIn([ 'components', 'targetDefiner', 'inspectorMode' ]) || props.loading,
|
||||
|
|
@ -43,7 +44,7 @@ const ASSIST_ROUTE = assistRoute();
|
|||
siteId: state.getIn([ 'user', 'siteId' ]),
|
||||
hasSessionsPath: hasSessioPath && !isAssist,
|
||||
metaList: state.getIn(['customFields', 'list']).map(i => i.key),
|
||||
closedLive: !!state.getIn([ 'sessions', 'errors' ]),
|
||||
closedLive: !!state.getIn([ 'sessions', 'errors' ]) || (isAssist && !session.live),
|
||||
}
|
||||
}, {
|
||||
toggleFavorite, fetchListIntegration, setSessionPath
|
||||
|
|
@ -138,7 +139,7 @@ export default class PlayerBlockHeader extends React.PureComponent {
|
|||
|
||||
{ _live && (
|
||||
<>
|
||||
<SessionMetaList className="" metaList={_metaList} />
|
||||
<SessionMetaList className="" metaList={_metaList} maxLength={3} />
|
||||
<div className={ stl.divider } />
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React from 'react';
|
||||
import { Form, SegmentSelection, Button, IconButton } from 'UI';
|
||||
import { Form, Button, IconButton } 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,91 @@ 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">
|
||||
{`${isTable ? 'Filter by' : 'Chart Series'}`}
|
||||
</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,10 +25,12 @@ 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;
|
||||
|
||||
|
|
@ -51,7 +53,7 @@ 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 }) } />
|
||||
</div>
|
||||
|
|
@ -78,10 +80,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}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,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 +44,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}</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 }
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -9,16 +9,19 @@ import {
|
|||
|
||||
const AUTOREFRESH_INTERVAL = 5 * 60 * 1000;
|
||||
const weekRange = getDateRangeFromValue(DATE_RANGE_VALUES.LAST_7_DAYS);
|
||||
let intervalId = null
|
||||
|
||||
function ErrorsBadge({ errorsStats = {}, fetchNewErrorsCount }) {
|
||||
function ErrorsBadge({ errorsStats = {}, fetchNewErrorsCount, projects }) {
|
||||
useEffect(() => {
|
||||
if (projects.size === 0 || !!intervalId) return;
|
||||
|
||||
const params = { startTimestamp: weekRange.start.unix() * 1000, endTimestamp: weekRange.end.unix() * 1000 };
|
||||
fetchNewErrorsCount(params)
|
||||
|
||||
setInterval(() => {
|
||||
intervalId = setInterval(() => {
|
||||
fetchNewErrorsCount(params);
|
||||
}, AUTOREFRESH_INTERVAL);
|
||||
}, [])
|
||||
}, [projects])
|
||||
|
||||
return errorsStats.unresolvedAndUnviewed > 0 ? (
|
||||
<div>{<div className={stl.badge} /> }</div>
|
||||
|
|
@ -27,4 +30,5 @@ function ErrorsBadge({ errorsStats = {}, fetchNewErrorsCount }) {
|
|||
|
||||
export default connect(state => ({
|
||||
errorsStats: state.getIn([ 'errors', 'stats' ]),
|
||||
projects: state.getIn([ 'site', 'list' ]),
|
||||
}), { fetchNewErrorsCount })(ErrorsBadge)
|
||||
|
|
|
|||
|
|
@ -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: [""],
|
||||
subFilters: filter.subFilters ? filter.subFilters.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,
|
||||
subFilters: filter.subFilters.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} />) }
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* SubFilters */}
|
||||
{isSubFilter && (
|
||||
<div className="grid grid-col ml-3 w-full">
|
||||
{filter.subFilters.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) => {
|
||||
|
|
|
|||
|
|
@ -8,11 +8,12 @@ import withPermissions from 'HOCs/withPermissions'
|
|||
import { KEYS } from 'Types/filter/customFilter';
|
||||
import { applyFilter, addAttribute } from 'Duck/filters';
|
||||
import { FilterCategory, FilterKey } from 'App/types/filter/filterType';
|
||||
import { addFilterByKeyAndValue, updateCurrentPage, toggleSortOrder } from 'Duck/liveSearch';
|
||||
import { addFilterByKeyAndValue, updateCurrentPage, updateSort } from 'Duck/liveSearch';
|
||||
import DropdownPlain from 'Shared/DropdownPlain';
|
||||
import SortOrderButton from 'Shared/SortOrderButton';
|
||||
import { TimezoneDropdown } from 'UI';
|
||||
import { capitalize } from 'App/utils';
|
||||
import LiveSessionReloadButton from 'Shared/LiveSessionReloadButton';
|
||||
|
||||
const AUTOREFRESH_INTERVAL = .5 * 60 * 1000
|
||||
const PER_PAGE = 20;
|
||||
|
|
@ -28,12 +29,12 @@ interface Props {
|
|||
updateCurrentPage: (page: number) => void,
|
||||
currentPage: number,
|
||||
metaList: any,
|
||||
sortOrder: string,
|
||||
toggleSortOrder: (sortOrder: string) => void,
|
||||
updateSort: (sort: any) => void,
|
||||
sort: any,
|
||||
}
|
||||
|
||||
function LiveSessionList(props: Props) {
|
||||
const { loading, filters, list, currentPage, metaList = [], sortOrder } = props;
|
||||
const { loading, filters, list, currentPage, metaList = [], sort } = props;
|
||||
var timeoutId;
|
||||
const hasUserFilter = filters.map(i => i.key).includes(KEYS.USERID);
|
||||
const [sessions, setSessions] = React.useState(list);
|
||||
|
|
@ -41,7 +42,6 @@ function LiveSessionList(props: Props) {
|
|||
text: capitalize(i), value: i
|
||||
})).toJS();
|
||||
|
||||
const [sortBy, setSortBy] = React.useState('');
|
||||
const displayedCount = Math.min(currentPage * PER_PAGE, sessions.size);
|
||||
|
||||
const addPage = () => props.updateCurrentPage(props.currentPage + 1)
|
||||
|
|
@ -53,9 +53,11 @@ function LiveSessionList(props: Props) {
|
|||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (metaList.size === 0 || !!sortBy) return;
|
||||
if (metaList.size === 0 || !!sort.field) return;
|
||||
|
||||
setSortBy(sortOptions[0] && sortOptions[0].value)
|
||||
if ( sortOptions[0]) {
|
||||
props.updateSort({ field: sortOptions[0].value });
|
||||
}
|
||||
}, [metaList]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -96,7 +98,7 @@ function LiveSessionList(props: Props) {
|
|||
}
|
||||
|
||||
const onSortChange = (e, { value }) => {
|
||||
setSortBy(value);
|
||||
props.updateSort({ field: value });
|
||||
}
|
||||
|
||||
const timeout = () => {
|
||||
|
|
@ -114,6 +116,8 @@ function LiveSessionList(props: Props) {
|
|||
<span>Live Sessions</span>
|
||||
<span className="ml-2 font-normal color-gray-medium">{sessions.size}</span>
|
||||
</h3>
|
||||
|
||||
<LiveSessionReloadButton />
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center">
|
||||
|
|
@ -125,10 +129,10 @@ function LiveSessionList(props: Props) {
|
|||
<DropdownPlain
|
||||
options={sortOptions}
|
||||
onChange={onSortChange}
|
||||
value={sortBy}
|
||||
value={sort.field}
|
||||
/>
|
||||
</div>
|
||||
<SortOrderButton onChange={props.toggleSortOrder} sortOrder={sortOrder} />
|
||||
<SortOrderButton onChange={(state) => props.updateSort({ order: state })} sortOrder={sort.order} />
|
||||
</div>
|
||||
</div>
|
||||
<NoContent
|
||||
|
|
@ -143,8 +147,8 @@ function LiveSessionList(props: Props) {
|
|||
show={ !loading && sessions && sessions.size === 0}
|
||||
>
|
||||
<Loader loading={ loading }>
|
||||
{sessions && sessions.sortBy(i => i.metadata[sortBy]).update(list => {
|
||||
return sortOrder === 'desc' ? list.reverse() : list;
|
||||
{sessions && sessions.sortBy(i => i.metadata[sort.field]).update(list => {
|
||||
return sort.order === 'desc' ? list.reverse() : list;
|
||||
}).take(displayedCount).map(session => (
|
||||
<SessionItem
|
||||
key={ session.sessionId }
|
||||
|
|
@ -157,7 +161,7 @@ function LiveSessionList(props: Props) {
|
|||
))}
|
||||
|
||||
<LoadMoreButton
|
||||
className="mt-3"
|
||||
className="my-6"
|
||||
displayedCount={displayedCount}
|
||||
totalCount={sessions.size}
|
||||
onClick={addPage}
|
||||
|
|
@ -168,14 +172,14 @@ function LiveSessionList(props: Props) {
|
|||
)
|
||||
}
|
||||
|
||||
export default withPermissions(['ASSIST_LIVE', 'SESSION_REPLAY'])(connect(
|
||||
export default withPermissions(['ASSIST_LIVE'])(connect(
|
||||
(state) => ({
|
||||
list: state.getIn(['sessions', 'liveSessions']),
|
||||
loading: state.getIn([ 'sessions', 'loading' ]),
|
||||
filters: state.getIn([ 'liveSearch', 'instance', 'filters' ]),
|
||||
currentPage: state.getIn(["liveSearch", "currentPage"]),
|
||||
metaList: state.getIn(['customFields', 'list']).map(i => i.key),
|
||||
sortOrder: state.getIn(['liveSearch', 'sortOrder']),
|
||||
sort: state.getIn(['liveSearch', 'sort']),
|
||||
}),
|
||||
{
|
||||
fetchLiveList,
|
||||
|
|
@ -183,6 +187,6 @@ export default withPermissions(['ASSIST_LIVE', 'SESSION_REPLAY'])(connect(
|
|||
addAttribute,
|
||||
addFilterByKeyAndValue,
|
||||
updateCurrentPage,
|
||||
toggleSortOrder,
|
||||
updateSort,
|
||||
}
|
||||
)(LiveSessionList));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
import React from 'react'
|
||||
import ReloadButton from '../ReloadButton'
|
||||
import { connect } from 'react-redux'
|
||||
import { fetchLiveList } from 'Duck/sessions'
|
||||
|
||||
interface Props {
|
||||
loading: boolean
|
||||
fetchLiveList: typeof fetchLiveList
|
||||
}
|
||||
function LiveSessionReloadButton(props: Props) {
|
||||
const { loading } = props
|
||||
return (
|
||||
<ReloadButton loading={loading} onClick={props.fetchLiveList} className="cursor-pointer" />
|
||||
)
|
||||
}
|
||||
|
||||
export default connect(state => ({
|
||||
loading: state.getIn([ 'sessions', 'fetchLiveListRequest', 'loading' ]),
|
||||
}), { fetchLiveList })(LiveSessionReloadButton)
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './LiveSessionReloadButton';
|
||||
22
frontend/app/components/shared/ReloadButton/ReloadButton.tsx
Normal file
22
frontend/app/components/shared/ReloadButton/ReloadButton.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import React from 'react'
|
||||
import { CircularLoader, Icon } from 'UI'
|
||||
import cn from 'classnames'
|
||||
|
||||
interface Props {
|
||||
loading?: boolean
|
||||
onClick: () => void
|
||||
iconSize?: number
|
||||
iconName?: string
|
||||
className?: string
|
||||
}
|
||||
export default function ReloadButton(props: Props) {
|
||||
const { loading, onClick, iconSize = "14", iconName = "sync-alt", className = '' } = props
|
||||
return (
|
||||
<div
|
||||
className={cn("ml-4 h-5 w-6 flex items-center justify-center", className)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{ loading ? <CircularLoader className="ml-1" /> : <Icon name={iconName} size={iconSize} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1
frontend/app/components/shared/ReloadButton/index.ts
Normal file
1
frontend/app/components/shared/ReloadButton/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from './ReloadButton';
|
||||
|
|
@ -17,16 +17,16 @@ export default function MetaMoreButton(props: Props) {
|
|||
</span>
|
||||
</div>
|
||||
) }
|
||||
className="p-0"
|
||||
content={
|
||||
<div className="flex flex-col">
|
||||
<div className="text-sm grid grid-col p-4 gap-3" style={{ maxHeight: '200px', overflowY: 'auto'}}>
|
||||
{list.slice(maxLength).map(({ label, value }, index) => (
|
||||
<MetaItem key={index} label={label} value={value} className="mb-3" />
|
||||
<MetaItem key={index} label={label} value={value} />
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
on="click"
|
||||
position="center center"
|
||||
hideOnScroll
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,19 +6,20 @@ import MetaMoreButton from '../MetaMoreButton';
|
|||
|
||||
interface Props {
|
||||
className?: string,
|
||||
metaList: []
|
||||
metaList: [],
|
||||
maxLength?: number,
|
||||
}
|
||||
const MAX_LENGTH = 3;
|
||||
|
||||
export default function SessionMetaList(props: Props) {
|
||||
const { className = '', metaList } = props
|
||||
const { className = '', metaList, maxLength = 4 } = props
|
||||
return (
|
||||
<div className={cn("text-sm flex items-start", className)}>
|
||||
{metaList.slice(0, MAX_LENGTH).map(({ label, value }, index) => (
|
||||
{metaList.slice(0, maxLength).map(({ label, value }, index) => (
|
||||
<MetaItem key={index} label={label} value={''+value} className="mr-3" />
|
||||
))}
|
||||
|
||||
{metaList.length > MAX_LENGTH && (
|
||||
<MetaMoreButton list={metaList} maxLength={MAX_LENGTH} />
|
||||
{metaList.length > maxLength && (
|
||||
<MetaMoreButton list={metaList} maxLength={maxLength} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,18 +1,25 @@
|
|||
import React from 'react'
|
||||
import React, { useEffect } from 'react'
|
||||
import { Icon } from 'UI'
|
||||
import { connect } from 'react-redux'
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { onboarding as onboardingRoute } from 'App/routes'
|
||||
import { withSiteId } from 'App/routes';
|
||||
import { isGreaterOrEqualVersion } from 'App/utils'
|
||||
|
||||
const TrackerUpdateMessage= (props) => {
|
||||
// const { site } = props;
|
||||
const { site, sites, match: { params: { siteId } } } = props;
|
||||
const [needUpdate, setNeedUpdate] = React.useState(false)
|
||||
const { sites, match: { params: { siteId } } } = props;
|
||||
const activeSite = sites.find(s => s.id == siteId);
|
||||
const hasSessions = !!activeSite && !activeSite.recorded;
|
||||
const appVersionInt = parseInt(window.ENV.TRACKER_VERSION.split(".").join(""))
|
||||
const trackerVersionInt = site.trackerVersion ? parseInt(site.trackerVersion.split(".").join("")) : 0
|
||||
const needUpdate = !hasSessions && appVersionInt > trackerVersionInt;
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSite || !activeSite.trackerVersion) return;
|
||||
|
||||
const isLatest = isGreaterOrEqualVersion(activeSite.trackerVersion, window.ENV.TRACKER_VERSION);
|
||||
if (!isLatest && activeSite.recorded) {
|
||||
setNeedUpdate(true)
|
||||
}
|
||||
}, [activeSite])
|
||||
|
||||
return needUpdate ? (
|
||||
<>
|
||||
{(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -15,14 +15,17 @@ const EDIT = editType(name);
|
|||
const CLEAR_SEARCH = `${name}/CLEAR_SEARCH`;
|
||||
const APPLY = `${name}/APPLY`;
|
||||
const UPDATE_CURRENT_PAGE = `${name}/UPDATE_CURRENT_PAGE`;
|
||||
const TOGGLE_SORT_ORDER = `${name}/TOGGLE_SORT_ORDER`;
|
||||
const UPDATE_SORT = `${name}/UPDATE_SORT`;
|
||||
|
||||
const initialState = Map({
|
||||
list: List(),
|
||||
instance: new Filter({ filters: [] }),
|
||||
filterSearchList: {},
|
||||
currentPage: 1,
|
||||
sortOrder: 'asc',
|
||||
sort: {
|
||||
order: 'asc',
|
||||
field: ''
|
||||
}
|
||||
});
|
||||
|
||||
function reducer(state = initialState, action = {}) {
|
||||
|
|
@ -31,8 +34,8 @@ function reducer(state = initialState, action = {}) {
|
|||
return state.mergeIn(['instance'], action.instance);
|
||||
case UPDATE_CURRENT_PAGE:
|
||||
return state.set('currentPage', action.page);
|
||||
case TOGGLE_SORT_ORDER:
|
||||
return state.set('sortOrder', action.order);
|
||||
case UPDATE_SORT:
|
||||
return state.mergeIn(['sort'], action.sort);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
|
@ -103,9 +106,9 @@ export function updateCurrentPage(page) {
|
|||
};
|
||||
}
|
||||
|
||||
export function toggleSortOrder (order) {
|
||||
export function updateSort(sort) {
|
||||
return {
|
||||
type: TOGGLE_SORT_ORDER,
|
||||
order,
|
||||
type: UPDATE_SORT,
|
||||
sort,
|
||||
};
|
||||
}
|
||||
|
|
@ -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, subFilters }) => ({
|
||||
value: checkValues(key, value),
|
||||
custom,
|
||||
type: category === FilterCategory.METADATA ? FilterKey.METADATA : key,
|
||||
operator,
|
||||
source: category === FilterCategory.METADATA ? key : source,
|
||||
sourceOperator,
|
||||
isEvent
|
||||
isEvent,
|
||||
filters: subFilters ? subFilters.map(filterMap) : [],
|
||||
});
|
||||
|
||||
const reduceThenFetchResource = actionCreator => (...args) => (dispatch, getState) => {
|
||||
|
|
@ -161,7 +162,7 @@ export const applySavedSearch = (filter) => (dispatch, getState) => {
|
|||
|
||||
export const fetchSessions = (filter) => (dispatch, getState) => {
|
||||
const _filter = filter ? filter : getState().getIn([ 'search', 'instance']);
|
||||
return dispatch(applyFilter(_filter));
|
||||
// return dispatch(applyFilter(_filter)); // TODO uncomment this line
|
||||
};
|
||||
|
||||
export const updateSeries = (index, series) => ({
|
||||
|
|
@ -233,6 +234,10 @@ export const hasFilterApplied = (filters, filter) => {
|
|||
|
||||
export const addFilter = (filter) => (dispatch, getState) => {
|
||||
filter.value = checkFilterValue(filter.value);
|
||||
filter.subFilters = filter.subFilters ? filter.subFilters.map(subFilter => ({
|
||||
...subFilter,
|
||||
value: checkFilterValue(subFilter.value),
|
||||
})) : null;
|
||||
const instance = getState().getIn([ 'search', 'instance']);
|
||||
|
||||
if (hasFilterApplied(instance.filters, filter)) {
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ export default class AssistManager {
|
|||
if (document.hidden && getState().calling === CallingState.NoCall) {
|
||||
this.socket?.close()
|
||||
}
|
||||
}, 15000)
|
||||
}, 30000)
|
||||
} else {
|
||||
inactiveTimeout && clearTimeout(inactiveTimeout)
|
||||
this.socket?.open()
|
||||
|
|
@ -171,7 +171,8 @@ export default class AssistManager {
|
|||
this.setStatus(ConnectionStatus.Disconnected)
|
||||
}, 12000)
|
||||
|
||||
if (getState().remoteControl === RemoteControlStatus.Requesting) {
|
||||
if (getState().remoteControl === RemoteControlStatus.Requesting ||
|
||||
getState().remoteControl === RemoteControlStatus.Enabled) {
|
||||
this.toggleRemoteControl(false)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -141,4 +141,10 @@
|
|||
|
||||
margin: 25px 0;
|
||||
background-color: $gray-light;
|
||||
}
|
||||
|
||||
.no-scroll {
|
||||
height: 100vh;
|
||||
overflow-y: hidden;
|
||||
padding-right: 15px;
|
||||
}
|
||||
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 |
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue