Skip to content

Commit

Permalink
feat(frontend): implement virtual list for timeline view (#3617)
Browse files Browse the repository at this point in the history
* feat(frontend): implement virtual list for timeline view

* remove prop

* add header and collapse
  • Loading branch information
jorgeepc authored Feb 9, 2024
1 parent 2f025cf commit 8c6fdc9
Show file tree
Hide file tree
Showing 18 changed files with 698 additions and 22 deletions.
32 changes: 32 additions & 0 deletions web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand Down
23 changes: 2 additions & 21 deletions web/src/components/RunDetailTrace/Visualization.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -24,24 +23,14 @@ 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;

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}));
Expand All @@ -56,15 +45,7 @@ const Visualization = ({isDAGDisabled, runEvents, runState, trace, trace: {spans
return type === VisualizationType.Dag && !isDAGDisabled ? (
<TraceDAG trace={trace} onNavigateToSpan={onNavigateToSpan} />
) : (
<Timeline
isMatchedMode={isMatchedMode}
matchedSpans={matchedSpans}
nodeType={NodeTypesEnum.TraceSpan}
onNavigateToSpan={onNavigateToSpan}
onNodeClick={onNodeClickTimeline}
selectedSpan={selectedSpan}
spans={spans}
/>
<TimelineV2 nodeType={NodeTypesEnum.TraceSpan} spans={spans} />
);
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import useSelectAsCurrent from '../../../hooks/useSelectAsCurrent';

interface IProps extends NodeProps<INodeDataSpan> {}

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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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) => {

Check warning on line 22 in web/src/components/Visualization/components/Timeline/BaseSpanNode/BaseSpanNodeV2.tsx

View workflow job for this annotation

GitHub Actions / WebUI unit tests

'header' is defined but never used
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 (
<div style={style}>
<S.Row
onClick={() => onSpanClick(node.data.id)}
$isEven={index % 2 === 0}
$isMatched={isMatched}
$isSelected={isSelected}
>
<S.Col>
<S.Header>
<Connector
hasParent={!!node.data.parentId}
id={node.data.id}
isCollapsed={isCollapsed}
leftPadding={leftPadding}
onCollapse={onSpanCollapse}
totalChildren={node.children}
/>
<S.NameContainer>
<S.Title>{span.name}</S.Title>
</S.NameContainer>
</S.Header>
<S.Separator />
</S.Col>

<S.ColDuration>
<S.SpanBar $type={span.type} style={{left: toPercent(viewStart), width: toPercent(viewEnd - viewStart)}}>
<S.SpanBarLabel $side={hintSide}>{span.duration}</S.SpanBarLabel>
</S.SpanBar>
</S.ColDuration>
</S.Row>
</div>
);
};

export default BaseSpanNode;
Original file line number Diff line number Diff line change
@@ -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) => (
<S.Connector height="100%" width={leftPadding + 30}>
{hasParent && (
<>
<S.LineBase x1={leftPadding - 3} x2={leftPadding + 12} y1="16" y2="16" />
<S.LineBase x1={leftPadding - 4} x2={leftPadding - 4} y1="0" y2="16.5" />
</>
)}

{totalChildren > 0 ? (
<>
{!isCollapsed && <S.LineBase x1={leftPadding + 12} x2={leftPadding + 12} y1="16" y2="32" />}
<S.RectBase x={leftPadding + 2} y="8" width="20" height="16" rx="3px" ry="3px" $isActive={isCollapsed} />
<S.TextConnector x={leftPadding + 12} y="20" textAnchor="middle" $isActive={isCollapsed}>
{totalChildren}
</S.TextConnector>
<S.RectBaseTransparent
x={leftPadding + 2}
y="8"
width="20"
height="16"
rx="3px"
ry="3px"
onClick={event => {
event.stopPropagation();
onCollapse(id);
}}
/>
</>
) : (
<S.CircleDot cx={leftPadding + 12} cy="16" r="3" />
)}
</S.Connector>
);

export default Connector;
22 changes: 22 additions & 0 deletions web/src/components/Visualization/components/Timeline/Header.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<S.HeaderRow>
<S.Col>
<S.HeaderContent>
<S.HeaderTitle level={3}>Span</S.HeaderTitle>
</S.HeaderContent>
<S.Separator />
</S.Col>
<Ticks numTicks={NUM_TICKS} startTime={0} endTime={duration} />
</S.HeaderRow>
);

export default Header;
Original file line number Diff line number Diff line change
@@ -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<List>;
}

const ListWrapper = ({listRef}: IProps) => {
const {spans, viewEnd, viewStart} = useTimeline();

return (
<S.Container>
<Header duration={viewEnd - viewStart} />
<List
height={window.innerHeight - HEADER_HEIGHT}
itemCount={spans.length}
itemData={spans}
itemSize={32}
ref={listRef}
width="100%"
>
{SpanNodeFactory}
</List>
</S.Container>
);
};

export default ListWrapper;
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import Navigation from '../Navigation';
import {useTimeline} from './Timeline.provider';

const NavigationWrapper = () => {
const {matchedSpans, onSpanNavigation, selectedSpan} = useTimeline();

return <Navigation matchedSpans={matchedSpans} onNavigateToSpan={onSpanNavigation} selectedSpan={selectedSpan} />;
};

export default NavigationWrapper;
Original file line number Diff line number Diff line change
@@ -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<NodeTypesEnum, (props: IPropsComponent) => 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 <Component {...props} node={node} />;
};

export default SpanNodeFactory;
Original file line number Diff line number Diff line change
@@ -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;
`};
`;
Loading

0 comments on commit 8c6fdc9

Please sign in to comment.