pulled dev

This commit is contained in:
Андрей Бабушкин 2025-03-24 17:24:47 +01:00
commit 7894d10509
38 changed files with 835 additions and 501 deletions

View file

@ -3,6 +3,7 @@ import withPageTitle from 'HOCs/withPageTitle';
import { PageTitle } from 'UI'; import { PageTitle } from 'UI';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { useStore } from 'App/mstore'; import { useStore } from 'App/mstore';
import LanguageSwitcher from "App/components/LanguageSwitcher";
import Settings from './Settings'; import Settings from './Settings';
import ChangePassword from './ChangePassword'; import ChangePassword from './ChangePassword';
import styles from './profileSettings.module.css'; import styles from './profileSettings.module.css';
@ -20,107 +21,90 @@ function ProfileSettings() {
return ( return (
<div className="bg-white rounded-lg border shadow-sm p-5"> <div className="bg-white rounded-lg border shadow-sm p-5">
<PageTitle title={<div>{t('Account')}</div>} /> <PageTitle title={<div>{t('Account')}</div>} />
<div className="flex items-center"> <Section
<div className={styles.left}> title={t('Profile')}
<h4 className="text-lg mb-4">{t('Profile')}</h4> description={t('Your email address is your identity on OpenReplay and is used to login.')}
<div className={styles.info}> children={<Settings />}
{t( />
'Your email address is your identity on OpenReplay and is used to login.',
)}
</div>
</div>
<div>
<Settings />
</div>
</div>
<div className="border-b my-10" /> <div className="border-b my-10" />
{account.hasPassword && ( {account.hasPassword && (
<> <>
<div className="flex items-center"> <Section
<div className={styles.left}> title={t('Change Password')}
<h4 className="text-lg mb-4">{t('Change Password')}</h4> description={t('Updating your password from time to time enhaces your accounts security')}
<div className={styles.info}> children={<ChangePassword />}
{t('Updating your password from time to time enhances your accounts security.')} />
</div>
</div>
<div>
<ChangePassword />
</div>
</div>
<div className="border-b my-10" /> <div className="border-b my-10" />
</> </>
)} )}
<div className="flex items-center"> <Section
<div className={styles.left}> title={t('Interface Language')}
<h4 className="text-lg mb-4">{t('Organization API Key')}</h4> description={t('Select the language in which OpenReplay will appear.')}
<div className={styles.info}> children={<LanguageSwitcher />}
{t('Your API key gives you access to an extra set of services.')} />
</div>
</div> <Section
<div> title={t('Organization API Key')}
<Api /> description={t('Your API key gives you access to an extra set of services.')}
</div> children={<Api />}
</div> />
{isEnterprise && (account.admin || account.superAdmin) && ( {isEnterprise && (account.admin || account.superAdmin) && (
<> <>
<div className="border-b my-10" /> <div className="border-b my-10" />
<div className="flex items-center"> <Section
<div className={styles.left}> title={t('Tenant Key')}
<h4 className="text-lg mb-4">{t('Tenant Key')}</h4> description={t('For SSO (SAML) authentication.')}
<div className={styles.info}> children={<TenantKey />}
{t('For SSO (SAML) authentication.')} />
</div>
</div>
<div>
<TenantKey />
</div>
</div>
</> </>
)} )}
{!isEnterprise && ( {!isEnterprise && (
<> <>
<div className="border-b my-10" /> <div className="border-b my-10" />
<div className="flex items-center"> <Section
<div className={styles.left}> title={t('Data Collection')}
<h4 className="text-lg mb-4">{t('Data Collection')}</h4> description={t('Enables you to control how OpenReplay captures data on your organizations usage to improve our product.')}
<div className={styles.info}> children={<OptOut />}
{t('Enables you to control how OpenReplay captures data on your organizations usage to improve our product.')} />
</div>
</div>
<div>
<OptOut />
</div>
</div>
</> </>
)} )}
{account.license && ( {account.license && (
<> <>
<div className="border-b my-10" /> <div className="border-b my-10" />
<Section title={t('License')} description={t('License key and expiration date.')} children={<Licenses />} />
<div className="flex items-center">
<div className={styles.left}>
<h4 className="text-lg mb-4">{t('License')}</h4>
<div className={styles.info}>
{t('License key and expiration date.')}
</div>
</div>
<div>
<Licenses />
</div>
</div>
</> </>
)} )}
</div> </div>
); );
} }
function Section({ title, description, children }: {
title: string;
description: string;
children: React.ReactNode;
}) {
return (
<div className="flex items-center">
<div className={styles.left}>
<h4 className="text-lg mb-4">{title}</h4>
<div className={styles.info}>
{description}
</div>
</div>
<div>
{children}
</div>
</div>
)
}
export default withPageTitle('Account - OpenReplay Preferences')( export default withPageTitle('Account - OpenReplay Preferences')(
observer(ProfileSettings), observer(ProfileSettings),
); );

View file

@ -1,9 +1,7 @@
import { Button, Dropdown, MenuProps, Space, Typography } from 'antd'; import { Button, Dropdown, MenuProps, Typography } from 'antd';
import React, { useCallback, useState } from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { CaretDownOutlined } from '@ant-design/icons'; import { ChevronDown } from 'lucide-react';
import { Languages } from 'lucide-react';
import { Icon } from '../ui';
const langs = [ const langs = [
{ code: 'en', label: 'English' }, { code: 'en', label: 'English' },
@ -12,14 +10,25 @@ const langs = [
{ code: 'ru', label: 'Русский' }, { code: 'ru', label: 'Русский' },
{ code: 'zh', label: '中國人' }, { code: 'zh', label: '中國人' },
]; ];
const langLabels = {
en: 'English',
fr: 'Français',
es: 'Español',
ru: 'Русский',
zh: '中國人',
}
function LanguageSwitcher() { function LanguageSwitcher() {
const { i18n } = useTranslation(); const { i18n } = useTranslation();
const [selected, setSelected] = React.useState(i18n.language);
const handleChangeLanguage = useCallback((lang: string) => { const onChange = (val: string) => {
i18n.changeLanguage(lang); setSelected(val)
localStorage.setItem('i18nextLng', lang); }
}, []); const handleChangeLanguage = () => {
void i18n.changeLanguage(selected)
localStorage.setItem('i18nextLng', selected)
}
const menuItems: MenuProps['items'] = langs.map((lang) => ({ const menuItems: MenuProps['items'] = langs.map((lang) => ({
key: lang.code, key: lang.code,
@ -31,21 +40,31 @@ function LanguageSwitcher() {
})); }));
return ( return (
<Dropdown <div className={'flex flex-col gap-2 align-start'}>
menu={{ <div className={'font-semibold'}>{i18n.t('Language')}</div>
items: menuItems, <Dropdown
selectable: true, menu={{
defaultSelectedKeys: [i18n.language], items: menuItems,
style: { selectable: true,
maxHeight: 500, defaultSelectedKeys: [i18n.language],
overflowY: 'auto', style: {
}, maxHeight: 500,
onClick: (e) => handleChangeLanguage(e.key), overflowY: 'auto',
}} },
placement="bottomLeft" onClick: (e) => onChange(e.key),
> }}
<Button icon={<Languages size={12} />} /> >
</Dropdown> <Button>
<div className={'flex justify-between items-center gap-8'}>
<span>{langLabels[selected]}</span>
<ChevronDown size={14} />
</div>
</Button>
</Dropdown>
<Button className={'w-fit'} onClick={handleChangeLanguage}>
{i18n.t('Update')}
</Button>
</div>
); );
} }

View file

@ -8,6 +8,7 @@ import MobileOnboardingTabs from '../OnboardingTabs/OnboardingMobileTabs';
import ProjectFormButton from '../ProjectFormButton'; import ProjectFormButton from '../ProjectFormButton';
import withOnboarding, { WithOnboardingProps } from '../withOnboarding'; import withOnboarding, { WithOnboardingProps } from '../withOnboarding';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { CircleHelp } from 'lucide-react'
interface Props extends WithOnboardingProps { interface Props extends WithOnboardingProps {
platforms: Array<{ platforms: Array<{
@ -45,8 +46,8 @@ function InstallOpenReplayTab(props: Props) {
</div> </div>
<a href={"https://docs.openreplay.com/en/sdk/using-or/"} target="_blank"> <a href={"https://docs.openreplay.com/en/sdk/using-or/"} target="_blank">
<Button size={"small"} type={"text"} className="ml-2 flex items-center gap-2"> <Button size={"small"} type={"text"} className="ml-2 flex items-center gap-2">
<Icon name={"question-circle"} /> <CircleHelp size={14} />
<div className={"text-main"}>{t('See Documentation')}</div> <div>{t('See Documentation')}</div>
</Button> </Button>
</a> </a>
</h1> </h1>

View file

@ -0,0 +1,32 @@
import React from 'react'
import DocCard from "App/components/shared/DocCard";
import { useTranslation } from 'react-i18next';
import { Mail } from 'lucide-react'
import { CopyButton } from "UI";
export function CollabCard({ showUserModal }: { showUserModal: () => void }) {
const { t } = useTranslation();
return (
<DocCard title={t('Need help from team member?')}>
<div className={'text-main cursor-pointer flex items-center gap-2'} onClick={showUserModal}>
<Mail size={14} />
<span>
{t('Invite and Collaborate')}
</span>
</div>
</DocCard>
)
}
export function ProjectKeyCard({ projectKey }: { projectKey: string }) {
const { t } = useTranslation();
return (
<DocCard title={t('Project Key')}>
<div className="p-2 rounded bg-white flex justify-between items-center">
<div className={'font-mono'}>{projectKey}</div>
<CopyButton content={projectKey} className={'capitalize font-medium text-neutral-400'} />
</div>
</DocCard>
)
}

View file

@ -4,6 +4,7 @@ import DocCard from 'Shared/DocCard/DocCard';
import { useModal } from 'App/components/Modal'; import { useModal } from 'App/components/Modal';
import UserForm from 'App/components/Client/Users/components/UserForm/UserForm'; import UserForm from 'App/components/Client/Users/components/UserForm/UserForm';
import AndroidInstallDocs from 'Components/Onboarding/components/OnboardingTabs/InstallDocs/AndroidInstallDocs'; import AndroidInstallDocs from 'Components/Onboarding/components/OnboardingTabs/InstallDocs/AndroidInstallDocs';
import { CollabCard, ProjectKeyCard } from "./Callouts";
import MobileInstallDocs from './InstallDocs/MobileInstallDocs'; import MobileInstallDocs from './InstallDocs/MobileInstallDocs';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -39,18 +40,9 @@ function MobileTrackingCodeModal(props: Props) {
</div> </div>
<div className="col-span-2"> <div className="col-span-2">
<DocCard title={t('Need help from team member?')}> <CollabCard showUserModal={showUserModal} />
<a className="link" onClick={showUserModal}>
{t('Invite and Collaborate')}
</a>
</DocCard>
<DocCard title={t('Project Key')}> <ProjectKeyCard projectKey={site.projectKey} />
<div className="p-2 rounded bg-white flex justify-between items-center">
{site.projectKey}
<CopyButton content={site.projectKey} />
</div>
</DocCard>
</div> </div>
</div> </div>
); );
@ -62,18 +54,9 @@ function MobileTrackingCodeModal(props: Props) {
</div> </div>
<div className="col-span-2"> <div className="col-span-2">
<DocCard title={t('Need help from team member?')}> <CollabCard showUserModal={showUserModal} />
<a className="link" onClick={showUserModal}>
{t('Invite and Collaborate')}
</a>
</DocCard>
<DocCard title={t('Project Key')}> <ProjectKeyCard projectKey={site.projectKey} />
<div className="p-2 rounded bg-white flex justify-between items-center">
{site.projectKey}
<CopyButton content={site.projectKey} />
</div>
</DocCard>
</div> </div>
</div> </div>
); );

View file

@ -3,6 +3,7 @@ import { Tabs, Icon, CopyButton } from 'UI';
import DocCard from 'Shared/DocCard/DocCard'; import DocCard from 'Shared/DocCard/DocCard';
import { useModal } from 'App/components/Modal'; import { useModal } from 'App/components/Modal';
import UserForm from 'App/components/Client/Users/components/UserForm/UserForm'; import UserForm from 'App/components/Client/Users/components/UserForm/UserForm';
import { CollabCard, ProjectKeyCard } from "./Callouts";
import InstallDocs from './InstallDocs'; import InstallDocs from './InstallDocs';
import ProjectCodeSnippet from './ProjectCodeSnippet'; import ProjectCodeSnippet from './ProjectCodeSnippet';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -37,20 +38,9 @@ function TrackingCodeModal(props: Props) {
</div> </div>
<div className="col-span-2"> <div className="col-span-2">
<DocCard title="Need help from team member?"> <CollabCard showUserModal={showUserModal} />
<a className="link" onClick={showUserModal}>
{t('Invite and Collaborate')} <ProjectKeyCard projectKey={site.projectKey} />
</a>
</DocCard>
<DocCard title="Project Key">
<div className="rounded bg-white px-2 py-1 flex items-center justify-between">
<span>{site.projectKey}</span>
<CopyButton
content={site.projectKey}
className="capitalize"
/>
</div>
</DocCard>
<DocCard title="Other ways to install"> <DocCard title="Other ways to install">
<a <a
className="link flex items-center" className="link flex items-center"
@ -77,18 +67,9 @@ function TrackingCodeModal(props: Props) {
</div> </div>
<div className="col-span-2"> <div className="col-span-2">
<DocCard title="Need help from team member?"> <CollabCard showUserModal={showUserModal} />
<a className="link" onClick={showUserModal}>
{t('Invite and Collaborate')}
</a>
</DocCard>
<DocCard title="Project Key"> <ProjectKeyCard projectKey={site.projectKey} />
<div className="p-2 rounded bg-white flex justify-between items-center">
{site.projectKey}
<CopyButton content={site.projectKey} />
</div>
</DocCard>
</div> </div>
</div> </div>
); );

View file

@ -41,7 +41,7 @@ function SideMenu(props: Props) {
<Menu <Menu
mode="inline" mode="inline"
onClick={handleClick} onClick={handleClick}
style={{ marginTop: '8px', border: 'none' }} style={{ border: 'none' }}
selectedKeys={activeTab ? [activeTab] : []} selectedKeys={activeTab ? [activeTab] : []}
> >
<Menu.Item <Menu.Item

View file

@ -65,7 +65,6 @@ function GraphQL({ panelHeight }: { panelHeight: number }) {
const filterList = (list: any, value: string) => { const filterList = (list: any, value: string) => {
const filterRE = getRE(value, 'i'); const filterRE = getRE(value, 'i');
const { t } = useTranslation();
return value return value
? list.filter( ? list.filter(

View file

@ -1,9 +1,17 @@
/* eslint-disable i18next/no-literal-string */ /* eslint-disable i18next/no-literal-string */
import { ResourceType, Timed } from 'Player'; import { ResourceType, Timed } from 'Player';
import { WsChannel } from 'Player/web/messages';
import MobilePlayer from 'Player/mobile/IOSPlayer'; import MobilePlayer from 'Player/mobile/IOSPlayer';
import WebPlayer from 'Player/web/WebPlayer'; import WebPlayer from 'Player/web/WebPlayer';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import React, { useMemo, useState } from 'react'; import React, {
useMemo,
useState,
useEffect,
useCallback,
useRef,
} from 'react';
import i18n from 'App/i18n'
import { useModal } from 'App/components/Modal'; import { useModal } from 'App/components/Modal';
import { import {
@ -12,25 +20,27 @@ import {
} from 'App/components/Session/playerContext'; } from 'App/components/Session/playerContext';
import { formatMs } from 'App/date'; import { formatMs } from 'App/date';
import { useStore } from 'App/mstore'; import { useStore } from 'App/mstore';
import { formatBytes } from 'App/utils'; import { formatBytes, debounceCall } from 'App/utils';
import { Icon, NoContent, Tabs } from 'UI'; import { Icon, NoContent, Tabs } from 'UI';
import { Tooltip, Input, Switch, Form } from 'antd'; import { Tooltip, Input, Switch, Form } from 'antd';
import { SearchOutlined, InfoCircleOutlined } from '@ant-design/icons'; import {
SearchOutlined,
InfoCircleOutlined,
} from '@ant-design/icons';
import FetchDetailsModal from 'Shared/FetchDetailsModal'; import FetchDetailsModal from 'Shared/FetchDetailsModal';
import { WsChannel } from 'App/player/web/messages';
import BottomBlock from '../BottomBlock'; import BottomBlock from '../BottomBlock';
import InfoLine from '../BottomBlock/InfoLine'; import InfoLine from '../BottomBlock/InfoLine';
import TabSelector from '../TabSelector'; import TabSelector from '../TabSelector';
import TimeTable from '../TimeTable'; import TimeTable from '../TimeTable';
import useAutoscroll, { getLastItemTime } from '../useAutoscroll'; import useAutoscroll, { getLastItemTime } from '../useAutoscroll';
import { useRegExListFilterMemo, useTabListFilterMemo } from '../useListFilter';
import WSPanel from './WSPanel'; import WSPanel from './WSPanel';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { mergeListsWithZoom, processInChunks } from './utils'
// Constants remain the same
const INDEX_KEY = 'network'; const INDEX_KEY = 'network';
const ALL = 'ALL'; const ALL = 'ALL';
const XHR = 'xhr'; const XHR = 'xhr';
const JS = 'js'; const JS = 'js';
@ -62,6 +72,9 @@ export const NETWORK_TABS = TAP_KEYS.map((tab) => ({
const DOM_LOADED_TIME_COLOR = 'teal'; const DOM_LOADED_TIME_COLOR = 'teal';
const LOAD_TIME_COLOR = 'red'; const LOAD_TIME_COLOR = 'red';
const BATCH_SIZE = 2500;
const INITIAL_LOAD_SIZE = 5000;
export function renderType(r: any) { export function renderType(r: any) {
return ( return (
<Tooltip style={{ width: '100%' }} title={<div>{r.type}</div>}> <Tooltip style={{ width: '100%' }} title={<div>{r.type}</div>}>
@ -79,13 +92,17 @@ export function renderName(r: any) {
} }
function renderSize(r: any) { function renderSize(r: any) {
const { t } = useTranslation(); const t = i18n.t;
if (r.responseBodySize) return formatBytes(r.responseBodySize); const notCaptured = t('Not captured');
const resSizeStr = t('Resource size')
let triggerText; let triggerText;
let content; let content;
if (r.decodedBodySize == null || r.decodedBodySize === 0) { if (r.responseBodySize) {
triggerText = formatBytes(r.responseBodySize);
content = undefined;
} else if (r.decodedBodySize == null || r.decodedBodySize === 0) {
triggerText = 'x'; triggerText = 'x';
content = t('Not captured'); content = notCaptured;
} else { } else {
const headerSize = r.headerSize || 0; const headerSize = r.headerSize || 0;
const showTransferred = r.headerSize != null; const showTransferred = r.headerSize != null;
@ -100,7 +117,7 @@ function renderSize(r: any) {
)} transferred over network`} )} transferred over network`}
</li> </li>
)} )}
<li>{`${t('Resource size')}: ${formatBytes(r.decodedBodySize)} `}</li> <li>{`${resSizeStr}: ${formatBytes(r.decodedBodySize)} `}</li>
</ul> </ul>
); );
} }
@ -168,6 +185,8 @@ function renderStatus({
); );
} }
// Main component for Network Panel
function NetworkPanelCont({ panelHeight }: { panelHeight: number }) { function NetworkPanelCont({ panelHeight }: { panelHeight: number }) {
const { player, store } = React.useContext(PlayerContext); const { player, store } = React.useContext(PlayerContext);
const { sessionStore, uiPlayerStore } = useStore(); const { sessionStore, uiPlayerStore } = useStore();
@ -216,6 +235,7 @@ function NetworkPanelCont({ panelHeight }: { panelHeight: number }) {
const getTabNum = (tab: string) => tabsArr.findIndex((t) => t === tab) + 1; const getTabNum = (tab: string) => tabsArr.findIndex((t) => t === tab) + 1;
const getTabName = (tabId: string) => tabNames[tabId]; const getTabName = (tabId: string) => tabNames[tabId];
return ( return (
<NetworkPanelComp <NetworkPanelComp
loadTime={loadTime} loadTime={loadTime}
@ -228,8 +248,8 @@ function NetworkPanelCont({ panelHeight }: { panelHeight: number }) {
resourceListNow={resourceListNow} resourceListNow={resourceListNow}
player={player} player={player}
startedAt={startedAt} startedAt={startedAt}
websocketList={websocketList as WSMessage[]} websocketList={websocketList}
websocketListNow={websocketListNow as WSMessage[]} websocketListNow={websocketListNow}
getTabNum={getTabNum} getTabNum={getTabNum}
getTabName={getTabName} getTabName={getTabName}
showSingleTab={showSingleTab} showSingleTab={showSingleTab}
@ -269,9 +289,7 @@ function MobileNetworkPanelCont({ panelHeight }: { panelHeight: number }) {
resourceListNow={resourceListNow} resourceListNow={resourceListNow}
player={player} player={player}
startedAt={startedAt} startedAt={startedAt}
// @ts-ignore
websocketList={websocketList} websocketList={websocketList}
// @ts-ignore
websocketListNow={websocketListNow} websocketListNow={websocketListNow}
zoomEnabled={zoomEnabled} zoomEnabled={zoomEnabled}
zoomStartTs={zoomStartTs} zoomStartTs={zoomStartTs}
@ -280,12 +298,35 @@ function MobileNetworkPanelCont({ panelHeight }: { panelHeight: number }) {
); );
} }
type WSMessage = Timed & { const useInfiniteScroll = (loadMoreCallback: () => void, hasMore: boolean) => {
channelName: string; const observerRef = useRef<IntersectionObserver>(null);
data: string; const loadingRef = useRef<HTMLDivElement>(null);
timestamp: number;
dir: 'up' | 'down'; useEffect(() => {
messageType: string; const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && hasMore) {
loadMoreCallback();
}
},
{ threshold: 0.1 },
);
if (loadingRef.current) {
observer.observe(loadingRef.current);
}
// @ts-ignore
observerRef.current = observer;
return () => {
if (observerRef.current) {
observerRef.current.disconnect();
}
};
}, [loadMoreCallback, hasMore, loadingRef]);
return loadingRef;
}; };
interface Props { interface Props {
@ -302,8 +343,8 @@ interface Props {
resourceList: Timed[]; resourceList: Timed[];
fetchListNow: Timed[]; fetchListNow: Timed[];
resourceListNow: Timed[]; resourceListNow: Timed[];
websocketList: Array<WSMessage>; websocketList: Array<WsChannel>;
websocketListNow: Array<WSMessage>; websocketListNow: Array<WsChannel>;
player: WebPlayer | MobilePlayer; player: WebPlayer | MobilePlayer;
startedAt: number; startedAt: number;
isMobile?: boolean; isMobile?: boolean;
@ -349,107 +390,189 @@ export const NetworkPanelComp = observer(
>(null); >(null);
const { showModal } = useModal(); const { showModal } = useModal();
const [showOnlyErrors, setShowOnlyErrors] = useState(false); const [showOnlyErrors, setShowOnlyErrors] = useState(false);
const [isDetailsModalActive, setIsDetailsModalActive] = useState(false); const [isDetailsModalActive, setIsDetailsModalActive] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isProcessing, setIsProcessing] = useState(false);
const [displayedItems, setDisplayedItems] = useState([]);
const [totalItems, setTotalItems] = useState(0);
const [summaryStats, setSummaryStats] = useState({
resourcesSize: 0,
transferredSize: 0,
});
const originalListRef = useRef([]);
const socketListRef = useRef([]);
const { const {
sessionStore: { devTools }, sessionStore: { devTools },
} = useStore(); } = useStore();
const { filter } = devTools[INDEX_KEY]; const { filter } = devTools[INDEX_KEY];
const { activeTab } = devTools[INDEX_KEY]; const { activeTab } = devTools[INDEX_KEY];
const activeIndex = activeOutsideIndex ?? devTools[INDEX_KEY].index; const activeIndex = activeOutsideIndex ?? devTools[INDEX_KEY].index;
const [inputFilterValue, setInputFilterValue] = useState(filter);
const socketList = useMemo( const debouncedFilter = useCallback(
() => debounceCall((filterValue) => {
websocketList.filter( devTools.update(INDEX_KEY, { filter: filterValue });
(ws, i, arr) => }, 300),
arr.findIndex((it) => it.channelName === ws.channelName) === i, [],
),
[websocketList],
); );
const list = useMemo( // Process socket lists once
() => useEffect(() => {
// TODO: better merge (with body size info) - do it in player const uniqueSocketList = websocketList.filter(
resourceList (ws, i, arr) =>
.filter( arr.findIndex((it) => it.channelName === ws.channelName) === i,
(res) =>
!fetchList.some((ft) => {
// res.url !== ft.url doesn't work on relative URLs appearing within fetchList (to-fix in player)
if (res.name === ft.name) {
if (res.time === ft.time) return true;
if (res.url.includes(ft.url)) {
return (
Math.abs(res.time - ft.time) < 350 ||
Math.abs(res.timestamp - ft.timestamp) < 350
);
}
}
if (res.name !== ft.name) {
return false;
}
if (Math.abs(res.time - ft.time) > 250) {
return false;
} // TODO: find good epsilons
if (Math.abs(res.duration - ft.duration) > 200) {
return false;
}
return true;
}),
)
.concat(fetchList)
.concat(
socketList.map((ws) => ({
...ws,
type: 'websocket',
method: 'ws',
url: ws.channelName,
name: ws.channelName,
status: '101',
duration: 0,
transferredBodySize: 0,
})),
)
.filter((req) =>
zoomEnabled
? req.time >= zoomStartTs! && req.time <= zoomEndTs!
: true,
)
.sort((a, b) => a.time - b.time),
[resourceList.length, fetchList.length, socketList.length],
);
let filteredList = useMemo(() => {
if (!showOnlyErrors) {
return list;
}
return list.filter(
(it) => parseInt(it.status) >= 400 || !it.success || it.error,
); );
}, [showOnlyErrors, list]); socketListRef.current = uniqueSocketList;
filteredList = useRegExListFilterMemo( }, [websocketList.length]);
filteredList,
(it) => [it.status, it.name, it.type, it.method],
filter,
);
filteredList = useTabListFilterMemo(
filteredList,
(it) => TYPE_TO_TAB[it.type],
ALL,
activeTab,
);
const onTabClick = (activeTab: (typeof TAP_KEYS)[number]) => // Initial data processing - do this only once when data changes
useEffect(() => {
setIsLoading(true);
// Heaviest operation here, will create a final merged network list
const processData = async () => {
const fetchUrls = new Set(
fetchList.map((ft) => {
return `${ft.name}-${Math.floor(ft.time / 100)}-${Math.floor(ft.duration / 100)}`;
}),
);
// We want to get resources that aren't in fetch list
const filteredResources = await processInChunks(resourceList, (chunk) =>
chunk.filter((res: any) => {
const key = `${res.name}-${Math.floor(res.time / 100)}-${Math.floor(res.duration / 100)}`;
return !fetchUrls.has(key);
}),
BATCH_SIZE,
25,
);
const processedSockets = socketListRef.current.map((ws: any) => ({
...ws,
type: 'websocket',
method: 'ws',
url: ws.channelName,
name: ws.channelName,
status: '101',
duration: 0,
transferredBodySize: 0,
}));
const mergedList: Timed[] = mergeListsWithZoom(
filteredResources as Timed[],
fetchList,
processedSockets as Timed[],
{ enabled: Boolean(zoomEnabled), start: zoomStartTs ?? 0, end: zoomEndTs ?? 0 }
)
originalListRef.current = mergedList;
setTotalItems(mergedList.length);
calculateResourceStats(resourceList);
// Only display initial chunk
setDisplayedItems(mergedList.slice(0, INITIAL_LOAD_SIZE));
setIsLoading(false);
};
void processData();
}, [
resourceList.length,
fetchList.length,
socketListRef.current.length,
zoomEnabled,
zoomStartTs,
zoomEndTs,
]);
const calculateResourceStats = (resourceList: Record<string, any>) => {
setTimeout(() => {
let resourcesSize = 0
let transferredSize = 0
resourceList.forEach(({ decodedBodySize, headerSize, encodedBodySize }: any) => {
resourcesSize += decodedBodySize || 0
transferredSize += (headerSize || 0) + (encodedBodySize || 0)
})
setSummaryStats({
resourcesSize,
transferredSize,
});
}, 0);
}
useEffect(() => {
if (originalListRef.current.length === 0) return;
setIsProcessing(true);
const applyFilters = async () => {
let filteredItems: any[] = originalListRef.current;
filteredItems = await processInChunks(filteredItems, (chunk) =>
chunk.filter(
(it) => {
let valid = true;
if (showOnlyErrors) {
valid = parseInt(it.status) >= 400 || !it.success || it.error
}
if (filter) {
try {
const regex = new RegExp(filter, 'i');
valid = valid && regex.test(it.status) || regex.test(it.name) || regex.test(it.type) || regex.test(it.method);
} catch (e) {
valid = valid && String(it.status).includes(filter) || it.name.includes(filter) || it.type.includes(filter) || (it.method && it.method.includes(filter));
}
}
if (activeTab !== ALL) {
valid = valid && TYPE_TO_TAB[it.type] === activeTab;
}
return valid;
},
),
);
// Update displayed items
setDisplayedItems(filteredItems.slice(0, INITIAL_LOAD_SIZE));
setTotalItems(filteredItems.length);
setIsProcessing(false);
};
void applyFilters();
}, [filter, activeTab, showOnlyErrors]);
const loadMoreItems = useCallback(() => {
if (isProcessing) return;
setIsProcessing(true);
setTimeout(() => {
setDisplayedItems((prevItems) => {
const currentLength = prevItems.length;
const newItems = originalListRef.current.slice(
currentLength,
currentLength + BATCH_SIZE,
);
return [...prevItems, ...newItems];
});
setIsProcessing(false);
}, 10);
}, [isProcessing]);
const hasMoreItems = displayedItems.length < totalItems;
const loadingRef = useInfiniteScroll(loadMoreItems, hasMoreItems);
const onTabClick = (activeTab) => {
devTools.update(INDEX_KEY, { activeTab }); devTools.update(INDEX_KEY, { activeTab });
const onFilterChange = ({ };
target: { value },
}: React.ChangeEvent<HTMLInputElement>) => const onFilterChange = ({ target: { value } }) => {
devTools.update(INDEX_KEY, { filter: value }); setInputFilterValue(value)
debouncedFilter(value);
};
// AutoScroll
const [timeoutStartAutoscroll, stopAutoscroll] = useAutoscroll( const [timeoutStartAutoscroll, stopAutoscroll] = useAutoscroll(
filteredList, displayedItems,
getLastItemTime(fetchListNow, resourceListNow), getLastItemTime(fetchListNow, resourceListNow),
activeIndex, activeIndex,
(index) => devTools.update(INDEX_KEY, { index }), (index) => devTools.update(INDEX_KEY, { index }),
@ -462,24 +585,6 @@ export const NetworkPanelComp = observer(
timeoutStartAutoscroll(); timeoutStartAutoscroll();
}; };
const resourcesSize = useMemo(
() =>
resourceList.reduce(
(sum, { decodedBodySize }) => sum + (decodedBodySize || 0),
0,
),
[resourceList.length],
);
const transferredSize = useMemo(
() =>
resourceList.reduce(
(sum, { headerSize, encodedBodySize }) =>
sum + (headerSize || 0) + (encodedBodySize || 0),
0,
),
[resourceList.length],
);
const referenceLines = useMemo(() => { const referenceLines = useMemo(() => {
const arr = []; const arr = [];
@ -513,7 +618,7 @@ export const NetworkPanelComp = observer(
isSpot={isSpot} isSpot={isSpot}
time={item.time + startedAt} time={item.time + startedAt}
resource={item} resource={item}
rows={filteredList} rows={displayedItems}
fetchPresented={fetchList.length > 0} fetchPresented={fetchList.length > 0}
/>, />,
{ {
@ -525,12 +630,12 @@ export const NetworkPanelComp = observer(
}, },
}, },
); );
devTools.update(INDEX_KEY, { index: filteredList.indexOf(item) }); devTools.update(INDEX_KEY, { index: displayedItems.indexOf(item) });
stopAutoscroll(); stopAutoscroll();
}; };
const tableCols = React.useMemo(() => { const tableCols = useMemo(() => {
const cols: any[] = [ const cols = [
{ {
label: t('Status'), label: t('Status'),
dataKey: 'status', dataKey: 'status',
@ -585,7 +690,7 @@ export const NetworkPanelComp = observer(
}); });
} }
return cols; return cols;
}, [showSingleTab]); }, [showSingleTab, activeTab, t, getTabName, getTabNum, isSpot]);
return ( return (
<BottomBlock <BottomBlock
@ -617,7 +722,7 @@ export const NetworkPanelComp = observer(
name="filter" name="filter"
onChange={onFilterChange} onChange={onFilterChange}
width={280} width={280}
value={filter} value={inputFilterValue}
size="small" size="small"
prefix={<SearchOutlined className="text-neutral-400" />} prefix={<SearchOutlined className="text-neutral-400" />}
/> />
@ -625,7 +730,7 @@ export const NetworkPanelComp = observer(
</BottomBlock.Header> </BottomBlock.Header>
<BottomBlock.Content> <BottomBlock.Content>
<div className="flex items-center justify-between px-4 border-b bg-teal/5 h-8"> <div className="flex items-center justify-between px-4 border-b bg-teal/5 h-8">
<div> <div className="flex items-center">
<Form.Item name="show-errors-only" className="mb-0"> <Form.Item name="show-errors-only" className="mb-0">
<label <label
style={{ style={{
@ -642,21 +747,29 @@ export const NetworkPanelComp = observer(
<span className="text-sm ms-2">4xx-5xx Only</span> <span className="text-sm ms-2">4xx-5xx Only</span>
</label> </label>
</Form.Item> </Form.Item>
{isProcessing && (
<span className="text-xs text-gray-500 ml-4">
Processing data...
</span>
)}
</div> </div>
<InfoLine> <InfoLine>
<InfoLine.Point label={`${totalItems}`} value="requests" />
<InfoLine.Point <InfoLine.Point
label={`${filteredList.length}`} label={`${displayedItems.length}/${totalItems}`}
value=" requests" value="displayed"
display={displayedItems.length < totalItems}
/> />
<InfoLine.Point <InfoLine.Point
label={formatBytes(transferredSize)} label={formatBytes(summaryStats.transferredSize)}
value="transferred" value="transferred"
display={transferredSize > 0} display={summaryStats.transferredSize > 0}
/> />
<InfoLine.Point <InfoLine.Point
label={formatBytes(resourcesSize)} label={formatBytes(summaryStats.resourcesSize)}
value="resources" value="resources"
display={resourcesSize > 0} display={summaryStats.resourcesSize > 0}
/> />
<InfoLine.Point <InfoLine.Point
label={formatMs(domBuildingTime)} label={formatMs(domBuildingTime)}
@ -679,42 +792,67 @@ export const NetworkPanelComp = observer(
/> />
</InfoLine> </InfoLine>
</div> </div>
<NoContent
title={ {isLoading ? (
<div className="capitalize flex items-center gap-2"> <div className="flex items-center justify-center h-full">
<InfoCircleOutlined size={18} /> <div className="text-center">
{t('No Data')} <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 mx-auto mb-2"></div>
<p>Processing initial network data...</p>
</div> </div>
} </div>
size="small" ) : (
show={filteredList.length === 0} <NoContent
> title={
{/* @ts-ignore */} <div className="capitalize flex items-center gap-2">
<TimeTable <InfoCircleOutlined size={18} />
rows={filteredList} {t('No Data')}
tableHeight={panelHeight - 102} </div>
referenceLines={referenceLines} }
renderPopup size="small"
onRowClick={showDetailsModal} show={displayedItems.length === 0}
sortBy="time"
sortAscending
onJump={(row: any) => {
devTools.update(INDEX_KEY, {
index: filteredList.indexOf(row),
});
player.jump(row.time);
}}
activeIndex={activeIndex}
> >
{tableCols} <div>
</TimeTable> <TimeTable
{selectedWsChannel ? ( rows={displayedItems}
<WSPanel tableHeight={panelHeight - 102 - (hasMoreItems ? 30 : 0)}
socketMsgList={selectedWsChannel} referenceLines={referenceLines}
onClose={() => setSelectedWsChannel(null)} renderPopup
/> onRowClick={showDetailsModal}
) : null} sortBy="time"
</NoContent> sortAscending
onJump={(row) => {
devTools.update(INDEX_KEY, {
index: displayedItems.indexOf(row),
});
player.jump(row.time);
}}
activeIndex={activeIndex}
>
{tableCols}
</TimeTable>
{hasMoreItems && (
<div
ref={loadingRef}
className="flex justify-center items-center text-xs text-gray-500"
>
<div className="flex items-center">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-600 mr-2"></div>
Loading more data ({totalItems - displayedItems.length}{' '}
remaining)
</div>
</div>
)}
</div>
{selectedWsChannel ? (
<WSPanel
socketMsgList={selectedWsChannel}
onClose={() => setSelectedWsChannel(null)}
/>
) : null}
</NoContent>
)}
</BottomBlock.Content> </BottomBlock.Content>
</BottomBlock> </BottomBlock>
); );
@ -722,7 +860,6 @@ export const NetworkPanelComp = observer(
); );
const WebNetworkPanel = observer(NetworkPanelCont); const WebNetworkPanel = observer(NetworkPanelCont);
const MobileNetworkPanel = observer(MobileNetworkPanelCont); const MobileNetworkPanel = observer(MobileNetworkPanelCont);
export { WebNetworkPanel, MobileNetworkPanel }; export { WebNetworkPanel, MobileNetworkPanel };

View file

@ -0,0 +1,178 @@
export function mergeListsWithZoom<
T extends Record<string, any>,
Y extends Record<string, any>,
Z extends Record<string, any>,
>(
arr1: T[],
arr2: Y[],
arr3: Z[],
zoom?: { enabled: boolean; start: number; end: number },
): Array<T | Y | Z> {
// Early return for empty arrays
if (arr1.length === 0 && arr2.length === 0 && arr3.length === 0) {
return [];
}
// Optimized for common case - no zoom
if (!zoom?.enabled) {
return mergeThreeSortedArrays(arr1, arr2, arr3);
}
// Binary search for start indexes (faster than linear search for large arrays)
const index1 = binarySearchStartIndex(arr1, zoom.start);
const index2 = binarySearchStartIndex(arr2, zoom.start);
const index3 = binarySearchStartIndex(arr3, zoom.start);
// Merge arrays within zoom range
return mergeThreeSortedArraysWithinRange(
arr1,
arr2,
arr3,
index1,
index2,
index3,
zoom.start,
zoom.end,
);
}
function binarySearchStartIndex<T extends Record<string, any>>(
arr: T[],
threshold: number,
): number {
if (arr.length === 0) return 0;
let low = 0;
let high = arr.length - 1;
// Handle edge cases first for better performance
if (arr[high].time < threshold) return arr.length;
if (arr[low].time >= threshold) return 0;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
if (arr[mid].time < threshold) {
low = mid + 1;
} else {
high = mid - 1;
}
}
return low;
}
function mergeThreeSortedArrays<
T extends Record<string, any>,
Y extends Record<string, any>,
Z extends Record<string, any>,
>(arr1: T[], arr2: Y[], arr3: Z[]): Array<T | Y | Z> {
const totalLength = arr1.length + arr2.length + arr3.length;
// prealloc array size
const result = new Array(totalLength);
let i = 0,
j = 0,
k = 0,
index = 0;
while (i < arr1.length || j < arr2.length || k < arr3.length) {
const val1 = i < arr1.length ? arr1[i].time : Infinity;
const val2 = j < arr2.length ? arr2[j].time : Infinity;
const val3 = k < arr3.length ? arr3[k].time : Infinity;
if (val1 <= val2 && val1 <= val3) {
result[index++] = arr1[i++];
} else if (val2 <= val1 && val2 <= val3) {
result[index++] = arr2[j++];
} else {
result[index++] = arr3[k++];
}
}
return result;
}
// same as above, just with zoom stuff
function mergeThreeSortedArraysWithinRange<
T extends Record<string, any>,
Y extends Record<string, any>,
Z extends Record<string, any>,
>(
arr1: T[],
arr2: Y[],
arr3: Z[],
startIdx1: number,
startIdx2: number,
startIdx3: number,
start: number,
end: number,
): Array<T | Y | Z> {
// we don't know beforehand how many items will be there
const result = [];
let i = startIdx1;
let j = startIdx2;
let k = startIdx3;
while (i < arr1.length || j < arr2.length || k < arr3.length) {
const val1 = i < arr1.length ? arr1[i].time : Infinity;
const val2 = j < arr2.length ? arr2[j].time : Infinity;
const val3 = k < arr3.length ? arr3[k].time : Infinity;
// Early termination: if all remaining values exceed end time
if (Math.min(val1, val2, val3) > end) {
break;
}
if (val1 <= val2 && val1 <= val3) {
if (val1 <= end) {
result.push(arr1[i]);
}
i++;
} else if (val2 <= val1 && val2 <= val3) {
if (val2 <= end) {
result.push(arr2[j]);
}
j++;
} else {
if (val3 <= end) {
result.push(arr3[k]);
}
k++;
}
}
return result;
}
export function processInChunks(
items: any[],
processFn: (item: any) => any,
chunkSize = 1000,
overscan = 0,
) {
return new Promise((resolve) => {
if (items.length === 0) {
resolve([]);
return;
}
let result: any[] = [];
let index = 0;
const processNextChunk = () => {
const chunk = items.slice(index, index + chunkSize + overscan);
result = result.concat(processFn(chunk));
index += chunkSize;
if (index < items.length) {
setTimeout(processNextChunk, 0);
} else {
resolve(result);
}
};
processNextChunk();
});
}

View file

@ -18,7 +18,7 @@ function DocCard(props: Props) {
} = props; } = props;
return ( return (
<div className={cn('p-5 bg-gray-lightest mb-4 rounded', className)}> <div className={cn('p-5 bg-gray-lightest mb-4 rounded-lg', className)}>
<div className="font-medium mb-2 flex items-center"> <div className="font-medium mb-2 flex items-center">
{props.icon && ( {props.icon && (
<div <div

View file

@ -1,8 +1,4 @@
import { Input } from 'antd';
import React from 'react'; import React from 'react';
import { useStore } from 'App/mstore';
import { observer } from 'mobx-react-lite';
import NoSessionsMessage from 'Shared/NoSessionsMessage/NoSessionsMessage'; import NoSessionsMessage from 'Shared/NoSessionsMessage/NoSessionsMessage';
import MainSearchBar from 'Shared/MainSearchBar/MainSearchBar'; import MainSearchBar from 'Shared/MainSearchBar/MainSearchBar';
import usePageTitle from '@/hooks/usePageTitle'; import usePageTitle from '@/hooks/usePageTitle';
@ -13,22 +9,8 @@ import SessionHeader from './components/SessionHeader';
import LatestSessionsMessage from './components/LatestSessionsMessage'; import LatestSessionsMessage from './components/LatestSessionsMessage';
function SessionsTabOverview() { function SessionsTabOverview() {
const [query, setQuery] = React.useState('');
const { aiFiltersStore, searchStore } = useStore();
const appliedFilter = searchStore.instance;
usePageTitle('Sessions - OpenReplay'); usePageTitle('Sessions - OpenReplay');
const handleKeyDown = (event: any) => {
if (event.key === 'Enter') {
fetchResults();
}
};
const fetchResults = () => {
void aiFiltersStore.omniSearch(query, appliedFilter.toData());
};
const testingKey =
localStorage.getItem('__mauricio_testing_access') === 'true';
return ( return (
<> <>
<NoSessionsMessage /> <NoSessionsMessage />
@ -36,15 +18,6 @@ function SessionsTabOverview() {
<MainSearchBar /> <MainSearchBar />
<div className="my-4" /> <div className="my-4" />
<div className="widget-wrapper"> <div className="widget-wrapper">
{testingKey ? (
<Input
value={query}
onKeyDown={handleKeyDown}
onChange={(e) => setQuery(e.target.value)}
className="mb-2"
placeholder="ask session ai"
/>
) : null}
<SessionHeader /> <SessionHeader />
<div className="border-b" /> <div className="border-b" />
<LatestSessionsMessage /> <LatestSessionsMessage />
@ -59,4 +32,4 @@ export default withPermissions(
'', '',
false, false,
false, false,
)(observer(SessionsTabOverview)); )(SessionsTabOverview);

View file

@ -20,73 +20,13 @@ const tagIcons = {
function SessionTags() { function SessionTags() {
const { t } = useTranslation(); const { t } = useTranslation();
const screens = useBreakpoint(); const screens = useBreakpoint();
const { projectsStore, sessionStore, searchStore } = useStore(); const { projectsStore, searchStore } = useStore();
const total = sessionStore.total;
const platform = projectsStore.active?.platform || ''; const platform = projectsStore.active?.platform || '';
const activeTab = searchStore.activeTags; const activeTab = searchStore.activeTags;
const [isMobile, setIsMobile] = useState(false);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement | null>(null);
const filteredOptions = issues_types React.useEffect(() => {
.filter( searchStore.toggleTag(types.ALL);
(tag) => }, [projectsStore.activeSiteId])
tag.type !== 'mouse_thrashing' &&
(platform === 'web'
? tag.type !== types.TAP_RAGE
: tag.type !== types.CLICK_RAGE),
)
.map((tag) => ({
value: tag.type,
icon: tagIcons[tag.type],
label: t(tag.name),
}));
// Find the currently active option
const activeOption =
filteredOptions.find((option) => option.value === activeTab[0]) ||
filteredOptions[0];
// Check if on mobile
useEffect(() => {
const checkIfMobile = () => {
setIsMobile(window.innerWidth < 768);
};
checkIfMobile();
window.addEventListener('resize', checkIfMobile);
return () => {
window.removeEventListener('resize', checkIfMobile);
};
}, []);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!(dropdownRef.current as HTMLElement).contains(event.target as Node)
) {
setIsDropdownOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
// Handler for dropdown item selection
const handleSelectOption = (value: string) => {
searchStore.toggleTag(value as any);
setIsDropdownOpen(false);
};
if (total === 0 && (activeTab.length === 0 || activeTab[0] === 'all')) {
return null;
}
return ( return (
<div className="flex items-center"> <div className="flex items-center">

View file

@ -0,0 +1,30 @@
import React from 'react'
import {
Languages, X, Info
} from 'lucide-react'
import { Button } from 'antd';
import { useHistory } from "react-router-dom";
import { client } from 'App/routes'
function LangBanner({ onClose }: { onClose: () => void }) {
const history = useHistory()
const onClick = () => {
history.push(client('account'))
}
return (
<div className={'px-4 py-2 bg-yellow flex items-center w-screen gap-2'}>
<Info size={16} />
<div>
OpenReplay now supports French, Russian, Chinese, and Spanish 🎉. Update your language in settings.
</div>
<div className={'ml-auto'} />
<Button icon={<Languages size={14} />} size={'small'} onClick={onClick}>
Change Language
</Button>
<Button icon={<X size={16} />} type={'text'} shape={'circle'} onClick={onClose} size={'small'} />
</div>
)
}
export default LangBanner

View file

@ -1,6 +1,7 @@
import { Layout, Space, Tooltip } from 'antd'; import { Layout, Space, Tooltip } from 'antd';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import LangBanner from './LangBanner';
import { INDEXES } from 'App/constants/zindex'; import { INDEXES } from 'App/constants/zindex';
import Logo from 'App/layout/Logo'; import Logo from 'App/layout/Logo';
@ -11,14 +12,27 @@ import { useTranslation } from 'react-i18next';
const { Header } = Layout; const { Header } = Layout;
const langBannerClosedKey = '__or__langBannerClosed';
const getLangBannerClosed = () => localStorage.getItem(langBannerClosedKey) === '1'
function TopHeader() { function TopHeader() {
const { userStore, notificationStore, projectsStore, settingsStore } = const { userStore, notificationStore, projectsStore, settingsStore } =
useStore(); useStore();
const { account } = userStore; const { account } = userStore;
const { siteId } = projectsStore; const { siteId } = projectsStore;
const { initialDataFetched } = userStore; const { initialDataFetched } = userStore;
const [langBannerClosed, setLangBannerClosed] = React.useState(getLangBannerClosed);
const { t } = useTranslation(); const { t } = useTranslation();
React.useEffect(() => {
const langBannerVal = localStorage.getItem(langBannerClosedKey);
if (langBannerVal === null) {
localStorage.setItem(langBannerClosedKey, '0')
}
if (langBannerVal === '0') {
localStorage.setItem(langBannerClosedKey, '1')
}
}, [])
useEffect(() => { useEffect(() => {
if (!account.id || initialDataFetched) return; if (!account.id || initialDataFetched) return;
Promise.all([ Promise.all([
@ -29,51 +43,58 @@ function TopHeader() {
}); });
}, [account]); }, [account]);
const closeLangBanner = () => {
setLangBannerClosed(true);
localStorage.setItem(langBannerClosedKey, '1');
}
return ( return (
<Header <>
style={{ {langBannerClosed ? null : <LangBanner onClose={closeLangBanner} />}
position: 'sticky', <Header
top: 0, style={{
zIndex: INDEXES.HEADER, position: 'sticky',
padding: '0 20px', top: 0,
display: 'flex', zIndex: INDEXES.HEADER,
alignItems: 'center', padding: '0 20px',
height: '60px', display: 'flex',
}} alignItems: 'center',
className="justify-between" height: '60px',
> }}
<Space> className="justify-between"
<div >
onClick={() => { <Space>
settingsStore.updateMenuCollapsed(!settingsStore.menuCollapsed); <div
}} onClick={() => {
style={{ paddingTop: '4px' }} settingsStore.updateMenuCollapsed(!settingsStore.menuCollapsed);
className="cursor-pointer xl:block hidden" }}
> style={{ paddingTop: '4px' }}
<Tooltip className="cursor-pointer xl:block hidden"
title={
settingsStore.menuCollapsed ? t('Show Menu') : t('Hide Menu')
}
mouseEnterDelay={1}
> >
<Icon <Tooltip
name={ title={
settingsStore.menuCollapsed settingsStore.menuCollapsed ? t('Show Menu') : t('Hide Menu')
? 'side_menu_closed'
: 'side_menu_open'
} }
size={20} mouseEnterDelay={1}
/> >
</Tooltip> <Icon
</div> name={
settingsStore.menuCollapsed
? 'side_menu_closed'
: 'side_menu_open'
}
size={20}
/>
</Tooltip>
</div>
<div className="flex items-center"> <div className="flex items-center">
<Logo siteId={siteId} /> <Logo siteId={siteId} />
</div> </div>
</Space> </Space>
<TopRight /> <TopRight />
</Header> </Header>
</>
); );
} }

View file

@ -11,18 +11,13 @@ import ProjectDropdown from 'Shared/ProjectDropdown';
import { useStore } from 'App/mstore'; import { useStore } from 'App/mstore';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
interface Props { function TopRight() {
account: any;
spotOnly?: boolean;
}
function TopRight(props: Props) {
const { userStore } = useStore(); const { userStore } = useStore();
const spotOnly = userStore.scopeState === 1; const spotOnly = userStore.scopeState === 1;
const { account } = userStore; const { account } = userStore;
return ( return (
<Space style={{ lineHeight: '0' }}> <Space style={{ lineHeight: '0' }}>
{props.spotOnly ? null : ( {spotOnly ? null : (
<> <>
<ProjectDropdown /> <ProjectDropdown />
<GettingStartedProgress /> <GettingStartedProgress />
@ -30,7 +25,6 @@ function TopRight(props: Props) {
<Notifications /> <Notifications />
{account.name ? <HealthStatus /> : null} {account.name ? <HealthStatus /> : null}
<LanguageSwitcher />
</> </>
)} )}

View file

@ -1498,5 +1498,8 @@
"More attribute": "More attribute", "More attribute": "More attribute",
"More attributes": "More attributes", "More attributes": "More attributes",
"Account settings updated successfully": "Account settings updated successfully", "Account settings updated successfully": "Account settings updated successfully",
"Include rage clicks": "Include rage clicks" "Include rage clicks": "Include rage clicks",
"Interface Language": "Interface Language",
"Select the language in which OpenReplay will appear.": "Select the language in which OpenReplay will appear.",
"Language": "Language"
} }

View file

@ -1498,5 +1498,8 @@
"More attribute": "Más atributos", "More attribute": "Más atributos",
"More attributes": "Más atributos", "More attributes": "Más atributos",
"Account settings updated successfully": "Configuración de la cuenta actualizada correctamente", "Account settings updated successfully": "Configuración de la cuenta actualizada correctamente",
"Include rage clicks": "Incluir clics de ira" "Include rage clicks": "Incluir clics de ira",
"Interface Language": "Idioma de la interfaz",
"Select the language in which OpenReplay will appear.": "Selecciona el idioma en el que aparecerá OpenReplay.",
"Language": "Idioma"
} }

View file

@ -1498,5 +1498,8 @@
"More attribute": "Plus d'attributs", "More attribute": "Plus d'attributs",
"More attributes": "Plus d'attributs", "More attributes": "Plus d'attributs",
"Account settings updated successfully": "Paramètres du compte mis à jour avec succès", "Account settings updated successfully": "Paramètres du compte mis à jour avec succès",
"Include rage clicks": "Inclure les clics de rage" "Include rage clicks": "Inclure les clics de rage",
"Interface Language": "Langue de l'interface",
"Select the language in which OpenReplay will appear.": "Sélectionnez la langue dans laquelle OpenReplay apparaîtra.",
"Language": "Langue"
} }

View file

@ -1498,5 +1498,8 @@
"More attribute": "Еще атрибут", "More attribute": "Еще атрибут",
"More attributes": "Еще атрибуты", "More attributes": "Еще атрибуты",
"Account settings updated successfully": "Настройки аккаунта успешно обновлены", "Account settings updated successfully": "Настройки аккаунта успешно обновлены",
"Include rage clicks": "Включить невыносимые клики" "Include rage clicks": "Включить невыносимые клики",
"Interface Language": "Язык интерфейса",
"Select the language in which OpenReplay will appear.": "Выберите язык, на котором будет отображаться OpenReplay.",
"Language": "Язык"
} }

View file

@ -1498,5 +1498,8 @@
"More attributes": "更多属性", "More attributes": "更多属性",
"More attribute": "更多属性", "More attribute": "更多属性",
"Account settings updated successfully": "帐户设置已成功更新", "Account settings updated successfully": "帐户设置已成功更新",
"Include rage clicks": "包括点击狂怒" "Include rage clicks": "包括点击狂怒",
"Interface Language": "界面语言",
"Select the language in which OpenReplay will appear.": "选择 OpenReplay 将显示的语言。",
"Language": "语言"
} }

View file

@ -240,18 +240,7 @@ class UserStore {
resolve(response); resolve(response);
}) })
.catch(async (e) => { .catch(async (e) => {
const err = await e.response?.json(); toast.error(e.message || this.t("Failed to save user's data."));
runInAction(() => {
this.saving = false;
});
const errStr = err.errors[0]
? err.errors[0].includes('already exists')
? this.t(
"This email is already linked to an account or team on OpenReplay and can't be used again.",
)
: err.errors[0]
: this.t('Error saving user');
toast.error(errStr);
reject(e); reject(e);
}) })
.finally(() => { .finally(() => {

View file

@ -29,6 +29,15 @@ export function debounce(callback, wait, context = this) {
}; };
} }
export function debounceCall(func, wait) {
let timeout;
return function (...args) {
const context = this;
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(context, args), wait);
};
}
export function randomInt(a, b) { export function randomInt(a, b) {
const min = (b ? a : 0) - 0.5; const min = (b ? a : 0) - 0.5;
const max = b || a || Number.MAX_SAFE_INTEGER; const max = b || a || Number.MAX_SAFE_INTEGER;

View file

@ -95,6 +95,8 @@ spec:
value: {{ .Values.global.jwtSecret }} value: {{ .Values.global.jwtSecret }}
- name: JWT_SPOT_SECRET - name: JWT_SPOT_SECRET
value: {{ .Values.global.jwtSpotSecret }} value: {{ .Values.global.jwtSpotSecret }}
- name: TOKEN_SECRET
value: {{ .Values.global.tokenSecret }}
ports: ports:
{{- range $key, $val := .Values.service.ports }} {{- range $key, $val := .Values.service.ports }}
- name: {{ $key }} - name: {{ $key }}

View file

@ -124,6 +124,7 @@ global:
assistJWTSecret: "{{ randAlphaNum 20}}" assistJWTSecret: "{{ randAlphaNum 20}}"
jwtSecret: "{{ randAlphaNum 20}}" jwtSecret: "{{ randAlphaNum 20}}"
jwtSpotSecret: "{{ randAlphaNum 20}}" jwtSpotSecret: "{{ randAlphaNum 20}}"
tokenSecret: "{{randAlphaNum 20}}"
# In case of multiple nodes in the kubernetes cluster, # In case of multiple nodes in the kubernetes cluster,
# we'll have to create an RWX PVC for shared components. # we'll have to create an RWX PVC for shared components.
# If it's a single node, we'll use hostVolume, which is the default for the community/oss edition. # If it's a single node, we'll use hostVolume, which is the default for the community/oss edition.

View file

@ -1,3 +1,7 @@
## 16.1.0
- new `privateMode` option to hide all possible data from tracking
## 16.0.3 ## 16.0.3
- better handling for local svg spritemaps - better handling for local svg spritemaps

View file

@ -8,6 +8,14 @@ const config = {
moduleNameMapper: { moduleNameMapper: {
'(.+)\\.js': '$1', '(.+)\\.js': '$1',
}, },
globals: {
'ts-jest': {
tsConfig: {
target: 'es2020',
lib: ['DOM', 'ES2022'],
},
},
},
} }
export default config export default config

View file

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

View file

@ -357,6 +357,9 @@ export default abstract class Observer {
if (name === 'href' || value.length > 1e5) { if (name === 'href' || value.length > 1e5) {
value = '' value = ''
} }
if (['alt', 'placeholder'].includes(name) && this.app.sanitizer.privateMode) {
value = value.replaceAll(/./g, '*')
}
this.app.attributeSender.sendSetAttribute(id, name, value) this.app.attributeSender.sendSetAttribute(id, name, value)
} }
@ -389,7 +392,7 @@ export default abstract class Observer {
{ {
acceptNode: (node) => { acceptNode: (node) => {
if (this.app.nodes.getID(node) !== undefined) { if (this.app.nodes.getID(node) !== undefined) {
this.app.debug.warn('! Node is already bound', node) this.app.debug.info('! Node is already bound', node)
} }
return isIgnored(node) || this.app.nodes.getID(node) !== undefined return isIgnored(node) || this.app.nodes.getID(node) !== undefined
? NodeFilter.FILTER_REJECT ? NodeFilter.FILTER_REJECT

View file

@ -1,6 +1,6 @@
import type App from './index.js' import type App from './index.js'
import { stars, hasOpenreplayAttribute } from '../utils.js' import { stars, hasOpenreplayAttribute } from '../utils.js'
import { isElementNode } from './guards.js' import { isElementNode, isTextNode } from './guards.js'
export enum SanitizeLevel { export enum SanitizeLevel {
Plain, Plain,
@ -32,31 +32,46 @@ export interface Options {
* *
* */ * */
domSanitizer?: (node: Element) => SanitizeLevel domSanitizer?: (node: Element) => SanitizeLevel
/**
* private by default mode that will mask all elements not marked by data-openreplay-unmask
* */
privateMode?: boolean
} }
export const stringWiper = (input: string) => export const stringWiper = (input: string) =>
input input
.trim() .trim()
.replace(/[^\f\n\r\t\v\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]/g, '█') .replace(/[^\f\n\r\t\v\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff\s]/g, '*')
export default class Sanitizer { export default class Sanitizer {
private readonly obscured: Set<number> = new Set() private readonly obscured: Set<number> = new Set()
private readonly hidden: Set<number> = new Set() private readonly hidden: Set<number> = new Set()
private readonly options: Options private readonly options: Options
public readonly privateMode: boolean
private readonly app: App private readonly app: App
constructor(params: { app: App; options?: Partial<Options> }) { constructor(params: { app: App; options?: Partial<Options> }) {
this.app = params.app this.app = params.app
this.options = Object.assign( const defaultOptions: Options = {
{ obscureTextEmails: true,
obscureTextEmails: true, obscureTextNumbers: false,
obscureTextNumbers: false, privateMode: false,
}, domSanitizer: undefined,
params.options, }
) this.privateMode = params.options?.privateMode ?? false
this.options = Object.assign(defaultOptions, params.options)
} }
handleNode(id: number, parentID: number, node: Node) { handleNode(id: number, parentID: number, node: Node) {
if (this.options.privateMode) {
if (isElementNode(node) && !hasOpenreplayAttribute(node, 'unmask')) {
return this.obscured.add(id)
}
if (isTextNode(node) && !hasOpenreplayAttribute(node.parentNode as Element, 'unmask')) {
return this.obscured.add(id)
}
}
if ( if (
this.obscured.has(parentID) || this.obscured.has(parentID) ||
(isElementNode(node) && (isElementNode(node) &&

View file

@ -108,9 +108,13 @@ export default function (app: App, opts: Partial<Options>): void {
return return
} }
const sendConsoleLog = app.safe((level: string, args: unknown[]): void => const sendConsoleLog = app.safe((level: string, args: unknown[]): void => {
app.send(ConsoleLog(level, printf(args))), let logMsg = printf(args)
) if (app.sanitizer.privateMode) {
logMsg = logMsg.replaceAll(/./g, '*')
}
app.send(ConsoleLog(level, logMsg))
})
let n = 0 let n = 0
const reset = (): void => { const reset = (): void => {

View file

@ -205,7 +205,10 @@ export default function (app: App, opts: Partial<Options>): void {
inputTime: number, inputTime: number,
) { ) {
const { value, mask } = getInputValue(id, node) const { value, mask } = getInputValue(id, node)
const label = getInputLabel(node) let label = getInputLabel(node)
if (app.sanitizer.privateMode) {
label = label.replaceAll(/./g, '*')
}
app.send(InputChange(id, value, mask !== 0, label, hesitationTime, inputTime)) app.send(InputChange(id, value, mask !== 0, label, hesitationTime, inputTime))
} }

View file

@ -230,11 +230,12 @@ export default function (app: App, options?: MouseHandlerOptions): void {
const normalizedY = roundNumber(clickY / contentHeight) const normalizedY = roundNumber(clickY / contentHeight)
sendMouseMove() sendMouseMove()
const label = getTargetLabel(target)
app.send( app.send(
MouseClick( MouseClick(
id, id,
mouseTarget === target ? Math.round(performance.now() - mouseTargetTime) : 0, mouseTarget === target ? Math.round(performance.now() - mouseTargetTime) : 0,
getTargetLabel(target), app.sanitizer.privateMode ? label.replaceAll(/./g, '*') : label,
isClickable(target) && !disableClickmaps ? getSelector(id, target, options) : '', isClickable(target) && !disableClickmaps ? getSelector(id, target, options) : '',
normalizedX, normalizedX,
normalizedY, normalizedY,

View file

@ -101,7 +101,7 @@ export default function (app: App, opts: Partial<Options> = {}) {
} }
function sanitize(reqResInfo: RequestResponseData) { function sanitize(reqResInfo: RequestResponseData) {
if (!options.capturePayload) { if (!options.capturePayload || app.sanitizer.privateMode) {
// @ts-ignore // @ts-ignore
delete reqResInfo.request.body delete reqResInfo.request.body
delete reqResInfo.response.body delete reqResInfo.response.body
@ -136,18 +136,19 @@ export default function (app: App, opts: Partial<Options> = {}) {
if (options.useProxy) { if (options.useProxy) {
return createNetworkProxy( return createNetworkProxy(
context, context,
options.ignoreHeaders, app.sanitizer.privateMode ? true : options.ignoreHeaders,
setSessionTokenHeader, setSessionTokenHeader,
sanitize, sanitize,
(message) => { (message) => {
if (options.failuresOnly && message.status < 400) { if (options.failuresOnly && message.status < 400) {
return return
} }
const url = app.sanitizer.privateMode ? '************' : message.url
app.send( app.send(
NetworkRequest( NetworkRequest(
message.requestType, message.requestType,
message.method, message.method,
message.url, url,
message.request, message.request,
message.response, message.response,
message.status, message.status,

View file

@ -147,7 +147,7 @@ export default function (app: App, opts: Partial<Options>): void {
entry.transferSize > entry.encodedBodySize ? entry.transferSize - entry.encodedBodySize : 0, entry.transferSize > entry.encodedBodySize ? entry.transferSize - entry.encodedBodySize : 0,
entry.encodedBodySize || 0, entry.encodedBodySize || 0,
entry.decodedBodySize || 0, entry.decodedBodySize || 0,
entry.name, app.sanitizer.privateMode ? entry.name.replaceAll(/./g, '*') : entry.name,
entry.initiatorType, entry.initiatorType,
entry.transferSize, entry.transferSize,
// @ts-ignore // @ts-ignore

View file

@ -1,6 +1,7 @@
import type App from '../app/index.js' import type App from '../app/index.js'
import { getTimeOrigin } from '../utils.js' import { getTimeOrigin } from '../utils.js'
import { SetPageLocation, SetViewportSize, SetPageVisibility } from '../app/messages.gen.js' import { SetPageLocation, SetViewportSize, SetPageVisibility } from '../app/messages.gen.js'
import { stringWiper } from '../app/sanitizer.js'
export default function (app: App): void { export default function (app: App): void {
let url: string | null, width: number, height: number let url: string | null, width: number, height: number
@ -11,7 +12,10 @@ export default function (app: App): void {
const { URL } = document const { URL } = document
if (URL !== url) { if (URL !== url) {
url = URL url = URL
app.send(SetPageLocation(url, referrer, navigationStart, document.title)) const safeTitle = app.sanitizer.privateMode ? stringWiper(document.title) : document.title
const safeUrl = app.sanitizer.privateMode ? stringWiper(url) : url
const safeReferrer = app.sanitizer.privateMode ? stringWiper(referrer) : referrer
app.send(SetPageLocation(safeUrl, safeReferrer, navigationStart, safeTitle))
navigationStart = 0 navigationStart = 0
referrer = url referrer = url
} }

View file

@ -23,6 +23,9 @@ describe('Console logging module', () => {
safe: jest.fn((callback) => callback), safe: jest.fn((callback) => callback),
send: jest.fn(), send: jest.fn(),
attachStartCallback: jest.fn(), attachStartCallback: jest.fn(),
sanitizer: {
privateMode: false,
},
ticker: { ticker: {
attach: jest.fn(), attach: jest.fn(),
}, },

View file

@ -2,8 +2,8 @@ import { describe, expect, jest, afterEach, beforeEach, test } from '@jest/globa
import Sanitizer, { SanitizeLevel, Options, stringWiper } from '../main/app/sanitizer.js' import Sanitizer, { SanitizeLevel, Options, stringWiper } from '../main/app/sanitizer.js'
describe('stringWiper', () => { describe('stringWiper', () => {
test('should replace all characters with ', () => { test('should replace all characters with *', () => {
expect(stringWiper('Sensitive Data')).toBe('██████████████') expect(stringWiper('Sensitive Data')).toBe('**************')
}) })
}) })
@ -126,7 +126,7 @@ describe('Sanitizer', () => {
element.mockId = 1 element.mockId = 1
element.innerText = 'Sensitive Data' element.innerText = 'Sensitive Data'
const sanitizedText = sanitizer.getInnerTextSecure(element) const sanitizedText = sanitizer.getInnerTextSecure(element)
expect(sanitizedText).toEqual('██████████████') expect(sanitizedText).toEqual('**************')
}) })
test('should return empty string if node element does not exist', () => { test('should return empty string if node element does not exist', () => {