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 2bcb1b0ac0..d356e453f8 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -60,6 +60,8 @@
"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",
"typescript": "5.0.2"
@@ -77,6 +79,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 +7538,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 +21182,36 @@
"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",
+ "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..7050ec53a1 100644
--- a/web/package.json
+++ b/web/package.json
@@ -56,6 +56,8 @@
"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",
"typescript": "5.0.2"
@@ -81,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"
},
@@ -122,6 +126,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/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 9480e0f04e..dbf81025ba 100644
--- a/web/src/components/AnalyzerResult/Rule.tsx
+++ b/web/src/components/AnalyzerResult/Rule.tsx
@@ -1,103 +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 Span from 'models/Span.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;
}
-function getSpanName(spans: Span[], spanId: string) {
- 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,
-}: 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}
-
- )}
-
-
-
- {results?.map((result, resultIndex) => (
- // eslint-disable-next-line react/no-array-index-key
-
-
}
- onClick={() => onSpanResultClick(result.spanId)}
- type="link"
- $error={!result.passed}
- >
- {getSpanName(trace.spans, result.spanId)}
-
-
- {!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/Expression/hooks/useAutoComplete.ts b/web/src/components/Inputs/Editor/Expression/hooks/useAutoComplete.ts
index 981aef66b5..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,13 +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);
- 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/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 = ({
{
const {getState} = useAppStore();
- const getAttributeList = useCallback(() => {
- const state = getState();
- 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) => {
@@ -55,7 +53,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/Inputs/SingleLine/SingleLine.tsx b/web/src/components/Inputs/SingleLine/SingleLine.tsx
new file mode 100644
index 0000000000..c6c540a982
--- /dev/null
+++ b/web/src/components/Inputs/SingleLine/SingleLine.tsx
@@ -0,0 +1,12 @@
+import {EditorState} from '@codemirror/state';
+import {Editor} from 'components/Inputs';
+import {SupportedEditors} from 'constants/Editor.constants';
+import {IEditorProps} from '../Editor/Editor';
+
+const extensions = [EditorState.transactionFilter.of(tr => (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/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/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/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 2c7ba26238..e7b3b19dd8 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,20 +115,23 @@ const TestPanel = ({run, testId, runEvents}: IProps) => {
{run.state === TestState.FINISHED && (
{
TestRunAnalytics.onSwitchDiagramView(type);
setVisualizationType(type);
}}
type={visualizationType}
+ totalSpans={run?.trace?.spans?.length}
/>
)}
{skipTraceCollection && }
@@ -217,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 7e41e954f4..6ea97b2b23 100644
--- a/web/src/components/RunDetailTest/Visualization.tsx
+++ b/web/src/components/RunDetailTest/Visualization.tsx
@@ -1,66 +1,32 @@
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 TimelineV2 from 'components/Visualization/components/Timeline/TimelineV2';
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 = ({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 {isOpen} = useTestSpecForm();
-
- useEffect(() => {
- dispatch(initNodes({spans}));
- }, [dispatch, spans]);
+const Visualization = ({isDAGDisabled, runEvents, runState, trace, trace: {spans, rootSpan}, type}: IProps) => {
+ const {onSelectSpan, matchedSpans, onSetFocusedSpan, selectedSpan} = useSpan();
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);
- onSelectSpan(spanId);
- },
- [onSelectSpan]
- );
+ onSelectSpan(rootSpan.id);
+ }, [onSelectSpan, rootSpan, selectedSpan, spans]);
const onNavigateToSpan = useCallback(
(spanId: string) => {
@@ -74,24 +40,14 @@ const Visualization = ({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/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/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/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
new file mode 100644
index 0000000000..a874852440
--- /dev/null
+++ b/web/src/components/RunDetailTrace/TraceDAG.tsx
@@ -0,0 +1,61 @@
+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;
+ matchedSpans: string[];
+ selectedSpan: string;
+}
+
+const TraceDAG = ({trace: {spans}, matchedSpans, selectedSpan, onNavigateToSpan}: IProps) => {
+ const nodes = useAppSelector(TraceSelectors.selectNodes);
+ const edges = useAppSelector(TraceSelectors.selectEdges);
+ const isMatchedMode = Boolean(matchedSpans.length);
+ const dispatch = useAppDispatch();
+
+ 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 53cc6edb8f..ed958e3f09 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,18 +37,21 @@ const TracePanel = ({run, testId, runEvents, skipTraceCollection}: TProps) => {
{run.state === TestState.FINISHED && (
{
TraceAnalyticsService.onSwitchDiagramView(type);
setVisualizationType(type);
}}
type={visualizationType}
+ totalSpans={run?.trace?.spans?.length}
/>
)}
diff --git a/web/src/components/RunDetailTrace/Visualization.tsx b/web/src/components/RunDetailTrace/Visualization.tsx
index 3ec49e140a..b2a52f9135 100644
--- a/web/src/components/RunDetailTrace/Visualization.tsx
+++ b/web/src/components/RunDetailTrace/Visualization.tsx
@@ -3,64 +3,43 @@ 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 TimelineV2 from 'components/Visualization/components/Timeline/TimelineV2';
import {VisualizationType} from './RunDetailTrace';
+import TraceDAG from './TraceDAG';
interface IProps {
+ isDAGDisabled: boolean;
runEvents: TestRunEvent[];
runState: TTestRunState;
- spans: Span[];
+ trace: Trace;
type: VisualizationType;
}
-const Visualization = ({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);
-
- useEffect(() => {
- dispatch(initNodes({spans}));
- }, [dispatch, spans]);
+ const matchedSpans = useAppSelector(TraceSelectors.selectMatchedSpans);
useEffect(() => {
if (selectedSpan) return;
- 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(
+ const onNavigateToSpan = useCallback(
(spanId: string) => {
- TraceAnalyticsService.onTimelineSpanClick(spanId);
dispatch(selectSpan({spanId}));
},
[dispatch]
);
- const onNavigateToSpan = useCallback(
+ const onNodeClickTimeline = useCallback(
(spanId: string) => {
dispatch(selectSpan({spanId}));
},
@@ -71,26 +50,21 @@ const Visualization = ({runEvents, runState, spans, type}: IProps) => {
return ;
}
- return type === VisualizationType.Dag ? (
-
) : (
-
);
};
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/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 90dea1340c..4ad20d8bcf 100644
--- a/web/src/components/TestSpecDetail/Content.tsx
+++ b/web/src/components/TestSpecDetail/Content.tsx
@@ -1,24 +1,21 @@
-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 TraceSelectors from 'selectors/Trace.selectors';
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';
+import Search from './Search';
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 +25,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 +36,28 @@ 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 matchedSpans = useAppSelector(TraceSelectors.selectMatchedSpans);
+ const results = useMemo(
+ () => Object.entries(AssertionService.getResultsHashedBySpanId(resultList, matchedSpans)),
+ [matchedSpans, 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,33 +82,22 @@ const Content = ({
title={!selector && !name ? 'All Spans' : name}
/>
- {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/Search.tsx b/web/src/components/TestSpecDetail/Search.tsx
new file mode 100644
index 0000000000..539d9333e5
--- /dev/null
+++ b/web/src/components/TestSpecDetail/Search.tsx
@@ -0,0 +1,70 @@
+import {Col} from 'antd';
+import {debounce} from 'lodash';
+import {useCallback, useMemo, useState} from 'react';
+
+import {Editor} from 'components/Inputs';
+import {SupportedEditors} from 'constants/Editor.constants';
+import TracetestAPI from 'redux/apis/Tracetest';
+import {useTestRun} from 'providers/TestRun/TestRun.provider';
+import {useTest} from 'providers/Test/Test.provider';
+import {useAppDispatch} from 'redux/hooks';
+import {matchSpans, selectSpan, setSearchText} from 'redux/slices/Trace.slice';
+import * as S from './TestSpecDetail.styled';
+
+const {useGetSearchedSpansMutation} = TracetestAPI.instance;
+
+const Search = () => {
+ 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/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..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';
@@ -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,24 @@ 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;
`;
+
+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/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/DAG/DAG.tsx b/web/src/components/Visualization/components/DAG/DAG.tsx
index f7a4fe77a9..c719c23643 100644
--- a/web/src/components/Visualization/components/DAG/DAG.tsx
+++ b/web/src/components/Visualization/components/DAG/DAG.tsx
@@ -5,6 +5,7 @@ import Actions from './Actions';
import * as S from './DAG.styled';
import TestSpanNode from './TestSpanNode/TestSpanNode';
import TraceSpanNode from './TraceSpanNode/TraceSpanNode';
+import {MAX_DAG_NODES} from '../../../../constants/Visualization.constants';
/** Important to define the nodeTypes outside the component to prevent re-renderings */
const nodeTypes = {traceSpan: TraceSpanNode, testSpan: TestSpanNode};
@@ -46,15 +47,17 @@ const DAG = ({
edges={edges}
nodes={nodes}
deleteKeyCode={null}
- fitView
minZoom={0.1}
multiSelectionKeyCode={null}
nodesConnectable={false}
nodeTypes={nodeTypes}
+ onInit={() => 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/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/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)}
+ />
1 - viewEnd ? 'left' : 'right';
+}
+
+interface IProps extends IPropsComponent {
+ span: Span;
+}
+
+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);
+ const isSelected = selectedSpan === node.data.id;
+ const isMatched = matchedSpans.includes(node.data.id);
+ const isCollapsed = collapsedSpans.includes(node.data.id);
+
+ 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..cb332a3f84
--- /dev/null
+++ b/web/src/components/Visualization/components/Timeline/BaseSpanNode/ConnectorV2.tsx
@@ -0,0 +1,56 @@
+import {BaseLeftPaddingV2} from 'constants/Timeline.constants';
+import * as S from '../TimelineV2.styled';
+
+interface IProps {
+ hasParent: boolean;
+ id: string;
+ isCollapsed: boolean;
+ nodeDepth: number;
+ onCollapse(id: string): void;
+ totalChildren: number;
+}
+
+const Connector = ({hasParent, id, isCollapsed, nodeDepth, onCollapse, totalChildren}: IProps) => {
+ const leftPadding = nodeDepth * BaseLeftPaddingV2;
+
+ 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/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..26257e2e6b
--- /dev/null
+++ b/web/src/components/Visualization/components/Timeline/Timeline.provider.tsx
@@ -0,0 +1,127 @@
+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 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[];
+ onNavigate(spanId: string): void;
+ onClick(spanId: string): void;
+ matchedSpans: string[];
+ selectedSpan: string;
+}
+
+export const useTimeline = () => useContext(Context);
+
+const TimelineProvider = ({
+ children,
+ listRef,
+ nodeType,
+ spans,
+ onClick,
+ onNavigate,
+ matchedSpans,
+ selectedSpan,
+}: IProps) => {
+ const [collapsedSpans, setCollapsedSpans] = useState([]);
+
+ 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);
+ onClick(spanId);
+ },
+ [onClick]
+ );
+
+ const onSpanNavigation = useCallback(
+ (spanId: string) => {
+ 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');
+ }
+ },
+ [filteredNodes, listRef, onNavigate]
+ );
+
+ 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..827d7b88a3
--- /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.borderLight};
+`;
+
+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..b3c72c9078
--- /dev/null
+++ b/web/src/components/Visualization/components/Timeline/TimelineV2.tsx
@@ -0,0 +1,37 @@
+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[];
+ onNavigate(spanId: string): void;
+ onClick(spanId: string): void;
+ matchedSpans: string[];
+ selectedSpan: string;
+}
+
+const Timeline = ({nodeType, spans, onClick, onNavigate, matchedSpans, selectedSpan}: 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..539eda5ae2
--- /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} = useSpanData(node.data.id);
+
+ return (
+ }
+ span={span}
+ />
+ );
+};
+
+export default TraceSpanNode;
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;
diff --git a/web/src/constants/Visualization.constants.ts b/web/src/constants/Visualization.constants.ts
index b1030ac6e9..9146f4a20d 100644
--- a/web/src/constants/Visualization.constants.ts
+++ b/web/src/constants/Visualization.constants.ts
@@ -2,3 +2,5 @@ export enum NodeTypesEnum {
TraceSpan = 'traceSpan',
TestSpan = 'testSpan',
}
+
+export const MAX_DAG_NODES = 200;
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}));
diff --git a/web/src/models/DAG.model.ts b/web/src/models/DAG.model.ts
index 38248f6d71..d1d4ea3598 100644
--- a/web/src/models/DAG.model.ts
+++ b/web/src/models/DAG.model.ts
@@ -19,7 +19,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/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/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 5fabfab841..b0cdc8f036 100644
--- a/web/src/models/Trace.model.ts
+++ b/web/src/models/Trace.model.ts
@@ -1,16 +1,35 @@
-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;
};
-const Trace = ({traceId = '', flat = {}}: TRawTrace): Trace => {
+const defaultTrace: TRawTrace = {
+ traceId: '',
+ flat: {},
+ tree: {},
+};
+
+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,
- spans: Object.values(flat).map(rawSpan => Span(rawSpan)),
+ rootSpan: Span(tree),
+ flat,
+ spans,
};
};
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/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();
}
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/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/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 d1dc43f3ad..9e6ecc8cc0 100644
--- a/web/src/selectors/Span.selectors.ts
+++ b/web/src/selectors/Span.selectors.ts
@@ -23,9 +23,7 @@ const SpanSelectors = () => ({
selectSpanById: createSelector(stateSelector, paramsSelector, (state, {spanId, testId, runId}) => {
const {data: {trace} = {}} = TracetestAPI.instance.endpoints.getRunById.select({testId, runId})(state);
- 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 bcc50c2c24..9638ed067f 100644
--- a/web/src/selectors/TestRun.selectors.ts
+++ b/web/src/selectors/TestRun.selectors.ts
@@ -15,7 +15,7 @@ const selectTestRun = (state: RootState, params: {testId: string; runId: number;
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/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/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/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 [];
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();
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/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);
+ });
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;