Compare commits

...
Sign in to create a new pull request.

58 commits

Author SHA1 Message Date
nick-delirium
d75ef60351
fix ui: feature flag filter icon crash 2024-07-09 16:47:08 +02:00
Mehdi Osman
692a07efee
Increment frontend chart version (#2374)
Co-authored-by: GitHub Action <action@github.com>
2024-07-08 18:10:18 +02:00
Shekar Siri
9bd5b77c49
change(ui): roles to support for service accounts (#2373) 2024-07-08 17:59:47 +02:00
Mehdi Osman
396c49cfeb
Increment chalice chart version (#2361)
Co-authored-by: GitHub Action <action@github.com>
2024-07-05 16:21:19 +02:00
Kraiem Taha Yassine
c9e049bbc0
fix(DB): permissions changes (#2360)
fix(chalice): permissions changes
2024-07-05 16:16:25 +02:00
Mehdi Osman
045b1e44da
Increment frontend chart version (#2356)
Co-authored-by: GitHub Action <action@github.com>
2024-07-04 16:28:37 +02:00
Shekar Siri
77fb1f0235
UI service permissions (#2355)
* change(ui): permission check for service accountts

* change(ui): permission check for service accountts condition with or
2024-07-04 16:23:33 +02:00
Mehdi Osman
0e7359957b
Increment frontend chart version (#2354)
Co-authored-by: GitHub Action <action@github.com>
2024-07-04 15:49:50 +02:00
Shekar Siri
9bb64239c6
change(ui): permission check for service accountts (#2353) 2024-07-04 15:40:38 +02:00
Mehdi Osman
18cf9c94b8
Increment chalice chart version (#2352)
Co-authored-by: GitHub Action <action@github.com>
2024-07-04 15:12:46 +02:00
Kraiem Taha Yassine
a39a752e64
fix(DB): permissions changes (#2351)
fix(chalice): permissions changes
2024-07-04 15:00:36 +02:00
Taha Yassine Kraiem
389ec4a8fc fix(chalice): changed s-permissions
fix(DB): changed s-permissions
2024-07-04 14:25:52 +02:00
Mehdi Osman
54332f3d48
Increment frontend chart version (#2345)
Co-authored-by: GitHub Action <action@github.com>
2024-07-02 18:46:10 +02:00
Shekar Siri
30180b7159
ui - url changes player and request modal (#2344)
* change(ui): truncate the player url based on screen width

* change(ui): network request show full url
2024-07-02 18:42:21 +02:00
Mehdi Osman
6d903468f1
Increment frontend chart version (#2343)
Co-authored-by: GitHub Action <action@github.com>
2024-07-02 18:33:57 +02:00
Mehdi Osman
f1c5231ff0
Increment frontend chart version (#2340)
Co-authored-by: GitHub Action <action@github.com>
2024-07-02 17:44:40 +02:00
Delirium
c2e3764e92
fix ui: fix canvas manager rendering q (#2339) 2024-07-02 17:32:58 +02:00
Mehdi Osman
8b585af366
Increment assets chart version (#2335)
Co-authored-by: GitHub Action <action@github.com>
2024-07-02 12:33:57 +02:00
Alexander
5ed05d5aba
feat(assets): use UA header every get call (#2334) 2024-07-02 12:23:37 +02:00
Mehdi Osman
c0b28f9f1f
Increment frontend chart version (#2321)
Co-authored-by: GitHub Action <action@github.com>
2024-06-27 19:43:17 +02:00
Shekar Siri
644ef31425
fix(ui): remove starting underscore from the metada which were added to avoid coflicting with existing filter keys (#2320) 2024-06-27 19:39:08 +02:00
Delirium
405b11380e
fix ui: fix ws line length (#2314) 2024-06-27 13:29:37 +02:00
Mehdi Osman
3da082b2e5
Increment frontend chart version (#2310)
Co-authored-by: GitHub Action <action@github.com>
2024-06-27 11:01:10 +02:00
Delirium
1126543bfd
fix ui: fix max ws msg len (#2309) 2024-06-27 10:56:18 +02:00
Mehdi Osman
43a04caa9f
Increment chalice chart version (#2303)
Co-authored-by: GitHub Action <action@github.com>
2024-06-24 19:02:21 +02:00
Kraiem Taha Yassine
7cfaa0a49b
fix(chalice): fixed send multi-recipient email (#2302)
close #2299
2024-06-24 18:49:42 +02:00
Mehdi Osman
d771ce8088
Increment frontend chart version (#2294)
Co-authored-by: GitHub Action <action@github.com>
2024-06-21 17:53:59 +02:00
Shekar Siri
409b05a24c
UI ingest point patch (#2293)
* fix ui: fix mobile installation docs modal

* change(ui): show ingest point

---------

Co-authored-by: nick-delirium <nikita@openreplay.com>
2024-06-21 17:46:55 +02:00
Mehdi Osman
4150d06ff9
Increment chalice chart version (#2276)
Co-authored-by: GitHub Action <action@github.com>
2024-06-13 14:46:27 +02:00
Kraiem Taha Yassine
6190a6b495
fix(chalice): fixed multi-metadata support (#2275) 2024-06-13 14:42:26 +02:00
Mehdi Osman
3e2ad3e4eb
Increment frontend chart version (#2274)
Co-authored-by: GitHub Action <action@github.com>
2024-06-13 10:23:55 +02:00
Delirium
5d02cfa844
fix ui: fix "open next session" shortcut (#2273) 2024-06-13 10:11:49 +02:00
Mehdi Osman
714b2fef7d
Increment chalice chart version (#2271)
Co-authored-by: GitHub Action <action@github.com>
2024-06-12 15:57:00 +02:00
Kraiem Taha Yassine
f113a43501
fix(chalice): fixed create clickmap card & generate clickmap's chart (#2270) 2024-06-12 15:52:32 +02:00
Koyomi Araragi
a543ed8737
fix: use values for ingressClassName (#2257) 2024-06-12 13:11:32 +02:00
Mehdi Osman
e86fa726f6
Increment chalice chart version (#2269)
Co-authored-by: GitHub Action <action@github.com>
2024-06-12 11:51:53 +02:00
Kraiem Taha Yassine
904d861bc2
fix(chalice): changed delete-metadata (#2268) 2024-06-12 11:34:57 +02:00
rjshrjndrn
be15f89677 chore(dbops): Consider version with and without v
Signed-off-by: rjshrjndrn <rjshrjndrn@gmail.com>
2024-06-11 18:13:55 +02:00
rjshrjndrn
b6e76ea07b fix(helm): allow ingress class to be overriden
Signed-off-by: rjshrjndrn <rjshrjndrn@gmail.com>
2024-06-10 18:22:37 +02:00
Mehdi Osman
47cb339004
Increment frontend chart version (#2263)
Co-authored-by: GitHub Action <action@github.com>
2024-06-10 18:01:34 +02:00
Delirium
24922a4ab6
feat ui: new audio player for multiple tracks etc (#2261) (#2262)
* feat ui: new audio player for multiple tracks etc

* fix ui: cleanup, fix speed control
2024-06-10 17:57:58 +02:00
Mehdi Osman
d190145d1c
Increment chalice chart version (#2259)
Co-authored-by: GitHub Action <action@github.com>
2024-06-10 12:31:08 +02:00
Alexander
70d1d2b464
feat(api): removed audio feature (#2258) 2024-06-10 12:15:03 +02:00
Mehdi Osman
2e65bf0255
Increment frontend chart version (#2252)
Co-authored-by: GitHub Action <action@github.com>
2024-06-07 17:26:05 +02:00
Delirium
0c24a9e8cc
feat tracker: introduce more settings for canvas files (#2251) 2024-06-07 17:21:29 +02:00
rjshrjndrn
7cc98bbb53 chore(actions): force push the tag
Signed-off-by: rjshrjndrn <rjshrjndrn@gmail.com>
2024-06-07 13:50:07 +02:00
Mehdi Osman
c606d885b1
Increment frontend chart version (#2250)
Co-authored-by: GitHub Action <action@github.com>
2024-06-07 13:48:26 +02:00
Delirium
2f9d387cc1
Canvas opt patch (#2249)
* feat ui: optimize canvas recording

* feat ui: optimize canvas recording
2024-06-07 13:42:58 +02:00
rjshrjndrn
2fee8a4ccd fix(actions): correct upstream name
Signed-off-by: rjshrjndrn <rjshrjndrn@gmail.com>
2024-06-06 23:56:25 +02:00
Mehdi Osman
64bf75555b
Increment frontend chart version (#2247)
Co-authored-by: GitHub Action <action@github.com>
2024-06-06 19:46:39 +02:00
Shekar Siri
d505b243c1
fix ui: add wait cycle for canvas url (#2246)
Co-authored-by: nick-delirium <nikita@openreplay.com>
2024-06-06 19:35:44 +02:00
Mehdi Osman
4c0bb2cd30
Increment frontend chart version (#2245)
Co-authored-by: GitHub Action <action@github.com>
2024-06-06 15:37:04 +02:00
Shekar Siri
e7612ccf8a
Fix date picker (#2244)
* fix(ui): date range - picking time messing up the date range

* change(ui): inreased the session url ellipsis condition
2024-06-06 15:01:54 +02:00
Shekar Siri
102a7cd2cd
fix(ui): date range - picking time messing up the date range (#2243) 2024-06-06 14:51:59 +02:00
rjshrjndrn
4bb5704fc9 respect platform arch 2024-06-04 19:34:28 +02:00
rjshrjndrn
d83fa0b052 fix(docker): remove hardcoded arch
Signed-off-by: rjshrjndrn <rjshrjndrn@gmail.com>
2024-06-04 19:30:44 +02:00
rjshrjndrn
2abc609dfc chore(build): Respect platform config 2024-06-04 19:10:26 +02:00
rjshrjndrn
051e0ca557 fix(migrate): Race condition on version check
Signed-off-by: rjshrjndrn <rjshrjndrn@gmail.com>
2024-06-04 18:59:59 +02:00
60 changed files with 847 additions and 550 deletions

View file

@ -27,7 +27,7 @@ jobs:
run: | run: |
git fetch --tags git fetch --tags
git checkout main git checkout main
git push rjshrjndn HEAD:refs/tags/$(git tag --list 'v[0-9]*' --sort=-v:refname | head -n 1) git push origin HEAD:refs/tags/$(git tag --list 'v[0-9]*' --sort=-v:refname | head -n 1) --force
# - name: Debug Job # - name: Debug Job
# if: ${{ failure() }} # if: ${{ failure() }}
# uses: mxschmitt/action-tmate@v3 # uses: mxschmitt/action-tmate@v3

View file

@ -52,7 +52,7 @@ function build_alerts() {
tag="ee-" tag="ee-"
} }
mv Dockerfile_alerts.dockerignore .dockerignore mv Dockerfile_alerts.dockerignore .dockerignore
docker build -f ./Dockerfile_alerts --build-arg envarg=$envarg --build-arg GIT_SHA=$git_sha -t ${DOCKER_REPO:-'local'}/alerts:${image_tag} . docker build -f ./Dockerfile_alerts --platform linux/${ARCH:-"amd64"} --build-arg envarg=$envarg --build-arg GIT_SHA=$git_sha -t ${DOCKER_REPO:-'local'}/alerts:${image_tag} .
cd ../api cd ../api
rm -rf ../${destination} rm -rf ../${destination}
[[ $PUSH_IMAGE -eq 1 ]] && { [[ $PUSH_IMAGE -eq 1 ]] && {

View file

@ -29,7 +29,7 @@ function build_crons() {
envarg="default-ee" envarg="default-ee"
tag="ee-" tag="ee-"
mv Dockerfile_crons.dockerignore .dockerignore mv Dockerfile_crons.dockerignore .dockerignore
docker build -f ./Dockerfile_crons --build-arg envarg=$envarg -t ${DOCKER_REPO:-'local'}/crons:${git_sha1} . docker build -f ./Dockerfile_crons --platform=linux/${ARCH:-'amd64'} --build-arg envarg=$envarg -t ${DOCKER_REPO:-'local'}/crons:${git_sha1} .
cd ../api cd ../api
rm -rf ../${destination} rm -rf ../${destination}
[[ $PUSH_IMAGE -eq 1 ]] && { [[ $PUSH_IMAGE -eq 1 ]] && {

View file

@ -1,7 +1,10 @@
import logging
import schemas import schemas
from chalicelib.core import sessions_mobs, sessions_legacy as sessions_search, events from chalicelib.core import sessions_mobs, sessions_legacy as sessions_search, events
from chalicelib.utils import pg_client, helper from chalicelib.utils import pg_client, helper
logger = logging.getLogger(__name__)
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,
@ -53,17 +56,17 @@ def search_short_session(data: schemas.ClickMapSessionsSearch, project_id, user_
{query_part} {query_part}
ORDER BY {data.sort} {data.order.value} ORDER BY {data.sort} {data.order.value}
LIMIT 1;""", full_args) LIMIT 1;""", full_args)
# print("--------------------") logger.debug("--------------------")
# print(main_query) logger.debug(main_query)
# print("--------------------") logger.debug("--------------------")
try: try:
cur.execute(main_query) cur.execute(main_query)
except Exception as err: except Exception as err:
print("--------- CLICK MAP SHORT SESSION SEARCH QUERY EXCEPTION -----------") logger.warning("--------- CLICK MAP SHORT SESSION SEARCH QUERY EXCEPTION -----------")
print(main_query.decode('UTF-8')) logger.warning(main_query.decode('UTF-8'))
print("--------- PAYLOAD -----------") logger.warning("--------- PAYLOAD -----------")
print(data.model_dump_json()) logger.warning(data.model_dump_json())
print("--------------------") logger.warning("--------------------")
raise err raise err
session = cur.fetchone() session = cur.fetchone()

View file

@ -88,8 +88,8 @@ def __get_sessions_list(project_id, user_id, data: schemas.CardSchema):
def __get_click_map_chart(project_id, user_id, data: schemas.CardClickMap, include_mobs: bool = True): def __get_click_map_chart(project_id, user_id, data: schemas.CardClickMap, include_mobs: bool = True):
if len(data.series) == 0: if len(data.series) == 0:
return None return None
# this code is duplicating the clickmap filters when creating a card data.series[0].filter.filters += data.series[0].filter.events
# data.series[0].filter.filters += data.series[0].filter.events data.series[0].filter.events = []
return click_maps.search_short_session(project_id=project_id, user_id=user_id, return click_maps.search_short_session(project_id=project_id, user_id=user_id,
data=schemas.ClickMapSessionsSearch( data=schemas.ClickMapSessionsSearch(
**data.series[0].filter.model_dump()), **data.series[0].filter.model_dump()),

View file

@ -132,13 +132,6 @@ def delete(tenant_id, project_id, index: int):
WHERE project_id = %(project_id)s AND deleted_at ISNULL;""", WHERE project_id = %(project_id)s AND deleted_at ISNULL;""",
{"project_id": project_id}) {"project_id": project_id})
cur.execute(query=query) cur.execute(query=query)
query = cur.mogrify(f"""UPDATE public.sessions
SET {colname}= NULL
WHERE project_id = %(project_id)s
AND {colname} IS NOT NULL
""",
{"project_id": project_id})
cur.execute(query=query)
return {"data": get(project_id)} return {"data": get(project_id)}

View file

@ -78,17 +78,6 @@ def get_mobile_videos(session_id, project_id, check_existence=False):
return results return results
def get_audio_url(project_id, session_id, check_existence=True):
k = "%s/audio.mp3" % session_id
if check_existence and not StorageClient.exists(bucket=config("sessions_bucket"), key=k):
return None
return StorageClient.get_presigned_url_for_sharing(
bucket=config("sessions_bucket"),
expires_in=config("PRESIGNED_URL_EXPIRATION", cast=int, default=900),
key=k
)
def delete_mobs(project_id, session_ids): def delete_mobs(project_id, session_ids):
for session_id in session_ids: for session_id in session_ids:
for k in __get_mob_keys(project_id=project_id, session_id=session_id) \ for k in __get_mob_keys(project_id=project_id, session_id=session_id) \

View file

@ -153,8 +153,6 @@ def get_replay(project_id, session_id, context: schemas.CurrentContext, full_dat
data['metadata'] = __group_metadata(project_metadata=data.pop("projectMetadata"), session=data) data['metadata'] = __group_metadata(project_metadata=data.pop("projectMetadata"), session=data)
data['live'] = live and assist.is_live(project_id=project_id, session_id=session_id, data['live'] = live and assist.is_live(project_id=project_id, session_id=session_id,
project_key=data["projectKey"]) project_key=data["projectKey"])
data['audio'] = sessions_mobs.get_audio_url(session_id=session_id, project_id=project_id,
check_existence=False)
data["inDB"] = True data["inDB"] = True
return data return data
elif live: elif live:

View file

@ -56,10 +56,10 @@ def send_html(BODY_HTML, SUBJECT, recipient):
msg.attach(body) msg.attach(body)
for m in mime_img: for m in mime_img:
msg.attach(m) msg.attach(m)
msg["To"] = ''
with smtp.SMTPClient() as s: with smtp.SMTPClient() as s:
for r in recipient: for r in recipient:
msg["To"] = r msg.replace_header('To', r)
try: try:
logging.info(f"Email sending to: {r}") logging.info(f"Email sending to: {r}")
s.send_message(msg) s.send_message(msg)

View file

@ -801,7 +801,9 @@ class SessionsSearchPayloadSchema(_TimedSchema, _PaginatedSchema):
continue continue
j = i + 1 j = i + 1
while j < len(values): while j < len(values):
if values[i].type == values[j].type: if values[i].type == values[j].type \
and values[i].operator == values[j].operator \
and (values[i].type != FilterType.metadata or values[i].source == values[j].source):
values[i].value += values[j].value values[i].value += values[j].value
del values[j] del values[j]
else: else:

View file

@ -1,7 +1,4 @@
#ARCH can be amd64 or arm64 FROM node:20-alpine
ARG ARCH=amd64
FROM --platform=linux/$ARCH node:20-alpine
LABEL Maintainer="KRAIEM Taha Yassine<tahayk2@gmail.com>" LABEL Maintainer="KRAIEM Taha Yassine<tahayk2@gmail.com>"
RUN apk add --no-cache tini RUN apk add --no-cache tini
@ -22,4 +19,4 @@ USER 1001
ADD --chown=1001 https://static.openreplay.com/geoip/GeoLite2-City.mmdb $MAXMINDDB_FILE ADD --chown=1001 https://static.openreplay.com/geoip/GeoLite2-City.mmdb $MAXMINDDB_FILE
ENTRYPOINT ["/sbin/tini", "--"] ENTRYPOINT ["/sbin/tini", "--"]
CMD npm start CMD npm start

View file

@ -6,6 +6,7 @@
# Usage: IMAGE_TAG=latest DOCKER_REPO=myDockerHubID bash build.sh <ee> # Usage: IMAGE_TAG=latest DOCKER_REPO=myDockerHubID bash build.sh <ee>
ARCH=${ARCH:-amd64}
git_sha=$(git rev-parse --short HEAD) git_sha=$(git rev-parse --short HEAD)
image_tag=${IMAGE_TAG:-git_sha} image_tag=${IMAGE_TAG:-git_sha}
check_prereq() { check_prereq() {
@ -51,7 +52,7 @@ function build_api() {
[[ $1 == "ee" ]] && { [[ $1 == "ee" ]] && {
cp -rf ../ee/assist/* ./ cp -rf ../ee/assist/* ./
} }
docker build -f ./Dockerfile --build-arg GIT_SHA=$git_sha -t ${DOCKER_REPO:-'local'}/assist:${image_tag} . docker build -f ./Dockerfile --platform linux/"${ARCH:-'amd64'}" --build-arg GIT_SHA=$git_sha -t ${DOCKER_REPO:-'local'}/assist:${image_tag} .
cd ../assist cd ../assist
rm -rf ../${destination} rm -rf ../${destination}

View file

@ -106,9 +106,7 @@ func (c *cacher) cacheURL(t *Task) {
t.retries-- t.retries--
start := time.Now() start := time.Now()
req, _ := http.NewRequest("GET", t.requestURL, nil) req, _ := http.NewRequest("GET", t.requestURL, nil)
if t.retries%2 == 0 { req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:98.0) Gecko/20100101 Firefox/98.0")
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:98.0) Gecko/20100101 Firefox/98.0")
}
for k, v := range c.requestHeaders { for k, v := range c.requestHeaders {
req.Header.Set(k, v) req.Header.Set(k, v)
} }

View file

@ -20,8 +20,6 @@ def _get_current_auth_context(request: Request, jwt_payload: dict) -> schemas.Cu
logger.warning("User not found.") logger.warning("User not found.")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User not found.") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User not found.")
request.state.authorizer_identity = "jwt" request.state.authorizer_identity = "jwt"
if user["serviceAccount"]:
user["permissions"] = [p.value for p in schemas.ServicePermissions]
request.state.currentContext = schemas.CurrentContext(tenantId=jwt_payload.get("tenantId", -1), request.state.currentContext = schemas.CurrentContext(tenantId=jwt_payload.get("tenantId", -1),
userId=jwt_payload.get("userId", -1), userId=jwt_payload.get("userId", -1),
email=user["email"], email=user["email"],

View file

@ -99,8 +99,8 @@ def __get_sessions_list(project_id, user_id, data: schemas.CardSchema):
def __get_click_map_chart(project_id, user_id, data: schemas.CardClickMap, include_mobs: bool = True): def __get_click_map_chart(project_id, user_id, data: schemas.CardClickMap, include_mobs: bool = True):
if len(data.series) == 0: if len(data.series) == 0:
return None return None
# this code is duplicating the clickmap filters when creating a card data.series[0].filter.filters += data.series[0].filter.events
# data.series[0].filter.filters += data.series[0].filter.events data.series[0].filter.events = []
return click_maps.search_short_session(project_id=project_id, user_id=user_id, return click_maps.search_short_session(project_id=project_id, user_id=user_id,
data=schemas.ClickMapSessionsSearch( data=schemas.ClickMapSessionsSearch(
**data.series[0].filter.model_dump()), **data.series[0].filter.model_dump()),

View file

@ -121,6 +121,7 @@ def get_roles(tenant_id):
AND projects.deleted_at ISNULL ) AS role_projects ON (TRUE) AND projects.deleted_at ISNULL ) AS role_projects ON (TRUE)
WHERE tenant_id =%(tenant_id)s WHERE tenant_id =%(tenant_id)s
AND deleted_at IS NULL AND deleted_at IS NULL
AND not service_role
ORDER BY role_id;""", ORDER BY role_id;""",
{"tenant_id": tenant_id}) {"tenant_id": tenant_id})
cur.execute(query=query) cur.execute(query=query)

View file

@ -34,7 +34,7 @@ class CurrentContext(schemas.CurrentContext):
if values.get("permissions") is not None: if values.get("permissions") is not None:
perms = [] perms = []
for p in values["permissions"]: for p in values["permissions"]:
if Permissions.has_value(p): if Permissions.has_value(p) or ServicePermissions.has_value(p):
perms.append(p) perms.append(p)
values["permissions"] = perms values["permissions"] = perms
return values return values

View file

@ -29,6 +29,14 @@ ALTER TABLE IF EXISTS public.sessions
CREATE INDEX IF NOT EXISTS graphql_session_id_idx ON events.graphql (session_id); CREATE INDEX IF NOT EXISTS graphql_session_id_idx ON events.graphql (session_id);
CREATE INDEX IF NOT EXISTS crashes_session_id_idx ON events_common.crashes (session_id); CREATE INDEX IF NOT EXISTS crashes_session_id_idx ON events_common.crashes (session_id);
UPDATE public.roles
SET permissions='{SERVICE_SESSION_REPLAY,SERVICE_DEV_TOOLS,SERVICE_ASSIST_LIVE,SERVICE_ASSIST_CALL}'
WHERE service_role;
UPDATE public.users
SET weekly_report= FALSE
WHERE service_account;
COMMIT; COMMIT;
\elif :is_next \elif :is_next

View file

@ -15,7 +15,6 @@ function Assist(props: Props) {
return ( return (
<AssistRouter /> <AssistRouter />
); );
} }
const Cont = connect((state: any) => ({ const Cont = connect((state: any) => ({
@ -25,5 +24,5 @@ const Cont = connect((state: any) => ({
}))(Assist); }))(Assist);
export default withPageTitle('Assist - OpenReplay')( export default withPageTitle('Assist - OpenReplay')(
withPermissions(['ASSIST_LIVE'])(withRouter(Cont)) withPermissions(['ASSIST_LIVE', 'SERVICE_ASSIST_LIVE'], '', false, false)(withRouter(Cont))
); );

View file

@ -292,7 +292,7 @@ function AssistActions({
const con = connect((state: any) => { const con = connect((state: any) => {
const permissions = state.getIn(['user', 'account', 'permissions']) || []; const permissions = state.getIn(['user', 'account', 'permissions']) || [];
return { return {
hasPermission: permissions.includes('ASSIST_CALL'), hasPermission: permissions.includes('ASSIST_CALL') || permissions.includes('SERVICE_ASSIST_CALL'),
isEnterprise: state.getIn(['user', 'account', 'edition']) === 'ee', isEnterprise: state.getIn(['user', 'account', 'edition']) === 'ee',
userDisplayName: state.getIn(['sessions', 'current']).userDisplayName, userDisplayName: state.getIn(['sessions', 'current']).userDisplayName,
agentId: state.getIn(['user', 'account', 'id']) agentId: state.getIn(['user', 'account', 'id'])

View file

@ -3,9 +3,9 @@ import stl from './installDocs.module.css';
import cn from 'classnames'; import cn from 'classnames';
import Highlight from 'react-highlight'; import Highlight from 'react-highlight';
import CircleNumber from '../../CircleNumber'; import CircleNumber from '../../CircleNumber';
import {CopyButton} from 'UI'; import { CopyButton } from 'UI';
const installationCommand = `// Add it in your root build.gradle at the end of repositories: export const installationCommand = `// Add it in your root build.gradle at the end of repositories:
dependencyResolutionManagement { dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories { repositories {
@ -20,18 +20,18 @@ dependencies {
} }
`; `;
const usageCode = `// MainActivity.kt export const usageCode = `// MainActivity.kt
import com.openreplay.tracker.OpenReplay import com.openreplay.tracker.OpenReplay
//... //...
class MainActivity : TrackingActivity() { class MainActivity : TrackingActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
// not required if you're using our SaaS version // not required if you're using our SaaS version
OpenReplay.serverURL = "https://your.instance.com/ingest" OpenReplay.serverURL = "INGEST_POINT"
// check out our SDK docs to see available options // check out our SDK docs to see available options
OpenReplay.start( OpenReplay.start(
applicationContext, applicationContext,
"projectkey", "PROJECT_KEY",
OpenReplay.Options.defaults(), OpenReplay.Options.defaults(),
onStarted = { onStarted = {
println("OpenReplay Started") println("OpenReplay Started")
@ -40,7 +40,7 @@ class MainActivity : TrackingActivity() {
// ... // ...
} }
}`; }`;
const configuration = `let crashs: Bool const configuration = `let crashes: Bool
let analytics: Bool let analytics: Bool
let performances: Bool let performances: Bool
let logs: Bool let logs: Bool
@ -57,38 +57,38 @@ const sensitive = `import com.openreplay.tracker.OpenReplay
OpenReplay.addIgnoredView(view) OpenReplay.addIgnoredView(view)
` `
const inputs = `import com.opnereplay.tracker.OpenReplay const inputs = `import com.openreplay.tracker.OpenReplay
val passwordEditText = binding.password val passwordEditText = binding.password
passwordEditText.trackTextInput(label = "password", masked = true)` passwordEditText.trackTextInput(label = "password", masked = true)`
function AndroidInstallDocs({site}: any) { function AndroidInstallDocs({ site, ingestPoint }: any) {
const _usageCode = usageCode.replace('PROJECT_KEY', site.projectKey); let _usageCode = usageCode.replace('PROJECT_KEY', site.projectKey).replace('INGEST_POINT', ingestPoint);
return ( return (
<div> <div>
<div className="mb-4"> <div className="mb-4">
<div className="font-semibold mb-2 flex items-center"> <div className="font-semibold mb-2 flex items-center">
<CircleNumber text="1"/> <CircleNumber text="1" />
Install the SDK Install the SDK
</div> </div>
<div className={cn(stl.snippetWrapper, 'ml-10')}> <div className={cn(stl.snippetWrapper, 'ml-10')}>
<div className="absolute mt-1 mr-2 right-0"> <div className="absolute mt-1 mr-2 right-0">
<CopyButton content={installationCommand}/> <CopyButton content={installationCommand} />
</div> </div>
<Highlight className="cli">{installationCommand}</Highlight> <Highlight className="cli">{installationCommand}</Highlight>
</div> </div>
</div> </div>
<div className="font-semibold mb-2 flex items-center"> <div className="font-semibold mb-2 flex items-center">
<CircleNumber text="2"/> <CircleNumber text="2" />
Add to your app Add to your app
</div> </div>
<div className="flex ml-10 mt-4"> <div className="flex ml-10 mt-4">
<div className="w-full"> <div className="w-full">
<div className={cn(stl.snippetWrapper)}> <div className={cn(stl.snippetWrapper)}>
<div className="absolute mt-1 mr-2 right-0"> <div className="absolute mt-1 mr-2 right-0">
<CopyButton content={_usageCode}/> <CopyButton content={_usageCode} />
</div> </div>
<Highlight className="swift">{_usageCode}</Highlight> <Highlight className="swift">{_usageCode}</Highlight>
</div> </div>
@ -96,7 +96,7 @@ function AndroidInstallDocs({site}: any) {
</div> </div>
<div className="font-semibold mb-2 mt-4 flex items-center"> <div className="font-semibold mb-2 mt-4 flex items-center">
<CircleNumber text="3"/> <CircleNumber text="3" />
Configuration Configuration
</div> </div>
<div className="flex ml-10 mt-4"> <div className="flex ml-10 mt-4">
@ -110,14 +110,14 @@ function AndroidInstallDocs({site}: any) {
</div> </div>
<div className="font-semibold mb-2 mt-4 flex items-center"> <div className="font-semibold mb-2 mt-4 flex items-center">
<CircleNumber text="4"/> <CircleNumber text="4" />
Set up touch events listener Set up touch events listener
</div> </div>
<div className="flex ml-10 mt-4"> <div className="flex ml-10 mt-4">
<div className="w-full"> <div className="w-full">
<div className={cn(stl.snippetWrapper)}> <div className={cn(stl.snippetWrapper)}>
<div className="absolute mt-1 mr-2 right-0"> <div className="absolute mt-1 mr-2 right-0">
<CopyButton content={touches}/> <CopyButton content={touches} />
</div> </div>
<Highlight className="swift">{touches}</Highlight> <Highlight className="swift">{touches}</Highlight>
</div> </div>
@ -125,14 +125,14 @@ function AndroidInstallDocs({site}: any) {
</div> </div>
<div className="font-semibold mb-2 mt-4 flex items-center"> <div className="font-semibold mb-2 mt-4 flex items-center">
<CircleNumber text="5"/> <CircleNumber text="5" />
Hide sensitive views Hide sensitive views
</div> </div>
<div className="flex ml-10 mt-4"> <div className="flex ml-10 mt-4">
<div className="w-full"> <div className="w-full">
<div className={cn(stl.snippetWrapper)}> <div className={cn(stl.snippetWrapper)}>
<div className="absolute mt-1 mr-2 right-0"> <div className="absolute mt-1 mr-2 right-0">
<CopyButton content={sensitive}/> <CopyButton content={sensitive} />
</div> </div>
<Highlight className="swift">{sensitive}</Highlight> <Highlight className="swift">{sensitive}</Highlight>
</div> </div>
@ -140,14 +140,14 @@ function AndroidInstallDocs({site}: any) {
</div> </div>
<div className="font-semibold mb-2 mt-4 flex items-center"> <div className="font-semibold mb-2 mt-4 flex items-center">
<CircleNumber text="6"/> <CircleNumber text="6" />
Track inputs Track inputs
</div> </div>
<div className="flex ml-10 mt-4"> <div className="flex ml-10 mt-4">
<div className="w-full"> <div className="w-full">
<div className={cn(stl.snippetWrapper)}> <div className={cn(stl.snippetWrapper)}>
<div className="absolute mt-1 mr-2 right-0"> <div className="absolute mt-1 mr-2 right-0">
<CopyButton content={inputs}/> <CopyButton content={inputs} />
</div> </div>
<Highlight className="swift">{inputs}</Highlight> <Highlight className="swift">{inputs}</Highlight>
</div> </div>
@ -157,4 +157,4 @@ function AndroidInstallDocs({site}: any) {
); );
} }
export default AndroidInstallDocs export default AndroidInstallDocs;

View file

@ -5,7 +5,7 @@ import Highlight from 'react-highlight';
import CircleNumber from '../../CircleNumber'; import CircleNumber from '../../CircleNumber';
import { CopyButton } from 'UI'; import { CopyButton } from 'UI';
const installationCommand = ` export const installationCommand = `
// make sure to grab latest version from https://github.com/openreplay/ios-tracker // make sure to grab latest version from https://github.com/openreplay/ios-tracker
// Cocoapods // Cocoapods
pod 'Openreplay', '~> 1.0.5' pod 'Openreplay', '~> 1.0.5'
@ -16,7 +16,7 @@ dependencies: [
] ]
`; `;
const usageCode = `// AppDelegate.swift export const usageCode = `// AppDelegate.swift
import OpenReplay import OpenReplay
//... //...
@ -24,15 +24,15 @@ import OpenReplay
class AppDelegate: UIResponder, UIApplicationDelegate { class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// not required if you're using our SaaS version
OpenReplay.shared.serverURL = "https://your.instance.com/ingest" OpenReplay.shared.serverURL = "INGEST_POINT"
OpenReplay.shared.start(projectKey: "PROJECT_KEY", options: .defaults) OpenReplay.shared.start(projectKey: "PROJECT_KEY", options: .defaults)
// ... // ...
return true return true
} }
// ...`; // ...`;
const configuration = `let crashs: Bool const configuration = `let crashes: Bool
let analytics: Bool let analytics: Bool
let performances: Bool let performances: Bool
let logs: Bool let logs: Bool
@ -72,8 +72,8 @@ TextField("Input", text: $text)
// UIKit will use placeholder as label and sender.isSecureTextEntry to mask the input // UIKit will use placeholder as label and sender.isSecureTextEntry to mask the input
Analytics.shared.addObservedInput(inputEl)` Analytics.shared.addObservedInput(inputEl)`
function MobileInstallDocs({ site }: any) { function MobileInstallDocs({ site, ingestPoint }: any) {
const _usageCode = usageCode.replace('PROJECT_KEY', site.projectKey); const _usageCode = usageCode.replace('INGEST_POINT', ingestPoint).replace('PROJECT_KEY', site.projectKey);
return ( return (
<div> <div>

View file

@ -21,6 +21,7 @@ const MobileTrackingCodeModal = (props: Props) => {
const { site } = props; const { site } = props;
const [activeTab, setActiveTab] = useState(iOS); const [activeTab, setActiveTab] = useState(iOS);
const { showModal } = useModal(); const { showModal } = useModal();
const ingestPoint = `https://${window.location.hostname}/ingest`;
const showUserModal = () => { const showUserModal = () => {
showModal(<UserForm />, { right: true }); showModal(<UserForm />, { right: true });
@ -32,7 +33,7 @@ const MobileTrackingCodeModal = (props: Props) => {
return ( return (
<div className="grid grid-cols-6 gap-4"> <div className="grid grid-cols-6 gap-4">
<div className="col-span-4"> <div className="col-span-4">
<MobileInstallDocs site={site} /> <MobileInstallDocs site={site} ingestPoint={ingestPoint} />
</div> </div>
<div className="col-span-2"> <div className="col-span-2">
@ -55,7 +56,7 @@ const MobileTrackingCodeModal = (props: Props) => {
return ( return (
<div className="grid grid-cols-6 gap-4"> <div className="grid grid-cols-6 gap-4">
<div className="col-span-4"> <div className="col-span-4">
<AndroidInstallDocs site={site} /> <AndroidInstallDocs site={site} ingestPoint={ingestPoint} />
</div> </div>
<div className="col-span-2"> <div className="col-span-2">

View file

@ -137,11 +137,7 @@ function LivePlayer({
); );
} }
export default withPermissions( export default withPermissions(['ASSIST_LIVE', 'SERVICE_ASSIST_LIVE'], '', true, false)(
['ASSIST_LIVE'],
'',
true
)(
connect((state: any) => { connect((state: any) => {
return { return {
siteId: state.getIn([ 'site', 'siteId' ]), siteId: state.getIn([ 'site', 'siteId' ]),

View file

@ -56,11 +56,7 @@ function LiveSession({
); );
} }
export default withPermissions( export default withPermissions(['ASSIST_LIVE', 'SERVICE_ASSIST_LIVE'], '', true, false)(
['ASSIST_LIVE'],
'',
true
)(
connect( connect(
(state, props) => { (state, props) => {
const { const {

View file

@ -286,7 +286,7 @@ export default connect(
const permissions = state.getIn(['user', 'account', 'permissions']) || []; const permissions = state.getIn(['user', 'account', 'permissions']) || [];
const isEnterprise = state.getIn(['user', 'account', 'edition']) === 'ee'; const isEnterprise = state.getIn(['user', 'account', 'edition']) === 'ee';
return { return {
disableDevtools: isEnterprise && !permissions.includes('DEV_TOOLS'), disableDevtools: isEnterprise && !(permissions.includes('DEV_TOOLS') || permissions.includes('SERVICE_DEV_TOOLS')),
fullscreen: state.getIn(['components', 'player', 'fullscreen']), fullscreen: state.getIn(['components', 'player', 'fullscreen']),
bottomBlock: state.getIn(['components', 'player', 'bottomBlock']), bottomBlock: state.getIn(['components', 'player', 'bottomBlock']),
showStorageRedux: !state.getIn([ showStorageRedux: !state.getIn([

View file

@ -1,34 +1,52 @@
import { MutedOutlined, SoundOutlined, CaretDownOutlined, ControlOutlined } from '@ant-design/icons'; import {
import { Button, Popover, InputNumber } from 'antd'; CaretDownOutlined,
ControlOutlined,
MutedOutlined,
SoundOutlined,
} from '@ant-design/icons';
import { Button, InputNumber, Popover } from 'antd';
import { Slider } from 'antd'; import { Slider } from 'antd';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import React, { useRef, useState } from 'react'; import React, { useContext, useEffect, useRef, useState } from 'react';
import { PlayerContext } from '../../playerContext'; import { PlayerContext } from 'App/components/Session/playerContext';
function DropdownAudioPlayer({ url }: { url: string }) { function DropdownAudioPlayer({
const { store } = React.useContext(PlayerContext); audioEvents,
}: {
audioEvents: { payload: Record<any, any>; timestamp: number }[];
}) {
const { store } = useContext(PlayerContext);
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
const [volume, setVolume] = useState(0); const [volume, setVolume] = useState(35);
const [delta, setDelta] = useState(0); const [delta, setDelta] = useState(0);
const [deltaInputValue, setDeltaInputValue] = useState(0); const [deltaInputValue, setDeltaInputValue] = useState(0);
const [isMuted, setIsMuted] = useState(true); const [isMuted, setIsMuted] = useState(false);
const lastPlayerTime = React.useRef(0); const lastPlayerTime = useRef(0);
const audioRef = useRef<HTMLAudioElement>(null); const audioRefs = useRef<Record<string, HTMLAudioElement | null>>({});
const { time = 0, speed = 1, playing } = store?.get() ?? {}; const { time = 0, speed = 1, playing, sessionStart } = store?.get() ?? {};
const files = audioEvents.map((pa) => {
const data = pa.payload;
return {
url: data.url,
timestamp: data.timestamp,
start: pa.timestamp - sessionStart,
};
});
const toggleMute = () => { const toggleMute = () => {
if (audioRef.current) { Object.values(audioRefs.current).forEach((audio) => {
if (audioRef.current?.paused && playing) { if (audio) {
audioRef.current?.play(); audio.muted = !audio.muted;
}
audioRef.current.muted = !audioRef.current.muted;
if (isMuted) {
onVolumeChange(35)
} else {
onVolumeChange(0)
} }
});
setIsMuted(!isMuted);
if (!isMuted) {
onVolumeChange(0);
} else {
onVolumeChange(35);
} }
}; };
@ -37,87 +55,102 @@ function DropdownAudioPlayer({ url }: { url: string }) {
}; };
const handleDelta = (value: any) => { const handleDelta = (value: any) => {
setDeltaInputValue(parseFloat(value)) setDeltaInputValue(parseFloat(value));
} };
const onSync = () => { const onSync = () => {
setDelta(deltaInputValue) setDelta(deltaInputValue);
handleSeek(time + deltaInputValue * 1000) handleSeek(time + deltaInputValue * 1000);
} };
const onCancel = () => { const onCancel = () => {
setDeltaInputValue(0) setDeltaInputValue(0);
setIsVisible(false) setIsVisible(false);
} };
const onReset = () => { const onReset = () => {
setDelta(0) setDelta(0);
setDeltaInputValue(0) setDeltaInputValue(0);
handleSeek(time) handleSeek(time);
} };
const onVolumeChange = (value: number) => { const onVolumeChange = (value: number) => {
if (audioRef.current) { Object.values(audioRefs.current).forEach((audio) => {
audioRef.current.volume = value / 100; if (audio) {
} audio.volume = value / 100;
if (value === 0) { }
setIsMuted(true); });
}
if (value > 0) {
setIsMuted(false);
}
setVolume(value); setVolume(value);
setIsMuted(value === 0);
}; };
const handleSeek = (timeMs: number) => { const handleSeek = (timeMs: number) => {
if (audioRef.current) { Object.entries(audioRefs.current).forEach(([key, audio]) => {
audioRef.current.currentTime = (timeMs + delta * 1000) / 1000; if (audio) {
} const file = files.find((f) => f.url === key);
if (file) {
audio.currentTime = Math.max(
(timeMs + delta * 1000 - file.start) / 1000,
0
);
}
}
});
}; };
const changePlaybackSpeed = (speed: number) => { const changePlaybackSpeed = (speed: number) => {
if (audioRef.current) { Object.values(audioRefs.current).forEach((audio) => {
audioRef.current.playbackRate = speed; if (audio) {
} audio.playbackRate = speed;
}
});
}; };
React.useEffect(() => { useEffect(() => {
const deltaMs = delta * 1000; const deltaMs = delta * 1000;
if (Math.abs(lastPlayerTime.current - time - deltaMs) >= 250) { if (Math.abs(lastPlayerTime.current - time - deltaMs) >= 250) {
handleSeek(time); handleSeek(time);
} }
if (audioRef.current) { Object.entries(audioRefs.current).forEach(([url, audio]) => {
if (audioRef.current.paused && playing) { if (audio) {
audioRef.current?.play(); const file = files.find((f) => f.url === url);
if (file && time >= file.start) {
if (audio.paused && playing) {
audio.play();
}
} else {
audio.pause();
}
if (audio.muted !== isMuted) {
console.log(isMuted, audio.muted);
audio.muted = isMuted;
}
} }
if (audioRef.current.muted !== isMuted) { });
audioRef.current.muted = isMuted;
}
}
lastPlayerTime.current = time + deltaMs; lastPlayerTime.current = time + deltaMs;
}, [time, delta]); }, [time, delta]);
React.useEffect(() => { useEffect(() => {
changePlaybackSpeed(speed); changePlaybackSpeed(speed);
}, [speed]); }, [speed]);
React.useEffect(() => { useEffect(() => {
if (playing) { Object.entries(audioRefs.current).forEach(([url, audio]) => {
audioRef.current?.play(); if (audio) {
} else { const file = files.find((f) => f.url === url);
audioRef.current?.pause(); if (file && playing && time >= file.start) {
} audio.play();
const volume = audioRef.current?.volume ?? 0 } else {
const shouldBeMuted = audioRef.current?.muted ?? isMuted audio.pause();
setVolume(shouldBeMuted ? 0 : volume * 100); }
}
});
setVolume(isMuted ? 0 : volume);
}, [playing]); }, [playing]);
return ( return (
<div className={'relative'}> <div className={'relative'}>
<div <div className={'flex items-center'} style={{ height: 24 }}>
className={'flex items-center'}
style={{ height: 24 }}
>
<Popover <Popover
trigger={'click'} trigger={'click'}
className={'h-full'} className={'h-full'}
@ -137,7 +170,11 @@ function DropdownAudioPlayer({ url }: { url: string }) {
</div> </div>
} }
> >
<div className={'px-2 h-full cursor-pointer border rounded-l border-gray-light hover:border-main hover:text-main hover:z-10'}> <div
className={
'px-2 h-full cursor-pointer border rounded-l border-gray-light hover:border-main hover:text-main hover:z-10'
}
>
{isMuted ? <MutedOutlined /> : <SoundOutlined />} {isMuted ? <MutedOutlined /> : <SoundOutlined />}
</div> </div>
</Popover> </Popover>
@ -153,46 +190,64 @@ function DropdownAudioPlayer({ url }: { url: string }) {
</div> </div>
{isVisible ? ( {isVisible ? (
<div className={"absolute left-1/2 top-0 border shadow border-gray-light rounded bg-white p-4 flex flex-col gap-4 mb-4"} <div
style={{ width: 240, transform: 'translate(-75%, -110%)', zIndex: 101 }}> className={
<div className={"font-semibold flex items-center gap-2"}> 'absolute left-1/2 top-0 border shadow border-gray-light rounded bg-white p-4 flex flex-col gap-4 mb-4'
<ControlOutlined /> }
<div>Audio Track Synchronization</div> style={{
width: 240,
transform: 'translate(-75%, -110%)',
zIndex: 101,
}}
>
<div className={'font-semibold flex items-center gap-2'}>
<ControlOutlined />
<div>Audio Track Synchronization</div>
</div>
<InputNumber
style={{ width: 180 }}
value={deltaInputValue}
size={'small'}
step={'0.250'}
name={'audio delta'}
formatter={(value) => `${value}s`}
parser={(value) => value?.replace('s', '') as unknown as number}
stringMode
onChange={handleDelta}
/>
<div className={'w-full flex items-center gap-2'}>
<Button size={'small'} type={'primary'} onClick={onSync}>
Sync
</Button>
<Button size={'small'} onClick={onCancel}>
Cancel
</Button>
<Button
size={'small'}
type={'text'}
className={'ml-auto'}
onClick={onReset}
>
Reset
</Button>
</div>
</div> </div>
<InputNumber
style={{ width: 180 }}
value={deltaInputValue}
size={"small"}
step={"0.250"}
name={"audio delta"}
formatter={(value) => `${value}s`}
parser={(value) => value?.replace('s', '') as unknown as number}
stringMode
onChange={handleDelta} />
<div className={"w-full flex items-center gap-2"}>
<Button size={"small"} type={"primary"} onClick={onSync}>Sync</Button>
<Button size={"small"} onClick={onCancel}>Cancel</Button>
<Button size={"small"} type={"text"} className={"ml-auto"} onClick={onReset}>Reset</Button>
</div>
</div>
) : null} ) : null}
<div <div style={{ display: 'none' }}>
style={{ {files.map((file) => (
display: 'none', <audio
}} key={file.url}
> ref={(el) => (audioRefs.current[file.url] = el)}
<audio controls
ref={audioRef} muted={isMuted}
controls className="w-full"
muted={isMuted} style={{ height: 32 }}
className="w-full" >
style={{ height: 32 }} <source src={file.url} type="audio/mpeg" />
> Your browser does not support the audio element.
<source src={url} type="audio/mpeg" /> </audio>
Your browser does not support the audio element. ))}
</audio>
</div> </div>
</div> </div>
); );

View file

@ -72,9 +72,7 @@ function Session({
} }
export default withPermissions( export default withPermissions(
['SESSION_REPLAY'], ['SESSION_REPLAY', 'SERVICE_SESSION_REPLAY'], '', true, false
'',
true
)( )(
connect( connect(
(state: any, props: any) => { (state: any, props: any) => {

View file

@ -4,6 +4,7 @@ import cn from 'classnames';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import React from 'react'; import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { useHistory } from 'react-router-dom';
import { PlayerContext } from 'App/components/Session/playerContext'; import { PlayerContext } from 'App/components/Session/playerContext';
import { useStore } from 'App/mstore'; import { useStore } from 'App/mstore';
@ -74,7 +75,7 @@ function getStorageName(type: any) {
function Controls(props: any) { function Controls(props: any) {
const { player, store } = React.useContext(PlayerContext); const { player, store } = React.useContext(PlayerContext);
const { uxtestingStore } = useStore(); const { uxtestingStore } = useStore();
const history = useHistory();
const { const {
playing, playing,
completed, completed,
@ -105,11 +106,11 @@ function Controls(props: any) {
const sessionTz = session?.timezone; const sessionTz = session?.timezone;
const nextHandler = () => { const nextHandler = () => {
props.history.push(withSiteId(sessionRoute(nextSessionId), siteId)); history.push(withSiteId(sessionRoute(nextSessionId), siteId));
}; };
const prevHandler = () => { const prevHandler = () => {
props.history.push(withSiteId(sessionRoute(previousSessionId), siteId)); history.push(withSiteId(sessionRoute(previousSessionId), siteId));
}; };
useShortcuts({ useShortcuts({
@ -144,6 +145,7 @@ function Controls(props: any) {
? PlayingState.Playing ? PlayingState.Playing
: PlayingState.Paused; : PlayingState.Paused;
const events = session.stackEvents ?? [];
return ( return (
<div className={styles.controls}> <div className={styles.controls}>
<Timeline /> <Timeline />
@ -181,7 +183,7 @@ function Controls(props: any) {
toggleBottomTools={toggleBottomTools} toggleBottomTools={toggleBottomTools}
bottomBlock={bottomBlock} bottomBlock={bottomBlock}
disabled={disabled} disabled={disabled}
audioUrl={session.audio} events={events}
/> />
)} )}
@ -204,7 +206,7 @@ interface IDevtoolsButtons {
toggleBottomTools: (blockName: number) => void; toggleBottomTools: (blockName: number) => void;
bottomBlock: number; bottomBlock: number;
disabled: boolean; disabled: boolean;
audioUrl?: string; events: any[];
} }
const DevtoolsButtons = observer( const DevtoolsButtons = observer(
@ -213,7 +215,7 @@ const DevtoolsButtons = observer(
toggleBottomTools, toggleBottomTools,
bottomBlock, bottomBlock,
disabled, disabled,
audioUrl, events,
}: IDevtoolsButtons) => { }: IDevtoolsButtons) => {
const { aiSummaryStore } = useStore(); const { aiSummaryStore } = useStore();
const { store, player } = React.useContext(PlayerContext); const { store, player } = React.useContext(PlayerContext);
@ -250,6 +252,8 @@ const DevtoolsButtons = observer(
} }
aiSummaryStore.setToggleSummary(!aiSummaryStore.toggleSummary); aiSummaryStore.setToggleSummary(!aiSummaryStore.toggleSummary);
}; };
const possibleAudio = events.filter((e) => e.name.includes('media/audio'));
return ( return (
<> <>
{isSaas ? <SummaryButton onClick={showSummary} /> : null} {isSaas ? <SummaryButton onClick={showSummary} /> : null}
@ -350,7 +354,9 @@ const DevtoolsButtons = observer(
label="Profiler" label="Profiler"
/> />
)} )}
{audioUrl ? <DropdownAudioPlayer url={audioUrl} /> : null} {possibleAudio.length ? (
<DropdownAudioPlayer audioEvents={possibleAudio} />
) : null}
</> </>
); );
} }
@ -426,7 +432,7 @@ export default connect(
const permissions = state.getIn(['user', 'account', 'permissions']) || []; const permissions = state.getIn(['user', 'account', 'permissions']) || [];
const isEnterprise = state.getIn(['user', 'account', 'edition']) === 'ee'; const isEnterprise = state.getIn(['user', 'account', 'edition']) === 'ee';
return { return {
disableDevtools: isEnterprise && !permissions.includes('DEV_TOOLS'), disableDevtools: isEnterprise && !(permissions.includes('DEV_TOOLS') || permissions.includes('SERVICE_DEV_TOOLS')),
fullscreen: state.getIn(['components', 'player', 'fullscreen']), fullscreen: state.getIn(['components', 'player', 'fullscreen']),
bottomBlock: state.getIn(['components', 'player', 'bottomBlock']), bottomBlock: state.getIn(['components', 'player', 'bottomBlock']),
showStorageRedux: !state.getIn([ showStorageRedux: !state.getIn([

View file

@ -1,7 +1,7 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { useStore } from 'App/mstore'; import { useStore } from 'App/mstore';
import KeyboardHelp from 'Components/Session_/Player/Controls/components/KeyboardHelp'; import KeyboardHelp from 'Components/Session_/Player/Controls/components/KeyboardHelp';
import { Icon, Tooltip } from 'UI'; import { Icon } from 'UI';
import QueueControls from './QueueControls'; import QueueControls from './QueueControls';
import Bookmark from 'Shared/Bookmark'; import Bookmark from 'Shared/Bookmark';
import SharePopup from '../shared/SharePopup/SharePopup'; import SharePopup from '../shared/SharePopup/SharePopup';
@ -13,8 +13,9 @@ import { connect } from 'react-redux';
import SessionTabs from 'Components/Session/Player/SharedComponents/SessionTabs'; import SessionTabs from 'Components/Session/Player/SharedComponents/SessionTabs';
import { IFRAME } from 'App/constants/storageKeys'; import { IFRAME } from 'App/constants/storageKeys';
import cn from 'classnames'; import cn from 'classnames';
import { Switch, Button as AntButton, Popover } from 'antd'; import { Switch, Button as AntButton, Popover, Tooltip } from 'antd';
import { ShareAltOutlined } from '@ant-design/icons'; import { ShareAltOutlined } from '@ant-design/icons';
import { checkParam, truncateStringToFit } from 'App/utils';
const localhostWarn = (project) => project + '_localhost_warn'; const localhostWarn = (project) => project + '_localhost_warn';
const disableDevtools = 'or_devtools_uxt_toggle'; const disableDevtools = 'or_devtools_uxt_toggle';
@ -27,6 +28,14 @@ function SubHeader(props) {
const { location: currentLocation = 'loading...' } = store.get(); const { location: currentLocation = 'loading...' } = store.get();
const hasIframe = localStorage.getItem(IFRAME) === 'true'; const hasIframe = localStorage.getItem(IFRAME) === 'true';
const { uxtestingStore } = useStore(); const { uxtestingStore } = useStore();
const [hideTools, setHideTools] = React.useState(false);
React.useEffect(() => {
const hideDevtools = checkParam('hideTools');
if (hideDevtools) {
setHideTools(true);
}
}, []);
const enabledIntegration = useMemo(() => { const enabledIntegration = useMemo(() => {
const { integrations } = props; const { integrations } = props;
@ -37,13 +46,10 @@ function SubHeader(props) {
return integrations.some((i) => i.token); return integrations.some((i) => i.token);
}, [props.integrations]); }, [props.integrations]);
const location = const locationTruncated = truncateStringToFit(currentLocation, window.innerWidth - 200);
currentLocation && currentLocation.length > 70
? `${currentLocation.slice(0, 25)}...${currentLocation.slice(-40)}`
: currentLocation;
const showWarning = const showWarning =
location && /(localhost)|(127.0.0.1)|(0.0.0.0)/.test(location) && showWarningModal; currentLocation && /(localhost)|(127.0.0.1)|(0.0.0.0)/.test(currentLocation) && showWarningModal;
const closeWarning = () => { const closeWarning = () => {
localStorage.setItem(localhostWarnKey, '1'); localStorage.setItem(localhostWarnKey, '1');
setWarning(false); setWarning(false);
@ -59,7 +65,7 @@ function SubHeader(props) {
<div <div
className="w-full px-4 flex items-center border-b relative" className="w-full px-4 flex items-center border-b relative"
style={{ style={{
background: uxtestingStore.isUxt() ? (props.live ? '#F6FFED' : '#EBF4F5') : undefined, background: uxtestingStore.isUxt() ? (props.live ? '#F6FFED' : '#EBF4F5') : undefined
}} }}
> >
{showWarning ? ( {showWarning ? (
@ -71,7 +77,7 @@ function SubHeader(props) {
left: '50%', left: '50%',
bottom: '-24px', bottom: '-24px',
transform: 'translate(-50%, 0)', transform: 'translate(-50%, 0)',
fontWeight: 500, fontWeight: 500
}} }}
> >
Some assets may load incorrectly on localhost. Some assets may load incorrectly on localhost.
@ -88,52 +94,57 @@ function SubHeader(props) {
</div> </div>
</div> </div>
) : null} ) : null}
<SessionTabs />
<div
className={cn(
'ml-auto text-sm flex items-center color-gray-medium gap-2',
hasIframe ? 'opacity-50 pointer-events-none' : ''
)}
style={{ width: 'max-content' }}
>
<KeyboardHelp />
<Bookmark sessionId={props.sessionId} />
<NotePopup />
{enabledIntegration && <Issues sessionId={props.sessionId} />}
<SharePopup
showCopyLink={true}
trigger={
<div className="relative">
<Popover content={'Share Session'}>
<AntButton size={'small'} className="flex items-center justify-center">
<ShareAltOutlined />
</AntButton>
</Popover>
</div>
}
/>
{uxtestingStore.isUxt() ? ( <SessionTabs />
<Switch
checkedChildren={'DevTools'} {!hideTools && (
unCheckedChildren={'DevTools'} <div
onChange={toggleDevtools} className={cn(
defaultChecked={!uxtestingStore.hideDevtools} 'ml-auto text-sm flex items-center color-gray-medium gap-2',
hasIframe ? 'opacity-50 pointer-events-none' : ''
)}
style={{ width: 'max-content' }}
>
<KeyboardHelp />
<Bookmark sessionId={props.sessionId} />
<NotePopup />
{enabledIntegration && <Issues sessionId={props.sessionId} />}
<SharePopup
showCopyLink={true}
trigger={
<div className="relative">
<Tooltip title="Share Session" placement="bottom">
<AntButton size={'small'} className="flex items-center justify-center">
<ShareAltOutlined />
</AntButton>
</Tooltip>
</div>
}
/> />
) : (
<div> {uxtestingStore.isUxt() ? (
<QueueControls /> <Switch
</div> checkedChildren={'DevTools'}
)} unCheckedChildren={'DevTools'}
</div> onChange={toggleDevtools}
defaultChecked={!uxtestingStore.hideDevtools}
/>
) : (
<div>
<QueueControls />
</div>
)}
</div>
)}
</div> </div>
{location && (
{locationTruncated && (
<div className={'w-full bg-white border-b border-gray-lighter'}> <div className={'w-full bg-white border-b border-gray-lighter'}>
<div className="flex w-fit items-center cursor-pointer color-gray-medium text-sm p-1"> <div className="flex w-fit items-center cursor-pointer color-gray-medium text-sm p-1">
<Icon size="20" name="event/link" className="mr-1" /> <Icon size="20" name="event/link" className="mr-1" />
<Tooltip title="Open in new tab" delay={0}> <Tooltip title="Open in new tab" delay={0}>
<a href={currentLocation} target="_blank"> <a href={currentLocation} target="_blank" className="truncate">
{location} {locationTruncated}
</a> </a>
</Tooltip> </Tooltip>
</div> </div>
@ -146,5 +157,5 @@ function SubHeader(props) {
export default connect((state) => ({ export default connect((state) => ({
siteId: state.getIn(['site', 'siteId']), siteId: state.getIn(['site', 'siteId']),
integrations: state.getIn(['issues', 'list']), integrations: state.getIn(['issues', 'list']),
modules: state.getIn(['user', 'account', 'modules']) || [], modules: state.getIn(['user', 'account', 'modules']) || []
}))(observer(SubHeader)); }))(observer(SubHeader));

View file

@ -1,20 +1,20 @@
import React from "react"; import React from 'react';
import { connect } from "react-redux"; import { connect } from 'react-redux';
import { NoPermission, NoSessionPermission } from "UI"; import { NoPermission, NoSessionPermission } from 'UI';
export default (requiredPermissions, className, isReplay = false) => (BaseComponent) => { export default (requiredPermissions, className, isReplay = false, andEd = true) => (BaseComponent) => {
@connect((state, props) => ({ @connect((state, props) => ({
permissions: permissions:
state.getIn(["user", "account", "permissions"]) || [], state.getIn(['user', 'account', 'permissions']) || [],
isEnterprise: isEnterprise:
state.getIn(["user", "account", "edition"]) === "ee", state.getIn(['user', 'account', 'edition']) === 'ee'
})) }))
class WrapperClass extends React.PureComponent { class WrapperClass extends React.PureComponent {
render() { render() {
const hasPermission = requiredPermissions.every( const hasPermission = andEd ?
(permission) => requiredPermissions.every((permission) => this.props.permissions.includes(permission)) :
this.props.permissions.includes(permission) requiredPermissions.some((permission) => this.props.permissions.includes(permission)
); );
return !this.props.isEnterprise || hasPermission ? ( return !this.props.isEnterprise || hasPermission ? (
<BaseComponent {...this.props} /> <BaseComponent {...this.props} />
@ -29,5 +29,6 @@ export default (requiredPermissions, className, isReplay = false) => (BaseCompon
); );
} }
} }
return WrapperClass
} return WrapperClass;
}

View file

@ -1,125 +1,139 @@
import React from 'react'; import React from 'react';
import DateRangePicker from 'react-daterange-picker' import DateRangePicker from 'react-daterange-picker'
import { getDateRangeFromValue, getDateRangeLabel, dateRangeValues, CUSTOM_RANGE, moment, DATE_RANGE_VALUES } from 'App/dateRange'; import {
import { Button } from 'antd' getDateRangeFromValue,
import { TimePicker } from 'App/components/shared/DatePicker' getDateRangeLabel,
dateRangeValues,
CUSTOM_RANGE,
moment,
DATE_RANGE_VALUES
} from 'App/dateRange';
import {Button} from 'antd'
import {TimePicker} from 'App/components/shared/DatePicker'
import styles from './dateRangePopup.module.css'; import styles from './dateRangePopup.module.css';
export default class DateRangePopup extends React.PureComponent { export default class DateRangePopup extends React.PureComponent {
state = { state = {
range: this.props.selectedDateRange || moment.range(), range: this.props.selectedDateRange || moment.range(),
value: null, value: null,
}
selectCustomRange = (range) => {
range.end.endOf('day');
this.setState({
range,
value: CUSTOM_RANGE,
});
}
setRangeTimeStart = value => {
if (value.isAfter(this.state.range.end)) {
return;
}
this.setState({
range: moment.range(
value,
this.state.range.end,
),
value: CUSTOM_RANGE,
});
}
setRangeTimeEnd = value => {
if (value && value.isBefore(this.state.range.start)) {
return;
}
this.setState({
range: moment.range(
this.state.range.start,
value,
),
value: CUSTOM_RANGE,
});
}
selectValue = (value) => {
const range = getDateRangeFromValue(value);
this.setState({ range, value });
}
onApply = () => this.props.onApply(this.state.range, this.state.value)
render() {
const { onCancel, onApply } = this.props;
const { range } = this.state;
const rangeForDisplay = range.clone();
rangeForDisplay.start.startOf('day');
rangeForDisplay.end.startOf('day');
const selectionRange = {
startDate: new Date(),
endDate: new Date(),
key: 'selection',
} }
return ( selectCustomRange = (range) => {
<div className={ styles.wrapper }> range.end.endOf('day');
<div className={ styles.body }> this.setState({
<div className={ styles.preSelections }> range,
{ dateRangeValues.filter(value => value !== CUSTOM_RANGE && value !== DATE_RANGE_VALUES.LAST_30_MINUTES).map(value => ( value: CUSTOM_RANGE,
<div });
key={ value } }
onClick={ () => this.selectValue(value) }
> setRangeTimeStart = value => {
{ getDateRangeLabel(value) } const { range } = this.state;
</div> const newStart = range.start.clone().set({ hour: value.hour(), minute: value.minute() });
)) if (newStart.isAfter(range.end)) {
} return;
</div> }
<DateRangePicker const _range = moment.range(
name="dateRangePicker" newStart,
onSelect={ this.selectCustomRange } range.end,
numberOfCalendars={ 2 } )
// singleDateRange this.setState({
selectionType="range" range: _range,
maximumDate={ new Date() } value: CUSTOM_RANGE,
singleDateRange={true} });
value={ rangeForDisplay } }
/>
</div> setRangeTimeEnd = value => {
<div className="flex items-center justify-between py-2 px-3"> const { range } = this.state;
<div className="flex items-center gap-2"> const newEnd = range.end.clone().set({ hour: value.hour(), minute: value.minute() });
<label>From: </label> if (newEnd.isBefore(range.start)) {
<span>{range.start.format("DD/MM")} </span> return;
<TimePicker }
format={"HH:mm"} const _range = moment.range(
defaultValue={ range.start } range.start,
className="w-24" newEnd,
onChange={this.setRangeTimeStart} )
needConfirm={false} this.setState({
showNow={false} range: _range,
/> value: CUSTOM_RANGE,
<label>To: </label> });
<span>{range.end.format("DD/MM")} </span> }
<TimePicker
format={"HH:mm"} selectValue = (value) => {
defaultValue={ range.end } const range = getDateRangeFromValue(value);
onChange={this.setRangeTimeEnd} this.setState({range, value});
className="w-24" }
needConfirm={false}
showNow={false} onApply = () => this.props.onApply(this.state.range, this.state.value)
/>
</div> render() {
<div className="flex items-center"> const {onCancel, onApply} = this.props;
<Button onClick={ onCancel }>{ 'Cancel' }</Button> const {range} = this.state;
<Button type="primary" className="ml-2" onClick={ this.onApply } disabled={ !range }>{ 'Apply' }</Button>
</div> const rangeForDisplay = range.clone();
</div> rangeForDisplay.start.startOf('day');
</div> rangeForDisplay.end.startOf('day');
);
} const selectionRange = {
startDate: range.start.toDate(),
endDate: range.end.toDate(),
key: 'selection',
}
return (
<div className={styles.wrapper}>
<div className={styles.body}>
<div className={styles.preSelections}>
{dateRangeValues.filter(value => value !== CUSTOM_RANGE && value !== DATE_RANGE_VALUES.LAST_30_MINUTES).map(value => (
<div
key={value}
onClick={() => this.selectValue(value)}
>
{getDateRangeLabel(value)}
</div>
))
}
</div>
<DateRangePicker
name="dateRangePicker"
onSelect={this.selectCustomRange}
numberOfCalendars={2}
selectionType="range"
maximumDate={new Date()}
singleDateRange={true}
value={rangeForDisplay}
/>
</div>
<div className="flex items-center justify-between py-2 px-3">
<div className="flex items-center gap-2">
<label>From: </label>
<span>{range.start.format("DD/MM")} </span>
<TimePicker
format={"HH:mm"}
defaultValue={range.start}
className="w-24"
onChange={this.setRangeTimeStart}
needConfirm={false}
showNow={false}
/>
<label>To: </label>
<span>{range.end.format("DD/MM")} </span>
<TimePicker
format={"HH:mm"}
defaultValue={range.end}
onChange={this.setRangeTimeEnd}
className="w-24"
needConfirm={false}
showNow={false}
/>
</div>
<div className="flex items-center">
<Button onClick={onCancel}>{'Cancel'}</Button>
<Button type="primary" className="ml-2" onClick={this.onApply}
disabled={!range}>{'Apply'}</Button>
</div>
</div>
</div>
);
}
} }

View file

@ -15,6 +15,8 @@ interface Props {
socketMsgList: Array<SocketMsg>; socketMsgList: Array<SocketMsg>;
} }
const strLength = 40
function WSModal({ socketMsgList }: Props) { function WSModal({ socketMsgList }: Props) {
return ( return (
<div className={'h-screen w-full bg-white shadow'}> <div className={'h-screen w-full bg-white shadow'}>
@ -49,9 +51,9 @@ function Row({ msg }) {
<> <>
<div <div
className={`border-b grid grid-cols-12 ${ className={`border-b grid grid-cols-12 ${
msg.data.length > 100 ? 'hover:bg-active-blue cursor-pointer' : '' msg.data.length > strLength ? 'hover:bg-active-blue cursor-pointer' : ''
}`} }`}
onClick={() => (msg.data.length > 100 ? setIsOpen(!isOpen) : null)} onClick={() => (msg.data.length > strLength ? setIsOpen(!isOpen) : null)}
style={{ width: 700 }} style={{ width: 700 }}
> >
<div className={'col-span-9 flex items-center gap-2 p-2'}> <div className={'col-span-9 flex items-center gap-2 p-2'}>
@ -63,7 +65,7 @@ function Row({ msg }) {
> >
{msg.data} {msg.data}
</span> </span>
{msg.data.length > 100 ? ( {msg.data.length > strLength ? (
<div <div
className={ className={
'rounded-full font-bold text-xl p-2 bg-white w-6 h-6 flex items-center justify-center' 'rounded-full font-bold text-xl p-2 bg-white w-6 h-6 flex items-center justify-center'

View file

@ -19,10 +19,10 @@ function FetchBasicDetails({ resource, timestamp }: Props) {
return ( return (
<div> <div>
<div className="flex items-center py-1"> <div className="flex items-start py-1">
<div className="font-medium">Name</div> <div className="font-medium">Name</div>
<div className="rounded-lg bg-active-blue px-2 py-1 ml-2 whitespace-nowrap overflow-hidden text-clip cursor-pointer"> <div className="rounded-lg bg-active-blue px-2 py-1 ml-2 cursor-pointer word-break">
<CopyText content={resource.url}>{text}</CopyText> <CopyText content={resource.url}>{resource.url}</CopyText>
</div> </div>
</div> </div>

View file

@ -146,12 +146,21 @@ function FilterAutoComplete(props: Props) {
}, [value]) }, [value])
const loadOptions = (inputValue: string, callback: (options: []) => void) => { const loadOptions = (inputValue: string, callback: (options: []) => void) => {
// remove underscore from params const _params = Object.keys(params).reduce((acc: any, key: string) => {
const _params: Record<string, string> = {} // all metadata keys start with underscore to avoid conflicts with predefined filter keys
const keys = Object.keys(params); // they should be removed before sending to the server
keys.forEach((key) => { if (key === 'type' && params[key] === 'metadata') {
_params[key.replace('_', '')] = params[key]; acc['key'] = params['key'].replace(/^_/, '');
}) acc['type'] = 'metadata';
}
return acc;
}, {});
// const _params: Record<string, string> = {}
// const keys = Object.keys(params);
// keys.forEach((key) => {
// _params[key.replace('_', '')] = params[key];
// })
new APIClient() new APIClient()
[method?.toLocaleLowerCase()](endpoint, { ..._params, q: inputValue }) [method?.toLocaleLowerCase()](endpoint, { ..._params, q: inputValue })

View file

@ -25,6 +25,7 @@ import {
Timer, Timer,
VenetianMask, VenetianMask,
Workflow, Workflow,
Flag,
} from 'lucide-react'; } from 'lucide-react';
import React from 'react'; import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
@ -67,6 +68,7 @@ const IconMap = {
[FilterKey.UTM_SOURCE]: <CornerDownRight size={18}/>, [FilterKey.UTM_SOURCE]: <CornerDownRight size={18}/>,
[FilterKey.UTM_MEDIUM]: <Layers size={18}/>, [FilterKey.UTM_MEDIUM]: <Layers size={18}/>,
[FilterKey.UTM_CAMPAIGN]: <Megaphone size={18}/>, [FilterKey.UTM_CAMPAIGN]: <Megaphone size={18}/>,
[FilterKey.FEATURE_FLAG]: <Flag size={18}/>,
}; };
function filterJson( function filterJson(
@ -172,7 +174,7 @@ function FilterModal(props: Props) {
Object.keys(matchingFilters).length === 0; Object.keys(matchingFilters).length === 0;
const getNewIcon = (filter: Record<string, any>) => { const getNewIcon = (filter: Record<string, any>) => {
if (filter.icon.includes('metadata')) { if (filter.icon?.includes('metadata')) {
return IconMap[FilterKey.METADATA] return IconMap[FilterKey.METADATA]
} }
// @ts-ignore // @ts-ignore

View file

@ -185,7 +185,7 @@ function LiveSessionList(props: Props) {
); );
} }
export default withPermissions(['ASSIST_LIVE'])( export default withPermissions(['ASSIST_LIVE', 'SERVICE_ASSIST_LIVE'], '', false, false)(
connect( connect(
(state: any) => ({ (state: any) => ({
list: state.getIn(['liveSearch', 'list']), list: state.getIn(['liveSearch', 'list']),

View file

@ -0,0 +1,57 @@
import React from 'react'
import cn from 'classnames'
import { Segmented } from 'antd';
import { CopyButton } from 'UI';
import Highlight from 'react-highlight'
import stl from './InstallDocs/installDocs.module.css'
import { usageCode as iosUsageCode, installationCommand as iosInstallCommand } from "../../Onboarding/components/OnboardingTabs/InstallDocs/MobileInstallDocs";
import { usageCode as androidUsageCode, installationCommand as androidInstallCommand } from "../../Onboarding/components/OnboardingTabs/InstallDocs/AndroidInstallDocs";
function InstallMobileDocs({ site, ingestPoint }: any) {
const [isIos, setIsIos] = React.useState(true)
const usageCode = isIos ? iosUsageCode : androidUsageCode
const installationCommand = isIos ? iosInstallCommand : androidInstallCommand
const _usageCode = usageCode.replace('PROJECT_KEY', site.projectKey).replace('INGEST_POINT', ingestPoint)
const docLink = `https://docs.openreplay.com/en/${isIos ? 'ios-' : 'android-'}sdk/`
return (
<div>
<div className="mb-3">
<Segmented
options={[
{ label: 'iOS', value: true },
{ label: 'Android', value: false },
]}
value={isIos}
onChange={setIsIos}
/>
</div>
<div className="mb-3">
<div className="font-semibold mb-2">1. Installation</div>
<div className={ '' }>
<div className={ cn(stl.snippetWrapper, '') }>
<CopyButton content={installationCommand} className={cn(stl.codeCopy, 'mt-2 mr-2')} />
<Highlight className="cli">
{installationCommand}
</Highlight>
</div>
</div>
</div>
<div>
<div className="font-semibold mb-2">2. Usage</div>
<div className={ '' }>
<div className={ cn(stl.snippetWrapper, '') }>
<CopyButton content={_usageCode} className={cn(stl.codeCopy, 'mt-2 mr-2')} />
<Highlight className="cli">
{_usageCode}
</Highlight>
</div>
</div>
</div>
<div className="mt-6">See <a href={docLink} className="color-teal underline" target="_blank">Documentation</a> for the list of available options.</div>
</div>
)
}
export default InstallMobileDocs

View file

@ -1,49 +1,71 @@
import React from 'react'; import React from 'react';
import { Tabs } from 'UI'; import { Tabs } from 'UI';
import ProjectCodeSnippet from './ProjectCodeSnippet';
import InstallDocs from './InstallDocs'; import InstallDocs from './InstallDocs';
import InstallMobileDocs from './InstallIosDocs';
import ProjectCodeSnippet from './ProjectCodeSnippet';
const PROJECT = 'Using Script'; const PROJECT = 'Using Script';
const DOCUMENTATION = 'Using NPM'; const DOCUMENTATION = 'Using NPM';
const TABS = [ const TABS = [
{ key: DOCUMENTATION, text: DOCUMENTATION }, { key: DOCUMENTATION, text: DOCUMENTATION },
{ key: PROJECT, text: PROJECT }, { key: PROJECT, text: PROJECT },
]; ];
class TrackingCodeModal extends React.PureComponent { class TrackingCodeModal extends React.PureComponent {
state = { copied: false, changed: false, activeTab: DOCUMENTATION }; state = { copied: false, changed: false, activeTab: DOCUMENTATION };
setActiveTab = (tab) => { setActiveTab = (tab) => {
this.setState({ activeTab: tab }); this.setState({ activeTab: tab });
}; };
renderActiveTab = () => { renderActiveTab = () => {
const { site } = this.props; const { site } = this.props;
switch (this.state.activeTab) { switch (this.state.activeTab) {
case PROJECT: case PROJECT:
return <ProjectCodeSnippet site={site} />; return <ProjectCodeSnippet site={site} />;
case DOCUMENTATION: case DOCUMENTATION:
return <InstallDocs site={site} />; return <InstallDocs site={site} />;
}
return null;
};
render() {
const { title = '', subTitle } = this.props;
const { activeTab } = this.state;
return (
<div className="bg-white h-screen overflow-y-auto" style={{ width: '700px' }}>
<h3 className="p-5 text-2xl">
{title} {subTitle && <span className="text-sm color-gray-dark">{subTitle}</span>}
</h3>
<div>
<Tabs className="px-5" tabs={TABS} active={activeTab} onClick={this.setActiveTab} />
<div className="p-5">{this.renderActiveTab()}</div>
</div>
</div>
);
} }
return null;
};
render() {
const { title = '', subTitle, site } = this.props;
const { activeTab } = this.state;
const ingestPoint = `https://${window.location.hostname}/ingest`;
return (
<div
className="bg-white h-screen overflow-y-auto"
style={{ width: '700px' }}
>
<h3 className="p-5 text-2xl">
{title}{' '}
{subTitle && (
<span className="text-sm color-gray-dark">{subTitle}</span>
)}
</h3>
{site.platform === 'ios' ? (
<div className={'p-5'}>
<InstallMobileDocs site={site} ingestPoint={ingestPoint} />
</div>
) : (
<div>
<Tabs
className="px-5"
tabs={TABS}
active={activeTab}
onClick={this.setActiveTab}
/>
<div className="p-5">{this.renderActiveTab()}</div>
</div>
)}
</div>
);
}
} }
export default TrackingCodeModal; export default TrackingCodeModal;

View file

@ -51,6 +51,7 @@ export interface SessionFilesInfo {
frustrations: Record<string, any>[] frustrations: Record<string, any>[]
errors: Record<string, any>[] errors: Record<string, any>[]
agentInfo?: { email: string, name: string } agentInfo?: { email: string, name: string }
canvasURL?: string[]
} }
export type PlayerMsg = Message & { tabId: string } export type PlayerMsg = Message & { tabId: string }

View file

@ -12,7 +12,7 @@ export default class SnapshotManager extends ListWalker<Timestamp> {
private snapshots: Snapshots = {} private snapshots: Snapshots = {}
public mapToSnapshots(files: TarFile[]) { public mapToSnapshots(files: TarFile[]) {
const filenameRegexp = /(\d+)_1_(\d+)\.jpeg$/; const filenameRegexp = /(\d+)_1_(\d+)\.(jpeg|png|avif|webp)$/;
const firstPair = files[0].name.match(filenameRegexp) const firstPair = files[0].name.match(filenameRegexp)
const sessionStart = firstPair ? parseInt(firstPair[1], 10) : 0 const sessionStart = firstPair ? parseInt(firstPair[1], 10) : 0
files.forEach(file => { files.forEach(file => {

View file

@ -129,8 +129,35 @@ export default class MessageLoader {
}; };
} }
waitForCanvasURL = () => {
const start = Date.now();
return new Promise((resolve) => {
const checkInterval = setInterval(() => {
if (Boolean(this.session.canvasURL?.length)) {
clearInterval(checkInterval);
resolve(true);
} else {
if (Date.now() - start > 15000) {
clearInterval(checkInterval);
throw new Error('could not load canvas data after 15 seconds')
}
}
}, 100);
});
};
processMessages = (msgs: PlayerMsg[], file?: string) => { processMessages = (msgs: PlayerMsg[], file?: string) => {
msgs.forEach((msg) => { msgs.forEach(async (msg) => {
if (msg.tp === MType.CanvasNode) {
/**
* in case of prefetched sessions with canvases,
* we wait for signed urls and then parse the session
* */
if (file?.includes('p:dom') && !Boolean(this.session.canvasURL?.length)) {
console.warn('⚠Openreplay is waiting for canvas node to load')
await this.waitForCanvasURL();
}
}
this.messageManager.distributeMessage(msg); this.messageManager.distributeMessage(msg);
}); });
logger.info('Messages count: ', msgs.length, msgs, file); logger.info('Messages count: ', msgs.length, msgs, file);

View file

@ -106,7 +106,7 @@ export default class MessageManager {
public readonly decoder = new Decoder(); public readonly decoder = new Decoder();
private readonly sessionStart: number; private sessionStart: number;
private lastMessageTime: number = 0; private lastMessageTime: number = 0;
private firstVisualEventSet = false; private firstVisualEventSet = false;
public readonly tabs: Record<string, TabSessionManager> = {}; public readonly tabs: Record<string, TabSessionManager> = {};
@ -114,7 +114,7 @@ export default class MessageManager {
private activeTab = ''; private activeTab = '';
constructor( constructor(
private readonly session: SessionFilesInfo, private session: SessionFilesInfo,
private readonly state: Store<State & { time: number }>, private readonly state: Store<State & { time: number }>,
private readonly screen: Screen, private readonly screen: Screen,
private readonly initialLists?: Partial<InitialLists>, private readonly initialLists?: Partial<InitialLists>,
@ -134,6 +134,13 @@ export default class MessageManager {
return Object.values(this.tabs)[0].getListsFullState(); return Object.values(this.tabs)[0].getListsFullState();
}; };
public setSession = (session: SessionFilesInfo) => {
this.session = session;
this.sessionStart = this.session.startedAt;
this.state.update({ sessionStart: this.sessionStart });
Object.values(this.tabs).forEach((tab) => tab.setSession(session));
}
public updateLists(lists: RawList) { public updateLists(lists: RawList) {
Object.keys(this.tabs).forEach((tab) => { Object.keys(this.tabs).forEach((tab) => {
this.tabs[tab]!.updateLists(lists); this.tabs[tab]!.updateLists(lists);

View file

@ -80,7 +80,7 @@ export default class TabSessionManager {
private canvasReplayWalker: ListWalker<CanvasNode> = new ListWalker(); private canvasReplayWalker: ListWalker<CanvasNode> = new ListWalker();
constructor( constructor(
private readonly session: any, private session: any,
private readonly state: Store<{ tabStates: { [tabId: string]: TabState } }>, private readonly state: Store<{ tabStates: { [tabId: string]: TabState } }>,
private readonly screen: Screen, private readonly screen: Screen,
private readonly id: string, private readonly id: string,
@ -98,6 +98,10 @@ export default class TabSessionManager {
}); });
} }
setSession = (session: any) => {
this.session = session;
}
public getNode = (id: number) => { public getNode = (id: number) => {
return this.pagesManager.getNode(id); return this.pagesManager.getNode(id);
}; };

View file

@ -95,6 +95,7 @@ export default class WebPlayer extends Player {
reinit(session: SessionFilesInfo) { reinit(session: SessionFilesInfo) {
if (this.wpState.get().mobsFetched) return; // already initialized if (this.wpState.get().mobsFetched) return; // already initialized
this.messageLoader.setSession(session) this.messageLoader.setSession(session)
this.messageManager.setSession(session)
void this.messageLoader.loadFiles(); void this.messageLoader.loadFiles();
this.targetMarker = new TargetMarker(this.screen, this.wpState) this.targetMarker = new TargetMarker(this.screen, this.wpState)

View file

@ -86,7 +86,7 @@ export default class CanvasManager extends ListWalker<Timestamp> {
public mapToSnapshots(files: TarFile[]) { public mapToSnapshots(files: TarFile[]) {
const tempArr: Timestamp[] = []; const tempArr: Timestamp[] = [];
const filenameRegexp = /(\d+)_(\d+)_(\d+)\.jpeg$/; const filenameRegexp = /(\d+)_(\d+)_(\d+)\.(jpeg|png|avif|webp)$/;
const firstPair = files[0].name.match(filenameRegexp); const firstPair = files[0].name.match(filenameRegexp);
if (!firstPair) { if (!firstPair) {
console.error('Invalid file name format', files[0].name); console.error('Invalid file name format', files[0].name);
@ -155,13 +155,16 @@ export default class CanvasManager extends ListWalker<Timestamp> {
if (node && node.node) { if (node && node.node) {
const canvasCtx = (node.node as HTMLCanvasElement).getContext('2d'); const canvasCtx = (node.node as HTMLCanvasElement).getContext('2d');
const canvasEl = node.node as HTMLVideoElement; const canvasEl = node.node as HTMLVideoElement;
canvasCtx?.drawImage( requestAnimationFrame(() => {
this.snapImage, canvasCtx?.clearRect(0, 0, canvasEl.width, canvasEl.height);
0, canvasCtx?.drawImage(
0, this.snapImage,
canvasEl.width, 0,
canvasEl.height 0,
); canvasEl.width,
canvasEl.height
);
})
this.debugCanvas this.debugCanvas
?.getContext('2d') ?.getContext('2d')
?.drawImage(this.snapImage, 0, 0, 300, 200); ?.drawImage(this.snapImage, 0, 0, 300, 200);

View file

@ -479,4 +479,23 @@ export const checkParam = (paramName: string, storageKey?: string, search?: stri
return existsAndTrue; return existsAndTrue;
}; };
export const isValidUrl = (url) => /((([A-Za-z]{3,9}:(?:\/\/)?)(?:[-;:&=\+\$,\w]+@)?[A-Za-z0-9.-]+|(?:www.|[-;:&=\+\$,\w]+@)[A-Za-z0-9.-]+)((?:\/[\+~%\/.\w-_]*)?\??(?:[-\+=&;%@.\w_]*)#?(?:[\w]*))?)/.test(url); export const isValidUrl = (url) => /((([A-Za-z]{3,9}:(?:\/\/)?)(?:[-;:&=\+\$,\w]+@)?[A-Za-z0-9.-]+|(?:www.|[-;:&=\+\$,\w]+@)[A-Za-z0-9.-]+)((?:\/[\+~%\/.\w-_]*)?\??(?:[-\+=&;%@.\w_]*)#?(?:[\w]*))?)/.test(url);
export function truncateStringToFit(string: string, screenWidth: number, charWidth: number = 5): string {
if (string.length * charWidth <= screenWidth) {
return string;
}
const ellipsis = '...';
const maxLen = Math.floor(screenWidth / charWidth);
if (maxLen <= ellipsis.length) {
return ellipsis.slice(0, maxLen);
}
const frontLen = Math.floor((maxLen - ellipsis.length) / 2);
const backLen = maxLen - ellipsis.length - frontLen;
return string.slice(0, frontLen) + ellipsis + string.slice(-backLen);
}

View file

@ -52,7 +52,7 @@ function build_api() {
[[ $1 == "ee" ]] && { [[ $1 == "ee" ]] && {
cp -rf ../ee/peers/* ./ cp -rf ../ee/peers/* ./
} }
docker build -f ./Dockerfile --build-arg GIT_SHA=$git_sha -t ${DOCKER_REPO:-'local'}/peers:${image_tag} . docker build -f ./Dockerfile --platform linux/${ARCH:-"amd64"} --build-arg GIT_SHA=$git_sha -t ${DOCKER_REPO:-'local'}/peers:${image_tag} .
cd ../peers cd ../peers
rm -rf ../${destination} rm -rf ../${destination}
[[ $PUSH_IMAGE -eq 1 ]] && { [[ $PUSH_IMAGE -eq 1 ]] && {

View file

@ -1,7 +1,6 @@
apiVersion: v2 apiVersion: v2
name: assets name: assets
description: A Helm chart for Kubernetes description: A Helm chart for Kubernetes
# A chart can be either an 'application' or a 'library' chart. # A chart can be either an 'application' or a 'library' chart.
# #
# Application charts are a collection of templates that can be packaged into versioned archives # Application charts are a collection of templates that can be packaged into versioned archives
@ -11,14 +10,12 @@ description: A Helm chart for Kubernetes
# a dependency of application charts to inject those utilities and functions into the rendering # a dependency of application charts to inject those utilities and functions into the rendering
# pipeline. Library charts do not define any templates and therefore cannot be deployed. # pipeline. Library charts do not define any templates and therefore cannot be deployed.
type: application type: application
# This is the chart version. This version number should be incremented each time you make changes # This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version. # to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/) # Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 0.1.1 version: 0.1.1
# This is the version number of the application being deployed. This version number should be # This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to # incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using. # follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes. # It is recommended to use it with quotes.
AppVersion: "v1.18.0" AppVersion: "v1.18.1"

View file

@ -1,7 +1,6 @@
apiVersion: v2 apiVersion: v2
name: chalice name: chalice
description: A Helm chart for Kubernetes description: A Helm chart for Kubernetes
# A chart can be either an 'application' or a 'library' chart. # A chart can be either an 'application' or a 'library' chart.
# #
# Application charts are a collection of templates that can be packaged into versioned archives # Application charts are a collection of templates that can be packaged into versioned archives
@ -11,14 +10,12 @@ description: A Helm chart for Kubernetes
# a dependency of application charts to inject those utilities and functions into the rendering # a dependency of application charts to inject those utilities and functions into the rendering
# pipeline. Library charts do not define any templates and therefore cannot be deployed. # pipeline. Library charts do not define any templates and therefore cannot be deployed.
type: application type: application
# This is the chart version. This version number should be incremented each time you make changes # This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version. # to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/) # Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 0.1.7 version: 0.1.7
# This is the version number of the application being deployed. This version number should be # This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to # incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using. # follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes. # It is recommended to use it with quotes.
AppVersion: "v1.18.0" AppVersion: "v1.18.7"

View file

@ -1,7 +1,6 @@
apiVersion: v2 apiVersion: v2
name: frontend name: frontend
description: A Helm chart for Kubernetes description: A Helm chart for Kubernetes
# A chart can be either an 'application' or a 'library' chart. # A chart can be either an 'application' or a 'library' chart.
# #
# Application charts are a collection of templates that can be packaged into versioned archives # Application charts are a collection of templates that can be packaged into versioned archives
@ -11,14 +10,12 @@ description: A Helm chart for Kubernetes
# a dependency of application charts to inject those utilities and functions into the rendering # a dependency of application charts to inject those utilities and functions into the rendering
# pipeline. Library charts do not define any templates and therefore cannot be deployed. # pipeline. Library charts do not define any templates and therefore cannot be deployed.
type: application type: application
# This is the chart version. This version number should be incremented each time you make changes # This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version. # to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (frontends://semver.org/) # Versions are expected to follow Semantic Versioning (frontends://semver.org/)
version: 0.1.10 version: 0.1.10
# This is the version number of the application being deployed. This version number should be # This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to # incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using. # follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes. # It is recommended to use it with quotes.
AppVersion: "v1.18.0" AppVersion: "v1.18.15"

View file

@ -70,7 +70,7 @@ metadata:
proxy_ssl_name static.openreplay.com; proxy_ssl_name static.openreplay.com;
proxy_ssl_server_name on; proxy_ssl_server_name on;
spec: spec:
ingressClassName: openreplay ingressClassName: "{{ tpl .Values.ingress.className . }}"
tls: tls:
- hosts: - hosts:
- {{ .Values.global.domainName }} - {{ .Values.global.domainName }}

View file

@ -5,9 +5,9 @@ cd $(dirname $0)
is_migrate=$1 is_migrate=$1
# Converting alphaneumeric to number. # Passed from env
PREVIOUS_APP_VERSION=`echo $PREVIOUS_APP_VERSION | cut -d "v" -f2` # PREVIOUS_APP_VERSION
CHART_APP_VERSION=`echo $CHART_APP_VERSION | cut -d "v" -f2` # CHART_APP_VERSION
function migration() { function migration() {
ls -la /opt/openreplay/openreplay ls -la /opt/openreplay/openreplay
@ -36,54 +36,63 @@ function migration() {
# We need to remove version dots # We need to remove version dots
function normalise_version { function normalise_version {
echo "$@" | awk -F. '{ printf("%d%03d%03d%03d\n", $1,$2,$3,$4); }' version=$1
version=${version#v} # Remove leading 'v' if it exists
echo ${version} | tr -d '.'
} }
all_versions=(`ls -l db/init_dbs/$db | grep -E ^d | grep -v create | awk '{print $NF}'`) all_versions=($(ls -l db/init_dbs/$db | grep -E ^d | grep -v create | awk '{print $NF}'))
migration_versions=(`for ver in ${all_versions[*]}; do if [[ $(normalise_version $ver) > $(normalise_version "${PREVIOUS_APP_VERSION}") ]]; then echo $ver; fi; done | sort -V`) migration_versions=($(for ver in ${all_versions[*]}; do
if [[ $(normalise_version $ver) -gt $(normalise_version "${PREVIOUS_APP_VERSION}") ]]; then
echo $ver
fi
done | sort -V))
echo "Migration version: ${migration_versions[*]}" echo "Migration version: ${migration_versions[*]}"
# Can't pass the space seperated array to ansible for migration. So joining them with , # Can't pass the space seperated array to ansible for migration. So joining them with ,
joined_migration_versions=$(IFS=, ; echo "${migration_versions[*]}") joined_migration_versions=$(
IFS=,
echo "${migration_versions[*]}"
)
cd - cd -
case "$1" in case "$1" in
postgresql) postgresql)
/bin/bash postgresql.sh migrate $joined_migration_versions /bin/bash postgresql.sh migrate $joined_migration_versions
;; ;;
minio) minio)
/bin/bash minio.sh migrate $joined_migration_versions /bin/bash minio.sh migrate $joined_migration_versions
;; ;;
clickhouse) clickhouse)
/bin/bash clickhouse.sh migrate $joined_migration_versions /bin/bash clickhouse.sh migrate $joined_migration_versions
;; ;;
kafka) kafka)
/bin/bash kafka.sh migrate $joined_migration_versions /bin/bash kafka.sh migrate $joined_migration_versions
;; ;;
*) *)
echo "Unknown operation for db migration; exiting." echo "Unknown operation for db migration; exiting."
exit 1 exit 1
;; ;;
esac esac
} }
function init(){ function init() {
case $1 in case $1 in
postgresql) postgresql)
/bin/bash postgresql.sh init /bin/bash postgresql.sh init
;; ;;
minio) minio)
/bin/bash minio.sh migrate $migration_versions /bin/bash minio.sh migrate $migration_versions
;; ;;
clickhouse) clickhouse)
/bin/bash clickhouse.sh init /bin/bash clickhouse.sh init
;; ;;
kafka) kafka)
/bin/bash kafka.sh init /bin/bash kafka.sh init
;; ;;
*) *)
echo "Unknown operation for db init; exiting." echo "Unknown operation for db init; exiting."
exit 1 exit 1
;; ;;
esac esac
} }
@ -94,14 +103,14 @@ fi
# dbops.sh true(upgrade) clickhouse # dbops.sh true(upgrade) clickhouse
case "$is_migrate" in case "$is_migrate" in
"false") "false")
init $2 init $2
;; ;;
"true") "true")
migration $2 migration $2
;; ;;
*) *)
echo "Unknown operation for db migration; exiting." echo "Unknown operation for db migration; exiting."
exit 1 exit 1
;; ;;
esac esac

View file

@ -61,7 +61,7 @@ function build_api() {
envarg="default-ee" envarg="default-ee"
tag="ee-" tag="ee-"
} }
docker build -f ./Dockerfile --build-arg GIT_SHA=$git_sha --build-arg envarg=$envarg -t ${DOCKER_REPO:-'local'}/${image_name}:${image_tag} . docker build -f ./Dockerfile --platform linux/${ARCH:-"amd64"} --build-arg GIT_SHA=$git_sha --build-arg envarg=$envarg -t ${DOCKER_REPO:-'local'}/${image_name}:${image_tag} .
cd ../sourcemapreader cd ../sourcemapreader
rm -rf ../${destination} rm -rf ../${destination}
[[ $PUSH_IMAGE -eq 1 ]] && { [[ $PUSH_IMAGE -eq 1 ]] && {

Binary file not shown.

View file

@ -1,3 +1,12 @@
# 13.0.2
- more file extensions for canvas
# 13.0.1
- moved canvas snapshots to webp, additional option to utilize useAnimationFrame method (for webgl)
- simpler, faster canvas recording manager
# 13.0.0 # 13.0.0
- `assistOnly` flag for tracker options (EE only feature) - `assistOnly` flag for tracker options (EE only feature)

Binary file not shown.

View file

@ -1,7 +1,7 @@
{ {
"name": "@openreplay/tracker", "name": "@openreplay/tracker",
"description": "The OpenReplay tracker main package", "description": "The OpenReplay tracker main package",
"version": "13.0.0", "version": "13.0.2",
"keywords": [ "keywords": [
"logging", "logging",
"replay" "replay"

View file

@ -3,7 +3,7 @@ import { hasTag } from './guards.js'
import Message, { CanvasNode } from './messages.gen.js' import Message, { CanvasNode } from './messages.gen.js'
interface CanvasSnapshot { interface CanvasSnapshot {
images: { data: string; id: number }[] images: { data: Blob; id: number }[]
createdAt: number createdAt: number
paused: boolean paused: boolean
dummy: HTMLCanvasElement dummy: HTMLCanvasElement
@ -14,17 +14,21 @@ interface Options {
quality: 'low' | 'medium' | 'high' quality: 'low' | 'medium' | 'high'
isDebug?: boolean isDebug?: boolean
fixedScaling?: boolean fixedScaling?: boolean
useAnimationFrame?: boolean
fileExt?: 'webp' | 'png' | 'jpeg' | 'avif'
} }
class CanvasRecorder { class CanvasRecorder {
private snapshots: Record<number, CanvasSnapshot> = {} private snapshots: Record<number, CanvasSnapshot> = {}
private readonly intervals: NodeJS.Timeout[] = [] private readonly intervals: NodeJS.Timeout[] = []
private readonly interval: number private readonly interval: number
private readonly fileExt: 'webp' | 'png' | 'jpeg' | 'avif'
constructor( constructor(
private readonly app: App, private readonly app: App,
private readonly options: Options, private readonly options: Options,
) { ) {
this.fileExt = options.fileExt ?? 'webp'
this.interval = 1000 / options.fps this.interval = 1000 / options.fps
} }
@ -90,6 +94,24 @@ class CanvasRecorder {
} }
const canvasMsg = CanvasNode(id.toString(), ts) const canvasMsg = CanvasNode(id.toString(), ts)
this.app.send(canvasMsg as Message) this.app.send(canvasMsg as Message)
const captureFn = (canvas: HTMLCanvasElement) => {
captureSnapshot(
canvas,
this.options.quality,
this.snapshots[id].dummy,
this.options.fixedScaling,
this.fileExt,
(blob) => {
if (!blob) return
this.snapshots[id].images.push({ id: this.app.timestamp(), data: blob })
if (this.snapshots[id].images.length > 9) {
this.sendSnaps(this.snapshots[id].images, id, this.snapshots[id].createdAt)
this.snapshots[id].images = []
}
},
)
}
const int = setInterval(() => { const int = setInterval(() => {
const cid = this.app.nodes.getID(node) const cid = this.app.nodes.getID(node)
const canvas = cid ? this.app.nodes.getNode(cid) : undefined const canvas = cid ? this.app.nodes.getNode(cid) : undefined
@ -98,16 +120,12 @@ class CanvasRecorder {
clearInterval(int) clearInterval(int)
} else { } else {
if (!this.snapshots[id].paused) { if (!this.snapshots[id].paused) {
const snapshot = captureSnapshot( if (this.options.useAnimationFrame) {
canvas, requestAnimationFrame(() => {
this.options.quality, captureFn(canvas)
this.snapshots[id].dummy, })
this.options.fixedScaling, } else {
) captureFn(canvas)
this.snapshots[id].images.push({ id: this.app.timestamp(), data: snapshot })
if (this.snapshots[id].images.length > 9) {
this.sendSnaps(this.snapshots[id].images, id, this.snapshots[id].createdAt)
this.snapshots[id].images = []
} }
} }
} }
@ -115,17 +133,17 @@ class CanvasRecorder {
this.intervals.push(int) this.intervals.push(int)
} }
sendSnaps(images: { data: string; id: number }[], canvasId: number, createdAt: number) { sendSnaps(images: { data: Blob; id: number }[], canvasId: number, createdAt: number) {
if (Object.keys(this.snapshots).length === 0) { if (Object.keys(this.snapshots).length === 0) {
return return
} }
const formData = new FormData() const formData = new FormData()
images.forEach((snapshot) => { images.forEach((snapshot) => {
const blob = dataUrlToBlob(snapshot.data) const blob = snapshot.data
if (!blob) return if (!blob) return
formData.append('snapshot', blob[0], `${createdAt}_${canvasId}_${snapshot.id}.jpeg`) formData.append('snapshot', blob, `${createdAt}_${canvasId}_${snapshot.id}.${this.fileExt}`)
if (this.options.isDebug) { if (this.options.isDebug) {
saveImageData(snapshot.data, `${createdAt}_${canvasId}_${snapshot.id}.jpeg`) saveImageData(blob, `${createdAt}_${canvasId}_${snapshot.id}.${this.fileExt}`)
} }
}) })
@ -161,8 +179,10 @@ function captureSnapshot(
quality: 'low' | 'medium' | 'high' = 'medium', quality: 'low' | 'medium' | 'high' = 'medium',
dummy: HTMLCanvasElement, dummy: HTMLCanvasElement,
fixedScaling = false, fixedScaling = false,
fileExt: 'webp' | 'png' | 'jpeg' | 'avif',
onBlob: (blob: Blob | null) => void,
) { ) {
const imageFormat = 'image/jpeg' // or /png' const imageFormat = `image/${fileExt}`
if (fixedScaling) { if (fixedScaling) {
const canvasScaleRatio = window.devicePixelRatio || 1 const canvasScaleRatio = window.devicePixelRatio || 1
dummy.width = canvas.width / canvasScaleRatio dummy.width = canvas.width / canvasScaleRatio
@ -171,13 +191,26 @@ function captureSnapshot(
if (!ctx) { if (!ctx) {
return '' return ''
} }
ctx.clearRect(0, 0, dummy.width, dummy.height)
ctx.drawImage(canvas, 0, 0, dummy.width, dummy.height) ctx.drawImage(canvas, 0, 0, dummy.width, dummy.height)
return dummy.toDataURL(imageFormat, qualityInt[quality]) dummy.toBlob(onBlob, imageFormat, qualityInt[quality])
} else { } else {
return canvas.toDataURL(imageFormat, qualityInt[quality]) canvas.toBlob(onBlob, imageFormat, qualityInt[quality])
} }
} }
function saveImageData(imageDataBlob: Blob, name: string) {
const imageDataUrl = URL.createObjectURL(imageDataBlob)
const link = document.createElement('a')
link.href = imageDataUrl
link.download = name
link.style.display = 'none'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
function dataUrlToBlob(dataUrl: string): [Blob, Uint8Array] | null { function dataUrlToBlob(dataUrl: string): [Blob, Uint8Array] | null {
const [header, base64] = dataUrl.split(',') const [header, base64] = dataUrl.split(',')
if (!header || !base64) return null if (!header || !base64) return null
@ -195,15 +228,4 @@ function dataUrlToBlob(dataUrl: string): [Blob, Uint8Array] | null {
return [new Blob([u8arr], { type: mime }), u8arr] return [new Blob([u8arr], { type: mime }), u8arr]
} }
function saveImageData(imageDataUrl: string, name: string) {
const link = document.createElement('a')
link.href = imageDataUrl
link.download = name
link.style.display = 'none'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
export default CanvasRecorder export default CanvasRecorder

View file

@ -117,14 +117,37 @@ type AppOptions = {
__is_snippet: boolean __is_snippet: boolean
__debug_report_edp: string | null __debug_report_edp: string | null
__debug__?: ILogLevel __debug__?: ILogLevel
/** @deprecated see canvas prop */
__save_canvas_locally?: boolean __save_canvas_locally?: boolean
/** @deprecated see canvas prop */
fixedCanvasScaling?: boolean fixedCanvasScaling?: boolean
localStorage: Storage | null localStorage: Storage | null
sessionStorage: Storage | null sessionStorage: Storage | null
forceSingleTab?: boolean forceSingleTab?: boolean
/** Sometimes helps to prevent session breaking due to dict reset */
disableStringDict?: boolean disableStringDict?: boolean
assistSocketHost?: string assistSocketHost?: string
/** @deprecated see canvas prop */
disableCanvas?: boolean disableCanvas?: boolean
canvas: {
disableCanvas?: boolean
/**
* If you expect HI-DPI users mostly, this will render canvas
* in 1:1 pixel ratio
* */
fixedCanvasScaling?: boolean
__save_canvas_locally?: boolean
/**
* Use with care since it hijacks one frame each time it captures
* snapshot for every canvas
* */
useAnimationFrame?: boolean
/**
* Use webp unless it produces too big images
* @default webp
* */
fileExt?: 'webp' | 'png' | 'jpeg' | 'avif'
}
/** @deprecated */ /** @deprecated */
onStart?: StartCallback onStart?: StartCallback
@ -192,6 +215,23 @@ export default class App {
options: Partial<Options>, options: Partial<Options>,
private readonly signalError: (error: string, apis: string[]) => void, private readonly signalError: (error: string, apis: string[]) => void,
) { ) {
if (
Object.keys(options).findIndex((k) => ['fixedCanvasScaling', 'disableCanvas'].includes(k)) !==
-1
) {
console.warn(
'Openreplay: canvas options are moving to separate key "canvas" in next update. Please update your configuration.',
)
options = {
...options,
canvas: {
__save_canvas_locally: options.__save_canvas_locally,
fixedCanvasScaling: options.fixedCanvasScaling,
disableCanvas: options.disableCanvas,
},
}
}
this.contextId = Math.random().toString(36).slice(2) this.contextId = Math.random().toString(36).slice(2)
this.projectKey = projectKey this.projectKey = projectKey
this.networkOptions = options.network this.networkOptions = options.network
@ -218,6 +258,12 @@ export default class App {
fixedCanvasScaling: false, fixedCanvasScaling: false,
disableCanvas: false, disableCanvas: false,
assistOnly: false, assistOnly: false,
canvas: {
disableCanvas: false,
fixedCanvasScaling: false,
__save_canvas_locally: false,
useAnimationFrame: false,
},
}, },
options, options,
) )
@ -1085,14 +1131,15 @@ export default class App {
await this.tagWatcher.fetchTags(this.options.ingestPoint, token) await this.tagWatcher.fetchTags(this.options.ingestPoint, token)
this.activityState = ActivityState.Active this.activityState = ActivityState.Active
if (canvasEnabled && !this.options.disableCanvas) { if (canvasEnabled && !this.options.canvas.disableCanvas) {
this.canvasRecorder = this.canvasRecorder =
this.canvasRecorder ?? this.canvasRecorder ??
new CanvasRecorder(this, { new CanvasRecorder(this, {
fps: canvasFPS, fps: canvasFPS,
quality: canvasQuality, quality: canvasQuality,
isDebug: this.options.__save_canvas_locally, isDebug: this.options.canvas.__save_canvas_locally,
fixedScaling: this.options.fixedCanvasScaling, fixedScaling: this.options.canvas.fixedCanvasScaling,
useAnimationFrame: this.options.canvas.useAnimationFrame,
}) })
this.canvasRecorder.startTracking() this.canvasRecorder.startTracking()
} }