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: |
git fetch --tags
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
# if: ${{ failure() }}
# uses: mxschmitt/action-tmate@v3

View file

@ -52,7 +52,7 @@ function build_alerts() {
tag="ee-"
}
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
rm -rf ../${destination}
[[ $PUSH_IMAGE -eq 1 ]] && {

View file

@ -29,7 +29,7 @@ function build_crons() {
envarg="default-ee"
tag="ee-"
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
rm -rf ../${destination}
[[ $PUSH_IMAGE -eq 1 ]] && {

View file

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

View file

@ -78,17 +78,6 @@ def get_mobile_videos(session_id, project_id, check_existence=False):
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):
for session_id in session_ids:
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['live'] = live and assist.is_live(project_id=project_id, session_id=session_id,
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
return data
elif live:

View file

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

View file

@ -801,7 +801,9 @@ class SessionsSearchPayloadSchema(_TimedSchema, _PaginatedSchema):
continue
j = i + 1
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
del values[j]
else:

View file

@ -1,7 +1,4 @@
#ARCH can be amd64 or arm64
ARG ARCH=amd64
FROM --platform=linux/$ARCH node:20-alpine
FROM node:20-alpine
LABEL Maintainer="KRAIEM Taha Yassine<tahayk2@gmail.com>"
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
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>
ARCH=${ARCH:-amd64}
git_sha=$(git rev-parse --short HEAD)
image_tag=${IMAGE_TAG:-git_sha}
check_prereq() {
@ -51,7 +52,7 @@ function build_api() {
[[ $1 == "ee" ]] && {
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
rm -rf ../${destination}

View file

@ -106,9 +106,7 @@ func (c *cacher) cacheURL(t *Task) {
t.retries--
start := time.Now()
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 {
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.")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User not found.")
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),
userId=jwt_payload.get("userId", -1),
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):
if len(data.series) == 0:
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,
data=schemas.ClickMapSessionsSearch(
**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)
WHERE tenant_id =%(tenant_id)s
AND deleted_at IS NULL
AND not service_role
ORDER BY role_id;""",
{"tenant_id": tenant_id})
cur.execute(query=query)

View file

@ -34,7 +34,7 @@ class CurrentContext(schemas.CurrentContext):
if values.get("permissions") is not None:
perms = []
for p in values["permissions"]:
if Permissions.has_value(p):
if Permissions.has_value(p) or ServicePermissions.has_value(p):
perms.append(p)
values["permissions"] = perms
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 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;
\elif :is_next

View file

@ -15,7 +15,6 @@ function Assist(props: Props) {
return (
<AssistRouter />
);
}
const Cont = connect((state: any) => ({
@ -25,5 +24,5 @@ const Cont = connect((state: any) => ({
}))(Assist);
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 permissions = state.getIn(['user', 'account', 'permissions']) || [];
return {
hasPermission: permissions.includes('ASSIST_CALL'),
hasPermission: permissions.includes('ASSIST_CALL') || permissions.includes('SERVICE_ASSIST_CALL'),
isEnterprise: state.getIn(['user', 'account', 'edition']) === 'ee',
userDisplayName: state.getIn(['sessions', 'current']).userDisplayName,
agentId: state.getIn(['user', 'account', 'id'])

View file

@ -3,9 +3,9 @@ import stl from './installDocs.module.css';
import cn from 'classnames';
import Highlight from 'react-highlight';
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 {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
@ -20,18 +20,18 @@ dependencies {
}
`;
const usageCode = `// MainActivity.kt
export const usageCode = `// MainActivity.kt
import com.openreplay.tracker.OpenReplay
//...
class MainActivity : TrackingActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
// 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
OpenReplay.start(
applicationContext,
"projectkey",
"PROJECT_KEY",
OpenReplay.Options.defaults(),
onStarted = {
println("OpenReplay Started")
@ -40,7 +40,7 @@ class MainActivity : TrackingActivity() {
// ...
}
}`;
const configuration = `let crashs: Bool
const configuration = `let crashes: Bool
let analytics: Bool
let performances: Bool
let logs: Bool
@ -57,38 +57,38 @@ const sensitive = `import com.openreplay.tracker.OpenReplay
OpenReplay.addIgnoredView(view)
`
const inputs = `import com.opnereplay.tracker.OpenReplay
const inputs = `import com.openreplay.tracker.OpenReplay
val passwordEditText = binding.password
passwordEditText.trackTextInput(label = "password", masked = true)`
function AndroidInstallDocs({site}: any) {
const _usageCode = usageCode.replace('PROJECT_KEY', site.projectKey);
function AndroidInstallDocs({ site, ingestPoint }: any) {
let _usageCode = usageCode.replace('PROJECT_KEY', site.projectKey).replace('INGEST_POINT', ingestPoint);
return (
<div>
<div className="mb-4">
<div className="font-semibold mb-2 flex items-center">
<CircleNumber text="1"/>
<CircleNumber text="1" />
Install the SDK
</div>
<div className={cn(stl.snippetWrapper, 'ml-10')}>
<div className="absolute mt-1 mr-2 right-0">
<CopyButton content={installationCommand}/>
<CopyButton content={installationCommand} />
</div>
<Highlight className="cli">{installationCommand}</Highlight>
</div>
</div>
<div className="font-semibold mb-2 flex items-center">
<CircleNumber text="2"/>
<CircleNumber text="2" />
Add to your app
</div>
<div className="flex ml-10 mt-4">
<div className="w-full">
<div className={cn(stl.snippetWrapper)}>
<div className="absolute mt-1 mr-2 right-0">
<CopyButton content={_usageCode}/>
<CopyButton content={_usageCode} />
</div>
<Highlight className="swift">{_usageCode}</Highlight>
</div>
@ -96,7 +96,7 @@ function AndroidInstallDocs({site}: any) {
</div>
<div className="font-semibold mb-2 mt-4 flex items-center">
<CircleNumber text="3"/>
<CircleNumber text="3" />
Configuration
</div>
<div className="flex ml-10 mt-4">
@ -110,14 +110,14 @@ function AndroidInstallDocs({site}: any) {
</div>
<div className="font-semibold mb-2 mt-4 flex items-center">
<CircleNumber text="4"/>
<CircleNumber text="4" />
Set up touch events listener
</div>
<div className="flex ml-10 mt-4">
<div className="w-full">
<div className={cn(stl.snippetWrapper)}>
<div className="absolute mt-1 mr-2 right-0">
<CopyButton content={touches}/>
<CopyButton content={touches} />
</div>
<Highlight className="swift">{touches}</Highlight>
</div>
@ -125,14 +125,14 @@ function AndroidInstallDocs({site}: any) {
</div>
<div className="font-semibold mb-2 mt-4 flex items-center">
<CircleNumber text="5"/>
<CircleNumber text="5" />
Hide sensitive views
</div>
<div className="flex ml-10 mt-4">
<div className="w-full">
<div className={cn(stl.snippetWrapper)}>
<div className="absolute mt-1 mr-2 right-0">
<CopyButton content={sensitive}/>
<CopyButton content={sensitive} />
</div>
<Highlight className="swift">{sensitive}</Highlight>
</div>
@ -140,14 +140,14 @@ function AndroidInstallDocs({site}: any) {
</div>
<div className="font-semibold mb-2 mt-4 flex items-center">
<CircleNumber text="6"/>
<CircleNumber text="6" />
Track inputs
</div>
<div className="flex ml-10 mt-4">
<div className="w-full">
<div className={cn(stl.snippetWrapper)}>
<div className="absolute mt-1 mr-2 right-0">
<CopyButton content={inputs}/>
<CopyButton content={inputs} />
</div>
<Highlight className="swift">{inputs}</Highlight>
</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 { CopyButton } from 'UI';
const installationCommand = `
export const installationCommand = `
// make sure to grab latest version from https://github.com/openreplay/ios-tracker
// Cocoapods
pod 'Openreplay', '~> 1.0.5'
@ -16,7 +16,7 @@ dependencies: [
]
`;
const usageCode = `// AppDelegate.swift
export const usageCode = `// AppDelegate.swift
import OpenReplay
//...
@ -24,15 +24,15 @@ import OpenReplay
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
OpenReplay.shared.serverURL = "https://your.instance.com/ingest"
// not required if you're using our SaaS version
OpenReplay.shared.serverURL = "INGEST_POINT"
OpenReplay.shared.start(projectKey: "PROJECT_KEY", options: .defaults)
// ...
return true
}
// ...`;
const configuration = `let crashs: Bool
const configuration = `let crashes: Bool
let analytics: Bool
let performances: 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
Analytics.shared.addObservedInput(inputEl)`
function MobileInstallDocs({ site }: any) {
const _usageCode = usageCode.replace('PROJECT_KEY', site.projectKey);
function MobileInstallDocs({ site, ingestPoint }: any) {
const _usageCode = usageCode.replace('INGEST_POINT', ingestPoint).replace('PROJECT_KEY', site.projectKey);
return (
<div>

View file

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

View file

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

View file

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

View file

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

View file

@ -1,34 +1,52 @@
import { MutedOutlined, SoundOutlined, CaretDownOutlined, ControlOutlined } from '@ant-design/icons';
import { Button, Popover, InputNumber } from 'antd';
import {
CaretDownOutlined,
ControlOutlined,
MutedOutlined,
SoundOutlined,
} from '@ant-design/icons';
import { Button, InputNumber, Popover } from 'antd';
import { Slider } from 'antd';
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 }) {
const { store } = React.useContext(PlayerContext);
function DropdownAudioPlayer({
audioEvents,
}: {
audioEvents: { payload: Record<any, any>; timestamp: number }[];
}) {
const { store } = useContext(PlayerContext);
const [isVisible, setIsVisible] = useState(false);
const [volume, setVolume] = useState(0);
const [volume, setVolume] = useState(35);
const [delta, setDelta] = useState(0);
const [deltaInputValue, setDeltaInputValue] = useState(0);
const [isMuted, setIsMuted] = useState(true);
const lastPlayerTime = React.useRef(0);
const audioRef = useRef<HTMLAudioElement>(null);
const [isMuted, setIsMuted] = useState(false);
const lastPlayerTime = useRef(0);
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 = () => {
if (audioRef.current) {
if (audioRef.current?.paused && playing) {
audioRef.current?.play();
}
audioRef.current.muted = !audioRef.current.muted;
if (isMuted) {
onVolumeChange(35)
} else {
onVolumeChange(0)
Object.values(audioRefs.current).forEach((audio) => {
if (audio) {
audio.muted = !audio.muted;
}
});
setIsMuted(!isMuted);
if (!isMuted) {
onVolumeChange(0);
} else {
onVolumeChange(35);
}
};
@ -37,87 +55,102 @@ function DropdownAudioPlayer({ url }: { url: string }) {
};
const handleDelta = (value: any) => {
setDeltaInputValue(parseFloat(value))
}
setDeltaInputValue(parseFloat(value));
};
const onSync = () => {
setDelta(deltaInputValue)
handleSeek(time + deltaInputValue * 1000)
}
setDelta(deltaInputValue);
handleSeek(time + deltaInputValue * 1000);
};
const onCancel = () => {
setDeltaInputValue(0)
setIsVisible(false)
}
setDeltaInputValue(0);
setIsVisible(false);
};
const onReset = () => {
setDelta(0)
setDeltaInputValue(0)
handleSeek(time)
}
setDelta(0);
setDeltaInputValue(0);
handleSeek(time);
};
const onVolumeChange = (value: number) => {
if (audioRef.current) {
audioRef.current.volume = value / 100;
}
if (value === 0) {
setIsMuted(true);
}
if (value > 0) {
setIsMuted(false);
}
Object.values(audioRefs.current).forEach((audio) => {
if (audio) {
audio.volume = value / 100;
}
});
setVolume(value);
setIsMuted(value === 0);
};
const handleSeek = (timeMs: number) => {
if (audioRef.current) {
audioRef.current.currentTime = (timeMs + delta * 1000) / 1000;
}
Object.entries(audioRefs.current).forEach(([key, audio]) => {
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) => {
if (audioRef.current) {
audioRef.current.playbackRate = speed;
}
Object.values(audioRefs.current).forEach((audio) => {
if (audio) {
audio.playbackRate = speed;
}
});
};
React.useEffect(() => {
useEffect(() => {
const deltaMs = delta * 1000;
if (Math.abs(lastPlayerTime.current - time - deltaMs) >= 250) {
handleSeek(time);
}
if (audioRef.current) {
if (audioRef.current.paused && playing) {
audioRef.current?.play();
Object.entries(audioRefs.current).forEach(([url, audio]) => {
if (audio) {
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;
}, [time, delta]);
React.useEffect(() => {
useEffect(() => {
changePlaybackSpeed(speed);
}, [speed]);
React.useEffect(() => {
if (playing) {
audioRef.current?.play();
} else {
audioRef.current?.pause();
}
const volume = audioRef.current?.volume ?? 0
const shouldBeMuted = audioRef.current?.muted ?? isMuted
setVolume(shouldBeMuted ? 0 : volume * 100);
useEffect(() => {
Object.entries(audioRefs.current).forEach(([url, audio]) => {
if (audio) {
const file = files.find((f) => f.url === url);
if (file && playing && time >= file.start) {
audio.play();
} else {
audio.pause();
}
}
});
setVolume(isMuted ? 0 : volume);
}, [playing]);
return (
<div className={'relative'}>
<div
className={'flex items-center'}
style={{ height: 24 }}
>
<div className={'flex items-center'} style={{ height: 24 }}>
<Popover
trigger={'click'}
className={'h-full'}
@ -137,7 +170,11 @@ function DropdownAudioPlayer({ url }: { url: string }) {
</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 />}
</div>
</Popover>
@ -153,46 +190,64 @@ function DropdownAudioPlayer({ url }: { url: string }) {
</div>
{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"}
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
className={
'absolute left-1/2 top-0 border shadow border-gray-light rounded bg-white p-4 flex flex-col gap-4 mb-4'
}
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>
<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}
<div
style={{
display: 'none',
}}
>
<audio
ref={audioRef}
controls
muted={isMuted}
className="w-full"
style={{ height: 32 }}
>
<source src={url} type="audio/mpeg" />
Your browser does not support the audio element.
</audio>
<div style={{ display: 'none' }}>
{files.map((file) => (
<audio
key={file.url}
ref={(el) => (audioRefs.current[file.url] = el)}
controls
muted={isMuted}
className="w-full"
style={{ height: 32 }}
>
<source src={file.url} type="audio/mpeg" />
Your browser does not support the audio element.
</audio>
))}
</div>
</div>
);

View file

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

View file

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

View file

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

View file

@ -1,20 +1,20 @@
import React from "react";
import { connect } from "react-redux";
import { NoPermission, NoSessionPermission } from "UI";
import React from 'react';
import { connect } from 'react-redux';
import { NoPermission, NoSessionPermission } from 'UI';
export default (requiredPermissions, className, isReplay = false) => (BaseComponent) => {
export default (requiredPermissions, className, isReplay = false, andEd = true) => (BaseComponent) => {
@connect((state, props) => ({
permissions:
state.getIn(["user", "account", "permissions"]) || [],
state.getIn(['user', 'account', 'permissions']) || [],
isEnterprise:
state.getIn(["user", "account", "edition"]) === "ee",
state.getIn(['user', 'account', 'edition']) === 'ee'
}))
class WrapperClass extends React.PureComponent {
render() {
const hasPermission = requiredPermissions.every(
(permission) =>
this.props.permissions.includes(permission)
);
const hasPermission = andEd ?
requiredPermissions.every((permission) => this.props.permissions.includes(permission)) :
requiredPermissions.some((permission) => this.props.permissions.includes(permission)
);
return !this.props.isEnterprise || hasPermission ? (
<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 DateRangePicker from 'react-daterange-picker'
import { getDateRangeFromValue, getDateRangeLabel, dateRangeValues, CUSTOM_RANGE, moment, DATE_RANGE_VALUES } from 'App/dateRange';
import { Button } from 'antd'
import { TimePicker } from 'App/components/shared/DatePicker'
import {
getDateRangeFromValue,
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';
export default class DateRangePopup extends React.PureComponent {
state = {
range: this.props.selectedDateRange || moment.range(),
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',
state = {
range: this.props.selectedDateRange || moment.range(),
value: null,
}
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 }
// singleDateRange
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>
);
}
selectCustomRange = (range) => {
range.end.endOf('day');
this.setState({
range,
value: CUSTOM_RANGE,
});
}
setRangeTimeStart = value => {
const { range } = this.state;
const newStart = range.start.clone().set({ hour: value.hour(), minute: value.minute() });
if (newStart.isAfter(range.end)) {
return;
}
const _range = moment.range(
newStart,
range.end,
)
this.setState({
range: _range,
value: CUSTOM_RANGE,
});
}
setRangeTimeEnd = value => {
const { range } = this.state;
const newEnd = range.end.clone().set({ hour: value.hour(), minute: value.minute() });
if (newEnd.isBefore(range.start)) {
return;
}
const _range = moment.range(
range.start,
newEnd,
)
this.setState({
range: _range,
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: 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>;
}
const strLength = 40
function WSModal({ socketMsgList }: Props) {
return (
<div className={'h-screen w-full bg-white shadow'}>
@ -49,9 +51,9 @@ function Row({ msg }) {
<>
<div
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 }}
>
<div className={'col-span-9 flex items-center gap-2 p-2'}>
@ -63,7 +65,7 @@ function Row({ msg }) {
>
{msg.data}
</span>
{msg.data.length > 100 ? (
{msg.data.length > strLength ? (
<div
className={
'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 (
<div>
<div className="flex items-center py-1">
<div className="flex items-start py-1">
<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">
<CopyText content={resource.url}>{text}</CopyText>
<div className="rounded-lg bg-active-blue px-2 py-1 ml-2 cursor-pointer word-break">
<CopyText content={resource.url}>{resource.url}</CopyText>
</div>
</div>

View file

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

View file

@ -25,6 +25,7 @@ import {
Timer,
VenetianMask,
Workflow,
Flag,
} from 'lucide-react';
import React from 'react';
import { connect } from 'react-redux';
@ -67,6 +68,7 @@ const IconMap = {
[FilterKey.UTM_SOURCE]: <CornerDownRight size={18}/>,
[FilterKey.UTM_MEDIUM]: <Layers size={18}/>,
[FilterKey.UTM_CAMPAIGN]: <Megaphone size={18}/>,
[FilterKey.FEATURE_FLAG]: <Flag size={18}/>,
};
function filterJson(
@ -172,7 +174,7 @@ function FilterModal(props: Props) {
Object.keys(matchingFilters).length === 0;
const getNewIcon = (filter: Record<string, any>) => {
if (filter.icon.includes('metadata')) {
if (filter.icon?.includes('metadata')) {
return IconMap[FilterKey.METADATA]
}
// @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(
(state: any) => ({
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 { Tabs } from 'UI';
import ProjectCodeSnippet from './ProjectCodeSnippet';
import InstallDocs from './InstallDocs';
import InstallMobileDocs from './InstallIosDocs';
import ProjectCodeSnippet from './ProjectCodeSnippet';
const PROJECT = 'Using Script';
const DOCUMENTATION = 'Using NPM';
const TABS = [
{ key: DOCUMENTATION, text: DOCUMENTATION },
{ key: PROJECT, text: PROJECT },
{ key: DOCUMENTATION, text: DOCUMENTATION },
{ key: PROJECT, text: PROJECT },
];
class TrackingCodeModal extends React.PureComponent {
state = { copied: false, changed: false, activeTab: DOCUMENTATION };
state = { copied: false, changed: false, activeTab: DOCUMENTATION };
setActiveTab = (tab) => {
this.setState({ activeTab: tab });
};
setActiveTab = (tab) => {
this.setState({ activeTab: tab });
};
renderActiveTab = () => {
const { site } = this.props;
switch (this.state.activeTab) {
case PROJECT:
return <ProjectCodeSnippet site={site} />;
case DOCUMENTATION:
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>
);
renderActiveTab = () => {
const { site } = this.props;
switch (this.state.activeTab) {
case PROJECT:
return <ProjectCodeSnippet site={site} />;
case DOCUMENTATION:
return <InstallDocs site={site} />;
}
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;

View file

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

View file

@ -12,7 +12,7 @@ export default class SnapshotManager extends ListWalker<Timestamp> {
private snapshots: Snapshots = {}
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 sessionStart = firstPair ? parseInt(firstPair[1], 10) : 0
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) => {
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);
});
logger.info('Messages count: ', msgs.length, msgs, file);

View file

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

View file

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

View file

@ -95,6 +95,7 @@ export default class WebPlayer extends Player {
reinit(session: SessionFilesInfo) {
if (this.wpState.get().mobsFetched) return; // already initialized
this.messageLoader.setSession(session)
this.messageManager.setSession(session)
void this.messageLoader.loadFiles();
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[]) {
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);
if (!firstPair) {
console.error('Invalid file name format', files[0].name);
@ -155,13 +155,16 @@ export default class CanvasManager extends ListWalker<Timestamp> {
if (node && node.node) {
const canvasCtx = (node.node as HTMLCanvasElement).getContext('2d');
const canvasEl = node.node as HTMLVideoElement;
canvasCtx?.drawImage(
this.snapImage,
0,
0,
canvasEl.width,
canvasEl.height
);
requestAnimationFrame(() => {
canvasCtx?.clearRect(0, 0, canvasEl.width, canvasEl.height);
canvasCtx?.drawImage(
this.snapImage,
0,
0,
canvasEl.width,
canvasEl.height
);
})
this.debugCanvas
?.getContext('2d')
?.drawImage(this.snapImage, 0, 0, 300, 200);

View file

@ -479,4 +479,23 @@ export const checkParam = (paramName: string, storageKey?: string, search?: stri
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" ]] && {
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
rm -rf ../${destination}
[[ $PUSH_IMAGE -eq 1 ]] && {

View file

@ -1,7 +1,6 @@
apiVersion: v2
name: assets
description: A Helm chart for Kubernetes
# 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
@ -11,14 +10,12 @@ description: A Helm chart for Kubernetes
# 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.
type: application
# 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.
# Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 0.1.1
# 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
# follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes.
AppVersion: "v1.18.0"
AppVersion: "v1.18.1"

View file

@ -1,7 +1,6 @@
apiVersion: v2
name: chalice
description: A Helm chart for Kubernetes
# 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
@ -11,14 +10,12 @@ description: A Helm chart for Kubernetes
# 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.
type: application
# 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.
# Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 0.1.7
# 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
# follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes.
AppVersion: "v1.18.0"
AppVersion: "v1.18.7"

View file

@ -1,7 +1,6 @@
apiVersion: v2
name: frontend
description: A Helm chart for Kubernetes
# 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
@ -11,14 +10,12 @@ description: A Helm chart for Kubernetes
# 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.
type: application
# 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.
# Versions are expected to follow Semantic Versioning (frontends://semver.org/)
version: 0.1.10
# 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
# follow Semantic Versioning. They should reflect the version the application is using.
# 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_server_name on;
spec:
ingressClassName: openreplay
ingressClassName: "{{ tpl .Values.ingress.className . }}"
tls:
- hosts:
- {{ .Values.global.domainName }}

View file

@ -5,9 +5,9 @@ cd $(dirname $0)
is_migrate=$1
# Converting alphaneumeric to number.
PREVIOUS_APP_VERSION=`echo $PREVIOUS_APP_VERSION | cut -d "v" -f2`
CHART_APP_VERSION=`echo $CHART_APP_VERSION | cut -d "v" -f2`
# Passed from env
# PREVIOUS_APP_VERSION
# CHART_APP_VERSION
function migration() {
ls -la /opt/openreplay/openreplay
@ -36,54 +36,63 @@ function migration() {
# We need to remove version dots
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}'`)
migration_versions=(`for ver in ${all_versions[*]}; do if [[ $(normalise_version $ver) > $(normalise_version "${PREVIOUS_APP_VERSION}") ]]; then echo $ver; fi; done | sort -V`)
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) -gt $(normalise_version "${PREVIOUS_APP_VERSION}") ]]; then
echo $ver
fi
done | sort -V))
echo "Migration version: ${migration_versions[*]}"
# 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 -
case "$1" in
postgresql)
/bin/bash postgresql.sh migrate $joined_migration_versions
;;
minio)
/bin/bash minio.sh migrate $joined_migration_versions
;;
clickhouse)
/bin/bash clickhouse.sh migrate $joined_migration_versions
;;
kafka)
/bin/bash kafka.sh migrate $joined_migration_versions
;;
*)
echo "Unknown operation for db migration; exiting."
exit 1
;;
esac
postgresql)
/bin/bash postgresql.sh migrate $joined_migration_versions
;;
minio)
/bin/bash minio.sh migrate $joined_migration_versions
;;
clickhouse)
/bin/bash clickhouse.sh migrate $joined_migration_versions
;;
kafka)
/bin/bash kafka.sh migrate $joined_migration_versions
;;
*)
echo "Unknown operation for db migration; exiting."
exit 1
;;
esac
}
function init(){
function init() {
case $1 in
postgresql)
/bin/bash postgresql.sh init
;;
minio)
/bin/bash minio.sh migrate $migration_versions
;;
clickhouse)
/bin/bash clickhouse.sh init
;;
kafka)
/bin/bash kafka.sh init
;;
*)
echo "Unknown operation for db init; exiting."
exit 1
;;
postgresql)
/bin/bash postgresql.sh init
;;
minio)
/bin/bash minio.sh migrate $migration_versions
;;
clickhouse)
/bin/bash clickhouse.sh init
;;
kafka)
/bin/bash kafka.sh init
;;
*)
echo "Unknown operation for db init; exiting."
exit 1
;;
esac
}
@ -94,14 +103,14 @@ fi
# dbops.sh true(upgrade) clickhouse
case "$is_migrate" in
"false")
init $2
;;
"true")
migration $2
;;
*)
echo "Unknown operation for db migration; exiting."
exit 1
;;
"false")
init $2
;;
"true")
migration $2
;;
*)
echo "Unknown operation for db migration; exiting."
exit 1
;;
esac

View file

@ -61,7 +61,7 @@ function build_api() {
envarg="default-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
rm -rf ../${destination}
[[ $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
- `assistOnly` flag for tracker options (EE only feature)

Binary file not shown.

View file

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

View file

@ -3,7 +3,7 @@ import { hasTag } from './guards.js'
import Message, { CanvasNode } from './messages.gen.js'
interface CanvasSnapshot {
images: { data: string; id: number }[]
images: { data: Blob; id: number }[]
createdAt: number
paused: boolean
dummy: HTMLCanvasElement
@ -14,17 +14,21 @@ interface Options {
quality: 'low' | 'medium' | 'high'
isDebug?: boolean
fixedScaling?: boolean
useAnimationFrame?: boolean
fileExt?: 'webp' | 'png' | 'jpeg' | 'avif'
}
class CanvasRecorder {
private snapshots: Record<number, CanvasSnapshot> = {}
private readonly intervals: NodeJS.Timeout[] = []
private readonly interval: number
private readonly fileExt: 'webp' | 'png' | 'jpeg' | 'avif'
constructor(
private readonly app: App,
private readonly options: Options,
) {
this.fileExt = options.fileExt ?? 'webp'
this.interval = 1000 / options.fps
}
@ -90,6 +94,24 @@ class CanvasRecorder {
}
const canvasMsg = CanvasNode(id.toString(), ts)
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 cid = this.app.nodes.getID(node)
const canvas = cid ? this.app.nodes.getNode(cid) : undefined
@ -98,16 +120,12 @@ class CanvasRecorder {
clearInterval(int)
} else {
if (!this.snapshots[id].paused) {
const snapshot = captureSnapshot(
canvas,
this.options.quality,
this.snapshots[id].dummy,
this.options.fixedScaling,
)
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 = []
if (this.options.useAnimationFrame) {
requestAnimationFrame(() => {
captureFn(canvas)
})
} else {
captureFn(canvas)
}
}
}
@ -115,17 +133,17 @@ class CanvasRecorder {
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) {
return
}
const formData = new FormData()
images.forEach((snapshot) => {
const blob = dataUrlToBlob(snapshot.data)
const blob = snapshot.data
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) {
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',
dummy: HTMLCanvasElement,
fixedScaling = false,
fileExt: 'webp' | 'png' | 'jpeg' | 'avif',
onBlob: (blob: Blob | null) => void,
) {
const imageFormat = 'image/jpeg' // or /png'
const imageFormat = `image/${fileExt}`
if (fixedScaling) {
const canvasScaleRatio = window.devicePixelRatio || 1
dummy.width = canvas.width / canvasScaleRatio
@ -171,13 +191,26 @@ function captureSnapshot(
if (!ctx) {
return ''
}
ctx.clearRect(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 {
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 {
const [header, base64] = dataUrl.split(',')
if (!header || !base64) return null
@ -195,15 +228,4 @@ function dataUrlToBlob(dataUrl: string): [Blob, Uint8Array] | null {
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

View file

@ -117,14 +117,37 @@ type AppOptions = {
__is_snippet: boolean
__debug_report_edp: string | null
__debug__?: ILogLevel
/** @deprecated see canvas prop */
__save_canvas_locally?: boolean
/** @deprecated see canvas prop */
fixedCanvasScaling?: boolean
localStorage: Storage | null
sessionStorage: Storage | null
forceSingleTab?: boolean
/** Sometimes helps to prevent session breaking due to dict reset */
disableStringDict?: boolean
assistSocketHost?: string
/** @deprecated see canvas prop */
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 */
onStart?: StartCallback
@ -192,6 +215,23 @@ export default class App {
options: Partial<Options>,
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.projectKey = projectKey
this.networkOptions = options.network
@ -218,6 +258,12 @@ export default class App {
fixedCanvasScaling: false,
disableCanvas: false,
assistOnly: false,
canvas: {
disableCanvas: false,
fixedCanvasScaling: false,
__save_canvas_locally: false,
useAnimationFrame: false,
},
},
options,
)
@ -1085,14 +1131,15 @@ export default class App {
await this.tagWatcher.fetchTags(this.options.ingestPoint, token)
this.activityState = ActivityState.Active
if (canvasEnabled && !this.options.disableCanvas) {
if (canvasEnabled && !this.options.canvas.disableCanvas) {
this.canvasRecorder =
this.canvasRecorder ??
new CanvasRecorder(this, {
fps: canvasFPS,
quality: canvasQuality,
isDebug: this.options.__save_canvas_locally,
fixedScaling: this.options.fixedCanvasScaling,
isDebug: this.options.canvas.__save_canvas_locally,
fixedScaling: this.options.canvas.fixedCanvasScaling,
useAnimationFrame: this.options.canvas.useAnimationFrame,
})
this.canvasRecorder.startTracking()
}