Merge branch 'dev' into network-request-messahe
This commit is contained in:
commit
b3cbf74deb
43 changed files with 508 additions and 1449 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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} />}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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 }`),
|
||||
};
|
||||
}
|
||||
|
|
@ -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 ];
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
69
frontend/app/player/web/messages/urlBasedResolver.ts
Normal file
69
frontend/app/player/web/messages/urlBasedResolver.ts
Normal 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
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
import fromJS from './run';
|
||||
|
||||
export * from './run';
|
||||
export default fromJS;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue