diff --git a/web/src/assets/terminal.svg b/web/src/assets/terminal.svg new file mode 100644 index 0000000000..d06ce18c5a --- /dev/null +++ b/web/src/assets/terminal.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/src/components/EventLogPopover/EventLogContent.tsx b/web/src/components/EventLogPopover/EventLogContent.tsx new file mode 100644 index 0000000000..0ba2469760 --- /dev/null +++ b/web/src/components/EventLogPopover/EventLogContent.tsx @@ -0,0 +1,29 @@ +import {useEffect, useRef} from 'react'; +import TestRunEvent from 'models/TestRunEvent.model'; +import EventLogService from 'services/EventLog.service'; +import * as S from './EventLogPopover.styled'; + +interface IProps { + runEvents: TestRunEvent[]; +} + +const EventLogContent = ({runEvents}: IProps) => { + const bottomRef = useRef(null); + + useEffect(() => { + bottomRef.current?.scrollIntoView({behavior: 'smooth'}); + }, [runEvents]); + + return ( + + {runEvents.map(event => ( + + {EventLogService.typeToString(event)} {EventLogService.detailsToString(event)} + + ))} +
+ + ); +}; + +export default EventLogContent; diff --git a/web/src/components/EventLogPopover/EventLogPopover.styled.ts b/web/src/components/EventLogPopover/EventLogPopover.styled.ts new file mode 100644 index 0000000000..a25e902413 --- /dev/null +++ b/web/src/components/EventLogPopover/EventLogPopover.styled.ts @@ -0,0 +1,62 @@ +import {CopyOutlined} from '@ant-design/icons'; +import {Typography} from 'antd'; +import styled, {DefaultTheme, createGlobalStyle} from 'styled-components'; +import {LogLevel} from 'constants/TestRunEvents.constants'; +import terminalIcon from 'assets/terminal.svg'; + +function getLogLevelColor(logLevel: LogLevel, theme: DefaultTheme): string { + if (logLevel === LogLevel.Error) return theme.color.error; + if (logLevel === LogLevel.Success) return theme.color.success; + if (logLevel === LogLevel.Warning) return theme.color.warningYellow; + return theme.color.text; +} + +export const GlobalStyle = createGlobalStyle` + #eventlog-popover { + .ant-popover-inner-content { + padding: 5px 16px; + } + } +`; + +export const Container = styled.div` + margin: 0; + max-height: calc(100vh - 200px); + width: 550px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 2px; + border-radius: 2px; + position: relative; +`; + +export const CopyIcon = styled(CopyOutlined)` + color: ${({theme}) => theme.color.primary}; + cursor: pointer; +`; + +export const EventEntry = styled(Typography.Text)<{$logLevel?: LogLevel}>` + font-size: ${({theme}) => theme.size.sm}; + color: ${({theme, $logLevel = LogLevel.Info}) => getLogLevelColor($logLevel, theme)}; + margin: 0; +`; + +export const TerminalIcon = styled.img.attrs({src: terminalIcon})` + width: 20px; + height: 20px; + cursor: pointer; +`; + +export const TitleContainer = styled.div` + padding: 5px 0; + display: flex; + justify-content: space-between; +`; + +export const Title = styled(Typography.Title)` + && { + margin: 0; + font-size: ${({theme}) => theme.size.md}; + } +`; diff --git a/web/src/components/EventLogPopover/EventLogPopover.tsx b/web/src/components/EventLogPopover/EventLogPopover.tsx new file mode 100644 index 0000000000..74369d112d --- /dev/null +++ b/web/src/components/EventLogPopover/EventLogPopover.tsx @@ -0,0 +1,38 @@ +import {Popover, Tooltip} from 'antd'; +import TestRunEvent from 'models/TestRunEvent.model'; +import useCopy from 'hooks/useCopy'; +import EventLogService from 'services/EventLog.service'; +import EventLogContent from './EventLogContent'; +import * as S from './EventLogPopover.styled'; + +interface IProps { + runEvents: TestRunEvent[]; +} + +const EventLogPopover = ({runEvents}: IProps) => { + const copy = useCopy(); + + return ( + <> + + } + trigger="click" + placement="bottomLeft" + title={ + + Event Log + + copy(EventLogService.listToString(runEvents))} /> + + + } + > + + + + ); +}; + +export default EventLogPopover; diff --git a/web/src/components/EventLogPopover/index.ts b/web/src/components/EventLogPopover/index.ts new file mode 100644 index 0000000000..9760459625 --- /dev/null +++ b/web/src/components/EventLogPopover/index.ts @@ -0,0 +1,2 @@ +// eslint-disable-next-line no-restricted-exports +export {default} from './EventLogPopover'; diff --git a/web/src/components/RunDetailLayout/HeaderRight.tsx b/web/src/components/RunDetailLayout/HeaderRight.tsx index 0c2286f056..f2fe32a516 100644 --- a/web/src/components/RunDetailLayout/HeaderRight.tsx +++ b/web/src/components/RunDetailLayout/HeaderRight.tsx @@ -10,6 +10,7 @@ import {useTestRun} from 'providers/TestRun/TestRun.provider'; import {useTestSpecs} from 'providers/TestSpecs/TestSpecs.provider'; import {useTestOutput} from 'providers/TestOutput/TestOutput.provider'; import * as S from './RunDetailLayout.styled'; +import EventLogPopover from '../EventLogPopover/EventLogPopover'; interface IProps { testId: string; @@ -20,7 +21,7 @@ const HeaderRight = ({testId, testVersion}: IProps) => { const {isDraftMode: isTestSpecsDraftMode} = useTestSpecs(); const {isDraftMode: isTestOutputsDraftMode} = useTestOutput(); const isDraftMode = isTestSpecsDraftMode || isTestOutputsDraftMode; - const {isLoadingStop, run, stopRun} = useTestRun(); + const {isLoadingStop, run, stopRun, runEvents} = useTestRun(); const {onRun} = useTest(); const state = run.state; @@ -51,6 +52,7 @@ const HeaderRight = ({testId, testVersion}: IProps) => { Run Test )} + React.ReactElement> = { [TestState.STOPPED]: StoppedHeader, }; -type TraceEventTypeWithoutFetching = Exclude< +export type TraceEventTypeWithoutFetching = Exclude< TraceEventType, TraceEventType.FETCHING_START | TraceEventType.FETCHING_ERROR | TraceEventType.FETCHING_SUCCESS >; diff --git a/web/src/models/TestRunEvent.model.ts b/web/src/models/TestRunEvent.model.ts index 0b196e7230..8a8e3e0741 100644 --- a/web/src/models/TestRunEvent.model.ts +++ b/web/src/models/TestRunEvent.model.ts @@ -13,7 +13,7 @@ type TestRunEvent = Model< {logLevel: LogLevel; dataStoreConnection?: ConnectionResult; polling?: PollingInfo; outputs?: OutputInfo[]} >; -function PollingInfo({ +export function PollingInfo({ type = PollingInfoType.Periodic, isComplete = false, periodic = {}, diff --git a/web/src/services/EventLog.service.ts b/web/src/services/EventLog.service.ts new file mode 100644 index 0000000000..0e3bc8522d --- /dev/null +++ b/web/src/services/EventLog.service.ts @@ -0,0 +1,55 @@ +import {parseISO, formatISO} from 'date-fns'; +import TestRunEvent, {PollingInfo} from 'models/TestRunEvent.model'; +import ConnectionResult from 'models/ConnectionResult.model'; +import {TraceEventType} from 'constants/TestRunEvents.constants'; + +type TEventToStringFn = (event: TestRunEvent) => string; + +const eventToString = ({title, description}: TestRunEvent): string => { + return `${title} - ${description}`; +}; + +const dataStoreEventToString = (event: TestRunEvent): string => { + const {dataStoreConnection: {allPassed, ...dataStoreConnection} = ConnectionResult({})} = event; + const baseText = eventToString(event); + const configValidText = allPassed ? 'Data store configuration is valid.' : 'Data store configuration is not valid.'; + + const connectionStepsDetailsText = Object.entries(dataStoreConnection || {}) + .map(([key, {message, error}]) => `${key.toUpperCase()} - ${message} ${error ? ` - ${error}` : ''}`, '') + .join(' - '); + + return `${baseText} - ${configValidText} - ${connectionStepsDetailsText}`; +}; + +const pollingEventToString = (event: TestRunEvent): string => { + const {polling: {type: pollingType, isComplete, periodic} = PollingInfo({})} = event; + const baseText = eventToString(event); + const pollingTypeText = `Polling type: ${pollingType}`; + const pollingCompleteText = `Polling complete: ${isComplete}`; + const periodicText = `Periodic polling - number of spans: ${periodic?.numberSpans}, number of iterations: ${periodic?.numberIterations}`; + + return `${baseText} - ${pollingTypeText} - ${pollingCompleteText} - ${periodicText}`; +}; + +const eventToStringMap: Record = { + [TraceEventType.DATA_STORE_CONNECTION_INFO]: dataStoreEventToString, + [TraceEventType.POLLING_ITERATION_INFO]: pollingEventToString, +}; + +const EventLogService = () => ({ + detailsToString(event: TestRunEvent) { + const eventToStringFn = eventToStringMap[event.type] || eventToString; + + return eventToStringFn(event); + }, + typeToString({type, createdAt}: TestRunEvent) { + const createdAtDate = parseISO(createdAt); + + return `[${formatISO(createdAtDate)} - ${type}]`; + }, + listToString(events: TestRunEvent[]) { + return events.map(event => `${this.typeToString(event)} ${this.detailsToString(event)}`).join('\r'); + }, +}); + +export default EventLogService();