import { useEffect, useMemo, useRef, useState } from 'react'; import type { CSSProperties, PointerEvent } from 'react'; import { X } from 'lucide-react'; import type { RequestId } from '../../shared/client'; import type { ProcessedRequest } from '../state/model'; import { useNetworkActivityActions, useSelectedRequestId, } from '../state/hooks'; import { formatTimelineOffset, getTimelineBarTopOffset, getTimelineModel, getTimelineTrackTop, isRequestActive, TIMELINE_LAYOUT, } from '../utils/timelineModel'; import type { TimelineModel, TimelineRangeSelection, TimelineRow, TimelineTick, } from '../utils/timelineModel'; const REQUEST_TIMELINE_COLORS = { error: 'bg-red-400', primary: 'bg-gray-400', active: 'bg-gray-500', httpTtfb: 'bg-gray-200', } as const; const getPrimaryBarClassName = (request: ProcessedRequest) => { if (request.status === 'failed' || request.status === 'error') { return REQUEST_TIMELINE_COLORS.error; } return REQUEST_TIMELINE_COLORS.primary; }; const getStyle = ( offsetPercent: number, widthPercent: number, ): CSSProperties => ({ left: `${offsetPercent}%`, width: `${widthPercent}%`, }); const GridLines = ({ ticks }: { ticks: TimelineTick[] }) => { return (
{ticks.map((tick) => (
))}
); }; const getTickLabelStyle = (tick: TimelineTick): CSSProperties => { if (tick.offsetPercent === 0) { return { left: 4, }; } if (tick.offsetPercent === 100) { return { right: 4, }; } return { left: `${tick.offsetPercent}%`, }; }; const TimelineTrack = ({ row, isSelected, onSelect, shouldSuppressSelect, }: { row: TimelineRow; isSelected: boolean; onSelect: (requestId: RequestId) => void; shouldSuppressSelect: () => boolean; }) => { const primaryBarClassName = row.isActive ? REQUEST_TIMELINE_COLORS.active : getPrimaryBarClassName(row.request); const isSplitHttpBar = row.request.type === 'http' && row.ttfbPercent > 0 && row.receivePercent > 0; const trackTop = getTimelineTrackTop(row.lane); const barTop = getTimelineBarTopOffset(); const positionStyle = { ...getStyle(row.offsetPercent, row.widthPercent), top: trackTop, }; const durationLabel = row.isActive ? `${formatTimelineOffset(row.duration)}+` : formatTimelineOffset(row.duration); const label = `${row.request.method} ${row.request.name} - ${durationLabel}`; return ( ); }; type DraftSelection = { anchorPercent: number; currentPercent: number; startedOnTrack: boolean; }; const clampPercent = (value: number) => Math.min(Math.max(value, 0), 100); const getPointerPercent = ( event: PointerEvent, element: HTMLDivElement, ) => { const rect = element.getBoundingClientRect(); if (rect.width === 0) { return 0; } return clampPercent(((event.clientX - rect.left) / rect.width) * 100); }; const getSelectionStyle = ( range: TimelineRangeSelection, timeline: TimelineModel, ): CSSProperties => { const startPercent = clampPercent( ((range.startTime - timeline.rangeStart) / timeline.rangeDuration) * 100, ); const endPercent = clampPercent( ((range.endTime - timeline.rangeStart) / timeline.rangeDuration) * 100, ); const left = Math.min(startPercent, endPercent); const width = Math.abs(endPercent - startPercent); return { left: `${left}%`, width: `${width}%`, top: TIMELINE_LAYOUT.rulerHeightPx, }; }; const getDraftSelectionStyle = (draft: DraftSelection): CSSProperties => { const left = Math.min(draft.anchorPercent, draft.currentPercent); const width = Math.abs(draft.currentPercent - draft.anchorPercent); return { left: `${left}%`, width: `${width}%`, top: TIMELINE_LAYOUT.rulerHeightPx, }; }; export type NetworkTimelineProps = { requests: ProcessedRequest[]; selection: TimelineRangeSelection | null; filteredRequestCount: number; onSelectionChange: (selection: TimelineRangeSelection | null) => void; }; export const NetworkTimeline = ({ requests, selection, filteredRequestCount, onSelectionChange, }: NetworkTimelineProps) => { const actions = useNetworkActivityActions(); const selectedRequestId = useSelectedRequestId(); const [now, setNow] = useState(() => Date.now()); const [draftSelection, setDraftSelection] = useState( null, ); const chartRef = useRef(null); const suppressTrackClickRef = useRef(false); const hasActiveRequests = requests.some(isRequestActive); useEffect(() => { if (!hasActiveRequests) { return; } const interval = window.setInterval(() => { setNow(Date.now()); }, TIMELINE_LAYOUT.liveRefreshMs); return () => window.clearInterval(interval); }, [hasActiveRequests]); const timeline = useMemo(() => { return getTimelineModel(requests, now); }, [requests, now]); const onRequestSelect = (requestId: RequestId) => { actions.setSelectedRequest(requestId); }; const onPointerDown = (event: PointerEvent) => { if (event.button !== 0 || requests.length === 0) { return; } const chartElement = chartRef.current; if (!chartElement) { return; } const percent = getPointerPercent(event, chartElement); const target = event.target; const startedOnTrack = target instanceof Element && target.closest('[data-timeline-track="true"]') !== null; setDraftSelection({ anchorPercent: percent, currentPercent: percent, startedOnTrack, }); chartElement.setPointerCapture(event.pointerId); }; const onPointerMove = (event: PointerEvent) => { if (!draftSelection) { return; } const chartElement = chartRef.current; if (!chartElement) { return; } event.preventDefault(); const percent = getPointerPercent(event, chartElement); setDraftSelection((current) => current ? { ...current, currentPercent: percent } : current, ); }; const onPointerUp = (event: PointerEvent) => { if (!draftSelection) { return; } const chartElement = chartRef.current; const currentPercent = chartElement ? getPointerPercent(event, chartElement) : draftSelection.currentPercent; const distance = Math.abs(currentPercent - draftSelection.anchorPercent); if (distance > 1) { const startOffset = (Math.min(draftSelection.anchorPercent, currentPercent) / 100) * timeline.rangeDuration; const endOffset = (Math.max(draftSelection.anchorPercent, currentPercent) / 100) * timeline.rangeDuration; onSelectionChange({ startTime: timeline.rangeStart + startOffset, endTime: timeline.rangeStart + endOffset, }); suppressTrackClickRef.current = true; window.setTimeout(() => { suppressTrackClickRef.current = false; }, 0); } else if (!draftSelection.startedOnTrack) { onSelectionChange(null); } setDraftSelection(null); if (chartElement?.hasPointerCapture(event.pointerId)) { chartElement.releasePointerCapture(event.pointerId); } }; return (
{timeline.ticks.map((tick) => (
{tick.label}
))} {selection && (
)} {draftSelection && (
)} {timeline.rows.map((row) => ( suppressTrackClickRef.current} /> ))} {selection && (
{filteredRequestCount} in range
)} {timeline.hiddenRequestCount > 0 && (
Showing latest {timeline.rows.length} of{' '} {timeline.totalRequestCount}
)}
); };