Merge branch 'dev' into network-request-messahe

This commit is contained in:
Alex K 2022-12-13 17:12:15 +01:00 committed by GitHub
commit b3cbf74deb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
43 changed files with 508 additions and 1449 deletions

View file

@ -69,7 +69,7 @@ ENV TZ=UTC \
PARTITIONS_NUMBER=16 \
QUEUE_MESSAGE_SIZE_LIMIT=1048576 \
BEACON_SIZE_LIMIT=1000000 \
USE_FAILOVER=true \
USE_FAILOVER=false \
GROUP_STORAGE_FAILOVER=failover \
TOPIC_STORAGE_FAILOVER=storage-failover

View file

@ -44,7 +44,7 @@ func main() {
messages.NewMessageIterator(
func(msg messages.Message) {
sesEnd := msg.(*messages.SessionEnd)
if err := srv.UploadSessionFiles(sesEnd); err != nil {
if err := srv.Upload(sesEnd); err != nil {
log.Printf("can't find session: %d", msg.SessionID())
sessionFinder.Find(msg.SessionID(), sesEnd.Timestamp)
}
@ -54,7 +54,7 @@ func main() {
[]int{messages.MsgSessionEnd},
true,
),
true,
false,
cfg.MessageSizeLimit,
)
@ -69,10 +69,15 @@ func main() {
case sig := <-sigchan:
log.Printf("Caught signal %v: terminating\n", sig)
sessionFinder.Stop()
srv.Wait()
consumer.Close()
os.Exit(0)
case <-counterTick:
go counter.Print()
srv.Wait()
if err := consumer.Commit(); err != nil {
log.Printf("can't commit messages: %s", err)
}
case msg := <-consumer.Rebalanced():
log.Println(msg)
default:

View file

@ -2,20 +2,33 @@ package storage
import (
"bytes"
"context"
"fmt"
gzip "github.com/klauspost/pgzip"
"go.opentelemetry.io/otel/metric/instrument/syncfloat64"
"log"
config "openreplay/backend/internal/config/storage"
"openreplay/backend/pkg/flakeid"
"openreplay/backend/pkg/messages"
"openreplay/backend/pkg/monitoring"
"openreplay/backend/pkg/storage"
"os"
"strconv"
"time"
"sync"
)
type FileType string
const (
DOM FileType = "/dom.mob"
DEV FileType = "/devtools.mob"
)
type Task struct {
id string
doms *bytes.Buffer
dome *bytes.Buffer
dev *bytes.Buffer
}
type Storage struct {
cfg *config.Config
s3 *storage.S3
@ -27,6 +40,9 @@ type Storage struct {
readingDOMTime syncfloat64.Histogram
readingTime syncfloat64.Histogram
archivingTime syncfloat64.Histogram
tasks chan *Task
ready chan struct{}
}
func New(cfg *config.Config, s3 *storage.S3, metrics *monitoring.Metrics) (*Storage, error) {
@ -57,7 +73,7 @@ func New(cfg *config.Config, s3 *storage.S3, metrics *monitoring.Metrics) (*Stor
if err != nil {
log.Printf("can't create archiving_duration metric: %s", err)
}
return &Storage{
newStorage := &Storage{
cfg: cfg,
s3: s3,
startBytes: make([]byte, cfg.FileSplitSize),
@ -66,169 +82,153 @@ func New(cfg *config.Config, s3 *storage.S3, metrics *monitoring.Metrics) (*Stor
sessionDevtoolsSize: sessionDevtoolsSize,
readingTime: readingTime,
archivingTime: archivingTime,
}, nil
tasks: make(chan *Task, 1),
ready: make(chan struct{}),
}
go newStorage.worker()
return newStorage, nil
}
func (s *Storage) UploadSessionFiles(msg *messages.SessionEnd) error {
if err := s.uploadKey(msg.SessionID(), "/dom.mob", true, 5, msg.EncryptionKey); err != nil {
return err
}
if err := s.uploadKey(msg.SessionID(), "/devtools.mob", false, 4, msg.EncryptionKey); err != nil {
log.Printf("can't find devtools for session: %d, err: %s", msg.SessionID(), err)
}
return nil
func (s *Storage) Wait() {
<-s.ready
}
// TODO: make a bit cleaner.
// TODO: Of course, I'll do!
func (s *Storage) uploadKey(sessID uint64, suffix string, shouldSplit bool, retryCount int, encryptionKey string) error {
if retryCount <= 0 {
return nil
func (s *Storage) Upload(msg *messages.SessionEnd) (err error) {
// Generate file path
sessionID := strconv.FormatUint(msg.SessionID(), 10)
filePath := s.cfg.FSDir + "/" + sessionID
// Prepare sessions
newTask := &Task{
id: sessionID,
}
start := time.Now()
fileName := strconv.FormatUint(sessID, 10)
mobFileName := fileName
if suffix == "/devtools.mob" {
mobFileName += "devtools"
}
filePath := s.cfg.FSDir + "/" + mobFileName
wg := &sync.WaitGroup{}
wg.Add(2)
go func() {
if prepErr := s.prepareSession(filePath, DOM, newTask); prepErr != nil {
err = fmt.Errorf("prepare session err: %s", prepErr)
}
wg.Done()
}()
go func() {
if prepErr := s.prepareSession(filePath, DOM, newTask); prepErr != nil {
err = fmt.Errorf("prepare session err: %s", prepErr)
}
wg.Done()
}()
wg.Wait()
// Send new task to worker
s.tasks <- newTask
// Unload worker
<-s.ready
return err
}
func (s *Storage) openSession(filePath string) ([]byte, error) {
// Check file size before download into memory
info, err := os.Stat(filePath)
if err == nil {
if info.Size() > s.cfg.MaxFileSize {
log.Printf("big file, size: %d, session: %d", info.Size(), sessID)
return nil
}
if err == nil && info.Size() > s.cfg.MaxFileSize {
return nil, fmt.Errorf("big file, size: %d", info.Size())
}
file, err := os.Open(filePath)
// Read file into memory
return os.ReadFile(filePath)
}
func (s *Storage) prepareSession(path string, tp FileType, task *Task) error {
// Open mob file
if tp == DEV {
path += "devtools"
}
mob, err := s.openSession(path)
if err != nil {
return fmt.Errorf("File open error: %v; sessID: %s, part: %d, sessStart: %s\n",
err, fileName, sessID%16,
time.UnixMilli(int64(flakeid.ExtractTimestamp(sessID))),
)
return err
}
defer file.Close()
var fileSize int64 = 0
fileInfo, err := file.Stat()
if err != nil {
log.Printf("can't get file info: %s", err)
if tp == DEV {
task.dev = s.compressSession(mob)
} else {
fileSize = fileInfo.Size()
}
var encryptedData []byte
fileName += suffix
if shouldSplit {
nRead, err := file.Read(s.startBytes)
if err != nil {
log.Printf("File read error: %s; sessID: %s, part: %d, sessStart: %s",
err,
fileName,
sessID%16,
time.UnixMilli(int64(flakeid.ExtractTimestamp(sessID))),
)
time.AfterFunc(s.cfg.RetryTimeout, func() {
s.uploadKey(sessID, suffix, shouldSplit, retryCount-1, encryptionKey)
})
if len(mob) <= s.cfg.FileSplitSize {
task.doms = s.compressSession(mob)
return nil
}
s.readingTime.Record(context.Background(), float64(time.Now().Sub(start).Milliseconds()))
start = time.Now()
// Encrypt session file if we have encryption key
if encryptionKey != "" {
encryptedData, err = EncryptData(s.startBytes[:nRead], []byte(encryptionKey))
if err != nil {
log.Printf("can't encrypt data: %s", err)
encryptedData = s.startBytes[:nRead]
}
} else {
encryptedData = s.startBytes[:nRead]
}
// Compress and save to s3
startReader := bytes.NewBuffer(encryptedData)
if err := s.s3.Upload(s.gzipFile(startReader), fileName+"s", "application/octet-stream", true); err != nil {
log.Fatalf("Storage: start upload failed. %v\n", err)
}
// TODO: fix possible error (if we read less then FileSplitSize)
if nRead == s.cfg.FileSplitSize {
restPartSize := fileSize - int64(nRead)
fileData := make([]byte, restPartSize)
nRead, err = file.Read(fileData)
if err != nil {
log.Printf("File read error: %s; sessID: %s, part: %d, sessStart: %s",
err,
fileName,
sessID%16,
time.UnixMilli(int64(flakeid.ExtractTimestamp(sessID))),
)
return nil
}
if int64(nRead) != restPartSize {
log.Printf("can't read the rest part of file")
}
// Encrypt session file if we have encryption key
if encryptionKey != "" {
encryptedData, err = EncryptData(fileData, []byte(encryptionKey))
if err != nil {
log.Printf("can't encrypt data: %s", err)
encryptedData = fileData
}
} else {
encryptedData = fileData
}
// Compress and save to s3
endReader := bytes.NewBuffer(encryptedData)
if err := s.s3.Upload(s.gzipFile(endReader), fileName+"e", "application/octet-stream", true); err != nil {
log.Fatalf("Storage: end upload failed. %v\n", err)
}
}
s.archivingTime.Record(context.Background(), float64(time.Now().Sub(start).Milliseconds()))
} else {
start = time.Now()
fileData := make([]byte, fileSize)
nRead, err := file.Read(fileData)
if err != nil {
log.Printf("File read error: %s; sessID: %s, part: %d, sessStart: %s",
err,
fileName,
sessID%16,
time.UnixMilli(int64(flakeid.ExtractTimestamp(sessID))),
)
return nil
}
if int64(nRead) != fileSize {
log.Printf("can't read the rest part of file")
}
// Encrypt session file if we have encryption key
if encryptionKey != "" {
encryptedData, err = EncryptData(fileData, []byte(encryptionKey))
if err != nil {
log.Printf("can't encrypt data: %s", err)
encryptedData = fileData
}
} else {
encryptedData = fileData
}
endReader := bytes.NewBuffer(encryptedData)
if err := s.s3.Upload(s.gzipFile(endReader), fileName, "application/octet-stream", true); err != nil {
log.Fatalf("Storage: end upload failed. %v\n", err)
}
s.archivingTime.Record(context.Background(), float64(time.Now().Sub(start).Milliseconds()))
wg := &sync.WaitGroup{}
wg.Add(2)
go func() {
task.doms = s.compressSession(mob[:s.cfg.FileSplitSize])
wg.Done()
}()
go func() {
task.dome = s.compressSession(mob[s.cfg.FileSplitSize:])
wg.Done()
}()
wg.Wait()
}
// Save metrics
ctx, _ := context.WithTimeout(context.Background(), time.Millisecond*200)
if shouldSplit {
s.totalSessions.Add(ctx, 1)
s.sessionDOMSize.Record(ctx, float64(fileSize))
} else {
s.sessionDevtoolsSize.Record(ctx, float64(fileSize))
}
return nil
}
func (s *Storage) encryptSession(data []byte, encryptionKey string) []byte {
var encryptedData []byte
var err error
if encryptionKey != "" {
encryptedData, err = EncryptData(data, []byte(encryptionKey))
if err != nil {
log.Printf("can't encrypt data: %s", err)
encryptedData = data
}
} else {
encryptedData = data
}
return encryptedData
}
func (s *Storage) compressSession(data []byte) *bytes.Buffer {
zippedMob := new(bytes.Buffer)
z, _ := gzip.NewWriterLevel(zippedMob, gzip.BestSpeed)
if _, err := z.Write(data); err != nil {
log.Printf("can't write session data to compressor: %s", err)
}
if err := z.Close(); err != nil {
log.Printf("can't close compressor: %s", err)
}
return zippedMob
}
func (s *Storage) uploadSession(task *Task) {
wg := &sync.WaitGroup{}
wg.Add(3)
go func() {
if task.doms != nil {
if err := s.s3.Upload(task.doms, task.id+string(DOM)+"s", "application/octet-stream", true); err != nil {
log.Fatalf("Storage: start upload failed. %s", err)
}
}
wg.Done()
}()
go func() {
if task.dome != nil {
if err := s.s3.Upload(task.dome, task.id+string(DOM)+"e", "application/octet-stream", true); err != nil {
log.Fatalf("Storage: start upload failed. %s", err)
}
}
wg.Done()
}()
go func() {
if task.dev != nil {
if err := s.s3.Upload(task.dev, task.id+string(DEV), "application/octet-stream", true); err != nil {
log.Fatalf("Storage: start upload failed. %s", err)
}
}
wg.Done()
}()
wg.Wait()
}
func (s *Storage) worker() {
for {
select {
case task := <-s.tasks:
s.uploadSession(task)
default:
// Signal that worker finished all tasks
s.ready <- struct{}{}
}
}
}

View file

@ -91,7 +91,7 @@ func (s *sessionFinderImpl) worker() {
func (s *sessionFinderImpl) findSession(sessionID, timestamp, partition uint64) {
sessEnd := &messages.SessionEnd{Timestamp: timestamp}
sessEnd.SetSessionID(sessionID)
err := s.storage.UploadSessionFiles(sessEnd)
err := s.storage.Upload(sessEnd)
if err == nil {
log.Printf("found session: %d in partition: %d, original: %d",
sessionID, partition, sessionID%numberOfPartitions)

View file

@ -13,7 +13,7 @@ function SelectorsList() {
return (
<NoContent title="No data available." size="small" show={targets && targets.length === 0}>
<div className={stl.wrapper}>
{targets && targets.map((target, index) => <SelectorCard target={target} index={index} showContent={activeTargetIndex === index} />)}
{targets && targets.map((target, index) => <React.Fragment key={index}><SelectorCard target={target} index={index} showContent={activeTargetIndex === index} /></React.Fragment>)}
</div>
</NoContent>
);

View file

@ -5,14 +5,13 @@ import TimeTracker from './TimeTracker';
import stl from './timeline.module.css';
import { setTimelinePointer, setTimelineHoverTime } from 'Duck/sessions';
import DraggableCircle from './components/DraggableCircle';
import CustomDragLayer from './components/CustomDragLayer';
import CustomDragLayer, { OnDragCallback } from './components/CustomDragLayer';
import { debounce } from 'App/utils';
import TooltipContainer from './components/TooltipContainer';
import { PlayerContext } from 'App/components/Session/playerContext';
import { observer } from 'mobx-react-lite';
import { useStore } from 'App/mstore';
const BOUNDRY = 0;
import { DateTime, Duration } from 'luxon';
function getTimelinePosition(value: number, scale: number) {
const pos = value * scale;
@ -23,7 +22,7 @@ function getTimelinePosition(value: number, scale: number) {
function Timeline(props) {
const { player, store } = useContext(PlayerContext)
const [wasPlaying, setWasPlaying] = useState(false)
const { notesStore } = useStore();
const { notesStore, settingsStore } = useStore();
const {
playing,
time,
@ -38,8 +37,8 @@ function Timeline(props) {
} = store.get()
const notes = notesStore.sessionNotes
const progressRef = useRef()
const timelineRef = useRef()
const progressRef = useRef<HTMLDivElement>()
const timelineRef = useRef<HTMLDivElement>()
const scale = 100 / endTime;
@ -64,10 +63,10 @@ function Timeline(props) {
}
};
const onDrag = (offset) => {
const onDrag: OnDragCallback = (offset) => {
if (live && !liveTimeTravel) return;
const p = (offset.x - BOUNDRY) / progressRef.current.offsetWidth;
const p = (offset.x) / progressRef.current.offsetWidth;
const time = Math.max(Math.round(p * endTime), 0);
debouncedJump(time);
hideTimeTooltip();
@ -90,20 +89,22 @@ function Timeline(props) {
return props.tooltipVisible && hideTimeTooltip();
}
let timeLineTooltip;
if (live) {
const [time, duration] = getLiveTime(e);
timeLineTooltip = {
time: duration - time,
time: Duration.fromMillis(duration - time).toFormat(`-mm:ss`),
offset: e.nativeEvent.offsetX,
isVisible: true,
};
} else {
const time = getTime(e);
const tz = settingsStore.sessionSettings.timezone.value
const timeStr = DateTime.fromMillis(props.startedAt + time).setZone(tz).toFormat(`hh:mm:ss a`)
timeLineTooltip = {
time: time,
time: Duration.fromMillis(time).toFormat(`mm:ss`),
timeStr,
offset: e.nativeEvent.offsetX,
isVisible: true,
};
@ -154,7 +155,6 @@ function Timeline(props) {
style={{
top: '-4px',
zIndex: 100,
padding: `0 ${BOUNDRY}px`,
maxWidth: 'calc(100% - 1rem)',
left: '0.5rem',
}}
@ -177,8 +177,8 @@ function Timeline(props) {
/>
<CustomDragLayer
onDrag={onDrag}
minX={BOUNDRY}
maxX={progressRef.current && progressRef.current.offsetWidth + BOUNDRY}
minX={0}
maxX={progressRef.current ? progressRef.current.offsetWidth : 0}
/>
<TimeTracker scale={scale} live={live} left={time * scale} />

View file

@ -1,98 +1,85 @@
import React, { memo } from 'react';
import { useDragLayer } from "react-dnd";
import Circle from './Circle'
import React, { memo, useEffect } from 'react';
import type { CSSProperties, FC } from 'react'
import { useDragLayer, XYCoord } from "react-dnd";
import Circle from './Circle'
const layerStyles: CSSProperties = {
position: "fixed",
pointerEvents: "none",
zIndex: 100,
left: 0,
top: 0,
width: "100%",
height: "100%"
};
const ItemTypes = {
BOX: 'box',
position: "fixed",
pointerEvents: "none",
zIndex: 100,
left: 0,
top: 0,
width: "100%",
height: "100%"
}
function getItemStyles(initialOffset, currentOffset, maxX, minX) {
if (!initialOffset || !currentOffset) {
return {
display: "none"
};
}
let { x, y } = currentOffset;
// if (isSnapToGrid) {
// x -= initialOffset.x;
// y -= initialOffset.y;
// [x, y] = [x, y];
// x += initialOffset.x;
// y += initialOffset.y;
// }
if (x > maxX) {
x = maxX;
}
if (x < minX) {
x = minX;
}
const transform = `translate(${x}px, ${initialOffset.y}px)`;
function getItemStyles(
initialOffset: XYCoord | null,
currentOffset: XYCoord | null,
maxX: number,
minX: number,
) {
if (!initialOffset || !currentOffset) {
return {
transition: 'transform 0.1s ease-out',
transform,
WebkitTransform: transform
};
display: "none"
}
}
let { x, y } = currentOffset;
if (x > maxX) {
x = maxX;
}
if (x < minX) {
x = minX;
}
const transform = `translate(${x}px, ${initialOffset.y}px)`;
return {
transition: 'transform 0.1s ease-out',
transform,
WebkitTransform: transform
}
}
export type OnDragCallback = (offset: XYCoord) => void
interface Props {
onDrag: (offset: { x: number, y: number } | null) => void;
maxX: number;
minX: number;
onDrag: OnDragCallback
maxX: number
minX: number
}
const CustomDragLayer: FC<Props> = memo(function CustomDragLayer(props) {
const {
itemType,
isDragging,
item,
initialOffset,
currentOffset,
} = useDragLayer((monitor) => ({
item: monitor.getItem(),
itemType: monitor.getItemType(),
initialOffset: monitor.getInitialSourceClientOffset(),
currentOffset: monitor.getSourceClientOffset(),
isDragging: monitor.isDragging(),
}));
const CustomDragLayer: FC<Props> = memo(function CustomDragLayer({ maxX, minX, onDrag }) {
const {
isDragging,
initialOffset,
currentOffset, // might be null (why is it not captured by types?)
} = useDragLayer((monitor) => ({
initialOffset: monitor.getInitialSourceClientOffset(),
currentOffset: monitor.getSourceClientOffset(),
isDragging: monitor.isDragging(),
}))
function renderItem() {
switch (itemType) {
case ItemTypes.BOX:
return <Circle />;
default:
return null;
}
}
if (!isDragging) {
return null;
useEffect(() => {
if (!isDragging || !currentOffset) {
return
}
onDrag(currentOffset)
}, [isDragging, currentOffset])
if (isDragging) {
props.onDrag(currentOffset)
}
if (!isDragging || !currentOffset) {
return null;
}
return (
<div style={layerStyles}>
<div
style={getItemStyles(initialOffset, currentOffset, props.maxX, props.minX)}
>
{renderItem()}
</div>
return (
<div style={layerStyles}>
<div
style={getItemStyles(initialOffset, currentOffset, maxX, minX)}
>
<Circle />
</div>
);
</div>
)
})
export default CustomDragLayer;

View file

@ -8,31 +8,39 @@ interface Props {
time: number;
offset: number;
isVisible: boolean;
liveTimeTravel: boolean;
timeStr: string;
}
function TimeTooltip({
time,
offset,
isVisible,
liveTimeTravel,
timeStr,
}: Props) {
const duration = Duration.fromMillis(time).toFormat(`${liveTimeTravel ? '-' : ''}mm:ss`);
return (
<div
className={stl.timeTooltip}
style={{
top: -30,
left: offset - 20,
top: 0,
left: offset,
display: isVisible ? 'block' : 'none',
transform: 'translate(-50%, -110%)',
whiteSpace: 'nowrap',
textAlign: "center",
}}
>
{!time ? 'Loading' : duration}
{!time ? 'Loading' : time}
{timeStr ? (
<>
<br />
<span className="text-gray-light">({timeStr})</span>
</>
) : null}
</div>
);
}
export default connect((state) => {
const { time = 0, offset = 0, isVisible } = state.getIn(['sessions', 'timeLineTooltip']);
return { time, offset, isVisible };
const { time = 0, offset = 0, isVisible, timeStr } = state.getIn(['sessions', 'timeLineTooltip']);
return { time, offset, isVisible, timeStr };
})(TimeTooltip);

View file

@ -1,9 +1,7 @@
import React from 'react';
import Marker from './ElementsMarker/Marker';
import type { MarkedTarget } from 'Player';
export default function ElementsMarker({ targets, activeIndex }) {
return targets && targets.map(t => <Marker target={t} active={activeIndex === t.index}/>)
export default function ElementsMarker({ targets, activeIndex }: { targets: MarkedTarget[], activeIndex: number }) {
return targets && targets.map(t => <React.Fragment key={t.index}><Marker target={t} active={activeIndex === t.index}/></React.Fragment>)
}

View file

@ -31,7 +31,6 @@ function PlayerBlockHeader(props: any) {
const { assistMultiviewStore } = useStore();
const { width, height, showEvents } = store.get();
const toggleEvents = player.toggleEvents;
const {
session,
@ -147,10 +146,10 @@ function PlayerBlockHeader(props: any) {
onClick={(tab) => {
if (activeTab === tab) {
setActiveTab('');
toggleEvents();
player.toggleEvents();
} else {
setActiveTab(tab);
!showEvents && toggleEvents();
!showEvents && player.toggleEvents();
}
}}
border={false}

View file

@ -11,6 +11,7 @@ import { useModal } from 'App/components/Modal';
import BugReportModal from './BugReport/BugReportModal';
import { PlayerContext } from 'App/components/Session/playerContext';
import { observer } from 'mobx-react-lite';
import { useStore } from 'App/mstore';
import AutoplayToggle from 'Shared/AutoplayToggle';
function SubHeader(props) {
@ -55,6 +56,7 @@ function SubHeader(props) {
return (
<div className="w-full px-4 py-2 flex items-center border-b">
{location && (
<div
className="flex items-center cursor-pointer color-gray-medium text-sm p-1 hover:bg-gray-light-shade rounded-md"

View file

@ -10,7 +10,7 @@ import { useModal } from 'App/components/Modal';
import FetchDetailsModal from 'Shared/FetchDetailsModal';
import { PlayerContext } from 'App/components/Session/playerContext';
import { useStore } from 'App/mstore';
import { connect } from 'react-redux'
import TimeTable from '../TimeTable';
import BottomBlock from '../BottomBlock';
import InfoLine from '../BottomBlock/InfoLine';
@ -128,7 +128,7 @@ export function renderDuration(r: any) {
);
}
function NetworkPanel() {
function NetworkPanel({ startedAt }: { startedAt: number }) {
const { player, store } = React.useContext(PlayerContext)
const {
@ -231,7 +231,7 @@ function NetworkPanel() {
const showDetailsModal = (item: any) => {
setIsDetailsModalActive(true)
showModal(
<FetchDetailsModal resource={item} rows={filteredList} fetchPresented={fetchList.length > 0} />,
<FetchDetailsModal time={item.time + startedAt} resource={item} rows={filteredList} fetchPresented={fetchList.length > 0} />,
{
right: true,
onClose: () => {
@ -369,7 +369,7 @@ function NetworkPanel() {
hidden: activeTab === XHR,
},
{
label: 'Time',
label: 'Duration',
width: 80,
dataKey: 'duration',
render: renderDuration,
@ -383,4 +383,6 @@ function NetworkPanel() {
);
}
export default observer(NetworkPanel);
export default connect((state: any) => ({
startedAt: state.getIn(['sessions', 'current', 'startedAt']),
}))(observer(NetworkPanel));

View file

@ -5,9 +5,11 @@ import FetchPluginMessage from './components/FetchPluginMessage';
import { TYPES } from 'Types/session/resource';
import FetchTabs from './components/FetchTabs/FetchTabs';
import { useStore } from 'App/mstore';
import { DateTime } from 'luxon';
interface Props {
resource: any;
time?: number;
rows?: any;
fetchPresented?: boolean;
}
@ -19,6 +21,7 @@ function FetchDetailsModal(props: Props) {
const isXHR = resource.type === TYPES.XHR
const {
sessionStore: { devTools },
settingsStore: { sessionSettings: { timezone }},
} = useStore();
useEffect(() => {
@ -47,7 +50,7 @@ function FetchDetailsModal(props: Props) {
return (
<div className="bg-white p-5 h-screen overflow-y-auto" style={{ width: '500px' }}>
<h5 className="mb-2 text-2xl">Network Request</h5>
<FetchBasicDetails resource={resource} />
<FetchBasicDetails resource={resource} timestamp={props.time ? DateTime.fromMillis(props.time).setZone(timezone.value).toFormat(`hh:mm:ss a`) : undefined} />
{isXHR && !fetchPresented && <FetchPluginMessage />}
{isXHR && <FetchTabs resource={resource} />}

View file

@ -5,8 +5,9 @@ import cn from 'classnames';
interface Props {
resource: any;
timestamp?: string;
}
function FetchBasicDetails({ resource }: Props) {
function FetchBasicDetails({ resource, timestamp }: Props) {
const _duration = parseInt(resource.duration);
const text = useMemo(() => {
if (resource.url.length > 50) {
@ -69,12 +70,22 @@ function FetchBasicDetails({ resource }: Props) {
{!!_duration && (
<div className="flex items-center py-1">
<div className="font-medium">Time</div>
<div className="font-medium">Duration</div>
<div className="rounded bg-active-blue px-2 py-1 ml-2 whitespace-nowrap overflow-hidden text-clip">
{_duration} ms
</div>
</div>
)}
{timestamp && (
<div className="flex items-center py-1">
<div className="font-medium">Time</div>
<div className="rounded bg-active-blue px-2 py-1 ml-2 whitespace-nowrap overflow-hidden text-clip">
{timestamp}
</div>
</div>
)}
</div>
);
}

View file

@ -7,12 +7,8 @@ import issues from './issues';
import assignments from './assignments';
import target from './target';
import targetCustom from './targetCustom';
import runs from './runs';
import filters from './filters';
import funnelFilters from './funnelFilters';
import tests from './tests';
import steps from './steps';
import schedules from './schedules';
import events from './events';
import environments from './environments';
import variables from './variables';
@ -46,12 +42,9 @@ export default combineReducers({
assignments,
target,
targetCustom,
runs,
filters,
funnelFilters,
tests,
steps,
schedules,
events,
environments,
variables,

View file

@ -1,7 +0,0 @@
import Run from 'Types/run';
import crudDuckGenerator from './tools/crudDuck';
const crudDuck = crudDuckGenerator('run', Run);
export const { fetchList, fetch, init, edit, save, remove } = crudDuck.actions;
export default crudDuck.reducer;

View file

@ -1,14 +0,0 @@
import Schedule from 'Types/schedule';
import crudDuckGenerator from './tools/crudDuck';
const crudDuck = crudDuckGenerator('scheduler', Schedule);
export const { fetchList, fetch, init, edit, remove } = crudDuck.actions;
export function save(instance) { // TODO: fix the crudDuckGenerator
return {
types: crudDuck.actionTypes.SAVE.toArray(),
call: client => client.post(`/schedulers${!!instance.schedulerId ? '/' + instance.schedulerId : '' }`, instance),
};
}
export default crudDuck.reducer;

View file

@ -70,7 +70,7 @@ const initialState = Map({
timelinePointer: null,
sessionPath: {},
lastPlayedSessionId: null,
timeLineTooltip: { time: 0, offset: 0, isVisible: false },
timeLineTooltip: { time: 0, offset: 0, isVisible: false, timeStr: '' },
createNoteTooltip: { time: 0, isVisible: false, isEdit: false, note: null },
});
@ -454,4 +454,4 @@ export function updateLastPlayedSession(sessionId) {
type: LAST_PLAYED_SESSION_ID,
sessionId,
};
}
}

View file

@ -1,77 +0,0 @@
import { List, Map } from 'immutable';
import { RequestTypes } from 'Duck/requestStateCreator';
import Step from 'Types/step';
import Event from 'Types/filter/event';
import { getRE } from 'App/utils';
import Test from 'Types/appTest';
import { countries } from 'App/constants';
import { KEYS } from 'Types/filter/customFilter';
const countryOptions = Object.keys(countries).map(c => ({filterKey: KEYS.USER_COUNTRY, label: KEYS.USER_COUNTRY, type: KEYS.USER_COUNTRY, value: c, actualValue: countries[c], isFilter: true }));
const INIT = 'steps/INIT';
const EDIT = 'steps/EDIT';
const SET_TEST = 'steps/SET_TEST';
const FETCH_LIST = new RequestTypes('steps/FETCH_LIST');
const initialState = Map({
list: List(),
test: Test(),
instance: Step(),
editingIndex: null,
});
const reducer = (state = initialState, action = {}) => {
switch (action.type) {
case FETCH_LIST.SUCCESS: {
return state.set('list', List(action.data).map(i => {
const type = i.type === 'navigate' ? i.type : 'location';
return {...i, type: type.toUpperCase()}
}))
}
case INIT:
return state
.set('instance', Step(action.instance))
.set('editingIndex', action.index)
.set('test', Test());
case EDIT:
return state.mergeIn([ 'instance' ], action.instance);
case SET_TEST:
return state.set('test', Test(action.test));
}
return state;
};
export default reducer;
export function init(instance, index) {
return {
type: INIT,
instance,
index,
};
}
export function edit(instance) {
return {
type: EDIT,
instance,
};
}
export function setTest(test) {
return {
type: SET_TEST,
test,
};
}
export function fetchList(params) {
return {
types: FETCH_LIST.toArray(),
call: client => client.get('/tests/steps/search', params),
params,
};
}

File diff suppressed because one or more lines are too long

View file

@ -1,118 +0,0 @@
import { Map } from 'immutable';
import Test from 'Types/appTest';
import Run, { RUNNING, STOPPED } from 'Types/run';
import requestDuckGenerator, { RequestTypes } from 'Duck/tools/requestDuck';
import { reduceDucks } from 'Duck/tools';
const GEN_TEST = new RequestTypes('tests/GEN_TEST');
const RUN_TEST = new RequestTypes('tests/RUN_TEST');
const STOP_RUN = new RequestTypes('tests/STOP_RUN');
const STOP_ALL_RUNS = new RequestTypes('tests/STOP_ALL_RUNS');
const CHECK_RUN = new RequestTypes('tests/CHECK_RUN');
const RESET_ERRORS = 'tests/RESET_ERRORS';
const updateRunInTest = run => (test) => {
const runIndex = test.runHistory
.findLastIndex(({ runId }) => run.runId === runId);
return runIndex === -1
? test.update('runHistory', list => list.push(run))
: test.mergeIn([ 'runHistory', runIndex ], run);
};
const updateRun = (state, testId, run) => {
const testIndex = state.get('list').findIndex(test => test.testId === testId);
if (testIndex === -1) return state;
const updater = updateRunInTest(run);
return state
.updateIn([ 'list', testIndex ], updater)
.updateIn([ 'instance' ], test => (test.testId === testId
? updater(test)
: test));
};
const initialState = Map({});
const reducer = (state = initialState, action = {}) => {
switch (action.type) {
case GEN_TEST.SUCCESS:
return state.set('instance', Test(action.data).set('generated', true));
case RUN_TEST.SUCCESS: {
const test = state.get('list').find(({ testId }) => testId === action.testId);
const run = Run({
runId: action.data.id, state: RUNNING, testId: action.testId, name: test.name
});
return updateRun(state, action.testId, run);
}
case STOP_RUN.SUCCESS: {
const { testId, runId } = action;
return updateRun(state, testId, { runId, state: STOPPED });
}
case STOP_ALL_RUNS.SUCCESS:
return state.update('list', list => list.map(test => {
test.runHistory.map(run => run.state === RUNNING ? run.set('state', STOPPED) : run.state);
return test;
})).setIn(['runRequest', 'errors'], null);
case CHECK_RUN.SUCCESS:
return updateRun(state, action.testId, Run(action.data));
case RESET_ERRORS:
return state.setIn(['runRequest', 'errors'], null);
}
return state;
};
const requestDuck = requestDuckGenerator({
runRequest: RUN_TEST,
stopRunRequest: STOP_RUN,
stopAllRunsRequest: STOP_ALL_RUNS,
genTestRequest: GEN_TEST,
});
export default reduceDucks({ reducer, initialState }, requestDuck);
export function generateTest(sessionId, params) {
return {
types: GEN_TEST.toArray(),
call: client => client.post(`/sessions/${ sessionId }/gentest`, params),
};
}
export function runTest(testId, params) {
return {
testId,
types: RUN_TEST.toArray(),
call: client => client.post(`/tests/${ testId }/execute`, params),
};
}
export function stopRun(testId, runId) {
return {
runId,
testId,
types: STOP_RUN.toArray(),
call: client => client.get(`/runs/${ runId }/stop`),
};
}
export function stopAllRuns() {
return {
types: STOP_ALL_RUNS.toArray(),
call: client => client.get(`/runs/all/stop`),
};
}
export function resetErrors() {
return {
type: RESET_ERRORS,
}
}
export function checkRun(testId, runId) {
return {
runId,
testId,
types: CHECK_RUN.toArray(),
call: client => client.get(`/runs/${ runId }`),
};
}

View file

@ -1,9 +1,9 @@
import { useState, useCallback } from 'react';
import { useState } from 'react';
export default function useToggle(defaultValue: boolean = false): [ boolean, () => void, () => void, () => void ] {
const [ value, setValue ] = useState(defaultValue);
const toggle = useCallback(() => setValue(d => !d), []);
const setFalse = useCallback(() => setValue(false), []);
const setTrue = useCallback(() => setValue(true), []);
const toggle = () => setValue(d => !d)
const setFalse = () => setValue(false)
const setTrue = () => setValue(true)
return [ value, toggle, setFalse, setTrue ];
}

View file

@ -44,7 +44,7 @@ export default class NotesStore {
this.loading = true
try {
const notes = await notesService.getNotesBySessionId(sessionId)
this.sessionNotes = notes
this.setNotes(notes)
return notes;
} catch (e) {
console.error(e)
@ -53,6 +53,10 @@ export default class NotesStore {
}
}
setNotes(notes: Note[]) {
this.sessionNotes = notes
}
async addNote(sessionId: string, note: WriteNote) {
this.loading = true
try {

View file

@ -13,27 +13,52 @@ const defaultDurationFilter = {
countType: 'sec'
}
const negativeExceptions = {
4: ['-04:30'],
3: ['-03:30'],
}
const exceptions = {
3: ['+03:30'],
4: ['+04:30'],
5: ['+05:30', '+05:45'],
6: ['+06:30'],
9: ['+09:30']
}
export const generateGMTZones = (): Timezone[] => {
const timezones: Timezone[] = [];
const positiveNumbers = [...Array(12).keys()];
const negativeNumbers = [...Array(12).keys()].reverse();
const positiveNumbers = [...Array(13).keys()];
const negativeNumbers = [...Array(13).keys()].reverse();
negativeNumbers.pop(); // remove trailing zero since we have one in positive numbers array
const combinedArray = [...negativeNumbers, ...positiveNumbers];
for (let i = 0; i < combinedArray.length; i++) {
let symbol = i < 11 ? '-' : '+';
let isUTC = i === 11;
let value = String(combinedArray[i]).padStart(2, '0');
let symbol = i < 12 ? '-' : '+';
let isUTC = i === 12;
const item = combinedArray[i]
let value = String(item).padStart(2, '0');
let tz = `UTC ${symbol}${String(combinedArray[i]).padStart(2, '0')}:00`;
let tz = `UTC ${symbol}${String(item).padStart(2, '0')}:00`;
let dropdownValue = `UTC${symbol}${value}`;
timezones.push({ label: tz, value: isUTC ? 'UTC' : dropdownValue });
// @ts-ignore
const negativeMatch = negativeExceptions[item], positiveMatch = exceptions[item]
if (i < 11 && negativeMatch) {
negativeMatch.forEach((str: string) => {
timezones.push({ label: `UTC ${str}`, value: `UTC${str}`})
})
} else if (i > 11 && positiveMatch) {
positiveMatch.forEach((str: string) => {
timezones.push({ label: `UTC ${str}`, value: `UTC${str}`})
})
}
}
timezones.splice(17, 0, { label: 'GMT +05:30', value: 'UTC+05:30' });
return timezones;
};

View file

@ -5,10 +5,14 @@ import styles from './cursor.module.css';
export default class Cursor {
private readonly cursor: HTMLDivElement;
private tagElement: HTMLDivElement;
constructor(overlay: HTMLDivElement) {
private isMobile: boolean;
constructor(overlay: HTMLDivElement, isMobile: boolean) {
this.cursor = document.createElement('div');
this.cursor.className = styles.cursor;
if (isMobile) this.cursor.style.backgroundImage = 'unset'
overlay.appendChild(this.cursor);
this.isMobile = isMobile;
}
toggle(flag: boolean) {
@ -51,9 +55,10 @@ export default class Cursor {
}
click() {
this.cursor.classList.add(styles.clicked)
const styleList = this.isMobile ? styles.clickedMobile : styles.clicked
this.cursor.classList.add(styleList)
setTimeout(() => {
this.cursor.classList.remove(styles.clicked)
this.cursor.classList.remove(styleList)
}, 600)
}

View file

@ -57,7 +57,7 @@ export default class Screen {
private readonly screen: HTMLDivElement;
private parentElement: HTMLElement | null = null;
constructor() {
constructor(isMobile: boolean) {
const iframe = document.createElement('iframe');
iframe.className = styles.iframe;
this.iframe = iframe;
@ -73,7 +73,7 @@ export default class Screen {
screen.appendChild(overlay);
this.screen = screen;
this.cursor = new Cursor(this.overlay) // TODO: move outside
this.cursor = new Cursor(this.overlay, isMobile) // TODO: move outside
}
attach(parentElement: HTMLElement) {

View file

@ -67,3 +67,44 @@
transform: scale3d(1.2, 1.2, 1);
}
}
.cursor.clickedMobile::after {
-webkit-animation: anim-effect-sanja 1s ease-out forwards;
animation: anim-effect-sanja 1s ease-out forwards;
}
@-webkit-keyframes anim-effect-sanja {
0% {
opacity: 1;
-webkit-transform: scale3d(0.5, 0.5, 1);
transform: scale3d(0.5, 0.5, 1);
}
25% {
opacity: 1;
-webkit-transform: scale3d(1, 1, 1);
transform: scale3d(1, 1, 1);
}
100% {
opacity: 0;
-webkit-transform: scale3d(1, 1, 1);
transform: scale3d(1, 1, 1);
}
}
@keyframes anim-effect-sanja {
0% {
opacity: 1;
-webkit-transform: scale3d(0.5, 0.5, 1);
transform: scale3d(0.5, 0.5, 1);
}
25% {
opacity: 1;
-webkit-transform: scale3d(1, 1, 1);
transform: scale3d(1, 1, 1);
}
100% {
opacity: 0;
-webkit-transform: scale3d(1, 1, 1);
transform: scale3d(1, 1, 1);
}
}

View file

@ -31,7 +31,6 @@ export default class WebPlayer extends Player {
private targetMarker: TargetMarker
constructor(private wpState: Store<typeof WebPlayer.INITIAL_STATE>, session, config: RTCIceServer[], live: boolean) {
let initialLists = live ? {} : {
event: session.events.toJSON(),
stack: session.stackEvents.toJSON(),
@ -46,7 +45,7 @@ export default class WebPlayer extends Player {
),
}
const screen = new Screen()
const screen = new Screen(session.isMobile)
const messageManager = new MessageManager(session, wpState, screen, initialLists)
super(wpState, messageManager)
this.screen = screen

View file

@ -1,21 +1,9 @@
import type {
RawMessage,
RawSetNodeAttributeURLBased,
RawSetNodeAttribute,
RawSetCssDataURLBased,
RawSetCssData,
RawCssInsertRuleURLBased,
RawCssInsertRule,
RawAdoptedSsInsertRuleURLBased,
RawAdoptedSsInsertRule,
RawAdoptedSsReplaceURLBased,
RawAdoptedSsReplace,
} from './raw.gen'
import type { RawMessage } from './raw.gen'
import type { TrackerMessage } from './tracker.gen'
import { MType } from './raw.gen'
import translate from './tracker.gen'
import { TP_MAP } from './tracker-legacy.gen'
import { resolveURL, resolveCSS } from './urlResolve'
import resolveURL from './urlBasedResolver'
function legacyTranslate(msg: any): RawMessage | null {
@ -29,54 +17,6 @@ function legacyTranslate(msg: any): RawMessage | null {
}
// TODO: commonURLBased logic for feilds
const resolvers = {
[MType.SetNodeAttributeURLBased]: (msg: RawSetNodeAttributeURLBased): RawSetNodeAttribute =>
({
...msg,
value: msg.name === 'src' || msg.name === 'href'
? resolveURL(msg.baseURL, msg.value)
: (msg.name === 'style'
? resolveCSS(msg.baseURL, msg.value)
: msg.value
),
tp: MType.SetNodeAttribute,
}),
[MType.SetCssDataURLBased]: (msg: RawSetCssDataURLBased): RawSetCssData =>
({
...msg,
data: resolveCSS(msg.baseURL, msg.data),
tp: MType.SetCssData,
}),
[MType.CssInsertRuleURLBased]: (msg: RawCssInsertRuleURLBased): RawCssInsertRule =>
({
...msg,
rule: resolveCSS(msg.baseURL, msg.rule),
tp: MType.CssInsertRule,
}),
[MType.AdoptedSsInsertRuleURLBased]: (msg: RawAdoptedSsInsertRuleURLBased): RawAdoptedSsInsertRule =>
({
...msg,
rule: resolveCSS(msg.baseURL, msg.rule),
tp: MType.AdoptedSsInsertRule,
}),
[MType.AdoptedSsReplaceURLBased]: (msg: RawAdoptedSsReplaceURLBased): RawAdoptedSsReplace =>
({
...msg,
text: resolveCSS(msg.baseURL, msg.text),
tp: MType.AdoptedSsReplace,
}),
} as const
type ResolvableType = keyof typeof resolvers
type ResolvableRawMessage = RawMessage & { tp: ResolvableType }
function isResolvable(msg: RawMessage): msg is ResolvableRawMessage {
//@ts-ignore
return resolvers[msg.tp] !== undefined
}
export default class JSONRawMessageReader {
constructor(private messages: TrackerMessage[] = []){}
append(messages: TrackerMessage[]) {
@ -91,11 +31,7 @@ export default class JSONRawMessageReader {
if (!rawMsg) {
return this.readMessage()
}
if (isResolvable(rawMsg)) {
//@ts-ignore ??? too complex typscript...
return resolvers[rawMsg.tp](rawMsg)
}
return rawMsg
return resolveURL(rawMsg)
}
}

View file

@ -3,6 +3,8 @@ import type { RawMessage } from './raw.gen';
import { MType } from './raw.gen';
import logger from 'App/logger';
import RawMessageReader from './RawMessageReader.gen';
import resolveURL from './urlBasedResolver'
// TODO: composition instead of inheritance
// needSkipMessage() and next() methods here use buf and p protected properties,
@ -77,7 +79,7 @@ export default class MFileReader extends RawMessageReader {
}
const index = this.getLastMessageID()
const msg = Object.assign(rMsg, {
const msg = Object.assign(resolveURL(rMsg), {
time: this.currentTime,
_index: index,
})

View file

@ -8,7 +8,7 @@ interface RawMessageReaderI {
}
export default class MStreamReader {
constructor(private readonly r: RawMessageReaderI = new RawMessageReader(), private startTs: number = 0){}
constructor(private readonly r: RawMessageReaderI, private startTs: number = 0){}
private t: number = 0
private idx: number = 0

View file

@ -0,0 +1,69 @@
import type {
RawMessage,
RawSetNodeAttributeURLBased,
RawSetNodeAttribute,
RawSetCssDataURLBased,
RawSetCssData,
RawCssInsertRuleURLBased,
RawCssInsertRule,
RawAdoptedSsInsertRuleURLBased,
RawAdoptedSsInsertRule,
RawAdoptedSsReplaceURLBased,
RawAdoptedSsReplace,
} from './raw.gen'
import { MType } from './raw.gen'
import { resolveURL, resolveCSS } from './urlResolve'
// type PickMessage<T extends MType> = Extract<RawMessage, { tp: T }>;
// type ResolversMap = {
// [Key in MType]: (event: PickMessage<Key>) => RawMessage
// }
// TODO: commonURLBased logic for feilds
const resolvers = {
[MType.SetNodeAttributeURLBased]: (msg: RawSetNodeAttributeURLBased): RawSetNodeAttribute =>
({
...msg,
value: msg.name === 'src' || msg.name === 'href'
? resolveURL(msg.baseURL, msg.value)
: (msg.name === 'style'
? resolveCSS(msg.baseURL, msg.value)
: msg.value
),
tp: MType.SetNodeAttribute,
}),
[MType.SetCssDataURLBased]: (msg: RawSetCssDataURLBased): RawSetCssData =>
({
...msg,
data: resolveCSS(msg.baseURL, msg.data),
tp: MType.SetCssData,
}),
[MType.CssInsertRuleURLBased]: (msg: RawCssInsertRuleURLBased): RawCssInsertRule =>
({
...msg,
rule: resolveCSS(msg.baseURL, msg.rule),
tp: MType.CssInsertRule,
}),
[MType.AdoptedSsInsertRuleURLBased]: (msg: RawAdoptedSsInsertRuleURLBased): RawAdoptedSsInsertRule =>
({
...msg,
rule: resolveCSS(msg.baseURL, msg.rule),
tp: MType.AdoptedSsInsertRule,
}),
[MType.AdoptedSsReplaceURLBased]: (msg: RawAdoptedSsReplaceURLBased): RawAdoptedSsReplace =>
({
...msg,
text: resolveCSS(msg.baseURL, msg.text),
tp: MType.AdoptedSsReplace,
}),
} as const
export default function resolve(msg: RawMessage): RawMessage {
// @ts-ignore --- any idea?
if (resolvers[msg.tp]) {
// @ts-ignore
return resolvers[msg.tp](msg)
}
return msg
}

View file

@ -1,4 +0,0 @@
import fromJS from './run';
export * from './run';
export default fromJS;

View file

@ -1,183 +0,0 @@
import { Record, List, Map } from 'immutable';
import { DateTime } from 'luxon';
import Environment from 'Types/environment';
import stepFromJS from './step';
import seleniumStepFromJS from './seleniumStep';
import Resource from '../session/resource';
export const NOT_FETCHED = undefined;
export const QUEUED = 'queued';
export const INITIALIZING = 'initializing';
export const RUNNING = 'running';
export const COMPLETED = 'completed';
export const PASSED = 'passed';
export const FAILED = 'failed';
export const STOPPED = 'stopped';
export const CRASHED = 'crashed';
export const EXPIRED = 'expired';
export const STATUS = {
NOT_FETCHED,
QUEUED,
INITIALIZING,
RUNNING,
COMPLETED,
PASSED,
FAILED,
STOPPED,
CRASHED,
EXPIRED,
}
class Run extends Record({
runId: undefined,
testId: undefined,
name: '',
tags: List(),
environment: Environment(),
scheduled: false,
schedulerId: undefined,
browser: undefined,
sessionId: undefined,
startedAt: undefined,
url_video: undefined,
finishedAt: undefined,
steps: List(),
resources: [],
seleniumSteps: List(),
url_browser_logs: undefined,
url_logs: undefined,
url_selenium_project: undefined,
sourceCode: undefined,
screenshotUrl: undefined,
clientId: undefined,
state: NOT_FETCHED,
baseRunId: undefined,
lastExecutedString: undefined,
durationString: undefined,
hour: undefined, // TODO: fine API
day: undefined,
location: undefined,
deviceType: undefined,
advancedOptions: undefined,
harfile: undefined,
lighthouseHtmlFile: undefined,
resultsFile: undefined,
lighthouseJsonFile: undefined,
totalStepsCount: undefined,
auditsPerformance: Map(),
auditsAd: Map(),
transferredSize: undefined,
resourcesSize: undefined,
domBuildingTime: undefined,
domContentLoadedTime: undefined,
loadTime: undefined,
starter: undefined,
// {
// "id": '',
// "title": '',
// "description": '',
// "score": 0,
// "scoreDisplayMode": '',
// "numericValue": 0,
// "numericUnit": '',
// "displayValue": ''
// }
}) {
idKey = 'runId';
isRunning() {
return this.state === RUNNING;
}
isQueued() {
return this.state === QUEUED;
}
isPassed() {
return this.state === PASSED;
}
}
// eslint-disable-next-line complexity
function fromJS(run = {}) {
if (run instanceof Run) return run;
const startedAt = run.startedAt && DateTime.fromMillis(run.startedAt);
const finishedAt = run.finishedAt && DateTime.fromMillis(run.finishedAt);
let durationString;
let lastExecutedString;
if (run.state === 'running') {
durationString = 'Running...';
lastExecutedString = 'Now';
} else if (startedAt && finishedAt) {
const _duration = Math.floor(finishedAt - startedAt);
if (_duration > 10000) {
const min = Math.floor(_duration / 60000);
durationString = `${ min < 1 ? 1 : min } min`;
} else {
durationString = `${ Math.floor(_duration / 1000) } secs`;
}
const diff = startedAt.diffNow([ 'days', 'hours', 'minutes', 'seconds' ]).negate();
if (diff.days > 0) {
lastExecutedString = `${ Math.round(diff.days) } day${ diff.days > 1 ? 's' : '' } ago`;
} else if (diff.hours > 0) {
lastExecutedString = `${ Math.round(diff.hours) } hrs ago`;
} else if (diff.minutes > 0) {
lastExecutedString = `${ Math.round(diff.minutes) } min ago`;
} else {
lastExecutedString = `${ Math.round(diff.seconds) } sec ago`;
}
}
const steps = List(run.steps).map(stepFromJS);
const seleniumSteps = List(run.seleniumSteps).map(seleniumStepFromJS);
const tags = List(run.tags);
const environment = Environment(run.environment);
let resources = List(run.network)
.map(i => Resource({
...i,
// success: 1,
// time: i.timestamp,
// type: 'xhr',
// headerSize: 1200,
// timings: {},
}));
const firstResourceTime = resources.map(r => r.time).reduce((a,b)=>Math.min(a,b), Number.MAX_SAFE_INTEGER);
resources = resources
.map(r => r.set("time", r.time - firstResourceTime))
.sort((r1, r2) => r1.time - r2.time).toArray()
const screenshotUrl = run.screenshot_url ||
seleniumSteps.find(({ screenshotUrl }) => !!screenshotUrl, null, {}).screenshotUrl;
const state = run.state === 'completed' ? PASSED : run.state;
const networkOverview = run.networkOverview || {};
return new Run({
...run,
startedAt,
finishedAt,
durationString,
lastExecutedString,
steps,
resources,
seleniumSteps,
tags,
environment,
screenshotUrl,
state,
deviceType: run.device || run.deviceType,
auditsPerformance: run.lighthouseJson && run.lighthouseJson.performance,
auditsAd: run.lighthouseJson && run.lighthouseJson.ad,
transferredSize: networkOverview.transferredSize,
resourcesSize: networkOverview.resourcesSize,
domBuildingTime: networkOverview.domBuildingTime,
domContentLoadedTime: networkOverview.domContentLoadedTime,
loadTime: networkOverview.loadTime,
});
}
Run.prototype.exists = function () {
return this.runId !== undefined;
};
export default fromJS;

View file

@ -1,29 +0,0 @@
import { Record, List } from 'immutable';
import { DateTime, Duration } from 'luxon';
const Step = Record({
duration: undefined,
startedAt: undefined,
label: undefined,
input: undefined,
info: undefined,
order: undefined,
screenshotUrl: undefined,
steps: List(),
});
function fromJS(step = {}) {
const startedAt = step.startedAt && DateTime.fromMillis(step.startedAt * 1000);
const duration = step.executionTime && Duration.fromMillis(step.executionTime);
const steps = List(step.steps).map(Step);
const screenshotUrl = step.screenshot_url;
return new Step({
...step,
steps,
startedAt,
duration,
screenshotUrl,
});
};
export default fromJS;

View file

@ -1,31 +0,0 @@
import { Record, List } from 'immutable';
import { DateTime, Duration } from 'luxon';
const Step = Record({
duration: undefined,
startedAt: undefined,
label: undefined,
input: undefined,
info: undefined,
order: undefined,
status: undefined,
title: undefined,
screenshotUrl: undefined,
steps: List(),
});
function fromJS(step = {}) {
const startedAt = step.startedAt && DateTime.fromMillis(step.startedAt);
const duration = step.executionTime && Duration.fromMillis(step.executionTime);
const steps = List(step.steps).map(Step);
const screenshotUrl = step.screenshot;
return new Step({
...step,
steps,
startedAt,
duration,
screenshotUrl,
});
};
export default fromJS;

View file

@ -1,228 +0,0 @@
import { Record, List, Map } from 'immutable';
import { DateTime } from 'luxon';
import {
CHANNEL,
DAYS,
HOURS,
EMAIL,
SLACK,
WEBHOOK
} from 'App/constants/schedule';
// import runFromJS from './run';
import { validateEmail } from 'App/validate';
export const DEFAULT_ENV_VALUE = '_';
const Schedule = Record({
minutes: 30,
hour: 0,
day: -2,
testId: '',
sourceCode: '',
name: '',
nextExecutionTime: undefined,
numberOFExecutions: undefined,
schedulerId: undefined,
environmentId: DEFAULT_ENV_VALUE,
device: 'desktop',
locations: [],
advancedOptions: false,
headers: [{}],
cookies: [{}],
basicAuth: {},
network: 'wifi',
bypassCSP: false,
slack: false,
slackInput: [],
webhook: false,
webhookInput: [],
email: false,
emailInput: [],
hasNotification: false,
options: Map({ message: [], device: 'desktop' }),
extraCaps: {},
validateEvery() {
if (this.day > -2) return true;
return this.minutes >= 5 && this.minutes <= 1440;
},
validateWebhookEmail() {
if (this.channel !== EMAIL) return true;
return validateEmail(this.webhookEmail);
},
validateWebhook() {
if (this.channel !== WEBHOOK) return true;
return this.webhookId !== '';
}
});
function fromJS(schedule = {}) {
if (schedule instanceof Schedule) return schedule;
const options = schedule.options || { message: [] };
const extraCaps = options.extraCaps || { };
let channel = '';
if (schedule.webhookEmail) {
channel = EMAIL;
} else if (schedule.webhookId && schedule.webhook) {
channel = schedule.webhook.type === 'slack' ? SLACK : WEBHOOK;
}
const nextExecutionTime = schedule.nextExecutionTime ?
DateTime.fromMillis(schedule.nextExecutionTime) : undefined;
let { day, minutes } = schedule;
let hour;
if (day !== -2) {
const utcOffset = new Date().getTimezoneOffset();
minutes = minutes - utcOffset
minutes = minutes >= 1440 ? (minutes - 1440) : minutes;
hour = Math.floor(minutes / 60);
}
// if (day !== -2) {
// const utcOffset = new Date().getTimezoneOffset();
// const hourOffset = Math.floor(utcOffset / 60);
// const minuteOffset = utcOffset - 60*hourOffset;
// minutes -= minuteOffset;
// hour -= hourOffset;
// if (day !== -1) {
// const dayOffset = Math.floor(hour/24); // +/-1
// day = (day + dayOffset + 7) % 7;
// }
// hour = (hour + 24) % 24;
// }
const slack = List(options.message).filter(i => i.type === 'slack');
const email = List(options.message).filter(i => i.type === 'email');
const webhook = List(options.message).filter(i => i.type === 'webhook');
const headers = extraCaps.headers ? Object.keys(extraCaps.headers).map(k => ({ name: k, value: extraCaps.headers[k] })) : [{}];
const cookies = extraCaps.cookies ? Object.keys(extraCaps.cookies).map(k => ({ name: k, value: extraCaps.cookies[k] })) : [{}];
return new Schedule({
...schedule,
day,
minutes,
hour,
channel,
nextExecutionTime,
device: options.device,
options,
advancedOptions: !!options.extraCaps,
bypassCSP: options.bypassCSP,
network: options.network,
headers,
cookies,
basicAuth: extraCaps.basicAuth,
slack: slack.size > 0,
slackInput: slack.map(i => parseInt(i.value)).toJS(),
email: email.size > 0,
emailInput: email.map(i => i.value).toJS(),
webhook: webhook.size > 0,
webhookInput: webhook.map(i => parseInt(i.value)).toJS(),
hasNotification: !!slack || !!email || !!webhook
});
}
function getObjetctFromArr(arr) {
const obj = {}
for (var i = 0; i < arr.length; i++) {
const temp = arr[i];
obj[temp.name] = temp.value
}
return obj;
}
Schedule.prototype.toData = function toData() {
const {
name, schedulerId, environmentId, device, options, bypassCSP, network, headers, cookies, basicAuth
} = this;
const js = this.toJS();
options.device = device;
options.bypassCSP = bypassCSP;
options.network = network;
options.extraCaps = {
headers: getObjetctFromArr(headers),
cookies: getObjetctFromArr(cookies),
basicAuth
};
if (js.slack && js.slackInput)
options.message = js.slackInput.map(i => ({ type: 'slack', value: i }))
if (js.email && js.emailInput)
options.message = options.message.concat(js.emailInput.map(i => ({ type: 'email', value: i })))
if (js.webhook && js.webhookInput)
options.message = options.message.concat(js.webhookInput.map(i => ({ type: 'webhook', value: i })))
let day = this.day;
let hour = undefined;
let minutes = this.minutes;
if (day !== -2) {
const utcOffset = new Date().getTimezoneOffset();
minutes = (this.hour * 60) + utcOffset;
// minutes += utcOffset;
minutes = minutes < 0 ? minutes + 1440 : minutes;
}
// if (day !== -2) {
// const utcOffset = new Date().getTimezoneOffset();
// const hourOffset = Math.floor(utcOffset / 60);
// const minuteOffset = utcOffset - 60*hourOffset;
// minutes = minuteOffset;
// hour = this.hour + hourOffset;
// if (day !== -1) {
// const dayOffset = Math.floor(hour/24); // +/-1
// day = (day + dayOffset + 7) % 7;
// }
// hour = (hour + 24) % 24;
// }
delete js.slack;
delete js.webhook;
delete js.email;
delete js.slackInput;
delete js.webhookInput;
delete js.emailInput;
delete js.hasNotification;
delete js.headers;
delete js.cookies;
delete js.device;
delete js.extraCaps;
// return {
// day, hour, name, minutes, schedulerId, environment,
// };
return { ...js, day, hour, name, minutes, schedulerId, environmentId, options: options };
};
Schedule.prototype.exists = function exists() {
return this.schedulerId !== undefined;
};
Schedule.prototype.valid = function validate() {
return this.validateEvery;
};
Schedule.prototype.getInterval = function getInterval() {
const DAY = List(DAYS).filter(item => item.value === this.day).first();
if (DAY.value === -2) {
return DAY.text + ' ' + this.minutes + ' Minutes'; // Every 30 minutes
}
const HOUR = List(HOURS).filter(item => item.value === this.hour).first();
return DAY.text + ' ' + HOUR.text; // Everyday/Sunday 2 AM;
};
export default fromJS;

View file

@ -1,152 +0,0 @@
import { Record, List, Set, isImmutable } from 'immutable';
import { TYPES as EVENT_TYPES } from 'Types/session/event';
export const CUSTOM = 'custom';
export const CLICK = 'click';
export const INPUT = 'input';
export const NAVIGATE = 'navigate';
export const TEST = 'test';
export const TYPES = {
CLICK,
INPUT,
CUSTOM,
NAVIGATE,
TEST,
};
const Step = defaultValues => class extends Record({
key: undefined,
name: '',
imported: false,
isDisabled: false,
importTestId: undefined,
...defaultValues,
}) {
hasTarget() {
return this.type === CLICK || this.type === INPUT;
}
isTest() {
return this.type === TEST;
}
getEventType() {
switch (this.type) {
case INPUT:
return EVENT_TYPES.INPUT;
case CLICK:
return EVENT_TYPES.CLICK;
case NAVIGATE:
return EVENT_TYPES.LOCATION;
default:
return null;
}
}
validate() {
const selectorsOK = this.selectors && this.selectors.size > 0;
const valueOK = this.value && this.value.trim().length > 0;
switch (this.type) {
case INPUT:
return selectorsOK;
case CLICK:
return selectorsOK;
case NAVIGATE:
return valueOK;
case CUSTOM:
// if (this.name.length === 0) return false;
/* if (window.JSHINT) {
window.JSHINT(this.code, { esversion: 6 });
const noErrors = window.JSHINT.errors.every(({ code }) => code && code.startsWith('W'));
return noErrors;
} */
return this.code && this.code.length > 0;
default:
return true;
}
}
toData() {
const {
value,
...step
} = this.toJS();
delete step.key;
return {
values: value && [ value ],
...step,
};
}
};
const Custom = Step({
type: CUSTOM,
code: '',
framework: 'any',
template: '',
});
const Click = Step({
type: CLICK,
selectors: List(),
customSelector: true,
});
const Input = Step({
type: INPUT,
selectors: List(),
value: '',
customSelector: true,
});
const Navigate = Step({
type: NAVIGATE,
value: '',
});
const TestAsStep = Step({
type: TEST,
testId: '',
name: '',
stepsCount: '',
steps: List(),
});
const EmptyStep = Step();
let uniqueKey = 0xff;
function nextKey() {
uniqueKey += 1;
return `${ uniqueKey }`;
}
function fromJS(initStep = {}) {
// TODO: more clear
if (initStep.importTestId) return new TestAsStep(initStep).set('steps', List(initStep.steps ? initStep.steps : initStep.test.steps).map(fromJS));
// todo: ?
if (isImmutable(initStep)) return initStep.set('key', nextKey());
const values = initStep.values && initStep.values.length > 0 && initStep.values[ 0 ];
// bad code
const step = {
...initStep,
selectors: Set(initStep.selectors).toList(), // to List not nrcrssary. TODO: check
value: initStep.value ? [initStep.value] : values,
key: nextKey(),
isDisabled: initStep.disabled
};
// bad code
if (step.type === CUSTOM) return new Custom(step);
if (step.type === CLICK) return new Click(step);
if (step.type === INPUT) return new Input(step);
if (step.type === NAVIGATE) return new Navigate(step);
return new EmptyStep();
// throw new Error(`Unknown step type: ${step.type}`);
}
export default fromJS;

View file

@ -1,4 +1,4 @@
## Licenses (as of November 04, 2022)
## Licenses (as of December 12, 2022)
Below is the list of dependencies used in OpenReplay software. Licenses may change between versions, so please keep this up to date with every new library you use.
@ -105,9 +105,8 @@ Below is the list of dependencies used in OpenReplay software. Licenses may chan
| kafka | Apache2 | Infrastructure |
| stern | Apache2 | Infrastructure |
| k9s | Apache2 | Infrastructure |
| minio | GPLv3 | Infrastructure |
| minio | [AGPLv3](https://github.com/minio/minio/blob/master/LICENSE) | Infrastructure |
| postgreSQL | PostgreSQL License | Infrastructure |
| ansible | GPLv3 | Infrastructure |
| k3s | Apache2 | Infrastructure |
| nginx | BSD2 | Infrastructure |
| clickhouse | Apache2 | Infrastructure |

View file

@ -2,6 +2,10 @@
OpenReplay Assist Plugin allows you to support your users by seeing their live screen and instantly hopping on call (WebRTC) with them without requiring any 3rd-party screen sharing software.
## Documentation
For launch options and available public methods, [refer to the documentation](https://docs.openreplay.com/plugins/assist)
## Installation
```bash
@ -72,7 +76,7 @@ trackerAssist({
type ConfirmOptions = {
text?:string,
style?: StyleObject, // style object (i.e {color: 'red', borderRadius: '10px'})
confirmBtn?: ButtonOptions,
confirmBtn?: ButtonOptions,
declineBtn?: ButtonOptions
}
@ -82,7 +86,7 @@ type ButtonOptions = HTMLButtonElement | string | {
}
```
- `callConfirm`: Customize the text and/or layout of the call request popup.
- `callConfirm`: Customize the text and/or layout of the call request popup.
- `controlConfirm`: Customize the text and/or layout of the remote control request popup.
- `config`: Contains any custom ICE/TURN server configuration. Defaults to `{ 'iceServers': [{ 'urls': 'stun:stun.l.google.com:19302' }], 'sdpSemantics': 'unified-plan' }`.
- `onAgentConnect: () => (()=>void | void)`: This callback function is fired when someone from OpenReplay UI connects to the current live session. It can return another function. In this case, returned callback will be called when the same agent connection gets closed.

View file

@ -2,10 +2,14 @@
The main package of the [OpenReplay](https://openreplay.com/) tracker.
## Documentation
For launch options and available public methods, [refer to the documentation](https://docs.openreplay.com/installation/javascript-sdk#options)
## Installation
```bash
npm i @openreplay/tracker
npm i @openreplay/tracker
```
## Usage
@ -13,30 +17,30 @@ npm i @openreplay/tracker
Initialize the package from your codebase entry point and start the tracker. You must set the `projectKey` option in the constructor. Its value can can be found in your OpenReplay dashboard under [Preferences -> Projects](https://app.openreplay.com/client/projects).
```js
import Tracker from '@openreplay/tracker';
import Tracker from '@openreplay/tracker'
const tracker = new Tracker({
projectKey: YOUR_PROJECT_KEY,
});
tracker.start({
userID: "Mr.Smith",
metadata: {
version: "3.5.0",
balance: "10M",
role: "admin",
}
}).then(startedSession => {
if (startedSession.success) {
console.log(startedSession)
}
})
tracker
.start({
userID: 'Mr.Smith',
metadata: {
version: '3.5.0',
balance: '10M',
role: 'admin',
},
})
.then((startedSession) => {
if (startedSession.success) {
console.log(startedSession)
}
})
```
Then you can use OpenReplay JavaScript API anywhere in your code.
```js
tracker.setUserID('my_user_id');
tracker.setMetadata('env', 'prod');
tracker.setUserID('my_user_id')
tracker.setMetadata('env', 'prod')
```
Read [our docs](https://docs.openreplay.com/) for more information.

View file

@ -464,7 +464,7 @@ export default class App {
return Promise.reject('no worker found after start request (this might not happen)')
}
if (this.activityState === ActivityState.NotActive) {
return Promise.reject('Tracker stopped during authorisation')
return Promise.reject('Tracker stopped during authorization')
}
const {
token,

View file

@ -2,24 +2,6 @@ const INGEST_PATH = '/v1/web/i'
const KEEPALIVE_SIZE_LIMIT = 64 << 10 // 64 kB
// function sendXHR(url: string, token: string, batch: Uint8Array): Promise<XMLHttpRequest> {
// const req = new XMLHttpRequest()
// req.open("POST", url)
// req.setRequestHeader("Authorization", "Bearer " + token)
// return new Promise((res, rej) => {
// req.onreadystatechange = function() {
// if (this.readyState === 4) {
// if (this.status == 0) {
// return; // happens simultaneously with onerror
// }
// res(this)
// }
// }
// req.onerror = rej
// req.send(batch.buffer)
// })
// }
export default class QueueSender {
private attemptsCount = 0
private busy = false
@ -38,6 +20,10 @@ export default class QueueSender {
authorise(token: string): void {
this.token = token
if (!this.busy) {
// TODO: transparent busy/send logic
this.sendNext()
}
}
push(batch: Uint8Array): void {
@ -48,9 +34,19 @@ export default class QueueSender {
}
}
private sendNext() {
const nextBatch = this.queue.shift()
if (nextBatch) {
this.sendBatch(nextBatch)
} else {
this.busy = false
}
}
private retry(batch: Uint8Array): void {
if (this.attemptsCount >= this.MAX_ATTEMPTS_COUNT) {
this.onFailure(`Failed to send batch after ${this.attemptsCount} attempts.`)
// remains this.busy === true
return
}
this.attemptsCount++
@ -83,12 +79,7 @@ export default class QueueSender {
// Success
this.attemptsCount = 0
const nextBatch = this.queue.shift()
if (nextBatch) {
this.sendBatch(nextBatch)
} else {
this.busy = false
}
this.sendNext()
})
.catch((e) => {
console.warn('OpenReplay:', e)
@ -98,5 +89,6 @@ export default class QueueSender {
clean() {
this.queue.length = 0
this.token = null
}
}