From e5290828515ae6e04b31d06a81d74cdfba9304b7 Mon Sep 17 00:00:00 2001 From: Oscar Reyes Date: Tue, 6 Feb 2024 12:13:28 -0600 Subject: [PATCH 1/9] chore(frontend): indentifying performance improvements --- web/src/components/AnalyzerResult/Rule.tsx | 2 ++ web/src/components/RunDetailTrace/Visualization.tsx | 3 +++ .../Visualization/components/Navigation/Navigation.tsx | 1 + web/src/models/DAG.model.ts | 1 + web/src/models/Trace.model.ts | 1 + web/src/selectors/Span.selectors.ts | 3 ++- web/src/selectors/TestRun.selectors.ts | 1 + web/src/services/Span.service.ts | 1 + 8 files changed, 12 insertions(+), 1 deletion(-) diff --git a/web/src/components/AnalyzerResult/Rule.tsx b/web/src/components/AnalyzerResult/Rule.tsx index 9480e0f04e..2b93effdfd 100644 --- a/web/src/components/AnalyzerResult/Rule.tsx +++ b/web/src/components/AnalyzerResult/Rule.tsx @@ -18,6 +18,7 @@ interface IProps { } function getSpanName(spans: Span[], spanId: string) { + // TODO: find an easier way to get the span name const span = spans.find(s => s.id === spanId); return span?.name ?? ''; } @@ -58,6 +59,7 @@ const Rule = ({ + {/* TODO: Add a search capability to look for spans in the result list */} {results?.map((result, resultIndex) => ( // eslint-disable-next-line react/no-array-index-key
diff --git a/web/src/components/RunDetailTrace/Visualization.tsx b/web/src/components/RunDetailTrace/Visualization.tsx index 3ec49e140a..e4d80a6aa0 100644 --- a/web/src/components/RunDetailTrace/Visualization.tsx +++ b/web/src/components/RunDetailTrace/Visualization.tsx @@ -31,12 +31,15 @@ const Visualization = ({runEvents, runState, spans, type}: IProps) => { const selectedSpan = useAppSelector(TraceSelectors.selectSelectedSpan); const isMatchedMode = Boolean(matchedSpans.length); + // TODO: Trace will never change, we can calculate this once and then keep using it useEffect(() => { dispatch(initNodes({spans})); }, [dispatch, spans]); useEffect(() => { if (selectedSpan) return; + + // TODO: Find an easier way to get the first span const firstSpan = spans.find(span => !span.parentId); dispatch(selectSpan({spanId: firstSpan?.id ?? ''})); }, [dispatch, selectedSpan, spans]); diff --git a/web/src/components/Visualization/components/Navigation/Navigation.tsx b/web/src/components/Visualization/components/Navigation/Navigation.tsx index 8517c217e8..c4ba60e0f4 100644 --- a/web/src/components/Visualization/components/Navigation/Navigation.tsx +++ b/web/src/components/Visualization/components/Navigation/Navigation.tsx @@ -13,6 +13,7 @@ interface IProps { } const Navigation = ({matchedSpans, onNavigateToSpan, selectedSpan}: IProps) => { + // TODO: save matched spans in a different data structure const index = matchedSpans.findIndex(spanId => spanId === selectedSpan) + 1; const navigate = useCallback( diff --git a/web/src/models/DAG.model.ts b/web/src/models/DAG.model.ts index 38248f6d71..afbb69eedf 100644 --- a/web/src/models/DAG.model.ts +++ b/web/src/models/DAG.model.ts @@ -13,6 +13,7 @@ function getNodesDatumFromSpans(spans: Span[], type: NodeTypesEnum): INodeDatum< } function DAG(spans: Span[], type: NodeTypesEnum) { + // TODO: this runs twice for the list of spans const nodesDatum = getNodesDatumFromSpans(spans, type).sort((a, b) => { if (b.data.startTime !== a.data.startTime) return b.data.startTime - a.data.startTime; if (b.id < a.id) return -1; diff --git a/web/src/models/Trace.model.ts b/web/src/models/Trace.model.ts index 5fabfab841..45bcc80f28 100644 --- a/web/src/models/Trace.model.ts +++ b/web/src/models/Trace.model.ts @@ -7,6 +7,7 @@ type Trace = { traceId: string; }; +// TODO: keep the flat map of spans for easy access const Trace = ({traceId = '', flat = {}}: TRawTrace): Trace => { return { traceId, diff --git a/web/src/selectors/Span.selectors.ts b/web/src/selectors/Span.selectors.ts index d1dc43f3ad..4709fda53f 100644 --- a/web/src/selectors/Span.selectors.ts +++ b/web/src/selectors/Span.selectors.ts @@ -22,7 +22,8 @@ const SpanSelectors = () => ({ selectMatchedSpans, selectSpanById: createSelector(stateSelector, paramsSelector, (state, {spanId, testId, runId}) => { const {data: {trace} = {}} = TracetestAPI.instance.endpoints.getRunById.select({testId, runId})(state); - + + // TODO: look for a simpler way of getting the span by id const spanList = trace?.spans || []; return spanList.find(span => span.id === spanId); diff --git a/web/src/selectors/TestRun.selectors.ts b/web/src/selectors/TestRun.selectors.ts index bcc50c2c24..31f8ff57f8 100644 --- a/web/src/selectors/TestRun.selectors.ts +++ b/web/src/selectors/TestRun.selectors.ts @@ -13,6 +13,7 @@ const selectTestRun = (state: RootState, params: {testId: string; runId: number; return data ?? TestRun({}); }; +// TODO: look for a simpler way of getting the span by id export const selectSpanById = createSelector([selectTestRun, selectParams], (testRun, params) => { const {trace} = testRun; return trace?.spans?.find(span => span.id === params.spanId) ?? Span({id: params.spanId}); diff --git a/web/src/services/Span.service.ts b/web/src/services/Span.service.ts index 1325e2d3e2..c4a439f851 100644 --- a/web/src/services/Span.service.ts +++ b/web/src/services/Span.service.ts @@ -45,6 +45,7 @@ const SpanService = () => ({ ).trim()}]`; }, + // TODO: this is very costly, we might need to move this to the backend searchSpanList(spanList: Span[], searchText: string) { if (!searchText.trim()) return []; From ece4497c392ec310447de6572bb1c1ad0abe53b8 Mon Sep 17 00:00:00 2001 From: Jorge Padilla Date: Tue, 6 Feb 2024 14:41:34 -0500 Subject: [PATCH 2/9] chore(frontend): identify performance improvements --- .../Inputs/Editor/Expression/hooks/useAutoComplete.ts | 1 + .../Inputs/Editor/Selector/hooks/useAutoComplete.ts | 5 ++++- web/src/components/TestSpecDetail/Content.tsx | 1 + web/src/hooks/useSpanData.ts | 2 ++ 4 files changed, 8 insertions(+), 1 deletion(-) diff --git a/web/src/components/Inputs/Editor/Expression/hooks/useAutoComplete.ts b/web/src/components/Inputs/Editor/Expression/hooks/useAutoComplete.ts index 981aef66b5..8336836d75 100644 --- a/web/src/components/Inputs/Editor/Expression/hooks/useAutoComplete.ts +++ b/web/src/components/Inputs/Editor/Expression/hooks/useAutoComplete.ts @@ -21,6 +21,7 @@ const useAutoComplete = ({testId, runId, onSelect = noop, autocompleteCustomValu const getAttributeList = useCallback(() => { const state = getState(); const spanIdList = SpanSelectors.selectMatchedSpans(state); + // TODO: this list is calculated multiple times while typing, we should memoize it const attributeList = AssertionSelectors.selectAttributeList(state, testId, runId, spanIdList); return uniqBy(attributeList, 'key'); diff --git a/web/src/components/Inputs/Editor/Selector/hooks/useAutoComplete.ts b/web/src/components/Inputs/Editor/Selector/hooks/useAutoComplete.ts index 19af2ce65e..c4350a1fea 100644 --- a/web/src/components/Inputs/Editor/Selector/hooks/useAutoComplete.ts +++ b/web/src/components/Inputs/Editor/Selector/hooks/useAutoComplete.ts @@ -24,6 +24,7 @@ const useAutoComplete = ({testId, runId}: IProps) => { const getAttributeList = useCallback(() => { const state = getState(); + // TODO: this list is calculated multiple times while typing, we should memoize it const defaultList = AssertionSelectors.selectAllAttributeList(state, testId, runId); return defaultList; @@ -55,7 +56,9 @@ const useAutoComplete = ({testId, runId}: IProps) => { const uniqueList = uniqBy(attributeList, 'key'); const identifierText = state.doc.sliceString(nodeBefore.from, nodeBefore.to); const isIdentifier = nodeBefore.name === Tokens.Identifier; - const list = isIdentifier ? uniqueList.filter(({key}) => key.toLowerCase().includes(identifierText.toLowerCase())) : uniqueList; + const list = isIdentifier + ? uniqueList.filter(({key}) => key.toLowerCase().includes(identifierText.toLowerCase())) + : uniqueList; return { from: isIdentifier ? nodeBefore.from : word.from, diff --git a/web/src/components/TestSpecDetail/Content.tsx b/web/src/components/TestSpecDetail/Content.tsx index 90dea1340c..943744a7e0 100644 --- a/web/src/components/TestSpecDetail/Content.tsx +++ b/web/src/components/TestSpecDetail/Content.tsx @@ -76,6 +76,7 @@ const Content = ({ title={!selector && !name ? 'All Spans' : name} /> + {/* // TODO: consider a virtualized list here */} {Object.entries(results).map(([spanId, checkResults]) => { const span = trace?.spans.find(({id}) => id === spanId); diff --git a/web/src/hooks/useSpanData.ts b/web/src/hooks/useSpanData.ts index 799bce5a07..a8ff64654d 100644 --- a/web/src/hooks/useSpanData.ts +++ b/web/src/hooks/useSpanData.ts @@ -28,6 +28,8 @@ const useSpanData = (id: string): IUseSpanData => { const span = useAppSelector(state => selectSpanById(state, {testId, runId, spanId: id})); + // TODO: should we get analyzerErrors, testSpecs and testOutputs as part of the trace struct from the BE? + // Right now we are getting them from the testRun struct for each span by spanId const analyzerErrors = useAppSelector(state => selectAnalyzerErrorsBySpanId(state, {testId, runId, spanId: id})); const testSpecs = useAppSelector(state => selectTestSpecsBySpanId(state, {testId, runId, spanId: id})); From d71374714a8f5113ffc23e512b53aadb46a561e4 Mon Sep 17 00:00:00 2001 From: Jorge Padilla Date: Thu, 8 Feb 2024 10:21:10 -0500 Subject: [PATCH 3/9] feat(frontend): hide dag view for big traces (#3606) --- .../components/RunDetailTest/TestPanel.tsx | 11 ++++++++-- .../RunDetailTest/Visualization.tsx | 6 ++++-- .../RunDetailTrace/RunDetailTrace.tsx | 5 +++++ .../components/RunDetailTrace/TracePanel.tsx | 10 +++++++-- .../RunDetailTrace/Visualization.tsx | 6 ++++-- .../components/Switch/Switch.styled.ts | 7 +++++-- .../components/Switch/Switch.tsx | 21 +++++++++++++++---- web/src/constants/Visualization.constants.ts | 2 ++ 8 files changed, 54 insertions(+), 14 deletions(-) diff --git a/web/src/components/RunDetailTest/TestPanel.tsx b/web/src/components/RunDetailTest/TestPanel.tsx index 2c7ba26238..4dac39af6d 100644 --- a/web/src/components/RunDetailTest/TestPanel.tsx +++ b/web/src/components/RunDetailTest/TestPanel.tsx @@ -1,7 +1,7 @@ import {Tabs} from 'antd'; import {useCallback, useState} from 'react'; import {useSearchParams} from 'react-router-dom'; -import {VisualizationType} from 'components/RunDetailTrace/RunDetailTrace'; +import {VisualizationType, getIsDAGDisabled} from 'components/RunDetailTrace/RunDetailTrace'; import TestOutputs from 'components/TestOutputs'; import TestOutputForm from 'components/TestOutputForm/TestOutputForm'; import TestResults from 'components/TestResults'; @@ -56,7 +56,11 @@ const TestPanel = ({run, testId, runEvents}: IProps) => { const { test: {skipTraceCollection}, } = useTest(); - const [visualizationType, setVisualizationType] = useState(VisualizationType.Dag); + + const isDAGDisabled = getIsDAGDisabled(run?.trace?.spans?.length); + const [visualizationType, setVisualizationType] = useState(() => + isDAGDisabled ? VisualizationType.Timeline : VisualizationType.Dag + ); const handleClose = useCallback(() => { onSetFocusedSpan(''); @@ -111,17 +115,20 @@ const TestPanel = ({run, testId, runEvents}: IProps) => { {run.state === TestState.FINISHED && ( { TestRunAnalytics.onSwitchDiagramView(type); setVisualizationType(type); }} type={visualizationType} + totalSpans={run?.trace?.spans?.length} /> )} {skipTraceCollection && } { +const Visualization = ({isDAGDisabled, runEvents, runState, spans, type}: IProps) => { const dispatch = useAppDispatch(); const edges = useAppSelector(DAGSelectors.selectEdges); const nodes = useAppSelector(DAGSelectors.selectNodes); @@ -35,8 +36,9 @@ const Visualization = ({runEvents, runState, spans, type}: IProps) => { const {isOpen} = useTestSpecForm(); useEffect(() => { + if (isDAGDisabled) return; dispatch(initNodes({spans})); - }, [dispatch, spans]); + }, [dispatch, isDAGDisabled, spans]); useEffect(() => { if (selectedSpan) return; diff --git a/web/src/components/RunDetailTrace/RunDetailTrace.tsx b/web/src/components/RunDetailTrace/RunDetailTrace.tsx index 3333e8ec22..5953b75b25 100644 --- a/web/src/components/RunDetailTrace/RunDetailTrace.tsx +++ b/web/src/components/RunDetailTrace/RunDetailTrace.tsx @@ -1,4 +1,5 @@ import ResizablePanels from 'components/ResizablePanels'; +import {MAX_DAG_NODES} from 'constants/Visualization.constants'; import TestRun from 'models/TestRun.model'; import TestRunEvent from 'models/TestRunEvent.model'; import * as S from './RunDetailTrace.styled'; @@ -19,6 +20,10 @@ export enum VisualizationType { Timeline, } +export function getIsDAGDisabled(totalSpans: number = 0): boolean { + return totalSpans > MAX_DAG_NODES; +} + const RunDetailTrace = ({run, runEvents, testId, skipTraceCollection}: IProps) => { return ( diff --git a/web/src/components/RunDetailTrace/TracePanel.tsx b/web/src/components/RunDetailTrace/TracePanel.tsx index 53cc6edb8f..a12b1708d7 100644 --- a/web/src/components/RunDetailTrace/TracePanel.tsx +++ b/web/src/components/RunDetailTrace/TracePanel.tsx @@ -4,7 +4,7 @@ import TraceAnalyticsService from 'services/Analytics/TestRunAnalytics.service'; import {TestState} from 'constants/TestRun.constants'; import TestRunEvent from 'models/TestRunEvent.model'; import Search from './Search'; -import {VisualizationType} from './RunDetailTrace'; +import {VisualizationType, getIsDAGDisabled} from './RunDetailTrace'; import * as S from './RunDetailTrace.styled'; import Switch from '../Visualization/components/Switch/Switch'; import Visualization from './Visualization'; @@ -19,7 +19,10 @@ type TProps = { }; const TracePanel = ({run, testId, runEvents, skipTraceCollection}: TProps) => { - const [visualizationType, setVisualizationType] = useState(VisualizationType.Dag); + const isDAGDisabled = getIsDAGDisabled(run?.trace?.spans?.length); + const [visualizationType, setVisualizationType] = useState(() => + isDAGDisabled ? VisualizationType.Timeline : VisualizationType.Dag + ); return ( @@ -34,15 +37,18 @@ const TracePanel = ({run, testId, runEvents, skipTraceCollection}: TProps) => { {run.state === TestState.FINISHED && ( { TraceAnalyticsService.onSwitchDiagramView(type); setVisualizationType(type); }} type={visualizationType} + totalSpans={run?.trace?.spans?.length} /> )} { +const Visualization = ({isDAGDisabled, runEvents, runState, spans, type}: IProps) => { const dispatch = useAppDispatch(); const edges = useAppSelector(TraceSelectors.selectEdges); const matchedSpans = useAppSelector(TraceSelectors.selectMatchedSpans); @@ -33,8 +34,9 @@ const Visualization = ({runEvents, runState, spans, type}: IProps) => { // TODO: Trace will never change, we can calculate this once and then keep using it useEffect(() => { + if (isDAGDisabled) return; dispatch(initNodes({spans})); - }, [dispatch, spans]); + }, [dispatch, isDAGDisabled, spans]); useEffect(() => { if (selectedSpan) return; diff --git a/web/src/components/Visualization/components/Switch/Switch.styled.ts b/web/src/components/Visualization/components/Switch/Switch.styled.ts index 4cc51f89ba..6e4067bf0a 100644 --- a/web/src/components/Visualization/components/Switch/Switch.styled.ts +++ b/web/src/components/Visualization/components/Switch/Switch.styled.ts @@ -11,10 +11,13 @@ export const Container = styled.div` padding: 7px; `; -export const DAGIcon = styled(ClusterOutlined)<{$isSelected?: boolean}>` +export const DAGIcon = styled(ClusterOutlined)<{$isDisabled?: boolean; $isSelected?: boolean}>` color: ${({$isSelected = false, theme}) => ($isSelected ? theme.color.primary : theme.color.textSecondary)}; - cursor: pointer; font-size: ${({theme}) => theme.size.xl}; + + && { + cursor: ${({$isDisabled}) => ($isDisabled ? 'not-allowed' : 'pointer')}; + } `; export const TimelineIcon = styled(BarsOutlined)<{$isSelected?: boolean}>` diff --git a/web/src/components/Visualization/components/Switch/Switch.tsx b/web/src/components/Visualization/components/Switch/Switch.tsx index 4e4fc4e68c..26458eb11f 100644 --- a/web/src/components/Visualization/components/Switch/Switch.tsx +++ b/web/src/components/Visualization/components/Switch/Switch.tsx @@ -1,17 +1,30 @@ import {Tooltip} from 'antd'; - import {VisualizationType} from 'components/RunDetailTrace/RunDetailTrace'; +import {MAX_DAG_NODES} from 'constants/Visualization.constants'; import * as S from './Switch.styled'; interface IProps { + isDAGDisabled: boolean; onChange(type: VisualizationType): void; type: VisualizationType; + totalSpans?: number; } -const Switch = ({onChange, type}: IProps) => ( +const Switch = ({isDAGDisabled, onChange, type, totalSpans = 0}: IProps) => ( - - onChange(VisualizationType.Dag)} /> + + !isDAGDisabled && onChange(VisualizationType.Dag)} + /> Date: Thu, 8 Feb 2024 13:45:53 -0600 Subject: [PATCH 4/9] Chore performance improvements tech debt 1 (#3607) * adding async functionality * chore(frontend): DAG improvements * chore(frontend): DAG improvements * chore(frontend): fixing spinner styles * chore(frontend): fixing spinner styles * feat(frontend): fixing tests * chore(frontend): fixing spinner styles --- web/src/components/AnalyzerResult/Rule.tsx | 9 +-- .../LoadingSpinner/LoadingSpinner.styled.ts | 8 +++ web/src/components/LoadingSpinner/index.ts | 4 ++ web/src/components/RunDetailTest/TestDAG.tsx | 62 +++++++++++++++++++ .../components/RunDetailTest/TestPanel.tsx | 2 +- .../RunDetailTest/Visualization.tsx | 46 +++----------- .../components/RunDetailTrace/TraceDAG.tsx | 62 +++++++++++++++++++ .../components/RunDetailTrace/TracePanel.tsx | 2 +- .../RunDetailTrace/Visualization.tsx | 50 +++------------ .../Visualization/components/DAG/DAG.tsx | 5 +- .../DAG/TestSpanNode/TestSpanNode.tsx | 2 +- web/src/models/DAG.model.ts | 3 + web/src/models/Span.model.ts | 11 +++- web/src/models/TestRun.model.ts | 4 +- web/src/models/Trace.model.ts | 28 ++++++--- .../models/__tests__/TestRun.model.test.ts | 2 - .../providers/TestRun/TestRun.provider.tsx | 5 +- .../TestSpecs/TestSpecs.provider.tsx | 10 +-- web/src/redux/slices/DAG.slice.ts | 15 +++-- web/src/redux/slices/Trace.slice.ts | 16 +++-- web/src/selectors/TestRun.selectors.ts | 2 +- web/src/services/DAG.service.ts | 17 ++--- web/src/utils/Common.ts | 9 +++ 23 files changed, 247 insertions(+), 127 deletions(-) create mode 100644 web/src/components/LoadingSpinner/LoadingSpinner.styled.ts create mode 100644 web/src/components/RunDetailTest/TestDAG.tsx create mode 100644 web/src/components/RunDetailTrace/TraceDAG.tsx diff --git a/web/src/components/AnalyzerResult/Rule.tsx b/web/src/components/AnalyzerResult/Rule.tsx index 2b93effdfd..731c09c4e2 100644 --- a/web/src/components/AnalyzerResult/Rule.tsx +++ b/web/src/components/AnalyzerResult/Rule.tsx @@ -3,7 +3,6 @@ import {CaretUpFilled} from '@ant-design/icons'; import {Space, Tooltip, Typography} from 'antd'; import {LinterResultPluginRule} from 'models/LinterResult.model'; import Trace from 'models/Trace.model'; -import Span from 'models/Span.model'; import {LinterRuleErrorLevel} from 'models/Linter.model'; import {useAppDispatch} from 'redux/hooks'; import {selectSpan} from 'redux/slices/Trace.slice'; @@ -17,12 +16,6 @@ interface IProps { trace: Trace; } -function getSpanName(spans: Span[], spanId: string) { - // TODO: find an easier way to get the span name - const span = spans.find(s => s.id === spanId); - return span?.name ?? ''; -} - const Rule = ({ rule: {id, tips, passed, description, name, errorDescription, results = [], level, weight = 0}, trace, @@ -69,7 +62,7 @@ const Rule = ({ type="link" $error={!result.passed} > - {getSpanName(trace.spans, result.spanId)} + {trace.flat[result.spanId].name ?? ''} {!result.passed && result.errors.length > 1 && ( diff --git a/web/src/components/LoadingSpinner/LoadingSpinner.styled.ts b/web/src/components/LoadingSpinner/LoadingSpinner.styled.ts new file mode 100644 index 0000000000..04c78890e0 --- /dev/null +++ b/web/src/components/LoadingSpinner/LoadingSpinner.styled.ts @@ -0,0 +1,8 @@ +import styled from 'styled-components'; + +export const SpinnerContainer = styled.div` + height: 100%; + display: flex; + align-items: center; + justify-content: center; +`; diff --git a/web/src/components/LoadingSpinner/index.ts b/web/src/components/LoadingSpinner/index.ts index 6e7f1055ec..b2e5482e70 100644 --- a/web/src/components/LoadingSpinner/index.ts +++ b/web/src/components/LoadingSpinner/index.ts @@ -1,2 +1,6 @@ +import {SpinnerContainer} from './LoadingSpinner.styled'; + +export {SpinnerContainer}; + // eslint-disable-next-line no-restricted-exports export {default} from './LoadingSpinner'; diff --git a/web/src/components/RunDetailTest/TestDAG.tsx b/web/src/components/RunDetailTest/TestDAG.tsx new file mode 100644 index 0000000000..3597fe4893 --- /dev/null +++ b/web/src/components/RunDetailTest/TestDAG.tsx @@ -0,0 +1,62 @@ +import {useCallback, useEffect} from 'react'; +import {Node, NodeChange} from 'react-flow-renderer'; + +import DAG from 'components/Visualization/components/DAG'; +import {useSpan} from 'providers/Span/Span.provider'; +import {useAppDispatch, useAppSelector} from 'redux/hooks'; +import {initNodes, onNodesChange as onNodesChangeAction} from 'redux/slices/DAG.slice'; +import DAGSelectors from 'selectors/DAG.selectors'; +import TraceDiagramAnalyticsService from 'services/Analytics/TraceDiagramAnalytics.service'; +import Trace from 'models/Trace.model'; +import {useTestSpecForm} from '../TestSpecForm/TestSpecForm.provider'; +import LoadingSpinner, {SpinnerContainer} from '../LoadingSpinner'; + +export interface IProps { + trace: Trace; + onNavigateToSpan(spanId: string): void; +} + +const TestDAG = ({trace: {spans}, onNavigateToSpan}: IProps) => { + const dispatch = useAppDispatch(); + const edges = useAppSelector(DAGSelectors.selectEdges); + const nodes = useAppSelector(DAGSelectors.selectNodes); + const {onSelectSpan, matchedSpans, focusedSpan} = useSpan(); + const {isOpen} = useTestSpecForm(); + + useEffect(() => { + dispatch(initNodes({spans})); + }, [dispatch, spans]); + + const onNodesChange = useCallback((changes: NodeChange[]) => dispatch(onNodesChangeAction({changes})), [dispatch]); + + const onNodeClick = useCallback( + (event, {id}: Node) => { + TraceDiagramAnalyticsService.onClickSpan(id); + onSelectSpan(id); + }, + [onSelectSpan] + ); + + if (spans.length && !nodes.length) { + return ( + + + + ); + } + + return ( + 0 || isOpen} + matchedSpans={matchedSpans} + nodes={nodes} + onNavigateToSpan={onNavigateToSpan} + onNodesChange={onNodesChange} + onNodeClick={onNodeClick} + selectedSpan={focusedSpan} + /> + ); +}; + +export default TestDAG; diff --git a/web/src/components/RunDetailTest/TestPanel.tsx b/web/src/components/RunDetailTest/TestPanel.tsx index 4dac39af6d..b1bf882227 100644 --- a/web/src/components/RunDetailTest/TestPanel.tsx +++ b/web/src/components/RunDetailTest/TestPanel.tsx @@ -131,7 +131,7 @@ const TestPanel = ({run, testId, runEvents}: IProps) => { isDAGDisabled={isDAGDisabled} runEvents={runEvents} runState={run.state} - spans={run?.trace?.spans ?? []} + trace={run.trace} type={visualizationType} /> diff --git a/web/src/components/RunDetailTest/Visualization.tsx b/web/src/components/RunDetailTest/Visualization.tsx index 21b6ba2f6e..f08bdc6f23 100644 --- a/web/src/components/RunDetailTest/Visualization.tsx +++ b/web/src/components/RunDetailTest/Visualization.tsx @@ -1,61 +1,38 @@ import {useCallback, useEffect} from 'react'; -import {Node, NodeChange} from 'react-flow-renderer'; import {VisualizationType} from 'components/RunDetailTrace/RunDetailTrace'; import RunEvents from 'components/RunEvents'; import {useTestSpecForm} from 'components/TestSpecForm/TestSpecForm.provider'; -import DAG from 'components/Visualization/components/DAG'; import Timeline from 'components/Visualization/components/Timeline'; import {TestRunStage} from 'constants/TestRunEvents.constants'; import {NodeTypesEnum} from 'constants/Visualization.constants'; -import Span from 'models/Span.model'; import TestRunEvent from 'models/TestRunEvent.model'; import {useSpan} from 'providers/Span/Span.provider'; -import {useAppDispatch, useAppSelector} from 'redux/hooks'; -import {initNodes, onNodesChange as onNodesChangeAction} from 'redux/slices/DAG.slice'; -import DAGSelectors from 'selectors/DAG.selectors'; import TraceAnalyticsService from 'services/Analytics/TestRunAnalytics.service'; -import TraceDiagramAnalyticsService from 'services/Analytics/TraceDiagramAnalytics.service'; +import Trace from 'models/Trace.model'; import TestRunService from 'services/TestRun.service'; import {TTestRunState} from 'types/TestRun.types'; +import TestDAG from './TestDAG'; export interface IProps { isDAGDisabled: boolean; runEvents: TestRunEvent[]; runState: TTestRunState; - spans: Span[]; type: VisualizationType; + trace: Trace; } -const Visualization = ({isDAGDisabled, runEvents, runState, spans, type}: IProps) => { - const dispatch = useAppDispatch(); - const edges = useAppSelector(DAGSelectors.selectEdges); - const nodes = useAppSelector(DAGSelectors.selectNodes); - const {onSelectSpan, matchedSpans, onSetFocusedSpan, focusedSpan, selectedSpan} = useSpan(); +const Visualization = ({isDAGDisabled, runEvents, runState, trace, trace: {spans}, type}: IProps) => { + const {onSelectSpan, matchedSpans, onSetFocusedSpan, selectedSpan} = useSpan(); const {isOpen} = useTestSpecForm(); - useEffect(() => { - if (isDAGDisabled) return; - dispatch(initNodes({spans})); - }, [dispatch, isDAGDisabled, spans]); - useEffect(() => { if (selectedSpan) return; const firstSpan = spans.find(span => !span.parentId); onSelectSpan(firstSpan?.id ?? ''); }, [onSelectSpan, selectedSpan, spans]); - const onNodesChange = useCallback((changes: NodeChange[]) => dispatch(onNodesChangeAction({changes})), [dispatch]); - - const onNodeClick = useCallback( - (event, {id}: Node) => { - TraceDiagramAnalyticsService.onClickSpan(id); - onSelectSpan(id); - }, - [onSelectSpan] - ); - const onNodeClickTimeline = useCallback( (spanId: string) => { TraceAnalyticsService.onTimelineSpanClick(spanId); @@ -76,17 +53,8 @@ const Visualization = ({isDAGDisabled, runEvents, runState, spans, type}: IProps return ; } - return type === VisualizationType.Dag ? ( - 0 || isOpen} - matchedSpans={matchedSpans} - nodes={nodes} - onNavigateToSpan={onNavigateToSpan} - onNodesChange={onNodesChange} - onNodeClick={onNodeClick} - selectedSpan={focusedSpan} - /> + return type === VisualizationType.Dag && !isDAGDisabled ? ( + ) : ( 0 || isOpen} diff --git a/web/src/components/RunDetailTrace/TraceDAG.tsx b/web/src/components/RunDetailTrace/TraceDAG.tsx new file mode 100644 index 0000000000..cbfedce81b --- /dev/null +++ b/web/src/components/RunDetailTrace/TraceDAG.tsx @@ -0,0 +1,62 @@ +import {useAppDispatch, useAppSelector} from 'redux/hooks'; +import TraceSelectors from 'selectors/Trace.selectors'; +import {Node, NodeChange} from 'react-flow-renderer'; +import {changeNodes, initNodes, selectSpan} from 'redux/slices/Trace.slice'; +import TraceDiagramAnalyticsService from 'services/Analytics/TraceDiagramAnalytics.service'; +import {useCallback, useEffect} from 'react'; +import Trace from 'models/Trace.model'; +import DAG from '../Visualization/components/DAG'; +import LoadingSpinner, {SpinnerContainer} from '../LoadingSpinner'; + +interface IProps { + trace: Trace; + onNavigateToSpan(spanId: string): void; +} + +const TraceDAG = ({trace: {spans}, onNavigateToSpan}: IProps) => { + const matchedSpans = useAppSelector(TraceSelectors.selectMatchedSpans); + const selectedSpan = useAppSelector(TraceSelectors.selectSelectedSpan); + const nodes = useAppSelector(TraceSelectors.selectNodes); + const edges = useAppSelector(TraceSelectors.selectEdges); + const isMatchedMode = Boolean(matchedSpans.length); + const dispatch = useAppDispatch(); + + // TODO: Trace will never change, we can calculate this once and then keep using it + useEffect(() => { + dispatch(initNodes({spans})); + }, [dispatch, spans]); + + const onNodesChange = useCallback((changes: NodeChange[]) => dispatch(changeNodes({changes})), [dispatch]); + + const onNodeClick = useCallback( + (event: React.MouseEvent, {id}: Node) => { + event.stopPropagation(); + TraceDiagramAnalyticsService.onClickSpan(id); + dispatch(selectSpan({spanId: id})); + }, + [dispatch] + ); + + if (spans.length && !nodes.length) { + return ( + + + + ); + } + + return ( + + ); +}; + +export default TraceDAG; diff --git a/web/src/components/RunDetailTrace/TracePanel.tsx b/web/src/components/RunDetailTrace/TracePanel.tsx index a12b1708d7..ed958e3f09 100644 --- a/web/src/components/RunDetailTrace/TracePanel.tsx +++ b/web/src/components/RunDetailTrace/TracePanel.tsx @@ -51,7 +51,7 @@ const TracePanel = ({run, testId, runEvents, skipTraceCollection}: TProps) => { isDAGDisabled={isDAGDisabled} runEvents={runEvents} runState={run.state} - spans={run?.trace?.spans ?? []} + trace={run.trace} type={visualizationType} /> diff --git a/web/src/components/RunDetailTrace/Visualization.tsx b/web/src/components/RunDetailTrace/Visualization.tsx index 864ad3381e..7c5ca645f5 100644 --- a/web/src/components/RunDetailTrace/Visualization.tsx +++ b/web/src/components/RunDetailTrace/Visualization.tsx @@ -3,59 +3,36 @@ import {TestRunStage} from 'constants/TestRunEvents.constants'; import {NodeTypesEnum} from 'constants/Visualization.constants'; import TestRunEvent from 'models/TestRunEvent.model'; import {useCallback, useEffect} from 'react'; -import {Node, NodeChange} from 'react-flow-renderer'; import {useAppDispatch, useAppSelector} from 'redux/hooks'; -import {changeNodes, initNodes, selectSpan} from 'redux/slices/Trace.slice'; +import {selectSpan} from 'redux/slices/Trace.slice'; import TraceSelectors from 'selectors/Trace.selectors'; import TraceAnalyticsService from 'services/Analytics/TestRunAnalytics.service'; -import TraceDiagramAnalyticsService from 'services/Analytics/TraceDiagramAnalytics.service'; import TestRunService from 'services/TestRun.service'; +import Trace from 'models/Trace.model'; import {TTestRunState} from 'types/TestRun.types'; -import Span from 'models/Span.model'; -import DAG from '../Visualization/components/DAG'; import Timeline from '../Visualization/components/Timeline'; import {VisualizationType} from './RunDetailTrace'; +import TraceDAG from './TraceDAG'; interface IProps { isDAGDisabled: boolean; runEvents: TestRunEvent[]; runState: TTestRunState; - spans: Span[]; + trace: Trace; type: VisualizationType; } -const Visualization = ({isDAGDisabled, runEvents, runState, spans, type}: IProps) => { +const Visualization = ({isDAGDisabled, runEvents, runState, trace, trace: {spans, rootSpan}, type}: IProps) => { const dispatch = useAppDispatch(); - const edges = useAppSelector(TraceSelectors.selectEdges); const matchedSpans = useAppSelector(TraceSelectors.selectMatchedSpans); - const nodes = useAppSelector(TraceSelectors.selectNodes); const selectedSpan = useAppSelector(TraceSelectors.selectSelectedSpan); const isMatchedMode = Boolean(matchedSpans.length); - // TODO: Trace will never change, we can calculate this once and then keep using it - useEffect(() => { - if (isDAGDisabled) return; - dispatch(initNodes({spans})); - }, [dispatch, isDAGDisabled, spans]); - useEffect(() => { if (selectedSpan) return; - // TODO: Find an easier way to get the first span - const firstSpan = spans.find(span => !span.parentId); - dispatch(selectSpan({spanId: firstSpan?.id ?? ''})); - }, [dispatch, selectedSpan, spans]); - - const onNodesChange = useCallback((changes: NodeChange[]) => dispatch(changeNodes({changes})), [dispatch]); - - const onNodeClick = useCallback( - (event: React.MouseEvent, {id}: Node) => { - event.stopPropagation(); - TraceDiagramAnalyticsService.onClickSpan(id); - dispatch(selectSpan({spanId: id})); - }, - [dispatch] - ); + dispatch(selectSpan({spanId: rootSpan.id ?? ''})); + }, [dispatch, rootSpan.id, selectedSpan, spans]); const onNodeClickTimeline = useCallback( (spanId: string) => { @@ -76,17 +53,8 @@ const Visualization = ({isDAGDisabled, runEvents, runState, spans, type}: IProps return ; } - return type === VisualizationType.Dag ? ( - + return type === VisualizationType.Dag && !isDAGDisabled ? ( + ) : ( nodes.length >= MAX_DAG_NODES && onNavigateToSpan(nodes[0]?.id)} onNodeClick={onNodeClick} onNodeDragStop={onNodeClick} onNodesChange={onNodesChange} + onlyRenderVisibleElements selectionKeyCode={null} + fitView={nodes.length <= MAX_DAG_NODES} > {isMiniMapActive && } diff --git a/web/src/components/Visualization/components/DAG/TestSpanNode/TestSpanNode.tsx b/web/src/components/Visualization/components/DAG/TestSpanNode/TestSpanNode.tsx index cf57b0ada0..8dccceee61 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}: IProps) => { +const TestSpanNode = ({data, id, selected, ...props}: IProps) => { const {span, testSpecs, testOutputs} = useSpanData(id); const {isLoading, onSelectAsCurrent, showSelectAsCurrent} = useSelectAsCurrent({ selected, diff --git a/web/src/models/DAG.model.ts b/web/src/models/DAG.model.ts index afbb69eedf..80744a0d6f 100644 --- a/web/src/models/DAG.model.ts +++ b/web/src/models/DAG.model.ts @@ -20,7 +20,10 @@ function DAG(spans: Span[], type: NodeTypesEnum) { if (b.id > a.id) return 1; return 0; }); + return DAGService.getEdgesAndNodes(nodesDatum); } +export const getShouldShowDAG = (spanCount: number): boolean => spanCount <= 200; + export default DAG; diff --git a/web/src/models/Span.model.ts b/web/src/models/Span.model.ts index 37f9c4cbae..580f156b82 100644 --- a/web/src/models/Span.model.ts +++ b/web/src/models/Span.model.ts @@ -48,7 +48,16 @@ const getSpanSignature = ( }, []); }; -const Span = ({id = '', attributes = {}, startTime = 0, endTime = 0, parentId = '', name = ''}: TRawSpan): Span => { +const defaultSpan: TRawSpan = { + id: '', + parentId: '', + name: '', + attributes: {}, + startTime: 0, + endTime: 0, +}; + +const Span = ({id = '', attributes = {}, startTime = 0, endTime = 0, parentId = '', name = ''} = defaultSpan): Span => { const mappedAttributeList: TSpanFlatAttribute[] = [{key: 'name', value: name}]; const attributeList = Object.entries(attributes) .map(([key, value]) => ({ diff --git a/web/src/models/TestRun.model.ts b/web/src/models/TestRun.model.ts index 4e39fedef8..e4dbcc3678 100644 --- a/web/src/models/TestRun.model.ts +++ b/web/src/models/TestRun.model.ts @@ -20,7 +20,7 @@ type TestRun = Model< TRawTestRun, { result: AssertionResults; - trace?: Trace; + trace: Trace; totalAssertionCount: number; failedAssertionCount: number; passedAssertionCount: number; @@ -138,7 +138,7 @@ const TestRun = ({ spanId, state, testVersion, - trace: trace ? Trace(trace) : undefined, + trace: trace ? Trace(trace) : Trace(), totalAssertionCount: getTestResultCount(result), failedAssertionCount: getTestResultCount(result, 'failed'), passedAssertionCount: getTestResultCount(result, 'passed'), diff --git a/web/src/models/Trace.model.ts b/web/src/models/Trace.model.ts index 45bcc80f28..d89eb3800e 100644 --- a/web/src/models/Trace.model.ts +++ b/web/src/models/Trace.model.ts @@ -1,18 +1,32 @@ -import { TTraceSchemas } from 'types/Common.types'; +import {TTraceSchemas} from 'types/Common.types'; import Span from './Span.model'; export type TRawTrace = TTraceSchemas['Trace']; +export type TSpanMap = Record; type Trace = { + flat: TSpanMap; spans: Span[]; traceId: string; + rootSpan: Span; }; -// TODO: keep the flat map of spans for easy access -const Trace = ({traceId = '', flat = {}}: TRawTrace): Trace => { - return { - traceId, - spans: Object.values(flat).map(rawSpan => Span(rawSpan)), - }; +const defaultTrace: TRawTrace = { + traceId: '', + flat: {}, + tree: {}, }; +const Trace = ({traceId = '', flat = {}, tree = {}} = defaultTrace): Trace => ({ + traceId, + rootSpan: Span(tree), + flat: Object.values(flat).reduce( + (acc, span) => ({ + ...acc, + [span.id || '']: Span(span), + }), + {} + ), + spans: Object.values(flat).map(rawSpan => Span(rawSpan)), +}); + export default Trace; diff --git a/web/src/models/__tests__/TestRun.model.test.ts b/web/src/models/__tests__/TestRun.model.test.ts index 91c806fb08..2aa1012335 100644 --- a/web/src/models/__tests__/TestRun.model.test.ts +++ b/web/src/models/__tests__/TestRun.model.test.ts @@ -7,7 +7,6 @@ describe('Test Run', () => { const testRunResult = TestRun(rawTestRunResult); expect(testRunResult.id).toEqual(rawTestRunResult.id); - expect(testRunResult.trace).not.toEqual(undefined); expect(testRunResult.totalAssertionCount).toEqual(0); expect(testRunResult.passedAssertionCount).toEqual(0); expect(testRunResult.failedAssertionCount).toEqual(0); @@ -21,7 +20,6 @@ describe('Test Run', () => { const testRunResult = TestRun(rawTestRunResult); - expect(testRunResult.trace).toEqual(undefined); expect(testRunResult.executionTime).toEqual(0); }); }); diff --git a/web/src/providers/TestRun/TestRun.provider.tsx b/web/src/providers/TestRun/TestRun.provider.tsx index 57a378658d..169d8d5801 100644 --- a/web/src/providers/TestRun/TestRun.provider.tsx +++ b/web/src/providers/TestRun/TestRun.provider.tsx @@ -5,6 +5,7 @@ import TestRun, {isRunStateFinished} from 'models/TestRun.model'; import TestRunEvent from 'models/TestRunEvent.model'; import TracetestAPI from 'redux/apis/Tracetest'; import TestProvider from '../Test'; +import LoadingSpinner, { SpinnerContainer } from '../../components/LoadingSpinner'; const {useGetRunByIdQuery, useGetRunEventsQuery, useStopRunMutation, useSkipPollingMutation} = TracetestAPI.instance; @@ -76,7 +77,9 @@ const TestRunProvider = ({children, testId, runId = 0}: IProps) => { ) : ( -
+ + + ); }; diff --git a/web/src/providers/TestSpecs/TestSpecs.provider.tsx b/web/src/providers/TestSpecs/TestSpecs.provider.tsx index 42e8fe4420..ac4179c0e2 100644 --- a/web/src/providers/TestSpecs/TestSpecs.provider.tsx +++ b/web/src/providers/TestSpecs/TestSpecs.provider.tsx @@ -60,11 +60,11 @@ const TestSpecsProvider = ({children, testId, runId}: IProps) => { const {test} = useTest(); const {run} = useTestRun(); - const assertionResults = useAppSelector(state => TestSpecsSelectors.selectAssertionResults(state)); - const specs = useAppSelector(state => TestSpecsSelectors.selectSpecs(state)); - const isDraftMode = useAppSelector(state => TestSpecsSelectors.selectIsDraftMode(state)); - const isLoading = useAppSelector(state => TestSpecsSelectors.selectIsLoading(state)); - const isInitialized = useAppSelector(state => TestSpecsSelectors.selectIsInitialized(state)); + const assertionResults = useAppSelector(TestSpecsSelectors.selectAssertionResults); + const specs = useAppSelector(TestSpecsSelectors.selectSpecs); + const isDraftMode = useAppSelector(TestSpecsSelectors.selectIsDraftMode); + const isLoading = useAppSelector(TestSpecsSelectors.selectIsLoading); + const isInitialized = useAppSelector(TestSpecsSelectors.selectIsInitialized); const selectedSpec = useAppSelector(TestSpecsSelectors.selectSelectedSpec); const selectedTestSpec = useAppSelector(state => TestSpecsSelectors.selectAssertionBySelector(state, selectedSpec!)); diff --git a/web/src/redux/slices/DAG.slice.ts b/web/src/redux/slices/DAG.slice.ts index db55e0aa55..cea4c45145 100644 --- a/web/src/redux/slices/DAG.slice.ts +++ b/web/src/redux/slices/DAG.slice.ts @@ -1,4 +1,4 @@ -import {createSlice, PayloadAction} from '@reduxjs/toolkit'; +import {createAsyncThunk, createSlice, PayloadAction} from '@reduxjs/toolkit'; import {applyNodeChanges, Edge, MarkerType, Node, NodeChange} from 'react-flow-renderer'; import {theme} from 'constants/Theme.constants'; @@ -23,8 +23,7 @@ const dagSlice = createSlice({ name: 'dag', initialState, reducers: { - initNodes(state, {payload}: PayloadAction<{spans: Span[]}>) { - const {edges, nodes} = DAGModel(payload.spans, NodeTypesEnum.TestSpan); + initNodes(state, {payload: {edges, nodes}}: PayloadAction<{edges: Edge[]; nodes: Node[]}>) { state.edges = edges; state.nodes = nodes; }, @@ -78,5 +77,13 @@ const dagSlice = createSlice({ }, }); -export const {initNodes, onNodesChange} = dagSlice.actions; +export const initNodes = createAsyncThunk( + 'dag/generateDagLayout', + async ({spans}, {dispatch}) => { + const {edges, nodes} = await DAGModel(spans, NodeTypesEnum.TestSpan); + dispatch(dagSlice.actions.initNodes({edges, nodes})); + } +); + +export const {onNodesChange} = dagSlice.actions; export default dagSlice.reducer; diff --git a/web/src/redux/slices/Trace.slice.ts b/web/src/redux/slices/Trace.slice.ts index d258bd9032..542c3f7d10 100644 --- a/web/src/redux/slices/Trace.slice.ts +++ b/web/src/redux/slices/Trace.slice.ts @@ -1,4 +1,4 @@ -import {createSlice, PayloadAction} from '@reduxjs/toolkit'; +import {createAsyncThunk, createSlice, PayloadAction} from '@reduxjs/toolkit'; import {applyNodeChanges, Edge, MarkerType, Node, NodeChange} from 'react-flow-renderer'; import {theme} from 'constants/Theme.constants'; @@ -26,10 +26,10 @@ const traceSlice = createSlice({ name: 'trace', initialState, reducers: { - initNodes(state, {payload}: PayloadAction<{spans: Span[]}>) { - const {edges, nodes} = DAGModel(payload.spans, NodeTypesEnum.TraceSpan); + initNodes(state, {payload: {edges, nodes}}: PayloadAction<{edges: Edge[]; nodes: Node[]}>) { state.edges = edges; state.nodes = nodes; + // Clear state state.matchedSpans = []; state.searchText = ''; @@ -70,5 +70,13 @@ const traceSlice = createSlice({ }, }); -export const {initNodes, changeNodes, selectSpan, matchSpans, setSearchText} = traceSlice.actions; +export const initNodes = createAsyncThunk( + 'trace/generateDagLayout', + async ({spans}, {dispatch}) => { + const {edges, nodes} = await DAGModel(spans, NodeTypesEnum.TraceSpan); + dispatch(traceSlice.actions.initNodes({edges, nodes})); + } +); + +export const {changeNodes, selectSpan, matchSpans, setSearchText} = traceSlice.actions; export default traceSlice.reducer; diff --git a/web/src/selectors/TestRun.selectors.ts b/web/src/selectors/TestRun.selectors.ts index 31f8ff57f8..307fd8dd68 100644 --- a/web/src/selectors/TestRun.selectors.ts +++ b/web/src/selectors/TestRun.selectors.ts @@ -16,7 +16,7 @@ const selectTestRun = (state: RootState, params: {testId: string; runId: number; // TODO: look for a simpler way of getting the span by id export const selectSpanById = createSelector([selectTestRun, selectParams], (testRun, params) => { const {trace} = testRun; - return trace?.spans?.find(span => span.id === params.spanId) ?? Span({id: params.spanId}); + return trace.flat[params.spanId] || Span({id: params.spanId}); }); const selectAnalyzerErrors = createSelector([selectTestRun], testRun => { diff --git a/web/src/services/DAG.service.ts b/web/src/services/DAG.service.ts index c8ec9358e6..e1fb1a7050 100644 --- a/web/src/services/DAG.service.ts +++ b/web/src/services/DAG.service.ts @@ -1,10 +1,11 @@ import {coordCenter, Dag, dagStratify, layeringSimplex, sugiyama} from 'd3-dag'; -import {MarkerType} from 'react-flow-renderer'; +import {Edge, MarkerType, Node} from 'react-flow-renderer'; import {theme} from 'constants/Theme.constants'; import {INodeDatum} from 'types/DAG.types'; +import {withLowPriority} from '../utils/Common'; -function getDagLayout(nodesDatum: INodeDatum[]) { +function getDagLayout(nodesDatum: INodeDatum[]): Dag, undefined> { const stratify = dagStratify(); const dag = stratify(nodesDatum); @@ -18,7 +19,7 @@ function getDagLayout(nodesDatum: INodeDatum[]) { return dag; } -function getNodes(dagLayout: Dag, undefined>) { +function getNodes(dagLayout: Dag, undefined>): Node[] { return dagLayout.descendants().map(({data: {id, data, type}, x, y}) => ({ data, id, @@ -27,7 +28,7 @@ function getNodes(dagLayout: Dag, undefined>) { })); } -function getEdges(dagLayout: Dag, undefined>) { +function getEdges(dagLayout: Dag, undefined>): Edge[] { return dagLayout.links().map(({source, target}) => ({ animated: false, id: `${source.data.id}-${target.data.id}`, @@ -39,12 +40,12 @@ function getEdges(dagLayout: Dag, undefined>) { } const DAGService = () => ({ - getEdgesAndNodes(nodesDatum: INodeDatum[]) { + async getEdgesAndNodes(nodesDatum: INodeDatum[]): Promise<{edges: Edge[]; nodes: Node[]}> { if (!nodesDatum.length) return {edges: [], nodes: []}; - const dagLayout = getDagLayout(nodesDatum); - const edges = getEdges(dagLayout); - const nodes = getNodes(dagLayout); + const dagLayout = await withLowPriority(getDagLayout)(nodesDatum); + const edges = await withLowPriority(getEdges)(dagLayout); + const nodes = await withLowPriority(getNodes)(dagLayout); return {edges, nodes}; }, diff --git a/web/src/utils/Common.ts b/web/src/utils/Common.ts index 6cdc3dab52..ef2c86c1e9 100644 --- a/web/src/utils/Common.ts +++ b/web/src/utils/Common.ts @@ -87,3 +87,12 @@ export const getParsedURL = (rawUrl: string): URL => { return new URL(rawUrl); }; + +export const withLowPriority = + any>(fn: T): ((...args: Parameters) => Promise>) => + (...args: Parameters): Promise> => + new Promise(resolve => { + setTimeout(() => { + resolve(fn(...args)); + }, 0); + }); From 2f025cfe3ef8c0e228164c63a1997cf212f7eb50 Mon Sep 17 00:00:00 2001 From: Oscar Reyes Date: Thu, 8 Feb 2024 14:22:47 -0600 Subject: [PATCH 5/9] chore(frontend): FE Improvements for the Test View (#3613) * adding async functionality * chore(frontend): DAG improvements * chore(frontend): DAG improvements * chore(frontend): fixing spinner styles * chore(frontend): fixing spinner styles * feat(frontend): fixing tests * chore(frontend): fixing spinner styles * chore(frontend): FE Improvements for the Test View * chore(frontend): reverting editor changes * chore(frontend): Adding memoization for useComplete hook * chore(frontend): Adding Search Service usage * chore(frontend): cleanup --- .../Expression/hooks/useAutoComplete.ts | 17 +++++------- .../Editor/Selector/hooks/useAutoComplete.ts | 13 ++++----- .../RunDetailTest/Visualization.tsx | 7 +++-- web/src/components/RunDetailTrace/Search.tsx | 27 +++++-------------- .../components/RunDetailTrace/TraceDAG.tsx | 1 - web/src/models/DAG.model.ts | 1 - web/src/models/SearchSpansResult.model.ts | 22 +++++++++++++++ .../Tracetest/endpoints/TestRun.endpoint.ts | 9 +++++++ web/src/redux/apis/Tracetest/index.ts | 2 ++ web/src/selectors/Assertion.selectors.ts | 2 +- web/src/selectors/Editor.selectors.ts | 26 ++++++++++++++++++ web/src/selectors/Span.selectors.ts | 5 +--- web/src/selectors/TestRun.selectors.ts | 1 - 13 files changed, 82 insertions(+), 51 deletions(-) create mode 100644 web/src/models/SearchSpansResult.model.ts create mode 100644 web/src/selectors/Editor.selectors.ts diff --git a/web/src/components/Inputs/Editor/Expression/hooks/useAutoComplete.ts b/web/src/components/Inputs/Editor/Expression/hooks/useAutoComplete.ts index 8336836d75..ea25aaebb0 100644 --- a/web/src/components/Inputs/Editor/Expression/hooks/useAutoComplete.ts +++ b/web/src/components/Inputs/Editor/Expression/hooks/useAutoComplete.ts @@ -1,10 +1,9 @@ import {useCallback} from 'react'; -import {noop, uniqBy} from 'lodash'; +import {noop} from 'lodash'; import {Completion, CompletionContext} from '@codemirror/autocomplete'; import {useAppStore} from 'redux/hooks'; -import AssertionSelectors from 'selectors/Assertion.selectors'; import VariableSetSelectors from 'selectors/VariableSet.selectors'; -import SpanSelectors from 'selectors/Span.selectors'; +import {selectExpressionAttributeList} from 'selectors/Editor.selectors'; import EditorService from 'services/Editor.service'; import {SupportedEditors} from 'constants/Editor.constants'; @@ -18,14 +17,10 @@ interface IProps { const useAutoComplete = ({testId, runId, onSelect = noop, autocompleteCustomValues}: IProps) => { const {getState} = useAppStore(); - const getAttributeList = useCallback(() => { - const state = getState(); - const spanIdList = SpanSelectors.selectMatchedSpans(state); - // TODO: this list is calculated multiple times while typing, we should memoize it - const attributeList = AssertionSelectors.selectAttributeList(state, testId, runId, spanIdList); - - return uniqBy(attributeList, 'key'); - }, [getState, runId, testId]); + const getAttributeList = useCallback( + () => selectExpressionAttributeList(getState(), testId, runId), + [getState, runId, testId] + ); const getSelectedVariableSetEntryList = useCallback(() => { const state = getState(); diff --git a/web/src/components/Inputs/Editor/Selector/hooks/useAutoComplete.ts b/web/src/components/Inputs/Editor/Selector/hooks/useAutoComplete.ts index c4350a1fea..c01a679f9d 100644 --- a/web/src/components/Inputs/Editor/Selector/hooks/useAutoComplete.ts +++ b/web/src/components/Inputs/Editor/Selector/hooks/useAutoComplete.ts @@ -11,8 +11,8 @@ import { Tokens, } from 'constants/Editor.constants'; import {useAppStore} from 'redux/hooks'; -import AssertionSelectors from 'selectors/Assertion.selectors'; import {escapeString} from 'utils/Common'; +import {selectSelectorAttributeList} from 'selectors/Editor.selectors'; interface IProps { testId: string; @@ -22,13 +22,10 @@ interface IProps { const useAutoComplete = ({testId, runId}: IProps) => { const {getState} = useAppStore(); - const getAttributeList = useCallback(() => { - const state = getState(); - // TODO: this list is calculated multiple times while typing, we should memoize it - const defaultList = AssertionSelectors.selectAllAttributeList(state, testId, runId); - - return defaultList; - }, [getState, runId, testId]); + const getAttributeList = useCallback( + () => selectSelectorAttributeList(getState(), testId, runId), + [getState, runId, testId] + ); return useCallback( async (context: CompletionContext) => { diff --git a/web/src/components/RunDetailTest/Visualization.tsx b/web/src/components/RunDetailTest/Visualization.tsx index f08bdc6f23..c3ac6b3567 100644 --- a/web/src/components/RunDetailTest/Visualization.tsx +++ b/web/src/components/RunDetailTest/Visualization.tsx @@ -22,16 +22,15 @@ export interface IProps { trace: Trace; } -const Visualization = ({isDAGDisabled, runEvents, runState, trace, trace: {spans}, type}: IProps) => { +const Visualization = ({isDAGDisabled, runEvents, runState, trace, trace: {spans, rootSpan}, type}: IProps) => { const {onSelectSpan, matchedSpans, onSetFocusedSpan, selectedSpan} = useSpan(); const {isOpen} = useTestSpecForm(); useEffect(() => { if (selectedSpan) return; - const firstSpan = spans.find(span => !span.parentId); - onSelectSpan(firstSpan?.id ?? ''); - }, [onSelectSpan, selectedSpan, spans]); + onSelectSpan(rootSpan.id); + }, [onSelectSpan, rootSpan, selectedSpan, spans]); const onNodeClickTimeline = useCallback( (spanId: string) => { diff --git a/web/src/components/RunDetailTrace/Search.tsx b/web/src/components/RunDetailTrace/Search.tsx index 0f33e2cb83..5e04dfd2a6 100644 --- a/web/src/components/RunDetailTrace/Search.tsx +++ b/web/src/components/RunDetailTrace/Search.tsx @@ -5,16 +5,13 @@ import {useCallback, useMemo, useState} from 'react'; import {Editor} from 'components/Inputs'; import {SupportedEditors} from 'constants/Editor.constants'; -import {useTestRun} from 'providers/TestRun/TestRun.provider'; import TracetestAPI from 'redux/apis/Tracetest'; import {useAppDispatch, useAppSelector} from 'redux/hooks'; import {matchSpans, selectSpan, setSearchText} from 'redux/slices/Trace.slice'; import TraceSelectors from 'selectors/Trace.selectors'; -import SpanService from 'services/Span.service'; -import EditorService from 'services/Editor.service'; import * as S from './RunDetailTrace.styled'; -const {useLazyGetSelectedSpansQuery} = TracetestAPI.instance; +const {useGetSearchedSpansMutation} = TracetestAPI.instance; interface IProps { runId: number; @@ -25,35 +22,25 @@ const Search = ({runId, testId}: IProps) => { const [search, setSearch] = useState(''); const dispatch = useAppDispatch(); const matchedSpans = useAppSelector(TraceSelectors.selectMatchedSpans); - const { - run: {trace: {spans = []} = {}}, - } = useTestRun(); - const [getSelectedSpans] = useLazyGetSelectedSpansQuery(); + const [getSearchedSpans] = useGetSearchedSpansMutation(); const handleSearch = useCallback( async (query: string) => { - const isValidSelector = EditorService.getIsQueryValid(SupportedEditors.Selector, query || ''); if (!query) { dispatch(matchSpans({spanIds: []})); dispatch(selectSpan({spanId: ''})); return; } - let spanIds = []; - if (isValidSelector) { - const selectedSpansData = await getSelectedSpans({query, runId, testId}).unwrap(); - spanIds = selectedSpansData.spanIds; - } else { - dispatch(setSearchText({searchText: query})); - spanIds = SpanService.searchSpanList(spans, query); - } - + const {spanIds} = await getSearchedSpans({query, runId, testId}).unwrap(); + dispatch(setSearchText({searchText: query})); dispatch(matchSpans({spanIds})); + if (spanIds.length) { dispatch(selectSpan({spanId: spanIds[0]})); } }, - [dispatch, getSelectedSpans, runId, spans, testId] + [dispatch, getSearchedSpans, runId, testId] ); const onSearch = useMemo(() => debounce(handleSearch, 500), [handleSearch]); @@ -67,7 +54,7 @@ const Search = ({runId, testId}: IProps) => { { onSearch(query); setSearch(query); diff --git a/web/src/components/RunDetailTrace/TraceDAG.tsx b/web/src/components/RunDetailTrace/TraceDAG.tsx index cbfedce81b..be414bc6e9 100644 --- a/web/src/components/RunDetailTrace/TraceDAG.tsx +++ b/web/src/components/RunDetailTrace/TraceDAG.tsx @@ -21,7 +21,6 @@ const TraceDAG = ({trace: {spans}, onNavigateToSpan}: IProps) => { const isMatchedMode = Boolean(matchedSpans.length); const dispatch = useAppDispatch(); - // TODO: Trace will never change, we can calculate this once and then keep using it useEffect(() => { dispatch(initNodes({spans})); }, [dispatch, spans]); diff --git a/web/src/models/DAG.model.ts b/web/src/models/DAG.model.ts index 80744a0d6f..d1d4ea3598 100644 --- a/web/src/models/DAG.model.ts +++ b/web/src/models/DAG.model.ts @@ -13,7 +13,6 @@ function getNodesDatumFromSpans(spans: Span[], type: NodeTypesEnum): INodeDatum< } function DAG(spans: Span[], type: NodeTypesEnum) { - // TODO: this runs twice for the list of spans const nodesDatum = getNodesDatumFromSpans(spans, type).sort((a, b) => { if (b.data.startTime !== a.data.startTime) return b.data.startTime - a.data.startTime; if (b.id < a.id) return -1; diff --git a/web/src/models/SearchSpansResult.model.ts b/web/src/models/SearchSpansResult.model.ts new file mode 100644 index 0000000000..affc074b76 --- /dev/null +++ b/web/src/models/SearchSpansResult.model.ts @@ -0,0 +1,22 @@ +import {Model, TTestSchemas} from '../types/Common.types'; + +export type TRawSearchSpansResult = TTestSchemas['SearchSpansResult']; +type SearchSpansResult = Model< + TRawSearchSpansResult, + { + spanIds: string[]; + spansIds?: undefined; + } +>; + +const defaultSearchSpansResult: TRawSearchSpansResult = { + spansIds: [], +}; + +function SearchSpansResult({spansIds = []} = defaultSearchSpansResult): SearchSpansResult { + return { + spanIds: spansIds, + }; +} + +export default SearchSpansResult; diff --git a/web/src/redux/apis/Tracetest/endpoints/TestRun.endpoint.ts b/web/src/redux/apis/Tracetest/endpoints/TestRun.endpoint.ts index e19a9ff977..61621b1f0b 100644 --- a/web/src/redux/apis/Tracetest/endpoints/TestRun.endpoint.ts +++ b/web/src/redux/apis/Tracetest/endpoints/TestRun.endpoint.ts @@ -8,6 +8,7 @@ import SelectedSpans, {TRawSelectedSpans} from 'models/SelectedSpans.model'; import Test from 'models/Test.model'; import TestRun, {TRawTestRun} from 'models/TestRun.model'; import TestRunEvent, {TRawTestRunEvent} from 'models/TestRunEvent.model'; +import SearchSpansResult, {TRawSearchSpansResult} from 'models/SearchSpansResult.model'; import {KnownSources} from 'models/RunMetadata.model'; import {TRawTestSpecs} from 'models/TestSpecs.model'; import {TTestApiEndpointBuilder} from '../Tracetest.api'; @@ -113,6 +114,14 @@ export const testRunEndpoints = (builder: TTestApiEndpointBuilder) => ({ providesTags: (result, error, {query}) => (result ? [{type: TracetestApiTags.SPAN, id: `${query}-LIST`}] : []), transformResponse: (rawSpanList: TRawSelectedSpans) => SelectedSpans(rawSpanList), }), + getSearchedSpans: builder.mutation({ + query: ({query, testId, runId}) => ({ + url: `/tests/${testId}/run/${runId}/search-spans`, + method: HTTP_METHOD.POST, + body: JSON.stringify({query}), + }), + transformResponse: (raw: TRawSearchSpansResult) => SearchSpansResult(raw), + }), getRunEvents: builder.query({ query: ({runId, testId}) => `/tests/${testId}/run/${runId}/events`, diff --git a/web/src/redux/apis/Tracetest/index.ts b/web/src/redux/apis/Tracetest/index.ts index f81596602d..c0c7935bab 100644 --- a/web/src/redux/apis/Tracetest/index.ts +++ b/web/src/redux/apis/Tracetest/index.ts @@ -68,6 +68,7 @@ const { useLazyTestOtlpConnectionQuery, useTestOtlpConnectionQuery, useResetTestOtlpConnectionMutation, + useGetSearchedSpansMutation, endpoints, } = TracetestAPI.instance; @@ -129,5 +130,6 @@ export { useLazyTestOtlpConnectionQuery, useTestOtlpConnectionQuery, useResetTestOtlpConnectionMutation, + useGetSearchedSpansMutation, endpoints, }; diff --git a/web/src/selectors/Assertion.selectors.ts b/web/src/selectors/Assertion.selectors.ts index 7351d3a2c8..8635b74055 100644 --- a/web/src/selectors/Assertion.selectors.ts +++ b/web/src/selectors/Assertion.selectors.ts @@ -29,7 +29,7 @@ const selectMatchedSpanList = createSelector(stateSelector, paramsSelector, (sta const {data: {trace} = {}} = TracetestAPI.instance.endpoints.getRunById.select({testId, runId})(state); if (!spanIdList.length) return trace?.spans || []; - return trace?.spans.filter(({id}) => spanIdList.includes(id)) || []; + return spanIdList.map((spanId) => trace!.flat[spanId]); }); const AssertionSelectors = () => { diff --git a/web/src/selectors/Editor.selectors.ts b/web/src/selectors/Editor.selectors.ts new file mode 100644 index 0000000000..9f511e96c2 --- /dev/null +++ b/web/src/selectors/Editor.selectors.ts @@ -0,0 +1,26 @@ +import {uniqBy} from 'lodash'; +import {createSelector} from '@reduxjs/toolkit'; +import {RootState} from 'redux/store'; +import AssertionSelectors from './Assertion.selectors'; +import SpanSelectors from './Span.selectors'; + +const stateSelector = (state: RootState) => state; +const paramsSelector = (state: RootState, testId: string, runId: number) => ({ + testId, + runId, +}); + +export const selectSelectorAttributeList = createSelector(stateSelector, paramsSelector, (state, {testId, runId}) => + AssertionSelectors.selectAllAttributeList(state, testId, runId) +); + +export const selectExpressionAttributeList = createSelector( + stateSelector, + paramsSelector, + SpanSelectors.selectMatchedSpans, + (state, {testId, runId}, spanIds) => { + const attributeList = AssertionSelectors.selectAttributeList(state, testId, runId, spanIds); + + return uniqBy(attributeList, 'key'); + } +); diff --git a/web/src/selectors/Span.selectors.ts b/web/src/selectors/Span.selectors.ts index 4709fda53f..9e6ecc8cc0 100644 --- a/web/src/selectors/Span.selectors.ts +++ b/web/src/selectors/Span.selectors.ts @@ -22,11 +22,8 @@ const SpanSelectors = () => ({ selectMatchedSpans, selectSpanById: createSelector(stateSelector, paramsSelector, (state, {spanId, testId, runId}) => { const {data: {trace} = {}} = TracetestAPI.instance.endpoints.getRunById.select({testId, runId})(state); - - // TODO: look for a simpler way of getting the span by id - const spanList = trace?.spans || []; - return spanList.find(span => span.id === spanId); + return trace?.flat[spanId]; }), selectSelectedSpan: createSelector(spansStateSelector, ({selectedSpan}) => selectedSpan), selectFocusedSpan: createSelector(spansStateSelector, ({focusedSpan}) => focusedSpan), diff --git a/web/src/selectors/TestRun.selectors.ts b/web/src/selectors/TestRun.selectors.ts index 307fd8dd68..9638ed067f 100644 --- a/web/src/selectors/TestRun.selectors.ts +++ b/web/src/selectors/TestRun.selectors.ts @@ -13,7 +13,6 @@ const selectTestRun = (state: RootState, params: {testId: string; runId: number; return data ?? TestRun({}); }; -// TODO: look for a simpler way of getting the span by id export const selectSpanById = createSelector([selectTestRun, selectParams], (testRun, params) => { const {trace} = testRun; return trace.flat[params.spanId] || Span({id: params.spanId}); From 8c6fdc97556d0676d37eb90007f72146e17484f5 Mon Sep 17 00:00:00 2001 From: Jorge Padilla Date: Fri, 9 Feb 2024 15:45:20 -0500 Subject: [PATCH 6/9] feat(frontend): implement virtual list for timeline view (#3617) * feat(frontend): implement virtual list for timeline view * remove prop * add header and collapse --- web/package-lock.json | 32 ++++ web/package.json | 2 + .../RunDetailTrace/Visualization.tsx | 23 +-- .../DAG/TestSpanNode/TestSpanNode.tsx | 2 +- .../Timeline/BaseSpanNode/BaseSpanNodeV2.tsx | 66 ++++++++ .../Timeline/BaseSpanNode/ConnectorV2.tsx | 47 ++++++ .../components/Timeline/Header.tsx | 22 +++ .../components/Timeline/ListWrapper.tsx | 33 ++++ .../components/Timeline/NavigationWrapper.tsx | 10 ++ .../components/Timeline/SpanNodeFactoryV2.tsx | 30 ++++ .../components/Timeline/Ticks/Ticks.styled.ts | 37 +++++ .../components/Timeline/Ticks/Ticks.tsx | 40 +++++ .../components/Timeline/Timeline.provider.tsx | 120 ++++++++++++++ .../components/Timeline/TimelineV2.styled.ts | 150 ++++++++++++++++++ .../components/Timeline/TimelineV2.tsx | 25 +++ .../TraceSpanNode/TraceSpanNodeV2.tsx | 19 +++ web/src/services/Timeline.service.ts | 18 +++ web/src/utils/Date.ts | 44 +++++ 18 files changed, 698 insertions(+), 22 deletions(-) create mode 100644 web/src/components/Visualization/components/Timeline/BaseSpanNode/BaseSpanNodeV2.tsx create mode 100644 web/src/components/Visualization/components/Timeline/BaseSpanNode/ConnectorV2.tsx create mode 100644 web/src/components/Visualization/components/Timeline/Header.tsx create mode 100644 web/src/components/Visualization/components/Timeline/ListWrapper.tsx create mode 100644 web/src/components/Visualization/components/Timeline/NavigationWrapper.tsx create mode 100644 web/src/components/Visualization/components/Timeline/SpanNodeFactoryV2.tsx create mode 100644 web/src/components/Visualization/components/Timeline/Ticks/Ticks.styled.ts create mode 100644 web/src/components/Visualization/components/Timeline/Ticks/Ticks.tsx create mode 100644 web/src/components/Visualization/components/Timeline/Timeline.provider.tsx create mode 100644 web/src/components/Visualization/components/Timeline/TimelineV2.styled.ts create mode 100644 web/src/components/Visualization/components/Timeline/TimelineV2.tsx create mode 100644 web/src/components/Visualization/components/Timeline/TraceSpanNode/TraceSpanNodeV2.tsx 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; From 34a21fc485203e14758a26990cc215a71ec05bb0 Mon Sep 17 00:00:00 2001 From: Jorge Padilla Date: Mon, 12 Feb 2024 13:03:27 -0500 Subject: [PATCH 7/9] feat: add timeline view connectors (#3623) --- .../Timeline/BaseSpanNode/BaseSpanNodeV2.tsx | 5 +- .../Timeline/BaseSpanNode/ConnectorV2.tsx | 77 +++++++++++-------- .../components/Timeline/TimelineV2.styled.ts | 2 +- web/src/constants/Timeline.constants.ts | 1 + 4 files changed, 46 insertions(+), 39 deletions(-) diff --git a/web/src/components/Visualization/components/Timeline/BaseSpanNode/BaseSpanNodeV2.tsx b/web/src/components/Visualization/components/Timeline/BaseSpanNode/BaseSpanNodeV2.tsx index 17edffd5d8..c42acaed0c 100644 --- a/web/src/components/Visualization/components/Timeline/BaseSpanNode/BaseSpanNodeV2.tsx +++ b/web/src/components/Visualization/components/Timeline/BaseSpanNode/BaseSpanNodeV2.tsx @@ -4,8 +4,6 @@ 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)}%`; } @@ -26,7 +24,6 @@ const BaseSpanNode = ({header, index, node, span, style}: IProps) => { 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 (
@@ -42,7 +39,7 @@ const BaseSpanNode = ({header, index, node, span, style}: IProps) => { hasParent={!!node.data.parentId} id={node.data.id} isCollapsed={isCollapsed} - leftPadding={leftPadding} + nodeDepth={node.depth} onCollapse={onSpanCollapse} totalChildren={node.children} /> diff --git a/web/src/components/Visualization/components/Timeline/BaseSpanNode/ConnectorV2.tsx b/web/src/components/Visualization/components/Timeline/BaseSpanNode/ConnectorV2.tsx index 650db16451..cb332a3f84 100644 --- a/web/src/components/Visualization/components/Timeline/BaseSpanNode/ConnectorV2.tsx +++ b/web/src/components/Visualization/components/Timeline/BaseSpanNode/ConnectorV2.tsx @@ -1,47 +1,56 @@ +import {BaseLeftPaddingV2} from 'constants/Timeline.constants'; import * as S from '../TimelineV2.styled'; interface IProps { hasParent: boolean; id: string; isCollapsed: boolean; - leftPadding: number; + nodeDepth: number; onCollapse(id: string): void; totalChildren: number; } -const Connector = ({hasParent, id, isCollapsed, leftPadding, onCollapse, totalChildren}: IProps) => ( - - {hasParent && ( - <> - - - - )} +const Connector = ({hasParent, id, isCollapsed, nodeDepth, onCollapse, totalChildren}: IProps) => { + const leftPadding = nodeDepth * BaseLeftPaddingV2; - {totalChildren > 0 ? ( - <> - {!isCollapsed && } - - - {totalChildren} - - { - event.stopPropagation(); - onCollapse(id); - }} - /> - - ) : ( - - )} - -); + return ( + + {hasParent && ( + <> + + + + )} + + {totalChildren > 0 ? ( + <> + {!isCollapsed && } + + + {totalChildren} + + { + event.stopPropagation(); + onCollapse(id); + }} + /> + + ) : ( + + )} + + {new Array(nodeDepth).fill(0).map((_, index) => { + return ; + })} + + ); +}; export default Connector; diff --git a/web/src/components/Visualization/components/Timeline/TimelineV2.styled.ts b/web/src/components/Visualization/components/Timeline/TimelineV2.styled.ts index 08d73185dd..827d7b88a3 100644 --- a/web/src/components/Visualization/components/Timeline/TimelineV2.styled.ts +++ b/web/src/components/Visualization/components/Timeline/TimelineV2.styled.ts @@ -114,7 +114,7 @@ export const CircleDot = styled.circle` `; export const LineBase = styled.line` - stroke: ${({theme}) => theme.color.textSecondary}; + stroke: ${({theme}) => theme.color.borderLight}; `; export const RectBase = styled.rect<{$isActive?: boolean}>` diff --git a/web/src/constants/Timeline.constants.ts b/web/src/constants/Timeline.constants.ts index a8c3a1c480..4a4677dafd 100644 --- a/web/src/constants/Timeline.constants.ts +++ b/web/src/constants/Timeline.constants.ts @@ -3,3 +3,4 @@ export const AxisOffset = 100; export const NodeHeight = 66; export const NodeOverlayHeight = NodeHeight - 2; export const BaseLeftPadding = 10; +export const BaseLeftPaddingV2 = 16; From 1b97c98a8e74691738a193ecba5fc40897dd020d Mon Sep 17 00:00:00 2001 From: Oscar Reyes Date: Tue, 13 Feb 2024 10:40:32 -0600 Subject: [PATCH 8/9] feat(frontend): Implemeting new Timeline for the Test Page (#3627) * feat(frontend): Implemeting new Timeline for the Test Page * feat(frontend): Fixing tests * feat(frontend): Fixing tests --- .../e2e/TestRunDetail/CreateAssertion.spec.ts | 8 -- web/cypress/support/commands.ts | 1 - web/docker-compose.yaml | 127 ++++++++++++++++++ web/package-lock.json | 10 ++ web/package.json | 3 + .../components/RunDetailTest/TestPanel.tsx | 1 - .../RunDetailTest/Visualization.tsx | 21 +-- .../components/RunDetailTrace/TraceDAG.tsx | 6 +- .../RunDetailTrace/Visualization.tsx | 24 +++- .../components/TestResults/TestResults.tsx | 2 +- .../components/TestSpec/TestSpec.styled.ts | 1 + web/src/components/TestSpecDetail/Content.tsx | 80 +++++------ .../components/TestSpecDetail/ResultCard.tsx | 65 +++++++++ .../components/TestSpecDetail/SpanHeader.tsx | 30 +++-- .../TestSpecDetail/TestSpecDetail.styled.ts | 11 +- .../TestSpecDetail/TestSpecDetail.tsx | 59 ++++---- .../components/TestSpecs/TestSpecs.styled.ts | 6 - web/src/components/TestSpecs/TestSpecs.tsx | 41 ++++-- .../Timeline/BaseSpanNode/BaseSpanNodeV2.tsx | 3 +- .../components/Timeline/Timeline.provider.tsx | 29 ++-- .../components/Timeline/TimelineV2.tsx | 16 ++- .../TraceSpanNode/TraceSpanNodeV2.tsx | 2 +- web/src/models/Trace.model.ts | 28 ++-- web/src/redux/actions/Router.actions.ts | 9 +- web/src/redux/actions/TestSpecs.actions.ts | 1 + 25 files changed, 401 insertions(+), 183 deletions(-) create mode 100644 web/docker-compose.yaml create mode 100644 web/src/components/TestSpecDetail/ResultCard.tsx diff --git a/web/cypress/e2e/TestRunDetail/CreateAssertion.spec.ts b/web/cypress/e2e/TestRunDetail/CreateAssertion.spec.ts index d4ac6ae833..404641d870 100644 --- a/web/cypress/e2e/TestRunDetail/CreateAssertion.spec.ts +++ b/web/cypress/e2e/TestRunDetail/CreateAssertion.spec.ts @@ -48,7 +48,6 @@ describe('Create Assertion', () => { cy.get('[data-cy=assertion-form-submit-button]').click(); - cy.get('[data-cy=test-specs-container]').should('be.visible'); cy.get('[data-cy=test-spec-container]').should('have.lengthOf', 1); }); @@ -74,7 +73,6 @@ describe('Create Assertion', () => { cy.get('[data-cy=assertion-form-submit-button]').click(); - cy.get('[data-cy=test-specs-container]').should('be.visible'); cy.get('[data-cy=test-spec-container]').should('have.lengthOf', 1); }); @@ -98,8 +96,6 @@ describe('Create Assertion', () => { cy.selectOperator(1); cy.get('[data-cy=assertion-form-submit-button]').click(); - - cy.get('[data-cy=test-specs-container]').should('be.visible'); cy.get('[data-cy=test-spec-container]').should('have.lengthOf', 1); }); @@ -124,8 +120,6 @@ describe('Create Assertion', () => { cy.selectOperator(1); cy.get('[data-cy=assertion-form-submit-button]').click(); - - cy.get('[data-cy=test-specs-container]').should('be.visible'); cy.get('[data-cy=test-spec-container]').should('have.lengthOf', 1); }); @@ -151,9 +145,7 @@ describe('Create Assertion', () => { cy.get('[data-cy=assertion-form-submit-button]').click(); - cy.get('[data-cy=test-specs-container]').should('exist'); cy.get('[data-cy=test-spec-container]').should('have.lengthOf', 2); - cy.get('[data-cy=trace-actions-revert-all').click(); cy.get('[data-cy=test-spec-container]').should('have.lengthOf', 0); }); diff --git a/web/cypress/support/commands.ts b/web/cypress/support/commands.ts index 62a6d24621..8e14833ba4 100644 --- a/web/cypress/support/commands.ts +++ b/web/cypress/support/commands.ts @@ -224,7 +224,6 @@ Cypress.Commands.add('createAssertion', () => { cy.get('[data-cy=assertion-check-operator]').click({force: true}); cy.get('[data-cy=assertion-form-submit-button]').click(); - cy.get('[data-cy=test-specs-container]').should('be.visible'); cy.get('[data-cy=test-spec-container]').should('have.lengthOf', 1); }); diff --git a/web/docker-compose.yaml b/web/docker-compose.yaml new file mode 100644 index 0000000000..e9cfa7ef85 --- /dev/null +++ b/web/docker-compose.yaml @@ -0,0 +1,127 @@ +version: '3.2' +services: + tracetest: + restart: unless-stopped + image: kubeshop/tracetest:${TAG:-latest} + extra_hosts: + - 'host.docker.internal:host-gateway' + build: + context: . + volumes: + - type: bind + source: ../local-config/tracetest.config.yaml + target: /app/tracetest.yaml + - type: bind + source: ../local-config/tracetest.provision.yaml + target: /app/provisioning.yaml + ports: + - 11633:11633 + command: --provisioning-file /app/provisioning.yaml + healthcheck: + test: ['CMD', 'wget', '--spider', 'localhost:11633'] + interval: 1s + timeout: 3s + retries: 60 + depends_on: + postgres: + condition: service_healthy + environment: + TRACETEST_DEV: ${TRACETEST_DEV} + TRACETEST_TESTPIPELINES_TRIGGEREXECUTE_ENABLED: ${TRACETEST_TESTPIPELINES_TRIGGEREXECUTE_ENABLED} + TRACETEST_TESTPIPELINES_TRACEFETCH_ENABLED: ${TRACETEST_TESTPIPELINES_TRACEFETCH_ENABLED} + TRACETEST_DATASTOREPIPELINES_TESTCONNECTION_ENABLED: ${TRACETEST_DATASTOREPIPELINES_TESTCONNECTION_ENABLED} + + postgres: + image: postgres:15.2 + environment: + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + ports: + - 5432:5432 + healthcheck: + test: pg_isready -U "$$POSTGRES_USER" -d "$$POSTGRES_DB" + interval: 1s + timeout: 5s + retries: 60 + + otel-collector: + image: otel/opentelemetry-collector-contrib:0.59.0 + extra_hosts: + - 'host.docker.internal:host-gateway' + ports: + - '4317:4317' + command: + - '--config' + - '/otel-local-config.yaml' + volumes: + - ../local-config/collector.config.yaml:/otel-local-config.yaml + depends_on: + - tracetest + + cache: + image: redis:6 + ports: + - 6379:6379 + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 1s + timeout: 3s + retries: 60 + + queue: + image: rabbitmq:3.12 + restart: unless-stopped + ports: + - 5672:5672 + - 15672:15672 + healthcheck: + test: rabbitmq-diagnostics -q check_running + interval: 1s + timeout: 5s + retries: 60 + + demo-api: + image: kubeshop/demo-pokemon-api:latest + restart: unless-stopped + pull_policy: always + environment: + REDIS_URL: cache + DATABASE_URL: postgresql://postgres:postgres@postgres:5432/postgres?schema=public + RABBITMQ_HOST: queue + POKE_API_BASE_URL: https://pokeapi.co/api/v2 + COLLECTOR_ENDPOINT: http://otel-collector:4317 + NPM_RUN_COMMAND: api + healthcheck: + test: ['CMD', 'wget', '--spider', 'localhost:8081'] + interval: 1s + timeout: 3s + retries: 60 + ports: + - 8081:8081 + depends_on: + postgres: + condition: service_healthy + cache: + condition: service_healthy + queue: + condition: service_healthy + + worker: + image: kubeshop/demo-pokemon-api:latest + restart: unless-stopped + pull_policy: always + environment: + REDIS_URL: cache + DATABASE_URL: postgresql://postgres:postgres@postgres:5432/postgres?schema=public + RABBITMQ_HOST: queue + POKE_API_BASE_URL: https://pokeapi.co/api/v2 + COLLECTOR_ENDPOINT: http://otel-collector:4317 + NPM_RUN_COMMAND: worker + depends_on: + postgres: + condition: service_healthy + cache: + condition: service_healthy + queue: + condition: service_healthy + diff --git a/web/package-lock.json b/web/package-lock.json index 98056c00a4..d356e453f8 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-virtualized-auto-sizer": "1.0.22", "react-window": "1.8.10", "redux-first-history": "5.0.12", "styled-components": "5.3.3", @@ -21181,6 +21182,15 @@ "react": ">= 0.14.0" } }, + "node_modules/react-virtualized-auto-sizer": { + "version": "1.0.22", + "resolved": "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.22.tgz", + "integrity": "sha512-2CGT/4rZ6jvVkKqzJGnZlyQxj4rWPKAwZR80vMlmpYToN18xaB0yIODOoBltWZLbSgpHBpIk0Ae1nrVO9hVClA==", + "peerDependencies": { + "react": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0", + "react-dom": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-window": { "version": "1.8.10", "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.10.tgz", diff --git a/web/package.json b/web/package.json index b00bf59f45..7050ec53a1 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-virtualized-auto-sizer": "1.0.22", "react-window": "1.8.10", "redux-first-history": "5.0.12", "styled-components": "5.3.3", @@ -82,6 +83,8 @@ "cy:open": "cypress open", "cy:run": "cypress run", "cy:ci": "cypress run --parallel --record --key $CYPRESS_RECORD_KEY", + "cy:local:run": "POKEMON_HTTP_ENDPOINT=http://demo-api:8081 cypress run", + "cy:local:open": "POKEMON_HTTP_ENDPOINT=http://demo-api:8081 cypress open", "prettier": "prettier --write ./src", "less": "lessc --js src/antd-theme/antd-customized.less src/antd-theme/antd-customized.css" }, diff --git a/web/src/components/RunDetailTest/TestPanel.tsx b/web/src/components/RunDetailTest/TestPanel.tsx index b1bf882227..e7b3b19dd8 100644 --- a/web/src/components/RunDetailTest/TestPanel.tsx +++ b/web/src/components/RunDetailTest/TestPanel.tsx @@ -224,7 +224,6 @@ const TestPanel = ({run, testId, runEvents}: IProps) => { onDelete={handleDelete} onEdit={handleEdit} onRevert={handleRevert} - onSelectSpan={handleSelectSpan} selectedSpan={selectedSpan?.id} testSpec={selectedTestSpec} /> diff --git a/web/src/components/RunDetailTest/Visualization.tsx b/web/src/components/RunDetailTest/Visualization.tsx index c3ac6b3567..6ea97b2b23 100644 --- a/web/src/components/RunDetailTest/Visualization.tsx +++ b/web/src/components/RunDetailTest/Visualization.tsx @@ -2,13 +2,11 @@ import {useCallback, useEffect} from 'react'; import {VisualizationType} from 'components/RunDetailTrace/RunDetailTrace'; import RunEvents from 'components/RunEvents'; -import {useTestSpecForm} from 'components/TestSpecForm/TestSpecForm.provider'; -import Timeline from 'components/Visualization/components/Timeline'; +import TimelineV2 from 'components/Visualization/components/Timeline/TimelineV2'; import {TestRunStage} from 'constants/TestRunEvents.constants'; import {NodeTypesEnum} from 'constants/Visualization.constants'; import TestRunEvent from 'models/TestRunEvent.model'; import {useSpan} from 'providers/Span/Span.provider'; -import TraceAnalyticsService from 'services/Analytics/TestRunAnalytics.service'; import Trace from 'models/Trace.model'; import TestRunService from 'services/TestRun.service'; import {TTestRunState} from 'types/TestRun.types'; @@ -25,21 +23,11 @@ export interface IProps { const Visualization = ({isDAGDisabled, runEvents, runState, trace, trace: {spans, rootSpan}, type}: IProps) => { const {onSelectSpan, matchedSpans, onSetFocusedSpan, selectedSpan} = useSpan(); - const {isOpen} = useTestSpecForm(); - useEffect(() => { if (selectedSpan) return; onSelectSpan(rootSpan.id); }, [onSelectSpan, rootSpan, selectedSpan, spans]); - const onNodeClickTimeline = useCallback( - (spanId: string) => { - TraceAnalyticsService.onTimelineSpanClick(spanId); - onSelectSpan(spanId); - }, - [onSelectSpan] - ); - const onNavigateToSpan = useCallback( (spanId: string) => { onSelectSpan(spanId); @@ -55,12 +43,11 @@ const Visualization = ({isDAGDisabled, runEvents, runState, trace, trace: {spans return type === VisualizationType.Dag && !isDAGDisabled ? ( ) : ( - 0 || isOpen} + diff --git a/web/src/components/RunDetailTrace/TraceDAG.tsx b/web/src/components/RunDetailTrace/TraceDAG.tsx index be414bc6e9..a874852440 100644 --- a/web/src/components/RunDetailTrace/TraceDAG.tsx +++ b/web/src/components/RunDetailTrace/TraceDAG.tsx @@ -11,11 +11,11 @@ import LoadingSpinner, {SpinnerContainer} from '../LoadingSpinner'; interface IProps { trace: Trace; onNavigateToSpan(spanId: string): void; + matchedSpans: string[]; + selectedSpan: string; } -const TraceDAG = ({trace: {spans}, onNavigateToSpan}: IProps) => { - const matchedSpans = useAppSelector(TraceSelectors.selectMatchedSpans); - const selectedSpan = useAppSelector(TraceSelectors.selectSelectedSpan); +const TraceDAG = ({trace: {spans}, matchedSpans, selectedSpan, onNavigateToSpan}: IProps) => { const nodes = useAppSelector(TraceSelectors.selectNodes); const edges = useAppSelector(TraceSelectors.selectEdges); const isMatchedMode = Boolean(matchedSpans.length); diff --git a/web/src/components/RunDetailTrace/Visualization.tsx b/web/src/components/RunDetailTrace/Visualization.tsx index b0d4fc8419..b2a52f9135 100644 --- a/web/src/components/RunDetailTrace/Visualization.tsx +++ b/web/src/components/RunDetailTrace/Visualization.tsx @@ -24,6 +24,7 @@ interface IProps { const Visualization = ({isDAGDisabled, runEvents, runState, trace, trace: {spans, rootSpan}, type}: IProps) => { const dispatch = useAppDispatch(); const selectedSpan = useAppSelector(TraceSelectors.selectSelectedSpan); + const matchedSpans = useAppSelector(TraceSelectors.selectMatchedSpans); useEffect(() => { if (selectedSpan) return; @@ -38,14 +39,33 @@ const Visualization = ({isDAGDisabled, runEvents, runState, trace, trace: {spans [dispatch] ); + const onNodeClickTimeline = useCallback( + (spanId: string) => { + dispatch(selectSpan({spanId})); + }, + [dispatch] + ); + if (TestRunService.shouldDisplayTraceEvents(runState, spans.length)) { return ; } return type === VisualizationType.Dag && !isDAGDisabled ? ( - + ) : ( - + ); }; diff --git a/web/src/components/TestResults/TestResults.tsx b/web/src/components/TestResults/TestResults.tsx index 77968585b9..715d768982 100644 --- a/web/src/components/TestResults/TestResults.tsx +++ b/web/src/components/TestResults/TestResults.tsx @@ -31,7 +31,7 @@ const TestResults = ({onDelete, onEdit, onRevert}: IProps) => { onSelectSpan(testSpec?.spanIds[0] || ''); setSelectedSpec(testSpec?.selector); }, - [assertionResults?.resultList, onSelectSpan, onSetFocusedSpan, setSelectedSpec] + [assertionResults, onSelectSpan, onSetFocusedSpan, setSelectedSpec] ); return ( diff --git a/web/src/components/TestSpec/TestSpec.styled.ts b/web/src/components/TestSpec/TestSpec.styled.ts index 28b70e3683..dd5a301bef 100644 --- a/web/src/components/TestSpec/TestSpec.styled.ts +++ b/web/src/components/TestSpec/TestSpec.styled.ts @@ -17,6 +17,7 @@ export const Container = styled.div<{$isDeleted: boolean}>` display: flex; gap: 12px; padding: 16px; + margin-bottom: 16px; > div:first-child { opacity: ${({$isDeleted}) => ($isDeleted ? 0.5 : 1)}; diff --git a/web/src/components/TestSpecDetail/Content.tsx b/web/src/components/TestSpecDetail/Content.tsx index 943744a7e0..4af8a4866c 100644 --- a/web/src/components/TestSpecDetail/Content.tsx +++ b/web/src/components/TestSpecDetail/Content.tsx @@ -1,24 +1,19 @@ -import {useEffect, useMemo} from 'react'; +import {useEffect, useMemo, useRef} from 'react'; +import {FixedSizeList as List} from 'react-window'; +import AutoSizer, {Size} from 'react-virtualized-auto-sizer'; -import {SemanticGroupNames} from 'constants/SemanticGroupNames.constants'; -import {useTestRun} from 'providers/TestRun/TestRun.provider'; import {useAppSelector} from 'redux/hooks'; import TestSpecsSelectors from 'selectors/TestSpecs.selectors'; import AssertionService from 'services/Assertion.service'; import {TAssertionResultEntry} from 'models/AssertionResults.model'; -import {useTest} from 'providers/Test/Test.provider'; -import useScrollTo from 'hooks/useScrollTo'; -import Assertion from './Assertion'; import Header from './Header'; -import SpanHeader from './SpanHeader'; -import * as S from './TestSpecDetail.styled'; +import ResultCard from './ResultCard'; interface IProps { onClose(): void; onDelete(selector: string): void; onEdit(assertionResult: TAssertionResultEntry, name: string): void; onRevert(originalSelector: string): void; - onSelectSpan(spanId: string): void; selectedSpan?: string; testSpec: TAssertionResultEntry; } @@ -28,17 +23,10 @@ const Content = ({ onDelete, onEdit, onRevert, - onSelectSpan, selectedSpan, testSpec, testSpec: {resultList, selector, spanIds}, }: IProps) => { - const { - run: {trace, id: runId}, - } = useTestRun(); - const { - test: {id: testId}, - } = useTest(); const { isDeleted = false, isDraft = false, @@ -46,12 +34,24 @@ const Content = ({ name = '', } = useAppSelector(state => TestSpecsSelectors.selectSpecBySelector(state, selector)) || {}; const totalPassedChecks = useMemo(() => AssertionService.getTotalPassedChecks(resultList), [resultList]); - const results = useMemo(() => AssertionService.getResultsHashedBySpanId(resultList), [resultList]); - const scrollTo = useScrollTo(); + const results = useMemo(() => Object.entries(AssertionService.getResultsHashedBySpanId(resultList)), [resultList]); + + const listRef = useRef(null); useEffect(() => { - scrollTo(`assertion-result-${selectedSpan}`); - }, [scrollTo, selectedSpan]); + if (listRef.current) { + const index = results.findIndex(([spanId]) => spanId === selectedSpan); + if (index !== -1) { + listRef?.current?.scrollToItem(index, 'smart'); + } + } + }, [results, selectedSpan]); + + const itemSize = useMemo(() => { + const [, checkResults = []] = results[0]; + + return checkResults.length * 72.59 + 40 + 16; + }, [results]); return ( <> @@ -76,34 +76,20 @@ const Content = ({ title={!selector && !name ? 'All Spans' : name} /> - {/* // TODO: consider a virtualized list here */} - {Object.entries(results).map(([spanId, checkResults]) => { - const span = trace?.spans.find(({id}) => id === spanId); - - return ( - } - type="inner" - $isSelected={spanId === selectedSpan} - $type={span?.type ?? SemanticGroupNames.General} - id={`assertion-result-${spanId}`} + + {({height, width}: Size) => ( + - onSelectSpan(span?.id ?? '')}> - {checkResults.map(checkResult => ( - - ))} - - - ); - })} + {ResultCard} + + )} + ); }; diff --git a/web/src/components/TestSpecDetail/ResultCard.tsx b/web/src/components/TestSpecDetail/ResultCard.tsx new file mode 100644 index 0000000000..95f4b33876 --- /dev/null +++ b/web/src/components/TestSpecDetail/ResultCard.tsx @@ -0,0 +1,65 @@ +import {useCallback, useMemo} from 'react'; +import {useSpan} from 'providers/Span/Span.provider'; +import {useTest} from 'providers/Test/Test.provider'; +import {ICheckResult} from 'types/Assertion.types'; +import {SemanticGroupNames} from 'constants/SemanticGroupNames.constants'; +import {useTestRun} from 'providers/TestRun/TestRun.provider'; +import * as S from './TestSpecDetail.styled'; +import Assertion from './Assertion'; +import SpanHeader from './SpanHeader'; + +interface IProps { + index: number; + data: [string, ICheckResult[]][]; + style: React.CSSProperties; +} + +const ResultCard = ({index, data, style}: IProps) => { + const [spanId, checkResults] = useMemo(() => data[index], [data, index]); + const { + run: {trace, id: runId}, + } = useTestRun(); + const { + test: {id: testId}, + } = useTest(); + const {selectedSpan, onSetFocusedSpan, onSelectSpan} = useSpan(); + + const onFocusAndSelect = useCallback(() => { + onSelectSpan(spanId); + onSetFocusedSpan(spanId); + }, [onSelectSpan, onSetFocusedSpan, spanId]); + + const span = trace?.flat[spanId]; + + return ( + } + type="inner" + $isSelected={spanId === selectedSpan?.id} + $type={span?.type ?? SemanticGroupNames.General} + id={`assertion-result-${spanId}`} + onClick={() => onSelectSpan(span?.id ?? '')} + > + + {checkResults.map(checkResult => ( + + ))} + + + ); +}; + +export default ResultCard; diff --git a/web/src/components/TestSpecDetail/SpanHeader.tsx b/web/src/components/TestSpecDetail/SpanHeader.tsx index 03ed113014..4817ca3d92 100644 --- a/web/src/components/TestSpecDetail/SpanHeader.tsx +++ b/web/src/components/TestSpecDetail/SpanHeader.tsx @@ -1,5 +1,6 @@ import {SettingOutlined, ToolOutlined} from '@ant-design/icons'; +import {Typography} from 'antd'; import * as SSpanNode from 'components/Visualization/components/DAG/BaseSpanNode/BaseSpanNode.styled'; import {SemanticGroupNamesToText} from 'constants/SemanticGroupNames.constants'; import {SpanKindToText} from 'constants/Span.constants'; @@ -16,20 +17,25 @@ const SpanHeader = ({onSelectSpan, span}: IProps) => { const {kind, name, service, system, type} = SpanService.getSpanInfo(span); return ( - onSelectSpan(span?.id ?? '')}> - - {name} - - - {`${service} ${SpanKindToText[kind]}`} - - {Boolean(system) && ( + + onSelectSpan(span?.id ?? '')}> + + {name} - - {system} + + {`${service} ${SpanKindToText[kind]}`} - )} - + {Boolean(system) && ( + + + {system} + + )} + + + {span?.id} + + ); }; diff --git a/web/src/components/TestSpecDetail/TestSpecDetail.styled.ts b/web/src/components/TestSpecDetail/TestSpecDetail.styled.ts index a1b2841500..45bd25b451 100644 --- a/web/src/components/TestSpecDetail/TestSpecDetail.styled.ts +++ b/web/src/components/TestSpecDetail/TestSpecDetail.styled.ts @@ -18,10 +18,6 @@ export const CardContainer = styled(Card)<{$isSelected: boolean; $type: Semantic border: ${({$isSelected, theme}) => $isSelected ? `1px solid ${theme.color.interactive}` : `1px solid ${theme.color.borderLight}`}; - :not(:last-child) { - margin-bottom: 16px; - } - .ant-card-head { border-bottom: ${({theme}) => `1px solid ${theme.color.borderLight}`}; border-top: ${({$type}) => `4px solid ${SemanticGroupNamesToColor[$type]}`}; @@ -108,5 +104,12 @@ export const SpanHeaderContainer = styled.div` cursor: pointer; display: flex; gap: 8px; +`; + +export const Wrapper = styled.div` + align-items: center; + cursor: pointer; + justify-content: space-between; + display: flex; padding: 8px 12px; `; diff --git a/web/src/components/TestSpecDetail/TestSpecDetail.tsx b/web/src/components/TestSpecDetail/TestSpecDetail.tsx index 8141068bd8..4a7adc3f40 100644 --- a/web/src/components/TestSpecDetail/TestSpecDetail.tsx +++ b/web/src/components/TestSpecDetail/TestSpecDetail.tsx @@ -8,45 +8,32 @@ interface IProps { onDelete(selector: string): void; onEdit(assertionResult: TAssertionResultEntry, name: string): void; onRevert(originalSelector: string): void; - onSelectSpan(spanId: string): void; selectedSpan?: string; testSpec?: TAssertionResultEntry; } -const TestSpecDetail = ({ - isOpen, - onClose, - onDelete, - onEdit, - onRevert, - onSelectSpan, - selectedSpan, - testSpec, -}: IProps) => { - return ( - - {testSpec && ( - - )} - - ); -}; +const TestSpecDetail = ({isOpen, onClose, onDelete, onEdit, onRevert, selectedSpan, testSpec}: IProps) => ( + + {testSpec && ( + + )} + +); export default TestSpecDetail; diff --git a/web/src/components/TestSpecs/TestSpecs.styled.ts b/web/src/components/TestSpecs/TestSpecs.styled.ts index 17cebb4d5b..25433c3f84 100644 --- a/web/src/components/TestSpecs/TestSpecs.styled.ts +++ b/web/src/components/TestSpecs/TestSpecs.styled.ts @@ -3,12 +3,6 @@ import styled from 'styled-components'; import noResultsIcon from 'assets/SpanAssertionsEmptyState.svg'; -export const Container = styled.div` - display: flex; - flex-direction: column; - gap: 16px; -`; - export const EmptyContainer = styled.div` align-items: center; display: flex; diff --git a/web/src/components/TestSpecs/TestSpecs.tsx b/web/src/components/TestSpecs/TestSpecs.tsx index 034b972e38..83aa70b175 100644 --- a/web/src/components/TestSpecs/TestSpecs.tsx +++ b/web/src/components/TestSpecs/TestSpecs.tsx @@ -1,7 +1,8 @@ import TestSpec from 'components/TestSpec'; +import AutoSizer, {Size} from 'react-virtualized-auto-sizer'; +import {FixedSizeList as List} from 'react-window'; import AssertionResults, {TAssertionResultEntry} from 'models/AssertionResults.model'; import Empty from './Empty'; -import * as S from './TestSpecs.styled'; interface IProps { assertionResults?: AssertionResults; @@ -17,20 +18,32 @@ const TestSpecs = ({assertionResults, onDelete, onEdit, onOpen, onRevert}: IProp } return ( - - {assertionResults?.resultList?.map(specResult => - specResult.resultList.length ? ( - - ) : null + + {({height, width}: Size) => ( + + {({index, data}) => { + const specResult = data[index]; + + return specResult.resultList.length ? ( + + ) : null; + }} + )} - + ); }; diff --git a/web/src/components/Visualization/components/Timeline/BaseSpanNode/BaseSpanNodeV2.tsx b/web/src/components/Visualization/components/Timeline/BaseSpanNode/BaseSpanNodeV2.tsx index c42acaed0c..96934f48a1 100644 --- a/web/src/components/Visualization/components/Timeline/BaseSpanNode/BaseSpanNodeV2.tsx +++ b/web/src/components/Visualization/components/Timeline/BaseSpanNode/BaseSpanNodeV2.tsx @@ -13,11 +13,10 @@ function getHintSide(viewStart: number, viewEnd: number) { } interface IProps extends IPropsComponent { - header?: React.ReactNode; span: Span; } -const BaseSpanNode = ({header, index, node, span, style}: IProps) => { +const BaseSpanNode = ({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); diff --git a/web/src/components/Visualization/components/Timeline/Timeline.provider.tsx b/web/src/components/Visualization/components/Timeline/Timeline.provider.tsx index 64a3c630c2..26257e2e6b 100644 --- a/web/src/components/Visualization/components/Timeline/Timeline.provider.tsx +++ b/web/src/components/Visualization/components/Timeline/Timeline.provider.tsx @@ -5,9 +5,6 @@ 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'; @@ -43,15 +40,25 @@ interface IProps { listRef: React.RefObject; nodeType: NodeTypesEnum; spans: Span[]; + onNavigate(spanId: string): void; + onClick(spanId: string): void; + matchedSpans: string[]; + selectedSpan: string; } export const useTimeline = () => useContext(Context); -const TimelineProvider = ({children, listRef, nodeType, spans}: IProps) => { - const dispatch = useAppDispatch(); +const TimelineProvider = ({ + children, + listRef, + nodeType, + spans, + onClick, + onNavigate, + matchedSpans, + selectedSpan, +}: IProps) => { 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]); @@ -61,21 +68,21 @@ const TimelineProvider = ({children, listRef, nodeType, spans}: IProps) => { const onSpanClick = useCallback( (spanId: string) => { TraceAnalyticsService.onTimelineSpanClick(spanId); - dispatch(selectSpan({spanId})); + onClick(spanId); }, - [dispatch] + [onClick] ); const onSpanNavigation = useCallback( (spanId: string) => { - dispatch(selectSpan({spanId})); + onNavigate(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] + [filteredNodes, listRef, onNavigate] ); const onSpanCollapse = useCallback((spanId: string) => { diff --git a/web/src/components/Visualization/components/Timeline/TimelineV2.tsx b/web/src/components/Visualization/components/Timeline/TimelineV2.tsx index 9797c82cd5..b3c72c9078 100644 --- a/web/src/components/Visualization/components/Timeline/TimelineV2.tsx +++ b/web/src/components/Visualization/components/Timeline/TimelineV2.tsx @@ -9,13 +9,25 @@ import ListWrapper from './ListWrapper'; export interface IProps { nodeType: NodeTypesEnum; spans: Span[]; + onNavigate(spanId: string): void; + onClick(spanId: string): void; + matchedSpans: string[]; + selectedSpan: string; } -const Timeline = ({nodeType, spans}: IProps) => { +const Timeline = ({nodeType, spans, onClick, onNavigate, matchedSpans, selectedSpan}: IProps) => { const listRef = useRef(null); return ( - + diff --git a/web/src/components/Visualization/components/Timeline/TraceSpanNode/TraceSpanNodeV2.tsx b/web/src/components/Visualization/components/Timeline/TraceSpanNode/TraceSpanNodeV2.tsx index 1f0228df1c..539eda5ae2 100644 --- a/web/src/components/Visualization/components/Timeline/TraceSpanNode/TraceSpanNodeV2.tsx +++ b/web/src/components/Visualization/components/Timeline/TraceSpanNode/TraceSpanNodeV2.tsx @@ -5,7 +5,7 @@ import {IPropsComponent} from '../SpanNodeFactoryV2'; const TraceSpanNode = (props: IPropsComponent) => { const {node} = props; - const {span, analyzerErrors} = useSpanData(node.data.id); + const {span} = useSpanData(node.data.id); return ( ({ - traceId, - rootSpan: Span(tree), - flat: Object.values(flat).reduce( - (acc, span) => ({ - ...acc, - [span.id || '']: Span(span), - }), - {} - ), - spans: Object.values(flat).map(rawSpan => Span(rawSpan)), -}); +const Trace = ({traceId = '', flat: rawFlat = {}, tree = {}} = defaultTrace): Trace => { + const flat: TSpanMap = {}; + const spans = Object.values(rawFlat).map(raw => { + const span = Span(raw); + flat[span.id || ''] = span; + + return span; + }); + + return { + traceId, + rootSpan: Span(tree), + flat, + spans, + }; +}; export default Trace; diff --git a/web/src/redux/actions/Router.actions.ts b/web/src/redux/actions/Router.actions.ts index a7b195d36b..f404f4d99c 100644 --- a/web/src/redux/actions/Router.actions.ts +++ b/web/src/redux/actions/Router.actions.ts @@ -4,7 +4,7 @@ import {Params} from 'react-router-dom'; import {push} from 'redux-first-history'; import {RouterSearchFields} from 'constants/Common.constants'; import TestSpecsSelectors from 'selectors/TestSpecs.selectors'; -import DAGSelectors from 'selectors/DAG.selectors'; +// import DAGSelectors from 'selectors/DAG.selectors'; import SpanSelectors from 'selectors/Span.selectors'; import {setSelectedSpan} from 'redux/slices/Span.slice'; import {setSelectedSpec} from 'redux/slices/TestSpecs.slice'; @@ -31,10 +31,13 @@ const RouterActions = () => ({ getState() as RootState, Number(positionIndex) ); - const isDagReady = DAGSelectors.selectNodes(getState() as RootState).length > 0; + + // TODO: the default view for big traces is no longer the DAG, so this check is no longer valid + // move the view to the state and check depending on the type + // const isViewReady = DAGSelectors.selectNodes(getState() as RootState).length > 0; if (selectedSpec === assertionResult?.selector) return; - if (assertionResult && isDagReady) dispatch(setSelectedSpec(assertionResult)); + if (assertionResult) dispatch(setSelectedSpec(assertionResult)); } ), updateSelectedSpan: createAsyncThunk( diff --git a/web/src/redux/actions/TestSpecs.actions.ts b/web/src/redux/actions/TestSpecs.actions.ts index 7d3dea5399..f6583b91e5 100644 --- a/web/src/redux/actions/TestSpecs.actions.ts +++ b/web/src/redux/actions/TestSpecs.actions.ts @@ -26,6 +26,7 @@ const TestSpecsActions = () => ({ const specs = TestSpecsSelectors.selectSpecs(getState() as RootState).filter(def => !def.isDeleted); const outputs = selectTestOutputs(getState() as RootState); const rawTest = await TestService.getUpdatedRawTest(test, {definition: {specs}, outputs}); + await dispatch(TestGateway.edit(rawTest, testId)); return dispatch(TestRunGateway.reRun(testId, runId)).unwrap(); } From c21b791e30b6cc310ce0c076c9aa0adcd585941f Mon Sep 17 00:00:00 2001 From: Oscar Reyes Date: Tue, 13 Feb 2024 10:42:23 -0600 Subject: [PATCH 9/9] Chore performance improvements span search (#3629) * feat(frontend): Implemeting new Timeline for the Test Page * feat(frontend): Implementing span search for analyzer and test views * feat(frontend): read improvements * feat(frontend): adding single line input component * feat(frontend): updating analyzer styles * feat(frontend): Fixing tests * feat(frontend): Fixing tests --- .../AnalyzerResult/AnalyzerResult.styled.ts | 16 +-- .../AnalyzerResult/AnalyzerResult.tsx | 10 +- web/src/components/AnalyzerResult/Plugins.tsx | 11 +- web/src/components/AnalyzerResult/Rule.tsx | 120 ++++++------------ .../components/AnalyzerResult/RuleResult.tsx | 64 ++++++++++ .../components/Fields/Auth/AuthApiKeyBase.tsx | 7 +- web/src/components/Fields/Auth/AuthBasic.tsx | 8 +- web/src/components/Fields/Auth/AuthBearer.tsx | 5 +- web/src/components/Fields/Headers/Headers.tsx | 7 +- .../Fields/KeyValueList/KeyValueList.tsx | 7 +- .../components/Fields/Metadata/Metadata.tsx | 7 +- .../components/Fields/MultiURL/MultiURL.tsx | 5 +- .../components/Fields/PlainAuth/Fields.tsx | 7 +- web/src/components/Fields/URL/URL.tsx | 6 +- .../Fields/VariableName/VariableName.tsx | 5 +- .../Inputs/Editor/Selector/Selector.tsx | 11 +- .../Inputs/SingleLine/SingleLine.tsx | 12 ++ web/src/components/Inputs/SingleLine/index.ts | 2 + .../components/RunDetailLayout/HeaderLeft.tsx | 21 +-- .../RunDetailTrace/AnalyzerPanel.tsx | 7 +- .../TestPlugins/Forms/Kafka/Kafka.tsx | 5 +- web/src/components/TestSpecDetail/Content.tsx | 10 +- web/src/components/TestSpecDetail/Search.tsx | 70 ++++++++++ .../TestSpecDetail/TestSpecDetail.styled.ts | 14 +- web/src/services/Analyzer.service.ts | 13 +- web/src/services/Assertion.service.ts | 3 +- web/src/services/TestRun.service.ts | 12 +- 27 files changed, 290 insertions(+), 175 deletions(-) create mode 100644 web/src/components/AnalyzerResult/RuleResult.tsx create mode 100644 web/src/components/Inputs/SingleLine/SingleLine.tsx create mode 100644 web/src/components/Inputs/SingleLine/index.ts create mode 100644 web/src/components/TestSpecDetail/Search.tsx diff --git a/web/src/components/AnalyzerResult/AnalyzerResult.styled.ts b/web/src/components/AnalyzerResult/AnalyzerResult.styled.ts index a04f33a243..cf3d442393 100644 --- a/web/src/components/AnalyzerResult/AnalyzerResult.styled.ts +++ b/web/src/components/AnalyzerResult/AnalyzerResult.styled.ts @@ -47,27 +47,19 @@ export const GlobalScoreContainer = styled.div` justify-content: center; `; -export const RuleContainer = styled.div` - border-bottom: ${({theme}) => `1px dashed ${theme.color.borderLight}`}; - padding-bottom: 16px; - margin-bottom: 16px; - margin-left: 43px; -`; - export const RuleHeader = styled.div` display: flex; flex-direction: row; justify-content: space-between; `; -export const Column = styled.div` - display: flex; - flex-direction: column; - margin-bottom: 8px; +export const Column = styled(RuleHeader)` + width: 95%; `; -export const RuleBody = styled(Column)` +export const RuleBody = styled(Column)<{$resultCount: number}>` padding-left: 20px; + height: ${({$resultCount}) => ($resultCount > 10 ? '100vh' : `${$resultCount * 32}px`)}; `; export const Subtitle = styled(Typography.Title)` diff --git a/web/src/components/AnalyzerResult/AnalyzerResult.tsx b/web/src/components/AnalyzerResult/AnalyzerResult.tsx index 08e66870de..45f21a36ed 100644 --- a/web/src/components/AnalyzerResult/AnalyzerResult.tsx +++ b/web/src/components/AnalyzerResult/AnalyzerResult.tsx @@ -2,7 +2,6 @@ import BetaBadge from 'components/BetaBadge/BetaBadge'; import Link from 'components/Link'; import {COMMUNITY_SLACK_URL, OCTOLIINT_ISSUE_URL} from 'constants/Common.constants'; import LinterResult from 'models/LinterResult.model'; -import Trace from 'models/Trace.model'; import {useSettingsValues} from 'providers/SettingsValues/SettingsValues.provider'; import * as S from './AnalyzerResult.styled'; import Empty from './Empty'; @@ -11,10 +10,9 @@ import Plugins from './Plugins'; interface IProps { result: LinterResult; - trace: Trace; } -const AnalyzerResult = ({result: {score, minimumScore, plugins = [], passed}, trace}: IProps) => { +const AnalyzerResult = ({result: {score, minimumScore, plugins = [], passed}}: IProps) => { const {linter} = useSettingsValues(); return ( @@ -31,13 +29,13 @@ const AnalyzerResult = ({result: {score, minimumScore, plugins = [], passed}, tr It can be globally disabled for all tests in the settings page.{' '} )} - We value your feedback on this beta release. Share your thoughts on Slack or add - them to this Issue. + We value your feedback on this beta release. Share your thoughts on Slack or + add them to this Issue. {plugins.length ? ( <> - + ) : ( diff --git a/web/src/components/AnalyzerResult/Plugins.tsx b/web/src/components/AnalyzerResult/Plugins.tsx index 4f6a347d21..a88d9e30e0 100644 --- a/web/src/components/AnalyzerResult/Plugins.tsx +++ b/web/src/components/AnalyzerResult/Plugins.tsx @@ -1,7 +1,8 @@ import {Space, Switch, Typography} from 'antd'; import {useState} from 'react'; import {LinterResultPlugin} from 'models/LinterResult.model'; -import Trace from 'models/Trace.model'; +import {useAppSelector} from 'redux/hooks'; +import TraceSelectors from 'selectors/Trace.selectors'; import TraceAnalyzerAnalytics from 'services/Analytics/TraceAnalyzer.service'; import AnalyzerService from 'services/Analyzer.service'; import * as S from './AnalyzerResult.styled'; @@ -11,12 +12,12 @@ import Collapse, {CollapsePanel} from '../Collapse'; interface IProps { plugins: LinterResultPlugin[]; - trace: Trace; } -const Plugins = ({plugins: rawPlugins, trace}: IProps) => { +const Plugins = ({plugins: rawPlugins}: IProps) => { const [onlyErrors, setOnlyErrors] = useState(false); - const plugins = AnalyzerService.getPlugins(rawPlugins, onlyErrors); + const matchedSpans = useAppSelector(TraceSelectors.selectMatchedSpans); + const plugins = AnalyzerService.getPlugins(rawPlugins, onlyErrors, matchedSpans); return ( <> @@ -38,7 +39,7 @@ const Plugins = ({plugins: rawPlugins, trace}: IProps) => { key={plugin.name} > {plugin.rules.map(rule => ( - + ))} ))} diff --git a/web/src/components/AnalyzerResult/Rule.tsx b/web/src/components/AnalyzerResult/Rule.tsx index 731c09c4e2..dbf81025ba 100644 --- a/web/src/components/AnalyzerResult/Rule.tsx +++ b/web/src/components/AnalyzerResult/Rule.tsx @@ -1,98 +1,54 @@ -import {useCallback} from 'react'; -import {CaretUpFilled} from '@ant-design/icons'; +import {FixedSizeList as List} from 'react-window'; +import AutoSizer, {Size} from 'react-virtualized-auto-sizer'; import {Space, Tooltip, Typography} from 'antd'; +import {PercentageOutlined} from '@ant-design/icons'; import {LinterResultPluginRule} from 'models/LinterResult.model'; -import Trace from 'models/Trace.model'; import {LinterRuleErrorLevel} from 'models/Linter.model'; -import {useAppDispatch} from 'redux/hooks'; -import {selectSpan} from 'redux/slices/Trace.slice'; -import TraceAnalyzerAnalytics from 'services/Analytics/TraceAnalyzer.service'; import * as S from './AnalyzerResult.styled'; import RuleIcon from './RuleIcon'; -import RuleLink from './RuleLink'; +import RuleResult from './RuleResult'; +import Collapse, {CollapsePanel} from '../Collapse'; interface IProps { rule: LinterResultPluginRule; - trace: Trace; } -const Rule = ({ - rule: {id, tips, passed, description, name, errorDescription, results = [], level, weight = 0}, - trace, -}: IProps) => { - const dispatch = useAppDispatch(); - - const onSpanResultClick = useCallback( - (spanId: string) => { - TraceAnalyzerAnalytics.onSpanNameClick(); - dispatch(selectSpan({spanId})); - }, - [dispatch] - ); - +const Rule = ({rule: {tips, id, passed, description, name, level, results, weight = 0}, rule}: IProps) => { return ( - - - - - - - {name} - - - - - {description} - - {level === LinterRuleErrorLevel.ERROR && ( - - Weight: {weight} - - )} - - - - {/* TODO: Add a search capability to look for spans in the result list */} - {results?.map((result, resultIndex) => ( - // eslint-disable-next-line react/no-array-index-key -
- } - onClick={() => onSpanResultClick(result.spanId)} - type="link" - $error={!result.passed} - > - {trace.flat[result.spanId].name ?? ''} - - - {!result.passed && result.errors.length > 1 && ( - <> -
- {errorDescription} -
- - {result.errors.map(error => ( -
  • - - {error.value} - -
  • - ))} -
    - + + + + + + + {name} + + {description} + + + {level === LinterRuleErrorLevel.ERROR && ( + + {weight} + + )} - - {!result.passed && result.errors.length === 1 && ( -
    - {result.errors[0].description} -
    + + } + key={id} + > + + + {({height, width}: Size) => ( + + {RuleResult} + )} - - {!result.passed && } -
    - ))} -
    -
    + + + + ); }; diff --git a/web/src/components/AnalyzerResult/RuleResult.tsx b/web/src/components/AnalyzerResult/RuleResult.tsx new file mode 100644 index 0000000000..89a42f8bd1 --- /dev/null +++ b/web/src/components/AnalyzerResult/RuleResult.tsx @@ -0,0 +1,64 @@ +import {Tooltip, Typography} from 'antd'; +import {CaretUpFilled} from '@ant-design/icons'; +import {useCallback, useMemo} from 'react'; +import {LinterResultPluginRule} from 'models/LinterResult.model'; +import {useAppDispatch} from 'redux/hooks'; +import {selectSpan} from 'redux/slices/Trace.slice'; +import {useTestRun} from 'providers/TestRun/TestRun.provider'; +import TraceAnalyzerAnalytics from 'services/Analytics/TraceAnalyzer.service'; +import * as S from './AnalyzerResult.styled'; +import RuleLink from './RuleLink'; + +interface IProps { + index: number; + data: LinterResultPluginRule; + style: React.CSSProperties; +} + +const RuleResult = ({index, data: {results, id, errorDescription}, style}: IProps) => { + const {spanId, passed, errors} = useMemo(() => results[index], [results, index]); + const dispatch = useAppDispatch(); + const { + run: {trace}, + } = useTestRun(); + + const onClick = useCallback(() => { + TraceAnalyzerAnalytics.onSpanNameClick(); + dispatch(selectSpan({spanId})); + }, [dispatch, spanId]); + + return ( +
    + } onClick={onClick} type="link" $error={!passed}> + {trace.flat[spanId].name ?? ''} + + + {!passed && errors.length > 1 && ( + <> +
    + {errorDescription} +
    + + {errors.map(error => ( +
  • + + {error.value} + +
  • + ))} +
    + + )} + + {!passed && errors.length === 1 && ( +
    + {errors[0].description} +
    + )} + + {!passed && } +
    + ); +}; + +export default RuleResult; diff --git a/web/src/components/Fields/Auth/AuthApiKeyBase.tsx b/web/src/components/Fields/Auth/AuthApiKeyBase.tsx index feb938f702..46d045fc76 100644 --- a/web/src/components/Fields/Auth/AuthApiKeyBase.tsx +++ b/web/src/components/Fields/Auth/AuthApiKeyBase.tsx @@ -1,7 +1,6 @@ import {Form} from 'antd'; -import {Editor} from 'components/Inputs'; -import {SupportedEditors} from 'constants/Editor.constants'; import * as S from './Auth.styled'; +import SingleLine from '../../Inputs/SingleLine'; interface IProps { baseName: string[]; @@ -17,7 +16,7 @@ const AuthApiKeyBase = ({baseName}: IProps) => ( label="Key" rules={[{required: true}]} > - + ( label="Value" rules={[{required: true}]} > - + diff --git a/web/src/components/Fields/Auth/AuthBasic.tsx b/web/src/components/Fields/Auth/AuthBasic.tsx index d8444a84ef..c98d055873 100644 --- a/web/src/components/Fields/Auth/AuthBasic.tsx +++ b/web/src/components/Fields/Auth/AuthBasic.tsx @@ -1,8 +1,6 @@ import {Form} from 'antd'; -import React from 'react'; -import {Editor} from 'components/Inputs'; -import {SupportedEditors} from 'constants/Editor.constants'; import * as S from './Auth.styled'; +import SingleLine from '../../Inputs/SingleLine'; interface IProps { baseName: string[]; @@ -18,7 +16,7 @@ const AuthBasic = ({baseName}: IProps) => ( label="Username" rules={[{required: true}]} > - + ( data-cy="basic-password" rules={[{required: true}]} > - + diff --git a/web/src/components/Fields/Auth/AuthBearer.tsx b/web/src/components/Fields/Auth/AuthBearer.tsx index 7f22363f58..37a6e8f10f 100644 --- a/web/src/components/Fields/Auth/AuthBearer.tsx +++ b/web/src/components/Fields/Auth/AuthBearer.tsx @@ -1,6 +1,5 @@ import {Form} from 'antd'; -import {Editor} from 'components/Inputs'; -import {SupportedEditors} from 'constants/Editor.constants'; +import SingleLine from '../../Inputs/SingleLine'; interface IProps { baseName: string[]; @@ -8,7 +7,7 @@ interface IProps { const AuthBearer = ({baseName}: IProps) => ( - + ); diff --git a/web/src/components/Fields/Headers/Headers.tsx b/web/src/components/Fields/Headers/Headers.tsx index de8462e793..e1fa486ff7 100644 --- a/web/src/components/Fields/Headers/Headers.tsx +++ b/web/src/components/Fields/Headers/Headers.tsx @@ -1,9 +1,8 @@ import {PlusOutlined} from '@ant-design/icons'; import {Button, Form} from 'antd'; import {DEFAULT_HEADERS, IKeyValue} from 'constants/Test.constants'; -import {Editor} from 'components/Inputs'; -import {SupportedEditors} from 'constants/Editor.constants'; import * as S from './Headers.styled'; +import SingleLine from '../../Inputs/SingleLine'; interface IProps { initialValue?: IKeyValue[]; @@ -26,11 +25,11 @@ const Headers = ({ {fields.map((field, index) => ( - + - + diff --git a/web/src/components/Fields/KeyValueList/KeyValueList.tsx b/web/src/components/Fields/KeyValueList/KeyValueList.tsx index 4d0efbf6f4..809d719b15 100644 --- a/web/src/components/Fields/KeyValueList/KeyValueList.tsx +++ b/web/src/components/Fields/KeyValueList/KeyValueList.tsx @@ -1,9 +1,8 @@ import {PlusOutlined} from '@ant-design/icons'; import {Button, Form} from 'antd'; -import {Editor} from 'components/Inputs'; -import {SupportedEditors} from 'constants/Editor.constants'; import {IKeyValue} from 'constants/Test.constants'; import * as S from './KeyValueList.styled'; +import SingleLine from '../../Inputs/SingleLine'; interface IProps { name?: string; @@ -31,13 +30,13 @@ const KeyValueList = ({ - + - + diff --git a/web/src/components/Fields/Metadata/Metadata.tsx b/web/src/components/Fields/Metadata/Metadata.tsx index af006b56d9..44019ca0c2 100644 --- a/web/src/components/Fields/Metadata/Metadata.tsx +++ b/web/src/components/Fields/Metadata/Metadata.tsx @@ -1,8 +1,7 @@ import {PlusOutlined} from '@ant-design/icons'; import {Button, Form} from 'antd'; -import {SupportedEditors} from 'constants/Editor.constants'; -import {Editor} from 'components/Inputs'; import * as S from './Metadata.styled'; +import SingleLine from '../../Inputs/SingleLine'; const Metadata = () => ( @@ -13,13 +12,13 @@ const Metadata = () => ( - + - + diff --git a/web/src/components/Fields/MultiURL/MultiURL.tsx b/web/src/components/Fields/MultiURL/MultiURL.tsx index 8648829153..436e31b4bd 100644 --- a/web/src/components/Fields/MultiURL/MultiURL.tsx +++ b/web/src/components/Fields/MultiURL/MultiURL.tsx @@ -1,8 +1,7 @@ import {PlusOutlined} from '@ant-design/icons'; import {Button, Form} from 'antd'; -import {SupportedEditors} from 'constants/Editor.constants'; -import {Editor} from 'components/Inputs'; import * as S from './MultiURL.styled'; +import SingleLine from '../../Inputs/SingleLine'; interface IProps { name?: string[]; @@ -24,7 +23,7 @@ const MultiURL = ({name = ['brokerUrls']}: IProps) => ( {fields.map((field, index) => ( - + {!isFirstItem(index) && ( diff --git a/web/src/components/Fields/PlainAuth/Fields.tsx b/web/src/components/Fields/PlainAuth/Fields.tsx index fce6259126..c1842f392e 100644 --- a/web/src/components/Fields/PlainAuth/Fields.tsx +++ b/web/src/components/Fields/PlainAuth/Fields.tsx @@ -1,7 +1,6 @@ import {Form} from 'antd'; -import {Editor} from 'components/Inputs'; -import {SupportedEditors} from 'constants/Editor.constants'; import * as S from './PlainAuth.styled'; +import SingleLine from '../../Inputs/SingleLine'; interface IProps { baseName: string[]; @@ -17,7 +16,7 @@ const Fields = ({baseName}: IProps) => ( rules={[{required: true}]} style={{flexBasis: '50%', overflow: 'hidden'}} > - + ( rules={[{required: true}]} style={{flexBasis: '50%', overflow: 'hidden'}} > - + diff --git a/web/src/components/Fields/URL/URL.tsx b/web/src/components/Fields/URL/URL.tsx index 69546d47c5..aee4ef6b65 100644 --- a/web/src/components/Fields/URL/URL.tsx +++ b/web/src/components/Fields/URL/URL.tsx @@ -1,7 +1,7 @@ import {Col, Form, Row, Select} from 'antd'; import {HTTP_METHOD} from 'constants/Common.constants'; -import {SupportedEditors} from 'constants/Editor.constants'; -import {Editor, DockerTip} from 'components/Inputs'; +import {DockerTip} from 'components/Inputs'; +import SingleLine from '../../Inputs/SingleLine'; interface IProps { showMethodSelector?: boolean; @@ -37,7 +37,7 @@ const URL = ({showMethodSelector = true}: IProps) => ( rules={[{required: true, message: 'Please enter a valid URL'}]} style={{marginBottom: 0}} > - + diff --git a/web/src/components/Fields/VariableName/VariableName.tsx b/web/src/components/Fields/VariableName/VariableName.tsx index cc9dc10b55..8f23aa6433 100644 --- a/web/src/components/Fields/VariableName/VariableName.tsx +++ b/web/src/components/Fields/VariableName/VariableName.tsx @@ -1,6 +1,5 @@ import {Form} from 'antd'; -import {Editor} from 'components/Inputs'; -import {SupportedEditors} from 'constants/Editor.constants'; +import SingleLine from '../../Inputs/SingleLine'; const VariableName = () => ( ( rules={[{required: true, message: 'Please enter a valid variable name'}]} style={{marginBottom: 0}} > - + ); diff --git a/web/src/components/Inputs/Editor/Selector/Selector.tsx b/web/src/components/Inputs/Editor/Selector/Selector.tsx index 960c52a277..fd29cab398 100644 --- a/web/src/components/Inputs/Editor/Selector/Selector.tsx +++ b/web/src/components/Inputs/Editor/Selector/Selector.tsx @@ -1,5 +1,6 @@ import {autocompletion} from '@codemirror/autocomplete'; import {linter} from '@codemirror/lint'; +import {EditorState} from '@codemirror/state'; import {EditorView} from '@codemirror/view'; import CodeMirror from '@uiw/react-codemirror'; import {useMemo} from 'react'; @@ -31,7 +32,13 @@ const Selector = ({ const editorTheme = useEditorTheme(); const extensionList = useMemo( - () => [autocompletion({override: [completionFn]}), linter(lintFn), selectorQL(), EditorView.lineWrapping], + () => [ + autocompletion({override: [completionFn]}), + linter(lintFn), + selectorQL(), + EditorView.lineWrapping, + EditorState.transactionFilter.of(tr => (tr.newDoc.lines > 1 ? [] : tr)), + ], [completionFn, lintFn] ); @@ -39,7 +46,7 @@ const Selector = ({ (tr.newDoc.lines > 1 ? [] : tr))]; + +const SingleLine = (props: IEditorProps) => ( + +); + +export default SingleLine; diff --git a/web/src/components/Inputs/SingleLine/index.ts b/web/src/components/Inputs/SingleLine/index.ts new file mode 100644 index 0000000000..42ff2f26a5 --- /dev/null +++ b/web/src/components/Inputs/SingleLine/index.ts @@ -0,0 +1,2 @@ +// eslint-disable-next-line no-restricted-exports +export {default} from './SingleLine'; diff --git a/web/src/components/RunDetailLayout/HeaderLeft.tsx b/web/src/components/RunDetailLayout/HeaderLeft.tsx index e719fd5c3b..a52e6f039b 100644 --- a/web/src/components/RunDetailLayout/HeaderLeft.tsx +++ b/web/src/components/RunDetailLayout/HeaderLeft.tsx @@ -3,13 +3,13 @@ import {useDashboard} from 'providers/Dashboard/Dashboard.provider'; import {useTest} from 'providers/Test/Test.provider'; import {useTestRun} from 'providers/TestRun/TestRun.provider'; import {useMemo} from 'react'; -import Date from 'utils/Date'; import {isRunStateFinished} from 'models/TestRun.model'; import {TDraftTest} from 'types/Test.types'; import TestService from 'services/Test.service'; import HeaderForm from './HeaderForm'; import Info from './Info'; import * as S from './RunDetailLayout.styled'; +import TestRunService from '../../services/TestRun.service'; interface IProps { name: string; @@ -18,21 +18,8 @@ interface IProps { } const HeaderLeft = ({name, triggerType, origin}: IProps) => { - const { - run: { - createdAt, - testSuiteId, - testSuiteRunId, - executionTime, - trace, - traceId, - testVersion, - metadata: {source} = {}, - } = {}, - run, - } = useTestRun(); + const {run: {createdAt, testSuiteId, testSuiteRunId, executionTime, trace, traceId} = {}, run} = useTestRun(); const {onEdit, isEditLoading: isLoading, test} = useTest(); - const createdTimeAgo = Date.getTimeAgo(createdAt ?? ''); const {navigate} = useDashboard(); const stateIsFinished = isRunStateFinished(run.state); @@ -44,7 +31,7 @@ const HeaderLeft = ({name, triggerType, origin}: IProps) => { const description = useMemo(() => { return ( <> - v{testVersion} • {triggerType} • Ran {createdTimeAgo} {source && <>• Run via {source.toUpperCase()}} + {TestRunService.getHeaderInfo(run, triggerType)} {testSuiteId && !!testSuiteRunId && ( <> {' '} @@ -56,7 +43,7 @@ const HeaderLeft = ({name, triggerType, origin}: IProps) => { )} ); - }, [testVersion, triggerType, createdTimeAgo, source, testSuiteId, testSuiteRunId]); + }, [run, triggerType, testSuiteId, testSuiteRunId]); return ( diff --git a/web/src/components/RunDetailTrace/AnalyzerPanel.tsx b/web/src/components/RunDetailTrace/AnalyzerPanel.tsx index e8da58fa9a..af25b789d4 100644 --- a/web/src/components/RunDetailTrace/AnalyzerPanel.tsx +++ b/web/src/components/RunDetailTrace/AnalyzerPanel.tsx @@ -1,5 +1,4 @@ import TestRun, {isRunStateFinished} from 'models/TestRun.model'; -import Trace from 'models/Trace.model'; import AnalyzerResult from '../AnalyzerResult/AnalyzerResult'; import SkeletonResponse from '../RunDetailTriggerResponse/SkeletonResponse'; import {RightPanel, PanelContainer} from '../ResizablePanels'; @@ -19,11 +18,7 @@ const AnalyzerPanel = ({run}: IProps) => ( {size => ( - {isRunStateFinished(run.state) ? ( - - ) : ( - - )} + {isRunStateFinished(run.state) ? : } )} diff --git a/web/src/components/TestPlugins/Forms/Kafka/Kafka.tsx b/web/src/components/TestPlugins/Forms/Kafka/Kafka.tsx index 951ad2da29..83b3be1d64 100644 --- a/web/src/components/TestPlugins/Forms/Kafka/Kafka.tsx +++ b/web/src/components/TestPlugins/Forms/Kafka/Kafka.tsx @@ -6,6 +6,7 @@ import useQueryTabs from 'hooks/useQueryTabs'; import {SupportedEditors} from 'constants/Editor.constants'; import {TDraftTest} from 'types/Test.types'; import * as S from './Kafka.styled'; +import SingleLine from '../../../Inputs/SingleLine'; const Kafka = () => { const [activeKey, setActiveKey] = useQueryTabs('auth', 'triggerTab'); @@ -25,7 +26,7 @@ const Kafka = () => { } key="message"> - + { } key="topic"> - + diff --git a/web/src/components/TestSpecDetail/Content.tsx b/web/src/components/TestSpecDetail/Content.tsx index 4af8a4866c..4ad20d8bcf 100644 --- a/web/src/components/TestSpecDetail/Content.tsx +++ b/web/src/components/TestSpecDetail/Content.tsx @@ -5,9 +5,11 @@ import AutoSizer, {Size} from 'react-virtualized-auto-sizer'; import {useAppSelector} from 'redux/hooks'; import TestSpecsSelectors from 'selectors/TestSpecs.selectors'; import AssertionService from 'services/Assertion.service'; +import TraceSelectors from 'selectors/Trace.selectors'; import {TAssertionResultEntry} from 'models/AssertionResults.model'; import Header from './Header'; import ResultCard from './ResultCard'; +import Search from './Search'; interface IProps { onClose(): void; @@ -34,7 +36,11 @@ const Content = ({ name = '', } = useAppSelector(state => TestSpecsSelectors.selectSpecBySelector(state, selector)) || {}; const totalPassedChecks = useMemo(() => AssertionService.getTotalPassedChecks(resultList), [resultList]); - const results = useMemo(() => Object.entries(AssertionService.getResultsHashedBySpanId(resultList)), [resultList]); + const matchedSpans = useAppSelector(TraceSelectors.selectMatchedSpans); + const results = useMemo( + () => Object.entries(AssertionService.getResultsHashedBySpanId(resultList, matchedSpans)), + [matchedSpans, resultList] + ); const listRef = useRef(null); @@ -76,6 +82,8 @@ const Content = ({ title={!selector && !name ? 'All Spans' : name} /> + + {({height, width}: Size) => ( { + const [search, setSearch] = useState(''); + const dispatch = useAppDispatch(); + const [getSearchedSpans] = useGetSearchedSpansMutation(); + const { + run: {id: runId}, + } = useTestRun(); + const { + test: {id: testId}, + } = useTest(); + + const handleSearch = useCallback( + async (query: string) => { + if (!query) { + dispatch(matchSpans({spanIds: []})); + dispatch(selectSpan({spanId: ''})); + return; + } + + const {spanIds} = await getSearchedSpans({query, runId, testId}).unwrap(); + dispatch(setSearchText({searchText: query})); + dispatch(matchSpans({spanIds})); + + if (spanIds.length) { + dispatch(selectSpan({spanId: spanIds[0]})); + } + }, + [dispatch, getSearchedSpans, runId, testId] + ); + + const onSearch = useMemo(() => debounce(handleSearch, 500), [handleSearch]); + const onClear = useCallback(() => { + onSearch(''); + setSearch(''); + }, [onSearch]); + + return ( + + + { + onSearch(query); + setSearch(query); + }} + value={search} + /> + {!!search && } + + + ); +}; + +export default Search; diff --git a/web/src/components/TestSpecDetail/TestSpecDetail.styled.ts b/web/src/components/TestSpecDetail/TestSpecDetail.styled.ts index 45bd25b451..24b00e82fc 100644 --- a/web/src/components/TestSpecDetail/TestSpecDetail.styled.ts +++ b/web/src/components/TestSpecDetail/TestSpecDetail.styled.ts @@ -1,4 +1,4 @@ -import {CheckCircleFilled, InfoCircleFilled, MinusCircleFilled} from '@ant-design/icons'; +import {CheckCircleFilled, CloseCircleFilled, InfoCircleFilled, MinusCircleFilled} from '@ant-design/icons'; import {Card, Drawer, Typography} from 'antd'; import styled from 'styled-components'; @@ -113,3 +113,15 @@ export const Wrapper = styled.div` display: flex; padding: 8px 12px; `; + +export const ClearSearchIcon = styled(CloseCircleFilled)` + position: absolute; + right: 8px; + top: 8px; + color: ${({theme}) => theme.color.textLight}; + cursor: pointer; +`; + +export const SearchContainer = styled(Row)` + margin-bottom: 16px; +`; diff --git a/web/src/services/Analyzer.service.ts b/web/src/services/Analyzer.service.ts index 12600cc319..a91bc39574 100644 --- a/web/src/services/Analyzer.service.ts +++ b/web/src/services/Analyzer.service.ts @@ -3,14 +3,23 @@ import LinterResult from 'models/LinterResult.model'; const MAX_PLUGIN_SCORE = 100; const AnalyzerService = () => ({ - getPlugins(plugins: LinterResult['plugins'], showOnlyErrors: boolean): LinterResult['plugins'] { + getPlugins( + plugins: LinterResult['plugins'], + showOnlyErrors: boolean, + spanIds: string[] = [] + ): LinterResult['plugins'] { return plugins .filter(plugin => !showOnlyErrors || plugin.score < MAX_PLUGIN_SCORE) .map(plugin => ({ ...plugin, rules: plugin.rules .filter(rule => !showOnlyErrors || !rule.passed) - .map(rule => ({...rule, results: rule?.results?.filter(result => !showOnlyErrors || !result.passed)})), + .map(rule => ({ + ...rule, + results: rule.results.filter( + result => (!spanIds.length || spanIds.includes(result.spanId)) && (!showOnlyErrors || !result.passed) + ), + })), })); }, }); diff --git a/web/src/services/Assertion.service.ts b/web/src/services/Assertion.service.ts index dcbbd21d88..cf616dc3da 100644 --- a/web/src/services/Assertion.service.ts +++ b/web/src/services/Assertion.service.ts @@ -53,9 +53,10 @@ const AssertionService = () => ({ .some(result => !!result); }, - getResultsHashedBySpanId(resultList: AssertionResult[]) { + getResultsHashedBySpanId(resultList: AssertionResult[], spanIds: string[] = []) { return resultList .flatMap(({assertion, spanResults}) => spanResults.map(spanResult => ({result: spanResult, assertion}))) + .filter(({result}) => !spanIds.length || spanIds.includes(result.spanId)) .reduce((prev: Record, curr) => { const items = prev[curr.result.spanId] || []; items.push(curr); diff --git a/web/src/services/TestRun.service.ts b/web/src/services/TestRun.service.ts index e0ad41303f..503b3b8aad 100644 --- a/web/src/services/TestRun.service.ts +++ b/web/src/services/TestRun.service.ts @@ -2,10 +2,12 @@ import {filter, findLastIndex, flow} from 'lodash'; import {TestRunStage, TraceEventType} from 'constants/TestRunEvents.constants'; import AssertionResults from 'models/AssertionResults.model'; import LinterResult from 'models/LinterResult.model'; -import {isRunStateAnalyzingError, isRunStateStopped, isRunStateSucceeded} from 'models/TestRun.model'; +import TestRun, {isRunStateAnalyzingError, isRunStateStopped, isRunStateSucceeded} from 'models/TestRun.model'; import TestRunEvent from 'models/TestRunEvent.model'; import TestRunOutput from 'models/TestRunOutput.model'; import {TAnalyzerErrorsBySpan, TTestOutputsBySpan, TTestRunState, TTestSpecsBySpan} from 'types/TestRun.types'; +import Date from 'utils/Date'; +import {singularOrPlural} from 'utils/Common'; const TestRunService = () => ({ shouldDisplayTraceEvents(state: TTestRunState, numberOfSpans: number) { @@ -96,6 +98,14 @@ const TestRunService = () => ({ return {...prev, [curr.spanId]: [...value, curr]}; }, {}); }, + + getHeaderInfo({createdAt, testVersion, metadata: {source = ''}, trace}: TestRun, triggerType: string) { + const createdTimeAgo = Date.getTimeAgo(createdAt ?? ''); + + return `v${testVersion} • ${triggerType} • Ran ${createdTimeAgo} • ${ + !!trace?.spans.length && `${trace.spans.length} ${singularOrPlural('span', trace?.spans.length)}` + } ${source && `• Run via ${source.toUpperCase()}`}`; + }, }); export default TestRunService();