diff --git a/backend/Dockerfile b/backend/Dockerfile index 0d7cad075..63b75dc61 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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 diff --git a/backend/cmd/storage/main.go b/backend/cmd/storage/main.go index 251ce82e2..267cb898d 100644 --- a/backend/cmd/storage/main.go +++ b/backend/cmd/storage/main.go @@ -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: diff --git a/backend/internal/storage/storage.go b/backend/internal/storage/storage.go index 12a37183f..594d97eea 100644 --- a/backend/internal/storage/storage.go +++ b/backend/internal/storage/storage.go @@ -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{}{} + } + } +} diff --git a/ee/backend/pkg/failover/failover.go b/ee/backend/pkg/failover/failover.go index 1b9321afc..11ff7e4be 100644 --- a/ee/backend/pkg/failover/failover.go +++ b/ee/backend/pkg/failover/failover.go @@ -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) diff --git a/frontend/app/components/Session_/PageInsightsPanel/components/SelectorsList/SelectorsList.tsx b/frontend/app/components/Session_/PageInsightsPanel/components/SelectorsList/SelectorsList.tsx index fabdca831..183b9754e 100644 --- a/frontend/app/components/Session_/PageInsightsPanel/components/SelectorsList/SelectorsList.tsx +++ b/frontend/app/components/Session_/PageInsightsPanel/components/SelectorsList/SelectorsList.tsx @@ -13,7 +13,7 @@ function SelectorsList() { return (
- {targets && targets.map((target, index) => )} + {targets && targets.map((target, index) => )}
); diff --git a/frontend/app/components/Session_/Player/Controls/Timeline.tsx b/frontend/app/components/Session_/Player/Controls/Timeline.tsx index 686cf85b4..89ce80e65 100644 --- a/frontend/app/components/Session_/Player/Controls/Timeline.tsx +++ b/frontend/app/components/Session_/Player/Controls/Timeline.tsx @@ -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() + const timelineRef = useRef() 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) { /> diff --git a/frontend/app/components/Session_/Player/Controls/components/CustomDragLayer.tsx b/frontend/app/components/Session_/Player/Controls/components/CustomDragLayer.tsx index eb93203b5..c106b14dc 100644 --- a/frontend/app/components/Session_/Player/Controls/components/CustomDragLayer.tsx +++ b/frontend/app/components/Session_/Player/Controls/components/CustomDragLayer.tsx @@ -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 = 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 = 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 ; - 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 ( -
-
- {renderItem()} -
+ return ( +
+
+
- ); +
+ ) }) export default CustomDragLayer; diff --git a/frontend/app/components/Session_/Player/Controls/components/TimeTooltip.tsx b/frontend/app/components/Session_/Player/Controls/components/TimeTooltip.tsx index e1be98622..e47593b97 100644 --- a/frontend/app/components/Session_/Player/Controls/components/TimeTooltip.tsx +++ b/frontend/app/components/Session_/Player/Controls/components/TimeTooltip.tsx @@ -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 (
- {!time ? 'Loading' : duration} + {!time ? 'Loading' : time} + {timeStr ? ( + <> +
+ ({timeStr}) + + ) : null}
); } 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); diff --git a/frontend/app/components/Session_/Player/Overlay/ElementsMarker.tsx b/frontend/app/components/Session_/Player/Overlay/ElementsMarker.tsx index 99499cac6..7be45a432 100644 --- a/frontend/app/components/Session_/Player/Overlay/ElementsMarker.tsx +++ b/frontend/app/components/Session_/Player/Overlay/ElementsMarker.tsx @@ -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 => ) +export default function ElementsMarker({ targets, activeIndex }: { targets: MarkedTarget[], activeIndex: number }) { + return targets && targets.map(t => ) } - - - diff --git a/frontend/app/components/Session_/PlayerBlockHeader.tsx b/frontend/app/components/Session_/PlayerBlockHeader.tsx index 1932ccb85..744db22cc 100644 --- a/frontend/app/components/Session_/PlayerBlockHeader.tsx +++ b/frontend/app/components/Session_/PlayerBlockHeader.tsx @@ -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} diff --git a/frontend/app/components/Session_/Subheader.js b/frontend/app/components/Session_/Subheader.js index be7679fd5..72ae28e3d 100644 --- a/frontend/app/components/Session_/Subheader.js +++ b/frontend/app/components/Session_/Subheader.js @@ -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 (
+ {location && (
{ setIsDetailsModalActive(true) showModal( - 0} />, + 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)); diff --git a/frontend/app/components/shared/FetchDetailsModal/FetchDetailsModal.tsx b/frontend/app/components/shared/FetchDetailsModal/FetchDetailsModal.tsx index a9f90e723..183294788 100644 --- a/frontend/app/components/shared/FetchDetailsModal/FetchDetailsModal.tsx +++ b/frontend/app/components/shared/FetchDetailsModal/FetchDetailsModal.tsx @@ -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 (
Network Request
- + {isXHR && !fetchPresented && } {isXHR && } diff --git a/frontend/app/components/shared/FetchDetailsModal/components/FetchBasicDetails/FetchBasicDetails.tsx b/frontend/app/components/shared/FetchDetailsModal/components/FetchBasicDetails/FetchBasicDetails.tsx index 49e16c00f..94aadfb1d 100644 --- a/frontend/app/components/shared/FetchDetailsModal/components/FetchBasicDetails/FetchBasicDetails.tsx +++ b/frontend/app/components/shared/FetchDetailsModal/components/FetchBasicDetails/FetchBasicDetails.tsx @@ -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 && (
-
Time
+
Duration
{_duration} ms
)} + + {timestamp && ( +
+
Time
+
+ {timestamp} +
+
+ + )}
); } diff --git a/frontend/app/duck/index.js b/frontend/app/duck/index.js index 5ad487c93..051ec6933 100644 --- a/frontend/app/duck/index.js +++ b/frontend/app/duck/index.js @@ -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, diff --git a/frontend/app/duck/runs.js b/frontend/app/duck/runs.js deleted file mode 100644 index 30b8051ac..000000000 --- a/frontend/app/duck/runs.js +++ /dev/null @@ -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; diff --git a/frontend/app/duck/schedules.js b/frontend/app/duck/schedules.js deleted file mode 100644 index 3f13e9188..000000000 --- a/frontend/app/duck/schedules.js +++ /dev/null @@ -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; diff --git a/frontend/app/duck/sessions.js b/frontend/app/duck/sessions.js index ecce3d713..8afb6d073 100644 --- a/frontend/app/duck/sessions.js +++ b/frontend/app/duck/sessions.js @@ -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, }; -} \ No newline at end of file +} diff --git a/frontend/app/duck/steps.js b/frontend/app/duck/steps.js deleted file mode 100644 index 02bfdbb90..000000000 --- a/frontend/app/duck/steps.js +++ /dev/null @@ -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, - }; -} diff --git a/frontend/app/duck/tests/index.js b/frontend/app/duck/tests/index.js deleted file mode 100644 index eda7edc43..000000000 --- a/frontend/app/duck/tests/index.js +++ /dev/null @@ -1,186 +0,0 @@ -import { List, Map, Set } from 'immutable'; -import Test from 'Types/appTest'; -import stepFromJS from 'Types/step'; -import crudDuckGenerator from 'Duck/tools/crudDuck'; -import { reduceDucks } from 'Duck/tools'; -import runsDuck from './runs'; -import Run from 'Types/run'; - -const sampleRun = Run({"runId":8,"testId":7,"name":"test import","createdAt":1601481986264,"createdBy":283,"starter":"on-demand","state":"failed","steps":[{"label":"Open URL","order":0,"title":"navigate","status":"passed","startedAt":1601647536513,"finishedAt":1601647546211,"screenshot":"https://parrot-tests.s3.eu-central-1.amazonaws.com/115/7/8/screenshots/1601647546211.jpg","executionTime":9698},{"label":"Open URL","order":1,"title":"Visit OpenReplay","status":"passed","startedAt":1601647548354,"finishedAt":1601647556991,"screenshot":"https://parrot-tests.s3.eu-central-1.amazonaws.com/115/7/8/screenshots/1601647556991.jpg","executionTime":8637},{"info":"Unhandled promise rejection: TimeoutError: waiting for selector \"[name=\"email\"]\" failed: timeout 30000ms exceeded","input":"failed","label":"Send Keys to Element","order":2,"title":"input","status":"failed","startedAt":1601647559091,"finishedAt":1601647589099,"screenshot":"https://parrot-tests.s3.eu-central-1.amazonaws.com/115/7/8/screenshots/1601647589099.jpg","executionTime":30008}],"browser":"chrome","meta":{"startedAt":1601487715818},"location":"FR","startedAt":1601647524205,"finishedAt":1601647591217,"network":[{"url":"http://yahoo.fr/","method":"GET","duration":1760,"requestID":"769C483871CB3D35DF4BB1CD7D3258C4","timestamp":1601647537533,"requestHeaders":{"cookie":"key1=myvalue1","user-agent":"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/83.0.4103.0 Safari/537.36","upgrade-insecure-requests":"1"},"responseHeaders":null,"response":{"data":[{"key":"user_id","index":1},{"key":"virtual_number","index":2}]}},{"url":"http://fr.yahoo.com/","method":"GET","duration":1112,"requestID":"769C483871CB3D35DF4BB1CD7D3258C4","timestamp":1601647539293,"requestHeaders":{"cookie":"key1=myvalue1","user-agent":"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/83.0.4103.0 Safari/537.36","upgrade-insecure-requests":"1"},"responseHeaders":null,"payload":{"data":[{"key":"user_id","index":1},{"key":"virtual_number","index":2}]}},{"url":"https://fr.yahoo.com/","method":"GET","duration":1204,"requestID":"769C483871CB3D35DF4BB1CD7D3258C4","timestamp":1601647540405,"requestHeaders":{"cookie":"key1=myvalue1","user-agent":"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/83.0.4103.0 Safari/537.36","upgrade-insecure-requests":"1"},"responseHeaders":null},{"url":"https://guce.yahoo.com/consent?brandType=eu&gcrumb=bxFB6Ac&lang=fr-FR&done=https%3A%2F%2Ffr.yahoo.com%2F","method":"GET","duration":1173,"requestID":"769C483871CB3D35DF4BB1CD7D3258C4","timestamp":1601647541609,"requestHeaders":{"cookie":"key1=myvalue1","user-agent":"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/83.0.4103.0 Safari/537.36","upgrade-insecure-requests":"1"},"responseHeaders":null},{"url":"https://consent.yahoo.com/v2/collectConsent?sessionId=3_cc-session_fab600d0-8323-4b52-88c1-5698e6288f48","method":"GET","duration":1169,"requestID":"769C483871CB3D35DF4BB1CD7D3258C4","timestamp":1601647542782,"requestHeaders":{"cookie":"key1=myvalue1","user-agent":"Mozilla/5.0 (X11; Linux x86_64)AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/83.0.4103.0 Safari/537.36","upgrade-insecure-requests":"1"},"responseHeaders":null},{"url":"https://s.yimg.com/oa/build/css/site-ltr-b1aa14b0.css","method":"GET","duration":1179,"requestID":"56.2","timestamp":1601647543958,"requestHeaders":{"cookie":"key1=myvalue1","referer":"https://consent.yahoo.com/","user-agent":"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/83.0.4103.0 Safari/537.36"},"responseHeaders":null},{"url":"https://s.yimg.com/rz/p/yahoo_frontpage_en-US_s_f_p_bestfit_frontpage.png","method":"GET","duration":1189,"requestID":"56.3","timestamp":1601647543959,"requestHeaders":{"cookie":"key1=myvalue1","referer":"https://consent.yahoo.com/","user-agent":"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/83.0.4103.0 Safari/537.36"},"responseHeaders":null},{"url":"https://s.yimg.com/oa/build/js/site-ee81be05.js","method":"GET","duration":1194,"requestID":"56.5","timestamp":1601647543961,"requestHeaders":{"cookie":"key1=myvalue1","referer":"https://consent.yahoo.com/","user-agent":"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/83.0.4103.0 Safari/537.36"},"responseHeaders":null},{"url":"https://s.yimg.com/rz/p/yahoo_frontpage_en-US_s_f_w_bestfit_frontpage.png","method":"GET","duration":1189,"requestID":"56.4","timestamp":1601647543961,"requestHeaders":{"cookie":"key1=myvalue1","referer":"https://consent.yahoo.com/","user-agent":"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/83.0.4103.0 Safari/537.36"},"responseHeaders":null},{"url":"https://s.yimg.com/oa/build/images/fr-FR-home_11f60c18d02223c8.jpeg","method":"GET","duration":1068,"requestID":"56.7","timestamp":1601647545141,"requestHeaders":{"cookie":"key1=myvalue1","referer":"https://s.yimg.com/oa/build/css/site-ltr-b1aa14b0.css","user-agent":"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/83.0.4103.0 Safari/537.36"},"responseHeaders":null},{"url":"http://yahoo.fr/","method":"GET","duration":1312,"requestID":"7CA8FE6239B07643872BF48C30D639D3","timestamp":1601647549363,"requestHeaders":{"cookie":"key1=myvalue1","user-agent":"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/83.0.4103.0 Safari/537.36","upgrade-insecure-requests":"1"},"responseHeaders":null},{"url":"http://fr.yahoo.com/","method":"GET","duration":1005,"requestID":"7CA8FE6239B07643872BF48C30D639D3","timestamp":1601647550675,"requestHeaders":{"cookie":"key1=myvalue1","user-agent":"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/83.0.4103.0 Safari/537.36","upgrade-insecure-requests":"1"},"responseHeaders":null},{"url":"https://fr.yahoo.com/","method":"GET","duration":1037,"requestID":"7CA8FE6239B07643872BF48C30D639D3","timestamp":1601647551680,"requestHeaders":{"cookie":"key1=myvalue1","user-agent":"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/83.0.4103.0 Safari/537.36","upgrade-insecure-requests":"1"},"responseHeaders":null},{"url":"https://guce.yahoo.com/consent?brandType=eu&gcrumb=ESjhlqw&lang=fr-FR&done=https%3A%2F%2Ffr.yahoo.com%2F","method":"GET","duration":1045,"requestID":"7CA8FE6239B07643872BF48C30D639D3","timestamp":1601647552717,"requestHeaders":{"cookie":"key1=myvalue1","user-agent":"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/83.0.4103.0 Safari/537.36","upgrade-insecure-requests":"1"},"responseHeaders":null},{"url":"https://consent.yahoo.com/v2/collectConsent?sessionId=3_cc-session_3b367c91-9f88-498b-96a5-728947dda245","method":"GET","duration":1115,"requestID":"7CA8FE6239B07643872BF48C30D639D3","timestamp":1601647553762,"requestHeaders":{"cookie":"key1=myvalue1","user-agent":"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/83.0.4103.0 Safari/537.36","upgrade-insecure-requests":"1"},"responseHeaders":null},{"url":"https://s.yimg.com/oa/build/css/site-ltr-b1aa14b0.css","method":"GET","duration":1052,"requestID":"56.14","timestamp":1601647554885,"requestHeaders":{"cookie":"key1=myvalue1","referer":"https://consent.yahoo.com/","user-agent":"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/83.0.4103.0 Safari/537.36"},"responseHeaders":null},{"url":"https://s.yimg.com/rz/p/yahoo_frontpage_en-US_s_f_p_bestfit_frontpage.png","method":"GET","duration":1060,"requestID":"56.15","timestamp":1601647554886,"requestHeaders":{"cookie":"key1=myvalue1","referer":"https://consent.yahoo.com/","user-agent":"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/83.0.4103.0 Safari/537.36"},"responseHeaders":null},{"url":"https://s.yimg.com/oa/build/js/site-ee81be05.js","method":"GET","duration":1065,"requestID":"56.17","timestamp":1601647554886,"requestHeaders":{"cookie":"key1=myvalue1","referer":"https://consent.yahoo.com/","user-agent":"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/83.0.4103.0 Safari/537.36"},"responseHeaders":null},{"url":"https://s.yimg.com/rz/p/yahoo_frontpage_en-US_s_f_w_bestfit_frontpage.png","method":"GET","duration":1063,"requestID":"56.16","timestamp":1601647554886,"requestHeaders":{"cookie":"key1=myvalue1","referer":"https://consent.yahoo.com/","user-agent":"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/83.0.4103.0 Safari/537.36"},"responseHeaders":null},{"url":"https://s.yimg.com/oa/build/images/fr-FR-home_11f60c18d02223c8.jpeg","method":"GET","duration":1046,"requestID":"56.19","timestamp":1601647555944,"requestHeaders":{"cookie":"key1=myvalue1","referer":"https://s.yimg.com/oa/build/css/site-ltr-b1aa14b0.css","user-agent":"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/83.0.4103.0 Safari/537.36"},"responseHeaders":null}],"environmentId":null,"tenantId":115,"consoleLogs":[{"_type":"warning","_text":"A cookie associated with a resource at http://openreplay.com/ was set with `SameSite=None` but without `Secure`. It has been blocked, as Chrome now only delivers cookies marked `SameSite=None` if they are also marked `Secure`. You can review cookies in developer tools under Application>Storage>Cookies and see more details at https://www.chromestatus.com/feature/5633521622188032.","_args":[],"_location":{"url":"https://app.openreplay.com/"},"timestamp":1602089840909},{"_type":"warning","_text":"A cookie associated with a resource at http://app.openreplay.com/ was set with `SameSite=None` but without `Secure`. It has been blocked, as Chrome now only delivers cookies marked `SameSite=None` if they are also marked `Secure`. You can review cookies in developer tools under Application>Storage>Cookies and see more details at https://www.chromestatus.com/feature/5633521622188032.","_args":[],"_location":{"url":"https://app.openreplay.com/"},"timestamp":1602089840918}]}); - -const ADD_STEPS = 'tests/ADD_STEPS'; -const MOVE_STEP = 'tests/MOVE_STEP'; -const REMOVE_STEP = 'tests/REMOVE_STEP'; -const COPY_STEP = 'tests/COPY_STEP'; -const EDIT_STEP = 'tests/EDIT_STEP'; -const TOGGLE_STEP = 'tests/TOGGLE_STEP'; -const ADD_TAG = 'tests/ADD_TAG'; -const REMOVE_TAG = 'tests/REMOVE_TAG'; -const TOGGLE_TAG = 'tests/TOGGLE_TAG'; -const SET_MODIFIED = 'tests/SET_MODIFIED'; -const SET_QUERY = 'tests/SET_QUERY'; - -const MOVE_TEST = 'tests/MOVE_TEST'; - -const initialState = Map({ - tags: Set(), - query: '', - modified: false, - sampleRun: sampleRun, -}); - -const reducer = (state = initialState, action = {}) => { - switch (action.type) { - case SET_MODIFIED: - return state.set('modified', action.state); - case SET_QUERY: - return state.set('query', action.query); - case ADD_STEPS: - // TODO check frameworks - return state - .updateIn([ 'instance', 'steps' ], list => list.concat(action.steps.map(stepFromJS))).set('modified', true); - case MOVE_STEP: { - const { fromI, toI } = action; - return state - .updateIn([ 'instance', 'steps' ], list => - list.remove(fromI).insert(toI, list.get(fromI))).set('modified', true); - } - case REMOVE_STEP: - return state.removeIn([ 'instance', 'steps', action.index ]).set('modified', true); - case COPY_STEP: { - // Use fromJS to make another key. - const copiedStep = stepFromJS(state - .getIn([ 'instance', 'steps', action.index ]) - .set('imported', false)); - return state - .updateIn([ 'instance', 'steps' ], steps => - steps.insert(action.index + 1, copiedStep)).set('modified', true); - } - case EDIT_STEP: - return state.mergeIn([ 'instance', 'steps', action.index ], action.step).set('modified', true); - case TOGGLE_STEP: - return state.updateIn([ 'instance', 'steps', action.index, 'isDisabled' ], isDisabled => !isDisabled).set('modified', true); - case ADD_TAG: - return state.updateIn([ 'instance', 'tags' ], tags => tags.add(action.tag)).set('modified', true); - case REMOVE_TAG: - return state.updateIn([ 'instance', 'tags' ], tags => tags.remove(action.tag)).set('modified', true); - case TOGGLE_TAG: { - const { tag, flag } = action; - const adding = typeof flag === 'boolean' - ? flag - : !state.hasIn([ 'tags', tag ]); - return state.update('tags', tags => (adding - ? tags.add(tag) - : tags.remove(tag))); - } - case MOVE_TEST: { - const { fromI, toI } = action; - return state - .updateIn([ 'list' ], list => - list.remove(fromI).insert(toI, list.get(fromI))); - } - } - return state; -}; - -const crudDuck = crudDuckGenerator('test', Test); -export const { fetchList, fetch, init, edit, save, remove } = crudDuck.actions; -export { runTest, stopRun, checkRun, generateTest, stopAllRuns, resetErrors } from './runs'; -export default reduceDucks(crudDuck, { reducer, initialState }, runsDuck).reducer; - -export function addSteps(stepOrSteps) { - const steps = Array.isArray(stepOrSteps) || List.isList(stepOrSteps) - ? stepOrSteps - : [ stepOrSteps ]; - return { - type: ADD_STEPS, - steps, - }; -} - -export function moveStep(fromI, toI) { - return { - type: MOVE_STEP, - fromI, - toI, - }; -} - -export function removeStep(index) { - return { - type: REMOVE_STEP, - index, - }; -} - -export function copyStep(index) { - return { - type: COPY_STEP, - index, - }; -} - -export function editStep(index, step) { - return { - type: EDIT_STEP, - index, - step, - }; -} - -export function setModified(state) { - return { - type: SET_MODIFIED, - state, - }; -} - -export function toggleStep(index) { - return { - type: TOGGLE_STEP, - index, - }; -} - -export const addTag = (tag) => (dispatch) => { - return new Promise((resolve) => { - dispatch({ - type: ADD_TAG, - tag, - }) - resolve() - }) -} - -export const removeTag = (tag) => (dispatch) => { - return new Promise((resolve) => { - dispatch({ - type: REMOVE_TAG, - tag, - }); - resolve() - }) -} - -export function toggleTag(tag, flag) { - return { - type: TOGGLE_TAG, - tag, - flag, - }; -} - -export function setQuery(query) { - return { - type: SET_QUERY, - query - }; -} - -export function moveTest(fromI, toI) { - return { - type: MOVE_TEST, - fromI, - toI, - }; -} diff --git a/frontend/app/duck/tests/runs.js b/frontend/app/duck/tests/runs.js deleted file mode 100644 index e5f1ecc3d..000000000 --- a/frontend/app/duck/tests/runs.js +++ /dev/null @@ -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 }`), - }; -} diff --git a/frontend/app/hooks/useToggle.ts b/frontend/app/hooks/useToggle.ts index d4e820388..426e19b41 100644 --- a/frontend/app/hooks/useToggle.ts +++ b/frontend/app/hooks/useToggle.ts @@ -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 ]; } \ No newline at end of file diff --git a/frontend/app/mstore/notesStore.ts b/frontend/app/mstore/notesStore.ts index 1cb041049..ea52cb2e7 100644 --- a/frontend/app/mstore/notesStore.ts +++ b/frontend/app/mstore/notesStore.ts @@ -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 { diff --git a/frontend/app/mstore/types/sessionSettings.ts b/frontend/app/mstore/types/sessionSettings.ts index 95005b85d..bde66cdaf 100644 --- a/frontend/app/mstore/types/sessionSettings.ts +++ b/frontend/app/mstore/types/sessionSettings.ts @@ -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; }; diff --git a/frontend/app/player/web/Screen/Cursor.ts b/frontend/app/player/web/Screen/Cursor.ts index 4d8094b4e..f2d371062 100644 --- a/frontend/app/player/web/Screen/Cursor.ts +++ b/frontend/app/player/web/Screen/Cursor.ts @@ -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) } diff --git a/frontend/app/player/web/Screen/Screen.ts b/frontend/app/player/web/Screen/Screen.ts index b1ceff509..043be5357 100644 --- a/frontend/app/player/web/Screen/Screen.ts +++ b/frontend/app/player/web/Screen/Screen.ts @@ -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) { diff --git a/frontend/app/player/web/Screen/cursor.module.css b/frontend/app/player/web/Screen/cursor.module.css index 7a94c99b8..93f3d05ff 100644 --- a/frontend/app/player/web/Screen/cursor.module.css +++ b/frontend/app/player/web/Screen/cursor.module.css @@ -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); + } +} diff --git a/frontend/app/player/web/WebPlayer.ts b/frontend/app/player/web/WebPlayer.ts index 8df6214d4..bd80c1189 100644 --- a/frontend/app/player/web/WebPlayer.ts +++ b/frontend/app/player/web/WebPlayer.ts @@ -31,7 +31,6 @@ export default class WebPlayer extends Player { private targetMarker: TargetMarker constructor(private wpState: Store, 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 diff --git a/frontend/app/player/web/messages/JSONRawMessageReader.ts b/frontend/app/player/web/messages/JSONRawMessageReader.ts index 04f622ecb..6a9cfb4f9 100644 --- a/frontend/app/player/web/messages/JSONRawMessageReader.ts +++ b/frontend/app/player/web/messages/JSONRawMessageReader.ts @@ -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) } } diff --git a/frontend/app/player/web/messages/MFileReader.ts b/frontend/app/player/web/messages/MFileReader.ts index e6fad8c63..b91080b4f 100644 --- a/frontend/app/player/web/messages/MFileReader.ts +++ b/frontend/app/player/web/messages/MFileReader.ts @@ -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, }) diff --git a/frontend/app/player/web/messages/MStreamReader.ts b/frontend/app/player/web/messages/MStreamReader.ts index 6446112a3..a37e43c46 100644 --- a/frontend/app/player/web/messages/MStreamReader.ts +++ b/frontend/app/player/web/messages/MStreamReader.ts @@ -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 diff --git a/frontend/app/player/web/messages/urlBasedResolver.ts b/frontend/app/player/web/messages/urlBasedResolver.ts new file mode 100644 index 000000000..53bf1ed81 --- /dev/null +++ b/frontend/app/player/web/messages/urlBasedResolver.ts @@ -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 = Extract; +// type ResolversMap = { +// [Key in MType]: (event: PickMessage) => 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 +} \ No newline at end of file diff --git a/frontend/app/types/run/index.js b/frontend/app/types/run/index.js deleted file mode 100644 index 658043461..000000000 --- a/frontend/app/types/run/index.js +++ /dev/null @@ -1,4 +0,0 @@ -import fromJS from './run'; - -export * from './run'; -export default fromJS; \ No newline at end of file diff --git a/frontend/app/types/run/run.js b/frontend/app/types/run/run.js deleted file mode 100644 index cfe35488e..000000000 --- a/frontend/app/types/run/run.js +++ /dev/null @@ -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; diff --git a/frontend/app/types/run/seleniumStep.js b/frontend/app/types/run/seleniumStep.js deleted file mode 100644 index 5178240c7..000000000 --- a/frontend/app/types/run/seleniumStep.js +++ /dev/null @@ -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; \ No newline at end of file diff --git a/frontend/app/types/run/step.js b/frontend/app/types/run/step.js deleted file mode 100644 index 5358c0985..000000000 --- a/frontend/app/types/run/step.js +++ /dev/null @@ -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; \ No newline at end of file diff --git a/frontend/app/types/schedule.js b/frontend/app/types/schedule.js deleted file mode 100644 index 87b38eb6d..000000000 --- a/frontend/app/types/schedule.js +++ /dev/null @@ -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; diff --git a/frontend/app/types/step.js b/frontend/app/types/step.js deleted file mode 100644 index 438b403d8..000000000 --- a/frontend/app/types/step.js +++ /dev/null @@ -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; diff --git a/third-party.md b/third-party.md index 186ad8817..1c003748b 100644 --- a/third-party.md +++ b/third-party.md @@ -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 | diff --git a/tracker/tracker-assist/README.md b/tracker/tracker-assist/README.md index 4897477b3..662e1e084 100644 --- a/tracker/tracker-assist/README.md +++ b/tracker/tracker-assist/README.md @@ -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. diff --git a/tracker/tracker/README.md b/tracker/tracker/README.md index c47f301dc..b1daf6d4d 100644 --- a/tracker/tracker/README.md +++ b/tracker/tracker/README.md @@ -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. diff --git a/tracker/tracker/src/main/app/index.ts b/tracker/tracker/src/main/app/index.ts index dc4caa143..5e33b84bc 100644 --- a/tracker/tracker/src/main/app/index.ts +++ b/tracker/tracker/src/main/app/index.ts @@ -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, diff --git a/tracker/tracker/src/webworker/QueueSender.ts b/tracker/tracker/src/webworker/QueueSender.ts index aa1ff4589..2a2863192 100644 --- a/tracker/tracker/src/webworker/QueueSender.ts +++ b/tracker/tracker/src/webworker/QueueSender.ts @@ -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 { -// 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 } }