Skip to content

Commit

Permalink
feat: logs v2 (#1028)
Browse files Browse the repository at this point in the history
* feat: env keyword highlights (#1015)

* refactor: creatable multi select

* refactor: update colors

* docs: test suite step docs link (#1023)

* docs: test suite step docs link

* fix lint

---------

Co-authored-by: Bogdan Hanea <[email protected]>

* docs: update link to test suite steps (#1024)

* Update README.md

* ci: add dispatch (#1025)

* add dispatch to CI workflow

* feat: logs v2 (#1027)

---------

Co-authored-by: Razvan Topliceanu <[email protected]>
Co-authored-by: Lilla Vass <[email protected]>
Co-authored-by: Bogdan Hanea <[email protected]>
Co-authored-by: ypoplavs <[email protected]>
  • Loading branch information
5 people authored Apr 1, 2024
1 parent a73266f commit c9ef9d7
Show file tree
Hide file tree
Showing 14 changed files with 383 additions and 131 deletions.
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
<p align="center">
:warning: <strong>The Open Source version of the Testkube Dashboard is being deprecated.
<br />
For further details and next steps, please refer to <a href="https://testkube.io/blog/testkube-dashboard-announcement">our official announcement</a>.</strong>
</p>

---

<p align="center">
<img src="docs/testkube-logo.svg" alt="Testkube Logo" width="80"/>
</p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,27 +34,21 @@ export const StyledMultiLabel = styled.div`
padding: 3px 5px;
`;

export const customStyles: (
validation?: boolean,
stylePlaceholderAsValue?: boolean
) => StylesConfig<Option, true, GroupBase<Option>> = (validation = true, stylePlaceholderAsValue = false) => ({
export const customStyles: (stylePlaceholderAsValue?: boolean) => StylesConfig<Option, true, GroupBase<Option>> = (
stylePlaceholderAsValue = false
) => ({
container: styles => ({...styles, width: '100%'}),
input: styles => ({...styles, color: Colors.slate200, fontWeight: 400}),
valueContainer: (styles, props) => ({
...styles,
backgroundColor: props.isDisabled ? 'transparent' : Colors.slate800,
gap: '4px',
}),
placeholder: styles => ({
...styles,
color: stylePlaceholderAsValue ? Colors.slate200 : Colors.slate500,
fontWeight: 400,
}),
control: (styles, props) => ({
...styles,
borderColor: validation ? 'transparent' : Colors.pink500,
backgroundColor: props.isDisabled ? '#1e293b80' : Colors.slate800,
minHeight: '44px',
}),
indicatorSeparator: styles => ({...styles, width: 0}),
dropdownIndicator: styles => ({
...styles,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import usePressEnter from '@hooks/usePressEnter';

import {Option} from '@models/form';

import Colors from '@src/styles/Colors';

import {customStyles, customTheme} from './CreatableMultiSelect.styled';
import {
DefaultDropdownIndicator,
Expand Down Expand Up @@ -91,7 +93,15 @@ const CreatableMultiSelect: React.FC<MultiSelectProps> = props => {
}}
formatCreateLabel={formatCreateLabel}
theme={customTheme}
styles={customStyles(validation, stylePlaceholderAsValue)}
styles={{
...customStyles(stylePlaceholderAsValue),
control: (styles, p) => ({
...styles,
borderColor: validation ? Colors.pink500 : 'transparent',
backgroundColor: p.isDisabled ? '#1e293b80' : Colors.slate800,
minHeight: '44px',
}),
}}
components={{
Option: CustomOptionComponent,
MultiValueLabel: CustomMultiValueLabelComponent,
Expand Down
64 changes: 7 additions & 57 deletions packages/web/src/components/molecules/LogOutput/LogOutput.tsx
Original file line number Diff line number Diff line change
@@ -1,64 +1,14 @@
import React, {Fragment, createElement, memo, useEffect, useRef} from 'react';
import {createPortal} from 'react-dom';
import React, {memo} from 'react';

import {useSearch} from '@molecules/LogOutput/useSearch';
import {isFeatureEnabled} from '@src/utils/apiInfo';

import {useTestsSlot} from '@plugins/tests-and-test-suites/hooks';

import {useLogOutputField, useLogOutputPick, useLogOutputSync} from '@store/logOutput';

import FullscreenLogOutput from './FullscreenLogOutput';
import {LogOutputWrapper} from './LogOutput.styled';
import LogOutputPure, {LogOutputPureRef} from './LogOutputPure';
import {LogOutputProps, useLogOutput} from './useLogOutput';
import LogOutputV1 from './LogOutputV1';
import LogOutputV2 from './LogOutputV2';
import {LogOutputProps} from './useLogOutput';

const LogOutput: React.FC<LogOutputProps> = props => {
const {isRunning} = props;

const logRef = useRef<LogOutputPureRef>(null);
const options = useLogOutput(props);
const {isFullscreen} = useLogOutputPick('isFullscreen');
const fullscreenContainer = document.querySelector('#log-output-container')!;

// Search logic
const [, setSearching] = useLogOutputField('searching');
const [searchQuery] = useLogOutputField('searchQuery');

useEffect(() => {
if (!searchQuery) {
setSearching(false);
}
}, [searchQuery, setSearching]);

const search = useSearch({searchQuery, output: options.logs});
useLogOutputSync({
searching: search.loading,
searchResults: search.list,
searchLinesMap: search.map,
});

const [searchIndex, setSearchIndex] = useLogOutputField('searchIndex');
useEffect(() => {
if (search.list.length === 0) {
// Do nothing
} else if (searchIndex >= search.list.length) {
setSearchIndex(0);
} else {
const highlight = search.list[searchIndex];
logRef.current?.console?.scrollToLine(highlight.line);
}
}, [searchIndex, searchQuery, search.loading, logRef.current?.console]);

return (
<>
<LogOutputWrapper>
{/* eslint-disable-next-line react/no-array-index-key */}
{useTestsSlot('logOutputTop').map((element, i) => createElement(Fragment, {key: i}, element))}
<LogOutputPure ref={logRef} isRunning={isRunning} {...options} />
</LogOutputWrapper>
{isFullscreen ? createPortal(<FullscreenLogOutput {...options} />, fullscreenContainer) : null}
</>
);
const isV2 = isFeatureEnabled('logsV2');
return <>{isV2 ? <LogOutputV2 {...props} /> : <LogOutputV1 {...props} />}</>;
};

export default memo(LogOutput);
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {CopyButton, DownloadButton} from '@atoms';
import useLocation from '@hooks/useLocation';
import useSecureContext from '@hooks/useSecureContext';

import {isFeatureEnabled} from '@src/utils/apiInfo';

import FullscreenAction from './FullscreenAction';
import {StyledLogOutputActionsContainer} from './LogOutput.styled';
import SearchAction from './SearchAction';
Expand All @@ -21,15 +23,18 @@ const LogOutputActions: React.FC<LogOutputActionsProps> = props => {
const isSecureContext = useSecureContext();
const filename = useLocation().lastPathSegment;

const isV2 = isFeatureEnabled('logsV2');

return (
<StyledLogOutputActionsContainer>
<SearchAction />
{isV2 ? null : <SearchAction />}
{isSecureContext ? (
<CopyButton content={strippedLogOutput} />
) : (
<DownloadButton filename={filename} extension="log" content={strippedLogOutput} />
)}
<FullscreenAction key="fullscreen-log-action" />

{isV2 ? null : <FullscreenAction key="fullscreen-log-action" />}
</StyledLogOutputActionsContainer>
);
};
Expand Down
63 changes: 63 additions & 0 deletions packages/web/src/components/molecules/LogOutput/LogOutputV1.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import {Fragment, createElement, memo, useEffect, useRef} from 'react';
import {createPortal} from 'react-dom';

import {useTestsSlot} from '@plugins/tests-and-test-suites/hooks';

import {useLogOutputField, useLogOutputPick, useLogOutputSync} from '@store/logOutput';

import FullscreenLogOutput from './FullscreenLogOutput';
import {LogOutputWrapper} from './LogOutput.styled';
import LogOutputPure, {LogOutputPureRef} from './LogOutputPure';
import {LogOutputProps, useLogOutput} from './useLogOutput';
import {useSearch} from './useSearch';

const LogOutputV1 = (props: LogOutputProps) => {
const {isRunning} = props;

const logRef = useRef<LogOutputPureRef>(null);
const options = useLogOutput(props);
const {isFullscreen} = useLogOutputPick('isFullscreen');
const fullscreenContainer = document.querySelector('#log-output-container')!;

// Search logic
const [, setSearching] = useLogOutputField('searching');
const [searchQuery] = useLogOutputField('searchQuery');

useEffect(() => {
if (!searchQuery) {
setSearching(false);
}
}, [searchQuery, setSearching]);

const search = useSearch({searchQuery, output: options.logs});
useLogOutputSync({
searching: search.loading,
searchResults: search.list,
searchLinesMap: search.map,
});

const [searchIndex, setSearchIndex] = useLogOutputField('searchIndex');
useEffect(() => {
if (search.list.length === 0) {
// Do nothing
} else if (searchIndex >= search.list.length) {
setSearchIndex(0);
} else {
const highlight = search.list[searchIndex];
logRef.current?.console?.scrollToLine(highlight.line);
}
}, [searchIndex, searchQuery, search.loading, logRef.current?.console]);

return (
<>
<LogOutputWrapper>
{/* eslint-disable-next-line react/no-array-index-key */}
{useTestsSlot('logOutputTop').map((element, i) => createElement(Fragment, {key: i}, element))}
<LogOutputPure ref={logRef} isRunning={isRunning} {...options} />
</LogOutputWrapper>
{isFullscreen ? createPortal(<FullscreenLogOutput {...options} />, fullscreenContainer) : null}
</>
);
};

export default memo(LogOutputV1);
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import styled from 'styled-components';

import Colors from '@src/styles/Colors';

export const SourceList = styled.ul<{$open?: boolean; $root?: boolean}>`
display: flex;
flex-direction: column;
list-style: none;
padding: 0;
margin: 0;
${({$open}) => ($open ? 'flex: 1;' : '')}
${({$root}) => ($root ? 'height: 100%;' : '')}
`;

export const SourceSection = styled.li<{$open?: boolean}>`
border: 1px solid ${Colors.slate700};
border-radius: 3px;
display: flex;
flex-direction: column;
margin-bottom: 8px;
${({$open}) =>
$open
? 'flex: 1;'
: `
${SourceContent} {
display: none;
}
`}
`;

export const SourceContent = styled.div<{$empty?: boolean}>`
position: relative;
display: flex;
align-items: stretch;
margin: 0;
background: ${Colors.slate900};
min-height: 300px;
flex: 1;
${({$empty}) => ($empty ? 'min-height: 80px;' : '')}
`;

export const SourceHeader = styled.header`
display: flex;
align-items: center;
background: ${Colors.slate900};
padding: 10px 16px;
gap: 16px;
user-select: none;
cursor: pointer;
width: 100%;
`;

export const Container = styled.div`
display: flex;
flex-direction: column;
`;
82 changes: 82 additions & 0 deletions packages/web/src/components/molecules/LogOutput/LogOutputV2.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import React, {memo, useMemo, useRef} from 'react';

import {uniq} from 'lodash';

import {LogOutputWrapper} from './LogOutput.styled';
import LogOutputPure, {LogOutputPureRef} from './LogOutputPure';
import * as S from './LogOutputV2.styled';
import {LogOutputProps} from './useLogOutput';
import {useLogsV2} from './useLogsV2';

const UNKNOWN_SOURCE = 'system' as const;

const LogOutputV2: React.FC<LogOutputProps> = props => {
const {isRunning, wrap, LineComponent, executionId} = props;

const [openSource, _setOpenSource] = React.useState<string>('');
const setOpenSource = (source: string) => {
_setOpenSource(prev => (prev === source ? '' : source));
};

const logRef = useRef<LogOutputPureRef>(null);

const logs = useLogsV2(executionId, isRunning);

const logSources = useMemo(
() => [
UNKNOWN_SOURCE,
...uniq(logs.map(log => log.source).filter((source): source is string => Boolean(source && source.length > 0))),
],
[logs]
);
const logsBySource = useMemo(() => {
const dict: Record<string, string> = {};
logs.forEach(log => {
let source = log.source && log.source.trim().length ? log.source : UNKNOWN_SOURCE;
if (!dict[source]) {
dict[source] = '';
}
let previous = dict[source];
if (previous.length && !previous.endsWith('\n')) {
previous += '\n';
}
dict[source] = previous + log.content;
});
// set 'No logs' for sources without logs
logSources.forEach(source => {
if (!dict[source]) {
dict[source] = 'No logs';
}
});

if (dict[UNKNOWN_SOURCE].trim() === '') {
delete dict[UNKNOWN_SOURCE];
}

return dict;
}, [logs, logSources]);

return (
<S.Container>
{logSources.map(source => (
<S.SourceSection $open={openSource === source}>
<S.SourceHeader onClick={() => setOpenSource(source)}>{source}</S.SourceHeader>
<S.SourceContent>
<LogOutputWrapper>
<LogOutputPure
ref={logRef}
logs={logsBySource[source]}
isRunning={isRunning}
hideActions
wrap={wrap}
LineComponent={LineComponent}
/>
</LogOutputWrapper>
</S.SourceContent>
</S.SourceSection>
))}
</S.Container>
);
};

export default memo(LogOutputV2);
Loading

0 comments on commit c9ef9d7

Please sign in to comment.