diff --git a/web/package-lock.json b/web/package-lock.json index 2bcb1b0ac0..98056c00a4 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -60,6 +60,7 @@ "react-scripts": "5.0.1", "react-spaces": "0.3.8", "react-syntax-highlighter": "15.5.0", + "react-window": "1.8.10", "redux-first-history": "5.0.12", "styled-components": "5.3.3", "typescript": "5.0.2" @@ -77,6 +78,7 @@ "@types/lodash": "4.14.181", "@types/postman-collection": "3.5.7", "@types/react-syntax-highlighter": "15.5.7", + "@types/react-window": "1.8.8", "@types/styled-components": "5.1.21", "concurrently": "7.2.1", "cypress": "13.2.0", @@ -7535,6 +7537,15 @@ "@types/react": "*" } }, + "node_modules/@types/react-window": { + "version": "1.8.8", + "resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.8.tgz", + "integrity": "sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/resolve": { "version": "1.17.1", "license": "MIT", @@ -21170,6 +21181,27 @@ "react": ">= 0.14.0" } }, + "node_modules/react-window": { + "version": "1.8.10", + "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.10.tgz", + "integrity": "sha512-Y0Cx+dnU6NLa5/EvoHukUD0BklJ8qITCtVEPY1C/nL8wwoZ0b5aEw8Ff1dOVHw7fCzMt55XfJDd8S8W8LCaUCg==", + "dependencies": { + "@babel/runtime": "^7.0.0", + "memoize-one": ">=3.1.1 <6" + }, + "engines": { + "node": ">8.0.0" + }, + "peerDependencies": { + "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/react-window/node_modules/memoize-one": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==" + }, "node_modules/readable-stream": { "version": "3.6.0", "license": "MIT", diff --git a/web/package.json b/web/package.json index b641c4f128..b00bf59f45 100644 --- a/web/package.json +++ b/web/package.json @@ -56,6 +56,7 @@ "react-scripts": "5.0.1", "react-spaces": "0.3.8", "react-syntax-highlighter": "15.5.0", + "react-window": "1.8.10", "redux-first-history": "5.0.12", "styled-components": "5.3.3", "typescript": "5.0.2" @@ -122,6 +123,7 @@ "@types/lodash": "4.14.181", "@types/postman-collection": "3.5.7", "@types/react-syntax-highlighter": "15.5.7", + "@types/react-window": "1.8.8", "@types/styled-components": "5.1.21", "concurrently": "7.2.1", "cypress": "13.2.0", diff --git a/web/src/components/RunDetailTrace/Visualization.tsx b/web/src/components/RunDetailTrace/Visualization.tsx index 7c5ca645f5..b0d4fc8419 100644 --- a/web/src/components/RunDetailTrace/Visualization.tsx +++ b/web/src/components/RunDetailTrace/Visualization.tsx @@ -6,11 +6,10 @@ import {useCallback, useEffect} from 'react'; import {useAppDispatch, useAppSelector} from 'redux/hooks'; import {selectSpan} from 'redux/slices/Trace.slice'; import TraceSelectors from 'selectors/Trace.selectors'; -import TraceAnalyticsService from 'services/Analytics/TestRunAnalytics.service'; import TestRunService from 'services/TestRun.service'; import Trace from 'models/Trace.model'; import {TTestRunState} from 'types/TestRun.types'; -import Timeline from '../Visualization/components/Timeline'; +import TimelineV2 from 'components/Visualization/components/Timeline/TimelineV2'; import {VisualizationType} from './RunDetailTrace'; import TraceDAG from './TraceDAG'; @@ -24,9 +23,7 @@ interface IProps { const Visualization = ({isDAGDisabled, runEvents, runState, trace, trace: {spans, rootSpan}, type}: IProps) => { const dispatch = useAppDispatch(); - const matchedSpans = useAppSelector(TraceSelectors.selectMatchedSpans); const selectedSpan = useAppSelector(TraceSelectors.selectSelectedSpan); - const isMatchedMode = Boolean(matchedSpans.length); useEffect(() => { if (selectedSpan) return; @@ -34,14 +31,6 @@ const Visualization = ({isDAGDisabled, runEvents, runState, trace, trace: {spans dispatch(selectSpan({spanId: rootSpan.id ?? ''})); }, [dispatch, rootSpan.id, selectedSpan, spans]); - const onNodeClickTimeline = useCallback( - (spanId: string) => { - TraceAnalyticsService.onTimelineSpanClick(spanId); - dispatch(selectSpan({spanId})); - }, - [dispatch] - ); - const onNavigateToSpan = useCallback( (spanId: string) => { dispatch(selectSpan({spanId})); @@ -56,15 +45,7 @@ const Visualization = ({isDAGDisabled, runEvents, runState, trace, trace: {spans return type === VisualizationType.Dag && !isDAGDisabled ? ( ) : ( - + ); }; diff --git a/web/src/components/Visualization/components/DAG/TestSpanNode/TestSpanNode.tsx b/web/src/components/Visualization/components/DAG/TestSpanNode/TestSpanNode.tsx index 8dccceee61..cf57b0ada0 100644 --- a/web/src/components/Visualization/components/DAG/TestSpanNode/TestSpanNode.tsx +++ b/web/src/components/Visualization/components/DAG/TestSpanNode/TestSpanNode.tsx @@ -9,7 +9,7 @@ import useSelectAsCurrent from '../../../hooks/useSelectAsCurrent'; interface IProps extends NodeProps {} -const TestSpanNode = ({data, id, selected, ...props}: IProps) => { +const TestSpanNode = ({data, id, selected}: IProps) => { const {span, testSpecs, testOutputs} = useSpanData(id); const {isLoading, onSelectAsCurrent, showSelectAsCurrent} = useSelectAsCurrent({ selected, diff --git a/web/src/components/Visualization/components/Timeline/BaseSpanNode/BaseSpanNodeV2.tsx b/web/src/components/Visualization/components/Timeline/BaseSpanNode/BaseSpanNodeV2.tsx new file mode 100644 index 0000000000..17edffd5d8 --- /dev/null +++ b/web/src/components/Visualization/components/Timeline/BaseSpanNode/BaseSpanNodeV2.tsx @@ -0,0 +1,66 @@ +import Span from 'models/Span.model'; +import Connector from './ConnectorV2'; +import {IPropsComponent} from '../SpanNodeFactoryV2'; +import {useTimeline} from '../Timeline.provider'; +import * as S from '../TimelineV2.styled'; + +const BaseLeftPadding = 16; // TODO: Move to Timeline.constants + +function toPercent(value: number) { + return `${(value * 100).toFixed(1)}%`; +} + +function getHintSide(viewStart: number, viewEnd: number) { + return viewStart > 1 - viewEnd ? 'left' : 'right'; +} + +interface IProps extends IPropsComponent { + header?: React.ReactNode; + span: Span; +} + +const BaseSpanNode = ({header, index, node, span, style}: IProps) => { + const {collapsedSpans, getScale, matchedSpans, onSpanCollapse, onSpanClick, selectedSpan} = useTimeline(); + const {start: viewStart, end: viewEnd} = getScale(span.startTime, span.endTime); + const hintSide = getHintSide(viewStart, viewEnd); + const isSelected = selectedSpan === node.data.id; + const isMatched = matchedSpans.includes(node.data.id); + const isCollapsed = collapsedSpans.includes(node.data.id); + const leftPadding = node.depth * BaseLeftPadding; + + return ( +
+ onSpanClick(node.data.id)} + $isEven={index % 2 === 0} + $isMatched={isMatched} + $isSelected={isSelected} + > + + + + + {span.name} + + + + + + + + {span.duration} + + + +
+ ); +}; + +export default BaseSpanNode; diff --git a/web/src/components/Visualization/components/Timeline/BaseSpanNode/ConnectorV2.tsx b/web/src/components/Visualization/components/Timeline/BaseSpanNode/ConnectorV2.tsx new file mode 100644 index 0000000000..650db16451 --- /dev/null +++ b/web/src/components/Visualization/components/Timeline/BaseSpanNode/ConnectorV2.tsx @@ -0,0 +1,47 @@ +import * as S from '../TimelineV2.styled'; + +interface IProps { + hasParent: boolean; + id: string; + isCollapsed: boolean; + leftPadding: number; + onCollapse(id: string): void; + totalChildren: number; +} + +const Connector = ({hasParent, id, isCollapsed, leftPadding, onCollapse, totalChildren}: IProps) => ( + + {hasParent && ( + <> + + + + )} + + {totalChildren > 0 ? ( + <> + {!isCollapsed && } + + + {totalChildren} + + { + event.stopPropagation(); + onCollapse(id); + }} + /> + + ) : ( + + )} + +); + +export default Connector; diff --git a/web/src/components/Visualization/components/Timeline/Header.tsx b/web/src/components/Visualization/components/Timeline/Header.tsx new file mode 100644 index 0000000000..d5ff7cdc41 --- /dev/null +++ b/web/src/components/Visualization/components/Timeline/Header.tsx @@ -0,0 +1,22 @@ +import Ticks from './Ticks/Ticks'; +import * as S from './TimelineV2.styled'; + +const NUM_TICKS = 5; + +interface IProps { + duration: number; +} + +const Header = ({duration}: IProps) => ( + + + + Span + + + + + +); + +export default Header; diff --git a/web/src/components/Visualization/components/Timeline/ListWrapper.tsx b/web/src/components/Visualization/components/Timeline/ListWrapper.tsx new file mode 100644 index 0000000000..cda954bfb7 --- /dev/null +++ b/web/src/components/Visualization/components/Timeline/ListWrapper.tsx @@ -0,0 +1,33 @@ +import {FixedSizeList as List} from 'react-window'; +import Header from './Header'; +import SpanNodeFactory from './SpanNodeFactoryV2'; +import * as S from './TimelineV2.styled'; +import {useTimeline} from './Timeline.provider'; + +const HEADER_HEIGHT = 242; + +interface IProps { + listRef: React.RefObject; +} + +const ListWrapper = ({listRef}: IProps) => { + const {spans, viewEnd, viewStart} = useTimeline(); + + return ( + +
+ + {SpanNodeFactory} + + + ); +}; + +export default ListWrapper; diff --git a/web/src/components/Visualization/components/Timeline/NavigationWrapper.tsx b/web/src/components/Visualization/components/Timeline/NavigationWrapper.tsx new file mode 100644 index 0000000000..9f3c92ca47 --- /dev/null +++ b/web/src/components/Visualization/components/Timeline/NavigationWrapper.tsx @@ -0,0 +1,10 @@ +import Navigation from '../Navigation'; +import {useTimeline} from './Timeline.provider'; + +const NavigationWrapper = () => { + const {matchedSpans, onSpanNavigation, selectedSpan} = useTimeline(); + + return ; +}; + +export default NavigationWrapper; diff --git a/web/src/components/Visualization/components/Timeline/SpanNodeFactoryV2.tsx b/web/src/components/Visualization/components/Timeline/SpanNodeFactoryV2.tsx new file mode 100644 index 0000000000..9e6aed970c --- /dev/null +++ b/web/src/components/Visualization/components/Timeline/SpanNodeFactoryV2.tsx @@ -0,0 +1,30 @@ +import {NodeTypesEnum} from 'constants/Visualization.constants'; +import {TNode} from 'types/Timeline.types'; +// import TestSpanNode from './TestSpanNode/TestSpanNode'; +import TraceSpanNode from './TraceSpanNode/TraceSpanNodeV2'; + +export interface IPropsComponent { + index: number; + node: TNode; + style: React.CSSProperties; +} + +const ComponentMap: Record React.ReactElement> = { + [NodeTypesEnum.TestSpan]: TraceSpanNode, + [NodeTypesEnum.TraceSpan]: TraceSpanNode, +}; + +interface IProps { + data: TNode[]; + index: number; + style: React.CSSProperties; +} + +const SpanNodeFactory = ({data, ...props}: IProps) => { + const node = data[props.index]; + const Component = ComponentMap[node.type]; + + return ; +}; + +export default SpanNodeFactory; diff --git a/web/src/components/Visualization/components/Timeline/Ticks/Ticks.styled.ts b/web/src/components/Visualization/components/Timeline/Ticks/Ticks.styled.ts new file mode 100644 index 0000000000..0af2ed4f32 --- /dev/null +++ b/web/src/components/Visualization/components/Timeline/Ticks/Ticks.styled.ts @@ -0,0 +1,37 @@ +import {Typography} from 'antd'; +import styled, {css} from 'styled-components'; + +export const Ticks = styled.div` + pointer-events: none; + position: relative; +`; + +export const Tick = styled.div` + align-items: center; + background: ${({theme}) => theme.color.borderLight}; + display: flex; + height: 100%; + position: absolute; + width: 1px; + + :first-child, + :last-child { + width: 0; + } +`; + +export const TickLabel = styled(Typography.Text)<{$isEndAnchor: boolean}>` + color: ${({theme}) => theme.color.text}; + font-size: ${({theme}) => theme.size.sm}; + font-weight: 400; + left: 0.25rem; + position: absolute; + white-space: nowrap; + + ${({$isEndAnchor}) => + $isEndAnchor && + css` + left: initial; + right: 0.25rem; + `}; +`; diff --git a/web/src/components/Visualization/components/Timeline/Ticks/Ticks.tsx b/web/src/components/Visualization/components/Timeline/Ticks/Ticks.tsx new file mode 100644 index 0000000000..5387421b91 --- /dev/null +++ b/web/src/components/Visualization/components/Timeline/Ticks/Ticks.tsx @@ -0,0 +1,40 @@ +import * as React from 'react'; +import Date, {ONE_MILLISECOND} from 'utils/Date'; +import * as S from './Ticks.styled'; + +function getLabels(numTicks: number, startTime: number, endTime: number) { + const viewingDuration = endTime - startTime; + const labels = []; + + for (let i = 0; i < numTicks; i += 1) { + const durationAtTick = startTime + (i / (numTicks - 1)) * viewingDuration; + labels.push(Date.formatDuration(durationAtTick * ONE_MILLISECOND)); + } + + return labels; +} + +interface IProps { + endTime?: number; + numTicks: number; + startTime?: number; +} + +const Ticks = ({endTime = 0, numTicks, startTime = 0}: IProps) => { + const labels = getLabels(numTicks, startTime, endTime); + + return ( + + {labels.map((label, index) => { + const portion = index / (numTicks - 1); + return ( + + = 1}>{label} + + ); + })} + + ); +}; + +export default Ticks; diff --git a/web/src/components/Visualization/components/Timeline/Timeline.provider.tsx b/web/src/components/Visualization/components/Timeline/Timeline.provider.tsx new file mode 100644 index 0000000000..64a3c630c2 --- /dev/null +++ b/web/src/components/Visualization/components/Timeline/Timeline.provider.tsx @@ -0,0 +1,120 @@ +import {NodeTypesEnum} from 'constants/Visualization.constants'; +import noop from 'lodash/noop'; +import without from 'lodash/without'; +import Span from 'models/Span.model'; +import TimelineModel from 'models/Timeline.model'; +import {createContext, useCallback, useContext, useMemo, useState} from 'react'; +import {FixedSizeList as List} from 'react-window'; +import {useAppDispatch, useAppSelector} from 'redux/hooks'; +import {selectSpan} from 'redux/slices/Trace.slice'; +import TraceSelectors from 'selectors/Trace.selectors'; +import TraceAnalyticsService from 'services/Analytics/TestRunAnalytics.service'; +import TimelineService, {TScaleFunction} from 'services/Timeline.service'; +import {TNode} from 'types/Timeline.types'; + +interface IContext { + collapsedSpans: string[]; + getScale: TScaleFunction; + matchedSpans: string[]; + onSpanClick(spanId: string): void; + onSpanCollapse(spanId: string): void; + onSpanNavigation(spanId: string): void; + selectedSpan: string; + spans: TNode[]; + viewEnd: number; + viewStart: number; +} + +export const Context = createContext({ + collapsedSpans: [], + getScale: () => ({start: 0, end: 0}), + matchedSpans: [], + onSpanClick: noop, + onSpanCollapse: noop, + onSpanNavigation: noop, + selectedSpan: '', + spans: [], + viewEnd: 0, + viewStart: 0, +}); + +interface IProps { + children: React.ReactNode; + listRef: React.RefObject; + nodeType: NodeTypesEnum; + spans: Span[]; +} + +export const useTimeline = () => useContext(Context); + +const TimelineProvider = ({children, listRef, nodeType, spans}: IProps) => { + const dispatch = useAppDispatch(); + const [collapsedSpans, setCollapsedSpans] = useState([]); + const matchedSpans = useAppSelector(TraceSelectors.selectMatchedSpans); + const selectedSpan = useAppSelector(TraceSelectors.selectSelectedSpan); + + const nodes = useMemo(() => TimelineModel(spans, nodeType), [spans, nodeType]); + const filteredNodes = useMemo(() => TimelineService.getFilteredNodes(nodes, collapsedSpans), [collapsedSpans, nodes]); + const [min, max] = useMemo(() => TimelineService.getMinMax(nodes), [nodes]); + const getScale = useCallback(() => TimelineService.createScaleFunc({min, max}), [max, min]); + + const onSpanClick = useCallback( + (spanId: string) => { + TraceAnalyticsService.onTimelineSpanClick(spanId); + dispatch(selectSpan({spanId})); + }, + [dispatch] + ); + + const onSpanNavigation = useCallback( + (spanId: string) => { + dispatch(selectSpan({spanId})); + // TODO: Improve the method to search for the index + const index = filteredNodes.findIndex(node => node.data.id === spanId); + if (index !== -1) { + listRef?.current?.scrollToItem(index, 'start'); + } + }, + [dispatch, filteredNodes, listRef] + ); + + const onSpanCollapse = useCallback((spanId: string) => { + setCollapsedSpans(prevCollapsed => { + if (prevCollapsed.includes(spanId)) { + return without(prevCollapsed, spanId); + } + return [...prevCollapsed, spanId]; + }); + }, []); + + const value = useMemo( + () => ({ + collapsedSpans, + getScale: getScale(), + matchedSpans, + onSpanClick, + onSpanCollapse, + onSpanNavigation, + selectedSpan, + spans: filteredNodes, + viewEnd: max, + viewStart: min, + }), + [ + collapsedSpans, + filteredNodes, + getScale, + matchedSpans, + max, + min, + onSpanClick, + onSpanCollapse, + onSpanNavigation, + selectedSpan, + ] + ); + + return {children}; +}; + +export default TimelineProvider; diff --git a/web/src/components/Visualization/components/Timeline/TimelineV2.styled.ts b/web/src/components/Visualization/components/Timeline/TimelineV2.styled.ts new file mode 100644 index 0000000000..08d73185dd --- /dev/null +++ b/web/src/components/Visualization/components/Timeline/TimelineV2.styled.ts @@ -0,0 +1,150 @@ +import {Typography} from 'antd'; +import {SemanticGroupNames, SemanticGroupNamesToColor} from 'constants/SemanticGroupNames.constants'; +import styled, {css} from 'styled-components'; + +export const Container = styled.div` + padding: 50px 24px 0 24px; +`; + +export const Row = styled.div<{$isEven: boolean; $isMatched: boolean; $isSelected: boolean}>` + background-color: ${({theme, $isEven}) => ($isEven ? theme.color.background : theme.color.white)}; + display: grid; + grid-template-columns: 300px 1fr; + grid-template-rows: 32px; + padding: 0px 16px; + + :hover { + background-color: ${({theme}) => theme.color.backgroundInteractive}; + } + + ${({$isMatched}) => + $isMatched && + css` + background-color: ${({theme}) => theme.color.alertYellow}; + `}; + + ${({$isSelected}) => + $isSelected && + css` + background: rgba(97, 23, 94, 0.1); + + :hover { + background: rgba(97, 23, 94, 0.1); + } + `}; +`; + +export const Col = styled.div` + display: grid; + grid-template-columns: 1fr 8px; +`; + +export const ColDuration = styled.div` + overflow: hidden; + position: relative; +`; + +export const Header = styled.div` + align-items: center; + display: flex; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +export const NameContainer = styled.div` + overflow: hidden; + text-overflow: ellipsis; +`; + +export const Separator = styled.div` + border-left: 1px solid rgb(222, 227, 236); + cursor: ew-resize; + height: 32px; + padding: 0px 3px; + width: 1px; +`; + +export const Title = styled(Typography.Text)` + color: ${({theme}) => theme.color.text}; + font-size: ${({theme}) => theme.size.sm}; + font-weight: 400; +`; + +export const Connector = styled.svg` + flex-shrink: 0; + overflow: hidden; + overflow-clip-margin: content-box; +`; + +export const SpanBar = styled.div<{$type: SemanticGroupNames}>` + background-color: ${({$type}) => SemanticGroupNamesToColor[$type]}; + border-radius: 3px; + height: 18px; + min-width: 2px; + position: absolute; + top: 7px; +`; + +export const SpanBarLabel = styled.div<{$side: 'left' | 'right'}>` + color: ${({theme}) => theme.color.textSecondary}; + font-size: ${({theme}) => theme.size.xs}; + padding: 1px 4px 0 4px; + position: absolute; + + ${({$side}) => + $side === 'left' + ? css` + right: 100%; + ` + : css` + left: 100%; + `}; +`; + +export const TextConnector = styled.text<{$isActive?: boolean}>` + fill: ${({theme, $isActive}) => ($isActive ? theme.color.white : theme.color.text)}; + font-size: ${({theme}) => theme.size.xs}; +`; + +export const CircleDot = styled.circle` + fill: ${({theme}) => theme.color.textSecondary}; + stroke-width: 2; + stroke: ${({theme}) => theme.color.white}; +`; + +export const LineBase = styled.line` + stroke: ${({theme}) => theme.color.textSecondary}; +`; + +export const RectBase = styled.rect<{$isActive?: boolean}>` + fill: ${({theme, $isActive}) => ($isActive ? theme.color.primary : theme.color.white)}; + stroke: ${({theme}) => theme.color.textSecondary}; +`; + +export const RectBaseTransparent = styled(RectBase)` + cursor: pointer; + fill: transparent; +`; + +export const HeaderRow = styled.div` + background-color: ${({theme}) => theme.color.white}; + display: grid; + grid-template-columns: 300px 1fr; + grid-template-rows: 32px; + padding: 0px 16px; +`; + +export const HeaderContent = styled.div` + align-items: center; + display: flex; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +export const HeaderTitle = styled(Typography.Title)` + && { + margin: 0; + } +`; diff --git a/web/src/components/Visualization/components/Timeline/TimelineV2.tsx b/web/src/components/Visualization/components/Timeline/TimelineV2.tsx new file mode 100644 index 0000000000..9797c82cd5 --- /dev/null +++ b/web/src/components/Visualization/components/Timeline/TimelineV2.tsx @@ -0,0 +1,25 @@ +import {NodeTypesEnum} from 'constants/Visualization.constants'; +import Span from 'models/Span.model'; +import {useRef} from 'react'; +import {FixedSizeList as List} from 'react-window'; +import NavigationWrapper from './NavigationWrapper'; +import TimelineProvider from './Timeline.provider'; +import ListWrapper from './ListWrapper'; + +export interface IProps { + nodeType: NodeTypesEnum; + spans: Span[]; +} + +const Timeline = ({nodeType, spans}: IProps) => { + const listRef = useRef(null); + + return ( + + + + + ); +}; + +export default Timeline; diff --git a/web/src/components/Visualization/components/Timeline/TraceSpanNode/TraceSpanNodeV2.tsx b/web/src/components/Visualization/components/Timeline/TraceSpanNode/TraceSpanNodeV2.tsx new file mode 100644 index 0000000000..1f0228df1c --- /dev/null +++ b/web/src/components/Visualization/components/Timeline/TraceSpanNode/TraceSpanNodeV2.tsx @@ -0,0 +1,19 @@ +import useSpanData from 'hooks/useSpanData'; +// import Header from './Header'; +import BaseSpanNode from '../BaseSpanNode/BaseSpanNodeV2'; +import {IPropsComponent} from '../SpanNodeFactoryV2'; + +const TraceSpanNode = (props: IPropsComponent) => { + const {node} = props; + const {span, analyzerErrors} = useSpanData(node.data.id); + + return ( + } + span={span} + /> + ); +}; + +export default TraceSpanNode; diff --git a/web/src/services/Timeline.service.ts b/web/src/services/Timeline.service.ts index 610f389dab..8b06096135 100644 --- a/web/src/services/Timeline.service.ts +++ b/web/src/services/Timeline.service.ts @@ -2,6 +2,8 @@ import {stratify} from '@visx/hierarchy'; import {NodeTypesEnum} from 'constants/Visualization.constants'; import {INodeDataSpan, TNode} from 'types/Timeline.types'; +export type TScaleFunction = (start: number, end: number) => {start: number; end: number}; + function getHierarchyNodes(nodesData: INodeDataSpan[]) { return stratify() .id(d => d.id) @@ -48,6 +50,22 @@ const TimelineService = () => ({ const endTimes = nodes.map(node => node.data.endTime); return [Math.min(...startTimes), Math.max(...endTimes)]; }, + + createScaleFunc(viewRange: {min: number; max: number}): TScaleFunction { + const {min, max} = viewRange; + const viewWindow = max - min; + + /** + * Scale function + * @param {number} start The start of the sub-range. + * @param {number} end The end of the sub-range. + * @return {Object} The resultant range. + */ + return (start: number, end: number) => ({ + start: (start - min) / viewWindow, + end: (end - min) / viewWindow, + }); + }, }); export default TimelineService(); diff --git a/web/src/utils/Date.ts b/web/src/utils/Date.ts index d534d4dbe3..19c73d2433 100644 --- a/web/src/utils/Date.ts +++ b/web/src/utils/Date.ts @@ -1,4 +1,21 @@ import {format, formatDistanceToNowStrict, isValid, parseISO} from 'date-fns'; +import dropWhile from 'lodash/dropWhile'; +import round from 'lodash/round'; + +export const ONE_MILLISECOND = 1000 * 1; +const ONE_SECOND = 1000 * ONE_MILLISECOND; +const ONE_MINUTE = 60 * ONE_SECOND; +const ONE_HOUR = 60 * ONE_MINUTE; +const ONE_DAY = 24 * ONE_HOUR; + +const UNIT_STEPS: {unit: string; microseconds: number; ofPrevious: number}[] = [ + {unit: 'd', microseconds: ONE_DAY, ofPrevious: 24}, + {unit: 'h', microseconds: ONE_HOUR, ofPrevious: 60}, + {unit: 'm', microseconds: ONE_MINUTE, ofPrevious: 60}, + {unit: 's', microseconds: ONE_SECOND, ofPrevious: 1000}, + {unit: 'ms', microseconds: ONE_MILLISECOND, ofPrevious: 1000}, + {unit: 'μs', microseconds: 1, ofPrevious: 1000}, +]; const Date = { format(date: string, dateFormat = "EEEE, yyyy/MM/dd 'at' HH:mm:ss") { @@ -8,6 +25,7 @@ const Date = { } return format(isoDate, dateFormat); }, + getTimeAgo(date: string) { const isoDate = parseISO(date); if (!isValid(isoDate)) { @@ -15,9 +33,35 @@ const Date = { } return formatDistanceToNowStrict(isoDate, {addSuffix: true}); }, + isDefaultDate(date: string) { return date === '0001-01-01T00:00:00Z'; }, + + /** + * Format duration for display. + * + * @param {number} duration - microseconds + * @return {string} formatted duration + */ + formatDuration(duration: number): string { + // Drop all units that are too large except the last one + const [primaryUnit, secondaryUnit] = dropWhile( + UNIT_STEPS, + ({microseconds}, index) => index < UNIT_STEPS.length - 1 && microseconds > duration + ); + + if (primaryUnit.ofPrevious === 1000) { + // If the unit is decimal based, display as a decimal + return `${round(duration / primaryUnit.microseconds, 2)}${primaryUnit.unit}`; + } + + const primaryValue = Math.floor(duration / primaryUnit.microseconds); + const primaryUnitString = `${primaryValue}${primaryUnit.unit}`; + const secondaryValue = Math.round((duration / secondaryUnit.microseconds) % primaryUnit.ofPrevious); + const secondaryUnitString = `${secondaryValue}${secondaryUnit.unit}`; + return secondaryValue === 0 ? primaryUnitString : `${primaryUnitString} ${secondaryUnitString}`; + }, }; export default Date;