/* eslint-disable i18next/no-literal-string */ import { ResourceType, Timed } from 'Player'; import { WsChannel } from 'Player/web/messages'; import MobilePlayer from 'Player/mobile/IOSPlayer'; import WebPlayer from 'Player/web/WebPlayer'; import { observer } from 'mobx-react-lite'; import React, { useMemo, useState, useEffect, useCallback, useRef, } from 'react'; import i18n from 'App/i18n' 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, debounceCall } from 'App/utils'; import { Icon, NoContent, Tabs } from 'UI'; import { Tooltip, Input, Switch, Form } from 'antd'; import { SearchOutlined, InfoCircleOutlined, } from '@ant-design/icons'; import FetchDetailsModal from 'Shared/FetchDetailsModal'; import BottomBlock from '../BottomBlock'; import InfoLine from '../BottomBlock/InfoLine'; import TabSelector from '../TabSelector'; import TimeTable from '../TimeTable'; import useAutoscroll, { getLastItemTime } from '../useAutoscroll'; import WSPanel from './WSPanel'; import { useTranslation } from 'react-i18next'; import { mergeListsWithZoom, processInChunks } from './utils' // Constants remain the same 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 GRAPHQL = 'graphql'; 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, [ResourceType.GRAPHQL]: GRAPHQL, }; const TAP_KEYS = [ALL, XHR, JS, CSS, IMG, MEDIA, OTHER, WS, GRAPHQL] 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'; const BATCH_SIZE = 2500; const INITIAL_LOAD_SIZE = 5000; export function renderType(r: any) { return ( {r.type}}>
{r.type}
); } export function renderName(r: any) { return ( {r.url}}>
{r.name}
); } function renderSize(r: any) { const t = i18n.t; const notCaptured = t('Not captured'); const resSizeStr = t('Resource size') let triggerText; let content; if (r.responseBodySize) { triggerText = formatBytes(r.responseBodySize); content = undefined; } else if (r.decodedBodySize == null || r.decodedBodySize === 0) { triggerText = 'x'; content = notCaptured; } else { const headerSize = r.headerSize || 0; const showTransferred = r.headerSize != null; triggerText = formatBytes(r.decodedBodySize); content = ( ); } return (
{triggerText}
); } export function renderDuration(r: any) { const { t } = useTranslation(); if (!r.success) return 'x'; const text = `${Math.floor(r.duration)}ms`; if (!r.isRed && !r.isYellow) return text; let tooltipText; if (r.isYellow) { tooltipText = t('Slower than average'); } else { tooltipText = t('Much slower than average'); } return (
{text}
); } function renderStatus({ status, cached, error, }: { status: string; cached: boolean; error?: string; }) { const { t } = useTranslation(); const displayedStatus = error ? (
{error}
) : ( status ); return ( <> {cached ? (
{displayedStatus}
) : ( displayedStatus )} ); } // Main component for Network Panel function NetworkPanelCont({ panelHeight }: { panelHeight: number }) { const { player, store } = React.useContext(PlayerContext); const { sessionStore, uiPlayerStore } = useStore(); const { startedAt } = sessionStore.current; const { domContentLoadedTime, loadTime, domBuildingTime, tabStates, currentTab, tabNames, } = store.get(); const tabsArr = Object.keys(tabStates); const tabValues = Object.values(tabStates); const { dataSource } = uiPlayerStore; const showSingleTab = dataSource === 'current'; let fetchList = []; let resourceList = []; let fetchListNow = []; let resourceListNow = []; let websocketList = []; let websocketListNow = []; if (showSingleTab) { const state = tabStates[currentTab] ?? {}; fetchList = state.fetchList ?? []; resourceList = state.resourceList ?? []; fetchListNow = state.fetchListNow ?? []; resourceListNow = state.resourceListNow ?? []; websocketList = state.websocketList ?? []; websocketListNow = state.websocketListNow ?? []; } else { fetchList = tabValues.flatMap((tab) => tab.fetchList); resourceList = tabValues.flatMap((tab) => tab.resourceList); fetchListNow = tabValues.flatMap((tab) => tab.fetchListNow).filter(Boolean); resourceListNow = tabValues .flatMap((tab) => tab.resourceListNow) .filter(Boolean); websocketList = tabValues.flatMap((tab) => tab.websocketList); websocketListNow = tabValues .flatMap((tab) => tab.websocketListNow) .filter(Boolean); } const getTabNum = (tab: string) => tabsArr.findIndex((t) => t === tab) + 1; const getTabName = (tabId: string) => tabNames[tabId]; return ( ); } function MobileNetworkPanelCont({ panelHeight }: { panelHeight: number }) { const { player, store } = React.useContext(MobilePlayerContext); const { uiPlayerStore, sessionStore } = useStore(); const { startedAt } = sessionStore.current; const zoomEnabled = uiPlayerStore.timelineZoom.enabled; const zoomStartTs = uiPlayerStore.timelineZoom.startTs; const zoomEndTs = uiPlayerStore.timelineZoom.endTs; const domContentLoadedTime = undefined; const loadTime = undefined; const domBuildingTime = undefined; const { fetchList = [], resourceList = [], fetchListNow = [], resourceListNow = [], websocketList = [], websocketListNow = [], } = store.get(); return ( ); } const useInfiniteScroll = (loadMoreCallback: () => void, hasMore: boolean) => { const observerRef = useRef(null); const loadingRef = useRef(null); useEffect(() => { const observer = new IntersectionObserver( (entries) => { if (entries[0]?.isIntersecting && hasMore) { loadMoreCallback(); } }, { threshold: 0.1 }, ); if (loadingRef.current) { observer.observe(loadingRef.current); } // @ts-ignore observerRef.current = observer; return () => { if (observerRef.current) { observerRef.current.disconnect(); } }; }, [loadMoreCallback, hasMore, loadingRef]); return loadingRef; }; interface Props { 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; isSpot?: boolean; getTabNum?: (tab: string) => number; getTabName?: (tabId: string) => string; showSingleTab?: boolean; } export const NetworkPanelComp = observer( ({ loadTime, domBuildingTime, domContentLoadedTime, fetchList, resourceList, fetchListNow, resourceListNow, player, startedAt, isMobile, panelHeight, websocketList, zoomEnabled, zoomStartTs, zoomEndTs, onClose, activeOutsideIndex, isSpot, getTabNum, showSingleTab, getTabName, }: Props) => { const { t } = useTranslation(); const [selectedWsChannel, setSelectedWsChannel] = React.useState< WsChannel[] | null >(null); const { showModal } = useModal(); const [showOnlyErrors, setShowOnlyErrors] = useState(false); const [isDetailsModalActive, setIsDetailsModalActive] = useState(false); const [isLoading, setIsLoading] = useState(true); const [isProcessing, setIsProcessing] = useState(false); const [displayedItems, setDisplayedItems] = useState([]); const [totalItems, setTotalItems] = useState(0); const [summaryStats, setSummaryStats] = useState({ resourcesSize: 0, transferredSize: 0, }); const originalListRef = useRef([]); const socketListRef = useRef([]); const { sessionStore: { devTools }, } = useStore(); const { filter } = devTools[INDEX_KEY]; const { activeTab } = devTools[INDEX_KEY]; const activeIndex = activeOutsideIndex ?? devTools[INDEX_KEY].index; const [inputFilterValue, setInputFilterValue] = useState(filter); const debouncedFilter = useCallback( debounceCall((filterValue) => { devTools.update(INDEX_KEY, { filter: filterValue }); }, 300), [], ); // Process socket lists once useEffect(() => { const uniqueSocketList = websocketList.filter( (ws, i, arr) => arr.findIndex((it) => it.channelName === ws.channelName) === i, ); socketListRef.current = uniqueSocketList; }, [websocketList.length]); // Initial data processing - do this only once when data changes useEffect(() => { setIsLoading(true); // Heaviest operation here, will create a final merged network list const processData = async () => { const fetchUrls = new Set( fetchList.map((ft) => { return `${ft.name}-${Math.floor(ft.time / 100)}-${Math.floor(ft.duration / 100)}`; }), ); // We want to get resources that aren't in fetch list const filteredResources = await processInChunks(resourceList, (chunk) => chunk.filter((res: any) => { const key = `${res.name}-${Math.floor(res.time / 100)}-${Math.floor(res.duration / 100)}`; return !fetchUrls.has(key); }), BATCH_SIZE, 25, ); const processedSockets = socketListRef.current.map((ws: any) => ({ ...ws, type: 'websocket', method: 'ws', url: ws.channelName, name: ws.channelName, status: '101', duration: 0, transferredBodySize: 0, })); const mergedList: Timed[] = mergeListsWithZoom( filteredResources as Timed[], fetchList, processedSockets as Timed[], { enabled: Boolean(zoomEnabled), start: zoomStartTs ?? 0, end: zoomEndTs ?? 0 } ) originalListRef.current = mergedList; setTotalItems(mergedList.length); calculateResourceStats(resourceList); // Only display initial chunk setDisplayedItems(mergedList.slice(0, INITIAL_LOAD_SIZE)); setIsLoading(false); }; void processData(); }, [ resourceList.length, fetchList.length, socketListRef.current.length, zoomEnabled, zoomStartTs, zoomEndTs, ]); const calculateResourceStats = (resourceList: Record) => { setTimeout(() => { let resourcesSize = 0 let transferredSize = 0 resourceList.forEach(({ decodedBodySize, headerSize, encodedBodySize }: any) => { resourcesSize += decodedBodySize || 0 transferredSize += (headerSize || 0) + (encodedBodySize || 0) }) setSummaryStats({ resourcesSize, transferredSize, }); }, 0); } useEffect(() => { if (originalListRef.current.length === 0) return; setIsProcessing(true); const applyFilters = async () => { let filteredItems: any[] = originalListRef.current; filteredItems = await processInChunks(filteredItems, (chunk) => chunk.filter( (it) => { let valid = true; if (showOnlyErrors) { valid = parseInt(it.status) >= 400 || !it.success || it.error } if (filter) { try { const regex = new RegExp(filter, 'i'); valid = valid && regex.test(it.status) || regex.test(it.name) || regex.test(it.type) || regex.test(it.method); } catch (e) { valid = valid && String(it.status).includes(filter) || it.name.includes(filter) || it.type.includes(filter) || (it.method && it.method.includes(filter)); } } if (activeTab !== ALL) { valid = valid && TYPE_TO_TAB[it.type] === activeTab; } return valid; }, ), ); // Update displayed items setDisplayedItems(filteredItems.slice(0, INITIAL_LOAD_SIZE)); setTotalItems(filteredItems.length); setIsProcessing(false); }; void applyFilters(); }, [filter, activeTab, showOnlyErrors]); const loadMoreItems = useCallback(() => { if (isProcessing) return; setIsProcessing(true); setTimeout(() => { setDisplayedItems((prevItems) => { const currentLength = prevItems.length; const newItems = originalListRef.current.slice( currentLength, currentLength + BATCH_SIZE, ); return [...prevItems, ...newItems]; }); setIsProcessing(false); }, 10); }, [isProcessing]); const hasMoreItems = displayedItems.length < totalItems; const loadingRef = useInfiniteScroll(loadMoreItems, hasMoreItems); const onTabClick = (activeTab) => { devTools.update(INDEX_KEY, { activeTab }); }; const onFilterChange = ({ target: { value } }) => { setInputFilterValue(value) debouncedFilter(value); }; const [timeoutStartAutoscroll, stopAutoscroll] = useAutoscroll( displayedItems, getLastItemTime(fetchListNow, resourceListNow), activeIndex, (index) => devTools.update(INDEX_KEY, { index }), ); const onMouseEnter = () => stopAutoscroll; const onMouseLeave = () => { if (isDetailsModalActive) { return; } timeoutStartAutoscroll(); }; 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 setSelectedWsChannel(socketMsgList); } setIsDetailsModalActive(true); showModal( 0} />, { right: true, width: 500, onClose: () => { setIsDetailsModalActive(false); timeoutStartAutoscroll(); }, }, ); devTools.update(INDEX_KEY, { index: displayedItems.indexOf(item) }); stopAutoscroll(); }; const tableCols = useMemo(() => { const cols = [ { label: t('Status'), dataKey: 'status', width: 90, render: renderStatus, }, { label: t('Type'), dataKey: 'type', width: 90, render: renderType, }, { label: t('Method'), width: 80, dataKey: 'method', }, { label: t('Name'), width: 240, dataKey: 'name', render: renderName, }, { label: t('Size'), width: 80, dataKey: 'decodedBodySize', render: renderSize, hidden: activeTab === XHR, }, { label: t('Duration'), width: 80, dataKey: 'duration', render: renderDuration, }, ]; if (!showSingleTab && !isSpot) { cols.unshift({ label: t('Source'), width: 64, render: (r: Record) => (
{getTabNum?.(r.tabId) ?? 0}
), }); } return cols; }, [showSingleTab, activeTab, t, getTabName, getTabNum, isSpot]); return (
{t('Network')} {isMobile ? null : ( )}
{!isMobile && !isSpot ? : null} } />
{isProcessing && ( Processing data... )}
0} /> 0} />
{isLoading ? (

Processing initial network data...

) : ( {t('No Data')} } size="small" show={displayedItems.length === 0} >
{ devTools.update(INDEX_KEY, { index: displayedItems.indexOf(row), }); player.jump(row.time); }} activeIndex={activeIndex} > {tableCols} {hasMoreItems && (
Loading more data ({totalItems - displayedItems.length}{' '} remaining)
)}
{selectedWsChannel ? ( setSelectedWsChannel(null)} /> ) : null}
)}
); }, ); const WebNetworkPanel = observer(NetworkPanelCont); const MobileNetworkPanel = observer(MobileNetworkPanelCont); export { WebNetworkPanel, MobileNetworkPanel };