import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { convertMetricValue, generateEventMarkers, getBoundRelativeTimes, ingestFramerateTooltipFormatter, ingestVideoBitrateTooltipFormatter, processMetricData } from './utils'; import { INGEST_FRAMERATE, INGEST_VIDEO_BITRATE, NO_DATA_VALUE } from '../../../../../constants'; import { useSynchronizedCharts, ZOOM_LEVELS } from '../../../../../contexts/SynchronizedCharts'; import { bound, clsm } from '../../../../../utils'; import { dashboard as $dashboardContent } from '../../../../../content'; import { formatTime } from '../../../../../hooks/useDateTime'; import { processEvents } from '../StreamEvents/utils'; import { useStreams } from '../../../../../contexts/Streams'; import MetricPanel from '../MetricPanel'; import ResponsiveChart from './Chart'; import usePrevious from '../../../../../hooks/usePrevious'; import ZoomButtons from './ZoomButtons'; import ZoomSlider from './ZoomSlider'; const $content = $dashboardContent.stream_session_page.charts; const UNITS = { [INGEST_FRAMERATE]: 'fps', [INGEST_VIDEO_BITRATE]: 'mbps' }; const Charts = () => { const chartsRef = useRef(); const { activeStreamSession, hasActiveStreamChanged, fetchActiveStreamSessionError, isLoadingStreamData } = useStreams(); const { handleSynchronizedTooltips, isTooltipOpen, selectedZoomLevel, setZoomBounds, setSelectedZoomLevel, zoomBounds } = useSynchronizedCharts(); const { isLive, metrics, truncatedEvents } = activeStreamSession || {}; const { IngestVideoBitrate: ingestVideoBitrateData = {}, IngestFramerate: ingestFramerateData = {} } = metrics?.reduce((dataSet, metric) => { const { label } = metric; if (label === INGEST_VIDEO_BITRATE || label === INGEST_FRAMERATE) { dataSet[label] = metric; } return dataSet; }, {}) || {}; const [dataLength, setDataLength] = useState( ingestVideoBitrateData?.data?.length || 1 ); const isMetricDataAvailable = !isLoadingStreamData && !fetchActiveStreamSessionError && dataLength > 1; const prevDataLength = usePrevious(dataLength); const dataPeriod = ingestVideoBitrateData?.period || 0; const eventsToDisplay = useMemo(() => { const [relativeStartTime, relativeEndTime] = getBoundRelativeTimes( [0, dataLength - 1], ingestVideoBitrateData.alignedStartTime, dataPeriod ); if (truncatedEvents?.length) { const streamEvents = isLive ? processEvents(truncatedEvents).reverse() : processEvents(truncatedEvents); return streamEvents.reduce( (acc, { error, eventTime, originalName: name }) => { const relativeEventTime = new Date(eventTime).getTime(); const hasRequiredProps = relativeEventTime && name; const zoomEventIndex = ((relativeEventTime - relativeStartTime) / (relativeEndTime - relativeStartTime)) * (dataLength - 1); /** * We include any type of error event even if they're not visible. * That includes starvation events so we can show gradients in between non-visible events */ if (hasRequiredProps && (error || name === 'Starvation End')) { return [...acc, { zoomEventIndex, relativeEventTime, name }]; } return acc; }, [] ); } return []; }, [ dataLength, dataPeriod, ingestVideoBitrateData.alignedStartTime, isLive, truncatedEvents ]); const eventMarkers = useMemo( () => generateEventMarkers(eventsToDisplay), [eventsToDisplay] ); const getChartMetricPanelProps = useCallback( (metricData) => { let zoomStart, zoomEnd, currentValue; if (isMetricDataAvailable) { const [relativeStartTime, relativeEndTime] = getBoundRelativeTimes( zoomBounds, ingestVideoBitrateData.alignedStartTime, dataPeriod ); zoomStart = formatTime(relativeStartTime, null, false); zoomEnd = zoomBounds[1] === dataLength - 1 && isLive ? $content.now : formatTime(relativeEndTime, null, false); if (metricData?.data?.length && isLive) { currentValue = metricData.data[metricData.data.length - 1]; } else if (metricData?.statistics?.average && !isLive) { currentValue = metricData.statistics.average; } currentValue = convertMetricValue(currentValue, metricData.label); currentValue = typeof currentValue === 'number' ? `${ metricData.label === INGEST_VIDEO_BITRATE ? currentValue.toFixed(1) : currentValue.toFixed(0) } ${UNITS[metricData.label]}` : NO_DATA_VALUE; } else { zoomStart = zoomEnd = currentValue = NO_DATA_VALUE; } const hasData = metricData?.data?.length >= 2; const isWaitingForData = activeStreamSession && !isLoadingStreamData && !fetchActiveStreamSessionError && isLive && // Ingest framerate data sometimes comes after video bitrate !hasData; const isChartLoading = isLoadingStreamData || isWaitingForData; const footerZoomBoundsProps = [ { children: zoomStart, dataTestId: 'chart-zoom-start-time' }, { children: zoomEnd, dataTestId: 'chart-zoom-end-time' } ]; return { hasData, isLoading: isChartLoading, wrapper: { className: 'h-[100px]' }, header: ( <h2 className={clsm(['dark:text-white', 'text-lightMode-gray-dark'])}> {currentValue} </h2> ), footer: footerZoomBoundsProps.map(({ children, dataTestId }) => ( <p className={clsm([ 'dark:text-darkMode-gray-light', 'text-lightMode-gray-medium', 'text-p2' ])} data-testid={dataTestId} key={dataTestId} > {children} </p> )) }; }, [ activeStreamSession, dataLength, dataPeriod, fetchActiveStreamSessionError, ingestVideoBitrateData.alignedStartTime, isLive, isLoadingStreamData, isMetricDataAvailable, zoomBounds ] ); const renderChart = useCallback( (Chart) => { if (!activeStreamSession) return null; return Chart; }, [activeStreamSession] ); const updateSelectedZoom = useCallback( (zoomAmountInSeconds) => { const newDataLength = ingestVideoBitrateData?.data?.length || 1; setDataLength(newDataLength); if (zoomAmountInSeconds === -1) { setZoomBounds([0, newDataLength - 1]); setSelectedZoomLevel(parseInt(zoomAmountInSeconds, 10)); return; } const numOfDatapoints = bound(zoomAmountInSeconds / dataPeriod, 1); const lowerBound = newDataLength - 1 - numOfDatapoints; setZoomBounds(() => [ bound(lowerBound, 0, newDataLength - 1), bound(newDataLength - 1, 0, newDataLength - 1) ]); setSelectedZoomLevel(parseInt(zoomAmountInSeconds, 10)); }, [ dataPeriod, ingestVideoBitrateData?.data?.length, setSelectedZoomLevel, setZoomBounds ] ); const handleSelectZoom = useCallback( ({ target: { value: zoomAmountInSeconds } }) => { updateSelectedZoom(parseInt(zoomAmountInSeconds, 10)); }, [updateSelectedZoom] ); // Update the zoom bounds and zoom level when the stream changes or when the stream status changes (live to offline) useEffect(() => { if (hasActiveStreamChanged || !isLive) { updateSelectedZoom(isLive ? ZOOM_LEVELS.FIVE_MIN : ZOOM_LEVELS.ALL); } }, [hasActiveStreamChanged, isLive, updateSelectedZoom]); // Update the initial zoom bounds and zoom level when new metrics data is fetched useEffect(() => { const newDataLength = ingestVideoBitrateData?.data?.length || 1; setDataLength(newDataLength); if (isMetricDataAvailable) { setZoomBounds((prevBounds) => { const [prevLowerBound, prevUpperBound] = prevBounds; // When the stream is live default to the last 5 mins if (prevUpperBound === 0 && isLive) { const numOfDatapoints = bound(ZOOM_LEVELS.FIVE_MIN / dataPeriod, 1); const lowerBound = newDataLength - 1 - numOfDatapoints; return [bound(lowerBound, 0), newDataLength - 1]; // When the stream is offline default to the entire dataset } else if (prevUpperBound === 0) { return [0, newDataLength - 1]; } else if ( prevDataLength < newDataLength && prevUpperBound === prevDataLength - 1 ) { const newUpperBound = newDataLength - 1; let newLowerBound = prevLowerBound; if (prevLowerBound > 0) { const offset = newDataLength - prevDataLength; newLowerBound += offset; } return [newLowerBound, newUpperBound]; } return prevBounds; }); } }, [ dataPeriod, ingestVideoBitrateData?.data?.length, isLive, isMetricDataAvailable, prevDataLength, setZoomBounds ]); useEffect(() => { if (isMetricDataAvailable && isTooltipOpen) { handleSynchronizedTooltips(); } }, [ ingestVideoBitrateData, isMetricDataAvailable, handleSynchronizedTooltips, isTooltipOpen ]); return ( <div className={clsm([ 'flex-col', 'flex', 'h-full', 'md:px-4', 'md:py-0', 'p-8', 'pr-0', 'w-full' ])} ref={chartsRef} > <MetricPanel {...getChartMetricPanelProps(ingestVideoBitrateData)} title={$content.video_bitrate} className={clsm(['mb-11', 'md:mb-8'])} > {renderChart( <ResponsiveChart eventMarkers={eventMarkers} initialData={processMetricData(ingestVideoBitrateData)} formatter={ingestVideoBitrateTooltipFormatter} zoomBounds={zoomBounds} /> )} </MetricPanel> <MetricPanel {...getChartMetricPanelProps(ingestFramerateData)} title={$content.frame_rate} className={clsm(['mb-8', 'md:mb-4'])} > {renderChart( <ResponsiveChart eventMarkers={eventMarkers} initialData={processMetricData(ingestFramerateData)} formatter={ingestFramerateTooltipFormatter} zoomBounds={zoomBounds} /> )} </MetricPanel> <div className={clsm([ 'flex-col', 'flex', 'items-center', 'md:mr-0', 'space-y-8' ])} > <ZoomSlider chartsRef={chartsRef} dataLength={dataLength} eventsToDisplay={eventsToDisplay} isEnabled={isMetricDataAvailable} setZoomBounds={setZoomBounds} setSelectedZoomLevel={setSelectedZoomLevel} zoomBounds={zoomBounds} /> <ZoomButtons handleSelectZoom={handleSelectZoom} isEnabled={isMetricDataAvailable} selectedZoomLevel={selectedZoomLevel} /> </div> </div> ); }; export default Charts;