import { ResourceType, Timed } from 'Player'; import MobilePlayer from 'Player/mobile/IOSPlayer'; import WebPlayer from 'Player/web/WebPlayer'; import { Duration } from 'luxon'; import { observer } from 'mobx-react-lite'; import React, { useMemo, useState } from 'react'; import { connect } from 'react-redux'; import { useModal } from 'App/components/Modal'; import { MobilePlayerContext, PlayerContext, } from 'App/components/Session/playerContext'; import { formatMs } from 'App/date'; import { useStore } from 'App/mstore'; import { formatBytes } from 'App/utils'; import { Icon, Input, NoContent, Tabs, Toggler, Tooltip } from 'UI'; import FetchDetailsModal from 'Shared/FetchDetailsModal'; import BottomBlock from '../BottomBlock'; import InfoLine from '../BottomBlock/InfoLine'; import TimeTable from '../TimeTable'; import useAutoscroll, { getLastItemTime } from '../useAutoscroll'; import { useRegExListFilterMemo, useTabListFilterMemo } from '../useListFilter'; import WSModal from './WSModal'; const INDEX_KEY = 'network'; const ALL = 'ALL'; const XHR = 'xhr'; const JS = 'js'; const CSS = 'css'; const IMG = 'img'; const MEDIA = 'media'; const OTHER = 'other'; const WS = 'websocket'; const TYPE_TO_TAB = { [ResourceType.XHR]: XHR, [ResourceType.FETCH]: XHR, [ResourceType.SCRIPT]: JS, [ResourceType.CSS]: CSS, [ResourceType.IMG]: IMG, [ResourceType.MEDIA]: MEDIA, [ResourceType.WS]: WS, [ResourceType.OTHER]: OTHER, }; const TAP_KEYS = [ALL, XHR, JS, CSS, IMG, MEDIA, OTHER, WS] as const; export const NETWORK_TABS = TAP_KEYS.map((tab) => ({ text: tab === 'xhr' ? 'Fetch/XHR' : tab, key: tab, })); const DOM_LOADED_TIME_COLOR = 'teal'; const LOAD_TIME_COLOR = 'red'; function compare(a: any, b: any, key: string) { if (a[key] > b[key]) return 1; if (a[key] < b[key]) return -1; return 0; } export function renderType(r: any) { return ( {r.type}}>
{r.type}
); } export function renderName(r: any) { return ( {r.url}}>
{r.name}
); } export function renderStart(r: any) { return (
{Duration.fromMillis(r.time).toFormat('mm:ss.SSS')}
); } function renderSize(r: any) { if (r.responseBodySize) return formatBytes(r.responseBodySize); let triggerText; let content; if (r.decodedBodySize == null || r.decodedBodySize === 0) { triggerText = 'x'; content = 'Not captured'; } else { const headerSize = r.headerSize || 0; const showTransferred = r.headerSize != null; triggerText = formatBytes(r.decodedBodySize); content = ( ); } return (
{triggerText}
); } export function renderDuration(r: any) { if (!r.success) return 'x'; const text = `${Math.floor(r.duration)}ms`; if (!r.isRed && !r.isYellow) return text; let tooltipText; let className = 'w-full h-full flex items-center '; if (r.isYellow) { tooltipText = 'Slower than average'; className += 'warn color-orange'; } else { tooltipText = 'Much slower than average'; className += 'error color-red'; } return (
{text}
); } function renderStatus({ status, cached, error, }: { status: string; cached: boolean; error?: string; }) { const displayedStatus = error ? (
{error}
) : ( status ); return ( <> {cached ? (
{displayedStatus}
) : ( displayedStatus )} ); } function NetworkPanelCont({ startedAt, panelHeight, zoomEnabled, zoomStartTs, zoomEndTs, }: { startedAt: number; panelHeight: number; zoomEnabled: boolean; zoomStartTs: number; zoomEndTs: number; }) { const { player, store } = React.useContext(PlayerContext); const { domContentLoadedTime, loadTime, domBuildingTime, tabStates, currentTab, } = store.get(); const { fetchList = [], resourceList = [], fetchListNow = [], resourceListNow = [], websocketList = [], websocketListNow = [], } = tabStates[currentTab]; return ( ); } function MobileNetworkPanelCont({ startedAt, panelHeight, zoomEnabled, zoomStartTs, zoomEndTs, }: { startedAt: number; panelHeight: number; zoomEnabled: boolean; zoomStartTs: number; zoomEndTs: number; }) { const { player, store } = React.useContext(MobilePlayerContext); const domContentLoadedTime = undefined; const loadTime = undefined; const domBuildingTime = undefined; const { fetchList = [], resourceList = [], fetchListNow = [], resourceListNow = [], websocketList = [], websocketListNow = [], } = store.get(); return ( ); } type WSMessage = Timed & { channelName: string; data: string; timestamp: number; dir: 'up' | 'down'; messageType: string; }; interface Props { domContentLoadedTime?: { time: number; value: number; }; loadTime?: { time: number; value: number; }; domBuildingTime?: number; fetchList: Timed[]; resourceList: Timed[]; fetchListNow: Timed[]; resourceListNow: Timed[]; websocketList: Array; websocketListNow: Array; player: WebPlayer | MobilePlayer; startedAt: number; isMobile?: boolean; zoomEnabled: boolean; zoomStartTs?: number; zoomEndTs?: number; panelHeight: number; onClose?: () => void; activeOutsideIndex?: number; } export const NetworkPanelComp = observer( ({ loadTime, domBuildingTime, domContentLoadedTime, fetchList, resourceList, fetchListNow, resourceListNow, player, startedAt, isMobile, panelHeight, websocketList, zoomEnabled, zoomStartTs, zoomEndTs, onClose, activeOutsideIndex, }: Props) => { const { showModal } = useModal(); const [sortBy, setSortBy] = useState('time'); const [sortAscending, setSortAscending] = useState(true); const [showOnlyErrors, setShowOnlyErrors] = useState(false); const [isDetailsModalActive, setIsDetailsModalActive] = useState(false); const { sessionStore: { devTools }, } = useStore(); const filter = devTools[INDEX_KEY].filter; const activeTab = devTools[INDEX_KEY].activeTab; const activeIndex = activeOutsideIndex ?? devTools[INDEX_KEY].index; const socketList = useMemo( () => websocketList.filter( (ws, i, arr) => arr.findIndex((it) => it.channelName === ws.channelName) === i ), [websocketList] ); const list = useMemo( () => // TODO: better merge (with body size info) - do it in player resourceList .filter( (res) => !fetchList.some((ft) => { // res.url !== ft.url doesn't work on relative URLs appearing within fetchList (to-fix in player) if (res.name === ft.name) { if (res.time === ft.time) return true; if (res.url.includes(ft.url)) { return ( Math.abs(res.time - ft.time) < 350 || Math.abs(res.timestamp - ft.timestamp) < 350 ); } } if (res.name !== ft.name) { return false; } if (Math.abs(res.time - ft.time) > 250) { return false; } // TODO: find good epsilons if (Math.abs(res.duration - ft.duration) > 200) { return false; } return true; }) ) .concat(fetchList) .concat( socketList.map((ws) => ({ ...ws, type: 'websocket', method: 'ws', url: ws.channelName, name: ws.channelName, status: '101', duration: 0, transferredBodySize: 0, })) ) .filter((req) => zoomEnabled ? req.time >= zoomStartTs! && req.time <= zoomEndTs! : true ) .sort((a, b) => a.time - b.time), [resourceList.length, fetchList.length, socketList] ); let filteredList = useMemo(() => { if (!showOnlyErrors) { return list; } return list.filter( (it) => parseInt(it.status) >= 400 || !it.success || it.error ); }, [showOnlyErrors, list]); filteredList = useRegExListFilterMemo( filteredList, (it) => [it.status, it.name, it.type, it.method], filter ); filteredList = useTabListFilterMemo( filteredList, (it) => TYPE_TO_TAB[it.type], ALL, activeTab ); const onTabClick = (activeTab: (typeof TAP_KEYS)[number]) => devTools.update(INDEX_KEY, { activeTab }); const onFilterChange = ({ target: { value }, }: React.ChangeEvent) => devTools.update(INDEX_KEY, { filter: value }); // AutoScroll const [timeoutStartAutoscroll, stopAutoscroll] = useAutoscroll( filteredList, getLastItemTime(fetchListNow, resourceListNow), activeIndex, (index) => devTools.update(INDEX_KEY, { index }) ); const onMouseEnter = stopAutoscroll; const onMouseLeave = () => { if (isDetailsModalActive) { return; } timeoutStartAutoscroll(); }; const resourcesSize = useMemo( () => resourceList.reduce( (sum, { decodedBodySize }) => sum + (decodedBodySize || 0), 0 ), [resourceList.length] ); const transferredSize = useMemo( () => resourceList.reduce( (sum, { headerSize, encodedBodySize }) => sum + (headerSize || 0) + (encodedBodySize || 0), 0 ), [resourceList.length] ); const referenceLines = useMemo(() => { const arr = []; if (domContentLoadedTime != null) { arr.push({ time: domContentLoadedTime.time, color: DOM_LOADED_TIME_COLOR, }); } if (loadTime != null) { arr.push({ time: loadTime.time, color: LOAD_TIME_COLOR, }); } return arr; }, [domContentLoadedTime, loadTime]); const showDetailsModal = (item: any) => { if (item.type === 'websocket') { const socketMsgList = websocketList.filter( (ws) => ws.channelName === item.channelName ); return showModal(, { right: true, width: 700, }); } setIsDetailsModalActive(true); showModal( 0} />, { right: true, width: 500, onClose: () => { setIsDetailsModalActive(false); timeoutStartAutoscroll(); }, } ); devTools.update(INDEX_KEY, { index: filteredList.indexOf(item) }); stopAutoscroll(); }; return (
Network {isMobile ? null : ( )}
setShowOnlyErrors(!showOnlyErrors)} label="4xx-5xx Only" />
0} /> 0} />
No Data } size="small" show={filteredList.length === 0} > {/*@ts-ignore*/} { devTools.update(INDEX_KEY, { index: filteredList.indexOf(row), }); player.jump(row.time); }} activeIndex={activeIndex} > {[ // { // label: 'Start', // width: 120, // render: renderStart, // }, { label: 'Status', dataKey: 'status', width: 90, render: renderStatus, }, { label: 'Type', dataKey: 'type', width: 90, render: renderType, }, { label: 'Method', width: 80, dataKey: 'method', }, { label: 'Name', width: 240, dataKey: 'name', render: renderName, }, { label: 'Size', width: 80, dataKey: 'decodedBodySize', render: renderSize, hidden: activeTab === XHR, }, { label: 'Duration', width: 80, dataKey: 'duration', render: renderDuration, }, ]}
); } ); const WebNetworkPanel = connect((state: any) => ({ startedAt: state.getIn(['sessions', 'current']).startedAt, zoomEnabled: state.getIn(['components', 'player']).timelineZoom.enabled, zoomStartTs: state.getIn(['components', 'player']).timelineZoom.startTs, zoomEndTs: state.getIn(['components', 'player']).timelineZoom.endTs, }))(observer(NetworkPanelCont)); const MobileNetworkPanel = connect((state: any) => ({ startedAt: state.getIn(['sessions', 'current']).startedAt, zoomEnabled: state.getIn(['components', 'player']).timelineZoom.enabled, zoomStartTs: state.getIn(['components', 'player']).timelineZoom.startTs, zoomEndTs: state.getIn(['components', 'player']).timelineZoom.endTs, }))(observer(MobileNetworkPanelCont)); export { WebNetworkPanel, MobileNetworkPanel };