Compare commits
58 commits
main
...
1.18-front
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d75ef60351 | ||
|
|
692a07efee | ||
|
|
9bd5b77c49 | ||
|
|
396c49cfeb | ||
|
|
c9e049bbc0 | ||
|
|
045b1e44da | ||
|
|
77fb1f0235 | ||
|
|
0e7359957b | ||
|
|
9bb64239c6 | ||
|
|
18cf9c94b8 | ||
|
|
a39a752e64 | ||
|
|
389ec4a8fc | ||
|
|
54332f3d48 | ||
|
|
30180b7159 | ||
|
|
6d903468f1 | ||
|
|
f1c5231ff0 | ||
|
|
c2e3764e92 | ||
|
|
8b585af366 | ||
|
|
5ed05d5aba | ||
|
|
c0b28f9f1f | ||
|
|
644ef31425 | ||
|
|
405b11380e | ||
|
|
3da082b2e5 | ||
|
|
1126543bfd | ||
|
|
43a04caa9f | ||
|
|
7cfaa0a49b | ||
|
|
d771ce8088 | ||
|
|
409b05a24c | ||
|
|
4150d06ff9 | ||
|
|
6190a6b495 | ||
|
|
3e2ad3e4eb | ||
|
|
5d02cfa844 | ||
|
|
714b2fef7d | ||
|
|
f113a43501 | ||
|
|
a543ed8737 | ||
|
|
e86fa726f6 | ||
|
|
904d861bc2 | ||
|
|
be15f89677 | ||
|
|
b6e76ea07b | ||
|
|
47cb339004 | ||
|
|
24922a4ab6 | ||
|
|
d190145d1c | ||
|
|
70d1d2b464 | ||
|
|
2e65bf0255 | ||
|
|
0c24a9e8cc | ||
|
|
7cc98bbb53 | ||
|
|
c606d885b1 | ||
|
|
2f9d387cc1 | ||
|
|
2fee8a4ccd | ||
|
|
64bf75555b | ||
|
|
d505b243c1 | ||
|
|
4c0bb2cd30 | ||
|
|
e7612ccf8a | ||
|
|
102a7cd2cd | ||
|
|
4bb5704fc9 | ||
|
|
d83fa0b052 | ||
|
|
2abc609dfc | ||
|
|
051e0ca557 |
60 changed files with 847 additions and 550 deletions
2
.github/workflows/update-tag.yaml
vendored
2
.github/workflows/update-tag.yaml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ]] && {
|
||||
|
|
|
|||
|
|
@ -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 ]] && {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) \
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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' ]),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -72,9 +72,7 @@ function Session({
|
|||
}
|
||||
|
||||
export default withPermissions(
|
||||
['SESSION_REPLAY'],
|
||||
'',
|
||||
true
|
||||
['SESSION_REPLAY', 'SERVICE_SESSION_REPLAY'], '', true, false
|
||||
)(
|
||||
connect(
|
||||
(state: any, props: any) => {
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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']),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ]] && {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
|
@ -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.
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@openreplay/tracker",
|
||||
"description": "The OpenReplay tracker main package",
|
||||
"version": "13.0.0",
|
||||
"version": "13.0.2",
|
||||
"keywords": [
|
||||
"logging",
|
||||
"replay"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue