commit
91b4abbaa5
119 changed files with 34849 additions and 861 deletions
|
|
@ -52,6 +52,8 @@
|
||||||
"S3_HOST": "",
|
"S3_HOST": "",
|
||||||
"S3_KEY": "",
|
"S3_KEY": "",
|
||||||
"S3_SECRET": "",
|
"S3_SECRET": "",
|
||||||
|
"invitation_link": "/api/users/invitation?token=%s",
|
||||||
|
"change_password_link": "/reset-password?invitation=%s&&pass=%s",
|
||||||
"version_number": "1.2.0"
|
"version_number": "1.2.0"
|
||||||
},
|
},
|
||||||
"lambda_timeout": 150,
|
"lambda_timeout": 150,
|
||||||
|
|
|
||||||
10
api/app.py
10
api/app.py
|
|
@ -60,7 +60,7 @@ _overrides.chalice_app(app)
|
||||||
def or_middleware(event, get_response):
|
def or_middleware(event, get_response):
|
||||||
global OR_SESSION_TOKEN
|
global OR_SESSION_TOKEN
|
||||||
OR_SESSION_TOKEN = app.current_request.headers.get('vnd.openreplay.com.sid',
|
OR_SESSION_TOKEN = app.current_request.headers.get('vnd.openreplay.com.sid',
|
||||||
app.current_request.headers.get('vnd.asayer.io.sid'))
|
app.current_request.headers.get('vnd.asayer.io.sid'))
|
||||||
if "authorizer" in event.context and event.context["authorizer"] is None:
|
if "authorizer" in event.context and event.context["authorizer"] is None:
|
||||||
print("Deleted user!!")
|
print("Deleted user!!")
|
||||||
pg_client.close()
|
pg_client.close()
|
||||||
|
|
@ -71,7 +71,13 @@ def or_middleware(event, get_response):
|
||||||
import time
|
import time
|
||||||
now = int(time.time() * 1000)
|
now = int(time.time() * 1000)
|
||||||
response = get_response(event)
|
response = get_response(event)
|
||||||
if response.status_code == 500 and helper.allow_sentry() and OR_SESSION_TOKEN is not None and not helper.is_local():
|
|
||||||
|
if response.status_code == 200 and response.body is not None and response.body.get("errors") is not None:
|
||||||
|
if "not found" in response.body["errors"][0]:
|
||||||
|
response = Response(status_code=404, body=response.body)
|
||||||
|
else:
|
||||||
|
response = Response(status_code=400, body=response.body)
|
||||||
|
if response.status_code // 100 == 5 and helper.allow_sentry() and OR_SESSION_TOKEN is not None and not helper.is_local():
|
||||||
with configure_scope() as scope:
|
with configure_scope() as scope:
|
||||||
scope.set_tag('stage', environ["stage"])
|
scope.set_tag('stage', environ["stage"])
|
||||||
scope.set_tag('openReplaySessionToken', OR_SESSION_TOKEN)
|
scope.set_tag('openReplaySessionToken', OR_SESSION_TOKEN)
|
||||||
|
|
|
||||||
|
|
@ -18,17 +18,19 @@ check_prereq() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function build_api(){
|
function build_api(){
|
||||||
|
tag=""
|
||||||
# Copy enterprise code
|
# Copy enterprise code
|
||||||
[[ $1 == "ee" ]] && {
|
[[ $1 == "ee" ]] && {
|
||||||
cp -rf ../ee/api/* ./
|
cp -rf ../ee/api/* ./
|
||||||
cp -rf ../ee/api/.chalice/* ./.chalice/
|
cp -rf ../ee/api/.chalice/* ./.chalice/
|
||||||
envarg="default-ee"
|
envarg="default-ee"
|
||||||
|
tag="ee-"
|
||||||
}
|
}
|
||||||
docker build -f ./Dockerfile --build-arg envarg=$envarg -t ${DOCKER_REPO:-'local'}/chalice:${git_sha1} .
|
docker build -f ./Dockerfile --build-arg envarg=$envarg -t ${DOCKER_REPO:-'local'}/chalice:${git_sha1} .
|
||||||
[[ $PUSH_IMAGE -eq 1 ]] && {
|
[[ $PUSH_IMAGE -eq 1 ]] && {
|
||||||
docker push ${DOCKER_REPO:-'local'}/chalice:${git_sha1}
|
docker push ${DOCKER_REPO:-'local'}/chalice:${git_sha1}
|
||||||
docker tag ${DOCKER_REPO:-'local'}/chalice:${git_sha1} ${DOCKER_REPO:-'local'}/chalice:latest
|
docker tag ${DOCKER_REPO:-'local'}/chalice:${git_sha1} ${DOCKER_REPO:-'local'}/chalice:${tag}latest
|
||||||
docker push ${DOCKER_REPO:-'local'}/chalice:latest
|
docker push ${DOCKER_REPO:-'local'}/chalice:${tag}latest
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
0
api/chalicelib/blueprints/app/__init__.py
Normal file
0
api/chalicelib/blueprints/app/__init__.py
Normal file
|
|
@ -11,7 +11,7 @@ from chalicelib.core import log_tool_rollbar, sourcemaps, events, sessions_assig
|
||||||
log_tool_stackdriver, reset_password, sessions_favorite_viewed, \
|
log_tool_stackdriver, reset_password, sessions_favorite_viewed, \
|
||||||
log_tool_cloudwatch, log_tool_sentry, log_tool_sumologic, log_tools, errors, sessions, \
|
log_tool_cloudwatch, log_tool_sentry, log_tool_sumologic, log_tools, errors, sessions, \
|
||||||
log_tool_newrelic, announcements, log_tool_bugsnag, weekly_report, integration_jira_cloud, integration_github, \
|
log_tool_newrelic, announcements, log_tool_bugsnag, weekly_report, integration_jira_cloud, integration_github, \
|
||||||
assist
|
assist, heatmaps
|
||||||
from chalicelib.core.collaboration_slack import Slack
|
from chalicelib.core.collaboration_slack import Slack
|
||||||
from chalicelib.utils import email_helper
|
from chalicelib.utils import email_helper
|
||||||
|
|
||||||
|
|
@ -32,8 +32,10 @@ def get_favorite_sessions2(projectId, context):
|
||||||
def get_session2(projectId, sessionId, context):
|
def get_session2(projectId, sessionId, context):
|
||||||
data = sessions.get_by_id2_pg(project_id=projectId, session_id=sessionId, full_data=True, user_id=context["userId"],
|
data = sessions.get_by_id2_pg(project_id=projectId, session_id=sessionId, full_data=True, user_id=context["userId"],
|
||||||
include_fav_viewed=True, group_metadata=True)
|
include_fav_viewed=True, group_metadata=True)
|
||||||
if data is not None:
|
if data is None:
|
||||||
sessions_favorite_viewed.view_session(project_id=projectId, user_id=context['userId'], session_id=sessionId)
|
return {"errors": ["session not found"]}
|
||||||
|
|
||||||
|
sessions_favorite_viewed.view_session(project_id=projectId, user_id=context['userId'], session_id=sessionId)
|
||||||
return {
|
return {
|
||||||
'data': data
|
'data': data
|
||||||
}
|
}
|
||||||
|
|
@ -507,8 +509,8 @@ def reset_password_handler(step):
|
||||||
if "email" not in data or len(data["email"]) < 5:
|
if "email" not in data or len(data["email"]) < 5:
|
||||||
return {"errors": ["please provide a valid email address"]}
|
return {"errors": ["please provide a valid email address"]}
|
||||||
return reset_password.step1(data)
|
return reset_password.step1(data)
|
||||||
elif step == "2":
|
# elif step == "2":
|
||||||
return reset_password.step2(data)
|
# return reset_password.step2(data)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/{projectId}/metadata', methods=['GET'])
|
@app.route('/{projectId}/metadata', methods=['GET'])
|
||||||
|
|
@ -585,9 +587,8 @@ def async_basic_emails(step):
|
||||||
if data.pop("auth") != environ["async_Token"]:
|
if data.pop("auth") != environ["async_Token"]:
|
||||||
return {}
|
return {}
|
||||||
if step.lower() == "member_invitation":
|
if step.lower() == "member_invitation":
|
||||||
email_helper.send_team_invitation(recipient=data["email"], user_name=data["userName"],
|
email_helper.send_team_invitation(recipient=data["email"], invitation_link=data["invitationLink"],
|
||||||
temp_password=data["tempPassword"], client_id=data["clientId"],
|
client_id=data["clientId"], sender_name=data["senderName"])
|
||||||
sender_name=data["senderName"])
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/{projectId}/sample_rate', methods=['GET'])
|
@app.route('/{projectId}/sample_rate', methods=['GET'])
|
||||||
|
|
@ -724,10 +725,10 @@ def get_funnel_insights(projectId, funnelId, context):
|
||||||
if params is None:
|
if params is None:
|
||||||
params = {}
|
params = {}
|
||||||
|
|
||||||
return {"data": funnels.get_top_insights(funnel_id=funnelId, project_id=projectId,
|
return funnels.get_top_insights(funnel_id=funnelId, project_id=projectId,
|
||||||
range_value=params.get("range_value", None),
|
range_value=params.get("range_value", None),
|
||||||
start_date=params.get('startDate', None),
|
start_date=params.get('startDate', None),
|
||||||
end_date=params.get('endDate', None))}
|
end_date=params.get('endDate', None))
|
||||||
|
|
||||||
|
|
||||||
@app.route('/{projectId}/funnels/{funnelId}/insights', methods=['POST', 'PUT'])
|
@app.route('/{projectId}/funnels/{funnelId}/insights', methods=['POST', 'PUT'])
|
||||||
|
|
@ -739,8 +740,7 @@ def get_funnel_insights_on_the_fly(projectId, funnelId, context):
|
||||||
if data is None:
|
if data is None:
|
||||||
data = {}
|
data = {}
|
||||||
|
|
||||||
return {
|
return funnels.get_top_insights_on_the_fly(funnel_id=funnelId, project_id=projectId, data={**params, **data})
|
||||||
"data": funnels.get_top_insights_on_the_fly(funnel_id=funnelId, project_id=projectId, data={**params, **data})}
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/{projectId}/funnels/{funnelId}/issues', methods=['GET'])
|
@app.route('/{projectId}/funnels/{funnelId}/issues', methods=['GET'])
|
||||||
|
|
@ -821,8 +821,11 @@ def get_funnel_issue_sessions(projectId, funnelId, issueId, context):
|
||||||
|
|
||||||
@app.route('/{projectId}/funnels/{funnelId}', methods=['GET'])
|
@app.route('/{projectId}/funnels/{funnelId}', methods=['GET'])
|
||||||
def get_funnel(projectId, funnelId, context):
|
def get_funnel(projectId, funnelId, context):
|
||||||
return {"data": funnels.get(funnel_id=funnelId,
|
data = funnels.get(funnel_id=funnelId,
|
||||||
project_id=projectId)}
|
project_id=projectId)
|
||||||
|
if data is None:
|
||||||
|
return {"errors": ["funnel not found"]}
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
@app.route('/{projectId}/funnels/{funnelId}', methods=['POST', 'PUT'])
|
@app.route('/{projectId}/funnels/{funnelId}', methods=['POST', 'PUT'])
|
||||||
|
|
@ -882,3 +885,9 @@ def removed_endpoints(projectId=None, context=None):
|
||||||
def sessions_live(projectId, context):
|
def sessions_live(projectId, context):
|
||||||
data = assist.get_live_sessions(projectId)
|
data = assist.get_live_sessions(projectId)
|
||||||
return {'data': data}
|
return {'data': data}
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/{projectId}/heatmaps/url', methods=['POST'])
|
||||||
|
def get_heatmaps_by_url(projectId, context):
|
||||||
|
data = app.current_request.json_body
|
||||||
|
return {"data": heatmaps.get_by_url(project_id=projectId, data=data)}
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,6 @@ def run_scheduled_jobs(event):
|
||||||
jobs.execute_jobs()
|
jobs.execute_jobs()
|
||||||
|
|
||||||
|
|
||||||
@app.schedule(Cron('0/60', '*', '*', '*', '?', '*'))
|
|
||||||
def clear_password_reset(event):
|
|
||||||
reset_password.cron()
|
|
||||||
|
|
||||||
|
|
||||||
# Run every monday.
|
# Run every monday.
|
||||||
@app.schedule(Cron('5', '0', '?', '*', 'MON', '*'))
|
@app.schedule(Cron('5', '0', '?', '*', 'MON', '*'))
|
||||||
def weekly_report2(event):
|
def weekly_report2(event):
|
||||||
|
|
|
||||||
|
|
@ -38,9 +38,9 @@ def login():
|
||||||
for_plugin=False
|
for_plugin=False
|
||||||
)
|
)
|
||||||
if r is None:
|
if r is None:
|
||||||
return {
|
return Response(status_code=401, body={
|
||||||
'errors': ['You’ve entered invalid Email or Password.']
|
'errors': ['You’ve entered invalid Email or Password.']
|
||||||
}
|
})
|
||||||
|
|
||||||
tenant_id = r.pop("tenantId")
|
tenant_id = r.pop("tenantId")
|
||||||
|
|
||||||
|
|
@ -53,11 +53,12 @@ def login():
|
||||||
c.pop("createdAt")
|
c.pop("createdAt")
|
||||||
c["projects"] = projects.get_projects(tenant_id=tenant_id, recording_state=True, recorded=True,
|
c["projects"] = projects.get_projects(tenant_id=tenant_id, recording_state=True, recorded=True,
|
||||||
stack_integrations=True)
|
stack_integrations=True)
|
||||||
|
c["smtp"] = helper.has_smtp()
|
||||||
return {
|
return {
|
||||||
'jwt': r.pop('jwt'),
|
'jwt': r.pop('jwt'),
|
||||||
'data': {
|
'data': {
|
||||||
"user": r,
|
"user": r,
|
||||||
"client": c,
|
"client": c
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,7 +75,7 @@ def get_account(context):
|
||||||
"metadata": metadata.get_remaining_metadata_with_count(context['tenantId'])
|
"metadata": metadata.get_remaining_metadata_with_count(context['tenantId'])
|
||||||
},
|
},
|
||||||
**license.get_status(context["tenantId"]),
|
**license.get_status(context["tenantId"]),
|
||||||
"smtp": environ["EMAIL_HOST"] is not None and len(environ["EMAIL_HOST"]) > 0
|
"smtp": helper.has_smtp()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -100,8 +101,11 @@ def create_edit_project(projectId, context):
|
||||||
|
|
||||||
@app.route('/projects/{projectId}', methods=['GET'])
|
@app.route('/projects/{projectId}', methods=['GET'])
|
||||||
def get_project(projectId, context):
|
def get_project(projectId, context):
|
||||||
return {"data": projects.get_project(tenant_id=context["tenantId"], project_id=projectId, include_last_session=True,
|
data = projects.get_project(tenant_id=context["tenantId"], project_id=projectId, include_last_session=True,
|
||||||
include_gdpr=True)}
|
include_gdpr=True)
|
||||||
|
if data is None:
|
||||||
|
return {"errors": ["project not found"]}
|
||||||
|
return {"data": data}
|
||||||
|
|
||||||
|
|
||||||
@app.route('/projects/{projectId}', methods=['DELETE'])
|
@app.route('/projects/{projectId}', methods=['DELETE'])
|
||||||
|
|
@ -353,6 +357,38 @@ def add_member(context):
|
||||||
return users.create_member(tenant_id=context['tenantId'], user_id=context['userId'], data=data)
|
return users.create_member(tenant_id=context['tenantId'], user_id=context['userId'], data=data)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/users/invitation', methods=['GET'], authorizer=None)
|
||||||
|
def process_invitation_link():
|
||||||
|
params = app.current_request.query_params
|
||||||
|
if params is None or len(params.get("token", "")) < 64:
|
||||||
|
return {"errors": ["please provide a valid invitation"]}
|
||||||
|
user = users.get_by_invitation_token(params["token"])
|
||||||
|
if user is None:
|
||||||
|
return {"errors": ["invitation not found"]}
|
||||||
|
if user["expiredInvitation"]:
|
||||||
|
return {"errors": ["expired invitation, please ask your admin to send a new one"]}
|
||||||
|
pass_token = users.allow_password_change(user_id=user["userId"])
|
||||||
|
return Response(
|
||||||
|
status_code=307,
|
||||||
|
body='',
|
||||||
|
headers={'Location': environ["SITE_URL"] + environ["change_password_link"] % (params["token"], pass_token),
|
||||||
|
'Content-Type': 'text/plain'})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/users/invitation/password', methods=['POST', 'PUT'], authorizer=None)
|
||||||
|
def change_password_by_invitation():
|
||||||
|
data = app.current_request.json_body
|
||||||
|
if data is None or len(data.get("invitation", "")) < 64 or len(data.get("pass", "")) < 8:
|
||||||
|
return {"errors": ["please provide a valid invitation & pass"]}
|
||||||
|
user = users.get_by_invitation_token(token=data["token"], pass_token=data["pass"])
|
||||||
|
if user is None:
|
||||||
|
return {"errors": ["invitation not found"]}
|
||||||
|
if user["expiredChange"]:
|
||||||
|
return {"errors": ["expired change, please re-use the invitation link"]}
|
||||||
|
|
||||||
|
return users.set_password_invitation(new_password=data["password"], user_id=user["userId"])
|
||||||
|
|
||||||
|
|
||||||
@app.route('/client/members/{memberId}', methods=['PUT', 'POST'])
|
@app.route('/client/members/{memberId}', methods=['PUT', 'POST'])
|
||||||
def edit_member(memberId, context):
|
def edit_member(memberId, context):
|
||||||
data = app.current_request.json_body
|
data = app.current_request.json_body
|
||||||
|
|
@ -360,6 +396,11 @@ def edit_member(memberId, context):
|
||||||
user_id_to_update=memberId)
|
user_id_to_update=memberId)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/client/members/{memberId}/reset', methods=['GET'])
|
||||||
|
def reset_reinvite_member(memberId, context):
|
||||||
|
return users.reset_member(tenant_id=context['tenantId'], editor_id=context['userId'], user_id_to_update=memberId)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/client/members/{memberId}', methods=['DELETE'])
|
@app.route('/client/members/{memberId}', methods=['DELETE'])
|
||||||
def delete_member(memberId, context):
|
def delete_member(memberId, context):
|
||||||
return users.delete_member(tenant_id=context["tenantId"], user_id=context['userId'], id_to_delete=memberId)
|
return users.delete_member(tenant_id=context["tenantId"], user_id=context['userId'], id_to_delete=memberId)
|
||||||
|
|
|
||||||
|
|
@ -239,7 +239,7 @@ def get_details(project_id, error_id, user_id, **data):
|
||||||
cur.execute(cur.mogrify(main_pg_query, params))
|
cur.execute(cur.mogrify(main_pg_query, params))
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
return {"errors": ["error doesn't exist"]}
|
return {"errors": ["error not found"]}
|
||||||
row["tags"] = __process_tags(row)
|
row["tags"] = __process_tags(row)
|
||||||
|
|
||||||
query = cur.mogrify(
|
query = cur.mogrify(
|
||||||
|
|
@ -387,7 +387,7 @@ def get_details_chart(project_id, error_id, user_id, **data):
|
||||||
cur.execute(cur.mogrify(main_pg_query, params))
|
cur.execute(cur.mogrify(main_pg_query, params))
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
return {"errors": ["error doesn't exist"]}
|
return {"errors": ["error not found"]}
|
||||||
row["tags"] = __process_tags(row)
|
row["tags"] = __process_tags(row)
|
||||||
return {"data": helper.dict_to_camel_case(row)}
|
return {"data": helper.dict_to_camel_case(row)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -145,7 +145,7 @@ def delete(project_id, funnel_id, user_id):
|
||||||
def get_sessions(project_id, funnel_id, user_id, range_value=None, start_date=None, end_date=None):
|
def get_sessions(project_id, funnel_id, user_id, range_value=None, start_date=None, end_date=None):
|
||||||
f = get(funnel_id=funnel_id, project_id=project_id)
|
f = get(funnel_id=funnel_id, project_id=project_id)
|
||||||
if f is None:
|
if f is None:
|
||||||
return {"errors": ["filter not found"]}
|
return {"errors": ["funnel not found"]}
|
||||||
get_start_end_time(filter_d=f["filter"], range_value=range_value, start_date=start_date, end_date=end_date)
|
get_start_end_time(filter_d=f["filter"], range_value=range_value, start_date=start_date, end_date=end_date)
|
||||||
return sessions.search2_pg(data=f["filter"], project_id=project_id, user_id=user_id)
|
return sessions.search2_pg(data=f["filter"], project_id=project_id, user_id=user_id)
|
||||||
|
|
||||||
|
|
@ -166,12 +166,12 @@ def get_sessions_on_the_fly(funnel_id, project_id, user_id, data):
|
||||||
def get_top_insights(project_id, funnel_id, range_value=None, start_date=None, end_date=None):
|
def get_top_insights(project_id, funnel_id, range_value=None, start_date=None, end_date=None):
|
||||||
f = get(funnel_id=funnel_id, project_id=project_id)
|
f = get(funnel_id=funnel_id, project_id=project_id)
|
||||||
if f is None:
|
if f is None:
|
||||||
return {"errors": ["filter not found"]}
|
return {"errors": ["funnel not found"]}
|
||||||
get_start_end_time(filter_d=f["filter"], range_value=range_value, start_date=start_date, end_date=end_date)
|
get_start_end_time(filter_d=f["filter"], range_value=range_value, start_date=start_date, end_date=end_date)
|
||||||
insights, total_drop_due_to_issues = significance.get_top_insights(filter_d=f["filter"], project_id=project_id)
|
insights, total_drop_due_to_issues = significance.get_top_insights(filter_d=f["filter"], project_id=project_id)
|
||||||
insights[-1]["dropDueToIssues"] = total_drop_due_to_issues
|
insights[-1]["dropDueToIssues"] = total_drop_due_to_issues
|
||||||
return {"stages": helper.list_to_camel_case(insights),
|
return {"data": {"stages": helper.list_to_camel_case(insights),
|
||||||
"totalDropDueToIssues": total_drop_due_to_issues}
|
"totalDropDueToIssues": total_drop_due_to_issues}}
|
||||||
|
|
||||||
|
|
||||||
def get_top_insights_on_the_fly(funnel_id, project_id, data):
|
def get_top_insights_on_the_fly(funnel_id, project_id, data):
|
||||||
|
|
@ -187,8 +187,8 @@ def get_top_insights_on_the_fly(funnel_id, project_id, data):
|
||||||
insights, total_drop_due_to_issues = significance.get_top_insights(filter_d=data, project_id=project_id)
|
insights, total_drop_due_to_issues = significance.get_top_insights(filter_d=data, project_id=project_id)
|
||||||
if len(insights) > 0:
|
if len(insights) > 0:
|
||||||
insights[-1]["dropDueToIssues"] = total_drop_due_to_issues
|
insights[-1]["dropDueToIssues"] = total_drop_due_to_issues
|
||||||
return {"stages": helper.list_to_camel_case(insights),
|
return {"data": {"stages": helper.list_to_camel_case(insights),
|
||||||
"totalDropDueToIssues": total_drop_due_to_issues}
|
"totalDropDueToIssues": total_drop_due_to_issues}}
|
||||||
|
|
||||||
|
|
||||||
def get_issues(project_id, funnel_id, range_value=None, start_date=None, end_date=None):
|
def get_issues(project_id, funnel_id, range_value=None, start_date=None, end_date=None):
|
||||||
|
|
|
||||||
30
api/chalicelib/core/heatmaps.py
Normal file
30
api/chalicelib/core/heatmaps.py
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
from chalicelib.utils.TimeUTC import TimeUTC
|
||||||
|
from chalicelib.utils import helper, pg_client
|
||||||
|
from chalicelib.utils import dev
|
||||||
|
|
||||||
|
|
||||||
|
@dev.timed
|
||||||
|
def get_by_url(project_id, data):
|
||||||
|
args = {"startDate": data.get('startDate', TimeUTC.now(delta_days=-30)),
|
||||||
|
"endDate": data.get('endDate', TimeUTC.now()),
|
||||||
|
"project_id": project_id, "url": data["url"]}
|
||||||
|
|
||||||
|
with pg_client.PostgresClient() as cur:
|
||||||
|
query = cur.mogrify("""SELECT selector, count(1) AS count
|
||||||
|
FROM events.clicks
|
||||||
|
INNER JOIN sessions USING (session_id)
|
||||||
|
WHERE project_id = %(project_id)s
|
||||||
|
AND url = %(url)s
|
||||||
|
AND timestamp >= %(startDate)s
|
||||||
|
AND timestamp <= %(endDate)s
|
||||||
|
AND start_ts >= %(startDate)s
|
||||||
|
AND start_ts <= %(endDate)s
|
||||||
|
AND duration IS NOT NULL
|
||||||
|
GROUP BY selector;""",
|
||||||
|
args)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
query
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
return helper.dict_to_camel_case(rows)
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
from chalicelib.utils import pg_client, helper, dev
|
from chalicelib.utils import pg_client, helper, dev
|
||||||
|
|
||||||
|
|
||||||
from chalicelib.core import projects
|
from chalicelib.core import projects
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
|
@ -24,9 +23,10 @@ def get(project_id):
|
||||||
)
|
)
|
||||||
metas = cur.fetchone()
|
metas = cur.fetchone()
|
||||||
results = []
|
results = []
|
||||||
for i, k in enumerate(metas.keys()):
|
if metas is not None:
|
||||||
if metas[k] is not None:
|
for i, k in enumerate(metas.keys()):
|
||||||
results.append({"key": metas[k], "index": i + 1})
|
if metas[k] is not None:
|
||||||
|
results.append({"key": metas[k], "index": i + 1})
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -56,7 +56,7 @@ def __edit(project_id, col_index, colname, new_name):
|
||||||
old_metas = get(project_id)
|
old_metas = get(project_id)
|
||||||
old_metas = {k["index"]: k for k in old_metas}
|
old_metas = {k["index"]: k for k in old_metas}
|
||||||
if col_index not in list(old_metas.keys()):
|
if col_index not in list(old_metas.keys()):
|
||||||
return {"errors": ["custom field doesn't exist"]}
|
return {"errors": ["custom field not found"]}
|
||||||
|
|
||||||
with pg_client.PostgresClient() as cur:
|
with pg_client.PostgresClient() as cur:
|
||||||
if old_metas[col_index]["key"].lower() != new_name:
|
if old_metas[col_index]["key"].lower() != new_name:
|
||||||
|
|
@ -79,7 +79,7 @@ def delete(tenant_id, project_id, index: int):
|
||||||
old_segments = get(project_id)
|
old_segments = get(project_id)
|
||||||
old_segments = [k["index"] for k in old_segments]
|
old_segments = [k["index"] for k in old_segments]
|
||||||
if index not in old_segments:
|
if index not in old_segments:
|
||||||
return {"errors": ["custom field doesn't exist"]}
|
return {"errors": ["custom field not found"]}
|
||||||
|
|
||||||
with pg_client.PostgresClient() as cur:
|
with pg_client.PostgresClient() as cur:
|
||||||
colname = index_to_colname(index)
|
colname = index_to_colname(index)
|
||||||
|
|
|
||||||
|
|
@ -18,48 +18,23 @@ def step1(data):
|
||||||
a_users = users.get_by_email_only(data["email"])
|
a_users = users.get_by_email_only(data["email"])
|
||||||
if len(a_users) > 1:
|
if len(a_users) > 1:
|
||||||
print(f"multiple users found for [{data['email']}] please contact our support")
|
print(f"multiple users found for [{data['email']}] please contact our support")
|
||||||
return {"errors": ["please contact our support"]}
|
return {"errors": ["multiple users, please contact our support"]}
|
||||||
elif len(a_users) == 1:
|
elif len(a_users) == 1:
|
||||||
a_users = a_users[0]
|
a_users = a_users[0]
|
||||||
reset_token = secrets.token_urlsafe(6)
|
invitation_link=users.generate_new_invitation(user_id=a_users["id"])
|
||||||
users.update(tenant_id=a_users["tenantId"], user_id=a_users["id"],
|
email_helper.send_forgot_password(recipient=data["email"], invitation_link=invitation_link)
|
||||||
changes={"token": reset_token})
|
|
||||||
email_helper.send_reset_code(recipient=data["email"], reset_code=reset_token)
|
|
||||||
else:
|
else:
|
||||||
print(f"invalid email address [{data['email']}]")
|
print(f"invalid email address [{data['email']}]")
|
||||||
return {"errors": ["invalid email address"]}
|
return {"errors": ["invalid email address"]}
|
||||||
return {"data": {"state": "success"}}
|
return {"data": {"state": "success"}}
|
||||||
|
|
||||||
|
|
||||||
def step2(data):
|
# def step2(data):
|
||||||
print("====================== change password 2 ===============")
|
# print("====================== change password 2 ===============")
|
||||||
user = users.get_by_email_reset(data["email"], data["code"])
|
# user = users.get_by_email_reset(data["email"], data["code"])
|
||||||
if not user:
|
# if not user:
|
||||||
print("error: wrong email or reset code")
|
# print("error: wrong email or reset code")
|
||||||
return {"errors": ["wrong email or reset code"]}
|
# return {"errors": ["wrong email or reset code"]}
|
||||||
users.update(tenant_id=user["tenantId"], user_id=user["id"],
|
# users.update(tenant_id=user["tenantId"], user_id=user["id"],
|
||||||
changes={"token": None, "password": data["password"], "generatedPassword": False})
|
# changes={"token": None, "password": data["password"], "generatedPassword": False})
|
||||||
return {"data": {"state": "success"}}
|
# return {"data": {"state": "success"}}
|
||||||
|
|
||||||
|
|
||||||
def cron():
|
|
||||||
with pg_client.PostgresClient() as cur:
|
|
||||||
cur.execute(
|
|
||||||
cur.mogrify("""\
|
|
||||||
SELECT user_id
|
|
||||||
FROM public.basic_authentication
|
|
||||||
WHERE token notnull
|
|
||||||
AND (token_requested_at isnull or (EXTRACT(EPOCH FROM token_requested_at)*1000)::BIGINT < %(time)s);""",
|
|
||||||
{"time": chalicelib.utils.TimeUTC.TimeUTC.now(delta_days=-1)})
|
|
||||||
)
|
|
||||||
results = cur.fetchall()
|
|
||||||
if len(results) == 0:
|
|
||||||
return
|
|
||||||
results = tuple([r["user_id"] for r in results])
|
|
||||||
cur.execute(
|
|
||||||
cur.mogrify("""\
|
|
||||||
UPDATE public.basic_authentication
|
|
||||||
SET token = NULL, token_requested_at = NULL
|
|
||||||
WHERE user_id in %(ids)s;""",
|
|
||||||
{"ids": results})
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -3,30 +3,29 @@ from chalicelib.core import events, sessions_metas, socket_ios, metadata, events
|
||||||
sessions_mobs, issues, projects, errors, resources, assist
|
sessions_mobs, issues, projects, errors, resources, assist
|
||||||
|
|
||||||
SESSION_PROJECTION_COLS = """s.project_id,
|
SESSION_PROJECTION_COLS = """s.project_id,
|
||||||
s.session_id::text AS session_id,
|
s.session_id::text AS session_id,
|
||||||
s.user_uuid,
|
s.user_uuid,
|
||||||
s.user_id,
|
s.user_id,
|
||||||
s.user_agent,
|
s.user_agent,
|
||||||
s.user_os,
|
s.user_os,
|
||||||
s.user_browser,
|
s.user_browser,
|
||||||
s.user_device,
|
s.user_device,
|
||||||
s.user_device_type,
|
s.user_device_type,
|
||||||
s.user_country,
|
s.user_country,
|
||||||
s.start_ts,
|
s.start_ts,
|
||||||
s.duration,
|
s.duration,
|
||||||
s.events_count,
|
s.events_count,
|
||||||
s.pages_count,
|
s.pages_count,
|
||||||
s.errors_count,
|
s.errors_count,
|
||||||
s.user_anonymous_id,
|
s.user_anonymous_id,
|
||||||
s.platform,
|
s.platform,
|
||||||
s.issue_score,
|
s.issue_score,
|
||||||
to_jsonb(s.issue_types) AS issue_types,
|
to_jsonb(s.issue_types) AS issue_types,
|
||||||
favorite_sessions.session_id NOTNULL AS favorite,
|
favorite_sessions.session_id NOTNULL AS favorite,
|
||||||
COALESCE((SELECT TRUE
|
COALESCE((SELECT TRUE
|
||||||
FROM public.user_viewed_sessions AS fs
|
FROM public.user_viewed_sessions AS fs
|
||||||
WHERE s.session_id = fs.session_id
|
WHERE s.session_id = fs.session_id
|
||||||
AND fs.user_id = %(userId)s LIMIT 1), FALSE) AS viewed
|
AND fs.user_id = %(userId)s LIMIT 1), FALSE) AS viewed """
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def __group_metadata(session, project_metadata):
|
def __group_metadata(session, project_metadata):
|
||||||
|
|
@ -120,7 +119,14 @@ new_line = "\n"
|
||||||
|
|
||||||
def __get_sql_operator(op):
|
def __get_sql_operator(op):
|
||||||
op = op.lower()
|
op = op.lower()
|
||||||
return "=" if op == "is" or op == "on" else "!=" if op == "isnot" else "ILIKE" if op == "contains" else "NOT ILIKE" if op == "notcontains" else "="
|
return {
|
||||||
|
"is": "=",
|
||||||
|
"on": "=",
|
||||||
|
"isnot": "!=",
|
||||||
|
"noton": "!=",
|
||||||
|
"contains": "ILIKE",
|
||||||
|
"notcontains": "NOT ILIKE",
|
||||||
|
}.get(op, "=")
|
||||||
|
|
||||||
|
|
||||||
def __is_negation_operator(op):
|
def __is_negation_operator(op):
|
||||||
|
|
@ -165,27 +171,30 @@ def search2_pg(data, project_id, user_id, favorite_only=False, errors_only=False
|
||||||
fav_only_join = "LEFT JOIN public.user_favorite_sessions AS fs ON fs.session_id = s.session_id"
|
fav_only_join = "LEFT JOIN public.user_favorite_sessions AS fs ON fs.session_id = s.session_id"
|
||||||
extra_constraints.append(cur.mogrify("fs.user_id = %(userId)s", {"userId": user_id}))
|
extra_constraints.append(cur.mogrify("fs.user_id = %(userId)s", {"userId": user_id}))
|
||||||
events_query_part = ""
|
events_query_part = ""
|
||||||
strict = True
|
|
||||||
|
|
||||||
if len(data.get("events", [])) > 0:
|
if len(data.get("events", [])) > 0:
|
||||||
events_query_from = []
|
events_query_from = []
|
||||||
event_index = 0
|
event_index = 0
|
||||||
|
|
||||||
for event in data["events"]:
|
for event in data["events"]:
|
||||||
# TODO: remove this when message_id is removed
|
|
||||||
seq_id = False
|
|
||||||
event_type = event["type"].upper()
|
event_type = event["type"].upper()
|
||||||
if event.get("operator") is None:
|
if event.get("operator") is None:
|
||||||
event["operator"] = "is"
|
event["operator"] = "is"
|
||||||
op = __get_sql_operator(event["operator"])
|
op = __get_sql_operator(event["operator"])
|
||||||
is_not = False
|
is_not = False
|
||||||
if __is_negation_operator(op) and event_index > 0:
|
if __is_negation_operator(op):
|
||||||
is_not = True
|
is_not = True
|
||||||
op = __reverse_sql_operator(op)
|
op = __reverse_sql_operator(op)
|
||||||
event_from = "%s INNER JOIN public.sessions AS ms USING (session_id)"
|
if event_index == 0:
|
||||||
event_where = ["ms.project_id = %(projectId)s", "main.timestamp >= %(startDate)s",
|
event_from = "%s INNER JOIN public.sessions AS ms USING (session_id)"
|
||||||
"main.timestamp <= %(endDate)s", "ms.start_ts >= %(startDate)s",
|
event_where = ["ms.project_id = %(projectId)s", "main.timestamp >= %(startDate)s",
|
||||||
"ms.start_ts <= %(endDate)s"]
|
"main.timestamp <= %(endDate)s", "ms.start_ts >= %(startDate)s",
|
||||||
|
"ms.start_ts <= %(endDate)s", "ms.duration IS NOT NULL"]
|
||||||
|
else:
|
||||||
|
event_from = "%s"
|
||||||
|
event_where = ["main.timestamp >= %(startDate)s", "main.timestamp <= %(endDate)s",
|
||||||
|
f"event_{event_index - 1}.timestamp <= main.timestamp",
|
||||||
|
"main.session_id=event_0.session_id"]
|
||||||
event_args = {"value": helper.string_to_sql_like_with_op(event['value'], op)}
|
event_args = {"value": helper.string_to_sql_like_with_op(event['value'], op)}
|
||||||
if event_type not in list(events.SUPPORTED_TYPES.keys()) \
|
if event_type not in list(events.SUPPORTED_TYPES.keys()) \
|
||||||
or event.get("value") in [None, "", "*"] \
|
or event.get("value") in [None, "", "*"] \
|
||||||
|
|
@ -206,11 +215,9 @@ def search2_pg(data, project_id, user_id, favorite_only=False, errors_only=False
|
||||||
event_from = event_from % f"{events.event_type.LOCATION.table} AS main "
|
event_from = event_from % f"{events.event_type.LOCATION.table} AS main "
|
||||||
event_where.append(f"main.{events.event_type.LOCATION.column} {op} %(value)s")
|
event_where.append(f"main.{events.event_type.LOCATION.column} {op} %(value)s")
|
||||||
elif event_type == events.event_type.CUSTOM.ui_type:
|
elif event_type == events.event_type.CUSTOM.ui_type:
|
||||||
seq_id = True
|
|
||||||
event_from = event_from % f"{events.event_type.CUSTOM.table} AS main "
|
event_from = event_from % f"{events.event_type.CUSTOM.table} AS main "
|
||||||
event_where.append(f"main.{events.event_type.CUSTOM.column} {op} %(value)s")
|
event_where.append(f"main.{events.event_type.CUSTOM.column} {op} %(value)s")
|
||||||
elif event_type == events.event_type.REQUEST.ui_type:
|
elif event_type == events.event_type.REQUEST.ui_type:
|
||||||
seq_id = True
|
|
||||||
event_from = event_from % f"{events.event_type.REQUEST.table} AS main "
|
event_from = event_from % f"{events.event_type.REQUEST.table} AS main "
|
||||||
event_where.append(f"main.{events.event_type.REQUEST.column} {op} %(value)s")
|
event_where.append(f"main.{events.event_type.REQUEST.column} {op} %(value)s")
|
||||||
elif event_type == events.event_type.GRAPHQL.ui_type:
|
elif event_type == events.event_type.GRAPHQL.ui_type:
|
||||||
|
|
@ -234,12 +241,10 @@ def search2_pg(data, project_id, user_id, favorite_only=False, errors_only=False
|
||||||
|
|
||||||
# ----- IOS
|
# ----- IOS
|
||||||
elif event_type == events.event_type.CLICK_IOS.ui_type:
|
elif event_type == events.event_type.CLICK_IOS.ui_type:
|
||||||
seq_id = True
|
|
||||||
event_from = event_from % f"{events.event_type.CLICK_IOS.table} AS main "
|
event_from = event_from % f"{events.event_type.CLICK_IOS.table} AS main "
|
||||||
event_where.append(f"main.{events.event_type.CLICK_IOS.column} {op} %(value)s")
|
event_where.append(f"main.{events.event_type.CLICK_IOS.column} {op} %(value)s")
|
||||||
|
|
||||||
elif event_type == events.event_type.INPUT_IOS.ui_type:
|
elif event_type == events.event_type.INPUT_IOS.ui_type:
|
||||||
seq_id = True
|
|
||||||
event_from = event_from % f"{events.event_type.INPUT_IOS.table} AS main "
|
event_from = event_from % f"{events.event_type.INPUT_IOS.table} AS main "
|
||||||
event_where.append(f"main.{events.event_type.INPUT_IOS.column} {op} %(value)s")
|
event_where.append(f"main.{events.event_type.INPUT_IOS.column} {op} %(value)s")
|
||||||
|
|
||||||
|
|
@ -247,19 +252,15 @@ def search2_pg(data, project_id, user_id, favorite_only=False, errors_only=False
|
||||||
event_where.append("main.value ILIKE %(custom)s")
|
event_where.append("main.value ILIKE %(custom)s")
|
||||||
event_args["custom"] = helper.string_to_sql_like_with_op(event['custom'], "ILIKE")
|
event_args["custom"] = helper.string_to_sql_like_with_op(event['custom'], "ILIKE")
|
||||||
elif event_type == events.event_type.VIEW_IOS.ui_type:
|
elif event_type == events.event_type.VIEW_IOS.ui_type:
|
||||||
seq_id = True
|
|
||||||
event_from = event_from % f"{events.event_type.VIEW_IOS.table} AS main "
|
event_from = event_from % f"{events.event_type.VIEW_IOS.table} AS main "
|
||||||
event_where.append(f"main.{events.event_type.VIEW_IOS.column} {op} %(value)s")
|
event_where.append(f"main.{events.event_type.VIEW_IOS.column} {op} %(value)s")
|
||||||
elif event_type == events.event_type.CUSTOM_IOS.ui_type:
|
elif event_type == events.event_type.CUSTOM_IOS.ui_type:
|
||||||
seq_id = True
|
|
||||||
event_from = event_from % f"{events.event_type.CUSTOM_IOS.table} AS main "
|
event_from = event_from % f"{events.event_type.CUSTOM_IOS.table} AS main "
|
||||||
event_where.append(f"main.{events.event_type.CUSTOM_IOS.column} {op} %(value)s")
|
event_where.append(f"main.{events.event_type.CUSTOM_IOS.column} {op} %(value)s")
|
||||||
elif event_type == events.event_type.REQUEST_IOS.ui_type:
|
elif event_type == events.event_type.REQUEST_IOS.ui_type:
|
||||||
seq_id = True
|
|
||||||
event_from = event_from % f"{events.event_type.REQUEST_IOS.table} AS main "
|
event_from = event_from % f"{events.event_type.REQUEST_IOS.table} AS main "
|
||||||
event_where.append(f"main.{events.event_type.REQUEST_IOS.column} {op} %(value)s")
|
event_where.append(f"main.{events.event_type.REQUEST_IOS.column} {op} %(value)s")
|
||||||
elif event_type == events.event_type.ERROR_IOS.ui_type:
|
elif event_type == events.event_type.ERROR_IOS.ui_type:
|
||||||
seq_id = True
|
|
||||||
event_from = event_from % f"{events.event_type.ERROR_IOS.table} AS main INNER JOIN public.crashes_ios AS main1 USING(crash_id)"
|
event_from = event_from % f"{events.event_type.ERROR_IOS.table} AS main INNER JOIN public.crashes_ios AS main1 USING(crash_id)"
|
||||||
if event.get("value") not in [None, "*", ""]:
|
if event.get("value") not in [None, "*", ""]:
|
||||||
event_where.append(f"(main1.reason {op} %(value)s OR main1.name {op} %(value)s)")
|
event_where.append(f"(main1.reason {op} %(value)s OR main1.name {op} %(value)s)")
|
||||||
|
|
@ -267,29 +268,50 @@ def search2_pg(data, project_id, user_id, favorite_only=False, errors_only=False
|
||||||
else:
|
else:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
event_index += 1
|
|
||||||
if is_not:
|
if is_not:
|
||||||
event_from += f""" LEFT JOIN (SELECT session_id FROM {event_from} WHERE {" AND ".join(event_where)}) AS left_not USING (session_id)"""
|
if event_index == 0:
|
||||||
event_where[-1] = "left_not.session_id ISNULL"
|
events_query_from.append(cur.mogrify(f"""\
|
||||||
events_query_from.append(cur.mogrify(f"""\
|
(SELECT
|
||||||
|
session_id,
|
||||||
|
0 AS timestamp,
|
||||||
|
{event_index} AS funnel_step
|
||||||
|
FROM sessions
|
||||||
|
WHERE EXISTS(SELECT session_id
|
||||||
|
FROM {event_from}
|
||||||
|
WHERE {" AND ".join(event_where)}
|
||||||
|
AND sessions.session_id=ms.session_id) IS FALSE
|
||||||
|
AND project_id = %(projectId)s
|
||||||
|
AND start_ts >= %(startDate)s
|
||||||
|
AND start_ts <= %(endDate)s
|
||||||
|
AND duration IS NOT NULL
|
||||||
|
) AS event_{event_index} {"ON(TRUE)" if event_index > 0 else ""}\
|
||||||
|
""", {**generic_args, **event_args}).decode('UTF-8'))
|
||||||
|
else:
|
||||||
|
events_query_from.append(cur.mogrify(f"""\
|
||||||
(SELECT
|
(SELECT
|
||||||
main.session_id, {'seq_index' if seq_id else 'message_id %%%% 2147483647 AS seq_index'}, timestamp, {event_index} AS funnel_step
|
event_0.session_id,
|
||||||
|
event_{event_index - 1}.timestamp AS timestamp,
|
||||||
|
{event_index} AS funnel_step
|
||||||
|
WHERE EXISTS(SELECT session_id FROM {event_from} WHERE {" AND ".join(event_where)}) IS FALSE
|
||||||
|
) AS event_{event_index} {"ON(TRUE)" if event_index > 0 else ""}\
|
||||||
|
""", {**generic_args, **event_args}).decode('UTF-8'))
|
||||||
|
else:
|
||||||
|
events_query_from.append(cur.mogrify(f"""\
|
||||||
|
(SELECT main.session_id, MIN(timestamp) AS timestamp,{event_index} AS funnel_step
|
||||||
FROM {event_from}
|
FROM {event_from}
|
||||||
WHERE {" AND ".join(event_where)}
|
WHERE {" AND ".join(event_where)}
|
||||||
)\
|
GROUP BY 1
|
||||||
|
) AS event_{event_index} {"ON(TRUE)" if event_index > 0 else ""}\
|
||||||
""", {**generic_args, **event_args}).decode('UTF-8'))
|
""", {**generic_args, **event_args}).decode('UTF-8'))
|
||||||
|
event_index += 1
|
||||||
if len(events_query_from) > 0:
|
if event_index > 0:
|
||||||
events_query_part = f"""\
|
events_query_part = f"""SELECT
|
||||||
SELECT
|
event_0.session_id,
|
||||||
session_id, MIN(timestamp) AS first_event_ts, MAX(timestamp) AS last_event_ts
|
MIN(event_0.timestamp) AS first_event_ts,
|
||||||
FROM
|
MAX(event_{event_index - 1}.timestamp) AS last_event_ts
|
||||||
({(" UNION ALL ").join(events_query_from)}) AS f_query
|
FROM {(" INNER JOIN LATERAL ").join(events_query_from)}
|
||||||
GROUP BY 1
|
GROUP BY 1
|
||||||
{"" if event_index < 2 else f"HAVING events.funnel(array_agg(funnel_step ORDER BY timestamp,seq_index ASC), {event_index})" if strict
|
{fav_only_join}"""
|
||||||
else f"HAVING array_length(array_agg(DISTINCT funnel_step), 1) = {len(data['events'])}"}
|
|
||||||
{fav_only_join}
|
|
||||||
"""
|
|
||||||
else:
|
else:
|
||||||
data["events"] = []
|
data["events"] = []
|
||||||
|
|
||||||
|
|
@ -423,8 +445,7 @@ def search2_pg(data, project_id, user_id, favorite_only=False, errors_only=False
|
||||||
{" AND ".join(extra_constraints)}"""
|
{" AND ".join(extra_constraints)}"""
|
||||||
|
|
||||||
if errors_only:
|
if errors_only:
|
||||||
main_query = cur.mogrify(f"""\
|
main_query = cur.mogrify(f"""SELECT DISTINCT er.error_id, ser.status, ser.parent_error_id, ser.payload,
|
||||||
SELECT DISTINCT er.error_id, ser.status, ser.parent_error_id, ser.payload,
|
|
||||||
COALESCE((SELECT TRUE
|
COALESCE((SELECT TRUE
|
||||||
FROM public.user_favorite_sessions AS fs
|
FROM public.user_favorite_sessions AS fs
|
||||||
WHERE s.session_id = fs.session_id
|
WHERE s.session_id = fs.session_id
|
||||||
|
|
@ -437,13 +458,12 @@ def search2_pg(data, project_id, user_id, favorite_only=False, errors_only=False
|
||||||
generic_args)
|
generic_args)
|
||||||
|
|
||||||
elif count_only:
|
elif count_only:
|
||||||
main_query = cur.mogrify(f"""\
|
main_query = cur.mogrify(
|
||||||
SELECT COUNT(DISTINCT s.session_id) AS count_sessions, COUNT(DISTINCT s.user_uuid) AS count_users
|
f"""SELECT COUNT(DISTINCT s.session_id) AS count_sessions, COUNT(DISTINCT s.user_uuid) AS count_users
|
||||||
{query_part};""",
|
{query_part};""",
|
||||||
generic_args)
|
generic_args)
|
||||||
else:
|
else:
|
||||||
main_query = cur.mogrify(f"""\
|
main_query = cur.mogrify(f"""SELECT * FROM
|
||||||
SELECT * FROM
|
|
||||||
(SELECT DISTINCT ON(s.session_id) {SESSION_PROJECTION_COLS}
|
(SELECT DISTINCT ON(s.session_id) {SESSION_PROJECTION_COLS}
|
||||||
{query_part}
|
{query_part}
|
||||||
ORDER BY s.session_id desc) AS filtred_sessions
|
ORDER BY s.session_id desc) AS filtred_sessions
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ def create_step1(data):
|
||||||
errors.append("Tenant already exists, please select it from dropdown")
|
errors.append("Tenant already exists, please select it from dropdown")
|
||||||
elif len(signed_ups) == 0 and data.get("tenantId") is not None \
|
elif len(signed_ups) == 0 and data.get("tenantId") is not None \
|
||||||
or len(signed_ups) > 0 and data.get("tenantId") not in [t['tenantId'] for t in signed_ups]:
|
or len(signed_ups) > 0 and data.get("tenantId") not in [t['tenantId'] for t in signed_ups]:
|
||||||
errors.append("Tenant does not exist")
|
errors.append("Tenant not found")
|
||||||
|
|
||||||
if len(errors) > 0:
|
if len(errors) > 0:
|
||||||
print("==> error")
|
print("==> error")
|
||||||
|
|
|
||||||
|
|
@ -9,20 +9,26 @@ from chalicelib.utils.TimeUTC import TimeUTC
|
||||||
from chalicelib.utils.helper import environ
|
from chalicelib.utils.helper import environ
|
||||||
|
|
||||||
from chalicelib.core import tenants
|
from chalicelib.core import tenants
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
|
||||||
def create_new_member(email, password, admin, name, owner=False):
|
def __generate_invitation_token():
|
||||||
|
return secrets.token_urlsafe(64)
|
||||||
|
|
||||||
|
|
||||||
|
def create_new_member(email, invitation_token, admin, name, owner=False):
|
||||||
with pg_client.PostgresClient() as cur:
|
with pg_client.PostgresClient() as cur:
|
||||||
query = cur.mogrify(f"""\
|
query = cur.mogrify(f"""\
|
||||||
WITH u AS (
|
WITH u AS (INSERT INTO public.users (email, role, name, data)
|
||||||
INSERT INTO public.users (email, role, name, data)
|
VALUES (%(email)s, %(role)s, %(name)s, %(data)s)
|
||||||
VALUES (%(email)s, %(role)s, %(name)s, %(data)s)
|
RETURNING user_id,email,role,name,appearance
|
||||||
RETURNING user_id,email,role,name,appearance
|
),
|
||||||
),
|
au AS (INSERT INTO public.basic_authentication (user_id, generated_password, invitation_token, invited_at)
|
||||||
au AS (INSERT
|
VALUES ((SELECT user_id FROM u), TRUE, %(invitation_token)s, timezone('utc'::text, now()))
|
||||||
INTO public.basic_authentication (user_id, password, generated_password)
|
RETURNING invitation_token
|
||||||
VALUES ((SELECT user_id FROM u), crypt(%(password)s, gen_salt('bf', 12)), TRUE))
|
)
|
||||||
SELECT u.user_id AS id,
|
SELECT u.user_id,
|
||||||
|
u.user_id AS id,
|
||||||
u.email,
|
u.email,
|
||||||
u.role,
|
u.role,
|
||||||
u.name,
|
u.name,
|
||||||
|
|
@ -30,18 +36,18 @@ def create_new_member(email, password, admin, name, owner=False):
|
||||||
(CASE WHEN u.role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin,
|
(CASE WHEN u.role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin,
|
||||||
(CASE WHEN u.role = 'admin' THEN TRUE ELSE FALSE END) AS admin,
|
(CASE WHEN u.role = 'admin' THEN TRUE ELSE FALSE END) AS admin,
|
||||||
(CASE WHEN u.role = 'member' THEN TRUE ELSE FALSE END) AS member,
|
(CASE WHEN u.role = 'member' THEN TRUE ELSE FALSE END) AS member,
|
||||||
u.appearance
|
au.invitation_token
|
||||||
FROM u;""",
|
FROM u,au;""",
|
||||||
{"email": email, "password": password,
|
{"email": email, "role": "owner" if owner else "admin" if admin else "member", "name": name,
|
||||||
"role": "owner" if owner else "admin" if admin else "member", "name": name,
|
"data": json.dumps({"lastAnnouncementView": TimeUTC.now()}),
|
||||||
"data": json.dumps({"lastAnnouncementView": TimeUTC.now()})})
|
"invitation_token": invitation_token})
|
||||||
cur.execute(
|
cur.execute(
|
||||||
query
|
query
|
||||||
)
|
)
|
||||||
return helper.dict_to_camel_case(cur.fetchone())
|
return helper.dict_to_camel_case(cur.fetchone())
|
||||||
|
|
||||||
|
|
||||||
def restore_member(user_id, email, password, admin, name, owner=False):
|
def restore_member(user_id, email, invitation_token, admin, name, owner=False):
|
||||||
with pg_client.PostgresClient() as cur:
|
with pg_client.PostgresClient() as cur:
|
||||||
query = cur.mogrify(f"""\
|
query = cur.mogrify(f"""\
|
||||||
UPDATE public.users
|
UPDATE public.users
|
||||||
|
|
@ -58,31 +64,62 @@ def restore_member(user_id, email, password, admin, name, owner=False):
|
||||||
TRUE AS change_password,
|
TRUE AS change_password,
|
||||||
(CASE WHEN role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin,
|
(CASE WHEN role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin,
|
||||||
(CASE WHEN role = 'admin' THEN TRUE ELSE FALSE END) AS admin,
|
(CASE WHEN role = 'admin' THEN TRUE ELSE FALSE END) AS admin,
|
||||||
(CASE WHEN role = 'member' THEN TRUE ELSE FALSE END) AS member,
|
(CASE WHEN role = 'member' THEN TRUE ELSE FALSE END) AS member;""",
|
||||||
appearance;""",
|
|
||||||
{"user_id": user_id, "email": email,
|
{"user_id": user_id, "email": email,
|
||||||
"role": "owner" if owner else "admin" if admin else "member", "name": name})
|
"role": "owner" if owner else "admin" if admin else "member", "name": name})
|
||||||
cur.execute(
|
cur.execute(
|
||||||
query
|
query
|
||||||
)
|
)
|
||||||
result = helper.dict_to_camel_case(cur.fetchone())
|
result = cur.fetchone()
|
||||||
query = cur.mogrify("""\
|
query = cur.mogrify("""\
|
||||||
UPDATE public.basic_authentication
|
UPDATE public.basic_authentication
|
||||||
SET password= crypt(%(password)s, gen_salt('bf', 12)),
|
SET generated_password = TRUE,
|
||||||
generated_password= TRUE,
|
invitation_token = %(invitation_token)s,
|
||||||
token=NULL,
|
invited_at = timezone('utc'::text, now()),
|
||||||
token_requested_at=NULL
|
change_pwd_expire_at = NULL,
|
||||||
WHERE user_id=%(user_id)s;""",
|
change_pwd_token = NULL
|
||||||
{"user_id": user_id, "password": password})
|
WHERE user_id=%(user_id)s
|
||||||
|
RETURNING invitation_token;""",
|
||||||
|
{"user_id": user_id, "invitation_token": invitation_token})
|
||||||
cur.execute(
|
cur.execute(
|
||||||
query
|
query
|
||||||
)
|
)
|
||||||
|
result["invitation_token"] = cur.fetchone()["invitation_token"]
|
||||||
|
|
||||||
return result
|
return helper.dict_to_camel_case(result)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_new_invitation(user_id):
|
||||||
|
invitation_token = __generate_invitation_token()
|
||||||
|
with pg_client.PostgresClient() as cur:
|
||||||
|
query = cur.mogrify("""\
|
||||||
|
UPDATE public.basic_authentication
|
||||||
|
SET invitation_token = %(invitation_token)s,
|
||||||
|
invited_at = timezone('utc'::text, now()),
|
||||||
|
change_pwd_expire_at = NULL,
|
||||||
|
change_pwd_token = NULL
|
||||||
|
WHERE user_id=%(user_id)s
|
||||||
|
RETURNING invitation_token;""",
|
||||||
|
{"user_id": user_id, "invitation_token": invitation_token})
|
||||||
|
cur.execute(
|
||||||
|
query
|
||||||
|
)
|
||||||
|
return __get_invitation_link(cur.fetchone().pop("invitation_token"))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def reset_member(tenant_id, editor_id, user_id_to_update):
|
||||||
|
admin = get(tenant_id=tenant_id, user_id=editor_id)
|
||||||
|
if not admin["admin"] and not admin["superAdmin"]:
|
||||||
|
return {"errors": ["unauthorized"]}
|
||||||
|
user = get(tenant_id=tenant_id, user_id=user_id_to_update)
|
||||||
|
if not user:
|
||||||
|
return {"errors": ["user not found"]}
|
||||||
|
return {"data": {"invitationLink": generate_new_invitation(user_id_to_update)}}
|
||||||
|
|
||||||
|
|
||||||
def update(tenant_id, user_id, changes):
|
def update(tenant_id, user_id, changes):
|
||||||
AUTH_KEYS = ["password", "generatedPassword", "token"]
|
AUTH_KEYS = ["password", "generatedPassword", "invitationToken", "invitedAt", "changePwdExpireAt", "changePwdToken"]
|
||||||
if len(changes.keys()) == 0:
|
if len(changes.keys()) == 0:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -93,13 +130,6 @@ def update(tenant_id, user_id, changes):
|
||||||
if key == "password":
|
if key == "password":
|
||||||
sub_query_bauth.append("password = crypt(%(password)s, gen_salt('bf', 12))")
|
sub_query_bauth.append("password = crypt(%(password)s, gen_salt('bf', 12))")
|
||||||
sub_query_bauth.append("changed_at = timezone('utc'::text, now())")
|
sub_query_bauth.append("changed_at = timezone('utc'::text, now())")
|
||||||
elif key == "token":
|
|
||||||
if changes[key] is not None:
|
|
||||||
sub_query_bauth.append("token = %(token)s")
|
|
||||||
sub_query_bauth.append("token_requested_at = timezone('utc'::text, now())")
|
|
||||||
else:
|
|
||||||
sub_query_bauth.append("token = NULL")
|
|
||||||
sub_query_bauth.append("token_requested_at = NULL")
|
|
||||||
else:
|
else:
|
||||||
sub_query_bauth.append(f"{helper.key_to_snake_case(key)} = %({key})s")
|
sub_query_bauth.append(f"{helper.key_to_snake_case(key)} = %({key})s")
|
||||||
else:
|
else:
|
||||||
|
|
@ -166,26 +196,43 @@ def create_member(tenant_id, user_id, data):
|
||||||
return {"errors": ["invalid user name"]}
|
return {"errors": ["invalid user name"]}
|
||||||
if name is None:
|
if name is None:
|
||||||
name = data["email"]
|
name = data["email"]
|
||||||
temp_pass = helper.generate_salt()[:8]
|
invitation_token = __generate_invitation_token()
|
||||||
user = get_deleted_user_by_email(email=data["email"])
|
user = get_deleted_user_by_email(email=data["email"])
|
||||||
if user is not None:
|
if user is not None:
|
||||||
new_member = restore_member(email=data["email"], password=temp_pass,
|
new_member = restore_member(email=data["email"], invitation_token=invitation_token,
|
||||||
admin=data.get("admin", False), name=name, user_id=user["userId"])
|
admin=data.get("admin", False), name=name, user_id=user["userId"])
|
||||||
else:
|
else:
|
||||||
new_member = create_new_member(email=data["email"], password=temp_pass,
|
new_member = create_new_member(email=data["email"], invitation_token=invitation_token,
|
||||||
admin=data.get("admin", False), name=name)
|
admin=data.get("admin", False), name=name)
|
||||||
|
new_member["invitationLink"] = __get_invitation_link(new_member.pop("invitationToken"))
|
||||||
helper.async_post(environ['email_basic'] % 'member_invitation',
|
helper.async_post(environ['email_basic'] % 'member_invitation',
|
||||||
{
|
{
|
||||||
"email": data["email"],
|
"email": data["email"],
|
||||||
"userName": data["email"],
|
"invitationLink": new_member["invitationLink"],
|
||||||
"tempPassword": temp_pass,
|
|
||||||
"clientId": tenants.get_by_tenant_id(tenant_id)["name"],
|
"clientId": tenants.get_by_tenant_id(tenant_id)["name"],
|
||||||
"senderName": admin["name"]
|
"senderName": admin["name"]
|
||||||
})
|
})
|
||||||
return {"data": new_member}
|
return {"data": new_member}
|
||||||
|
|
||||||
|
|
||||||
|
def __get_invitation_link(invitation_token):
|
||||||
|
return environ["SITE_URL"] + environ["invitation_link"] % invitation_token
|
||||||
|
|
||||||
|
|
||||||
|
def allow_password_change(user_id, delta_min=10):
|
||||||
|
pass_token = secrets.token_urlsafe(8)
|
||||||
|
with pg_client.PostgresClient() as cur:
|
||||||
|
query = cur.mogrify(f"""UPDATE public.basic_authentication
|
||||||
|
SET change_pwd_expire_at = timezone('utc'::text, now()+INTERVAL '%(delta)s MINUTES'),
|
||||||
|
change_pwd_token = %(pass_token)s
|
||||||
|
WHERE user_id = %(user_id)s""",
|
||||||
|
{"user_id": user_id, "delta": delta_min, "pass_token": pass_token})
|
||||||
|
cur.execute(
|
||||||
|
query
|
||||||
|
)
|
||||||
|
return pass_token
|
||||||
|
|
||||||
|
|
||||||
def get(user_id, tenant_id):
|
def get(user_id, tenant_id):
|
||||||
with pg_client.PostgresClient() as cur:
|
with pg_client.PostgresClient() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
|
|
@ -317,14 +364,24 @@ def get_members(tenant_id):
|
||||||
basic_authentication.generated_password,
|
basic_authentication.generated_password,
|
||||||
(CASE WHEN users.role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin,
|
(CASE WHEN users.role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin,
|
||||||
(CASE WHEN users.role = 'admin' THEN TRUE ELSE FALSE END) AS admin,
|
(CASE WHEN users.role = 'admin' THEN TRUE ELSE FALSE END) AS admin,
|
||||||
(CASE WHEN users.role = 'member' THEN TRUE ELSE FALSE END) AS member
|
(CASE WHEN users.role = 'member' THEN TRUE ELSE FALSE END) AS member,
|
||||||
|
DATE_PART('day',timezone('utc'::text, now()) \
|
||||||
|
- COALESCE(basic_authentication.invited_at,'2000-01-01'::timestamp ))>=1 AS expired_invitation,
|
||||||
|
basic_authentication.password IS NOT NULL AS joined,
|
||||||
|
invitation_token
|
||||||
FROM public.users LEFT JOIN public.basic_authentication ON users.user_id=basic_authentication.user_id
|
FROM public.users LEFT JOIN public.basic_authentication ON users.user_id=basic_authentication.user_id
|
||||||
WHERE users.deleted_at IS NULL
|
WHERE users.deleted_at IS NULL
|
||||||
ORDER BY name, id"""
|
ORDER BY name, id"""
|
||||||
)
|
)
|
||||||
r = cur.fetchall()
|
r = cur.fetchall()
|
||||||
if len(r):
|
if len(r):
|
||||||
return helper.list_to_camel_case(r)
|
r = helper.list_to_camel_case(r)
|
||||||
|
for u in r:
|
||||||
|
if u["invitationToken"]:
|
||||||
|
u["invitationLink"] = __get_invitation_link(u.pop("invitationToken"))
|
||||||
|
else:
|
||||||
|
u["invitationLink"] = None
|
||||||
|
return r
|
||||||
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
@ -367,6 +424,15 @@ def change_password(tenant_id, user_id, email, old_password, new_password):
|
||||||
"jwt": authenticate(email, new_password)["jwt"]}
|
"jwt": authenticate(email, new_password)["jwt"]}
|
||||||
|
|
||||||
|
|
||||||
|
def set_password_invitation(user_id, new_password):
|
||||||
|
changes = {"password": new_password, "generatedPassword": False,
|
||||||
|
"invitationToken": None, "invitedAt": None,
|
||||||
|
"changePwdExpireAt": None, "changePwdToken": None}
|
||||||
|
user = update(tenant_id=-1, user_id=user_id, changes=changes)
|
||||||
|
return {"data": user,
|
||||||
|
"jwt": authenticate(user["email"], new_password)["jwt"]}
|
||||||
|
|
||||||
|
|
||||||
def count_members():
|
def count_members():
|
||||||
with pg_client.PostgresClient() as cur:
|
with pg_client.PostgresClient() as cur:
|
||||||
cur.execute("""SELECT COUNT(user_id)
|
cur.execute("""SELECT COUNT(user_id)
|
||||||
|
|
@ -409,6 +475,24 @@ def get_deleted_user_by_email(email):
|
||||||
return helper.dict_to_camel_case(r)
|
return helper.dict_to_camel_case(r)
|
||||||
|
|
||||||
|
|
||||||
|
def get_by_invitation_token(token, pass_token=None):
|
||||||
|
with pg_client.PostgresClient() as cur:
|
||||||
|
cur.execute(
|
||||||
|
cur.mogrify(
|
||||||
|
f"""SELECT
|
||||||
|
*,
|
||||||
|
DATE_PART('day',timezone('utc'::text, now()) \
|
||||||
|
- COALESCE(basic_authentication.invited_at,'2000-01-01'::timestamp ))>=1 AS expired_invitation,
|
||||||
|
change_pwd_expire_at <= timezone('utc'::text, now()) AS expired_change
|
||||||
|
FROM public.users INNER JOIN public.basic_authentication USING(user_id)
|
||||||
|
WHERE invitation_token = %(token)s {"AND change_pwd_token = %(pass_token)s" if pass_token else ""}
|
||||||
|
LIMIT 1;""",
|
||||||
|
{"token": token, "pass_token": token})
|
||||||
|
)
|
||||||
|
r = cur.fetchone()
|
||||||
|
return helper.dict_to_camel_case(r)
|
||||||
|
|
||||||
|
|
||||||
def auth_exists(user_id, tenant_id, jwt_iat, jwt_aud):
|
def auth_exists(user_id, tenant_id, jwt_iat, jwt_aud):
|
||||||
with pg_client.PostgresClient() as cur:
|
with pg_client.PostgresClient() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
|
|
|
||||||
|
|
@ -2,18 +2,18 @@ from chalicelib.utils.TimeUTC import TimeUTC
|
||||||
from chalicelib.utils.email_handler import __get_html_from_file, send_html, __escape_text_html
|
from chalicelib.utils.email_handler import __get_html_from_file, send_html, __escape_text_html
|
||||||
|
|
||||||
|
|
||||||
def send_team_invitation(recipient, user_name, temp_password, client_id, sender_name):
|
def send_team_invitation(recipient, client_id, sender_name, invitation_link):
|
||||||
BODY_HTML = __get_html_from_file("chalicelib/utils/html/invitation.html",
|
BODY_HTML = __get_html_from_file("chalicelib/utils/html/invitation.html",
|
||||||
formatting_variables={"userName": __escape_text_html(user_name),
|
formatting_variables={"invitationLink": invitation_link,
|
||||||
"password": temp_password, "clientId": client_id,
|
"clientId": client_id,
|
||||||
"sender": sender_name})
|
"sender": sender_name})
|
||||||
SUBJECT = "Welcome to OpenReplay"
|
SUBJECT = "Welcome to OpenReplay"
|
||||||
send_html(BODY_HTML, SUBJECT, recipient)
|
send_html(BODY_HTML, SUBJECT, recipient)
|
||||||
|
|
||||||
|
|
||||||
def send_reset_code(recipient, reset_code):
|
def send_forgot_password(recipient, invitation_link):
|
||||||
BODY_HTML = __get_html_from_file("chalicelib/utils/html/reset_password.html",
|
BODY_HTML = __get_html_from_file("chalicelib/utils/html/reset_password.html",
|
||||||
formatting_variables={"code": reset_code})
|
formatting_variables={"invitationLink": invitation_link})
|
||||||
SUBJECT = "Password recovery"
|
SUBJECT = "Password recovery"
|
||||||
send_html(BODY_HTML, SUBJECT, recipient)
|
send_html(BODY_HTML, SUBJECT, recipient)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -363,3 +363,7 @@ def get_internal_project_id(project_id64):
|
||||||
return None
|
return None
|
||||||
project_id = int(project_id64)
|
project_id = int(project_id64)
|
||||||
return project_id
|
return project_id
|
||||||
|
|
||||||
|
|
||||||
|
def has_smtp():
|
||||||
|
return environ["EMAIL_HOST"] is not None and len(environ["EMAIL_HOST"]) > 0
|
||||||
|
|
|
||||||
|
|
@ -447,10 +447,10 @@ width: 25%!important
|
||||||
<p style="font-size: 14px; line-height: 16px; text-align: center; margin: 0;">
|
<p style="font-size: 14px; line-height: 16px; text-align: center; margin: 0;">
|
||||||
Please use this link to login:</p>
|
Please use this link to login:</p>
|
||||||
<p style="font-size: 14px; line-height: 21px; text-align: center; margin: 0;">
|
<p style="font-size: 14px; line-height: 21px; text-align: center; margin: 0;">
|
||||||
<span style="font-size: 18px;"><a href="%(frontend_url)s"
|
<span style="font-size: 18px;"><a href="%(invitationLink)s"
|
||||||
rel="noopener"
|
rel="noopener"
|
||||||
style="text-decoration: underline; color: #009193;"
|
style="text-decoration: underline; color: #009193;"
|
||||||
target="_blank" title="OpenReplay Login">%(frontend_url)s</a></span><span
|
target="_blank" title="OpenReplay Login">%(invitationLink)s</a></span><span
|
||||||
style="font-size: 18px; line-height: 21px;"></span></p>
|
style="font-size: 18px; line-height: 21px;"></span></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -485,40 +485,18 @@ width: 25%!important
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding-right: 10px; padding-left: 10px; padding-top: 10px; padding-bottom: 10px; font-family: Arial, sans-serif">
|
<td style="padding-right: 10px; padding-left: 10px; padding-top: 10px; padding-bottom: 10px; font-family: Arial, sans-serif">
|
||||||
<![endif]-->
|
<![endif]-->
|
||||||
<div style="color:#555555;font-family:-apple-system,BlinkMacSystemFont,'Helvetica Neue','Segoe UI',Roboto,Oxygen-Sans,Ubuntu,Cantarell,sans-serif;line-height:120%;padding-top:10px;padding-right:10px;padding-bottom:10px;padding-left:10px;">
|
|
||||||
<div style="font-size: 12px; line-height: 14px; color: #555555; font-family: -apple-system,BlinkMacSystemFont,'Helvetica Neue','Segoe UI',Roboto,Oxygen-Sans,Ubuntu,Cantarell,sans-serif;">
|
|
||||||
<p style="font-size: 14px; line-height: 16px; text-align: center; margin: 0;">
|
|
||||||
Your login credentials</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!--[if mso]></td></tr></table><![endif]-->
|
<!--[if mso]></td></tr></table><![endif]-->
|
||||||
<!--[if mso]>
|
<!--[if mso]>
|
||||||
<table width="100%" cellpadding="0" cellspacing="0" border="0">
|
<table width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding-right: 10px; padding-left: 10px; padding-top: 10px; padding-bottom: 10px; font-family: Arial, sans-serif">
|
<td style="padding-right: 10px; padding-left: 10px; padding-top: 10px; padding-bottom: 10px; font-family: Arial, sans-serif">
|
||||||
<![endif]-->
|
<![endif]-->
|
||||||
<div style="color:#555555;font-family:-apple-system,BlinkMacSystemFont,'Helvetica Neue','Segoe UI',Roboto,Oxygen-Sans,Ubuntu,Cantarell,sans-serif;line-height:120%;padding-top:10px;padding-right:10px;padding-bottom:10px;padding-left:10px;">
|
|
||||||
<div style="font-size: 12px; line-height: 14px; color: #555555; font-family: -apple-system,BlinkMacSystemFont,'Helvetica Neue','Segoe UI',Roboto,Oxygen-Sans,Ubuntu,Cantarell,sans-serif;">
|
|
||||||
<p style="font-size: 14px; line-height: 16px; text-align: center; margin: 0;">
|
|
||||||
<strong>Username / Email</strong></p>
|
|
||||||
<p style="font-size: 14px; line-height: 16px; text-align: center; margin: 0;">
|
|
||||||
<span style="text-decoration: none; color: #009193;">%(userName)s</span></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!--[if mso]></td></tr></table><![endif]-->
|
<!--[if mso]></td></tr></table><![endif]-->
|
||||||
<!--[if mso]>
|
<!--[if mso]>
|
||||||
<table width="100%" cellpadding="0" cellspacing="0" border="0">
|
<table width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding-right: 10px; padding-left: 10px; padding-top: 10px; padding-bottom: 10px; font-family: Arial, sans-serif">
|
<td style="padding-right: 10px; padding-left: 10px; padding-top: 10px; padding-bottom: 10px; font-family: Arial, sans-serif">
|
||||||
<![endif]-->
|
<![endif]-->
|
||||||
<div style="color:#555555;font-family:-apple-system,BlinkMacSystemFont,'Helvetica Neue','Segoe UI',Roboto,Oxygen-Sans,Ubuntu,Cantarell,sans-serif;line-height:120%;padding-top:10px;padding-right:10px;padding-bottom:10px;padding-left:10px;">
|
|
||||||
<div style="font-size: 12px; line-height: 14px; color: #555555; font-family: -apple-system,BlinkMacSystemFont,'Helvetica Neue','Segoe UI',Roboto,Oxygen-Sans,Ubuntu,Cantarell,sans-serif;">
|
|
||||||
<p style="font-size: 14px; line-height: 16px; text-align: center; margin: 0;">
|
|
||||||
<strong>Password</strong></p>
|
|
||||||
<p style="font-size: 14px; line-height: 16px; text-align: center; margin: 0;">
|
|
||||||
%(password)s</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!--[if mso]></td></tr></table><![endif]-->
|
<!--[if mso]></td></tr></table><![endif]-->
|
||||||
<table border="0" cellpadding="0" cellspacing="0" class="divider"
|
<table border="0" cellpadding="0" cellspacing="0" class="divider"
|
||||||
role="presentation"
|
role="presentation"
|
||||||
|
|
|
||||||
|
|
@ -452,10 +452,10 @@ width: 25%!important
|
||||||
<div style="color:#555555;font-family:-apple-system,BlinkMacSystemFont,'Helvetica Neue','Segoe UI',Roboto,Oxygen-Sans,Ubuntu,Cantarell,sans-serif;line-height:120%;padding-top:10px;padding-right:10px;padding-bottom:10px;padding-left:10px;">
|
<div style="color:#555555;font-family:-apple-system,BlinkMacSystemFont,'Helvetica Neue','Segoe UI',Roboto,Oxygen-Sans,Ubuntu,Cantarell,sans-serif;line-height:120%;padding-top:10px;padding-right:10px;padding-bottom:10px;padding-left:10px;">
|
||||||
<div style="font-size: 12px; line-height: 14px; color: #555555; font-family: -apple-system,BlinkMacSystemFont,'Helvetica Neue','Segoe UI',Roboto,Oxygen-Sans,Ubuntu,Cantarell,sans-serif;">
|
<div style="font-size: 12px; line-height: 14px; color: #555555; font-family: -apple-system,BlinkMacSystemFont,'Helvetica Neue','Segoe UI',Roboto,Oxygen-Sans,Ubuntu,Cantarell,sans-serif;">
|
||||||
<p style="font-size: 14px; line-height: 16px; text-align: center; margin: 0;">
|
<p style="font-size: 14px; line-height: 16px; text-align: center; margin: 0;">
|
||||||
Use the code below to reset your password (valid for 24 hours only):</p>
|
Use the link below to reset your password (valid for 24 hours only):</p>
|
||||||
<p style="font-size: 14px; line-height: 21px; text-align: center; margin: 0;">
|
<p style="font-size: 14px; line-height: 21px; text-align: center; margin: 0;">
|
||||||
<br/>
|
<br/>
|
||||||
<span style="font-size: 18px;"><b>%(code)s</b></span><span
|
<span style="font-size: 18px;"><a href="%(invitationLink)s"><b>%(invitationLink)s</b></a></span><span
|
||||||
style="font-size: 18px; line-height: 21px;"></span></p>
|
style="font-size: 18px; line-height: 21px;"></span></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,3 @@
|
||||||
BEGIN;
|
BEGIN;
|
||||||
CREATE INDEX pages_first_contentful_paint_time_idx ON events.pages (first_contentful_paint_time) WHERE first_contentful_paint_time>0;
|
|
||||||
CREATE INDEX pages_dom_content_loaded_time_idx ON events.pages (dom_content_loaded_time) WHERE dom_content_loaded_time>0;
|
|
||||||
CREATE INDEX pages_first_paint_time_idx ON events.pages (first_paint_time) WHERE first_paint_time > 0;
|
|
||||||
CREATE INDEX pages_ttfb_idx ON events.pages (ttfb) WHERE ttfb > 0;
|
CREATE INDEX pages_ttfb_idx ON events.pages (ttfb) WHERE ttfb > 0;
|
||||||
CREATE INDEX pages_time_to_interactive_idx ON events.pages (time_to_interactive) WHERE time_to_interactive > 0;
|
|
||||||
COMMIT;
|
COMMIT;
|
||||||
4
backend/pkg/db/cache/project.go
vendored
4
backend/pkg/db/cache/project.go
vendored
|
|
@ -11,7 +11,7 @@ func (c *PGCache) GetProjectByKey(projectKey string) (*Project, error) {
|
||||||
return c.projectsByKeys[ projectKey ].Project, nil
|
return c.projectsByKeys[ projectKey ].Project, nil
|
||||||
}
|
}
|
||||||
p, err := c.Conn.GetProjectByKey(projectKey)
|
p, err := c.Conn.GetProjectByKey(projectKey)
|
||||||
if err != nil {
|
if p == nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
c.projectsByKeys[ projectKey ] = &ProjectMeta{ p, time.Now().Add(c.projectExpirationTimeout) }
|
c.projectsByKeys[ projectKey ] = &ProjectMeta{ p, time.Now().Add(c.projectExpirationTimeout) }
|
||||||
|
|
@ -27,7 +27,7 @@ func (c *PGCache) GetProject(projectID uint32) (*Project, error) {
|
||||||
return c.projects[ projectID ].Project, nil
|
return c.projects[ projectID ].Project, nil
|
||||||
}
|
}
|
||||||
p, err := c.Conn.GetProject(projectID)
|
p, err := c.Conn.GetProject(projectID)
|
||||||
if err != nil {
|
if p == nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
c.projects[ projectID ] = &ProjectMeta{ p, time.Now().Add(c.projectExpirationTimeout) }
|
c.projects[ projectID ] = &ProjectMeta{ p, time.Now().Add(c.projectExpirationTimeout) }
|
||||||
|
|
|
||||||
|
|
@ -105,11 +105,14 @@ func (conn *Conn) InsertWebClickEvent(sessionID uint64, e *ClickEvent) error {
|
||||||
defer tx.rollback()
|
defer tx.rollback()
|
||||||
if err = tx.exec(`
|
if err = tx.exec(`
|
||||||
INSERT INTO events.clicks
|
INSERT INTO events.clicks
|
||||||
(session_id, message_id, timestamp, label)
|
(session_id, message_id, timestamp, label, selector, url)
|
||||||
VALUES
|
(SELECT
|
||||||
($1, $2, $3, NULLIF($4, ''))
|
$1, $2, $3, NULLIF($4, ''), $5, host || base_path
|
||||||
|
FROM events.pages
|
||||||
|
WHERE session_id = $1 AND timestamp <= $3 ORDER BY timestamp DESC LIMIT 1
|
||||||
|
)
|
||||||
`,
|
`,
|
||||||
sessionID, e.MessageID, e.Timestamp, e.Label,
|
sessionID, e.MessageID, e.Timestamp, e.Label, e.Selector,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ func ReadBatch(b []byte, callback func(Message)) error {
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return errors.Wrapf(err, "Batch Message decoding error on message with index %v", index)
|
return errors.Wrapf(err, "Batch Message decoding error on message with index %v", index)
|
||||||
}
|
}
|
||||||
|
msg = transformDepricated(msg)
|
||||||
|
|
||||||
isBatchMeta := false
|
isBatchMeta := false
|
||||||
switch m := msg.(type){
|
switch m := msg.(type){
|
||||||
case *BatchMeta: // Is not required to be present in batch since IOS doesn't have it (though we might change it)
|
case *BatchMeta: // Is not required to be present in batch since IOS doesn't have it (though we might change it)
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// Auto-generated, do not edit
|
||||||
package messages
|
package messages
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
32
backend/pkg/messages/legacy_message_transform.go
Normal file
32
backend/pkg/messages/legacy_message_transform.go
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
package messages
|
||||||
|
|
||||||
|
|
||||||
|
func transformDepricated(msg Message) Message {
|
||||||
|
switch m := msg.(type) {
|
||||||
|
case *MouseClickDepricated:
|
||||||
|
meta := m.Meta()
|
||||||
|
meta.TypeID = 33
|
||||||
|
return &MouseClick{
|
||||||
|
meta: meta,
|
||||||
|
ID: m.ID,
|
||||||
|
HesitationTime: m.HesitationTime,
|
||||||
|
Label: m.Label,
|
||||||
|
// Selector: '',
|
||||||
|
}
|
||||||
|
// case *FetchDepricated:
|
||||||
|
// return &Fetch {
|
||||||
|
// Method: m.Method,
|
||||||
|
// URL: m.URL,
|
||||||
|
// Request: m.Request,
|
||||||
|
// Response: m.Response,
|
||||||
|
// Status: m.Status,
|
||||||
|
// Timestamp: m.Timestamp,
|
||||||
|
// Duration: m.Duration,
|
||||||
|
// // Headers: ''
|
||||||
|
// }
|
||||||
|
default:
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -350,13 +350,13 @@ p = WriteUint(msg.Y, buf, p)
|
||||||
return buf[:p]
|
return buf[:p]
|
||||||
}
|
}
|
||||||
|
|
||||||
type MouseClick struct {
|
type MouseClickDepricated struct {
|
||||||
*meta
|
*meta
|
||||||
ID uint64
|
ID uint64
|
||||||
HesitationTime uint64
|
HesitationTime uint64
|
||||||
Label string
|
Label string
|
||||||
}
|
}
|
||||||
func (msg *MouseClick) Encode() []byte{
|
func (msg *MouseClickDepricated) Encode() []byte{
|
||||||
buf := make([]byte, 31 + len(msg.Label))
|
buf := make([]byte, 31 + len(msg.Label))
|
||||||
buf[0] = 21
|
buf[0] = 21
|
||||||
p := 1
|
p := 1
|
||||||
|
|
@ -582,15 +582,17 @@ type ClickEvent struct {
|
||||||
Timestamp uint64
|
Timestamp uint64
|
||||||
HesitationTime uint64
|
HesitationTime uint64
|
||||||
Label string
|
Label string
|
||||||
|
Selector string
|
||||||
}
|
}
|
||||||
func (msg *ClickEvent) Encode() []byte{
|
func (msg *ClickEvent) Encode() []byte{
|
||||||
buf := make([]byte, 41 + len(msg.Label))
|
buf := make([]byte, 51 + len(msg.Label)+ len(msg.Selector))
|
||||||
buf[0] = 33
|
buf[0] = 33
|
||||||
p := 1
|
p := 1
|
||||||
p = WriteUint(msg.MessageID, buf, p)
|
p = WriteUint(msg.MessageID, buf, p)
|
||||||
p = WriteUint(msg.Timestamp, buf, p)
|
p = WriteUint(msg.Timestamp, buf, p)
|
||||||
p = WriteUint(msg.HesitationTime, buf, p)
|
p = WriteUint(msg.HesitationTime, buf, p)
|
||||||
p = WriteString(msg.Label, buf, p)
|
p = WriteString(msg.Label, buf, p)
|
||||||
|
p = WriteString(msg.Selector, buf, p)
|
||||||
return buf[:p]
|
return buf[:p]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1146,6 +1148,24 @@ p = WriteString(msg.BaseURL, buf, p)
|
||||||
return buf[:p]
|
return buf[:p]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MouseClick struct {
|
||||||
|
*meta
|
||||||
|
ID uint64
|
||||||
|
HesitationTime uint64
|
||||||
|
Label string
|
||||||
|
Selector string
|
||||||
|
}
|
||||||
|
func (msg *MouseClick) Encode() []byte{
|
||||||
|
buf := make([]byte, 41 + len(msg.Label)+ len(msg.Selector))
|
||||||
|
buf[0] = 69
|
||||||
|
p := 1
|
||||||
|
p = WriteUint(msg.ID, buf, p)
|
||||||
|
p = WriteUint(msg.HesitationTime, buf, p)
|
||||||
|
p = WriteString(msg.Label, buf, p)
|
||||||
|
p = WriteString(msg.Selector, buf, p)
|
||||||
|
return buf[:p]
|
||||||
|
}
|
||||||
|
|
||||||
type IOSSessionStart struct {
|
type IOSSessionStart struct {
|
||||||
*meta
|
*meta
|
||||||
Timestamp uint64
|
Timestamp uint64
|
||||||
|
|
|
||||||
|
|
@ -159,7 +159,7 @@ if msg.Y, err = ReadUint(reader); err != nil { return nil, err }
|
||||||
return msg, nil
|
return msg, nil
|
||||||
|
|
||||||
case 21:
|
case 21:
|
||||||
msg := &MouseClick{ meta: &meta{ TypeID: 21} }
|
msg := &MouseClickDepricated{ meta: &meta{ TypeID: 21} }
|
||||||
if msg.ID, err = ReadUint(reader); err != nil { return nil, err }
|
if msg.ID, err = ReadUint(reader); err != nil { return nil, err }
|
||||||
if msg.HesitationTime, err = ReadUint(reader); err != nil { return nil, err }
|
if msg.HesitationTime, err = ReadUint(reader); err != nil { return nil, err }
|
||||||
if msg.Label, err = ReadString(reader); err != nil { return nil, err }
|
if msg.Label, err = ReadString(reader); err != nil { return nil, err }
|
||||||
|
|
@ -265,6 +265,7 @@ if msg.Label, err = ReadString(reader); err != nil { return nil, err }
|
||||||
if msg.Timestamp, err = ReadUint(reader); err != nil { return nil, err }
|
if msg.Timestamp, err = ReadUint(reader); err != nil { return nil, err }
|
||||||
if msg.HesitationTime, err = ReadUint(reader); err != nil { return nil, err }
|
if msg.HesitationTime, err = ReadUint(reader); err != nil { return nil, err }
|
||||||
if msg.Label, err = ReadString(reader); err != nil { return nil, err }
|
if msg.Label, err = ReadString(reader); err != nil { return nil, err }
|
||||||
|
if msg.Selector, err = ReadString(reader); err != nil { return nil, err }
|
||||||
return msg, nil
|
return msg, nil
|
||||||
|
|
||||||
case 34:
|
case 34:
|
||||||
|
|
@ -512,6 +513,14 @@ if msg.Index, err = ReadUint(reader); err != nil { return nil, err }
|
||||||
if msg.BaseURL, err = ReadString(reader); err != nil { return nil, err }
|
if msg.BaseURL, err = ReadString(reader); err != nil { return nil, err }
|
||||||
return msg, nil
|
return msg, nil
|
||||||
|
|
||||||
|
case 69:
|
||||||
|
msg := &MouseClick{ meta: &meta{ TypeID: 69} }
|
||||||
|
if msg.ID, err = ReadUint(reader); err != nil { return nil, err }
|
||||||
|
if msg.HesitationTime, err = ReadUint(reader); err != nil { return nil, err }
|
||||||
|
if msg.Label, err = ReadString(reader); err != nil { return nil, err }
|
||||||
|
if msg.Selector, err = ReadString(reader); err != nil { return nil, err }
|
||||||
|
return msg, nil
|
||||||
|
|
||||||
case 90:
|
case 90:
|
||||||
msg := &IOSSessionStart{ meta: &meta{ TypeID: 90} }
|
msg := &IOSSessionStart{ meta: &meta{ TypeID: 90} }
|
||||||
if msg.Timestamp, err = ReadUint(reader); err != nil { return nil, err }
|
if msg.Timestamp, err = ReadUint(reader); err != nil { return nil, err }
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"net/url"
|
"net/url"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func getSessionKey(sessionID uint64) string {
|
func getSessionKey(sessionID uint64) string {
|
||||||
|
|
@ -56,15 +57,16 @@ func GetFullCachableURL(baseURL string, relativeURL string) (string, bool) {
|
||||||
const OPENREPLAY_QUERY_START = "OPENREPLAY_QUERY"
|
const OPENREPLAY_QUERY_START = "OPENREPLAY_QUERY"
|
||||||
|
|
||||||
func getCachePath(rawurl string) string {
|
func getCachePath(rawurl string) string {
|
||||||
u, _ := url.Parse(rawurl)
|
return strings.ReplaceAll(url.QueryEscape(rawurl), "%", "!") // s3 keys are ok with "!"
|
||||||
s := "/" + u.Scheme + "/" + u.Hostname() + u.Path
|
// u, _ := url.Parse(rawurl)
|
||||||
if u.RawQuery != "" {
|
// s := "/" + u.Scheme + "/" + u.Hostname() + u.Path
|
||||||
if (s[len(s) - 1] != '/') {
|
// if u.RawQuery != "" {
|
||||||
s += "/"
|
// if (s[len(s) - 1] != '/') {
|
||||||
}
|
// s += "/"
|
||||||
s += OPENREPLAY_QUERY_START + url.PathEscape(u.RawQuery)
|
// }
|
||||||
}
|
// s += OPENREPLAY_QUERY_START + url.PathEscape(u.RawQuery)
|
||||||
return s
|
// }
|
||||||
|
// return s
|
||||||
}
|
}
|
||||||
|
|
||||||
func getCachePathWithKey(sessionID uint64, rawurl string) string {
|
func getCachePathWithKey(sessionID uint64, rawurl string) string {
|
||||||
|
|
|
||||||
|
|
@ -187,6 +187,7 @@ func (b *builder) handleMessage(message Message, messageID uint64) {
|
||||||
Label: msg.Label,
|
Label: msg.Label,
|
||||||
HesitationTime: msg.HesitationTime,
|
HesitationTime: msg.HesitationTime,
|
||||||
Timestamp: b.timestamp,
|
Timestamp: b.timestamp,
|
||||||
|
Selector: msg.Selector,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
case *JSException:
|
case *JSException:
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
const CLICK_TIME_DIFF = 200
|
const CLICK_TIME_DIFF = 300
|
||||||
const MIN_CLICKS_IN_A_ROW = 3
|
const MIN_CLICKS_IN_A_ROW = 3
|
||||||
|
|
||||||
type clickRageDetector struct {
|
type clickRageDetector struct {
|
||||||
|
|
@ -40,7 +40,7 @@ func (crd *clickRageDetector) Build() *IssueEvent {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (crd *clickRageDetector) HandleMouseClick(msg *MouseClick, messageID uint64, timestamp uint64) *IssueEvent {
|
func (crd *clickRageDetector) HandleMouseClick(msg *MouseClick, messageID uint64, timestamp uint64) *IssueEvent {
|
||||||
if crd.lastTimestamp + CLICK_TIME_DIFF < timestamp && crd.lastLabel == msg.Label {
|
if crd.lastTimestamp + CLICK_TIME_DIFF > timestamp && crd.lastLabel == msg.Label {
|
||||||
crd.lastTimestamp = timestamp
|
crd.lastTimestamp = timestamp
|
||||||
crd.countsInARow += 1
|
crd.countsInARow += 1
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ type deadClickDetector struct {
|
||||||
lastMouseClick *MouseClick
|
lastMouseClick *MouseClick
|
||||||
lastTimestamp uint64
|
lastTimestamp uint64
|
||||||
lastMessageID uint64
|
lastMessageID uint64
|
||||||
|
inputIDSet map[uint64]bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -24,6 +25,7 @@ func (d *deadClickDetector) HandleReaction(timestamp uint64) *IssueEvent {
|
||||||
MessageID: d.lastMessageID,
|
MessageID: d.lastMessageID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
d.inputIDSet = nil
|
||||||
d.lastMouseClick = nil
|
d.lastMouseClick = nil
|
||||||
d.lastTimestamp = 0
|
d.lastTimestamp = 0
|
||||||
d.lastMessageID = 0
|
d.lastMessageID = 0
|
||||||
|
|
@ -33,8 +35,18 @@ func (d *deadClickDetector) HandleReaction(timestamp uint64) *IssueEvent {
|
||||||
func (d *deadClickDetector) HandleMessage(msg Message, messageID uint64, timestamp uint64) *IssueEvent {
|
func (d *deadClickDetector) HandleMessage(msg Message, messageID uint64, timestamp uint64) *IssueEvent {
|
||||||
var i *IssueEvent
|
var i *IssueEvent
|
||||||
switch m := msg.(type) {
|
switch m := msg.(type) {
|
||||||
|
case *SetInputTarget:
|
||||||
|
if d.inputIDSet == nil {
|
||||||
|
d.inputIDSet = make(map[uint64]bool)
|
||||||
|
}
|
||||||
|
d.inputIDSet[m.ID] = true
|
||||||
|
case *CreateDocument:
|
||||||
|
d.inputIDSet = nil
|
||||||
case *MouseClick:
|
case *MouseClick:
|
||||||
i = d.HandleReaction(timestamp)
|
i = d.HandleReaction(timestamp)
|
||||||
|
if d.inputIDSet[m.ID] { // ignore if input
|
||||||
|
return i
|
||||||
|
}
|
||||||
d.lastMouseClick = m
|
d.lastMouseClick = m
|
||||||
d.lastTimestamp = timestamp
|
d.lastTimestamp = timestamp
|
||||||
d.lastMessageID = messageID
|
d.lastMessageID = messageID
|
||||||
|
|
|
||||||
|
|
@ -61,62 +61,64 @@ func main() {
|
||||||
Addr: ":" + HTTP_PORT,
|
Addr: ":" + HTTP_PORT,
|
||||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
// TODO: agree with specification
|
// TODO: agree with specification
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
w.Header().Set("Access-Control-Allow-Methods", "POST")
|
w.Header().Set("Access-Control-Allow-Methods", "POST")
|
||||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type,Authorization")
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type,Authorization")
|
||||||
if r.Method == http.MethodOptions {
|
if r.Method == http.MethodOptions {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.Header().Set("Cache-Control", "max-age=86400")
|
||||||
return
|
w.WriteHeader(http.StatusOK)
|
||||||
}
|
return
|
||||||
|
}
|
||||||
|
|
||||||
switch r.URL.Path {
|
switch r.URL.Path {
|
||||||
case "/":
|
case "/":
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
case "/v1/web/not-started":
|
case "/v1/web/not-started":
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case "POST":
|
case http.MethodPost:
|
||||||
notStartedHandler(w, r)
|
notStartedHandler(w, r)
|
||||||
default:
|
default:
|
||||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
}
|
}
|
||||||
case "/v1/web/start":
|
case "/v1/web/start":
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case "POST":
|
case http.MethodPost:
|
||||||
startSessionHandlerWeb(w, r)
|
startSessionHandlerWeb(w, r)
|
||||||
default:
|
default:
|
||||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
}
|
}
|
||||||
case "/v1/web/i":
|
case "/v1/web/i":
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case "POST":
|
case http.MethodPost:
|
||||||
pushMessagesSeparatelyHandler(w, r)
|
pushMessagesSeparatelyHandler(w, r)
|
||||||
default:
|
default:
|
||||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
}
|
}
|
||||||
// case "/v1/ios/start":
|
// case "/v1/ios/start":
|
||||||
// switch r.Method {
|
// switch r.Method {
|
||||||
// case "POST":
|
// case http.MethodPost:
|
||||||
// startSessionHandlerIOS(w, r)
|
// startSessionHandlerIOS(w, r)
|
||||||
// default:
|
// default:
|
||||||
// w.WriteHeader(http.StatusMethodNotAllowed)
|
// w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
// }
|
// }
|
||||||
// case "/v1/ios/append":
|
// case "/v1/ios/append":
|
||||||
// switch r.Method {
|
// switch r.Method {
|
||||||
// case "POST":
|
// case http.MethodPost:
|
||||||
// pushMessagesHandler(w, r)
|
// pushMessagesHandler(w, r)
|
||||||
// default:
|
// default:
|
||||||
// w.WriteHeader(http.StatusMethodNotAllowed)
|
// w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
// }
|
// }
|
||||||
// case "/v1/ios/late":
|
// case "/v1/ios/late":
|
||||||
// switch r.Method {
|
// switch r.Method {
|
||||||
// case "POST":
|
// case http.MethodPost:
|
||||||
// pushLateMessagesHandler(w, r)
|
// pushLateMessagesHandler(w, r)
|
||||||
// default:
|
// default:
|
||||||
// w.WriteHeader(http.StatusMethodNotAllowed)
|
// w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
// }
|
// }
|
||||||
// case "/v1/ios/images":
|
// case "/v1/ios/images":
|
||||||
// switch r.Method {
|
// switch r.Method {
|
||||||
// case "POST":
|
// case http.MethodPost:
|
||||||
// iosImagesUploadHandler(w, r)
|
// iosImagesUploadHandler(w, r)
|
||||||
// default:
|
// default:
|
||||||
// w.WriteHeader(http.StatusMethodNotAllowed)
|
// w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,9 @@
|
||||||
"idp_entityId": "",
|
"idp_entityId": "",
|
||||||
"idp_sso_url": "",
|
"idp_sso_url": "",
|
||||||
"idp_x509cert": "",
|
"idp_x509cert": "",
|
||||||
"idp_sls_url": ""
|
"idp_sls_url": "",
|
||||||
|
"invitation_link": "/api/users/invitation?token=%s",
|
||||||
|
"change_password_link": "/reset-password?invitation=%s&&pass=%s"
|
||||||
},
|
},
|
||||||
"lambda_timeout": 150,
|
"lambda_timeout": 150,
|
||||||
"lambda_memory_size": 400,
|
"lambda_memory_size": 400,
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,12 @@ def or_middleware(event, get_response):
|
||||||
import time
|
import time
|
||||||
now = int(time.time() * 1000)
|
now = int(time.time() * 1000)
|
||||||
response = get_response(event)
|
response = get_response(event)
|
||||||
if response.status_code == 500 and helper.allow_sentry() and OR_SESSION_TOKEN is not None and not helper.is_local():
|
if response.status_code == 200 and response.body is not None and response.body.get("errors") is not None:
|
||||||
|
if "not found" in response.body["errors"][0]:
|
||||||
|
response = Response(status_code=404, body=response.body)
|
||||||
|
else:
|
||||||
|
response = Response(status_code=400, body=response.body)
|
||||||
|
if response.status_code // 100 == 5 and helper.allow_sentry() and OR_SESSION_TOKEN is not None and not helper.is_local():
|
||||||
with configure_scope() as scope:
|
with configure_scope() as scope:
|
||||||
scope.set_tag('stage', environ["stage"])
|
scope.set_tag('stage', environ["stage"])
|
||||||
scope.set_tag('openReplaySessionToken', OR_SESSION_TOKEN)
|
scope.set_tag('openReplaySessionToken', OR_SESSION_TOKEN)
|
||||||
|
|
|
||||||
|
|
@ -38,9 +38,9 @@ def login():
|
||||||
for_plugin=False
|
for_plugin=False
|
||||||
)
|
)
|
||||||
if r is None:
|
if r is None:
|
||||||
return {
|
return Response(status_code=401, body={
|
||||||
'errors': ['You’ve entered invalid Email or Password.']
|
'errors': ['You’ve entered invalid Email or Password.']
|
||||||
}
|
})
|
||||||
elif "errors" in r:
|
elif "errors" in r:
|
||||||
return r
|
return r
|
||||||
|
|
||||||
|
|
@ -103,8 +103,11 @@ def create_edit_project(projectId, context):
|
||||||
|
|
||||||
@app.route('/projects/{projectId}', methods=['GET'])
|
@app.route('/projects/{projectId}', methods=['GET'])
|
||||||
def get_project(projectId, context):
|
def get_project(projectId, context):
|
||||||
return {"data": projects.get_project(tenant_id=context["tenantId"], project_id=projectId, include_last_session=True,
|
data = projects.get_project(tenant_id=context["tenantId"], project_id=projectId, include_last_session=True,
|
||||||
include_gdpr=True)}
|
include_gdpr=True)
|
||||||
|
if data is None:
|
||||||
|
return {"errors": ["project not found"]}
|
||||||
|
return {"data": data}
|
||||||
|
|
||||||
|
|
||||||
@app.route('/projects/{projectId}', methods=['DELETE'])
|
@app.route('/projects/{projectId}', methods=['DELETE'])
|
||||||
|
|
@ -359,6 +362,38 @@ def add_member(context):
|
||||||
return users.create_member(tenant_id=context['tenantId'], user_id=context['userId'], data=data)
|
return users.create_member(tenant_id=context['tenantId'], user_id=context['userId'], data=data)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/users/invitation', methods=['GET'], authorizer=None)
|
||||||
|
def process_invitation_link():
|
||||||
|
params = app.current_request.query_params
|
||||||
|
if params is None or len(params.get("token", "")) < 64:
|
||||||
|
return {"errors": ["please provide a valid invitation"]}
|
||||||
|
user = users.get_by_invitation_token(params["token"])
|
||||||
|
if user is None:
|
||||||
|
return {"errors": ["invitation not found"]}
|
||||||
|
if user["expiredInvitation"]:
|
||||||
|
return {"errors": ["expired invitation, please ask your admin to send a new one"]}
|
||||||
|
pass_token = users.allow_password_change(user_id=user["userId"])
|
||||||
|
return Response(
|
||||||
|
status_code=307,
|
||||||
|
body='',
|
||||||
|
headers={'Location': environ["SITE_URL"] + environ["change_password_link"] % (params["token"], pass_token),
|
||||||
|
'Content-Type': 'text/plain'})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/users/invitation/password', methods=['POST', 'PUT'], authorizer=None)
|
||||||
|
def change_password_by_invitation():
|
||||||
|
data = app.current_request.json_body
|
||||||
|
if data is None or len(data.get("invitation", "")) < 64 or len(data.get("pass", "")) < 8:
|
||||||
|
return {"errors": ["please provide a valid invitation & pass"]}
|
||||||
|
user = users.get_by_invitation_token(token=data["token"], pass_token=data["pass"])
|
||||||
|
if user is None:
|
||||||
|
return {"errors": ["invitation not found"]}
|
||||||
|
if user["expiredChange"]:
|
||||||
|
return {"errors": ["expired change, please re-use the invitation link"]}
|
||||||
|
|
||||||
|
return users.set_password_invitation(new_password=data["password"], user_id=user["userId"])
|
||||||
|
|
||||||
|
|
||||||
@app.route('/client/members/{memberId}', methods=['PUT', 'POST'])
|
@app.route('/client/members/{memberId}', methods=['PUT', 'POST'])
|
||||||
def edit_member(memberId, context):
|
def edit_member(memberId, context):
|
||||||
data = app.current_request.json_body
|
data = app.current_request.json_body
|
||||||
|
|
@ -366,6 +401,11 @@ def edit_member(memberId, context):
|
||||||
user_id_to_update=memberId)
|
user_id_to_update=memberId)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/client/members/{memberId}/reset', methods=['GET'])
|
||||||
|
def reset_reinvite_member(memberId, context):
|
||||||
|
return users.reset_member(tenant_id=context['tenantId'], editor_id=context['userId'], user_id_to_update=memberId)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/client/members/{memberId}', methods=['DELETE'])
|
@app.route('/client/members/{memberId}', methods=['DELETE'])
|
||||||
def delete_member(memberId, context):
|
def delete_member(memberId, context):
|
||||||
return users.delete_member(tenant_id=context["tenantId"], user_id=context['userId'], id_to_delete=memberId)
|
return users.delete_member(tenant_id=context["tenantId"], user_id=context['userId'], id_to_delete=memberId)
|
||||||
|
|
|
||||||
|
|
@ -250,7 +250,7 @@ def get_details(project_id, error_id, user_id, **data):
|
||||||
# print("--------------------")
|
# print("--------------------")
|
||||||
row = ch.execute(query=main_ch_query, params=params)
|
row = ch.execute(query=main_ch_query, params=params)
|
||||||
if len(row) == 0:
|
if len(row) == 0:
|
||||||
return {"errors": ["error doesn't exist"]}
|
return {"errors": ["error not found"]}
|
||||||
row = row[0]
|
row = row[0]
|
||||||
row["tags"] = __process_tags(row)
|
row["tags"] = __process_tags(row)
|
||||||
with pg_client.PostgresClient() as cur:
|
with pg_client.PostgresClient() as cur:
|
||||||
|
|
@ -406,7 +406,7 @@ def get_details_chart(project_id, error_id, user_id, **data):
|
||||||
# print(main_ch_query % params)
|
# print(main_ch_query % params)
|
||||||
row = ch.execute(query=main_ch_query, params=params)
|
row = ch.execute(query=main_ch_query, params=params)
|
||||||
if len(row) == 0:
|
if len(row) == 0:
|
||||||
return {"errors": ["error doesn't exist"]}
|
return {"errors": ["error not found"]}
|
||||||
row = row[0]
|
row = row[0]
|
||||||
row["tags"] = __process_tags(row)
|
row["tags"] = __process_tags(row)
|
||||||
row["chart"] = __rearrange_chart_details(start_at=data["startDate"], end_at=data["endDate"], density=density,
|
row["chart"] = __rearrange_chart_details(start_at=data["startDate"], end_at=data["endDate"], density=density,
|
||||||
|
|
|
||||||
|
|
@ -151,7 +151,7 @@ def delete(project_id, funnel_id, user_id):
|
||||||
def get_sessions(project_id, funnel_id, user_id, range_value=None, start_date=None, end_date=None):
|
def get_sessions(project_id, funnel_id, user_id, range_value=None, start_date=None, end_date=None):
|
||||||
f = get(funnel_id=funnel_id, project_id=project_id)
|
f = get(funnel_id=funnel_id, project_id=project_id)
|
||||||
if f is None:
|
if f is None:
|
||||||
return {"errors": ["filter not found"]}
|
return {"errors": ["funnel not found"]}
|
||||||
get_start_end_time(filter_d=f["filter"], range_value=range_value, start_date=start_date, end_date=end_date)
|
get_start_end_time(filter_d=f["filter"], range_value=range_value, start_date=start_date, end_date=end_date)
|
||||||
return sessions.search2_pg(data=f["filter"], project_id=project_id, user_id=user_id)
|
return sessions.search2_pg(data=f["filter"], project_id=project_id, user_id=user_id)
|
||||||
|
|
||||||
|
|
@ -172,12 +172,12 @@ def get_sessions_on_the_fly(funnel_id, project_id, user_id, data):
|
||||||
def get_top_insights(project_id, funnel_id, range_value=None, start_date=None, end_date=None):
|
def get_top_insights(project_id, funnel_id, range_value=None, start_date=None, end_date=None):
|
||||||
f = get(funnel_id=funnel_id, project_id=project_id)
|
f = get(funnel_id=funnel_id, project_id=project_id)
|
||||||
if f is None:
|
if f is None:
|
||||||
return {"errors": ["filter not found"]}
|
return {"errors": ["funnel not found"]}
|
||||||
get_start_end_time(filter_d=f["filter"], range_value=range_value, start_date=start_date, end_date=end_date)
|
get_start_end_time(filter_d=f["filter"], range_value=range_value, start_date=start_date, end_date=end_date)
|
||||||
insights, total_drop_due_to_issues = significance.get_top_insights(filter_d=f["filter"], project_id=project_id)
|
insights, total_drop_due_to_issues = significance.get_top_insights(filter_d=f["filter"], project_id=project_id)
|
||||||
insights[-1]["dropDueToIssues"] = total_drop_due_to_issues
|
insights[-1]["dropDueToIssues"] = total_drop_due_to_issues
|
||||||
return {"stages": helper.list_to_camel_case(insights),
|
return {"data": {"stages": helper.list_to_camel_case(insights),
|
||||||
"totalDropDueToIssues": total_drop_due_to_issues}
|
"totalDropDueToIssues": total_drop_due_to_issues}}
|
||||||
|
|
||||||
|
|
||||||
def get_top_insights_on_the_fly(funnel_id, project_id, data):
|
def get_top_insights_on_the_fly(funnel_id, project_id, data):
|
||||||
|
|
@ -193,8 +193,8 @@ def get_top_insights_on_the_fly(funnel_id, project_id, data):
|
||||||
insights, total_drop_due_to_issues = significance.get_top_insights(filter_d=data, project_id=project_id)
|
insights, total_drop_due_to_issues = significance.get_top_insights(filter_d=data, project_id=project_id)
|
||||||
if len(insights) > 0:
|
if len(insights) > 0:
|
||||||
insights[-1]["dropDueToIssues"] = total_drop_due_to_issues
|
insights[-1]["dropDueToIssues"] = total_drop_due_to_issues
|
||||||
return {"stages": helper.list_to_camel_case(insights),
|
return {"data": {"stages": helper.list_to_camel_case(insights),
|
||||||
"totalDropDueToIssues": total_drop_due_to_issues}
|
"totalDropDueToIssues": total_drop_due_to_issues}}
|
||||||
|
|
||||||
|
|
||||||
def get_issues(project_id, funnel_id, range_value=None, start_date=None, end_date=None):
|
def get_issues(project_id, funnel_id, range_value=None, start_date=None, end_date=None):
|
||||||
|
|
@ -272,4 +272,4 @@ def search_by_issue(user_id, project_id, funnel_id, issue_id, data, range_value=
|
||||||
data=data) if issue is not None else {"total": 0, "sessions": []},
|
data=data) if issue is not None else {"total": 0, "sessions": []},
|
||||||
# "stages": helper.list_to_camel_case(insights),
|
# "stages": helper.list_to_camel_case(insights),
|
||||||
# "totalDropDueToIssues": total_drop_due_to_issues,
|
# "totalDropDueToIssues": total_drop_due_to_issues,
|
||||||
"issue": issue}
|
"issue": issue}
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,10 @@ def get(project_id):
|
||||||
)
|
)
|
||||||
metas = cur.fetchone()
|
metas = cur.fetchone()
|
||||||
results = []
|
results = []
|
||||||
for i, k in enumerate(metas.keys()):
|
if metas is not None:
|
||||||
if metas[k] is not None:
|
for i, k in enumerate(metas.keys()):
|
||||||
results.append({"key": metas[k], "index": i + 1})
|
if metas[k] is not None:
|
||||||
|
results.append({"key": metas[k], "index": i + 1})
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -56,7 +57,7 @@ def __edit(project_id, col_index, colname, new_name):
|
||||||
old_metas = get(project_id)
|
old_metas = get(project_id)
|
||||||
old_metas = {k["index"]: k for k in old_metas}
|
old_metas = {k["index"]: k for k in old_metas}
|
||||||
if col_index not in list(old_metas.keys()):
|
if col_index not in list(old_metas.keys()):
|
||||||
return {"errors": ["custom field doesn't exist"]}
|
return {"errors": ["custom field not found"]}
|
||||||
|
|
||||||
with pg_client.PostgresClient() as cur:
|
with pg_client.PostgresClient() as cur:
|
||||||
if old_metas[col_index]["key"].lower() != new_name:
|
if old_metas[col_index]["key"].lower() != new_name:
|
||||||
|
|
@ -79,7 +80,7 @@ def delete(tenant_id, project_id, index: int):
|
||||||
old_segments = get(project_id)
|
old_segments = get(project_id)
|
||||||
old_segments = [k["index"] for k in old_segments]
|
old_segments = [k["index"] for k in old_segments]
|
||||||
if index not in old_segments:
|
if index not in old_segments:
|
||||||
return {"errors": ["custom field doesn't exist"]}
|
return {"errors": ["custom field not found"]}
|
||||||
|
|
||||||
with pg_client.PostgresClient() as cur:
|
with pg_client.PostgresClient() as cur:
|
||||||
colname = index_to_colname(index)
|
colname = index_to_colname(index)
|
||||||
|
|
@ -136,7 +137,7 @@ def search(tenant_id, project_id, key, value):
|
||||||
key = c
|
key = c
|
||||||
break
|
break
|
||||||
if key is None:
|
if key is None:
|
||||||
return {"errors": ["key does not exist"]}
|
return {"errors": ["key not found"]}
|
||||||
cur.execute(
|
cur.execute(
|
||||||
cur.mogrify(
|
cur.mogrify(
|
||||||
f"""\
|
f"""\
|
||||||
|
|
@ -259,4 +260,4 @@ def get_remaining_metadata_with_count(tenant_id):
|
||||||
remaining = MAX_INDEXES - len(used_metas)
|
remaining = MAX_INDEXES - len(used_metas)
|
||||||
results.append({**p, "limit": MAX_INDEXES, "remaining": remaining, "count": len(used_metas)})
|
results.append({**p, "limit": MAX_INDEXES, "remaining": remaining, "count": len(used_metas)})
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,4 @@
|
||||||
import chalicelib.utils.TimeUTC
|
|
||||||
from chalicelib.utils import email_helper, captcha, helper
|
from chalicelib.utils import email_helper, captcha, helper
|
||||||
import secrets
|
|
||||||
from chalicelib.utils import pg_client
|
|
||||||
|
|
||||||
from chalicelib.core import users
|
from chalicelib.core import users
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -18,49 +14,23 @@ def step1(data):
|
||||||
a_users = users.get_by_email_only(data["email"])
|
a_users = users.get_by_email_only(data["email"])
|
||||||
if len(a_users) > 1:
|
if len(a_users) > 1:
|
||||||
print(f"multiple users found for [{data['email']}] please contact our support")
|
print(f"multiple users found for [{data['email']}] please contact our support")
|
||||||
return {"errors": ["please contact our support"]}
|
return {"errors": ["multiple users, please contact our support"]}
|
||||||
elif len(a_users) == 1:
|
elif len(a_users) == 1:
|
||||||
a_users = a_users[0]
|
a_users = a_users[0]
|
||||||
reset_token = secrets.token_urlsafe(6)
|
invitation_link = users.generate_new_invitation(user_id=a_users["id"])
|
||||||
users.update(tenant_id=a_users["tenantId"], user_id=a_users["id"],
|
email_helper.send_forgot_password(recipient=data["email"], invitation_link=invitation_link)
|
||||||
changes={"token": reset_token})
|
|
||||||
email_helper.send_reset_code(recipient=data["email"], reset_code=reset_token)
|
|
||||||
else:
|
else:
|
||||||
print(f"invalid email address [{data['email']}]")
|
print(f"invalid email address [{data['email']}]")
|
||||||
return {"errors": ["invalid email address"]}
|
return {"errors": ["invalid email address"]}
|
||||||
return {"data": {"state": "success"}}
|
return {"data": {"state": "success"}}
|
||||||
|
|
||||||
|
# def step2(data):
|
||||||
def step2(data):
|
# print("====================== change password 2 ===============")
|
||||||
print("====================== change password 2 ===============")
|
# user = users.get_by_email_reset(data["email"], data["code"])
|
||||||
user = users.get_by_email_reset(data["email"], data["code"])
|
# if not user:
|
||||||
if not user:
|
# print("error: wrong email or reset code")
|
||||||
print("error: wrong email or reset code")
|
# return {"errors": ["wrong email or reset code"]}
|
||||||
return {"errors": ["wrong email or reset code"]}
|
# users.update(tenant_id=user["tenantId"], user_id=user["id"],
|
||||||
users.update(tenant_id=user["tenantId"], user_id=user["id"],
|
# changes={"token": None, "password": data["password"], "generatedPassword": False,
|
||||||
changes={"token": None, "password": data["password"], "generatedPassword": False,
|
# "verifiedEmail": True})
|
||||||
"verifiedEmail": True})
|
# return {"data": {"state": "success"}}
|
||||||
return {"data": {"state": "success"}}
|
|
||||||
|
|
||||||
|
|
||||||
def cron():
|
|
||||||
with pg_client.PostgresClient() as cur:
|
|
||||||
cur.execute(
|
|
||||||
cur.mogrify("""\
|
|
||||||
SELECT user_id
|
|
||||||
FROM public.basic_authentication
|
|
||||||
WHERE token notnull
|
|
||||||
AND (token_requested_at isnull or (EXTRACT(EPOCH FROM token_requested_at)*1000)::BIGINT < %(time)s);""",
|
|
||||||
{"time": chalicelib.utils.TimeUTC.TimeUTC.now(delta_days=-1)})
|
|
||||||
)
|
|
||||||
results = cur.fetchall()
|
|
||||||
if len(results) == 0:
|
|
||||||
return
|
|
||||||
results = tuple([r["user_id"] for r in results])
|
|
||||||
cur.execute(
|
|
||||||
cur.mogrify("""\
|
|
||||||
UPDATE public.basic_authentication
|
|
||||||
SET token = NULL, token_requested_at = NULL
|
|
||||||
WHERE user_id in %(ids)s;""",
|
|
||||||
{"ids": results})
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ def create_step1(data):
|
||||||
if len(signed_ups) == 0 and data.get("tenantId") is not None \
|
if len(signed_ups) == 0 and data.get("tenantId") is not None \
|
||||||
or len(signed_ups) > 0 and data.get("tenantId") is not None\
|
or len(signed_ups) > 0 and data.get("tenantId") is not None\
|
||||||
and data.get("tenantId") not in [t['tenantId'] for t in signed_ups]:
|
and data.get("tenantId") not in [t['tenantId'] for t in signed_ups]:
|
||||||
errors.append("Tenant does not exist")
|
errors.append("Tenant not found")
|
||||||
if len(errors) > 0:
|
if len(errors) > 0:
|
||||||
print("==> error")
|
print("==> error")
|
||||||
print(errors)
|
print(errors)
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,17 @@ from chalicelib.core import authorizers
|
||||||
from chalicelib.core import tenants
|
from chalicelib.core import tenants
|
||||||
from chalicelib.utils import helper
|
from chalicelib.utils import helper
|
||||||
from chalicelib.utils import pg_client
|
from chalicelib.utils import pg_client
|
||||||
|
from chalicelib.utils import dev
|
||||||
from chalicelib.utils.TimeUTC import TimeUTC
|
from chalicelib.utils.TimeUTC import TimeUTC
|
||||||
from chalicelib.utils.helper import environ
|
from chalicelib.utils.helper import environ
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
|
||||||
def create_new_member(tenant_id, email, password, admin, name, owner=False):
|
def __generate_invitation_token():
|
||||||
|
return secrets.token_urlsafe(64)
|
||||||
|
|
||||||
|
|
||||||
|
def create_new_member(tenant_id, email, invitation_token, admin, name, owner=False):
|
||||||
with pg_client.PostgresClient() as cur:
|
with pg_client.PostgresClient() as cur:
|
||||||
query = cur.mogrify(f"""\
|
query = cur.mogrify(f"""\
|
||||||
WITH u AS (
|
WITH u AS (
|
||||||
|
|
@ -16,10 +22,12 @@ def create_new_member(tenant_id, email, password, admin, name, owner=False):
|
||||||
VALUES (%(tenantId)s, %(email)s, %(role)s, %(name)s, %(data)s)
|
VALUES (%(tenantId)s, %(email)s, %(role)s, %(name)s, %(data)s)
|
||||||
RETURNING user_id,email,role,name,appearance
|
RETURNING user_id,email,role,name,appearance
|
||||||
),
|
),
|
||||||
au AS (INSERT
|
au AS (INSERT INTO public.basic_authentication (user_id, generated_password, invitation_token, invited_at)
|
||||||
INTO public.basic_authentication (user_id, password, generated_password)
|
VALUES ((SELECT user_id FROM u), TRUE, %(invitation_token)s, timezone('utc'::text, now()))
|
||||||
VALUES ((SELECT user_id FROM u), crypt(%(password)s, gen_salt('bf', 12)), TRUE))
|
RETURNING invitation_token
|
||||||
|
)
|
||||||
SELECT u.user_id AS id,
|
SELECT u.user_id AS id,
|
||||||
|
u.user_id,
|
||||||
u.email,
|
u.email,
|
||||||
u.role,
|
u.role,
|
||||||
u.name,
|
u.name,
|
||||||
|
|
@ -27,18 +35,19 @@ def create_new_member(tenant_id, email, password, admin, name, owner=False):
|
||||||
(CASE WHEN u.role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin,
|
(CASE WHEN u.role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin,
|
||||||
(CASE WHEN u.role = 'admin' THEN TRUE ELSE FALSE END) AS admin,
|
(CASE WHEN u.role = 'admin' THEN TRUE ELSE FALSE END) AS admin,
|
||||||
(CASE WHEN u.role = 'member' THEN TRUE ELSE FALSE END) AS member,
|
(CASE WHEN u.role = 'member' THEN TRUE ELSE FALSE END) AS member,
|
||||||
u.appearance
|
au.invitation_token
|
||||||
FROM u;""",
|
FROM u,au;""",
|
||||||
{"tenantId": tenant_id, "email": email, "password": password,
|
{"tenantId": tenant_id, "email": email,
|
||||||
"role": "owner" if owner else "admin" if admin else "member", "name": name,
|
"role": "owner" if owner else "admin" if admin else "member", "name": name,
|
||||||
"data": json.dumps({"lastAnnouncementView": TimeUTC.now()})})
|
"data": json.dumps({"lastAnnouncementView": TimeUTC.now()}),
|
||||||
|
"invitation_token": invitation_token})
|
||||||
cur.execute(
|
cur.execute(
|
||||||
query
|
query
|
||||||
)
|
)
|
||||||
return helper.dict_to_camel_case(cur.fetchone())
|
return helper.dict_to_camel_case(cur.fetchone())
|
||||||
|
|
||||||
|
|
||||||
def restore_member(tenant_id, user_id, email, password, admin, name, owner=False):
|
def restore_member(tenant_id, user_id, email, invitation_token, admin, name, owner=False):
|
||||||
with pg_client.PostgresClient() as cur:
|
with pg_client.PostgresClient() as cur:
|
||||||
query = cur.mogrify(f"""\
|
query = cur.mogrify(f"""\
|
||||||
UPDATE public.users
|
UPDATE public.users
|
||||||
|
|
@ -56,31 +65,62 @@ def restore_member(tenant_id, user_id, email, password, admin, name, owner=False
|
||||||
TRUE AS change_password,
|
TRUE AS change_password,
|
||||||
(CASE WHEN role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin,
|
(CASE WHEN role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin,
|
||||||
(CASE WHEN role = 'admin' THEN TRUE ELSE FALSE END) AS admin,
|
(CASE WHEN role = 'admin' THEN TRUE ELSE FALSE END) AS admin,
|
||||||
(CASE WHEN role = 'member' THEN TRUE ELSE FALSE END) AS member,
|
(CASE WHEN role = 'member' THEN TRUE ELSE FALSE END) AS member;""",
|
||||||
appearance;""",
|
|
||||||
{"tenant_id": tenant_id, "user_id": user_id, "email": email,
|
{"tenant_id": tenant_id, "user_id": user_id, "email": email,
|
||||||
"role": "owner" if owner else "admin" if admin else "member", "name": name})
|
"role": "owner" if owner else "admin" if admin else "member", "name": name})
|
||||||
cur.execute(
|
cur.execute(
|
||||||
query
|
query
|
||||||
)
|
)
|
||||||
result = helper.dict_to_camel_case(cur.fetchone())
|
result = cur.fetchone()
|
||||||
query = cur.mogrify("""\
|
query = cur.mogrify("""\
|
||||||
UPDATE public.basic_authentication
|
UPDATE public.basic_authentication
|
||||||
SET password= crypt(%(password)s, gen_salt('bf', 12)),
|
SET generated_password = TRUE,
|
||||||
generated_password= TRUE,
|
invitation_token = %(invitation_token)s,
|
||||||
token=NULL,
|
invited_at = timezone('utc'::text, now()),
|
||||||
token_requested_at=NULL
|
change_pwd_expire_at = NULL,
|
||||||
WHERE user_id=%(user_id)s;""",
|
change_pwd_token = NULL
|
||||||
{"user_id": user_id, "password": password})
|
WHERE user_id=%(user_id)s
|
||||||
|
RETURNING invitation_token;""",
|
||||||
|
{"user_id": user_id, "invitation_token": invitation_token})
|
||||||
cur.execute(
|
cur.execute(
|
||||||
query
|
query
|
||||||
)
|
)
|
||||||
|
result["invitation_token"] = cur.fetchone()["invitation_token"]
|
||||||
|
|
||||||
return result
|
return helper.dict_to_camel_case(result)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_new_invitation(user_id):
|
||||||
|
invitation_token = __generate_invitation_token()
|
||||||
|
with pg_client.PostgresClient() as cur:
|
||||||
|
query = cur.mogrify("""\
|
||||||
|
UPDATE public.basic_authentication
|
||||||
|
SET invitation_token = %(invitation_token)s,
|
||||||
|
invited_at = timezone('utc'::text, now()),
|
||||||
|
change_pwd_expire_at = NULL,
|
||||||
|
change_pwd_token = NULL
|
||||||
|
WHERE user_id=%(user_id)s
|
||||||
|
RETURNING invitation_token;""",
|
||||||
|
{"user_id": user_id, "invitation_token": invitation_token})
|
||||||
|
cur.execute(
|
||||||
|
query
|
||||||
|
)
|
||||||
|
return __get_invitation_link(cur.fetchone().pop("invitation_token"))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def reset_member(tenant_id, editor_id, user_id_to_update):
|
||||||
|
admin = get(tenant_id=tenant_id, user_id=editor_id)
|
||||||
|
if not admin["admin"] and not admin["superAdmin"]:
|
||||||
|
return {"errors": ["unauthorized"]}
|
||||||
|
user = get(tenant_id=tenant_id, user_id=user_id_to_update)
|
||||||
|
if not user:
|
||||||
|
return {"errors": ["user not found"]}
|
||||||
|
return {"data": {"invitationLink": generate_new_invitation(user_id_to_update)}}
|
||||||
|
|
||||||
|
|
||||||
def update(tenant_id, user_id, changes):
|
def update(tenant_id, user_id, changes):
|
||||||
AUTH_KEYS = ["password", "generatedPassword", "token"]
|
AUTH_KEYS = ["password", "generatedPassword", "invitationToken", "invitedAt", "changePwdExpireAt", "changePwdToken"]
|
||||||
if len(changes.keys()) == 0:
|
if len(changes.keys()) == 0:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -91,13 +131,6 @@ def update(tenant_id, user_id, changes):
|
||||||
if key == "password":
|
if key == "password":
|
||||||
sub_query_bauth.append("password = crypt(%(password)s, gen_salt('bf', 12))")
|
sub_query_bauth.append("password = crypt(%(password)s, gen_salt('bf', 12))")
|
||||||
sub_query_bauth.append("changed_at = timezone('utc'::text, now())")
|
sub_query_bauth.append("changed_at = timezone('utc'::text, now())")
|
||||||
elif key == "token":
|
|
||||||
if changes[key] is not None:
|
|
||||||
sub_query_bauth.append("token = %(token)s")
|
|
||||||
sub_query_bauth.append("token_requested_at = timezone('utc'::text, now())")
|
|
||||||
else:
|
|
||||||
sub_query_bauth.append("token = NULL")
|
|
||||||
sub_query_bauth.append("token_requested_at = NULL")
|
|
||||||
else:
|
else:
|
||||||
sub_query_bauth.append(f"{helper.key_to_snake_case(key)} = %({key})s")
|
sub_query_bauth.append(f"{helper.key_to_snake_case(key)} = %({key})s")
|
||||||
else:
|
else:
|
||||||
|
|
@ -166,26 +199,43 @@ def create_member(tenant_id, user_id, data):
|
||||||
return {"errors": ["invalid user name"]}
|
return {"errors": ["invalid user name"]}
|
||||||
if name is None:
|
if name is None:
|
||||||
name = data["email"]
|
name = data["email"]
|
||||||
temp_pass = helper.generate_salt()[:8]
|
invitation_token = __generate_invitation_token()
|
||||||
user = get_deleted_user_by_email(email=data["email"])
|
user = get_deleted_user_by_email(email=data["email"])
|
||||||
if user is not None:
|
if user is not None:
|
||||||
new_member = restore_member(tenant_id=tenant_id, email=data["email"], password=temp_pass,
|
new_member = restore_member(tenant_id=tenant_id, email=data["email"], invitation_token=invitation_token,
|
||||||
admin=data.get("admin", False), name=name, user_id=user["userId"])
|
admin=data.get("admin", False), name=name, user_id=user["userId"])
|
||||||
else:
|
else:
|
||||||
new_member = create_new_member(tenant_id=tenant_id, email=data["email"], password=temp_pass,
|
new_member = create_new_member(tenant_id=tenant_id, email=data["email"], invitation_token=invitation_token,
|
||||||
admin=data.get("admin", False), name=name)
|
admin=data.get("admin", False), name=name)
|
||||||
|
new_member["invitationLink"] = __get_invitation_link(new_member.pop("invitationToken"))
|
||||||
helper.async_post(environ['email_basic'] % 'member_invitation',
|
helper.async_post(environ['email_basic'] % 'member_invitation',
|
||||||
{
|
{
|
||||||
"email": data["email"],
|
"email": data["email"],
|
||||||
"userName": data["email"],
|
"invitationLink": new_member["invitationLink"],
|
||||||
"tempPassword": temp_pass,
|
|
||||||
"clientId": tenants.get_by_tenant_id(tenant_id)["name"],
|
"clientId": tenants.get_by_tenant_id(tenant_id)["name"],
|
||||||
"senderName": admin["name"]
|
"senderName": admin["name"]
|
||||||
})
|
})
|
||||||
return {"data": new_member}
|
return {"data": new_member}
|
||||||
|
|
||||||
|
|
||||||
|
def __get_invitation_link(invitation_token):
|
||||||
|
return environ["SITE_URL"] + environ["invitation_link"] % invitation_token
|
||||||
|
|
||||||
|
|
||||||
|
def allow_password_change(user_id, delta_min=10):
|
||||||
|
pass_token = secrets.token_urlsafe(8)
|
||||||
|
with pg_client.PostgresClient() as cur:
|
||||||
|
query = cur.mogrify(f"""UPDATE public.basic_authentication
|
||||||
|
SET change_pwd_expire_at = timezone('utc'::text, now()+INTERVAL '%(delta)s MINUTES'),
|
||||||
|
change_pwd_token = %(pass_token)s
|
||||||
|
WHERE user_id = %(user_id)s""",
|
||||||
|
{"user_id": user_id, "delta": delta_min, "pass_token": pass_token})
|
||||||
|
cur.execute(
|
||||||
|
query
|
||||||
|
)
|
||||||
|
return pass_token
|
||||||
|
|
||||||
|
|
||||||
def get(user_id, tenant_id):
|
def get(user_id, tenant_id):
|
||||||
with pg_client.PostgresClient() as cur:
|
with pg_client.PostgresClient() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
|
|
@ -321,7 +371,11 @@ def get_members(tenant_id):
|
||||||
basic_authentication.generated_password,
|
basic_authentication.generated_password,
|
||||||
(CASE WHEN users.role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin,
|
(CASE WHEN users.role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin,
|
||||||
(CASE WHEN users.role = 'admin' THEN TRUE ELSE FALSE END) AS admin,
|
(CASE WHEN users.role = 'admin' THEN TRUE ELSE FALSE END) AS admin,
|
||||||
(CASE WHEN users.role = 'member' THEN TRUE ELSE FALSE END) AS member
|
(CASE WHEN users.role = 'member' THEN TRUE ELSE FALSE END) AS member,
|
||||||
|
DATE_PART('day',timezone('utc'::text, now()) \
|
||||||
|
- COALESCE(basic_authentication.invited_at,'2000-01-01'::timestamp ))>=1 AS expired_invitation,
|
||||||
|
basic_authentication.password IS NOT NULL AS joined,
|
||||||
|
invitation_token
|
||||||
FROM public.users LEFT JOIN public.basic_authentication ON users.user_id=basic_authentication.user_id
|
FROM public.users LEFT JOIN public.basic_authentication ON users.user_id=basic_authentication.user_id
|
||||||
WHERE users.tenant_id = %(tenantId)s AND users.deleted_at IS NULL
|
WHERE users.tenant_id = %(tenantId)s AND users.deleted_at IS NULL
|
||||||
ORDER BY name, id""",
|
ORDER BY name, id""",
|
||||||
|
|
@ -329,7 +383,13 @@ def get_members(tenant_id):
|
||||||
)
|
)
|
||||||
r = cur.fetchall()
|
r = cur.fetchall()
|
||||||
if len(r):
|
if len(r):
|
||||||
return helper.list_to_camel_case(r)
|
r = helper.list_to_camel_case(r)
|
||||||
|
for u in r:
|
||||||
|
if u["invitationToken"]:
|
||||||
|
u["invitationLink"] = __get_invitation_link(u.pop("invitationToken"))
|
||||||
|
else:
|
||||||
|
u["invitationLink"] = None
|
||||||
|
return r
|
||||||
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
@ -374,6 +434,15 @@ def change_password(tenant_id, user_id, email, old_password, new_password):
|
||||||
"jwt": authenticate(email, new_password)["jwt"]}
|
"jwt": authenticate(email, new_password)["jwt"]}
|
||||||
|
|
||||||
|
|
||||||
|
def set_password_invitation(user_id, new_password):
|
||||||
|
changes = {"password": new_password, "generatedPassword": False,
|
||||||
|
"invitationToken": None, "invitedAt": None,
|
||||||
|
"changePwdExpireAt": None, "changePwdToken": None}
|
||||||
|
user = update(tenant_id=-1, user_id=user_id, changes=changes)
|
||||||
|
return {"data": user,
|
||||||
|
"jwt": authenticate(user["email"], new_password)["jwt"]}
|
||||||
|
|
||||||
|
|
||||||
def count_members(tenant_id):
|
def count_members(tenant_id):
|
||||||
with pg_client.PostgresClient() as cur:
|
with pg_client.PostgresClient() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
|
|
@ -393,7 +462,7 @@ def email_exists(email):
|
||||||
cur.mogrify(
|
cur.mogrify(
|
||||||
f"""SELECT
|
f"""SELECT
|
||||||
count(user_id)
|
count(user_id)
|
||||||
FROM public.users
|
FROM public.users
|
||||||
WHERE
|
WHERE
|
||||||
email = %(email)s
|
email = %(email)s
|
||||||
AND deleted_at IS NULL
|
AND deleted_at IS NULL
|
||||||
|
|
@ -410,7 +479,7 @@ def get_deleted_user_by_email(email):
|
||||||
cur.mogrify(
|
cur.mogrify(
|
||||||
f"""SELECT
|
f"""SELECT
|
||||||
*
|
*
|
||||||
FROM public.users
|
FROM public.users
|
||||||
WHERE
|
WHERE
|
||||||
email = %(email)s
|
email = %(email)s
|
||||||
AND deleted_at NOTNULL
|
AND deleted_at NOTNULL
|
||||||
|
|
@ -421,6 +490,24 @@ def get_deleted_user_by_email(email):
|
||||||
return helper.dict_to_camel_case(r)
|
return helper.dict_to_camel_case(r)
|
||||||
|
|
||||||
|
|
||||||
|
def get_by_invitation_token(token, pass_token=None):
|
||||||
|
with pg_client.PostgresClient() as cur:
|
||||||
|
cur.execute(
|
||||||
|
cur.mogrify(
|
||||||
|
f"""SELECT
|
||||||
|
*,
|
||||||
|
DATE_PART('day',timezone('utc'::text, now()) \
|
||||||
|
- COALESCE(basic_authentication.invited_at,'2000-01-01'::timestamp ))>=1 AS expired_invitation,
|
||||||
|
change_pwd_expire_at <= timezone('utc'::text, now()) AS expired_change
|
||||||
|
FROM public.users INNER JOIN public.basic_authentication USING(user_id)
|
||||||
|
WHERE invitation_token = %(token)s {"AND change_pwd_token = %(pass_token)s" if pass_token else ""}
|
||||||
|
LIMIT 1;""",
|
||||||
|
{"token": token, "pass_token": token})
|
||||||
|
)
|
||||||
|
r = cur.fetchone()
|
||||||
|
return helper.dict_to_camel_case(r)
|
||||||
|
|
||||||
|
|
||||||
def auth_exists(user_id, tenant_id, jwt_iat, jwt_aud):
|
def auth_exists(user_id, tenant_id, jwt_iat, jwt_aud):
|
||||||
with pg_client.PostgresClient() as cur:
|
with pg_client.PostgresClient() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
|
|
@ -450,6 +537,7 @@ def change_jwt_iat(user_id):
|
||||||
return cur.fetchone().get("jwt_iat")
|
return cur.fetchone().get("jwt_iat")
|
||||||
|
|
||||||
|
|
||||||
|
@dev.timed
|
||||||
def authenticate(email, password, for_change_password=False, for_plugin=False):
|
def authenticate(email, password, for_change_password=False, for_plugin=False):
|
||||||
with pg_client.PostgresClient() as cur:
|
with pg_client.PostgresClient() as cur:
|
||||||
query = cur.mogrify(
|
query = cur.mogrify(
|
||||||
|
|
|
||||||
22
ee/scripts/helm/db/init_dbs/postgresql/1.3.0/1.3.0.sql
Normal file
22
ee/scripts/helm/db/init_dbs/postgresql/1.3.0/1.3.0.sql
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
BEGIN;
|
||||||
|
CREATE INDEX sessions_session_id_project_id_start_ts_durationNN_idx ON sessions (session_id, project_id, start_ts) WHERE duration IS NOT NULL;
|
||||||
|
CREATE INDEX clicks_label_session_id_timestamp_idx ON events.clicks (label, session_id, timestamp);
|
||||||
|
CREATE INDEX pages_base_path_session_id_timestamp_idx ON events.pages (base_path, session_id, timestamp);
|
||||||
|
CREATE INDEX ON unstarted_sessions (project_id);
|
||||||
|
CREATE INDEX ON assigned_sessions (session_id);
|
||||||
|
CREATE INDEX ON technical_info (session_id);
|
||||||
|
CREATE INDEX inputs_label_session_id_timestamp_idx ON events.inputs (label, session_id, timestamp);
|
||||||
|
|
||||||
|
CREATE INDEX clicks_url_idx ON events.clicks (url);
|
||||||
|
CREATE INDEX clicks_url_gin_idx ON events.clicks USING GIN (url gin_trgm_ops);
|
||||||
|
CREATE INDEX clicks_url_session_id_timestamp_selector_idx ON events.clicks (url, session_id, timestamp, selector);
|
||||||
|
|
||||||
|
ALTER TABLE public.basic_authentication
|
||||||
|
RENAME COLUMN token TO invitation_token;
|
||||||
|
ALTER TABLE public.basic_authentication
|
||||||
|
RENAME COLUMN token_requested_at TO invited_at;
|
||||||
|
ALTER TABLE public.basic_authentication
|
||||||
|
ADD COLUMN change_pwd_expire_at timestamp without time zone NULL DEFAULT NULL;
|
||||||
|
ALTER TABLE basic_authentication
|
||||||
|
ADD COLUMN change_pwd_token text NULL DEFAULT NULL;
|
||||||
|
COMMIT;
|
||||||
|
|
@ -60,7 +60,7 @@ CREATE TABLE users
|
||||||
"role": "dev",
|
"role": "dev",
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"cpu": true,
|
"cpu": true,
|
||||||
"fps": false,
|
"fps": false,
|
||||||
"avgCpu": true,
|
"avgCpu": true,
|
||||||
"avgFps": true,
|
"avgFps": true,
|
||||||
"errors": true,
|
"errors": true,
|
||||||
|
|
@ -121,19 +121,21 @@ CREATE TABLE users
|
||||||
jwt_iat timestamp without time zone NULL DEFAULT NULL,
|
jwt_iat timestamp without time zone NULL DEFAULT NULL,
|
||||||
data jsonb NOT NULL DEFAULT '{}'::jsonb,
|
data jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
weekly_report boolean NOT NULL DEFAULT TRUE,
|
weekly_report boolean NOT NULL DEFAULT TRUE,
|
||||||
origin user_origin NULL DEFAULT NULL,
|
origin user_origin NULL DEFAULT NULL,
|
||||||
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
CREATE TABLE basic_authentication
|
CREATE TABLE basic_authentication
|
||||||
(
|
(
|
||||||
user_id integer NOT NULL REFERENCES users (user_id) ON DELETE CASCADE,
|
user_id integer NOT NULL REFERENCES users (user_id) ON DELETE CASCADE,
|
||||||
password text DEFAULT NULL,
|
password text DEFAULT NULL,
|
||||||
generated_password boolean NOT NULL DEFAULT false,
|
generated_password boolean NOT NULL DEFAULT false,
|
||||||
token text NULL DEFAULT NULL,
|
invitation_token text NULL DEFAULT NULL,
|
||||||
token_requested_at timestamp without time zone NULL DEFAULT NULL,
|
invited_at timestamp without time zone NULL DEFAULT NULL,
|
||||||
changed_at timestamp,
|
change_pwd_token text NULL DEFAULT NULL,
|
||||||
|
change_pwd_expire_at timestamp without time zone NULL DEFAULT NULL,
|
||||||
|
changed_at timestamp,
|
||||||
UNIQUE (user_id)
|
UNIQUE (user_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -534,6 +536,8 @@ CREATE INDEX sessions_user_anonymous_id_gin_idx ON public.sessions USING GIN (us
|
||||||
CREATE INDEX sessions_user_country_gin_idx ON public.sessions (project_id, user_country);
|
CREATE INDEX sessions_user_country_gin_idx ON public.sessions (project_id, user_country);
|
||||||
CREATE INDEX ON sessions (project_id, user_country);
|
CREATE INDEX ON sessions (project_id, user_country);
|
||||||
CREATE INDEX ON sessions (project_id, user_browser);
|
CREATE INDEX ON sessions (project_id, user_browser);
|
||||||
|
CREATE INDEX sessions_session_id_project_id_start_ts_durationNN_idx ON sessions (session_id, project_id, start_ts) WHERE duration IS NOT NULL;
|
||||||
|
|
||||||
|
|
||||||
ALTER TABLE public.sessions
|
ALTER TABLE public.sessions
|
||||||
ADD CONSTRAINT web_browser_constraint CHECK ( (sessions.platform = 'web' AND sessions.user_browser NOTNULL) OR
|
ADD CONSTRAINT web_browser_constraint CHECK ( (sessions.platform = 'web' AND sessions.user_browser NOTNULL) OR
|
||||||
|
|
@ -574,6 +578,7 @@ create table assigned_sessions
|
||||||
created_at timestamp default timezone('utc'::text, now()) NOT NULL,
|
created_at timestamp default timezone('utc'::text, now()) NOT NULL,
|
||||||
provider_data jsonb default '{}'::jsonb NOT NULL
|
provider_data jsonb default '{}'::jsonb NOT NULL
|
||||||
);
|
);
|
||||||
|
CREATE INDEX ON assigned_sessions (session_id);
|
||||||
|
|
||||||
-- --- events_common.sql ---
|
-- --- events_common.sql ---
|
||||||
|
|
||||||
|
|
@ -677,6 +682,7 @@ CREATE INDEX pages_path_idx ON events.pages (path);
|
||||||
CREATE INDEX pages_visually_complete_idx ON events.pages (visually_complete) WHERE visually_complete > 0;
|
CREATE INDEX pages_visually_complete_idx ON events.pages (visually_complete) WHERE visually_complete > 0;
|
||||||
CREATE INDEX pages_dom_building_time_idx ON events.pages (dom_building_time) WHERE dom_building_time > 0;
|
CREATE INDEX pages_dom_building_time_idx ON events.pages (dom_building_time) WHERE dom_building_time > 0;
|
||||||
CREATE INDEX pages_load_time_idx ON events.pages (load_time) WHERE load_time > 0;
|
CREATE INDEX pages_load_time_idx ON events.pages (load_time) WHERE load_time > 0;
|
||||||
|
CREATE INDEX pages_base_path_session_id_timestamp_idx ON events.pages (base_path, session_id, timestamp);
|
||||||
|
|
||||||
|
|
||||||
CREATE TABLE events.clicks
|
CREATE TABLE events.clicks
|
||||||
|
|
@ -691,6 +697,11 @@ CREATE INDEX ON events.clicks (session_id);
|
||||||
CREATE INDEX ON events.clicks (label);
|
CREATE INDEX ON events.clicks (label);
|
||||||
CREATE INDEX clicks_label_gin_idx ON events.clicks USING GIN (label gin_trgm_ops);
|
CREATE INDEX clicks_label_gin_idx ON events.clicks USING GIN (label gin_trgm_ops);
|
||||||
CREATE INDEX ON events.clicks (timestamp);
|
CREATE INDEX ON events.clicks (timestamp);
|
||||||
|
CREATE INDEX clicks_label_session_id_timestamp_idx ON events.clicks (label, session_id, timestamp);
|
||||||
|
CREATE INDEX clicks_url_idx ON events.clicks (url);
|
||||||
|
CREATE INDEX clicks_url_gin_idx ON events.clicks USING GIN (url gin_trgm_ops);
|
||||||
|
CREATE INDEX clicks_url_session_id_timestamp_selector_idx ON events.clicks (url, session_id, timestamp, selector);
|
||||||
|
|
||||||
|
|
||||||
CREATE TABLE events.inputs
|
CREATE TABLE events.inputs
|
||||||
(
|
(
|
||||||
|
|
@ -706,6 +717,7 @@ CREATE INDEX ON events.inputs (label, value);
|
||||||
CREATE INDEX inputs_label_gin_idx ON events.inputs USING GIN (label gin_trgm_ops);
|
CREATE INDEX inputs_label_gin_idx ON events.inputs USING GIN (label gin_trgm_ops);
|
||||||
CREATE INDEX inputs_label_idx ON events.inputs (label);
|
CREATE INDEX inputs_label_idx ON events.inputs (label);
|
||||||
CREATE INDEX ON events.inputs (timestamp);
|
CREATE INDEX ON events.inputs (timestamp);
|
||||||
|
CREATE INDEX inputs_label_session_id_timestamp_idx ON events.inputs (label, session_id, timestamp);
|
||||||
|
|
||||||
CREATE TABLE events.errors
|
CREATE TABLE events.errors
|
||||||
(
|
(
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,13 @@ import { configure, addDecorator } from '@storybook/react';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
import store from '../app/store';
|
import store from '../app/store';
|
||||||
import { MemoryRouter } from "react-router"
|
import { MemoryRouter } from "react-router"
|
||||||
|
import { PlayerProvider } from '../app/player/store'
|
||||||
|
|
||||||
const withProvider = (story) => (
|
const withProvider = (story) => (
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
{ story() }
|
<PlayerProvider>
|
||||||
|
{ story() }
|
||||||
|
</PlayerProvider>
|
||||||
</Provider>
|
</Provider>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,8 @@ const siteIdRequiredPaths = [
|
||||||
'/sourcemaps',
|
'/sourcemaps',
|
||||||
'/errors',
|
'/errors',
|
||||||
'/funnels',
|
'/funnels',
|
||||||
'/assist'
|
'/assist',
|
||||||
|
'/heatmaps'
|
||||||
];
|
];
|
||||||
|
|
||||||
const noStoringFetchPathStarts = [
|
const noStoringFetchPathStarts = [
|
||||||
|
|
|
||||||
BIN
frontend/app/assets/img/live-sessions.png
Normal file
BIN
frontend/app/assets/img/live-sessions.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 151 KiB |
|
|
@ -9,8 +9,7 @@
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||||
<link rel="stylesheet" href="//cdn.jsdelivr.net/npm/semantic-ui@2.4.2/dist/semantic.min.css" />
|
<link rel="stylesheet" href="//cdn.jsdelivr.net/npm/semantic-ui@2.4.2/dist/semantic.min.css" />
|
||||||
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="/path/to/styles/theme-name.css">
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|
|
||||||
|
|
@ -35,8 +35,8 @@ function LiveSessionList(props: Props) {
|
||||||
<div>
|
<div>
|
||||||
<NoContent
|
<NoContent
|
||||||
title={"No live sessions!"}
|
title={"No live sessions!"}
|
||||||
subtext="Please try changing your search parameters."
|
// subtext="Please try changing your search parameters."
|
||||||
icon="exclamation-circle"
|
image={<img src="/img/live-sessions.png" style={{ width: '70%', marginBottom: '30px' }}/>}
|
||||||
show={ !loading && list && list.size === 0}
|
show={ !loading && list && list.size === 0}
|
||||||
>
|
>
|
||||||
<Loader loading={ loading }>
|
<Loader loading={ loading }>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
import Highlight from 'react-highlight'
|
||||||
|
import ToggleContent from 'Shared/ToggleContent'
|
||||||
|
import DocLink from 'Shared/DocLink/DocLink';
|
||||||
|
|
||||||
|
const AssistDoc = (props) => {
|
||||||
|
return (
|
||||||
|
<div className="p-4">
|
||||||
|
<div>OpenReplay Assist allows you to support your users by seeing their live screen and instantly hopping on call (WebRTC) with them without requiring any 3rd-party screen sharing software.</div>
|
||||||
|
|
||||||
|
<div className="font-bold my-2">Installation</div>
|
||||||
|
<Highlight className="js">
|
||||||
|
{`npm i @openreplay/tracker-assist`}
|
||||||
|
</Highlight>
|
||||||
|
|
||||||
|
<div className="font-bold my-2">Usage</div>
|
||||||
|
<p>Initialize the tracker then load the @openreplay/tracker-assist plugin.</p>
|
||||||
|
<div className="py-3" />
|
||||||
|
|
||||||
|
<div className="font-bold my-2">Usage</div>
|
||||||
|
<ToggleContent
|
||||||
|
label="Is SSR?"
|
||||||
|
first={
|
||||||
|
<Highlight className="js">
|
||||||
|
{`import Tracker from '@openreplay/tracker';
|
||||||
|
import trackerAssist from '@openreplay/tracker-assist';
|
||||||
|
const tracker = new Tracker({
|
||||||
|
projectKey: PROJECT_KEY,
|
||||||
|
});
|
||||||
|
tracker.start();
|
||||||
|
tracker.use(trackerAssist(options)); // check the list of available options below`}
|
||||||
|
</Highlight>
|
||||||
|
}
|
||||||
|
second={
|
||||||
|
<Highlight className="js">
|
||||||
|
{`import OpenReplay from '@openreplay/tracker/cjs';
|
||||||
|
import trackerFetch from '@openreplay/tracker-assist/cjs';
|
||||||
|
const tracker = new OpenReplay({
|
||||||
|
projectKey: PROJECT_KEY
|
||||||
|
});
|
||||||
|
const trackerAssist = tracker.use(trackerAssist(options)); // check the list of available options below
|
||||||
|
//...
|
||||||
|
function MyApp() {
|
||||||
|
useEffect(() => { // use componentDidMount in case of React Class Component
|
||||||
|
tracker.start();
|
||||||
|
}, [])
|
||||||
|
//...
|
||||||
|
}`}
|
||||||
|
</Highlight>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="font-bold my-2">Options</div>
|
||||||
|
<Highlight className="js">
|
||||||
|
{`trackerAssist({
|
||||||
|
confirmText: string;
|
||||||
|
})`}
|
||||||
|
</Highlight>
|
||||||
|
|
||||||
|
<DocLink className="mt-4" label="Install Assist" url="https://docs.openreplay.com/installation/assist" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
AssistDoc.displayName = "AssistDoc";
|
||||||
|
|
||||||
|
export default AssistDoc;
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export { default } from './AssistDoc'
|
||||||
|
|
@ -28,6 +28,7 @@ import SlackAddForm from './SlackAddForm';
|
||||||
import FetchDoc from './FetchDoc';
|
import FetchDoc from './FetchDoc';
|
||||||
import MobxDoc from './MobxDoc';
|
import MobxDoc from './MobxDoc';
|
||||||
import ProfilerDoc from './ProfilerDoc';
|
import ProfilerDoc from './ProfilerDoc';
|
||||||
|
import AssistDoc from './AssistDoc';
|
||||||
|
|
||||||
const NONE = -1;
|
const NONE = -1;
|
||||||
const SENTRY = 0;
|
const SENTRY = 0;
|
||||||
|
|
@ -49,6 +50,7 @@ const SLACK = 15;
|
||||||
const FETCH = 16;
|
const FETCH = 16;
|
||||||
const MOBX = 17;
|
const MOBX = 17;
|
||||||
const PROFILER = 18;
|
const PROFILER = 18;
|
||||||
|
const ASSIST = 19;
|
||||||
|
|
||||||
const TITLE = {
|
const TITLE = {
|
||||||
[ SENTRY ]: 'Sentry',
|
[ SENTRY ]: 'Sentry',
|
||||||
|
|
@ -70,6 +72,7 @@ const TITLE = {
|
||||||
[ FETCH ] : 'Fetch',
|
[ FETCH ] : 'Fetch',
|
||||||
[ MOBX ] : 'MobX',
|
[ MOBX ] : 'MobX',
|
||||||
[ PROFILER ] : 'Profiler',
|
[ PROFILER ] : 'Profiler',
|
||||||
|
[ ASSIST ] : 'Assist',
|
||||||
}
|
}
|
||||||
|
|
||||||
const DOCS = [REDUX, VUE, GRAPHQL, NGRX, FETCH, MOBX, PROFILER]
|
const DOCS = [REDUX, VUE, GRAPHQL, NGRX, FETCH, MOBX, PROFILER]
|
||||||
|
|
@ -182,6 +185,8 @@ export default class Integrations extends React.PureComponent {
|
||||||
return <MobxDoc onClose={ this.closeModal } />
|
return <MobxDoc onClose={ this.closeModal } />
|
||||||
case PROFILER:
|
case PROFILER:
|
||||||
return <ProfilerDoc onClose={ this.closeModal } />
|
return <ProfilerDoc onClose={ this.closeModal } />
|
||||||
|
case ASSIST:
|
||||||
|
return <AssistDoc onClose={ this.closeModal } />
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -253,7 +258,7 @@ export default class Integrations extends React.PureComponent {
|
||||||
{plugins && (
|
{plugins && (
|
||||||
<div className="" >
|
<div className="" >
|
||||||
<div className="mb-4">Use plugins to better debug your application's store, monitor queries and track performance issues.</div>
|
<div className="mb-4">Use plugins to better debug your application's store, monitor queries and track performance issues.</div>
|
||||||
<div className="flex">
|
<div className="flex flex-wrap">
|
||||||
<IntegrationItem
|
<IntegrationItem
|
||||||
title="Redux"
|
title="Redux"
|
||||||
icon="integrations/redux"
|
icon="integrations/redux"
|
||||||
|
|
@ -313,6 +318,14 @@ export default class Integrations extends React.PureComponent {
|
||||||
onClick={ () => this.showIntegrationConfig(PROFILER) }
|
onClick={ () => this.showIntegrationConfig(PROFILER) }
|
||||||
// integrated={ sentryIntegrated }
|
// integrated={ sentryIntegrated }
|
||||||
/>
|
/>
|
||||||
|
<IntegrationItem
|
||||||
|
title="Assist"
|
||||||
|
icon="integrations/assist"
|
||||||
|
url={ null }
|
||||||
|
dockLink="https://docs.openreplay.com/installation/assist"
|
||||||
|
onClick={ () => this.showIntegrationConfig(ASSIST) }
|
||||||
|
// integrated={ sentryIntegrated }
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import cn from 'classnames';
|
import cn from 'classnames';
|
||||||
import withPageTitle from 'HOCs/withPageTitle';
|
import withPageTitle from 'HOCs/withPageTitle';
|
||||||
import { IconButton, SlideModal, Input, Button, Loader, NoContent, Popup } from 'UI';
|
import { IconButton, SlideModal, Input, Button, Loader, NoContent, Popup, CopyButton } from 'UI';
|
||||||
import { init, save, edit, remove as deleteMember, fetchList } from 'Duck/member';
|
import { init, save, edit, remove as deleteMember, fetchList, generateInviteLink } from 'Duck/member';
|
||||||
import styles from './manageUsers.css';
|
import styles from './manageUsers.css';
|
||||||
import UserItem from './UserItem';
|
import UserItem from './UserItem';
|
||||||
import { confirm } from 'UI/Confirmation';
|
import { confirm } from 'UI/Confirmation';
|
||||||
|
|
@ -24,11 +24,12 @@ const LIMIT_WARNING = 'You have reached users limit.';
|
||||||
save,
|
save,
|
||||||
edit,
|
edit,
|
||||||
deleteMember,
|
deleteMember,
|
||||||
fetchList
|
fetchList,
|
||||||
|
generateInviteLink
|
||||||
})
|
})
|
||||||
@withPageTitle('Manage Users - OpenReplay Preferences')
|
@withPageTitle('Manage Users - OpenReplay Preferences')
|
||||||
class ManageUsers extends React.PureComponent {
|
class ManageUsers extends React.PureComponent {
|
||||||
state = { showModal: false, remaining: this.props.account.limits.teamMember.remaining }
|
state = { showModal: false, remaining: this.props.account.limits.teamMember.remaining, invited: false }
|
||||||
|
|
||||||
onChange = (e, { name, value }) => this.props.edit({ [ name ]: value });
|
onChange = (e, { name, value }) => this.props.edit({ [ name ]: value });
|
||||||
onChangeCheckbox = ({ target: { checked, name } }) => this.props.edit({ [ name ]: checked });
|
onChangeCheckbox = ({ target: { checked, name } }) => this.props.edit({ [ name ]: checked });
|
||||||
|
|
@ -70,11 +71,12 @@ class ManageUsers extends React.PureComponent {
|
||||||
toast.error(e);
|
toast.error(e);
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
this.closeModal()
|
this.setState({ invited: true })
|
||||||
|
// this.closeModal()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
formContent = member => (
|
formContent = (member, account) => (
|
||||||
<div className={ styles.form }>
|
<div className={ styles.form }>
|
||||||
<form onSubmit={ this.save } >
|
<form onSubmit={ this.save } >
|
||||||
<div className={ styles.formGroup }>
|
<div className={ styles.formGroup }>
|
||||||
|
|
@ -99,7 +101,11 @@ class ManageUsers extends React.PureComponent {
|
||||||
className={ styles.input }
|
className={ styles.input }
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{ !account.smtp &&
|
||||||
|
<div className={cn("mb-4 p-2", styles.smtpMessage)}>
|
||||||
|
SMTP is not configured, <a className="link" href="https://docs.openreplay.com/configuration/configure-smtp" target="_blank">setup SMTP</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<div className={ styles.formGroup }>
|
<div className={ styles.formGroup }>
|
||||||
<label className={ styles.checkbox }>
|
<label className={ styles.checkbox }>
|
||||||
<input
|
<input
|
||||||
|
|
@ -111,26 +117,37 @@ class ManageUsers extends React.PureComponent {
|
||||||
/>
|
/>
|
||||||
<span>{ 'Admin' }</span>
|
<span>{ 'Admin' }</span>
|
||||||
</label>
|
</label>
|
||||||
<div className={ styles.adminInfo }>{ 'Can manage Projects and Users.' }</div>
|
<div className={ styles.adminInfo }>{ 'Can manage Projects and team members.' }</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<Button
|
<div className="flex items-center">
|
||||||
onClick={ this.save }
|
<div className="flex items-center mr-auto">
|
||||||
disabled={ !member.validate() }
|
<Button
|
||||||
loading={ this.props.saving }
|
onClick={ this.save }
|
||||||
primary
|
disabled={ !member.validate() }
|
||||||
marginRight
|
loading={ this.props.saving }
|
||||||
>
|
primary
|
||||||
{ member.exists() ? 'Update' : 'Invite' }
|
marginRight
|
||||||
</Button>
|
>
|
||||||
<Button
|
{ member.exists() ? 'Update' : 'Invite' }
|
||||||
data-hidden={ !member.exists() }
|
</Button>
|
||||||
onClick={ this.closeModal }
|
<Button
|
||||||
outline
|
data-hidden={ !member.exists() }
|
||||||
>
|
onClick={ this.closeModal }
|
||||||
{ 'Cancel' }
|
outline
|
||||||
</Button>
|
>
|
||||||
|
{ 'Cancel' }
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{ !member.joined && member.invitationLink &&
|
||||||
|
<CopyButton
|
||||||
|
content={member.invitationLink}
|
||||||
|
className="link"
|
||||||
|
btnText="Copy invite link"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -144,7 +161,7 @@ class ManageUsers extends React.PureComponent {
|
||||||
const {
|
const {
|
||||||
members, member, loading, account, hideHeader = false,
|
members, member, loading, account, hideHeader = false,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const { showModal, remaining } = this.state;
|
const { showModal, remaining, invited } = this.state;
|
||||||
const isAdmin = account.admin || account.superAdmin;
|
const isAdmin = account.admin || account.superAdmin;
|
||||||
const canAddUsers = isAdmin && remaining !== 0;
|
const canAddUsers = isAdmin && remaining !== 0;
|
||||||
|
|
||||||
|
|
@ -155,7 +172,7 @@ class ManageUsers extends React.PureComponent {
|
||||||
title="Inivte People"
|
title="Inivte People"
|
||||||
size="small"
|
size="small"
|
||||||
isDisplayed={ showModal }
|
isDisplayed={ showModal }
|
||||||
content={ this.formContent(member) }
|
content={ this.formContent(member, account) }
|
||||||
onClose={ this.closeModal }
|
onClose={ this.closeModal }
|
||||||
/>
|
/>
|
||||||
<div className={ styles.wrapper }>
|
<div className={ styles.wrapper }>
|
||||||
|
|
@ -202,6 +219,7 @@ class ManageUsers extends React.PureComponent {
|
||||||
{
|
{
|
||||||
members.map(user => (
|
members.map(user => (
|
||||||
<UserItem
|
<UserItem
|
||||||
|
generateInviteLink={this.props.generateInviteLink}
|
||||||
key={ user.id }
|
key={ user.id }
|
||||||
user={ user }
|
user={ user }
|
||||||
adminLabel={ this.adminLabel(user) }
|
adminLabel={ this.adminLabel(user) }
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,43 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Icon } from 'UI';
|
import { Icon, CopyButton, Popup } from 'UI';
|
||||||
import styles from './userItem.css';
|
import styles from './userItem.css';
|
||||||
|
|
||||||
const UserItem = ({ user, adminLabel, deleteHandler, editHandler }) => (
|
const UserItem = ({ user, adminLabel, deleteHandler, editHandler, generateInviteLink }) => (
|
||||||
<div className={ styles.wrapper } id="user-row">
|
<div className={ styles.wrapper } id="user-row">
|
||||||
<Icon name="user-alt" size="16" marginRight="10" />
|
<Icon name="user-alt" size="16" marginRight="10" />
|
||||||
<div id="user-name">{ user.name || user.email }</div>
|
<div id="user-name">{ user.name || user.email }</div>
|
||||||
{ adminLabel && <div className={ styles.adminLabel }>{ adminLabel }</div>}
|
{ adminLabel && <div className={ styles.adminLabel }>{ adminLabel }</div>}
|
||||||
<div className={ styles.actions }>
|
<div className={ styles.actions }>
|
||||||
|
{ user.expiredInvitation && !user.joined &&
|
||||||
|
<Popup
|
||||||
|
trigger={
|
||||||
|
<div className={ styles.button } onClick={ () => generateInviteLink(user) } id="trash">
|
||||||
|
<Icon name="link-45deg" size="16" color="red"/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
content={ `Generate Invitation Link` }
|
||||||
|
size="tiny"
|
||||||
|
inverted
|
||||||
|
position="top center"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
{ !user.expiredInvitation && !user.joined && user.invitationLink &&
|
||||||
|
<Popup
|
||||||
|
trigger={
|
||||||
|
<div className={ styles.button }>
|
||||||
|
<CopyButton
|
||||||
|
content={user.invitationLink}
|
||||||
|
className="link"
|
||||||
|
btnText={<Icon name="link-45deg" size="16" color="teal"/>}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
content={ `Copy Invitation Link` }
|
||||||
|
size="tiny"
|
||||||
|
inverted
|
||||||
|
position="top center"
|
||||||
|
/>
|
||||||
|
}
|
||||||
{ !!deleteHandler &&
|
{ !!deleteHandler &&
|
||||||
<div className={ styles.button } onClick={ () => deleteHandler(user) } id="trash">
|
<div className={ styles.button } onClick={ () => deleteHandler(user) } id="trash">
|
||||||
<Icon name="trash" size="16" color="teal"/>
|
<Icon name="trash" size="16" color="teal"/>
|
||||||
|
|
|
||||||
|
|
@ -34,4 +34,9 @@
|
||||||
.adminInfo {
|
.adminInfo {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: $gray-medium;
|
color: $gray-medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
.smtpMessage {
|
||||||
|
background-color: #faf6e0;
|
||||||
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
@ -35,6 +35,9 @@
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
margin-left: 10px;
|
margin-left: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
&:hover {
|
&:hover {
|
||||||
& svg {
|
& svg {
|
||||||
fill: $teal-dark;
|
fill: $teal-dark;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import withPageTitle from 'HOCs/withPageTitle';
|
||||||
import { Loader, Button, Link, Icon, Message } from 'UI';
|
import { Loader, Button, Link, Icon, Message } from 'UI';
|
||||||
import { requestResetPassword, resetPassword } from 'Duck/user';
|
import { requestResetPassword, resetPassword } from 'Duck/user';
|
||||||
import { login as loginRoute } from 'App/routes';
|
import { login as loginRoute } from 'App/routes';
|
||||||
|
import { withRouter } from 'react-router-dom';
|
||||||
|
import { validateEmail } from 'App/validate';
|
||||||
import cn from 'classnames';
|
import cn from 'classnames';
|
||||||
import stl from './forgotPassword.css';
|
import stl from './forgotPassword.css';
|
||||||
|
|
||||||
|
|
@ -17,14 +19,16 @@ const checkDontMatch = (newPassword, newPasswordRepeat) =>
|
||||||
newPasswordRepeat.length > 0 && newPasswordRepeat !== newPassword;
|
newPasswordRepeat.length > 0 && newPasswordRepeat !== newPassword;
|
||||||
|
|
||||||
@connect(
|
@connect(
|
||||||
state => ({
|
(state, props) => ({
|
||||||
errors: state.getIn([ 'user', 'requestResetPassowrd', 'errors' ]),
|
errors: state.getIn([ 'user', 'requestResetPassowrd', 'errors' ]),
|
||||||
resetErrors: state.getIn([ 'user', 'resetPassword', 'errors' ]),
|
resetErrors: state.getIn([ 'user', 'resetPassword', 'errors' ]),
|
||||||
loading: state.getIn([ 'user', 'requestResetPassowrd', 'loading' ]),
|
loading: state.getIn([ 'user', 'requestResetPassowrd', 'loading' ]),
|
||||||
|
params: new URLSearchParams(props.location.search)
|
||||||
}),
|
}),
|
||||||
{ requestResetPassword, resetPassword },
|
{ requestResetPassword, resetPassword },
|
||||||
)
|
)
|
||||||
@withPageTitle("Password Reset - OpenReplay")
|
@withPageTitle("Password Reset - OpenReplay")
|
||||||
|
@withRouter
|
||||||
export default class ForgotPassword extends React.PureComponent {
|
export default class ForgotPassword extends React.PureComponent {
|
||||||
state = {
|
state = {
|
||||||
email: '',
|
email: '',
|
||||||
|
|
@ -37,15 +41,20 @@ export default class ForgotPassword extends React.PureComponent {
|
||||||
|
|
||||||
handleSubmit = (token) => {
|
handleSubmit = (token) => {
|
||||||
const { email, requested, code, password } = this.state;
|
const { email, requested, code, password } = this.state;
|
||||||
|
const { params } = this.props;
|
||||||
|
|
||||||
if (!requested) {
|
const pass = params.get('pass')
|
||||||
|
const invitation = params.get('invitation')
|
||||||
|
const resetting = pass && invitation
|
||||||
|
|
||||||
|
if (!resetting) {
|
||||||
this.props.requestResetPassword({ email: email.trim(), 'g-recaptcha-response': token }).then(() => {
|
this.props.requestResetPassword({ email: email.trim(), 'g-recaptcha-response': token }).then(() => {
|
||||||
const { errors } = this.props;
|
const { errors } = this.props;
|
||||||
if (!errors) this.setState({ requested: true });
|
if (!errors) this.setState({ requested: true });
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
if (this.isSubmitDisabled()) return;
|
if (this.isSubmitDisabled()) return;
|
||||||
this.props.resetPassword({ email: email.trim(), code, password }).then(() => {
|
this.props.resetPassword({ email: email.trim(), invitation, pass, password }).then(() => {
|
||||||
const { resetErrors } = this.props;
|
const { resetErrors } = this.props;
|
||||||
if (!resetErrors) this.setState({ updated: true });
|
if (!resetErrors) this.setState({ updated: true });
|
||||||
});
|
});
|
||||||
|
|
@ -78,9 +87,14 @@ export default class ForgotPassword extends React.PureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { errors, loading } = this.props;
|
const { errors, loading, params } = this.props;
|
||||||
const { requested, updated, password, passwordRepeat, code } = this.state;
|
const { requested, updated, password, passwordRepeat, email } = this.state;
|
||||||
const dontMatch = checkDontMatch(password, passwordRepeat);
|
const dontMatch = checkDontMatch(password, passwordRepeat);
|
||||||
|
|
||||||
|
const pass = params.get('pass')
|
||||||
|
const invitation = params.get('invitation')
|
||||||
|
const resetting = pass && invitation
|
||||||
|
const validEmail = validateEmail(email)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex" style={{ height: '100vh'}}>
|
<div className="flex" style={{ height: '100vh'}}>
|
||||||
|
|
@ -113,7 +127,7 @@ export default class ForgotPassword extends React.PureComponent {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{ !requested ?
|
{ !resetting && !requested &&
|
||||||
<div className={ stl.inputWithIcon }>
|
<div className={ stl.inputWithIcon }>
|
||||||
<i className={ stl.inputIconUser } />
|
<i className={ stl.inputIconUser } />
|
||||||
<input
|
<input
|
||||||
|
|
@ -125,47 +139,57 @@ export default class ForgotPassword extends React.PureComponent {
|
||||||
onChange={ this.write }
|
onChange={ this.write }
|
||||||
className={ stl.input }
|
className={ stl.input }
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
:
|
}
|
||||||
<React.Fragment>
|
|
||||||
<div className={ stl.inputWithIcon } >
|
|
||||||
<i className={ stl.inputIconPassword } />
|
|
||||||
<input
|
|
||||||
autocomplete="new-password"
|
|
||||||
type="text"
|
|
||||||
placeholder="Code"
|
|
||||||
name="code"
|
|
||||||
onChange={ this.write }
|
|
||||||
className={ stl.input }
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={ stl.inputWithIcon } >
|
{
|
||||||
<i className={ stl.inputIconPassword } />
|
requested && (
|
||||||
<input
|
<div>Reset password link has been sent to your email.</div>
|
||||||
autocomplete="new-password"
|
)
|
||||||
type="password"
|
}
|
||||||
placeholder="New Password"
|
|
||||||
name="password"
|
{
|
||||||
onChange={ this.write }
|
resetting && (
|
||||||
className={ stl.input }
|
<React.Fragment>
|
||||||
/>
|
{/* <div className={ stl.inputWithIcon } >
|
||||||
</div>
|
<i className={ stl.inputIconPassword } />
|
||||||
<div className={ stl.passwordPolicy } data-hidden={ !this.shouldShouwPolicy() }>
|
<input
|
||||||
{ PASSWORD_POLICY }
|
autocomplete="new-password"
|
||||||
</div>
|
type="text"
|
||||||
<div className={ stl.inputWithIcon } >
|
placeholder="Code"
|
||||||
<i className={ stl.inputIconPassword } />
|
name="code"
|
||||||
<input
|
onChange={ this.write }
|
||||||
autocomplete="new-password"
|
className={ stl.input }
|
||||||
type="password"
|
/>
|
||||||
placeholder="Repeat New Password"
|
</div> */}
|
||||||
name="passwordRepeat"
|
|
||||||
onChange={ this.write }
|
<div className={ stl.inputWithIcon } >
|
||||||
className={ stl.input }
|
<i className={ stl.inputIconPassword } />
|
||||||
/>
|
<input
|
||||||
</div>
|
autocomplete="new-password"
|
||||||
</React.Fragment>
|
type="password"
|
||||||
|
placeholder="New Password"
|
||||||
|
name="password"
|
||||||
|
onChange={ this.write }
|
||||||
|
className={ stl.input }
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={ stl.passwordPolicy } data-hidden={ !this.shouldShouwPolicy() }>
|
||||||
|
{ PASSWORD_POLICY }
|
||||||
|
</div>
|
||||||
|
<div className={ stl.inputWithIcon } >
|
||||||
|
<i className={ stl.inputIconPassword } />
|
||||||
|
<input
|
||||||
|
autocomplete="new-password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Repeat New Password"
|
||||||
|
name="passwordRepeat"
|
||||||
|
onChange={ this.write }
|
||||||
|
className={ stl.input }
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</React.Fragment>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
<Message error hidden={ !dontMatch }>
|
<Message error hidden={ !dontMatch }>
|
||||||
|
|
@ -185,7 +209,13 @@ export default class ForgotPassword extends React.PureComponent {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={ stl.formFooter }>
|
<div className={ stl.formFooter }>
|
||||||
<Button data-hidden={ updated } type="submit" primary >{ 'Reset' }</Button>
|
<Button
|
||||||
|
data-hidden={ updated || requested }
|
||||||
|
type="submit" primary
|
||||||
|
disabled={ (resetting && this.isSubmitDisabled()) || (!resetting && !validEmail)}
|
||||||
|
>
|
||||||
|
{ 'Reset' }
|
||||||
|
</Button>
|
||||||
|
|
||||||
<div className={ stl.links }>
|
<div className={ stl.links }>
|
||||||
<Link to={ LOGIN }>
|
<Link to={ LOGIN }>
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,7 @@ const FunnelHeader = (props) => {
|
||||||
endDate={funnelFilters.endDate}
|
endDate={funnelFilters.endDate}
|
||||||
onDateChange={onDateChange}
|
onDateChange={onDateChange}
|
||||||
customRangeRight
|
customRangeRight
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -31,12 +31,7 @@ function Layout({ children, player, toolbar }) {
|
||||||
</div>
|
</div>
|
||||||
{ !player.fullscreen.enabled && <ToolPanel player={ player } toolbar={ toolbar }/> }
|
{ !player.fullscreen.enabled && <ToolPanel player={ player } toolbar={ toolbar }/> }
|
||||||
</div>
|
</div>
|
||||||
{ !player.fullscreen.enabled &&
|
|
||||||
<Events
|
|
||||||
style={{ width: "270px" }}
|
|
||||||
player={ player }
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
43
frontend/app/components/Session/RightBlock.tsx
Normal file
43
frontend/app/components/Session/RightBlock.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import EventsBlock from '../Session_/EventsBlock';
|
||||||
|
import PageInsightsPanel from '../Session_/PageInsightsPanel/PageInsightsPanel'
|
||||||
|
import { Controls as PlayerControls } from 'Player';
|
||||||
|
import { Tabs } from 'UI';
|
||||||
|
import { connectPlayer } from 'Player';
|
||||||
|
|
||||||
|
const EVENTS = 'Events';
|
||||||
|
const HEATMAPS = 'Heatmaps';
|
||||||
|
|
||||||
|
const TABS = [ EVENTS, HEATMAPS ].map(tab => ({ text: tab, key: tab }));
|
||||||
|
|
||||||
|
|
||||||
|
const EventsBlockConnected = connectPlayer(state => ({
|
||||||
|
currentTimeEventIndex: state.eventListNow.length > 0 ? state.eventListNow.length - 1 : 0,
|
||||||
|
playing: state.playing,
|
||||||
|
}))(EventsBlock)
|
||||||
|
|
||||||
|
export default function RightBlock() {
|
||||||
|
const [activeTab, setActiveTab] = useState(EVENTS)
|
||||||
|
|
||||||
|
const renderActiveTab = (tab) => {
|
||||||
|
switch(tab) {
|
||||||
|
case EVENTS:
|
||||||
|
return <EventsBlockConnected player={PlayerControls}/>
|
||||||
|
case HEATMAPS:
|
||||||
|
return <PageInsightsPanel />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div style={{ width: '270px', height: 'calc(100vh- 50px)'}} className="flex flex-col">
|
||||||
|
<Tabs
|
||||||
|
tabs={ TABS }
|
||||||
|
active={ activeTab }
|
||||||
|
onClick={ (tab) => setActiveTab(tab) }
|
||||||
|
border={ true }
|
||||||
|
/>
|
||||||
|
{
|
||||||
|
renderActiveTab(activeTab)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -8,22 +8,13 @@ import {
|
||||||
init as initPlayer,
|
init as initPlayer,
|
||||||
clean as cleanPlayer,
|
clean as cleanPlayer,
|
||||||
} from 'Player';
|
} from 'Player';
|
||||||
import { Controls as PlayerControls, toggleEvents } from 'Player';
|
|
||||||
import cn from 'classnames'
|
import cn from 'classnames'
|
||||||
|
import RightBlock from './RightBlock'
|
||||||
|
|
||||||
|
|
||||||
import PlayerBlockHeader from '../Session_/PlayerBlockHeader';
|
import PlayerBlockHeader from '../Session_/PlayerBlockHeader';
|
||||||
import EventsBlock from '../Session_/EventsBlock';
|
|
||||||
import PlayerBlock from '../Session_/PlayerBlock';
|
import PlayerBlock from '../Session_/PlayerBlock';
|
||||||
import styles from '../Session_/session.css';
|
import styles from '../Session_/session.css';
|
||||||
import EventsToggleButton from './EventsToggleButton';
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const EventsBlockConnected = connectPlayer(state => ({
|
|
||||||
currentTimeEventIndex: state.eventListNow.length > 0 ? state.eventListNow.length - 1 : 0,
|
|
||||||
playing: state.playing,
|
|
||||||
}))(EventsBlock)
|
|
||||||
|
|
||||||
|
|
||||||
const InitLoader = connectPlayer(state => ({
|
const InitLoader = connectPlayer(state => ({
|
||||||
|
|
@ -32,14 +23,14 @@ const InitLoader = connectPlayer(state => ({
|
||||||
|
|
||||||
const PlayerContentConnected = connectPlayer(state => ({
|
const PlayerContentConnected = connectPlayer(state => ({
|
||||||
showEvents: !state.showEvents
|
showEvents: !state.showEvents
|
||||||
}), { toggleEvents })(PlayerContent);
|
}))(PlayerContent);
|
||||||
|
|
||||||
|
|
||||||
function PlayerContent({ live, fullscreen, showEvents, toggleEvents }) {
|
function PlayerContent({ live, fullscreen, showEvents }) {
|
||||||
return (
|
return (
|
||||||
<div className={ cn(styles.session, 'relative') } data-fullscreen={fullscreen}>
|
<div className={ cn(styles.session, 'relative') } data-fullscreen={fullscreen}>
|
||||||
<PlayerBlock />
|
<PlayerBlock />
|
||||||
{ showEvents && !live && !fullscreen && <EventsBlockConnected player={PlayerControls}/> }
|
{ showEvents && !live && !fullscreen && <RightBlock /> }
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { default } from './AutoplayTimer'
|
|
||||||
|
|
@ -6,7 +6,7 @@ export default function EventSearch(props) {
|
||||||
const [showSearch, setShowSearch] = useState(false)
|
const [showSearch, setShowSearch] = useState(false)
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center w-full">
|
<div className="flex items-center w-full">
|
||||||
<div className="flex-1 relative">
|
<div className="flex flex-1 relative items-center" style={{ height: '32px' }}>
|
||||||
{ showSearch ?
|
{ showSearch ?
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Input
|
<Input
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import cn from 'classnames';
|
import cn from 'classnames';
|
||||||
import { List, AutoSizer, CellMeasurer, CellMeasurerCache } from "react-virtualized";
|
import { List, AutoSizer, CellMeasurer, CellMeasurerCache } from "react-virtualized";
|
||||||
import { Avatar, Input, Dropdown, Icon } from 'UI';
|
|
||||||
import { TYPES } from 'Types/session/event';
|
import { TYPES } from 'Types/session/event';
|
||||||
import { setSelected } from 'Duck/events';
|
import { setSelected } from 'Duck/events';
|
||||||
import { setEventFilter } from 'Duck/sessions';
|
import { setEventFilter } from 'Duck/sessions';
|
||||||
|
|
@ -18,8 +17,7 @@ import EventSearch from './EventSearch/EventSearch';
|
||||||
eventsIndex: state.getIn([ 'sessions', 'eventsIndex' ]),
|
eventsIndex: state.getIn([ 'sessions', 'eventsIndex' ]),
|
||||||
selectedEvents: state.getIn([ 'events', 'selected' ]),
|
selectedEvents: state.getIn([ 'events', 'selected' ]),
|
||||||
targetDefinerDisplayed: state.getIn([ 'components', 'targetDefiner', 'isDisplayed' ]),
|
targetDefinerDisplayed: state.getIn([ 'components', 'targetDefiner', 'isDisplayed' ]),
|
||||||
testsAvaliable: false,
|
testsAvaliable: false,
|
||||||
//state.getIn([ 'user', 'account', 'appearance', 'tests' ]),
|
|
||||||
}), {
|
}), {
|
||||||
showTargetDefiner,
|
showTargetDefiner,
|
||||||
setSelected,
|
setSelected,
|
||||||
|
|
@ -74,9 +72,6 @@ export default class EventsBlock extends React.PureComponent {
|
||||||
this.setState({ editingEvent: null });
|
this.setState({ editingEvent: null });
|
||||||
}
|
}
|
||||||
if (prevProps.session !== this.props.session) { // Doesn't happen
|
if (prevProps.session !== this.props.session) { // Doesn't happen
|
||||||
// this.setState({
|
|
||||||
// groups: groupEvents(this.props.session.events),
|
|
||||||
// });
|
|
||||||
this.cache = new CellMeasurerCache({
|
this.cache = new CellMeasurerCache({
|
||||||
fixedWidth: true,
|
fixedWidth: true,
|
||||||
defaultHeight: 300
|
defaultHeight: 300
|
||||||
|
|
@ -148,8 +143,7 @@ export default class EventsBlock extends React.PureComponent {
|
||||||
<CellMeasurer
|
<CellMeasurer
|
||||||
key={key}
|
key={key}
|
||||||
cache={this.cache}
|
cache={this.cache}
|
||||||
parent={parent}
|
parent={parent}
|
||||||
//columnIndex={0}
|
|
||||||
rowIndex={index}
|
rowIndex={index}
|
||||||
>
|
>
|
||||||
{({measure, registerChild}) => (
|
{({measure, registerChild}) => (
|
||||||
|
|
@ -176,14 +170,12 @@ export default class EventsBlock extends React.PureComponent {
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { query } = this.state;
|
const { query } = this.state;
|
||||||
const {
|
const {
|
||||||
playing,
|
|
||||||
testsAvaliable,
|
testsAvaliable,
|
||||||
session: {
|
session: {
|
||||||
events,
|
events,
|
||||||
userNumericHash,
|
userNumericHash,
|
||||||
userDisplayName,
|
userDisplayName,
|
||||||
userUuid,
|
|
||||||
userId,
|
userId,
|
||||||
userAnonymousId
|
userAnonymousId
|
||||||
},
|
},
|
||||||
|
|
@ -193,7 +185,7 @@ export default class EventsBlock extends React.PureComponent {
|
||||||
const _events = filteredEvents || events;
|
const _events = filteredEvents || events;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={ cn("flex flex-col", styles.eventsBlock) }>
|
<>
|
||||||
<div className={ cn(styles.header, 'p-3') }>
|
<div className={ cn(styles.header, 'p-3') }>
|
||||||
<UserCard
|
<UserCard
|
||||||
className=""
|
className=""
|
||||||
|
|
@ -203,8 +195,7 @@ export default class EventsBlock extends React.PureComponent {
|
||||||
userAnonymousId={userAnonymousId}
|
userAnonymousId={userAnonymousId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className={ cn(styles.hAndProgress, 'mt-3') }>
|
<div className={ cn(styles.hAndProgress, 'mt-3') }>
|
||||||
{/* <div className="text-lg">{ `User Events (${ events.size })` }</div> */}
|
|
||||||
<EventSearch
|
<EventSearch
|
||||||
onChange={this.write}
|
onChange={this.write}
|
||||||
clearSearch={this.clearSearch}
|
clearSearch={this.clearSearch}
|
||||||
|
|
@ -213,29 +204,7 @@ export default class EventsBlock extends React.PureComponent {
|
||||||
<div className="text-lg">{ `User Events (${ events.size })` }</div>
|
<div className="text-lg">{ `User Events (${ events.size })` }</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex mt-3">
|
|
||||||
{/* <Dropdown
|
|
||||||
trigger={
|
|
||||||
<div className={cn("py-3 px-3 bg-white flex items-center text-sm mb-2 border rounded ml-2")} style={{ height: '32px' }}>
|
|
||||||
<Icon name="filter" size="12" color="teal" />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
options={ [
|
|
||||||
// { text: 'Visited', value: TYPES.LOCATION },
|
|
||||||
{ text: 'Clicked', value: TYPES.CLICK },
|
|
||||||
{ text: 'Input', value: TYPES.INPUT },
|
|
||||||
] }
|
|
||||||
name="filter"
|
|
||||||
icon={null}
|
|
||||||
onChange={this.onSetEventFilter}
|
|
||||||
basic
|
|
||||||
direction="left"
|
|
||||||
scrolling
|
|
||||||
selectOnBlur={true}
|
|
||||||
closeOnChange={true}
|
|
||||||
/> */}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={ cn("flex-1 px-3 pb-3", styles.eventsList) }
|
className={ cn("flex-1 px-3 pb-3", styles.eventsList) }
|
||||||
|
|
@ -263,7 +232,7 @@ export default class EventsBlock extends React.PureComponent {
|
||||||
</AutoSizer>
|
</AutoSizer>
|
||||||
</div>
|
</div>
|
||||||
{ testsAvaliable && <AutomateButton /> }
|
{ testsAvaliable && <AutomateButton /> }
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { Dropdown, Loader } from 'UI'
|
||||||
|
import DateRange from 'Shared/DateRange';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { fetchInsights } from 'Duck/sessions';
|
||||||
|
import SelectorsList from './components/SelectorsList/SelectorsList';
|
||||||
|
import { markTargets, Controls as Player } from 'Player';
|
||||||
|
|
||||||
|
const JUMP_OFFSET = 1000;
|
||||||
|
interface Props {
|
||||||
|
filters: any
|
||||||
|
fetchInsights: (filters) => void
|
||||||
|
urls: []
|
||||||
|
insights: any
|
||||||
|
events: Array<any>
|
||||||
|
urlOptions: Array<any>
|
||||||
|
loading: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageInsightsPanel({ filters, fetchInsights, events = [], insights, urlOptions, loading = true }: Props) {
|
||||||
|
const [insightsFilters, setInsightsFilters] = useState(filters)
|
||||||
|
|
||||||
|
const onDateChange = (e) => {
|
||||||
|
const { startDate, endDate, rangeValue } = e;
|
||||||
|
setInsightsFilters({ ...insightsFilters, startDate, endDate, rangeValue })
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
markTargets(insights.toJS());
|
||||||
|
return () => {
|
||||||
|
markTargets(null)
|
||||||
|
}
|
||||||
|
}, [insights])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const url = insightsFilters.url ? insightsFilters.url : urlOptions[0].value;
|
||||||
|
Player.pause();
|
||||||
|
fetchInsights({ ...insightsFilters, url })
|
||||||
|
}, [insightsFilters])
|
||||||
|
|
||||||
|
const onPageSelect = (e, { name, value }) => {
|
||||||
|
const event = events.find(item => item.url === value)
|
||||||
|
Player.jump(event.time + JUMP_OFFSET)
|
||||||
|
setInsightsFilters({ ...insightsFilters, url: value })
|
||||||
|
markTargets([])
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-4 bg-gray-lightest">
|
||||||
|
<div className="my-3 flex -ml-2">
|
||||||
|
<DateRange
|
||||||
|
rangeValue={insightsFilters.rangeValue}
|
||||||
|
startDate={insightsFilters.startDate}
|
||||||
|
endDate={insightsFilters.endDate}
|
||||||
|
onDateChange={onDateChange}
|
||||||
|
customHidden
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-4 flex items-center">
|
||||||
|
<div className="mr-2 flex-shrink-0">In Page</div>
|
||||||
|
<Dropdown
|
||||||
|
search
|
||||||
|
labeled
|
||||||
|
placeholder="change"
|
||||||
|
selection
|
||||||
|
options={ urlOptions }
|
||||||
|
name="url"
|
||||||
|
defaultValue={urlOptions[0].value}
|
||||||
|
onChange={ onPageSelect }
|
||||||
|
id="change-dropdown"
|
||||||
|
className="customDropdown"
|
||||||
|
style={{ minWidth: '80px', width: '100%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Loader loading={ loading }>
|
||||||
|
<SelectorsList />
|
||||||
|
</Loader>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default connect(state => {
|
||||||
|
const events = state.getIn([ 'sessions', 'visitedEvents' ])
|
||||||
|
return {
|
||||||
|
filters: state.getIn(['sessions', 'insightFilters']),
|
||||||
|
insights: state.getIn([ 'sessions', 'insights' ]),
|
||||||
|
events: events,
|
||||||
|
urlOptions: events.map(({ url }) => ({ text: url, value: url})),
|
||||||
|
loading: state.getIn([ 'sessions', 'fetchInsightsRequest', 'loading' ]),
|
||||||
|
}
|
||||||
|
}, { fetchInsights })(PageInsightsPanel);
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
.wrapper {
|
||||||
|
padding: 10px;
|
||||||
|
box-shadow: 1px 1px 1px rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 3px;
|
||||||
|
background-color: white;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
|
||||||
|
& .top {
|
||||||
|
display: flex;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .index {
|
||||||
|
margin-right: 10px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background-color: $tealx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: solid thin white;
|
||||||
|
box-shadow: 1px 1px 1px rgba(0, 0, 0, 0.1);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .counts {
|
||||||
|
text-align: center;
|
||||||
|
padding: 5px;
|
||||||
|
margin: 20px 0;
|
||||||
|
|
||||||
|
& div:first-child {
|
||||||
|
font-size: 18px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.active {
|
||||||
|
background-color: #f9ffff;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import stl from './SelectorCard.css'
|
||||||
|
import cn from 'classnames';
|
||||||
|
import type { MarkedTarget } from 'Player/MessageDistributor/StatedScreen/StatedScreen';
|
||||||
|
import { activeTarget } from 'Player';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
index?: number,
|
||||||
|
target: MarkedTarget,
|
||||||
|
showContent: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SelectorCard({ index = 1, target, showContent } : Props) {
|
||||||
|
return (
|
||||||
|
<div className={cn(stl.wrapper, { [stl.active]: showContent })}>
|
||||||
|
<div className={stl.top} onClick={() => activeTarget(index)}>
|
||||||
|
<div className={stl.index}>{index + 1}</div>
|
||||||
|
<div className="truncate">{target.selector}</div>
|
||||||
|
</div>
|
||||||
|
{ showContent && (
|
||||||
|
<div className={stl.counts}>
|
||||||
|
<div>{target.count} Clicks - {target.percent}%</div>
|
||||||
|
<div className="color-gray-medium">TOTAL CLICKS</div>
|
||||||
|
</div>
|
||||||
|
) }
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export { default } from './SelectorCard'
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { NoContent } from 'UI'
|
||||||
|
import { connectPlayer } from 'Player/store';
|
||||||
|
import SelectorCard from '../SelectorCard/SelectorCard';
|
||||||
|
import type { MarkedTarget } from 'Player/MessageDistributor/StatedScreen/StatedScreen';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
targets: Array<MarkedTarget>,
|
||||||
|
activeTargetIndex: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectorsList({ targets, activeTargetIndex }: Props) {
|
||||||
|
return (
|
||||||
|
<NoContent
|
||||||
|
title="No data available."
|
||||||
|
size="small"
|
||||||
|
show={ targets && targets.length === 0 }
|
||||||
|
>
|
||||||
|
{ targets && targets.map((target, index) => (
|
||||||
|
<SelectorCard target={target} index={index} showContent={activeTargetIndex === index} />
|
||||||
|
))}
|
||||||
|
</NoContent>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default connectPlayer(state => ({
|
||||||
|
targets: state.markedTargets,
|
||||||
|
activeTargetIndex: state.activeTargetIndex,
|
||||||
|
}))(SelectorsList)
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export { default } from './SelectorsList'
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export { default } from './PageInsightsPanel'
|
||||||
|
|
@ -0,0 +1,116 @@
|
||||||
|
import { storiesOf } from '@storybook/react';
|
||||||
|
import { List } from 'immutable';
|
||||||
|
import PageInsightsPanel from './';
|
||||||
|
|
||||||
|
const list = [
|
||||||
|
{
|
||||||
|
"alertId": 2,
|
||||||
|
"projectId": 1,
|
||||||
|
"name": "new alert",
|
||||||
|
"description": null,
|
||||||
|
"active": true,
|
||||||
|
"threshold": 240,
|
||||||
|
"detectionMethod": "threshold",
|
||||||
|
"query": {
|
||||||
|
"left": "avgPageLoad",
|
||||||
|
"right": 1.0,
|
||||||
|
"operator": ">="
|
||||||
|
},
|
||||||
|
"createdAt": 1591893324078,
|
||||||
|
"options": {
|
||||||
|
"message": [
|
||||||
|
{
|
||||||
|
"type": "slack",
|
||||||
|
"value": "51"
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"LastNotification": 1592929583000,
|
||||||
|
"renotifyInterval": 120
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alertId": 14,
|
||||||
|
"projectId": 1,
|
||||||
|
"name": "alert 19.06",
|
||||||
|
"description": null,
|
||||||
|
"active": true,
|
||||||
|
"threshold": 30,
|
||||||
|
"detectionMethod": "threshold",
|
||||||
|
"query": {
|
||||||
|
"left": "avgPageLoad",
|
||||||
|
"right": 3000.0,
|
||||||
|
"operator": ">="
|
||||||
|
},
|
||||||
|
"createdAt": 1592579750935,
|
||||||
|
"options": {
|
||||||
|
"message": [
|
||||||
|
{
|
||||||
|
"type": "slack",
|
||||||
|
"value": "51"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"renotifyInterval": 120
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alertId": 15,
|
||||||
|
"projectId": 1,
|
||||||
|
"name": "notify every 60min",
|
||||||
|
"description": null,
|
||||||
|
"active": true,
|
||||||
|
"threshold": 30,
|
||||||
|
"detectionMethod": "threshold",
|
||||||
|
"query": {
|
||||||
|
"left": "avgPageLoad",
|
||||||
|
"right": 1.0,
|
||||||
|
"operator": ">="
|
||||||
|
},
|
||||||
|
"createdAt": 1592848779604,
|
||||||
|
"options": {
|
||||||
|
"message": [
|
||||||
|
{
|
||||||
|
"type": "slack",
|
||||||
|
"value": "51"
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"LastNotification": 1599135058000,
|
||||||
|
"renotifyInterval": 60
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alertId": 21,
|
||||||
|
"projectId": 1,
|
||||||
|
"name": "always notify",
|
||||||
|
"description": null,
|
||||||
|
"active": true,
|
||||||
|
"threshold": 30,
|
||||||
|
"detectionMethod": "threshold",
|
||||||
|
"query": {
|
||||||
|
"left": "avgPageLoad",
|
||||||
|
"right": 1.0,
|
||||||
|
"operator": ">="
|
||||||
|
},
|
||||||
|
"createdAt": 1592849011350,
|
||||||
|
"options": {
|
||||||
|
"message": [
|
||||||
|
{
|
||||||
|
"type": "slack",
|
||||||
|
"value": "51"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"LastNotification": 1599135058000,
|
||||||
|
"renotifyInterval": 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const notifications = List([
|
||||||
|
{ title: 'test', type: 'change', createdAt: 1591893324078, description: 'Lorem ipusm'},
|
||||||
|
{ title: 'test', type: 'threshold', createdAt: 1591893324078, description: 'Lorem ipusm'},
|
||||||
|
{ title: 'test', type: 'threshold', createdAt: 1591893324078, description: 'Lorem ipusm'},
|
||||||
|
{ title: 'test', type: 'threshold', createdAt: 1591893324078, description: 'Lorem ipusm'},
|
||||||
|
])
|
||||||
|
storiesOf('PageInsights', module)
|
||||||
|
.add('Panel', () => (
|
||||||
|
<PageInsightsPanel />
|
||||||
|
))
|
||||||
|
|
@ -73,7 +73,7 @@ function getStorageName(type) {
|
||||||
skip: state.skip,
|
skip: state.skip,
|
||||||
skipToIssue: state.skipToIssue,
|
skipToIssue: state.skipToIssue,
|
||||||
speed: state.speed,
|
speed: state.speed,
|
||||||
disabled: state.cssLoading || state.messagesLoading || state.inspectorMode,
|
disabled: state.cssLoading || state.messagesLoading || state.inspectorMode || state.markedTargets,
|
||||||
inspectorMode: state.inspectorMode,
|
inspectorMode: state.inspectorMode,
|
||||||
fullscreenDisabled: state.messagesLoading,
|
fullscreenDisabled: state.messagesLoading,
|
||||||
logCount: state.logListNow.length,
|
logCount: state.logListNow.length,
|
||||||
|
|
@ -246,11 +246,12 @@ export default class Controls extends React.Component {
|
||||||
showLongtasks,
|
showLongtasks,
|
||||||
exceptionsCount,
|
exceptionsCount,
|
||||||
showExceptions,
|
showExceptions,
|
||||||
fullscreen,
|
fullscreen,
|
||||||
skipToIssue
|
skipToIssue,
|
||||||
|
inspectorMode
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
const inspectorMode = bottomBlock === INSPECTOR;
|
// const inspectorMode = bottomBlock === INSPECTOR;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={ cn(styles.controls, {'px-5 pt-0' : live}) }>
|
<div className={ cn(styles.controls, {'px-5 pt-0' : live}) }>
|
||||||
|
|
@ -419,6 +420,7 @@ export default class Controls extends React.Component {
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
{!live && (
|
{!live && (
|
||||||
<ControlButton
|
<ControlButton
|
||||||
disabled={ disabled && !inspectorMode }
|
disabled={ disabled && !inspectorMode }
|
||||||
|
|
|
||||||
79
frontend/app/components/Session_/Player/Overlay.tsx
Normal file
79
frontend/app/components/Session_/Player/Overlay.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
import React, {useEffect} from 'react';
|
||||||
|
import { connectPlayer, markTargets } from 'Player';
|
||||||
|
import { getStatusText } from 'Player/MessageDistributor/managers/AssistManager';
|
||||||
|
import type { MarkedTarget } from 'Player/MessageDistributor/StatedScreen/StatedScreen';
|
||||||
|
|
||||||
|
import AutoplayTimer from './Overlay/AutoplayTimer';
|
||||||
|
import PlayIconLayer from './Overlay/PlayIconLayer';
|
||||||
|
import LiveStatusText from './Overlay/LiveStatusText';
|
||||||
|
import Loader from './Overlay/Loader';
|
||||||
|
import ElementsMarker from './Overlay/ElementsMarker';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
playing: boolean,
|
||||||
|
completed: boolean,
|
||||||
|
inspectorMode: boolean,
|
||||||
|
messagesLoading: boolean,
|
||||||
|
loading: boolean,
|
||||||
|
live: boolean,
|
||||||
|
liveStatusText: string,
|
||||||
|
autoplay: boolean,
|
||||||
|
markedTargets: MarkedTarget[] | null,
|
||||||
|
activeTargetIndex: number,
|
||||||
|
|
||||||
|
nextId: string,
|
||||||
|
togglePlay: () => void,
|
||||||
|
}
|
||||||
|
|
||||||
|
function Overlay({
|
||||||
|
playing,
|
||||||
|
completed,
|
||||||
|
inspectorMode,
|
||||||
|
messagesLoading,
|
||||||
|
loading,
|
||||||
|
live,
|
||||||
|
liveStatusText,
|
||||||
|
autoplay,
|
||||||
|
markedTargets,
|
||||||
|
activeTargetIndex,
|
||||||
|
nextId,
|
||||||
|
togglePlay,
|
||||||
|
}: Props) {
|
||||||
|
|
||||||
|
// useEffect(() =>{
|
||||||
|
// setTimeout(() => markTargets([{ selector: 'div', count:6}]), 5000)
|
||||||
|
// setTimeout(() => markTargets(null), 8000)
|
||||||
|
// },[])
|
||||||
|
|
||||||
|
const showAutoplayTimer = !live && completed && autoplay && nextId
|
||||||
|
const showPlayIconLayer = !live && !markedTargets && !inspectorMode && !loading && !showAutoplayTimer;
|
||||||
|
const showLiveStatusText = live && liveStatusText && !loading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{ showAutoplayTimer && <AutoplayTimer /> }
|
||||||
|
{ showLiveStatusText &&
|
||||||
|
<LiveStatusText text={liveStatusText} />
|
||||||
|
}
|
||||||
|
{ messagesLoading && <Loader/> }
|
||||||
|
{ showPlayIconLayer &&
|
||||||
|
<PlayIconLayer playing={playing} togglePlay={togglePlay} />
|
||||||
|
}
|
||||||
|
{ markedTargets && <ElementsMarker targets={ markedTargets } activeIndex={activeTargetIndex}/>
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default connectPlayer(state => ({
|
||||||
|
playing: state.playing,
|
||||||
|
messagesLoading: state.messagesLoading,
|
||||||
|
loading: state.messagesLoading || state.cssLoading,
|
||||||
|
completed: state.completed,
|
||||||
|
autoplay: state.autoplay,
|
||||||
|
live: state.live,
|
||||||
|
liveStatusText: getStatusText(state.peerConnectionStatus),
|
||||||
|
markedTargets: state.markedTargets,
|
||||||
|
activeTargetIndex: state.activeTargetIndex,
|
||||||
|
}))(Overlay);
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
.overlayBg {
|
||||||
|
background-color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import cn from 'classnames';
|
||||||
import { connect } from 'react-redux'
|
import { connect } from 'react-redux'
|
||||||
|
import { withRouter } from 'react-router-dom';
|
||||||
import { Button, Link } from 'UI'
|
import { Button, Link } from 'UI'
|
||||||
import { session as sessionRoute, withSiteId } from 'App/routes'
|
import { session as sessionRoute, withSiteId } from 'App/routes'
|
||||||
import stl from './AutoplayTimer.css'
|
import stl from './AutoplayTimer.css';
|
||||||
import { withRouter } from 'react-router-dom';
|
import clsOv from './overlay.css';
|
||||||
|
|
||||||
function AutoplayTimer({ nextId, siteId, history }) {
|
function AutoplayTimer({ nextId, siteId, history }) {
|
||||||
let timer
|
let timer
|
||||||
|
|
@ -33,7 +35,7 @@ function AutoplayTimer({ nextId, siteId, history }) {
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={stl.overlay}>
|
<div className={ cn(clsOv.overlay, stl.overlayBg) } >
|
||||||
<div className="border p-6 shadow-lg bg-white rounded">
|
<div className="border p-6 shadow-lg bg-white rounded">
|
||||||
<div className="py-4">Next recording will be played in {counter}s</div>
|
<div className="py-4">Next recording will be played in {counter}s</div>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import React from 'react';
|
||||||
|
import Marker from './ElementsMarker/Marker';
|
||||||
|
|
||||||
|
export default function ElementsMarker({ targets, activeIndex }) {
|
||||||
|
return targets && targets.map(t => <Marker target={t} active={activeIndex === t.index - 1}/>)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
.marker {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 100;
|
||||||
|
border: 2px dotted transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
& .index {
|
||||||
|
opacity: 1;
|
||||||
|
transition: 0.3s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& .index {
|
||||||
|
opacity: 0.3;
|
||||||
|
position: absolute;
|
||||||
|
top: -10px;
|
||||||
|
left: -10px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background-color: $tealx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: solid thin white;
|
||||||
|
box-shadow: 1px 1px 1px rgba(0, 0, 0, 0.1);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .tooltip {
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background-color: $tealx;
|
||||||
|
box-shadow: 1px 1px 1px rgba(0, 0, 0, 0.2);
|
||||||
|
font-size: 12px;
|
||||||
|
/* position: absolute; */
|
||||||
|
/* bottom: 100%; */
|
||||||
|
/* left: 0; */
|
||||||
|
/* margin-bottom: 20px; */
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
top: 100%;
|
||||||
|
left: 40%;
|
||||||
|
border-color: $tealx transparent transparent transparent;
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
border-style: solid;
|
||||||
|
border-width: 10px 10px 10px 10px;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.active {
|
||||||
|
border: 2px dotted $tealx;
|
||||||
|
|
||||||
|
& .index {
|
||||||
|
opacity: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
import React from 'react';
|
||||||
|
import type { MarkedTarget } from 'Player/MessageDistributor/StatedScreen/StatedScreen';
|
||||||
|
import { Tooltip } from 'react-tippy';
|
||||||
|
import cn from 'classnames';
|
||||||
|
import stl from './Marker.css';
|
||||||
|
import { activeTarget } from 'Player';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
target: MarkedTarget;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Marker({ target, active }: Props) {
|
||||||
|
const style = {
|
||||||
|
top: `${ target.boundingRect.top }px`,
|
||||||
|
left: `${ target.boundingRect.left }px`,
|
||||||
|
width: `${ target.boundingRect.width }px`,
|
||||||
|
height: `${ target.boundingRect.height }px`,
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={ cn(stl.marker, { [stl.active] : active }) } style={ style } onClick={() => activeTarget(target.index - 1)}>
|
||||||
|
<div className={stl.index}>{target.index}</div>
|
||||||
|
<Tooltip
|
||||||
|
open={active}
|
||||||
|
arrow
|
||||||
|
sticky
|
||||||
|
distance={15}
|
||||||
|
html={(
|
||||||
|
<div>{target.count} Clicks</div>
|
||||||
|
)}
|
||||||
|
trigger="mouseenter"
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0"></div>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
.text {
|
||||||
|
color: $gray-light;
|
||||||
|
font-size: 40px;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import React from 'react';
|
||||||
|
import stl from './LiveStatusText.css';
|
||||||
|
import ovStl from './overlay.css';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LiveStatusText({ text }: Props) {
|
||||||
|
return <div className={ovStl.overlay}><span className={stl.text}>{text}</span></div>
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Loader } from 'UI';
|
||||||
|
import ovStl from './overlay.css';
|
||||||
|
|
||||||
|
export default function OverlayLoader() {
|
||||||
|
return <div className={ovStl.overlay}><Loader loading /></div>
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
.iconWrapper {
|
||||||
|
background-color: rgba(0, 0, 0, 0.1);
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
opacity: 0;
|
||||||
|
transition: all .2s; /* Animation */
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoomIcon {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1.8);
|
||||||
|
transition: all .8s;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import cn from 'classnames';
|
||||||
|
import { Icon } from 'UI';
|
||||||
|
|
||||||
|
import cls from './PlayIconLayer.css';
|
||||||
|
import clsOv from './overlay.css';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
togglePlay: () => void,
|
||||||
|
playing: boolean,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PlayIconLayer({ playing, togglePlay }: Props) {
|
||||||
|
const [ showPlayOverlayIcon, setShowPlayOverlayIcon ] = useState(false);
|
||||||
|
const togglePlayAnimated = useCallback(() => {
|
||||||
|
setShowPlayOverlayIcon(true);
|
||||||
|
togglePlay();
|
||||||
|
setTimeout(
|
||||||
|
() => setShowPlayOverlayIcon(false),
|
||||||
|
800,
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<div className={ clsOv.overlay } onClick={ togglePlayAnimated }>
|
||||||
|
<div
|
||||||
|
className={ cn(cls.iconWrapper, {
|
||||||
|
[ cls.zoomIcon ]: showPlayOverlayIcon
|
||||||
|
}) }
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
name={ playing ? "play" : "pause" }
|
||||||
|
color="gray-medium"
|
||||||
|
size={30}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,3 @@
|
||||||
.wrapper {
|
|
||||||
width: 30%;
|
|
||||||
height: 30%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overlay {
|
.overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|
@ -13,5 +8,4 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background-color: rgba(255, 255, 255, 0.8);
|
}
|
||||||
}
|
|
||||||
|
|
@ -2,29 +2,17 @@ import { connect } from 'react-redux';
|
||||||
import { findDOMNode } from 'react-dom';
|
import { findDOMNode } from 'react-dom';
|
||||||
import cn from 'classnames';
|
import cn from 'classnames';
|
||||||
import { Loader, IconButton, EscapeButton } from 'UI';
|
import { Loader, IconButton, EscapeButton } from 'UI';
|
||||||
import { hide as hideTargetDefiner, toggleInspectorMode } from 'Duck/components/targetDefiner';
|
import { hide as hideTargetDefiner } from 'Duck/components/targetDefiner';
|
||||||
import { fullscreenOff } from 'Duck/components/player';
|
import { fullscreenOff } from 'Duck/components/player';
|
||||||
import withOverlay from 'Components/hocs/withOverlay';
|
|
||||||
import { attach as attachPlayer, Controls as PlayerControls, connectPlayer } from 'Player';
|
import { attach as attachPlayer, Controls as PlayerControls, connectPlayer } from 'Player';
|
||||||
import Controls from './Controls';
|
import Controls from './Controls';
|
||||||
|
import Overlay from './Overlay';
|
||||||
import stl from './player.css';
|
import stl from './player.css';
|
||||||
import AutoplayTimer from '../AutoplayTimer';
|
|
||||||
import EventsToggleButton from '../../Session/EventsToggleButton';
|
import EventsToggleButton from '../../Session/EventsToggleButton';
|
||||||
import { getStatusText } from 'Player/MessageDistributor/managers/AssistManager';
|
|
||||||
|
|
||||||
|
|
||||||
const ScreenWrapper = withOverlay()(React.memo(() => <div className={ stl.screenWrapper } />));
|
|
||||||
|
|
||||||
@connectPlayer(state => ({
|
@connectPlayer(state => ({
|
||||||
playing: state.playing,
|
|
||||||
loading: state.messagesLoading,
|
|
||||||
disconnected: state.disconnected,
|
|
||||||
disabled: state.cssLoading || state.messagesLoading || state.inspectorMode,
|
|
||||||
removeOverlay: !state.messagesLoading && state.inspectorMode || state.live,
|
|
||||||
completed: state.completed,
|
|
||||||
autoplay: state.autoplay,
|
|
||||||
live: state.live,
|
live: state.live,
|
||||||
liveStatusText: getStatusText(state.peerConnectionStatus),
|
|
||||||
}))
|
}))
|
||||||
@connect(state => ({
|
@connect(state => ({
|
||||||
//session: state.getIn([ 'sessions', 'current' ]),
|
//session: state.getIn([ 'sessions', 'current' ]),
|
||||||
|
|
@ -32,15 +20,9 @@ const ScreenWrapper = withOverlay()(React.memo(() => <div className={ stl.screen
|
||||||
nextId: state.getIn([ 'sessions', 'nextId' ]),
|
nextId: state.getIn([ 'sessions', 'nextId' ]),
|
||||||
}), {
|
}), {
|
||||||
hideTargetDefiner,
|
hideTargetDefiner,
|
||||||
toggleInspectorMode: () => toggleInspectorMode(false),
|
|
||||||
fullscreenOff,
|
fullscreenOff,
|
||||||
})
|
})
|
||||||
export default class Player extends React.PureComponent {
|
export default class Player extends React.PureComponent {
|
||||||
state = {
|
|
||||||
showPlayOverlayIcon: false,
|
|
||||||
|
|
||||||
startedToPlayAt: Date.now(),
|
|
||||||
};
|
|
||||||
screenWrapper = React.createRef();
|
screenWrapper = React.createRef();
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
|
|
@ -48,69 +30,14 @@ export default class Player extends React.PureComponent {
|
||||||
attachPlayer(parentElement);
|
attachPlayer(parentElement);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
|
||||||
if (prevProps.targetSelector !== this.props.targetSelector) {
|
|
||||||
PlayerControls.mark(this.props.targetSelector);
|
|
||||||
}
|
|
||||||
if (prevProps.playing !== this.props.playing) {
|
|
||||||
if (this.props.playing) {
|
|
||||||
this.setState({ startedToPlayAt: Date.now() });
|
|
||||||
} else {
|
|
||||||
this.updateWatchingTime();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount() {
|
|
||||||
if (this.props.playing) {
|
|
||||||
this.updateWatchingTime();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateWatchingTime() {
|
|
||||||
const diff = Date.now() - this.state.startedToPlayAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// onTargetClick = (targetPath) => {
|
|
||||||
// const { targetCustomList, location } = this.props;
|
|
||||||
// const targetCustomFromList = targetCustomList !== this.props.targetSelector
|
|
||||||
// .find(({ path }) => path === targetPath);
|
|
||||||
// const target = targetCustomFromList
|
|
||||||
// ? targetCustomFromList.set('location', location)
|
|
||||||
// : { path: targetPath, isCustom: true, location };
|
|
||||||
// this.props.showTargetDefiner(target);
|
|
||||||
// }
|
|
||||||
|
|
||||||
togglePlay = () => {
|
|
||||||
this.setState({ showPlayOverlayIcon: true });
|
|
||||||
PlayerControls.togglePlay();
|
|
||||||
|
|
||||||
setTimeout(
|
|
||||||
() => this.setState({ showPlayOverlayIcon: false }),
|
|
||||||
800,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const {
|
|
||||||
showPlayOverlayIcon,
|
|
||||||
} = this.state;
|
|
||||||
const {
|
const {
|
||||||
className,
|
className,
|
||||||
playing,
|
|
||||||
disabled,
|
|
||||||
removeOverlay,
|
|
||||||
bottomBlockIsActive,
|
bottomBlockIsActive,
|
||||||
loading,
|
|
||||||
disconnected,
|
|
||||||
fullscreen,
|
fullscreen,
|
||||||
fullscreenOff,
|
fullscreenOff,
|
||||||
completed,
|
|
||||||
autoplay,
|
|
||||||
nextId,
|
nextId,
|
||||||
live,
|
live,
|
||||||
liveStatusText,
|
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -120,40 +47,12 @@ export default class Player extends React.PureComponent {
|
||||||
>
|
>
|
||||||
{ fullscreen &&
|
{ fullscreen &&
|
||||||
<EscapeButton onClose={ fullscreenOff } />
|
<EscapeButton onClose={ fullscreenOff } />
|
||||||
// <IconButton
|
|
||||||
// size="18"
|
|
||||||
// className="ml-auto mb-5"
|
|
||||||
// style={{ marginTop: '-5px' }}
|
|
||||||
// onClick={ fullscreenOff }
|
|
||||||
// size="small"
|
|
||||||
// icon="close"
|
|
||||||
// label="Esc"
|
|
||||||
// />
|
|
||||||
}
|
}
|
||||||
{!live && !fullscreen && <EventsToggleButton /> }
|
{!live && !fullscreen && <EventsToggleButton /> }
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1 overflow-hidden">
|
||||||
{ (!removeOverlay || live && liveStatusText) &&
|
<Overlay nextId={nextId} togglePlay={PlayerControls.togglePlay} />
|
||||||
<div
|
<div
|
||||||
className={ stl.overlay }
|
className={ stl.screenWrapper }
|
||||||
onClick={ disabled ? null : this.togglePlay }
|
|
||||||
>
|
|
||||||
{ live && liveStatusText
|
|
||||||
? <span className={stl.liveStatusText}>{liveStatusText}</span>
|
|
||||||
: <Loader loading={ loading } />
|
|
||||||
}
|
|
||||||
{ !live &&
|
|
||||||
<div
|
|
||||||
className={ cn(stl.iconWrapper, {
|
|
||||||
[ stl.zoomIcon ]: showPlayOverlayIcon
|
|
||||||
}) }
|
|
||||||
>
|
|
||||||
<div className={ playing ? stl.playIcon : stl.pauseIcon } />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
{ completed && autoplay && nextId && <AutoplayTimer /> }
|
|
||||||
<ScreenWrapper
|
|
||||||
ref={ this.screenWrapper }
|
ref={ this.screenWrapper }
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
@import 'icons.css';
|
|
||||||
|
|
||||||
.playerBody {
|
.playerBody {
|
||||||
background: $white;
|
background: $white;
|
||||||
/* border-radius: 3px; */
|
/* border-radius: 3px; */
|
||||||
|
|
@ -25,61 +23,8 @@
|
||||||
font-weight: 200;
|
font-weight: 200;
|
||||||
color: $gray-medium;
|
color: $gray-medium;
|
||||||
}
|
}
|
||||||
.overlay {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
right: 0;
|
|
||||||
left: 0;
|
|
||||||
z-index: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
|
|
||||||
/* &[data-protect] {
|
|
||||||
pointer-events: none;
|
|
||||||
background: $white;
|
|
||||||
opacity: 0.3;
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
& .iconWrapper {
|
|
||||||
background-color: rgba(0, 0, 0, 0.1);
|
|
||||||
width: 50px;
|
|
||||||
height: 50px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 50%;
|
|
||||||
opacity: 0;
|
|
||||||
transition: all .2s; /* Animation */
|
|
||||||
}
|
|
||||||
|
|
||||||
& .zoomIcon {
|
|
||||||
opacity: 1;
|
|
||||||
transform: scale(1.8);
|
|
||||||
transition: all .8s;
|
|
||||||
}
|
|
||||||
|
|
||||||
& .playIcon {
|
|
||||||
@mixin icon play, $gray-medium, 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
& .pauseIcon {
|
|
||||||
@mixin icon pause, $gray-medium, 30px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.playerView {
|
.playerView {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inspectorMode {
|
|
||||||
z-index: 99991 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.liveStatusText {
|
|
||||||
color: $gray-light;
|
|
||||||
font-size: 40px;
|
|
||||||
}
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { connect } from 'react-redux';
|
||||||
import DateRangeDropdown from 'Shared/DateRangeDropdown';
|
import DateRangeDropdown from 'Shared/DateRangeDropdown';
|
||||||
|
|
||||||
function DateRange (props) {
|
function DateRange (props) {
|
||||||
const { startDate, endDate, rangeValue, className, onDateChange, customRangeRight=false } = props;
|
const { startDate, endDate, rangeValue, className, onDateChange, customRangeRight=false, customHidden = false } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DateRangeDropdown
|
<DateRangeDropdown
|
||||||
|
|
@ -13,6 +13,7 @@ function DateRange (props) {
|
||||||
endDate={ endDate }
|
endDate={ endDate }
|
||||||
className={ className }
|
className={ className }
|
||||||
customRangeRight={customRangeRight}
|
customRangeRight={customRangeRight}
|
||||||
|
customHidden={customHidden}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
.wrapper {
|
.wrapper {
|
||||||
& .body {
|
background-color: white;
|
||||||
display: flex;
|
outline: solid thin #CCC;
|
||||||
border-bottom: solid thin $gray-light;
|
& .body {
|
||||||
padding: 5px;
|
display: flex;
|
||||||
}
|
border-bottom: solid thin $gray-light;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.preSelections {
|
.preSelections {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import React from 'react'
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import copy from 'copy-to-clipboard';
|
import copy from 'copy-to-clipboard';
|
||||||
|
|
||||||
function CopyButton({ content, className }) {
|
function CopyButton({ content, className, btnText = 'copy' }) {
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
const copyHandler = () => {
|
const copyHandler = () => {
|
||||||
|
|
@ -17,7 +17,7 @@ function CopyButton({ content, className }) {
|
||||||
className={ className }
|
className={ className }
|
||||||
onClick={ copyHandler }
|
onClick={ copyHandler }
|
||||||
>
|
>
|
||||||
{ copied ? 'copied' : 'copy' }
|
{ copied ? 'copied' : btnText }
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import React from 'react';
|
||||||
import cn from 'classnames';
|
import cn from 'classnames';
|
||||||
import SVG from 'UI/SVG';
|
import SVG from 'UI/SVG';
|
||||||
import styles from './icon.css';
|
import styles from './icon.css';
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,12 @@ export default ({
|
||||||
show = true,
|
show = true,
|
||||||
children = null,
|
children = null,
|
||||||
empty = false,
|
empty = false,
|
||||||
|
image = null
|
||||||
}) => (!show ? children :
|
}) => (!show ? children :
|
||||||
<div className={ `${ styles.wrapper } ${ size && styles[ size ] }` }>
|
<div className={ `${ styles.wrapper } ${ size && styles[ size ] }` }>
|
||||||
|
{
|
||||||
|
image && image
|
||||||
|
}
|
||||||
{
|
{
|
||||||
icon && <div className={ empty ? styles.emptyIcon : styles.icon } />
|
icon && <div className={ empty ? styles.emptyIcon : styles.icon } />
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,48 @@
|
||||||
|
import { Map } from 'immutable';
|
||||||
import Member from 'Types/member';
|
import Member from 'Types/member';
|
||||||
import crudDuckGenerator from './tools/crudDuck';
|
import crudDuckGenerator from './tools/crudDuck';
|
||||||
|
import withRequestState, { RequestTypes } from 'Duck/requestStateCreator';
|
||||||
|
import { reduceDucks } from 'Duck/tools';
|
||||||
|
|
||||||
|
const GENERATE_LINK = new RequestTypes('member/GENERATE_LINK');
|
||||||
|
|
||||||
const crudDuck = crudDuckGenerator('client/member', Member, { idKey: 'id' });
|
const crudDuck = crudDuckGenerator('client/member', Member, { idKey: 'id' });
|
||||||
export const {
|
export const { fetchList, init, edit, remove, } = crudDuck.actions;
|
||||||
fetchList, init, edit, remove,
|
|
||||||
} = crudDuck.actions;
|
const initialState = Map({
|
||||||
|
definedPercent: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const reducer = (state = initialState, action = {}) => {
|
||||||
|
switch (action.type) {
|
||||||
|
case GENERATE_LINK.SUCCESS:
|
||||||
|
return state.update(
|
||||||
|
'list',
|
||||||
|
list => list
|
||||||
|
.map(member => {
|
||||||
|
if(member.id === action.id) {
|
||||||
|
return Member({...member.toJS(), invitationLink: action.data.invitationLink })
|
||||||
|
}
|
||||||
|
return member
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
export function save(instance) {
|
export function save(instance) {
|
||||||
return {
|
return {
|
||||||
types: crudDuck.actionTypes.SAVE.toArray(),
|
types: crudDuck.actionTypes.SAVE.toArray(),
|
||||||
call: client => client.put( instance.id ? `/client/members/${ instance.id }` : '/client/members', instance.toData()),
|
call: client => client.put( instance.id ? `/client/members/${ instance.id }` : '/client/members', instance.toData()),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default crudDuck.reducer;
|
export function generateInviteLink(instance) {
|
||||||
|
return {
|
||||||
|
types: GENERATE_LINK.toArray(),
|
||||||
|
call: client => client.get(`/client/members/${ instance.id }/reset`),
|
||||||
|
id: instance.id
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default reduceDucks(crudDuck, { initialState, reducer }).reducer;
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import Watchdog, { getSessionWatchdogTypes } from 'Types/watchdog';
|
||||||
import { clean as cleanParams } from 'App/api_client';
|
import { clean as cleanParams } from 'App/api_client';
|
||||||
import withRequestState, { RequestTypes } from './requestStateCreator';
|
import withRequestState, { RequestTypes } from './requestStateCreator';
|
||||||
import { getRE } from 'App/utils';
|
import { getRE } from 'App/utils';
|
||||||
|
import { LAST_7_DAYS } from 'Types/app/period';
|
||||||
|
import { getDateRangeFromValue } from 'App/dateRange';
|
||||||
|
|
||||||
|
|
||||||
const INIT = 'sessions/INIT';
|
const INIT = 'sessions/INIT';
|
||||||
|
|
@ -15,6 +17,7 @@ const FETCH_FAVORITE_LIST = new RequestTypes('sessions/FETCH_FAVORITE_LIST');
|
||||||
const FETCH_LIVE_LIST = new RequestTypes('sessions/FETCH_LIVE_LIST');
|
const FETCH_LIVE_LIST = new RequestTypes('sessions/FETCH_LIVE_LIST');
|
||||||
const TOGGLE_FAVORITE = new RequestTypes('sessions/TOGGLE_FAVORITE');
|
const TOGGLE_FAVORITE = new RequestTypes('sessions/TOGGLE_FAVORITE');
|
||||||
const FETCH_ERROR_STACK = new RequestTypes('sessions/FETCH_ERROR_STACK');
|
const FETCH_ERROR_STACK = new RequestTypes('sessions/FETCH_ERROR_STACK');
|
||||||
|
const FETCH_INSIGHTS = new RequestTypes('sessions/FETCH_INSIGHTS');
|
||||||
const SORT = 'sessions/SORT';
|
const SORT = 'sessions/SORT';
|
||||||
const REDEFINE_TARGET = 'sessions/REDEFINE_TARGET';
|
const REDEFINE_TARGET = 'sessions/REDEFINE_TARGET';
|
||||||
const SET_TIMEZONE = 'sessions/SET_TIMEZONE';
|
const SET_TIMEZONE = 'sessions/SET_TIMEZONE';
|
||||||
|
|
@ -24,6 +27,14 @@ const TOGGLE_CHAT_WINDOW = 'sessions/TOGGLE_CHAT_WINDOW';
|
||||||
|
|
||||||
const SET_ACTIVE_TAB = 'sessions/SET_ACTIVE_TAB';
|
const SET_ACTIVE_TAB = 'sessions/SET_ACTIVE_TAB';
|
||||||
|
|
||||||
|
const range = getDateRangeFromValue(LAST_7_DAYS);
|
||||||
|
const defaultDateFilters = {
|
||||||
|
url: '',
|
||||||
|
rangeValue: LAST_7_DAYS,
|
||||||
|
startDate: range.start.unix() * 1000,
|
||||||
|
endDate: range.end.unix() * 1000
|
||||||
|
}
|
||||||
|
|
||||||
const initialState = Map({
|
const initialState = Map({
|
||||||
list: List(),
|
list: List(),
|
||||||
sessionIds: [],
|
sessionIds: [],
|
||||||
|
|
@ -39,7 +50,10 @@ const initialState = Map({
|
||||||
sourcemapUploaded: true,
|
sourcemapUploaded: true,
|
||||||
filteredEvents: null,
|
filteredEvents: null,
|
||||||
showChatWindow: false,
|
showChatWindow: false,
|
||||||
liveSessions: List()
|
liveSessions: List(),
|
||||||
|
visitedEvents: List(),
|
||||||
|
insights: List(),
|
||||||
|
insightFilters: defaultDateFilters
|
||||||
});
|
});
|
||||||
|
|
||||||
const reducer = (state = initialState, action = {}) => {
|
const reducer = (state = initialState, action = {}) => {
|
||||||
|
|
@ -136,21 +150,32 @@ const reducer = (state = initialState, action = {}) => {
|
||||||
const session = Session(action.data);
|
const session = Session(action.data);
|
||||||
|
|
||||||
const matching = [];
|
const matching = [];
|
||||||
|
|
||||||
|
const visitedEvents = []
|
||||||
|
const tmpMap = {}
|
||||||
|
session.events.forEach(event => {
|
||||||
|
if (event.type === 'LOCATION' && !tmpMap.hasOwnProperty(event.url)) {
|
||||||
|
tmpMap[event.url] = event.url
|
||||||
|
visitedEvents.push(event)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
events.forEach(({ key, operator, value }) => {
|
events.forEach(({ key, operator, value }) => {
|
||||||
session.events.forEach((e, index) => {
|
session.events.forEach((e, index) => {
|
||||||
if (key === e.type) {
|
if (key === e.type) {
|
||||||
const val = (e.type === 'LOCATION' ? e.url : e.value);
|
const val = (e.type === 'LOCATION' ? e.url : e.value);
|
||||||
if (operator === 'is' && value === val) {
|
if (operator === 'is' && value === val) {
|
||||||
matching.push(index);
|
matching.push(index);
|
||||||
}
|
}
|
||||||
if (operator === 'contains' && val.includes(value)) {
|
if (operator === 'contains' && val.includes(value)) {
|
||||||
matching.push(index);
|
matching.push(index);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
return state.set('current', current.merge(session)).set('eventsIndex', matching);
|
return state.set('current', current.merge(session))
|
||||||
|
.set('eventsIndex', matching)
|
||||||
|
.set('visitedEvents', visitedEvents);
|
||||||
}
|
}
|
||||||
case FETCH_FAVORITE_LIST.SUCCESS:
|
case FETCH_FAVORITE_LIST.SUCCESS:
|
||||||
return state
|
return state
|
||||||
|
|
@ -202,9 +227,11 @@ const reducer = (state = initialState, action = {}) => {
|
||||||
.set('sessionIds', allList.map(({ sessionId }) => sessionId ).toJS())
|
.set('sessionIds', allList.map(({ sessionId }) => sessionId ).toJS())
|
||||||
case SET_TIMEZONE:
|
case SET_TIMEZONE:
|
||||||
return state.set('timezone', action.timezone)
|
return state.set('timezone', action.timezone)
|
||||||
case TOGGLE_CHAT_WINDOW:
|
case TOGGLE_CHAT_WINDOW:
|
||||||
console.log(action)
|
|
||||||
return state.set('showChatWindow', action.state)
|
return state.set('showChatWindow', action.state)
|
||||||
|
case FETCH_INSIGHTS.SUCCESS:
|
||||||
|
return state.set('insights', List(action.data).sort((a, b) => b.count - a.count));
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
@ -215,6 +242,7 @@ export default withRequestState({
|
||||||
fetchFavoriteListRequest: FETCH_FAVORITE_LIST,
|
fetchFavoriteListRequest: FETCH_FAVORITE_LIST,
|
||||||
toggleFavoriteRequest: TOGGLE_FAVORITE,
|
toggleFavoriteRequest: TOGGLE_FAVORITE,
|
||||||
fetchErrorStackList: FETCH_ERROR_STACK,
|
fetchErrorStackList: FETCH_ERROR_STACK,
|
||||||
|
fetchInsightsRequest: FETCH_INSIGHTS,
|
||||||
}, reducer);
|
}, reducer);
|
||||||
|
|
||||||
function init(session) {
|
function init(session) {
|
||||||
|
|
@ -263,6 +291,13 @@ export function fetchFavoriteList() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchInsights(params) {
|
||||||
|
return {
|
||||||
|
types: FETCH_INSIGHTS.toArray(),
|
||||||
|
call: client => client.post('/heatmaps/url', params),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function fetchLiveList() {
|
export function fetchLiveList() {
|
||||||
return {
|
return {
|
||||||
types: FETCH_LIVE_LIST.toArray(),
|
types: FETCH_LIVE_LIST.toArray(),
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,7 @@ const initialState = Map({
|
||||||
|
|
||||||
const reducer = (state = initialState, action = {}) => {
|
const reducer = (state = initialState, action = {}) => {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case FETCH_LIST.SUCCESS: {
|
case FETCH_LIST.SUCCESS: {
|
||||||
console.log(action);
|
|
||||||
return state.set('list', List(action.data).map(i => {
|
return state.set('list', List(action.data).map(i => {
|
||||||
const type = i.type === 'navigate' ? i.type : 'location';
|
const type = i.type === 'navigate' ? i.type : 'location';
|
||||||
return {...i, type: type.toUpperCase()}
|
return {...i, type: type.toUpperCase()}
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ const reducer = (state = initialState, action = {}) => {
|
||||||
case UPDATE_PASSWORD.SUCCESS:
|
case UPDATE_PASSWORD.SUCCESS:
|
||||||
case LOGIN.SUCCESS:
|
case LOGIN.SUCCESS:
|
||||||
return setClient(
|
return setClient(
|
||||||
state.set('account', Account(action.data.user)),
|
state.set('account', Account({...action.data.user, smtp: action.data.client.smtp })),
|
||||||
action.data.client,
|
action.data.client,
|
||||||
);
|
);
|
||||||
case SIGNUP.SUCCESS:
|
case SIGNUP.SUCCESS:
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import {
|
||||||
setStartTime as setListsStartTime
|
setStartTime as setListsStartTime
|
||||||
} from '../lists';
|
} from '../lists';
|
||||||
|
|
||||||
import StatedScreen from './StatedScreen';
|
import StatedScreen from './StatedScreen/StatedScreen';
|
||||||
|
|
||||||
import ListWalker from './managers/ListWalker';
|
import ListWalker from './managers/ListWalker';
|
||||||
import PagesManager from './managers/PagesManager';
|
import PagesManager from './managers/PagesManager';
|
||||||
|
|
@ -27,7 +27,7 @@ import AssistManager from './managers/AssistManager';
|
||||||
|
|
||||||
import MessageReader from './MessageReader';
|
import MessageReader from './MessageReader';
|
||||||
|
|
||||||
import { INITIAL_STATE as SUPER_INITIAL_STATE, State as SuperState } from './StatedScreen';
|
import { INITIAL_STATE as SUPER_INITIAL_STATE, State as SuperState } from './StatedScreen/StatedScreen';
|
||||||
import { INITIAL_STATE as ASSIST_INITIAL_STATE, State as AssistState } from './managers/AssistManager';
|
import { INITIAL_STATE as ASSIST_INITIAL_STATE, State as AssistState } from './managers/AssistManager';
|
||||||
|
|
||||||
import type { TimedMessage } from './Timed';
|
import type { TimedMessage } from './Timed';
|
||||||
|
|
@ -306,9 +306,7 @@ export default class MessageDistributor extends StatedScreen {
|
||||||
this.pagesManager.moveReady(t).then(() => {
|
this.pagesManager.moveReady(t).then(() => {
|
||||||
|
|
||||||
const lastScroll = this.scrollManager.moveToLast(t, index);
|
const lastScroll = this.scrollManager.moveToLast(t, index);
|
||||||
// @ts-ignore ??can't see double inheritance
|
|
||||||
if (!!lastScroll && this.window) {
|
if (!!lastScroll && this.window) {
|
||||||
// @ts-ignore
|
|
||||||
this.window.scrollTo(lastScroll.x, lastScroll.y);
|
this.window.scrollTo(lastScroll.x, lastScroll.y);
|
||||||
}
|
}
|
||||||
// Moving mouse and setting :hover classes on ready view
|
// Moving mouse and setting :hover classes on ready view
|
||||||
|
|
@ -479,7 +477,6 @@ export default class MessageDistributor extends StatedScreen {
|
||||||
|
|
||||||
// TODO: clean managers?
|
// TODO: clean managers?
|
||||||
clean() {
|
clean() {
|
||||||
// @ts-ignore
|
|
||||||
super.clean();
|
super.clean();
|
||||||
//if (this._socket) this._socket.close();
|
//if (this._socket) this._socket.close();
|
||||||
update(INITIAL_STATE);
|
update(INITIAL_STATE);
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ export const INITIAL_STATE: State = {
|
||||||
export default abstract class BaseScreen {
|
export default abstract class BaseScreen {
|
||||||
public readonly overlay: HTMLDivElement;
|
public readonly overlay: HTMLDivElement;
|
||||||
private readonly iframe: HTMLIFrameElement;
|
private readonly iframe: HTMLIFrameElement;
|
||||||
private readonly _screen: HTMLDivElement;
|
protected readonly screen: HTMLDivElement;
|
||||||
protected parentElement: HTMLElement | null = null;
|
protected parentElement: HTMLElement | null = null;
|
||||||
constructor() {
|
constructor() {
|
||||||
const iframe = document.createElement('iframe');
|
const iframe = document.createElement('iframe');
|
||||||
|
|
@ -44,7 +44,7 @@ export default abstract class BaseScreen {
|
||||||
screen.className = styles.screen;
|
screen.className = styles.screen;
|
||||||
screen.appendChild(iframe);
|
screen.appendChild(iframe);
|
||||||
screen.appendChild(overlay);
|
screen.appendChild(overlay);
|
||||||
this._screen = screen;
|
this.screen = screen;
|
||||||
}
|
}
|
||||||
|
|
||||||
attach(parentElement: HTMLElement) {
|
attach(parentElement: HTMLElement) {
|
||||||
|
|
@ -52,7 +52,7 @@ export default abstract class BaseScreen {
|
||||||
throw new Error("BaseScreen: Trying to attach an attached screen.");
|
throw new Error("BaseScreen: Trying to attach an attached screen.");
|
||||||
}
|
}
|
||||||
|
|
||||||
parentElement.appendChild(this._screen);
|
parentElement.appendChild(this.screen);
|
||||||
|
|
||||||
this.parentElement = parentElement;
|
this.parentElement = parentElement;
|
||||||
// parentElement.onresize = this.scale;
|
// parentElement.onresize = this.scale;
|
||||||
|
|
@ -115,29 +115,38 @@ export default abstract class BaseScreen {
|
||||||
return this.getElementsFromInternalPoint(this.getInternalCoordinates(point));
|
return this.getElementsFromInternalPoint(this.getInternalCoordinates(point));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getElementBySelector(selector: string): Element | null {
|
||||||
|
if (!selector) return null;
|
||||||
|
return this.document?.querySelector(selector) || null;
|
||||||
|
}
|
||||||
|
|
||||||
display(flag: boolean = true) {
|
display(flag: boolean = true) {
|
||||||
this._screen.style.display = flag ? '' : 'none';
|
this.screen.style.display = flag ? '' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
displayFrame(flag: boolean = true) {
|
displayFrame(flag: boolean = true) {
|
||||||
this.iframe.style.display = flag ? '' : 'none';
|
this.iframe.style.display = flag ? '' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private s: number = 1;
|
||||||
|
getScale() {
|
||||||
|
return this.s;
|
||||||
|
}
|
||||||
|
|
||||||
_scale() {
|
_scale() {
|
||||||
if (!this.parentElement) return;
|
if (!this.parentElement) return;
|
||||||
let s = 1;
|
|
||||||
const { height, width } = getState();
|
const { height, width } = getState();
|
||||||
const { offsetWidth, offsetHeight } = this.parentElement;
|
const { offsetWidth, offsetHeight } = this.parentElement;
|
||||||
|
|
||||||
s = Math.min(offsetWidth / width, offsetHeight / height);
|
this.s = Math.min(offsetWidth / width, offsetHeight / height);
|
||||||
if (s > 1) {
|
if (this.s > 1) {
|
||||||
s = 1;
|
this.s = 1;
|
||||||
} else {
|
} else {
|
||||||
s = Math.round(s * 1e3) / 1e3;
|
this.s = Math.round(this.s * 1e3) / 1e3;
|
||||||
}
|
}
|
||||||
this._screen.style.transform = `scale(${ s }) translate(-50%, -50%)`;
|
this.screen.style.transform = `scale(${ this.s }) translate(-50%, -50%)`;
|
||||||
this._screen.style.width = width + 'px';
|
this.screen.style.width = width + 'px';
|
||||||
this._screen.style.height = height + 'px';
|
this.screen.style.height = height + 'px';
|
||||||
this.iframe.style.width = width + 'px';
|
this.iframe.style.width = width + 'px';
|
||||||
this.iframe.style.height = height + 'px';
|
this.iframe.style.height = height + 'px';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,33 @@
|
||||||
import Screen, { INITIAL_STATE as SUPER_INITIAL_STATE, State as SuperState } from './Screen';
|
import Screen, { INITIAL_STATE as SUPER_INITIAL_STATE, State as SuperState } from './Screen/Screen';
|
||||||
import { update, getState } from '../../store';
|
import { update, getState } from '../../store';
|
||||||
|
|
||||||
|
|
||||||
|
//export interface targetPosition
|
||||||
|
|
||||||
|
interface BoundingRect {
|
||||||
|
top: number,
|
||||||
|
left: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarkedTarget {
|
||||||
|
boundingRect: BoundingRect,
|
||||||
|
el: Element,
|
||||||
|
selector: string,
|
||||||
|
count: number,
|
||||||
|
index: number,
|
||||||
|
active?: boolean,
|
||||||
|
percent: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface State extends SuperState {
|
export interface State extends SuperState {
|
||||||
messagesLoading: boolean,
|
messagesLoading: boolean,
|
||||||
cssLoading: boolean,
|
cssLoading: boolean,
|
||||||
disconnected: boolean,
|
disconnected: boolean,
|
||||||
userPageLoading: boolean,
|
userPageLoading: boolean,
|
||||||
|
markedTargets: MarkedTarget[] | null,
|
||||||
|
activeTargetIndex: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const INITIAL_STATE: State = {
|
export const INITIAL_STATE: State = {
|
||||||
|
|
@ -15,40 +36,88 @@ export const INITIAL_STATE: State = {
|
||||||
cssLoading: false,
|
cssLoading: false,
|
||||||
disconnected: false,
|
disconnected: false,
|
||||||
userPageLoading: false,
|
userPageLoading: false,
|
||||||
}
|
markedTargets: null,
|
||||||
|
activeTargetIndex: 0
|
||||||
|
};
|
||||||
|
|
||||||
export default class StatedScreen extends Screen {
|
export default class StatedScreen extends Screen {
|
||||||
constructor() { super(); }
|
constructor() { super(); }
|
||||||
|
|
||||||
setMessagesLoading(messagesLoading: boolean) {
|
setMessagesLoading(messagesLoading: boolean) {
|
||||||
// @ts-ignore
|
|
||||||
this.display(!messagesLoading);
|
this.display(!messagesLoading);
|
||||||
update({ messagesLoading });
|
update({ messagesLoading });
|
||||||
}
|
}
|
||||||
|
|
||||||
setCSSLoading(cssLoading: boolean) {
|
setCSSLoading(cssLoading: boolean) {
|
||||||
// @ts-ignore
|
|
||||||
|
|
||||||
this.displayFrame(!cssLoading);
|
this.displayFrame(!cssLoading);
|
||||||
update({ cssLoading });
|
update({ cssLoading });
|
||||||
}
|
}
|
||||||
|
|
||||||
setDisconnected(disconnected: boolean) {
|
setDisconnected(disconnected: boolean) {
|
||||||
if (!getState().live) return; //?
|
if (!getState().live) return; //?
|
||||||
// @ts-ignore
|
|
||||||
this.display(!disconnected);
|
this.display(!disconnected);
|
||||||
update({ disconnected });
|
update({ disconnected });
|
||||||
}
|
}
|
||||||
|
|
||||||
setUserPageLoading(userPageLoading: boolean) {
|
setUserPageLoading(userPageLoading: boolean) {
|
||||||
// @ts-ignore
|
|
||||||
this.display(!userPageLoading);
|
this.display(!userPageLoading);
|
||||||
update({ userPageLoading });
|
update({ userPageLoading });
|
||||||
}
|
}
|
||||||
|
|
||||||
setSize({ height, width }: { height: number, width: number }) {
|
setSize({ height, width }: { height: number, width: number }) {
|
||||||
update({ width, height });
|
update({ width, height });
|
||||||
// @ts-ignore
|
|
||||||
this.scale();
|
this.scale();
|
||||||
|
|
||||||
|
const { markedTargets } = getState();
|
||||||
|
if (markedTargets) {
|
||||||
|
update({
|
||||||
|
markedTargets: markedTargets.map(mt => ({
|
||||||
|
...mt,
|
||||||
|
boundingRect: this.calculateRelativeBoundingRect(mt.el),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculateRelativeBoundingRect(el: Element): BoundingRect {
|
||||||
|
if (!this.parentElement) return {top:0, left:0, width:0,height:0} //TODO
|
||||||
|
const { top, left, width, height } = el.getBoundingClientRect();
|
||||||
|
const s = this.getScale();
|
||||||
|
const scrinRect = this.screen.getBoundingClientRect();
|
||||||
|
const parentRect = this.parentElement.getBoundingClientRect();
|
||||||
|
|
||||||
|
return {
|
||||||
|
top: top*s + scrinRect.top - parentRect.top,
|
||||||
|
left: left*s + scrinRect.left - parentRect.left,
|
||||||
|
width: width*s,
|
||||||
|
height: height*s,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveTarget(index) {
|
||||||
|
update({ activeTargetIndex: index });
|
||||||
|
}
|
||||||
|
|
||||||
|
setMarkedTargets(selections: { selector: string, count: number }[] | null) {
|
||||||
|
if (selections) {
|
||||||
|
const targets: MarkedTarget[] = [];
|
||||||
|
const totalCount = selections.reduce((a, b) => {
|
||||||
|
return a + b.count
|
||||||
|
}, 0);
|
||||||
|
selections.forEach((s, index) => {
|
||||||
|
const el = this.getElementBySelector(s.selector);
|
||||||
|
if (!el) return;
|
||||||
|
targets.push({
|
||||||
|
...s,
|
||||||
|
el,
|
||||||
|
index,
|
||||||
|
percent: Math.round((s.count * totalCount) / 100),
|
||||||
|
boundingRect: this.calculateRelativeBoundingRect(el),
|
||||||
|
})
|
||||||
|
});
|
||||||
|
update({ markedTargets: targets });
|
||||||
|
} else {
|
||||||
|
update({ markedTargets: null });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -17,6 +17,7 @@ export enum CallingState {
|
||||||
|
|
||||||
export enum ConnectionStatus {
|
export enum ConnectionStatus {
|
||||||
Connecting,
|
Connecting,
|
||||||
|
WaitingMessages,
|
||||||
Connected,
|
Connected,
|
||||||
Inactive,
|
Inactive,
|
||||||
Disconnected,
|
Disconnected,
|
||||||
|
|
@ -36,6 +37,8 @@ export function getStatusText(status: ConnectionStatus): string {
|
||||||
return "Disconnected";
|
return "Disconnected";
|
||||||
case ConnectionStatus.Error:
|
case ConnectionStatus.Error:
|
||||||
return "Something went wrong. Try to reload the page.";
|
return "Something went wrong. Try to reload the page.";
|
||||||
|
case ConnectionStatus.WaitingMessages:
|
||||||
|
return "Connected. Waiting for the data..."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -114,6 +117,21 @@ function resolveCSS(baseURL: string, css: string): string {
|
||||||
export default class AssistManager {
|
export default class AssistManager {
|
||||||
constructor(private session, private md: MessageDistributor) {}
|
constructor(private session, private md: MessageDistributor) {}
|
||||||
|
|
||||||
|
|
||||||
|
private setStatus(status: ConnectionStatus) {
|
||||||
|
if (status === ConnectionStatus.Connecting) {
|
||||||
|
this.md.setMessagesLoading(true);
|
||||||
|
} else {
|
||||||
|
this.md.setMessagesLoading(false);
|
||||||
|
}
|
||||||
|
if (status === ConnectionStatus.Connected) {
|
||||||
|
// this.md.display(true);
|
||||||
|
} else {
|
||||||
|
// this.md.display(false);
|
||||||
|
}
|
||||||
|
update({ peerConnectionStatus: status });
|
||||||
|
}
|
||||||
|
|
||||||
private get peerID(): string {
|
private get peerID(): string {
|
||||||
return `${this.session.projectKey}-${this.session.sessionId}`
|
return `${this.session.projectKey}-${this.session.sessionId}`
|
||||||
}
|
}
|
||||||
|
|
@ -126,7 +144,7 @@ export default class AssistManager {
|
||||||
console.error("AssistManager: trying to connect more than once");
|
console.error("AssistManager: trying to connect more than once");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.md.setMessagesLoading(true);
|
this.setStatus(ConnectionStatus.Connecting)
|
||||||
import('peerjs').then(({ default: Peer }) => {
|
import('peerjs').then(({ default: Peer }) => {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const peer = new Peer({
|
const peer = new Peer({
|
||||||
|
|
@ -139,16 +157,16 @@ export default class AssistManager {
|
||||||
peer.on('error', e => {
|
peer.on('error', e => {
|
||||||
if (['peer-unavailable', 'network'].includes(e.type)) {
|
if (['peer-unavailable', 'network'].includes(e.type)) {
|
||||||
if (this.peer && this.connectionAttempts++ < MAX_RECONNECTION_COUNT) {
|
if (this.peer && this.connectionAttempts++ < MAX_RECONNECTION_COUNT) {
|
||||||
update({ peerConnectionStatus: ConnectionStatus.Connecting });
|
this.setStatus(ConnectionStatus.Connecting);
|
||||||
console.log("peerunavailable")
|
console.log("peerunavailable")
|
||||||
this.connectToPeer();
|
this.connectToPeer();
|
||||||
} else {
|
} else {
|
||||||
update({ peerConnectionStatus: ConnectionStatus.Disconnected });
|
this.setStatus(ConnectionStatus.Disconnected);
|
||||||
this.dataCheckIntervalID && clearInterval(this.dataCheckIntervalID);
|
this.dataCheckIntervalID && clearInterval(this.dataCheckIntervalID);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.error(`PeerJS error (on peer). Type ${e.type}`, e);
|
console.error(`PeerJS error (on peer). Type ${e.type}`, e);
|
||||||
update({ peerConnectionStatus: ConnectionStatus.Error })
|
this.setStatus(ConnectionStatus.Error)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
peer.on("open", () => {
|
peer.on("open", () => {
|
||||||
|
|
@ -163,7 +181,7 @@ export default class AssistManager {
|
||||||
private dataCheckIntervalID: ReturnType<typeof setInterval> | undefined;
|
private dataCheckIntervalID: ReturnType<typeof setInterval> | undefined;
|
||||||
private connectToPeer() {
|
private connectToPeer() {
|
||||||
if (!this.peer) { return; }
|
if (!this.peer) { return; }
|
||||||
update({ peerConnectionStatus: ConnectionStatus.Connecting })
|
this.setStatus(ConnectionStatus.Connecting);
|
||||||
const id = this.peerID;
|
const id = this.peerID;
|
||||||
console.log("trying to connect to", id)
|
console.log("trying to connect to", id)
|
||||||
const conn = this.peer.connect(id, { serialization: 'json', reliable: true});
|
const conn = this.peer.connect(id, { serialization: 'json', reliable: true});
|
||||||
|
|
@ -174,14 +192,14 @@ export default class AssistManager {
|
||||||
let i = 0;
|
let i = 0;
|
||||||
let firstMessage = true;
|
let firstMessage = true;
|
||||||
|
|
||||||
update({ peerConnectionStatus: ConnectionStatus.Connected })
|
this.setStatus(ConnectionStatus.WaitingMessages)
|
||||||
|
|
||||||
conn.on('data', (data) => {
|
conn.on('data', (data) => {
|
||||||
if (!Array.isArray(data)) { return this.handleCommand(data); }
|
if (!Array.isArray(data)) { return this.handleCommand(data); }
|
||||||
this.mesagesRecieved = true;
|
this.mesagesRecieved = true;
|
||||||
if (firstMessage) {
|
if (firstMessage) {
|
||||||
firstMessage = false;
|
firstMessage = false;
|
||||||
this.md.setMessagesLoading(false);
|
this.setStatus(ConnectionStatus.Connected)
|
||||||
}
|
}
|
||||||
|
|
||||||
let time = 0;
|
let time = 0;
|
||||||
|
|
@ -230,8 +248,7 @@ export default class AssistManager {
|
||||||
|
|
||||||
const onDataClose = () => {
|
const onDataClose = () => {
|
||||||
this.initiateCallEnd();
|
this.initiateCallEnd();
|
||||||
this.md.setMessagesLoading(true);
|
this.setStatus(ConnectionStatus.Connecting);
|
||||||
update({ peerConnectionStatus: ConnectionStatus.Connecting });
|
|
||||||
console.log('closed peer conn. Reconnecting...')
|
console.log('closed peer conn. Reconnecting...')
|
||||||
this.connectToPeer();
|
this.connectToPeer();
|
||||||
}
|
}
|
||||||
|
|
@ -244,7 +261,7 @@ export default class AssistManager {
|
||||||
conn.on('close', onDataClose);// Does it work ?
|
conn.on('close', onDataClose);// Does it work ?
|
||||||
conn.on("error", (e) => {
|
conn.on("error", (e) => {
|
||||||
console.log("PeerJS connection error", e);
|
console.log("PeerJS connection error", e);
|
||||||
update({ peerConnectionStatus: ConnectionStatus.Error });
|
this.setStatus(ConnectionStatus.Error);
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -304,7 +321,7 @@ export default class AssistManager {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
this.md.display(false);
|
this.md.display(false);
|
||||||
this.dataConnection?.close();
|
this.dataConnection?.close();
|
||||||
update({ peerConnectionStatus: ConnectionStatus.Disconnected });
|
this.setStatus(ConnectionStatus.Disconnected);
|
||||||
}, 8000); // TODO: more convenient way
|
}, 8000); // TODO: more convenient way
|
||||||
//this.dataConnection?.close();
|
//this.dataConnection?.close();
|
||||||
return;
|
return;
|
||||||
|
|
@ -313,7 +330,7 @@ export default class AssistManager {
|
||||||
return;
|
return;
|
||||||
case "call_error":
|
case "call_error":
|
||||||
this.onTrackerCallEnd();
|
this.onTrackerCallEnd();
|
||||||
update({ peerConnectionStatus: ConnectionStatus.Error });
|
this.setStatus(ConnectionStatus.Error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ export const ID_TP_MAP = {
|
||||||
22: "console_log",
|
22: "console_log",
|
||||||
37: "css_insert_rule",
|
37: "css_insert_rule",
|
||||||
38: "css_delete_rule",
|
38: "css_delete_rule",
|
||||||
39: "fetch_depricated",
|
39: "fetch",
|
||||||
40: "profiler",
|
40: "profiler",
|
||||||
41: "o_table",
|
41: "o_table",
|
||||||
44: "redux",
|
44: "redux",
|
||||||
|
|
@ -36,7 +36,6 @@ export const ID_TP_MAP = {
|
||||||
54: "connection_information",
|
54: "connection_information",
|
||||||
55: "set_page_visibility",
|
55: "set_page_visibility",
|
||||||
59: "long_task",
|
59: "long_task",
|
||||||
68: "fetch",
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -167,8 +166,8 @@ export interface CssDeleteRule {
|
||||||
index: number,
|
index: number,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FetchDepricated {
|
export interface Fetch {
|
||||||
tp: "fetch_depricated",
|
tp: "fetch",
|
||||||
method: string,
|
method: string,
|
||||||
url: string,
|
url: string,
|
||||||
request: string,
|
request: string,
|
||||||
|
|
@ -256,20 +255,8 @@ export interface LongTask {
|
||||||
containerName: string,
|
containerName: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Fetch {
|
|
||||||
tp: "fetch",
|
|
||||||
method: string,
|
|
||||||
url: string,
|
|
||||||
request: string,
|
|
||||||
response: string,
|
|
||||||
status: number,
|
|
||||||
timestamp: number,
|
|
||||||
duration: number,
|
|
||||||
headers: string,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
export type Message = Timestamp | SetPageLocation | SetViewportSize | SetViewportScroll | CreateDocument | CreateElementNode | CreateTextNode | MoveNode | RemoveNode | SetNodeAttribute | RemoveNodeAttribute | SetNodeData | SetCssData | SetNodeScroll | SetInputValue | SetInputChecked | MouseMove | ConsoleLog | CssInsertRule | CssDeleteRule | Fetch | Profiler | OTable | Redux | Vuex | MobX | NgRx | GraphQl | PerformanceTrack | ConnectionInformation | SetPageVisibility | LongTask;
|
||||||
export type Message = Timestamp | SetPageLocation | SetViewportSize | SetViewportScroll | CreateDocument | CreateElementNode | CreateTextNode | MoveNode | RemoveNode | SetNodeAttribute | RemoveNodeAttribute | SetNodeData | SetCssData | SetNodeScroll | SetInputValue | SetInputChecked | MouseMove | ConsoleLog | CssInsertRule | CssDeleteRule | FetchDepricated | Profiler | OTable | Redux | Vuex | MobX | NgRx | GraphQl | PerformanceTrack | ConnectionInformation | SetPageVisibility | LongTask | Fetch;
|
|
||||||
|
|
||||||
export default function (r: PrimitiveReader): Message | null {
|
export default function (r: PrimitiveReader): Message | null {
|
||||||
switch (r.readUint()) {
|
switch (r.readUint()) {
|
||||||
|
|
@ -522,19 +509,6 @@ export default function (r: PrimitiveReader): Message | null {
|
||||||
containerName: r.readString(),
|
containerName: r.readString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
case 68:
|
|
||||||
return {
|
|
||||||
tp: ID_TP_MAP[68],
|
|
||||||
method: r.readString(),
|
|
||||||
url: r.readString(),
|
|
||||||
request: r.readString(),
|
|
||||||
response: r.readString(),
|
|
||||||
status: r.readUint(),
|
|
||||||
timestamp: r.readUint(),
|
|
||||||
duration: r.readUint(),
|
|
||||||
headers: r.readString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
r.readUint(); // IOS skip timestamp
|
r.readUint(); // IOS skip timestamp
|
||||||
r.skip(r.readUint());
|
r.skip(r.readUint());
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue